# Ticket Dispatcher 设计

补充设计：

- [Dispatch Triage Action 设计](./2026-07-14-dispatch-triage-action-design.md)
- [Dispatch Assignee Notification 设计](./2026-08-12-dispatch-assignee-notification-design.md)

Assignee/L2 通知、ACK、page retry、Leader escalation、去重与恢复的语义见
Assignee Notification 补充设计。该功能由配置开关保护，默认关闭。

## 1. 状态与范围

本文描述 Ticket Dispatcher v1 的最终计算与执行语义。

v1 提供：

- 对实时 Jira Issue 数据进行 Condition 求值；
- 解析 `user`、`users`、`group`、`oncall_team` Receiver；
- 使用 `first` 或 `round_robin` 选择 Engineer；
- 执行 `assign`、`assign_l2`、`retrigger` Action；
- 保存 live / dry-run Execution 与 RuleExecution 审计；
- 提供无外部副作用的 Policy dry-run；
- 对同一 Issue 匹配到的多个 active Policy 做并发 fan-out。

v1 不提供 Retry、Shadow、子 Execution、ActionExecution 表、`set_fields`、`comment` 或 `noop`。

## 2. 核心原则

### 2.1 Issue 是实时数据源

Issue 相关运行数据保存在 `DispatchContext`。Issue 本身不复制到 Dispatch 表；每次运行都由 `services/ticket-manager/internal/services/ticket/issueloader.IssueLoader` 实时读取。

因此：

- Policy 使用本次读取到的 Issue 快照完成计算；
- Execution 保存 Issue key、Policy，以及创建时预建的 Rule/Action 快照与后续计算结果；
- dry-run 不接受 Issue 字段覆盖，也使用实时 Issue 数据。

### 2.2 Execution 是审计边界

一次 live Dispatch 可能匹配多个 active Policy。每个 Policy 创建一条独立 Execution，并分别记录其有序 RuleExecution。

最终 Decision 不单独持久化，可从成功 RuleExecution 的 ActionExecutions 派生。

### 2.3 逐 Action 计划并执行

Execution 创建时预建全部 ActionExecution 及其 Context。匹配 Rule 的 Action 按声明顺序处理；Behavior 先把对应 Action 从 `pending` 更新为 `executing`，再由 `Plan` 原地更新 Context，并把同一 Context 传给 `Execute`，最后持久化终态。

Action 规划或执行期间发生错误时停止当前 Execution；空候选属于 Plan error；已经完成的前序副作用不补偿。

## 3. 领域对象

### 3.1 DispatchContext

```go
type DispatchContext struct {
    IssueKey   string
    Assignee   string
    AssigneeL2 string
}
```

Assignee 与 AssigneeL2 保留现有存储和查询接口，但 AssignExecutor 不再更新这两个字段。

### 3.2 Policy 与 Rule

Policy 通过 Trigger 中的 Project 和 active status 参与 live Dispatch。Rule 按 `priority` 升序求值；priority 相同时按 Rule ID 排序。

Rule 通过 `transition.on_success` 控制成功后的流程转移，支持 `continue` 与 `terminate`：

- Condition 不匹配：Outcome 记录 `not_matched`，继续下一 Rule；
- Condition 匹配：Outcome 记录 `matched`；assign 无候选由状态为 `failed` 的 ActionExecution 和 Execution failure 共同记录；
- Rule 全部 Action 逐个计划并执行成功后，`continue` 继续求值后续 Rule，`terminate` 立即成功结束当前 Execution；
- integration、数据库或 Action 错误：由 ActionExecution 与 Execution failure 记录，当前 Execution 失败；When 求值错误才记录 `system_error`；
- 所有 Rule 均处理完成：Execution 成功。

## 4. Condition

### 4.1 表达式

Condition 支持递归组合：

```yaml
all:
  - field: project_key
    operator: eq
    value: TCID
  - any:
      - field: component
        operator: in
        values: [tidb, tikv]
      - not:
          field: environment
          operator: eq
          value: on_premise
```

也支持 `always: true`。

### 4.2 字段白名单

Condition 只能读取 IssueLoader 当前稳定提供的字段：

