fix: 资产钱包自动创建机制 — 修复C端购买时钱包不存在报错
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m8s
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卡和设备补建资产钱包
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-28
|
||||
126
openspec/changes/archive/2026-03-28-add-refund-system/design.md
Normal file
126
openspec/changes/archive/2026-03-28-add-refund-system/design.md
Normal file
@@ -0,0 +1,126 @@
|
||||
## 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 等多个模块 → 通过依赖注入管理,各步骤独立失败不影响审批结果
|
||||
@@ -0,0 +1,32 @@
|
||||
## Why
|
||||
|
||||
系统目前没有退款功能。当已支付的套餐购买订单需要退款时,只能线下处理,无法追踪退款状态,也无法自动回扣已发放的佣金、停掉已激活的套餐和资产。需要一套完整的退款审批流程:人工申请 → 审批/拒绝/退回 → 审批通过后自动停掉套餐和资产、全额回扣佣金、更新订单状态。
|
||||
|
||||
## What Changes
|
||||
|
||||
- **I-1 退款数据模型**:新建 `tb_refund_request` 表,定义退款单的完整生命周期字段(退款单号、关联订单、店铺ID、实收金额、申请退款金额、审批金额、状态流转、审批人/审批时间、佣金回扣标记、资产重置标记)。新建 `internal/model/refund.go` GORM Model。
|
||||
- **I-2 退款接口设计**:新建完整的 Handler + Service + Store 三层实现,提供 7 个 API 接口:发起申请、列表查询、详情查询、审批通过、审批拒绝、退回申请、重新提交。仅平台账号可操作。状态流转:1(待审批) → 2(已通过)/3(已拒绝)/4(已退回),4(已退回) → 1(待审批)。创建时检查同一订单不能重复退款(已拒绝的除外)。
|
||||
- **I-3 审批通过后的佣金全额回扣**:审批通过后,自动查找该订单产生的所有已入账佣金记录,全额从各代理的佣金钱包中扣减(允许余额为负),写入交易流水。
|
||||
- **I-4 审批通过后的资产处理**:审批通过后,自动失效该资产所有套餐、停机(单卡调停机接口、设备调停设备接口停所有绑定卡)、世代重置(generation+1、累计充值清零、asset_status 回到"在库"、清理客户绑定和资产钱包),参照换货模块逻辑。最后更新订单状态为已退款。
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
- `refund-request`: 退款申请数据模型和状态流转
|
||||
- `refund-api`: 退款审批流程的 7 个 API 接口(仅平台账号可操作)
|
||||
- `refund-commission-deduct`: 退款审批通过后的佣金全额回扣机制
|
||||
- `refund-asset-reset`: 退款审批通过后的资产处理(套餐失效 + 停机 + 世代重置 + 订单状态更新)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
_无需修改现有能力的接口签名。需在 `package/activation_service.go` 中新增 `InvalidateAllPackagesByAsset` 方法。_
|
||||
|
||||
## Impact
|
||||
|
||||
- **新增文件**:`internal/model/refund.go`、`internal/model/dto/refund_dto.go`、`internal/store/postgres/refund_store.go`、`internal/service/refund/service.go`、`internal/handler/admin/refund.go`、路由注册文件 `internal/routes/refund.go`
|
||||
- **修改文件**:`internal/bootstrap/stores.go`、`internal/bootstrap/services.go`、`internal/bootstrap/handlers.go`、`internal/routes/routes.go`、`cmd/api/docs.go`、`cmd/gendocs/main.go`、`internal/service/package/activation_service.go`(新增方法)
|
||||
- **DB 迁移**:新建 `tb_refund_request` 表
|
||||
- **新增常量**:退款状态常量(RefundStatusPending=1, Approved=2, Rejected=3, Returned=4)、交易类型 `AgentTransactionTypeCommissionDeduct`、关联业务类型 `ReferenceTypeRefund`
|
||||
- **跨模块依赖**:退款 Service 需注入 orderStore、commissionRecordStore、agentWalletStore、agentWalletTransactionStore、stopResumeService、deviceService、packageActivationService、iotCardStore、deviceStore、assetWalletStore(参照换货模块的资产重置)
|
||||
- **前端影响**:管理端需新增退款管理页面;佣金明细需展示 `commission_deduct` 类型流水
|
||||
@@ -0,0 +1,56 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 发起退款申请接口
|
||||
`POST /api/admin/refunds` SHALL 创建退款申请。请求体 MUST 包含 `order_id`、`actual_received_amount`、`requested_refund_amount`、`refund_reason`。可选 `package_usage_id`。仅平台用户(user_type IN 1,2)可调用。
|
||||
|
||||
#### Scenario: 成功创建退款申请
|
||||
- **WHEN** 平台管理员提交合法的退款申请(关联已支付套餐订单且无进行中的退款记录)
|
||||
- **THEN** 系统 SHALL 返回创建成功的退款记录,状态为待审批
|
||||
|
||||
#### Scenario: 订单不可退款
|
||||
- **WHEN** 管理员对非已支付订单、充值订单或已有进行中退款记录的订单发起退款
|
||||
- **THEN** 系统 SHALL 返回对应错误
|
||||
|
||||
### Requirement: 退款列表查询接口
|
||||
`GET /api/admin/refunds` SHALL 返回分页退款列表,支持按 `status`、`order_id`、`shop_id` 筛选。自动应用数据权限过滤(通过 `shop_id` 字段)。
|
||||
|
||||
#### Scenario: 按状态筛选退款列表
|
||||
- **WHEN** 管理员查询 `status=1` 的退款列表
|
||||
- **THEN** 系统 SHALL 返回所有待审批的退款记录,支持分页
|
||||
|
||||
### Requirement: 退款详情查询接口
|
||||
`GET /api/admin/refunds/:id` SHALL 返回退款单详情,包含关联订单信息。
|
||||
|
||||
#### Scenario: 查询退款详情
|
||||
- **WHEN** 管理员查询某退款单详情
|
||||
- **THEN** 系统 SHALL 返回退款单完整信息(含 `commission_deducted` 和 `asset_reset` 标记状态)
|
||||
|
||||
### Requirement: 审批通过接口
|
||||
`POST /api/admin/refunds/:id/approve` SHALL 审批通过退款。请求体可选 `approved_refund_amount`(不填则等于 `requested_refund_amount`)和 `remark`。审批通过后异步触发资产处理和佣金全额回扣。
|
||||
|
||||
#### Scenario: 审批通过并触发副作用
|
||||
- **WHEN** 审批人通过退款申请
|
||||
- **THEN** 退款状态 SHALL 变为已通过,记录审批人和审批时间
|
||||
- **AND** 系统 SHALL 异步执行:失效所有套餐 → 停机 → 世代重置资产 → 更新订单状态为已退款 → 标记 `asset_reset=true`
|
||||
- **AND** 系统 SHALL 异步执行:全额回扣该订单所有佣金 → 标记 `commission_deducted=true`
|
||||
|
||||
### Requirement: 审批拒绝接口
|
||||
`POST /api/admin/refunds/:id/reject` SHALL 拒绝退款。请求体 MUST 包含 `reject_reason`。
|
||||
|
||||
#### Scenario: 拒绝退款
|
||||
- **WHEN** 审批人拒绝退款且填写了拒绝原因
|
||||
- **THEN** 退款状态 SHALL 变为已拒绝
|
||||
|
||||
### Requirement: 退回申请接口
|
||||
`POST /api/admin/refunds/:id/return` SHALL 退回退款申请。请求体可选 `remark`。
|
||||
|
||||
#### Scenario: 退回退款申请
|
||||
- **WHEN** 审批人退回退款申请
|
||||
- **THEN** 退款状态 SHALL 变为已退回,申请人可重新提交
|
||||
|
||||
### Requirement: 重新提交接口
|
||||
`POST /api/admin/refunds/:id/resubmit` SHALL 重新提交被退回的退款申请。请求体可修改 `actual_received_amount`、`requested_refund_amount`、`refund_reason`。
|
||||
|
||||
#### Scenario: 重新提交退款申请
|
||||
- **WHEN** 申请人重新提交被退回的退款
|
||||
- **THEN** 退款状态 SHALL 从已退回变回待审批
|
||||
@@ -0,0 +1,49 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 退款后套餐失效
|
||||
退款审批通过后,系统 SHALL 查找该订单关联资产(`iot_card_id` 或 `device_id`)的全部套餐使用记录(`status IN (0,1,2)` 待生效/生效中/已用完),批量更新为 `status=4`(已失效)。
|
||||
|
||||
#### Scenario: 失效单卡套餐
|
||||
- **WHEN** 退款订单为单卡购买,关联 iot_card_id
|
||||
- **THEN** 系统 SHALL 将该卡所有 `status IN (0,1,2)` 的套餐使用记录更新为 `status=4`
|
||||
|
||||
#### Scenario: 失效设备套餐
|
||||
- **WHEN** 退款订单为设备购买,关联 device_id
|
||||
- **THEN** 系统 SHALL 将该设备所有 `status IN (0,1,2)` 的套餐使用记录更新为 `status=4`
|
||||
|
||||
### Requirement: 退款后停机
|
||||
套餐失效后,系统 SHALL 对资产执行停机操作。
|
||||
|
||||
#### Scenario: 单卡停机
|
||||
- **WHEN** 退款订单为单卡购买
|
||||
- **THEN** 系统 SHALL 调用 `StopResumeService.ManualStopCard(iccid)` 停机
|
||||
|
||||
#### Scenario: 设备停机(停所有绑定卡)
|
||||
- **WHEN** 退款订单为设备购买
|
||||
- **THEN** 系统 SHALL 调用 `DeviceService.StopDevice(deviceID)`,内部遍历所有绑定卡逐一停机
|
||||
|
||||
### Requirement: 退款后资产世代重置
|
||||
停机后,系统 SHALL 对资产执行世代重置(参照换货模块 `exchange/service.go` 的重置逻辑),使资产回到可重新销售状态,一次性佣金可重新触发。
|
||||
|
||||
#### Scenario: 单卡世代重置
|
||||
- **WHEN** 退款订单为单卡购买
|
||||
- **THEN** 系统 SHALL 更新 IoT 卡:`generation+1`、`asset_status=1(在库)`、`accumulated_recharge=0`、`first_commission_paid=false`、清零系列充值和触发标记,清理个人客户绑定(`tb_personal_customer_device`),删除旧资产钱包并创建新空钱包
|
||||
|
||||
#### Scenario: 设备世代重置
|
||||
- **WHEN** 退款订单为设备购买
|
||||
- **THEN** 系统 SHALL 更新设备:`generation+1`、`asset_status=1(在库)`、`accumulated_recharge=0`、`first_commission_paid=false`、清零系列充值和触发标记,清理个人客户绑定,删除旧资产钱包并创建新空钱包
|
||||
|
||||
### Requirement: 退款后订单状态更新
|
||||
资产处理完成后,系统 SHALL 将订单 `payment_status` 更新为 4(已退款)。
|
||||
|
||||
#### Scenario: 订单标记已退款
|
||||
- **WHEN** 资产处理全部成功
|
||||
- **THEN** 订单 `payment_status` SHALL 变为 4(已退款),退款单 `asset_reset` 标记为 `true`
|
||||
|
||||
### Requirement: 资产处理失败容错
|
||||
资产处理 SHALL 在独立 Goroutine 中执行,失败不影响审批结果。
|
||||
|
||||
#### Scenario: 资产处理失败
|
||||
- **WHEN** 套餐失效/停机/世代重置过程中发生错误
|
||||
- **THEN** 审批结果 SHALL NOT 受影响(已通过),`asset_reset` 保持 `false`,记录 Error 日志(含 refund_id 和 error)
|
||||
- **AND** 管理员可通过 `asset_reset=false` 筛选需手动处理的退款单
|
||||
@@ -0,0 +1,26 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 退款佣金全额回扣
|
||||
退款审批通过后,系统 SHALL 查找该订单产生的所有已入账佣金记录(`tb_commission_record WHERE order_id=? AND status=1`,使用 `CommissionRecord` 模型的 `CommissionStatusReleased=1` 常量),全额从各代理佣金钱包(`wallet_type="commission"`)中扣减。扣减允许余额为负。
|
||||
|
||||
#### Scenario: 正常佣金全额回扣
|
||||
- **WHEN** 退款审批通过,该订单有 N 条已入账佣金记录
|
||||
- **THEN** 系统 SHALL 对每条佣金记录全额扣减 `deductAmount = commission.Amount`,从对应代理的佣金钱包扣减(使用 `GetCommissionWallet`,乐观锁更新),创建 `transaction_type="commission_deduct"` 的交易流水,全部完成后标记退款单 `commission_deducted=true`
|
||||
|
||||
#### Scenario: 无佣金记录
|
||||
- **WHEN** 退款审批通过,但该订单无已入账佣金记录
|
||||
- **THEN** 系统 SHALL 直接标记 `commission_deducted=true`,不执行扣减
|
||||
|
||||
#### Scenario: 回扣执行失败
|
||||
- **WHEN** 佣金回扣过程中发生错误
|
||||
- **THEN** 审批结果 SHALL NOT 受影响(已通过),`commission_deducted` 保持 `false`,记录 Error 日志
|
||||
|
||||
### Requirement: 佣金回扣交易流水
|
||||
每次佣金扣减 SHALL 创建交易流水记录:`transaction_type` 为 `commission_deduct`,`reference_type` 为 `refund`,`reference_id` 为退款单 ID,金额为负值(`-commission.Amount`)。
|
||||
|
||||
#### Scenario: 交易流水格式
|
||||
- **WHEN** 佣金回扣成功执行
|
||||
- **THEN** `tb_agent_wallet_transaction` SHALL 新增记录:`shop_id=佣金所属代理, transaction_type=commission_deduct, amount=-commission.Amount, reference_type=refund, reference_id=退款单ID, remark=退款佣金回扣`
|
||||
|
||||
### Requirement: 佣金钱包为负对提现的影响
|
||||
佣金钱包余额为负时,代理 SHALL NOT 能发起新的提现申请。后续新佣金入账会逐步补填负数。退款回扣时不考虑是否有正在审批的提现申请,正常扣减佣金钱包。
|
||||
@@ -0,0 +1,40 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 退款申请数据模型
|
||||
系统 SHALL 提供 `tb_refund_request` 表存储退款申请,包含退款单号(唯一)、关联订单 ID、店铺 ID(数据权限过滤)、实收金额、申请退款金额、审批退款金额、状态、审批人、审批时间、佣金回扣标记、资产重置标记等字段。
|
||||
|
||||
#### Scenario: 创建退款申请
|
||||
- **WHEN** 平台管理员提交退款申请,填写订单 ID、实收金额、申请退款金额、退款原因
|
||||
- **THEN** 系统 SHALL 生成唯一退款单号(格式 RF+日期时间+随机数),创建 `status=1(待审批)` 的退款记录,`shop_id` 从关联订单自动读取
|
||||
|
||||
#### Scenario: 仅针对已支付套餐订单
|
||||
- **WHEN** 管理员对非已支付订单或充值订单发起退款
|
||||
- **THEN** 系统 SHALL 返回错误,拒绝创建
|
||||
|
||||
#### Scenario: 重复退款防护
|
||||
- **WHEN** 管理员对已有待审批/已通过/已退回退款记录的订单再次发起退款
|
||||
- **THEN** 系统 SHALL 返回错误,拒绝创建
|
||||
- **WHEN** 管理员对仅有已拒绝退款记录的订单重新发起退款
|
||||
- **THEN** 系统 SHALL 允许创建新的退款申请
|
||||
|
||||
### Requirement: 退款状态流转
|
||||
退款单状态 SHALL 按以下规则流转:待审批(1) → 已通过(2)/已拒绝(3)/已退回(4);已退回(4) → 待审批(1)。已通过和已拒绝为终态。
|
||||
|
||||
#### Scenario: 审批通过
|
||||
- **WHEN** 审批人对待审批退款单执行通过操作
|
||||
- **THEN** 状态 SHALL 变为 2(已通过),记录审批人和审批时间,异步触发资产处理和佣金回扣
|
||||
|
||||
#### Scenario: 审批拒绝
|
||||
- **WHEN** 审批人对待审批退款单执行拒绝操作,填写拒绝原因
|
||||
- **THEN** 状态 SHALL 变为 3(已拒绝),记录拒绝原因
|
||||
|
||||
#### Scenario: 退回重提
|
||||
- **WHEN** 审批人退回退款申请,申请人修改后重新提交
|
||||
- **THEN** 状态 SHALL 从 4(已退回)变回 1(待审批)
|
||||
|
||||
#### Scenario: 非法状态变更被拒绝
|
||||
- **WHEN** 对已通过或已拒绝的退款单执行任何状态变更
|
||||
- **THEN** 系统 SHALL 返回错误,状态不变
|
||||
|
||||
### Requirement: 仅平台账号可操作
|
||||
退款的发起和审批 SHALL 限制为平台用户(user_type IN 1,2)。同一人可以既发起又审批。
|
||||
@@ -0,0 +1,63 @@
|
||||
# 任务清单:add-refund-system
|
||||
|
||||
> 退款功能从零建设,严格串行:数据模型 → Store → Service → Handler → 路由注册 → 文档。
|
||||
> 审批通过后需自动执行资产处理和佣金回扣,涉及跨模块依赖。
|
||||
|
||||
## 任务组 1:I-1 数据模型
|
||||
|
||||
- [x] 1.1 新建迁移文件 `migrations/000XXX_create_refund_request.up.sql`(编号接续现有最大值),创建 `tb_refund_request` 表,包含所有字段(参考 design.md 表设计,含 shop_id、processor_id、processed_at、reject_reason、remark、asset_reset 等)
|
||||
- [x] 1.2 新建对应 `.down.sql`:`DROP TABLE IF EXISTS tb_refund_request;`
|
||||
- [x] 1.3 新建 `internal/model/refund.go`,定义 `RefundRequest` GORM Model(使用 `gorm.Model` + `BaseModel` 嵌入),实现 `TableName()` 返回 `tb_refund_request`,所有字段显式指定 `gorm:"column:xxx"` 标签,注释使用中文
|
||||
- [x] 1.4 在 `pkg/constants/` 中新增以下常量:
|
||||
- 退款状态:`RefundStatusPending=1, RefundStatusApproved=2, RefundStatusRejected=3, RefundStatusReturned=4`
|
||||
- 交易类型:`AgentTransactionTypeCommissionDeduct = "commission_deduct"`(在 `pkg/constants/wallet.go` 的代理钱包交易类型分组中新增)
|
||||
- 关联业务类型:`ReferenceTypeRefund = "refund"`(在 `pkg/constants/wallet.go` 的关联业务类型分组中新增)
|
||||
- [x] 1.5 新建 `internal/model/dto/refund_dto.go`,定义请求/响应 DTO:`CreateRefundRequest`、`RejectRefundRequest`、`ResubmitRefundRequest`、`ApproveRefundRequest`、`ReturnRefundRequest`、`RefundListRequest`、`RefundResponse`、`RefundListResponse`。所有 DTO 字段需含 `description` 标签
|
||||
- [x] 1.6 执行迁移,通过 DBHub 确认表已创建
|
||||
- [x] 1.7 验证:`go build ./...` 编译通过
|
||||
|
||||
## 任务组 2:I-2 接口实现
|
||||
|
||||
- [x] 2.1 新建 `internal/store/postgres/refund_store.go`,实现 `Create`、`GetByID`、`List`(分页+筛选,含 `ApplyShopFilter` 数据权限过滤)、`Update` 方法
|
||||
- [x] 2.2 新建 `internal/service/refund/service.go`,实现 7 个业务方法:`Create`、`List`、`GetByID`、`Approve`、`Reject`、`Return`、`Resubmit`。每个状态变更方法 MUST 使用条件更新 `WHERE status = expected` 保证幂等。`Create` 方法 MUST 检查同一订单不能重复退款(已存在 status IN (1,2,4) 的退款记录时拒绝)
|
||||
- [x] 2.3 退款单号生成:`generateRefundNo()` 格式 `RF` + 年月日时分秒 + 6位随机数,参考 `GenerateOrderNo` 实现
|
||||
- [x] 2.4 `Create` 方法中从关联订单读取 `seller_shop_id`(或根据 buyer_type/buyer_id 确定)填入退款单的 `shop_id` 字段
|
||||
- [x] 2.5 新建 `internal/handler/admin/refund.go`,实现 7 个 Handler 方法,遵循 Handler 只做参数解析+响应格式化的原则
|
||||
- [x] 2.6 新建 `internal/routes/refund.go`,注册 `/api/admin/refunds` 路由组(参考 `routes/commission.go` 的 Register 模式),路由中间件限制仅平台用户可访问
|
||||
- [x] 2.7 在 `internal/bootstrap/stores.go`、`services.go`、`handlers.go` 中注册 refund 模块:
|
||||
- Store: `RefundStore`
|
||||
- Service 依赖注入: `db`, `refundStore`, `orderStore`, `commissionRecordStore`, `agentWalletStore`, `agentWalletTransactionStore`, `stopResumeService`, `deviceService`, `packageActivationService`, `iotCardStore`, `deviceStore`, `assetWalletStore`
|
||||
- Handler: `RefundHandler`
|
||||
- [x] 2.8 在 `internal/routes/routes.go` 中调用 `registerRefundRoutes`
|
||||
- [x] 2.9 更新 `cmd/api/docs.go` 和 `cmd/gendocs/main.go`,注册退款 Handler
|
||||
- [x] 2.10 验证:`go build ./...` 编译通过
|
||||
|
||||
## 任务组 3:I-3 佣金全额回扣
|
||||
|
||||
- [x] 3.1 在 `internal/service/refund/service.go` 中新增私有方法 `deductAllCommission(ctx, refundID)`
|
||||
- [x] 3.2 实现回扣逻辑:查退款单 → 查该订单所有已入账佣金记录(`WHERE order_id=? AND status=1`,使用 `CommissionStatusReleased` 常量)→ 对每条记录全额扣减对应代理的佣金钱包(`wallet_type="commission"`,使用 `GetCommissionWallet`)→ 使用乐观锁更新余额(`WHERE version=?`)→ 创建交易流水(`transaction_type="commission_deduct"`, `reference_type="refund"`, `reference_id=退款单ID`)→ 全部完成后标记 `commission_deducted=true`
|
||||
- [x] 3.3 在 `Approve()` 方法中,事务提交成功后通过 Goroutine 调用 `deductAllCommission`
|
||||
- [x] 3.4 回扣失败记录 Error 日志(含 refund_id 和 error),不影响审批结果
|
||||
- [x] 3.5 验证:`go build ./...` 编译通过
|
||||
|
||||
## 任务组 4:I-4 资产处理
|
||||
|
||||
- [x] 4.1 在 `internal/service/package/activation_service.go` 中新增公开方法 `InvalidateAllPackagesByAsset(ctx, assetType string, assetID uint) error`:查询 `status IN (0,1,2)` 的套餐使用记录(按 `iot_card_id` 或 `device_id` 查),批量更新为 `status=4`(已失效)
|
||||
- [x] 4.2 在 `internal/service/refund/service.go` 中新增私有方法 `handleAssetReset(ctx, refundID)`,包含:
|
||||
- a. 查退款单和关联订单,确定资产类型(单卡/设备)和资产ID
|
||||
- b. 调用 `InvalidateAllPackagesByAsset` 失效所有套餐
|
||||
- c. 停机:单卡调 `stopResumeService.ManualStopCard(iccid)` / 设备调 `deviceService.StopDevice(deviceID)`
|
||||
- d. 世代重置(参照 `exchange/service.go` 第 278-305 行):`generation+1`、`asset_status=1`、清零累计充值字段、清理个人客户绑定、删除旧资产钱包并创建新空钱包
|
||||
- e. 更新订单 `payment_status=4`(已退款)
|
||||
- f. 标记退款单 `asset_reset=true`
|
||||
- [x] 4.3 在 `Approve()` 方法中,事务提交成功后通过 Goroutine 调用 `handleAssetReset`(与佣金回扣独立执行)
|
||||
- [x] 4.4 资产处理失败记录 Error 日志(含 refund_id 和 error),`asset_reset` 保持 `false`,不影响审批结果
|
||||
- [x] 4.5 验证:`go build ./...` 编译通过
|
||||
|
||||
## 收尾验证
|
||||
|
||||
- [x] 5.1 执行 `go build ./...`,确认全量编译通过
|
||||
- [x] 5.2 通过 DBHub 确认 `tb_refund_request` 表结构正确
|
||||
- [x] 5.3 通过 `rg "refund" internal/bootstrap/` 确认模块已注册
|
||||
- [x] 5.4 通过 `rg "commission_deduct" pkg/constants/` 确认新常量已添加
|
||||
- [x] 5.5 通过 `rg "InvalidateAllPackagesByAsset" internal/service/package/` 确认新方法已添加
|
||||
Reference in New Issue
Block a user