Files
junhong_cmp_fiber/openspec/changes/archive/2026-03-28-add-refund-system/design.md
huang f339fb1987
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m8s
fix: 资产钱包自动创建机制 — 修复C端购买时钱包不存在报错
- client_order: 新增 getOrCreateWallet 兜底,钱包不存在时自动创建
- device_import: 设备导入事务内同步创建设备钱包
- iot_card_import: IoT卡批量导入后批量创建卡钱包
- queue/handler: 传递 AssetWalletStore 给两个导入 handler
- migration 000098: 为存量IoT卡和设备补建资产钱包
2026-03-30 11:37:41 +08:00

7.3 KiB
Raw Blame History

Context

系统支持代理/平台给资产购买套餐,购买产生订单并触发佣金分配。目前没有退款机制,退款只能线下处理。需要新建一个完整的退款模块,参考现有 commission_withdrawal(提现)的实现模式。

关键约束:

  • 退款不走自动化打款,由人工填写金额并线下打款
  • 审批通过后需全额回扣该订单产生的所有佣金(允许佣金钱包余额为负)
  • 审批通过后需停掉套餐、停机、世代重置资产(参照换货模块)
  • 支持"退回 → 重提"的灵活流程
  • 仅针对已支付的套餐购买订单,不含充值订单
  • 仅平台账号可发起和审批退款

Goals / Non-Goals

Goals:

  • 退款仅针对 payment_status=2(已支付)的套餐购买订单(tb_order),不含充值订单
  • 退款审批通过后自动执行:停掉套餐 → 停机 → 资产世代重置 → 佣金全额回扣 → 更新订单状态为已退款
  • 完整的退款申请 → 审批 → 资产处理 → 佣金回扣闭环
  • 参考 commission_withdrawal 模块的代码结构和模式
  • 所有操作记录审计日志

Non-Goals:

  • 不做自动退款(不对接支付渠道退款接口,全部线下退钱)
  • 不做部分退款拆分(一次退款对应一笔订单)
  • 不涉及企业客户授权分配的退款(企业不走订单流程)
  • 不涉及充值订单退款
  • 不新增自动化测试

Decisions

1. 表设计

参考 tb_commission_withdrawal_request 的模式,核心字段:

