Files
junhong_cmp_fiber/openspec/changes/build-multi-view-audit-center/design.md
break c64f3d8b80
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m31s
全局审计完成
2026-08-07 11:02:52 +08:00

365 lines
49 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## Context
当前仓库同时存在四类不能互相替代的事实Access Log 面向开发人员记录 HTTP 调试信息;`tb_integration_log` 记录外部请求、回调、未发送尝试和结果未知;订单、支付、退款、钱包流水、审批、套餐权益等业务表是 Domain Ledger账号和资产 operation log 则是结构不一致、覆盖有限且使用裸 goroutine 的旧内部审计。现状没有统一回答操作者、动作、多资源、结果、风险和跨请求因果关系的 Audit Event也没有 Integration Log 的平台查询接口。
本 Change 是新的独立规划,不恢复七月旧 Change。实施前以当前代码重新盘点 HTTP、Application、旧 Service、Worker、Scheduler 和 Callback 写入口;旧 490 项覆盖清单只作遗漏参考,不能直接作为现行契约。当前盘点已确认账号权限、店铺企业、个人客户、卡、设备、设备卡槽、资产流转、换货、套餐、订单、支付、退款、充值、钱包、佣金、审批、配置、导入导出、通知、轮询、外部集成和可靠事件等领域。
架构遵循触碰式迁移:复杂写在既有 Domain/Application 完整事务边界接入审计;简单写使用 Application 事务脚本;尚未迁移的旧用例由旧 Service 通过统一 Adapter 接入;所有调查读取使用 `Handler → Query → GORM/DTO`,不经过聚合根。新增表不建立外键,不使用 GORM 关联标签。
## Goals / Non-Goals
**Goals:**
- 建立统一、不可变、多资源、可串联的内部 Audit Event覆盖全部非查询业务操作及有调查价值的失败、拒绝和自动状态变化。
- 让同一事实可以从全局、操作者、资源、请求、业务关联、资金、风险和外部集成等视角查询。
- 为卡、设备及其多卡槽关系、换货旧新资产、资金和批量操作保存足以独立理解历史的业务标识快照。
- 保持 Audit Event、Integration Log、Domain Ledger、Outbox/任务状态和 Access Log 的权威边界。
- 让平台看到完整业务审计数据,让代理/企业只看到其有权资源的安全业务结论。
- 停止旧账号/资产 operation log 新增;旧表原样保留且不进入新审计中心,新中心从切换后的 Audit Event 开始形成完整视角。
- 将高频 Audit Event 和 Integration Log 按日压缩到对象存储,并在可证明归档完整后删除 PostgreSQL 上月数据,避免日志无限占用数据库;对象存储备份长期保留。
**Non-Goals:**
- 第一阶段不提供面向用户的审计导出、对象存储历史查询/恢复、敏感二次查看、细粒度平台权限码、平台数据行限制、风险处置或自动恢复操作。
- 不把 Access Log 文件导入 PostgreSQL不在调查接口中扫描本地日志文件。
- 不保存密码、验证码、Token、Secret、私钥、回调凭据、Authorization、Cookie、签名 URL、支付密钥或完整第三方原始正文。
- 不用 Audit Event 替代钱包流水、订单、支付、退款、审批、套餐权益等 Domain Ledger。
- 不为每个领域新建审计表,不在线回填或删除旧 operation log不建立长期新旧双写。
- 不建设任意关系图、任意 JSONB 搜索、审计数据修改/删除 API、归档下载 API 或 Integration Log 恢复 API。
- 不借审计接入一次性重构未触碰业务模块;迁移单位是完整用例。
## Decisions
### 1. 四类事实保持分离,以稳定标识关联
```text
HTTP 请求 ──────────────── Access Log开发调试
业务写事务 ─┬──────────── Domain Ledger业务权威
├──────────── Audit Event谁对什么做了什么
└──────────── Outbox可靠副作用
外部请求/回调 ─────────── Integration Log外部交互事实
```
`request_id` 关联一次 HTTP 请求;`correlation_id` 关联跨请求、Outbox、Asynq、回调和业务后续步骤`parent_event_id` 只表示直接因果。Integration Log、Outbox 和任务通过稳定 ID 进入关联时间线,但查询结果必须标明 `record_source`,不得把不同事实伪装成同一种记录。
**否决方案:** 把所有内容合并为万能日志表。它会混淆业务事实、技术尝试和审计责任,并让保留期、更新规则与查询索引互相冲突。
### 2. Audit Event 使用主事件和资源关系两张表
`tb_audit_event` 保存操作级事实:
| 字段组 | 主要字段 | 含义 |
|---|---|---|
| 身份 | `id``event_id``occurred_at` | 数据库内部 ID、稳定公开 ID和真实发生时间 |
| 动作 | `category``action_code``action_name``summary` | 稳定动作编码、中文快照和内部摘要 |
| 操作者 | `actor_kind``actor_id``actor_name``actor_shop_id/name``actor_enterprise_id/name` | 人工、OpenAPI、系统任务或外部系统真实身份快照 |
| 入口 | `source``request_path``request_method``ip_address``user_agent` | API、Worker、Scheduler、Callback 等入口及 HTTP 摘要 |
| 目标范围 | `scope_type``scope_id``scope_name` | 本次操作主要涉及的店铺、企业、平台或个人范围快照 |
| 结果 | `result``risk_level``error_code``error_summary` | `success/failed/denied/partial/unknown` 与稳定风险 |
| 链路 | `request_id``correlation_id``parent_event_id` | 请求、业务链路和直接因果 |
| 批量 | `batch_total``success_count``fail_count` | 批次根事件统计,普通事件为零 |
| 补充 | `metadata``content_hash``created_at` | 有界业务参数、不可变内容哈希和落库时间 |
`tb_audit_event_resource` 保存每个资源自己的身份与变化:
| 字段 | 含义 |
|---|---|
| `audit_event_id` | 主事件内部 ID普通索引不建外键 |
| `resource_type``resource_id` | 注册资源类型与可空内部 ID统一按字符串投影 |
| `resource_key``display_name` | 事件发生时的稳定业务 Key 和可读名称 |
| `relation` | `primary/affected/reference` |
| `role` | `old_card/new_card/bound_card/entry_device/shop/order` 等稳定业务角色 |
| `identity_snapshot` | 事件发生时的业务标识快照,不依赖当前业务表 |
| `before_data``after_data` | 只保存该资源本次实际涉及的业务字段 |
| `subject_visibility` | `internal_only/subject_result/subject_detail` |
| `subject_summary` | 代理/企业可见的安全结论;不从内部摘要临时删字段生成 |
| `subject_data` | 写入时按 Action Registry 白名单生成的主体可见结构化业务字段 |
| `sort_order``created_at` | 稳定展示顺序和写入时间 |
每个事件至少有一个 `primary` 资源。资源关系使用事件、资源、关系和角色复合唯一约束;资源 ID、Key、事件时间建立时间线索引。业务 Repository 不提供 Update/Delete只有内部 Retention Worker 可在归档完整性门禁通过后按已归档月份执行受控删除。
**否决方案:** 继续按账号、资产、订单各建 operation log。它无法低成本支持跨领域资源时间线并重复制造不一致字段。
### 3. Action Registry 与 Resource Registry 是写入和展示契约
所有常量位于 `pkg/constants/`。Action Registry 至少定义动作编码、中文名称、类别、默认风险、允许的操作者来源、资源类型与角色、是否必须同事务、默认主体可见性和安全字段规则。Resource Registry 定义资源类型、中文名、稳定 Key、展示名称和允许的快照字段。
未经注册的 action、缺少主要资源、资源角色不合法、系统安全凭据未被清理或高风险动作未使用要求的事务策略时统一 Writer 拒绝写入。Action 中文名保存快照历史不因后续改名而改变Query 同时返回编码和中文名。
覆盖清单按完整业务用例登记入口、action、actor、资源、事务、失败策略、Audit Event、Domain Ledger、Integration Log、Outbox 和 N/A 理由。自动扫描只发现候选业务语义必须由实现任务确认Setter、装配方法和纯查询不得因命名被误判为业务动作。
### 4. 操作者上下文由入口提供,业务语义由 Application/Service 显式写入
HTTP 中间件只构造 Audit Context操作者、来源、IP、User-Agent、路径、方法和 request ID。Worker、Scheduler、OpenAPI 与 Callback 构造对应系统或外部操作者上下文。Application 或旧 Service 在完整用例边界显式提供 action、资源、前后值、结果和关联 ID。
全局 Fiber 中间件不得自动生成 Audit Event因为它不知道业务是否落地、影响哪些资源、修改前后值或真实结果。Handler 也不得在业务完成前自行写成功事件。
依赖通过结构体字段或构造器显式注入小接口:复杂写 Application 使用 `AuditWriter` Port 与 GORM 事务 Adapter旧 Service 可暂时使用接受 `*gorm.DB`/事务上下文的兼容 Adapter调用迁完后删除旧门面。失败/拒绝短事务使用同一 Writer 的独立入口,禁止 goroutine。
### 5. 不同结果采用不同可靠性策略
| 场景 | 记录策略 |
|---|---|
| 成功或部分成功且改变关键业务事实 | 与 Domain Ledger 和必要 Outbox 同一 GORM 事务;审计失败则业务回滚 |
| 权限或业务规则拒绝且可确定主要业务资源 | 业务未落地后使用独立短事务记录 `denied`;资源 ID 可空,但必须有稳定 Key/快照 |
| 已识别主要业务资源后的执行失败 | 保留原业务错误,独立短事务记录 `failed`;二次失败写 critical 日志和指标 |
| 参数解析失败、只有 actor 或无法确定主要业务资源 | 不写 Audit Event仅保留 Access/Security Log |
| 外部请求未改变内部事实 | 只写 Integration Log |
| 外部回调或系统任务改变内部事实 | 保留 Integration/任务事实,并写 `external_system/system_task` Audit Event |
| 普通查询 | 不写 Audit Event |
| Action Registry 标记的敏感读取 | 返回敏感结果前写 Audit Event写入失败则不返回该结果 |
第一阶段“所有非查询操作”包含通知已读等低风险写操作;它们仍进入 Action Registry但可标记低风险并在默认调查列表中降低优先级不能擅自 N/A。普通列表和详情保持 N/A查看审计中的受控敏感详情、明文业务凭证或其他被 Registry 明确标记的敏感读取属于例外,必须记录读取者、目标资源和读取字段类别,但仍不得返回系统安全凭据。
### 6. 多资源、设备多卡和批量操作按实际业务关系记录
一个同步业务操作写一个根事件并关联全部资源。设备某张卡操作必须把设备、卡和必要的 `device_sim_binding` 都作为一等资源:通过设备入口操作卡时,卡为 `primary/affected`,设备为 `reference`;修改绑定关系时设备、旧卡、新卡及相关绑定均为 `affected`,并保存 `slot_position/is_current`
换货事件必须区分旧卡、旧设备、新卡、新设备及其绑定卡,不得只保存通用 `asset_identifier`。订单、退款、审批、钱包、流水、套餐权益、个人客户绑定和店铺按实际变化关联为 `affected/reference`
批量操作写一条根事件保存条件与统计;每个实际变化资源写可进入自身时间线的子事件,子事件共享 correlation 并以根事件为 parent。未处理或未命中资源不写子事件失败项若已识别资源则写失败子事件。
### 7. 资源快照按当前真实领域盘点,不复制完整 Model
| 资源 | 至少保存的身份快照 |
|---|---|
| 账号 | ID、用户名、手机号、用户类型、所属店铺/企业、企微 userid/name |
| 角色/权限 | ID、角色名称/类型、权限 code/name、目标账号 |
| 店铺/企业 | ID、编码、名称、上级/归属店铺、层级 |
| 个人客户 | ID、昵称、当前手机号、微信主体标识 |
| IoT 卡 | ID、ICCID、VirtualNo、MSISDN、运营商、店铺、系列、generation |
| 设备 | ID、VirtualNo、IMEI、SN、名称/型号、店铺、系列、generation |
| 设备卡槽 | binding ID、设备标识、slot、卡 ICCID/VirtualNo、是否当前卡 |
| 资产分配 | 分配单号、资产类型/ID/标识、来源/目标主体、关联设备/卡 |
| 换货 | 换货单号、流程类型、旧/新资产完整标识、店铺、状态 |
| 套餐/系列 | ID、编码、名称、类型、期限、价格、上下架状态 |
| 套餐权益 | usage ID、订单号、套餐、资产完整标识、generation、状态、激活/到期时间 |
| 订单 | ID、订单号、买家、操作者、资产、套餐、金额、支付方式/状态、购买角色 |
| 支付 | 支付 ID/单号、业务单号、渠道、金额、状态、第三方交易号、配置 ID |
| 退款/充值 | 业务单号、订单/资产/店铺、申请和批准金额、审批、支付方式、状态 |
| 钱包/流水 | 钱包 ID/类型、店铺或资产、币种、流水 ID、reference、amount、balance before/after |
| 佣金 | 记录/申请 ID、店铺、订单、系列、金额、状态、结算周期 |
| 审批 | 实例 ID、业务类型/ID、提交人、provider、external ref、correlation、状态 |
| 配置 | 配置 Key/模块或配置 ID/name/provider凭据只记是否已配置 |
| 导入/批量/导出任务 | 任务 ID/单号、文件名、目标、操作者、总数与结果数;审计中心自身不导出 |
| 通知 | event ID、接收人、类别、ref type/id/key |
| 轮询 | 配置/规则/触发 ID、资产、任务类型、触发人运行进度仍由任务表承担 |
| Integration/Outbox | integration ID/provider/operation/resource/external IDevent ID/type/aggregate |
实现任务必须继续扫描新增领域并更新该矩阵。手机号、IP、ICCID、VirtualNo、金额和交易号可供平台完整展示安全凭据无论用户权限如何都不进入快照、前后值或 metadata。
### 8. 平台调查 API 采用少量稳定视角,不返回 GORM Model
| 视角 | API | 主要内容 |
|---|---|---|
| 全局事件 | `GET /api/admin/audit/events``/{event_id}` | 组合筛选、事件详情、多资源及各自变化 |
| 操作者 | `GET /api/admin/audit/actors/{kind}/{id}/events` | 指定人员/系统的行为时间线 |
| 资源搜索/时间线 | `GET /api/admin/audit/resources/search``/{type}/{id}/timeline` | 注册资源候选与通用资源轨迹 |
| 请求链路 | `GET /api/admin/audit/requests/{request_id}/timeline` | Audit、Integration、Outbox/任务摘要及 Domain Ledger 链接 |
| 业务链路 | `GET /api/admin/audit/correlations/{correlation_id}/timeline` | 跨请求、异步、外部和后续业务步骤 |
| 资金 | `GET /api/admin/audit/finance/timeline` | Audit Event + 钱包流水/订单/支付/退款/充值/佣金/审批 |
| 风险 | `GET /api/admin/audit/risks/overview``/events` | 高风险、失败、拒绝、部分成功和结果未知 |
| 外部集成 | `GET /api/admin/audit/integrations/overview``/integrations``/integrations/{integration_id}` | 总览、组合筛选、详情和尝试序列 |
查询入参按来源分为四类Handler/DTO 和前端契约必须明确来源,不能只列字段名:
| API | 关键入参 | 入参来源 |
|---|---|---|
| `GET /api/admin/audit/events` | `created_from``created_to``action``category``actor_kind``actor_id``source``result``risk``scope_type``scope_id``resource_type``resource_id``resource_key``request_id``correlation_id``page``page_size` | 调查人员在全局事件筛选区输入或从其他视角“查看相关事件”跳转带入;除分页默认值外不由后端猜测 |
| `GET /api/admin/audit/events/{event_id}` | `event_id` | 来自事件列表、资源/actor/风险/链路时间线节点的稳定跳转 ID允许调查人员粘贴稳定 ID |
| `GET /api/admin/audit/actors/{kind}/{id}/events` | `kind``id``action``result``risk``resource_type``resource_id``created_from``created_to``page``page_size` | `kind/id` 来自事件详情中的 actor 引用或平台账号选择器,其他字段来自当前视角筛选区 |
| `GET /api/admin/audit/resources/search` | `resource_type``keyword`、page/page_size | 调查人员选择资源类型并输入业务标识keyword 对卡使用 ICCID/VirtualNo、设备使用 VirtualNo/IMEI/SN其他类型使用 Registry 声明的 Key |
| `GET /api/admin/audit/resources/{resource_type}/{resource_id}/timeline` | `resource_type``resource_id``created_from``created_to``action``result``page``page_size` | 类型和 ID 必须来自下方业务页面映射、资源搜索结果或调查节点 `resource_refs[]`;后端不从模糊 keyword 猜 ID |
| `GET /api/admin/audit/requests/{request_id}/timeline` | `request_id` | 来自事件/Integration 详情的 request ID 或开发人员从 Access Log 粘贴;不是前端自行生成 |
| `GET /api/admin/audit/correlations/{correlation_id}/timeline` | `correlation_id` | 来自事件、Integration、Outbox/任务或业务详情中的稳定关联 ID不是按时间或资源推断 |
| `GET /api/admin/audit/finance/timeline` | `shop_id``wallet_id``order_id``order_no``payment_id``payment_no``refund_id``refund_no``recharge_id``recharge_no``approval_instance_id``third_party_trade_no``actor_kind``actor_id``correlation_id``created_from``created_to``page``page_size` | 调查人员在资金筛选区输入业务标识,或按下方业务页面映射和调查节点跳转带入;服务端按已提供的稳定字段解析 Domain Ledger不要求前端补齐同一业务的其他 ID |
| `GET /api/admin/audit/risks/overview``/events` | `created_from``created_to``risk``result``action``source``page``page_size` | 调查人员选择时间窗口和风险筛选;从 overview 分桶跳转时前端带入相同筛选条件 |
| Integration overview/list | `created_from``created_to``bucket``integration_id``provider``direction``operation``result``result_category``external_id``resource_type``resource_id``resource_key``trigger_source``trigger_scene``trigger_series``state_changed``http_status``provider_code``request_id``correlation_id``page``page_size` | 调查人员筛选、通知目标跳转或其他时间线节点带入;枚举选项来自后端稳定常量,不由前端自由拼接 |
| `GET /api/admin/audit/integrations/{integration_id}` | `integration_id` | 来自 Integration 列表、通知目标或关联时间线节点;使用稳定 Integration ID不使用数据库自增 ID |
认证用户类型、平台/代理/企业身份、当前账号 ID、代理店铺范围和企业 ID 永远来自认证上下文,不允许由 query/path/body 传入。中文名称、派生结果类别、资源展示名称、关联资源、Domain Ledger 节点和 attempts 由 Query 根据稳定 ID 批量派生,不要求前端提供。
所有列表默认 20、最大 100使用稳定时间+ID倒序时间线统一返回 `record_source`、节点 ID、发生时间、标题、结果、资源和可跳转引用。专业资金视角以 Domain Ledger 为金额权威;通用资源时间线只陈述事件,不重新计算业务结论。
平台审计路由第一阶段只校验用户类型为超级管理员或平台账号,不引入权限码,不做店铺/企业数据行过滤。平台返回存储中的完整业务字段,但不会返回从未入库的系统安全凭据。
所有新 Handler 使用 RouteSpec统一响应 `{code,msg,data,timestamp}`,错误定义在 `pkg/errors/`;同步生产组合根、共享文档 Handler、`cmd/api/docs.go``cmd/gendocs/main.go`
| 场景 | 错误码 | 对外语义 |
|---|---|---|
| 参数、枚举、分页或时间范围非法 | `CodeInvalidParam` | 参数错误,不返回底层校验细节 |
| 非平台访问内部调查或主体越权 | `CodeForbidden` | 无权限操作该资源或资源不存在 |
| 在线窗口内稳定 ID 不存在 | 复用项目资源不存在错误码 | 资源不存在,不猜测归档内容 |
| 显式时间范围早于或跨越在线边界 | 新增 `CodeAuditDataArchived` | 数据已归档,第一阶段不支持在线查询;响应携带当前在线边界 |
#### 8.1 现有业务页面到资源时间线的参数映射
资源时间线不能要求前端重新解析 ICCID、虚拟号或中文描述。第一阶段以当前业务接口已经返回的稳定字段作为跳转来源并冻结以下映射。表中的字段路径均位于统一响应的 `response.data` 下;分页接口使用 `items[]`
平台在列表行操作和详情页签显示“审计记录”,代理/企业显示“活动记录”;点击后再加载对应时间线,不改变原业务详情响应。只有下表所需字段齐全且当前身份允许目标接口时才显示入口。
**资产详情完整调用样例**:页面先调用 `GET /api/admin/assets/resolve/8986...`。若返回 `data.asset_type="card"``data.asset_id=321``data.iccid="8986..."`,平台“审计记录”页签调用 `GET /api/admin/audit/resources/iot_card/321/timeline?page=1&page_size=20`,代理“活动记录”页签调用 `GET /api/admin/agent/resource-activities/iot_card/8986...?page=1&page_size=20`。若返回 `asset_type="device"`,平台使用 `data.asset_id``.../resources/device/{asset_id}/timeline`,代理使用 `data.virtual_no``.../agent/resource-activities/device/{virtual_no}`。企业身份不能调用当前 resolve 接口,必须从企业卡/设备列表按下表进入企业活动接口。
| 业务页面/前置接口 | 当前响应中的稳定字段 | 平台内部时间线 | 代理安全活动 | 企业安全活动 |
|---|---|---|---|---|
| 卡列表 `GET /api/admin/iot-cards/standalone` | `items[].id``items[].iccid``items[].virtual_no``items[].shop_id``items[].device_virtual_no``items[].authorized_enterprise_id` | `iot_card/{id}` | `iot_card/{iccid}`;后端按卡当前 `shop_id` 校验自己及下级店铺 | 不以通用列表中的企业 ID 作为授权证明;企业从企业卡列表进入 |
| 设备列表 `GET /api/admin/devices` | `items[].id``items[].virtual_no``items[].imei``items[].sn``items[].shop_id``items[].bound_card_count``items[].authorized_enterprise_id` | `device/{id}` | `device/{virtual_no}`;后端按设备当前店铺归属校验 | 企业从企业设备列表进入并复核当前有效授权 |
| 设备卡槽 `GET /api/admin/devices/{virtual_no}/cards` | `bindings[].id``bindings[].iot_card_id``bindings[].iccid``bindings[].slot_position``bindings[].is_current` | 卡使用 `iot_card/{iot_card_id}`;需要查看绑定关系时使用 `device_sim_binding/{binding.id}`;设备 ID 由上层设备列表或详情提供 | 点击某张卡使用 `iot_card/{iccid}`;设备轨迹继续使用上层设备 `virtual_no` | 同代理参数,但必须再次验证设备及卡均处于当前企业有效授权范围;绑定关系不作为越权捷径 |
| 统一资产详情 `GET /api/admin/assets/resolve/{identifier}` | `asset_type``asset_id``identifier``virtual_no``iccid``bound_device_id``cards[].card_id`、换货轨迹中的 `asset_id/can_view` | `asset_type=card` 映射为 `iot_card/{asset_id}``device` 映射为 `device/{asset_id}`;绑定资产按其 ID 跳转 | 卡用 `iot_card/{iccid}`,设备用 `device/{virtual_no}`;换货前后代仅在 `can_view=true` 时开放跳转 | 不展示入口:当前接口明确禁止企业调用;企业只能从企业卡/设备列表进入 |
| 资产分配列表/详情 `GET /api/admin/asset-allocation-records[/{id}]` | `items[].id/allocation_no/asset_type/asset_id/asset_identifier/from_owner_type/from_owner_id/to_owner_type/to_owner_id/related_device_id`;详情另有 `related_card_ids[]` | 分配记录 `asset_allocation_record/{id}`;主资产使用 `{asset_type}/{asset_id}`;关联设备、卡和店铺分别使用对应资源 ID | 分配记录使用 `asset_allocation_record/{allocation_no}`;仅当记录关联的当前资产或店铺仍在代理范围内时返回安全活动 | 第一阶段不提供独立分配记录入口;企业从已授权卡/设备时间线查看允许感知的分配结论 |
| 换货列表/详情 `GET /api/admin/exchanges[/{id}]` | `items[].id/exchange_no/old_asset_type/old_asset_id/new_asset_type/new_asset_id/shop_id/submitter_id` | 换货单 `exchange_order/{id}`;旧、新资产分别使用响应中的类型和 ID | 换货单使用 `exchange_order/{exchange_no}`;后端按 `shop_id` 校验;旧、新资产仅在各自当前可管理时允许独立跳转 | 第一阶段不提供独立换货单入口;企业从有效授权资产时间线查看允许感知的换货结论 |
| 店铺列表/详情 `GET /api/admin/shops[/{id}]` | `items[].id/shop_name/shop_code/parent_id/business_owner_account_id` 或详情同名字段 | `shop/{id}` | `shop/{shop_code}`,仅自己及下级店铺 | 不提供店铺活动入口 |
| 企业列表 `GET /api/admin/enterprises` | `items[].id/enterprise_name/enterprise_code/owner_shop_id` | `enterprise/{id}` | `enterprise/{enterprise_code}`,仅 `owner_shop_id` 在代理范围内 | 第一阶段不提供企业自身活动入口;认证上下文 `enterprise_id` 仅用于卡/设备授权范围,不作为前端路径参数 |
| 企业卡列表 `GET /api/admin/enterprises/{id}/cards` | `items[].id``items[].iccid``items[].virtual_no``items[].device_id` | `iot_card/{id}` | 不作为代理授权来源 | `iot_card/{iccid}`;路由中的企业 ID 不能作为信任依据,必须使用认证上下文 `enterprise_id` 复核有效授权 |
| 企业设备列表 `GET /api/admin/enterprises/{id}/devices` | `items[].device_id``items[].virtual_no`;若后续注册详情路由,可使用 `device.device_id``cards[].card_id` | `device/{device_id}` | 不作为代理授权来源 | `device/{virtual_no}`;必须使用认证上下文 `enterprise_id` 复核有效授权 |
平台内部接口固定使用 Resource Registry 类型与内部稳定 ID`GET /api/admin/audit/resources/{resource_type}/{resource_id}/timeline`。代理和企业固定使用各自安全接口与 Registry 声明的业务标识:`GET /api/admin/{agent|enterprise}/resource-activities/{resource_type}/{identifier}`。前端只负责透传上表字段,资源解析、身份范围、当前归属和有效授权全部由后端判断。
第一阶段首批 Resource Registry 类型至少包括 `account``shop``enterprise``iot_card``device``device_sim_binding``asset_allocation_record``exchange_order``order``refund``agent_recharge``asset_wallet``approval_instance`。其中统一资产接口返回的 `card` 只在跳转层转换为 `iot_card`;分配记录和换货接口已经返回 `iot_card/device`,不得再次错误转换。
当前资产分配列表已按关联店铺过滤,但详情 `GetByID` 仍是直接按主键读取。新的平台时间线和代理安全活动 Query 不得把“能够打开现有详情”当作归属证明,必须独立查询分配记录关联店铺、资产当前归属及调用方范围;越权与不存在继续使用统一错误,不泄露记录是否存在。
#### 8.2 账号、组织、交易和资金页面的导航映射
下表冻结平台页面的第一跳。资源页签统一使用 `GET /api/admin/audit/resources/{resource_type}/{resource_id}/timeline`;资金页签统一使用 `GET /api/admin/audit/finance/timeline`。同一页面可以同时展示“审计记录”和“资金链路”,但只传当前业务接口实际返回的字段,其他关联由 Query 解析。
| 页面/前置接口 | `response.data` 字段 | 页面入口与目标调用 |
|---|---|---|
| 账号列表/详情 `GET /api/admin/accounts[/{id}]` | 列表 `items[].id`;详情 `id` | “审计记录”→资源时间线,`resource_type=account``resource_id=id` |
| 店铺列表/详情 `GET /api/admin/shops[/{id}]` | 列表 `items[].id`;详情 `id` | “审计记录”→`shop/{id}`;“资金链路”→`finance/timeline?shop_id={id}` |
| 企业列表 `GET /api/admin/enterprises` | `items[].id` | “审计记录”→`enterprise/{id}`;当前没有企业 GET 详情接口,前端不得假设存在 |
| 订单列表/详情 `GET /api/admin/orders[/{id}]` | `items[].id/order_no` 或详情 `id/order_no` | “审计记录”→`order/{id}`;“资金链路”→`finance/timeline?order_id={id}`,无需再传 payment/refund ID |
| 退款列表/详情 `GET /api/admin/refunds[/{id}]` | `items[].id/refund_no/order_id/approval_instance_id` 或详情同名字段 | “审计记录”→`refund/{id}`;“资金链路”→`finance/timeline?refund_id={id}`;审批实例非空时可跳 `approval_instance/{approval_instance_id}` |
| 代理充值列表/详情 `GET /api/admin/agent-recharges[/{id}]` | `items[].id/recharge_no/shop_id/agent_wallet_id/approval_instance_id` 或详情同名字段 | “审计记录”→`agent_recharge/{id}`;“资金链路”→`finance/timeline?recharge_id={id}`;详情没有 `payment_no` 时由服务端关联支付,不要求前端猜测 |
| 代理在线充值创建 `POST /api/admin/agent-recharges` | `recharge_id``recharge_no``payment_no` | 结果页“资金链路”→`finance/timeline?recharge_id={recharge_id}``payment_no` 仅作为额外精确筛选,不直接假设存在 Integration 详情 |
| 资产钱包 `GET /api/admin/assets/{identifier}/wallet` | `wallet_id``resource_type``resource_id` | “资金链路”→`finance/timeline?wallet_id={wallet_id}`;“资产审计”→资源时间线 `{resource_type}/{resource_id}`,其中现有 `iot_card/device` 可直接透传 |
| 店铺资金概况 `GET /api/admin/shops/fund-summary` | `items[].shop_id` | 行内“资金链路”→`finance/timeline?shop_id={shop_id}`;不得要求该响应没有提供的 agent wallet ID |
| 店铺主钱包流水 `GET /api/admin/shops/{shop_id}/main-wallet/transactions` | 上层 path `shop_id``items[].id/asset_type/asset_id/asset_identifier` | 默认→`finance/timeline?shop_id={shop_id}`;存在资产 ID 时可跳资源时间线 `{asset_type}/{asset_id}` |
| 资产钱包流水 `GET /api/admin/assets/{identifier}/wallet/transactions` | 上层钱包接口的 `wallet_id``items[].id/reference_type/reference_no` | 默认→`finance/timeline?wallet_id={wallet_id}`;业务引用仅在 Query/节点返回明确资源引用后继续跳转,不由前端解析编号前缀 |
第一阶段资金 Query 必须允许只凭订单、退款、充值、钱包或店铺中的任一稳定条件进入,不得要求前端先取得所有关联 ID。当前微信订单支付响应、代理充值详情/支付状态和店铺资金概况均缺少部分支付或钱包标识,关联补全属于服务端 Query 职责。
#### 8.3 调查结果节点的统一跳转引用
平台事件列表、资源/actor/request/correlation/finance/risk 时间线及 Integration 关联节点必须返回同一 `investigation_refs` 结构;字段没有事实依据时为 `null` 或空数组,不得猜测:
```json
{
"event_id": "evt_xxx",
"actor_ref": {"kind": "account", "id": "123"},
"resource_refs": [
{"resource_type": "iot_card", "resource_id": "321", "resource_key": "8986...", "display_name": "8986..."}
],
"request_id": "req_xxx",
"correlation_id": "corr_xxx",
"integration_refs": [{"integration_id": "int_xxx"}]
}
```
前端映射固定为:`event_id`→事件详情;`actor_ref.kind/id`→操作者视角;`resource_refs[].resource_type/resource_id`→平台资源时间线;`request_id`→请求时间线;`correlation_id`→业务链路;`integration_refs[].integration_id`→Integration 详情。代理/企业活动 DTO 不得返回该内部结构;允许继续查看的关联资源只返回安全的 `resource_type/identifier/display_name`
`actor_ref.kind` 第一阶段冻结为 `account``openapi``system_task``scheduled_job``external_system`;人工平台、代理和企业后台账号统一使用 `account`,账号类型由事件快照展示,不把可变用户类型拼进路径。
通知必须先调用既有 `GET /api/admin/notifications/{id}/target`。仅当 `data.available=true` 时展示跳转;`target_type=integration_log` 时将 `data.target_key` 原样传给 `GET /api/admin/audit/integrations/{integration_id}`,其他 `target_type` 先进入对应业务详情页,再由该页面按本节矩阵进入审计,禁止直接解析通知 `ref_type/ref_id/ref_key` 拼审计 URL。
#### 8.4 导航降级规则
- 业务响应缺少目标接口必需的稳定 ID/identifier 时隐藏该审计或活动入口,不按名称、时间、中文描述或编号前缀猜测。
- 平台只有 Resource Registry 稳定 Key 而没有内部 ID 时,先调用 `GET /api/admin/audit/resources/search?resource_type=...&keyword=...`;唯一命中后使用结果的稳定 ID零命中或多命中停留搜索结果不自动选择。
- 已删除资源只要调查节点仍有稳定 `resource_type/resource_id`,平台仍可打开历史时间线;普通业务详情已不存在不影响审计快照。
- 代理/企业遇到资源不存在、授权撤销或越权时统一显示活动不可用,不回退到平台调查、资源搜索或旧 operation log。
- 旧 operation log 只由平台既有独立旧历史入口访问;任何旧记录都不跳转或拼接到新 `/api/admin/audit/*`
### 9. 代理/企业使用独立资源活动投影
代理/企业不得访问 `/api/admin/audit/*`,也不得复用内部 DTO。第一阶段冻结两个 GET 契约:代理使用 `GET /api/admin/agent/resource-activities/{resource_type}/{identifier}`,企业使用 `GET /api/admin/enterprise/resource-activities/{resource_type}/{identifier}``resource_type/identifier` 来自代理或企业当前卡/设备等业务详情页,或其已有受权资源列表的“活动记录”跳转,不能由前端传入 shop/enterprise 身份冒充范围;`page/page_size` 来自活动列表分页控件。调用方身份、代理店铺范围和企业 ID 由认证上下文注入。响应资源摘要包含 `resource_type/resource_id/resource_key/display_name`,活动项只包含外部动作编码/中文名、`subject_summary`、Registry 白名单约束的 `subject_data`、结果、发生时间和允许公开的关联资源摘要。
资源活动 API 先验证资源归属,再读取 Event Resource 中的主体投影:代理按自身及下级店铺范围;企业按当前有效卡/设备授权关系,不能因通用店铺过滤缺少上下文而退化为全量。
响应只包含动作的外部名称、发生时间、结果、`subject_summary` 和写入时生成的 `subject_data`。平台操作者、内部原因/备注、IP、内部 before/after、Audit Event ID、Integration Log、风险规则和内部因果不可见`internal_only` 事件不泄露其存在。自身操作或业务允许公开的变化可使用 `subject_detail`,平台内部处理通常使用 `subject_result`。Query 不得读取内部 before/after 后临时删字段生成 `subject_data`
### 10. Integration Log 复用现表并补查询契约
现有 `tb_integration_log` 和 Repository 保留,不新建第二套 Integration 表。第一阶段不增加多资源子表:一次外呼保存直接主资源,其他资源经关联 Audit Event 展开;只有未来出现无法通过 Audit Event 关联的真实多资源外呼需求才重新评审。
查询返回原始 result、中文名和派生类别`pending→processing``success→succeeded``unknown→indeterminate``failed/not_found/invalid_payload/conflict→failed``ignored/merged/rate_limited/completed/cancelled→not_sent``completed` 是未请求上游且业务已达预期,不能伪装为 success。
有稳定 `trigger_series` 时,详情按 attempt 和时间返回完整尝试序列;没有 series 时只展示单次交互,不按同资源或 correlation 猜测重试。`correlation_id` 表示业务链路,不表示技术重试。新可重试/分阶段外呼必须传播稳定 series 和单调 attemptHTTP→Application→Outbox→Worker→Integration 必须传播 correlation。
现有字段增量修正correlation 长度与 Outbox/审批统一至少 100成功解析资源后补真实 `resource_id`;企微审批回调不再把 application ID 伪装为审批资源;允许调用方提供经筛选、有长度上限的可读业务错误摘要,原始第三方错误正文仍不入库。历史 Integration JSON 在返回前使用字段白名单和同一凭据删除器重新清理,禁止直接透传旧 `request_summary/response_summary/metadata`;这属于安全删除,不是业务字段展示脱敏。
组合查询补最小 B-tree 索引:`(result, created_at, id)``(provider, created_at, id)`、非空 `external_id``audit_event_id``trigger_series+attempt``correlation_id+created_at`。第一阶段不给 JSONB 建 GIN不支持任意摘要全文搜索overview 强制时间范围,防止高频轮询历史全表聚合。
### 11. 每日冷归档与月初受控清理
归档复用现有 S3 兼容对象存储、Worker 和 Asynq Scheduler不增加依赖。时间边界统一使用 `Asia/Shanghai`:每日任务处理前一完整自然日 `[00:00:00, 次日 00:00:00)`,月初任务先完成上月最后一天归档,再处理上一个完整自然月。业务写入不等待对象存储;对象存储故障只让归档重试并阻止清理,不影响新的 Audit Event 或 Integration Log 正常落库。
| 数据源 | 每日归档内容 | 月初清理 | 清理后在线查询 |
|---|---|---|---|
| Audit Event + Event Resource | 按 `created_at` 输出完整事件及其全部资源关系、快照和前后数据 | 两表作为一个事实集合从 PostgreSQL 物理删除,禁止软删除、只删主表或只删资源表 | 不支持,仅保留对象存储冷归档 |
| Integration Log | 按 `created_at` 输出结构化持久化事实;月初对上月可变记录做最终快照复核 | 归档最终版本校验成功后从 PostgreSQL 物理删除 | 不支持,仅保留对象存储冷归档 |
| Access Log | N/A继续写应用服务器本地文件并使用现有 Lumberjack 轮转与保留策略,不上传对象存储 | N/A不涉及 PostgreSQL | 继续供开发在应用服务器排障,不接入审计 API |
| Domain Ledger | N/A订单、支付、退款、钱包流水等继续按各领域留存 | 不清理 | 继续作为业务权威事实 |
| Outbox/Asynq/手动轮询 | N/A沿用各自生命周期 | 不随审计归档清理 | 继续按现有查询能力使用 |
| 旧 operation log | N/A不迁移、不纳入新归档 | 不清理 | 继续使用独立旧历史入口 |
归档格式固定为 UTF-8 `JSON Lines + gzip`。Audit 每行包含一个事件及其完整 `resources[]`避免恢复或人工检查时主从分片错配Integration 每行对应一条结构化持久化记录。对象 Key 使用版本化稳定前缀,例如 `audit-archive/v1/2026/07/28/audit-events-2026-07-28.jsonl.gz``integration-logs-2026-07-28-r{revision}.jsonl.gz`,不得静默覆盖内容不同的对象。本 Change 不配置删除这些归档对象的生命周期规则,所有月份备份长期保留。
每个对象同时保存 manifest至少包含 `schema_version``source``archive_date``timezone``range_start/range_end``instance_id`、事件/资源或记录数量、未压缩与压缩字节数、对象 Key、SHA-256、revision、生成时间和最终状态。PostgreSQL 新增轻量 `tb_log_archive_run` 作为任务 ledger`source + archive_date + instance_id + schema_version` 唯一,记录 manifest/object Key、数量、hash、状态、尝试次数、完成与清理时间它不保存日志正文也不作为 Audit Event 或 Domain Ledger。
每日任务通过该唯一键和 Asynq 唯一任务保持幂等。相同输入重复执行时复用已通过校验的对象;数据库计数、内容 hash 或对象 metadata 不一致时生成新 revision 并保留旧对象不允许覆盖后宣称成功。Integration Log 允许完成状态等字段在创建日后更新,因此月初清理门禁必须基于数据库当前最终内容重新计算;存在变更时先生成新的最终 revision再更新 manifest。仍处于可变状态且无法形成最终快照的记录会阻止该月清理不得先删后补。
月初清理必须同时满足:上月每个自然日的 Audit 和 Integration 均有成功 manifestAudit Event 数量与归档事件数一致、Event Resource 数量与归档资源总数一致Integration 最终 revision 与数据库当前内容一致对象存在且大小、SHA-256、时间范围和记录数一致没有 `pending/failed` 归档任务。任一条件失败时整月不清理,记录 critical 日志和指标并由后续调度重试。门禁通过后Retention Worker 使用 GORM 和 `created_at` 索引,以有界批次按 Event Resource、Audit Event、Integration Log 顺序执行数据库物理 `DELETE`;任务中断后从 ledger 继续不重复删除窗口外数据。Audit Event、Event Resource 和 Integration Log 均不得通过 `deleted_at`、状态字段或归档标记模拟删除,也不得把上月数据迁移到另一张 PostgreSQL 历史表。第一阶段不改造现有 Integration 表为分区表只有批量物理删除、VACUUM 或锁等待指标证明不能满足窗口后再评审月分区。
清理属于内部数据留存策略,不是业务删除、更正事件或用户审计导出。清理任务以 `retention_worker` 系统身份写入当月 Audit Event记录清理月份、各数据源数量、manifest Key、结果和失败摘要禁止写回被清理月份。业务 Repository、Handler 和调查 Query 仍无 Update/Delete 能力,也不存在归档下载、跨对象存储查询或恢复接口。
在线调查 DTO 必须返回 `retention{online_from, archived_before, timezone}`。默认查询仅覆盖 PostgreSQL 在线窗口;请求的显式时间范围全部早于 `online_from` 时返回稳定“数据已归档、第一阶段不支持在线查询”错误跨越边界时拒绝并要求缩小到在线窗口不能返回看似完整的空列表或部分时间线。ID-only 详情在在线库不存在时仍按资源不存在处理,同时页面固定展示当前 `online_from`,不得尝试扫描对象存储。
### 12. 旧日志只停写,不迁移到新审计中心
1. **Expand**先创建新表、Registry、Writer、统一 Query 和主体活动接口;旧 Writer 继续服务未迁移用例。
2. **Migrate**:按完整纵向用例迁移调用方,每个切片同时完成资源快照、成功/失败/拒绝和查询可见性核对;同一用例不得长期双写。
3. **Contract**:生产装配和静态清单证明旧账号/资产 Writer 调用归零后停止旧表新增;旧表原样保留,不删除、不在线回填、不转换、不进入统一 Query。
新审计中心的时间范围和完整性从切换点开始。旧账号/资产历史仍由原表和既有独立查询能力承担;不为兼容新 DTO 解析中文描述、补造资源、结果、风险或 correlation。现有旧资产 operation-log 接口可收缩为平台历史入口,代理/企业改用新的安全资源活动接口,但该旧接口不会并入 `/api/admin/audit/*`
现有资产 operation-log API 先限制为平台身份并标记兼容,代理/企业改用安全 activities统一资源时间线稳定后再收缩旧入口。手动轮询表继续作为可变任务运行事实手动触发/取消另写 Audit Event不为停旧 operation log 而删除轮询进度能力。
### 13. 不可变性、性能与验证策略
本 Change 明确禁止新增、修改或运行任何自动化测试也不创建测试任务。后续实现不得以补充单元、集成、HTTP 或端到端测试为由扩大改动范围。
Audit 业务 Repository 只有 Append/Read应用运行账号不获得审计 Update/Delete 路径Retention Worker 使用独立最小权限删除入口。后续业务修正通过新的业务动作产生新事件并以 correlation/parent 关联原链路,第一阶段不提供专用 `audit_event.corrected` 写接口。`content_hash` 基于清理、标准化后的主事件和资源内容计算,不包含数据库自增 ID。单个 JSON 字段设置有界大小,超限保存截断标志、原字节数和摘要;批量明细进入子事件或原业务任务表。
主要索引覆盖事件时间、actor、action/result/risk、request、correlation、parent、scope以及资源 type+id/key+event time。Query 先分页事件 ID 再批量加载资源和必要业务投影,禁止逐事件 N+1公共关键词只查有索引的编码、名称和 Key不对 JSONB 全表模糊搜索。目标仍为数据库查询 <50ms、API P95<200ms、P99<500ms。
按用户确认,本 Change 的完成证据仅使用代码格式化、LSP/静态扫描、Registry 与覆盖基线比对、数据库结构和抽样数据核对、迁移检查、EXPLAIN/性能观测、接口人工调用、业务页面跳转核对、构建以及 OpenAPI 两条生成路径。
## Risks / Trade-offs
- **[第一阶段所有平台账号均可查看完整业务审计数据]** → 后端至少限制为平台身份,安全凭据不入库;细粒度权限码、敏感查看和导出待权限体系稳定后另开 Change。
- **[“所有非查询操作”产生较大数据量]** → 低风险事件降低默认展示优先级,批量使用根+子事件JSON 有界;每日压缩冷归档、月初校验后批量清理上月,分区只在批量清理指标证明必要时再评审。
- **[关键成功审计同事务增加延迟和可用性耦合]** → 只做同库顺序 Append 和必要索引,禁止同步外部调用;这是资金与高风险操作可追责性的必要代价。
- **[新审计中心无法查询切换前历史]** → 这是明确边界:旧表原样保留并使用既有独立能力查阅,不回填、不转换;新查询从切换点起提供完整事实。
- **[correlation/series 历史传播不完整]** → 历史按已有字段展示,新用例强制传播;不得用相似资源或时间邻近猜测链路。
- **[代理/企业活动投影可能泄露内部事件存在性]** → 使用独立 Query/DTO 和资源关系上的主体快照;`internal_only` 不返回占位或计数。
- **[资源当前归属与事件时归属不同]** → 身份快照用于历史解释,访问授权以查询时有效资源归属为准;越权时不泄露资源或事件存在性。
- **[提案领域盘点仍可能随并行开发变化]** → 实施第一任务重新生成当前候选清单并逐入口评审,新增写入口未登记时门禁失败。
- **[对象存储损坏或少归档一日会造成不可恢复删除]** → manifest、行数、资源数、对象存在性和 SHA-256 是清理硬门禁;任何一天、实例或数据源失败都阻止整月清理并告警。
- **[月初清理后无法通过接口调查上月]** → 这是已确认的产品边界:接口固定展示在线窗口并拒绝归档范围查询;对象存储只用于冷留存,第一阶段不建设在线读取或恢复能力。
## Migration Plan
1. 冻结当前写入口与领域/资源清单,业务、研发、安全确认 Action/Resource Registry、N/A 理由和平台/主体可见性。
2. 增量创建 Audit Event/Resource、归档运行记录表与索引保留现有 Integration/旧 operation 表;迁移 down 只允许在尚未产生事实的环境使用。
3. 交付 Writer、Registry、安全清理、平台基础事件/资源 Query 和主体活动 Query以代表性配置、Outbox 人工操作、多卡设备和资金用例证明接缝;不创建旧日志历史 Adapter。
4. 按纵向切片迁移账号权限、店铺企业、资产/换货、套餐、订单支付退款充值、钱包佣金、审批配置、批量任务、通知轮询、Worker/Scheduler/Callback每片独立验证后停止该用例旧写。
5. 交付 Integration 调查及 request/correlation/finance/risk 组合 Query完成 OpenAPI/中文文档与前端契约;旧 operation log 不接入这些 Query。
6. 先在显式确认的隔离测试环境构造一个已结束的完整自然月以只归档不清理模式验证每日对象、manifest、重复任务和月度最终复核无需等待现实时间流逝。dry-run 通过后仅在该测试环境启用清理,验证目标月三张日志表归零且月前/月后哨兵、其他业务事实和对象归档不受影响;生产清理开关仍由发布决策单独启用。
7. 停机前核对旧 Writer 调用归零、Action 覆盖无空白、同事务失败回滚、主体隔离、凭据不落库、查询性能、归档完整性和历史样本;失败则不切换旧写或清理开关。
8. 切换生产组合根并启用旧写护栏。产生新 Audit Event 后,业务回滚只允许暂停异常生产者并前向修复;正常保留期清理由 Retention Worker 按已归档月份执行,不得恢复旧 Writer 制造分裂历史。
## Open Questions
无阻塞性产品问题。已确认月初清理后上月 Audit Event 与 Integration Log 只保留在对象存储,审计接口不再在线查询;细粒度平台权限、用户审计导出、对象存储历史查询/恢复、风险处置和自动恢复操作均不属于第一阶段。数据库月分区仅在有界批量清理无法满足指标时另行评审。