## 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 等多个模块 → 通过依赖注入管理,各步骤独立失败不影响审批结果