| 字段 | 来源与语义 |
| --- | --- |
| `project_key` | `Issue.Project.Key` |
| `environment` | `Issue.CustomerInfo.Environment` |
| `reporter_email` | `Issue.Reporter.Email` |
| `created_at` | Issue 创建时间 |
| `components` | 全部 `Issue.Components` |
| `component` | 全部 `Issue.Components`，兼容单数 DSL 名称 |
| `primary_component` | 第一个 Component |
| `summary` | `Issue.Summary` |

### 4.3 Operator

支持：

- 单值：`eq`、`ne`、`regex`、`contains`；
- 多值：`in`、`not_in`；
- 无值：`exists`、`not_exists`；
- 时间：`within_weekly_time_window`、`overlaps_weekly_time_window`。

集合字段的正向操作只要任一元素命中即成功；`ne`、`not_in` 要求所有元素均不命中。字段缺失时仅 `not_exists` 成功，其他 Operator 均不匹配。

`within_weekly_time_window` 使用 IANA 时区和显式 weekday。开始时间包含，结束时间不包含。Policy 发布校验要求 `start < end`，v1 不用单个时间段表达跨午夜窗口。

`overlaps_weekly_time_window` 判断从规则求值时间到字段时间形成的闭区间，是否与任意一个周时间窗口实例相交。字段时间早于求值时间时不匹配；字段不存在时不匹配；unlimited SLA 始终匹配。

## 5. Receiver Resolution 与 Assignment Strategy

### 5.1 Receiver

| Type | 实时来源 |
| --- | --- |
| `user` | 按一个 email 查询本地 Engineer |
| `users` | 按 email 列表查询本地 Engineer |
| `group` | AssignExecutor 通过 Core group 获取用户，再按 email 查询本地 Engineer |
| `oncall_team` | 通过 Oncall 获取当前 responder，再查询本地 Engineer |

`group` 与 `oncall_team` 必须且只能配置静态 `name` 或动态 `mapping`。Mapping 使用如下结构：

```yaml
mapping:
  field: issue.custom_fields.oncall_tier
  name_to_values:
    gs-l1: [L1, Gold]
    gs-l2: [L2]
  default: gs-default
```

Mapping 只允许读取字符串标量 Issue Field，`name_to_values` 的 key 为 Group Name 或 OnCall Team Key，value 为映射到该 Receiver 的 Field Value 数组。匹配区分大小写，不对 Field Value 或配置值做 Trim；同一个 Field Value 只能属于一个 Receiver。Field 缺失、值为空或不存在于 `name_to_values` 时均使用必填的 `default`。Execution 创建时基于本次 Issue 快照完成一次解析，并将最终 Group Name 或 OnCall Team Key 写入 `ActionExecution.Context.Assign.ReceiverName`；后续 Plan 与重试使用该固化结果。

所有 Receiver 最终统一过滤 `DispatchEnabled=true`。配置中不存在、未同步或 disabled 的 Engineer 不进入候选集合。

空候选和 Core、Oncall、数据库错误都作为 Plan error，由 ActionExecution 与 Execution failure 记录并立即终止当前 Execution。

### 5.2 Strategy

`first` 按 email 升序选择第一个候选。

`round_robin` 的排序为：

1. 从未分配过，即 `LastDispatchedAt == nil`；
2. 最早的 `LastDispatchedAt`；
3. email 升序打破平局。

解析后的 Receiver Name、候选、最终 assignee 和选择原因写入 `ActionExecution.Context.Assign`。Assign 与 Assign L2 共用该执行证据结构。选择成功本身不会更新 Engineer；只有 live assign 执行成功后才更新 dispatch state。

## 6. Action

### 6.1 ActionExecutor

所有 Action 使用统一抽象：

```go
type ActionExecutor interface {
    Type() ActionType
    BuildContext(issue *JiraIssue, dispatchContext *DispatchContext, action Action) (*ActionExecutionContext, error)
    Plan(ctx context.Context, execution *Execution, action Action, actionContext *ActionExecutionContext) error
    Execute(ctx context.Context, execution *Execution, action Action, actionContext *ActionExecutionContext) error
}
```

Execution 创建时调用 `BuildContext` 预建 ActionExecutionContext。`Plan` 直接更新传入 Context，`Execute` 消费同一个 Context 指针。Behavior 管理 Action 状态和持久化，并在 dry-run 中跳过 `Execute`。

