49 KiB
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. 四类事实保持分离,以稳定标识关联
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 ID;event 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 或空数组,不得猜测:
{
"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 和单调 attempt;HTTP→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 均有成功 manifest;Audit 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. 旧日志只停写,不迁移到新审计中心
- Expand:先创建新表、Registry、Writer、统一 Query 和主体活动接口;旧 Writer 继续服务未迁移用例。
- Migrate:按完整纵向用例迁移调用方,每个切片同时完成资源快照、成功/失败/拒绝和查询可见性核对;同一用例不得长期双写。
- 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
- 冻结当前写入口与领域/资源清单,业务、研发、安全确认 Action/Resource Registry、N/A 理由和平台/主体可见性。
- 增量创建 Audit Event/Resource、归档运行记录表与索引,保留现有 Integration/旧 operation 表;迁移 down 只允许在尚未产生事实的环境使用。
- 交付 Writer、Registry、安全清理、平台基础事件/资源 Query 和主体活动 Query,以代表性配置、Outbox 人工操作、多卡设备和资金用例证明接缝;不创建旧日志历史 Adapter。
- 按纵向切片迁移账号权限、店铺企业、资产/换货、套餐、订单支付退款充值、钱包佣金、审批配置、批量任务、通知轮询、Worker/Scheduler/Callback;每片独立验证后停止该用例旧写。
- 交付 Integration 调查及 request/correlation/finance/risk 组合 Query,完成 OpenAPI/中文文档与前端契约;旧 operation log 不接入这些 Query。
- 先在显式确认的隔离测试环境构造一个已结束的完整自然月,以只归档不清理模式验证每日对象、manifest、重复任务和月度最终复核;无需等待现实时间流逝。dry-run 通过后仅在该测试环境启用清理,验证目标月三张日志表归零且月前/月后哨兵、其他业务事实和对象归档不受影响;生产清理开关仍由发布决策单独启用。
- 停机前核对旧 Writer 调用归零、Action 覆盖无空白、同事务失败回滚、主体隔离、凭据不落库、查询性能、归档完整性和历史样本;失败则不切换旧写或清理开关。
- 切换生产组合根并启用旧写护栏。产生新 Audit Event 后,业务回滚只允许暂停异常生产者并前向修复;正常保留期清理由 Retention Worker 按已归档月份执行,不得恢复旧 Writer 制造分裂历史。
Open Questions
无阻塞性产品问题。已确认月初清理后上月 Audit Event 与 Integration Log 只保留在对象存储,审计接口不再在线查询;细粒度平台权限、用户审计导出、对象存储历史查询/恢复、风险处置和自动恢复操作均不属于第一阶段。数据库月分区仅在有界批量清理无法满足指标时另行评审。