工单状态由 TicketTransitionRules 与聚合行为共同维护。Application 的 Start/Resolve/Close/Reopen 共用 TicketStatusTransitionFlow,Assign 使用独立 TicketAssignmentFlow。共享模板统一了加载、授权、保存、通知和审计顺序,但没有自动补足业务规则。
1. 六状态不是任意跳转
| 当前状态 | 允许目标 | 公开命令 | 拒绝示例 |
|---|---|---|---|
| New | Assigned | Assign | New→Start、Resolve、Close |
| Assigned | InProgress | Start | Assigned→Close |
| InProgress | Resolved | Resolve | InProgress→Assigned |
| Resolved | Closed、Reopened | Close、Reopen | Resolved→Assign |
| Closed | Reopened | Reopen | Closed→Start、Assign |
| Reopened | InProgress、Assigned | Start、Assign | Reopened→Resolved |
TransitionTo 对“当前状态等于目标状态”直接返回成功。状态 Handler 比较变更前后状态;未变化时不保存、不 Track 聚合,也不重复通知和审计。需要注意,聚合方法本身在 TransitionTo 成功后仍会构造事件;因此这里保证的是公开 Handler 的无副作用重放,不应把聚合方法单独当成完整的幂等消息边界。
2. 分派的精确语义
第一次 Assign 同时完成两件事:New→Assigned,以及写入 TicketAssignment(AssigneeId, AssignedBy, AssignedAt)。后续在 Assigned、InProgress 或 Reopened 状态可重新分派;Resolved/Closed 返回 Ticket.AssignmentClosed。
同一 assignee 且状态不是 New 时直接成功,不更新 AssignedBy/AssignedAt,也不发通知或审计。不同 assignee 会写入新分派快照并 Raise TicketAssigned。
var ticket = Ticket.Open( "tenant-a", "requester-1", "无法登录", "MFA 后失败", TicketPriority.High, dueAt: now.AddHours(4), now).Value!;
// 第一次分派还负责 New -> Assigned,并记录分派人和 UTC 时间。ticket.Assign("agent-1", "lead-1", now).IsSuccess.ShouldBeTrue();ticket.Status.ShouldBe(TicketStatus.Assigned);
// 相同处理人是幂等成功;不会刷新 AssignedAt 或再 Raise TicketAssigned。var assignedAt = ticket.Assignment!.AssignedAt;ticket.Assign("agent-1", "lead-2", now.AddMinutes(5)).IsSuccess.ShouldBeTrue();ticket.Assignment.AssignedAt.ShouldBe(assignedAt);
// 解决后分派失败,必须先 Reopen 再选择 Start 或 Assign。ticket.Start(now.AddMinutes(10));ticket.Resolve(now.AddMinutes(20));ticket.Assign("agent-2", "lead-1", now.AddMinutes(30)) .Error.Code.ShouldBe(TicketErrorCodes.AssignmentClosed);3. 分派主体校验
AssigneeId 表示直接用户键或带命名空间的用户组主体键。TicketAssignmentFlow 在修改聚合前调用 TicketAssignmentSubjectResolver.EnsureActiveAsync,后者通过 Authorization owner 的 IAuthorizationSubjectReader 验证:
- 主体键非空且格式有效;
- 用户或用户组存在于当前租户;
- 主体未被停用或删除;
- 跨租户主体不会被接受。
校验失败统一返回 Ticket.AssigneeUnavailable。通知会把用户组展开为当前租户内的有效用户;个人待办也会同时查询当前用户键和有效用户组键。
var active = await assignmentSubjects.EnsureActiveAsync( command.AssigneeId, cancellationToken);// 统一错误隐藏“缺失、停用、删除或跨租户”的目录细节。if (active.IsFailure) return Result.Failure<TicketSummary>(active.Error);
// 聚合仍负责状态迁移、分派快照和事件。var assigned = ticket.Assign( command.AssigneeId, callerUserId, // 业务时间由 IAppClock 提供并在聚合内归一化为 UTC。 clock.UtcNow);当前校验不判断 support 角色、技能、容量、排班或休假,也没有在主体失效后自动解除/转派。若产品需要这些规则,应在现有主体读取边界之上增加可受理策略,不能把“当前租户内有效”解释为“适合接单”。
4. Resolve、Close 与时间字段
Resolve 成功后写 ResolvedAt 和可选 Resolution,并 Raise TicketResolved;Close 写 ClosedAt 并 Raise TicketClosed;Reopen 清空 ResolvedAt、ClosedAt 和 Resolution,并 Raise TicketReopened;Start Raise TicketStarted。加上 Open、Assign,当前六个可见生命周期行为都有独立 topic。
Reporting 已订阅六类 typed integration event,可以重建 New、Assigned、InProgress、Resolved、Closed 和 Reopened。事件仍没有 Ticket Version/sequence;相同 OccurredAt 的不同事件按到达顺序覆盖,因此完整的倒序保护尚未闭合。
Close 只能从 Resolved 进入,因此 ClosedAt 正常晚于 ResolvedAt。Restore 对这些时间组合没有做状态一致性校验:例如 New 也可以带 ResolvedAt。数据库损坏或旧迁移错误不会在 EnsurePersistedState 被拒绝。
5. DueAt 不是 SLA 引擎
当前 SLA 能力只有一个可空的 UTC DueAt 列:
- Open 接受任意时间,包括过去;
UpdateTicket可更新 Priority/DueAt,批量优先级命令也有 HTTP 入口;- 没有首次响应、解决、暂停或等待客户计时;
- 没有工作日历、时区、节假日、优先级策略;
- 没有到期扫描、升级、提醒、自动分派或 breach 状态;
- 列表摘要返回 DueAt,但当前列表筛选没有 DueAt 范围;
- 没有 SLA 指标或告警。
所以 UI 可以把 DueAt 展示为“目标时间”,但不能把它宣称为可审计 SLA。真正 SLA 至少需要策略快照,避免策略修改后历史工单被重新解释。
6. 并发版本当前被绕过
Ticket 继承 AggregateRoot.Version,ORM 元数据也把 Version 标为并发字段。然而 TicketRepository.SaveAsync 的更新分支使用 UpdateWhereAsync,谓词只有 TenantId+Id,更新集合既不比较旧 Version,也不递增 Version。
结果是两个并发请求都可能从同一快照开始,后保存者覆盖前一个状态、评论或附件 JSON。命令没有 ExpectedVersion,TicketSummary 也不返回 Version,客户端无法执行 If-Match。
public static class TicketErrors{ public static readonly Error VersionConflict = Error.Conflict("Ticket.VersionConflict", "工单已被其他操作更新。");}
// 客户端从详情/ETag 携带读取到的版本,服务端拒绝陈旧写入。var affected = await tickets.UpdateWhereAsync( row => row.TenantId == ticket.TenantId && row.Id == ticket.Id && row.Version == expectedVersion, update => update .Set(row => row.StatusName, _ => ticket.StatusName) .Set(row => row.CommentsJson, _ => ticket.CommentsJson) .Set(row => row.Version, row => row.Version + 1), cancellationToken);
// 0 行不是 NotFound;应返回明确的 Ticket.VersionConflict。if (affected == 0) return TicketErrors.VersionConflict;现有 IEntitySet.UpdateWhereAsync 不返回影响行数,所以上述目标还要求收窄仓储端口,不能只在 Handler 增加一个字段。
7. 业务幂等范围
状态目标相同和相同 assignee 是聚合幂等;CommentId、AttachmentId/FileId 是集合幂等。OpenTicket 没有业务幂等键,同一个请求重试会创建两张工单。HTTP 也没有 Idempotency-Key 契约。
对于移动网络或三方接入,Open 应接收稳定 OperationId,并在 (TenantId, OperationId) 唯一约束下返回原工单。不能用 Subject+Description 去重,因为相同问题可以合法重复发生。
8. 推荐的生命周期契约
每次修改应返回:TicketId、Status、AssigneeId、DueAt、Version、ModifyTime。状态命令应接受 ExpectedVersion;冲突返回 409 和当前版本链接。分派还应返回 Assignee display snapshot 或通过独立用户查询解析,避免客户端把裸 Id 当可展示名称。
六个生命周期事件已经存在。下一步应补 Ticket Version/sequence 与 TraceId;消费者以 TicketId+Version 拒绝倒序覆盖,而不是只比较 EventId 和 OccurredAt。
9. 必测状态矩阵
除当前单元测试外,还要固定:
- 所有 36 个 from/to 组合,而不是只抽样;
- 每个状态的重复命令是否真正无副作用;
- Reopen 清理时间/Resolution 字段并发布事件;
- 目标 assignee 跨租户、不存在、禁用、删除、用户组展开,以及未覆盖的技能规则;
- 两个并发评论、状态+评论、分派+关闭的冲突;
- DueAt 过去值、时区归一、策略快照与升级幂等;
- Open 相同 OperationId 的并发重放;
- Restore 的状态/ResolvedAt/ClosedAt 组合失败关闭。
10. 变更审查命令
# 列出唯一迁移事实;新增状态必须同步规则、事件、序列化与测试。rg -n "AllowedTransitions|TransitionTo|TicketStatus" src/Platform/Tickets -g '*.cs'
# 检查 SLA 是否仍只有字段而没有策略、任务和指标。rg -n "DueAt|Sla|SLA|Escalat|Breach" src/Platform/Tickets src/Jobs -g '*.cs'
# 预期修复后 Save 谓词包含 Version,并且影响行数能转成 Conflict。rg -n "UpdateWhereAsync|Version|VersionConflict" src/Platform/Tickets -g '*.cs'