字段 类型 说明
refund_no VARCHAR(50) UNIQUE 退款单号,系统生成
order_id BIGINT NOT NULL 关联订单
package_usage_id BIGINT 关联套餐使用记录(可选)
shop_id BIGINT 店铺ID冗余字段从订单获取用于数据权限过滤
actual_received_amount BIGINT NOT NULL 实收金额(分,即进入系统的金额,对应订单的 total_amount
requested_refund_amount BIGINT NOT NULL 申请退款金额(分)
approved_refund_amount BIGINT 审批实际退款金额(分)
refund_reason TEXT 退款原因
status INT NOT NULL DEFAULT 1 1-待审批 2-已通过 3-已拒绝 4-已退回
commission_deducted BOOLEAN DEFAULT FALSE 佣金是否已回扣
asset_reset BOOLEAN DEFAULT FALSE 资产是否已重置(套餐停用+停机+世代重置)
processor_id BIGINT 审批人ID
processed_at TIMESTAMP 审批时间
reject_reason TEXT 拒绝原因
remark TEXT 审批备注

使用软删除(deleted_at),遵循项目 Model 规范(gorm.Model + BaseModel{creator, updater})。

2. 状态流转

1(待审批) ──Approve──▶ 2(已通过) ──▶ 触发资产处理 + 佣金回扣 + 订单状态更新
1(待审批) ──Reject───▶ 3(已拒绝)
1(待审批) ──Return───▶ 4(已退回) ──Resubmit──▶ 1(待审批)

拒绝后不可重提(终态)。退回后可重提(修改金额/原因后重新进入审批)。

3. 重复退款防护

不在 order_id 上加唯一约束,而是在 Service 层检查:创建退款时,如果该订单已存在 status IN (1,2,4) 的退款记录(待审批/已通过/已退回),则拒绝创建。只有 status=3(已拒绝)的订单允许重新发起退款。

4. 佣金全额回扣策略

审批通过后,查找该订单产生的所有已入账佣金记录(tb_commission_record WHERE order_id=? AND status=1,使用 CommissionRecord 模型的 CommissionStatusReleased=1),每条佣金全额扣回(deductAmount = commission.Amount)。

Approve() 事务提交成功后,通过 Goroutine 异步执行佣金回扣。回扣失败记 Error 日志但不影响审批结果。

回扣操作在单独事务中:

  1. 遍历该订单所有已入账佣金记录
  2. 对每条记录:找到对应代理的佣金钱包wallet_type="commission"
  3. 全额扣减余额(允许负数):UPDATE tb_agent_wallet SET balance = balance - ?, version = version + 1 WHERE id = ? AND version = ?
  4. 创建交易流水:transaction_type="commission_deduct", reference_type="refund", reference_id=退款单ID, amount=-commission.Amount
  5. 全部完成后标记 commission_deducted=true

佣金钱包为负时的影响:代理不能发起提现(提现 Service 校验余额),后续新佣金入账会逐步补填负数。不管退款时是否有正在审批的提现申请,正常扣佣金。

5. 资产处理策略(审批通过后异步执行)

参照换货模块 internal/service/exchange/service.go 的资产重置逻辑。

步骤 1失效所有套餐 查询该订单关联资产(iot_card_iddevice_id)的全部套餐使用记录(status IN (0,1,2) 待生效/生效中/已用完),批量更新为 status=4(已失效)。

步骤 2停机

  • 单卡订单:调用 StopResumeService.ManualStopCard(iccid) — 调运营商停机接口 + 更新 network_status=offline
  • 设备订单:调用 DeviceService.StopDevice(deviceID) — 内部遍历所有绑定卡逐一停机

步骤 3世代重置 与换货完成后重置逻辑一致(exchange/service.go 第 278-289 行):

  • generation = generation + 1(世代递增,使一次性佣金可重新触发)
  • asset_status = 1(回到"在库"
  • accumulated_recharge = 0(清零累计充值)
  • first_commission_paid = false(重置一次性佣金标记)
  • accumulated_recharge_by_series = "{}"(清零系列充值)
  • first_recharge_triggered_by_series = "{}"(清零系列触发标记)
  • 清理个人客户绑定(tb_personal_customer_device
  • 清理资产钱包(删除旧 tb_asset_wallet,创建新空钱包)

步骤 4更新订单状态 UPDATE tb_order SET payment_status=4 WHERE id=?(已退款)

失败处理:asset_reset 保持 false,记 Error 日志(含 refund_id 和 error不影响审批结果。管理员可通过 asset_reset 字段识别需要手动处理的退款单。

6. 路由设计

挂载到 /api/admin/refunds,使用 AdminAuth 中间件。路由中间件限制仅平台用户(user_type IN (1,2),超级管理员和平台用户)可访问。同一人可申请+审批(无需审批分离)。

7. 退款单号生成

格式:RF + 年月日时分秒 + 6位随机数RF20260328143052123456。参考现有 GenerateOrderNo 的实现方式(使用 rand.Intn 生成随机数)。

Risks / Trade-offs

  • [资产处理异步失败] Goroutine 内执行失败不自动重试 → 通过 asset_reset 字段标记,管理员可识别需手动处理的记录(或后续改为 Asynq 任务)
  • [佣金回扣异步失败] 同上 → 通过 commission_deducted 字段标记
  • [佣金余额为负] 允许扣成负数可能导致代理不满 → 这是业务决策(退款本身就是扣钱),管理员应提前沟通
  • [并发审批] 两人同时审批同一笔退款 → 使用状态条件更新 WHERE status = 1 保证幂等
  • [停机失败] 运营商接口不可用导致停机失败 → 记日志,管理员手动处理
  • [跨模块依赖] 退款 Service 依赖 package、iot_card、device、asset 等多个模块 → 通过依赖注入管理,各步骤独立失败不影响审批结果