### 6.2 assign / assign_l2

两种 Action 分别使用 `assign` 和 `assign_l2` 配置，复用同一个 AssignExecutor 流程。BuildContext 初始化 Assign Context。Plan 解析 Receiver、过滤 Engineer、应用 Strategy，并把候选邮箱、assignee 和 reason 写入传入 Context；无候选时保留已写入数据并返回 error。

live Execute：

1. Assign 使用选中的 assignee 更新 Jira Issue 的标准 Assignee；Assign L2 先按邮箱解析 Jira 用户的 `accountId`，再更新 `customfield_10053`；
2. Jira 更新成功后更新 Engineer 的 `LastDispatchedAt` 和 `LastDispatchedIssue`；
3. 最后清理 Jira Issue 上的自动分配 Label，清理失败仅记录日志。

Jira 更新失败时不更新 Engineer；Engineer 更新失败时 Execution 失败，但不回滚已完成的 Jira 分配。

dry-run 不调用 Execute，Plan 完成后 ActionExecution 标记为 `succeeded`。

## 7. Execution 状态机

```text
created -> executing -> succeeded
              |
              +-> failed
```

- `created`：Execution 已创建；
- `executing`：只求值 `not_evaluated` Rule，并按顺序推进预建的 pending/executing Action；
- `succeeded` / `failed`：终态。

每次状态或审计更新都是短数据库操作。Receiver、Jira、Core、Oncall 等外部调用期间不持有数据库事务。

## 8. 审计模型

### 8.1 Execution

`dispatch_executions` 保存：

- Issue key；
- `live` / `dry_run` mode；
- nullable Policy ID；
- status、failure code、failure reason；
- created、updated、completed 时间；

live 和 dry-run Execution 都写入此表并通过异步 EventJob 执行。

### 8.2 RuleExecution

`dispatch_rule_executions` 以 `(execution_id, sequence)` 保序并唯一，保存：

- Rule ID，以及嵌套 RuleEvaluation 中的 When outcome 和 When reason；
- JSON 数组形式的有序 ActionExecutions。

Execution 创建时按 priority、Rule ID 排序并聚合保存全部 RuleExecution 和 ActionExecution。Rule 初始 Outcome 为 `not_evaluated`；ActionExecution 包含 action index、`pending` / `executing` / `succeeded` / `failed` / `skipped` 状态和嵌套 ActionExecutionContext。Behavior 在 Plan 前保存 `executing`，Plan/Execute 后更新同一数组元素；终止路径把后续 pending Action 收敛为 `skipped`。进程重启后，状态为 `executing` 的 Action 会在原位置重新执行 `Plan -> Execute`。v1 不创建单独的 ActionExecution 表，也不兼容缺少预建骨架的旧未完成数据。

## 9. Live Dispatch

live 流程：

1. 校验 Issue key；
2. 实时加载 Issue；
3. 读取当前 DispatchContext；缺失时构造空的默认 Context，并查询匹配 Project 的全部 active Policy；
4. 在进程级 Execution 写锁内查询同一 Issue + Policy 的运行中 live Execution；
5. 在一个短事务内跳过忙碌 Policy，为空闲 Policy 预建并聚合创建 Execution；至少创建一条且 Context 缺失时持久化空的默认 Context；
6. 事务提交后按 Policy 顺序把新建 Execution ID 写入内存 channel；
7. 返回新建 Execution ID；全部 Policy 忙碌或没有匹配 Policy 时返回空数组。

Dispatch Service 注册 `RunDispatchExecution` EventJob。每个 worker 根据 ID 重新加载 Execution、实时 Issue 和 Policy，再推进 Behavior 到终态。EventJob 允许不同 ID 最多 10 路并发，一条 Execution 失败不会取消其他 Execution。多个 Policy 同时修改同一 DispatchContext assignee 字段时不做跨 Policy 协调，最终值由最后完成的写入决定。

Issue 加载失败直接返回错误；没有匹配 Policy 时返回空数组，二者都不写 Execution 或 Context。

## 10. 互斥与恢复

