Reporting 的验收标准不是“接口返回 200”,而是结果能否解释、追溯和恢复。当前实现已经形成查询、授权、导出和六事件投影骨架,但生产可信度仍受消息标识、事件顺序、审计数据来源及恢复能力限制。
1. 当前证据边界
现有测试已经证明:
- 两个查询使用
AuthorizationAction.Read,把可信 TenantId 传给IReportingMartStore,并返回完整PagedResult; - 非 Tenant
DataScope会 fail closed,PageSize 1001 和超过 366 天的窗口会在 Store 前被拒绝; reporting模块由运行时 Feature evaluator 映射到platform.reporting;- 导出只接受 csv/excel、使用服务端固定 Builder、要求 Tenant DataScope,并在批次间重新检查工单可见性;
TicketReportingEventConsumerTests直接构造六类消费 DTO,验证 Opened→Assigned→Started→Resolved→Closed→Reopened、重复 Reopened 和较旧 Closed;- 架构测试约束 Reporting 的 ORM 中立、Query Shape 调用和元数据生成;API Shell 缺少持久化能力时会 fail closed。
现有证据不能证明:
- Ticket 聚合通过真实事务管道写入 Outbox 后,CAP 字节可以绑定到六个 Reporting DTO;
- Opened 事件包含持久化后的 TicketId;当前
Ticket.Open以"0"建聚合并立即Raise(new TicketOpened(ticket.Id,...)),没有使用RaiseWhenPersistenceIdAssigned; - A→B→A、同时间不同事件、并发 Upsert 和非相邻重复都不会回退投影;
- SqlSugar 与 EF Core 对列宽、排序、唯一冲突和计数/页面的一致性相同;
- 生产代码会写入审计日报 Mart;
- 端到端 HTTP 的 401/403/Feature/DataScope/导出矩阵已经执行;
- Mart 有 watermark、对账、重建、切换和回滚路径。
2. 分层验收模型
直接构造 DTO 的测试适合验证消费状态机,但不能替代真实 Outbox payload、CAP 绑定、关系数据库约束和恢复演练。
3. 消息字节与最终标识
六个 Ticket 领域事件都实现 IIntegrationEvent 并声明 [IntegrationTopic]。生成器把公开业务字段映射到 payload;DomainEventDispatchPipelineBehavior 再加入 eventId 与 eventType,并在事务提交前写入 CAP Outbox。发布失败会向外传播。
当前最直接的缺陷位于 Opened 事件生成时机:
var ticket = new Ticket("0", tenantId, requesterId, subject, priority, nowUtc);
// 此时仓储尚未回填最终持久化 ID,事件中的 TicketId 因而是 "0"。ticket.Raise(new TicketOpened(ticket.Id, ticket.TenantId, requesterId, subject, priority.Name, nowUtc));后续 Assigned/Started/Resolved/Closed/Reopened 使用已经持久化的真实 TicketId。Reporting 因此可能先建立 (TenantId, "0") 行,随后因找不到真实 TicketId 的前序行而抛错重试。修复应使用实体基类已经提供的 RaiseWhenPersistenceIdAssigned,并由集成测试验证 Outbox 中 TicketId 等于仓储回填值。
[Fact]public async Task TicketOpened_OutboxPayload_ShouldCarryPersistedTicketId(){ // 走真实 Command、事务和持久化 ID 回填,不直接 new consumer DTO。 var opened = await OpenTicketThroughRealCommandAndTransactionAsync(); var message = await ReadOutboxMessageAsync("ticket.opened"); // 使用生产 CAP 绑定选项读取 Outbox body,覆盖 wire contract。 var bound = BindWithCapOptions<TicketOpenedIntegrationEvent>(message);
bound.TicketId.Should().Be(opened.TicketId); bound.TicketId.Should().NotBe("0"); bound.EventId.Should().NotBeNullOrWhiteSpace();}六个 topic 都要固定精确名称、字段名、nullability、时间格式、枚举编码、缺失字段行为及向后兼容策略。破坏性变更应使用新版本 topic 或显式 schema version。
4. 投影顺序与幂等测试
当前消费者的实际规则是:
- 只以 LastEventId 识别“与最后一次相同”的直接重复;
- 除 Opened 外,
OccurredAt < LastUpdatedAt视为 stale;时间相等仍允许覆盖; - 非 Opened 事件找不到前序行会抛异常,Store 失败也会重新抛出,CAP 可据此重试;
- Reopened 会把 StatusName 设为
Reopened并清空 ClosedAt; - Opened 不比较 LastUpdatedAt,且 Store Upsert 没有版本条件。
现有六事件生命周期测试保留;还应加入以下序列:
| 输入序列 | 当前风险 | 目标断言 |
|---|---|---|
| Opened(A), Closed(C), Opened(B) | Opened 无 stale gate,可回写 New | B 被判为 stale 或按版本拒绝 |
| Closed(C), Opened(A) | C 找不到前序行并重试;A 可能使用 ID 0 | 修复 ID 后重试 C,最终 Closed |
| Opened(A), Assigned(B), Opened(A) | LastEventId 已变,旧 A 可再次写入 | 非相邻重复不得回退 |
| Resolved(B), Closed(C),时间相等 | < 不拒绝同时间事件 | 以 AggregateVersion 决定顺序 |
| 两个 consumer 并发首次写同一键 | check-then-add 唯一冲突 | 明确 retry/read-back 结果 |
| Closed(C), Reopened(D), Closed(B) | 只靠时间可能丢失业务顺序 | B 为 Stale,ClosedAt 保持 null |
要给出稳定结论,消息需要 AggregateVersion,Mart 需要 LastAppliedVersion,写入需要原子 compare-and-set;Inbox 或唯一消费记录负责非相邻重复。OccurredAt 只能辅助诊断,不能替代业务版本。
5. 双 ORM Store 契约
同一套关系数据库行为测试应分别装配 SqlSugar 和 EF Core,至少覆盖:
- 同租户 insert/update/query,以及跨租户同 TicketId;
- Reopened 以 null 清空 ClosedAt;
- Ticket
(OpenedAt,TicketId)与 Audit(ActivityDate,UserId,ActionType)的稳定排序; - PageSize 1..1000、默认最大 offset 100000、366 天 inclusive 边界;
- 64 字符 Requester/Assignee 写入 Mart 36 字符列的明确失败行为;
- DateTimeOffset/DateTime 的精度、offset 与数据库 collation;
- 并发首次 insert 的唯一冲突恢复,以及旧版本 compare-and-set;
- EF 的 count+page 与 SqlSugar
ToPageListAsync在并发写入下的允许差异; - 导出默认批次 5000、上限 100000、最大 offset 10000000;
- 归档或擦除后重放旧事件不会恢复已删除敏感数据。
InMemory Store 不会暴露字段长度、排序、时间精度、唯一约束和事务隔离问题,不能作为这组测试的替代品。
6. HTTP 与授权验收
实际 Host 上应执行以下矩阵:
| 场景 | 预期结果 |
|---|---|
| 未认证访问 QUERY/POST fallback/导出 | 401 |
缺少 reporting.ticket-summary.read 或 reporting.activity.read | 403 |
Feature platform.reporting 禁用 | 403/fail closed |
| 权限允许但 DataScope 不是 Tenant | Reporting.DataScope.TenantRequired |
| Tenant A 调用 | Store 只收到 Tenant A,不接受请求体租户 |
| 非法分页、状态、日期、格式、幂等键 | 稳定 4xx 错误码,Store/Scheduler 未调用 |
| 导出后撤销工单可见性 | 后续批次重新授权并跳过不可见行 |
| QUERY 不可用的代理环境 | POST .../_query 与 QUERY 返回同一契约 |
| GET 调用查询路由 | 不存在该端点,不应由缓存层转换 |
查询权限已经统一到 Catalog 中的 .read,Feature evaluator 也已经映射 reporting -> platform.reporting。剩余重点是用生产授权管道和真实路由验证组合结果。另需记录 Feature 定义的双轨现状:中央运行时使用 platform.reporting,Reporting owner catalog 仍声明默认关闭的 reporting.mart;两者目前不是同一开关。
7. 新鲜度与可观测性
当前 DTO 没有 LastUpdatedAt/asOf,Mart 也没有 AggregateVersion 或消费 checkpoint。生产上线前至少补齐:
| 层次 | 指标/事件 | 诊断问题 |
|---|---|---|
| Producer | outbox pending、oldest age、publish failure | 源事实是否离开事务 |
| Broker | lag、retry、dead-letter | 消息是否积压 |
| Consumer | applied、duplicate、stale、missing predecessor、failed | 投影做了什么决定 |
| Mart | tenant/partition watermark、last version | 数据截至何时 |
| Query | latency、error、rows scanned、deep-page rejection | API 和索引是否健康 |
| Export | queued/running/failed、authorized rows、skipped rows | 异步导出是否完整 |
| Reconcile | source/Mart count、状态分布、sample drift | 投影是否仍等于事实 |
日志应包含 TenantId、topic、EventId、TicketId、AggregateVersion(补齐后)、结果和 trace/correlation id,不记录完整 Subject。查询响应或 companion endpoint 应提供 asOf/watermark,否则调用方无法区分“确实为 0”和“投影尚未追上”。
8. 重建与回滚
当前仓库没有 Reporting 事件归档、checkpoint、rebuild CLI 或 reconcile job。以下流程是 GA 验收目标,不是现有能力:
- 固定 schema/consumer 版本并记录 tenant、操作人、原因和起始 watermark;
- 创建约束和索引与生产一致的影子 Mart;
- 从可审计源按 tenant、aggregate、version 重放;
- 捕获重建期间的 live delta 并追赶至切换 watermark;
- 校验总数、状态分布、关键不变量、抽样快照和孤儿行;
- 原子切换读写目标,观察错误率、lag 和授权拒绝;
- 保留旧表一个批准的回滚窗口,再按保留策略清理。
9. 事故处置
CAP 持续重试 missing predecessor
先比较 Opened 与后续事件的 TicketId。若 Opened 为 "0"、后续为真实 ID,根因是聚合在持久化 ID 回填前抛出事件。不要仅手工补 Mart 行;修复事件生成时机后,从可信 Outbox/归档按顺序重放受影响 Ticket。
状态发生回退
比较全部事件的 EventId、OccurredAt 和源业务顺序。当前消费者没有 AggregateVersion,且时间相等允许覆盖;先停止受影响分区的错误写入,再补版本门控和 CAS,最后重建该 Ticket。直接改数据库会在重放时复发。
审计日报不更新
生产源码中还没有 UpsertAuditActivityDailyAsync 的调用方。重启 consumer 无法恢复不存在的生产链路;需要先实现聚合器、checkpoint、时区/日期归一与幂等回补,再建立 lag 告警。
大量 403
按顺序检查 platform.reporting、精确 .read/.export grant、授权缓存和最终 DataScope。reporting.mart 与 platform.reporting 是两个定义,排障时不要把 owner catalog 开关误当作运行时模块开关。
10. GA 门禁
| 门禁 | 当前状态 | 缺口 |
|---|---|---|
| 六事件 topic 与消费者 | 部分满足 | 已有处理器和直接 DTO 测试;缺真实 Outbox/CAP 字节测试 |
| 最终 TicketId | 不满足 | Opened 当前携带占位 ID 0 |
| 重试语义 | 部分满足 | 异常会重抛;缺 DLQ、退避和演练证据 |
| 幂等与顺序 | 不满足 | 只有 LastEventId/OccurredAt,无版本、Inbox、CAS |
| 查询输入与稳定分页 | 已实现 | 仍需双 ORM/HTTP 边界测试,错误描述还残留 1-100 |
| 权限、Feature、DataScope | 已实现主路径 | 缺真实 Host 端到端矩阵;Feature 名称有双轨 |
| 受治理导出 | 已实现主路径 | 缺真实数据库、并发撤权和大批次运行证据 |
| 审计日报 | 不满足 | 只有读模型和写端口,没有生产 writer |
| 新鲜度与对账 | 不满足 | 无 watermark、版本、reconciler |
| 重建与回滚 | 不满足 | 无归档、checkpoint、CLI/job 和演练 |
当前最优先的三项工作是:让 Opened 使用最终 TicketId;为六个 topic 建立真实 Outbox 字节契约测试;引入 AggregateVersion、Inbox/CAS 及可观测处理结果。完成后再推进审计日报生产者和重建链路。
11. 评审命令
# 查看当前查询、导出、消费者和 Feature 证据。rg -n "Reporting|TicketReportingEventConsumer|platform.reporting" tests -g '*.cs'
# 定位占位 ID、最终 ID 延迟事件以及消费者的重试/顺序规则。rg -n 'new Ticket\(|RaiseWhenPersistenceIdAssigned|ticket.Raise|LastEventId|LastUpdatedAt|will retry' \ src/Platform/Tickets src/Platform/Reporting src/Framework -g '*.cs'
# 查找审计日报生产 writer;当前生产源码只应看到端口和 Store 实现。rg -n "UpsertAuditActivityDailyAsync" src -g '*.cs'返回 Reporting 总览 · 工单投影 · 审计日报 · Mart 存储