4 Commits

Author SHA1 Message Date
88cc5e96ec 暂存
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m32s
2026-08-06 09:35:00 +08:00
8659dfc658 让迁移套餐恢复月流量重置调度
缺少 next_reset_at 时,轮询根据已有激活时间或到期时间与套餐天数推算下一重置点;已有值通过查询条件和条件更新双重保护,不会被覆盖。

Constraint: 兼容迁移套餐缺少 activated_at 与 next_reset_at 的历史数据
Rejected: 单次 SQL 人工回填 | 后续迁移数据仍可能再次遗漏
Confidence: high
Scope-risk: narrow
Directive: 保持 next_reset_at 非空记录不可覆盖
Not-tested: 按用户要求未运行测试
2026-08-05 14:36:06 +08:00
5e552d99bc 收口审计治理与套餐任务进展
Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展
Confidence: medium
Scope-risk: broad
Directive: 后续修改需保持审计事件与业务事务边界一致
Tested: git diff --cached --check
Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
2026-08-05 14:30:54 +08:00
b3499adfca 固化七月迭代审计治理进展以隔离线上热修
Constraint: 切换 main 前必须保存当前七月分支全部项目进展,套餐生效提案仅属于 Iteration/7-11。

Rejected: 将七月套餐修复直接移植到 main | 两个分支的可靠投递架构不同。

Confidence: medium

Scope-risk: broad

Directive: 不得将本提交整体 cherry-pick 到 main;main 套餐热修必须基于其纯 Asynq 代码独立实施。

Tested: git diff --check;openspec validate fix-package-activation-starvation --strict。

Not-tested: 按用户要求未运行自动化测试;go build ./... 因当前审计改造中的 Enterprise 模型字面量和 role.recordFailure 参数类型错误未通过。
2026-08-03 09:47:22 +08:00
299 changed files with 40040 additions and 8667 deletions

View File

@@ -1,17 +1,56 @@
# 全系统审计覆盖基线
状态:源码扫描与自动分类已完成,待业务、研发和安全三方评审确认
状态:2026-07-29 当前源码重扫及业务语义、研发边界、安全字段三视角复核已完成;后续入口变更由静态门禁持续校验
## 可复核制品
- 显式逐入口清单:[`审计覆盖清单.json`](审计覆盖清单.json),当前共 490 项
- 扫描范围:270 个 HTTP RouteSpec、24 个 Asynq Worker、4 个定时任务、3 个 Application 公共写入口、189 个旧 Service 公共写入口
- 显式逐入口清单:[`审计覆盖清单.json`](审计覆盖清单.json),当前共 647 项;七月旧 490 项只用于遗漏比对,不再作为现行契约
- 扫描范围:326 个 HTTP RouteSpec、31 个 Asynq Worker、8 Asynq 定时任务、27 个 Application、5 个 Domain、178 个旧 Service 公共业务入口、57 个 Integration Log 调用点和 15 个旧 Writer 调用点
- 生成入口:`go run ./cmd/audit-coverage`
- 发布门禁:`go test ./internal/governance/auditcoverage`。源码新增、删除或修改入口后,若未同步更新显式清单,测试必定失败
- 每项均固定代码入口、业务所有者、中文摘要以及待评审的 Audit Event / Domain Ledger / Integration Log / Outbox 分类候选,并预留动作、风险、资源、操作者来源、事务边界、失败策略、前后数据、敏感策略和确认测试接缝。
- 发布门禁:重新执行 `go run ./cmd/audit-coverage`并通过清单差异、gopls、构建和数据库数据核对确认入口与决策同步
- 每项均固定代码入口、业务所有者、中文摘要以及待评审的 Audit Event / Domain Ledger / Integration Log / Outbox 分类候选,并预留动作、风险、资源、操作者来源、事务边界、失败策略、前后数据、敏感策略和确认核对接缝。
- 当前 `Audit Event=N/A` 候选均填写逐入口理由;评审前不能据此宣称全系统分类已经确认。
生成器只负责产生待评审候选,不能自行代表评审通过。更新清单时必须核对业务语义,不能仅运行生成命令后直接提交。
生成器只负责产生待评审候选,不能自行代表评审通过。更新清单时必须核对业务语义,不能仅运行生成命令后直接提交。本轮复核已按下述证据完成,后续新增入口仍需重新执行同样流程。
## 2026-07-29 当前入口重扫结论
### 现行矩阵与旧清单差异
- 当前逐入口矩阵固定 `code_entry/kind/owner/action/actor/resource/transaction/visibility`,并分别登记 Audit Event、Domain Ledger、Integration Log、Outbox 或 N/A 理由647 项不存在空白决策字段。
- RouteSpec 从旧 270 项增至当前 316 项Worker 从 24 项增至 31 项Asynq 定时任务从 4 项增至 8 项。当前另显式登记 60 个 Integration Log 调用点和 21 个旧 Writer 调用点,避免用模块级泛化描述替代真实入口。
- 扫描器排除无 `context.Context` 的依赖注入 `SetXxx`、构造器、注册/装配函数和纯分发壳;普通 GET、购买检查和资产验证均按只读 N/A 登记。`SetSpeedTier` 因包含真实业务上下文和 Gateway 副作用保留为业务入口。
- Callback 使用 `external_system/callback` actorWorker 与消费者使用 `system_task/asynq`Scheduler 只投递任务时为 N/A真实业务变化归对应 Worker/Application。
### 敏感读取单列
| 入口 | 资源与安全决定 | 四类事实 |
|---|---|---|
| 企微应用配置 `GET /applications` | 返回 Secret、CallbackToken、EncodingAESKey返回前必须 fail-closed Audit凭据不得写入 Audit/Integration | Audit=必须Domain Ledger=只读配置Integration/Outbox=N/A |
| 资产实时状态 `GET /:identifier/realtime-status` | 设备响应含 WiFi 明文密码且实时访问 Gateway | Audit=必须Domain Ledger=只读Integration=每次尝试必须Outbox=N/A |
| IoT 卡实名链接 `GET /:iccid/realname-link` 与 C 端 `/realname/link` | 返回短期实名业务凭证 | Audit=必须Domain Ledger=只读卡事实;实际外部尝试写 IntegrationOutbox=N/A |
| 导出任务详情 `GET /export-tasks/:id` | 完成态返回预签名 `download_url` | Audit=必须导出任务是运行事实Integration=N/AOutbox=N/A |
| 批量下载/上传预签名 URL | 对象 Key 和 URL 受资源权限约束,签名 URL 不入审计正文 | Audit=必须Domain Ledger 按目标业务资源对象存储只写基础设施日志Outbox=N/A |
操作密码是否设置、实名状态、普通账号/资产/订单列表与统计均为普通受权读取Audit Event=N/AJSSDK 配置只返回客户端初始化签名,真实 token 回源由 Integration Log 记录,不把短期客户端配置误判为系统凭据读取。
### Integration Log 与旧 Writer 复核
- Integration Log 唯一 Repository 写入口为 `Start/Complete/RecordInbound/ClaimExpiredInboundPending`,当前已覆盖运营商回调、支付 H5 下单/查单/回调、卡观测、卡限速及企微 token/通讯录/模板/附件/提交/详情/回调;支付宝 WAP URL 本地签名不伪造外部尝试。
- 当前设备 Gateway、IoT 卡 Gateway、统一资产实时状态等直连调用尚未全部接入 Integration Log矩阵已逐用例登记为“必须”由后续 3.3、6.x、8.68.8 纵向切片落实,不能用旧 Asset Operation Log 代替。
- 支付配置的 5 个旧账号日志外层 goroutine 已归零;旧 Writer 仍有少量未迁移资产、卡、设备、导入和轮询调用,旧 Writer 自身的 goroutine 留待最终 contract 任务统一移除。
### 已落地代表切片
- 受控系统配置更新使用 `system_config.updated`:成功与 `tb_system_config` 同事务,已注册只读/非法值拒绝及事务失败在业务未落地后同步写独立短事务;未注册 Key、空 Key 或无 actor 不生成无资源 Audit Event。
- Outbox 人工重放与过期租约释放分别使用 `outbox.replayed``outbox.expired_lease_released`:成功事件关联批次内全部 Outbox 资源并保存状态/租约前后值;重复恢复、有效租约等完整定位资源后的拒绝写 `denied`,业务事务或成功审计失败写 `failed`,均保持 Outbox 原状态。
- 失败/拒绝审计二次写入失败保留原业务错误,使用进程内原子计数器累计,并输出含 `severity=critical`、稳定 action/resource/request/correlation/error code 的安全日志;全链路无裸 goroutine。
### 三视角复核记录
- 业务语义:逐 RouteSpec、Application/Service、Worker/Callback/Consumer 追踪真实副作用,确认通知已读属于低风险写,购买检查/资产验证属于 N/A多资源、资金、批量和系统动作未按名称一刀切。
- 研发边界:核对 GORM 事务、Domain Ledger、Integration Repository、Outbox consumer/relay、旧 Writer 调用链Setter、注册器、纯查询和分发壳已剔除。
- 安全字段单列企微明文凭据、WiFi 密码、实名链接、预签名 URL/导出下载;平台内部、代理/企业安全投影和 `internal_only` 可见性已逐项填写,越权与不存在保持同错。
## 分类边界
@@ -33,15 +72,17 @@
| 明确后台账号通知事件消费与幂等写入 | N/A测试环境冻结生产前由 6.5 重新评审通知失败与系统告警治理) | `tb_notification` 是通知投递与接收人已读状态的权威事实 | N/A无外部系统调用 | 消费公共 Outbox 的稳定事件,不复制 Outbox |
| 当前后台账号单条通知已读 | N/A低风险个人阅读状态普通已读操作只进入 Access Log | `tb_notification.is_read/read_at` 是权威状态 | N/A | N/A |
| 当前后台账号未读数与基础列表 | N/A普通读取不返回其他接收人数据或敏感业务正文 | 只读 `tb_notification` 投影 | N/A | N/A |
| 代理主钱包订单统一扣款 | 延期(测试环境冻结;生产前由 6.5 为资金变更补齐同事务 Audit Event | `tb_order``tb_agent_wallet``tb_agent_wallet_transaction``tb_payment` 与套餐使用记录在同一事务形成权威事实 | N/A不调用外部系统 | 同事务写入 `wallet.agent_main.debited`,为余额预警等后续消费者提供稳定事实 |
| 代理主钱包订单资金预占、释放与完成扣除 | 延期(测试环境冻结;生产前由 6.5 为冻结及终态变更补齐同事务 Audit Event | `tb_agent_wallet_reservation` 是预占金额、付款钱包与唯一终态的权威事实;钱包与完成扣除流水同事务更新 | N/A不调用外部系统 | 同事务写入 `wallet.agent_main.reservation.changed`;完成扣除同时写入 `wallet.agent_main.debited`,消费者按权威事实幂等确认 |
| 代理主钱包充值与人工调整正向入账 | 延期(测试环境冻结;生产前由 6.5 为入账补齐同事务 Audit Event | 充值/人工调整业务事实、`tb_agent_wallet` 与唯一成功流水在同一事务形成权威事实 | N/A本接缝不调用支付或审批外部系统 | 同事务写入 `wallet.agent_main.credited`,消费者按成功流水复核;支付/审批 Integration Log 由 UR#34 外部流程负责 |
| 代理订单主钱包退款回充 | 延期(测试环境冻结;生产前由 6.5 为退款资金变更补齐同事务 Audit Event | 原成功扣款流水定位付款钱包并限定金额;退款审批、`tb_agent_wallet` 与唯一成功退款流水同事务形成权威事实 | N/A本资金接缝不调用渠道或审批外部系统 | 同事务写入 `wallet.agent_main.refunded`,消费者复核退款流水、原扣款事实、金额上限和资产快照 |
| 代理主钱包订单统一扣款 | 使用 `agent_wallet.order_debit`,关联订单、主钱包和唯一成功流水,保存余额前后值;成功与订单、钱包、流水及 Outbox 同事务,审计失败回滚,已定位订单后的失败/拒绝在业务回滚后写独立短事务 | `tb_order``tb_agent_wallet``tb_agent_wallet_transaction``tb_payment` 与套餐使用记录在同一事务形成权威事实;现有行锁、乐观锁和唯一业务引用保持不变 | N/A不调用外部系统 | 同事务写入 `wallet.agent_main.debited`,为余额预警等后续消费者提供稳定事实Audit Event 不替代 Outbox |
| 代理主钱包订单资金预占、释放与完成扣除 | 使用 `agent_wallet.order_reserve/order_release/order_complete`,关联订单、主钱包、预占事实及完成时的唯一扣款流水,保存余额和冻结余额前后值;成功与原资金事务同写,重复终态不伪造事件 | `tb_agent_wallet_reservation` 是预占金额、付款钱包与唯一终态的权威事实;钱包与完成扣除流水同事务更新。当前生产仅取消订单调用 releasefreeze/complete Application 接缝暂无生产调用者,已接好审计但不借本任务新增业务调用 | N/A不调用外部系统 | 同事务写入 `wallet.agent_main.reservation.changed`;完成扣除同时写入 `wallet.agent_main.debited`,消费者按权威事实幂等确认Audit Event 不替代 Outbox |
| 代理主钱包充值与人工调整正向入账 | 充值沿用 `agent_recharge.credit`,以一条事件关联充值单、提交人、店铺、主钱包和唯一流水,避免为同一入账重复造事件;人工调整使用 `agent_wallet.adjust_balance`,要求保留人工原因并关联主钱包、唯一调整流水及余额前后值。成功与原资金事务同写,重复业务引用不伪造成功 | 充值/人工调整业务事实、`tb_agent_wallet` 与唯一成功流水在同一事务形成权威事实;当前没有人工调整生产入口,统一 Posting 接缝已覆盖但不借审计新增接口 | N/A本接缝不调用支付或审批外部系统 | 同事务写入 `wallet.agent_main.credited`,消费者按成功流水复核;支付/审批 Integration Log 由代理充值外部流程负责Audit Event 不替代 Outbox |
| 代理主钱包实际信用额度更新 | 使用 `agent_wallet.change_credit`,关联主钱包及所属店铺,保存 balance/frozen_balance 不变事实、credit_enabled/credit_limit/version 前后值;成功与信用字段版本条件更新同事务,已定位钱包后的资金占用拒绝、版本冲突或审计回滚失败使用独立短事务 | `tb_agent_wallet` 是实际信用开关、额度、余额、冻结余额和版本的权威事实;保持现有可用额度及资金占用校验,不产生钱包流水 | N/A本地信用配置不调用外部系统 | N/A信用额度更新不产生可靠异步副作用 |
| 代理在线充值支付链接创建 | 延期(测试环境冻结;创建人、店铺、金额和支付方式由充值单与支付单留痕,生产前按 6.5 复核 Audit Event | `tb_agent_recharge_record``tb_payment` 同事务保存待支付事实和收款身份快照 | 微信 v3 H5/v2 MWEB 下单每次真实外呼写 Integration Log支付宝 WAP URL 仅本地签名N/A后续查单与回调仍逐次记录 | N/A创建阶段不产生可靠异步副作用支付确认后才同事务写入钱包入账 Outbox |
| 代理订单主钱包退款回充 | `refund.approve` 与退款单、订单、原扣款钱包、原扣款流水和唯一退款流水同事务;重复退款流水不重复写成功事件 | 原成功扣款流水定位付款钱包并限定金额;退款审批、`tb_agent_wallet` 与唯一成功退款流水同事务形成权威事实 | N/A本资金接缝不调用渠道或审批外部系统原路渠道退款尚未实现 | 同事务写入 `wallet.agent_main.refunded`,消费者复核退款流水、原扣款事实、金额上限和资产快照 |
| 代理商资金概况信用投影 | N/A普通受权读取不返回其他数据范围的资金事实不执行资金或配置变更 | 只读投影 `tb_shop`、主/佣金钱包、提现汇总和主账号;派生金额不另建事实表 | N/A无外部系统调用 | N/A纯 Query 不产生可靠副作用) |
| 受控系统配置更新 | N/A用户已明确取消全局 Audit Event仅超级管理员可更新代码注册 Key未知 Key、非法类型和值域均拒绝 | `tb_system_config` 是配置值、类型、模块及更新人的 PostgreSQL 权威事实,更新后失效 Redis 缓存 | N/A配置更新不调用外部系统不得写 Integration Log 冒充配置审计) | N/A配置更新不产生可靠异步副作用 |
| 电信实名结果回调 | N/AAudit Event 已移出本 Change运营商来源真实性验证也不在本票边界 | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实 | 每次入站先写 `tb_integration_log`,仅保存正文摘要;覆盖 `invalid_payload/ignored/not_found/conflict/success/failed` 终态 | 实名事实首次变化时由公共 `ApplyCardObservation` 同事务写入实名状态变化 Outbox重复成功不重复写事件 |
| 移动实名成功回调 | N/AAudit Event 已移出本 Change不接入旧平台登录、MSISDN 补查或来源真实性验证) | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实 | 每次入站先写 `tb_integration_log`,仅保存正文摘要;覆盖 `invalid_payload/not_found/conflict/success/failed` 终态,并通过 pending 租约恢复中断处理 | 仅合法成功报文进入公共 `ApplyCardObservation`;实名事实首次变化时同事务写入 Outbox重复成功不重复写事件 |
| 联通实名成功回调 | N/AAudit Event 已移出本 Change不复制旧 `inner_callback`、第三方推送或 Gateway 二次确认) | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实;上游 `dateChanged` 只用于幂等和留痕 | 每次入站先写 `tb_integration_log` 正文摘要;覆盖 `invalid_payload/not_found/conflict/success/failed`,关闭时记录 `ignored` | 仅合法成功报文进入公共 `ApplyCardObservation`;实名事实首次变化时同事务写入 Outbox重复成功不重复写事件 |
| 电信实名结果回调 | 实名状态首次实际变化时写 `iot_card.realname_callback_sync`,使用 `external_system/callback`,关联 IoT 卡和入站 Integration LogAudit Event 与卡状态、首次实名时间及实名变化 Outbox 同事务,审计失败回滚。已解析卡后的业务失败写独立短事务;重复成功不伪造事件 | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实 | 每次入站先写 `tb_integration_log`,仅保存正文摘要;覆盖 `invalid_payload/ignored/not_found/conflict/success/failed` 终态;未改变实名状态时只保留 Integration Log | 实名事实首次变化时由公共 `ApplyCardObservation` 同事务写入实名状态变化 Outbox重复成功不重复写事件 |
| 移动实名成功回调 | 实名状态首次实际变化时写 `iot_card.realname_callback_sync`,使用 `external_system/callback`,关联 IoT 卡和入站 Integration LogAudit Event 与卡状态、首次实名时间及实名变化 Outbox 同事务,审计失败回滚。已解析卡后的业务失败写独立短事务;重复成功不伪造事件 | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实 | 每次入站先写 `tb_integration_log`,仅保存正文摘要;覆盖 `invalid_payload/not_found/conflict/success/failed` 终态,并通过 pending 租约恢复中断处理;未改变实名状态时只保留 Integration Log | 仅合法成功报文进入公共 `ApplyCardObservation`;实名事实首次变化时同事务写入 Outbox重复成功不重复写事件 |
| 联通实名成功回调 | 实名状态首次实际变化时写 `iot_card.realname_callback_sync`,使用 `external_system/callback`,关联 IoT 卡和入站 Integration LogAudit Event 与卡状态、首次实名时间及实名变化 Outbox 同事务,审计失败回滚。已解析卡后的业务失败写独立短事务;重复成功不伪造事件 | `tb_iot_card` 是实名状态、首次实名时间和逆转窗口的权威事实;上游 `dateChanged` 只用于幂等和留痕 | 每次入站先写 `tb_integration_log` 正文摘要;覆盖 `invalid_payload/not_found/conflict/success/failed`,关闭时记录 `ignored`;未改变实名状态时只保留 Integration Log | 仅合法成功报文进入公共 `ApplyCardObservation`;实名事实首次变化时同事务写入 Outbox重复成功不重复写事件 |
| 联通解除实名回调 | N/A只识别并留痕外部解除通知不把单次回调作为本地实名逆转事实 | `tb_iot_card` 保持原实名状态、首次实名时间、检查时间及逆转计数,回调不写领域事实 | 每次入站先写 `tb_integration_log` 正文摘要;覆盖 `invalid_payload/not_found/conflict/ignored/failed` 终态并支持 pending 租约恢复 | N/A不调用公共实名观测不产生状态变化、停机或套餐事件 |
## 七月确认范围增量登记
@@ -50,31 +91,49 @@
|---|---|---|---|---|
| 换货迁移套餐在原订单退款后失效 | N/A退款终态的自动后处理不新增人工决定人工申请与审批审计沿用退款入口 | 原订单、换货新旧资产关系和 `tb_package_usage` 是套餐权益来源及失效状态的权威事实;仅失效该订单迁移后的对应权益 | N/A本地数据库后处理不调用企微、支付或 Gateway | 企微退款沿用 `approval.terminal_decision.recorded` 驱动;存量旧退款沿用原同步链路,本修复不新增 Outbox |
| 订单渠道、资产标识、退款/充值/换货提交人和卡/设备实名筛选 | N/A均为字段来源修正或受权只读投影不改变订单、资产、实名或审批事实 | 只读既有订单 `purchase_role`、卡 ICCID、设备 VirtualNo/IMEI、创建人账号及有效卡绑定实名事实不另建领域账本 | N/A查询使用本地批量账号查询和 EXISTS不实时调用外部系统 | N/A纯查询和创建时既有字段赋值不产生新增可靠副作用 |
| 换货创建前未终结退款拦截 | N/A拒绝发生在换货事实写入前拒绝原因进入 Access Log不伪造成功业务审计 | `tb_refund_request` 的未终结状态是拦截依据;拒绝后不创建换货单、不修改资产 | N/A本地前置校验不调用企微或其他外部系统 | N/A校验拒绝不产生业务事实或可靠副作用 |
| 换货创建前未终结退款拦截 | 已识别旧卡或旧设备后分别使用 `exchange.card.create``exchange.device.create` 记录 `denied`,关联稳定换货单 Key、旧资产和店铺 | `tb_refund_request` 的未终结状态是拦截依据;拒绝后不创建换货单、不修改资产 | N/A本地前置校验不调用企微或其他外部系统 | N/A校验拒绝不产生业务事实或可靠副作用 |
| 店铺 C 端新登录限制配置与登录拦截 | 店铺更新入口记录操作者 `updater`、字段前后值进入 Access Log本 Change 不新建店铺专用 Audit Writer统一店铺配置 Audit Event 接缝登记为后续治理项 | `tb_shop.client_login_disabled` 是是否允许新登录的权威事实;已有 Token 不修改,拦截时不创建新 Token | N/A配置更新与资产登录判断均为本地数据库操作 | N/A同步配置与登录前拦截不产生必须可靠投递的提交后副作用 |
| 卡/设备实名策略单个及批量更新 | 复用资产操作审计 `card_realname_policy` / `asset_realname_policy`,记录操作者、资产集合、策略和 success/failed 结果;批量业务更新先在单事务中全量校验并写入 | `tb_iot_card.realname_policy``tb_device.realname_policy` 是策略权威事实C 端仅实时计算 `effective_realname_policy` | N/A策略更新和读取均不调用运营商或其他外部系统 | N/A策略同步更新不产生可靠异步副作用 |
| 卡/设备实名策略单个及批量更新 | IoT 卡使用 `iot_card.realname_policy_update` / `iot_card.realname_policy_batch_update`,设备使用 `device.realname_policy_update` / `device.realname_policy_batch_update`;均记录真实后台操作者、目标资产、实际绑定卡/设备与卡槽引用、策略前后值及 success/failed/denied批量根子事件与实际策略变化同事务 | `tb_iot_card.realname_policy``tb_device.realname_policy` 是策略权威事实C 端仅实时计算 `effective_realname_policy` | N/A策略更新和读取均不调用运营商或其他外部系统 | N/A策略同步更新不产生可靠异步副作用 |
| 下架套餐当前使用者续费与普通列表过滤 | N/A普通受权查询与既有订单创建规则订单创建继续沿用原订单/资金审计接缝) | 当前套餐使用记录决定续费资格,新订单、订单明细、套餐当前配置和支付记录是新购买权威事实;不修改历史订单 | 第三方支付仍沿用既有支付 Integration Log本资格判断不新增外部调用 | 新订单支付、钱包扣款、自动购包及佣金继续沿用既有任务/Outbox本切片不新增事件类型 |
| 卡/设备 C 端支付方式配置更新 | 复用 `systemconfig.UpdateService` 的事务内 `AuditWriter`,记录操作者、请求标识及配置前后值;审计 Writer 已装配时写入失败会回滚配置更新 | `tb_system_config` 是卡、设备允许支付方式集合及更新人的 PostgreSQL 权威事实Redis 仅为可失效缓存 | N/A配置更新不调用外部系统 | N/A提交后仅失效可重建缓存不产生必须可靠投递的业务副作用 |
| C 端读取支付方式与后端订单/充值校验 | N/A普通受权读取和业务规则校验不产生独立敏感事实拒绝原因进入 Access Log | 只读 `tb_system_config`,订单创建后由 `tb_order.payment_method` 固化所选方式,充值与支付事实沿用既有订单、充值单和支付记录 | 第三方支付请求继续沿用既有支付集成日志接缝,本配置策略本身不新增外部调用 | 强充支付成功后的自动购包继续沿用既有 Asynq/业务幂等链路,本配置读取不新增 Outbox |
| 主钱包首次跌破 100 元通知店铺业务员 | N/A由已审计资金事实派生的内部提醒不新增人工操作或敏感读取 | `tb_agent_wallet_transaction``wallet.agent_main.debited` 是余额前后值的权威事实,`tb_notification` 保存最终通知与已读状态 | N/A不调用外部系统 | 扣款事实消费者仅在 `balance_before >= 10000 && balance_after < 10000` 时同事务幂等写入明确后台账号通知 Outbox无有效业务员时正常结束 |
| 创建物流换货单并提醒关联个人客户 | N/A通知记录本身是投递事实当前 Change 不新增全局 Audit Event Writer后台创建操作继续进入 Access Log | `tb_exchange_order` 是物流换货申请及状态的权威事实,`tb_notification` 是接收人通知与已读状态的权威事实 | N/A不调用外部系统 | 换货单与每个启用关联客户的 `notification.personal_customer.direct.requested` 在同一 GORM 事务写入;事件 ID 使用换货单和客户 ID 稳定防重,消费端按事件与接收人唯一键幂等 |
| 创建物流换货单并提醒关联个人客户 | 卡/设备换货分别使用 `exchange.card.create``exchange.device.create` 记录真实后台操作者、换货单、旧资产和店铺,成功事实与换货单及通知 Outbox 同一 GORM 事务 | `tb_exchange_order` 是物流换货申请及状态的权威事实,`tb_notification` 是接收人通知与已读状态的权威事实 | N/A不调用外部系统 | 换货单与每个启用关联客户的 `notification.personal_customer.direct.requested` 在同一 GORM 事务写入;事件 ID 使用换货单和客户 ID 稳定防重,消费端按事件与接收人唯一键幂等 |
| 套餐临期列表、数量与每日/手动 15/7/3 天节点提醒 | N/A列表和数量是普通受权读取手动入口仅允许超级管理员提交同一幂等扫描任务操作者进入 Access Log 和任务日志,不直接修改业务事实) | `tb_package_usage` 的计时条款快照和到期队列是预计最终到期的权威事实,`tb_notification` 保存店铺账号、平台业务员和个人账号通知及已读状态 | N/A不调用企业微信、短信、邮件或其他外部系统 | 每日或手动任务按资产、到期日和节点生成稳定事件 ID同一 GORM 事务内向店铺动态接收人写入 `notification.admin.dynamic.requested`,并向绑定个人账号写入 `notification.personal_customer.direct.requested`;列表和数量纯 Query 不产生 Outbox |
| 企业微信应用连接配置保存与明文读取 | 配置保存复用 `systemconfig.AuditWriter`,只记录应用标识、状态和 `credentials_configured=true`,不记录连接凭据;明文读取仅允许超级管理员并进入 Access Log统一敏感读取 Audit Writer 在本 Change 的治理收口任务中继续核验 | `tb_wecom_application` 是 corp_id、agent_id、应用状态及明文 Secret、回调 Token、EncodingAESKey 的权威事实;管理响应按用户确认向超级管理员返回明文 | 保存和读取本身不调用企微;连接测试或 token 缓存未命中时,每次真实回源均写 `tb_integration_log`,请求和响应摘要不含 Secret、回调凭据或 access_token | N/A连接配置提交后仅同步失效可重建 token 缓存,不产生必须可靠投递的业务副作用) |
| 企业微信可见成员同步与账号显式绑定 | 成员同步是外部只读快照更新,不记录独立人工审计;账号绑定沿用现有账号操作日志,记录操作者、目标账号及绑定前后 `(corp_id, userid, name)`记录手机号或邮箱 | `tb_wecom_member` 是最近同步的应用可见成员选择快照,`tb_account.wecom_*` 是管理员确认后的账号绑定事实;不建立部门组织模型 | 每次真实调用应用可见成员接口均写 `tb_integration_log`,仅记录应用、根部门、成员数量、状态码和耗时,不保存 access_token 或成员列表正文 | N/A同步和绑定均为同步事务,不产生必须可靠投递的提交后副作用) |
| 企业微信审批业务场景与模板控件映射 | 配置保存复用事务内 `systemconfig.AuditWriter`记录业务类型、应用 ID、模板 ID、状态和最近校验时间不保存凭据、审批节点或审批人规则到审计数据 | `tb_wecom_approval_scene` 是两个稳定业务类型的当前模板、控件映射、模板最小快照和启用状态权威事实 | 保存前每次真实调用模板详情接口均写 `tb_integration_log`,记录应用、模板 ID、状态码、控件数量和耗时不保存 access_token 或完整外部响应 | N/A配置保存为同步事务不产生必须可靠投递的提交后副作用) |
| 企业微信默认发起人与审批提交 | 默认发起人配置复用事务内 `systemconfig.AuditWriter`记录应用、userid 和姓名快照;真实业务提交人继续保存在业务申请及通用审批实例中,不以默认成员伪造操作者 | `tb_wecom_application.default_creator_*` 是应用默认发起人当前配置,`tb_wecom_approval_context` 冻结模板、实际 creator 来源和提交状态,`tb_approval_instance.external_ref` 保存 sp_no | 每次附件上传和 applyevent 均写 `tb_integration_log`;摘要不含 Secret、access_token、media_id、附件正文或完整企微响应提交超时记 unknown 并登记时间窗批量单号/详情查询恢复策略 | 业务事务写入 `approval.submission.requested`Worker 条件领取后只提交一次,明确失败和结果未知均终结自动重试,禁止盲目创建第二张审批单 |
| 企业微信审批加密回调与详情终态同步 | 回调为外部自动事实,不伪造人工 Audit Event验签、AES 解密、receiveid 校验失败只记录安全错误,不进入业务终态 | `tb_integration_log` 按应用和签名幂等保存入站密文哈希,`tb_wecom_approval_context.latest_detail_snapshot` 保存权威详情,通用审批实例及决策投递表保存标准终态 | 入站回调先写 Integration Log pending详情任务完成后置 completed每次 `getapprovaldetail` 写独立出站 Integration Log不保存 access_token但保存 sp_no、sp_status 和耗时摘要 | 回调只入队结构化 `wecom:approval:sync` 任务;权威终态通过现有 `SyncDecisionService` 原子写 `approval.terminal_decision.recorded`,不直接修改退款或钱包 |
| 企业微信审批主动恢复、未终态轮询与审批人读取投影 | N/A定时恢复和只读投影均为系统自动动作不伪造人工操作者映射不到账号不改变业务事实 | `tb_wecom_approval_context.submission_attempted_at/last_recovery_at/sp_no/latest_detail_snapshot` 与通用审批实例是恢复和展示的权威本地事实;只有唯一候选可从结果未知转为审批中 | 每次 `getapprovalinfo` 分页和 `getapprovaldetail` 均写独立出站 Integration Log只保存应用、时间窗、模板、发起人、单号数量、sp_no、状态和耗时等安全摘要,不保存 Secret、access_token、media_id、附件正文或完整响应 | Scheduler 仅提交 `wecom:approval:recovery`;恢复和轮询仅提交结构化 `wecom:approval:sync`,不写审批提交 Outbox、不调用 `applyevent`,标准终态仍沿用既有终态 Outbox |
| 主套餐过期接续与孤儿恢复 | N/A系统自动推进套餐生命周期不包含人工敏感操作 | `tb_package_usage` 是套餐待生效、生效、用完和过期状态的权威事实 | N/A仅使用本地 PostgreSQL 与 Redis不新增外部请求 | 成功激活继续在同一事务复用 `card.observation.series.requested`,稳定事件 ID 基于套餐使用记录 ID不新增套餐激活事件类型或消费者 |
| 企业微信应用连接配置保存与明文读取 | 配置保存和默认发起人分别使用 `wecom.application.save``wecom.application.save_default_creator`记录应用标识、状态和真实 `credentials_configured` 布尔事实;明文列表读取仅允许超级管理员,返回前以独立短事务同步写 `wecom.application.credentials_read`审计缺失或失败均不返回结果Audit Writer 不接收 Secret、回调 Token 或 EncodingAESKey | `tb_wecom_application` 是 corp_id、agent_id、应用状态及明文 Secret、回调 Token、EncodingAESKey 的权威事实;管理响应按用户确认向超级管理员返回明文 | 保存和读取本身不调用企微;连接测试或 token 缓存未命中时,每次真实回源均写 `tb_integration_log`,请求和响应摘要不含 Secret、回调凭据或 access_token | N/A连接配置提交后仅同步失效可重建 token 缓存,不产生必须可靠投递的业务副作用) |
| 支付连接配置 CRUD 与启停 | 使用 `payment_config.create/update/delete/activate/deactivate`保存配置 ID、名称、渠道、启用状态、非敏感商户/应用标识和各渠道 `credentials_configured` 安全事实;不保存 Secret、Token、AESKey、支付密钥、私钥、公钥正文或证书正文。创建字段校验、删除生效配置或在途业务等已定位资源后的拒绝写独立短事务激活其他配置时为被自动停用的原配置另写同事务停用事件 | `tb_wechat_config` 是配置及明文渠道凭据的权威事实CRUD/启停与 Audit Event 同一 GORM 事务,审计失败回滚配置事实,提交后 Redis 缓存仅 best-effort 失效 | N/A配置 CRUD/启停不调用渠道;支付、查单和回调的真实外部尝试仍由 7.4 Integration Log 负责) | N/A配置提交不产生可靠异步副作用) |
| 运营商配置 CRUD 与启停 | 使用 `carrier.create/update/delete/update_status`保存配置 ID、编码、名称、类型、状态、流量重置日和实名链接业务配置重复编码、非法模板配置等已定位资源后的拒绝进入独立短事务 | `tb_carrier` 是运营商及实名链接配置权威事实;成功审计与 CRUD/状态变更同一 GORM 事务 | N/A本切片只改本地运营商配置不调用 Gateway 或运营商) | N/A同步配置不产生可靠异步副作用 |
| 运营商回调开关与卡/设备支付方式等受控连接策略 | 继续复用 `system_config.updated`,记录注册 Key、模块、前后值、操作者和请求链路只读、非法值和事务失败沿用已落地的拒绝/失败短事务策略 | `tb_system_config` 是回调启停和支付方式策略权威事实,代码 Registry 默认值与 Redis 缓存不替代 PostgreSQL | N/A配置本身不调用运营商或支付渠道真实回调/支付尝试由各自 Integration Log 记录) | N/A提交后仅失效可重建缓存 |
| 企业微信可见成员同步与账号显式绑定 | 成员同步使用 `wecom.application.sync_members`,记录应用身份、状态、同步数量和时间,成员快照替换与 Audit Event 同一事务;账号绑定继续使用 `account.bind_wecom`,记录操作者、目标账号及绑定前后 `(corp_id, userid, name)`,不记录手机号或邮箱 | `tb_wecom_member` 是最近同步的应用可见成员选择快照,`tb_account.wecom_*` 是管理员确认后的账号绑定事实;不建立部门组织模型 | 每次真实调用应用可见成员接口均写 `tb_integration_log`,仅记录应用、根部门、成员数量、状态和耗时,不保存 access_token 或成员列表正文 | N/A同步和绑定均为同步事务不产生必须可靠投递的提交后副作用 |
| 企业微信审批业务场景与模板控件映射 | 配置保存使用 `wecom.approval_scene.save`,记录场景 ID、业务类型、应用 ID、模板 ID/名称、状态、指纹和最近校验时间;配置与审计同事务,不保存凭据、审批节点或审批人规则到审计数据 | `tb_wecom_approval_scene` 是两个稳定业务类型的当前模板、控件映射、模板最小快照和启用状态权威事实 | 保存前每次真实调用模板详情接口均写 `tb_integration_log`,记录应用、模板 ID、状态码、控件数量和耗时不保存 access_token 或完整外部响应 | N/A配置保存为同步事务不产生必须可靠投递的提交后副作用 |
| 企业微信默认发起人与审批提交 | 默认发起人配置继续复用事务内 `systemconfig.AuditWriter`;业务事务创建通用审批时写 `approval.request`,关联真实提交账号、退款或线下充值业务单及 `approval.submission.requested` Outbox。企微提交 Worker 以 `system_task/worker``approval.sync_submission` 的 success/failed/unknown不以默认成员伪造操作者 | `tb_wecom_application.default_creator_*` 是当前默认发起配置,`tb_approval_instance``tb_wecom_approval_context` 是审批和提交状态权威事实Audit Event 仅保存状态变化及稳定引用 | 每次附件上传和 applyevent 均写精确 Integration Log摘要不含 Secret、access_token、media_id、附件正文或完整企微响应提交审计引用对应 `integration_id`,但不替代或冻结其可变结果 | 业务事务写入 `approval.submission.requested`Worker 条件领取后只提交一次,明确失败和结果未知均终结自动重试,禁止盲目创建第二张审批单 |
| 企业微信审批加密回调与详情终态同步 | 首次权威终态写 `approval.sync_decision`,回调使用真实 `external_system/callback` actor关联审批实例、业务单、真实提交账号、入站回调/详情 Integration 及终态 Outbox不记录本地或企微审批人为操作者。重复终态未改变审批事实时不伪造成功事件 | `tb_integration_log` 保存回调和详情外部事实,`tb_wecom_approval_context.latest_detail_snapshot`、通用审批实例及决策投递表保存权威标准终态Audit Event 与审批状态、决策投递和终态 Outbox 同事务 | 入站回调先写 Integration Log pending详情任务完成后置 completed每次 `getapprovaldetail` 写独立出站 Integration Log。审计只引用稳定 `integration_id` 与身份字段,不保存 access_token、完整响应或尚可变化的 Integration result | 回调只入队结构化 `wecom:approval:sync` 任务;权威终态原子写 `approval.terminal_decision.recorded`,退款和线下充值消费者继续使用既有业务/资金审计,并传播终态 Outbox 的 correlation/parent |
| 企业微信审批主动恢复、未终态轮询与审批人读取投影 | 唯一确认结果未知提交时写 `approval.recover_submission`:回调恢复使用 `external_system/callback`,主动恢复使用 `scheduled_job/scheduler`;轮询取得首次权威终态写 `approval.sync_decision`。普通审批人读取投影仍为 N/A重复恢复或重复终态未改变事实时不写成功事件 | `tb_wecom_approval_context.submission_attempted_at/last_recovery_at/sp_no/latest_detail_snapshot` 与通用审批实例是恢复和展示的权威本地事实;只有唯一候选可从结果未知转为审批中 | 每次 `getapprovalinfo` 分页和 `getapprovaldetail` 均写独立出站 Integration Log恢复审计关联实际提交或查询 Integration只保存安全摘要不保存 Secret、access_token、media_id、附件正文或完整响应 | Scheduler 仅提交 `wecom:approval:recovery`;恢复和轮询只提交结构化 `wecom:approval:sync`,不写新的审批提交 Outbox、不调用 `applyevent`,标准终态继续使用既有终态 Outbox |
| 员工线下代充值申请与企微终态入账 | 申请保存以真实提交人及明文业务快照留痕;资金成功 Audit Event 延期至既有统一钱包治理任务,企微自动终态不伪造人工审批人 | `tb_agent_recharge_record``tb_approval_instance``tb_wecom_approval_context` 同事务保存申请事实approved 通过 `topup + recharge_record_id` 唯一成功钱包流水幂等入账,其他终态不修改钱包,通过后撤销不自动冲正;`tb_notification` 保存到账通知 | 申请创建本身不外呼后续附件上传、applyevent、详情与恢复沿用企微 Integration Log业务参数按用户确认保存明文日志仍不记录 Secret、access_token、media_id 或附件正文 | 创建事务写 `approval.submission.requested`;标准终态写 `approval.terminal_decision.recorded`approved 入账事务再写 `wallet.agent_main.credited` 和目标店铺的 `notification.admin.dynamic.requested`;在线充值复用同一入账接缝;新审批单禁止旧人工确认或驳回入口绕过 |
| 代理充值列表、详情与支付状态读取 | N/A普通受权读取请求进入 Access Log不改变充值、支付或钱包事实 | 只读 `tb_agent_recharge_record`、相关支付单和店铺名称;平台账号不限制,代理账号统一按当前上下文的自身及下级店铺 ID 过滤 | N/A不调用支付渠道或企微 | N/A纯 Query不产生 Outbox |
| 退款申请与企微终态处理 | 申请以真实提交人和订单/金额/凭证明文快照留痕;企微自动终态不伪造本地人工审批人,资金与佣金 Audit Event 继续沿用既有治理延期口径 | `tb_refund_request`、通用审批实例和企微上下文同事务保存approved 条件更新订单与退款单,代理主钱包按 refund ID、资产钱包按退款单号复核成功回款佣金按记录锁定并失效套餐按订单及换货迁移关系幂等失效`tb_notification` 保存退款完成通知 | 申请创建不外呼附件、applyevent、详情、回调和恢复沿用企微 Integration Log业务参数明文保存在业务/审批快照中但不复制到 Integration LogSecret、access_token、media_id 和附件正文仍禁止记录 | 创建事务写 `approval.submission.requested`;终态写 `approval.terminal_decision.recorded`;退款事务幂等写目标店铺的 `notification.admin.dynamic.requested`;业务消费者只有在订单、钱包、佣金和资产后处理完成后才确认投递成功,失败释放租约重试 |
| 退款申请与企微终态处理 | 申请、人工/企微通过与拒绝、退回、重提使用 `refund.create/approve/reject/return/resubmit`;佣金实际回扣使用 `refund.invalidate_commission`,资产后处理完成使用 `refund.process_asset`。成功事实与各自 Domain Ledger 同事务,已定位退款后的失败/拒绝使用独立短事务;代理/企业仅看到“已提交/已通过/已拒绝/处理完成”等安全结论 | `tb_refund_request`、通用审批实例和企微上下文同事务保存approved 条件更新订单与退款单,代理主钱包按 refund ID、资产钱包按退款单号复核成功回款佣金按记录锁定并失效套餐按订单及换货迁移关系幂等失效`tb_notification` 保存退款完成通知。Audit Event 关联退款、审批、订单、资产、钱包、原扣款/退款流水、佣金、实际失效套餐权益和通知 Outbox但不替代这些权威事实 | 申请创建不外呼附件、applyevent、详情、回调和恢复沿用企微 Integration Log业务参数明文保存在业务/审批快照中但不复制到 Integration LogSecret、access_token、media_id 和附件正文仍禁止记录原路渠道退款未实现Integration Log 明确 N/A | 创建事务写 `approval.submission.requested`;终态写 `approval.terminal_decision.recorded`;退款事务幂等写目标店铺的 `notification.admin.dynamic.requested`通知继续以 Outbox 为权威投递事实Audit Event 仅保存稳定引用;业务消费者只有在订单、钱包、佣金和资产后处理完成后才确认投递成功,失败释放租约重试 |
| 退款与线下代充值旧审批入口发布切换 | N/A部署环境开关控制旧入口是否可用不新增业务操作实际旧入口操作继续沿用各自既有审计口径 | `approval_instance_id IS NULL` 是存量旧 provider 的兼容边界,非空记录只接受企微标准终态;关闭开关不修改或删除任何业务事实 | N/A开关判断不调用外部系统也不得写 Integration Log 冒充发布审计) | N/A开关判断不产生可靠副作用企微 Worker 继续消费既有标准终态 Outbox |
| 单列 CSV 资产套餐批量订购 | 任务创建和逐行订单继续进入 Access Log、任务操作者快照与既有订单/钱包审计接缝;本 Change 不新建批量任务专用 Audit Writer | `tb_asset_package_batch_order_task` 保存输入参数和逐结果,成功行以 `tb_order`、订单明细、套餐使用、支付记录及代理钱包成功流水为权威业务事实 | 对象存储上传和下载沿用现有存储日志,不把文件正文写入 Integration Log本切片不新增外部支付或 Gateway 调用 | 创建接口提交结构化 `asset:package:batch_order` Asynq 任务;逐行钱包订单继续沿用既有钱包扣款 Outbox 和佣金任务,重复任务由状态条件与订单幂等规则阻断 |
| IoT 卡与套餐业务导出 | N/A只读投影不改变资产、套餐或权限状态任务创建操作者与权限快照沿用现有导出任务记录 | N/A导出只读取现有卡、套餐使用、套餐和分配事实不写入领域账本 | N/A不调用外部业务系统对象存储文件生成和下载沿用现有导出基础设施日志 | 沿用现有 `export:dispatch``export:shard``export:finalize` Asynq 链路,不新增业务 Outbox |
| 钱包流水与代理充值业务导出 | N/A只读投影不改变钱包、充值或审批状态任务创建操作者与店铺权限快照沿用现有导出任务记录 | N/A只读取主钱包流水、充值记录和本地通用审批实例金额及余额使用既有权威事实不写入领域账本 | N/A不实时调用支付渠道或企业微信明文业务凭证 Key 只进入导出结果,不复制到 Integration Log且仍禁止记录 Secret、access_token、media_id 和附件正文 | 仅沿用现有 `export:dispatch``export:shard``export:finalize` Asynq 链路,不新增业务 Outbox |
| 退款与换货业务导出 | N/A只读投影不改变退款、换货、资产或审批状态任务创建操作者与店铺权限快照沿用现有导出任务记录 | N/A只读取退款、订单、套餐使用、换货资产快照和本地审批事实;金额及处理标记沿用既有权威事实,不写入领域账本 | N/A不实时调用企业微信、支付或 Gateway明文业务凭证和收货资料只进入有权导出结果不复制到 Integration Log且仍禁止记录 Secret、access_token、media_id 和附件正文 | 仅沿用现有 `export:dispatch``export:shard``export:finalize` Asynq 链路,不新增业务 Outbox |
| IoT 卡固定档位限速 | 复用资产操作审计 `card_speed_tier`,记录后台操作者、卡 ICCID、固定档位、Integration Log ID 和 success/failed 结果;设备无入口且不通过绑定卡间接限速 | N/A不在本地保存或修改卡当前限速状态Gateway 是外部执行方 | 每次实际 Gateway 调用前写 pending按 success/failed/unknown 终结;超时 unknown 保存按 ICCID 人工核对策略,摘要不含 Secret、access_token 或完整响应正文 | N/A单次外部命令无后续可靠副作用结果未知禁止盲目重发不创建自动补偿 Outbox |
| 设备 CSV 批量分配、设置套餐系列或回收 | 任务记录冻结操作者和可选目标;逐批复用 `device_allocate``device_series_binding``device_recall` 资产操作审计,记录设备前后值、目标、成功/失败数和失败原因;任务查询的 `target_name` 仅批量投影当前店铺或套餐系列名称 | N/A设备归属、绑定卡归属、分配记录及 `series_id` 是权威业务事实,不另建领域账本 | N/ACSV 解析、分配和回收均为本地数据库操作,不调用 Gateway、支付或企微对象存储沿用现有存储日志 | 复用 `device:import` Asynq 任务;状态条件阻止完成任务重复执行,处理中断恢复时已达到目标关系的设备按成功处理,不新增业务 Outbox |
| IoT 卡/设备导入与设备 CSV 批量操作 | 创建使用 `iot_card_import_task.create``device_import_task.create`,完成使用对应 `*.complete` 根事件;任务只保存 ID/单号、文件名、运营商或操作目标和操作者,不保存 StorageKey、签名 URL 或文件正文。每张实际新增卡/设备继续使用既有 `iot_card.create/device.create` 业务子事件并以完成根为 parent设备 CSV 分配/系列/回收继续复用既有批量根子审计。跳过项不伪造子事件,根事件 success/partial/failed 与实际子事件及失败统计一致 | `tb_iot_card_import_task``tb_device_import_task` 保存任务输入、进度和逐结果;卡、设备、资产标识、卡槽绑定、钱包及分配/系列事实仍由原业务表权威保存Audit Event 不替代任务或资产事实 | 对象存储下载只读取任务表中的 StorageKey不写 Integration Log审计源头只使用安全文件名。设备批量操作本身不新增外部调用 | HTTP 创建继续提交结构化 `IotCardImportPayload/DeviceImportPayload`Worker 使用任务单号 correlation 和稳定完成根 parent重试通过稳定 EventID 与原业务幂等规则避免重复子事件 |
| 单列 CSV 资产套餐批量订购 | 使用 `asset_package_batch_order_task.create/complete` 记录任务、文件名、套餐目标、支付方式、操作者和实际根子统计;既有 `order.create` 作为每个真实订单子事件并继承任务 correlation/parent不另造同义订单审计。StorageKey、VoucherKey、签名 URL 和 CSV 正文不进入审计 | `tb_asset_package_batch_order_task` 保存输入参数和逐行结果,成功行以 `tb_order`、订单明细、套餐使用、支付记录及代理钱包成功流水为权威业务事实 | 对象存储上传和下载沿用现有存储日志,不把文件正文写入 Integration Log本切片不新增外部支付或 Gateway 调用 | 创建接口提交结构化 `asset:package:batch_order` Asynq 任务;逐行钱包订单继续沿用既有钱包扣款 Outbox 和佣金任务,重复任务由状态条件与订单幂等规则阻断 |
| 订单套餐 CSV 批量失效 | 使用 `order_package_invalidate_task.create/complete` 记录任务根,使用 `order_package_invalidate_task.item` 为每个实际变化或已识别失败订单写子事件;订单为主要资源,实际失效套餐权益逐条关联并保存状态 `before→4`。无有效权益的幂等行不伪造变化子事件;任务 UI 统计与审计实际子事件统计分别保留 | `tb_order_package_invalidate_task` 保存文件任务状态和失败明细,`tb_package_usage.status` 是套餐权益终态权威事实;逐订单状态更新与子事件同一事务,任务终态与完成根同一事务 | 对象存储只用于读取 CSVStorageKey、VoucherKey、签名 URL 和文件正文不进入 Audit/Integration | 创建接口提交结构化 `InvalidateTaskPayload`Worker 原子 Claim审计失败时任务恢复待处理以便安全重试稳定子事件 ID 防止重复写入 |
| ICCID 批量生成待失效订单号 CSV | N/A脚本复用现有受权资产套餐查询属于运维只读投影请求进入 Access Log不改变套餐或订单事实 | N/A只读取 `tb_package_usage` 的状态和订单号快照,不写入领域账本;实际失效仍由既有批量失效任务负责) | N/A只调用本系统后台接口不调用 Gateway、支付或其他外部系统 | N/A纯查询和本地 CSV 生成,不产生 Outbox 或异步任务) |
| CSV 批量修改生效中套餐过期时间 | 每条修改复用既有 `asset_package_expires_at` 资产操作审计,记录操作者、资产、套餐使用记录及过期时间前后值和 success/failed 结果 | `tb_package_usage.expires_at` 是套餐过期时间权威事实;脚本只通过受权接口逐条修改,不直连数据库 | N/A仅调用本系统后台接口不调用 Gateway、支付或其他外部系统 | N/A同步修改不产生新增 Outbox后续套餐过期推进沿用既有轮询任务 |
| IoT 卡与套餐业务导出 | 创建/取消统一使用 `export_task.create/cancel`记录任务单号、scene、format、真实操作者和店铺范围取消状态或取消请求与 Audit Event 同事务。导出 Query、FileKey、签名下载 URL 和导出内容不进入审计;导出数据读取本身仍为 N/A | N/A导出只读取现有卡、套餐使用、套餐和分配事实,不写入领域账本 | N/A不调用外部业务系统;对象存储文件生成和下载沿用现有导出基础设施日志 | 沿用现有 `export:dispatch``export:shard``export:finalize` Asynq 链路,不新增业务 Outbox审计中心自身不注册导出路由 |
| 钱包流水与代理充值业务导出 | 创建/取消统一使用 `export_task.create/cancel`,只保存任务身份、格式、操作者和受控店铺范围,不复制 Query、业务凭证 Key、FileKey、签名 URL 或导出内容 | N/A只读取主钱包流水、充值记录和本地通用审批实例金额及余额使用既有权威事实不写入领域账本 | N/A不实时调用支付渠道或企业微信明文业务凭证 Key 只进入有权导出结果,不复制到 Audit/Integration且仍禁止记录 Secret、access_token、media_id 和附件正文 | 仅沿用现有 `export:dispatch``export:shard``export:finalize` Asynq 链路,不新增业务 Outbox取消重复请求不伪造新的状态变化事件 |
| 退款与换货业务导出 | 创建/取消统一使用 `export_task.create/cancel`;平台或代理 actor、scene/format 和当前权限范围进入任务资源Query、收货资料、业务凭证、FileKey、签名 URL 与结果正文不进入审计 | N/A只读取退款、订单、套餐使用、换货资产快照和本地审批事实金额及处理标记沿用既有权威事实不写入领域账本 | N/A不实时调用企业微信、支付或 Gateway明文业务凭证和收货资料只进入有权导出结果不复制到 Audit/Integration且仍禁止记录 Secret、access_token、media_id 和附件正文 | 仅沿用现有 `export:dispatch``export:shard``export:finalize` Asynq 链路,不新增业务 Outbox审计中心不提供用户导出 |
| 站内通知生成、已读与保留清理 | Outbox 消费实际生成通知使用 `notification.deliver`,只保存通知 ID、事件 ID、接收人、类别、类型、严重级别和受控资源引用不复制标题或正文后台账号与个人客户首次单条/全部已读使用 `notification.read/read_all`,重复已读不伪造事件;保留清理使用 `notification.cleanup/cleanup_item`,由 `system_task/worker` 记录批次和每条实际删除通知。通知创建、已读更新或物理删除与对应 Audit Event 共用 GORM 事务,审计失败回滚业务事实 | `tb_notification` 继续是通知内容、接收人、展示期限和已读状态的权威事实Audit Event 只解释生成、阅读和清理动作,不替代通知正文或接收状态 | N/A站内通知不调用短信、邮件、企微或其他外部系统受控 `ref_id/ref_key` 禁止 URL系统安全凭据和通知正文不进入审计 | 业务事务仍只写现有三类结构化通知请求 OutboxOutbox 投递事实与通知生成 Audit Event 分离,重复消费由事件+接收人唯一键幂等;清理继续复用 `notification:cleanup` Asynq 计划任务,不新增 Outbox |
| IoT 卡固定档位限速 | 统一动作 `iot_card.speed_tier_set` 记录认证上下文中的真实操作者、目标卡 ID/ICCID、请求档位编码与名称如 128Kbps/1Mbps/恢复不限速、Integration Log ID、Integration 是否终结及 success/failed/unknown 结果;设备无入口且不通过绑定卡间接限速 | N/A不在本地保存或修改卡当前限速状态Gateway 是外部执行方 | 每次实际 Gateway 调用前写 pending按 success/failed/unknown 终结;超时 unknown 保存按 ICCID 人工核对策略,摘要不含操作者或 Secret、access_token、完整响应正文 | N/A单次外部命令无后续可靠副作用结果未知禁止盲目重发不创建自动补偿 Outbox |
| IoT 卡人工实名状态纠偏 | `iot_card.realname_status_update` 记录真实后台操作者、目标卡、实名状态前后值及是否实际变化状态事实、Audit Event 和实名变化 Outbox 在同一 GORM 事务,失败写独立短事务 | `tb_iot_card.real_name_status`、首次实名时间及激活字段是内部实名事实 | N/A人工纠偏不直接调用 Gateway | 状态实际变化时复用 `card.realname.changed`,未变化不伪造变化事件 |
| IoT 卡后台/个人人工刷新 | 后台使用 `iot_card.manual_refresh`,个人客户使用 `iot_card.personal_refresh`;记录真实 actor、目标卡、实际状态变化及最终 success/partial/failed/unknown任一结果未知不写成成功 | 卡网络、实名、流量及 `last_sync_time` 是内部观测事实;各实际变化与统一 Audit Event 同事务 | 网络、实名、流量查询的每次真实 HTTP 尝试分别写 Integration Log失败/超时逐次终结,成功尝试在内部应用结果确定后记录 `state_changed` | 实名、网络、流量实际变化沿用各自 Card Observation Outbox人工刷新汇总不另造可靠事件 |
| IoT 卡人工/OpenAPI/自动/保护期停复机 | 分别使用 `iot_card.manual_stop``iot_card.manual_start``iot_card.openapi_start``iot_card.auto_stop``iot_card.auto_start``iot_card.auto_stop_reason_update`;记录真实人工/OpenAPI/system actor、目标卡、关联设备与卡槽、停复机原因、状态前后值、Integration ID 及 success/failed/denied/unknown保护期强制修正复用同一服务不再旁路审计 | `tb_iot_card.network_status/stopped_at/resumed_at/stop_reason/gateway_extend` 是内部状态权威事实 | 保持既有内外层重试次数和退避;每次真实 Gateway HTTP 分别写同一 `trigger_series` 下单调递增 attempt超时为 unknown成功尝试待状态事务结束后终结 | 状态、Audit Event 与 `card.observation.series.requested` 同事务;停复机完成后沿用轮询重排,不新增补偿 Outbox |
| 单笔资产分配/回收 | 当前无独立通用单笔写入口IoT 卡和设备同步分配/回收接口在请求仅含一个资产时,分别复用 `iot_card.allocate/recall``device.allocate/recall` 单资源子事件,记录真实后台或代理操作者、资产、分配记录、来源/目标店铺;设备同时关联实际连带变化的卡和卡槽 binding。每个已识别资源的拒绝/失败仍进入对应子事件,不另造 `asset.*` 重复动作 | 卡/设备归属与状态、实际连带卡归属及 `tb_asset_allocation_record` 是权威业务事实;单项事实、分配记录和 Audit Event 沿用 6.2/6.5 已有同一 GORM 事务 | N/A本地资产归属事务不调用 Gateway、支付、企微或其他外部系统 | N/A同步流转不产生新增可靠副作用提交后缓存失效和轮询通知保持既有顺序。批量请求根事件及 CSV 批量任务分别沿用 6.2/6.5 和 6.9,不在本切片重复实现 |
| 设备 CSV 批量分配、设置套餐系列或回收 | Worker 使用真实 `system_task/worker` 上下文,每个任务以稳定 `task_no` 作为 correlation 和批次键,仅对尚未达到目标关系的设备调用一次现有设备批量服务,复用 `device.allocate_batch`/`device.allocate``device.series_binding_batch`/`device.series_binding``device.recall_batch`/`device.recall` 根子事件;根事件统计与已识别设备子事件一致,实际绑定卡和卡槽随设备子事件进入各自资源时间线,不再另写 CSV 专用设备事实审计。任务查询的 `target_name` 仅批量投影当前店铺或套餐系列名称 | 设备归属、实际绑定卡归属、`tb_asset_allocation_record``series_id` 是权威业务事实;成功事实与 Audit Event 共用设备服务原 GORM 事务,审计失败回滚业务,不另建领域账本 | N/ACSV 解析、分配、系列绑定和回收均为本地数据库操作,不调用 Gateway、支付或企微对象存储沿用现有存储日志 | 复用 `device:import` Asynq 任务;完成/失败状态阻止终态任务重复执行,处理中断恢复时已达到目标关系的设备不再写业务事实或成功子事件,其余设备仍使用相同 `task_no` 保持审计幂等,不新增业务 Outbox |
| 设备停复机、Wi-Fi、切卡模式、重启和重置 | 使用 `device.stop``device.start``device.set_wifi``device.set_switch_mode``device.reboot``device.reset`,记录后台、个人或 OpenAPI 的真实 actor/source、设备、实际绑定卡与卡槽引用、命令参数、Integration ID 及 success/failed/denied/unknownWi-Fi 只记录 `credentials_configured`,不记录密码;当前卡切换由独立卡槽关系用例记录 | 停复机只修改实际处理卡的 `network_status/stopped_at/resumed_at/stop_reason`Wi-Fi、模式、重启和重置无同步本地状态变化只记录外部命令结论不伪造设备字段已生效`enabled` 当前未下发,登记 N/A | 保留 Gateway 既有重试与退避,每次真实 HTTP 尝试写同一 `trigger_series` 下的独立 attempt停复机按实际卡记录其他命令按设备记录超时为 unknown成功尝试在本地事务结果明确后终结 | 停复机的卡状态、Audit Event 与既有 `card.observation.series.requested` 同一事务,并保留保护期和轮询缓存失效;其他命令成功后沿用 best-effort 观测分发。Worker/Scheduler/Callback 不直接执行这些设备命令,自动停复机继续由 6.3 卡级审计覆盖 |
| 设备绑卡、解绑与当前卡切换 | 使用 `device.bind_card``device.unbind_card``device.switch_current_card`;记录后台账号、个人客户或 OpenAPI 的真实 actor/source设备、目标卡、旧/新当前卡及实际相关 binding 均为独立资源binding 快照及 before/after 固化 `slot_position/is_current`;设备导入和删除产生的隐式绑卡/解绑继续使用既有 `device.create/delete`,但同样关联每张卡和 binding已识别设备后的拒绝、失败和 unknown 使用独立短事务 | `tb_device_sim_binding.bind_status/slot_position/is_current` 是设备 1-4 卡槽及当前卡的权威内部事实;绑卡创建、解绑状态和切卡当前标识与 Audit Event 同一 GORM 事务,卡的 `device_virtual_no` 仍保持原提交后 best-effort 快照语义 | 绑卡、解绑 N/A纯本地事务切卡每次真实 Gateway HTTP 尝试写相同 `trigger_series` 下的独立 Integration Log attempt超时为 unknown目标卡必须是当前设备有效绑定卡Integration 只表达外呼结果,不提前声称本地状态已变化 | 切卡成功后保留既有 Card Observation best-effort 分发,用 Gateway 后续观测校准真实当前槽位;观测 Worker 的自动回写属于 8.7,本切片不重复审计;不新增 Outbox 类型,不迁移设备/卡其他状态机。后台绑卡/解绑只有平台入口,切卡三类入口汇聚共享 Service三组旧资产 operation log 已停止 |
| 订单创建、取消、钱包支付与过期关闭 | 使用 `order.create``order.cancel``order.wallet_pay``order.expire_close`;后台创建/代购记录真实账号C 端记录个人客户,代理 OpenAPI 记录 OpenAPI actor资产套餐批量订购逐笔记录 `system_task/worker`,过期关闭记录 `scheduled_job/scheduler`。事件关联订单、买家、卡或设备、套餐、实际钱包/流水和 Payment订单快照保存金额、支付方式/状态、购买角色及操作者;成功与原订单事务同写,已识别订单或资产后的拒绝/失败使用独立短事务。普通订单列表和详情仍为受权读取 N/A | `tb_order``tb_order_item``tb_payment``tb_package_usage``tb_agent_wallet`/`tb_asset_wallet` 及对应唯一流水继续是订单、套餐和资金权威事实Audit Event 不替代余额、支付或权益账本。钱包下单及待支付后的钱包支付均在原 GORM 事务内追加审计,审计失败回滚业务;重复命中且未发生状态变化时不伪造成功事件 | 本切片不直接调用支付渠道或 GatewayIntegration Log=N/A微信、支付宝、富友预下单、查单和回调属于 7.4,不能用订单事件替代外部尝试记录 | 代理钱包扣款继续在同一事务写既有钱包 Outbox佣金任务和套餐观测沿用原提交后链路Audit Event 不替代 Outbox。OpenAPI/CSV 批量根事件与 partial 统计属于 8.3,本切片只记录每笔实际订单,不提前伪造批次根事件 |
| 支付外部尝试与内部终态 | 使用 `payment.create``payment.confirm``payment.fail`;没有独立 Payment 的旧订单回调使用 `order.online_pay`。支付单为主要资源,订单或充值单为业务单资源,渠道交易号保存在支付快照;个人资产充值确认同时关联实际变化的钱包和唯一流水。支付创建、明确关闭及回调确认均与对应 Payment、订单、充值或钱包 Domain Ledger 共用原 GORM 事务,重复回调未发生状态变化时不重复写成功 Audit Event | `tb_payment``tb_order``tb_recharge_order``tb_agent_recharge_record`、钱包及唯一流水继续是支付、订单、充值和资金权威事实Audit Event 只解释操作者、回调来源、资源关系及前后状态,不替代支付状态、到账金额或渠道交易号 | 微信/富友真实预下单、代理充值微信/支付宝查单及所有已识别的微信/支付宝/富友回调逐次写 Integration Log使用稳定 `trigger_series+attempt`、支付单号 correlation 和渠道交易号幂等;成功回调仅在内部状态真实变化时标记 `state_changed`。支付宝 WAP URL 由本地签名生成Integration Log=N/A当前没有富友主动查单实现登记 N/A不虚构外部尝试 | 支付确认后既有佣金、套餐恢复、自动购包及代理充值入账 Outbox 保持原链路Audit Event 和 Integration Log 均不替代 Outbox。个人/代理充值完整创建至入账、钱包资金专项分别留给 7.67.9,本切片不修改渠道协议、金额校验、价格、佣金、套餐激活、钱包算法或状态机 |
| 个人资产充值与代理在线/线下充值 | 个人资产充值继续以 `payment.create/confirm/fail` 记录支付生命周期,并补齐真实个人客户、卡/设备、资产钱包和充值单资源;代理充值使用 `agent_recharge.create/credit/close` 记录线下申请、审批终态、在线/线下真实入账和关闭,支付事件补齐提交账号、目标店铺及主钱包。支付回调使用 `external_system/callback`,主动恢复使用 `scheduled_job/scheduler`Outbox 入账和自动购包使用 `system_task/worker`,不伪造最初提交人;重复支付、重复审批和重复入账未改变事实时不重复写成功事件 | `tb_recharge_order``tb_agent_recharge_record``tb_payment``tb_asset_wallet`/`tb_agent_wallet` 及唯一成功流水继续是充值与资金权威事实;成功 Audit Event 与充值状态、钱包余额、唯一流水及必要 Outbox 共用原 GORM 事务,审计失败回滚业务。`asset_recharge.auto_purchase` 与自动创建订单、钱包扣款流水、Payment、套餐权益和充值单自动购包状态同事务最终失败状态同样与 failed 审计同事务 | 微信、支付宝、富友预下单、回调和代理主动查单沿用 7.4 的逐次 Integration Logunknown 只表示外部结果未确认,不推进充值或钱包事实,也不伪造成功 Audit Event。线下申请创建和本地钱包入账不外呼Integration Log=N/A企微提交、终态同步与主动恢复继续由审批链 Integration Log 负责 | 代理在线支付确认继续写 `agent_recharge.payment_confirmed.v1`,由 Outbox 消费者幂等入账;线下申请继续写审批提交 Outbox审批通过后同事务写钱包 credited Outbox个人资产充值到账后沿用自动购包 Asynq自动购包继续写观测 Outbox。旧线下人工确认的账号 operation log 裸 goroutine 已停止;不新增充值渠道,不改变金额、钱包、审批、套餐激活或自动购包规则 |
### `deliver-july-iteration-confirmed-scope` 任务覆盖映射
@@ -93,14 +152,66 @@
- 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
-切换入口:账号创建、基础资料更新、独立启停、软删除、管理员改密、本人改密、企微绑定及后台登录/登出已改用统一 WriterPostgreSQL 账号安全事实与审计同事务,登录/登出审计为提交后 best-effort失败不撤销 Token 也不改变原认证结果;刷新保持原单次刷新协议,不因审计重写。企微绑定旧账号日志调用已移除,其他借用旧账号日志的调用模块仍按后续任务保留
- 角色创建/更新/启停/删除/默认信用、权限创建/更新/删除及角色权限分配/移除已切换统一 Writer角色、权限和关联变化与 Audit Event 同一 GORM 事务,批量配置不再逐项自动提交,权限快照保存 code/name 和资源级 before/after。
- 账号角色分配/移除及店铺默认角色分配/移除已切换统一 Writer账号或店铺为主要资源实际变化角色保存 ID/name/type/status 与分配前后值,关系变化与审计共用同一 GORM 事务;权限缓存在提交后 best-effort 失效Redis 失败不回滚业务。账号两处旧 operation log 写入已移除;店铺侧原本不存在旧账号日志写入。
- 店铺创建与基础资料变化已切换统一 Writer创建事件保存店铺编码、名称、上级和层级并与店铺、初始账号、默认角色及钱包初始化共用原有事务更新事件只记录实际变化的基础资料状态、业务员和 C 端登录限制留给 5.6。父子层级仍仅在创建时按既有七级规则确定,相关入口原本不存在旧账号日志写入。
- 店铺状态、业务员归属、C 端登录限制及删除已切换统一 Writer混合 PUT 按实际字段差异分别记录 `shop.enable/disable``shop.update_business_owner``shop.update_client_login_limit`,主体投影只保存安全结论;删除与账号禁用、账号资源变化及审计使用同一 GORM 事务,相关缓存在提交后 best-effort 失效。`Service.Enable/Disable` 当前无生产调用方,生产启停仍由 Update 状态字段承载;这些入口原本不存在旧账号日志写入。
- 企业创建、基础资料、状态和账号改密已切换统一 Writer分别使用 `enterprise.create``enterprise.update``enterprise.update_status``enterprise.update_password`,企业为主要资源,归属店铺为引用资源,实际企业账号为受影响资源;成功审计与企业/账号事实共用 GORM 事务,改密仅保存 `credentials_configured/state` 安全事实,不借审计改变原令牌行为。普通企业列表保持 N/A资产授权明确留给 5.8/5.9;这些入口原本不存在旧 operation log 写入。
- 企业卡授权、回收和授权备注已切换统一 Writer分别使用 `enterprise_card.allocate_cards``enterprise_card.recall_cards``enterprise_card.update_record_remark`企业为主要资源owner shop 为引用资源,实际变化的 IoT 卡和授权记录为受影响资源;授权事实与 Audit Event 共用 GORM 事务,重复有效授权不伪造变化。卡资源仅保存 `subject_result`安全结论,授权记录及备注保持 `internal_only``BatchAuthorize``RevokeAuthorizations` 无生产调用方、`AllocateCardsPreview` 为普通读取,均登记 Audit Event N/A。设备授权留给 5.9,本切片不改变卡授权有效性规则,也不引入第二套 Writer。
- 企业设备授权与回收已切换统一 Writer使用 `enterprise_device.allocate_devices``enterprise_device.recall_devices`企业为主要资源owner shop 为引用资源,实际变化的设备、设备授权、随设备处理的绑定卡及卡授权为受影响资源,实际卡槽绑定仅作为引用快照。授权创建与 Audit Event 共用 GORM 事务并锁定设备与当前卡槽绑定;回收改为事务内行锁和条件更新,按事务内真实命中项返回计数,不再调用持有独立 `db` 的 Store 方法形成伪事务。Service 边界显式拒绝空筛选和非法选取模式,参数错误不写 Audit Event企业/设备越权统一同错,零成功及并发全项冲突使用独立短事务写 `denied`。企业账号被明确拒绝,平台/代理继续复用 `CanManageEnterprise`,代理设备范围保持既有“仅本店设备”规则;设备与卡仅保存 `subject_result`,授权记录与卡槽绑定保持 `internal_only`。本切片不改变 1-4 卡槽绑定规则,不停止设备或卡的旧资产 Writer对应后续 6.x 用例)。
- 个人客户资料、手机号与微信主体已切换统一 Writer使用 `personal_customer.update_profile``personal_customer.bind_phone``personal_customer.change_phone``personal_customer.update_wechat_identity`,个人客户为主要资源,实际手机号或 OpenID 关系为受影响资源;资料更新、手机号绑定/换绑、客户或 OpenID 实际创建/同步与 Audit Event 共用 GORM 事务已识别客户后的业务拒绝或失败使用独立短事务。actor/source 固定为真实 `personal_customer/personal_api`,主体投影为 Registry 白名单约束的 `subject_detail`验证码、JWT、Cookie 和 Redis Token 不进入审计。重复微信登录且资料/OpenID 无变化不写资料事件,普通 `GetProfile`、资产令牌签发、登录 Token 签发与读取保持 N/A。
- 个人客户资产关系已切换统一 Writer使用 `personal_customer.bind_asset``personal_customer.unbind_asset``personal_customer.migrate_asset_binding`,个人客户为主要资源,实际新增、删除或迁移的 `tb_personal_customer_device`/`tb_personal_customer_iccid` 绑定为受影响资源,卡或设备以稳定标识快照进入同一事件。绑定沿用真实 `personal_customer/personal_api`,换货迁移和旧资产重置解绑沿用真实后台账号上下文;所有成功事件与原绑定写入、换货迁移或清理共用既有 GORM 事务,幂等绑定及无有效迁移记录不伪造成功事件。本切片不改变资产校验、首次绑定售出、换货资金/套餐迁移或无虚拟号旧资产重置规则。
- IoT 卡身份生命周期已切换统一 Writer实际导入落库使用 `iot_card.create`,每张新增卡与 `tb_asset_identifier`、Audit Event 共用原批次 GORM 事务actor/source 固定为真实 `system_task/worker`,以导入任务单号关联链路;已存在卡不写成功事件,导入任务创建及任务级根事件仍留给 8.3。单卡、批量删除分别使用 `iot_card.delete``iot_card.batch_delete`,实际删除卡与根子事件共用事务,缓存失效和轮询回调保持提交后执行,对应旧资产删除日志已停止。卡快照保存 ID、ICCID、VirtualNo、MSISDN、运营商、店铺、系列和 generation当前没有独立卡创建或基础资料更新生产入口后者登记 N/A。本切片不迁移分配/回收/系列、状态、实名、限速或 Gateway 命令。
- IoT 卡分配、回收和系列绑定已切换统一 Writer分别使用 `iot_card.allocate_batch`/`iot_card.allocate``iot_card.recall_batch`/`iot_card.recall``iot_card.series_binding_batch`/`iot_card.series_binding` 根子动作;实际卡归属、原有本地状态、`tb_asset_allocation_record``series_id` 更新与 Audit Event 共用原 GORM 事务,审计失败回滚业务,已识别资源后的拒绝/失败使用独立短事务。分配记录继续作为 Domain Ledger卡子事件关联来源/目标店铺、套餐系列以及实际存在的设备和卡槽绑定;绑定设备导致分配/回收拒绝时只记录真实既有关系不修改设备或绑定。缓存失效和轮询回调保持原提交后顺序对应三组旧资产操作日志已停止Integration Log、Outbox 均为 N/A本地事务不调用外部系统且无新增可靠副作用不迁移 6.3 的卡状态命令、实名、限速或 Gateway 行为。
- 设备身份生命周期已切换统一 Writer设备导入 Worker 使用 `device.create`actor/source 固定为 `system_task/worker`correlation 使用导入任务单号;每台设备的主表、资产标识、既有卡槽绑定与卡设备号快照、设备钱包及设备 Audit Event 保持在原单行 GORM 事务,审计只关联设备资源,不提前迁移卡槽语义。平台单删使用 `device.delete`,现有解绑、设备软删、资产标识清理和成功 Audit Event 同事务,失败/拒绝在业务未落地后写独立短事务;对应 `AssetAuditOpDeviceDelete` 调用已归零。设备快照保存 ID、VirtualNo、IMEI、SN、名称、型号、类型、制造商、店铺、系列和 generation当前不存在设备基础资料更新或批量删除生产入口均登记 N/A不为未来入口创建 Action 或 Service。导入任务创建的旧任务级审计仍留给 8.3;本切片不迁移状态、实名策略、分配回收、卡槽资源或 Gateway。
- 设备归属与策略已切换统一 Writer后台与 CSV Worker 共用 `device.allocate_batch`/`device.allocate``device.recall_batch`/`device.recall``device.series_binding_batch`/`device.series_binding`,实名策略使用 `device.realname_policy_batch_update`/`device.realname_policy_update`。分配、回收的设备与实际绑定卡归属/状态、分配记录和 Audit Event 共用 GORM 事务,系列与实名策略事实亦与审计同事务;子事件关联真实来源/目标店铺、分配记录、前后套餐系列、实际绑定卡及当前有效卡槽。已识资源的全拒绝和业务回滚失败使用独立短事务,二次失败保留原业务错误并记录 critical部分成功只为实际变化设备写 success 子事件。三组旧资产操作审计及 CSV 专用事实双写已停止;企业设备授权/回收已由 5.9 的 `enterprise_device.*` 覆盖,本切片登记 N/A不重复迁移。Integration Log 与 Outbox 均为 N/A均为本地事务且无新增可靠副作用设备外部命令明确留给 6.6。
- 设备外部命令已切换统一 Writer后台设备停复机、后台/个人 Wi-Fi、后台切卡模式以及后台/个人/OpenAPI 重启和重置均在共享设备 Service 接入。每次真实 Gateway HTTP 尝试写 Integration Log保留原重试与退避超时记录 unknown。停复机只为实际变化卡在原状态事务内写 `device.stop`/`device.start`,并关联入口设备、目标卡和现有 binding其他命令无同步内部字段变化Audit Event 只记录已下发、失败或结果未知的外部命令事实。Wi-Fi 密码不进入 Audit/Integration当前 `enabled` 未实际下发登记 N/A当前卡切换由后续卡槽关系切片覆盖。上述六组旧资产 operation log 调用已停止。
- 设备卡槽关系已切换统一 Writer后台绑卡/解绑分别使用 `device.bind_card``device.unbind_card`,平台后台、个人端和代理 OpenAPI 共用的当前卡切换使用 `device.switch_current_card`。设备、目标卡、旧/新当前卡和实际相关 binding 均进入独立资源时间线binding 快照及 before/after 保存 `slot_position/is_current`;设备导入创建 binding 和删除设备批量解绑则在原 `device.create/delete` 事件中补齐每张卡与 binding不另造重复动作。绑卡创建、解绑及切卡本地 `is_current` 与 Audit Event 共用 GORM 事务,审计失败回滚本地事实。切卡只允许当前设备有效绑定卡,每次 Gateway HTTP 尝试写独立 Integration Log超时或本地收口失败不伪装成功成功后仍保留原 Card Observation 分发校准真实当前槽位。三组旧资产 operation log 调用已归零;本切片不迁移设备/卡其他状态机,也不改变卡设备虚拟号提交后 best-effort 维护。
- 卡换货完整用例已切换统一 Writer物流创建、个人客户填写收货信息、后台发货、完成、取消和换出旧卡转新分别使用 `exchange.card.create``exchange.card.submit_shipping_info``exchange.card.ship``exchange.card.complete``exchange.card.cancel``exchange.card.renew`;直接换货创建即完成,只记录一次完成事件。成功事件与原换货单、卡状态、客户绑定迁移、资产钱包和流水、套餐权益及通知 Outbox 共用既有 GORM 事务,审计失败回滚业务;已识别换货单或旧卡后的拒绝/失败使用独立短事务并保留原错。完成事件分别关联换货单、旧/新卡 ICCID+VirtualNo、店铺、实际客户绑定、旧新钱包、迁移流水和实际迁移套餐权益客户绑定仍保留独立 `personal_customer.migrate_asset_binding` 事件,钱包流水和套餐权益仍是 Domain Ledger。个人收货资料、内部备注不进入审计主体仅看到安全结果旧卡转新为 `internal_only`。换货不调用外部系统Integration Log 为 N/A物流创建通知继续使用既有 Outbox。本切片不改变钱包迁移、PCI 解绑、套餐、资产归属或换货状态规则。
- 设备换货完整用例已切换统一 Writer物流创建、个人客户填写收货信息、后台发货、完成、取消和换出旧设备转新分别使用 `exchange.device.create``exchange.device.submit_shipping_info``exchange.device.ship``exchange.device.complete``exchange.device.cancel``exchange.device.renew`;直接换货仍只记录一次完成事件。成功事件继续与原换货事务、通知 Outbox、客户绑定、钱包/流水和套餐权益共用同一 GORM 事务,失败或拒绝使用独立短事务。完成事件关联换货单、旧/新设备 VirtualNo+IMEI+SN、店铺、实际客户绑定、旧新钱包、实际迁移流水和套餐权益并逐张关联旧/新设备当前有效绑定卡及 `device_sim_binding` 的 slot/is_current。现有业务不会在设备换货时迁移卡槽因此绑定卡和卡槽按真实状态记录为 reference不伪造变化客户绑定、钱包及套餐权益仅在实际变化时记录 affected。客户绑定独立事件、Domain Ledger、Integration Log N/A 和通知 Outbox 边界保持不变;本切片未修改设备卡槽、钱包、套餐、资产归属或换货状态规则。
- 套餐配置与授权已切换统一 Writer套餐系列使用 `package_series.create/update/delete/update_status`,套餐商品使用 `package.create/update/delete/update_status/update_shelf_status`,店铺套餐上下架、零售价和生效条件分别使用 `shop_package.update_shelf_status``package.update_retail_price``shop_package.update_expiry_base`,系列授权使用 `shop_series_grant.create/update/manage_packages/delete`。批量套餐分配与批量成本价调整分别使用 `shop_package.batch_allocate`/`shop_package.allocate``shop_package.batch_update_pricing`/`shop_package.update_pricing_item` 根子事件;全量价格锁拒绝为 `denied`,成功与拒绝混合为 `partial`,未实际变化或已存在而跳过的资源不伪造成功子事件。系列、套餐、店铺、系列授权、套餐授权和价格历史均作为独立资源进入各自时间线,配置与价格 before/after 只记录本次相关字段;成功事件与原配置、授权及价格写入共用 GORM 事务,已识别资源后的拒绝或回滚失败使用独立短事务并保留原业务错误。`tb_shop_package_allocation_price_history` 继续是价格变化的 Domain LedgerAudit Event 不替代它Integration Log 与 Outbox 均为 N/A本切片仅本地配置事务不发生外部交互或可靠异步副作用`shop_package_batch_allocation` 借用旧账号 operation log 的写入已停止,原上架、直属下级授权、佣金天花板、成本价锁定、赠送套餐和分配规则保持不变;套餐购买及 `package_usage` 权益生命周期明确留给 7.27.3,不在本切片迁移。
- 套餐权益生命周期已切换统一 Writer实际激活、到期及加油包级联失效、流量扣减、日/月/年重置、退款精准失效和按资产失效分别使用 `package_usage.activate/expire/deduct_traffic/reset_traffic/invalidate_refund/invalidate_asset`,只在状态条件更新或真实数值变化命中时写 success重复任务和无变化分支不伪造事件。`package_usage` 为主要/受影响资源,关联订单、套餐商品、当前卡或设备以及退款单均作为独立 reference 资源进入各自时间线;成功事件与 `tb_package_usage` 及每日流量详单 Domain Ledger 共用原 GORM 事务审计失败回滚业务已定位权益后的事务失败使用独立短事务并保留原错。Scheduler 使用真实 `scheduled_job/scheduler`Asynq、Outbox 消费和旧退款异步后处理使用真实 `system_task/worker`,人工实名激活保留 HTTP 账号 actorOutbox 消费以当前 envelope EventID 作为直接 parent沿用既有 request/correlation。退款审批标准 Outbox 同样把决策 EventID 传播为后处理 parent。换货完成继续复用 7.1 已有的同事务完成事件,不重复新增套餐迁移动作,并补齐迁移权益的激活/到期等快照以及独立订单、套餐引用;`tb_package_usage` 仍是权益权威事实。流量观测 Outbox 继续承担可靠传递Audit Event 不替代 Outbox套餐生命周期本地写不产生外部请求Integration Log 为 N/A。`InvalidateAllPackagesByAsset` 当前没有生产调用者,登记为已接好审计但生产覆盖 N/A不借本任务新增“资产停用即权益失效”规则既有套餐列表 Query 保持 N/A。`fix-package-activation-starvation` 的孤儿 CTE、每载体一个名额、同步恢复和两事务接续设计均作为独立专项修复保留本切片不改其业务规则。
- 订单生命周期已切换统一 Writer后台创建与代购、C 端普通购买、代理 OpenAPI 每笔购买、手工取消、待支付订单钱包支付及计划任务过期关闭分别使用 `order.create/cancel/wallet_pay/expire_close`。账号、个人客户、OpenAPI、批量 Worker 和 Scheduler 均保留真实 actor/source订单为主要资源买家、卡或设备、套餐、实际钱包/流水及 Payment 为独立关联资源,金额、支付状态、方式和购买角色保存为快照。成功事件与订单、明细、资金、支付、套餐权益及既有 Outbox 共用原 GORM 事务,审计失败回滚业务;拒绝和事务失败保留原错误并写独立短事务,幂等无变化分支不伪造成功。支付渠道 Integration Log 留给 7.4,代理及资产钱包自身资金动作分别留给 7.7/7.9OpenAPI/CSV 批量根事件留给 8.3;本切片未修改价格、佣金、套餐激活、钱包算法、授权或订单状态机,普通订单 Query 保持 N/A。
- 支付外部与内部终态已接入统一边界Payment 创建、渠道明确关闭和回调确认分别使用 `payment.create/confirm/fail`,没有 Payment 的旧订单回调使用 `order.online_pay`;支付单、订单或充值单、渠道交易号以及回调实际变化的钱包/流水进入同一事件,成功审计与对应 Domain Ledger 共用原 GORM 事务,重复回调不伪造成功事件。微信/富友真实预下单、代理充值微信/支付宝查单及微信/支付宝/富友已识别回调写 Integration Log支付宝 WAP 本地签名和当前未实现的富友主动查单均明确 N/A。Integration 使用支付单号 correlation回调用渠道交易号幂等结果未知不伪装失败或成功本切片未重构渠道协议也未修改价格、佣金、套餐激活、钱包、授权、金额规则或状态机充值与资金专项审计仍由 7.67.9 收口。
- 个人资产和代理充值已切换统一 Writer个人资产充值的 `payment.create/confirm/fail` 事件补齐个人客户、卡/设备、充值单、资产钱包和唯一流水;代理线下申请、真实入账及拒绝/关闭分别使用 `agent_recharge.create/credit/close`关联提交账号、目标店铺、审批实例、支付单、主钱包和唯一流水。外部回调、主动恢复、Outbox 消费分别保留 `external_system``scheduled_job``system_task` actor重复终态不伪造成功。自动购包使用 `asset_recharge.auto_purchase`,与订单、支付、钱包扣款流水、套餐权益及充值单自动购包状态同事务,最终失败状态同样原子记录;既有 Integration Log、审批/入账 Outbox、观测 Outbox 和自动购包 Asynq 边界不变。旧线下人工确认的账号 operation log 裸 goroutine已停止本切片不新增渠道也未修改金额、钱包、审批、套餐激活、佣金或自动购包规则。
- 代理主钱包订单资金已切换统一 Writer直接扣款使用 `agent_wallet.order_debit`,预占、释放和完成分别使用 `agent_wallet.order_reserve/order_release/order_complete`;订单为主要资源,主钱包、预占事实及实际成功流水为受影响资源,钱包记录 balance/frozen_balance 前后值流水保留唯一业务引用。Audit Event 与既有行锁、乐观锁、状态条件、唯一流水和钱包 Outbox 共用原事务,写入失败回滚全部业务事实;订单事务整体失败后按已尝试的钱包动作写独立短事务,重复扣款或重复预占终态不伪造成功事件。完成预占由 `order_complete` 同时关联扣款流水,不再重复生成 `order_debit` 审计。当前生产只有直接扣款和取消订单 release 调用freeze/complete Application 接缝暂无生产调用者,本任务只接入审计,不新增调用、不修改钱包、订单、套餐、佣金、充值、退款或信用额度规则。
- 代理主钱包正向及回退资金已完成专项收口:充值继续复用 7.6 的 `agent_recharge.credit`,退款继续复用 7.5 的 `refund.approve`,两者均已在一条业务事件中关联主钱包、原流水/新流水和余额前后值,不新增同义钱包事件。人工调整使用 `agent_wallet.adjust_balance`,在既有 Posting/Outbox 事务内关联唯一调整流水并强制记录原因;当前无生产入口,只接好现有 Application 能力,不新增接口。实际信用额度更新使用 `agent_wallet.change_credit`,主钱包为主要资源、店铺为引用资源,保存余额/冻结余额不变和信用字段/version 前后值;成功审计失败回滚信用更新,已识别钱包后的拒绝/失败使用独立短事务并保留原错。Integration Log 均为 N/A本切片未修改充值、退款上限、钱包算法、信用占用、审批、佣金或资产钱包规则。
- 卡/设备资产钱包资金已完成专项收口:充值复用 `payment.confirm`,扣款复用 `order.wallet_pay`,退款复用 `refund.approve`,换货迁移复用 `exchange.card.complete/exchange.device.complete`,不新增同义钱包动作。四类成功事件均在原业务事务内关联卡的 ICCID/VirtualNo 或设备的 VirtualNo/IMEI/SN、资产钱包、充值/订单/退款/换货单及唯一流水,并保存余额前后值;充值事件只保留一条受影响钱包资源,订单扣款的钱包和流水明确标记为 affected。支付、订单、退款状态条件及换货状态机继续阻止重复业务键或重复终态伪造成功无余额迁移不创建流水钱包流水继续作为 Domain Ledger。充值渠道外部尝试沿用 Integration Log其余本地钱包动作 N/A本切片未修改钱包余额/冻结/乐观锁、退款上限、换货迁移、套餐、客户绑定或代理主钱包规则。
- 佣金与提现状态机已切换统一 Writer订单 Worker 使用 `commission.calculate` 记录真实 `system_task/worker`、订单佣金状态结果、全部佣金记录、归属店铺和套餐系列;每笔自动入账及待审人工入账使用 `commission.credit`,关联佣金记录、订单、店铺、系列、佣金钱包及实际存在的钱包流水和余额前后值;待审人工失效使用 `commission.invalidate`。退款回扣继续只使用既有 `refund.invalidate_commission`,不重复造佣金失效事件。提现申请、通过、驳回分别使用 `commission_withdrawal.request/approve/reject`,提现单为主要资源,店铺、佣金钱包及冻结/扣除/解冻流水为独立资源,收款账户 JSON 不进入审计。成功事件与现有佣金、订单、钱包、流水和提现事实同事务,已定位订单、佣金记录或提现单后的拒绝/失败使用独立短事务;幂等完成订单不伪造重复成功。现有“待审佣金人工入账”业务只更新佣金记录和钱包、不创建钱包流水,本切片按真实事实关联钱包但不伪造 Domain Ledger统计 Query、佣金公式、阶梯规则、提现金额/手续费、冻结算法和审批状态机均未修改。Integration Log 与 Outbox 为 N/A这些链路没有外部调用或新增可靠副作用
- 轮询配置与人工动作已切换统一 Writer轮询配置创建、更新、删除和启停使用 `polling_config.create/update/delete/update_status`,并发配置更新与计数重置使用 `polling_concurrency.update/reset`,告警规则创建、更新和删除使用 `polling_alert.create_rule/update_rule/delete_rule`;成功事件与对应 PostgreSQL 配置事实共用 GORM 事务审计失败回滚配置写入。Redis 并发计数重置无法与 PostgreSQL 原子提交,审计失败时恢复重置前计数;人工单卡去重键在日志/审计事务失败或队列写入失败时移除,避免阻断原有重试。
- 单卡、批量、条件筛选人工触发和取消分别使用 `polling_manual_trigger.trigger_single/trigger_batch/trigger_by_condition/cancel_trigger`,记录真实后台账号、手动任务和实际卡资源;配置重名、每日触发上限、重复入队、越权取消和已结束任务取消等已定位资源的拒绝写独立短事务,二次失败保留原业务错误并记录 critical。`tb_polling_manual_trigger_log` 继续承担进度、结果与历史查询,不被 Audit Event 替代或停写;审计不复制 `CardIDs`、条件正文、通知渠道正文或其他安全凭据,只保存任务类型、触发方式、数量、状态及“条件/通知渠道是否配置”等安全事实。
- 实名、流量和卡状态轮询的每次真实 Gateway 尝试继续写 Integration Log套餐/保护期轮询自身不伪造 Gateway 尝试,实际停复机复用共享 `StopResumeService` 的 Audit/Integration 边界。轮询配置、并发状态、告警历史、人工任务状态/历史和监控页面等普通运行查询均为 N/A通知投递、任务 ledger、Audit Event 与 Integration Log 保持独立事实。`polling_cleanup` 数据清理配置未包含在 8.5 明确边界,本轮不借轮询审计扩展其 CRUD 或手动清理动作,继续保留在覆盖清单等待对应显式切片。
- 外部 Callback 已按当前生产路由收口:微信/支付宝/富友支付回调继续复用 7.4 的支付、订单和充值事务审计,企微审批回调继续复用 8.1 的权威终态审计;电信、移动、联通实名成功回调在共享 `ApplyCardObservation` 事务中使用 `iot_card.realname_callback_sync`,真实 actor 为各运营商 `external_system/callback`,关联 IoT 卡和入站 Integration Log。重复支付、重复审批、重复实名及无状态变化只保留 Integration Log不伪造成功 Audit Event联通解除实名仍仅留痕且不改变本地实名事实。本切片不修改支付、运营商或企微协议。
- 当前 31 个 Asynq Worker 已按 8.7 逐项复核:`iot_card:import``device:import``order_package:invalidate``asset_package:batch_order``commission:calculate``package:first_activation``package:queue_activation``order:expire``notification:cleanup``auto_purchase:after_recharge``wecom:approval:sync``wecom:approval:recovery``agent_recharge:recovery` 已复用前序纵向切片的统一 Audit Event本切片新增 `card_observation:series``polling:realname/carddata/card_status``system_task/worker` 上下文,仅在实名、流量、网络或设备字段/当前卡槽实际变化时分别写 `iot_card.worker_realname_sync``iot_card.worker_traffic_sync``iot_card.worker_network_sync``device.worker_observation_sync`,并关联对应 Gateway Integration Log。`polling:package/protect` 不另造轮询动作,实际停复机继续复用 `iot_card.auto_stop/auto_start/auto_stop_reason_update`。上述链路传播 request/correlation/parent重试失败写 failed 短事务,无实际变化仅保留 Integration Log 或任务运行事实,不伪造 success。
- Worker N/A 与后续边界:`email:send` 仅模拟邮件投递;`export:dispatch/shard/finalize` 仅做导出技术装配;`commission_stats:update/sync/archive` 仅维护 Redis/PostgreSQL 统计投影;`polling:alert_check` 仅生成告警运行事实;`polling:data_cleanup` 仅执行既有轮询数据清理;`package:expiry_reminder` 仅生成可靠通知 Outbox`daily_traffic:flush` 仅把 Redis 日流量 Domain Ledger 落盘,均不重复创建 Audit Event。`outbox:deliver` 自身是可靠投递技术入口Relay/投递事实 N/A其各业务消费者是否形成新业务事实按任务 8.9 逐项收口,不在 8.7 提前迁移。Scheduler 仅投递或创建任务的边界留给 8.8。
- Scheduler 已按当前注册清单逐项复核:代理在线充值恢复、订单过期关闭和企微审批恢复在真实业务事实变化时使用 `scheduled_job/scheduler`;轮询 Scheduler 直接执行的套餐到期、后续权益接续、流量周期重置及套餐到期停机检查同样保留 `scheduled_job/scheduler` 操作者。套餐到期后的异步停机检查使用不受调度 tick 取消影响的原审计上下文,继续传播 actor、correlation 和 parent不伪造人工操作者。
- Scheduler N/A 与幂等边界Asynq 周期注册、轮询心跳、队列深度检查、分片/手动队列出队、重复调度的 `Unique` 去重与失败回队只是技术调度或任务事实,不写 Audit Event也不把后续 Worker 结果伪装成 Scheduler 成功。告警检查、轮询数据清理、通知保留清理、套餐临期提醒和日流量落盘继续按 8.7 的 Worker/Domain Ledger/Outbox 边界处理;重复调度依赖既有状态条件、领取租约、稳定事件 ID 和 Asynq `Unique`,无实际变化不伪造 success。Worker 消费逻辑未在 8.8 迁移。
- 当前 14 个 Outbox 事件注册、12 个消费者实现已按 8.9 逐项复核:企微审批提交终态、审批标准决策分发、卡实名/流量/网络变化后续处理、代理在线充值入账和三类站内通知生成会形成新的内部业务事实,均通过 `outbox:deliver` 入口取得 `system_task/worker` actor并沿用信封的 request/correlation/parent实际变化继续复用 8.1、7.2、7.6 和 8.4 已接入的同事务 Audit Event。状态条件、处理租约、稳定事件 ID、业务唯一键和通知 `CreateIdempotent` 保证至少一次投递不会伪造重复 success消费者返回成功只代表本次业务处理完成Outbox 的 delivered 状态仍是独立投递事实。
- Outbox 消费者 N/A 边界:卡观测序列请求仅幂等创建后续观测任务,代理主钱包预占/入账/退款消费者仅复核既有 Domain Ledger扣款消费者仅复核流水并按阈值幂等追加新的通知 Outbox均不把“校验通过、任务触发或二次投递成功”写成业务 Audit Event后续观测或通知实际改变业务事实时由对应 Worker/Application 自身写 Audit Event。Relay 的领取、入队、续租、delivered/failed、退避和重投保持技术投递事实不在 8.9 修改或审计化。
- request/correlation 组合时间线只按非空稳定 ID 精确读取 `tb_audit_event``tb_integration_log``tb_outbox_event`,以 `record_source` 保留 Audit、外部交互及可靠投递边界Outbox 只展示当前投递摘要,不把 delivered 解释为业务成功。Asynq 没有通用 PostgreSQL 历史表Query 仅从已落库的导入、批量购包、套餐失效、设备批量和导出任务资源生成 `asynq_task` 摘要,不扫描 Redis、不展示技术重试。订单、支付、退款、充值、钱包/流水、套餐权益、审批和佣金等只生成 `domain_ledger_ref` 稳定引用,金额与状态仍以业务表为准并留给 9.2 专业资金视角。Access Log 只返回 request ID 供开发检索,不读取日志文件;历史缺少 request/correlation/parent、直接 Audit 关联或稳定资源时通过 fidelity 标记原样降级,不按相近时间、相似资源或相同 correlation 猜测技术尝试。节点统一按发生时间、`record_source`、稳定节点 ID 升序排列。
- 资金调查时间线以平台只读 Query 组合 Audit Event、代理/资产钱包流水、代理钱包预占、订单、支付、退款、代理/个人资产充值、佣金、提现和审批当前业务事实;支持从店铺、钱包、订单、支付、退款、充值、审批、第三方交易号、操作者、时间或 correlation 中任一稳定条件进入,并在服务端解析已持久化关联,不要求前端补齐整条链路。节点按发生时间、`record_source`、稳定节点 ID 倒序分页,统一返回调查跳转引用;钱包流水的 `amount/balance_before/balance_after` 及各业务表金额字段明确标记为权威Audit Event 金额只作操作摘要,冲突时不改写历史事件并以对应 Domain Ledger 为准。平台身份复用统一审计 Query 的 SuperAdmin/Platform 后端校验,不增加店铺数据范围,也不提供资金重算、状态修改、导出或恢复能力。
- 风险调查视角只读聚合统一 Audit Event高/严重风险、涉及订单/支付/退款/充值/钱包/佣金等资金资源、安全类别,以及 `failed/denied/partial/unknown` 结果进入固定风险集合普通低风险成功事件明确排除。overview 在最长 31 天的显式时间范围内按风险、结果、action、来源和小时/日趋势聚合events 复用统一事件批量投影、稳定倒序分页及 `investigation_refs`可继续跳转事件、资源、actor 和 correlation。该视角不读取或修改业务状态不产生 Audit Event、Domain Ledger、Integration Log 或 Outbox也不提供处置工单、自动封禁、导出或恢复能力。
- 跨视角查询性能仅补已确认路径的 PostgreSQL B-tree保留事件时间、actor、scope、correlation 和资源时间线既有索引,补 action、result、risk、category、source 的稳定倒序分页索引,将 request/parent 索引补齐时间与 ID并为资源 type+id/key 到事件的反向关联补索引。全局、actor、资源和风险事件先分页事件 ID再批量投影事件与资源Integration 列表同样先分页 ID再批量读取列表字段不加载正文 JSON。第一阶段不增加 JSONB 任意搜索、Redis 结果缓存、月分区或冷热联合查询。
- 跨视角 HTTP 契约复用既有平台 `AuditHandler` 和生产/文档装配,仅新增 request、correlation、finance、risk 的只读 GET Handler/DTO/RouteSpec身份继续只取认证上下文未增加写路由、导出、处置、恢复或前端实现。前端导航文档逐行冻结 design 8.1-8.4 的源页面、前置接口、`response.data` 字段、入口可见条件、目标参数和降级规则,并以资产、订单、退款、钱包、通知、风险节点演示完整调用链。
- Audit Event 与 Event Resource 每日冷归档使用独立 `audit:daily:archive` Asynq 任务和 `tb_log_archive_run` 轻量账本:按 `Asia/Shanghai` 前一完整自然日、事件 `created_at` 半开区间分页读取,每行保存一个完整事件及其全部 `resources[]`,生成 JSONL+gzip、manifest 和 SHA-256对象 Key 按日期与 revision 稳定生成,上传后回读 metadata 核对大小、hash、事件数和资源数成功重复任务直接复用失败或对象不一致使用新 revision 且不覆盖旧对象。该基础设施任务不写业务 Audit Event不读取 Integration/Access/Domain Ledger/Outbox/旧 operation log不清理数据库也不向业务 Writer 注入对象存储;对象存储失败只更新归档账本并由 Asynq 重试,业务审计写入继续正常执行。
- Integration Log 冷归档复用同一对象存储、归档 Service 和 `tb_log_archive_run``integration:daily:archive``created_at` 保存前一自然日的结构化 JSONL+gzip 创建日快照;`integration:monthly:finalize` 在月初逐日按数据库当前内容重新生成并比较记录数与 SHA-256首次终结、内容变化或对象 metadata 不一致时创建新的不可变 revision旧对象不覆盖。月度复核仅把无 `pending` 记录且最终对象、manifest 均复核成功的日期标记 `is_final``pending`、对象损坏或复核失败会使任务失败并明确阻止后续清理。该切片不修改 Integration Writer、恢复语义或业务状态也不删除 PostgreSQL 数据、不提供对象存储查询/恢复接口。
- 月度留存清理使用 `audit:monthly:retention` Asynq 任务,在 `Asia/Shanghai` 每月 1 日 06:00 处理上一完整自然月:先补齐最后一日 Audit/Integration 归档并完成 Integration 最终 revision再逐日核对 ledger、manifest、对象 metadata、压缩对象实际大小/SHA-256 及数据库数量。全月硬门禁通过后仅按 Event Resource → Audit Event → Integration Log 顺序对 `tb_audit_event_resource``tb_audit_event``tb_integration_log` 以 1000 行有界批次执行 GORM 物理删除,并复用 `tb_log_archive_run.cleanup_started_at/cleaned_at` 断点续跑;对象存储归档和 manifest 长期保留。清理结果以 `retention_worker/system_task` 写当月 `audit.retention_cleanup` Audit Event资源为 `log_archive_month`Domain Ledger、Integration Log 新写、Outbox 均为 N/A因该事实是内部留存执行结果不是业务状态、外部交互或可靠投递。Access Log、订单/支付/退款/钱包等 Domain Ledger、Outbox、Asynq 运行事实、手动轮询、旧 operation log 及其他业务表明确不删除。
- 在线审计查询以 `tb_log_archive_run.cleaned_at/range_end` 作为真实清理边界:平台 Audit/Integration、request/correlation/finance/risk 及代理/企业活动响应统一返回 `retention{online_from,archived_before,timezone}`;缺省时间范围只查 PostgreSQL 在线窗口,显式早于或跨越边界返回 `CodeAuditDataArchived` 和当前边界。稳定事件或 Integration ID 只在在线库查找,不存在仍返回资源不存在;历史资源快照搜索和 Integration 尝试序列同样受边界限制。该 Query 切片不访问对象存储,不新增归档下载、恢复、冷热联合查询、导出或写路由,普通读取仍为 Audit Event/Domain Ledger/Integration Log/Outbox N/A。
### 与审计接入分开保留的独立修复
- 企业、企业卡和企业设备的权限校验、空筛选防全量、批量边界、越权同错及企业设备 TOCTOU/真实命中计数作为独立安全与并发修复保留,不视为审计所需的业务重构。
- 手机号绑定/换绑的行锁和事务内二次复检、账号状态与代理越权校验、管理员账号改密后撤销 Token 作为独立修复保留;企业账号改密不扩展同样的 Token 行为。
- 角色/权限变化和店铺删除后的权限缓存清理能力作为独立修复保留,但只在数据库提交后 best-effort 执行,不让 Redis 失败反向回滚业务事实。
- 已确认支付配置和员工线下充值的外层裸 goroutine 均已归零;旧 Writer 自身仍另启 goroutine由最终 contract 任务统一处理。
- 迁移责任04、07、0819 号票验证生产装配和直接旧表写入归零。
### 旧资产审计
- 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/service/asset/``internal/service/device/``internal/service/iot_card/``internal/service/polling/asset_polling_service.go`;卡/设备导入任务创建已停止调用旧 Writer但生产组合根的兼容参数留待 11.1 统一清理
- 兼容读取:`internal/handler/admin/asset.go``internal/routes/asset.go``internal/model/dto/asset_operation_log_dto.go`;由 10 号票保留读取契约。
- 迁移责任05、06、0919 号票验证生产装配和直接旧表写入归零。
@@ -108,14 +219,14 @@
- 状态与写入:`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 Log10 号票提供历史投影19 号票再启用旧写护栏
- 当前继续承担运行状态、进度、结果和历史查询8.5 仅为人工触发/取消补充统一 Audit Event不得停写、删除或以 Audit Event 替代该 ledger最终旧写护栏必须将其列入显式白名单
## 评审门禁
以下确认尚未由本地代码执行替代00 号票在三方评审前不得标记完成
本轮已按当前代码完成以下三视角复核;后续源码入口、事务、敏感字段或可见性变化会使覆盖门禁失败并要求重新复核
- 业务评审逐入口业务所有者、资源、Domain Ledger 与 N/A 理由准确,没有改变已评审业务范围。
- 研发评审:事务边界、失败策略、旧 Writer 清单、动作编码和测试接缝能由对应迁移票落地。
- 安全评审:风险等级、敏感字段策略、拒绝/失败覆盖和外部正文摘要策略完整。
评审发现错误时应修改对应显式条目和生成分类规则,并重新运行覆盖门禁;禁止仅手改统计数字。
后续评审发现错误时应修改对应显式条目和生成分类规则,并重新运行覆盖门禁;禁止仅手改统计数字。

File diff suppressed because it is too large Load Diff

View File

@@ -6,6 +6,7 @@
- [全局审计当前实现进度](docs/tech-global-audit/当前实现进度.md)
- [C 端资产续费价格](docs/client-asset-renewal-price/功能总结.md)
- [七月迭代套餐接续恢复](docs/feature-505-package-activation-recovery/功能总结.md)
## 系统简介
@@ -253,6 +254,8 @@ default:
- **代理开放接口**:新增 `/api/open/v1` 签名接口,代理店铺第三方系统可调用卡流量、卡状态、实名状态、套餐列表、预充值钱包余额/流水和钱包套餐购买能力。详见 [对接说明](docs/agent-open-api/功能总结.md) 与 [误发差价佣金修复说明](docs/agent-open-api/开放接口误发差价佣金修复说明.md)
- **批量同步**:卡状态、实名状态、流量使用情况
- **批量购买套餐脚本**:支持从单列 CSV 读取 ICCID/虚拟号,逐资产调用后台订单接口购买统一套餐,提供预演、重复拦截和逐条结果落盘能力。详见 [使用说明](scripts/batch_package_purchase/README.md) 与 [功能总结](docs/批量购买套餐脚本/功能总结.md)
- **批量套餐失效订单生成脚本**:支持从单列 ICCID CSV 查询待生效、生效中和已用完套餐的订单号,去重后生成现有批量失效功能可直接使用的 CSV脚本本身不修改套餐状态。详见 [使用说明](scripts/batch_package_purchase/README.md) 与 [功能总结](docs/批量套餐失效订单生成脚本/功能总结.md)
- **批量套餐过期时间修改脚本**:支持从单列资产 CSV 查询全部生效中套餐,默认预演,确认后统一修改为指定过期时间并逐条保存结果。详见 [使用说明](scripts/batch_package_purchase/README.md) 与 [功能总结](docs/批量套餐过期时间修改脚本/功能总结.md)
- **批量换货脚本**:支持从双列 CSV 读取新旧资产映射,固定执行直接换货和全量数据迁移,提供预演、批次冲突拦截和逐条结果落盘能力。详见 [使用说明](scripts/batch_exchange/README.md) 与 [功能总结](docs/批量换货脚本/功能总结.md)
- **批量回收设备脚本**:支持从单列 CSV 读取 IMEI/虚拟号,复用已部署的设备查询与回收接口,提供整批预检查、默认预演和逐设备结果记录。详见 [使用说明](scripts/batch_device_recall/README.md) 与 [功能总结](docs/批量回收设备脚本/功能总结.md)
- **轮询系统**IoT 卡实名状态、流量使用、套餐余额的定时轮询检查;支持配置化轮询策略、动态并发控制、告警系统、数据清理和手动触发功能;详见 [轮询系统文档](docs/polling-system/README.md)
@@ -940,8 +943,14 @@ rdb.Set(ctx, key, status, time.Hour)
## 文档
- [平台基础审计调查接口](docs/feature-504-multi-view-audit-center/平台基础审计调查接口.md)
- [跨视角调查与前端导航契约](docs/feature-504-multi-view-audit-center/跨视角调查与前端导航契约.md)
- [外部集成调查接口](docs/feature-504-multi-view-audit-center/外部集成调查接口.md)
- [代理和企业主体资源活动接口](docs/feature-504-multi-view-audit-center/主体资源活动接口.md)
### 开发规范
- **[前端建设总览](docs/前端建设/README.md)**:面向从零建设管理 Web、管理 H5 和独立 C 端 H5说明业务全景、前端选型、页面接口矩阵、权限模型和联调契约
- **[API 文档生成规范](docs/api-documentation-guide.md)**路由注册规范、DTO 规范、OpenAPI 文档生成流程
- **[数据库验证规范](AGENTS.md#数据库验证规范)**:使用 PostgreSQL MCP 验证接口逻辑和业务数据的正确性
- **[开发规范总览](AGENTS.md)**:完整的项目开发规范(必读)

View File

@@ -25,6 +25,7 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
// 3. 创建所有 Handler使用 nil 依赖,因为只需要路由结构)
// 新增 Handler 必须注册到 openapi.BuildDocHandlers代理开放接口也从该入口进入文档生成器。
handlers := openapi.BuildDocHandlers()
handlers.Audit = admin.NewAuditHandler(nil, nil)
handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
handlers.WeCom = admin.NewWeComHandler(nil, nil)

View File

@@ -34,6 +34,7 @@ func generateAdminDocs(outputPath string) error {
// 3. 创建所有 Handler使用 nil 依赖,因为只需要路由结构)
// 新增 Handler 必须注册到 openapi.BuildDocHandlers代理开放接口也从该入口进入文档生成器。
handlers := openapi.BuildDocHandlers()
handlers.Audit = admin.NewAuditHandler(nil, nil)
handlers.AssetPackageBatchOrder = admin.NewAssetPackageBatchOrderHandler(nil, nil)
// 企业微信 Handler 在此显式装配,避免新增管理接口遗漏文档注册。
handlers.WeCom = admin.NewWeComHandler(nil, nil)

View File

@@ -16,12 +16,14 @@ import (
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
auditArchiveApp "github.com/break/junhong_cmp_fiber/internal/application/auditarchive"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
notificationApp "github.com/break/junhong_cmp_fiber/internal/application/notification"
walletApp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/gateway"
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
@@ -145,6 +147,7 @@ func runWorker(cfg *config.Config) {
taskHandler.RegisterHandlers()
registerWeComApprovalTasks(taskHandler.GetMux(), runtime, cfg, appLogger)
registerAgentRechargeRecoveryTask(taskHandler.GetMux(), runtime, appLogger)
registerAuditArchiveTask(taskHandler.GetMux(), runtime, appLogger)
outboxHandler := outbox.NewHandler(runtime.outboxConsumers)
taskHandler.GetMux().HandleFunc(constants.TaskTypeOutboxDeliver, outboxHandler.Handle)
startOutboxRelay(ctx, runtime, cfg.Worker.InstanceName, appLogger)
@@ -319,6 +322,10 @@ func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.L
// registerWeComApprovalOutboxConsumer 注册企业微信审批提交和标准终态业务消费者。
func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Config, appLogger *zap.Logger) {
auditWriter, ok := runtime.workerResult.Services.PaymentAudit.(*auditInfra.Writer)
if !ok || auditWriter == nil {
appLogger.Fatal("通用审批统一审计 Writer 未配置")
}
applicationRepository := wecomInfra.NewApplicationRepository(runtime.db)
integrationRepository := integrationlog.NewRepository(runtime.db)
tokenProvider := wecomInfra.NewTokenProvider(
@@ -326,7 +333,7 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
cfg.WeCom.BaseURL, cfg.WeCom.Timeout, appLogger,
)
consumer := wecomInfra.NewApprovalSubmissionConsumer(
wecomInfra.NewApprovalContextRepository(runtime.db),
wecomInfra.NewApprovalContextRepository(runtime.db, auditWriter),
wecomInfra.NewApprovalSubmissionClient(tokenProvider, integrationRepository, cfg.WeCom.BaseURL, cfg.WeCom.Timeout),
wecomInfra.NewApprovalAttachmentUploader(tokenProvider, integrationRepository, runtime.storageSvc, cfg.WeCom.BaseURL, cfg.WeCom.Timeout),
)
@@ -339,7 +346,7 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
owner = fmt.Sprintf("worker-%d", os.Getpid())
}
walletPosting := walletApp.NewPostingService(
walletInfra.NewCreditEventWriter(outbox.NewRepository()),
walletInfra.NewCreditEventWriter(outbox.NewRepository(), auditInfra.NewWriter(auditInfra.NewRegistry(), nil)),
nil,
)
stopResumeService, _ := runtime.workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService)
@@ -362,10 +369,11 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
walletApp.NewRefundService(walletInfra.NewRefundEventWriter(outbox.NewRepository()), nil),
)
refundService.SetNotificationOutbox(outbox.NewRepository())
refundService.SetLifecycleAudit(auditWriter)
decisionDispatcher := approvalApp.NewDecisionDispatcher(
approvalInfra.NewDecisionDeliveryStore(runtime.db),
map[string]approvalApp.BusinessDecisionHandler{
constants.ApprovalBusinessTypeOfflineRecharge: agentrechargeApp.NewApprovalDecisionHandler(runtime.db, walletPosting),
constants.ApprovalBusinessTypeOfflineRecharge: agentrechargeApp.NewApprovalDecisionHandler(runtime.db, walletPosting, runtime.workerResult.Services.RechargeAudit),
constants.ApprovalBusinessTypeRefund: refundService,
},
owner,
@@ -381,6 +389,10 @@ func registerWeComApprovalOutboxConsumer(runtime *workerRuntime, cfg *config.Con
// registerWeComApprovalTasks 注册企微权威详情同步和主动恢复任务。
func registerWeComApprovalTasks(mux *asynq.ServeMux, runtime *workerRuntime, cfg *config.Config, appLogger *zap.Logger) {
auditWriter, ok := runtime.workerResult.Services.PaymentAudit.(*auditInfra.Writer)
if !ok || auditWriter == nil {
appLogger.Fatal("通用审批统一审计 Writer 未配置")
}
applicationRepository := wecomInfra.NewApplicationRepository(runtime.db)
integrationRepository := integrationlog.NewRepository(runtime.db)
tokenProvider := wecomInfra.NewTokenProvider(
@@ -392,7 +404,8 @@ func registerWeComApprovalTasks(mux *asynq.ServeMux, runtime *workerRuntime, cfg
approvalInfra.NewTerminalEventWriter(outbox.NewRepository()),
approvalInfra.NewDecisionDeliveryStore(runtime.db), nil,
)
contexts := wecomInfra.NewApprovalContextRepository(runtime.db)
decisionSync.SetAuditWriter(auditWriter)
contexts := wecomInfra.NewApprovalContextRepository(runtime.db, auditWriter)
detailHandler := wecomInfra.NewApprovalDetailTaskHandler(
wecomInfra.NewApprovalDetailClient(tokenProvider, integrationRepository, cfg.WeCom.BaseURL, cfg.WeCom.Timeout),
contexts, decisionSync, integrationRepository,
@@ -414,12 +427,14 @@ func registerAgentRechargeRecoveryTask(mux *asynq.ServeMux, runtime *workerRunti
confirm := agentrechargeApp.NewConfirmOnlinePaymentService(
runtime.db,
paymentInfra.NewAgentRechargePaymentEventWriter(outbox.NewRepository()),
runtime.workerResult.Services.PaymentAudit,
)
recovery := agentrechargeApp.NewRecoverOnlinePaymentService(
runtime.db,
paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(runtime.redisClient), integration, appLogger),
paymentInfra.NewAlipayWapAdapter(integration, appLogger),
confirm,
runtime.workerResult.Services.PaymentAudit,
)
handler := paymentInfra.NewAgentRechargeRecoveryTaskHandler(recovery)
mux.HandleFunc(constants.TaskTypeAgentRechargeRecovery, handler.Handle)
@@ -468,8 +483,8 @@ func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *za
// registerWalletOutboxConsumer 注册代理主钱包资金事实消费者。
func registerWalletOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) {
agentRechargePosting := walletApp.NewPostingService(walletInfra.NewCreditEventWriter(outbox.NewRepository()), nil)
agentRechargeConsumer := paymentInfra.NewAgentRechargePaymentConsumer(runtime.db, agentRechargePosting)
agentRechargePosting := walletApp.NewPostingService(walletInfra.NewCreditEventWriter(outbox.NewRepository(), auditInfra.NewWriter(auditInfra.NewRegistry(), nil)), nil)
agentRechargeConsumer := paymentInfra.NewAgentRechargePaymentConsumer(runtime.db, agentRechargePosting, runtime.workerResult.Services.RechargeAudit)
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAgentRechargePaymentConfirmed, agentRechargeConsumer); err != nil {
appLogger.Fatal("注册代理在线充值入账 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypeAgentRechargePaymentConfirmed), zap.Error(err))
@@ -502,7 +517,7 @@ func registerNotificationOutboxConsumer(runtime *workerRuntime, appLogger *zap.L
registry := notificationInfra.NewRegistry()
shopRecipientResolver := shopInfra.NewRecipientResolver(runtime.db)
dynamicRecipientResolver := notificationInfra.NewDynamicRecipientResolver(runtime.db, shopRecipientResolver)
consumer := notificationApp.NewDeliveryService(repository, registry, dynamicRecipientResolver, appLogger)
consumer := notificationApp.NewDeliveryService(repository, registry, dynamicRecipientResolver, appLogger, auditInfra.NewWriter(auditInfra.NewRegistry(), nil))
if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAdminDirectNotification, consumer); err != nil {
appLogger.Fatal("注册站内通知 Outbox 消费者失败",
zap.String("event_type", constants.OutboxEventTypeAdminDirectNotification), zap.Error(err))
@@ -629,6 +644,7 @@ func startPollingScheduler(
)
trafficSyncer.SetRedisClient(runtime.redisClient)
trafficSyncer.SetCardObservationService(runtime.workerResult.Services.CardObservation)
trafficSyncer.SetSpeedTierIntegrationLog(integrationlog.NewRepository(runtime.db))
activationHandler.SetTrafficSyncer(trafficSyncer)
pollingScheduler := polling.NewScheduler(
@@ -671,7 +687,7 @@ func startAsynqScheduler(cfg *config.Config, redisAddr string, appLogger *zap.Lo
}
}()
appLogger.Info("Asynq Scheduler 已启动(企微审批恢复: 每 2 分钟,套餐临期提醒: 上海时区每日 03:00")
appLogger.Info("Asynq Scheduler 已启动(企微审批恢复: 每 2 分钟,套餐临期提醒: 上海时区每日 03:00Audit 归档: 每日 04:00Integration 归档: 每日 04:30/月初 05:00月度留存清理: 月初 06:00")
return asynqScheduler
}
@@ -746,9 +762,80 @@ func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
); err != nil {
return fmt.Errorf("注册每日流量落盘定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 4 * * *", asynq.NewTask(
constants.TaskTypeAuditDailyArchive,
nil,
asynq.MaxRetry(10),
asynq.Timeout(2*time.Hour),
asynq.Unique(23*time.Hour),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAuditDailyArchive)),
)); err != nil {
return fmt.Errorf("注册统一审计每日冷归档定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 30 4 * * *", asynq.NewTask(
constants.TaskTypeIntegrationDailyArchive,
nil,
asynq.MaxRetry(10),
asynq.Timeout(2*time.Hour),
asynq.Unique(23*time.Hour),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeIntegrationDailyArchive)),
)); err != nil {
return fmt.Errorf("注册 Integration Log 每日冷归档定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 5 1 * *", asynq.NewTask(
constants.TaskTypeIntegrationMonthlyFinalize,
nil,
asynq.MaxRetry(10),
asynq.Timeout(6*time.Hour),
asynq.Unique(27*24*time.Hour),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeIntegrationMonthlyFinalize)),
)); err != nil {
return fmt.Errorf("注册 Integration Log 月度最终版本复核任务失败: %w", err)
}
if _, err := asynqScheduler.Register("CRON_TZ=Asia/Shanghai 0 6 1 * *", asynq.NewTask(
constants.TaskTypeAuditMonthlyRetention,
nil,
asynq.MaxRetry(10),
asynq.Timeout(12*time.Hour),
asynq.Unique(27*24*time.Hour),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAuditMonthlyRetention)),
)); err != nil {
return fmt.Errorf("注册月度日志留存清理任务失败: %w", err)
}
return nil
}
// registerAuditArchiveTask 注册 Audit 与 Integration 冷归档任务处理器。
func registerAuditArchiveTask(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) {
if runtime.storageSvc == nil {
appLogger.Warn("对象存储未配置,审计归档任务将在执行时重试")
mux.HandleFunc(constants.TaskTypeAuditDailyArchive, task.NewAuditDailyArchiveHandler(nil, appLogger).Handle)
integrationHandler := task.NewIntegrationArchiveHandler(nil, appLogger)
mux.HandleFunc(constants.TaskTypeIntegrationDailyArchive, integrationHandler.HandleDaily)
mux.HandleFunc(constants.TaskTypeIntegrationMonthlyFinalize, integrationHandler.HandleMonthlyFinalize)
mux.HandleFunc(constants.TaskTypeAuditMonthlyRetention, task.NewAuditMonthlyRetentionHandler(nil, appLogger).Handle)
return
}
auditWriter, ok := runtime.workerResult.Services.PaymentAudit.(*auditInfra.Writer)
if !ok || auditWriter == nil {
appLogger.Fatal("初始化月度日志留存清理失败:统一审计 Writer 未配置")
}
service, err := auditArchiveApp.NewService(runtime.db, runtime.storageSvc.Provider(), constants.AuditArchiveInstanceID, auditWriter)
if err != nil {
appLogger.Fatal("初始化统一审计归档服务失败", zap.Error(err))
}
mux.HandleFunc(constants.TaskTypeAuditDailyArchive, task.NewAuditDailyArchiveHandler(service, appLogger).Handle)
integrationHandler := task.NewIntegrationArchiveHandler(service, appLogger)
mux.HandleFunc(constants.TaskTypeIntegrationDailyArchive, integrationHandler.HandleDaily)
mux.HandleFunc(constants.TaskTypeIntegrationMonthlyFinalize, integrationHandler.HandleMonthlyFinalize)
mux.HandleFunc(constants.TaskTypeAuditMonthlyRetention, task.NewAuditMonthlyRetentionHandler(service, appLogger).Handle)
appLogger.Info("注册审计归档任务处理器",
zap.String("audit_task_type", constants.TaskTypeAuditDailyArchive),
zap.String("integration_daily_task_type", constants.TaskTypeIntegrationDailyArchive),
zap.String("integration_monthly_task_type", constants.TaskTypeIntegrationMonthlyFinalize),
zap.String("retention_task_type", constants.TaskTypeAuditMonthlyRetention))
}
// createTaskHandler 创建并返回包含全部任务处理器的 Asynq Handler。
func createTaskHandler(runtime *workerRuntime, appLogger *zap.Logger) *queue.Handler {
stopResumeSvc, _ := runtime.workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface)

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,30 @@
# 主体资源活动接口
代理和企业使用独立安全投影,不得调用 `/api/admin/audit/*`,也不复用平台审计 DTO。
## 接口
- 代理:`GET /api/admin/agent/resource-activities/{resource_type}/{identifier}`
- 企业:`GET /api/admin/enterprise/resource-activities/{resource_type}/{identifier}`
- 查询参数:`page` 默认 1`page_size` 默认 20、最大 100。
响应资源摘要仅包含 `resource_type/resource_id/resource_key/display_name`;活动项仅包含动作编码及中文名、`subject_summary`、Registry 白名单约束的 `subject_data`、结果、发生时间和当前主体仍有权查看的关联资源摘要。
平台操作者、内部原因和备注、风险、内部 `before/after`、Audit Event ID、Integration Log 内容及系统安全凭据均不返回。`internal_only` 事件不会产生占位记录或数量提示。
## 页面字段映射
| 主体与前置接口 | `response.data` 字段 | 目标调用 | 降级行为 |
|---|---|---|---|
| 代理卡资产详情 `GET /api/admin/assets/resolve/{identifier}` | `asset_type=card``iccid` | `/agent/resource-activities/iot_card/{iccid}` | ICCID 为空时隐藏入口 |
| 代理设备资产详情 | `asset_type=device``virtual_no` | `/agent/resource-activities/device/{virtual_no}` | VirtualNo 为空时隐藏入口 |
| 代理分配记录 | `allocation_no` | `/agent/resource-activities/asset_allocation_record/{allocation_no}` | 后端独立复核记录关联店铺和当前资产归属 |
| 代理换货单 | `exchange_no` | `/agent/resource-activities/exchange_order/{exchange_no}` | 后端按换货单当前所属店铺复核 |
| 代理店铺 | `shop_code` | `/agent/resource-activities/shop/{shop_code}` | 仅自己及下级店铺 |
| 代理归属企业 | `enterprise_code` | `/agent/resource-activities/enterprise/{enterprise_code}` | 仅 owner shop 在代理范围内 |
| 企业卡列表 `GET /api/admin/enterprises/{id}/cards` | `items[].iccid` | `/enterprise/resource-activities/iot_card/{iccid}` | 路由企业 ID 不作为授权证明 |
| 企业设备列表 `GET /api/admin/enterprises/{id}/devices` | `items[].virtual_no` | `/enterprise/resource-activities/device/{virtual_no}` | 授权撤销或字段为空时隐藏入口 |
企业不能调用统一资产 resolve 接口,因此不从该页面构造活动入口。所有 shop ID、enterprise ID 和授权范围都来自认证上下文;调用方不能通过 query、path 或 body 伪造。资源不存在、不支持、越权或授权已撤销统一返回“无权限操作该资源或资源不存在”。
`GET /api/admin/assets/{identifier}/operation-logs` 仅保留为平台切换前历史入口,不向代理或企业开放,也不把旧记录拼接到新活动接口。

View File

@@ -0,0 +1,26 @@
# 外部集成调查接口
本文冻结 `build-multi-view-audit-center` 任务 3.4 的只读接口与跳转契约。
## 接口
| 视角 | 接口 | 参数来源 |
|---|---|---|
| 总览 | `GET /api/admin/audit/integrations/overview` | 调查筛选区或关联视角携带的稳定筛选值;必须提供 RFC3339 时间范围 |
| 列表 | `GET /api/admin/audit/integrations` | 调查筛选区、通知目标或关联调查节点;默认每页 20最大 100 |
| 详情 | `GET /api/admin/audit/integrations/{integration_id}` | 列表返回的 `integration_id`、通知目标 `target_key` 或调查节点稳定引用 |
认证身份只来自认证上下文。接口仅允许超级管理员和平台账号访问,全部为 GET不提供重试、补偿、结果确认、外部单号绑定、人工恢复、修改、删除或导出能力。
## 通知跳转
点击通知时先调用 `GET /api/admin/notifications/{id}/target`。仅当响应同时满足 `available=true``target_type=integration_log``target_key` 非空时,前端才将 `target_key` 原样作为 `integration_id` 打开详情;目标不可用时只展示通知正文,不解析通知列表的 `ref_type/ref_id/ref_key` 猜测目标。
## 调查与降级规则
- provider、direction、operation、result 等筛选使用后端稳定编码,时间范围最长 31 天。
- 详情使用稳定 `integration_id`,不接受数据库自增 ID。
- attempts 只按相同非空 `trigger_series` 组织;`correlation_id` 只表示业务链路,不代表技术重试。
- 缺少稳定 ID 时隐藏跳转,不按相似资源、相近时间、中文描述或编号前缀猜测。
- 请求摘要、响应摘要和 metadata 返回前再次删除安全凭据;第三方原始错误正文不直接展示。
- 第一阶段不增加 JSONB GIN 或任意全文搜索。

View File

@@ -0,0 +1,63 @@
# 平台基础审计调查接口
本文冻结 `build-multi-view-audit-center` 任务 2.4 的接口、业务页面参数来源和降级规则。字段路径均位于统一响应的 `response.data` 下;列表字段位于 `items[]`
## 基础接口
| 视角 | 接口 | 参数来源 |
|---|---|---|
| 全局事件 | `GET /api/admin/audit/events` | 调查筛选区或其他节点携带的稳定筛选值 |
| 事件详情 | `GET /api/admin/audit/events/{event_id}` | `investigation_refs.event_id` 或人工粘贴的稳定 ID |
| 操作者 | `GET /api/admin/audit/actors/{kind}/{id}/events` | `investigation_refs.actor_ref` 或平台账号选择器 |
| 资源搜索 | `GET /api/admin/audit/resources/search` | 调查人员选择类型并输入精确业务标识 |
| 资源时间线 | `GET /api/admin/audit/resources/{resource_type}/{resource_id}/timeline` | 业务响应稳定 ID、资源搜索结果或 `investigation_refs.resource_refs[]` |
| 请求链路 | `GET /api/admin/audit/requests/{request_id}/timeline` | 节点 `request_id` 或开发人员从 Access Log 粘贴 |
| 业务链路 | `GET /api/admin/audit/correlations/{correlation_id}/timeline` | 节点或业务详情中的稳定 `correlation_id` |
| 资金时间线 | `GET /api/admin/audit/finance/timeline` | 店铺、钱包、订单、支付、退款、充值、审批、交易号、操作者、时间或 correlation |
| 风险总览/明细 | `GET /api/admin/audit/risks/overview``GET /api/admin/audit/risks/events` | 调查时间范围及风险分桶稳定筛选 |
认证身份和数据范围只来自认证上下文,不接受 query/path/body 伪造。接口仅允许超级管理员和平台账号访问,全部为 GET不提供导出、修改、删除、恢复或处置能力。
## 资产与组织页面映射
| 前置接口 | 稳定字段 | 平台目标 |
|---|---|---|
| 卡列表 `/iot-cards/standalone` | `id` | `iot_card/{id}` |
| 设备列表 `/devices` | `id` | `device/{id}` |
| 设备卡槽 `/devices/{virtual_no}/cards` | `bindings[].iot_card_id``bindings[].id` | `iot_card/{iot_card_id}``device_sim_binding/{id}` |
| 统一资产 `/assets/resolve/{identifier}` | `asset_type``asset_id`、绑定资产 ID | `card` 转换为 `iot_card/{asset_id}``device` 使用 `device/{asset_id}` |
| 分配列表/详情 `/asset-allocation-records[/{id}]` | `id``asset_type``asset_id``related_device_id``related_card_ids[]` | `asset_allocation_record/{id}` 及相应资产资源 ID接口已返回的 `iot_card/device` 不再转换 |
| 换货列表/详情 `/exchanges[/{id}]` | `id`、旧/新 `asset_type``asset_id` | `exchange_order/{id}` 及旧、新资产资源 ID |
| 店铺列表/详情 `/shops[/{id}]` | `id` | `shop/{id}` |
| 企业列表 `/enterprises` | `id` | `enterprise/{id}`;不得假设存在企业详情接口 |
| 企业卡列表 `/enterprises/{id}/cards` | `items[].id` | `iot_card/{id}` |
| 企业设备列表 `/enterprises/{id}/devices` | `items[].device_id` | `device/{device_id}` |
## 交易与资金页面映射
| 前置接口 | 稳定字段 | 平台目标 |
|---|---|---|
| 账号列表/详情 `/accounts[/{id}]` | `id` | `account/{id}` |
| 订单列表/详情 `/orders[/{id}]` | `id` | `order/{id}`;资金视角使用 `order_id={id}` |
| 退款列表/详情 `/refunds[/{id}]` | `id``approval_instance_id` | `refund/{id}`;资金视角使用 `refund_id={id}`;审批非空时使用 `approval_instance/{id}` |
| 代理充值列表/详情 `/agent-recharges[/{id}]` | `id` | `agent_recharge/{id}`;资金视角使用 `recharge_id={id}` |
| 代理在线充值创建结果 | `recharge_id``payment_no` | 资金视角以 `recharge_id` 为第一跳,`payment_no` 仅作额外精确筛选 |
| 资产钱包 `/assets/{identifier}/wallet` | `wallet_id``resource_type``resource_id` | 资金视角使用 `wallet_id`;资产审计使用 `{resource_type}/{resource_id}` |
| 店铺资金概况 `/shops/fund-summary` | `items[].shop_id` | 资金视角使用 `shop_id` |
| 店铺主钱包流水 | path `shop_id`、资产类型与 ID | 资金视角使用 `shop_id`;资产 ID 存在时使用对应资源时间线 |
| 资产钱包流水 | 上层 `wallet_id` | 资金视角使用 `wallet_id`;不解析业务编号前缀猜测资源 |
资金视角已交付;缺失的支付、退款、钱包等关联由服务端 Query 解析。完整逐页面映射和调用链见[跨视角调查与前端导航契约](跨视角调查与前端导航契约.md)。
## 统一调查引用
平台调查节点统一返回 `investigation_refs`:可空 `event_id`、可空 `actor_ref{kind,id}``resource_refs[]{resource_type,resource_id,resource_key,display_name}`、可空 `request_id`、可空 `correlation_id``integration_refs[]{integration_id}`。前端只使用存在的稳定引用,不解析中文名称、备注、编号前缀或相近时间推断关系。
## 降级规则
- 缺少目标接口必需的稳定 ID 或 identifier 时隐藏入口。
- 只有 Registry Key、没有内部 ID 时先精确资源搜索;零命中或多命中均不自动选择。
- 已删除资源只要节点保留 `resource_type/resource_id`,仍可查看事件快照时间线。
- 代理或企业不得回退调用平台审计、资源搜索或旧 operation log。
- 旧 operation log 保持独立历史入口,不拼接到新审计接口。
- 通知先调用 `/notifications/{id}/target`;仅 `available=true` 时按白名单目标继续跳转。

View File

@@ -0,0 +1,113 @@
# 跨视角调查与前端导航契约
本文对应 `build-multi-view-audit-center` 任务 9.5,冻结 request、correlation、资金和风险调查接口以及现有业务页面进入审计中心的第一跳。所有字段路径均位于统一响应的 `response.data` 下;列表字段位于 `items[]`。本文只定义前端调用契约,不包含前端页面实现。
## 跨视角只读接口
| 视角 | 接口 | 入参来源 | 响应重点 |
|---|---|---|---|
| 请求链路 | `GET /api/admin/audit/requests/{request_id}/timeline` | 审计或 Integration 节点的 `investigation_refs.request_id`,或开发人员从 Access Log 粘贴 | `request_id``access_log_lookup_request_id``nodes[]` |
| 业务链路 | `GET /api/admin/audit/correlations/{correlation_id}/timeline` | 审计、Integration、Outbox、任务或业务详情中的稳定 correlation | `correlation_id``nodes[]` |
| 资金时间线 | `GET /api/admin/audit/finance/timeline` | 业务页面稳定 ID、调查节点引用或调查人员输入 | 分页 `items[]`、事实来源、金额权威、`investigation_refs` |
| 风险总览 | `GET /api/admin/audit/risks/overview` | 调查人员选择的 RFC3339 时间范围及可选筛选,最长 31 天 | 信号、风险、结果、动作、来源和趋势 |
| 风险明细 | `GET /api/admin/audit/risks/events` | 风险总览分桶携带相同筛选,或调查人员输入 | 分页风险事件及 `investigation_refs` |
以上接口仅允许超级管理员和平台账号访问,全部为 GET。认证身份只来自认证上下文不提供导出、修改、删除、风险处置、自动封禁、重试、补偿或恢复能力。
## 资产和组织页面逐行导航
| 源页面 | 前置接口 | `response.data` 稳定字段 | 入口名称与可见条件 | 目标接口与参数映射 | 降级行为 |
|---|---|---|---|---|---|
| 卡列表 | `GET /api/admin/iot-cards/standalone` | `items[].id/iccid/virtual_no/shop_id/device_virtual_no/authorized_enterprise_id` | 平台显示“审计记录”,要求 `id` 非零;代理显示“活动记录”,要求 `iccid` 非空;企业不从此列表进入 | 平台:`/audit/resources/iot_card/{id}/timeline`;代理:`/agent/resource-activities/iot_card/{iccid}` | 缺少对应 ID/ICCID 时隐藏;企业改从企业卡列表进入 |
| 设备列表 | `GET /api/admin/devices` | `items[].id/virtual_no/imei/sn/shop_id/bound_card_count/authorized_enterprise_id` | 平台要求 `id`;代理要求 `virtual_no`;企业不从此列表进入 | 平台:`/audit/resources/device/{id}/timeline`;代理:`/agent/resource-activities/device/{virtual_no}` | 缺少字段时隐藏;企业改从企业设备列表进入 |
| 设备卡槽 | `GET /api/admin/devices/{virtual_no}/cards` | `bindings[].id/iot_card_id/iccid/slot_position/is_current` | 平台可分别查看卡和绑定审计,要求相应 ID代理可查看卡活动要求 ICCID企业仅在设备和卡均有效授权时显示卡活动 | 平台卡:`iot_card/{iot_card_id}`;平台绑定:`device_sim_binding/{bindings[].id}`;代理/企业卡:`iot_card/{iccid}` 的主体活动接口 | 不用绑定 ID 证明授权;设备时间线继续使用上层设备 ID/VirtualNo |
| 统一资产详情 | `GET /api/admin/assets/resolve/{identifier}` | `asset_type/asset_id/identifier/virtual_no/iccid/bound_device_id/cards[].card_id/exchange_trace[].asset_id/can_view` | 平台在 `asset_id` 存在时显示“审计记录”;代理在卡 ICCID 或设备 VirtualNo 存在时显示“活动记录”;企业不显示 | 平台:`card→iot_card/{asset_id}``device→device/{asset_id}`;代理:卡用 `iot_card/{iccid}`、设备用 `device/{virtual_no}` | 缺稳定字段时隐藏;换货轨迹仅 `can_view=true` 且资产 ID 存在时跳转;企业不得回退调用 resolve |
| 资产分配列表/详情 | `GET /api/admin/asset-allocation-records[/{id}]` | `items[].id/allocation_no/asset_type/asset_id/asset_identifier/from_owner_type/from_owner_id/to_owner_type/to_owner_id/related_device_id`;详情另有 `related_card_ids[]` | 平台要求记录或资产 ID代理要求 `allocation_no` 且当前关联资产或店铺仍在范围内;企业无独立入口 | 平台:`asset_allocation_record/{id}``{asset_type}/{asset_id}`;代理:`/agent/resource-activities/asset_allocation_record/{allocation_no}` | 后端独立复核分配归属;不能因可打开旧详情就视为有权;企业从已授权资产活动查看结论 |
| 换货列表/详情 | `GET /api/admin/exchanges[/{id}]` | `items[].id/exchange_no/old_asset_type/old_asset_id/new_asset_type/new_asset_id/shop_id/submitter_id` | 平台要求换货或资产 ID代理要求 `exchange_no` 且换货店铺仍在范围内;企业无独立入口 | 平台:`exchange_order/{id}`,旧新资产分别使用响应类型和 ID代理`/agent/resource-activities/exchange_order/{exchange_no}` | 旧新资产仅在各自仍可管理时开放;企业从有效授权资产活动查看结论 |
| 店铺列表/详情 | `GET /api/admin/shops[/{id}]` | `items[].id/shop_name/shop_code/parent_id/business_owner_account_id` 或详情同名字段 | 平台要求 `id`;代理要求 `shop_code` 且为自己或下级店铺;企业不显示 | 平台:`/audit/resources/shop/{id}/timeline`;代理:`/agent/resource-activities/shop/{shop_code}` | 缺字段或越权时隐藏/显示不可用,不搜索平台审计 |
| 企业列表 | `GET /api/admin/enterprises` | `items[].id/enterprise_name/enterprise_code/owner_shop_id` | 平台要求 `id`;代理要求 `enterprise_code` 且 owner shop 在范围内;企业自身不显示 | 平台:`enterprise/{id}`;代理:`/agent/resource-activities/enterprise/{enterprise_code}` | 当前没有企业详情接口,前端不得假设存在;企业 ID 不作为主体活动路径参数 |
| 企业卡列表 | `GET /api/admin/enterprises/{id}/cards` | `items[].id/iccid/virtual_no/device_id` | 平台要求 `id`;企业要求 `iccid` 且当前授权有效;代理不从此列表进入 | 平台:`iot_card/{id}`;企业:`/enterprise/resource-activities/iot_card/{iccid}` | 路由中的企业 ID 不作为授权证明;后端始终使用认证上下文复核 |
| 企业设备列表 | `GET /api/admin/enterprises/{id}/devices` | `items[].device_id/virtual_no` | 平台要求 `device_id`;企业要求 `virtual_no` 且当前授权有效;代理不从此列表进入 | 平台:`device/{device_id}`;企业:`/enterprise/resource-activities/device/{virtual_no}` | 字段为空或授权撤销时隐藏/显示活动不可用,不回退平台接口 |
## 账号、交易和资金页面逐行导航
| 源页面 | 前置接口 | `response.data` 稳定字段 | 入口名称与可见条件 | 目标接口与参数映射 | 降级行为 |
|---|---|---|---|---|---|
| 账号列表/详情 | `GET /api/admin/accounts[/{id}]` | 列表 `items[].id`;详情 `id` | 平台在 ID 非零时显示“审计记录” | `/audit/resources/account/{id}/timeline` | 缺 ID 时隐藏,不按用户名搜索 |
| 店铺列表/详情 | `GET /api/admin/shops[/{id}]` | 列表 `items[].id`;详情 `id` | ID 非零时显示“审计记录”和“资金链路” | 审计:`shop/{id}`;资金:`/audit/finance/timeline?shop_id={id}` | 缺 ID 时两个入口均隐藏 |
| 企业列表 | `GET /api/admin/enterprises` | `items[].id` | ID 非零时显示“审计记录” | `/audit/resources/enterprise/{id}/timeline` | 不假设存在企业详情接口 |
| 订单列表/详情 | `GET /api/admin/orders[/{id}]` | `items[].id/order_no` 或详情 `id/order_no` | ID 非零时显示“审计记录”和“资金链路” | 审计:`order/{id}`;资金:`/audit/finance/timeline?order_id={id}` | 不要求前端补 payment/refund ID缺 ID 时隐藏 |
| 退款列表/详情 | `GET /api/admin/refunds[/{id}]` | `items[].id/refund_no/order_id/approval_instance_id` 或详情同名字段 | ID 非零时显示审计和资金入口;审批 ID 非零时显示“审批审计” | 审计:`refund/{id}`;资金:`finance/timeline?refund_id={id}`;审批:`approval_instance/{approval_instance_id}` | 缺审批 ID 只隐藏审批入口,不解析退款编号猜测 |
| 代理充值列表/详情 | `GET /api/admin/agent-recharges[/{id}]` | `items[].id/recharge_no/shop_id/agent_wallet_id/approval_instance_id` 或详情同名字段 | ID 非零时显示审计和资金入口;审批 ID 非零时显示审批审计 | 审计:`agent_recharge/{id}`;资金:`finance/timeline?recharge_id={id}` | 缺 `payment_no` 由服务端关联,不要求前端补猜 |
| 代理在线充值结果 | `POST /api/admin/agent-recharges` | `recharge_id/recharge_no/payment_no` | `recharge_id` 非零时显示“资金链路” | `finance/timeline?recharge_id={recharge_id}`;可附加 `payment_no` 精确筛选 | `payment_no` 不直接构造 Integration 详情;缺 recharge ID 时隐藏 |
| 资产钱包 | `GET /api/admin/assets/{identifier}/wallet` | `wallet_id/resource_type/resource_id` | `wallet_id` 非零时显示“资金链路”;资源类型和 ID 齐全时显示“资产审计” | 资金:`finance/timeline?wallet_id={wallet_id}`;审计:`resources/{resource_type}/{resource_id}/timeline` | 两个入口独立判断;缺某组字段只隐藏对应入口 |
| 店铺资金概况 | `GET /api/admin/shops/fund-summary` | `items[].shop_id` | `shop_id` 非零时显示行内“资金链路” | `finance/timeline?shop_id={shop_id}` | 不要求该接口未返回的 agent wallet ID |
| 店铺主钱包流水 | `GET /api/admin/shops/{shop_id}/main-wallet/transactions` | path `shop_id``items[].id/asset_type/asset_id/asset_identifier` | 始终可按合法 path 显示资金入口;资产类型和 ID 齐全时显示资产审计 | 资金:`finance/timeline?shop_id={shop_id}`;审计:`resources/{asset_type}/{asset_id}/timeline` | 缺资产 ID 仍保留店铺资金入口,不按资产编号猜测 |
| 资产钱包流水 | `GET /api/admin/assets/{identifier}/wallet/transactions` | 上层钱包接口 `wallet_id``items[].id/reference_type/reference_no` | 上层 `wallet_id` 非零时显示“资金链路” | `finance/timeline?wallet_id={wallet_id}` | `reference_type/reference_no` 仅展示;需后端节点明确返回资源引用后才能继续跳转 |
## 调查节点逐行跳转
| `investigation_refs` 字段 | 入口名称 | 可见条件 | 目标接口与参数 | 降级行为 |
|---|---|---|---|---|
| `event_id` | “事件详情” | 非空 | `GET /api/admin/audit/events/{event_id}` | 空值隐藏 |
| `actor_ref.kind/id` | “查看操作者行为” | kind 和 id 均非空 | `GET /api/admin/audit/actors/{kind}/{id}/events` | 任一缺失即隐藏,不用当前账号资料补齐 |
| `resource_refs[]` | “查看资源审计” | `resource_type/resource_id` 均非空 | `GET /api/admin/audit/resources/{resource_type}/{resource_id}/timeline` | 只有 Key 时先精确搜索;零或多命中不自动选择 |
| `request_id` | “查看请求链路” | 非空 | `GET /api/admin/audit/requests/{request_id}/timeline` | 空值隐藏,不扫描 Access Log 猜测 |
| `correlation_id` | “查看业务链路” | 非空 | `GET /api/admin/audit/correlations/{correlation_id}/timeline` | 空值隐藏,不按相近时间拼链路 |
| `integration_refs[].integration_id` | “查看外部交互” | 非空 | `GET /api/admin/audit/integrations/{integration_id}` | 空值隐藏,不使用数据库主键或相似资源猜测 |
`actor_ref.kind` 第一阶段只使用 `account/openapi/system_task/scheduled_job/external_system`。代理和企业活动响应不得包含 `investigation_refs`
## 六条完整调用链
### 资产详情
1. 调用 `GET /api/admin/assets/resolve/{identifier}`
2. 平台读取 `data.asset_type/asset_id`:卡将 `card` 转为 `iot_card`,调用 `GET /api/admin/audit/resources/iot_card/{asset_id}/timeline?page=1&page_size=20`;设备调用 `.../device/{asset_id}/timeline`
3. 代理读取 `data.iccid``data.virtual_no`,调用对应 `/agent/resource-activities/...`
4. 字段缺失时隐藏入口;企业不调用 resolve也不回退平台接口。
### 订单
1. 调用 `GET /api/admin/orders/{id}`,读取 `data.id`
2. “审计记录”调用 `GET /api/admin/audit/resources/order/{id}/timeline`
3. “资金链路”调用 `GET /api/admin/audit/finance/timeline?order_id={id}&page=1&page_size=20`
4. Payment、Refund、钱包等关联由服务端解析前端不补猜。
### 退款
1. 调用 `GET /api/admin/refunds/{id}`,读取 `data.id` 和可选 `data.approval_instance_id`
2. 审计调用 `resources/refund/{id}/timeline`,资金调用 `finance/timeline?refund_id={id}`
3. 审批实例 ID 非零时再调用 `resources/approval_instance/{approval_instance_id}/timeline`
4. 审批字段缺失只隐藏审批入口,不影响退款审计与资金链路。
### 钱包
1. 调用 `GET /api/admin/assets/{identifier}/wallet`,读取 `data.wallet_id/resource_type/resource_id`
2. “资金链路”调用 `finance/timeline?wallet_id={wallet_id}`
3. “资产审计”调用 `resources/{resource_type}/{resource_id}/timeline`
4. 两组稳定字段分别判断,不使用交易备注或 reference 编号前缀推断资源。
### 通知
1. 点击通知后先调用 `GET /api/admin/notifications/{id}/target`
2. 仅当 `data.available=true` 时展示跳转。
3. `data.target_type=integration_log``data.target_key` 非空时,将 target key 原样作为 `integration_id` 调用 `GET /api/admin/audit/integrations/{target_key}`
4. 其他 target type 先进入对应业务详情,再按本文业务页面矩阵进入审计;不可用时只展示通知正文。
### 风险节点
1. 调用 `GET /api/admin/audit/risks/overview?created_from={from}&created_to={to}`
2. 点击风险、结果、动作或来源分桶时,将相同时间范围和对应稳定编码带入 `GET /api/admin/audit/risks/events`
3. 从明细 `items[].investigation_refs` 直接进入事件、操作者、资源、request、correlation 或 Integration 视角。
4. 缺少的引用入口隐藏;风险中心不提供处置、封禁或恢复按钮。
## 统一降级与事实边界
- 缺少目标接口必需的稳定 ID 或 identifier 时隐藏入口,不按名称、中文描述、时间或编号前缀猜测。
- 平台只有 Registry Key 时先调用精确资源搜索;零命中或多命中停留在搜索结果。
- 已删除资源只要调查节点仍有稳定资源类型和 ID就可查看事件快照时间线。
- 代理或企业遇到越权、授权撤销或资源不存在时统一显示“活动不可用”,不回退平台调查、资源搜索或旧 operation log。
- request/correlation 时间线的 `record_source` 保留 Audit Event、Integration Log、Outbox、Asynq 摘要和 Domain Ledger 引用的事实边界Outbox 投递成功不等于业务成功。
- 资金金额和余额以钱包流水及对应业务表为权威Audit Event 仅用于解释谁做了什么,不用于资金重算。
- 旧 operation log 仅由平台独立历史入口访问,不拼接到新 `/api/admin/audit/*`

View File

@@ -0,0 +1,139 @@
# 七月迭代套餐接续恢复
## 故障基线
生产只读诊断结果为 `scanned_count=100``skipped_as_occupied=100``waiting_realname=0`。旧扫描先截取全库前 100 条待生效套餐,再逐条排除仍有占位套餐的载体,导致窗口之外的真实孤儿永久饥饿。
## 候选查询
以下 SQL 只读取真实孤儿载体的稳定队首,`EXPLAIN` 版本仅在可访问目标数据库时执行:
```sql
EXPLAIN (ANALYZE, BUFFERS)
WITH pending_queue AS (
SELECT pending.id,
pending.priority,
pending.created_at,
ROW_NUMBER() OVER (
PARTITION BY
CASE WHEN COALESCE(pending.iot_card_id, 0) > 0 THEN 'iot_card' ELSE 'device' END,
CASE WHEN COALESCE(pending.iot_card_id, 0) > 0 THEN pending.iot_card_id ELSE pending.device_id END
ORDER BY pending.priority ASC, pending.created_at ASC, pending.id ASC
) AS queue_position
FROM tb_package_usage AS pending
WHERE pending.status = 0
AND pending.master_usage_id IS NULL
AND pending.deleted_at IS NULL
AND (COALESCE(pending.iot_card_id, 0) > 0 OR COALESCE(pending.device_id, 0) > 0)
AND NOT EXISTS (
SELECT 1
FROM tb_package_usage AS occupied
WHERE occupied.status IN (1, 2)
AND occupied.master_usage_id IS NULL
AND occupied.deleted_at IS NULL
AND (
(COALESCE(pending.iot_card_id, 0) > 0 AND occupied.iot_card_id = pending.iot_card_id)
OR (COALESCE(pending.iot_card_id, 0) = 0 AND pending.device_id > 0 AND occupied.device_id = pending.device_id)
)
)
)
SELECT usage.*
FROM pending_queue AS candidate
JOIN tb_package_usage AS usage ON usage.id = candidate.id
WHERE candidate.queue_position = 1
ORDER BY candidate.priority ASC, candidate.created_at ASC, candidate.id ASC
LIMIT 100;
```
去掉首行 `EXPLAIN (ANALYZE, BUFFERS)` 可查看实际候选。没有查询计划证据前不新增索引。
## 实现边界
- 旧主套餐及关联加油包先在原事务提交,随后调用 `ActivateNextPendingMainPackage`
- 孤儿扫描对每个真实孤儿直接调用同一应用能力,不投递排队激活任务。
- 应用能力在新事务内重新检查占位状态、队首与实名条件,并使用载体级 Redis 锁收敛并发。
- 激活状态与稳定的 `card.observation.series.requested` Outbox 在同一事务提交;事件 ID 为 `card-observation:package-usage:{usage_id}:activated`
- Audit Event、Domain Ledger 新增项和 Integration Log 均为 N/A`tb_package_usage` 继续作为套餐状态权威事实。
## 部署前后只读核验
记录真实孤儿数量和最老等待时间:
```sql
WITH pending_queue AS (
SELECT pending.id,
pending.created_at,
ROW_NUMBER() OVER (
PARTITION BY
CASE WHEN COALESCE(pending.iot_card_id, 0) > 0 THEN 'iot_card' ELSE 'device' END,
CASE WHEN COALESCE(pending.iot_card_id, 0) > 0 THEN pending.iot_card_id ELSE pending.device_id END
ORDER BY pending.priority ASC, pending.created_at ASC, pending.id ASC
) AS queue_position
FROM tb_package_usage AS pending
WHERE pending.status = 0
AND pending.master_usage_id IS NULL
AND pending.deleted_at IS NULL
AND (COALESCE(pending.iot_card_id, 0) > 0 OR COALESCE(pending.device_id, 0) > 0)
AND NOT EXISTS (
SELECT 1
FROM tb_package_usage AS occupied
WHERE occupied.status IN (1, 2)
AND occupied.master_usage_id IS NULL
AND occupied.deleted_at IS NULL
AND (
(COALESCE(pending.iot_card_id, 0) > 0 AND occupied.iot_card_id = pending.iot_card_id)
OR (COALESCE(pending.iot_card_id, 0) = 0 AND pending.device_id > 0 AND occupied.device_id = pending.device_id)
)
)
)
SELECT COUNT(*) AS orphan_count,
MIN(created_at) AS oldest_created_at,
NOW() - MIN(created_at) AS oldest_waiting
FROM pending_queue
WHERE queue_position = 1;
```
检查同一载体是否存在多条占位主套餐:
```sql
SELECT 'iot_card' AS carrier_type, iot_card_id AS carrier_id, COUNT(*) AS occupied_count
FROM tb_package_usage
WHERE status IN (1, 2)
AND master_usage_id IS NULL
AND deleted_at IS NULL
AND COALESCE(iot_card_id, 0) > 0
GROUP BY iot_card_id
HAVING COUNT(*) > 1
UNION ALL
SELECT 'device', device_id, COUNT(*)
FROM tb_package_usage
WHERE status IN (1, 2)
AND master_usage_id IS NULL
AND deleted_at IS NULL
AND COALESCE(iot_card_id, 0) = 0
AND COALESCE(device_id, 0) > 0
GROUP BY device_id
HAVING COUNT(*) > 1;
```
检查卡观测 Outbox 待投递状态:
```sql
SELECT status, COUNT(*) AS event_count, MIN(created_at) AS oldest_created_at
FROM tb_outbox_event
WHERE event_type = 'card.observation.series.requested'
GROUP BY status
ORDER BY status;
```
部署后至少观察两个轮询周期。停止观察的条件是:真实孤儿持续收敛、重复占位查询为 0、同步接续成功日志与对应套餐 `status=1` 一致、卡观测 Outbox 正常进入已投递状态。当前环境未连接目标数据库,以上 SQL 的实际结果需在部署窗口记录。
## 回滚与人工恢复
本次没有数据库迁移。发布后如需回滚,则回退本修复代码并重新部署 Worker已经正确激活的套餐保持业务事实不执行反向 SQL。
单卡人工恢复只允许通过 `ActivateNextPendingMainPackage` 按载体类型和 ID 调用,由应用服务重新检查 `status IN (1,2)` 占位、队首、实名条件、Redis 锁及 Outbox 原子写入;禁止直接把 `tb_package_usage.status` 更新为 `1`,避免遗漏激活时间、到期时间、重置时间和卡观测 Outbox。
## 验证声明
按用户要求,本修复不新增、修改或运行自动化测试。代码验证使用格式化、差异检查、静态检查和全量构建;数据库验证仅使用上述只读 SQL。

View File

@@ -39,7 +39,7 @@ debt_amount = max(-balance, 0)
## 既有店铺实际额度调整
`PUT /api/admin/shops/{id}/credit-limit` 用于调整既有店铺主钱包的实际信用额度。按当前产品决定,后端不校验 `shop:credit-limit:manage` 或账号类型;该权限编码只供前端决定是否展示按钮,能够看到按钮的账号即可调用。请求携带钱包 `version`,更新同时约束主钱包类型、版本和调整后的总可用金额,成功后版本加一;降额或关闭信用无法覆盖当前欠款/冻结占用时保持原值。该动作不修改余额、冻结金额,也不创建金额为零的钱包流水。后端授权收紧留待未来单独实施。
`PUT /api/admin/shops/{id}/credit-limit` 用于调整既有店铺主钱包的实际信用额度。按当前产品决定,后端不校验 `shop:credit-limit:manage` 或账号类型;该权限编码只供前端决定是否展示按钮,能够看到按钮的账号即可调用。请求携带 `credit_enabled``credit_limit`;服务端读取钱包当前版本并执行乐观锁条件更新,前端不管理乐观锁。降额或关闭信用无法覆盖当前欠款/冻结占用时保持原值。该动作不修改余额、冻结金额,也不创建金额为零的钱包流水。后端授权收紧留待未来单独实施。
## 统一订单扣款

View File

@@ -0,0 +1,17 @@
# 批量套餐失效订单生成脚本功能总结
## 功能说明
新增 `scripts/batch_package_purchase/generate_invalidate_orders.py`,读取单列 ICCID CSV通过现有后台资产套餐接口查询状态为待生效、生效中或已用完的套餐按订单号去重后生成现有订单套餐批量失效功能要求的单列 CSV。
## 边界与安全
- 仅接受 19 或 20 位 ICCID并复用批量购买脚本的表头、重复值和单列校验。
- 只读取现有受权后台接口,不直连数据库,不新增依赖。
- 已过期和已失效套餐不进入结果;无订单号的套餐记录跳过。
- 任一 ICCID 查询失败时整批终止,不生成不完整的订单号文件。
- 脚本不触发套餐失效,实际状态变更继续使用已有批量失效功能。
## 输出
输出文件首行为 `order_no`,后续每行一个去重后的订单号,可直接上传到已有订单套餐批量失效功能。

View File

@@ -0,0 +1,18 @@
# 批量套餐过期时间修改脚本功能总结
## 功能说明
新增 `scripts/batch_package_purchase/batch_update_package_expiry.py`,读取单列资产标识 CSV复用现有资产套餐查询及过期时间修改接口将每个资产全部生效中套餐统一修改为指定过期时间。
## 安全控制
- 默认仅查询和预演,显式增加 `--execute` 后才会修改。
- 真实执行前先完成整批资产查询,避免查询阶段错误造成半批修改。
- 只处理套餐状态 `1`,不修改待生效、已用完、已过期或已失效套餐。
- 每条修改继续由现有接口校验资产归属,并记录 `asset_package_expires_at` 前后值审计。
- 接口权限沿用现状,仅后台账号 ID `41``127` 可调用。
- PATCH 请求不自动重试,结果逐条刷新到 CSV中断时已完成记录不会丢失。
## 输出
结果 CSV 记录资产标识、套餐使用记录 ID、套餐名称、原过期时间、目标过期时间、HTTP 状态码、业务错误码和接口消息。

View File

@@ -0,0 +1,243 @@
// Package accessaudit 定义账号权限与组织简单写用例的统一审计接缝。
package accessaudit
import (
"context"
stderrors "errors"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
// ChangeAudit 是账号权限与组织变更交给统一审计 Port 的事实。
type ChangeAudit struct {
ActionCode string
Summary string
Result string
ErrorCode string
ErrorSummary string
OperatorID uint
ActorKind string
ActorName string
Source string
ScopeType string
Account *model.Account
Accounts []AccountChange
Shop *model.Shop
ParentShop *model.Shop
Enterprise *model.Enterprise
Cards []IotCardChange
CardAuthorizations []EnterpriseCardAuthorizationChange
Devices []DeviceChange
DeviceBindings []DeviceSimBindingChange
DeviceAuthorizations []EnterpriseDeviceAuthorizationChange
PersonalCustomer *model.PersonalCustomer
PersonalPhones []PersonalCustomerPhoneChange
PersonalOpenIDs []PersonalCustomerOpenIDChange
PersonalDevices []PersonalCustomerDeviceChange
PersonalICCIDs []PersonalCustomerICCIDChange
Role *model.Role
Roles []RoleChange
Permissions []PermissionChange
BeforeData map[string]any
AfterData map[string]any
SubjectVisibility string
SubjectSummary string
SubjectData map[string]any
}
// PersonalCustomerPhoneChange 保存个人客户手机号资源变化。
type PersonalCustomerPhoneChange struct {
Phone *model.PersonalCustomerPhone
BeforeData map[string]any
AfterData map[string]any
}
// PersonalCustomerOpenIDChange 保存个人客户微信主体资源变化。
type PersonalCustomerOpenIDChange struct {
OpenID *model.PersonalCustomerOpenID
BeforeData map[string]any
AfterData map[string]any
}
// PersonalCustomerDeviceChange 保存个人客户设备号绑定资源变化。
type PersonalCustomerDeviceChange struct {
Binding *model.PersonalCustomerDevice
Relation string
Role string
BeforeData map[string]any
AfterData map[string]any
}
// PersonalCustomerICCIDChange 保存个人客户 ICCID 绑定资源变化。
type PersonalCustomerICCIDChange struct {
Binding *model.PersonalCustomerICCID
Relation string
Role string
BeforeData map[string]any
AfterData map[string]any
}
// DeviceChange 保存组织操作关联设备的资源变化与主体安全投影。
type DeviceChange struct {
Device *model.Device
Relation string
Role string
BeforeData map[string]any
AfterData map[string]any
SubjectVisibility string
SubjectSummary string
SubjectData map[string]any
}
// DeviceSimBindingChange 保存企业设备授权涉及的卡槽绑定快照。
type DeviceSimBindingChange struct {
Binding *model.DeviceSimBinding
Relation string
Role string
BeforeData map[string]any
AfterData map[string]any
}
// EnterpriseDeviceAuthorizationChange 保存企业设备授权记录的直接变化。
type EnterpriseDeviceAuthorizationChange struct {
Authorization *model.EnterpriseDeviceAuthorization
Relation string
Role string
BeforeData map[string]any
AfterData map[string]any
}
// IotCardChange 保存组织操作关联卡的资源变化与主体安全投影。
type IotCardChange struct {
Card *model.IotCard
Relation string
Role string
BeforeData map[string]any
AfterData map[string]any
SubjectVisibility string
SubjectSummary string
SubjectData map[string]any
}
// EnterpriseCardAuthorizationChange 保存企业卡授权记录的直接变化。
type EnterpriseCardAuthorizationChange struct {
Authorization *model.EnterpriseCardAuthorization
BeforeData map[string]any
AfterData map[string]any
}
// AccountChange 保存店铺操作关联账号的资源角色与直接变化。
type AccountChange struct {
Account *model.Account
Relation string
Role string
BeforeData map[string]any
AfterData map[string]any
}
// RoleChange 保存主体授权中单个角色资源的前后变化。
type RoleChange struct {
Role *model.Role
BeforeData map[string]any
AfterData map[string]any
}
// PermissionChange 保存单个权限资源的前后变化。
type PermissionChange struct {
Permission *model.Permission
BeforeData map[string]any
AfterData map[string]any
}
// Writer 接收账号权限与组织事务内审计事实。
type Writer interface {
WriteAccessChange(context.Context, *gorm.DB, ChangeAudit) error
}
// RecordFailure 在业务回滚后使用独立短事务记录失败或拒绝事实。
func RecordFailure(ctx context.Context, db *gorm.DB, writer Writer, change ChangeAudit, originalErr error) {
fillFailure(changeError(originalErr), &change)
if db == nil || writer == nil {
recordSecondaryFailure(ctx, change, apperrors.New(apperrors.CodeInvalidStatus, "统一组织审计接缝未配置"))
return
}
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return writer.WriteAccessChange(ctx, tx, change)
}); err != nil {
recordSecondaryFailure(ctx, change, err)
}
}
func changeError(err error) *apperrors.AppError {
var appErr *apperrors.AppError
if stderrors.As(err, &appErr) {
return appErr
}
return apperrors.New(apperrors.CodeInternalError, "账号权限或组织操作失败")
}
func fillFailure(appErr *apperrors.AppError, change *ChangeAudit) {
if change.Result == "" {
change.Result = constants.AuditResultFailed
}
if change.ErrorCode == "" {
change.ErrorCode = strconv.Itoa(appErr.Code)
}
if change.ErrorSummary == "" {
change.ErrorSummary = appErr.Message
}
}
func recordSecondaryFailure(ctx context.Context, change ChangeAudit, err error) {
value := auditcontext.From(ctx)
auditfailure.RecordSecondaryWriteFailure(
change.ActionCode, resourceKey(change), value.RequestID, value.CorrelationID, change.ErrorCode, err,
)
}
func resourceKey(change ChangeAudit) string {
if change.Account != nil {
if change.Account.ID != 0 {
return strconv.FormatUint(uint64(change.Account.ID), 10)
}
return change.Account.Username
}
if change.Enterprise != nil {
if change.Enterprise.ID != 0 {
return strconv.FormatUint(uint64(change.Enterprise.ID), 10)
}
return change.Enterprise.EnterpriseCode
}
if change.PersonalCustomer != nil {
return strconv.FormatUint(uint64(change.PersonalCustomer.ID), 10)
}
if change.Shop != nil {
if change.Shop.ID != 0 {
return strconv.FormatUint(uint64(change.Shop.ID), 10)
}
return change.Shop.ShopCode
}
if change.Role != nil {
if change.Role.ID != 0 {
return strconv.FormatUint(uint64(change.Role.ID), 10)
}
return change.Role.RoleName
}
for _, permission := range change.Permissions {
if permission.Permission == nil {
continue
}
if permission.Permission.ID != 0 {
return strconv.FormatUint(uint64(permission.Permission.ID), 10)
}
return permission.Permission.PermCode
}
return "unknown"
}

View File

@@ -0,0 +1,46 @@
// Package accountaudit 定义账号生命周期写入统一审计的应用边界。
package accountaudit
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"gorm.io/gorm"
)
// LifecycleAudit 是账号生命周期用例提交给统一 Writer 的业务事实。
type LifecycleAudit struct {
ActionCode string
Summary string
Result string
ErrorCode string
ErrorSummary string
Account *model.Account
Shop *model.Shop
Enterprise *model.Enterprise
Roles []*model.Role
BeforeData map[string]any
AfterData map[string]any
}
// SecurityAudit 是账号安全用例提交给统一 Writer 的无凭据业务事实。
type SecurityAudit struct {
ActionCode string
Summary string
Result string
ErrorCode string
ErrorSummary string
ActorID uint
ActorName string
Account *model.Account
AuthenticationKey string
Authentication map[string]any
BeforeData map[string]any
AfterData map[string]any
}
// Writer 在调用方提供的事务内追加账号生命周期事件。
type Writer interface {
WriteAccountLifecycle(ctx context.Context, tx *gorm.DB, audit LifecycleAudit) error
WriteAccountSecurity(ctx context.Context, tx *gorm.DB, audit SecurityAudit) error
}

View File

@@ -10,6 +10,7 @@ import (
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -18,21 +19,23 @@ import (
type ApprovalDecisionHandler struct {
db *gorm.DB
posting *walletapp.PostingService
audit RechargeAuditWriter
}
// NewApprovalDecisionHandler 创建员工线下代充值审批终态消费者。
func NewApprovalDecisionHandler(db *gorm.DB, posting *walletapp.PostingService) *ApprovalDecisionHandler {
return &ApprovalDecisionHandler{db: db, posting: posting}
func NewApprovalDecisionHandler(db *gorm.DB, posting *walletapp.PostingService, audit RechargeAuditWriter) *ApprovalDecisionHandler {
return &ApprovalDecisionHandler{db: db, posting: posting, audit: audit}
}
// Handle 幂等处理标准审批终态;只有 approved 首次入账,其他终态不修改钱包。
func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error {
if h == nil || h.db == nil || h.posting == nil {
if h == nil || h.db == nil || h.posting == nil || h.audit == nil {
return errors.New(errors.CodeInternalError, "员工线下代充值审批终态能力未配置")
}
if event.BusinessType != constants.ApprovalBusinessTypeOfflineRecharge || event.BusinessID == 0 || event.InstanceID == 0 {
return errors.New(errors.CodeInvalidParam, "员工线下代充值审批终态参数无效")
}
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID})
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var record model.AgentRechargeRecord
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
@@ -50,11 +53,11 @@ func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp.
case constants.ApprovalDecisionApproved:
return h.applyApproved(ctx, tx, &record, event)
case constants.ApprovalDecisionRejected:
return closeOfflineRecharge(ctx, tx, &record, constants.RechargeStatusRejected, "企业微信审批已拒绝")
return h.closeOfflineRecharge(ctx, tx, &record, event, constants.RechargeStatusRejected, "企业微信审批已拒绝")
case constants.ApprovalDecisionCancelled:
return closeOfflineRecharge(ctx, tx, &record, constants.RechargeStatusClosed, "企业微信审批已撤销")
return h.closeOfflineRecharge(ctx, tx, &record, event, constants.RechargeStatusClosed, "企业微信审批已撤销")
case constants.ApprovalDecisionDeleted:
return closeOfflineRecharge(ctx, tx, &record, constants.RechargeStatusClosed, "企业微信审批已删除")
return h.closeOfflineRecharge(ctx, tx, &record, event, constants.RechargeStatusClosed, "企业微信审批已删除")
case constants.ApprovalDecisionRevokedAfterApproved:
return nil
default:
@@ -86,17 +89,23 @@ func (h *ApprovalDecisionHandler) applyApproved(
return errors.New(errors.CodeConflict, "线下代充值申请状态已变化")
}
}
_, err := h.posting.PostInTx(ctx, tx, walletapp.PostingCommand{
posting, err := h.posting.PostInTx(ctx, tx, walletapp.PostingCommand{
ShopID: record.ShopID, WalletID: record.AgentWalletID, Amount: record.Amount,
ReferenceType: constants.ReferenceTypeTopup, ReferenceID: record.ID,
TransactionType: constants.AgentTransactionTypeRecharge,
UserID: event.SubmitterAccountID, Creator: event.SubmitterAccountID,
Remark: "企业微信审批通过线下充值", CorrelationID: event.CorrelationID,
})
return err
if err != nil {
return err
}
if record.Status == constants.RechargeStatusCompleted && posting.AlreadyApplied {
return nil
}
return h.appendTerminalAudit(ctx, tx, record, event, constants.AuditActionAgentRechargeCredited, "企业微信审批通过,代理充值已入账", constants.RechargeStatusCompleted, true)
}
func closeOfflineRecharge(ctx context.Context, tx *gorm.DB, record *model.AgentRechargeRecord, status int, reason string) error {
func (h *ApprovalDecisionHandler) closeOfflineRecharge(ctx context.Context, tx *gorm.DB, record *model.AgentRechargeRecord, event approvalapp.TerminalDecisionEvent, status int, reason string) error {
if record.Status == status {
return nil
}
@@ -113,7 +122,35 @@ func closeOfflineRecharge(ctx context.Context, tx *gorm.DB, record *model.AgentR
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "线下代充值申请状态已变化")
}
return nil
return h.appendTerminalAudit(ctx, tx, record, event, constants.AuditActionAgentRechargeClosed, reason, status, false)
}
func (h *ApprovalDecisionHandler) appendTerminalAudit(ctx context.Context, tx *gorm.DB, record *model.AgentRechargeRecord, event approvalapp.TerminalDecisionEvent, actionCode, summary string, status int, withWallet bool) error {
after := *record
after.Status = status
change := RechargeAudit{
ActionCode: actionCode, Summary: summary, Record: &after,
BeforeData: map[string]any{"status": record.Status}, AfterData: map[string]any{"status": status},
}
var approval model.ApprovalInstance
if err := tx.WithContext(ctx).First(&approval, event.InstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询线下代充值审批审计快照失败")
}
change.Approval = &approval
if withWallet {
var wallet model.AgentWallet
if err := tx.WithContext(ctx).First(&wallet, record.AgentWalletID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值钱包审计快照失败")
}
var transaction model.AgentWalletTransaction
if err := tx.WithContext(ctx).Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
constants.ReferenceTypeTopup, record.ID, constants.AgentTransactionTypeRecharge, constants.TransactionStatusSuccess).
First(&transaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值入账流水审计快照失败")
}
change.Wallet, change.Transaction = &wallet, &transaction
}
return h.audit.WriteAgentRecharge(ctx, tx, change)
}
var _ approvalapp.BusinessDecisionHandler = (*ApprovalDecisionHandler)(nil)

View File

@@ -26,6 +26,7 @@ type ConfirmOnlinePaymentCommand struct {
PaidAt time.Time
RequestID string
CorrelationID string
ParentEventID string
}
// PaymentConfirmedEvent 是第三方收款事实提交后的代理充值入账事件。
@@ -44,6 +45,7 @@ type PaymentConfirmedEvent struct {
PaidAt time.Time `json:"paid_at"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
ParentEventID string `json:"parent_event_id,omitempty"`
}
// PaymentConfirmedEventWriter 在支付确认事务内追加可靠入账事件。
@@ -54,6 +56,7 @@ type PaymentConfirmedEventWriter interface {
// ConfirmOnlinePaymentResult 返回支付确认是否属于幂等重放。
type ConfirmOnlinePaymentResult struct {
RechargeID uint
PaymentID uint
AlreadyConfirmed bool
}
@@ -61,16 +64,17 @@ type ConfirmOnlinePaymentResult struct {
type ConfirmOnlinePaymentService struct {
db *gorm.DB
eventWriter PaymentConfirmedEventWriter
auditWriter PaymentAuditWriter
}
// NewConfirmOnlinePaymentService 创建代理充值支付确认用例。
func NewConfirmOnlinePaymentService(db *gorm.DB, eventWriter PaymentConfirmedEventWriter) *ConfirmOnlinePaymentService {
return &ConfirmOnlinePaymentService{db: db, eventWriter: eventWriter}
func NewConfirmOnlinePaymentService(db *gorm.DB, eventWriter PaymentConfirmedEventWriter, auditWriter PaymentAuditWriter) *ConfirmOnlinePaymentService {
return &ConfirmOnlinePaymentService{db: db, eventWriter: eventWriter, auditWriter: auditWriter}
}
// Execute 在一个短事务中校验并固化支付事实和可靠入账事件。
func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command ConfirmOnlinePaymentCommand) (*ConfirmOnlinePaymentResult, error) {
if s == nil || s.db == nil || s.eventWriter == nil {
if s == nil || s.db == nil || s.eventWriter == nil || s.auditWriter == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "代理充值支付确认能力未配置")
}
command.PaymentNo = strings.TrimSpace(command.PaymentNo)
@@ -92,6 +96,7 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
return err
}
result.RechargeID = recharge.ID
result.PaymentID = payment.ID
if alreadyConfirmed {
result.AlreadyConfirmed = true
return nil
@@ -124,11 +129,23 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
ShopID: recharge.ShopID, WalletID: recharge.AgentWalletID, UserID: recharge.UserID, Amount: recharge.Amount,
PaymentMethod: command.PaymentMethod, ThirdPartyTradeNo: command.ThirdPartyTradeNo,
PaidAt: paidAt, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
ParentEventID: command.ParentEventID,
}
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入代理充值支付确认事件失败")
}
return nil
afterPayment := *payment
afterPayment.Status = model.PaymentRecordStatusPaid
afterPayment.ThirdPartyTradeNo = command.ThirdPartyTradeNo
afterPayment.PaidAt = &paidAt
return s.auditWriter.WriteAgentRechargePayment(ctx, tx, PaymentAudit{
ActionCode: constants.AuditActionPaymentConfirmed, Summary: "确认代理充值支付成功",
Payment: &afterPayment, Recharge: recharge,
BeforeData: map[string]any{"status": payment.Status, "third_party_trade_no": payment.ThirdPartyTradeNo, "paid_at": payment.PaidAt},
AfterData: map[string]any{"status": afterPayment.Status, "third_party_trade_no": afterPayment.ThirdPartyTradeNo, "paid_at": afterPayment.PaidAt},
RechargeBeforeData: map[string]any{"status": recharge.Status, "payment_transaction_id": recharge.PaymentTransactionID, "paid_at": recharge.PaidAt},
RechargeAfterData: map[string]any{"status": constants.RechargeStatusPaid, "payment_transaction_id": command.ThirdPartyTradeNo, "paid_at": paidAt},
})
})
if err != nil {
return nil, err

View File

@@ -38,16 +38,17 @@ type CreateOfflineResult struct {
type OfflineCreationService struct {
db *gorm.DB
approval approvalapp.Port
audit RechargeAuditWriter
}
// NewOfflineCreationService 创建员工线下代充值申请用例。
func NewOfflineCreationService(db *gorm.DB, approval approvalapp.Port) *OfflineCreationService {
return &OfflineCreationService{db: db, approval: approval}
func NewOfflineCreationService(db *gorm.DB, approval approvalapp.Port, audit RechargeAuditWriter) *OfflineCreationService {
return &OfflineCreationService{db: db, approval: approval, audit: audit}
}
// Execute 在业务写入前校验审批渠道,并在同一事务保存充值申请、审批实例和提交 Outbox。
func (s *OfflineCreationService) Execute(ctx context.Context, command CreateOfflineCommand) (*CreateOfflineResult, error) {
if s == nil || s.db == nil || s.approval == nil {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "员工线下代充值审批能力未配置")
}
if err := validateCreateOfflineCommand(command); err != nil {
@@ -101,7 +102,14 @@ func (s *OfflineCreationService) Execute(ctx context.Context, command CreateOffl
}
record.ApprovalInstanceID = &reference.InstanceID
approvalStatus = reference.Status
return nil
var instance model.ApprovalInstance
if err := tx.WithContext(ctx).First(&instance, reference.InstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询线下代充值审批审计快照失败")
}
return s.audit.WriteAgentRecharge(ctx, tx, RechargeAudit{
ActionCode: constants.AuditActionAgentRechargeCreated, Summary: "创建员工线下代充值申请",
Record: record, Approval: &instance, Wallet: wallet, AfterData: map[string]any{"status": record.Status},
})
})
if err != nil {
return nil, err

View File

@@ -49,16 +49,17 @@ type OnlineCreationService struct {
db *gorm.DB
wechat OnlinePaymentPort
alipay OnlinePaymentPort
audit PaymentAuditWriter
}
// NewOnlineCreationService 创建代理在线充值用例并以结构体字段注入两个渠道 Adapter。
func NewOnlineCreationService(db *gorm.DB, wechat, alipay OnlinePaymentPort) *OnlineCreationService {
return &OnlineCreationService{db: db, wechat: wechat, alipay: alipay}
func NewOnlineCreationService(db *gorm.DB, wechat, alipay OnlinePaymentPort, audit PaymentAuditWriter) *OnlineCreationService {
return &OnlineCreationService{db: db, wechat: wechat, alipay: alipay, audit: audit}
}
// Execute 以短事务建单,事务外生成支付链接,再条件保存链接或关闭失败订单。
func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlineCommand) (*CreateOnlineResult, error) {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.audit == nil {
return nil, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置")
}
command.PaymentMethod = strings.TrimSpace(command.PaymentMethod)
@@ -230,7 +231,10 @@ func (s *OnlineCreationService) createLocalFacts(
if err := tx.Create(payment).Error; err != nil {
return err
}
return nil
return s.audit.WriteAgentRechargePayment(ctx, tx, PaymentAudit{
ActionCode: constants.AuditActionPaymentCreated, Summary: "创建代理充值支付记录",
Payment: payment, Recharge: record, AfterData: map[string]any{"status": payment.Status},
})
})
if err != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "创建在线充值本地订单失败")
@@ -301,7 +305,14 @@ func (s *OnlineCreationService) closeFailedCreation(ctx context.Context, result
if rechargeUpdate.Error != nil {
return apperrors.Wrap(apperrors.CodeDatabaseError, rechargeUpdate.Error, "关闭失败充值单失败")
}
return nil
afterPayment := *result.Payment
afterPayment.Status = model.PaymentRecordStatusFailed
return s.audit.WriteAgentRechargePayment(ctx, tx, PaymentAudit{
ActionCode: constants.AuditActionPaymentFailed, Summary: "支付链接生成失败,关闭支付记录",
Payment: &afterPayment, Recharge: result.Recharge,
BeforeData: map[string]any{"status": result.Payment.Status}, AfterData: map[string]any{"status": afterPayment.Status},
RechargeBeforeData: map[string]any{"status": result.Recharge.Status}, RechargeAfterData: map[string]any{"status": constants.RechargeStatusClosed},
})
})
}

View File

@@ -5,8 +5,26 @@ import (
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"gorm.io/gorm"
)
// PaymentAudit 描述代理充值支付记录的实际生命周期变化。
type PaymentAudit struct {
ActionCode string
Summary string
Payment *model.Payment
Recharge *model.AgentRechargeRecord
BeforeData map[string]any
AfterData map[string]any
RechargeBeforeData map[string]any
RechargeAfterData map[string]any
}
// PaymentAuditWriter 在支付业务事务内追加统一 Audit Event。
type PaymentAuditWriter interface {
WriteAgentRechargePayment(ctx context.Context, tx *gorm.DB, change PaymentAudit) error
}
const (
// OnlinePaymentStatePending 表示渠道仍在等待付款。
OnlinePaymentStatePending = "pending"

View File

@@ -0,0 +1,27 @@
package agentrecharge
import (
"context"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// RechargeAudit 描述代理充值申请或资金终态的实际变化。
type RechargeAudit struct {
ActionCode string
Summary string
Record *model.AgentRechargeRecord
Payment *model.Payment
Approval *model.ApprovalInstance
Wallet *model.AgentWallet
Transaction *model.AgentWalletTransaction
BeforeData map[string]any
AfterData map[string]any
}
// RechargeAuditWriter 在代理充值业务事务内追加统一 Audit Event。
type RechargeAuditWriter interface {
WriteAgentRecharge(ctx context.Context, tx *gorm.DB, change RechargeAudit) error
}

View File

@@ -17,17 +17,18 @@ type RecoverOnlinePaymentService struct {
wechat OnlinePaymentPort
alipay OnlinePaymentPort
confirm *ConfirmOnlinePaymentService
audit PaymentAuditWriter
now func() time.Time
}
// NewRecoverOnlinePaymentService 创建代理在线充值支付恢复用例。
func NewRecoverOnlinePaymentService(db *gorm.DB, wechat, alipay OnlinePaymentPort, confirm *ConfirmOnlinePaymentService) *RecoverOnlinePaymentService {
return &RecoverOnlinePaymentService{db: db, wechat: wechat, alipay: alipay, confirm: confirm, now: time.Now}
func NewRecoverOnlinePaymentService(db *gorm.DB, wechat, alipay OnlinePaymentPort, confirm *ConfirmOnlinePaymentService, audit PaymentAuditWriter) *RecoverOnlinePaymentService {
return &RecoverOnlinePaymentService{db: db, wechat: wechat, alipay: alipay, confirm: confirm, audit: audit, now: time.Now}
}
// ProcessBatch 按固定批次读取本地待处理事实并调用对应渠道收敛状态。
func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, error) {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.confirm == nil {
if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.confirm == nil || s.audit == nil {
return 0, errors.New(errors.CodeServiceUnavailable, "代理在线充值支付恢复能力未配置")
}
now := s.now().UTC()
@@ -128,7 +129,7 @@ func (s *RecoverOnlinePaymentService) queryPayment(ctx context.Context, adapter
})
return err
case OnlinePaymentStateClosed:
return s.closePending(ctx, payment.ID, recharge.ID)
return s.closePending(ctx, payment, recharge)
default:
return nil
}
@@ -171,21 +172,31 @@ func recoveryConfig(payment *model.Payment, configs map[uint]*model.WechatConfig
return configs[*payment.PaymentConfigID]
}
func (s *RecoverOnlinePaymentService) closePending(ctx context.Context, paymentID, rechargeID uint) error {
func (s *RecoverOnlinePaymentService) closePending(ctx context.Context, payment *model.Payment, recharge *model.AgentRechargeRecord) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
paymentUpdate := tx.Model(&model.Payment{}).
Where("id = ? AND status = ?", paymentID, model.PaymentRecordStatusPending).
Where("id = ? AND status = ?", payment.ID, model.PaymentRecordStatusPending).
Update("status", model.PaymentRecordStatusFailed)
if paymentUpdate.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, paymentUpdate.Error, "关闭失效代理充值支付单失败")
}
rechargeUpdate := tx.Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status = ?", rechargeID, constants.RechargeStatusPending).
Where("id = ? AND status = ?", recharge.ID, constants.RechargeStatusPending).
Update("status", constants.RechargeStatusClosed)
if rechargeUpdate.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, rechargeUpdate.Error, "关闭失效代理充值单失败")
}
return nil
if paymentUpdate.RowsAffected == 0 {
return nil
}
afterPayment := *payment
afterPayment.Status = model.PaymentRecordStatusFailed
return s.audit.WriteAgentRechargePayment(ctx, tx, PaymentAudit{
ActionCode: constants.AuditActionPaymentFailed, Summary: "支付渠道确认订单已关闭",
Payment: &afterPayment, Recharge: recharge,
BeforeData: map[string]any{"status": payment.Status}, AfterData: map[string]any{"status": afterPayment.Status},
RechargeBeforeData: map[string]any{"status": recharge.Status}, RechargeAfterData: map[string]any{"status": constants.RechargeStatusClosed},
})
})
}

View File

@@ -0,0 +1,40 @@
package approval
import (
"context"
"gorm.io/gorm"
)
// AuditChange 描述通用审批链路一次实际状态变化。
type AuditChange struct {
EventID string
ActionCode string
Summary string
InstanceID uint
BusinessType string
BusinessID uint
SubmitterAccountID uint
SubmitterSnapshot []byte
Provider string
BeforeExternalRef string
AfterExternalRef string
CorrelationID string
ParentEventID string
BeforeStatus *int
AfterStatus *int
ActorKind string
ActorID string
ActorName string
Source string
Result string
ErrorSummary string
Decision string
IntegrationIDs []string
OutboxEventID string
}
// AuditWriter 在审批事实事务中追加统一 Audit Event。
type AuditWriter interface {
WriteApproval(ctx context.Context, tx *gorm.DB, change AuditChange) error
}

View File

@@ -18,6 +18,7 @@ type CreationService struct {
providers ProviderPort
repositories RepositoryProvider
eventWriter SubmissionEventWriter
audit AuditWriter
now func() time.Time
}
@@ -34,6 +35,11 @@ func NewCreationService(
return &CreationService{providers: providers, repositories: repositories, eventWriter: eventWriter, now: now}
}
// SetAuditWriter 注入通用审批统一审计 Writer。
func (s *CreationService) SetAuditWriter(writer AuditWriter) {
s.audit = writer
}
// Prepare 在任何业务事实写入前确认 Adapter、场景和真实发起身份可用。
func (s *CreationService) Prepare(ctx context.Context, request PrepareRequest) (Preparation, error) {
if s == nil || s.providers == nil || s.repositories == nil || s.eventWriter == nil {
@@ -62,7 +68,7 @@ func (s *CreationService) Prepare(ctx context.Context, request PrepareRequest) (
// CreateInTx 使用调用方业务事务原子创建通用实例、渠道上下文和提交 Outbox。
func (s *CreationService) CreateInTx(ctx context.Context, tx *gorm.DB, request CreateRequest) (Reference, error) {
if s == nil || tx == nil || s.repositories == nil || s.providers == nil || s.eventWriter == nil {
if s == nil || tx == nil || s.repositories == nil || s.providers == nil || s.eventWriter == nil || s.audit == nil {
return Reference{}, errors.New(errors.CodeInternalError, "通用审批创建用例未完整配置")
}
now := s.now().UTC()
@@ -97,6 +103,18 @@ func (s *CreationService) CreateInTx(ctx context.Context, tx *gorm.DB, request C
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
return Reference{}, err
}
afterStatus := instance.Status
if err := s.audit.WriteApproval(ctx, tx, AuditChange{
EventID: "approval:" + strconv.FormatUint(uint64(instance.ID), 10) + ":audit:requested",
ActionCode: constants.AuditActionApprovalRequested, Summary: "提交通用审批申请",
InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot,
Provider: instance.Provider, CorrelationID: instance.CorrelationID, AfterStatus: &afterStatus,
ActorKind: constants.AuditActorAccount, ActorID: strconv.FormatUint(uint64(instance.SubmitterAccountID), 10),
Source: constants.AuditSourceAdminAPI, Result: constants.AuditResultSuccess, OutboxEventID: event.EventID,
}); err != nil {
return Reference{}, err
}
return Reference{InstanceID: instance.ID, Status: instance.Status}, nil
}

View File

@@ -46,6 +46,7 @@ type SyncDecisionCommand struct {
Decision string
DecisionSnapshot []byte
Source string
IntegrationIDs []string
}
// SyncDecisionResult 返回本次是否首次记录该标准终态。
@@ -60,6 +61,7 @@ type SyncDecisionService struct {
repositories RepositoryProvider
eventWriter TerminalEventWriter
deliveryWriter DecisionDeliveryWriter
audit AuditWriter
now func() time.Time
}
@@ -79,9 +81,14 @@ func NewSyncDecisionService(
}
}
// SetAuditWriter 注入通用审批统一审计 Writer。
func (s *SyncDecisionService) SetAuditWriter(writer AuditWriter) {
s.audit = writer
}
// Execute 将回调或轮询取得的权威渠道状态原子转换为通用审批终态和可靠业务事件。
func (s *SyncDecisionService) Execute(ctx context.Context, command SyncDecisionCommand) (*SyncDecisionResult, error) {
if s == nil || s.db == nil || s.repositories == nil || s.eventWriter == nil || s.deliveryWriter == nil {
if s == nil || s.db == nil || s.repositories == nil || s.eventWriter == nil || s.deliveryWriter == nil || s.audit == nil {
return nil, errors.New(errors.CodeInternalError, "通用审批决策同步用例未完整配置")
}
if command.InstanceID == 0 || !isSupportedSyncSource(command.Source) {
@@ -98,6 +105,8 @@ func (s *SyncDecisionService) Execute(ctx context.Context, command SyncDecisionC
return err
}
expectedStatus, expectedVersion := instance.Status, instance.Version
beforeStatus := instance.Status
beforeExternalRef := instance.ExternalRef
changed, err := instance.ApplyDecision(command.Decision, command.DecisionSnapshot, s.now().UTC())
if err != nil {
return err
@@ -125,6 +134,20 @@ func (s *SyncDecisionService) Execute(ctx context.Context, command SyncDecisionC
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
return err
}
actorKind, actorID, source := approvalSyncAuditOrigin(command.Source)
afterStatus := instance.Status
if err := s.audit.WriteApproval(ctx, tx, AuditChange{
EventID: event.EventID + ":audit", ActionCode: constants.AuditActionApprovalDecisionSynced,
Summary: "同步审批权威终态", InstanceID: instance.ID,
BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot,
Provider: instance.Provider, BeforeExternalRef: beforeExternalRef, AfterExternalRef: instance.ExternalRef,
CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &afterStatus,
ActorKind: actorKind, ActorID: actorID, Source: source, Result: constants.AuditResultSuccess,
Decision: command.Decision, IntegrationIDs: command.IntegrationIDs, OutboxEventID: event.EventID,
}); err != nil {
return err
}
result.FirstTerminal = true
return nil
})
@@ -134,6 +157,17 @@ func (s *SyncDecisionService) Execute(ctx context.Context, command SyncDecisionC
return result, nil
}
func approvalSyncAuditOrigin(source string) (string, string, string) {
switch source {
case constants.ApprovalSyncSourceCallback:
return constants.AuditActorExternalSystem, constants.ApprovalAuditActorWeCom, constants.AuditSourceCallback
case constants.ApprovalSyncSourcePolling:
return constants.AuditActorScheduledJob, constants.ApprovalAuditActorRecoveryJob, constants.AuditSourceScheduler
default:
return constants.AuditActorAccount, "", constants.AuditSourceAdminAPI
}
}
func terminalDecisionEventID(instanceID uint, decision string) string {
return "approval:" + strconv.FormatUint(uint64(instanceID), 10) + ":" + decision
}

View File

@@ -0,0 +1,350 @@
package auditarchive
import (
"compress/gzip"
"context"
"crypto/sha256"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
type integrationArchiveFile struct {
path string
recordCount int64
uncompressedBytes int64
compressedBytes int64
sha256 string
}
type integrationArchiveManifest struct {
SchemaVersion string `json:"schema_version"`
Source string `json:"source"`
ArchiveDate string `json:"archive_date"`
Timezone string `json:"timezone"`
RangeStart time.Time `json:"range_start"`
RangeEnd time.Time `json:"range_end"`
InstanceID string `json:"instance_id"`
RecordCount int64 `json:"record_count"`
UncompressedBytes int64 `json:"uncompressed_bytes"`
CompressedBytes int64 `json:"compressed_bytes"`
ObjectKey string `json:"object_key"`
SHA256 string `json:"sha256"`
Revision int `json:"revision"`
GeneratedAt time.Time `json:"generated_at"`
Status string `json:"status"`
Final bool `json:"final"`
}
// ArchivePreviousIntegrationDay 归档 Asia/Shanghai 前一完整自然日的 Integration Log 创建日快照。
func (s *Service) ArchivePreviousIntegrationDay(ctx context.Context) error {
now := time.Now().In(s.location)
return s.ArchiveIntegrationDate(ctx, now.AddDate(0, 0, -1))
}
// ArchiveIntegrationDate 归档指定 Asia/Shanghai 自然日的 Integration Log 创建日快照。
func (s *Service) ArchiveIntegrationDate(ctx context.Context, archiveDate time.Time) error {
return s.archiveIntegrationDate(ctx, archiveDate, false)
}
// FinalizePreviousIntegrationMonth 复核并终结上一个完整自然月的 Integration Log 归档。
func (s *Service) FinalizePreviousIntegrationMonth(ctx context.Context) error {
now := time.Now().In(s.location)
return s.FinalizeIntegrationMonth(ctx, now.AddDate(0, -1, 0))
}
// FinalizeIntegrationMonth 逐日复核指定完整自然月,并为变化内容创建最终 revision。
func (s *Service) FinalizeIntegrationMonth(ctx context.Context, month time.Time) error {
monthStart := time.Date(month.In(s.location).Year(), month.In(s.location).Month(), 1, 0, 0, 0, 0, s.location)
currentMonth := time.Now().In(s.location)
currentMonthStart := time.Date(currentMonth.Year(), currentMonth.Month(), 1, 0, 0, 0, 0, s.location)
if !monthStart.Before(currentMonthStart) {
return fmt.Errorf("只能终结已经结束的 Integration Log 完整自然月")
}
for date := monthStart; date.Before(monthStart.AddDate(0, 1, 0)); date = date.AddDate(0, 0, 1) {
if err := s.archiveIntegrationDate(ctx, date, true); err != nil {
return fmt.Errorf("终结 %s Integration Log 归档失败: %w", date.Format(time.DateOnly), err)
}
}
return nil
}
func (s *Service) archiveIntegrationDate(ctx context.Context, archiveDate time.Time, final bool) error {
if s.db == nil || s.store == nil {
return fmt.Errorf("Integration Log 归档数据库或对象存储未配置")
}
start := time.Date(archiveDate.In(s.location).Year(), archiveDate.In(s.location).Month(), archiveDate.In(s.location).Day(), 0, 0, 0, 0, s.location)
end := start.AddDate(0, 0, 1)
today := time.Now().In(s.location)
if end.After(time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, s.location)) {
return fmt.Errorf("Integration Log 只能归档已经结束的完整自然日")
}
run, err := s.ensureIntegrationRun(ctx, start, end)
if err != nil {
return err
}
file, err := s.buildIntegrationArchiveFile(ctx, start, end)
if err != nil {
return err
}
defer os.Remove(file.path)
if final {
pending, pendingErr := s.integrationPendingCount(ctx, start, end)
if pendingErr != nil {
return pendingErr
}
if pending > 0 {
_ = s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("id = ?", run.ID).
Updates(map[string]any{"is_final": false, "error_summary": "存在 pending Integration Log无法形成最终归档", "updated_at": time.Now()}).Error
return fmt.Errorf("仍有 %d 条 pending Integration Log无法形成最终归档", pending)
}
}
if run.Status == constants.ArchiveStatusSuccess && run.RecordCount == file.recordCount && run.SHA256 == file.sha256 {
valid, validateErr := s.validateIntegrationRun(ctx, run)
if validateErr == nil && valid && (!final || run.IsFinal) {
return nil
}
}
acquired, err := s.acquireIntegrationRun(ctx, run)
if err != nil {
return err
}
if !acquired {
return fmt.Errorf("Integration Log 归档任务正在执行")
}
if err := s.uploadIntegrationArchive(ctx, run, file, final); err != nil {
s.markFailed(ctx, run.ID, err)
return err
}
return nil
}
func (s *Service) ensureIntegrationRun(ctx context.Context, start, end time.Time) (*model.LogArchiveRun, error) {
run := model.LogArchiveRun{
Source: constants.IntegrationArchiveSource, ArchiveDate: start, InstanceID: s.instanceID,
SchemaVersion: constants.IntegrationArchiveSchemaVersion, Revision: 1,
Status: constants.ArchiveStatusPending, RangeStart: start, RangeEnd: end,
}
result := s.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source"}, {Name: "archive_date"}, {Name: "instance_id"}, {Name: "schema_version"}},
DoNothing: true,
}).Create(&run)
if result.Error != nil {
return nil, fmt.Errorf("创建 Integration Log 归档账本失败: %w", result.Error)
}
if result.RowsAffected == 0 {
if err := s.db.WithContext(ctx).Where(
"source = ? AND archive_date = ? AND instance_id = ? AND schema_version = ?",
constants.IntegrationArchiveSource, start, s.instanceID, constants.IntegrationArchiveSchemaVersion,
).First(&run).Error; err != nil {
return nil, fmt.Errorf("读取 Integration Log 归档账本失败: %w", err)
}
}
return &run, nil
}
func (s *Service) acquireIntegrationRun(ctx context.Context, run *model.LogArchiveRun) (bool, error) {
revision := run.Revision
if run.Status != constants.ArchiveStatusPending {
revision++
}
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("id = ? AND (status <> ? OR updated_at < ?)", run.ID, constants.ArchiveStatusRunning, now.Add(-3*time.Hour)).
Updates(map[string]any{
"status": constants.ArchiveStatusRunning, "revision": revision, "is_final": false,
"attempt_count": gorm.Expr("attempt_count + 1"), "error_summary": "", "completed_at": nil, "updated_at": now,
})
if result.Error != nil {
return false, fmt.Errorf("锁定 Integration Log 归档任务失败: %w", result.Error)
}
if result.RowsAffected == 0 {
return false, nil
}
run.Revision = revision
return true, nil
}
func (s *Service) buildIntegrationArchiveFile(ctx context.Context, start, end time.Time) (*integrationArchiveFile, error) {
temp, err := os.CreateTemp("", "integration-logs-*.jsonl.gz")
if err != nil {
return nil, fmt.Errorf("创建 Integration Log 归档临时文件失败: %w", err)
}
path := temp.Name()
failed := true
defer func() {
_ = temp.Close()
if failed {
_ = os.Remove(path)
}
}()
hasher := sha256.New()
gzipWriter := gzip.NewWriter(io.MultiWriter(temp, hasher))
result := &integrationArchiveFile{path: path}
var lastID uint
for {
var logs []model.IntegrationLog
if err := s.db.WithContext(ctx).Where("created_at >= ? AND created_at < ? AND id > ?", start, end, lastID).
Order("id ASC").Limit(archivePageSize).Find(&logs).Error; err != nil {
return nil, fmt.Errorf("读取 Integration Log 归档记录失败: %w", err)
}
if len(logs) == 0 {
break
}
for i := range logs {
line, marshalErr := sonic.Marshal(logs[i])
if marshalErr != nil {
return nil, fmt.Errorf("序列化 Integration Log 归档记录失败: %w", marshalErr)
}
line = append(line, '\n')
if _, writeErr := gzipWriter.Write(line); writeErr != nil {
return nil, fmt.Errorf("写入 Integration Log 归档压缩流失败: %w", writeErr)
}
result.recordCount++
result.uncompressedBytes += int64(len(line))
}
lastID = logs[len(logs)-1].ID
}
if err := gzipWriter.Close(); err != nil {
return nil, fmt.Errorf("关闭 Integration Log 归档压缩流失败: %w", err)
}
if err := temp.Close(); err != nil {
return nil, fmt.Errorf("关闭 Integration Log 归档临时文件失败: %w", err)
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("读取 Integration Log 归档临时文件信息失败: %w", err)
}
result.compressedBytes = info.Size()
result.sha256 = fmt.Sprintf("%x", hasher.Sum(nil))
failed = false
return result, nil
}
func (s *Service) uploadIntegrationArchive(ctx context.Context, run *model.LogArchiveRun, file *integrationArchiveFile, final bool) error {
count, err := s.integrationRecordCount(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
if count != file.recordCount {
return fmt.Errorf("Integration Log 归档生成期间记录数量发生变化")
}
objectKey, manifestKey := integrationObjectKeys(run.RangeStart, run.Revision)
metadata := integrationArchiveMetadata(file, run, final)
reader, err := os.Open(file.path)
if err != nil {
return fmt.Errorf("打开 Integration Log 归档临时文件失败: %w", err)
}
uploadErr := s.store.UploadWithMetadata(ctx, objectKey, reader, "application/gzip", metadata)
closeErr := reader.Close()
if uploadErr != nil {
return fmt.Errorf("上传 Integration Log 归档对象失败: %w", uploadErr)
}
if closeErr != nil {
return fmt.Errorf("关闭 Integration Log 归档临时文件失败: %w", closeErr)
}
if err := s.verifyObject(ctx, objectKey, file.compressedBytes, metadata); err != nil {
return err
}
generatedAt := time.Now().In(s.location)
manifest := integrationArchiveManifest{
SchemaVersion: constants.IntegrationArchiveSchemaVersion, Source: constants.IntegrationArchiveSource,
ArchiveDate: run.RangeStart.In(s.location).Format(time.DateOnly), Timezone: constants.AuditArchiveTimezone,
RangeStart: run.RangeStart, RangeEnd: run.RangeEnd, InstanceID: s.instanceID,
RecordCount: file.recordCount, UncompressedBytes: file.uncompressedBytes, CompressedBytes: file.compressedBytes,
ObjectKey: objectKey, SHA256: file.sha256, Revision: run.Revision, GeneratedAt: generatedAt,
Status: constants.ArchiveStatusSuccess, Final: final,
}
manifestBytes, err := sonic.Marshal(manifest)
if err != nil {
return fmt.Errorf("序列化 Integration Log 归档清单失败: %w", err)
}
manifestMetadata := map[string]string{
"source": constants.IntegrationArchiveSource, "data-sha256": file.sha256,
"revision": strconv.Itoa(run.Revision), "final": strconv.FormatBool(final),
}
if err := s.store.UploadWithMetadata(ctx, manifestKey, strings.NewReader(string(manifestBytes)), "application/json", manifestMetadata); err != nil {
return fmt.Errorf("上传 Integration Log 归档清单失败: %w", err)
}
if err := s.verifyObject(ctx, manifestKey, int64(len(manifestBytes)), manifestMetadata); err != nil {
return err
}
completedAt := time.Now()
return s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("id = ?", run.ID).Updates(map[string]any{
"status": constants.ArchiveStatusSuccess, "is_final": final,
"object_key": objectKey, "manifest_key": manifestKey, "record_count": file.recordCount,
"uncompressed_bytes": file.uncompressedBytes, "compressed_bytes": file.compressedBytes,
"sha256": file.sha256, "generated_at": generatedAt, "completed_at": completedAt, "updated_at": completedAt,
}).Error
}
func (s *Service) validateIntegrationRun(ctx context.Context, run *model.LogArchiveRun) (bool, error) {
metadata := map[string]string{
"sha256": run.SHA256, "record-count": strconv.FormatInt(run.RecordCount, 10),
"revision": strconv.Itoa(run.Revision), "final": strconv.FormatBool(run.IsFinal),
}
if err := s.verifyObject(ctx, run.ObjectKey, run.CompressedBytes, metadata); err != nil {
return false, nil
}
manifestMetadata := map[string]string{
"source": constants.IntegrationArchiveSource, "data-sha256": run.SHA256,
"revision": strconv.Itoa(run.Revision), "final": strconv.FormatBool(run.IsFinal),
}
if err := s.verifyObject(ctx, run.ManifestKey, -1, manifestMetadata); err != nil {
return false, nil
}
return true, nil
}
func (s *Service) integrationRecordCount(ctx context.Context, start, end time.Time) (int64, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&model.IntegrationLog{}).
Where("created_at >= ? AND created_at < ?", start, end).Count(&count).Error; err != nil {
return 0, fmt.Errorf("统计 Integration Log 归档记录失败: %w", err)
}
return count, nil
}
func (s *Service) integrationPendingCount(ctx context.Context, start, end time.Time) (int64, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&model.IntegrationLog{}).
Where("created_at >= ? AND created_at < ? AND result = ?", start, end, constants.IntegrationResultPending).
Count(&count).Error; err != nil {
return 0, fmt.Errorf("统计 pending Integration Log 失败: %w", err)
}
return count, nil
}
func integrationObjectKeys(date time.Time, revision int) (string, string) {
prefix := fmt.Sprintf("audit-archive/v1/%04d/%02d/%02d", date.Year(), date.Month(), date.Day())
name := fmt.Sprintf("integration-logs-%s-r%d", date.Format(time.DateOnly), revision)
return prefix + "/" + name + ".jsonl.gz", prefix + "/" + name + ".manifest.json"
}
func integrationArchiveMetadata(file *integrationArchiveFile, run *model.LogArchiveRun, final bool) map[string]string {
return map[string]string{
"schema-version": constants.IntegrationArchiveSchemaVersion,
"source": constants.IntegrationArchiveSource,
"archive-date": run.RangeStart.Format(time.DateOnly),
"timezone": constants.AuditArchiveTimezone,
"record-count": strconv.FormatInt(file.recordCount, 10),
"sha256": file.sha256,
"revision": strconv.Itoa(run.Revision),
"final": strconv.FormatBool(final),
}
}

View File

@@ -0,0 +1,542 @@
package auditarchive
import (
"context"
"crypto/sha256"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
const maxManifestBytes = 1024 * 1024
// RetentionAudit 描述月度物理清理的统一审计事实。
type RetentionAudit struct {
EventID string
Month string
Summary string
Result string
ErrorSummary string
RangeStart time.Time
RangeEnd time.Time
EventCount int64
ResourceCount int64
IntegrationCount int64
ManifestKeys []string
DurationMS int64
}
// RetentionResult 是月度留存清理的结构化执行结果。
type RetentionResult struct {
Month string
EventCount int64
ResourceCount int64
IntegrationCount int64
ManifestKeys []string
Duration time.Duration
}
type retentionRuns struct {
audit []*model.LogArchiveRun
integration []*model.LogArchiveRun
}
// CleanupPreviousMonth 校验并物理清理上一个完整自然月的在线审计日志。
func (s *Service) CleanupPreviousMonth(ctx context.Context) (RetentionResult, error) {
now := time.Now().In(s.location)
return s.CleanupMonth(ctx, now.AddDate(0, -1, 0))
}
// CleanupMonth 校验归档硬门禁后按固定顺序物理清理指定完整自然月。
func (s *Service) CleanupMonth(ctx context.Context, month time.Time) (result RetentionResult, cleanupErr error) {
if s.db == nil || s.store == nil || s.audit == nil {
return result, fmt.Errorf("日志留存清理数据库、对象存储或审计 Writer 未配置")
}
start, end, err := s.retentionMonthRange(month)
if err != nil {
return result, err
}
startedAt := time.Now()
result.Month = start.Format("2006-01")
cleanupErr = s.executeRetention(ctx, start, end, &result)
result.Duration = time.Since(startedAt)
if auditErr := s.recordRetentionAudit(ctx, start, end, result, cleanupErr); auditErr != nil {
if cleanupErr != nil {
return result, fmt.Errorf("%w记录留存清理失败审计失败: %v", cleanupErr, auditErr)
}
return result, fmt.Errorf("记录留存清理成功审计失败: %w", auditErr)
}
return result, cleanupErr
}
func (s *Service) retentionMonthRange(month time.Time) (time.Time, time.Time, error) {
start := time.Date(month.In(s.location).Year(), month.In(s.location).Month(), 1, 0, 0, 0, 0, s.location)
end := start.AddDate(0, 1, 0)
now := time.Now().In(s.location)
currentMonth := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, s.location)
if !end.Before(currentMonth) && !end.Equal(currentMonth) {
return time.Time{}, time.Time{}, fmt.Errorf("只能清理已经结束的完整自然月")
}
return start, end, nil
}
func (s *Service) executeRetention(ctx context.Context, start, end time.Time, result *RetentionResult) error {
started, err := s.retentionCleanupStarted(ctx, start, end)
if err != nil {
return err
}
if !started {
lastDay := end.AddDate(0, 0, -1)
if err := s.ArchiveDate(ctx, lastDay); err != nil {
return fmt.Errorf("完成上月最后一天 Audit 归档失败: %w", err)
}
if err := s.ArchiveIntegrationDate(ctx, lastDay); err != nil {
return fmt.Errorf("完成上月最后一天 Integration Log 归档失败: %w", err)
}
if err := s.FinalizeIntegrationMonth(ctx, start); err != nil {
return err
}
}
runs, err := s.loadRetentionRuns(ctx, start, end)
if err != nil {
return err
}
if err := s.validateRetentionRuns(ctx, start, end, runs); err != nil {
return err
}
summarizeRetentionRuns(runs, result)
if err := s.cleanupAuditMonth(ctx, start, end, runs.audit); err != nil {
return err
}
return s.cleanupIntegrationMonth(ctx, start, end, runs.integration)
}
func (s *Service) retentionCleanupStarted(ctx context.Context, start, end time.Time) (bool, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("archive_date >= ? AND archive_date < ? AND instance_id = ? AND cleanup_started_at IS NOT NULL",
start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Count(&count).Error
if err != nil {
return false, fmt.Errorf("读取月度清理断点失败: %w", err)
}
return count > 0, nil
}
func (s *Service) loadRetentionRuns(ctx context.Context, start, end time.Time) (retentionRuns, error) {
var rows []model.LogArchiveRun
err := s.db.WithContext(ctx).Where(
"source IN ? AND archive_date >= ? AND archive_date < ? AND instance_id = ?",
[]string{constants.AuditArchiveSource, constants.IntegrationArchiveSource}, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID,
).Order("archive_date ASC, source ASC").Find(&rows).Error
if err != nil {
return retentionRuns{}, fmt.Errorf("读取月度归档账本失败: %w", err)
}
days := int(end.Sub(start).Hours() / 24)
if len(rows) != days*2 {
return retentionRuns{}, fmt.Errorf("月度归档账本缺日:期望 %d 条,实际 %d 条", days*2, len(rows))
}
runs := retentionRuns{audit: make([]*model.LogArchiveRun, 0, days), integration: make([]*model.LogArchiveRun, 0, days)}
for index := range rows {
run := &rows[index]
switch run.Source {
case constants.AuditArchiveSource:
runs.audit = append(runs.audit, run)
case constants.IntegrationArchiveSource:
runs.integration = append(runs.integration, run)
}
}
if len(runs.audit) != days || len(runs.integration) != days {
return retentionRuns{}, fmt.Errorf("月度 Audit 或 Integration 归档账本不完整")
}
return runs, nil
}
func (s *Service) validateRetentionRuns(ctx context.Context, start, end time.Time, runs retentionRuns) error {
if err := validateCleanupLedgerState(runs.audit); err != nil {
return fmt.Errorf("Audit 清理断点非法: %w", err)
}
if err := validateCleanupLedgerState(runs.integration); err != nil {
return fmt.Errorf("Integration 清理断点非法: %w", err)
}
for index := range runs.audit {
date := start.AddDate(0, 0, index)
if err := s.validateAuditRetentionDay(ctx, date, runs.audit[index]); err != nil {
return fmt.Errorf("%s Audit 清理门禁失败: %w", date.Format(time.DateOnly), err)
}
if err := s.validateIntegrationRetentionDay(ctx, date, runs.integration[index]); err != nil {
return fmt.Errorf("%s Integration 清理门禁失败: %w", date.Format(time.DateOnly), err)
}
}
return nil
}
func validateCleanupLedgerState(runs []*model.LogArchiveRun) error {
started, cleaned := 0, 0
for _, run := range runs {
if run.CleanupStartedAt != nil {
started++
}
if run.CleanedAt != nil {
cleaned++
}
}
if started != 0 && started != len(runs) {
return fmt.Errorf("清理开始断点不是整月原子状态")
}
if cleaned != 0 && cleaned != len(runs) {
return fmt.Errorf("清理完成断点不是整月原子状态")
}
if cleaned > 0 && started == 0 {
return fmt.Errorf("清理完成但缺少开始断点")
}
return nil
}
func (s *Service) validateAuditRetentionDay(ctx context.Context, date time.Time, run *model.LogArchiveRun) error {
if err := validateRunBase(run, date, constants.AuditArchiveSchemaVersion, false); err != nil {
return err
}
if err := s.validateAuditManifest(ctx, run); err != nil {
return err
}
events, resources, err := s.databaseCounts(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
return validateRemainingCounts(run, events, resources)
}
func (s *Service) validateIntegrationRetentionDay(ctx context.Context, date time.Time, run *model.LogArchiveRun) error {
if err := validateRunBase(run, date, constants.IntegrationArchiveSchemaVersion, true); err != nil {
return err
}
if err := s.validateIntegrationManifest(ctx, run); err != nil {
return err
}
count, err := s.integrationRecordCount(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
if run.CleanedAt != nil {
if count != 0 {
return fmt.Errorf("已标记清理完成但数据库仍有 %d 条记录", count)
}
return nil
}
if run.CleanupStartedAt != nil {
if count > run.RecordCount {
return fmt.Errorf("续跑窗口记录数超过最终归档数量")
}
return nil
}
file, err := s.buildIntegrationArchiveFile(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
defer os.Remove(file.path)
if file.recordCount != run.RecordCount || file.sha256 != run.SHA256 {
return fmt.Errorf("数据库当前 Integration 内容与最终 revision 不一致")
}
return nil
}
func validateRunBase(run *model.LogArchiveRun, date time.Time, schema string, final bool) error {
if run.Status != constants.ArchiveStatusSuccess || run.SchemaVersion != schema {
return fmt.Errorf("归档状态或 schema version 不符合清理要求")
}
if run.ArchiveDate.Format(time.DateOnly) != date.Format(time.DateOnly) ||
!run.RangeStart.Equal(date) || !run.RangeEnd.Equal(date.AddDate(0, 0, 1)) {
return fmt.Errorf("归档日期或半开时间范围不一致")
}
if final && !run.IsFinal {
return fmt.Errorf("Integration 最终 revision 尚未形成")
}
if run.ObjectKey == "" || run.ManifestKey == "" || run.SHA256 == "" {
return fmt.Errorf("归档对象、manifest 或 SHA-256 缺失")
}
return nil
}
func validateRemainingCounts(run *model.LogArchiveRun, events, resources int64) error {
if run.CleanedAt != nil {
if events != 0 || resources != 0 {
return fmt.Errorf("已标记清理完成但数据库仍有事件或资源")
}
return nil
}
if run.CleanupStartedAt != nil {
if events > run.EventCount || resources > run.ResourceCount {
return fmt.Errorf("续跑窗口数量超过已归档数量")
}
return nil
}
if events != run.EventCount || resources != run.ResourceCount {
return fmt.Errorf("数据库事件或资源数量与 manifest 不一致")
}
return nil
}
func (s *Service) validateAuditManifest(ctx context.Context, run *model.LogArchiveRun) error {
var manifest Manifest
if err := s.readManifest(ctx, run.ManifestKey, &manifest); err != nil {
return err
}
if manifest.Source != run.Source || manifest.SchemaVersion != run.SchemaVersion || manifest.Status != constants.ArchiveStatusSuccess ||
manifest.ArchiveDate != run.ArchiveDate.Format(time.DateOnly) || manifest.Timezone != constants.AuditArchiveTimezone ||
manifest.InstanceID != run.InstanceID || !manifest.RangeStart.Equal(run.RangeStart) || !manifest.RangeEnd.Equal(run.RangeEnd) ||
manifest.EventCount != run.EventCount || manifest.ResourceCount != run.ResourceCount ||
manifest.CompressedBytes != run.CompressedBytes || manifest.ObjectKey != run.ObjectKey ||
manifest.SHA256 != run.SHA256 || manifest.Revision != run.Revision {
return fmt.Errorf("Audit manifest 与 ledger 不一致")
}
if err := s.verifyObject(ctx, run.ManifestKey, -1, map[string]string{
"source": constants.AuditArchiveSource, "data-sha256": run.SHA256, "revision": strconv.Itoa(run.Revision),
}); err != nil {
return err
}
return s.verifyRetentionObject(ctx, run, false)
}
func (s *Service) validateIntegrationManifest(ctx context.Context, run *model.LogArchiveRun) error {
var manifest integrationArchiveManifest
if err := s.readManifest(ctx, run.ManifestKey, &manifest); err != nil {
return err
}
if manifest.Source != run.Source || manifest.SchemaVersion != run.SchemaVersion || manifest.Status != constants.ArchiveStatusSuccess || !manifest.Final ||
manifest.ArchiveDate != run.ArchiveDate.Format(time.DateOnly) || manifest.Timezone != constants.AuditArchiveTimezone ||
manifest.InstanceID != run.InstanceID || !manifest.RangeStart.Equal(run.RangeStart) || !manifest.RangeEnd.Equal(run.RangeEnd) ||
manifest.RecordCount != run.RecordCount || manifest.CompressedBytes != run.CompressedBytes ||
manifest.ObjectKey != run.ObjectKey || manifest.SHA256 != run.SHA256 || manifest.Revision != run.Revision {
return fmt.Errorf("Integration manifest 与最终 ledger 不一致")
}
if err := s.verifyObject(ctx, run.ManifestKey, -1, map[string]string{
"source": constants.IntegrationArchiveSource, "data-sha256": run.SHA256,
"revision": strconv.Itoa(run.Revision), "final": "true",
}); err != nil {
return err
}
return s.verifyRetentionObject(ctx, run, true)
}
func (s *Service) readManifest(ctx context.Context, key string, target any) error {
object, err := s.store.Stat(ctx, key)
if err != nil {
return fmt.Errorf("读取 manifest metadata 失败: %w", err)
}
reader, err := s.store.Download(ctx, key)
if err != nil {
return fmt.Errorf("下载 manifest 失败: %w", err)
}
data, readErr := io.ReadAll(io.LimitReader(reader, maxManifestBytes+1))
closeErr := reader.Close()
if readErr != nil {
return fmt.Errorf("读取 manifest 失败: %w", readErr)
}
if closeErr != nil {
return fmt.Errorf("关闭 manifest 对象失败: %w", closeErr)
}
if len(data) > maxManifestBytes || int64(len(data)) != object.Size {
return fmt.Errorf("manifest 大小非法或不完整")
}
if err := sonic.Unmarshal(data, target); err != nil {
return fmt.Errorf("解析 manifest 失败: %w", err)
}
return nil
}
func (s *Service) verifyRetentionObject(ctx context.Context, run *model.LogArchiveRun, final bool) error {
metadata := map[string]string{
"schema-version": run.SchemaVersion, "source": run.Source,
"archive-date": run.RangeStart.Format(time.DateOnly), "timezone": constants.AuditArchiveTimezone,
"sha256": run.SHA256, "revision": strconv.Itoa(run.Revision),
}
if run.Source == constants.AuditArchiveSource {
metadata["event-count"] = strconv.FormatInt(run.EventCount, 10)
metadata["resource-count"] = strconv.FormatInt(run.ResourceCount, 10)
} else {
metadata["record-count"] = strconv.FormatInt(run.RecordCount, 10)
metadata["final"] = strconv.FormatBool(final)
}
if err := s.verifyObject(ctx, run.ObjectKey, run.CompressedBytes, metadata); err != nil {
return err
}
reader, err := s.store.Download(ctx, run.ObjectKey)
if err != nil {
return fmt.Errorf("下载归档对象复核 SHA-256 失败: %w", err)
}
hasher := sha256.New()
written, copyErr := io.Copy(hasher, reader)
closeErr := reader.Close()
if copyErr != nil {
return fmt.Errorf("读取归档对象复核 SHA-256 失败: %w", copyErr)
}
if closeErr != nil {
return fmt.Errorf("关闭归档对象失败: %w", closeErr)
}
if written != run.CompressedBytes || fmt.Sprintf("%x", hasher.Sum(nil)) != run.SHA256 {
return fmt.Errorf("归档对象大小或 SHA-256 复核失败")
}
return nil
}
func summarizeRetentionRuns(runs retentionRuns, result *RetentionResult) {
result.ManifestKeys = make([]string, 0, len(runs.audit)+len(runs.integration))
for _, run := range runs.audit {
result.EventCount += run.EventCount
result.ResourceCount += run.ResourceCount
result.ManifestKeys = append(result.ManifestKeys, run.ManifestKey)
}
for _, run := range runs.integration {
result.IntegrationCount += run.RecordCount
result.ManifestKeys = append(result.ManifestKeys, run.ManifestKey)
}
}
func (s *Service) cleanupAuditMonth(ctx context.Context, start, end time.Time, runs []*model.LogArchiveRun) error {
if allRunsCleaned(runs) {
return nil
}
if err := s.markCleanupStarted(ctx, constants.AuditArchiveSource, start, end); err != nil {
return err
}
if err := s.deleteAuditResources(ctx, start, end); err != nil {
return err
}
if err := s.deleteAuditEvents(ctx, start, end); err != nil {
return err
}
return s.markCleaned(ctx, constants.AuditArchiveSource, start, end)
}
func (s *Service) cleanupIntegrationMonth(ctx context.Context, start, end time.Time, runs []*model.LogArchiveRun) error {
if allRunsCleaned(runs) {
return nil
}
if err := s.markCleanupStarted(ctx, constants.IntegrationArchiveSource, start, end); err != nil {
return err
}
for {
subquery := s.db.Model(&model.IntegrationLog{}).Select("id").
Where("created_at >= ? AND created_at < ?", start, end).Order("id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
deleted := s.db.WithContext(ctx).Where("id IN (?)", subquery).Delete(&model.IntegrationLog{})
if deleted.Error != nil {
return fmt.Errorf("分批物理删除 Integration Log 失败: %w", deleted.Error)
}
if deleted.RowsAffected == 0 {
break
}
}
return s.markCleaned(ctx, constants.IntegrationArchiveSource, start, end)
}
func (s *Service) deleteAuditResources(ctx context.Context, start, end time.Time) error {
for {
subquery := s.db.Model(&model.AuditEventResource{}).Select("tb_audit_event_resource.id").
Joins("JOIN tb_audit_event ON tb_audit_event.id = tb_audit_event_resource.audit_event_id").
Where("tb_audit_event.created_at >= ? AND tb_audit_event.created_at < ?", start, end).
Order("tb_audit_event_resource.id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
deleted := s.db.WithContext(ctx).Where("id IN (?)", subquery).Delete(&model.AuditEventResource{})
if deleted.Error != nil {
return fmt.Errorf("分批物理删除 Audit Event Resource 失败: %w", deleted.Error)
}
if deleted.RowsAffected == 0 {
return nil
}
}
}
func (s *Service) deleteAuditEvents(ctx context.Context, start, end time.Time) error {
for {
subquery := s.db.Model(&model.AuditEvent{}).Select("id").
Where("created_at >= ? AND created_at < ?", start, end).Order("id ASC").Limit(constants.AuditRetentionDeleteBatchSize)
deleted := s.db.WithContext(ctx).Where("id IN (?)", subquery).Delete(&model.AuditEvent{})
if deleted.Error != nil {
return fmt.Errorf("分批物理删除 Audit Event 失败: %w", deleted.Error)
}
if deleted.RowsAffected == 0 {
return nil
}
}
}
func (s *Service) markCleanupStarted(ctx context.Context, source string, start, end time.Time) error {
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("source = ? AND archive_date >= ? AND archive_date < ? AND instance_id = ? AND cleanup_started_at IS NULL",
source, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Updates(map[string]any{"cleanup_started_at": now, "updated_at": now})
if result.Error != nil {
return fmt.Errorf("记录月度清理开始断点失败: %w", result.Error)
}
return s.validateCleanupMarkerCount(ctx, source, start, end, "cleanup_started_at IS NOT NULL", "开始")
}
func (s *Service) markCleaned(ctx context.Context, source string, start, end time.Time) error {
now := time.Now()
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("source = ? AND archive_date >= ? AND archive_date < ? AND instance_id = ? AND cleanup_started_at IS NOT NULL",
source, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Updates(map[string]any{"cleaned_at": now, "updated_at": now})
if result.Error != nil {
return fmt.Errorf("记录月度清理完成断点失败: %w", result.Error)
}
return s.validateCleanupMarkerCount(ctx, source, start, end, "cleaned_at IS NOT NULL", "完成")
}
func (s *Service) validateCleanupMarkerCount(ctx context.Context, source string, start, end time.Time, marker, label string) error {
var count int64
err := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("source = ? AND archive_date >= ? AND archive_date < ? AND instance_id = ? AND "+marker,
source, start.Format(time.DateOnly), end.Format(time.DateOnly), s.instanceID).
Count(&count).Error
if err != nil {
return fmt.Errorf("复核月度清理%s断点失败: %w", label, err)
}
expected := int64(end.Sub(start).Hours() / 24)
if count != expected {
return fmt.Errorf("月度清理%s断点不完整期望 %d 条,实际 %d 条", label, expected, count)
}
return nil
}
func allRunsCleaned(runs []*model.LogArchiveRun) bool {
return len(runs) > 0 && runs[0].CleanedAt != nil
}
func (s *Service) recordRetentionAudit(ctx context.Context, start, end time.Time, result RetentionResult, cleanupErr error) error {
audit := RetentionAudit{
Month: result.Month, RangeStart: start, RangeEnd: end,
EventCount: result.EventCount, ResourceCount: result.ResourceCount,
IntegrationCount: result.IntegrationCount, ManifestKeys: result.ManifestKeys,
DurationMS: result.Duration.Milliseconds(), Result: constants.AuditResultSuccess,
Summary: "完成已归档在线日志月度物理清理",
EventID: "evt_retention_" + strings.ReplaceAll(result.Month, "-", "_"),
}
if cleanupErr != nil {
audit.EventID = ""
audit.Result = constants.AuditResultFailed
audit.Summary = "已归档在线日志月度物理清理失败"
audit.ErrorSummary = truncateRetentionError(cleanupErr)
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.audit.WriteRetentionCleanup(ctx, tx, audit)
})
}
func truncateRetentionError(err error) string {
value := []rune(err.Error())
if len(value) > 500 {
value = value[:500]
}
return string(value)
}

View File

@@ -0,0 +1,412 @@
// Package auditarchive 实现统一审计每日冷归档用例。
package auditarchive
import (
"compress/gzip"
"context"
"crypto/sha256"
"fmt"
"io"
"os"
"strconv"
"strings"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/storage"
)
const archivePageSize = 1000
// ObjectStore 是每日归档需要的最小对象存储能力。
type ObjectStore interface {
UploadWithMetadata(context.Context, string, io.Reader, string, map[string]string) error
Stat(context.Context, string) (*storage.ObjectMetadata, error)
Download(context.Context, string) (io.ReadCloser, error)
}
// RetentionAuditWriter 记录月度留存清理的系统审计事实。
type RetentionAuditWriter interface {
WriteRetentionCleanup(context.Context, *gorm.DB, RetentionAudit) error
}
// Service 编排审计归档生成、上传、复核和幂等账本更新。
type Service struct {
db *gorm.DB
store ObjectStore
audit RetentionAuditWriter
instanceID string
location *time.Location
}
// Manifest 是归档对象的完整性清单。
type Manifest struct {
SchemaVersion string `json:"schema_version"`
Source string `json:"source"`
ArchiveDate string `json:"archive_date"`
Timezone string `json:"timezone"`
RangeStart time.Time `json:"range_start"`
RangeEnd time.Time `json:"range_end"`
InstanceID string `json:"instance_id"`
EventCount int64 `json:"event_count"`
ResourceCount int64 `json:"resource_count"`
UncompressedBytes int64 `json:"uncompressed_bytes"`
CompressedBytes int64 `json:"compressed_bytes"`
ObjectKey string `json:"object_key"`
SHA256 string `json:"sha256"`
Revision int `json:"revision"`
GeneratedAt time.Time `json:"generated_at"`
Status string `json:"status"`
}
type archiveLine struct {
Event model.AuditEvent `json:"event"`
Resources []model.AuditEventResource `json:"resources"`
}
type archiveFile struct {
path string
eventCount int64
resourceCount int64
uncompressedBytes int64
compressedBytes int64
sha256 string
}
// NewService 创建统一审计每日冷归档服务。
func NewService(db *gorm.DB, store ObjectStore, instanceID string, audit ...RetentionAuditWriter) (*Service, error) {
location, err := time.LoadLocation(constants.AuditArchiveTimezone)
if err != nil {
return nil, fmt.Errorf("加载审计归档时区失败: %w", err)
}
if strings.TrimSpace(instanceID) == "" {
instanceID = "audit-archive"
}
var auditWriter RetentionAuditWriter
if len(audit) > 0 {
auditWriter = audit[0]
}
return &Service{db: db, store: store, audit: auditWriter, instanceID: instanceID, location: location}, nil
}
// ArchivePreviousDay 归档 Asia/Shanghai 前一完整自然日。
func (s *Service) ArchivePreviousDay(ctx context.Context) error {
now := time.Now().In(s.location)
return s.ArchiveDate(ctx, now.AddDate(0, 0, -1))
}
// ArchiveDate 归档指定 Asia/Shanghai 自然日。
func (s *Service) ArchiveDate(ctx context.Context, archiveDate time.Time) error {
if s.db == nil || s.store == nil {
return fmt.Errorf("审计归档数据库或对象存储未配置")
}
start := time.Date(archiveDate.In(s.location).Year(), archiveDate.In(s.location).Month(), archiveDate.In(s.location).Day(), 0, 0, 0, 0, s.location)
end := start.AddDate(0, 0, 1)
today := time.Now().In(s.location)
todayStart := time.Date(today.Year(), today.Month(), today.Day(), 0, 0, 0, 0, s.location)
if end.After(todayStart) {
return fmt.Errorf("统一审计只能归档已经结束的完整自然日")
}
run, err := s.ensureRun(ctx, start, end)
if err != nil {
return err
}
if run.Status == constants.ArchiveStatusSuccess {
valid, validateErr := s.validateSuccessfulRun(ctx, run)
if validateErr == nil && valid {
return nil
}
}
acquired, err := s.acquireRun(ctx, run)
if err != nil || !acquired {
return err
}
if err := s.execute(ctx, run); err != nil {
s.markFailed(ctx, run.ID, err)
return err
}
return nil
}
func (s *Service) ensureRun(ctx context.Context, start, end time.Time) (*model.LogArchiveRun, error) {
run := model.LogArchiveRun{
Source: constants.AuditArchiveSource, ArchiveDate: start, InstanceID: s.instanceID,
SchemaVersion: constants.AuditArchiveSchemaVersion, Revision: 1,
Status: constants.ArchiveStatusPending, RangeStart: start, RangeEnd: end,
}
result := s.db.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "source"}, {Name: "archive_date"}, {Name: "instance_id"}, {Name: "schema_version"}},
DoNothing: true,
}).Create(&run)
if result.Error != nil {
return nil, fmt.Errorf("创建审计归档账本失败: %w", result.Error)
}
if result.RowsAffected == 0 {
if err := s.db.WithContext(ctx).Where(
"source = ? AND archive_date = ? AND instance_id = ? AND schema_version = ?",
constants.AuditArchiveSource, start, s.instanceID, constants.AuditArchiveSchemaVersion,
).First(&run).Error; err != nil {
return nil, fmt.Errorf("读取审计归档账本失败: %w", err)
}
}
return &run, nil
}
func (s *Service) acquireRun(ctx context.Context, run *model.LogArchiveRun) (bool, error) {
revision := run.Revision
if run.Status == constants.ArchiveStatusFailed || run.Status == constants.ArchiveStatusSuccess || run.Status == constants.ArchiveStatusRunning {
revision++
}
now := time.Now()
staleBefore := now.Add(-3 * time.Hour)
result := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).
Where("id = ? AND (status <> ? OR updated_at < ?)", run.ID, constants.ArchiveStatusRunning, staleBefore).
Updates(map[string]any{
"status": constants.ArchiveStatusRunning, "revision": revision,
"attempt_count": gorm.Expr("attempt_count + 1"), "error_summary": "",
"completed_at": nil, "updated_at": now,
})
if result.Error != nil {
return false, fmt.Errorf("锁定审计归档任务失败: %w", result.Error)
}
if result.RowsAffected == 0 {
return false, nil
}
run.Revision = revision
run.Status = constants.ArchiveStatusRunning
return true, nil
}
func (s *Service) execute(ctx context.Context, run *model.LogArchiveRun) error {
file, err := s.buildArchiveFile(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
defer os.Remove(file.path)
dbEvents, dbResources, err := s.databaseCounts(ctx, run.RangeStart, run.RangeEnd)
if err != nil {
return err
}
if dbEvents != file.eventCount || dbResources != file.resourceCount {
return fmt.Errorf("审计归档生成期间数据数量发生变化")
}
objectKey, manifestKey := objectKeys(run.RangeStart, run.Revision)
metadata := archiveMetadata(file, run)
reader, err := os.Open(file.path)
if err != nil {
return fmt.Errorf("打开审计归档临时文件失败: %w", err)
}
uploadErr := s.store.UploadWithMetadata(ctx, objectKey, reader, "application/gzip", metadata)
closeErr := reader.Close()
if uploadErr != nil {
return fmt.Errorf("上传审计归档对象失败: %w", uploadErr)
}
if closeErr != nil {
return fmt.Errorf("关闭审计归档临时文件失败: %w", closeErr)
}
if err := s.verifyObject(ctx, objectKey, file.compressedBytes, metadata); err != nil {
return err
}
generatedAt := time.Now().In(s.location)
manifest := Manifest{
SchemaVersion: constants.AuditArchiveSchemaVersion, Source: constants.AuditArchiveSource,
ArchiveDate: run.RangeStart.In(s.location).Format(time.DateOnly), Timezone: constants.AuditArchiveTimezone,
RangeStart: run.RangeStart, RangeEnd: run.RangeEnd, InstanceID: s.instanceID,
EventCount: file.eventCount, ResourceCount: file.resourceCount,
UncompressedBytes: file.uncompressedBytes, CompressedBytes: file.compressedBytes,
ObjectKey: objectKey, SHA256: file.sha256, Revision: run.Revision,
GeneratedAt: generatedAt, Status: constants.ArchiveStatusSuccess,
}
manifestBytes, err := sonic.Marshal(manifest)
if err != nil {
return fmt.Errorf("序列化审计归档清单失败: %w", err)
}
manifestMetadata := map[string]string{"source": constants.AuditArchiveSource, "data-sha256": file.sha256, "revision": strconv.Itoa(run.Revision)}
if err := s.store.UploadWithMetadata(ctx, manifestKey, strings.NewReader(string(manifestBytes)), "application/json", manifestMetadata); err != nil {
return fmt.Errorf("上传审计归档清单失败: %w", err)
}
if err := s.verifyObject(ctx, manifestKey, int64(len(manifestBytes)), manifestMetadata); err != nil {
return err
}
completedAt := time.Now()
updates := map[string]any{
"status": constants.ArchiveStatusSuccess, "object_key": objectKey, "manifest_key": manifestKey,
"event_count": file.eventCount, "resource_count": file.resourceCount,
"uncompressed_bytes": file.uncompressedBytes, "compressed_bytes": file.compressedBytes,
"sha256": file.sha256, "generated_at": generatedAt, "completed_at": completedAt,
"updated_at": completedAt,
}
if err := s.db.WithContext(ctx).Model(&model.LogArchiveRun{}).Where("id = ?", run.ID).Updates(updates).Error; err != nil {
return fmt.Errorf("更新审计归档成功账本失败: %w", err)
}
return nil
}
func (s *Service) buildArchiveFile(ctx context.Context, start, end time.Time) (*archiveFile, error) {
temp, err := os.CreateTemp("", "audit-events-*.jsonl.gz")
if err != nil {
return nil, fmt.Errorf("创建审计归档临时文件失败: %w", err)
}
path := temp.Name()
failed := true
defer func() {
_ = temp.Close()
if failed {
_ = os.Remove(path)
}
}()
hasher := sha256.New()
gzipWriter := gzip.NewWriter(io.MultiWriter(temp, hasher))
result := &archiveFile{path: path}
var lastID uint
for {
var events []model.AuditEvent
if err := s.db.WithContext(ctx).Where("created_at >= ? AND created_at < ? AND id > ?", start, end, lastID).
Order("id ASC").Limit(archivePageSize).Find(&events).Error; err != nil {
return nil, fmt.Errorf("读取审计归档事件失败: %w", err)
}
if len(events) == 0 {
break
}
ids := make([]uint, 0, len(events))
for i := range events {
ids = append(ids, events[i].ID)
}
var resources []model.AuditEventResource
if err := s.db.WithContext(ctx).Where("audit_event_id IN ?", ids).
Order("audit_event_id ASC, sort_order ASC, id ASC").Find(&resources).Error; err != nil {
return nil, fmt.Errorf("读取审计归档资源失败: %w", err)
}
grouped := make(map[uint][]model.AuditEventResource, len(events))
for i := range resources {
resource := resources[i]
grouped[resource.AuditEventID] = append(grouped[resource.AuditEventID], resource)
}
for i := range events {
eventResources := grouped[events[i].ID]
if eventResources == nil {
eventResources = []model.AuditEventResource{}
}
line, marshalErr := sonic.Marshal(archiveLine{Event: events[i], Resources: eventResources})
if marshalErr != nil {
return nil, fmt.Errorf("序列化审计归档事件失败: %w", marshalErr)
}
line = append(line, '\n')
if _, writeErr := gzipWriter.Write(line); writeErr != nil {
return nil, fmt.Errorf("写入审计归档压缩流失败: %w", writeErr)
}
result.eventCount++
result.resourceCount += int64(len(grouped[events[i].ID]))
result.uncompressedBytes += int64(len(line))
}
lastID = events[len(events)-1].ID
}
if err := gzipWriter.Close(); err != nil {
return nil, fmt.Errorf("关闭审计归档压缩流失败: %w", err)
}
if err := temp.Close(); err != nil {
return nil, fmt.Errorf("关闭审计归档临时文件失败: %w", err)
}
info, err := os.Stat(path)
if err != nil {
return nil, fmt.Errorf("读取审计归档临时文件信息失败: %w", err)
}
result.compressedBytes = info.Size()
result.sha256 = fmt.Sprintf("%x", hasher.Sum(nil))
failed = false
return result, nil
}
func (s *Service) databaseCounts(ctx context.Context, start, end time.Time) (int64, int64, error) {
var eventCount int64
if err := s.db.WithContext(ctx).Model(&model.AuditEvent{}).
Where("created_at >= ? AND created_at < ?", start, end).Count(&eventCount).Error; err != nil {
return 0, 0, fmt.Errorf("统计审计归档事件失败: %w", err)
}
var resourceCount int64
subquery := s.db.Model(&model.AuditEvent{}).Select("id").Where("created_at >= ? AND created_at < ?", start, end)
if err := s.db.WithContext(ctx).Model(&model.AuditEventResource{}).
Where("audit_event_id IN (?)", subquery).Count(&resourceCount).Error; err != nil {
return 0, 0, fmt.Errorf("统计审计归档资源失败: %w", err)
}
return eventCount, resourceCount, nil
}
func (s *Service) validateSuccessfulRun(ctx context.Context, run *model.LogArchiveRun) (bool, error) {
events, resources, err := s.databaseCounts(ctx, run.RangeStart, run.RangeEnd)
if err != nil || events != run.EventCount || resources != run.ResourceCount {
return false, err
}
metadata := map[string]string{
"sha256": run.SHA256, "event-count": strconv.FormatInt(run.EventCount, 10),
"resource-count": strconv.FormatInt(run.ResourceCount, 10), "revision": strconv.Itoa(run.Revision),
}
if err := s.verifyObject(ctx, run.ObjectKey, run.CompressedBytes, metadata); err != nil {
return false, nil
}
manifestMetadata := map[string]string{
"source": constants.AuditArchiveSource, "data-sha256": run.SHA256, "revision": strconv.Itoa(run.Revision),
}
if err := s.verifyObject(ctx, run.ManifestKey, -1, manifestMetadata); err != nil {
return false, nil
}
return true, nil
}
func (s *Service) verifyObject(ctx context.Context, key string, expectedSize int64, expectedMetadata map[string]string) error {
object, err := s.store.Stat(ctx, key)
if err != nil {
return fmt.Errorf("复核归档对象 metadata 失败: %w", err)
}
if expectedSize >= 0 && object.Size != expectedSize {
return fmt.Errorf("归档对象大小复核失败")
}
for name, value := range expectedMetadata {
if object.Metadata[strings.ToLower(name)] != value {
return fmt.Errorf("归档对象 metadata 字段 %s 复核失败", name)
}
}
return nil
}
func (s *Service) markFailed(ctx context.Context, runID uint, archiveErr error) {
summary := []rune(archiveErr.Error())
if len(summary) > 500 {
summary = summary[:500]
}
failedCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
defer cancel()
_ = s.db.WithContext(failedCtx).Model(&model.LogArchiveRun{}).Where("id = ?", runID).Updates(map[string]any{
"status": constants.ArchiveStatusFailed, "error_summary": string(summary), "updated_at": time.Now(),
}).Error
}
func objectKeys(date time.Time, revision int) (string, string) {
prefix := fmt.Sprintf("audit-archive/v1/%04d/%02d/%02d", date.Year(), date.Month(), date.Day())
name := fmt.Sprintf("audit-events-%s-r%d", date.Format(time.DateOnly), revision)
return prefix + "/" + name + ".jsonl.gz", prefix + "/" + name + ".manifest.json"
}
func archiveMetadata(file *archiveFile, run *model.LogArchiveRun) map[string]string {
return map[string]string{
"schema-version": constants.AuditArchiveSchemaVersion,
"source": constants.AuditArchiveSource,
"archive-date": run.RangeStart.Format(time.DateOnly),
"timezone": constants.AuditArchiveTimezone,
"event-count": strconv.FormatInt(file.eventCount, 10),
"resource-count": strconv.FormatInt(file.resourceCount, 10),
"sha256": file.sha256,
"revision": strconv.Itoa(run.Revision),
}
}

View File

@@ -10,6 +10,7 @@ import (
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
@@ -42,11 +43,28 @@ type CacheInvalidator interface {
Invalidate(ctx context.Context, cardID uint)
}
// StateAudit 描述一次需要与卡事实关联保存的状态操作。
type StateAudit struct {
ActionCode string
Summary string
Card *model.IotCard
IntegrationID string
BeforeData map[string]any
AfterData map[string]any
}
// StateAuditWriter 在卡状态事务中追加统一 Audit Event。
type StateAuditWriter interface {
WriteCardStateAudit(ctx context.Context, tx *gorm.DB, input StateAudit) error
WriteCardStateFailure(ctx context.Context, input StateAudit, businessErr error)
}
// Service 负责卡实名观测的锁定、规则应用和可靠事件写入。
type Service struct {
db *gorm.DB
eventWriter EventWriter
cache CacheInvalidator
auditWriter StateAuditWriter
}
// NewService 创建卡实名观测应用服务。
@@ -54,12 +72,29 @@ func NewService(db *gorm.DB, eventWriter EventWriter, cache CacheInvalidator) *S
return &Service{db: db, eventWriter: eventWriter, cache: cache}
}
// SetStateAuditWriter 注入卡状态统一审计 Writer。
func (s *Service) SetStateAuditWriter(writer StateAuditWriter) {
s.auditWriter = writer
}
// RecordCarrierCallbackFailure 在已解析卡资源后记录运营商回调处理失败。
func (s *Service) RecordCarrierCallbackFailure(ctx context.Context, card *model.IotCard, integrationID string, businessErr error) {
if s == nil || s.auditWriter == nil || card == nil || card.ID == 0 {
return
}
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardRealnameCallbackSynced,
Summary: "运营商回调同步 IoT 卡实名状态失败", Card: card, IntegrationID: integrationID,
}, businessErr)
}
// ApplyCardObservation 在同一事务中应用实名状态、逆转窗口和状态变更事件。
func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.RealnameObservation) (domain.RealnameDecision, error) {
if s == nil || s.db == nil || s.eventWriter == nil {
return domain.RealnameDecision{}, errors.New(errors.CodeInternalError, "卡实名观测能力未完整配置")
}
var decision domain.RealnameDecision
var auditedCard *model.IotCard
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
@@ -68,6 +103,7 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡失败")
}
auditedCard = &card
nextDecision, decisionErr := domain.ApplyRealname(domain.CardRealnameSnapshot{
CardID: card.ID, Status: card.RealNameStatus, FirstRealnameAt: card.FirstRealnameAt,
ReversalCount: card.RealnameReversalCount, ReversalStartedAt: card.RealnameReversalStartedAt,
@@ -111,9 +147,83 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
return errors.Wrap(errors.CodeDatabaseError, err, "写入卡实名 Outbox 事件失败")
}
}
if observation.Metadata.Source == constants.CardObservationSourceManualOverride {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
summary := "人工更新 IoT 卡实名状态"
if !decision.StatusChanged {
summary = "人工确认 IoT 卡实名状态无需变化"
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardRealnameStatusUpdated,
Summary: summary,
Card: &card,
BeforeData: map[string]any{"real_name_status": card.RealNameStatus, "first_realname_at": card.FirstRealnameAt},
AfterData: map[string]any{
"real_name_status": decision.AfterStatus, "first_realname_at": firstRealnameAfter(card.FirstRealnameAt, observation.Metadata.ObservedAt, decision.FirstVerified),
"status_changed": decision.StatusChanged,
},
}); err != nil {
return err
}
} else if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && decision.StatusChanged && audited {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: actionCode,
Summary: "人工刷新 IoT 卡实名状态",
Card: &card,
BeforeData: map[string]any{"real_name_status": card.RealNameStatus, "first_realname_at": card.FirstRealnameAt},
AfterData: map[string]any{
"real_name_status": decision.AfterStatus, "first_realname_at": firstRealnameAfter(card.FirstRealnameAt, observation.Metadata.ObservedAt, decision.FirstVerified),
},
}); err != nil {
return err
}
} else if observation.Metadata.Source == constants.CardObservationSourceCarrierCallback && decision.StatusChanged {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardRealnameCallbackSynced,
Summary: "运营商回调同步 IoT 卡实名状态",
Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{"real_name_status": card.RealNameStatus, "first_realname_at": card.FirstRealnameAt},
AfterData: map[string]any{
"real_name_status": decision.AfterStatus, "first_realname_at": firstRealnameAfter(card.FirstRealnameAt, observation.Metadata.ObservedAt, decision.FirstVerified),
},
}); err != nil {
return err
}
} else if workerObservationAudited(ctx) && decision.StatusChanged {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerRealnameSynced,
Summary: "Worker 同步 IoT 卡实名事实", Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{"real_name_status": card.RealNameStatus, "first_realname_at": card.FirstRealnameAt},
AfterData: map[string]any{
"real_name_status": decision.AfterStatus, "first_realname_at": firstRealnameAfter(card.FirstRealnameAt, observation.Metadata.ObservedAt, decision.FirstVerified),
},
}); err != nil {
return err
}
}
return nil
})
if err != nil {
if workerObservationAudited(ctx) && auditedCard != nil && s.auditWriter != nil {
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerRealnameSynced,
Summary: "Worker 同步 IoT 卡实名事实失败", Card: auditedCard,
IntegrationID: observation.Metadata.ObservationID,
}, err)
}
return domain.RealnameDecision{}, err
}
if s.cache != nil {
@@ -122,6 +232,29 @@ func (s *Service) ApplyCardObservation(ctx context.Context, observation domain.R
return decision, nil
}
func firstRealnameAfter(before *time.Time, observedAt time.Time, firstVerified bool) *time.Time {
if firstVerified {
return &observedAt
}
return before
}
func manualRefreshAuditAction(ctx context.Context) (string, bool) {
switch auditcontext.From(ctx).ActorKind {
case constants.AuditActorAccount:
return constants.AuditActionIotCardManualRefreshed, true
case constants.AuditActorPersonalCustomer:
return constants.AuditActionIotCardPersonalRefreshed, true
default:
return "", false
}
}
func workerObservationAudited(ctx context.Context) bool {
linkage := auditcontext.From(ctx)
return linkage.ActorKind == constants.AuditActorSystemTask && linkage.Source == constants.AuditSourceWorker
}
func realnameChangedEventID(cardID uint, observationID string) string {
prefix := "card-realname:"
digest := sha256.Sum256([]byte(strconv.FormatUint(uint64(cardID), 10) + ":" + observationID + ":changed"))

View File

@@ -7,6 +7,7 @@ import (
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -32,6 +33,7 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
return domain.NetworkDecision{}, errors.New(errors.CodeInternalError, "卡网络观测能力未完整配置")
}
var decision domain.NetworkDecision
var auditedCard *model.IotCard
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
@@ -40,6 +42,7 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡网络事实失败")
}
auditedCard = &card
nextDecision, decisionErr := domain.ApplyNetwork(domain.CardNetworkSnapshot{
CardID: card.ID, NetworkStatus: card.NetworkStatus, StopReason: card.StopReason,
IsStandalone: card.IsStandalone, EnablePolling: card.EnablePolling,
@@ -74,21 +77,88 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "卡网络事实已被其他请求更新")
}
if !decision.StatusChanged {
return nil
if decision.StatusChanged {
eventID := "card-network:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":changed"
if err := s.eventWriter.AppendNetwork(ctx, tx, NetworkChangedEvent{
EventID: eventID, CardID: card.ID, BeforeStatus: card.NetworkStatus, AfterStatus: decision.AfterStatus,
GatewayExtend: decision.GatewayExtend, ObservedAt: observation.Metadata.ObservedAt,
Source: observation.Metadata.Source, Scene: observation.Metadata.Scene,
RequestID: observation.Metadata.RequestID, CorrelationID: observation.Metadata.CorrelationID,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入卡网络 Outbox 事件失败")
}
}
eventID := "card-network:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":changed"
if err := s.eventWriter.AppendNetwork(ctx, tx, NetworkChangedEvent{
EventID: eventID, CardID: card.ID, BeforeStatus: card.NetworkStatus, AfterStatus: decision.AfterStatus,
GatewayExtend: decision.GatewayExtend, ObservedAt: observation.Metadata.ObservedAt,
Source: observation.Metadata.Source, Scene: observation.Metadata.Scene,
RequestID: observation.Metadata.RequestID, CorrelationID: observation.Metadata.CorrelationID,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入卡网络 Outbox 事件失败")
stateChanged := decision.StatusChanged || decision.StopReasonChanged || decision.StopPolling ||
decision.GatewayExtend != card.GatewayExtend || decision.UpdateIMEI && decision.GatewayIMEI != card.GatewayCardIMEI
if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && stateChanged && audited {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
enablePolling := card.EnablePolling
if decision.StopPolling {
enablePolling = false
}
gatewayIMEI := card.GatewayCardIMEI
if decision.UpdateIMEI {
gatewayIMEI = decision.GatewayIMEI
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: actionCode,
Summary: "人工刷新 IoT 卡网络状态",
Card: &card,
BeforeData: map[string]any{
"network_status": card.NetworkStatus, "stop_reason": card.StopReason,
"gateway_extend": card.GatewayExtend, "gateway_card_imei": card.GatewayCardIMEI,
"enable_polling": card.EnablePolling,
},
AfterData: map[string]any{
"network_status": decision.AfterStatus, "stop_reason": decision.StopReason,
"gateway_extend": decision.GatewayExtend, "gateway_card_imei": gatewayIMEI,
"enable_polling": enablePolling,
},
}); err != nil {
return err
}
} else if workerObservationAudited(ctx) && stateChanged {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
enablePolling := card.EnablePolling
if decision.StopPolling {
enablePolling = false
}
gatewayIMEI := card.GatewayCardIMEI
if decision.UpdateIMEI {
gatewayIMEI = decision.GatewayIMEI
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerNetworkSynced,
Summary: "Worker 同步 IoT 卡网络事实", Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{
"network_status": card.NetworkStatus, "stop_reason": card.StopReason,
"gateway_extend": card.GatewayExtend, "gateway_card_imei": card.GatewayCardIMEI,
"enable_polling": card.EnablePolling,
},
AfterData: map[string]any{
"network_status": decision.AfterStatus, "stop_reason": decision.StopReason,
"gateway_extend": decision.GatewayExtend, "gateway_card_imei": gatewayIMEI,
"enable_polling": enablePolling,
},
}); err != nil {
return err
}
}
return nil
})
if err != nil {
if workerObservationAudited(ctx) && auditedCard != nil && s.auditWriter != nil {
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerNetworkSynced,
Summary: "Worker 同步 IoT 卡网络事实失败", Card: auditedCard,
IntegrationID: observation.Metadata.ObservationID,
}, err)
}
return domain.NetworkDecision{}, err
}
if s.cache != nil {

View File

@@ -7,6 +7,7 @@ import (
"github.com/google/uuid"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -21,6 +22,7 @@ type SeriesRequest struct {
Source string `json:"source"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
ParentEventID string `json:"parent_event_id,omitempty"`
}
// DeviceCardsSeriesRequest 描述需要在后台展开设备有效绑定卡的观测请求。
@@ -59,6 +61,7 @@ type SeriesTaskPayload struct {
Source string `json:"source"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
ParentEventID string `json:"parent_event_id,omitempty"`
}
// RunResult 描述一次实际 Gateway 请求及公共观测应用结果。
@@ -135,7 +138,7 @@ func (s *SeriesTrigger) trigger(ctx context.Context, request SeriesRequest, cand
if s == nil || s.coordinator == nil || s.scheduler == nil || s.logger == nil {
return "", false, errors.New(errors.CodeInternalError, "卡观测序列触发能力未完整配置")
}
request = normalizeSeriesTrace(request)
request = normalizeSeriesTrace(ctx, request)
if err := validateSeriesRequest(request); err != nil {
return "", false, err
}
@@ -171,6 +174,7 @@ func (s *SeriesTrigger) trigger(ctx context.Context, request SeriesRequest, cand
Scene: originalRequest.Scene, ResourceType: originalRequest.ResourceType, ResourceID: originalRequest.ResourceID,
SyncType: originalRequest.SyncType, ExpectedValue: originalRequest.ExpectedValue, Source: originalRequest.Source,
RequestID: originalRequest.RequestID, CorrelationID: originalRequest.CorrelationID,
ParentEventID: originalRequest.ParentEventID,
}
if err := s.scheduler.Enqueue(ctx, payload); err != nil {
s.coordinator.ReleaseSchedule(ctx, seriesID, attempt)
@@ -303,12 +307,24 @@ func validateSeriesPayload(payload SeriesTaskPayload) error {
return validateSeriesRequest(SeriesRequest{
Scene: payload.Scene, ResourceType: payload.ResourceType, ResourceID: payload.ResourceID,
SyncType: payload.SyncType, Source: payload.Source, RequestID: payload.RequestID, CorrelationID: payload.CorrelationID,
ParentEventID: payload.ParentEventID,
})
}
func normalizeSeriesTrace(request SeriesRequest) SeriesRequest {
func normalizeSeriesTrace(ctx context.Context, request SeriesRequest) SeriesRequest {
linkage := auditcontext.From(ctx)
request.RequestID = strings.TrimSpace(request.RequestID)
request.CorrelationID = strings.TrimSpace(request.CorrelationID)
request.ParentEventID = strings.TrimSpace(request.ParentEventID)
if request.RequestID == "" {
request.RequestID = strings.TrimSpace(linkage.RequestID)
}
if request.CorrelationID == "" {
request.CorrelationID = strings.TrimSpace(linkage.CorrelationID)
}
if request.ParentEventID == "" {
request.ParentEventID = strings.TrimSpace(linkage.ParentEventID)
}
if request.RequestID == "" && request.CorrelationID == "" {
traceID := uuid.NewString()
request.RequestID = traceID

View File

@@ -10,6 +10,7 @@ import (
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -31,6 +32,7 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai
return domain.TrafficDecision{}, errors.New(errors.CodeInternalError, "卡流量观测能力未完整配置")
}
var decision domain.TrafficDecision
var auditedCard *model.IotCard
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
@@ -39,6 +41,7 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡流量事实失败")
}
auditedCard = &card
nextDecision, decisionErr := domain.ApplyTraffic(domain.CardTrafficSnapshot{
CardID: card.ID, DataUsageMB: card.DataUsageMB, CurrentMonthUsageMB: card.CurrentMonthUsageMB,
CurrentMonthStartDate: card.CurrentMonthStartDate, LastMonthTotalMB: card.LastMonthTotalMB,
@@ -65,18 +68,71 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "卡流量基线已被其他请求更新")
}
if decision.IncrementMB <= 0 {
return nil
if decision.IncrementMB > 0 {
eventID := "card-traffic:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":incremented"
if err := s.eventWriter.AppendTraffic(ctx, tx, TrafficIncrementedEvent{
EventID: eventID, CardID: card.ID, IncrementMB: decision.IncrementMB,
ObservedAt: observation.Metadata.ObservedAt, Source: observation.Metadata.Source,
Scene: observation.Metadata.Scene, RequestID: observation.Metadata.RequestID,
CorrelationID: observation.Metadata.CorrelationID,
}); err != nil {
return err
}
}
eventID := "card-traffic:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":incremented"
return s.eventWriter.AppendTraffic(ctx, tx, TrafficIncrementedEvent{
EventID: eventID, CardID: card.ID, IncrementMB: decision.IncrementMB,
ObservedAt: observation.Metadata.ObservedAt, Source: observation.Metadata.Source,
Scene: observation.Metadata.Scene, RequestID: observation.Metadata.RequestID,
CorrelationID: observation.Metadata.CorrelationID,
})
stateChanged := decision.IncrementMB != 0 || decision.CrossMonth || decision.LastGatewayReadingMB != card.LastGatewayReadingMB
if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && stateChanged && audited {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: actionCode,
Summary: "人工刷新 IoT 卡流量",
Card: &card,
BeforeData: map[string]any{
"data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB,
"current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB,
"last_gateway_reading_mb": card.LastGatewayReadingMB,
},
AfterData: map[string]any{
"data_usage_mb": decision.DataUsageMB, "current_month_usage_mb": decision.CurrentMonthUsageMB,
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
"last_gateway_reading_mb": decision.LastGatewayReadingMB, "increment_mb": decision.IncrementMB,
},
}); err != nil {
return err
}
} else if workerObservationAudited(ctx) && stateChanged {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerTrafficSynced,
Summary: "Worker 同步 IoT 卡流量事实", Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{
"data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB,
"current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB,
"last_gateway_reading_mb": card.LastGatewayReadingMB,
},
AfterData: map[string]any{
"data_usage_mb": decision.DataUsageMB, "current_month_usage_mb": decision.CurrentMonthUsageMB,
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
"last_gateway_reading_mb": decision.LastGatewayReadingMB, "increment_mb": decision.IncrementMB,
},
}); err != nil {
return err
}
}
return nil
})
if err != nil {
if workerObservationAudited(ctx) && auditedCard != nil && s.auditWriter != nil {
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerTrafficSynced,
Summary: "Worker 同步 IoT 卡流量事实失败", Card: auditedCard,
IntegrationID: observation.Metadata.ObservationID,
}, err)
}
return domain.TrafficDecision{}, err
}
if s.cache != nil {

View File

@@ -8,7 +8,9 @@ import (
"github.com/bytedance/sonic"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
notificationinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification"
"github.com/break/junhong_cmp_fiber/internal/model"
@@ -53,19 +55,24 @@ type deliveryRequest struct {
// DeliveryService 校验接收人并幂等生成站内通知。
type DeliveryService struct {
repository *notificationinfra.Repository
registry *notificationinfra.Registry
resolver DynamicRecipientResolver
logger *zap.Logger
now func() time.Time
repository *notificationinfra.Repository
registry *notificationinfra.Registry
resolver DynamicRecipientResolver
logger *zap.Logger
auditWriter *audit.Writer
now func() time.Time
}
// NewDeliveryService 创建站内通知投递用例。
func NewDeliveryService(repository *notificationinfra.Repository, registry *notificationinfra.Registry, resolver DynamicRecipientResolver, logger *zap.Logger) *DeliveryService {
func NewDeliveryService(repository *notificationinfra.Repository, registry *notificationinfra.Registry, resolver DynamicRecipientResolver, logger *zap.Logger, auditWriters ...*audit.Writer) *DeliveryService {
if logger == nil {
logger = zap.NewNop()
}
return &DeliveryService{repository: repository, registry: registry, resolver: resolver, logger: logger, now: time.Now}
service := &DeliveryService{repository: repository, registry: registry, resolver: resolver, logger: logger, now: time.Now}
if len(auditWriters) > 0 {
service.auditWriter = auditWriters[0]
}
return service
}
// Consume 消费明确或动态接收人通知事件;所有可恢复错误交给 Asynq 重试策略处理。
@@ -150,6 +157,9 @@ func validateDeliveryRequest(request deliveryRequest) error {
}
func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind string, recipientIDs []uint, request deliveryRequest) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
}
rendered, err := s.registry.Render(request.notificationType, request.templateData, request.refType, recipientKind)
if err != nil {
s.logger.Error("站内通知模板校验失败",
@@ -182,7 +192,23 @@ func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind st
RefType: request.refType, RefID: request.refID, RefKey: request.refKey,
ExpiresAt: expiresAt, CreatedAt: now,
}
created, err := s.repository.CreateIdempotent(ctx, notification)
created := false
err = s.repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var createErr error
created, createErr = s.repository.WithTx(tx).CreateIdempotent(ctx, notification)
if createErr != nil || !created {
return createErr
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "delivered"),
ActionCode: constants.AuditActionNotificationDelivered, Summary: "生成站内通知",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
Metadata: map[string]any{"outbox_event_id": eventID},
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
nil, map[string]any{"created": true, "is_read": false})},
})
})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入站内通知失败")
}

View File

@@ -2,10 +2,15 @@ package notification
import (
"context"
stderrors "errors"
"strconv"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -14,29 +19,24 @@ import (
// ReadService 执行后台账号与个人客户的幂等已读事务脚本。
type ReadService struct {
db *gorm.DB
now func() time.Time
db *gorm.DB
auditWriter *audit.Writer
now func() time.Time
}
// NewReadService 创建单条已读用例。
func NewReadService(db *gorm.DB) *ReadService {
return &ReadService{db: db, now: time.Now}
func NewReadService(db *gorm.DB, auditWriters ...*audit.Writer) *ReadService {
service := &ReadService{db: db, now: time.Now}
if len(auditWriters) > 0 {
service.auditWriter = auditWriters[0]
}
return service
}
// MarkRead 仅首次更新当前接收人的未过期未读通知。
func (s *ReadService) MarkRead(ctx context.Context, recipientID, notificationID uint) error {
if recipientID == 0 || notificationID == 0 {
return errors.New(errors.CodeInvalidParam)
}
now := s.now().UTC()
result := s.db.WithContext(ctx).Model(&model.Notification{}).
Where("id = ? AND recipient_kind = ? AND recipient_id = ? AND is_read = ? AND (expires_at IS NULL OR expires_at > ?)",
notificationID, constants.NotificationRecipientKindAccount, recipientID, false, now).
Updates(map[string]any{"is_read": true, "read_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新通知已读状态失败")
}
return nil
return s.markOneRead(ctx, constants.NotificationRecipientKindAccount, recipientID, notificationID,
constants.AuditActorAccount, constants.AuditSourceAdminAPI)
}
// MarkAllRead 将当前后台账号全部或指定类别的未过期通知幂等标记为已读。
@@ -44,18 +44,12 @@ func (s *ReadService) MarkAllRead(ctx context.Context, recipientID uint, request
if recipientID == 0 || !isReadAllCategory(request.Category) {
return nil, errors.New(errors.CodeInvalidParam)
}
now := s.now().UTC()
db := s.db.WithContext(ctx).Model(&model.Notification{}).
Where("recipient_kind = ? AND recipient_id = ? AND is_read = ? AND (expires_at IS NULL OR expires_at > ?)",
constants.NotificationRecipientKindAccount, recipientID, false, now)
if request.Category != "" {
db = db.Where("category = ?", request.Category)
count, err := s.markAllRead(ctx, constants.NotificationRecipientKindAccount, recipientID, request.Category,
constants.AuditActorAccount, constants.AuditSourceAdminAPI)
if err != nil {
return nil, err
}
result := db.Updates(map[string]any{"is_read": true, "read_at": now})
if result.Error != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新通知已读状态失败")
}
return &dto.NotificationReadAllResponse{UpdatedCount: result.RowsAffected}, nil
return &dto.NotificationReadAllResponse{UpdatedCount: count}, nil
}
func isReadAllCategory(category string) bool {
@@ -70,17 +64,8 @@ func isReadAllCategory(category string) bool {
// MarkPersonalRead 仅首次更新当前个人客户可见的未过期未读通知。
func (s *ReadService) MarkPersonalRead(ctx context.Context, customerID, notificationID uint) error {
if customerID == 0 || notificationID == 0 {
return errors.New(errors.CodeInvalidParam)
}
now := s.now().UTC()
result := personalReadScope(s.db.WithContext(ctx).Model(&model.Notification{}), customerID, now).
Where("id = ? AND is_read = ?", notificationID, false).
Updates(map[string]any{"is_read": true, "read_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新个人客户通知已读状态失败")
}
return nil
return s.markOneRead(ctx, constants.NotificationRecipientKindPersonalCustomer, customerID, notificationID,
constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI)
}
// MarkAllPersonalRead 将当前个人客户可见的全部未过期通知幂等标记为已读。
@@ -88,14 +73,139 @@ func (s *ReadService) MarkAllPersonalRead(ctx context.Context, customerID uint)
if customerID == 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
now := s.now().UTC()
result := personalReadScope(s.db.WithContext(ctx).Model(&model.Notification{}), customerID, now).
Where("is_read = ?", false).
Updates(map[string]any{"is_read": true, "read_at": now})
if result.Error != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新个人客户通知已读状态失败")
count, err := s.markAllRead(ctx, constants.NotificationRecipientKindPersonalCustomer, customerID, "",
constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI)
if err != nil {
return nil, err
}
return &dto.NotificationReadAllResponse{UpdatedCount: result.RowsAffected}, nil
return &dto.NotificationReadAllResponse{UpdatedCount: count}, nil
}
func (s *ReadService) markOneRead(ctx context.Context, recipientKind string, recipientID, notificationID uint, actorKind, source string) error {
if recipientID == 0 || notificationID == 0 {
return errors.New(errors.CodeInvalidParam)
}
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
}
now := s.now().UTC()
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var notification model.Notification
query := readScope(tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}), recipientKind, recipientID, now).
Where("id = ? AND is_read = ?", notificationID, false).Take(&notification)
if stderrors.Is(query.Error, gorm.ErrRecordNotFound) {
return nil
}
if query.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, query.Error, "查询通知已读状态失败")
}
if err := tx.WithContext(ctx).Model(&model.Notification{}).Where("id = ? AND is_read = ?", notification.ID, false).
Updates(map[string]any{"is_read": true, "read_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新通知已读状态失败")
}
return s.appendReadAudit(ctx, tx, &notification, now, actorKind, source, "")
})
}
func (s *ReadService) markAllRead(ctx context.Context, recipientKind string, recipientID uint, category, actorKind, source string) (int64, error) {
if s.auditWriter == nil {
return 0, errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置")
}
now := s.now().UTC()
var updated int64
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var notifications []*model.Notification
query := readScope(tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}), recipientKind, recipientID, now).
Where("is_read = ?", false)
if category != "" {
query = query.Where("category = ?", category)
}
if err := query.Order("id ASC").Find(&notifications).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询批量通知已读状态失败")
}
if len(notifications) == 0 {
return nil
}
ids := make([]uint, 0, len(notifications))
for _, notification := range notifications {
ids = append(ids, notification.ID)
}
result := tx.WithContext(ctx).Model(&model.Notification{}).Where("id IN ? AND is_read = ?", ids, false).
Updates(map[string]any{"is_read": true, "read_at": now})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新通知已读状态失败")
}
if result.RowsAffected != int64(len(notifications)) {
return errors.New(errors.CodeInvalidStatus, "通知已读状态发生并发变化")
}
updated = result.RowsAffected
return s.appendReadAllAudit(ctx, tx, notifications, now, recipientKind, recipientID, category, actorKind, source)
})
return updated, err
}
func (s *ReadService) appendReadAudit(ctx context.Context, tx *gorm.DB, notification *model.Notification, now time.Time, actorKind, source, parentEventID string) error {
scopeType, scopeID := notificationScope(notification.RecipientKind, notification.RecipientID)
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "read"),
ActionCode: constants.AuditActionNotificationRead, Summary: "标记通知已读",
Actor: audit.ActorInput{Kind: actorKind, ID: strconv.FormatUint(uint64(notification.RecipientID), 10)},
Source: source, ScopeType: scopeType, ScopeID: scopeID,
Result: constants.AuditResultSuccess, ParentEventID: parentEventID,
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
map[string]any{"is_read": false}, map[string]any{"is_read": true, "read_at": now})},
})
}
func (s *ReadService) appendReadAllAudit(ctx context.Context, tx *gorm.DB, notifications []*model.Notification, now time.Time, recipientKind string, recipientID uint, category, actorKind, source string) error {
rootID := "evt_" + uuid.NewString()
actor := audit.ActorInput{Kind: actorKind, ID: strconv.FormatUint(uint64(recipientID), 10)}
scopeType, scopeID := notificationScope(recipientKind, recipientID)
children := make([]audit.AppendInput, 0, len(notifications))
for _, notification := range notifications {
children = append(children, audit.AppendInput{
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "read"),
ActionCode: constants.AuditActionNotificationRead, Summary: "批量标记通知已读",
Actor: actor, Source: source, ScopeType: scopeType, ScopeID: scopeID, Result: constants.AuditResultSuccess,
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
map[string]any{"is_read": false}, map[string]any{"is_read": true, "read_at": now})},
})
}
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
Root: audit.AppendInput{
EventID: rootID, ActionCode: constants.AuditActionNotificationReadAll, Summary: "批量标记通知已读",
Actor: actor, Source: source, ScopeType: scopeType, ScopeID: scopeID, Result: constants.AuditResultSuccess,
BatchTotal: len(notifications), SuccessCount: len(notifications),
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceNotificationReadBatch, Key: rootID, DisplayName: "通知批量已读",
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchTask,
IdentitySnapshot: map[string]any{
"recipient_kind": recipientKind, "recipient_id": recipientID,
"category": category, "updated_count": len(notifications),
}, SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
},
Children: children,
})
}
func notificationScope(recipientKind string, recipientID uint) (string, string) {
if recipientKind == constants.NotificationRecipientKindPersonalCustomer {
return constants.AuditScopePersonalCustomer, strconv.FormatUint(uint64(recipientID), 10)
}
return constants.AuditScopePlatform, ""
}
func readScope(db *gorm.DB, recipientKind string, recipientID uint, now time.Time) *gorm.DB {
if recipientKind == constants.NotificationRecipientKindPersonalCustomer {
return personalReadScope(db, recipientID, now)
}
return db.Model(&model.Notification{}).Where(
"recipient_kind = ? AND recipient_id = ? AND (expires_at IS NULL OR expires_at > ?)",
recipientKind, recipientID, now,
)
}
func personalReadScope(db *gorm.DB, customerID uint, now time.Time) *gorm.DB {

View File

@@ -4,6 +4,7 @@ package outbox
import (
"context"
stderrors "errors"
"strconv"
"time"
"github.com/google/uuid"
@@ -11,6 +12,7 @@ import (
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -33,6 +35,30 @@ type RecoveryAudit struct {
BatchID string
RequestID string
CorrelationID string
Events []RecoveryEventAudit
Result string
ErrorCode string
ErrorSummary string
}
// RecoveryEventAudit 是一次人工恢复中单个 Outbox 事件的身份和状态变化。
type RecoveryEventAudit struct {
ID uint
EventID string
EventType string
AggregateType string
AggregateID string
ResourceType string
ResourceID string
BusinessKey string
BeforeStatus int
AfterStatus int
BeforeNextAttempt time.Time
AfterNextAttempt time.Time
BeforeLeaseOwner *string
BeforeLeaseExpires *time.Time
AfterLeaseOwner *string
AfterLeaseExpires *time.Time
}
// AuditWriter 是 tech-global-audit 提供实现的统一审计接缝。
@@ -65,23 +91,38 @@ func (s *RecoveryService) Replay(ctx context.Context, operator Operator, ids []u
}
batchID := uuid.NewString()
now := s.now().UTC()
failureAudit := RecoveryAudit{
OperatorID: operator.ID, OperationType: constants.AuditOperationOutboxReplay,
Description: "人工重放 Outbox 事件失败", Reason: reason, BatchID: batchID,
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID,
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
events, err := loadSelectedForUpdate(tx, ids)
if err != nil {
return err
}
failureAudit.EventIDs, failureAudit.Events = unchangedRecoveryAudit(events)
if len(events) != len(ids) {
failureAudit.Events = nil
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的事件不存在或状态不允许重放")
}
eventIDs := make([]string, 0, len(events))
auditEvents := make([]RecoveryEventAudit, 0, len(events))
for _, event := range events {
allowed := event.Status == constants.OutboxStatusFailed ||
(event.Status == constants.OutboxStatusDelivering && event.LeaseExpiresAt != nil && !event.LeaseExpiresAt.After(now))
if !allowed {
failureAudit.Result = constants.AuditResultDenied
failureAudit.ErrorCode = strconv.Itoa(pkgerrors.CodeInvalidStatus)
failureAudit.ErrorSummary = "选择的事件不存在或状态不允许重放"
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的事件不存在或状态不允许重放")
}
eventIDs = append(eventIDs, event.EventID)
auditEvents = append(auditEvents, recoveryEventAudit(event, now))
}
failureAudit.Result = constants.AuditResultFailed
failureAudit.ErrorCode = strconv.Itoa(pkgerrors.CodeDatabaseError)
failureAudit.ErrorSummary = "Outbox 人工重放事务已回滚"
result := tx.Model(&model.OutboxEvent{}).Where("id IN ?", ids).Updates(map[string]any{
"status": constants.OutboxStatusPending, "next_attempt_at": now,
"lease_owner": nil, "lease_expires_at": nil, "updated_at": now,
@@ -90,11 +131,14 @@ func (s *RecoveryService) Replay(ctx context.Context, operator Operator, ids []u
return result.Error
}
return s.audit.WriteRecovery(ctx, tx, RecoveryAudit{
OperatorID: operator.ID, OperationType: "outbox_replay", Description: "人工重放 Outbox 事件",
OperatorID: operator.ID, OperationType: constants.AuditOperationOutboxReplay, Description: "人工重放 Outbox 事件",
EventIDs: eventIDs, Reason: reason, BatchID: batchID,
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID,
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID, Events: auditEvents,
})
})
if err != nil {
s.recordFailure(ctx, failureAudit)
}
return batchID, err
}
@@ -105,21 +149,36 @@ func (s *RecoveryService) ReleaseExpiredLeases(ctx context.Context, operator Ope
}
batchID := uuid.NewString()
now := s.now().UTC()
failureAudit := RecoveryAudit{
OperatorID: operator.ID, OperationType: constants.AuditOperationOutboxReleaseExpiredLease,
Description: "人工释放 Outbox 过期租约失败", Reason: reason, BatchID: batchID,
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID,
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
events, err := loadSelectedForUpdate(tx, ids)
if err != nil {
return err
}
failureAudit.EventIDs, failureAudit.Events = unchangedRecoveryAudit(events)
if len(events) != len(ids) {
failureAudit.Events = nil
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的租约不存在或仍然有效")
}
eventIDs := make([]string, 0, len(events))
auditEvents := make([]RecoveryEventAudit, 0, len(events))
for _, event := range events {
if event.Status != constants.OutboxStatusDelivering || event.LeaseExpiresAt == nil || event.LeaseExpiresAt.After(now) {
failureAudit.Result = constants.AuditResultDenied
failureAudit.ErrorCode = strconv.Itoa(pkgerrors.CodeInvalidStatus)
failureAudit.ErrorSummary = "选择的租约不存在或仍然有效"
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "选择的租约不存在或仍然有效")
}
eventIDs = append(eventIDs, event.EventID)
auditEvents = append(auditEvents, recoveryEventAudit(event, now))
}
failureAudit.Result = constants.AuditResultFailed
failureAudit.ErrorCode = strconv.Itoa(pkgerrors.CodeDatabaseError)
failureAudit.ErrorSummary = "Outbox 过期租约释放事务已回滚"
result := tx.Model(&model.OutboxEvent{}).Where("id IN ? AND status = ? AND lease_expires_at <= ?", ids, constants.OutboxStatusDelivering, now).
Updates(map[string]any{
"status": constants.OutboxStatusPending, "next_attempt_at": now,
@@ -129,14 +188,31 @@ func (s *RecoveryService) ReleaseExpiredLeases(ctx context.Context, operator Ope
return result.Error
}
return s.audit.WriteRecovery(ctx, tx, RecoveryAudit{
OperatorID: operator.ID, OperationType: "outbox_release_expired_lease", Description: "人工释放 Outbox 过期租约",
OperatorID: operator.ID, OperationType: constants.AuditOperationOutboxReleaseExpiredLease, Description: "人工释放 Outbox 过期租约",
EventIDs: eventIDs, Reason: reason, BatchID: batchID,
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID,
RequestID: operator.RequestID, CorrelationID: operator.CorrelationID, Events: auditEvents,
})
})
if err != nil {
s.recordFailure(ctx, failureAudit)
}
return batchID, err
}
func (s *RecoveryService) recordFailure(ctx context.Context, audit RecoveryAudit) {
if len(audit.Events) == 0 || audit.Result == "" {
return
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.audit.WriteRecovery(ctx, tx, audit)
})
if err != nil {
auditfailure.RecordSecondaryWriteFailure(
audit.OperationType, audit.Events[0].EventID, audit.RequestID, audit.CorrelationID, audit.ErrorCode, err,
)
}
}
func validateCommand(operator Operator, ids []uint, reason string) error {
if !operator.SuperAdmin {
return pkgerrors.New(pkgerrors.CodeForbidden)
@@ -152,3 +228,28 @@ func loadSelectedForUpdate(tx *gorm.DB, ids []uint) ([]model.OutboxEvent, error)
err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id IN ?", ids).Order("id ASC").Find(&events).Error
return events, err
}
func recoveryEventAudit(event model.OutboxEvent, nextAttempt time.Time) RecoveryEventAudit {
return RecoveryEventAudit{
ID: event.ID, EventID: event.EventID, EventType: event.EventType,
AggregateType: event.AggregateType, AggregateID: event.AggregateID,
ResourceType: event.ResourceType, ResourceID: event.ResourceID, BusinessKey: event.BusinessKey,
BeforeStatus: event.Status, AfterStatus: constants.OutboxStatusPending,
BeforeNextAttempt: event.NextAttemptAt, AfterNextAttempt: nextAttempt,
BeforeLeaseOwner: event.LeaseOwner, BeforeLeaseExpires: event.LeaseExpiresAt,
}
}
func unchangedRecoveryAudit(events []model.OutboxEvent) ([]string, []RecoveryEventAudit) {
eventIDs := make([]string, 0, len(events))
auditEvents := make([]RecoveryEventAudit, 0, len(events))
for _, event := range events {
eventIDs = append(eventIDs, event.EventID)
auditEvent := recoveryEventAudit(event, event.NextAttemptAt)
auditEvent.AfterStatus = event.Status
auditEvent.AfterLeaseOwner = event.LeaseOwner
auditEvent.AfterLeaseExpires = event.LeaseExpiresAt
auditEvents = append(auditEvents, auditEvent)
}
return eventIDs, auditEvents
}

View File

@@ -18,9 +18,23 @@ import (
// CreateCommand 描述已通过订单与金额校验的退款审批申请。
type CreateCommand struct {
Refund *model.RefundRequest
Order *model.Order
SubmitterAccountID uint
}
// ApplicationAudit 描述退款申请、审批、订单和提交人的同事务审计事实。
type ApplicationAudit struct {
Refund *model.RefundRequest
Order *model.Order
Approval *model.ApprovalInstance
Submitter *model.Account
}
// AuditWriter 接收退款申请事务内审计事实。
type AuditWriter interface {
WriteRefundApplication(ctx context.Context, tx *gorm.DB, audit ApplicationAudit) error
}
// CreateResult 返回原子保存后的退款申请和初始审批状态。
type CreateResult struct {
Refund *model.RefundRequest
@@ -32,19 +46,20 @@ type CreateResult struct {
type CreationService struct {
db *gorm.DB
approval approvalapp.Port
audit AuditWriter
}
// NewCreationService 创建退款审批申请用例。
func NewCreationService(db *gorm.DB, approval approvalapp.Port) *CreationService {
return &CreationService{db: db, approval: approval}
func NewCreationService(db *gorm.DB, approval approvalapp.Port, audit AuditWriter) *CreationService {
return &CreationService{db: db, approval: approval, audit: audit}
}
// Execute 在业务写入前校验审批渠道,并在同一事务冻结退款事实和审批事实。
func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*CreateResult, error) {
if s == nil || s.db == nil || s.approval == nil {
if s == nil || s.db == nil || s.approval == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置")
}
if command.Refund == nil || command.Refund.OrderID == 0 || command.SubmitterAccountID == 0 ||
if command.Refund == nil || command.Order == nil || command.Refund.OrderID == 0 || command.Order.ID != command.Refund.OrderID || command.SubmitterAccountID == 0 ||
command.Refund.Creator != command.SubmitterAccountID || strings.TrimSpace(command.Refund.RefundNo) == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
@@ -100,7 +115,13 @@ func (s *CreationService) Execute(ctx context.Context, command CreateCommand) (*
}
command.Refund.ApprovalInstanceID = &reference.InstanceID
approvalStatus = reference.Status
return nil
var approval model.ApprovalInstance
if err := tx.WithContext(ctx).First(&approval, reference.InstanceID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败")
}
return s.audit.WriteRefundApplication(ctx, tx, ApplicationAudit{
Refund: command.Refund, Order: command.Order, Approval: &approval, Submitter: account,
})
})
if err != nil {
return nil, err

View File

@@ -3,7 +3,9 @@ package role
import (
"context"
stdErrors "errors"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
@@ -20,11 +22,12 @@ type PermissionChecker interface {
type DefaultCreditService struct {
db *gorm.DB
permissionChecker PermissionChecker
accessAudit accessauditapp.Writer
}
// NewDefaultCreditService 创建角色默认信用模板服务。
func NewDefaultCreditService(db *gorm.DB, permissionChecker PermissionChecker) *DefaultCreditService {
return &DefaultCreditService{db: db, permissionChecker: permissionChecker}
func NewDefaultCreditService(db *gorm.DB, permissionChecker PermissionChecker, accessAudit accessauditapp.Writer) *DefaultCreditService {
return &DefaultCreditService{db: db, permissionChecker: permissionChecker, accessAudit: accessAudit}
}
// Update 更新模板;该操作不扫描或修改任何既有钱包。
@@ -39,8 +42,8 @@ func (s *DefaultCreditService) Update(ctx context.Context, roleID uint, enabled
if err := validateDefaultCredit(enabled, limit); err != nil {
return nil, err
}
var role model.Role
var beforeData map[string]any
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Clauses().First(&role, roleID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
@@ -48,6 +51,7 @@ func (s *DefaultCreditService) Update(ctx context.Context, roleID uint, enabled
}
return errors.Wrap(errors.CodeInternalError, err, "读取角色失败")
}
beforeData = defaultCreditAuditData(&role)
if role.RoleType != constants.RoleTypeCustomer {
return errors.New(errors.CodeInvalidParam, "只有客户角色可以配置新建代理默认信用")
}
@@ -68,14 +72,46 @@ func (s *DefaultCreditService) Update(ctx context.Context, roleID uint, enabled
role.DefaultCreditEnabled = enabled
role.DefaultCreditLimit = limit
role.Updater = operatorID
return nil
if s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "角色默认信用审计接缝未配置")
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionRoleDefaultCreditUpdated, Summary: "更新角色默认信用模板", Result: constants.AuditResultSuccess,
OperatorID: operatorID, Role: &role, BeforeData: beforeData, AfterData: defaultCreditAuditData(&role),
})
})
if err != nil {
if role.ID != 0 {
accessauditapp.RecordFailure(ctx, s.db, s.accessAudit, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionRoleDefaultCreditUpdated, Summary: "更新角色默认信用模板失败",
Result: defaultCreditAuditResult(err), OperatorID: operatorID, Role: &role, BeforeData: beforeData,
}, err)
}
return nil, err
}
return &role, nil
}
func defaultCreditAuditData(role *model.Role) map[string]any {
return map[string]any{
"role_name": role.RoleName, "role_type": role.RoleType,
"default_credit_enabled": role.DefaultCreditEnabled, "default_credit_limit": role.DefaultCreditLimit,
}
}
func defaultCreditAuditResult(err error) string {
var appErr *errors.AppError
if !stdErrors.As(err, &appErr) {
return constants.AuditResultFailed
}
switch appErr.Code {
case errors.CodeInternalError, errors.CodeDatabaseError, errors.CodeRedisError:
return constants.AuditResultFailed
default:
return constants.AuditResultDenied
}
}
func (s *DefaultCreditService) authorize(ctx context.Context, operatorID uint) error {
userType := middleware.GetUserTypeFromContext(ctx)
if userType == constants.UserTypeSuperAdmin {

View File

@@ -3,11 +3,13 @@ package shop
import (
"context"
stderrors "errors"
"strings"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -17,12 +19,13 @@ import (
// CreateService 收口平台与代理创建店铺的完整事务。
type CreateService struct {
db *gorm.DB
db *gorm.DB
audit accessauditapp.Writer
}
// NewCreateService 创建店铺创建事务脚本。
func NewCreateService(db *gorm.DB) *CreateService {
return &CreateService{db: db}
func NewCreateService(db *gorm.DB, audit accessauditapp.Writer) *CreateService {
return &CreateService{db: db, audit: audit}
}
// Create 按操作者类型执行平台显式归属或代理安全继承。
@@ -37,26 +40,26 @@ func (s *CreateService) Create(ctx context.Context, request *dto.CreateShopReque
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
case constants.UserTypeAgent:
if request.BusinessOwnerAccountIDSet {
return nil, errors.New(errors.CodeForbidden, "无权限设置店铺业务员")
return s.fail(ctx, request, errors.New(errors.CodeForbidden, "无权限设置店铺业务员"))
}
if request.ParentID == nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
return s.fail(ctx, request, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在"))
}
if err := middleware.CanManageShop(ctx, *request.ParentID); err != nil {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
return s.fail(ctx, request, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在"))
}
resolver = resolveInheritedBusinessOwner
default:
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
return s.fail(ctx, request, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在"))
}
hashedPassword, err := bcrypt.GenerateFromPassword([]byte(request.InitPassword), bcrypt.DefaultCost)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败")
return s.fail(ctx, request, errors.Wrap(errors.CodeInternalError, err, "密码哈希失败"))
}
var response *dto.ShopResponse
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
created, createErr := createShop(tx, request, operatorID, string(hashedPassword), resolver)
created, createErr := createShop(ctx, tx, request, operatorID, string(hashedPassword), resolver, s.audit)
if createErr != nil {
return createErr
}
@@ -64,14 +67,14 @@ func (s *CreateService) Create(ctx context.Context, request *dto.CreateShopReque
return nil
})
if err != nil {
return nil, err
return s.fail(ctx, request, err)
}
return response, nil
}
type businessOwnerResolver func(*gorm.DB, *dto.CreateShopRequest, *model.Shop) (*uint, error)
func createShop(tx *gorm.DB, request *dto.CreateShopRequest, operatorID uint, hashedPassword string, resolveOwner businessOwnerResolver) (*dto.ShopResponse, error) {
func createShop(ctx context.Context, tx *gorm.DB, request *dto.CreateShopRequest, operatorID uint, hashedPassword string, resolveOwner businessOwnerResolver, audit accessauditapp.Writer) (*dto.ShopResponse, error) {
if exists, err := recordExists(tx, &model.Shop{}, "shop_code = ?", request.ShopCode); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "校验店铺编号失败")
} else if exists {
@@ -157,9 +160,77 @@ func createShop(tx *gorm.DB, request *dto.CreateShopRequest, operatorID uint, ha
if err := fillBusinessOwnerResponse(tx, shop, response); err != nil {
return nil, err
}
if audit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "店铺创建审计接缝未配置")
}
if err := audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopCreated, Summary: "创建店铺",
OperatorID: operatorID, Shop: shop, ParentShop: parent,
AfterData: shopCreationData(shop),
}); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "写入店铺创建审计失败")
}
if ownerID != nil {
if err := audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopBusinessOwnerUpdated, Summary: "设置店铺业务员归属",
OperatorID: operatorID, Shop: shop, ParentShop: parent,
Accounts: businessOwnerAuditAccounts(tx, nil, ownerID),
AfterData: map[string]any{"business_owner_account_id": ownerID},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "店铺业务员归属已设置",
}); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "写入店铺业务员审计失败")
}
}
return response, nil
}
func (s *CreateService) fail(ctx context.Context, request *dto.CreateShopRequest, originalErr error) (*dto.ShopResponse, error) {
shop := &model.Shop{ShopName: request.ShopName, ShopCode: request.ShopCode, ParentID: request.ParentID}
var parent *model.Shop
if request.ParentID != nil {
parent = &model.Shop{}
parent.ID = *request.ParentID
}
accessauditapp.RecordFailure(ctx, s.db, s.audit, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopCreated, Summary: "创建店铺失败", Result: shopAuditFailureResult(originalErr),
OperatorID: middleware.GetUserIDFromContext(ctx), Shop: shop, ParentShop: parent,
}, originalErr)
return nil, originalErr
}
func shopCreationData(shop *model.Shop) map[string]any {
data := shopProfileData(shop)
data["shop_code"] = shop.ShopCode
data["parent_id"] = shop.ParentID
data["level"] = shop.Level
return data
}
func shopProfileData(shop *model.Shop) map[string]any {
return map[string]any{
"shop_name": shop.ShopName, "contact_name": shop.ContactName, "contact_phone": shop.ContactPhone,
"province": shop.Province, "city": shop.City, "district": shop.District, "address": shop.Address,
}
}
func shopProfileChanged(before, after *model.Shop) bool {
return before.ShopName != after.ShopName || before.ContactName != after.ContactName ||
before.ContactPhone != after.ContactPhone || before.Province != after.Province || before.City != after.City ||
before.District != after.District || before.Address != after.Address
}
func shopAuditFailureResult(err error) string {
var appErr *errors.AppError
if stderrors.As(err, &appErr) {
switch appErr.Code {
case errors.CodeForbidden, errors.CodeInvalidParam, errors.CodeNotFound, errors.CodeInvalidParentID,
errors.CodeShopLevelExceeded, errors.CodeShopCodeExists, errors.CodeUsernameExists, errors.CodePhoneExists:
return constants.AuditResultDenied
}
}
return constants.AuditResultFailed
}
func resolveParent(tx *gorm.DB, parentID *uint) (*model.Shop, int, error) {
if parentID == nil {
return nil, 1, nil

View File

@@ -6,6 +6,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
accessauditapp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -15,12 +16,13 @@ import (
// UpdateService 收口店铺资料与业务员归属的简单写事务脚本。
type UpdateService struct {
db *gorm.DB
db *gorm.DB
audit accessauditapp.Writer
}
// NewUpdateService 创建店铺更新事务脚本。
func NewUpdateService(db *gorm.DB) *UpdateService {
return &UpdateService{db: db}
func NewUpdateService(db *gorm.DB, audit accessauditapp.Writer) *UpdateService {
return &UpdateService{db: db, audit: audit}
}
// Update 更新单个店铺;业务员归属变化不会传播到其他店铺。
@@ -50,11 +52,16 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up
return nil, errors.New(errors.CodeUnauthorized)
}
var response *dto.ShopResponse
var beforeShop *model.Shop
var parentShop *model.Shop
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var shop model.Shop
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&shop, shopID).Error; err != nil {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
before := shop
beforeShop = &before
parentShop = loadAuditParentShop(tx, shop.ParentID)
if request.BusinessOwnerAccountIDSet {
ownerID, err := validateUpdatedBusinessOwner(tx, request.BusinessOwnerAccountID)
if err != nil {
@@ -88,14 +95,160 @@ func (s *UpdateService) Update(ctx context.Context, shopID uint, request *dto.Up
if err := fillBusinessOwnerResponse(tx, &shop, response); err != nil {
return err
}
if shopProfileChanged(&before, &shop) {
if s.audit == nil {
return errors.New(errors.CodeInvalidStatus, "店铺更新审计接缝未配置")
}
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopUpdated, Summary: "更新店铺基础资料",
OperatorID: operatorID, Shop: &shop, ParentShop: parentShop,
BeforeData: shopProfileData(&before), AfterData: shopProfileData(&shop),
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入店铺更新审计失败")
}
}
if err := s.writeStateAudits(ctx, tx, &before, &shop, parentShop, operatorID); err != nil {
return err
}
return nil
})
if err != nil {
if beforeShop != nil && requestedShopProfileChanged(beforeShop, request) {
accessauditapp.RecordFailure(ctx, s.db, s.audit, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopUpdated, Summary: "更新店铺基础资料失败", Result: shopAuditFailureResult(err),
OperatorID: operatorID, Shop: beforeShop, ParentShop: parentShop,
}, err)
}
s.recordStateFailures(ctx, beforeShop, parentShop, request, operatorID, err)
return nil, err
}
return response, nil
}
func (s *UpdateService) writeStateAudits(ctx context.Context, tx *gorm.DB, before, after, parent *model.Shop, operatorID uint) error {
if s.audit == nil && (before.Status != after.Status || !sameOptionalUint(before.BusinessOwnerAccountID, after.BusinessOwnerAccountID) || before.ClientLoginDisabled != after.ClientLoginDisabled) {
return errors.New(errors.CodeInvalidStatus, "店铺状态审计接缝未配置")
}
if before.Status != after.Status {
action, summary, subject := constants.AuditActionShopDisabled, "禁用店铺", "店铺已禁用"
if after.Status == constants.StatusEnabled {
action, summary, subject = constants.AuditActionShopEnabled, "启用店铺", "店铺已启用"
}
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: action, Summary: summary, OperatorID: operatorID, Shop: after, ParentShop: parent,
BeforeData: map[string]any{"status": before.Status}, AfterData: map[string]any{"status": after.Status},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: subject,
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入店铺状态审计失败")
}
}
if !sameOptionalUint(before.BusinessOwnerAccountID, after.BusinessOwnerAccountID) {
accounts := businessOwnerAuditAccounts(tx, before.BusinessOwnerAccountID, after.BusinessOwnerAccountID)
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopBusinessOwnerUpdated, Summary: "更新店铺业务员归属",
OperatorID: operatorID, Shop: after, ParentShop: parent, Accounts: accounts,
BeforeData: map[string]any{"business_owner_account_id": before.BusinessOwnerAccountID},
AfterData: map[string]any{"business_owner_account_id": after.BusinessOwnerAccountID},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "店铺业务员归属已更新",
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入店铺业务员审计失败")
}
}
if before.ClientLoginDisabled != after.ClientLoginDisabled {
subject := "店铺 C 端登录限制已解除"
if after.ClientLoginDisabled {
subject = "店铺 C 端登录已限制"
}
if err := s.audit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: constants.AuditActionShopClientLoginLimitUpdated, Summary: "更新店铺 C 端登录限制",
OperatorID: operatorID, Shop: after, ParentShop: parent,
BeforeData: map[string]any{"client_login_disabled": before.ClientLoginDisabled},
AfterData: map[string]any{"client_login_disabled": after.ClientLoginDisabled},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: subject,
}); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "写入店铺登录限制审计失败")
}
}
return nil
}
func (s *UpdateService) recordStateFailures(ctx context.Context, shop, parent *model.Shop, request *dto.UpdateShopRequest, operatorID uint, originalErr error) {
if shop == nil {
return
}
record := func(action, summary string) {
accessauditapp.RecordFailure(ctx, s.db, s.audit, accessauditapp.ChangeAudit{
ActionCode: action, Summary: summary, Result: shopAuditFailureResult(originalErr),
OperatorID: operatorID, Shop: shop, ParentShop: parent, SubjectVisibility: constants.AuditSubjectInternalOnly,
}, originalErr)
}
if shop.Status != request.Status {
action := constants.AuditActionShopDisabled
if request.Status == constants.StatusEnabled {
action = constants.AuditActionShopEnabled
}
record(action, "更新店铺状态失败")
}
if request.BusinessOwnerAccountIDSet && !sameOptionalUint(shop.BusinessOwnerAccountID, request.BusinessOwnerAccountID) {
record(constants.AuditActionShopBusinessOwnerUpdated, "更新店铺业务员归属失败")
}
if request.ClientLoginDisabled != nil && shop.ClientLoginDisabled != *request.ClientLoginDisabled {
record(constants.AuditActionShopClientLoginLimitUpdated, "更新店铺 C 端登录限制失败")
}
}
func businessOwnerAuditAccounts(tx *gorm.DB, beforeID, afterID *uint) []accessauditapp.AccountChange {
changes := make([]accessauditapp.AccountChange, 0, 2)
if account := loadAuditAccount(tx, beforeID); account != nil {
changes = append(changes, accessauditapp.AccountChange{
Account: account, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleShopPreviousBusinessOwner,
BeforeData: map[string]any{"assigned": true}, AfterData: map[string]any{"assigned": false},
})
}
if account := loadAuditAccount(tx, afterID); account != nil {
changes = append(changes, accessauditapp.AccountChange{
Account: account, Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleShopBusinessOwner,
BeforeData: map[string]any{"assigned": false}, AfterData: map[string]any{"assigned": true},
})
}
return changes
}
func loadAuditAccount(tx *gorm.DB, accountID *uint) *model.Account {
if accountID == nil {
return nil
}
var account model.Account
if err := tx.Unscoped().First(&account, *accountID).Error; err != nil {
return nil
}
return &account
}
func sameOptionalUint(left, right *uint) bool {
if left == nil || right == nil {
return left == nil && right == nil
}
return *left == *right
}
func requestedShopProfileChanged(shop *model.Shop, request *dto.UpdateShopRequest) bool {
return shop.ShopName != request.ShopName || shop.ContactName != request.ContactName ||
shop.ContactPhone != request.ContactPhone || shop.Province != request.Province || shop.City != request.City ||
shop.District != request.District || shop.Address != request.Address
}
func loadAuditParentShop(tx *gorm.DB, parentID *uint) *model.Shop {
if parentID == nil {
return nil
}
var parent model.Shop
if err := tx.Unscoped().First(&parent, *parentID).Error; err != nil {
return nil
}
return &parent
}
func validateUpdatedBusinessOwner(tx *gorm.DB, requestedID *uint) (*uint, error) {
if requestedID == nil {
return nil, nil

View File

@@ -3,8 +3,7 @@ package systemconfig
import (
"context"
"crypto/sha256"
"encoding/hex"
"strconv"
"time"
"gorm.io/gorm"
@@ -13,6 +12,7 @@ import (
configinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
@@ -24,13 +24,20 @@ type ChangeAudit struct {
OperationType string
Description string
ConfigKey string
Module string
ResourceID *string
DisplayName string
Identity map[string]any
BeforeData map[string]any
AfterData map[string]any
RequestID string
CorrelationID string
Result string
ErrorCode string
ErrorSummary string
}
// AuditWriter 可选接收系统配置事务内审计事实。
// AuditWriter 接收系统配置事务内审计事实。
type AuditWriter interface {
WriteConfigChange(ctx context.Context, tx *gorm.DB, audit ChangeAudit) error
}
@@ -69,14 +76,29 @@ func (s *UpdateService) Execute(ctx context.Context, key string, request dto.Upd
if operatorID == 0 || key == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
if s.audit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "系统配置审计接缝未配置")
}
definition, registered := s.registry.Get(key)
if !registered {
return nil, errors.New(errors.CodeInvalidParam, "系统配置 Key 未注册")
}
if definition.Readonly {
s.recordFailure(ctx, ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationSystemConfigUpdate,
Description: "拒绝更新只读系统配置", ConfigKey: key, Module: definition.Module,
Result: constants.AuditResultDenied, ErrorCode: strconv.Itoa(errors.CodeInvalidStatus),
ErrorSummary: "系统配置为只读,更新请求已拒绝",
})
return nil, errors.New(errors.CodeInvalidStatus, "系统配置为只读,不能更新")
}
if err := configinfra.ValidateValue(definition, request.Value); err != nil {
s.recordFailure(ctx, ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationSystemConfigUpdate,
Description: "拒绝非法系统配置值", ConfigKey: key, Module: definition.Module,
Result: constants.AuditResultDenied, ErrorCode: strconv.Itoa(errors.CodeInvalidParam),
ErrorSummary: "系统配置值不符合注册规则",
})
return nil, errors.New(errors.CodeInvalidParam, "系统配置值不符合注册规则")
}
now := s.now().UTC()
@@ -121,21 +143,24 @@ func (s *UpdateService) Execute(ctx context.Context, key string, request dto.Upd
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
if s.audit != nil {
if err := s.audit.WriteConfigChange(ctx, tx, ChangeAudit{
OperatorID: operatorID, OperationType: "system_config_update", Description: "更新受控系统配置",
ConfigKey: key,
BeforeData: map[string]any{"config_key": key, "value": auditValue(definition, beforeValue)},
AfterData: map[string]any{"config_key": key, "value": auditValue(definition, request.Value)},
RequestID: requestID, CorrelationID: requestID,
}); err != nil {
return err
}
if err := s.audit.WriteConfigChange(ctx, tx, ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationSystemConfigUpdate, Description: "更新受控系统配置",
ConfigKey: key, Module: definition.Module,
BeforeData: auditData(definition, beforeValue), AfterData: auditData(definition, request.Value),
RequestID: requestID, CorrelationID: requestID,
}); err != nil {
return err
}
saved = existing
return nil
})
if err != nil {
s.recordFailure(ctx, ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationSystemConfigUpdate,
Description: "系统配置更新失败", ConfigKey: key, Module: definition.Module,
Result: constants.AuditResultFailed, ErrorCode: strconv.Itoa(errors.CodeDatabaseError),
ErrorSummary: "系统配置更新事务已回滚",
})
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新系统配置失败")
}
s.registry.Remember(key, request.Value)
@@ -159,10 +184,24 @@ func (s *UpdateService) Execute(ctx context.Context, key string, request dto.Upd
}, nil
}
func auditValue(definition configinfra.Definition, value string) string {
if !definition.Sensitive {
return value
func (s *UpdateService) recordFailure(ctx context.Context, audit ChangeAudit) {
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
audit.RequestID = *value
audit.CorrelationID = *value
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.audit.WriteConfigChange(ctx, tx, audit)
})
if err != nil {
auditfailure.RecordSecondaryWriteFailure(
audit.OperationType, audit.ConfigKey, audit.RequestID, audit.CorrelationID, audit.ErrorCode, err,
)
}
sum := sha256.Sum256([]byte(value))
return "[敏感值 sha256:" + hex.EncodeToString(sum[:8]) + "]"
}
func auditData(definition configinfra.Definition, value string) map[string]any {
if definition.Sensitive {
return map[string]any{"credentials_configured": value != ""}
}
return map[string]any{"value": value}
}

View File

@@ -3,39 +3,61 @@ package wallet
import (
"context"
stderrors "errors"
"strconv"
domainwallet "github.com/break/junhong_cmp_fiber/internal/domain/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
)
// CreditChangeAudit 描述一次代理主钱包实际信用额度变化。
type CreditChangeAudit struct {
Wallet *model.AgentWallet
BeforeData map[string]any
AfterData map[string]any
Result string
ErrorCode string
ErrorSummary string
}
// CreditChangeAuditWriter 在信用额度业务事务内追加统一 Audit Event。
type CreditChangeAuditWriter interface {
WriteAgentWalletCreditChange(context.Context, *gorm.DB, CreditChangeAudit) error
}
// ChangeCreditService 调整既有店铺主钱包实际信用额度。
type ChangeCreditService struct {
db *gorm.DB
db *gorm.DB
audit CreditChangeAuditWriter
}
// NewChangeCreditService 创建实际信用额度调整服务。
func NewChangeCreditService(db *gorm.DB) *ChangeCreditService {
return &ChangeCreditService{db: db}
func NewChangeCreditService(db *gorm.DB, audit CreditChangeAuditWriter) *ChangeCreditService {
return &ChangeCreditService{db: db, audit: audit}
}
// Execute 按主钱包类型和版本条件更新,不修改余额、冻结金额或钱包流水。
func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled bool, limit int64, version int) (*dto.ShopCreditLimitResponse, error) {
// Execute 使用服务端读取的版本条件更新,不修改余额、冻结金额或钱包流水。
func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled bool, limit int64) (*dto.ShopCreditLimitResponse, error) {
var result *dto.ShopCreditLimitResponse
var stored model.AgentWallet
var beforeData map[string]any
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var stored model.AgentWallet
if err := tx.Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).First(&stored).Error; err != nil {
return errors.New(errors.CodeWalletNotFound, "店铺主钱包不存在")
}
beforeData = creditAuditData(&stored)
aggregate := domainwallet.AgentWallet{ID: stored.ID, ShopID: stored.ShopID, WalletType: stored.WalletType, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: stored.CreditEnabled, CreditLimit: stored.CreditLimit, Status: stored.Status, Version: stored.Version}
if err := aggregate.ChangeCredit(enabled, limit); err != nil {
return err
}
update := tx.Model(&model.AgentWallet{}).
Where("id = ? AND wallet_type = ? AND version = ? AND balance::numeric - frozen_balance::numeric + ?::numeric >= 0", stored.ID, constants.AgentWalletTypeMain, version, aggregate.EffectiveCredit()).
Where("id = ? AND wallet_type = ? AND version = ? AND balance::numeric - frozen_balance::numeric + ?::numeric >= 0", stored.ID, constants.AgentWalletTypeMain, stored.Version, aggregate.EffectiveCredit()).
Updates(map[string]any{"credit_enabled": enabled, "credit_limit": limit, "version": gorm.Expr("version + 1")})
if update.Error != nil {
return errors.Wrap(errors.CodeInternalError, update.Error, "更新店铺信用额度失败")
@@ -45,14 +67,68 @@ func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled
if err := tx.Where("id = ? AND wallet_type = ?", stored.ID, constants.AgentWalletTypeMain).First(&current).Error; err != nil {
return errors.New(errors.CodeWalletNotFound, "店铺主钱包不存在")
}
if current.Version != version {
return errors.New(errors.CodeConflict, "钱包版本已变化,请刷新后重试")
if current.Version != stored.Version {
return errors.New(errors.CodeConflict, "钱包版本已变化,请重试")
}
return errors.New(errors.CodeInsufficientQuota, "当前资金占用无法降低或关闭信用额度")
}
available, _ := aggregate.AvailableBalance()
result = &dto.ShopCreditLimitResponse{ShopID: shopID, WalletID: stored.ID, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: enabled, CreditLimit: limit, AvailableBalance: available, Version: version + 1}
return nil
after := stored
after.CreditEnabled = enabled
after.CreditLimit = limit
after.Version++
result = &dto.ShopCreditLimitResponse{ShopID: shopID, WalletID: stored.ID, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: enabled, CreditLimit: limit, AvailableBalance: available, Version: after.Version}
if s.audit == nil {
return errors.New(errors.CodeInternalError, "代理主钱包信用额度审计接缝未配置")
}
return s.audit.WriteAgentWalletCreditChange(ctx, tx, CreditChangeAudit{
Wallet: &after, BeforeData: beforeData, AfterData: creditAuditData(&after), Result: constants.AuditResultSuccess,
})
})
if err != nil && stored.ID != 0 {
s.recordCreditChangeFailure(ctx, &stored, beforeData, err)
}
return result, err
}
func creditAuditData(wallet *model.AgentWallet) map[string]any {
return map[string]any{
"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance,
"credit_enabled": wallet.CreditEnabled, "credit_limit": wallet.CreditLimit, "version": wallet.Version,
}
}
func (s *ChangeCreditService) recordCreditChangeFailure(ctx context.Context, wallet *model.AgentWallet, beforeData map[string]any, originalErr error) {
result, code, summary := creditChangeError(originalErr)
if s.audit == nil || s.db == nil {
recordCreditChangeSecondaryFailure(ctx, wallet.ID, code, errors.New(errors.CodeInvalidStatus, "代理主钱包信用额度审计接缝未配置"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.audit.WriteAgentWalletCreditChange(ctx, tx, CreditChangeAudit{
Wallet: wallet, BeforeData: beforeData, Result: result, ErrorCode: code, ErrorSummary: summary,
})
}); err != nil {
recordCreditChangeSecondaryFailure(ctx, wallet.ID, code, err)
}
}
func recordCreditChangeSecondaryFailure(ctx context.Context, walletID uint, errorCode string, err error) {
linkage := auditcontext.From(ctx)
auditfailure.RecordSecondaryWriteFailure(
constants.AuditActionAgentWalletCreditChanged, strconv.FormatUint(uint64(walletID), 10),
linkage.RequestID, linkage.CorrelationID, errorCode, err,
)
}
func creditChangeError(err error) (string, string, string) {
var appErr *errors.AppError
if !stderrors.As(err, &appErr) {
return constants.AuditResultFailed, strconv.Itoa(errors.CodeInternalError), "更新代理主钱包信用额度失败"
}
result := constants.AuditResultDenied
if appErr.Code == errors.CodeDatabaseError || appErr.Code == errors.CodeInternalError {
result = constants.AuditResultFailed
}
return result, strconv.Itoa(appErr.Code), appErr.Message
}

View File

@@ -51,6 +51,7 @@ type CreditedEvent struct {
ReferenceType string `json:"reference_type"`
ReferenceID uint `json:"reference_id"`
TransactionType string `json:"transaction_type"`
Remark string `json:"remark,omitempty"`
OccurredAt time.Time `json:"occurred_at"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
@@ -145,6 +146,7 @@ func (s *PostingService) PostInTx(ctx context.Context, tx *gorm.DB, command Post
WalletID: stored.ID, ShopID: stored.ShopID, Amount: command.Amount,
BalanceBefore: stored.Balance, BalanceAfter: aggregate.Balance, Version: stored.Version + 1,
ReferenceType: referenceType, ReferenceID: command.ReferenceID, TransactionType: command.TransactionType,
Remark: remark,
OccurredAt: now, RequestID: command.RequestID, CorrelationID: command.CorrelationID,
}
if err := s.eventWriter.Append(ctx, tx, event); err != nil {
@@ -163,6 +165,9 @@ func validatePostingCommand(command PostingCommand) error {
if !validRecharge && !validAdjustment {
return errors.New(errors.CodeInvalidParam, "代理主钱包入账业务类型无效")
}
if validAdjustment && strings.TrimSpace(command.Remark) == "" {
return errors.New(errors.CodeInvalidParam, "人工调整代理主钱包必须填写原因")
}
return nil
}

View File

@@ -12,6 +12,7 @@ import (
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
@@ -38,14 +39,44 @@ type AccessTokenProvider interface {
Invalidate(ctx context.Context, applicationID uint)
}
// SensitiveReadAuditWriter 定义明文连接凭据读取的失败关闭审计边界。
type SensitiveReadAuditWriter interface {
WriteSensitiveRead(ctx context.Context, tx *gorm.DB, audit SensitiveReadAudit) error
}
// SensitiveReadAudit 是一次企业微信应用明文凭据读取事实。
type SensitiveReadAudit struct {
OperatorID uint
Applications []SensitiveReadResource
FieldClasses []string
RequestID string
CorrelationID string
}
// SensitiveReadResource 是不含任何明文凭据的读取目标快照。
type SensitiveReadResource struct {
ID uint
CorpID string
AgentID int64
Name string
Status int
CredentialsConfigured bool
}
// ConnectionService 保存应用配置并测试企业微信连接。
type ConnectionService struct {
db *gorm.DB
repo ApplicationRepository
tokens AccessTokenProvider
audit systemconfigapp.AuditWriter
members DefaultCreatorMemberFinder
now func() time.Time
db *gorm.DB
repo ApplicationRepository
tokens AccessTokenProvider
audit systemconfigapp.AuditWriter
readAudit SensitiveReadAuditWriter
members DefaultCreatorMemberFinder
now func() time.Time
}
// SetSensitiveReadAuditWriter 注入明文凭据读取的失败关闭审计 Writer。
func (s *ConnectionService) SetSensitiveReadAuditWriter(writer SensitiveReadAuditWriter) {
s.readAudit = writer
}
// SetDefaultCreatorMemberFinder 注入默认审批发起人的可见成员查询边界。
@@ -60,7 +91,7 @@ func NewConnectionService(db *gorm.DB, repo ApplicationRepository, tokens Access
// Save 创建或更新企业微信应用配置。
func (s *ConnectionService) Save(ctx context.Context, request dto.SaveWeComApplicationRequest) (*dto.WeComApplicationResponse, error) {
if s == nil || s.db == nil || s.repo == nil {
if s == nil || s.db == nil || s.repo == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信连接服务未配置")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
@@ -110,10 +141,12 @@ func (s *ConnectionService) Save(ctx context.Context, request dto.SaveWeComAppli
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
resourceID := fmt.Sprintf("%d", existing.ID)
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: "wecom_application_save", Description: "保存企业微信应用安全配置",
OperatorID: operatorID, OperationType: constants.AuditOperationWeComApplicationSave, Description: "保存企业微信应用安全配置",
ConfigKey: fmt.Sprintf("wecom.application.%d", existing.ID), BeforeData: before,
AfterData: applicationAuditSnapshot(existing), RequestID: requestID, CorrelationID: requestID,
ResourceID: &resourceID, DisplayName: existing.Name, Identity: applicationAuditIdentity(existing),
}); err != nil {
return err
}
@@ -122,6 +155,14 @@ func (s *ConnectionService) Save(ctx context.Context, request dto.SaveWeComAppli
return nil
})
if err != nil {
recordConfigFailure(ctx, s.db, s.audit, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationWeComApplicationSave,
Description: "保存企业微信应用配置失败", ConfigKey: fmt.Sprintf("wecom.application.%s.%d", request.CorpID, request.AgentID),
DisplayName: request.Name, Identity: map[string]any{
"corp_id": request.CorpID, "agent_id": request.AgentID, "name": request.Name, "status": request.Status,
"credentials_configured": request.Secret != "" && request.CallbackToken != "" && request.EncodingAESKey != "",
}, Result: constants.AuditResultFailed, ErrorCode: fmt.Sprintf("%d", errors.CodeDatabaseError), ErrorSummary: "企业微信应用配置事务已回滚",
})
var appErr *errors.AppError
if stdErrors.As(err, &appErr) {
return nil, appErr
@@ -156,6 +197,33 @@ func (s *ConnectionService) List(ctx context.Context, request dto.WeComApplicati
if err != nil {
return nil, err
}
if len(applications) > 0 {
if s.db == nil || s.readAudit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "敏感读取审计能力未配置")
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
resources := make([]SensitiveReadResource, 0, len(applications))
for _, application := range applications {
resources = append(resources, SensitiveReadResource{
ID: application.ID, CorpID: application.CorpID, AgentID: application.AgentID,
Name: application.Name, Status: application.Status,
CredentialsConfigured: application.Secret != "" && application.CallbackToken != "" && application.EncodingAESKey != "",
})
}
readAudit := SensitiveReadAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), Applications: resources,
FieldClasses: []string{"secret", "callback_token", "encoding_aes_key"},
RequestID: requestID, CorrelationID: requestID,
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.readAudit.WriteSensitiveRead(ctx, tx, readAudit)
}); err != nil {
return nil, err
}
}
result := make([]dto.WeComApplicationResponse, 0, len(applications))
for _, application := range applications {
result = append(result, toApplicationResponse(application))
@@ -183,7 +251,7 @@ func (s *ConnectionService) Test(ctx context.Context, applicationID uint) error
// SaveDefaultCreator 从应用当前可见成员中保存代理等账号使用的默认审批发起人。
func (s *ConnectionService) SaveDefaultCreator(ctx context.Context, applicationID uint, request dto.SaveWeComDefaultCreatorRequest) (*dto.WeComApplicationResponse, error) {
if s == nil || s.db == nil || s.repo == nil || s.members == nil {
if s == nil || s.db == nil || s.repo == nil || s.members == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信默认审批发起人服务未配置")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
@@ -199,6 +267,7 @@ func (s *ConnectionService) SaveDefaultCreator(ctx context.Context, applicationI
}
member, err := s.members.GetVisible(ctx, applicationID, request.UserID)
if err != nil {
recordApplicationFailure(ctx, s.db, s.audit, constants.AuditOperationWeComDefaultCreatorSave, "拒绝保存不可用的企业微信默认审批发起人", application, constants.AuditResultDenied, errors.CodeInvalidParam)
return nil, err
}
now := s.now().UTC()
@@ -216,15 +285,18 @@ func (s *ConnectionService) SaveDefaultCreator(ctx context.Context, applicationI
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
resourceID := fmt.Sprintf("%d", applicationID)
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: "wecom_default_creator_save", Description: "保存企业微信默认审批发起人",
OperatorID: operatorID, OperationType: constants.AuditOperationWeComDefaultCreatorSave, Description: "保存企业微信默认审批发起人",
ConfigKey: fmt.Sprintf("wecom.application.%d.default_creator", applicationID), BeforeData: before,
AfterData: applicationAuditSnapshot(application), RequestID: requestID, CorrelationID: requestID,
ResourceID: &resourceID, DisplayName: application.Name, Identity: applicationAuditIdentity(application),
})
}
return nil
})
if err != nil {
recordApplicationFailure(ctx, s.db, s.audit, constants.AuditOperationWeComDefaultCreatorSave, "保存企业微信默认审批发起人失败", application, constants.AuditResultFailed, errors.CodeDatabaseError)
var appErr *errors.AppError
if stdErrors.As(err, &appErr) {
return nil, appErr
@@ -238,12 +310,52 @@ func (s *ConnectionService) SaveDefaultCreator(ctx context.Context, applicationI
func applicationAuditSnapshot(application *model.WeComApplication) map[string]any {
return map[string]any{
"id": application.ID, "corp_id": application.CorpID, "agent_id": application.AgentID,
"name": application.Name, "status": application.Status, "credentials_configured": true,
"name": application.Name, "status": application.Status,
"credentials_configured": application.Secret != "" && application.CallbackToken != "" && application.EncodingAESKey != "",
"default_creator_userid": application.DefaultCreatorUserID,
"default_creator_name": application.DefaultCreatorName,
}
}
func applicationAuditIdentity(application *model.WeComApplication) map[string]any {
if application == nil {
return nil
}
return map[string]any{
"id": application.ID, "corp_id": application.CorpID, "agent_id": application.AgentID,
"name": application.Name, "status": application.Status,
"credentials_configured": application.Secret != "" && application.CallbackToken != "" && application.EncodingAESKey != "",
}
}
func recordApplicationFailure(ctx context.Context, db *gorm.DB, audit systemconfigapp.AuditWriter, operation, description string, application *model.WeComApplication, result string, code int) {
if application == nil {
return
}
resourceID := fmt.Sprintf("%d", application.ID)
recordConfigFailure(ctx, db, audit, systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description,
ConfigKey: fmt.Sprintf("wecom.application.%d", application.ID), ResourceID: &resourceID,
DisplayName: application.Name, Identity: applicationAuditIdentity(application), BeforeData: applicationAuditSnapshot(application),
Result: result, ErrorCode: fmt.Sprintf("%d", code), ErrorSummary: description,
})
}
func recordConfigFailure(ctx context.Context, db *gorm.DB, audit systemconfigapp.AuditWriter, change systemconfigapp.ChangeAudit) {
if db == nil || audit == nil || change.OperatorID == 0 || change.ConfigKey == "" {
return
}
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
change.RequestID = *value
change.CorrelationID = *value
}
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return audit.WriteConfigChange(ctx, tx, change)
}); err != nil {
auditfailure.RecordSecondaryWriteFailure(change.OperationType, change.ConfigKey, change.RequestID, change.CorrelationID, change.ErrorCode, err)
}
}
func toApplicationResponse(application model.WeComApplication) dto.WeComApplicationResponse {
statusName := "禁用"
if application.Status == constants.StatusEnabled {

View File

@@ -2,10 +2,13 @@ package wecom
import (
"context"
"fmt"
"time"
"github.com/bytedance/sonic"
"gorm.io/gorm"
systemconfigapp "github.com/break/junhong_cmp_fiber/internal/application/systemconfig"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -27,30 +30,32 @@ type DirectoryProvider interface {
// MemberRepository 定义可见成员快照同步和分页查询边界。
type MemberRepository interface {
ReplaceVisible(ctx context.Context, applicationID uint, members []model.WeComMember, syncedAt time.Time) error
ReplaceVisible(ctx context.Context, tx *gorm.DB, applicationID uint, members []model.WeComMember, syncedAt time.Time) error
ListVisible(ctx context.Context, applicationID uint, page, pageSize int, keyword string) ([]model.WeComMember, int64, error)
}
// DirectoryService 同步并分页查询企业微信应用可见成员。
type DirectoryService struct {
db *gorm.DB
applications interface {
GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error)
}
provider DirectoryProvider
members MemberRepository
audit systemconfigapp.AuditWriter
now func() time.Time
}
// NewDirectoryService 创建企业微信通讯录同步用例。
func NewDirectoryService(applications interface {
func NewDirectoryService(db *gorm.DB, applications interface {
GetEnabled(ctx context.Context, applicationID uint) (*model.WeComApplication, error)
}, provider DirectoryProvider, members MemberRepository) *DirectoryService {
return &DirectoryService{applications: applications, provider: provider, members: members, now: time.Now}
}, provider DirectoryProvider, members MemberRepository, audit systemconfigapp.AuditWriter) *DirectoryService {
return &DirectoryService{db: db, applications: applications, provider: provider, members: members, audit: audit, now: time.Now}
}
// Sync 拉取并替换指定应用当前可见成员快照。
func (s *DirectoryService) Sync(ctx context.Context, applicationID uint) (*dto.WeComMemberSyncResponse, error) {
if s == nil || s.applications == nil || s.provider == nil || s.members == nil || applicationID == 0 {
if s == nil || s.db == nil || s.applications == nil || s.provider == nil || s.members == nil || s.audit == nil || applicationID == 0 {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信通讯录服务未配置")
}
if !canManageWeComDirectory(ctx) {
@@ -62,6 +67,7 @@ func (s *DirectoryService) Sync(ctx context.Context, applicationID uint) (*dto.W
}
remoteMembers, err := s.provider.ListVisibleMembers(ctx, applicationID)
if err != nil {
s.recordFailure(ctx, application, "同步企业微信应用可见成员失败")
return nil, err
}
syncedAt := s.now().UTC()
@@ -77,12 +83,46 @@ func (s *DirectoryService) Sync(ctx context.Context, applicationID uint) (*dto.W
CreatedAt: syncedAt, UpdatedAt: syncedAt,
})
}
if err := s.members.ReplaceVisible(ctx, applicationID, members, syncedAt); err != nil {
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.members.ReplaceVisible(ctx, tx, applicationID, members, syncedAt); err != nil {
return err
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
resourceID := fmt.Sprintf("%d", applicationID)
after := applicationAuditSnapshot(application)
after["synced_count"] = len(members)
after["synced_at"] = syncedAt
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: constants.AuditOperationWeComMembersSync,
Description: "同步企业微信应用可见成员", ConfigKey: fmt.Sprintf("wecom.application.%d.members", applicationID),
ResourceID: &resourceID, DisplayName: application.Name, Identity: applicationAuditIdentity(application),
AfterData: after, RequestID: requestID, CorrelationID: requestID,
})
})
if err != nil {
s.recordFailure(ctx, application, "保存企业微信应用可见成员快照失败")
return nil, err
}
return &dto.WeComMemberSyncResponse{ApplicationID: applicationID, SyncedCount: len(members), SyncedAt: syncedAt}, nil
}
func (s *DirectoryService) recordFailure(ctx context.Context, application *model.WeComApplication, description string) {
if application == nil {
return
}
resourceID := fmt.Sprintf("%d", application.ID)
recordConfigFailure(ctx, s.db, s.audit, systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: constants.AuditOperationWeComMembersSync,
Description: description, ConfigKey: fmt.Sprintf("wecom.application.%d.members", application.ID),
ResourceID: &resourceID, DisplayName: application.Name, Identity: applicationAuditIdentity(application),
BeforeData: applicationAuditSnapshot(application), Result: constants.AuditResultFailed,
ErrorCode: fmt.Sprintf("%d", errors.CodeInternalError), ErrorSummary: description,
})
}
// List 分页返回本地最近一次同步的应用可见成员。
func (s *DirectoryService) List(ctx context.Context, applicationID uint, request dto.WeComMemberListRequest) (*dto.WeComMemberListResponse, error) {
if s == nil || s.applications == nil || s.members == nil || applicationID == 0 {

View File

@@ -5,6 +5,7 @@ import (
"crypto/sha256"
"encoding/hex"
stdErrors "errors"
"fmt"
"strings"
"time"
@@ -108,7 +109,7 @@ func (s *SceneService) ListBusinessFields(ctx context.Context, businessType stri
// Save 校验模板控件后创建或替换指定稳定业务场景映射。
func (s *SceneService) Save(ctx context.Context, businessType string, request dto.SaveWeComApprovalSceneRequest) (*dto.WeComApprovalSceneResponse, error) {
if s == nil || s.db == nil || s.provider == nil || s.repo == nil {
if s == nil || s.db == nil || s.provider == nil || s.repo == nil || s.audit == nil {
return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置")
}
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
@@ -124,9 +125,11 @@ func (s *SceneService) Save(ctx context.Context, businessType string, request dt
}
definition, err := s.provider.GetTemplateDetail(ctx, request.ApplicationID, strings.TrimSpace(request.TemplateID))
if err != nil {
s.recordFailure(ctx, businessType, request, constants.AuditResultFailed, errors.CodeInternalError, "校验企业微信审批模板失败")
return nil, err
}
if err := validateSceneMapping(businessType, request.ControlMapping, definition.Controls); err != nil {
s.recordFailure(ctx, businessType, request, constants.AuditResultDenied, errors.CodeInvalidParam, "拒绝保存非法企业微信审批场景映射")
return nil, err
}
request.ControlMapping = normalizeSceneMapping(request.ControlMapping)
@@ -177,23 +180,27 @@ func (s *SceneService) Save(ctx context.Context, businessType string, request dt
} else if err := s.repo.Update(ctx, tx, existing); err != nil {
return err
}
if s.audit != nil {
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: "wecom_approval_scene_save", Description: "保存企业微信审批模板映射",
ConfigKey: "wecom.approval_scene." + businessType, BeforeData: before,
AfterData: sceneAuditSnapshot(existing), RequestID: requestID, CorrelationID: requestID,
}); err != nil {
return err
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
resourceID := strings.TrimSpace(existing.BusinessType)
if existing.ID != 0 {
resourceID = fmt.Sprintf("%d", existing.ID)
}
if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: operatorID, OperationType: constants.AuditOperationWeComApprovalSceneSave, Description: "保存企业微信审批模板映射",
ConfigKey: "wecom.approval_scene." + businessType, BeforeData: before,
AfterData: sceneAuditSnapshot(existing), RequestID: requestID, CorrelationID: requestID,
ResourceID: &resourceID, DisplayName: existing.TemplateName, Identity: sceneAuditIdentity(existing),
}); err != nil {
return err
}
saved = existing
return nil
})
if err != nil {
s.recordFailure(ctx, businessType, request, constants.AuditResultFailed, errors.CodeDatabaseError, "保存企业微信审批场景失败")
var appErr *errors.AppError
if stdErrors.As(err, &appErr) {
return nil, appErr
@@ -364,6 +371,28 @@ func sceneAuditSnapshot(scene *model.WeComApprovalScene) map[string]any {
}
}
func sceneAuditIdentity(scene *model.WeComApprovalScene) map[string]any {
if scene == nil {
return nil
}
return map[string]any{
"id": scene.ID, "business_type": scene.BusinessType, "application_id": scene.ApplicationID,
"template_id": scene.TemplateID, "template_name": scene.TemplateName, "status": scene.Status,
}
}
func (s *SceneService) recordFailure(ctx context.Context, businessType string, request dto.SaveWeComApprovalSceneRequest, result string, code int, description string) {
recordConfigFailure(ctx, s.db, s.audit, systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: constants.AuditOperationWeComApprovalSceneSave,
Description: description, ConfigKey: "wecom.approval_scene." + businessType,
DisplayName: businessType, Identity: map[string]any{
"business_type": businessType, "application_id": request.ApplicationID,
"template_id": strings.TrimSpace(request.TemplateID), "status": request.Status,
},
Result: result, ErrorCode: fmt.Sprintf("%d", code), ErrorSummary: description,
})
}
func sceneResponse(scene model.WeComApprovalScene) (*dto.WeComApprovalSceneResponse, error) {
var mapping []dto.WeComControlMappingItem
if err := sonic.Unmarshal(scene.ControlMapping, &mapping); err != nil {

View File

@@ -28,5 +28,5 @@ type Dependencies struct {
GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil
WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选)
SystemConfigRegistry *systemConfigInfra.Registry // 业务模块共享的受控配置注册表(可选)
SystemConfigAudit systemConfigApp.AuditWriter // 配置变更审计 Port(可选,装配后与配置同事务写入)
SystemConfigAudit systemConfigApp.AuditWriter // 配置变更审计 Port;生产为空时装配统一 Writer
}

View File

@@ -12,6 +12,7 @@ import (
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
openapiHandler "github.com/break/junhong_cmp_fiber/internal/handler/openapi"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/carriercallback"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
@@ -19,7 +20,9 @@ import (
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
agentRechargeQuery "github.com/break/junhong_cmp_fiber/internal/query/agentrecharge"
assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset"
auditQuery "github.com/break/junhong_cmp_fiber/internal/query/audit"
exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange"
integrationQuery "github.com/break/junhong_cmp_fiber/internal/query/integration"
notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification"
packageExpiryQuery "github.com/break/junhong_cmp_fiber/internal/query/packageexpiry"
shopQuery "github.com/break/junhong_cmp_fiber/internal/query/shop"
@@ -71,6 +74,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
deps.QueueClient,
deps.Logger,
)
rechargeOrderService.SetPaymentAudit(svc.AccessAudit)
clientOrderService := clientOrderSvc.New(
svc.Asset,
svc.PurchaseValidation,
@@ -106,9 +110,14 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
systemConfigReader := systemConfigInfra.NewReader(deps.DB, systemConfigRegistry, systemConfigCache, systemConfigAlerts)
paymentMethodPolicy := paymentmethod.NewPolicy(systemConfigReader)
clientOrderService.SetPaymentMethodPolicy(paymentMethodPolicy)
clientOrderService.SetPaymentAudit(svc.AccessAudit, integrationlog.NewRepository(deps.DB))
systemConfigList := systemConfigQuery.NewListQuery(systemConfigReader)
systemConfigAudit := deps.SystemConfigAudit
if systemConfigAudit == nil {
systemConfigAudit = auditInfra.NewWriter(auditInfra.NewRegistry(), nil)
}
systemConfigUpdate := systemConfigApp.NewUpdateService(
deps.DB, systemConfigRegistry, systemConfigCache, deps.SystemConfigAudit, systemConfigAlerts, nil,
deps.DB, systemConfigRegistry, systemConfigCache, systemConfigAudit, systemConfigAlerts, nil,
)
wecomRepository := wecomInfra.NewApplicationRepository(deps.DB)
wecomBaseURL := ""
@@ -122,19 +131,23 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
wecomBaseURL, wecomTimeout, deps.Logger,
)
wecomConnections := wecomApp.NewConnectionService(
deps.DB, wecomRepository, wecomTokens, deps.SystemConfigAudit,
deps.DB, wecomRepository, wecomTokens, systemConfigAudit,
)
if sensitiveReadAudit, ok := systemConfigAudit.(wecomApp.SensitiveReadAuditWriter); ok {
wecomConnections.SetSensitiveReadAuditWriter(sensitiveReadAudit)
}
wecomMembers := wecomInfra.NewMemberRepository(deps.DB)
wecomConnections.SetDefaultCreatorMemberFinder(wecomMembers)
wecomDirectory := wecomApp.NewDirectoryService(
deps.DB,
wecomRepository,
wecomInfra.NewDirectoryClient(wecomTokens, integrationlog.NewRepository(deps.DB), wecomBaseURL, wecomTimeout),
wecomMembers,
wecomMembers, systemConfigAudit,
)
wecomScenes := wecomApp.NewSceneService(
deps.DB,
wecomInfra.NewTemplateClient(wecomTokens, integrationlog.NewRepository(deps.DB), wecomBaseURL, wecomTimeout),
wecomInfra.NewSceneRepository(deps.DB), deps.SystemConfigAudit,
wecomInfra.NewSceneRepository(deps.DB), systemConfigAudit,
)
wecomApprovalCallback := callback.NewWeComApprovalHandler(wecomInfra.NewCallbackService(
wecomRepository, integrationlog.NewRepository(deps.DB), deps.QueueClient, deps.Logger,
@@ -146,7 +159,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
Account: admin.NewAccountHandler(svc.Account),
Role: func() *admin.RoleHandler {
handler := admin.NewRoleHandler(svc.Role, validate)
handler.SetDefaultCreditService(roleApp.NewDefaultCreditService(deps.DB, svc.Permission))
handler.SetDefaultCreditService(roleApp.NewDefaultCreditService(deps.DB, svc.Permission, svc.AccessAudit))
return handler
}(),
Permission: admin.NewPermissionHandler(svc.Permission),
@@ -162,6 +175,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
ClientWallet: func() *app.ClientWalletHandler {
handler := app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore)
handler.SetPaymentMethodPolicy(paymentMethodPolicy)
handler.SetPaymentAudit(svc.AccessAudit, integrationlog.NewRepository(deps.DB))
return handler
}(),
ClientOrder: app.NewClientOrderHandler(clientOrderService, deps.Logger),
@@ -177,13 +191,14 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
return handler
}(),
ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger),
ClientNotification: app.NewClientNotificationHandler(notificationQuery.NewQuery(deps.DB), notificationApp.NewReadService(deps.DB), validate),
ClientNotification: app.NewClientNotificationHandler(notificationQuery.NewQuery(deps.DB),
notificationApp.NewReadService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)), validate),
Shop: func() *admin.ShopHandler {
handler := admin.NewShopHandler(svc.Shop, validate)
handler.SetCreateService(shopApp.NewCreateService(deps.DB))
handler.SetUpdateService(shopApp.NewUpdateService(deps.DB))
handler.SetCreateService(shopApp.NewCreateService(deps.DB, svc.AccessAudit))
handler.SetUpdateService(shopApp.NewUpdateService(deps.DB, svc.AccessAudit))
handler.SetBusinessOwnerQuery(shopQuery.NewBusinessOwnerQuery(deps.DB))
handler.SetChangeCreditService(walletApp.NewChangeCreditService(deps.DB))
handler.SetChangeCreditService(walletApp.NewChangeCreditService(deps.DB, svc.AccessAudit))
return handler
}(),
ShopRole: admin.NewShopRoleHandler(svc.Shop),
@@ -202,20 +217,21 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
IotCard: admin.NewIotCardHandler(svc.IotCard),
IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport),
ExportTask: admin.NewExportTaskHandler(svc.ExportTask),
Notification: admin.NewNotificationHandler(notificationQuery.NewQuery(deps.DB), notificationApp.NewReadService(deps.DB), validate),
Device: admin.NewDeviceHandler(svc.Device),
DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport),
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),
Storage: admin.NewStorageHandler(deps.StorageService),
Carrier: admin.NewCarrierHandler(svc.Carrier),
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
Package: admin.NewPackageHandler(svc.Package),
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
AdminExchange: admin.NewExchangeHandler(svc.Exchange, exchangeQuery.NewListQuery(deps.DB), validate),
Notification: admin.NewNotificationHandler(notificationQuery.NewQuery(deps.DB),
notificationApp.NewReadService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)), validate),
Device: admin.NewDeviceHandler(svc.Device),
DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport),
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),
Storage: admin.NewStorageHandler(deps.StorageService),
Carrier: admin.NewCarrierHandler(svc.Carrier),
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
Package: admin.NewPackageHandler(svc.Package),
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
AdminExchange: admin.NewExchangeHandler(svc.Exchange, exchangeQuery.NewListQuery(deps.DB), validate),
PaymentCallback: callback.NewPaymentHandler(
svc.Order, svc.Recharge, rechargeOrderService, svc.AgentRecharge,
deps.WechatPayment, svc.WechatConfig, paymentStore,
@@ -280,6 +296,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
ClientWechat: app.NewClientWechatHandler(svc.WechatConfig, deps.Redis, deps.Logger),
SuperAdmin: admin.NewSuperAdminHandler(svc.OperationPassword),
SystemConfig: admin.NewSystemConfigHandler(systemConfigList, systemConfigUpdate),
Audit: admin.NewAuditHandler(auditQuery.New(deps.DB), integrationQuery.New(deps.DB)),
WeCom: func() *admin.WeComHandler {
handler := admin.NewWeComHandler(wecomConnections, validate)
handler.SetDirectoryService(wecomDirectory)

View File

@@ -12,6 +12,7 @@ import (
refundapprovalApp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
approvalInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/approval"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
exchangeInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/exchange"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
@@ -76,6 +77,7 @@ import (
)
type services struct {
AccessAudit *auditInfra.Writer
Approval *approvalApp.CreationService
Account *accountSvc.Service
AccountAudit *accountAuditSvc.Service
@@ -147,7 +149,14 @@ func initServices(s *stores, deps *Dependencies) *services {
purchaseValidation := purchaseValidationSvc.New(deps.DB, s.IotCard, s.Device, s.Package, s.ShopPackageAllocation)
accountAudit := accountAuditSvc.NewService(s.AccountOperationLog)
assetAudit := assetAuditSvc.NewService(s.AssetOperationLog, deps.DB)
auditWriter := auditInfra.NewWriter(auditInfra.NewRegistry(), nil)
customerBinding.SetAccessAudit(auditWriter)
account := accountSvc.New(s.Account, s.Role, s.AccountRole, s.ShopRole, s.Shop, s.Enterprise, accountAudit)
account.SetLifecycleAudit(deps.DB, auditWriter)
account.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
account.SetTokenManager(deps.TokenManager)
authService := authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger)
authService.SetSecurityAudit(deps.DB, auditWriter)
// 创建 IotCard service 并设置回调
iotCard := iotCardSvc.New(
@@ -162,6 +171,7 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.Logger,
assetAudit,
)
iotCard.SetAccessAudit(auditWriter)
cardObservationOutbox := outbox.NewRepository()
observationSeriesEvents := cardObservationInfra.NewSeriesEventWriter(cardObservationOutbox)
cardObservationService := cardObservationApp.NewService(
@@ -169,6 +179,7 @@ func initServices(s *stores, deps *Dependencies) *services {
cardObservationInfra.NewEventWriter(cardObservationOutbox),
cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger),
)
cardObservationService.SetStateAuditWriter(iotCard)
iotCard.SetCardObservationService(cardObservationService)
iotCard.SetSpeedTierIntegrationLog(integrationlog.NewRepository(deps.DB))
seriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
@@ -180,7 +191,7 @@ func initServices(s *stores, deps *Dependencies) *services {
)
cardObservationSeries := cardObservationApp.NewSeriesAttemptService(
seriesCoordinator,
cardObservationInfra.NewSeriesRunner(deps.DB, deps.GatewayClient, cardObservationService, seriesIntegration),
cardObservationInfra.NewSeriesRunner(deps.DB, deps.GatewayClient, cardObservationService, seriesIntegration, auditWriter),
cardObservationInfra.NewSeriesAttemptLogger(seriesIntegration),
)
observationSeries := cardObservationInfra.NewBestEffortSeriesDispatcher(seriesTrigger, deps.Logger, s.DeviceSimBinding, s.Carrier)
@@ -196,7 +207,7 @@ func initServices(s *stores, deps *Dependencies) *services {
pollingLifecycleSvc := polling.NewPollingLifecycleService(pollingQueueMgr, pollingConfigMgr, s.IotCard, s.DeviceSimBinding, s.Device, deps.Logger)
iotCard.SetPollingCallback(pollingLifecycleSvc)
// 创建支付配置服务Order 和 Recharge 依赖)
wechatConfig := wechatConfigSvc.New(s.WechatConfig, s.Order, s.RechargeOrder, s.AgentRecharge, s.Payment, accountAudit, deps.Redis, deps.Logger)
wechatConfig := wechatConfigSvc.New(s.WechatConfig, s.Order, s.RechargeOrder, s.AgentRecharge, s.Payment, auditWriter, deps.Redis, deps.Logger)
// 创建支付配置动态加载器Order 和 Recharge 依赖)
paymentLoader := payment.NewPaymentConfigLoader(s.WechatConfig, deps.Redis, deps.Logger)
@@ -209,6 +220,7 @@ func initServices(s *stores, deps *Dependencies) *services {
s.PackageUsageDailyRecord,
deps.Logger,
)
packageActivation.SetLifecycleAudit(auditWriter)
packageActivation.SetObservationSeriesEventWriter(observationSeriesEvents)
stopResumeService := iotCardSvc.NewStopResumeService(
@@ -222,6 +234,7 @@ func initServices(s *stores, deps *Dependencies) *services {
)
stopResumeService.SetPollingCallback(pollingLifecycleSvc)
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
iotCard.SetRealnameActivator(packageActivation)
iotCard.SetStopResumeService(stopResumeService)
iotCard.SetDeviceSimBindingStore(s.DeviceSimBinding)
@@ -245,17 +258,25 @@ func initServices(s *stores, deps *Dependencies) *services {
s.EnterpriseDeviceAuthorization,
s.Enterprise,
)
device.SetAccessAudit(auditWriter)
device.SetGatewayIntegrationLog(integrationlog.NewRepository(deps.DB))
device.SetObservationSeriesEventWriter(observationSeriesEvents)
device.SetObservationSeriesDispatcher(observationSeries)
operationPassword := operationPasswordSvc.New(deps.Redis)
shopCommission := shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction, deps.DB, deps.Logger)
shopCommission.SetAuditWriter(auditWriter)
packageService := packageSvc.New(s.Package, s.PackageSeries, s.ShopPackageAllocation, s.ShopSeriesAllocation)
packageService.SetAccessAudit(deps.DB, auditWriter)
packageSeriesService := packageSeriesSvc.New(s.PackageSeries, s.ShopSeriesAllocation, s.Package)
packageSeriesService.SetAccessAudit(deps.DB, auditWriter)
orderService := orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.AssetWallet, s.Payment, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, wechatConfig, deps.WechatPayment, paymentLoader, deps.QueueClient, deps.Logger, s.AssetIdentifier, s.PersonalCustomer, s.PersonalCustomerPhone)
orderService.SetLifecycleAudit(auditWriter)
orderService.SetPaymentIntegrationLog(integrationlog.NewRepository(deps.DB))
orderService.SetObservationSeriesEventWriter(observationSeriesEvents)
walletOutbox := outbox.NewRepository()
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox)
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox, auditWriter)
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox), walletDebitEvents, nil))
orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox, auditWriter), walletDebitEvents, nil))
agentRechargeService := agentRechargeSvc.New(
deps.DB,
s.AgentRecharge,
@@ -267,17 +288,19 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.Redis,
deps.Logger,
)
agentWalletPosting := walletapp.NewPostingService(walletinfra.NewCreditEventWriter(walletOutbox), nil)
agentWalletPosting := walletapp.NewPostingService(walletinfra.NewCreditEventWriter(walletOutbox, auditWriter), nil)
agentRechargeService.SetAgentWalletPostingService(agentWalletPosting)
paymentIntegration := integrationlog.NewRepository(deps.DB)
agentRechargeOnline := agentrechargeApp.NewOnlineCreationService(
deps.DB,
paymentInfra.NewWechatWebAdapter(wechat.NewRedisCache(deps.Redis), paymentIntegration, deps.Logger),
paymentInfra.NewAlipayWapAdapter(paymentIntegration, deps.Logger),
auditWriter,
)
agentRechargePaymentConfirm := agentrechargeApp.NewConfirmOnlinePaymentService(
deps.DB,
paymentInfra.NewAgentRechargePaymentEventWriter(outbox.NewRepository()),
auditWriter,
)
refundService := refundSvc.New(
deps.DB,
@@ -296,8 +319,10 @@ func initServices(s *stores, deps *Dependencies) *services {
)
refundService.SetAgentWalletRefundService(walletapp.NewRefundService(walletinfra.NewRefundEventWriter(walletOutbox), nil))
refundService.SetNotificationOutbox(walletOutbox)
refundService.SetLifecycleAudit(auditWriter)
exchangeService := exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger)
exchangeService.SetShippingCreatedNotifier(exchangeApp.NewShippingCreatedNotifier(exchangeInfra.NewShippingNotificationWriter(outbox.NewRepository())))
exchangeService.SetAccessAudit(auditWriter)
assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder, assetAudit)
agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device)
agentOpenAPI.SetObservationSeriesDispatcher(observationSeries)
@@ -324,21 +349,59 @@ func initServices(s *stores, deps *Dependencies) *services {
approvalInfra.NewSubmissionEventWriter(outbox.NewRepository()),
nil,
)
approvalCreationService.SetAuditWriter(auditWriter)
agentRechargeService.SetOfflineCreationService(
agentrechargeApp.NewOfflineCreationService(deps.DB, approvalCreationService),
agentrechargeApp.NewOfflineCreationService(deps.DB, approvalCreationService, auditWriter),
)
agentRechargeService.SetRechargeAudit(auditWriter)
refundService.SetRefundApprovalCreationService(
refundapprovalApp.NewCreationService(deps.DB, approvalCreationService),
refundapprovalApp.NewCreationService(deps.DB, approvalCreationService, auditWriter),
)
roleService := roleSvc.New(s.Role, s.Permission, s.RolePermission, s.AccountRole, s.ShopRole)
roleService.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
permissionService := permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis)
permissionService.SetAccessAudit(deps.DB, auditWriter)
shopService := shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role)
shopService.SetAccessAudit(deps.DB, deps.Redis, auditWriter)
commissionWithdrawal := commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest)
commissionWithdrawal.SetAuditWriter(auditWriter)
commissionCalculation := commissionCalculationSvc.New(
deps.DB,
s.CommissionRecord,
s.Shop,
s.ShopPackageAllocation,
s.ShopSeriesAllocation,
s.PackageSeries,
s.IotCard,
s.Device,
s.AgentWallet,
s.AgentWalletTransaction,
s.Order,
s.OrderItem,
s.Package,
s.ShopSeriesCommissionStats,
commissionStatsSvc.New(s.ShopSeriesCommissionStats),
deps.Logger,
)
commissionCalculation.SetAuditWriter(auditWriter)
pollingConfigService := pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger)
pollingConfigService.SetAudit(deps.DB, auditWriter)
pollingConcurrencyService := pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis)
pollingConcurrencyService.SetAudit(deps.DB, auditWriter)
pollingAlertService := pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger)
pollingAlertService.SetAudit(deps.DB, auditWriter)
pollingManualTriggerService := pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger)
pollingManualTriggerService.SetAudit(deps.DB, auditWriter)
return &services{
AccessAudit: auditWriter,
Approval: approvalCreationService,
Account: account,
AccountAudit: accountAudit,
AssetAudit: assetAudit,
Role: roleSvc.New(s.Role, s.Permission, s.RolePermission, s.AccountRole, s.ShopRole),
Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis),
PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.Logger),
Role: roleService,
Permission: permissionService,
PersonalCustomer: personalCustomerSvc.NewService(deps.DB, s.PersonalCustomer, s.PersonalCustomerPhone, deps.Logger, auditWriter),
ClientAuth: clientAuthSvc.New(
deps.DB,
s.PersonalCustomerOpenID,
@@ -352,59 +415,43 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.Redis,
deps.Logger,
customerBinding,
auditWriter,
),
Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role),
Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger),
Shop: shopService,
Auth: authService,
ShopCommission: shopCommission,
CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest),
CommissionWithdrawal: commissionWithdrawal,
CommissionWithdrawalSetting: commissionWithdrawalSettingSvc.New(deps.DB, s.Account, s.CommissionWithdrawalSetting),
CommissionCalculation: commissionCalculationSvc.New(
deps.DB,
s.CommissionRecord,
s.Shop,
s.ShopPackageAllocation,
s.ShopSeriesAllocation,
s.PackageSeries,
s.IotCard,
s.Device,
s.AgentWallet,
s.AgentWalletTransaction,
s.Order,
s.OrderItem,
s.Package,
s.ShopSeriesCommissionStats,
commissionStatsSvc.New(s.ShopSeriesCommissionStats),
deps.Logger,
),
Enterprise: enterpriseSvc.New(deps.DB, s.Enterprise, s.Shop, s.Account),
EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization, s.IotCard),
EnterpriseDevice: enterpriseDeviceSvc.New(deps.DB, s.Enterprise, s.Device, s.DeviceSimBinding, s.EnterpriseDeviceAuthorization, s.EnterpriseCardAuthorization, deps.Logger),
Authorization: enterpriseCardSvc.NewAuthorizationService(s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger),
CommissionCalculation: commissionCalculation,
Enterprise: enterpriseSvc.New(deps.DB, s.Enterprise, s.Shop, s.Account, auditWriter),
EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization, s.IotCard, auditWriter),
EnterpriseDevice: enterpriseDeviceSvc.New(deps.DB, s.Enterprise, s.Device, s.DeviceSimBinding, s.EnterpriseDeviceAuthorization, s.EnterpriseCardAuthorization, deps.Logger, auditWriter),
Authorization: enterpriseCardSvc.NewAuthorizationService(deps.DB, s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger, auditWriter),
IotCard: iotCard,
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient, assetAudit),
ExportTask: exportTaskSvc.New(deps.DB, s.ExportTask, deps.QueueClient, deps.StorageService),
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient, assetAudit, auditWriter),
ExportTask: exportTaskSvc.New(deps.DB, s.ExportTask, deps.QueueClient, deps.StorageService, auditWriter),
Device: device,
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient, assetAudit),
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient, assetAudit, auditWriter),
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
Carrier: carrierSvc.New(s.Carrier),
PackageSeries: packageSeriesSvc.New(s.PackageSeries, s.ShopSeriesAllocation, s.Package),
Carrier: carrierSvc.New(s.Carrier, auditWriter),
PackageSeries: packageSeriesService,
Package: packageService,
PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger),
PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, deps.Logger),
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop, accountAudit),
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop),
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger),
ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop, auditWriter),
ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, auditWriter),
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger, auditWriter),
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
PurchaseValidation: purchaseValidation,
Order: orderService,
Exchange: exchangeService,
Recharge: rechargeSvc.New(deps.DB, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, paymentLoader, deps.Logger),
PollingConfig: pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger),
PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis),
PollingConfig: pollingConfigService,
PollingConcurrency: pollingConcurrencyService,
PollingMonitoring: pollingSvc.NewMonitoringServiceWithQueueMgr(deps.Redis, pollingQueueMgr, deps.Logger),
PollingAlert: pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger),
PollingAlert: pollingAlertService,
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
PollingManualTrigger: pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger),
PollingManualTrigger: pollingManualTriggerService,
Asset: assetService,
AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device, assetAudit),
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),
@@ -419,8 +466,8 @@ func initServices(s *stores, deps *Dependencies) *services {
AgentOpenAPI: agentOpenAPI,
Refund: refundService,
CustomerBinding: customerBinding,
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient),
AssetPackageBatchOrder: assetPackageBatchOrderSvc.New(s.AssetPackageBatchOrderTask, s.Package, deps.QueueClient),
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient, auditWriter),
AssetPackageBatchOrder: assetPackageBatchOrderSvc.New(s.AssetPackageBatchOrderTask, s.Package, deps.QueueClient, auditWriter),
ObservationSeries: observationSeries,
CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeries,

View File

@@ -75,6 +75,7 @@ type Handlers struct {
ClientWechat *app.ClientWechatHandler
SuperAdmin *admin.SuperAdminHandler
SystemConfig *admin.SystemConfigHandler
Audit *admin.AuditHandler
WeCom *admin.WeComHandler
AgentOpenAPI *openapiHandler.Handler
}

View File

@@ -3,6 +3,7 @@ package bootstrap
import (
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
auditInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
cardObservationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
@@ -31,6 +32,7 @@ type workerServices struct {
func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *queue.WorkerServices {
assetAudit := assetAuditSvc.NewService(stores.AssetOperationLog, deps.DB)
auditWriter := auditInfra.NewWriter(auditInfra.NewRegistry(), nil)
commissionStatsService := commission_stats.New(stores.ShopSeriesCommissionStats)
commissionCalculationService := commission_calculation.New(
@@ -51,6 +53,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
commissionStatsService,
deps.Logger,
)
commissionCalculationService.SetAuditWriter(auditWriter)
usageService := packagepkg.NewUsageService(
deps.DB,
@@ -76,6 +79,9 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stores.PackageUsage,
deps.Logger,
)
activationService.SetLifecycleAudit(auditWriter)
usageService.SetLifecycleAudit(auditWriter)
resetService.SetLifecycleAudit(auditWriter)
alertService := pollingSvc.NewAlertService(
stores.PollingAlertRule,
@@ -96,11 +102,18 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
cardObservationInfra.NewEventWriter(cardObservationOutbox),
cardObservationInfra.NewCacheInvalidator(deps.Redis, deps.Logger),
)
iotCardAuditService := iotCardSvc.New(
deps.DB, stores.IotCard, stores.Shop, stores.AssetAllocationRecord,
stores.ShopPackageAllocation, stores.ShopSeriesAllocation, stores.PackageSeries,
deps.GatewayClient, deps.Logger, assetAudit,
)
iotCardAuditService.SetAccessAudit(auditWriter)
cardObservationService.SetStateAuditWriter(iotCardAuditService)
cardObservationIntegration := integrationlog.NewRepository(deps.DB)
cardObservationSeriesCoordinator := cardObservationInfra.NewSeriesCoordinator(deps.Redis)
cardObservationSeriesService := cardObservationApp.NewSeriesAttemptService(
cardObservationSeriesCoordinator,
cardObservationInfra.NewSeriesRunner(deps.DB, deps.GatewayClient, cardObservationService, cardObservationIntegration),
cardObservationInfra.NewSeriesRunner(deps.DB, deps.GatewayClient, cardObservationService, cardObservationIntegration, auditWriter),
cardObservationInfra.NewSeriesAttemptLogger(cardObservationIntegration),
)
@@ -131,9 +144,11 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stores.PersonalCustomer,
stores.PersonalCustomerPhone,
)
orderService.SetLifecycleAudit(auditWriter)
orderService.SetPaymentIntegrationLog(integrationlog.NewRepository(deps.DB))
walletOutbox := outbox.NewRepository()
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox)
orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox), walletDebitEvents, nil))
walletDebitEvents := walletinfra.NewDebitEventWriter(walletOutbox, auditWriter)
orderService.SetAgentWalletReservationService(walletapp.NewReservationService(walletinfra.NewReservationEventWriter(walletOutbox, auditWriter), walletDebitEvents, nil))
orderService.SetAgentWalletDebitService(walletapp.NewDebitService(walletDebitEvents, nil))
orderService.SetObservationSeriesEventWriter(observationSeriesEvents)
@@ -148,6 +163,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
assetAudit,
)
stopResumeService.SetObservationSeriesEventWriter(deps.DB, observationSeriesEvents)
stopResumeService.SetUnifiedAudit(auditWriter, integrationlog.NewRepository(deps.DB))
activationService.SetObservationSeriesEventWriter(observationSeriesEvents)
usageService.SetStopResumeCallback(stopResumeService)
activationService.SetResumeCallback(stopResumeService)
@@ -160,6 +176,8 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
)
return &queue.WorkerServices{
PaymentAudit: auditWriter,
RechargeAudit: auditWriter,
CardObservation: cardObservationService,
CardObservationSeries: cardObservationSeriesService,
ObservationSeriesEvents: observationSeriesEvents,

View File

@@ -38,6 +38,22 @@ type Client struct {
maxRetries int
}
type attemptObserverKey struct{}
// AttemptObserver 记录一次 Gateway HTTP 尝试的开始和结果。
type AttemptObserver interface {
BeforeAttempt(ctx context.Context, attempt int) error
AfterAttempt(ctx context.Context, attempt int, callErr error) error
}
// WithAttemptObserver 为当前 Gateway 调用注入逐次 HTTP 尝试观察器。
func WithAttemptObserver(ctx context.Context, observer AttemptObserver) context.Context {
if observer == nil {
return ctx
}
return context.WithValue(ctx, attemptObserverKey{}, observer)
}
// requestWrapper 用于将请求参数包装为 Gateway 的 {"params": ...} 格式
type requestWrapper struct {
Params interface{} `json:"params"`
@@ -101,6 +117,7 @@ func (c *Client) doRequest(ctx context.Context, path string, params interface{})
// 带重试的 HTTP 请求
var lastErr error
observer, _ := ctx.Value(attemptObserverKey{}).(AttemptObserver)
for attempt := 0; attempt <= c.maxRetries; attempt++ {
if attempt > 0 {
// 检查用户 Context 是否已取消
@@ -120,7 +137,18 @@ func (c *Client) doRequest(ctx context.Context, path string, params interface{})
time.Sleep(delay)
}
attemptNumber := attempt + 1
if observer != nil {
if err := observer.BeforeAttempt(ctx, attemptNumber); err != nil {
return nil, err
}
}
result, retryable, err := c.executeHTTPRequest(ctx, path, encryptedData)
if observer != nil {
if observeErr := observer.AfterAttempt(ctx, attemptNumber, err); observeErr != nil {
return nil, observeErr
}
}
if err != nil {
lastErr = err
// 仅对网络级错误重试

View File

@@ -35,6 +35,7 @@ type Entry struct {
PrimaryResource string `json:"primary_resource,omitempty"`
AffectedResource string `json:"affected_resource,omitempty"`
ActorSource string `json:"actor_source"`
Visibility string `json:"visibility"`
Transaction string `json:"transaction"`
FailureStrategy string `json:"failure_strategy"`
SensitivePolicy string `json:"sensitive_policy"`
@@ -46,7 +47,10 @@ type Entry struct {
// 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"}
files := []string{
"internal/routes", "internal/application", "internal/domain", "internal/service",
"internal/handler", "internal/infrastructure", "internal/polling", "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 {
@@ -108,7 +112,10 @@ func scanFile(root, path string) ([]Entry, error) {
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 {
if !pathOK {
pathSuffix = expression(call.Args[4])
}
if methodOK {
entry := classifyHTTP(relative, position.Line, method, pathSuffix, expression(call.Args[5]), routeSummary(call.Args[6]))
entries = append(entries, entry)
}
@@ -129,6 +136,12 @@ func scanFile(root, path string) ([]Entry, error) {
entries = append(entries, classifySchedule(relative, position.Line, taskType, schedule))
}
}
case "LogOperation":
entries = append(entries, classifyLegacyWriter(relative, position.Line, expression(call.Fun)))
case "Start", "Complete", "RecordInbound":
if isIntegrationLogCall(relative, expression(selector.X)) {
entries = append(entries, classifyIntegrationLog(relative, position.Line, expression(call.Fun)))
}
}
return true
})
@@ -136,7 +149,7 @@ func scanFile(root, path string) ([]Entry, error) {
strings.HasPrefix(relative, "internal/service/") {
for _, declaration := range file.Decls {
function, ok := declaration.(*ast.FuncDecl)
if !ok || function.Recv == nil || !isBusinessMethod(function.Name.Name) {
if !ok || function.Recv == nil || !isBusinessMethod(function) {
continue
}
position := set.Position(function.Pos())
@@ -154,14 +167,15 @@ func classifyHTTP(file string, line int, method, path, handler, summary string)
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),
Method: method, Path: path, Summary: summary, ActorSource: httpActorSource(file, path, handler),
DomainLedger: ledgerDecision(owner), IntegrationLog: integrationDecision(file, path),
Outbox: "按用例是否存在提交后可靠副作用决定;无可靠副作用时 N/A",
Visibility: httpVisibility(file, path, handler),
SensitivePolicy: "禁止字段删除手机号、IP、ICCID、金额和第三方单号按权限脱敏单字段 16KB 上限",
BeforeAfterPolicy: "写操作保存脱敏后的直接字段变化;批量命令保存摘要和权威明细引用",
TestSeam: "真实 Fiber + Application/Service 公共用例 + PostgreSQL 事实;覆盖门禁静态比对本入口",
}
if method == "GET" && !isSensitiveRead(file, path, summary) {
if isReadOnlyHTTP(method, path) && !isSensitiveRead(file, path, summary) {
entry.AuditEvent = "N/A"
entry.Transaction = "N/A"
entry.FailureStrategy = "Access Log 记录统一错误;普通读取不创建业务审计"
@@ -192,6 +206,7 @@ func classifyWorker(file string, line int, taskType, handler string) Entry {
Category: categoryFor(owner), Risk: riskFor(owner, taskType, handler),
PrimaryResource: owner, AffectedResource: "任务载荷定位的直接业务资源",
ActorSource: "system_task/asynq", Transaction: "业务状态变化、领域流水和 Audit Event 按用例原子提交",
Visibility: "内部系统入口;外部主体只读取对应业务安全投影",
FailureStrategy: "Worker 返回错误由公共重试恢复;终态失败保存中文安全摘要,禁止裸 goroutine 审计",
SensitivePolicy: "不记录完整任务载荷、文件内容、外部正文、凭证或签名 URL",
BeforeAfterPolicy: "状态变化保存直接前后值;无业务变化时仅保留 Integration Log",
@@ -206,6 +221,7 @@ func classifySchedule(file string, line int, taskType, schedule string) Entry {
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",
Visibility: "内部系统入口,不直接对用户展示",
FailureStrategy: "调度注册失败阻止 Worker 启动;执行结果由对应 Worker 入口负责",
SensitivePolicy: "调度日志仅记录任务类型与安全时间信息",
BeforeAfterPolicy: "N/A调度入口不修改业务事实",
@@ -230,6 +246,7 @@ func classifyBusinessMethod(file string, line int, method string) Entry {
ActionCode: actionCode(owner, method), Risk: riskFor(owner, file, method),
PrimaryResource: owner, AffectedResource: "完整用例直接修改或引用的资源",
ActorSource: "由调用入口传入操作者与来源快照",
Visibility: "由完整用例决定平台完整视图、主体安全投影或 internal_only",
SensitivePolicy: "禁止字段删除;受控字段脱敏;批量明细留在领域任务或制品",
BeforeAfterPolicy: "完整用例保存脱敏后的直接业务变化Domain 方法由 Application 投影",
TestSeam: "Application/Service 公共方法 + PostgreSQL 事实Domain 使用纯领域测试;覆盖门禁静态比对本入口",
@@ -254,6 +271,45 @@ func classifyBusinessMethod(file string, line int, method string) Entry {
return entry
}
func classifyLegacyWriter(file string, line int, call string) Entry {
return Entry{
Key: fmt.Sprintf("legacy_writer:%s:%d:%s", file, line, call), Kind: "legacy_writer",
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, call), Owner: filepath.Base(filepath.Dir(file)),
Summary: "调用旧 Operation Log Writer", AuditEvent: "必须迁移到统一 Audit Event 后停写旧表",
DomainLedger: "既有业务表仍是权威事实,旧 Operation Log 不是 Domain Ledger",
IntegrationLog: "N/A旧 Writer 仅记录内部操作;实际外部交互由 Integration Log 单独记录",
Outbox: "由原完整用例决定,旧 Writer 不得替代 Outbox",
ActionCode: actionCode(filepath.Base(filepath.Dir(file)), call), ActionName: "迁移旧审计写入",
Category: categoryFor(file), Risk: riskFor(file, call, ""), PrimaryResource: filepath.Base(filepath.Dir(file)),
AffectedResource: "按原完整业务用例登记实际资源", ActorSource: "沿用原调用入口真实操作者",
Visibility: "旧表仅保留平台历史入口;新事件按 Registry 生成主体安全投影",
Transaction: "迁移后关键成功与业务事实同事务,旧异步 Writer 停写",
FailureStrategy: "迁移后业务回滚的 failed/denied 使用独立短事务;禁止裸 goroutine",
SensitivePolicy: "迁移时删除密码、Token、Secret、私钥、Cookie、签名 URL 等安全凭据",
BeforeAfterPolicy: "按资源保存本次直接变化,不复制旧单体 JSON",
TestSeam: "静态调用归零扫描 + 对应业务入口与数据库抽样核对",
}
}
func classifyIntegrationLog(file string, line int, call string) Entry {
return Entry{
Key: fmt.Sprintf("integration_log:%s:%d:%s", file, line, call), Kind: "integration_log",
CodeEntry: fmt.Sprintf("%s:%d %s", file, line, call), Owner: filepath.Base(filepath.Dir(file)),
Summary: "记录外部交互尝试或终态", AuditEvent: "N/A",
DomainLedger: "N/AIntegration Log 只记录外部交互事实,不替代内部业务表",
IntegrationLog: "必须:保存实际请求、未发送裁决、入站回调或终态安全摘要",
Outbox: "存在提交后可靠副作用时由业务用例另行登记;本调用点不替代 Outbox",
ActorSource: "external_system 或发起外呼的真实 Application/Worker/Callback",
Visibility: "仅平台内部调查完整可见;代理/企业不得读取外部交互细节",
Transaction: "按外部尝试生命周期写入;内部状态变化另由业务事务记录 Audit Event",
FailureStrategy: "保留真实 failed/unknown/not_sent 结果,不把记录失败伪装成业务成功",
SensitivePolicy: "请求、响应和 metadata 写入前删除凭据,历史读取再次清理",
BeforeAfterPolicy: "N/A保存外部尝试结构化摘要和本地状态是否变化",
TestSeam: "Integration Log 数据抽样 + 调用链 correlation/series/attempt 核对",
NAReason: "该入口只记录外部交互事实;只有改变内部业务事实时才由业务用例另写 Audit Event",
}
}
func routeSummary(expr ast.Expr) string {
composite, ok := expr.(*ast.CompositeLit)
if !ok {
@@ -305,13 +361,18 @@ func expression(expr ast.Expr) string {
return value.Value
case *ast.CallExpr:
return expression(value.Fun)
case *ast.BinaryExpr:
return expression(value.X) + value.Op.String() + expression(value.Y)
default:
return fmt.Sprintf("%T", expr)
}
}
func httpActorSource(file string) string {
func httpActorSource(file, path, handler string) string {
text := strings.ToLower(file + " " + path + " " + handler)
switch {
case strings.Contains(text, "callback") || strings.Contains(path, "/carriers/"):
return "external_system/callback"
case strings.HasSuffix(file, "personal.go"):
return "personal_customer/personal_api"
case strings.HasSuffix(file, "order.go"):
@@ -321,14 +382,46 @@ func httpActorSource(file string) string {
}
}
func httpVisibility(file, path, handler string) string {
text := strings.ToLower(file + " " + path + " " + handler)
switch {
case strings.Contains(text, "callback"):
return "外部回调入口;只记录内部完整事实,不直接向外部主体展示"
case strings.Contains(text, "/audit"):
return "仅超级管理员和平台账号可见"
case strings.Contains(text, "enterprise"):
return "企业认证上下文范围内可见;内部审计字段不可见"
case strings.Contains(text, "personal"):
return "当前个人客户本人范围内可见"
default:
return "按认证账号类型和现有数据权限可见;审计调查另按平台/主体投影隔离"
}
}
func isIntegrationLogCall(file, receiver string) bool {
if strings.Contains(file, "/integrationlog/") {
return false
}
receiver = strings.ToLower(receiver)
return strings.Contains(receiver, "integration")
}
func isSensitiveRead(file, path, summary string) bool {
text := strings.ToLower(file + " " + path + " " + summary)
for _, marker := range []string{"download", "export", "realname-link", "实名", "敏感", "完整", "operation-password"} {
for _, marker := range []string{"download", "realname-link", "realname/link", "实名链接", "敏感", "realtime-status"} {
if strings.Contains(text, marker) {
return true
}
}
return false
return strings.HasSuffix(file, "wecom.go") && path == "/applications" ||
strings.HasSuffix(file, "export_task.go") && path == "/:id"
}
func isReadOnlyHTTP(method, path string) bool {
if method == "GET" {
return true
}
return strings.Contains(path, "purchase-check") || strings.Contains(path, "verify-asset")
}
func integrationDecision(file, path string) string {
@@ -412,7 +505,11 @@ func workerOwner(taskType string) string {
return normalize(strings.TrimPrefix(taskType, "constants.TaskType"))
}
func isBusinessMethod(name string) bool {
func isBusinessMethod(function *ast.FuncDecl) bool {
name := function.Name.Name
if strings.HasPrefix(name, "Set") && !hasContextParameter(function) {
return false
}
for _, prefix := range []string{
"Create", "Update", "Delete", "Set", "Assign", "Remove", "Cancel", "Reject", "Approve",
"Import", "Allocate", "Recall", "Stop", "Resume", "Bind", "Unbind", "Reset", "Activate",
@@ -429,6 +526,19 @@ func isBusinessMethod(name string) bool {
return false
}
func hasContextParameter(function *ast.FuncDecl) bool {
if function.Type.Params == nil {
return false
}
for _, field := range function.Type.Params.List {
selector, ok := field.Type.(*ast.SelectorExpr)
if ok && expression(selector) == "context.Context" {
return true
}
}
return false
}
func normalize(value string) string {
value = strings.Trim(value, "\"")
var output []rune

View File

@@ -452,6 +452,10 @@ func (h *AssetHandler) Orders(c *fiber.Ctx) error {
// OperationLogs 查询资产操作审计日志
// GET /api/admin/assets/:identifier/operation-logs
func (h *AssetHandler) OperationLogs(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
if h.assetAuditService == nil {
return errors.New(errors.CodeInternalError, "资产审计服务未配置")
}

View File

@@ -0,0 +1,370 @@
package admin
import (
"time"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
auditquery "github.com/break/junhong_cmp_fiber/internal/query/audit"
integrationquery "github.com/break/junhong_cmp_fiber/internal/query/integration"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// AuditHandler 提供平台基础审计调查只读接口。
type AuditHandler struct {
auditQuery *auditquery.Query
integrationQuery *integrationquery.Query
}
// NewAuditHandler 创建平台基础审计调查 Handler。
func NewAuditHandler(auditQuery *auditquery.Query, integrationQuery *integrationquery.Query) *AuditHandler {
return &AuditHandler{auditQuery: auditQuery, integrationQuery: integrationQuery}
}
// ListEvents 查询平台全局审计事件。
// GET /api/admin/audit/events
func (h *AuditHandler) ListEvents(c *fiber.Ctx) error {
var request dto.AuditEventListRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
if err != nil || invalidAuditPage(request.Page, request.PageSize) {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.auditQuery.List(c.UserContext(), auditquery.EventFilter{
CreatedFrom: from, CreatedTo: to, Action: request.Action, Category: request.Category,
ActorKind: request.ActorKind, ActorID: request.ActorID, Source: request.Source,
Result: request.Result, Risk: request.Risk, ScopeType: request.ScopeType, ScopeID: request.ScopeID,
ResourceType: request.ResourceType, ResourceID: request.ResourceID, ResourceKey: request.ResourceKey,
RequestID: request.RequestID, CorrelationID: request.CorrelationID,
Page: request.Page, PageSize: request.PageSize,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// GetEvent 查询单个稳定审计事件详情。
// GET /api/admin/audit/events/:event_id
func (h *AuditHandler) GetEvent(c *fiber.Ctx) error {
result, err := h.auditQuery.Get(c.UserContext(), c.Params("event_id"))
if err != nil {
return err
}
return response.Success(c, result)
}
// ListActorEvents 查询操作者行为时间线。
// GET /api/admin/audit/actors/:kind/:id/events
func (h *AuditHandler) ListActorEvents(c *fiber.Ctx) error {
var request dto.AuditActorEventsRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
request.Kind, request.ID = c.Params("kind"), c.Params("id")
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
if err != nil || invalidAuditPage(request.Page, request.PageSize) {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.auditQuery.ListActorEvents(c.UserContext(), auditquery.ActorEventFilter{
Kind: request.Kind, ID: request.ID, Action: request.Action, Result: request.Result, Risk: request.Risk,
ResourceType: request.ResourceType, ResourceID: request.ResourceID,
CreatedFrom: from, CreatedTo: to, Page: request.Page, PageSize: request.PageSize,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// SearchResources 按注册业务标识精确搜索资源。
// GET /api/admin/audit/resources/search
func (h *AuditHandler) SearchResources(c *fiber.Ctx) error {
var request dto.AuditResourceSearchRequest
if err := c.QueryParser(&request); err != nil || invalidAuditPage(request.Page, request.PageSize) {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.auditQuery.SearchResources(c.UserContext(), auditquery.ResourceSearchFilter{
ResourceType: request.ResourceType, Keyword: request.Keyword,
Page: request.Page, PageSize: request.PageSize,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// ResourceTimeline 查询资源作为任意关系参与的通用事件时间线。
// GET /api/admin/audit/resources/:resource_type/:resource_id/timeline
func (h *AuditHandler) ResourceTimeline(c *fiber.Ctx) error {
var request dto.AuditResourceTimelineRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
request.ResourceType, request.ResourceID = c.Params("resource_type"), c.Params("resource_id")
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
if err != nil || invalidAuditPage(request.Page, request.PageSize) {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.auditQuery.ResourceTimeline(c.UserContext(), auditquery.ResourceTimelineFilter{
ResourceType: request.ResourceType, ResourceID: request.ResourceID,
CreatedFrom: from, CreatedTo: to, Action: request.Action, Result: request.Result,
Page: request.Page, PageSize: request.PageSize,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// RequestTimeline 查询指定 HTTP 请求关联的跨事实时间线。
// GET /api/admin/audit/requests/:request_id/timeline
func (h *AuditHandler) RequestTimeline(c *fiber.Ctx) error {
requestID := c.Params("request_id")
if requestID == "" {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.auditQuery.RequestTimeline(c.UserContext(), requestID)
if err != nil {
return err
}
return response.Success(c, result)
}
// CorrelationTimeline 查询跨请求业务关联时间线。
// GET /api/admin/audit/correlations/:correlation_id/timeline
func (h *AuditHandler) CorrelationTimeline(c *fiber.Ctx) error {
correlationID := c.Params("correlation_id")
if correlationID == "" {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.auditQuery.CorrelationTimeline(c.UserContext(), correlationID)
if err != nil {
return err
}
return response.Success(c, result)
}
// FinanceTimeline 查询资金审计与业务账本的组合时间线。
// GET /api/admin/audit/finance/timeline
func (h *AuditHandler) FinanceTimeline(c *fiber.Ctx) error {
var request dto.AuditFinanceTimelineRequest
if err := c.QueryParser(&request); err != nil || invalidAuditPage(request.Page, request.PageSize) {
return errors.New(errors.CodeInvalidParam)
}
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
if err != nil {
return errors.New(errors.CodeInvalidParam)
}
result, err := h.auditQuery.FinanceTimeline(c.UserContext(), auditquery.FinanceFilter{
ShopID: request.ShopID, WalletID: request.WalletID, OrderID: request.OrderID, OrderNo: request.OrderNo,
PaymentID: request.PaymentID, PaymentNo: request.PaymentNo, RefundID: request.RefundID, RefundNo: request.RefundNo,
RechargeID: request.RechargeID, RechargeNo: request.RechargeNo, ApprovalInstanceID: request.ApprovalInstanceID,
ThirdPartyTradeNo: request.ThirdPartyTradeNo, ActorKind: request.ActorKind, ActorID: request.ActorID,
CorrelationID: request.CorrelationID, CreatedFrom: from, CreatedTo: to, Page: request.Page, PageSize: request.PageSize,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// RiskOverview 查询固定风险信号总览。
// GET /api/admin/audit/risks/overview
func (h *AuditHandler) RiskOverview(c *fiber.Ctx) error {
var request dto.AuditRiskOverviewRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
filter, err := riskFilter(request.AuditRiskFilterRequest, 0, 0)
if err != nil {
return err
}
result, err := h.auditQuery.RiskOverview(c.UserContext(), filter)
if err != nil {
return err
}
return response.Success(c, result)
}
// RiskEvents 查询固定风险集合的事件明细。
// GET /api/admin/audit/risks/events
func (h *AuditHandler) RiskEvents(c *fiber.Ctx) error {
var request dto.AuditRiskEventsRequest
if err := c.QueryParser(&request); err != nil || invalidAuditPage(request.Page, request.PageSize) {
return errors.New(errors.CodeInvalidParam)
}
filter, err := riskFilter(request.AuditRiskFilterRequest, request.Page, request.PageSize)
if err != nil {
return err
}
result, err := h.auditQuery.RiskEvents(c.UserContext(), filter)
if err != nil {
return err
}
return response.Success(c, result)
}
func riskFilter(request dto.AuditRiskFilterRequest, page, pageSize int) (auditquery.RiskFilter, error) {
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
if err != nil {
return auditquery.RiskFilter{}, errors.New(errors.CodeInvalidParam)
}
return auditquery.RiskFilter{
CreatedFrom: from, CreatedTo: to, Risk: request.Risk, Result: request.Result,
Action: request.Action, Source: request.Source, Page: page, PageSize: pageSize,
}, nil
}
// AgentResourceActivities 查询代理范围内的安全资源活动。
// GET /api/admin/agent/resource-activities/:resource_type/:identifier
func (h *AuditHandler) AgentResourceActivities(c *fiber.Ctx) error {
request, from, to, err := subjectActivityRequest(c)
if err != nil {
return err
}
result, err := h.auditQuery.AgentResourceActivities(c.UserContext(), auditquery.SubjectActivityFilter{
ResourceType: request.ResourceType, Identifier: request.Identifier,
CreatedFrom: from, CreatedTo: to,
Page: request.Page, PageSize: request.PageSize,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// EnterpriseResourceActivities 查询企业当前有效授权资产的安全资源活动。
// GET /api/admin/enterprise/resource-activities/:resource_type/:identifier
func (h *AuditHandler) EnterpriseResourceActivities(c *fiber.Ctx) error {
request, from, to, err := subjectActivityRequest(c)
if err != nil {
return err
}
result, err := h.auditQuery.EnterpriseResourceActivities(c.UserContext(), auditquery.SubjectActivityFilter{
ResourceType: request.ResourceType, Identifier: request.Identifier,
CreatedFrom: from, CreatedTo: to,
Page: request.Page, PageSize: request.PageSize,
})
if err != nil {
return err
}
return response.Success(c, result)
}
func subjectActivityRequest(c *fiber.Ctx) (dto.SubjectResourceActivityRequest, *time.Time, *time.Time, error) {
var request dto.SubjectResourceActivityRequest
if err := c.QueryParser(&request); err != nil || invalidAuditPage(request.Page, request.PageSize) {
return request, nil, nil, errors.New(errors.CodeInvalidParam)
}
request.ResourceType = c.Params("resource_type")
request.Identifier = c.Params("identifier")
if request.ResourceType == "" || request.Identifier == "" {
return request, nil, nil, errors.New(errors.CodeInvalidParam)
}
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
if err != nil {
return request, nil, nil, errors.New(errors.CodeInvalidParam)
}
return request, from, to, nil
}
// IntegrationOverview 查询外部集成交互总览。
// GET /api/admin/audit/integrations/overview
func (h *AuditHandler) IntegrationOverview(c *fiber.Ctx) error {
var request dto.IntegrationOverviewRequest
if err := c.QueryParser(&request); err != nil {
return errors.New(errors.CodeInvalidParam)
}
filter, err := integrationFilter(request.IntegrationFilterRequest)
if err != nil {
return err
}
result, err := h.integrationQuery.Overview(c.UserContext(), integrationquery.OverviewFilter{
ListFilter: filter,
Bucket: request.Bucket,
})
if err != nil {
return err
}
return response.Success(c, result)
}
// ListIntegrations 查询外部集成交互列表。
// GET /api/admin/audit/integrations
func (h *AuditHandler) ListIntegrations(c *fiber.Ctx) error {
var request dto.IntegrationListRequest
if err := c.QueryParser(&request); err != nil || invalidAuditPage(request.Page, request.PageSize) {
return errors.New(errors.CodeInvalidParam)
}
filter, err := integrationFilter(request.IntegrationFilterRequest)
if err != nil {
return err
}
filter.Page, filter.PageSize = request.Page, request.PageSize
result, err := h.integrationQuery.List(c.UserContext(), filter)
if err != nil {
return err
}
return response.Success(c, result)
}
// GetIntegration 查询稳定外部集成记录详情。
// GET /api/admin/audit/integrations/:integration_id
func (h *AuditHandler) GetIntegration(c *fiber.Ctx) error {
result, err := h.integrationQuery.Get(c.UserContext(), c.Params("integration_id"))
if err != nil {
return err
}
return response.Success(c, result)
}
func integrationFilter(request dto.IntegrationFilterRequest) (integrationquery.ListFilter, error) {
from, to, err := auditTimeRange(request.CreatedFrom, request.CreatedTo)
if err != nil {
return integrationquery.ListFilter{}, errors.New(errors.CodeInvalidParam)
}
return integrationquery.ListFilter{
CreatedFrom: from, CreatedTo: to, IntegrationID: request.IntegrationID,
Provider: request.Provider, Direction: request.Direction, Operation: request.Operation,
Result: request.Result, ResultCategory: request.ResultCategory, ExternalID: request.ExternalID,
ResourceType: request.ResourceType, ResourceID: request.ResourceID, ResourceKey: request.ResourceKey,
TriggerSource: request.TriggerSource, TriggerScene: request.TriggerScene, TriggerSeries: request.TriggerSeries,
StateChanged: request.StateChanged, HTTPStatus: request.HTTPStatus, ProviderCode: request.ProviderCode,
RequestID: request.RequestID, CorrelationID: request.CorrelationID,
}, nil
}
func auditTimeRange(fromValue, toValue string) (*time.Time, *time.Time, error) {
from, err := optionalAuditTime(fromValue)
if err != nil {
return nil, nil, err
}
to, err := optionalAuditTime(toValue)
if err != nil {
return nil, nil, err
}
if from != nil && to != nil && !from.Before(*to) {
return nil, nil, errors.New(errors.CodeInvalidParam)
}
return from, to, nil
}
func optionalAuditTime(value string) (*time.Time, error) {
if value == "" {
return nil, nil
}
parsed, err := time.Parse(time.RFC3339, value)
if err != nil {
return nil, err
}
return &parsed, nil
}
func invalidAuditPage(page, pageSize int) bool {
return page < 0 || pageSize < 0 || pageSize > 100
}

View File

@@ -137,7 +137,7 @@ func (h *ShopHandler) UpdateCreditLimit(c *fiber.Ctx) error {
if h.changeCreditService == nil {
return errors.New(errors.CodeInternalError, "店铺信用额度服务未配置")
}
result, err := h.changeCreditService.Execute(c.UserContext(), uint(id), *request.CreditEnabled, *request.CreditLimit, *request.Version)
result, err := h.changeCreditService.Execute(c.UserContext(), uint(id), *request.CreditEnabled, *request.CreditLimit)
if err != nil {
return err
}

View File

@@ -8,6 +8,8 @@ import (
"strings"
"time"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
@@ -46,6 +48,8 @@ type ClientWalletHandler struct {
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
paymentMethodPolicy ClientPaymentMethodPolicy
auditWriter *audit.Writer
paymentIntegration *integrationlog.Repository
}
// SetPaymentMethodPolicy 注入 C 端支付方式策略。
@@ -53,6 +57,12 @@ func (h *ClientWalletHandler) SetPaymentMethodPolicy(policy ClientPaymentMethodP
h.paymentMethodPolicy = policy
}
// SetPaymentAudit 注入充值支付审计与外部交互日志接缝。
func (h *ClientWalletHandler) SetPaymentAudit(writer *audit.Writer, integration *integrationlog.Repository) {
h.auditWriter = writer
h.paymentIntegration = integration
}
// NewClientWalletHandler 创建 C 端钱包处理器
func NewClientWalletHandler(
assetService *asset.Service,
@@ -352,6 +362,10 @@ func (h *ClientWalletHandler) createWechatRecharge(
// 先初始化生效支付通道并创建预支付订单,确认支付通道可用
// 避免先写入充值记录后支付初始化失败,导致产生孤儿记录
attempt, startedAt, err := h.startRechargePaymentAttempt(resolved.SkipPermissionCtx, config, paymentNo, rechargeNo, req.Amount)
if err != nil {
return err
}
payConfig, err := h.createClientRechargePayConfig(
resolved.SkipPermissionCtx,
config,
@@ -363,6 +377,12 @@ func (h *ClientWalletHandler) createWechatRecharge(
int(req.Amount),
)
if err != nil {
if completeErr := h.completeRechargePaymentAttempt(resolved.SkipPermissionCtx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "充值支付预下单结果未知"); completeErr != nil {
return completeErr
}
return err
}
if err := h.completeRechargePaymentAttempt(resolved.SkipPermissionCtx, attempt, startedAt, constants.IntegrationResultSuccess, "SUCCESS", ""); err != nil {
return err
}
@@ -381,10 +401,6 @@ func (h *ClientWalletHandler) createWechatRecharge(
OperatorType: constants.OperatorTypePersonalCustomer,
Generation: resolved.Generation,
}
if err := h.rechargeOrderStore.Create(resolved.SkipPermissionCtx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
payment := &model.Payment{
PaymentNo: paymentNo,
OrderID: rechargeOrder.ID,
@@ -394,8 +410,17 @@ func (h *ClientWalletHandler) createWechatRecharge(
Status: model.PaymentRecordStatusPending,
PaymentConfigID: &config.ID,
}
if err := h.paymentStore.Create(resolved.SkipPermissionCtx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
if err := h.db.WithContext(resolved.SkipPermissionCtx).Transaction(func(tx *gorm.DB) error {
if err := h.rechargeOrderStore.CreateWithTx(resolved.SkipPermissionCtx, tx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
payment.OrderID = rechargeOrder.ID
if err := h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
}
return h.appendRechargePaymentCreatedAudit(resolved.SkipPermissionCtx, tx, payment, rechargeOrder)
}); err != nil {
return err
}
return response.Success(c, &dto.ClientRechargeResponse{
@@ -456,14 +481,17 @@ func (h *ClientWalletHandler) createAlipayRecharge(
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
payment.OrderID = rechargeOrder.ID
return h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment)
if err := h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment); err != nil {
return err
}
return h.appendRechargePaymentCreatedAudit(resolved.SkipPermissionCtx, tx, payment, rechargeOrder)
}); err != nil {
return err
}
wapURL, err := alipay.BuildWapPayURL(resolved.SkipPermissionCtx, config, payment, "资产钱包充值")
if err != nil {
if updateErr := h.paymentStore.UpdateStatus(resolved.SkipPermissionCtx, payment.ID, model.PaymentRecordStatusFailed); updateErr != nil {
if updateErr := h.markRechargePaymentFailed(resolved.SkipPermissionCtx, payment); updateErr != nil {
h.logger.Warn("标记支付宝支付单 failed 失败",
zap.String("payment_no", paymentNo),
zap.Error(updateErr),

View File

@@ -0,0 +1,100 @@
package app
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
func (h *ClientWalletHandler) appendRechargePaymentCreatedAudit(ctx context.Context, tx *gorm.DB, payment *model.Payment, recharge *model.RechargeOrder) error {
if h.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "充值支付统一审计接缝未配置")
}
rechargeID := strconv.FormatUint(uint64(recharge.ID), 10)
resources := []audit.ResourceInput{
audit.PaymentResource(payment, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePaymentTarget, nil, map[string]any{"status": payment.Status}),
{
Type: constants.AuditResourceRechargeOrder, ID: &rechargeID, Key: recharge.RechargeOrderNo, DisplayName: recharge.RechargeOrderNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRolePaymentBusinessOrder,
IdentitySnapshot: map[string]any{
"id": recharge.ID, "recharge_order_no": recharge.RechargeOrderNo, "user_id": recharge.UserID,
"asset_wallet_id": recharge.AssetWalletID, "resource_type": recharge.ResourceType,
"resource_id": recharge.ResourceID, "amount": recharge.Amount, "status": recharge.Status,
},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "资产充值支付已创建",
},
}
references, err := audit.AssetRechargeReferences(ctx, tx, recharge)
if err != nil {
return err
}
resources = append(resources, references...)
return h.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionPaymentCreated, Summary: "创建资产充值支付记录",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: payment.PaymentNo,
Resources: resources,
})
}
func (h *ClientWalletHandler) startRechargePaymentAttempt(ctx context.Context, config *model.WechatConfig, paymentNo, rechargeNo string, amount int64) (*model.IntegrationLog, time.Time, error) {
if h.paymentIntegration == nil {
return nil, time.Time{}, errors.New(errors.CodeInvalidStatus, "充值支付 Integration Log 接缝未配置")
}
provider := constants.IntegrationProviderWechatPay
if config.ProviderType == model.ProviderTypeFuiou {
provider = constants.IntegrationProviderFuiou
}
series := "payment:" + paymentNo + ":" + constants.IntegrationOperationPaymentPreCreate
log, err := h.paymentIntegration.Start(ctx, integrationlog.Attempt{
Provider: provider, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationPaymentPreCreate,
ResourceType: constants.IntegrationResourceTypePayment, ResourceKey: &paymentNo, ExternalID: &paymentNo,
TriggerSeries: &series, CorrelationID: &rechargeNo,
RequestSummary: map[string]any{"payment_config_id": config.ID, "amount": amount},
})
return log, time.Now(), err
}
func (h *ClientWalletHandler) completeRechargePaymentAttempt(ctx context.Context, log *model.IntegrationLog, startedAt time.Time, result, providerCode, safeMessage string) error {
completion := integrationlog.Completion{
Result: result, ProviderCode: providerCode, SafeProviderMessage: safeMessage,
ResponseSummary: map[string]any{"success": result == constants.IntegrationResultSuccess},
DurationMS: time.Since(startedAt).Milliseconds(),
}
if result == constants.IntegrationResultUnknown {
completion.RecoveryStrategy = "使用原支付单号向支付渠道查单,确认结果后再推进本地充值状态"
}
_, err := h.paymentIntegration.Complete(ctx, log.IntegrationID, completion)
return err
}
func (h *ClientWalletHandler) markRechargePaymentFailed(ctx context.Context, payment *model.Payment) error {
return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.Payment{}).Where("id = ? AND status = ?", payment.ID, model.PaymentRecordStatusPending).
Update("status", model.PaymentRecordStatusFailed)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "关闭失败支付记录失败")
}
if result.RowsAffected == 0 {
return nil
}
after := *payment
after.Status = model.PaymentRecordStatusFailed
return h.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionPaymentFailed, Summary: "支付宝支付链接生成失败,关闭支付记录",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: payment.PaymentNo,
Resources: []audit.ResourceInput{audit.PaymentResource(&after, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePaymentTarget,
map[string]any{"status": payment.Status}, map[string]any{"status": after.Status})},
})
})
}

View File

@@ -7,11 +7,19 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
func carrierCallbackContext(ctx context.Context, provider string) context.Context {
return auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorExternalSystem, ActorID: provider,
ActorName: provider, Source: constants.AuditSourceCallback,
})
}
// SystemConfigReader 提供运营商回调运行时开关读取能力。
type SystemConfigReader interface {
Get(ctx context.Context, key string) (string, error)
@@ -77,3 +85,13 @@ func recordDisabledCarrierCallback(
})
return err
}
// completeResolvedCarrierCallback 在回调已精确解析卡后补充真实本地资源ID。
func completeResolvedCarrierCallback(ctx context.Context, repository *integrationlog.Repository, integrationID, result string, stateChanged bool, reason string, cardID uint) error {
resourceID := strconv.FormatUint(uint64(cardID), 10)
_, err := repository.Complete(ctx, integrationID, integrationlog.Completion{
Result: result, HTTPStatus: fiber.StatusOK, StateChanged: stateChanged, ResourceID: &resourceID,
ResponseSummary: map[string]any{"reason": reason},
})
return err
}

View File

@@ -60,6 +60,7 @@ func (h *CMCCRealnameHandler) process(ctx context.Context, body []byte, contentT
if h == nil || h.translator == nil || h.resolver == nil || h.integration == nil || h.observation == nil {
return apperrors.New(apperrors.CodeInternalError, "移动实名回调能力未完整配置")
}
ctx = carrierCallbackContext(ctx, constants.IntegrationProviderCMCC)
translated, translateErr := h.translator.Translate(body)
idempotencyKey := cmccIdempotencyKey(body, translated)
integrationID := "cmcc-realname:" + shortHash(idempotencyKey)
@@ -108,6 +109,7 @@ func (h *CMCCRealnameHandler) process(ctx context.Context, body []byte, contentT
},
})
if err != nil {
h.observation.RecordCarrierCallbackFailure(ctx, card, log.IntegrationID, err)
return h.fail(ctx, log.IntegrationID, err)
}
if h.series != nil {
@@ -115,7 +117,7 @@ func (h *CMCCRealnameHandler) process(ctx context.Context, body []byte, contentT
h.logger.Warn("移动实名事实已应用但提前完成观测序列失败", zap.Uint("card_id", card.ID), zap.Error(err))
}
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用")
return completeResolvedCarrierCallback(ctx, h.integration, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用", card.ID)
}
func (h *CMCCRealnameHandler) recordConflict(ctx context.Context, body []byte, contentType, baseKey string, translated carriercallback.CMCCRealnameTranslation, requestID *string) error {

View File

@@ -82,6 +82,7 @@ func (h *CTCCRealnameHandler) process(ctx context.Context, body []byte, contentT
if h == nil || h.translator == nil || h.resolver == nil || h.integration == nil || h.observation == nil {
return apperrors.New(apperrors.CodeInternalError, "电信实名回调能力未完整配置")
}
ctx = carrierCallbackContext(ctx, constants.IntegrationProviderCTCC)
translated, translateErr := h.translator.Translate(body)
idempotencyKey := ctccIdempotencyKey(body, translated)
integrationID := "ctcc-realname:" + shortHash(idempotencyKey)
@@ -134,6 +135,7 @@ func (h *CTCCRealnameHandler) process(ctx context.Context, body []byte, contentT
},
})
if err != nil {
h.observation.RecordCarrierCallbackFailure(ctx, card, log.IntegrationID, err)
return h.failPending(ctx, log.IntegrationID, err)
}
if h.series != nil {
@@ -141,7 +143,7 @@ func (h *CTCCRealnameHandler) process(ctx context.Context, body []byte, contentT
h.logger.Warn("电信实名事实已应用但提前完成观测序列失败", zap.Uint("card_id", card.ID), zap.Error(err))
}
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用")
return completeResolvedCarrierCallback(ctx, h.integration, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用", card.ID)
}
func (h *CTCCRealnameHandler) failPending(ctx context.Context, integrationID string, original error) error {

View File

@@ -61,6 +61,7 @@ func (h *CUCCRealnameHandler) process(ctx context.Context, body []byte, contentT
if h == nil || h.translator == nil || h.resolver == nil || h.integration == nil || h.observation == nil {
return apperrors.New(apperrors.CodeInternalError, "联通实名回调能力未完整配置")
}
ctx = carrierCallbackContext(ctx, constants.IntegrationProviderCUCC)
translated, translateErr := h.translator.Translate(body)
key := cuccRealnameIdempotencyKey(body, translated, translateErr == nil)
integrationID := "cucc-realname:" + shortHash(key)
@@ -107,6 +108,7 @@ func (h *CUCCRealnameHandler) process(ctx context.Context, body []byte, contentT
},
})
if err != nil {
h.observation.RecordCarrierCallbackFailure(ctx, card, log.IntegrationID, err)
return h.fail(ctx, log.IntegrationID, err)
}
if h.series != nil {
@@ -114,7 +116,7 @@ func (h *CUCCRealnameHandler) process(ctx context.Context, body []byte, contentT
h.logger.Warn("联通实名事实已应用但提前完成观测序列失败", zap.Uint("card_id", card.ID), zap.Error(err))
}
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用")
return completeResolvedCarrierCallback(ctx, h.integration, log.IntegrationID, constants.IntegrationResultSuccess, decision.StatusChanged, "实名事实已幂等应用", card.ID)
}
func (h *CUCCRealnameHandler) recordConflict(ctx context.Context, body []byte, contentType, baseKey string, translated carriercallback.CUCCRealnameTranslation, requestID *string) error {

View File

@@ -93,7 +93,7 @@ func (h *CUCCRealnameRemovalHandler) process(ctx context.Context, body []byte, c
if len(cards) > 1 {
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultConflict, "精确列匹配多张卡")
}
return h.complete(ctx, log.IntegrationID, constants.IntegrationResultIgnored, "解除实名仅留痕,不修改本地实名事实")
return completeResolvedCarrierCallback(ctx, h.integration, log.IntegrationID, constants.IntegrationResultIgnored, false, "解除实名仅留痕,不修改本地实名事实", cards[0].ID)
}
func (h *CUCCRealnameRemovalHandler) recordConflict(ctx context.Context, body []byte, contentType, baseKey string, translated carriercallback.CUCCRealnameRemovalTranslation, requestID *string) error {

View File

@@ -20,6 +20,7 @@ import (
rechargeOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge_order"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/alipay"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/fuiou"
@@ -112,6 +113,7 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
)
return errors.New(errors.CodeWechatCallbackInvalid, "微信支付服务未配置")
}
ctx = paymentCallbackContext(ctx, constants.IntegrationProviderWechatPay)
switch cfg.ProviderType {
case model.ProviderTypeWechatV2:
@@ -121,6 +123,12 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "微信 v2 回调验签失败")
}
if result.TradeState != "SUCCESS" {
if err := h.recordIgnoredPaymentCallback(ctx, verifiedPaymentCallback{
PaymentNo: result.OutTradeNo, TransactionID: result.TransactionID,
Provider: constants.IntegrationProviderWechatPay, RawPayload: body, ContentType: c.Get("Content-Type"),
}, result.TradeState); err != nil {
return err
}
return h.wechatV2SuccessResponse(c)
}
// TotalFee 为字符串格式的分,解析失败则降级为 0后续 handlePaymentCallback 会记录日志)
@@ -143,7 +151,10 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
fasthttpadaptor.ConvertRequest(c.Context(), &httpReq, true)
_, err := h.wechatPayment.HandlePaymentNotify(&httpReq, func(result *wechat.PaymentNotifyResult) error {
if result.TradeState != "SUCCESS" {
return nil
return h.recordIgnoredPaymentCallback(ctx, verifiedPaymentCallback{
PaymentNo: result.OutTradeNo, TransactionID: result.TransactionID,
Provider: constants.IntegrationProviderWechatPay, RawPayload: body, ContentType: c.Get("Content-Type"),
}, result.TradeState)
}
paidAt, _ := time.Parse(time.RFC3339, result.SuccessTime)
return h.dispatchWechatCallback(ctx, verifiedPaymentCallback{
@@ -168,19 +179,35 @@ func (h *PaymentHandler) dispatchPaymentRecordCallback(ctx context.Context, call
if h.paymentStore != nil {
payment, err := h.paymentStore.GetByPaymentNo(ctx, callback.PaymentNo)
if err == nil {
log, err := h.recordPaymentCallback(ctx, callback, payment)
if err != nil {
return true, err
}
var processErr error
switch payment.OrderType {
case model.PaymentOrderTypePackage:
return true, h.orderService.HandlePaymentRecordCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.TransactionID, callback.Amount)
processErr = h.orderService.HandlePaymentRecordCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.TransactionID, callback.Amount)
case model.PaymentOrderTypeRecharge:
if h.rechargeOrderService != nil {
return true, h.rechargeOrderService.HandlePaymentCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.TransactionID)
processErr = h.rechargeOrderService.HandlePaymentCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.TransactionID)
} else {
processErr = errors.New(errors.CodeInternalError, "充值订单服务未配置")
}
return true, errors.New(errors.CodeInternalError, "充值订单服务未配置")
case model.PaymentOrderTypeAgentRecharge:
return true, h.confirmAgentRechargePayment(ctx, callback)
return true, h.confirmAgentRechargePayment(ctx, callback, log)
default:
return true, errors.New(errors.CodeInvalidStatus, "未知支付记录类型")
processErr = errors.New(errors.CodeInvalidStatus, "未知支付记录类型")
}
completion := integrationlog.Completion{Result: constants.IntegrationResultSuccess, ResponseSummary: map[string]any{"confirmed": true}}
if processErr != nil {
completion.Result = constants.IntegrationResultFailed
completion.SafeProviderMessage = "支付回调业务确认失败"
completion.ResponseSummary = map[string]any{"confirmed": false}
} else if current, currentErr := h.paymentStore.GetByPaymentNo(ctx, callback.PaymentNo); currentErr == nil {
completion.StateChanged = payment.Status != model.PaymentRecordStatusPaid && current.Status == model.PaymentRecordStatusPaid
}
h.completePaymentCallbackLog(ctx, log, completion)
return true, processErr
}
if err != gorm.ErrRecordNotFound {
return true, errors.Wrap(errors.CodeDatabaseError, err, "查询支付记录失败")
@@ -196,29 +223,14 @@ func (h *PaymentHandler) dispatchWechatCallback(ctx context.Context, callback ve
return err
}
switch {
case strings.HasPrefix(callback.PaymentNo, "ORD"):
return h.orderService.HandlePaymentCallback(ctx, callback.PaymentNo, model.PaymentMethodWechat, callback.Amount)
case strings.HasPrefix(callback.PaymentNo, constants.AssetRechargeOrderPrefix):
if h.rechargeOrderService != nil {
return h.rechargeOrderService.HandlePaymentCallback(ctx, callback.PaymentNo, model.PaymentByWechat, callback.TransactionID)
}
return errors.New(errors.CodeInternalError, "充值订单服务未配置")
case strings.HasPrefix(callback.PaymentNo, constants.AgentRechargeOrderPrefix):
if h.agentRechargeService != nil {
return h.agentRechargeService.HandlePaymentCallback(ctx, callback.PaymentNo, model.PaymentMethodWechat, callback.TransactionID, callback.Amount)
}
return errors.New(errors.CodeInternalError, "代理充值服务未配置")
default:
return errors.New(errors.CodeInvalidStatus, "未知订单号前缀")
}
return h.dispatchLegacyPaymentCallback(ctx, callback)
}
func (h *PaymentHandler) confirmAgentRechargePayment(ctx context.Context, callback verifiedPaymentCallback) error {
if h.agentPaymentConfirm == nil || h.integration == nil {
return errors.New(errors.CodeInternalError, "代理充值支付回调能力未配置")
func (h *PaymentHandler) dispatchLegacyPaymentCallback(ctx context.Context, callback verifiedPaymentCallback) error {
if h.integration == nil {
return errors.New(errors.CodeInvalidStatus, "支付回调 Integration Log 接缝未配置")
}
resourceID, correlationID := callback.PaymentNo, callback.PaymentNo
resourceKey, correlationID := callback.PaymentNo, callback.PaymentNo
idempotencyKey := callback.TransactionID
if idempotencyKey == "" {
idempotencyKey = callback.PaymentNo
@@ -226,30 +238,75 @@ func (h *PaymentHandler) confirmAgentRechargePayment(ctx context.Context, callba
log, _, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IdempotencyKey: idempotencyKey, Provider: callback.Provider,
Operation: constants.IntegrationOperationPaymentCallback, ExternalID: callback.TransactionID,
ResourceType: constants.IntegrationResourceTypeAgentRechargePayment, ResourceID: &resourceID,
ResourceType: constants.IntegrationResourceTypePayment, ResourceKey: &resourceKey,
RawPayload: callback.RawPayload, ContentType: callback.ContentType,
RequestID: pkgmiddleware.GetRequestIDFromContext(ctx), CorrelationID: &correlationID,
})
if err != nil {
return err
}
if err := h.preparePaymentCallbackRetry(ctx, log); err != nil {
return err
}
var processErr error
switch {
case strings.HasPrefix(callback.PaymentNo, "ORD"):
processErr = h.orderService.HandlePaymentCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.Amount)
case strings.HasPrefix(callback.PaymentNo, constants.AssetRechargeOrderPrefix):
if h.rechargeOrderService != nil {
processErr = h.rechargeOrderService.HandlePaymentCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.TransactionID)
} else {
processErr = errors.New(errors.CodeInternalError, "充值订单服务未配置")
}
case strings.HasPrefix(callback.PaymentNo, constants.AgentRechargeOrderPrefix):
if h.agentRechargeService != nil {
processErr = h.agentRechargeService.HandlePaymentCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.TransactionID, callback.Amount)
} else {
processErr = errors.New(errors.CodeInternalError, "代理充值服务未配置")
}
default:
processErr = errors.New(errors.CodeInvalidStatus, "未知订单号前缀")
}
completion := integrationlog.Completion{Result: constants.IntegrationResultSuccess, ResponseSummary: map[string]any{"confirmed": processErr == nil}}
if processErr != nil {
completion.Result = constants.IntegrationResultFailed
completion.SafeProviderMessage = "旧支付回调业务确认失败"
}
h.completePaymentCallbackLog(ctx, log, completion)
return processErr
}
func (h *PaymentHandler) confirmAgentRechargePayment(ctx context.Context, callback verifiedPaymentCallback, log *model.IntegrationLog) error {
if h.agentPaymentConfirm == nil || h.integration == nil {
return errors.New(errors.CodeInternalError, "代理充值支付回调能力未配置")
}
correlationID := callback.PaymentNo
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: correlationID})
linkage := auditcontext.From(ctx)
result, confirmErr := h.agentPaymentConfirm.Execute(ctx, agentrechargeApp.ConfirmOnlinePaymentCommand{
PaymentNo: callback.PaymentNo, PaymentMethod: callback.PaymentMethod, ConfigID: callback.ConfigID,
MerchantIdentity: callback.MerchantIdentity, ThirdPartyTradeNo: callback.TransactionID,
Amount: callback.Amount, PaidAt: callback.PaidAt, CorrelationID: correlationID,
Amount: callback.Amount, PaidAt: callback.PaidAt, RequestID: linkage.RequestID,
CorrelationID: correlationID, ParentEventID: linkage.ParentEventID,
})
if confirmErr != nil {
h.logger.Error("代理充值支付确认失败",
zap.String("integration_id", log.IntegrationID),
zap.String("payment_no", callback.PaymentNo),
zap.Error(confirmErr),
)
h.completePaymentCallbackLog(ctx, log, integrationlog.Completion{
Result: constants.IntegrationResultFailed, ProviderMessage: confirmErr.Error(),
Result: constants.IntegrationResultFailed, SafeProviderMessage: "代理充值支付确认失败",
ResponseSummary: map[string]any{"confirmed": false},
})
return confirmErr
}
if log.Result == constants.IntegrationResultPending {
_, err = h.integration.Complete(ctx, log.IntegrationID, integrationlog.Completion{
resolvedResourceID := strconv.FormatUint(uint64(result.PaymentID), 10)
_, err := h.integration.Complete(ctx, log.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, ProviderCode: "SUCCESS",
ResponseSummary: map[string]any{"confirmed": true, "already_confirmed": result.AlreadyConfirmed},
StateChanged: !result.AlreadyConfirmed,
StateChanged: !result.AlreadyConfirmed, ResourceID: &resolvedResourceID,
})
if err != nil {
return err
@@ -258,6 +315,69 @@ func (h *PaymentHandler) confirmAgentRechargePayment(ctx context.Context, callba
return nil
}
func (h *PaymentHandler) recordPaymentCallback(ctx context.Context, callback verifiedPaymentCallback, payment *model.Payment) (*model.IntegrationLog, error) {
if h.integration == nil || payment == nil {
return nil, errors.New(errors.CodeInvalidStatus, "支付回调 Integration Log 接缝未配置")
}
resourceID, resourceKey, correlationID := strconv.FormatUint(uint64(payment.ID), 10), payment.PaymentNo, payment.PaymentNo
idempotencyKey := callback.TransactionID
if idempotencyKey == "" {
idempotencyKey = callback.PaymentNo
}
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: correlationID})
log, _, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IdempotencyKey: idempotencyKey, Provider: callback.Provider,
Operation: constants.IntegrationOperationPaymentCallback, ExternalID: callback.TransactionID,
ResourceType: constants.IntegrationResourceTypePayment, ResourceID: &resourceID, ResourceKey: &resourceKey,
RawPayload: callback.RawPayload, ContentType: callback.ContentType,
RequestID: pkgmiddleware.GetRequestIDFromContext(ctx), CorrelationID: &correlationID,
})
if err != nil {
return nil, err
}
if err := h.preparePaymentCallbackRetry(ctx, log); err != nil {
return nil, err
}
return log, nil
}
func (h *PaymentHandler) preparePaymentCallbackRetry(ctx context.Context, log *model.IntegrationLog) error {
if log == nil || log.Result != constants.IntegrationResultFailed {
return nil
}
claimed, err := h.integration.ClaimFailedInbound(ctx, log.IntegrationID)
if err != nil {
return err
}
if claimed {
log.Result = constants.IntegrationResultPending
}
return nil
}
func (h *PaymentHandler) recordIgnoredPaymentCallback(ctx context.Context, callback verifiedPaymentCallback, providerCode string) error {
if h.integration == nil {
return errors.New(errors.CodeInvalidStatus, "支付回调 Integration Log 接缝未配置")
}
resourceKey, correlationID := callback.PaymentNo, callback.PaymentNo
idempotencyKey := callback.PaymentNo + ":" + providerCode
log, _, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{
IdempotencyKey: idempotencyKey, Provider: callback.Provider,
Operation: constants.IntegrationOperationPaymentCallback, ExternalID: callback.TransactionID,
ResourceType: constants.IntegrationResourceTypePayment, ResourceKey: &resourceKey,
RawPayload: callback.RawPayload, ContentType: callback.ContentType,
RequestID: pkgmiddleware.GetRequestIDFromContext(ctx), CorrelationID: &correlationID,
})
if err != nil || log.Result != constants.IntegrationResultPending {
return err
}
_, err = h.integration.Complete(ctx, log.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultIgnored, ProviderCode: providerCode,
ResponseSummary: map[string]any{"confirmed": false},
})
return err
}
func (h *PaymentHandler) completePaymentCallbackLog(ctx context.Context, log *model.IntegrationLog, completion integrationlog.Completion) {
if log == nil || log.Result != constants.IntegrationResultPending {
return
@@ -305,6 +425,7 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
)
return errors.New(errors.CodeWechatCallbackInvalid, "支付配置不可用")
}
ctx = paymentCallbackContext(ctx, constants.IntegrationProviderAlipay)
// 使用支付宝公钥验签DecodeNotification 内部完成签名校验)
notification, err := alipay.DecodeNotification(ctx, cfg, values)
@@ -334,6 +455,12 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
zap.String("out_trade_no", outTradeNo),
zap.String("trade_status", tradeStatus),
)
if err := h.recordIgnoredPaymentCallback(ctx, verifiedPaymentCallback{
PaymentNo: outTradeNo, TransactionID: notification.TradeNo,
Provider: constants.IntegrationProviderAlipay, RawPayload: c.Body(), ContentType: c.Get("Content-Type"),
}, tradeStatus); err != nil {
return err
}
return c.SendString("success")
}
@@ -397,57 +524,13 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
return errors.New(errors.CodeWechatCallbackInvalid, "支付金额校验失败")
}
// 新代理充值由统一确认事务原子写入交易号,旧业务保持原处理方式。
if payment.OrderType != model.PaymentOrderTypeAgentRecharge {
if err := h.paymentStore.UpdatePaymentInfo(ctx, payment.ID, notification.TradeNo, nil); err != nil {
h.logger.Error("支付宝回调:写入 third_party_trade_no 失败",
zap.String("out_trade_no", outTradeNo),
zap.String("trade_no", notification.TradeNo),
zap.Error(err),
)
// 不中断存量业务,继续由原幂等业务层处理状态。
}
callback := verifiedPaymentCallback{
PaymentNo: outTradeNo, PaymentMethod: model.PaymentByAlipay,
TransactionID: notification.TradeNo, Amount: notifyAmountFen, ConfigID: cfg.ID,
MerchantIdentity: notification.AppId, Provider: constants.IntegrationProviderAlipay,
RawPayload: c.Body(), ContentType: c.Get("Content-Type"),
}
// 按支付单 order_type 分发业务
switch payment.OrderType {
case model.PaymentOrderTypePackage:
if err := h.orderService.HandlePaymentRecordCallback(ctx, outTradeNo, model.PaymentByAlipay, notification.TradeNo, payment.Amount); err != nil {
h.logger.Error("支付宝回调:推进套餐订单失败",
zap.String("out_trade_no", outTradeNo),
zap.String("trade_no", notification.TradeNo),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "处理支付宝支付回调失败")
}
h.logger.Info("支付宝回调:套餐订单支付成功",
zap.String("out_trade_no", outTradeNo),
zap.String("trade_no", notification.TradeNo),
zap.String("order_type", payment.OrderType),
)
case model.PaymentOrderTypeRecharge:
if h.rechargeOrderService == nil {
h.logger.Error("支付宝回调:充值订单服务未配置",
zap.String("out_trade_no", outTradeNo),
)
return errors.New(errors.CodeInternalError, "充值订单服务未配置")
}
if err := h.rechargeOrderService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentByAlipay, notification.TradeNo); err != nil {
h.logger.Error("支付宝回调:推进充值订单失败",
zap.String("out_trade_no", outTradeNo),
zap.String("trade_no", notification.TradeNo),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "处理支付宝充值回调失败")
}
h.logger.Info("支付宝回调:充值订单支付成功",
zap.String("out_trade_no", outTradeNo),
zap.String("trade_no", notification.TradeNo),
zap.String("order_type", payment.OrderType),
)
case model.PaymentOrderTypeAgentRecharge:
if payment.OrderType == model.PaymentOrderTypeAgentRecharge {
paidAt, parseErr := time.ParseInLocation("2006-01-02 15:04:05", notification.GmtPayment, time.Local)
if parseErr != nil {
h.logger.Error("支付宝回调:付款时间格式无效",
@@ -456,25 +539,13 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
)
return errors.New(errors.CodeWechatCallbackInvalid, "支付宝付款时间格式错误")
}
if err := h.confirmAgentRechargePayment(ctx, verifiedPaymentCallback{
PaymentNo: outTradeNo, PaymentMethod: model.PaymentByAlipay,
TransactionID: notification.TradeNo, Amount: notifyAmountFen, ConfigID: cfg.ID,
MerchantIdentity: notification.AppId, PaidAt: paidAt, Provider: constants.IntegrationProviderAlipay,
RawPayload: c.Body(), ContentType: c.Get("Content-Type"),
}); err != nil {
h.logger.Error("支付宝回调:确认代理充值支付失败",
zap.String("out_trade_no", outTradeNo),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "处理支付宝代理充值回调失败")
}
default:
h.logger.Error("支付宝回调:未知支付记录类型",
zap.String("out_trade_no", outTradeNo),
zap.String("order_type", payment.OrderType),
)
return errors.New(errors.CodeInternalError, "未知支付记录类型")
callback.PaidAt = paidAt
}
if handled, dispatchErr := h.dispatchPaymentRecordCallback(ctx, callback); dispatchErr != nil {
h.logger.Error("支付宝回调:确认支付失败", zap.String("out_trade_no", outTradeNo), zap.Error(dispatchErr))
return errors.Wrap(errors.CodeInternalError, dispatchErr, "处理支付宝支付回调失败")
} else if !handled {
return errors.New(errors.CodeInternalError, "支付记录分发失败")
}
return c.SendString("success")
@@ -513,6 +584,7 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
)
return c.Send(fuiou.BuildNotifyFailResponse("payment config unavailable"))
}
ctx = paymentCallbackContext(ctx, model.ProviderTypeFuiou)
if cfg.ProviderType != model.ProviderTypeFuiou ||
strings.TrimSpace(preNotify.InsCd) != strings.TrimSpace(cfg.FyInsCd) ||
strings.TrimSpace(preNotify.MchntCd) != strings.TrimSpace(cfg.FyMchntCd) {
@@ -550,6 +622,12 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
h.logger.Warn("富友回调:非成功结果",
zap.String("result_code", notify.ResultCode),
zap.String("result_msg", notify.ResultMsg))
if recordErr := h.recordIgnoredPaymentCallback(ctx, verifiedPaymentCallback{
PaymentNo: notify.MchntOrderNo, TransactionID: notify.TransactionId,
Provider: constants.IntegrationProviderFuiou, RawPayload: body, ContentType: c.Get("Content-Type"),
}, notify.ResultCode); recordErr != nil {
return c.Send(fuiou.BuildNotifyFailResponse("integration log failed"))
}
return c.Send(fuiou.BuildNotifySuccessResponse())
}
h.logger.Error("富友回调:验签或解析失败",
@@ -568,9 +646,10 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
orderNo := notify.MchntOrderNo
// OrderAmt 为字符串格式的分,解析失败则降级为 0
orderAmt, _ := strconv.ParseInt(notify.OrderAmt, 10, 64)
paidAt, _ := time.ParseInLocation("20060102150405", notify.TxnFinTs, time.Local)
if handled, err := h.dispatchPaymentRecordCallback(ctx, verifiedPaymentCallback{
PaymentNo: orderNo, PaymentMethod: "fuiou", TransactionID: notify.TransactionId, Amount: orderAmt,
ConfigID: cfg.ID, MerchantIdentity: cfg.FyMchntCd, Provider: model.ProviderTypeFuiou,
ConfigID: cfg.ID, MerchantIdentity: cfg.FyMchntCd, PaidAt: paidAt, Provider: constants.IntegrationProviderFuiou,
RawPayload: body, ContentType: c.Get("Content-Type"),
}); err != nil {
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
@@ -578,32 +657,23 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
return c.Send(fuiou.BuildNotifySuccessResponse())
}
switch {
case strings.HasPrefix(orderNo, "ORD"):
if err := h.orderService.HandlePaymentCallback(ctx, orderNo, "fuiou", orderAmt); err != nil {
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
}
case strings.HasPrefix(orderNo, constants.AssetRechargeOrderPrefix):
if h.rechargeOrderService != nil {
if err := h.rechargeOrderService.HandlePaymentCallback(ctx, orderNo, "fuiou", notify.TransactionId); err != nil {
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
}
return c.Send(fuiou.BuildNotifySuccessResponse())
}
return c.Send(fuiou.BuildNotifyFailResponse("充值订单服务未配置"))
case strings.HasPrefix(orderNo, constants.AgentRechargeOrderPrefix):
if h.agentRechargeService != nil {
if err := h.agentRechargeService.HandlePaymentCallback(ctx, orderNo, model.ProviderTypeFuiou, notify.TransactionId, orderAmt); err != nil {
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
}
}
default:
return c.Send(fuiou.BuildNotifyFailResponse("unknown order prefix"))
if err := h.dispatchLegacyPaymentCallback(ctx, verifiedPaymentCallback{
PaymentNo: orderNo, PaymentMethod: model.ProviderTypeFuiou, TransactionID: notify.TransactionId, Amount: orderAmt,
Provider: constants.IntegrationProviderFuiou, RawPayload: body, ContentType: c.Get("Content-Type"),
}); err != nil {
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
}
return c.Send(fuiou.BuildNotifySuccessResponse())
}
func paymentCallbackContext(ctx context.Context, provider string) context.Context {
return auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorExternalSystem, ActorID: provider,
ActorName: provider, Source: constants.AuditSourceCallback,
})
}
// fuiouCallbackPayload 提取富友回调载荷,兼容 body、form req 和 query req 三种来源。
func fuiouCallbackPayload(c *fiber.Ctx) ([]byte, string) {
if req := strings.TrimSpace(c.FormValue("req")); req != "" {

View File

@@ -0,0 +1,189 @@
package audit
import (
"context"
"strconv"
"strings"
"github.com/bytedance/sonic"
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// WriteApproval 将通用审批状态变化及其 Integration/Outbox 引用写入统一 Audit Event。
func (w *Writer) WriteApproval(ctx context.Context, tx *gorm.DB, change approvalapp.AuditChange) error {
if change.InstanceID == 0 || change.BusinessID == 0 || change.BusinessType == "" || change.SubmitterAccountID == 0 {
return errors.New(errors.CodeInvalidParam, "通用审批审计资源不完整")
}
resources, err := approvalResources(ctx, tx, change)
if err != nil {
return err
}
result := change.Result
if result == "" {
result = constants.AuditResultSuccess
}
return w.Append(ctx, tx, AppendInput{
EventID: change.EventID, ActionCode: change.ActionCode, Summary: change.Summary,
Actor: ActorInput{Kind: change.ActorKind, ID: change.ActorID, Name: change.ActorName}, Source: change.Source,
ScopeType: constants.AuditScopePlatform, Result: result, ErrorSummary: change.ErrorSummary,
CorrelationID: change.CorrelationID, ParentEventID: change.ParentEventID,
Metadata: map[string]any{"provider": change.Provider, "decision": change.Decision}, Resources: resources,
})
}
func approvalResources(ctx context.Context, tx *gorm.DB, change approvalapp.AuditChange) ([]ResourceInput, error) {
instanceID := strconv.FormatUint(uint64(change.InstanceID), 10)
resources := []ResourceInput{{
Type: constants.AuditResourceApprovalInstance, ID: &instanceID, Key: instanceID, DisplayName: "审批实例 " + instanceID,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleApprovalTarget,
IdentitySnapshot: map[string]any{
"id": change.InstanceID, "business_type": change.BusinessType, "business_id": change.BusinessID,
"submitter_account_id": change.SubmitterAccountID, "provider": change.Provider,
"external_ref": change.AfterExternalRef, "correlation_id": change.CorrelationID,
"status": statusValue(change.AfterStatus),
},
BeforeData: approvalState(change.BeforeStatus, change.BeforeExternalRef),
AfterData: approvalState(change.AfterStatus, change.AfterExternalRef),
}}
business, err := approvalBusinessResource(ctx, tx, change.BusinessType, change.BusinessID, change.InstanceID)
if err != nil {
return nil, err
}
resources = append(resources, business)
resources = append(resources, approvalSubmitterResource(change))
seenIntegrationIDs := make(map[string]struct{}, len(change.IntegrationIDs))
for _, integrationID := range change.IntegrationIDs {
integrationID = strings.TrimSpace(integrationID)
if integrationID == "" {
continue
}
if _, exists := seenIntegrationIDs[integrationID]; exists {
continue
}
seenIntegrationIDs[integrationID] = struct{}{}
resource, err := approvalIntegrationResource(ctx, tx, integrationID)
if err != nil {
return nil, err
}
resources = append(resources, resource)
}
if strings.TrimSpace(change.OutboxEventID) != "" {
resource, err := approvalOutboxResource(ctx, tx, change.OutboxEventID)
if err != nil {
return nil, err
}
resources = append(resources, resource)
}
return resources, nil
}
func approvalBusinessResource(ctx context.Context, tx *gorm.DB, businessType string, businessID, instanceID uint) (ResourceInput, error) {
id := strconv.FormatUint(uint64(businessID), 10)
switch businessType {
case constants.ApprovalBusinessTypeRefund:
var refund model.RefundRequest
if err := tx.WithContext(ctx).First(&refund, businessID).Error; err != nil {
return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联退款单失败")
}
return ResourceInput{
Type: constants.AuditResourceRefund, ID: &id, Key: refund.RefundNo, DisplayName: refund.RefundNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
IdentitySnapshot: map[string]any{
"id": refund.ID, "refund_no": refund.RefundNo, "order_id": refund.OrderID, "order_no": refund.OrderNo,
"order_type": refund.OrderType, "asset_identifier": refund.AssetIdentifier, "shop_id": refund.ShopID,
"requested_refund_amount": refund.RequestedRefundAmount, "actual_received_amount": refund.ActualReceivedAmount,
"approval_instance_id": instanceID, "status": refund.Status,
},
}, nil
case constants.ApprovalBusinessTypeOfflineRecharge:
var recharge model.AgentRechargeRecord
if err := tx.WithContext(ctx).First(&recharge, businessID).Error; err != nil {
return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联充值单失败")
}
return ResourceInput{
Type: constants.AuditResourceAgentRecharge, ID: &id, Key: recharge.RechargeNo, DisplayName: recharge.RechargeNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalBusiness,
IdentitySnapshot: map[string]any{
"id": recharge.ID, "recharge_no": recharge.RechargeNo, "user_id": recharge.UserID,
"shop_id": recharge.ShopID, "agent_wallet_id": recharge.AgentWalletID, "amount": recharge.Amount,
"payment_method": recharge.PaymentMethod, "payment_channel": recharge.PaymentChannel,
"approval_instance_id": instanceID, "status": recharge.Status,
},
}, nil
default:
return ResourceInput{}, errors.New(errors.CodeInvalidParam, "审批业务类型尚未注册审计资源")
}
}
func approvalSubmitterResource(change approvalapp.AuditChange) ResourceInput {
accountID := strconv.FormatUint(uint64(change.SubmitterAccountID), 10)
identity := map[string]any{"id": change.SubmitterAccountID}
var snapshot map[string]any
if sonic.Unmarshal(change.SubmitterSnapshot, &snapshot) == nil {
identity["username"] = snapshot["account_name"]
identity["user_type"] = snapshot["user_type"]
}
displayName, _ := identity["username"].(string)
if displayName == "" {
displayName = "账号 " + accountID
}
return ResourceInput{
Type: constants.AuditResourceAccount, ID: &accountID, Key: accountID, DisplayName: displayName,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalSubmitter,
IdentitySnapshot: identity,
}
}
func approvalIntegrationResource(ctx context.Context, tx *gorm.DB, integrationID string) (ResourceInput, error) {
var record model.IntegrationLog
if err := tx.WithContext(ctx).Where("integration_id = ?", integrationID).First(&record).Error; err != nil {
return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联 Integration Log 失败")
}
id := strconv.FormatUint(uint64(record.ID), 10)
return ResourceInput{
Type: constants.AuditResourceIntegrationLog, ID: &id, Key: record.IntegrationID, DisplayName: record.IntegrationID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalIntegration,
IdentitySnapshot: map[string]any{
"integration_id": record.IntegrationID, "provider": record.Provider, "direction": record.Direction,
"operation": record.Operation, "external_id": record.ExternalID,
"resource_type": record.ResourceType, "resource_id": record.ResourceID, "resource_key": record.ResourceKey,
"correlation_id": record.CorrelationID,
},
}, nil
}
func approvalOutboxResource(ctx context.Context, tx *gorm.DB, eventID string) (ResourceInput, error) {
var event model.OutboxEvent
if err := tx.WithContext(ctx).Where("event_id = ?", eventID).First(&event).Error; err != nil {
return ResourceInput{}, errors.Wrap(errors.CodeDatabaseError, err, "查询审批关联 Outbox 事件失败")
}
id := strconv.FormatUint(uint64(event.ID), 10)
return ResourceInput{
Type: constants.AuditResourceOutboxEvent, ID: &id, Key: event.EventID, DisplayName: event.EventID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleApprovalOutbox,
IdentitySnapshot: map[string]any{
"event_id": event.EventID, "event_type": event.EventType, "aggregate_type": event.AggregateType,
"aggregate_id": event.AggregateID, "resource_type": event.ResourceType,
"resource_id": event.ResourceID, "business_key": event.BusinessKey,
},
}, nil
}
func approvalState(status *int, externalRef string) map[string]any {
if status == nil {
return nil
}
return map[string]any{"status": *status, "external_ref": externalRef}
}
func statusValue(status *int) any {
if status == nil {
return nil
}
return *status
}

View File

@@ -0,0 +1,41 @@
package audit
import (
"context"
"gorm.io/gorm"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
// BatchInput 描述一条批次根事件及每个已识别资源的子事件。
type BatchInput struct {
Root AppendInput
Children []AppendInput
}
// AppendBatch 在同一事务内追加批次根事件和资源子事件。
func (w *Writer) AppendBatch(ctx context.Context, tx *gorm.DB, input BatchInput) error {
if input.Root.EventID == "" {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "批次根事件缺少稳定事件ID")
}
if err := w.Append(ctx, tx, input.Root); err != nil {
return err
}
for index := range input.Children {
child := input.Children[index]
if child.EventID == "" {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "批次子事件缺少稳定事件ID")
}
if child.ParentEventID == "" {
child.ParentEventID = input.Root.EventID
}
if child.CorrelationID == "" {
child.CorrelationID = input.Root.CorrelationID
}
if err := w.Append(ctx, tx, child); err != nil {
return err
}
}
return nil
}

View File

@@ -0,0 +1,46 @@
package audit
import (
"strconv"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// CommissionWithdrawalResource 构造佣金提现单审计资源,不记录收款账户信息。
func CommissionWithdrawalResource(withdrawal *model.CommissionWithdrawalRequest, relation, role string, beforeData, afterData map[string]any) ResourceInput {
id := strconv.FormatUint(uint64(withdrawal.ID), 10)
key := withdrawal.WithdrawalNo
if key == "" {
key = id
}
return ResourceInput{
Type: constants.AuditResourceCommissionWithdrawal, ID: optionalResourceID(withdrawal.ID),
Key: key, DisplayName: "佣金提现单 " + key, Relation: relation, Role: role,
IdentitySnapshot: map[string]any{
"id": withdrawal.ID, "withdrawal_no": withdrawal.WithdrawalNo,
"shop_id": withdrawal.ShopID, "applicant_id": withdrawal.ApplicantID,
"amount": withdrawal.Amount, "fee": withdrawal.Fee, "fee_rate": withdrawal.FeeRate,
"actual_amount": withdrawal.ActualAmount, "withdrawal_method": withdrawal.WithdrawalMethod,
"payment_type": withdrawal.PaymentType, "status": withdrawal.Status,
"processor_id": withdrawal.ProcessorID, "processed_at": withdrawal.ProcessedAt, "paid_at": withdrawal.PaidAt,
},
BeforeData: beforeData, AfterData: afterData,
}
}
// AgentWalletResource 构造代理钱包审计资源及余额前后值。
func AgentWalletResource(wallet *model.AgentWallet, relation, role string, beforeData, afterData map[string]any) ResourceInput {
resource := agentWalletAuditResource(wallet, relation, role)
resource.BeforeData = beforeData
resource.AfterData = afterData
return resource
}
// AgentWalletTransactionResource 构造代理钱包流水审计资源。
func AgentWalletTransactionResource(transaction *model.AgentWalletTransaction, relation, role string) ResourceInput {
resource := agentWalletTransactionResource(transaction)
resource.Relation = relation
resource.Role = role
return resource
}

View File

@@ -0,0 +1,62 @@
package audit
import (
"context"
stderrors "errors"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
// RecordFailure 在业务回滚后使用独立短事务记录失败或拒绝事实。
func (w *Writer) RecordFailure(ctx context.Context, db *gorm.DB, input AppendInput, originalErr error) {
fillFailureInput(&input, originalErr)
if w == nil || db == nil {
recordFailureWriteError(ctx, input, pkgerrors.New(pkgerrors.CodeInvalidStatus, "统一审计失败记录接缝未配置"))
return
}
if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return w.Append(ctx, tx, input)
}); err != nil {
recordFailureWriteError(ctx, input, err)
}
}
func fillFailureInput(input *AppendInput, originalErr error) {
var appErr *pkgerrors.AppError
if stderrors.As(originalErr, &appErr) && appErr != nil {
if input.Result == "" {
input.Result = constants.AuditResultDenied
if appErr.Code == pkgerrors.CodeDatabaseError || appErr.Code == pkgerrors.CodeInternalError {
input.Result = constants.AuditResultFailed
}
}
input.ErrorCode = strconv.Itoa(appErr.Code)
input.ErrorSummary = appErr.Message
return
}
if input.Result == "" {
input.Result = constants.AuditResultFailed
}
input.ErrorCode = strconv.Itoa(pkgerrors.CodeInternalError)
input.ErrorSummary = "业务操作失败"
}
func recordFailureWriteError(ctx context.Context, input AppendInput, err error) {
linkage := auditcontext.From(ctx)
resourceKey := ""
for _, resource := range input.Resources {
if resource.Relation == constants.AuditResourceRelationPrimary {
resourceKey = resource.Key
break
}
}
auditfailure.RecordSecondaryWriteFailure(
input.ActionCode, resourceKey, linkage.RequestID, linkage.CorrelationID, input.ErrorCode, err,
)
}

View File

@@ -0,0 +1,24 @@
package audit
import (
"strconv"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// NotificationResource 构造不包含通知正文的安全资源快照。
func NotificationResource(notification *model.Notification, relation, role string, before, after map[string]any) ResourceInput {
id := strconv.FormatUint(uint64(notification.ID), 10)
return ResourceInput{
Type: constants.AuditResourceNotification, ID: &id, Key: notification.EventID + ":" + notification.RecipientKind + ":" + id,
DisplayName: notification.Type, Relation: relation, Role: role,
IdentitySnapshot: map[string]any{
"id": notification.ID, "event_id": notification.EventID,
"recipient_kind": notification.RecipientKind, "recipient_id": notification.RecipientID,
"category": notification.Category, "type": notification.Type, "severity": notification.Severity,
"ref_type": notification.RefType, "ref_id": notification.RefID, "ref_key": notification.RefKey,
},
BeforeData: before, AfterData: after, SubjectVisibility: constants.AuditSubjectInternalOnly,
}
}

View File

@@ -0,0 +1,218 @@
package audit
import (
"strconv"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// PackageSeriesResource 构造套餐系列审计资源。
func PackageSeriesResource(series *model.PackageSeries, relation, role string, beforeData, afterData map[string]any) ResourceInput {
id := strconv.FormatUint(uint64(series.ID), 10)
var resourceID *string
if series.ID > 0 {
resourceID = &id
}
return ResourceInput{
Type: constants.AuditResourcePackageSeries, ID: resourceID, Key: series.SeriesCode, DisplayName: series.SeriesName,
Relation: relation, Role: role, IdentitySnapshot: map[string]any{
"id": series.ID, "series_code": series.SeriesCode, "series_name": series.SeriesName,
"status": series.Status, "enable_one_time_commission": series.EnableOneTimeCommission,
},
BeforeData: beforeData, AfterData: afterData,
}
}
// PackageResource 构造套餐商品审计资源。
func PackageResource(pkg *model.Package, relation, role string, beforeData, afterData map[string]any) ResourceInput {
id := strconv.FormatUint(uint64(pkg.ID), 10)
var resourceID *string
if pkg.ID > 0 {
resourceID = &id
}
return ResourceInput{
Type: constants.AuditResourcePackage, ID: resourceID, Key: pkg.PackageCode, DisplayName: pkg.PackageName,
Relation: relation, Role: role, IdentitySnapshot: map[string]any{
"id": pkg.ID, "package_code": pkg.PackageCode, "package_name": pkg.PackageName,
"series_id": pkg.SeriesID, "package_type": pkg.PackageType, "duration_months": pkg.DurationMonths,
"duration_days": pkg.DurationDays, "price_config_status": pkg.PriceConfigStatus,
"is_gift": pkg.IsGift, "status": pkg.Status, "shelf_status": pkg.ShelfStatus,
},
BeforeData: beforeData, AfterData: afterData,
}
}
// ShopResource 构造套餐配置关联的店铺审计资源。
func ShopResource(shop *model.Shop, relation, role string) ResourceInput {
id := strconv.FormatUint(uint64(shop.ID), 10)
key := shop.ShopCode
if key == "" {
key = id
}
return ResourceInput{
Type: constants.AuditResourceShop, ID: &id, Key: key, DisplayName: shop.ShopName,
Relation: relation, Role: role, IdentitySnapshot: map[string]any{
"id": shop.ID, "shop_code": shop.ShopCode, "shop_name": shop.ShopName,
"parent_id": shop.ParentID, "level": shop.Level,
},
}
}
// ShopSeriesAllocationResource 构造店铺系列授权审计资源。
func ShopSeriesAllocationResource(allocation *model.ShopSeriesAllocation, relation, role string, beforeData, afterData map[string]any) ResourceInput {
id := strconv.FormatUint(uint64(allocation.ID), 10)
var resourceID *string
if allocation.ID > 0 {
resourceID = &id
}
key := id
if allocation.ID == 0 {
key = "shop-series-" + strconv.FormatUint(uint64(allocation.ShopID), 10) + "-" + strconv.FormatUint(uint64(allocation.SeriesID), 10)
}
return ResourceInput{
Type: constants.AuditResourceShopSeriesAllocation, ID: resourceID, Key: key, DisplayName: "系列授权 " + key,
Relation: relation, Role: role, IdentitySnapshot: map[string]any{
"id": allocation.ID, "shop_id": allocation.ShopID, "series_id": allocation.SeriesID,
"allocator_shop_id": allocation.AllocatorShopID, "status": allocation.Status,
},
BeforeData: beforeData, AfterData: afterData,
}
}
// ShopPackageAllocationResource 构造店铺套餐授权审计资源。
func ShopPackageAllocationResource(allocation *model.ShopPackageAllocation, relation, role string, beforeData, afterData map[string]any) ResourceInput {
id := strconv.FormatUint(uint64(allocation.ID), 10)
return ResourceInput{
Type: constants.AuditResourceShopPackageAllocation, ID: &id, Key: id, DisplayName: "套餐授权 " + id,
Relation: relation, Role: role, IdentitySnapshot: map[string]any{
"id": allocation.ID, "shop_id": allocation.ShopID, "package_id": allocation.PackageID,
"allocator_shop_id": allocation.AllocatorShopID, "series_allocation_id": allocation.SeriesAllocationID,
"status": allocation.Status, "shelf_status": allocation.ShelfStatus,
"retail_price_config_status": allocation.RetailPriceConfigStatus,
},
BeforeData: beforeData, AfterData: afterData,
}
}
// ShopPackagePriceHistoryResource 构造套餐价格历史审计资源。
func ShopPackagePriceHistoryResource(history *model.ShopPackageAllocationPriceHistory) ResourceInput {
id := strconv.FormatUint(uint64(history.ID), 10)
return ResourceInput{
Type: constants.AuditResourceShopPackagePriceHistory, ID: &id, Key: id, DisplayName: "价格历史 " + id,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePackagePriceHistory,
IdentitySnapshot: map[string]any{
"id": history.ID, "allocation_id": history.AllocationID, "changed_by": history.ChangedBy,
"effective_from": history.EffectiveFrom,
},
AfterData: map[string]any{
"old_cost_price": history.OldCostPrice, "new_cost_price": history.NewCostPrice,
"change_reason": history.ChangeReason,
},
}
}
// PackageConfigBatchResource 构造套餐配置批次根资源。
func PackageConfigBatchResource(batchKey, operation string, shopID, seriesID uint) ResourceInput {
return ResourceInput{
Type: constants.AuditResourcePackageConfigBatch, Key: batchKey, DisplayName: batchKey,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRolePackageConfigBatch,
IdentitySnapshot: map[string]any{
"batch_key": batchKey, "operation": operation, "shop_id": shopID, "series_id": seriesID,
},
}
}
// PackageUsageResource 构造套餐权益生命周期审计资源。
func PackageUsageResource(usage *model.PackageUsage, relation, role string, beforeData, afterData map[string]any) ResourceInput {
id := strconv.FormatUint(uint64(usage.ID), 10)
return ResourceInput{
Type: constants.AuditResourcePackageUsage, ID: &id, Key: id, DisplayName: usage.PackageName,
Relation: relation, Role: role, IdentitySnapshot: map[string]any{
"id": usage.ID, "order_id": usage.OrderID, "order_no": usage.OrderNo,
"refund_id": usage.RefundID, "refund_no": usage.RefundNo,
"package_id": usage.PackageID, "package_name": usage.PackageName, "usage_type": usage.UsageType,
"iot_card_id": usage.IotCardID, "device_id": usage.DeviceID,
"data_limit_mb": usage.DataLimitMB, "data_usage_mb": usage.DataUsageMB,
"activated_at": usage.ActivatedAt, "expires_at": usage.ExpiresAt, "status": usage.Status,
"pending_realname_activation": usage.PendingRealnameActivation,
"last_reset_at": usage.LastResetAt, "next_reset_at": usage.NextResetAt, "generation": usage.Generation,
},
BeforeData: beforeData, AfterData: afterData,
}
}
// OrderResource 构造订单审计资源。
func OrderResource(order *model.Order, relation, role string) ResourceInput {
id := strconv.FormatUint(uint64(order.ID), 10)
var resourceID *string
if order.ID > 0 {
resourceID = &id
}
key := order.OrderNo
if key == "" {
key = id
}
return ResourceInput{
Type: constants.AuditResourceOrder, ID: resourceID, Key: key, DisplayName: key,
Relation: relation, Role: role, IdentitySnapshot: map[string]any{
"id": order.ID, "order_no": order.OrderNo, "order_type": order.OrderType,
"buyer_type": order.BuyerType, "buyer_id": order.BuyerID,
"iot_card_id": order.IotCardID, "device_id": order.DeviceID,
"asset_identifier": order.AssetIdentifier, "total_amount": order.TotalAmount,
"actual_paid_amount": order.ActualPaidAmount, "payment_method": order.PaymentMethod,
"payment_status": order.PaymentStatus, "purchase_role": order.PurchaseRole,
"source": order.Source, "operator_account_id": order.OperatorAccountID,
"operator_account_type": order.OperatorAccountType, "operator_account_name": order.OperatorAccountName,
"seller_shop_id": order.SellerShopID, "expires_at": order.ExpiresAt,
},
}
}
// PaymentResource 构造支付记录审计资源。
func PaymentResource(payment *model.Payment, relation, role string, beforeData, afterData map[string]any) ResourceInput {
id := strconv.FormatUint(uint64(payment.ID), 10)
var resourceID *string
if payment.ID > 0 {
resourceID = &id
}
key := payment.PaymentNo
if key == "" {
key = id
}
return ResourceInput{
Type: constants.AuditResourcePayment, ID: resourceID, Key: key, DisplayName: key,
Relation: relation, Role: role, IdentitySnapshot: map[string]any{
"id": payment.ID, "payment_no": payment.PaymentNo, "order_id": payment.OrderID,
"order_type": payment.OrderType, "payment_method": payment.PaymentMethod,
"amount": payment.Amount, "status": payment.Status,
"third_party_trade_no": payment.ThirdPartyTradeNo, "payment_config_id": payment.PaymentConfigID,
},
BeforeData: beforeData, AfterData: afterData,
}
}
// RefundResource 构造退款单审计资源。
func RefundResource(refund *model.RefundRequest, relation, role string) ResourceInput {
id := strconv.FormatUint(uint64(refund.ID), 10)
var resourceID *string
if refund.ID > 0 {
resourceID = &id
}
key := refund.RefundNo
if key == "" {
key = id
}
return ResourceInput{
Type: constants.AuditResourceRefund, ID: resourceID, Key: key, DisplayName: key,
Relation: relation, Role: role, IdentitySnapshot: map[string]any{
"id": refund.ID, "refund_no": refund.RefundNo, "order_id": refund.OrderID,
"order_no": refund.OrderNo, "order_type": refund.OrderType,
"package_usage_id": refund.PackageUsageID, "asset_identifier": refund.AssetIdentifier,
"shop_id": refund.ShopID, "requested_refund_amount": refund.RequestedRefundAmount,
"actual_received_amount": refund.ActualReceivedAmount, "refund_reason": refund.RefundReason,
"approved_refund_amount": refund.ApprovedRefundAmount, "approval_instance_id": refund.ApprovalInstanceID,
"status": refund.Status, "commission_deducted": refund.CommissionDeducted, "asset_reset": refund.AssetReset,
},
}
}

View File

@@ -0,0 +1,84 @@
package audit
import (
"context"
"strconv"
"gorm.io/gorm"
agentrecharge "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// WriteAgentRechargePayment 将代理充值支付生命周期写入统一 Audit Event。
func (w *Writer) WriteAgentRechargePayment(ctx context.Context, tx *gorm.DB, change agentrecharge.PaymentAudit) error {
if change.Payment == nil || change.Payment.ID == 0 || change.Payment.PaymentNo == "" || change.Recharge == nil || change.Recharge.ID == 0 {
return errors.New(errors.CodeInvalidParam, "代理充值支付审计资源不完整")
}
payment := PaymentResource(change.Payment, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePaymentTarget, change.BeforeData, change.AfterData)
rechargeID := strconv.FormatUint(uint64(change.Recharge.ID), 10)
rechargeRelation := constants.AuditResourceRelationReference
if len(change.RechargeBeforeData) > 0 || len(change.RechargeAfterData) > 0 {
rechargeRelation = constants.AuditResourceRelationAffected
}
rechargeStatus := any(change.Recharge.Status)
if status, ok := change.RechargeAfterData["status"]; ok {
rechargeStatus = status
}
recharge := ResourceInput{
Type: constants.AuditResourceAgentRecharge, ID: &rechargeID,
Key: change.Recharge.RechargeNo, DisplayName: change.Recharge.RechargeNo,
Relation: rechargeRelation, Role: constants.AuditResourceRolePaymentBusinessOrder,
IdentitySnapshot: map[string]any{
"id": change.Recharge.ID, "recharge_no": change.Recharge.RechargeNo,
"user_id": change.Recharge.UserID,
"shop_id": change.Recharge.ShopID, "agent_wallet_id": change.Recharge.AgentWalletID,
"amount": change.Recharge.Amount, "payment_method": change.Recharge.PaymentMethod,
"payment_channel": change.Recharge.PaymentChannel,
"approval_instance_id": change.Recharge.ApprovalInstanceID, "status": rechargeStatus,
},
BeforeData: change.RechargeBeforeData, AfterData: change.RechargeAfterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary,
}
resources := []ResourceInput{payment, recharge}
if change.Recharge.UserID > 0 {
var account model.Account
if err := tx.WithContext(ctx).Unscoped().First(&account, change.Recharge.UserID).Error; err != nil && err != gorm.ErrRecordNotFound {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值提交人审计快照失败")
}
if account.ID > 0 {
accountID := strconv.FormatUint(uint64(account.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceAccount, ID: &accountID, Key: accountID, DisplayName: account.Username,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRechargeSubmitter,
IdentitySnapshot: accountIdentity(&account),
})
}
}
var shop model.Shop
if err := tx.WithContext(ctx).Unscoped().First(&shop, change.Recharge.ShopID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值店铺审计快照失败")
}
resources = append(resources, ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleRechargeShop))
var wallet model.AgentWallet
if err := tx.WithContext(ctx).First(&wallet, change.Recharge.AgentWalletID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值钱包审计快照失败")
}
walletID := strconv.FormatUint(uint64(wallet.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceAgentWallet, ID: &walletID, Key: walletID, DisplayName: "代理主钱包 " + walletID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRechargeWallet,
IdentitySnapshot: map[string]any{
"id": wallet.ID, "shop_id": wallet.ShopID, "wallet_type": wallet.WalletType,
"currency": wallet.Currency, "status": wallet.Status,
},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary,
})
return w.Append(ctx, tx, AppendInput{
ActionCode: change.ActionCode, Summary: change.Summary,
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: change.Payment.PaymentNo, Resources: resources,
})
}

View File

@@ -0,0 +1,73 @@
package audit
import (
"context"
"strconv"
"gorm.io/gorm"
"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/middleware"
)
// PollingInput 描述轮询配置、规则或人工任务的审计事实。
type PollingInput struct {
EventID string
ActionCode string
Summary string
ResourceType string
ResourceID uint
ResourceKey string
DisplayName string
OperatorID uint
IdentitySnapshot map[string]any
BeforeData map[string]any
AfterData map[string]any
Metadata map[string]any
Cards []*model.IotCard
Result string
ErrorCode string
ErrorSummary string
}
// WritePolling 将轮询配置、规则或人工任务转换为统一 Audit Event。
func (w *Writer) WritePolling(ctx context.Context, tx *gorm.DB, input PollingInput) error {
if input.OperatorID == 0 || input.ResourceType == "" || input.ResourceKey == "" {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "轮询审计资源或操作者不完整")
}
resourceID := optionalResourceID(input.ResourceID)
resources := []ResourceInput{{
Type: input.ResourceType, ID: resourceID, Key: input.ResourceKey, DisplayName: input.DisplayName,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRolePollingTarget,
IdentitySnapshot: input.IdentitySnapshot, BeforeData: input.BeforeData, AfterData: input.AfterData,
SubjectVisibility: constants.AuditSubjectInternalOnly,
}}
for index, card := range input.Cards {
if card == nil || card.ID == 0 {
continue
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourceIotCard, ID: optionalResourceID(card.ID),
Key: iotCardResourceKey(card), DisplayName: card.ICCID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRolePollingCard,
IdentitySnapshot: iotCardIdentity(card), SubjectVisibility: constants.AuditSubjectInternalOnly,
SortOrder: index + 1,
})
}
result := input.Result
if result == "" {
result = constants.AuditResultSuccess
}
return w.Append(ctx, tx, AppendInput{
EventID: input.EventID, ActionCode: input.ActionCode, Summary: input.Summary,
Actor: ActorInput{
Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(input.OperatorID), 10),
Name: middleware.GetUsernameFromContext(ctx),
},
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
Result: result, ErrorCode: input.ErrorCode, ErrorSummary: input.ErrorSummary,
Metadata: input.Metadata, Resources: resources,
})
}

View File

@@ -0,0 +1,187 @@
package audit
import (
"context"
"strconv"
"gorm.io/gorm"
agentrecharge "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// WriteAgentRecharge 将代理充值申请、终态和实际入账写入统一 Audit Event。
func (w *Writer) WriteAgentRecharge(ctx context.Context, tx *gorm.DB, change agentrecharge.RechargeAudit) error {
if change.Record == nil || change.Record.ID == 0 || change.Record.RechargeNo == "" {
return errors.New(errors.CodeInvalidParam, "代理充值审计资源不完整")
}
resources, err := agentRechargeResources(ctx, tx, change)
if err != nil {
return err
}
return w.Append(ctx, tx, AppendInput{
ActionCode: change.ActionCode, Summary: change.Summary,
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: change.Record.RechargeNo, Resources: resources,
})
}
func agentRechargeResources(ctx context.Context, tx *gorm.DB, change agentrecharge.RechargeAudit) ([]ResourceInput, error) {
record := change.Record
id := strconv.FormatUint(uint64(record.ID), 10)
primary := ResourceInput{
Type: constants.AuditResourceAgentRecharge, ID: &id, Key: record.RechargeNo, DisplayName: record.RechargeNo,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleRechargeTarget,
IdentitySnapshot: map[string]any{
"id": record.ID, "recharge_no": record.RechargeNo, "user_id": record.UserID,
"shop_id": record.ShopID, "agent_wallet_id": record.AgentWalletID, "amount": record.Amount,
"payment_method": record.PaymentMethod, "payment_channel": record.PaymentChannel,
"payment_transaction_id": record.PaymentTransactionID, "approval_instance_id": record.ApprovalInstanceID,
"status": record.Status,
},
BeforeData: change.BeforeData, AfterData: change.AfterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary,
}
resources := []ResourceInput{primary}
var account model.Account
if record.UserID > 0 {
if err := tx.WithContext(ctx).Unscoped().First(&account, record.UserID).Error; err != nil && err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值提交人审计快照失败")
}
if account.ID > 0 {
accountID := strconv.FormatUint(uint64(account.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceAccount, ID: &accountID, Key: accountID, DisplayName: account.Username,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRechargeSubmitter,
IdentitySnapshot: accountIdentity(&account),
})
}
}
var shop model.Shop
if err := tx.WithContext(ctx).Unscoped().First(&shop, record.ShopID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值店铺审计快照失败")
}
resources = append(resources, ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleRechargeShop))
if change.Payment != nil {
resources = append(resources, PaymentResource(change.Payment, constants.AuditResourceRelationReference, constants.AuditResourceRolePaymentTarget, nil, nil))
}
if change.Approval != nil {
approvalID := strconv.FormatUint(uint64(change.Approval.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceApprovalInstance, ID: &approvalID, Key: approvalID, DisplayName: "审批实例 " + approvalID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRechargeApproval,
IdentitySnapshot: map[string]any{
"id": change.Approval.ID, "business_type": change.Approval.BusinessType,
"business_id": change.Approval.BusinessID, "submitter_account_id": change.Approval.SubmitterAccountID,
"provider": change.Approval.Provider, "external_ref": change.Approval.ExternalRef,
"correlation_id": change.Approval.CorrelationID, "status": change.Approval.Status,
},
})
}
if change.Wallet != nil {
walletID := strconv.FormatUint(uint64(change.Wallet.ID), 10)
relation := constants.AuditResourceRelationReference
if change.Transaction != nil {
relation = constants.AuditResourceRelationAffected
}
wallet := ResourceInput{
Type: constants.AuditResourceAgentWallet, ID: &walletID, Key: walletID, DisplayName: "代理主钱包 " + walletID,
Relation: relation, Role: constants.AuditResourceRoleRechargeWallet,
IdentitySnapshot: map[string]any{
"id": change.Wallet.ID, "shop_id": change.Wallet.ShopID, "wallet_type": change.Wallet.WalletType,
"currency": change.Wallet.Currency, "status": change.Wallet.Status,
},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: change.Summary,
}
if change.Transaction != nil {
wallet.BeforeData = map[string]any{"balance": change.Transaction.BalanceBefore}
wallet.AfterData = map[string]any{"balance": change.Transaction.BalanceAfter}
}
resources = append(resources, wallet)
}
if change.Transaction != nil {
transactionID := strconv.FormatUint(uint64(change.Transaction.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceAgentWalletTransaction, ID: &transactionID, Key: transactionID, DisplayName: "代理钱包流水 " + transactionID,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleRechargeWalletTransaction,
IdentitySnapshot: map[string]any{
"id": change.Transaction.ID, "agent_wallet_id": change.Transaction.AgentWalletID,
"shop_id": change.Transaction.ShopID, "transaction_type": change.Transaction.TransactionType,
"transaction_subtype": change.Transaction.TransactionSubtype,
"reference_type": change.Transaction.ReferenceType, "reference_id": change.Transaction.ReferenceID,
"status": change.Transaction.Status,
},
AfterData: map[string]any{
"amount": change.Transaction.Amount, "balance_before": change.Transaction.BalanceBefore,
"balance_after": change.Transaction.BalanceAfter,
},
})
}
return resources, nil
}
// AssetRechargeReferences 构造个人资产充值关联的提交人、钱包和资产资源。
func AssetRechargeReferences(ctx context.Context, tx *gorm.DB, recharge *model.RechargeOrder) ([]ResourceInput, error) {
if recharge == nil || recharge.ID == 0 {
return nil, errors.New(errors.CodeInvalidParam, "资产充值审计资源不完整")
}
resources := make([]ResourceInput, 0, 3)
var customer model.PersonalCustomer
if err := tx.WithContext(ctx).Unscoped().First(&customer, recharge.UserID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产充值提交人审计快照失败")
}
customerID := strconv.FormatUint(uint64(customer.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourcePersonalCustomer, ID: &customerID, Key: customerID, DisplayName: customer.Nickname,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRechargeSubmitter,
IdentitySnapshot: map[string]any{
"id": customer.ID, "nickname": customer.Nickname, "wx_open_id": customer.WxOpenID,
"wx_union_id": customer.WxUnionID, "status": customer.Status,
},
})
var wallet model.AssetWallet
if err := tx.WithContext(ctx).First(&wallet, recharge.AssetWalletID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产充值钱包审计快照失败")
}
walletID := strconv.FormatUint(uint64(wallet.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceAssetWallet, ID: &walletID, Key: walletID, DisplayName: "资产钱包 " + walletID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRechargeWallet,
IdentitySnapshot: map[string]any{
"id": wallet.ID, "resource_type": wallet.ResourceType, "resource_id": wallet.ResourceID,
"currency": wallet.Currency, "shop_id_tag": wallet.ShopIDTag, "enterprise_id_tag": wallet.EnterpriseIDTag,
},
})
switch recharge.ResourceType {
case constants.AssetWalletResourceTypeIotCard:
var card model.IotCard
if err := tx.WithContext(ctx).Unscoped().First(&card, recharge.ResourceID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产充值卡审计快照失败")
}
cardID := strconv.FormatUint(uint64(card.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceIotCard, ID: &cardID, Key: IotCardResourceKey(&card), DisplayName: card.ICCID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleOrderAsset,
IdentitySnapshot: IotCardIdentitySnapshot(&card), SubjectVisibility: constants.AuditSubjectResult,
SubjectSummary: "资产充值状态已更新",
})
case constants.AssetWalletResourceTypeDevice:
var device model.Device
if err := tx.WithContext(ctx).Unscoped().First(&device, recharge.ResourceID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产充值设备审计快照失败")
}
deviceID := strconv.FormatUint(uint64(device.ID), 10)
resources = append(resources, ResourceInput{
Type: constants.AuditResourceDevice, ID: &deviceID, Key: DeviceResourceKey(&device), DisplayName: device.VirtualNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleOrderAsset,
IdentitySnapshot: DeviceIdentitySnapshot(&device), SubjectVisibility: constants.AuditSubjectResult,
SubjectSummary: "资产充值状态已更新",
})
}
return resources, nil
}

View File

@@ -0,0 +1,128 @@
package audit
import (
"context"
"strconv"
"gorm.io/gorm"
refundapprovalapp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval"
"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"
)
// WriteRefundApplication 将退款申请、审批和关联业务资源写入同一事务。
func (w *Writer) WriteRefundApplication(ctx context.Context, tx *gorm.DB, input refundapprovalapp.ApplicationAudit) error {
if input.Refund == nil || input.Order == nil || input.Approval == nil || input.Submitter == nil {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "退款申请审计资源不完整")
}
primary := RefundResource(input.Refund, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleRefundTarget)
primary.AfterData = refundStateData(input.Refund)
primary.SubjectVisibility = constants.AuditSubjectResult
primary.SubjectSummary = "退款申请已提交"
resources := []ResourceInput{
primary,
OrderResource(input.Order, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundOrder),
ApprovalInstanceResource(input.Approval, constants.AuditResourceRelationAffected, constants.AuditResourceRoleRefundApproval, nil, map[string]any{"status": input.Approval.Status}),
AccountResource(input.Submitter, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundSubmitter),
}
for index := 1; index < len(resources); index++ {
resources[index].SubjectVisibility = constants.AuditSubjectInternalOnly
}
asset, err := RefundAssetResource(ctx, tx, input.Order, "退款申请已提交")
if err != nil {
return err
}
if asset != nil {
resources = append(resources, *asset)
}
return w.Append(ctx, tx, AppendInput{
EventID: "refund:" + strconv.FormatUint(uint64(input.Refund.ID), 10) + ":created",
ActionCode: constants.AuditActionRefundCreated, Summary: "提交退款申请",
Actor: ActorInput{Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(input.Submitter.ID), 10), Name: input.Submitter.Username},
Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform,
Result: constants.AuditResultSuccess, CorrelationID: input.Refund.RefundNo,
Metadata: map[string]any{"requested_refund_amount": input.Refund.RequestedRefundAmount},
Resources: resources,
})
}
// ApprovalInstanceResource 构造审批实例审计资源。
func ApprovalInstanceResource(instance *model.ApprovalInstance, relation, role string, beforeData, afterData map[string]any) ResourceInput {
id := strconv.FormatUint(uint64(instance.ID), 10)
return ResourceInput{
Type: constants.AuditResourceApprovalInstance, ID: &id, Key: id, DisplayName: "审批实例 " + id,
Relation: relation, Role: role,
IdentitySnapshot: map[string]any{
"id": instance.ID, "business_type": instance.BusinessType, "business_id": instance.BusinessID,
"provider": instance.Provider, "external_ref": instance.ExternalRef, "status": instance.Status,
},
BeforeData: beforeData, AfterData: afterData,
}
}
// AccountResource 构造退款链路中的后台账号资源。
func AccountResource(account *model.Account, relation, role string) ResourceInput {
id := strconv.FormatUint(uint64(account.ID), 10)
return ResourceInput{
Type: constants.AuditResourceAccount, ID: &id, Key: id, DisplayName: account.Username,
Relation: relation, Role: role, IdentitySnapshot: accountIdentity(account),
}
}
// RefundAssetResource 构造退款订单实际关联的卡或设备资源。
func RefundAssetResource(ctx context.Context, tx *gorm.DB, order *model.Order, subjectSummary string) (*ResourceInput, error) {
if order.IotCardID != nil {
var card model.IotCard
if err := tx.WithContext(ctx).First(&card, *order.IotCardID).Error; err != nil {
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "查询退款关联卡审计快照失败")
}
id := strconv.FormatUint(uint64(card.ID), 10)
return &ResourceInput{
Type: constants.AuditResourceIotCard, ID: &id, Key: IotCardResourceKey(&card), DisplayName: card.ICCID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRefundAsset,
IdentitySnapshot: IotCardIdentitySnapshot(&card), SubjectVisibility: constants.AuditSubjectResult,
SubjectSummary: subjectSummary,
}, nil
}
if order.DeviceID != nil {
var device model.Device
if err := tx.WithContext(ctx).First(&device, *order.DeviceID).Error; err != nil {
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "查询退款关联设备审计快照失败")
}
id := strconv.FormatUint(uint64(device.ID), 10)
return &ResourceInput{
Type: constants.AuditResourceDevice, ID: &id, Key: DeviceResourceKey(&device), DisplayName: device.VirtualNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRefundAsset,
IdentitySnapshot: DeviceIdentitySnapshot(&device), SubjectVisibility: constants.AuditSubjectResult,
SubjectSummary: subjectSummary,
}, nil
}
return nil, nil
}
// CommissionRecordResource 构造退款失效的佣金记录资源。
func CommissionRecordResource(record *model.CommissionRecord, beforeData, afterData map[string]any) ResourceInput {
id := strconv.FormatUint(uint64(record.ID), 10)
return ResourceInput{
Type: constants.AuditResourceCommissionRecord, ID: &id, Key: id, DisplayName: "佣金记录 " + id,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleRefundCommission,
IdentitySnapshot: map[string]any{
"id": record.ID, "shop_id": record.ShopID, "order_id": record.OrderID,
"iot_card_id": record.IotCardID, "device_id": record.DeviceID,
"commission_source": record.CommissionSource, "amount": record.Amount,
"status": record.Status, "released_at": record.ReleasedAt,
},
BeforeData: beforeData, AfterData: afterData,
}
}
func refundStateData(refund *model.RefundRequest) map[string]any {
return map[string]any{
"status": refund.Status, "approved_refund_amount": refund.ApprovedRefundAmount,
"approval_instance_id": refund.ApprovalInstanceID, "processor_id": refund.ProcessorID,
"processed_at": refund.ProcessedAt, "commission_deducted": refund.CommissionDeducted,
"asset_reset": refund.AssetReset, "reject_reason": refund.RejectReason, "remark": refund.Remark,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
package audit
import (
"context"
"gorm.io/gorm"
auditarchive "github.com/break/junhong_cmp_fiber/internal/application/auditarchive"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// WriteRetentionCleanup 将月度物理清理结果写入当前在线月份的统一审计事件。
func (w *Writer) WriteRetentionCleanup(ctx context.Context, tx *gorm.DB, input auditarchive.RetentionAudit) error {
return w.Append(ctx, tx, AppendInput{
EventID: input.EventID, ActionCode: constants.AuditActionLogRetentionCleanup,
Summary: input.Summary,
Actor: ActorInput{
Kind: constants.AuditActorSystemTask, ID: constants.AuditActorIDRetentionWorker, Name: "日志留存清理任务",
},
Source: constants.AuditSourceWorker, ScopeType: constants.AuditScopePlatform,
Result: input.Result, ErrorSummary: input.ErrorSummary,
CorrelationID: "retention:" + input.Month,
Metadata: map[string]any{
"event_count": input.EventCount, "resource_count": input.ResourceCount,
"integration_count": input.IntegrationCount, "manifest_keys": input.ManifestKeys,
"duration_ms": input.DurationMS,
},
Resources: []ResourceInput{{
Type: constants.AuditResourceLogArchiveMonth, Key: input.Month, DisplayName: input.Month,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleRetentionMonth,
IdentitySnapshot: map[string]any{
"month": input.Month, "timezone": constants.AuditArchiveTimezone,
"range_start": input.RangeStart, "range_end": input.RangeEnd,
},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
})
}

View File

@@ -0,0 +1,57 @@
package audit
import (
"testing"
"github.com/bytedance/sonic"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// TestSecurityActionsRegistered 验证账号安全动作不会绕过注册表。
func TestSecurityActionsRegistered(t *testing.T) {
registry := NewRegistry()
for _, code := range []string{
constants.AuditActionAccountPasswordReset,
constants.AuditActionAccountPasswordChanged,
constants.AuditActionAccountWeComBound,
constants.AuditActionAuthLogin,
constants.AuditActionAuthLogout,
constants.AuditActionAuthTokenRefreshed,
} {
action, ok := registry.Action(code)
if !ok {
t.Fatalf("安全动作未注册:%s", code)
}
if action.PrimaryResource != constants.AuditResourceAccount || action.Category != constants.AuditCategorySecurity {
t.Fatalf("安全动作注册错误:%s", code)
}
}
}
// TestSecurityAuditRemovesCredentials 验证安全凭据不会进入审计 JSON。
func TestSecurityAuditRemovesCredentials(t *testing.T) {
encoded, err := safeObject(map[string]any{
"password": "secret",
"verification_code": "123456",
"access_token": "token",
"cookie": "session=value",
"credentials_configured": true,
"state": "changed",
})
if err != nil {
t.Fatalf("清理审计 JSON 失败:%v", err)
}
var value map[string]any
if err := sonic.Unmarshal(encoded, &value); err != nil {
t.Fatalf("解析审计 JSON 失败:%v", err)
}
for _, field := range []string{"password", "verification_code", "access_token", "cookie"} {
if _, exists := value[field]; exists {
t.Fatalf("安全凭据未删除:%s", field)
}
}
if value["credentials_configured"] != true || value["state"] != "changed" {
t.Fatalf("安全业务事实被错误删除:%v", value)
}
}

View File

@@ -0,0 +1,91 @@
package audit
import (
"context"
"crypto/sha256"
"fmt"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/constants"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
// TaskInput 描述导入、批购、失效或导出任务的安全审计事实。
type TaskInput struct {
EventID string
ActionCode string
Summary string
TaskID uint
TaskNo string
DisplayName string
Actor ActorInput
Source string
ScopeType string
ScopeID string
ScopeName string
Result string
ErrorCode string
ErrorSummary string
CorrelationID string
ParentEventID string
BatchTotal int
SuccessCount int
FailCount int
IdentitySnapshot map[string]any
BeforeData map[string]any
AfterData map[string]any
Metadata map[string]any
}
// WriteTask 将任务状态与批量统计写入对应的注册任务资源。
func (w *Writer) WriteTask(ctx context.Context, tx *gorm.DB, input TaskInput) error {
if w == nil || w.registry == nil {
return pkgerrors.New(pkgerrors.CodeInvalidStatus, "任务统一审计 Writer 未正确配置")
}
action, ok := w.registry.Action(input.ActionCode)
if !ok {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "任务审计动作未注册")
}
if input.TaskNo == "" {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "任务审计缺少稳定任务编号")
}
var taskID *string
if input.TaskID != 0 {
value := strconv.FormatUint(uint64(input.TaskID), 10)
taskID = &value
}
displayName := input.DisplayName
if displayName == "" {
displayName = input.TaskNo
}
return w.Append(ctx, tx, AppendInput{
EventID: input.EventID, ActionCode: input.ActionCode, Summary: input.Summary,
Actor: input.Actor, Source: input.Source,
ScopeType: input.ScopeType, ScopeID: input.ScopeID, ScopeName: input.ScopeName,
Result: input.Result, ErrorCode: input.ErrorCode, ErrorSummary: input.ErrorSummary,
CorrelationID: input.CorrelationID, ParentEventID: input.ParentEventID,
BatchTotal: input.BatchTotal, SuccessCount: input.SuccessCount, FailCount: input.FailCount,
Metadata: input.Metadata,
Resources: []ResourceInput{{
Type: action.PrimaryResource, ID: taskID, Key: input.TaskNo, DisplayName: displayName,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchTask,
IdentitySnapshot: input.IdentitySnapshot, BeforeData: input.BeforeData, AfterData: input.AfterData,
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
})
}
// TaskEventID 返回可重试任务阶段的稳定审计事件 ID。
func TaskEventID(resourceType string, taskID uint, phase string) string {
if taskID == 0 || phase == "" {
return ""
}
value := fmt.Sprintf("task:%s:%d:%s", resourceType, taskID, phase)
if len(value) <= 64 {
return value
}
digest := sha256.Sum256([]byte(value))
return fmt.Sprintf("task:%x", digest[:16])
}

View File

@@ -0,0 +1,293 @@
package audit
import (
"context"
"strconv"
"strings"
"gorm.io/gorm"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// WriteAgentWalletBalanceAdjustment 将人工余额调整写入统一 Audit Event。
func (w *Writer) WriteAgentWalletBalanceAdjustment(ctx context.Context, tx *gorm.DB, event walletapp.CreditedEvent) error {
if event.WalletID == 0 || event.ReferenceType != constants.ReferenceTypeManualAdjustment ||
event.ReferenceID == 0 || event.TransactionType != constants.AgentTransactionTypeAdjustment ||
event.Amount <= 0 || strings.TrimSpace(event.Remark) == "" {
return errors.New(errors.CodeInvalidParam, "代理主钱包人工调整审计事实不完整")
}
var wallet model.AgentWallet
if err := tx.WithContext(ctx).Unscoped().First(&wallet, event.WalletID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询人工调整代理主钱包审计快照失败")
}
var transaction model.AgentWalletTransaction
if err := tx.WithContext(ctx).Unscoped().Where(
"agent_wallet_id = ? AND reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
event.WalletID, event.ReferenceType, event.ReferenceID, event.TransactionType, constants.TransactionStatusSuccess,
).First(&transaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包人工调整流水失败")
}
walletResource := agentWalletAuditResource(&wallet, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleWalletTarget)
walletResource.BeforeData = map[string]any{"balance": transaction.BalanceBefore, "frozen_balance": wallet.FrozenBalance}
walletResource.AfterData = map[string]any{"balance": transaction.BalanceAfter, "frozen_balance": wallet.FrozenBalance}
walletResource.SubjectVisibility = constants.AuditSubjectResult
walletResource.SubjectSummary = "代理主钱包余额已人工调整"
transactionResource := agentWalletTransactionResource(&transaction)
transactionResource.Role = constants.AuditResourceRoleWalletTransaction
return w.Append(ctx, tx, AppendInput{
ActionCode: constants.AuditActionAgentWalletBalanceAdjusted, Summary: "人工调整代理主钱包余额",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: event.CorrelationID,
Metadata: map[string]any{
"amount": event.Amount, "reason": strings.TrimSpace(event.Remark),
"reference_type": event.ReferenceType, "reference_id": event.ReferenceID,
},
Resources: []ResourceInput{walletResource, transactionResource},
})
}
// WriteAgentWalletCreditChange 将实际信用额度变化写入统一 Audit Event。
func (w *Writer) WriteAgentWalletCreditChange(ctx context.Context, tx *gorm.DB, change walletapp.CreditChangeAudit) error {
if change.Wallet == nil || change.Wallet.ID == 0 || change.Wallet.ShopID == 0 || change.Wallet.WalletType != constants.AgentWalletTypeMain {
return errors.New(errors.CodeInvalidParam, "代理主钱包信用额度审计事实不完整")
}
walletResource := agentWalletAuditResource(change.Wallet, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleWalletTarget)
walletResource.BeforeData = change.BeforeData
walletResource.AfterData = change.AfterData
walletResource.SubjectVisibility = constants.AuditSubjectResult
var shop model.Shop
if err := tx.WithContext(ctx).Unscoped().First(&shop, change.Wallet.ShopID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询信用额度关联店铺审计快照失败")
}
shopResource := ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleWalletShop)
result := change.Result
if result == "" {
result = constants.AuditResultSuccess
}
walletResource.SubjectSummary = "代理主钱包信用额度已更新"
if result != constants.AuditResultSuccess {
walletResource.SubjectSummary = "代理主钱包信用额度更新未完成"
}
return w.Append(ctx, tx, AppendInput{
ActionCode: constants.AuditActionAgentWalletCreditChanged, Summary: "调整代理主钱包信用额度",
ScopeType: constants.AuditScopePlatform, Result: result,
ErrorCode: change.ErrorCode, ErrorSummary: change.ErrorSummary,
Resources: []ResourceInput{walletResource, shopResource},
})
}
// WriteAgentWalletDebit 将代理主钱包订单扣款写入统一 Audit Event。
func (w *Writer) WriteAgentWalletDebit(ctx context.Context, tx *gorm.DB, event walletapp.DebitedEvent) error {
if event.WalletID == 0 || event.ReferenceType != constants.ReferenceTypeOrder || event.ReferenceID == 0 || event.Amount <= 0 {
return errors.New(errors.CodeInvalidParam, "代理主钱包订单扣款审计事实不完整")
}
completed, err := completedReservationExists(ctx, tx, event.ReferenceID)
if err != nil || completed {
return err
}
order, wallet, transaction, err := loadAgentWalletDebitFacts(ctx, tx, event)
if err != nil {
return err
}
resources := []ResourceInput{
walletOrderResource(order, "代理主钱包订单扣款"),
agentWalletResource(wallet, transaction.BalanceBefore, wallet.FrozenBalance, transaction.BalanceAfter, wallet.FrozenBalance),
agentWalletTransactionResource(transaction),
}
return w.Append(ctx, tx, AppendInput{
ActionCode: constants.AuditActionAgentWalletOrderDebited, Summary: "代理主钱包完成订单扣款",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: event.CorrelationID, Metadata: map[string]any{"amount": event.Amount}, Resources: resources,
})
}
// WriteAgentWalletReservation 将代理主钱包订单预占状态变化写入统一 Audit Event。
func (w *Writer) WriteAgentWalletReservation(ctx context.Context, tx *gorm.DB, event walletapp.ReservationEvent) error {
actionCode, summary, err := reservationAuditAction(event.Status)
if err != nil {
return err
}
if event.ReservationID == 0 || event.WalletID == 0 || event.ReferenceType != constants.ReferenceTypeOrder || event.ReferenceID == 0 || event.Amount <= 0 {
return errors.New(errors.CodeInvalidParam, "代理主钱包订单预占审计事实不完整")
}
order, wallet, reservation, transaction, err := loadAgentWalletReservationFacts(ctx, tx, event)
if err != nil {
return err
}
beforeBalance, beforeFrozen, afterBalance, afterFrozen := reservationWalletState(wallet, transaction, event)
resources := []ResourceInput{
walletOrderResource(order, summary),
agentWalletResource(wallet, beforeBalance, beforeFrozen, afterBalance, afterFrozen),
agentWalletReservationResource(reservation, event.Status),
}
if transaction != nil {
resources = append(resources, agentWalletTransactionResource(transaction))
}
return w.Append(ctx, tx, AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
Result: constants.AuditResultSuccess, CorrelationID: event.CorrelationID,
Metadata: map[string]any{"amount": event.Amount}, Resources: resources,
})
}
func completedReservationExists(ctx context.Context, tx *gorm.DB, orderID uint) (bool, error) {
var count int64
err := tx.WithContext(ctx).Model(&model.AgentWalletReservation{}).
Where("reference_type = ? AND reference_id = ? AND status = ?", constants.ReferenceTypeOrder, orderID, constants.AgentWalletReservationStatusCompleted).
Count(&count).Error
if err != nil {
return false, errors.Wrap(errors.CodeDatabaseError, err, "查询订单钱包预占终态失败")
}
return count > 0, nil
}
func loadAgentWalletDebitFacts(ctx context.Context, tx *gorm.DB, event walletapp.DebitedEvent) (*model.Order, *model.AgentWallet, *model.AgentWalletTransaction, error) {
var order model.Order
if err := tx.WithContext(ctx).Unscoped().First(&order, event.ReferenceID).Error; err != nil {
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包扣款订单审计快照失败")
}
var wallet model.AgentWallet
if err := tx.WithContext(ctx).Unscoped().First(&wallet, event.WalletID).Error; err != nil {
return nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包审计快照失败")
}
transaction, err := loadOrderDebitTransaction(ctx, tx, event.WalletID, event.ReferenceID)
if err != nil {
return nil, nil, nil, err
}
return &order, &wallet, transaction, nil
}
func loadAgentWalletReservationFacts(ctx context.Context, tx *gorm.DB, event walletapp.ReservationEvent) (*model.Order, *model.AgentWallet, *model.AgentWalletReservation, *model.AgentWalletTransaction, error) {
var order model.Order
if err := tx.WithContext(ctx).Unscoped().First(&order, event.ReferenceID).Error; err != nil {
return nil, nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包预占订单审计快照失败")
}
var wallet model.AgentWallet
if err := tx.WithContext(ctx).Unscoped().First(&wallet, event.WalletID).Error; err != nil {
return nil, nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包审计快照失败")
}
var reservation model.AgentWalletReservation
if err := tx.WithContext(ctx).First(&reservation, event.ReservationID).Error; err != nil {
return nil, nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包预占审计快照失败")
}
var transaction *model.AgentWalletTransaction
if event.Status == constants.AgentWalletReservationStatusCompleted {
loaded, err := loadOrderDebitTransaction(ctx, tx, event.WalletID, event.ReferenceID)
if err != nil {
return nil, nil, nil, nil, err
}
transaction = loaded
}
return &order, &wallet, &reservation, transaction, nil
}
func loadOrderDebitTransaction(ctx context.Context, tx *gorm.DB, walletID, orderID uint) (*model.AgentWalletTransaction, error) {
var transaction model.AgentWalletTransaction
err := tx.WithContext(ctx).Unscoped().Where(
"agent_wallet_id = ? AND reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
walletID, constants.ReferenceTypeOrder, orderID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess,
).First(&transaction).Error
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理主钱包订单扣款流水失败")
}
return &transaction, nil
}
func walletOrderResource(order *model.Order, summary string) ResourceInput {
resource := OrderResource(order, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleOrderTarget)
resource.SubjectVisibility = constants.AuditSubjectResult
resource.SubjectSummary = summary
return resource
}
func agentWalletResource(wallet *model.AgentWallet, beforeBalance, beforeFrozen, afterBalance, afterFrozen int64) ResourceInput {
id := strconv.FormatUint(uint64(wallet.ID), 10)
return ResourceInput{
Type: constants.AuditResourceAgentWallet, ID: &id, Key: id, DisplayName: "代理主钱包 " + id,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleOrderWallet,
IdentitySnapshot: map[string]any{
"id": wallet.ID, "shop_id": wallet.ShopID, "wallet_type": wallet.WalletType,
"currency": wallet.Currency, "status": wallet.Status,
},
BeforeData: map[string]any{"balance": beforeBalance, "frozen_balance": beforeFrozen},
AfterData: map[string]any{"balance": afterBalance, "frozen_balance": afterFrozen},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "代理主钱包订单资金已更新",
}
}
func agentWalletAuditResource(wallet *model.AgentWallet, relation, role string) ResourceInput {
id := strconv.FormatUint(uint64(wallet.ID), 10)
return ResourceInput{
Type: constants.AuditResourceAgentWallet, ID: &id, Key: id, DisplayName: "代理主钱包 " + id,
Relation: relation, Role: role,
IdentitySnapshot: map[string]any{
"id": wallet.ID, "shop_id": wallet.ShopID, "wallet_type": wallet.WalletType,
"currency": wallet.Currency, "status": wallet.Status,
"credit_enabled": wallet.CreditEnabled, "credit_limit": wallet.CreditLimit,
},
}
}
func agentWalletReservationResource(reservation *model.AgentWalletReservation, status int) ResourceInput {
id := strconv.FormatUint(uint64(reservation.ID), 10)
return ResourceInput{
Type: constants.AuditResourceAgentWalletReservation, ID: &id, Key: reservation.ReferenceType + ":" + strconv.FormatUint(uint64(reservation.ReferenceID), 10),
DisplayName: "订单钱包预占 " + id, Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleOrderWalletReservation,
IdentitySnapshot: map[string]any{
"id": reservation.ID, "agent_wallet_id": reservation.AgentWalletID, "shop_id": reservation.ShopID,
"amount": reservation.Amount, "status": status, "reference_type": reservation.ReferenceType, "reference_id": reservation.ReferenceID,
},
BeforeData: map[string]any{"status": reservationStatusBefore(status)}, AfterData: map[string]any{"status": status},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "订单钱包预占状态已更新",
}
}
func agentWalletTransactionResource(transaction *model.AgentWalletTransaction) ResourceInput {
id := strconv.FormatUint(uint64(transaction.ID), 10)
return ResourceInput{
Type: constants.AuditResourceAgentWalletTransaction, ID: &id, Key: id, DisplayName: "代理钱包流水 " + id,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleOrderWalletTransaction,
IdentitySnapshot: map[string]any{
"id": transaction.ID, "agent_wallet_id": transaction.AgentWalletID, "shop_id": transaction.ShopID,
"transaction_type": transaction.TransactionType, "transaction_subtype": transaction.TransactionSubtype,
"reference_type": transaction.ReferenceType, "reference_id": transaction.ReferenceID, "status": transaction.Status,
},
AfterData: map[string]any{"amount": transaction.Amount, "balance_before": transaction.BalanceBefore, "balance_after": transaction.BalanceAfter},
}
}
func reservationAuditAction(status int) (string, string, error) {
switch status {
case constants.AgentWalletReservationStatusFrozen:
return constants.AuditActionAgentWalletOrderReserved, "代理主钱包已预占订单资金", nil
case constants.AgentWalletReservationStatusReleased:
return constants.AuditActionAgentWalletOrderReleased, "代理主钱包已释放订单预占", nil
case constants.AgentWalletReservationStatusCompleted:
return constants.AuditActionAgentWalletOrderCompleted, "代理主钱包已完成订单预占扣款", nil
default:
return "", "", errors.New(errors.CodeInvalidParam, "代理主钱包预占状态不受支持")
}
}
func reservationWalletState(wallet *model.AgentWallet, transaction *model.AgentWalletTransaction, event walletapp.ReservationEvent) (int64, int64, int64, int64) {
afterBalance, afterFrozen := wallet.Balance, wallet.FrozenBalance
switch event.Status {
case constants.AgentWalletReservationStatusFrozen:
return afterBalance, afterFrozen - event.Amount, afterBalance, afterFrozen
case constants.AgentWalletReservationStatusReleased:
return afterBalance, afterFrozen + event.Amount, afterBalance, afterFrozen
default:
return transaction.BalanceBefore, afterFrozen + event.Amount, transaction.BalanceAfter, afterFrozen
}
}
func reservationStatusBefore(status int) any {
if status == constants.AgentWalletReservationStatusFrozen {
return nil
}
return constants.AgentWalletReservationStatusFrozen
}

File diff suppressed because it is too large Load Diff

View File

@@ -15,6 +15,7 @@ import (
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -180,7 +181,7 @@ func (c *RealnameChangedConsumer) Consume(ctx context.Context, envelope outbox.D
if c == nil || c.db == nil {
return errors.New(errors.CodeInternalError, "卡实名事件消费者未配置")
}
ctx = cardapp.SuppressSeriesTriggerContext(ctx)
ctx = cardapp.SuppressSeriesTriggerContext(auditcontext.With(ctx, auditcontext.Context{ParentEventID: envelope.EventID}))
if envelope.EventType != constants.OutboxEventTypeCardRealnameChanged || envelope.PayloadVersion != constants.CardRealnameChangedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡实名事件类型或版本不受支持")
}
@@ -237,7 +238,7 @@ func (c *TrafficIncrementedConsumer) Consume(ctx context.Context, envelope outbo
if c == nil || c.db == nil || c.redis == nil || c.deductor == nil {
return errors.New(errors.CodeInternalError, "卡流量事件消费者未配置")
}
ctx = cardapp.SuppressSeriesTriggerContext(ctx)
ctx = cardapp.SuppressSeriesTriggerContext(auditcontext.With(ctx, auditcontext.Context{ParentEventID: envelope.EventID}))
if envelope.EventType != constants.OutboxEventTypeCardTrafficIncremented || envelope.PayloadVersion != constants.CardTrafficIncrementedPayloadVersionV1 {
return errors.New(errors.CodeInvalidParam, "卡流量事件类型或版本不受支持")
}

View File

@@ -12,9 +12,11 @@ import (
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
apperrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -77,11 +79,15 @@ type SeriesRunner struct {
observation *cardapp.Service
integration *integrationlog.Repository
carrier *postgres.CarrierStore
auditWriter *audit.Writer
}
// NewSeriesRunner 创建卡观测序列 Gateway 执行器。
func NewSeriesRunner(db *gorm.DB, gatewayClient *gateway.Client, observation *cardapp.Service, integration *integrationlog.Repository) *SeriesRunner {
return &SeriesRunner{db: db, gateway: gatewayClient, observation: observation, integration: integration, carrier: postgres.NewCarrierStore(db)}
func NewSeriesRunner(db *gorm.DB, gatewayClient *gateway.Client, observation *cardapp.Service, integration *integrationlog.Repository, auditWriter *audit.Writer) *SeriesRunner {
return &SeriesRunner{
db: db, gateway: gatewayClient, observation: observation, integration: integration,
carrier: postgres.NewCarrierStore(db), auditWriter: auditWriter,
}
}
// Provider 返回用于请求互斥的运营商接入标识。
@@ -188,7 +194,7 @@ func (r *SeriesRunner) runDeviceInfo(ctx context.Context, payload cardapp.Series
response, runErr := r.gateway.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{CardNo: deviceGatewayIdentifier(device)})
result := cardapp.RunResult{}
if runErr == nil {
result.StateChanged, runErr = r.applyDeviceInfo(ctx, device, response)
result.StateChanged, runErr = r.applyDeviceInfo(ctx, device, response, attempt.IntegrationID)
}
completionResult := constants.IntegrationResultSuccess
if runErr != nil {
@@ -222,7 +228,7 @@ func (r *SeriesRunner) startDeviceAttempt(ctx context.Context, payload cardapp.S
})
}
func (r *SeriesRunner) applyDeviceInfo(ctx context.Context, device *model.Device, response *gateway.SyncDeviceInfoResp) (bool, error) {
func (r *SeriesRunner) applyDeviceInfo(ctx context.Context, device *model.Device, response *gateway.SyncDeviceInfoResp, integrationID string) (bool, error) {
if response == nil {
return false, apperrors.New(apperrors.CodeGatewayError, "Gateway 设备信息响应为空")
}
@@ -236,21 +242,19 @@ func (r *SeriesRunner) applyDeviceInfo(ctx context.Context, device *model.Device
if lastOnlineTime := parseGatewayTime(response.LastOnlineTime); lastOnlineTime != nil {
updates["last_online_time"] = lastOnlineTime
}
var currentBinding struct {
SlotPosition int
}
currentSlotErr := r.db.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Select("slot_position").
Where("device_id = ? AND bind_status = ? AND is_current = ?", device.ID, constants.BindStatusBound, true).
Take(&currentBinding).Error
if currentSlotErr != nil && currentSlotErr != gorm.ErrRecordNotFound {
return false, apperrors.Wrap(apperrors.CodeDatabaseError, currentSlotErr, "读取设备当前槽位失败")
}
changed := device.OnlineStatus != int(response.OnlineStatus) ||
deviceChanged := device.OnlineStatus != int(response.OnlineStatus) ||
device.SoftwareVersion != string(response.SoftwareVersion) ||
device.SwitchMode != string(response.SwitchMode) ||
currentBinding.SlotPosition != int(response.CurrentSlotNo)
device.SwitchMode != string(response.SwitchMode)
changed := deviceChanged
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
state, err := loadDeviceObservationState(ctx, tx, device.ID, int(response.CurrentSlotNo))
if err != nil {
return err
}
beforeSlot := bindingSlot(state.current)
afterSlot := bindingSlot(state.target)
changed = deviceChanged || beforeSlot != int(response.CurrentSlotNo)
auditChanged := deviceChanged || beforeSlot != afterSlot
if err := tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(updates).Error; err != nil {
return err
}
@@ -259,19 +263,192 @@ func (r *SeriesRunner) applyDeviceInfo(ctx context.Context, device *model.Device
Update("is_current", false).Error; err != nil {
return err
}
if int(response.CurrentSlotNo) <= 0 {
if int(response.CurrentSlotNo) > 0 {
if err := tx.Model(&model.DeviceSimBinding{}).
Where("device_id = ? AND slot_position = ? AND bind_status = ?", device.ID, int(response.CurrentSlotNo), constants.BindStatusBound).
Update("is_current", true).Error; err != nil {
return err
}
}
if !auditChanged {
return nil
}
return tx.Model(&model.DeviceSimBinding{}).
Where("device_id = ? AND slot_position = ? AND bind_status = ?", device.ID, int(response.CurrentSlotNo), constants.BindStatusBound).
Update("is_current", true).Error
return r.appendDeviceObservationAudit(ctx, tx, device, state, beforeSlot, afterSlot, integrationID, map[string]any{
"online_status": device.OnlineStatus, "software_version": device.SoftwareVersion,
"switch_mode": device.SwitchMode, "current_slot": beforeSlot,
}, map[string]any{
"online_status": int(response.OnlineStatus), "software_version": string(response.SoftwareVersion),
"switch_mode": string(response.SwitchMode), "current_slot": afterSlot,
})
})
if err != nil {
return false, apperrors.Wrap(apperrors.CodeDatabaseError, err, "回写设备 Gateway 信息失败")
wrapped := apperrors.Wrap(apperrors.CodeDatabaseError, err, "回写设备 Gateway 信息失败")
r.recordDeviceObservationFailure(ctx, device, integrationID, wrapped)
return false, wrapped
}
return changed, nil
}
type deviceObservationState struct {
bindings []model.DeviceSimBinding
cards map[uint]*model.IotCard
current *model.DeviceSimBinding
target *model.DeviceSimBinding
}
func loadDeviceObservationState(ctx context.Context, tx *gorm.DB, deviceID uint, targetSlot int) (*deviceObservationState, error) {
state := &deviceObservationState{cards: make(map[uint]*model.IotCard)}
if err := tx.WithContext(ctx).Where("device_id = ? AND bind_status = ?", deviceID, constants.BindStatusBound).
Order("slot_position ASC").Find(&state.bindings).Error; err != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "读取设备有效卡槽失败")
}
cardIDs := make([]uint, 0, len(state.bindings))
for index := range state.bindings {
binding := &state.bindings[index]
cardIDs = append(cardIDs, binding.IotCardID)
if binding.IsCurrent && state.current == nil {
state.current = binding
}
if targetSlot > 0 && binding.SlotPosition == targetSlot {
state.target = binding
}
}
if len(cardIDs) == 0 {
return state, nil
}
var cards []*model.IotCard
if err := tx.WithContext(ctx).Where("id IN ?", cardIDs).Find(&cards).Error; err != nil {
return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "读取设备卡槽关联 IoT 卡失败")
}
for _, card := range cards {
state.cards[card.ID] = card
}
return state, nil
}
func bindingSlot(binding *model.DeviceSimBinding) int {
if binding == nil {
return 0
}
return binding.SlotPosition
}
func (r *SeriesRunner) appendDeviceObservationAudit(
ctx context.Context,
tx *gorm.DB,
device *model.Device,
state *deviceObservationState,
beforeSlot, afterSlot int,
integrationID string,
beforeData, afterData map[string]any,
) error {
if r.auditWriter == nil {
return apperrors.New(apperrors.CodeInternalError, "设备观测统一审计能力未配置")
}
deviceID := strconv.FormatUint(uint64(device.ID), 10)
resources := []audit.ResourceInput{{
Type: constants.AuditResourceDevice, ID: &deviceID,
Key: audit.DeviceResourceKey(device), DisplayName: device.VirtualNo,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleDeviceTarget,
IdentitySnapshot: audit.DeviceIdentitySnapshot(device), BeforeData: beforeData, AfterData: afterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "Worker 同步设备观测事实",
}}
if beforeSlot != afterSlot {
resources = appendDeviceObservationBindingResources(resources, device, state.current, state.cards[stateCardID(state.current)], false,
constants.AuditResourceRoleDeviceOldCurrentCard, constants.AuditResourceRoleDeviceOldCurrentBinding)
resources = appendDeviceObservationBindingResources(resources, device, state.target, state.cards[stateCardID(state.target)], true,
constants.AuditResourceRoleDeviceNewCurrentCard, constants.AuditResourceRoleDeviceNewCurrentBinding)
}
resources = append(resources, deviceObservationIntegrationResource(ctx, device, integrationID))
return r.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionDeviceWorkerObservationSynced,
Summary: "Worker 同步设备观测事实", ScopeType: constants.AuditScopePlatform,
Result: constants.AuditResultSuccess, Resources: resources,
})
}
func appendDeviceObservationBindingResources(
resources []audit.ResourceInput,
device *model.Device,
binding *model.DeviceSimBinding,
card *model.IotCard,
afterCurrent bool,
cardRole, bindingRole string,
) []audit.ResourceInput {
if binding == nil {
return resources
}
if card != nil {
cardID := strconv.FormatUint(uint64(card.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceIotCard, ID: &cardID,
Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID,
Relation: constants.AuditResourceRelationAffected, Role: cardRole,
IdentitySnapshot: audit.IotCardIdentitySnapshot(card),
BeforeData: map[string]any{"is_current": binding.IsCurrent}, AfterData: map[string]any{"is_current": afterCurrent},
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "设备当前卡槽已同步",
})
}
bindingID := strconv.FormatUint(uint64(binding.ID), 10)
identity := map[string]any{
"id": binding.ID, "device_id": binding.DeviceID, "device_virtual_no": device.VirtualNo,
"slot_position": binding.SlotPosition, "iot_card_id": binding.IotCardID, "is_current": afterCurrent,
}
if card != nil {
identity["iccid"] = card.ICCID
identity["virtual_no"] = card.VirtualNo
}
return append(resources, audit.ResourceInput{
Type: constants.AuditResourceDeviceSIMBinding, ID: &bindingID,
Key: bindingID, DisplayName: device.VirtualNo,
Relation: constants.AuditResourceRelationAffected, Role: bindingRole,
IdentitySnapshot: identity,
BeforeData: map[string]any{"is_current": binding.IsCurrent}, AfterData: map[string]any{"is_current": afterCurrent},
SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
func stateCardID(binding *model.DeviceSimBinding) uint {
if binding == nil {
return 0
}
return binding.IotCardID
}
func deviceObservationIntegrationResource(ctx context.Context, device *model.Device, integrationID string) audit.ResourceInput {
deviceID := strconv.FormatUint(uint64(device.ID), 10)
return audit.ResourceInput{
Type: constants.AuditResourceIntegrationLog, Key: integrationID, DisplayName: integrationID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleWorkerIntegration,
IdentitySnapshot: map[string]any{
"integration_id": integrationID, "provider": constants.IntegrationProviderGateway,
"direction": constants.IntegrationDirectionOutbound, "operation": constants.IntegrationOperationGatewayDeviceInfo,
"resource_type": constants.CardObservationResourceTypeDevice, "resource_id": deviceID,
"resource_key": "device:" + deviceID, "correlation_id": auditcontext.From(ctx).CorrelationID,
},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}
}
func (r *SeriesRunner) recordDeviceObservationFailure(ctx context.Context, device *model.Device, integrationID string, businessErr error) {
deviceID := strconv.FormatUint(uint64(device.ID), 10)
r.auditWriter.RecordFailure(ctx, r.db, audit.AppendInput{
ActionCode: constants.AuditActionDeviceWorkerObservationSynced,
Summary: "Worker 同步设备观测事实失败", ScopeType: constants.AuditScopePlatform,
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceDevice, ID: &deviceID,
Key: audit.DeviceResourceKey(device), DisplayName: device.VirtualNo,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleDeviceTarget,
IdentitySnapshot: audit.DeviceIdentitySnapshot(device),
BeforeData: map[string]any{
"online_status": device.OnlineStatus, "software_version": device.SoftwareVersion,
"switch_mode": device.SwitchMode,
},
SubjectVisibility: constants.AuditSubjectResult,
}, deviceObservationIntegrationResource(ctx, device, integrationID)},
}, businessErr)
}
func (r *SeriesRunner) loadDevice(ctx context.Context, payload cardapp.SeriesTaskPayload) (*model.Device, error) {
if payload.ResourceType != constants.CardObservationResourceTypeDevice {
return nil, apperrors.New(apperrors.CodeInvalidParam, "设备信息观测资源类型无效")

View File

@@ -7,6 +7,7 @@ import (
"encoding/hex"
"strings"
"time"
"unicode/utf8"
"github.com/google/uuid"
"gorm.io/datatypes"
@@ -46,15 +47,18 @@ type Attempt struct {
// Completion 描述外部尝试从待处理状态进入终态的结果。
type Completion struct {
Result string
HTTPStatus int
ProviderCode string
ProviderMessage string
ResponseSummary any
DurationMS int64
StateChanged bool
AuditEventID *uint
RecoveryStrategy string
Result string
HTTPStatus int
ProviderCode string
ProviderMessage string
SafeProviderMessage string
ResponseSummary any
DurationMS int64
StateChanged bool
ResourceID *string
ResourceKey *string
AuditEventID *uint
RecoveryStrategy string
}
// InboundAttempt 描述业务处理前必须保存的入站回调安全事实。
@@ -105,6 +109,13 @@ func (r *Repository) Start(ctx context.Context, input Attempt) (*model.Integrati
}
if input.Attempt <= 0 {
input.Attempt = 1
if input.TriggerSeries != nil {
if err := r.db.WithContext(ctx).Model(&model.IntegrationLog{}).
Select("COALESCE(MAX(attempt), 0) + 1").
Where("trigger_series = ?", *input.TriggerSeries).Scan(&input.Attempt).Error; err != nil {
return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "计算 Integration Log 尝试序号失败")
}
}
}
if input.StartedAt == nil {
startedAt := r.now().UTC()
@@ -136,12 +147,19 @@ func (r *Repository) Complete(ctx context.Context, integrationID string, complet
if r == nil || r.db == nil {
return nil, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Integration Log 数据库未配置")
}
if integrationID == "" || !isTerminalResult(completion.Result) {
if !validRequiredString(integrationID, constants.IntegrationIDMaxLength) ||
!validOptionalString(completion.ResourceID, constants.IntegrationResourceIDMaxLength) ||
!validOptionalString(completion.ResourceKey, constants.IntegrationResourceKeyMaxLength) ||
!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, "结果未知必须记录明确恢复策略")
}
safeProviderMessage := strings.TrimSpace(completion.SafeProviderMessage)
if safeProviderMessage != "" && utf8.RuneCountInString(constants.IntegrationSafeMessagePrefix+safeProviderMessage) > constants.IntegrationProviderMessageMaxLength {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 安全结果摘要过长")
}
responseSummary, err := marshalSummary(completion.ResponseSummary)
if err != nil {
return nil, pkgerrors.Wrap(pkgerrors.CodeInvalidParam, err, "Integration Log 响应摘要无效")
@@ -157,12 +175,20 @@ func (r *Repository) Complete(ctx context.Context, integrationID string, complet
if completion.ProviderCode != "" {
updates["provider_code"] = completion.ProviderCode
}
if completion.ProviderMessage != "" {
if safeProviderMessage != "" {
updates["provider_message"] = constants.IntegrationSafeMessagePrefix + safeProviderMessage
} else if completion.ProviderMessage != "" {
updates["provider_message"] = sanitizer.TextSummary(completion.ProviderMessage)
}
if completion.AuditEventID != nil {
updates["audit_event_id"] = completion.AuditEventID
}
if completion.ResourceID != nil {
updates["resource_id"] = completion.ResourceID
}
if completion.ResourceKey != nil {
updates["resource_key"] = completion.ResourceKey
}
if completion.RecoveryStrategy != "" {
updates["recovery_strategy"] = completion.RecoveryStrategy
}
@@ -187,12 +213,17 @@ func (r *Repository) RecordInbound(ctx context.Context, input InboundAttempt) (*
if r == nil || r.db == nil {
return nil, false, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Integration Log 数据库未配置")
}
if input.Provider == "" || input.Operation == "" || input.IdempotencyKey == "" {
if input.Provider == "" || input.Operation == "" || input.IdempotencyKey == "" ||
!validGeneratedString(input.IntegrationID, constants.IntegrationIDMaxLength) ||
!validOptionalString(input.ResourceID, constants.IntegrationResourceIDMaxLength) ||
!validOptionalString(input.ResourceKey, constants.IntegrationResourceKeyMaxLength) ||
!validOptionalString(input.CorrelationID, constants.IntegrationCorrelationIDMaxLength) {
return nil, false, pkgerrors.New(pkgerrors.CodeInvalidParam, "入站 Integration Log 参数无效")
}
if input.IntegrationID == "" {
input.IntegrationID = uuid.NewString()
}
triggerSeries := input.IntegrationID
hash := sha256.Sum256(input.RawPayload)
summary, err := marshalSummary(map[string]any{
"content_type": input.ContentType,
@@ -208,7 +239,8 @@ func (r *Repository) RecordInbound(ctx context.Context, input InboundAttempt) (*
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,
TriggerSeries: &triggerSeries,
Result: constants.IntegrationResultPending, RequestSummary: summary,
ContentHash: hex.EncodeToString(hash[:]), RequestID: input.RequestID, CorrelationID: input.CorrelationID,
}
result := r.db.WithContext(ctx).Clauses(clause.OnConflict{
@@ -289,9 +321,28 @@ func validateAttempt(input Attempt) error {
if input.InitialResult != "" && input.InitialResult != constants.IntegrationResultPending && !isUnsentResult(input.InitialResult) {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 初始结果只能是待处理或未发送终态")
}
if !validGeneratedString(input.IntegrationID, constants.IntegrationIDMaxLength) ||
!validOptionalString(input.TriggerSeries, constants.IntegrationTriggerSeriesMaxLength) ||
!validOptionalString(input.CorrelationID, constants.IntegrationCorrelationIDMaxLength) ||
!validOptionalString(input.ResourceID, constants.IntegrationResourceIDMaxLength) ||
!validOptionalString(input.ResourceKey, constants.IntegrationResourceKeyMaxLength) {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "Integration Log 链路或资源标识无效")
}
return nil
}
func validGeneratedString(value string, maxLength int) bool {
return value == "" || validRequiredString(value, maxLength)
}
func validRequiredString(value string, maxLength int) bool {
return strings.TrimSpace(value) == value && value != "" && utf8.RuneCountInString(value) <= maxLength
}
func validOptionalString(value *string, maxLength int) bool {
return value == nil || validRequiredString(*value, maxLength)
}
func isTerminalResult(result string) bool {
switch result {
case constants.IntegrationResultSuccess, constants.IntegrationResultFailed, constants.IntegrationResultUnknown,

View File

@@ -14,6 +14,7 @@ import (
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
@@ -29,6 +30,7 @@ type DeliveryEnvelope struct {
BusinessKey string `json:"business_key,omitempty"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
ParentEventID string `json:"parent_event_id,omitempty"`
Payload sonic.NoCopyRawMessage `json:"payload"`
}
@@ -253,7 +255,7 @@ func deliveryEnvelope(event model.OutboxEvent) DeliveryEnvelope {
EventID: event.EventID, EventType: event.EventType, PayloadVersion: event.PayloadVersion,
AggregateType: event.AggregateType, AggregateID: event.AggregateID,
ResourceType: event.ResourceType, ResourceID: event.ResourceID, BusinessKey: event.BusinessKey,
RequestID: event.RequestID, CorrelationID: event.CorrelationID,
RequestID: event.RequestID, CorrelationID: event.CorrelationID, ParentEventID: event.ParentEventID,
Payload: sonic.NoCopyRawMessage(event.Payload),
}
}
@@ -326,5 +328,10 @@ func (h *Handler) Handle(ctx context.Context, task *asynq.Task) error {
if envelope.EventID == "" || envelope.EventType == "" || envelope.PayloadVersion <= 0 {
return stderrors.New("Outbox 事件信封不完整")
}
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: envelope.EventType,
ActorName: "Outbox 消费任务", Source: constants.AuditSourceWorker,
RequestID: envelope.RequestID, CorrelationID: envelope.CorrelationID, ParentEventID: envelope.ParentEventID,
})
return h.consumer.Consume(ctx, envelope)
}

View File

@@ -15,6 +15,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/asynctask"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
@@ -30,6 +31,7 @@ type Envelope struct {
BusinessKey string `json:"business_key,omitempty"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
ParentEventID string `json:"parent_event_id,omitempty"`
Payload any `json:"payload"`
}
@@ -73,13 +75,24 @@ func (r *Repository) append(ctx context.Context, tx *gorm.DB, envelope Envelope,
if envelope.PayloadVersion <= 0 {
envelope.PayloadVersion = 1
}
linkage := auditcontext.From(ctx)
if envelope.RequestID == "" {
envelope.RequestID = linkage.RequestID
}
if envelope.CorrelationID == "" {
envelope.CorrelationID = linkage.CorrelationID
}
if envelope.ParentEventID == "" {
envelope.ParentEventID = linkage.ParentEventID
}
now := time.Now().UTC()
event := &model.OutboxEvent{
EventID: envelope.EventID, EventType: envelope.EventType, PayloadVersion: envelope.PayloadVersion,
AggregateType: envelope.AggregateType, AggregateID: envelope.AggregateID,
ResourceType: envelope.ResourceType, ResourceID: envelope.ResourceID, BusinessKey: envelope.BusinessKey,
RequestID: envelope.RequestID, CorrelationID: envelope.CorrelationID, Payload: datatypes.JSON(payload),
Status: constants.OutboxStatusPending, MaxRetries: constants.OutboxDefaultMaxRetries, NextAttemptAt: now,
RequestID: envelope.RequestID, CorrelationID: envelope.CorrelationID, ParentEventID: envelope.ParentEventID,
Payload: datatypes.JSON(payload),
Status: constants.OutboxStatusPending, MaxRetries: constants.OutboxDefaultMaxRetries, NextAttemptAt: now,
}
create := tx.WithContext(ctx)
if idempotent {

View File

@@ -2,28 +2,39 @@ package notification
import (
"context"
"fmt"
"strconv"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// CleanupService 按通知类别的保留期限分批删除过期通知事实。
type CleanupService struct {
db *gorm.DB
logger *zap.Logger
now func() time.Time
db *gorm.DB
logger *zap.Logger
auditWriter *audit.Writer
now func() time.Time
}
// NewCleanupService 创建通知保留清理服务。
func NewCleanupService(db *gorm.DB, logger *zap.Logger) *CleanupService {
func NewCleanupService(db *gorm.DB, logger *zap.Logger, auditWriters ...*audit.Writer) *CleanupService {
if logger == nil {
logger = zap.NewNop()
}
return &CleanupService{db: db, logger: logger, now: time.Now}
service := &CleanupService{db: db, logger: logger, now: time.Now}
if len(auditWriters) > 0 {
service.auditWriter = auditWriters[0]
}
return service
}
// Run 按类别、创建时间和稳定主键执行有界分批清理。
@@ -49,24 +60,74 @@ func (s *CleanupService) Run(ctx context.Context) error {
}
func (s *CleanupService) cleanupCategory(ctx context.Context, category string, cutoff time.Time) (int64, error) {
if s.auditWriter == nil {
return 0, errors.New(errors.CodeInvalidStatus, "通知清理统一审计接缝未配置")
}
var total int64
for batch := 0; batch < constants.NotificationCleanupMaxBatches; batch++ {
result := s.db.WithContext(ctx).Exec(`WITH candidates AS (
SELECT id FROM tb_notification
WHERE category = ? AND created_at < ?
ORDER BY created_at ASC, id ASC
LIMIT ?
)
DELETE FROM tb_notification AS notification
USING candidates
WHERE notification.id = candidates.id`, category, cutoff, constants.NotificationCleanupBatchSize)
if result.Error != nil {
return total, errors.Wrap(errors.CodeDatabaseError, result.Error, "清理站内通知失败")
var deleted []*model.Notification
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).
Where("category = ? AND created_at < ?", category, cutoff).
Order("created_at ASC, id ASC").Limit(constants.NotificationCleanupBatchSize).Find(&deleted).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询待清理站内通知失败")
}
if len(deleted) == 0 {
return nil
}
ids := make([]uint, 0, len(deleted))
for _, notification := range deleted {
ids = append(ids, notification.ID)
}
result := tx.WithContext(ctx).Where("id IN ?", ids).Delete(&model.Notification{})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "清理站内通知失败")
}
if result.RowsAffected != int64(len(deleted)) {
return errors.New(errors.CodeInvalidStatus, "通知清理数量发生并发变化")
}
return s.appendCleanupAudit(ctx, tx, category, cutoff, deleted)
}); err != nil {
return total, err
}
total += result.RowsAffected
if result.RowsAffected < constants.NotificationCleanupBatchSize {
total += int64(len(deleted))
if len(deleted) < constants.NotificationCleanupBatchSize {
break
}
}
return total, nil
}
func (s *CleanupService) appendCleanupAudit(ctx context.Context, tx *gorm.DB, category string, cutoff time.Time, notifications []*model.Notification) error {
firstID, lastID := notifications[0].ID, notifications[len(notifications)-1].ID
key := fmt.Sprintf("%s:%s:%d:%d", category, cutoff.UTC().Format(time.RFC3339), firstID, lastID)
rootID := "evt_" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("notification-cleanup:"+key)).String()
children := make([]audit.AppendInput, 0, len(notifications))
for _, notification := range notifications {
children = append(children, audit.AppendInput{
EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "cleanup"),
ActionCode: constants.AuditActionNotificationCleanupItem, Summary: "清理单条过期通知",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
Resources: []audit.ResourceInput{audit.NotificationResource(notification,
constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget,
map[string]any{"exists": true}, map[string]any{"deleted": true})},
})
}
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
Root: audit.AppendInput{
EventID: rootID, ActionCode: constants.AuditActionNotificationCleanup, Summary: "清理过期通知",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
BatchTotal: len(notifications), SuccessCount: len(notifications),
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceNotificationCleanupBatch, Key: rootID, DisplayName: "通知清理批次",
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchTask,
IdentitySnapshot: map[string]any{
"category": category, "cutoff": cutoff.UTC(), "deleted_count": len(notifications),
"first_id": firstID, "last_id": lastID,
}, SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
Metadata: map[string]any{"first_id": strconv.FormatUint(uint64(firstID), 10), "last_id": strconv.FormatUint(uint64(lastID), 10)},
},
Children: children,
})
}

View File

@@ -20,6 +20,12 @@ func NewRepository(db *gorm.DB) *Repository {
return &Repository{db: db}
}
// DB 返回通知 Repository 使用的数据库连接。
func (r *Repository) DB() *gorm.DB { return r.db }
// WithTx 返回绑定指定事务的通知 Repository。
func (r *Repository) WithTx(tx *gorm.DB) *Repository { return &Repository{db: tx} }
// CreateIdempotent 以事件、接收人类型和接收人 ID 唯一键幂等写入通知。
func (r *Repository) CreateIdempotent(ctx context.Context, notification *model.Notification) (bool, error) {
result := r.db.WithContext(ctx).Clauses(clause.OnConflict{

View File

@@ -20,16 +20,17 @@ import (
type AgentRechargePaymentConsumer struct {
db *gorm.DB
posting *walletapp.PostingService
audit agentrecharge.RechargeAuditWriter
}
// NewAgentRechargePaymentConsumer 创建代理在线充值入账消费者。
func NewAgentRechargePaymentConsumer(db *gorm.DB, posting *walletapp.PostingService) *AgentRechargePaymentConsumer {
return &AgentRechargePaymentConsumer{db: db, posting: posting}
func NewAgentRechargePaymentConsumer(db *gorm.DB, posting *walletapp.PostingService, audit agentrecharge.RechargeAuditWriter) *AgentRechargePaymentConsumer {
return &AgentRechargePaymentConsumer{db: db, posting: posting, audit: audit}
}
// Consume 校验支付与充值权威事实后,在独立事务中完成唯一入账和充值终态。
func (c *AgentRechargePaymentConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.db == nil || c.posting == nil {
if c == nil || c.db == nil || c.posting == nil || c.audit == nil {
return errors.New(errors.CodeInternalError, "代理在线充值入账消费者未配置")
}
if envelope.EventType != constants.OutboxEventTypeAgentRechargePaymentConfirmed ||
@@ -45,23 +46,24 @@ func (c *AgentRechargePaymentConsumer) Consume(ctx context.Context, envelope out
return errors.New(errors.CodeInvalidParam, "代理充值支付确认事件载荷不完整")
}
return c.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
recharge, _, err := lockCreditingFacts(ctx, tx, event)
recharge, payment, err := lockCreditingFacts(ctx, tx, event)
if err != nil {
return err
}
if recharge.Status != constants.RechargeStatusPaid && recharge.Status != constants.RechargeStatusCompleted {
return errors.New(errors.CodeInvalidStatus, "代理在线充值当前状态不可入账")
}
if _, err := c.posting.PostInTx(ctx, tx, walletapp.PostingCommand{
posting, err := c.posting.PostInTx(ctx, tx, walletapp.PostingCommand{
ShopID: recharge.ShopID, WalletID: recharge.AgentWalletID, Amount: recharge.Amount,
ReferenceType: constants.ReferenceTypeTopup, ReferenceID: recharge.ID,
TransactionType: constants.AgentTransactionTypeRecharge,
UserID: recharge.UserID, Creator: recharge.UserID, Remark: "代理在线扫码充值",
RequestID: envelope.RequestID, CorrelationID: envelope.CorrelationID,
}); err != nil {
})
if err != nil {
return err
}
if recharge.Status == constants.RechargeStatusCompleted {
if posting.AlreadyApplied && recharge.Status == constants.RechargeStatusCompleted {
return nil
}
completedAt := time.Now().UTC()
@@ -74,7 +76,25 @@ func (c *AgentRechargePaymentConsumer) Consume(ctx context.Context, envelope out
if update.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "代理在线充值状态已变化")
}
return nil
var wallet model.AgentWallet
if err := tx.WithContext(ctx).First(&wallet, recharge.AgentWalletID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值钱包审计快照失败")
}
var transaction model.AgentWalletTransaction
if err := tx.WithContext(ctx).Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?",
constants.ReferenceTypeTopup, recharge.ID, constants.AgentTransactionTypeRecharge, constants.TransactionStatusSuccess).
First(&transaction).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值入账流水审计快照失败")
}
after := *recharge
after.Status = constants.RechargeStatusCompleted
after.CompletedAt = &completedAt
return c.audit.WriteAgentRecharge(ctx, tx, agentrecharge.RechargeAudit{
ActionCode: constants.AuditActionAgentRechargeCredited, Summary: "代理在线充值已入账",
Record: &after, Payment: payment, Wallet: &wallet, Transaction: &transaction,
BeforeData: map[string]any{"status": recharge.Status},
AfterData: map[string]any{"status": after.Status, "completed_at": completedAt},
})
})
}

View File

@@ -32,7 +32,8 @@ func (w *AgentRechargePaymentEventWriter) Append(ctx context.Context, tx *gorm.D
PayloadVersion: constants.AgentRechargePaymentConfirmedPayloadVersionV1,
AggregateType: "agent_recharge", AggregateID: strconv.FormatUint(uint64(event.RechargeID), 10),
ResourceType: "payment", ResourceID: strconv.FormatUint(uint64(event.PaymentID), 10),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID,
ParentEventID: event.ParentEventID, Payload: event,
})
return err
}

View File

@@ -6,6 +6,8 @@ import (
"github.com/hibiken/asynq"
agentrecharge "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -20,10 +22,18 @@ func NewAgentRechargeRecoveryTaskHandler(service *agentrecharge.RecoverOnlinePay
}
// Handle 扫描长期待支付记录并复用原支付单号收敛渠道状态。
func (h *AgentRechargeRecoveryTaskHandler) Handle(ctx context.Context, _ *asynq.Task) error {
func (h *AgentRechargeRecoveryTaskHandler) Handle(ctx context.Context, task *asynq.Task) error {
if h == nil || h.service == nil {
return errors.New(errors.CodeServiceUnavailable, "代理在线充值支付恢复任务未配置")
}
taskType := constants.TaskTypeAgentRechargeRecovery
if task != nil && task.Type() != "" {
taskType = task.Type()
}
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorScheduledJob, ActorID: taskType,
ActorName: "代理在线充值支付恢复计划任务", Source: constants.AuditSourceScheduler,
})
_, err := h.service.ProcessBatch(ctx)
return err
}

View File

@@ -118,7 +118,7 @@ func (a *AlipayWapAdapter) completeUnknown(ctx context.Context, integrationID st
a.logger.Warn("支付宝支付请求结果未知", zap.String("integration_id", integrationID), zap.Error(cause))
}
_, err := a.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", ProviderMessage: "支付宝支付请求结果未知",
Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", SafeProviderMessage: "支付宝支付请求结果未知",
ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(),
RecoveryStrategy: "使用原支付单号主动查单,确认不存在或关闭后才允许关闭本地支付单",
})

View File

@@ -184,7 +184,7 @@ func (a *WechatWebAdapter) completeUnknown(ctx context.Context, integrationID st
a.logger.Warn("微信支付请求结果未知", zap.String("integration_id", integrationID), zap.Error(cause))
}
_, err := a.integration.Complete(ctx, integrationID, integrationlog.Completion{
Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", ProviderMessage: "微信支付请求结果未知",
Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", SafeProviderMessage: "微信支付请求结果未知",
ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(),
RecoveryStrategy: "使用原支付单号主动查单,确认不存在或关闭后才允许关闭本地支付单",
})

View File

@@ -14,19 +14,25 @@ import (
"gorm.io/gorm"
)
// CreditEventWriter 将代理主钱包正向入账事实写入公共 Outbox
// BalanceAdjustmentAuditWriter 在人工调整事务内追加统一 Audit Event
type BalanceAdjustmentAuditWriter interface {
WriteAgentWalletBalanceAdjustment(context.Context, *gorm.DB, walletapp.CreditedEvent) error
}
// CreditEventWriter 将代理主钱包正向入账事实写入公共 Outbox并审计人工调整。
type CreditEventWriter struct {
outbox *outbox.Repository
audit BalanceAdjustmentAuditWriter
}
// NewCreditEventWriter 创建代理主钱包入账 Outbox Writer。
func NewCreditEventWriter(repository *outbox.Repository) *CreditEventWriter {
return &CreditEventWriter{outbox: repository}
func NewCreditEventWriter(repository *outbox.Repository, auditWriter BalanceAdjustmentAuditWriter) *CreditEventWriter {
return &CreditEventWriter{outbox: repository, audit: auditWriter}
}
// Append 在调用方业务事务中追加代理主钱包入账事件。
// Append 在调用方业务事务中追加代理主钱包入账事件及必要审计
func (w *CreditEventWriter) Append(ctx context.Context, tx *gorm.DB, event walletapp.CreditedEvent) error {
if w == nil || w.outbox == nil {
if w == nil || w.outbox == nil || w.audit == nil {
return errors.New(errors.CodeInternalError, "代理主钱包入账 Outbox Writer 未配置")
}
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
@@ -36,9 +42,15 @@ func (w *CreditEventWriter) Append(ctx context.Context, tx *gorm.DB, event walle
ResourceType: event.ReferenceType, ResourceID: strconv.FormatUint(uint64(event.ReferenceID), 10),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
})
if err != nil || event.ReferenceType != constants.ReferenceTypeTopup || event.TransactionType != constants.AgentTransactionTypeRecharge {
if err != nil {
return err
}
if event.ReferenceType == constants.ReferenceTypeManualAdjustment && event.TransactionType == constants.AgentTransactionTypeAdjustment {
return w.audit.WriteAgentWalletBalanceAdjustment(ctx, tx, event)
}
if event.ReferenceType != constants.ReferenceTypeTopup || event.TransactionType != constants.AgentTransactionTypeRecharge {
return nil
}
rechargeID := strconv.FormatUint(uint64(event.ReferenceID), 10)
var shop model.Shop
if err := tx.WithContext(ctx).Select("shop_name").Where("id = ?", event.ShopID).Take(&shop).Error; err != nil {

View File

@@ -6,26 +6,28 @@ import (
"strconv"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
)
// DebitEventWriter 将代理主钱包扣款事实写入公共 Outbox。
// DebitEventWriter 将代理主钱包扣款事实写入公共 Outbox 和统一审计
type DebitEventWriter struct {
outbox *outbox.Repository
audit *audit.Writer
}
// NewDebitEventWriter 创建代理主钱包扣款 Outbox Writer。
func NewDebitEventWriter(repository *outbox.Repository) *DebitEventWriter {
return &DebitEventWriter{outbox: repository}
// NewDebitEventWriter 创建代理主钱包扣款事件 Writer。
func NewDebitEventWriter(repository *outbox.Repository, auditWriter *audit.Writer) *DebitEventWriter {
return &DebitEventWriter{outbox: repository, audit: auditWriter}
}
// Append 在调用方业务事务中追加代理主钱包扣款事件。
// Append 在调用方业务事务中追加代理主钱包扣款 Outbox 与审计事件。
func (w *DebitEventWriter) Append(ctx context.Context, tx *gorm.DB, event walletapp.DebitedEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "代理主钱包扣款 Outbox Writer 未配置")
if w == nil || w.outbox == nil || w.audit == nil {
return errors.New(errors.CodeInternalError, "代理主钱包扣款事件 Writer 未配置")
}
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeAgentMainWalletDebited,
@@ -34,5 +36,8 @@ func (w *DebitEventWriter) Append(ctx context.Context, tx *gorm.DB, event wallet
ResourceType: event.ReferenceType, ResourceID: strconv.FormatUint(uint64(event.ReferenceID), 10),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
})
return err
if err != nil {
return err
}
return w.audit.WriteAgentWalletDebit(ctx, tx, event)
}

View File

@@ -5,26 +5,28 @@ import (
"strconv"
walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"gorm.io/gorm"
)
// ReservationEventWriter 将代理主钱包预占状态写入公共 Outbox。
// ReservationEventWriter 将代理主钱包预占状态写入公共 Outbox 和统一审计
type ReservationEventWriter struct {
outbox *outbox.Repository
audit *audit.Writer
}
// NewReservationEventWriter 创建代理主钱包预占 Outbox Writer。
func NewReservationEventWriter(repository *outbox.Repository) *ReservationEventWriter {
return &ReservationEventWriter{outbox: repository}
// NewReservationEventWriter 创建代理主钱包预占事件 Writer。
func NewReservationEventWriter(repository *outbox.Repository, auditWriter *audit.Writer) *ReservationEventWriter {
return &ReservationEventWriter{outbox: repository, audit: auditWriter}
}
// Append 在调用方事务中追加预占状态事件。
// Append 在调用方事务中追加预占状态 Outbox 与审计事件。
func (w *ReservationEventWriter) Append(ctx context.Context, tx *gorm.DB, event walletapp.ReservationEvent) error {
if w == nil || w.outbox == nil {
return errors.New(errors.CodeInternalError, "代理主钱包预占 Outbox Writer 未配置")
if w == nil || w.outbox == nil || w.audit == nil {
return errors.New(errors.CodeInternalError, "代理主钱包预占事件 Writer 未配置")
}
_, err := w.outbox.Append(ctx, tx, outbox.Envelope{
EventID: event.EventID, EventType: constants.OutboxEventTypeAgentMainWalletReservationChanged,
@@ -33,5 +35,8 @@ func (w *ReservationEventWriter) Append(ctx context.Context, tx *gorm.DB, event
ResourceType: event.ReferenceType, ResourceID: strconv.FormatUint(uint64(event.ReferenceID), 10),
BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event,
})
return err
if err != nil {
return err
}
return w.audit.WriteAgentWalletReservation(ctx, tx, event)
}

View File

@@ -92,10 +92,13 @@ func (u *ApprovalAttachmentUploader) Upload(ctx context.Context, applicationID,
return "", err
}
resourceID := strconv.FormatUint(uint64(instanceID), 10)
integrationID, triggerSeries, correlationID := singleIntegrationLinkage(nil)
attempt, err := u.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
IntegrationID: integrationID,
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComAttachmentUpload, ResourceType: constants.WeComApprovalInstanceResourceType,
ResourceID: &resourceID, RequestSummary: map[string]any{
ResourceID: &resourceID, TriggerSeries: triggerSeries, CorrelationID: correlationID,
RequestSummary: map[string]any{
"application_id": applicationID, "file_name": fileName, "file_bytes": info.Size(),
},
})

View File

@@ -11,6 +11,7 @@ import (
"gorm.io/gorm"
"gorm.io/gorm/clause"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
@@ -35,13 +36,18 @@ type ApprovalRecoveryRecord struct {
// ApprovalContextRepository 管理企微提交的领取、终态和结果未知状态。
type ApprovalContextRepository struct {
db *gorm.DB
now func() time.Time
db *gorm.DB
audit approvalapp.AuditWriter
now func() time.Time
}
// NewApprovalContextRepository 创建企微审批渠道上下文 Repository。
func NewApprovalContextRepository(db *gorm.DB) *ApprovalContextRepository {
return &ApprovalContextRepository{db: db, now: time.Now}
func NewApprovalContextRepository(db *gorm.DB, audits ...approvalapp.AuditWriter) *ApprovalContextRepository {
var audit approvalapp.AuditWriter
if len(audits) > 0 {
audit = audits[0]
}
return &ApprovalContextRepository{db: db, audit: audit, now: time.Now}
}
// ClaimSubmission 将待提交上下文原子置为请求处理中,阻止并发或重投重复提单。
@@ -68,7 +74,7 @@ func (r *ApprovalContextRepository) ClaimSubmission(ctx context.Context, instanc
// PromoteStaleSendingToUnknown 将超出租约的提交中记录保守转为结果未知,禁止直接重新提交。
func (r *ApprovalContextRepository) PromoteStaleSendingToUnknown(ctx context.Context, cutoff time.Time) error {
if r == nil || r.db == nil || cutoff.IsZero() {
if r == nil || r.db == nil || r.audit == nil || cutoff.IsZero() {
return errors.New(errors.CodeInvalidParam, "企业微信审批恢复参数无效")
}
now := r.now().UTC()
@@ -97,13 +103,38 @@ func (r *ApprovalContextRepository) PromoteStaleSendingToUnknown(ctx context.Con
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记企业微信审批提交结果未知失败")
}
if err := tx.Model(&model.ApprovalInstance{}).
instanceResult := tx.Model(&model.ApprovalInstance{}).
Where("id IN ? AND status = ?", instanceIDs, constants.ApprovalStatusSubmitting).
Updates(map[string]any{
"status": constants.ApprovalStatusSubmissionUnknown, "status_changed_at": now,
"version": gorm.Expr("version + 1"), "updated_at": now,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "同步通用审批提交结果未知状态失败")
})
if instanceResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, instanceResult.Error, "同步通用审批提交结果未知状态失败")
}
if instanceResult.RowsAffected != result.RowsAffected {
return errors.New(errors.CodeConflict, "通用审批提交结果未知状态已变化")
}
for _, channelContext := range stale {
instance, err := loadApprovalAuditInstance(ctx, tx, channelContext.ApprovalInstanceID)
if err != nil {
return err
}
beforeStatus := constants.ApprovalStatusSubmitting
afterStatus := constants.ApprovalStatusSubmissionUnknown
if err := r.audit.WriteApproval(ctx, tx, approvalapp.AuditChange{
EventID: approvalSubmissionAuditEventID(instance.ID, afterStatus),
ActionCode: constants.AuditActionApprovalSubmissionSynced, Summary: "审批提交处理中断,结果转为未知",
InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot,
Provider: instance.Provider, BeforeExternalRef: instance.ExternalRef, AfterExternalRef: instance.ExternalRef,
CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &afterStatus,
ActorKind: constants.AuditActorScheduledJob, ActorID: constants.ApprovalAuditActorRecoveryJob,
Source: constants.AuditSourceScheduler, Result: constants.AuditResultUnknown,
ErrorSummary: "企业微信审批提交处理中断,已进入结果未知恢复",
}); err != nil {
return err
}
}
return nil
})
@@ -160,7 +191,7 @@ func (r *ApprovalContextRepository) ListPendingSync(ctx context.Context, cutoff
}
// FindSuccessfulSubmissionSPNo 从已成功的安全 Integration Log 摘要恢复本地未保存的审批单号。
func (r *ApprovalContextRepository) FindSuccessfulSubmissionSPNo(ctx context.Context, instanceID uint) (string, error) {
func (r *ApprovalContextRepository) FindSuccessfulSubmissionSPNo(ctx context.Context, instanceID uint) (string, string, error) {
resourceID := strconv.FormatUint(uint64(instanceID), 10)
var log model.IntegrationLog
err := r.db.WithContext(ctx).
@@ -169,18 +200,18 @@ func (r *ApprovalContextRepository) FindSuccessfulSubmissionSPNo(ctx context.Con
constants.IntegrationDirectionOutbound, resourceID, constants.IntegrationResultSuccess).
Order("id DESC").First(&log).Error
if err == gorm.ErrRecordNotFound {
return "", nil
return "", "", nil
}
if err != nil {
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批提交日志失败")
return "", "", errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批提交日志失败")
}
var summary struct {
SPNo string `json:"sp_no"`
}
if sonic.Unmarshal(log.ResponseSummary, &summary) != nil {
return "", nil
return "", "", nil
}
return strings.TrimSpace(summary.SPNo), nil
return strings.TrimSpace(summary.SPNo), log.IntegrationID, nil
}
// ExistingSPNos 批量过滤已经关联到本地审批实例的企微审批单号。
@@ -231,13 +262,24 @@ func (r *ApprovalContextRepository) FindUniqueUnknownByFingerprint(
}
// RecoverSubmitted 将唯一确认的企微审批单号原子关联回结果未知实例。
func (r *ApprovalContextRepository) RecoverSubmitted(ctx context.Context, instanceID uint, spNo string) (bool, error) {
func (r *ApprovalContextRepository) RecoverSubmitted(
ctx context.Context,
instanceID uint,
spNo string,
integrationIDs []string,
actorKind string,
actorID string,
source string,
) (bool, error) {
spNo = strings.TrimSpace(spNo)
if instanceID == 0 || spNo == "" {
return false, errors.New(errors.CodeInvalidParam, "企业微信审批恢复关联参数无效")
}
now := r.now().UTC()
recovered := false
if r.audit == nil {
return false, errors.New(errors.CodeServiceUnavailable, "通用审批审计 Writer 未配置")
}
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
contextResult := tx.Model(&model.WeComApprovalContext{}).
Where("approval_instance_id = ? AND submission_status = ? AND sp_no = ''", instanceID, constants.WeComSubmissionStatusUnknown).
@@ -263,6 +305,24 @@ func (r *ApprovalContextRepository) RecoverSubmitted(ctx context.Context, instan
if instanceResult.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "通用审批恢复状态已变化")
}
instance, err := loadApprovalAuditInstance(ctx, tx, instanceID)
if err != nil {
return err
}
beforeStatus := constants.ApprovalStatusSubmissionUnknown
afterStatus := constants.ApprovalStatusPending
if err := r.audit.WriteApproval(ctx, tx, approvalapp.AuditChange{
EventID: "approval:" + strconv.FormatUint(uint64(instanceID), 10) + ":audit:submission_recovered",
ActionCode: constants.AuditActionApprovalSubmissionRecovered, Summary: "恢复结果未知的审批提交",
InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot,
Provider: instance.Provider, BeforeExternalRef: "", AfterExternalRef: spNo,
CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &afterStatus,
ActorKind: actorKind, ActorID: actorID, Source: source, Result: constants.AuditResultSuccess,
IntegrationIDs: integrationIDs,
}); err != nil {
return err
}
recovered = true
return nil
})
@@ -335,7 +395,10 @@ func (r *ApprovalContextRepository) ReleaseForRetry(ctx context.Context, instanc
}
// MarkSubmitted 原子保存企微 sp_no并把通用审批实例置为审批中。
func (r *ApprovalContextRepository) MarkSubmitted(ctx context.Context, instanceID uint, spNo string) error {
func (r *ApprovalContextRepository) MarkSubmitted(ctx context.Context, instanceID uint, spNo string, integrationID string) error {
if r.audit == nil {
return errors.New(errors.CodeServiceUnavailable, "通用审批审计 Writer 未配置")
}
now := r.now().UTC()
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
contextResult := tx.Model(&model.WeComApprovalContext{}).
@@ -359,37 +422,90 @@ func (r *ApprovalContextRepository) MarkSubmitted(ctx context.Context, instanceI
if instanceResult.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "通用审批提交状态已变化")
}
return nil
instance, err := loadApprovalAuditInstance(ctx, tx, instanceID)
if err != nil {
return err
}
beforeStatus := constants.ApprovalStatusSubmitting
afterStatus := constants.ApprovalStatusPending
return r.audit.WriteApproval(ctx, tx, approvalapp.AuditChange{
EventID: approvalSubmissionAuditEventID(instanceID, afterStatus),
ActionCode: constants.AuditActionApprovalSubmissionSynced, Summary: "企业微信审批提交成功",
InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot,
Provider: instance.Provider, BeforeExternalRef: "", AfterExternalRef: spNo,
CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &afterStatus,
ActorKind: constants.AuditActorSystemTask, ActorID: constants.ApprovalAuditActorSubmissionWorker,
Source: constants.AuditSourceWorker, Result: constants.AuditResultSuccess, IntegrationIDs: []string{integrationID},
})
})
}
// MarkFailed 将企微明确拒绝的提交记录为提交失败。
func (r *ApprovalContextRepository) MarkFailed(ctx context.Context, instanceID uint, message string) error {
return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusFailed, constants.ApprovalStatusSubmissionFailed, message)
func (r *ApprovalContextRepository) MarkFailed(ctx context.Context, instanceID uint, message string, integrationID string) error {
return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusFailed, constants.ApprovalStatusSubmissionFailed, message, constants.AuditResultFailed, integrationID)
}
// MarkUnknown 将请求已发出但无法确认结果的提交记录为结果未知。
func (r *ApprovalContextRepository) MarkUnknown(ctx context.Context, instanceID uint, message string) error {
return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusUnknown, constants.ApprovalStatusSubmissionUnknown, message)
func (r *ApprovalContextRepository) MarkUnknown(ctx context.Context, instanceID uint, message string, integrationID string) error {
return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusUnknown, constants.ApprovalStatusSubmissionUnknown, message, constants.AuditResultUnknown, integrationID)
}
// markSubmissionState 在同一事务中同步企微渠道状态与通用审批状态,避免两侧事实分裂。
func (r *ApprovalContextRepository) markSubmissionState(ctx context.Context, instanceID uint, channelStatus, approvalStatus int, message string) error {
func (r *ApprovalContextRepository) markSubmissionState(ctx context.Context, instanceID uint, channelStatus, approvalStatus int, message, auditResult, integrationID string) error {
if r.audit == nil {
return errors.New(errors.CodeServiceUnavailable, "通用审批审计 Writer 未配置")
}
now := r.now().UTC()
return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.WeComApprovalContext{}).
contextResult := tx.Model(&model.WeComApprovalContext{}).
Where("approval_instance_id = ? AND submission_status = ?", instanceID, constants.WeComSubmissionStatusSending).
Updates(map[string]any{"submission_status": channelStatus, "last_error": message, "updated_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信审批提交结果失败")
Updates(map[string]any{"submission_status": channelStatus, "last_error": message, "updated_at": now})
if contextResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, contextResult.Error, "更新企业微信审批提交结果失败")
}
if err := tx.Model(&model.ApprovalInstance{}).
if contextResult.RowsAffected == 0 {
return nil
}
instanceResult := tx.Model(&model.ApprovalInstance{}).
Where("id = ? AND status = ?", instanceID, constants.ApprovalStatusSubmitting).
Updates(map[string]any{
"status": approvalStatus, "status_changed_at": now,
"version": gorm.Expr("version + 1"), "updated_at": now,
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新通用审批提交结果失败")
})
if instanceResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, instanceResult.Error, "更新通用审批提交结果失败")
}
return nil
if instanceResult.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "通用审批提交结果状态已变化")
}
instance, err := loadApprovalAuditInstance(ctx, tx, instanceID)
if err != nil {
return err
}
beforeStatus := constants.ApprovalStatusSubmitting
return r.audit.WriteApproval(ctx, tx, approvalapp.AuditChange{
EventID: approvalSubmissionAuditEventID(instanceID, approvalStatus),
ActionCode: constants.AuditActionApprovalSubmissionSynced, Summary: "同步企业微信审批提交结果",
InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID,
SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot,
Provider: instance.Provider, BeforeExternalRef: "", AfterExternalRef: instance.ExternalRef,
CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &approvalStatus,
ActorKind: constants.AuditActorSystemTask, ActorID: constants.ApprovalAuditActorSubmissionWorker,
Source: constants.AuditSourceWorker, Result: auditResult, ErrorSummary: message,
IntegrationIDs: []string{integrationID},
})
})
}
func loadApprovalAuditInstance(ctx context.Context, tx *gorm.DB, instanceID uint) (*model.ApprovalInstance, error) {
var instance model.ApprovalInstance
if err := tx.WithContext(ctx).First(&instance, instanceID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通用审批审计快照失败")
}
return &instance, nil
}
func approvalSubmissionAuditEventID(instanceID uint, status int) string {
return "approval:" + strconv.FormatUint(uint64(instanceID), 10) + ":audit:submission:" + strconv.Itoa(status)
}

View File

@@ -19,9 +19,10 @@ import (
// ApprovalDetail 是企微审批详情的权威状态和安全原始 JSON 快照。
type ApprovalDetail struct {
SPNo string
SPStatus int
Snapshot []byte
SPNo string
SPStatus int
Snapshot []byte
IntegrationID string
}
// ApprovalDetailClient 获取企业微信审批申请详情。
@@ -42,7 +43,7 @@ func NewApprovalDetailClient(tokens DirectoryTokenProvider, integration TokenInt
}
// Get 获取审批详情并记录一次真实外呼。
func (c *ApprovalDetailClient) Get(ctx context.Context, applicationID uint, spNo string) (ApprovalDetail, error) {
func (c *ApprovalDetailClient) Get(ctx context.Context, applicationID uint, spNo string, resourceID *string, correlationID string) (ApprovalDetail, error) {
token, err := c.tokens.GetAccessToken(ctx, applicationID)
if err != nil {
return ApprovalDetail{}, err
@@ -51,11 +52,14 @@ func (c *ApprovalDetailClient) Get(ctx context.Context, applicationID uint, spNo
if err != nil {
return ApprovalDetail{}, err
}
resourceID := strconv.FormatUint(uint64(applicationID), 10)
resourceKey := strings.TrimSpace(spNo)
triggerSeries := "wecom-approval-detail:" + resourceKey
attempt, err := c.integration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderWeCom, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationWeComApprovalDetail, ResourceType: constants.WeComApprovalInstanceResourceType,
ResourceID: &resourceID, ExternalID: &spNo, RequestSummary: map[string]any{"application_id": applicationID, "sp_no": spNo},
ResourceID: resourceID, ResourceKey: &resourceKey, ExternalID: &spNo,
TriggerSeries: &triggerSeries, CorrelationID: optionalIntegrationString(correlationID),
RequestSummary: map[string]any{"application_id": applicationID, "sp_no": spNo},
})
if err != nil {
return ApprovalDetail{}, err
@@ -90,7 +94,7 @@ func (c *ApprovalDetailClient) Get(ctx context.Context, applicationID uint, spNo
ProviderCode: strconv.FormatInt(errCode, 10), ProviderMessage: errMsg,
ResponseSummary: map[string]any{"sp_no": spNo, "sp_status": status}, DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
return ApprovalDetail{SPNo: spNo, SPStatus: status, Snapshot: snapshot}, err
return ApprovalDetail{SPNo: spNo, SPStatus: status, Snapshot: snapshot, IntegrationID: attempt.IntegrationID}, err
}
// newRequest 组装按字符串审批单号查询权威详情的企微请求。

View File

@@ -2,6 +2,7 @@ package wecom
import (
"context"
"strconv"
"strings"
"time"
@@ -39,11 +40,18 @@ func (h *ApprovalDetailTaskHandler) Handle(ctx context.Context, task *asynq.Task
if !validApprovalSyncSource(payload.Source) {
return errors.New(errors.CodeInvalidParam, "企业微信审批详情同步来源无效")
}
detail, err := h.details.Get(ctx, payload.ApplicationID, payload.SPNo)
record, err := h.contexts.FindBySPNo(ctx, payload.ApplicationID, payload.SPNo)
if err != nil {
return err
}
record, err := h.contexts.FindBySPNo(ctx, payload.ApplicationID, payload.SPNo)
correlationID := payload.SPNo
var resourceID *string
if record != nil {
value := strconv.FormatUint(uint64(record.Instance.ID), 10)
resourceID = &value
correlationID = record.Instance.CorrelationID
}
detail, err := h.details.Get(ctx, payload.ApplicationID, payload.SPNo, resourceID, correlationID)
if err != nil {
return err
}
@@ -63,16 +71,20 @@ func (h *ApprovalDetailTaskHandler) Handle(ctx context.Context, task *asynq.Task
for _, decision := range decisions {
if _, err := h.decisions.Execute(ctx, approvalapp.SyncDecisionCommand{
InstanceID: record.Instance.ID, Decision: decision, DecisionSnapshot: detail.Snapshot, Source: payload.Source,
IntegrationIDs: []string{payload.IntegrationID, detail.IntegrationID},
}); err != nil {
return err
}
}
if strings.TrimSpace(payload.IntegrationID) != "" {
resolvedResourceID := strconv.FormatUint(uint64(record.Instance.ID), 10)
resolvedResourceKey := strings.TrimSpace(payload.SPNo)
_, err = h.integration.Complete(ctx, payload.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultCompleted, ProviderCode: "processed",
ProviderMessage: "企业微信审批回调已完成权威详情同步",
ResponseSummary: map[string]any{"sp_no": payload.SPNo, "sp_status": detail.SPStatus, "decisions": decisions},
DurationMS: 0, StateChanged: len(decisions) > 0,
ResourceID: &resolvedResourceID, ResourceKey: &resolvedResourceKey,
})
}
return err
@@ -89,7 +101,10 @@ func (h *ApprovalDetailTaskHandler) recoverUnknownCallback(ctx context.Context,
if err != nil || candidate == nil {
return nil, err
}
recovered, err := h.contexts.RecoverSubmitted(ctx, candidate.InstanceID, payload.SPNo)
recovered, err := h.contexts.RecoverSubmitted(
ctx, candidate.InstanceID, payload.SPNo,
[]string{payload.IntegrationID, detail.IntegrationID}, constants.AuditActorExternalSystem, constants.ApprovalAuditActorWeCom, constants.AuditSourceCallback,
)
if err != nil || !recovered {
return nil, err
}

Some files were not shown because too many files have changed in this diff Show More