同一进程内，Execution 写锁保证 live 检查与创建原子化。同一 `(issue_key, policy_id)` 最多一个 `created` 或 `executing` live Execution；自动匹配 Dispatch 跳过忙碌 Policy，指定 Policy 的 live Dispatch 冲突时将 DispatchTrigger 标记为 failed。dry-run 不参与互斥。

EventJob 使用 Execution ID 作为进程内 Dedupe key，相同 ID 正在处理时忽略重复事件，不影响不同 ID 并发运行。服务启动时按创建时间顺序把全部 `created` / `executing` Execution（包括 dry-run）重新写入 channel；恢复查询失败会阻止服务启动。

channel、互斥锁和 Dedupe 都只在单副本进程内生效；v1 不提供跨副本 claim 或周期重试。

## 11. Dry-run

指定 PolicyID 的 Dispatch 可通过设置 `dryrun` 参数对指定 Policy 运行完整计算：

- 实时读取 Issue；
- 使用当前 DispatchContext；缺失时使用空的默认 Context；
- 执行相同 Condition、Receiver 和 Strategy 逻辑；
- 可通过 `dryrun.overwrite_data.now` 冻结业务运行时钟；该值随 Execution 持久化，恢复执行时继续用于 Condition、Webhook 模板、Oncall 查询、Retrigger 计划和 Triage 生成时间；
- 异步持久化 Execution、RuleExecution、RuleEvaluation 和 ActionExecutions；
- 不写 DispatchContext；
- 不修改 Jira、Ticket、Engineer 或其他外部系统。

业务运行时钟覆盖不影响 DispatchTrigger/Execution 的创建、更新和完成时间，也不用于 live Action 的外部审计写入。

Policy 无效或 Issue 加载失败时不创建 Execution，并将错误记录到 DispatchTrigger。

## 12. HTTP API

基础路径为 `/api/v1/ticketmanager`。

| Method | Path | 说明 |
| --- | --- | --- |
| `POST` | `/dispatch/triggers` | 创建 DispatchTrigger；可通过 `policy_id` 指定 Policy，通过 `dryrun` 开启 dry-run |
| `GET` | `/dispatch/triggers/:id` | 获取 Trigger 状态及 `output.execution_ids` |
| `GET` | `/dispatch/executions/:id` | 获取一条 Execution 及其 RuleExecutions |
| `GET` | `/dispatch/executions?issue_key=...` | 分页查询 Issue 的 Executions |

POST 接口在 Trigger 持久化并入队后立即返回 Trigger。调用方查询 Trigger，completed 后从 `output.execution_ids` 获取 Execution ID；Trigger 处理失败记录在 `failure_reason`，Execution 后续业务失败记录在对应 Execution 中。

## 13. 错误语义

| 场景 | 结果 |
| --- | --- |
| Condition 不匹配 | Outcome `not_matched`，继续下一 Rule |
| Receiver 无候选 | failed / `ACTION_PLAN_FAILED`，ActionExecution Context 保留失败原因 |
| Rule 成功且 transition 为 `continue` | 继续下一 Rule |
| Rule 成功且 transition 为 `terminate` | succeeded |
| 所有 Rule 已处理 | succeeded |
| Condition 错误 | failed / `CONDITION_EVALUATION_FAILED` |
| Action Plan 错误 | failed / `ACTION_PLAN_FAILED` |
| Action Execute 错误 | failed / `ACTION_EXECUTION_FAILED` |
| Issue 加载失败 | API error，不创建 Execution |
| 无 active Policy | 返回空 `execution_ids` |

## 14. 测试要求

- 表驱动并行测试所有 Condition 字段、Operator、嵌套表达式、集合、缺失字段和跨时区 weekly time window；
- 覆盖四类 Receiver、disabled/missing Engineer、空候选和 integration error；
- 覆盖 first 与 round-robin 的 nil、时间和平局排序；
- 验证 Action live 副作用与 dry-run 零外部写入；
- 覆盖 Rule fallthrough、全量预规划、system error 和状态转换；
- 使用并发屏障证明不同 Execution 并发，验证相同 ID 去重、失败隔离、live 互斥、dry-run 绕过互斥和启动恢复；
- 覆盖 Execution/RuleExecution JSON、排序和旧 Context 迁移；
- 覆盖 HTTP binding/response、Swagger 和 TypeScript SDK 生成。
