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

127 lines
7.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
## 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_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 等多个模块 → 通过依赖注入管理,各步骤独立失败不影响审批结果