All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m8s
- client_order: 新增 getOrCreateWallet 兜底,钱包不存在时自动创建 - device_import: 设备导入事务内同步创建设备钱包 - iot_card_import: IoT卡批量导入后批量创建卡钱包 - queue/handler: 传递 AssetWalletStore 给两个导入 handler - migration 000098: 为存量IoT卡和设备补建资产钱包
127 lines
7.3 KiB
Markdown
127 lines
7.3 KiB
Markdown
## 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 等多个模块 → 通过依赖注入管理,各步骤独立失败不影响审批结果
|