Skip to content
bitzorcas
中EN

Guide

Tickets 生命周期、分派与 SLA

深入讲解 Ticket 六状态迁移、用户与用户组分派校验、六类状态事件、时间字段、并发更新和当前只有 DueAt 字段的 SLA 边界。

Last updated

工单状态由 TicketTransitionRules 与聚合行为共同维护。Application 的 Start/Resolve/Close/Reopen 共用 TicketStatusTransitionFlow,Assign 使用独立 TicketAssignmentFlow。共享模板统一了加载、授权、保存、通知和审计顺序,但没有自动补足业务规则。

1. 六状态不是任意跳转

OpenAssignStartResolveCloseReopenReopenStartAssign

New

Assigned

InProgress

Resolved

Closed

Reopened

当前状态允许目标公开命令拒绝示例
NewAssignedAssignNew→Start、Resolve、Close
AssignedInProgressStartAssigned→Close
InProgressResolvedResolveInProgress→Assigned
ResolvedClosed、ReopenedClose、ReopenResolved→Assign
ClosedReopenedReopenClosed→Start、Assign
ReopenedInProgress、AssignedStart、AssignReopened→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 至少需要策略快照,避免策略修改后历史工单被重新解释。

SLA Policy
priority + calendar + targets

Ticket opened

SLA snapshot
response/resolution deadlines

business clock
pause/resume

deadline scanner

escalation attempt
idempotent

breach + latency metrics

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. 必测状态矩阵

除当前单元测试外,还要固定:

  1. 所有 36 个 from/to 组合,而不是只抽样;
  2. 每个状态的重复命令是否真正无副作用;
  3. Reopen 清理时间/Resolution 字段并发布事件;
  4. 目标 assignee 跨租户、不存在、禁用、删除、用户组展开,以及未覆盖的技能规则;
  5. 两个并发评论、状态+评论、分派+关闭的冲突;
  6. DueAt 过去值、时区归一、策略快照与升级幂等;
  7. Open 相同 OperationId 的并发重放;
  8. Restore 的状态/ResolvedAt/ClosedAt 组合失败关闭。

10. 变更审查命令

Terminal window
# 列出唯一迁移事实;新增状态必须同步规则、事件、序列化与测试。
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'

返回 Tickets 总览 · 授权与查询 · 测试与 GA 门禁

100%

滚轮或按钮缩放 · 放大后拖动画面 · 双击切换 100% / 200%