- client_order: 新增 getOrCreateWallet 兜底,钱包不存在时自动创建 - device_import: 设备导入事务内同步创建设备钱包 - iot_card_import: IoT卡批量导入后批量创建卡钱包 - queue/handler: 传递 AssetWalletStore 给两个导入 handler - migration 000098: 为存量IoT卡和设备补建资产钱包
7.3 KiB
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 日志但不影响审批结果。
回扣操作在单独事务中:
- 遍历该订单所有已入账佣金记录
- 对每条记录:找到对应代理的佣金钱包(
wallet_type="commission") - 全额扣减余额(允许负数):
UPDATE tb_agent_wallet SET balance = balance - ?, version = version + 1 WHERE id = ? AND version = ? - 创建交易流水:
transaction_type="commission_deduct",reference_type="refund",reference_id=退款单ID,amount=-commission.Amount - 全部完成后标记
commission_deducted=true
佣金钱包为负时的影响:代理不能发起提现(提现 Service 校验余额),后续新佣金入账会逐步补填负数。不管退款时是否有正在审批的提现申请,正常扣佣金。
5. 资产处理策略(审批通过后异步执行)
参照换货模块 internal/service/exchange/service.go 的资产重置逻辑。
步骤 1:失效所有套餐
查询该订单关联资产(iot_card_id 或 device_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 等多个模块 → 通过依赖注入管理,各步骤独立失败不影响审批结果