Compare commits
2 Commits
17782d5f8e
...
ff44305d0e
| Author | SHA1 | Date | |
|---|---|---|---|
| ff44305d0e | |||
| 7e0171a8b4 |
@@ -18,6 +18,8 @@ Access Log 当前只递归脱敏请求体,响应体仍原样记录,登录 To
|
||||
|
||||
本次切换覆盖全部现有敏感写操作:新旧业务统一使用 Audit Writer,旧账号、资产和手动轮询审计表停止新增,不双写;历史数据保留原表并通过 Query 只读投影到新审计中心。切换准备或验证失败则本次版本整体不放量,不能以局部模块继续写旧表作为中间态。
|
||||
|
||||
“全部现有敏感写操作”以仓库级《审计覆盖基线》为可验收证据,不以七月迭代需求清单代替。基线必须覆盖所有状态变更、敏感读取、关键拒绝和失败入口,并对 Audit Event、Domain Ledger、Integration Log、Outbox 或 N/A 作出逐入口决定。
|
||||
|
||||
## User Stories
|
||||
|
||||
1. 作为审计人员,我希望回答谁在什么入口对哪些资源做了什么,结果、风险和前后变化是什么。
|
||||
@@ -53,6 +55,7 @@ Access Log 当前只递归脱敏请求体,响应体仍原样记录,登录 To
|
||||
|
||||
### 动作注册表与写入可靠性
|
||||
|
||||
- 实现 Writer 前先完成全仓审计面盘点。每个 Handler、Application、Service、Worker、定时任务和回调入口都必须登记业务所有者、动作、资源、事务边界、失败策略和确认测试;N/A 必须写明理由并经评审。
|
||||
- 建立 Action Registry,定义稳定动作编码、中文名称、类别、默认风险、允许的资源类型和敏感字段规则;DTO 枚举说明、筛选项和前端名称都从同一注册表生成。
|
||||
- 至少覆盖账号/角色/权限、店铺、资产、套餐、钱包/资金、订单/退款/充值、企微、支付与系统配置、数据同步、导入导出及登录安全的本期动作。未经注册的动作不得写入生产审计。
|
||||
- 钱包余额、人工退款结果、代理钱包回退、线下充值入账、账号角色/权限、支付/企微/关键系统配置、人工卡状态、敏感店铺业务员归属及手工绑定企微审批号等成功事件必须与业务变更同事务 `AppendWithTx`;审计失败则业务事务回滚。
|
||||
@@ -61,6 +64,7 @@ Access Log 当前只递归脱敏请求体,响应体仍原样记录,登录 To
|
||||
- 异步系统事件与状态变化通过原业务事务或 Outbox 可靠关联,不使用 `go func()`。Asynq/Outbox 载荷必须传递 `event_id/request_id/correlation_id/parent_event_id`。
|
||||
- `request_id` 由现有中间件生成并贯穿同一 HTTP 请求;`correlation_id` 在退款、充值、审批、钱包、卡状态等跨请求业务起点生成并贯穿 Outbox/Asynq/Integration Log;`parent_event_id` 表示直接因果,不用于替代 correlation。
|
||||
- 对全部旧审计调用建立切换清单和自动检查。发布产物中禁止继续调用旧 `account_audit/asset_audit` 写服务或直接 Create 旧日志模型;启动装配不再注入旧 Writer。
|
||||
- 系统配置更新和 Outbox 人工恢复必须在各自 Application 组合根注入正式 Audit Writer:业务事实与审计同事务,未装配或审计失败时失败关闭;不得回退到空 Adapter、临时表或裸 goroutine。
|
||||
|
||||
### Integration Log
|
||||
|
||||
@@ -131,6 +135,8 @@ Access Log 当前只递归脱敏请求体,响应体仍原样记录,登录 To
|
||||
- 领域/Application 测试覆盖事件不可变、多资源至少一个 primary、动作注册、风险默认值、内容哈希稳定、16KB 截断和禁止字段删除。
|
||||
- PostgreSQL 集成测试验证唯一/查询索引、关键业务与 Audit Event 同事务、审计写入失败回滚、失败/拒绝短事务、重复事件幂等和资源时间线。
|
||||
- 对账号、角色权限、资产、套餐、钱包、退款、充值、配置、导入导出、登录安全和手动同步建立切换清单;自动测试或静态检查证明生产装配不再调用旧 Writer,旧三表发布后无新增。
|
||||
- 覆盖基线测试或静态检查证明仓库内全部状态变更、敏感读取、关键拒绝和失败入口均已分类;新增敏感入口未登记、动作未注册或 N/A 缺少理由时门禁失败。
|
||||
- 公共基础 Adapter 集成测试覆盖系统配置更新和 Outbox 人工恢复的成功同事务、审计失败整体回滚、敏感值不落库、恢复原因与批次留痕以及生产组合根装配。
|
||||
- 历史投影测试使用旧账号/资产/手动轮询样本,验证 `UNION ALL` 的字段映射、确定性历史键、分页排序、`record_source` 和新旧交界时间无重复/漏项。
|
||||
- Integration 测试覆盖 outbound 成功/失败/响应未知、inbound 回调、未发送的 merged/rate_limited/completed、同序列 0/3/5、状态变化关联 Audit Event 和重复回调。
|
||||
- Access Log 测试覆盖嵌套 JSON、数组、非 JSON、登录/Token、支付/企微/运营商回调、上传下载和响应体,证明 Token、Secret、操作密码、签名 URL、Authorization、Cookie 等不落盘且 50KB 生效。
|
||||
@@ -154,6 +160,7 @@ Access Log 当前只递归脱敏请求体,响应体仍原样记录,登录 To
|
||||
## Further Notes
|
||||
|
||||
- 用户已明确确认一次性全局切换,覆盖标准稿中的“旧写停止、不双写、历史只读投影”口径;这不是可由实现阶段改回渐进双写的建议项。
|
||||
- 全系统覆盖由 `00-full-audit-surface-inventory.md` 建立基线,系统配置由 `20-system-config-audit-adapter.md` 接入,Outbox 恢复由 `21-outbox-recovery-audit-adapter.md` 接入,最终统一由 19 号票验收;后续业务 PRD 也必须增量维护该基线,不能把“公共审计已完成”理解为新业务自动获得审计。
|
||||
- 当前已核实旧账号/资产审计使用裸 goroutine,Access Log 响应体未脱敏;这两项是发布前必须消除的现存缺陷。
|
||||
- 当前手动轮询日志兼做进度存储,切断旧表时必须先由公共异步任务状态承接,不得违反 UR#94“轮询管理外部行为保持现状”的确认结论。
|
||||
- 本需求较大,进入实现前应依据本 Spec 拆成可独立验证的纵向切片,但不得按“先建表、再 Service、再 Handler”的水平层级拆分,也不得改变一次停机切换这一最终发布门禁。
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
# 00 — 建立全系统审计面与公共能力决策基线
|
||||
|
||||
**What to build:** 对整个现有系统而非仅七月迭代执行一次可复核的审计面盘点,形成动作注册表输入和机器可检查的覆盖基线。每个状态变更、敏感读取、关键拒绝或失败入口都必须明确 Audit Event、Domain Ledger、Integration Log、Outbox 的使用决策或给出不适用理由,后续需求以此基线增量维护。
|
||||
|
||||
**Blocked by:** None — can start immediately
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
**架构通道:** 主通道为 Infrastructure 治理,辅助通道为 Query 验证。
|
||||
|
||||
**完整业务边界:** 本票收口 Handler、Application、Domain、Service、Worker 和回调入口的全仓审计分类、动作编码、事务策略、资源关系、失败策略和测试责任人。明确不迁移任何业务代码、不改变已评审业务规则、不把 Access Log、Audit Event、Domain Ledger 和 Integration Log 合并。
|
||||
|
||||
- [ ] 盘点全部 HTTP/Worker/定时任务/回调入口,至少覆盖创建、修改、删除、状态流转、资金、权限、关键配置、导入导出、人工恢复、敏感读取、拒绝和关键失败;记录代码入口与业务所有者。(自动扫描候选已生成,待三方确认不存在启发式漏项)
|
||||
- [ ] 每个入口明确 `Audit Event / Domain Ledger / Integration Log / Outbox / N/A` 决策;选择 N/A 必须写明原因,禁止空白或“以后处理”。(自动分类候选已生成,待三方逐入口评审确认)
|
||||
- [ ] 对需要审计的入口填写稳定动作编码、中文名称、风险、主要及受影响资源、操作者与来源、前后数据、事务边界、失败策略和自动化测试接缝。(结构已固定,待三方修正并确认业务语义)
|
||||
- [x] 建立可被静态检查或测试读取的动作注册与覆盖基线,未经登记的新敏感操作不能通过发布门禁。
|
||||
- [x] 对旧账号、资产、手动轮询 Writer 及全部直接写旧日志表的位置建立准确清单,作为 04~09 迁移票和 19 号切换票的输入。
|
||||
- [ ] 更新《审计覆盖基线》,经业务、研发和安全评审确认不存在未分类入口;评审只确认覆盖与分类,不借机扩大各业务 PRD 范围。
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
**What to build:** 业务用例可以通过统一 Audit Writer 写入不可变、多资源、可串联且默认脱敏的审计事件。成功事件能够与业务事实共用事务,失败或拒绝事件使用独立短事务保留;未注册动作、缺少主要资源或审计写入失败时按明确策略阻止错误事实落地。
|
||||
|
||||
**Blocked by:** `.scratch/tech-public-foundation/issues/01-public-migration-ownership-and-gates.md` — 01 — 建立公共迁移所有权与检查门禁
|
||||
**Blocked by:**
|
||||
|
||||
- 00 — 建立全系统审计面与公共能力决策基线
|
||||
- `.scratch/tech-public-foundation/issues/01-public-migration-ownership-and-gates.md` — 01 — 建立公共迁移所有权与检查门禁
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
|
||||
@@ -4,15 +4,15 @@
|
||||
|
||||
**Blocked by:** `.scratch/tech-public-foundation/issues/01-public-migration-ownership-and-gates.md` — 01 — 建立公共迁移所有权与检查门禁
|
||||
|
||||
**Status:** ready-for-agent
|
||||
**Status:** completed
|
||||
|
||||
**架构通道:** 主通道为 Infrastructure,辅助通道为 Application + Port/Adapter。
|
||||
|
||||
**完整业务边界:** 本票收口 Integration Log 模型、Writer、执行态到终态的条件更新、入站摘要和未发送结果语义。明确不接管 Gateway、支付、企微或运营商的业务状态机,不把外部交互记录作为业务权威状态。
|
||||
|
||||
- [ ] Integration Log 支持 provider、方向、operation、外部单号、资源、触发来源/场景/序列/尝试、计划与开始时间、结果、渠道摘要、脱敏请求响应摘要、耗时、状态变化标志和关联 ID。
|
||||
- [ ] 方向固定为 `inbound/outbound`;公开终态至少覆盖成功、失败、未找到、无效载荷、忽略、合并、限频、提前完成和取消,并返回对应中文名称。
|
||||
- [ ] 实际外部调用前持久化稳定尝试身份,完成后使用预期状态条件更新;并发完成、重复回调或重复消费不能改写既有终态。
|
||||
- [ ] 请求已发出但响应未知时记录明确的未知结论和恢复策略,不自动把具有副作用的请求当成普通失败盲目重发。
|
||||
- [ ] 入站回调先保存脱敏摘要、内容哈希和幂等标识;原始密文、完整正文、签名、附件和密钥不进入普通记录。
|
||||
- [ ] PostgreSQL 集成测试覆盖出站成功、明确失败、响应未知、未发送终态、入站回调、重复回调、条件更新冲突和 Audit Event 关联。
|
||||
- [x] Integration Log 支持 provider、方向、operation、外部单号、资源、触发来源/场景/序列/尝试、计划与开始时间、结果、渠道摘要、脱敏请求响应摘要、耗时、状态变化标志和关联 ID。
|
||||
- [x] 方向固定为 `inbound/outbound`;公开终态至少覆盖成功、失败、未找到、无效载荷、忽略、合并、限频、提前完成和取消,并返回对应中文名称。
|
||||
- [x] 实际外部调用前持久化稳定尝试身份,完成后使用预期状态条件更新;并发完成、重复回调或重复消费不能改写既有终态。
|
||||
- [x] 请求已发出但响应未知时记录明确的未知结论和恢复策略,不自动把具有副作用的请求当成普通失败盲目重发。
|
||||
- [x] 入站回调先保存脱敏摘要、内容哈希和幂等标识;原始密文、完整正文、签名、附件和密钥不进入普通记录。
|
||||
- [x] PostgreSQL 集成测试覆盖出站成功、明确失败、响应未知、未发送终态、入站回调、重复回调、条件更新冲突和 Audit Event 关联。
|
||||
|
||||
@@ -7,15 +7,15 @@
|
||||
- `.scratch/tech-public-foundation/issues/09-access-log-recursive-redaction.md` — 09 — 统一 Access Log 请求与响应递归脱敏
|
||||
- `.scratch/tech-public-foundation/issues/10-sensitive-route-safe-summaries.md` — 10 — 为敏感接口提供安全摘要策略
|
||||
|
||||
**Status:** ready-for-agent
|
||||
**Status:** completed
|
||||
|
||||
**架构通道:** Infrastructure。
|
||||
|
||||
**完整业务边界:** 本票将公共 Access Log 安全能力应用到当前仓库真实路由并建立固定回归矩阵。明确不修改业务响应,不创建审计事实,不把 Access Log 升级为业务权威存储。
|
||||
|
||||
- [ ] 登录、Token、支付与企微配置路由只记录字段存在性、长度和安全结果,不记录密码、验证码、Token、Secret、密钥或完整配置值。
|
||||
- [ ] 支付、企微和运营商回调只记录事件类型、安全资源标识、大小、内容类型、摘要哈希与处理结果,不记录密文、完整正文、签名或附件。
|
||||
- [ ] 上传、下载和导出路由不记录文件字节、Base64、multipart 正文、临时凭证或签名 URL,只保留脱敏文件元数据和任务标识。
|
||||
- [ ] Query 和 Header 覆盖 token、secret、sign、nonce、authorization、cookie 等大小写变体;请求和响应均先脱敏再执行 50KB 截断。
|
||||
- [ ] 非 JSON、XML、表单、二进制及解析失败场景均按路由策略安全降级,不能回退记录原文。
|
||||
- [ ] 真实 Fiber 测试捕获最终 Access Log,覆盖敏感路由矩阵,并断言测试凭证、操作密码、回调原文、签名 URL、Authorization 和 Cookie 均未落盘。
|
||||
- [x] 登录、Token、支付与企微配置路由只记录字段存在性、长度和安全结果,不记录密码、验证码、Token、Secret、密钥或完整配置值。
|
||||
- [x] 支付、企微和运营商回调只记录事件类型、安全资源标识、大小、内容类型、摘要哈希与处理结果,不记录密文、完整正文、签名或附件。
|
||||
- [x] 上传、下载和导出路由不记录文件字节、Base64、multipart 正文、临时凭证或签名 URL,只保留脱敏文件元数据和任务标识。
|
||||
- [x] Query 和 Header 覆盖 token、secret、sign、nonce、authorization、cookie 等大小写变体;请求和响应均先脱敏再执行 50KB 截断。
|
||||
- [x] 非 JSON、XML、表单、二进制及解析失败场景均按路由策略安全降级,不能回退记录原文。
|
||||
- [x] 真实 Fiber 测试捕获最终 Access Log,覆盖敏感路由矩阵,并断言测试凭证、操作密码、回调原文、签名 URL、Authorization 和 Cookie 均未落盘。
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
**Blocked by:**
|
||||
|
||||
- 00 — 建立全系统审计面与公共能力决策基线
|
||||
- 03 — 完成 Access Log 全路由敏感信息防泄漏
|
||||
- 04 — 迁移账号、角色与权限敏感操作到统一审计
|
||||
- 05 — 迁移卡资产生命周期操作到统一审计
|
||||
@@ -20,6 +21,8 @@
|
||||
- 16 — 交付审计导出与字段授权快照
|
||||
- 17 — 交付审计保留、清理和运行监控
|
||||
- 18 — 冻结审计中心跨仓前端契约与验收包
|
||||
- 20 — 接入公共系统配置正式审计 Adapter
|
||||
- 21 — 接入 Outbox 人工恢复正式审计 Adapter
|
||||
- `.scratch/tech-public-foundation/issues/12-foundation-release-gate-and-integration-contract.md` — 12 — 建立公共基础发布门禁和下游接入契约
|
||||
|
||||
**Status:** ready-for-agent
|
||||
@@ -30,6 +33,7 @@
|
||||
|
||||
- [ ] 停机顺序明确覆盖暂停流量与 Worker、前置检查、增量迁移、权限初始化、新 Writer 装配、旧写护栏、历史对账、Worker 恢复和开放流量。
|
||||
- [ ] 自动切换清单证明生产产物不再调用旧账号/资产审计 Writer、不再直接 Create 旧三表、启动装配不再注入旧 Writer;旧表意外新增会立即告警或失败。
|
||||
- [ ] 《审计覆盖基线》的全部入口均已分类且无空白项;所有 Audit Event 动作已注册,所有 N/A 均有评审理由,系统配置更新和 Outbox 人工恢复已装配正式 Audit Writer。
|
||||
- [ ] 门禁覆盖关键业务与审计同事务、审计失败回滚、失败短事务、Integration 结果未知、历史新旧交界、Access Log 敏感矩阵和权限隔离。
|
||||
- [ ] Query 性能验证常用过滤使用索引、无 JSONB 全表模糊扫描、无资源/操作者 N+1,并满足项目 P95/P99 目标。
|
||||
- [ ] 任一迁移、旧写清单、样本对账、事务、安全、权限、性能、监控或跨仓验收失败均在开放流量前整体终止发布,不能让局部模块继续写旧表。
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
# 20 — 接入公共系统配置正式审计 Adapter
|
||||
|
||||
**What to build:** 在系统配置更新的 Application 组合根注入正式 Audit Writer Adapter,使关键配置变更进入统一 Audit Event,并按公共基础已经确认的事务边界失败关闭。由真实全局审计替换公共基础的未装配占位,不创建临时审计表或旁路 goroutine。
|
||||
|
||||
**Blocked by:**
|
||||
|
||||
- 01 — 交付不可变 Audit Event 写入闭环
|
||||
- `.scratch/tech-public-foundation/issues/08-controlled-system-config-update.md` — 08 — 交付系统配置更新、权限和审计闭环
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
**架构通道:** 主通道为 Application + Port/Adapter,辅助通道为 Infrastructure。
|
||||
|
||||
**完整业务边界:** 本票仅收口公共系统配置更新既有用例的正式审计装配、事务一致性、脱敏和测试。明确不新增配置 Key、不建设配置前端页面、不迁移其他业务审计。
|
||||
|
||||
- [ ] 注册系统配置更新的稳定动作编码、中文名称、风险等级及允许资源类型,并写入全系统审计覆盖基线。
|
||||
- [ ] 系统配置更新的业务事实与 Audit Event 使用同一 GORM 事务;审计失败时配置更新回滚,敏感配置只记录“是否配置”和安全摘要,不记录原值。
|
||||
- [ ] 组合根显式注入正式 Audit Writer Adapter;未装配时失败关闭,禁止回退到空实现、旧审计表或裸 goroutine。
|
||||
- [ ] PostgreSQL 集成测试覆盖成功同事务、审计失败整体回滚、敏感值不落库和未装配失败关闭。
|
||||
- [ ] 发布检查能证明生产组合根已装配正式 Adapter,并由 19 号一次性切换门禁纳入停机演练。
|
||||
@@ -0,0 +1,20 @@
|
||||
# 21 — 接入 Outbox 人工恢复正式审计 Adapter
|
||||
|
||||
**What to build:** 在 Outbox 人工恢复的 Application 组合根注入正式 Audit Writer Adapter,使每次受控恢复完整记录操作者、原因、批次和状态变化,并与恢复事实同事务失败关闭。由真实全局审计替换公共基础的未装配占位,不创建临时审计表或旁路 goroutine。
|
||||
|
||||
**Blocked by:**
|
||||
|
||||
- 01 — 交付不可变 Audit Event 写入闭环
|
||||
- `.scratch/tech-public-foundation/issues/04-outbox-observability-and-recovery.md` — 04 — 提供 Outbox 监控和受控恢复能力
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
**架构通道:** 主通道为 Application + Port/Adapter,辅助通道为 Infrastructure。
|
||||
|
||||
**完整业务边界:** 本票仅收口 Outbox 人工恢复既有用例的正式审计装配、事务一致性和测试。明确不改变恢复资格与租约规则、不建设 Outbox 前端页面、不迁移其他业务审计。
|
||||
|
||||
- [ ] 注册 Outbox 人工恢复的稳定动作编码、中文名称、风险等级及允许资源类型,并写入全系统审计覆盖基线。
|
||||
- [ ] Outbox 状态变更与 Audit Event 使用同一 GORM 事务;记录操作者、中文原因、恢复批次、事件标识及恢复前后状态,审计失败时恢复回滚。
|
||||
- [ ] 组合根显式注入正式 Audit Writer Adapter;未装配时失败关闭,禁止回退到空实现、旧审计表或裸 goroutine。
|
||||
- [ ] PostgreSQL 集成测试覆盖成功同事务、审计失败整体回滚、重复恢复裁决、有效租约不可恢复和未装配失败关闭。
|
||||
- [ ] 发布检查能证明生产组合根已装配正式 Adapter,并由 19 号一次性切换门禁纳入停机演练。
|
||||
59
.scratch/tech-global-audit/审计覆盖基线.md
Normal file
59
.scratch/tech-global-audit/审计覆盖基线.md
Normal file
@@ -0,0 +1,59 @@
|
||||
# 全系统审计覆盖基线
|
||||
|
||||
状态:源码扫描与自动分类已完成,待业务、研发和安全三方评审确认
|
||||
|
||||
## 可复核制品
|
||||
|
||||
- 显式逐入口清单:[`审计覆盖清单.json`](审计覆盖清单.json),当前共 490 项。
|
||||
- 扫描范围:270 个 HTTP RouteSpec、24 个 Asynq Worker、4 个定时任务、3 个 Application 公共写入口、189 个旧 Service 公共写入口。
|
||||
- 生成入口:`go run ./cmd/audit-coverage`。
|
||||
- 发布门禁:`go test ./internal/governance/auditcoverage`。源码新增、删除或修改入口后,若未同步更新显式清单,测试必定失败。
|
||||
- 每项均固定代码入口、业务所有者、中文摘要以及待评审的 Audit Event / Domain Ledger / Integration Log / Outbox 分类候选,并预留动作、风险、资源、操作者来源、事务边界、失败策略、前后数据、敏感策略和确认测试接缝。
|
||||
- 当前 `Audit Event=N/A` 候选均填写逐入口理由;评审前不能据此宣称全系统分类已经确认。
|
||||
|
||||
生成器只负责产生待评审候选,不能自行代表评审通过。更新清单时必须核对业务语义,不能仅运行生成命令后直接提交。
|
||||
|
||||
## 分类边界
|
||||
|
||||
| 入口类型 | Audit Event | Domain Ledger | Integration Log | Outbox |
|
||||
|---|---|---|---|---|
|
||||
| 状态变更、资金、权限、关键配置 | 必须;关键成功与业务事实同事务 | 既有业务表仍是权威 | 存在外部调用时必须 | 存在提交后可靠副作用时必须 |
|
||||
| 失败、拒绝 | 业务回滚后独立短事务 | 不伪造领域事实 | 外部尝试仍记录真实结果 | 不为已回滚事实制造事件 |
|
||||
| 普通读取 | N/A,逐项记录理由 | 只读投影 | N/A | N/A |
|
||||
| 敏感读取、下载和导出 | 返回敏感结果前必须,自审计失败则不返回 | 业务数据仍是权威 | N/A | 异步导出按任务契约决定 |
|
||||
| 外部回调、轮询和 Gateway 调用 | 状态变化、人工触发、连续失败或高风险异常时必须 | 业务状态仍在领域表 | 每次实际或未发送尝试都必须 | 需要可靠后续处理时必须 |
|
||||
| Domain 方法 | 不直接依赖审计基础设施,由 Application 写入 | 维护业务不变量 | 由 Application/Adapter 负责 | 只记录领域事件,由 Application 持久化 |
|
||||
|
||||
## 旧 Writer 与旧表写入口清单
|
||||
|
||||
### 旧账号审计
|
||||
|
||||
- Writer 与 Store:`internal/service/account_audit/service.go`、`internal/store/postgres/account_operation_log_store.go`。
|
||||
- 生产装配:`internal/bootstrap/services.go`、`internal/bootstrap/stores.go`。
|
||||
- 调用模块:`internal/service/account/service.go`、`internal/service/agent_recharge/service.go`、`internal/service/shop_package_batch_allocation/service.go`、`internal/service/wechat_config/service.go`。
|
||||
- 已确认裸 goroutine 调用:`internal/service/agent_recharge/service.go` 1 处,`internal/service/wechat_config/service.go` 5 处;Writer 自身另启 goroutine。
|
||||
- 迁移责任:04、07、08;19 号票验证生产装配和直接旧表写入归零。
|
||||
|
||||
### 旧资产审计
|
||||
|
||||
- Writer、Builder 与 Store:`internal/service/asset_audit/`、`internal/store/postgres/asset_operation_log_store.go`。
|
||||
- API/Worker 装配:`internal/bootstrap/services.go`、`internal/bootstrap/worker_services.go`、`internal/bootstrap/stores.go`、`internal/bootstrap/worker_stores.go`。
|
||||
- 调用模块:`internal/service/asset/`、`internal/service/device/`、`internal/service/device_import/`、`internal/service/iot_card/`、`internal/service/iot_card_import/`、`internal/service/polling/asset_polling_service.go`。
|
||||
- 兼容读取:`internal/handler/admin/asset.go`、`internal/routes/asset.go`、`internal/model/dto/asset_operation_log_dto.go`;由 10 号票保留读取契约。
|
||||
- 迁移责任:05、06、09;19 号票验证生产装配和直接旧表写入归零。
|
||||
|
||||
### 旧手动轮询日志
|
||||
|
||||
- 状态与写入:`internal/service/polling/manual_trigger_service.go`、`internal/store/postgres/polling_manual_trigger_store.go`、`internal/model/polling.go`。
|
||||
- 装配与接口:`internal/bootstrap/services.go`、`internal/bootstrap/stores.go`、`internal/handler/admin/polling_manual_trigger.go`。
|
||||
- 当前仍同时承担运行状态和历史查询,不能提前停写;09 号票先切到公共异步任务与 Integration Log,10 号票提供历史投影,19 号票再启用旧写护栏。
|
||||
|
||||
## 评审门禁
|
||||
|
||||
以下确认尚未由本地代码执行替代,00 号票在三方评审前不得标记完成:
|
||||
|
||||
- 业务评审:逐入口业务所有者、资源、Domain Ledger 与 N/A 理由准确,没有改变已评审业务范围。
|
||||
- 研发评审:事务边界、失败策略、旧 Writer 清单、动作编码和测试接缝能由对应迁移票落地。
|
||||
- 安全评审:风险等级、敏感字段策略、拒绝/失败覆盖和外部正文摘要策略完整。
|
||||
|
||||
评审发现错误时应修改对应显式条目和生成分类规则,并重新运行覆盖门禁;禁止仅手改统计数字。
|
||||
11195
.scratch/tech-global-audit/审计覆盖清单.json
Normal file
11195
.scratch/tech-global-audit/审计覆盖清单.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -99,6 +99,11 @@ Status: ready-for-agent
|
||||
- 未来短信或企微消息使用独立 Delivery 消费同一业务事件;不得在 Notification Handler/Worker 写完站内消息后同步循环调用外部渠道。
|
||||
- 新增管理端和 C 端 Handler 后同步注册路由和两个 OpenAPI 文档生成器;公共通知能力先于 UR#33、UR#97 和企微结果通知启用。
|
||||
|
||||
## 公共能力发布依赖
|
||||
|
||||
- 实现阶段复用公共 Outbox 与异步任务,不自行复制表或状态机;最终 08 号发布票必须阻塞于公共基础 12 号票和全局审计 19 号票。
|
||||
- 每个下游通知生产者仍须登记事件类型、载荷版本、消费者幂等键、接收人、失败明细和通知策略;“通知基础完成”不代表业务生产者已经接入。
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
- Application/Worker 测试覆盖重复事件、多个接收人、接收人去重、停用/删除接收人、无接收人、模板字段缺失、Outbox/Asynq 重试和过期时间。
|
||||
|
||||
@@ -10,6 +10,8 @@
|
||||
- 05 — 交付通知受控目标解析与权限复核
|
||||
- 06 — 交付通知保留清理与失败可观测闭环
|
||||
- 07 — 冻结后台与 C 端通知前端契约及验收包
|
||||
- `.scratch/tech-public-foundation/issues/12-foundation-release-gate-and-integration-contract.md` — 12 — 建立公共基础发布门禁和下游接入契约
|
||||
- `.scratch/tech-global-audit/issues/19-one-time-audit-cutover-gate.md` — 19 — 执行一次性审计切换与停机发布门禁
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
|
||||
@@ -208,6 +208,12 @@ Status: ready-for-agent
|
||||
- 列表和详情按 `approval_source` 展示:`none` 审批列为“-”;`wecom` 展示只读企微状态;`legacy` 展示“历史审批”且无操作按钮。代理界面不展示审批人列或内部意见。
|
||||
- 加载、空、网络失败、支付方式为空、支付创建失败、支付已成功但入账失败、企微提交中/失败、审批拒绝和异常撤销均有独立展示,不由前端自行推导或改写服务端状态。
|
||||
|
||||
## 公共能力发布依赖
|
||||
|
||||
- 最终发布票必须明确阻塞于公共基础 12 号票、全局审计 19 号票、公共通知 08 号票、UR#37 企微发布 14 号票和 UR#38 钱包切换 10 号票。
|
||||
- 充值事件必须登记载荷版本、消费者幂等键、失败明细和通知策略;资金结论以 Domain Ledger 为准,Audit Event 记录操作者与前后变化,企微/支付外部事实进入 Integration Log。
|
||||
- 当前 PRD 尚未拆票;生成 tickets 时必须把上述依赖落到最终发布门禁,不得只写“依赖公共基础”。
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
### 最高公共测试接缝
|
||||
|
||||
@@ -203,6 +203,11 @@ Status: ready-for-agent
|
||||
- 回滚时先关闭前端入口和任务创建,等待或人工处置已领取任务,再回滚应用。已经创建的标准订单、钱包流水、套餐使用、任务和审计均作为业务事实保留,不做反向删除。
|
||||
- 不在仍有待处理或处理中任务时删除新表或上传用途;数据库降级迁移不是常规应用回滚步骤。
|
||||
|
||||
## 公共能力发布依赖
|
||||
|
||||
- 最终 11 号发布票阻塞于公共基础 12 号票、全局审计 19 号票和 UR#38 钱包切换 10 号票;公共能力未完成不阻止独立切片开发,但阻止生产放量。
|
||||
- 批量任务必须登记事件类型、载荷版本、Worker 幂等键和逐行失败明细;订单与钱包仍以各自 Domain Ledger 为权威,任务创建、终态及人工操作进入 Audit Event。
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
### 可复用 Agent 集成测试规范与工具
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
|
||||
- `.scratch/ur36-bulk-package-purchase/issues/03-real-dependency-integration-harness.md` — 03 — 建立批量任务真实依赖的集成测试 Harness
|
||||
- `.scratch/ur36-bulk-package-purchase/issues/10-bulk-purchase-task-queries.md` — 10 — 提供任务汇总与逐行明细查询
|
||||
- `.scratch/tech-public-foundation/issues/12-foundation-release-gate-and-integration-contract.md` — 12 — 建立公共基础发布门禁和下游接入契约
|
||||
- `.scratch/tech-global-audit/issues/19-one-time-audit-cutover-gate.md` — 19 — 执行一次性审计切换与停机发布门禁
|
||||
- `.scratch/ur38-agent-main-wallet-credit/issues/10-credit-wallet-cutover-gate.md` — 10 — 完成信用钱包停机切换与发布门禁
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
|
||||
@@ -214,6 +214,11 @@ Status: ready-for-agent
|
||||
- 已结束的历史本地审批保留为 `legacy` 只读事实,不伪造企微实例。发布时仍未结束的退款/线下充值按创建人类型形成真实企微审批;缺少平台绑定、固定代理身份或创建人事实的记录进入迁移待处理清单,不能继续使用旧本地审批接口。
|
||||
- 回滚应用时保留已经形成的企微实例、提交人/身份快照、Integration Log 和 Audit Event。已有企微申请进入运行后不得恢复旧本地审批动作,只能继续同步或人工处置。
|
||||
|
||||
## 公共能力发布依赖
|
||||
|
||||
- 最终 14 号真实企微发布票阻塞于公共基础 12 号票、全局审计 19 号票和公共通知 08 号票;退款业务消费者还必须替换为 UR#35 的具体纵向 Ticket。
|
||||
- 审批终态事件必须登记版本和消费者幂等键;审批实例是领域事实,企微请求/回调进入 Integration Log,提交、人工同步和状态变化进入 Audit Event。
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
- 主要自动化接缝采用最高公共行为边界:Fiber HTTP 路由与认证 → Application/Domain/Query → GORM → 开发 PostgreSQL、Redis 和 Asynq 可控队列;企业微信网络统一替换为可编程 WeCom Adapter。测试不直接断言私有函数或目录结构。
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
- 12 — 交付历史审批与待处理数据迁移接缝
|
||||
- 13 — 冻结企微后台与个人中心前端契约
|
||||
- `.scratch/tech-public-foundation/issues/12-foundation-release-gate-and-integration-contract.md` — 12 — 建立公共基础发布门禁和下游接入契约
|
||||
- `.scratch/tech-global-audit/issues/19-one-time-audit-cutover-gate.md` — 19 — 执行一次性审计切换与停机发布门禁
|
||||
- `.scratch/tech-inapp-notifications/issues/08-notification-release-gate.md` — 08 — 完成公共通知发布门禁与下游接入契约
|
||||
- UR#35 退款企微审批的“退款创建接入公共审批并消费终态事件”纵向 Ticket;UR#35 拆票后必须在本票实施前替换为其具体文件引用
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
@@ -154,6 +154,11 @@ GET /api/admin/shops/fund-summary
|
||||
- 开放普通访问前,保持历史钱包信用关闭,验证普通现金支付、信用扣款、冻结、回充、调额和并发场景。之后再由授权平台人员配置角色模板和既有代理额度。
|
||||
- 尚未启用信用且未产生负余额时可以回滚应用和可逆字段;一旦出现负余额,不得回滚到不理解信用额度的旧写入逻辑,也不得删除字段或强制关闭信用,必须先清偿欠款或继续保留新资金逻辑。
|
||||
|
||||
## 公共能力发布依赖
|
||||
|
||||
- 最终 10 号切换票阻塞于公共基础 12 号票和全局审计 19 号票,不能只以 Outbox Relay 可用替代业务事件消费者、审计 Adapter 和停机验收。
|
||||
- 余额、冻结与信用额度以钱包 Domain Ledger 为权威;高风险资金变更必须同事务写 Audit Event,可靠事件必须登记类型、版本、消费者和业务幂等键。
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
- 领域测试覆盖关闭/0、开启/正数、负额度、开启/0、关闭/非0、普通现金、使用部分信用、用尽信用、超过信用以及每个算术溢出边界。
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
- `.scratch/ur38-agent-main-wallet-credit/issues/07-unified-agent-wallet-credit-posting.md` — 07 — 统一代理主钱包充值入账与人工调整
|
||||
- `.scratch/ur38-agent-main-wallet-credit/issues/08-unified-agent-wallet-refund.md` — 08 — 统一代理订单退款回充
|
||||
- `.scratch/ur38-agent-main-wallet-credit/issues/09-fund-summary-credit-query.md` — 09 — 资金概况返回信用、总可用金额与欠款
|
||||
- `.scratch/tech-public-foundation/issues/03-outbox-at-least-once-delivery.md` — 03 — 完成 Outbox 到 Asynq 的至少一次投递闭环
|
||||
- `.scratch/tech-public-foundation/issues/12-foundation-release-gate-and-integration-contract.md` — 12 — 建立公共基础发布门禁和下游接入契约
|
||||
- `.scratch/tech-global-audit/issues/19-one-time-audit-cutover-gate.md` — 19 — 执行一次性审计切换与停机发布门禁
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
|
||||
@@ -245,6 +245,12 @@ POST /api/admin/export-tasks
|
||||
- 恢复服务后监控权限解析失败、零字段拒绝、各 Scene 任务失败、附件本地化缺失及 Count/Fetch 行数差异。
|
||||
- 回滚时先停止新任务,处理新版本未完成任务,再回滚 Worker/API/前端和数据库;已经生成的导出文件仍按附件落地页的当前鉴权访问。
|
||||
|
||||
## 公共能力发布依赖
|
||||
|
||||
- 最终发布票必须明确阻塞于公共基础 12 号票和全局审计 19 号票;涉及企微审批导出时还必须引用 UR#37 14 号发布票。
|
||||
- 导出任务复用公共五态并保留业务失败明细;敏感字段查看和导出均进入 Audit Event,导出文件与任务是业务事实,不把明细塞入审计 JSON。
|
||||
- 当前 PRD 尚未拆票;生成 tickets 时必须把上述依赖落到最终发布门禁,不得只写“依赖公共基础”。
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
- 字段权限单元/集成测试覆盖超级管理员全目录、单角色、多角色并集、账号角色与店铺角色既有解析规则、软删除/禁用角色关联、空授权、陈旧字段 key 和权限存储异常。
|
||||
|
||||
@@ -136,6 +136,11 @@ Status: ready-for-agent
|
||||
- 前后端同批切换 `/orders/create` 必填字段和 `/pay` 不可换方式契约。发布窗口应阻断旧前端继续创建缺少方式的新订单。
|
||||
- 回滚应用时保留系统配置、订单快照、支付记录和审计事实;不得通过回滚迁移删除已产生数据。
|
||||
|
||||
## 公共能力发布依赖
|
||||
|
||||
- 新增 05 号发布票,阻塞于公共基础 12 号票、全局审计 20 号系统配置 Adapter 接入票和全局审计 19 号切换票。
|
||||
- UR#48 负责注册正式支付配置 Key;系统配置更新的 Audit Event 与配置事实同事务,支付方式以订单快照为业务事实。公共配置接口存在不等于前端现在就新增配置页面。
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
- 配置单元测试覆盖卡/设备默认集合、合法自定义、钱包不可移除、空数组、未知值、重复值、非法 JSON、缺失记录和未知资产类型。
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# 05 — 完成资产支付方式公共能力接入与发布门禁
|
||||
|
||||
**What to build:** 发布负责人可以验证 UR#48 的支付方式配置已通过公共受控系统配置注册、正式审计 Adapter 和订单快照链路接入生产组合根,并在接口、权限、缓存、审计或回滚检查失败时阻止放量。
|
||||
|
||||
**Blocked by:**
|
||||
|
||||
- 04 — 订单按支付方式快照完成支付与取消闭环
|
||||
- `.scratch/tech-public-foundation/issues/12-foundation-release-gate-and-integration-contract.md` — 12 — 建立公共基础发布门禁和下游接入契约
|
||||
- `.scratch/tech-global-audit/issues/20-system-config-audit-adapter.md` — 20 — 接入公共系统配置正式审计 Adapter
|
||||
- `.scratch/tech-global-audit/issues/19-one-time-audit-cutover-gate.md` — 19 — 执行一次性审计切换与停机发布门禁
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
**架构通道:** 主通道为 Infrastructure 发布验收,辅助通道为 Application 与 Query 验收。
|
||||
|
||||
**完整业务边界:** 本票只收口 UR#48 正式配置 Key、支付方式策略、订单快照、审计装配、接口契约和发布回滚。明确不新增公共配置页面、不改变已评审支付规则、不建设 Outbox 运维前端、不迁移其他支付模块。
|
||||
|
||||
- [ ] UR#48 拥有的配置 Key 已在代码注册表声明类型、默认值、值域、只读和敏感策略,未注册 Key 不可写。
|
||||
- [ ] 系统配置更新通过正式 Audit Writer 与配置事实同事务,能追踪操作者、动作和前后变化且不记录敏感原值;未装配或审计失败时更新回滚。
|
||||
- [ ] `GET /api/admin/system-configs` 与 `PUT /api/admin/system-configs/{key}` 的权限、缓存失效、错误语义和 OpenAPI 与真实路由一致;前端只消费 UR#48 注册的配置项。
|
||||
- [ ] 真实 Fiber、PostgreSQL 和 Redis 验收覆盖按资产类型读取、配置更新、订单方式快照、支付、取消、并发和回滚边界。
|
||||
- [ ] 发布顺序为公共迁移与门禁、全局审计及 Adapter、UR#48 后端、前端;任一正式装配或审计样本失败均阻止放量。
|
||||
@@ -149,6 +149,11 @@ Status: ready-for-agent
|
||||
- 发布前核查重复 IMEI,并验证平台、代理、直属下级和系列授权的数据范围;不自动修改历史设备数据。
|
||||
- 回滚应用时保留已经产生的任务、资产分配记录与审计事实;不得通过回滚删除已经完成的设备归属或系列变更。
|
||||
|
||||
## 公共能力发布依赖
|
||||
|
||||
- 最终 05 号发布票阻塞于公共基础 12 号票和全局审计 19 号票;Worker、监控和消费者未就绪前不得开放前端入口。
|
||||
- 批量分配任务复用公共五态,逐行失败留在业务任务明细;任务创建、人工操作和资产归属变化进入 Audit Event,可靠事件登记版本与消费者幂等键。
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
- CSV 解析测试覆盖 UTF-8、UTF-8 BOM、LF、CRLF、中文表头、引号、空行、空值、额外列、非法引号、非 UTF-8、控制字符、公式前缀、科学计数法、超长标识、1000/1001 行和 10MB 边界。
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
|
||||
- 04 — 交付设备管理页 CSV 双入口与任务结果交互
|
||||
- [.scratch/ur36-bulk-package-purchase/issues/03-real-dependency-integration-harness.md](../../ur36-bulk-package-purchase/issues/03-real-dependency-integration-harness.md) — 03 — 建立批量任务真实依赖的集成测试 Harness
|
||||
- [.scratch/tech-public-foundation/issues/12-foundation-release-gate-and-integration-contract.md](../../tech-public-foundation/issues/12-foundation-release-gate-and-integration-contract.md) — 12 — 建立公共基础发布门禁和下游接入契约
|
||||
- [.scratch/tech-global-audit/issues/19-one-time-audit-cutover-gate.md](../../tech-global-audit/issues/19-one-time-audit-cutover-gate.md) — 19 — 执行一次性审计切换与停机发布门禁
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
|
||||
@@ -102,6 +102,11 @@ Status: ready-for-agent
|
||||
- 发布前记录并对比当前启用的轮询配置、队列深度、卡级开关和监控结果;发布后这些调度事实应保持一致。
|
||||
- 回调 URL 需在测试环境用运营商原始样例验证后再配置到运营商平台;正式启用顺序按运营商逐个灰度,单个 Adapter 可独立关闭而不影响事件和轮询兜底。
|
||||
|
||||
## 公共能力发布依赖
|
||||
|
||||
- 最终 13 号发布票阻塞于公共基础 12 号票和全局审计 19 号票;生产者放量前必须确认事件消费者、Integration Log、Audit Writer 和监控同时装配。
|
||||
- 每个卡状态事件登记稳定类型、载荷版本和消费者幂等键;卡状态是领域事实,运营商请求/回调进入 Integration Log,状态变化、人工触发及连续失败进入 Audit Event。
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
- 领域单元测试覆盖实名首次成功/重复成功/逆转确认、流量正增量/跨月/运营商重置/异常下降、网络状态映射/未知状态/运营商停机原因,以及每类观测不变时不重复发布副作用。
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
- 10 — 接入电信实名回调
|
||||
- 11 — 接入移动实名成功回调
|
||||
- 12 — 接入联通解除实名留痕回调
|
||||
- `.scratch/tech-public-foundation/issues/12-foundation-release-gate-and-integration-contract.md` — 12 — 建立公共基础发布门禁和下游接入契约
|
||||
- `.scratch/tech-global-audit/issues/19-one-time-audit-cutover-gate.md` — 19 — 执行一次性审计切换与停机发布门禁
|
||||
|
||||
**Status:** ready-for-agent
|
||||
|
||||
|
||||
@@ -73,6 +73,12 @@ Status: ready-for-agent
|
||||
- 发布依赖统一钱包变更边界、公共 Outbox、公共站内通知和 UR#96 业务员关系;依赖尚未完成时可以先完成领域事件,但不得另建临时通知表或直接发消息。
|
||||
- 存量低余额钱包不批量补发,避免上线瞬间产生大量无业务变更通知;上线后只监听新提交的跨阈值事务。
|
||||
|
||||
## 公共能力发布依赖
|
||||
|
||||
- 最终发布票必须明确阻塞于公共基础 12 号票、全局审计 19 号票、公共通知 08 号票和 UR#38 钱包切换 10 号票。
|
||||
- 低余额事件必须登记载荷版本、消费者幂等键、通知接收人和防重策略;钱包 Domain Ledger 是余额权威,通知事实不替代资金审计。
|
||||
- 当前 PRD 尚未拆票;生成 tickets 时必须把上述依赖落到最终发布门禁,不得只写“依赖公共基础”。
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
- 领域单元测试覆盖 `10100→10000`、`10001→10000`、`10000→9999`、`9000→8000`、`9000→10100→10000`,验证只在严格跨越时发事件。
|
||||
|
||||
13
AGENTS.md
13
AGENTS.md
@@ -321,13 +321,14 @@ queueClient.EnqueueTask(ctx, constants.TaskTypeXxx, payloadBytes)
|
||||
|
||||
### 审计日志规范
|
||||
|
||||
**适用场景**:任何敏感操作(账号管理、权限变更、数据删除等)
|
||||
**适用场景**:整个系统中的状态变更、敏感读取以及关键拒绝和失败,不限于七月迭代。实现前必须查阅并增量维护 `.scratch/tech-global-audit/审计覆盖基线.md`;选择不审计必须登记 N/A 理由,禁止留空。
|
||||
|
||||
- 旧模块在 Service 层、新 DDD 模块在 Application UseCase 中注入审计能力,操作成功后调用 `LogOperation()`
|
||||
- 必填字段:`OperatorID`、`OperationType`、`OperationDesc`、`BeforeData`、`AfterData`
|
||||
- 异步写入(Goroutine),写入失败不影响业务,失败时记录 Error 日志
|
||||
|
||||
**示例参考**:`internal/service/account/service.go`
|
||||
- Access Log 只负责 HTTP 调试,Audit Event 负责业务审计,Domain Ledger 负责金额和状态等领域事实,Integration Log 负责外部交互;四者不得混用或合并。
|
||||
- 旧模块在 Service/Application 事务脚本中、新 DDD 模块在 Application UseCase 中通过统一 Audit Writer Port/Adapter 接入;禁止新增裸 goroutine 审计写入。
|
||||
- 成功的资金、权限、关键配置、人工状态变更和其他高风险操作必须与业务事实同一 GORM 事务,审计失败则业务回滚。
|
||||
- 业务已回滚后的 `failed/denied` 审计使用独立短事务;二次失败保留原业务错误,并记录 critical 日志和指标。
|
||||
- Audit Event 至少记录稳定动作编码、操作者、来源、主要资源、前后数据、结果、风险及 request/correlation 标识;敏感值按统一 Sanitizer 删除或摘要化。
|
||||
- 新增或修改 PRD、Ticket 时必须明确 Audit Event、Domain Ledger、Integration Log、Outbox 的使用决定或 N/A 理由,并在最终发布门禁引用具体公共基础票。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
基于 Go + Fiber 框架的 HTTP 服务,集成了认证、限流、结构化日志和嵌入式配置功能。
|
||||
|
||||
## 功能文档
|
||||
|
||||
- [全局审计当前实现进度](docs/tech-global-audit/当前实现进度.md)
|
||||
|
||||
## 系统简介
|
||||
|
||||
物联网卡 + 号卡全生命周期管理平台,支持代理商体系和分佣结算。
|
||||
@@ -199,7 +203,7 @@ default:
|
||||
|
||||
### 公共技术基础
|
||||
|
||||
公共 Outbox、命令幂等、统一异步任务、受控系统配置、迁移门禁和 Access Log 安全策略的能力边界、发布顺序与下游接入方式见[公共技术基础功能总结](docs/tech-public-foundation/功能总结.md)。
|
||||
公共 Outbox、命令幂等、统一异步任务、受控系统配置、迁移门禁和 Access Log 安全策略的能力边界、发布顺序与下游接入方式见[公共技术基础功能总结](docs/tech-public-foundation/功能总结.md);前端当前需要处理的接口字段和页面规则见[公共技术基础前端即时接口交接](docs/tech-public-foundation/前端即时接口交接.md)。
|
||||
|
||||
### 账号管理重构(2025-02)
|
||||
|
||||
|
||||
38
cmd/audit-coverage/main.go
Normal file
38
cmd/audit-coverage/main.go
Normal file
@@ -0,0 +1,38 @@
|
||||
// audit-coverage 生成全系统审计覆盖显式清单,生成结果必须经过业务、研发和安全评审。
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/governance/auditcoverage"
|
||||
)
|
||||
|
||||
func main() {
|
||||
if err := run(); err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "生成审计覆盖清单失败:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func run() error {
|
||||
root, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries, err := auditcoverage.Scan(root)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
data, err := auditcoverage.MarshalManifest(entries)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(root, ".scratch/tech-global-audit/审计覆盖清单.json")
|
||||
if err := os.WriteFile(path, append(data, '\n'), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
fmt.Printf("已生成 %d 条审计覆盖入口:%s\n", len(entries), path)
|
||||
return nil
|
||||
}
|
||||
@@ -201,7 +201,7 @@ FE/BE 研发需求开发完成
|
||||
| INT-05 | 支付、代理钱包、信用和余额预警联调 | #48、#38、#34、#36、#96、#97 | FE 1~1.5h / BE 1~1.5h,已含 | 支付配置、钱包领域和业务员接口完成 | 支付方式限制、扫码充值、信用扣款、角色默认额度、店铺调额、100元预警 |
|
||||
| INT-06 | 企业微信审批、退款和线下充值联调 | #37、#35、#34、#44 | FE 1.5~2h / BE 1.5~2h,已含 | 企微模板、绑定、回调、轮询和业务详情完成 | 扫码绑定、发起审批、意见附件、通过/驳回/撤销、退款/充值终态和列表摘要 |
|
||||
| INT-07 | Gateway卡限速联调 | #47 | FE 0.5~1h / BE 0.5~1h,已含 | Gateway联调配置和卡/设备入口完成 | 单卡限速、设备解析当前卡、取消限速、无当前卡、失败审计 |
|
||||
| INT-08 | 七月迭代全链路与停机发布验收 | 全部激活需求 | FE 3~4h / BE 4~5h,额外 | INT-01~07完成 | 权限、通知、审计、历史数据、旧入口关闭、Worker恢复和发布检查 |
|
||||
| INT-08 | 七月迭代全链路与停机发布验收 | 全部激活需求及三项公共技术需求 | FE 3~4h / BE 4~5h,额外 | INT-01~07、公共基础12、全局审计19、公共通知08及各业务发布门禁完成 | 权限、通知、全系统审计覆盖、正式Adapter、事件消费者、历史数据、旧入口关闭、Worker恢复、生产阈值和发布检查 |
|
||||
|
||||
## 七、联调任务责任方式
|
||||
|
||||
@@ -231,7 +231,7 @@ FE/BE 研发需求开发完成
|
||||
再分别拆分:
|
||||
|
||||
```text
|
||||
[FE] 七月迭代公共状态与异步任务交互
|
||||
[FE][TECH] 七月迭代公共状态与异步任务交互(当前先调整导出任务列表/详情的统一字段、五态、轮询与恢复;系统配置和 Outbox 运维页面不在本票)
|
||||
[BE] 七月迭代公共迁移幂等与异步任务基础
|
||||
[FE] 顶部通知铃铛与站内通知中心
|
||||
[BE] 站内通知基础设施与受控跳转
|
||||
|
||||
@@ -1598,17 +1598,22 @@
|
||||
**描述**
|
||||
|
||||
```markdown
|
||||
目标:为批量分配、批量订购、导出等页面提供一致的加载、错误和异步任务交互。
|
||||
目标:先统一现有导出任务接口与批量任务页面的加载、错误和异步任务交互;系统配置和 Outbox 运维页面不在本研发需求范围。
|
||||
|
||||
预计工时:前端1~2小时。
|
||||
|
||||
交付内容:
|
||||
1. 统一加载、空数据、权限不足、接口失败和重试状态。
|
||||
2. 统一异步任务进度结构:状态固定为1待处理、2处理中、3已完成、4已失败、5已取消;总数、成功数、失败数和失败明细独立返回,部分成功只由计数表达,不占状态码。
|
||||
3. 创建任务后按2秒、3秒、5秒递增轮询,最大间隔10秒;页面不可见暂停,恢复后立即刷新。
|
||||
4. 页面刷新后通过task_id恢复任务详情。
|
||||
2. `GET /api/admin/export-tasks` 兼容新增或统一 `task_id`、`total_count`、`success_count`、`failed_count`、`error_code`、`error_summary`、`updated_at`,原字段不删除、不改名。
|
||||
3. `GET /api/admin/export-tasks/{id}` 同步新增或统一上述字段,字段语义与列表一致。
|
||||
4. 统一异步任务进度结构:状态固定为1待处理、2处理中、3已完成、4已失败、5已取消;部分成功仍为已完成,只通过总数、成功数和失败数表达。
|
||||
5. 创建任务后保存 `task_id`;刷新或重新进入页面查询原任务,不得重新创建。
|
||||
6. 按2秒、3秒、5秒递增轮询,最大间隔10秒;终态停止,页面不可见暂停,恢复后立即刷新。
|
||||
7. 403显示无权限且不重试;瞬时失败保留已有数据和输入并提供重试;用户错误文案展示安全的 `error_summary`。
|
||||
|
||||
完成标准:设备批量分配、批量订购和导出页面复用同一交互规则,不各自实现不同状态语义。
|
||||
不包含:`GET /api/admin/system-configs`、`PUT /api/admin/system-configs/{key}` 的新页面,以及 Outbox 监控/人工恢复页面。支付配置页面继续归 UR#48,待正式配置 Key 和审计 Adapter 就绪后联调。
|
||||
|
||||
完成标准:导出页面先完成上述兼容字段和交互规则;设备批量分配、批量订购在对应业务接口落地后复用同一状态语义。
|
||||
```
|
||||
|
||||
### 后端研发需求
|
||||
@@ -1925,13 +1930,13 @@
|
||||
**描述**
|
||||
|
||||
```markdown
|
||||
关联需求:全部激活用户需求及全局审计技术需求。
|
||||
关联需求:全部激活用户需求、七月迭代公共开发基础、全局审计和公共站内通知技术需求。
|
||||
|
||||
预计工时:前端3~4小时、后端4~5小时,本项为额外发布工时并计入总工时。
|
||||
|
||||
进入条件:INT-01至INT-07完成,迁移脚本、配置、Worker和发布清单已准备。
|
||||
进入条件:INT-01至INT-07完成;公共基础12号票、全局审计19号票、公共站内通知08号票及各业务最终发布门禁完成;迁移脚本、配置、Worker和发布清单已准备。
|
||||
|
||||
验收范围:权限与越权、通知跳转、审计多视角、历史数据兼容、旧审批接口下线、旧审计停止写入、异步任务恢复、停机迁移、配置校验和回滚边界。
|
||||
验收范围:权限与越权、通知跳转、审计多视角、历史数据兼容、旧审批接口下线、旧审计停止写入、异步任务恢复、停机迁移、配置校验和回滚边界;全系统审计覆盖基线无未分类入口,系统配置更新和 Outbox 人工恢复已注入正式 Audit Writer;每个实际投递事件已登记事件类型、载荷版本、消费者、幂等键、业务失败明细和通知策略;告警阈值已填写具体数值与责任人。
|
||||
|
||||
完成标准:前端和后端使用冻结接口契约,核心链路人工验收通过,发布检查项和已知风险已记录。
|
||||
完成标准:前端和后端使用冻结接口契约,能从样本回答谁在什么入口、对什么资源、如何操作、前后变化和关联请求/业务链路;旧 Writer 无新增,消费者先于生产者就绪,核心链路人工验收通过。任何审计覆盖空白、正式 Adapter 未装配、事件无消费者或阈值未确定都阻止放量。
|
||||
```
|
||||
|
||||
@@ -43,7 +43,9 @@
|
||||
2. PRD 已完成评审,没有尚未决定的业务问题。
|
||||
3. 工作区没有来源不明的未提交修改。
|
||||
4. 当前需求的跨 PRD 前置能力已经发布为具体 Issue;不能只写“依赖公共基础”。
|
||||
5. 记录拆票前或实现前的 commit,供后续双轴评审使用:
|
||||
5. 当前完整用例已经明确 Audit Event、Domain Ledger、Integration Log、Outbox 的使用决定或 N/A 理由;涉及审计时已增量维护全系统审计覆盖基线。
|
||||
6. 最终发布门禁已经引用具体公共基础 Ticket;需要事件时已确认事件类型、载荷版本、消费者幂等键、业务失败明细和通知策略。
|
||||
7. 记录拆票前或实现前的 commit,供后续双轴评审使用:
|
||||
|
||||
```bash
|
||||
git status --short --branch
|
||||
@@ -74,6 +76,8 @@ git rev-parse HEAD
|
||||
- 适用的 DDD 架构通道;
|
||||
- 当前票收口的完整业务边界;
|
||||
- 明确不迁移的旧代码范围。
|
||||
- Audit Event、Domain Ledger、Integration Log、Outbox 的逐项决定或 N/A 理由;
|
||||
- 依赖公共能力时引用具体 Ticket,并在本 PRD 最终发布票中收口装配和验收。
|
||||
|
||||
出现以下拆法时应要求重拆:
|
||||
|
||||
@@ -82,6 +86,7 @@ git rev-parse HEAD
|
||||
- 阻塞项只写“公共基础设施”而没有具体 Issue;
|
||||
- 为简单 Query 或单表写操作强行创建聚合、工厂或多层接口;
|
||||
- 在拆票阶段改变已评审 PRD 的业务或架构决定。
|
||||
- 把“公共能力已经实现”当作业务组合根已经装配,遗漏正式 Adapter、事件消费者、监控或审计覆盖基线。
|
||||
|
||||
确认草稿后回复:
|
||||
|
||||
@@ -337,6 +342,7 @@ go test ./...
|
||||
- 验收条件有行为或测试证据;
|
||||
- 没有擅自改变架构或扩大迁移范围;
|
||||
- 相关测试通过,或存量/环境失败已准确记录。
|
||||
- 本票涉及的审计、领域流水、外部集成和可靠事件决定已经实现或按评审理由标记 N/A,覆盖基线同步更新。
|
||||
|
||||
### 9.2 PRD 完成
|
||||
|
||||
@@ -345,6 +351,7 @@ go test ./...
|
||||
- 累计 diff 通过 Standards + Spec 双轴 review;
|
||||
- 最终代码已经完成约定的全量测试;
|
||||
- 文档、迁移和人工验收项按 PRD 处理。
|
||||
- 最终发布票已验证正式 Adapter/消费者在生产组合根装配,事件版本与幂等键已登记,监控阈值有具体数值和责任人。
|
||||
|
||||
### 9.3 七月迭代完成
|
||||
|
||||
@@ -361,6 +368,8 @@ INT-07 Gateway 卡限速
|
||||
INT-08 七月迭代全链路与停机发布验收
|
||||
```
|
||||
|
||||
INT-08 还必须汇总验证:全系统审计覆盖基线无空白、旧 Writer 停写、系统配置与 Outbox 恢复使用正式审计 Adapter;所有实际生产的 Outbox 事件均已有消费者、版本和幂等键;生产告警阈值已经依据容量基线填写。任一项未闭环均不能用“公共基础已完成”代替。
|
||||
|
||||
最终结论必须区分:
|
||||
|
||||
- 本地自动化完成;
|
||||
|
||||
@@ -302,7 +302,7 @@ func (e WalletDebitedEvent) OccurredAt() time.Time { return e.occurredAt }
|
||||
```text
|
||||
数据库事务
|
||||
├── 保存聚合
|
||||
├── 保存操作日志
|
||||
├── 保存 Audit Event(该动作需要审计时)
|
||||
└── 保存 Outbox 事件
|
||||
提交事务
|
||||
↓
|
||||
@@ -330,9 +330,20 @@ func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) err
|
||||
```
|
||||
|
||||
- Outbox 写入失败:事务回滚,避免“业务成功但关键事件永久丢失”。
|
||||
- 高风险成功操作的 Audit Event 写入失败:事务回滚,避免“业务事实存在但无法回答谁做了什么”。业务已回滚后的失败或拒绝审计使用独立短事务,禁止裸 goroutine。
|
||||
- Asynq 暂时不可用:不回滚已提交业务,Relay 后续重试。
|
||||
- 消费者必须幂等;涉及余额、退款、充值时使用业务键或状态条件更新。
|
||||
|
||||
### 公共能力决策
|
||||
|
||||
每个新增或修改的完整用例都必须在 PRD/Ticket 中明确以下决定,不能只写“接入公共基础”:
|
||||
|
||||
- 是否写 Audit Event;若否,写明 N/A 理由,并增量维护全系统审计覆盖基线;
|
||||
- 是否已有 Domain Ledger 作为金额、订单或状态事实,Audit Event 不得替代它;
|
||||
- 是否存在外部请求或回调,需要 Integration Log;
|
||||
- 是否存在提交后可靠副作用,需要同事务 Outbox,并明确事件类型、版本和消费者幂等键;
|
||||
- 最终发布门禁所依赖的具体公共 Ticket,不得使用无法判断完成状态的模糊依赖。
|
||||
|
||||
---
|
||||
|
||||
## 八、仓储接口
|
||||
|
||||
27
docs/tech-global-audit/当前实现进度.md
Normal file
27
docs/tech-global-audit/当前实现进度.md
Normal file
@@ -0,0 +1,27 @@
|
||||
# 全局审计当前实现进度
|
||||
|
||||
## 已完成
|
||||
|
||||
- Integration Log 写入闭环:新增 `tb_integration_log`、稳定 Integration ID、入站幂等键、调用前 `pending` 事实、条件终态更新、结果未知恢复策略、未发送终态、中文结果名称和脱敏摘要。
|
||||
- Access Log 敏感路由加固:真实登录、Token、支付回调、文件/导出、系统配置与微信配置路由使用安全摘要;请求、响应和 Query 均先脱敏后执行 50KB 限制,Authorization/Cookie 不进入日志正文。
|
||||
- 全系统覆盖扫描:当前显式固定 490 个 HTTP、Application、Service、Worker 和定时任务入口;源码与清单不一致时静态门禁失败。
|
||||
|
||||
## 当前阻塞
|
||||
|
||||
00 号票要求《审计覆盖基线》经业务、研发和安全三方评审。源码扫描、分类、旧 Writer 清单和自动门禁已完成,但本地自动化不能替代该外部评审,因此 01 及其后续依赖票尚未解锁。
|
||||
|
||||
## 验证方式
|
||||
|
||||
```bash
|
||||
go test ./internal/governance/auditcoverage
|
||||
go test ./pkg/logger
|
||||
go test ./internal/infrastructure/integrationlog
|
||||
```
|
||||
|
||||
Integration Log 测试使用真实 PostgreSQL;覆盖门禁和 Access Log 测试不依赖外部服务。
|
||||
|
||||
## 发布边界
|
||||
|
||||
- 当前尚未切换旧账号、资产和手动轮询 Writer,不允许把本阶段单独作为一次性审计切换版本放量。
|
||||
- `tb_integration_log` 已产生事实后禁止通过 down migration 删除;应停止异常生产者并前向修复。
|
||||
- 后续必须先完成 00 三方评审,再按依赖图推进 Audit Event Writer、业务迁移、查询与 19 号停机门禁。
|
||||
49
docs/tech-public-foundation/前端即时接口交接.md
Normal file
49
docs/tech-public-foundation/前端即时接口交接.md
Normal file
@@ -0,0 +1,49 @@
|
||||
# 公共技术基础前端即时接口交接
|
||||
|
||||
## 禅道归属
|
||||
|
||||
现在需要前端处理的内容统一填写到技术用户需求“七月迭代公共开发基础”下的研发需求:
|
||||
|
||||
`[FE][TECH] 七月迭代公共状态与异步任务交互`
|
||||
|
||||
不要把本次改动拆到全局审计或各业务需求下。支付方式与系统配置页面仍归 UR#48;全局审计中心页面归“全局多视角审计”技术用户需求。
|
||||
|
||||
## 现在需要调整的接口
|
||||
|
||||
### GET /api/admin/export-tasks
|
||||
|
||||
变更类型:兼容性新增字段,原字段不删除、不改名。
|
||||
|
||||
每条任务新增或统一以下字段:
|
||||
|
||||
| 字段 | 含义 | 前端用途 |
|
||||
|---|---|---|
|
||||
| `task_id` | 稳定任务标识 | 保存并用于恢复任务查询 |
|
||||
| `total_count` | 总条数 | 展示整体进度 |
|
||||
| `success_count` | 成功条数 | 展示成功结果 |
|
||||
| `failed_count` | 失败条数 | 展示失败结果 |
|
||||
| `error_code` | 稳定错误码 | 决定错误类型,不直接展示底层错误 |
|
||||
| `error_summary` | 安全中文错误摘要 | 展示任务失败原因 |
|
||||
| `updated_at` | 最近更新时间 | 判断任务是否仍有进展 |
|
||||
|
||||
### GET /api/admin/export-tasks/{id}
|
||||
|
||||
变更类型:兼容性新增字段,原字段不删除、不改名。
|
||||
|
||||
任务详情新增或统一 `task_id`、`total_count`、`success_count`、`failed_count`、`error_code`、`error_summary`、`updated_at`,字段语义与列表一致。
|
||||
|
||||
## 页面与业务逻辑调整
|
||||
|
||||
- 所有异步任务统一使用五态:`1=待处理、2=处理中、3=已完成、4=已失败、5=已取消`。
|
||||
- “部分成功”不是新的状态。只要任务执行结束,状态就是已完成,页面通过总数、成功数、失败数表达部分成功。
|
||||
- 创建任务成功后保存 `task_id`;刷新页面或重新进入页面时查询原任务,不得再次创建任务。
|
||||
- 待处理和处理中按 2 秒、3 秒、5 秒递增轮询,之后最长保持 10 秒;进入终态立即停止。
|
||||
- 页面不可见时暂停轮询,恢复可见后立即刷新一次。
|
||||
- 403 显示无权限且不自动重试;瞬时失败保留已有数据和用户输入,并提供明确重试入口。
|
||||
- 任务失败优先展示 `error_summary`;不要把 `error_code` 或底层技术错误直接作为用户文案。
|
||||
|
||||
## 现在不需要调整的内容
|
||||
|
||||
`GET /api/admin/system-configs` 和 `PUT /api/admin/system-configs/{key}` 当前只是公共受控配置接缝,不代表前端现在要新增系统配置页面。UR#48 注册正式支付配置 Key、全局审计正式 Adapter 接入并通过发布门禁后,再按 UR#48 的页面范围联调。
|
||||
|
||||
Outbox 监控和人工恢复也不在本次前端范围内;公共基础只提供后端运维接缝,未来如需运营页面必须另行评审。
|
||||
@@ -60,10 +60,12 @@ Access Log 对 query、请求 JSON 和响应 JSON 复用大小写不敏感的递
|
||||
- 异步任务所有者:保留业务任务表和失败明细,把公共五态投影到 API,并采用租约或等价 PostgreSQL 恢复事实。
|
||||
- 发布负责人:运行前后置门禁,确认 Relay、消费者、监控和前端依次就绪后再放量。
|
||||
|
||||
## 待决策项
|
||||
## 已登记的后续闭环项
|
||||
|
||||
- Audit Event 公共实现完成后,需要在系统配置更新和 Outbox 人工恢复的组合根注入正式审计 Adapter。
|
||||
- 各下游 PRD 需要分别确认事件类型、载荷版本、消费者幂等键、业务失败明细和通知策略;公共基础不预先注册这些内容。
|
||||
- 生产阈值需结合容量基线确定待投递年龄、积压量、过期租约比例和成功率告警值;当前公共 Query 提供指标与阈值计算接缝,不固化业务容量数字。
|
||||
- 系统配置更新的正式审计 Adapter 由[全局审计 20 号票](../../.scratch/tech-global-audit/issues/20-system-config-audit-adapter.md)接入,Outbox 人工恢复由[全局审计 21 号票](../../.scratch/tech-global-audit/issues/21-outbox-recovery-audit-adapter.md)接入,并统一由[全局审计 19 号发布门禁](../../.scratch/tech-global-audit/issues/19-one-time-audit-cutover-gate.md)验证;不再作为无负责人的待决策项。
|
||||
- 各下游 PRD 必须在自己的最终发布门禁中确认事件类型、载荷版本、消费者幂等键、业务失败明细和通知策略,并引用具体公共票;[INT-08](../7月迭代/7月迭代禅道研发需求逐条录入稿.md#int-08-全链路与停机发布验收) 汇总检查,公共基础仍不猜测业务事件定义。
|
||||
- Outbox 待投递年龄、积压量、过期租约比例和成功率的生产告警值由发布负责人依据容量基线填写到 INT-08 发布清单。没有数值、负责人和验证记录时不得放量。
|
||||
|
||||
前端现在需要处理的接口字段与页面规则见[前端即时接口交接](前端即时接口交接.md)。
|
||||
|
||||
只有真实 PostgreSQL、Redis、Asynq、Fiber 接缝测试、全量 Go 测试和累计差异评审全部通过后,才可把本基础标记为可供下游接入。
|
||||
|
||||
46
internal/governance/auditcoverage/coverage_test.go
Normal file
46
internal/governance/auditcoverage/coverage_test.go
Normal file
@@ -0,0 +1,46 @@
|
||||
package auditcoverage_test
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/governance/auditcoverage"
|
||||
)
|
||||
|
||||
func TestReviewedAuditCoverageManifestMatchesAllRegisteredEntrypoints(t *testing.T) {
|
||||
_, currentFile, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("无法定位覆盖门禁测试文件")
|
||||
}
|
||||
root := filepath.Clean(filepath.Join(filepath.Dir(currentFile), "../../../"))
|
||||
actual, err := auditcoverage.Scan(root)
|
||||
if err != nil {
|
||||
t.Fatalf("扫描全系统入口失败:%v", err)
|
||||
}
|
||||
expected, err := auditcoverage.LoadManifest(filepath.Join(root, ".scratch/tech-global-audit/审计覆盖清单.json"))
|
||||
if err != nil {
|
||||
t.Fatalf("读取经评审审计覆盖清单失败:%v", err)
|
||||
}
|
||||
if len(actual) == 0 {
|
||||
t.Fatal("入口扫描结果为空,覆盖门禁不可用")
|
||||
}
|
||||
if !reflect.DeepEqual(expected, actual) {
|
||||
t.Fatalf("源码入口与审计覆盖清单不一致:清单 %d 项,源码 %d 项;请重新分类并完成评审后更新清单", len(expected), len(actual))
|
||||
}
|
||||
for _, entry := range expected {
|
||||
if entry.AuditEvent == "" || entry.DomainLedger == "" || entry.IntegrationLog == "" || entry.Outbox == "" ||
|
||||
entry.Transaction == "" || entry.FailureStrategy == "" || entry.SensitivePolicy == "" ||
|
||||
entry.BeforeAfterPolicy == "" || entry.TestSeam == "" {
|
||||
t.Fatalf("入口 %s 存在未分类字段", entry.Key)
|
||||
}
|
||||
if entry.AuditEvent == "N/A" && entry.NAReason == "" {
|
||||
t.Fatalf("入口 %s 的 Audit Event 为 N/A 但缺少理由", entry.Key)
|
||||
}
|
||||
if entry.AuditEvent != "N/A" && (entry.ActionCode == "" || entry.ActionName == "" || entry.Category == "" ||
|
||||
entry.Risk == "" || entry.PrimaryResource == "") {
|
||||
t.Fatalf("入口 %s 缺少动作、风险或主要资源", entry.Key)
|
||||
}
|
||||
}
|
||||
}
|
||||
448
internal/governance/auditcoverage/scanner.go
Normal file
448
internal/governance/auditcoverage/scanner.go
Normal file
@@ -0,0 +1,448 @@
|
||||
// Package auditcoverage 提供全系统入口的可复核审计覆盖扫描。
|
||||
package auditcoverage
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
// Entry 是一个必须经过审计分类的 HTTP、Worker 或定时任务入口。
|
||||
type Entry struct {
|
||||
Key string `json:"key"`
|
||||
Kind string `json:"kind"`
|
||||
CodeEntry string `json:"code_entry"`
|
||||
Owner string `json:"owner"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Path string `json:"path,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
AuditEvent string `json:"audit_event"`
|
||||
DomainLedger string `json:"domain_ledger"`
|
||||
IntegrationLog string `json:"integration_log"`
|
||||
Outbox string `json:"outbox"`
|
||||
ActionCode string `json:"action_code,omitempty"`
|
||||
ActionName string `json:"action_name,omitempty"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Risk string `json:"risk,omitempty"`
|
||||
PrimaryResource string `json:"primary_resource,omitempty"`
|
||||
AffectedResource string `json:"affected_resource,omitempty"`
|
||||
ActorSource string `json:"actor_source"`
|
||||
Transaction string `json:"transaction"`
|
||||
FailureStrategy string `json:"failure_strategy"`
|
||||
SensitivePolicy string `json:"sensitive_policy"`
|
||||
BeforeAfterPolicy string `json:"before_after_policy"`
|
||||
TestSeam string `json:"test_seam"`
|
||||
NAReason string `json:"na_reason,omitempty"`
|
||||
}
|
||||
|
||||
// Scan 扫描当前仓库中对外 HTTP、Asynq Worker 和定时任务注册入口。
|
||||
func Scan(root string) ([]Entry, error) {
|
||||
var entries []Entry
|
||||
files := []string{"internal/routes", "internal/application", "internal/domain", "internal/service", "pkg/queue", "cmd/worker"}
|
||||
for _, directory := range files {
|
||||
err := filepath.Walk(filepath.Join(root, directory), func(path string, info os.FileInfo, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if info.IsDir() || !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
found, err := scanFile(root, path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
entries = append(entries, found...)
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
sort.Slice(entries, func(i, j int) bool { return entries[i].Key < entries[j].Key })
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// LoadManifest 读取经评审的显式覆盖快照。
|
||||
func LoadManifest(path string) ([]Entry, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var entries []Entry
|
||||
if err := sonic.Unmarshal(data, &entries); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
// MarshalManifest 将扫描结果输出为稳定、便于评审的 JSON。
|
||||
func MarshalManifest(entries []Entry) ([]byte, error) {
|
||||
return sonic.ConfigStd.MarshalIndent(entries, "", " ")
|
||||
}
|
||||
|
||||
func scanFile(root, path string) ([]Entry, error) {
|
||||
set := token.NewFileSet()
|
||||
file, err := parser.ParseFile(set, path, nil, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
relative, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var entries []Entry
|
||||
ast.Inspect(file, func(node ast.Node) bool {
|
||||
call, ok := node.(*ast.CallExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
position := set.Position(call.Pos())
|
||||
if identifier, ok := call.Fun.(*ast.Ident); ok && identifier.Name == "Register" && len(call.Args) >= 7 {
|
||||
method, methodOK := stringLiteral(call.Args[3])
|
||||
pathSuffix, pathOK := stringLiteral(call.Args[4])
|
||||
if methodOK && pathOK {
|
||||
entry := classifyHTTP(relative, position.Line, method, pathSuffix, expression(call.Args[5]), routeSummary(call.Args[6]))
|
||||
entries = append(entries, entry)
|
||||
}
|
||||
return true
|
||||
}
|
||||
selector, ok := call.Fun.(*ast.SelectorExpr)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
switch selector.Sel.Name {
|
||||
case "HandleFunc":
|
||||
if len(call.Args) >= 2 {
|
||||
entries = append(entries, classifyWorker(relative, position.Line, expression(call.Args[0]), expression(call.Args[1])))
|
||||
}
|
||||
case "Register":
|
||||
if strings.HasPrefix(relative, "cmd/worker/") {
|
||||
if taskType, schedule, ok := scheduledTask(call); ok {
|
||||
entries = append(entries, classifySchedule(relative, position.Line, taskType, schedule))
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if strings.HasPrefix(relative, "internal/application/") || strings.HasPrefix(relative, "internal/domain/") ||
|
||||
strings.HasPrefix(relative, "internal/service/") {
|
||||
for _, declaration := range file.Decls {
|
||||
function, ok := declaration.(*ast.FuncDecl)
|
||||
if !ok || function.Recv == nil || !isBusinessMethod(function.Name.Name) {
|
||||
continue
|
||||
}
|
||||
position := set.Position(function.Pos())
|
||||
entries = append(entries, classifyBusinessMethod(relative, position.Line, function.Name.Name))
|
||||
}
|
||||
}
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func classifyHTTP(file string, line int, method, path, handler, summary string) Entry {
|
||||
owner := strings.TrimSuffix(filepath.Base(file), ".go")
|
||||
if summary == "" {
|
||||
summary = handler
|
||||
}
|
||||
entry := Entry{
|
||||
Key: fmt.Sprintf("http:%s:%d:%s:%s", file, line, method, path), Kind: "http",
|
||||
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, handler), Owner: owner,
|
||||
Method: method, Path: path, Summary: summary, ActorSource: httpActorSource(file),
|
||||
DomainLedger: ledgerDecision(owner), IntegrationLog: integrationDecision(file, path),
|
||||
Outbox: "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A",
|
||||
SensitivePolicy: "禁止字段删除;手机号、IP、ICCID、金额和第三方单号按权限脱敏;单字段 16KB 上限",
|
||||
BeforeAfterPolicy: "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用",
|
||||
TestSeam: "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口",
|
||||
}
|
||||
if method == "GET" && !isSensitiveRead(file, path, summary) {
|
||||
entry.AuditEvent = "N/A"
|
||||
entry.Transaction = "N/A"
|
||||
entry.FailureStrategy = "Access Log 记录统一错误;普通读取不创建业务审计"
|
||||
entry.NAReason = "普通只读查询,不改变业务事实且不返回需二次授权的完整敏感值"
|
||||
return entry
|
||||
}
|
||||
entry.AuditEvent = "必须"
|
||||
entry.ActionCode = actionCode(owner, handler)
|
||||
entry.ActionName = summary
|
||||
entry.Category = categoryFor(owner)
|
||||
entry.Risk = riskFor(owner, path, summary)
|
||||
entry.PrimaryResource = owner
|
||||
entry.AffectedResource = "由对应 Application/Service 用例按直接影响资源显式填写,禁止递归扩展"
|
||||
entry.Transaction = "成功事件与关键业务事实同一 GORM 事务;敏感读取在返回前写入"
|
||||
entry.FailureStrategy = "业务回滚后的 failed/denied 使用独立短事务;二次失败保留业务错误并记录 critical"
|
||||
return entry
|
||||
}
|
||||
|
||||
func classifyWorker(file string, line int, taskType, handler string) Entry {
|
||||
owner := workerOwner(taskType)
|
||||
entry := Entry{
|
||||
Key: fmt.Sprintf("worker:%s:%d:%s", file, line, taskType), Kind: "worker",
|
||||
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, handler), Owner: owner,
|
||||
Summary: "处理异步任务 " + taskType, AuditEvent: "按状态变化、人工触发、连续失败或高风险异常决定",
|
||||
DomainLedger: ledgerDecision(owner), IntegrationLog: workerIntegrationDecision(taskType),
|
||||
Outbox: "任务来源 Outbox/业务任务事实;消费端按稳定事件或任务 ID 幂等",
|
||||
ActionCode: actionCode(owner, handler), ActionName: "处理异步任务(" + taskType + ")",
|
||||
Category: categoryFor(owner), Risk: riskFor(owner, taskType, handler),
|
||||
PrimaryResource: owner, AffectedResource: "任务载荷定位的直接业务资源",
|
||||
ActorSource: "system_task/asynq", Transaction: "业务状态变化、领域流水和 Audit Event 按用例原子提交",
|
||||
FailureStrategy: "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计",
|
||||
SensitivePolicy: "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL",
|
||||
BeforeAfterPolicy: "状态变化保存直接前后值;无业务变化时仅保留 Integration Log",
|
||||
TestSeam: "公开 Asynq Handler + PostgreSQL/Redis 事实 + 重复消费测试;覆盖门禁静态比对本入口",
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
func classifySchedule(file string, line int, taskType, schedule string) Entry {
|
||||
return Entry{
|
||||
Key: fmt.Sprintf("schedule:%s:%d:%s", file, line, taskType), Kind: "scheduled_job",
|
||||
CodeEntry: fmt.Sprintf("%s:%d", file, line), Owner: workerOwner(taskType), Summary: "按 " + schedule + " 调度 " + taskType,
|
||||
AuditEvent: "N/A", DomainLedger: "N/A", IntegrationLog: "N/A", Outbox: "N/A",
|
||||
ActorSource: "system_task/scheduled_job", Transaction: "N/A",
|
||||
FailureStrategy: "调度注册失败阻止 Worker 启动;执行结果由对应 Worker 入口负责",
|
||||
SensitivePolicy: "调度日志仅记录任务类型与安全时间信息",
|
||||
BeforeAfterPolicy: "N/A:调度入口不修改业务事实",
|
||||
TestSeam: "调度注册公开函数 + 覆盖门禁静态比对本入口",
|
||||
NAReason: "本入口只产生调度信号,不直接读取或修改业务事实;审计责任位于对应 Worker",
|
||||
}
|
||||
}
|
||||
|
||||
func classifyBusinessMethod(file string, line int, method string) Entry {
|
||||
parts := strings.Split(file, "/")
|
||||
layer := parts[1]
|
||||
owner := strings.TrimSuffix(filepath.Base(filepath.Dir(file)), ".go")
|
||||
if owner == "service" || owner == "application" || owner == "domain" {
|
||||
owner = strings.TrimSuffix(filepath.Base(file), ".go")
|
||||
}
|
||||
entry := Entry{
|
||||
Key: fmt.Sprintf("%s:%s:%d:%s", layer, file, line, method), Kind: layer,
|
||||
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, method), Owner: owner,
|
||||
Summary: "业务方法 " + method, DomainLedger: ledgerDecision(owner),
|
||||
IntegrationLog: businessIntegrationDecision(file, method),
|
||||
Outbox: "存在提交后可靠副作用时必须在同一事务追加;否则 N/A",
|
||||
ActionCode: actionCode(owner, method), Risk: riskFor(owner, file, method),
|
||||
PrimaryResource: owner, AffectedResource: "完整用例直接修改或引用的资源",
|
||||
ActorSource: "由调用入口传入操作者与来源快照",
|
||||
SensitivePolicy: "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品",
|
||||
BeforeAfterPolicy: "完整用例保存脱敏后的直接业务变化;Domain 方法由 Application 投影",
|
||||
TestSeam: "Application/Service 公共方法 + PostgreSQL 事实;Domain 使用纯领域测试;覆盖门禁静态比对本入口",
|
||||
}
|
||||
if layer == "domain" {
|
||||
entry.AuditEvent = "N/A"
|
||||
entry.Transaction = "由 Application 组合根负责"
|
||||
entry.FailureStrategy = "返回领域错误,由 Application 在回滚后裁决 failed/denied 审计"
|
||||
entry.NAReason = "Domain 只维护业务不变量和领域事实,不依赖审计基础设施;Audit Event 由 Application 写入"
|
||||
entry.ActionCode = ""
|
||||
entry.ActionName = ""
|
||||
entry.Category = ""
|
||||
entry.Risk = ""
|
||||
entry.PrimaryResource = ""
|
||||
return entry
|
||||
}
|
||||
entry.AuditEvent = "必须"
|
||||
entry.ActionName = "执行业务方法(" + method + ")"
|
||||
entry.Category = categoryFor(owner)
|
||||
entry.Transaction = "关键成功事件与业务事实同一 GORM 事务"
|
||||
entry.FailureStrategy = "业务回滚后的 failed/denied 使用独立短事务;审计二次失败记录 critical"
|
||||
return entry
|
||||
}
|
||||
|
||||
func routeSummary(expr ast.Expr) string {
|
||||
composite, ok := expr.(*ast.CompositeLit)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
for _, element := range composite.Elts {
|
||||
pair, ok := element.(*ast.KeyValueExpr)
|
||||
if !ok || expression(pair.Key) != "Summary" {
|
||||
continue
|
||||
}
|
||||
value, _ := stringLiteral(pair.Value)
|
||||
return value
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func scheduledTask(call *ast.CallExpr) (string, string, bool) {
|
||||
if len(call.Args) < 2 {
|
||||
return "", "", false
|
||||
}
|
||||
schedule, _ := stringLiteral(call.Args[0])
|
||||
taskCall, ok := call.Args[1].(*ast.CallExpr)
|
||||
if !ok {
|
||||
return "", "", false
|
||||
}
|
||||
selector, ok := taskCall.Fun.(*ast.SelectorExpr)
|
||||
if !ok || selector.Sel.Name != "NewTask" || len(taskCall.Args) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return expression(taskCall.Args[0]), schedule, true
|
||||
}
|
||||
|
||||
func stringLiteral(expr ast.Expr) (string, bool) {
|
||||
literal, ok := expr.(*ast.BasicLit)
|
||||
if !ok || literal.Kind != token.STRING {
|
||||
return "", false
|
||||
}
|
||||
value, err := strconv.Unquote(literal.Value)
|
||||
return value, err == nil
|
||||
}
|
||||
|
||||
func expression(expr ast.Expr) string {
|
||||
switch value := expr.(type) {
|
||||
case *ast.Ident:
|
||||
return value.Name
|
||||
case *ast.SelectorExpr:
|
||||
return expression(value.X) + "." + value.Sel.Name
|
||||
case *ast.BasicLit:
|
||||
return value.Value
|
||||
case *ast.CallExpr:
|
||||
return expression(value.Fun)
|
||||
default:
|
||||
return fmt.Sprintf("%T", expr)
|
||||
}
|
||||
}
|
||||
|
||||
func httpActorSource(file string) string {
|
||||
switch {
|
||||
case strings.HasSuffix(file, "personal.go"):
|
||||
return "personal_customer/personal_api"
|
||||
case strings.HasSuffix(file, "order.go"):
|
||||
return "按路由分为 admin_user/admin_api 或外部回调入口"
|
||||
default:
|
||||
return "登录账号快照/admin_api"
|
||||
}
|
||||
}
|
||||
|
||||
func isSensitiveRead(file, path, summary string) bool {
|
||||
text := strings.ToLower(file + " " + path + " " + summary)
|
||||
for _, marker := range []string{"download", "export", "realname-link", "实名", "敏感", "完整", "operation-password"} {
|
||||
if strings.Contains(text, marker) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func integrationDecision(file, path string) string {
|
||||
text := strings.ToLower(file + " " + path)
|
||||
if strings.Contains(text, "callback") || strings.HasSuffix(file, "order.go") &&
|
||||
(strings.Contains(path, "pay") || strings.Contains(path, "alipay")) {
|
||||
return "必须:业务处理前保存入站安全摘要与幂等标识"
|
||||
}
|
||||
return "无外部交互时 N/A;用例调用 Gateway、支付、企微或运营商时必须"
|
||||
}
|
||||
|
||||
func workerIntegrationDecision(taskType string) string {
|
||||
text := strings.ToLower(taskType)
|
||||
if strings.Contains(text, "polling") {
|
||||
return "必须:每次实际请求或未发送裁决均记录"
|
||||
}
|
||||
return "Worker 调用外部系统时必须;纯本地处理 N/A"
|
||||
}
|
||||
|
||||
func businessIntegrationDecision(file, method string) string {
|
||||
text := strings.ToLower(file + " " + method)
|
||||
for _, marker := range []string{"polling", "gateway", "payment", "wechat", "wecom", "carrier", "sms"} {
|
||||
if strings.Contains(text, marker) {
|
||||
return "调用外部系统或处理回调时必须;纯本地分支 N/A"
|
||||
}
|
||||
}
|
||||
return "N/A:当前方法按代码位置属于本地业务用例;后续新增外部调用必须重新分类"
|
||||
}
|
||||
|
||||
func ledgerDecision(owner string) string {
|
||||
for _, marker := range []string{"order", "recharge", "refund", "commission", "wallet"} {
|
||||
if strings.Contains(owner, marker) {
|
||||
return "必须:订单、充值、退款、钱包流水等既有业务表是领域权威"
|
||||
}
|
||||
}
|
||||
if strings.Contains(owner, "import") || strings.Contains(owner, "export") {
|
||||
return "业务任务及明细表是批量结果权威"
|
||||
}
|
||||
return "既有业务表是状态事实;Audit Event 不替代业务模型"
|
||||
}
|
||||
|
||||
func riskFor(owner, path, summary string) string {
|
||||
text := strings.ToLower(owner + " " + path + " " + summary)
|
||||
for _, marker := range []string{"wallet", "refund", "recharge", "permission", "role", "password", "config", "权限", "资金", "退款", "充值"} {
|
||||
if strings.Contains(text, marker) {
|
||||
return "high"
|
||||
}
|
||||
}
|
||||
return "normal"
|
||||
}
|
||||
|
||||
func categoryFor(owner string) string {
|
||||
text := strings.ToLower(owner)
|
||||
for _, marker := range []string{"wallet", "refund", "recharge", "commission", "order"} {
|
||||
if strings.Contains(text, marker) {
|
||||
return "finance"
|
||||
}
|
||||
}
|
||||
for _, marker := range []string{"account", "role", "permission", "auth"} {
|
||||
if strings.Contains(text, marker) {
|
||||
return "security"
|
||||
}
|
||||
}
|
||||
for _, marker := range []string{"card", "device", "asset", "polling"} {
|
||||
if strings.Contains(text, marker) {
|
||||
return "asset"
|
||||
}
|
||||
}
|
||||
return "business"
|
||||
}
|
||||
|
||||
func actionCode(owner, handler string) string {
|
||||
method := handler
|
||||
if index := strings.LastIndex(method, "."); index >= 0 {
|
||||
method = method[index+1:]
|
||||
}
|
||||
return normalize(owner) + "." + normalize(method)
|
||||
}
|
||||
|
||||
func workerOwner(taskType string) string {
|
||||
return normalize(strings.TrimPrefix(taskType, "constants.TaskType"))
|
||||
}
|
||||
|
||||
func isBusinessMethod(name string) bool {
|
||||
for _, prefix := range []string{
|
||||
"Create", "Update", "Delete", "Set", "Assign", "Remove", "Cancel", "Reject", "Approve",
|
||||
"Import", "Allocate", "Recall", "Stop", "Resume", "Bind", "Unbind", "Reset", "Activate",
|
||||
"Deactivate", "Trigger", "Handle", "Process", "Execute", "Replay", "Release", "Change", "Pay",
|
||||
"Refund", "Recharge", "Withdraw", "Grant", "Revoke", "Deduct", "Credit", "Debit", "Freeze",
|
||||
"Unfreeze", "Resolve", "Expire", "Invalidate", "Archive", "Cleanup", "Adjust", "Add", "Batch",
|
||||
"Enable", "Disable", "Login", "Logout", "Refresh", "Upload", "Download", "Save", "Restore",
|
||||
"Submit", "Sync", "Migrate", "Send",
|
||||
} {
|
||||
if strings.HasPrefix(name, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalize(value string) string {
|
||||
value = strings.Trim(value, "\"")
|
||||
var output []rune
|
||||
for index, current := range []rune(value) {
|
||||
if current >= 'A' && current <= 'Z' {
|
||||
if index > 0 {
|
||||
output = append(output, '_')
|
||||
}
|
||||
current += 'a' - 'A'
|
||||
}
|
||||
if current == '-' || current == ':' || current == '/' {
|
||||
current = '_'
|
||||
}
|
||||
output = append(output, current)
|
||||
}
|
||||
return strings.Trim(strings.ReplaceAll(string(output), "__", "_"), "_")
|
||||
}
|
||||
284
internal/infrastructure/integrationlog/repository.go
Normal file
284
internal/infrastructure/integrationlog/repository.go
Normal file
@@ -0,0 +1,284 @@
|
||||
// Package integrationlog 提供外部交互尝试的可靠持久化能力。
|
||||
package integrationlog
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"gorm.io/datatypes"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/sanitizer"
|
||||
)
|
||||
|
||||
// Attempt 描述一次外部调用前必须持久化的稳定事实。
|
||||
type Attempt struct {
|
||||
IntegrationID string
|
||||
Provider string
|
||||
Direction string
|
||||
Operation string
|
||||
ExternalID *string
|
||||
ResourceType string
|
||||
ResourceID *string
|
||||
ResourceKey *string
|
||||
TriggerSource *string
|
||||
TriggerScene *string
|
||||
TriggerSeries *string
|
||||
ScheduledAt *time.Time
|
||||
StartedAt *time.Time
|
||||
Attempt int
|
||||
RequestSummary any
|
||||
Metadata any
|
||||
RequestID *string
|
||||
CorrelationID *string
|
||||
AuditEventID *uint
|
||||
InitialResult string
|
||||
RecoveryStrategy *string
|
||||
}
|
||||
|
||||
// Completion 描述外部尝试从待处理状态进入终态的结果。
|
||||
type Completion struct {
|
||||
Result string
|
||||
HTTPStatus int
|
||||
ProviderCode string
|
||||
ProviderMessage string
|
||||
ResponseSummary any
|
||||
DurationMS int64
|
||||
StateChanged bool
|
||||
AuditEventID *uint
|
||||
RecoveryStrategy string
|
||||
}
|
||||
|
||||
// InboundAttempt 描述业务处理前必须保存的入站回调安全事实。
|
||||
type InboundAttempt struct {
|
||||
IntegrationID string
|
||||
IdempotencyKey string
|
||||
Provider string
|
||||
Operation string
|
||||
ExternalID string
|
||||
ResourceType string
|
||||
ResourceID *string
|
||||
ResourceKey *string
|
||||
RawPayload []byte
|
||||
ContentType string
|
||||
RequestID *string
|
||||
CorrelationID *string
|
||||
}
|
||||
|
||||
// Repository 负责创建稳定尝试及受控地进入终态。
|
||||
type Repository struct {
|
||||
db *gorm.DB
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewRepository 创建 Integration Log Repository。
|
||||
func NewRepository(db *gorm.DB) *Repository {
|
||||
return &Repository{db: db, now: time.Now}
|
||||
}
|
||||
|
||||
// Start 在实际调用外部系统前持久化尝试事实。
|
||||
func (r *Repository) Start(ctx context.Context, input Attempt) (*model.IntegrationLog, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Integration Log 数据库未配置")
|
||||
}
|
||||
if err := validateAttempt(input); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
requestSummary, err := marshalSummary(input.RequestSummary)
|
||||
if err != nil {
|
||||
return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "Integration Log 请求摘要无效")
|
||||
}
|
||||
metadata, err := marshalSummary(input.Metadata)
|
||||
if err != nil {
|
||||
return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "Integration Log 元数据无效")
|
||||
}
|
||||
if input.IntegrationID == "" {
|
||||
input.IntegrationID = uuid.NewString()
|
||||
}
|
||||
if input.Attempt <= 0 {
|
||||
input.Attempt = 1
|
||||
}
|
||||
if input.StartedAt == nil {
|
||||
startedAt := r.now().UTC()
|
||||
input.StartedAt = &startedAt
|
||||
}
|
||||
result := input.InitialResult
|
||||
if result == "" {
|
||||
result = constants.IntegrationResultPending
|
||||
}
|
||||
resourceType := optionalString(input.ResourceType)
|
||||
log := &model.IntegrationLog{
|
||||
IntegrationID: input.IntegrationID, Provider: input.Provider, Direction: input.Direction,
|
||||
Operation: input.Operation, ExternalID: input.ExternalID, ResourceType: resourceType,
|
||||
ResourceID: input.ResourceID, ResourceKey: input.ResourceKey, TriggerSource: input.TriggerSource,
|
||||
TriggerScene: input.TriggerScene, TriggerSeries: input.TriggerSeries, ScheduledAt: input.ScheduledAt,
|
||||
StartedAt: input.StartedAt, Attempt: input.Attempt, Result: result,
|
||||
RequestSummary: requestSummary, Metadata: metadata, RequestID: input.RequestID,
|
||||
CorrelationID: input.CorrelationID, AuditEventID: input.AuditEventID,
|
||||
RecoveryStrategy: input.RecoveryStrategy,
|
||||
}
|
||||
if err := r.db.WithContext(ctx).Create(log).Error; err != nil {
|
||||
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "写入 Integration Log 失败")
|
||||
}
|
||||
return log, nil
|
||||
}
|
||||
|
||||
// Complete 仅允许把待处理尝试条件更新为一个公开终态。
|
||||
func (r *Repository) Complete(ctx context.Context, integrationID string, completion Completion) (*model.IntegrationLog, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Integration Log 数据库未配置")
|
||||
}
|
||||
if integrationID == "" || !isTerminalResult(completion.Result) {
|
||||
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 终态参数无效")
|
||||
}
|
||||
if completion.Result == constants.IntegrationResultUnknown && strings.TrimSpace(completion.RecoveryStrategy) == "" {
|
||||
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "结果未知必须记录明确恢复策略")
|
||||
}
|
||||
responseSummary, err := marshalSummary(completion.ResponseSummary)
|
||||
if err != nil {
|
||||
return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "Integration Log 响应摘要无效")
|
||||
}
|
||||
updates := map[string]any{
|
||||
"result": completion.Result, "duration_ms": completion.DurationMS,
|
||||
"state_changed": completion.StateChanged, "response_summary": responseSummary,
|
||||
"updated_at": r.now().UTC(),
|
||||
}
|
||||
if completion.HTTPStatus != 0 {
|
||||
updates["http_status"] = completion.HTTPStatus
|
||||
}
|
||||
if completion.ProviderCode != "" {
|
||||
updates["provider_code"] = completion.ProviderCode
|
||||
}
|
||||
if completion.ProviderMessage != "" {
|
||||
updates["provider_message"] = sanitizer.TextSummary(completion.ProviderMessage)
|
||||
}
|
||||
if completion.AuditEventID != nil {
|
||||
updates["audit_event_id"] = completion.AuditEventID
|
||||
}
|
||||
if completion.RecoveryStrategy != "" {
|
||||
updates["recovery_strategy"] = completion.RecoveryStrategy
|
||||
}
|
||||
result := r.db.WithContext(ctx).Model(&model.IntegrationLog{}).
|
||||
Where("integration_id = ? AND result = ?", integrationID, constants.IntegrationResultPending).
|
||||
Updates(updates)
|
||||
if result.Error != nil {
|
||||
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, result.Error, "终结 Integration Log 失败")
|
||||
}
|
||||
if result.RowsAffected != 1 {
|
||||
return nil, pkgerrors.New(pkgerrors.CodeConflict, "Integration Log 已进入终态或不存在")
|
||||
}
|
||||
var saved model.IntegrationLog
|
||||
if err := r.db.WithContext(ctx).Where("integration_id = ?", integrationID).First(&saved).Error; err != nil {
|
||||
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "读取 Integration Log 终态失败")
|
||||
}
|
||||
return &saved, nil
|
||||
}
|
||||
|
||||
// RecordInbound 在业务处理前幂等保存入站回调的安全摘要。
|
||||
func (r *Repository) RecordInbound(ctx context.Context, input InboundAttempt) (*model.IntegrationLog, bool, error) {
|
||||
if r == nil || r.db == nil {
|
||||
return nil, false, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Integration Log 数据库未配置")
|
||||
}
|
||||
if input.Provider == "" || input.Operation == "" || input.IdempotencyKey == "" || len(input.RawPayload) == 0 {
|
||||
return nil, false, pkgerrors.New(pkgerrors.CodeInvalidParam, "入站 Integration Log 参数无效")
|
||||
}
|
||||
if input.IntegrationID == "" {
|
||||
input.IntegrationID = uuid.NewString()
|
||||
}
|
||||
hash := sha256.Sum256(input.RawPayload)
|
||||
summary, err := marshalSummary(map[string]any{
|
||||
"content_type": input.ContentType,
|
||||
"payload_bytes": len(input.RawPayload),
|
||||
"content_hash": hex.EncodeToString(hash[:]),
|
||||
})
|
||||
if err != nil {
|
||||
return nil, false, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "入站 Integration Log 摘要无效")
|
||||
}
|
||||
now := r.now().UTC()
|
||||
log := &model.IntegrationLog{
|
||||
IntegrationID: input.IntegrationID, IdempotencyKey: &input.IdempotencyKey,
|
||||
Provider: input.Provider, Direction: constants.IntegrationDirectionInbound, Operation: input.Operation,
|
||||
ExternalID: optionalString(input.ExternalID), ResourceType: optionalString(input.ResourceType),
|
||||
ResourceID: input.ResourceID, ResourceKey: input.ResourceKey, StartedAt: &now, Attempt: 1,
|
||||
Result: constants.IntegrationResultPending, RequestSummary: summary,
|
||||
ContentHash: hex.EncodeToString(hash[:]), RequestID: input.RequestID, CorrelationID: input.CorrelationID,
|
||||
}
|
||||
result := r.db.WithContext(ctx).Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "provider"}, {Name: "operation"}, {Name: "idempotency_key"}},
|
||||
DoNothing: true,
|
||||
}).Create(log)
|
||||
if result.Error != nil {
|
||||
return nil, false, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, result.Error, "写入入站 Integration Log 失败")
|
||||
}
|
||||
if result.RowsAffected == 1 {
|
||||
return log, true, nil
|
||||
}
|
||||
var existing model.IntegrationLog
|
||||
if err := r.db.WithContext(ctx).Where(
|
||||
"provider = ? AND operation = ? AND idempotency_key = ?", input.Provider, input.Operation, input.IdempotencyKey,
|
||||
).First(&existing).Error; err != nil {
|
||||
return nil, false, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "读取重复入站 Integration Log 失败")
|
||||
}
|
||||
if existing.ContentHash != hex.EncodeToString(hash[:]) {
|
||||
return nil, false, pkgerrors.New(pkgerrors.CodeConflict, "入站幂等标识对应的载荷不一致")
|
||||
}
|
||||
return &existing, false, nil
|
||||
}
|
||||
|
||||
func validateAttempt(input Attempt) error {
|
||||
if input.Provider == "" || input.Operation == "" {
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 提供方和操作不能为空")
|
||||
}
|
||||
if input.Direction != constants.IntegrationDirectionInbound && input.Direction != constants.IntegrationDirectionOutbound {
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 方向无效")
|
||||
}
|
||||
if input.InitialResult != "" && input.InitialResult != constants.IntegrationResultPending && !isUnsentResult(input.InitialResult) {
|
||||
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 初始结果只能是待处理或未发送终态")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isTerminalResult(result string) bool {
|
||||
switch result {
|
||||
case constants.IntegrationResultSuccess, constants.IntegrationResultFailed, constants.IntegrationResultUnknown,
|
||||
constants.IntegrationResultNotFound, constants.IntegrationResultInvalidPayload, constants.IntegrationResultIgnored,
|
||||
constants.IntegrationResultMerged, constants.IntegrationResultRateLimited, constants.IntegrationResultCompleted,
|
||||
constants.IntegrationResultCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isUnsentResult(result string) bool {
|
||||
switch result {
|
||||
case constants.IntegrationResultIgnored, constants.IntegrationResultMerged, constants.IntegrationResultRateLimited,
|
||||
constants.IntegrationResultCompleted, constants.IntegrationResultCancelled:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func marshalSummary(value any) (datatypes.JSON, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
encoded, err := sanitizer.MarshalSummary(value)
|
||||
return datatypes.JSON(encoded), err
|
||||
}
|
||||
|
||||
func optionalString(value string) *string {
|
||||
if value == "" {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
package integrationlog_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/testutil"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
)
|
||||
|
||||
func TestOutboundAttemptPersistsBeforeConditionalCompletion(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTemporaryIntegrationLogTable(t, db)
|
||||
repository := integrationlog.NewRepository(db)
|
||||
|
||||
attempt, err := repository.Start(context.Background(), integrationlog.Attempt{
|
||||
IntegrationID: "integration-outbound-1",
|
||||
Provider: "gateway",
|
||||
Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: "query_card_status",
|
||||
ResourceType: "iot_card",
|
||||
ResourceID: testutil.StringPointer("1001"),
|
||||
RequestSummary: map[string]any{
|
||||
"iccid": "8986001234567890123",
|
||||
"access_token": "must-not-persist",
|
||||
"credential": "must-not-persist",
|
||||
"private_url": "https://must-not-persist.example",
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("持久化外部尝试失败:%v", err)
|
||||
}
|
||||
if attempt.Result != constants.IntegrationResultPending {
|
||||
t.Fatalf("调用前必须是待处理状态,得到 %q", attempt.Result)
|
||||
}
|
||||
if strings.Contains(string(attempt.RequestSummary), "must-not-persist") {
|
||||
t.Fatalf("请求摘要泄露禁止字段:%s", attempt.RequestSummary)
|
||||
}
|
||||
|
||||
auditEventID := uint(77)
|
||||
completed, err := repository.Complete(context.Background(), attempt.IntegrationID, integrationlog.Completion{
|
||||
Result: constants.IntegrationResultSuccess,
|
||||
HTTPStatus: 200,
|
||||
ProviderCode: "0",
|
||||
ProviderMessage: "查询成功",
|
||||
StateChanged: true,
|
||||
AuditEventID: &auditEventID,
|
||||
ResponseSummary: map[string]any{"status": "active", "secret": "must-not-persist"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("终结外部尝试失败:%v", err)
|
||||
}
|
||||
if completed.Result != constants.IntegrationResultSuccess || !completed.StateChanged ||
|
||||
completed.AuditEventID == nil || *completed.AuditEventID != auditEventID {
|
||||
t.Fatalf("外部尝试终态错误:%+v", completed)
|
||||
}
|
||||
if completed.ProviderMessage == nil || strings.Contains(*completed.ProviderMessage, "查询成功") {
|
||||
t.Fatalf("不可信渠道消息必须转换为不可逆摘要:%+v", completed.ProviderMessage)
|
||||
}
|
||||
|
||||
if _, err := repository.Complete(context.Background(), attempt.IntegrationID, integrationlog.Completion{
|
||||
Result: constants.IntegrationResultFailed,
|
||||
}); err == nil {
|
||||
t.Fatal("既有终态不得被重复完成改写")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIntegrationResultNamesCoverEveryPublicTerminalState(t *testing.T) {
|
||||
for _, result := range []string{
|
||||
constants.IntegrationResultSuccess, constants.IntegrationResultFailed, constants.IntegrationResultUnknown,
|
||||
constants.IntegrationResultNotFound, constants.IntegrationResultInvalidPayload, constants.IntegrationResultIgnored,
|
||||
constants.IntegrationResultMerged, constants.IntegrationResultRateLimited, constants.IntegrationResultCompleted,
|
||||
constants.IntegrationResultCancelled,
|
||||
} {
|
||||
if constants.IntegrationResultName(result) == "" {
|
||||
t.Fatalf("公开终态 %q 缺少中文名称", result)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboundAttemptIsIdempotentAndNeverPersistsRawPayload(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTemporaryIntegrationLogTable(t, db)
|
||||
repository := integrationlog.NewRepository(db)
|
||||
|
||||
input := integrationlog.InboundAttempt{
|
||||
IntegrationID: "integration-inbound-1",
|
||||
IdempotencyKey: "wechat:callback:transaction-1",
|
||||
Provider: "wechat",
|
||||
Operation: "payment_callback",
|
||||
ExternalID: "transaction-1",
|
||||
RawPayload: []byte(`{"sign":"raw-signature","ciphertext":"raw-ciphertext"}`),
|
||||
ContentType: "application/json",
|
||||
}
|
||||
first, created, err := repository.RecordInbound(context.Background(), input)
|
||||
if err != nil || !created {
|
||||
t.Fatalf("首次保存入站尝试失败:created=%v err=%v", created, err)
|
||||
}
|
||||
second, created, err := repository.RecordInbound(context.Background(), input)
|
||||
if err != nil || created {
|
||||
t.Fatalf("重复入站尝试应返回既有事实:created=%v err=%v", created, err)
|
||||
}
|
||||
if first.ID != second.ID || first.ContentHash == "" {
|
||||
t.Fatalf("重复回调未复用稳定事实或缺少内容哈希:first=%+v second=%+v", first, second)
|
||||
}
|
||||
serialized := string(first.RequestSummary)
|
||||
if strings.Contains(serialized, "raw-signature") || strings.Contains(serialized, "raw-ciphertext") {
|
||||
t.Fatalf("入站摘要泄露原始载荷:%s", serialized)
|
||||
}
|
||||
conflict := input
|
||||
conflict.IntegrationID = "integration-inbound-conflict"
|
||||
conflict.RawPayload = []byte(`{"different":true}`)
|
||||
if _, _, err := repository.RecordInbound(context.Background(), conflict); err == nil {
|
||||
t.Fatal("相同入站幂等标识对应不同载荷时必须拒绝")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownResultRequiresExplicitRecoveryAndUnsentAttemptIsTerminal(t *testing.T) {
|
||||
db := testutil.NewPostgresTransaction(t)
|
||||
createTemporaryIntegrationLogTable(t, db)
|
||||
repository := integrationlog.NewRepository(db)
|
||||
|
||||
pending, err := repository.Start(context.Background(), integrationlog.Attempt{
|
||||
IntegrationID: "integration-unknown-1", Provider: "wecom",
|
||||
Direction: constants.IntegrationDirectionOutbound, Operation: "submit_approval",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("准备结果未知尝试失败:%v", err)
|
||||
}
|
||||
if _, err := repository.Complete(context.Background(), pending.IntegrationID, integrationlog.Completion{
|
||||
Result: constants.IntegrationResultUnknown,
|
||||
}); err == nil {
|
||||
t.Fatal("结果未知必须记录明确恢复策略")
|
||||
}
|
||||
unknown, err := repository.Complete(context.Background(), pending.IntegrationID, integrationlog.Completion{
|
||||
Result: constants.IntegrationResultUnknown, RecoveryStrategy: "按原外部单号查询结果,禁止盲目重发",
|
||||
})
|
||||
if err != nil || unknown.RecoveryStrategy == nil {
|
||||
t.Fatalf("保存结果未知及恢复策略失败:log=%+v err=%v", unknown, err)
|
||||
}
|
||||
|
||||
for index, terminal := range []string{
|
||||
constants.IntegrationResultIgnored, constants.IntegrationResultMerged, constants.IntegrationResultRateLimited,
|
||||
constants.IntegrationResultCompleted, constants.IntegrationResultCancelled,
|
||||
} {
|
||||
unsent, err := repository.Start(context.Background(), integrationlog.Attempt{
|
||||
IntegrationID: fmt.Sprintf("integration-unsent-%d", index), Provider: "gateway",
|
||||
Direction: constants.IntegrationDirectionOutbound, Operation: "query_card_status", InitialResult: terminal,
|
||||
})
|
||||
if err != nil || unsent.Result != terminal || unsent.HTTPStatus != nil {
|
||||
t.Fatalf("未发送终态不得伪造 HTTP 结果:log=%+v err=%v", unsent, err)
|
||||
}
|
||||
if _, err := repository.Complete(context.Background(), unsent.IntegrationID, integrationlog.Completion{
|
||||
Result: constants.IntegrationResultSuccess,
|
||||
}); err == nil {
|
||||
t.Fatal("未发送终态不得被后续完成改写")
|
||||
}
|
||||
}
|
||||
if _, err := repository.Start(context.Background(), integrationlog.Attempt{
|
||||
Provider: "gateway", Direction: constants.IntegrationDirectionOutbound,
|
||||
Operation: "query_card_status", InitialResult: constants.IntegrationResultSuccess,
|
||||
}); err == nil {
|
||||
t.Fatal("实际调用结果不得绕过 pending 直接写终态")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExplicitFailureAndConcurrentCompletionKeepFirstTerminalFact(t *testing.T) {
|
||||
db := testutil.NewPostgresDatabase(t)
|
||||
integrationIDs := []string{"integration-failed-1", "integration-concurrent-1"}
|
||||
t.Cleanup(func() {
|
||||
if err := db.Where("integration_id IN ?", integrationIDs).Delete(&model.IntegrationLog{}).Error; err != nil {
|
||||
t.Errorf("清理 Integration Log 并发测试数据失败:%v", err)
|
||||
}
|
||||
})
|
||||
if err := db.Where("integration_id IN ?", integrationIDs).Delete(&model.IntegrationLog{}).Error; err != nil {
|
||||
t.Fatalf("准备 Integration Log 并发测试隔离数据失败:%v", err)
|
||||
}
|
||||
repository := integrationlog.NewRepository(db)
|
||||
failedAttempt, err := repository.Start(context.Background(), integrationlog.Attempt{
|
||||
IntegrationID: "integration-failed-1", Provider: "gateway",
|
||||
Direction: constants.IntegrationDirectionOutbound, Operation: "query_card_status",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("准备明确失败尝试失败:%v", err)
|
||||
}
|
||||
failed, err := repository.Complete(context.Background(), failedAttempt.IntegrationID, integrationlog.Completion{
|
||||
Result: constants.IntegrationResultFailed, ProviderCode: "UPSTREAM_REJECTED", ProviderMessage: "上游明确拒绝",
|
||||
})
|
||||
if err != nil || failed.Result != constants.IntegrationResultFailed {
|
||||
t.Fatalf("明确失败未保存为失败终态:log=%+v err=%v", failed, err)
|
||||
}
|
||||
|
||||
attempt, err := repository.Start(context.Background(), integrationlog.Attempt{
|
||||
IntegrationID: "integration-concurrent-1", Provider: "gateway",
|
||||
Direction: constants.IntegrationDirectionOutbound, Operation: "query_card_status",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("准备并发终结尝试失败:%v", err)
|
||||
}
|
||||
|
||||
results := make(chan error, 2)
|
||||
var group sync.WaitGroup
|
||||
for _, terminal := range []string{constants.IntegrationResultSuccess, constants.IntegrationResultFailed} {
|
||||
group.Add(1)
|
||||
go func(result string) {
|
||||
defer group.Done()
|
||||
_, completeErr := repository.Complete(context.Background(), attempt.IntegrationID, integrationlog.Completion{Result: result})
|
||||
results <- completeErr
|
||||
}(terminal)
|
||||
}
|
||||
group.Wait()
|
||||
close(results)
|
||||
successes := 0
|
||||
for completeErr := range results {
|
||||
if completeErr == nil {
|
||||
successes++
|
||||
}
|
||||
}
|
||||
if successes != 1 {
|
||||
t.Fatalf("并发终结必须且只能一个成功,实际成功 %d 次", successes)
|
||||
}
|
||||
var saved model.IntegrationLog
|
||||
if err := db.Where("integration_id = ?", attempt.IntegrationID).First(&saved).Error; err != nil {
|
||||
t.Fatalf("读取并发终态失败:%v", err)
|
||||
}
|
||||
if saved.Result != constants.IntegrationResultSuccess && saved.Result != constants.IntegrationResultFailed {
|
||||
t.Fatalf("并发终结未保存明确成功或失败:%+v", saved)
|
||||
}
|
||||
}
|
||||
|
||||
func createTemporaryIntegrationLogTable(t *testing.T, db *gorm.DB) {
|
||||
t.Helper()
|
||||
if err := db.Exec(`CREATE TEMP TABLE tb_integration_log (
|
||||
id bigserial PRIMARY KEY,
|
||||
integration_id varchar(64) NOT NULL UNIQUE,
|
||||
idempotency_key varchar(160),
|
||||
provider varchar(32) NOT NULL,
|
||||
direction varchar(16) NOT NULL,
|
||||
operation varchar(64) NOT NULL,
|
||||
external_id varchar(128),
|
||||
resource_type varchar(64),
|
||||
resource_id varchar(128),
|
||||
resource_key varchar(128),
|
||||
trigger_source varchar(32),
|
||||
trigger_scene varchar(128),
|
||||
trigger_series varchar(64),
|
||||
scheduled_at timestamptz,
|
||||
started_at timestamptz,
|
||||
attempt integer NOT NULL DEFAULT 1,
|
||||
result varchar(20) NOT NULL,
|
||||
http_status integer,
|
||||
provider_code varchar(64),
|
||||
provider_message varchar(500),
|
||||
request_summary jsonb,
|
||||
response_summary jsonb,
|
||||
content_hash varchar(64),
|
||||
duration_ms bigint NOT NULL DEFAULT 0,
|
||||
state_changed boolean NOT NULL DEFAULT false,
|
||||
metadata jsonb,
|
||||
recovery_strategy varchar(255),
|
||||
request_id varchar(64),
|
||||
correlation_id varchar(64),
|
||||
audit_event_id bigint,
|
||||
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||
updated_at timestamptz NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_test_integration_idempotency UNIQUE (provider, operation, idempotency_key)
|
||||
) ON COMMIT DROP`).Error; err != nil {
|
||||
t.Fatalf("创建 Integration Log 测试表失败:%v", err)
|
||||
}
|
||||
}
|
||||
48
internal/model/integration_log.go
Normal file
48
internal/model/integration_log.go
Normal file
@@ -0,0 +1,48 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/datatypes"
|
||||
)
|
||||
|
||||
// IntegrationLog 是外部交互及未发送尝试的权威持久化模型。
|
||||
type IntegrationLog struct {
|
||||
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
|
||||
IntegrationID string `gorm:"column:integration_id;type:varchar(64);not null;uniqueIndex" json:"integration_id"`
|
||||
IdempotencyKey *string `gorm:"column:idempotency_key;type:varchar(160)" json:"idempotency_key,omitempty"`
|
||||
Provider string `gorm:"column:provider;type:varchar(32);not null" json:"provider"`
|
||||
Direction string `gorm:"column:direction;type:varchar(16);not null" json:"direction"`
|
||||
Operation string `gorm:"column:operation;type:varchar(64);not null" json:"operation"`
|
||||
ExternalID *string `gorm:"column:external_id;type:varchar(128)" json:"external_id,omitempty"`
|
||||
ResourceType *string `gorm:"column:resource_type;type:varchar(64)" json:"resource_type,omitempty"`
|
||||
ResourceID *string `gorm:"column:resource_id;type:varchar(128)" json:"resource_id,omitempty"`
|
||||
ResourceKey *string `gorm:"column:resource_key;type:varchar(128)" json:"resource_key,omitempty"`
|
||||
TriggerSource *string `gorm:"column:trigger_source;type:varchar(32)" json:"trigger_source,omitempty"`
|
||||
TriggerScene *string `gorm:"column:trigger_scene;type:varchar(128)" json:"trigger_scene,omitempty"`
|
||||
TriggerSeries *string `gorm:"column:trigger_series;type:varchar(64)" json:"trigger_series,omitempty"`
|
||||
ScheduledAt *time.Time `gorm:"column:scheduled_at;type:timestamptz" json:"scheduled_at,omitempty"`
|
||||
StartedAt *time.Time `gorm:"column:started_at;type:timestamptz" json:"started_at,omitempty"`
|
||||
Attempt int `gorm:"column:attempt;type:int;not null;default:1" json:"attempt"`
|
||||
Result string `gorm:"column:result;type:varchar(20);not null" json:"result"`
|
||||
HTTPStatus *int `gorm:"column:http_status" json:"http_status,omitempty"`
|
||||
ProviderCode *string `gorm:"column:provider_code;type:varchar(64)" json:"provider_code,omitempty"`
|
||||
ProviderMessage *string `gorm:"column:provider_message;type:varchar(500)" json:"provider_message,omitempty"`
|
||||
RequestSummary datatypes.JSON `gorm:"column:request_summary;type:jsonb" json:"request_summary,omitempty"`
|
||||
ResponseSummary datatypes.JSON `gorm:"column:response_summary;type:jsonb" json:"response_summary,omitempty"`
|
||||
ContentHash string `gorm:"column:content_hash;type:varchar(64);not null;default:''" json:"content_hash,omitempty"`
|
||||
DurationMS int64 `gorm:"column:duration_ms;not null;default:0" json:"duration_ms"`
|
||||
StateChanged bool `gorm:"column:state_changed;not null;default:false" json:"state_changed"`
|
||||
Metadata datatypes.JSON `gorm:"column:metadata;type:jsonb" json:"metadata,omitempty"`
|
||||
RecoveryStrategy *string `gorm:"column:recovery_strategy;type:varchar(255)" json:"recovery_strategy,omitempty"`
|
||||
RequestID *string `gorm:"column:request_id;type:varchar(64)" json:"request_id,omitempty"`
|
||||
CorrelationID *string `gorm:"column:correlation_id;type:varchar(64)" json:"correlation_id,omitempty"`
|
||||
AuditEventID *uint `gorm:"column:audit_event_id" json:"audit_event_id,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;type:timestamptz;not null;autoCreateTime" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;type:timestamptz;not null;autoUpdateTime" json:"updated_at"`
|
||||
}
|
||||
|
||||
// TableName 返回外部集成日志表名。
|
||||
func (IntegrationLog) TableName() string {
|
||||
return "tb_integration_log"
|
||||
}
|
||||
@@ -15,6 +15,19 @@ import (
|
||||
|
||||
// NewPostgresTransaction 创建自动回滚的 PostgreSQL 测试事务。
|
||||
func NewPostgresTransaction(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db := NewPostgresDatabase(t)
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatalf("开启测试事务失败:%v", tx.Error)
|
||||
}
|
||||
t.Cleanup(func() { _ = tx.Rollback().Error })
|
||||
return tx
|
||||
}
|
||||
|
||||
// NewPostgresDatabase 创建自动关闭、可使用多个连接的 PostgreSQL 测试数据库。
|
||||
// 仅在需要验证真实并发条件更新时使用;普通集成测试仍优先使用自动回滚事务。
|
||||
func NewPostgresDatabase(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
if os.Getenv("JUNHONG_DATABASE_HOST") == "" {
|
||||
t.Skip("未加载 .env.local,跳过依赖真实 PostgreSQL 的集成测试")
|
||||
@@ -27,17 +40,12 @@ func NewPostgresTransaction(t *testing.T) *gorm.DB {
|
||||
if err != nil {
|
||||
t.Fatalf("连接 PostgreSQL 失败:%v", err)
|
||||
}
|
||||
tx := db.Begin()
|
||||
if tx.Error != nil {
|
||||
t.Fatalf("开启测试事务失败:%v", tx.Error)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = tx.Rollback().Error
|
||||
if sqlDB, dbErr := db.DB(); dbErr == nil {
|
||||
_ = sqlDB.Close()
|
||||
}
|
||||
})
|
||||
return tx
|
||||
return db
|
||||
}
|
||||
|
||||
// NewRedisClient 创建真实 Redis 测试客户端并在测试结束时关闭。
|
||||
|
||||
11
migrations/000167_create_audit_integration_log.down.sql
Normal file
11
migrations/000167_create_audit_integration_log.down.sql
Normal file
@@ -0,0 +1,11 @@
|
||||
-- 已产生外部交互事实后禁止通过降级删除,避免链路历史丢失。
|
||||
DO $$
|
||||
BEGIN
|
||||
IF to_regclass('tb_integration_log') IS NOT NULL
|
||||
AND EXISTS (SELECT 1 FROM tb_integration_log LIMIT 1) THEN
|
||||
RAISE EXCEPTION 'tb_integration_log 已存在外部交互事实,禁止删表回滚,请停止生产者后向前修复';
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
DROP TABLE IF EXISTS tb_integration_log;
|
||||
55
migrations/000167_create_audit_integration_log.up.sql
Normal file
55
migrations/000167_create_audit_integration_log.up.sql
Normal file
@@ -0,0 +1,55 @@
|
||||
-- 创建外部集成尝试权威记录;不建立外键,业务关系通过稳定 ID 显式维护。
|
||||
CREATE TABLE tb_integration_log (
|
||||
id bigserial PRIMARY KEY,
|
||||
integration_id varchar(64) NOT NULL,
|
||||
idempotency_key varchar(160),
|
||||
provider varchar(32) NOT NULL,
|
||||
direction varchar(16) NOT NULL,
|
||||
operation varchar(64) NOT NULL,
|
||||
external_id varchar(128),
|
||||
resource_type varchar(64),
|
||||
resource_id varchar(128),
|
||||
resource_key varchar(128),
|
||||
trigger_source varchar(32),
|
||||
trigger_scene varchar(128),
|
||||
trigger_series varchar(64),
|
||||
scheduled_at timestamptz,
|
||||
started_at timestamptz,
|
||||
attempt integer NOT NULL DEFAULT 1,
|
||||
result varchar(20) NOT NULL,
|
||||
http_status integer,
|
||||
provider_code varchar(64),
|
||||
provider_message varchar(500),
|
||||
request_summary jsonb,
|
||||
response_summary jsonb,
|
||||
content_hash varchar(64) NOT NULL DEFAULT '',
|
||||
duration_ms bigint NOT NULL DEFAULT 0,
|
||||
state_changed boolean NOT NULL DEFAULT false,
|
||||
metadata jsonb,
|
||||
recovery_strategy varchar(255),
|
||||
request_id varchar(64),
|
||||
correlation_id varchar(64),
|
||||
audit_event_id bigint,
|
||||
created_at timestamptz NOT NULL DEFAULT NOW(),
|
||||
updated_at timestamptz NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_integration_log_id UNIQUE (integration_id),
|
||||
CONSTRAINT uq_integration_log_idempotency UNIQUE (provider, operation, idempotency_key),
|
||||
CONSTRAINT ck_integration_log_direction CHECK (direction IN ('inbound', 'outbound')),
|
||||
CONSTRAINT ck_integration_log_result CHECK (result IN (
|
||||
'pending', 'success', 'failed', 'unknown', 'not_found', 'invalid_payload',
|
||||
'ignored', 'merged', 'rate_limited', 'completed', 'cancelled'
|
||||
)),
|
||||
CONSTRAINT ck_integration_log_attempt CHECK (attempt > 0),
|
||||
CONSTRAINT ck_integration_log_duration CHECK (duration_ms >= 0)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_integration_log_time ON tb_integration_log (created_at DESC, id DESC);
|
||||
CREATE INDEX idx_integration_log_provider ON tb_integration_log (provider, operation, result, created_at DESC);
|
||||
CREATE INDEX idx_integration_log_resource ON tb_integration_log (resource_type, resource_id, created_at DESC);
|
||||
CREATE INDEX idx_integration_log_resource_key ON tb_integration_log (resource_type, resource_key, created_at DESC);
|
||||
CREATE INDEX idx_integration_log_trigger ON tb_integration_log (trigger_series, attempt);
|
||||
CREATE INDEX idx_integration_log_request ON tb_integration_log (request_id, created_at ASC) WHERE request_id IS NOT NULL;
|
||||
CREATE INDEX idx_integration_log_correlation ON tb_integration_log (correlation_id, created_at ASC) WHERE correlation_id IS NOT NULL;
|
||||
|
||||
COMMENT ON TABLE tb_integration_log IS '外部集成调用、回调及未发送尝试记录';
|
||||
COMMENT ON COLUMN tb_integration_log.result IS '尝试结果,pending 仅为内部执行态,其余为公开终态';
|
||||
51
pkg/constants/integration_log.go
Normal file
51
pkg/constants/integration_log.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package constants
|
||||
|
||||
const (
|
||||
// IntegrationDirectionInbound 表示外部系统调用本系统。
|
||||
IntegrationDirectionInbound = "inbound"
|
||||
// IntegrationDirectionOutbound 表示本系统调用外部系统。
|
||||
IntegrationDirectionOutbound = "outbound"
|
||||
)
|
||||
|
||||
const (
|
||||
// IntegrationResultPending 表示外部尝试已建立但尚未终结。
|
||||
IntegrationResultPending = "pending"
|
||||
// IntegrationResultSuccess 表示外部尝试成功。
|
||||
IntegrationResultSuccess = "success"
|
||||
// IntegrationResultFailed 表示外部尝试明确失败。
|
||||
IntegrationResultFailed = "failed"
|
||||
// IntegrationResultUnknown 表示请求已发出但结果未知,需要按记录的策略恢复。
|
||||
IntegrationResultUnknown = "unknown"
|
||||
// IntegrationResultNotFound 表示外部资源不存在。
|
||||
IntegrationResultNotFound = "not_found"
|
||||
// IntegrationResultInvalidPayload 表示入站载荷无效。
|
||||
IntegrationResultInvalidPayload = "invalid_payload"
|
||||
// IntegrationResultIgnored 表示外部尝试被安全忽略。
|
||||
IntegrationResultIgnored = "ignored"
|
||||
// IntegrationResultMerged 表示尝试被合并且未发送请求。
|
||||
IntegrationResultMerged = "merged"
|
||||
// IntegrationResultRateLimited 表示尝试因限频未发送请求。
|
||||
IntegrationResultRateLimited = "rate_limited"
|
||||
// IntegrationResultCompleted 表示业务已达预期,尝试提前完成且未发送请求。
|
||||
IntegrationResultCompleted = "completed"
|
||||
// IntegrationResultCancelled 表示尝试已取消。
|
||||
IntegrationResultCancelled = "cancelled"
|
||||
)
|
||||
|
||||
// IntegrationResultName 返回外部尝试结果的中文名称。
|
||||
func IntegrationResultName(result string) string {
|
||||
names := map[string]string{
|
||||
IntegrationResultPending: "待处理",
|
||||
IntegrationResultSuccess: "成功",
|
||||
IntegrationResultFailed: "失败",
|
||||
IntegrationResultUnknown: "结果未知",
|
||||
IntegrationResultNotFound: "未找到",
|
||||
IntegrationResultInvalidPayload: "无效载荷",
|
||||
IntegrationResultIgnored: "已忽略",
|
||||
IntegrationResultMerged: "已合并",
|
||||
IntegrationResultRateLimited: "已限频",
|
||||
IntegrationResultCompleted: "已提前完成",
|
||||
IntegrationResultCancelled: "已取消",
|
||||
}
|
||||
return names[result]
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package logger
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -10,25 +11,34 @@ import (
|
||||
|
||||
// AccessPolicy 是敏感路由的安全摘要策略。
|
||||
type AccessPolicy struct {
|
||||
Name string
|
||||
Sensitive bool
|
||||
SafeFields map[string]struct{}
|
||||
FileFields map[string]struct{}
|
||||
Name string
|
||||
Sensitive bool
|
||||
PresenceOnly bool
|
||||
SafeFields map[string]struct{}
|
||||
ResultFields map[string]struct{}
|
||||
FileFields map[string]struct{}
|
||||
}
|
||||
|
||||
var (
|
||||
loginPolicy = AccessPolicy{
|
||||
Name: "login_token", Sensitive: true,
|
||||
SafeFields: fieldSet("username", "user_id", "account_id", "success", "result_code"),
|
||||
Name: "login_token", Sensitive: true, PresenceOnly: true,
|
||||
SafeFields: fieldSet("username", "user_id", "account_id", "success", "result_code"),
|
||||
ResultFields: fieldSet("success", "result_code", "status"),
|
||||
}
|
||||
paymentPolicy = AccessPolicy{
|
||||
Name: "payment", Sensitive: true,
|
||||
SafeFields: fieldSet("order_no", "payment_no", "channel", "payment_method", "result_code", "amount", "status"),
|
||||
Name: "payment", Sensitive: true, PresenceOnly: true,
|
||||
SafeFields: fieldSet("order_no", "payment_no", "channel", "payment_method", "result_code", "amount", "status"),
|
||||
ResultFields: fieldSet("result_code", "status"),
|
||||
}
|
||||
wecomPolicy = AccessPolicy{
|
||||
Name: "wecom_callback", Sensitive: true,
|
||||
SafeFields: fieldSet("event_type", "resource_type", "resource_id", "result_code", "status"),
|
||||
}
|
||||
configPolicy = AccessPolicy{
|
||||
Name: "sensitive_config", Sensitive: true, PresenceOnly: true,
|
||||
SafeFields: fieldSet("config_key", "value_type", "module", "readonly", "sensitive", "configured", "status", "result_code"),
|
||||
ResultFields: fieldSet("readonly", "sensitive", "configured", "status", "result_code"),
|
||||
}
|
||||
filePolicy = AccessPolicy{
|
||||
Name: "file_export", Sensitive: true,
|
||||
SafeFields: fieldSet("content_type", "file_size", "size", "count", "task_id", "status", "result_code"),
|
||||
@@ -50,6 +60,8 @@ func policyForPath(path string) AccessPolicy {
|
||||
switch {
|
||||
case strings.Contains(normalized, "/login"), strings.Contains(normalized, "token"):
|
||||
return loginPolicy
|
||||
case strings.Contains(normalized, "/system-configs"), strings.Contains(normalized, "/wechat-configs"):
|
||||
return configPolicy
|
||||
case strings.Contains(normalized, "/callback") && (strings.Contains(normalized, "wecom") || strings.Contains(normalized, "wework")):
|
||||
return wecomPolicy
|
||||
case strings.Contains(normalized, "payment"), strings.Contains(normalized, "wechat-pay"),
|
||||
@@ -102,7 +114,13 @@ func collectSafeFields(value any, policy AccessPolicy, output map[string]any) {
|
||||
case string:
|
||||
collectSafeScalar(key, scalar, policy, output)
|
||||
case float64, bool:
|
||||
if _, allowed := policy.SafeFields[strings.ToLower(key)]; allowed {
|
||||
if policy.PresenceOnly {
|
||||
if _, isResult := policy.ResultFields[strings.ToLower(key)]; isResult {
|
||||
output[key] = scalar
|
||||
} else {
|
||||
output[key] = map[string]any{"present": true, "length": len(fmt.Sprint(scalar))}
|
||||
}
|
||||
} else if _, allowed := policy.SafeFields[strings.ToLower(key)]; allowed {
|
||||
output[key] = scalar
|
||||
}
|
||||
default:
|
||||
@@ -118,6 +136,14 @@ func collectSafeFields(value any, policy AccessPolicy, output map[string]any) {
|
||||
|
||||
func collectSafeScalar(key, value string, policy AccessPolicy, output map[string]any) {
|
||||
normalized := strings.ToLower(key)
|
||||
if policy.PresenceOnly {
|
||||
if _, isResult := policy.ResultFields[normalized]; isResult {
|
||||
output[key] = value
|
||||
return
|
||||
}
|
||||
output[key] = map[string]any{"present": true, "length": len(value)}
|
||||
return
|
||||
}
|
||||
if _, isFileName := policy.FileFields[normalized]; isFileName {
|
||||
base := filepath.Base(value)
|
||||
hash := digest([]byte(base))
|
||||
|
||||
@@ -5,10 +5,10 @@ import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/sanitizer"
|
||||
"github.com/bytedance/sonic"
|
||||
"github.com/gofiber/fiber/v2"
|
||||
"go.uber.org/zap"
|
||||
@@ -135,20 +135,7 @@ func sanitizeJSONValue(value any) {
|
||||
|
||||
// shouldMaskField 判断字段名是否属于访问日志敏感字段
|
||||
func shouldMaskField(key string) bool {
|
||||
normalized := strings.ToLower(key)
|
||||
return strings.Contains(normalized, "password") ||
|
||||
strings.Contains(normalized, "passwd") ||
|
||||
strings.Contains(normalized, "credential") ||
|
||||
strings.Contains(normalized, "authorization") ||
|
||||
strings.Contains(normalized, "cookie") ||
|
||||
strings.Contains(normalized, "key") ||
|
||||
strings.Contains(normalized, "url") ||
|
||||
strings.Contains(normalized, "qr_content") ||
|
||||
strings.Contains(normalized, "verification_code") ||
|
||||
strings.Contains(normalized, "sign") ||
|
||||
strings.Contains(normalized, "nonce") ||
|
||||
strings.Contains(normalized, "token") ||
|
||||
strings.Contains(normalized, "secret")
|
||||
return sanitizer.IsForbiddenField(key)
|
||||
}
|
||||
|
||||
// Middleware 创建 Fiber 日志中间件
|
||||
|
||||
@@ -92,6 +92,8 @@ func TestSensitiveRouteMatrixNeverLogsRawPayload(t *testing.T) {
|
||||
})
|
||||
request := httptest.NewRequest("POST", tc.path+"?signature=query-secret", strings.NewReader(tc.body))
|
||||
request.Header.Set("Content-Type", tc.contentType)
|
||||
request.Header.Set("Authorization", "Bearer header-secret")
|
||||
request.Header.Set("Cookie", "session=cookie-secret")
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatalf("执行敏感路由请求失败:%v", err)
|
||||
@@ -99,7 +101,7 @@ func TestSensitiveRouteMatrixNeverLogsRawPayload(t *testing.T) {
|
||||
_, _ = io.Copy(io.Discard, response.Body)
|
||||
_ = response.Body.Close()
|
||||
logged := output.String()
|
||||
for _, secret := range []string{tc.secret, "query-secret", "response-secret"} {
|
||||
for _, secret := range []string{tc.secret, "query-secret", "response-secret", "header-secret", "cookie-secret"} {
|
||||
if strings.Contains(logged, secret) {
|
||||
t.Fatalf("敏感路由日志泄露 %q:%s", secret, logged)
|
||||
}
|
||||
@@ -131,6 +133,92 @@ func TestSensitiveRouteLongBodyRecordsTruncationWithoutRawSecret(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRealConfigurationRoutesOnlyLogSafeSummary(t *testing.T) {
|
||||
for _, path := range []string{
|
||||
"/api/admin/system-configs/payment.private_key",
|
||||
"/api/admin/wechat-configs/1",
|
||||
} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
log := zap.New(zapcore.NewCore(zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()), zapcore.AddSync(&output), zapcore.InfoLevel))
|
||||
app := fiber.New()
|
||||
app.Use(MiddlewareWithLogger(log))
|
||||
app.Put(path, func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"value": "response-private-material", "status": "ok"})
|
||||
})
|
||||
request := httptest.NewRequest("PUT", path, strings.NewReader(`{"value":"request-private-material","config_key":"payment.private_key"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatalf("执行配置路由请求失败:%v", err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
logged := output.String()
|
||||
for _, secret := range []string{"request-private-material", "response-private-material"} {
|
||||
if strings.Contains(logged, secret) {
|
||||
t.Fatalf("配置路由日志泄露 %q:%s", secret, logged)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(logged, `"body_policy":"sensitive_config"`) {
|
||||
t.Fatalf("配置路由未应用安全摘要策略:%s", logged)
|
||||
}
|
||||
if !strings.Contains(logged, `\"present\":true`) || !strings.Contains(logged, `\"length\":`) {
|
||||
t.Fatalf("配置路由未记录字段存在性和长度:%s", logged)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoginRouteDoesNotLogUsernameAndOnlyKeepsPresenceLength(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
log := zap.New(zapcore.NewCore(zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()), zapcore.AddSync(&output), zapcore.InfoLevel))
|
||||
app := fiber.New()
|
||||
app.Use(MiddlewareWithLogger(log))
|
||||
app.Post("/api/auth/login", func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"username": "visible-user", "success": true})
|
||||
})
|
||||
request := httptest.NewRequest("POST", "/api/auth/login", strings.NewReader(`{"username":"visible-user","password":"login-secret"}`))
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatalf("执行登录请求失败:%v", err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
logged := output.String()
|
||||
if strings.Contains(logged, "visible-user") || strings.Contains(logged, "login-secret") {
|
||||
t.Fatalf("登录路由泄露账号或凭证:%s", logged)
|
||||
}
|
||||
if !strings.Contains(logged, `\"present\":true`) || !strings.Contains(logged, `\"length\":`) {
|
||||
t.Fatalf("登录路由未记录字段存在性和长度:%s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPaymentRouteOnlyLogsPresenceLengthAndSafeResult(t *testing.T) {
|
||||
var output bytes.Buffer
|
||||
log := zap.New(zapcore.NewCore(zapcore.NewJSONEncoder(zap.NewProductionEncoderConfig()), zapcore.AddSync(&output), zapcore.InfoLevel))
|
||||
app := fiber.New()
|
||||
app.Use(MiddlewareWithLogger(log))
|
||||
app.Post("/api/callback/alipay", func(c *fiber.Ctx) error {
|
||||
return c.JSON(fiber.Map{"payment_no": "PAY-SECRET-1", "amount": 12345, "status": "success"})
|
||||
})
|
||||
request := httptest.NewRequest("POST", "/api/callback/alipay", strings.NewReader("order_no=ORDER-SECRET-1&amount=12345&sign=signature-secret"))
|
||||
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
response, err := app.Test(request)
|
||||
if err != nil {
|
||||
t.Fatalf("执行支付路由请求失败:%v", err)
|
||||
}
|
||||
_ = response.Body.Close()
|
||||
logged := output.String()
|
||||
for _, value := range []string{"PAY-SECRET-1", "ORDER-SECRET-1", "signature-secret"} {
|
||||
if strings.Contains(logged, value) {
|
||||
t.Fatalf("支付路由泄露原值 %q:%s", value, logged)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(logged, `\"present\":true`) || !strings.Contains(logged, `\"status\":\"success\"`) {
|
||||
t.Fatalf("支付路由缺少存在性摘要或安全结果:%s", logged)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeInvalidJSONNeverFallsBackToRawBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
76
pkg/sanitizer/sanitizer.go
Normal file
76
pkg/sanitizer/sanitizer.go
Normal file
@@ -0,0 +1,76 @@
|
||||
// Package sanitizer 提供 Access、Audit 与 Integration 共用的敏感字段清理能力。
|
||||
package sanitizer
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/bytedance/sonic"
|
||||
)
|
||||
|
||||
var forbiddenFragments = []string{
|
||||
"password", "passwd", "credential", "operation_password", "verification_code", "captcha",
|
||||
"access_token", "refresh_token", "authorization", "cookie", "secret", "private_key", "public_key",
|
||||
"encoding_aes_key", "callback_token", "signature", "sign", "nonce", "media_id", "signed_url",
|
||||
"private_url", "qr_content", "id_card", "identity_number",
|
||||
}
|
||||
|
||||
// IsForbiddenField 判断字段是否禁止进入普通日志、审计或外部交互摘要。
|
||||
func IsForbiddenField(key string) bool {
|
||||
normalized := strings.ToLower(strings.NewReplacer("-", "_", ".", "_").Replace(key))
|
||||
for _, fragment := range forbiddenFragments {
|
||||
if strings.Contains(normalized, fragment) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
if strings.HasSuffix(normalized, "_key") || normalized == "key" || strings.HasSuffix(normalized, "_url") || normalized == "url" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// MarshalSummary 递归删除禁止字段并返回 sonic 编码的安全 JSON。
|
||||
func MarshalSummary(value any) ([]byte, error) {
|
||||
if value == nil {
|
||||
return nil, nil
|
||||
}
|
||||
encoded, err := sonic.Marshal(value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var normalized any
|
||||
if err := sonic.Unmarshal(encoded, &normalized); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
RemoveForbiddenFields(normalized)
|
||||
return sonic.Marshal(normalized)
|
||||
}
|
||||
|
||||
// RemoveForbiddenFields 原地递归删除 Map 或数组中的禁止字段。
|
||||
func RemoveForbiddenFields(value any) {
|
||||
switch typed := value.(type) {
|
||||
case map[string]any:
|
||||
for key, item := range typed {
|
||||
if IsForbiddenField(key) {
|
||||
delete(typed, key)
|
||||
continue
|
||||
}
|
||||
RemoveForbiddenFields(item)
|
||||
}
|
||||
case []any:
|
||||
for _, item := range typed {
|
||||
RemoveForbiddenFields(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TextSummary 将不可信外部文本转换为不可逆大小和哈希摘要。
|
||||
func TextSummary(value string) string {
|
||||
if value == "" {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256([]byte(value))
|
||||
return fmt.Sprintf("外部文本摘要 bytes=%d sha256=%s", len(value), hex.EncodeToString(sum[:8]))
|
||||
}
|
||||
Reference in New Issue
Block a user