Tickets 的消息链已经从“只有公共元数据”演进为生成式业务载荷:聚合事件同时实现 IIntegrationEvent,源生成注册表提供 topic 与字段映射,事务管道把它们写入 CAP Outbox。文档需要把这条已实现链路与仍未闭合的 Id、Broker 绑定和 Webhook 边界分开说明。
1. 六类生命周期事件
| 行为 | 领域事件 | topic | 业务字段 |
|---|---|---|---|
| Open | TicketOpened | ticket.opened | TicketId、TenantId、RequesterId、Subject、PriorityName、OccurredAt |
| Assign | TicketAssigned | ticket.assigned | TicketId、TenantId、AssigneeId、AssignedBy、OccurredAt |
| Start | TicketStarted | ticket.started | TicketId、TenantId、StartedBy、OccurredAt |
| Resolve | TicketResolved | ticket.resolved | TicketId、TenantId、ResolvedBy、OccurredAt |
| Close | TicketClosed | ticket.closed | TicketId、TenantId、ClosedBy、OccurredAt |
| Reopen | TicketReopened | ticket.reopened | TicketId、TenantId、ReopenedBy、OccurredAt |
六个 record 都继承 DomainEvent、实现 IIntegrationEvent,并标注 [IntegrationTopic]。EventId 由 DomainEvent 提供;业务字段由事件的公开属性提供。Start 与 Reopen 已经不再是事件空白。
2. 事务发布链
DomainEventDispatchPipelineBehavior 位于 Transaction pipeline 的内层 post-phase。它只处理成功的 Command,并执行:
- 从
IDomainEventCollector读取事件; - 要求事件实现
IIntegrationEvent; - 通过
IIntegrationTopicRegistry.TryBuildPublication取得 topic 和生成式业务参数; - 加入保留字段
eventId、eventType; - 调用 CAP publisher,将参数字典作为消息对象写入 Outbox。
映射缺失、字段冲突或发布异常都会向外抛出。Transaction pipeline 随后回滚工作单元,并保留聚合事件供显式重试;只有提交成功才清除事件。这不是 post-commit best-effort。
{ "eventId": "019c48cb7ba47000953d5af574ddcb81", "eventType": "TicketOpened", "ticketId": "851987654321", "tenantId": "tenant-a", "requesterId": "user-100", "subject": "无法登录", "priorityName": "High", "occurredAt": "2026-08-14T08:00:00.0000000+00:00"}架构测试已经固定 TicketOpened 和 TicketStarted 的生成式字段映射。CAP publisher 实际调用 PublishAsync(topic, (object)parameters),所以 wire body 是参数字典的序列化结果,而不是生产者显式 new 出 TicketOpenedIntegrationEvent。
3. opened Id 回填缺陷
Ticket.Open 先以 Id="0" 构造聚合,随后立即执行:
// 此时 ticket.Id 仍是持久化占位值 "0"。ticket.Raise(new TicketOpened( ticket.Id, ticket.TenantId, ticket.RequesterId, ticket.Subject, ticket.Priority.Name, // 事件是 immutable record;后续 ID 回填不会改写已捕获字段。 nowUtc));仓储保存后会为聚合回填最终雪花 Id,但已经构造的 immutable event 不会随之变化。框架提供 RaiseWhenPersistenceIdAssigned,Ticket 当前没有使用它。因此 opened 消息可能携带 ticketId: "0",而随后 assigned/started/… 事件使用最终 Id,Reporting 会把同一工单拆成不同投影键。
修复方案应复用框架既有的延迟 Raise 机制,或在调用 Ticket.Open 前生成最终业务 Id。修复验收必须捕获真实 Outbox 消息并断言其 TicketId 等于 API 返回值。
4. Reporting typed consumer
Reporting 订阅全部六个 topic,并分别接收 Ticket*IntegrationEvent:
- opened 创建或覆盖基础 Mart 行;
- assigned 设置 AssigneeId 和 Assigned 状态;
- started、resolved、closed、reopened 更新状态;
- reopened 清空
ClosedAt; - EventId 与当前
LastEventId相同则跳过; - 非 opened 事件早于
LastUpdatedAt时按陈旧事件跳过; - store Failure、缺失前置 opened 或其他可重试异常会抛出,让 CAP 重试。
现有集成测试直接构造六个 consumer DTO 并验证完整序列、重复 reopened 和陈旧 closed。它证明 consumer 状态机,但没有证明 CAP 把生产者的参数字典按实际 serializer 绑定到这些 DTO。两侧字段名目前相容,这是源码推断,不是字节级契约证据。
LastEventId 只识别“当前最后事件的直接重复”。两个不同 EventId 若 OccurredAt 相等,后到事件仍可覆盖前者;opened 也没有陈旧时间保护。可靠的单调投影仍需要 Ticket Version/sequence 或明确定义的相同时刻排序键。
5. Webhook 尚未桥接
Tickets 发布 ticket.opened。WebhookPlatformEventConsumer 订阅的值是 tickets.opened,并要求 PlatformWebhookEventEnvelope(EventId, TenantId, PayloadJson, OccurredAt)。当前可见源码中没有 topic 转换器或 envelope 映射器。
修复时应明确三个层次:内部 Tickets topic、平台 Webhook envelope、对外 event name。不要只把字符串改成相似值;还要转换 payload、保留 TenantId/EventId/OccurredAt,并用真实序列化器做契约测试。
6. 通知失败语义
Assign、状态变更和 AddComment 在业务变更后同步调用 NotificationService.CreateAsync。当前辅助方法会检查每次 Result:
- 分派主体可以是直接用户或用户组;用户组会展开为当前租户内的有效成员;
- 状态和评论通知合并 requester 与 assignee 成员,并排除当前 actor;
- HashSet 去除同一用户的重复通知;
- 任一通知创建返回 Failure,Ticket Command 返回相同错误;
- 外层 Transaction pipeline 对
Result.Failure执行回滚,因此本次 Ticket 保存、通知事实和尚未提交的 Outbox 不会提交; - 通知 title/body 仍是 Handler 中的中文硬编码文本,没有经过模板与本地化目录。
这条语义保证的是“通知意图与业务命令共同成功或回滚”,不是邮件、短信或实时渠道已经送达。渠道投递仍由 Notifications 自己的 outbox/consumer/attempt 语义负责。
批量分派当前直接保存和发布搜索投影,没有复用单工单分派的参与人通知、领域事件收集或 Ticket 审计流程。批量行为与单项行为并不完全等价,必须作为独立产品契约验收。
7. 生产审计适配器
Application 默认有 NullTicketAuditSink,便于不加载审计基础设施的测试和开发组合。API 的生产持久化注册会执行:
services.RemoveAll<ITicketAuditSink>();services.AddSingleton<ITicketAuditSink, PersistentTicketAuditSink>();PersistentTicketAuditSink 将 TicketAuditRecord 转为 EntityChangeRecord,写入 IEntityChangeAuditSink。生产就绪守卫把 NullTicketAuditSink 列为禁止适配器,因此生产环境解析到 Null 会导致 readiness 失败。
当前记录包含 TicketId、TenantId、Actor、Action、Before、After 和 OccurredAt。适配器把 CorrelationId 固定为 null;部分命令的 Before/After 只记录状态或 Id,详情更新也可能没有字段级差异。因此“已持久化”不等于“具备完整取证上下文”。
审计端口返回 Task 而不是 Result;异常会向外传播并触发命令事务回滚。生产策略实际是 fail-closed,不是静默降级。
8. 搜索投影是另一条集成链
Open、详情更新、分派和状态变化等写路径会发布 SearchIndexChangedIntegrationEvent,由 Search owner 建立工单索引。它与六个 Reporting topic 是不同契约:搜索事件表达索引文档的 upsert/remove,Reporting 事件表达生命周期事实。
排障时应分别观察 Search projection lag 与 Reporting Mart lag。一个正常不代表另一个正常,也不能用搜索重建替代审计或 Reporting 重放。
9. 事务语义总表
| 失败点 | 当前结果 | 业务数据 |
|---|---|---|
| Repository 返回 Failure | Command Failure | 回滚 |
| 通知创建返回 Failure | Command Failure | 回滚 |
| 审计抛异常 | 异常 | 回滚 |
| generated topic 映射缺失 | 配置异常 | 回滚 |
| CAP Outbox 发布抛异常 | 异常 | 回滚 |
| UnitOfWork commit 失败 | 异常/未知提交语义 | 按事务框架处理,事件不清除 |
| 下游 Reporting 消费失败 | CAP 重试 | Ticket 事务已经提交 |
10. 必须保留的契约测试
- 六个 domain event 的 topic 与公开字段映射;
- Open 的最终 TicketId,不得为
"0"; - 实际 CAP serializer 产物可绑定六个 Reporting DTO;
- consumer store Failure/异常进入 retry,重复 EventId 不重复写;
- 相同时刻和倒序事件的确定性规则;
- Webhook topic 与 envelope 的显式映射;
- 通知 Failure 与审计异常均回滚 Ticket;
- 生产 Profile 不能解析到
NullTicketAuditSink; - 批量与单项命令在事件、通知、审计上的预期差异。
11. 可观测性
至少记录 outbox enqueue/publish、consumer attempt、Reporting/Search projection lag、notification intent/attempt 和 audit append。标签使用 module/topic/result/error_code;TenantId/UserId 做受控 hash。Subject、Description、评论正文、通知正文与 StorageKey 不应进入日志。
告警项:oldest outbox age、typed binding/deserialization failure、Reporting/Search lag、生产解析到 Null audit、notification intent failure ratio,以及 opened ticketId="0"。
12. 审查命令
# 六个事件、生成式业务载荷和事务派发。rg -n "IntegrationTopic|TryBuildPublication|BuildParameters|RaiseWhenPersistenceIdAssigned" \ src/Platform/Tickets src/Framework tests -g '*.cs'
# Reporting 与 Webhook 的订阅 topic/envelope。rg -n "CapSubscribe|Ticket.*IntegrationEvent|tickets\.opened|PlatformWebhookEventEnvelope" \ src/Platform/Reporting src/Platform/Webhooks -g '*.cs'
# 开发空落点、生产替换与 readiness 禁止清单。rg -n "ITicketAuditSink|NullTicketAuditSink|PersistentTicketAuditSink" src/Platform/Tickets src/Hosts tests -g '*.cs'