Skip to content
bitzorcas
中EN

Guide

Reporting Mart 模型、分页与数据范围

说明 Reporting owner-local Mart 的物理模型、快照写入、Query Shape、稳定分页、时间范围、双 ORM 差异及数据保留边界。

Last updated

Reporting Mart 是 ADR 0305 的特殊读取模型:它可以去规范化、由事件/聚合任务写入,并通过窄 IReportingMartStore 查询。它不能被当作普通 OLTP Aggregate,也不能成为源业务事实。

1. Owner-local Mart 例外

Sprint 31/58 已把旧 Framework Rpt*Entity 和手写 EF 配置删除,保留 Reporting Infrastructure 内两个 Record:

  • RptTicketSummaryRecord;
  • RptAuditActivityDailyRecord。

二者通过 [BitzTable] 与 source-generated metadata 适配 SqlSugar/EF Core,Store 只依赖 IEntitySet<T>。Architecture test 禁止 Reporting.Infrastructure 引用具体 ORM,并防止已删除 Framework 路径回归。

IReportingMartStore

ReportingMartStore

RptTicketSummaryRecord
owner-local Mart row

RptAuditActivityDailyRecord
owner-local Mart row

IEntitySet
provider-neutral

SqlSugar

EF Core

“ORM-neutral”不等于行为 parity 已被充分测试。现有 Reporting 专属测试主要守架构/元数据,没有运行同一 Store 场景验证两个 provider 的 upsert、过滤、排序和并发。

2. 工单 Mart 结构

列约束/索引风险提示
TenantIdtenant base每个谓词显式带 tenant
TicketIdrequired 36;与 Tenant 唯一Tickets 业务 ID 契约未在此验证长度
RequesterIdnullable 36Ticket 源字段允许 64,潜在列宽漂移
AssigneeIdnullable 36ticket.assigned 会写入;源字段允许 64
Subjectnullable 500宽于 Ticket 255,但含自由文本/敏感信息
PriorityNamenullable 20自由字符串,不解析 smart enum
StatusNamerequired 20;单列索引不含 Tenant 的索引效率需用真实计划验证
OpenedAtrequired;单列索引查询范围字段
ClosedAtnullable快照直接赋值;ticket.reopened 会清空
LastEventIdnullable 36只记最后事件,不是 inbox
LastUpdatedAtrequired未进入 API DTO

Ticket 聚合的 RequesterId/AssigneeId 列长 64,而 Mart 只有 36。当前常见 UUID/数字 ID 可容纳,但 Contracts 没有保证所有身份 ID≤36;接入外部目录时可能在投影写阶段失败。

3. 工单 Upsert 的快照语义

已存在行时,Store 会覆盖 Requester、Assignee、Subject、Priority、Status、ClosedAt、LastEventId 和 LastUpdatedAt。OpenedAt 保留首次插入值。ClosedAt 是快照字段:传入 null 会清空旧关闭时间,这正是 ticket.reopened 的当前写入方式。

当前快照覆盖逻辑
// OpenedAt 固定为首次插入值;后续 summary.OpenedAt 不参与更新。
existing.RequesterId = summary.RequesterId;
existing.AssigneeId = summary.AssigneeId;
existing.Subject = summary.Subject;
existing.PriorityName = summary.PriorityName;
existing.StatusName = summary.StatusName;
existing.ClosedAt = summary.ClosedAt;
// LastEventId 和 LastUpdatedAt 共同提供当前有限的重复/陈旧事件边界。
existing.LastEventId = summary.LastEventId;
existing.LastUpdatedAt = summary.LastUpdatedAt;
await rows.UpdateAsync(existing, cancellationToken);

这解决了 Reopened 的字段表达问题,但没有解决事件乱序:较旧快照仍可能覆盖较新状态。当前 LastEventId 只支持直接重复检查,不是带唯一约束的 Inbox;LastUpdatedAt 也没有进入原子 compare-and-set 条件。

4. 工单查询与稳定排序

公开 Handler 先执行统一规则,再把查询交给 Query Shape:

  • PageIndex 必须 ≥1;PageSize 必须在 1..1000;
  • Status 会 trim,最长 64 字符,且不得包含控制字符;空白值按“未筛选”处理;
  • Status 不校验 TicketStatus 枚举,未知短文本会执行精确查询并通常返回空页;
  • From/To 作用于 OpenedAt,均为 inclusive;两端都有值时跨度不得超过 366 天。

TicketSummaryListInput.MapFrom 仍会按 PagingLimits 归一分页,作为 Store 内部的防御边界。排序固定为 (OpenedAt desc, TicketId desc)。由于 (TenantId, TicketId) 唯一,同一租户内的排序有确定性。Status 的大小写规则仍由数据库 collation/provider 决定。

Query Shape 执行器通过 PageWindow 把默认最大 offset 限制为 100000。超过该深度时仍返回总数和请求页元数据,但 Items 为空;调用方不应把空页误判为筛选条件没有数据。需要继续深挖时,应改用 (OpenedAt, TicketId) keyset cursor。

5. 审计查询已有完整稳定排序

审计 Query Shape 的默认排序是 (ActivityDate desc, UserId desc, ActionType desc)。查询始终带 TenantId 谓词,因此这三个字段覆盖唯一键中剩余的全部维度,跨页顺序是确定的。不要在 Handler 或 Store 外层重排,否则会破坏该保证。

6. 时间范围语义

