收口审计治理与套餐任务进展

Constraint: 在线热修前必须保存当前迭代分支全部有效代码进展
Confidence: medium
Scope-risk: broad
Directive: 后续修改需保持审计事件与业务事务边界一致
Tested: git diff --cached --check
Not-tested: 未运行全量测试,提交用于切换分支前保存既有工作
This commit is contained in:
2026-08-05 14:30:54 +08:00
parent b3499adfca
commit 5e552d99bc
178 changed files with 16797 additions and 5674 deletions

View File

@@ -38,7 +38,7 @@
- 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 代替。
- 旧 Writer 当前 15 个显式调用点仍覆盖线下充值、套餐配置、支付配置及部分资产、卡、设备、导入和轮询Writer 自身使用裸 goroutine,充值和支付配置还存在外层 goroutine形成双层异步并丢失事务/request/correlation后续切片必须逐调用归零
- 支付配置的 5 个旧账号日志外层 goroutine 已归零;旧 Writer 仍有少量未迁移资产、卡、设备、导入和轮询调用,旧 Writer 自身 goroutine 留待最终 contract 任务统一移除
### 已落地代表切片
@@ -72,11 +72,12 @@
| 明确后台账号通知事件消费与幂等写入 | 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 外部流程负责 |
| 代理主钱包订单统一扣款 | 使用 `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 |
| 代理订单主钱包退款回充 | 延期(测试环境冻结;生产前由 6.5 为退款资金变更补齐同事务 Audit Event | 原成功扣款流水定位付款钱包并限定金额;退款审批、`tb_agent_wallet` 与唯一成功退款流水同事务形成权威事实 | N/A本资金接缝不调用渠道或审批外部系统 | 同事务写入 `wallet.agent_main.refunded`,消费者复核退款流水、原扣款事实、金额上限和资产快照 |
| 代理订单主钱包退款回充 | `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重复成功不重复写事件 |
@@ -90,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`;明文列表读取仅允许超级管理员,返回前以独立短事务同步写 `wecom.application.credentials_read`审计缺失或失败均不返回结果Application 先投影无凭据结构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 缓存,不产生必须可靠投递的业务副作用) |
| 企业微信可见成员同步与账号显式绑定 | 成员同步是外部只读快照更新,不记录独立人工审计;账号绑定沿用现有账号操作日志,记录操作者、目标账号及绑定前后 `(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` 任务覆盖映射
@@ -141,21 +160,42 @@
- 企业创建、基础资料、状态和账号改密已切换统一 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;客户资产绑定仍留给 5.11,登录链路不会伪造该任务已迁移
- 个人客户资料、手机号与微信主体已切换统一 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 或手动清理动作,继续保留在覆盖清单等待对应显式切片。
### 与审计接入分开保留的独立修复
- 企业、企业卡和企业设备的权限校验、空筛选防全量、批量边界、越权同错及企业设备 TOCTOU/真实命中计数作为独立安全与并发修复保留,不视为审计所需的业务重构。
- 手机号绑定/换绑的行锁和事务内二次复检、账号状态与代理越权校验、管理员账号改密后撤销 Token 作为独立修复保留;企业账号改密不扩展同样的 Token 行为。
- 角色/权限变化和店铺删除后的权限缓存清理能力作为独立修复保留,但只在数据库提交后 best-effort 执行,不让 Redis 失败反向回滚业务事实。
- 已确认裸 goroutine 调用:`internal/service/agent_recharge/service.go` 1 处,`internal/service/wechat_config/service.go` 5 处;Writer 自身另启 goroutine。
- 已确认支付配置和员工线下充值的外层裸 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 号票验证生产装配和直接旧表写入归零。
@@ -163,7 +203,7 @@
- 状态与写入:`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最终旧写护栏必须将其列入显式白名单
## 评审门禁

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)

View File

@@ -22,6 +22,7 @@ import (
"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"
@@ -319,6 +320,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 +331,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 +344,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 +367,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 +387,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 +402,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 +425,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 +481,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 +515,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 +642,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(

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

@@ -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

@@ -40,6 +40,8 @@ type ChangeAudit struct {
PersonalCustomer *model.PersonalCustomer
PersonalPhones []PersonalCustomerPhoneChange
PersonalOpenIDs []PersonalCustomerOpenIDChange
PersonalDevices []PersonalCustomerDeviceChange
PersonalICCIDs []PersonalCustomerICCIDChange
Role *model.Role
Roles []RoleChange
Permissions []PermissionChange
@@ -64,6 +66,24 @@ type PersonalCustomerOpenIDChange struct {
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

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,
})
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

@@ -64,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)
@@ -133,7 +134,18 @@ func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command Confi
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, "关闭失效代理充值单失败")
}
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

@@ -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,6 +72,22 @@ 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 {
@@ -111,6 +145,58 @@ 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
}
}
return nil
})
if err != nil {
@@ -122,6 +208,24 @@ 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 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"
@@ -74,9 +75,7 @@ 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,
@@ -86,6 +85,39 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai
}); 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
}
}
return nil
})
if err != nil {

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"
)
@@ -65,16 +66,41 @@ 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"
return s.eventWriter.AppendTraffic(ctx, tx, TrafficIncrementedEvent{
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
}
}
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
}
}
return nil
})
if err != nil {
return domain.TrafficDecision{}, err

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"
@@ -57,15 +59,20 @@ type DeliveryService struct {
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"
@@ -15,28 +20,23 @@ import (
// ReadService 执行后台账号与个人客户的幂等已读事务脚本。
type ReadService struct {
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)
}
count, err := s.markAllRead(ctx, constants.NotificationRecipientKindPersonalCustomer, customerID, "",
constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI)
if err != nil {
return nil, err
}
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()
result := personalReadScope(s.db.WithContext(ctx).Model(&model.Notification{}), customerID, now).
Where("is_read = ?", false).
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 nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新个人客户通知已读状态失败")
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新通知已读状态失败")
}
return &dto.NotificationReadAllResponse{UpdatedCount: result.RowsAffected}, nil
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

@@ -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

@@ -25,6 +25,9 @@ type ChangeAudit struct {
Description string
ConfigKey string
Module string
ResourceID *string
DisplayName string
Identity map[string]any
BeforeData map[string]any
AfterData map[string]any
RequestID string

View File

@@ -3,33 +3,55 @@ 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
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) (*dto.ShopCreditLimitResponse, error) {
var result *dto.ShopCreditLimitResponse
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var stored model.AgentWallet
var beforeData map[string]any
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
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
@@ -51,8 +73,62 @@ func (s *ChangeCreditService) Execute(ctx context.Context, shopID uint, enabled
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: stored.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"
@@ -90,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 {
@@ -140,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
}
@@ -152,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
@@ -240,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 {
@@ -256,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()
@@ -273,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
@@ -295,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
}
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: "wecom_approval_scene_save", Description: "保存企业微信审批模板映射",
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

@@ -74,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,
@@ -109,6 +110,7 @@ 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 {
@@ -137,14 +139,15 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
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,
@@ -172,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),
@@ -187,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, 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),
@@ -212,7 +217,8 @@ 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),
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),

View File

@@ -5,7 +5,6 @@ import (
"go.uber.org/zap"
accessauditApp "github.com/break/junhong_cmp_fiber/internal/application/accessaudit"
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
approvalApp "github.com/break/junhong_cmp_fiber/internal/application/approval"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
@@ -78,7 +77,7 @@ import (
)
type services struct {
AccessAudit accessauditApp.Writer
AccessAudit *auditInfra.Writer
Approval *approvalApp.CreationService
Account *accountSvc.Service
AccountAudit *accountAuditSvc.Service
@@ -151,6 +150,7 @@ func initServices(s *stores, deps *Dependencies) *services {
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)
@@ -171,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(
@@ -178,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)
@@ -205,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)
@@ -218,6 +220,7 @@ func initServices(s *stores, deps *Dependencies) *services {
s.PackageUsageDailyRecord,
deps.Logger,
)
packageActivation.SetLifecycleAudit(auditWriter)
packageActivation.SetObservationSeriesEventWriter(observationSeriesEvents)
stopResumeService := iotCardSvc.NewStopResumeService(
@@ -231,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)
@@ -254,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,
@@ -276,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,
@@ -305,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)
@@ -333,11 +349,13 @@ 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)
@@ -345,6 +363,35 @@ func initServices(s *stores, deps *Dependencies) *services {
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,
@@ -373,55 +420,38 @@ func initServices(s *stores, deps *Dependencies) *services {
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,
),
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),
@@ -436,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

@@ -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,
@@ -131,9 +137,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 +156,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 +169,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

@@ -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,9 +410,18 @@ func (h *ClientWalletHandler) createWechatRecharge(
Status: model.PaymentRecordStatusPending,
PaymentConfigID: &config.ID,
}
if err := h.paymentStore.Create(resolved.SkipPermissionCtx, payment); err != nil {
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{
Recharge: dto.ClientRechargeResult{
@@ -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)

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 {

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 {

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 {

View File

@@ -123,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 会记录日志)
@@ -145,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{
@@ -170,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, "查询支付记录失败")
@@ -198,31 +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 接缝未配置")
}
resourceKey, correlationID := callback.PaymentNo, callback.PaymentNo
ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: correlationID})
linkage := auditcontext.From(ctx)
idempotencyKey := callback.TransactionID
if idempotencyKey == "" {
idempotencyKey = callback.PaymentNo
@@ -230,13 +238,51 @@ 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, ResourceKey: &resourceKey,
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,
@@ -269,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
@@ -346,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")
}
@@ -409,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("支付宝回调:付款时间格式无效",
@@ -468,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, "处理支付宝代理充值回调失败")
callback.PaidAt = paidAt
}
default:
h.logger.Error("支付宝回调:未知支付记录类型",
zap.String("out_trade_no", outTradeNo),
zap.String("order_type", payment.OrderType),
)
return errors.New(errors.CodeInternalError, "未知支付记录类型")
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")
@@ -563,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("富友回调:验签或解析失败",
@@ -581,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()))
@@ -591,28 +657,12 @@ 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 {
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()))
}
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"))
}
return c.Send(fuiou.BuildNotifySuccessResponse())
}

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,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,
}
}

View File

@@ -17,6 +17,13 @@ type ActionDefinition struct {
AllowedVisibility []string
SubjectFields []string
SensitiveRead bool
AllowedOrigins []ActionOrigin
}
// ActionOrigin 定义动作允许的操作者与入口组合。
type ActionOrigin struct {
Actor string
Source string
}
// ResourceDefinition 是受控审计资源的快照契约。
@@ -70,6 +77,76 @@ func NewRegistry() *Registry {
personalPhoneBound := personalAction(constants.AuditActionPersonalCustomerPhoneBound, "绑定个人手机号", []string{"phone"})
personalPhoneChanged := personalAction(constants.AuditActionPersonalCustomerPhoneChanged, "更换个人手机号", []string{"phone"})
personalWechatIdentityUpdated := personalAction(constants.AuditActionPersonalCustomerWechatIdentityUpdated, "同步个人微信主体", []string{"app_id", "app_type"})
personalAssetBound := personalAction(constants.AuditActionPersonalCustomerAssetBound, "绑定个人客户资产", []string{"asset_type", "asset_id"})
personalAssetBound.Category = constants.AuditCategoryAsset
personalAssetUnbound := customerAssetAdminAction(constants.AuditActionPersonalCustomerAssetUnbound, "解除个人客户资产绑定")
personalAssetBindingMigrated := customerAssetAdminAction(constants.AuditActionPersonalCustomerAssetBindingMigrated, "迁移个人客户资产绑定")
iotCardCreated := iotCardAction(constants.AuditActionIotCardCreated, "创建 IoT 卡", constants.AuditActorSystemTask, constants.AuditSourceWorker)
iotCardDeleted := iotCardAction(constants.AuditActionIotCardDeleted, "删除 IoT 卡", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardDeleted.Risk = constants.AuditRiskHigh
iotCardBatchDeleted := ActionDefinition{
Code: constants.AuditActionIotCardBatchDeleted, Name: "批量删除 IoT 卡",
Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskHigh,
PrimaryResource: constants.AuditResourceIotCardBatch, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
iotCardAllocationBatch := iotCardBatchAction(constants.AuditActionIotCardAllocationBatch, "批量分配 IoT 卡")
iotCardAllocated := iotCardAction(constants.AuditActionIotCardAllocated, "分配 IoT 卡", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardRecallBatch := iotCardBatchAction(constants.AuditActionIotCardRecallBatch, "批量回收 IoT 卡")
iotCardRecalled := iotCardAction(constants.AuditActionIotCardRecalled, "回收 IoT 卡", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardSeriesBindingBatch := iotCardBatchAction(constants.AuditActionIotCardSeriesBindingBatch, "批量设置 IoT 卡系列绑定")
iotCardSeriesBound := iotCardAction(constants.AuditActionIotCardSeriesBound, "设置 IoT 卡系列绑定", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardSpeedTierSet := iotCardAction(constants.AuditActionIotCardSpeedTierSet, "设置 IoT 卡固定限速档位", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardRealnamePolicyBatchUpdated := iotCardBatchAction(constants.AuditActionIotCardRealnamePolicyBatchUpdated, "批量更新 IoT 卡实名策略")
iotCardRealnamePolicyUpdated := iotCardAction(constants.AuditActionIotCardRealnamePolicyUpdated, "更新 IoT 卡实名策略", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardRealnameStatusUpdated := iotCardAction(constants.AuditActionIotCardRealnameStatusUpdated, "人工更新 IoT 卡实名状态", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardRealnameCallbackSynced := iotCardAction(constants.AuditActionIotCardRealnameCallbackSynced, "运营商回调同步 IoT 卡实名状态", constants.AuditActorExternalSystem, constants.AuditSourceCallback)
iotCardManualRefreshed := iotCardAction(constants.AuditActionIotCardManualRefreshed, "人工刷新 IoT 卡", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardPersonalRefreshed := iotCardAction(constants.AuditActionIotCardPersonalRefreshed, "个人客户刷新 IoT 卡", constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI)
iotCardManualStopped := iotCardAction(constants.AuditActionIotCardManualStopped, "人工停用 IoT 卡网络", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardManualStarted := iotCardAction(constants.AuditActionIotCardManualStarted, "人工恢复 IoT 卡网络", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardAutoStopped := iotCardAction(constants.AuditActionIotCardAutoStopped, "自动停用 IoT 卡网络", constants.AuditActorSystemTask, constants.AuditSourceWorker)
iotCardAutoStarted := iotCardAction(constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络", constants.AuditActorSystemTask, constants.AuditSourceWorker)
iotCardOpenAPIStarted := iotCardAction(constants.AuditActionIotCardOpenAPIStarted, "OpenAPI 恢复 IoT 卡网络", constants.AuditActorOpenAPI, constants.AuditSourceOpenAPI)
iotCardAutoStopReasonUpdated := iotCardAction(constants.AuditActionIotCardAutoStopReasonUpdated, "自动更新 IoT 卡停机原因", constants.AuditActorSystemTask, constants.AuditSourceWorker)
deviceCreated := deviceAction(constants.AuditActionDeviceCreated, "导入创建设备", constants.AuditActorSystemTask, constants.AuditSourceWorker)
deviceDeleted := deviceAction(constants.AuditActionDeviceDeleted, "删除设备", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
deviceDeleted.Risk = constants.AuditRiskHigh
deviceAllocationBatch := deviceMultiOriginBatchAction(constants.AuditActionDeviceAllocationBatch, "批量分配设备")
deviceAllocated := deviceMultiOriginAction(constants.AuditActionDeviceAllocated, "分配设备")
deviceRecallBatch := deviceMultiOriginBatchAction(constants.AuditActionDeviceRecallBatch, "批量回收设备")
deviceRecalled := deviceMultiOriginAction(constants.AuditActionDeviceRecalled, "回收设备")
deviceSeriesBindingBatch := deviceMultiOriginBatchAction(constants.AuditActionDeviceSeriesBindingBatch, "批量设置设备系列绑定")
deviceSeriesBound := deviceMultiOriginAction(constants.AuditActionDeviceSeriesBound, "设置设备系列绑定")
deviceRealnamePolicyBatchUpdated := deviceAccountBatchAction(constants.AuditActionDeviceRealnamePolicyBatchUpdated, "批量更新设备实名策略")
deviceRealnamePolicyUpdated := deviceAction(constants.AuditActionDeviceRealnamePolicyUpdated, "更新设备实名策略", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
deviceStopped := deviceAction(constants.AuditActionDeviceStopped, "停用设备绑定卡网络", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
deviceStarted := deviceAction(constants.AuditActionDeviceStarted, "恢复设备绑定卡网络", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
deviceWiFiSet := deviceExternalAction(constants.AuditActionDeviceWiFiSet, "设置设备 Wi-Fi", false)
deviceSwitchModeSet := deviceAction(constants.AuditActionDeviceSwitchModeSet, "设置设备切卡模式", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
deviceRebooted := deviceExternalAction(constants.AuditActionDeviceRebooted, "重启设备", true)
deviceReset := deviceExternalAction(constants.AuditActionDeviceReset, "恢复设备出厂设置", true)
deviceCardBound := deviceAction(constants.AuditActionDeviceCardBound, "设备绑定 IoT 卡", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
deviceCardUnbound := deviceAction(constants.AuditActionDeviceCardUnbound, "设备解绑 IoT 卡", constants.AuditActorAccount, constants.AuditSourceAdminAPI)
deviceCurrentCardSwitched := deviceExternalAction(constants.AuditActionDeviceCurrentCardSwitched, "切换设备当前卡", true)
cardExchangeCreated := cardExchangeAction(constants.AuditActionCardExchangeCreated, "创建卡换货单", constants.AuditRiskNormal, false)
cardExchangeShippingInfoSubmitted := cardExchangeAction(constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息", constants.AuditRiskHigh, true)
cardExchangeShipped := cardExchangeAction(constants.AuditActionCardExchangeShipped, "卡换货发货", constants.AuditRiskNormal, false)
cardExchangeCompleted := cardExchangeAction(constants.AuditActionCardExchangeCompleted, "完成卡换货", constants.AuditRiskHigh, false)
cardExchangeCancelled := cardExchangeAction(constants.AuditActionCardExchangeCancelled, "取消卡换货", constants.AuditRiskNormal, false)
cardExchangeRenewed := cardExchangeAction(constants.AuditActionCardExchangeRenewed, "换出旧卡转新", constants.AuditRiskHigh, false)
cardExchangeRenewed.DefaultVisibility = constants.AuditSubjectInternalOnly
cardExchangeRenewed.AllowedVisibility = []string{constants.AuditSubjectInternalOnly}
deviceExchangeCreated := cardExchangeAction(constants.AuditActionDeviceExchangeCreated, "创建设备换货单", constants.AuditRiskNormal, false)
deviceExchangeShippingInfoSubmitted := cardExchangeAction(constants.AuditActionDeviceExchangeShippingInfoSubmitted, "提交设备换货收货信息", constants.AuditRiskHigh, true)
deviceExchangeShipped := cardExchangeAction(constants.AuditActionDeviceExchangeShipped, "设备换货发货", constants.AuditRiskNormal, false)
deviceExchangeCompleted := cardExchangeAction(constants.AuditActionDeviceExchangeCompleted, "完成设备换货", constants.AuditRiskHigh, false)
deviceExchangeCancelled := cardExchangeAction(constants.AuditActionDeviceExchangeCancelled, "取消设备换货", constants.AuditRiskNormal, false)
deviceExchangeRenewed := cardExchangeAction(constants.AuditActionDeviceExchangeRenewed, "换出旧设备转新", constants.AuditRiskHigh, false)
deviceExchangeRenewed.DefaultVisibility = constants.AuditSubjectInternalOnly
deviceExchangeRenewed.AllowedVisibility = []string{constants.AuditSubjectInternalOnly}
systemConfigUpdated := ActionDefinition{
Code: constants.AuditActionSystemConfigUpdated, Name: "更新受控系统配置",
Category: constants.AuditCategoryConfiguration, Risk: constants.AuditRiskHigh,
@@ -78,6 +155,19 @@ func NewRegistry() *Registry {
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
paymentConfigCreated := connectionConfigAction(constants.AuditActionPaymentConfigCreated, "创建支付连接配置", constants.AuditResourcePaymentConfig, constants.AuditRiskHigh)
paymentConfigUpdated := connectionConfigAction(constants.AuditActionPaymentConfigUpdated, "更新支付连接配置", constants.AuditResourcePaymentConfig, constants.AuditRiskHigh)
paymentConfigDeleted := connectionConfigAction(constants.AuditActionPaymentConfigDeleted, "删除支付连接配置", constants.AuditResourcePaymentConfig, constants.AuditRiskHigh)
paymentConfigActivated := connectionConfigAction(constants.AuditActionPaymentConfigActivated, "激活支付连接配置", constants.AuditResourcePaymentConfig, constants.AuditRiskHigh)
paymentConfigDeactivated := connectionConfigAction(constants.AuditActionPaymentConfigDeactivated, "停用支付连接配置", constants.AuditResourcePaymentConfig, constants.AuditRiskHigh)
carrierCreated := connectionConfigAction(constants.AuditActionCarrierCreated, "创建运营商配置", constants.AuditResourceCarrier, constants.AuditRiskNormal)
carrierUpdated := connectionConfigAction(constants.AuditActionCarrierUpdated, "更新运营商配置", constants.AuditResourceCarrier, constants.AuditRiskNormal)
carrierDeleted := connectionConfigAction(constants.AuditActionCarrierDeleted, "删除运营商配置", constants.AuditResourceCarrier, constants.AuditRiskHigh)
carrierStatusUpdated := connectionConfigAction(constants.AuditActionCarrierStatusUpdated, "更新运营商配置状态", constants.AuditResourceCarrier, constants.AuditRiskHigh)
wecomApplicationSaved := connectionConfigAction(constants.AuditActionWeComApplicationSaved, "保存企业微信应用配置", constants.AuditResourceWeComApplication, constants.AuditRiskHigh)
wecomDefaultCreatorSaved := connectionConfigAction(constants.AuditActionWeComDefaultCreatorSaved, "保存企业微信默认审批发起人", constants.AuditResourceWeComApplication, constants.AuditRiskHigh)
wecomMembersSynced := connectionConfigAction(constants.AuditActionWeComMembersSynced, "同步企业微信应用可见成员", constants.AuditResourceWeComApplication, constants.AuditRiskNormal)
wecomApprovalSceneSaved := connectionConfigAction(constants.AuditActionWeComApprovalSceneSaved, "保存企业微信审批场景配置", constants.AuditResourceWeComApprovalScene, constants.AuditRiskHigh)
outboxReplayed := outboxRecoveryAction(
constants.AuditActionOutboxReplayed,
"人工重放 Outbox 事件",
@@ -97,6 +187,37 @@ func NewRegistry() *Registry {
constants.AuditResourceDevice,
)
deviceBatchItem.AllowedVisibility = []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult}
iotCardImportTaskCreated := taskAction(constants.AuditActionIotCardImportTaskCreated, "创建 IoT 卡导入任务", constants.AuditResourceIotCardImportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
iotCardImportTaskCompleted := taskAction(constants.AuditActionIotCardImportTaskCompleted, "完成 IoT 卡导入任务", constants.AuditResourceIotCardImportTask, constants.AuditActorSystemTask, constants.AuditSourceWorker)
deviceImportTaskCreated := taskAction(constants.AuditActionDeviceImportTaskCreated, "创建设备导入任务", constants.AuditResourceDeviceImportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
deviceImportTaskCompleted := taskAction(constants.AuditActionDeviceImportTaskCompleted, "完成设备导入任务", constants.AuditResourceDeviceImportTask, constants.AuditActorSystemTask, constants.AuditSourceWorker)
assetPackageBatchOrderTaskCreated := taskAction(constants.AuditActionAssetPackageBatchOrderTaskCreated, "创建资产套餐批量订购任务", constants.AuditResourceAssetPackageBatchOrderTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
assetPackageBatchOrderTaskCompleted := taskAction(constants.AuditActionAssetPackageBatchOrderTaskCompleted, "完成资产套餐批量订购任务", constants.AuditResourceAssetPackageBatchOrderTask, constants.AuditActorSystemTask, constants.AuditSourceWorker)
orderPackageInvalidateTaskCreated := taskAction(constants.AuditActionOrderPackageInvalidateTaskCreated, "创建订单套餐批量失效任务", constants.AuditResourceOrderPackageInvalidateTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
orderPackageInvalidateTaskCompleted := taskAction(constants.AuditActionOrderPackageInvalidateTaskCompleted, "完成订单套餐批量失效任务", constants.AuditResourceOrderPackageInvalidateTask, constants.AuditActorSystemTask, constants.AuditSourceWorker)
orderPackageInvalidateItem := taskAction(constants.AuditActionOrderPackageInvalidateItem, "失效订单套餐权益", constants.AuditResourceOrder, constants.AuditActorSystemTask, constants.AuditSourceWorker)
exportTaskCreated := taskAction(constants.AuditActionExportTaskCreated, "创建业务导出任务", constants.AuditResourceExportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
exportTaskCancelled := taskAction(constants.AuditActionExportTaskCancelled, "取消业务导出任务", constants.AuditResourceExportTask, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
notificationDelivered := notificationAction(constants.AuditActionNotificationDelivered, "生成站内通知", constants.AuditResourceNotification, constants.AuditActorSystemTask, constants.AuditSourceWorker)
notificationRead := notificationAction(constants.AuditActionNotificationRead, "标记通知已读", constants.AuditResourceNotification, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
notificationRead.AllowedOrigins = []ActionOrigin{{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI}}
notificationReadAll := notificationAction(constants.AuditActionNotificationReadAll, "批量标记通知已读", constants.AuditResourceNotificationReadBatch, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
notificationReadAll.AllowedOrigins = []ActionOrigin{{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI}}
notificationCleanup := notificationAction(constants.AuditActionNotificationCleanup, "清理过期通知", constants.AuditResourceNotificationCleanupBatch, constants.AuditActorSystemTask, constants.AuditSourceWorker)
notificationCleanupItem := notificationAction(constants.AuditActionNotificationCleanupItem, "清理单条过期通知", constants.AuditResourceNotification, constants.AuditActorSystemTask, constants.AuditSourceWorker)
pollingConfigCreated := pollingAction(constants.AuditActionPollingConfigCreated, "创建轮询配置", constants.AuditResourcePollingConfig, constants.AuditRiskHigh)
pollingConfigUpdated := pollingAction(constants.AuditActionPollingConfigUpdated, "更新轮询配置", constants.AuditResourcePollingConfig, constants.AuditRiskHigh)
pollingConfigDeleted := pollingAction(constants.AuditActionPollingConfigDeleted, "删除轮询配置", constants.AuditResourcePollingConfig, constants.AuditRiskHigh)
pollingConfigStatusUpdated := pollingAction(constants.AuditActionPollingConfigStatusUpdated, "更新轮询配置状态", constants.AuditResourcePollingConfig, constants.AuditRiskHigh)
pollingConcurrencyUpdated := pollingAction(constants.AuditActionPollingConcurrencyUpdated, "更新轮询并发配置", constants.AuditResourcePollingConcurrencyConfig, constants.AuditRiskNormal)
pollingConcurrencyReset := pollingAction(constants.AuditActionPollingConcurrencyReset, "重置轮询并发计数", constants.AuditResourcePollingConcurrencyConfig, constants.AuditRiskNormal)
pollingAlertRuleCreated := pollingAction(constants.AuditActionPollingAlertRuleCreated, "创建轮询告警规则", constants.AuditResourcePollingAlertRule, constants.AuditRiskNormal)
pollingAlertRuleUpdated := pollingAction(constants.AuditActionPollingAlertRuleUpdated, "更新轮询告警规则", constants.AuditResourcePollingAlertRule, constants.AuditRiskNormal)
pollingAlertRuleDeleted := pollingAction(constants.AuditActionPollingAlertRuleDeleted, "删除轮询告警规则", constants.AuditResourcePollingAlertRule, constants.AuditRiskNormal)
pollingManualTriggerSingle := pollingAction(constants.AuditActionPollingManualTriggerSingle, "单卡手动触发", constants.AuditResourcePollingManualTrigger, constants.AuditRiskNormal)
pollingManualTriggerBatch := pollingAction(constants.AuditActionPollingManualTriggerBatch, "批量手动触发", constants.AuditResourcePollingManualTrigger, constants.AuditRiskNormal)
pollingManualTriggerByCondition := pollingAction(constants.AuditActionPollingManualTriggerByCondition, "条件筛选触发", constants.AuditResourcePollingManualTrigger, constants.AuditRiskNormal)
pollingManualCancelled := pollingAction(constants.AuditActionPollingManualCancelled, "取消手动触发任务", constants.AuditResourcePollingManualTrigger, constants.AuditRiskNormal)
wecomCredentialsRead := ActionDefinition{
Code: constants.AuditActionWeComCredentialsRead, Name: "读取企业微信应用明文凭据",
Category: constants.AuditCategorySecurity, Risk: constants.AuditRiskHigh,
@@ -115,9 +236,121 @@ func NewRegistry() *Registry {
permissionCreated := accessAction(constants.AuditActionPermissionCreated, "创建权限", constants.AuditResourcePermission)
permissionUpdated := accessAction(constants.AuditActionPermissionUpdated, "更新权限", constants.AuditResourcePermission)
permissionDeleted := accessAction(constants.AuditActionPermissionDeleted, "删除权限", constants.AuditResourcePermission)
packageSeriesCreated := packageConfigAction(constants.AuditActionPackageSeriesCreated, "创建套餐系列", constants.AuditResourcePackageSeries, constants.AuditRiskNormal)
packageSeriesUpdated := packageConfigAction(constants.AuditActionPackageSeriesUpdated, "更新套餐系列", constants.AuditResourcePackageSeries, constants.AuditRiskNormal)
packageSeriesDeleted := packageConfigAction(constants.AuditActionPackageSeriesDeleted, "删除套餐系列", constants.AuditResourcePackageSeries, constants.AuditRiskHigh)
packageSeriesStatusUpdated := packageConfigAction(constants.AuditActionPackageSeriesStatusUpdated, "更新套餐系列状态", constants.AuditResourcePackageSeries, constants.AuditRiskNormal)
packageCreated := packageConfigAction(constants.AuditActionPackageCreated, "创建套餐商品", constants.AuditResourcePackage, constants.AuditRiskNormal)
packageUpdated := packageConfigAction(constants.AuditActionPackageUpdated, "更新套餐商品", constants.AuditResourcePackage, constants.AuditRiskNormal)
packageDeleted := packageConfigAction(constants.AuditActionPackageDeleted, "删除套餐商品", constants.AuditResourcePackage, constants.AuditRiskHigh)
packageStatusUpdated := packageConfigAction(constants.AuditActionPackageStatusUpdated, "更新套餐商品状态", constants.AuditResourcePackage, constants.AuditRiskNormal)
packageShelfStatusUpdated := packageConfigAction(constants.AuditActionPackageShelfStatusUpdated, "更新套餐上架状态", constants.AuditResourcePackage, constants.AuditRiskNormal)
shopPackageShelfStatusUpdated := packageConfigAction(constants.AuditActionShopPackageShelfStatusUpdated, "更新店铺套餐上架状态", constants.AuditResourceShopPackageAllocation, constants.AuditRiskNormal)
packageRetailPriceUpdated := packageConfigAction(constants.AuditActionPackageRetailPriceUpdated, "更新店铺套餐零售价", constants.AuditResourceShopPackageAllocation, constants.AuditRiskNormal)
shopSeriesGrantCreated := packageConfigAction(constants.AuditActionShopSeriesGrantCreated, "创建店铺套餐系列授权", constants.AuditResourceShopSeriesAllocation, constants.AuditRiskNormal)
shopSeriesGrantUpdated := packageConfigAction(constants.AuditActionShopSeriesGrantUpdated, "更新店铺套餐系列授权", constants.AuditResourceShopSeriesAllocation, constants.AuditRiskNormal)
shopSeriesGrantPackagesManaged := packageConfigAction(constants.AuditActionShopSeriesGrantPackagesManaged, "管理店铺系列套餐授权", constants.AuditResourceShopSeriesAllocation, constants.AuditRiskNormal)
shopSeriesGrantDeleted := packageConfigAction(constants.AuditActionShopSeriesGrantDeleted, "删除店铺套餐系列授权", constants.AuditResourceShopSeriesAllocation, constants.AuditRiskHigh)
shopPackageBatchAllocated := packageConfigAction(constants.AuditActionShopPackageBatchAllocated, "批量分配店铺套餐", constants.AuditResourcePackageConfigBatch, constants.AuditRiskNormal)
shopPackageAllocated := packageConfigAction(constants.AuditActionShopPackageAllocated, "分配店铺套餐", constants.AuditResourceShopPackageAllocation, constants.AuditRiskNormal)
shopPackageExpiryBaseUpdated := packageConfigAction(constants.AuditActionShopPackageExpiryBaseUpdated, "更新店铺套餐生效条件", constants.AuditResourceShopPackageAllocation, constants.AuditRiskNormal)
shopPackageBatchPricingUpdated := packageConfigAction(constants.AuditActionShopPackageBatchPricingUpdated, "批量更新店铺套餐成本价", constants.AuditResourcePackageConfigBatch, constants.AuditRiskNormal)
shopPackagePricingItemUpdated := packageConfigAction(constants.AuditActionShopPackagePricingItemUpdated, "更新店铺套餐成本价", constants.AuditResourceShopPackageAllocation, constants.AuditRiskNormal)
packageUsageActivated := packageUsageAction(constants.AuditActionPackageUsageActivated, "激活套餐权益")
packageUsageExpired := packageUsageAction(constants.AuditActionPackageUsageExpired, "套餐权益到期")
packageUsageTrafficDeducted := packageUsageAction(constants.AuditActionPackageUsageTrafficDeducted, "扣减套餐权益流量")
packageUsageTrafficReset := packageUsageAction(constants.AuditActionPackageUsageTrafficReset, "重置套餐权益流量")
packageUsageRefundInvalidated := packageUsageAction(constants.AuditActionPackageUsageRefundInvalidated, "退款失效套餐权益")
packageUsageAssetInvalidated := packageUsageAction(constants.AuditActionPackageUsageAssetInvalidated, "资产失效套餐权益")
orderCreated := orderAction(constants.AuditActionOrderCreated, "创建订单")
orderCancelled := orderAction(constants.AuditActionOrderCancelled, "取消订单")
orderWalletPaid := orderAction(constants.AuditActionOrderWalletPaid, "钱包支付订单")
orderExpiredClosed := orderAction(constants.AuditActionOrderExpiredClosed, "关闭过期订单")
orderOnlinePaid := orderAction(constants.AuditActionOrderOnlinePaid, "第三方支付订单")
orderOnlinePaid.AllowedOrigins = append(orderOnlinePaid.AllowedOrigins, ActionOrigin{Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback})
agentWalletOrderDebited := agentWalletOrderAction(constants.AuditActionAgentWalletOrderDebited, "代理主钱包订单扣款")
agentWalletOrderReserved := agentWalletOrderAction(constants.AuditActionAgentWalletOrderReserved, "代理主钱包订单资金预占")
agentWalletOrderReleased := agentWalletOrderAction(constants.AuditActionAgentWalletOrderReleased, "释放代理主钱包订单预占")
agentWalletOrderCompleted := agentWalletOrderAction(constants.AuditActionAgentWalletOrderCompleted, "完成代理主钱包订单预占扣款")
agentWalletBalanceAdjusted := agentWalletAction(constants.AuditActionAgentWalletBalanceAdjusted, "人工调整代理主钱包余额")
agentWalletCreditChanged := agentWalletAction(constants.AuditActionAgentWalletCreditChanged, "调整代理主钱包信用额度")
paymentCreated := paymentAction(constants.AuditActionPaymentCreated, "创建支付记录", false)
paymentConfirmed := paymentAction(constants.AuditActionPaymentConfirmed, "确认支付成功", true)
paymentFailed := paymentAction(constants.AuditActionPaymentFailed, "关闭失败支付记录", false)
agentRechargeCreated := rechargeAction(constants.AuditActionAgentRechargeCreated, "创建代理充值申请", constants.AuditResourceAgentRecharge)
agentRechargeCredited := rechargeAction(constants.AuditActionAgentRechargeCredited, "代理充值资金入账", constants.AuditResourceAgentRecharge)
agentRechargeClosed := rechargeAction(constants.AuditActionAgentRechargeClosed, "关闭代理充值申请", constants.AuditResourceAgentRecharge)
assetRechargeAutoPurchased := rechargeAction(constants.AuditActionAssetRechargeAutoPurchased, "充值后自动购包", constants.AuditResourceRechargeOrder)
refundCreated := refundAction(constants.AuditActionRefundCreated, "提交退款申请", false)
refundApproved := refundAction(constants.AuditActionRefundApproved, "通过退款审批", true)
refundRejected := refundAction(constants.AuditActionRefundRejected, "拒绝退款审批", true)
refundReturned := refundAction(constants.AuditActionRefundReturned, "退回退款申请", false)
refundResubmitted := refundAction(constants.AuditActionRefundResubmitted, "重新提交退款申请", false)
refundCommissionInvalidated := refundSystemAction(constants.AuditActionRefundCommissionInvalidated, "退款失效佣金")
refundAssetProcessed := refundSystemAction(constants.AuditActionRefundAssetProcessed, "完成退款资产后处理")
approvalRequested := approvalAction(constants.AuditActionApprovalRequested, "提交通用审批申请", []ActionOrigin{
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
})
approvalSubmissionSynced := approvalAction(constants.AuditActionApprovalSubmissionSynced, "同步审批提交结果", []ActionOrigin{
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
})
approvalSubmissionRecovered := approvalAction(constants.AuditActionApprovalSubmissionRecovered, "恢复审批提交结果", []ActionOrigin{
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
{Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback},
})
approvalDecisionSynced := approvalAction(constants.AuditActionApprovalDecisionSynced, "同步审批权威终态", []ActionOrigin{
{Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback},
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
})
approvalDecisionSynced.Risk = constants.AuditRiskHigh
commissionCalculated := ActionDefinition{
Code: constants.AuditActionCommissionCalculated, Name: "计算订单佣金",
Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh,
PrimaryResource: constants.AuditResourceOrder, AllowedActor: constants.AuditActorSystemTask,
Source: constants.AuditSourceWorker, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
commissionCredited := ActionDefinition{
Code: constants.AuditActionCommissionCredited, Name: "佣金入账",
Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh,
PrimaryResource: constants.AuditResourceCommissionRecord, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
AllowedOrigins: []ActionOrigin{
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
},
}
commissionInvalidated := ActionDefinition{
Code: constants.AuditActionCommissionInvalidated, Name: "失效待审佣金",
Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh,
PrimaryResource: constants.AuditResourceCommissionRecord, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
withdrawalRequested := commissionWithdrawalAction(constants.AuditActionCommissionWithdrawalRequested, "提交佣金提现申请")
withdrawalApproved := commissionWithdrawalAction(constants.AuditActionCommissionWithdrawalApproved, "通过佣金提现申请")
withdrawalRejected := commissionWithdrawalAction(constants.AuditActionCommissionWithdrawalRejected, "驳回佣金提现申请")
return &Registry{
actionsByOperation: map[string]ActionDefinition{
constants.AuditOperationSystemConfigUpdate: systemConfigUpdated,
constants.AuditOperationPaymentConfigCreate: paymentConfigCreated,
constants.AuditOperationPaymentConfigUpdate: paymentConfigUpdated,
constants.AuditOperationPaymentConfigDelete: paymentConfigDeleted,
constants.AuditOperationPaymentConfigActivate: paymentConfigActivated,
constants.AuditOperationPaymentConfigDeactivate: paymentConfigDeactivated,
constants.AuditOperationCarrierCreate: carrierCreated,
constants.AuditOperationCarrierUpdate: carrierUpdated,
constants.AuditOperationCarrierDelete: carrierDeleted,
constants.AuditOperationCarrierStatusUpdate: carrierStatusUpdated,
constants.AuditOperationWeComApplicationSave: wecomApplicationSaved,
constants.AuditOperationWeComDefaultCreatorSave: wecomDefaultCreatorSaved,
constants.AuditOperationWeComMembersSync: wecomMembersSynced,
constants.AuditOperationWeComApprovalSceneSave: wecomApprovalSceneSaved,
constants.AuditOperationOutboxReplay: outboxReplayed,
constants.AuditOperationOutboxReleaseExpiredLease: outboxExpiredLeaseReleased,
},
@@ -155,11 +388,109 @@ func NewRegistry() *Registry {
constants.AuditActionPersonalCustomerPhoneBound: personalPhoneBound,
constants.AuditActionPersonalCustomerPhoneChanged: personalPhoneChanged,
constants.AuditActionPersonalCustomerWechatIdentityUpdated: personalWechatIdentityUpdated,
constants.AuditActionPersonalCustomerAssetBound: personalAssetBound,
constants.AuditActionPersonalCustomerAssetUnbound: personalAssetUnbound,
constants.AuditActionPersonalCustomerAssetBindingMigrated: personalAssetBindingMigrated,
constants.AuditActionIotCardCreated: iotCardCreated,
constants.AuditActionIotCardDeleted: iotCardDeleted,
constants.AuditActionIotCardBatchDeleted: iotCardBatchDeleted,
constants.AuditActionIotCardAllocationBatch: iotCardAllocationBatch,
constants.AuditActionIotCardAllocated: iotCardAllocated,
constants.AuditActionIotCardRecallBatch: iotCardRecallBatch,
constants.AuditActionIotCardRecalled: iotCardRecalled,
constants.AuditActionIotCardSeriesBindingBatch: iotCardSeriesBindingBatch,
constants.AuditActionIotCardSeriesBound: iotCardSeriesBound,
constants.AuditActionIotCardSpeedTierSet: iotCardSpeedTierSet,
constants.AuditActionIotCardRealnamePolicyBatchUpdated: iotCardRealnamePolicyBatchUpdated,
constants.AuditActionIotCardRealnamePolicyUpdated: iotCardRealnamePolicyUpdated,
constants.AuditActionIotCardRealnameStatusUpdated: iotCardRealnameStatusUpdated,
constants.AuditActionIotCardRealnameCallbackSynced: iotCardRealnameCallbackSynced,
constants.AuditActionIotCardManualRefreshed: iotCardManualRefreshed,
constants.AuditActionIotCardPersonalRefreshed: iotCardPersonalRefreshed,
constants.AuditActionIotCardManualStopped: iotCardManualStopped,
constants.AuditActionIotCardManualStarted: iotCardManualStarted,
constants.AuditActionIotCardAutoStopped: iotCardAutoStopped,
constants.AuditActionIotCardAutoStarted: iotCardAutoStarted,
constants.AuditActionIotCardOpenAPIStarted: iotCardOpenAPIStarted,
constants.AuditActionIotCardAutoStopReasonUpdated: iotCardAutoStopReasonUpdated,
constants.AuditActionDeviceCreated: deviceCreated,
constants.AuditActionDeviceDeleted: deviceDeleted,
constants.AuditActionDeviceAllocationBatch: deviceAllocationBatch,
constants.AuditActionDeviceAllocated: deviceAllocated,
constants.AuditActionDeviceRecallBatch: deviceRecallBatch,
constants.AuditActionDeviceRecalled: deviceRecalled,
constants.AuditActionDeviceSeriesBindingBatch: deviceSeriesBindingBatch,
constants.AuditActionDeviceSeriesBound: deviceSeriesBound,
constants.AuditActionDeviceRealnamePolicyBatchUpdated: deviceRealnamePolicyBatchUpdated,
constants.AuditActionDeviceRealnamePolicyUpdated: deviceRealnamePolicyUpdated,
constants.AuditActionDeviceStopped: deviceStopped,
constants.AuditActionDeviceStarted: deviceStarted,
constants.AuditActionDeviceWiFiSet: deviceWiFiSet,
constants.AuditActionDeviceSwitchModeSet: deviceSwitchModeSet,
constants.AuditActionDeviceRebooted: deviceRebooted,
constants.AuditActionDeviceReset: deviceReset,
constants.AuditActionDeviceCardBound: deviceCardBound,
constants.AuditActionDeviceCardUnbound: deviceCardUnbound,
constants.AuditActionDeviceCurrentCardSwitched: deviceCurrentCardSwitched,
constants.AuditActionCardExchangeCreated: cardExchangeCreated,
constants.AuditActionCardExchangeShippingInfoSubmitted: cardExchangeShippingInfoSubmitted,
constants.AuditActionCardExchangeShipped: cardExchangeShipped,
constants.AuditActionCardExchangeCompleted: cardExchangeCompleted,
constants.AuditActionCardExchangeCancelled: cardExchangeCancelled,
constants.AuditActionCardExchangeRenewed: cardExchangeRenewed,
constants.AuditActionDeviceExchangeCreated: deviceExchangeCreated,
constants.AuditActionDeviceExchangeShippingInfoSubmitted: deviceExchangeShippingInfoSubmitted,
constants.AuditActionDeviceExchangeShipped: deviceExchangeShipped,
constants.AuditActionDeviceExchangeCompleted: deviceExchangeCompleted,
constants.AuditActionDeviceExchangeCancelled: deviceExchangeCancelled,
constants.AuditActionDeviceExchangeRenewed: deviceExchangeRenewed,
constants.AuditActionSystemConfigUpdated: systemConfigUpdated,
constants.AuditActionPaymentConfigCreated: paymentConfigCreated,
constants.AuditActionPaymentConfigUpdated: paymentConfigUpdated,
constants.AuditActionPaymentConfigDeleted: paymentConfigDeleted,
constants.AuditActionPaymentConfigActivated: paymentConfigActivated,
constants.AuditActionPaymentConfigDeactivated: paymentConfigDeactivated,
constants.AuditActionCarrierCreated: carrierCreated,
constants.AuditActionCarrierUpdated: carrierUpdated,
constants.AuditActionCarrierDeleted: carrierDeleted,
constants.AuditActionCarrierStatusUpdated: carrierStatusUpdated,
constants.AuditActionWeComApplicationSaved: wecomApplicationSaved,
constants.AuditActionWeComDefaultCreatorSaved: wecomDefaultCreatorSaved,
constants.AuditActionWeComMembersSynced: wecomMembersSynced,
constants.AuditActionWeComApprovalSceneSaved: wecomApprovalSceneSaved,
constants.AuditActionOutboxReplayed: outboxReplayed,
constants.AuditActionOutboxExpiredLeaseReleased: outboxExpiredLeaseReleased,
constants.AuditActionDeviceBatchAllocationCompleted: deviceBatchCompleted,
constants.AuditActionDeviceBatchAllocationItem: deviceBatchItem,
constants.AuditActionIotCardImportTaskCreated: iotCardImportTaskCreated,
constants.AuditActionIotCardImportTaskCompleted: iotCardImportTaskCompleted,
constants.AuditActionDeviceImportTaskCreated: deviceImportTaskCreated,
constants.AuditActionDeviceImportTaskCompleted: deviceImportTaskCompleted,
constants.AuditActionAssetPackageBatchOrderTaskCreated: assetPackageBatchOrderTaskCreated,
constants.AuditActionAssetPackageBatchOrderTaskCompleted: assetPackageBatchOrderTaskCompleted,
constants.AuditActionOrderPackageInvalidateTaskCreated: orderPackageInvalidateTaskCreated,
constants.AuditActionOrderPackageInvalidateTaskCompleted: orderPackageInvalidateTaskCompleted,
constants.AuditActionOrderPackageInvalidateItem: orderPackageInvalidateItem,
constants.AuditActionExportTaskCreated: exportTaskCreated,
constants.AuditActionExportTaskCancelled: exportTaskCancelled,
constants.AuditActionNotificationDelivered: notificationDelivered,
constants.AuditActionNotificationRead: notificationRead,
constants.AuditActionNotificationReadAll: notificationReadAll,
constants.AuditActionNotificationCleanup: notificationCleanup,
constants.AuditActionNotificationCleanupItem: notificationCleanupItem,
constants.AuditActionPollingConfigCreated: pollingConfigCreated,
constants.AuditActionPollingConfigUpdated: pollingConfigUpdated,
constants.AuditActionPollingConfigDeleted: pollingConfigDeleted,
constants.AuditActionPollingConfigStatusUpdated: pollingConfigStatusUpdated,
constants.AuditActionPollingConcurrencyUpdated: pollingConcurrencyUpdated,
constants.AuditActionPollingConcurrencyReset: pollingConcurrencyReset,
constants.AuditActionPollingAlertRuleCreated: pollingAlertRuleCreated,
constants.AuditActionPollingAlertRuleUpdated: pollingAlertRuleUpdated,
constants.AuditActionPollingAlertRuleDeleted: pollingAlertRuleDeleted,
constants.AuditActionPollingManualTriggerSingle: pollingManualTriggerSingle,
constants.AuditActionPollingManualTriggerBatch: pollingManualTriggerBatch,
constants.AuditActionPollingManualTriggerByCondition: pollingManualTriggerByCondition,
constants.AuditActionPollingManualCancelled: pollingManualCancelled,
constants.AuditActionWeComCredentialsRead: wecomCredentialsRead,
constants.AuditActionRoleCreated: roleCreated,
constants.AuditActionRoleUpdated: roleUpdated,
@@ -172,6 +503,67 @@ func NewRegistry() *Registry {
constants.AuditActionPermissionCreated: permissionCreated,
constants.AuditActionPermissionUpdated: permissionUpdated,
constants.AuditActionPermissionDeleted: permissionDeleted,
constants.AuditActionPackageSeriesCreated: packageSeriesCreated,
constants.AuditActionPackageSeriesUpdated: packageSeriesUpdated,
constants.AuditActionPackageSeriesDeleted: packageSeriesDeleted,
constants.AuditActionPackageSeriesStatusUpdated: packageSeriesStatusUpdated,
constants.AuditActionPackageCreated: packageCreated,
constants.AuditActionPackageUpdated: packageUpdated,
constants.AuditActionPackageDeleted: packageDeleted,
constants.AuditActionPackageStatusUpdated: packageStatusUpdated,
constants.AuditActionPackageShelfStatusUpdated: packageShelfStatusUpdated,
constants.AuditActionShopPackageShelfStatusUpdated: shopPackageShelfStatusUpdated,
constants.AuditActionPackageRetailPriceUpdated: packageRetailPriceUpdated,
constants.AuditActionShopSeriesGrantCreated: shopSeriesGrantCreated,
constants.AuditActionShopSeriesGrantUpdated: shopSeriesGrantUpdated,
constants.AuditActionShopSeriesGrantPackagesManaged: shopSeriesGrantPackagesManaged,
constants.AuditActionShopSeriesGrantDeleted: shopSeriesGrantDeleted,
constants.AuditActionShopPackageBatchAllocated: shopPackageBatchAllocated,
constants.AuditActionShopPackageAllocated: shopPackageAllocated,
constants.AuditActionShopPackageExpiryBaseUpdated: shopPackageExpiryBaseUpdated,
constants.AuditActionShopPackageBatchPricingUpdated: shopPackageBatchPricingUpdated,
constants.AuditActionShopPackagePricingItemUpdated: shopPackagePricingItemUpdated,
constants.AuditActionPackageUsageActivated: packageUsageActivated,
constants.AuditActionPackageUsageExpired: packageUsageExpired,
constants.AuditActionPackageUsageTrafficDeducted: packageUsageTrafficDeducted,
constants.AuditActionPackageUsageTrafficReset: packageUsageTrafficReset,
constants.AuditActionPackageUsageRefundInvalidated: packageUsageRefundInvalidated,
constants.AuditActionPackageUsageAssetInvalidated: packageUsageAssetInvalidated,
constants.AuditActionOrderCreated: orderCreated,
constants.AuditActionOrderCancelled: orderCancelled,
constants.AuditActionOrderWalletPaid: orderWalletPaid,
constants.AuditActionOrderExpiredClosed: orderExpiredClosed,
constants.AuditActionOrderOnlinePaid: orderOnlinePaid,
constants.AuditActionAgentWalletOrderDebited: agentWalletOrderDebited,
constants.AuditActionAgentWalletOrderReserved: agentWalletOrderReserved,
constants.AuditActionAgentWalletOrderReleased: agentWalletOrderReleased,
constants.AuditActionAgentWalletOrderCompleted: agentWalletOrderCompleted,
constants.AuditActionAgentWalletBalanceAdjusted: agentWalletBalanceAdjusted,
constants.AuditActionAgentWalletCreditChanged: agentWalletCreditChanged,
constants.AuditActionPaymentCreated: paymentCreated,
constants.AuditActionPaymentConfirmed: paymentConfirmed,
constants.AuditActionPaymentFailed: paymentFailed,
constants.AuditActionAgentRechargeCreated: agentRechargeCreated,
constants.AuditActionAgentRechargeCredited: agentRechargeCredited,
constants.AuditActionAgentRechargeClosed: agentRechargeClosed,
constants.AuditActionAssetRechargeAutoPurchased: assetRechargeAutoPurchased,
constants.AuditActionRefundCreated: refundCreated,
constants.AuditActionRefundApproved: refundApproved,
constants.AuditActionRefundRejected: refundRejected,
constants.AuditActionRefundReturned: refundReturned,
constants.AuditActionRefundResubmitted: refundResubmitted,
constants.AuditActionRefundCommissionInvalidated: refundCommissionInvalidated,
constants.AuditActionRefundAssetProcessed: refundAssetProcessed,
constants.AuditActionApprovalRequested: approvalRequested,
constants.AuditActionApprovalSubmissionSynced: approvalSubmissionSynced,
constants.AuditActionApprovalSubmissionRecovered: approvalSubmissionRecovered,
constants.AuditActionApprovalDecisionSynced: approvalDecisionSynced,
constants.AuditActionCommissionCalculated: commissionCalculated,
constants.AuditActionCommissionCredited: commissionCredited,
constants.AuditActionCommissionInvalidated: commissionInvalidated,
constants.AuditActionCommissionWithdrawalRequested: withdrawalRequested,
constants.AuditActionCommissionWithdrawalApproved: withdrawalApproved,
constants.AuditActionCommissionWithdrawalRejected: withdrawalRejected,
},
resources: map[string]ResourceDefinition{
constants.AuditResourceAccount: {
@@ -190,6 +582,18 @@ func NewRegistry() *Registry {
Type: constants.AuditResourceSystemConfig, Name: "受控系统配置",
IdentityFields: []string{"config_key", "module"},
},
constants.AuditResourcePaymentConfig: {
Type: constants.AuditResourcePaymentConfig, Name: "支付连接配置",
IdentityFields: []string{"id", "name", "provider_type", "is_active", "credentials_configured"},
},
constants.AuditResourceCarrier: {
Type: constants.AuditResourceCarrier, Name: "运营商配置",
IdentityFields: []string{"id", "carrier_code", "carrier_name", "carrier_type", "status"},
},
constants.AuditResourceWeComApprovalScene: {
Type: constants.AuditResourceWeComApprovalScene, Name: "企业微信审批场景配置",
IdentityFields: []string{"id", "business_type", "application_id", "template_id", "template_name", "status"},
},
constants.AuditResourceOutboxEvent: {
Type: constants.AuditResourceOutboxEvent, Name: "Outbox 事件",
IdentityFields: []string{
@@ -197,13 +601,68 @@ func NewRegistry() *Registry {
"resource_type", "resource_id", "business_key",
},
},
constants.AuditResourceIntegrationLog: {
Type: constants.AuditResourceIntegrationLog, Name: "外部集成日志",
IdentityFields: []string{
"integration_id", "provider", "direction", "operation", "result", "external_id",
"resource_type", "resource_id", "resource_key", "correlation_id",
},
},
constants.AuditResourceDeviceBatchTask: {
Type: constants.AuditResourceDeviceBatchTask, Name: "设备批量分配任务",
IdentityFields: []string{"task_no", "operation_type"},
},
constants.AuditResourceIotCardImportTask: {
Type: constants.AuditResourceIotCardImportTask, Name: "IoT 卡导入任务",
IdentityFields: []string{"id", "task_no", "file_name", "carrier_id", "carrier_name", "batch_no", "card_category", "realname_policy"},
},
constants.AuditResourceDeviceImportTask: {
Type: constants.AuditResourceDeviceImportTask, Name: "设备导入任务",
IdentityFields: []string{"id", "task_no", "file_name", "operation_type", "target_id", "batch_no", "realname_policy"},
},
constants.AuditResourceAssetPackageBatchOrderTask: {
Type: constants.AuditResourceAssetPackageBatchOrderTask, Name: "资产套餐批量订购任务",
IdentityFields: []string{"id", "task_no", "file_name", "package_id", "package_code", "package_name", "payment_method"},
},
constants.AuditResourceOrderPackageInvalidateTask: {
Type: constants.AuditResourceOrderPackageInvalidateTask, Name: "订单套餐批量失效任务",
IdentityFields: []string{"id", "task_no", "file_name"},
},
constants.AuditResourceExportTask: {
Type: constants.AuditResourceExportTask, Name: "业务导出任务",
IdentityFields: []string{"id", "task_no", "scene", "format", "creator_user_id", "creator_user_type", "creator_shop_id", "creator_enterprise_id", "scope_shop_ids"},
},
constants.AuditResourceNotification: {
Type: constants.AuditResourceNotification, Name: "站内通知",
IdentityFields: []string{"id", "event_id", "recipient_kind", "recipient_id", "category", "type", "severity", "ref_type", "ref_id", "ref_key"},
},
constants.AuditResourceNotificationReadBatch: {
Type: constants.AuditResourceNotificationReadBatch, Name: "通知批量已读",
IdentityFields: []string{"recipient_kind", "recipient_id", "category", "updated_count"},
},
constants.AuditResourceNotificationCleanupBatch: {
Type: constants.AuditResourceNotificationCleanupBatch, Name: "通知清理批次",
IdentityFields: []string{"category", "cutoff", "deleted_count", "first_id", "last_id"},
},
constants.AuditResourcePollingConfig: {
Type: constants.AuditResourcePollingConfig, Name: "轮询配置",
IdentityFields: []string{"id", "config_name", "card_condition", "card_category", "carrier_id", "priority", "status"},
},
constants.AuditResourcePollingConcurrencyConfig: {
Type: constants.AuditResourcePollingConcurrencyConfig, Name: "轮询并发配置",
IdentityFields: []string{"id", "task_type", "max_concurrency"},
},
constants.AuditResourcePollingAlertRule: {
Type: constants.AuditResourcePollingAlertRule, Name: "轮询告警规则",
IdentityFields: []string{"id", "rule_name", "task_type", "metric_type", "operator", "threshold", "alert_level", "status"},
},
constants.AuditResourcePollingManualTrigger: {
Type: constants.AuditResourcePollingManualTrigger, Name: "手动轮询任务",
IdentityFields: []string{"id", "task_type", "trigger_type", "total_count", "status", "triggered_by"},
},
constants.AuditResourceDevice: {
Type: constants.AuditResourceDevice, Name: "设备",
IdentityFields: []string{"id", "virtual_no", "imei", "sn", "generation"},
IdentityFields: []string{"id", "virtual_no", "imei", "sn", "device_name", "device_model", "device_type", "manufacturer", "shop_id", "series_id", "generation"},
},
constants.AuditResourceIotCard: {
Type: constants.AuditResourceIotCard, Name: "IoT卡",
@@ -215,11 +674,11 @@ func NewRegistry() *Registry {
},
constants.AuditResourceOrder: {
Type: constants.AuditResourceOrder, Name: "订单",
IdentityFields: []string{"id", "order_no", "buyer_type", "buyer_id", "asset_identifier", "total_amount", "payment_method", "payment_status"},
IdentityFields: []string{"id", "order_no", "order_type", "buyer_type", "buyer_id", "iot_card_id", "device_id", "asset_identifier", "total_amount", "actual_paid_amount", "payment_method", "payment_status", "purchase_role", "source", "operator_account_id", "operator_account_type", "operator_account_name", "seller_shop_id", "expires_at"},
},
constants.AuditResourceRefund: {
Type: constants.AuditResourceRefund, Name: "退款单",
IdentityFields: []string{"id", "refund_no", "order_id", "order_no", "asset_identifier", "shop_id", "requested_refund_amount", "status"},
IdentityFields: []string{"id", "refund_no", "order_id", "order_no", "order_type", "package_usage_id", "asset_identifier", "shop_id", "requested_refund_amount", "actual_received_amount", "refund_reason", "approved_refund_amount", "approval_instance_id", "status", "commission_deducted", "asset_reset"},
},
constants.AuditResourceEnterprise: {
Type: constants.AuditResourceEnterprise, Name: "企业",
@@ -233,21 +692,81 @@ func NewRegistry() *Registry {
Type: constants.AuditResourceAssetAllocationRecord, Name: "资产分配记录",
IdentityFields: []string{"id", "allocation_no", "asset_type", "asset_id", "asset_identifier", "from_owner_type", "from_owner_id", "to_owner_type", "to_owner_id"},
},
constants.AuditResourcePackageSeries: {
Type: constants.AuditResourcePackageSeries, Name: "套餐系列",
IdentityFields: []string{"id", "series_code", "series_name", "status", "enable_one_time_commission"},
},
constants.AuditResourcePackage: {
Type: constants.AuditResourcePackage, Name: "套餐商品",
IdentityFields: []string{"id", "package_code", "package_name", "series_id", "package_type", "duration_months", "duration_days", "price_config_status", "is_gift", "status", "shelf_status"},
},
constants.AuditResourceShopSeriesAllocation: {
Type: constants.AuditResourceShopSeriesAllocation, Name: "店铺套餐系列授权",
IdentityFields: []string{"id", "shop_id", "series_id", "allocator_shop_id", "status"},
},
constants.AuditResourceShopPackageAllocation: {
Type: constants.AuditResourceShopPackageAllocation, Name: "店铺套餐授权",
IdentityFields: []string{"id", "shop_id", "package_id", "allocator_shop_id", "series_allocation_id", "status", "shelf_status", "retail_price_config_status"},
},
constants.AuditResourceShopPackagePriceHistory: {
Type: constants.AuditResourceShopPackagePriceHistory, Name: "店铺套餐价格历史",
IdentityFields: []string{"id", "allocation_id", "changed_by", "effective_from"},
},
constants.AuditResourcePackageConfigBatch: {
Type: constants.AuditResourcePackageConfigBatch, Name: "套餐配置批次",
IdentityFields: []string{"batch_key", "operation", "shop_id", "series_id"},
},
constants.AuditResourceExchangeOrder: {
Type: constants.AuditResourceExchangeOrder, Name: "换货单",
IdentityFields: []string{"id", "exchange_no", "old_asset_type", "old_asset_id", "new_asset_type", "new_asset_id", "shop_id", "status"},
IdentityFields: []string{"id", "exchange_no", "flow_type", "old_asset_type", "old_asset_id", "old_asset_identifier", "new_asset_type", "new_asset_id", "new_asset_identifier", "shop_id", "status"},
},
constants.AuditResourceAgentRecharge: {
Type: constants.AuditResourceAgentRecharge, Name: "代理充值单",
IdentityFields: []string{"id", "recharge_no", "shop_id", "agent_wallet_id", "approval_instance_id", "status"},
IdentityFields: []string{"id", "recharge_no", "user_id", "shop_id", "agent_wallet_id", "amount", "payment_method", "payment_channel", "payment_transaction_id", "approval_instance_id", "status"},
},
constants.AuditResourceRechargeOrder: {
Type: constants.AuditResourceRechargeOrder, Name: "资产充值单",
IdentityFields: []string{"id", "recharge_order_no", "user_id", "asset_wallet_id", "resource_type", "resource_id", "amount", "status"},
},
constants.AuditResourceAssetWallet: {
Type: constants.AuditResourceAssetWallet, Name: "资产钱包",
IdentityFields: []string{"id", "resource_type", "resource_id", "currency"},
IdentityFields: []string{"id", "resource_type", "resource_id", "currency", "shop_id_tag", "enterprise_id_tag"},
},
constants.AuditResourceAssetWalletTransaction: {
Type: constants.AuditResourceAssetWalletTransaction, Name: "资产钱包流水",
IdentityFields: []string{"id", "asset_wallet_id", "resource_type", "resource_id", "transaction_type", "reference_type", "reference_no", "status"},
},
constants.AuditResourceAgentWallet: {
Type: constants.AuditResourceAgentWallet, Name: "代理主钱包",
IdentityFields: []string{"id", "shop_id", "wallet_type", "currency", "status", "credit_enabled", "credit_limit"},
},
constants.AuditResourceAgentWalletTransaction: {
Type: constants.AuditResourceAgentWalletTransaction, Name: "代理主钱包流水",
IdentityFields: []string{"id", "agent_wallet_id", "shop_id", "transaction_type", "transaction_subtype", "reference_type", "reference_id", "status"},
},
constants.AuditResourceAgentWalletReservation: {
Type: constants.AuditResourceAgentWalletReservation, Name: "代理主钱包预占",
IdentityFields: []string{"id", "agent_wallet_id", "shop_id", "amount", "status", "reference_type", "reference_id"},
},
constants.AuditResourcePayment: {
Type: constants.AuditResourcePayment, Name: "支付记录",
IdentityFields: []string{"id", "payment_no", "order_id", "order_type", "payment_method", "amount", "status", "third_party_trade_no", "payment_config_id"},
},
constants.AuditResourcePackageUsage: {
Type: constants.AuditResourcePackageUsage, Name: "套餐权益",
IdentityFields: []string{"id", "order_id", "order_no", "refund_id", "refund_no", "package_id", "package_name", "usage_type", "iot_card_id", "device_id", "data_limit_mb", "data_usage_mb", "activated_at", "expires_at", "status", "pending_realname_activation", "last_reset_at", "next_reset_at", "generation"},
},
constants.AuditResourceApprovalInstance: {
Type: constants.AuditResourceApprovalInstance, Name: "审批实例",
IdentityFields: []string{"id", "business_type", "business_id", "provider", "external_ref", "status"},
IdentityFields: []string{"id", "business_type", "business_id", "submitter_account_id", "provider", "external_ref", "correlation_id", "status"},
},
constants.AuditResourceCommissionRecord: {
Type: constants.AuditResourceCommissionRecord, Name: "佣金记录",
IdentityFields: []string{"id", "shop_id", "order_id", "iot_card_id", "device_id", "commission_source", "amount", "status", "released_at"},
},
constants.AuditResourceCommissionWithdrawal: {
Type: constants.AuditResourceCommissionWithdrawal, Name: "佣金提现单",
IdentityFields: []string{"id", "withdrawal_no", "shop_id", "applicant_id", "amount", "fee", "fee_rate", "actual_amount", "withdrawal_method", "payment_type", "status", "processor_id", "processed_at", "paid_at"},
},
constants.AuditResourceWeComApplication: {
Type: constants.AuditResourceWeComApplication, Name: "企业微信应用配置",
@@ -277,6 +796,22 @@ func NewRegistry() *Registry {
Type: constants.AuditResourcePersonalCustomerOpenID, Name: "个人客户微信主体",
IdentityFields: []string{"id", "customer_id", "app_id", "open_id", "union_id", "app_type"},
},
constants.AuditResourcePersonalCustomerDevice: {
Type: constants.AuditResourcePersonalCustomerDevice, Name: "个人客户设备号绑定",
IdentityFields: []string{"id", "customer_id", "virtual_no", "bind_at", "last_used_at", "status"},
},
constants.AuditResourcePersonalCustomerICCID: {
Type: constants.AuditResourcePersonalCustomerICCID, Name: "个人客户 ICCID 绑定",
IdentityFields: []string{"id", "customer_id", "iccid", "iccid_19", "bind_at", "last_used_at", "status"},
},
constants.AuditResourceIotCardBatch: {
Type: constants.AuditResourceIotCardBatch, Name: "IoT 卡批量操作",
IdentityFields: []string{"request_id", "card_count"},
},
constants.AuditResourceDeviceBatch: {
Type: constants.AuditResourceDeviceBatch, Name: "设备批量操作",
IdentityFields: []string{"request_id", "correlation_id", "device_count", "operation_type"},
},
},
}
}
@@ -352,6 +887,233 @@ func personalAction(code, name string, subjectFields []string) ActionDefinition
}
}
func customerAssetAdminAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourcePersonalCustomer, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
}
}
func packageConfigAction(code, name, primaryResource, risk string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryConfiguration, Risk: risk,
PrimaryResource: primaryResource, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
func packageUsageAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourcePackageUsage, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
AllowedOrigins: []ActionOrigin{
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
},
}
}
func orderAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourceOrder, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult, constants.AuditSubjectDetail},
AllowedOrigins: []ActionOrigin{
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI},
{Actor: constants.AuditActorOpenAPI, Source: constants.AuditSourceOpenAPI},
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
},
}
}
func agentWalletOrderAction(code, name string) ActionDefinition {
action := orderAction(code, name)
action.Risk = constants.AuditRiskHigh
return action
}
func agentWalletAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh,
PrimaryResource: constants.AuditResourceAgentWallet, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
}
}
func paymentAction(code, name string, confirmation bool) ActionDefinition {
origins := []ActionOrigin{
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI},
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
}
if confirmation {
origins = append(origins, ActionOrigin{Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback})
}
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourcePayment, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
AllowedOrigins: origins,
}
}
func rechargeAction(code, name, primaryResource string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh,
PrimaryResource: primaryResource, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult, constants.AuditSubjectDetail},
AllowedOrigins: []ActionOrigin{
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI},
{Actor: constants.AuditActorExternalSystem, Source: constants.AuditSourceCallback},
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
{Actor: constants.AuditActorScheduledJob, Source: constants.AuditSourceScheduler},
},
}
}
func refundAction(code, name string, allowWorker bool) ActionDefinition {
origins := []ActionOrigin{{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI}}
if allowWorker {
origins = append(origins, ActionOrigin{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker})
}
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh,
PrimaryResource: constants.AuditResourceRefund, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
AllowedOrigins: origins,
}
}
func refundSystemAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh,
PrimaryResource: constants.AuditResourceRefund, AllowedActor: constants.AuditActorSystemTask,
Source: constants.AuditSourceWorker, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
}
}
func approvalAction(code, name string, origins []ActionOrigin) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourceApprovalInstance, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly}, AllowedOrigins: origins,
}
}
func commissionWithdrawalAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskHigh,
PrimaryResource: constants.AuditResourceCommissionWithdrawal,
AllowedActor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI,
RequireTransaction: true, DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult, constants.AuditSubjectDetail},
SubjectFields: []string{"amount", "fee", "actual_amount", "withdrawal_method", "payment_type", "status"},
}
}
func iotCardAction(code, name, actorKind, source string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourceIotCard, AllowedActor: actorKind, Source: source,
RequireTransaction: true, DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
}
}
func deviceAction(code, name, actorKind, source string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourceDevice, AllowedActor: actorKind, Source: source,
RequireTransaction: true, DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
}
}
func deviceMultiOriginAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourceDevice, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
AllowedOrigins: []ActionOrigin{
{Actor: constants.AuditActorAccount, Source: constants.AuditSourceAdminAPI},
{Actor: constants.AuditActorSystemTask, Source: constants.AuditSourceWorker},
},
}
}
func deviceExternalAction(code, name string, allowOpenAPI bool) ActionDefinition {
action := deviceAction(code, name, constants.AuditActorAccount, constants.AuditSourceAdminAPI)
action.AllowedOrigins = []ActionOrigin{{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI}}
if allowOpenAPI {
action.AllowedOrigins = append(action.AllowedOrigins, ActionOrigin{Actor: constants.AuditActorOpenAPI, Source: constants.AuditSourceOpenAPI})
}
return action
}
func cardExchangeAction(code, name, risk string, personal bool) ActionDefinition {
action := ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: risk,
PrimaryResource: constants.AuditResourceExchangeOrder, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectResult,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly, constants.AuditSubjectResult},
}
if personal {
action.AllowedOrigins = []ActionOrigin{{Actor: constants.AuditActorPersonalCustomer, Source: constants.AuditSourcePersonalAPI}}
}
return action
}
func deviceMultiOriginBatchAction(code, name string) ActionDefinition {
action := deviceMultiOriginAction(code, name)
action.PrimaryResource = constants.AuditResourceDeviceBatch
action.DefaultVisibility = constants.AuditSubjectInternalOnly
action.AllowedVisibility = []string{constants.AuditSubjectInternalOnly}
return action
}
func deviceAccountBatchAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourceDeviceBatch, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
func iotCardBatchAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: constants.AuditRiskNormal,
PrimaryResource: constants.AuditResourceIotCardBatch, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
func accountLifecycleAction(code, name, risk string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryIdentity, Risk: risk,
@@ -372,6 +1134,34 @@ func deviceBatchAction(code, name, primaryResource string) ActionDefinition {
}
}
func taskAction(code, name, primaryResource, actor, source string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskNormal,
PrimaryResource: primaryResource, AllowedActor: actor, Source: source, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
func notificationAction(code, name, primaryResource, actor, source string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryBusiness, Risk: constants.AuditRiskLow,
PrimaryResource: primaryResource, AllowedActor: actor, Source: source, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
func pollingAction(code, name, primaryResource, risk string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryAsset, Risk: risk,
PrimaryResource: primaryResource, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
func outboxRecoveryAction(code, name string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryReliability, Risk: constants.AuditRiskHigh,
@@ -382,6 +1172,16 @@ func outboxRecoveryAction(code, name string) ActionDefinition {
}
}
func connectionConfigAction(code, name, resourceType, risk string) ActionDefinition {
return ActionDefinition{
Code: code, Name: name, Category: constants.AuditCategoryConfiguration, Risk: risk,
PrimaryResource: resourceType, AllowedActor: constants.AuditActorAccount,
Source: constants.AuditSourceAdminAPI, RequireTransaction: true,
DefaultVisibility: constants.AuditSubjectInternalOnly,
AllowedVisibility: []string{constants.AuditSubjectInternalOnly},
}
}
// Action 返回已注册动作定义。
func (r *Registry) Action(code string) (ActionDefinition, bool) {
if r == nil {

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
}

View File

@@ -167,7 +167,7 @@ func (w *Writer) WriteAccessChange(ctx context.Context, tx *gorm.DB, change acce
}
func accessResources(change accessauditapp.ChangeAudit, primaryResource string) ([]ResourceInput, error) {
resources := make([]ResourceInput, 0, 2+len(change.Accounts)+len(change.Cards)+len(change.CardAuthorizations)+len(change.Devices)+len(change.DeviceBindings)+len(change.DeviceAuthorizations)+len(change.PersonalPhones)+len(change.PersonalOpenIDs)+len(change.Roles)+len(change.Permissions))
resources := make([]ResourceInput, 0, 2+len(change.Accounts)+len(change.Cards)+len(change.CardAuthorizations)+len(change.Devices)+len(change.DeviceBindings)+len(change.DeviceAuthorizations)+len(change.PersonalPhones)+len(change.PersonalOpenIDs)+len(change.PersonalDevices)+len(change.PersonalICCIDs)+len(change.Roles)+len(change.Permissions))
switch primaryResource {
case constants.AuditResourceAccount:
if change.Account == nil || (change.Account.ID == 0 && change.Account.Username == "") {
@@ -392,6 +392,46 @@ func accessResources(change accessauditapp.ChangeAudit, primaryResource string)
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
})
}
for index, item := range change.PersonalDevices {
if item.Binding == nil || item.Binding.ID == 0 {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "个人客户设备绑定审计资源不完整")
}
relation := item.Relation
if relation == "" {
relation = constants.AuditResourceRelationAffected
}
role := item.Role
if role == "" {
role = constants.AuditResourceRolePersonalCustomerAssetBinding
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourcePersonalCustomerDevice, ID: optionalResourceID(item.Binding.ID),
Key: strconv.FormatUint(uint64(item.Binding.ID), 10), DisplayName: item.Binding.VirtualNo,
Relation: relation, Role: role, IdentitySnapshot: personalCustomerDeviceIdentity(item.Binding),
BeforeData: item.BeforeData, AfterData: item.AfterData,
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
})
}
for index, item := range change.PersonalICCIDs {
if item.Binding == nil || item.Binding.ID == 0 {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "个人客户 ICCID 绑定审计资源不完整")
}
relation := item.Relation
if relation == "" {
relation = constants.AuditResourceRelationAffected
}
role := item.Role
if role == "" {
role = constants.AuditResourceRolePersonalCustomerAssetBinding
}
resources = append(resources, ResourceInput{
Type: constants.AuditResourcePersonalCustomerICCID, ID: optionalResourceID(item.Binding.ID),
Key: strconv.FormatUint(uint64(item.Binding.ID), 10), DisplayName: item.Binding.ICCID,
Relation: relation, Role: role, IdentitySnapshot: personalCustomerICCIDIdentity(item.Binding),
BeforeData: item.BeforeData, AfterData: item.AfterData,
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index + 1,
})
}
if primaryResource == constants.AuditResourceRole {
if change.Role == nil || (change.Role.ID == 0 && change.Role.RoleName == "") {
return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "角色审计资源不完整")
@@ -482,6 +522,16 @@ func iotCardIdentity(card *model.IotCard) map[string]any {
}
}
// IotCardIdentitySnapshot 返回统一 Registry 允许的 IoT 卡身份快照。
func IotCardIdentitySnapshot(card *model.IotCard) map[string]any {
return iotCardIdentity(card)
}
// IotCardResourceKey 返回 IoT 卡审计使用的稳定资源 Key。
func IotCardResourceKey(card *model.IotCard) string {
return iotCardResourceKey(card)
}
func deviceResourceKey(device *model.Device) string {
if device.ID != 0 {
return strconv.FormatUint(uint64(device.ID), 10)
@@ -492,10 +542,22 @@ func deviceResourceKey(device *model.Device) string {
func deviceIdentity(device *model.Device) map[string]any {
return map[string]any{
"id": device.ID, "virtual_no": device.VirtualNo, "imei": device.IMEI,
"sn": device.SN, "generation": device.Generation,
"sn": device.SN, "device_name": device.DeviceName, "device_model": device.DeviceModel,
"device_type": device.DeviceType, "manufacturer": device.Manufacturer,
"shop_id": device.ShopID, "series_id": device.SeriesID, "generation": device.Generation,
}
}
// DeviceIdentitySnapshot 返回统一 Registry 允许的设备身份快照。
func DeviceIdentitySnapshot(device *model.Device) map[string]any {
return deviceIdentity(device)
}
// DeviceResourceKey 返回设备审计使用的稳定资源 Key。
func DeviceResourceKey(device *model.Device) string {
return deviceResourceKey(device)
}
func deviceSimBindingIdentity(binding *model.DeviceSimBinding) map[string]any {
return map[string]any{
"id": binding.ID, "device_id": binding.DeviceID, "slot_position": binding.SlotPosition,
@@ -541,6 +603,21 @@ func personalCustomerOpenIDIdentity(openID *model.PersonalCustomerOpenID) map[st
}
}
func personalCustomerDeviceIdentity(binding *model.PersonalCustomerDevice) map[string]any {
return map[string]any{
"id": binding.ID, "customer_id": binding.CustomerID, "virtual_no": binding.VirtualNo,
"bind_at": binding.BindAt, "last_used_at": binding.LastUsedAt, "status": binding.Status,
}
}
func personalCustomerICCIDIdentity(binding *model.PersonalCustomerICCID) map[string]any {
return map[string]any{
"id": binding.ID, "customer_id": binding.CustomerID, "iccid": binding.ICCID,
"iccid_19": binding.ICCID19, "bind_at": binding.BindAt,
"last_used_at": binding.LastUsedAt, "status": binding.Status,
}
}
func roleResource(role *model.Role, beforeData, afterData map[string]any) ResourceInput {
return ResourceInput{
Type: constants.AuditResourceRole, ID: optionalResourceID(role.ID), Key: roleResourceKey(role), DisplayName: role.RoleName,
@@ -706,6 +783,14 @@ func (w *Writer) WriteConfigChange(ctx context.Context, tx *gorm.DB, change syst
if result == "" {
result = constants.AuditResultSuccess
}
displayName := change.DisplayName
if displayName == "" {
displayName = change.ConfigKey
}
identity := change.Identity
if identity == nil {
identity = map[string]any{"config_key": change.ConfigKey, "module": change.Module}
}
return w.Append(ctx, tx, AppendInput{
ActionCode: action.Code, Summary: change.Description,
Actor: ActorInput{
@@ -719,9 +804,9 @@ func (w *Writer) WriteConfigChange(ctx context.Context, tx *gorm.DB, change syst
ErrorCode: change.ErrorCode, ErrorSummary: change.ErrorSummary,
RequestID: change.RequestID, CorrelationID: change.CorrelationID,
Resources: []ResourceInput{{
Type: action.PrimaryResource, Key: change.ConfigKey, DisplayName: change.ConfigKey,
Type: action.PrimaryResource, ID: change.ResourceID, Key: change.ConfigKey, DisplayName: displayName,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleConfig,
IdentitySnapshot: map[string]any{"config_key": change.ConfigKey, "module": change.Module},
IdentitySnapshot: identity,
BeforeData: change.BeforeData, AfterData: change.AfterData,
SubjectVisibility: action.DefaultVisibility,
}},
@@ -795,7 +880,7 @@ func (w *Writer) Append(ctx context.Context, tx *gorm.DB, input AppendInput) err
if !ok {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "审计动作未注册")
}
if input.Actor.Kind != action.AllowedActor || input.Actor.ID == "" || input.Source != action.Source {
if !actionAllowsOrigin(action, input.Actor.Kind, input.Source) || input.Actor.ID == "" {
return pkgerrors.New(pkgerrors.CodeInvalidParam, "审计操作者或入口不符合动作注册规则")
}
if !validResult(input.Result) || len(input.Resources) == 0 {
@@ -853,6 +938,18 @@ func (w *Writer) Append(ctx context.Context, tx *gorm.DB, input AppendInput) err
return nil
}
func actionAllowsOrigin(action ActionDefinition, actor, source string) bool {
if action.AllowedActor == actor && action.Source == source {
return true
}
for _, origin := range action.AllowedOrigins {
if origin.Actor == actor && origin.Source == source {
return true
}
}
return false
}
func fillFromContext(ctx context.Context, input AppendInput) AppendInput {
value := auditcontext.From(ctx)
if input.Actor.Kind == "" {

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

@@ -2,11 +2,17 @@ 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"
)
@@ -15,15 +21,20 @@ import (
type CleanupService struct {
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, "查询待清理站内通知失败")
}
total += result.RowsAffected
if result.RowsAffected < constants.NotificationCleanupBatchSize {
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 += 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

@@ -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,
})
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,
})
if err != nil {
return err
}
return w.audit.WriteAgentWalletReservation(ctx, tx, event)
}

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"
@@ -36,12 +37,17 @@ type ApprovalRecoveryRecord struct {
// ApprovalContextRepository 管理企微提交的领取、终态和结果未知状态。
type ApprovalContextRepository struct {
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

@@ -22,6 +22,7 @@ type ApprovalDetail struct {
SPNo string
SPStatus int
Snapshot []byte
IntegrationID string
}
// ApprovalDetailClient 获取企业微信审批申请详情。
@@ -93,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

@@ -71,6 +71,7 @@ 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
}
@@ -100,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
}

View File

@@ -32,6 +32,7 @@ type ApprovalInfoQuery struct {
type ApprovalInfoPage struct {
SPNos []string
NextCursor string
IntegrationID string
}
// ApprovalInfoClient 按提交时间窗批量获取企业微信审批单号。
@@ -116,7 +117,7 @@ func (c *ApprovalInfoClient) List(ctx context.Context, input ApprovalInfoQuery)
},
DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
return ApprovalInfoPage{SPNos: spNos, NextCursor: strings.TrimSpace(result.NewNextCursor)}, err
return ApprovalInfoPage{SPNos: spNos, NextCursor: strings.TrimSpace(result.NewNextCursor), IntegrationID: attempt.IntegrationID}, err
}
func validateApprovalInfoQuery(input ApprovalInfoQuery) error {

View File

@@ -67,20 +67,26 @@ func (h *ApprovalRecoveryTaskHandler) recoverUnknown(ctx context.Context, now ti
if !claimed {
continue
}
spNo, err := h.contexts.FindSuccessfulSubmissionSPNo(ctx, record.InstanceID)
spNo, submissionIntegrationID, err := h.contexts.FindSuccessfulSubmissionSPNo(ctx, record.InstanceID)
if err != nil {
return err
}
integrationIDs := []string{submissionIntegrationID}
if spNo == "" {
spNo, err = h.findUniqueSPNo(ctx, record, now)
var recoveryIntegrationIDs []string
spNo, recoveryIntegrationIDs, err = h.findUniqueSPNo(ctx, record, now)
if err != nil {
return err
}
integrationIDs = append(integrationIDs, recoveryIntegrationIDs...)
}
if spNo == "" {
continue
}
recovered, err := h.contexts.RecoverSubmitted(ctx, record.InstanceID, spNo)
recovered, err := h.contexts.RecoverSubmitted(
ctx, record.InstanceID, spNo, integrationIDs,
constants.AuditActorScheduledJob, constants.ApprovalAuditActorRecoveryJob, constants.AuditSourceScheduler,
)
if err != nil {
return err
}
@@ -94,19 +100,20 @@ func (h *ApprovalRecoveryTaskHandler) recoverUnknown(ctx context.Context, now ti
}
// findUniqueSPNo 使用提交时间附近的固定窄窗口分页查询,并只接受唯一未关联候选。
func (h *ApprovalRecoveryTaskHandler) findUniqueSPNo(ctx context.Context, record ApprovalRecoveryRecord, now time.Time) (string, error) {
func (h *ApprovalRecoveryTaskHandler) findUniqueSPNo(ctx context.Context, record ApprovalRecoveryRecord, now time.Time) (string, []string, error) {
startTime := record.SubmissionAttemptedAt.Add(-constants.WeComApprovalRecoveryWindow)
endTime := record.SubmissionAttemptedAt.Add(constants.WeComApprovalRecoveryWindow)
if endTime.After(now) {
endTime = now
}
if !startTime.Before(endTime) {
return "", nil
return "", nil, nil
}
cursor := ""
seenCursors := make(map[string]struct{})
seenCandidates := make(map[string]struct{})
unbound := make([]string, 0, 2)
integrationIDs := make([]string, 0, 2)
for {
page, err := h.infos.List(ctx, ApprovalInfoQuery{
ApplicationID: record.ApplicationID, StartTime: startTime, EndTime: endTime,
@@ -114,11 +121,12 @@ func (h *ApprovalRecoveryTaskHandler) findUniqueSPNo(ctx context.Context, record
Cursor: cursor, Size: constants.WeComApprovalInfoMaxPageSize,
})
if err != nil {
return "", err
return "", nil, err
}
integrationIDs = append(integrationIDs, page.IntegrationID)
existing, err := h.contexts.ExistingSPNos(ctx, record.ApplicationID, page.SPNos)
if err != nil {
return "", err
return "", nil, err
}
for _, candidate := range page.SPNos {
if _, seen := seenCandidates[candidate]; seen {
@@ -128,7 +136,7 @@ func (h *ApprovalRecoveryTaskHandler) findUniqueSPNo(ctx context.Context, record
if _, exists := existing[candidate]; !exists {
unbound = append(unbound, candidate)
if len(unbound) > 1 {
return "", nil
return "", integrationIDs, nil
}
}
}
@@ -137,15 +145,15 @@ func (h *ApprovalRecoveryTaskHandler) findUniqueSPNo(ctx context.Context, record
break
}
if _, exists := seenCursors[next]; exists {
return "", errors.New(errors.CodeServiceUnavailable, "企业微信批量审批单号分页游标重复")
return "", nil, errors.New(errors.CodeServiceUnavailable, "企业微信批量审批单号分页游标重复")
}
seenCursors[next] = struct{}{}
cursor = next
}
if len(unbound) != 1 {
return "", nil
return "", integrationIDs, nil
}
return unbound[0], nil
return unbound[0], integrationIDs, nil
}
func (h *ApprovalRecoveryTaskHandler) enqueueDetailSync(ctx context.Context, applicationID uint, spNo string) error {

View File

@@ -39,6 +39,7 @@ type ApprovalSubmitResult struct {
SPNo string
Message string
SafeToRetry bool
IntegrationID string
}
// ApprovalSubmissionClient 调用企微 applyevent 并记录每次真实外呼。
@@ -98,9 +99,9 @@ func (c *ApprovalSubmissionClient) Submit(ctx context.Context, input ApprovalSub
RecoveryStrategy: "按申请时间窗批量获取审批单号并核对详情,确认不存在后才允许受控重提",
})
if completeErr != nil {
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, completeErr
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message, IntegrationID: attempt.IntegrationID}, completeErr
}
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, nil
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message, IntegrationID: attempt.IntegrationID}, nil
}
defer response.Body.Close()
responseBody, readErr := io.ReadAll(io.LimitReader(response.Body, constants.WeComMaxResponseBodyBytes+1))
@@ -113,9 +114,9 @@ func (c *ApprovalSubmissionClient) Submit(ctx context.Context, input ApprovalSub
RecoveryStrategy: "按申请时间窗批量获取审批单号并核对详情,确认不存在后才允许受控重提",
})
if completeErr != nil {
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, completeErr
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message, IntegrationID: attempt.IntegrationID}, completeErr
}
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message}, nil
return ApprovalSubmitResult{Outcome: submissionOutcomeUnknown, Message: message, IntegrationID: attempt.IntegrationID}, nil
}
var result struct {
ErrCode int64 `json:"errcode"`
@@ -141,9 +142,9 @@ func (c *ApprovalSubmissionClient) Submit(ctx context.Context, input ApprovalSub
DurationMS: c.now().Sub(startedAt).Milliseconds(),
})
if completeErr != nil {
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: message}, completeErr
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: message, IntegrationID: attempt.IntegrationID}, completeErr
}
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: message}, nil
return ApprovalSubmitResult{Outcome: submissionOutcomeFailed, Message: message, IntegrationID: attempt.IntegrationID}, nil
}
_, err = c.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{
Result: constants.IntegrationResultSuccess, HTTPStatus: response.StatusCode,
@@ -151,7 +152,7 @@ func (c *ApprovalSubmissionClient) Submit(ctx context.Context, input ApprovalSub
ResponseSummary: map[string]any{"success": true, "sp_no": strings.TrimSpace(result.SPNo)},
DurationMS: c.now().Sub(startedAt).Milliseconds(), StateChanged: true,
})
return ApprovalSubmitResult{Outcome: submissionOutcomeSuccess, SPNo: strings.TrimSpace(result.SPNo)}, err
return ApprovalSubmitResult{Outcome: submissionOutcomeSuccess, SPNo: strings.TrimSpace(result.SPNo), IntegrationID: attempt.IntegrationID}, err
}
// newSubmitRequest 只组装本次企微审批请求,调用前不会产生外部副作用。

View File

@@ -59,7 +59,7 @@ func (c *ApprovalSubmissionConsumer) Consume(ctx context.Context, envelope outbo
}
return err
}
if markErr := c.repository.MarkFailed(ctx, event.InstanceID, err.Error()); markErr != nil {
if markErr := c.repository.MarkFailed(ctx, event.InstanceID, err.Error(), ""); markErr != nil {
return markErr
}
return nil
@@ -71,11 +71,11 @@ func (c *ApprovalSubmissionConsumer) Consume(ctx context.Context, envelope outbo
})
switch result.Outcome {
case submissionOutcomeSuccess:
if err := c.repository.MarkSubmitted(ctx, event.InstanceID, result.SPNo); err != nil {
if err := c.repository.MarkSubmitted(ctx, event.InstanceID, result.SPNo, result.IntegrationID); err != nil {
return err
}
case submissionOutcomeUnknown:
if err := c.repository.MarkUnknown(ctx, event.InstanceID, result.Message); err != nil {
if err := c.repository.MarkUnknown(ctx, event.InstanceID, result.Message, result.IntegrationID); err != nil {
return err
}
case submissionOutcomeFailed:
@@ -88,7 +88,7 @@ func (c *ApprovalSubmissionConsumer) Consume(ctx context.Context, envelope outbo
}
return errors.New(errors.CodeServiceUnavailable, result.Message)
}
if err := c.repository.MarkFailed(ctx, event.InstanceID, result.Message); err != nil {
if err := c.repository.MarkFailed(ctx, event.InstanceID, result.Message, result.IntegrationID); err != nil {
return err
}
default:

View File

@@ -24,28 +24,25 @@ func NewMemberRepository(db *gorm.DB) *MemberRepository {
}
// ReplaceVisible 原子替换指定应用当前可见成员,历史不可见成员仅标记为不可见。
func (r *MemberRepository) ReplaceVisible(ctx context.Context, applicationID uint, members []model.WeComMember, syncedAt time.Time) error {
if r == nil || r.db == nil {
func (r *MemberRepository) ReplaceVisible(ctx context.Context, tx *gorm.DB, applicationID uint, members []model.WeComMember, syncedAt time.Time) error {
if r == nil || tx == nil {
return errors.New(errors.CodeDatabaseError, "企业微信成员存储未配置")
}
err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Model(&model.WeComMember{}).Where("application_id = ?", applicationID).
if err := tx.WithContext(ctx).Model(&model.WeComMember{}).Where("application_id = ?", applicationID).
Updates(map[string]any{"visible": false, "updated_at": syncedAt}).Error; err != nil {
return err
return errors.Wrap(errors.CodeDatabaseError, err, "同步企业微信可见成员失败")
}
if len(members) == 0 {
return nil
}
return tx.Clauses(clause.OnConflict{
if err := tx.WithContext(ctx).Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "application_id"}, {Name: "userid"}},
DoUpdates: clause.Assignments(map[string]any{
"corp_id": gorm.Expr("EXCLUDED.corp_id"), "name": gorm.Expr("EXCLUDED.name"),
"department_ids": gorm.Expr("EXCLUDED.department_ids"), "visible": true,
"synced_at": gorm.Expr("EXCLUDED.synced_at"), "updated_at": syncedAt,
}),
}).CreateInBatches(&members, constants.WeComMemberSyncBatchSize).Error
})
if err != nil {
}).CreateInBatches(&members, constants.WeComMemberSyncBatchSize).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "同步企业微信可见成员失败")
}
return nil

View File

@@ -37,6 +37,9 @@ type Order struct {
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);comment:支付方式 wallet-钱包 wechat-微信 alipay-支付宝" json:"payment_method"`
PaymentStatus int `gorm:"column:payment_status;type:int;default:1;not null;index:idx_order_payment_status;comment:支付状态 1-待支付 2-已支付 3-已取消 4-已退款" json:"payment_status"`
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"`
// AssetWalletReservationWalletID 和 AssetWalletReservedAmount 共同记录个人钱包订单的资金预占事实。
AssetWalletReservationWalletID *uint `gorm:"column:asset_wallet_reservation_wallet_id;comment:个人钱包订单预占的资产钱包ID无外键" json:"-"`
AssetWalletReservedAmount int64 `gorm:"column:asset_wallet_reserved_amount;type:bigint;not null;default:0;comment:个人钱包订单预占金额0表示历史未预占订单" json:"-"`
// 佣金信息
CommissionStatus int `gorm:"column:commission_status;type:int;default:1;not null;comment:佣金流程状态 1-待计算 2-已完成 3-待人工处理" json:"commission_status"`

View File

@@ -13,10 +13,14 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
"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"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// orphanPackageScanLimit 单轮最多恢复的真实孤儿载体数量。
const orphanPackageScanLimit = 100
// TrafficSyncer 套餐失效前流量同步接口
// 在主套餐标记为已过期之前,从 Gateway 拉取最新流量写入 DB
type TrafficSyncer interface {
@@ -156,17 +160,48 @@ func (h *PackageActivationHandler) HandlePackageActivationCheck(ctx context.Cont
}
// findAndActivateOrphanPackages 任务 6.1-6.2: 查找孤儿载体并触发激活
// 孤儿定义:存在 status=0 的主套餐,但不存在 status=1 的主套餐。
// 孤儿定义:存在待生效主套餐,但不存在生效中或已用完的占位主套餐。
func (h *PackageActivationHandler) findAndActivateOrphanPackages(ctx context.Context) (int, error) {
// 查询孤儿待生效主套餐(无生效主套餐但有待生效主套餐)
var orphanUsages []*model.PackageUsage
err := h.db.WithContext(ctx).
Where("status = ?", constants.PackageUsageStatusPending).
Where("master_usage_id IS NULL").
Where("deleted_at IS NULL").
Order("priority ASC, created_at ASC").
Limit(100).
Find(&orphanUsages).Error
err := h.db.WithContext(ctx).Raw(`
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 = ?
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 (?, ?)
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 ?`,
constants.PackageUsageStatusPending,
constants.PackageUsageStatusActive,
constants.PackageUsageStatusDepleted,
orphanPackageScanLimit,
).Scan(&orphanUsages).Error
if err != nil {
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询孤儿套餐失败")
}
@@ -175,78 +210,32 @@ func (h *PackageActivationHandler) findAndActivateOrphanPackages(ctx context.Con
return 0, nil
}
// 按载体分组,去重
type carrierKey struct {
carrierType string
carrierID uint
}
carrierMap := make(map[carrierKey]*model.PackageUsage) // 保留 priority 最低的套餐
for _, usage := range orphanUsages {
key := carrierKey{}
if usage.IotCardID > 0 {
key.carrierType = "iot_card"
key.carrierID = usage.IotCardID
} else if usage.DeviceID > 0 {
key.carrierType = "device"
key.carrierID = usage.DeviceID
} else {
continue
}
// 检查该载体是否已有占位主套餐(生效中或已用完均视为占位,不允许激活待生效套餐)
var activeCount int64
var countErr error
occupiedStatuses := []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}
if key.carrierType == "iot_card" {
countErr = h.db.WithContext(ctx).
Model(&model.PackageUsage{}).
Where("status IN ?", occupiedStatuses).
Where("master_usage_id IS NULL").
Where("iot_card_id = ?", key.carrierID).
Count(&activeCount).Error
} else {
countErr = h.db.WithContext(ctx).
Model(&model.PackageUsage{}).
Where("status IN ?", occupiedStatuses).
Where("master_usage_id IS NULL").
Where("device_id = ?", key.carrierID).
Count(&activeCount).Error
}
if countErr != nil {
h.logger.Warn("检查载体生效套餐失败",
zap.String("carrier_type", key.carrierType),
zap.Uint("carrier_id", key.carrierID),
zap.Error(countErr))
continue
}
// 已有生效或已用完(占位)套餐,跳过
if activeCount > 0 {
continue
}
// 保留购买顺序最靠前的套餐,队首未满足实名条件时不跳过。
if existing, ok := carrierMap[key]; ok {
if usage.Priority < existing.Priority || (usage.Priority == existing.Priority && usage.CreatedAt.Before(existing.CreatedAt)) {
carrierMap[key] = usage
}
} else {
carrierMap[key] = usage
}
}
// 为每个孤儿载体提交激活任务
count := 0
for key, usage := range carrierMap {
if err := h.enqueueActivationTask(ctx, usage.ID, key.carrierType, key.carrierID, "orphan_recovery"); err != nil {
h.logger.Warn("提交孤儿套餐激活任务失败",
for _, usage := range orphanUsages {
carrierType, carrierID := h.getCarrierInfo(usage)
activated, activationErr := h.activationService.ActivateNextPendingMainPackage(ctx, carrierType, carrierID)
if activationErr != nil {
h.logger.Warn("孤儿套餐同步激活失败",
zap.Uint("package_usage_id", usage.ID),
zap.String("carrier_type", key.carrierType),
zap.Uint("carrier_id", key.carrierID),
zap.Error(err))
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.String("activation_source", "orphan_recovery"),
zap.Error(activationErr))
continue
}
if !activated {
h.logger.Info("孤儿套餐本轮未激活",
zap.Uint("package_usage_id", usage.ID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.String("activation_source", "orphan_recovery"))
continue
}
h.logger.Info("孤儿套餐同步激活成功",
zap.Uint("package_usage_id", usage.ID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.String("activation_source", "orphan_recovery"))
count++
}
@@ -269,7 +258,7 @@ func (h *PackageActivationHandler) findExpiredMainPackages(ctx context.Context)
}
// processExpiredPackage 处理单个过期套餐
// 流程:先同步最新流量 → 事务内标记过期/失效/激活下一个事务提交后触发停机
// 流程:先同步最新流量 → 事务内标记过期失效加油包 → 提交后同步接续并触发停机检查
func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pkg *model.PackageUsage) error {
carrierType, carrierID := h.getCarrierInfo(pkg)
@@ -278,11 +267,19 @@ func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pk
h.syncTrafficBeforeExpiry(ctx, carrierType, carrierID)
}
expired := false
err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 任务 19.3: 更新过期主套餐状态为 Expired (status=3)
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusExpired).Error; err != nil {
return err
result := tx.Model(pkg).
Where("status IN ? AND expires_at <= ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}, time.Now()).
Update("status", constants.PackageUsageStatusExpired)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return nil
}
expired = true
expiresAt := time.Now()
if pkg.ExpiresAt != nil {
@@ -293,52 +290,85 @@ func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pk
zap.Time("expires_at", expiresAt))
// 任务 19.4: 加油包级联失效
if err := h.invalidateAddons(ctx, tx, pkg.ID); err != nil {
h.logger.Warn("加油包级联失效失败",
zap.Uint("master_usage_id", pkg.ID),
zap.Error(err))
addons, err := h.invalidateAddons(ctx, tx, pkg.ID)
if err != nil {
return err
}
// 任务 19.5: 查询并激活下一个待生效主套餐
if carrierType != "" && carrierID > 0 {
if err := h.activateNextPackage(ctx, tx, carrierType, carrierID); err != nil {
h.logger.Warn("激活下一个待生效套餐失败",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.Error(err))
if h.activationService == nil {
return errors.New(errors.CodeInternalError, "套餐激活服务未注入")
}
if err := h.activationService.AppendExpirationAudit(ctx, tx, pkg, addons); err != nil {
return err
}
return nil
})
if err != nil {
if h.activationService != nil {
h.activationService.RecordUsageFailure(ctx, constants.AuditActionPackageUsageExpired, "套餐权益到期处理失败", pkg, err)
}
return err
}
if !expired {
return nil
}
// 事务提交后再触发异步停机,确保 CheckAndStopCard 读到最新的套餐状态
if carrierType != "" && carrierID > 0 {
activated, activationErr := h.activationService.ActivateNextPendingMainPackage(ctx, carrierType, carrierID)
if activationErr != nil {
h.logger.Warn("过期后同步接续套餐失败",
zap.Uint("expired_package_usage_id", pkg.ID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.String("activation_source", "expired_package"),
zap.Error(activationErr))
} else if activated {
h.logger.Info("过期后同步接续套餐成功",
zap.Uint("expired_package_usage_id", pkg.ID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.String("activation_source", "expired_package"))
} else {
h.logger.Info("过期后本轮未接续套餐",
zap.Uint("expired_package_usage_id", pkg.ID),
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.String("activation_source", "expired_package"))
}
h.triggerStopAfterExpiry(ctx, carrierType, carrierID)
return activationErr
}
return nil
}
// invalidateAddons 任务 19.4: 加油包级联失效
func (h *PackageActivationHandler) invalidateAddons(ctx context.Context, tx *gorm.DB, masterUsageID uint) error {
func (h *PackageActivationHandler) invalidateAddons(ctx context.Context, tx *gorm.DB, masterUsageID uint) ([]*model.PackageUsage, error) {
// 查询主套餐下的所有加油包status IN (0,1,2) 的加油包)
result := tx.Model(&model.PackageUsage{}).
var addons []*model.PackageUsage
if err := tx.WithContext(ctx).
Where("master_usage_id = ?", masterUsageID).
Where("status IN ?", []int{
constants.PackageUsageStatusPending,
constants.PackageUsageStatusActive,
constants.PackageUsageStatusDepleted,
}).
}).Find(&addons).Error; err != nil {
return nil, err
}
if len(addons) == 0 {
return nil, nil
}
ids := make([]uint, 0, len(addons))
for _, addon := range addons {
ids = append(ids, addon.ID)
}
result := tx.Model(&model.PackageUsage{}).
Where("id IN ? AND status IN ?", ids, []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
Update("status", constants.PackageUsageStatusInvalidated)
if result.Error != nil {
return result.Error
return nil, result.Error
}
if result.RowsAffected > 0 {
@@ -347,7 +377,7 @@ func (h *PackageActivationHandler) invalidateAddons(ctx context.Context, tx *gor
zap.Int64("invalidated_count", result.RowsAffected))
}
return nil
return addons, nil
}
// getCarrierInfo 获取载体信息
@@ -361,34 +391,6 @@ func (h *PackageActivationHandler) getCarrierInfo(pkg *model.PackageUsage) (stri
return "", 0
}
// activateNextPackage 任务 19.5: 激活下一个待生效主套餐
func (h *PackageActivationHandler) activateNextPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) error {
// 查询下一个待生效主套餐
// WHERE status=0 AND master_usage_id IS NULL ORDER BY priority ASC LIMIT 1
var nextPkg model.PackageUsage
query := tx.Where("status = ?", constants.PackageUsageStatusPending).
Where("master_usage_id IS NULL"). // 主套餐
Order("priority ASC").
Limit(1)
if carrierType == "iot_card" {
query = query.Where("iot_card_id = ?", carrierID)
} else if carrierType == "device" {
query = query.Where("device_id = ?", carrierID)
}
if err := query.First(&nextPkg).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// 没有待生效套餐,正常情况
return nil
}
return err
}
// 提交 Asynq 任务进行激活(避免长事务)
return h.enqueueActivationTask(ctx, nextPkg.ID, carrierType, carrierID, "queue")
}
// triggerStopAfterExpiry 套餐过期后异步触发停机检查
// 仅在确认无后续生效套餐时有效CheckAndStopCard 内部有幂等保护,重复调用安全
func (h *PackageActivationHandler) triggerStopAfterExpiry(ctx context.Context, carrierType string, carrierID uint) {
@@ -472,6 +474,10 @@ func (h *PackageActivationHandler) HandlePackageQueueActivation(ctx context.Cont
h.logger.Error("解析套餐激活任务载荷失败", zap.Error(err))
return nil // 不重试
}
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypePackageQueueActivation,
ActorName: "套餐排队激活任务", Source: constants.AuditSourceWorker,
})
h.logger.Info("开始执行套餐激活",
zap.Uint("package_usage_id", payload.PackageUsageID),
@@ -525,6 +531,10 @@ func (h *PackageActivationHandler) HandlePackageFirstActivation(ctx context.Cont
h.logger.Error("解析首次实名激活任务载荷失败", zap.Error(err))
return nil
}
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypePackageFirstActivation,
ActorName: "套餐首次实名激活任务", Source: constants.AuditSourceWorker,
})
if payload.CarrierType == "" || payload.CarrierID == 0 {
h.logger.Error("首次实名激活任务 carrier 信息缺失",

View File

@@ -10,6 +10,7 @@ import (
"go.uber.org/zap"
packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
@@ -224,6 +225,10 @@ func (s *Scheduler) processOneShard(ctx context.Context, shardID int) {
// processActivationTasks 套餐激活检查和流量重置调度(每 10 秒触发)
func (s *Scheduler) processActivationTasks(ctx context.Context) {
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorScheduledJob, ActorID: constants.AuditActorIDPackageLifecycleScheduler,
ActorName: "套餐权益生命周期计划任务", Source: constants.AuditSourceScheduler,
})
if s.packageActivationHandler != nil {
if err := s.packageActivationHandler.HandlePackageActivationCheck(ctx); err != nil {
s.logger.Warn("套餐激活检查失败", zap.Error(err))

View File

@@ -53,6 +53,7 @@ type Service struct {
operationPasswordService OperationPasswordServiceInterface
redis *redis.Client
logger *zap.Logger
rechargeAudit agentrechargeapp.RechargeAuditWriter
}
// New 创建代理预充值服务实例
@@ -90,6 +91,11 @@ func (s *Service) SetOfflineCreationService(service *agentrechargeapp.OfflineCre
s.offlineCreation = service
}
// SetRechargeAudit 注入代理充值统一审计 Writer。
func (s *Service) SetRechargeAudit(writer agentrechargeapp.RechargeAuditWriter) {
s.rechargeAudit = writer
}
// Create 创建代理充值订单
// POST /api/admin/agent-recharges
func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeRequest) (*dto.AgentRechargeResponse, error) {
@@ -215,33 +221,22 @@ func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOffline
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
if _, err := s.agentWalletPosting.PostInTx(ctx, tx, walletapp.PostingCommand{
_, err := s.agentWalletPosting.PostInTx(ctx, tx, walletapp.PostingCommand{
ShopID: record.ShopID, WalletID: record.AgentWalletID, Amount: record.Amount,
ReferenceType: constants.ReferenceTypeTopup, ReferenceID: record.ID,
TransactionType: constants.AgentTransactionTypeRecharge, UserID: userID, Creator: userID,
Remark: "线下充值确认", RequestID: requestID, CorrelationID: record.RechargeNo,
}); err != nil {
})
if err != nil {
return err
}
return nil
return s.appendCreditedAudit(ctx, tx, record, nil, constants.RechargeStatusCompleted, "线下充值确认已入账")
})
if err != nil {
return nil, err
}
// 异步记录审计日志
go s.auditService.LogOperation(ctx, &model.AccountOperationLog{
OperatorID: userID,
OperatorType: userType,
OperationType: "offline_recharge_confirm",
OperationDesc: fmt.Sprintf("确认线下充值,充值单号: %s金额: %d分", record.RechargeNo, record.Amount),
RequestID: middleware.GetRequestIDFromContext(ctx),
IPAddress: middleware.GetIPFromContext(ctx),
UserAgent: middleware.GetUserAgentFromContext(ctx),
})
shop, _ := s.shopStore.GetByID(ctx, record.ShopID)
shopName := ""
if shop != nil {
@@ -325,16 +320,16 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string,
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
if _, err := s.agentWalletPosting.PostInTx(ctx, tx, walletapp.PostingCommand{
_, err := s.agentWalletPosting.PostInTx(ctx, tx, walletapp.PostingCommand{
ShopID: record.ShopID, WalletID: record.AgentWalletID, Amount: record.Amount,
ReferenceType: constants.ReferenceTypeTopup, ReferenceID: record.ID,
TransactionType: constants.AgentTransactionTypeRecharge, UserID: record.UserID, Creator: record.UserID,
Remark: "在线支付充值", RequestID: requestID, CorrelationID: record.RechargeNo,
}); err != nil {
})
if err != nil {
return err
}
return nil
return s.appendCreditedAudit(ctx, tx, record, nil, constants.RechargeStatusCompleted, "代理充值支付回调已入账")
})
if err != nil {
@@ -391,11 +386,30 @@ func (s *Service) Reject(ctx context.Context, id uint, rejectionReason string) e
return errors.New(errors.CodeInvalidStatus, "该线下充值申请由企业微信审批决定,不能人工驳回")
}
if err := s.agentRechargeStore.UpdateStatusWithRejection(ctx, id, rejectionReason); err != nil {
if err == gorm.ErrRecordNotFound {
if s.rechargeAudit == nil {
return errors.New(errors.CodeInvalidStatus, "代理充值统一审计接缝未配置")
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status = ?", record.ID, constants.RechargeStatusPending).
Updates(map[string]any{"status": constants.RechargeStatusRejected, "rejection_reason": strings.TrimSpace(rejectionReason)})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "驳回充值订单失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeInvalidStatus, "仅待支付订单可驳回")
}
return errors.Wrap(errors.CodeDatabaseError, err, "驳回充值订单失败")
after := *record
after.Status = constants.RechargeStatusRejected
reason := strings.TrimSpace(rejectionReason)
after.RejectionReason = &reason
return s.rechargeAudit.WriteAgentRecharge(ctx, tx, agentrechargeapp.RechargeAudit{
ActionCode: constants.AuditActionAgentRechargeClosed, Summary: "驳回代理充值申请",
Record: &after, BeforeData: map[string]any{"status": record.Status},
AfterData: map[string]any{"status": after.Status, "rejection_reason": reason},
})
}); err != nil {
return err
}
s.logger.Info("代理充值订单驳回成功",
@@ -405,6 +419,29 @@ func (s *Service) Reject(ctx context.Context, id uint, rejectionReason string) e
return nil
}
func (s *Service) appendCreditedAudit(ctx context.Context, tx *gorm.DB, record *model.AgentRechargeRecord, payment *model.Payment, status int, summary string) error {
if s.rechargeAudit == nil {
return errors.New(errors.CodeInvalidStatus, "代理充值统一审计接缝未配置")
}
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, "查询代理充值入账流水审计快照失败")
}
after := *record
after.Status = status
return s.rechargeAudit.WriteAgentRecharge(ctx, tx, agentrechargeapp.RechargeAudit{
ActionCode: constants.AuditActionAgentRechargeCredited, Summary: summary,
Record: &after, Payment: payment, Wallet: &wallet, Transaction: &transaction,
BeforeData: map[string]any{"status": record.Status}, AfterData: map[string]any{"status": status},
})
}
// GetByID 根据ID查询充值订单详情
// GET /api/admin/agent-recharges/:id
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.AgentRechargeResponse, error) {

View File

@@ -0,0 +1,47 @@
package asset_package_batch_order
import (
"context"
"strconv"
"gorm.io/gorm"
"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/middleware"
)
func (s *Service) writeBatchOrderTaskAudit(ctx context.Context, tx *gorm.DB, task *model.AssetPackageBatchOrderTask, before, after map[string]any, result, phase, errorCode, errorSummary string) error {
scopeType, scopeID := constants.AuditScopePlatform, ""
if task.CreatorShopID != 0 {
scopeType, scopeID = constants.AuditScopeShop, strconv.FormatUint(uint64(task.CreatorShopID), 10)
}
return s.auditWriter.WriteTask(ctx, tx, audit.TaskInput{
EventID: audit.TaskEventID(constants.AuditResourceAssetPackageBatchOrderTask, task.ID, phase),
ActionCode: constants.AuditActionAssetPackageBatchOrderTaskCreated,
Summary: "创建资产套餐批量订购任务", TaskID: task.ID, TaskNo: task.TaskNo,
Actor: audit.ActorInput{
Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(middleware.GetUserIDFromContext(ctx)), 10),
Name: middleware.GetUsernameFromContext(ctx),
},
Source: constants.AuditSourceAdminAPI, ScopeType: scopeType, ScopeID: scopeID,
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
IdentitySnapshot: map[string]any{
"id": task.ID, "task_no": task.TaskNo, "file_name": task.FileName,
"package_id": task.PackageID, "package_code": task.PackageCode,
"package_name": task.PackageName, "payment_method": task.PaymentMethod,
},
BeforeData: before, AfterData: after,
})
}
func batchOrderTaskState(task *model.AssetPackageBatchOrderTask) map[string]any {
if task == nil {
return nil
}
return map[string]any{
"status": task.Status, "total_count": task.TotalCount,
"success_count": task.SuccessCount, "fail_count": task.FailCount,
}
}

View File

@@ -4,17 +4,20 @@ package asset_package_batch_order
import (
"context"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/hibiken/asynq"
"gorm.io/gorm"
"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/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/asynctask"
"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"
@@ -26,11 +29,16 @@ type Service struct {
taskStore *postgres.AssetPackageBatchOrderTaskStore
packageStore *postgres.PackageStore
queueClient *queue.Client
auditWriter *audit.Writer
}
// New 创建资产套餐批量订购任务服务。
func New(taskStore *postgres.AssetPackageBatchOrderTaskStore, packageStore *postgres.PackageStore, queueClient *queue.Client) *Service {
return &Service{taskStore: taskStore, packageStore: packageStore, queueClient: queueClient}
func New(taskStore *postgres.AssetPackageBatchOrderTaskStore, packageStore *postgres.PackageStore, queueClient *queue.Client, auditWriters ...*audit.Writer) *Service {
service := &Service{taskStore: taskStore, packageStore: packageStore, queueClient: queueClient}
if len(auditWriters) > 0 {
service.auditWriter = auditWriters[0]
}
return service
}
// TaskPayload 批量订购 Worker 结构化载荷。
@@ -69,7 +77,15 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAssetPackageBatchOr
CreatorUserType: middleware.GetUserTypeFromContext(ctx), CreatorShopID: middleware.GetShopIDFromContext(ctx),
CreatorName: middleware.GetUsernameFromContext(ctx),
}
if err := s.taskStore.Create(ctx, task); err != nil {
if s.auditWriter == nil {
return nil, errors.New(errors.CodeInvalidStatus, "资产套餐批量订购统一审计接缝未配置")
}
if err := s.taskStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.taskStore.WithTx(tx).Create(ctx, task); err != nil {
return err
}
return s.writeBatchOrderTaskAudit(ctx, tx, task, nil, batchOrderTaskState(task), constants.AuditResultSuccess, "created", "", "")
}); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建批量订购任务失败")
}
var enqueueErr error
@@ -82,10 +98,19 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAssetPackageBatchOr
}
if enqueueErr != nil {
message := "批量订购任务入队失败"
_ = s.taskStore.MarkFailed(ctx, task.ID, message)
secondaryErr := s.taskStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
before := batchOrderTaskState(task)
if err := s.taskStore.WithTx(tx).MarkFailed(ctx, task.ID, message); err != nil {
return err
}
task.Status, task.ErrorMessage = asynctask.StatusFailed, message
now := time.Now()
task.CompletedAt = &now
return s.writeBatchOrderTaskAudit(ctx, tx, task, before, batchOrderTaskState(task), constants.AuditResultFailed, "enqueue_failed", strconv.Itoa(errors.CodeTaskQueueError), message)
})
if secondaryErr != nil {
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionAssetPackageBatchOrderTaskCreated, task.TaskNo, "", task.TaskNo, strconv.Itoa(errors.CodeTaskQueueError), secondaryErr)
}
}
return toResponse(task), nil
}

View File

@@ -2,14 +2,17 @@ package carrier
import (
"context"
"strconv"
"time"
"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/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"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"
@@ -17,10 +20,11 @@ import (
type Service struct {
carrierStore *postgres.CarrierStore
audit systemconfigapp.AuditWriter
}
func New(carrierStore *postgres.CarrierStore) *Service {
return &Service{carrierStore: carrierStore}
func New(carrierStore *postgres.CarrierStore, audit systemconfigapp.AuditWriter) *Service {
return &Service{carrierStore: carrierStore, audit: audit}
}
func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*dto.CarrierResponse, error) {
@@ -31,8 +35,12 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*d
existing, _ := s.carrierStore.GetByCode(ctx, req.CarrierCode)
if existing != nil {
s.recordDenied(ctx, constants.AuditOperationCarrierCreate, "拒绝创建重复运营商配置", existing, errors.CodeCarrierCodeExists)
return nil, errors.New(errors.CodeCarrierCodeExists, "运营商编码已存在")
}
if s.audit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "运营商配置审计接缝未配置")
}
carrier := &model.Carrier{
CarrierCode: req.CarrierCode,
@@ -53,7 +61,14 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*d
}
carrier.Creator = currentUserID
if err := s.carrierStore.Create(ctx, carrier); err != nil {
err := s.carrierStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.carrierStore.WithTx(tx).Create(ctx, carrier); err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationCarrierCreate, "创建运营商配置", nil, carrier)
})
if err != nil {
s.recordFailure(ctx, constants.AuditOperationCarrierCreate, "创建运营商配置失败", carrier)
return nil, errors.Wrap(errors.CodeInternalError, err, "创建运营商失败")
}
@@ -68,6 +83,10 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.CarrierResponse, error
}
return nil, errors.Wrap(errors.CodeInternalError, err, "获取运营商失败")
}
if s.audit == nil {
return nil, errors.New(errors.CodeInvalidStatus, "运营商配置审计接缝未配置")
}
before := *carrier
return s.toResponse(carrier), nil
}
@@ -101,11 +120,19 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierReq
carrier.RealnameLinkTemplate = *req.RealnameLinkTemplate
}
if carrier.RealnameLinkType == "template" && carrier.RealnameLinkTemplate == "" {
s.recordDenied(ctx, constants.AuditOperationCarrierUpdate, "拒绝保存非法运营商实名链接配置", &before, errors.CodeInvalidParam)
return nil, errors.New(errors.CodeInvalidParam, "模板URL类型必须提供实名链接模板")
}
carrier.Updater = currentUserID
if err := s.carrierStore.Update(ctx, carrier); err != nil {
err = s.carrierStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.carrierStore.WithTx(tx).Update(ctx, carrier); err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationCarrierUpdate, "更新运营商配置", &before, carrier)
})
if err != nil {
s.recordFailure(ctx, constants.AuditOperationCarrierUpdate, "更新运营商配置失败", carrier)
return nil, errors.Wrap(errors.CodeInternalError, err, "更新运营商失败")
}
@@ -113,15 +140,25 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierReq
}
func (s *Service) Delete(ctx context.Context, id uint) error {
_, err := s.carrierStore.GetByID(ctx, id)
carrier, err := s.carrierStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeCarrierNotFound, "运营商不存在")
}
return errors.Wrap(errors.CodeInternalError, err, "获取运营商失败")
}
if s.audit == nil {
return errors.New(errors.CodeInvalidStatus, "运营商配置审计接缝未配置")
}
if err := s.carrierStore.Delete(ctx, id); err != nil {
err = s.carrierStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.carrierStore.WithTx(tx).Delete(ctx, id); err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationCarrierDelete, "删除运营商配置", carrier, nil)
})
if err != nil {
s.recordFailure(ctx, constants.AuditOperationCarrierDelete, "删除运营商配置失败", carrier)
return errors.Wrap(errors.CodeInternalError, err, "删除运营商失败")
}
@@ -178,17 +215,100 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error {
}
return errors.Wrap(errors.CodeInternalError, err, "获取运营商失败")
}
if s.audit == nil {
return errors.New(errors.CodeInvalidStatus, "运营商配置审计接缝未配置")
}
before := *carrier
carrier.Status = status
carrier.Updater = currentUserID
if err := s.carrierStore.Update(ctx, carrier); err != nil {
err = s.carrierStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.carrierStore.WithTx(tx).Update(ctx, carrier); err != nil {
return err
}
return s.writeAudit(ctx, tx, constants.AuditOperationCarrierStatusUpdate, "更新运营商配置状态", &before, carrier)
})
if err != nil {
s.recordFailure(ctx, constants.AuditOperationCarrierStatusUpdate, "更新运营商配置状态失败", carrier)
return errors.Wrap(errors.CodeInternalError, err, "更新运营商状态失败")
}
return nil
}
func (s *Service) writeAudit(ctx context.Context, tx *gorm.DB, operation, description string, before, after *model.Carrier) error {
carrier := after
if carrier == nil {
carrier = before
}
resourceID := strconv.FormatUint(uint64(carrier.ID), 10)
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
return s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description,
ConfigKey: "carrier." + carrier.CarrierCode, Module: "carrier", ResourceID: &resourceID,
DisplayName: carrier.CarrierName, Identity: carrierIdentity(carrier),
BeforeData: carrierAuditSnapshot(before), AfterData: carrierAuditSnapshot(after),
RequestID: requestID, CorrelationID: requestID,
})
}
func (s *Service) recordDenied(ctx context.Context, operation, description string, carrier *model.Carrier, code int) {
s.recordAuditResult(ctx, operation, description, carrier, constants.AuditResultDenied, code)
}
func (s *Service) recordFailure(ctx context.Context, operation, description string, carrier *model.Carrier) {
s.recordAuditResult(ctx, operation, description, carrier, constants.AuditResultFailed, errors.CodeDatabaseError)
}
func (s *Service) recordAuditResult(ctx context.Context, operation, description string, carrier *model.Carrier, result string, code int) {
if s.audit == nil || carrier == nil || s.carrierStore == nil || s.carrierStore.DB() == nil {
return
}
resourceID := strconv.FormatUint(uint64(carrier.ID), 10)
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
requestID = *value
}
audit := systemconfigapp.ChangeAudit{
OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description,
ConfigKey: "carrier." + carrier.CarrierCode, Module: "carrier", ResourceID: &resourceID,
DisplayName: carrier.CarrierName, Identity: carrierIdentity(carrier), BeforeData: carrierAuditSnapshot(carrier),
Result: result, ErrorCode: strconv.Itoa(code), ErrorSummary: description,
RequestID: requestID, CorrelationID: requestID,
}
if err := s.carrierStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.audit.WriteConfigChange(ctx, tx, audit)
}); err != nil {
auditfailure.RecordSecondaryWriteFailure(operation, audit.ConfigKey, requestID, requestID, audit.ErrorCode, err)
}
}
func carrierIdentity(carrier *model.Carrier) map[string]any {
if carrier == nil {
return nil
}
return map[string]any{
"id": carrier.ID, "carrier_code": carrier.CarrierCode, "carrier_name": carrier.CarrierName,
"carrier_type": carrier.CarrierType, "status": carrier.Status,
}
}
func carrierAuditSnapshot(carrier *model.Carrier) map[string]any {
if carrier == nil {
return nil
}
return map[string]any{
"id": carrier.ID, "carrier_code": carrier.CarrierCode, "carrier_name": carrier.CarrierName,
"carrier_type": carrier.CarrierType, "description": carrier.Description, "status": carrier.Status,
"realname_link_type": carrier.RealnameLinkType, "realname_link_template": carrier.RealnameLinkTemplate,
"data_reset_day": carrier.DataResetDay,
}
}
func (s *Service) toResponse(c *model.Carrier) *dto.CarrierResponse {
return &dto.CarrierResponse{
ID: c.ID,

View File

@@ -0,0 +1,103 @@
package client_order
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/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
func (s *Service) appendPaymentCreatedAudit(ctx context.Context, tx *gorm.DB, payment *model.Payment, order *model.Order, recharge *model.RechargeOrder) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "支付统一审计接缝未配置")
}
resources := []audit.ResourceInput{audit.PaymentResource(payment, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePaymentTarget, nil, map[string]any{"status": payment.Status})}
if order != nil {
resources = append(resources, audit.OrderResource(order, constants.AuditResourceRelationReference, constants.AuditResourceRolePaymentBusinessOrder))
}
if recharge != nil {
id := strconv.FormatUint(uint64(recharge.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceRechargeOrder, ID: &id, 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,
},
})
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionPaymentCreated, Summary: "创建第三方支付记录",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: payment.PaymentNo, Resources: resources,
})
}
func (s *Service) startPaymentAttempt(ctx context.Context, payment *model.Payment, provider, scene string) (*model.IntegrationLog, time.Time, error) {
if s.paymentIntegration == nil {
return nil, time.Time{}, errors.New(errors.CodeInvalidStatus, "支付 Integration Log 接缝未配置")
}
resourceID := strconv.FormatUint(uint64(payment.ID), 10)
resourceKey, series, correlationID := payment.PaymentNo, "payment:"+resourceID+":"+constants.IntegrationOperationPaymentPreCreate, payment.PaymentNo
triggerSource, triggerScene := auditcontext.From(ctx).Source, scene
log, err := s.paymentIntegration.Start(ctx, integrationlog.Attempt{
Provider: provider, Direction: constants.IntegrationDirectionOutbound,
Operation: constants.IntegrationOperationPaymentPreCreate,
ResourceType: constants.IntegrationResourceTypePayment, ResourceID: &resourceID, ResourceKey: &resourceKey,
ExternalID: &resourceKey, TriggerSource: &triggerSource, TriggerScene: &triggerScene,
TriggerSeries: &series, CorrelationID: &correlationID,
RequestSummary: map[string]any{"payment_config_id": payment.PaymentConfigID, "amount": payment.Amount},
})
return log, time.Now(), err
}
func (s *Service) completePaymentAttempt(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 := s.paymentIntegration.Complete(ctx, log.IntegrationID, completion)
return err
}
func paymentIntegrationProvider(config *model.WechatConfig) string {
if config != nil && config.ProviderType == model.ProviderTypeFuiou {
return constants.IntegrationProviderFuiou
}
return constants.IntegrationProviderWechatPay
}
func (s *Service) markPaymentFailed(ctx context.Context, payment *model.Payment, summary string) error {
return s.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 s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionPaymentFailed, Summary: 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

@@ -10,6 +10,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/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
@@ -40,6 +42,8 @@ type WechatConfigServiceInterface interface {
// 用于将钱包扣款、套餐激活、佣金计算等核心逻辑委托给 B 端 order.Service 处理。
type OrderWalletPayServiceInterface interface {
WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) error
CreatePendingOrder(ctx context.Context, order *model.Order, items []*model.OrderItem) error
RecordCreateFailure(ctx context.Context, order *model.Order, businessErr error)
}
// PaymentMethodPolicy 提供按资产类型校验支付方式的能力。
@@ -76,6 +80,8 @@ type Service struct {
redis *redis.Client
logger *zap.Logger
paymentMethodPolicy PaymentMethodPolicy
auditWriter *audit.Writer
paymentIntegration *integrationlog.Repository
}
// SetPaymentMethodPolicy 注入 C 端支付方式策略。
@@ -83,6 +89,12 @@ func (s *Service) SetPaymentMethodPolicy(policy PaymentMethodPolicy) {
s.paymentMethodPolicy = policy
}
// SetPaymentAudit 注入支付审计与外部交互日志接缝。
func (s *Service) SetPaymentAudit(writer *audit.Writer, integration *integrationlog.Repository) {
s.auditWriter = writer
s.paymentIntegration = integration
}
// New 创建客户端订单服务。
func New(
assetService *asset.Service,
@@ -131,7 +143,7 @@ func New(
// CreateOrder 创建客户端订单。
// 普通套餐下单:仅创建待支付订单,不发起支付,需后续调用 POST /orders/:id/pay 支付。
// 强充场景:检测到需要强充时,直接创建充值单并发起微信支付(一步完成),此时 app_type 必传。
func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.ClientCreateOrderRequest) (*dto.ClientCreateOrderResponse, error) {
func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.ClientCreateOrderRequest) (resp *dto.ClientCreateOrderResponse, err error) {
if req == nil {
return nil, errors.New(errors.CodeInvalidParam)
}
@@ -144,6 +156,23 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
if err != nil {
return nil, err
}
auditOrder := &model.Order{
OrderNo: "create:client:" + strings.TrimSpace(req.Identifier), BuyerType: model.BuyerTypePersonal,
BuyerID: customerID, AssetIdentifier: strings.TrimSpace(req.Identifier), PaymentMethod: req.PaymentMethod,
}
if assetInfo.AssetType == "card" || assetInfo.AssetType == constants.AssetTypeIotCard {
auditOrder.OrderType = model.OrderTypeSingleCard
auditOrder.IotCardID = &assetInfo.AssetID
} else {
auditOrder.OrderType = model.OrderTypeDevice
auditOrder.DeviceID = &assetInfo.AssetID
}
orderFlow := true
defer func() {
if orderFlow && s.orderPaymentService != nil {
s.orderPaymentService.RecordCreateFailure(skipCtx, auditOrder, err)
}
}()
if owned, err := s.customerBinding.OwnsAsset(skipCtx, customerID, assetInfo.AssetType, assetInfo.AssetID); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败")
} else if !owned {
@@ -249,6 +278,7 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
}()
if forceRecharge.NeedForceRecharge {
orderFlow = false
if s.paymentMethodPolicy == nil {
return nil, errors.New(errors.CodeNoPaymentConfig)
}
@@ -405,8 +435,11 @@ func (s *Service) createPackageOrder(
return nil, err
}
if err := s.orderStore.Create(ctx, order, items); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败")
if s.orderPaymentService == nil {
return nil, errors.New(errors.CodeInternalError, "订单创建能力未配置")
}
if err := s.orderPaymentService.CreatePendingOrder(ctx, order, items); err != nil {
return nil, err
}
s.markClientPurchaseCreated(ctx, redisKey, order.OrderNo)
@@ -484,10 +517,6 @@ func (s *Service) createForceRechargeOrder(
AutoPurchaseStatus: model.AutoPurchaseStatusPending,
}
if err := s.rechargeOrderStore.Create(ctx, rechargeOrder); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
paymentNo := generateClientPaymentNo()
payment := &model.Payment{
PaymentNo: paymentNo,
@@ -499,12 +528,34 @@ func (s *Service) createForceRechargeOrder(
PaymentConfigID: &activeConfig.ID,
}
if err := s.paymentStore.Create(ctx, payment); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.rechargeOrderStore.CreateWithTx(ctx, tx, rechargeOrder); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
payment.OrderID = rechargeOrder.ID
if err := s.paymentStore.CreateWithTx(ctx, tx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
}
return s.appendPaymentCreatedAudit(ctx, tx, payment, nil, rechargeOrder)
}); err != nil {
return nil, err
}
attempt, startedAt, err := s.startPaymentAttempt(ctx, payment, paymentIntegrationProvider(activeConfig), "client_force_recharge")
if err != nil {
return nil, err
}
paymentResult, err := paymentProvider.CreateJSAPIPayment(ctx, paymentNo, "余额充值", openID, int(rechargeOrder.Amount))
if err != nil {
if completeErr := s.completePaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "支付预下单结果未知"); completeErr != nil {
return nil, completeErr
}
if updateErr := s.markPaymentFailed(ctx, payment, "支付预下单失败,关闭支付记录"); updateErr != nil {
return nil, updateErr
}
return nil, err
}
if err := s.completePaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultSuccess, "SUCCESS", ""); err != nil {
return nil, err
}
@@ -937,7 +988,7 @@ func (s *Service) getOrBuildAlipayPaymentLink(
} else {
// 过期或不存在,标记旧单 failed 并新建
if existing != nil {
if updateErr := s.paymentStore.UpdateStatus(ctx, existing.ID, model.PaymentRecordStatusFailed); updateErr != nil {
if updateErr := s.markPaymentFailed(ctx, existing, "支付宝支付记录过期关闭"); updateErr != nil {
s.logger.Warn("标记过期支付宝支付单 failed 失败",
zap.Uint("payment_id", existing.ID),
zap.Error(updateErr),
@@ -959,8 +1010,13 @@ func (s *Service) getOrBuildAlipayPaymentLink(
PaymentConfigID: &activeConfig.ID,
ExpireAt: &expireAt,
}
if err := s.paymentStore.Create(ctx, newPayment); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建支付宝支付单失败")
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.paymentStore.CreateWithTx(ctx, tx, newPayment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付宝支付单失败")
}
return s.appendPaymentCreatedAudit(ctx, tx, newPayment, nil, nil)
}); err != nil {
return nil, err
}
payment = newPayment
s.logger.Info("创建支付宝支付单",
@@ -977,7 +1033,7 @@ func (s *Service) getOrBuildAlipayPaymentLink(
if err != nil {
// 新建的 payment 生成链接失败,标记 failed
if existing == nil || payment.ID != existing.ID {
_ = s.paymentStore.UpdateStatus(ctx, payment.ID, model.PaymentRecordStatusFailed)
_ = s.markPaymentFailed(ctx, payment, "支付宝支付链接生成失败,关闭支付记录")
}
return nil, err
}
@@ -1074,14 +1130,17 @@ func (s *Service) createAlipayForceRechargeOrder(
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
payment.OrderID = rechargeOrder.ID
return s.paymentStore.CreateWithTx(ctx, tx, payment)
if err := s.paymentStore.CreateWithTx(ctx, tx, payment); err != nil {
return err
}
return s.appendPaymentCreatedAudit(ctx, tx, payment, nil, rechargeOrder)
}); err != nil {
return nil, err
}
wapURL, err := alipay.BuildWapPayURL(ctx, activeConfig, payment, "余额充值")
if err != nil {
if updateErr := s.paymentStore.UpdateStatus(ctx, payment.ID, model.PaymentRecordStatusFailed); updateErr != nil {
if updateErr := s.markPaymentFailed(ctx, payment, "支付宝支付链接生成失败,关闭支付记录"); updateErr != nil {
s.logger.Warn("标记支付宝支付单 failed 失败",
zap.String("payment_no", paymentNo),
zap.Error(updateErr),
@@ -1407,14 +1466,21 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
if err := s.paymentStore.CreateWithTx(skipCtx, tx, payment); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败")
}
return nil
return s.appendPaymentCreatedAudit(skipCtx, tx, payment, order, nil)
}); err != nil {
return nil, err
}
attempt, startedAt, err := s.startPaymentAttempt(skipCtx, payment, paymentIntegrationProvider(activeConfig), "client_order")
if err != nil {
return nil, err
}
paymentResult, err := paymentProvider.CreateJSAPIPayment(skipCtx, paymentNo, "套餐购买", openID, int(order.TotalAmount))
if err != nil {
if updateErr := s.paymentStore.UpdateStatus(skipCtx, payment.ID, model.PaymentRecordStatusFailed); updateErr != nil {
if completeErr := s.completePaymentAttempt(skipCtx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "支付预下单结果未知"); completeErr != nil {
return nil, completeErr
}
if updateErr := s.markPaymentFailed(skipCtx, payment, "支付预下单失败,关闭支付记录"); updateErr != nil {
s.logger.Warn("标记支付记录失败状态失败",
zap.Uint("payment_id", payment.ID),
zap.String("payment_no", paymentNo),
@@ -1423,6 +1489,9 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
}
return nil, err
}
if err := s.completePaymentAttempt(skipCtx, attempt, startedAt, constants.IntegrationResultSuccess, "SUCCESS", ""); err != nil {
return nil, err
}
return &dto.ClientPayOrderResponse{
PaymentMethod: paymentMethod,
PayConfig: buildClientPayConfigFromResult(paymentResult),

View File

@@ -0,0 +1,148 @@
package commission_calculation
import (
"context"
"strconv"
"gorm.io/gorm"
"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"
)
func (s *Service) appendCommissionCalculationAudit(ctx context.Context, tx *gorm.DB, order *model.Order, status, result int) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "佣金统一审计接缝未配置")
}
primary := audit.OrderResource(order, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleCommissionOrder)
primary.BeforeData = map[string]any{"commission_status": order.CommissionStatus, "commission_result": order.CommissionResult}
primary.AfterData = map[string]any{"commission_status": status, "commission_result": result}
primary.SubjectVisibility = constants.AuditSubjectInternalOnly
resources := []audit.ResourceInput{primary}
var records []model.CommissionRecord
if err := tx.WithContext(ctx).Where("order_id = ?", order.ID).Order("id ASC").Find(&records).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单佣金审计快照失败")
}
shopIDs := make([]uint, 0, len(records))
seenShops := make(map[uint]struct{}, len(records))
for i := range records {
resource := audit.CommissionRecordResource(&records[i], nil, map[string]any{
"amount": records[i].Amount, "status": records[i].Status, "balance_after": records[i].BalanceAfter,
})
resource.Relation = constants.AuditResourceRelationAffected
resource.Role = constants.AuditResourceRoleCommissionRecord
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources = append(resources, resource)
if _, ok := seenShops[records[i].ShopID]; !ok {
seenShops[records[i].ShopID] = struct{}{}
shopIDs = append(shopIDs, records[i].ShopID)
}
}
if len(shopIDs) > 0 {
var shops []model.Shop
if err := tx.WithContext(ctx).Where("id IN ?", shopIDs).Order("id ASC").Find(&shops).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询佣金归属店铺审计快照失败")
}
for i := range shops {
resource := audit.ShopResource(&shops[i], constants.AuditResourceRelationReference, constants.AuditResourceRoleCommissionShop)
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources = append(resources, resource)
}
}
seriesResource, err := commissionSeriesResource(ctx, tx, order)
if err != nil {
return err
}
if seriesResource != nil {
resources = append(resources, *seriesResource)
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
EventID: "commission:order:" + strconv.FormatUint(uint64(order.ID), 10) + ":calculated",
ActionCode: constants.AuditActionCommissionCalculated, Summary: "完成订单佣金计算",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: order.OrderNo, Resources: resources,
Metadata: map[string]any{"commission_status": status, "commission_result": result, "record_count": len(records)},
})
}
func (s *Service) appendCommissionCreditAudit(ctx context.Context, tx *gorm.DB, record *model.CommissionRecord, wallet *model.AgentWallet, transaction *model.AgentWalletTransaction, balanceBefore int64) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "佣金统一审计接缝未配置")
}
var saved model.CommissionRecord
if err := tx.WithContext(ctx).First(&saved, record.ID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询佣金入账审计快照失败")
}
var order model.Order
if err := tx.WithContext(ctx).First(&order, saved.OrderID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询佣金关联订单审计快照失败")
}
var shop model.Shop
if err := tx.WithContext(ctx).First(&shop, saved.ShopID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询佣金归属店铺审计快照失败")
}
primary := audit.CommissionRecordResource(&saved,
map[string]any{"amount": record.Amount, "status": record.Status, "balance_after": record.BalanceAfter},
map[string]any{"amount": saved.Amount, "status": saved.Status, "balance_after": saved.BalanceAfter, "released_at": saved.ReleasedAt})
primary.Relation = constants.AuditResourceRelationPrimary
primary.Role = constants.AuditResourceRoleCommissionRecord
primary.SubjectVisibility = constants.AuditSubjectInternalOnly
walletResource := audit.AgentWalletResource(wallet, constants.AuditResourceRelationAffected, constants.AuditResourceRoleCommissionWallet,
map[string]any{"balance": balanceBefore, "frozen_balance": wallet.FrozenBalance},
map[string]any{"balance": balanceBefore + saved.Amount, "frozen_balance": wallet.FrozenBalance})
walletResource.SubjectVisibility = constants.AuditSubjectInternalOnly
transactionResource := audit.AgentWalletTransactionResource(transaction, constants.AuditResourceRelationAffected, constants.AuditResourceRoleCommissionTransaction)
transactionResource.SubjectVisibility = constants.AuditSubjectInternalOnly
orderResource := audit.OrderResource(&order, constants.AuditResourceRelationReference, constants.AuditResourceRoleCommissionOrder)
orderResource.SubjectVisibility = constants.AuditSubjectInternalOnly
shopResource := audit.ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleCommissionShop)
shopResource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources := []audit.ResourceInput{primary, walletResource, transactionResource, orderResource, shopResource}
seriesResource, err := commissionSeriesResource(ctx, tx, &order)
if err != nil {
return err
}
if seriesResource != nil {
resources = append(resources, *seriesResource)
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
EventID: "commission:record:" + strconv.FormatUint(uint64(saved.ID), 10) + ":credited",
ActionCode: constants.AuditActionCommissionCredited, Summary: "佣金已入账",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess,
CorrelationID: order.OrderNo, Resources: resources,
Metadata: map[string]any{"amount": saved.Amount, "balance_before": transaction.BalanceBefore, "balance_after": transaction.BalanceAfter},
})
}
func commissionSeriesResource(ctx context.Context, tx *gorm.DB, order *model.Order) (*audit.ResourceInput, error) {
if order.SeriesID == nil {
return nil, nil
}
var series model.PackageSeries
if err := tx.WithContext(ctx).First(&series, *order.SeriesID).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询佣金关联套餐系列审计快照失败")
}
resource := audit.PackageSeriesResource(&series, constants.AuditResourceRelationReference, constants.AuditResourceRoleCommissionSeries, nil, nil)
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
return &resource, nil
}
func (s *Service) recordCommissionCalculationFailure(ctx context.Context, order *model.Order, businessErr error) {
if businessErr == nil || order == nil || order.OrderNo == "" || s.auditWriter == nil || s.db == nil {
return
}
primary := audit.OrderResource(order, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleCommissionOrder)
primary.BeforeData = map[string]any{"commission_status": order.CommissionStatus, "commission_result": order.CommissionResult}
primary.SubjectVisibility = constants.AuditSubjectInternalOnly
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
ActionCode: constants.AuditActionCommissionCalculated, Summary: "订单佣金计算失败",
ScopeType: constants.AuditScopePlatform, CorrelationID: order.OrderNo,
Resources: []audit.ResourceInput{primary},
}, businessErr)
}

View File

@@ -5,6 +5,7 @@ import (
"fmt"
"time"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
@@ -30,6 +31,7 @@ type Service struct {
packageStore *postgres.PackageStore
commissionStatsStore *postgres.ShopSeriesCommissionStatsStore
commissionStatsService *commission_stats.Service
auditWriter *audit.Writer
logger *zap.Logger
}
@@ -71,12 +73,19 @@ func New(
}
}
// SetAuditWriter 注入佣金计算与入账统一审计 Writer。
func (s *Service) SetAuditWriter(writer *audit.Writer) {
s.auditWriter = writer
}
func (s *Service) CalculateCommission(ctx context.Context, orderID uint) error {
return s.db.Transaction(func(tx *gorm.DB) error {
order, err := s.orderStore.GetByID(ctx, orderID)
var order *model.Order
err := s.db.Transaction(func(tx *gorm.DB) error {
loadedOrder, err := s.orderStore.GetByID(ctx, orderID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "获取订单失败")
}
order = loadedOrder
if order.CommissionStatus == model.CommissionStatusCompleted || order.CommissionStatus == model.CommissionStatusPendingReview {
s.logger.Warn("订单佣金流程已结束,跳过",
@@ -120,8 +129,12 @@ func (s *Service) CalculateCommission(ctx context.Context, orderID uint) error {
return errors.Wrap(errors.CodeDatabaseError, err, "更新订单佣金结果失败")
}
return nil
return s.appendCommissionCalculationAudit(ctx, tx, order, commissionStatus, commissionResult)
})
if err != nil {
s.recordCommissionCalculationFailure(ctx, order, err)
}
return err
}
func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model.Order) ([]*model.CommissionRecord, error) {
@@ -702,7 +715,7 @@ func (s *Service) creditCommissionInTx(ctx context.Context, tx *gorm.DB, record
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败")
}
return nil
return s.appendCommissionCreditAudit(ctx, tx, record, &wallet, transaction, balanceBefore)
}
func (s *Service) persistCommissionRecordsInTx(ctx context.Context, tx *gorm.DB, records []*model.CommissionRecord) error {

View File

@@ -0,0 +1,86 @@
package commission_withdrawal
import (
"context"
"strconv"
"gorm.io/gorm"
"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"
)
func (s *Service) appendWithdrawalDecisionAudit(ctx context.Context, tx *gorm.DB, before *model.CommissionWithdrawalRequest, wallet *model.AgentWallet, transaction *model.AgentWalletTransaction, actionCode, summary string, amount int64) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "佣金提现统一审计接缝未配置")
}
var saved model.CommissionWithdrawalRequest
if err := tx.WithContext(ctx).First(&saved, before.ID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批审计快照失败")
}
expectedStatus := constants.WithdrawalStatusApproved
if actionCode == constants.AuditActionCommissionWithdrawalRejected {
expectedStatus = constants.WithdrawalStatusRejected
}
if saved.Status != expectedStatus {
return errors.New(errors.CodeInvalidStatus, "提现申请终态更新未生效")
}
var shop model.Shop
if err := tx.WithContext(ctx).First(&shop, saved.ShopID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询提现审批店铺审计快照失败")
}
primary := audit.CommissionWithdrawalResource(&saved, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleWithdrawalTarget,
withdrawalDecisionState(before), withdrawalDecisionState(&saved))
primary.SubjectVisibility = constants.AuditSubjectResult
primary.SubjectSummary = summary
walletResource := audit.AgentWalletResource(wallet, constants.AuditResourceRelationAffected, constants.AuditResourceRoleWithdrawalWallet,
map[string]any{"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance},
map[string]any{"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance - amount})
walletResource.SubjectVisibility = constants.AuditSubjectInternalOnly
transactionResource := audit.AgentWalletTransactionResource(transaction, constants.AuditResourceRelationAffected, constants.AuditResourceRoleWithdrawalTransaction)
transactionResource.SubjectVisibility = constants.AuditSubjectInternalOnly
shopResource := audit.ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleWithdrawalShop)
shopResource.SubjectVisibility = constants.AuditSubjectInternalOnly
suffix := "approved"
if actionCode == constants.AuditActionCommissionWithdrawalRejected {
suffix = "rejected"
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
EventID: "commission-withdrawal:" + strconv.FormatUint(uint64(saved.ID), 10) + ":" + suffix,
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
Result: constants.AuditResultSuccess, CorrelationID: saved.WithdrawalNo,
Metadata: map[string]any{"amount": saved.Amount, "fee": saved.Fee, "actual_amount": saved.ActualAmount, "status": saved.Status},
Resources: []audit.ResourceInput{primary, walletResource, transactionResource, shopResource},
})
}
func (s *Service) recordWithdrawalDecisionFailure(ctx context.Context, withdrawal *model.CommissionWithdrawalRequest, wallet *model.AgentWallet, actionCode, summary string, businessErr error) {
if businessErr == nil || withdrawal == nil || withdrawal.WithdrawalNo == "" || s.auditWriter == nil || s.db == nil {
return
}
primary := audit.CommissionWithdrawalResource(withdrawal, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleWithdrawalTarget,
withdrawalDecisionState(withdrawal), nil)
primary.SubjectVisibility = constants.AuditSubjectInternalOnly
resources := []audit.ResourceInput{primary}
if wallet != nil {
resource := audit.AgentWalletResource(wallet, constants.AuditResourceRelationReference, constants.AuditResourceRoleWithdrawalWallet, nil, nil)
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources = append(resources, resource)
}
s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
CorrelationID: withdrawal.WithdrawalNo, Resources: resources,
}, businessErr)
}
func withdrawalDecisionState(withdrawal *model.CommissionWithdrawalRequest) map[string]any {
return map[string]any{
"amount": withdrawal.Amount, "fee": withdrawal.Fee, "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,
"reject_reason": withdrawal.RejectReason, "remark": withdrawal.Remark,
}
}

View File

@@ -5,6 +5,7 @@ import (
"encoding/json"
"time"
"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/internal/store"
@@ -22,6 +23,12 @@ type Service struct {
agentWalletStore *postgres.AgentWalletStore
agentWalletTransactionStore *postgres.AgentWalletTransactionStore
commissionWithdrawalReqStore *postgres.CommissionWithdrawalRequestStore
auditWriter *audit.Writer
}
// SetAuditWriter 注入佣金提现审批统一审计 Writer。
func (s *Service) SetAuditWriter(writer *audit.Writer) {
s.auditWriter = writer
}
func New(
@@ -154,13 +161,17 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveWithdraw
}
if withdrawal.Status != constants.WithdrawalStatusPending {
return nil, errors.New(errors.CodeInvalidStatus, "申请状态不允许此操作")
businessErr := errors.New(errors.CodeInvalidStatus, "申请状态不允许此操作")
s.recordWithdrawalDecisionFailure(ctx, withdrawal, nil, constants.AuditActionCommissionWithdrawalApproved, "通过佣金提现申请失败", businessErr)
return nil, businessErr
}
// 获取店铺分佣钱包
wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, withdrawal.ShopID)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "店铺佣金钱包不存在")
businessErr := errors.New(errors.CodeNotFound, "店铺佣金钱包不存在")
s.recordWithdrawalDecisionFailure(ctx, withdrawal, nil, constants.AuditActionCommissionWithdrawalApproved, "通过佣金提现申请失败", businessErr)
return nil, businessErr
}
amount := withdrawal.Amount
@@ -169,7 +180,9 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveWithdraw
}
if wallet.FrozenBalance < amount {
return nil, errors.New(errors.CodeInsufficientBalance, "钱包冻结余额不足")
businessErr := errors.New(errors.CodeInsufficientBalance, "钱包冻结余额不足")
s.recordWithdrawalDecisionFailure(ctx, withdrawal, wallet, constants.AuditActionCommissionWithdrawalApproved, "通过佣金提现申请失败", businessErr)
return nil, businessErr
}
now := time.Now()
@@ -239,10 +252,11 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveWithdraw
return errors.Wrap(errors.CodeInternalError, err, "更新提现申请状态失败")
}
return nil
return s.appendWithdrawalDecisionAudit(ctx, tx, withdrawal, wallet, transaction, constants.AuditActionCommissionWithdrawalApproved, "佣金提现申请已通过", amount)
})
if err != nil {
s.recordWithdrawalDecisionFailure(ctx, withdrawal, wallet, constants.AuditActionCommissionWithdrawalApproved, "通过佣金提现申请失败", err)
return nil, err
}
@@ -267,12 +281,16 @@ func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectWithdrawal
}
if withdrawal.Status != constants.WithdrawalStatusPending {
return nil, errors.New(errors.CodeInvalidStatus, "申请状态不允许此操作")
businessErr := errors.New(errors.CodeInvalidStatus, "申请状态不允许此操作")
s.recordWithdrawalDecisionFailure(ctx, withdrawal, nil, constants.AuditActionCommissionWithdrawalRejected, "驳回佣金提现申请失败", businessErr)
return nil, businessErr
}
wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, withdrawal.ShopID)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "店铺佣金钱包不存在")
businessErr := errors.New(errors.CodeNotFound, "店铺佣金钱包不存在")
s.recordWithdrawalDecisionFailure(ctx, withdrawal, nil, constants.AuditActionCommissionWithdrawalRejected, "驳回佣金提现申请失败", businessErr)
return nil, businessErr
}
now := time.Now()
@@ -312,10 +330,11 @@ func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectWithdrawal
return errors.Wrap(errors.CodeInternalError, err, "更新提现申请状态失败")
}
return nil
return s.appendWithdrawalDecisionAudit(ctx, tx, withdrawal, wallet, transaction, constants.AuditActionCommissionWithdrawalRejected, "佣金提现申请已驳回", withdrawal.Amount)
})
if err != nil {
s.recordWithdrawalDecisionFailure(ctx, withdrawal, wallet, constants.AuditActionCommissionWithdrawalRejected, "驳回佣金提现申请失败", err)
return nil, err
}

View File

@@ -0,0 +1,200 @@
package customer_binding
import (
"context"
"strconv"
"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/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/middleware"
)
// SetAccessAudit 注入个人客户资产关系的统一审计接缝。
func (s *Service) SetAccessAudit(writer accessauditapp.Writer) {
s.accessAudit = writer
}
func (s *Service) writeBindingAudit(
ctx context.Context,
tx *gorm.DB,
actionCode, summary string,
customerID uint,
personalDevices []accessauditapp.PersonalCustomerDeviceChange,
personalICCIDs []accessauditapp.PersonalCustomerICCIDChange,
cards []accessauditapp.IotCardChange,
devices []accessauditapp.DeviceChange,
) error {
if s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "个人客户资产审计接缝未配置")
}
var customer model.PersonalCustomer
if err := tx.WithContext(ctx).First(&customer, customerID).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询个人客户审计快照失败")
}
value := auditcontext.From(ctx)
operatorID := customerID
actorKind := constants.AuditActorPersonalCustomer
actorName := customer.Nickname
source := constants.AuditSourcePersonalAPI
scopeType := constants.AuditScopePersonalCustomer
visibility := constants.AuditSubjectDetail
subjectData := map[string]any(nil)
if actionCode == constants.AuditActionPersonalCustomerAssetBound {
assetType, assetID := bindingAssetReference(cards, devices)
subjectData = map[string]any{"asset_type": assetType, "asset_id": assetID}
} else {
operatorID = middleware.GetUserIDFromContext(ctx)
if parsed, err := strconv.ParseUint(value.ActorID, 10, 64); err == nil && parsed > 0 {
operatorID = uint(parsed)
}
actorKind = value.ActorKind
actorName = value.ActorName
source = value.Source
scopeType = constants.AuditScopePlatform
visibility = constants.AuditSubjectResult
}
if operatorID == 0 {
return errors.New(errors.CodeInvalidStatus, "个人客户资产审计操作者不完整")
}
return s.accessAudit.WriteAccessChange(ctx, tx, accessauditapp.ChangeAudit{
ActionCode: actionCode, Summary: summary, Result: constants.AuditResultSuccess,
OperatorID: operatorID, ActorKind: actorKind, ActorName: actorName, Source: source, ScopeType: scopeType,
PersonalCustomer: &customer, PersonalDevices: personalDevices, PersonalICCIDs: personalICCIDs,
Cards: cards, Devices: devices,
SubjectVisibility: visibility, SubjectSummary: summary, SubjectData: subjectData,
})
}
func bindingAssetReference(cards []accessauditapp.IotCardChange, devices []accessauditapp.DeviceChange) (string, uint) {
if len(cards) > 0 && cards[0].Card != nil {
return constants.AuditResourceIotCard, cards[0].Card.ID
}
if len(devices) > 0 && devices[0].Device != nil {
return constants.AuditResourceDevice, devices[0].Device.ID
}
return "", 0
}
func cardAuditChange(card *model.IotCard, relation, role string, beforeData, afterData map[string]any) accessauditapp.IotCardChange {
return accessauditapp.IotCardChange{
Card: card, Relation: relation, Role: role, BeforeData: beforeData, AfterData: afterData,
SubjectSummary: "个人客户资产关系已更新",
}
}
func deviceAuditChange(device *model.Device, relation, role string, beforeData, afterData map[string]any) accessauditapp.DeviceChange {
return accessauditapp.DeviceChange{
Device: device, Relation: relation, Role: role, BeforeData: beforeData, AfterData: afterData,
SubjectSummary: "个人客户资产关系已更新",
}
}
func (s *Service) loadAuditAssets(ctx context.Context, tx *gorm.DB, oldType string, oldID uint, newType string, newID uint) ([]accessauditapp.IotCardChange, []accessauditapp.DeviceChange, error) {
cards := make([]accessauditapp.IotCardChange, 0, 2)
devices := make([]accessauditapp.DeviceChange, 0, 2)
appendAsset := func(assetType string, assetID uint, role string) error {
switch normalizeAssetType(assetType) {
case assetTypeIotCard:
card, err := s.readCard(ctx, tx, assetID)
if err != nil {
return err
}
cards = append(cards, cardAuditChange(card, constants.AuditResourceRelationAffected, role, nil, nil))
case assetTypeDevice:
device, err := s.readDevice(ctx, tx, assetID)
if err != nil {
return err
}
devices = append(devices, deviceAuditChange(device, constants.AuditResourceRelationAffected, role, nil, nil))
default:
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
}
return nil
}
if err := appendAsset(oldType, oldID, constants.AuditResourceRolePersonalCustomerOldAsset); err != nil {
return nil, nil, err
}
if err := appendAsset(newType, newID, constants.AuditResourceRolePersonalCustomerNewAsset); err != nil {
return nil, nil, err
}
return cards, devices, nil
}
func (s *Service) writeMigrationAudit(
ctx context.Context,
tx *gorm.DB,
customerID uint,
oldType string,
oldID uint,
newType string,
newID uint,
personalDevices []accessauditapp.PersonalCustomerDeviceChange,
personalICCIDs []accessauditapp.PersonalCustomerICCIDChange,
) error {
cards, devices, err := s.loadAuditAssets(ctx, tx, oldType, oldID, newType, newID)
if err != nil {
return err
}
return s.writeBindingAudit(
ctx, tx, constants.AuditActionPersonalCustomerAssetBindingMigrated, "换货迁移个人客户资产绑定",
customerID, personalDevices, personalICCIDs, cards, devices,
)
}
// UnbindByVirtualNo 按现有换货重置语义删除设备号绑定,并在同一事务记录实际删除关系。
func (s *Service) UnbindByVirtualNo(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, virtualNo string) error {
if s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "个人客户资产审计接缝未配置")
}
if tx == nil {
tx = s.db
}
records, err := s.makePCD(tx).GetByDeviceNo(ctx, virtualNo)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询个人客户资产绑定失败")
}
if err := tx.WithContext(ctx).Where("virtual_no = ?", virtualNo).Delete(&model.PersonalCustomerDevice{}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "清理个人客户绑定失败")
}
for _, record := range records {
if record == nil {
continue
}
cards := []accessauditapp.IotCardChange(nil)
devices := []accessauditapp.DeviceChange(nil)
switch normalizeAssetType(assetType) {
case assetTypeIotCard:
card, loadErr := s.readCard(ctx, tx, assetID)
if loadErr != nil {
return loadErr
}
cards = append(cards, cardAuditChange(card, constants.AuditResourceRelationAffected, constants.AuditResourceRolePersonalCustomerBoundAsset, nil, nil))
case assetTypeDevice:
device, loadErr := s.readDevice(ctx, tx, assetID)
if loadErr != nil {
return loadErr
}
devices = append(devices, deviceAuditChange(device, constants.AuditResourceRelationAffected, constants.AuditResourceRolePersonalCustomerBoundAsset, nil, nil))
default:
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
}
if err := s.writeBindingAudit(
ctx, tx, constants.AuditActionPersonalCustomerAssetUnbound, "解除个人客户资产绑定", record.CustomerID,
[]accessauditapp.PersonalCustomerDeviceChange{{
Binding: record, Role: constants.AuditResourceRolePersonalCustomerAssetBinding,
BeforeData: map[string]any{"virtual_no": record.VirtualNo, "status": record.Status},
AfterData: map[string]any{"deleted": true},
}}, nil, cards, devices,
); err != nil {
return err
}
}
return nil
}

View File

@@ -9,6 +9,7 @@ import (
"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/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
@@ -69,6 +70,7 @@ type Service struct {
// readCard/readDevice 用于 Bind/Migrate 内部,接收事务 db 以保持读写在同一事务内
readCard func(ctx context.Context, db *gorm.DB, id uint) (*model.IotCard, error)
readDevice func(ctx context.Context, db *gorm.DB, id uint) (*model.Device, error)
accessAudit accessauditapp.Writer
}
// New 创建客户绑定服务实例
@@ -216,6 +218,9 @@ func collectActiveCardCustomerIDs(target map[uint]struct{}, records []*model.Per
// 有虚拟号的 IoT 卡 / 设备 → tb_personal_customer_device
// 无虚拟号的 IoT 卡 → tb_personal_customer_iccidIssue 02
func (s *Service) Bind(ctx context.Context, tx *gorm.DB, customerID uint, assetType string, assetID uint) error {
if s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "个人客户资产审计接缝未配置")
}
if tx == nil {
tx = s.db
}
@@ -229,10 +234,10 @@ func (s *Service) Bind(ctx context.Context, tx *gorm.DB, customerID uint, assetT
return err
}
if card.VirtualNo != "" {
return s.bindViaPCD(ctx, pcd, tx, customerID, card.VirtualNo, assetTypeIotCard, assetID)
return s.bindViaPCD(ctx, pcd, tx, customerID, card.VirtualNo, assetTypeIotCard, assetID, card, nil)
}
// 无虚拟号路径Issue 02
return s.bindViaPCI(ctx, pci, tx, customerID, card.ICCID, assetTypeIotCard, assetID)
return s.bindViaPCI(ctx, pci, tx, customerID, card.ICCID, assetTypeIotCard, assetID, card)
case assetTypeDevice:
device, err := s.readDevice(ctx, tx, assetID)
@@ -243,14 +248,14 @@ func (s *Service) Bind(ctx context.Context, tx *gorm.DB, customerID uint, assetT
if key == "" {
key = device.IMEI
}
return s.bindViaPCD(ctx, pcd, tx, customerID, key, assetType, assetID)
return s.bindViaPCD(ctx, pcd, tx, customerID, key, assetType, assetID, nil, device)
}
return errors.New(errors.CodeInvalidParam)
}
// bindViaPCD 通过 tb_personal_customer_device 创建绑定
func (s *Service) bindViaPCD(ctx context.Context, pcd pcdOps, tx *gorm.DB, customerID uint, virtualNo string, assetType string, assetID uint) error {
func (s *Service) bindViaPCD(ctx context.Context, pcd pcdOps, tx *gorm.DB, customerID uint, virtualNo string, assetType string, assetID uint, card *model.IotCard, device *model.Device) error {
count, err := pcd.CountByVirtualNo(ctx, virtualNo)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询资产绑定数量失败")
@@ -262,8 +267,9 @@ func (s *Service) bindViaPCD(ctx context.Context, pcd pcdOps, tx *gorm.DB, custo
return errors.Wrap(errors.CodeInternalError, err, "查询客户资产绑定关系失败")
}
var record *model.PersonalCustomerDevice
if !exists {
record := &model.PersonalCustomerDevice{
record = &model.PersonalCustomerDevice{
CustomerID: customerID,
VirtualNo: virtualNo,
Status: 1,
@@ -274,14 +280,30 @@ func (s *Service) bindViaPCD(ctx context.Context, pcd pcdOps, tx *gorm.DB, custo
}
if firstEverBind {
return s.markAsSold(ctx, tx, assetType, assetID)
if err := s.markAsSold(ctx, tx, assetType, assetID); err != nil {
return err
}
}
if record == nil {
return nil
}
cards := []accessauditapp.IotCardChange(nil)
devices := []accessauditapp.DeviceChange(nil)
if card != nil {
cards = append(cards, cardAuditChange(card, constants.AuditResourceRelationReference, constants.AuditResourceRolePersonalCustomerBoundAsset, nil, nil))
}
if device != nil {
devices = append(devices, deviceAuditChange(device, constants.AuditResourceRelationReference, constants.AuditResourceRolePersonalCustomerBoundAsset, nil, nil))
}
return s.writeBindingAudit(ctx, tx, constants.AuditActionPersonalCustomerAssetBound, "绑定个人客户资产", customerID,
[]accessauditapp.PersonalCustomerDeviceChange{{
Binding: record, Role: constants.AuditResourceRolePersonalCustomerAssetBinding,
AfterData: map[string]any{"virtual_no": record.VirtualNo, "status": record.Status},
}}, nil, cards, devices)
}
// bindViaPCI 通过 tb_personal_customer_iccid 创建绑定无虚拟号卡专用Issue 02
func (s *Service) bindViaPCI(ctx context.Context, pci pciOps, tx *gorm.DB, customerID uint, iccid string, assetType string, assetID uint) error {
func (s *Service) bindViaPCI(ctx context.Context, pci pciOps, tx *gorm.DB, customerID uint, iccid string, assetType string, assetID uint, card *model.IotCard) error {
count, err := pci.CountByICCID(ctx, iccid)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询 ICCID 绑定数量失败")
@@ -293,12 +315,13 @@ func (s *Service) bindViaPCI(ctx context.Context, pci pciOps, tx *gorm.DB, custo
return errors.Wrap(errors.CodeInternalError, err, "查询客户 ICCID 绑定关系失败")
}
var record *model.PersonalCustomerICCID
if !exists {
iccid19 := iccid
if len(iccid) == 20 {
iccid19 = iccid[:19]
}
record := &model.PersonalCustomerICCID{
record = &model.PersonalCustomerICCID{
CustomerID: customerID,
ICCID: iccid,
ICCID19: iccid19,
@@ -310,15 +333,28 @@ func (s *Service) bindViaPCI(ctx context.Context, pci pciOps, tx *gorm.DB, custo
}
if firstEverBind {
return s.markAsSold(ctx, tx, assetType, assetID)
if err := s.markAsSold(ctx, tx, assetType, assetID); err != nil {
return err
}
}
if record == nil {
return nil
}
return s.writeBindingAudit(ctx, tx, constants.AuditActionPersonalCustomerAssetBound, "绑定个人客户资产", customerID,
nil, []accessauditapp.PersonalCustomerICCIDChange{{
Binding: record, Role: constants.AuditResourceRolePersonalCustomerAssetBinding,
AfterData: map[string]any{"iccid": record.ICCID, "status": record.Status},
}}, []accessauditapp.IotCardChange{
cardAuditChange(card, constants.AuditResourceRelationReference, constants.AuditResourceRolePersonalCustomerBoundAsset, nil, nil),
}, nil)
}
// Migrate 将旧资产的所有有效客户绑定迁移到新资产(换货专用)
// 无绑定时静默跳过;按旧/新资产虚拟号有无路由到 pcd 或 pci
func (s *Service) Migrate(ctx context.Context, tx *gorm.DB, oldAssetType string, oldAssetID uint, newAssetType string, newAssetID uint) error {
if s.accessAudit == nil {
return errors.New(errors.CodeInvalidStatus, "个人客户资产审计接缝未配置")
}
if tx == nil {
tx = s.db
}
@@ -332,9 +368,9 @@ func (s *Service) Migrate(ctx context.Context, tx *gorm.DB, oldAssetType string,
return err
}
if oldCard.VirtualNo != "" {
return s.migrateFromPCD(ctx, tx, pcd, pci, oldCard.VirtualNo, newAssetType, newAssetID)
return s.migrateFromPCD(ctx, tx, pcd, pci, oldCard.VirtualNo, oldAssetType, oldAssetID, newAssetType, newAssetID)
}
return s.migrateFromPCI(ctx, tx, pcd, pci, oldCard.ICCID, newAssetType, newAssetID)
return s.migrateFromPCI(ctx, tx, pcd, pci, oldCard.ICCID, oldAssetType, oldAssetID, newAssetType, newAssetID)
case assetTypeDevice:
oldDevice, err := s.readDevice(ctx, tx, oldAssetID)
@@ -345,14 +381,14 @@ func (s *Service) Migrate(ctx context.Context, tx *gorm.DB, oldAssetType string,
if key == "" {
key = oldDevice.IMEI
}
return s.migrateFromPCD(ctx, tx, pcd, pci, key, newAssetType, newAssetID)
return s.migrateFromPCD(ctx, tx, pcd, pci, key, oldAssetType, oldAssetID, newAssetType, newAssetID)
}
return errors.New(errors.CodeInvalidParam)
}
// migrateFromPCD 将 tb_personal_customer_device 中 oldKey 的所有有效绑定迁移到新资产
func (s *Service) migrateFromPCD(ctx context.Context, db *gorm.DB, pcd pcdOps, pci pciOps, oldKey string, newAssetType string, newAssetID uint) error {
func (s *Service) migrateFromPCD(ctx context.Context, db *gorm.DB, pcd pcdOps, pci pciOps, oldKey string, oldAssetType string, oldAssetID uint, newAssetType string, newAssetID uint) error {
records, err := pcd.GetByDeviceNo(ctx, oldKey)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询旧资产绑定记录失败")
@@ -381,6 +417,16 @@ func (s *Service) migrateFromPCD(ctx context.Context, db *gorm.DB, pcd pcdOps, p
if err := pcd.UpdateVirtualNo(ctx, rec.ID, newCard.VirtualNo); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "迁移客户绑定关系失败")
}
after := *rec
after.VirtualNo = newCard.VirtualNo
if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID,
[]accessauditapp.PersonalCustomerDeviceChange{{
Binding: &after, Role: constants.AuditResourceRolePersonalCustomerAssetBinding,
BeforeData: map[string]any{"virtual_no": rec.VirtualNo, "status": rec.Status},
AfterData: map[string]any{"virtual_no": after.VirtualNo, "status": after.Status},
}}, nil); err != nil {
return err
}
}
} else {
// 新卡无虚拟号:禁用旧 pcd + 创建新 pci
@@ -401,6 +447,17 @@ func (s *Service) migrateFromPCD(ctx context.Context, db *gorm.DB, pcd pcdOps, p
if err := pci.Create(ctx, newPCI); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建新客户绑定关系失败")
}
if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID,
[]accessauditapp.PersonalCustomerDeviceChange{{
Binding: rec, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding,
BeforeData: map[string]any{"virtual_no": rec.VirtualNo, "status": rec.Status},
AfterData: map[string]any{"virtual_no": rec.VirtualNo, "status": 0},
}}, []accessauditapp.PersonalCustomerICCIDChange{{
Binding: newPCI, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding,
AfterData: map[string]any{"iccid": newPCI.ICCID, "status": newPCI.Status},
}}); err != nil {
return err
}
}
}
@@ -417,6 +474,16 @@ func (s *Service) migrateFromPCD(ctx context.Context, db *gorm.DB, pcd pcdOps, p
if err := pcd.UpdateVirtualNo(ctx, rec.ID, newKey); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "迁移设备客户绑定关系失败")
}
after := *rec
after.VirtualNo = newKey
if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID,
[]accessauditapp.PersonalCustomerDeviceChange{{
Binding: &after, Role: constants.AuditResourceRolePersonalCustomerAssetBinding,
BeforeData: map[string]any{"virtual_no": rec.VirtualNo, "status": rec.Status},
AfterData: map[string]any{"virtual_no": after.VirtualNo, "status": after.Status},
}}, nil); err != nil {
return err
}
}
default:
@@ -427,7 +494,7 @@ func (s *Service) migrateFromPCD(ctx context.Context, db *gorm.DB, pcd pcdOps, p
}
// migrateFromPCI 将 tb_personal_customer_iccid 中 oldICCID 的所有有效绑定迁移到新资产
func (s *Service) migrateFromPCI(ctx context.Context, db *gorm.DB, pcd pcdOps, pci pciOps, oldICCID string, newAssetType string, newAssetID uint) error {
func (s *Service) migrateFromPCI(ctx context.Context, db *gorm.DB, pcd pcdOps, pci pciOps, oldICCID string, oldAssetType string, oldAssetID uint, newAssetType string, newAssetID uint) error {
records, err := pci.GetByICCID(ctx, oldICCID)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询旧 ICCID 绑定记录失败")
@@ -463,6 +530,17 @@ func (s *Service) migrateFromPCI(ctx context.Context, db *gorm.DB, pcd pcdOps, p
if err := pcd.Create(ctx, newPCD); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建新客户绑定关系失败")
}
if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID,
[]accessauditapp.PersonalCustomerDeviceChange{{
Binding: newPCD, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding,
AfterData: map[string]any{"virtual_no": newPCD.VirtualNo, "status": newPCD.Status},
}}, []accessauditapp.PersonalCustomerICCIDChange{{
Binding: rec, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding,
BeforeData: map[string]any{"iccid": rec.ICCID, "status": rec.Status},
AfterData: map[string]any{"iccid": rec.ICCID, "status": 0},
}}); err != nil {
return err
}
}
} else {
// 新卡也无虚拟号:禁用旧 pci + 创建新 pci新 ICCID
@@ -483,6 +561,20 @@ func (s *Service) migrateFromPCI(ctx context.Context, db *gorm.DB, pcd pcdOps, p
if err := pci.Create(ctx, newPCI); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建新 ICCID 绑定关系失败")
}
if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID,
nil, []accessauditapp.PersonalCustomerICCIDChange{
{
Binding: rec, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding,
BeforeData: map[string]any{"iccid": rec.ICCID, "status": rec.Status},
AfterData: map[string]any{"iccid": rec.ICCID, "status": 0},
},
{
Binding: newPCI, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding,
AfterData: map[string]any{"iccid": newPCI.ICCID, "status": newPCI.Status},
},
}); err != nil {
return err
}
}
}
@@ -507,6 +599,17 @@ func (s *Service) migrateFromPCI(ctx context.Context, db *gorm.DB, pcd pcdOps, p
if err := pcd.Create(ctx, newPCD); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建新客户绑定关系失败")
}
if err := s.writeMigrationAudit(ctx, db, rec.CustomerID, oldAssetType, oldAssetID, newAssetType, newAssetID,
[]accessauditapp.PersonalCustomerDeviceChange{{
Binding: newPCD, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding,
AfterData: map[string]any{"virtual_no": newPCD.VirtualNo, "status": newPCD.Status},
}}, []accessauditapp.PersonalCustomerICCIDChange{{
Binding: rec, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding,
BeforeData: map[string]any{"iccid": rec.ICCID, "status": rec.Status},
AfterData: map[string]any{"iccid": rec.ICCID, "status": 0},
}}); err != nil {
return err
}
}
default:

View File

@@ -1,117 +0,0 @@
package device
import (
"context"
"strconv"
"github.com/google/uuid"
"gorm.io/gorm"
"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/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
func (s *Service) appendCSVBatchAllocationAudit(
ctx context.Context,
tx *gorm.DB,
devices []*model.Device,
succeededIDs []uint,
failedItems []dto.AllocationDeviceFailedItem,
targetShopID uint,
) error {
linkage := auditcontext.From(ctx)
if s.auditWriter == nil || linkage.ActorKind != constants.AuditActorSystemTask ||
linkage.ActorID != constants.TaskTypeDeviceImport || linkage.Source != constants.AuditSourceWorker ||
linkage.CorrelationID == "" {
return nil
}
devicesByID := make(map[uint]*model.Device, len(devices))
for _, device := range devices {
if device != nil {
devicesByID[device.ID] = device
}
}
rootEventID := stableBatchEventID("root", linkage.CorrelationID)
children := make([]audit.AppendInput, 0, len(succeededIDs)+len(failedItems))
for _, deviceID := range succeededIDs {
if device := devicesByID[deviceID]; device != nil {
children = append(children, deviceBatchChild(device, rootEventID, linkage.CorrelationID, targetShopID, true, ""))
}
}
for _, item := range failedItems {
if device := devicesByID[item.DeviceID]; device != nil {
children = append(children, deviceBatchChild(device, rootEventID, linkage.CorrelationID, targetShopID, false, item.Reason))
}
}
result := constants.AuditResultSuccess
if len(succeededIDs) > 0 && len(failedItems) > 0 {
result = constants.AuditResultPartial
}
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
Root: audit.AppendInput{
EventID: rootEventID, ActionCode: constants.AuditActionDeviceBatchAllocationCompleted,
Summary: "设备CSV批量分配完成", Result: result,
CorrelationID: linkage.CorrelationID,
BatchTotal: len(succeededIDs) + len(failedItems), SuccessCount: len(succeededIDs), FailCount: len(failedItems),
Metadata: map[string]any{"operation_type": constants.DeviceImportOperationAssignShop, "target_shop_id": targetShopID},
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceDeviceBatchTask, Key: linkage.CorrelationID, DisplayName: linkage.CorrelationID,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchTask,
IdentitySnapshot: map[string]any{"task_no": linkage.CorrelationID, "operation_type": constants.DeviceImportOperationAssignShop},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
},
Children: children,
})
}
func deviceBatchChild(
device *model.Device,
parentEventID string,
correlationID string,
targetShopID uint,
succeeded bool,
reason string,
) audit.AppendInput {
resourceID := strconv.FormatUint(uint64(device.ID), 10)
before := map[string]any{"shop_id": device.ShopID, "status": device.Status}
after := before
result := constants.AuditResultFailed
summary := "设备批量分配失败"
if succeeded {
after = map[string]any{"shop_id": targetShopID, "status": constants.DeviceStatusDistributed}
result = constants.AuditResultSuccess
summary = "设备批量分配成功"
}
return audit.AppendInput{
EventID: stableBatchEventID("device", correlationID+":"+resourceID),
ActionCode: constants.AuditActionDeviceBatchAllocationItem, Summary: summary,
Result: result, ErrorSummary: reason, CorrelationID: correlationID, ParentEventID: parentEventID,
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceDevice, ID: &resourceID, Key: deviceAuditKey(device), DisplayName: deviceAuditKey(device),
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchItem,
IdentitySnapshot: map[string]any{
"id": device.ID, "virtual_no": device.VirtualNo, "imei": device.IMEI, "sn": device.SN,
"shop_id": device.ShopID, "series_id": device.SeriesID, "generation": device.Generation,
},
BeforeData: before, AfterData: after,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
}},
}
}
func stableBatchEventID(kind, key string) string {
return "evt_" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("device-batch:"+kind+":"+key)).String()
}
func deviceAuditKey(device *model.Device) string {
for _, value := range []string{device.VirtualNo, device.IMEI, device.SN} {
if value != "" {
return value
}
}
return strconv.FormatUint(uint64(device.ID), 10)
}

View File

@@ -5,6 +5,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
@@ -80,105 +81,29 @@ func (s *Service) BindCard(ctx context.Context, deviceID uint, req *dto.BindCard
device, err := s.deviceStore.GetByID(ctx, deviceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
appErr := errors.New(errors.CodeNotFound, "设备不存在")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡失败",
constants.AssetAuditResultFailed,
nil,
nil,
map[string]any{
"device_id": deviceID,
"iot_card_id": req.IotCardID,
"slot_position": req.SlotPosition,
},
0,
0,
0,
appErr,
)
return nil, appErr
return nil, errors.New(errors.CodeNotFound, "设备不存在")
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡失败",
constants.AssetAuditResultFailed,
nil,
nil,
map[string]any{
"device_id": deviceID,
"iot_card_id": req.IotCardID,
"slot_position": req.SlotPosition,
},
0,
0,
0,
err,
)
return nil, err
}
metadata := map[string]any{"iot_card_id": req.IotCardID, "slot_position": req.SlotPosition}
if req.SlotPosition > device.MaxSimSlots {
appErr := errors.New(errors.CodeInvalidParam, "插槽位置超出设备最大插槽数")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{
"iot_card_id": req.IotCardID,
"slot_position": req.SlotPosition,
},
0,
0,
0,
appErr,
)
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡被拒绝", constants.AuditResultDenied,
device, nil, nil, metadata, appErr)
return nil, appErr
}
existingBinding, err := s.deviceSimBindingStore.GetByDeviceAndSlot(ctx, device.ID, req.SlotPosition)
if err != nil && err != gorm.ErrRecordNotFound {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{
"iot_card_id": req.IotCardID,
"slot_position": req.SlotPosition,
},
0,
0,
0,
err,
)
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡失败", constants.AuditResultFailed,
device, nil, nil, metadata, err)
return nil, err
}
if existingBinding != nil {
appErr := errors.New(errors.CodeConflict, "该插槽已有绑定的卡")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{
"iot_card_id": req.IotCardID,
"slot_position": req.SlotPosition,
},
0,
0,
0,
appErr,
)
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡被拒绝", constants.AuditResultDenied,
device, nil, nil, metadata, appErr)
return nil, appErr
}
@@ -186,88 +111,30 @@ func (s *Service) BindCard(ctx context.Context, deviceID uint, req *dto.BindCard
if err != nil {
if err == gorm.ErrRecordNotFound {
appErr := errors.New(errors.CodeIotCardNotFound)
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{
"iot_card_id": req.IotCardID,
"slot_position": req.SlotPosition,
},
0,
0,
0,
appErr,
)
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡失败", constants.AuditResultFailed,
device, nil, nil, metadata, appErr)
return nil, appErr
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{
"iot_card_id": req.IotCardID,
"slot_position": req.SlotPosition,
},
0,
0,
0,
err,
)
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡失败", constants.AuditResultFailed,
device, nil, nil, metadata, err)
return nil, err
}
item := deviceBindingAuditItem{
Card: card, CardRole: constants.AuditResourceRoleDeviceBindingTargetCard,
CardBefore: map[string]any{"device_id": nil, "slot_position": nil},
CardAfter: map[string]any{"device_id": device.ID, "slot_position": req.SlotPosition},
}
activeBinding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
if err != nil && err != gorm.ErrRecordNotFound {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{
"iot_card_id": req.IotCardID,
"slot_position": req.SlotPosition,
},
0,
0,
0,
err,
)
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡失败", constants.AuditResultFailed,
device, nil, []deviceBindingAuditItem{item}, metadata, err)
return nil, err
}
if activeBinding != nil {
appErr := errors.New(errors.CodeIotCardBoundToDevice, "该卡已绑定到其他设备")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{
"device": deviceSnapshot(device),
"card": map[string]any{
"id": card.ID,
"iccid": card.ICCID,
"status": card.Status,
},
},
map[string]any{
"iot_card_id": req.IotCardID,
"slot_position": req.SlotPosition,
},
0,
0,
0,
appErr,
)
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡被拒绝", constants.AuditResultDenied,
device, nil, []deviceBindingAuditItem{item}, metadata, appErr)
return nil, appErr
}
@@ -278,29 +145,26 @@ func (s *Service) BindCard(ctx context.Context, deviceID uint, req *dto.BindCard
BindStatus: 1,
}
if err := s.deviceSimBindingStore.Create(ctx, binding); err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡失败",
constants.AssetAuditResultFailed,
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := postgres.NewDeviceSimBindingStore(tx, nil).Create(ctx, binding); err != nil {
return err
}
item.Binding = binding
item.BindingRole = constants.AuditResourceRoleDeviceCreatedBinding
item.BindingAfter = bindingStateData(binding, constants.BindStatusBound, false)
return s.appendDeviceBindingAudit(ctx, tx, constants.AuditActionDeviceCardBound, "设备绑定 IoT 卡", constants.AuditResultSuccess,
device,
map[string]any{
"device": deviceSnapshot(device),
"card": map[string]any{
"id": card.ID,
"iccid": card.ICCID,
"status": card.Status,
},
},
map[string]any{
"slot_position": req.SlotPosition,
},
0,
0,
0,
err,
)
map[string]any{"slot_position": req.SlotPosition, "iot_card_id": nil},
map[string]any{"slot_position": req.SlotPosition, "iot_card_id": card.ID},
[]deviceBindingAuditItem{item}, metadata, nil)
})
if err != nil {
result := constants.AuditResultFailed
if appErr, ok := err.(*errors.AppError); ok && (appErr.Code == errors.CodeConflict || appErr.Code == errors.CodeIotCardBoundToDevice) {
result = constants.AuditResultDenied
}
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardBound, "设备绑卡失败", result,
device, nil, []deviceBindingAuditItem{{Card: card, CardRole: constants.AuditResourceRoleDeviceBindingTargetCard}}, metadata, err)
return nil, err
}
@@ -313,32 +177,6 @@ func (s *Service) BindCard(ctx context.Context, deviceID uint, req *dto.BindCard
)
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceBindCard,
"设备绑卡",
constants.AssetAuditResultSuccess,
device,
map[string]any{
"device": deviceSnapshot(device),
"card": map[string]any{
"id": card.ID,
"iccid": card.ICCID,
"status": card.Status,
},
},
map[string]any{
"binding_id": binding.ID,
"slot_position": req.SlotPosition,
"iot_card_id": card.ID,
"iccid": card.ICCID,
},
1,
1,
0,
nil,
)
return &dto.BindCardToDeviceResponse{
BindingID: binding.ID,
Message: "绑定成功",
@@ -349,111 +187,54 @@ func (s *Service) UnbindCard(ctx context.Context, deviceID uint, cardID uint) (*
device, err := s.deviceStore.GetByID(ctx, deviceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
appErr := errors.New(errors.CodeNotFound, "设备不存在")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceUnbindCard,
"设备解绑卡失败",
constants.AssetAuditResultFailed,
nil,
nil,
map[string]any{
"device_id": deviceID,
"iot_card_id": cardID,
},
0,
0,
0,
appErr,
)
return nil, appErr
return nil, errors.New(errors.CodeNotFound, "设备不存在")
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceUnbindCard,
"设备解绑卡失败",
constants.AssetAuditResultFailed,
nil,
nil,
map[string]any{
"device_id": deviceID,
"iot_card_id": cardID,
},
0,
0,
0,
err,
)
return nil, err
}
metadata := map[string]any{"iot_card_id": cardID}
binding, err := s.deviceSimBindingStore.GetByDeviceAndCard(ctx, device.ID, cardID)
if err != nil {
if err == gorm.ErrRecordNotFound {
appErr := errors.New(errors.CodeNotFound, "该卡未绑定到此设备")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceUnbindCard,
"设备解绑卡被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"iot_card_id": cardID},
0,
0,
0,
appErr,
)
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardUnbound, "设备解绑卡被拒绝", constants.AuditResultDenied,
device, nil, nil, metadata, appErr)
return nil, appErr
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceUnbindCard,
"设备解绑卡失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"iot_card_id": cardID},
0,
0,
0,
err,
)
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardUnbound, "设备解绑卡失败", constants.AuditResultFailed,
device, nil, nil, metadata, err)
return nil, err
}
var cardAudit map[string]any
if card, cardErr := s.iotCardStore.GetByID(ctx, binding.IotCardID); cardErr == nil {
cardAudit = map[string]any{
"id": card.ID,
"iccid": card.ICCID,
"status": card.Status,
card, cardErr := s.iotCardStore.GetByID(ctx, binding.IotCardID)
if cardErr != nil {
card = &model.IotCard{}
card.ID = binding.IotCardID
}
item := deviceBindingAuditItem{
Card: card, Binding: binding,
CardRole: constants.AuditResourceRoleDeviceBindingTargetCard, BindingRole: constants.AuditResourceRoleDeviceRemovedBinding,
CardBefore: map[string]any{"device_id": device.ID, "slot_position": binding.SlotPosition},
CardAfter: map[string]any{"device_id": nil, "slot_position": nil},
BindingBefore: bindingStateData(binding, constants.BindStatusBound, binding.IsCurrent),
BindingAfter: bindingStateData(binding, constants.BindStatusUnbound, false),
}
beforeAuditData := map[string]any{
"device": deviceSnapshot(device),
"binding_id": binding.ID,
"iot_card_id": binding.IotCardID,
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := postgres.NewDeviceSimBindingStore(tx, nil).Unbind(ctx, binding.ID); err != nil {
return err
}
if cardAudit != nil {
beforeAuditData["card"] = cardAudit
if err := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).Where("id = ?", binding.ID).Update("is_current", false).Error; err != nil {
return err
}
if err := s.deviceSimBindingStore.Unbind(ctx, binding.ID); err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceUnbindCard,
"设备解绑卡失败",
constants.AssetAuditResultFailed,
return s.appendDeviceBindingAudit(ctx, tx, constants.AuditActionDeviceCardUnbound, "设备解绑 IoT 卡", constants.AuditResultSuccess,
device,
beforeAuditData,
nil,
0,
0,
0,
err,
)
map[string]any{"slot_position": binding.SlotPosition, "iot_card_id": binding.IotCardID},
map[string]any{"slot_position": binding.SlotPosition, "iot_card_id": nil},
[]deviceBindingAuditItem{item}, metadata, nil)
})
if err != nil {
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCardUnbound, "设备解绑卡失败", constants.AuditResultFailed,
device, nil, []deviceBindingAuditItem{item}, metadata, err)
return nil, err
}
@@ -466,28 +247,6 @@ func (s *Service) UnbindCard(ctx context.Context, deviceID uint, cardID uint) (*
)
}
afterAuditData := map[string]any{
"iot_card_id": cardID,
"unbind": true,
}
if cardAudit != nil {
afterAuditData["card"] = cardAudit
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceUnbindCard,
"设备解绑卡",
constants.AssetAuditResultSuccess,
device,
beforeAuditData,
afterAuditData,
1,
1,
0,
nil,
)
return &dto.UnbindCardFromDeviceResponse{
Message: "解绑成功",
}, nil

View File

@@ -0,0 +1,236 @@
package device
import (
"context"
"strconv"
"strings"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
type deviceBindingAuditItem struct {
Card *model.IotCard
Binding *model.DeviceSimBinding
CardRole string
BindingRole string
CardBefore map[string]any
CardAfter map[string]any
BindingBefore map[string]any
BindingAfter map[string]any
}
type deviceBindingState struct {
bindings []*model.DeviceSimBinding
cards map[uint]*model.IotCard
target *model.DeviceSimBinding
current *model.DeviceSimBinding
}
func (s *Service) appendDeviceBindingAudit(
ctx context.Context,
tx *gorm.DB,
actionCode, summary, result string,
device *model.Device,
deviceBefore, deviceAfter map[string]any,
items []deviceBindingAuditItem,
metadata map[string]any,
businessErr error,
) error {
if s.auditWriter == nil || device == nil || device.ID == 0 {
return errors.New(errors.CodeInvalidStatus, "设备卡槽统一审计接缝未配置或资源不完整")
}
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: deviceBefore, AfterData: deviceAfter,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
}}
for index, item := range items {
if item.Card != nil && item.Card.ID > 0 {
cardID := strconv.FormatUint(uint64(item.Card.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceIotCard, ID: &cardID,
Key: audit.IotCardResourceKey(item.Card), DisplayName: item.Card.ICCID,
Relation: constants.AuditResourceRelationAffected, Role: item.CardRole,
IdentitySnapshot: audit.IotCardIdentitySnapshot(item.Card), BeforeData: item.CardBefore, AfterData: item.CardAfter,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary, SortOrder: index*2 + 1,
})
}
if item.Binding != nil && item.Binding.ID > 0 {
bindingID := strconv.FormatUint(uint64(item.Binding.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceDeviceSIMBinding, ID: &bindingID,
Key: bindingID, DisplayName: device.VirtualNo,
Relation: constants.AuditResourceRelationAffected, Role: item.BindingRole,
IdentitySnapshot: deviceBindingIdentity(device, item.Card, item.Binding),
BeforeData: item.BindingBefore, AfterData: item.BindingAfter,
SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index*2 + 2,
})
}
}
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
Metadata: metadata, Resources: resources,
})
}
func (s *Service) recordDeviceBindingAuditFailure(
ctx context.Context,
actionCode, summary, result string,
device *model.Device,
deviceBefore map[string]any,
items []deviceBindingAuditItem,
metadata map[string]any,
businessErr error,
) {
deviceID := uint(0)
if device != nil {
deviceID = device.ID
}
if s.db == nil || s.auditWriter == nil || deviceID == 0 {
recordDeviceAuditSecondaryFailure(ctx, actionCode, deviceID, businessErr, errors.New(errors.CodeInvalidStatus, "设备卡槽统一审计接缝未配置或资源不完整"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendDeviceBindingAudit(ctx, tx, actionCode, summary, result, device, deviceBefore, nil, items, metadata, businessErr)
}); err != nil {
recordDeviceAuditSecondaryFailure(ctx, actionCode, deviceID, businessErr, err)
}
}
func deviceBindingIdentity(device *model.Device, card *model.IotCard, binding *model.DeviceSimBinding) map[string]any {
identity := map[string]any{
"id": binding.ID, "device_id": binding.DeviceID, "slot_position": binding.SlotPosition,
"iot_card_id": binding.IotCardID, "is_current": binding.IsCurrent,
}
if device != nil {
identity["device_virtual_no"] = device.VirtualNo
}
if card != nil {
identity["iccid"] = card.ICCID
identity["virtual_no"] = card.VirtualNo
}
return identity
}
func bindingStateData(binding *model.DeviceSimBinding, bindStatus int, isCurrent bool) map[string]any {
return map[string]any{
"slot_position": binding.SlotPosition,
"bind_status": bindStatus,
"is_current": isCurrent,
}
}
func loadDeviceBindingState(ctx context.Context, db *gorm.DB, deviceID uint, targetICCID string, lock bool) (*deviceBindingState, error) {
query := db.WithContext(ctx).Where("device_id = ? AND bind_status = ?", deviceID, constants.BindStatusBound).Order("slot_position ASC")
if lock {
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
}
state := &deviceBindingState{cards: make(map[uint]*model.IotCard)}
if err := query.Find(&state.bindings).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备卡槽关系失败")
}
cardIDs := make([]uint, 0, len(state.bindings))
for _, binding := range state.bindings {
cardIDs = append(cardIDs, binding.IotCardID)
if binding.IsCurrent {
state.current = binding
}
}
if len(cardIDs) > 0 {
var cards []*model.IotCard
if err := db.WithContext(ctx).Where("id IN ?", cardIDs).Find(&cards).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备绑定卡失败")
}
for _, card := range cards {
state.cards[card.ID] = card
}
}
targetICCID = strings.TrimSpace(targetICCID)
for _, binding := range state.bindings {
if card := state.cards[binding.IotCardID]; card != nil && cardMatchesICCID(card, targetICCID) {
state.target = binding
break
}
}
return state, nil
}
func switchCardAuditItems(state *deviceBindingState) []deviceBindingAuditItem {
items := make([]deviceBindingAuditItem, 0, 2)
if state.current != nil {
oldCurrentAfter := false
if state.target != nil && state.current.ID == state.target.ID {
oldCurrentAfter = true
}
items = append(items, deviceBindingAuditItem{
Card: state.cards[state.current.IotCardID], Binding: state.current,
CardRole: constants.AuditResourceRoleDeviceOldCurrentCard, BindingRole: constants.AuditResourceRoleDeviceOldCurrentBinding,
CardBefore: map[string]any{"is_current": true}, CardAfter: map[string]any{"is_current": oldCurrentAfter},
BindingBefore: bindingStateData(state.current, constants.BindStatusBound, true),
BindingAfter: bindingStateData(state.current, constants.BindStatusBound, oldCurrentAfter),
})
}
if state.target != nil {
wasCurrent := state.target.IsCurrent
items = append(items, deviceBindingAuditItem{
Card: state.cards[state.target.IotCardID], Binding: state.target,
CardRole: constants.AuditResourceRoleDeviceNewCurrentCard, BindingRole: constants.AuditResourceRoleDeviceNewCurrentBinding,
CardBefore: map[string]any{"is_current": wasCurrent}, CardAfter: map[string]any{"is_current": true},
BindingBefore: bindingStateData(state.target, constants.BindStatusBound, wasCurrent),
BindingAfter: bindingStateData(state.target, constants.BindStatusBound, true),
})
}
return items
}
func currentCardID(state *deviceBindingState) uint {
if state == nil || state.current == nil {
return 0
}
return state.current.IotCardID
}
func loadDeviceUnbindAuditReferences(ctx context.Context, tx *gorm.DB, device *model.Device) ([]audit.ResourceInput, error) {
referencesByDevice, _, err := loadDeviceCardAuditReferences(ctx, tx, []*model.Device{device}, nil)
if err != nil {
return nil, err
}
references := referencesByDevice[device.ID]
for index := range references {
resource := &references[index]
resource.Relation = constants.AuditResourceRelationAffected
switch resource.Type {
case constants.AuditResourceIotCard:
resource.Role = constants.AuditResourceRoleDeviceBindingTargetCard
resource.BeforeData = map[string]any{"device_id": device.ID}
resource.AfterData = map[string]any{"device_id": nil}
resource.SubjectVisibility = constants.AuditSubjectResult
resource.SubjectSummary = "设备删除并解绑 IoT 卡"
case constants.AuditResourceDeviceSIMBinding:
resource.Role = constants.AuditResourceRoleDeviceRemovedBinding
resource.BeforeData = map[string]any{
"slot_position": resource.IdentitySnapshot["slot_position"],
"bind_status": constants.BindStatusBound,
"is_current": resource.IdentitySnapshot["is_current"],
}
resource.AfterData = map[string]any{
"slot_position": resource.IdentitySnapshot["slot_position"],
"bind_status": constants.BindStatusUnbound,
"is_current": false,
}
}
}
return references, nil
}

View File

@@ -0,0 +1,294 @@
package device
import (
"context"
stderrors "errors"
"strconv"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
"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"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
type deviceGatewayIntegrationLog interface {
Start(ctx context.Context, input integrationlog.Attempt) (*model.IntegrationLog, error)
Complete(ctx context.Context, integrationID string, completion integrationlog.Completion) (*model.IntegrationLog, error)
}
// SetGatewayIntegrationLog 注入设备外部命令的 Integration Log 接缝。
func (s *Service) SetGatewayIntegrationLog(integration deviceGatewayIntegrationLog) {
s.gatewayIntegration = integration
}
type deviceGatewayResource struct {
Type string
ID string
Key string
ExternalID string
RequestSummary map[string]any
}
type deviceGatewayAttempt struct {
log *model.IntegrationLog
startedAt time.Time
}
type deviceGatewayAttemptObserver struct {
service *Service
operation string
scene string
seriesKey string
resource deviceGatewayResource
current *deviceGatewayAttempt
successful *deviceGatewayAttempt
integration string
unknown bool
}
func (o *deviceGatewayAttemptObserver) BeforeAttempt(ctx context.Context, attempt int) error {
started, err := o.service.startDeviceGatewayAttempt(ctx, o.operation, o.scene, o.seriesKey, attempt, o.resource)
if err != nil {
return err
}
o.current = started
o.integration = started.log.IntegrationID
return nil
}
func (o *deviceGatewayAttemptObserver) AfterAttempt(ctx context.Context, _ int, callErr error) error {
if isDeviceGatewayTimeout(callErr) {
o.unknown = true
}
if callErr == nil {
o.successful = o.current
o.current = nil
return nil
}
err := o.service.completeDeviceGatewayAttempt(ctx, o.current, callErr, false)
o.current = nil
return err
}
func (o *deviceGatewayAttemptObserver) completeSuccess(ctx context.Context, stateChanged bool) error {
return o.service.completeDeviceGatewayAttempt(ctx, o.successful, nil, stateChanged)
}
func (s *Service) startDeviceGatewayAttempt(
ctx context.Context,
operation, scene, seriesKey string,
attempt int,
resource deviceGatewayResource,
) (*deviceGatewayAttempt, error) {
if s == nil || s.gatewayIntegration == nil {
return nil, errors.New(errors.CodeInvalidStatus, "设备 Gateway Integration Log 接缝未配置")
}
linkage := auditcontext.From(ctx)
triggerSource := linkage.Source
if triggerSource == "" {
triggerSource = "service"
}
triggerSeries := uuid.NewSHA1(uuid.NameSpaceOID, []byte("gateway-device-command:"+seriesKey+":"+operation+":"+resource.Type+":"+resource.ID)).String()
var requestID, correlationID *string
if linkage.RequestID != "" {
requestID = &linkage.RequestID
}
if linkage.CorrelationID != "" {
correlationID = &linkage.CorrelationID
} else {
correlationID = requestID
}
log, err := s.gatewayIntegration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderGateway, Direction: constants.IntegrationDirectionOutbound,
Operation: operation, ExternalID: &resource.ExternalID,
ResourceType: resource.Type, ResourceID: &resource.ID, ResourceKey: &resource.Key,
TriggerSource: &triggerSource, TriggerScene: &scene, TriggerSeries: &triggerSeries,
Attempt: attempt, RequestID: requestID, CorrelationID: correlationID,
RequestSummary: resource.RequestSummary,
})
if err != nil {
return nil, err
}
return &deviceGatewayAttempt{log: log, startedAt: time.Now()}, nil
}
func (s *Service) completeDeviceGatewayAttempt(ctx context.Context, attempt *deviceGatewayAttempt, callErr error, stateChanged bool) error {
if attempt == nil || attempt.log == nil {
return nil
}
completion := integrationlog.Completion{
Result: constants.IntegrationResultSuccess, DurationMS: time.Since(attempt.startedAt).Milliseconds(),
StateChanged: stateChanged, ResponseSummary: map[string]any{"result": "success"},
}
if callErr != nil {
completion.Result = constants.IntegrationResultFailed
completion.SafeProviderMessage = "Gateway 设备命令失败"
completion.ResponseSummary = map[string]any{"result": "failed"}
if isDeviceGatewayTimeout(callErr) {
completion.Result = constants.IntegrationResultUnknown
completion.SafeProviderMessage = "Gateway 设备命令结果未知"
completion.ResponseSummary = map[string]any{"result": "unknown"}
completion.RecoveryStrategy = constants.GatewayDeviceCommandUnknownRecoveryStrategy
}
}
_, err := s.gatewayIntegration.Complete(ctx, attempt.log.IntegrationID, completion)
return err
}
type deviceGatewayCommand struct {
ActionCode string
Summary string
Operation string
Scene string
RequestSummary map[string]any
Metadata map[string]any
TargetCard *model.IotCard
Call func(context.Context) error
}
func (s *Service) executeDeviceGatewayCommand(ctx context.Context, device *model.Device, command deviceGatewayCommand) error {
deviceID := strconv.FormatUint(uint64(device.ID), 10)
seriesKey := deviceCommandSeriesKey(ctx)
observer := &deviceGatewayAttemptObserver{
service: s, operation: command.Operation, scene: command.Scene, seriesKey: seriesKey,
resource: deviceGatewayResource{
Type: constants.AuditResourceDevice, ID: deviceID, Key: audit.DeviceResourceKey(device),
ExternalID: device.IMEI, RequestSummary: command.RequestSummary,
},
}
callErr := command.Call(gateway.WithAttemptObserver(ctx, observer))
metadata := cloneDeviceCommandMetadata(command.Metadata)
metadata["integration_id"] = observer.integration
if callErr != nil {
result := constants.AuditResultFailed
summary := command.Summary + "失败"
if observer.unknown {
result = constants.AuditResultUnknown
summary = command.Summary + "结果未知"
}
s.recordDeviceCommandAudit(ctx, command.ActionCode, summary, result, device, command.TargetCard, nil, nil, metadata, callErr)
return callErr
}
if err := observer.completeSuccess(ctx, false); err != nil {
s.recordDeviceCommandAudit(ctx, command.ActionCode, command.Summary+"结果未知", constants.AuditResultUnknown,
device, command.TargetCard, nil, nil, metadata, err)
return errors.Wrap(errors.CodeDatabaseError, err, "终结设备 Gateway Integration Log 失败")
}
s.recordDeviceCommandAudit(ctx, command.ActionCode, command.Summary, constants.AuditResultSuccess,
device, command.TargetCard, nil, nil, metadata, nil)
return nil
}
func cloneDeviceCommandMetadata(source map[string]any) map[string]any {
result := make(map[string]any, len(source)+1)
for key, value := range source {
result[key] = value
}
return result
}
func deviceCommandSeriesKey(ctx context.Context) string {
linkage := auditcontext.From(ctx)
if linkage.CorrelationID != "" {
return linkage.CorrelationID
}
if linkage.RequestID != "" {
return linkage.RequestID
}
return uuid.NewString()
}
func isDeviceGatewayTimeout(err error) bool {
var appErr *errors.AppError
return stderrors.As(err, &appErr) && appErr != nil && appErr.Code == errors.CodeGatewayTimeout
}
func (s *Service) appendDeviceCommandAudit(
ctx context.Context,
tx *gorm.DB,
actionCode, summary, result string,
device *model.Device,
targetCard *model.IotCard,
cardBefore, cardAfter, metadata map[string]any,
businessErr error,
) error {
if s.auditWriter == nil || device == nil || device.ID == 0 {
return errors.New(errors.CodeInvalidStatus, "设备命令统一审计接缝未配置或资源不完整")
}
cardReferences, _, err := loadDeviceCardAuditReferences(ctx, tx, []*model.Device{device}, nil)
if err != nil {
return err
}
if targetCard != nil && targetCard.ID > 0 {
targetID := strconv.FormatUint(uint64(targetCard.ID), 10)
found := false
for i := range cardReferences[device.ID] {
resource := &cardReferences[device.ID][i]
if resource.Type == constants.AuditResourceIotCard && resource.ID != nil && *resource.ID == targetID {
found = true
if cardBefore != nil || cardAfter != nil {
resource.Relation = constants.AuditResourceRelationAffected
resource.BeforeData = cardBefore
resource.AfterData = cardAfter
resource.SubjectVisibility = constants.AuditSubjectResult
resource.SubjectSummary = summary
} else {
resource.Role = constants.AuditResourceRoleDeviceCommandTargetCard
}
}
}
if !found {
cardReferences[device.ID] = append(cardReferences[device.ID], audit.ResourceInput{
Type: constants.AuditResourceIotCard, ID: &targetID,
Key: audit.IotCardResourceKey(targetCard), DisplayName: targetCard.ICCID,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleDeviceCommandTargetCard,
IdentitySnapshot: audit.IotCardIdentitySnapshot(targetCard),
SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
}
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),
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary,
}}
resources = append(resources, cardReferences[device.ID]...)
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
Metadata: metadata, Resources: resources,
})
}
func (s *Service) recordDeviceCommandAudit(
ctx context.Context,
actionCode, summary, result string,
device *model.Device,
targetCard *model.IotCard,
cardBefore, cardAfter, metadata map[string]any,
businessErr error,
) {
if s.db == nil || s.auditWriter == nil || device == nil || device.ID == 0 {
recordDeviceAuditSecondaryFailure(ctx, actionCode, 0, businessErr, errors.New(errors.CodeInvalidStatus, "设备命令统一审计接缝未配置或资源不完整"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendDeviceCommandAudit(ctx, tx, actionCode, summary, result, device, targetCard, cardBefore, cardAfter, metadata, businessErr)
}); err != nil {
recordDeviceAuditSecondaryFailure(ctx, actionCode, device.ID, businessErr, err)
}
}
var _ deviceGatewayIntegrationLog = (*integrationlog.Repository)(nil)

View File

@@ -5,6 +5,7 @@ import (
"strconv"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"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"
@@ -51,73 +52,31 @@ func (s *Service) GatewayGetSlotInfo(ctx context.Context, identifier string) (*g
func (s *Service) GatewaySetWiFi(ctx context.Context, identifier string, req *dto.SetWiFiRequest) error {
device, imei, err := s.getGatewayDevice(ctx, identifier)
if err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSetWiFi,
"设备设置WiFi失败",
constants.AssetAuditResultFailed,
nil,
nil,
map[string]any{
"identifier": identifier,
"ssid": req.SSID,
"enabled": req.Enabled,
"password": req.Password,
},
0,
0,
0,
err,
)
return err
}
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, "")
if err = s.gatewayClient.SetWiFi(ctx, &gateway.WiFiReq{
err = s.executeDeviceGatewayCommand(ctx, device, deviceGatewayCommand{
ActionCode: constants.AuditActionDeviceWiFiSet,
Summary: "设置设备 Wi-Fi",
Operation: constants.IntegrationOperationGatewaySetWiFi,
Scene: constants.CardObservationSceneDeviceSetWiFi,
RequestSummary: map[string]any{
"device_id": device.ID, "imei": imei, "ssid": req.SSID,
"enabled_requested": req.Enabled, "credentials_configured": req.Password != "",
},
Metadata: map[string]any{
"ssid": req.SSID, "enabled_requested": req.Enabled, "credentials_configured": req.Password != "",
},
Call: func(callCtx context.Context) error {
return s.gatewayClient.SetWiFi(callCtx, &gateway.WiFiReq{
CardNo: imei,
Params: gateway.WiFiParams{
SSIDName: req.SSID,
SSIDPassword: req.Password,
Params: gateway.WiFiParams{SSIDName: req.SSID, SSIDPassword: req.Password},
})
},
}); err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSetWiFi,
"设备设置WiFi失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{
"imei": imei,
"ssid": req.SSID,
"enabled": req.Enabled,
"password": req.Password,
},
0,
0,
0,
err,
)
})
if err != nil {
return err
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSetWiFi,
"设备设置WiFi",
constants.AssetAuditResultSuccess,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{
"imei": imei,
"ssid": req.SSID,
"enabled": req.Enabled,
"password": req.Password,
},
0,
0,
0,
nil,
)
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceSetWiFi, observation, false)
return nil
}
@@ -126,58 +85,92 @@ func (s *Service) GatewaySetWiFi(ctx context.Context, identifier string, req *dt
func (s *Service) GatewaySwitchCard(ctx context.Context, identifier string, req *dto.SwitchCardRequest) error {
device, imei, err := s.getGatewayDevice(ctx, identifier)
if err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchCard,
"设备切卡失败",
constants.AssetAuditResultFailed,
nil,
nil,
map[string]any{
"identifier": identifier,
"target_iccid": req.TargetICCID,
},
0,
0,
0,
err,
)
return err
}
state, err := loadDeviceBindingState(ctx, s.db, device.ID, req.TargetICCID, false)
metadata := map[string]any{"target_iccid": req.TargetICCID}
if err != nil {
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCurrentCardSwitched, "设备切卡失败", constants.AuditResultFailed,
device, nil, nil, metadata, err)
return err
}
if state.target == nil {
appErr := errors.New(errors.CodeForbidden, "目标卡未绑定到当前设备")
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCurrentCardSwitched, "设备切卡被拒绝", constants.AuditResultDenied,
device, map[string]any{"current_iot_card_id": currentCardID(state)}, switchCardAuditItems(state), metadata, appErr)
return appErr
}
targetCard := state.cards[state.target.IotCardID]
if targetCard == nil {
appErr := errors.New(errors.CodeNotFound, "目标卡资产不存在或无权限访问")
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCurrentCardSwitched, "设备切卡失败", constants.AuditResultFailed,
device, map[string]any{"current_iot_card_id": currentCardID(state)}, switchCardAuditItems(state), metadata, appErr)
return appErr
}
metadata["target_iot_card_id"] = targetCard.ID
metadata["target_slot_position"] = state.target.SlotPosition
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, req.TargetICCID)
if err = s.gatewayClient.SwitchCard(ctx, &gateway.SwitchCardReq{
deviceID := strconv.FormatUint(uint64(device.ID), 10)
observer := &deviceGatewayAttemptObserver{
service: s, operation: constants.IntegrationOperationGatewaySwitchCard,
scene: constants.CardObservationSceneDeviceSwitchCard, seriesKey: deviceCommandSeriesKey(ctx),
resource: deviceGatewayResource{
Type: constants.AuditResourceDevice, ID: deviceID, Key: audit.DeviceResourceKey(device), ExternalID: imei,
RequestSummary: map[string]any{
"device_id": device.ID, "imei": imei, "target_iot_card_id": targetCard.ID,
"target_iccid": targetCard.ICCID, "target_slot_position": state.target.SlotPosition,
},
},
}
if err = s.gatewayClient.SwitchCard(gateway.WithAttemptObserver(ctx, observer), &gateway.SwitchCardReq{
CardNo: imei,
ICCID: req.TargetICCID,
}); err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchCard,
"设备切卡失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"target_iccid": req.TargetICCID},
0,
0,
0,
err,
)
result, summary := constants.AuditResultFailed, "设备切卡失败"
if observer.unknown {
result, summary = constants.AuditResultUnknown, "设备切卡结果未知"
}
metadata["integration_id"] = observer.integration
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCurrentCardSwitched, summary, result,
device, map[string]any{"current_iot_card_id": currentCardID(state)}, switchCardAuditItems(state), metadata, err)
return err
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchCard,
"设备切卡",
constants.AssetAuditResultSuccess,
metadata["integration_id"] = observer.integration
if err := observer.completeSuccess(ctx, false); err != nil {
appErr := errors.Wrap(errors.CodeDatabaseError, err, "终结设备切卡 Integration Log 失败")
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCurrentCardSwitched, "设备切卡结果未知", constants.AuditResultUnknown,
device, map[string]any{"current_iot_card_id": currentCardID(state)}, switchCardAuditItems(state), metadata, appErr)
return appErr
}
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedState, err := loadDeviceBindingState(ctx, tx, device.ID, targetCard.ICCID, true)
if err != nil {
return err
}
if lockedState.target == nil {
return errors.New(errors.CodeConflict, "切卡期间目标卡绑定关系已变化")
}
if err := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Where("device_id = ? AND bind_status = ?", device.ID, constants.BindStatusBound).
Update("is_current", false).Error; err != nil {
return err
}
if err := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Where("id = ? AND bind_status = ?", lockedState.target.ID, constants.BindStatusBound).
Update("is_current", true).Error; err != nil {
return err
}
return s.appendDeviceBindingAudit(ctx, tx, constants.AuditActionDeviceCurrentCardSwitched, "切换设备当前卡", constants.AuditResultSuccess,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"target_iccid": req.TargetICCID},
0,
0,
0,
nil,
)
map[string]any{"current_iot_card_id": currentCardID(lockedState)},
map[string]any{"current_iot_card_id": lockedState.target.IotCardID},
switchCardAuditItems(lockedState), metadata, nil)
})
if err != nil {
s.recordDeviceBindingAuditFailure(ctx, constants.AuditActionDeviceCurrentCardSwitched, "设备切卡结果未知", constants.AuditResultUnknown,
device, map[string]any{"current_iot_card_id": currentCardID(state)}, switchCardAuditItems(state), metadata, err)
return err
}
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceSwitchCard, observation, true)
return nil
}
@@ -186,54 +179,21 @@ func (s *Service) GatewaySwitchCard(ctx context.Context, identifier string, req
func (s *Service) GatewayRebootDevice(ctx context.Context, identifier string) error {
device, imei, err := s.getGatewayDevice(ctx, identifier)
if err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceReboot,
"设备重启失败",
constants.AssetAuditResultFailed,
nil,
nil,
map[string]any{"identifier": identifier},
0,
0,
0,
err,
)
return err
}
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, "")
if err = s.gatewayClient.RebootDevice(ctx, &gateway.DeviceOperationReq{
DeviceID: imei,
}); err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceReboot,
"设备重启失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
nil,
0,
0,
0,
err,
)
err = s.executeDeviceGatewayCommand(ctx, device, deviceGatewayCommand{
ActionCode: constants.AuditActionDeviceRebooted, Summary: "重启设备",
Operation: constants.IntegrationOperationGatewayReboot, Scene: constants.CardObservationSceneDeviceReboot,
RequestSummary: map[string]any{"device_id": device.ID, "imei": imei},
Metadata: map[string]any{"requested_action": "reboot"},
Call: func(callCtx context.Context) error {
return s.gatewayClient.RebootDevice(callCtx, &gateway.DeviceOperationReq{DeviceID: imei})
},
})
if err != nil {
return err
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceReboot,
"设备重启",
constants.AssetAuditResultSuccess,
device,
map[string]any{"device": deviceSnapshot(device)},
nil,
0,
0,
0,
nil,
)
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceReboot, observation, false)
return nil
}
@@ -242,54 +202,21 @@ func (s *Service) GatewayRebootDevice(ctx context.Context, identifier string) er
func (s *Service) GatewayResetDevice(ctx context.Context, identifier string) error {
device, imei, err := s.getGatewayDevice(ctx, identifier)
if err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceReset,
"设备恢复出厂失败",
constants.AssetAuditResultFailed,
nil,
nil,
map[string]any{"identifier": identifier},
0,
0,
0,
err,
)
return err
}
observation := s.captureDeviceControlObservation(ctx, device.ID, 0, "")
if err = s.gatewayClient.ResetDevice(ctx, &gateway.DeviceOperationReq{
DeviceID: imei,
}); err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceReset,
"设备恢复出厂失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
nil,
0,
0,
0,
err,
)
err = s.executeDeviceGatewayCommand(ctx, device, deviceGatewayCommand{
ActionCode: constants.AuditActionDeviceReset, Summary: "恢复设备出厂设置",
Operation: constants.IntegrationOperationGatewayReset, Scene: constants.CardObservationSceneDeviceReset,
RequestSummary: map[string]any{"device_id": device.ID, "imei": imei},
Metadata: map[string]any{"requested_action": "factory_reset"},
Call: func(callCtx context.Context) error {
return s.gatewayClient.ResetDevice(callCtx, &gateway.DeviceOperationReq{DeviceID: imei})
},
})
if err != nil {
return err
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceReset,
"设备恢复出厂",
constants.AssetAuditResultSuccess,
device,
map[string]any{"device": deviceSnapshot(device)},
nil,
0,
0,
0,
nil,
)
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceReset, observation, false)
return nil
}
@@ -303,244 +230,77 @@ func (s *Service) GatewaySwitchMode(ctx context.Context, identifier string, req
device, imei, err := s.getGatewayDevice(ctx, identifier)
if err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换失败",
constants.AssetAuditResultFailed,
nil,
nil,
map[string]any{
"identifier": identifier,
"switch_mode": switchMode,
"iot_card_id": req.IotCardID,
},
0,
0,
0,
err,
)
return err
}
recordRejected := func(summary, result string, businessErr error, targetCard *model.IotCard) error {
s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceSwitchModeSet, summary, result,
device, targetCard, nil, nil,
map[string]any{"requested_switch_mode": switchMode, "iot_card_id": req.IotCardID}, businessErr)
return businessErr
}
if req.SwitchMode == nil {
appErr := errors.New(errors.CodeInvalidParam, "切卡模式不能为空")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"iot_card_id": req.IotCardID},
0,
0,
0,
appErr,
)
return appErr
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, nil)
}
if switchMode != 0 && switchMode != 1 {
appErr := errors.New(errors.CodeInvalidParam, "切卡模式仅支持0或1")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID},
0,
0,
0,
appErr,
)
return appErr
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, nil)
}
if req.IotCardID == 0 {
appErr := errors.New(errors.CodeInvalidParam, "目标卡资产ID不能为空")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID},
0,
0,
0,
appErr,
)
return appErr
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, nil)
}
targetCard, err := s.iotCardStore.GetByID(ctx, req.IotCardID)
if err != nil {
if err == gorm.ErrRecordNotFound {
appErr := errors.New(errors.CodeNotFound, "目标卡资产不存在或无权限访问")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID},
0,
0,
0,
appErr,
)
return appErr
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, nil)
}
appErr := errors.Wrap(errors.CodeDatabaseError, err, "查询目标卡资产失败")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID},
0,
0,
0,
appErr,
)
return appErr
return recordRejected("设置设备切卡模式失败", constants.AuditResultFailed, appErr, nil)
}
if _, err = s.deviceSimBindingStore.GetByDeviceAndCard(ctx, device.ID, targetCard.ID); err != nil {
if err == gorm.ErrRecordNotFound {
appErr := errors.New(errors.CodeForbidden, "目标卡未绑定到当前设备,禁止设置切卡模式")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID, "iccid": targetCard.ICCID},
0,
0,
0,
appErr,
)
return appErr
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, targetCard)
}
appErr := errors.Wrap(errors.CodeDatabaseError, err, "查询设备卡绑定关系失败")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID, "iccid": targetCard.ICCID},
0,
0,
0,
appErr,
)
return appErr
return recordRejected("设置设备切卡模式失败", constants.AuditResultFailed, appErr, targetCard)
}
if targetCard.ICCID == "" {
appErr := errors.New(errors.CodeConflict, "目标卡资产缺少ICCID无法设置切卡模式")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID},
0,
0,
0,
appErr,
)
return appErr
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, targetCard)
}
if targetCard.NetworkStatus != constants.NetworkStatusOnline {
appErr := errors.New(errors.CodeForbidden, "目标卡状态异常,仅正常状态的卡允许设置切卡模式")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{
"switch_mode": switchMode,
"iot_card_id": req.IotCardID,
"iccid": targetCard.ICCID,
"network_status": targetCard.NetworkStatus,
"real_name_status": targetCard.RealNameStatus,
},
0,
0,
0,
appErr,
)
return appErr
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, targetCard)
}
if targetCard.RealNameStatus != constants.RealNameStatusVerified {
appErr := errors.New(errors.CodeForbidden, "目标卡未实名,禁止设置切卡模式")
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换被拒绝",
constants.AssetAuditResultDenied,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{
"switch_mode": switchMode,
"iot_card_id": req.IotCardID,
"iccid": targetCard.ICCID,
"network_status": targetCard.NetworkStatus,
"real_name_status": targetCard.RealNameStatus,
},
0,
0,
0,
appErr,
)
return appErr
return recordRejected("设置设备切卡模式被拒绝", constants.AuditResultDenied, appErr, targetCard)
}
observation := s.captureDeviceControlObservation(ctx, device.ID, targetCard.ID, targetCard.ICCID)
if err = s.gatewayClient.SwitchMode(ctx, &gateway.SwitchModeReq{
CardNo: imei,
SwitchMode: strconv.Itoa(switchMode),
ICCID: targetCard.ICCID,
}); err != nil {
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换失败",
constants.AssetAuditResultFailed,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID, "iccid": targetCard.ICCID},
0,
0,
0,
err,
)
err = s.executeDeviceGatewayCommand(ctx, device, deviceGatewayCommand{
ActionCode: constants.AuditActionDeviceSwitchModeSet, Summary: "设置设备切卡模式",
Operation: constants.IntegrationOperationGatewaySwitchMode, Scene: constants.CardObservationSceneDeviceSwitchMode,
RequestSummary: map[string]any{
"device_id": device.ID, "imei": imei, "switch_mode": switchMode,
"iot_card_id": targetCard.ID, "iccid": targetCard.ICCID,
},
Metadata: map[string]any{
"requested_switch_mode": switchMode, "iot_card_id": targetCard.ID, "iccid": targetCard.ICCID,
},
TargetCard: targetCard,
Call: func(callCtx context.Context) error {
return s.gatewayClient.SwitchMode(callCtx, &gateway.SwitchModeReq{
CardNo: imei, SwitchMode: strconv.Itoa(switchMode), ICCID: targetCard.ICCID,
})
},
})
if err != nil {
return err
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpDeviceSwitchMode,
"设备切卡模式切换",
constants.AssetAuditResultSuccess,
device,
map[string]any{"device": deviceSnapshot(device)},
map[string]any{"switch_mode": switchMode, "iot_card_id": req.IotCardID, "iccid": targetCard.ICCID},
0,
0,
0,
nil,
)
s.dispatchDeviceControlObservation(ctx, device.ID, constants.CardObservationSceneDeviceSwitchMode, observation, true)
return nil
}

View File

@@ -21,8 +21,8 @@ func (s *Service) BatchUpdateRealnamePolicy(ctx context.Context, req *dto.BatchU
if err != nil {
return nil, err
}
var devices []*model.Device
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var devices []model.Device
query := middleware.ApplyShopFilter(ctx, tx.Model(&model.Device{})).Clauses(clause.Locking{Strength: "UPDATE"})
if err := query.Where("id IN ?", ids).Find(&devices).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询批量设备资产失败")
@@ -30,31 +30,31 @@ func (s *Service) BatchUpdateRealnamePolicy(ctx context.Context, req *dto.BatchU
if len(devices) != len(ids) {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
result := tx.Model(&model.Device{}).Where("id IN ?", ids).Update("realname_policy", req.RealnamePolicy)
changedIDs := make([]uint, 0, len(devices))
for _, device := range devices {
if device != nil && device.RealnamePolicy != req.RealnamePolicy {
changedIDs = append(changedIDs, device.ID)
}
}
if len(changedIDs) > 0 {
result := tx.Model(&model.Device{}).Where("id IN ?", changedIDs).Update("realname_policy", req.RealnamePolicy)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新设备实名认证策略失败")
}
if result.RowsAffected != int64(len(ids)) {
if result.RowsAffected != int64(len(changedIDs)) {
return errors.New(errors.CodeConflict, "设备资产状态已变化,请刷新后重试")
}
return nil
}
return s.appendDeviceRealnamePolicyBatchAudit(ctx, tx, devices, req.RealnamePolicy)
})
if err != nil {
result := constants.AuditResultFailed
if appErr, ok := err.(*errors.AppError); ok && appErr.Code == errors.CodeForbidden {
result = constants.AuditResultDenied
}
s.recordDeviceRealnamePolicyBatchFailure(ctx, devices, req.RealnamePolicy, result, err)
return nil, err
}
s.logDeviceOperation(
ctx,
constants.AssetAuditOpAssetRealnamePolicy,
"批量更新设备实名认证策略",
constants.AssetAuditResultSuccess,
nil,
nil,
map[string]any{"asset_ids": ids, "realname_policy": req.RealnamePolicy},
len(ids),
len(ids),
0,
nil,
)
return &dto.BatchUpdateAssetRealnamePolicyResponse{SuccessCount: len(ids), RealnamePolicy: req.RealnamePolicy}, nil
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,673 @@
package device
import (
"context"
"strconv"
"github.com/google/uuid"
"gorm.io/gorm"
"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"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
"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"
)
// SetAccessAudit 注入设备身份生命周期的统一审计 Writer。
func (s *Service) SetAccessAudit(writer *audit.Writer) {
s.auditWriter = writer
}
func (s *Service) appendDeviceLifecycleAudit(
ctx context.Context,
tx *gorm.DB,
actionCode, summary, result string,
device *model.Device,
beforeData, afterData map[string]any,
references []audit.ResourceInput,
businessErr error,
) error {
if s.auditWriter == nil || device == nil || device.ID == 0 {
return errors.New(errors.CodeInvalidStatus, "设备统一审计接缝未配置或资源不完整")
}
resourceID := strconv.FormatUint(uint64(device.ID), 10)
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
resources := []audit.ResourceInput{{
Type: constants.AuditResourceDevice, ID: &resourceID,
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: summary,
}}
resources = append(resources, references...)
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform,
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
Resources: resources,
})
}
func (s *Service) recordDeviceLifecycleFailure(ctx context.Context, actionCode, summary, result string, device *model.Device, deviceID uint, businessErr error) {
if device == nil {
device = &model.Device{}
device.ID = deviceID
}
if s.db == nil || s.auditWriter == nil || device.ID == 0 {
recordDeviceAuditSecondaryFailure(ctx, actionCode, deviceID, businessErr, errors.New(errors.CodeInvalidStatus, "设备统一审计接缝未配置"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendDeviceLifecycleAudit(ctx, tx, actionCode, summary, result, device, nil, nil, nil, businessErr)
}); err != nil {
recordDeviceAuditSecondaryFailure(ctx, actionCode, device.ID, businessErr, err)
}
}
func recordDeviceAuditSecondaryFailure(ctx context.Context, actionCode string, deviceID uint, businessErr, auditErr error) {
errorCode, _ := assetAuditSvc.BuildErrorInfo(businessErr)
linkage := auditcontext.From(ctx)
auditfailure.RecordSecondaryWriteFailure(
actionCode, strconv.FormatUint(uint64(deviceID), 10),
linkage.RequestID, linkage.CorrelationID, errorCode, auditErr,
)
}
type deviceAuditOutcome struct {
Result string
Summary string
}
type deviceBatchAuditItem struct {
Device *model.Device
PrimaryRole string
Result string
Summary string
ErrorSummary string
BeforeData map[string]any
AfterData map[string]any
References []audit.ResourceInput
}
type deviceCardAuditChange struct {
ShopID *uint
Status int
}
func deviceAuditOutcomes(devices []*model.Device, result, summary string) map[uint]deviceAuditOutcome {
outcomes := make(map[uint]deviceAuditOutcome, len(devices))
for _, device := range devices {
if device != nil && device.ID > 0 {
outcomes[device.ID] = deviceAuditOutcome{Result: result, Summary: summary}
}
}
return outcomes
}
func setDeviceAuditOutcomes(outcomes map[uint]deviceAuditOutcome, ids []uint, result, summary string) {
for _, id := range ids {
if _, ok := outcomes[id]; ok {
outcomes[id] = deviceAuditOutcome{Result: result, Summary: summary}
}
}
}
func setDeviceAuditFailedItems(outcomes map[uint]deviceAuditOutcome, items []dto.AllocationDeviceFailedItem) {
for _, item := range items {
if _, ok := outcomes[item.DeviceID]; ok {
outcomes[item.DeviceID] = deviceAuditOutcome{Result: constants.AuditResultDenied, Summary: item.Reason}
}
}
}
func deviceModelsByIDs(devices []*model.Device, ids []uint) []*model.Device {
wanted := make(map[uint]struct{}, len(ids))
for _, id := range ids {
wanted[id] = struct{}{}
}
result := make([]*model.Device, 0, len(ids))
for _, device := range devices {
if device != nil {
if _, ok := wanted[device.ID]; ok {
result = append(result, device)
}
}
}
return result
}
func (s *Service) appendDeviceTransferAudit(
ctx context.Context,
tx *gorm.DB,
rootAction, itemAction, kind, summary, result string,
devices []*model.Device,
outcomes map[uint]deviceAuditOutcome,
records []*model.AssetAllocationRecord,
targetShopID *uint,
newStatus, batchTotal, successCount, failCount int,
cardReferences map[uint][]audit.ResourceInput,
businessErr error,
) error {
if cardReferences == nil {
var err error
cardReferences, _, err = loadDeviceCardAuditReferences(ctx, tx, devices, nil)
if err != nil {
return err
}
}
shops, err := loadDeviceTransferAuditShops(ctx, tx, devices, targetShopID)
if err != nil {
return err
}
recordByDeviceID := make(map[uint]*model.AssetAllocationRecord, len(records))
for _, record := range records {
if record != nil {
recordByDeviceID[record.AssetID] = record
}
}
items := make([]deviceBatchAuditItem, 0, len(devices))
for _, device := range devices {
if device == nil || device.ID == 0 {
continue
}
outcome, ok := outcomes[device.ID]
if !ok {
continue
}
var afterData map[string]any
if outcome.Result == constants.AuditResultSuccess {
afterData = map[string]any{"shop_id": targetShopID, "status": newStatus}
}
references := deviceTransferAuditReferences(device, recordByDeviceID[device.ID], targetShopID, shops)
references = append(references, cardReferences[device.ID]...)
items = append(items, deviceBatchAuditItem{
Device: device, PrimaryRole: constants.AuditResourceRoleDeviceTransferTarget,
Result: outcome.Result, Summary: outcome.Summary, ErrorSummary: outcome.Summary,
BeforeData: map[string]any{"shop_id": device.ShopID, "status": device.Status}, AfterData: afterData,
References: references,
})
}
allocationNo := ""
if len(records) > 0 && records[0] != nil {
allocationNo = records[0].AllocationNo
}
return s.appendDeviceBatchAudit(ctx, tx, rootAction, itemAction, kind, summary, result,
batchTotal, successCount, failCount, items,
map[string]any{"allocation_no": allocationNo, "to_shop_id": targetShopID, "new_status": newStatus}, businessErr)
}
func loadDeviceTransferAuditShops(ctx context.Context, tx *gorm.DB, devices []*model.Device, targetShopID *uint) (map[uint]*model.Shop, error) {
shopIDs := make(map[uint]struct{})
if targetShopID != nil && *targetShopID > 0 {
shopIDs[*targetShopID] = struct{}{}
}
for _, device := range devices {
if device != nil && device.ShopID != nil && *device.ShopID > 0 {
shopIDs[*device.ShopID] = struct{}{}
}
}
ids := make([]uint, 0, len(shopIDs))
for id := range shopIDs {
ids = append(ids, id)
}
var rows []*model.Shop
if len(ids) > 0 {
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", ids).Find(&rows).Error; err != nil {
return nil, err
}
}
shops := make(map[uint]*model.Shop, len(rows))
for _, shop := range rows {
shops[shop.ID] = shop
}
return shops, nil
}
func deviceTransferAuditReferences(device *model.Device, record *model.AssetAllocationRecord, targetShopID *uint, shops map[uint]*model.Shop) []audit.ResourceInput {
resources := make([]audit.ResourceInput, 0, 3)
if record != nil && record.ID > 0 {
recordID := strconv.FormatUint(uint64(record.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceAssetAllocationRecord, ID: &recordID,
Key: recordID, DisplayName: record.AllocationNo,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleAssetAllocationRecord,
IdentitySnapshot: map[string]any{
"id": record.ID, "allocation_no": record.AllocationNo, "asset_type": record.AssetType,
"asset_id": record.AssetID, "asset_identifier": record.AssetIdentifier,
"from_owner_type": record.FromOwnerType, "from_owner_id": record.FromOwnerID,
"to_owner_type": record.ToOwnerType, "to_owner_id": record.ToOwnerID,
},
AfterData: map[string]any{"created": true}, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
if device.ShopID != nil && *device.ShopID > 0 {
resources = appendDeviceShopAuditReference(resources, shops[*device.ShopID], *device.ShopID, constants.AuditResourceRoleTransferSourceShop)
}
if targetShopID != nil && *targetShopID > 0 {
resources = appendDeviceShopAuditReference(resources, shops[*targetShopID], *targetShopID, constants.AuditResourceRoleTransferTargetShop)
}
return resources
}
func appendDeviceShopAuditReference(resources []audit.ResourceInput, shop *model.Shop, shopID uint, role string) []audit.ResourceInput {
id := strconv.FormatUint(uint64(shopID), 10)
name := id
identity := map[string]any{"id": shopID}
if shop != nil {
name = shop.ShopName
identity = map[string]any{"id": shop.ID, "shop_code": shop.ShopCode, "shop_name": shop.ShopName, "parent_id": shop.ParentID, "level": shop.Level}
}
return append(resources, audit.ResourceInput{
Type: constants.AuditResourceShop, ID: &id, Key: id, DisplayName: name,
Relation: constants.AuditResourceRelationReference, Role: role,
IdentitySnapshot: identity, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
func (s *Service) appendDeviceBatchAudit(
ctx context.Context,
tx *gorm.DB,
rootAction, itemAction, kind, summary, result string,
batchTotal, successCount, failCount int,
items []deviceBatchAuditItem,
metadata map[string]any,
businessErr error,
) error {
linkage := auditcontext.From(ctx)
batchKey := linkage.RequestID
if batchKey == "" {
batchKey = linkage.CorrelationID
}
if s.auditWriter == nil || batchKey == "" {
return errors.New(errors.CodeInvalidStatus, "设备批量审计上下文不完整")
}
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
children := make([]audit.AppendInput, 0, len(items))
for _, item := range items {
if item.Device == nil || item.Device.ID == 0 {
continue
}
deviceID := strconv.FormatUint(uint64(item.Device.ID), 10)
primaryRole := item.PrimaryRole
if primaryRole == "" {
primaryRole = constants.AuditResourceRoleDeviceTarget
}
resources := []audit.ResourceInput{{
Type: constants.AuditResourceDevice, ID: &deviceID,
Key: audit.DeviceResourceKey(item.Device), DisplayName: item.Device.VirtualNo,
Relation: constants.AuditResourceRelationPrimary, Role: primaryRole,
IdentitySnapshot: audit.DeviceIdentitySnapshot(item.Device), BeforeData: item.BeforeData, AfterData: item.AfterData,
SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: item.Summary,
}}
resources = append(resources, item.References...)
childErrorCode, childErrorSummary := "", ""
if item.Result == constants.AuditResultFailed || item.Result == constants.AuditResultDenied {
childErrorCode = errorCode
childErrorSummary = item.ErrorSummary
if childErrorSummary == "" {
childErrorSummary = errorSummary
}
}
children = append(children, audit.AppendInput{
EventID: stableDeviceBatchEventID(kind+"-"+item.Result+"-device", batchKey+":"+deviceID),
ActionCode: itemAction, Summary: item.Summary, ScopeType: constants.AuditScopePlatform, Result: item.Result,
ErrorCode: childErrorCode, ErrorSummary: childErrorSummary, Resources: resources,
})
}
return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{
Root: audit.AppendInput{
EventID: stableDeviceBatchEventID(kind+"-"+result, batchKey),
ActionCode: rootAction, Summary: summary, ScopeType: constants.AuditScopePlatform, Result: result,
ErrorCode: errorCode, ErrorSummary: errorSummary,
BatchTotal: batchTotal, SuccessCount: successCount, FailCount: failCount, Metadata: metadata,
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceDeviceBatch, Key: batchKey, DisplayName: batchKey,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleDeviceBatch,
IdentitySnapshot: map[string]any{
"request_id": linkage.RequestID, "correlation_id": linkage.CorrelationID,
"device_count": len(items), "operation_type": kind,
},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
},
Children: children,
})
}
func loadDeviceCardAuditReferences(
ctx context.Context,
tx *gorm.DB,
devices []*model.Device,
change *deviceCardAuditChange,
) (map[uint][]audit.ResourceInput, []uint, error) {
deviceByID := make(map[uint]*model.Device, len(devices))
deviceIDs := make([]uint, 0, len(devices))
for _, device := range devices {
if device != nil && device.ID > 0 {
deviceByID[device.ID] = device
deviceIDs = append(deviceIDs, device.ID)
}
}
result := make(map[uint][]audit.ResourceInput)
if len(deviceIDs) == 0 {
return result, nil, nil
}
var bindings []*model.DeviceSimBinding
if err := tx.WithContext(ctx).Where("device_id IN ? AND bind_status = ?", deviceIDs, 1).Find(&bindings).Error; err != nil {
return nil, nil, err
}
cardIDs := make([]uint, 0, len(bindings))
seenCards := make(map[uint]struct{}, len(bindings))
for _, binding := range bindings {
if _, exists := seenCards[binding.IotCardID]; !exists {
seenCards[binding.IotCardID] = struct{}{}
cardIDs = append(cardIDs, binding.IotCardID)
}
}
var cards []*model.IotCard
if len(cardIDs) > 0 {
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", cardIDs).Find(&cards).Error; err != nil {
return nil, nil, err
}
}
cardByID := make(map[uint]*model.IotCard, len(cards))
for _, card := range cards {
cardByID[card.ID] = card
}
for _, binding := range bindings {
device := deviceByID[binding.DeviceID]
card := cardByID[binding.IotCardID]
bindingID := strconv.FormatUint(uint64(binding.ID), 10)
cardID := strconv.FormatUint(uint64(binding.IotCardID), 10)
deviceVirtualNo, cardICCID, cardVirtualNo := "", "", ""
if device != nil {
deviceVirtualNo = device.VirtualNo
}
cardIdentity := map[string]any{"id": binding.IotCardID}
cardKey, cardName := cardID, cardID
if card != nil {
cardICCID, cardVirtualNo = card.ICCID, card.VirtualNo
cardKey, cardName = audit.IotCardResourceKey(card), card.ICCID
cardIdentity = audit.IotCardIdentitySnapshot(card)
}
cardRelation := constants.AuditResourceRelationReference
var beforeData, afterData map[string]any
if change != nil {
cardRelation = constants.AuditResourceRelationAffected
if card != nil {
beforeData = map[string]any{"shop_id": card.ShopID, "status": card.Status}
}
afterData = map[string]any{"shop_id": change.ShopID, "status": change.Status}
}
result[binding.DeviceID] = append(result[binding.DeviceID],
audit.ResourceInput{
Type: constants.AuditResourceIotCard, ID: &cardID, Key: cardKey, DisplayName: cardName,
Relation: cardRelation, Role: constants.AuditResourceRoleDeviceBoundCard,
IdentitySnapshot: cardIdentity, BeforeData: beforeData, AfterData: afterData,
SubjectVisibility: constants.AuditSubjectInternalOnly,
},
audit.ResourceInput{
Type: constants.AuditResourceDeviceSIMBinding, ID: &bindingID,
Key: bindingID, DisplayName: deviceVirtualNo,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleDeviceCardBinding,
IdentitySnapshot: map[string]any{
"id": binding.ID, "device_id": binding.DeviceID, "device_virtual_no": deviceVirtualNo,
"slot_position": binding.SlotPosition, "iot_card_id": binding.IotCardID,
"iccid": cardICCID, "virtual_no": cardVirtualNo, "is_current": binding.IsCurrent,
},
SubjectVisibility: constants.AuditSubjectInternalOnly,
},
)
}
return result, cardIDs, nil
}
func (s *Service) recordDeviceTransferAuditFailure(
ctx context.Context,
rootAction, itemAction, kind, summary, result string,
devices []*model.Device,
outcomes map[uint]deviceAuditOutcome,
targetShopID *uint,
newStatus, batchTotal, successCount, failCount int,
businessErr error,
) {
if s.db == nil || s.auditWriter == nil || len(devices) == 0 {
recordDeviceAuditSecondaryFailure(ctx, rootAction, 0, businessErr, errors.New(errors.CodeInvalidStatus, "设备批量审计接缝未配置或资源不完整"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendDeviceTransferAudit(ctx, tx, rootAction, itemAction, kind, summary, result,
devices, outcomes, nil, targetShopID, newStatus, batchTotal, successCount, failCount, nil, businessErr)
}); err != nil {
recordDeviceAuditSecondaryFailure(ctx, rootAction, 0, businessErr, err)
}
}
func stableDeviceBatchEventID(kind, key string) string {
return "evt_" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("device:"+kind+":"+key)).String()
}
func (s *Service) appendDeviceSeriesBindingAudit(
ctx context.Context,
tx *gorm.DB,
devices []*model.Device,
outcomes map[uint]deviceAuditOutcome,
seriesID *uint,
result string,
batchTotal, successCount, failCount int,
metadata map[string]any,
businessErr error,
) error {
series, err := loadDeviceSeriesAuditResources(ctx, tx, devices, seriesID)
if err != nil {
return err
}
cardReferences, _, err := loadDeviceCardAuditReferences(ctx, tx, devices, nil)
if err != nil {
return err
}
items := make([]deviceBatchAuditItem, 0, len(devices))
for _, device := range devices {
if device == nil || device.ID == 0 {
continue
}
outcome, ok := outcomes[device.ID]
if !ok {
continue
}
var afterData map[string]any
if outcome.Result == constants.AuditResultSuccess {
afterData = map[string]any{"series_id": seriesID}
}
references := deviceSeriesAuditReferences(device.SeriesID, seriesID, series)
references = append(references, cardReferences[device.ID]...)
items = append(items, deviceBatchAuditItem{
Device: device, PrimaryRole: constants.AuditResourceRoleDeviceSeriesTarget,
Result: outcome.Result, Summary: outcome.Summary, ErrorSummary: outcome.Summary,
BeforeData: map[string]any{"series_id": device.SeriesID}, AfterData: afterData,
References: references,
})
}
return s.appendDeviceBatchAudit(ctx, tx,
constants.AuditActionDeviceSeriesBindingBatch,
constants.AuditActionDeviceSeriesBound,
"series-binding", "批量设置设备系列绑定", result,
batchTotal, successCount, failCount, items, metadata, businessErr)
}
func loadDeviceSeriesAuditResources(ctx context.Context, tx *gorm.DB, devices []*model.Device, targetSeriesID *uint) (map[uint]*model.PackageSeries, error) {
seriesIDs := make(map[uint]struct{})
if targetSeriesID != nil && *targetSeriesID > 0 {
seriesIDs[*targetSeriesID] = struct{}{}
}
for _, device := range devices {
if device != nil && device.SeriesID != nil && *device.SeriesID > 0 {
seriesIDs[*device.SeriesID] = struct{}{}
}
}
ids := make([]uint, 0, len(seriesIDs))
for id := range seriesIDs {
ids = append(ids, id)
}
var rows []*model.PackageSeries
if len(ids) > 0 {
if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", ids).Find(&rows).Error; err != nil {
return nil, err
}
}
series := make(map[uint]*model.PackageSeries, len(rows))
for _, item := range rows {
series[item.ID] = item
}
return series, nil
}
func deviceSeriesAuditReferences(previousID, targetID *uint, series map[uint]*model.PackageSeries) []audit.ResourceInput {
resources := make([]audit.ResourceInput, 0, 2)
if previousID != nil && *previousID > 0 {
resources = appendDevicePackageSeriesAuditReference(resources, series[*previousID], *previousID, constants.AuditResourceRolePreviousPackageSeries)
}
if targetID != nil && *targetID > 0 {
resources = appendDevicePackageSeriesAuditReference(resources, series[*targetID], *targetID, constants.AuditResourceRoleTargetPackageSeries)
}
return resources
}
func appendDevicePackageSeriesAuditReference(resources []audit.ResourceInput, series *model.PackageSeries, seriesID uint, role string) []audit.ResourceInput {
id := strconv.FormatUint(uint64(seriesID), 10)
name := id
identity := map[string]any{"id": seriesID}
if series != nil {
name = series.SeriesName
identity = map[string]any{"id": series.ID, "series_code": series.SeriesCode, "series_name": series.SeriesName, "status": series.Status}
}
return append(resources, audit.ResourceInput{
Type: constants.AuditResourcePackageSeries, ID: &id, Key: id, DisplayName: name,
Relation: constants.AuditResourceRelationReference, Role: role,
IdentitySnapshot: identity, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
func (s *Service) recordDeviceSeriesBindingAuditFailure(
ctx context.Context,
devices []*model.Device,
outcomes map[uint]deviceAuditOutcome,
seriesID *uint,
result string,
batchTotal, successCount, failCount int,
metadata map[string]any,
businessErr error,
) {
if s.db == nil || s.auditWriter == nil || len(devices) == 0 {
recordDeviceAuditSecondaryFailure(ctx, constants.AuditActionDeviceSeriesBindingBatch, 0, businessErr, errors.New(errors.CodeInvalidStatus, "设备系列绑定审计接缝未配置或资源不完整"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendDeviceSeriesBindingAudit(ctx, tx, devices, outcomes, seriesID, result,
batchTotal, successCount, failCount, metadata, businessErr)
}); err != nil {
recordDeviceAuditSecondaryFailure(ctx, constants.AuditActionDeviceSeriesBindingBatch, 0, businessErr, err)
}
}
func (s *Service) appendDeviceRealnamePolicyBatchAudit(ctx context.Context, tx *gorm.DB, devices []*model.Device, policy string) error {
cardReferences, _, err := loadDeviceCardAuditReferences(ctx, tx, devices, nil)
if err != nil {
return err
}
items := make([]deviceBatchAuditItem, 0, len(devices))
for _, device := range devices {
if device == nil || device.ID == 0 || device.RealnamePolicy == policy {
continue
}
items = append(items, deviceBatchAuditItem{
Device: device, PrimaryRole: constants.AuditResourceRoleDeviceTarget,
Result: constants.AuditResultSuccess, Summary: "更新设备实名策略",
BeforeData: map[string]any{"realname_policy": device.RealnamePolicy},
AfterData: map[string]any{"realname_policy": policy},
References: cardReferences[device.ID],
})
}
return s.appendDeviceBatchAudit(ctx, tx,
constants.AuditActionDeviceRealnamePolicyBatchUpdated,
constants.AuditActionDeviceRealnamePolicyUpdated,
"realname-policy", "批量更新设备实名策略", constants.AuditResultSuccess,
len(items), len(items), 0, items,
map[string]any{"realname_policy": policy, "requested_count": len(devices)}, nil)
}
func (s *Service) recordDeviceRealnamePolicyBatchFailure(ctx context.Context, devices []*model.Device, policy, result string, businessErr error) {
if s.db == nil || s.auditWriter == nil || len(devices) == 0 {
recordDeviceAuditSecondaryFailure(ctx, constants.AuditActionDeviceRealnamePolicyBatchUpdated, 0, businessErr, errors.New(errors.CodeInvalidStatus, "设备实名策略批量审计接缝未配置或资源不完整"))
return
}
items := make([]deviceBatchAuditItem, 0, len(devices))
for _, device := range devices {
if device == nil || device.ID == 0 {
continue
}
items = append(items, deviceBatchAuditItem{
Device: device, PrimaryRole: constants.AuditResourceRoleDeviceTarget,
Result: result, Summary: "更新设备实名策略未完成",
BeforeData: map[string]any{"realname_policy": device.RealnamePolicy},
AfterData: map[string]any{"requested_realname_policy": policy},
})
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendDeviceBatchAudit(ctx, tx,
constants.AuditActionDeviceRealnamePolicyBatchUpdated,
constants.AuditActionDeviceRealnamePolicyUpdated,
"realname-policy", "批量更新设备实名策略未完成", result,
len(devices), 0, len(devices), items, map[string]any{"realname_policy": policy}, businessErr)
}); err != nil {
recordDeviceAuditSecondaryFailure(ctx, constants.AuditActionDeviceRealnamePolicyBatchUpdated, 0, businessErr, err)
}
}
func (s *Service) appendDeviceRealnamePolicyAudit(
ctx context.Context,
tx *gorm.DB,
summary, result string,
device *model.Device,
beforeData, afterData map[string]any,
businessErr error,
) error {
if s.auditWriter == nil || device == nil || device.ID == 0 {
return errors.New(errors.CodeInvalidStatus, "设备实名策略审计接缝未配置或资源不完整")
}
cardReferences, _, err := loadDeviceCardAuditReferences(ctx, tx, []*model.Device{device}, nil)
if err != nil {
return err
}
deviceID := strconv.FormatUint(uint64(device.ID), 10)
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
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: summary,
}}
resources = append(resources, cardReferences[device.ID]...)
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionDeviceRealnamePolicyUpdated, Summary: summary,
ScopeType: constants.AuditScopePlatform, Result: result,
ErrorCode: errorCode, ErrorSummary: errorSummary, Resources: resources,
})
}
func (s *Service) recordDeviceRealnamePolicyFailure(ctx context.Context, device *model.Device, deviceID uint, businessErr error) {
if device == nil || device.ID == 0 || s.db == nil || s.auditWriter == nil {
recordDeviceAuditSecondaryFailure(ctx, constants.AuditActionDeviceRealnamePolicyUpdated, deviceID, businessErr, errors.New(errors.CodeInvalidStatus, "设备实名策略审计接缝未配置或资源不完整"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendDeviceRealnamePolicyAudit(ctx, tx, "更新设备实名策略失败", constants.AuditResultFailed,
device, map[string]any{"realname_policy": device.RealnamePolicy}, nil, businessErr)
}); err != nil {
recordDeviceAuditSecondaryFailure(ctx, constants.AuditActionDeviceRealnamePolicyUpdated, deviceID, businessErr, err)
}
}

View File

@@ -2,11 +2,17 @@ package device_import
import (
"context"
"strconv"
"time"
"gorm.io/gorm"
infraAudit "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"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
"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"
)
// AssetAuditService 资产审计服务接口。
@@ -14,62 +20,62 @@ type AssetAuditService interface {
LogOperation(ctx context.Context, log *model.AssetOperationLog)
}
func (s *Service) logDeviceImportAudit(ctx context.Context, p assetAuditSvc.BuildLogParams) {
if s == nil || s.assetAudit == nil {
func (s *Service) writeDeviceImportTaskAudit(ctx context.Context, tx *gorm.DB, task *model.DeviceImportTask, before, after map[string]any, result, phase, errorCode, errorSummary string) error {
scopeType, scopeID := constants.AuditScopePlatform, ""
if task.OperatorShopID != nil {
scopeType, scopeID = constants.AuditScopeShop, strconv.FormatUint(uint64(*task.OperatorShopID), 10)
}
return s.auditWriter.WriteTask(ctx, tx, infraAudit.TaskInput{
EventID: infraAudit.TaskEventID(constants.AuditResourceDeviceImportTask, task.ID, phase),
ActionCode: constants.AuditActionDeviceImportTaskCreated, Summary: "创建设备导入任务",
TaskID: task.ID, TaskNo: task.TaskNo,
Actor: infraAudit.ActorInput{
Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(middleware.GetUserIDFromContext(ctx)), 10),
Name: middleware.GetUsernameFromContext(ctx), ShopID: task.OperatorShopID,
},
Source: constants.AuditSourceAdminAPI, ScopeType: scopeType, ScopeID: scopeID,
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
IdentitySnapshot: map[string]any{
"id": task.ID, "task_no": task.TaskNo, "file_name": task.FileName,
"operation_type": task.OperationType, "target_id": task.TargetID,
"batch_no": task.BatchNo, "realname_policy": task.RealnamePolicy,
},
BeforeData: before, AfterData: after,
})
}
func (s *Service) recordDeviceImportTaskAudit(ctx context.Context, task *model.DeviceImportTask, before, after map[string]any, result, phase string, errorCode int, summary string) {
if s == nil || s.db == nil || s.auditWriter == nil || task == nil || task.TaskNo == "" {
return
}
if p.Operator.Type == "" {
p.Operator = assetAuditSvc.OperatorFromContext(ctx)
}
if p.OperationType == "" {
p.OperationType = constants.AssetAuditOpDeviceImportTaskCreate
}
if p.AssetType == "" {
p.AssetType = constants.AssetTypeDevice
}
p.BeforeData, p.AfterData = assetAuditSvc.WrapOperationContent(p.BeforeData, p.AfterData, nil)
s.assetAudit.LogOperation(ctx, assetAuditSvc.BuildLog(ctx, p))
}
func newDeviceImportAuditParams(
taskID uint,
taskNo string,
req *dto.ImportDeviceRequest,
resultStatus string,
err error,
) assetAuditSvc.BuildLogParams {
afterData := map[string]any{}
if req != nil {
afterData["batch_no"] = req.BatchNo
afterData["file_key"] = req.FileKey
afterData["realname_policy"] = req.RealnamePolicy
}
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
return assetAuditSvc.BuildLogParams{
AssetID: taskID,
AssetIdentifier: taskNo,
OperationDesc: "创建设备导入任务",
ResultStatus: resultStatus,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AfterData: afterData,
code := strconv.Itoa(errorCode)
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.writeDeviceImportTaskAudit(ctx, tx, task, before, after, result, phase, code, summary)
}); err != nil {
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionDeviceImportTaskCreated, task.TaskNo, "", task.TaskNo, code, err)
}
}
func newDeviceBatchAllocationAuditParams(taskID uint, taskNo string, req *dto.CreateDeviceBatchAllocationRequest, resultStatus string, err error) assetAuditSvc.BuildLogParams {
afterData := map[string]any{}
if req != nil {
afterData["file_key"] = req.FileKey
afterData["operation_type"] = req.OperationType
if req.OperationType != constants.DeviceImportOperationRecall {
afterData["target_id"] = req.TargetID
}
}
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err)
return assetAuditSvc.BuildLogParams{
AssetID: taskID, AssetIdentifier: taskNo,
OperationType: constants.AssetAuditOpDeviceBatchTaskCreate,
OperationDesc: "创建设备CSV批量操作任务", ResultStatus: resultStatus,
ErrorCode: errorCode, ErrorMsg: errorMsg, AfterData: afterData,
func (s *Service) failEnqueueWithAudit(ctx context.Context, task *model.DeviceImportTask, summary string) error {
before := deviceImportTaskState(task)
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
now := time.Now()
if err := tx.WithContext(ctx).Model(&model.DeviceImportTask{}).Where("id = ?", task.ID).Updates(map[string]any{
"status": model.ImportTaskStatusFailed, "error_message": summary, "completed_at": now, "updated_at": now,
}).Error; err != nil {
return err
}
task.Status, task.ErrorMessage = model.ImportTaskStatusFailed, summary
return s.writeDeviceImportTaskAudit(ctx, tx, task, before, deviceImportTaskState(task), constants.AuditResultFailed, "enqueue_failed", strconv.Itoa(errors.CodeTaskQueueError), summary)
})
}
func deviceImportTaskState(task *model.DeviceImportTask) map[string]any {
if task == nil {
return nil
}
return map[string]any{
"status": task.Status, "total_count": task.TotalCount, "success_count": task.SuccessCount,
"skip_count": task.SkipCount, "fail_count": task.FailCount, "warning_count": task.WarningCount,
}
}

View File

@@ -6,6 +6,7 @@ import (
"strings"
"time"
"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/internal/store"
@@ -23,6 +24,7 @@ type Service struct {
importTaskStore *postgres.DeviceImportTaskStore
queueClient *queue.Client
assetAudit AssetAuditService
auditWriter *audit.Writer
}
type DeviceImportPayload struct {
@@ -34,21 +36,24 @@ func New(
importTaskStore *postgres.DeviceImportTaskStore,
queueClient *queue.Client,
assetAudit AssetAuditService,
auditWriters ...*audit.Writer,
) *Service {
return &Service{
service := &Service{
db: db,
importTaskStore: importTaskStore,
queueClient: queueClient,
assetAudit: assetAudit,
}
if len(auditWriters) > 0 {
service.auditWriter = auditWriters[0]
}
return service
}
func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceRequest) (*dto.ImportDeviceResponse, error) {
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
appErr := errors.New(errors.CodeUnauthorized, "未授权访问")
s.logDeviceImportAudit(ctx, newDeviceImportAuditParams(0, "", req, constants.AssetAuditResultDenied, appErr))
return nil, appErr
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
taskNo := s.importTaskStore.GenerateTaskNo(ctx)
@@ -67,9 +72,17 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceReq
task.Creator = userID
task.Updater = userID
if err := s.importTaskStore.Create(ctx, task); err != nil {
if s.auditWriter == nil {
return nil, errors.New(errors.CodeInvalidStatus, "设备导入任务统一审计接缝未配置")
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Create(task).Error; err != nil {
return err
}
return s.writeDeviceImportTaskAudit(ctx, tx, task, nil, deviceImportTaskState(task), constants.AuditResultSuccess, "created", "", "")
}); err != nil {
appErr := errors.Wrap(errors.CodeInternalError, err, "创建导入任务失败")
s.logDeviceImportAudit(ctx, newDeviceImportAuditParams(0, taskNo, req, constants.AssetAuditResultFailed, appErr))
s.recordDeviceImportTaskAudit(ctx, task, nil, deviceImportTaskState(task), constants.AuditResultFailed, "create_failed", errors.CodeDatabaseError, "创建设备导入任务失败")
return nil, appErr
}
@@ -81,14 +94,13 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceReq
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeDeviceImport)),
)
if err != nil {
s.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败: "+err.Error())
if secondaryErr := s.failEnqueueWithAudit(ctx, task, "设备导入任务入队失败"); secondaryErr != nil {
s.recordDeviceImportTaskAudit(ctx, task, nil, deviceImportTaskState(task), constants.AuditResultFailed, "enqueue_audit_failed", errors.CodeTaskQueueError, "设备导入任务入队失败")
}
appErr := errors.Wrap(errors.CodeInternalError, err, "任务入队失败")
s.logDeviceImportAudit(ctx, newDeviceImportAuditParams(task.ID, taskNo, req, constants.AssetAuditResultFailed, appErr))
return nil, appErr
}
s.logDeviceImportAudit(ctx, newDeviceImportAuditParams(task.ID, taskNo, req, constants.AssetAuditResultSuccess, nil))
return &dto.ImportDeviceResponse{
TaskID: task.ID,
TaskNo: taskNo,
@@ -101,9 +113,7 @@ func (s *Service) CreateBatchAllocationTask(ctx context.Context, req *dto.Create
userID := middleware.GetUserIDFromContext(ctx)
userType := middleware.GetUserTypeFromContext(ctx)
if userID == 0 || (userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform && userType != constants.UserTypeAgent) {
appErr := errors.New(errors.CodeForbidden, "仅平台和代理后台账号可创建设备CSV批量任务")
s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(0, "", req, constants.AssetAuditResultDenied, appErr))
return nil, appErr
return nil, errors.New(errors.CodeForbidden, "仅平台和代理后台账号可创建设备CSV批量任务")
}
if req == nil || !constants.IsDeviceImportOperation(req.OperationType) || req.OperationType == constants.DeviceImportOperationCreate {
return nil, errors.New(errors.CodeInvalidParam, "设备CSV批量任务参数不合法")
@@ -136,20 +146,28 @@ func (s *Service) CreateBatchAllocationTask(ctx context.Context, req *dto.Create
CreatorName: middleware.GetUsernameFromContext(ctx),
}
task.Creator, task.Updater = userID, userID
if err := s.importTaskStore.Create(ctx, task); err != nil {
if s.auditWriter == nil {
return nil, errors.New(errors.CodeInvalidStatus, "设备批量任务统一审计接缝未配置")
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.WithContext(ctx).Create(task).Error; err != nil {
return err
}
return s.writeDeviceImportTaskAudit(ctx, tx, task, nil, deviceImportTaskState(task), constants.AuditResultSuccess, "created", "", "")
}); err != nil {
appErr := errors.Wrap(errors.CodeDatabaseError, err, "创建设备CSV批量任务失败")
s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(0, taskNo, req, constants.AssetAuditResultFailed, appErr))
s.recordDeviceImportTaskAudit(ctx, task, nil, deviceImportTaskState(task), constants.AuditResultFailed, "create_failed", errors.CodeDatabaseError, "创建设备 CSV 批量任务失败")
return nil, appErr
}
if err := s.queueClient.EnqueueTask(ctx, constants.TaskTypeDeviceImport, DeviceImportPayload{TaskID: task.ID},
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeDeviceImport)),
asynq.Timeout(constants.DeviceBatchAllocationTaskTimeout)); err != nil {
_ = s.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败")
if secondaryErr := s.failEnqueueWithAudit(ctx, task, "设备 CSV 批量任务入队失败"); secondaryErr != nil {
s.recordDeviceImportTaskAudit(ctx, task, nil, deviceImportTaskState(task), constants.AuditResultFailed, "enqueue_audit_failed", errors.CodeTaskQueueError, "设备 CSV 批量任务入队失败")
}
appErr := errors.Wrap(errors.CodeInternalError, err, "设备CSV批量任务入队失败")
s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(task.ID, taskNo, req, constants.AssetAuditResultFailed, appErr))
return nil, appErr
}
s.logDeviceImportAudit(ctx, newDeviceBatchAllocationAuditParams(task.ID, taskNo, req, constants.AssetAuditResultSuccess, nil))
return &dto.CreateDeviceBatchAllocationResponse{
TaskID: task.ID, TaskNo: task.TaskNo, Message: "设备CSV批量任务已创建Worker 将异步处理CSV文件",
}, nil

View File

@@ -0,0 +1,494 @@
package exchange
import (
"context"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
"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"
)
type cardExchangeAuditBefore struct {
Wallets map[uint]model.AssetWallet
DeviceBindings []*model.PersonalCustomerDevice
ICCIDBindings []*model.PersonalCustomerICCID
}
// SetAccessAudit 注入卡与设备换货完整用例的统一审计 Writer。
func (s *Service) SetAccessAudit(writer *audit.Writer) {
s.auditWriter = writer
}
func (s *Service) appendCardExchangeAudit(
ctx context.Context,
tx *gorm.DB,
actionCode, summary, result string,
order *model.ExchangeOrder,
oldCard, newCard *model.IotCard,
orderBefore, orderAfter map[string]any,
oldCardBefore, oldCardAfter map[string]any,
newCardBefore, newCardAfter map[string]any,
extra []audit.ResourceInput,
businessErr error,
) error {
if order == nil || order.OldAssetType != constants.ExchangeAssetTypeIotCard {
return nil
}
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "卡换货统一审计接缝未配置")
}
internalOnly := actionCode == constants.AuditActionCardExchangeRenewed
resources := []audit.ResourceInput{cardExchangeOrderAuditResource(order, summary, internalOnly, orderBefore, orderAfter)}
if oldCard != nil {
resources = append(resources, cardExchangeCardAuditResource(oldCard, constants.AuditResourceRoleCardExchangeOldCard, summary, internalOnly, oldCardBefore, oldCardAfter))
}
if newCard != nil {
resources = append(resources, cardExchangeCardAuditResource(newCard, constants.AuditResourceRoleCardExchangeNewCard, summary, internalOnly, newCardBefore, newCardAfter))
}
shopResource, err := loadCardExchangeShopAuditResource(ctx, tx, order.ShopID)
if err != nil {
return err
}
if shopResource != nil {
resources = append(resources, *shopResource)
}
resources = append(resources, extra...)
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
scopeType, scopeID := constants.AuditScopePlatform, ""
if actionCode == constants.AuditActionCardExchangeShippingInfoSubmitted {
scopeType = constants.AuditScopePersonalCustomer
scopeID = auditcontext.From(ctx).ActorID
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: scopeType, ScopeID: scopeID,
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
Metadata: map[string]any{"flow_type": effectiveExchangeFlowType(order.FlowType), "migrate_data": order.MigrateData},
Resources: resources,
})
}
func cardExchangeOrderAuditResource(order *model.ExchangeOrder, summary string, internalOnly bool, beforeData, afterData map[string]any) audit.ResourceInput {
key := order.ExchangeNo
if key == "" {
key = "iot_card:" + strconv.FormatUint(uint64(order.OldAssetID), 10) + ":exchange"
}
var id *string
if order.ID > 0 {
value := strconv.FormatUint(uint64(order.ID), 10)
id = &value
}
resource := audit.ResourceInput{
Type: constants.AuditResourceExchangeOrder, ID: id, Key: key, DisplayName: key,
Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleCardExchangeOrder,
IdentitySnapshot: map[string]any{
"id": order.ID, "exchange_no": order.ExchangeNo, "flow_type": effectiveExchangeFlowType(order.FlowType),
"old_asset_type": order.OldAssetType, "old_asset_id": order.OldAssetID, "old_asset_identifier": order.OldAssetIdentifier,
"new_asset_type": order.NewAssetType, "new_asset_id": order.NewAssetID, "new_asset_identifier": order.NewAssetIdentifier,
"shop_id": order.ShopID, "status": order.Status,
},
BeforeData: beforeData, AfterData: afterData,
}
if internalOnly {
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
} else {
resource.SubjectVisibility = constants.AuditSubjectResult
resource.SubjectSummary = summary
}
return resource
}
func cardExchangeCardAuditResource(card *model.IotCard, role, summary string, internalOnly bool, beforeData, afterData map[string]any) audit.ResourceInput {
id := strconv.FormatUint(uint64(card.ID), 10)
relation := constants.AuditResourceRelationReference
if len(beforeData) > 0 || len(afterData) > 0 {
relation = constants.AuditResourceRelationAffected
}
resource := audit.ResourceInput{
Type: constants.AuditResourceIotCard, ID: &id, Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID,
Relation: relation, Role: role, IdentitySnapshot: audit.IotCardIdentitySnapshot(card),
BeforeData: beforeData, AfterData: afterData,
}
if internalOnly {
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
} else {
resource.SubjectVisibility = constants.AuditSubjectResult
resource.SubjectSummary = summary
}
return resource
}
func loadCardExchangeShopAuditResource(ctx context.Context, tx *gorm.DB, shopID *uint) (*audit.ResourceInput, error) {
if shopID == nil || *shopID == 0 {
return nil, nil
}
var shop model.Shop
if err := tx.WithContext(ctx).Unscoped().Where("id = ?", *shopID).First(&shop).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货所属店铺失败")
}
id := strconv.FormatUint(uint64(shop.ID), 10)
return &audit.ResourceInput{
Type: constants.AuditResourceShop, ID: &id, Key: id, DisplayName: shop.ShopName,
Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleCardExchangeShop,
IdentitySnapshot: map[string]any{"id": shop.ID, "shop_code": shop.ShopCode, "shop_name": shop.ShopName, "parent_id": shop.ParentID, "level": shop.Level},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}, nil
}
func (s *Service) captureCardExchangeAuditBefore(ctx context.Context, tx *gorm.DB, oldCard, newCard *model.IotCard) (*cardExchangeAuditBefore, error) {
state := &cardExchangeAuditBefore{Wallets: make(map[uint]model.AssetWallet)}
cardIDs := make([]uint, 0, 2)
if oldCard != nil {
cardIDs = append(cardIDs, oldCard.ID)
}
if newCard != nil {
cardIDs = append(cardIDs, newCard.ID)
}
if len(cardIDs) > 0 {
var wallets []model.AssetWallet
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id IN ?", constants.ExchangeAssetTypeIotCard, cardIDs).Find(&wallets).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货钱包审计快照失败")
}
for _, wallet := range wallets {
state.Wallets[wallet.ResourceID] = wallet
}
}
devices, iccids, err := loadCardExchangeBindings(ctx, tx, oldCard)
if err != nil {
return nil, err
}
state.DeviceBindings, state.ICCIDBindings = devices, iccids
return state, nil
}
func loadCardExchangeBindings(ctx context.Context, tx *gorm.DB, card *model.IotCard) ([]*model.PersonalCustomerDevice, []*model.PersonalCustomerICCID, error) {
if card == nil {
return nil, nil, nil
}
if card.VirtualNo != "" {
var rows []*model.PersonalCustomerDevice
if err := tx.WithContext(ctx).Where("virtual_no = ? AND status = ?", card.VirtualNo, constants.StatusEnabled).Find(&rows).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货客户绑定失败")
}
return rows, nil, nil
}
var rows []*model.PersonalCustomerICCID
if err := tx.WithContext(ctx).Where("iccid = ? AND status = ?", card.ICCID, constants.StatusEnabled).Find(&rows).Error; err != nil {
return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货 ICCID 绑定失败")
}
return nil, rows, nil
}
func (s *Service) buildCardExchangeCompletionResources(
ctx context.Context,
tx *gorm.DB,
order *model.ExchangeOrder,
oldCard, newCard *model.IotCard,
before *cardExchangeAuditBefore,
migration *exchangeMigrationResult,
) ([]audit.ResourceInput, error) {
resources := cardExchangeOldBindingResources(before)
devices, iccids, err := loadCardExchangeBindings(ctx, tx, newCard)
if err != nil {
return nil, err
}
resources = append(resources, cardExchangeNewBindingResources(devices, iccids)...)
beforeWallets := map[uint]model.AssetWallet(nil)
if before != nil {
beforeWallets = before.Wallets
}
walletResources, err := loadCardExchangeWalletResources(ctx, tx, oldCard.ID, newCard.ID, beforeWallets)
if err != nil {
return nil, err
}
resources = append(resources, walletResources...)
if migration == nil {
return resources, nil
}
transactionResources, err := loadCardExchangeTransactionResources(ctx, tx, order.ExchangeNo)
if err != nil {
return nil, err
}
resources = append(resources, transactionResources...)
usageResources, err := loadCardExchangePackageUsageResources(ctx, tx, migration.PackageUsageIDs, oldCard.ID, newCard.ID)
if err != nil {
return nil, err
}
return append(resources, usageResources...), nil
}
func cardExchangeOldBindingResources(before *cardExchangeAuditBefore) []audit.ResourceInput {
if before == nil {
return nil
}
resources := make([]audit.ResourceInput, 0, len(before.DeviceBindings)+len(before.ICCIDBindings))
for _, row := range before.DeviceBindings {
if row == nil {
continue
}
id := strconv.FormatUint(uint64(row.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourcePersonalCustomerDevice, ID: &id, Key: id, DisplayName: row.VirtualNo,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding,
IdentitySnapshot: map[string]any{"id": row.ID, "customer_id": row.CustomerID, "virtual_no": row.VirtualNo, "bind_at": row.BindAt, "last_used_at": row.LastUsedAt, "status": row.Status},
BeforeData: map[string]any{"virtual_no": row.VirtualNo, "status": row.Status}, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
for _, row := range before.ICCIDBindings {
if row == nil {
continue
}
id := strconv.FormatUint(uint64(row.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourcePersonalCustomerICCID, ID: &id, Key: id, DisplayName: row.ICCID,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePersonalCustomerOldAssetBinding,
IdentitySnapshot: map[string]any{"id": row.ID, "customer_id": row.CustomerID, "iccid": row.ICCID, "iccid_19": row.ICCID19, "bind_at": row.BindAt, "last_used_at": row.LastUsedAt, "status": row.Status},
BeforeData: map[string]any{"iccid": row.ICCID, "status": row.Status}, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
return resources
}
func cardExchangeNewBindingResources(devices []*model.PersonalCustomerDevice, iccids []*model.PersonalCustomerICCID) []audit.ResourceInput {
resources := make([]audit.ResourceInput, 0, len(devices)+len(iccids))
for _, row := range devices {
if row == nil {
continue
}
id := strconv.FormatUint(uint64(row.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourcePersonalCustomerDevice, ID: &id, Key: id, DisplayName: row.VirtualNo,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding,
IdentitySnapshot: map[string]any{"id": row.ID, "customer_id": row.CustomerID, "virtual_no": row.VirtualNo, "bind_at": row.BindAt, "last_used_at": row.LastUsedAt, "status": row.Status},
AfterData: map[string]any{"virtual_no": row.VirtualNo, "status": row.Status}, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
for _, row := range iccids {
if row == nil {
continue
}
id := strconv.FormatUint(uint64(row.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourcePersonalCustomerICCID, ID: &id, Key: id, DisplayName: row.ICCID,
Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePersonalCustomerNewAssetBinding,
IdentitySnapshot: map[string]any{"id": row.ID, "customer_id": row.CustomerID, "iccid": row.ICCID, "iccid_19": row.ICCID19, "bind_at": row.BindAt, "last_used_at": row.LastUsedAt, "status": row.Status},
AfterData: map[string]any{"iccid": row.ICCID, "status": row.Status}, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
return resources
}
func loadCardExchangeWalletResources(ctx context.Context, tx *gorm.DB, oldCardID, newCardID uint, before map[uint]model.AssetWallet) ([]audit.ResourceInput, error) {
return loadExchangeWalletResources(ctx, tx, constants.ExchangeAssetTypeIotCard, oldCardID, newCardID, before,
constants.AuditResourceRoleCardExchangeOldWallet, constants.AuditResourceRoleCardExchangeNewWallet, "卡")
}
func loadExchangeWalletResources(ctx context.Context, tx *gorm.DB, assetType string, oldAssetID, newAssetID uint, before map[uint]model.AssetWallet, oldRole, newRole, assetName string) ([]audit.ResourceInput, error) {
var wallets []model.AssetWallet
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id IN ?", assetType, []uint{oldAssetID, newAssetID}).Find(&wallets).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询"+assetName+"换货迁移后钱包失败")
}
resources := make([]audit.ResourceInput, 0, len(wallets))
for _, wallet := range wallets {
id := strconv.FormatUint(uint64(wallet.ID), 10)
role := newRole
if wallet.ResourceID == oldAssetID {
role = oldRole
}
relation := constants.AuditResourceRelationAffected
beforeData, afterData := map[string]any{"exists": false}, cardExchangeWalletData(wallet)
if previous, ok := before[wallet.ResourceID]; ok {
if !cardExchangeWalletChanged(previous, wallet) {
relation, beforeData, afterData = constants.AuditResourceRelationReference, nil, nil
} else {
beforeData = cardExchangeWalletData(previous)
}
}
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceAssetWallet, ID: &id, Key: id, DisplayName: id,
Relation: relation, Role: role,
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},
BeforeData: beforeData, AfterData: afterData, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
return resources, nil
}
func cardExchangeWalletData(wallet model.AssetWallet) map[string]any {
return map[string]any{"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance, "status": wallet.Status, "version": wallet.Version, "shop_id_tag": wallet.ShopIDTag, "enterprise_id_tag": wallet.EnterpriseIDTag}
}
func cardExchangeWalletChanged(before, after model.AssetWallet) bool {
return before.Balance != after.Balance || before.FrozenBalance != after.FrozenBalance || before.Status != after.Status ||
before.Version != after.Version || before.ShopIDTag != after.ShopIDTag || !sameOptionalUint(before.EnterpriseIDTag, after.EnterpriseIDTag)
}
func sameOptionalUint(left, right *uint) bool {
return left == nil && right == nil || left != nil && right != nil && *left == *right
}
func loadCardExchangeRenewWalletResource(ctx context.Context, tx *gorm.DB, cardID uint, before map[uint]model.AssetWallet) (*audit.ResourceInput, error) {
return loadExchangeRenewWalletResource(ctx, tx, constants.ExchangeAssetTypeIotCard, cardID, before, constants.AuditResourceRoleCardExchangeOldWallet, "卡")
}
func loadExchangeRenewWalletResource(ctx context.Context, tx *gorm.DB, assetType string, assetID uint, before map[uint]model.AssetWallet, role, assetName string) (*audit.ResourceInput, error) {
var wallet model.AssetWallet
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id = ?", assetType, assetID).First(&wallet).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询旧"+assetName+"转新钱包失败")
}
id := strconv.FormatUint(uint64(wallet.ID), 10)
beforeData := map[string]any{"exists": false}
if previous, ok := before[assetID]; ok {
beforeData = cardExchangeWalletData(previous)
}
return &audit.ResourceInput{
Type: constants.AuditResourceAssetWallet, ID: &id, Key: id, DisplayName: id,
Relation: constants.AuditResourceRelationAffected, Role: role,
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},
BeforeData: beforeData, AfterData: cardExchangeWalletData(wallet), SubjectVisibility: constants.AuditSubjectInternalOnly,
}, nil
}
func loadCardExchangeTransactionResources(ctx context.Context, tx *gorm.DB, exchangeNo string) ([]audit.ResourceInput, error) {
return loadExchangeTransactionResources(ctx, tx, exchangeNo, constants.AuditResourceRoleCardExchangeWalletTransaction, "卡")
}
func loadExchangeTransactionResources(ctx context.Context, tx *gorm.DB, exchangeNo, role, assetName string) ([]audit.ResourceInput, error) {
var rows []model.AssetWalletTransaction
if err := tx.WithContext(ctx).Where("transaction_type = ? AND reference_type = ? AND reference_no = ?", constants.AssetTransactionTypeExchange, constants.ReferenceTypeExchange, exchangeNo).Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询"+assetName+"换货钱包流水失败")
}
resources := make([]audit.ResourceInput, 0, len(rows))
for _, row := range rows {
id := strconv.FormatUint(uint64(row.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceAssetWalletTransaction, ID: &id, Key: id, DisplayName: exchangeNo,
Relation: constants.AuditResourceRelationAffected, Role: role,
IdentitySnapshot: map[string]any{"id": row.ID, "asset_wallet_id": row.AssetWalletID, "resource_type": row.ResourceType, "resource_id": row.ResourceID, "transaction_type": row.TransactionType, "reference_type": row.ReferenceType, "reference_no": row.ReferenceNo, "status": row.Status},
AfterData: map[string]any{"amount": row.Amount, "balance_before": row.BalanceBefore, "balance_after": row.BalanceAfter}, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
return resources, nil
}
func loadCardExchangePackageUsageResources(ctx context.Context, tx *gorm.DB, ids []uint, oldCardID, newCardID uint) ([]audit.ResourceInput, error) {
return loadExchangePackageUsageResources(ctx, tx, ids, "iot_card_id", oldCardID, newCardID, constants.AuditResourceRoleCardExchangePackageUsage, "卡")
}
func loadExchangePackageUsageResources(ctx context.Context, tx *gorm.DB, ids []uint, assetIDField string, oldAssetID, newAssetID uint, role, assetName string) ([]audit.ResourceInput, error) {
if len(ids) == 0 {
return nil, nil
}
var rows []model.PackageUsage
if err := tx.WithContext(ctx).Where("id IN ?", ids).Order("id ASC").Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询"+assetName+"换货套餐权益失败")
}
orderIDs := make(map[uint]struct{}, len(rows))
packageIDs := make(map[uint]struct{}, len(rows))
resources := make([]audit.ResourceInput, 0, len(rows)*3)
for _, row := range rows {
resource := audit.PackageUsageResource(&row, constants.AuditResourceRelationAffected, role,
map[string]any{assetIDField: oldAssetID}, map[string]any{assetIDField: newAssetID})
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
resources = append(resources, resource)
orderIDs[row.OrderID] = struct{}{}
packageIDs[row.PackageID] = struct{}{}
}
var orders []model.Order
if err := tx.WithContext(ctx).Where("id IN ?", exchangeUintKeys(orderIDs)).Order("id ASC").Find(&orders).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询"+assetName+"换货套餐权益关联订单失败")
}
for i := range orders {
resources = append(resources, audit.OrderResource(&orders[i], constants.AuditResourceRelationReference, constants.AuditResourceRolePackageUsageOrder))
}
var packages []model.Package
if err := tx.WithContext(ctx).Where("id IN ?", exchangeUintKeys(packageIDs)).Order("id ASC").Find(&packages).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询"+assetName+"换货套餐权益关联套餐失败")
}
for i := range packages {
resources = append(resources, audit.PackageResource(&packages[i], constants.AuditResourceRelationReference, constants.AuditResourceRolePackageUsagePackage, nil, nil))
}
return resources, nil
}
func exchangeUintKeys(values map[uint]struct{}) []uint {
result := make([]uint, 0, len(values))
for value := range values {
if value > 0 {
result = append(result, value)
}
}
return result
}
func (s *Service) recordCardExchangeFailure(ctx context.Context, actionCode, summary, result string, order *model.ExchangeOrder, oldCard, newCard *model.IotCard, businessErr error) {
if order == nil || order.OldAssetType != constants.ExchangeAssetTypeIotCard {
return
}
if s.db == nil || s.auditWriter == nil {
recordCardExchangeAuditSecondaryFailure(ctx, actionCode, order.ExchangeNo, businessErr, errors.New(errors.CodeInvalidStatus, "卡换货统一审计接缝未配置"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendCardExchangeAudit(ctx, tx, actionCode, summary, result, order, oldCard, newCard,
nil, nil, nil, nil, nil, nil, nil, businessErr)
}); err != nil {
recordCardExchangeAuditSecondaryFailure(ctx, actionCode, order.ExchangeNo, businessErr, err)
}
}
func recordCardExchangeAuditSecondaryFailure(ctx context.Context, actionCode, exchangeNo string, businessErr, auditErr error) {
errorCode, _ := assetAuditSvc.BuildErrorInfo(businessErr)
linkage := auditcontext.From(ctx)
auditfailure.RecordSecondaryWriteFailure(actionCode, exchangeNo, linkage.RequestID, linkage.CorrelationID, errorCode, auditErr)
}
func (s *Service) recordCardExchangeOrderFailure(ctx context.Context, actionCode, summary string, order *model.ExchangeOrder, businessErr error) {
if order == nil || order.OldAssetType != constants.ExchangeAssetTypeIotCard {
return
}
oldCard, newCard := s.loadCardExchangeAuditCards(ctx, order)
s.recordCardExchangeFailure(ctx, actionCode, summary, cardExchangeFailureResult(businessErr), order, oldCard, newCard, businessErr)
}
func (s *Service) loadCardExchangeAuditCards(ctx context.Context, order *model.ExchangeOrder) (*model.IotCard, *model.IotCard) {
if order == nil || order.OldAssetType != constants.ExchangeAssetTypeIotCard {
return nil, nil
}
var oldCard *model.IotCard
if s.iotCardStore != nil {
oldCard, _ = s.iotCardStore.GetByID(ctx, order.OldAssetID)
}
if oldCard == nil {
oldCard = &model.IotCard{Model: gorm.Model{ID: order.OldAssetID}, ICCID: order.OldAssetIdentifier, ShopID: order.ShopID}
}
var newCard *model.IotCard
if order.NewAssetID != nil && *order.NewAssetID > 0 {
if s.iotCardStore != nil {
newCard, _ = s.iotCardStore.GetByID(ctx, *order.NewAssetID)
}
if newCard == nil {
newCard = &model.IotCard{Model: gorm.Model{ID: *order.NewAssetID}, ICCID: order.NewAssetIdentifier, ShopID: order.ShopID}
}
}
return oldCard, newCard
}
func cardExchangeFailureResult(err error) string {
appErr, ok := err.(*errors.AppError)
if !ok {
return constants.AuditResultFailed
}
switch appErr.Code {
case errors.CodeDatabaseError, errors.CodeInternalError, errors.CodeExchangeMigrationFailed:
return constants.AuditResultFailed
default:
return constants.AuditResultDenied
}
}

View File

@@ -0,0 +1,393 @@
package exchange
import (
"context"
"strconv"
"strings"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
type deviceExchangeAuditBefore struct {
Wallets map[uint]model.AssetWallet
CustomerBindings []*model.PersonalCustomerDevice
}
func (s *Service) appendExchangeAudit(
ctx context.Context,
tx *gorm.DB,
cardActionCode, summary, result string,
order *model.ExchangeOrder,
oldAsset, newAsset *resolvedExchangeAsset,
orderBefore, orderAfter map[string]any,
oldAssetBefore, oldAssetAfter map[string]any,
newAssetBefore, newAssetAfter map[string]any,
extra []audit.ResourceInput,
businessErr error,
) error {
if order != nil && order.OldAssetType == constants.ExchangeAssetTypeDevice {
return s.appendDeviceExchangeAudit(ctx, tx, deviceExchangeActionCode(cardActionCode), strings.ReplaceAll(summary, "卡", "设备"), result,
order, resolvedDevice(oldAsset), resolvedDevice(newAsset), orderBefore, orderAfter,
oldAssetBefore, oldAssetAfter, newAssetBefore, newAssetAfter, extra, businessErr)
}
return s.appendCardExchangeAudit(ctx, tx, cardActionCode, summary, result, order, resolvedCard(oldAsset), resolvedCard(newAsset),
orderBefore, orderAfter, oldAssetBefore, oldAssetAfter, newAssetBefore, newAssetAfter, extra, businessErr)
}
func resolvedCard(asset *resolvedExchangeAsset) *model.IotCard {
if asset == nil {
return nil
}
return asset.Card
}
func resolvedDevice(asset *resolvedExchangeAsset) *model.Device {
if asset == nil {
return nil
}
return asset.Device
}
func deviceExchangeActionCode(cardActionCode string) string {
switch cardActionCode {
case constants.AuditActionCardExchangeCreated:
return constants.AuditActionDeviceExchangeCreated
case constants.AuditActionCardExchangeShippingInfoSubmitted:
return constants.AuditActionDeviceExchangeShippingInfoSubmitted
case constants.AuditActionCardExchangeShipped:
return constants.AuditActionDeviceExchangeShipped
case constants.AuditActionCardExchangeCompleted:
return constants.AuditActionDeviceExchangeCompleted
case constants.AuditActionCardExchangeCancelled:
return constants.AuditActionDeviceExchangeCancelled
case constants.AuditActionCardExchangeRenewed:
return constants.AuditActionDeviceExchangeRenewed
default:
return cardActionCode
}
}
func (s *Service) appendDeviceExchangeAudit(
ctx context.Context,
tx *gorm.DB,
actionCode, summary, result string,
order *model.ExchangeOrder,
oldDevice, newDevice *model.Device,
orderBefore, orderAfter map[string]any,
oldDeviceBefore, oldDeviceAfter map[string]any,
newDeviceBefore, newDeviceAfter map[string]any,
extra []audit.ResourceInput,
businessErr error,
) error {
if order == nil || order.OldAssetType != constants.ExchangeAssetTypeDevice {
return nil
}
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "设备换货统一审计接缝未配置")
}
internalOnly := actionCode == constants.AuditActionDeviceExchangeRenewed
resources := []audit.ResourceInput{deviceExchangeOrderAuditResource(order, summary, internalOnly, orderBefore, orderAfter)}
if oldDevice != nil {
resources = append(resources, deviceExchangeDeviceAuditResource(oldDevice, constants.AuditResourceRoleDeviceExchangeOldDevice, summary, internalOnly, oldDeviceBefore, oldDeviceAfter))
}
if newDevice != nil {
resources = append(resources, deviceExchangeDeviceAuditResource(newDevice, constants.AuditResourceRoleDeviceExchangeNewDevice, summary, internalOnly, newDeviceBefore, newDeviceAfter))
}
shopResource, err := loadDeviceExchangeShopAuditResource(ctx, tx, order.ShopID)
if err != nil {
return err
}
if shopResource != nil {
resources = append(resources, *shopResource)
}
resources = append(resources, extra...)
errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr)
scopeType, scopeID := constants.AuditScopePlatform, ""
if actionCode == constants.AuditActionDeviceExchangeShippingInfoSubmitted {
scopeType = constants.AuditScopePersonalCustomer
scopeID = auditcontext.From(ctx).ActorID
}
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: actionCode, Summary: summary, ScopeType: scopeType, ScopeID: scopeID,
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
Metadata: map[string]any{"flow_type": effectiveExchangeFlowType(order.FlowType), "migrate_data": order.MigrateData},
Resources: resources,
})
}
func deviceExchangeOrderAuditResource(order *model.ExchangeOrder, summary string, internalOnly bool, beforeData, afterData map[string]any) audit.ResourceInput {
resource := cardExchangeOrderAuditResource(order, summary, internalOnly, beforeData, afterData)
resource.Role = constants.AuditResourceRoleDeviceExchangeOrder
return resource
}
func deviceExchangeDeviceAuditResource(device *model.Device, role, summary string, internalOnly bool, beforeData, afterData map[string]any) audit.ResourceInput {
id := strconv.FormatUint(uint64(device.ID), 10)
relation := constants.AuditResourceRelationReference
if len(beforeData) > 0 || len(afterData) > 0 {
relation = constants.AuditResourceRelationAffected
}
resource := audit.ResourceInput{
Type: constants.AuditResourceDevice, ID: &id, Key: audit.DeviceResourceKey(device), DisplayName: preferredDeviceIdentifier(device),
Relation: relation, Role: role, IdentitySnapshot: audit.DeviceIdentitySnapshot(device),
BeforeData: beforeData, AfterData: afterData,
}
if internalOnly {
resource.SubjectVisibility = constants.AuditSubjectInternalOnly
} else {
resource.SubjectVisibility = constants.AuditSubjectResult
resource.SubjectSummary = summary
}
return resource
}
func loadDeviceExchangeShopAuditResource(ctx context.Context, tx *gorm.DB, shopID *uint) (*audit.ResourceInput, error) {
resource, err := loadCardExchangeShopAuditResource(ctx, tx, shopID)
if resource != nil {
resource.Role = constants.AuditResourceRoleDeviceExchangeShop
}
return resource, err
}
func (s *Service) captureDeviceExchangeAuditBefore(ctx context.Context, tx *gorm.DB, oldDevice, newDevice *model.Device) (*deviceExchangeAuditBefore, error) {
state := &deviceExchangeAuditBefore{Wallets: make(map[uint]model.AssetWallet)}
deviceIDs := make([]uint, 0, 2)
if oldDevice != nil {
deviceIDs = append(deviceIDs, oldDevice.ID)
}
if newDevice != nil {
deviceIDs = append(deviceIDs, newDevice.ID)
}
if len(deviceIDs) > 0 {
var wallets []model.AssetWallet
if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id IN ?", constants.ExchangeAssetTypeDevice, deviceIDs).Find(&wallets).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货钱包审计快照失败")
}
for _, wallet := range wallets {
state.Wallets[wallet.ResourceID] = wallet
}
}
rows, err := loadDeviceExchangeCustomerBindings(ctx, tx, oldDevice)
if err != nil {
return nil, err
}
state.CustomerBindings = rows
return state, nil
}
func loadDeviceExchangeCustomerBindings(ctx context.Context, tx *gorm.DB, device *model.Device) ([]*model.PersonalCustomerDevice, error) {
if device == nil {
return nil, nil
}
key := exchangeAssetBindingKey(&resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: device, VirtualNo: device.VirtualNo})
if key == "" {
return nil, nil
}
var rows []*model.PersonalCustomerDevice
if err := tx.WithContext(ctx).Where("virtual_no = ? AND status = ?", key, constants.StatusEnabled).Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货客户绑定失败")
}
return rows, nil
}
func (s *Service) buildDeviceExchangeCompletionResources(
ctx context.Context,
tx *gorm.DB,
order *model.ExchangeOrder,
oldDevice, newDevice *model.Device,
before *deviceExchangeAuditBefore,
migration *exchangeMigrationResult,
) ([]audit.ResourceInput, error) {
resources := deviceExchangeOldCustomerBindingResources(before)
newBindings, err := loadDeviceExchangeCustomerBindings(ctx, tx, newDevice)
if err != nil {
return nil, err
}
resources = append(resources, deviceExchangeNewCustomerBindingResources(newBindings)...)
simResources, err := loadDeviceExchangeSIMResources(ctx, tx, oldDevice, newDevice)
if err != nil {
return nil, err
}
resources = append(resources, simResources...)
beforeWallets := map[uint]model.AssetWallet(nil)
if before != nil {
beforeWallets = before.Wallets
}
walletResources, err := loadDeviceExchangeWalletResources(ctx, tx, oldDevice.ID, newDevice.ID, beforeWallets)
if err != nil {
return nil, err
}
resources = append(resources, walletResources...)
if migration == nil {
return resources, nil
}
transactions, err := loadDeviceExchangeTransactionResources(ctx, tx, order.ExchangeNo)
if err != nil {
return nil, err
}
resources = append(resources, transactions...)
usages, err := loadDeviceExchangePackageUsageResources(ctx, tx, migration.PackageUsageIDs, oldDevice.ID, newDevice.ID)
if err != nil {
return nil, err
}
return append(resources, usages...), nil
}
func deviceExchangeOldCustomerBindingResources(before *deviceExchangeAuditBefore) []audit.ResourceInput {
if before == nil {
return nil
}
return deviceExchangeCustomerBindingResources(before.CustomerBindings, constants.AuditResourceRoleDeviceExchangeOldCustomerBinding, true)
}
func deviceExchangeNewCustomerBindingResources(rows []*model.PersonalCustomerDevice) []audit.ResourceInput {
return deviceExchangeCustomerBindingResources(rows, constants.AuditResourceRoleDeviceExchangeNewCustomerBinding, false)
}
func deviceExchangeCustomerBindingResources(rows []*model.PersonalCustomerDevice, role string, before bool) []audit.ResourceInput {
resources := make([]audit.ResourceInput, 0, len(rows))
for _, row := range rows {
if row == nil {
continue
}
id := strconv.FormatUint(uint64(row.ID), 10)
resource := audit.ResourceInput{
Type: constants.AuditResourcePersonalCustomerDevice, ID: &id, Key: id, DisplayName: row.VirtualNo,
Relation: constants.AuditResourceRelationAffected, Role: role,
IdentitySnapshot: map[string]any{"id": row.ID, "customer_id": row.CustomerID, "virtual_no": row.VirtualNo, "bind_at": row.BindAt, "last_used_at": row.LastUsedAt, "status": row.Status},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}
if before {
resource.BeforeData = map[string]any{"virtual_no": row.VirtualNo, "status": row.Status}
} else {
resource.AfterData = map[string]any{"virtual_no": row.VirtualNo, "status": row.Status}
}
resources = append(resources, resource)
}
return resources
}
func loadDeviceExchangeSIMResources(ctx context.Context, tx *gorm.DB, oldDevice, newDevice *model.Device) ([]audit.ResourceInput, error) {
resources := make([]audit.ResourceInput, 0)
for _, item := range []struct {
device *model.Device
cardRole string
bindingRole string
}{
{oldDevice, constants.AuditResourceRoleDeviceExchangeOldBoundCard, constants.AuditResourceRoleDeviceExchangeOldSIMBinding},
{newDevice, constants.AuditResourceRoleDeviceExchangeNewBoundCard, constants.AuditResourceRoleDeviceExchangeNewSIMBinding},
} {
if item.device == nil {
continue
}
var bindings []*model.DeviceSimBinding
if err := tx.WithContext(ctx).Where("device_id = ? AND bind_status = ?", item.device.ID, constants.BindStatusBound).
Order("slot_position ASC, id ASC").Find(&bindings).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货卡槽绑定失败")
}
cardIDs := make([]uint, 0, len(bindings))
for _, binding := range bindings {
cardIDs = append(cardIDs, binding.IotCardID)
}
cards := make(map[uint]*model.IotCard, len(cardIDs))
if len(cardIDs) > 0 {
var rows []*model.IotCard
if err := tx.WithContext(ctx).Where("id IN ?", cardIDs).Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货绑定卡失败")
}
for _, card := range rows {
cards[card.ID] = card
}
}
for _, binding := range bindings {
card := cards[binding.IotCardID]
if card == nil {
return nil, errors.New(errors.CodeAssetNotFound, "设备换货绑定卡不存在")
}
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.AuditResourceRelationReference, Role: item.cardRole,
IdentitySnapshot: audit.IotCardIdentitySnapshot(card), SubjectVisibility: constants.AuditSubjectInternalOnly,
})
bindingID := strconv.FormatUint(uint64(binding.ID), 10)
resources = append(resources, audit.ResourceInput{
Type: constants.AuditResourceDeviceSIMBinding, ID: &bindingID, Key: bindingID, DisplayName: preferredDeviceIdentifier(item.device),
Relation: constants.AuditResourceRelationReference, Role: item.bindingRole,
IdentitySnapshot: map[string]any{
"id": binding.ID, "device_id": binding.DeviceID, "device_virtual_no": item.device.VirtualNo,
"slot_position": binding.SlotPosition, "iot_card_id": binding.IotCardID,
"iccid": card.ICCID, "virtual_no": card.VirtualNo, "is_current": binding.IsCurrent,
}, SubjectVisibility: constants.AuditSubjectInternalOnly,
})
}
}
return resources, nil
}
func loadDeviceExchangeWalletResources(ctx context.Context, tx *gorm.DB, oldDeviceID, newDeviceID uint, before map[uint]model.AssetWallet) ([]audit.ResourceInput, error) {
return loadExchangeWalletResources(ctx, tx, constants.ExchangeAssetTypeDevice, oldDeviceID, newDeviceID, before,
constants.AuditResourceRoleDeviceExchangeOldWallet, constants.AuditResourceRoleDeviceExchangeNewWallet, "设备")
}
func loadDeviceExchangeRenewWalletResource(ctx context.Context, tx *gorm.DB, deviceID uint, before map[uint]model.AssetWallet) (*audit.ResourceInput, error) {
return loadExchangeRenewWalletResource(ctx, tx, constants.ExchangeAssetTypeDevice, deviceID, before, constants.AuditResourceRoleDeviceExchangeOldWallet, "设备")
}
func loadDeviceExchangeTransactionResources(ctx context.Context, tx *gorm.DB, exchangeNo string) ([]audit.ResourceInput, error) {
return loadExchangeTransactionResources(ctx, tx, exchangeNo, constants.AuditResourceRoleDeviceExchangeWalletTransaction, "设备")
}
func loadDeviceExchangePackageUsageResources(ctx context.Context, tx *gorm.DB, ids []uint, oldDeviceID, newDeviceID uint) ([]audit.ResourceInput, error) {
return loadExchangePackageUsageResources(ctx, tx, ids, "device_id", oldDeviceID, newDeviceID, constants.AuditResourceRoleDeviceExchangePackageUsage, "设备")
}
func (s *Service) recordExchangeOrderFailure(ctx context.Context, cardActionCode, summary string, order *model.ExchangeOrder, businessErr error) {
if order == nil || order.OldAssetType != constants.ExchangeAssetTypeDevice {
s.recordCardExchangeOrderFailure(ctx, cardActionCode, summary, order, businessErr)
return
}
oldDevice, newDevice := s.loadDeviceExchangeAuditDevices(ctx, order)
if s.db == nil || s.auditWriter == nil {
recordCardExchangeAuditSecondaryFailure(ctx, deviceExchangeActionCode(cardActionCode), order.ExchangeNo, businessErr,
errors.New(errors.CodeInvalidStatus, "设备换货统一审计接缝未配置"))
return
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.appendDeviceExchangeAudit(ctx, tx, deviceExchangeActionCode(cardActionCode), strings.ReplaceAll(summary, "卡", "设备"),
cardExchangeFailureResult(businessErr), order, oldDevice, newDevice,
nil, nil, nil, nil, nil, nil, nil, businessErr)
}); err != nil {
recordCardExchangeAuditSecondaryFailure(ctx, deviceExchangeActionCode(cardActionCode), order.ExchangeNo, businessErr, err)
}
}
func (s *Service) loadDeviceExchangeAuditDevices(ctx context.Context, order *model.ExchangeOrder) (*model.Device, *model.Device) {
if order == nil || order.OldAssetType != constants.ExchangeAssetTypeDevice {
return nil, nil
}
var oldDevice *model.Device
if s.deviceStore != nil {
oldDevice, _ = s.deviceStore.GetByID(ctx, order.OldAssetID)
}
if oldDevice == nil {
oldDevice = &model.Device{Model: gorm.Model{ID: order.OldAssetID}, ShopID: order.ShopID}
}
var newDevice *model.Device
if order.NewAssetID != nil && *order.NewAssetID > 0 {
if s.deviceStore != nil {
newDevice, _ = s.deviceStore.GetByID(ctx, *order.NewAssetID)
}
if newDevice == nil {
newDevice = &model.Device{Model: gorm.Model{ID: *order.NewAssetID}, ShopID: order.ShopID}
}
}
return oldDevice, newDevice
}

View File

@@ -12,21 +12,27 @@ import (
"gorm.io/gorm/clause"
)
func (s *Service) executeMigrationWithTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, oldAsset, newAsset *resolvedExchangeAsset) (int64, error) {
type exchangeMigrationResult struct {
Balance int64
PackageUsageIDs []uint
}
func (s *Service) executeMigrationWithTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, oldAsset, newAsset *resolvedExchangeAsset) (*exchangeMigrationResult, error) {
migrationBalance, err := s.transferWalletBalanceWithTx(ctx, tx, order, oldAsset, newAsset)
if err != nil {
return 0, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "执行钱包迁移失败")
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "执行钱包迁移失败")
}
if err = s.migratePackageUsageWithTx(ctx, tx, oldAsset, newAsset); err != nil {
return 0, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "迁移套餐使用记录失败")
usageIDs, err := s.migratePackageUsageWithTx(ctx, tx, oldAsset, newAsset)
if err != nil {
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "迁移套餐使用记录失败")
}
if err = s.copyAccumulatedFieldsWithTx(tx, oldAsset, newAsset); err != nil {
return 0, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "复制累计充值字段失败")
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "复制累计充值字段失败")
}
if err = s.copyResourceTagsWithTx(ctx, tx, oldAsset, newAsset); err != nil {
return 0, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "复制资产标签失败")
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "复制资产标签失败")
}
return migrationBalance, nil
return &exchangeMigrationResult{Balance: migrationBalance, PackageUsageIDs: usageIDs}, nil
}
func (s *Service) transferWalletBalanceWithTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, oldAsset, newAsset *resolvedExchangeAsset) (int64, error) {
@@ -95,7 +101,7 @@ func (s *Service) transferWalletBalanceWithTx(ctx context.Context, tx *gorm.DB,
return migrationBalance, nil
}
func (s *Service) migratePackageUsageWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
func (s *Service) migratePackageUsageWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) ([]uint, error) {
query := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted})
if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard {
query = query.Where("iot_card_id = ?", oldAsset.AssetID)
@@ -105,11 +111,11 @@ func (s *Service) migratePackageUsageWithTx(ctx context.Context, tx *gorm.DB, ol
var usageIDs []uint
if err := query.Pluck("id", &usageIDs).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
}
if len(usageIDs) == 0 {
return nil
return nil, nil
}
updates := map[string]any{"updated_at": time.Now()}
@@ -120,14 +126,14 @@ func (s *Service) migratePackageUsageWithTx(ctx context.Context, tx *gorm.DB, ol
}
if err := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("id IN ?", usageIDs).Updates(updates).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐使用记录失败")
return nil, errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐使用记录失败")
}
if err := tx.WithContext(ctx).Model(&model.PackageUsageDailyRecord{}).Where("package_usage_id IN ?", usageIDs).Update("updated_at", gorm.Expr("updated_at")).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐日记录失败")
return nil, errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐日记录失败")
}
return nil
return usageIDs, nil
}
func (s *Service) copyAccumulatedFieldsWithTx(tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {

View File

@@ -6,6 +6,7 @@ import (
"time"
exchangeapp "github.com/break/junhong_cmp_fiber/internal/application/exchange"
"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"
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
@@ -31,6 +32,7 @@ type Service struct {
resourceTagStore *postgres.ResourceTagStore
customerBinding *customerBindingSvc.Service
shippingCreatedNotifier *exchangeapp.ShippingCreatedNotifier
auditWriter *audit.Writer
logger *zap.Logger
}
@@ -81,31 +83,14 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*
if err != nil {
return nil, err
}
if !isExchangeableAssetStatus(asset.AssetStatus) {
return nil, oldAssetStatusError(asset.AssetStatus)
migrateData := false
if req.MigrateData != nil {
migrateData = *req.MigrateData
}
hasUnfinishedRefund, err := s.refundStore.HasUnfinishedByAsset(ctx, asset.AssetType, asset.AssetID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产退款申请失败")
}
if hasUnfinishedRefund {
return nil, errors.New(errors.CodeExchangeActiveRefund)
}
if _, err = s.exchangeStore.FindActiveByOldAsset(ctx, asset.AssetType, asset.AssetID); err == nil {
return nil, errors.New(errors.CodeExchangeInProgress)
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询进行中换货单失败")
}
if flowType == constants.ExchangeFlowTypeDirect {
return s.createDirectExchange(ctx, req, asset)
}
creator := middleware.GetUserIDFromContext(ctx)
order := &model.ExchangeOrder{
ExchangeNo: model.GenerateExchangeNo(),
FlowType: constants.ExchangeFlowTypeShipping,
FlowType: flowType,
OldAssetType: asset.AssetType,
OldAssetID: asset.AssetID,
OldAssetIdentifier: asset.Identifier,
@@ -114,15 +99,57 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*
Status: constants.ExchangeStatusPendingInfo,
MigrationCompleted: false,
MigrationBalance: 0,
MigrateData: false,
MigrateData: flowType == constants.ExchangeFlowTypeDirect && migrateData,
BaseModel: model.BaseModel{Creator: creator, Updater: creator},
}
if asset.ShopID != nil {
order.ShopID = asset.ShopID
}
if !isExchangeableAssetStatus(asset.AssetStatus) {
err = oldAssetStatusError(asset.AssetStatus)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单被拒绝", order, err)
return nil, err
}
hasUnfinishedRefund, err := s.refundStore.HasUnfinishedByAsset(ctx, asset.AssetType, asset.AssetID)
if err != nil {
err = errors.Wrap(errors.CodeDatabaseError, err, "查询资产退款申请失败")
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单失败", order, err)
return nil, err
}
if hasUnfinishedRefund {
err = errors.New(errors.CodeExchangeActiveRefund)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单被拒绝", order, err)
return nil, err
}
if _, err = s.exchangeStore.FindActiveByOldAsset(ctx, asset.AssetType, asset.AssetID); err == nil {
err = errors.New(errors.CodeExchangeInProgress)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单被拒绝", order, err)
return nil, err
} else if err != gorm.ErrRecordNotFound {
err = errors.Wrap(errors.CodeDatabaseError, err, "查询进行中换货单失败")
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单失败", order, err)
return nil, err
}
if flowType == constants.ExchangeFlowTypeDirect {
orderID, directErr := s.createDirectExchange(ctx, req, asset, order)
if directErr != nil {
order.ID = 0
order.Status = constants.ExchangeStatusPendingInfo
order.MigrationCompleted = false
order.MigrationBalance = 0
order.CompletedAt = nil
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡直接换货失败", order, directErr)
return nil, directErr
}
return s.Get(ctx, orderID)
}
if s.shippingCreatedNotifier == nil {
return nil, errors.New(errors.CodeInternalError, "物流换货通知服务未配置")
err = errors.New(errors.CodeInternalError, "物流换货通知服务未配置")
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单失败", order, err)
return nil, err
}
requestID := ""
if value := middleware.GetRequestIDFromContext(ctx); value != nil {
@@ -145,9 +172,14 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*
return notifyErr
}
}
return nil
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCreated, "已创建卡换货单", constants.AuditResultSuccess,
order, asset, nil,
map[string]any{"exists": false}, map[string]any{"status": constants.ExchangeStatusPendingInfo},
nil, nil, nil, nil, nil, nil)
})
if err != nil {
order.ID = 0
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCreated, "创建卡换货单失败", order, err)
return nil, err
}
@@ -178,13 +210,18 @@ func (s *Service) Ship(ctx context.Context, id uint, req *dto.ExchangeShipReques
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if order.Status != constants.ExchangeStatusPendingShip {
return nil, errors.New(errors.CodeExchangeStatusInvalid)
err = errors.New(errors.CodeExchangeStatusInvalid)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShipped, "卡换货发货被拒绝", order, err)
return nil, err
}
if !isShippingExchangeFlow(order.FlowType) {
return nil, errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持发货")
err = errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持发货")
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShipped, "卡换货发货被拒绝", order, err)
return nil, err
}
if err = s.shipWithTx(ctx, order, req); err != nil {
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShipped, "卡换货发货失败", order, err)
return nil, err
}
@@ -200,13 +237,17 @@ func (s *Service) Complete(ctx context.Context, id uint) error {
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if order.Status != constants.ExchangeStatusShipped {
return errors.New(errors.CodeExchangeStatusInvalid)
err = errors.New(errors.CodeExchangeStatusInvalid)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货被拒绝", order, err)
return err
}
if !isShippingExchangeFlow(order.FlowType) {
return errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持确认完成")
err = errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持确认完成")
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货被拒绝", order, err)
return err
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedOrder, lockErr := s.lockExchangeOrderByID(ctx, tx, id)
if lockErr != nil {
return lockErr
@@ -219,6 +260,10 @@ func (s *Service) Complete(ctx context.Context, id uint) error {
}
return s.completeExchangeWithTx(ctx, tx, lockedOrder, constants.ExchangeStatusShipped)
})
if err != nil {
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCompleted, "完成卡换货失败", order, err)
}
return err
}
func (s *Service) Cancel(ctx context.Context, id uint, req *dto.ExchangeCancelRequest) error {
@@ -230,10 +275,14 @@ func (s *Service) Cancel(ctx context.Context, id uint, req *dto.ExchangeCancelRe
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if order.Status != constants.ExchangeStatusPendingInfo && order.Status != constants.ExchangeStatusPendingShip {
return errors.New(errors.CodeExchangeStatusInvalid)
err = errors.New(errors.CodeExchangeStatusInvalid)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCancelled, "取消卡换货被拒绝", order, err)
return err
}
if !isShippingExchangeFlow(order.FlowType) {
return errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持取消")
err = errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持取消")
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCancelled, "取消卡换货被拒绝", order, err)
return err
}
updates := map[string]any{
@@ -243,13 +292,36 @@ func (s *Service) Cancel(ctx context.Context, id uint, req *dto.ExchangeCancelRe
if req != nil {
updates["remark"] = req.Remark
}
if err = s.exchangeStore.UpdateStatus(ctx, id, order.Status, constants.ExchangeStatusCancelled, updates); err != nil {
if err == gorm.ErrRecordNotFound {
oldAsset, resolveErr := s.resolveAssetByID(ctx, order.OldAssetType, order.OldAssetID)
if resolveErr != nil {
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCancelled, "取消卡换货失败", order, resolveErr)
return resolveErr
}
fromStatus := order.Status
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
values := make(map[string]any, len(updates)+1)
for key, value := range updates {
values[key] = value
}
values["status"] = constants.ExchangeStatusCancelled
result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).Where("id = ? AND status = ?", id, fromStatus).Updates(values)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "取消换货失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeExchangeStatusInvalid)
}
return errors.Wrap(errors.CodeDatabaseError, err, "取消换货失败")
order.Status = constants.ExchangeStatusCancelled
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCancelled, "已取消卡换货单", constants.AuditResultSuccess,
order, oldAsset, nil,
map[string]any{"status": fromStatus}, map[string]any{"status": constants.ExchangeStatusCancelled},
nil, nil, nil, nil, nil, nil)
})
if err != nil {
order.Status = fromStatus
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeCancelled, "取消卡换货失败", order, err)
}
return nil
return err
}
func (s *Service) Renew(ctx context.Context, id uint) error {
@@ -261,52 +333,84 @@ func (s *Service) Renew(ctx context.Context, id uint) error {
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if order.Status != constants.ExchangeStatusCompleted {
return errors.New(errors.CodeExchangeStatusInvalid)
err = errors.New(errors.CodeExchangeStatusInvalid)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeRenewed, "换出旧卡转新被拒绝", order, err)
return err
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if order.OldAssetType == constants.ExchangeAssetTypeIotCard {
var card model.IotCard
if err = tx.Where("id = ?", order.OldAssetID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
if queryErr := tx.WithContext(ctx).Where("id = ?", order.OldAssetID).First(&card).Error; queryErr != nil {
if queryErr == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAssetNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询旧卡失败")
return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询旧卡失败")
}
if card.AssetStatus != constants.AssetStatusExchanged {
return errors.New(errors.CodeExchangeAssetNotExchanged)
}
var newCard *model.IotCard
if order.NewAssetID != nil && *order.NewAssetID > 0 {
var value model.IotCard
if queryErr := tx.WithContext(ctx).Where("id = ?", *order.NewAssetID).First(&value).Error; queryErr != nil {
if queryErr == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAssetNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询换货新卡失败")
}
newCard = &value
}
auditBefore, auditErr := s.captureCardExchangeAuditBefore(ctx, tx, &card, newCard)
if auditErr != nil {
return auditErr
}
cardBefore := map[string]any{"generation": card.Generation, "asset_status": card.AssetStatus}
if err = tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{
if updateErr := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{
"generation": card.Generation + 1,
"asset_status": constants.AssetStatusInStock,
"accumulated_recharge_by_series": "{}",
"first_recharge_triggered_by_series": "{}",
"updater": middleware.GetUserIDFromContext(ctx),
"updated_at": time.Now(),
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "重置旧卡转新状态失败")
}).Error; updateErr != nil {
return errors.Wrap(errors.CodeDatabaseError, updateErr, "重置旧卡转新状态失败")
}
cardKey := exchangeAssetBindingKey(&resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, Card: &card, VirtualNo: card.VirtualNo})
if cardKey != "" {
if err = tx.Where("virtual_no = ?", cardKey).Delete(&model.PersonalCustomerDevice{}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "清理个人客户绑定失败")
if unbindErr := s.customerBinding.UnbindByVirtualNo(ctx, tx, constants.ExchangeAssetTypeIotCard, card.ID, cardKey); unbindErr != nil {
return unbindErr
}
}
if err = tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeIotCard, card.ID).Delete(&model.AssetWallet{}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "清理旧钱包失败")
if deleteErr := tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeIotCard, card.ID).Delete(&model.AssetWallet{}).Error; deleteErr != nil {
return errors.Wrap(errors.CodeDatabaseError, deleteErr, "清理旧钱包失败")
}
shopTag := uint(0)
if card.ShopID != nil {
shopTag = *card.ShopID
}
if err = tx.Create(&model.AssetWallet{ResourceType: constants.ExchangeAssetTypeIotCard, ResourceID: card.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: constants.AssetWalletStatusNormal, Version: 0, ShopIDTag: shopTag}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建新钱包失败")
if createErr := tx.Create(&model.AssetWallet{ResourceType: constants.ExchangeAssetTypeIotCard, ResourceID: card.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: constants.AssetWalletStatusNormal, Version: 0, ShopIDTag: shopTag}).Error; createErr != nil {
return errors.Wrap(errors.CodeDatabaseError, createErr, "创建新钱包失败")
}
return nil
var renewedCard model.IotCard
if queryErr := tx.WithContext(ctx).Where("id = ?", card.ID).First(&renewedCard).Error; queryErr != nil {
return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询旧卡转新结果失败")
}
walletResource, resourceErr := loadCardExchangeRenewWalletResource(ctx, tx, card.ID, auditBefore.Wallets)
if resourceErr != nil {
return resourceErr
}
extra := cardExchangeOldBindingResources(auditBefore)
extra = append(extra, *walletResource)
return s.appendCardExchangeAudit(ctx, tx, constants.AuditActionCardExchangeRenewed, "换出旧卡已转为新卡状态", constants.AuditResultSuccess,
order, &renewedCard, newCard,
nil, nil,
cardBefore, map[string]any{"generation": renewedCard.Generation, "asset_status": renewedCard.AssetStatus},
nil, nil, extra, nil)
}
var device model.Device
@@ -319,6 +423,22 @@ func (s *Service) Renew(ctx context.Context, id uint) error {
if device.AssetStatus != constants.AssetStatusExchanged {
return errors.New(errors.CodeExchangeAssetNotExchanged)
}
var newDevice *model.Device
if order.NewAssetID != nil && *order.NewAssetID > 0 {
var value model.Device
if queryErr := tx.WithContext(ctx).Where("id = ?", *order.NewAssetID).First(&value).Error; queryErr != nil {
if queryErr == gorm.ErrRecordNotFound {
return errors.New(errors.CodeAssetNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询换货新设备失败")
}
newDevice = &value
}
deviceAuditBefore, auditErr := s.captureDeviceExchangeAuditBefore(ctx, tx, &device, newDevice)
if auditErr != nil {
return auditErr
}
deviceBefore := map[string]any{"generation": device.Generation, "asset_status": device.AssetStatus}
if err = tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(map[string]any{
"generation": device.Generation + 1,
@@ -333,8 +453,8 @@ func (s *Service) Renew(ctx context.Context, id uint) error {
deviceKey := exchangeAssetBindingKey(&resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &device, VirtualNo: device.VirtualNo})
if deviceKey != "" {
if err = tx.Where("virtual_no = ?", deviceKey).Delete(&model.PersonalCustomerDevice{}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "清理个人客户绑定失败")
if err = s.customerBinding.UnbindByVirtualNo(ctx, tx, constants.ExchangeAssetTypeDevice, device.ID, deviceKey); err != nil {
return err
}
}
@@ -349,8 +469,31 @@ func (s *Service) Renew(ctx context.Context, id uint) error {
if err = tx.Create(&model.AssetWallet{ResourceType: constants.ExchangeAssetTypeDevice, ResourceID: device.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: constants.AssetWalletStatusNormal, Version: 0, ShopIDTag: shopTag}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建新钱包失败")
}
return nil
var renewedDevice model.Device
if queryErr := tx.WithContext(ctx).Where("id = ?", device.ID).First(&renewedDevice).Error; queryErr != nil {
return errors.Wrap(errors.CodeDatabaseError, queryErr, "查询旧设备转新结果失败")
}
walletResource, resourceErr := loadDeviceExchangeRenewWalletResource(ctx, tx, device.ID, deviceAuditBefore.Wallets)
if resourceErr != nil {
return resourceErr
}
extra := deviceExchangeOldCustomerBindingResources(deviceAuditBefore)
simResources, resourceErr := loadDeviceExchangeSIMResources(ctx, tx, &renewedDevice, newDevice)
if resourceErr != nil {
return resourceErr
}
extra = append(extra, simResources...)
extra = append(extra, *walletResource)
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeRenewed, "换出旧卡已转为新卡状态", constants.AuditResultSuccess,
order, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &renewedDevice}, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: newDevice},
nil, nil,
deviceBefore, map[string]any{"generation": renewedDevice.Generation, "asset_status": renewedDevice.AssetStatus},
nil, nil, extra, nil)
})
if err != nil {
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeRenewed, "换出旧卡转新失败", order, err)
}
return err
}
func (s *Service) GetPending(ctx context.Context, identifier string) (*dto.ClientExchangePendingResponse, error) {
@@ -392,17 +535,24 @@ func (s *Service) SubmitShippingInfo(ctx context.Context, id uint, req *dto.Clie
return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败")
}
if !isShippingExchangeFlow(order.FlowType) {
return errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持填写收货信息")
err = errors.New(errors.CodeExchangeStatusInvalid, "该流程类型不支持填写收货信息")
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息被拒绝", order, err)
return err
}
if order.Status != constants.ExchangeStatusPendingInfo {
return errors.New(errors.CodeExchangeStatusInvalid)
err = errors.New(errors.CodeExchangeStatusInvalid)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息被拒绝", order, err)
return err
}
oldAsset, err := s.resolveAssetByID(ctx, order.OldAssetType, order.OldAssetID)
if err != nil {
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息失败", order, err)
return err
}
if !s.customerOwnsAsset(ctx, oldAsset) {
return errors.New(errors.CodeExchangeOrderNotFound)
err = errors.New(errors.CodeExchangeOrderNotFound)
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息被拒绝", order, err)
return err
}
updates := map[string]any{
@@ -411,13 +561,32 @@ func (s *Service) SubmitShippingInfo(ctx context.Context, id uint, req *dto.Clie
"recipient_address": req.RecipientAddress,
"updated_at": time.Now(),
}
if err := s.exchangeStore.UpdateStatus(ctx, id, constants.ExchangeStatusPendingInfo, constants.ExchangeStatusPendingShip, updates); err != nil {
if err == gorm.ErrRecordNotFound {
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
values := make(map[string]any, len(updates)+1)
for key, value := range updates {
values[key] = value
}
values["status"] = constants.ExchangeStatusPendingShip
result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).
Where("id = ? AND status = ?", id, constants.ExchangeStatusPendingInfo).
Updates(values)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "提交收货信息失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeExchangeStatusInvalid)
}
return errors.Wrap(errors.CodeDatabaseError, err, "提交收货信息失败")
order.Status = constants.ExchangeStatusPendingShip
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeShippingInfoSubmitted, "已提交卡换货收货信息", constants.AuditResultSuccess,
order, oldAsset, nil,
map[string]any{"status": constants.ExchangeStatusPendingInfo}, map[string]any{"status": constants.ExchangeStatusPendingShip},
nil, nil, nil, nil, nil, nil)
})
if err != nil {
order.Status = constants.ExchangeStatusPendingInfo
s.recordExchangeOrderFailure(ctx, constants.AuditActionCardExchangeShippingInfoSubmitted, "提交卡换货收货信息失败", order, err)
}
return nil
return err
}
type resolvedExchangeAsset struct {
@@ -497,12 +666,8 @@ func isShippingExchangeFlow(flowType string) bool {
return effectiveExchangeFlowType(flowType) == constants.ExchangeFlowTypeShipping
}
func (s *Service) createDirectExchange(ctx context.Context, req *dto.CreateExchangeRequest, oldAsset *resolvedExchangeAsset) (*dto.ExchangeOrderResponse, error) {
func (s *Service) createDirectExchange(ctx context.Context, req *dto.CreateExchangeRequest, oldAsset *resolvedExchangeAsset, order *model.ExchangeOrder) (uint, error) {
var orderID uint
migrateData := false
if req.MigrateData != nil {
migrateData = *req.MigrateData
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
lockedOldAsset, err := s.resolveAssetByIDWithTx(ctx, tx, oldAsset.AssetType, oldAsset.AssetID)
@@ -524,27 +689,13 @@ func (s *Service) createDirectExchange(ctx context.Context, req *dto.CreateExcha
return errors.New(errors.CodeExchangeAssetTypeMismatch)
}
creator := middleware.GetUserIDFromContext(ctx)
order := &model.ExchangeOrder{
ExchangeNo: model.GenerateExchangeNo(),
FlowType: constants.ExchangeFlowTypeDirect,
OldAssetType: lockedOldAsset.AssetType,
OldAssetID: lockedOldAsset.AssetID,
OldAssetIdentifier: lockedOldAsset.Identifier,
NewAssetType: newAsset.AssetType,
NewAssetID: &newAsset.AssetID,
NewAssetIdentifier: newAsset.Identifier,
ExchangeReason: req.ExchangeReason,
Remark: req.Remark,
Status: constants.ExchangeStatusPendingInfo,
MigrationCompleted: false,
MigrationBalance: 0,
MigrateData: migrateData,
BaseModel: model.BaseModel{Creator: creator, Updater: creator},
}
if lockedOldAsset.ShopID != nil {
order.ShopID = lockedOldAsset.ShopID
}
order.OldAssetType = lockedOldAsset.AssetType
order.OldAssetID = lockedOldAsset.AssetID
order.OldAssetIdentifier = lockedOldAsset.Identifier
order.NewAssetType = newAsset.AssetType
order.NewAssetID = &newAsset.AssetID
order.NewAssetIdentifier = newAsset.Identifier
order.ShopID = cloneShopID(lockedOldAsset.ShopID)
if err = tx.WithContext(ctx).Create(order).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建直接换货单失败")
}
@@ -555,9 +706,9 @@ func (s *Service) createDirectExchange(ctx context.Context, req *dto.CreateExcha
return nil
})
if err != nil {
return nil, err
return 0, err
}
return s.Get(ctx, orderID)
return orderID, nil
}
func (s *Service) shipWithTx(ctx context.Context, order *model.ExchangeOrder, req *dto.ExchangeShipRequest) error {
@@ -609,7 +760,16 @@ func (s *Service) shipWithTx(ctx context.Context, order *model.ExchangeOrder, re
if result.RowsAffected == 0 {
return errors.New(errors.CodeExchangeStatusInvalid)
}
return nil
lockedOrder.NewAssetType = newAsset.AssetType
lockedOrder.NewAssetID = &newAsset.AssetID
lockedOrder.NewAssetIdentifier = newAsset.Identifier
lockedOrder.MigrateData = req.MigrateData
lockedOrder.ShippedAt = &now
lockedOrder.Status = constants.ExchangeStatusShipped
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeShipped, "卡换货单已发货", constants.AuditResultSuccess,
lockedOrder, oldAsset, newAsset,
map[string]any{"status": constants.ExchangeStatusPendingShip}, map[string]any{"status": constants.ExchangeStatusShipped},
nil, nil, nil, nil, nil, nil)
})
}
@@ -629,6 +789,19 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order
if err = s.validateExchangeAssetsWithTx(ctx, tx, order.ID, oldAsset, newAsset); err != nil {
return err
}
var auditBefore *cardExchangeAuditBefore
var deviceAuditBefore *deviceExchangeAuditBefore
if order.OldAssetType == constants.ExchangeAssetTypeIotCard {
auditBefore, err = s.captureCardExchangeAuditBefore(ctx, tx, oldAsset.Card, newAsset.Card)
if err != nil {
return err
}
} else {
deviceAuditBefore, err = s.captureDeviceExchangeAuditBefore(ctx, tx, oldAsset.Device, newAsset.Device)
if err != nil {
return err
}
}
if err = s.syncNewAssetOwnershipWithTx(ctx, tx, oldAsset, newAsset); err != nil {
return err
}
@@ -639,9 +812,9 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order
return err
}
var migrationBalance int64
var migration *exchangeMigrationResult
if order.MigrateData {
migrationBalance, err = s.executeMigrationWithTx(ctx, tx, order, oldAsset, newAsset)
migration, err = s.executeMigrationWithTx(ctx, tx, order, oldAsset, newAsset)
if err != nil {
return err
}
@@ -656,7 +829,7 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order
}
if order.MigrateData {
updates["migration_completed"] = true
updates["migration_balance"] = migrationBalance
updates["migration_balance"] = migration.Balance
}
result := tx.WithContext(ctx).Model(&model.ExchangeOrder{}).
Where("id = ? AND status = ?", order.ID, fromStatus).
@@ -667,7 +840,49 @@ func (s *Service) completeExchangeWithTx(ctx context.Context, tx *gorm.DB, order
if result.RowsAffected == 0 {
return errors.New(errors.CodeExchangeStatusInvalid)
}
return nil
orderBefore := map[string]any{"status": fromStatus, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance}
order.Status = constants.ExchangeStatusCompleted
order.CompletedAt = &now
if migration != nil {
order.MigrationCompleted = true
order.MigrationBalance = migration.Balance
}
if order.OldAssetType == constants.ExchangeAssetTypeDevice {
var oldDeviceAfter, newDeviceAfter model.Device
if err = tx.WithContext(ctx).Where("id = ?", oldAsset.AssetID).First(&oldDeviceAfter).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货旧设备结果失败")
}
if err = tx.WithContext(ctx).Where("id = ?", newAsset.AssetID).First(&newDeviceAfter).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备换货新设备结果失败")
}
extra, resourceErr := s.buildDeviceExchangeCompletionResources(ctx, tx, order, &oldDeviceAfter, &newDeviceAfter, deviceAuditBefore, migration)
if resourceErr != nil {
return resourceErr
}
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCompleted, "卡换货已完成", constants.AuditResultSuccess,
order, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &oldDeviceAfter}, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, Device: &newDeviceAfter},
orderBefore, map[string]any{"status": constants.ExchangeStatusCompleted, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance},
map[string]any{"asset_status": oldAsset.Device.AssetStatus, "shop_id": oldAsset.Device.ShopID}, map[string]any{"asset_status": oldDeviceAfter.AssetStatus, "shop_id": oldDeviceAfter.ShopID},
map[string]any{"asset_status": newAsset.Device.AssetStatus, "shop_id": newAsset.Device.ShopID}, map[string]any{"asset_status": newDeviceAfter.AssetStatus, "shop_id": newDeviceAfter.ShopID},
extra, nil)
}
var oldCardAfter, newCardAfter model.IotCard
if err = tx.WithContext(ctx).Where("id = ?", oldAsset.AssetID).First(&oldCardAfter).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货旧卡结果失败")
}
if err = tx.WithContext(ctx).Where("id = ?", newAsset.AssetID).First(&newCardAfter).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询卡换货新卡结果失败")
}
extra, err := s.buildCardExchangeCompletionResources(ctx, tx, order, &oldCardAfter, &newCardAfter, auditBefore, migration)
if err != nil {
return err
}
return s.appendExchangeAudit(ctx, tx, constants.AuditActionCardExchangeCompleted, "卡换货已完成", constants.AuditResultSuccess,
order, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, Card: &oldCardAfter}, &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, Card: &newCardAfter},
orderBefore, map[string]any{"status": constants.ExchangeStatusCompleted, "migration_completed": order.MigrationCompleted, "migration_balance": order.MigrationBalance},
map[string]any{"asset_status": oldAsset.Card.AssetStatus, "shop_id": oldAsset.Card.ShopID}, map[string]any{"asset_status": oldCardAfter.AssetStatus, "shop_id": oldCardAfter.ShopID},
map[string]any{"asset_status": newAsset.Card.AssetStatus, "shop_id": newAsset.Card.ShopID}, map[string]any{"asset_status": newCardAfter.AssetStatus, "shop_id": newCardAfter.ShopID},
extra, nil)
}
func (s *Service) lockExchangeOrderByID(ctx context.Context, tx *gorm.DB, id uint) (*model.ExchangeOrder, error) {

View File

@@ -0,0 +1,60 @@
package export_task
import (
"context"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditfailure"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
func (s *Service) writeTaskAudit(ctx context.Context, tx *gorm.DB, actionCode, summary string, task *model.ExportTask, before, after map[string]any, result, phase, errorCode, errorSummary string) error {
scopeType, scopeID := constants.AuditScopePlatform, ""
if task.CreatorShopID != nil {
scopeType, scopeID = constants.AuditScopeShop, strconv.FormatUint(uint64(*task.CreatorShopID), 10)
}
return s.auditWriter.WriteTask(ctx, tx, audit.TaskInput{
EventID: audit.TaskEventID(constants.AuditResourceExportTask, task.ID, phase),
ActionCode: actionCode, Summary: summary, TaskID: task.ID, TaskNo: task.TaskNo,
Actor: audit.ActorInput{
Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(middleware.GetUserIDFromContext(ctx)), 10),
Name: middleware.GetUsernameFromContext(ctx), ShopID: task.CreatorShopID, EnterpriseID: task.CreatorEnterpriseID,
},
Source: constants.AuditSourceAdminAPI, ScopeType: scopeType, ScopeID: scopeID,
Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary,
IdentitySnapshot: map[string]any{
"id": task.ID, "task_no": task.TaskNo, "scene": task.Scene, "format": task.Format,
"creator_user_id": task.CreatorUserID, "creator_user_type": task.CreatorUserType,
"creator_shop_id": task.CreatorShopID, "creator_enterprise_id": task.CreatorEnterpriseID,
"scope_shop_ids": task.ScopeShopIDs,
},
BeforeData: before, AfterData: after,
})
}
func (s *Service) recordTaskAudit(ctx context.Context, actionCode, summary string, task *model.ExportTask, before, after map[string]any, result, phase string, errorCode int) {
if s == nil || s.auditWriter == nil || s.db == nil || task == nil || task.TaskNo == "" {
return
}
code := strconv.Itoa(errorCode)
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
return s.writeTaskAudit(ctx, tx, actionCode, summary, task, before, after, result, phase, code, summary)
})
if err != nil {
auditfailure.RecordSecondaryWriteFailure(actionCode, task.TaskNo, "", task.TaskNo, code, err)
}
}
func exportTaskState(task *model.ExportTask) map[string]any {
if task == nil {
return nil
}
return map[string]any{
"status": task.Status, "cancel_requested": task.CancelRequested, "progress": task.Progress,
}
}

View File

@@ -2,6 +2,8 @@ package export_task
import (
"context"
stderrors "errors"
"strconv"
"time"
"github.com/bytedance/sonic"
@@ -10,10 +12,12 @@ import (
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/exporter"
"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/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"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"
@@ -28,6 +32,7 @@ type Service struct {
queueClient *queue.Client
storageSvc *storage.Service
sceneRegistry *exporter.Registry
auditWriter *audit.Writer
}
type dispatchPayload struct {
@@ -35,14 +40,18 @@ type dispatchPayload struct {
}
// New 创建导出任务服务。
func New(db *gorm.DB, taskStore *postgres.ExportTaskStore, queueClient *queue.Client, storageSvc *storage.Service) *Service {
return &Service{
func New(db *gorm.DB, taskStore *postgres.ExportTaskStore, queueClient *queue.Client, storageSvc *storage.Service, auditWriters ...*audit.Writer) *Service {
service := &Service{
db: db,
taskStore: taskStore,
queueClient: queueClient,
storageSvc: storageSvc,
sceneRegistry: exporter.NewDefaultRegistry(db),
}
if len(auditWriters) > 0 {
service.auditWriter = auditWriters[0]
}
return service
}
// CreateTask 创建导出任务并入队 dispatch。
@@ -118,7 +127,16 @@ func (s *Service) CreateTask(ctx context.Context, req *dto.CreateExportTaskReque
task.Creator = userID
task.Updater = userID
if err := s.taskStore.Create(ctx, task); err != nil {
if s.auditWriter == nil {
return nil, errors.New(errors.CodeInvalidStatus, "导出任务统一审计接缝未配置")
}
if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.taskStore.WithTx(tx).Create(ctx, task); err != nil {
return err
}
return s.writeTaskAudit(ctx, tx, constants.AuditActionExportTaskCreated, "创建业务导出任务", task, nil, exportTaskState(task), constants.AuditResultSuccess, "created", "", "")
}); err != nil {
s.recordTaskAudit(ctx, constants.AuditActionExportTaskCreated, "创建业务导出任务失败", task, nil, exportTaskState(task), constants.AuditResultFailed, "create_failed", errors.CodeDatabaseError)
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建导出任务失败")
}
@@ -130,7 +148,18 @@ func (s *Service) CreateTask(ctx context.Context, req *dto.CreateExportTaskReque
asynq.Timeout(constants.ExportDispatchTaskTimeout),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeExportDispatch)),
); err != nil {
_ = s.taskStore.MarkFailed(ctx, task.ID, userID, "导出任务入队失败")
secondaryErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := s.taskStore.WithTx(tx).MarkFailed(ctx, task.ID, userID, "导出任务入队失败"); err != nil {
return err
}
before := exportTaskState(task)
task.Status = constants.ExportTaskStatusFailed
task.ErrorMessage = "导出任务入队失败"
return s.writeTaskAudit(ctx, tx, constants.AuditActionExportTaskCreated, "导出任务入队失败", task, before, exportTaskState(task), constants.AuditResultFailed, "enqueue_failed", strconv.Itoa(errors.CodeTaskQueueError), "导出任务入队失败")
})
if secondaryErr != nil {
auditfailure.RecordSecondaryWriteFailure(constants.AuditActionExportTaskCreated, task.TaskNo, "", task.TaskNo, strconv.Itoa(errors.CodeTaskQueueError), secondaryErr)
}
return nil, errors.Wrap(errors.CodeTaskQueueError, err, "导出任务入队失败")
}
@@ -233,45 +262,68 @@ func (s *Service) CancelTask(ctx context.Context, id uint) (*dto.CancelExportTas
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询导出任务失败")
}
if s.auditWriter == nil {
return nil, errors.New(errors.CodeInvalidStatus, "导出任务统一审计接缝未配置")
}
message := "取消请求已提交"
before := exportTaskState(task)
changed := false
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
txStore := s.taskStore.WithTx(tx)
switch task.Status {
case constants.ExportTaskStatusPending:
ok, err := s.taskStore.CancelPendingTask(ctx, id, userID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "取消导出任务失败")
ok, updateErr := txStore.CancelPendingTask(ctx, id, userID)
if updateErr != nil {
return updateErr
}
if !ok {
return nil, errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
return errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
}
message = "任务已取消"
task.Status, task.CancelRequested, task.Progress = constants.ExportTaskStatusCancelled, true, 100
message, changed = "任务已取消", true
case constants.ExportTaskStatusProcessing:
if !task.CancelRequested {
ok, err := s.taskStore.SetCancelRequested(ctx, id, userID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "提交取消请求失败")
if task.CancelRequested {
message = "取消请求已提交,请稍后刷新状态"
return nil
}
ok, updateErr := txStore.SetCancelRequested(ctx, id, userID)
if updateErr != nil {
return updateErr
}
if !ok {
return nil, errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
}
} else {
message = "取消请求已提交,请稍后刷新状态"
return errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
}
task.CancelRequested, changed = true, true
case constants.ExportTaskStatusCompleted, constants.ExportTaskStatusFailed, constants.ExportTaskStatusCancelled:
return nil, errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
return errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
default:
return nil, errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
return errors.New(errors.CodeInvalidStatus, "当前状态不支持取消")
}
latestTask, err := s.taskStore.GetByID(ctx, id)
if !changed {
return nil
}
return s.writeTaskAudit(ctx, tx, constants.AuditActionExportTaskCancelled, message, task, before, exportTaskState(task), constants.AuditResultSuccess, "cancelled", "", "")
})
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询最新任务状态失败")
result := constants.AuditResultFailed
errorCode := errors.CodeDatabaseError
var appErr *errors.AppError
if stderrors.As(err, &appErr) && appErr.Code == errors.CodeInvalidStatus {
result = constants.AuditResultDenied
errorCode = appErr.Code
}
s.recordTaskAudit(ctx, constants.AuditActionExportTaskCancelled, "取消业务导出任务失败", task, before, exportTaskState(task), result, "", errorCode)
if appErr != nil {
return nil, appErr
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "取消导出任务失败")
}
return &dto.CancelExportTaskResponse{
TaskID: latestTask.ID,
Status: latestTask.Status,
StatusName: constants.GetExportTaskStatusName(latestTask.Status),
CancelRequested: latestTask.CancelRequested,
TaskID: task.ID,
Status: task.Status,
StatusName: constants.GetExportTaskStatusName(task.Status),
CancelRequested: task.CancelRequested,
Message: message,
}, nil
}

View File

@@ -0,0 +1,117 @@
package iot_card
import (
"context"
"strconv"
"time"
"github.com/google/uuid"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors"
)
type gatewayAttempt struct {
log *model.IntegrationLog
startedAt time.Time
}
func (s *Service) startGatewayCardAttempt(ctx context.Context, card *model.IotCard, operation, scene, seriesKey string, attempt int) (*gatewayAttempt, error) {
if s == nil || s.speedTierIntegration == nil {
return nil, pkgerrors.New(pkgerrors.CodeInvalidStatus, "Gateway Integration Log 接缝未配置")
}
resourceID := strconv.FormatUint(uint64(card.ID), 10)
triggerSource := auditcontext.From(ctx).Source
if triggerSource == "" {
triggerSource = "service"
}
triggerScene := scene
triggerSeries := uuid.NewSHA1(uuid.NameSpaceOID, []byte("gateway-card:"+seriesKey+":"+operation)).String()
requestID := requestIDFromContext(ctx)
var requestIDPtr *string
if requestID != "" {
requestIDPtr = &requestID
}
log, err := s.speedTierIntegration.Start(ctx, integrationlog.Attempt{
Provider: constants.IntegrationProviderGateway, Direction: constants.IntegrationDirectionOutbound,
Operation: operation, ExternalID: &card.ICCID,
ResourceType: constants.AssetTypeIotCard, ResourceID: &resourceID, ResourceKey: &card.ICCID,
TriggerSource: &triggerSource, TriggerScene: &triggerScene, TriggerSeries: &triggerSeries,
Attempt: attempt, RequestID: requestIDPtr, CorrelationID: requestIDPtr,
RequestSummary: map[string]any{"iot_card_id": card.ID, "iccid": card.ICCID},
})
if err != nil {
return nil, err
}
return &gatewayAttempt{log: log, startedAt: time.Now()}, nil
}
func (s *Service) completeGatewayCardAttempt(ctx context.Context, attempt *gatewayAttempt, callErr error, stateChanged bool) error {
if attempt == nil || attempt.log == nil {
return nil
}
completion := integrationlog.Completion{
Result: constants.IntegrationResultSuccess, DurationMS: time.Since(attempt.startedAt).Milliseconds(),
StateChanged: stateChanged, ResponseSummary: map[string]any{"result": "success"},
}
if callErr != nil {
completion.Result = constants.IntegrationResultFailed
completion.SafeProviderMessage = "Gateway 请求失败"
completion.ResponseSummary = map[string]any{"result": "failed"}
if isGatewayTimeout(callErr) {
completion.Result = constants.IntegrationResultUnknown
completion.SafeProviderMessage = "Gateway 请求结果未知"
completion.ResponseSummary = map[string]any{"result": "unknown"}
completion.RecoveryStrategy = constants.GatewayQueryUnknownRecoveryStrategy
}
}
_, err := s.speedTierIntegration.Complete(ctx, attempt.log.IntegrationID, completion)
return err
}
type gatewayCardAttemptObserver struct {
service *Service
card *model.IotCard
operation string
scene string
seriesKey string
nextAttempt int
current *gatewayAttempt
successful *gatewayAttempt
lastCallErr error
recordingErr error
unknown bool
}
func (o *gatewayCardAttemptObserver) BeforeAttempt(ctx context.Context, _ int) error {
o.nextAttempt++
o.lastCallErr = nil
attempt, err := o.service.startGatewayCardAttempt(ctx, o.card, o.operation, o.scene, o.seriesKey, o.nextAttempt)
if err != nil {
o.recordingErr = err
return err
}
o.current = attempt
return nil
}
func (o *gatewayCardAttemptObserver) AfterAttempt(ctx context.Context, _ int, callErr error) error {
o.lastCallErr = callErr
if isGatewayTimeout(callErr) {
o.unknown = true
}
if callErr == nil {
o.successful = o.current
o.current = nil
return nil
}
err := o.service.completeGatewayCardAttempt(ctx, o.current, callErr, false)
o.current = nil
if err != nil {
o.recordingErr = err
}
return err
}

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