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