Skip to content
bitzorcas
中EN

Guide

Reporting 测试、可观测性与 GA 门禁

区分 Reporting 已有证据与待补能力,并给出消息字节、事件顺序、双 ORM、安全、重建和事故处置的生产验收清单。

Last updated

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. 分层验收模型

查询与规则单测
映射、输入、授权决策

双 ORM Store 契约
租户、分页、并发

真实消息字节
Outbox、topic、字段

投影集成测试
六事件、重放、失败

HTTP 安全矩阵
RBAC、Feature、DataScope

恢复演练
watermark、对账、切换

GA 证据包

直接构造 DTO 的测试适合验证消费状态机,但不能替代真实 Outbox payload、CAP 绑定、关系数据库约束和恢复演练。

3. 消息字节与最终标识

六个 Ticket 领域事件都实现 IIntegrationEvent 并声明 [IntegrationTopic]。生成器把公开业务字段映射到 payload;DomainEventDispatchPipelineBehavior 再加入 eventId 与 eventType,并在事务提交前写入 CAP Outbox。发布失败会向外传播。

当前最直接的缺陷位于 Opened 事件生成时机:

当前 Ticket.Open 的标识时序
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,可回写 NewB 被判为 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,至少覆盖:

  1. 同租户 insert/update/query,以及跨租户同 TicketId;
  2. Reopened 以 null 清空 ClosedAt;
  3. Ticket (OpenedAt,TicketId) 与 Audit (ActivityDate,UserId,ActionType) 的稳定排序;
  4. PageSize 1..1000、默认最大 offset 100000、366 天 inclusive 边界;
  5. 64 字符 Requester/Assignee 写入 Mart 36 字符列的明确失败行为;
  6. DateTimeOffset/DateTime 的精度、offset 与数据库 collation;
  7. 并发首次 insert 的唯一冲突恢复,以及旧版本 compare-and-set;
  8. EF 的 count+page 与 SqlSugar ToPageListAsync 在并发写入下的允许差异;
  9. 导出默认批次 5000、上限 100000、最大 offset 10000000;
  10. 归档或擦除后重放旧事件不会恢复已删除敏感数据。

InMemory Store 不会暴露字段长度、排序、时间精度、唯一约束和事务隔离问题,不能作为这组测试的替代品。

6. HTTP 与授权验收

实际 Host 上应执行以下矩阵:

场景预期结果
未认证访问 QUERY/POST fallback/导出401
缺少 reporting.ticket-summary.read 或 reporting.activity.read403
Feature platform.reporting 禁用403/fail closed
权限允许但 DataScope 不是 TenantReporting.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。生产上线前至少补齐:

层次指标/事件诊断问题
Produceroutbox pending、oldest age、publish failure源事实是否离开事务
Brokerlag、retry、dead-letter消息是否积压
Consumerapplied、duplicate、stale、missing predecessor、failed投影做了什么决定
Marttenant/partition watermark、last version数据截至何时
Querylatency、error、rows scanned、deep-page rejectionAPI 和索引是否健康
Exportqueued/running/failed、authorized rows、skipped rows异步导出是否完整
Reconcilesource/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 验收目标,不是现有能力:

  1. 固定 schema/consumer 版本并记录 tenant、操作人、原因和起始 watermark;
  2. 创建约束和索引与生产一致的影子 Mart;
  3. 从可审计源按 tenant、aggregate、version 重放;
  4. 捕获重建期间的 live delta 并追赶至切换 watermark;
  5. 校验总数、状态分布、关键不变量、抽样快照和孤儿行;
  6. 原子切换读写目标,观察错误率、lag 和授权拒绝;
  7. 保留旧表一个批准的回滚窗口,再按保留策略清理。

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. 评审命令

Terminal window
# 查看当前查询、导出、消费者和 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 存储

100%

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