工单 From/To 都是可选值,按 OpenedAt 而非 ClosedAt/UpdatedAt inclusive 比较;审计 From/To 必填,按 ActivityDate inclusive 比较。Application 会拒绝逆序范围和超过 366 天的范围。当前仍没有:

  • half-open [from,to) 约定;
  • UTC normalization;
  • ClosedAt/LastUpdatedAt 过滤;
  • 业务时区;
  • projection freshness filter。

状态报表若按 opened month 统计 closed count,会把历史月份打开、本月关闭的工单排除。产品指标必须明确 cohort(opened-in-period)还是 flow(closed-in-period)。

7. 总数与页面的一致性

Reporting 通过生成的 Query Shape 执行整包分页。EF Core 适配器先 LongCountAsync,再读取页面;SqlSugar 适配器使用 ToPageListAsync 返回页面和总数。代码没有声明跨 provider 的 snapshot 一致性,因此 Mart 并发写入时,TotalCount 和 Items 仍可能来自不同的可见时刻。

普通交互式报表按 eventual consistency 使用即可。导出不能借用 UI 页大小:当前批读默认 5000、上限 100000、最大 offset 10000000,并且 EstimateTotalRows 与每批读取也没有统一快照水位。需要审计级一致性时,应在导出请求中固定 Mart watermark 或数据库快照。

大 offset 分页性能随页码下降。报表规模上来后使用 (OpenedAt,TicketId) keyset,并返回 opaque cursor 与 Mart watermark;TotalCount 可异步/近似计算。

8. Upsert 原子性与唯一冲突

两类 Upsert 都是 FirstOrDefault→Add/Update。并发新键依赖 unique index 报错,没有捕获回读;并发已存在行没有 version compare-and-set。BizEntityBase 不是领域 Aggregate 的 Version 语义来源。

目标 SQL/端口需表达:只有 incoming source version 高于 LastAppliedVersion 才更新,并返回 Applied/Duplicate/Stale/Gap。不同 ORM 必须 parity 测试影响行数和唯一冲突映射。

目标 compare-and-set 结果
public static class ReportingErrors
{
public static readonly Error VersionGap =
Error.Conflict("Reporting.VersionGap", "投影缺少前序事件。");
public static readonly Error StoreFailure =
Error.Failure("Reporting.StoreFailure", "Mart 写入失败。");
}
// Store 不替 consumer 猜顺序;调用方必须提供 source version。
var outcome = await mart.TryApplyTicketSnapshotAsync(
snapshot,
expectedPreviousVersion: snapshot.TicketVersion - 1,
cancellationToken);
// 重复/过期是已识别的消费结果;gap 和基础设施失败必须进入不同恢复路径。
return outcome switch
{
ProjectionApplyOutcome.Applied => Result.Success(),
ProjectionApplyOutcome.Duplicate or ProjectionApplyOutcome.Stale => Result.Success(),
ProjectionApplyOutcome.Gap => Result.Failure(ReportingErrors.VersionGap),
_ => Result.Failure(ReportingErrors.StoreFailure)
};

9. 索引与执行计划

当前索引:

  • Ticket unique (TenantId,TicketId);
  • Ticket StatusName;
  • Ticket OpenedAt;
  • Audit unique four dimensions;
  • Audit ActivityDate。

常用谓词总以 TenantId 开头,单列 Status/OpenedAt/ActivityDate 是否最优取决于 ORM schema generator 与数据库。典型复合索引可能是 (TenantId,StatusName,OpenedAt desc,TicketId desc) 和 (TenantId,ActivityDate desc,UserId desc,ActionType),但必须用两 provider 的真实 explain/benchmark 决定。

10. 保留与派生数据治理

两表 IsSoftDelete=false,Reporting 没有 DataLifecycle policy。删除源 Ticket、清理 Audit 或 GDPR erasure 不会自动清理 Mart。需要 lineage:每个 Mart 字段来自哪个 source、何种合法目的、保留多久、如何重建/删除。

对于 Ticket Subject/User IDs,删除策略可能是删除整行、匿名化列或保留合规指标。执行后还要防止旧事件 replay 把已擦除数据重新写回。

11. 必测 Store 矩阵

  1. SqlSugar/EF 同一 insert/update/query snapshot;
  2. tenant isolation 的 count/page/get/upsert;
  3. status trim、未知值、case/collation parity;
  4. 366 天上限、UTC/offset/precision 与 inclusive 边界;
  5. PageIndex/PageSize、三键审计排序和 100000 深度边界;
  6. Query Shape 计数与页面之间的并发写;
  7. 并发 insert unique conflict 与 update CAS;
  8. 64 字符身份 ID、500 subject、未知 status/priority;
  9. reopen 清空 ClosedAt、直接重复与 stale event;
  10. retention/erasure 后旧事件重放。

12. 审查命令

Terminal window
# 物理列、唯一键与查询谓词必须一起审查。
rg -n "BitzTable|BitzIndex|BitzColumn|CountAsync|PageByDescendingAsync" \
src/Platform/Reporting -g '*.cs'
# 暴露两类非原子 check-then-write 与 ClosedAt 快照覆盖。
rg -n "FirstOrDefaultAsync|AddAsync|UpdateAsync|ClosedAt =" \
src/Platform/Reporting/BitzOrcas.Platform.Reporting.Infrastructure -g '*.cs'
# 检查 Query Shape、分页边界和稳定排序。
rg -n "ExecutePageAsync|PagingLimits|ReadModelSort|MaximumPeriod" \
src/Platform/Reporting src/Framework/BitzOrcas.Domain/Abstractions/Queries -g '*.cs'

返回 Reporting 总览 · HTTP 契约与授权 · 测试与 GA

100%

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