Rpt_AuditActivityDaily 设计为按 (TenantId, ActivityDate, UserId, ActionType) 汇总的宽表,但当前只有模型、store 和查询端点,没有任何生产任务。文档必须把“数据结构可写”与“审计活动已经自动聚合”分开。
1. 当前物理模型
| 字段 | 数据库约束 | 业务含义 |
|---|---|---|
| TenantId | BizEntityBase + tenant table | 聚合所属租户 |
| ActivityDate | required DateTime | 注释称 UTC 活动日期,但未强制 date-only |
| UserId | required,长度 36 | 系统活动也要求稳定用户标识 |
| ActionType | required,长度 50 | 动作维度 |
| ActivityCount | required int | 当前维度的完整计数快照 |
| LastUpdatedAt | required DateTimeOffset | 聚合写入时间 |
唯一索引包含四个维度;另有 ActivityDate 索引。表不是软删除,模块没有 retention/delete/archive API。
2. Upsert 是覆盖,不是累加
// 同一四维键命中后,store 直接用调用方给出的完整快照覆盖。var existing = await rows.FirstOrDefaultAsync( x => x.TenantId == input.TenantId && x.ActivityDate == input.ActivityDate && x.UserId == input.UserId && x.ActionType == input.ActionType, cancellationToken);
if (existing is null){ await rows.AddAsync(ToPersistenceRecord(input), cancellationToken); return Result.Success();}
// 这里不是 ActivityCount += delta;重放 delta 会丢计数或重复计数。existing.ActivityCount = input.ActivityCount;existing.LastUpdatedAt = input.LastUpdatedAt;await rows.UpdateAsync(existing, cancellationToken);return Result.Success();因此未来 writer 必须提交“截至 checkpoint 的完整 bucket count”。若要消费逐条审计事件做增量,应在数据库里用事件 inbox + 原子 increment,并处理重复;不能把 delta 填进现有覆盖接口。
3. 当前没有生产者
全仓库 UpsertAuditActivityDailyAsync 的引用只有:
IReportingMartStore声明;ReportingMartStore实现。
没有 Auditing consumer、CAP subscription、Quartz/JobHost executor、SQL group-by service、后台计划、管理回补命令或 seed。ReportingQueryHandlerTests 用 substitute store 返回一条手工记录,只验证 Handler 映射。
4. 当前查询契约
审计日报通过 QUERY /api/reports/activities/daily 查询;不支持 QUERY 的客户端使用 POST /api/reports/activities/daily/_query,没有 GET 路由。请求体包含必填 From/To 和默认 PageIndex=1、PageSize=20。
Handler 会在访问 Store 前执行以下检查:
- From 不得晚于 To,时间跨度不得超过 366 天;
- PageIndex 必须 ≥1,PageSize 必须在 1..1000;
- 调用方必须是带有效 TenantId 和正 UserId 的 User/Delegated 身份;
- 必须拥有
reporting.activity.read,且最终 DataScope 必须精确为 Tenant。
返回值是完整 PagedResult<AuditActivityDailyRow>。Store 使用 inclusive 的 ActivityDate 范围,默认排序为 (ActivityDate desc, UserId desc, ActionType desc);在固定 TenantId 下,该排序覆盖唯一键剩余维度。公开 DTO 只有 ActivityDate、UserId、ActionType、ActivityCount,不包含 LastUpdatedAt 或聚合 watermark。
5. 日期与时区不变量
注释说 UTC 按日聚合,但类型是 DateTime,Upsert 不归一化:
2026-07-15 00:00Z与2026-07-15 08:00Z是两个唯一键;- Kind=Local/Unspecified 的 provider 行为可能不同;
- 若产品要按租户本地日,UTC midnight 又不满足业务语义;
- DST 会产生 23/25 小时本地日。
必须先选择:UTC calendar day,还是 tenant timezone day。建议存 ActivityDate 为 DateOnly 业务键,并保存 TimeZoneId/bucket start/end UTC 或聚合策略版本,避免租户时区变更后历史被重解释。
// 先把事件时刻映射到租户配置的 IANA 时区,再得到业务 DateOnly。var zone = DateTimeZoneProviders.Tzdb[tenant.TimeZoneId];var instant = Instant.FromDateTimeOffset(audit.OccurredAt);var activityDate = instant.InZone(zone).Date;
// 桶边界保留 UTC,可用于增量扫描和 DST 正确性。var start = activityDate.AtStartOfDayInZone(zone).ToInstant();var end = activityDate.PlusDays(1).AtStartOfDayInZone(zone).ToInstant();
// 完整快照与 checkpoint 一起提交;不要用处理时间决定归属日。await aggregator.UpsertSnapshotAsync( tenant.Id, activityDate, audit.UserId, audit.ActionType, count, sourceCheckpoint, start, end, cancellationToken);当前表没有 TimeZoneId、bucket boundaries 或 policy version,若选择本地日需要 schema 演进。
6. ActionType 契约
ActionType 长度 50,只是自由字符串。没有 catalog、大小写/空白规范、版本或未知动作策略。Auditing 的不同 sink 可能用 activity name、permission、HTTP route 或事件类型,若直接混入会形成不可聚合的高基数维度。
应定义稳定动作 taxonomy,例如 tickets.ticket.view、identity.login.failed,并把 resource/operation/result 拆维度。不要把 URL、TicketId、错误消息、用户输入拼进 ActionType。
7. UserId 与系统活动
UserId required 且最长 36。审计源可能包含:
- 数字用户 ID;
- API Client;
- BackgroundJob/System;
- 未认证失败登录;
- impersonator 与 effective actor。
把所有类型塞进 UserId 会丢 caller type。建议 ActorType + ActorId,并单独保留 EffectiveUser/Impersonator 聚合策略。未知/系统活动使用稳定保留值,不要空字符串。
8. 推荐的批量增量聚合
每批读取 (lastCheckpoint, newHighWatermark],但覆盖式快照需要知道该 bucket 的完整累计值。可维护专用 accumulator,或按可控日期窗口从 immutable audit source 重算。Late event 应重开对应日桶;超过 lateness window 的事件进入 correction queue。
9. Checkpoint 与幂等
同一事务写:
- Mart snapshot;
- source high-watermark;
- aggregation run id/status/control totals。
若 source 是分区/分片,checkpoint 必须按 partition。重试同 run 产生相同 absolute counts。不要只保存 LastUpdatedAt:它是处理时间,不证明读到了哪个 source offset。
10. 回补与策略变更
Action taxonomy、时区、过滤规则或隐私策略改变时,要以 ProjectionVersion 重建 shadow 表。步骤:冻结定义 → 记录 source range → 分批 rebuild → control-total 校对 → 追平增量 → 原子切换 → 保留回滚窗口。
当前没有 rebuild API。直接 DELETE+重算会让查询看到部分数据,且无法区分空数据与重建中。
11. 隐私与保留
即使是计数,UserId+ActionType+日期也可形成行为画像。需要:
- 最小访问范围和独立审计报表权限;
- 小样本 suppression/k-anonymity(对跨人群分析);
- UserId pseudonymization 或组织级聚合;
- 与原始审计不同的 Mart retention;
- GDPR erasure/合法保留冲突策略;
- 导出 watermark 与下载过期。
当前表 IsSoftDelete=false 且没有 data lifecycle policy。不能假设审计源清理会自动清理 Mart 派生数据。
12. 必测聚合矩阵
- UTC 日、本地日、DST、时区修改;
- 同一事件重复、批次重跑、checkpoint 崩溃;
- late event、跨日边界、未来时间和错误 Kind;
- 多 partition checkpoint 与乱序;
- ActionType normalization/未知/高基数;
- User/API Client/System/anonymous/impersonation;
- int overflow、负数、空维度和列长;
- shadow rebuild 与 live catch-up;
- source/Mart control totals 与抽样明细;
- retention、erasure、访问和导出审计。
13. 审查命令
# 当前生产源码只有接口和 Store;出现第三个调用点后必须同步本章与契约测试。rg -n "UpsertAuditActivityDailyAsync" src -g '*.cs'
# 暴露尚不存在的 scheduler/checkpoint/rebuild。rg -n "Reporting.*(Job|Checkpoint|Backfill|Rebuild|Reconcile)|AuditActivity.*Consumer" \ src/Hosts src/Platform/Reporting -g '*.cs'
# 日期、用户、动作维度的物理约束必须与聚合器一致。rg -n "ActivityDate|UserId|ActionType|ActivityCount|LastUpdatedAt" \ src/Platform/Reporting -g '*.cs'
# 查询路由、Read 权限、输入边界和三字段稳定排序。rg -n "HttpRoute.Query|AuthorizationAction.Read|ValidateDateRange|ValidatePage|ReadModelSort" \ src/Platform/Reporting -g '*.cs'