diff --git a/.scratch/tech-global-audit/审计覆盖基线.md b/.scratch/tech-global-audit/审计覆盖基线.md index 6be7be4..0380aed 100644 --- a/.scratch/tech-global-audit/审计覆盖基线.md +++ b/.scratch/tech-global-audit/审计覆盖基线.md @@ -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.6~8.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` 是预占金额、付款钱包与唯一终态的权威事实;钱包与完成扣除流水同事务更新。当前生产仅取消订单调用 release,freeze/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/A(Audit 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 Log,Secret、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 Log,Secret、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/A:CSV 解析、分配和回收均为本地数据库操作,不调用 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` 是套餐权益终态权威事实;逐订单状态更新与子事件同一事务,任务终态与完成根同一事务 | 对象存储只用于读取 CSV,StorageKey、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,系统安全凭据和通知正文不进入审计 | 业务事务仍只写现有三类结构化通知请求 Outbox;Outbox 投递事实与通知生成 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/A:CSV 解析、分配、系列绑定和回收均为本地数据库操作,不调用 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/unknown;Wi-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 事务内追加审计,审计失败回滚业务;重复命中且未发生状态变化时不伪造成功事件 | 本切片不直接调用支付渠道或 Gateway,Integration 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.6~7.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 Log;unknown 只表示外部结果未确认,不推进充值或钱包事实,也不伪造成功 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 Ledger,Audit Event 不替代它;Integration Log 与 Outbox 均为 N/A(本切片仅本地配置事务,不发生外部交互或可靠异步副作用)。`shop_package_batch_allocation` 借用旧账号 operation log 的写入已停止,原上架、直属下级授权、佣金天花板、成本价锁定、赠送套餐和分配规则保持不变;套餐购买及 `package_usage` 权益生命周期明确留给 7.2~7.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 账号 actor;Outbox 消费以当前 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.9,OpenAPI/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.6~7.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、08;19 号票验证生产装配和直接旧表写入归零。 ### 旧资产审计 - Writer、Builder 与 Store:`internal/service/asset_audit/`、`internal/store/postgres/asset_operation_log_store.go`。 - API/Worker 装配:`internal/bootstrap/services.go`、`internal/bootstrap/worker_services.go`、`internal/bootstrap/stores.go`、`internal/bootstrap/worker_stores.go`。 -- 调用模块:`internal/service/asset/`、`internal/service/device/`、`internal/service/device_import/`、`internal/service/iot_card/`、`internal/service/iot_card_import/`、`internal/service/polling/asset_polling_service.go`。 +- 调用模块:`internal/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、09;19 号票验证生产装配和直接旧表写入归零。 @@ -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 Log,10 号票提供历史投影,19 号票再启用旧写护栏。 +- 当前继续承担运行状态、进度、结果和历史查询,8.5 仅为人工触发/取消补充统一 Audit Event;不得停写、删除或以 Audit Event 替代该 ledger,最终旧写护栏必须将其列入显式白名单。 ## 评审门禁 diff --git a/README.md b/README.md index d160334..f347270 100644 --- a/README.md +++ b/README.md @@ -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) diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 255ab01..2dc0f2b 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -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( diff --git a/docs/feature-505-package-activation-recovery/功能总结.md b/docs/feature-505-package-activation-recovery/功能总结.md new file mode 100644 index 0000000..5b2400d --- /dev/null +++ b/docs/feature-505-package-activation-recovery/功能总结.md @@ -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。 diff --git a/docs/批量套餐失效订单生成脚本/功能总结.md b/docs/批量套餐失效订单生成脚本/功能总结.md new file mode 100644 index 0000000..ca5701c --- /dev/null +++ b/docs/批量套餐失效订单生成脚本/功能总结.md @@ -0,0 +1,17 @@ +# 批量套餐失效订单生成脚本功能总结 + +## 功能说明 + +新增 `scripts/batch_package_purchase/generate_invalidate_orders.py`,读取单列 ICCID CSV,通过现有后台资产套餐接口查询状态为待生效、生效中或已用完的套餐,按订单号去重后生成现有订单套餐批量失效功能要求的单列 CSV。 + +## 边界与安全 + +- 仅接受 19 或 20 位 ICCID,并复用批量购买脚本的表头、重复值和单列校验。 +- 只读取现有受权后台接口,不直连数据库,不新增依赖。 +- 已过期和已失效套餐不进入结果;无订单号的套餐记录跳过。 +- 任一 ICCID 查询失败时整批终止,不生成不完整的订单号文件。 +- 脚本不触发套餐失效,实际状态变更继续使用已有批量失效功能。 + +## 输出 + +输出文件首行为 `order_no`,后续每行一个去重后的订单号,可直接上传到已有订单套餐批量失效功能。 diff --git a/docs/批量套餐过期时间修改脚本/功能总结.md b/docs/批量套餐过期时间修改脚本/功能总结.md new file mode 100644 index 0000000..846f7da --- /dev/null +++ b/docs/批量套餐过期时间修改脚本/功能总结.md @@ -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 状态码、业务错误码和接口消息。 diff --git a/internal/application/accessaudit/change.go b/internal/application/accessaudit/change.go index 38ac3b1..8467f94 100644 --- a/internal/application/accessaudit/change.go +++ b/internal/application/accessaudit/change.go @@ -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 diff --git a/internal/application/agentrecharge/approval_decision.go b/internal/application/agentrecharge/approval_decision.go index e6d3367..615ba75 100644 --- a/internal/application/agentrecharge/approval_decision.go +++ b/internal/application/agentrecharge/approval_decision.go @@ -10,6 +10,7 @@ import ( approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/auditcontext" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) @@ -18,21 +19,23 @@ import ( type ApprovalDecisionHandler struct { db *gorm.DB posting *walletapp.PostingService + audit RechargeAuditWriter } // NewApprovalDecisionHandler 创建员工线下代充值审批终态消费者。 -func NewApprovalDecisionHandler(db *gorm.DB, posting *walletapp.PostingService) *ApprovalDecisionHandler { - return &ApprovalDecisionHandler{db: db, posting: posting} +func NewApprovalDecisionHandler(db *gorm.DB, posting *walletapp.PostingService, audit RechargeAuditWriter) *ApprovalDecisionHandler { + return &ApprovalDecisionHandler{db: db, posting: posting, audit: audit} } // Handle 幂等处理标准审批终态;只有 approved 首次入账,其他终态不修改钱包。 func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp.TerminalDecisionEvent) error { - if h == nil || h.db == nil || h.posting == nil { + if h == nil || h.db == nil || h.posting == nil || h.audit == nil { return errors.New(errors.CodeInternalError, "员工线下代充值审批终态能力未配置") } if event.BusinessType != constants.ApprovalBusinessTypeOfflineRecharge || event.BusinessID == 0 || event.InstanceID == 0 { return errors.New(errors.CodeInvalidParam, "员工线下代充值审批终态参数无效") } + ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID}) return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var record model.AgentRechargeRecord if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). @@ -50,11 +53,11 @@ func (h *ApprovalDecisionHandler) Handle(ctx context.Context, event approvalapp. case constants.ApprovalDecisionApproved: return h.applyApproved(ctx, tx, &record, event) case constants.ApprovalDecisionRejected: - return closeOfflineRecharge(ctx, tx, &record, constants.RechargeStatusRejected, "企业微信审批已拒绝") + return h.closeOfflineRecharge(ctx, tx, &record, event, constants.RechargeStatusRejected, "企业微信审批已拒绝") case constants.ApprovalDecisionCancelled: - return closeOfflineRecharge(ctx, tx, &record, constants.RechargeStatusClosed, "企业微信审批已撤销") + return h.closeOfflineRecharge(ctx, tx, &record, event, constants.RechargeStatusClosed, "企业微信审批已撤销") case constants.ApprovalDecisionDeleted: - return closeOfflineRecharge(ctx, tx, &record, constants.RechargeStatusClosed, "企业微信审批已删除") + return h.closeOfflineRecharge(ctx, tx, &record, event, constants.RechargeStatusClosed, "企业微信审批已删除") case constants.ApprovalDecisionRevokedAfterApproved: return nil default: @@ -86,17 +89,23 @@ func (h *ApprovalDecisionHandler) applyApproved( return errors.New(errors.CodeConflict, "线下代充值申请状态已变化") } } - _, err := h.posting.PostInTx(ctx, tx, walletapp.PostingCommand{ + posting, err := h.posting.PostInTx(ctx, tx, walletapp.PostingCommand{ ShopID: record.ShopID, WalletID: record.AgentWalletID, Amount: record.Amount, ReferenceType: constants.ReferenceTypeTopup, ReferenceID: record.ID, TransactionType: constants.AgentTransactionTypeRecharge, UserID: event.SubmitterAccountID, Creator: event.SubmitterAccountID, Remark: "企业微信审批通过线下充值", CorrelationID: event.CorrelationID, }) - return err + if err != nil { + return err + } + if record.Status == constants.RechargeStatusCompleted && posting.AlreadyApplied { + return nil + } + return h.appendTerminalAudit(ctx, tx, record, event, constants.AuditActionAgentRechargeCredited, "企业微信审批通过,代理充值已入账", constants.RechargeStatusCompleted, true) } -func closeOfflineRecharge(ctx context.Context, tx *gorm.DB, record *model.AgentRechargeRecord, status int, reason string) error { +func (h *ApprovalDecisionHandler) closeOfflineRecharge(ctx context.Context, tx *gorm.DB, record *model.AgentRechargeRecord, event approvalapp.TerminalDecisionEvent, status int, reason string) error { if record.Status == status { return nil } @@ -113,7 +122,35 @@ func closeOfflineRecharge(ctx context.Context, tx *gorm.DB, record *model.AgentR if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "线下代充值申请状态已变化") } - return nil + return h.appendTerminalAudit(ctx, tx, record, event, constants.AuditActionAgentRechargeClosed, reason, status, false) +} + +func (h *ApprovalDecisionHandler) appendTerminalAudit(ctx context.Context, tx *gorm.DB, record *model.AgentRechargeRecord, event approvalapp.TerminalDecisionEvent, actionCode, summary string, status int, withWallet bool) error { + after := *record + after.Status = status + change := RechargeAudit{ + ActionCode: actionCode, Summary: summary, Record: &after, + BeforeData: map[string]any{"status": record.Status}, AfterData: map[string]any{"status": status}, + } + var approval model.ApprovalInstance + if err := tx.WithContext(ctx).First(&approval, event.InstanceID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询线下代充值审批审计快照失败") + } + change.Approval = &approval + if withWallet { + var wallet model.AgentWallet + if err := tx.WithContext(ctx).First(&wallet, record.AgentWalletID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值钱包审计快照失败") + } + var transaction model.AgentWalletTransaction + if err := tx.WithContext(ctx).Where("reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?", + constants.ReferenceTypeTopup, record.ID, constants.AgentTransactionTypeRecharge, constants.TransactionStatusSuccess). + First(&transaction).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值入账流水审计快照失败") + } + change.Wallet, change.Transaction = &wallet, &transaction + } + return h.audit.WriteAgentRecharge(ctx, tx, change) } var _ approvalapp.BusinessDecisionHandler = (*ApprovalDecisionHandler)(nil) diff --git a/internal/application/agentrecharge/confirm_online_payment.go b/internal/application/agentrecharge/confirm_online_payment.go index a9fa231..24c9c66 100644 --- a/internal/application/agentrecharge/confirm_online_payment.go +++ b/internal/application/agentrecharge/confirm_online_payment.go @@ -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 diff --git a/internal/application/agentrecharge/offline_creation.go b/internal/application/agentrecharge/offline_creation.go index f76a5a4..d11c373 100644 --- a/internal/application/agentrecharge/offline_creation.go +++ b/internal/application/agentrecharge/offline_creation.go @@ -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 diff --git a/internal/application/agentrecharge/online_creation.go b/internal/application/agentrecharge/online_creation.go index bad530a..573e994 100644 --- a/internal/application/agentrecharge/online_creation.go +++ b/internal/application/agentrecharge/online_creation.go @@ -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}, + }) }) } diff --git a/internal/application/agentrecharge/online_payment.go b/internal/application/agentrecharge/online_payment.go index b643750..f104496 100644 --- a/internal/application/agentrecharge/online_payment.go +++ b/internal/application/agentrecharge/online_payment.go @@ -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" diff --git a/internal/application/agentrecharge/recharge_audit.go b/internal/application/agentrecharge/recharge_audit.go new file mode 100644 index 0000000..a97cf31 --- /dev/null +++ b/internal/application/agentrecharge/recharge_audit.go @@ -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 +} diff --git a/internal/application/agentrecharge/recover_online_payment.go b/internal/application/agentrecharge/recover_online_payment.go index bd29c1f..f8f0150 100644 --- a/internal/application/agentrecharge/recover_online_payment.go +++ b/internal/application/agentrecharge/recover_online_payment.go @@ -17,17 +17,18 @@ type RecoverOnlinePaymentService struct { wechat OnlinePaymentPort alipay OnlinePaymentPort confirm *ConfirmOnlinePaymentService + audit PaymentAuditWriter now func() time.Time } // NewRecoverOnlinePaymentService 创建代理在线充值支付恢复用例。 -func NewRecoverOnlinePaymentService(db *gorm.DB, wechat, alipay OnlinePaymentPort, confirm *ConfirmOnlinePaymentService) *RecoverOnlinePaymentService { - return &RecoverOnlinePaymentService{db: db, wechat: wechat, alipay: alipay, confirm: confirm, now: time.Now} +func NewRecoverOnlinePaymentService(db *gorm.DB, wechat, alipay OnlinePaymentPort, confirm *ConfirmOnlinePaymentService, audit PaymentAuditWriter) *RecoverOnlinePaymentService { + return &RecoverOnlinePaymentService{db: db, wechat: wechat, alipay: alipay, confirm: confirm, audit: audit, now: time.Now} } // ProcessBatch 按固定批次读取本地待处理事实并调用对应渠道收敛状态。 func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, error) { - if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.confirm == nil { + if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.confirm == nil || s.audit == nil { return 0, errors.New(errors.CodeServiceUnavailable, "代理在线充值支付恢复能力未配置") } now := s.now().UTC() @@ -128,7 +129,7 @@ func (s *RecoverOnlinePaymentService) queryPayment(ctx context.Context, adapter }) return err case OnlinePaymentStateClosed: - return s.closePending(ctx, payment.ID, recharge.ID) + return s.closePending(ctx, payment, recharge) default: return nil } @@ -171,21 +172,31 @@ func recoveryConfig(payment *model.Payment, configs map[uint]*model.WechatConfig return configs[*payment.PaymentConfigID] } -func (s *RecoverOnlinePaymentService) closePending(ctx context.Context, paymentID, rechargeID uint) error { +func (s *RecoverOnlinePaymentService) closePending(ctx context.Context, payment *model.Payment, recharge *model.AgentRechargeRecord) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { paymentUpdate := tx.Model(&model.Payment{}). - Where("id = ? AND status = ?", paymentID, model.PaymentRecordStatusPending). + Where("id = ? AND status = ?", payment.ID, model.PaymentRecordStatusPending). Update("status", model.PaymentRecordStatusFailed) if paymentUpdate.Error != nil { return errors.Wrap(errors.CodeDatabaseError, paymentUpdate.Error, "关闭失效代理充值支付单失败") } rechargeUpdate := tx.Model(&model.AgentRechargeRecord{}). - Where("id = ? AND status = ?", rechargeID, constants.RechargeStatusPending). + Where("id = ? AND status = ?", recharge.ID, constants.RechargeStatusPending). Update("status", constants.RechargeStatusClosed) if rechargeUpdate.Error != nil { return errors.Wrap(errors.CodeDatabaseError, rechargeUpdate.Error, "关闭失效代理充值单失败") } - return nil + if paymentUpdate.RowsAffected == 0 { + return nil + } + afterPayment := *payment + afterPayment.Status = model.PaymentRecordStatusFailed + return s.audit.WriteAgentRechargePayment(ctx, tx, PaymentAudit{ + ActionCode: constants.AuditActionPaymentFailed, Summary: "支付渠道确认订单已关闭", + Payment: &afterPayment, Recharge: recharge, + BeforeData: map[string]any{"status": payment.Status}, AfterData: map[string]any{"status": afterPayment.Status}, + RechargeBeforeData: map[string]any{"status": recharge.Status}, RechargeAfterData: map[string]any{"status": constants.RechargeStatusClosed}, + }) }) } diff --git a/internal/application/approval/audit.go b/internal/application/approval/audit.go new file mode 100644 index 0000000..2742ef3 --- /dev/null +++ b/internal/application/approval/audit.go @@ -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 +} diff --git a/internal/application/approval/create.go b/internal/application/approval/create.go index bd3dc34..4f9cafd 100644 --- a/internal/application/approval/create.go +++ b/internal/application/approval/create.go @@ -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 } diff --git a/internal/application/approval/sync_decision.go b/internal/application/approval/sync_decision.go index efa9632..0e84600 100644 --- a/internal/application/approval/sync_decision.go +++ b/internal/application/approval/sync_decision.go @@ -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 } diff --git a/internal/application/cardobservation/apply.go b/internal/application/cardobservation/apply.go index f2cb630..16ff296 100644 --- a/internal/application/cardobservation/apply.go +++ b/internal/application/cardobservation/apply.go @@ -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")) diff --git a/internal/application/cardobservation/network.go b/internal/application/cardobservation/network.go index 3d795cf..e2e48d3 100644 --- a/internal/application/cardobservation/network.go +++ b/internal/application/cardobservation/network.go @@ -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,17 +75,48 @@ func (s *Service) ApplyNetworkObservation(ctx context.Context, observation domai if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "卡网络事实已被其他请求更新") } - if !decision.StatusChanged { - return nil + if decision.StatusChanged { + eventID := "card-network:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":changed" + if err := s.eventWriter.AppendNetwork(ctx, tx, NetworkChangedEvent{ + EventID: eventID, CardID: card.ID, BeforeStatus: card.NetworkStatus, AfterStatus: decision.AfterStatus, + GatewayExtend: decision.GatewayExtend, ObservedAt: observation.Metadata.ObservedAt, + Source: observation.Metadata.Source, Scene: observation.Metadata.Scene, + RequestID: observation.Metadata.RequestID, CorrelationID: observation.Metadata.CorrelationID, + }); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入卡网络 Outbox 事件失败") + } } - eventID := "card-network:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":changed" - if err := s.eventWriter.AppendNetwork(ctx, tx, NetworkChangedEvent{ - EventID: eventID, CardID: card.ID, BeforeStatus: card.NetworkStatus, AfterStatus: decision.AfterStatus, - GatewayExtend: decision.GatewayExtend, ObservedAt: observation.Metadata.ObservedAt, - Source: observation.Metadata.Source, Scene: observation.Metadata.Scene, - RequestID: observation.Metadata.RequestID, CorrelationID: observation.Metadata.CorrelationID, - }); err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "写入卡网络 Outbox 事件失败") + stateChanged := decision.StatusChanged || decision.StopReasonChanged || decision.StopPolling || + decision.GatewayExtend != card.GatewayExtend || decision.UpdateIMEI && decision.GatewayIMEI != card.GatewayCardIMEI + if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && stateChanged && audited { + if s.auditWriter == nil { + return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置") + } + enablePolling := card.EnablePolling + if decision.StopPolling { + enablePolling = false + } + gatewayIMEI := card.GatewayCardIMEI + if decision.UpdateIMEI { + gatewayIMEI = decision.GatewayIMEI + } + if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{ + ActionCode: actionCode, + Summary: "人工刷新 IoT 卡网络状态", + Card: &card, + BeforeData: map[string]any{ + "network_status": card.NetworkStatus, "stop_reason": card.StopReason, + "gateway_extend": card.GatewayExtend, "gateway_card_imei": card.GatewayCardIMEI, + "enable_polling": card.EnablePolling, + }, + AfterData: map[string]any{ + "network_status": decision.AfterStatus, "stop_reason": decision.StopReason, + "gateway_extend": decision.GatewayExtend, "gateway_card_imei": gatewayIMEI, + "enable_polling": enablePolling, + }, + }); err != nil { + return err + } } return nil }) diff --git a/internal/application/cardobservation/traffic.go b/internal/application/cardobservation/traffic.go index 01603b5..759eb12 100644 --- a/internal/application/cardobservation/traffic.go +++ b/internal/application/cardobservation/traffic.go @@ -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" + if err := s.eventWriter.AppendTraffic(ctx, tx, TrafficIncrementedEvent{ + EventID: eventID, CardID: card.ID, IncrementMB: decision.IncrementMB, + ObservedAt: observation.Metadata.ObservedAt, Source: observation.Metadata.Source, + Scene: observation.Metadata.Scene, RequestID: observation.Metadata.RequestID, + CorrelationID: observation.Metadata.CorrelationID, + }); err != nil { + return err + } } - eventID := "card-traffic:" + strconv.FormatUint(uint64(card.ID), 10) + ":" + observation.Metadata.ObservationID + ":incremented" - return s.eventWriter.AppendTraffic(ctx, tx, TrafficIncrementedEvent{ - EventID: eventID, CardID: card.ID, IncrementMB: decision.IncrementMB, - ObservedAt: observation.Metadata.ObservedAt, Source: observation.Metadata.Source, - Scene: observation.Metadata.Scene, RequestID: observation.Metadata.RequestID, - CorrelationID: observation.Metadata.CorrelationID, - }) + stateChanged := decision.IncrementMB != 0 || decision.CrossMonth || decision.LastGatewayReadingMB != card.LastGatewayReadingMB + if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && stateChanged && audited { + if s.auditWriter == nil { + return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置") + } + if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{ + ActionCode: actionCode, + Summary: "人工刷新 IoT 卡流量", + Card: &card, + BeforeData: map[string]any{ + "data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB, + "current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB, + "last_gateway_reading_mb": card.LastGatewayReadingMB, + }, + AfterData: map[string]any{ + "data_usage_mb": decision.DataUsageMB, "current_month_usage_mb": decision.CurrentMonthUsageMB, + "current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB, + "last_gateway_reading_mb": decision.LastGatewayReadingMB, "increment_mb": decision.IncrementMB, + }, + }); err != nil { + return err + } + } + return nil }) if err != nil { return domain.TrafficDecision{}, err diff --git a/internal/application/notification/delivery.go b/internal/application/notification/delivery.go index 9b2d346..29bbaed 100644 --- a/internal/application/notification/delivery.go +++ b/internal/application/notification/delivery.go @@ -8,7 +8,9 @@ import ( "github.com/bytedance/sonic" "go.uber.org/zap" + "gorm.io/gorm" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" notificationinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification" "github.com/break/junhong_cmp_fiber/internal/model" @@ -53,19 +55,24 @@ type deliveryRequest struct { // DeliveryService 校验接收人并幂等生成站内通知。 type DeliveryService struct { - repository *notificationinfra.Repository - registry *notificationinfra.Registry - resolver DynamicRecipientResolver - logger *zap.Logger - now func() time.Time + repository *notificationinfra.Repository + registry *notificationinfra.Registry + resolver DynamicRecipientResolver + logger *zap.Logger + auditWriter *audit.Writer + now func() time.Time } // NewDeliveryService 创建站内通知投递用例。 -func NewDeliveryService(repository *notificationinfra.Repository, registry *notificationinfra.Registry, resolver DynamicRecipientResolver, logger *zap.Logger) *DeliveryService { +func NewDeliveryService(repository *notificationinfra.Repository, registry *notificationinfra.Registry, resolver DynamicRecipientResolver, logger *zap.Logger, auditWriters ...*audit.Writer) *DeliveryService { if logger == nil { logger = zap.NewNop() } - return &DeliveryService{repository: repository, registry: registry, resolver: resolver, logger: logger, now: time.Now} + service := &DeliveryService{repository: repository, registry: registry, resolver: resolver, logger: logger, now: time.Now} + if len(auditWriters) > 0 { + service.auditWriter = auditWriters[0] + } + return service } // Consume 消费明确或动态接收人通知事件;所有可恢复错误交给 Asynq 重试策略处理。 @@ -150,6 +157,9 @@ func validateDeliveryRequest(request deliveryRequest) error { } func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind string, recipientIDs []uint, request deliveryRequest) error { + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置") + } rendered, err := s.registry.Render(request.notificationType, request.templateData, request.refType, recipientKind) if err != nil { s.logger.Error("站内通知模板校验失败", @@ -182,7 +192,23 @@ func (s *DeliveryService) deliver(ctx context.Context, eventID, recipientKind st RefType: request.refType, RefID: request.refID, RefKey: request.refKey, ExpiresAt: expiresAt, CreatedAt: now, } - created, err := s.repository.CreateIdempotent(ctx, notification) + created := false + err = s.repository.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var createErr error + created, createErr = s.repository.WithTx(tx).CreateIdempotent(ctx, notification) + if createErr != nil || !created { + return createErr + } + return s.auditWriter.Append(ctx, tx, audit.AppendInput{ + EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "delivered"), + ActionCode: constants.AuditActionNotificationDelivered, Summary: "生成站内通知", + ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, + Metadata: map[string]any{"outbox_event_id": eventID}, + Resources: []audit.ResourceInput{audit.NotificationResource(notification, + constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget, + nil, map[string]any{"created": true, "is_read": false})}, + }) + }) if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "写入站内通知失败") } diff --git a/internal/application/notification/read.go b/internal/application/notification/read.go index 9c50ddb..7f1dee8 100644 --- a/internal/application/notification/read.go +++ b/internal/application/notification/read.go @@ -2,10 +2,15 @@ package notification import ( "context" + stderrors "errors" + "strconv" "time" + "github.com/google/uuid" "gorm.io/gorm" + "gorm.io/gorm/clause" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/pkg/constants" @@ -14,29 +19,24 @@ import ( // ReadService 执行后台账号与个人客户的幂等已读事务脚本。 type ReadService struct { - db *gorm.DB - now func() time.Time + db *gorm.DB + auditWriter *audit.Writer + now func() time.Time } // NewReadService 创建单条已读用例。 -func NewReadService(db *gorm.DB) *ReadService { - return &ReadService{db: db, now: time.Now} +func NewReadService(db *gorm.DB, auditWriters ...*audit.Writer) *ReadService { + service := &ReadService{db: db, now: time.Now} + if len(auditWriters) > 0 { + service.auditWriter = auditWriters[0] + } + return service } // MarkRead 仅首次更新当前接收人的未过期未读通知。 func (s *ReadService) MarkRead(ctx context.Context, recipientID, notificationID uint) error { - if recipientID == 0 || notificationID == 0 { - return errors.New(errors.CodeInvalidParam) - } - now := s.now().UTC() - result := s.db.WithContext(ctx).Model(&model.Notification{}). - Where("id = ? AND recipient_kind = ? AND recipient_id = ? AND is_read = ? AND (expires_at IS NULL OR expires_at > ?)", - notificationID, constants.NotificationRecipientKindAccount, recipientID, false, now). - Updates(map[string]any{"is_read": true, "read_at": now}) - if result.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新通知已读状态失败") - } - return nil + return s.markOneRead(ctx, constants.NotificationRecipientKindAccount, recipientID, notificationID, + constants.AuditActorAccount, constants.AuditSourceAdminAPI) } // MarkAllRead 将当前后台账号全部或指定类别的未过期通知幂等标记为已读。 @@ -44,18 +44,12 @@ func (s *ReadService) MarkAllRead(ctx context.Context, recipientID uint, request if recipientID == 0 || !isReadAllCategory(request.Category) { return nil, errors.New(errors.CodeInvalidParam) } - now := s.now().UTC() - db := s.db.WithContext(ctx).Model(&model.Notification{}). - Where("recipient_kind = ? AND recipient_id = ? AND is_read = ? AND (expires_at IS NULL OR expires_at > ?)", - constants.NotificationRecipientKindAccount, recipientID, false, now) - if request.Category != "" { - db = db.Where("category = ?", request.Category) + count, err := s.markAllRead(ctx, constants.NotificationRecipientKindAccount, recipientID, request.Category, + constants.AuditActorAccount, constants.AuditSourceAdminAPI) + if err != nil { + return nil, err } - result := db.Updates(map[string]any{"is_read": true, "read_at": now}) - if result.Error != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新通知已读状态失败") - } - return &dto.NotificationReadAllResponse{UpdatedCount: result.RowsAffected}, nil + return &dto.NotificationReadAllResponse{UpdatedCount: count}, nil } func isReadAllCategory(category string) bool { @@ -70,17 +64,8 @@ func isReadAllCategory(category string) bool { // MarkPersonalRead 仅首次更新当前个人客户可见的未过期未读通知。 func (s *ReadService) MarkPersonalRead(ctx context.Context, customerID, notificationID uint) error { - if customerID == 0 || notificationID == 0 { - return errors.New(errors.CodeInvalidParam) - } - now := s.now().UTC() - result := personalReadScope(s.db.WithContext(ctx).Model(&model.Notification{}), customerID, now). - Where("id = ? AND is_read = ?", notificationID, false). - Updates(map[string]any{"is_read": true, "read_at": now}) - if result.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新个人客户通知已读状态失败") - } - return nil + return s.markOneRead(ctx, constants.NotificationRecipientKindPersonalCustomer, customerID, notificationID, + constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI) } // MarkAllPersonalRead 将当前个人客户可见的全部未过期通知幂等标记为已读。 @@ -88,14 +73,139 @@ func (s *ReadService) MarkAllPersonalRead(ctx context.Context, customerID uint) if customerID == 0 { return nil, errors.New(errors.CodeInvalidParam) } - now := s.now().UTC() - result := personalReadScope(s.db.WithContext(ctx).Model(&model.Notification{}), customerID, now). - Where("is_read = ?", false). - Updates(map[string]any{"is_read": true, "read_at": now}) - if result.Error != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新个人客户通知已读状态失败") + count, err := s.markAllRead(ctx, constants.NotificationRecipientKindPersonalCustomer, customerID, "", + constants.AuditActorPersonalCustomer, constants.AuditSourcePersonalAPI) + if err != nil { + return nil, err } - return &dto.NotificationReadAllResponse{UpdatedCount: result.RowsAffected}, nil + return &dto.NotificationReadAllResponse{UpdatedCount: count}, nil +} + +func (s *ReadService) markOneRead(ctx context.Context, recipientKind string, recipientID, notificationID uint, actorKind, source string) error { + if recipientID == 0 || notificationID == 0 { + return errors.New(errors.CodeInvalidParam) + } + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "通知统一审计接缝未配置") + } + now := s.now().UTC() + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var notification model.Notification + query := readScope(tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}), recipientKind, recipientID, now). + Where("id = ? AND is_read = ?", notificationID, false).Take(¬ification) + 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, ¬ification, 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(¬ifications).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询批量通知已读状态失败") + } + if len(notifications) == 0 { + return nil + } + ids := make([]uint, 0, len(notifications)) + for _, notification := range notifications { + ids = append(ids, notification.ID) + } + result := tx.WithContext(ctx).Model(&model.Notification{}).Where("id IN ? AND is_read = ?", ids, false). + Updates(map[string]any{"is_read": true, "read_at": now}) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新通知已读状态失败") + } + if result.RowsAffected != int64(len(notifications)) { + return errors.New(errors.CodeInvalidStatus, "通知已读状态发生并发变化") + } + updated = result.RowsAffected + return s.appendReadAllAudit(ctx, tx, notifications, now, recipientKind, recipientID, category, actorKind, source) + }) + return updated, err +} + +func (s *ReadService) appendReadAudit(ctx context.Context, tx *gorm.DB, notification *model.Notification, now time.Time, actorKind, source, parentEventID string) error { + scopeType, scopeID := notificationScope(notification.RecipientKind, notification.RecipientID) + return s.auditWriter.Append(ctx, tx, audit.AppendInput{ + EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "read"), + ActionCode: constants.AuditActionNotificationRead, Summary: "标记通知已读", + Actor: audit.ActorInput{Kind: actorKind, ID: strconv.FormatUint(uint64(notification.RecipientID), 10)}, + Source: source, ScopeType: scopeType, ScopeID: scopeID, + Result: constants.AuditResultSuccess, ParentEventID: parentEventID, + Resources: []audit.ResourceInput{audit.NotificationResource(notification, + constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget, + map[string]any{"is_read": false}, map[string]any{"is_read": true, "read_at": now})}, + }) +} + +func (s *ReadService) appendReadAllAudit(ctx context.Context, tx *gorm.DB, notifications []*model.Notification, now time.Time, recipientKind string, recipientID uint, category, actorKind, source string) error { + rootID := "evt_" + uuid.NewString() + actor := audit.ActorInput{Kind: actorKind, ID: strconv.FormatUint(uint64(recipientID), 10)} + scopeType, scopeID := notificationScope(recipientKind, recipientID) + children := make([]audit.AppendInput, 0, len(notifications)) + for _, notification := range notifications { + children = append(children, audit.AppendInput{ + EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "read"), + ActionCode: constants.AuditActionNotificationRead, Summary: "批量标记通知已读", + Actor: actor, Source: source, ScopeType: scopeType, ScopeID: scopeID, Result: constants.AuditResultSuccess, + Resources: []audit.ResourceInput{audit.NotificationResource(notification, + constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget, + map[string]any{"is_read": false}, map[string]any{"is_read": true, "read_at": now})}, + }) + } + return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{ + Root: audit.AppendInput{ + EventID: rootID, ActionCode: constants.AuditActionNotificationReadAll, Summary: "批量标记通知已读", + Actor: actor, Source: source, ScopeType: scopeType, ScopeID: scopeID, Result: constants.AuditResultSuccess, + BatchTotal: len(notifications), SuccessCount: len(notifications), + Resources: []audit.ResourceInput{{ + Type: constants.AuditResourceNotificationReadBatch, Key: rootID, DisplayName: "通知批量已读", + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchTask, + IdentitySnapshot: map[string]any{ + "recipient_kind": recipientKind, "recipient_id": recipientID, + "category": category, "updated_count": len(notifications), + }, SubjectVisibility: constants.AuditSubjectInternalOnly, + }}, + }, + Children: children, + }) +} + +func notificationScope(recipientKind string, recipientID uint) (string, string) { + if recipientKind == constants.NotificationRecipientKindPersonalCustomer { + return constants.AuditScopePersonalCustomer, strconv.FormatUint(uint64(recipientID), 10) + } + return constants.AuditScopePlatform, "" +} + +func readScope(db *gorm.DB, recipientKind string, recipientID uint, now time.Time) *gorm.DB { + if recipientKind == constants.NotificationRecipientKindPersonalCustomer { + return personalReadScope(db, recipientID, now) + } + return db.Model(&model.Notification{}).Where( + "recipient_kind = ? AND recipient_id = ? AND (expires_at IS NULL OR expires_at > ?)", + recipientKind, recipientID, now, + ) } func personalReadScope(db *gorm.DB, customerID uint, now time.Time) *gorm.DB { diff --git a/internal/application/refundapproval/creation.go b/internal/application/refundapproval/creation.go index a77155e..af6c774 100644 --- a/internal/application/refundapproval/creation.go +++ b/internal/application/refundapproval/creation.go @@ -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 diff --git a/internal/application/systemconfig/update.go b/internal/application/systemconfig/update.go index 81336ce..a19124b 100644 --- a/internal/application/systemconfig/update.go +++ b/internal/application/systemconfig/update.go @@ -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 diff --git a/internal/application/wallet/change_credit.go b/internal/application/wallet/change_credit.go index 719be60..4542fd1 100644 --- a/internal/application/wallet/change_credit.go +++ b/internal/application/wallet/change_credit.go @@ -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 + 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 + var stored model.AgentWallet + var beforeData map[string]any err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var stored model.AgentWallet if err := tx.Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).First(&stored).Error; err != nil { return errors.New(errors.CodeWalletNotFound, "店铺主钱包不存在") } + beforeData = creditAuditData(&stored) aggregate := domainwallet.AgentWallet{ID: stored.ID, ShopID: stored.ShopID, WalletType: stored.WalletType, Balance: stored.Balance, FrozenBalance: stored.FrozenBalance, CreditEnabled: stored.CreditEnabled, CreditLimit: stored.CreditLimit, Status: stored.Status, Version: stored.Version} if err := aggregate.ChangeCredit(enabled, limit); err != nil { return err @@ -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 +} diff --git a/internal/application/wallet/posting.go b/internal/application/wallet/posting.go index 8134db8..e2f2fdb 100644 --- a/internal/application/wallet/posting.go +++ b/internal/application/wallet/posting.go @@ -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 } diff --git a/internal/application/wecom/connection.go b/internal/application/wecom/connection.go index ce64824..bfc85f5 100644 --- a/internal/application/wecom/connection.go +++ b/internal/application/wecom/connection.go @@ -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 { diff --git a/internal/application/wecom/directory.go b/internal/application/wecom/directory.go index a0023aa..684731c 100644 --- a/internal/application/wecom/directory.go +++ b/internal/application/wecom/directory.go @@ -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 { diff --git a/internal/application/wecom/scene.go b/internal/application/wecom/scene.go index 4e71356..eb9ed91 100644 --- a/internal/application/wecom/scene.go +++ b/internal/application/wecom/scene.go @@ -5,6 +5,7 @@ import ( "crypto/sha256" "encoding/hex" stdErrors "errors" + "fmt" "strings" "time" @@ -108,7 +109,7 @@ func (s *SceneService) ListBusinessFields(ctx context.Context, businessType stri // Save 校验模板控件后创建或替换指定稳定业务场景映射。 func (s *SceneService) Save(ctx context.Context, businessType string, request dto.SaveWeComApprovalSceneRequest) (*dto.WeComApprovalSceneResponse, error) { - if s == nil || s.db == nil || s.provider == nil || s.repo == nil { + if s == nil || s.db == nil || s.provider == nil || s.repo == nil || s.audit == nil { return nil, errors.New(errors.CodeServiceUnavailable, "企业微信审批场景服务未配置") } if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin { @@ -124,9 +125,11 @@ func (s *SceneService) Save(ctx context.Context, businessType string, request dt } definition, err := s.provider.GetTemplateDetail(ctx, request.ApplicationID, strings.TrimSpace(request.TemplateID)) if err != nil { + s.recordFailure(ctx, businessType, request, constants.AuditResultFailed, errors.CodeInternalError, "校验企业微信审批模板失败") return nil, err } if err := validateSceneMapping(businessType, request.ControlMapping, definition.Controls); err != nil { + s.recordFailure(ctx, businessType, request, constants.AuditResultDenied, errors.CodeInvalidParam, "拒绝保存非法企业微信审批场景映射") return nil, err } request.ControlMapping = normalizeSceneMapping(request.ControlMapping) @@ -177,23 +180,27 @@ func (s *SceneService) Save(ctx context.Context, businessType string, request dt } else if err := s.repo.Update(ctx, tx, existing); err != nil { return err } - if s.audit != nil { - requestID := "" - if value := middleware.GetRequestIDFromContext(ctx); value != nil { - requestID = *value - } - if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{ - OperatorID: operatorID, OperationType: "wecom_approval_scene_save", Description: "保存企业微信审批模板映射", - ConfigKey: "wecom.approval_scene." + businessType, BeforeData: before, - AfterData: sceneAuditSnapshot(existing), RequestID: requestID, CorrelationID: requestID, - }); err != nil { - return err - } + requestID := "" + if value := middleware.GetRequestIDFromContext(ctx); value != nil { + requestID = *value + } + resourceID := strings.TrimSpace(existing.BusinessType) + if existing.ID != 0 { + resourceID = fmt.Sprintf("%d", existing.ID) + } + if err := s.audit.WriteConfigChange(ctx, tx, systemconfigapp.ChangeAudit{ + OperatorID: operatorID, OperationType: constants.AuditOperationWeComApprovalSceneSave, Description: "保存企业微信审批模板映射", + ConfigKey: "wecom.approval_scene." + businessType, BeforeData: before, + AfterData: sceneAuditSnapshot(existing), RequestID: requestID, CorrelationID: requestID, + ResourceID: &resourceID, DisplayName: existing.TemplateName, Identity: sceneAuditIdentity(existing), + }); err != nil { + return err } saved = existing return nil }) if err != nil { + s.recordFailure(ctx, businessType, request, constants.AuditResultFailed, errors.CodeDatabaseError, "保存企业微信审批场景失败") var appErr *errors.AppError if stdErrors.As(err, &appErr) { return nil, appErr @@ -364,6 +371,28 @@ func sceneAuditSnapshot(scene *model.WeComApprovalScene) map[string]any { } } +func sceneAuditIdentity(scene *model.WeComApprovalScene) map[string]any { + if scene == nil { + return nil + } + return map[string]any{ + "id": scene.ID, "business_type": scene.BusinessType, "application_id": scene.ApplicationID, + "template_id": scene.TemplateID, "template_name": scene.TemplateName, "status": scene.Status, + } +} + +func (s *SceneService) recordFailure(ctx context.Context, businessType string, request dto.SaveWeComApprovalSceneRequest, result string, code int, description string) { + recordConfigFailure(ctx, s.db, s.audit, systemconfigapp.ChangeAudit{ + OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: constants.AuditOperationWeComApprovalSceneSave, + Description: description, ConfigKey: "wecom.approval_scene." + businessType, + DisplayName: businessType, Identity: map[string]any{ + "business_type": businessType, "application_id": request.ApplicationID, + "template_id": strings.TrimSpace(request.TemplateID), "status": request.Status, + }, + Result: result, ErrorCode: fmt.Sprintf("%d", code), ErrorSummary: description, + }) +} + func sceneResponse(scene model.WeComApprovalScene) (*dto.WeComApprovalSceneResponse, error) { var mapping []dto.WeComControlMappingItem if err := sonic.Unmarshal(scene.ControlMapping, &mapping); err != nil { diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 731486d..5372e5a 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -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,20 +217,21 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { IotCard: admin.NewIotCardHandler(svc.IotCard), IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport), ExportTask: admin.NewExportTaskHandler(svc.ExportTask), - Notification: admin.NewNotificationHandler(notificationQuery.NewQuery(deps.DB), notificationApp.NewReadService(deps.DB), validate), - Device: admin.NewDeviceHandler(svc.Device), - DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport), - AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord), - Storage: admin.NewStorageHandler(deps.StorageService), - Carrier: admin.NewCarrierHandler(svc.Carrier), - PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries), - Package: admin.NewPackageHandler(svc.Package), - PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord), - ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation), - ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing), - ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant), - AdminOrder: admin.NewOrderHandler(svc.Order, validate), - AdminExchange: admin.NewExchangeHandler(svc.Exchange, exchangeQuery.NewListQuery(deps.DB), validate), + Notification: admin.NewNotificationHandler(notificationQuery.NewQuery(deps.DB), + notificationApp.NewReadService(deps.DB, auditInfra.NewWriter(auditInfra.NewRegistry(), nil)), validate), + Device: admin.NewDeviceHandler(svc.Device), + DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport), + AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord), + Storage: admin.NewStorageHandler(deps.StorageService), + Carrier: admin.NewCarrierHandler(svc.Carrier), + PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries), + Package: admin.NewPackageHandler(svc.Package), + PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord), + ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation), + ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing), + ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant), + AdminOrder: admin.NewOrderHandler(svc.Order, validate), + AdminExchange: admin.NewExchangeHandler(svc.Exchange, exchangeQuery.NewListQuery(deps.DB), validate), PaymentCallback: callback.NewPaymentHandler( svc.Order, svc.Recharge, rechargeOrderService, svc.AgentRecharge, deps.WechatPayment, svc.WechatConfig, paymentStore, diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 90316b4..807e5e4 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -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, diff --git a/internal/bootstrap/worker_services.go b/internal/bootstrap/worker_services.go index 60a0a93..77db317 100644 --- a/internal/bootstrap/worker_services.go +++ b/internal/bootstrap/worker_services.go @@ -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, diff --git a/internal/gateway/client.go b/internal/gateway/client.go index 20a67cb..7e4a39c 100644 --- a/internal/gateway/client.go +++ b/internal/gateway/client.go @@ -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 // 仅对网络级错误重试 diff --git a/internal/handler/app/client_wallet.go b/internal/handler/app/client_wallet.go index 8b625d5..7ae003f 100644 --- a/internal/handler/app/client_wallet.go +++ b/internal/handler/app/client_wallet.go @@ -8,6 +8,8 @@ import ( "strings" "time" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/middleware" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" @@ -46,6 +48,8 @@ type ClientWalletHandler struct { iotCardStore *postgres.IotCardStore deviceStore *postgres.DeviceStore paymentMethodPolicy ClientPaymentMethodPolicy + auditWriter *audit.Writer + paymentIntegration *integrationlog.Repository } // SetPaymentMethodPolicy 注入 C 端支付方式策略。 @@ -53,6 +57,12 @@ func (h *ClientWalletHandler) SetPaymentMethodPolicy(policy ClientPaymentMethodP h.paymentMethodPolicy = policy } +// SetPaymentAudit 注入充值支付审计与外部交互日志接缝。 +func (h *ClientWalletHandler) SetPaymentAudit(writer *audit.Writer, integration *integrationlog.Repository) { + h.auditWriter = writer + h.paymentIntegration = integration +} + // NewClientWalletHandler 创建 C 端钱包处理器 func NewClientWalletHandler( assetService *asset.Service, @@ -352,6 +362,10 @@ func (h *ClientWalletHandler) createWechatRecharge( // 先初始化生效支付通道并创建预支付订单,确认支付通道可用 // 避免先写入充值记录后支付初始化失败,导致产生孤儿记录 + attempt, startedAt, err := h.startRechargePaymentAttempt(resolved.SkipPermissionCtx, config, paymentNo, rechargeNo, req.Amount) + if err != nil { + return err + } payConfig, err := h.createClientRechargePayConfig( resolved.SkipPermissionCtx, config, @@ -363,6 +377,12 @@ func (h *ClientWalletHandler) createWechatRecharge( int(req.Amount), ) if err != nil { + if completeErr := h.completeRechargePaymentAttempt(resolved.SkipPermissionCtx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "充值支付预下单结果未知"); completeErr != nil { + return completeErr + } + return err + } + if err := h.completeRechargePaymentAttempt(resolved.SkipPermissionCtx, attempt, startedAt, constants.IntegrationResultSuccess, "SUCCESS", ""); err != nil { return err } @@ -381,10 +401,6 @@ func (h *ClientWalletHandler) createWechatRecharge( OperatorType: constants.OperatorTypePersonalCustomer, Generation: resolved.Generation, } - if err := h.rechargeOrderStore.Create(resolved.SkipPermissionCtx, rechargeOrder); err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败") - } - payment := &model.Payment{ PaymentNo: paymentNo, OrderID: rechargeOrder.ID, @@ -394,8 +410,17 @@ func (h *ClientWalletHandler) createWechatRecharge( Status: model.PaymentRecordStatusPending, PaymentConfigID: &config.ID, } - if err := h.paymentStore.Create(resolved.SkipPermissionCtx, payment); err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败") + if err := h.db.WithContext(resolved.SkipPermissionCtx).Transaction(func(tx *gorm.DB) error { + if err := h.rechargeOrderStore.CreateWithTx(resolved.SkipPermissionCtx, tx, rechargeOrder); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败") + } + payment.OrderID = rechargeOrder.ID + if err := h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建支付记录失败") + } + return h.appendRechargePaymentCreatedAudit(resolved.SkipPermissionCtx, tx, payment, rechargeOrder) + }); err != nil { + return err } return response.Success(c, &dto.ClientRechargeResponse{ @@ -456,14 +481,17 @@ func (h *ClientWalletHandler) createAlipayRecharge( return errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败") } payment.OrderID = rechargeOrder.ID - return h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment) + if err := h.paymentStore.CreateWithTx(resolved.SkipPermissionCtx, tx, payment); err != nil { + return err + } + return h.appendRechargePaymentCreatedAudit(resolved.SkipPermissionCtx, tx, payment, rechargeOrder) }); err != nil { return err } wapURL, err := alipay.BuildWapPayURL(resolved.SkipPermissionCtx, config, payment, "资产钱包充值") if err != nil { - if updateErr := h.paymentStore.UpdateStatus(resolved.SkipPermissionCtx, payment.ID, model.PaymentRecordStatusFailed); updateErr != nil { + if updateErr := h.markRechargePaymentFailed(resolved.SkipPermissionCtx, payment); updateErr != nil { h.logger.Warn("标记支付宝支付单 failed 失败", zap.String("payment_no", paymentNo), zap.Error(updateErr), diff --git a/internal/handler/app/client_wallet_payment_audit.go b/internal/handler/app/client_wallet_payment_audit.go new file mode 100644 index 0000000..d72cb70 --- /dev/null +++ b/internal/handler/app/client_wallet_payment_audit.go @@ -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})}, + }) + }) +} diff --git a/internal/handler/callback/carrier_switch.go b/internal/handler/callback/carrier_switch.go index c240dc1..75f2980 100644 --- a/internal/handler/callback/carrier_switch.go +++ b/internal/handler/callback/carrier_switch.go @@ -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) diff --git a/internal/handler/callback/cmcc_realname.go b/internal/handler/callback/cmcc_realname.go index b4a2d0f..59dcc31 100644 --- a/internal/handler/callback/cmcc_realname.go +++ b/internal/handler/callback/cmcc_realname.go @@ -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 { diff --git a/internal/handler/callback/ctcc_realname.go b/internal/handler/callback/ctcc_realname.go index 9244a0e..7e6eb36 100644 --- a/internal/handler/callback/ctcc_realname.go +++ b/internal/handler/callback/ctcc_realname.go @@ -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 { diff --git a/internal/handler/callback/cucc_realname.go b/internal/handler/callback/cucc_realname.go index a810095..01b4922 100644 --- a/internal/handler/callback/cucc_realname.go +++ b/internal/handler/callback/cucc_realname.go @@ -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 { diff --git a/internal/handler/callback/payment.go b/internal/handler/callback/payment.go index dad7c72..6d72498 100644 --- a/internal/handler/callback/payment.go +++ b/internal/handler/callback/payment.go @@ -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, "处理支付宝代理充值回调失败") - } - - default: - h.logger.Error("支付宝回调:未知支付记录类型", - zap.String("out_trade_no", outTradeNo), - zap.String("order_type", payment.OrderType), - ) - return errors.New(errors.CodeInternalError, "未知支付记录类型") + callback.PaidAt = paidAt + } + if handled, dispatchErr := h.dispatchPaymentRecordCallback(ctx, callback); dispatchErr != nil { + h.logger.Error("支付宝回调:确认支付失败", zap.String("out_trade_no", outTradeNo), zap.Error(dispatchErr)) + return errors.Wrap(errors.CodeInternalError, dispatchErr, "处理支付宝支付回调失败") + } else if !handled { + return errors.New(errors.CodeInternalError, "支付记录分发失败") } return c.SendString("success") @@ -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,27 +657,11 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error { return c.Send(fuiou.BuildNotifySuccessResponse()) } - switch { - case strings.HasPrefix(orderNo, "ORD"): - if err := h.orderService.HandlePaymentCallback(ctx, orderNo, "fuiou", orderAmt); err != nil { - return c.Send(fuiou.BuildNotifyFailResponse(err.Error())) - } - case strings.HasPrefix(orderNo, constants.AssetRechargeOrderPrefix): - if h.rechargeOrderService != nil { - if err := h.rechargeOrderService.HandlePaymentCallback(ctx, orderNo, "fuiou", notify.TransactionId); err != nil { - return c.Send(fuiou.BuildNotifyFailResponse(err.Error())) - } - return c.Send(fuiou.BuildNotifySuccessResponse()) - } - return c.Send(fuiou.BuildNotifyFailResponse("充值订单服务未配置")) - case strings.HasPrefix(orderNo, constants.AgentRechargeOrderPrefix): - if h.agentRechargeService != nil { - if err := h.agentRechargeService.HandlePaymentCallback(ctx, orderNo, model.ProviderTypeFuiou, notify.TransactionId, orderAmt); err != nil { - return c.Send(fuiou.BuildNotifyFailResponse(err.Error())) - } - } - default: - return c.Send(fuiou.BuildNotifyFailResponse("unknown order prefix")) + if err := h.dispatchLegacyPaymentCallback(ctx, verifiedPaymentCallback{ + PaymentNo: orderNo, PaymentMethod: model.ProviderTypeFuiou, TransactionID: notify.TransactionId, Amount: orderAmt, + Provider: constants.IntegrationProviderFuiou, RawPayload: body, ContentType: c.Get("Content-Type"), + }); err != nil { + return c.Send(fuiou.BuildNotifyFailResponse(err.Error())) } return c.Send(fuiou.BuildNotifySuccessResponse()) diff --git a/internal/infrastructure/audit/approval.go b/internal/infrastructure/audit/approval.go new file mode 100644 index 0000000..0dcda57 --- /dev/null +++ b/internal/infrastructure/audit/approval.go @@ -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 +} diff --git a/internal/infrastructure/audit/commission.go b/internal/infrastructure/audit/commission.go new file mode 100644 index 0000000..bed59df --- /dev/null +++ b/internal/infrastructure/audit/commission.go @@ -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 +} diff --git a/internal/infrastructure/audit/failure.go b/internal/infrastructure/audit/failure.go new file mode 100644 index 0000000..b1c6625 --- /dev/null +++ b/internal/infrastructure/audit/failure.go @@ -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, + ) +} diff --git a/internal/infrastructure/audit/notification.go b/internal/infrastructure/audit/notification.go new file mode 100644 index 0000000..b379003 --- /dev/null +++ b/internal/infrastructure/audit/notification.go @@ -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, + } +} diff --git a/internal/infrastructure/audit/package.go b/internal/infrastructure/audit/package.go new file mode 100644 index 0000000..b636918 --- /dev/null +++ b/internal/infrastructure/audit/package.go @@ -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, + }, + } +} diff --git a/internal/infrastructure/audit/payment.go b/internal/infrastructure/audit/payment.go new file mode 100644 index 0000000..41c43e8 --- /dev/null +++ b/internal/infrastructure/audit/payment.go @@ -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, + }) +} diff --git a/internal/infrastructure/audit/polling.go b/internal/infrastructure/audit/polling.go new file mode 100644 index 0000000..c43e4f9 --- /dev/null +++ b/internal/infrastructure/audit/polling.go @@ -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, + }) +} diff --git a/internal/infrastructure/audit/recharge.go b/internal/infrastructure/audit/recharge.go new file mode 100644 index 0000000..8f4d23d --- /dev/null +++ b/internal/infrastructure/audit/recharge.go @@ -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 +} diff --git a/internal/infrastructure/audit/refund.go b/internal/infrastructure/audit/refund.go new file mode 100644 index 0000000..957791e --- /dev/null +++ b/internal/infrastructure/audit/refund.go @@ -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, + } +} diff --git a/internal/infrastructure/audit/registry.go b/internal/infrastructure/audit/registry.go index 7b31042..9705b34 100644 --- a/internal/infrastructure/audit/registry.go +++ b/internal/infrastructure/audit/registry.go @@ -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 { diff --git a/internal/infrastructure/audit/task.go b/internal/infrastructure/audit/task.go new file mode 100644 index 0000000..201e996 --- /dev/null +++ b/internal/infrastructure/audit/task.go @@ -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]) +} diff --git a/internal/infrastructure/audit/wallet.go b/internal/infrastructure/audit/wallet.go new file mode 100644 index 0000000..54bcc17 --- /dev/null +++ b/internal/infrastructure/audit/wallet.go @@ -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 +} diff --git a/internal/infrastructure/audit/writer.go b/internal/infrastructure/audit/writer.go index b78bd34..83273b7 100644 --- a/internal/infrastructure/audit/writer.go +++ b/internal/infrastructure/audit/writer.go @@ -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 == "" { diff --git a/internal/infrastructure/cardobservation/event.go b/internal/infrastructure/cardobservation/event.go index 58839dd..c4b3ed3 100644 --- a/internal/infrastructure/cardobservation/event.go +++ b/internal/infrastructure/cardobservation/event.go @@ -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, "卡流量事件类型或版本不受支持") } diff --git a/internal/infrastructure/notification/cleanup.go b/internal/infrastructure/notification/cleanup.go index efe8543..c21e584 100644 --- a/internal/infrastructure/notification/cleanup.go +++ b/internal/infrastructure/notification/cleanup.go @@ -2,28 +2,39 @@ package notification import ( "context" + "fmt" + "strconv" "time" + "github.com/google/uuid" "go.uber.org/zap" "gorm.io/gorm" + "gorm.io/gorm/clause" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" + "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // CleanupService 按通知类别的保留期限分批删除过期通知事实。 type CleanupService struct { - db *gorm.DB - logger *zap.Logger - now func() time.Time + db *gorm.DB + logger *zap.Logger + auditWriter *audit.Writer + now func() time.Time } // NewCleanupService 创建通知保留清理服务。 -func NewCleanupService(db *gorm.DB, logger *zap.Logger) *CleanupService { +func NewCleanupService(db *gorm.DB, logger *zap.Logger, auditWriters ...*audit.Writer) *CleanupService { if logger == nil { logger = zap.NewNop() } - return &CleanupService{db: db, logger: logger, now: time.Now} + service := &CleanupService{db: db, logger: logger, now: time.Now} + if len(auditWriters) > 0 { + service.auditWriter = auditWriters[0] + } + return service } // Run 按类别、创建时间和稳定主键执行有界分批清理。 @@ -49,24 +60,74 @@ func (s *CleanupService) Run(ctx context.Context) error { } func (s *CleanupService) cleanupCategory(ctx context.Context, category string, cutoff time.Time) (int64, error) { + if s.auditWriter == nil { + return 0, errors.New(errors.CodeInvalidStatus, "通知清理统一审计接缝未配置") + } var total int64 for batch := 0; batch < constants.NotificationCleanupMaxBatches; batch++ { - result := s.db.WithContext(ctx).Exec(`WITH candidates AS ( - SELECT id FROM tb_notification - WHERE category = ? AND created_at < ? - ORDER BY created_at ASC, id ASC - LIMIT ? - ) - DELETE FROM tb_notification AS notification - USING candidates - WHERE notification.id = candidates.id`, category, cutoff, constants.NotificationCleanupBatchSize) - if result.Error != nil { - return total, errors.Wrap(errors.CodeDatabaseError, result.Error, "清理站内通知失败") + var deleted []*model.Notification + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + Where("category = ? AND created_at < ?", category, cutoff). + Order("created_at ASC, id ASC").Limit(constants.NotificationCleanupBatchSize).Find(&deleted).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询待清理站内通知失败") + } + if len(deleted) == 0 { + return nil + } + ids := make([]uint, 0, len(deleted)) + for _, notification := range deleted { + ids = append(ids, notification.ID) + } + result := tx.WithContext(ctx).Where("id IN ?", ids).Delete(&model.Notification{}) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "清理站内通知失败") + } + if result.RowsAffected != int64(len(deleted)) { + return errors.New(errors.CodeInvalidStatus, "通知清理数量发生并发变化") + } + return s.appendCleanupAudit(ctx, tx, category, cutoff, deleted) + }); err != nil { + return total, err } - total += result.RowsAffected - if result.RowsAffected < constants.NotificationCleanupBatchSize { + total += int64(len(deleted)) + if len(deleted) < constants.NotificationCleanupBatchSize { break } } return total, nil } + +func (s *CleanupService) appendCleanupAudit(ctx context.Context, tx *gorm.DB, category string, cutoff time.Time, notifications []*model.Notification) error { + firstID, lastID := notifications[0].ID, notifications[len(notifications)-1].ID + key := fmt.Sprintf("%s:%s:%d:%d", category, cutoff.UTC().Format(time.RFC3339), firstID, lastID) + rootID := "evt_" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("notification-cleanup:"+key)).String() + children := make([]audit.AppendInput, 0, len(notifications)) + for _, notification := range notifications { + children = append(children, audit.AppendInput{ + EventID: audit.TaskEventID(constants.AuditResourceNotification, notification.ID, "cleanup"), + ActionCode: constants.AuditActionNotificationCleanupItem, Summary: "清理单条过期通知", + ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, + Resources: []audit.ResourceInput{audit.NotificationResource(notification, + constants.AuditResourceRelationPrimary, constants.AuditResourceRoleNotificationTarget, + map[string]any{"exists": true}, map[string]any{"deleted": true})}, + }) + } + return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{ + Root: audit.AppendInput{ + EventID: rootID, ActionCode: constants.AuditActionNotificationCleanup, Summary: "清理过期通知", + ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, + BatchTotal: len(notifications), SuccessCount: len(notifications), + Resources: []audit.ResourceInput{{ + Type: constants.AuditResourceNotificationCleanupBatch, Key: rootID, DisplayName: "通知清理批次", + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleBatchTask, + IdentitySnapshot: map[string]any{ + "category": category, "cutoff": cutoff.UTC(), "deleted_count": len(notifications), + "first_id": firstID, "last_id": lastID, + }, SubjectVisibility: constants.AuditSubjectInternalOnly, + }}, + Metadata: map[string]any{"first_id": strconv.FormatUint(uint64(firstID), 10), "last_id": strconv.FormatUint(uint64(lastID), 10)}, + }, + Children: children, + }) +} diff --git a/internal/infrastructure/notification/repository.go b/internal/infrastructure/notification/repository.go index ae33c4d..4001283 100644 --- a/internal/infrastructure/notification/repository.go +++ b/internal/infrastructure/notification/repository.go @@ -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{ diff --git a/internal/infrastructure/payment/agent_recharge_consumer.go b/internal/infrastructure/payment/agent_recharge_consumer.go index ed44199..e9c0ac6 100644 --- a/internal/infrastructure/payment/agent_recharge_consumer.go +++ b/internal/infrastructure/payment/agent_recharge_consumer.go @@ -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}, + }) }) } diff --git a/internal/infrastructure/wallet/credit_event.go b/internal/infrastructure/wallet/credit_event.go index f0847cd..ca5aed3 100644 --- a/internal/infrastructure/wallet/credit_event.go +++ b/internal/infrastructure/wallet/credit_event.go @@ -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 { diff --git a/internal/infrastructure/wallet/debit_event.go b/internal/infrastructure/wallet/debit_event.go index eedeced..4aa21a1 100644 --- a/internal/infrastructure/wallet/debit_event.go +++ b/internal/infrastructure/wallet/debit_event.go @@ -6,26 +6,28 @@ import ( "strconv" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "gorm.io/gorm" ) -// DebitEventWriter 将代理主钱包扣款事实写入公共 Outbox。 +// DebitEventWriter 将代理主钱包扣款事实写入公共 Outbox 和统一审计。 type DebitEventWriter struct { outbox *outbox.Repository + audit *audit.Writer } -// NewDebitEventWriter 创建代理主钱包扣款 Outbox Writer。 -func NewDebitEventWriter(repository *outbox.Repository) *DebitEventWriter { - return &DebitEventWriter{outbox: repository} +// NewDebitEventWriter 创建代理主钱包扣款事件 Writer。 +func NewDebitEventWriter(repository *outbox.Repository, auditWriter *audit.Writer) *DebitEventWriter { + return &DebitEventWriter{outbox: repository, audit: auditWriter} } -// Append 在调用方业务事务中追加代理主钱包扣款事件。 +// Append 在调用方业务事务中追加代理主钱包扣款 Outbox 与审计事件。 func (w *DebitEventWriter) Append(ctx context.Context, tx *gorm.DB, event walletapp.DebitedEvent) error { - if w == nil || w.outbox == nil { - return errors.New(errors.CodeInternalError, "代理主钱包扣款 Outbox Writer 未配置") + if w == nil || w.outbox == nil || w.audit == nil { + return errors.New(errors.CodeInternalError, "代理主钱包扣款事件 Writer 未配置") } _, err := w.outbox.Append(ctx, tx, outbox.Envelope{ EventID: event.EventID, EventType: constants.OutboxEventTypeAgentMainWalletDebited, @@ -34,5 +36,8 @@ func (w *DebitEventWriter) Append(ctx context.Context, tx *gorm.DB, event wallet ResourceType: event.ReferenceType, ResourceID: strconv.FormatUint(uint64(event.ReferenceID), 10), BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event, }) - return err + if err != nil { + return err + } + return w.audit.WriteAgentWalletDebit(ctx, tx, event) } diff --git a/internal/infrastructure/wallet/reservation_event.go b/internal/infrastructure/wallet/reservation_event.go index 3055dce..94a586f 100644 --- a/internal/infrastructure/wallet/reservation_event.go +++ b/internal/infrastructure/wallet/reservation_event.go @@ -5,26 +5,28 @@ import ( "strconv" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "gorm.io/gorm" ) -// ReservationEventWriter 将代理主钱包预占状态写入公共 Outbox。 +// ReservationEventWriter 将代理主钱包预占状态写入公共 Outbox 和统一审计。 type ReservationEventWriter struct { outbox *outbox.Repository + audit *audit.Writer } -// NewReservationEventWriter 创建代理主钱包预占 Outbox Writer。 -func NewReservationEventWriter(repository *outbox.Repository) *ReservationEventWriter { - return &ReservationEventWriter{outbox: repository} +// NewReservationEventWriter 创建代理主钱包预占事件 Writer。 +func NewReservationEventWriter(repository *outbox.Repository, auditWriter *audit.Writer) *ReservationEventWriter { + return &ReservationEventWriter{outbox: repository, audit: auditWriter} } -// Append 在调用方事务中追加预占状态事件。 +// Append 在调用方事务中追加预占状态 Outbox 与审计事件。 func (w *ReservationEventWriter) Append(ctx context.Context, tx *gorm.DB, event walletapp.ReservationEvent) error { - if w == nil || w.outbox == nil { - return errors.New(errors.CodeInternalError, "代理主钱包预占 Outbox Writer 未配置") + if w == nil || w.outbox == nil || w.audit == nil { + return errors.New(errors.CodeInternalError, "代理主钱包预占事件 Writer 未配置") } _, err := w.outbox.Append(ctx, tx, outbox.Envelope{ EventID: event.EventID, EventType: constants.OutboxEventTypeAgentMainWalletReservationChanged, @@ -33,5 +35,8 @@ func (w *ReservationEventWriter) Append(ctx context.Context, tx *gorm.DB, event ResourceType: event.ReferenceType, ResourceID: strconv.FormatUint(uint64(event.ReferenceID), 10), BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event, }) - return err + if err != nil { + return err + } + return w.audit.WriteAgentWalletReservation(ctx, tx, event) } diff --git a/internal/infrastructure/wecom/approval_context_repository.go b/internal/infrastructure/wecom/approval_context_repository.go index 12d1686..cc32baf 100644 --- a/internal/infrastructure/wecom/approval_context_repository.go +++ b/internal/infrastructure/wecom/approval_context_repository.go @@ -11,6 +11,7 @@ import ( "gorm.io/gorm" "gorm.io/gorm/clause" + approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" @@ -35,13 +36,18 @@ type ApprovalRecoveryRecord struct { // ApprovalContextRepository 管理企微提交的领取、终态和结果未知状态。 type ApprovalContextRepository struct { - db *gorm.DB - now func() time.Time + db *gorm.DB + audit approvalapp.AuditWriter + now func() time.Time } // NewApprovalContextRepository 创建企微审批渠道上下文 Repository。 -func NewApprovalContextRepository(db *gorm.DB) *ApprovalContextRepository { - return &ApprovalContextRepository{db: db, now: time.Now} +func NewApprovalContextRepository(db *gorm.DB, audits ...approvalapp.AuditWriter) *ApprovalContextRepository { + var audit approvalapp.AuditWriter + if len(audits) > 0 { + audit = audits[0] + } + return &ApprovalContextRepository{db: db, audit: audit, now: time.Now} } // ClaimSubmission 将待提交上下文原子置为请求处理中,阻止并发或重投重复提单。 @@ -68,7 +74,7 @@ func (r *ApprovalContextRepository) ClaimSubmission(ctx context.Context, instanc // PromoteStaleSendingToUnknown 将超出租约的提交中记录保守转为结果未知,禁止直接重新提交。 func (r *ApprovalContextRepository) PromoteStaleSendingToUnknown(ctx context.Context, cutoff time.Time) error { - if r == nil || r.db == nil || cutoff.IsZero() { + if r == nil || r.db == nil || r.audit == nil || cutoff.IsZero() { return errors.New(errors.CodeInvalidParam, "企业微信审批恢复参数无效") } now := r.now().UTC() @@ -97,13 +103,38 @@ func (r *ApprovalContextRepository) PromoteStaleSendingToUnknown(ctx context.Con if result.Error != nil { return errors.Wrap(errors.CodeDatabaseError, result.Error, "标记企业微信审批提交结果未知失败") } - if err := tx.Model(&model.ApprovalInstance{}). + instanceResult := tx.Model(&model.ApprovalInstance{}). Where("id IN ? AND status = ?", instanceIDs, constants.ApprovalStatusSubmitting). Updates(map[string]any{ "status": constants.ApprovalStatusSubmissionUnknown, "status_changed_at": now, "version": gorm.Expr("version + 1"), "updated_at": now, - }).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "同步通用审批提交结果未知状态失败") + }) + if instanceResult.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, instanceResult.Error, "同步通用审批提交结果未知状态失败") + } + if instanceResult.RowsAffected != result.RowsAffected { + return errors.New(errors.CodeConflict, "通用审批提交结果未知状态已变化") + } + for _, channelContext := range stale { + instance, err := loadApprovalAuditInstance(ctx, tx, channelContext.ApprovalInstanceID) + if err != nil { + return err + } + beforeStatus := constants.ApprovalStatusSubmitting + afterStatus := constants.ApprovalStatusSubmissionUnknown + if err := r.audit.WriteApproval(ctx, tx, approvalapp.AuditChange{ + EventID: approvalSubmissionAuditEventID(instance.ID, afterStatus), + ActionCode: constants.AuditActionApprovalSubmissionSynced, Summary: "审批提交处理中断,结果转为未知", + InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID, + SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot, + Provider: instance.Provider, BeforeExternalRef: instance.ExternalRef, AfterExternalRef: instance.ExternalRef, + CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &afterStatus, + ActorKind: constants.AuditActorScheduledJob, ActorID: constants.ApprovalAuditActorRecoveryJob, + Source: constants.AuditSourceScheduler, Result: constants.AuditResultUnknown, + ErrorSummary: "企业微信审批提交处理中断,已进入结果未知恢复", + }); err != nil { + return err + } } return nil }) @@ -160,7 +191,7 @@ func (r *ApprovalContextRepository) ListPendingSync(ctx context.Context, cutoff } // FindSuccessfulSubmissionSPNo 从已成功的安全 Integration Log 摘要恢复本地未保存的审批单号。 -func (r *ApprovalContextRepository) FindSuccessfulSubmissionSPNo(ctx context.Context, instanceID uint) (string, error) { +func (r *ApprovalContextRepository) FindSuccessfulSubmissionSPNo(ctx context.Context, instanceID uint) (string, string, error) { resourceID := strconv.FormatUint(uint64(instanceID), 10) var log model.IntegrationLog err := r.db.WithContext(ctx). @@ -169,18 +200,18 @@ func (r *ApprovalContextRepository) FindSuccessfulSubmissionSPNo(ctx context.Con constants.IntegrationDirectionOutbound, resourceID, constants.IntegrationResultSuccess). Order("id DESC").First(&log).Error if err == gorm.ErrRecordNotFound { - return "", nil + return "", "", nil } if err != nil { - return "", errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批提交日志失败") + return "", "", errors.Wrap(errors.CodeDatabaseError, err, "查询企业微信审批提交日志失败") } var summary struct { SPNo string `json:"sp_no"` } if sonic.Unmarshal(log.ResponseSummary, &summary) != nil { - return "", nil + return "", "", nil } - return strings.TrimSpace(summary.SPNo), nil + return strings.TrimSpace(summary.SPNo), log.IntegrationID, nil } // ExistingSPNos 批量过滤已经关联到本地审批实例的企微审批单号。 @@ -231,13 +262,24 @@ func (r *ApprovalContextRepository) FindUniqueUnknownByFingerprint( } // RecoverSubmitted 将唯一确认的企微审批单号原子关联回结果未知实例。 -func (r *ApprovalContextRepository) RecoverSubmitted(ctx context.Context, instanceID uint, spNo string) (bool, error) { +func (r *ApprovalContextRepository) RecoverSubmitted( + ctx context.Context, + instanceID uint, + spNo string, + integrationIDs []string, + actorKind string, + actorID string, + source string, +) (bool, error) { spNo = strings.TrimSpace(spNo) if instanceID == 0 || spNo == "" { return false, errors.New(errors.CodeInvalidParam, "企业微信审批恢复关联参数无效") } now := r.now().UTC() recovered := false + if r.audit == nil { + return false, errors.New(errors.CodeServiceUnavailable, "通用审批审计 Writer 未配置") + } err := r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { contextResult := tx.Model(&model.WeComApprovalContext{}). Where("approval_instance_id = ? AND submission_status = ? AND sp_no = ''", instanceID, constants.WeComSubmissionStatusUnknown). @@ -263,6 +305,24 @@ func (r *ApprovalContextRepository) RecoverSubmitted(ctx context.Context, instan if instanceResult.RowsAffected != 1 { return errors.New(errors.CodeConflict, "通用审批恢复状态已变化") } + instance, err := loadApprovalAuditInstance(ctx, tx, instanceID) + if err != nil { + return err + } + beforeStatus := constants.ApprovalStatusSubmissionUnknown + afterStatus := constants.ApprovalStatusPending + if err := r.audit.WriteApproval(ctx, tx, approvalapp.AuditChange{ + EventID: "approval:" + strconv.FormatUint(uint64(instanceID), 10) + ":audit:submission_recovered", + ActionCode: constants.AuditActionApprovalSubmissionRecovered, Summary: "恢复结果未知的审批提交", + InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID, + SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot, + Provider: instance.Provider, BeforeExternalRef: "", AfterExternalRef: spNo, + CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &afterStatus, + ActorKind: actorKind, ActorID: actorID, Source: source, Result: constants.AuditResultSuccess, + IntegrationIDs: integrationIDs, + }); err != nil { + return err + } recovered = true return nil }) @@ -335,7 +395,10 @@ func (r *ApprovalContextRepository) ReleaseForRetry(ctx context.Context, instanc } // MarkSubmitted 原子保存企微 sp_no,并把通用审批实例置为审批中。 -func (r *ApprovalContextRepository) MarkSubmitted(ctx context.Context, instanceID uint, spNo string) error { +func (r *ApprovalContextRepository) MarkSubmitted(ctx context.Context, instanceID uint, spNo string, integrationID string) error { + if r.audit == nil { + return errors.New(errors.CodeServiceUnavailable, "通用审批审计 Writer 未配置") + } now := r.now().UTC() return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { contextResult := tx.Model(&model.WeComApprovalContext{}). @@ -359,37 +422,90 @@ func (r *ApprovalContextRepository) MarkSubmitted(ctx context.Context, instanceI if instanceResult.RowsAffected != 1 { return errors.New(errors.CodeConflict, "通用审批提交状态已变化") } - return nil + instance, err := loadApprovalAuditInstance(ctx, tx, instanceID) + if err != nil { + return err + } + beforeStatus := constants.ApprovalStatusSubmitting + afterStatus := constants.ApprovalStatusPending + return r.audit.WriteApproval(ctx, tx, approvalapp.AuditChange{ + EventID: approvalSubmissionAuditEventID(instanceID, afterStatus), + ActionCode: constants.AuditActionApprovalSubmissionSynced, Summary: "企业微信审批提交成功", + InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID, + SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot, + Provider: instance.Provider, BeforeExternalRef: "", AfterExternalRef: spNo, + CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &afterStatus, + ActorKind: constants.AuditActorSystemTask, ActorID: constants.ApprovalAuditActorSubmissionWorker, + Source: constants.AuditSourceWorker, Result: constants.AuditResultSuccess, IntegrationIDs: []string{integrationID}, + }) }) } // MarkFailed 将企微明确拒绝的提交记录为提交失败。 -func (r *ApprovalContextRepository) MarkFailed(ctx context.Context, instanceID uint, message string) error { - return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusFailed, constants.ApprovalStatusSubmissionFailed, message) +func (r *ApprovalContextRepository) MarkFailed(ctx context.Context, instanceID uint, message string, integrationID string) error { + return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusFailed, constants.ApprovalStatusSubmissionFailed, message, constants.AuditResultFailed, integrationID) } // MarkUnknown 将请求已发出但无法确认结果的提交记录为结果未知。 -func (r *ApprovalContextRepository) MarkUnknown(ctx context.Context, instanceID uint, message string) error { - return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusUnknown, constants.ApprovalStatusSubmissionUnknown, message) +func (r *ApprovalContextRepository) MarkUnknown(ctx context.Context, instanceID uint, message string, integrationID string) error { + return r.markSubmissionState(ctx, instanceID, constants.WeComSubmissionStatusUnknown, constants.ApprovalStatusSubmissionUnknown, message, constants.AuditResultUnknown, integrationID) } // markSubmissionState 在同一事务中同步企微渠道状态与通用审批状态,避免两侧事实分裂。 -func (r *ApprovalContextRepository) markSubmissionState(ctx context.Context, instanceID uint, channelStatus, approvalStatus int, message string) error { +func (r *ApprovalContextRepository) markSubmissionState(ctx context.Context, instanceID uint, channelStatus, approvalStatus int, message, auditResult, integrationID string) error { + if r.audit == nil { + return errors.New(errors.CodeServiceUnavailable, "通用审批审计 Writer 未配置") + } now := r.now().UTC() return r.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&model.WeComApprovalContext{}). + contextResult := tx.Model(&model.WeComApprovalContext{}). Where("approval_instance_id = ? AND submission_status = ?", instanceID, constants.WeComSubmissionStatusSending). - Updates(map[string]any{"submission_status": channelStatus, "last_error": message, "updated_at": now}).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "更新企业微信审批提交结果失败") + Updates(map[string]any{"submission_status": channelStatus, "last_error": message, "updated_at": now}) + if contextResult.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, contextResult.Error, "更新企业微信审批提交结果失败") } - if err := tx.Model(&model.ApprovalInstance{}). + if contextResult.RowsAffected == 0 { + return nil + } + instanceResult := tx.Model(&model.ApprovalInstance{}). Where("id = ? AND status = ?", instanceID, constants.ApprovalStatusSubmitting). Updates(map[string]any{ "status": approvalStatus, "status_changed_at": now, "version": gorm.Expr("version + 1"), "updated_at": now, - }).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "更新通用审批提交结果失败") + }) + if instanceResult.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, instanceResult.Error, "更新通用审批提交结果失败") } - return nil + if instanceResult.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "通用审批提交结果状态已变化") + } + instance, err := loadApprovalAuditInstance(ctx, tx, instanceID) + if err != nil { + return err + } + beforeStatus := constants.ApprovalStatusSubmitting + return r.audit.WriteApproval(ctx, tx, approvalapp.AuditChange{ + EventID: approvalSubmissionAuditEventID(instanceID, approvalStatus), + ActionCode: constants.AuditActionApprovalSubmissionSynced, Summary: "同步企业微信审批提交结果", + InstanceID: instance.ID, BusinessType: instance.BusinessType, BusinessID: instance.BusinessID, + SubmitterAccountID: instance.SubmitterAccountID, SubmitterSnapshot: instance.SubmitterSnapshot, + Provider: instance.Provider, BeforeExternalRef: "", AfterExternalRef: instance.ExternalRef, + CorrelationID: instance.CorrelationID, BeforeStatus: &beforeStatus, AfterStatus: &approvalStatus, + ActorKind: constants.AuditActorSystemTask, ActorID: constants.ApprovalAuditActorSubmissionWorker, + Source: constants.AuditSourceWorker, Result: auditResult, ErrorSummary: message, + IntegrationIDs: []string{integrationID}, + }) }) } + +func loadApprovalAuditInstance(ctx context.Context, tx *gorm.DB, instanceID uint) (*model.ApprovalInstance, error) { + var instance model.ApprovalInstance + if err := tx.WithContext(ctx).First(&instance, instanceID).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询通用审批审计快照失败") + } + return &instance, nil +} + +func approvalSubmissionAuditEventID(instanceID uint, status int) string { + return "approval:" + strconv.FormatUint(uint64(instanceID), 10) + ":audit:submission:" + strconv.Itoa(status) +} diff --git a/internal/infrastructure/wecom/approval_detail_client.go b/internal/infrastructure/wecom/approval_detail_client.go index 3e15ca8..439b4df 100644 --- a/internal/infrastructure/wecom/approval_detail_client.go +++ b/internal/infrastructure/wecom/approval_detail_client.go @@ -19,9 +19,10 @@ import ( // ApprovalDetail 是企微审批详情的权威状态和安全原始 JSON 快照。 type ApprovalDetail struct { - SPNo string - SPStatus int - Snapshot []byte + SPNo string + SPStatus int + Snapshot []byte + IntegrationID string } // ApprovalDetailClient 获取企业微信审批申请详情。 @@ -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 组装按字符串审批单号查询权威详情的企微请求。 diff --git a/internal/infrastructure/wecom/approval_detail_task.go b/internal/infrastructure/wecom/approval_detail_task.go index 4f69b75..aadeeb5 100644 --- a/internal/infrastructure/wecom/approval_detail_task.go +++ b/internal/infrastructure/wecom/approval_detail_task.go @@ -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 } diff --git a/internal/infrastructure/wecom/approval_info_client.go b/internal/infrastructure/wecom/approval_info_client.go index aefb37f..25fef1b 100644 --- a/internal/infrastructure/wecom/approval_info_client.go +++ b/internal/infrastructure/wecom/approval_info_client.go @@ -30,8 +30,9 @@ type ApprovalInfoQuery struct { // ApprovalInfoPage 是企业微信批量审批单号接口的一页结果。 type ApprovalInfoPage struct { - SPNos []string - NextCursor string + 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 { diff --git a/internal/infrastructure/wecom/approval_recovery_task.go b/internal/infrastructure/wecom/approval_recovery_task.go index d67b15a..f28c3f7 100644 --- a/internal/infrastructure/wecom/approval_recovery_task.go +++ b/internal/infrastructure/wecom/approval_recovery_task.go @@ -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 { diff --git a/internal/infrastructure/wecom/approval_submission_client.go b/internal/infrastructure/wecom/approval_submission_client.go index 457063c..0f95a99 100644 --- a/internal/infrastructure/wecom/approval_submission_client.go +++ b/internal/infrastructure/wecom/approval_submission_client.go @@ -35,10 +35,11 @@ type ApprovalSubmitRequest struct { // ApprovalSubmitResult 描述企微是否明确创建审批单。 type ApprovalSubmitResult struct { - Outcome string - SPNo string - Message string - SafeToRetry bool + Outcome string + 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 只组装本次企微审批请求,调用前不会产生外部副作用。 diff --git a/internal/infrastructure/wecom/approval_submission_consumer.go b/internal/infrastructure/wecom/approval_submission_consumer.go index ce7d2d9..80abfc5 100644 --- a/internal/infrastructure/wecom/approval_submission_consumer.go +++ b/internal/infrastructure/wecom/approval_submission_consumer.go @@ -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: diff --git a/internal/infrastructure/wecom/member_repository.go b/internal/infrastructure/wecom/member_repository.go index 3de6b89..7f1ba35 100644 --- a/internal/infrastructure/wecom/member_repository.go +++ b/internal/infrastructure/wecom/member_repository.go @@ -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). - Updates(map[string]any{"visible": false, "updated_at": syncedAt}).Error; err != nil { - return err - } - if len(members) == 0 { - return nil - } - return tx.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 { + 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 errors.Wrap(errors.CodeDatabaseError, err, "同步企业微信可见成员失败") + } + if len(members) == 0 { + return nil + } + 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; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "同步企业微信可见成员失败") } return nil diff --git a/internal/model/order.go b/internal/model/order.go index 6d1586f..233a981 100644 --- a/internal/model/order.go +++ b/internal/model/order.go @@ -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"` diff --git a/internal/polling/package_activation_handler.go b/internal/polling/package_activation_handler.go index a4642ad..0d99d80 100644 --- a/internal/polling/package_activation_handler.go +++ b/internal/polling/package_activation_handler.go @@ -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 信息缺失", diff --git a/internal/polling/scheduler.go b/internal/polling/scheduler.go index 9d2dbcb..1bca40f 100644 --- a/internal/polling/scheduler.go +++ b/internal/polling/scheduler.go @@ -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)) diff --git a/internal/service/agent_recharge/service.go b/internal/service/agent_recharge/service.go index 2455d30..f27927f 100644 --- a/internal/service/agent_recharge/service.go +++ b/internal/service/agent_recharge/service.go @@ -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) { diff --git a/internal/service/asset_package_batch_order/audit.go b/internal/service/asset_package_batch_order/audit.go new file mode 100644 index 0000000..4545899 --- /dev/null +++ b/internal/service/asset_package_batch_order/audit.go @@ -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, + } +} diff --git a/internal/service/asset_package_batch_order/service.go b/internal/service/asset_package_batch_order/service.go index 5b6d715..77ceb43 100644 --- a/internal/service/asset_package_batch_order/service.go +++ b/internal/service/asset_package_batch_order/service.go @@ -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) - task.Status, task.ErrorMessage = asynctask.StatusFailed, message - now := time.Now() - task.CompletedAt = &now + 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 } diff --git a/internal/service/carrier/service.go b/internal/service/carrier/service.go index dfcb376..dbe7267 100644 --- a/internal/service/carrier/service.go +++ b/internal/service/carrier/service.go @@ -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, diff --git a/internal/service/client_order/payment_audit.go b/internal/service/client_order/payment_audit.go new file mode 100644 index 0000000..3cb494d --- /dev/null +++ b/internal/service/client_order/payment_audit.go @@ -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})}, + }) + }) +} diff --git a/internal/service/client_order/service.go b/internal/service/client_order/service.go index 0669751..95f62b9 100644 --- a/internal/service/client_order/service.go +++ b/internal/service/client_order/service.go @@ -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), diff --git a/internal/service/commission_calculation/audit.go b/internal/service/commission_calculation/audit.go new file mode 100644 index 0000000..e81140e --- /dev/null +++ b/internal/service/commission_calculation/audit.go @@ -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) +} diff --git a/internal/service/commission_calculation/service.go b/internal/service/commission_calculation/service.go index ad73db5..dc3ad91 100644 --- a/internal/service/commission_calculation/service.go +++ b/internal/service/commission_calculation/service.go @@ -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 { diff --git a/internal/service/commission_withdrawal/audit.go b/internal/service/commission_withdrawal/audit.go new file mode 100644 index 0000000..37f4bb5 --- /dev/null +++ b/internal/service/commission_withdrawal/audit.go @@ -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, + } +} diff --git a/internal/service/commission_withdrawal/service.go b/internal/service/commission_withdrawal/service.go index a5e35a1..ef2bf50 100644 --- a/internal/service/commission_withdrawal/service.go +++ b/internal/service/commission_withdrawal/service.go @@ -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 } diff --git a/internal/service/customer_binding/audit.go b/internal/service/customer_binding/audit.go new file mode 100644 index 0000000..188d910 --- /dev/null +++ b/internal/service/customer_binding/audit.go @@ -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 +} diff --git a/internal/service/customer_binding/service.go b/internal/service/customer_binding/service.go index faa55b5..17d0e92 100644 --- a/internal/service/customer_binding/service.go +++ b/internal/service/customer_binding/service.go @@ -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" @@ -67,8 +68,9 @@ type Service struct { makePCI func(*gorm.DB) pciOps markAsSold func(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error // 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) + 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_iccid(Issue 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 + } } - - return nil + 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 + } } - - return nil + 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: diff --git a/internal/service/device/batch_audit.go b/internal/service/device/batch_audit.go deleted file mode 100644 index 7829f52..0000000 --- a/internal/service/device/batch_audit.go +++ /dev/null @@ -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) -} diff --git a/internal/service/device/binding.go b/internal/service/device/binding.go index cf5a2fa..803982b 100644 --- a/internal/service/device/binding.go +++ b/internal/service/device/binding.go @@ -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), + } + 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 } - } - - beforeAuditData := map[string]any{ - "device": deviceSnapshot(device), - "binding_id": binding.ID, - "iot_card_id": binding.IotCardID, - } - if cardAudit != nil { - beforeAuditData["card"] = cardAudit - } - - if err := s.deviceSimBindingStore.Unbind(ctx, binding.ID); err != nil { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceUnbindCard, - "设备解绑卡失败", - constants.AssetAuditResultFailed, + if err := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).Where("id = ?", binding.ID).Update("is_current", false).Error; err != nil { + return err + } + 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 diff --git a/internal/service/device/binding_audit.go b/internal/service/device/binding_audit.go new file mode 100644 index 0000000..0b4f7c5 --- /dev/null +++ b/internal/service/device/binding_audit.go @@ -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 +} diff --git a/internal/service/device/gateway_audit.go b/internal/service/device/gateway_audit.go new file mode 100644 index 0000000..b8a672d --- /dev/null +++ b/internal/service/device/gateway_audit.go @@ -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) diff --git a/internal/service/device/gateway_service.go b/internal/service/device/gateway_service.go index d75adac..912e5cc 100644 --- a/internal/service/device/gateway_service.go +++ b/internal/service/device/gateway_service.go @@ -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{ - CardNo: imei, - Params: gateway.WiFiParams{ - SSIDName: req.SSID, - SSIDPassword: req.Password, + 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 != "", }, - }); 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, - ) + 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}, + }) + }, + }) + 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 + } + 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{"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.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceSwitchCard, - "设备切卡", - constants.AssetAuditResultSuccess, - device, - map[string]any{"device": deviceSnapshot(device)}, - map[string]any{"target_iccid": req.TargetICCID}, - 0, - 0, - 0, - nil, - ) 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 } diff --git a/internal/service/device/realname_policy_batch.go b/internal/service/device/realname_policy_batch.go index 8b05468..6fcecda 100644 --- a/internal/service/device/realname_policy_batch.go +++ b/internal/service/device/realname_policy_batch.go @@ -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) - if result.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新设备实名认证策略失败") + changedIDs := make([]uint, 0, len(devices)) + for _, device := range devices { + if device != nil && device.RealnamePolicy != req.RealnamePolicy { + changedIDs = append(changedIDs, device.ID) + } } - if result.RowsAffected != int64(len(ids)) { - return errors.New(errors.CodeConflict, "设备资产状态已变化,请刷新后重试") + 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(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 } diff --git a/internal/service/device/service.go b/internal/service/device/service.go index 6a91094..662de67 100644 --- a/internal/service/device/service.go +++ b/internal/service/device/service.go @@ -2,6 +2,7 @@ package device import ( "context" + "strconv" "strings" "time" @@ -10,6 +11,7 @@ import ( "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" + "gorm.io/gorm/clause" "github.com/break/junhong_cmp_fiber/internal/gateway" auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" @@ -44,6 +46,7 @@ type Service struct { observationSeriesEvents cardObservationApp.SeriesEventWriter observationSeries cardObservationApp.BestEffortSeriesDispatcher auditWriter *auditinfra.Writer + gatewayIntegration deviceGatewayIntegrationLog } // SetObservationSeriesEventWriter 注入设备停复机成功观测序列 Outbox Writer。 @@ -414,98 +417,47 @@ func (s *Service) GetDeviceByIdentifier(ctx context.Context, identifier string) } func (s *Service) Delete(ctx context.Context, id uint) error { - auditDevice := &model.Device{} - auditDevice.ID = id + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "设备统一审计接缝未配置") + } device, err := s.deviceStore.GetByID(ctx, id) if err != nil { if err == gorm.ErrRecordNotFound { appErr := errors.New(errors.CodeNotFound, "设备不存在") - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceDelete, - "删除设备失败", - constants.AssetAuditResultFailed, - auditDevice, - nil, - nil, - 0, - 0, - 0, - appErr, - ) + s.recordDeviceLifecycleFailure(ctx, constants.AuditActionDeviceDeleted, "删除设备被拒绝", constants.AuditResultDenied, nil, id, appErr) return appErr } - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceDelete, - "删除设备失败", - constants.AssetAuditResultFailed, - auditDevice, - nil, - nil, - 0, - 0, - 0, - err, - ) + s.recordDeviceLifecycleFailure(ctx, constants.AuditActionDeviceDeleted, "删除设备失败", constants.AuditResultFailed, nil, id, err) return err } - beforeData := deviceSnapshot(device) - - if err := s.deviceSimBindingStore.UnbindByDeviceID(ctx, device.ID); err != nil { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceDelete, - "删除设备失败", - constants.AssetAuditResultFailed, - device, - beforeData, - nil, - 0, - 0, - 0, - err, + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + bindingReferences, txErr := loadDeviceUnbindAuditReferences(ctx, tx, device) + if txErr != nil { + return txErr + } + if txErr := postgres.NewDeviceSimBindingStore(tx, nil).UnbindByDeviceID(ctx, device.ID); txErr != nil { + return txErr + } + if txErr := tx.WithContext(ctx).Model(&model.DeviceSimBinding{}).Where("device_id = ?", device.ID).Update("is_current", false).Error; txErr != nil { + return txErr + } + if txErr := postgres.NewDeviceStore(tx, nil).Delete(ctx, id); txErr != nil { + return txErr + } + if s.assetIdentifierStore != nil { + if txErr := postgres.NewAssetIdentifierStore(tx).DeleteByAsset(ctx, model.AssetTypeDevice, id); txErr != nil { + return txErr + } + } + return s.appendDeviceLifecycleAudit( + ctx, tx, constants.AuditActionDeviceDeleted, "删除设备", constants.AuditResultSuccess, + device, auditinfra.DeviceIdentitySnapshot(device), map[string]any{"deleted": true}, bindingReferences, nil, ) + }) + if err != nil { + s.recordDeviceLifecycleFailure(ctx, constants.AuditActionDeviceDeleted, "删除设备失败", constants.AuditResultFailed, device, id, err) return err } - - if err := s.deviceStore.Delete(ctx, id); err != nil { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceDelete, - "删除设备失败", - constants.AssetAuditResultFailed, - device, - beforeData, - nil, - 0, - 0, - 0, - err, - ) - return err - } - - if s.assetIdentifierStore != nil { - _ = s.assetIdentifierStore.DeleteByAsset(ctx, model.AssetTypeDevice, id) - } - - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceDelete, - "删除设备", - constants.AssetAuditResultSuccess, - device, - beforeData, - map[string]any{ - "deleted": true, - }, - 0, - 0, - 0, - nil, - ) - return nil } @@ -530,64 +482,15 @@ func (s *Service) GetCardByICCID(ctx context.Context, iccid string) (*model.IotC func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesRequest, operatorID uint, operatorShopID *uint) (*dto.AllocateDevicesResponse, error) { // 代理仅可分配给直属下级;平台/超级管理员可跨级分配 if err := s.validateDirectSubordinate(ctx, operatorShopID, req.TargetShopID); err != nil { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceAllocate, - "设备分配被拒绝", - constants.AssetAuditResultDenied, - nil, - nil, - map[string]any{ - "target_shop_id": req.TargetShopID, - "device_ids": req.DeviceIDs, - }, - len(req.DeviceIDs), - 0, - len(req.DeviceIDs), - err, - ) return nil, err } devices, err := s.deviceStore.GetByIDs(ctx, req.DeviceIDs) if err != nil { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceAllocate, - "设备分配失败", - constants.AssetAuditResultFailed, - nil, - nil, - map[string]any{ - "target_shop_id": req.TargetShopID, - "device_ids": req.DeviceIDs, - }, - len(req.DeviceIDs), - 0, - len(req.DeviceIDs), - err, - ) return nil, err } if len(devices) == 0 { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceAllocate, - "设备分配被拒绝", - constants.AssetAuditResultDenied, - nil, - nil, - map[string]any{ - "target_shop_id": req.TargetShopID, - "device_ids": req.DeviceIDs, - "reason": "未找到可分配设备", - }, - len(req.DeviceIDs), - 0, - len(req.DeviceIDs), - errors.New(errors.CodeNotFound, "未找到可分配设备"), - ) return &dto.AllocateDevicesResponse{ SuccessCount: 0, FailCount: 0, @@ -625,22 +528,15 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR } if len(deviceIDs) == 0 { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceAllocate, - "设备分配被拒绝", - constants.AssetAuditResultDenied, - nil, - nil, - map[string]any{ - "target_shop_id": req.TargetShopID, - "failed_items": failedItems, - }, - len(devices), - 0, - len(failedItems), - errors.New(errors.CodeForbidden, "无可分配设备"), - ) + denyErr := errors.New(errors.CodeForbidden, "无可分配设备") + outcomes := deviceAuditOutcomes(devices, constants.AuditResultDenied, "设备不可分配") + setDeviceAuditFailedItems(outcomes, failedItems) + targetShopID := req.TargetShopID + s.recordDeviceTransferAuditFailure(ctx, + constants.AuditActionDeviceAllocationBatch, constants.AuditActionDeviceAllocated, + "allocate", "批量分配设备被拒绝", constants.AuditResultDenied, + devices, outcomes, &targetShopID, constants.DeviceStatusDistributed, + len(devices), 0, len(failedItems), denyErr) return &dto.AllocateDevicesResponse{ SuccessCount: 0, FailCount: len(failedItems), @@ -650,28 +546,36 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR newStatus := constants.DeviceStatusDistributed targetShopID := req.TargetShopID - targetShopName := s.getAuditShopName(ctx, &targetShopID) - shopMap := s.loadShopData(ctx, devices) - beforeData := make([]map[string]any, 0, len(devices)) - for _, device := range devices { - snapshot := deviceSnapshot(device) - snapshot["shop_name"] = deviceShopMapValue(shopMap, device.ShopID) - beforeData = append(beforeData, snapshot) + allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate) + records := s.buildAllocationRecords(devices, deviceIDs, operatorShopID, targetShopID, operatorID, allocationNo, req.Remark) + outcomes := deviceAuditOutcomes(devices, constants.AuditResultDenied, "设备不可分配") + setDeviceAuditFailedItems(outcomes, failedItems) + setDeviceAuditOutcomes(outcomes, deviceIDs, constants.AuditResultSuccess, "设备已分配") + auditResult := constants.AuditResultSuccess + if len(failedItems) > 0 { + auditResult = constants.AuditResultPartial } err = s.db.Transaction(func(tx *gorm.DB) error { txDeviceStore := postgres.NewDeviceStore(tx, nil) txCardStore := postgres.NewIotCardStore(tx, nil) txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil) - - if err := txDeviceStore.BatchUpdateShopIDAndStatus(ctx, deviceIDs, &targetShopID, newStatus); err != nil { + allCardReferences, _, err := loadDeviceCardAuditReferences(ctx, tx, devices, nil) + if err != nil { return err } - - boundCardIDs, err := s.deviceSimBindingStore.GetBoundCardIDsByDeviceIDs(ctx, deviceIDs) + successDevices := deviceModelsByIDs(devices, deviceIDs) + changedCardReferences, boundCardIDs, err := loadDeviceCardAuditReferences(ctx, tx, successDevices, &deviceCardAuditChange{ShopID: &targetShopID, Status: constants.IotCardStatusDistributed}) if err != nil { return err } + for deviceID, references := range changedCardReferences { + allCardReferences[deviceID] = references + } + + if err := txDeviceStore.BatchUpdateShopIDAndStatus(ctx, deviceIDs, &targetShopID, newStatus); err != nil { + return err + } if len(boundCardIDs) > 0 { if err := txCardStore.BatchUpdateShopIDAndStatus(ctx, boundCardIDs, &targetShopID, constants.IotCardStatusDistributed); err != nil { @@ -679,55 +583,29 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR } } - allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate) - records := s.buildAllocationRecords(devices, deviceIDs, operatorShopID, targetShopID, operatorID, allocationNo, req.Remark) if err := txRecordStore.BatchCreate(ctx, records); err != nil { return err } - return s.appendCSVBatchAllocationAudit(ctx, tx, devices, deviceIDs, failedItems, targetShopID) + return s.appendDeviceTransferAudit(ctx, tx, + constants.AuditActionDeviceAllocationBatch, constants.AuditActionDeviceAllocated, + "allocate", "批量分配设备", auditResult, + devices, outcomes, records, &targetShopID, newStatus, + len(devices), len(deviceIDs), len(failedItems), allCardReferences, nil) }) if err != nil { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceAllocate, - "设备分配失败", - constants.AssetAuditResultFailed, - nil, - map[string]any{"devices": beforeData}, - map[string]any{ - "target_shop_id": req.TargetShopID, - "target_shop_name": targetShopName, - "failed_items": failedItems, - }, - len(devices), - len(deviceIDs), - len(failedItems), - err, - ) + failedOutcomes := deviceAuditOutcomes(devices, constants.AuditResultDenied, "设备不可分配") + setDeviceAuditFailedItems(failedOutcomes, failedItems) + setDeviceAuditOutcomes(failedOutcomes, deviceIDs, constants.AuditResultFailed, "设备分配失败") + s.recordDeviceTransferAuditFailure(ctx, + constants.AuditActionDeviceAllocationBatch, constants.AuditActionDeviceAllocated, + "allocate", "批量分配设备失败", constants.AuditResultFailed, + devices, failedOutcomes, &targetShopID, newStatus, + len(devices), 0, len(devices), err) return nil, err } s.iotCardStore.InvalidateListCountCache(ctx) - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceAllocate, - "设备分配", - constants.AssetAuditResultSuccess, - nil, - map[string]any{"devices": beforeData}, - map[string]any{ - "target_shop_id": req.TargetShopID, - "target_shop_name": targetShopName, - "device_ids": deviceIDs, - "failed_items": failedItems, - }, - len(devices), - len(deviceIDs), - len(failedItems), - nil, - ) - return &dto.AllocateDevicesResponse{ SuccessCount: len(deviceIDs), FailCount: len(failedItems), @@ -739,41 +617,10 @@ func (s *Service) AllocateDevices(ctx context.Context, req *dto.AllocateDevicesR func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesRequest, operatorID uint, operatorShopID *uint) (*dto.RecallDevicesResponse, error) { devices, err := s.deviceStore.GetByIDs(ctx, req.DeviceIDs) if err != nil { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceRecall, - "设备回收失败", - constants.AssetAuditResultFailed, - nil, - nil, - map[string]any{ - "device_ids": req.DeviceIDs, - }, - len(req.DeviceIDs), - 0, - len(req.DeviceIDs), - err, - ) return nil, err } if len(devices) == 0 { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceRecall, - "设备回收被拒绝", - constants.AssetAuditResultDenied, - nil, - nil, - map[string]any{ - "device_ids": req.DeviceIDs, - "reason": "未找到可回收设备", - }, - len(req.DeviceIDs), - 0, - len(req.DeviceIDs), - errors.New(errors.CodeNotFound, "未找到可回收设备"), - ) return &dto.RecallDevicesResponse{ SuccessCount: 0, FailCount: 0, @@ -813,22 +660,20 @@ func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesReque } if len(deviceIDs) == 0 { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceRecall, - "设备回收被拒绝", - constants.AssetAuditResultDenied, - nil, - nil, - map[string]any{ - "device_ids": req.DeviceIDs, - "failed_items": failedItems, - }, - len(devices), - 0, - len(failedItems), - errors.New(errors.CodeForbidden, "无可回收设备"), - ) + denyErr := errors.New(errors.CodeForbidden, "无可回收设备") + outcomes := deviceAuditOutcomes(devices, constants.AuditResultDenied, "设备不可回收") + setDeviceAuditFailedItems(outcomes, failedItems) + var deniedTargetShopID *uint + deniedStatus := constants.DeviceStatusInStock + if !isPlatform { + deniedTargetShopID = operatorShopID + deniedStatus = constants.DeviceStatusDistributed + } + s.recordDeviceTransferAuditFailure(ctx, + constants.AuditActionDeviceRecallBatch, constants.AuditActionDeviceRecalled, + "recall", "批量回收设备被拒绝", constants.AuditResultDenied, + devices, outcomes, deniedTargetShopID, deniedStatus, + len(devices), 0, len(failedItems), denyErr) return &dto.RecallDevicesResponse{ SuccessCount: 0, FailCount: len(failedItems), @@ -837,95 +682,73 @@ func (s *Service) RecallDevices(ctx context.Context, req *dto.RecallDevicesReque } var newShopID *uint - var newStatus int - if isPlatform { - newShopID = nil - newStatus = constants.DeviceStatusInStock - } else { + newStatus := constants.DeviceStatusInStock + cardStatus := constants.IotCardStatusInStock + if !isPlatform { newShopID = operatorShopID newStatus = constants.DeviceStatusDistributed + cardStatus = constants.IotCardStatusDistributed } - targetShopName := s.getAuditRecallTargetShopName(ctx, newShopID) - shopMap := s.loadShopData(ctx, devices) - beforeData := make([]map[string]any, 0, len(devices)) - for _, device := range devices { - snapshot := deviceSnapshot(device) - snapshot["shop_name"] = deviceShopMapValue(shopMap, device.ShopID) - beforeData = append(beforeData, snapshot) + allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall) + records := s.buildRecallRecords(devices, deviceIDs, operatorShopID, newShopID, operatorID, allocationNo, req.Remark) + outcomes := deviceAuditOutcomes(devices, constants.AuditResultDenied, "设备不可回收") + setDeviceAuditFailedItems(outcomes, failedItems) + setDeviceAuditOutcomes(outcomes, deviceIDs, constants.AuditResultSuccess, "设备已回收") + auditResult := constants.AuditResultSuccess + if len(failedItems) > 0 { + auditResult = constants.AuditResultPartial } err = s.db.Transaction(func(tx *gorm.DB) error { txDeviceStore := postgres.NewDeviceStore(tx, nil) txCardStore := postgres.NewIotCardStore(tx, nil) txRecordStore := postgres.NewAssetAllocationRecordStore(tx, nil) + allCardReferences, _, err := loadDeviceCardAuditReferences(ctx, tx, devices, nil) + if err != nil { + return err + } + successDevices := deviceModelsByIDs(devices, deviceIDs) + changedCardReferences, boundCardIDs, err := loadDeviceCardAuditReferences(ctx, tx, successDevices, &deviceCardAuditChange{ShopID: newShopID, Status: cardStatus}) + if err != nil { + return err + } + for deviceID, references := range changedCardReferences { + allCardReferences[deviceID] = references + } if err := txDeviceStore.BatchUpdateShopIDAndStatus(ctx, deviceIDs, newShopID, newStatus); err != nil { return err } - boundCardIDs, err := s.deviceSimBindingStore.GetBoundCardIDsByDeviceIDs(ctx, deviceIDs) - if err != nil { - return err - } - if len(boundCardIDs) > 0 { - var cardStatus int - if isPlatform { - cardStatus = constants.IotCardStatusInStock - } else { - cardStatus = constants.IotCardStatusDistributed - } if err := txCardStore.BatchUpdateShopIDAndStatus(ctx, boundCardIDs, newShopID, cardStatus); err != nil { return err } } - allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall) - records := s.buildRecallRecords(devices, deviceIDs, operatorShopID, newShopID, operatorID, allocationNo, req.Remark) - return txRecordStore.BatchCreate(ctx, records) + if err := txRecordStore.BatchCreate(ctx, records); err != nil { + return err + } + return s.appendDeviceTransferAudit(ctx, tx, + constants.AuditActionDeviceRecallBatch, constants.AuditActionDeviceRecalled, + "recall", "批量回收设备", auditResult, + devices, outcomes, records, newShopID, newStatus, + len(devices), len(deviceIDs), len(failedItems), allCardReferences, nil) }) if err != nil { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceRecall, - "设备回收失败", - constants.AssetAuditResultFailed, - nil, - map[string]any{"devices": beforeData}, - map[string]any{ - "device_ids": deviceIDs, - "failed_items": failedItems, - "target_shop_name": targetShopName, - }, - len(devices), - len(deviceIDs), - len(failedItems), - err, - ) + failedOutcomes := deviceAuditOutcomes(devices, constants.AuditResultDenied, "设备不可回收") + setDeviceAuditFailedItems(failedOutcomes, failedItems) + setDeviceAuditOutcomes(failedOutcomes, deviceIDs, constants.AuditResultFailed, "设备回收失败") + s.recordDeviceTransferAuditFailure(ctx, + constants.AuditActionDeviceRecallBatch, constants.AuditActionDeviceRecalled, + "recall", "批量回收设备失败", constants.AuditResultFailed, + devices, failedOutcomes, newShopID, newStatus, + len(devices), 0, len(devices), err) return nil, err } s.iotCardStore.InvalidateListCountCache(ctx) - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceRecall, - "设备回收", - constants.AssetAuditResultSuccess, - nil, - map[string]any{"devices": beforeData}, - map[string]any{ - "device_ids": deviceIDs, - "failed_items": failedItems, - "target_shop": newShopID, - "target_shop_name": targetShopName, - }, - len(devices), - len(deviceIDs), - len(failedItems), - nil, - ) - return &dto.RecallDevicesResponse{ SuccessCount: len(deviceIDs), FailCount: len(failedItems), @@ -961,31 +784,6 @@ func (s *Service) validateDirectSubordinate(ctx context.Context, operatorShopID return nil } -func (s *Service) getAuditShopName(ctx context.Context, shopID *uint) string { - if shopID == nil || *shopID == 0 { - return "" - } - var shop model.Shop - if err := s.db.WithContext(ctx).Unscoped().First(&shop, *shopID).Error; err != nil { - return "" - } - return shop.ShopName -} - -func (s *Service) getAuditRecallTargetShopName(ctx context.Context, shopID *uint) string { - if shopID == nil { - return "平台库存" - } - return s.getAuditShopName(ctx, shopID) -} - -func deviceShopMapValue(shopMap map[uint]string, shopID *uint) string { - if shopID == nil { - return "" - } - return shopMap[*shopID] -} - func (s *Service) loadShopData(ctx context.Context, devices []*model.Device) map[uint]string { shopIDs := make([]uint, 0) shopIDSet := make(map[uint]bool) @@ -1195,21 +993,7 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe devices, err := s.getDevicesForSeriesBinding(ctx, req, selectionType) batchTotal := deviceSeriesBindingBatchTotal(req, selectionType, devices) - auditData := deviceSeriesBindingAuditData(req, selectionType, operatorShopID) if err != nil { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceSeriesBinding, - "设备系列绑定失败", - constants.AssetAuditResultFailed, - nil, - nil, - auditData, - batchTotal, - 0, - batchTotal, - err, - ) return nil, err } @@ -1217,19 +1001,6 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe failedItems := []dto.DeviceSeriesBindngFailedItem{} if selectionType == dto.SelectionTypeList { failedItems = s.buildDeviceNotFoundFailedItems(req.DeviceIDs) - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceSeriesBinding, - "设备系列绑定被拒绝", - constants.AssetAuditResultDenied, - nil, - nil, - auditData, - batchTotal, - 0, - len(failedItems), - errors.New(errors.CodeNotFound, "设备不存在"), - ) } return &dto.BatchSetDeviceSeriesBindngResponse{ SuccessCount: 0, @@ -1250,51 +1021,27 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe if err != nil { if err == gorm.ErrRecordNotFound { appErr := errors.New(errors.CodeNotFound, "套餐系列不存在或已禁用") - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceSeriesBinding, - "设备系列绑定被拒绝", - constants.AssetAuditResultDenied, - nil, - nil, - auditData, - batchTotal, - 0, - batchTotal, - appErr, - ) + outcomes := deviceAuditOutcomes(devices, constants.AuditResultDenied, "套餐系列不存在或已禁用") + targetSeriesID := req.SeriesID + s.recordDeviceSeriesBindingAuditFailure(ctx, devices, outcomes, &targetSeriesID, + constants.AuditResultDenied, batchTotal, 0, batchTotal, + deviceSeriesBindingAuditData(req, selectionType, operatorShopID), appErr) return nil, appErr } - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceSeriesBinding, - "设备系列绑定失败", - constants.AssetAuditResultFailed, - nil, - nil, - auditData, - batchTotal, - 0, - batchTotal, - err, - ) + outcomes := deviceAuditOutcomes(devices, constants.AuditResultFailed, "查询套餐系列失败") + targetSeriesID := req.SeriesID + s.recordDeviceSeriesBindingAuditFailure(ctx, devices, outcomes, &targetSeriesID, + constants.AuditResultFailed, batchTotal, 0, batchTotal, + deviceSeriesBindingAuditData(req, selectionType, operatorShopID), err) return nil, err } if packageSeries.Status != 1 { appErr := errors.New(errors.CodeInvalidParam, "套餐系列不存在或已禁用") - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceSeriesBinding, - "设备系列绑定被拒绝", - constants.AssetAuditResultDenied, - nil, - nil, - auditData, - batchTotal, - 0, - batchTotal, - appErr, - ) + outcomes := deviceAuditOutcomes(devices, constants.AuditResultDenied, "套餐系列不存在或已禁用") + targetSeriesID := req.SeriesID + s.recordDeviceSeriesBindingAuditFailure(ctx, devices, outcomes, &targetSeriesID, + constants.AuditResultDenied, batchTotal, 0, batchTotal, + deviceSeriesBindingAuditData(req, selectionType, operatorShopID), appErr) return nil, appErr } } @@ -1307,19 +1054,11 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe if operatorShopID != nil && req.SeriesID > 0 { hasSeriesAllocation, err = s.hasAvailableSeriesAllocation(ctx, *operatorShopID, req.SeriesID) if err != nil { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceSeriesBinding, - "设备系列绑定失败", - constants.AssetAuditResultFailed, - nil, - nil, - auditData, - batchTotal, - 0, - batchTotal, - err, - ) + outcomes := deviceAuditOutcomes(devices, constants.AuditResultFailed, "查询店铺系列授权失败") + targetSeriesID := req.SeriesID + s.recordDeviceSeriesBindingAuditFailure(ctx, devices, outcomes, &targetSeriesID, + constants.AuditResultFailed, batchTotal, 0, batchTotal, + deviceSeriesBindingAuditData(req, selectionType, operatorShopID), err) return nil, err } } @@ -1370,65 +1109,54 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetDe } } - if len(successDeviceIDs) > 0 { - var seriesIDPtr *uint - if req.SeriesID > 0 { - seriesIDPtr = &req.SeriesID + var seriesIDPtr *uint + if req.SeriesID > 0 { + seriesIDPtr = &req.SeriesID + } + outcomes := deviceAuditOutcomes(devices, constants.AuditResultDenied, "设备不可设置系列") + for _, item := range failedItems { + if _, ok := outcomes[item.DeviceID]; ok { + outcomes[item.DeviceID] = deviceAuditOutcome{Result: constants.AuditResultDenied, Summary: item.Reason} } - if err := s.deviceStore.BatchUpdateSeriesID(ctx, successDeviceIDs, seriesIDPtr); err != nil { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceSeriesBinding, - "设备系列绑定失败", - constants.AssetAuditResultFailed, - nil, - nil, - map[string]any{ - "series_id": req.SeriesID, - "selection_type": selectionType, - "success_device_ids": successDeviceIDs, - "failed_items": failedItems, - }, - batchTotal, - len(successDeviceIDs), - len(failedItems), - err, - ) + } + setDeviceAuditOutcomes(outcomes, successDeviceIDs, constants.AuditResultSuccess, "设备系列绑定已更新") + resultStatus := constants.AuditResultSuccess + if len(successDeviceIDs) == 0 && len(failedItems) > 0 { + resultStatus = constants.AuditResultDenied + } else if len(successDeviceIDs) > 0 && len(failedItems) > 0 { + resultStatus = constants.AuditResultPartial + } + metadata := map[string]any{ + "selection_type": selectionType, "series_id": req.SeriesID, + "success_device_ids": successDeviceIDs, "failed_items": failedItems, + } + if len(successDeviceIDs) > 0 { + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + txDeviceStore := postgres.NewDeviceStore(tx, nil) + if err := txDeviceStore.BatchUpdateSeriesID(ctx, successDeviceIDs, seriesIDPtr); err != nil { + return err + } + return s.appendDeviceSeriesBindingAudit(ctx, tx, devices, outcomes, seriesIDPtr, resultStatus, + batchTotal, len(successDeviceIDs), len(failedItems), metadata, nil) + }) + if err != nil { + failedOutcomes := deviceAuditOutcomes(devices, constants.AuditResultDenied, "设备不可设置系列") + for _, item := range failedItems { + if _, ok := failedOutcomes[item.DeviceID]; ok { + failedOutcomes[item.DeviceID] = deviceAuditOutcome{Result: constants.AuditResultDenied, Summary: item.Reason} + } + } + setDeviceAuditOutcomes(failedOutcomes, successDeviceIDs, constants.AuditResultFailed, "设备系列绑定失败") + s.recordDeviceSeriesBindingAuditFailure(ctx, devices, failedOutcomes, seriesIDPtr, + constants.AuditResultFailed, batchTotal, 0, len(devices), metadata, err) return nil, err } + } else if len(failedItems) > 0 { + denyErr := errors.New(errors.CodeForbidden, "无可绑定设备") + s.recordDeviceSeriesBindingAuditFailure(ctx, devices, outcomes, seriesIDPtr, + constants.AuditResultDenied, batchTotal, 0, len(failedItems), metadata, denyErr) } - beforeData := make([]map[string]any, 0, len(devices)) - for _, device := range devices { - beforeData = append(beforeData, deviceSnapshot(device)) - } - resultStatus := constants.AssetAuditResultSuccess - var resultErr error - if len(successDeviceIDs) == 0 && len(failedItems) > 0 { - resultStatus = constants.AssetAuditResultDenied - resultErr = errors.New(errors.CodeForbidden, "无可绑定设备") - } - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceSeriesBinding, - "设备系列绑定", - resultStatus, - nil, - map[string]any{ - "devices": beforeData, - }, - map[string]any{ - "selection_type": selectionType, - "series_id": req.SeriesID, - "success_device_ids": successDeviceIDs, - "failed_items": failedItems, - }, - batchTotal, - len(successDeviceIDs), - len(failedItems), - resultErr, - ) - return &dto.BatchSetDeviceSeriesBindngResponse{ SuccessCount: len(successDeviceIDs), FailCount: len(failedItems), @@ -1615,40 +1343,12 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus userID := middleware.GetUserIDFromContext(ctx) if userID == 0 { - appErr := errors.New(errors.CodeUnauthorized, "未授权访问") - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceStop, - "设备停机被拒绝", - constants.AssetAuditResultDenied, - nil, - nil, - map[string]any{"device_id": deviceID}, - 0, - 0, - 0, - appErr, - ) - return nil, appErr + return nil, errors.New(errors.CodeUnauthorized, "未授权访问") } device, err := s.deviceStore.GetByID(ctx, deviceID) if err != nil { - appErr := errors.New(errors.CodeNotFound, "设备不存在") - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceStop, - "设备停机失败", - constants.AssetAuditResultFailed, - nil, - nil, - map[string]any{"device_id": deviceID}, - 0, - 0, - 0, - appErr, - ) - return nil, appErr + return nil, errors.New(errors.CodeNotFound, "设备不存在") } // 复机保护期内禁止停机 @@ -1656,19 +1356,8 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "start")).Result() if exists > 0 { appErr := errors.New(errors.CodeForbidden, "设备复机保护期内,禁止停机") - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceStop, - "设备停机被拒绝", - constants.AssetAuditResultDenied, - device, - map[string]any{"device": deviceSnapshot(device)}, - nil, - 0, - 0, - 0, - appErr, - ) + s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStopped, "停用设备绑定卡网络被拒绝", + constants.AuditResultDenied, device, nil, nil, nil, nil, appErr) return nil, appErr } } @@ -1676,40 +1365,15 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID) if err != nil { appErr := errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败") - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceStop, - "设备停机失败", - constants.AssetAuditResultFailed, - device, - map[string]any{"device": deviceSnapshot(device)}, - nil, - 0, - 0, - 0, - appErr, - ) + s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStopped, "停用设备绑定卡网络失败", + constants.AuditResultFailed, device, nil, nil, nil, nil, appErr) return nil, appErr } if len(bindings) == 0 { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceStop, - "设备停机", - constants.AssetAuditResultSuccess, - device, - map[string]any{"device": deviceSnapshot(device)}, - map[string]any{ - "success_count": 0, - "fail_count": 0, - "skip_count": 0, - }, - 0, - 0, - 0, - nil, - ) + s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStopped, "设备没有需要停用的绑定卡", + constants.AuditResultSuccess, device, nil, nil, nil, + map[string]any{"success_count": 0, "fail_count": 0, "skip_count": 0}, nil) return &dto.DeviceSuspendResponse{}, nil } @@ -1721,33 +1385,12 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs) if err != nil { appErr := errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败") - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceStop, - "设备停机失败", - constants.AssetAuditResultFailed, - device, - map[string]any{"device": deviceSnapshot(device)}, - nil, - len(cardIDs), - 0, - len(cardIDs), - appErr, - ) + s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStopped, "停用设备绑定卡网络失败", + constants.AuditResultFailed, device, nil, nil, nil, + map[string]any{"card_count": len(cardIDs)}, appErr) return nil, appErr } - beforeCards := make([]map[string]any, 0, len(cards)) - for _, card := range cards { - beforeCards = append(beforeCards, map[string]any{ - "id": card.ID, - "iccid": card.ICCID, - "network_status": card.NetworkStatus, - "real_name_status": card.RealNameStatus, - "stop_reason": card.StopReason, - }) - } - var successCount, skipCount int var failedItems []dto.DeviceSuspendFailItem @@ -1757,15 +1400,36 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus continue } + integrationID := "" + var observer *deviceGatewayAttemptObserver if s.gatewayClient != nil { + cardID := strconv.FormatUint(uint64(card.ID), 10) + observer = &deviceGatewayAttemptObserver{ + service: s, operation: constants.IntegrationOperationGatewayStopCard, + scene: constants.CardObservationSceneBusinessStop, seriesKey: deviceCommandSeriesKey(ctx), + resource: deviceGatewayResource{ + Type: constants.AuditResourceIotCard, ID: cardID, Key: auditinfra.IotCardResourceKey(card), ExternalID: card.ICCID, + RequestSummary: map[string]any{"device_id": device.ID, "iot_card_id": card.ID, "iccid": card.ICCID}, + }, + } log.Info("调用网关停机(设备)", zap.Uint("device_id", deviceID), zap.String("iccid", card.ICCID)) - if gwErr := s.gatewayClient.StopCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); gwErr != nil { + gwErr := s.gatewayClient.StopCard(gateway.WithAttemptObserver(ctx, observer), &gateway.CardOperationReq{CardNo: card.ICCID}) + integrationID = observer.integration + if gwErr != nil { log.Error("设备停机-调网关停机失败", zap.Uint("device_id", deviceID), zap.String("iccid", card.ICCID), zap.Error(gwErr)) + result := constants.AuditResultFailed + summary := "停用设备绑定卡网络失败" + if observer.unknown { + result = constants.AuditResultUnknown + summary = "停用设备绑定卡网络结果未知" + } + s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStopped, summary, result, + device, card, nil, nil, map[string]any{"integration_id": integrationID}, gwErr) failedItems = append(failedItems, dto.DeviceSuspendFailItem{ ICCID: card.ICCID, Reason: "网关停机失败", @@ -1778,20 +1442,35 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus } now := time.Now() - if dbErr := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{ + if dbErr := s.updateCardAndAppendNetworkSeries(ctx, device, card, map[string]any{ "network_status": constants.NetworkStatusOffline, "stopped_at": now, "stop_reason": constants.StopReasonManual, - }, constants.CardObservationSceneBusinessStop, "offline", s.gatewayClient != nil); dbErr != nil { + }, constants.CardObservationSceneBusinessStop, "offline", + constants.AuditActionDeviceStopped, "停用设备绑定卡网络", integrationID, s.gatewayClient != nil); dbErr != nil { + if observer != nil { + if logErr := observer.completeSuccess(ctx, false); logErr != nil { + log.Error("终结设备停机 Integration Log 失败", zap.String("integration_id", integrationID), zap.Error(logErr)) + } + } log.Error("设备停机-更新卡状态失败", zap.Uint("card_id", card.ID), zap.Error(dbErr)) + s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStopped, "停用设备绑定卡网络结果未知", + constants.AuditResultUnknown, device, card, nil, + map[string]any{"requested_network_status": constants.NetworkStatusOffline, "stop_reason": constants.StopReasonManual}, + map[string]any{"integration_id": integrationID}, dbErr) failedItems = append(failedItems, dto.DeviceSuspendFailItem{ ICCID: card.ICCID, Reason: "更新卡状态失败", }) continue } + if observer != nil { + if logErr := observer.completeSuccess(ctx, true); logErr != nil { + log.Error("终结设备停机 Integration Log 失败", zap.String("integration_id", integrationID), zap.Error(logErr)) + } + } s.invalidatePollingCardCache(card.ID) successCount++ @@ -1803,33 +1482,11 @@ func (s *Service) StopDevice(ctx context.Context, deviceID uint) (*dto.DeviceSus s.redis.Del(ctx, constants.RedisDeviceProtectKey(deviceID, "start")) } - resultStatus := constants.AssetAuditResultSuccess - var resultErr error - if successCount == 0 && len(failedItems) > 0 { - resultStatus = constants.AssetAuditResultFailed - resultErr = errors.New(errors.CodeGatewayError, "设备停机失败,未成功处理任何卡") + if successCount == 0 && len(failedItems) == 0 { + s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStopped, "设备绑定卡网络无需停用", + constants.AuditResultSuccess, device, nil, nil, nil, + map[string]any{"success_count": 0, "fail_count": 0, "skip_count": skipCount}, nil) } - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceStop, - "设备停机", - resultStatus, - device, - map[string]any{ - "device": deviceSnapshot(device), - "cards": beforeCards, - }, - map[string]any{ - "success_count": successCount, - "fail_count": len(failedItems), - "skip_count": skipCount, - "failed_items": failedItems, - }, - len(cards), - successCount, - len(failedItems), - resultErr, - ) return &dto.DeviceSuspendResponse{ SuccessCount: successCount, @@ -1847,40 +1504,12 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error { userID := middleware.GetUserIDFromContext(ctx) if userID == 0 { - appErr := errors.New(errors.CodeUnauthorized, "未授权访问") - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceStart, - "设备复机被拒绝", - constants.AssetAuditResultDenied, - nil, - nil, - map[string]any{"device_id": deviceID}, - 0, - 0, - 0, - appErr, - ) - return appErr + return errors.New(errors.CodeUnauthorized, "未授权访问") } device, err := s.deviceStore.GetByID(ctx, deviceID) if err != nil { - appErr := errors.New(errors.CodeNotFound, "设备不存在") - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceStart, - "设备复机失败", - constants.AssetAuditResultFailed, - nil, - nil, - map[string]any{"device_id": deviceID}, - 0, - 0, - 0, - appErr, - ) - return appErr + return errors.New(errors.CodeNotFound, "设备不存在") } // 停机保护期内禁止复机 @@ -1888,19 +1517,8 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error { exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(deviceID, "stop")).Result() if exists > 0 { appErr := errors.New(errors.CodeForbidden, "设备停机保护期内,禁止复机") - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceStart, - "设备复机被拒绝", - constants.AssetAuditResultDenied, - device, - map[string]any{"device": deviceSnapshot(device)}, - nil, - 0, - 0, - 0, - appErr, - ) + s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStarted, "恢复设备绑定卡网络被拒绝", + constants.AuditResultDenied, device, nil, nil, nil, nil, appErr) return appErr } } @@ -1908,39 +1526,15 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error { bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, deviceID) if err != nil { appErr := errors.Wrap(errors.CodeInternalError, err, "查询设备绑定卡失败") - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceStart, - "设备复机失败", - constants.AssetAuditResultFailed, - device, - map[string]any{"device": deviceSnapshot(device)}, - nil, - 0, - 0, - 0, - appErr, - ) + s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStarted, "恢复设备绑定卡网络失败", + constants.AuditResultFailed, device, nil, nil, nil, nil, appErr) return appErr } if len(bindings) == 0 { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceStart, - "设备复机", - constants.AssetAuditResultSuccess, - device, - map[string]any{"device": deviceSnapshot(device)}, - map[string]any{ - "success_count": 0, - "fail_count": 0, - }, - 0, - 0, - 0, - nil, - ) + s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStarted, "设备没有需要恢复的绑定卡", + constants.AuditResultSuccess, device, nil, nil, nil, + map[string]any{"success_count": 0, "fail_count": 0}, nil) return nil } @@ -1952,33 +1546,12 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error { cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs) if err != nil { appErr := errors.Wrap(errors.CodeInternalError, err, "查询卡信息失败") - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceStart, - "设备复机失败", - constants.AssetAuditResultFailed, - device, - map[string]any{"device": deviceSnapshot(device)}, - nil, - len(cardIDs), - 0, - len(cardIDs), - appErr, - ) + s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStarted, "恢复设备绑定卡网络失败", + constants.AuditResultFailed, device, nil, nil, nil, + map[string]any{"card_count": len(cardIDs)}, appErr) return appErr } - beforeCards := make([]map[string]any, 0, len(cards)) - for _, card := range cards { - beforeCards = append(beforeCards, map[string]any{ - "id": card.ID, - "iccid": card.ICCID, - "network_status": card.NetworkStatus, - "real_name_status": card.RealNameStatus, - "stop_reason": card.StopReason, - }) - } - var successCount int var failCount int var lastErr error @@ -1988,15 +1561,36 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error { continue } + integrationID := "" + var observer *deviceGatewayAttemptObserver if s.gatewayClient != nil { + cardID := strconv.FormatUint(uint64(card.ID), 10) + observer = &deviceGatewayAttemptObserver{ + service: s, operation: constants.IntegrationOperationGatewayStartCard, + scene: constants.CardObservationSceneBusinessResume, seriesKey: deviceCommandSeriesKey(ctx), + resource: deviceGatewayResource{ + Type: constants.AuditResourceIotCard, ID: cardID, Key: auditinfra.IotCardResourceKey(card), ExternalID: card.ICCID, + RequestSummary: map[string]any{"device_id": device.ID, "iot_card_id": card.ID, "iccid": card.ICCID}, + }, + } log.Info("调用网关复机(设备)", zap.Uint("device_id", deviceID), zap.String("iccid", card.ICCID)) - if gwErr := s.gatewayClient.StartCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); gwErr != nil { + gwErr := s.gatewayClient.StartCard(gateway.WithAttemptObserver(ctx, observer), &gateway.CardOperationReq{CardNo: card.ICCID}) + integrationID = observer.integration + if gwErr != nil { log.Error("设备复机-调网关复机失败", zap.Uint("device_id", deviceID), zap.String("iccid", card.ICCID), zap.Error(gwErr)) + result := constants.AuditResultFailed + summary := "恢复设备绑定卡网络失败" + if observer.unknown { + result = constants.AuditResultUnknown + summary = "恢复设备绑定卡网络结果未知" + } + s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStarted, summary, result, + device, card, nil, nil, map[string]any{"integration_id": integrationID}, gwErr) lastErr = gwErr failCount++ continue @@ -2007,18 +1601,33 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error { } now := time.Now() - if dbErr := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{ + if dbErr := s.updateCardAndAppendNetworkSeries(ctx, device, card, map[string]any{ "network_status": constants.NetworkStatusOnline, "resumed_at": now, "stop_reason": "", - }, constants.CardObservationSceneBusinessResume, "online", s.gatewayClient != nil); dbErr != nil { + }, constants.CardObservationSceneBusinessResume, "online", + constants.AuditActionDeviceStarted, "恢复设备绑定卡网络", integrationID, s.gatewayClient != nil); dbErr != nil { + if observer != nil { + if logErr := observer.completeSuccess(ctx, false); logErr != nil { + log.Error("终结设备复机 Integration Log 失败", zap.String("integration_id", integrationID), zap.Error(logErr)) + } + } log.Error("设备复机-更新卡状态失败", zap.Uint("card_id", card.ID), zap.Error(dbErr)) + s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStarted, "恢复设备绑定卡网络结果未知", + constants.AuditResultUnknown, device, card, nil, + map[string]any{"requested_network_status": constants.NetworkStatusOnline, "stop_reason": ""}, + map[string]any{"integration_id": integrationID}, dbErr) lastErr = dbErr failCount++ continue } + if observer != nil { + if logErr := observer.completeSuccess(ctx, true); logErr != nil { + log.Error("终结设备复机 Integration Log 失败", zap.String("integration_id", integrationID), zap.Error(logErr)) + } + } s.invalidatePollingCardCache(card.ID) successCount++ @@ -2033,153 +1642,48 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error { // 全部失败时返回 error if successCount == 0 && lastErr != nil { appErr := errors.Wrap(errors.CodeGatewayError, lastErr, "设备复机失败,所有卡均复机失败") - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceStart, - "设备复机失败", - constants.AssetAuditResultFailed, - device, - map[string]any{ - "device": deviceSnapshot(device), - "cards": beforeCards, - }, - map[string]any{ - "success_count": successCount, - "fail_count": failCount, - }, - len(cards), - successCount, - failCount, - appErr, - ) return appErr } - s.logDeviceOperation( - ctx, - constants.AssetAuditOpDeviceStart, - "设备复机", - constants.AssetAuditResultSuccess, - device, - map[string]any{ - "device": deviceSnapshot(device), - "cards": beforeCards, - }, - map[string]any{ - "success_count": successCount, - "fail_count": failCount, - }, - len(cards), - successCount, - failCount, - nil, - ) + if successCount == 0 && failCount == 0 { + s.recordDeviceCommandAudit(ctx, constants.AuditActionDeviceStarted, "设备绑定卡网络无需恢复", + constants.AuditResultSuccess, device, nil, nil, nil, + map[string]any{"success_count": 0, "fail_count": 0}, nil) + } return nil } // UpdateRealnamePolicy 更新设备的实名认证策略 func (s *Service) UpdateRealnamePolicy(ctx context.Context, deviceID uint, realnamePolicy string) error { - // 检查设备是否存在 - device, err := s.deviceStore.GetByID(ctx, deviceID) - if err != nil { - if err == gorm.ErrRecordNotFound { - appErr := errors.New(errors.CodeNotFound, "设备不存在") - s.logDeviceOperation( - ctx, - constants.AssetAuditOpAssetRealnamePolicy, - "更新设备实名策略失败", - constants.AssetAuditResultFailed, - nil, - nil, - map[string]any{ - "device_id": deviceID, - "realname_policy": realnamePolicy, - }, - 0, - 0, - 0, - appErr, - ) - return appErr + var device model.Device + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", deviceID).First(&device).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeNotFound, "设备不存在") + } + return errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败") } - appErr := errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败") - s.logDeviceOperation( - ctx, - constants.AssetAuditOpAssetRealnamePolicy, - "更新设备实名策略失败", - constants.AssetAuditResultFailed, - nil, - nil, - map[string]any{ - "device_id": deviceID, - "realname_policy": realnamePolicy, - }, - 0, - 0, - 0, - appErr, - ) - return appErr + changed := device.RealnamePolicy != realnamePolicy + if changed { + if err := tx.Model(&model.Device{}).Where("id = ?", deviceID).Update("realname_policy", realnamePolicy).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新实名认证策略失败") + } + } + summary := "更新设备实名策略" + if !changed { + summary = "确认设备实名策略无需变化" + } + return s.appendDeviceRealnamePolicyAudit(ctx, tx, summary, constants.AuditResultSuccess, + &device, map[string]any{"realname_policy": device.RealnamePolicy}, + map[string]any{"realname_policy": realnamePolicy, "status_changed": changed}, nil) + }) + if err != nil { + if device.ID > 0 { + s.recordDeviceRealnamePolicyFailure(ctx, &device, deviceID, err) + } + return err } - beforeData := map[string]any{ - "realname_policy": device.RealnamePolicy, - "device": deviceSnapshot(device), - } - - // 幂等检查 - if device.RealnamePolicy == realnamePolicy { - s.logDeviceOperation( - ctx, - constants.AssetAuditOpAssetRealnamePolicy, - "更新设备实名策略被拒绝", - constants.AssetAuditResultDenied, - device, - beforeData, - map[string]any{"realname_policy": realnamePolicy}, - 0, - 0, - 0, - errors.New(errors.CodeConflict, "实名认证策略未变化"), - ) - return nil - } - - // 更新数据库 - if err := s.deviceStore.UpdateRealnamePolicy(ctx, deviceID, realnamePolicy); err != nil { - appErr := errors.Wrap(errors.CodeDatabaseError, err, "更新实名认证策略失败") - s.logDeviceOperation( - ctx, - constants.AssetAuditOpAssetRealnamePolicy, - "更新设备实名策略失败", - constants.AssetAuditResultFailed, - device, - beforeData, - map[string]any{"realname_policy": realnamePolicy}, - 0, - 0, - 0, - appErr, - ) - return appErr - } - - s.logDeviceOperation( - ctx, - constants.AssetAuditOpAssetRealnamePolicy, - "更新设备实名策略", - constants.AssetAuditResultSuccess, - device, - beforeData, - map[string]any{ - "realname_policy": realnamePolicy, - }, - 0, - 0, - 0, - nil, - ) - return nil } @@ -2190,11 +1694,24 @@ func (s *Service) invalidatePollingCardCache(cardID uint) { _ = s.redis.Del(context.Background(), constants.RedisPollingCardInfoKey(cardID)).Err() } -func (s *Service) updateCardAndAppendNetworkSeries(ctx context.Context, cardID uint, fields map[string]any, scene, expected string, upstreamCalled bool) error { +func (s *Service) updateCardAndAppendNetworkSeries( + ctx context.Context, + device *model.Device, + card *model.IotCard, + fields map[string]any, + scene, expected, actionCode, summary, integrationID string, + upstreamCalled bool, +) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).Updates(fields).Error; err != nil { + if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(fields).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新设备绑定卡停复机状态失败") } + if err := s.appendDeviceCommandAudit(ctx, tx, actionCode, summary, constants.AuditResultSuccess, + device, card, + map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason}, + fields, map[string]any{"integration_id": integrationID}, nil); err != nil { + return err + } if !upstreamCalled || cardObservationApp.IsSeriesTriggerSuppressed(ctx) { return nil } @@ -2208,7 +1725,7 @@ func (s *Service) updateCardAndAppendNetworkSeries(ctx context.Context, cardID u } return s.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{ EventID: "card-observation:network-command:" + operationID, - Scene: scene, ResourceType: constants.CardObservationResourceTypeCard, ResourceID: cardID, + Scene: scene, ResourceType: constants.CardObservationResourceTypeCard, ResourceID: card.ID, SyncTypes: []string{constants.CardObservationSyncTypeNetwork}, ExpectedValue: expected, Source: constants.CardObservationSourceBusinessEvent, OccurredAt: time.Now().UTC(), RequestID: requestID, CorrelationID: requestID, diff --git a/internal/service/device/unified_audit.go b/internal/service/device/unified_audit.go new file mode 100644 index 0000000..a1c6b82 --- /dev/null +++ b/internal/service/device/unified_audit.go @@ -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) + } +} diff --git a/internal/service/device_import/audit.go b/internal/service/device_import/audit.go index 9b755f4..b90808b 100644 --- a/internal/service/device_import/audit.go +++ b/internal/service/device_import/audit.go @@ -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 +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 } - 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, + 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, } } diff --git a/internal/service/device_import/service.go b/internal/service/device_import/service.go index ba91890..17cb693 100644 --- a/internal/service/device_import/service.go +++ b/internal/service/device_import/service.go @@ -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 diff --git a/internal/service/exchange/audit.go b/internal/service/exchange/audit.go new file mode 100644 index 0000000..a866aa8 --- /dev/null +++ b/internal/service/exchange/audit.go @@ -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 + } +} diff --git a/internal/service/exchange/device_audit.go b/internal/service/exchange/device_audit.go new file mode 100644 index 0000000..71059a0 --- /dev/null +++ b/internal/service/exchange/device_audit.go @@ -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 +} diff --git a/internal/service/exchange/migration.go b/internal/service/exchange/migration.go index 59b78dc..51abcf4 100644 --- a/internal/service/exchange/migration.go +++ b/internal/service/exchange/migration.go @@ -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 { diff --git a/internal/service/exchange/service.go b/internal/service/exchange/service.go index cb4becd..6af1bc1 100644 --- a/internal/service/exchange/service.go +++ b/internal/service/exchange/service.go @@ -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) { diff --git a/internal/service/export_task/audit.go b/internal/service/export_task/audit.go new file mode 100644 index 0000000..e7b8127 --- /dev/null +++ b/internal/service/export_task/audit.go @@ -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, + } +} diff --git a/internal/service/export_task/service.go b/internal/service/export_task/service.go index de16be1..8829a22 100644 --- a/internal/service/export_task/service.go +++ b/internal/service/export_task/service.go @@ -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 := "取消请求已提交" - switch task.Status { - case constants.ExportTaskStatusPending: - ok, err := s.taskStore.CancelPendingTask(ctx, id, userID) - if err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "取消导出任务失败") - } - if !ok { - return nil, errors.New(errors.CodeInvalidStatus, "当前状态不支持取消") - } - message = "任务已取消" - case constants.ExportTaskStatusProcessing: - if !task.CancelRequested { - ok, err := s.taskStore.SetCancelRequested(ctx, id, userID) - if err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "提交取消请求失败") + 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, updateErr := txStore.CancelPendingTask(ctx, id, userID) + if updateErr != nil { + return updateErr } if !ok { - return nil, errors.New(errors.CodeInvalidStatus, "当前状态不支持取消") + return errors.New(errors.CodeInvalidStatus, "当前状态不支持取消") } - } else { - message = "取消请求已提交,请稍后刷新状态" + task.Status, task.CancelRequested, task.Progress = constants.ExportTaskStatusCancelled, true, 100 + message, changed = "任务已取消", true + case constants.ExportTaskStatusProcessing: + if task.CancelRequested { + message = "取消请求已提交,请稍后刷新状态" + return nil + } + ok, updateErr := txStore.SetCancelRequested(ctx, id, userID) + if updateErr != nil { + return updateErr + } + if !ok { + return errors.New(errors.CodeInvalidStatus, "当前状态不支持取消") + } + task.CancelRequested, changed = true, true + case constants.ExportTaskStatusCompleted, constants.ExportTaskStatusFailed, constants.ExportTaskStatusCancelled: + return errors.New(errors.CodeInvalidStatus, "当前状态不支持取消") + default: + return errors.New(errors.CodeInvalidStatus, "当前状态不支持取消") } - case constants.ExportTaskStatusCompleted, constants.ExportTaskStatusFailed, constants.ExportTaskStatusCancelled: - return nil, errors.New(errors.CodeInvalidStatus, "当前状态不支持取消") - default: - return nil, 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 } diff --git a/internal/service/iot_card/gateway_integration.go b/internal/service/iot_card/gateway_integration.go new file mode 100644 index 0000000..8ab1fda --- /dev/null +++ b/internal/service/iot_card/gateway_integration.go @@ -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 +} diff --git a/internal/service/iot_card/realname_policy_batch.go b/internal/service/iot_card/realname_policy_batch.go index 62bca37..d60e88e 100644 --- a/internal/service/iot_card/realname_policy_batch.go +++ b/internal/service/iot_card/realname_policy_batch.go @@ -5,7 +5,6 @@ import ( "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/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" @@ -22,8 +21,8 @@ func (s *Service) BatchUpdateRealnamePolicy(ctx context.Context, req *dto.BatchU if err != nil { return nil, err } + var cards []*model.IotCard err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var cards []model.IotCard query := middleware.ApplyShopFilter(ctx, tx.Model(&model.IotCard{})).Clauses(clause.Locking{Strength: "UPDATE"}) if err := query.Where("id IN ?", ids).Find(&cards).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询批量卡资产失败") @@ -31,29 +30,31 @@ func (s *Service) BatchUpdateRealnamePolicy(ctx context.Context, req *dto.BatchU if len(cards) != len(ids) { return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } - result := tx.Model(&model.IotCard{}).Where("id IN ?", ids).Update("realname_policy", req.RealnamePolicy) - if result.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量更新卡实名认证策略失败") + changedIDs := make([]uint, 0, len(cards)) + for _, card := range cards { + if card != nil && card.RealnamePolicy != req.RealnamePolicy { + changedIDs = append(changedIDs, card.ID) + } } - if result.RowsAffected != int64(len(ids)) { - return errors.New(errors.CodeConflict, "卡资产状态已变化,请刷新后重试") + if len(changedIDs) > 0 { + result := tx.Model(&model.IotCard{}).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(changedIDs)) { + return errors.New(errors.CodeConflict, "卡资产状态已变化,请刷新后重试") + } } - return nil + return s.appendCardRealnamePolicyBatchAudit(ctx, tx, cards, req.RealnamePolicy) }) if err != nil { + result := constants.AuditResultFailed + if appErr, ok := err.(*errors.AppError); ok && appErr.Code == errors.CodeForbidden { + result = constants.AuditResultDenied + } + s.recordCardRealnamePolicyBatchFailure(ctx, cards, req.RealnamePolicy, result, err) return nil, err } - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardRealnamePolicy, - OperationDesc: "批量更新卡实名认证策略", - ResultStatus: constants.AssetAuditResultSuccess, - BatchTotal: len(ids), - SuccessCount: len(ids), - AfterData: map[string]any{ - "asset_ids": ids, - "realname_policy": req.RealnamePolicy, - }, - }) return &dto.BatchUpdateAssetRealnamePolicyResponse{SuccessCount: len(ids), RealnamePolicy: req.RealnamePolicy}, nil } diff --git a/internal/service/iot_card/service.go b/internal/service/iot_card/service.go index 77c6fdb..1605e43 100644 --- a/internal/service/iot_card/service.go +++ b/internal/service/iot_card/service.go @@ -9,6 +9,7 @@ import ( cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation" "github.com/break/junhong_cmp_fiber/internal/gateway" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/infrastructure/cardtrafficlock" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/model/dto" @@ -16,6 +17,7 @@ import ( assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit" "github.com/break/junhong_cmp_fiber/internal/store" "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" "github.com/break/junhong_cmp_fiber/pkg/middleware" @@ -23,6 +25,7 @@ import ( "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" + "gorm.io/gorm/clause" ) // PollingCallback 轮询回调接口 @@ -73,6 +76,7 @@ type Service struct { observationSeries cardapp.BestEffortSeriesDispatcher trafficLock *cardtrafficlock.Lock speedTierIntegration speedTierIntegrationLog + auditWriter *audit.Writer } // SetObservationSeriesDispatcher 注入获取实名链接后的后台观测端口。 @@ -513,37 +517,11 @@ func (s *Service) toStandaloneResponse(card *model.IotCard, shopMap map[uint]str func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandaloneCardsRequest, operatorID uint, operatorShopID *uint) (*dto.AllocateStandaloneCardsResponse, error) { if err := s.validateDirectSubordinate(ctx, operatorShopID, req.ToShopID); err != nil { - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardAllocate, - OperationDesc: "单卡分配被拒绝", - ResultStatus: constants.AssetAuditResultDenied, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AfterData: map[string]any{ - "to_shop_id": req.ToShopID, - "selection_type": req.SelectionType, - "requested_count": len(req.ICCIDs), - }, - }) return nil, err } cards, err := s.getCardsForAllocation(ctx, req, operatorShopID) if err != nil { - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardAllocate, - OperationDesc: "单卡分配执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AfterData: map[string]any{ - "to_shop_id": req.ToShopID, - "selection_type": req.SelectionType, - "requested_count": len(req.ICCIDs), - }, - }) return nil, err } @@ -561,6 +539,12 @@ func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandalone boundCardIDs, err := s.iotCardStore.GetBoundCardIDs(ctx, s.extractCardIDs(cards)) if err != nil { + outcomes := cardAuditOutcomes(cards, constants.AuditResultFailed, "IoT 卡分配失败") + s.recordCardTransferAuditFailure(ctx, + constants.AuditActionIotCardAllocationBatch, constants.AuditActionIotCardAllocated, + "allocate", "批量分配 IoT 卡失败", constants.AuditResultFailed, + cards, outcomes, &req.ToShopID, constants.IotCardStatusDistributed, + len(cards), 0, len(cards), err) return nil, err } boundCardIDSet := make(map[uint]bool) @@ -599,19 +583,16 @@ func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandalone } if len(cardIDs) == 0 { - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardAllocate, - OperationDesc: "单卡分配被拒绝", - ResultStatus: constants.AssetAuditResultDenied, - ErrorMsg: "无可分配卡", - BatchTotal: len(cards), - FailCount: len(failedItems), - AfterData: map[string]any{ - "to_shop_id": req.ToShopID, - "failed_items": failedItems, - "selection_type": req.SelectionType, - }, - }) + denyErr := errors.New(errors.CodeInvalidStatus, "无可分配卡") + outcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "IoT 卡不可分配") + for _, item := range failedItems { + setCardAuditOutcomeByICCID(outcomes, cards, item.ICCID, constants.AuditResultDenied, item.Reason) + } + s.recordCardTransferAuditFailure(ctx, + constants.AuditActionIotCardAllocationBatch, constants.AuditActionIotCardAllocated, + "allocate", "批量分配 IoT 卡被拒绝", constants.AuditResultDenied, + cards, outcomes, &req.ToShopID, constants.IotCardStatusDistributed, + len(cards), 0, len(failedItems), denyErr) return &dto.AllocateStandaloneCardsResponse{ TotalCount: len(cards), SuccessCount: 0, @@ -623,6 +604,16 @@ func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandalone newStatus := constants.IotCardStatusDistributed toShopID := req.ToShopID allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate) + records := s.buildAllocationRecords(cards, cardIDs, operatorShopID, toShopID, operatorID, allocationNo, req.Remark) + outcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "IoT 卡不可分配") + for _, item := range failedItems { + setCardAuditOutcomeByICCID(outcomes, cards, item.ICCID, constants.AuditResultDenied, item.Reason) + } + setCardAuditOutcomes(outcomes, cardIDs, constants.AuditResultSuccess, "IoT 卡已分配") + auditResult := constants.AuditResultSuccess + if len(failedItems) > 0 { + auditResult = constants.AuditResultPartial + } err = s.db.Transaction(func(tx *gorm.DB) error { txIotCardStore := postgres.NewIotCardStore(tx, nil) @@ -632,26 +623,27 @@ func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandalone return err } - records := s.buildAllocationRecords(cards, cardIDs, operatorShopID, toShopID, operatorID, allocationNo, req.Remark) - return txRecordStore.BatchCreate(ctx, records) + if err := txRecordStore.BatchCreate(ctx, records); err != nil { + return err + } + return s.appendCardTransferAudit(ctx, tx, + constants.AuditActionIotCardAllocationBatch, constants.AuditActionIotCardAllocated, + "allocate", "批量分配 IoT 卡", auditResult, + cards, outcomes, records, &toShopID, newStatus, + len(cards), len(cardIDs), len(failedItems), nil) }) if err != nil { - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardAllocate, - OperationDesc: "单卡分配执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - BatchTotal: len(cards), - SuccessCount: len(cardIDs), - FailCount: len(failedItems), - AfterData: map[string]any{ - "to_shop_id": req.ToShopID, - "failed_items": failedItems, - }, - }) + failedOutcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "IoT 卡不可分配") + for _, item := range failedItems { + setCardAuditOutcomeByICCID(failedOutcomes, cards, item.ICCID, constants.AuditResultDenied, item.Reason) + } + setCardAuditOutcomes(failedOutcomes, cardIDs, constants.AuditResultFailed, "IoT 卡分配失败") + s.recordCardTransferAuditFailure(ctx, + constants.AuditActionIotCardAllocationBatch, constants.AuditActionIotCardAllocated, + "allocate", "批量分配 IoT 卡失败", constants.AuditResultFailed, + cards, failedOutcomes, &toShopID, newStatus, + len(cards), 0, len(cards), err) return nil, err } s.iotCardStore.InvalidateListCountCache(ctx) @@ -666,36 +658,6 @@ func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandalone }() } - shopMap := s.loadShopNames(ctx, cards) - targetShopName := s.getAuditShopName(ctx, &req.ToShopID) - beforeData := make([]map[string]any, 0, len(cards)) - for _, card := range cards { - beforeData = append(beforeData, map[string]any{ - "id": card.ID, - "iccid": card.ICCID, - "shop_id": card.ShopID, - "shop_name": shopMapValue(shopMap, card.ShopID), - "status": card.Status, - }) - } - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardAllocate, - OperationDesc: "单卡分配", - ResultStatus: constants.AssetAuditResultSuccess, - BatchTotal: len(cards), - SuccessCount: len(cardIDs), - FailCount: len(failedItems), - BeforeData: map[string]any{ - "cards": beforeData, - }, - AfterData: map[string]any{ - "to_shop_id": req.ToShopID, - "to_shop_name": targetShopName, - "new_status": newStatus, - "failed_items": failedItems, - }, - }) - return &dto.AllocateStandaloneCardsResponse{ TotalCount: len(cards), SuccessCount: len(cardIDs), @@ -709,18 +671,6 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard // 1. 查询卡列表 cards, err := s.getCardsForRecall(ctx, req) if err != nil { - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardRecall, - OperationDesc: "单卡回收执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AfterData: map[string]any{ - "selection_type": req.SelectionType, - "requested_count": len(req.ICCIDs), - }, - }) return nil, err } @@ -732,6 +682,11 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard FailedItems: []dto.AllocationFailedItem{}, }, nil } + newShopID := operatorShopID + newStatus := constants.IotCardStatusDistributed + if operatorShopID == nil { + newStatus = constants.IotCardStatusInStock + } // 2. 收集所有卡的店铺 ID,批量查询店铺信息以验证直属下级关系 shopIDSet := make(map[uint]bool) @@ -750,6 +705,11 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard if len(shopIDs) > 0 { shops, err := s.shopStore.GetByIDs(ctx, shopIDs) if err != nil { + outcomes := cardAuditOutcomes(cards, constants.AuditResultFailed, "IoT 卡回收失败") + s.recordCardTransferAuditFailure(ctx, + constants.AuditActionIotCardRecallBatch, constants.AuditActionIotCardRecalled, + "recall", "批量回收 IoT 卡失败", constants.AuditResultFailed, + cards, outcomes, newShopID, newStatus, len(cards), 0, len(cards), err) return nil, err } for _, shop := range shops { @@ -767,6 +727,11 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard boundCardIDs, err := s.iotCardStore.GetBoundCardIDs(ctx, s.extractCardIDs(cards)) if err != nil { + outcomes := cardAuditOutcomes(cards, constants.AuditResultFailed, "IoT 卡回收失败") + s.recordCardTransferAuditFailure(ctx, + constants.AuditActionIotCardRecallBatch, constants.AuditActionIotCardRecalled, + "recall", "批量回收 IoT 卡失败", constants.AuditResultFailed, + cards, outcomes, newShopID, newStatus, len(cards), 0, len(cards), err) return nil, err } boundCardIDSet := make(map[uint]bool) @@ -807,18 +772,16 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard } if len(cardIDs) == 0 { - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardRecall, - OperationDesc: "单卡回收被拒绝", - ResultStatus: constants.AssetAuditResultDenied, - ErrorMsg: "无可回收卡", - BatchTotal: len(cards), - FailCount: len(failedItems), - AfterData: map[string]any{ - "failed_items": failedItems, - "selection_type": req.SelectionType, - }, - }) + denyErr := errors.New(errors.CodeInvalidStatus, "无可回收卡") + outcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "IoT 卡不可回收") + for _, item := range failedItems { + setCardAuditOutcomeByICCID(outcomes, cards, item.ICCID, constants.AuditResultDenied, item.Reason) + } + s.recordCardTransferAuditFailure(ctx, + constants.AuditActionIotCardRecallBatch, constants.AuditActionIotCardRecalled, + "recall", "批量回收 IoT 卡被拒绝", constants.AuditResultDenied, + cards, outcomes, newShopID, newStatus, + len(cards), 0, len(failedItems), denyErr) return &dto.RecallStandaloneCardsResponse{ TotalCount: len(cards), SuccessCount: 0, @@ -828,18 +791,17 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard } // 6. 执行回收 - isPlatform := operatorShopID == nil - var newShopID *uint - var newStatus int - if isPlatform { - newShopID = nil - newStatus = constants.IotCardStatusInStock - } else { - newShopID = operatorShopID - newStatus = constants.IotCardStatusDistributed - } - allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeRecall) + records := s.buildRecallRecords(successCards, operatorShopID, operatorID, allocationNo, req.Remark) + outcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "IoT 卡不可回收") + for _, item := range failedItems { + setCardAuditOutcomeByICCID(outcomes, cards, item.ICCID, constants.AuditResultDenied, item.Reason) + } + setCardAuditOutcomes(outcomes, cardIDs, constants.AuditResultSuccess, "IoT 卡已回收") + auditResult := constants.AuditResultSuccess + if len(failedItems) > 0 { + auditResult = constants.AuditResultPartial + } err = s.db.Transaction(func(tx *gorm.DB) error { txIotCardStore := postgres.NewIotCardStore(tx, nil) @@ -849,25 +811,27 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard return err } - records := s.buildRecallRecords(successCards, operatorShopID, operatorID, allocationNo, req.Remark) - return txRecordStore.BatchCreate(ctx, records) + if err := txRecordStore.BatchCreate(ctx, records); err != nil { + return err + } + return s.appendCardTransferAudit(ctx, tx, + constants.AuditActionIotCardRecallBatch, constants.AuditActionIotCardRecalled, + "recall", "批量回收 IoT 卡", auditResult, + cards, outcomes, records, newShopID, newStatus, + len(cards), len(cardIDs), len(failedItems), nil) }) if err != nil { - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardRecall, - OperationDesc: "单卡回收执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - BatchTotal: len(cards), - SuccessCount: len(cardIDs), - FailCount: len(failedItems), - AfterData: map[string]any{ - "failed_items": failedItems, - }, - }) + failedOutcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "IoT 卡不可回收") + for _, item := range failedItems { + setCardAuditOutcomeByICCID(failedOutcomes, cards, item.ICCID, constants.AuditResultDenied, item.Reason) + } + setCardAuditOutcomes(failedOutcomes, cardIDs, constants.AuditResultFailed, "IoT 卡回收失败") + s.recordCardTransferAuditFailure(ctx, + constants.AuditActionIotCardRecallBatch, constants.AuditActionIotCardRecalled, + "recall", "批量回收 IoT 卡失败", constants.AuditResultFailed, + cards, failedOutcomes, newShopID, newStatus, + len(cards), 0, len(cards), err) return nil, err } s.iotCardStore.InvalidateListCountCache(ctx) @@ -882,36 +846,6 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard }() } - shopMap := s.loadShopNames(ctx, successCards) - targetShopName := s.getAuditRecallTargetShopName(ctx, newShopID) - beforeData := make([]map[string]any, 0, len(successCards)) - for _, card := range successCards { - beforeData = append(beforeData, map[string]any{ - "id": card.ID, - "iccid": card.ICCID, - "shop_id": card.ShopID, - "shop_name": shopMapValue(shopMap, card.ShopID), - "status": card.Status, - }) - } - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardRecall, - OperationDesc: "单卡回收", - ResultStatus: constants.AssetAuditResultSuccess, - BatchTotal: len(cards), - SuccessCount: len(cardIDs), - FailCount: len(failedItems), - BeforeData: map[string]any{ - "cards": beforeData, - }, - AfterData: map[string]any{ - "to_shop_id": newShopID, - "target_shop_name": targetShopName, - "new_status": newStatus, - "failed_items": failedItems, - }, - }) - return &dto.RecallStandaloneCardsResponse{ TotalCount: len(cards), SuccessCount: len(cardIDs), @@ -958,31 +892,6 @@ func (s *Service) validateDirectSubordinate(ctx context.Context, operatorShopID return nil } -func (s *Service) getAuditShopName(ctx context.Context, shopID *uint) string { - if shopID == nil || *shopID == 0 { - return "" - } - var shop model.Shop - if err := s.db.WithContext(ctx).Unscoped().First(&shop, *shopID).Error; err != nil { - return "" - } - return shop.ShopName -} - -func (s *Service) getAuditRecallTargetShopName(ctx context.Context, shopID *uint) string { - if shopID == nil { - return "平台库存" - } - return s.getAuditShopName(ctx, shopID) -} - -func shopMapValue(shopMap map[uint]string, shopID *uint) string { - if shopID == nil { - return "" - } - return shopMap[*shopID] -} - func (s *Service) getCardsForAllocation(ctx context.Context, req *dto.AllocateStandaloneCardsRequest, operatorShopID *uint) ([]*model.IotCard, error) { switch req.SelectionType { case dto.SelectionTypeList: @@ -1114,16 +1023,6 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCa batchTotal := cardSeriesBindingBatchTotal(req, selectionType, cards) auditData := cardSeriesBindingAuditData(req, selectionType) if err != nil { - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardSeriesBinding, - OperationDesc: "卡系列绑定执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - BatchTotal: batchTotal, - AfterData: auditData, - }) return nil, err } @@ -1131,15 +1030,6 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCa failedItems := []dto.CardSeriesBindngFailedItem{} if selectionType == dto.SelectionTypeList { failedItems = s.buildCardNotFoundFailedItems(req.ICCIDs) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardSeriesBinding, - OperationDesc: "卡系列绑定被拒绝", - ResultStatus: constants.AssetAuditResultDenied, - ErrorMsg: "卡不存在", - BatchTotal: batchTotal, - FailCount: len(failedItems), - AfterData: auditData, - }) } return &dto.BatchSetCardSeriesBindngResponse{ SuccessCount: 0, @@ -1156,42 +1046,24 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCa if err != nil { if err == gorm.ErrRecordNotFound { denyErr := errors.New(errors.CodeNotFound, "套餐系列不存在或已禁用") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardSeriesBinding, - OperationDesc: "卡系列绑定被拒绝", - ResultStatus: constants.AssetAuditResultDenied, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - BatchTotal: batchTotal, - AfterData: auditData, - }) + outcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "套餐系列不存在或已禁用") + targetSeriesID := req.SeriesID + s.recordCardSeriesBindingAuditFailure(ctx, cards, outcomes, &targetSeriesID, + constants.AuditResultDenied, batchTotal, 0, batchTotal, auditData, denyErr) return nil, denyErr } - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardSeriesBinding, - OperationDesc: "卡系列绑定执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - BatchTotal: batchTotal, - AfterData: auditData, - }) + outcomes := cardAuditOutcomes(cards, constants.AuditResultFailed, "IoT 卡系列绑定失败") + targetSeriesID := req.SeriesID + s.recordCardSeriesBindingAuditFailure(ctx, cards, outcomes, &targetSeriesID, + constants.AuditResultFailed, batchTotal, 0, batchTotal, auditData, err) return nil, err } if packageSeries.Status != 1 { denyErr := errors.New(errors.CodeInvalidParam, "套餐系列不存在或已禁用") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardSeriesBinding, - OperationDesc: "卡系列绑定被拒绝", - ResultStatus: constants.AssetAuditResultDenied, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - BatchTotal: batchTotal, - AfterData: auditData, - }) + outcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "套餐系列不存在或已禁用") + targetSeriesID := req.SeriesID + s.recordCardSeriesBindingAuditFailure(ctx, cards, outcomes, &targetSeriesID, + constants.AuditResultDenied, batchTotal, 0, batchTotal, auditData, denyErr) return nil, denyErr } } @@ -1204,6 +1076,10 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCa if operatorShopID != nil && req.SeriesID > 0 { hasSeriesAllocation, err = s.hasAvailableSeriesAllocation(ctx, *operatorShopID, req.SeriesID) if err != nil { + outcomes := cardAuditOutcomes(cards, constants.AuditResultFailed, "IoT 卡系列绑定失败") + targetSeriesID := req.SeriesID + s.recordCardSeriesBindingAuditFailure(ctx, cards, outcomes, &targetSeriesID, + constants.AuditResultFailed, batchTotal, 0, batchTotal, auditData, err) return nil, err } } @@ -1251,75 +1127,54 @@ func (s *Service) BatchSetSeriesBinding(ctx context.Context, req *dto.BatchSetCa } } + if len(successCardIDs) == 0 && len(failedItems) > 0 { + denyErr := errors.New(errors.CodeInvalidStatus, "无可操作卡") + outcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "无权设置 IoT 卡系列绑定") + for _, item := range failedItems { + setCardAuditOutcomeByICCID(outcomes, cards, item.ICCID, constants.AuditResultDenied, item.Reason) + } + var seriesIDPtr *uint + if req.SeriesID > 0 { + seriesIDPtr = &req.SeriesID + } + s.recordCardSeriesBindingAuditFailure(ctx, cards, outcomes, seriesIDPtr, + constants.AuditResultDenied, batchTotal, 0, len(failedItems), auditData, denyErr) + } + if len(successCardIDs) > 0 { var seriesIDPtr *uint if req.SeriesID > 0 { seriesIDPtr = &req.SeriesID } - if err := s.iotCardStore.BatchUpdateSeriesID(ctx, successCardIDs, seriesIDPtr); err != nil { - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardSeriesBinding, - OperationDesc: "卡系列绑定执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - BatchTotal: batchTotal, - SuccessCount: len(successCardIDs), - FailCount: len(failedItems), - AfterData: map[string]any{ - "selection_type": selectionType, - "series_id": req.SeriesID, - "success_ids": successCardIDs, - "failed_items": failedItems, - }, - }) + outcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "IoT 卡系列绑定被拒绝") + for _, item := range failedItems { + setCardAuditOutcomeByICCID(outcomes, cards, item.ICCID, constants.AuditResultDenied, item.Reason) + } + setCardAuditOutcomes(outcomes, successCardIDs, constants.AuditResultSuccess, "IoT 卡系列绑定已更新") + auditResult := constants.AuditResultSuccess + if len(failedItems) > 0 { + auditResult = constants.AuditResultPartial + } + err = s.db.Transaction(func(tx *gorm.DB) error { + txIotCardStore := postgres.NewIotCardStore(tx, nil) + if err := txIotCardStore.BatchUpdateSeriesID(ctx, successCardIDs, seriesIDPtr); err != nil { + return err + } + return s.appendCardSeriesBindingAudit(ctx, tx, cards, outcomes, seriesIDPtr, auditResult, + batchTotal, len(successCardIDs), len(failedItems), auditData, nil) + }) + if err != nil { + failedOutcomes := cardAuditOutcomes(cards, constants.AuditResultDenied, "IoT 卡系列绑定被拒绝") + for _, item := range failedItems { + setCardAuditOutcomeByICCID(failedOutcomes, cards, item.ICCID, constants.AuditResultDenied, item.Reason) + } + setCardAuditOutcomes(failedOutcomes, successCardIDs, constants.AuditResultFailed, "IoT 卡系列绑定失败") + s.recordCardSeriesBindingAuditFailure(ctx, cards, failedOutcomes, seriesIDPtr, + constants.AuditResultFailed, batchTotal, 0, batchTotal, auditData, err) return nil, err } } - resultStatus := constants.AssetAuditResultSuccess - operationDesc := "批量设置卡系列绑定" - errorMsg := "" - if len(successCardIDs) == 0 && len(failedItems) > 0 { - resultStatus = constants.AssetAuditResultDenied - operationDesc = "卡系列绑定被拒绝" - errorMsg = "无可操作卡" - } - beforeCards := make([]map[string]any, 0, len(successCardIDs)) - successSet := make(map[uint]struct{}, len(successCardIDs)) - for _, id := range successCardIDs { - successSet[id] = struct{}{} - } - for _, card := range cards { - if _, ok := successSet[card.ID]; !ok { - continue - } - beforeCards = append(beforeCards, map[string]any{ - "id": card.ID, - "iccid": card.ICCID, - "series_id": card.SeriesID, - }) - } - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardSeriesBinding, - OperationDesc: operationDesc, - ResultStatus: resultStatus, - ErrorMsg: errorMsg, - BatchTotal: batchTotal, - SuccessCount: len(successCardIDs), - FailCount: len(failedItems), - BeforeData: map[string]any{ - "cards": beforeCards, - }, - AfterData: map[string]any{ - "selection_type": selectionType, - "series_id": req.SeriesID, - "success_ids": successCardIDs, - "failed_items": failedItems, - }, - }) - return &dto.BatchSetCardSeriesBindngResponse{ SuccessCount: len(successCardIDs), FailCount: len(failedItems), @@ -1544,111 +1399,268 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string) lockToken, locked, lockErr := s.acquireCardTrafficSyncLock(ctx, card.ID) if lockErr != nil { - return errors.Wrap(errors.CodeInternalError, lockErr, "获取卡流量同步锁失败") + wrapErr := errors.Wrap(errors.CodeInternalError, lockErr, "获取卡流量同步锁失败") + s.recordCardRefreshFailure(ctx, card, constants.AuditResultFailed, wrapErr) + return wrapErr } else if !locked { - return errors.New(errors.CodeTooManyRequests, "卡流量正在同步,请稍后重试") + denyErr := errors.New(errors.CodeTooManyRequests, "卡流量正在同步,请稍后重试") + s.recordCardRefreshFailure(ctx, card, constants.AuditResultDenied, denyErr) + return denyErr } else { defer s.releaseCardTrafficSyncLock(ctx, card.ID, lockToken) latestCard, loadErr := s.iotCardStore.GetByID(ctx, card.ID) if loadErr != nil { - return errors.Wrap(errors.CodeInternalError, loadErr, "刷新卡数据失败") + wrapErr := errors.Wrap(errors.CodeInternalError, loadErr, "刷新卡数据失败") + s.recordCardRefreshFailure(ctx, card, constants.AuditResultFailed, wrapErr) + return wrapErr } card = latestCard } syncTime := time.Now() - updates := map[string]any{ - "last_sync_time": syncTime, - } var flowIncrementMB float64 + var refreshSuccess, refreshFailed, refreshUnknown int + seriesKey := requestIDFromContext(ctx) + if seriesKey == "" { + seriesKey = uuid.NewString() + } if s.gatewayClient != nil { // 1. 查询网络状态(卡的开/停机状态) - statusResp, err := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{ - CardNo: iccid, - }) - if err != nil { - s.logger.Warn("刷新卡数据:查询网络状态失败", zap.String("iccid", iccid), zap.Error(err)) - } else if strings.TrimSpace(statusResp.ICCID) == "" { - s.logger.Warn("刷新卡数据:网络状态响应缺少 ICCID,跳过本次网络观测", zap.Uint("card_id", card.ID)) - } else if s.cardObservation == nil { - return errors.New(errors.CodeInternalError, "卡网络观测能力未配置") - } else { - requestID := requestIDFromContext(ctx) - decision, applyErr := s.cardObservation.ApplyNetworkObservation(ctx, carddomain.NetworkObservation{ - CardID: card.ID, GatewayStatus: statusResp.CardStatus, - GatewayExtend: statusResp.Extend, GatewayIMEI: statusResp.IMEI, - Metadata: carddomain.ObservationMetadata{ - ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualSync, - Scene: constants.CardObservationSceneManualRefresh, ObservedAt: syncTime, - RequestID: requestID, CorrelationID: requestID, - }, - }) - if applyErr != nil { - return applyErr + statusObserver := &gatewayCardAttemptObserver{service: s, card: card, + operation: constants.IntegrationOperationGatewayNetwork, scene: constants.CardObservationSceneManualRefresh, seriesKey: seriesKey} + statusResp, callErr := s.gatewayClient.QueryCardStatus(gateway.WithAttemptObserver(ctx, statusObserver), &gateway.CardStatusReq{CardNo: iccid}) + statusAttempt := statusObserver.successful + if statusObserver.recordingErr != nil && statusObserver.lastCallErr == nil { + if statusObserver.unknown { + refreshUnknown++ + } else { + refreshFailed++ } - if !decision.StatusKnown { - s.logger.Warn("刷新卡数据:未知 Gateway 卡状态", - zap.String("iccid", iccid), - zap.String("card_status", statusResp.CardStatus), - zap.String("extend", strings.TrimSpace(statusResp.Extend))) + s.logger.Warn("刷新卡数据:记录网络状态查询尝试失败,跳过外呼", zap.String("iccid", iccid), zap.Error(statusObserver.recordingErr)) + } else if statusObserver.recordingErr != nil { + wrapErr := errors.Wrap(errors.CodeDatabaseError, statusObserver.recordingErr, "终结网络状态查询记录失败") + result := constants.AuditResultFailed + if statusObserver.unknown { + result = constants.AuditResultUnknown + } + s.recordCardRefreshFailure(ctx, card, result, wrapErr) + return wrapErr + } else { + if callErr != nil { + if statusAttempt != nil { + if logErr := s.completeCardRefreshAttempt(ctx, card, statusAttempt, callErr, false, "终结网络状态查询记录失败"); logErr != nil { + return logErr + } + } + if statusObserver.unknown { + refreshUnknown++ + } else { + refreshFailed++ + } + s.logger.Warn("刷新卡数据:查询网络状态失败", zap.String("iccid", iccid), zap.Error(callErr)) + } else if strings.TrimSpace(statusResp.ICCID) == "" { + refreshFailed++ + invalidRespErr := errors.New(errors.CodeGatewayInvalidResp, "Gateway 网络状态响应缺少 ICCID") + if logErr := s.completeCardRefreshAttempt(ctx, card, statusAttempt, invalidRespErr, false, "终结网络状态查询记录失败"); logErr != nil { + return logErr + } + s.logger.Warn("刷新卡数据:网络状态响应缺少 ICCID,跳过本次网络观测", zap.Uint("card_id", card.ID)) + } else if s.cardObservation == nil { + configErr := errors.New(errors.CodeInternalError, "卡网络观测能力未配置") + if logErr := s.completeCardRefreshAttempt(ctx, card, statusAttempt, nil, false, "终结网络状态查询记录失败"); logErr != nil { + return logErr + } + s.recordCardRefreshFailure(ctx, card, constants.AuditResultFailed, configErr) + return configErr + } else { + requestID := requestIDFromContext(ctx) + decision, applyErr := s.cardObservation.ApplyNetworkObservation(ctx, carddomain.NetworkObservation{ + CardID: card.ID, GatewayStatus: statusResp.CardStatus, + GatewayExtend: statusResp.Extend, GatewayIMEI: statusResp.IMEI, + Metadata: carddomain.ObservationMetadata{ + ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualSync, + Scene: constants.CardObservationSceneManualRefresh, ObservedAt: syncTime, + RequestID: requestID, CorrelationID: requestID, + }, + }) + if applyErr != nil { + if logErr := s.completeCardRefreshAttempt(ctx, card, statusAttempt, nil, false, "终结网络状态查询记录失败"); logErr != nil { + return logErr + } + s.recordCardRefreshFailure(ctx, card, constants.AuditResultFailed, applyErr) + return applyErr + } + stateChanged := decision.StatusChanged || decision.StopReasonChanged || decision.StopPolling || + decision.GatewayExtend != card.GatewayExtend || decision.UpdateIMEI && decision.GatewayIMEI != card.GatewayCardIMEI + if logErr := s.completeCardRefreshAttempt(ctx, card, statusAttempt, nil, stateChanged, "终结网络状态查询记录失败"); logErr != nil { + return logErr + } + if !decision.StatusKnown { + refreshUnknown++ + s.logger.Warn("刷新卡数据:未知 Gateway 卡状态", + zap.String("iccid", iccid), + zap.String("card_status", statusResp.CardStatus), + zap.String("extend", strings.TrimSpace(statusResp.Extend))) + } else { + refreshSuccess++ + } } } // 2. 查询实名状态 - realnameResp, err := s.gatewayClient.QueryRealnameStatus(ctx, &gateway.CardStatusReq{ - CardNo: iccid, - }) - if err != nil { - s.logger.Warn("刷新卡数据:查询实名状态失败", zap.String("iccid", iccid), zap.Error(err)) - } else if strings.TrimSpace(realnameResp.ICCID) == "" { - s.logger.Warn("刷新卡数据:实名响应缺少 ICCID,跳过本次实名观测", zap.Uint("card_id", card.ID)) - } else if s.cardObservation == nil { - return errors.New(errors.CodeInternalError, "卡实名观测能力未配置") + realnameObserver := &gatewayCardAttemptObserver{service: s, card: card, + operation: constants.IntegrationOperationGatewayRealname, scene: constants.CardObservationSceneManualRefresh, seriesKey: seriesKey} + realnameResp, callErr := s.gatewayClient.QueryRealnameStatus(gateway.WithAttemptObserver(ctx, realnameObserver), &gateway.CardStatusReq{CardNo: iccid}) + realnameAttempt := realnameObserver.successful + if realnameObserver.recordingErr != nil && realnameObserver.lastCallErr == nil { + if realnameObserver.unknown { + refreshUnknown++ + } else { + refreshFailed++ + } + s.logger.Warn("刷新卡数据:记录实名状态查询尝试失败,跳过外呼", zap.String("iccid", iccid), zap.Error(realnameObserver.recordingErr)) + } else if realnameObserver.recordingErr != nil { + wrapErr := errors.Wrap(errors.CodeDatabaseError, realnameObserver.recordingErr, "终结实名状态查询记录失败") + result := constants.AuditResultFailed + if realnameObserver.unknown { + result = constants.AuditResultUnknown + } + s.recordCardRefreshFailure(ctx, card, result, wrapErr) + return wrapErr } else { - requestID := requestIDFromContext(ctx) - _, applyErr := s.cardObservation.ApplyCardObservation(ctx, carddomain.RealnameObservation{ - CardID: card.ID, - Verified: realnameResp.RealStatus, - Metadata: carddomain.ObservationMetadata{ - ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualSync, - Scene: constants.CardObservationSceneManualRefresh, ObservedAt: syncTime, - RequestID: requestID, CorrelationID: requestID, - }, - }) - if applyErr != nil { - return applyErr + if callErr != nil { + if realnameAttempt != nil { + if logErr := s.completeCardRefreshAttempt(ctx, card, realnameAttempt, callErr, false, "终结实名状态查询记录失败"); logErr != nil { + return logErr + } + } + if realnameObserver.unknown { + refreshUnknown++ + } else { + refreshFailed++ + } + s.logger.Warn("刷新卡数据:查询实名状态失败", zap.String("iccid", iccid), zap.Error(callErr)) + } else if strings.TrimSpace(realnameResp.ICCID) == "" { + refreshFailed++ + invalidRespErr := errors.New(errors.CodeGatewayInvalidResp, "Gateway 实名状态响应缺少 ICCID") + if logErr := s.completeCardRefreshAttempt(ctx, card, realnameAttempt, invalidRespErr, false, "终结实名状态查询记录失败"); logErr != nil { + return logErr + } + s.logger.Warn("刷新卡数据:实名响应缺少 ICCID,跳过本次实名观测", zap.Uint("card_id", card.ID)) + } else if s.cardObservation == nil { + configErr := errors.New(errors.CodeInternalError, "卡实名观测能力未配置") + if logErr := s.completeCardRefreshAttempt(ctx, card, realnameAttempt, nil, false, "终结实名状态查询记录失败"); logErr != nil { + return logErr + } + s.recordCardRefreshFailure(ctx, card, constants.AuditResultFailed, configErr) + return configErr + } else { + requestID := requestIDFromContext(ctx) + decision, applyErr := s.cardObservation.ApplyCardObservation(ctx, carddomain.RealnameObservation{ + CardID: card.ID, + Verified: realnameResp.RealStatus, + Metadata: carddomain.ObservationMetadata{ + ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualSync, + Scene: constants.CardObservationSceneManualRefresh, ObservedAt: syncTime, + RequestID: requestID, CorrelationID: requestID, + }, + }) + if applyErr != nil { + if logErr := s.completeCardRefreshAttempt(ctx, card, realnameAttempt, nil, false, "终结实名状态查询记录失败"); logErr != nil { + return logErr + } + s.recordCardRefreshFailure(ctx, card, constants.AuditResultFailed, applyErr) + return applyErr + } + if logErr := s.completeCardRefreshAttempt(ctx, card, realnameAttempt, nil, decision.StatusChanged, "终结实名状态查询记录失败"); logErr != nil { + return logErr + } + refreshSuccess++ } } // 3. 查询本月流量用量 — 使用增量计算(与轮询 calculateFlowUpdates 逻辑一致) - flowResp, err := s.gatewayClient.QueryFlow(ctx, &gateway.FlowQueryReq{ - CardNo: iccid, - }) - if err != nil { - s.logger.Warn("刷新卡数据:查询流量失败", zap.String("iccid", iccid), zap.Error(err)) - } else if s.cardObservation == nil { - return errors.New(errors.CodeInternalError, "卡流量观测能力未配置") - } else { - requestID := requestIDFromContext(ctx) - resetDay := postgres.NewCarrierStore(s.db).GetDataResetDay(ctx, card.CarrierID) - decision, applyErr := s.cardObservation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{ - CardID: card.ID, GatewayReadingMB: float64(flowResp.Used), ResetDay: resetDay, - Metadata: carddomain.ObservationMetadata{ - ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualSync, - Scene: constants.CardObservationSceneManualRefresh, ObservedAt: syncTime, - RequestID: requestID, CorrelationID: requestID, - }, - }) - if applyErr != nil { - return applyErr + flowObserver := &gatewayCardAttemptObserver{service: s, card: card, + operation: constants.IntegrationOperationGatewayTraffic, scene: constants.CardObservationSceneManualRefresh, seriesKey: seriesKey} + flowResp, callErr := s.gatewayClient.QueryFlow(gateway.WithAttemptObserver(ctx, flowObserver), &gateway.FlowQueryReq{CardNo: iccid}) + flowAttempt := flowObserver.successful + if flowObserver.recordingErr != nil && flowObserver.lastCallErr == nil { + if flowObserver.unknown { + refreshUnknown++ + } else { + refreshFailed++ + } + s.logger.Warn("刷新卡数据:记录流量查询尝试失败,跳过外呼", zap.String("iccid", iccid), zap.Error(flowObserver.recordingErr)) + } else if flowObserver.recordingErr != nil { + wrapErr := errors.Wrap(errors.CodeDatabaseError, flowObserver.recordingErr, "终结流量查询记录失败") + result := constants.AuditResultFailed + if flowObserver.unknown { + result = constants.AuditResultUnknown + } + s.recordCardRefreshFailure(ctx, card, result, wrapErr) + return wrapErr + } else { + if callErr != nil { + if flowAttempt != nil { + if logErr := s.completeCardRefreshAttempt(ctx, card, flowAttempt, callErr, false, "终结流量查询记录失败"); logErr != nil { + return logErr + } + } + if flowObserver.unknown { + refreshUnknown++ + } else { + refreshFailed++ + } + s.logger.Warn("刷新卡数据:查询流量失败", zap.String("iccid", iccid), zap.Error(callErr)) + } else if s.cardObservation == nil { + configErr := errors.New(errors.CodeInternalError, "卡流量观测能力未配置") + if logErr := s.completeCardRefreshAttempt(ctx, card, flowAttempt, nil, false, "终结流量查询记录失败"); logErr != nil { + return logErr + } + s.recordCardRefreshFailure(ctx, card, constants.AuditResultFailed, configErr) + return configErr + } else { + requestID := requestIDFromContext(ctx) + resetDay := postgres.NewCarrierStore(s.db).GetDataResetDay(ctx, card.CarrierID) + decision, applyErr := s.cardObservation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{ + CardID: card.ID, GatewayReadingMB: float64(flowResp.Used), ResetDay: resetDay, + Metadata: carddomain.ObservationMetadata{ + ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualSync, + Scene: constants.CardObservationSceneManualRefresh, ObservedAt: syncTime, + RequestID: requestID, CorrelationID: requestID, + }, + }) + if applyErr != nil { + if logErr := s.completeCardRefreshAttempt(ctx, card, flowAttempt, nil, false, "终结流量查询记录失败"); logErr != nil { + return logErr + } + s.recordCardRefreshFailure(ctx, card, constants.AuditResultFailed, applyErr) + return applyErr + } + stateChanged := decision.IncrementMB != 0 || decision.CrossMonth || decision.LastGatewayReadingMB != card.LastGatewayReadingMB + if logErr := s.completeCardRefreshAttempt(ctx, card, flowAttempt, nil, stateChanged, "终结流量查询记录失败"); logErr != nil { + return logErr + } + refreshSuccess++ + flowIncrementMB = decision.IncrementMB } - flowIncrementMB = decision.IncrementMB } } - if err := s.iotCardStore.UpdateFields(ctx, card.ID, updates); err != nil { - return errors.Wrap(errors.CodeInternalError, err, "更新卡数据失败") + refreshResult, refreshSummary := constants.AuditResultSuccess, "人工刷新 IoT 卡" + switch { + case s.gatewayClient == nil || refreshSuccess == 0 && refreshUnknown == 0: + refreshResult, refreshSummary = constants.AuditResultFailed, "人工刷新 IoT 卡未完成" + case refreshUnknown > 0: + refreshResult, refreshSummary = constants.AuditResultUnknown, "人工刷新 IoT 卡结果待核对" + case refreshFailed > 0: + refreshResult, refreshSummary = constants.AuditResultPartial, "人工刷新 IoT 卡部分完成" + } + if err := s.updateCardRefreshCompletion(ctx, card, syncTime, refreshResult, refreshSummary); err != nil { + wrapErr := errors.Wrap(errors.CodeInternalError, err, "更新卡数据失败") + s.recordCardRefreshFailure(ctx, card, constants.AuditResultFailed, wrapErr) + return wrapErr } // 失效轮询缓存,确保轮询系统读到最新的 network_status/real_name_status/流量 @@ -1656,10 +1668,17 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string) s.pollingCallback.OnCardStatusChanged(ctx, card.ID) } - s.logger.Info("刷新卡数据成功", + s.logger.Info("刷新卡数据完成", zap.String("iccid", iccid), zap.Uint("card_id", card.ID), + zap.String("result", refreshResult), zap.Float64("flow_increment_mb", flowIncrementMB)) + if refreshResult == constants.AuditResultFailed { + return errors.New(errors.CodeGatewayError, "刷新卡数据未完成") + } + if refreshResult == constants.AuditResultUnknown { + return errors.New(errors.CodeGatewayTimeout, "刷新卡数据结果待核对") + } return nil } @@ -1840,51 +1859,38 @@ func (s *Service) BatchUpdatePollingStatus(ctx context.Context, cardIDs []uint, // DeleteCard 删除卡(软删除) func (s *Service) DeleteCard(ctx context.Context, cardID uint) error { + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "IoT 卡统一审计接缝未配置") + } card, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { if err == gorm.ErrRecordNotFound { denyErr := errors.New(errors.CodeNotFound, "IoT卡不存在") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardDelete, - OperationDesc: "删除卡被拒绝", - ResultStatus: constants.AssetAuditResultDenied, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: cardID, - }) + s.recordCardLifecycleFailure(ctx, constants.AuditActionIotCardDeleted, "删除 IoT 卡被拒绝", constants.AuditResultDenied, nil, cardID, denyErr) return denyErr } - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardDelete, - OperationDesc: "删除卡执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: cardID, - }) + s.recordCardLifecycleFailure(ctx, constants.AuditActionIotCardDeleted, "删除 IoT 卡失败", constants.AuditResultFailed, nil, cardID, err) return err } - if err := s.iotCardStore.Delete(ctx, cardID); err != nil { - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardDelete, - OperationDesc: "删除卡执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: cardSnapshot(card), - }) + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if txErr := postgres.NewIotCardStore(tx, nil).Delete(ctx, cardID); txErr != nil { + return txErr + } + return s.appendCardLifecycleAudit( + ctx, tx, constants.AuditActionIotCardDeleted, "删除 IoT 卡", constants.AuditResultSuccess, + card, cardSnapshot(card), map[string]any{"deleted": true}, nil, + ) + }) + if err != nil { + s.recordCardLifecycleFailure(ctx, constants.AuditActionIotCardDeleted, "删除 IoT 卡失败", constants.AuditResultFailed, card, cardID, err) return err } if s.assetIdentifierStore != nil { _ = s.assetIdentifierStore.DeleteByAsset(ctx, model.AssetTypeIotCard, cardID) } + s.iotCardStore.InvalidateListCountCache(ctx) s.logger.Info("删除卡", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID)) @@ -1892,18 +1898,6 @@ func (s *Service) DeleteCard(ctx context.Context, cardID uint) error { s.pollingCallback.OnCardDeleted(ctx, cardID) } - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardDelete, - OperationDesc: "删除卡", - ResultStatus: constants.AssetAuditResultSuccess, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: cardSnapshot(card), - AfterData: map[string]any{ - "deleted": true, - }, - }) - return nil } @@ -1912,41 +1906,30 @@ func (s *Service) BatchDeleteCards(ctx context.Context, cardIDs []uint) error { if len(cardIDs) == 0 { return nil } - cards, queryErr := s.iotCardStore.GetByIDs(ctx, cardIDs) + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "IoT 卡统一审计接缝未配置") + } + _, queryErr := s.iotCardStore.GetByIDs(ctx, cardIDs) if queryErr != nil { - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(queryErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardBatchDelete, - OperationDesc: "批量删除卡执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - BatchTotal: len(cardIDs), - FailCount: len(cardIDs), - AfterData: map[string]any{ - "card_ids": cardIDs, - }, - }) + s.recordBatchDeleteFailure(ctx, cardIDs, queryErr) return queryErr } - // 批量软删除 - if err := s.iotCardStore.BatchDelete(ctx, cardIDs); err != nil { - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardBatchDelete, - OperationDesc: "批量删除卡执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - BatchTotal: len(cardIDs), - FailCount: len(cardIDs), - AfterData: map[string]any{ - "card_ids": cardIDs, - }, - }) + actualCards := make([]*model.IotCard, 0, len(cardIDs)) + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if txErr := tx.WithContext(ctx).Where("id IN ?", cardIDs).Find(&actualCards).Error; txErr != nil { + return txErr + } + if txErr := postgres.NewIotCardStore(tx, nil).BatchDelete(ctx, cardIDs); txErr != nil { + return txErr + } + return s.appendBatchDeleteAudit(ctx, tx, actualCards, len(cardIDs)) + }) + if err != nil { + s.recordBatchDeleteFailure(ctx, cardIDs, err) return err } + s.iotCardStore.InvalidateListCountCache(ctx) s.logger.Info("批量删除卡", zap.Int("count", len(cardIDs))) @@ -1956,103 +1939,41 @@ func (s *Service) BatchDeleteCards(ctx context.Context, cardIDs []uint) error { s.pollingCallback.OnCardDeleted(ctx, cardID) } } - beforeCards := make([]map[string]any, 0, len(cards)) - for _, card := range cards { - beforeCards = append(beforeCards, cardSnapshot(card)) - } - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardBatchDelete, - OperationDesc: "批量删除卡", - ResultStatus: constants.AssetAuditResultSuccess, - BatchTotal: len(cardIDs), - SuccessCount: len(cardIDs), - BeforeData: map[string]any{ - "cards": beforeCards, - }, - AfterData: map[string]any{ - "card_ids": cardIDs, - "deleted": true, - }, - }) return nil } // UpdateRealnamePolicy 更新卡的实名认证策略 func (s *Service) UpdateRealnamePolicy(ctx context.Context, cardID uint, realnamePolicy string) error { - // 检查卡是否存在 - card, err := s.iotCardStore.GetByID(ctx, cardID) - if err != nil { - if err == gorm.ErrRecordNotFound { - denyErr := errors.New(errors.CodeNotFound, "IoT卡不存在") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardRealnamePolicy, - OperationDesc: "更新卡实名认证策略被拒绝", - ResultStatus: constants.AssetAuditResultDenied, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: cardID, - AfterData: map[string]any{ - "realname_policy": realnamePolicy, - }, - }) - return denyErr + var card model.IotCard + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", cardID).First(&card).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeNotFound, "IoT卡不存在") + } + return errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败") } - wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardRealnamePolicy, - OperationDesc: "更新卡实名认证策略执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: cardID, - AfterData: map[string]any{ - "realname_policy": realnamePolicy, - }, - }) - return wrapErr - } - - // 幂等检查 - if card.RealnamePolicy == realnamePolicy { - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardRealnamePolicy, - OperationDesc: "更新卡实名认证策略", - ResultStatus: constants.AssetAuditResultSuccess, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: map[string]any{ - "realname_policy": card.RealnamePolicy, - }, - AfterData: map[string]any{ - "realname_policy": realnamePolicy, - }, - }) - return nil - } - - // 更新数据库 - if err := s.iotCardStore.UpdateRealnamePolicy(ctx, cardID, realnamePolicy); err != nil { - wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新实名认证策略失败") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardRealnamePolicy, - OperationDesc: "更新卡实名认证策略执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: map[string]any{ - "realname_policy": card.RealnamePolicy, - }, - AfterData: map[string]any{ - "realname_policy": realnamePolicy, - }, - }) - return wrapErr + changed := card.RealnamePolicy != realnamePolicy + if changed { + if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).Update("realname_policy", realnamePolicy).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新实名认证策略失败") + } + } + summary := "更新 IoT 卡实名策略" + if !changed { + summary = "确认 IoT 卡实名策略无需变化" + } + return s.appendCardLifecycleAudit(ctx, tx, + constants.AuditActionIotCardRealnamePolicyUpdated, summary, constants.AuditResultSuccess, + &card, map[string]any{"realname_policy": card.RealnamePolicy}, + map[string]any{"realname_policy": realnamePolicy, "status_changed": changed}, nil) + }) + if err != nil { + if card.ID > 0 { + s.recordCardLifecycleFailure(ctx, constants.AuditActionIotCardRealnamePolicyUpdated, + "更新 IoT 卡实名策略失败", constants.AuditResultFailed, &card, cardID, err) + } + return err } s.logger.Info("更新卡实名认证策略", @@ -2060,20 +1981,6 @@ func (s *Service) UpdateRealnamePolicy(ctx context.Context, cardID uint, realnam zap.String("realname_policy", realnamePolicy), ) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardRealnamePolicy, - OperationDesc: "更新卡实名认证策略", - ResultStatus: constants.AssetAuditResultSuccess, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: map[string]any{ - "realname_policy": card.RealnamePolicy, - }, - AfterData: map[string]any{ - "realname_policy": realnamePolicy, - }, - }) - return nil } @@ -2082,54 +1989,15 @@ func (s *Service) UpdateRealnamePolicy(ctx context.Context, cardID uint, realnam func (s *Service) ManualUpdateRealnameStatus(ctx context.Context, cardID uint, realNameStatus int) (*model.IotCard, error) { if realNameStatus != constants.RealNameStatusNotVerified && realNameStatus != constants.RealNameStatusVerified { - denyErr := errors.New(errors.CodeInvalidParam, "无效的实名状态") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardRealnameStatus, - OperationDesc: "手动更新卡实名状态被拒绝", - ResultStatus: constants.AssetAuditResultDenied, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: cardID, - AfterData: map[string]any{ - "real_name_status": realNameStatus, - }, - }) - return nil, denyErr + return nil, errors.New(errors.CodeInvalidParam, "无效的实名状态") } card, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { if err == gorm.ErrRecordNotFound { - denyErr := errors.New(errors.CodeNotFound, "IoT卡不存在") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardRealnameStatus, - OperationDesc: "手动更新卡实名状态被拒绝", - ResultStatus: constants.AssetAuditResultDenied, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: cardID, - AfterData: map[string]any{ - "real_name_status": realNameStatus, - }, - }) - return nil, denyErr + return nil, errors.New(errors.CodeNotFound, "IoT卡不存在") } - wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardRealnameStatus, - OperationDesc: "手动更新卡实名状态执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: cardID, - AfterData: map[string]any{ - "real_name_status": realNameStatus, - }, - }) - return nil, wrapErr + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败") } oldStatus := card.RealNameStatus @@ -2148,45 +2016,14 @@ func (s *Service) ManualUpdateRealnameStatus(ctx context.Context, cardID uint, r }, }); err != nil { wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡实名状态失败") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardRealnameStatus, - OperationDesc: "手动更新卡实名状态执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: map[string]any{ - "real_name_status": oldStatus, - }, - AfterData: map[string]any{ - "real_name_status": realNameStatus, - }, - }) + s.recordCardLifecycleFailure(ctx, constants.AuditActionIotCardRealnameStatusUpdated, + "人工更新 IoT 卡实名状态失败", constants.AuditResultFailed, card, cardID, wrapErr) return nil, wrapErr } freshCard, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { - wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "查询更新后的IoT卡失败") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardRealnameStatus, - OperationDesc: "手动更新卡实名状态执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: map[string]any{ - "real_name_status": oldStatus, - }, - AfterData: map[string]any{ - "real_name_status": realNameStatus, - }, - }) - return nil, wrapErr + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询更新后的IoT卡失败") } if s.pollingCallback != nil { @@ -2199,31 +2036,15 @@ func (s *Service) ManualUpdateRealnameStatus(ctx context.Context, cardID uint, r zap.Int("new_status", realNameStatus), zap.Uint("operator_id", middleware.GetUserIDFromContext(ctx))) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardRealnameStatus, - OperationDesc: "手动更新卡实名状态", - ResultStatus: constants.AssetAuditResultSuccess, - AssetID: freshCard.ID, - AssetIdentifier: freshCard.ICCID, - BeforeData: map[string]any{ - "real_name_status": oldStatus, - "first_realname_at": card.FirstRealnameAt, - }, - AfterData: map[string]any{ - "real_name_status": freshCard.RealNameStatus, - "first_realname_at": freshCard.FirstRealnameAt, - }, - }) - return freshCard, nil } func requestIDFromContext(ctx context.Context) string { requestID := middleware.GetRequestIDFromContext(ctx) - if requestID == nil { - return "" + if requestID != nil && *requestID != "" { + return *requestID } - return *requestID + return auditcontext.From(ctx).RequestID } // handleManualRealnameStatusChanged 处理手动实名状态变更后的联动逻辑 diff --git a/internal/service/iot_card/speed_tier.go b/internal/service/iot_card/speed_tier.go index be8f118..e730ed5 100644 --- a/internal/service/iot_card/speed_tier.go +++ b/internal/service/iot_card/speed_tier.go @@ -10,11 +10,11 @@ import ( "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" - assetAuditSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_audit" "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" "go.uber.org/zap" + "gorm.io/gorm" ) type speedTierIntegrationLog interface { @@ -30,7 +30,7 @@ func (s *Service) SetSpeedTier(ctx context.Context, iccid string, code *int) (*d if code == nil || !constants.IsGatewaySpeedTier(*code) { return nil, pkgerrors.New(pkgerrors.CodeInvalidParam, "固定限速档位不合法") } - if s == nil || s.iotCardStore == nil || s.gatewayClient == nil || s.speedTierIntegration == nil { + if s == nil || s.iotCardStore == nil || s.gatewayClient == nil || s.speedTierIntegration == nil || s.db == nil || s.auditWriter == nil { return nil, pkgerrors.New(pkgerrors.CodeServiceUnavailable, "卡限速服务未完整配置") } @@ -58,11 +58,10 @@ func (s *Service) SetSpeedTier(ctx context.Context, iccid string, code *int) (*d "iccid": card.ICCID, "tier_code": *code, "tier_name": tierName, - "operator_id": middleware.GetUserIDFromContext(ctx), }, }) if err != nil { - s.logSpeedTierAudit(ctx, card, *code, "", constants.AssetAuditResultFailed, err) + s.recordSpeedTierAudit(ctx, card, *code, "", false, constants.AuditResultFailed, err) return nil, err } @@ -87,18 +86,22 @@ func (s *Service) SetSpeedTier(ctx context.Context, iccid string, code *int) (*d zap.Error(completeErr), ) } - s.logSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, constants.AssetAuditResultFailed, completeErr) + auditErr := gatewayErr + if auditErr == nil { + auditErr = completeErr + } + s.recordSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, false, speedTierAuditResult(gatewayErr), auditErr) return nil, pkgerrors.Wrap(pkgerrors.CodeDatabaseError, completeErr, "终结卡限速外部交互记录失败") } if gatewayErr != nil { - s.logSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, constants.AssetAuditResultFailed, gatewayErr) + s.recordSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, true, speedTierAuditResult(gatewayErr), gatewayErr) if isGatewayTimeout(gatewayErr) { return nil, pkgerrors.New(pkgerrors.CodeGatewayTimeout, "Gateway 卡限速请求结果未知,请核对实际档位后再操作") } return nil, gatewayErr } - s.logSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, constants.AssetAuditResultSuccess, nil) + s.recordSpeedTierAudit(ctx, card, *code, attempt.IntegrationID, true, constants.AuditResultSuccess, nil) return &dto.SetIotCardSpeedTierResponse{ IotCardID: card.ID, ICCID: card.ICCID, Code: *code, SpeedTierName: tierName, IntegrationID: attempt.IntegrationID, @@ -118,7 +121,7 @@ func speedTierCompletion(err error, duration time.Duration) integrationlog.Compl completion := integrationlog.Completion{ Result: constants.IntegrationResultSuccess, DurationMS: duration.Milliseconds(), - StateChanged: true, + StateChanged: false, ResponseSummary: map[string]any{ "result": "success", }, @@ -132,6 +135,7 @@ func speedTierCompletion(err error, duration time.Duration) integrationlog.Compl completion.ResponseSummary = map[string]any{"result": "failed"} if isGatewayTimeout(err) { completion.Result = constants.IntegrationResultUnknown + completion.SafeProviderMessage = "Gateway 卡限速请求结果未知" completion.ResponseSummary = map[string]any{"result": "unknown"} completion.RecoveryStrategy = constants.GatewaySpeedTierUnknownRecoveryStrategy } @@ -143,24 +147,34 @@ func isGatewayTimeout(err error) bool { return stderrors.As(err, &appErr) && appErr != nil && appErr.Code == pkgerrors.CodeGatewayTimeout } -func (s *Service) logSpeedTierAudit(ctx context.Context, card *model.IotCard, code int, integrationID, result string, err error) { - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - AssetType: constants.AssetTypeIotCard, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - OperationType: constants.AssetAuditOpCardSpeedTier, - OperationDesc: "设置 IoT 卡固定限速档位", - BeforeData: map[string]any{"card": cardSnapshot(card)}, - AfterData: map[string]any{ - "tier_code": code, - "tier_name": constants.GetGatewaySpeedTierName(code), - "integration_id": integrationID, - }, - ResultStatus: result, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - }) +func speedTierAuditResult(err error) string { + if err == nil { + return constants.AuditResultSuccess + } + if isGatewayTimeout(err) { + return constants.AuditResultUnknown + } + return constants.AuditResultFailed +} + +func (s *Service) recordSpeedTierAudit(ctx context.Context, card *model.IotCard, code int, integrationID string, integrationLogCompleted bool, result string, businessErr error) { + afterData := map[string]any{ + "requested_tier_code": code, + "requested_tier_name": constants.GetGatewaySpeedTierName(code), + "integration_id": integrationID, + "integration_log_completed": integrationLogCompleted, + } + if s.db == nil || s.auditWriter == nil { + recordCardAuditSecondaryFailure(ctx, constants.AuditActionIotCardSpeedTierSet, card.ID, businessErr, + pkgerrors.New(pkgerrors.CodeInvalidStatus, "IoT 卡统一审计接缝未配置")) + return + } + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return s.appendCardLifecycleAudit(ctx, tx, constants.AuditActionIotCardSpeedTierSet, + "设置 IoT 卡固定限速档位为"+constants.GetGatewaySpeedTierName(code), result, card, nil, afterData, businessErr) + }); err != nil { + recordCardAuditSecondaryFailure(ctx, constants.AuditActionIotCardSpeedTierSet, card.ID, businessErr, err) + } } var _ speedTierIntegrationLog = (*integrationlog.Repository)(nil) diff --git a/internal/service/iot_card/stop_resume_audit.go b/internal/service/iot_card/stop_resume_audit.go new file mode 100644 index 0000000..9f7d532 --- /dev/null +++ b/internal/service/iot_card/stop_resume_audit.go @@ -0,0 +1,239 @@ +package iot_card + +import ( + "context" + "strconv" + "time" + + "github.com/google/uuid" + "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" + 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" +) + +func (s *StopResumeService) appendCardCommandAudit( + ctx context.Context, + tx *gorm.DB, + card *model.IotCard, + actionCode, summary, result, integrationID string, + beforeData, afterData map[string]any, + businessErr error, +) error { + if s.auditWriter == nil || card == nil || card.ID == 0 { + return errors.New(errors.CodeInvalidStatus, "IoT 卡停复机统一审计接缝未配置或资源不完整") + } + resourcesByCard, err := loadCardDeviceAuditReferences(ctx, tx, []*model.IotCard{card}) + if err != nil { + return err + } + cardID := strconv.FormatUint(uint64(card.ID), 10) + resources := []audit.ResourceInput{{ + Type: constants.AuditResourceIotCard, ID: &cardID, + Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardTarget, + IdentitySnapshot: audit.IotCardIdentitySnapshot(card), BeforeData: beforeData, AfterData: afterData, + SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary, + }} + resources = append(resources, resourcesByCard[card.ID]...) + errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr) + input := audit.AppendInput{ + ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform, + Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary, + Metadata: map[string]any{"integration_id": integrationID}, Resources: resources, + } + if actionCode == constants.AuditActionIotCardAutoStopped || actionCode == constants.AuditActionIotCardAutoStarted || + actionCode == constants.AuditActionIotCardAutoStopReasonUpdated { + input.Actor = audit.ActorInput{Kind: constants.AuditActorSystemTask, ID: "iot-card-stop-resume", Name: "IoT 卡停复机服务"} + input.Source = constants.AuditSourceWorker + } + return s.auditWriter.Append(ctx, tx, input) +} + +func (s *StopResumeService) recordCardCommandAudit( + ctx context.Context, + card *model.IotCard, + actionCode, summary, result, integrationID string, + beforeData, afterData map[string]any, + businessErr error, +) { + if s.db == nil || s.auditWriter == nil || card == nil || card.ID == 0 { + recordCardAuditSecondaryFailure(ctx, actionCode, cardID(card), businessErr, + errors.New(errors.CodeInvalidStatus, "IoT 卡停复机统一审计接缝未配置")) + return + } + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return s.appendCardCommandAudit(ctx, tx, card, actionCode, summary, result, integrationID, beforeData, afterData, businessErr) + }); err != nil { + recordCardAuditSecondaryFailure(ctx, actionCode, card.ID, businessErr, err) + } +} + +func cardID(card *model.IotCard) uint { + if card == nil { + return 0 + } + return card.ID +} + +func stopAuditAction(ctx context.Context, stopReason string) (string, string) { + if stopReason == constants.StopReasonManual && auditcontext.From(ctx).ActorKind == constants.AuditActorAccount { + return constants.AuditActionIotCardManualStopped, "人工停用 IoT 卡网络" + } + return constants.AuditActionIotCardAutoStopped, "自动停用 IoT 卡网络" +} + +func cardCommandSeriesKey(ctx context.Context) string { + linkage := auditcontext.From(ctx) + if linkage.CorrelationID != "" { + return linkage.CorrelationID + } + if linkage.RequestID != "" { + return linkage.RequestID + } + return uuid.NewString() +} + +func cardCommandAuditResult(err error) string { + if err == nil { + return constants.AuditResultSuccess + } + if isGatewayTimeout(err) { + return constants.AuditResultUnknown + } + return constants.AuditResultFailed +} + +func (s *StopResumeService) updateCardStopReasonWithAudit(ctx context.Context, card *model.IotCard, stopReason string) error { + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Update("stop_reason", stopReason).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新卡停机原因失败") + } + return s.appendCardCommandAudit(ctx, tx, card, constants.AuditActionIotCardAutoStopReasonUpdated, + "自动更新 IoT 卡停机原因", constants.AuditResultSuccess, "", + map[string]any{"stop_reason": card.StopReason}, map[string]any{"stop_reason": stopReason}, nil) + }) +} + +func (s *StopResumeService) startCardCommandAttempt( + ctx context.Context, + card *model.IotCard, + operation, scene, seriesKey string, + attempt int, +) (*gatewayAttempt, error) { + if s.integration == nil { + return nil, errors.New(errors.CodeInvalidStatus, "停复机 Integration Log 接缝未配置") + } + resourceID := strconv.FormatUint(uint64(card.ID), 10) + triggerSource := auditcontext.From(ctx).Source + if triggerSource == "" { + triggerSource = constants.AuditSourceWorker + } + triggerScene := scene + triggerSeries := uuid.NewSHA1(uuid.NameSpaceOID, []byte("gateway-card-command:"+seriesKey+":"+operation)).String() + requestID := requestIDFromContext(ctx) + correlationID := auditcontext.From(ctx).CorrelationID + if correlationID == "" { + correlationID = requestID + } + var requestIDPtr, correlationIDPtr *string + if requestID != "" { + requestIDPtr = &requestID + } + if correlationID != "" { + correlationIDPtr = &correlationID + } + log, err := s.integration.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: correlationIDPtr, + 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 *StopResumeService) completeCardCommandAttempt(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.GatewayCardCommandUnknownRecoveryStrategy + } + } + _, err := s.integration.Complete(ctx, attempt.log.IntegrationID, completion) + return err +} + +type cardCommandAttemptObserver struct { + service *StopResumeService + card *model.IotCard + operation string + scene string + seriesKey string + nextAttempt int + current *gatewayAttempt + successful *gatewayAttempt + lastIntegrationID string + lastCallErr error + recordingErr error + unknown bool +} + +func (o *cardCommandAttemptObserver) BeforeAttempt(ctx context.Context, _ int) error { + o.nextAttempt++ + o.lastCallErr = nil + attempt, err := o.service.startCardCommandAttempt(ctx, o.card, o.operation, o.scene, o.seriesKey, o.nextAttempt) + if err != nil { + o.recordingErr = err + return err + } + o.current = attempt + o.lastIntegrationID = attempt.log.IntegrationID + return nil +} + +func (o *cardCommandAttemptObserver) 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.completeCardCommandAttempt(ctx, o.current, callErr, false) + o.current = nil + if err != nil { + o.recordingErr = err + } + return err +} + +func (o *cardCommandAttemptObserver) auditResult(lastErr error) string { + if o.unknown { + return constants.AuditResultUnknown + } + return cardCommandAuditResult(lastErr) +} diff --git a/internal/service/iot_card/stop_resume_service.go b/internal/service/iot_card/stop_resume_service.go index 0625dd3..e4378aa 100644 --- a/internal/service/iot_card/stop_resume_service.go +++ b/internal/service/iot_card/stop_resume_service.go @@ -13,8 +13,9 @@ import ( "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/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" @@ -25,6 +26,10 @@ import ( type StopResumeServiceInterface interface { // EvaluateAndAct 停复机统一入口,根据卡的当前状态自动判断并执行停机或复机 EvaluateAndAct(ctx context.Context, card *model.IotCard) error + // ForceStopCard 强制停机单张卡,不执行正常停机条件判断。 + ForceStopCard(ctx context.Context, card *model.IotCard, stopReason string) error + // ForceStartCard 强制复机单张卡,不执行正常复机条件判断。 + ForceStartCard(ctx context.Context, card *model.IotCard) error } // 编译时验证 StopResumeService 实现了 StopResumeServiceInterface @@ -43,6 +48,8 @@ type StopResumeService struct { assetAuditService AssetAuditService pollingCallback PollingCallback observationSeriesEvents cardObservationApp.SeriesEventWriter + auditWriter *audit.Writer + integration *integrationlog.Repository maxRetries int retryInterval time.Duration @@ -54,6 +61,12 @@ func (s *StopResumeService) SetObservationSeriesEventWriter(db *gorm.DB, writer s.observationSeriesEvents = writer } +// SetUnifiedAudit 注入停复机统一审计和外部交互日志接缝。 +func (s *StopResumeService) SetUnifiedAudit(writer *audit.Writer, integration *integrationlog.Repository) { + s.auditWriter = writer + s.integration = integration +} + // NewStopResumeService 创建停复机服务 func NewStopResumeService( redis *redis.Client, @@ -364,7 +377,7 @@ func (s *StopResumeService) resumeDeviceCards(ctx context.Context, deviceID uint var cardErrors []error for _, card := range cards { if !s.isRealnameOK(card) { - if updateErr := s.iotCardStore.UpdateStopReason(ctx, card.ID, constants.StopReasonNotRealname); updateErr != nil { + if updateErr := s.updateCardStopReasonWithAudit(ctx, card, constants.StopReasonNotRealname); updateErr != nil { cardErrors = append(cardErrors, updateErr) s.logger.Warn("更新未实名卡停机原因失败", zap.Uint("card_id", card.ID), zap.Error(updateErr)) @@ -408,21 +421,48 @@ func (s *StopResumeService) ResumeCardIfStopped(ctx context.Context, carrierType } } +// ForceStopCard 强制停机单张卡,不执行正常停机条件判断。 +func (s *StopResumeService) ForceStopCard(ctx context.Context, card *model.IotCard, stopReason string) error { + if card == nil || card.ID == 0 { + return errors.New(errors.CodeInvalidParam) + } + return s.stopCardWithRetry(ctx, card, stopReason) +} + +// ForceStartCard 强制复机单张卡,不执行正常复机条件判断。 +func (s *StopResumeService) ForceStartCard(ctx context.Context, card *model.IotCard) error { + if card == nil || card.ID == 0 { + return errors.New(errors.CodeInvalidParam) + } + actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络" + attempt, err := s.resumeCardWithRetry(ctx, card, actionCode, summary) + if err != nil { + return err + } + if err := s.updateCardAndAppendNetworkSeries(ctx, card, map[string]any{ + "network_status": constants.NetworkStatusOnline, + "resumed_at": time.Now(), + "stop_reason": "", + }, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil { + if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil { + s.logger.Error("终结保护期复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr)) + } + s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown, + attempt.log.IntegrationID, cardSnapshot(card), map[string]any{"requested_network_status": constants.NetworkStatusOnline}, err) + return err + } + s.reschedulePolling(ctx, card.ID) + if err := s.completeCardCommandAttempt(ctx, attempt, nil, true); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "终结保护期复机 Integration Log 失败") + } + return nil +} + // resumeSingleCard 对单张卡执行复机逻辑 // 依次检查:已开机则跳过 → 非轮询停机原因则跳过 → 不满足复机条件则跳过 → 加锁 → 调 Gateway → 更新 DB func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) error { card, err := s.iotCardStore.GetByID(ctx, cardID) if err != nil { - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - Operator: assetAuditSvc.SystemOperator("系统任务"), - OperationType: constants.AssetAuditOpCardAutoStart, - OperationDesc: "自动复机执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: cardID, - }) return err } @@ -462,112 +502,54 @@ func (s *StopResumeService) resumeSingleCard(ctx context.Context, cardID uint) e } defer s.redis.Del(ctx, lockKey) - if err := s.resumeCardWithRetry(ctx, card); err != nil { + actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动恢复 IoT 卡网络" + attempt, err := s.resumeCardWithRetry(ctx, card, actionCode, summary) + if err != nil { s.logger.Error("调用运营商复机接口失败", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID), zap.Error(err)) - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - Operator: assetAuditSvc.SystemOperator("系统任务"), - OperationType: constants.AssetAuditOpCardAutoStart, - OperationDesc: "自动复机执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: map[string]any{ - "network_status": card.NetworkStatus, - "stop_reason": card.StopReason, - }, - }) return err } now := time.Now() - if err := s.updateCardAndAppendNetworkSeries(ctx, cardID, map[string]any{ + if err := s.updateCardAndAppendNetworkSeries(ctx, card, map[string]any{ "network_status": constants.NetworkStatusOnline, "resumed_at": now, "stop_reason": "", - }, constants.CardObservationSceneBusinessResume, "online", uuid.NewString()); err != nil { + }, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil { + if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil { + s.logger.Error("终结复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr)) + } s.logger.Error("复机 Gateway 成功但 DB 更新失败", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID), zap.Error(err)) - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - Operator: assetAuditSvc.SystemOperator("系统任务"), - OperationType: constants.AssetAuditOpCardAutoStart, - OperationDesc: "自动复机执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: map[string]any{ - "network_status": constants.NetworkStatusOffline, - "stop_reason": card.StopReason, - }, - AfterData: map[string]any{ - "network_status": constants.NetworkStatusOnline, - "stop_reason": "", - }, - }) + s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown, + attempt.log.IntegrationID, + map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason}, + map[string]any{"requested_network_status": constants.NetworkStatusOnline}, err) return err } - s.reschedulePolling(ctx, card.ID) + if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, true); logErr != nil { + return errors.Wrap(errors.CodeDatabaseError, logErr, "终结复机 Integration Log 失败") + } s.logger.Info("卡已自动复机", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID)) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - Operator: assetAuditSvc.SystemOperator("系统任务"), - OperationType: constants.AssetAuditOpCardAutoStart, - OperationDesc: "自动复机", - ResultStatus: constants.AssetAuditResultSuccess, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: map[string]any{ - "network_status": constants.NetworkStatusOffline, - "stop_reason": card.StopReason, - }, - AfterData: map[string]any{ - "network_status": constants.NetworkStatusOnline, - "stop_reason": "", - }, - }) - return nil } // stopCardWithRetry 调用运营商停机接口(带重试机制),并更新 DB 停机原因 func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.IotCard, stopReason string) error { - operator := assetAuditSvc.SystemOperator("系统任务") - operationType := constants.AssetAuditOpCardAutoStop - operationDesc := "自动停卡" - if stopReason == constants.StopReasonManual { - operator = assetAuditSvc.OperatorFromContext(ctx) - operationType = constants.AssetAuditOpCardManualStop - operationDesc = "手动停卡" - } - + actionCode, summary := stopAuditAction(ctx, stopReason) if s.gatewayClient == nil { failErr := errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(failErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - Operator: operator, - OperationType: operationType, - OperationDesc: operationDesc + "执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: cardSnapshot(card), - }) + s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", + cardSnapshot(card), nil, failErr) return failErr } @@ -576,7 +558,14 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I zap.String("iccid", card.ICCID), zap.String("stop_reason", stopReason)) + seriesKey := cardCommandSeriesKey(ctx) + attemptObserver := &cardCommandAttemptObserver{ + service: s, card: card, operation: constants.IntegrationOperationGatewayStopCard, + scene: constants.CardObservationSceneBusinessStop, seriesKey: seriesKey, + } + gatewayCtx := gateway.WithAttemptObserver(ctx, attemptObserver) var lastErr error + lastIntegrationID := "" for i := 0; i < s.maxRetries; i++ { if i > 0 { s.logger.Debug("重试调用停机接口", @@ -585,107 +574,83 @@ func (s *StopResumeService) stopCardWithRetry(ctx context.Context, card *model.I time.Sleep(s.retryInterval) } - err := s.gatewayClient.StopCard(ctx, &gateway.CardOperationReq{ - CardNo: card.ICCID, - }) - if err == nil { + callErr := s.gatewayClient.StopCard(gatewayCtx, &gateway.CardOperationReq{CardNo: card.ICCID}) + lastIntegrationID = attemptObserver.lastIntegrationID + if attemptObserver.recordingErr != nil { + s.logger.Error("记录停机 Integration Log 失败", zap.String("integration_id", lastIntegrationID), zap.Error(attemptObserver.recordingErr)) + lastErr = attemptObserver.lastCallErr + if lastErr == nil { + lastErr = attemptObserver.recordingErr + } + break + } + if callErr == nil { + attempt := attemptObserver.successful s.logger.Info("网关停机成功", zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID)) now := time.Now() - if updateErr := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{ + if updateErr := s.updateCardAndAppendNetworkSeries(ctx, card, map[string]any{ "network_status": constants.NetworkStatusOffline, "stopped_at": now, "stop_reason": stopReason, - }, constants.CardObservationSceneBusinessStop, "offline", uuid.NewString()); updateErr != nil { + }, constants.CardObservationSceneBusinessStop, "offline", uuid.NewString(), actionCode, summary, lastIntegrationID); updateErr != nil { + if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil { + s.logger.Error("终结停机 Integration Log 失败", zap.String("integration_id", lastIntegrationID), zap.Error(logErr)) + } s.logger.Error("停机 Gateway 成功但 DB 更新失败", zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID), zap.Error(updateErr)) - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(updateErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - Operator: operator, - OperationType: operationType, - OperationDesc: operationDesc + "执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: map[string]any{ - "network_status": card.NetworkStatus, - "stop_reason": card.StopReason, - }, - AfterData: map[string]any{ - "network_status": constants.NetworkStatusOffline, - "stop_reason": stopReason, - }, - }) + s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown, + lastIntegrationID, + map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason}, + map[string]any{"requested_network_status": constants.NetworkStatusOffline, "stop_reason": stopReason}, updateErr) return updateErr } s.reschedulePolling(ctx, card.ID) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - Operator: operator, - OperationType: operationType, - OperationDesc: operationDesc, - ResultStatus: constants.AssetAuditResultSuccess, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: map[string]any{ - "network_status": card.NetworkStatus, - "stop_reason": card.StopReason, - }, - AfterData: map[string]any{ - "network_status": constants.NetworkStatusOffline, - "stop_reason": stopReason, - }, - }) + if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, true); logErr != nil { + return errors.Wrap(errors.CodeDatabaseError, logErr, "终结停机 Integration Log 失败") + } return nil } - lastErr = err + lastErr = callErr s.logger.Warn("调用停机接口失败,准备重试", zap.Int("attempt", i+1), zap.String("iccid", card.ICCID), - zap.Error(err)) + zap.Error(callErr)) } - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(lastErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - Operator: operator, - OperationType: operationType, - OperationDesc: operationDesc + "执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: map[string]any{ - "network_status": card.NetworkStatus, - "stop_reason": card.StopReason, - }, - AfterData: map[string]any{ - "network_status": constants.NetworkStatusOffline, - "stop_reason": stopReason, - }, - }) + s.recordCardCommandAudit(ctx, card, actionCode, summary+"未完成", attemptObserver.auditResult(lastErr), lastIntegrationID, + map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason}, + map[string]any{"requested_network_status": constants.NetworkStatusOffline, "stop_reason": stopReason}, lastErr) return lastErr } -// resumeCardWithRetry 调用运营商复机接口(带重试机制) -func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model.IotCard) error { +// resumeCardWithRetry 调用运营商复机接口(带重试机制)。 +func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model.IotCard, actionCode, summary string) (*gatewayAttempt, error) { if s.gatewayClient == nil { - return errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用") + failErr := errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用") + s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, failErr) + return nil, failErr } s.logger.Info("调用网关复机", zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID)) + seriesKey := cardCommandSeriesKey(ctx) + attemptObserver := &cardCommandAttemptObserver{ + service: s, card: card, operation: constants.IntegrationOperationGatewayStartCard, + scene: constants.CardObservationSceneBusinessResume, seriesKey: seriesKey, + } + gatewayCtx := gateway.WithAttemptObserver(ctx, attemptObserver) var lastErr error + lastIntegrationID := "" for i := 0; i < s.maxRetries; i++ { if i > 0 { s.logger.Debug("重试调用复机接口", @@ -698,22 +663,34 @@ func (s *StopResumeService) resumeCardWithRetry(ctx context.Context, card *model if strings.TrimSpace(card.GatewayExtend) == constants.GatewayCardExtendMachineSeparated { req.Extend = constants.GatewayCardStartExtendMachineSeparated } - err := s.gatewayClient.StartCard(ctx, req) - if err == nil { + callErr := s.gatewayClient.StartCard(gatewayCtx, req) + lastIntegrationID = attemptObserver.lastIntegrationID + if attemptObserver.recordingErr != nil { + s.logger.Error("记录复机 Integration Log 失败", zap.String("integration_id", lastIntegrationID), zap.Error(attemptObserver.recordingErr)) + lastErr = attemptObserver.lastCallErr + if lastErr == nil { + lastErr = attemptObserver.recordingErr + } + break + } + if callErr == nil { s.logger.Info("网关复机成功", zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID)) - return nil + return attemptObserver.successful, nil } - lastErr = err + lastErr = callErr s.logger.Warn("调用复机接口失败,准备重试", zap.Int("attempt", i+1), zap.String("iccid", card.ICCID), - zap.Error(err)) + zap.Error(callErr)) } - return lastErr + s.recordCardCommandAudit(ctx, card, actionCode, summary+"未完成", attemptObserver.auditResult(lastErr), lastIntegrationID, + map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason, "gateway_extend": card.GatewayExtend}, + map[string]any{"requested_network_status": constants.NetworkStatusOnline}, lastErr) + return nil, lastErr } // StartMachineSeparatedCard 对机卡分离停机卡执行复机 @@ -722,8 +699,11 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card if card == nil { return errors.New(errors.CodeInvalidParam) } + actionCode, summary := constants.AuditActionIotCardOpenAPIStarted, "OpenAPI 恢复 IoT 卡网络" if s.gatewayClient == nil { - return errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用") + failErr := errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用") + s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, failErr) + return failErr } gatewayExtend := strings.TrimSpace(card.GatewayExtend) @@ -737,108 +717,43 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card denyMsg = "该卡已被运营商销户,不允许复机" } denyErr := errors.New(errors.CodeForbidden, denyMsg) - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardManualStart, - OperationDesc: "机卡分离复机被拒绝(风险状态)", - ResultStatus: constants.AssetAuditResultDenied, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: cardSnapshot(card), - }) + s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "", cardSnapshot(card), nil, denyErr) return denyErr } if gatewayExtend != constants.GatewayCardExtendMachineSeparated { denyErr := errors.New(errors.CodeForbidden, constants.AgentOpenAPIResumeOnlyMachineSeparatedMessage) - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardManualStart, - OperationDesc: "机卡分离复机被拒绝", - ResultStatus: constants.AssetAuditResultDenied, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: cardSnapshot(card), - AfterData: map[string]any{ - "gateway_extend": gatewayExtend, - }, - }) + s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "", + cardSnapshot(card), map[string]any{"gateway_extend": gatewayExtend}, denyErr) return denyErr } - if err := s.resumeCardWithRetry(ctx, card); err != nil { + attempt, err := s.resumeCardWithRetry(ctx, card, actionCode, summary) + if err != nil { wrapErr := errors.Wrap(errors.CodeGatewayError, err, "调用运营商复机失败,请稍后重试") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardManualStart, - OperationDesc: "机卡分离复机执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: cardSnapshot(card), - AfterData: map[string]any{ - "gateway_extend": gatewayExtend, - }, - }) return wrapErr } now := time.Now() - if err := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{ + if err := s.updateCardAndAppendNetworkSeries(ctx, card, map[string]any{ "network_status": constants.NetworkStatusOnline, "resumed_at": now, "stop_reason": "", "gateway_extend": "", - }, constants.CardObservationSceneBusinessResume, "online", uuid.NewString()); err != nil { + }, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil { + if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil { + s.logger.Error("终结复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr)) + } wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardManualStart, - OperationDesc: "机卡分离复机执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: map[string]any{ - "network_status": card.NetworkStatus, - "stop_reason": card.StopReason, - "gateway_extend": gatewayExtend, - }, - AfterData: map[string]any{ - "network_status": constants.NetworkStatusOnline, - "stop_reason": "", - "gateway_extend": "", - }, - }) + s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown, + attempt.log.IntegrationID, cardSnapshot(card), map[string]any{"requested_network_status": constants.NetworkStatusOnline}, wrapErr) return wrapErr } s.reschedulePolling(ctx, card.ID) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardManualStart, - OperationDesc: "机卡分离复机", - ResultStatus: constants.AssetAuditResultSuccess, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: map[string]any{ - "network_status": card.NetworkStatus, - "stop_reason": card.StopReason, - "gateway_extend": gatewayExtend, - }, - AfterData: map[string]any{ - "network_status": constants.NetworkStatusOnline, - "stop_reason": "", - "gateway_extend": "", - }, - }) - + if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, true); logErr != nil { + return errors.Wrap(errors.CodeDatabaseError, logErr, "终结 OpenAPI 复机 Integration Log 失败") + } return nil } @@ -846,32 +761,13 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) error { card, err := s.iotCardStore.GetByICCID(ctx, iccid) if err != nil { - denyErr := errors.New(errors.CodeNotFound, "卡不存在") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardManualStop, - OperationDesc: "手动停卡被拒绝", - ResultStatus: constants.AssetAuditResultDenied, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetIdentifier: iccid, - }) - return denyErr + return errors.New(errors.CodeNotFound, "卡不存在") } + actionCode, summary := stopAuditAction(ctx, constants.StopReasonManual) if card.RealNameStatus != constants.RealNameStatusVerified { denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardManualStop, - OperationDesc: "手动停卡被拒绝", - ResultStatus: constants.AssetAuditResultDenied, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: cardSnapshot(card), - }) + s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "", cardSnapshot(card), nil, denyErr) return denyErr } @@ -882,41 +778,19 @@ func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) er exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(binding.DeviceID, "start")).Result() if exists > 0 { denyErr := errors.New(errors.CodeForbidden, "设备复机保护期内,禁止停机") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardManualStop, - OperationDesc: "手动停卡被拒绝", - ResultStatus: constants.AssetAuditResultDenied, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: cardSnapshot(card), - AfterData: map[string]any{ - "device_id": binding.DeviceID, - }, - }) + s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "", + cardSnapshot(card), map[string]any{"device_id": binding.DeviceID}, denyErr) return denyErr } } else if bindErr != nil && !stderrors.Is(bindErr, gorm.ErrRecordNotFound) { wrapErr := errors.Wrap(errors.CodeInternalError, bindErr, "查询卡绑定关系失败") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardManualStop, - OperationDesc: "手动停卡执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: cardSnapshot(card), - }) + s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, wrapErr) return wrapErr } } if err := s.stopCardWithRetry(ctx, card, constants.StopReasonManual); err != nil { - return errors.Wrap(errors.CodeGatewayError, err, "调用运营商停机失败,请稍后重试") + return err } return nil @@ -926,18 +800,9 @@ func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) er func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) error { card, err := s.iotCardStore.GetByICCID(ctx, iccid) if err != nil { - denyErr := errors.New(errors.CodeNotFound, "卡不存在") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardManualStart, - OperationDesc: "手动复机被拒绝", - ResultStatus: constants.AssetAuditResultDenied, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetIdentifier: iccid, - }) - return denyErr + return errors.New(errors.CodeNotFound, "卡不存在") } + actionCode, summary := constants.AuditActionIotCardManualStarted, "人工恢复 IoT 卡网络" // 独立卡处于风险停机或已销户状态时,拒绝复机 if card.IsStandalone && isRiskGatewayExtend(card.GatewayExtend) { @@ -948,33 +813,13 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e denyMsg = "该卡已被运营商销户,不允许复机" } denyErr := errors.New(errors.CodeForbidden, denyMsg) - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardManualStart, - OperationDesc: "手动复机被拒绝", - ResultStatus: constants.AssetAuditResultDenied, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: cardSnapshot(card), - }) + s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "", cardSnapshot(card), nil, denyErr) return denyErr } if card.RealNameStatus != constants.RealNameStatusVerified { denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardManualStart, - OperationDesc: "手动复机被拒绝", - ResultStatus: constants.AssetAuditResultDenied, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: cardSnapshot(card), - }) + s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "", cardSnapshot(card), nil, denyErr) return denyErr } @@ -985,106 +830,52 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e exists, _ := s.redis.Exists(ctx, constants.RedisDeviceProtectKey(binding.DeviceID, "stop")).Result() if exists > 0 { denyErr := errors.New(errors.CodeForbidden, "设备停机保护期内,禁止复机") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardManualStart, - OperationDesc: "手动复机被拒绝", - ResultStatus: constants.AssetAuditResultDenied, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: cardSnapshot(card), - AfterData: map[string]any{ - "device_id": binding.DeviceID, - }, - }) + s.recordCardCommandAudit(ctx, card, actionCode, summary+"被拒绝", constants.AuditResultDenied, "", + cardSnapshot(card), map[string]any{"device_id": binding.DeviceID}, denyErr) return denyErr } } else if bindErr != nil && !stderrors.Is(bindErr, gorm.ErrRecordNotFound) { wrapErr := errors.Wrap(errors.CodeInternalError, bindErr, "查询卡绑定关系失败") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardManualStart, - OperationDesc: "手动复机执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: cardSnapshot(card), - }) + s.recordCardCommandAudit(ctx, card, actionCode, summary+"失败", constants.AuditResultFailed, "", cardSnapshot(card), nil, wrapErr) return wrapErr } } - if err := s.resumeCardWithRetry(ctx, card); err != nil { + attempt, err := s.resumeCardWithRetry(ctx, card, actionCode, summary) + if err != nil { wrapErr := errors.Wrap(errors.CodeGatewayError, err, "调用运营商复机失败,请稍后重试") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardManualStart, - OperationDesc: "手动复机执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: cardSnapshot(card), - }) return wrapErr } now := time.Now() - if err := s.updateCardAndAppendNetworkSeries(ctx, card.ID, map[string]any{ + if err := s.updateCardAndAppendNetworkSeries(ctx, card, map[string]any{ "network_status": constants.NetworkStatusOnline, "resumed_at": now, "stop_reason": "", - }, constants.CardObservationSceneBusinessResume, "online", uuid.NewString()); err != nil { + }, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil { + if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil { + s.logger.Error("终结复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr)) + } wrapErr := errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败") - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(wrapErr) - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardManualStart, - OperationDesc: "手动复机执行失败", - ResultStatus: constants.AssetAuditResultFailed, - ErrorCode: errorCode, - ErrorMsg: errorMsg, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: map[string]any{ - "network_status": card.NetworkStatus, - "stop_reason": card.StopReason, - }, - AfterData: map[string]any{ - "network_status": constants.NetworkStatusOnline, - "stop_reason": "", - }, - }) + s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown, + attempt.log.IntegrationID, cardSnapshot(card), map[string]any{"requested_network_status": constants.NetworkStatusOnline}, wrapErr) return wrapErr } s.reschedulePolling(ctx, card.ID) - - s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{ - OperationType: constants.AssetAuditOpCardManualStart, - OperationDesc: "手动复机", - ResultStatus: constants.AssetAuditResultSuccess, - AssetID: card.ID, - AssetIdentifier: card.ICCID, - BeforeData: map[string]any{ - "network_status": card.NetworkStatus, - "stop_reason": card.StopReason, - }, - AfterData: map[string]any{ - "network_status": constants.NetworkStatusOnline, - "stop_reason": "", - }, - }) - + if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, true); logErr != nil { + return errors.Wrap(errors.CodeDatabaseError, logErr, "终结人工复机 Integration Log 失败") + } return nil } -func (s *StopResumeService) updateCardAndAppendNetworkSeries(ctx context.Context, cardID uint, fields map[string]any, scene, expected, operationID string) error { - if s.db == nil || s.observationSeriesEvents == nil { +func (s *StopResumeService) updateCardAndAppendNetworkSeries( + ctx context.Context, + card *model.IotCard, + fields map[string]any, + scene, expected, operationID, actionCode, summary, integrationID string, +) error { + if s.db == nil || s.observationSeriesEvents == nil || card == nil || card.ID == 0 { return errors.New(errors.CodeInternalError, "停复机观测 Outbox 能力未配置") } requestID := requestIDFromContext(ctx) @@ -1092,15 +883,21 @@ func (s *StopResumeService) updateCardAndAppendNetworkSeries(ctx context.Context requestID = operationID } return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).Updates(fields).Error; err != nil { + if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(fields).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新卡停复机状态失败") } + if err := s.appendCardCommandAudit(ctx, tx, card, actionCode, summary, constants.AuditResultSuccess, + integrationID, + map[string]any{"network_status": card.NetworkStatus, "stop_reason": card.StopReason, "gateway_extend": card.GatewayExtend}, + fields, nil); err != nil { + return err + } if cardObservationApp.IsSeriesTriggerSuppressed(ctx) { return nil } return s.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{ EventID: "card-observation:network-command:" + operationID, - Scene: scene, ResourceType: constants.CardObservationResourceTypeCard, ResourceID: cardID, + Scene: scene, ResourceType: constants.CardObservationResourceTypeCard, ResourceID: card.ID, SyncTypes: []string{constants.CardObservationSyncTypeNetwork}, ExpectedValue: expected, Source: constants.CardObservationSourceBusinessEvent, OccurredAt: time.Now().UTC(), RequestID: requestID, CorrelationID: requestID, diff --git a/internal/service/iot_card/unified_audit.go b/internal/service/iot_card/unified_audit.go new file mode 100644 index 0000000..3d3b5d9 --- /dev/null +++ b/internal/service/iot_card/unified_audit.go @@ -0,0 +1,751 @@ +package iot_card + +import ( + "context" + "strconv" + "time" + + "github.com/google/uuid" + "gorm.io/gorm" + + cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + "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" +) + +// SetAccessAudit 注入 IoT 卡身份生命周期的统一审计 Writer。 +func (s *Service) SetAccessAudit(writer *audit.Writer) { + s.auditWriter = writer +} + +// WriteCardStateAudit 将卡观测事务中的人工状态操作写入统一 Audit Event。 +func (s *Service) WriteCardStateAudit(ctx context.Context, tx *gorm.DB, input cardapp.StateAudit) error { + extraResources := make([]audit.ResourceInput, 0, 1) + if input.IntegrationID != "" { + extraResources = append(extraResources, callbackIntegrationAuditResource(ctx, input.IntegrationID)) + } + return s.appendCardLifecycleAudit(ctx, tx, input.ActionCode, input.Summary, constants.AuditResultSuccess, + input.Card, input.BeforeData, input.AfterData, nil, extraResources...) +} + +// WriteCardStateFailure 使用独立短事务记录已解析卡资源后的回调失败。 +func (s *Service) WriteCardStateFailure(ctx context.Context, input cardapp.StateAudit, businessErr error) { + extraResources := make([]audit.ResourceInput, 0, 1) + if input.IntegrationID != "" { + extraResources = append(extraResources, callbackIntegrationAuditResource(ctx, input.IntegrationID)) + } + s.recordCardLifecycleFailure(ctx, input.ActionCode, input.Summary, constants.AuditResultFailed, + input.Card, input.Card.ID, businessErr, extraResources...) +} + +func callbackIntegrationAuditResource(ctx context.Context, integrationID string) audit.ResourceInput { + linkage := auditcontext.From(ctx) + correlationID := linkage.CorrelationID + if correlationID == "" { + correlationID = linkage.RequestID + } + return audit.ResourceInput{ + Type: constants.AuditResourceIntegrationLog, Key: integrationID, DisplayName: integrationID, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleCallbackIntegration, + IdentitySnapshot: map[string]any{ + "integration_id": integrationID, "provider": linkage.ActorID, + "direction": constants.IntegrationDirectionInbound, "correlation_id": correlationID, + }, + SubjectVisibility: constants.AuditSubjectInternalOnly, + } +} + +func cardRefreshAuditAction(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 (s *Service) updateCardRefreshCompletion(ctx context.Context, card *model.IotCard, syncTime time.Time, result, summary string) error { + actionCode, audited := cardRefreshAuditAction(ctx) + if !audited { + return s.iotCardStore.UpdateFields(ctx, card.ID, map[string]any{"last_sync_time": syncTime}) + } + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Update("last_sync_time", syncTime).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新卡刷新时间失败") + } + return s.appendCardLifecycleAudit(ctx, tx, actionCode, summary, result, + card, map[string]any{"last_sync_time": card.LastSyncTime}, map[string]any{"last_sync_time": syncTime}, nil) + }) +} + +func (s *Service) recordCardRefreshFailure(ctx context.Context, card *model.IotCard, result string, businessErr error) { + actionCode, audited := cardRefreshAuditAction(ctx) + if !audited || card == nil { + return + } + s.recordCardLifecycleFailure(ctx, actionCode, "人工刷新 IoT 卡未完成", result, card, card.ID, businessErr) +} + +func (s *Service) completeCardRefreshAttempt( + ctx context.Context, + card *model.IotCard, + attempt *gatewayAttempt, + callErr error, + stateChanged bool, + message string, +) error { + if err := s.completeGatewayCardAttempt(ctx, attempt, callErr, stateChanged); err != nil { + wrapped := errors.Wrap(errors.CodeDatabaseError, err, message) + s.recordCardRefreshFailure(ctx, card, constants.AuditResultFailed, wrapped) + return wrapped + } + return nil +} + +func (s *Service) appendCardLifecycleAudit( + ctx context.Context, + tx *gorm.DB, + actionCode, summary, result string, + card *model.IotCard, + beforeData, afterData map[string]any, + businessErr error, + extraResources ...audit.ResourceInput, +) error { + if s.auditWriter == nil || card == nil || card.ID == 0 { + return errors.New(errors.CodeInvalidStatus, "IoT 卡统一审计接缝未配置或资源不完整") + } + resourceID := strconv.FormatUint(uint64(card.ID), 10) + errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr) + resources := []audit.ResourceInput{{ + Type: constants.AuditResourceIotCard, ID: &resourceID, + Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardTarget, + IdentitySnapshot: audit.IotCardIdentitySnapshot(card), BeforeData: beforeData, AfterData: afterData, + SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: summary, + }} + resources = append(resources, extraResources...) + 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) recordCardLifecycleFailure(ctx context.Context, actionCode, summary, result string, card *model.IotCard, cardID uint, businessErr error, extraResources ...audit.ResourceInput) { + if card == nil { + card = &model.IotCard{} + card.ID = cardID + } + if s.db == nil || s.auditWriter == nil || card.ID == 0 { + recordCardAuditSecondaryFailure(ctx, actionCode, cardID, businessErr, errors.New(errors.CodeInvalidStatus, "IoT 卡统一审计接缝未配置")) + return + } + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return s.appendCardLifecycleAudit(ctx, tx, actionCode, summary, result, card, nil, nil, businessErr, extraResources...) + }); err != nil { + recordCardAuditSecondaryFailure(ctx, actionCode, card.ID, businessErr, err) + } +} + +func (s *Service) appendBatchDeleteAudit(ctx context.Context, tx *gorm.DB, cards []*model.IotCard, batchTotal int) error { + linkage := auditcontext.From(ctx) + if s.auditWriter == nil || linkage.RequestID == "" { + return errors.New(errors.CodeInvalidStatus, "IoT 卡批量删除审计上下文不完整") + } + rootEventID := stableCardBatchEventID("delete", linkage.RequestID) + result := constants.AuditResultSuccess + if len(cards) < batchTotal { + result = constants.AuditResultPartial + } + children := make([]audit.AppendInput, 0, len(cards)) + for _, card := range cards { + if card == nil || card.ID == 0 { + continue + } + resourceID := strconv.FormatUint(uint64(card.ID), 10) + children = append(children, audit.AppendInput{ + EventID: stableCardBatchEventID("delete-card", linkage.RequestID+":"+resourceID), + ActionCode: constants.AuditActionIotCardDeleted, Summary: "批量删除 IoT 卡", Result: constants.AuditResultSuccess, + Resources: []audit.ResourceInput{{ + Type: constants.AuditResourceIotCard, ID: &resourceID, + Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardTarget, + IdentitySnapshot: audit.IotCardIdentitySnapshot(card), BeforeData: cardSnapshot(card), + AfterData: map[string]any{"deleted": true}, + SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "IoT 卡已删除", + }}, + }) + } + return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{ + Root: audit.AppendInput{ + EventID: rootEventID, ActionCode: constants.AuditActionIotCardBatchDeleted, + Summary: "批量删除 IoT 卡", Result: result, + BatchTotal: batchTotal, SuccessCount: len(cards), FailCount: batchTotal - len(cards), + Resources: []audit.ResourceInput{{ + Type: constants.AuditResourceIotCardBatch, Key: linkage.RequestID, DisplayName: linkage.RequestID, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardBatch, + IdentitySnapshot: map[string]any{"request_id": linkage.RequestID, "card_count": len(cards)}, + SubjectVisibility: constants.AuditSubjectInternalOnly, + }}, + }, + Children: children, + }) +} + +func (s *Service) recordBatchDeleteFailure(ctx context.Context, cardIDs []uint, businessErr error) { + linkage := auditcontext.From(ctx) + if s.db == nil || s.auditWriter == nil || linkage.RequestID == "" { + recordCardAuditSecondaryFailure(ctx, constants.AuditActionIotCardBatchDeleted, 0, businessErr, errors.New(errors.CodeInvalidStatus, "IoT 卡批量删除审计接缝未配置")) + return + } + errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr) + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return s.auditWriter.Append(ctx, tx, audit.AppendInput{ + EventID: stableCardBatchEventID("delete-failed", linkage.RequestID), + ActionCode: constants.AuditActionIotCardBatchDeleted, Summary: "批量删除 IoT 卡失败", + Result: constants.AuditResultFailed, ErrorCode: errorCode, ErrorSummary: errorSummary, + BatchTotal: len(cardIDs), FailCount: len(cardIDs), + Resources: []audit.ResourceInput{{ + Type: constants.AuditResourceIotCardBatch, Key: linkage.RequestID, DisplayName: linkage.RequestID, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardBatch, + IdentitySnapshot: map[string]any{"request_id": linkage.RequestID, "card_count": len(cardIDs)}, + SubjectVisibility: constants.AuditSubjectInternalOnly, + }}, + }) + }) + if err != nil { + recordCardAuditSecondaryFailure(ctx, constants.AuditActionIotCardBatchDeleted, 0, businessErr, err) + } +} + +type cardAuditOutcome struct { + Result string + Summary string +} + +func cardAuditOutcomes(cards []*model.IotCard, result, summary string) map[uint]cardAuditOutcome { + outcomes := make(map[uint]cardAuditOutcome, len(cards)) + for _, card := range cards { + if card != nil && card.ID > 0 { + outcomes[card.ID] = cardAuditOutcome{Result: result, Summary: summary} + } + } + return outcomes +} + +func setCardAuditOutcomes(outcomes map[uint]cardAuditOutcome, cardIDs []uint, result, summary string) { + for _, cardID := range cardIDs { + outcomes[cardID] = cardAuditOutcome{Result: result, Summary: summary} + } +} + +func setCardAuditOutcomeByICCID(outcomes map[uint]cardAuditOutcome, cards []*model.IotCard, iccid, result, summary string) { + for _, card := range cards { + if card != nil && (card.ICCID == iccid || card.ICCID19 == iccid || card.ICCID20 != nil && *card.ICCID20 == iccid) { + outcomes[card.ID] = cardAuditOutcome{Result: result, Summary: summary} + return + } + } +} + +func (s *Service) appendCardTransferAudit( + ctx context.Context, + tx *gorm.DB, + rootAction, itemAction, kind, summary, result string, + cards []*model.IotCard, + outcomes map[uint]cardAuditOutcome, + records []*model.AssetAllocationRecord, + newShopID *uint, + newStatus, batchTotal, successCount, failCount int, + businessErr error, +) error { + shops, err := loadCardTransferAuditShops(ctx, tx, cards, records, newShopID) + if err != nil { + return err + } + deviceReferences, err := loadCardDeviceAuditReferences(ctx, tx, cards) + if err != nil { + return err + } + recordByCardID := make(map[uint]*model.AssetAllocationRecord, len(records)) + for _, record := range records { + if record != nil { + recordByCardID[record.AssetID] = record + } + } + items := make([]cardBatchAuditItem, 0, len(cards)) + for _, card := range cards { + if card == nil || card.ID == 0 { + continue + } + outcome, ok := outcomes[card.ID] + if !ok { + continue + } + beforeData := map[string]any{"shop_id": card.ShopID, "status": card.Status} + var afterData map[string]any + if outcome.Result == constants.AuditResultSuccess { + afterData = map[string]any{"shop_id": newShopID, "status": newStatus} + } + references := cardTransferAuditReferences(card, recordByCardID[card.ID], newShopID, shops) + references = append(references, deviceReferences[card.ID]...) + items = append(items, cardBatchAuditItem{ + Card: card, Result: outcome.Result, Summary: outcome.Summary, + BeforeData: beforeData, AfterData: afterData, + References: references, + }) + } + allocationNo := "" + if len(records) > 0 && records[0] != nil { + allocationNo = records[0].AllocationNo + } + return s.appendCardBatchAudit(ctx, tx, rootAction, itemAction, kind, summary, result, + batchTotal, successCount, failCount, items, + map[string]any{"allocation_no": allocationNo, "to_shop_id": newShopID, "new_status": newStatus}, businessErr) +} + +func loadCardTransferAuditShops(ctx context.Context, tx *gorm.DB, cards []*model.IotCard, records []*model.AssetAllocationRecord, newShopID *uint) (map[uint]*model.Shop, error) { + shopIDs := make(map[uint]struct{}) + if newShopID != nil && *newShopID > 0 { + shopIDs[*newShopID] = struct{}{} + } + for _, card := range cards { + if card != nil && card.ShopID != nil && *card.ShopID > 0 { + shopIDs[*card.ShopID] = struct{}{} + } + } + for _, record := range records { + if record == nil { + continue + } + if record.FromOwnerType == constants.OwnerTypeShop && record.FromOwnerID != nil { + shopIDs[*record.FromOwnerID] = struct{}{} + } + if record.ToOwnerType == constants.OwnerTypeShop && record.ToOwnerID > 0 { + shopIDs[record.ToOwnerID] = 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 cardTransferAuditReferences(card *model.IotCard, 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, + }) + } + sourceShopID := card.ShopID + if record != nil && record.FromOwnerType == constants.OwnerTypeShop { + sourceShopID = record.FromOwnerID + } + if sourceShopID != nil && *sourceShopID > 0 { + resources = appendShopAuditReference(resources, shops[*sourceShopID], *sourceShopID, constants.AuditResourceRoleTransferSourceShop) + } + if record != nil && record.ToOwnerType == constants.OwnerTypeShop && record.ToOwnerID > 0 { + resources = appendShopAuditReference(resources, shops[record.ToOwnerID], record.ToOwnerID, constants.AuditResourceRoleTransferTargetShop) + } else if targetShopID != nil && *targetShopID > 0 { + resources = appendShopAuditReference(resources, shops[*targetShopID], *targetShopID, constants.AuditResourceRoleTransferTargetShop) + } + return resources +} + +func appendShopAuditReference(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, + }) +} + +type cardBatchAuditItem struct { + Card *model.IotCard + PrimaryRole string + Result string + Summary string + BeforeData map[string]any + AfterData map[string]any + References []audit.ResourceInput +} + +func (s *Service) appendCardBatchAudit( + ctx context.Context, + tx *gorm.DB, + rootAction, itemAction, kind, summary, result string, + batchTotal, successCount, failCount int, + items []cardBatchAuditItem, + metadata map[string]any, + businessErr error, +) error { + linkage := auditcontext.From(ctx) + if s.auditWriter == nil || linkage.RequestID == "" { + return errors.New(errors.CodeInvalidStatus, "IoT 卡批量审计上下文不完整") + } + errorCode, errorSummary := assetAuditSvc.BuildErrorInfo(businessErr) + children := make([]audit.AppendInput, 0, len(items)) + for _, item := range items { + if item.Card == nil || item.Card.ID == 0 { + continue + } + cardID := strconv.FormatUint(uint64(item.Card.ID), 10) + primaryRole := item.PrimaryRole + if primaryRole == "" { + primaryRole = constants.AuditResourceRoleIotCardTransferTarget + } + resources := []audit.ResourceInput{{ + Type: constants.AuditResourceIotCard, ID: &cardID, + Key: audit.IotCardResourceKey(item.Card), DisplayName: item.Card.ICCID, + Relation: constants.AuditResourceRelationPrimary, Role: primaryRole, + IdentitySnapshot: audit.IotCardIdentitySnapshot(item.Card), 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, childErrorSummary = errorCode, errorSummary + } + children = append(children, audit.AppendInput{ + EventID: stableCardBatchEventID(kind+"-"+item.Result+"-card", linkage.RequestID+":"+cardID), + 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: stableCardBatchEventID(kind+"-"+result, linkage.RequestID), + 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.AuditResourceIotCardBatch, Key: linkage.RequestID, DisplayName: linkage.RequestID, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardBatch, + IdentitySnapshot: map[string]any{"request_id": linkage.RequestID, "card_count": len(items)}, + SubjectVisibility: constants.AuditSubjectInternalOnly, + }}, + }, + Children: children, + }) +} + +func (s *Service) recordCardTransferAuditFailure( + ctx context.Context, + rootAction, itemAction, kind, summary, result string, + cards []*model.IotCard, + outcomes map[uint]cardAuditOutcome, + newShopID *uint, + newStatus, batchTotal, successCount, failCount int, + businessErr error, +) { + if s.db == nil || s.auditWriter == nil || len(cards) == 0 { + recordCardAuditSecondaryFailure(ctx, rootAction, 0, businessErr, errors.New(errors.CodeInvalidStatus, "IoT 卡批量审计接缝未配置")) + return + } + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return s.appendCardTransferAudit(ctx, tx, rootAction, itemAction, kind, summary, result, + cards, outcomes, nil, newShopID, newStatus, batchTotal, successCount, failCount, businessErr) + }); err != nil { + recordCardAuditSecondaryFailure(ctx, rootAction, 0, businessErr, err) + } +} + +func (s *Service) appendCardSeriesBindingAudit( + ctx context.Context, + tx *gorm.DB, + cards []*model.IotCard, + outcomes map[uint]cardAuditOutcome, + seriesID *uint, + result string, + batchTotal, successCount, failCount int, + metadata map[string]any, + businessErr error, +) error { + series, err := loadCardSeriesAuditResources(ctx, tx, cards, seriesID) + if err != nil { + return err + } + deviceReferences, err := loadCardDeviceAuditReferences(ctx, tx, cards) + if err != nil { + return err + } + items := make([]cardBatchAuditItem, 0, len(cards)) + for _, card := range cards { + if card == nil || card.ID == 0 { + continue + } + outcome, ok := outcomes[card.ID] + if !ok { + continue + } + var afterData map[string]any + if outcome.Result == constants.AuditResultSuccess { + afterData = map[string]any{"series_id": seriesID} + } + references := cardSeriesAuditReferences(card.SeriesID, seriesID, series) + references = append(references, deviceReferences[card.ID]...) + items = append(items, cardBatchAuditItem{ + Card: card, PrimaryRole: constants.AuditResourceRoleIotCardSeriesTarget, + Result: outcome.Result, Summary: outcome.Summary, + BeforeData: map[string]any{"series_id": card.SeriesID}, AfterData: afterData, + References: references, + }) + } + return s.appendCardBatchAudit(ctx, tx, + constants.AuditActionIotCardSeriesBindingBatch, + constants.AuditActionIotCardSeriesBound, + "series-binding", "批量设置 IoT 卡系列绑定", result, + batchTotal, successCount, failCount, items, metadata, businessErr) +} + +func (s *Service) appendCardRealnamePolicyBatchAudit(ctx context.Context, tx *gorm.DB, cards []*model.IotCard, policy string) error { + deviceReferences, err := loadCardDeviceAuditReferences(ctx, tx, cards) + if err != nil { + return err + } + items := make([]cardBatchAuditItem, 0, len(cards)) + for _, card := range cards { + if card == nil || card.ID == 0 || card.RealnamePolicy == policy { + continue + } + items = append(items, cardBatchAuditItem{ + Card: card, PrimaryRole: constants.AuditResourceRoleIotCardTarget, + Result: constants.AuditResultSuccess, Summary: "更新 IoT 卡实名策略", + BeforeData: map[string]any{"realname_policy": card.RealnamePolicy}, + AfterData: map[string]any{"realname_policy": policy}, + References: deviceReferences[card.ID], + }) + } + return s.appendCardBatchAudit(ctx, tx, + constants.AuditActionIotCardRealnamePolicyBatchUpdated, + constants.AuditActionIotCardRealnamePolicyUpdated, + "realname-policy", "批量更新 IoT 卡实名策略", constants.AuditResultSuccess, + len(items), len(items), 0, items, map[string]any{"realname_policy": policy, "requested_count": len(cards)}, nil) +} + +func (s *Service) recordCardRealnamePolicyBatchFailure(ctx context.Context, cards []*model.IotCard, policy, result string, businessErr error) { + if s.db == nil || s.auditWriter == nil || len(cards) == 0 { + return + } + items := make([]cardBatchAuditItem, 0, len(cards)) + for _, card := range cards { + if card == nil || card.ID == 0 { + continue + } + items = append(items, cardBatchAuditItem{ + Card: card, PrimaryRole: constants.AuditResourceRoleIotCardTarget, + Result: result, Summary: "更新 IoT 卡实名策略未完成", + BeforeData: map[string]any{"realname_policy": card.RealnamePolicy}, + AfterData: map[string]any{"requested_realname_policy": policy}, + }) + } + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return s.appendCardBatchAudit(ctx, tx, + constants.AuditActionIotCardRealnamePolicyBatchUpdated, + constants.AuditActionIotCardRealnamePolicyUpdated, + "realname-policy", "批量更新 IoT 卡实名策略未完成", result, + len(cards), 0, len(cards), items, map[string]any{"realname_policy": policy}, businessErr) + }); err != nil { + recordCardAuditSecondaryFailure(ctx, constants.AuditActionIotCardRealnamePolicyBatchUpdated, 0, businessErr, err) + } +} + +func loadCardSeriesAuditResources(ctx context.Context, tx *gorm.DB, cards []*model.IotCard, targetSeriesID *uint) (map[uint]*model.PackageSeries, error) { + seriesIDs := make(map[uint]struct{}) + if targetSeriesID != nil && *targetSeriesID > 0 { + seriesIDs[*targetSeriesID] = struct{}{} + } + for _, card := range cards { + if card != nil && card.SeriesID != nil && *card.SeriesID > 0 { + seriesIDs[*card.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 cardSeriesAuditReferences(previousID, targetID *uint, series map[uint]*model.PackageSeries) []audit.ResourceInput { + resources := make([]audit.ResourceInput, 0, 2) + if previousID != nil && *previousID > 0 { + resources = appendPackageSeriesAuditReference(resources, series[*previousID], *previousID, constants.AuditResourceRolePreviousPackageSeries) + } + if targetID != nil && *targetID > 0 { + resources = appendPackageSeriesAuditReference(resources, series[*targetID], *targetID, constants.AuditResourceRoleTargetPackageSeries) + } + return resources +} + +func appendPackageSeriesAuditReference(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 loadCardDeviceAuditReferences(ctx context.Context, tx *gorm.DB, cards []*model.IotCard) (map[uint][]audit.ResourceInput, error) { + cardByID := make(map[uint]*model.IotCard, len(cards)) + cardIDs := make([]uint, 0, len(cards)) + for _, card := range cards { + if card != nil && card.ID > 0 { + cardByID[card.ID] = card + cardIDs = append(cardIDs, card.ID) + } + } + result := make(map[uint][]audit.ResourceInput) + if len(cardIDs) == 0 { + return result, nil + } + var bindings []*model.DeviceSimBinding + if err := tx.WithContext(ctx).Where("iot_card_id IN ? AND bind_status = ?", cardIDs, 1).Find(&bindings).Error; err != nil { + return nil, err + } + deviceIDs := make([]uint, 0, len(bindings)) + for _, binding := range bindings { + deviceIDs = append(deviceIDs, binding.DeviceID) + } + var devices []*model.Device + if len(deviceIDs) > 0 { + if err := tx.WithContext(ctx).Unscoped().Where("id IN ?", deviceIDs).Find(&devices).Error; err != nil { + return nil, err + } + } + deviceByID := make(map[uint]*model.Device, len(devices)) + for _, device := range devices { + deviceByID[device.ID] = device + } + for _, binding := range bindings { + card := cardByID[binding.IotCardID] + device := deviceByID[binding.DeviceID] + bindingID := strconv.FormatUint(uint64(binding.ID), 10) + deviceID := strconv.FormatUint(uint64(binding.DeviceID), 10) + deviceKey, deviceName := deviceID, deviceID + deviceIdentity := map[string]any{"id": binding.DeviceID} + deviceVirtualNo := "" + if device != nil { + deviceVirtualNo = device.VirtualNo + if device.VirtualNo != "" { + deviceKey = device.VirtualNo + } + deviceName = device.DeviceName + if deviceName == "" { + deviceName = device.VirtualNo + } + deviceIdentity = map[string]any{"id": device.ID, "virtual_no": device.VirtualNo, "imei": device.IMEI, "sn": device.SN, "generation": device.Generation} + } + cardICCID, cardVirtualNo := "", "" + if card != nil { + cardICCID, cardVirtualNo = card.ICCID, card.VirtualNo + } + result[binding.IotCardID] = append(result[binding.IotCardID], + audit.ResourceInput{ + Type: constants.AuditResourceDeviceSIMBinding, ID: &bindingID, + Key: bindingID, DisplayName: deviceVirtualNo, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleIotCardDeviceBinding, + 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, + }, + audit.ResourceInput{ + Type: constants.AuditResourceDevice, ID: &deviceID, + Key: deviceKey, DisplayName: deviceName, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleIotCardRelatedDevice, + IdentitySnapshot: deviceIdentity, SubjectVisibility: constants.AuditSubjectInternalOnly, + }, + ) + } + return result, nil +} + +func (s *Service) recordCardSeriesBindingAuditFailure( + ctx context.Context, + cards []*model.IotCard, + outcomes map[uint]cardAuditOutcome, + seriesID *uint, + result string, + batchTotal, successCount, failCount int, + metadata map[string]any, + businessErr error, +) { + if s.db == nil || s.auditWriter == nil || len(cards) == 0 { + recordCardAuditSecondaryFailure(ctx, constants.AuditActionIotCardSeriesBindingBatch, 0, businessErr, errors.New(errors.CodeInvalidStatus, "IoT 卡系列绑定审计接缝未配置")) + return + } + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return s.appendCardSeriesBindingAudit(ctx, tx, cards, outcomes, seriesID, result, + batchTotal, successCount, failCount, metadata, businessErr) + }); err != nil { + recordCardAuditSecondaryFailure(ctx, constants.AuditActionIotCardSeriesBindingBatch, 0, businessErr, err) + } +} + +func stableCardBatchEventID(kind, key string) string { + return "evt_" + uuid.NewSHA1(uuid.NameSpaceOID, []byte("iot-card:"+kind+":"+key)).String() +} + +func recordCardAuditSecondaryFailure(ctx context.Context, actionCode string, cardID uint, businessErr, auditErr error) { + errorCode, _ := assetAuditSvc.BuildErrorInfo(businessErr) + linkage := auditcontext.From(ctx) + auditfailure.RecordSecondaryWriteFailure( + actionCode, strconv.FormatUint(uint64(cardID), 10), linkage.RequestID, linkage.CorrelationID, errorCode, auditErr, + ) +} diff --git a/internal/service/iot_card_import/audit.go b/internal/service/iot_card_import/audit.go index 89c7b6c..5406355 100644 --- a/internal/service/iot_card_import/audit.go +++ b/internal/service/iot_card_import/audit.go @@ -2,11 +2,15 @@ package iot_card_import import ( "context" + "strconv" + "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/middleware" ) // AssetAuditService 资产审计服务接口。 @@ -14,46 +18,44 @@ type AssetAuditService interface { LogOperation(ctx context.Context, log *model.AssetOperationLog) } -func (s *Service) logIotCardImportAudit(ctx context.Context, p assetAuditSvc.BuildLogParams) { - if s == nil || s.assetAudit == nil { - return - } - if p.Operator.Type == "" { - p.Operator = assetAuditSvc.OperatorFromContext(ctx) - } - if p.OperationType == "" { - p.OperationType = constants.AssetAuditOpIotCardImportTaskCreate - } - if p.AssetType == "" { - p.AssetType = constants.AssetTypeIotCard - } - p.BeforeData, p.AfterData = assetAuditSvc.WrapOperationContent(p.BeforeData, p.AfterData, nil) - s.assetAudit.LogOperation(ctx, assetAuditSvc.BuildLog(ctx, p)) +func (s *Service) writeImportTaskAudit(ctx context.Context, tx *gorm.DB, task *model.IotCardImportTask, before, after map[string]any, result, phase, errorCode, errorSummary string) error { + return s.auditWriter.WriteTask(ctx, tx, infraAudit.TaskInput{ + EventID: infraAudit.TaskEventID(constants.AuditResourceIotCardImportTask, task.ID, phase), + ActionCode: constants.AuditActionIotCardImportTaskCreated, Summary: "创建 IoT 卡导入任务", + TaskID: task.ID, TaskNo: task.TaskNo, + Actor: infraAudit.ActorInput{ + Kind: constants.AuditActorAccount, ID: strconv.FormatUint(uint64(middleware.GetUserIDFromContext(ctx)), 10), + Name: middleware.GetUsernameFromContext(ctx), + }, + Source: constants.AuditSourceAdminAPI, ScopeType: constants.AuditScopePlatform, + Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary, + IdentitySnapshot: map[string]any{ + "id": task.ID, "task_no": task.TaskNo, "file_name": task.FileName, + "carrier_id": task.CarrierID, "carrier_name": task.CarrierName, "batch_no": task.BatchNo, + "card_category": task.CardCategory, "realname_policy": task.RealnamePolicy, + }, + BeforeData: before, AfterData: after, + }) } -func newIotCardImportAuditParams( - taskID uint, - taskNo string, - req *dto.ImportIotCardRequest, - resultStatus string, - err error, -) assetAuditSvc.BuildLogParams { - afterData := map[string]any{} - if req != nil { - afterData["carrier_id"] = req.CarrierID - afterData["batch_no"] = req.BatchNo - afterData["file_key"] = req.FileKey - afterData["card_category"] = req.CardCategory - afterData["realname_policy"] = req.RealnamePolicy +func (s *Service) recordImportTaskAudit(ctx context.Context, task *model.IotCardImportTask, 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 } - errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(err) - return assetAuditSvc.BuildLogParams{ - AssetID: taskID, - AssetIdentifier: taskNo, - OperationDesc: "创建IoT卡导入任务", - 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.writeImportTaskAudit(ctx, tx, task, before, after, result, phase, code, summary) + }); err != nil { + auditfailure.RecordSecondaryWriteFailure(constants.AuditActionIotCardImportTaskCreated, task.TaskNo, "", task.TaskNo, code, err) + } +} + +func importTaskState(task *model.IotCardImportTask) 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, } } diff --git a/internal/service/iot_card_import/service.go b/internal/service/iot_card_import/service.go index a0c7e8a..e8f9768 100644 --- a/internal/service/iot_card_import/service.go +++ b/internal/service/iot_card_import/service.go @@ -3,8 +3,10 @@ package iot_card_import import ( "context" "path/filepath" + "strconv" "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 +25,7 @@ type Service struct { carrierStore carrierGetter queueClient *queue.Client assetAudit AssetAuditService + auditWriter *audit.Writer } type carrierGetter interface { @@ -50,14 +53,19 @@ func New( importTaskStore *postgres.IotCardImportTaskStore, queueClient *queue.Client, assetAudit AssetAuditService, + auditWriters ...*audit.Writer, ) *Service { - return &Service{ + service := &Service{ db: db, importTaskStore: importTaskStore, carrierStore: NewCarrierStore(db), queueClient: queueClient, assetAudit: assetAudit, } + if len(auditWriters) > 0 { + service.auditWriter = auditWriters[0] + } + return service } type IotCardImportPayload struct { @@ -67,16 +75,12 @@ type IotCardImportPayload struct { func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportIotCardRequest) (*dto.ImportIotCardResponse, error) { userID := middleware.GetUserIDFromContext(ctx) if userID == 0 { - appErr := errors.New(errors.CodeUnauthorized, "未授权访问") - s.logIotCardImportAudit(ctx, newIotCardImportAuditParams(0, "", req, constants.AssetAuditResultDenied, appErr)) - return nil, appErr + return nil, errors.New(errors.CodeUnauthorized, "未授权访问") } carrier, err := s.carrierStore.GetByID(ctx, req.CarrierID) if err != nil { - appErr := errors.New(errors.CodeInvalidParam, "运营商不存在") - s.logIotCardImportAudit(ctx, newIotCardImportAuditParams(0, "", req, constants.AssetAuditResultDenied, appErr)) - return nil, appErr + return nil, errors.New(errors.CodeInvalidParam, "运营商不存在") } taskNo := s.importTaskStore.GenerateTaskNo(ctx) @@ -103,9 +107,17 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportIotCardRe 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, "IoT 卡导入任务统一审计接缝未配置") + } + 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.writeImportTaskAudit(ctx, tx, task, nil, importTaskState(task), constants.AuditResultSuccess, "created", "", "") + }); err != nil { appErr := errors.Wrap(errors.CodeInternalError, err, "创建导入任务失败") - s.logIotCardImportAudit(ctx, newIotCardImportAuditParams(0, taskNo, req, constants.AssetAuditResultFailed, appErr)) + s.recordImportTaskAudit(ctx, task, nil, importTaskState(task), constants.AuditResultFailed, "create_failed", errors.CodeDatabaseError, "创建 IoT 卡导入任务失败") return nil, appErr } @@ -117,14 +129,23 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportIotCardRe asynq.Queue(constants.QueueForTaskType(constants.TaskTypeIotCardImport)), ) if err != nil { - s.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败: "+err.Error()) + secondaryErr := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + before := importTaskState(task) + if updateErr := tx.WithContext(ctx).Model(&model.IotCardImportTask{}).Where("id = ?", task.ID).Updates(map[string]any{ + "status": model.ImportTaskStatusFailed, "error_message": "任务入队失败", "completed_at": time.Now(), "updated_at": time.Now(), + }).Error; updateErr != nil { + return updateErr + } + task.Status, task.ErrorMessage = model.ImportTaskStatusFailed, "任务入队失败" + return s.writeImportTaskAudit(ctx, tx, task, before, importTaskState(task), constants.AuditResultFailed, "enqueue_failed", strconv.Itoa(errors.CodeTaskQueueError), "IoT 卡导入任务入队失败") + }) + if secondaryErr != nil { + s.recordImportTaskAudit(ctx, task, nil, importTaskState(task), constants.AuditResultFailed, "enqueue_audit_failed", errors.CodeTaskQueueError, "IoT 卡导入任务入队失败") + } appErr := errors.Wrap(errors.CodeInternalError, err, "任务入队失败") - s.logIotCardImportAudit(ctx, newIotCardImportAuditParams(task.ID, taskNo, req, constants.AssetAuditResultFailed, appErr)) return nil, appErr } - s.logIotCardImportAudit(ctx, newIotCardImportAuditParams(task.ID, taskNo, req, constants.AssetAuditResultSuccess, nil)) - return &dto.ImportIotCardResponse{ TaskID: task.ID, TaskNo: taskNo, diff --git a/internal/service/order/asset_wallet_reservation_integration_test.go b/internal/service/order/asset_wallet_reservation_integration_test.go new file mode 100644 index 0000000..d3df709 --- /dev/null +++ b/internal/service/order/asset_wallet_reservation_integration_test.go @@ -0,0 +1,80 @@ +package order + +import ( + "context" + "testing" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/testutil" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "gorm.io/gorm" +) + +// TestAssetWalletOrderReservationLifecycle 验证个人钱包订单冻结、超额拦截、支付核销和历史订单兼容。 +func TestAssetWalletOrderReservationLifecycle(t *testing.T) { + tx := testutil.NewPostgresTransaction(t) + resourceID := uint(time.Now().UnixNano() & testIDMask) + wallet := &model.AssetWallet{ + ResourceType: constants.AssetWalletResourceTypeIotCard, + ResourceID: resourceID, + Balance: 1000, + Status: 1, + } + if err := tx.Create(wallet).Error; err != nil { + t.Fatalf("创建测试资产钱包失败:%v", err) + } + service := &Service{} + order := &model.Order{ + OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypePersonal, + PaymentMethod: model.PaymentMethodWallet, TotalAmount: 700, IotCardID: &resourceID, + } + if err := service.freezeAssetWalletForOrder(context.Background(), tx, order); err != nil { + t.Fatalf("冻结订单金额失败:%v", err) + } + assertAssetWalletFunds(t, tx, wallet.ID, 1000, 700) + if order.AssetWalletReservationWalletID == nil || *order.AssetWalletReservationWalletID != wallet.ID || order.AssetWalletReservedAmount != 700 { + t.Fatalf("订单预占快照错误:wallet_id=%v amount=%d", order.AssetWalletReservationWalletID, order.AssetWalletReservedAmount) + } + + overdrawOrder := &model.Order{ + OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypePersonal, + PaymentMethod: model.PaymentMethodWallet, TotalAmount: 400, IotCardID: &resourceID, + } + if err := service.freezeAssetWalletForOrder(context.Background(), tx, overdrawOrder); err == nil { + t.Fatal("可用余额不足时必须拒绝第二笔冻结") + } + assertAssetWalletFunds(t, tx, wallet.ID, 1000, 700) + + if _, err := service.deductAssetWalletForOrder(context.Background(), tx, order, constants.AssetWalletResourceTypeIotCard, resourceID); err != nil { + t.Fatalf("核销订单预占失败:%v", err) + } + assertAssetWalletFunds(t, tx, wallet.ID, 300, 0) + + if err := tx.Model(&model.AssetWallet{}).Where("id = ?", wallet.ID). + Updates(map[string]any{"balance": 300, "frozen_balance": 200, "version": 10}).Error; err != nil { + t.Fatalf("准备历史订单场景失败:%v", err) + } + legacyOrder := &model.Order{ + OrderType: model.OrderTypeSingleCard, BuyerType: model.BuyerTypePersonal, + PaymentMethod: model.PaymentMethodWallet, TotalAmount: 150, IotCardID: &resourceID, + } + if err := service.releaseAssetWalletReservation(context.Background(), tx, legacyOrder); err != nil { + t.Fatalf("历史订单取消不应释放其他订单冻结额:%v", err) + } + if _, err := service.deductAssetWalletForOrder(context.Background(), tx, legacyOrder, constants.AssetWalletResourceTypeIotCard, resourceID); err == nil { + t.Fatal("历史订单不得占用其他订单的冻结余额") + } + assertAssetWalletFunds(t, tx, wallet.ID, 300, 200) +} + +func assertAssetWalletFunds(t *testing.T, tx *gorm.DB, walletID uint, balance, frozenBalance int64) { + t.Helper() + var wallet model.AssetWallet + if err := tx.First(&wallet, walletID).Error; err != nil { + t.Fatalf("查询资产钱包失败:%v", err) + } + if wallet.Balance != balance || wallet.FrozenBalance != frozenBalance { + t.Fatalf("资产钱包金额错误:balance=%d frozen=%d,期望 balance=%d frozen=%d", wallet.Balance, wallet.FrozenBalance, balance, frozenBalance) + } +} diff --git a/internal/service/order/audit.go b/internal/service/order/audit.go new file mode 100644 index 0000000..6f05a20 --- /dev/null +++ b/internal/service/order/audit.go @@ -0,0 +1,363 @@ +package 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/errors" +) + +// SetLifecycleAudit 注入订单生命周期统一审计 Writer。 +func (s *Service) SetLifecycleAudit(writer *audit.Writer) { + s.auditWriter = writer +} + +func (s *Service) appendOrderAudit(ctx context.Context, tx *gorm.DB, actionCode, summary string, order *model.Order, beforeData, afterData map[string]any) error { + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "订单统一审计接缝未配置") + } + resources, err := orderAuditResources(ctx, tx, order, beforeData, afterData) + if err != nil { + return err + } + return s.auditWriter.Append(ctx, tx, audit.AppendInput{ + ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform, + Result: constants.AuditResultSuccess, CorrelationID: order.OrderNo, + Metadata: map[string]any{ + "buyer_type": order.BuyerType, "buyer_id": order.BuyerID, + "purchase_role": order.PurchaseRole, "payment_method": order.PaymentMethod, + }, + Resources: resources, + }) +} + +func (s *Service) recordOrderFailure(ctx context.Context, actionCode, summary string, order *model.Order, businessErr error) { + if businessErr == nil || order == nil || order.OrderNo == "" || s.auditWriter == nil || s.db == nil { + return + } + resource := audit.OrderResource(order, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleOrderTarget) + resource.BeforeData = orderStateData(order) + correlationID := order.OrderNo + if order.ID == 0 { + correlationID = "" + } + s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{ + ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform, + CorrelationID: correlationID, Resources: []audit.ResourceInput{resource}, + }, businessErr) +} + +func (s *Service) recordAgentWalletOrderFailure(ctx context.Context, actionCode, summary string, order *model.Order, shopID uint, businessErr error) { + if businessErr == nil || order == nil || order.OrderNo == "" || s.auditWriter == nil || s.db == nil { + return + } + orderSnapshot := *order + if orderSnapshot.ID > 0 { + var count int64 + if err := s.db.WithContext(ctx).Unscoped().Model(&model.Order{}).Where("id = ?", orderSnapshot.ID).Count(&count).Error; err == nil && count == 0 { + orderSnapshot.ID = 0 + } + } + primary := audit.OrderResource(&orderSnapshot, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleOrderTarget) + primary.BeforeData = orderStateData(order) + resources := []audit.ResourceInput{primary} + + var reservation model.AgentWalletReservation + if order.ID > 0 { + if err := s.db.WithContext(ctx).Where("reference_type = ? AND reference_id = ?", constants.ReferenceTypeOrder, order.ID).First(&reservation).Error; err == nil { + reservationID := strconv.FormatUint(uint64(reservation.ID), 10) + resources = append(resources, audit.ResourceInput{ + Type: constants.AuditResourceAgentWalletReservation, ID: &reservationID, + Key: reservation.ReferenceType + ":" + strconv.FormatUint(uint64(reservation.ReferenceID), 10), DisplayName: "订单钱包预占 " + reservationID, + 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": reservation.Status, + "reference_type": reservation.ReferenceType, "reference_id": reservation.ReferenceID, + }, + BeforeData: map[string]any{"status": reservation.Status}, + }) + shopID = reservation.ShopID + } + } + if shopID > 0 { + var wallet model.AgentWallet + if err := s.db.WithContext(ctx).Unscoped().Where("shop_id = ? AND wallet_type = ?", shopID, constants.AgentWalletTypeMain).First(&wallet).Error; err == nil { + walletID := strconv.FormatUint(uint64(wallet.ID), 10) + resources = append(resources, audit.ResourceInput{ + Type: constants.AuditResourceAgentWallet, ID: &walletID, Key: walletID, DisplayName: "代理主钱包 " + walletID, + Relation: constants.AuditResourceRelationReference, 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": wallet.Balance, "frozen_balance": wallet.FrozenBalance}, + }) + } + } + s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{ + ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform, + CorrelationID: order.OrderNo, Metadata: map[string]any{"amount": order.TotalAmount}, Resources: resources, + }, businessErr) +} + +// RecordCreateFailure 在订单创建调用方已识别资产后记录失败或拒绝事实。 +func (s *Service) RecordCreateFailure(ctx context.Context, order *model.Order, businessErr error) { + s.recordOrderFailure(ctx, constants.AuditActionOrderCreated, "创建订单失败", order, businessErr) +} + +func orderAuditResources(ctx context.Context, tx *gorm.DB, order *model.Order, beforeData, afterData map[string]any) ([]audit.ResourceInput, error) { + if order == nil || order.ID == 0 || order.OrderNo == "" { + return nil, errors.New(errors.CodeInvalidParam, "订单审计资源不完整") + } + primary := audit.OrderResource(order, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleOrderTarget) + primary.BeforeData, primary.AfterData = beforeData, afterData + primary.SubjectVisibility = constants.AuditSubjectResult + primary.SubjectSummary = "订单状态已更新" + resources := []audit.ResourceInput{primary} + + buyer, err := orderBuyerAuditResource(ctx, tx, order) + if err != nil { + return nil, err + } + if buyer != nil { + resources = append(resources, *buyer) + } + asset, err := orderAssetAuditResource(ctx, tx, order) + if err != nil { + return nil, err + } + if asset != nil { + resources = append(resources, *asset) + } + + packages, err := orderPackageAuditResources(ctx, tx, order.ID) + if err != nil { + return nil, err + } + resources = append(resources, packages...) + finance, err := orderFinanceAuditResources(ctx, tx, order) + if err != nil { + return nil, err + } + return append(resources, finance...), nil +} + +func orderBuyerAuditResource(ctx context.Context, tx *gorm.DB, order *model.Order) (*audit.ResourceInput, error) { + if order.BuyerID == 0 { + return nil, nil + } + switch order.BuyerType { + case model.BuyerTypeAgent: + var shop model.Shop + if err := tx.WithContext(ctx).First(&shop, order.BuyerID).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单买家店铺审计快照失败") + } + resource := audit.ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleOrderBuyer) + return &resource, nil + case model.BuyerTypePersonal: + var customer model.PersonalCustomer + if err := tx.WithContext(ctx).First(&customer, order.BuyerID).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单买家审计快照失败") + } + id := strconv.FormatUint(uint64(customer.ID), 10) + resource := audit.ResourceInput{ + Type: constants.AuditResourcePersonalCustomer, ID: &id, Key: id, DisplayName: customer.Nickname, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleOrderBuyer, + IdentitySnapshot: map[string]any{ + "id": customer.ID, "nickname": customer.Nickname, "wx_open_id": customer.WxOpenID, + "wx_union_id": customer.WxUnionID, "status": customer.Status, + }, + } + return &resource, nil + default: + return nil, nil + } +} + +func orderAssetAuditResource(ctx context.Context, tx *gorm.DB, order *model.Order) (*audit.ResourceInput, error) { + if order.IotCardID != nil { + var card model.IotCard + if err := tx.WithContext(ctx).First(&card, *order.IotCardID).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单关联卡审计快照失败") + } + id := strconv.FormatUint(uint64(card.ID), 10) + resource := audit.ResourceInput{ + Type: constants.AuditResourceIotCard, ID: &id, Key: audit.IotCardResourceKey(&card), DisplayName: card.ICCID, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleOrderAsset, + IdentitySnapshot: audit.IotCardIdentitySnapshot(&card), SubjectVisibility: constants.AuditSubjectResult, + SubjectSummary: "关联订单状态已更新", + } + return &resource, nil + } + if order.DeviceID != nil { + var device model.Device + if err := tx.WithContext(ctx).First(&device, *order.DeviceID).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单关联设备审计快照失败") + } + id := strconv.FormatUint(uint64(device.ID), 10) + resource := audit.ResourceInput{ + Type: constants.AuditResourceDevice, ID: &id, Key: audit.DeviceResourceKey(&device), DisplayName: device.VirtualNo, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleOrderAsset, + IdentitySnapshot: audit.DeviceIdentitySnapshot(&device), SubjectVisibility: constants.AuditSubjectResult, + SubjectSummary: "关联订单状态已更新", + } + return &resource, nil + } + return nil, nil +} + +func orderPackageAuditResources(ctx context.Context, tx *gorm.DB, orderID uint) ([]audit.ResourceInput, error) { + var packages []model.Package + if err := tx.WithContext(ctx).Model(&model.Package{}).Distinct("tb_package.*"). + Joins("JOIN tb_order_item item ON item.package_id = tb_package.id AND item.deleted_at IS NULL"). + Where("item.order_id = ?", orderID).Order("tb_package.id ASC").Find(&packages).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单关联套餐审计快照失败") + } + resources := make([]audit.ResourceInput, 0, len(packages)) + for i := range packages { + resources = append(resources, audit.PackageResource(&packages[i], constants.AuditResourceRelationReference, constants.AuditResourceRoleOrderPackage, nil, nil)) + } + return resources, nil +} + +func orderFinanceAuditResources(ctx context.Context, tx *gorm.DB, order *model.Order) ([]audit.ResourceInput, error) { + resources := make([]audit.ResourceInput, 0, 5) + agentResources, err := agentWalletOrderAuditResources(ctx, tx, order.ID) + if err != nil { + return nil, err + } + resources = append(resources, agentResources...) + assetResources, err := assetWalletOrderAuditResources(ctx, tx, order.OrderNo) + if err != nil { + return nil, err + } + resources = append(resources, assetResources...) + + var payments []model.Payment + if err := tx.WithContext(ctx).Where("order_id = ?", order.ID).Order("id ASC").Find(&payments).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单支付记录审计快照失败") + } + for i := range payments { + id := strconv.FormatUint(uint64(payments[i].ID), 10) + resources = append(resources, audit.ResourceInput{ + Type: constants.AuditResourcePayment, ID: &id, Key: payments[i].PaymentNo, DisplayName: payments[i].PaymentNo, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleOrderPayment, + IdentitySnapshot: map[string]any{ + "id": payments[i].ID, "payment_no": payments[i].PaymentNo, "order_id": payments[i].OrderID, + "order_type": payments[i].OrderType, "payment_method": payments[i].PaymentMethod, + "amount": payments[i].Amount, "status": payments[i].Status, + "third_party_trade_no": payments[i].ThirdPartyTradeNo, "payment_config_id": payments[i].PaymentConfigID, + }, + }) + } + return resources, nil +} + +func agentWalletOrderAuditResources(ctx context.Context, tx *gorm.DB, orderID uint) ([]audit.ResourceInput, error) { + var transactions []model.AgentWalletTransaction + if err := tx.WithContext(ctx).Where("reference_type = ? AND reference_id = ?", constants.ReferenceTypeOrder, orderID). + Order("id ASC").Find(&transactions).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单代理钱包流水审计快照失败") + } + resources := make([]audit.ResourceInput, 0, len(transactions)*2) + wallets := make(map[uint]struct{}, len(transactions)) + for i := range transactions { + transaction := &transactions[i] + if _, ok := wallets[transaction.AgentWalletID]; !ok { + var wallet model.AgentWallet + if err := tx.WithContext(ctx).First(&wallet, transaction.AgentWalletID).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单代理钱包审计快照失败") + } + id := strconv.FormatUint(uint64(wallet.ID), 10) + resources = append(resources, audit.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": transaction.BalanceBefore}, + AfterData: map[string]any{"balance": transaction.BalanceAfter}, + }) + wallets[transaction.AgentWalletID] = struct{}{} + } + id := strconv.FormatUint(uint64(transaction.ID), 10) + resources = append(resources, audit.ResourceInput{ + Type: constants.AuditResourceAgentWalletTransaction, ID: &id, Key: id, DisplayName: "代理钱包流水 " + id, + Relation: constants.AuditResourceRelationReference, 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, + }, + }) + } + return resources, nil +} + +func assetWalletOrderAuditResources(ctx context.Context, tx *gorm.DB, orderNo string) ([]audit.ResourceInput, error) { + var transactions []model.AssetWalletTransaction + if err := tx.WithContext(ctx).Where("reference_type = ? AND reference_no = ?", constants.ReferenceTypeOrder, orderNo). + Order("id ASC").Find(&transactions).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单资产钱包流水审计快照失败") + } + resources := make([]audit.ResourceInput, 0, len(transactions)*2) + wallets := make(map[uint]struct{}, len(transactions)) + for i := range transactions { + transaction := &transactions[i] + if _, ok := wallets[transaction.AssetWalletID]; !ok { + var wallet model.AssetWallet + if err := tx.WithContext(ctx).First(&wallet, transaction.AssetWalletID).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单资产钱包审计快照失败") + } + id := strconv.FormatUint(uint64(wallet.ID), 10) + resources = append(resources, audit.ResourceInput{ + Type: constants.AuditResourceAssetWallet, ID: &id, Key: id, DisplayName: "资产钱包 " + id, + Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleOrderWallet, + 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: map[string]any{"balance": transaction.BalanceBefore}, + AfterData: map[string]any{"balance": transaction.BalanceAfter}, + }) + wallets[transaction.AssetWalletID] = struct{}{} + } + id := strconv.FormatUint(uint64(transaction.ID), 10) + resources = append(resources, audit.ResourceInput{ + Type: constants.AuditResourceAssetWalletTransaction, ID: &id, Key: id, DisplayName: "资产钱包流水 " + id, + Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleOrderWalletTransaction, + IdentitySnapshot: map[string]any{ + "id": transaction.ID, "asset_wallet_id": transaction.AssetWalletID, + "resource_type": transaction.ResourceType, "resource_id": transaction.ResourceID, + "transaction_type": transaction.TransactionType, "reference_type": transaction.ReferenceType, + "reference_no": transaction.ReferenceNo, "status": transaction.Status, + }, + AfterData: map[string]any{ + "amount": transaction.Amount, "balance_before": transaction.BalanceBefore, "balance_after": transaction.BalanceAfter, + }, + }) + } + return resources, nil +} + +func orderStateData(order *model.Order) map[string]any { + if order == nil { + return nil + } + return map[string]any{ + "payment_status": order.PaymentStatus, "payment_method": order.PaymentMethod, + "total_amount": order.TotalAmount, "actual_paid_amount": order.ActualPaidAmount, + "paid_at": order.PaidAt, "expires_at": order.ExpiresAt, + "purchase_role": order.PurchaseRole, + } +} diff --git a/internal/service/order/payment_audit.go b/internal/service/order/payment_audit.go new file mode 100644 index 0000000..119d5f8 --- /dev/null +++ b/internal/service/order/payment_audit.go @@ -0,0 +1,78 @@ +package 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" +) + +// SetPaymentIntegrationLog 注入支付渠道 Integration Log。 +func (s *Service) SetPaymentIntegrationLog(repository *integrationlog.Repository) { + s.paymentIntegration = repository +} + +func (s *Service) startOrderPaymentAttempt(ctx context.Context, order *model.Order, 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(order.ID), 10) + resourceKey, series, correlationID := order.OrderNo, "order-payment:"+resourceID+":"+constants.IntegrationOperationPaymentPreCreate, order.OrderNo + 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.AuditResourceOrder, ResourceID: &resourceID, ResourceKey: &resourceKey, + ExternalID: &resourceKey, TriggerSource: &triggerSource, TriggerScene: &triggerScene, + TriggerSeries: &series, CorrelationID: &correlationID, + RequestSummary: map[string]any{"payment_config_id": order.PaymentConfigID, "amount": order.TotalAmount}, + }) + return log, time.Now(), err +} + +func (s *Service) completeOrderPaymentAttempt(ctx context.Context, log *model.IntegrationLog, startedAt time.Time, result, providerCode, safeMessage string) error { + if log == nil { + return errors.New(errors.CodeInvalidStatus, "支付 Integration Log 尝试不存在") + } + 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 (s *Service) appendPaymentConfirmedAudit(ctx context.Context, tx *gorm.DB, payment *model.Payment, order *model.Order, beforePayment, afterPayment, beforeOrder, afterOrder map[string]any) error { + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "支付统一审计接缝未配置") + } + paymentResource := audit.PaymentResource(payment, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePaymentTarget, beforePayment, afterPayment) + orderResource := audit.OrderResource(order, constants.AuditResourceRelationAffected, constants.AuditResourceRolePaymentBusinessOrder) + orderResource.BeforeData, orderResource.AfterData = beforeOrder, afterOrder + orderResource.SubjectVisibility = constants.AuditSubjectResult + orderResource.SubjectSummary = "订单支付已确认" + return s.auditWriter.Append(ctx, tx, audit.AppendInput{ + ActionCode: constants.AuditActionPaymentConfirmed, Summary: "第三方支付确认订单已支付", + ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, + CorrelationID: payment.PaymentNo, Resources: []audit.ResourceInput{paymentResource, orderResource}, + }) +} + +func paymentStateData(payment *model.Payment) map[string]any { + return map[string]any{ + "status": payment.Status, "third_party_trade_no": payment.ThirdPartyTradeNo, + "paid_at": payment.PaidAt, + } +} diff --git a/internal/service/order/service.go b/internal/service/order/service.go index 942accb..5a086e6 100644 --- a/internal/service/order/service.go +++ b/internal/service/order/service.go @@ -13,6 +13,8 @@ import ( cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package" + "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" packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" @@ -20,6 +22,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/service/purchase_validation" "github.com/break/junhong_cmp_fiber/internal/store" "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" "github.com/break/junhong_cmp_fiber/pkg/fuiou" @@ -68,6 +71,8 @@ type Service struct { agentWalletDebit *walletapp.DebitService agentWalletReservation *walletapp.ReservationService observationSeriesEvents cardObservationApp.SeriesEventWriter + auditWriter *audit.Writer + paymentIntegration *integrationlog.Repository } // SetObservationSeriesEventWriter 注入购包成功观测序列 Outbox Writer。 @@ -145,7 +150,7 @@ func (s *Service) SetResumeCallback(callback packagepkg.ResumeCallback) { // CreateAdminOrder 后台订单创建(仅支持 wallet/offline,立即扣款或激活) // 与 CreateH5Order 的核心区别:后台订单不创建待支付状态,wallet 立即扣款,offline 立即激活 // POST /api/admin/orders -func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) { +func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrderRequest, buyerType string, buyerID uint) (resp *dto.OrderResponse, err error) { resolvedCard, resolvedDevice, resolveErr := s.resolveAssetByIdentifier(ctx, req.Identifier) if resolveErr != nil { return nil, resolveErr @@ -164,9 +169,16 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde deviceID = &resolvedDevice.ID resourceShopID = resolvedDevice.ShopID } + auditOrder := &model.Order{ + OrderNo: "create:" + orderType + ":" + req.Identifier, OrderType: orderType, + BuyerType: buyerType, BuyerID: buyerID, IotCardID: iotCardID, DeviceID: deviceID, + AssetIdentifier: req.Identifier, PaymentMethod: req.PaymentMethod, + } + defer func() { + s.recordOrderFailure(ctx, constants.AuditActionOrderCreated, "创建订单失败", auditOrder, err) + }() var validationResult *purchase_validation.PurchaseValidationResult - var err error operatorUserType := middleware.GetUserTypeFromContext(ctx) validationCtx := ctx if req.PaymentMethod == model.PaymentMethodOffline && (operatorUserType == constants.UserTypeSuperAdmin || operatorUserType == constants.UserTypePlatform) { @@ -477,6 +489,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde PurchaseRole: purchaseRole, PaymentConfigID: paymentConfigID, } + auditOrder = order // 线下支付订单写入支付凭证 file_key 列表 if req.PaymentMethod == model.PaymentMethodOffline { @@ -549,9 +562,16 @@ func rewriteAdminAgentPackageOffShelfError(err error, resourceShopID *uint, shou // CreateH5Order H5 端订单创建(支持 wallet/wechat/alipay,支持待支付状态) // 保留原 Create() 方法的完整逻辑,H5 端行为不变 // POST /api/h5/orders -func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest, buyerType string, buyerID uint) (*dto.OrderResponse, error) { +func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest, buyerType string, buyerID uint) (resp *dto.OrderResponse, err error) { var validationResult *purchase_validation.PurchaseValidationResult - var err error + auditOrder := &model.Order{ + OrderNo: "create:" + req.OrderType + ":" + strconv.FormatUint(uint64(buyerID), 10), + OrderType: req.OrderType, BuyerType: buyerType, BuyerID: buyerID, + IotCardID: req.IotCardID, DeviceID: req.DeviceID, PaymentMethod: req.PaymentMethod, + } + defer func() { + s.recordOrderFailure(ctx, constants.AuditActionOrderCreated, "创建订单失败", auditOrder, err) + }() if req.OrderType == model.OrderTypeSingleCard { if req.IotCardID == nil { @@ -776,6 +796,7 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest ExpiresAt: expiresAt, PaymentConfigID: h5PaymentConfigID, } + auditOrder = order items := s.buildOrderItems(userID, validationResult.Packages, itemUnitPriceMap, itemCostPriceMap) @@ -816,7 +837,7 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest // 待支付订单设置过期时间,超过 30 分钟未支付则自动取消 expireTime := now.Add(constants.OrderExpireTimeout) order.ExpiresAt = &expireTime - if err := s.orderStore.Create(ctx, order, items); err != nil { + if err := s.CreatePendingOrder(ctx, order, items); err != nil { return nil, err } s.markOrderCreated(ctx, idempotencyKey, order.ID) @@ -1134,6 +1155,7 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model } actualAmount := *order.ActualPaidAmount var existingOrderID uint + walletDebitAttempted := false err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { matchedOrderID, err := lockAndFindRecentWalletOrder(ctx, tx, order.IdempotencyKey) @@ -1159,6 +1181,7 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model if order.PurchaseRole == model.PurchaseRolePurchaseForSubordinate { relatedShopID = &buyerShopID } + walletDebitAttempted = true if err := s.debitAgentMainWalletInTx(ctx, tx, order, operatorShopID, actualAmount, relatedShopID); err != nil { return err } @@ -1168,11 +1191,17 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model if err := s.activatePackage(ctx, tx, order); err != nil { return err } + if err := s.appendOrderAudit(ctx, tx, constants.AuditActionOrderCreated, "创建并使用钱包支付订单", order, nil, orderStateData(order)); err != nil { + return err + } return nil }) if err != nil { + if walletDebitAttempted { + s.recordAgentWalletOrderFailure(ctx, constants.AuditActionAgentWalletOrderDebited, "代理主钱包订单扣款失败", order, operatorShopID, err) + } return 0, err } if existingOrderID > 0 { @@ -1200,7 +1229,29 @@ func (s *Service) createOrderWithActivation(ctx context.Context, order *model.Or return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败") } - return s.activatePackage(ctx, tx, order) + if err := s.activatePackage(ctx, tx, order); err != nil { + return err + } + return s.appendOrderAudit(ctx, tx, constants.AuditActionOrderCreated, "创建并完成订单", order, nil, orderStateData(order)) + }) +} + +// CreatePendingOrder 在同一事务中创建待支付订单、冻结个人资产钱包、创建明细和审计事件。 +func (s *Service) CreatePendingOrder(ctx context.Context, order *model.Order, items []*model.OrderItem) error { + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := s.freezeAssetWalletForOrder(ctx, tx, order); err != nil { + return err + } + if err := tx.Create(order).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败") + } + for _, item := range items { + item.OrderID = order.ID + if err := tx.Create(item).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败") + } + } + return s.appendOrderAudit(ctx, tx, constants.AuditActionOrderCreated, "创建待支付订单", order, nil, orderStateData(order)) }) } @@ -1394,14 +1445,19 @@ func (s *Service) Cancel(ctx context.Context, id uint, buyerType string, buyerID } if order.BuyerType != buyerType || order.BuyerID != buyerID { - return errors.New(errors.CodeForbidden, "无权操作此订单") + err = errors.New(errors.CodeForbidden, "无权操作此订单") + s.recordOrderFailure(ctx, constants.AuditActionOrderCancelled, "取消订单被拒绝", order, err) + return err } if order.PaymentStatus != model.PaymentStatusPending { - return errors.New(errors.CodeInvalidStatus, "只能取消待支付的订单") + err = errors.New(errors.CodeInvalidStatus, "只能取消待支付的订单") + s.recordOrderFailure(ctx, constants.AuditActionOrderCancelled, "取消订单被拒绝", order, err) + return err } - return s.cancelOrder(ctx, order) + _, err = s.cancelOrder(ctx, order, constants.AuditActionOrderCancelled) + return err } // CancelExpiredOrders 批量取消已超时的待支付订单 @@ -1420,7 +1476,8 @@ func (s *Service) CancelExpiredOrders(ctx context.Context) (int, error) { cancelledCount := 0 for _, order := range orders { - if err := s.cancelOrder(ctx, order); err != nil { + orderCtx := auditcontext.With(ctx, auditcontext.Context{CorrelationID: order.OrderNo}) + if _, err := s.cancelOrder(orderCtx, order, constants.AuditActionOrderExpiredClosed); err != nil { s.logger.Error("自动取消超时订单失败", zap.Uint("order_id", order.ID), zap.String("order_no", order.OrderNo), @@ -1443,8 +1500,10 @@ func (s *Service) CancelExpiredOrders(ctx context.Context) (int, error) { // cancelOrder 内部取消订单逻辑(共用于手动取消和自动超时取消) // 在事务中执行:更新订单状态为已取消、清除过期时间、解冻钱包余额(如有) -func (s *Service) cancelOrder(ctx context.Context, order *model.Order) error { - return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { +func (s *Service) cancelOrder(ctx context.Context, order *model.Order, actionCode string) (bool, error) { + changed := false + walletReleaseAttempted := false + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 使用条件更新确保幂等性:只有待支付的订单才能取消 result := tx.Model(&model.Order{}). Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPending). @@ -1459,11 +1518,11 @@ func (s *Service) cancelOrder(ctx context.Context, order *model.Order) error { // 订单已被处理(幂等),直接返回 return nil } + changed = true - // 检查是否需要解冻钱包余额(混合支付场景) - // 当前系统中钱包支付订单是立即支付的,不会进入待支付状态 - // 此处为预留逻辑,支持未来混合支付场景的钱包解冻 + // 待支付钱包订单取消时释放该订单创建时记录的资金预占。 if order.PaymentMethod == model.PaymentMethodWallet { + walletReleaseAttempted = order.BuyerType == model.BuyerTypeAgent if err := s.unfreezeWalletForCancel(ctx, tx, order); err != nil { s.logger.Error("取消订单时解冻钱包失败", zap.Uint("order_id", order.ID), @@ -1473,8 +1532,22 @@ func (s *Service) cancelOrder(ctx context.Context, order *model.Order) error { } } - return nil + after := *order + after.PaymentStatus = model.PaymentStatusCancelled + after.ExpiresAt = nil + summary := "取消待支付订单" + if actionCode == constants.AuditActionOrderExpiredClosed { + summary = "关闭过期待支付订单" + } + return s.appendOrderAudit(ctx, tx, actionCode, summary, &after, orderStateData(order), orderStateData(&after)) }) + if err != nil { + s.recordOrderFailure(ctx, actionCode, "订单关闭失败", order, err) + if walletReleaseAttempted { + s.recordAgentWalletOrderFailure(ctx, constants.AuditActionAgentWalletOrderReleased, "释放代理主钱包订单预占失败", order, 0, err) + } + } + return changed, err } // unfreezeWalletForCancel 取消订单时解冻钱包余额。 @@ -1494,39 +1567,83 @@ func (s *Service) unfreezeWalletForCancel(ctx context.Context, tx *gorm.DB, orde RequestID: requestID, CorrelationID: order.OrderNo, Remark: "取消订单释放预占资金", }) } else if order.BuyerType == model.BuyerTypePersonal { - // 个人客户钱包(卡/设备钱包) - var resourceType string - var resourceID uint - if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil { - resourceType = "iot_card" - resourceID = *order.IotCardID - } else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil { - resourceType = "device" - resourceID = *order.DeviceID - } else { - return errors.New(errors.CodeInternalError, "无法确定钱包归属") - } - wallet, err := s.assetWalletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID) - if err != nil { - return errors.Wrap(errors.CodeWalletNotFound, err, "查询资产钱包失败") - } - // 资产钱包解冻:直接减少冻结余额 - result := tx.Model(&model.AssetWallet{}). - Where("id = ? AND frozen_balance >= ?", wallet.ID, order.TotalAmount). - Updates(map[string]any{ - "frozen_balance": gorm.Expr("frozen_balance - ?", order.TotalAmount), - }) - if result.Error != nil { - return result.Error - } - if result.RowsAffected == 0 { - return errors.New(errors.CodeInsufficientBalance, "冻结余额不足,无法解冻") - } - return nil + return s.releaseAssetWalletReservation(ctx, tx, order) } return nil } +// freezeAssetWalletForOrder 为个人待支付钱包订单冻结精确金额,并把钱包与金额写入订单快照。 +func (s *Service) freezeAssetWalletForOrder(ctx context.Context, tx *gorm.DB, order *model.Order) error { + if order == nil || order.BuyerType != model.BuyerTypePersonal || order.PaymentMethod != model.PaymentMethodWallet || order.TotalAmount == 0 { + return nil + } + if order.TotalAmount < 0 { + return errors.New(errors.CodeInvalidParam, "订单金额无效") + } + resourceType, resourceID, err := resolveOrderAssetWalletReference(order) + if err != nil { + return err + } + var wallet model.AssetWallet + if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id = ?", resourceType, resourceID).First(&wallet).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeWalletNotFound, "资产钱包不存在") + } + return errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败") + } + result := tx.WithContext(ctx).Model(&model.AssetWallet{}). + Where("id = ? AND balance - frozen_balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version). + Updates(map[string]any{ + "frozen_balance": gorm.Expr("frozen_balance + ?", order.TotalAmount), + "version": gorm.Expr("version + 1"), + "updated_at": time.Now(), + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "冻结资产钱包余额失败") + } + if result.RowsAffected == 0 { + return errors.New(errors.CodeInsufficientBalance, "钱包可用余额不足或并发冲突") + } + order.AssetWalletReservationWalletID = &wallet.ID + order.AssetWalletReservedAmount = order.TotalAmount + return nil +} + +// releaseAssetWalletReservation 释放订单快照指向的个人资产钱包预占。 +// 历史订单没有预占快照,取消时只更新订单状态,避免误释放其他订单的冻结资金。 +func (s *Service) releaseAssetWalletReservation(ctx context.Context, tx *gorm.DB, order *model.Order) error { + if order.AssetWalletReservationWalletID == nil && order.AssetWalletReservedAmount == 0 { + return nil + } + if order.AssetWalletReservationWalletID == nil || order.AssetWalletReservedAmount <= 0 { + return errors.New(errors.CodeInternalError, "资产钱包预占快照不完整") + } + result := tx.WithContext(ctx).Model(&model.AssetWallet{}). + Where("id = ? AND frozen_balance >= ?", *order.AssetWalletReservationWalletID, order.AssetWalletReservedAmount). + Updates(map[string]any{ + "frozen_balance": gorm.Expr("frozen_balance - ?", order.AssetWalletReservedAmount), + "version": gorm.Expr("version + 1"), + "updated_at": time.Now(), + }) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "释放资产钱包预占失败") + } + if result.RowsAffected == 0 { + return errors.New(errors.CodeConflict, "资产钱包预占状态不一致") + } + return nil +} + +func resolveOrderAssetWalletReference(order *model.Order) (string, uint, error) { + if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil { + return constants.AssetWalletResourceTypeIotCard, *order.IotCardID, nil + } + if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil { + return constants.AssetWalletResourceTypeDevice, *order.DeviceID, nil + } + return "", 0, errors.New(errors.CodeInternalError, "无法确定资产钱包归属") +} + func (s *Service) createWalletPaymentRecord(tx *gorm.DB, order *model.Order, paymentMethod string, amount int64) error { paidAt := order.PaidAt if paidAt == nil { @@ -1548,7 +1665,7 @@ func (s *Service) createWalletPaymentRecord(tx *gorm.DB, order *model.Order, pay return nil } -func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) error { +func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, buyerID uint) (err error) { order, err := s.orderStore.GetByID(ctx, orderID) if err != nil { if err == gorm.ErrRecordNotFound { @@ -1556,6 +1673,13 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, } return err } + agentWalletDebitAttempted := false + defer func() { + s.recordOrderFailure(ctx, constants.AuditActionOrderWalletPaid, "钱包支付订单失败", order, err) + if agentWalletDebitAttempted { + s.recordAgentWalletOrderFailure(ctx, constants.AuditActionAgentWalletOrderDebited, "代理主钱包订单扣款失败", order, buyerID, err) + } + }() if order.BuyerType != buyerType || order.BuyerID != buyerID { return errors.New(errors.CodeForbidden, "无权操作此订单") @@ -1636,6 +1760,7 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, order.PaidAt = &now shouldEnqueueCommission = true + agentWalletDebitAttempted = true if err := s.debitAgentMainWalletInTx(ctx, tx, order, resourceID, order.TotalAmount, nil); err != nil { return err } @@ -1644,22 +1769,18 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, return err } - return s.activatePackage(ctx, tx, order) + if err := s.activatePackage(ctx, tx, order); err != nil { + return err + } + after := *order + after.PaymentStatus = model.PaymentStatusPaid + after.PaymentMethod = model.PaymentMethodWallet + after.PaidAt = &now + after.ExpiresAt = nil + return s.appendOrderAudit(ctx, tx, constants.AuditActionOrderWalletPaid, "使用代理钱包支付订单", &after, orderStateData(order), orderStateData(&after)) }) } else { // 资产钱包系统(iot_card 或 device) - wallet, err := s.assetWalletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID) - if err != nil { - if err == gorm.ErrRecordNotFound { - return errors.New(errors.CodeWalletNotFound, "钱包不存在") - } - return err - } - - if wallet.Balance < order.TotalAmount { - return errors.New(errors.CodeInsufficientBalance, "余额不足") - } - err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { result := tx.Model(&model.Order{}). Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending). @@ -1695,22 +1816,12 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, actualPaidAmountSnapshot := actualPaidAmount order.ActualPaidAmount = &actualPaidAmountSnapshot - // 扣款前记录余额快照,用于写入流水 + wallet, err := s.deductAssetWalletForOrder(ctx, tx, order, resourceType, resourceID) + if err != nil { + return err + } balanceBefore := wallet.Balance - walletResult := tx.Model(&model.AssetWallet{}). - Where("id = ? AND balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version). - Updates(map[string]any{ - "balance": gorm.Expr("balance - ?", order.TotalAmount), - "version": gorm.Expr("version + 1"), - }) - if walletResult.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, walletResult.Error, "扣减钱包余额失败") - } - if walletResult.RowsAffected == 0 { - return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突") - } - // 扣款成功后补写扣款流水,填补流水表中扣款记录缺失的问题 deductTx := &model.AssetWalletTransaction{ AssetWalletID: wallet.ID, @@ -1736,7 +1847,15 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, return err } - return s.activatePackage(ctx, tx, order) + if err := s.activatePackage(ctx, tx, order); err != nil { + return err + } + after := *order + after.PaymentStatus = model.PaymentStatusPaid + after.PaymentMethod = model.PaymentMethodWallet + after.PaidAt = &now + after.ExpiresAt = nil + return s.appendOrderAudit(ctx, tx, constants.AuditActionOrderWalletPaid, "使用资产钱包支付订单", &after, orderStateData(order), orderStateData(&after)) }) } @@ -1750,6 +1869,47 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string, return nil } +// deductAssetWalletForOrder 完成个人钱包订单扣款;新订单同时核销冻结额,历史订单只使用可用余额。 +func (s *Service) deductAssetWalletForOrder(ctx context.Context, tx *gorm.DB, order *model.Order, resourceType string, resourceID uint) (*model.AssetWallet, error) { + var wallet model.AssetWallet + query := tx.WithContext(ctx) + if order.AssetWalletReservationWalletID != nil || order.AssetWalletReservedAmount != 0 { + if order.AssetWalletReservationWalletID == nil || order.AssetWalletReservedAmount != order.TotalAmount || order.AssetWalletReservedAmount <= 0 { + return nil, errors.New(errors.CodeConflict, "资产钱包预占快照与订单金额不一致") + } + query = query.Where("id = ? AND resource_type = ? AND resource_id = ?", *order.AssetWalletReservationWalletID, resourceType, resourceID) + } else { + query = query.Where("resource_type = ? AND resource_id = ?", resourceType, resourceID) + } + if err := query.First(&wallet).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeWalletNotFound, "资产钱包不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败") + } + + updates := map[string]any{ + "balance": gorm.Expr("balance - ?", order.TotalAmount), + "version": gorm.Expr("version + 1"), + "updated_at": time.Now(), + } + updateQuery := tx.WithContext(ctx).Model(&model.AssetWallet{}).Where("id = ? AND version = ?", wallet.ID, wallet.Version) + if order.AssetWalletReservedAmount > 0 { + updateQuery = updateQuery.Where("balance >= ? AND frozen_balance >= ?", order.TotalAmount, order.AssetWalletReservedAmount) + updates["frozen_balance"] = gorm.Expr("frozen_balance - ?", order.AssetWalletReservedAmount) + } else { + updateQuery = updateQuery.Where("balance - frozen_balance >= ?", order.TotalAmount) + } + result := updateQuery.Updates(updates) + if result.Error != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, result.Error, "扣减资产钱包余额失败") + } + if result.RowsAffected == 0 { + return nil, errors.New(errors.CodeInsufficientBalance, "钱包可用余额不足或并发冲突") + } + return &wallet, nil +} + func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, paymentMethod string, actualPaidAmount int64) error { order, err := s.orderStore.GetByOrderNo(ctx, orderNo) if err != nil { @@ -1760,6 +1920,7 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, pay } now := time.Now() + beforeOrder := *order shouldResumeAfterPayment := false shouldEnqueueCommission := false err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -1800,7 +1961,15 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, orderNo string, pay shouldResumeAfterPayment = true shouldEnqueueCommission = true - return s.activatePackage(ctx, tx, order) + if err := s.activatePackage(ctx, tx, order); err != nil { + return err + } + after := *order + after.PaymentStatus = model.PaymentStatusPaid + after.PaymentMethod = paymentMethod + after.PaidAt = &now + after.ExpiresAt = nil + return s.appendOrderAudit(ctx, tx, constants.AuditActionOrderOnlinePaid, "第三方支付确认订单已支付", &after, orderStateData(&beforeOrder), orderStateData(&after)) }) if err != nil { @@ -1838,6 +2007,8 @@ func (s *Service) HandlePaymentRecordCallback(ctx context.Context, paymentNo str } now := time.Now() + beforePayment := *payment + beforeOrder := *order shouldResumeAfterPayment := false shouldEnqueueCommission := false err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { @@ -1900,7 +2071,23 @@ func (s *Service) HandlePaymentRecordCallback(ctx context.Context, paymentNo str shouldResumeAfterPayment = true shouldEnqueueCommission = true - return s.activatePackage(ctx, tx, order) + if err := s.activatePackage(ctx, tx, order); err != nil { + return err + } + afterPayment := beforePayment + afterPayment.Status = model.PaymentRecordStatusPaid + afterPayment.PaidAt = &now + if thirdPartyTradeNo != "" { + afterPayment.ThirdPartyTradeNo = thirdPartyTradeNo + } + afterOrder := beforeOrder + afterOrder.PaymentStatus = model.PaymentStatusPaid + afterOrder.PaymentMethod = paymentMethod + afterOrder.PaidAt = &now + afterOrder.ExpiresAt = nil + afterOrder.ActualPaidAmount = &actualPaidAmountSnapshot + return s.appendPaymentConfirmedAudit(ctx, tx, &afterPayment, &afterOrder, + paymentStateData(&beforePayment), paymentStateData(&afterPayment), orderStateData(&beforeOrder), orderStateData(&afterOrder)) }) if err != nil { @@ -2765,8 +2952,15 @@ func (s *Service) WechatPayJSAPI(ctx context.Context, orderID uint, openID strin description = items[0].PackageName } + attempt, startedAt, err := s.startOrderPaymentAttempt(ctx, order, constants.IntegrationProviderWechatPay, "order_jsapi") + if err != nil { + return nil, err + } result, err := paymentSvc.CreateJSAPIOrder(ctx, order.OrderNo, description, openID, int(order.TotalAmount)) if err != nil { + if completeErr := s.completeOrderPaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "微信支付预下单结果未知"); completeErr != nil { + return nil, completeErr + } s.logger.Error("创建 JSAPI 支付失败", zap.Uint("order_id", orderID), zap.String("order_no", order.OrderNo), @@ -2774,6 +2968,9 @@ func (s *Service) WechatPayJSAPI(ctx context.Context, orderID uint, openID strin ) return nil, err } + if err := s.completeOrderPaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultSuccess, "SUCCESS", ""); err != nil { + return nil, err + } s.logger.Info("创建 JSAPI 支付成功", zap.Uint("order_id", orderID), @@ -2831,8 +3028,15 @@ func (s *Service) WechatPayH5(ctx context.Context, orderID uint, sceneInfo *dto. H5Type: sceneInfo.H5Info.Type, } + attempt, startedAt, err := s.startOrderPaymentAttempt(ctx, order, constants.IntegrationProviderWechatPay, "order_h5") + if err != nil { + return nil, err + } result, err := paymentSvc.CreateH5Order(ctx, order.OrderNo, description, int(order.TotalAmount), h5SceneInfo) if err != nil { + if completeErr := s.completeOrderPaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "微信支付预下单结果未知"); completeErr != nil { + return nil, completeErr + } s.logger.Error("创建 H5 支付失败", zap.Uint("order_id", orderID), zap.String("order_no", order.OrderNo), @@ -2840,6 +3044,9 @@ func (s *Service) WechatPayH5(ctx context.Context, orderID uint, sceneInfo *dto. ) return nil, err } + if err := s.completeOrderPaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultSuccess, "SUCCESS", ""); err != nil { + return nil, err + } s.logger.Info("创建 H5 支付成功", zap.Uint("order_id", orderID), @@ -3075,6 +3282,10 @@ func (s *Service) fuiouPreCreate( termIP = *ip } + attempt, startedAt, err := s.startOrderPaymentAttempt(ctx, order, constants.IntegrationProviderFuiou, "order_"+strings.ToLower(tradeType)) + if err != nil { + return nil, err + } resp, err := client.WxPreCreate( order.OrderNo, strconv.FormatInt(order.TotalAmount, 10), @@ -3085,6 +3296,9 @@ func (s *Service) fuiouPreCreate( openID, ) if err != nil { + if completeErr := s.completeOrderPaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultUnknown, "request_unknown", "富友支付预下单结果未知"); completeErr != nil { + return nil, completeErr + } s.logger.Error("富友预下单失败", zap.Uint("order_id", orderID), zap.String("order_no", order.OrderNo), @@ -3093,6 +3307,9 @@ func (s *Service) fuiouPreCreate( ) return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "富友预下单失败") } + if err := s.completeOrderPaymentAttempt(ctx, attempt, startedAt, constants.IntegrationResultSuccess, "000000", ""); err != nil { + return nil, err + } s.logger.Info("富友预下单成功", zap.Uint("order_id", orderID), diff --git a/internal/service/order_package_invalidate/audit.go b/internal/service/order_package_invalidate/audit.go new file mode 100644 index 0000000..239008c --- /dev/null +++ b/internal/service/order_package_invalidate/audit.go @@ -0,0 +1,39 @@ +package order_package_invalidate + +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) writeInvalidateTaskAudit(ctx context.Context, tx *gorm.DB, task *model.OrderPackageInvalidateTask, before, after map[string]any, result, phase, errorCode, errorSummary string) error { + return s.auditWriter.WriteTask(ctx, tx, audit.TaskInput{ + EventID: audit.TaskEventID(constants.AuditResourceOrderPackageInvalidateTask, task.ID, phase), + ActionCode: constants.AuditActionOrderPackageInvalidateTaskCreated, + 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: constants.AuditScopePlatform, + Result: result, ErrorCode: errorCode, ErrorSummary: errorSummary, + IdentitySnapshot: map[string]any{"id": task.ID, "task_no": task.TaskNo, "file_name": task.FileName}, + BeforeData: before, AfterData: after, + }) +} + +func invalidateTaskState(task *model.OrderPackageInvalidateTask) 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, + } +} diff --git a/internal/service/order_package_invalidate/service.go b/internal/service/order_package_invalidate/service.go index 262b3aa..67eece7 100644 --- a/internal/service/order_package_invalidate/service.go +++ b/internal/service/order_package_invalidate/service.go @@ -3,14 +3,18 @@ package order_package_invalidate import ( "context" "path/filepath" + "strconv" "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/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" @@ -21,17 +25,23 @@ import ( type Service struct { taskStore *postgres.OrderPackageInvalidateTaskStore queueClient *queue.Client + auditWriter *audit.Writer } // New 创建 Service 实例 func New( taskStore *postgres.OrderPackageInvalidateTaskStore, queueClient *queue.Client, + auditWriters ...*audit.Writer, ) *Service { - return &Service{ + service := &Service{ taskStore: taskStore, queueClient: queueClient, } + if len(auditWriters) > 0 { + service.auditWriter = auditWriters[0] + } + return service } // InvalidateTaskPayload Worker 任务载荷 @@ -59,7 +69,15 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderPackageInvalid Updater: userID, } - 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.writeInvalidateTaskAudit(ctx, tx, task, nil, invalidateTaskState(task), constants.AuditResultSuccess, "created", "", "") + }); err != nil { return nil, errors.Wrap(errors.CodeInternalError, err, "创建任务失败") } @@ -71,7 +89,17 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateOrderPackageInvalid asynq.Queue(constants.QueueForTaskType(constants.TaskTypeOrderPackageInvalidate)), ) if err != nil { - s.taskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败: "+err.Error()) + secondaryErr := s.taskStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + before := invalidateTaskState(task) + if updateErr := s.taskStore.WithTx(tx).UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败"); updateErr != nil { + return updateErr + } + task.Status, task.ErrorMessage = model.ImportTaskStatusFailed, "任务入队失败" + return s.writeInvalidateTaskAudit(ctx, tx, task, before, invalidateTaskState(task), constants.AuditResultFailed, "enqueue_failed", strconv.Itoa(errors.CodeTaskQueueError), "订单套餐失效任务入队失败") + }) + if secondaryErr != nil { + auditfailure.RecordSecondaryWriteFailure(constants.AuditActionOrderPackageInvalidateTaskCreated, task.TaskNo, "", task.TaskNo, strconv.Itoa(errors.CodeTaskQueueError), secondaryErr) + } } return s.toResponse(task), nil diff --git a/internal/service/package/activation_service.go b/internal/service/package/activation_service.go index 02dbf24..dadfce7 100644 --- a/internal/service/package/activation_service.go +++ b/internal/service/package/activation_service.go @@ -6,6 +6,7 @@ import ( "time" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "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" @@ -30,6 +31,12 @@ type ActivationService struct { logger *zap.Logger resumeCallback ResumeCallback // 复机回调,可选 observationSeriesEvents cardObservationApp.SeriesEventWriter + auditWriter *audit.Writer +} + +// SetLifecycleAudit 注入套餐权益生命周期统一审计 Writer。 +func (s *ActivationService) SetLifecycleAudit(writer *audit.Writer) { + s.auditWriter = writer } // SetObservationSeriesEventWriter 注入套餐激活成功观测序列 Outbox Writer。 @@ -90,9 +97,11 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType now := time.Now() activated := false + var failedUsage *model.PackageUsage // 在事务中激活套餐 err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { for _, usage := range pendingUsages { + failedUsage = usage // 查询套餐信息 var pkg model.Package if err := tx.First(&pkg, usage.PackageID).Error; err != nil { @@ -150,12 +159,28 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType updates["next_reset_at"] = *nextResetAt } - if err := tx.Model(usage).Updates(updates).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "激活套餐失败") + beforeData := packageUsageStateData(usage) + result := tx.Model(usage).Where("status = ?", constants.PackageUsageStatusPending).Updates(updates) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "激活套餐失败") } + if result.RowsAffected == 0 { + continue + } + activatedUsage := *usage + activatedUsage.Status = constants.PackageUsageStatusActive + activatedUsage.PendingRealnameActivation = false + activatedUsage.ActivatedAt = &activatedAt + activatedUsage.ExpiresAt = &expiresAt + activatedUsage.NextResetAt = nextResetAt if err := s.appendActivationObservation(ctx, tx, usage, carrierType, carrierID, now); err != nil { return err } + if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageActivated, "实名后激活套餐权益", []packageUsageAuditChange{{ + Usage: &activatedUsage, BeforeData: beforeData, AfterData: packageUsageStateData(&activatedUsage), + }}, nil, map[string]any{"activation_source": "realname"}); err != nil { + return err + } s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID) @@ -171,6 +196,7 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType return nil }) if err != nil { + recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageActivated, "实名后激活套餐权益失败", failedUsage, err) return err } if activated { @@ -198,6 +224,7 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy defer s.redis.Del(ctx, lockKey) activated := false + var failedUsage *model.PackageUsage err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { // 任务 9.5: 检测并标记过期的主套餐 now := time.Now() @@ -213,9 +240,17 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy } for _, expiredMain := range expiredMainUsages { + failedUsage = expiredMain // 更新主套餐状态为已过期 - if err := tx.Model(expiredMain).Update("status", constants.PackageUsageStatusExpired).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "更新过期主套餐状态失败") + mainBeforeData := packageUsageStateData(expiredMain) + result := tx.Model(expiredMain). + Where("status = ?", constants.PackageUsageStatusActive). + Update("status", constants.PackageUsageStatusExpired) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新过期主套餐状态失败") + } + if result.RowsAffected == 0 { + continue } expiresAt := now @@ -227,7 +262,13 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy zap.Time("expires_at", expiresAt)) // 任务 9.7: 加油包级联失效 - if err := s.invalidateAddons(ctx, tx, expiredMain.ID); err != nil { + addons, err := s.invalidateAddons(ctx, tx, expiredMain.ID) + if err != nil { + return err + } + expiredUsage := *expiredMain + expiredUsage.Status = constants.PackageUsageStatusExpired + if err := s.appendExpirationAudit(ctx, tx, &expiredUsage, mainBeforeData, addons); err != nil { return err } @@ -242,6 +283,7 @@ func (s *ActivationService) ActivateQueuedPackage(ctx context.Context, carrierTy return nil }) if err != nil { + recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageExpired, "套餐权益到期处理失败", failedUsage, err) return err } if activated { @@ -341,13 +383,14 @@ func (s *ActivationService) ActivateSpecificPackage(ctx context.Context, package } } - if err := s.activatePendingUsage(ctx, tx, ¤tUsage, &pkg, carrierType, carrierID, time.Now(), "指定套餐已激活"); err != nil { + if err := s.activatePendingUsage(ctx, tx, ¤tUsage, &pkg, carrierType, carrierID, time.Now(), "specific", "指定套餐已激活"); err != nil { return err } activated = true return nil }) if err != nil { + recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageActivated, "激活指定套餐权益失败", &usage, err) return err } if activated { @@ -381,22 +424,30 @@ func (s *ActivationService) ActivateNextPendingMainPackage(ctx context.Context, defer s.redis.Del(ctx, lockKey) activated := false + var failedUsage *model.PackageUsage err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { hasActive, err := s.hasActiveMainPackage(ctx, tx, carrierType, carrierID) if err != nil { return err } if hasActive { + s.logger.Info("载体已有占位主套餐,本轮不接续", + zap.String("carrier_type", carrierType), + zap.Uint("carrier_id", carrierID)) return nil } nextMain, err := s.getNextPendingMainPackage(ctx, tx, carrierType, carrierID) if err == gorm.ErrRecordNotFound { + s.logger.Info("载体没有待生效主套餐,本轮不接续", + zap.String("carrier_type", carrierType), + zap.Uint("carrier_id", carrierID)) return nil } if err != nil { return err } + failedUsage = nextMain canActivate, err := s.canActivatePendingUsage(ctx, tx, nextMain, carrierType, carrierID) if err != nil { @@ -416,13 +467,14 @@ func (s *ActivationService) ActivateNextPendingMainPackage(ctx context.Context, return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败") } - if err := s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, time.Now(), "队首待生效套餐已激活"); err != nil { + if err := s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, time.Now(), "queue", "队首待生效套餐已激活"); err != nil { return err } activated = true return nil }) if err != nil { + recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageActivated, "激活排队套餐权益失败", failedUsage, err) return false, err } if activated { @@ -438,16 +490,16 @@ func (s *ActivationService) HasActiveMainPackage(ctx context.Context, carrierTyp } // invalidateAddons 任务 9.7: 加油包级联失效 -func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, masterUsageID uint) error { +func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, masterUsageID uint) ([]packageUsageAuditChange, error) { var addons []*model.PackageUsage if err := tx.Where("master_usage_id = ?", masterUsageID). Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusPending}). Find(&addons).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "查询加油包失败") + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询加油包失败") } if len(addons) == 0 { - return nil + return nil, nil } addonIDs := make([]uint, len(addons)) @@ -459,14 +511,20 @@ func (s *ActivationService) invalidateAddons(ctx context.Context, tx *gorm.DB, m if err := tx.Model(&model.PackageUsage{}). Where("id IN ?", addonIDs). Update("status", constants.PackageUsageStatusInvalidated).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "批量失效加油包失败") + return nil, errors.Wrap(errors.CodeDatabaseError, err, "批量失效加油包失败") } s.logger.Info("加油包已级联失效", zap.Uint("master_usage_id", masterUsageID), zap.Int("addon_count", len(addons))) - return nil + changes := make([]packageUsageAuditChange, 0, len(addons)) + for _, addon := range addons { + beforeData := packageUsageStateData(addon) + addon.Status = constants.PackageUsageStatusInvalidated + changes = append(changes, packageUsageAuditChange{Usage: addon, BeforeData: beforeData, AfterData: packageUsageStateData(addon)}) + } + return changes, nil } // activateNextMainPackage 任务 9.6: 激活下一个待生效主套餐 @@ -502,7 +560,7 @@ func (s *ActivationService) activateNextMainPackage(ctx context.Context, tx *gor return false, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败") } - if err := s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, now, "排队主套餐已激活"); err != nil { + if err := s.activatePendingUsage(ctx, tx, nextMain, &pkg, carrierType, carrierID, now, "queue", "排队主套餐已激活"); err != nil { return false, err } return true, nil @@ -577,7 +635,7 @@ func (s *ActivationService) isCarrierRealnamed(ctx context.Context, tx *gorm.DB, } } -func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, carrierType string, carrierID uint, now time.Time, logMessage string) error { +func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, carrierType string, carrierID uint, now time.Time, activationSource, logMessage string) error { terms, err := ResolveUsageTerms(usage, pkg, s.logger) if err != nil { return err @@ -608,12 +666,27 @@ func (s *ActivationService) activatePendingUsage(ctx context.Context, tx *gorm.D updates["next_reset_at"] = *nextResetAt } - if err := tx.Model(usage).Updates(updates).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "激活排队主套餐失败") + beforeData := packageUsageStateData(usage) + result := tx.Model(usage).Where("status = ?", constants.PackageUsageStatusPending).Updates(updates) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "激活排队主套餐失败") } + if result.RowsAffected == 0 { + return nil + } + usage.Status = constants.PackageUsageStatusActive + usage.PendingRealnameActivation = false + usage.ActivatedAt = &activatedAt + usage.ExpiresAt = &expiresAt + usage.NextResetAt = nextResetAt if err := s.appendActivationObservation(ctx, tx, usage, carrierType, carrierID, now); err != nil { return err } + if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageActivated, logMessage, []packageUsageAuditChange{{ + Usage: usage, BeforeData: beforeData, AfterData: packageUsageStateData(usage), + }}, nil, map[string]any{"activation_source": activationSource}); err != nil { + return err + } s.syncCarrierStatusActivated(ctx, tx, usage, carrierType, carrierID) @@ -711,137 +784,139 @@ func (s *ActivationService) InvalidatePackagesForRefund(ctx context.Context, ass constants.PackageUsageStatusDepleted, } - // 换货会把套餐使用记录迁移到新资产,但原订单与套餐使用记录的关联保持不变。 - // 因此退款必须以订单和套餐使用记录为权威定位键,不能再用旧资产 ID 缩小查询范围。 - baseQuery := s.db.WithContext(ctx).Model(&model.PackageUsage{}) - - var targets []model.PackageUsage - if packageUsageID != nil && *packageUsageID > 0 { - var usage model.PackageUsage - err := baseQuery. - Where("id = ? AND order_id = ?", *packageUsageID, orderID). - Where("status IN ?", validStatuses). - First(&usage).Error - if err != nil { - if err == gorm.ErrRecordNotFound { - s.logger.Info("退款精准失效:未命中可失效套餐", - zap.String("asset_type", assetType), - zap.Uint("asset_id", assetID), - zap.Uint("order_id", orderID), - zap.Uint("package_usage_id", *packageUsageID), - ) - return nil + var failedUsage *model.PackageUsage + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + // 换货后权益仍保留原订单关系,退款必须按订单定位并使用权益当前资产快照。 + query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("status IN ?", validStatuses) + var targets []model.PackageUsage + if packageUsageID != nil && *packageUsageID > 0 { + var usage model.PackageUsage + if err := query.Where("id = ? AND order_id = ?", *packageUsageID, orderID).First(&usage).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil + } + return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联套餐失败") } - return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联套餐失败") - } - targets = append(targets, usage) - } else { - if err := baseQuery. - Where("order_id = ?", orderID). - Where("status IN ?", validStatuses). - Find(&targets).Error; err != nil { + targets = append(targets, usage) + } else if err := query.Where("order_id = ?", orderID).Find(&targets).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询退款订单套餐失败") } if len(targets) == 0 { - s.logger.Info("退款精准失效:订单无可失效套餐", - zap.String("asset_type", assetType), - zap.Uint("asset_id", assetID), - zap.Uint("order_id", orderID), - ) return nil } - } - targetIDSet := make(map[uint]struct{}, len(targets)) - mainUsageIDs := make([]uint, 0, len(targets)) - for _, usage := range targets { - targetIDSet[usage.ID] = struct{}{} - if usage.MasterUsageID == nil { - mainUsageIDs = append(mainUsageIDs, usage.ID) + mainUsageIDs := make([]uint, 0, len(targets)) + for i := range targets { + if targets[i].MasterUsageID == nil { + mainUsageIDs = append(mainUsageIDs, targets[i].ID) + } } - } - - if len(mainUsageIDs) > 0 { - var addons []model.PackageUsage - if err := s.db.WithContext(ctx).Model(&model.PackageUsage{}). - Where("master_usage_id IN ?", mainUsageIDs). - Where("status IN ?", validStatuses). - Find(&addons).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐关联加油包失败") + if len(mainUsageIDs) > 0 { + var addons []model.PackageUsage + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}). + Where("master_usage_id IN ? AND status IN ?", mainUsageIDs, validStatuses).Find(&addons).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询主套餐关联加油包失败") + } + targets = append(targets, addons...) } - for _, addon := range addons { - targetIDSet[addon.ID] = struct{}{} + failedUsage = &targets[0] + targetIDs := make([]uint, 0, len(targets)) + changes := make([]packageUsageAuditChange, 0, len(targets)) + for i := range targets { + targetIDs = append(targetIDs, targets[i].ID) + beforeData := packageUsageStateData(&targets[i]) + targets[i].Status = constants.PackageUsageStatusInvalidated + if refundID > 0 { + targets[i].RefundID = &refundID + } + targets[i].RefundNo = refundNo + changes = append(changes, packageUsageAuditChange{Usage: &targets[i], BeforeData: beforeData, AfterData: packageUsageStateData(&targets[i])}) } + updates := map[string]any{"status": constants.PackageUsageStatusInvalidated} + if refundID > 0 { + updates["refund_id"] = refundID + } + if refundNo != "" { + updates["refund_no"] = refundNo + } + result := tx.WithContext(ctx).Model(&model.PackageUsage{}). + Where("id IN ? AND status IN ?", targetIDs, validStatuses).Updates(updates) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "退款失效套餐失败") + } + if result.RowsAffected != int64(len(targetIDs)) { + return errors.New(errors.CodeConflict, "退款套餐权益状态已变化") + } + var refund *model.RefundRequest + if refundID > 0 { + refund = &model.RefundRequest{} + if err := tx.WithContext(ctx).Where("id = ?", refundID).First(refund).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐权益关联退款单审计快照失败") + } + } + return appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageRefundInvalidated, "退款失效套餐权益", changes, refund, map[string]any{ + "asset_type": assetType, "asset_id": assetID, "order_id": orderID, "refund_id": refundID, "refund_no": refundNo, + }) + }) + if err != nil { + recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageRefundInvalidated, "退款失效套餐权益失败", failedUsage, err) + return err } - - targetIDs := make([]uint, 0, len(targetIDSet)) - for id := range targetIDSet { - targetIDs = append(targetIDs, id) - } - if len(targetIDs) == 0 { - return nil - } - - updates := map[string]any{ - "status": constants.PackageUsageStatusInvalidated, - } - if refundID > 0 { - updates["refund_id"] = refundID - } - if refundNo != "" { - updates["refund_no"] = refundNo - } - - result := s.db.WithContext(ctx). - Model(&model.PackageUsage{}). - Where("id IN ?", targetIDs). - Updates(updates) - if result.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, result.Error, "退款失效套餐失败") - } - - s.logger.Info("退款精准失效套餐完成", - zap.String("asset_type", assetType), - zap.Uint("asset_id", assetID), - zap.Uint("order_id", orderID), - zap.Uint("refund_id", refundID), - zap.String("refund_no", refundNo), - zap.Int("target_count", len(targetIDs)), - zap.Int64("affected", result.RowsAffected), - ) - return nil } // InvalidateAllPackagesByAsset 批量失效资产关联的所有有效套餐 // 退款时调用:将该资产下状态为待生效(0)、生效中(1)、已用完(2)的套餐全部标记为已失效(4) func (s *ActivationService) InvalidateAllPackagesByAsset(ctx context.Context, assetType string, assetID uint) error { - query := s.db.WithContext(ctx). - Model(&model.PackageUsage{}). - Where("status IN ?", []int{ - constants.PackageUsageStatusPending, - constants.PackageUsageStatusActive, - constants.PackageUsageStatusDepleted, - }) - - switch assetType { - case "iot_card": - query = query.Where("iot_card_id = ?", assetID) - case "device": - query = query.Where("device_id = ?", assetID) - default: + validStatuses := []int{ + constants.PackageUsageStatusPending, + constants.PackageUsageStatusActive, + constants.PackageUsageStatusDepleted, + } + if assetType != constants.AssetTypeIotCard && assetType != constants.AssetTypeDevice { return errors.New(errors.CodeInvalidParam, "无效的资产类型") } - - result := query.Update("status", constants.PackageUsageStatusInvalidated) - if result.Error != nil { - return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量失效套餐失败") + var failedUsage *model.PackageUsage + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + query := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("status IN ?", validStatuses) + query = query.Where(assetType+"_id = ?", assetID) + var targets []model.PackageUsage + if err := query.Find(&targets).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询资产关联套餐权益失败") + } + if len(targets) == 0 { + return nil + } + failedUsage = &targets[0] + ids := make([]uint, 0, len(targets)) + changes := make([]packageUsageAuditChange, 0, len(targets)) + for i := range targets { + ids = append(ids, targets[i].ID) + beforeData := packageUsageStateData(&targets[i]) + targets[i].Status = constants.PackageUsageStatusInvalidated + changes = append(changes, packageUsageAuditChange{Usage: &targets[i], BeforeData: beforeData, AfterData: packageUsageStateData(&targets[i])}) + } + result := tx.WithContext(ctx).Model(&model.PackageUsage{}). + Where("id IN ? AND status IN ?", ids, validStatuses). + Update("status", constants.PackageUsageStatusInvalidated) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量失效套餐失败") + } + if result.RowsAffected != int64(len(ids)) { + return errors.New(errors.CodeConflict, "资产套餐权益状态已变化") + } + return appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageAssetInvalidated, "资产失效套餐权益", changes, nil, map[string]any{ + "asset_type": assetType, "asset_id": assetID, + }) + }) + if err != nil { + recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageAssetInvalidated, "资产失效套餐权益失败", failedUsage, err) + return err } s.logger.Info("批量失效套餐完成", zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), - zap.Int64("affected", result.RowsAffected), ) return nil diff --git a/internal/service/package/audit.go b/internal/service/package/audit.go new file mode 100644 index 0000000..022c96e --- /dev/null +++ b/internal/service/package/audit.go @@ -0,0 +1,111 @@ +package packagepkg + +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" +) + +// SetAccessAudit 注入套餐商品统一审计 Writer。 +func (s *Service) SetAccessAudit(db *gorm.DB, writer *audit.Writer) { + s.db = db + s.auditWriter = writer +} + +func (s *Service) appendPackageAudit(ctx context.Context, tx *gorm.DB, actionCode, summary string, pkg *model.Package, beforeData, afterData map[string]any) error { + if s.auditWriter == nil || s.db == nil { + return errors.New(errors.CodeInvalidStatus, "套餐商品统一审计接缝未配置") + } + resources := []audit.ResourceInput{ + audit.PackageResource(pkg, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePackageTarget, beforeData, afterData), + } + if pkg.SeriesID > 0 { + var series model.PackageSeries + if err := tx.WithContext(ctx).Unscoped().Where("id = ?", pkg.SeriesID).First(&series).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐系列审计快照失败") + } + resources = append(resources, audit.PackageSeriesResource(&series, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageSeries, nil, nil)) + } + return s.auditWriter.Append(ctx, tx, audit.AppendInput{ + ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform, + Result: constants.AuditResultSuccess, Resources: resources, + }) +} + +func (s *Service) appendAllocationAudit(ctx context.Context, tx *gorm.DB, actionCode, summary string, allocation *model.ShopPackageAllocation, pkg *model.Package, beforeData, afterData map[string]any) error { + if s.auditWriter == nil || s.db == nil { + return errors.New(errors.CodeInvalidStatus, "店铺套餐统一审计接缝未配置") + } + resources := []audit.ResourceInput{ + audit.ShopPackageAllocationResource(allocation, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleShopPackageAllocation, beforeData, afterData), + audit.PackageResource(pkg, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageTarget, nil, nil), + } + var shop model.Shop + if err := tx.WithContext(ctx).Unscoped().Where("id = ?", allocation.ShopID).First(&shop).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询店铺套餐审计快照失败") + } + resources = append(resources, audit.ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop)) + return s.auditWriter.Append(ctx, tx, audit.AppendInput{ + ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopeShop, + ScopeID: allocationShopID(allocation), Result: constants.AuditResultSuccess, Resources: resources, + }) +} + +func (s *Service) recordPackageFailure(ctx context.Context, actionCode, summary string, pkg *model.Package, beforeData map[string]any, businessErr error) { + if pkg == nil { + return + } + s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{ + ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform, + Resources: []audit.ResourceInput{audit.PackageResource( + pkg, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePackageTarget, beforeData, nil, + )}, + }, businessErr) +} + +func (s *Service) recordAllocationFailure(ctx context.Context, actionCode, summary string, allocation *model.ShopPackageAllocation, pkg *model.Package, beforeData map[string]any, businessErr error) { + if allocation == nil || pkg == nil { + return + } + shop := &model.Shop{Model: gorm.Model{ID: allocation.ShopID}} + s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{ + ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopeShop, + ScopeID: allocationShopID(allocation), Resources: []audit.ResourceInput{ + audit.ShopPackageAllocationResource(allocation, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleShopPackageAllocation, beforeData, nil), + audit.PackageResource(pkg, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageTarget, nil, nil), + audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop), + }, + }, businessErr) +} + +func allocationShopID(allocation *model.ShopPackageAllocation) string { + if allocation == nil { + return "" + } + return uintString(allocation.ShopID) +} + +func uintString(value uint) string { + return strconv.FormatUint(uint64(value), 10) +} + +func packageData(pkg *model.Package) map[string]any { + if pkg == nil { + return nil + } + return map[string]any{ + "package_name": pkg.PackageName, "series_id": pkg.SeriesID, "package_type": pkg.PackageType, + "duration_months": pkg.DurationMonths, "duration_days": pkg.DurationDays, + "real_data_mb": pkg.RealDataMB, "virtual_data_mb": pkg.VirtualDataMB, + "enable_virtual_data": pkg.EnableVirtualData, "cost_price": pkg.CostPrice, + "suggested_retail_price": pkg.SuggestedRetailPrice, "price_config_status": pkg.PriceConfigStatus, + "is_gift": pkg.IsGift, "status": pkg.Status, "shelf_status": pkg.ShelfStatus, + "calendar_type": pkg.CalendarType, "data_reset_cycle": pkg.DataResetCycle, "expiry_base": pkg.ExpiryBase, + } +} diff --git a/internal/service/package/lifecycle_audit.go b/internal/service/package/lifecycle_audit.go new file mode 100644 index 0000000..bc6b179 --- /dev/null +++ b/internal/service/package/lifecycle_audit.go @@ -0,0 +1,221 @@ +package packagepkg + +import ( + "context" + "sort" + "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" +) + +type packageUsageAuditChange struct { + Usage *model.PackageUsage + BeforeData map[string]any + AfterData map[string]any +} + +func appendPackageUsageAudit(ctx context.Context, tx *gorm.DB, writer *audit.Writer, actionCode, summary string, changes []packageUsageAuditChange, refund *model.RefundRequest, metadata map[string]any) error { + if writer == nil { + return errors.New(errors.CodeInvalidStatus, "套餐权益统一审计接缝未配置") + } + resources, err := packageUsageAuditResources(ctx, tx, changes, refund, summary) + if err != nil { + return err + } + return writer.Append(ctx, tx, audit.AppendInput{ + ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform, + Result: constants.AuditResultSuccess, Metadata: metadata, Resources: resources, + }) +} + +func (s *ActivationService) appendExpirationAudit(ctx context.Context, tx *gorm.DB, main *model.PackageUsage, mainBeforeData map[string]any, addons []packageUsageAuditChange) error { + changes := make([]packageUsageAuditChange, 0, 1+len(addons)) + changes = append(changes, packageUsageAuditChange{Usage: main, BeforeData: mainBeforeData, AfterData: packageUsageStateData(main)}) + changes = append(changes, addons...) + return appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageExpired, "套餐权益到期并处理关联加油包", changes, nil, map[string]any{ + "invalidated_addon_count": len(addons), + }) +} + +// AppendExpirationAudit 在调度器的既有过期事务内记录实际权益变化。 +func (s *ActivationService) AppendExpirationAudit(ctx context.Context, tx *gorm.DB, main *model.PackageUsage, addons []*model.PackageUsage) error { + if main == nil { + return errors.New(errors.CodeInvalidParam, "过期套餐权益审计资源不完整") + } + mainAfter := *main + mainAfter.Status = constants.PackageUsageStatusExpired + addonChanges := make([]packageUsageAuditChange, 0, len(addons)) + for _, addon := range addons { + if addon == nil { + continue + } + beforeData := packageUsageStateData(addon) + after := *addon + after.Status = constants.PackageUsageStatusInvalidated + addonChanges = append(addonChanges, packageUsageAuditChange{Usage: &after, BeforeData: beforeData, AfterData: packageUsageStateData(&after)}) + } + return s.appendExpirationAudit(ctx, tx, &mainAfter, packageUsageStateData(main), addonChanges) +} + +// RecordUsageFailure 在权益已定位且业务事务回滚后记录失败事实。 +func (s *ActivationService) RecordUsageFailure(ctx context.Context, actionCode, summary string, usage *model.PackageUsage, businessErr error) { + recordPackageUsageFailure(ctx, s.db, s.auditWriter, actionCode, summary, usage, businessErr) +} + +func packageUsageAuditResources(ctx context.Context, tx *gorm.DB, changes []packageUsageAuditChange, refund *model.RefundRequest, subjectSummary string) ([]audit.ResourceInput, error) { + if len(changes) == 0 || changes[0].Usage == nil || changes[0].Usage.ID == 0 { + return nil, errors.New(errors.CodeInvalidParam, "套餐权益审计资源不完整") + } + orderIDs, packageIDs, cardIDs, deviceIDs := packageUsageReferenceIDs(changes) + orders, packages, cards, devices, err := loadPackageUsageReferences(ctx, tx, orderIDs, packageIDs, cardIDs, deviceIDs) + if err != nil { + return nil, err + } + + resources := make([]audit.ResourceInput, 0, len(changes)+len(orders)+len(packages)+len(cards)+len(devices)+1) + for index, change := range changes { + if change.Usage == nil || change.Usage.ID == 0 { + continue + } + relation := constants.AuditResourceRelationAffected + if index == 0 { + relation = constants.AuditResourceRelationPrimary + } + resource := audit.PackageUsageResource(change.Usage, relation, constants.AuditResourceRolePackageUsageTarget, change.BeforeData, change.AfterData) + resource.SubjectVisibility = constants.AuditSubjectResult + resource.SubjectSummary = subjectSummary + resources = append(resources, resource) + } + for i := range orders { + resources = append(resources, audit.OrderResource(&orders[i], constants.AuditResourceRelationReference, constants.AuditResourceRolePackageUsageOrder)) + } + for i := range packages { + resources = append(resources, audit.PackageResource(&packages[i], constants.AuditResourceRelationReference, constants.AuditResourceRolePackageUsagePackage, nil, nil)) + } + for i := range cards { + id := strconv.FormatUint(uint64(cards[i].ID), 10) + resources = append(resources, audit.ResourceInput{ + Type: constants.AuditResourceIotCard, ID: &id, Key: audit.IotCardResourceKey(&cards[i]), DisplayName: cards[i].ICCID, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRolePackageUsageAsset, + IdentitySnapshot: audit.IotCardIdentitySnapshot(&cards[i]), SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: subjectSummary, + }) + } + for i := range devices { + id := strconv.FormatUint(uint64(devices[i].ID), 10) + resources = append(resources, audit.ResourceInput{ + Type: constants.AuditResourceDevice, ID: &id, Key: audit.DeviceResourceKey(&devices[i]), DisplayName: devices[i].VirtualNo, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRolePackageUsageAsset, + IdentitySnapshot: audit.DeviceIdentitySnapshot(&devices[i]), SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: subjectSummary, + }) + } + if refund != nil && refund.ID > 0 { + resources = append(resources, audit.RefundResource(refund, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageUsageRefund)) + } + return resources, nil +} + +func packageUsageReferenceIDs(changes []packageUsageAuditChange) ([]uint, []uint, []uint, []uint) { + orders, packages, cards, devices := map[uint]struct{}{}, map[uint]struct{}{}, map[uint]struct{}{}, map[uint]struct{}{} + for _, change := range changes { + if change.Usage == nil { + continue + } + orders[change.Usage.OrderID] = struct{}{} + packages[change.Usage.PackageID] = struct{}{} + if change.Usage.IotCardID > 0 { + cards[change.Usage.IotCardID] = struct{}{} + } + if change.Usage.DeviceID > 0 { + devices[change.Usage.DeviceID] = struct{}{} + } + } + return mapUintKeys(orders), mapUintKeys(packages), mapUintKeys(cards), mapUintKeys(devices) +} + +func loadPackageUsageReferences(ctx context.Context, tx *gorm.DB, orderIDs, packageIDs, cardIDs, deviceIDs []uint) ([]model.Order, []model.Package, []model.IotCard, []model.Device, error) { + var orders []model.Order + if len(orderIDs) > 0 { + if err := tx.WithContext(ctx).Where("id IN ?", orderIDs).Order("id ASC").Find(&orders).Error; err != nil { + return nil, nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐权益关联订单审计快照失败") + } + } + var packages []model.Package + if len(packageIDs) > 0 { + if err := tx.WithContext(ctx).Where("id IN ?", packageIDs).Order("id ASC").Find(&packages).Error; err != nil { + return nil, nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐权益关联套餐审计快照失败") + } + } + var cards []model.IotCard + if len(cardIDs) > 0 { + if err := tx.WithContext(ctx).Where("id IN ?", cardIDs).Order("id ASC").Find(&cards).Error; err != nil { + return nil, nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐权益关联卡审计快照失败") + } + } + var devices []model.Device + if len(deviceIDs) > 0 { + if err := tx.WithContext(ctx).Where("id IN ?", deviceIDs).Order("id ASC").Find(&devices).Error; err != nil { + return nil, nil, nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐权益关联设备审计快照失败") + } + } + return orders, packages, cards, devices, nil +} + +func mapUintKeys(values map[uint]struct{}) []uint { + result := make([]uint, 0, len(values)) + for value := range values { + if value > 0 { + result = append(result, value) + } + } + sort.Slice(result, func(i, j int) bool { return result[i] < result[j] }) + return result +} + +func packageUsageStateData(usage *model.PackageUsage) map[string]any { + if usage == nil { + return nil + } + return map[string]any{ + "status": usage.Status, "data_usage_mb": usage.DataUsageMB, + "pending_realname_activation": usage.PendingRealnameActivation, + "activated_at": usage.ActivatedAt, "expires_at": usage.ExpiresAt, + "last_reset_at": usage.LastResetAt, "next_reset_at": usage.NextResetAt, + "refund_id": usage.RefundID, "refund_no": usage.RefundNo, + "iot_card_id": usage.IotCardID, "device_id": usage.DeviceID, + } +} + +func normalizePackageUsageAuditChanges(changes []packageUsageAuditChange) []packageUsageAuditChange { + result := make([]packageUsageAuditChange, 0, len(changes)) + positions := make(map[uint]int, len(changes)) + for _, change := range changes { + if change.Usage == nil || change.Usage.ID == 0 { + continue + } + if position, ok := positions[change.Usage.ID]; ok { + result[position].Usage = change.Usage + result[position].AfterData = change.AfterData + continue + } + positions[change.Usage.ID] = len(result) + result = append(result, change) + } + return result +} + +func recordPackageUsageFailure(ctx context.Context, db *gorm.DB, writer *audit.Writer, actionCode, summary string, usage *model.PackageUsage, businessErr error) { + if usage == nil || usage.ID == 0 || writer == nil || db == nil { + return + } + writer.RecordFailure(ctx, db, audit.AppendInput{ + ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform, + Resources: []audit.ResourceInput{audit.PackageUsageResource( + usage, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePackageUsageTarget, packageUsageStateData(usage), nil, + )}, + }, businessErr) +} diff --git a/internal/service/package/reset_service.go b/internal/service/package/reset_service.go index a510b2f..f1eadeb 100644 --- a/internal/service/package/reset_service.go +++ b/internal/service/package/reset_service.go @@ -4,6 +4,7 @@ import ( "context" "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/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" @@ -11,6 +12,7 @@ import ( "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" + "gorm.io/gorm/clause" ) type ResetService struct { @@ -19,6 +21,12 @@ type ResetService struct { packageUsageStore *postgres.PackageUsageStore logger *zap.Logger resumeCallback ResumeCallback + auditWriter *audit.Writer +} + +// SetLifecycleAudit 注入套餐权益流量重置统一审计 Writer。 +func (s *ResetService) SetLifecycleAudit(writer *audit.Writer) { + s.auditWriter = writer } func NewResetService( @@ -55,6 +63,7 @@ func (s *ResetService) resetDailyUsageWithDB(ctx context.Context, db *gorm.DB) e err := tx.Where("data_reset_cycle = ?", constants.PackageDataResetDaily). Where("next_reset_at <= ?", now). Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}). + Clauses(clause.Locking{Strength: "UPDATE"}). Find(&packages).Error if err != nil { @@ -80,21 +89,40 @@ func (s *ResetService) resetDailyUsageWithDB(ctx context.Context, db *gorm.DB) e "status": constants.PackageUsageStatusActive, } - if err := tx.Model(&model.PackageUsage{}). - Where("id IN ?", packageIDs). - Updates(updates).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "批量重置日流量失败") + result := tx.Model(&model.PackageUsage{}). + Where("id IN ? AND next_reset_at <= ? AND status IN ?", packageIDs, now, []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}). + Updates(updates) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量重置日流量失败") + } + if result.RowsAffected != int64(len(packages)) { + return errors.New(errors.CodeConflict, "日流量重置目标状态已变化") + } + changes := make([]packageUsageAuditChange, 0, len(packages)) + for _, usage := range packages { + beforeData := packageUsageStateData(usage) + usage.DataUsageMB = 0 + usage.LastResetAt = &now + usage.NextResetAt = &nextReset + usage.Status = constants.PackageUsageStatusActive + changes = append(changes, packageUsageAuditChange{Usage: usage, BeforeData: beforeData, AfterData: packageUsageStateData(usage)}) + } + resetPackages = packages + if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageTrafficReset, "重置套餐权益日流量", changes, nil, map[string]any{"reset_cycle": constants.PackageDataResetDaily}); err != nil { + return err } s.logger.Info("日流量重置完成", zap.Int("count", len(packages)), zap.Time("next_reset_at", nextReset)) - resetPackages = packages return nil }) if err != nil { + if len(resetPackages) > 0 { + recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageTrafficReset, "重置套餐权益日流量失败", resetPackages[0], err) + } return err } @@ -117,6 +145,7 @@ func (s *ResetService) resetMonthlyUsageWithDB(ctx context.Context, db *gorm.DB) err := tx.Where("data_reset_cycle = ?", constants.PackageDataResetMonthly). Where("next_reset_at <= ?", now). Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}). + Clauses(clause.Locking{Strength: "UPDATE"}). Find(&packages).Error if err != nil { @@ -128,6 +157,7 @@ func (s *ResetService) resetMonthlyUsageWithDB(ctx context.Context, db *gorm.DB) return nil } + changes := make([]packageUsageAuditChange, 0, len(packages)) for _, usage := range packages { var pkg model.Package if err := tx.First(&pkg, usage.PackageID).Error; err != nil { @@ -156,9 +186,22 @@ func (s *ResetService) resetMonthlyUsageWithDB(ctx context.Context, db *gorm.DB) "status": constants.PackageUsageStatusActive, } - if err := tx.Model(usage).Updates(updates).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "重置月流量失败") + beforeData := packageUsageStateData(usage) + result := tx.Model(usage). + Where("next_reset_at <= ? AND status IN ?", now, []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}). + Updates(updates) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "重置月流量失败") } + if result.RowsAffected == 0 { + continue + } + usage.DataUsageMB = 0 + usage.LastResetAt = &now + usage.NextResetAt = nextResetAt + usage.Status = constants.PackageUsageStatusActive + changes = append(changes, packageUsageAuditChange{Usage: usage, BeforeData: beforeData, AfterData: packageUsageStateData(usage)}) + resetPackages = append(resetPackages, usage) s.logger.Info("月流量已重置", zap.Uint("usage_id", usage.ID), @@ -166,11 +209,18 @@ func (s *ResetService) resetMonthlyUsageWithDB(ctx context.Context, db *gorm.DB) zap.Time("next_reset_at", *nextResetAt)) } - resetPackages = packages + if len(changes) > 0 { + if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageTrafficReset, "重置套餐权益月流量", changes, nil, map[string]any{"reset_cycle": constants.PackageDataResetMonthly}); err != nil { + return err + } + } return nil }) if err != nil { + if len(resetPackages) > 0 { + recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageTrafficReset, "重置套餐权益月流量失败", resetPackages[0], err) + } return err } @@ -193,6 +243,7 @@ func (s *ResetService) resetYearlyUsageWithDB(ctx context.Context, db *gorm.DB) err := tx.Where("data_reset_cycle = ?", constants.PackageDataResetYearly). Where("next_reset_at <= ?", now). Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}). + Clauses(clause.Locking{Strength: "UPDATE"}). Find(&packages).Error if err != nil { @@ -218,21 +269,40 @@ func (s *ResetService) resetYearlyUsageWithDB(ctx context.Context, db *gorm.DB) "status": constants.PackageUsageStatusActive, } - if err := tx.Model(&model.PackageUsage{}). - Where("id IN ?", packageIDs). - Updates(updates).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "批量重置年流量失败") + result := tx.Model(&model.PackageUsage{}). + Where("id IN ? AND next_reset_at <= ? AND status IN ?", packageIDs, now, []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}). + Updates(updates) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "批量重置年流量失败") + } + if result.RowsAffected != int64(len(packages)) { + return errors.New(errors.CodeConflict, "年流量重置目标状态已变化") + } + changes := make([]packageUsageAuditChange, 0, len(packages)) + for _, usage := range packages { + beforeData := packageUsageStateData(usage) + usage.DataUsageMB = 0 + usage.LastResetAt = &now + usage.NextResetAt = &nextReset + usage.Status = constants.PackageUsageStatusActive + changes = append(changes, packageUsageAuditChange{Usage: usage, BeforeData: beforeData, AfterData: packageUsageStateData(usage)}) + } + resetPackages = packages + if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageTrafficReset, "重置套餐权益年流量", changes, nil, map[string]any{"reset_cycle": constants.PackageDataResetYearly}); err != nil { + return err } s.logger.Info("年流量重置完成", zap.Int("count", len(packages)), zap.Time("next_reset_at", nextReset)) - resetPackages = packages return nil }) if err != nil { + if len(resetPackages) > 0 { + recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageTrafficReset, "重置套餐权益年流量失败", resetPackages[0], err) + } return err } diff --git a/internal/service/package/service.go b/internal/service/package/service.go index 353cdea..3c37e39 100644 --- a/internal/service/package/service.go +++ b/internal/service/package/service.go @@ -7,6 +7,7 @@ import ( "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/service/packageprice" @@ -18,10 +19,12 @@ import ( ) type Service struct { + db *gorm.DB packageStore *postgres.PackageStore packageSeriesStore *postgres.PackageSeriesStore packageAllocationStore *postgres.ShopPackageAllocationStore shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore + auditWriter *audit.Writer } func New( @@ -38,7 +41,7 @@ func New( } } -func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*dto.PackageResponse, error) { +func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (_ *dto.PackageResponse, retErr error) { currentUserID := middleware.GetUserIDFromContext(ctx) if currentUserID == 0 { return nil, errors.New(errors.CodeUnauthorized, "未授权访问") @@ -114,6 +117,13 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d Status: constants.StatusEnabled, ShelfStatus: 2, } + defer func() { + if retErr != nil { + failedPackage := *pkg + failedPackage.ID = 0 + s.recordPackageFailure(ctx, constants.AuditActionPackageCreated, "创建套餐商品失败 "+pkg.PackageCode, &failedPackage, nil, retErr) + } + }() if req.SeriesID != nil { pkg.SeriesID = *req.SeriesID } @@ -140,8 +150,13 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageRequest) (*d pkg.VirtualRatio = calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB) pkg.Creator = currentUserID - if err := s.packageStore.Create(ctx, pkg); err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "创建套餐失败") + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := postgres.NewPackageStore(tx).Create(ctx, pkg); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "创建套餐失败") + } + return s.appendPackageAudit(ctx, tx, constants.AuditActionPackageCreated, "创建套餐商品 "+pkg.PackageCode, pkg, nil, packageData(pkg)) + }); err != nil { + return nil, err } resp := s.toResponse(ctx, pkg) @@ -157,7 +172,6 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageResponse, error } return nil, errors.Wrap(errors.CodeInternalError, err, "获取套餐失败") } - resp := s.toResponse(ctx, pkg) // 查询系列名称 if pkg.SeriesID > 0 { @@ -191,7 +205,7 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageResponse, error return resp, nil } -func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageRequest) (*dto.PackageResponse, error) { +func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageRequest) (_ *dto.PackageResponse, retErr error) { currentUserID := middleware.GetUserIDFromContext(ctx) if currentUserID == 0 { return nil, errors.New(errors.CodeUnauthorized, "未授权访问") @@ -204,6 +218,12 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq } return nil, errors.Wrap(errors.CodeInternalError, err, "获取套餐失败") } + before := *pkg + defer func() { + if retErr != nil { + s.recordPackageFailure(ctx, constants.AuditActionPackageUpdated, "更新套餐商品失败 "+before.PackageCode, &before, packageData(&before), retErr) + } + }() var seriesName *string if req.SeriesID != nil && *req.SeriesID > 0 { @@ -305,8 +325,13 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq pkg.VirtualRatio = calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB) pkg.Updater = currentUserID - if err := s.packageStore.Update(ctx, pkg); err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "更新套餐失败") + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := postgres.NewPackageStore(tx).Update(ctx, pkg); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "更新套餐失败") + } + return s.appendPackageAudit(ctx, tx, constants.AuditActionPackageUpdated, "更新套餐商品 "+pkg.PackageCode, pkg, packageData(&before), packageData(pkg)) + }); err != nil { + return nil, err } resp := s.toResponse(ctx, pkg) @@ -314,20 +339,26 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageReq return resp, nil } -func (s *Service) Delete(ctx context.Context, id uint) error { - _, err := s.packageStore.GetByID(ctx, id) +func (s *Service) Delete(ctx context.Context, id uint) (retErr error) { + pkg, err := s.packageStore.GetByID(ctx, id) if err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeNotFound, "套餐不存在") } return errors.Wrap(errors.CodeInternalError, err, "获取套餐失败") } + defer func() { + if retErr != nil { + s.recordPackageFailure(ctx, constants.AuditActionPackageDeleted, "删除套餐商品失败 "+pkg.PackageCode, pkg, packageData(pkg), retErr) + } + }() - if err := s.packageStore.Delete(ctx, id); err != nil { - return errors.Wrap(errors.CodeInternalError, err, "删除套餐失败") - } - - return nil + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := postgres.NewPackageStore(tx).Delete(ctx, id); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "删除套餐失败") + } + return s.appendPackageAudit(ctx, tx, constants.AuditActionPackageDeleted, "删除套餐商品 "+pkg.PackageCode, pkg, packageData(pkg), map[string]any{"deleted": true}) + }) } func (s *Service) List(ctx context.Context, req *dto.PackageListRequest) ([]*dto.PackageResponse, int64, error) { @@ -444,7 +475,7 @@ func (s *Service) batchGetSeriesAllocationsForShop(ctx context.Context, shopID u return result } -func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error { +func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) (retErr error) { currentUserID := middleware.GetUserIDFromContext(ctx) if currentUserID == 0 { return errors.New(errors.CodeUnauthorized, "未授权访问") @@ -457,6 +488,12 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error { } return errors.Wrap(errors.CodeInternalError, err, "获取套餐失败") } + before := *pkg + defer func() { + if retErr != nil { + s.recordPackageFailure(ctx, constants.AuditActionPackageStatusUpdated, "更新套餐商品状态失败 "+before.PackageCode, &before, map[string]any{"status": before.Status, "shelf_status": before.ShelfStatus}, retErr) + } + }() pkg.Status = status pkg.Updater = currentUserID @@ -465,14 +502,16 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error { pkg.ShelfStatus = 2 } - if err := s.packageStore.Update(ctx, pkg); err != nil { - return errors.Wrap(errors.CodeInternalError, err, "更新套餐状态失败") - } - - return nil + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := postgres.NewPackageStore(tx).Update(ctx, pkg); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "更新套餐状态失败") + } + return s.appendPackageAudit(ctx, tx, constants.AuditActionPackageStatusUpdated, "更新套餐商品状态 "+pkg.PackageCode, pkg, + map[string]any{"status": before.Status, "shelf_status": before.ShelfStatus}, map[string]any{"status": pkg.Status, "shelf_status": pkg.ShelfStatus}) + }) } -func (s *Service) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus int) error { +func (s *Service) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus int) (retErr error) { currentUserID := middleware.GetUserIDFromContext(ctx) if currentUserID == 0 { return errors.New(errors.CodeUnauthorized, "未授权访问") @@ -493,6 +532,12 @@ func (s *Service) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus in } return errors.Wrap(errors.CodeInternalError, err, "获取套餐失败") } + before := *pkg + defer func() { + if retErr != nil { + s.recordPackageFailure(ctx, constants.AuditActionPackageShelfStatusUpdated, "更新套餐上架状态失败 "+before.PackageCode, &before, map[string]any{"shelf_status": before.ShelfStatus}, retErr) + } + }() if shelfStatus == constants.ShelfStatusOn && pkg.Status == constants.StatusDisabled { return errors.New(errors.CodeInvalidStatus, "禁用的套餐不能上架,请先启用") @@ -501,15 +546,17 @@ func (s *Service) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus in pkg.ShelfStatus = shelfStatus pkg.Updater = currentUserID - if err := s.packageStore.Update(ctx, pkg); err != nil { - return errors.Wrap(errors.CodeInternalError, err, "更新套餐上架状态失败") - } - - return nil + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := postgres.NewPackageStore(tx).Update(ctx, pkg); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "更新套餐上架状态失败") + } + return s.appendPackageAudit(ctx, tx, constants.AuditActionPackageShelfStatusUpdated, "更新套餐上架状态 "+pkg.PackageCode, pkg, + map[string]any{"shelf_status": before.ShelfStatus}, map[string]any{"shelf_status": pkg.ShelfStatus}) + }) } // UpdateRetailPrice 代理修改自己店铺的套餐零售价 -func (s *Service) UpdateRetailPrice(ctx context.Context, packageID uint, retailPrice int64) error { +func (s *Service) UpdateRetailPrice(ctx context.Context, packageID uint, retailPrice int64) (retErr error) { currentUserID := middleware.GetUserIDFromContext(ctx) if currentUserID == 0 { return errors.New(errors.CodeUnauthorized, "未授权访问") @@ -540,6 +587,14 @@ func (s *Service) UpdateRetailPrice(ctx context.Context, packageID uint, retailP } return errors.Wrap(errors.CodeInternalError, err, "获取套餐失败") } + beforePrice, beforeStatus := allocation.RetailPrice, allocation.RetailPriceConfigStatus + beforeAllocation := *allocation + defer func() { + if retErr != nil { + s.recordAllocationFailure(ctx, constants.AuditActionPackageRetailPriceUpdated, "更新店铺套餐零售价失败 "+pkg.PackageCode, &beforeAllocation, pkg, + map[string]any{"retail_price": beforePrice, "retail_price_config_status": beforeStatus}, retErr) + } + }() if pkg.IsGift { return errors.New(errors.CodeForbidden, "赠送套餐不允许代理修改零售价") } @@ -550,16 +605,19 @@ func (s *Service) UpdateRetailPrice(ctx context.Context, packageID uint, retailP if retailPrice < allocation.CostPrice { return errors.New(errors.CodeInvalidParam, "零售价不能低于成本价") } - - if err := s.packageAllocationStore.UpdateRetailPrice(ctx, allocation.ID, storedRetailPrice, priceConfigStatus, currentUserID); err != nil { - return errors.Wrap(errors.CodeInternalError, err, "更新零售价失败") - } - - return nil + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := postgres.NewShopPackageAllocationStore(tx).UpdateRetailPrice(ctx, allocation.ID, storedRetailPrice, priceConfigStatus, currentUserID); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "更新零售价失败") + } + allocation.RetailPrice, allocation.RetailPriceConfigStatus = storedRetailPrice, priceConfigStatus + return s.appendAllocationAudit(ctx, tx, constants.AuditActionPackageRetailPriceUpdated, "更新店铺套餐零售价 "+pkg.PackageCode, allocation, pkg, + map[string]any{"retail_price": beforePrice, "retail_price_config_status": beforeStatus}, + map[string]any{"retail_price": storedRetailPrice, "retail_price_config_status": priceConfigStatus}) + }) } // updateAgentShelfStatus 代理上下架路径:更新分配记录的 shelf_status -func (s *Service) updateAgentShelfStatus(ctx context.Context, packageID uint, shelfStatus int, updaterID uint) error { +func (s *Service) updateAgentShelfStatus(ctx context.Context, packageID uint, shelfStatus int, updaterID uint) (retErr error) { shopID := middleware.GetShopIDFromContext(ctx) if shopID == 0 { return errors.New(errors.CodeUnauthorized, "当前用户不属于任何店铺") @@ -573,26 +631,34 @@ func (s *Service) updateAgentShelfStatus(ctx context.Context, packageID uint, sh } return errors.Wrap(errors.CodeInternalError, err, "获取分配记录失败") } + beforeShelfStatus := allocation.ShelfStatus + beforeAllocation := *allocation + pkg, err := s.packageStore.GetByID(ctx, packageID) + if err != nil { + return errors.New(errors.CodeNotFound, "套餐不存在") + } + defer func() { + if retErr != nil { + s.recordAllocationFailure(ctx, constants.AuditActionShopPackageShelfStatusUpdated, "更新店铺套餐上架状态失败 "+pkg.PackageCode, &beforeAllocation, pkg, + map[string]any{"shelf_status": beforeShelfStatus}, retErr) + } + }() // 上架时检查套餐全局禁用状态 if shelfStatus == constants.ShelfStatusOn { - pkg, err := s.packageStore.GetByID(ctx, packageID) - if err != nil { - if err == gorm.ErrRecordNotFound { - return errors.New(errors.CodeNotFound, "套餐不存在") - } - return errors.Wrap(errors.CodeInternalError, err, "获取套餐失败") - } if pkg.Status == constants.StatusDisabled { return errors.New(errors.CodeInvalidStatus, "套餐已禁用,无法上架") } } - if err := s.packageAllocationStore.UpdateShelfStatus(ctx, allocation.ID, shelfStatus, updaterID); err != nil { - return errors.Wrap(errors.CodeInternalError, err, "更新上下架状态失败") - } - - return nil + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := postgres.NewShopPackageAllocationStore(tx).UpdateShelfStatus(ctx, allocation.ID, shelfStatus, updaterID); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "更新上下架状态失败") + } + allocation.ShelfStatus = shelfStatus + return s.appendAllocationAudit(ctx, tx, constants.AuditActionShopPackageShelfStatusUpdated, "更新店铺套餐上架状态 "+pkg.PackageCode, allocation, pkg, + map[string]any{"shelf_status": beforeShelfStatus}, map[string]any{"shelf_status": shelfStatus}) + }) } func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.PackageResponse { @@ -607,29 +673,29 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa } resp := &dto.PackageResponse{ - ID: pkg.ID, - PackageCode: pkg.PackageCode, - PackageName: pkg.PackageName, - SeriesID: seriesID, - PackageType: pkg.PackageType, - IsGift: pkg.IsGift, - DurationMonths: pkg.DurationMonths, - RealDataMB: pkg.RealDataMB, - VirtualDataMB: pkg.VirtualDataMB, - EnableVirtualData: pkg.EnableVirtualData, - VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB), - CostPrice: pkg.CostPrice, - SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg), - PriceConfigStatus: pkg.PriceConfigStatus, - PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus), - CalendarType: pkg.CalendarType, - DurationDays: durationDays, - DataResetCycle: pkg.DataResetCycle, - ExpiryBase: pkg.ExpiryBase, - Status: pkg.Status, - ShelfStatus: pkg.ShelfStatus, - CreatedAt: pkg.CreatedAt.Format(time.RFC3339), - UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339), + ID: pkg.ID, + PackageCode: pkg.PackageCode, + PackageName: pkg.PackageName, + SeriesID: seriesID, + PackageType: pkg.PackageType, + IsGift: pkg.IsGift, + DurationMonths: pkg.DurationMonths, + RealDataMB: pkg.RealDataMB, + VirtualDataMB: pkg.VirtualDataMB, + EnableVirtualData: pkg.EnableVirtualData, + VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB), + CostPrice: pkg.CostPrice, + SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg), + PriceConfigStatus: pkg.PriceConfigStatus, + PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus), + CalendarType: pkg.CalendarType, + DurationDays: durationDays, + DataResetCycle: pkg.DataResetCycle, + ExpiryBase: pkg.ExpiryBase, + Status: pkg.Status, + ShelfStatus: pkg.ShelfStatus, + CreatedAt: pkg.CreatedAt.Format(time.RFC3339), + UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339), } initPackageExpiryBaseFields(resp, pkg) @@ -686,29 +752,29 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package } resp := &dto.PackageResponse{ - ID: pkg.ID, - PackageCode: pkg.PackageCode, - PackageName: pkg.PackageName, - SeriesID: seriesID, - PackageType: pkg.PackageType, - IsGift: pkg.IsGift, - DurationMonths: pkg.DurationMonths, - RealDataMB: pkg.RealDataMB, - VirtualDataMB: pkg.VirtualDataMB, - EnableVirtualData: pkg.EnableVirtualData, - VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB), - CostPrice: pkg.CostPrice, - SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg), - PriceConfigStatus: pkg.PriceConfigStatus, - PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus), - CalendarType: pkg.CalendarType, - DurationDays: durationDays, - DataResetCycle: pkg.DataResetCycle, - ExpiryBase: pkg.ExpiryBase, - Status: pkg.Status, - ShelfStatus: pkg.ShelfStatus, - CreatedAt: pkg.CreatedAt.Format(time.RFC3339), - UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339), + ID: pkg.ID, + PackageCode: pkg.PackageCode, + PackageName: pkg.PackageName, + SeriesID: seriesID, + PackageType: pkg.PackageType, + IsGift: pkg.IsGift, + DurationMonths: pkg.DurationMonths, + RealDataMB: pkg.RealDataMB, + VirtualDataMB: pkg.VirtualDataMB, + EnableVirtualData: pkg.EnableVirtualData, + VirtualRatio: calculateVirtualRatio(pkg.EnableVirtualData, pkg.RealDataMB, pkg.VirtualDataMB), + CostPrice: pkg.CostPrice, + SuggestedRetailPrice: packageprice.PackageRawSuggestedRetailPrice(pkg), + PriceConfigStatus: pkg.PriceConfigStatus, + PriceConfigStatusName: packagePriceConfigStatusName(pkg.PriceConfigStatus), + CalendarType: pkg.CalendarType, + DurationDays: durationDays, + DataResetCycle: pkg.DataResetCycle, + ExpiryBase: pkg.ExpiryBase, + Status: pkg.Status, + ShelfStatus: pkg.ShelfStatus, + CreatedAt: pkg.CreatedAt.Format(time.RFC3339), + UpdatedAt: pkg.UpdatedAt.Format(time.RFC3339), } initPackageExpiryBaseFields(resp, pkg) diff --git a/internal/service/package/usage_service.go b/internal/service/package/usage_service.go index daeb45b..76e3371 100644 --- a/internal/service/package/usage_service.go +++ b/internal/service/package/usage_service.go @@ -6,6 +6,7 @@ import ( "strconv" "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/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" @@ -38,6 +39,12 @@ type UsageService struct { deviceSimBindingStore *postgres.DeviceSimBindingStore logger *zap.Logger stopResumeCallback StopResumeCallback // 停复机回调,可选 + auditWriter *audit.Writer +} + +// SetLifecycleAudit 注入套餐权益流量扣减统一审计 Writer。 +func (s *UsageService) SetLifecycleAudit(writer *audit.Writer) { + s.auditWriter = writer } func NewUsageService( @@ -81,6 +88,7 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string, shouldSuspend := false suspendCarrierType := "" var suspendCarrierID uint + var auditChanges []packageUsageAuditChange err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { targetCarrierType, targetCarrierID, packages, err := s.resolveActivePackages(ctx, tx, carrierType, carrierID) @@ -116,9 +124,12 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string, isLastPackage := index == len(packages)-1 if remainingQuota <= 0 && !isLastPackage { // 套餐已用完,标记为已用完 + beforeData := packageUsageStateData(pkg) if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐状态失败") } + pkg.Status = constants.PackageUsageStatusDepleted + auditChanges = append(auditChanges, packageUsageAuditChange{Usage: pkg, BeforeData: beforeData, AfterData: packageUsageStateData(pkg)}) continue } @@ -132,9 +143,12 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string, deductFromPkg = remainingQuota } if deductFromPkg <= 0 { + beforeData := packageUsageStateData(pkg) if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐状态失败") } + pkg.Status = constants.PackageUsageStatusDepleted + auditChanges = append(auditChanges, packageUsageAuditChange{Usage: pkg, BeforeData: beforeData, AfterData: packageUsageStateData(pkg)}) continue } @@ -149,14 +163,20 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string, updates["status"] = constants.PackageUsageStatusDepleted } + beforeData := packageUsageStateData(pkg) if err := tx.Model(pkg).Updates(updates).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐使用量失败") } + pkg.DataUsageMB = newUsage + if status, ok := updates["status"].(int); ok { + pkg.Status = status + } // 任务 10.6: 写入日记录 if err := s.updateDailyRecord(ctx, tx, pkg.ID, today, deductFromPkg, newUsage); err != nil { return err } + auditChanges = append(auditChanges, packageUsageAuditChange{Usage: pkg, BeforeData: beforeData, AfterData: packageUsageStateData(pkg)}) remainingUsage -= deductFromPkg @@ -171,17 +191,28 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string, } // 任务 10.5: 检查是否所有套餐都用完(触发停机) - shouldSuspendCurrent, err := s.checkAndTriggerSuspension(ctx, tx, targetCarrierType, targetCarrierID) + shouldSuspendCurrent, suspensionChanges, err := s.checkAndTriggerSuspension(ctx, tx, targetCarrierType, targetCarrierID) if err != nil { return err } + auditChanges = append(auditChanges, suspensionChanges...) shouldSuspend = shouldSuspendCurrent suspendCarrierType = targetCarrierType suspendCarrierID = targetCarrierID + if len(auditChanges) > 0 { + if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageTrafficDeducted, "扣减套餐权益流量", normalizePackageUsageAuditChanges(auditChanges), nil, map[string]any{ + "carrier_type": targetCarrierType, "carrier_id": targetCarrierID, "usage_mb": deductUsageMB, + }); err != nil { + return err + } + } return nil }) if err != nil { + if len(auditChanges) > 0 { + recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageTrafficDeducted, "扣减套餐权益流量失败", auditChanges[0].Usage, err) + } return err } @@ -360,7 +391,7 @@ func (s *UsageService) updateDailyRecord(ctx context.Context, tx *gorm.DB, packa } // checkAndTriggerSuspension 任务 10.5: 检查停机条件 -func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) { +func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, []packageUsageAuditChange, error) { query := tx.Model(&model.PackageUsage{}). Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}) @@ -369,24 +400,28 @@ func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.D } else if carrierType == constants.AssetTypeDevice { query = query.Where("device_id = ?", carrierID) } else { - return false, errors.New(errors.CodeInvalidParam, "无效的载体类型") + return false, nil, errors.New(errors.CodeInvalidParam, "无效的载体类型") } var packages []*model.PackageUsage if err := query.Find(&packages).Error; err != nil { - return false, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐状态失败") + return false, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐状态失败") } hasAvailablePackage := false + var changes []packageUsageAuditChange for _, pkg := range packages { if pkg == nil { continue } if pkg.IsTrafficDepleted() { if pkg.Status != constants.PackageUsageStatusDepleted { + beforeData := packageUsageStateData(pkg) if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil { - return false, errors.Wrap(errors.CodeDatabaseError, err, "更新套餐耗尽状态失败") + return false, nil, errors.Wrap(errors.CodeDatabaseError, err, "更新套餐耗尽状态失败") } + pkg.Status = constants.PackageUsageStatusDepleted + changes = append(changes, packageUsageAuditChange{Usage: pkg, BeforeData: beforeData, AfterData: packageUsageStateData(pkg)}) } continue } @@ -398,10 +433,10 @@ func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.D s.logger.Warn("所有套餐已用完,触发停机", zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID)) - return true, nil + return true, changes, nil } - return false, nil + return false, changes, nil } // triggerSuspensionAfterCommit 在事务提交后触发停机检查,避免回调读到未提交状态。 diff --git a/internal/service/package_series/audit.go b/internal/service/package_series/audit.go new file mode 100644 index 0000000..f2b5650 --- /dev/null +++ b/internal/service/package_series/audit.go @@ -0,0 +1,54 @@ +package package_series + +import ( + "context" + + "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" +) + +// SetAccessAudit 注入套餐系列统一审计 Writer。 +func (s *Service) SetAccessAudit(db *gorm.DB, writer *audit.Writer) { + s.db = db + s.auditWriter = writer +} + +func (s *Service) appendAudit(ctx context.Context, tx *gorm.DB, actionCode, summary string, series *model.PackageSeries, beforeData, afterData map[string]any) error { + if s.auditWriter == nil || s.db == nil { + return errors.New(errors.CodeInvalidStatus, "套餐系列统一审计接缝未配置") + } + return s.auditWriter.Append(ctx, tx, audit.AppendInput{ + ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform, + Result: constants.AuditResultSuccess, + Resources: []audit.ResourceInput{audit.PackageSeriesResource( + series, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePackageSeriesTarget, beforeData, afterData, + )}, + }) +} + +func (s *Service) recordAuditFailure(ctx context.Context, actionCode, summary string, series *model.PackageSeries, beforeData map[string]any, businessErr error) { + if series == nil { + return + } + s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{ + ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform, + Resources: []audit.ResourceInput{audit.PackageSeriesResource( + series, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePackageSeriesTarget, beforeData, nil, + )}, + }, businessErr) +} + +func packageSeriesData(series *model.PackageSeries) map[string]any { + if series == nil { + return nil + } + return map[string]any{ + "series_name": series.SeriesName, "description": series.Description, "status": series.Status, + "enable_one_time_commission": series.EnableOneTimeCommission, + "one_time_commission_config": series.OneTimeCommissionConfigJSON, + } +} diff --git a/internal/service/package_series/service.go b/internal/service/package_series/service.go index 2436000..aa15dc0 100644 --- a/internal/service/package_series/service.go +++ b/internal/service/package_series/service.go @@ -7,6 +7,7 @@ import ( "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" @@ -18,9 +19,11 @@ import ( // Service 套餐系列业务服务 type Service struct { + db *gorm.DB packageSeriesStore *postgres.PackageSeriesStore shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore packageStore *postgres.PackageStore + auditWriter *audit.Writer } // New 创建套餐系列服务实例 @@ -32,7 +35,7 @@ func New(packageSeriesStore *postgres.PackageSeriesStore, shopSeriesAllocationSt } } -func (s *Service) Create(ctx context.Context, req *dto.CreatePackageSeriesRequest) (*dto.PackageSeriesResponse, error) { +func (s *Service) Create(ctx context.Context, req *dto.CreatePackageSeriesRequest) (_ *dto.PackageSeriesResponse, retErr error) { currentUserID := middleware.GetUserIDFromContext(ctx) if currentUserID == 0 { return nil, errors.New(errors.CodeUnauthorized, "未授权访问") @@ -50,6 +53,13 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageSeriesReques Status: constants.StatusEnabled, OneTimeCommissionConfigJSON: "{}", } + defer func() { + if retErr != nil { + failedSeries := *series + failedSeries.ID = 0 + s.recordAuditFailure(ctx, constants.AuditActionPackageSeriesCreated, "创建套餐系列失败 "+series.SeriesCode, &failedSeries, nil, retErr) + } + }() series.Creator = currentUserID if req.EnableOneTimeCommission != nil { @@ -69,8 +79,13 @@ func (s *Service) Create(ctx context.Context, req *dto.CreatePackageSeriesReques } } - if err := s.packageSeriesStore.Create(ctx, series); err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "创建套餐系列失败") + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := postgres.NewPackageSeriesStore(tx).Create(ctx, series); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "创建套餐系列失败") + } + return s.appendAudit(ctx, tx, constants.AuditActionPackageSeriesCreated, "创建套餐系列 "+series.SeriesCode, series, nil, packageSeriesData(series)) + }); err != nil { + return nil, err } return s.toResponse(series), nil @@ -87,7 +102,7 @@ func (s *Service) Get(ctx context.Context, id uint) (*dto.PackageSeriesResponse, return s.toResponse(series), nil } -func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageSeriesRequest) (*dto.PackageSeriesResponse, error) { +func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageSeriesRequest) (_ *dto.PackageSeriesResponse, retErr error) { currentUserID := middleware.GetUserIDFromContext(ctx) if currentUserID == 0 { return nil, errors.New(errors.CodeUnauthorized, "未授权访问") @@ -100,6 +115,12 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageSer } return nil, errors.Wrap(errors.CodeInternalError, err, "获取套餐系列失败") } + before := *series + defer func() { + if retErr != nil { + s.recordAuditFailure(ctx, constants.AuditActionPackageSeriesUpdated, "更新套餐系列失败 "+before.SeriesCode, &before, packageSeriesData(&before), retErr) + } + }() if req.SeriesName != nil { series.SeriesName = *req.SeriesName @@ -128,21 +149,31 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePackageSer } series.Updater = currentUserID - if err := s.packageSeriesStore.Update(ctx, series); err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "更新套餐系列失败") + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := postgres.NewPackageSeriesStore(tx).Update(ctx, series); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "更新套餐系列失败") + } + return s.appendAudit(ctx, tx, constants.AuditActionPackageSeriesUpdated, "更新套餐系列 "+series.SeriesCode, series, packageSeriesData(&before), packageSeriesData(series)) + }); err != nil { + return nil, err } return s.toResponse(series), nil } -func (s *Service) Delete(ctx context.Context, id uint) error { - _, err := s.packageSeriesStore.GetByID(ctx, id) +func (s *Service) Delete(ctx context.Context, id uint) (retErr error) { + series, err := s.packageSeriesStore.GetByID(ctx, id) if err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeNotFound, "套餐系列不存在") } return errors.Wrap(errors.CodeInternalError, err, "获取套餐系列失败") } + defer func() { + if retErr != nil { + s.recordAuditFailure(ctx, constants.AuditActionPackageSeriesDeleted, "删除套餐系列失败 "+series.SeriesCode, series, packageSeriesData(series), retErr) + } + }() count, err := s.packageStore.CountBySeriesID(ctx, id) if err != nil { @@ -152,11 +183,12 @@ func (s *Service) Delete(ctx context.Context, id uint) error { return errors.New(errors.CodeInvalidParam, fmt.Sprintf("该系列下有 %d 个关联套餐,请先处理后再删除", count)) } - if err := s.packageSeriesStore.Delete(ctx, id); err != nil { - return errors.Wrap(errors.CodeInternalError, err, "删除套餐系列失败") - } - - return nil + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := postgres.NewPackageSeriesStore(tx).Delete(ctx, id); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "删除套餐系列失败") + } + return s.appendAudit(ctx, tx, constants.AuditActionPackageSeriesDeleted, "删除套餐系列 "+series.SeriesCode, series, packageSeriesData(series), map[string]any{"deleted": true}) + }) } func (s *Service) List(ctx context.Context, req *dto.PackageSeriesListRequest) ([]*dto.PackageSeriesResponse, int64, error) { @@ -223,7 +255,7 @@ func (s *Service) List(ctx context.Context, req *dto.PackageSeriesListRequest) ( return responses, total, nil } -func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error { +func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) (retErr error) { currentUserID := middleware.GetUserIDFromContext(ctx) if currentUserID == 0 { return errors.New(errors.CodeUnauthorized, "未授权访问") @@ -236,15 +268,22 @@ func (s *Service) UpdateStatus(ctx context.Context, id uint, status int) error { } return errors.Wrap(errors.CodeInternalError, err, "获取套餐系列失败") } + before := *series + defer func() { + if retErr != nil { + s.recordAuditFailure(ctx, constants.AuditActionPackageSeriesStatusUpdated, "更新套餐系列状态失败 "+before.SeriesCode, &before, map[string]any{"status": before.Status}, retErr) + } + }() series.Status = status series.Updater = currentUserID - if err := s.packageSeriesStore.Update(ctx, series); err != nil { - return errors.Wrap(errors.CodeInternalError, err, "更新套餐系列状态失败") - } - - return nil + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := postgres.NewPackageSeriesStore(tx).Update(ctx, series); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "更新套餐系列状态失败") + } + return s.appendAudit(ctx, tx, constants.AuditActionPackageSeriesStatusUpdated, "更新套餐系列状态 "+series.SeriesCode, series, map[string]any{"status": before.Status}, map[string]any{"status": series.Status}) + }) } func (s *Service) toResponse(series *model.PackageSeries) *dto.PackageSeriesResponse { diff --git a/internal/service/polling/alert_service.go b/internal/service/polling/alert_service.go index 1925974..fa27be5 100644 --- a/internal/service/polling/alert_service.go +++ b/internal/service/polling/alert_service.go @@ -12,21 +12,32 @@ import ( "github.com/bytedance/sonic" "github.com/redis/go-redis/v9" "go.uber.org/zap" + "gorm.io/gorm" + auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "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" "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" ) // AlertService 告警服务 type AlertService struct { ruleStore *postgres.PollingAlertRuleStore historyStore *postgres.PollingAlertHistoryStore + db *gorm.DB + auditWriter *auditinfra.Writer redis *redis.Client logger *zap.Logger } +// SetAudit 注入轮询告警规则事务与统一审计 Writer。 +func (s *AlertService) SetAudit(db *gorm.DB, writer *auditinfra.Writer) { + s.db = db + s.auditWriter = writer +} + // NewAlertService 创建告警服务实例 func NewAlertService( ruleStore *postgres.PollingAlertRuleStore, @@ -44,6 +55,10 @@ func NewAlertService( // CreateRule 创建告警规则 func (s *AlertService) CreateRule(ctx context.Context, rule *model.PollingAlertRule) error { + operatorID := middleware.GetUserIDFromContext(ctx) + if operatorID == 0 { + return errors.New(errors.CodeUnauthorized, "未授权访问") + } // 验证参数 if rule.RuleName == "" { return errors.New(errors.CodeInvalidParam, "规则名称不能为空") @@ -62,7 +77,28 @@ func (s *AlertService) CreateRule(ctx context.Context, rule *model.PollingAlertR if rule.Operator == "" { rule.Operator = ">" // 默认大于 } - return s.ruleStore.Create(ctx, rule) + rule.CreatedBy = &operatorID + rule.UpdatedBy = &operatorID + err := runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { + if err := s.ruleStore.WithTx(tx).Create(ctx, rule); err != nil { + return err + } + return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingAlertRuleCreated, Summary: "创建轮询告警规则", + ResourceType: constants.AuditResourcePollingAlertRule, ResourceID: rule.ID, + ResourceKey: pollingManualTriggerKey(rule.ID), DisplayName: rule.RuleName, OperatorID: operatorID, + IdentitySnapshot: pollingAlertRuleIdentity(rule), AfterData: pollingAlertRuleState(rule), + }) + }) + if err != nil { + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingAlertRuleCreated, Summary: "创建轮询告警规则失败", + ResourceType: constants.AuditResourcePollingAlertRule, ResourceKey: rule.RuleName, + DisplayName: rule.RuleName, OperatorID: operatorID, + IdentitySnapshot: pollingAlertRuleIdentity(rule), AfterData: pollingAlertRuleState(rule), + }, err) + } + return err } // GetRule 获取告警规则 @@ -81,10 +117,15 @@ func (s *AlertService) ListRules(ctx context.Context) ([]*model.PollingAlertRule // UpdateRule 更新告警规则 func (s *AlertService) UpdateRule(ctx context.Context, id uint, updates map[string]interface{}) error { + operatorID := middleware.GetUserIDFromContext(ctx) + if operatorID == 0 { + return errors.New(errors.CodeUnauthorized, "未授权访问") + } rule, err := s.ruleStore.GetByID(ctx, id) if err != nil { return errors.Wrap(errors.CodeNotFound, err, "告警规则不存在") } + before := *rule if name, ok := updates["rule_name"].(string); ok && name != "" { rule.RuleName = name @@ -104,17 +145,60 @@ func (s *AlertService) UpdateRule(ctx context.Context, id uint, updates map[stri if channels, ok := updates["notification_channels"].(string); ok { rule.NotificationChannels = channels } - - return s.ruleStore.Update(ctx, rule) + rule.UpdatedBy = &operatorID + err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { + if err := s.ruleStore.WithTx(tx).Update(ctx, rule); err != nil { + return err + } + return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingAlertRuleUpdated, Summary: "更新轮询告警规则", + ResourceType: constants.AuditResourcePollingAlertRule, ResourceID: rule.ID, + ResourceKey: pollingManualTriggerKey(rule.ID), DisplayName: rule.RuleName, OperatorID: operatorID, + IdentitySnapshot: pollingAlertRuleIdentity(rule), + BeforeData: pollingAlertRuleState(&before), AfterData: pollingAlertRuleState(rule), + }) + }) + if err != nil { + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingAlertRuleUpdated, Summary: "更新轮询告警规则失败", + ResourceType: constants.AuditResourcePollingAlertRule, ResourceID: rule.ID, + ResourceKey: pollingManualTriggerKey(rule.ID), DisplayName: rule.RuleName, OperatorID: operatorID, + IdentitySnapshot: pollingAlertRuleIdentity(rule), BeforeData: pollingAlertRuleState(&before), + }, err) + } + return err } // DeleteRule 删除告警规则 func (s *AlertService) DeleteRule(ctx context.Context, id uint) error { - _, err := s.ruleStore.GetByID(ctx, id) + operatorID := middleware.GetUserIDFromContext(ctx) + if operatorID == 0 { + return errors.New(errors.CodeUnauthorized, "未授权访问") + } + rule, err := s.ruleStore.GetByID(ctx, id) if err != nil { return errors.Wrap(errors.CodeNotFound, err, "告警规则不存在") } - return s.ruleStore.Delete(ctx, id) + err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { + if err := s.ruleStore.WithTx(tx).Delete(ctx, id); err != nil { + return err + } + return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingAlertRuleDeleted, Summary: "删除轮询告警规则", + ResourceType: constants.AuditResourcePollingAlertRule, ResourceID: rule.ID, + ResourceKey: pollingManualTriggerKey(rule.ID), DisplayName: rule.RuleName, OperatorID: operatorID, + IdentitySnapshot: pollingAlertRuleIdentity(rule), BeforeData: pollingAlertRuleState(rule), + }) + }) + if err != nil { + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingAlertRuleDeleted, Summary: "删除轮询告警规则失败", + ResourceType: constants.AuditResourcePollingAlertRule, ResourceID: rule.ID, + ResourceKey: pollingManualTriggerKey(rule.ID), DisplayName: rule.RuleName, OperatorID: operatorID, + IdentitySnapshot: pollingAlertRuleIdentity(rule), BeforeData: pollingAlertRuleState(rule), + }, err) + } + return err } // ListHistory 获取告警历史 diff --git a/internal/service/polling/audit.go b/internal/service/polling/audit.go new file mode 100644 index 0000000..98a4605 --- /dev/null +++ b/internal/service/polling/audit.go @@ -0,0 +1,121 @@ +package polling + +import ( + "context" + stderrors "errors" + "strconv" + + "gorm.io/gorm" + + auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/auditcontext" + "github.com/break/junhong_cmp_fiber/pkg/auditfailure" + "github.com/break/junhong_cmp_fiber/pkg/constants" + pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +func runPollingTransaction(ctx context.Context, db *gorm.DB, writer *auditinfra.Writer, fn func(*gorm.DB) error) error { + if db == nil || writer == nil { + return pkgerrors.New(pkgerrors.CodeInvalidStatus, "统一轮询审计接缝未配置") + } + return db.WithContext(ctx).Transaction(fn) +} + +func writePollingAudit(ctx context.Context, tx *gorm.DB, writer *auditinfra.Writer, input auditinfra.PollingInput) error { + return writer.WritePolling(ctx, tx, input) +} + +func recordPollingFailure(ctx context.Context, db *gorm.DB, writer *auditinfra.Writer, input auditinfra.PollingInput, originalErr error) { + if input.OperatorID == 0 || input.ResourceType == "" || input.ResourceKey == "" { + return + } + var appErr *pkgerrors.AppError + if !stderrors.As(originalErr, &appErr) { + appErr = pkgerrors.New(pkgerrors.CodeInternalError, "轮询操作失败") + } + if input.Result == "" { + input.Result = constants.AuditResultFailed + } + if input.ErrorCode == "" { + input.ErrorCode = strconv.Itoa(appErr.Code) + } + if input.ErrorSummary == "" { + input.ErrorSummary = appErr.Message + } + if db != nil && writer != nil { + if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return writePollingAudit(ctx, tx, writer, input) + }); err == nil { + return + } else { + originalErr = err + } + } + linkage := auditcontext.From(ctx) + auditfailure.RecordSecondaryWriteFailure( + input.ActionCode, input.ResourceKey, linkage.RequestID, linkage.CorrelationID, input.ErrorCode, originalErr, + ) +} + +func pollingConfigIdentity(config *model.PollingConfig) map[string]any { + return map[string]any{ + "id": config.ID, "config_name": config.ConfigName, "card_condition": config.CardCondition, + "card_category": config.CardCategory, "carrier_id": config.CarrierID, "priority": config.Priority, + "status": config.Status, + } +} + +func pollingConfigState(config *model.PollingConfig) map[string]any { + return map[string]any{ + "config_name": config.ConfigName, "card_condition": config.CardCondition, + "card_category": config.CardCategory, "carrier_id": config.CarrierID, "priority": config.Priority, + "realname_check_interval": config.RealnameCheckInterval, "carddata_check_interval": config.CarddataCheckInterval, + "package_check_interval": config.PackageCheckInterval, "protect_check_interval": config.ProtectCheckInterval, + "card_status_check_interval": config.CardStatusCheckInterval, "status": config.Status, + "description": config.Description, + } +} + +func pollingConcurrencyIdentity(config *model.PollingConcurrencyConfig) map[string]any { + return map[string]any{"id": config.ID, "task_type": config.TaskType, "max_concurrency": config.MaxConcurrency} +} + +func pollingAlertRuleIdentity(rule *model.PollingAlertRule) map[string]any { + return map[string]any{ + "id": rule.ID, "rule_name": rule.RuleName, "task_type": rule.TaskType, + "metric_type": rule.MetricType, "operator": rule.Operator, "threshold": rule.Threshold, + "alert_level": rule.AlertLevel, "status": rule.Status, + } +} + +func pollingAlertRuleState(rule *model.PollingAlertRule) map[string]any { + return map[string]any{ + "rule_name": rule.RuleName, "task_type": rule.TaskType, "metric_type": rule.MetricType, + "operator": rule.Operator, "threshold": rule.Threshold, "duration_minutes": rule.DurationMinutes, + "alert_level": rule.AlertLevel, "status": rule.Status, "cooldown_minutes": rule.CooldownMinutes, + "notification_channels_configured": rule.NotificationChannels != "", "description": rule.Description, + } +} + +func pollingManualTriggerIdentity(log *model.PollingManualTriggerLog) map[string]any { + return map[string]any{ + "id": log.ID, "task_type": log.TaskType, "trigger_type": log.TriggerType, + "total_count": log.TotalCount, "status": log.Status, "triggered_by": log.TriggeredBy, + } +} + +func pollingManualTriggerKey(id uint) string { + return strconv.FormatUint(uint64(id), 10) +} + +func pollingManualAttemptKey(taskType, triggerType string, operatorID uint) string { + return triggerType + ":" + taskType + ":" + strconv.FormatUint(uint64(operatorID), 10) +} + +func pollingManualAttemptIdentity(taskType, triggerType string, totalCount int, operatorID uint) map[string]any { + return map[string]any{ + "task_type": taskType, "trigger_type": triggerType, + "total_count": totalCount, "triggered_by": operatorID, + } +} diff --git a/internal/service/polling/concurrency_service.go b/internal/service/polling/concurrency_service.go index 8bcd141..f80a9d9 100644 --- a/internal/service/polling/concurrency_service.go +++ b/internal/service/polling/concurrency_service.go @@ -5,17 +5,28 @@ import ( "time" "github.com/redis/go-redis/v9" + "gorm.io/gorm" + auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "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" "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" ) // ConcurrencyService 并发控制服务 type ConcurrencyService struct { - store *postgres.PollingConcurrencyConfigStore - redis *redis.Client + store *postgres.PollingConcurrencyConfigStore + db *gorm.DB + auditWriter *auditinfra.Writer + redis *redis.Client +} + +// SetAudit 注入轮询并发配置事务与统一审计 Writer。 +func (s *ConcurrencyService) SetAudit(db *gorm.DB, writer *auditinfra.Writer) { + s.db = db + s.auditWriter = writer } // NewConcurrencyService 创建并发控制服务实例 @@ -113,14 +124,34 @@ func (s *ConcurrencyService) UpdateMaxConcurrency(ctx context.Context, taskType } // 验证任务类型存在 - _, err := s.store.GetByTaskType(ctx, taskType) + config, err := s.store.GetByTaskType(ctx, taskType) if err != nil { return errors.Wrap(errors.CodeNotFound, err, "任务类型不存在") } - // 更新数据库 - if err := s.store.UpdateMaxConcurrency(ctx, taskType, maxConcurrency, updatedBy); err != nil { - return errors.Wrap(errors.CodeInternalError, err, "更新并发配置失败") + before := config.MaxConcurrency + err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { + if err := s.store.WithTx(tx).UpdateMaxConcurrency(ctx, taskType, maxConcurrency, updatedBy); err != nil { + return err + } + config.MaxConcurrency = maxConcurrency + return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingConcurrencyUpdated, Summary: "更新轮询并发配置", + ResourceType: constants.AuditResourcePollingConcurrencyConfig, ResourceID: config.ID, + ResourceKey: config.TaskType, DisplayName: s.getTaskTypeName(config.TaskType), OperatorID: updatedBy, + IdentitySnapshot: pollingConcurrencyIdentity(config), + BeforeData: map[string]any{"max_concurrency": before}, AfterData: map[string]any{"max_concurrency": maxConcurrency}, + }) + }) + if err != nil { + appErr := errors.Wrap(errors.CodeInternalError, err, "更新并发配置失败") + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingConcurrencyUpdated, Summary: "更新轮询并发配置失败", + ResourceType: constants.AuditResourcePollingConcurrencyConfig, ResourceID: config.ID, + ResourceKey: config.TaskType, DisplayName: s.getTaskTypeName(config.TaskType), OperatorID: updatedBy, + IdentitySnapshot: pollingConcurrencyIdentity(config), BeforeData: map[string]any{"max_concurrency": before}, + }, appErr) + return appErr } // 同步更新 Redis 配置缓存 @@ -134,19 +165,72 @@ func (s *ConcurrencyService) UpdateMaxConcurrency(ctx context.Context, taskType // ResetConcurrency 重置并发计数(用于信号量修复) func (s *ConcurrencyService) ResetConcurrency(ctx context.Context, taskType string) error { + operatorID := middleware.GetUserIDFromContext(ctx) + if operatorID == 0 { + return errors.New(errors.CodeUnauthorized, "未授权访问") + } // 验证任务类型存在 - _, err := s.store.GetByTaskType(ctx, taskType) + config, err := s.store.GetByTaskType(ctx, taskType) if err != nil { return errors.Wrap(errors.CodeNotFound, err, "任务类型不存在") } // 重置 Redis 当前计数为 0 currentKey := constants.RedisPollingConcurrencyCurrentKey(taskType) - if err := s.redis.Set(ctx, currentKey, 0, 24*time.Hour).Err(); err != nil { - return errors.Wrap(errors.CodeInternalError, err, "重置并发计数失败") + before, getErr := s.redis.Get(ctx, currentKey).Int64() + beforeExists := getErr == nil + if getErr != nil && getErr != redis.Nil { + appErr := errors.Wrap(errors.CodeInternalError, getErr, "读取并发计数失败") + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingConcurrencyReset, Summary: "重置轮询并发计数失败", + ResourceType: constants.AuditResourcePollingConcurrencyConfig, ResourceID: config.ID, + ResourceKey: config.TaskType, DisplayName: s.getTaskTypeName(config.TaskType), OperatorID: operatorID, + IdentitySnapshot: pollingConcurrencyIdentity(config), + }, appErr) + return appErr } - - return nil + beforeTTL := time.Duration(0) + if beforeExists { + beforeTTL, _ = s.redis.PTTL(ctx, currentKey).Result() + if beforeTTL < 0 { + beforeTTL = 0 + } + } + if err := s.redis.Set(ctx, currentKey, 0, 24*time.Hour).Err(); err != nil { + appErr := errors.Wrap(errors.CodeInternalError, err, "重置并发计数失败") + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingConcurrencyReset, Summary: "重置轮询并发计数失败", + ResourceType: constants.AuditResourcePollingConcurrencyConfig, ResourceID: config.ID, + ResourceKey: config.TaskType, DisplayName: s.getTaskTypeName(config.TaskType), OperatorID: operatorID, + IdentitySnapshot: pollingConcurrencyIdentity(config), BeforeData: map[string]any{"current": before}, + }, appErr) + return appErr + } + err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { + return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingConcurrencyReset, Summary: "重置轮询并发计数", + ResourceType: constants.AuditResourcePollingConcurrencyConfig, ResourceID: config.ID, + ResourceKey: config.TaskType, DisplayName: s.getTaskTypeName(config.TaskType), OperatorID: operatorID, + IdentitySnapshot: pollingConcurrencyIdentity(config), + BeforeData: map[string]any{"current": before}, AfterData: map[string]any{"current": int64(0)}, + }) + }) + if err == nil { + return nil + } + if beforeExists { + _ = s.redis.Set(ctx, currentKey, before, beforeTTL).Err() + } else { + _ = s.redis.Del(ctx, currentKey).Err() + } + appErr := errors.Wrap(errors.CodeInternalError, err, "记录重置并发计数审计失败") + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingConcurrencyReset, Summary: "重置轮询并发计数失败", + ResourceType: constants.AuditResourcePollingConcurrencyConfig, ResourceID: config.ID, + ResourceKey: config.TaskType, DisplayName: s.getTaskTypeName(config.TaskType), OperatorID: operatorID, + IdentitySnapshot: pollingConcurrencyIdentity(config), BeforeData: map[string]any{"current": before}, + }, appErr) + return appErr } // InitFromDB 从数据库初始化 Redis 并发配置 diff --git a/internal/service/polling/config_service.go b/internal/service/polling/config_service.go index a9d03f3..098fc41 100644 --- a/internal/service/polling/config_service.go +++ b/internal/service/polling/config_service.go @@ -8,6 +8,7 @@ import ( "go.uber.org/zap" "gorm.io/gorm" + auditinfra "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" @@ -20,10 +21,18 @@ import ( // ConfigService 轮询配置服务 type ConfigService struct { configStore *postgres.PollingConfigStore + db *gorm.DB + auditWriter *auditinfra.Writer redis *redis.Client logger *zap.Logger } +// SetAudit 注入轮询配置事务与统一审计 Writer。 +func (s *ConfigService) SetAudit(db *gorm.DB, writer *auditinfra.Writer) { + s.db = db + s.auditWriter = writer +} + // NewConfigService 创建轮询配置服务实例 func NewConfigService(configStore *postgres.PollingConfigStore, redisClient *redis.Client, logger *zap.Logger) *ConfigService { return &ConfigService{configStore: configStore, redis: redisClient, logger: logger} @@ -48,7 +57,14 @@ func (s *ConfigService) Create(ctx context.Context, req *dto.CreatePollingConfig // 验证配置名称唯一性 existing, _ := s.configStore.GetByName(ctx, req.ConfigName) if existing != nil { - return nil, errors.New(errors.CodeInvalidParam, "配置名称已存在") + appErr := errors.New(errors.CodeInvalidParam, "配置名称已存在") + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingConfigCreated, Summary: "拒绝创建重复轮询配置", + ResourceType: constants.AuditResourcePollingConfig, ResourceKey: req.ConfigName, + DisplayName: req.ConfigName, OperatorID: currentUserID, Result: constants.AuditResultDenied, + IdentitySnapshot: map[string]any{"config_name": req.ConfigName}, + }, appErr) + return nil, appErr } // 验证检查间隔(至少一个不为空) @@ -75,8 +91,26 @@ func (s *ConfigService) Create(ctx context.Context, req *dto.CreatePollingConfig UpdatedBy: ¤tUserID, } - if err := s.configStore.Create(ctx, config); err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "创建轮询配置失败") + err := runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { + if err := s.configStore.WithTx(tx).Create(ctx, config); err != nil { + return err + } + return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingConfigCreated, Summary: "创建轮询配置", + ResourceType: constants.AuditResourcePollingConfig, ResourceID: config.ID, + ResourceKey: pollingManualTriggerKey(config.ID), DisplayName: config.ConfigName, + OperatorID: currentUserID, IdentitySnapshot: pollingConfigIdentity(config), AfterData: pollingConfigState(config), + }) + }) + if err != nil { + appErr := errors.Wrap(errors.CodeInternalError, err, "创建轮询配置失败") + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingConfigCreated, Summary: "创建轮询配置失败", + ResourceType: constants.AuditResourcePollingConfig, + ResourceKey: config.ConfigName, DisplayName: config.ConfigName, OperatorID: currentUserID, + IdentitySnapshot: pollingConfigIdentity(config), AfterData: pollingConfigState(config), + }, appErr) + return nil, appErr } s.notifyConfigChanged(ctx, "created") @@ -109,13 +143,22 @@ func (s *ConfigService) Update(ctx context.Context, id uint, req *dto.UpdatePoll } return nil, errors.Wrap(errors.CodeInternalError, err, "获取轮询配置失败") } + before := *config // 更新字段 if req.ConfigName != nil { // 检查名称唯一性 existing, _ := s.configStore.GetByName(ctx, *req.ConfigName) if existing != nil && existing.ID != id { - return nil, errors.New(errors.CodeInvalidParam, "配置名称已存在") + appErr := errors.New(errors.CodeInvalidParam, "配置名称已存在") + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingConfigUpdated, Summary: "拒绝更新为重复轮询配置名称", + ResourceType: constants.AuditResourcePollingConfig, ResourceID: config.ID, + ResourceKey: pollingManualTriggerKey(config.ID), DisplayName: config.ConfigName, + OperatorID: currentUserID, Result: constants.AuditResultDenied, + IdentitySnapshot: pollingConfigIdentity(config), BeforeData: pollingConfigState(config), + }, appErr) + return nil, appErr } config.ConfigName = *req.ConfigName } @@ -151,8 +194,28 @@ func (s *ConfigService) Update(ctx context.Context, id uint, req *dto.UpdatePoll } config.UpdatedBy = ¤tUserID - if err := s.configStore.Update(ctx, config); err != nil { - return nil, errors.Wrap(errors.CodeInternalError, err, "更新轮询配置失败") + err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { + if err := s.configStore.WithTx(tx).Update(ctx, config); err != nil { + return err + } + return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingConfigUpdated, Summary: "更新轮询配置", + ResourceType: constants.AuditResourcePollingConfig, ResourceID: config.ID, + ResourceKey: pollingManualTriggerKey(config.ID), DisplayName: config.ConfigName, + OperatorID: currentUserID, IdentitySnapshot: pollingConfigIdentity(config), + BeforeData: pollingConfigState(&before), AfterData: pollingConfigState(config), + }) + }) + if err != nil { + appErr := errors.Wrap(errors.CodeInternalError, err, "更新轮询配置失败") + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingConfigUpdated, Summary: "更新轮询配置失败", + ResourceType: constants.AuditResourcePollingConfig, ResourceID: config.ID, + ResourceKey: pollingManualTriggerKey(config.ID), DisplayName: config.ConfigName, + OperatorID: currentUserID, IdentitySnapshot: pollingConfigIdentity(config), + BeforeData: pollingConfigState(&before), AfterData: pollingConfigState(config), + }, appErr) + return nil, appErr } s.notifyConfigChanged(ctx, "updated") @@ -161,7 +224,11 @@ func (s *ConfigService) Update(ctx context.Context, id uint, req *dto.UpdatePoll // Delete 删除轮询配置 func (s *ConfigService) Delete(ctx context.Context, id uint) error { - _, err := s.configStore.GetByID(ctx, id) + currentUserID := middleware.GetUserIDFromContext(ctx) + if currentUserID == 0 { + return errors.New(errors.CodeUnauthorized, "未授权访问") + } + config, err := s.configStore.GetByID(ctx, id) if err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodePollingConfigNotFound, "轮询配置不存在") @@ -169,8 +236,26 @@ func (s *ConfigService) Delete(ctx context.Context, id uint) error { return errors.Wrap(errors.CodeInternalError, err, "获取轮询配置失败") } - if err := s.configStore.Delete(ctx, id); err != nil { - return errors.Wrap(errors.CodeInternalError, err, "删除轮询配置失败") + err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { + if err := s.configStore.WithTx(tx).Delete(ctx, id); err != nil { + return err + } + return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingConfigDeleted, Summary: "删除轮询配置", + ResourceType: constants.AuditResourcePollingConfig, ResourceID: config.ID, + ResourceKey: pollingManualTriggerKey(config.ID), DisplayName: config.ConfigName, + OperatorID: currentUserID, IdentitySnapshot: pollingConfigIdentity(config), BeforeData: pollingConfigState(config), + }) + }) + if err != nil { + appErr := errors.Wrap(errors.CodeInternalError, err, "删除轮询配置失败") + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingConfigDeleted, Summary: "删除轮询配置失败", + ResourceType: constants.AuditResourcePollingConfig, ResourceID: config.ID, + ResourceKey: pollingManualTriggerKey(config.ID), DisplayName: config.ConfigName, + OperatorID: currentUserID, IdentitySnapshot: pollingConfigIdentity(config), BeforeData: pollingConfigState(config), + }, appErr) + return appErr } s.notifyConfigChanged(ctx, "deleted") @@ -228,7 +313,7 @@ func (s *ConfigService) UpdateStatus(ctx context.Context, id uint, status int16) return errors.New(errors.CodeUnauthorized, "未授权访问") } - _, err := s.configStore.GetByID(ctx, id) + config, err := s.configStore.GetByID(ctx, id) if err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodePollingConfigNotFound, "轮询配置不存在") @@ -236,8 +321,29 @@ func (s *ConfigService) UpdateStatus(ctx context.Context, id uint, status int16) return errors.Wrap(errors.CodeInternalError, err, "获取轮询配置失败") } - if err := s.configStore.UpdateStatus(ctx, id, status, currentUserID); err != nil { - return errors.Wrap(errors.CodeInternalError, err, "更新轮询配置状态失败") + before := pollingConfigState(config) + err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { + if err := s.configStore.WithTx(tx).UpdateStatus(ctx, id, status, currentUserID); err != nil { + return err + } + config.Status = status + return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingConfigStatusUpdated, Summary: "更新轮询配置状态", + ResourceType: constants.AuditResourcePollingConfig, ResourceID: config.ID, + ResourceKey: pollingManualTriggerKey(config.ID), DisplayName: config.ConfigName, + OperatorID: currentUserID, IdentitySnapshot: pollingConfigIdentity(config), + BeforeData: before, AfterData: pollingConfigState(config), + }) + }) + if err != nil { + appErr := errors.Wrap(errors.CodeInternalError, err, "更新轮询配置状态失败") + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingConfigStatusUpdated, Summary: "更新轮询配置状态失败", + ResourceType: constants.AuditResourcePollingConfig, ResourceID: config.ID, + ResourceKey: pollingManualTriggerKey(config.ID), DisplayName: config.ConfigName, + OperatorID: currentUserID, IdentitySnapshot: pollingConfigIdentity(config), BeforeData: before, + }, appErr) + return appErr } s.notifyConfigChanged(ctx, "updated") diff --git a/internal/service/polling/manual_trigger_service.go b/internal/service/polling/manual_trigger_service.go index 56ee69b..ab69e0c 100644 --- a/internal/service/polling/manual_trigger_service.go +++ b/internal/service/polling/manual_trigger_service.go @@ -7,7 +7,9 @@ import ( "github.com/redis/go-redis/v9" "go.uber.org/zap" + "gorm.io/gorm" + auditinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "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" @@ -19,10 +21,18 @@ import ( type ManualTriggerService struct { logStore *postgres.PollingManualTriggerLogStore iotCardStore *postgres.IotCardStore + db *gorm.DB + auditWriter *auditinfra.Writer redis *redis.Client logger *zap.Logger } +// SetAudit 注入手动轮询任务事务与统一审计 Writer。 +func (s *ManualTriggerService) SetAudit(db *gorm.DB, writer *auditinfra.Writer) { + s.db = db + s.auditWriter = writer +} + // NewManualTriggerService 创建手动触发服务实例 func NewManualTriggerService( logStore *postgres.PollingManualTriggerLogStore, @@ -49,6 +59,10 @@ func (s *ManualTriggerService) TriggerSingle(ctx context.Context, cardID uint, t if err := s.canManageCard(ctx, cardID); err != nil { return err } + cards, err := s.iotCardStore.GetByIDs(ctx, []uint{cardID}) + if err != nil { + return errors.Wrap(errors.CodeInternalError, err, "查询手动轮询卡失败") + } // 检查每日触发限制 todayCount, err := s.logStore.CountTodayTriggers(ctx, triggeredBy) @@ -60,7 +74,15 @@ func (s *ManualTriggerService) TriggerSingle(ctx context.Context, cardID uint, t return err } if todayCount >= 500 { // 每日最多触发500次 - return errors.New(errors.CodeInvalidParam, "已达到每日触发次数上限") + appErr := errors.New(errors.CodeInvalidParam, "已达到每日触发次数上限") + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingManualTriggerSingle, Summary: "拒绝超过每日上限的单卡手动触发", + ResourceType: constants.AuditResourcePollingManualTrigger, + ResourceKey: pollingManualAttemptKey(taskType, "single", triggeredBy), DisplayName: "单卡手动触发", + OperatorID: triggeredBy, Result: constants.AuditResultDenied, + IdentitySnapshot: pollingManualAttemptIdentity(taskType, "single", 1, triggeredBy), Cards: cards, + }, appErr) + return appErr } // 检查去重 @@ -74,7 +96,15 @@ func (s *ManualTriggerService) TriggerSingle(ctx context.Context, cardID uint, t return err } if added == 0 { - return errors.New(errors.CodeInvalidParam, "该卡已在手动触发队列中") + appErr := errors.New(errors.CodeInvalidParam, "该卡已在手动触发队列中") + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingManualTriggerSingle, Summary: "拒绝重复加入手动触发队列", + ResourceType: constants.AuditResourcePollingManualTrigger, + ResourceKey: pollingManualAttemptKey(taskType, "single", triggeredBy), DisplayName: "单卡手动触发", + OperatorID: triggeredBy, Result: constants.AuditResultDenied, + IdentitySnapshot: pollingManualAttemptIdentity(taskType, "single", 1, triggeredBy), Cards: cards, + }, appErr) + return appErr } // 设置去重 key 过期时间(24小时,与日限制周期对齐) s.redis.Expire(ctx, dedupeKey, 24*time.Hour) @@ -90,7 +120,27 @@ func (s *ManualTriggerService) TriggerSingle(ctx context.Context, cardID uint, t TriggeredBy: triggeredBy, TriggeredAt: time.Now(), } - if err := s.logStore.Create(ctx, triggerLog); err != nil { + err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { + if err := s.logStore.WithTx(tx).Create(ctx, triggerLog); err != nil { + return err + } + return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingManualTriggerSingle, Summary: "单卡手动触发", + ResourceType: constants.AuditResourcePollingManualTrigger, ResourceID: triggerLog.ID, + ResourceKey: pollingManualTriggerKey(triggerLog.ID), DisplayName: "手动轮询任务", + OperatorID: triggeredBy, IdentitySnapshot: pollingManualTriggerIdentity(triggerLog), + AfterData: map[string]any{"status": triggerLog.Status, "task_type": taskType, "trigger_type": triggerLog.TriggerType}, + Cards: cards, + }) + }) + if err != nil { + _ = s.redis.SRem(ctx, dedupeKey, cardID).Err() + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingManualTriggerSingle, Summary: "单卡手动触发失败", + ResourceType: constants.AuditResourcePollingManualTrigger, + ResourceKey: pollingManualAttemptKey(taskType, "single", triggeredBy), DisplayName: "单卡手动触发", + OperatorID: triggeredBy, IdentitySnapshot: pollingManualAttemptIdentity(taskType, "single", 1, triggeredBy), Cards: cards, + }, err) s.logger.Error("创建触发日志失败", zap.Uint("card_id", cardID), zap.Uint("triggered_by", triggeredBy), @@ -101,6 +151,7 @@ func (s *ManualTriggerService) TriggerSingle(ctx context.Context, cardID uint, t // 加入手动触发队列(使用 List,优先级高于定时轮询) queueKey := constants.RedisPollingManualQueueKey(taskType) if err := s.redis.LPush(ctx, queueKey, cardID).Err(); err != nil { + _ = s.redis.SRem(ctx, dedupeKey, cardID).Err() s.logger.Error("写入手动触发队列失败", zap.Uint("card_id", cardID), zap.String("task_type", taskType), @@ -136,6 +187,10 @@ func (s *ManualTriggerService) TriggerBatch(ctx context.Context, cardIDs []uint, if err := s.canManageCards(ctx, cardIDs); err != nil { return nil, err } + cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "查询手动轮询卡失败") + } // 检查每日触发限制 todayCount, err := s.logStore.CountTodayTriggers(ctx, triggeredBy) @@ -143,7 +198,15 @@ func (s *ManualTriggerService) TriggerBatch(ctx context.Context, cardIDs []uint, return nil, err } if todayCount >= 500 { // 每日最多触发500次 - return nil, errors.New(errors.CodeInvalidParam, "已达到每日触发次数上限") + appErr := errors.New(errors.CodeInvalidParam, "已达到每日触发次数上限") + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingManualTriggerBatch, Summary: "拒绝超过每日上限的批量手动触发", + ResourceType: constants.AuditResourcePollingManualTrigger, + ResourceKey: pollingManualAttemptKey(taskType, "batch", triggeredBy), DisplayName: "批量手动触发", + OperatorID: triggeredBy, Result: constants.AuditResultDenied, + IdentitySnapshot: pollingManualAttemptIdentity(taskType, "batch", len(cardIDs), triggeredBy), Cards: cards, + }, appErr) + return nil, appErr } // 创建触发日志 @@ -157,7 +220,26 @@ func (s *ManualTriggerService) TriggerBatch(ctx context.Context, cardIDs []uint, TriggeredBy: triggeredBy, TriggeredAt: time.Now(), } - if err := s.logStore.Create(ctx, triggerLog); err != nil { + err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { + if err := s.logStore.WithTx(tx).Create(ctx, triggerLog); err != nil { + return err + } + return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingManualTriggerBatch, Summary: "批量手动触发", + ResourceType: constants.AuditResourcePollingManualTrigger, ResourceID: triggerLog.ID, + ResourceKey: pollingManualTriggerKey(triggerLog.ID), DisplayName: "手动轮询任务", + OperatorID: triggeredBy, IdentitySnapshot: pollingManualTriggerIdentity(triggerLog), + AfterData: map[string]any{"status": triggerLog.Status, "task_type": taskType, "trigger_type": triggerLog.TriggerType}, + Cards: cards, + }) + }) + if err != nil { + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingManualTriggerBatch, Summary: "批量手动触发失败", + ResourceType: constants.AuditResourcePollingManualTrigger, + ResourceKey: pollingManualAttemptKey(taskType, "batch", triggeredBy), DisplayName: "批量手动触发", + OperatorID: triggeredBy, IdentitySnapshot: pollingManualAttemptIdentity(taskType, "batch", len(cardIDs), triggeredBy), Cards: cards, + }, err) return nil, err } @@ -252,7 +334,16 @@ func (s *ManualTriggerService) TriggerByCondition(ctx context.Context, filter *C return nil, err } if todayCount >= 500 { // 每日最多触发500次 - return nil, errors.New(errors.CodeInvalidParam, "已达到每日触发次数上限") + appErr := errors.New(errors.CodeInvalidParam, "已达到每日触发次数上限") + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingManualTriggerByCondition, Summary: "拒绝超过每日上限的条件筛选触发", + ResourceType: constants.AuditResourcePollingManualTrigger, + ResourceKey: pollingManualAttemptKey(taskType, "by_condition", triggeredBy), DisplayName: "条件筛选触发", + OperatorID: triggeredBy, Result: constants.AuditResultDenied, + IdentitySnapshot: pollingManualAttemptIdentity(taskType, "by_condition", 0, triggeredBy), + Metadata: map[string]any{"condition_filter_configured": true}, + }, appErr) + return nil, appErr } // 查询符合条件的卡(已应用权限过滤) @@ -264,6 +355,10 @@ func (s *ManualTriggerService) TriggerByCondition(ctx context.Context, filter *C if len(cardIDs) == 0 { return nil, errors.New(errors.CodeInvalidParam, "没有符合条件的卡") } + cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "查询手动轮询卡失败") + } // 创建触发日志 filterJSON, _ := json.Marshal(filter) @@ -278,7 +373,27 @@ func (s *ManualTriggerService) TriggerByCondition(ctx context.Context, filter *C TriggeredBy: triggeredBy, TriggeredAt: time.Now(), } - if err := s.logStore.Create(ctx, triggerLog); err != nil { + err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { + if err := s.logStore.WithTx(tx).Create(ctx, triggerLog); err != nil { + return err + } + return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingManualTriggerByCondition, Summary: "条件筛选触发", + ResourceType: constants.AuditResourcePollingManualTrigger, ResourceID: triggerLog.ID, + ResourceKey: pollingManualTriggerKey(triggerLog.ID), DisplayName: "手动轮询任务", + OperatorID: triggeredBy, IdentitySnapshot: pollingManualTriggerIdentity(triggerLog), + AfterData: map[string]any{"status": triggerLog.Status, "task_type": taskType, "trigger_type": triggerLog.TriggerType}, + Metadata: map[string]any{"condition_filter_configured": true}, Cards: cards, + }) + }) + if err != nil { + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingManualTriggerByCondition, Summary: "条件筛选触发失败", + ResourceType: constants.AuditResourcePollingManualTrigger, + ResourceKey: pollingManualAttemptKey(taskType, "by_condition", triggeredBy), DisplayName: "条件筛选触发", + OperatorID: triggeredBy, IdentitySnapshot: pollingManualAttemptIdentity(taskType, "by_condition", len(cardIDs), triggeredBy), + Metadata: map[string]any{"condition_filter_configured": true}, Cards: cards, + }, err) return nil, err } @@ -341,14 +456,53 @@ func (s *ManualTriggerService) CancelTrigger(ctx context.Context, logID uint, tr } if log.TriggeredBy != triggeredBy { - return errors.New(errors.CodeForbidden, "无权限取消该任务") + appErr := errors.New(errors.CodeForbidden, "无权限取消该任务") + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingManualCancelled, Summary: "拒绝取消其他账号的手动触发任务", + ResourceType: constants.AuditResourcePollingManualTrigger, ResourceID: log.ID, + ResourceKey: pollingManualTriggerKey(log.ID), DisplayName: "手动轮询任务", + OperatorID: triggeredBy, Result: constants.AuditResultDenied, IdentitySnapshot: pollingManualTriggerIdentity(log), + }, appErr) + return appErr } if log.Status != constants.PollingManualTriggerStatusPending && log.Status != constants.PollingManualTriggerStatusProcessing { - return errors.New(errors.CodeInvalidParam, "任务已完成或已取消") + appErr := errors.New(errors.CodeInvalidParam, "任务已完成或已取消") + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingManualCancelled, Summary: "拒绝取消已结束的手动触发任务", + ResourceType: constants.AuditResourcePollingManualTrigger, ResourceID: log.ID, + ResourceKey: pollingManualTriggerKey(log.ID), DisplayName: "手动轮询任务", + OperatorID: triggeredBy, Result: constants.AuditResultDenied, IdentitySnapshot: pollingManualTriggerIdentity(log), + }, appErr) + return appErr } - return s.logStore.UpdateStatus(ctx, logID, constants.PollingManualTriggerStatusCancelled) + var cardIDs []uint + _ = json.Unmarshal([]byte(log.CardIDs), &cardIDs) + cards, _ := s.iotCardStore.GetByIDs(ctx, cardIDs) + err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error { + if err := s.logStore.WithTx(tx).UpdateStatus(ctx, logID, constants.PollingManualTriggerStatusCancelled); err != nil { + return err + } + before := log.Status + log.Status = constants.PollingManualTriggerStatusCancelled + return writePollingAudit(ctx, tx, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingManualCancelled, Summary: "人工取消轮询任务", + ResourceType: constants.AuditResourcePollingManualTrigger, ResourceID: log.ID, + ResourceKey: pollingManualTriggerKey(log.ID), DisplayName: "手动轮询任务", + OperatorID: triggeredBy, IdentitySnapshot: pollingManualTriggerIdentity(log), + BeforeData: map[string]any{"status": before}, AfterData: map[string]any{"status": log.Status}, Cards: cards, + }) + }) + if err != nil { + recordPollingFailure(ctx, s.db, s.auditWriter, auditinfra.PollingInput{ + ActionCode: constants.AuditActionPollingManualCancelled, Summary: "取消手动触发任务失败", + ResourceType: constants.AuditResourcePollingManualTrigger, ResourceID: log.ID, + ResourceKey: pollingManualTriggerKey(log.ID), DisplayName: "手动轮询任务", + OperatorID: triggeredBy, IdentitySnapshot: pollingManualTriggerIdentity(log), Cards: cards, + }, err) + } + return err } // GetRunningTasks 获取正在运行的任务 diff --git a/internal/service/recharge_order/service.go b/internal/service/recharge_order/service.go index 613e07f..111b50c 100644 --- a/internal/service/recharge_order/service.go +++ b/internal/service/recharge_order/service.go @@ -2,8 +2,10 @@ package recharge_order import ( "context" + "strconv" "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/store/postgres" "github.com/break/junhong_cmp_fiber/internal/task" @@ -28,6 +30,12 @@ type Service struct { commissionRecordStore *postgres.CommissionRecordStore queueClient *queue.Client logger *zap.Logger + auditWriter *audit.Writer +} + +// SetPaymentAudit 注入充值支付统一审计 Writer。 +func (s *Service) SetPaymentAudit(writer *audit.Writer) { + s.auditWriter = writer } func New( @@ -157,8 +165,60 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, paymentNo string, p if err := s.triggerOneTimeCommissionIfNeededInTx(ctx, tx, rechargeOrder, rechargeOrder.Amount); err != nil { return err } - - return nil + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "充值支付统一审计接缝未配置") + } + afterPayment := *payment + afterPayment.Status = model.PaymentRecordStatusPaid + afterPayment.ThirdPartyTradeNo = transactionID + afterPayment.PaidAt = &now + paymentResource := audit.PaymentResource(&afterPayment, constants.AuditResourceRelationPrimary, constants.AuditResourceRolePaymentTarget, + map[string]any{"status": payment.Status, "third_party_trade_no": payment.ThirdPartyTradeNo, "paid_at": payment.PaidAt}, + map[string]any{"status": afterPayment.Status, "third_party_trade_no": afterPayment.ThirdPartyTradeNo, "paid_at": afterPayment.PaidAt}) + rechargeID := strconv.FormatUint(uint64(rechargeOrder.ID), 10) + rechargeResource := audit.ResourceInput{ + Type: constants.AuditResourceRechargeOrder, ID: &rechargeID, Key: rechargeOrder.RechargeOrderNo, DisplayName: rechargeOrder.RechargeOrderNo, + Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePaymentBusinessOrder, + IdentitySnapshot: map[string]any{ + "id": rechargeOrder.ID, "recharge_order_no": rechargeOrder.RechargeOrderNo, "user_id": rechargeOrder.UserID, + "asset_wallet_id": rechargeOrder.AssetWalletID, "resource_type": rechargeOrder.ResourceType, + "resource_id": rechargeOrder.ResourceID, "amount": rechargeOrder.Amount, "status": model.RechargeOrderStatusPaid, + }, + BeforeData: map[string]any{"status": rechargeOrder.Status}, AfterData: map[string]any{"status": model.RechargeOrderStatusPaid}, + SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "充值支付已到账", + } + transactionIDValue := strconv.FormatUint(uint64(transaction.ID), 10) + transactionResource := audit.ResourceInput{ + Type: constants.AuditResourceAssetWalletTransaction, ID: &transactionIDValue, Key: transactionIDValue, DisplayName: "资产钱包流水 " + transactionIDValue, + Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRolePaymentWalletTransaction, + IdentitySnapshot: map[string]any{ + "id": transaction.ID, "asset_wallet_id": transaction.AssetWalletID, "resource_type": transaction.ResourceType, + "resource_id": transaction.ResourceID, "transaction_type": transaction.TransactionType, + "reference_type": transaction.ReferenceType, "reference_no": transaction.ReferenceNo, "status": transaction.Status, + }, + AfterData: map[string]any{"amount": transaction.Amount, "balance_before": transaction.BalanceBefore, "balance_after": transaction.BalanceAfter}, + } + resources := []audit.ResourceInput{paymentResource, rechargeResource, transactionResource} + references, err := audit.AssetRechargeReferences(ctx, tx, rechargeOrder) + if err != nil { + return err + } + for i := range references { + if references[i].Type != constants.AuditResourceAssetWallet { + continue + } + references[i].Relation = constants.AuditResourceRelationAffected + references[i].BeforeData = map[string]any{"balance": balanceBefore} + references[i].AfterData = map[string]any{"balance": balanceBefore + rechargeOrder.Amount} + references[i].SubjectVisibility = constants.AuditSubjectResult + references[i].SubjectSummary = "充值支付已到账" + } + resources = append(resources, references...) + return s.auditWriter.Append(ctx, tx, audit.AppendInput{ + ActionCode: constants.AuditActionPaymentConfirmed, Summary: "第三方支付确认资产充值已到账", + ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, + CorrelationID: payment.PaymentNo, Resources: resources, + }) }) if err != nil { diff --git a/internal/service/refund/approval_decision.go b/internal/service/refund/approval_decision.go index 52676d1..30d53d7 100644 --- a/internal/service/refund/approval_decision.go +++ b/internal/service/refund/approval_decision.go @@ -2,6 +2,7 @@ package refund import ( "context" + "strconv" "strings" "gorm.io/gorm" @@ -9,6 +10,7 @@ import ( 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/auditcontext" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) @@ -32,8 +34,11 @@ func (s *Service) Handle(ctx context.Context, event approvalapp.TerminalDecision } func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.TerminalDecisionEvent) error { + ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID}) + var refund model.RefundRequest + var order model.Order + changed := false err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var refund model.RefundRequest if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", event.BusinessID).First(&refund).Error; err != nil { if err == gorm.ErrRecordNotFound { return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") @@ -46,15 +51,17 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T if refund.Status != model.RefundStatusPending && refund.Status != model.RefundStatusApproved { return errors.New(errors.CodeInvalidStatus, "退款申请状态不允许审批通过") } - var order model.Order if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", refund.OrderID).First(&order).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款关联订单失败") } + beforeRefund := refundAuditState(&refund) + beforeOrder := map[string]any{"payment_status": order.PaymentStatus} approvedAmount := refund.RequestedRefundAmount if err := validateApprovedRefundAmount(approvedAmount, refund.RequestedRefundAmount, &order); err != nil { return err } if refund.Status == model.RefundStatusPending { + changed = true result := tx.WithContext(ctx).Model(&model.RefundRequest{}). Where("id = ? AND status = ?", refund.ID, model.RefundStatusPending). Updates(map[string]any{ @@ -71,6 +78,7 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T } switch order.PaymentStatus { case model.PaymentStatusPaid: + changed = true result := tx.WithContext(ctx).Model(&model.Order{}). Where("id = ? AND payment_status = ?", order.ID, model.PaymentStatusPaid). Updates(map[string]any{"payment_status": model.PaymentStatusRefunded, "updated_at": event.OccurredAt}) @@ -88,22 +96,32 @@ func (s *Service) applyApprovedDecision(ctx context.Context, event approvalapp.T if err := s.refundWalletPayment(ctx, tx, &refund, &order, approvedAmount, event.SubmitterAccountID); err != nil { return err } - return s.appendCompletedNotification(ctx, tx, &refund) + if err := s.appendCompletedNotification(ctx, tx, &refund); err != nil { + return err + } + if !changed { + return nil + } + return s.appendRefundAudit(ctx, tx, refund.ID, constants.AuditActionRefundApproved, "通过退款审批", + "refund:"+strconv.FormatUint(uint64(refund.ID), 10)+":approved", beforeRefund, beforeOrder, "退款已通过") }) if err != nil { + s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", &refund, &order, err) return err } return s.ensureApprovedPostProcessing(ctx, event.BusinessID) } func (s *Service) applyClosedDecision(ctx context.Context, event approvalapp.TerminalDecisionEvent) error { + ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: event.CorrelationID, ParentEventID: event.EventID}) reason := map[string]string{ constants.ApprovalDecisionRejected: "企业微信审批已拒绝", constants.ApprovalDecisionCancelled: "企业微信审批已撤销", constants.ApprovalDecisionDeleted: "企业微信审批已删除", }[event.Decision] - return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - var refund model.RefundRequest + var refund model.RefundRequest + var order model.Order + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", event.BusinessID).First(&refund).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "锁定退款申请失败") } @@ -116,6 +134,10 @@ func (s *Service) applyClosedDecision(ctx context.Context, event approvalapp.Ter if refund.Status != model.RefundStatusPending { return errors.New(errors.CodeInvalidStatus, "退款申请状态不允许结束审批") } + if err := tx.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败") + } + beforeRefund := refundAuditState(&refund) result := tx.WithContext(ctx).Model(&model.RefundRequest{}). Where("id = ? AND status = ?", refund.ID, model.RefundStatusPending). Updates(map[string]any{ @@ -128,8 +150,13 @@ func (s *Service) applyClosedDecision(ctx context.Context, event approvalapp.Ter if result.RowsAffected != 1 { return errors.New(errors.CodeConflict, "退款申请状态已变化") } - return nil + return s.appendRefundAudit(ctx, tx, refund.ID, constants.AuditActionRefundRejected, "拒绝退款审批", + "refund:"+strconv.FormatUint(uint64(refund.ID), 10)+":rejected", beforeRefund, nil, "退款已拒绝") }) + if err != nil { + s.recordRefundFailure(ctx, constants.AuditActionRefundRejected, "拒绝退款审批失败", &refund, &order, err) + } + return err } func (s *Service) ensureApprovedPostProcessing(ctx context.Context, refundID uint) error { diff --git a/internal/service/refund/audit.go b/internal/service/refund/audit.go new file mode 100644 index 0000000..55fdfb6 --- /dev/null +++ b/internal/service/refund/audit.go @@ -0,0 +1,357 @@ +package refund + +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" +) + +// appendRefundAudit 在调用方事务内追加退款状态及完整关联资源。 +func (s *Service) appendRefundAudit( + ctx context.Context, + tx *gorm.DB, + refundID uint, + actionCode string, + summary string, + eventID string, + beforeRefund map[string]any, + beforeOrder map[string]any, + subjectSummary string, +) error { + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "退款统一审计接缝未配置") + } + var refund model.RefundRequest + if err := tx.WithContext(ctx).First(&refund, refundID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审计快照失败") + } + var order model.Order + if err := tx.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单审计快照失败") + } + + primary := audit.RefundResource(&refund, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleRefundTarget) + primary.BeforeData = beforeRefund + primary.AfterData = refundAuditState(&refund) + primary.SubjectVisibility = constants.AuditSubjectResult + primary.SubjectSummary = subjectSummary + orderResource := audit.OrderResource(&order, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundOrder) + orderResource.BeforeData = beforeOrder + orderResource.AfterData = map[string]any{"payment_status": order.PaymentStatus} + orderResource.SubjectVisibility = constants.AuditSubjectInternalOnly + resources := []audit.ResourceInput{primary, orderResource} + + if refund.ApprovalInstanceID != nil { + var approval model.ApprovalInstance + if err := tx.WithContext(ctx).First(&approval, *refund.ApprovalInstanceID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询退款审批审计快照失败") + } + resource := audit.ApprovalInstanceResource(&approval, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundApproval, nil, nil) + resource.SubjectVisibility = constants.AuditSubjectInternalOnly + resources = append(resources, resource) + } + asset, err := audit.RefundAssetResource(ctx, tx, &order, subjectSummary) + if err != nil { + return err + } + if asset != nil { + resources = append(resources, *asset) + } + if actionCode == constants.AuditActionRefundApproved || actionCode == constants.AuditActionRefundAssetProcessed { + chain, err := refundChainAuditResources(ctx, tx, &refund, &order) + if err != nil { + return err + } + resources = append(resources, chain...) + } + + return s.auditWriter.Append(ctx, tx, audit.AppendInput{ + EventID: eventID, ActionCode: actionCode, Summary: summary, + ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, + CorrelationID: refund.RefundNo, + Metadata: map[string]any{ + "requested_refund_amount": refund.RequestedRefundAmount, + "approved_refund_amount": refund.ApprovedRefundAmount, + }, + Resources: resources, + }) +} + +// refundChainAuditResources 汇总退款已形成的资金、佣金、套餐和通知事实引用。 +func refundChainAuditResources(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order) ([]audit.ResourceInput, error) { + resources, err := refundFinanceAuditResources(ctx, tx, refund, order) + if err != nil { + return nil, err + } + var commissions []model.CommissionRecord + if err := tx.WithContext(ctx).Where("order_id = ?", order.ID).Order("id ASC").Find(&commissions).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款佣金审计快照失败") + } + for i := range commissions { + resource := audit.CommissionRecordResource(&commissions[i], nil, nil) + resource.Relation = constants.AuditResourceRelationReference + resource.SubjectVisibility = constants.AuditSubjectInternalOnly + resources = append(resources, resource) + } + var usages []model.PackageUsage + if err := tx.WithContext(ctx).Where("refund_id = ?", refund.ID).Order("id ASC").Find(&usages).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款套餐权益审计快照失败") + } + for i := range usages { + resource := audit.PackageUsageResource(&usages[i], constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundPackageUsage, nil, nil) + resource.SubjectVisibility = constants.AuditSubjectInternalOnly + resources = append(resources, resource) + } + var notification model.OutboxEvent + notificationEventID := "refund:" + strconv.FormatUint(uint64(refund.ID), 10) + ":completed" + if err := tx.WithContext(ctx).Where("event_id = ?", notificationEventID).First(¬ification).Error; err == nil { + id := strconv.FormatUint(uint64(notification.ID), 10) + resources = append(resources, audit.ResourceInput{ + Type: constants.AuditResourceOutboxEvent, ID: &id, Key: notification.EventID, DisplayName: notification.EventID, + Relation: constants.AuditResourceRelationReference, Role: constants.AuditResourceRoleRefundNotification, + IdentitySnapshot: map[string]any{ + "event_id": notification.EventID, "event_type": notification.EventType, + "aggregate_type": notification.AggregateType, "aggregate_id": notification.AggregateID, + "resource_type": notification.ResourceType, "resource_id": notification.ResourceID, + "business_key": notification.BusinessKey, + }, + SubjectVisibility: constants.AuditSubjectInternalOnly, + }) + } else if err != gorm.ErrRecordNotFound { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款通知审计快照失败") + } + return resources, nil +} + +// refundFinanceAuditResources 关联原扣款与退款流水,但不替代钱包流水权威事实。 +func refundFinanceAuditResources(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, order *model.Order) ([]audit.ResourceInput, error) { + var agentTransactions []model.AgentWalletTransaction + if err := tx.WithContext(ctx).Unscoped(). + Where("(reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?) OR (reference_type = ? AND reference_id = ? AND transaction_type = ? AND status = ?)", + constants.ReferenceTypeOrder, order.ID, constants.AgentTransactionTypeDeduct, constants.TransactionStatusSuccess, + constants.ReferenceTypeRefund, refund.ID, constants.AgentTransactionTypeRefund, constants.TransactionStatusSuccess). + Order("id DESC").Find(&agentTransactions).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款代理钱包流水审计快照失败") + } + resources := make([]audit.ResourceInput, 0, len(agentTransactions)*2+2) + wallets := make(map[uint]struct{}, len(agentTransactions)) + for i := range agentTransactions { + transaction := &agentTransactions[i] + if _, exists := wallets[transaction.AgentWalletID]; !exists { + var wallet model.AgentWallet + if err := tx.WithContext(ctx).First(&wallet, transaction.AgentWalletID).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款代理钱包审计快照失败") + } + resources = append(resources, agentWalletRefundResource(&wallet, transaction)) + wallets[transaction.AgentWalletID] = struct{}{} + } + resources = append(resources, agentWalletRefundTransactionResource(transaction, refund.ID)) + } + + var assetTransactions []model.AssetWalletTransaction + if err := tx.WithContext(ctx).Unscoped(). + Where("(reference_type = ? AND reference_no = ? AND transaction_type = ? AND status = ?) OR (reference_type = ? AND reference_no = ? AND transaction_type = ? AND status = ?)", + constants.ReferenceTypeOrder, order.OrderNo, constants.AssetTransactionTypeDeduct, constants.TransactionStatusSuccess, + constants.ReferenceTypeRefund, refund.RefundNo, constants.AssetTransactionTypeRefund, constants.TransactionStatusSuccess). + Order("id DESC").Find(&assetTransactions).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款资产钱包流水审计快照失败") + } + assetWallets := make(map[uint]struct{}, len(assetTransactions)) + for i := range assetTransactions { + transaction := &assetTransactions[i] + if _, exists := assetWallets[transaction.AssetWalletID]; !exists { + var wallet model.AssetWallet + if err := tx.WithContext(ctx).First(&wallet, transaction.AssetWalletID).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询退款资产钱包审计快照失败") + } + resources = append(resources, assetWalletRefundResource(&wallet, transaction)) + assetWallets[transaction.AssetWalletID] = struct{}{} + } + resources = append(resources, assetWalletRefundTransactionResource(transaction, refund.RefundNo)) + } + return resources, nil +} + +// agentWalletRefundResource 构造代理钱包退款余额变化资源。 +func agentWalletRefundResource(wallet *model.AgentWallet, transaction *model.AgentWalletTransaction) audit.ResourceInput { + id := strconv.FormatUint(uint64(wallet.ID), 10) + return audit.ResourceInput{ + Type: constants.AuditResourceAgentWallet, ID: &id, Key: id, DisplayName: "代理钱包 " + id, + Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleRefundWallet, + 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": transaction.BalanceBefore}, + AfterData: map[string]any{"balance": transaction.BalanceAfter}, SubjectVisibility: constants.AuditSubjectInternalOnly, + } +} + +// agentWalletRefundTransactionResource 区分原扣款流水和退款回充流水。 +func agentWalletRefundTransactionResource(transaction *model.AgentWalletTransaction, refundID uint) audit.ResourceInput { + id := strconv.FormatUint(uint64(transaction.ID), 10) + role := constants.AuditResourceRoleRefundOriginalTransaction + relation := constants.AuditResourceRelationReference + if transaction.ReferenceType != nil && *transaction.ReferenceType == constants.ReferenceTypeRefund && + transaction.ReferenceID != nil && *transaction.ReferenceID == refundID { + role, relation = constants.AuditResourceRoleRefundTransaction, constants.AuditResourceRelationAffected + } + return audit.ResourceInput{ + Type: constants.AuditResourceAgentWalletTransaction, ID: &id, Key: id, DisplayName: "代理钱包流水 " + id, + Relation: relation, Role: role, + 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, + }, + SubjectVisibility: constants.AuditSubjectInternalOnly, + } +} + +// assetWalletRefundResource 构造资产钱包退款余额变化资源。 +func assetWalletRefundResource(wallet *model.AssetWallet, transaction *model.AssetWalletTransaction) audit.ResourceInput { + id := strconv.FormatUint(uint64(wallet.ID), 10) + return audit.ResourceInput{ + Type: constants.AuditResourceAssetWallet, ID: &id, Key: id, DisplayName: "资产钱包 " + id, + Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleRefundWallet, + 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: map[string]any{"balance": transaction.BalanceBefore}, + AfterData: map[string]any{"balance": transaction.BalanceAfter}, SubjectVisibility: constants.AuditSubjectInternalOnly, + } +} + +// assetWalletRefundTransactionResource 区分资产钱包原扣款流水和退款回充流水。 +func assetWalletRefundTransactionResource(transaction *model.AssetWalletTransaction, refundNo string) audit.ResourceInput { + id := strconv.FormatUint(uint64(transaction.ID), 10) + role := constants.AuditResourceRoleRefundOriginalTransaction + relation := constants.AuditResourceRelationReference + if transaction.ReferenceType != nil && *transaction.ReferenceType == constants.ReferenceTypeRefund && + transaction.ReferenceNo != nil && *transaction.ReferenceNo == refundNo { + role, relation = constants.AuditResourceRoleRefundTransaction, constants.AuditResourceRelationAffected + } + return audit.ResourceInput{ + Type: constants.AuditResourceAssetWalletTransaction, ID: &id, Key: id, DisplayName: "资产钱包流水 " + id, + Relation: relation, Role: role, + IdentitySnapshot: map[string]any{ + "id": transaction.ID, "asset_wallet_id": transaction.AssetWalletID, + "resource_type": transaction.ResourceType, "resource_id": transaction.ResourceID, + "transaction_type": transaction.TransactionType, "reference_type": transaction.ReferenceType, + "reference_no": transaction.ReferenceNo, "status": transaction.Status, + }, + AfterData: map[string]any{ + "amount": transaction.Amount, "balance_before": transaction.BalanceBefore, "balance_after": transaction.BalanceAfter, + }, + SubjectVisibility: constants.AuditSubjectInternalOnly, + } +} + +// appendCommissionAudit 将佣金失效、钱包扣减和回扣流水绑定在同一事务。 +func (s *Service) appendCommissionAudit(ctx context.Context, tx *gorm.DB, refund *model.RefundRequest, commission *model.CommissionRecord, wallet *model.AgentWallet, transaction *model.AgentWalletTransaction) error { + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "退款统一审计接缝未配置") + } + primary := audit.RefundResource(refund, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleRefundTarget) + primary.SubjectVisibility = constants.AuditSubjectResult + primary.SubjectSummary = "退款佣金已处理" + commissionResource := audit.CommissionRecordResource(commission, + map[string]any{"status": constants.CommissionStatusReleased}, map[string]any{"status": constants.CommissionStatusInvalid}) + commissionResource.SubjectVisibility = constants.AuditSubjectInternalOnly + transactionResource := agentWalletRefundTransactionResource(transaction, refund.ID) + transactionResource.Relation = constants.AuditResourceRelationAffected + transactionResource.Role = constants.AuditResourceRoleRefundTransaction + resources := []audit.ResourceInput{primary, commissionResource, agentWalletRefundResource(wallet, transaction), transactionResource} + var order model.Order + if err := tx.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询退款佣金关联订单审计快照失败") + } + orderResource := audit.OrderResource(&order, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundOrder) + orderResource.SubjectVisibility = constants.AuditSubjectInternalOnly + resources = append(resources, orderResource) + var shop model.Shop + if err := tx.WithContext(ctx).First(&shop, commission.ShopID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询退款佣金关联店铺审计快照失败") + } + shopResource := audit.ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundCommission) + shopResource.SubjectVisibility = constants.AuditSubjectInternalOnly + resources = append(resources, shopResource) + return s.auditWriter.Append(ctx, tx, audit.AppendInput{ + EventID: "refund:" + strconv.FormatUint(uint64(refund.ID), 10) + ":commission:" + strconv.FormatUint(uint64(commission.ID), 10) + ":invalidated", + ActionCode: constants.AuditActionRefundCommissionInvalidated, Summary: "退款失效佣金", + ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, + CorrelationID: refund.RefundNo, Resources: resources, + }) +} + +// recordRefundFailure 在原业务事务回滚后记录已定位退款的失败或拒绝。 +func (s *Service) recordRefundFailure(ctx context.Context, actionCode, summary string, refund *model.RefundRequest, order *model.Order, businessErr error) { + if businessErr == nil || refund == nil || refund.RefundNo == "" || s.auditWriter == nil || s.db == nil { + return + } + primary := audit.RefundResource(refund, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleRefundTarget) + primary.BeforeData = refundAuditState(refund) + primary.SubjectVisibility = constants.AuditSubjectInternalOnly + resources := []audit.ResourceInput{primary} + if order != nil && (order.ID > 0 || order.OrderNo != "") { + resource := audit.OrderResource(order, constants.AuditResourceRelationReference, constants.AuditResourceRoleRefundOrder) + resource.SubjectVisibility = constants.AuditSubjectInternalOnly + resources = append(resources, resource) + } + s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{ + ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform, + CorrelationID: refund.RefundNo, Resources: resources, + }, businessErr) +} + +// recordCommissionFailure 在单条佣金回扣事务回滚后记录失败事实。 +func (s *Service) recordCommissionFailure(ctx context.Context, refund *model.RefundRequest, commission *model.CommissionRecord, businessErr error) { + if businessErr == nil || refund == nil || commission == nil || s.auditWriter == nil || s.db == nil { + return + } + primary := audit.RefundResource(refund, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleRefundTarget) + primary.SubjectVisibility = constants.AuditSubjectInternalOnly + commissionResource := audit.CommissionRecordResource(commission, map[string]any{"status": commission.Status}, nil) + commissionResource.SubjectVisibility = constants.AuditSubjectInternalOnly + s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{ + ActionCode: constants.AuditActionRefundCommissionInvalidated, Summary: "退款失效佣金失败", + ScopeType: constants.AuditScopePlatform, CorrelationID: refund.RefundNo, + Resources: []audit.ResourceInput{primary, commissionResource}, + }, businessErr) +} + +func refundAuditState(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, + } +} + +// markRefundAssetProcessed 仅在首次完成后处理时同事务写入完成标记和审计事件。 +func (s *Service) markRefundAssetProcessed(ctx context.Context, refundID uint) error { + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.WithContext(ctx).Model(&model.RefundRequest{}). + Where("id = ? AND asset_reset = ?", refundID, false).Update("asset_reset", true) + if result.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新退款资产处理标记失败") + } + if result.RowsAffected == 0 { + return nil + } + return s.appendRefundAudit(ctx, tx, refundID, constants.AuditActionRefundAssetProcessed, "完成退款资产后处理", + "refund:"+strconv.FormatUint(uint64(refundID), 10)+":asset-processed", + map[string]any{"asset_reset": false}, nil, "退款资产处理已完成") + }) +} diff --git a/internal/service/refund/service.go b/internal/service/refund/service.go index 0b80e05..7f633ce 100644 --- a/internal/service/refund/service.go +++ b/internal/service/refund/service.go @@ -7,6 +7,7 @@ import ( "context" "fmt" "math/rand" + "strconv" "strings" "time" @@ -17,11 +18,13 @@ import ( notificationapp "github.com/break/junhong_cmp_fiber/internal/application/notification" refundapprovalapp "github.com/break/junhong_cmp_fiber/internal/application/refundapproval" 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/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/auditcontext" "github.com/break/junhong_cmp_fiber/pkg/config" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" @@ -50,6 +53,7 @@ type Service struct { agentWalletRefundService *walletapp.RefundService refundApprovalCreation *refundapprovalapp.CreationService notificationOutbox *outbox.Repository + auditWriter *audit.Writer logger *zap.Logger } @@ -101,6 +105,11 @@ func (s *Service) SetNotificationOutbox(repository *outbox.Repository) { s.notificationOutbox = repository } +// SetLifecycleAudit 注入退款完整业务链统一审计 Writer。 +func (s *Service) SetLifecycleAudit(writer *audit.Writer) { + s.auditWriter = writer +} + // Create 创建退款申请 // 校验订单存在且已支付,检查是否存在活跃退款申请,生成退款单号并创建记录 func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dto.RefundResponse, error) { @@ -159,9 +168,13 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateRefundRequest) (*dt return nil, errors.New(errors.CodeServiceUnavailable, "退款审批能力未配置") } result, err := s.refundApprovalCreation.Execute(ctx, refundapprovalapp.CreateCommand{ - Refund: refund, SubmitterAccountID: userID, + Refund: refund, Order: order, SubmitterAccountID: userID, }) if err != nil { + failedRefund := *refund + failedRefund.ID = 0 + failedRefund.ApprovalInstanceID = nil + s.recordRefundFailure(ctx, constants.AuditActionRefundCreated, "提交退款申请失败", &failedRefund, order, err) return nil, err } @@ -258,11 +271,16 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe if err != nil { return errors.New(errors.CodeNotFound, "退款申请不存在") } + ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: refund.RefundNo}) if refund.Status != model.RefundStatusPending { - return errors.New(errors.CodeInvalidStatus, "仅待审批状态可审批通过") + businessErr := errors.New(errors.CodeInvalidStatus, "仅待审批状态可审批通过") + s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", refund, nil, businessErr) + return businessErr } if refund.ApprovalInstanceID != nil { - return errors.New(errors.CodeInvalidStatus, "该退款申请由企业微信审批决定,不能人工审批") + businessErr := errors.New(errors.CodeInvalidStatus, "该退款申请由企业微信审批决定,不能人工审批") + s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", refund, nil, businessErr) + return businessErr } now := time.Now() @@ -272,14 +290,19 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe } order, err := s.orderStore.GetByID(ctx, refund.OrderID) if err != nil { - return errors.New(errors.CodeNotFound, "订单不存在") + businessErr := errors.New(errors.CodeNotFound, "订单不存在") + s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", refund, nil, businessErr) + return businessErr } if err := validateApprovedRefundAmount(approvedAmount, refund.RequestedRefundAmount, order); err != nil { + s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", refund, order, err) return err } // 事务内同步更新退款状态、订单支付状态和钱包回款,避免订单已退款但资金未退回。 - if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + beforeRefund := refundAuditState(refund) + beforeOrder := map[string]any{"payment_status": order.PaymentStatus} + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { result := tx.Model(&model.RefundRequest{}). Where("id = ? AND status = ?", id, model.RefundStatusPending). Updates(map[string]any{ @@ -314,19 +337,32 @@ func (s *Service) Approve(ctx context.Context, id uint, req *dto.ApproveRefundRe if err := s.refundWalletPayment(ctx, tx, refund, order, approvedAmount, userID); err != nil { return err } - return s.appendCompletedNotification(ctx, tx, refund) - }); err != nil { + if err := s.appendCompletedNotification(ctx, tx, refund); err != nil { + return err + } + return s.appendRefundAudit(ctx, tx, refund.ID, constants.AuditActionRefundApproved, "通过退款审批", + "refund:"+strconv.FormatUint(uint64(refund.ID), 10)+":approved", beforeRefund, beforeOrder, "退款已通过") + }) + if err != nil { + s.recordRefundFailure(ctx, constants.AuditActionRefundApproved, "通过退款审批失败", refund, order, err) return err } // 事务提交成功后,异步执行佣金回扣和退款后资产处理(失败不影响审批结果) go func() { - asyncCtx := context.Background() + asyncCtx := auditcontext.With(context.Background(), auditcontext.Context{ + ActorKind: constants.AuditActorSystemTask, ActorID: constants.AuditActorIDRefundCommissionPostProcessing, + ActorName: "退款佣金自动回扣任务", Source: constants.AuditSourceWorker, + CorrelationID: refund.RefundNo, + }) s.deductAllCommission(asyncCtx, id) }() go func() { - asyncCtx := context.Background() + asyncCtx := auditcontext.With(context.Background(), auditcontext.Context{ + ActorKind: constants.AuditActorSystemTask, ActorID: constants.AuditActorIDRefundAssetPostProcessing, + ActorName: "退款资产自动后处理任务", Source: constants.AuditSourceWorker, + }) s.handleRefundAssetProcessing(asyncCtx, id) }() @@ -561,25 +597,40 @@ func (s *Service) Reject(ctx context.Context, id uint, req *dto.RejectRefundRequ return err } + var refund model.RefundRequest + var order model.Order now := time.Now() - result := s.db.WithContext(ctx). - Model(&model.RefundRequest{}). - Where("id = ? AND status = ? AND approval_instance_id IS NULL", id, model.RefundStatusPending). - Updates(map[string]any{ - "status": model.RefundStatusRejected, - "processor_id": userID, - "processed_at": now, - "reject_reason": req.RejectReason, - "updater": userID, - "updated_at": now, - }) - if result.Error != nil { - return errors.Wrap(errors.CodeInternalError, result.Error, "拒绝退款申请失败") + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.WithContext(ctx).Where("id = ?", id).First(&refund).Error; err != nil { + return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试") + } + if err := tx.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败") + } + beforeRefund := refundAuditState(&refund) + result := tx.WithContext(ctx).Model(&model.RefundRequest{}). + Where("id = ? AND status = ? AND approval_instance_id IS NULL", id, model.RefundStatusPending). + Updates(map[string]any{ + "status": model.RefundStatusRejected, + "processor_id": userID, + "processed_at": now, + "reject_reason": req.RejectReason, + "updater": userID, + "updated_at": now, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeInternalError, result.Error, "拒绝退款申请失败") + } + if result.RowsAffected == 0 { + return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试") + } + return s.appendRefundAudit(ctx, tx, id, constants.AuditActionRefundRejected, "拒绝退款审批", + "refund:"+strconv.FormatUint(uint64(id), 10)+":rejected", beforeRefund, nil, "退款已拒绝") + }) + if err != nil { + s.recordRefundFailure(ctx, constants.AuditActionRefundRejected, "拒绝退款审批失败", &refund, &order, err) } - if result.RowsAffected == 0 { - return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试") - } - return nil + return err } func legacyRefundManualEnabled() bool { @@ -598,25 +649,39 @@ func (s *Service) Return(ctx context.Context, id uint, req *dto.ReturnRefundRequ return err } + var refund model.RefundRequest + var order model.Order now := time.Now() - result := s.db.WithContext(ctx). - Model(&model.RefundRequest{}). - Where("id = ? AND status = ? AND approval_instance_id IS NULL", id, model.RefundStatusPending). - Updates(map[string]any{ - "status": model.RefundStatusReturned, - "processor_id": userID, - "processed_at": now, - "remark": req.Remark, - "updater": userID, - "updated_at": now, - }) - if result.Error != nil { - return errors.Wrap(errors.CodeInternalError, result.Error, "退回退款申请失败") + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.WithContext(ctx).Where("id = ?", id).First(&refund).Error; err != nil { + return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试") + } + if err := tx.WithContext(ctx).First(&order, refund.OrderID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询退款关联订单失败") + } + beforeRefund := refundAuditState(&refund) + result := tx.WithContext(ctx).Model(&model.RefundRequest{}). + Where("id = ? AND status = ? AND approval_instance_id IS NULL", id, model.RefundStatusPending). + Updates(map[string]any{ + "status": model.RefundStatusReturned, + "processor_id": userID, + "processed_at": now, + "remark": req.Remark, + "updater": userID, + "updated_at": now, + }) + if result.Error != nil { + return errors.Wrap(errors.CodeInternalError, result.Error, "退回退款申请失败") + } + if result.RowsAffected == 0 { + return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试") + } + return s.appendRefundAudit(ctx, tx, id, constants.AuditActionRefundReturned, "退回退款申请", "", beforeRefund, nil, "退款申请已退回") + }) + if err != nil { + s.recordRefundFailure(ctx, constants.AuditActionRefundReturned, "退回退款申请失败", &refund, &order, err) } - if result.RowsAffected == 0 { - return errors.New(errors.CodeInvalidStatus, "退款申请状态已变更,请刷新后重试") - } - return nil + return err } // ensureRefundProcessor 确保只有平台侧账号可以处理审批类动作。 @@ -640,8 +705,11 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund if err != nil { return errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交") } + ctx = auditcontext.With(ctx, auditcontext.Context{CorrelationID: refund.RefundNo}) if refund.Status != model.RefundStatusReturned { - return errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交") + businessErr := errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交") + s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, businessErr) + return businessErr } requestedRefundAmount := refund.RequestedRefundAmount @@ -652,6 +720,7 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund if req.RefundVoucherKey != nil { normalized, normErr := normalizeRefundVoucherKey(*req.RefundVoucherKey) if normErr != nil { + s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, normErr) return normErr } refundVoucherKey = normalized @@ -659,9 +728,12 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund order, err := s.orderStore.GetByID(ctx, refund.OrderID) if err != nil { - return errors.New(errors.CodeNotFound, "订单不存在") + businessErr := errors.New(errors.CodeNotFound, "订单不存在") + s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, nil, businessErr) + return businessErr } if err := validateRequestedRefundAmountByOrder(requestedRefundAmount, order); err != nil { + s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err) return err } @@ -684,17 +756,23 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund updates["refund_reason"] = *req.RefundReason } - result := s.db.WithContext(ctx). - Model(&model.RefundRequest{}). - Where("id = ? AND status = ?", id, model.RefundStatusReturned). - Updates(updates) - if result.Error != nil { - return errors.Wrap(errors.CodeInternalError, result.Error, "重新提交退款申请失败") + beforeRefund := refundAuditState(refund) + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + result := tx.WithContext(ctx).Model(&model.RefundRequest{}). + Where("id = ? AND status = ?", id, model.RefundStatusReturned). + Updates(updates) + if result.Error != nil { + return errors.Wrap(errors.CodeInternalError, result.Error, "重新提交退款申请失败") + } + if result.RowsAffected == 0 { + return errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交") + } + return s.appendRefundAudit(ctx, tx, id, constants.AuditActionRefundResubmitted, "重新提交退款申请", "", beforeRefund, nil, "退款申请已重新提交") + }) + if err != nil { + s.recordRefundFailure(ctx, constants.AuditActionRefundResubmitted, "重新提交退款申请失败", refund, order, err) } - if result.RowsAffected == 0 { - return errors.New(errors.CodeInvalidStatus, "仅已退回状态可重新提交") - } - return nil + return err } // deductAllCommission 幂等回扣该订单所有已入账佣金。 @@ -708,6 +786,10 @@ func (s *Service) deductAllCommission(ctx context.Context, refundID uint) { logger.Error("佣金回扣:查询退款单失败", zap.Uint("refund_id", refundID), zap.Error(err)) return } + ctx = auditcontext.With(ctx, auditcontext.Context{ + ActorKind: constants.AuditActorSystemTask, ActorID: constants.AuditActorIDRefundCommissionPostProcessing, + ActorName: "退款佣金自动回扣任务", Source: constants.AuditSourceWorker, CorrelationID: refund.RefundNo, + }) if refund.CommissionDeducted { return } @@ -731,6 +813,7 @@ func (s *Service) deductAllCommission(ctx context.Context, refundID uint) { for _, commission := range commissions { if err := s.deductSingleCommission(ctx, &refund, &commission); err != nil { allSucceeded = false + s.recordCommissionFailure(ctx, &refund, &commission, err) logger.Error("佣金回扣:单条佣金扣减失败", zap.Uint("refund_id", refundID), zap.Uint("commission_id", commission.ID), @@ -814,7 +897,8 @@ func (s *Service) deductSingleCommission(ctx context.Context, refund *model.Refu if updated.RowsAffected != 1 { return errors.New(errors.CodeConflict, "退款佣金状态已变化") } - return nil + current.Status = constants.CommissionStatusInvalid + return s.appendCommissionAudit(ctx, tx, refund, ¤t, &wallet, transaction) }) } @@ -832,13 +916,21 @@ func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint if refund.AssetReset { return } + ctx = auditcontext.With(ctx, auditcontext.Context{ + ActorKind: constants.AuditActorSystemTask, ActorID: constants.AuditActorIDRefundAssetPostProcessing, + ActorName: "退款资产自动后处理任务", Source: constants.AuditSourceWorker, CorrelationID: refund.RefundNo, + }) // 查询关联订单 var order model.Order if err := s.db.Where("id = ?", refund.OrderID).First(&order).Error; err != nil { + s.recordRefundFailure(ctx, constants.AuditActionRefundAssetProcessed, "完成退款资产后处理失败", &refund, nil, err) logger.Error("退款资产处理:查询订单失败", zap.Uint("refund_id", refundID), zap.Uint("order_id", refund.OrderID), zap.Error(err)) return } + recordFailure := func(err error) { + s.recordRefundFailure(ctx, constants.AuditActionRefundAssetProcessed, "完成退款资产后处理失败", &refund, &order, err) + } // 确定资产类型和 ID var assetType string @@ -846,6 +938,7 @@ func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint switch order.OrderType { case model.OrderTypeSingleCard: if order.IotCardID == nil { + recordFailure(errors.New(errors.CodeInternalError, "退款单卡订单缺少资产ID")) logger.Error("退款资产处理:单卡订单缺少 iot_card_id", zap.Uint("order_id", order.ID)) return } @@ -853,24 +946,29 @@ func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint assetID = *order.IotCardID case model.OrderTypeDevice: if order.DeviceID == nil { + recordFailure(errors.New(errors.CodeInternalError, "退款设备订单缺少资产ID")) logger.Error("退款资产处理:设备订单缺少 device_id", zap.Uint("order_id", order.ID)) return } assetType = "device" assetID = *order.DeviceID default: + recordFailure(errors.New(errors.CodeInvalidParam, "退款订单资产类型无效")) logger.Error("退款资产处理:未知订单类型", zap.String("order_type", order.OrderType)) return } // 1. 按退款单精准失效套餐(仅处理本次退款订单关联套餐) if s.packageActivationService == nil { + businessErr := errors.New(errors.CodeServiceUnavailable, "退款套餐处理能力未配置") + recordFailure(businessErr) logger.Error("退款资产处理:套餐激活服务未注入", zap.Uint("refund_id", refund.ID), zap.Uint("order_id", order.ID)) return } if err := s.packageActivationService.InvalidatePackagesForRefund(ctx, assetType, assetID, order.ID, refund.ID, refund.RefundNo, refund.PackageUsageID); err != nil { + recordFailure(err) fields := []zap.Field{ zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), @@ -888,6 +986,7 @@ func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint // 2. 尝试按购买顺序接续待生效主套餐 if _, err := s.packageActivationService.ActivateNextPendingMainPackage(ctx, assetType, assetID); err != nil { + recordFailure(err) logger.Error("退款资产处理:接续激活待生效套餐失败", zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), @@ -898,6 +997,7 @@ func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint hasActiveMain, err := s.packageActivationService.HasActiveMainPackage(ctx, assetType, assetID) if err != nil { + recordFailure(err) logger.Error("退款资产处理:查询生效主套餐失败", zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), @@ -908,12 +1008,14 @@ func (s *Service) handleRefundAssetProcessing(ctx context.Context, refundID uint if !hasActiveMain { // 3. 无可用主套餐时才停机;退款不再重置世代或重建钱包。 if !s.stopAsset(ctx, assetType, assetID) { + recordFailure(errors.New(errors.CodeServiceUnavailable, "退款资产停机处理失败")) return } } // 4. 标记退款后资产处理已完成 - if err := s.db.Model(&model.RefundRequest{}).Where("id = ?", refundID).Update("asset_reset", true).Error; err != nil { + if err := s.markRefundAssetProcessed(ctx, refundID); err != nil { + recordFailure(err) logger.Error("退款资产处理:更新处理标记失败", zap.Uint("refund_id", refundID), zap.Error(err)) } } diff --git a/internal/service/shop_commission/audit.go b/internal/service/shop_commission/audit.go new file mode 100644 index 0000000..4754a36 --- /dev/null +++ b/internal/service/shop_commission/audit.go @@ -0,0 +1,148 @@ +package shop_commission + +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) appendWithdrawalRequestAudit(ctx context.Context, tx *gorm.DB, withdrawal *model.CommissionWithdrawalRequest, wallet *model.AgentWallet, transaction *model.AgentWalletTransaction) error { + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "佣金提现统一审计接缝未配置") + } + var saved model.CommissionWithdrawalRequest + if err := tx.WithContext(ctx).First(&saved, withdrawal.ID).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.CommissionWithdrawalResource(&saved, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleWithdrawalTarget, + nil, withdrawalAuditState(&saved)) + primary.SubjectVisibility = constants.AuditSubjectDetail + primary.SubjectSummary = "提现申请已提交" + primary.SubjectData = map[string]any{ + "amount": saved.Amount, "fee": saved.Fee, "actual_amount": saved.ActualAmount, + "withdrawal_method": saved.WithdrawalMethod, "status": saved.Status, + } + 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 + saved.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 + return s.auditWriter.Append(ctx, tx, audit.AppendInput{ + EventID: "commission-withdrawal:" + strconv.FormatUint(uint64(saved.ID), 10) + ":requested", + ActionCode: constants.AuditActionCommissionWithdrawalRequested, Summary: "提交佣金提现申请", + ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, + CorrelationID: saved.WithdrawalNo, + Metadata: map[string]any{"amount": saved.Amount, "fee": saved.Fee, "actual_amount": saved.ActualAmount}, + Resources: []audit.ResourceInput{primary, walletResource, transactionResource, shopResource}, + }) +} + +func (s *Service) recordWithdrawalRequestFailure(ctx context.Context, withdrawal *model.CommissionWithdrawalRequest, wallet *model.AgentWallet, businessErr error) { + if businessErr == nil || withdrawal == nil || withdrawal.WithdrawalNo == "" || s.auditWriter == nil || s.db == nil { + return + } + primary := audit.CommissionWithdrawalResource(withdrawal, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleWithdrawalTarget, nil, 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: constants.AuditActionCommissionWithdrawalRequested, Summary: "提交佣金提现申请失败", + ScopeType: constants.AuditScopePlatform, CorrelationID: withdrawal.WithdrawalNo, Resources: resources, + }, businessErr) +} + +func (s *Service) appendCommissionResolutionAudit(ctx context.Context, tx *gorm.DB, before *model.CommissionRecord, wallet *model.AgentWallet, actionCode, summary string) error { + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "佣金统一审计接缝未配置") + } + var saved model.CommissionRecord + if err := tx.WithContext(ctx).First(&saved, before.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": before.Amount, "status": before.Status, "balance_after": before.BalanceAfter, "remark": before.Remark}, + map[string]any{"amount": saved.Amount, "status": saved.Status, "balance_after": saved.BalanceAfter, "released_at": saved.ReleasedAt, "remark": saved.Remark}) + primary.Relation = constants.AuditResourceRelationPrimary + primary.Role = constants.AuditResourceRoleCommissionRecord + primary.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, orderResource, shopResource} + if wallet != nil { + resource := audit.AgentWalletResource(wallet, constants.AuditResourceRelationAffected, constants.AuditResourceRoleCommissionWallet, + map[string]any{"balance": wallet.Balance, "frozen_balance": wallet.FrozenBalance}, + map[string]any{"balance": wallet.Balance + saved.Amount, "frozen_balance": wallet.FrozenBalance}) + resource.SubjectVisibility = constants.AuditSubjectInternalOnly + resources = append(resources, resource) + } + if order.SeriesID != nil { + var series model.PackageSeries + if err := tx.WithContext(ctx).First(&series, *order.SeriesID).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询佣金修正关联系列审计快照失败") + } + resource := audit.PackageSeriesResource(&series, constants.AuditResourceRelationReference, constants.AuditResourceRoleCommissionSeries, nil, nil) + resource.SubjectVisibility = constants.AuditSubjectInternalOnly + resources = append(resources, resource) + } + suffix := "invalidated" + if actionCode == constants.AuditActionCommissionCredited { + suffix = "credited" + } + return s.auditWriter.Append(ctx, tx, audit.AppendInput{ + EventID: "commission:record:" + strconv.FormatUint(uint64(saved.ID), 10) + ":" + suffix, + ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform, + Result: constants.AuditResultSuccess, CorrelationID: order.OrderNo, + Metadata: map[string]any{"amount": saved.Amount, "status": saved.Status}, Resources: resources, + }) +} + +func (s *Service) recordCommissionResolutionFailure(ctx context.Context, record *model.CommissionRecord, actionCode, summary string, businessErr error) { + if businessErr == nil || record == nil || record.ID == 0 || s.auditWriter == nil || s.db == nil { + return + } + primary := audit.CommissionRecordResource(record, map[string]any{"amount": record.Amount, "status": record.Status}, nil) + primary.Relation = constants.AuditResourceRelationPrimary + primary.Role = constants.AuditResourceRoleCommissionRecord + primary.SubjectVisibility = constants.AuditSubjectInternalOnly + s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{ + ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopePlatform, + Resources: []audit.ResourceInput{primary}, + }, businessErr) +} + +func withdrawalAuditState(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, + } +} diff --git a/internal/service/shop_commission/service.go b/internal/service/shop_commission/service.go index a259431..6dfdb3f 100644 --- a/internal/service/shop_commission/service.go +++ b/internal/service/shop_commission/service.go @@ -7,6 +7,7 @@ import ( "math/rand" "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" @@ -28,9 +29,15 @@ type Service struct { commissionRecordStore *postgres.CommissionRecordStore agentWalletTransactionStore *postgres.AgentWalletTransactionStore db *gorm.DB + auditWriter *audit.Writer logger *zap.Logger } +// SetAuditWriter 注入佣金与提现统一审计 Writer。 +func (s *Service) SetAuditWriter(writer *audit.Writer) { + s.auditWriter = writer +} + // New 创建代理商资金管理服务 func New( shopStore *postgres.ShopStore, @@ -460,7 +467,20 @@ func (s *Service) CreateWithdrawalRequest(ctx context.Context, shopID uint, req } accountInfoJSON, _ := json.Marshal(accountInfo) - var withdrawalRequest *model.CommissionWithdrawalRequest + withdrawalRequest := &model.CommissionWithdrawalRequest{ + WithdrawalNo: withdrawalNo, + ShopID: shopID, + ApplicantID: currentUserID, + Amount: req.Amount, + FeeRate: setting.FeeRate, + Fee: fee, + ActualAmount: actualAmount, + WithdrawalMethod: req.WithdrawalMethod, + AccountInfo: accountInfoJSON, + Status: constants.WithdrawalStatusPending, + } + withdrawalRequest.Creator = currentUserID + withdrawalRequest.Updater = currentUserID err = s.db.Transaction(func(tx *gorm.DB) error { // 使用条件更新防并发 @@ -476,21 +496,6 @@ func (s *Service) CreateWithdrawalRequest(ctx context.Context, shopID uint, req return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突,请稍后重试") } - withdrawalRequest = &model.CommissionWithdrawalRequest{ - WithdrawalNo: withdrawalNo, - ShopID: shopID, - ApplicantID: currentUserID, - Amount: req.Amount, - FeeRate: setting.FeeRate, - Fee: fee, - ActualAmount: actualAmount, - WithdrawalMethod: req.WithdrawalMethod, - AccountInfo: accountInfoJSON, - Status: constants.WithdrawalStatusPending, // 待审核 - } - withdrawalRequest.Creator = currentUserID - withdrawalRequest.Updater = currentUserID - if err := tx.WithContext(ctx).Create(withdrawalRequest).Error; err != nil { return errors.Wrap(errors.CodeInternalError, err, "创建提现申请失败") } @@ -517,9 +522,10 @@ func (s *Service) CreateWithdrawalRequest(ctx context.Context, shopID uint, req return errors.Wrap(errors.CodeInternalError, err, "创建钱包流水失败") } - return nil + return s.appendWithdrawalRequestAudit(ctx, tx, withdrawalRequest, wallet, transaction) }) if err != nil { + s.recordWithdrawalRequestFailure(ctx, withdrawalRequest, wallet, err) return nil, err } @@ -625,7 +631,13 @@ func (s *Service) ResolveCommissionRecord(ctx context.Context, recordID uint, re } if record.Status != constants.CommissionStatusPendingReview { - return errors.New(errors.CodeInvalidParam, "该记录不是待修正状态") + actionCode := constants.AuditActionCommissionInvalidated + if req.Action == "release" { + actionCode = constants.AuditActionCommissionCredited + } + businessErr := errors.New(errors.CodeInvalidParam, "该记录不是待修正状态") + s.recordCommissionResolutionFailure(ctx, record, actionCode, "修正待审佣金失败", businessErr) + return businessErr } now := time.Now() @@ -635,24 +647,37 @@ func (s *Service) ResolveCommissionRecord(ctx context.Context, recordID uint, re } if req.Action == "invalidate" { - return s.commissionRecordStore.UpdateByID(ctx, nil, recordID, map[string]any{ - "status": constants.CommissionStatusInvalid, - "remark": resolveRemark, + err = s.db.Transaction(func(tx *gorm.DB) error { + if err := s.commissionRecordStore.UpdateByID(ctx, tx, recordID, map[string]any{ + "status": constants.CommissionStatusInvalid, + "remark": resolveRemark, + }); err != nil { + return err + } + return s.appendCommissionResolutionAudit(ctx, tx, record, nil, constants.AuditActionCommissionInvalidated, "待审佣金已失效") }) + if err != nil { + s.recordCommissionResolutionFailure(ctx, record, constants.AuditActionCommissionInvalidated, "失效待审佣金失败", err) + } + return err } // release 入账 if req.Amount == nil || *req.Amount <= 0 { - return errors.New(errors.CodeInvalidParam, "入账操作必须指定金额") + businessErr := errors.New(errors.CodeInvalidParam, "入账操作必须指定金额") + s.recordCommissionResolutionFailure(ctx, record, constants.AuditActionCommissionCredited, "待审佣金入账失败", businessErr) + return businessErr } amount := *req.Amount wallet, err := s.agentWalletStore.GetCommissionWallet(ctx, record.ShopID) if err != nil { - return errors.Wrap(errors.CodeNotFound, err, "店铺佣金钱包不存在") + businessErr := errors.Wrap(errors.CodeNotFound, err, "店铺佣金钱包不存在") + s.recordCommissionResolutionFailure(ctx, record, constants.AuditActionCommissionCredited, "待审佣金入账失败", businessErr) + return businessErr } - return s.db.Transaction(func(tx *gorm.DB) error { + err = s.db.Transaction(func(tx *gorm.DB) error { if err := s.commissionRecordStore.UpdateByID(ctx, tx, recordID, map[string]any{ "status": constants.CommissionStatusReleased, "amount": amount, @@ -682,8 +707,12 @@ func (s *Service) ResolveCommissionRecord(ctx context.Context, recordID uint, re return errors.Wrap(errors.CodeDatabaseError, err, "更新入账后余额失败") } - return nil + return s.appendCommissionResolutionAudit(ctx, tx, record, wallet, constants.AuditActionCommissionCredited, "待审佣金已入账") }) + if err != nil { + s.recordCommissionResolutionFailure(ctx, record, constants.AuditActionCommissionCredited, "待审佣金入账失败", err) + } + return err } // generateWithdrawalNo 生成提现单号 diff --git a/internal/service/shop_package_batch_allocation/audit.go b/internal/service/shop_package_batch_allocation/audit.go new file mode 100644 index 0000000..9690bae --- /dev/null +++ b/internal/service/shop_package_batch_allocation/audit.go @@ -0,0 +1,109 @@ +package shop_package_batch_allocation + +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/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +func (s *Service) appendExpiryBaseAudit(ctx context.Context, tx *gorm.DB, allocation *model.ShopPackageAllocation, pkg *model.Package, before, after *string) error { + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "店铺套餐统一审计接缝未配置") + } + resources, err := s.allocationResources(ctx, tx, allocation, pkg, constants.AuditResourceRelationPrimary, + map[string]any{"expiry_base_override": before}, map[string]any{"expiry_base_override": after}) + if err != nil { + return err + } + return s.auditWriter.Append(ctx, tx, audit.AppendInput{ + ActionCode: constants.AuditActionShopPackageExpiryBaseUpdated, + Summary: "更新店铺套餐生效条件", ScopeType: constants.AuditScopeShop, + ScopeID: strconv.FormatUint(uint64(allocation.ShopID), 10), Result: constants.AuditResultSuccess, + Resources: resources, + }) +} + +func (s *Service) appendBatchAllocationAudit(ctx context.Context, tx *gorm.DB, batchKey string, shop *model.Shop, series *model.PackageSeries, allocations []*model.ShopPackageAllocation, packages map[uint]*model.Package, total, skipped int) error { + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "店铺套餐统一审计接缝未配置") + } + rootEventID := "evt_" + uuid.NewString() + children := make([]audit.AppendInput, 0, len(allocations)) + for _, allocation := range allocations { + pkg := packages[allocation.PackageID] + resources, err := s.allocationResources(ctx, tx, allocation, pkg, constants.AuditResourceRelationPrimary, nil, + map[string]any{"cost_price": allocation.CostPrice, "retail_price": allocation.RetailPrice, "expiry_base_override": allocation.ExpiryBaseOverride, "status": allocation.Status}) + if err != nil { + return err + } + children = append(children, audit.AppendInput{ + EventID: "evt_" + uuid.NewString(), ActionCode: constants.AuditActionShopPackageAllocated, + Summary: "分配店铺套餐 " + pkg.PackageCode, ScopeType: constants.AuditScopeShop, + ScopeID: strconv.FormatUint(uint64(shop.ID), 10), Result: constants.AuditResultSuccess, Resources: resources, + }) + } + return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{ + Root: audit.AppendInput{ + EventID: rootEventID, ActionCode: constants.AuditActionShopPackageBatchAllocated, + Summary: "批量分配店铺套餐", ScopeType: constants.AuditScopeShop, + ScopeID: strconv.FormatUint(uint64(shop.ID), 10), Result: constants.AuditResultSuccess, + BatchTotal: total, SuccessCount: len(allocations), FailCount: 0, + Metadata: map[string]any{"skipped_count": skipped}, + Resources: []audit.ResourceInput{ + audit.PackageConfigBatchResource(batchKey, "batch_allocate", shop.ID, series.ID), + audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop), + audit.PackageSeriesResource(series, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageSeries, nil, nil), + }, + }, + Children: children, + }) +} + +func (s *Service) recordExpiryBaseFailure(ctx context.Context, allocation *model.ShopPackageAllocation, pkg *model.Package, before *string, businessErr error) { + if allocation == nil || pkg == nil { + return + } + shop := &model.Shop{Model: gorm.Model{ID: allocation.ShopID}} + s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{ + ActionCode: constants.AuditActionShopPackageExpiryBaseUpdated, Summary: "更新店铺套餐生效条件失败", + ScopeType: constants.AuditScopeShop, ScopeID: strconv.FormatUint(uint64(allocation.ShopID), 10), + Resources: []audit.ResourceInput{ + audit.ShopPackageAllocationResource(allocation, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleShopPackageAllocation, map[string]any{"expiry_base_override": before}, nil), + audit.PackageResource(pkg, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageTarget, nil, nil), + audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop), + }, + }, businessErr) +} + +func (s *Service) recordBatchAllocationFailure(ctx context.Context, batchKey string, shop *model.Shop, seriesID uint, total int, businessErr error) { + if shop == nil { + return + } + s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{ + ActionCode: constants.AuditActionShopPackageBatchAllocated, Summary: "批量分配店铺套餐失败", + ScopeType: constants.AuditScopeShop, ScopeID: strconv.FormatUint(uint64(shop.ID), 10), + BatchTotal: total, FailCount: total, Resources: []audit.ResourceInput{ + audit.PackageConfigBatchResource(batchKey, "batch_allocate", shop.ID, seriesID), + audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop), + }, + }, businessErr) +} + +func (s *Service) allocationResources(ctx context.Context, tx *gorm.DB, allocation *model.ShopPackageAllocation, pkg *model.Package, relation string, beforeData, afterData map[string]any) ([]audit.ResourceInput, error) { + var shop model.Shop + if err := tx.WithContext(ctx).Unscoped().Where("id = ?", allocation.ShopID).First(&shop).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺套餐审计快照失败") + } + return []audit.ResourceInput{ + audit.ShopPackageAllocationResource(allocation, relation, constants.AuditResourceRoleShopPackageAllocation, beforeData, afterData), + audit.PackageResource(pkg, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageTarget, nil, nil), + audit.ShopResource(&shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop), + }, nil +} diff --git a/internal/service/shop_package_batch_allocation/service.go b/internal/service/shop_package_batch_allocation/service.go index d3e21a3..0385df1 100644 --- a/internal/service/shop_package_batch_allocation/service.go +++ b/internal/service/shop_package_batch_allocation/service.go @@ -2,8 +2,10 @@ package shop_package_batch_allocation import ( "context" - "strconv" + "github.com/google/uuid" + + "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" packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" @@ -20,12 +22,7 @@ type Service struct { packageAllocationStore *postgres.ShopPackageAllocationStore seriesAllocationStore *postgres.ShopSeriesAllocationStore shopStore *postgres.ShopStore - auditService AuditLogger -} - -// AuditLogger 敏感配置变更审计能力。 -type AuditLogger interface { - LogOperation(ctx context.Context, log *model.AccountOperationLog) + auditWriter *audit.Writer } func New( @@ -34,7 +31,7 @@ func New( packageAllocationStore *postgres.ShopPackageAllocationStore, seriesAllocationStore *postgres.ShopSeriesAllocationStore, shopStore *postgres.ShopStore, - auditService AuditLogger, + auditWriter *audit.Writer, ) *Service { return &Service{ db: db, @@ -42,12 +39,12 @@ func New( packageAllocationStore: packageAllocationStore, seriesAllocationStore: seriesAllocationStore, shopStore: shopStore, - auditService: auditService, + auditWriter: auditWriter, } } // UpdateExpiryBase 修改单条套餐分配的生效条件覆盖。 -func (s *Service) UpdateExpiryBase(ctx context.Context, id uint, req *dto.UpdateAllocationExpiryBaseRequest) (*dto.ShopPackageAllocationTermsResponse, error) { +func (s *Service) UpdateExpiryBase(ctx context.Context, id uint, req *dto.UpdateAllocationExpiryBaseRequest) (_ *dto.ShopPackageAllocationTermsResponse, retErr error) { override, err := packagepkg.ValidateExpiryBaseOverride(req.ExpiryBaseOverride, req.ExpiryBaseOverrideSet) if err != nil { return nil, err @@ -61,13 +58,23 @@ func (s *Service) UpdateExpiryBase(ctx context.Context, id uint, req *dto.Update return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } before := allocation.ExpiryBaseOverride - if !sameNullableString(before, override) { - allocation.ExpiryBaseOverride = override - allocation.Updater = middleware.GetUserIDFromContext(ctx) - if err := s.packageAllocationStore.Update(ctx, allocation); err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新套餐分配生效条件失败") + beforeAllocation := *allocation + defer func() { + if retErr != nil { + s.recordExpiryBaseFailure(ctx, &beforeAllocation, pkg, before, retErr) + } + }() + if !sameNullableString(before, override) { + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + allocation.ExpiryBaseOverride = override + allocation.Updater = middleware.GetUserIDFromContext(ctx) + if err := postgres.NewShopPackageAllocationStore(tx).Update(ctx, allocation); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐分配生效条件失败") + } + return s.appendExpiryBaseAudit(ctx, tx, allocation, pkg, before, override) + }); err != nil { + return nil, err } - s.logExpiryBaseAudit(ctx, allocation, before, override) } return buildTermsResponse(pkg, allocation), nil } @@ -87,21 +94,7 @@ func sameNullableString(left, right *string) bool { return left == nil && right == nil || left != nil && right != nil && *left == *right } -func (s *Service) logExpiryBaseAudit(ctx context.Context, allocation *model.ShopPackageAllocation, before, after *string) { - if s.auditService == nil { - return - } - s.auditService.LogOperation(ctx, &model.AccountOperationLog{ - OperatorID: middleware.GetUserIDFromContext(ctx), OperatorType: middleware.GetUserTypeFromContext(ctx), - OperatorName: middleware.GetUsernameFromContext(ctx), OperationType: "update_package_expiry_base", - OperationDesc: "修改套餐分配生效条件覆盖: " + strconv.FormatUint(uint64(allocation.ID), 10), - BeforeData: model.JSONB{"allocation_id": allocation.ID, "expiry_base_override": before}, - AfterData: model.JSONB{"allocation_id": allocation.ID, "expiry_base_override": after}, - RequestID: middleware.GetRequestIDFromContext(ctx), IPAddress: middleware.GetIPFromContext(ctx), UserAgent: middleware.GetUserAgentFromContext(ctx), - }) -} - -func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePackagesRequest) (*dto.BatchAllocatePackagesResponse, error) { +func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePackagesRequest) (_ *dto.BatchAllocatePackagesResponse, retErr error) { expiryBaseOverride, err := packagepkg.ValidateExpiryBaseOverride(req.ExpiryBaseOverride, req.ExpiryBaseOverrideSet) if err != nil { return nil, err @@ -125,6 +118,13 @@ func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePacka } return nil, errors.Wrap(errors.CodeInternalError, err, "获取目标店铺失败") } + batchKey := "package-allocation-" + uuid.NewString() + batchTotal := 0 + defer func() { + if retErr != nil { + s.recordBatchAllocationFailure(ctx, batchKey, targetShop, req.SeriesID, batchTotal, retErr) + } + }() if userType == constants.UserTypeAgent { if targetShop.ParentID == nil || *targetShop.ParentID != allocatorShopID { @@ -151,6 +151,12 @@ func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePacka } result := &dto.BatchAllocatePackagesResponse{TotalPackages: len(packages), Allocations: []dto.ShopPackageAllocationTermsResponse{}} + createdAllocations := make([]*model.ShopPackageAllocation, 0, len(packages)) + packageMap := make(map[uint]*model.Package, len(packages)) + for _, pkg := range packages { + packageMap[pkg.ID] = pkg + } + batchTotal = len(packages) err = s.db.Transaction(func(tx *gorm.DB) error { txPkgAllocStore := postgres.NewShopPackageAllocationStore(tx) @@ -182,10 +188,15 @@ func (s *Service) BatchAllocate(ctx context.Context, req *dto.BatchAllocatePacka if err := tx.Create(allocation).Error; err != nil { return errors.Wrap(errors.CodeInternalError, err, "创建套餐分配失败") } + createdAllocations = append(createdAllocations, allocation) result.Allocations = append(result.Allocations, *buildTermsResponse(pkg, allocation)) } - return nil + var series model.PackageSeries + if err := tx.WithContext(ctx).Where("id = ?", req.SeriesID).First(&series).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐系列审计快照失败") + } + return s.appendBatchAllocationAudit(ctx, tx, batchKey, targetShop, &series, createdAllocations, packageMap, batchTotal, result.SkippedCount) }) if err != nil { return nil, err diff --git a/internal/service/shop_package_batch_pricing/audit.go b/internal/service/shop_package_batch_pricing/audit.go new file mode 100644 index 0000000..b585a4b --- /dev/null +++ b/internal/service/shop_package_batch_pricing/audit.go @@ -0,0 +1,98 @@ +package shop_package_batch_pricing + +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/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +type pricingAuditItem struct { + allocation *model.ShopPackageAllocation + history *model.ShopPackageAllocationPriceHistory + oldPrice int64 + result string + errorCode string + reason string +} + +func (s *Service) appendBatchPricingAudit(ctx context.Context, tx *gorm.DB, batchKey string, shop *model.Shop, seriesID uint, items []pricingAuditItem) error { + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "批量套餐调价统一审计接缝未配置") + } + rootEventID := "evt_" + uuid.NewString() + children := make([]audit.AppendInput, 0, len(items)) + successCount, failCount := 0, 0 + for _, item := range items { + var pkg model.Package + if err := tx.WithContext(ctx).Unscoped().Where("id = ?", item.allocation.PackageID).First(&pkg).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询批量调价套餐审计快照失败") + } + resources := []audit.ResourceInput{ + audit.ShopPackageAllocationResource(item.allocation, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleShopPackageAllocation, + map[string]any{"cost_price": item.oldPrice}, pricingAfterData(item)), + audit.PackageResource(&pkg, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageTarget, nil, nil), + audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop), + } + if item.history != nil { + resources = append(resources, audit.ShopPackagePriceHistoryResource(item.history)) + } + if item.result == constants.AuditResultSuccess { + successCount++ + } else { + failCount++ + } + children = append(children, audit.AppendInput{ + EventID: "evt_" + uuid.NewString(), ActionCode: constants.AuditActionShopPackagePricingItemUpdated, + Summary: "更新店铺套餐成本价 " + pkg.PackageCode, ScopeType: constants.AuditScopeShop, + ScopeID: strconv.FormatUint(uint64(shop.ID), 10), Result: item.result, + ErrorCode: item.errorCode, ErrorSummary: item.reason, Resources: resources, + }) + } + result := constants.AuditResultSuccess + if successCount == 0 && failCount > 0 { + result = constants.AuditResultDenied + } else if failCount > 0 { + result = constants.AuditResultPartial + } + return s.auditWriter.AppendBatch(ctx, tx, audit.BatchInput{ + Root: audit.AppendInput{ + EventID: rootEventID, ActionCode: constants.AuditActionShopPackageBatchPricingUpdated, + Summary: "批量更新店铺套餐成本价", ScopeType: constants.AuditScopeShop, + ScopeID: strconv.FormatUint(uint64(shop.ID), 10), Result: result, + BatchTotal: len(items), SuccessCount: successCount, FailCount: failCount, + Resources: []audit.ResourceInput{ + audit.PackageConfigBatchResource(batchKey, "batch_update_pricing", shop.ID, seriesID), + audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop), + }, + }, + Children: children, + }) +} + +func (s *Service) recordBatchPricingFailure(ctx context.Context, batchKey string, shop *model.Shop, seriesID uint, total int, businessErr error) { + if shop == nil { + return + } + s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{ + ActionCode: constants.AuditActionShopPackageBatchPricingUpdated, Summary: "批量更新店铺套餐成本价失败", + ScopeType: constants.AuditScopeShop, ScopeID: strconv.FormatUint(uint64(shop.ID), 10), + BatchTotal: total, FailCount: total, Resources: []audit.ResourceInput{ + audit.PackageConfigBatchResource(batchKey, "batch_update_pricing", shop.ID, seriesID), + audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop), + }, + }, businessErr) +} + +func pricingAfterData(item pricingAuditItem) map[string]any { + if item.result != constants.AuditResultSuccess { + return nil + } + return map[string]any{"cost_price": item.allocation.CostPrice} +} diff --git a/internal/service/shop_package_batch_pricing/service.go b/internal/service/shop_package_batch_pricing/service.go index 7f7dbcd..56ceac8 100644 --- a/internal/service/shop_package_batch_pricing/service.go +++ b/internal/service/shop_package_batch_pricing/service.go @@ -2,8 +2,12 @@ package shop_package_batch_pricing import ( "context" + "strconv" "time" + "github.com/google/uuid" + + "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/postgres" @@ -18,6 +22,7 @@ type Service struct { packageAllocationStore *postgres.ShopPackageAllocationStore priceHistoryStore *postgres.ShopPackageAllocationPriceHistoryStore shopStore *postgres.ShopStore + auditWriter *audit.Writer } func New( @@ -25,16 +30,18 @@ func New( packageAllocationStore *postgres.ShopPackageAllocationStore, priceHistoryStore *postgres.ShopPackageAllocationPriceHistoryStore, shopStore *postgres.ShopStore, + auditWriter *audit.Writer, ) *Service { return &Service{ db: db, packageAllocationStore: packageAllocationStore, priceHistoryStore: priceHistoryStore, shopStore: shopStore, + auditWriter: auditWriter, } } -func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCostPriceRequest) (*dto.BatchUpdateCostPriceResponse, error) { +func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCostPriceRequest) (_ *dto.BatchUpdateCostPriceResponse, retErr error) { currentUserID := middleware.GetUserIDFromContext(ctx) if currentUserID == 0 { return nil, errors.New(errors.CodeUnauthorized, "未授权访问") @@ -46,6 +53,21 @@ func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCo if userType == constants.UserTypeAgent && shopID == 0 { return nil, errors.New(errors.CodeUnauthorized, "当前用户不属于任何店铺") } + targetShop, err := s.shopStore.GetByID(ctx, req.ShopID) + if err != nil { + return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + batchKey := "package-pricing-" + uuid.NewString() + seriesID := uint(0) + if req.SeriesID != nil { + seriesID = *req.SeriesID + } + batchTotal := 0 + defer func() { + if retErr != nil { + s.recordBatchPricingFailure(ctx, batchKey, targetShop, seriesID, batchTotal, retErr) + } + }() filters := map[string]interface{}{ "shop_id": req.ShopID, @@ -64,11 +86,13 @@ func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCo if len(allocations) == 0 { return nil, errors.New(errors.CodeInvalidParam, "没有找到符合条件的分配记录") } + batchTotal = len(allocations) updatedCount := 0 now := time.Now() affectedIDs := make([]uint, 0) skipped := make([]dto.BatchPricingSkipped, 0) + auditItems := make([]pricingAuditItem, 0, len(allocations)) err = s.db.Transaction(func(tx *gorm.DB) error { for _, allocation := range allocations { @@ -84,6 +108,10 @@ func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCo Where("allocator_shop_id = ? AND package_id = ? AND deleted_at IS NULL", allocation.ShopID, allocation.PackageID). Count(&subCount) if subCount > 0 { + auditItems = append(auditItems, pricingAuditItem{ + allocation: allocation, oldPrice: oldPrice, result: constants.AuditResultDenied, + errorCode: strconv.Itoa(errors.CodeForbidden), reason: "存在下级分配记录,请先回收后再修改成本价", + }) skipped = append(skipped, dto.BatchPricingSkipped{ AllocationID: allocation.ID, Reason: "存在下级分配记录,请先回收后再修改成本价", @@ -110,10 +138,14 @@ func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCo } affectedIDs = append(affectedIDs, allocation.ID) + auditItems = append(auditItems, pricingAuditItem{allocation: allocation, history: history, oldPrice: oldPrice, result: constants.AuditResultSuccess}) updatedCount++ } - return nil + if len(auditItems) == 0 { + return nil + } + return s.appendBatchPricingAudit(ctx, tx, batchKey, targetShop, seriesID, auditItems) }) if err != nil { diff --git a/internal/service/shop_series_grant/audit.go b/internal/service/shop_series_grant/audit.go new file mode 100644 index 0000000..373ad58 --- /dev/null +++ b/internal/service/shop_series_grant/audit.go @@ -0,0 +1,93 @@ +package shop_series_grant + +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" +) + +type allocationAuditChange struct { + before map[string]any + after map[string]any +} + +func (s *Service) appendGrantAudit( + ctx context.Context, + tx *gorm.DB, + actionCode, summary string, + allocation *model.ShopSeriesAllocation, + series *model.PackageSeries, + shop *model.Shop, + beforeData, afterData map[string]any, + packageAllocations []*model.ShopPackageAllocation, + priceHistories []*model.ShopPackageAllocationPriceHistory, + packageChanges map[uint]allocationAuditChange, +) error { + if s.auditWriter == nil { + return errors.New(errors.CodeInvalidStatus, "店铺系列授权统一审计接缝未配置") + } + resources := []audit.ResourceInput{ + audit.ShopSeriesAllocationResource(allocation, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleShopSeriesAllocation, beforeData, afterData), + audit.PackageSeriesResource(series, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageSeries, nil, nil), + audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop), + } + for _, packageAllocation := range packageAllocations { + change, ok := packageChanges[packageAllocation.ID] + if !ok { + change.after = map[string]any{ + "cost_price": packageAllocation.CostPrice, "retail_price": packageAllocation.RetailPrice, + "expiry_base_override": packageAllocation.ExpiryBaseOverride, "status": packageAllocation.Status, + } + } + resources = append(resources, audit.ShopPackageAllocationResource( + packageAllocation, constants.AuditResourceRelationAffected, constants.AuditResourceRoleShopPackageAllocation, change.before, change.after, + )) + var pkg model.Package + if err := tx.WithContext(ctx).Unscoped().Where("id = ?", packageAllocation.PackageID).First(&pkg).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询系列授权套餐审计快照失败") + } + resources = append(resources, audit.PackageResource(&pkg, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageTarget, nil, nil)) + } + for _, history := range priceHistories { + resources = append(resources, audit.ShopPackagePriceHistoryResource(history)) + } + return s.auditWriter.Append(ctx, tx, audit.AppendInput{ + ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopeShop, + ScopeID: strconv.FormatUint(uint64(shop.ID), 10), Result: constants.AuditResultSuccess, + Resources: resources, + }) +} + +func (s *Service) recordGrantFailure(ctx context.Context, actionCode, summary string, allocation *model.ShopSeriesAllocation, series *model.PackageSeries, shop *model.Shop, beforeData map[string]any, businessErr error) { + if allocation == nil || series == nil || shop == nil { + return + } + s.auditWriter.RecordFailure(ctx, s.db, audit.AppendInput{ + ActionCode: actionCode, Summary: summary, ScopeType: constants.AuditScopeShop, + ScopeID: strconv.FormatUint(uint64(shop.ID), 10), Resources: []audit.ResourceInput{ + audit.ShopSeriesAllocationResource(allocation, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleShopSeriesAllocation, beforeData, nil), + audit.PackageSeriesResource(series, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageSeries, nil, nil), + audit.ShopResource(shop, constants.AuditResourceRelationReference, constants.AuditResourceRolePackageConfigShop), + }, + }, businessErr) +} + +func grantData(allocation *model.ShopSeriesAllocation) map[string]any { + if allocation == nil { + return nil + } + return map[string]any{ + "one_time_commission_amount": allocation.OneTimeCommissionAmount, + "commission_tiers": allocation.CommissionTiersJSON, + "enable_force_recharge": allocation.EnableForceRecharge, + "force_recharge_amount": allocation.ForceRechargeAmount, + "force_recharge_trigger_type": allocation.ForceRechargeTriggerType, + "status": allocation.Status, + } +} diff --git a/internal/service/shop_series_grant/service.go b/internal/service/shop_series_grant/service.go index 30a61a5..b8bb13a 100644 --- a/internal/service/shop_series_grant/service.go +++ b/internal/service/shop_series_grant/service.go @@ -6,6 +6,7 @@ import ( "context" "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" packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" @@ -29,6 +30,7 @@ type Service struct { packageStore *postgres.PackageStore packageSeriesStore *postgres.PackageSeriesStore logger *zap.Logger + auditWriter *audit.Writer } // initialGrantPackage 保存通过创建前校验的套餐及其请求价格。 @@ -47,6 +49,7 @@ func New( packageStore *postgres.PackageStore, packageSeriesStore *postgres.PackageSeriesStore, logger *zap.Logger, + auditWriter *audit.Writer, ) *Service { return &Service{ db: db, @@ -57,6 +60,7 @@ func New( packageStore: packageStore, packageSeriesStore: packageSeriesStore, logger: logger, + auditWriter: auditWriter, } } @@ -299,7 +303,7 @@ func (s *Service) buildGrantResponse(ctx context.Context, allocation *model.Shop // Create 创建系列授权 // POST /api/admin/shop-series-grants -func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequest) (*dto.ShopSeriesGrantResponse, error) { +func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequest) (_ *dto.ShopSeriesGrantResponse, retErr error) { expiryBaseOverride, err := packagepkg.ValidateExpiryBaseOverride(req.ExpiryBaseOverride, req.ExpiryBaseOverrideSet) if err != nil { return nil, err @@ -324,6 +328,17 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ if err != nil { return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") } + allocation := &model.ShopSeriesAllocation{ + ShopID: req.ShopID, SeriesID: req.SeriesID, Status: constants.StatusEnabled, CommissionTiersJSON: "[]", + } + businessCommitted := false + defer func() { + if retErr != nil && !businessCommitted { + failedAllocation := *allocation + failedAllocation.ID = 0 + s.recordGrantFailure(ctx, constants.AuditActionShopSeriesGrantCreated, "创建店铺套餐系列授权失败", &failedAllocation, series, targetShop, nil, retErr) + } + }() // 2. 检查重复授权 exists, err := s.shopSeriesAllocationStore.ExistsByShopAndSeries(ctx, req.ShopID, req.SeriesID) @@ -354,13 +369,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ } // 4. 参数验证:仅启用一次性佣金的系列才需要配置佣金金额 - allocation := &model.ShopSeriesAllocation{ - ShopID: req.ShopID, - SeriesID: req.SeriesID, - AllocatorShopID: allocatorShopID, - Status: constants.StatusEnabled, - CommissionTiersJSON: "[]", - } + allocation.AllocatorShopID = allocatorShopID allocation.Creator = operatorID allocation.Updater = operatorID @@ -423,6 +432,8 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ } // 6. 事务中创建 ShopSeriesAllocation + N 条 ShopPackageAllocation + createdPackageAllocations := make([]*model.ShopPackageAllocation, 0, len(req.Packages)) + createdPriceHistories := make([]*model.ShopPackageAllocationPriceHistory, 0, len(req.Packages)) err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { var lockedTargetShop model.Shop if lockErr := tx.WithContext(ctx). @@ -476,23 +487,28 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateShopSeriesGrantRequ if err := txPkgStore.Create(ctx, pkgAlloc); err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐分配失败") } - if err := txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{ + history := &model.ShopPackageAllocationPriceHistory{ AllocationID: pkgAlloc.ID, OldCostPrice: 0, NewCostPrice: *item.CostPrice, ChangeReason: "初始授权", ChangedBy: operatorID, EffectiveFrom: time.Now(), - }); err != nil { + } + if err := txHistoryStore.Create(ctx, history); err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建套餐价格历史失败") } + createdPackageAllocations = append(createdPackageAllocations, pkgAlloc) + createdPriceHistories = append(createdPriceHistories, history) } - return nil + return s.appendGrantAudit(ctx, tx, constants.AuditActionShopSeriesGrantCreated, "创建店铺套餐系列授权", allocation, series, targetShop, + nil, grantData(allocation), createdPackageAllocations, createdPriceHistories, nil) }) if err != nil { return nil, err } + businessCommitted = true // 事务提交后构建完整响应(此时 packages 已可查询到) return s.buildGrantResponse(ctx, allocation, series, config) @@ -638,7 +654,7 @@ func (s *Service) List(ctx context.Context, req *dto.ShopSeriesGrantListRequest) // Update 更新系列授权 // PUT /api/admin/shop-series-grants/:id -func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeriesGrantRequest) (*dto.ShopSeriesGrantResponse, error) { +func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeriesGrantRequest) (_ *dto.ShopSeriesGrantResponse, retErr error) { operatorID := middleware.GetUserIDFromContext(ctx) operatorShopID := middleware.GetShopIDFromContext(ctx) @@ -649,16 +665,27 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeries } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询授权记录失败") } + before := *allocation + series, seriesErr := s.packageSeriesStore.GetByID(ctx, allocation.SeriesID) + if seriesErr != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, seriesErr, "查询套餐系列失败") + } + shop, shopErr := s.shopStore.GetByID(ctx, allocation.ShopID) + if shopErr != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, shopErr, "查询授权店铺失败") + } + businessCommitted := false + defer func() { + if retErr != nil && !businessCommitted { + s.recordGrantFailure(ctx, constants.AuditActionShopSeriesGrantUpdated, "更新店铺套餐系列授权失败", &before, series, shop, grantData(&before), retErr) + } + }() // 代理只能修改自己分配出去的授权 if operatorShopID > 0 && allocation.AllocatorShopID != operatorShopID { return nil, errors.New(errors.CodeForbidden, "无权限操作该授权记录") } - series, err := s.packageSeriesStore.GetByID(ctx, allocation.SeriesID) - if err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐系列失败") - } config, err := series.GetOneTimeCommissionConfig() if err != nil || config == nil { return nil, errors.New(errors.CodeInternalError, "获取系列佣金配置失败") @@ -713,16 +740,23 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateShopSeries } allocation.Updater = operatorID - if err := s.shopSeriesAllocationStore.Update(ctx, allocation); err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新授权记录失败") + if err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := postgres.NewShopSeriesAllocationStore(tx).Update(ctx, allocation); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新授权记录失败") + } + return s.appendGrantAudit(ctx, tx, constants.AuditActionShopSeriesGrantUpdated, "更新店铺套餐系列授权", allocation, series, shop, + grantData(&before), grantData(allocation), nil, nil, nil) + }); err != nil { + return nil, err } + businessCommitted = true return s.buildGrantResponse(ctx, allocation, series, config) } // ManagePackages 管理授权套餐(新增/更新/删除) // PUT /api/admin/shop-series-grants/:id/packages -func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGrantPackagesRequest) (*dto.ShopSeriesGrantResponse, error) { +func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGrantPackagesRequest) (_ *dto.ShopSeriesGrantResponse, retErr error) { expiryBaseOverride, err := packagepkg.ValidateExpiryBaseOverride(req.ExpiryBaseOverride, req.ExpiryBaseOverrideSet) if err != nil { return nil, err @@ -737,12 +771,29 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr } return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询授权记录失败") } + series, seriesErr := s.packageSeriesStore.GetByID(ctx, allocation.SeriesID) + if seriesErr != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, seriesErr, "查询套餐系列失败") + } + shop, shopErr := s.shopStore.GetByID(ctx, allocation.ShopID) + if shopErr != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, shopErr, "查询授权店铺失败") + } + businessCommitted := false + defer func() { + if retErr != nil && !businessCommitted { + s.recordGrantFailure(ctx, constants.AuditActionShopSeriesGrantPackagesManaged, "管理店铺系列套餐授权失败", allocation, series, shop, nil, retErr) + } + }() // 代理只能操作自己分配的授权 if operatorShopID > 0 && allocation.AllocatorShopID != operatorShopID { return nil, errors.New(errors.CodeForbidden, "无权限操作该授权记录") } + affectedAllocations := make([]*model.ShopPackageAllocation, 0, len(req.Packages)) + priceHistories := make([]*model.ShopPackageAllocationPriceHistory, 0, len(req.Packages)) + packageChanges := make(map[uint]allocationAuditChange, len(req.Packages)) err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { txPkgStore := postgres.NewShopPackageAllocationStore(tx) txHistoryStore := postgres.NewShopPackageAllocationPriceHistoryStore(tx) @@ -758,6 +809,11 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr if deleteErr := txPkgStore.Delete(ctx, existing.ID); deleteErr != nil { return errors.Wrap(errors.CodeDatabaseError, deleteErr, "删除套餐分配失败") } + affectedAllocations = append(affectedAllocations, existing) + packageChanges[existing.ID] = allocationAuditChange{ + before: map[string]any{"cost_price": existing.CostPrice, "status": existing.Status}, + after: map[string]any{"deleted": true}, + } continue } @@ -789,16 +845,22 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr return errors.Wrap(errors.CodeDatabaseError, updateErr, "更新套餐分配失败") } if oldPrice != costPrice { - if historyErr := txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{ + history := &model.ShopPackageAllocationPriceHistory{ AllocationID: existing.ID, OldCostPrice: oldPrice, NewCostPrice: costPrice, ChangeReason: "手动调价", ChangedBy: operatorID, EffectiveFrom: time.Now(), - }); historyErr != nil { + } + if historyErr := txHistoryStore.Create(ctx, history); historyErr != nil { return errors.Wrap(errors.CodeDatabaseError, historyErr, "创建套餐价格历史失败") } + priceHistories = append(priceHistories, history) + affectedAllocations = append(affectedAllocations, existing) + packageChanges[existing.ID] = allocationAuditChange{ + before: map[string]any{"cost_price": oldPrice}, after: map[string]any{"cost_price": costPrice}, + } } } else { pkg, pkgErr := s.packageStore.GetByID(ctx, item.PackageID) @@ -831,23 +893,35 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr if createErr := txPkgStore.Create(ctx, pkgAlloc); createErr != nil { return errors.Wrap(errors.CodeDatabaseError, createErr, "创建套餐分配失败") } - if historyErr := txHistoryStore.Create(ctx, &model.ShopPackageAllocationPriceHistory{ + history := &model.ShopPackageAllocationPriceHistory{ AllocationID: pkgAlloc.ID, OldCostPrice: 0, NewCostPrice: costPrice, ChangeReason: "新增授权", ChangedBy: operatorID, EffectiveFrom: time.Now(), - }); historyErr != nil { + } + if historyErr := txHistoryStore.Create(ctx, history); historyErr != nil { return errors.Wrap(errors.CodeDatabaseError, historyErr, "创建套餐价格历史失败") } + affectedAllocations = append(affectedAllocations, pkgAlloc) + priceHistories = append(priceHistories, history) + packageChanges[pkgAlloc.ID] = allocationAuditChange{after: map[string]any{ + "cost_price": pkgAlloc.CostPrice, "retail_price": pkgAlloc.RetailPrice, + "expiry_base_override": pkgAlloc.ExpiryBaseOverride, "status": pkgAlloc.Status, + }} } } - return nil + if len(affectedAllocations) == 0 { + return nil + } + return s.appendGrantAudit(ctx, tx, constants.AuditActionShopSeriesGrantPackagesManaged, "管理店铺系列套餐授权", allocation, series, shop, + nil, nil, affectedAllocations, priceHistories, packageChanges) }) if err != nil { return nil, err } + businessCommitted = true // 重新查询最新状态 return s.Get(ctx, id) @@ -855,7 +929,7 @@ func (s *Service) ManagePackages(ctx context.Context, id uint, req *dto.ManageGr // Delete 删除系列授权(软删除) // DELETE /api/admin/shop-series-grants/:id -func (s *Service) Delete(ctx context.Context, id uint) error { +func (s *Service) Delete(ctx context.Context, id uint) (retErr error) { operatorShopID := middleware.GetShopIDFromContext(ctx) allocation, err := s.shopSeriesAllocationStore.GetByID(ctx, id) @@ -865,6 +939,19 @@ func (s *Service) Delete(ctx context.Context, id uint) error { } return errors.Wrap(errors.CodeDatabaseError, err, "查询授权记录失败") } + series, seriesErr := s.packageSeriesStore.GetByID(ctx, allocation.SeriesID) + if seriesErr != nil { + return errors.Wrap(errors.CodeDatabaseError, seriesErr, "查询套餐系列失败") + } + shop, shopErr := s.shopStore.GetByID(ctx, allocation.ShopID) + if shopErr != nil { + return errors.Wrap(errors.CodeDatabaseError, shopErr, "查询授权店铺失败") + } + defer func() { + if retErr != nil { + s.recordGrantFailure(ctx, constants.AuditActionShopSeriesGrantDeleted, "删除店铺套餐系列授权失败", allocation, series, shop, grantData(allocation), retErr) + } + }() // 代理只能删除自己分配的授权 if operatorShopID > 0 && allocation.AllocatorShopID != operatorShopID { @@ -886,7 +973,12 @@ func (s *Service) Delete(ctx context.Context, id uint) error { txPkgStore := postgres.NewShopPackageAllocationStore(tx) pkgAllocations, _ := txPkgStore.GetBySeriesAllocationID(ctx, id) + packageChanges := make(map[uint]allocationAuditChange, len(pkgAllocations)) for _, pa := range pkgAllocations { + packageChanges[pa.ID] = allocationAuditChange{ + before: map[string]any{"cost_price": pa.CostPrice, "retail_price": pa.RetailPrice, "status": pa.Status}, + after: map[string]any{"deleted": true}, + } if delErr := txPkgStore.Delete(ctx, pa.ID); delErr != nil { return errors.Wrap(errors.CodeDatabaseError, delErr, "删除套餐分配失败") } @@ -895,6 +987,7 @@ func (s *Service) Delete(ctx context.Context, id uint) error { if delErr := txSeriesStore.Delete(ctx, id); delErr != nil { return errors.Wrap(errors.CodeDatabaseError, delErr, "删除系列授权失败") } - return nil + return s.appendGrantAudit(ctx, tx, constants.AuditActionShopSeriesGrantDeleted, "删除店铺套餐系列授权", allocation, series, shop, + grantData(allocation), map[string]any{"deleted": true}, pkgAllocations, nil, packageChanges) }) } diff --git a/internal/service/wechat_config/service.go b/internal/service/wechat_config/service.go index f351b89..1597fbe 100644 --- a/internal/service/wechat_config/service.go +++ b/internal/service/wechat_config/service.go @@ -5,6 +5,7 @@ package wechat_config import ( "context" "fmt" + "strconv" "time" "github.com/bytedance/sonic" @@ -12,10 +13,12 @@ import ( "go.uber.org/zap" "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" @@ -24,11 +27,6 @@ import ( // Redis 缓存键 const redisActiveConfigKey = "wechat:config:active" -// AuditServiceInterface 审计日志服务接口 -type AuditServiceInterface interface { - LogOperation(ctx context.Context, log *model.AccountOperationLog) -} - // Service 微信参数配置业务服务 type Service struct { store *postgres.WechatConfigStore @@ -36,7 +34,7 @@ type Service struct { rechargeOrderStore *postgres.RechargeOrderStore agentRechargeStore *postgres.AgentRechargeStore paymentStore *postgres.PaymentStore - auditService AuditServiceInterface + audit systemconfigapp.AuditWriter redis *redis.Client logger *zap.Logger } @@ -48,7 +46,7 @@ func New( rechargeOrderStore *postgres.RechargeOrderStore, agentRechargeStore *postgres.AgentRechargeStore, paymentStore *postgres.PaymentStore, - auditService AuditServiceInterface, + audit systemconfigapp.AuditWriter, rdb *redis.Client, logger *zap.Logger, ) *Service { @@ -58,7 +56,7 @@ func New( rechargeOrderStore: rechargeOrderStore, agentRechargeStore: agentRechargeStore, paymentStore: paymentStore, - auditService: auditService, + audit: audit, redis: rdb, logger: logger, } @@ -69,8 +67,17 @@ func New( func (s *Service) Create(ctx context.Context, req *dto.CreateWechatConfigRequest) (*dto.WechatConfigResponse, error) { // 根据 provider_type 校验必填字段 if err := s.validateProviderFields(req); err != nil { + s.recordAuditFailure(ctx, systemconfigapp.ChangeAudit{ + OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: constants.AuditOperationPaymentConfigCreate, + Description: "拒绝创建非法支付连接配置", ConfigKey: "payment_config.new:" + req.Name, + DisplayName: req.Name, Identity: map[string]any{"name": req.Name, "provider_type": req.ProviderType, "credentials_configured": paymentRequestCredentialsConfigured(req)}, + Result: constants.AuditResultDenied, ErrorCode: strconv.Itoa(errors.CodeInvalidParam), ErrorSummary: "支付连接配置字段校验失败", + }) return nil, err } + if s.audit == nil { + return nil, errors.New(errors.CodeInvalidStatus, "支付配置审计接缝未配置") + } var desc *string if req.Description != "" { @@ -118,28 +125,17 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateWechatConfigRequest } config.Creator = middleware.GetUserIDFromContext(ctx) - if err := s.store.Create(ctx, config); err != nil { + err := s.store.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := s.store.WithTx(tx).Create(ctx, config); err != nil { + return err + } + return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigCreate, "创建支付连接配置", nil, config) + }) + if err != nil { + s.recordPaymentFailure(ctx, constants.AuditOperationPaymentConfigCreate, "创建支付连接配置失败", config, err) return nil, errors.Wrap(errors.CodeInternalError, err, "创建微信支付配置失败") } - // 审计日志 - afterData := model.JSONB{ - "id": config.ID, - "name": config.Name, - "provider_type": config.ProviderType, - } - go s.auditService.LogOperation(ctx, &model.AccountOperationLog{ - OperatorID: middleware.GetUserIDFromContext(ctx), - OperatorType: middleware.GetUserTypeFromContext(ctx), - OperatorName: "", - OperationType: "create", - OperationDesc: fmt.Sprintf("创建微信支付配置:%s", config.Name), - AfterData: afterData, - RequestID: middleware.GetRequestIDFromContext(ctx), - IPAddress: middleware.GetIPFromContext(ctx), - UserAgent: middleware.GetUserAgentFromContext(ctx), - }) - return dto.FromWechatConfigModel(config), nil } @@ -200,6 +196,10 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateWechatConf } return nil, errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败") } + if s.audit == nil { + return nil, errors.New(errors.CodeInvalidStatus, "支付配置审计接缝未配置") + } + before := *config // 合并字段:指针非 nil 时更新,敏感字段空字符串表示保持原值 if req.Name != nil { @@ -256,7 +256,14 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateWechatConf config.Updater = middleware.GetUserIDFromContext(ctx) - if err := s.store.Update(ctx, config); err != nil { + err = s.store.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := s.store.WithTx(tx).Update(ctx, config); err != nil { + return err + } + return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigUpdate, "更新支付连接配置", &before, config) + }) + if err != nil { + s.recordPaymentFailure(ctx, constants.AuditOperationPaymentConfigUpdate, "更新支付连接配置失败", config, err) return nil, errors.Wrap(errors.CodeInternalError, err, "更新微信支付配置失败") } @@ -265,24 +272,6 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateWechatConf s.clearActiveConfigCache(ctx) } - afterData := model.JSONB{ - "id": config.ID, - "name": config.Name, - "provider_type": config.ProviderType, - "is_active": config.IsActive, - } - go s.auditService.LogOperation(ctx, &model.AccountOperationLog{ - OperatorID: middleware.GetUserIDFromContext(ctx), - OperatorType: middleware.GetUserTypeFromContext(ctx), - OperatorName: "", - OperationType: "update", - OperationDesc: fmt.Sprintf("更新微信支付配置:%s", config.Name), - AfterData: afterData, - RequestID: middleware.GetRequestIDFromContext(ctx), - IPAddress: middleware.GetIPFromContext(ctx), - UserAgent: middleware.GetUserAgentFromContext(ctx), - }) - return dto.FromWechatConfigModel(config), nil } @@ -296,9 +285,13 @@ func (s *Service) Delete(ctx context.Context, id uint) error { } return errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败") } + if s.audit == nil { + return errors.New(errors.CodeInvalidStatus, "支付配置审计接缝未配置") + } // 不允许删除正在激活的配置 if config.IsActive { + s.recordPaymentDenied(ctx, constants.AuditOperationPaymentConfigDelete, "拒绝删除生效中的支付连接配置", config, errors.CodeWechatConfigActive) return errors.New(errors.CodeWechatConfigActive) } @@ -314,32 +307,23 @@ func (s *Service) Delete(ctx context.Context, id uint) error { } if pendingOrders > 0 || pendingRecharges > 0 { + s.recordPaymentDenied(ctx, constants.AuditOperationPaymentConfigDelete, "拒绝删除存在在途业务的支付连接配置", config, errors.CodeWechatConfigHasPendingOrders) return errors.New(errors.CodeWechatConfigHasPendingOrders) } - if err := s.store.SoftDelete(ctx, id); err != nil { + err = s.store.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := s.store.WithTx(tx).SoftDelete(ctx, id); err != nil { + return err + } + return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigDelete, "删除支付连接配置", config, nil) + }) + if err != nil { + s.recordPaymentFailure(ctx, constants.AuditOperationPaymentConfigDelete, "删除支付连接配置失败", config, err) return errors.Wrap(errors.CodeInternalError, err, "删除微信支付配置失败") } s.clearActiveConfigCache(ctx) - beforeData := model.JSONB{ - "id": config.ID, - "name": config.Name, - "provider_type": config.ProviderType, - } - go s.auditService.LogOperation(ctx, &model.AccountOperationLog{ - OperatorID: middleware.GetUserIDFromContext(ctx), - OperatorType: middleware.GetUserTypeFromContext(ctx), - OperatorName: "", - OperationType: "delete", - OperationDesc: fmt.Sprintf("删除微信支付配置:%s", config.Name), - BeforeData: beforeData, - RequestID: middleware.GetRequestIDFromContext(ctx), - IPAddress: middleware.GetIPFromContext(ctx), - UserAgent: middleware.GetUserAgentFromContext(ctx), - }) - return nil } @@ -353,19 +337,32 @@ func (s *Service) Activate(ctx context.Context, id uint) (*dto.WechatConfigRespo } return nil, errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败") } - - // 记录旧的激活配置名称 - oldActiveName := "" - oldActive, oldErr := s.store.GetActive(ctx) - if oldErr == nil && oldActive != nil { - oldActiveName = oldActive.Name + if s.audit == nil { + return nil, errors.New(errors.CodeInvalidStatus, "支付配置审计接缝未配置") } + before := *config + + // 保留原激活配置快照,确保自动停用也进入该配置自身时间线。 + oldActive, oldErr := s.store.GetActive(ctx) // 事务内激活 db := s.store.DB() if err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - return s.store.ActivateInTx(ctx, tx, id) + if err := s.store.ActivateInTx(ctx, tx, id); err != nil { + return err + } + after := before + after.IsActive = true + if oldErr == nil && oldActive != nil && oldActive.ID != id { + oldAfter := *oldActive + oldAfter.IsActive = false + if err := s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigDeactivate, "激活其他配置时停用原支付连接配置", oldActive, &oldAfter); err != nil { + return err + } + } + return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigActivate, "激活支付连接配置", &before, &after) }); err != nil { + s.recordPaymentFailure(ctx, constants.AuditOperationPaymentConfigActivate, "激活支付连接配置失败", config, err) return nil, errors.Wrap(errors.CodeInternalError, err, "激活微信支付配置失败") } @@ -374,22 +371,6 @@ func (s *Service) Activate(ctx context.Context, id uint) (*dto.WechatConfigRespo // 重新查询最新状态 config, _ = s.store.GetByID(ctx, id) - desc := fmt.Sprintf("激活微信支付配置:%s", config.Name) - if oldActiveName != "" && oldActiveName != config.Name { - desc = fmt.Sprintf("激活微信支付配置:%s(原激活配置:%s)", config.Name, oldActiveName) - } - go s.auditService.LogOperation(ctx, &model.AccountOperationLog{ - OperatorID: middleware.GetUserIDFromContext(ctx), - OperatorType: middleware.GetUserTypeFromContext(ctx), - OperatorName: "", - OperationType: "activate", - OperationDesc: desc, - AfterData: model.JSONB{"id": config.ID, "name": config.Name, "is_active": true}, - RequestID: middleware.GetRequestIDFromContext(ctx), - IPAddress: middleware.GetIPFromContext(ctx), - UserAgent: middleware.GetUserAgentFromContext(ctx), - }) - return dto.FromWechatConfigModel(config), nil } @@ -403,8 +384,21 @@ func (s *Service) Deactivate(ctx context.Context, id uint) (*dto.WechatConfigRes } return nil, errors.Wrap(errors.CodeInternalError, err, "获取微信支付配置失败") } + if s.audit == nil { + return nil, errors.New(errors.CodeInvalidStatus, "支付配置审计接缝未配置") + } + before := *config + after := before + after.IsActive = false - if err := s.store.Deactivate(ctx, id); err != nil { + err = s.store.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := s.store.WithTx(tx).Deactivate(ctx, id); err != nil { + return err + } + return s.writeAudit(ctx, tx, constants.AuditOperationPaymentConfigDeactivate, "停用支付连接配置", &before, &after) + }) + if err != nil { + s.recordPaymentFailure(ctx, constants.AuditOperationPaymentConfigDeactivate, "停用支付连接配置失败", config, err) return nil, errors.Wrap(errors.CodeInternalError, err, "停用微信支付配置失败") } @@ -413,21 +407,109 @@ func (s *Service) Deactivate(ctx context.Context, id uint) (*dto.WechatConfigRes // 重新查询最新状态 config, _ = s.store.GetByID(ctx, id) - go s.auditService.LogOperation(ctx, &model.AccountOperationLog{ - OperatorID: middleware.GetUserIDFromContext(ctx), - OperatorType: middleware.GetUserTypeFromContext(ctx), - OperatorName: "", - OperationType: "deactivate", - OperationDesc: fmt.Sprintf("停用微信支付配置:%s", config.Name), - AfterData: model.JSONB{"id": config.ID, "name": config.Name, "is_active": false}, - RequestID: middleware.GetRequestIDFromContext(ctx), - IPAddress: middleware.GetIPFromContext(ctx), - UserAgent: middleware.GetUserAgentFromContext(ctx), - }) - return dto.FromWechatConfigModel(config), nil } +func (s *Service) writeAudit(ctx context.Context, tx *gorm.DB, operation, description string, before, after *model.WechatConfig) error { + config := after + if config == nil { + config = before + } + resourceID := strconv.FormatUint(uint64(config.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: "payment_config." + resourceID, Module: "payment", ResourceID: &resourceID, + DisplayName: config.Name, Identity: paymentConfigIdentity(config), + BeforeData: paymentConfigAuditSnapshot(before), AfterData: paymentConfigAuditSnapshot(after), + RequestID: requestID, CorrelationID: requestID, + }) +} + +func (s *Service) recordPaymentDenied(ctx context.Context, operation, description string, config *model.WechatConfig, code int) { + s.recordAuditFailure(ctx, paymentFailureAudit(ctx, operation, description, config, constants.AuditResultDenied, code)) +} + +func (s *Service) recordPaymentFailure(ctx context.Context, operation, description string, config *model.WechatConfig, _ error) { + s.recordAuditFailure(ctx, paymentFailureAudit(ctx, operation, description, config, constants.AuditResultFailed, errors.CodeDatabaseError)) +} + +func (s *Service) recordAuditFailure(ctx context.Context, audit systemconfigapp.ChangeAudit) { + if s.audit == nil || s.store == nil || s.store.DB() == nil || audit.OperatorID == 0 || audit.ConfigKey == "" { + return + } + if value := middleware.GetRequestIDFromContext(ctx); value != nil { + audit.RequestID = *value + audit.CorrelationID = *value + } + if err := s.store.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + return s.audit.WriteConfigChange(ctx, tx, audit) + }); err != nil { + auditfailure.RecordSecondaryWriteFailure(audit.OperationType, audit.ConfigKey, audit.RequestID, audit.CorrelationID, audit.ErrorCode, err) + } +} + +func paymentFailureAudit(ctx context.Context, operation, description string, config *model.WechatConfig, result string, code int) systemconfigapp.ChangeAudit { + resourceID := strconv.FormatUint(uint64(config.ID), 10) + return systemconfigapp.ChangeAudit{ + OperatorID: middleware.GetUserIDFromContext(ctx), OperationType: operation, Description: description, + ConfigKey: "payment_config." + resourceID, Module: "payment", ResourceID: &resourceID, + DisplayName: config.Name, Identity: paymentConfigIdentity(config), BeforeData: paymentConfigAuditSnapshot(config), + Result: result, ErrorCode: strconv.Itoa(code), ErrorSummary: description, + } +} + +func paymentConfigIdentity(config *model.WechatConfig) map[string]any { + if config == nil { + return nil + } + return map[string]any{ + "id": config.ID, "name": config.Name, "provider_type": config.ProviderType, + "is_active": config.IsActive, "credentials_configured": paymentConfigCredentialsConfigured(config), + } +} + +func paymentConfigAuditSnapshot(config *model.WechatConfig) map[string]any { + if config == nil { + return nil + } + return map[string]any{ + "id": config.ID, "name": config.Name, "description": config.Description, + "provider_type": config.ProviderType, "is_active": config.IsActive, + "oa_app_id": config.OaAppID, "oa_oauth_redirect_url": config.OaOAuthRedirectURL, + "miniapp_app_id": config.MiniappAppID, "wx_mch_id": config.WxMchID, + "wx_serial_no": config.WxSerialNo, "wx_notify_url": config.WxNotifyURL, + "fy_ins_cd": config.FyInsCd, "fy_mchnt_cd": config.FyMchntCd, "fy_term_id": config.FyTermID, + "fy_api_url": config.FyAPIURL, "fy_notify_url": config.FyNotifyURL, + "ali_app_id": config.AliAppID, "ali_notify_url": config.AliNotifyURL, + "ali_return_url": config.AliReturnURL, "ali_production": config.AliProduction, + "ali_pay_expire_minutes": config.AliPayExpireMinutes, + "credentials_configured": paymentConfigCredentialsConfigured(config), + "oauth_configured": config.OaAppSecret != "" || config.OaToken != "" || config.OaAesKey != "" || config.MiniappAppSecret != "", + "wechat_payment_configured": config.WxAPIV3Key != "" || config.WxAPIV2Key != "" || config.WxCertContent != "" || config.WxKeyContent != "", + "fuiou_configured": config.FyPrivateKey != "" || config.FyPublicKey != "", + "alipay_configured": config.AliPrivateKey != "" || config.AliPublicKey != "", + } +} + +func paymentConfigCredentialsConfigured(config *model.WechatConfig) bool { + if config == nil { + return false + } + return config.OaAppSecret != "" || config.OaToken != "" || config.OaAesKey != "" || config.MiniappAppSecret != "" || + config.WxAPIV3Key != "" || config.WxAPIV2Key != "" || config.WxCertContent != "" || config.WxKeyContent != "" || + config.FyPrivateKey != "" || config.FyPublicKey != "" || config.AliPrivateKey != "" || config.AliPublicKey != "" +} + +func paymentRequestCredentialsConfigured(request *dto.CreateWechatConfigRequest) bool { + return request != nil && (request.OaAppSecret != "" || request.OaToken != "" || request.OaAesKey != "" || request.MiniappAppSecret != "" || + request.WxAPIV3Key != "" || request.WxAPIV2Key != "" || request.WxCertContent != "" || request.WxKeyContent != "" || + request.FyPrivateKey != "" || request.FyPublicKey != "" || request.AliPrivateKey != "" || request.AliPublicKey != "") +} + // GetActiveConfig 获取当前生效的支付配置(带 Redis 缓存) // 缓存策略:命中直接返回,未命中查 DB 后缓存 5 分钟,无记录缓存 "none" 1 分钟 func (s *Service) GetActiveConfig(ctx context.Context) (*model.WechatConfig, error) { diff --git a/internal/store/postgres/asset_package_batch_order_task_store.go b/internal/store/postgres/asset_package_batch_order_task_store.go index d8f8dcf..272bf33 100644 --- a/internal/store/postgres/asset_package_batch_order_task_store.go +++ b/internal/store/postgres/asset_package_batch_order_task_store.go @@ -24,6 +24,14 @@ func NewAssetPackageBatchOrderTaskStore(db *gorm.DB) *AssetPackageBatchOrderTask return &AssetPackageBatchOrderTaskStore{db: db} } +// DB 返回任务 Store 使用的数据库连接。 +func (s *AssetPackageBatchOrderTaskStore) DB() *gorm.DB { return s.db } + +// WithTx 返回绑定指定事务的任务 Store。 +func (s *AssetPackageBatchOrderTaskStore) WithTx(tx *gorm.DB) *AssetPackageBatchOrderTaskStore { + return &AssetPackageBatchOrderTaskStore{db: tx} +} + // Create 创建批量订购任务。 func (s *AssetPackageBatchOrderTaskStore) Create(ctx context.Context, task *model.AssetPackageBatchOrderTask) error { return s.db.WithContext(ctx).Create(task).Error diff --git a/internal/store/postgres/carrier_store.go b/internal/store/postgres/carrier_store.go index 27544fa..3c803bb 100644 --- a/internal/store/postgres/carrier_store.go +++ b/internal/store/postgres/carrier_store.go @@ -17,6 +17,16 @@ func NewCarrierStore(db *gorm.DB) *CarrierStore { return &CarrierStore{db: db} } +// WithTx 返回复用当前事务的运营商 Store。 +func (s *CarrierStore) WithTx(tx *gorm.DB) *CarrierStore { + return &CarrierStore{db: tx} +} + +// DB 返回底层数据库连接,用于业务事实与审计同事务提交。 +func (s *CarrierStore) DB() *gorm.DB { + return s.db +} + func (s *CarrierStore) Create(ctx context.Context, carrier *model.Carrier) error { return s.db.WithContext(ctx).Create(carrier).Error } diff --git a/internal/store/postgres/export_task_store.go b/internal/store/postgres/export_task_store.go index 17cf15c..52e9d66 100644 --- a/internal/store/postgres/export_task_store.go +++ b/internal/store/postgres/export_task_store.go @@ -28,6 +28,11 @@ func NewExportTaskStore(db *gorm.DB, redis *redis.Client) *ExportTaskStore { return &ExportTaskStore{db: db, redis: redis} } +// WithTx 返回绑定指定事务的导出任务 Store。 +func (s *ExportTaskStore) WithTx(tx *gorm.DB) *ExportTaskStore { + return &ExportTaskStore{db: tx, redis: s.redis} +} + // Create 创建导出主任务。 func (s *ExportTaskStore) Create(ctx context.Context, task *model.ExportTask) error { return s.db.WithContext(ctx).Create(task).Error @@ -208,7 +213,7 @@ func (s *ExportTaskStore) SetCancelRequested(ctx context.Context, taskID uint, u now := time.Now() result := s.db.WithContext(ctx). Model(&model.ExportTask{}). - Where("id = ? AND status = ?", taskID, constants.ExportTaskStatusProcessing). + Where("id = ? AND status = ? AND cancel_requested = ?", taskID, constants.ExportTaskStatusProcessing, false). Updates(map[string]any{ "cancel_requested": true, "updated_at": now, diff --git a/internal/store/postgres/order_package_invalidate_task_store.go b/internal/store/postgres/order_package_invalidate_task_store.go index 9cd4ac7..29e69dd 100644 --- a/internal/store/postgres/order_package_invalidate_task_store.go +++ b/internal/store/postgres/order_package_invalidate_task_store.go @@ -22,6 +22,22 @@ func NewOrderPackageInvalidateTaskStore(db *gorm.DB) *OrderPackageInvalidateTask return &OrderPackageInvalidateTaskStore{db: db} } +// DB 返回任务 Store 使用的数据库连接。 +func (s *OrderPackageInvalidateTaskStore) DB() *gorm.DB { return s.db } + +// WithTx 返回绑定指定事务的任务 Store。 +func (s *OrderPackageInvalidateTaskStore) WithTx(tx *gorm.DB) *OrderPackageInvalidateTaskStore { + return &OrderPackageInvalidateTaskStore{db: tx} +} + +// Claim 原子领取待处理任务,避免并发重复消费。 +func (s *OrderPackageInvalidateTaskStore) Claim(ctx context.Context, id uint) (bool, error) { + result := s.db.WithContext(ctx).Model(&model.OrderPackageInvalidateTask{}). + Where("id = ? AND status = ?", id, model.ImportTaskStatusPending). + Updates(map[string]any{"status": model.ImportTaskStatusProcessing, "started_at": time.Now(), "updated_at": time.Now()}) + return result.RowsAffected == 1, result.Error +} + // Create 创建任务 func (s *OrderPackageInvalidateTaskStore) Create(ctx context.Context, task *model.OrderPackageInvalidateTask) error { return s.db.WithContext(ctx).Create(task).Error diff --git a/internal/store/postgres/polling_alert_store.go b/internal/store/postgres/polling_alert_store.go index 99b617a..3bae69e 100644 --- a/internal/store/postgres/polling_alert_store.go +++ b/internal/store/postgres/polling_alert_store.go @@ -18,6 +18,11 @@ func NewPollingAlertRuleStore(db *gorm.DB) *PollingAlertRuleStore { return &PollingAlertRuleStore{db: db} } +// WithTx 返回绑定指定事务的轮询告警规则存储。 +func (s *PollingAlertRuleStore) WithTx(tx *gorm.DB) *PollingAlertRuleStore { + return &PollingAlertRuleStore{db: tx} +} + // Create 创建告警规则 func (s *PollingAlertRuleStore) Create(ctx context.Context, rule *model.PollingAlertRule) error { return s.db.WithContext(ctx).Create(rule).Error diff --git a/internal/store/postgres/polling_concurrency_config_store.go b/internal/store/postgres/polling_concurrency_config_store.go index 4ab93c6..f107128 100644 --- a/internal/store/postgres/polling_concurrency_config_store.go +++ b/internal/store/postgres/polling_concurrency_config_store.go @@ -18,6 +18,11 @@ func NewPollingConcurrencyConfigStore(db *gorm.DB) *PollingConcurrencyConfigStor return &PollingConcurrencyConfigStore{db: db} } +// WithTx 返回绑定指定事务的轮询并发配置存储。 +func (s *PollingConcurrencyConfigStore) WithTx(tx *gorm.DB) *PollingConcurrencyConfigStore { + return &PollingConcurrencyConfigStore{db: tx} +} + // List 获取所有并发控制配置 func (s *PollingConcurrencyConfigStore) List(ctx context.Context) ([]*model.PollingConcurrencyConfig, error) { var configs []*model.PollingConcurrencyConfig diff --git a/internal/store/postgres/polling_config_store.go b/internal/store/postgres/polling_config_store.go index e3de86f..9d8a26f 100644 --- a/internal/store/postgres/polling_config_store.go +++ b/internal/store/postgres/polling_config_store.go @@ -19,6 +19,11 @@ func NewPollingConfigStore(db *gorm.DB) *PollingConfigStore { return &PollingConfigStore{db: db} } +// WithTx 返回绑定指定事务的轮询配置存储。 +func (s *PollingConfigStore) WithTx(tx *gorm.DB) *PollingConfigStore { + return &PollingConfigStore{db: tx} +} + // Create 创建轮询配置 func (s *PollingConfigStore) Create(ctx context.Context, config *model.PollingConfig) error { return s.db.WithContext(ctx).Create(config).Error diff --git a/internal/store/postgres/polling_manual_trigger_store.go b/internal/store/postgres/polling_manual_trigger_store.go index 771b8b4..91fb86d 100644 --- a/internal/store/postgres/polling_manual_trigger_store.go +++ b/internal/store/postgres/polling_manual_trigger_store.go @@ -19,6 +19,11 @@ func NewPollingManualTriggerLogStore(db *gorm.DB) *PollingManualTriggerLogStore return &PollingManualTriggerLogStore{db: db} } +// WithTx 返回绑定指定事务的手动轮询日志存储。 +func (s *PollingManualTriggerLogStore) WithTx(tx *gorm.DB) *PollingManualTriggerLogStore { + return &PollingManualTriggerLogStore{db: tx} +} + // Create 创建手动触发日志 func (s *PollingManualTriggerLogStore) Create(ctx context.Context, log *model.PollingManualTriggerLog) error { return s.db.WithContext(ctx).Create(log).Error diff --git a/internal/store/postgres/wechat_config_store.go b/internal/store/postgres/wechat_config_store.go index b23427b..61837b7 100644 --- a/internal/store/postgres/wechat_config_store.go +++ b/internal/store/postgres/wechat_config_store.go @@ -21,6 +21,11 @@ func NewWechatConfigStore(db *gorm.DB, rdb *redis.Client) *WechatConfigStore { return &WechatConfigStore{db: db, rdb: rdb} } +// WithTx 返回复用当前事务的支付配置 Store。 +func (s *WechatConfigStore) WithTx(tx *gorm.DB) *WechatConfigStore { + return &WechatConfigStore{db: tx, rdb: s.rdb} +} + // Create 创建微信参数配置 func (s *WechatConfigStore) Create(ctx context.Context, config *model.WechatConfig) error { return s.db.WithContext(ctx).Create(config).Error diff --git a/internal/task/asset_package_batch_order.go b/internal/task/asset_package_batch_order.go index 62dffd9..9f13102 100644 --- a/internal/task/asset_package_batch_order.go +++ b/internal/task/asset_package_batch_order.go @@ -14,10 +14,14 @@ import ( "github.com/bytedance/sonic" "github.com/hibiken/asynq" "go.uber.org/zap" + "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/postgres" + "github.com/break/junhong_cmp_fiber/pkg/asynctask" + "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" @@ -41,11 +45,16 @@ type AssetPackageBatchOrderHandler struct { orderCreator AssetPackageBatchOrderCreator storageService *storage.Service logger *zap.Logger + auditWriter *audit.Writer } // NewAssetPackageBatchOrderHandler 创建资产套餐批量订购任务处理器。 -func NewAssetPackageBatchOrderHandler(taskStore *postgres.AssetPackageBatchOrderTaskStore, shopStore *postgres.ShopStore, orderCreator AssetPackageBatchOrderCreator, storageService *storage.Service, logger *zap.Logger) *AssetPackageBatchOrderHandler { - return &AssetPackageBatchOrderHandler{taskStore: taskStore, shopStore: shopStore, orderCreator: orderCreator, storageService: storageService, logger: logger} +func NewAssetPackageBatchOrderHandler(taskStore *postgres.AssetPackageBatchOrderTaskStore, shopStore *postgres.ShopStore, orderCreator AssetPackageBatchOrderCreator, storageService *storage.Service, logger *zap.Logger, auditWriters ...*audit.Writer) *AssetPackageBatchOrderHandler { + handler := &AssetPackageBatchOrderHandler{taskStore: taskStore, shopStore: shopStore, orderCreator: orderCreator, storageService: storageService, logger: logger} + if len(auditWriters) > 0 { + handler.auditWriter = auditWriters[0] + } + return handler } // Handle 处理资产套餐批量订购任务。 @@ -60,6 +69,12 @@ func (h *AssetPackageBatchOrderHandler) Handle(ctx context.Context, taskMessage h.logger.Error("查询资产套餐批量订购任务失败", zap.Uint("task_id", payload.TaskID), zap.Error(err)) return asynq.SkipRetry } + rootEventID := audit.TaskEventID(constants.AuditResourceAssetPackageBatchOrderTask, taskRecord.ID, "completed") + ctx = auditcontext.With(ctx, auditcontext.Context{ + ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypeAssetPackageBatchOrder, + ActorName: "资产套餐批量订购任务", Source: constants.AuditSourceWorker, + CorrelationID: taskRecord.TaskNo, ParentEventID: rootEventID, + }) claimed, err := h.taskStore.Claim(ctx, taskRecord.ID) if err != nil { return err @@ -71,17 +86,68 @@ func (h *AssetPackageBatchOrderHandler) Handle(ctx context.Context, taskMessage rows, err := h.downloadAndParse(ctx, taskRecord.StorageKey) if err != nil { h.logger.Error("下载或解析批量订购CSV失败", zap.Uint("task_id", taskRecord.ID), zap.Error(err)) - _ = h.taskStore.MarkFailed(ctx, taskRecord.ID, err.Error()) + if finishErr := h.finishBatchOrderTask(ctx, taskRecord, nil, 0, 1, asynctask.StatusFailed, err.Error()); finishErr != nil { + h.resetBatchOrderTaskForRetry(ctx, taskRecord.ID) + return finishErr + } return asynq.SkipRetry } items, successCount, failCount := h.processRows(ctx, taskRecord, rows) - if err := h.taskStore.Complete(ctx, taskRecord.ID, items, successCount, failCount); err != nil { + if err := h.finishBatchOrderTask(ctx, taskRecord, items, successCount, failCount, asynctask.StatusCompleted, ""); err != nil { + h.resetBatchOrderTaskForRetry(ctx, taskRecord.ID) return err } h.logger.Info("资产套餐批量订购任务完成", zap.Uint("task_id", taskRecord.ID), zap.Int("success", successCount), zap.Int("fail", failCount)) return nil } +func (h *AssetPackageBatchOrderHandler) resetBatchOrderTaskForRetry(ctx context.Context, taskID uint) { + _ = h.taskStore.DB().WithContext(ctx).Model(&model.AssetPackageBatchOrderTask{}). + Where("id = ? AND status = ?", taskID, asynctask.StatusProcessing). + Updates(map[string]any{"status": asynctask.StatusPending, "started_at": nil}).Error +} + +func (h *AssetPackageBatchOrderHandler) finishBatchOrderTask(ctx context.Context, taskRecord *model.AssetPackageBatchOrderTask, items model.AssetPackageBatchOrderResultItems, successCount, failCount, status int, errorMessage string) error { + if h.auditWriter == nil { + return apperrors.New(apperrors.CodeInvalidStatus, "资产套餐批量订购统一审计接缝未配置") + } + return h.taskStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + txStore := h.taskStore.WithTx(tx) + if status == asynctask.StatusFailed { + if err := txStore.MarkFailed(ctx, taskRecord.ID, errorMessage); err != nil { + return err + } + } else if err := txStore.Complete(ctx, taskRecord.ID, items, successCount, failCount); err != nil { + return err + } + rootID := audit.TaskEventID(constants.AuditResourceAssetPackageBatchOrderTask, taskRecord.ID, "completed") + var childCount int64 + if err := tx.WithContext(ctx).Model(&model.AuditEvent{}). + Where("parent_event_id = ? AND action_code = ?", rootID, constants.AuditActionOrderCreated). + Count(&childCount).Error; err != nil { + return err + } + result := batchAuditResult(int(childCount), failCount) + return h.auditWriter.WriteTask(ctx, tx, audit.TaskInput{ + EventID: rootID, ActionCode: constants.AuditActionAssetPackageBatchOrderTaskCompleted, + Summary: "完成资产套餐批量订购任务", TaskID: taskRecord.ID, TaskNo: taskRecord.TaskNo, + Result: result, CorrelationID: taskRecord.TaskNo, + ParentEventID: audit.TaskEventID(constants.AuditResourceAssetPackageBatchOrderTask, taskRecord.ID, "created"), + BatchTotal: len(items), SuccessCount: int(childCount), FailCount: failCount, + IdentitySnapshot: map[string]any{ + "id": taskRecord.ID, "task_no": taskRecord.TaskNo, "file_name": taskRecord.FileName, + "package_id": taskRecord.PackageID, "package_code": taskRecord.PackageCode, + "package_name": taskRecord.PackageName, "payment_method": taskRecord.PaymentMethod, + }, + BeforeData: map[string]any{"status": asynctask.StatusProcessing}, + AfterData: map[string]any{ + "status": status, "total_count": len(items), "success_count": successCount, "fail_count": failCount, + }, + Metadata: map[string]any{"task_success_count": successCount, "task_fail_count": failCount}, + }) + }) +} + type assetPackageBatchOrderRow struct { Line int Identifier string @@ -150,6 +216,12 @@ func (h *AssetPackageBatchOrderHandler) processRows(ctx context.Context, taskRec Username: taskRecord.CreatorName, ShopID: taskRecord.CreatorShopID, SubordinateShopIDs: subordinateShopIDs, }) + workerCtx = auditcontext.With(workerCtx, auditcontext.Context{ + ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypeAssetPackageBatchOrder, + ActorName: "资产套餐批量订购任务", Source: constants.AuditSourceWorker, + CorrelationID: taskRecord.TaskNo, + ParentEventID: audit.TaskEventID(constants.AuditResourceAssetPackageBatchOrderTask, taskRecord.ID, "completed"), + }) buyerType, buyerID := "", uint(0) if taskRecord.CreatorUserType == constants.UserTypeAgent { buyerType, buyerID = model.BuyerTypeAgent, taskRecord.CreatorShopID diff --git a/internal/task/auto_purchase.go b/internal/task/auto_purchase.go index eef212d..33c402f 100644 --- a/internal/task/auto_purchase.go +++ b/internal/task/auto_purchase.go @@ -15,10 +15,12 @@ import ( cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "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/service/packageprice" "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" pkgerrors "github.com/break/junhong_cmp_fiber/pkg/errors" ) @@ -44,6 +46,7 @@ type AutoPurchaseHandler struct { asynqClient *asynq.Client // 用于事务提交成功后触发佣金计算任务 logger *zap.Logger observationSeriesEvents cardObservationApp.SeriesEventWriter + auditWriter *audit.Writer } // NewAutoPurchaseHandler 创建充值后自动购包处理器 @@ -59,6 +62,7 @@ func NewAutoPurchaseHandler( asynqClient *asynq.Client, logger *zap.Logger, observationSeriesEvents cardObservationApp.SeriesEventWriter, + auditWriter *audit.Writer, ) *AutoPurchaseHandler { if orderStore == nil { orderStore = postgres.NewOrderStore(db, redisClient) @@ -94,6 +98,7 @@ func NewAutoPurchaseHandler( asynqClient: asynqClient, logger: logger, observationSeriesEvents: observationSeriesEvents, + auditWriter: auditWriter, } } @@ -118,6 +123,11 @@ func (h *AutoPurchaseHandler) ProcessTask(ctx context.Context, task *asynq.Task) h.logger.Error("查询充值订单失败", zap.Uint("recharge_order_id", payload.RechargeOrderID), zap.Error(err)) return err } + ctx = auditcontext.With(ctx, auditcontext.Context{ + ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypeAutoPurchaseAfterRecharge, + ActorName: "充值后自动购包任务", Source: constants.AuditSourceWorker, + CorrelationID: rechargeOrder.RechargeOrderNo, + }) if rechargeOrder.AutoPurchaseStatus == constants.AutoPurchaseStatusSuccess { return nil @@ -276,8 +286,10 @@ func (h *AutoPurchaseHandler) ProcessTask(ctx context.Context, task *asynq.Task) Update("auto_purchase_status", constants.AutoPurchaseStatusSuccess).Error; err != nil { return err } - - return nil + if h.auditWriter == nil { + return pkgerrors.New(pkgerrors.CodeInvalidStatus, "自动购包统一审计接缝未配置") + } + return h.appendAutoPurchaseAudit(ctx, tx, rechargeOrder, order, payment, wallet, walletTx, packages) }); err != nil { h.logger.Error("自动购包任务执行失败", zap.Uint("recharge_record_id", rechargeOrder.ID), @@ -311,6 +323,61 @@ func (h *AutoPurchaseHandler) ProcessTask(ctx context.Context, task *asynq.Task) return nil } +func (h *AutoPurchaseHandler) appendAutoPurchaseAudit(ctx context.Context, tx *gorm.DB, recharge *model.RechargeOrder, order *model.Order, payment *model.Payment, wallet *model.AssetWallet, walletTx *model.AssetWalletTransaction, packages []*model.Package) error { + rechargeID := strconv.FormatUint(uint64(recharge.ID), 10) + resources := []audit.ResourceInput{{ + Type: constants.AuditResourceRechargeOrder, ID: &rechargeID, Key: recharge.RechargeOrderNo, DisplayName: recharge.RechargeOrderNo, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleRechargeTarget, + 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, + }, + BeforeData: map[string]any{"auto_purchase_status": recharge.AutoPurchaseStatus}, + AfterData: map[string]any{"auto_purchase_status": constants.AutoPurchaseStatusSuccess}, + SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "充值后自动购包已完成", + }} + orderResource := audit.OrderResource(order, constants.AuditResourceRelationAffected, constants.AuditResourceRoleRechargeAutoPurchaseOrder) + orderResource.SubjectVisibility = constants.AuditSubjectResult + orderResource.SubjectSummary = "充值后自动购包已完成" + resources = append(resources, orderResource) + walletID := strconv.FormatUint(uint64(wallet.ID), 10) + resources = append(resources, audit.ResourceInput{ + Type: constants.AuditResourceAssetWallet, ID: &walletID, Key: walletID, DisplayName: "资产钱包 " + walletID, + Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleRechargeWallet, + IdentitySnapshot: map[string]any{"id": wallet.ID, "resource_type": wallet.ResourceType, "resource_id": wallet.ResourceID, "currency": wallet.Currency}, + BeforeData: map[string]any{"balance": walletTx.BalanceBefore}, AfterData: map[string]any{"balance": walletTx.BalanceAfter}, + SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "充值后自动购包已完成", + }) + walletTxID := strconv.FormatUint(uint64(walletTx.ID), 10) + resources = append(resources, audit.ResourceInput{ + Type: constants.AuditResourceAssetWalletTransaction, ID: &walletTxID, Key: walletTxID, DisplayName: "资产钱包流水 " + walletTxID, + Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleRechargeWalletTransaction, + IdentitySnapshot: map[string]any{ + "id": walletTx.ID, "asset_wallet_id": walletTx.AssetWalletID, "resource_type": walletTx.ResourceType, + "resource_id": walletTx.ResourceID, "transaction_type": walletTx.TransactionType, + "reference_type": walletTx.ReferenceType, "reference_no": walletTx.ReferenceNo, "status": walletTx.Status, + }, + AfterData: map[string]any{"amount": walletTx.Amount, "balance_before": walletTx.BalanceBefore, "balance_after": walletTx.BalanceAfter}, + }) + resources = append(resources, audit.PaymentResource(payment, constants.AuditResourceRelationReference, constants.AuditResourceRoleOrderPayment, nil, nil)) + for _, pkg := range packages { + resources = append(resources, audit.PackageResource(pkg, constants.AuditResourceRelationReference, constants.AuditResourceRoleOrderPackage, nil, nil)) + } + var usages []model.PackageUsage + if err := tx.WithContext(ctx).Where("order_id = ?", order.ID).Order("id ASC").Find(&usages).Error; err != nil { + return pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "查询自动购包套餐权益审计快照失败") + } + for i := range usages { + resources = append(resources, audit.PackageUsageResource(&usages[i], constants.AuditResourceRelationAffected, constants.AuditResourceRolePackageUsageTarget, nil, nil)) + } + return h.auditWriter.Append(ctx, tx, audit.AppendInput{ + ActionCode: constants.AuditActionAssetRechargeAutoPurchased, Summary: "充值后自动购包已完成", + ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, + CorrelationID: recharge.RechargeOrderNo, Resources: resources, + }) +} + func orderObservationResource(order *model.Order) (string, uint) { if order != nil && order.DeviceID != nil { return constants.CardObservationResourceTypeDevice, *order.DeviceID @@ -348,10 +415,39 @@ func (h *AutoPurchaseHandler) markAutoPurchaseFailedIfFinalRetry(ctx context.Con return } - if err := h.db.WithContext(ctx). - Model(&model.RechargeOrder{}). - Where("id = ?", rechargeOrderID). - Update("auto_purchase_status", constants.AutoPurchaseStatusFailed).Error; err != nil { + if err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + var recharge model.RechargeOrder + if err := tx.WithContext(ctx).First(&recharge, rechargeOrderID).Error; err != nil { + return err + } + result := tx.WithContext(ctx).Model(&model.RechargeOrder{}). + Where("id = ? AND auto_purchase_status <> ?", rechargeOrderID, constants.AutoPurchaseStatusFailed). + Update("auto_purchase_status", constants.AutoPurchaseStatusFailed) + if result.Error != nil || result.RowsAffected == 0 { + return result.Error + } + if h.auditWriter == nil { + return pkgerrors.New(pkgerrors.CodeInvalidStatus, "自动购包统一审计接缝未配置") + } + rechargeID := strconv.FormatUint(uint64(recharge.ID), 10) + return h.auditWriter.Append(ctx, tx, audit.AppendInput{ + ActionCode: constants.AuditActionAssetRechargeAutoPurchased, Summary: "充值后自动购包失败", + ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultFailed, + CorrelationID: recharge.RechargeOrderNo, + Resources: []audit.ResourceInput{{ + Type: constants.AuditResourceRechargeOrder, ID: &rechargeID, Key: recharge.RechargeOrderNo, DisplayName: recharge.RechargeOrderNo, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleRechargeTarget, + 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, + }, + BeforeData: map[string]any{"auto_purchase_status": recharge.AutoPurchaseStatus}, + AfterData: map[string]any{"auto_purchase_status": constants.AutoPurchaseStatusFailed}, + SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "充值后自动购包失败", + }}, + }) + }); err != nil { h.logger.Error("更新自动购包失败状态失败", zap.Uint("recharge_record_id", rechargeOrderID), zap.Error(err), diff --git a/internal/task/commission_calculation.go b/internal/task/commission_calculation.go index 53c9b83..906b7ad 100644 --- a/internal/task/commission_calculation.go +++ b/internal/task/commission_calculation.go @@ -9,6 +9,8 @@ import ( "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation" + "github.com/break/junhong_cmp_fiber/pkg/auditcontext" + "github.com/break/junhong_cmp_fiber/pkg/constants" ) const ( @@ -46,6 +48,10 @@ func (h *CommissionCalculationHandler) HandleCommissionCalculation(ctx context.C ) return asynq.SkipRetry } + ctx = auditcontext.With(ctx, auditcontext.Context{ + ActorKind: constants.AuditActorSystemTask, ActorID: constants.AuditActorIDCommissionCalculationWorker, + ActorName: "订单佣金计算任务", Source: constants.AuditSourceWorker, + }) if err := h.service.CalculateCommission(ctx, payload.OrderID); err != nil { h.logger.Error("佣金计算失败", diff --git a/internal/task/device_batch_allocation.go b/internal/task/device_batch_allocation.go index ac813af..f0549ae 100644 --- a/internal/task/device_batch_allocation.go +++ b/internal/task/device_batch_allocation.go @@ -10,6 +10,7 @@ import ( "github.com/hibiken/asynq" + "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/postgres" @@ -26,17 +27,24 @@ type deviceBatchAllocationRow struct { func (h *DeviceImportHandler) handleDeviceBatchAllocation(ctx context.Context, task *model.DeviceImportTask) error { if h.allocationExecutor == nil || (task.OperationType != constants.DeviceImportOperationRecall && (task.TargetID == nil || *task.TargetID == 0)) { - _ = h.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "设备CSV批量执行器或目标未配置") + if err := h.finishDeviceImportTask(ctx, task, 0, 0, 1, model.ImportTaskStatusFailed, "设备CSV批量执行器或目标未配置"); err != nil { + return err + } return asynq.SkipRetry } rows, err := h.downloadDeviceBatchAllocationCSV(ctx, task) if err != nil { - _ = h.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, err.Error()) + if finishErr := h.finishDeviceImportTask(ctx, task, 0, 0, 1, model.ImportTaskStatusFailed, err.Error()); finishErr != nil { + return finishErr + } return asynq.SkipRetry } + task.TotalCount = len(rows) shopScope, err := h.resolveDeviceBatchShopScope(ctx, task) if err != nil { - _ = h.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, err.Error()) + if finishErr := h.finishDeviceImportTask(ctx, task, 0, 0, 1, model.ImportTaskStatusFailed, err.Error()); finishErr != nil { + return finishErr + } return asynq.SkipRetry } workerCtx := middleware.SetUserContext(ctx, &middleware.UserContextInfo{ @@ -47,19 +55,20 @@ func (h *DeviceImportHandler) handleDeviceBatchAllocation(ctx context.Context, t ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypeDeviceImport, ActorName: "设备CSV批量操作任务", Source: constants.AuditSourceWorker, CorrelationID: task.TaskNo, + ParentEventID: audit.TaskEventID(constants.AuditResourceDeviceImportTask, task.ID, "completed"), }) result, err := h.executeDeviceBatchAllocation(workerCtx, task, rows) if err != nil { - _ = h.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, err.Error()) + if finishErr := h.finishDeviceImportTask(ctx, task, 0, 0, 1, model.ImportTaskStatusFailed, err.Error()); finishErr != nil { + return finishErr + } return asynq.SkipRetry } - _ = h.importTaskStore.UpdateResult(ctx, task.ID, len(rows), result.successCount, result.skipCount, result.failCount, 0, result.skippedItems, result.failedItems, nil) + status, errorMessage := model.ImportTaskStatusCompleted, "" if result.successCount == 0 && result.failCount > 0 { - _ = h.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "所有设备操作均失败") - } else { - _ = h.importTaskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusCompleted, "") + status, errorMessage = model.ImportTaskStatusFailed, "所有设备操作均失败" } - return nil + return h.finishDeviceImportTask(ctx, task, result.successCount, result.skipCount, result.failCount, status, errorMessage, result.skippedItems, result.failedItems) } func (h *DeviceImportHandler) downloadDeviceBatchAllocationCSV(ctx context.Context, task *model.DeviceImportTask) ([]deviceBatchAllocationRow, error) { diff --git a/internal/task/device_import.go b/internal/task/device_import.go index d572dc3..dbd6a16 100644 --- a/internal/task/device_import.go +++ b/internal/task/device_import.go @@ -5,6 +5,7 @@ import ( stderrors "errors" "fmt" "path/filepath" + "strconv" "strings" "time" @@ -14,10 +15,13 @@ import ( "go.uber.org/zap" "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/postgres" + "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" "github.com/break/junhong_cmp_fiber/pkg/storage" "github.com/break/junhong_cmp_fiber/pkg/utils" ) @@ -45,6 +49,7 @@ type DeviceImportHandler struct { assetWalletStore *postgres.AssetWalletStore assetIdentifierStore *postgres.AssetIdentifierStore storageService *storage.Service + auditWriter *audit.Writer logger *zap.Logger allocationExecutor DeviceBatchAllocationExecutor } @@ -59,6 +64,7 @@ func NewDeviceImportHandler( assetWalletStore *postgres.AssetWalletStore, assetIdentifierStore *postgres.AssetIdentifierStore, storageSvc *storage.Service, + auditWriter *audit.Writer, logger *zap.Logger, allocationExecutor DeviceBatchAllocationExecutor, ) *DeviceImportHandler { @@ -72,6 +78,7 @@ func NewDeviceImportHandler( assetWalletStore: assetWalletStore, assetIdentifierStore: assetIdentifierStore, storageService: storageSvc, + auditWriter: auditWriter, logger: logger, allocationExecutor: allocationExecutor, } @@ -95,6 +102,12 @@ func (h *DeviceImportHandler) HandleDeviceImport(ctx context.Context, task *asyn ) return asynq.SkipRetry } + ctx = auditcontext.With(ctx, auditcontext.Context{ + ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypeDeviceImport, + ActorName: "设备导入任务", Source: constants.AuditSourceWorker, + CorrelationID: importTask.TaskNo, + ParentEventID: audit.TaskEventID(constants.AuditResourceDeviceImportTask, importTask.ID, "completed"), + }) switch importTask.Status { case model.ImportTaskStatusPending: @@ -132,7 +145,9 @@ func (h *DeviceImportHandler) HandleDeviceImport(ctx context.Context, task *asyn zap.Uint("task_id", importTask.ID), zap.Error(err), ) - h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusFailed, err.Error()) + if finishErr := h.finishDeviceImportTask(ctx, importTask, 0, 0, 1, model.ImportTaskStatusFailed, err.Error()); finishErr != nil { + return finishErr + } return asynq.SkipRetry } @@ -147,12 +162,13 @@ func (h *DeviceImportHandler) HandleDeviceImport(ctx context.Context, task *asyn result.failCount++ } - h.importTaskStore.UpdateResult(ctx, importTask.ID, parseResult.TotalCount, result.successCount, result.skipCount, result.failCount, 0, result.skippedItems, result.failedItems, nil) - + importTask.TotalCount = parseResult.TotalCount + status, errorMessage := model.ImportTaskStatusCompleted, "" if result.failCount > 0 && result.successCount == 0 { - h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusFailed, "所有导入均失败") - } else { - h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusCompleted, "") + status, errorMessage = model.ImportTaskStatusFailed, "所有导入均失败" + } + if err := h.finishDeviceImportTask(ctx, importTask, result.successCount, result.skipCount, result.failCount, status, errorMessage, result.skippedItems, result.failedItems); err != nil { + return err } h.logger.Info("设备导入任务完成", @@ -393,7 +409,7 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi return err } - return nil + return h.appendDeviceCreateAudit(ctx, tx, task, device) }) if err != nil { @@ -423,4 +439,137 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi } } +func (h *DeviceImportHandler) appendDeviceCreateAudit(ctx context.Context, tx *gorm.DB, task *model.DeviceImportTask, device *model.Device) error { + if h.auditWriter == nil || task == nil || device == nil || device.ID == 0 { + return pkgerrors.New(pkgerrors.CodeInvalidStatus, "设备导入统一审计接缝未配置或资源不完整") + } + resourceID := strconv.FormatUint(uint64(device.ID), 10) + 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), AfterData: map[string]any{"created": true}, + SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "设备已导入", + }} + var bindings []*model.DeviceSimBinding + if err := tx.WithContext(ctx).Where("device_id = ? AND bind_status = ?", device.ID, constants.BindStatusBound).Order("slot_position ASC").Find(&bindings).Error; err != nil { + return pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "查询设备导入卡槽关系失败") + } + cardIDs := make([]uint, 0, len(bindings)) + for _, binding := range bindings { + cardIDs = append(cardIDs, binding.IotCardID) + } + cardByID := make(map[uint]*model.IotCard, len(cardIDs)) + if len(cardIDs) > 0 { + var cards []*model.IotCard + if err := tx.WithContext(ctx).Where("id IN ?", cardIDs).Find(&cards).Error; err != nil { + return pkgerrors.Wrap(pkgerrors.CodeDatabaseError, err, "查询设备导入绑定卡失败") + } + for _, card := range cards { + cardByID[card.ID] = card + } + } + for index, binding := range bindings { + card := cardByID[binding.IotCardID] + if card != nil { + cardID := strconv.FormatUint(uint64(card.ID), 10) + resources = append(resources, audit.ResourceInput{ + Type: constants.AuditResourceIotCard, ID: &cardID, + Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID, + Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleDeviceBindingTargetCard, + IdentitySnapshot: audit.IotCardIdentitySnapshot(card), + AfterData: map[string]any{"device_id": device.ID, "slot_position": binding.SlotPosition}, + SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "设备导入并绑定 IoT 卡", SortOrder: index*2 + 1, + }) + } + bindingID := strconv.FormatUint(uint64(binding.ID), 10) + identity := map[string]any{ + "id": binding.ID, "device_id": binding.DeviceID, "device_virtual_no": device.VirtualNo, + "slot_position": binding.SlotPosition, "iot_card_id": binding.IotCardID, "is_current": binding.IsCurrent, + } + if card != nil { + identity["iccid"] = card.ICCID + identity["virtual_no"] = card.VirtualNo + } + resources = append(resources, audit.ResourceInput{ + Type: constants.AuditResourceDeviceSIMBinding, ID: &bindingID, + Key: bindingID, DisplayName: device.VirtualNo, + Relation: constants.AuditResourceRelationAffected, Role: constants.AuditResourceRoleDeviceCreatedBinding, + IdentitySnapshot: identity, + AfterData: map[string]any{ + "slot_position": binding.SlotPosition, "bind_status": constants.BindStatusBound, "is_current": binding.IsCurrent, + }, + SubjectVisibility: constants.AuditSubjectInternalOnly, SortOrder: index*2 + 2, + }) + } + return h.auditWriter.Append(ctx, tx, audit.AppendInput{ + EventID: audit.TaskEventID(constants.AuditResourceDeviceImportTask, device.ID, "item"), + ActionCode: constants.AuditActionDeviceCreated, Summary: "导入创建设备", + ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, + Metadata: map[string]any{"import_task_id": task.ID, "import_task_no": task.TaskNo}, + Resources: resources, + }) +} + +func (h *DeviceImportHandler) finishDeviceImportTask(ctx context.Context, task *model.DeviceImportTask, successCount, skipCount, failCount, status int, errorMessage string, items ...model.ImportResultItems) error { + if h.auditWriter == nil { + return pkgerrors.New(pkgerrors.CodeInvalidStatus, "设备导入任务统一审计接缝未配置") + } + return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + now := time.Now() + updates := map[string]any{ + "status": status, "total_count": task.TotalCount, "success_count": successCount, + "skip_count": skipCount, "fail_count": failCount, "error_message": errorMessage, + "completed_at": now, "updated_at": now, + } + if len(items) > 0 { + updates["skipped_items"] = items[0] + } + if len(items) > 1 { + updates["failed_items"] = items[1] + } + if err := tx.WithContext(ctx).Model(&model.DeviceImportTask{}).Where("id = ?", task.ID).Updates(updates).Error; err != nil { + return err + } + rootID := audit.TaskEventID(constants.AuditResourceDeviceImportTask, task.ID, "completed") + var childCount int64 + if err := tx.WithContext(ctx).Model(&model.AuditEvent{}). + Where("correlation_id = ? AND action_code = ? AND result = ?", task.TaskNo, deviceImportItemAction(task.OperationType), constants.AuditResultSuccess). + Count(&childCount).Error; err != nil { + return err + } + return h.auditWriter.WriteTask(ctx, tx, audit.TaskInput{ + EventID: rootID, ActionCode: constants.AuditActionDeviceImportTaskCompleted, + Summary: "完成设备导入任务", TaskID: task.ID, TaskNo: task.TaskNo, + Result: batchAuditResult(int(childCount), failCount), CorrelationID: task.TaskNo, + ParentEventID: audit.TaskEventID(constants.AuditResourceDeviceImportTask, task.ID, "created"), + BatchTotal: task.TotalCount, SuccessCount: int(childCount), FailCount: failCount, + 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: map[string]any{"status": model.ImportTaskStatusProcessing}, + AfterData: map[string]any{ + "status": status, "total_count": task.TotalCount, "success_count": successCount, + "skip_count": skipCount, "fail_count": failCount, + }, + Metadata: map[string]any{"skip_count": skipCount}, + }) + }) +} + +func deviceImportItemAction(operationType string) string { + switch operationType { + case constants.DeviceImportOperationAssignShop: + return constants.AuditActionDeviceAllocated + case constants.DeviceImportOperationAssignSeries: + return constants.AuditActionDeviceSeriesBound + case constants.DeviceImportOperationRecall: + return constants.AuditActionDeviceRecalled + default: + return constants.AuditActionDeviceCreated + } +} + var ErrMissingDeviceNoColumn = stderrors.New("CSV 缺少 virtual_no 列") diff --git a/internal/task/iot_card_import.go b/internal/task/iot_card_import.go index 82f891d..5fc46ba 100644 --- a/internal/task/iot_card_import.go +++ b/internal/task/iot_card_import.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "path/filepath" + "strconv" "strings" "time" @@ -14,9 +15,12 @@ import ( "go.uber.org/zap" "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/store/postgres" + "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" "github.com/break/junhong_cmp_fiber/pkg/storage" "github.com/break/junhong_cmp_fiber/pkg/utils" "github.com/break/junhong_cmp_fiber/pkg/validator" @@ -41,14 +45,15 @@ type PollingCallback interface { } type IotCardImportHandler struct { - db *gorm.DB - redis *redis.Client - importTaskStore *postgres.IotCardImportTaskStore - iotCardStore *postgres.IotCardStore + db *gorm.DB + redis *redis.Client + importTaskStore *postgres.IotCardImportTaskStore + iotCardStore *postgres.IotCardStore assetWalletStore *postgres.AssetWalletStore - storageService *storage.Service - pollingCallback PollingCallback - logger *zap.Logger + storageService *storage.Service + pollingCallback PollingCallback + auditWriter *audit.Writer + logger *zap.Logger } func NewIotCardImportHandler( @@ -59,6 +64,7 @@ func NewIotCardImportHandler( assetWalletStore *postgres.AssetWalletStore, storageSvc *storage.Service, pollingCallback PollingCallback, + auditWriter *audit.Writer, logger *zap.Logger, ) *IotCardImportHandler { return &IotCardImportHandler{ @@ -69,6 +75,7 @@ func NewIotCardImportHandler( assetWalletStore: assetWalletStore, storageService: storageSvc, pollingCallback: pollingCallback, + auditWriter: auditWriter, logger: logger, } } @@ -91,6 +98,12 @@ func (h *IotCardImportHandler) HandleIotCardImport(ctx context.Context, task *as ) return asynq.SkipRetry } + ctx = auditcontext.With(ctx, auditcontext.Context{ + ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypeIotCardImport, + ActorName: "IoT 卡导入任务", Source: constants.AuditSourceWorker, + CorrelationID: importTask.TaskNo, + ParentEventID: audit.TaskEventID(constants.AuditResourceIotCardImportTask, importTask.ID, "completed"), + }) switch importTask.Status { case model.ImportTaskStatusPending: @@ -125,7 +138,9 @@ func (h *IotCardImportHandler) HandleIotCardImport(ctx context.Context, task *as zap.Uint("task_id", importTask.ID), zap.Error(err), ) - h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusFailed, err.Error()) + if finishErr := h.finishImportTask(ctx, importTask, 0, 0, 1, model.ImportTaskStatusFailed, err.Error()); finishErr != nil { + return finishErr + } return asynq.SkipRetry } @@ -138,12 +153,12 @@ func (h *IotCardImportHandler) HandleIotCardImport(ctx context.Context, task *as result.failedItems = append(parseFailures, result.failedItems...) result.failCount += len(parseFailures) - h.importTaskStore.UpdateResult(ctx, importTask.ID, result.successCount, result.skipCount, result.failCount, result.skippedItems, result.failedItems) - + status, errorMessage := model.ImportTaskStatusCompleted, "" if result.failCount > 0 && result.successCount == 0 { - h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusFailed, "所有导入均失败") - } else { - h.importTaskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusCompleted, "") + status, errorMessage = model.ImportTaskStatusFailed, "所有导入均失败" + } + if err := h.finishImportTask(ctx, importTask, result.successCount, result.skipCount, result.failCount, status, errorMessage, result.skippedItems, result.failedItems); err != nil { + return err } h.logger.Info("IoT 卡导入任务完成", @@ -434,7 +449,10 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot }) } } - return tx.CreateInBatches(&identifiers, 500).Error + if err := tx.CreateInBatches(&identifiers, 500).Error; err != nil { + return err + } + return h.appendCardCreateAudits(ctx, tx, iotCards) }) if txErr != nil { @@ -463,6 +481,91 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot } } +func (h *IotCardImportHandler) appendCardCreateAudits(ctx context.Context, tx *gorm.DB, cards []*model.IotCard) error { + if h.auditWriter == nil { + return pkgerrors.New(pkgerrors.CodeInvalidStatus, "IoT 卡导入统一审计接缝未配置") + } + for _, card := range cards { + if card == nil || card.ID == 0 { + return pkgerrors.New(pkgerrors.CodeInvalidStatus, "IoT 卡导入审计资源不完整") + } + resourceID := strconv.FormatUint(uint64(card.ID), 10) + if err := h.auditWriter.Append(ctx, tx, audit.AppendInput{ + EventID: audit.TaskEventID(constants.AuditResourceIotCardImportTask, card.ID, "item"), + ActionCode: constants.AuditActionIotCardCreated, Summary: "导入创建 IoT 卡", + ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, + Resources: []audit.ResourceInput{{ + Type: constants.AuditResourceIotCard, ID: &resourceID, + Key: audit.IotCardResourceKey(card), DisplayName: card.ICCID, + Relation: constants.AuditResourceRelationPrimary, Role: constants.AuditResourceRoleIotCardTarget, + IdentitySnapshot: audit.IotCardIdentitySnapshot(card), AfterData: map[string]any{"created": true}, + SubjectVisibility: constants.AuditSubjectResult, SubjectSummary: "IoT 卡已导入", + }}, + }); err != nil { + return err + } + } + return nil +} + +func (h *IotCardImportHandler) finishImportTask(ctx context.Context, task *model.IotCardImportTask, successCount, skipCount, failCount, status int, errorMessage string, items ...model.ImportResultItems) error { + if h.auditWriter == nil { + return pkgerrors.New(pkgerrors.CodeInvalidStatus, "IoT 卡导入任务统一审计接缝未配置") + } + return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + now := time.Now() + updates := map[string]any{ + "status": status, "success_count": successCount, "skip_count": skipCount, "fail_count": failCount, + "error_message": errorMessage, "completed_at": now, "updated_at": now, + } + if len(items) > 0 { + updates["skipped_items"] = items[0] + } + if len(items) > 1 { + updates["failed_items"] = items[1] + } + if err := tx.WithContext(ctx).Model(&model.IotCardImportTask{}).Where("id = ?", task.ID).Updates(updates).Error; err != nil { + return err + } + rootID := audit.TaskEventID(constants.AuditResourceIotCardImportTask, task.ID, "completed") + var childCount int64 + if err := tx.WithContext(ctx).Model(&model.AuditEvent{}). + Where("parent_event_id = ? AND action_code = ?", rootID, constants.AuditActionIotCardCreated). + Count(&childCount).Error; err != nil { + return err + } + result := batchAuditResult(int(childCount), failCount) + return h.auditWriter.WriteTask(ctx, tx, audit.TaskInput{ + EventID: rootID, ActionCode: constants.AuditActionIotCardImportTaskCompleted, + Summary: "完成 IoT 卡导入任务", TaskID: task.ID, TaskNo: task.TaskNo, + Result: result, CorrelationID: task.TaskNo, + ParentEventID: audit.TaskEventID(constants.AuditResourceIotCardImportTask, task.ID, "created"), + BatchTotal: task.TotalCount, SuccessCount: int(childCount), FailCount: failCount, + IdentitySnapshot: map[string]any{ + "id": task.ID, "task_no": task.TaskNo, "file_name": task.FileName, + "carrier_id": task.CarrierID, "carrier_name": task.CarrierName, "batch_no": task.BatchNo, + "card_category": task.CardCategory, "realname_policy": task.RealnamePolicy, + }, + BeforeData: map[string]any{"status": model.ImportTaskStatusProcessing}, + AfterData: map[string]any{ + "status": status, "total_count": task.TotalCount, "success_count": successCount, + "skip_count": skipCount, "fail_count": failCount, + }, + Metadata: map[string]any{"skip_count": skipCount}, + }) + }) +} + +func batchAuditResult(successCount, failCount int) string { + if successCount > 0 && failCount > 0 { + return constants.AuditResultPartial + } + if successCount == 0 && failCount > 0 { + return constants.AuditResultFailed + } + return constants.AuditResultSuccess +} + // batchCreateWallets 批量为 IoT 卡创建资产钱包 func (h *IotCardImportHandler) batchCreateWallets(ctx context.Context, cards []*model.IotCard) { if h.assetWalletStore == nil { diff --git a/internal/task/notification_cleanup.go b/internal/task/notification_cleanup.go index e37a305..1c7116f 100644 --- a/internal/task/notification_cleanup.go +++ b/internal/task/notification_cleanup.go @@ -7,6 +7,8 @@ import ( "go.uber.org/zap" notificationinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification" + "github.com/break/junhong_cmp_fiber/pkg/auditcontext" + "github.com/break/junhong_cmp_fiber/pkg/constants" ) // NotificationCleanupHandler 处理低峰通知保留清理任务。 @@ -22,6 +24,10 @@ func NewNotificationCleanupHandler(service *notificationinfra.CleanupService, lo // Handle 执行有界、可重入的通知分批清理。 func (h *NotificationCleanupHandler) Handle(ctx context.Context, _ *asynq.Task) error { + ctx = auditcontext.With(ctx, auditcontext.Context{ + ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypeNotificationCleanup, + ActorName: "站内通知清理任务", Source: constants.AuditSourceWorker, + }) h.logger.Info("开始执行站内通知保留清理") if err := h.service.Run(ctx); err != nil { h.logger.Error("站内通知保留清理失败", zap.String("failure_category", "database"), zap.Error(err)) diff --git a/internal/task/order_expire.go b/internal/task/order_expire.go index fca2762..1d714ba 100644 --- a/internal/task/order_expire.go +++ b/internal/task/order_expire.go @@ -3,6 +3,8 @@ package task import ( "context" + "github.com/break/junhong_cmp_fiber/pkg/auditcontext" + "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/hibiken/asynq" "go.uber.org/zap" ) @@ -29,6 +31,10 @@ func NewOrderExpireHandler(orderExpirer OrderExpirer, logger *zap.Logger) *Order // HandleOrderExpire 处理订单超时取消任务 // 由 Asynq Scheduler 每分钟触发,扫描并取消所有已超时的待支付订单 func (h *OrderExpireHandler) HandleOrderExpire(ctx context.Context, _ *asynq.Task) error { + ctx = auditcontext.With(ctx, auditcontext.Context{ + ActorKind: constants.AuditActorScheduledJob, ActorID: constants.AuditActorIDOrderExpireScheduler, + ActorName: "订单过期关闭计划任务", Source: constants.AuditSourceScheduler, + }) cancelled, err := h.orderExpirer.CancelExpiredOrders(ctx) if err != nil { h.logger.Error("订单超时自动取消失败", zap.Error(err)) diff --git a/internal/task/order_package_invalidate.go b/internal/task/order_package_invalidate.go index 59a353e..c0600bc 100644 --- a/internal/task/order_package_invalidate.go +++ b/internal/task/order_package_invalidate.go @@ -2,7 +2,9 @@ package task import ( "context" + "crypto/sha256" "encoding/csv" + "fmt" "io" "os" "strings" @@ -10,10 +12,15 @@ import ( "github.com/bytedance/sonic" "github.com/hibiken/asynq" "go.uber.org/zap" + "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/store/postgres" + "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" "github.com/break/junhong_cmp_fiber/pkg/storage" ) @@ -29,6 +36,7 @@ type OrderPackageInvalidateHandler struct { packageUsageStore *postgres.PackageUsageStore storageService *storage.Service logger *zap.Logger + auditWriter *audit.Writer } // NewOrderPackageInvalidateHandler 创建处理器实例 @@ -38,14 +46,19 @@ func NewOrderPackageInvalidateHandler( packageUsageStore *postgres.PackageUsageStore, storageSvc *storage.Service, logger *zap.Logger, + auditWriters ...*audit.Writer, ) *OrderPackageInvalidateHandler { - return &OrderPackageInvalidateHandler{ + handler := &OrderPackageInvalidateHandler{ taskStore: taskStore, orderStore: orderStore, packageUsageStore: packageUsageStore, storageService: storageSvc, logger: logger, } + if len(auditWriters) > 0 { + handler.auditWriter = auditWriters[0] + } + return handler } // Handle 处理批量失效订单套餐任务 @@ -68,8 +81,17 @@ func (h *OrderPackageInvalidateHandler) Handle(ctx context.Context, t *asynq.Tas ) return asynq.SkipRetry } - - if importTask.Status != model.ImportTaskStatusPending { + rootEventID := audit.TaskEventID(constants.AuditResourceOrderPackageInvalidateTask, importTask.ID, "completed") + ctx = auditcontext.With(ctx, auditcontext.Context{ + ActorKind: constants.AuditActorSystemTask, ActorID: constants.TaskTypeOrderPackageInvalidate, + ActorName: "订单套餐批量失效任务", Source: constants.AuditSourceWorker, + CorrelationID: importTask.TaskNo, ParentEventID: rootEventID, + }) + claimed, err := h.taskStore.Claim(ctx, importTask.ID) + if err != nil { + return err + } + if !claimed { h.logger.Info("批量失效任务已处理,跳过", zap.Uint("task_id", payload.TaskID), zap.Int("status", importTask.Status), @@ -77,8 +99,6 @@ func (h *OrderPackageInvalidateHandler) Handle(ctx context.Context, t *asynq.Tas return nil } - h.taskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusProcessing, "") - h.logger.Info("开始处理批量失效订单套餐任务", zap.Uint("task_id", importTask.ID), zap.String("task_no", importTask.TaskNo), @@ -90,21 +110,24 @@ func (h *OrderPackageInvalidateHandler) Handle(ctx context.Context, t *asynq.Tas zap.Uint("task_id", importTask.ID), zap.Error(err), ) - h.taskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusFailed, err.Error()) + if finishErr := h.finishInvalidateTask(ctx, importTask, 0, 0, 1, model.ImportTaskStatusFailed, err.Error(), nil); finishErr != nil { + h.resetInvalidateTaskForRetry(ctx, importTask.ID) + return finishErr + } return asynq.SkipRetry } - successCount, failedItems := h.processRows(ctx, orderNos) + successCount, failedItems := h.processRows(ctx, importTask.ID, orderNos) failCount := len(failedItems) totalCount := len(orderNos) - h.taskStore.UpdateResult(ctx, importTask.ID, totalCount, successCount, failCount, - model.ImportResultItems(toImportResultItems(failedItems))) - + status, errorMessage := model.ImportTaskStatusCompleted, "" if failCount > 0 && successCount == 0 { - h.taskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusFailed, "所有行均处理失败") - } else { - h.taskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusCompleted, "") + status, errorMessage = model.ImportTaskStatusFailed, "所有行均处理失败" + } + if err := h.finishInvalidateTask(ctx, importTask, totalCount, successCount, failCount, status, errorMessage, model.ImportResultItems(toImportResultItems(failedItems))); err != nil { + h.resetInvalidateTaskForRetry(ctx, importTask.ID) + return err } h.logger.Info("批量失效订单套餐任务完成", @@ -117,6 +140,12 @@ func (h *OrderPackageInvalidateHandler) Handle(ctx context.Context, t *asynq.Tas return nil } +func (h *OrderPackageInvalidateHandler) resetInvalidateTaskForRetry(ctx context.Context, taskID uint) { + _ = h.taskStore.DB().WithContext(ctx).Model(&model.OrderPackageInvalidateTask{}). + Where("id = ? AND status = ?", taskID, model.ImportTaskStatusProcessing). + Updates(map[string]any{"status": model.ImportTaskStatusPending, "started_at": nil}).Error +} + // invalidateRow 单行处理结果 type invalidateRow struct { line int @@ -125,13 +154,13 @@ type invalidateRow struct { } // processRows 逐行处理订单号,返回成功数和失败列表 -func (h *OrderPackageInvalidateHandler) processRows(ctx context.Context, rows []string) (int, []invalidateRow) { +func (h *OrderPackageInvalidateHandler) processRows(ctx context.Context, taskID uint, rows []string) (int, []invalidateRow) { successCount := 0 var failed []invalidateRow for i, orderNo := range rows { line := i + 2 // 第1行为表头,数据从第2行开始 - if err := h.processOneOrder(ctx, orderNo); err != nil { + if err := h.processOneOrder(ctx, taskID, orderNo); err != nil { failed = append(failed, invalidateRow{line: line, orderNo: orderNo, reason: err.Error()}) } else { successCount++ @@ -142,34 +171,117 @@ func (h *OrderPackageInvalidateHandler) processRows(ctx context.Context, rows [] } // processOneOrder 处理单个订单号:查订单 → 查套餐 → 批量更新状态=4 -func (h *OrderPackageInvalidateHandler) processOneOrder(ctx context.Context, orderNo string) error { +func (h *OrderPackageInvalidateHandler) processOneOrder(ctx context.Context, taskID uint, orderNo string) error { order, err := h.orderStore.GetByOrderNo(ctx, orderNo) if err != nil { + h.appendInvalidateFailure(ctx, taskID, &model.Order{OrderNo: orderNo}, "订单不存在") return errOrderNotFound(orderNo) } - usages, err := h.packageUsageStore.ListActiveByOrderID(ctx, order.ID) + queryFailed := false + err = h.taskStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + usages, queryErr := postgres.NewPackageUsageStore(tx, nil).ListActiveByOrderID(ctx, order.ID) + if queryErr != nil { + queryFailed = true + return queryErr + } + if len(usages) == 0 { + return nil + } + ids := make([]uint, 0, len(usages)) + resources := []audit.ResourceInput{audit.OrderResource(order, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleOrderTarget)} + for _, usage := range usages { + ids = append(ids, usage.ID) + resources = append(resources, audit.PackageUsageResource(usage, constants.AuditResourceRelationAffected, constants.AuditResourceRolePackageUsageTarget, + map[string]any{"status": usage.Status}, map[string]any{"status": constants.PackageUsageStatusInvalidated})) + } + if err := postgres.NewPackageUsageStore(tx, nil).BatchUpdateStatus(ctx, ids, constants.PackageUsageStatusInvalidated); err != nil { + return err + } + return h.auditWriter.Append(ctx, tx, audit.AppendInput{ + EventID: audit.TaskEventID(constants.AuditResourceOrderPackageInvalidateTask, taskID, fmt.Sprintf("item:%d", order.ID)), + ActionCode: constants.AuditActionOrderPackageInvalidateItem, Summary: "失效订单套餐权益", + ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultSuccess, Resources: resources, + }) + }) if err != nil { - return errQueryFailed(orderNo) - } - - if len(usages) == 0 { - // 套餐全部已是终态,视为成功 - return nil - } - - ids := make([]uint, 0, len(usages)) - for _, u := range usages { - ids = append(ids, u.ID) - } - - if err := h.packageUsageStore.BatchUpdateStatus(ctx, ids, constants.PackageUsageStatusInvalidated); err != nil { + summary := "更新套餐状态失败" + if queryFailed { + summary = "查询套餐失败" + } + h.appendInvalidateFailure(ctx, taskID, order, summary) + if queryFailed { + return errQueryFailed(orderNo) + } return errUpdateFailed(orderNo) } return nil } +func (h *OrderPackageInvalidateHandler) appendInvalidateFailure(ctx context.Context, taskID uint, order *model.Order, summary string) { + if h.auditWriter == nil || order == nil || order.OrderNo == "" { + return + } + err := h.taskStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + keyHash := sha256.Sum256([]byte(order.OrderNo)) + return h.auditWriter.Append(ctx, tx, audit.AppendInput{ + EventID: fmt.Sprintf("task:order_invalidate:%d:failed:%x", taskID, keyHash[:6]), + ActionCode: constants.AuditActionOrderPackageInvalidateItem, Summary: summary, + ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultFailed, + ErrorCode: fmt.Sprintf("%d", pkgerrors.CodeDatabaseError), ErrorSummary: summary, + Resources: []audit.ResourceInput{audit.OrderResource(order, constants.AuditResourceRelationPrimary, constants.AuditResourceRoleOrderTarget)}, + }) + }) + if err != nil { + auditfailure.RecordSecondaryWriteFailure(constants.AuditActionOrderPackageInvalidateItem, order.OrderNo, "", auditcontext.From(ctx).CorrelationID, fmt.Sprintf("%d", pkgerrors.CodeDatabaseError), err) + } +} + +func (h *OrderPackageInvalidateHandler) finishInvalidateTask(ctx context.Context, task *model.OrderPackageInvalidateTask, totalCount, successCount, failCount, status int, errorMessage string, failedItems model.ImportResultItems) error { + if h.auditWriter == nil { + return pkgerrors.New(pkgerrors.CodeInvalidStatus, "订单套餐失效任务统一审计接缝未配置") + } + return h.taskStore.DB().WithContext(ctx).Transaction(func(tx *gorm.DB) error { + txStore := h.taskStore.WithTx(tx) + if err := txStore.UpdateResult(ctx, task.ID, totalCount, successCount, failCount, failedItems); err != nil { + return err + } + if err := txStore.UpdateStatus(ctx, task.ID, status, errorMessage); err != nil { + return err + } + rootID := audit.TaskEventID(constants.AuditResourceOrderPackageInvalidateTask, task.ID, "completed") + var actualSuccess, actualFail int64 + if err := tx.WithContext(ctx).Model(&model.AuditEvent{}). + Where("parent_event_id = ? AND action_code = ? AND result = ?", rootID, constants.AuditActionOrderPackageInvalidateItem, constants.AuditResultSuccess). + Count(&actualSuccess).Error; err != nil { + return err + } + if err := tx.WithContext(ctx).Model(&model.AuditEvent{}). + Where("parent_event_id = ? AND action_code = ? AND result = ?", rootID, constants.AuditActionOrderPackageInvalidateItem, constants.AuditResultFailed). + Count(&actualFail).Error; err != nil { + return err + } + auditFailCount := int(actualFail) + if auditFailCount == 0 && failCount > 0 { + auditFailCount = failCount + } + return h.auditWriter.WriteTask(ctx, tx, audit.TaskInput{ + EventID: rootID, ActionCode: constants.AuditActionOrderPackageInvalidateTaskCompleted, + Summary: "完成订单套餐批量失效任务", TaskID: task.ID, TaskNo: task.TaskNo, + Result: batchAuditResult(int(actualSuccess), auditFailCount), CorrelationID: task.TaskNo, + ParentEventID: audit.TaskEventID(constants.AuditResourceOrderPackageInvalidateTask, task.ID, "created"), + BatchTotal: int(actualSuccess) + auditFailCount, SuccessCount: int(actualSuccess), FailCount: auditFailCount, + IdentitySnapshot: map[string]any{"id": task.ID, "task_no": task.TaskNo, "file_name": task.FileName}, + BeforeData: map[string]any{"status": model.ImportTaskStatusProcessing}, + AfterData: map[string]any{ + "status": status, "total_count": totalCount, "success_count": successCount, "fail_count": failCount, + }, + Metadata: map[string]any{"task_success_count": successCount, "task_fail_count": failCount}, + }) + }) +} + // downloadAndParseCSV 从对象存储下载 CSV 并解析 order_no 列 func (h *OrderPackageInvalidateHandler) downloadAndParseCSV(ctx context.Context, task *model.OrderPackageInvalidateTask) ([]string, error) { if h.storageService == nil { diff --git a/internal/task/polling_protect_handler.go b/internal/task/polling_protect_handler.go index 3ec648e..1a2131d 100644 --- a/internal/task/polling_protect_handler.go +++ b/internal/task/polling_protect_handler.go @@ -2,7 +2,6 @@ package task import ( "context" - "strconv" "time" "github.com/hibiken/asynq" @@ -11,11 +10,9 @@ import ( cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" "github.com/break/junhong_cmp_fiber/internal/gateway" - "github.com/break/junhong_cmp_fiber/internal/model" iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" "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" ) // PollingProtectHandler 保护期一致性检查任务处理器 @@ -23,13 +20,10 @@ import ( // 保护期结束:调 EvaluateAndAct 重新评估正常停复机条件 // 两种路径不可混淆:保护期内=强制修正;保护期结束=重新评估 type PollingProtectHandler struct { - db *gorm.DB - observationSeriesEvents cardObservationApp.SeriesEventWriter - base *PollingBase - gateway *gateway.Client - iotCardStore *postgres.IotCardStore - deviceSimBindingStore *postgres.DeviceSimBindingStore - stopResumeSvc iot_card_svc.StopResumeServiceInterface + base *PollingBase + iotCardStore *postgres.IotCardStore + deviceSimBindingStore *postgres.DeviceSimBindingStore + stopResumeSvc iot_card_svc.StopResumeServiceInterface } // NewPollingProtectHandler 创建保护期一致性检查任务处理器 @@ -43,13 +37,10 @@ func NewPollingProtectHandler( stopResumeSvc iot_card_svc.StopResumeServiceInterface, ) *PollingProtectHandler { return &PollingProtectHandler{ - db: db, - observationSeriesEvents: observationSeriesEvents, - base: base, - gateway: gw, - iotCardStore: iotCardStore, - deviceSimBindingStore: deviceSimBindingStore, - stopResumeSvc: stopResumeSvc, + base: base, + iotCardStore: iotCardStore, + deviceSimBindingStore: deviceSimBindingStore, + stopResumeSvc: stopResumeSvc, } } @@ -105,28 +96,15 @@ func (h *PollingProtectHandler) Handle(ctx context.Context, t *asynq.Task) error // 保护期内:停机保护期发现开机卡 → 强制停机(绕过 EvaluateAndAct) h.base.logger.Info("保护期一致性:停机保护期内发现开机卡,强制停机", zap.Uint("card_id", card.ID), zap.Uint("device_id", deviceID)) - if h.gateway == nil { + if h.stopResumeSvc == nil { break } - if err := h.gateway.StopCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); err != nil { + if err := h.stopResumeSvc.ForceStopCard(ctx, card, constants.StopReasonProtectPeriod); err != nil { h.base.logger.Error("保护期强制停机失败", zap.Uint("card_id", card.ID), zap.Error(err)) h.base.updateStats(ctx, constants.TaskTypePollingProtect, false, time.Since(startTime)) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect) } - if updateErr := h.updateCardAndAppendNetworkSeries(ctx, cardID, map[string]any{ - "network_status": constants.NetworkStatusOffline, - "stopped_at": time.Now(), - "stop_reason": constants.StopReasonProtectPeriod, - }, constants.CardObservationSceneBusinessStop, "offline", "stop", stopProtectGeneration); updateErr != nil { - h.base.logger.Warn("保护期停机 DB 更新失败", zap.Uint("card_id", cardID), zap.Error(updateErr)) - h.base.invalidateCardCache(ctx, cardID) - h.base.updateStats(ctx, constants.TaskTypePollingProtect, false, time.Since(startTime)) - if requeueErr := h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect); requeueErr != nil { - return errors.Wrap(errors.CodeInternalError, requeueErr, "保护期停机事务失败且重入队失败") - } - return updateErr - } h.base.updateCardCache(ctx, cardID, map[string]any{"network_status": constants.NetworkStatusOffline}) actionTaken = "forced_stop" @@ -134,28 +112,15 @@ func (h *PollingProtectHandler) Handle(ctx context.Context, t *asynq.Task) error // 保护期内:复机保护期发现停机卡 → 强制复机(绕过 EvaluateAndAct) h.base.logger.Info("保护期一致性:复机保护期内发现停机卡,强制复机", zap.Uint("card_id", card.ID), zap.Uint("device_id", deviceID)) - if h.gateway == nil { + if h.stopResumeSvc == nil { break } - if err := h.gateway.StartCard(ctx, &gateway.CardOperationReq{CardNo: card.ICCID}); err != nil { + if err := h.stopResumeSvc.ForceStartCard(ctx, card); err != nil { h.base.logger.Error("保护期强制复机失败", zap.Uint("card_id", card.ID), zap.Error(err)) h.base.updateStats(ctx, constants.TaskTypePollingProtect, false, time.Since(startTime)) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect) } - if updateErr := h.updateCardAndAppendNetworkSeries(ctx, cardID, map[string]any{ - "network_status": constants.NetworkStatusOnline, - "resumed_at": time.Now(), - "stop_reason": "", - }, constants.CardObservationSceneBusinessResume, "online", "start", startProtectGeneration); updateErr != nil { - h.base.logger.Warn("保护期复机 DB 更新失败", zap.Uint("card_id", cardID), zap.Error(updateErr)) - h.base.invalidateCardCache(ctx, cardID) - h.base.updateStats(ctx, constants.TaskTypePollingProtect, false, time.Since(startTime)) - if requeueErr := h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect); requeueErr != nil { - return errors.Wrap(errors.CodeInternalError, requeueErr, "保护期复机事务失败且重入队失败") - } - return updateErr - } h.base.updateCardCache(ctx, cardID, map[string]any{"network_status": constants.NetworkStatusOnline}) actionTaken = "forced_resume" @@ -189,35 +154,3 @@ func (h *PollingProtectHandler) Handle(ctx context.Context, t *asynq.Task) error h.base.updateStats(ctx, constants.TaskTypePollingProtect, true, time.Since(startTime)) return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingProtect) } - -// updateCardAndAppendNetworkSeries 在同一事务中更新卡状态并写入网络观测请求。 -func (h *PollingProtectHandler) updateCardAndAppendNetworkSeries( - ctx context.Context, - cardID uint, - fields map[string]any, - scene string, - expected string, - operation string, - protectGeneration string, -) error { - if h.db == nil || h.observationSeriesEvents == nil { - return errors.New(errors.CodeInternalError, "保护期停复机观测 Outbox 能力未配置") - } - if protectGeneration == "" { - return errors.New(errors.CodeInvalidParam, "保护期停复机事件缺少保护期标识") - } - requestID := "card-observation:polling-protect:" + operation + ":" + - strconv.FormatUint(uint64(cardID), 10) + ":" + protectGeneration - return h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { - if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).Updates(fields).Error; err != nil { - return errors.Wrap(errors.CodeDatabaseError, err, "更新保护期停复机卡状态失败") - } - return h.observationSeriesEvents.AppendSeriesRequested(ctx, tx, cardObservationApp.SeriesRequestedEvent{ - EventID: requestID, Scene: scene, - ResourceType: constants.CardObservationResourceTypeCard, ResourceID: cardID, - SyncTypes: []string{constants.CardObservationSyncTypeNetwork}, ExpectedValue: expected, - Source: constants.CardObservationSourceBusinessEvent, OccurredAt: time.Now().UTC(), - RequestID: requestID, CorrelationID: requestID, - }) - }) -} diff --git a/migrations/000203_add_order_asset_wallet_reservation.down.sql b/migrations/000203_add_order_asset_wallet_reservation.down.sql new file mode 100644 index 0000000..6956dd9 --- /dev/null +++ b/migrations/000203_add_order_asset_wallet_reservation.down.sql @@ -0,0 +1,5 @@ +-- 仅删除订单上的资产钱包预占快照,不修改钱包余额。 +ALTER TABLE tb_order + DROP CONSTRAINT IF EXISTS ck_order_asset_wallet_reservation, + DROP COLUMN IF EXISTS asset_wallet_reserved_amount, + DROP COLUMN IF EXISTS asset_wallet_reservation_wallet_id; diff --git a/migrations/000203_add_order_asset_wallet_reservation.up.sql b/migrations/000203_add_order_asset_wallet_reservation.up.sql new file mode 100644 index 0000000..42fabbb --- /dev/null +++ b/migrations/000203_add_order_asset_wallet_reservation.up.sql @@ -0,0 +1,15 @@ +-- 为个人钱包待支付订单记录精确的钱包和预占金额,历史订单默认视为未预占。 +ALTER TABLE tb_order + ADD COLUMN asset_wallet_reservation_wallet_id BIGINT, + ADD COLUMN asset_wallet_reserved_amount BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE tb_order + ADD CONSTRAINT ck_order_asset_wallet_reservation + CHECK ( + (asset_wallet_reservation_wallet_id IS NULL AND asset_wallet_reserved_amount = 0) + OR + (asset_wallet_reservation_wallet_id IS NOT NULL AND asset_wallet_reserved_amount > 0) + ); + +COMMENT ON COLUMN tb_order.asset_wallet_reservation_wallet_id IS '个人钱包订单预占的资产钱包ID(无外键)'; +COMMENT ON COLUMN tb_order.asset_wallet_reserved_amount IS '个人钱包订单预占金额(单位:分,0表示历史未预占订单)'; diff --git a/openspec/changes/build-multi-view-audit-center/tasks.md b/openspec/changes/build-multi-view-audit-center/tasks.md index d071002..0c96e24 100644 --- a/openspec/changes/build-multi-view-audit-center/tasks.md +++ b/openspec/changes/build-multi-view-audit-center/tasks.md @@ -46,42 +46,42 @@ - [x] 5.8 迁移企业卡授权/回收纵向用例,关联企业、owner shop、卡和授权记录并保持现有有效授权语义。【主:简单写 + Application|边界:企业卡授权|不迁移:设备授权|验证:授权/撤销/重复/越权、卡活动和企业范围核对通过,LSP 无诊断】 - [x] 5.9 迁移企业设备授权/回收纵向用例,关联企业、设备及实际随设备处理的绑定卡,保持现有授权语义。【主:复杂写或简单写事务脚本|边界:企业设备授权|不迁移:卡槽绑定规则|验证:多卡设备授权/撤销/越权、资源关系和企业活动核对通过,LSP 无诊断】 - [x] 5.10 迁移个人客户资料、手机号和微信主体纵向用例,记录真实个人 actor,不把普通查询或 Token 刷新误记为业务操作。【主:简单写 + Application|边界:个人客户身份资料|不迁移:资产绑定|验证:资料/手机号/微信变更、失败、凭据删除和 subject_detail 核对通过,LSP 无诊断】 -- [ ] 5.11 迁移个人客户卡/设备绑定、解绑和换货绑定迁移纵向用例,关联客户、资产和绑定记录。【主:简单写或复杂写 Application|边界:客户资产关系|不迁移:换货其他资金/套餐步骤|验证:绑定/解绑/迁移、资源快照、越权和多资源活动核对通过,LSP 无诊断】 +- [x] 5.11 迁移个人客户卡/设备绑定、解绑和换货绑定迁移纵向用例,关联客户、资产和绑定记录。【主:简单写或复杂写 Application|边界:客户资产关系|不迁移:换货其他资金/套餐步骤|验证:绑定/解绑/迁移、资源快照、越权和多资源活动核对通过,LSP 无诊断】 ## 6. 卡、设备、绑定、分配与换货迁移 -- [ ] 6.1 迁移 IoT 卡创建/导入落库、基础资料、删除纵向用例,快照含 ID/ICCID/VirtualNo/MSISDN/运营商/店铺/系列/generation。【主:简单写 + 旧 Service Adapter|边界:卡身份生命周期|不迁移:状态与外部命令|验证:创建/更新/删除、历史快照、凭据规则和旧资产写归零核对通过,LSP 无诊断】 -- [ ] 6.2 迁移 IoT 卡分配、回收和系列绑定纵向用例,关联分配记录、来源/目标店铺、系列和必要设备关系。【主:复杂写或简单写事务脚本|边界:卡归属与系列|不迁移:卡状态/Gateway|验证:分配/回收/拒绝、多资源、主体活动和旧写归零核对通过,LSP 无诊断】 -- [ ] 6.3 迁移 IoT 卡停复机、实名策略/状态、限速和人工刷新纵向用例,实际外部尝试写 Integration Log,内部状态变化写 Audit Event,unknown 不伪装失败或成功。【主:复杂写 + Gateway Adapter|边界:卡状态与外部命令|不迁移:卡归属|验证:success/failed/denied/unknown、Integration/Audit 分界、主体结论和旧写归零核对通过,LSP 无诊断】 -- [ ] 6.4 迁移设备创建/导入落库、基础资料和删除纵向用例,快照含 ID/VirtualNo/IMEI/SN/型号/店铺/系列/generation。【主:简单写 + 旧 Service Adapter|边界:设备身份生命周期|不迁移:状态、卡槽与 Gateway|验证:创建/更新/删除、历史快照和旧资产写归零核对通过,LSP 无诊断】 -- [ ] 6.5 迁移设备分配、回收和系列/实名策略纵向用例,关联分配记录、来源/目标店铺及实际连带卡。【主:复杂写或简单写事务脚本|边界:设备归属与策略|不迁移:设备外部命令|验证:分配/回收/策略、设备卡关系、主体活动和旧写归零核对通过,LSP 无诊断】 -- [ ] 6.6 迁移设备停复机、Wi-Fi、切卡模式、重启和重置纵向用例,外部尝试写 Integration Log,实际内部变化写 Audit Event。【主:复杂写 + Gateway Adapter|边界:设备外部命令|不迁移:设备归属与卡槽绑定|验证:success/failed/unknown、设备时间线、安全结论和旧写归零核对通过,LSP 无诊断】 -- [ ] 6.7 迁移设备绑卡、解绑和当前卡切换纵向用例,把设备、目标卡、旧/新当前卡及每个 binding 作为一等资源,保存 slot/is_current。【主:复杂写|边界:设备卡槽关系完整用例|不迁移:设备/卡其他状态机|验证:1-4 卡槽、旧新卡角色、资源双向时间线、事务和主体范围核对通过,LSP 无诊断】 -- [ ] 6.8 迁移单笔资产分配/回收纵向用例,关联分配记录、来源/目标店铺、设备及实际连带卡。【主:复杂写 Application|边界:单笔资产流转|不迁移:批量分配和企业授权|验证:卡/设备流转、多资源、越权和每资源时间线核对通过,LSP 无诊断】 -- [ ] 6.9 迁移批量设备分配纵向用例,批次根事件与每台实际变化设备/绑定卡子事件计数一致。【主:复杂写 + Application/Asynq|边界:批量设备流转|不迁移:其他批量任务|验证:success/partial/failed、根子计数、幂等和店铺范围核对通过,LSP 无诊断】 -- [ ] 6.10 迁移卡换货完整用例,记录换货单、旧/新卡 ICCID+VirtualNo、客户绑定、钱包/流水、套餐权益、店铺和状态变化。【主:复杂写 Domain/Application|辅:Outbox/Query|边界:卡换货创建至完成/取消/迁移|不迁移:设备换货|验证:资金/套餐/客户迁移、失败回滚、多资源时间线和外部安全结论核对通过,LSP 无诊断】 -- [ ] 6.11 迁移设备换货完整用例,记录旧/新设备 VirtualNo+IMEI+SN、实际绑定卡/卡槽、客户绑定、钱包/流水、套餐权益、店铺和换货单。【主:复杂写 Domain/Application|辅:Outbox/Query|边界:设备换货完整流程|不迁移:无关订单规则|验证:多卡设备换货、资金/套餐/客户迁移、失败回滚和所有资源时间线核对通过,LSP 无诊断】 +- [x] 6.1 迁移 IoT 卡创建/导入落库、基础资料、删除纵向用例,快照含 ID/ICCID/VirtualNo/MSISDN/运营商/店铺/系列/generation。【主:简单写 + 旧 Service Adapter|边界:卡身份生命周期|不迁移:状态与外部命令|验证:创建/更新/删除、历史快照、凭据规则和旧资产写归零核对通过,LSP 无诊断】 +- [x] 6.2 迁移 IoT 卡分配、回收和系列绑定纵向用例,关联分配记录、来源/目标店铺、系列和必要设备关系。【主:复杂写或简单写事务脚本|边界:卡归属与系列|不迁移:卡状态/Gateway|验证:分配/回收/拒绝、多资源、主体活动和旧写归零核对通过,LSP 无诊断】 +- [x] 6.3 迁移 IoT 卡停复机、实名策略/状态、限速和人工刷新纵向用例,实际外部尝试写 Integration Log,内部状态变化写 Audit Event,unknown 不伪装失败或成功。【主:复杂写 + Gateway Adapter|边界:卡状态与外部命令|不迁移:卡归属|验证:success/failed/denied/unknown、Integration/Audit 分界、主体结论和旧写归零核对通过,LSP 无诊断】 +- [x] 6.4 迁移设备创建/导入落库、基础资料和删除纵向用例,快照含 ID/VirtualNo/IMEI/SN/型号/店铺/系列/generation。【主:简单写 + 旧 Service Adapter|边界:设备身份生命周期|不迁移:状态、卡槽与 Gateway|验证:创建/更新/删除、历史快照和旧资产写归零核对通过,LSP 无诊断】 +- [x] 6.5 迁移设备分配、回收和系列/实名策略纵向用例,关联分配记录、来源/目标店铺及实际连带卡。【主:复杂写或简单写事务脚本|边界:设备归属与策略|不迁移:设备外部命令|验证:分配/回收/策略、设备卡关系、主体活动和旧写归零核对通过,LSP 无诊断】 +- [x] 6.6 迁移设备停复机、Wi-Fi、切卡模式、重启和重置纵向用例,外部尝试写 Integration Log,实际内部变化写 Audit Event。【主:复杂写 + Gateway Adapter|边界:设备外部命令|不迁移:设备归属与卡槽绑定|验证:success/failed/unknown、设备时间线、安全结论和旧写归零核对通过,LSP 无诊断】 +- [x] 6.7 迁移设备绑卡、解绑和当前卡切换纵向用例,把设备、目标卡、旧/新当前卡及每个 binding 作为一等资源,保存 slot/is_current。【主:复杂写|边界:设备卡槽关系完整用例|不迁移:设备/卡其他状态机|验证:1-4 卡槽、旧新卡角色、资源双向时间线、事务和主体范围核对通过,LSP 无诊断】 +- [x] 6.8 迁移单笔资产分配/回收纵向用例,关联分配记录、来源/目标店铺、设备及实际连带卡。【主:复杂写 Application|边界:单笔资产流转|不迁移:批量分配和企业授权|验证:卡/设备流转、多资源、越权和每资源时间线核对通过,LSP 无诊断】 +- [x] 6.9 迁移批量设备分配纵向用例,批次根事件与每台实际变化设备/绑定卡子事件计数一致。【主:复杂写 + Application/Asynq|边界:批量设备流转|不迁移:其他批量任务|验证:success/partial/failed、根子计数、幂等和店铺范围核对通过,LSP 无诊断】 +- [x] 6.10 迁移卡换货完整用例,记录换货单、旧/新卡 ICCID+VirtualNo、客户绑定、钱包/流水、套餐权益、店铺和状态变化。【主:复杂写 Domain/Application|辅:Outbox/Query|边界:卡换货创建至完成/取消/迁移|不迁移:设备换货|验证:资金/套餐/客户迁移、失败回滚、多资源时间线和外部安全结论核对通过,LSP 无诊断】 +- [x] 6.11 迁移设备换货完整用例,记录旧/新设备 VirtualNo+IMEI+SN、实际绑定卡/卡槽、客户绑定、钱包/流水、套餐权益、店铺和换货单。【主:复杂写 Domain/Application|辅:Outbox/Query|边界:设备换货完整流程|不迁移:无关订单规则|验证:多卡设备换货、资金/套餐/客户迁移、失败回滚和所有资源时间线核对通过,LSP 无诊断】 ## 7. 套餐、交易与资金纵向迁移 -- [ ] 7.1 迁移套餐系列、套餐商品、店铺系列/套餐分配、批量定价和关键价格配置纵向用例,关联店铺、系列、套餐和价格历史,保持现有上架/分配规则。【主:简单写 + Application|边界:套餐配置与授权|不迁移:套餐购买流程|验证:before/after、批量根子、店铺时间线、凭据规则和旧 account 借用写归零核对通过,LSP 无诊断】 -- [ ] 7.2 迁移套餐权益激活、排队、流量重置/扣减、退款失效、资产失效和换货迁移纵向用例,系统任务使用真实 actor,权益、订单、套餐和资产作为独立资源。【主:复杂写 Domain/Application|辅:Worker/Outbox|边界:package_usage 生命周期|不迁移:套餐列表 Query|验证:状态条件、重复任务幂等、系统 actor、父子链路和资源时间线核对通过,LSP 无诊断】 -- [ ] 7.3 迁移订单创建、后台代购、C 端/OpenAPI 购买、取消、钱包支付和过期关闭纵向用例,关联买家/操作者、资产、套餐、金额、支付方式、钱包/流水和购买角色。【主:复杂写 Domain/Application|辅:Outbox|边界:订单状态与支付准备|不迁移:订单只读列表|验证:各入口 actor、成功/拒绝/失败、资金同事务、系统过期和链路核对通过,LSP 无诊断】 -- [ ] 7.4 迁移支付创建、微信/支付宝/富友预下单、查单和回调确认纵向用例:每次外部尝试写 Integration Log,实际改变支付/订单/充值事实时写 Audit Event,关联支付单、业务单、渠道交易号和 correlation。【主:复杂写 + Infrastructure Adapter|边界:支付外部与内部终态|不迁移:渠道协议重构|验证:success/unknown/迟到回调/重复回调、series/correlation、Domain Ledger 和时间线核对通过,LSP 无诊断】 -- [ ] 7.5 迁移退款申请、审批终态、钱包回充、佣金失效、套餐/资产后处理和通知纵向用例,关联退款、审批、订单、资产、钱包、原扣款/退款流水、佣金和套餐权益,资金事实与审计同事务。【主:复杂写 Domain/Application|辅:Outbox/Integration|边界:退款完整业务链|不迁移:原路退款等未实现能力|验证:通过/拒绝/重提/重复终态/失败回滚、资金时间线和代理安全结论核对通过,LSP 无诊断】 -- [ ] 7.6 迁移个人资产充值和代理在线/线下充值纵向用例,关联充值单、提交人、店铺/资产、支付或审批、钱包、交易流水和自动购包;外部回调与系统恢复使用真实 actor。【主:复杂写 Domain/Application|辅:Payment/Approval/Outbox|边界:充值创建至入账|不迁移:新充值渠道|验证:在线/线下、回调/审批、重复入账、unknown、资金同事务和主体投影核对通过,LSP 无诊断】 -- [ ] 7.7 迁移代理主钱包订单扣款和预占/释放/完成纵向用例,关联订单、钱包、预占、唯一流水和余额前后值。【主:复杂写 Domain/Application|辅:Outbox|边界:订单资金占用与扣款|不迁移:充值/退款/信用额度|验证:乐观锁/状态条件、唯一业务键、审计失败回滚和失败短事务核对通过,LSP 无诊断】 -- [ ] 7.8 迁移代理主钱包充值入账、人工调整、退款回充和信用额度纵向用例,逐项关联业务单、原流水/新流水和余额前后值。【主:复杂写 Domain/Application|辅:Outbox|边界:代理钱包正向及回退资金|不迁移:资产钱包与佣金提现|验证:重复入账/退款上限/人工原因/信用变更、同事务和资金查询核对通过,LSP 无诊断】 -- [ ] 7.9 迁移卡/设备资产钱包充值、扣款、退款和换货迁移纵向用例,关联资产完整标识、钱包、业务单和唯一流水。【主:复杂写 Domain/Application|边界:资产钱包资金|不迁移:代理主钱包|验证:卡/设备、换货、重复业务键、余额前后值和资源时间线核对通过,LSP 无诊断】 -- [ ] 7.10 迁移佣金计算/入账/失效和提现申请/审批/驳回纵向用例,关联店铺、订单、系列、佣金记录、提现单、钱包/流水和金额状态。【主:复杂写 Domain/Application|辅:Worker/Outbox|边界:佣金与提现完整状态机|不迁移:统计 Query|验证:计算幂等、审批终态、资金同事务、失败短事务和资金时间线核对通过,LSP 无诊断】 +- [x] 7.1 迁移套餐系列、套餐商品、店铺系列/套餐分配、批量定价和关键价格配置纵向用例,关联店铺、系列、套餐和价格历史,保持现有上架/分配规则。【主:简单写 + Application|边界:套餐配置与授权|不迁移:套餐购买流程|验证:before/after、批量根子、店铺时间线、凭据规则和旧 account 借用写归零核对通过,LSP 无诊断】 +- [x] 7.2 迁移套餐权益激活、排队、流量重置/扣减、退款失效、资产失效和换货迁移纵向用例,系统任务使用真实 actor,权益、订单、套餐和资产作为独立资源。【主:复杂写 Domain/Application|辅:Worker/Outbox|边界:package_usage 生命周期|不迁移:套餐列表 Query|验证:状态条件、重复任务幂等、系统 actor、父子链路和资源时间线核对通过,LSP 无诊断】 +- [x] 7.3 迁移订单创建、后台代购、C 端/OpenAPI 购买、取消、钱包支付和过期关闭纵向用例,关联买家/操作者、资产、套餐、金额、支付方式、钱包/流水和购买角色。【主:复杂写 Domain/Application|辅:Outbox|边界:订单状态与支付准备|不迁移:订单只读列表|验证:各入口 actor、成功/拒绝/失败、资金同事务、系统过期和链路核对通过,LSP 无诊断】 +- [x] 7.4 迁移支付创建、微信/支付宝/富友预下单、查单和回调确认纵向用例:每次外部尝试写 Integration Log,实际改变支付/订单/充值事实时写 Audit Event,关联支付单、业务单、渠道交易号和 correlation。【主:复杂写 + Infrastructure Adapter|边界:支付外部与内部终态|不迁移:渠道协议重构|验证:success/unknown/迟到回调/重复回调、series/correlation、Domain Ledger 和时间线核对通过,LSP 无诊断】 +- [x] 7.5 迁移退款申请、审批终态、钱包回充、佣金失效、套餐/资产后处理和通知纵向用例,关联退款、审批、订单、资产、钱包、原扣款/退款流水、佣金和套餐权益,资金事实与审计同事务。【主:复杂写 Domain/Application|辅:Outbox/Integration|边界:退款完整业务链|不迁移:原路退款等未实现能力|验证:通过/拒绝/重提/重复终态/失败回滚、资金时间线和代理安全结论核对通过,LSP 无诊断】 +- [x] 7.6 迁移个人资产充值和代理在线/线下充值纵向用例,关联充值单、提交人、店铺/资产、支付或审批、钱包、交易流水和自动购包;外部回调与系统恢复使用真实 actor。【主:复杂写 Domain/Application|辅:Payment/Approval/Outbox|边界:充值创建至入账|不迁移:新充值渠道|验证:在线/线下、回调/审批、重复入账、unknown、资金同事务和主体投影核对通过,LSP 无诊断】 +- [x] 7.7 迁移代理主钱包订单扣款和预占/释放/完成纵向用例,关联订单、钱包、预占、唯一流水和余额前后值。【主:复杂写 Domain/Application|辅:Outbox|边界:订单资金占用与扣款|不迁移:充值/退款/信用额度|验证:乐观锁/状态条件、唯一业务键、审计失败回滚和失败短事务核对通过,LSP 无诊断】 +- [x] 7.8 迁移代理主钱包充值入账、人工调整、退款回充和信用额度纵向用例,逐项关联业务单、原流水/新流水和余额前后值。【主:复杂写 Domain/Application|辅:Outbox|边界:代理钱包正向及回退资金|不迁移:资产钱包与佣金提现|验证:重复入账/退款上限/人工原因/信用变更、同事务和资金查询核对通过,LSP 无诊断】 +- [x] 7.9 迁移卡/设备资产钱包充值、扣款、退款和换货迁移纵向用例,关联资产完整标识、钱包、业务单和唯一流水。【主:复杂写 Domain/Application|边界:资产钱包资金|不迁移:代理主钱包|验证:卡/设备、换货、重复业务键、余额前后值和资源时间线核对通过,LSP 无诊断】 +- [x] 7.10 迁移佣金计算/入账/失效和提现申请/审批/驳回纵向用例,关联店铺、订单、系列、佣金记录、提现单、钱包/流水和金额状态。【主:复杂写 Domain/Application|辅:Worker/Outbox|边界:佣金与提现完整状态机|不迁移:统计 Query|验证:计算幂等、审批终态、资金同事务、失败短事务和资金时间线核对通过,LSP 无诊断】 ## 8. 审批、配置、批量与自动入口迁移 -- [ ] 8.1 迁移通用审批和企微申请提交、回调同步、主动恢复及终态分发纵向用例,记录真实提交人、外部系统/系统任务 actor、审批实例、业务单、Integration Log 和 Outbox,不伪造本地审批人。【主:复杂写 Domain/Application|辅:WeCom Adapter/Outbox|边界:审批完整链路|不迁移:新审批引擎|验证:提交、unknown、回调、恢复、重复终态、actor/correlation 和资源时间线核对通过,LSP 无诊断】 -- [ ] 8.2 迁移支付配置、运营商、企微应用/成员/场景及其他关键连接配置纵向用例,记录配置身份、状态和“凭据是否已配置”,不记录 Secret、Token、AESKey、私钥或证书正文。【主:简单写 + Infrastructure Adapter|边界:外部连接配置|不迁移:配置 UI 与渠道业务协议|验证:CRUD/启停/校验失败、同事务、凭据删除和平台完整业务字段核对通过,LSP 无诊断】 -- [ ] 8.3 迁移卡/设备导入、资产套餐批购、订单套餐失效和导出任务创建/取消纵向用例,记录任务、文件名/目标/操作者和批量根子结果;对象存储签名 URL 不入审计,审计中心自身仍不提供导出。【主:Application + Asynq|辅:Object Storage|边界:现有导入批量和业务导出任务动作|不迁移:导出 DataSource 内容|验证:结构化 payload、幂等、partial、凭据删除和任务/资源时间线核对通过,LSP 无诊断】 -- [ ] 8.4 迁移通知生成、投递、单条/全部已读和清理纵向用例,低风险写仍登记 action,系统清理使用系统 actor,Audit Event 不替代通知或 Outbox 投递事实。【主:简单写/Application + Worker|边界:通知状态变化|不迁移:通知查询|验证:人工/系统 actor、低风险默认展示、Outbox 幂等和资源关联核对通过,LSP 无诊断】 -- [ ] 8.5 迁移轮询配置、并发配置、告警规则、人工触发/取消和状态变化纵向用例;手动轮询表继续承担进度/结果,实际 Gateway 尝试写 Integration Log,人工触发动作另写 Audit Event。【主:Application + Adapter|辅:Scheduler/Query|边界:轮询配置与人工动作|不迁移:删除手动任务表和普通运行查询|验证:配置/触发/拒绝、任务 ledger 保留、Integration/Audit 分界和 actor 核对通过,LSP 无诊断】 +- [x] 8.1 迁移通用审批和企微申请提交、回调同步、主动恢复及终态分发纵向用例,记录真实提交人、外部系统/系统任务 actor、审批实例、业务单、Integration Log 和 Outbox,不伪造本地审批人。【主:复杂写 Domain/Application|辅:WeCom Adapter/Outbox|边界:审批完整链路|不迁移:新审批引擎|验证:提交、unknown、回调、恢复、重复终态、actor/correlation 和资源时间线核对通过,LSP 无诊断】 +- [x] 8.2 迁移支付配置、运营商、企微应用/成员/场景及其他关键连接配置纵向用例,记录配置身份、状态和“凭据是否已配置”,不记录 Secret、Token、AESKey、私钥或证书正文。【主:简单写 + Infrastructure Adapter|边界:外部连接配置|不迁移:配置 UI 与渠道业务协议|验证:CRUD/启停/校验失败、同事务、凭据删除和平台完整业务字段核对通过,LSP 无诊断】 +- [x] 8.3 迁移卡/设备导入、资产套餐批购、订单套餐失效和导出任务创建/取消纵向用例,记录任务、文件名/目标/操作者和批量根子结果;对象存储签名 URL 不入审计,审计中心自身仍不提供导出。【主:Application + Asynq|辅:Object Storage|边界:现有导入批量和业务导出任务动作|不迁移:导出 DataSource 内容|验证:结构化 payload、幂等、partial、凭据删除和任务/资源时间线核对通过,LSP 无诊断】 +- [x] 8.4 迁移通知生成、投递、单条/全部已读和清理纵向用例,低风险写仍登记 action,系统清理使用系统 actor,Audit Event 不替代通知或 Outbox 投递事实。【主:简单写/Application + Worker|边界:通知状态变化|不迁移:通知查询|验证:人工/系统 actor、低风险默认展示、Outbox 幂等和资源关联核对通过,LSP 无诊断】 +- [x] 8.5 迁移轮询配置、并发配置、告警规则、人工触发/取消和状态变化纵向用例;手动轮询表继续承担进度/结果,实际 Gateway 尝试写 Integration Log,人工触发动作另写 Audit Event。【主:Application + Adapter|辅:Scheduler/Query|边界:轮询配置与人工动作|不迁移:删除手动任务表和普通运行查询|验证:配置/触发/拒绝、任务 ledger 保留、Integration/Audit 分界和 actor 核对通过,LSP 无诊断】 - [ ] 8.6 迁移支付、运营商实名和企微等外部 Callback 中实际改变内部事实的入口,使用 external actor;无状态变化只保留 Integration Log。【主:Application/Infrastructure Adapter|边界:当前外部回调|不迁移:渠道协议|验证:逐回调 action、幂等、资源解析、Integration/Audit 分界和链路核对通过,LSP 无诊断】 - [ ] 8.7 迁移当前 Worker 中实际改变内部业务事实的入口,使用 system_task actor 并传播 correlation/parent;纯投递或技术装配按清单 N/A。【主:Application/Asynq|边界:当前 Worker 写入口|不迁移:Scheduler 与 Callback|验证:逐 Worker 覆盖、幂等、失败重试和未登记动作门禁 PASS,LSP 无诊断】 - [ ] 8.8 迁移当前 Scheduler 中实际创建任务、改变配置/状态或产生业务事实的入口,使用 scheduled_job actor;实施时以当前清单为准,不硬编码历史数量。【主:Application/Scheduler|边界:当前计划任务|不迁移:Worker 消费逻辑|验证:逐 Scheduler 覆盖、重复调度幂等、父子链路和 N/A 理由 PASS,LSP 无诊断】 diff --git a/openspec/changes/fix-package-activation-starvation/design.md b/openspec/changes/fix-package-activation-starvation/design.md index 95b6114..89bebcf 100644 --- a/openspec/changes/fix-package-activation-starvation/design.md +++ b/openspec/changes/fix-package-activation-starvation/design.md @@ -4,7 +4,7 @@ 七月迭代分支虽然仍保留旧 `enqueueActivationTask`,但已经具备更合适的本地接续边界:`ActivationService.ActivateNextPendingMainPackage` 负责载体锁、队首选择、条款快照、状态事务和提交后复机;实际激活事务还会原子追加 `card.observation.series.requested` Outbox。套餐状态推进本身是本地数据库用例,不需要再经过一次 Asynq 才能执行。 -本设计仅适用于 `Iteration/7-11`,不包含任何 `main` 分支兼容、纯 Asynq 热修或跨分支移植决策。 +本设计仅适用于 `Iteration/7-11`,不包含跨分支兼容或移植决策。 ## Goals / Non-Goals @@ -18,7 +18,7 @@ **Non-Goals:** -- 不涉及 `main`,不生成可向 `main` cherry-pick 的修复提交。 +- 不提供跨分支兼容或移植方案。 - 不新增套餐激活 Outbox 事件、消费者、迁移或队列类型。 - 不删除仍可能被其他旧入口使用的 `TaskTypePackageQueueActivation` 和 Handler;只停止过期接续与孤儿恢复继续走该路径。 - 不修改购买、优先级分配、实名激活、流量扣减、退款失效和停复机规则。 @@ -106,12 +106,12 @@ Outbox 写入失败时套餐激活事务回滚,避免状态已生效但后续 1. 在 `Iteration/7-11` 实施真实孤儿查询和同步接续调用。 2. 更新审计覆盖基线与功能总结,执行格式化、静态检查和 `go build ./...`。 3. 用只读 SQL/查询计划验证候选公平性,用运行日志和数据库事实核验同步接续及卡观测 Outbox。 -4. 形成七月分支专属 Lore commit;该提交不面向 `main` 移植。 +4. 复核七月分支专属差异并保留在当前工作树,不创建提交。 5. 发布七月迭代时观察至少两个轮询周期,确认真实孤儿收敛且卡观测 Outbox 正常投递。 ### 回滚 -- 无数据库迁移,回滚七月专属修复提交并重新部署 Worker。 +- 无数据库迁移,发布后如需回滚,则回退七月专属修复代码并重新部署 Worker。 - 已正确激活的套餐保持业务事实,不执行反向 SQL。 - 回滚后出现孤儿时,使用带状态条件的单卡修复 SQL逐条处理。 diff --git a/openspec/changes/fix-package-activation-starvation/proposal.md b/openspec/changes/fix-package-activation-starvation/proposal.md index fc8401f..2eed731 100644 --- a/openspec/changes/fix-package-activation-starvation/proposal.md +++ b/openspec/changes/fix-package-activation-starvation/proposal.md @@ -25,7 +25,7 @@ ## Impact -- **适用分支**:仅 `Iteration/7-11`;本 Change 不描述、不实施、不引用 `main` 线上热修。 +- **适用分支**:仅 `Iteration/7-11`,按当前分支的同步接续与卡观测 Outbox 架构独立实施。 - **架构通道**:主通道为套餐生命周期复杂写,沿用 `Polling Handler → Package Activation Service → GORM transaction`;辅助通道为 PostgreSQL 候选查询、Redis 载体锁和既有卡观测 Outbox。完整边界只覆盖“旧套餐过期后接续队首套餐及孤儿恢复”,不迁移购买、实名、流量、退款和停复机用例。 - **主要代码**:`internal/polling/package_activation_handler.go`、必要时最小调整 `internal/service/package/activation_service.go` 的结果日志;复用现有 `internal/infrastructure/cardobservation/series_event.go` 和公共 Outbox 装配。 - **数据库**:不新增表、字段、索引或迁移;只调整现有 `tb_package_usage` 查询和调用顺序。 diff --git a/openspec/changes/fix-package-activation-starvation/tasks.md b/openspec/changes/fix-package-activation-starvation/tasks.md index 40ce97d..de62f02 100644 --- a/openspec/changes/fix-package-activation-starvation/tasks.md +++ b/openspec/changes/fix-package-activation-starvation/tasks.md @@ -1,29 +1,29 @@ ## 0. 七月分支基线与边界 -- [ ] 0.1 在 `Iteration/7-11` 记录套餐激活相关代码、现有卡观测 Outbox 装配和工作树状态,确认本 Change 只覆盖“旧套餐过期后接续队首套餐及孤儿恢复”,不涉及 `main`、线上纯 Asynq 热修或其他套餐用例。验证:相关文件差异摘要不包含跨分支移植计划。 -- [ ] 0.2 保存生产故障只读基线 `100/100/0`,准备真实孤儿候选 SQL和 `EXPLAIN (ANALYZE, BUFFERS)`;所有 SQL 仅允许读取。验证:SQL 能区分占位记录、真实孤儿和每个载体的稳定队首。 -- [ ] 0.3 增量更新 `.scratch/tech-global-audit/审计覆盖基线.md`:Audit Event N/A、Domain Ledger N/A、Integration Log N/A,Outbox 明确复用 `card.observation.series.requested` 且不新增事件类型。验证:四类公共能力均有明确决定。 +- [x] 0.1 在 `Iteration/7-11` 记录套餐激活相关代码、现有卡观测 Outbox 装配和工作树状态,确认本 Change 只覆盖“旧套餐过期后接续队首套餐及孤儿恢复”,不涉及其他套餐用例。验证:相关文件差异摘要不包含跨分支移植计划。 +- [x] 0.2 保存生产故障只读基线 `100/100/0`,准备真实孤儿候选 SQL和 `EXPLAIN (ANALYZE, BUFFERS)`;所有 SQL 仅允许读取。验证:SQL 能区分占位记录、真实孤儿和每个载体的稳定队首。 +- [x] 0.3 增量更新 `.scratch/tech-global-audit/审计覆盖基线.md`:Audit Event N/A、Domain Ledger N/A、Integration Log N/A,Outbox 明确复用 `card.observation.series.requested` 且不新增事件类型。验证:四类公共能力均有明确决定。 ## 1. 真实孤儿公平恢复切片 -- [ ] 1.1 修改 `findAndActivateOrphanPackages`:通过 GORM 执行 PostgreSQL CTE/窗口查询,先按卡或设备选择 `priority ASC, created_at ASC, id ASC` 的唯一队首,再用 `NOT EXISTS` 排除 `status IN (1,2)` 的占位载体,最后限制 100 个真实孤儿;删除逐条 `Count` 和 Go map 二次分组。验证:只读 SQL 在前 100 条全部被占位时仍返回窗口后的真实孤儿,同一载体只返回一条队首。 -- [ ] 1.2 对每个真实孤儿直接调用 `ActivateNextPendingMainPackage`,根据 `activated bool` 和错误记录实际结果,不调用 `enqueueActivationTask`。验证:孤儿路径不存在 `package:queue:activation` 投递,锁冲突和条件未满足不记录成功。 +- [x] 1.1 修改 `findAndActivateOrphanPackages`:通过 GORM 执行 PostgreSQL CTE/窗口查询,先按卡或设备选择 `priority ASC, created_at ASC, id ASC` 的唯一队首,再用 `NOT EXISTS` 排除 `status IN (1,2)` 的占位载体,最后限制 100 个真实孤儿;删除逐条 `Count` 和 Go map 二次分组。验证:只读 SQL 在前 100 条全部被占位时仍返回窗口后的真实孤儿,同一载体只返回一条队首。 +- [x] 1.2 对每个真实孤儿直接调用 `ActivateNextPendingMainPackage`,根据 `activated bool` 和错误记录实际结果,不调用 `enqueueActivationTask`。验证:孤儿路径不存在 `package:queue:activation` 投递,锁冲突和条件未满足不记录成功。 - [ ] 1.3 执行候选 SQL和 `EXPLAIN (ANALYZE, BUFFERS)`,记录真实孤儿数量、执行耗时和扫描行数;没有性能证据时不新增索引。验证:候选查询无逐载体 N+1,单轮最多返回 100 个载体。 ## 2. 过期接续与 Outbox 一致性切片 -- [ ] 2.1 修改 `processExpiredPackage`:事务内只提交旧主套餐过期和关联加油包失效,事务成功后直接调用 `ActivateNextPendingMainPackage`;删除该路径对 `activateNextPackage/enqueueActivationTask` 的调用。验证:旧状态未提交时不会执行新套餐激活,提交后进程退出可由孤儿扫描恢复。 -- [ ] 2.2 核对同步接续成功事务继续调用 `appendActivationObservation`,并由现有 Writer 原子追加 `card.observation.series.requested`;不得新增套餐激活 Outbox 事件或消费者。验证:激活状态和卡观测事件同事务成功或回滚,稳定事件 ID仍基于 package usage ID。 -- [ ] 2.3 收紧轮询日志:仅 `activated=true` 记录本轮成功;锁冲突、无队首、占位变化、等待实名和幂等分别记录明确结果。验证:日志包含载体类型、载体 ID、套餐使用记录或可定位的候选信息和触发来源。 +- [x] 2.1 修改 `processExpiredPackage`:事务内只提交旧主套餐过期和关联加油包失效,事务成功后直接调用 `ActivateNextPendingMainPackage`;删除该路径对 `activateNextPackage/enqueueActivationTask` 的调用。验证:旧状态未提交时不会执行新套餐激活,提交后进程退出可由孤儿扫描恢复。 +- [x] 2.2 核对同步接续成功事务继续调用 `appendActivationObservation`,并由现有 Writer 原子追加 `card.observation.series.requested`;不得新增套餐激活 Outbox 事件或消费者。验证:激活状态和卡观测事件同事务成功或回滚,稳定事件 ID仍基于 package usage ID。 +- [x] 2.3 收紧轮询日志:仅 `activated=true` 记录本轮成功;锁冲突、无队首、占位变化、等待实名和幂等分别记录明确结果。验证:日志包含载体类型、载体 ID、套餐使用记录或可定位的候选信息和触发来源。 ## 3. 七月分支验证与文档 - [ ] 3.1 对受影响 Go 文件执行 `gofmt`、现有静态检查和 `go build ./...`;按用户要求不新增、修改或运行自动化测试。验证:全量构建退出码为 0,无临时调试标记。 - [ ] 3.2 使用只读数据库事实核验:真实孤儿进入候选、同步接续后队首变为 `status=1`、同一载体无第二条生效主套餐、对应卡观测 Outbox 与激活事务一致。验证:保存 SQL、预期和实际结果,不执行批量修复。 -- [ ] 3.3 新建 `docs/feature-505-package-activation-recovery/功能总结.md` 并更新 README 功能索引,记录七月专属架构、根因、SQL、Outbox 一致性、发布观察、回滚和未执行自动化测试声明。验证:文档不出现 `main`、cherry-pick 或纯 Asynq 热修步骤。 -- [ ] 3.4 复核七月分支差异并使用中文 Lore commit 提交修复。验证:提交不包含线上 `main` 提案或实现,可独立 revert,`openspec validate fix-package-activation-starvation --strict` 通过。 +- [x] 3.3 新建 `docs/feature-505-package-activation-recovery/功能总结.md` 并更新 README 功能索引,记录七月专属架构、根因、SQL、Outbox 一致性、发布观察、回滚和未执行自动化测试声明。验证:文档只描述当前分支架构和发布步骤。 +- [x] 3.4 复核七月分支差异并保留在当前工作树,不创建提交。验证:差异仅包含本 Change,`openspec validate fix-package-activation-starvation --strict` 通过。 ## 4. 七月迭代发布观察 - [ ] 4.1 部署前记录真实孤儿数量、最老等待时间、同载体重复生效检查和卡观测 Outbox 待投递状态;部署后观察至少两个轮询周期。验证:真实孤儿持续收敛、无重复生效、Outbox 正常投递。 -- [ ] 4.2 准备回滚说明:无数据库迁移,仅 revert 七月专属修复提交并重新部署 Worker;已正确激活的套餐不反向修改。验证:说明包含观察停止条件和带状态保护的单卡人工恢复边界。 +- [x] 4.2 准备回滚说明:无数据库迁移,发布后仅回退七月专属修复代码并重新部署 Worker;已正确激活的套餐不反向修改。验证:说明包含观察停止条件和带状态保护的单卡人工恢复边界。 diff --git a/pkg/constants/approval.go b/pkg/constants/approval.go index 47fef1f..e29ebc1 100644 --- a/pkg/constants/approval.go +++ b/pkg/constants/approval.go @@ -108,6 +108,15 @@ const ( ApprovalPreparationTTL = 30 * time.Second ) +const ( + // ApprovalAuditActorWeCom 表示企业微信外部审批系统。 + ApprovalAuditActorWeCom = "wecom" + // ApprovalAuditActorSubmissionWorker 表示企业微信审批提交 Worker。 + ApprovalAuditActorSubmissionWorker = "wecom_approval_submission" + // ApprovalAuditActorRecoveryJob 表示企业微信审批主动恢复计划任务。 + ApprovalAuditActorRecoveryJob = "wecom_approval_recovery" +) + const ( // ApprovalDecisionDeliveryPending 表示标准决策等待业务消费。 ApprovalDecisionDeliveryPending = 0 diff --git a/pkg/constants/audit.go b/pkg/constants/audit.go index 8b8b1bf..f020678 100644 --- a/pkg/constants/audit.go +++ b/pkg/constants/audit.go @@ -67,8 +67,268 @@ const ( AuditActionPersonalCustomerPhoneChanged = "personal_customer.change_phone" // AuditActionPersonalCustomerWechatIdentityUpdated 表示创建或同步个人客户微信主体。 AuditActionPersonalCustomerWechatIdentityUpdated = "personal_customer.update_wechat_identity" + // AuditActionPersonalCustomerAssetBound 表示个人客户绑定资产。 + AuditActionPersonalCustomerAssetBound = "personal_customer.bind_asset" + // AuditActionPersonalCustomerAssetUnbound 表示解除个人客户资产绑定。 + AuditActionPersonalCustomerAssetUnbound = "personal_customer.unbind_asset" + // AuditActionPersonalCustomerAssetBindingMigrated 表示换货迁移个人客户资产绑定。 + AuditActionPersonalCustomerAssetBindingMigrated = "personal_customer.migrate_asset_binding" + // AuditActionIotCardCreated 表示 IoT 卡实际创建落库。 + AuditActionIotCardCreated = "iot_card.create" + // AuditActionIotCardDeleted 表示删除单张 IoT 卡。 + AuditActionIotCardDeleted = "iot_card.delete" + // AuditActionIotCardBatchDeleted 表示批量删除 IoT 卡根事件。 + AuditActionIotCardBatchDeleted = "iot_card.batch_delete" + // AuditActionIotCardAllocationBatch 表示 IoT 卡分配批次根事件。 + AuditActionIotCardAllocationBatch = "iot_card.allocate_batch" + // AuditActionIotCardAllocated 表示单张 IoT 卡分配子事件。 + AuditActionIotCardAllocated = "iot_card.allocate" + // AuditActionIotCardRecallBatch 表示 IoT 卡回收批次根事件。 + AuditActionIotCardRecallBatch = "iot_card.recall_batch" + // AuditActionIotCardRecalled 表示单张 IoT 卡回收子事件。 + AuditActionIotCardRecalled = "iot_card.recall" + // AuditActionIotCardSeriesBindingBatch 表示 IoT 卡系列绑定批次根事件。 + AuditActionIotCardSeriesBindingBatch = "iot_card.series_binding_batch" + // AuditActionIotCardSeriesBound 表示单张 IoT 卡系列绑定子事件。 + AuditActionIotCardSeriesBound = "iot_card.series_binding" + // AuditActionIotCardSpeedTierSet 表示人工请求设置 IoT 卡固定限速档位。 + AuditActionIotCardSpeedTierSet = "iot_card.speed_tier_set" + // AuditActionIotCardRealnamePolicyBatchUpdated 表示批量更新 IoT 卡实名策略根事件。 + AuditActionIotCardRealnamePolicyBatchUpdated = "iot_card.realname_policy_batch_update" + // AuditActionIotCardRealnamePolicyUpdated 表示更新单张 IoT 卡实名策略。 + AuditActionIotCardRealnamePolicyUpdated = "iot_card.realname_policy_update" + // AuditActionIotCardRealnameStatusUpdated 表示人工更新 IoT 卡实名状态。 + AuditActionIotCardRealnameStatusUpdated = "iot_card.realname_status_update" + // AuditActionIotCardRealnameCallbackSynced 表示运营商回调同步 IoT 卡实名状态。 + AuditActionIotCardRealnameCallbackSynced = "iot_card.realname_callback_sync" + // AuditActionIotCardManualRefreshed 表示人工刷新 IoT 卡并同步实际变化的内部事实。 + AuditActionIotCardManualRefreshed = "iot_card.manual_refresh" + // AuditActionIotCardPersonalRefreshed 表示个人客户刷新名下 IoT 卡并同步实际变化的内部事实。 + AuditActionIotCardPersonalRefreshed = "iot_card.personal_refresh" + // AuditActionIotCardManualStopped 表示人工停用 IoT 卡网络。 + AuditActionIotCardManualStopped = "iot_card.manual_stop" + // AuditActionIotCardManualStarted 表示人工恢复 IoT 卡网络。 + AuditActionIotCardManualStarted = "iot_card.manual_start" + // AuditActionIotCardAutoStopped 表示系统任务自动停用 IoT 卡网络。 + AuditActionIotCardAutoStopped = "iot_card.auto_stop" + // AuditActionIotCardAutoStarted 表示系统任务自动恢复 IoT 卡网络。 + AuditActionIotCardAutoStarted = "iot_card.auto_start" + // AuditActionIotCardOpenAPIStarted 表示代理 OpenAPI 恢复机卡分离 IoT 卡网络。 + AuditActionIotCardOpenAPIStarted = "iot_card.openapi_start" + // AuditActionIotCardAutoStopReasonUpdated 表示系统任务更新已停机卡的停机原因。 + AuditActionIotCardAutoStopReasonUpdated = "iot_card.auto_stop_reason_update" + // AuditActionDeviceCreated 表示设备实际创建落库。 + AuditActionDeviceCreated = "device.create" + // AuditActionDeviceDeleted 表示删除设备。 + AuditActionDeviceDeleted = "device.delete" + // AuditActionDeviceAllocationBatch 表示设备分配批次根事件。 + AuditActionDeviceAllocationBatch = "device.allocate_batch" + // AuditActionDeviceAllocated 表示单台设备分配子事件。 + AuditActionDeviceAllocated = "device.allocate" + // AuditActionDeviceRecallBatch 表示设备回收批次根事件。 + AuditActionDeviceRecallBatch = "device.recall_batch" + // AuditActionDeviceRecalled 表示单台设备回收子事件。 + AuditActionDeviceRecalled = "device.recall" + // AuditActionDeviceSeriesBindingBatch 表示设备系列绑定批次根事件。 + AuditActionDeviceSeriesBindingBatch = "device.series_binding_batch" + // AuditActionDeviceSeriesBound 表示单台设备系列绑定子事件。 + AuditActionDeviceSeriesBound = "device.series_binding" + // AuditActionDeviceRealnamePolicyBatchUpdated 表示批量更新设备实名策略根事件。 + AuditActionDeviceRealnamePolicyBatchUpdated = "device.realname_policy_batch_update" + // AuditActionDeviceRealnamePolicyUpdated 表示更新单台设备实名策略。 + AuditActionDeviceRealnamePolicyUpdated = "device.realname_policy_update" + // AuditActionDeviceStopped 表示通过设备入口停用实际绑定卡网络。 + AuditActionDeviceStopped = "device.stop" + // AuditActionDeviceStarted 表示通过设备入口恢复实际绑定卡网络。 + AuditActionDeviceStarted = "device.start" + // AuditActionDeviceWiFiSet 表示设置设备 Wi-Fi。 + AuditActionDeviceWiFiSet = "device.set_wifi" + // AuditActionDeviceSwitchModeSet 表示设置设备切卡模式。 + AuditActionDeviceSwitchModeSet = "device.set_switch_mode" + // AuditActionDeviceRebooted 表示重启设备。 + AuditActionDeviceRebooted = "device.reboot" + // AuditActionDeviceReset 表示恢复设备出厂设置。 + AuditActionDeviceReset = "device.reset" + // AuditActionDeviceCardBound 表示设备绑定 IoT 卡。 + AuditActionDeviceCardBound = "device.bind_card" + // AuditActionDeviceCardUnbound 表示设备解绑 IoT 卡。 + AuditActionDeviceCardUnbound = "device.unbind_card" + // AuditActionDeviceCurrentCardSwitched 表示切换设备当前使用的 IoT 卡。 + AuditActionDeviceCurrentCardSwitched = "device.switch_current_card" + // AuditActionCardExchangeCreated 表示创建卡换货单。 + AuditActionCardExchangeCreated = "exchange.card.create" + // AuditActionCardExchangeShippingInfoSubmitted 表示个人客户提交卡换货收货信息。 + AuditActionCardExchangeShippingInfoSubmitted = "exchange.card.submit_shipping_info" + // AuditActionCardExchangeShipped 表示卡换货单已发货。 + AuditActionCardExchangeShipped = "exchange.card.ship" + // AuditActionCardExchangeCompleted 表示完成卡换货及其迁移。 + AuditActionCardExchangeCompleted = "exchange.card.complete" + // AuditActionCardExchangeCancelled 表示取消卡换货单。 + AuditActionCardExchangeCancelled = "exchange.card.cancel" + // AuditActionCardExchangeRenewed 表示将已换出的旧卡转为新卡状态。 + AuditActionCardExchangeRenewed = "exchange.card.renew" + // AuditActionDeviceExchangeCreated 表示创建设备换货单。 + AuditActionDeviceExchangeCreated = "exchange.device.create" + // AuditActionDeviceExchangeShippingInfoSubmitted 表示个人客户提交设备换货收货信息。 + AuditActionDeviceExchangeShippingInfoSubmitted = "exchange.device.submit_shipping_info" + // AuditActionDeviceExchangeShipped 表示设备换货单已发货。 + AuditActionDeviceExchangeShipped = "exchange.device.ship" + // AuditActionDeviceExchangeCompleted 表示完成设备换货及其迁移。 + AuditActionDeviceExchangeCompleted = "exchange.device.complete" + // AuditActionDeviceExchangeCancelled 表示取消设备换货单。 + AuditActionDeviceExchangeCancelled = "exchange.device.cancel" + // AuditActionDeviceExchangeRenewed 表示将已换出的旧设备转为新设备状态。 + AuditActionDeviceExchangeRenewed = "exchange.device.renew" + // AuditActionPackageSeriesCreated 表示创建套餐系列。 + AuditActionPackageSeriesCreated = "package_series.create" + // AuditActionPackageSeriesUpdated 表示更新套餐系列及关键佣金配置。 + AuditActionPackageSeriesUpdated = "package_series.update" + // AuditActionPackageSeriesDeleted 表示删除套餐系列。 + AuditActionPackageSeriesDeleted = "package_series.delete" + // AuditActionPackageSeriesStatusUpdated 表示更新套餐系列状态。 + AuditActionPackageSeriesStatusUpdated = "package_series.update_status" + // AuditActionPackageCreated 表示创建套餐商品。 + AuditActionPackageCreated = "package.create" + // AuditActionPackageUpdated 表示更新套餐商品及关键价格配置。 + AuditActionPackageUpdated = "package.update" + // AuditActionPackageDeleted 表示删除套餐商品。 + AuditActionPackageDeleted = "package.delete" + // AuditActionPackageStatusUpdated 表示更新套餐商品状态。 + AuditActionPackageStatusUpdated = "package.update_status" + // AuditActionPackageShelfStatusUpdated 表示更新套餐或店铺套餐上架状态。 + AuditActionPackageShelfStatusUpdated = "package.update_shelf_status" + // AuditActionShopPackageShelfStatusUpdated 表示更新店铺套餐上架状态。 + AuditActionShopPackageShelfStatusUpdated = "shop_package.update_shelf_status" + // AuditActionPackageRetailPriceUpdated 表示更新店铺套餐零售价。 + AuditActionPackageRetailPriceUpdated = "package.update_retail_price" + // AuditActionShopSeriesGrantCreated 表示创建店铺套餐系列授权。 + AuditActionShopSeriesGrantCreated = "shop_series_grant.create" + // AuditActionShopSeriesGrantUpdated 表示更新店铺套餐系列授权配置。 + AuditActionShopSeriesGrantUpdated = "shop_series_grant.update" + // AuditActionShopSeriesGrantPackagesManaged 表示管理店铺系列下的套餐授权。 + AuditActionShopSeriesGrantPackagesManaged = "shop_series_grant.manage_packages" + // AuditActionShopSeriesGrantDeleted 表示删除店铺套餐系列授权。 + AuditActionShopSeriesGrantDeleted = "shop_series_grant.delete" + // AuditActionShopPackageBatchAllocated 表示批量分配店铺套餐。 + AuditActionShopPackageBatchAllocated = "shop_package.batch_allocate" + // AuditActionShopPackageAllocated 表示分配单条店铺套餐。 + AuditActionShopPackageAllocated = "shop_package.allocate" + // AuditActionShopPackageExpiryBaseUpdated 表示更新店铺套餐生效条件覆盖。 + AuditActionShopPackageExpiryBaseUpdated = "shop_package.update_expiry_base" + // AuditActionShopPackageBatchPricingUpdated 表示批量更新店铺套餐成本价。 + AuditActionShopPackageBatchPricingUpdated = "shop_package.batch_update_pricing" + // AuditActionShopPackagePricingItemUpdated 表示更新单条店铺套餐成本价及价格历史。 + AuditActionShopPackagePricingItemUpdated = "shop_package.update_pricing_item" + // AuditActionPackageUsageActivated 表示激活套餐权益。 + AuditActionPackageUsageActivated = "package_usage.activate" + // AuditActionPackageUsageExpired 表示套餐权益到期及其加油包失效。 + AuditActionPackageUsageExpired = "package_usage.expire" + // AuditActionPackageUsageTrafficDeducted 表示扣减套餐权益流量。 + AuditActionPackageUsageTrafficDeducted = "package_usage.deduct_traffic" + // AuditActionPackageUsageTrafficReset 表示重置套餐权益流量。 + AuditActionPackageUsageTrafficReset = "package_usage.reset_traffic" + // AuditActionPackageUsageRefundInvalidated 表示退款导致套餐权益失效。 + AuditActionPackageUsageRefundInvalidated = "package_usage.invalidate_refund" + // AuditActionPackageUsageAssetInvalidated 表示按资产失效套餐权益。 + AuditActionPackageUsageAssetInvalidated = "package_usage.invalidate_asset" + // AuditActionOrderCreated 表示创建套餐订单。 + AuditActionOrderCreated = "order.create" + // AuditActionOrderCancelled 表示人工取消待支付订单。 + AuditActionOrderCancelled = "order.cancel" + // AuditActionOrderWalletPaid 表示使用钱包支付待支付订单。 + AuditActionOrderWalletPaid = "order.wallet_pay" + // AuditActionOrderExpiredClosed 表示计划任务关闭过期待支付订单。 + AuditActionOrderExpiredClosed = "order.expire_close" + // AuditActionOrderOnlinePaid 表示第三方支付确认旧订单已支付。 + AuditActionOrderOnlinePaid = "order.online_pay" + // AuditActionAgentWalletOrderDebited 表示代理主钱包完成订单扣款。 + AuditActionAgentWalletOrderDebited = "agent_wallet.order_debit" + // AuditActionAgentWalletOrderReserved 表示代理主钱包为订单预占资金。 + AuditActionAgentWalletOrderReserved = "agent_wallet.order_reserve" + // AuditActionAgentWalletOrderReleased 表示代理主钱包释放订单预占资金。 + AuditActionAgentWalletOrderReleased = "agent_wallet.order_release" + // AuditActionAgentWalletOrderCompleted 表示代理主钱包完成订单预占扣款。 + AuditActionAgentWalletOrderCompleted = "agent_wallet.order_complete" + // AuditActionAgentWalletBalanceAdjusted 表示人工调整代理主钱包余额。 + AuditActionAgentWalletBalanceAdjusted = "agent_wallet.adjust_balance" + // AuditActionAgentWalletCreditChanged 表示调整代理主钱包实际信用额度。 + AuditActionAgentWalletCreditChanged = "agent_wallet.change_credit" + // AuditActionPaymentCreated 表示创建第三方支付记录。 + AuditActionPaymentCreated = "payment.create" + // AuditActionPaymentConfirmed 表示外部回调或主动查单确认支付成功。 + AuditActionPaymentConfirmed = "payment.confirm" + // AuditActionPaymentFailed 表示支付外部处理失败后关闭支付记录。 + AuditActionPaymentFailed = "payment.fail" + // AuditActionAgentRechargeCreated 表示创建代理充值申请。 + AuditActionAgentRechargeCreated = "agent_recharge.create" + // AuditActionAgentRechargeCredited 表示代理充值资金已入账。 + AuditActionAgentRechargeCredited = "agent_recharge.credit" + // AuditActionAgentRechargeClosed 表示代理充值申请被拒绝或关闭。 + AuditActionAgentRechargeClosed = "agent_recharge.close" + // AuditActionAssetRechargeAutoPurchased 表示资产充值后自动购包完成。 + AuditActionAssetRechargeAutoPurchased = "asset_recharge.auto_purchase" + // AuditActionRefundCreated 表示提交退款申请并创建审批实例。 + AuditActionRefundCreated = "refund.create" + // AuditActionRefundApproved 表示退款审批通过并完成退款资金处理。 + AuditActionRefundApproved = "refund.approve" + // AuditActionRefundRejected 表示退款审批拒绝或关闭。 + AuditActionRefundRejected = "refund.reject" + // AuditActionRefundReturned 表示平台退回退款申请。 + AuditActionRefundReturned = "refund.return" + // AuditActionRefundResubmitted 表示重新提交已退回的退款申请。 + AuditActionRefundResubmitted = "refund.resubmit" + // AuditActionRefundCommissionInvalidated 表示退款导致单条已入账佣金失效并回扣。 + AuditActionRefundCommissionInvalidated = "refund.invalidate_commission" + // AuditActionRefundAssetProcessed 表示退款后的套餐与资产处理已完成。 + AuditActionRefundAssetProcessed = "refund.process_asset" + // AuditActionApprovalRequested 表示创建通用审批实例并请求渠道提交。 + AuditActionApprovalRequested = "approval.request" + // AuditActionApprovalSubmissionSynced 表示同步审批渠道提交结果。 + AuditActionApprovalSubmissionSynced = "approval.sync_submission" + // AuditActionApprovalSubmissionRecovered 表示主动恢复结果未知的审批提交。 + AuditActionApprovalSubmissionRecovered = "approval.recover_submission" + // AuditActionApprovalDecisionSynced 表示同步审批渠道权威终态。 + AuditActionApprovalDecisionSynced = "approval.sync_decision" + // AuditActionCommissionCalculated 表示完成订单佣金计算。 + AuditActionCommissionCalculated = "commission.calculate" + // AuditActionCommissionCredited 表示佣金记录已入账。 + AuditActionCommissionCredited = "commission.credit" + // AuditActionCommissionInvalidated 表示人工将待审佣金记录标记为失效。 + AuditActionCommissionInvalidated = "commission.invalidate" + // AuditActionCommissionWithdrawalRequested 表示提交佣金提现申请。 + AuditActionCommissionWithdrawalRequested = "commission_withdrawal.request" + // AuditActionCommissionWithdrawalApproved 表示通过佣金提现申请。 + AuditActionCommissionWithdrawalApproved = "commission_withdrawal.approve" + // AuditActionCommissionWithdrawalRejected 表示驳回佣金提现申请。 + AuditActionCommissionWithdrawalRejected = "commission_withdrawal.reject" // AuditActionSystemConfigUpdated 表示更新受控系统配置。 AuditActionSystemConfigUpdated = "system_config.updated" + // AuditActionPaymentConfigCreated 表示创建支付连接配置。 + AuditActionPaymentConfigCreated = "payment_config.create" + // AuditActionPaymentConfigUpdated 表示更新支付连接配置。 + AuditActionPaymentConfigUpdated = "payment_config.update" + // AuditActionPaymentConfigDeleted 表示删除支付连接配置。 + AuditActionPaymentConfigDeleted = "payment_config.delete" + // AuditActionPaymentConfigActivated 表示激活支付连接配置。 + AuditActionPaymentConfigActivated = "payment_config.activate" + // AuditActionPaymentConfigDeactivated 表示停用支付连接配置。 + AuditActionPaymentConfigDeactivated = "payment_config.deactivate" + // AuditActionCarrierCreated 表示创建运营商配置。 + AuditActionCarrierCreated = "carrier.create" + // AuditActionCarrierUpdated 表示更新运营商配置。 + AuditActionCarrierUpdated = "carrier.update" + // AuditActionCarrierDeleted 表示删除运营商配置。 + AuditActionCarrierDeleted = "carrier.delete" + // AuditActionCarrierStatusUpdated 表示更新运营商配置状态。 + AuditActionCarrierStatusUpdated = "carrier.update_status" + // AuditActionWeComApplicationSaved 表示保存企业微信应用配置。 + AuditActionWeComApplicationSaved = "wecom.application.save" + // AuditActionWeComDefaultCreatorSaved 表示保存企业微信默认审批发起人。 + AuditActionWeComDefaultCreatorSaved = "wecom.application.save_default_creator" + // AuditActionWeComMembersSynced 表示同步企业微信应用可见成员。 + AuditActionWeComMembersSynced = "wecom.application.sync_members" + // AuditActionWeComApprovalSceneSaved 表示保存企业微信审批场景配置。 + AuditActionWeComApprovalSceneSaved = "wecom.approval_scene.save" // AuditActionOutboxReplayed 表示人工重放 Outbox 事件。 AuditActionOutboxReplayed = "outbox.replayed" // AuditActionOutboxExpiredLeaseReleased 表示人工释放 Outbox 过期租约。 @@ -77,6 +337,64 @@ const ( AuditActionDeviceBatchAllocationCompleted = "device.batch_allocation.completed" // AuditActionDeviceBatchAllocationItem 表示设备批量分配资源子事件。 AuditActionDeviceBatchAllocationItem = "device.batch_allocation.item" + // AuditActionIotCardImportTaskCreated 表示创建 IoT 卡导入任务。 + AuditActionIotCardImportTaskCreated = "iot_card_import_task.create" + // AuditActionIotCardImportTaskCompleted 表示 IoT 卡导入任务完成。 + AuditActionIotCardImportTaskCompleted = "iot_card_import_task.complete" + // AuditActionDeviceImportTaskCreated 表示创建设备导入或批量操作任务。 + AuditActionDeviceImportTaskCreated = "device_import_task.create" + // AuditActionDeviceImportTaskCompleted 表示设备导入或批量操作任务完成。 + AuditActionDeviceImportTaskCompleted = "device_import_task.complete" + // AuditActionAssetPackageBatchOrderTaskCreated 表示创建资产套餐批量订购任务。 + AuditActionAssetPackageBatchOrderTaskCreated = "asset_package_batch_order_task.create" + // AuditActionAssetPackageBatchOrderTaskCompleted 表示资产套餐批量订购任务完成。 + AuditActionAssetPackageBatchOrderTaskCompleted = "asset_package_batch_order_task.complete" + // AuditActionOrderPackageInvalidateTaskCreated 表示创建订单套餐批量失效任务。 + AuditActionOrderPackageInvalidateTaskCreated = "order_package_invalidate_task.create" + // AuditActionOrderPackageInvalidateTaskCompleted 表示订单套餐批量失效任务完成。 + AuditActionOrderPackageInvalidateTaskCompleted = "order_package_invalidate_task.complete" + // AuditActionOrderPackageInvalidateItem 表示单个订单的套餐权益批量失效结果。 + AuditActionOrderPackageInvalidateItem = "order_package_invalidate_task.item" + // AuditActionExportTaskCreated 表示创建业务导出任务。 + AuditActionExportTaskCreated = "export_task.create" + // AuditActionExportTaskCancelled 表示取消业务导出任务或提交取消请求。 + AuditActionExportTaskCancelled = "export_task.cancel" + // AuditActionNotificationDelivered 表示 Outbox 消费后实际生成站内通知。 + AuditActionNotificationDelivered = "notification.deliver" + // AuditActionNotificationRead 表示单条通知首次标记已读。 + AuditActionNotificationRead = "notification.read" + // AuditActionNotificationReadAll 表示批量标记通知已读。 + AuditActionNotificationReadAll = "notification.read_all" + // AuditActionNotificationCleanup 表示系统清理过期通知批次。 + AuditActionNotificationCleanup = "notification.cleanup" + // AuditActionNotificationCleanupItem 表示系统清理单条过期通知。 + AuditActionNotificationCleanupItem = "notification.cleanup_item" + // AuditActionPollingConfigCreated 表示创建轮询配置。 + AuditActionPollingConfigCreated = "polling_config.create" + // AuditActionPollingConfigUpdated 表示更新轮询配置。 + AuditActionPollingConfigUpdated = "polling_config.update" + // AuditActionPollingConfigDeleted 表示删除轮询配置。 + AuditActionPollingConfigDeleted = "polling_config.delete" + // AuditActionPollingConfigStatusUpdated 表示启用或禁用轮询配置。 + AuditActionPollingConfigStatusUpdated = "polling_config.update_status" + // AuditActionPollingConcurrencyUpdated 表示更新轮询任务并发配置。 + AuditActionPollingConcurrencyUpdated = "polling_concurrency.update" + // AuditActionPollingConcurrencyReset 表示人工重置轮询并发计数。 + AuditActionPollingConcurrencyReset = "polling_concurrency.reset" + // AuditActionPollingAlertRuleCreated 表示创建轮询告警规则。 + AuditActionPollingAlertRuleCreated = "polling_alert.create_rule" + // AuditActionPollingAlertRuleUpdated 表示更新轮询告警规则。 + AuditActionPollingAlertRuleUpdated = "polling_alert.update_rule" + // AuditActionPollingAlertRuleDeleted 表示删除轮询告警规则。 + AuditActionPollingAlertRuleDeleted = "polling_alert.delete_rule" + // AuditActionPollingManualTriggerSingle 表示人工触发单卡轮询任务。 + AuditActionPollingManualTriggerSingle = "polling_manual_trigger.trigger_single" + // AuditActionPollingManualTriggerBatch 表示人工批量触发轮询任务。 + AuditActionPollingManualTriggerBatch = "polling_manual_trigger.trigger_batch" + // AuditActionPollingManualTriggerByCondition 表示人工按条件触发轮询任务。 + AuditActionPollingManualTriggerByCondition = "polling_manual_trigger.trigger_by_condition" + // AuditActionPollingManualCancelled 表示人工取消轮询任务。 + AuditActionPollingManualCancelled = "polling_manual_trigger.cancel_trigger" // AuditActionWeComCredentialsRead 表示读取企业微信应用明文凭据。 AuditActionWeComCredentialsRead = "wecom.application.credentials_read" // AuditActionRoleCreated 表示创建角色。 @@ -103,6 +421,32 @@ const ( AuditActionPermissionDeleted = "permission.delete" // AuditOperationSystemConfigUpdate 表示系统配置旧接缝传入的操作类型。 AuditOperationSystemConfigUpdate = "system_config_update" + // AuditOperationPaymentConfigCreate 表示创建支付连接配置。 + AuditOperationPaymentConfigCreate = "payment_config_create" + // AuditOperationPaymentConfigUpdate 表示更新支付连接配置。 + AuditOperationPaymentConfigUpdate = "payment_config_update" + // AuditOperationPaymentConfigDelete 表示删除支付连接配置。 + AuditOperationPaymentConfigDelete = "payment_config_delete" + // AuditOperationPaymentConfigActivate 表示激活支付连接配置。 + AuditOperationPaymentConfigActivate = "payment_config_activate" + // AuditOperationPaymentConfigDeactivate 表示停用支付连接配置。 + AuditOperationPaymentConfigDeactivate = "payment_config_deactivate" + // AuditOperationCarrierCreate 表示创建运营商配置。 + AuditOperationCarrierCreate = "carrier_create" + // AuditOperationCarrierUpdate 表示更新运营商配置。 + AuditOperationCarrierUpdate = "carrier_update" + // AuditOperationCarrierDelete 表示删除运营商配置。 + AuditOperationCarrierDelete = "carrier_delete" + // AuditOperationCarrierStatusUpdate 表示更新运营商配置状态。 + AuditOperationCarrierStatusUpdate = "carrier_status_update" + // AuditOperationWeComApplicationSave 表示保存企业微信应用配置。 + AuditOperationWeComApplicationSave = "wecom_application_save" + // AuditOperationWeComDefaultCreatorSave 表示保存企业微信默认审批发起人。 + AuditOperationWeComDefaultCreatorSave = "wecom_default_creator_save" + // AuditOperationWeComMembersSync 表示同步企业微信应用可见成员。 + AuditOperationWeComMembersSync = "wecom_members_sync" + // AuditOperationWeComApprovalSceneSave 表示保存企业微信审批场景配置。 + AuditOperationWeComApprovalSceneSave = "wecom_approval_scene_save" // AuditOperationOutboxReplay 表示 Outbox 人工重放接缝操作类型。 AuditOperationOutboxReplay = "outbox_replay" // AuditOperationOutboxReleaseExpiredLease 表示 Outbox 人工释放过期租约接缝操作类型。 @@ -112,10 +456,42 @@ const ( const ( // AuditResourceSystemConfig 表示受控系统配置资源。 AuditResourceSystemConfig = "system_config" + // AuditResourcePaymentConfig 表示支付连接配置资源。 + AuditResourcePaymentConfig = "payment_config" + // AuditResourceCarrier 表示运营商配置资源。 + AuditResourceCarrier = "carrier" + // AuditResourceWeComApprovalScene 表示企业微信审批场景配置资源。 + AuditResourceWeComApprovalScene = "wecom_approval_scene" // AuditResourceOutboxEvent 表示公共 Outbox 事件资源。 AuditResourceOutboxEvent = "outbox_event" + // AuditResourceIntegrationLog 表示外部集成日志资源。 + AuditResourceIntegrationLog = "integration_log" // AuditResourceDeviceBatchTask 表示设备批量分配任务资源。 AuditResourceDeviceBatchTask = "device_batch_task" + // AuditResourceIotCardImportTask 表示 IoT 卡导入任务资源。 + AuditResourceIotCardImportTask = "iot_card_import_task" + // AuditResourceDeviceImportTask 表示设备导入或批量操作任务资源。 + AuditResourceDeviceImportTask = "device_import_task" + // AuditResourceAssetPackageBatchOrderTask 表示资产套餐批量订购任务资源。 + AuditResourceAssetPackageBatchOrderTask = "asset_package_batch_order_task" + // AuditResourceOrderPackageInvalidateTask 表示订单套餐批量失效任务资源。 + AuditResourceOrderPackageInvalidateTask = "order_package_invalidate_task" + // AuditResourceExportTask 表示业务导出任务资源。 + AuditResourceExportTask = "export_task" + // AuditResourceNotification 表示站内通知资源。 + AuditResourceNotification = "notification" + // AuditResourceNotificationReadBatch 表示通知批量已读资源。 + AuditResourceNotificationReadBatch = "notification_read_batch" + // AuditResourceNotificationCleanupBatch 表示通知清理批次资源。 + AuditResourceNotificationCleanupBatch = "notification_cleanup_batch" + // AuditResourcePollingConfig 表示轮询配置资源。 + AuditResourcePollingConfig = "polling_config" + // AuditResourcePollingConcurrencyConfig 表示轮询并发配置资源。 + AuditResourcePollingConcurrencyConfig = "polling_concurrency" + // AuditResourcePollingAlertRule 表示轮询告警规则资源。 + AuditResourcePollingAlertRule = "polling_alert" + // AuditResourcePollingManualTrigger 表示手动轮询任务资源。 + AuditResourcePollingManualTrigger = "polling_manual_trigger" // AuditResourceDevice 表示设备资源。 AuditResourceDevice = "device" // AuditResourceIotCard 表示 IoT 卡资源。 @@ -138,14 +514,44 @@ const ( AuditResourceDeviceSIMBinding = "device_sim_binding" // AuditResourceAssetAllocationRecord 表示资产分配记录资源。 AuditResourceAssetAllocationRecord = "asset_allocation_record" + // AuditResourcePackageSeries 表示套餐系列资源。 + AuditResourcePackageSeries = "package_series" + // AuditResourcePackage 表示套餐商品资源。 + AuditResourcePackage = "package" + // AuditResourceShopSeriesAllocation 表示店铺套餐系列授权资源。 + AuditResourceShopSeriesAllocation = "shop_series_allocation" + // AuditResourceShopPackageAllocation 表示店铺套餐授权及价格配置资源。 + AuditResourceShopPackageAllocation = "shop_package_allocation" + // AuditResourceShopPackagePriceHistory 表示店铺套餐价格历史资源。 + AuditResourceShopPackagePriceHistory = "shop_package_price_history" + // AuditResourcePackageConfigBatch 表示套餐配置批次根资源。 + AuditResourcePackageConfigBatch = "package_config_batch" // AuditResourceExchangeOrder 表示换货单资源。 AuditResourceExchangeOrder = "exchange_order" // AuditResourceAgentRecharge 表示代理充值单资源。 AuditResourceAgentRecharge = "agent_recharge" + // AuditResourceRechargeOrder 表示个人资产充值单资源。 + AuditResourceRechargeOrder = "recharge_order" // AuditResourceAssetWallet 表示资产钱包资源。 AuditResourceAssetWallet = "asset_wallet" + // AuditResourceAssetWalletTransaction 表示资产钱包流水资源。 + AuditResourceAssetWalletTransaction = "asset_wallet_transaction" + // AuditResourceAgentWallet 表示代理主钱包资源。 + AuditResourceAgentWallet = "agent_wallet" + // AuditResourceAgentWalletTransaction 表示代理主钱包流水资源。 + AuditResourceAgentWalletTransaction = "agent_wallet_transaction" + // AuditResourceAgentWalletReservation 表示代理主钱包预占资源。 + AuditResourceAgentWalletReservation = "agent_wallet_reservation" + // AuditResourcePayment 表示支付记录资源。 + AuditResourcePayment = "payment" + // AuditResourcePackageUsage 表示套餐权益资源。 + AuditResourcePackageUsage = "package_usage" // AuditResourceApprovalInstance 表示审批实例资源。 AuditResourceApprovalInstance = "approval_instance" + // AuditResourceCommissionRecord 表示佣金记录资源。 + AuditResourceCommissionRecord = "commission_record" + // AuditResourceCommissionWithdrawal 表示佣金提现单资源。 + AuditResourceCommissionWithdrawal = "commission_withdrawal" // AuditResourceWeComApplication 表示企业微信应用配置资源。 AuditResourceWeComApplication = "wecom_application" // AuditResourceAuthentication 表示不含 Token 或 Cookie 的认证状态资源。 @@ -160,6 +566,14 @@ const ( AuditResourcePersonalCustomerPhone = "personal_customer_phone" // AuditResourcePersonalCustomerOpenID 表示个人客户微信 OpenID 资源。 AuditResourcePersonalCustomerOpenID = "personal_customer_openid" + // AuditResourcePersonalCustomerDevice 表示个人客户设备号绑定资源。 + AuditResourcePersonalCustomerDevice = "personal_customer_device" + // AuditResourcePersonalCustomerICCID 表示个人客户 ICCID 绑定资源。 + AuditResourcePersonalCustomerICCID = "personal_customer_iccid" + // AuditResourceIotCardBatch 表示 IoT 卡批量操作根资源。 + AuditResourceIotCardBatch = "iot_card_batch" + // AuditResourceDeviceBatch 表示设备批量操作根资源。 + AuditResourceDeviceBatch = "device_batch" // AuditResourceRelationPrimary 表示事件的主要资源。 AuditResourceRelationPrimary = "primary" // AuditResourceRelationAffected 表示被本次动作改变的资源。 @@ -168,12 +582,30 @@ const ( AuditResourceRelationReference = "reference" // AuditResourceRoleConfig 表示配置资源角色。 AuditResourceRoleConfig = "config" + // AuditResourceRolePollingTarget 表示轮询配置、规则或人工任务主资源。 + AuditResourceRolePollingTarget = "polling_target" + // AuditResourceRolePollingCard 表示人工轮询任务关联的卡。 + AuditResourceRolePollingCard = "polling_card" // AuditResourceRoleRecoveryTarget 表示人工恢复裁决的目标事件。 AuditResourceRoleRecoveryTarget = "recovery_target" + // AuditResourceRoleApprovalTarget 表示通用审批链路的目标审批实例。 + AuditResourceRoleApprovalTarget = "approval_target" + // AuditResourceRoleApprovalBusiness 表示审批关联的业务单。 + AuditResourceRoleApprovalBusiness = "approval_business" + // AuditResourceRoleApprovalSubmitter 表示审批申请的真实提交账号。 + AuditResourceRoleApprovalSubmitter = "approval_submitter" + // AuditResourceRoleApprovalIntegration 表示审批链路对应的外部交互事实。 + AuditResourceRoleApprovalIntegration = "approval_integration" + // AuditResourceRoleCallbackIntegration 表示外部回调对应的集成交互事实。 + AuditResourceRoleCallbackIntegration = "callback_integration" + // AuditResourceRoleApprovalOutbox 表示审批链路对应的可靠 Outbox 事实。 + AuditResourceRoleApprovalOutbox = "approval_outbox" // AuditResourceRoleBatchTask 表示批量根事件的任务资源。 AuditResourceRoleBatchTask = "batch_task" // AuditResourceRoleBatchItem 表示批量子事件的单项资源。 AuditResourceRoleBatchItem = "batch_item" + // AuditResourceRoleNotificationTarget 表示本次写操作的通知资源。 + AuditResourceRoleNotificationTarget = "notification_target" // AuditResourceRoleSensitiveReadTarget 表示敏感读取目标资源。 AuditResourceRoleSensitiveReadTarget = "sensitive_read_target" // AuditResourceRoleAccountTarget 表示账号生命周期的目标账号。 @@ -222,6 +654,224 @@ const ( AuditResourceRolePersonalCustomerPhone = "personal_customer_phone" // AuditResourceRolePersonalCustomerWechatIdentity 表示个人客户微信主体关系。 AuditResourceRolePersonalCustomerWechatIdentity = "personal_customer_wechat_identity" + // AuditResourceRolePersonalCustomerAssetBinding 表示个人客户资产绑定关系。 + AuditResourceRolePersonalCustomerAssetBinding = "personal_customer_asset_binding" + // AuditResourceRolePersonalCustomerOldAssetBinding 表示换货前的个人客户资产绑定关系。 + AuditResourceRolePersonalCustomerOldAssetBinding = "personal_customer_old_asset_binding" + // AuditResourceRolePersonalCustomerNewAssetBinding 表示换货后的个人客户资产绑定关系。 + AuditResourceRolePersonalCustomerNewAssetBinding = "personal_customer_new_asset_binding" + // AuditResourceRolePersonalCustomerBoundAsset 表示个人客户绑定的资产。 + AuditResourceRolePersonalCustomerBoundAsset = "personal_customer_bound_asset" + // AuditResourceRolePersonalCustomerOldAsset 表示换货迁移前的资产。 + AuditResourceRolePersonalCustomerOldAsset = "personal_customer_old_asset" + // AuditResourceRolePersonalCustomerNewAsset 表示换货迁移后的资产。 + AuditResourceRolePersonalCustomerNewAsset = "personal_customer_new_asset" + // AuditResourceRoleIotCardTarget 表示 IoT 卡身份生命周期目标。 + AuditResourceRoleIotCardTarget = "iot_card_target" + // AuditResourceRoleIotCardBatch 表示 IoT 卡批量操作根资源。 + AuditResourceRoleIotCardBatch = "iot_card_batch" + // AuditResourceRoleIotCardTransferTarget 表示分配或回收的 IoT 卡。 + AuditResourceRoleIotCardTransferTarget = "iot_card_transfer_target" + // AuditResourceRoleAssetAllocationRecord 表示资产流转对应的分配记录。 + AuditResourceRoleAssetAllocationRecord = "asset_allocation_record" + // AuditResourceRoleTransferSourceShop 表示资产流转来源店铺。 + AuditResourceRoleTransferSourceShop = "transfer_source_shop" + // AuditResourceRoleTransferTargetShop 表示资产流转目标店铺。 + AuditResourceRoleTransferTargetShop = "transfer_target_shop" + // AuditResourceRoleIotCardSeriesTarget 表示系列绑定涉及的 IoT 卡。 + AuditResourceRoleIotCardSeriesTarget = "iot_card_series_target" + // AuditResourceRolePreviousPackageSeries 表示系列绑定前的套餐系列。 + AuditResourceRolePreviousPackageSeries = "previous_package_series" + // AuditResourceRoleTargetPackageSeries 表示系列绑定后的套餐系列。 + AuditResourceRoleTargetPackageSeries = "target_package_series" + // AuditResourceRolePackageSeriesTarget 表示套餐系列配置目标。 + AuditResourceRolePackageSeriesTarget = "package_series_target" + // AuditResourceRolePackageTarget 表示套餐商品配置目标。 + AuditResourceRolePackageTarget = "package_target" + // AuditResourceRolePackageSeries 表示套餐所属系列。 + AuditResourceRolePackageSeries = "package_series" + // AuditResourceRoleShopSeriesAllocation 表示店铺系列授权记录。 + AuditResourceRoleShopSeriesAllocation = "shop_series_allocation" + // AuditResourceRoleShopPackageAllocation 表示店铺套餐授权记录。 + AuditResourceRoleShopPackageAllocation = "shop_package_allocation" + // AuditResourceRolePackageConfigShop 表示套餐配置涉及的店铺。 + AuditResourceRolePackageConfigShop = "package_config_shop" + // AuditResourceRolePackagePriceHistory 表示套餐价格变更历史。 + AuditResourceRolePackagePriceHistory = "package_price_history" + // AuditResourceRolePackageConfigBatch 表示套餐配置批次根资源。 + AuditResourceRolePackageConfigBatch = "package_config_batch" + // AuditResourceRolePackageUsageTarget 表示套餐权益生命周期目标。 + AuditResourceRolePackageUsageTarget = "package_usage_target" + // AuditResourceRolePackageUsageOrder 表示套餐权益关联订单。 + AuditResourceRolePackageUsageOrder = "package_usage_order" + // AuditResourceRolePackageUsagePackage 表示套餐权益关联套餐商品。 + AuditResourceRolePackageUsagePackage = "package_usage_package" + // AuditResourceRolePackageUsageAsset 表示套餐权益实际所属资产。 + AuditResourceRolePackageUsageAsset = "package_usage_asset" + // AuditResourceRolePackageUsageRefund 表示套餐权益关联退款单。 + AuditResourceRolePackageUsageRefund = "package_usage_refund" + // AuditResourceRoleOrderTarget 表示订单操作的主要订单。 + AuditResourceRoleOrderTarget = "order_target" + // AuditResourceRoleOrderBuyer 表示订单买家。 + AuditResourceRoleOrderBuyer = "order_buyer" + // AuditResourceRoleOrderAsset 表示订单购买套餐的资产。 + AuditResourceRoleOrderAsset = "order_asset" + // AuditResourceRoleOrderPackage 表示订单购买的套餐商品。 + AuditResourceRoleOrderPackage = "order_package" + // AuditResourceRoleOrderWallet 表示订单实际使用的钱包。 + AuditResourceRoleOrderWallet = "order_wallet" + // AuditResourceRoleOrderWalletTransaction 表示订单实际产生的钱包流水。 + AuditResourceRoleOrderWalletTransaction = "order_wallet_transaction" + // AuditResourceRoleOrderWalletReservation 表示订单对应的钱包预占事实。 + AuditResourceRoleOrderWalletReservation = "order_wallet_reservation" + // AuditResourceRoleWalletTarget 表示钱包专项操作的目标钱包。 + AuditResourceRoleWalletTarget = "wallet_target" + // AuditResourceRoleWalletTransaction 表示钱包专项操作产生的流水。 + AuditResourceRoleWalletTransaction = "wallet_transaction" + // AuditResourceRoleWalletShop 表示钱包所属店铺。 + AuditResourceRoleWalletShop = "wallet_shop" + // AuditResourceRoleOrderPayment 表示订单对应的支付记录。 + AuditResourceRoleOrderPayment = "order_payment" + // AuditResourceRolePaymentTarget 表示支付生命周期操作的主要支付记录。 + AuditResourceRolePaymentTarget = "payment_target" + // AuditResourceRolePaymentBusinessOrder 表示支付记录关联的业务单。 + AuditResourceRolePaymentBusinessOrder = "payment_business_order" + // AuditResourceRolePaymentWallet 表示支付确认实际变更的钱包。 + AuditResourceRolePaymentWallet = "payment_wallet" + // AuditResourceRolePaymentWalletTransaction 表示支付确认产生的钱包流水。 + AuditResourceRolePaymentWalletTransaction = "payment_wallet_transaction" + // AuditResourceRoleRechargeTarget 表示充值业务的主要充值单。 + AuditResourceRoleRechargeTarget = "recharge_target" + // AuditResourceRoleRechargeSubmitter 表示充值申请的真实提交人。 + AuditResourceRoleRechargeSubmitter = "recharge_submitter" + // AuditResourceRoleRechargeShop 表示代理充值的目标店铺。 + AuditResourceRoleRechargeShop = "recharge_shop" + // AuditResourceRoleRechargeApproval 表示线下充值关联的审批实例。 + AuditResourceRoleRechargeApproval = "recharge_approval" + // AuditResourceRoleRechargeWallet 表示充值实际变更的钱包。 + AuditResourceRoleRechargeWallet = "recharge_wallet" + // AuditResourceRoleRechargeWalletTransaction 表示充值实际产生的钱包流水。 + AuditResourceRoleRechargeWalletTransaction = "recharge_wallet_transaction" + // AuditResourceRoleRechargeAutoPurchaseOrder 表示充值后自动购包创建的订单。 + AuditResourceRoleRechargeAutoPurchaseOrder = "recharge_auto_purchase_order" + // AuditResourceRoleRefundTarget 表示退款完整业务链的主要退款单。 + AuditResourceRoleRefundTarget = "refund_target" + // AuditResourceRoleRefundApproval 表示退款关联的审批实例。 + AuditResourceRoleRefundApproval = "refund_approval" + // AuditResourceRoleRefundSubmitter 表示退款申请的真实提交账号。 + AuditResourceRoleRefundSubmitter = "refund_submitter" + // AuditResourceRoleRefundOrder 表示退款关联并改变支付状态的订单。 + AuditResourceRoleRefundOrder = "refund_order" + // AuditResourceRoleRefundAsset 表示退款实际影响的卡或设备。 + AuditResourceRoleRefundAsset = "refund_asset" + // AuditResourceRoleRefundWallet 表示退款回充或佣金回扣涉及的钱包。 + AuditResourceRoleRefundWallet = "refund_wallet" + // AuditResourceRoleRefundOriginalTransaction 表示退款对应的原扣款流水。 + AuditResourceRoleRefundOriginalTransaction = "refund_original_transaction" + // AuditResourceRoleRefundTransaction 表示退款新产生的回充或回扣流水。 + AuditResourceRoleRefundTransaction = "refund_transaction" + // AuditResourceRoleRefundCommission 表示退款导致失效的佣金记录。 + AuditResourceRoleRefundCommission = "refund_commission" + // AuditResourceRoleRefundPackageUsage 表示退款后处理涉及的套餐权益。 + AuditResourceRoleRefundPackageUsage = "refund_package_usage" + // AuditResourceRoleRefundNotification 表示退款完成通知的可靠 Outbox 事实。 + AuditResourceRoleRefundNotification = "refund_notification" + // AuditResourceRoleCommissionRecord 表示佣金计算或入账涉及的佣金记录。 + AuditResourceRoleCommissionRecord = "commission_record" + // AuditResourceRoleCommissionOrder 表示佣金关联订单。 + AuditResourceRoleCommissionOrder = "commission_order" + // AuditResourceRoleCommissionShop 表示佣金归属店铺。 + AuditResourceRoleCommissionShop = "commission_shop" + // AuditResourceRoleCommissionSeries 表示佣金计算使用的套餐系列。 + AuditResourceRoleCommissionSeries = "commission_series" + // AuditResourceRoleCommissionWallet 表示佣金实际变更的钱包。 + AuditResourceRoleCommissionWallet = "commission_wallet" + // AuditResourceRoleCommissionTransaction 表示佣金实际产生的钱包流水。 + AuditResourceRoleCommissionTransaction = "commission_transaction" + // AuditResourceRoleWithdrawalTarget 表示提现状态机的目标提现单。 + AuditResourceRoleWithdrawalTarget = "withdrawal_target" + // AuditResourceRoleWithdrawalShop 表示提现所属店铺。 + AuditResourceRoleWithdrawalShop = "withdrawal_shop" + // AuditResourceRoleWithdrawalWallet 表示提现冻结、扣除或解冻的钱包。 + AuditResourceRoleWithdrawalWallet = "withdrawal_wallet" + // AuditResourceRoleWithdrawalTransaction 表示提现实际产生的钱包流水。 + AuditResourceRoleWithdrawalTransaction = "withdrawal_transaction" + // AuditResourceRoleIotCardRelatedDevice 表示 IoT 卡操作关联的设备。 + AuditResourceRoleIotCardRelatedDevice = "iot_card_related_device" + // AuditResourceRoleIotCardDeviceBinding 表示 IoT 卡操作关联的设备卡槽绑定。 + AuditResourceRoleIotCardDeviceBinding = "iot_card_device_binding" + // AuditResourceRoleDeviceTarget 表示设备身份生命周期目标。 + AuditResourceRoleDeviceTarget = "device_target" + // AuditResourceRoleDeviceBatch 表示设备批量操作根资源。 + AuditResourceRoleDeviceBatch = "device_batch" + // AuditResourceRoleDeviceTransferTarget 表示分配或回收的设备。 + AuditResourceRoleDeviceTransferTarget = "device_transfer_target" + // AuditResourceRoleDeviceSeriesTarget 表示系列绑定涉及的设备。 + AuditResourceRoleDeviceSeriesTarget = "device_series_target" + // AuditResourceRoleDeviceBoundCard 表示设备操作实际连带的卡。 + AuditResourceRoleDeviceBoundCard = "device_bound_card" + // AuditResourceRoleDeviceCardBinding 表示设备操作关联的卡槽绑定。 + AuditResourceRoleDeviceCardBinding = "device_card_binding" + // AuditResourceRoleDeviceCommandTargetCard 表示设备外部命令指定的目标卡。 + AuditResourceRoleDeviceCommandTargetCard = "device_command_target_card" + // AuditResourceRoleDeviceBindingTargetCard 表示设备绑卡或解绑的目标卡。 + AuditResourceRoleDeviceBindingTargetCard = "device_binding_target_card" + // AuditResourceRoleDeviceCreatedBinding 表示设备绑卡新建的卡槽关系。 + AuditResourceRoleDeviceCreatedBinding = "device_created_binding" + // AuditResourceRoleDeviceRemovedBinding 表示设备解绑移除的卡槽关系。 + AuditResourceRoleDeviceRemovedBinding = "device_removed_binding" + // AuditResourceRoleDeviceOldCurrentCard 表示切卡前的当前卡。 + AuditResourceRoleDeviceOldCurrentCard = "device_old_current_card" + // AuditResourceRoleDeviceNewCurrentCard 表示切卡后的当前卡。 + AuditResourceRoleDeviceNewCurrentCard = "device_new_current_card" + // AuditResourceRoleDeviceOldCurrentBinding 表示切卡前当前卡的卡槽关系。 + AuditResourceRoleDeviceOldCurrentBinding = "device_old_current_binding" + // AuditResourceRoleDeviceNewCurrentBinding 表示切卡后当前卡的卡槽关系。 + AuditResourceRoleDeviceNewCurrentBinding = "device_new_current_binding" + // AuditResourceRoleCardExchangeOrder 表示卡换货单主资源。 + AuditResourceRoleCardExchangeOrder = "card_exchange_order" + // AuditResourceRoleCardExchangeOldCard 表示卡换货旧卡。 + AuditResourceRoleCardExchangeOldCard = "card_exchange_old_card" + // AuditResourceRoleCardExchangeNewCard 表示卡换货新卡。 + AuditResourceRoleCardExchangeNewCard = "card_exchange_new_card" + // AuditResourceRoleCardExchangeShop 表示卡换货所属店铺。 + AuditResourceRoleCardExchangeShop = "card_exchange_shop" + // AuditResourceRoleCardExchangeOldWallet 表示卡换货旧卡钱包。 + AuditResourceRoleCardExchangeOldWallet = "card_exchange_old_wallet" + // AuditResourceRoleCardExchangeNewWallet 表示卡换货新卡钱包。 + AuditResourceRoleCardExchangeNewWallet = "card_exchange_new_wallet" + // AuditResourceRoleCardExchangeWalletTransaction 表示卡换货迁移钱包流水。 + AuditResourceRoleCardExchangeWalletTransaction = "card_exchange_wallet_transaction" + // AuditResourceRoleCardExchangePackageUsage 表示卡换货迁移套餐权益。 + AuditResourceRoleCardExchangePackageUsage = "card_exchange_package_usage" + // AuditResourceRoleDeviceExchangeOrder 表示设备换货单主资源。 + AuditResourceRoleDeviceExchangeOrder = "device_exchange_order" + // AuditResourceRoleDeviceExchangeOldDevice 表示设备换货旧设备。 + AuditResourceRoleDeviceExchangeOldDevice = "device_exchange_old_device" + // AuditResourceRoleDeviceExchangeNewDevice 表示设备换货新设备。 + AuditResourceRoleDeviceExchangeNewDevice = "device_exchange_new_device" + // AuditResourceRoleDeviceExchangeShop 表示设备换货所属店铺。 + AuditResourceRoleDeviceExchangeShop = "device_exchange_shop" + // AuditResourceRoleDeviceExchangeOldBoundCard 表示旧设备实际绑定卡。 + AuditResourceRoleDeviceExchangeOldBoundCard = "device_exchange_old_bound_card" + // AuditResourceRoleDeviceExchangeNewBoundCard 表示新设备实际绑定卡。 + AuditResourceRoleDeviceExchangeNewBoundCard = "device_exchange_new_bound_card" + // AuditResourceRoleDeviceExchangeOldSIMBinding 表示旧设备卡槽绑定。 + AuditResourceRoleDeviceExchangeOldSIMBinding = "device_exchange_old_sim_binding" + // AuditResourceRoleDeviceExchangeNewSIMBinding 表示新设备卡槽绑定。 + AuditResourceRoleDeviceExchangeNewSIMBinding = "device_exchange_new_sim_binding" + // AuditResourceRoleDeviceExchangeOldCustomerBinding 表示旧设备客户绑定。 + AuditResourceRoleDeviceExchangeOldCustomerBinding = "device_exchange_old_customer_binding" + // AuditResourceRoleDeviceExchangeNewCustomerBinding 表示新设备客户绑定。 + AuditResourceRoleDeviceExchangeNewCustomerBinding = "device_exchange_new_customer_binding" + // AuditResourceRoleDeviceExchangeOldWallet 表示设备换货旧设备钱包。 + AuditResourceRoleDeviceExchangeOldWallet = "device_exchange_old_wallet" + // AuditResourceRoleDeviceExchangeNewWallet 表示设备换货新设备钱包。 + AuditResourceRoleDeviceExchangeNewWallet = "device_exchange_new_wallet" + // AuditResourceRoleDeviceExchangeWalletTransaction 表示设备换货迁移钱包流水。 + AuditResourceRoleDeviceExchangeWalletTransaction = "device_exchange_wallet_transaction" + // AuditResourceRoleDeviceExchangePackageUsage 表示设备换货迁移套餐权益。 + AuditResourceRoleDeviceExchangePackageUsage = "device_exchange_package_usage" ) const ( @@ -237,6 +887,16 @@ const ( AuditActorScheduledJob = "scheduled_job" // AuditActorExternalSystem 表示经验证的外部系统回调。 AuditActorExternalSystem = "external_system" + // AuditActorIDPackageLifecycleScheduler 表示套餐权益生命周期计划任务。 + AuditActorIDPackageLifecycleScheduler = "package_usage_lifecycle_scheduler" + // AuditActorIDOrderExpireScheduler 表示订单过期关闭计划任务。 + AuditActorIDOrderExpireScheduler = "order_expire_scheduler" + // AuditActorIDRefundAssetPostProcessing 表示退款资产自动后处理任务。 + AuditActorIDRefundAssetPostProcessing = "refund_asset_post_processing" + // AuditActorIDRefundCommissionPostProcessing 表示退款佣金自动回扣任务。 + AuditActorIDRefundCommissionPostProcessing = "refund_commission_post_processing" + // AuditActorIDCommissionCalculationWorker 表示订单佣金计算任务。 + AuditActorIDCommissionCalculationWorker = "commission_calculation_worker" // AuditSourceAdminAPI 表示后台管理 API 入口。 AuditSourceAdminAPI = "admin_api" // AuditSourcePersonalAPI 表示个人客户 API 入口。 @@ -251,6 +911,8 @@ const ( AuditSourceCallback = "callback" // AuditScopePlatform 表示平台级业务范围。 AuditScopePlatform = "platform" + // AuditScopeShop 表示店铺级业务范围。 + AuditScopeShop = "shop" // AuditScopePersonalCustomer 表示个人客户本人业务范围。 AuditScopePersonalCustomer = "personal_customer" ) diff --git a/pkg/constants/card_observation.go b/pkg/constants/card_observation.go index 1aa45a4..57d9f18 100644 --- a/pkg/constants/card_observation.go +++ b/pkg/constants/card_observation.go @@ -64,12 +64,19 @@ const ( // Gateway 轮询 Integration Log 操作编码。 const ( - IntegrationProviderGateway = "gateway" // Gateway 外部系统 - IntegrationOperationGatewayRealname = "query_realname_status" // 查询实名状态 - IntegrationOperationGatewayTraffic = "query_flow" // 查询流量 - IntegrationOperationGatewayNetwork = "query_card_status" // 查询网络状态 - IntegrationOperationGatewayDeviceInfo = "query_device_info" // 查询设备信息 - IntegrationOperationGatewaySpeedTier = "set_speed_tier" // 设置或恢复固定限速档位 + IntegrationProviderGateway = "gateway" // Gateway 外部系统 + IntegrationOperationGatewayRealname = "query_realname_status" // 查询实名状态 + IntegrationOperationGatewayTraffic = "query_flow" // 查询流量 + IntegrationOperationGatewayNetwork = "query_card_status" // 查询网络状态 + IntegrationOperationGatewayDeviceInfo = "query_device_info" // 查询设备信息 + IntegrationOperationGatewaySpeedTier = "set_speed_tier" // 设置或恢复固定限速档位 + IntegrationOperationGatewayStopCard = "stop_card" // 停机 + IntegrationOperationGatewayStartCard = "start_card" // 复机 + IntegrationOperationGatewaySetWiFi = "set_device_wifi" // 设置设备 Wi-Fi + IntegrationOperationGatewaySwitchMode = "set_device_switch_mode" // 设置设备切卡模式 + IntegrationOperationGatewaySwitchCard = "switch_device_card" // 切换设备当前卡 + IntegrationOperationGatewayReboot = "reboot_device" // 重启设备 + IntegrationOperationGatewayReset = "reset_device" // 恢复设备出厂设置 ) // Gateway 固定限速档位编码。 @@ -122,6 +129,15 @@ func GetGatewaySpeedTierName(code int) string { // GatewaySpeedTierUnknownRecoveryStrategy 表示限速超时后的人工核对策略。 const GatewaySpeedTierUnknownRecoveryStrategy = "通过 Gateway 运维侧按 ICCID 核对当前限速档位后,再决定是否重试" +// GatewayQueryUnknownRecoveryStrategy 表示 Gateway 查询结果未知时的人工核对策略。 +const GatewayQueryUnknownRecoveryStrategy = "通过 Gateway 运维侧按 ICCID 核对查询结果,并结合后续轮询确认内部状态" + +// GatewayCardCommandUnknownRecoveryStrategy 表示停复机命令结果未知时的人工核对策略。 +const GatewayCardCommandUnknownRecoveryStrategy = "通过 Gateway 运维侧按 ICCID 核对实际停复机状态后,再决定是否重试" + +// GatewayDeviceCommandUnknownRecoveryStrategy 表示设备命令超时后的人工核对策略。 +const GatewayDeviceCommandUnknownRecoveryStrategy = "通过 Gateway 运维侧按设备 IMEI 核对命令实际执行结果后,再决定是否重试" + // 卡实名观测场景 const ( CardObservationSceneRealnamePolling = "realname_polling" // 实名轮询 diff --git a/pkg/constants/integration_log.go b/pkg/constants/integration_log.go index 962b77c..be71052 100644 --- a/pkg/constants/integration_log.go +++ b/pkg/constants/integration_log.go @@ -52,6 +52,8 @@ const ( IntegrationProviderWechatPay = "wechat_pay" // IntegrationProviderAlipay 表示支付宝提供方。 IntegrationProviderAlipay = "alipay" + // IntegrationProviderFuiou 表示富友支付提供方。 + IntegrationProviderFuiou = "fuiou" // IntegrationOperationPaymentPreCreate 表示扫码支付预下单。 IntegrationOperationPaymentPreCreate = "payment_precreate" // IntegrationOperationPaymentQuery 表示支付订单查询。 @@ -60,6 +62,8 @@ const ( IntegrationOperationPaymentCallback = "payment_callback" // IntegrationResourceTypeAgentRechargePayment 表示代理充值支付单资源。 IntegrationResourceTypeAgentRechargePayment = "agent_recharge_payment" + // IntegrationResourceTypePayment 表示通用支付记录资源。 + IntegrationResourceTypePayment = "payment" ) const ( @@ -144,7 +148,7 @@ func IntegrationProviderName(provider string) string { names := map[string]string{ IntegrationProviderCTCC: "中国电信", IntegrationProviderCMCC: "中国移动", IntegrationProviderCUCC: "中国联通", IntegrationProviderWechatPay: "微信支付", - IntegrationProviderAlipay: "支付宝", IntegrationProviderWeCom: "企业微信", + IntegrationProviderAlipay: "支付宝", IntegrationProviderFuiou: "富友支付", IntegrationProviderWeCom: "企业微信", IntegrationProviderGateway: "Gateway", } if name := names[provider]; name != "" { @@ -183,7 +187,11 @@ func IntegrationOperationName(operation string) string { IntegrationOperationWeComApprovalInfo: "查询企业微信审批单号", IntegrationOperationGatewayRealname: "查询实名状态", IntegrationOperationGatewayTraffic: "查询流量", IntegrationOperationGatewayNetwork: "查询卡网络状态", IntegrationOperationGatewayDeviceInfo: "查询设备信息", - IntegrationOperationGatewaySpeedTier: "设置卡限速档位", + IntegrationOperationGatewaySpeedTier: "设置卡限速档位", IntegrationOperationGatewayStopCard: "停机", + IntegrationOperationGatewayStartCard: "复机", IntegrationOperationGatewaySetWiFi: "设置设备 Wi-Fi", + IntegrationOperationGatewaySwitchMode: "设置设备切卡模式", IntegrationOperationGatewaySwitchCard: "切换设备当前卡", + IntegrationOperationGatewayReboot: "重启设备", + IntegrationOperationGatewayReset: "恢复设备出厂设置", } if name := names[operation]; name != "" { return name diff --git a/pkg/queue/handler.go b/pkg/queue/handler.go index 36dec59..920ca05 100644 --- a/pkg/queue/handler.go +++ b/pkg/queue/handler.go @@ -9,6 +9,7 @@ import ( packageExpiryApp "github.com/break/junhong_cmp_fiber/internal/application/packageexpiry" "github.com/break/junhong_cmp_fiber/internal/exporter" "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/infrastructure/messaging/outbox" notification "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification" @@ -106,6 +107,7 @@ func (h *Handler) registerIotCardImportHandler() { h.workerResult.Stores.AssetWallet, h.storage, h.pollingCallback, + audit.NewWriter(audit.NewRegistry(), nil), h.logger, ) @@ -120,6 +122,7 @@ func (h *Handler) registerOrderPackageInvalidateHandler() { h.workerResult.Stores.PackageUsage, h.storage, h.logger, + audit.NewWriter(audit.NewRegistry(), nil), ) h.mux.HandleFunc(constants.TaskTypeOrderPackageInvalidate, orderPkgHandler.Handle) h.logger.Info("注册订单套餐批量失效任务处理器", zap.String("task_type", constants.TaskTypeOrderPackageInvalidate)) @@ -132,6 +135,7 @@ func (h *Handler) registerAssetPackageBatchOrderHandler() { h.workerResult.Services.AssetPackageOrderCreator, h.storage, h.logger, + audit.NewWriter(audit.NewRegistry(), nil), ) h.mux.HandleFunc(constants.TaskTypeAssetPackageBatchOrder, handler.Handle) h.logger.Info("注册资产套餐批量订购任务处理器", zap.String("task_type", constants.TaskTypeAssetPackageBatchOrder)) @@ -148,6 +152,7 @@ func (h *Handler) registerDeviceImportHandler() { h.workerResult.Stores.AssetWallet, h.workerResult.Stores.AssetIdentifier, h.storage, + audit.NewWriter(audit.NewRegistry(), nil), h.logger, h.workerResult.Services.DeviceBatchAllocator, ) @@ -297,7 +302,7 @@ func (h *Handler) registerDataCleanupHandler() { } func (h *Handler) registerNotificationCleanupHandler() { - cleanupService := notification.NewCleanupService(h.db, h.logger) + cleanupService := notification.NewCleanupService(h.db, h.logger, audit.NewWriter(audit.NewRegistry(), nil)) cleanupHandler := task.NewNotificationCleanupHandler(cleanupService, h.logger) h.mux.HandleFunc(constants.TaskTypeNotificationCleanup, cleanupHandler.Handle) h.logger.Info("注册站内通知保留清理任务处理器", zap.String("task_type", constants.TaskTypeNotificationCleanup)) @@ -325,6 +330,7 @@ func (h *Handler) registerAutoPurchaseHandler() { h.asynqClient, h.logger, h.workerResult.Services.ObservationSeriesEvents, + audit.NewWriter(audit.NewRegistry(), nil), ) h.mux.HandleFunc(constants.TaskTypeAutoPurchaseAfterRecharge, autoPurchaseHandler.ProcessTask) h.logger.Info("注册自动购包任务处理器", zap.String("task_type", constants.TaskTypeAutoPurchaseAfterRecharge)) diff --git a/pkg/queue/types.go b/pkg/queue/types.go index f332169..8022b28 100644 --- a/pkg/queue/types.go +++ b/pkg/queue/types.go @@ -3,6 +3,7 @@ package queue import ( "context" + agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation" "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation" "github.com/break/junhong_cmp_fiber/internal/service/commission_stats" @@ -57,6 +58,8 @@ type WorkerStores struct { // WorkerServices Worker 侧所有 Service 的集合 type WorkerServices struct { + PaymentAudit agentrechargeApp.PaymentAuditWriter + RechargeAudit agentrechargeApp.RechargeAuditWriter CardObservation *cardObservationApp.Service CardObservationSeries *cardObservationApp.SeriesAttemptService ObservationSeriesEvents cardObservationApp.SeriesEventWriter diff --git a/scripts/batch_package_purchase/README.md b/scripts/batch_package_purchase/README.md index 751b240..906d2e1 100644 --- a/scripts/batch_package_purchase/README.md +++ b/scripts/batch_package_purchase/README.md @@ -86,3 +86,32 @@ python3 scripts/batch_package_purchase/batch_purchase.py \ - `--execute`:显式开启真实下单。 脚本固定使用 `wallet` 钱包支付,每个资产单独创建一个订单。脚本不会自动重试 POST 请求,避免服务端已成功但客户端未收到响应时重复下单。失败项可根据结果 CSV 人工确认后单独重跑。 + +## 生成批量失效订单号 + +`generate_invalidate_orders.py` 读取单列 ICCID CSV,调用现有资产套餐查询接口,筛选待生效、生效中和已用完(状态 `0/1/2`)的套餐,并按订单号去重生成 `order_no` 单列 CSV。已过期和已失效套餐不会进入结果。 + +```bash +JUNHONG_ADMIN_TOKEN='<后台Access Token>' \ +python3 scripts/batch_package_purchase/generate_invalidate_orders.py \ + --base-url https://cmp-api.example.com \ + --csv scripts/batch_package_purchase/assets.example.csv +``` + +默认输出文件名为 `原文件名_待失效订单_YYYYMMDD_HHMMSS.csv`,可以通过 `--output` 指定。脚本只查询和生成文件,不会直接执行套餐失效;生成的 CSV 可上传到现有“订单套餐批量失效”功能。 + +## 批量修改生效中套餐过期时间 + +`batch_update_package_expiry.py` 读取单列资产标识 CSV,查询每个资产全部生效中(状态 `1`)的套餐,并将它们统一修改为 `--expires-at` 指定的时间。支持 ICCID、设备虚拟号、IMEI、SN 或 MSISDN。 + +默认只预演: + +```bash +JUNHONG_ADMIN_TOKEN='<后台Access Token>' \ +python3 scripts/batch_package_purchase/batch_update_package_expiry.py \ + --base-url https://cmp-api.example.com \ + --csv scripts/batch_package_purchase/assets.example.csv \ + --expires-at '2026-12-31 23:59:59' +``` + +确认预演结果后增加 `--execute` 才会真实修改。真实执行会逐套餐生成 `原文件名_过期时间修改结果_YYYYMMDD_HHMMSS.csv`,记录原过期时间、新过期时间及接口结果。接口当前只允许后台账号 ID `41` 或 `127` 调用。 diff --git a/scripts/batch_package_purchase/assets.example.csv b/scripts/batch_package_purchase/assets.example.csv index 4160639..f4856a7 100644 --- a/scripts/batch_package_purchase/assets.example.csv +++ b/scripts/batch_package_purchase/assets.example.csv @@ -1,551 +1,91 @@ identifier -898604A8012270312000, -898604A8012270312001, -898604A8012270312002, -898604A8012270312003, -898604A8012270312004, -898604A8012270312005, -898604A8012270312006, -898604A8012270312007, -898604A8012270312008, -898604A8012270312009, -898604A8012270312010, -898604A8012270312011, -898604A8012270312012, -898604A8012270312013, -898604A8012270312014, -898604A8012270312015, -898604A8012270312016, -898604A8012270312017, -898604A8012270312018, -898604A8012270312019, -898604A8012270312020, -898604A8012270312021, -898604A8012270312022, -898604A8012270312023, -898604A8012270312024, -898604A8012270312025, -898604A8012270312026, -898604A8012270312027, -898604A8012270312028, -898604A8012270312029, -898604A8012270312030, -898604A8012270312031, -898604A8012270312032, -898604A8012270312033, -898604A8012270312034, -898604A8012270312035, -898604A8012270312036, -898604A8012270312037, -898604A8012270312038, -898604A8012270312039, -898604A8012270312040, -898604A8012270312041, -898604A8012270312042, -898604A8012270312043, -898604A8012270312044, -898604A8012270312045, -898604A8012270312046, -898604A8012270312047, -898604A8012270312048, -898604A8012270312049, -898604A8012270312050, -898604A8012270312051, -898604A8012270312052, -898604A8012270312053, -898604A8012270312054, -898604A8012270312055, -898604A8012270312056, -898604A8012270312057, -898604A8012270312058, -898604A8012270312059, -898604A8012270312060, -898604A8012270312061, -898604A8012270312062, -898604A8012270312063, -898604A8012270312064, -898604A8012270312065, -898604A8012270312066, -898604A8012270312067, -898604A8012270312068, -898604A8012270312069, -898604A8012270312070, -898604A8012270312071, -898604A8012270312072, -898604A8012270312073, -898604A8012270312074, -898604A8012270312075, -898604A8012270312076, -898604A8012270312077, -898604A8012270312078, -898604A8012270312079, -898604A8012270312080, -898604A8012270312081, -898604A8012270312082, -898604A8012270312083, -898604A8012270312084, -898604A8012270312085, -898604A8012270312086, -898604A8012270312087, -898604A8012270312088, -898604A8012270312089, -898604A8012270312090, -898604A8012270312091, -898604A8012270312092, -898604A8012270312093, -898604A8012270312094, -898604A8012270312095, -898604A8012270312096, -898604A8012270312097, -898604A8012270312098, -898604A8012270312099, -898604A8012270312100, -898604A8012270312101, -898604A8012270312102, -898604A8012270312103, -898604A8012270312104, -898604A8012270312105, -898604A8012270312106, -898604A8012270312107, -898604A8012270312108, -898604A8012270312109, -898604A8012270312110, -898604A8012270312111, -898604A8012270312112, -898604A8012270312113, -898604A8012270312114, -898604A8012270312115, -898604A8012270312116, -898604A8012270312117, -898604A8012270312118, -898604A8012270312119, -898604A8012270312120, -898604A8012270312121, -898604A8012270312122, -898604A8012270312123, -898604A8012270312124, -898604A8012270312125, -898604A8012270312126, -898604A8012270312127, -898604A8012270312128, -898604A8012270312129, -898604A8012270312130, -898604A8012270312131, -898604A8012270312132, -898604A8012270312133, -898604A8012270312134, -898604A8012270312135, -898604A8012270312136, -898604A8012270312137, -898604A8012270312138, -898604A8012270312139, -898604A8012270312140, -898604A8012270312141, -898604A8012270312142, -898604A8012270312143, -898604A8012270312144, -898604A8012270312145, -898604A8012270312146, -898604A8012270312147, -898604A8012270312148, -898604A8012270312149, -898604A8012270312150, -898604A8012270312151, -898604A8012270312152, -898604A8012270312153, -898604A8012270312154, -898604A8012270312155, -898604A8012270312156, -898604A8012270312157, -898604A8012270312158, -898604A8012270312159, -898604A8012270312160, -898604A8012270312161, -898604A8012270312162, -898604A8012270312163, -898604A8012270312164, -898604A8012270312165, -898604A8012270312166, -898604A8012270312167, -898604A8012270312168, -898604A8012270312169, -898604A8012270312170, -898604A8012270312171, -898604A8012270312172, -898604A8012270312173, -898604A8012270312174, -898604A8012270312175, -898604A8012270312176, -898604A8012270312177, -898604A8012270312178, -898604A8012270312179, -898604A8012270312180, -898604A8012270312181, -898604A8012270312182, -898604A8012270312183, -898604A8012270312184, -898604A8012270312185, -898604A8012270312186, -898604A8012270312187, -898604A8012270312188, -898604A8012270312189, -898604A8012270312190, -898604A8012270312191, -898604A8012270312192, -898604A8012270312193, -898604A8012270312194, -898604A8012270312195, -898604A8012270312196, -898604A8012270312197, -898604A8012270312198, -898604A8012270312199, -898604A8012270312200, -898604A8012270312201, -898604A8012270312202, -898604A8012270312203, -898604A8012270312204, -898604A8012270312205, -898604A8012270312206, -898604A8012270312207, -898604A8012270312208, -898604A8012270312209, -898604A8012270312210, -898604A8012270312211, -898604A8012270312212, -898604A8012270312213, -898604A8012270312214, -898604A8012270312215, -898604A8012270312216, -898604A8012270312217, -898604A8012270312218, -898604A8012270312219, -898604A8012270312220, -898604A8012270312221, -898604A8012270312222, -898604A8012270312223, -898604A8012270312224, -898604A8012270312225, -898604A8012270312226, -898604A8012270312227, -898604A8012270312228, -898604A8012270312229, -898604A8012270312230, -898604A8012270312231, -898604A8012270312232, -898604A8012270312233, -898604A8012270312234, -898604A8012270312235, -898604A8012270312236, -898604A8012270312237, -898604A8012270312238, -898604A8012270312239, -898604A8012270312240, -898604A8012270312241, -898604A8012270312242, -898604A8012270312243, -898604A8012270312244, -898604A8012270312245, -898604A8012270312246, -898604A8012270312247, -898604A8012270312248, -898604A8012270312249, -898604A8012270312250, -898604A8012270312251, -898604A8012270312252, -898604A8012270312253, -898604A8012270312254, -898604A8012270312255, -898604A8012270312256, -898604A8012270312257, -898604A8012270312258, -898604A8012270312259, -898604A8012270312260, -898604A8012270312261, -898604A8012270312262, -898604A8012270312263, -898604A8012270312264, -898604A8012270312265, -898604A8012270312266, -898604A8012270312267, -898604A8012270312268, -898604A8012270312269, -898604A8012270312270, -898604A8012270312271, -898604A8012270312272, -898604A8012270312273, -898604A8012270312274, -898604A8012270312275, -898604A8012270312276, -898604A8012270312277, -898604A8012270312278, -898604A8012270312279, -898604A8012270312280, -898604A8012270312281, -898604A8012270312282, -898604A8012270312283, -898604A8012270312284, -898604A8012270312285, -898604A8012270312286, -898604A8012270312287, -898604A8012270312288, -898604A8012270312289, -898604A8012270312290, -898604A8012270312291, -898604A8012270312292, -898604A8012270312293, -898604A8012270312294, -898604A8012270312295, -898604A8012270312296, -898604A8012270312297, -898604A8012270312298, -898604A8012270312299, -898604A8012270312300, -898604A8012270312301, -898604A8012270312302, -898604A8012270312303, -898604A8012270312304, -898604A8012270312305, -898604A8012270312306, -898604A8012270312307, -898604A8012270312308, -898604A8012270312309, -898604A8012270312310, -898604A8012270312311, -898604A8012270312312, -898604A8012270312313, -898604A8012270312314, -898604A8012270312315, -898604A8012270312316, -898604A8012270312317, -898604A8012270312318, -898604A8012270312319, -898604A8012270312320, -898604A8012270312321, -898604A8012270312322, -898604A8012270312323, -898604A8012270312324, -898604A8012270312325, -898604A8012270312326, -898604A8012270312327, -898604A8012270312328, -898604A8012270312329, -898604A8012270312330, -898604A8012270312331, -898604A8012270312332, -898604A8012270312333, -898604A8012270312334, -898604A8012270312335, -898604A8012270312336, -898604A8012270312337, -898604A8012270312338, -898604A8012270312339, -898604A8012270312340, -898604A8012270312341, -898604A8012270312342, -898604A8012270312343, -898604A8012270312344, -898604A8012270312345, -898604A8012270312346, -898604A8012270312347, -898604A8012270312348, -898604A8012270312349, -898604A8012270312350, -898604A8012270312351, -898604A8012270312352, -898604A8012270312353, -898604A8012270312354, -898604A8012270312355, -898604A8012270312356, -898604A8012270312357, -898604A8012270312358, -898604A8012270312359, -898604A8012270312360, -898604A8012270312361, -898604A8012270312362, -898604A8012270312363, -898604A8012270312364, -898604A8012270312365, -898604A8012270312366, -898604A8012270312367, -898604A8012270312368, -898604A8012270312369, -898604A8012270312370, -898604A8012270312371, -898604A8012270312372, -898604A8012270312373, -898604A8012270312374, -898604A8012270312375, -898604A8012270312376, -898604A8012270312377, -898604A8012270312378, -898604A8012270312379, -898604A8012270312380, -898604A8012270312381, -898604A8012270312382, -898604A8012270312383, -898604A8012270312384, -898604A8012270312385, -898604A8012270312386, -898604A8012270312387, -898604A8012270312388, -898604A8012270312389, -898604A8012270312390, -898604A8012270312391, -898604A8012270312392, -898604A8012270312393, -898604A8012270312394, -898604A8012270312395, -898604A8012270312396, -898604A8012270312397, -898604A8012270312398, -898604A8012270312399, -898604A8012270312400, -898604A8012270312401, -898604A8012270312402, -898604A8012270312403, -898604A8012270312404, -898604A8012270312405, -898604A8012270312406, -898604A8012270312407, -898604A8012270312408, -898604A8012270312409, -898604A8012270312410, -898604A8012270312411, -898604A8012270312412, -898604A8012270312413, -898604A8012270312414, -898604A8012270312415, -898604A8012270312416, -898604A8012270312417, -898604A8012270312418, -898604A8012270312419, -898604A8012270312420, -898604A8012270312421, -898604A8012270312422, -898604A8012270312423, -898604A8012270312424, -898604A8012270312425, -898604A8012270312426, -898604A8012270312427, -898604A8012270312428, -898604A8012270312429, -898604A8012270312430, -898604A8012270312431, -898604A8012270312432, -898604A8012270312433, -898604A8012270312434, -898604A8012270312435, -898604A8012270312436, -898604A8012270312437, -898604A8012270312438, -898604A8012270312439, -898604A8012270312440, -898604A8012270312441, -898604A8012270312442, -898604A8012270312443, -898604A8012270312444, -898604A8012270312445, -898604A8012270312446, -898604A8012270312447, -898604A8012270312448, -898604A8012270312449, -898604A8012270312450, -898604A8012270312451, -898604A8012270312452, -898604A8012270312453, -898604A8012270312454, -898604A8012270312455, -898604A8012270312456, -898604A8012270312457, -898604A8012270312458, -898604A8012270312459, -898604A8012270312460, -898604A8012270312461, -898604A8012270312462, -898604A8012270312463, -898604A8012270312464, -898604A8012270312465, -898604A8012270312466, -898604A8012270312467, -898604A8012270312468, -898604A8012270312469, -898604A8012270312470, -898604A8012270312471, -898604A8012270312472, -898604A8012270312473, -898604A8012270312474, -898604A8012270312475, -898604A8012270312476, -898604A8012270312477, -898604A8012270312478, -898604A8012270312479, -898604A8012270312480, -898604A8012270312481, -898604A8012270312482, -898604A8012270312483, -898604A8012270312484, -898604A8012270312485, -898604A8012270312486, -898604A8012270312487, -898604A8012270312488, -898604A8012270312489, -898604A8012270312490, -898604A8012270312491, -898604A8012270312492, -898604A8012270312493, -898604A8012270312494, -898604A8012270312495, -898604A8012270312496, -898604A8012270312497, -898604A8012270312498, -898604A8012270312499, -898604A8012270312500, -898604A8012270312501, -898604A8012270312502, -898604A8012270312503, -898604A8012270312504, -898604A8012270312505, -898604A8012270312506, -898604A8012270312507, -898604A8012270312508, -898604A8012270312509, -898604A8012270312510, -898604A8012270312511, -898604A8012270312512, -898604A8012270312513, -898604A8012270312514, -898604A8012270312515, -898604A8012270312516, -898604A8012270312517, -898604A8012270312518, -898604A8012270312519, -898604A8012270312520, -898604A8012270312521, -898604A8012270312522, -898604A8012270312523, -898604A8012270312524, -898604A8012270312525, -898604A8012270312526, -898604A8012270312527, -898604A8012270312528, -898604A8012270312529, -898604A8012270312530, -898604A8012270312531, -898604A8012270312532, -898604A8012270312533, -898604A8012270312534, -898604A8012270312535, -898604A8012270312536, -898604A8012270312537, -898604A8012270312538, -898604A8012270312539, -898604A8012270312540, -898604A8012270312541, -898604A8012270312542, -898604A8012270312543, -898604A8012270312544, -898604A8012270312545, -898604A8012270312546, -898604A8012270312547, -898604A8012270312548, -898604A8012270312549 +8986032445201075309, +8986032445201075310, +8986032445201075311, +8986032445201075312, +8986032445201075313, +8986032445201075314, +8986032445201075315, +8986032445201075316, +8986032445201075317, +8986032445201075318, +8986032445201075319, +8986032445201075320, +8986032445201075321, +8986032445201075322, +8986032445201075323, +8986032445201075324, +8986032445201075325, +8986032445201075326, +8986032445201075327, +8986032445201075328, +8986032445201075329, +8986032445201075330, +8986032445201075331, +8986032445201075332, +8986032445201075333, +8986032445201075334, +8986032445201075335, +8986032445201075336, +8986032445201075337, +8986032445201075338, +8986032445201075339, +8986032445201075340, +8986032445201075341, +8986032445201075342, +8986032445201075343, +8986032445201075344, +8986032445201075345, +8986032445201075346, +8986032445201075347, +8986032445201075348, +8986032445201075349, +8986032445201075350, +8986032445201075351, +8986032445201075352, +8986032445201075353, +8986032445201075354, +8986032445201075355, +8986032445201075356, +8986032445201075357, +8986032445201075358, +8986032445201075359, +8986032445201075360, +8986032445201075361, +8986032445201075362, +8986032445201075363, +8986032445201075364, +8986032445201075365, +8986032445201075366, +8986032445201075367, +8986032445201075368, +8986032445201075369, +8986032445201075370, +8986032445201075371, +8986032445201075372, +8986032445201075373, +8986032445201075374, +8986032445201075375, +8986032445201075376, +8986032445201075377, +8986032445201075378, +8986032445201075379, +8986032445201075380, +8986032445201075381, +8986032445201075382, +8986032445201075383, +8986032445201075384, +8986032445201075385, +8986032445201075386, +8986032445201075387, +8986032445201075388, +8986032445201075389, +8986032445201075390, +8986032445201075391, +8986032445201075392, +8986032445201075393, +8986032445201075394, +8986032445201075395, +8986032445201075396, +8986032445201075397, +8986032445201075398 diff --git a/scripts/batch_package_purchase/assets.example_购买结果_20260724_130306.csv b/scripts/batch_package_purchase/assets.example_购买结果_20260724_130306.csv deleted file mode 100644 index 7021133..0000000 --- a/scripts/batch_package_purchase/assets.example_购买结果_20260724_130306.csv +++ /dev/null @@ -1,551 +0,0 @@ -line_no,identifier,status,http_status,code,msg,order_id,order_no,total_amount -2,898604A8012270312000,成功,200,0,success,30068,ORD20260724130306370347,600 -3,898604A8012270312001,成功,200,0,success,30069,ORD20260724130306069885,600 -4,898604A8012270312002,成功,200,0,success,30070,ORD20260724130307308044,600 -5,898604A8012270312003,成功,200,0,success,30071,ORD20260724130307416407,600 -6,898604A8012270312004,成功,200,0,success,30072,ORD20260724130308762086,600 -7,898604A8012270312005,成功,200,0,success,30073,ORD20260724130308872425,600 -8,898604A8012270312006,成功,200,0,success,30074,ORD20260724130309471278,600 -9,898604A8012270312007,成功,200,0,success,30075,ORD20260724130309129677,600 -10,898604A8012270312008,成功,200,0,success,30076,ORD20260724130309721027,600 -11,898604A8012270312009,成功,200,0,success,30077,ORD20260724130310596954,600 -12,898604A8012270312010,成功,200,0,success,30078,ORD20260724130310712467,600 -13,898604A8012270312011,成功,200,0,success,30079,ORD20260724130311942350,600 -14,898604A8012270312012,成功,200,0,success,30080,ORD20260724130311435126,600 -15,898604A8012270312013,成功,200,0,success,30081,ORD20260724130311014596,600 -16,898604A8012270312014,成功,200,0,success,30082,ORD20260724130312348939,600 -17,898604A8012270312015,成功,200,0,success,30083,ORD20260724130312828641,600 -18,898604A8012270312016,成功,200,0,success,30084,ORD20260724130313060613,600 -19,898604A8012270312017,成功,200,0,success,30085,ORD20260724130313751714,600 -20,898604A8012270312018,成功,200,0,success,30086,ORD20260724130314248259,600 -21,898604A8012270312019,成功,200,0,success,30087,ORD20260724130314740972,600 -22,898604A8012270312020,成功,200,0,success,30088,ORD20260724130314436214,600 -23,898604A8012270312021,成功,200,0,success,30089,ORD20260724130315490988,600 -24,898604A8012270312022,成功,200,0,success,30090,ORD20260724130315977211,600 -25,898604A8012270312023,成功,200,0,success,30091,ORD20260724130315166762,600 -26,898604A8012270312024,成功,200,0,success,30092,ORD20260724130316111130,600 -27,898604A8012270312025,成功,200,0,success,30093,ORD20260724130317260516,600 -28,898604A8012270312026,成功,200,0,success,30094,ORD20260724130317509823,600 -29,898604A8012270312027,成功,200,0,success,30095,ORD20260724130318464955,600 -30,898604A8012270312028,成功,200,0,success,30096,ORD20260724130319607166,600 -31,898604A8012270312029,成功,200,0,success,30097,ORD20260724130319407518,600 -32,898604A8012270312030,成功,200,0,success,30098,ORD20260724130319808152,600 -33,898604A8012270312031,成功,200,0,success,30099,ORD20260724130320396116,600 -34,898604A8012270312032,成功,200,0,success,30100,ORD20260724130320419778,600 -35,898604A8012270312033,成功,200,0,success,30101,ORD20260724130321650144,600 -36,898604A8012270312034,成功,200,0,success,30102,ORD20260724130321966171,600 -37,898604A8012270312035,成功,200,0,success,30103,ORD20260724130322093259,600 -38,898604A8012270312036,成功,200,0,success,30104,ORD20260724130322481254,600 -39,898604A8012270312037,成功,200,0,success,30105,ORD20260724130322823190,600 -40,898604A8012270312038,成功,200,0,success,30106,ORD20260724130323861066,600 -41,898604A8012270312039,成功,200,0,success,30107,ORD20260724130323104567,600 -42,898604A8012270312040,成功,200,0,success,30108,ORD20260724130324776288,600 -43,898604A8012270312041,成功,200,0,success,30109,ORD20260724130324127246,600 -44,898604A8012270312042,成功,200,0,success,30110,ORD20260724130325645601,600 -45,898604A8012270312043,成功,200,0,success,30111,ORD20260724130326297990,600 -46,898604A8012270312044,成功,200,0,success,30112,ORD20260724130327785398,600 -47,898604A8012270312045,成功,200,0,success,30113,ORD20260724130328514674,600 -48,898604A8012270312046,成功,200,0,success,30114,ORD20260724130329677063,600 -49,898604A8012270312047,成功,200,0,success,30115,ORD20260724130329445412,600 -50,898604A8012270312048,成功,200,0,success,30116,ORD20260724130330288241,600 -51,898604A8012270312049,成功,200,0,success,30117,ORD20260724130331809635,600 -52,898604A8012270312050,成功,200,0,success,30118,ORD20260724130331245655,600 -53,898604A8012270312051,成功,200,0,success,30119,ORD20260724130332040988,600 -54,898604A8012270312052,成功,200,0,success,30120,ORD20260724130332666410,600 -55,898604A8012270312053,成功,200,0,success,30121,ORD20260724130333807490,600 -56,898604A8012270312054,成功,200,0,success,30122,ORD20260724130333743808,600 -57,898604A8012270312055,成功,200,0,success,30123,ORD20260724130334479629,600 -58,898604A8012270312056,成功,200,0,success,30124,ORD20260724130335072534,600 -59,898604A8012270312057,成功,200,0,success,30125,ORD20260724130336078006,600 -60,898604A8012270312058,成功,200,0,success,30126,ORD20260724130337424695,600 -61,898604A8012270312059,成功,200,0,success,30127,ORD20260724130338884577,600 -62,898604A8012270312060,成功,200,0,success,30128,ORD20260724130339182426,600 -63,898604A8012270312061,成功,200,0,success,30129,ORD20260724130339667408,600 -64,898604A8012270312062,成功,200,0,success,30130,ORD20260724130340179246,600 -65,898604A8012270312063,成功,200,0,success,30131,ORD20260724130341148553,600 -66,898604A8012270312064,成功,200,0,success,30132,ORD20260724130342181756,600 -67,898604A8012270312065,成功,200,0,success,30133,ORD20260724130343036273,600 -68,898604A8012270312066,成功,200,0,success,30134,ORD20260724130343124871,600 -69,898604A8012270312067,成功,200,0,success,30135,ORD20260724130345585234,600 -70,898604A8012270312068,成功,200,0,success,30136,ORD20260724130346834357,600 -71,898604A8012270312069,成功,200,0,success,30137,ORD20260724130347809735,600 -72,898604A8012270312070,成功,200,0,success,30138,ORD20260724130348333269,600 -73,898604A8012270312071,成功,200,0,success,30139,ORD20260724130348682274,600 -74,898604A8012270312072,成功,200,0,success,30140,ORD20260724130349398594,600 -75,898604A8012270312073,成功,200,0,success,30141,ORD20260724130349035241,600 -76,898604A8012270312074,成功,200,0,success,30142,ORD20260724130350615124,600 -77,898604A8012270312075,成功,200,0,success,30143,ORD20260724130350415301,600 -78,898604A8012270312076,成功,200,0,success,30144,ORD20260724130350955649,600 -79,898604A8012270312077,成功,200,0,success,30145,ORD20260724130351745875,600 -80,898604A8012270312078,成功,200,0,success,30146,ORD20260724130351288521,600 -81,898604A8012270312079,成功,200,0,success,30147,ORD20260724130352501341,600 -82,898604A8012270312080,成功,200,0,success,30148,ORD20260724130353449526,600 -83,898604A8012270312081,成功,200,0,success,30149,ORD20260724130353346262,600 -84,898604A8012270312082,成功,200,0,success,30150,ORD20260724130353196926,600 -85,898604A8012270312083,成功,200,0,success,30151,ORD20260724130354702420,600 -86,898604A8012270312084,成功,200,0,success,30152,ORD20260724130354319427,600 -87,898604A8012270312085,成功,200,0,success,30153,ORD20260724130355713869,600 -88,898604A8012270312086,成功,200,0,success,30154,ORD20260724130355874872,600 -89,898604A8012270312087,成功,200,0,success,30155,ORD20260724130356948473,600 -90,898604A8012270312088,成功,200,0,success,30156,ORD20260724130356864268,600 -91,898604A8012270312089,成功,200,0,success,30157,ORD20260724130356882889,600 -92,898604A8012270312090,成功,200,0,success,30158,ORD20260724130357354724,600 -93,898604A8012270312091,成功,200,0,success,30159,ORD20260724130357206653,600 -94,898604A8012270312092,成功,200,0,success,30160,ORD20260724130358418770,600 -95,898604A8012270312093,成功,200,0,success,30161,ORD20260724130359260531,600 -96,898604A8012270312094,成功,200,0,success,30162,ORD20260724130359103577,600 -97,898604A8012270312095,成功,200,0,success,30163,ORD20260724130400367096,600 -98,898604A8012270312096,成功,200,0,success,30164,ORD20260724130400084976,600 -99,898604A8012270312097,成功,200,0,success,30165,ORD20260724130401560965,600 -100,898604A8012270312098,成功,200,0,success,30166,ORD20260724130401443819,600 -101,898604A8012270312099,成功,200,0,success,30167,ORD20260724130402248233,600 -102,898604A8012270312100,成功,200,0,success,30168,ORD20260724130402329779,600 -103,898604A8012270312101,成功,200,0,success,30169,ORD20260724130402400899,600 -104,898604A8012270312102,成功,200,0,success,30170,ORD20260724130403100138,600 -105,898604A8012270312103,成功,200,0,success,30171,ORD20260724130403559476,600 -106,898604A8012270312104,成功,200,0,success,30172,ORD20260724130404322961,600 -107,898604A8012270312105,成功,200,0,success,30173,ORD20260724130404148967,600 -108,898604A8012270312106,成功,200,0,success,30174,ORD20260724130404177642,600 -109,898604A8012270312107,成功,200,0,success,30175,ORD20260724130405785999,600 -110,898604A8012270312108,成功,200,0,success,30176,ORD20260724130405519466,600 -111,898604A8012270312109,成功,200,0,success,30177,ORD20260724130406220074,600 -112,898604A8012270312110,成功,200,0,success,30178,ORD20260724130406161990,600 -113,898604A8012270312111,成功,200,0,success,30179,ORD20260724130407037068,600 -114,898604A8012270312112,成功,200,0,success,30180,ORD20260724130407167640,600 -115,898604A8012270312113,成功,200,0,success,30181,ORD20260724130408535733,600 -116,898604A8012270312114,成功,200,0,success,30182,ORD20260724130409893323,600 -117,898604A8012270312115,成功,200,0,success,30183,ORD20260724130409447467,600 -118,898604A8012270312116,成功,200,0,success,30184,ORD20260724130410736837,600 -119,898604A8012270312117,成功,200,0,success,30185,ORD20260724130411616978,600 -120,898604A8012270312118,成功,200,0,success,30186,ORD20260724130411284278,600 -121,898604A8012270312119,成功,200,0,success,30187,ORD20260724130411415968,600 -122,898604A8012270312120,成功,200,0,success,30188,ORD20260724130412175626,600 -123,898604A8012270312121,成功,200,0,success,30189,ORD20260724130412192720,600 -124,898604A8012270312122,成功,200,0,success,30190,ORD20260724130413846448,600 -125,898604A8012270312123,成功,200,0,success,30191,ORD20260724130414578916,600 -126,898604A8012270312124,成功,200,0,success,30192,ORD20260724130415656628,600 -127,898604A8012270312125,成功,200,0,success,30193,ORD20260724130416885746,600 -128,898604A8012270312126,成功,200,0,success,30194,ORD20260724130417207392,600 -129,898604A8012270312127,成功,200,0,success,30195,ORD20260724130418355086,600 -130,898604A8012270312128,成功,200,0,success,30196,ORD20260724130418060629,600 -131,898604A8012270312129,成功,200,0,success,30197,ORD20260724130419954497,600 -132,898604A8012270312130,成功,200,0,success,30198,ORD20260724130420537747,600 -133,898604A8012270312131,成功,200,0,success,30199,ORD20260724130420635143,600 -134,898604A8012270312132,成功,200,0,success,30200,ORD20260724130421556473,600 -135,898604A8012270312133,成功,200,0,success,30201,ORD20260724130421284734,600 -136,898604A8012270312134,成功,200,0,success,30202,ORD20260724130422479524,600 -137,898604A8012270312135,成功,200,0,success,30203,ORD20260724130423808615,600 -138,898604A8012270312136,成功,200,0,success,30204,ORD20260724130423047294,600 -139,898604A8012270312137,成功,200,0,success,30205,ORD20260724130424936847,600 -140,898604A8012270312138,成功,200,0,success,30206,ORD20260724130424390578,600 -141,898604A8012270312139,成功,200,0,success,30207,ORD20260724130425906876,600 -142,898604A8012270312140,成功,200,0,success,30208,ORD20260724130426148243,600 -143,898604A8012270312141,成功,200,0,success,30209,ORD20260724130427941389,600 -144,898604A8012270312142,成功,200,0,success,30210,ORD20260724130427017407,600 -145,898604A8012270312143,成功,200,0,success,30211,ORD20260724130428757829,600 -146,898604A8012270312144,成功,200,0,success,30212,ORD20260724130428925402,600 -147,898604A8012270312145,成功,200,0,success,30213,ORD20260724130429440223,600 -148,898604A8012270312146,成功,200,0,success,30214,ORD20260724130430239285,600 -149,898604A8012270312147,成功,200,0,success,30215,ORD20260724130431122277,600 -150,898604A8012270312148,成功,200,0,success,30216,ORD20260724130431607142,600 -151,898604A8012270312149,成功,200,0,success,30217,ORD20260724130432892401,600 -152,898604A8012270312150,成功,200,0,success,30218,ORD20260724130433826340,600 -153,898604A8012270312151,成功,200,0,success,30219,ORD20260724130433681886,600 -154,898604A8012270312152,成功,200,0,success,30220,ORD20260724130434992111,600 -155,898604A8012270312153,成功,200,0,success,30221,ORD20260724130435931762,600 -156,898604A8012270312154,成功,200,0,success,30222,ORD20260724130436427731,600 -157,898604A8012270312155,成功,200,0,success,30223,ORD20260724130436355440,600 -158,898604A8012270312156,成功,200,0,success,30224,ORD20260724130437910346,600 -159,898604A8012270312157,成功,200,0,success,30225,ORD20260724130438291165,600 -160,898604A8012270312158,成功,200,0,success,30226,ORD20260724130438950252,600 -161,898604A8012270312159,成功,200,0,success,30227,ORD20260724130439120253,600 -162,898604A8012270312160,成功,200,0,success,30228,ORD20260724130440953528,600 -163,898604A8012270312161,成功,200,0,success,30229,ORD20260724130440243862,600 -164,898604A8012270312162,成功,200,0,success,30230,ORD20260724130441532543,600 -165,898604A8012270312163,成功,200,0,success,30231,ORD20260724130442272476,600 -166,898604A8012270312164,成功,200,0,success,30232,ORD20260724130443877361,600 -167,898604A8012270312165,成功,200,0,success,30233,ORD20260724130443746499,600 -168,898604A8012270312166,成功,200,0,success,30234,ORD20260724130444629737,600 -169,898604A8012270312167,成功,200,0,success,30235,ORD20260724130444235707,600 -170,898604A8012270312168,成功,200,0,success,30236,ORD20260724130445578895,600 -171,898604A8012270312169,成功,200,0,success,30237,ORD20260724130445524490,600 -172,898604A8012270312170,成功,200,0,success,30238,ORD20260724130445734010,600 -173,898604A8012270312171,成功,200,0,success,30239,ORD20260724130446853282,600 -174,898604A8012270312172,成功,200,0,success,30240,ORD20260724130446103637,600 -175,898604A8012270312173,成功,200,0,success,30241,ORD20260724130447502869,600 -176,898604A8012270312174,成功,200,0,success,30242,ORD20260724130447799715,600 -177,898604A8012270312175,成功,200,0,success,30243,ORD20260724130447272920,600 -178,898604A8012270312176,成功,200,0,success,30244,ORD20260724130448434729,600 -179,898604A8012270312177,成功,200,0,success,30245,ORD20260724130449320016,600 -180,898604A8012270312178,成功,200,0,success,30246,ORD20260724130449594988,600 -181,898604A8012270312179,成功,200,0,success,30247,ORD20260724130450758373,600 -182,898604A8012270312180,成功,200,0,success,30248,ORD20260724130451002052,600 -183,898604A8012270312181,成功,200,0,success,30249,ORD20260724130451827734,600 -184,898604A8012270312182,成功,200,0,success,30250,ORD20260724130452333807,600 -185,898604A8012270312183,成功,200,0,success,30251,ORD20260724130452225945,600 -186,898604A8012270312184,成功,200,0,success,30252,ORD20260724130452299122,600 -187,898604A8012270312185,成功,200,0,success,30253,ORD20260724130453518396,600 -188,898604A8012270312186,成功,200,0,success,30254,ORD20260724130453188542,600 -189,898604A8012270312187,成功,200,0,success,30255,ORD20260724130454243862,600 -190,898604A8012270312188,成功,200,0,success,30256,ORD20260724130454069408,600 -191,898604A8012270312189,成功,200,0,success,30257,ORD20260724130455664554,600 -192,898604A8012270312190,成功,200,0,success,30258,ORD20260724130455159678,600 -193,898604A8012270312191,成功,200,0,success,30259,ORD20260724130455822278,600 -194,898604A8012270312192,成功,200,0,success,30260,ORD20260724130456542869,600 -195,898604A8012270312193,成功,200,0,success,30261,ORD20260724130456903951,600 -196,898604A8012270312194,成功,200,0,success,30262,ORD20260724130457779367,600 -197,898604A8012270312195,成功,200,0,success,30263,ORD20260724130457396046,600 -198,898604A8012270312196,成功,200,0,success,30264,ORD20260724130458952991,600 -199,898604A8012270312197,成功,200,0,success,30265,ORD20260724130458704239,600 -200,898604A8012270312198,成功,200,0,success,30266,ORD20260724130458597159,600 -201,898604A8012270312199,成功,200,0,success,30267,ORD20260724130459072126,600 -202,898604A8012270312200,成功,200,0,success,30268,ORD20260724130459792564,600 -203,898604A8012270312201,成功,200,0,success,30269,ORD20260724130500364949,600 -204,898604A8012270312202,成功,200,0,success,30270,ORD20260724130500722450,600 -205,898604A8012270312203,成功,200,0,success,30271,ORD20260724130500240261,600 -206,898604A8012270312204,成功,200,0,success,30272,ORD20260724130501845611,600 -207,898604A8012270312205,成功,200,0,success,30273,ORD20260724130501602839,600 -208,898604A8012270312206,成功,200,0,success,30274,ORD20260724130502686176,600 -209,898604A8012270312207,成功,200,0,success,30275,ORD20260724130502156668,600 -210,898604A8012270312208,成功,200,0,success,30276,ORD20260724130502871652,600 -211,898604A8012270312209,成功,200,0,success,30277,ORD20260724130503422088,600 -212,898604A8012270312210,成功,200,0,success,30278,ORD20260724130503882605,600 -213,898604A8012270312211,成功,200,0,success,30279,ORD20260724130504053657,600 -214,898604A8012270312212,成功,200,0,success,30280,ORD20260724130504909258,600 -215,898604A8012270312213,成功,200,0,success,30281,ORD20260724130505599448,600 -216,898604A8012270312214,成功,200,0,success,30282,ORD20260724130505677013,600 -217,898604A8012270312215,成功,200,0,success,30283,ORD20260724130505926358,600 -218,898604A8012270312216,成功,200,0,success,30284,ORD20260724130506656596,600 -219,898604A8012270312217,成功,200,0,success,30285,ORD20260724130506041094,600 -220,898604A8012270312218,成功,200,0,success,30286,ORD20260724130507557360,600 -221,898604A8012270312219,成功,200,0,success,30287,ORD20260724130507068376,600 -222,898604A8012270312220,成功,200,0,success,30288,ORD20260724130507319633,600 -223,898604A8012270312221,成功,200,0,success,30289,ORD20260724130508818149,600 -224,898604A8012270312222,成功,200,0,success,30290,ORD20260724130508037772,600 -225,898604A8012270312223,成功,200,0,success,30291,ORD20260724130509231592,600 -226,898604A8012270312224,成功,200,0,success,30292,ORD20260724130509637399,600 -227,898604A8012270312225,成功,200,0,success,30293,ORD20260724130509192432,600 -228,898604A8012270312226,成功,200,0,success,30294,ORD20260724130510165715,600 -229,898604A8012270312227,成功,200,0,success,30295,ORD20260724130510942353,600 -230,898604A8012270312228,成功,200,0,success,30296,ORD20260724130510658564,600 -231,898604A8012270312229,成功,200,0,success,30297,ORD20260724130511226502,600 -232,898604A8012270312230,成功,200,0,success,30298,ORD20260724130511659729,600 -233,898604A8012270312231,成功,200,0,success,30299,ORD20260724130512740485,600 -234,898604A8012270312232,成功,200,0,success,30300,ORD20260724130512937784,600 -235,898604A8012270312233,成功,200,0,success,30301,ORD20260724130513571843,600 -236,898604A8012270312234,成功,200,0,success,30302,ORD20260724130513954859,600 -237,898604A8012270312235,成功,200,0,success,30303,ORD20260724130513444782,600 -238,898604A8012270312236,成功,200,0,success,30304,ORD20260724130514239954,600 -239,898604A8012270312237,成功,200,0,success,30305,ORD20260724130514812808,600 -240,898604A8012270312238,成功,200,0,success,30306,ORD20260724130514322740,600 -241,898604A8012270312239,成功,200,0,success,30307,ORD20260724130515318301,600 -242,898604A8012270312240,成功,200,0,success,30308,ORD20260724130515115516,600 -243,898604A8012270312241,成功,200,0,success,30309,ORD20260724130516023553,600 -244,898604A8012270312242,成功,200,0,success,30310,ORD20260724130516857702,600 -245,898604A8012270312243,成功,200,0,success,30311,ORD20260724130516326906,600 -246,898604A8012270312244,成功,200,0,success,30312,ORD20260724130517860664,600 -247,898604A8012270312245,成功,200,0,success,30313,ORD20260724130517630459,600 -248,898604A8012270312246,成功,200,0,success,30314,ORD20260724130518772889,600 -249,898604A8012270312247,成功,200,0,success,30315,ORD20260724130518404662,600 -250,898604A8012270312248,成功,200,0,success,30316,ORD20260724130519398984,600 -251,898604A8012270312249,成功,200,0,success,30317,ORD20260724130519602164,600 -252,898604A8012270312250,成功,200,0,success,30318,ORD20260724130519679582,600 -253,898604A8012270312251,成功,200,0,success,30319,ORD20260724130520636441,600 -254,898604A8012270312252,成功,200,0,success,30320,ORD20260724130520503621,600 -255,898604A8012270312253,成功,200,0,success,30321,ORD20260724130520067978,600 -256,898604A8012270312254,成功,200,0,success,30322,ORD20260724130521284515,600 -257,898604A8012270312255,成功,200,0,success,30323,ORD20260724130521573976,600 -258,898604A8012270312256,成功,200,0,success,30324,ORD20260724130522649929,600 -259,898604A8012270312257,成功,200,0,success,30325,ORD20260724130522548933,600 -260,898604A8012270312258,成功,200,0,success,30326,ORD20260724130522047969,600 -261,898604A8012270312259,成功,200,0,success,30327,ORD20260724130523057061,600 -262,898604A8012270312260,成功,200,0,success,30328,ORD20260724130523224779,600 -263,898604A8012270312261,成功,200,0,success,30329,ORD20260724130524437102,600 -264,898604A8012270312262,成功,200,0,success,30330,ORD20260724130524748786,600 -265,898604A8012270312263,成功,200,0,success,30331,ORD20260724130524270498,600 -266,898604A8012270312264,成功,200,0,success,30332,ORD20260724130525003527,600 -267,898604A8012270312265,成功,200,0,success,30333,ORD20260724130525209785,600 -268,898604A8012270312266,成功,200,0,success,30334,ORD20260724130526819846,600 -269,898604A8012270312267,成功,200,0,success,30335,ORD20260724130526296210,600 -270,898604A8012270312268,成功,200,0,success,30336,ORD20260724130527821261,600 -271,898604A8012270312269,成功,200,0,success,30337,ORD20260724130527862263,600 -272,898604A8012270312270,成功,200,0,success,30338,ORD20260724130528908834,600 -273,898604A8012270312271,成功,200,0,success,30339,ORD20260724130528425508,600 -274,898604A8012270312272,成功,200,0,success,30340,ORD20260724130529223597,600 -275,898604A8012270312273,成功,200,0,success,30341,ORD20260724130529475260,600 -276,898604A8012270312274,成功,200,0,success,30342,ORD20260724130530016171,600 -277,898604A8012270312275,成功,200,0,success,30343,ORD20260724130530259478,600 -278,898604A8012270312276,成功,200,0,success,30344,ORD20260724130530354407,600 -279,898604A8012270312277,成功,200,0,success,30345,ORD20260724130531023321,600 -280,898604A8012270312278,成功,200,0,success,30346,ORD20260724130531979694,600 -281,898604A8012270312279,成功,200,0,success,30347,ORD20260724130531744827,600 -282,898604A8012270312280,成功,200,0,success,30348,ORD20260724130532383244,600 -283,898604A8012270312281,成功,200,0,success,30349,ORD20260724130532692448,600 -284,898604A8012270312282,成功,200,0,success,30350,ORD20260724130533023091,600 -285,898604A8012270312283,成功,200,0,success,30351,ORD20260724130533032218,600 -286,898604A8012270312284,成功,200,0,success,30352,ORD20260724130533073716,600 -287,898604A8012270312285,成功,200,0,success,30353,ORD20260724130534653491,600 -288,898604A8012270312286,成功,200,0,success,30354,ORD20260724130535576563,600 -289,898604A8012270312287,成功,200,0,success,30355,ORD20260724130536735721,600 -290,898604A8012270312288,成功,200,0,success,30356,ORD20260724130537434412,600 -291,898604A8012270312289,成功,200,0,success,30357,ORD20260724130538354682,600 -292,898604A8012270312290,成功,200,0,success,30358,ORD20260724130539875601,600 -293,898604A8012270312291,成功,200,0,success,30359,ORD20260724130539087961,600 -294,898604A8012270312292,成功,200,0,success,30360,ORD20260724130542599452,600 -295,898604A8012270312293,成功,200,0,success,30361,ORD20260724130542011398,600 -296,898604A8012270312294,成功,200,0,success,30362,ORD20260724130543307468,600 -297,898604A8012270312295,成功,200,0,success,30363,ORD20260724130543015310,600 -298,898604A8012270312296,成功,200,0,success,30364,ORD20260724130544849884,600 -299,898604A8012270312297,成功,200,0,success,30365,ORD20260724130544549373,600 -300,898604A8012270312298,成功,200,0,success,30366,ORD20260724130544599241,600 -301,898604A8012270312299,成功,200,0,success,30367,ORD20260724130545252361,600 -302,898604A8012270312300,成功,200,0,success,30368,ORD20260724130545114222,600 -303,898604A8012270312301,成功,200,0,success,30369,ORD20260724130545126839,600 -304,898604A8012270312302,成功,200,0,success,30370,ORD20260724130546186875,600 -305,898604A8012270312303,成功,200,0,success,30371,ORD20260724130546847566,600 -306,898604A8012270312304,成功,200,0,success,30372,ORD20260724130547543999,600 -307,898604A8012270312305,成功,200,0,success,30373,ORD20260724130547181884,600 -308,898604A8012270312306,成功,200,0,success,30374,ORD20260724130547147638,600 -309,898604A8012270312307,成功,200,0,success,30375,ORD20260724130548684347,600 -310,898604A8012270312308,成功,200,0,success,30376,ORD20260724130548798124,600 -311,898604A8012270312309,成功,200,0,success,30377,ORD20260724130549884681,600 -312,898604A8012270312310,成功,200,0,success,30378,ORD20260724130549692743,600 -313,898604A8012270312311,成功,200,0,success,30379,ORD20260724130549860560,600 -314,898604A8012270312312,成功,200,0,success,30380,ORD20260724130550380381,600 -315,898604A8012270312313,成功,200,0,success,30381,ORD20260724130550304616,600 -316,898604A8012270312314,成功,200,0,success,30382,ORD20260724130550504182,600 -317,898604A8012270312315,成功,200,0,success,30383,ORD20260724130551604949,600 -318,898604A8012270312316,成功,200,0,success,30384,ORD20260724130552747964,600 -319,898604A8012270312317,成功,200,0,success,30385,ORD20260724130552165692,600 -320,898604A8012270312318,成功,200,0,success,30386,ORD20260724130552514971,600 -321,898604A8012270312319,成功,200,0,success,30387,ORD20260724130553965542,600 -322,898604A8012270312320,成功,200,0,success,30388,ORD20260724130553552414,600 -323,898604A8012270312321,成功,200,0,success,30389,ORD20260724130553547339,600 -324,898604A8012270312322,成功,200,0,success,30390,ORD20260724130554508256,600 -325,898604A8012270312323,成功,200,0,success,30391,ORD20260724130554188530,600 -326,898604A8012270312324,成功,200,0,success,30392,ORD20260724130555560940,600 -327,898604A8012270312325,成功,200,0,success,30393,ORD20260724130555908872,600 -328,898604A8012270312326,成功,200,0,success,30394,ORD20260724130555622724,600 -329,898604A8012270312327,成功,200,0,success,30395,ORD20260724130556588663,600 -330,898604A8012270312328,成功,200,0,success,30396,ORD20260724130556268196,600 -331,898604A8012270312329,成功,200,0,success,30397,ORD20260724130556136449,600 -332,898604A8012270312330,成功,200,0,success,30398,ORD20260724130557328229,600 -333,898604A8012270312331,成功,200,0,success,30399,ORD20260724130557092644,600 -334,898604A8012270312332,成功,200,0,success,30400,ORD20260724130558118599,600 -335,898604A8012270312333,成功,200,0,success,30401,ORD20260724130559776369,600 -336,898604A8012270312334,成功,200,0,success,30402,ORD20260724130559235003,600 -337,898604A8012270312335,成功,200,0,success,30404,ORD20260724130600449072,600 -338,898604A8012270312336,成功,200,0,success,30405,ORD20260724130600189073,600 -339,898604A8012270312337,成功,200,0,success,30406,ORD20260724130601687215,600 -340,898604A8012270312338,成功,200,0,success,30407,ORD20260724130601284801,600 -341,898604A8012270312339,成功,200,0,success,30408,ORD20260724130601284546,600 -342,898604A8012270312340,成功,200,0,success,30409,ORD20260724130602702212,600 -343,898604A8012270312341,成功,200,0,success,30412,ORD20260724130603954573,600 -344,898604A8012270312342,成功,200,0,success,30413,ORD20260724130604274949,600 -345,898604A8012270312343,成功,200,0,success,30414,ORD20260724130604193982,600 -346,898604A8012270312344,成功,200,0,success,30415,ORD20260724130605196962,600 -347,898604A8012270312345,成功,200,0,success,30416,ORD20260724130605021598,600 -348,898604A8012270312346,成功,200,0,success,30419,ORD20260724130606466709,600 -349,898604A8012270312347,成功,200,0,success,30420,ORD20260724130606968654,600 -350,898604A8012270312348,成功,200,0,success,30421,ORD20260724130606354884,600 -351,898604A8012270312349,成功,200,0,success,30422,ORD20260724130607477923,600 -352,898604A8012270312350,成功,200,0,success,30423,ORD20260724130607502632,600 -353,898604A8012270312351,成功,200,0,success,30424,ORD20260724130607917453,600 -354,898604A8012270312352,成功,200,0,success,30425,ORD20260724130608838129,600 -355,898604A8012270312353,成功,200,0,success,30427,ORD20260724130609126378,600 -356,898604A8012270312354,成功,200,0,success,30428,ORD20260724130609878588,600 -357,898604A8012270312355,成功,200,0,success,30429,ORD20260724130610617592,600 -358,898604A8012270312356,成功,200,0,success,30430,ORD20260724130610027427,600 -359,898604A8012270312357,成功,200,0,success,30431,ORD20260724130610132288,600 -360,898604A8012270312358,成功,200,0,success,30432,ORD20260724130611860940,600 -361,898604A8012270312359,成功,200,0,success,30433,ORD20260724130611176929,600 -362,898604A8012270312360,成功,200,0,success,30434,ORD20260724130612700143,600 -363,898604A8012270312361,成功,200,0,success,30435,ORD20260724130612297773,600 -364,898604A8012270312362,成功,200,0,success,30436,ORD20260724130613642272,600 -365,898604A8012270312363,成功,200,0,success,30437,ORD20260724130613475739,600 -366,898604A8012270312364,成功,200,0,success,30438,ORD20260724130614283321,600 -367,898604A8012270312365,成功,200,0,success,30439,ORD20260724130615138577,600 -368,898604A8012270312366,成功,200,0,success,30440,ORD20260724130616791398,600 -369,898604A8012270312367,成功,200,0,success,30441,ORD20260724130617039796,600 -370,898604A8012270312368,成功,200,0,success,30442,ORD20260724130617286872,600 -371,898604A8012270312369,成功,200,0,success,30443,ORD20260724130618460465,600 -372,898604A8012270312370,成功,200,0,success,30444,ORD20260724130618076180,600 -373,898604A8012270312371,成功,200,0,success,30445,ORD20260724130618849697,600 -374,898604A8012270312372,成功,200,0,success,30446,ORD20260724130619146913,600 -375,898604A8012270312373,成功,200,0,success,30447,ORD20260724130619044088,600 -376,898604A8012270312374,成功,200,0,success,30448,ORD20260724130620167521,600 -377,898604A8012270312375,成功,200,0,success,30449,ORD20260724130620015045,600 -378,898604A8012270312376,成功,200,0,success,30450,ORD20260724130620102025,600 -379,898604A8012270312377,成功,200,0,success,30451,ORD20260724130621970762,600 -380,898604A8012270312378,成功,200,0,success,30452,ORD20260724130621700113,600 -381,898604A8012270312379,成功,200,0,success,30453,ORD20260724130622338568,600 -382,898604A8012270312380,成功,200,0,success,30454,ORD20260724130622552110,600 -383,898604A8012270312381,成功,200,0,success,30455,ORD20260724130623407717,600 -384,898604A8012270312382,成功,200,0,success,30456,ORD20260724130623014561,600 -385,898604A8012270312383,成功,200,0,success,30457,ORD20260724130624249647,600 -386,898604A8012270312384,成功,200,0,success,30458,ORD20260724130624173547,600 -387,898604A8012270312385,成功,200,0,success,30459,ORD20260724130624962189,600 -388,898604A8012270312386,成功,200,0,success,30460,ORD20260724130625915135,600 -389,898604A8012270312387,成功,200,0,success,30461,ORD20260724130625716892,600 -390,898604A8012270312388,成功,200,0,success,30462,ORD20260724130626149376,600 -391,898604A8012270312389,成功,200,0,success,30463,ORD20260724130626458887,600 -392,898604A8012270312390,成功,200,0,success,30464,ORD20260724130626355145,600 -393,898604A8012270312391,成功,200,0,success,30465,ORD20260724130627545760,600 -394,898604A8012270312392,成功,200,0,success,30466,ORD20260724130627134629,600 -395,898604A8012270312393,成功,200,0,success,30467,ORD20260724130628219843,600 -396,898604A8012270312394,成功,200,0,success,30468,ORD20260724130628007661,600 -397,898604A8012270312395,成功,200,0,success,30469,ORD20260724130629026415,600 -398,898604A8012270312396,成功,200,0,success,30470,ORD20260724130629006524,600 -399,898604A8012270312397,成功,200,0,success,30471,ORD20260724130629937436,600 -400,898604A8012270312398,成功,200,0,success,30472,ORD20260724130630123235,600 -401,898604A8012270312399,成功,200,0,success,30473,ORD20260724130630572317,600 -402,898604A8012270312400,成功,200,0,success,30474,ORD20260724130630910602,600 -403,898604A8012270312401,成功,200,0,success,30475,ORD20260724130631288217,600 -404,898604A8012270312402,成功,200,0,success,30476,ORD20260724130631064556,600 -405,898604A8012270312403,成功,200,0,success,30477,ORD20260724130632236245,600 -406,898604A8012270312404,成功,200,0,success,30478,ORD20260724130632702828,600 -407,898604A8012270312405,成功,200,0,success,30479,ORD20260724130633126769,600 -408,898604A8012270312406,成功,200,0,success,30480,ORD20260724130633448473,600 -409,898604A8012270312407,成功,200,0,success,30481,ORD20260724130633287084,600 -410,898604A8012270312408,成功,200,0,success,30482,ORD20260724130634138774,600 -411,898604A8012270312409,成功,200,0,success,30483,ORD20260724130634763722,600 -412,898604A8012270312410,成功,200,0,success,30484,ORD20260724130635337945,600 -413,898604A8012270312411,成功,200,0,success,30485,ORD20260724130635739734,600 -414,898604A8012270312412,成功,200,0,success,30486,ORD20260724130635944553,600 -415,898604A8012270312413,成功,200,0,success,30487,ORD20260724130636890849,600 -416,898604A8012270312414,成功,200,0,success,30488,ORD20260724130636412153,600 -417,898604A8012270312415,成功,200,0,success,30489,ORD20260724130637424282,600 -418,898604A8012270312416,成功,200,0,success,30490,ORD20260724130637646977,600 -419,898604A8012270312417,成功,200,0,success,30491,ORD20260724130637180940,600 -420,898604A8012270312418,成功,200,0,success,30492,ORD20260724130638003864,600 -421,898604A8012270312419,成功,200,0,success,30493,ORD20260724130638352638,600 -422,898604A8012270312420,成功,200,0,success,30494,ORD20260724130639612733,600 -423,898604A8012270312421,成功,200,0,success,30495,ORD20260724130639817975,600 -424,898604A8012270312422,成功,200,0,success,30496,ORD20260724130640114122,600 -425,898604A8012270312423,成功,200,0,success,30497,ORD20260724130640062038,600 -426,898604A8012270312424,成功,200,0,success,30498,ORD20260724130640538816,600 -427,898604A8012270312425,成功,200,0,success,30499,ORD20260724130641392459,600 -428,898604A8012270312426,成功,200,0,success,30500,ORD20260724130641993192,600 -429,898604A8012270312427,成功,200,0,success,30501,ORD20260724130642628107,600 -430,898604A8012270312428,成功,200,0,success,30502,ORD20260724130642145898,600 -431,898604A8012270312429,成功,200,0,success,30503,ORD20260724130642678657,600 -432,898604A8012270312430,成功,200,0,success,30504,ORD20260724130643223183,600 -433,898604A8012270312431,成功,200,0,success,30505,ORD20260724130644675384,600 -434,898604A8012270312432,成功,200,0,success,30506,ORD20260724130645994278,600 -435,898604A8012270312433,成功,200,0,success,30507,ORD20260724130645627360,600 -436,898604A8012270312434,成功,200,0,success,30508,ORD20260724130646667388,600 -437,898604A8012270312435,成功,200,0,success,30509,ORD20260724130646296910,600 -438,898604A8012270312436,成功,200,0,success,30510,ORD20260724130647660429,600 -439,898604A8012270312437,成功,200,0,success,30511,ORD20260724130647380697,600 -440,898604A8012270312438,成功,200,0,success,30512,ORD20260724130647401458,600 -441,898604A8012270312439,成功,200,0,success,30513,ORD20260724130648243263,600 -442,898604A8012270312440,成功,200,0,success,30514,ORD20260724130648741168,600 -443,898604A8012270312441,成功,200,0,success,30515,ORD20260724130649528545,600 -444,898604A8012270312442,成功,200,0,success,30516,ORD20260724130649180520,600 -445,898604A8012270312443,成功,200,0,success,30517,ORD20260724130649711183,600 -446,898604A8012270312444,成功,200,0,success,30518,ORD20260724130650930396,600 -447,898604A8012270312445,成功,200,0,success,30519,ORD20260724130650078491,600 -448,898604A8012270312446,成功,200,0,success,30520,ORD20260724130651560835,600 -449,898604A8012270312447,成功,200,0,success,30521,ORD20260724130652416251,600 -450,898604A8012270312448,成功,200,0,success,30522,ORD20260724130653836455,600 -451,898604A8012270312449,成功,200,0,success,30523,ORD20260724130654484205,600 -452,898604A8012270312450,成功,200,0,success,30524,ORD20260724130654193355,600 -453,898604A8012270312451,成功,200,0,success,30525,ORD20260724130655672983,600 -454,898604A8012270312452,成功,200,0,success,30526,ORD20260724130655297092,600 -455,898604A8012270312453,成功,200,0,success,30527,ORD20260724130655657688,600 -456,898604A8012270312454,成功,200,0,success,30528,ORD20260724130656979277,600 -457,898604A8012270312455,成功,200,0,success,30529,ORD20260724130656600214,600 -458,898604A8012270312456,成功,200,0,success,30530,ORD20260724130657430401,600 -459,898604A8012270312457,成功,200,0,success,30531,ORD20260724130657960549,600 -460,898604A8012270312458,成功,200,0,success,30532,ORD20260724130658071113,600 -461,898604A8012270312459,成功,200,0,success,30533,ORD20260724130658645070,600 -462,898604A8012270312460,成功,200,0,success,30534,ORD20260724130659267835,600 -463,898604A8012270312461,成功,200,0,success,30535,ORD20260724130659875786,600 -464,898604A8012270312462,成功,200,0,success,30536,ORD20260724130659048365,600 -465,898604A8012270312463,成功,200,0,success,30537,ORD20260724130700086056,600 -466,898604A8012270312464,成功,200,0,success,30538,ORD20260724130700807298,600 -467,898604A8012270312465,成功,200,0,success,30539,ORD20260724130701879900,600 -468,898604A8012270312466,成功,200,0,success,30540,ORD20260724130701399152,600 -469,898604A8012270312467,成功,200,0,success,30541,ORD20260724130702024347,600 -470,898604A8012270312468,成功,200,0,success,30542,ORD20260724130702850184,600 -471,898604A8012270312469,成功,200,0,success,30543,ORD20260724130703793357,600 -472,898604A8012270312470,成功,200,0,success,30544,ORD20260724130703235524,600 -473,898604A8012270312471,成功,200,0,success,30545,ORD20260724130704670599,600 -474,898604A8012270312472,成功,200,0,success,30546,ORD20260724130704801676,600 -475,898604A8012270312473,成功,200,0,success,30547,ORD20260724130705744051,600 -476,898604A8012270312474,成功,200,0,success,30548,ORD20260724130705506053,600 -477,898604A8012270312475,成功,200,0,success,30549,ORD20260724130705312832,600 -478,898604A8012270312476,成功,200,0,success,30550,ORD20260724130706318085,600 -479,898604A8012270312477,成功,200,0,success,30551,ORD20260724130706277432,600 -480,898604A8012270312478,成功,200,0,success,30552,ORD20260724130707168926,600 -481,898604A8012270312479,成功,200,0,success,30553,ORD20260724130707855317,600 -482,898604A8012270312480,成功,200,0,success,30554,ORD20260724130707539891,600 -483,898604A8012270312481,成功,200,0,success,30555,ORD20260724130708369898,600 -484,898604A8012270312482,成功,200,0,success,30556,ORD20260724130708728689,600 -485,898604A8012270312483,成功,200,0,success,30557,ORD20260724130709172733,600 -486,898604A8012270312484,成功,200,0,success,30558,ORD20260724130709567152,600 -487,898604A8012270312485,成功,200,0,success,30559,ORD20260724130710737281,600 -488,898604A8012270312486,成功,200,0,success,30560,ORD20260724130710624162,600 -489,898604A8012270312487,成功,200,0,success,30561,ORD20260724130710294661,600 -490,898604A8012270312488,成功,200,0,success,30562,ORD20260724130711030822,600 -491,898604A8012270312489,成功,200,0,success,30563,ORD20260724130711516359,600 -492,898604A8012270312490,成功,200,0,success,30564,ORD20260724130712018158,600 -493,898604A8012270312491,成功,200,0,success,30565,ORD20260724130712603443,600 -494,898604A8012270312492,成功,200,0,success,30566,ORD20260724130712899537,600 -495,898604A8012270312493,成功,200,0,success,30567,ORD20260724130713738502,600 -496,898604A8012270312494,成功,200,0,success,30568,ORD20260724130714816423,600 -497,898604A8012270312495,成功,200,0,success,30569,ORD20260724130714784785,600 -498,898604A8012270312496,成功,200,0,success,30570,ORD20260724130714174852,600 -499,898604A8012270312497,成功,200,0,success,30571,ORD20260724130715324337,600 -500,898604A8012270312498,成功,200,0,success,30572,ORD20260724130715949072,600 -501,898604A8012270312499,成功,200,0,success,30573,ORD20260724130716940749,600 -502,898604A8012270312500,成功,200,0,success,30574,ORD20260724130716330921,600 -503,898604A8012270312501,成功,200,0,success,30575,ORD20260724130717900299,600 -504,898604A8012270312502,成功,200,0,success,30576,ORD20260724130717062046,600 -505,898604A8012270312503,成功,200,0,success,30577,ORD20260724130718305769,600 -506,898604A8012270312504,成功,200,0,success,30578,ORD20260724130718609334,600 -507,898604A8012270312505,成功,200,0,success,30579,ORD20260724130719949613,600 -508,898604A8012270312506,成功,200,0,success,30580,ORD20260724130719464163,600 -509,898604A8012270312507,成功,200,0,success,30581,ORD20260724130719531979,600 -510,898604A8012270312508,成功,200,0,success,30582,ORD20260724130720197377,600 -511,898604A8012270312509,成功,200,0,success,30583,ORD20260724130721845273,600 -512,898604A8012270312510,成功,200,0,success,30584,ORD20260724130722001071,600 -513,898604A8012270312511,成功,200,0,success,30585,ORD20260724130722753143,600 -514,898604A8012270312512,成功,200,0,success,30586,ORD20260724130723428672,600 -515,898604A8012270312513,成功,200,0,success,30587,ORD20260724130724070338,600 -516,898604A8012270312514,成功,200,0,success,30588,ORD20260724130724247068,600 -517,898604A8012270312515,成功,200,0,success,30589,ORD20260724130725069597,600 -518,898604A8012270312516,成功,200,0,success,30590,ORD20260724130725091525,600 -519,898604A8012270312517,成功,200,0,success,30591,ORD20260724130726982833,600 -520,898604A8012270312518,成功,200,0,success,30592,ORD20260724130727932536,600 -521,898604A8012270312519,成功,200,0,success,30593,ORD20260724130728137514,600 -522,898604A8012270312520,成功,200,0,success,30594,ORD20260724130728488863,600 -523,898604A8012270312521,成功,200,0,success,30595,ORD20260724130730940939,600 -524,898604A8012270312522,成功,200,0,success,30596,ORD20260724130730157237,600 -525,898604A8012270312523,成功,200,0,success,30597,ORD20260724130731175875,600 -526,898604A8012270312524,成功,200,0,success,30598,ORD20260724130731639030,600 -527,898604A8012270312525,成功,200,0,success,30599,ORD20260724130732992255,600 -528,898604A8012270312526,成功,200,0,success,30600,ORD20260724130732448584,600 -529,898604A8012270312527,成功,200,0,success,30601,ORD20260724130733384984,600 -530,898604A8012270312528,成功,200,0,success,30602,ORD20260724130733071669,600 -531,898604A8012270312529,成功,200,0,success,30603,ORD20260724130734501184,600 -532,898604A8012270312530,成功,200,0,success,30604,ORD20260724130734432170,600 -533,898604A8012270312531,成功,200,0,success,30605,ORD20260724130734659813,600 -534,898604A8012270312532,成功,200,0,success,30606,ORD20260724130735838523,600 -535,898604A8012270312533,成功,200,0,success,30607,ORD20260724130735858896,600 -536,898604A8012270312534,成功,200,0,success,30608,ORD20260724130736773575,600 -537,898604A8012270312535,成功,200,0,success,30609,ORD20260724130736739812,600 -538,898604A8012270312536,成功,200,0,success,30610,ORD20260724130736064418,600 -539,898604A8012270312537,成功,200,0,success,30611,ORD20260724130737824291,600 -540,898604A8012270312538,成功,200,0,success,30612,ORD20260724130738453981,600 -541,898604A8012270312539,成功,200,0,success,30613,ORD20260724130738598106,600 -542,898604A8012270312540,成功,200,0,success,30614,ORD20260724130739641512,600 -543,898604A8012270312541,成功,200,0,success,30615,ORD20260724130739954853,600 -544,898604A8012270312542,成功,200,0,success,30616,ORD20260724130740407349,600 -545,898604A8012270312543,成功,200,0,success,30617,ORD20260724130740304994,600 -546,898604A8012270312544,成功,200,0,success,30618,ORD20260724130741800175,600 -547,898604A8012270312545,成功,200,0,success,30619,ORD20260724130741506967,600 -548,898604A8012270312546,成功,200,0,success,30620,ORD20260724130742368330,600 -549,898604A8012270312547,成功,200,0,success,30621,ORD20260724130742913230,600 -550,898604A8012270312548,成功,200,0,success,30622,ORD20260724130743093125,600 -551,898604A8012270312549,成功,200,0,success,30623,ORD20260724130744387843,600 diff --git a/scripts/batch_package_purchase/batch_purchase.py b/scripts/batch_package_purchase/batch_purchase.py index 042e90b..bccd62e 100755 --- a/scripts/batch_package_purchase/batch_purchase.py +++ b/scripts/batch_package_purchase/batch_purchase.py @@ -76,7 +76,8 @@ class AdminAPIClient: def login(self, username: str, password: str) -> str: """使用后台账号登录并返回 Access Token。""" - result = self._post_json( + result = self._request_json( + "POST", LOGIN_PATH, {"username": username, "password": password, "device": "web"}, token=None, @@ -96,7 +97,8 @@ class AdminAPIClient: def create_order(self, token: str, identifier: str, package_id: int) -> HTTPResult: """为单个资产创建钱包套餐订单。""" - return self._post_json( + return self._request_json( + "POST", ORDER_PATH, { "identifier": identifier, @@ -106,13 +108,30 @@ class AdminAPIClient: token=token, ) - def _post_json(self, path: str, payload: dict[str, Any], token: str | None) -> HTTPResult: - body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") + def get_json(self, path: str, token: str) -> HTTPResult: + """调用后台 GET 接口。""" + return self._request_json("GET", path, None, token) + + def patch_json(self, path: str, payload: dict[str, Any], token: str) -> HTTPResult: + """调用后台 PATCH 接口。""" + return self._request_json("PATCH", path, payload, token) + + def _request_json( + self, + method: str, + path: str, + payload: dict[str, Any] | None, + token: str | None, + ) -> HTTPResult: + body = None + if payload is not None: + body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8") headers = { "Accept": "application/json", - "Content-Type": "application/json", "User-Agent": "junhong-batch-package-purchase/1.0", } + if payload is not None: + headers["Content-Type"] = "application/json" if token: headers["Authorization"] = f"Bearer {token}" @@ -120,7 +139,7 @@ class AdminAPIClient: url=self.base_url + path, data=body, headers=headers, - method="POST", + method=method, ) try: with urlopen(request, timeout=self.timeout) as response: diff --git a/scripts/batch_package_purchase/batch_update_package_expiry.py b/scripts/batch_package_purchase/batch_update_package_expiry.py new file mode 100644 index 0000000..e6f1a7c --- /dev/null +++ b/scripts/batch_package_purchase/batch_update_package_expiry.py @@ -0,0 +1,339 @@ +#!/usr/bin/env python3 +"""批量修改资产生效中套餐的过期时间。""" +from __future__ import annotations + +import argparse +import csv +import os +import re +import sys +import time +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any +from urllib.parse import quote, urlencode + +from batch_purchase import ( + AUTH_ERROR_CODES, + AdminAPIClient, + AssetInput, + RequestFailedError, + display_value, + is_success, + load_assets, + resolve_token, + response_code, + response_message, +) + + +PACKAGE_PATH_TEMPLATE = "/api/admin/assets/{identifier}/packages" +PACKAGE_EXPIRY_PATH_TEMPLATE = ( + "/api/admin/assets/{identifier}/packages/{package_usage_id}/expires-at" +) +ACTIVE_STATUS = 1 +PAGE_SIZE = 100 + + +@dataclass(frozen=True) +class PackageTarget: + """保存待修改的资产套餐。""" + + source: AssetInput + package_usage_id: int + package_name: str + old_expires_at: str + + +def parse_args() -> argparse.Namespace: + """解析命令行参数。""" + parser = argparse.ArgumentParser( + description="读取单列资产 CSV,批量修改所有生效中套餐的过期时间", + ) + parser.add_argument( + "--base-url", + default=os.getenv("JUNHONG_ADMIN_BASE_URL", ""), + help="接口 Base URL;也可使用 JUNHONG_ADMIN_BASE_URL", + ) + parser.add_argument("--csv", required=True, help="单列资产标识 CSV,首行可有表头") + parser.add_argument( + "--expires-at", + required=True, + help="统一过期时间,例如 2026-12-31 23:59:59 或 RFC3339", + ) + parser.add_argument( + "--token", + default=os.getenv("JUNHONG_ADMIN_TOKEN", ""), + help="后台 Access Token;也可使用 JUNHONG_ADMIN_TOKEN", + ) + parser.add_argument( + "--username", + default=os.getenv("JUNHONG_ADMIN_USERNAME", ""), + help="未提供 Token 时用于自动登录", + ) + parser.add_argument( + "--password", + default=os.getenv("JUNHONG_ADMIN_PASSWORD", ""), + help="后台登录密码;建议通过环境变量传入", + ) + parser.add_argument("--output", default="", help="结果 CSV 路径;默认输出到输入文件同目录") + parser.add_argument("--timeout", type=float, default=30.0, help="单次请求超时秒数(默认 30)") + parser.add_argument("--interval", type=float, default=0.2, help="每次修改后的间隔秒数(默认 0.2)") + parser.add_argument( + "--execute", + action="store_true", + help="真实修改套餐过期时间;不传时只查询并预览", + ) + return parser.parse_args() + + +def validate_expires_at(value: str) -> str: + """校验后端支持的过期时间格式并保留原值。""" + raw = value.strip() + if not raw: + raise ValueError("expires-at 不能为空") + for layout in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%d"): + try: + datetime.strptime(raw, layout) + return raw + except ValueError: + pass + if re.fullmatch( + r"\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})", + raw, + ): + try: + datetime.fromisoformat(raw.replace("Z", "+00:00")) + return raw + except ValueError: + pass + raise ValueError("expires-at 格式无效,请使用日期、日期时间或 RFC3339") + + +def find_active_packages( + client: AdminAPIClient, + token: str, + asset: AssetInput, +) -> list[PackageTarget]: + """分页查询资产全部生效中套餐。""" + page = 1 + targets: list[PackageTarget] = [] + while True: + query = urlencode({"page": page, "page_size": PAGE_SIZE, "status": ACTIVE_STATUS}) + path = PACKAGE_PATH_TEMPLATE.format(identifier=quote(asset.identifier, safe="")) + result = client.get_json(path + "?" + query, token) + code = response_code(result.body) + if not is_success(result.status, code): + raise RequestFailedError( + f"查询资产 {asset.identifier} 失败:HTTP {result.status}," + f"code={display_value(code)},msg={response_message(result.body, result.raw_body)}" + ) + + data = result.body.get("data") if result.body else None + items = data.get("items") if isinstance(data, dict) else None + total = data.get("total") if isinstance(data, dict) else None + if not isinstance(items, list) or not isinstance(total, int): + raise RequestFailedError( + f"查询资产 {asset.identifier} 的响应缺少 data.items 或 data.total" + ) + + for item in items: + if not isinstance(item, dict) or item.get("status") != ACTIVE_STATUS: + continue + package_usage_id = item.get("package_usage_id") + if not isinstance(package_usage_id, int) or package_usage_id <= 0: + raise RequestFailedError( + f"查询资产 {asset.identifier} 的响应包含无效 package_usage_id" + ) + targets.append( + PackageTarget( + source=asset, + package_usage_id=package_usage_id, + package_name=str(item.get("package_name") or ""), + old_expires_at=str(item.get("expires_at") or ""), + ) + ) + + if not items or page * PAGE_SIZE >= total: + return targets + page += 1 + + +def update_package_expiry( + client: AdminAPIClient, + token: str, + target: PackageTarget, + expires_at: str, +) -> tuple[dict[str, object], bool, int | str | None]: + """修改单条套餐过期时间并生成结果行。""" + path = PACKAGE_EXPIRY_PATH_TEMPLATE.format( + identifier=quote(target.source.identifier, safe=""), + package_usage_id=target.package_usage_id, + ) + try: + result = client.patch_json(path, {"expires_at": expires_at}, token) + code = response_code(result.body) + success = is_success(result.status, code) + message = response_message(result.body, result.raw_body) + row = result_row( + target, + expires_at, + "成功" if success else "失败", + result.status, + code, + message, + ) + return row, success, code + except RequestFailedError as exc: + return result_row(target, expires_at, "失败", "", "", str(exc)), False, None + + +def result_row( + target: PackageTarget, + expires_at: str, + status: str, + http_status: object, + code: object, + message: str, +) -> dict[str, object]: + """构造一行修改结果。""" + return { + "line_no": target.source.line_no, + "identifier": target.source.identifier, + "package_usage_id": target.package_usage_id, + "package_name": target.package_name, + "old_expires_at": target.old_expires_at, + "new_expires_at": expires_at, + "status": status, + "http_status": display_value(http_status), + "code": display_value(code), + "msg": message, + } + + +def resolve_output_path(input_path: Path, output_arg: str) -> Path: + """生成结果文件路径。""" + if output_arg.strip(): + return Path(output_arg).expanduser().resolve() + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + return input_path.with_name(f"{input_path.stem}_过期时间修改结果_{timestamp}.csv") + + +def preview(targets: list[PackageTarget], asset_count: int, expires_at: str) -> int: + """展示预检结果,不修改套餐。""" + print("预演完成:未修改任何套餐。") + print(f"资产数量:{asset_count}") + print(f"生效中套餐数量:{len(targets)}") + print(f"目标过期时间:{expires_at}") + for target in targets[:10]: + print( + f" {target.source.identifier} / 套餐记录 {target.package_usage_id}:" + f"{target.old_expires_at or '无'} -> {expires_at}" + ) + if len(targets) > 10: + print(f" 其余 {len(targets) - 10} 条已省略") + print("确认无误后增加 --execute 才会真实修改。") + return 0 + + +def execute( + client: AdminAPIClient, + token: str, + targets: list[PackageTarget], + expires_at: str, + output_path: Path, + interval: float, +) -> int: + """逐条修改套餐,并立即写入结果 CSV。""" + output_path.parent.mkdir(parents=True, exist_ok=True) + fields = [ + "line_no", + "identifier", + "package_usage_id", + "package_name", + "old_expires_at", + "new_expires_at", + "status", + "http_status", + "code", + "msg", + ] + success_count = 0 + failed_count = 0 + with output_path.open("w", encoding="utf-8-sig", newline="") as file: + writer = csv.DictWriter(file, fieldnames=fields) + writer.writeheader() + file.flush() + for index, target in enumerate(targets, start=1): + row, success, code = update_package_expiry( + client, token, target, expires_at + ) + writer.writerow(row) + file.flush() + if success: + success_count += 1 + print(f"[{index}/{len(targets)}] 成功:{target.source.identifier} / {target.package_usage_id}") + else: + failed_count += 1 + print( + f"[{index}/{len(targets)}] 失败:{target.source.identifier} / " + f"{target.package_usage_id},msg={row['msg']}", + file=sys.stderr, + ) + if row["http_status"] == "401" or code in AUTH_ERROR_CODES: + print("认证已失效,停止后续修改;已处理结果已保存。", file=sys.stderr) + break + if interval > 0 and index < len(targets): + time.sleep(interval) + + print(f"执行结束:成功 {success_count} 条,失败 {failed_count} 条。") + print(f"结果文件:{output_path}") + return 0 if success_count == len(targets) else 2 + + +def main() -> int: + """预检资产套餐,并执行预演或真实修改。""" + args = parse_args() + try: + base_url = args.base_url.strip() + if not base_url.startswith(("http://", "https://")): + raise ValueError("必须提供以 http:// 或 https:// 开头的 base-url") + if args.timeout <= 0: + raise ValueError("timeout 必须大于 0") + if args.interval < 0: + raise ValueError("interval 不能小于 0") + expires_at = validate_expires_at(args.expires_at) + input_path = Path(args.csv).expanduser().resolve() + assets = load_assets(input_path) + + client = AdminAPIClient(base_url, args.timeout) + token = resolve_token(args, client) + targets: list[PackageTarget] = [] + for index, asset in enumerate(assets, start=1): + packages = find_active_packages(client, token, asset) + targets.extend(packages) + print(f"[{index}/{len(assets)}] {asset.identifier}:找到 {len(packages)} 个生效中套餐") + + if not args.execute: + return preview(targets, len(assets), expires_at) + if not targets: + print("没有生效中的套餐,无需修改。") + return 0 + + output_path = resolve_output_path(input_path, args.output) + if output_path == input_path: + raise ValueError("结果文件不能与输入 CSV 使用同一路径") + if output_path.exists(): + raise ValueError(f"结果文件已存在,请更换 --output 路径:{output_path}") + return execute(client, token, targets, expires_at, output_path, args.interval) + except (ValueError, RequestFailedError) as exc: + print(f"错误:{exc}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + print("\n用户中断执行;已完成的修改不会回滚。", file=sys.stderr) + return 130 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/batch_package_purchase/generate_invalidate_orders.py b/scripts/batch_package_purchase/generate_invalidate_orders.py new file mode 100644 index 0000000..2d68e87 --- /dev/null +++ b/scripts/batch_package_purchase/generate_invalidate_orders.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +"""按 ICCID 查询未失效套餐,并生成批量失效接口需要的订单号 CSV。""" +from __future__ import annotations + +import argparse +import csv +import os +import re +import sys +from datetime import datetime +from pathlib import Path +from urllib.parse import quote, urlencode + +from batch_purchase import ( + AdminAPIClient, + AssetInput, + RequestFailedError, + display_value, + is_success, + load_assets, + resolve_token, + response_code, + response_message, +) + + +PACKAGE_PATH_TEMPLATE = "/api/admin/assets/{iccid}/packages" +PAGE_SIZE = 100 +INVALIDATABLE_STATUSES = {0, 1, 2} +ICCID_PATTERN = re.compile(r"^[0-9A-Za-z]{19,20}$") + + +def parse_args() -> argparse.Namespace: + """解析命令行参数。""" + parser = argparse.ArgumentParser( + description="读取单列 ICCID CSV,生成可上传到批量套餐失效功能的 order_no CSV", + ) + parser.add_argument( + "--base-url", + default=os.getenv("JUNHONG_ADMIN_BASE_URL", ""), + help="接口 Base URL;也可使用 JUNHONG_ADMIN_BASE_URL", + ) + parser.add_argument("--csv", required=True, help="单列 ICCID CSV 文件路径,首行可有表头") + parser.add_argument( + "--token", + default=os.getenv("JUNHONG_ADMIN_TOKEN", ""), + help="后台 Access Token;也可使用 JUNHONG_ADMIN_TOKEN", + ) + parser.add_argument( + "--username", + default=os.getenv("JUNHONG_ADMIN_USERNAME", ""), + help="未提供 Token 时用于自动登录", + ) + parser.add_argument( + "--password", + default=os.getenv("JUNHONG_ADMIN_PASSWORD", ""), + help="后台登录密码;建议通过环境变量传入", + ) + parser.add_argument("--output", default="", help="订单号 CSV 路径;默认输出到输入文件同目录") + parser.add_argument("--timeout", type=float, default=30.0, help="单次请求超时秒数(默认 30)") + return parser.parse_args() + + +def load_iccids(csv_path: Path) -> list[AssetInput]: + """读取并校验 ICCID,复用批量购买脚本的单列 CSV 和重复检查。""" + assets = load_assets(csv_path) + invalid = [asset for asset in assets if not ICCID_PATTERN.fullmatch(asset.identifier)] + if invalid: + preview = "\n".join( + f" - 第 {asset.line_no} 行不是 19 或 20 位 ICCID:{asset.identifier}" + for asset in invalid[:20] + ) + raise ValueError(f"CSV 校验失败,请修正后重试:\n{preview}") + return assets + + +def select_order_nos(items: list[object]) -> list[str]: + """从套餐记录中选出待生效、生效中或已用完套餐的订单号。""" + order_nos: list[str] = [] + seen: set[str] = set() + for item in items: + if not isinstance(item, dict) or item.get("status") not in INVALIDATABLE_STATUSES: + continue + order_no = str(item.get("order_no") or "").strip() + if order_no and order_no not in seen: + seen.add(order_no) + order_nos.append(order_no) + return order_nos + + +def find_order_nos(client: AdminAPIClient, token: str, iccid: str) -> list[str]: + """分页查询单张卡的套餐,并返回仍可失效的订单号。""" + page = 1 + order_nos: list[str] = [] + seen: set[str] = set() + while True: + query = urlencode({"page": page, "page_size": PAGE_SIZE}) + path = PACKAGE_PATH_TEMPLATE.format(iccid=quote(iccid, safe="")) + "?" + query + result = client.get_json(path, token) + code = response_code(result.body) + if not is_success(result.status, code): + raise RequestFailedError( + f"查询 ICCID {iccid} 失败:HTTP {result.status}," + f"code={display_value(code)},msg={response_message(result.body, result.raw_body)}" + ) + + data = result.body.get("data") if result.body else None + items = data.get("items") if isinstance(data, dict) else None + total = data.get("total") if isinstance(data, dict) else None + if not isinstance(items, list) or not isinstance(total, int): + raise RequestFailedError(f"查询 ICCID {iccid} 的响应缺少 data.items 或 data.total") + + for order_no in select_order_nos(items): + if order_no not in seen: + seen.add(order_no) + order_nos.append(order_no) + + if not items or page * PAGE_SIZE >= total: + return order_nos + page += 1 + + +def resolve_output_path(input_path: Path, output_arg: str) -> Path: + """生成订单号 CSV 路径。""" + if output_arg.strip(): + return Path(output_arg).expanduser().resolve() + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + return input_path.with_name(f"{input_path.stem}_待失效订单_{timestamp}.csv") + + +def write_order_nos(output_path: Path, order_nos: list[str]) -> None: + """写入批量套餐失效功能要求的单列 CSV。""" + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", encoding="utf-8-sig", newline="") as file: + writer = csv.writer(file) + writer.writerow(["order_no"]) + writer.writerows([order_no] for order_no in order_nos) + + +def main() -> int: + """查询全部 ICCID,并在整批成功后生成订单号 CSV。""" + args = parse_args() + try: + base_url = args.base_url.strip() + if not base_url.startswith(("http://", "https://")): + raise ValueError("必须提供以 http:// 或 https:// 开头的 base-url") + if args.timeout <= 0: + raise ValueError("timeout 必须大于 0") + + input_path = Path(args.csv).expanduser().resolve() + iccids = load_iccids(input_path) + output_path = resolve_output_path(input_path, args.output) + if output_path == input_path: + raise ValueError("输出文件不能与输入 CSV 使用同一路径") + if output_path.exists(): + raise ValueError(f"输出文件已存在,请更换 --output 路径:{output_path}") + + client = AdminAPIClient(base_url, args.timeout) + token = resolve_token(args, client) + all_order_nos: list[str] = [] + seen: set[str] = set() + for index, asset in enumerate(iccids, start=1): + order_nos = find_order_nos(client, token, asset.identifier) + for order_no in order_nos: + if order_no not in seen: + seen.add(order_no) + all_order_nos.append(order_no) + print(f"[{index}/{len(iccids)}] {asset.identifier}:找到 {len(order_nos)} 个订单号") + + write_order_nos(output_path, all_order_nos) + print(f"生成完成:ICCID {len(iccids)} 个,待失效订单 {len(all_order_nos)} 个。") + print(f"输出文件:{output_path}") + return 0 + except (ValueError, RequestFailedError) as exc: + print(f"错误:{exc}", file=sys.stderr) + return 1 + except KeyboardInterrupt: + print("\n用户中断执行,未生成订单号 CSV。", file=sys.stderr) + return 130 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/batch_package_purchase/test_batch_update_package_expiry.py b/scripts/batch_package_purchase/test_batch_update_package_expiry.py new file mode 100644 index 0000000..135f8a7 --- /dev/null +++ b/scripts/batch_package_purchase/test_batch_update_package_expiry.py @@ -0,0 +1,110 @@ +"""批量修改套餐过期时间脚本测试。""" +from __future__ import annotations + +import importlib.util +import sys +import unittest +from pathlib import Path +from types import SimpleNamespace + + +SCRIPT_PATH = Path(__file__).with_name("batch_update_package_expiry.py") +sys.path.insert(0, str(SCRIPT_PATH.parent)) +SPEC = importlib.util.spec_from_file_location("batch_update_package_expiry", SCRIPT_PATH) +assert SPEC is not None and SPEC.loader is not None +batch_update_package_expiry = importlib.util.module_from_spec(SPEC) +sys.modules[SPEC.name] = batch_update_package_expiry +SPEC.loader.exec_module(batch_update_package_expiry) +sys.path.pop(0) + + +class BatchUpdatePackageExpiryTest(unittest.TestCase): + """验证过期时间与生效套餐筛选。""" + + def test_validates_supported_expiry_formats(self) -> None: + """接受后端支持的日期时间格式并拒绝无效值。""" + self.assertEqual( + batch_update_package_expiry.validate_expires_at("2026-12-31 23:59:59"), + "2026-12-31 23:59:59", + ) + with self.assertRaisesRegex(ValueError, "格式无效"): + batch_update_package_expiry.validate_expires_at("2026/12/31") + + def test_collects_only_active_packages_across_pages(self) -> None: + """分页收集状态为生效中的套餐。""" + + class StubClient: + """返回两页套餐数据。""" + + def __init__(self) -> None: + self.calls = 0 + + def get_json(self, path: str, token: str) -> SimpleNamespace: + self.calls += 1 + return SimpleNamespace( + status=200, + body={ + "code": 0, + "data": { + "items": [ + { + "status": self.calls, + "package_usage_id": self.calls, + "package_name": f"套餐{self.calls}", + "expires_at": "2026-01-01T00:00:00+08:00", + } + ], + "total": 101, + }, + }, + raw_body="", + ) + + client = StubClient() + asset = batch_update_package_expiry.AssetInput(2, "8986000000000000001") + targets = batch_update_package_expiry.find_active_packages(client, "token", asset) + + self.assertEqual([target.package_usage_id for target in targets], [1]) + self.assertEqual(client.calls, 2) + + def test_updates_package_with_requested_expiry(self) -> None: + """PATCH 路径和请求体应使用查询得到的套餐记录。""" + + class StubClient: + """记录过期时间修改请求。""" + + def patch_json( + self, path: str, payload: dict[str, object], token: str + ) -> SimpleNamespace: + self.path = path + self.payload = payload + return SimpleNamespace( + status=200, + body={"code": 0, "msg": "success", "data": {}}, + raw_body="", + ) + + client = StubClient() + target = batch_update_package_expiry.PackageTarget( + source=batch_update_package_expiry.AssetInput(2, "8986000000000000001"), + package_usage_id=88, + package_name="测试套餐", + old_expires_at="2026-01-01T00:00:00+08:00", + ) + + row, success, code = batch_update_package_expiry.update_package_expiry( + client, "token", target, "2026-12-31 23:59:59" + ) + + self.assertTrue(success) + self.assertEqual(code, 0) + self.assertEqual( + client.path, + "/api/admin/assets/8986000000000000001/packages/88/expires-at", + ) + self.assertEqual(client.payload, {"expires_at": "2026-12-31 23:59:59"}) + self.assertEqual(row["status"], "成功") + + +if __name__ == "__main__": + unittest.main()