# 修正业务 · 完整修复方案 v5（完整规格书）

> **写给另一个 AI 看的规格书**。本文档包含每一个修复的完整上下文：
> - **业务背景**：为什么存在这个功能、为什么会出这个问题
> - **代码现状**：问题在哪个文件哪一行，现在的代码是什么
> - **修复方式**：精确到文件+行号的改法，含完整代码片段
>
> 技术栈：Go + Fiber v2 + GORM + Asynq + PostgreSQL + Redis
> 架构：Handler → Service → Store → Model 严格分层
> 仓库根目录：`/Users/break/csxjProject/junhong_cmp_fiber`

---

## 系统概览

这是一个物联网卡（IoT SIM Card）+ 设备全生命周期管理平台，支持多级代理商体系和分佣结算。

**核心实体**：
- **IoT 卡**：物联网 SIM 卡，绑定运营商，有实名状态、网络状态、套餐使用情况
- **设备**：物联网设备（GPS、传感器等），可绑定 1-4 张 IoT 卡
- **套餐**：用于 IoT 卡的流量套餐，有时长、流量额度、计费模式
- **代理商（Shop）**：多级分销体系（最多 7 级），有佣金钱包、成本价体系
- **个人客户（PersonalCustomer）**：C 端用户，有资产钱包（跟着卡/设备走，转手后钱包一起转）
- **企业账号**：B 端大客户，被分配卡/设备，可管控但不能购买套餐

**关键路径**：
```
代理/平台 → 采购 IoT 卡 + 设备 → 分配给下级代理/企业 → C端客户购买套餐 → 充值/激活/使用
                                                               ↓
                                          佣金链：逐级向上分差额（成本价之差）
```

## 全文统一验收约定（适用于所有方案）

> 本文档**不要求新增自动化测试代码**，默认采用：`rg/grep`、`go build ./...`、DBHub、Worker/API 日志四类手段做人工验收。

**统一工具**：
- **代码结构核对**：`rg` / `grep`
- **编译验收**：`go build ./...`
- **表结构 / 数据核对**：DBHub（`search_objects` + `execute_sql`）
- **异步链路核对**：Worker / API 日志关键字

**统一通过标准**：
1. 相关代码路径存在且引用关系正确
2. 涉及 schema 的任务，DBHub 能查到新增字段 / 新表 / 新索引
3. 涉及业务数据流转的任务，DBHub 能查到状态变化或新增记录
4. 涉及异步任务的任务，日志能看到"入队 / 消费 / 成功 / 失败"关键节点
5. 最后执行一次 `go build ./...`，必须能通过

---

## 方案 A：紧急 Bug 修复（P0 全部 + P2-2 前置）

> 这 7 个 P0 是**生产直接跑不通的 Bug**，优先级最高。
> A-1 是其他所有 A 系修复的前置条件，必须第一个做。

---

### A-1 · 实名状态常量统一（P2-2，前置）

**业务背景**

IoT 卡需要实名认证（普通卡）。系统通过网关（Gateway）轮询获取卡的实名状态，结果写入 `iot_card.real_name_status` 字段。购买需实名套餐时检查此字段。

**问题**

三处代码对同一个字段的含义定义不一致，造成整条链路断裂：

| 位置 | 文件 | 定义 |
|------|------|------|
| 常量定义 | `pkg/constants/iot.go:47-48` | `RealNameStatusVerified = 1` |
| 轮询实际写入 | `internal/task/polling_handler.go:696-700` | `true → return 2` |
| Model 注释 | `internal/model/iot_card.go:30` | `0-未实名 1-已实名` |

结果：已实名的卡（DB 里值=2）用常量（=1）校验，逻辑全错。

**正确定义**（以常量为准）：`0 = 未实名，1 = 已实名`

**修改清单**

**① `internal/task/polling_handler.go:696-700`**（轮询写入值，改为 1）

```go
// 修改前
func (h *PollingHandler) parseRealnameStatus(realStatus bool) int {
    if realStatus {
        return 2 // 已实名
    }
    return 0 // 未实名
}

// 修改后
func (h *PollingHandler) parseRealnameStatus(realStatus bool) int {
    if realStatus {
        return constants.RealNameStatusVerified // = 1
    }
    return constants.RealNameStatusNotVerified // = 0
}
```

**② `internal/task/polling_handler.go:170`**（isFirstRealname 判断，改用常量）

```go
// 修改前
isFirstRealname := (card.RealNameStatus == 0 || card.RealNameStatus == 1) && newRealnameStatus == 2

// 修改后
isFirstRealname := card.RealNameStatus != constants.RealNameStatusVerified &&
    newRealnameStatus == constants.RealNameStatusVerified
```

**③ `internal/model/iot_card.go:30`**（Model 注释）

```go
// 修改前
RealNameStatus int `gorm:"...; comment:实名状态 0-未实名 1-已实名(行业卡可以保持0)"`

// 修改后（注释统一）
RealNameStatus int `gorm:"...; comment:实名状态 0-未实名 1-已实名"`
```

**④ 全局搜索并修正** DTO 注释（不影响运行，但影响文档）：

```bash
grep -rn "real_name_status\|RealNameStatus" internal/model/dto/ --include="*.go"
```

凡是注释写 `2=已实名` 或 `1实名中` 的，统一改为 `0=未实名, 1=已实名`。

---

### A-2 · C端实名校验修复（P0-1）

**业务背景**

C 端客户购买"需实名套餐"前，系统检查卡的实名状态。普通卡（`card_category = "normal"`）必须已实名才能购买。

**问题**

`internal/service/client_order/service.go:115`（依赖 A-1 修完后自动正确）：

```go
// 当前错误（硬编码 1，但 DB 里实际是 2，A-1 修完前暂时改这里）
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != 1 {
    return errors.New(errors.CodeForbidden, "请先完成实名认证")
}
```

**修改方式**

```go
// 修改后（使用常量，A-1 先修好后此行自动正确）
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
    return errors.New(errors.CodeForbidden, "请先完成实名认证")
}
```

同时检查 `internal/task/polling_handler.go:982`：
```go
if card.RealNameStatus != constants.RealNameStatusVerified {
```
A-1 修完后此行也自动正确，无需单独改。

---

### A-3 · 实名激活任务断链修复（P0-2）

**业务背景**

后台代理/平台可以"囤货"——预购套餐时套餐不立即激活（`PackageUsage.status = pending, pending_realname_activation = true`），等客户完成实名认证后自动激活。这是"先备货，客户实名后开始计费"的业务模式。

完整链路：
```
1. 后台囤货购买 → PackageUsage(status=0=pending, pending_realname_activation=true)
2. 轮询 polling:realname 定期调 Gateway 检测卡实名状态
3. 检测到首次实名（card.RealNameStatus 从 0 变为 1）
4. triggerFirstRealnameActivation(cardID) 查找该卡所有 pending 套餐
5. 对每个套餐 Enqueue Asynq 任务 → 消费者激活套餐（设定激活时间、到期时间）
```

**问题**

步骤 5 没有接入 Asynq。`PollingHandler` 没有注入 `asynq.Client`，开发者临时用 `RPush` 到 Redis List 降级，然后用 `_ = task` 把正确的 Asynq task 对象扔掉。

关键代码 `internal/task/polling_handler.go:1100-1115`（当前状态）：

```go
task := asynq.NewTask(constants.TaskTypePackageFirstActivation, payloadBytes, ...)

// 这里需要访问 Asynq Client，暂时使用 Redis 队列
activationKey := constants.RedisPollingManualQueueKey(constants.TaskTypePackageFirstActivation)
h.redis.RPush(ctx, activationKey, string(payloadBytes))

_ = task  // ← task 被扔掉！
```

而 `pkg/queue/handler.go:Handler` 结构体里已经有 `asynqClient *asynq.Client`，只需传进去。

**修改清单**

**① `internal/task/polling_handler.go`：PollingHandler struct 新增字段**

```go
// PollingHandler struct（第 31-42 行附近）
type PollingHandler struct {
    db                    *gorm.DB
    redis                 *redis.Client
    asynqClient           *asynq.Client    // 新增此行
    gatewayClient         *gateway.Client
    // ... 其余字段不变
}
```

**② `internal/task/polling_handler.go:NewPollingHandler`（第 44 行）**

```go
// 修改前
func NewPollingHandler(
    db *gorm.DB,
    redis *redis.Client,
    gatewayClient *gateway.Client,
    usageService *packagepkg.UsageService,
    logger *zap.Logger,
) *PollingHandler {
    return &PollingHandler{
        db:            db,
        redis:         redis,
        gatewayClient: gatewayClient,
        // ...

// 修改后（新增 asynqClient 参数）
func NewPollingHandler(
    db *gorm.DB,
    redis *redis.Client,
    asynqClient *asynq.Client,   // 新增
    gatewayClient *gateway.Client,
    usageService *packagepkg.UsageService,
    logger *zap.Logger,
) *PollingHandler {
    return &PollingHandler{
        db:            db,
        redis:         redis,
        asynqClient:   asynqClient, // 新增
        gatewayClient: gatewayClient,
        // ...
```

**③ `pkg/queue/handler.go:registerPollingHandlers`（第 151 行）**

```go
// 修改前
pollingHandler := task.NewPollingHandler(
    h.db,
    h.redis,
    h.gatewayClient,
    h.workerResult.Services.UsageService,
    h.logger,
)

// 修改后（传入 asynqClient）
pollingHandler := task.NewPollingHandler(
    h.db,
    h.redis,
    h.asynqClient, // 新增，Handler 已有此字段
    h.gatewayClient,
    h.workerResult.Services.UsageService,
    h.logger,
)
```

**④ `internal/task/polling_handler.go:triggerFirstRealnameActivation`（第 1100 行附近）**

```go
// 修改前（RPush 降级 + _ = task）
task := asynq.NewTask(constants.TaskTypePackageFirstActivation, payloadBytes,
    asynq.MaxRetry(3),
    asynq.Timeout(30*time.Second),
    asynq.Queue(constants.QueueDefault),
)
activationKey := constants.RedisPollingManualQueueKey(constants.TaskTypePackageFirstActivation)
if err := h.redis.RPush(ctx, activationKey, string(payloadBytes)).Err(); err != nil {
    // ...
}
_ = task  // 删掉

// 修改后（直接 Enqueue）
task := asynq.NewTask(constants.TaskTypePackageFirstActivation, payloadBytes,
    asynq.MaxRetry(3),
    asynq.Timeout(30*time.Second),
    asynq.Queue(constants.QueueDefault),
)
if _, err := h.asynqClient.EnqueueContext(ctx, task); err != nil {
    h.logger.Warn("提交首次实名激活任务失败",
        zap.Uint("package_usage_id", pkg.ID),
        zap.Error(err))
    continue
}
```

---

### A-4 · 自动购包触发 + 佣金补全（P0-3 + P0-4）

**业务背景**

C 端客户"强充"（ForceRecharge）时，可以同时指定想自动购买的套餐 IDs（存在 `AssetRechargeRecord.LinkedPackageIDs` JSONB 字段里）。充值成功到账后，系统应自动从资产钱包扣款购买这些套餐。这是"充值即购包"一步操作，省去客户手动购买。

**两个断点**

- **P0-3**：`recharge/service.go:HandlePaymentCallback` 充值回调成功后，完全没有触发自动购包的代码。`task.NewAutoPurchaseTask` 函数全项目只有定义，**零调用点**。
- **P0-4**：`AutoPurchaseHandler.ProcessTask()` 创建订单并激活套餐后，没有调用 `enqueueCommissionCalculation()`，自动购包产生的订单不计佣金。

**涉及的关键事实**

- `AssetRechargeRecord.LinkedPackageIDs` 字段：`internal/model/asset_wallet.go:91`（JSONB，存放 package IDs 数组）
- `recharge/service.go` 当前没有 `queueClient` 依赖，需要新增
- `pkg/queue/Client` 有 `EnqueueTask(ctx, taskType, payload, opts...)` 方法（`pkg/queue/client.go:38`），适合 **Service 层** 调用
- `pkg/queue/handler.go:Handler` 当前持有的是 `asynqClient *asynq.Client`，适合 **Worker 任务处理器** 在消费完成后继续提交下游任务
- `TaskTypeAutoPurchaseAfterRecharge = "task:auto_purchase_after_recharge"` 常量已存在（`pkg/constants/constants.go:68`），`task.NewAutoPurchaseTask()` 也已使用该常量，无需新增第二个自动购包任务类型
- `AutoPurchaseHandler` 目前没有在 `pkg/queue/handler.go` 里注册，需要新增

**调用链路（修正版）**

```text
充值支付回调成功
    │
    ▼
recharge.Service.HandlePaymentCallback()
    │  Service 层：使用 *queue.Client 提交任务
    ▼
TaskTypeAutoPurchaseAfterRecharge
    │
    ▼
Asynq Worker → registerAutoPurchaseHandler()
    │
    ▼
AutoPurchaseHandler.ProcessTask()
    │  事务内：扣钱包 → 创建订单 → 激活套餐 → 更新 auto_purchase_status=success
    │  事务提交成功后：使用 *asynq.Client 提交下游任务
    ▼
TaskTypeCommission
    │
    ▼
CommissionCalculationHandler
```

**一句话结论**

- **Service 层（充值回调）**：用 `*queue.Client`，复用已有 `TaskTypeAutoPurchaseAfterRecharge`
- **Worker 层（AutoPurchaseHandler）**：用 `*asynq.Client`，在事务提交成功后再投递 `TaskTypeCommission`
- **禁止做法**：新增第二套自动购包常量、在同一段代码里混用 `queueClient` / `asynqClient`、在事务未提交前先投递佣金任务

**修改清单（P0-3）**

**P0-3 目标**：充值支付回调成功后，自动把 `RechargeRecordID` 投递到自动购包任务队列。

**① `internal/service/recharge/service.go`：Service struct 新增 queueClient**

```go
type Service struct {
    db                          *gorm.DB
    // ... 其余字段 ...
    queueClient                 *queue.Client  // 新增
}

func New(
    db *gorm.DB,
    // ... 其余参数 ...
    queueClient *queue.Client,  // 新增
) *Service {
    return &Service{
        // ... 其余字段 ...
        queueClient: queueClient,
    }
}
```

**② `internal/service/recharge/service.go:HandlePaymentCallback`（第 272 行附近，事务成功后）**

在 `if err != nil { return err }` 之后，`s.logger.Info("充值支付回调处理成功"...)` 之前，添加：

```go
// 充值成功后，如有关联套餐则触发自动购包（强充绑套餐功能）
linkedIDs := recharge.LinkedPackageIDs
if len(linkedIDs) > 0 && string(linkedIDs) != "[]" && string(linkedIDs) != "null" {
    if s.queueClient != nil {
        taskPayload := task.AutoPurchasePayload{RechargeRecordID: recharge.ID}
        if err := s.queueClient.EnqueueTask(ctx, constants.TaskTypeAutoPurchaseAfterRecharge, taskPayload,
            asynq.Queue(constants.QueueDefault),
            asynq.MaxRetry(3),
        ); err != nil {
            s.logger.Error("自动购包任务入队失败",
                zap.Uint("recharge_id", recharge.ID),
                zap.Error(err))
            // 不影响充值成功，仅记录日志
        }
    }
}
```

**③ `pkg/constants/constants.go`：复用现有任务类型常量，不新增第二套名字**

```go
// 已存在，直接复用
TaskTypeAutoPurchaseAfterRecharge = "task:auto_purchase_after_recharge" // 充值后自动购包
```

不要新增 `TaskTypeAutoPurchase = "auto_purchase"` 这类新常量，否则会出现：
- producer 用新常量入队
- `task.NewAutoPurchaseTask()` 和 worker handler 仍使用旧常量

最终导致生产者 / 消费者任务类型对不上。

**④ `pkg/queue/handler.go`：新增 registerAutoPurchaseHandler()**

在 `RegisterHandlers()` 中添加 `h.registerAutoPurchaseHandler()` 调用，并新增方法。

同时，`internal/task/auto_purchase.go` 中的 `AutoPurchaseHandler struct` 和 `NewAutoPurchaseHandler(...)` 需要新增 `asynqClient *asynq.Client` 字段 / 参数，供自动购包完成后继续提交佣金任务：

```go
func (h *Handler) registerAutoPurchaseHandler() {
    autoPurchaseHandler := task.NewAutoPurchaseHandler(
        h.db,
        nil, // orderStore：让 handler 内部初始化
        nil, // rechargeRecordStore
        nil, // walletStore
        nil, // walletTransactionStore
        nil, // packageUsageStore
        h.redis,
        h.asynqClient, // 新增：传入裸 asynq client，供 Worker 内继续投递下游任务
        h.logger,
    )
    h.mux.HandleFunc(constants.TaskTypeAutoPurchaseAfterRecharge, autoPurchaseHandler.ProcessTask)
    h.logger.Info("注册自动购包任务处理器", zap.String("task_type", constants.TaskTypeAutoPurchaseAfterRecharge))
}
```

**⑤ `internal/bootstrap/` 中找到 recharge service 的初始化，传入 queueClient**

在 `internal/bootstrap/services.go` 或 `worker_services.go` 中找到 `recharge.New(...)` 的调用位置，补充 `queueClient` 参数传入。

**修改清单（P0-4）**

**P0-4 目标**：AutoPurchaseHandler 在订单创建 + 套餐激活成功后，把 `order_id` 投递到佣金计算队列，确保自动购包订单与普通订单一样进入佣金链路。

**`internal/task/auto_purchase.go`：ProcessTask 事务提交成功后触发佣金**

这里要明确分层：
- `recharge.Service` 属于 Service 层，用 `*queue.Client`
- `AutoPurchaseHandler` 属于 Worker 消费者，直接持有并使用 `*asynq.Client`

不要在同一段代码里写成 `if h.asynqClient != nil { h.queueClient.EnqueueTask(...) }`，这会把两套依赖混在一起。

建议写法：在 `ProcessTask()` 里先声明 `var createdOrderID uint`，事务内创建订单后赋值，事务成功返回后再入队佣金任务：

```go
var createdOrderID uint

err = h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
    // ... 扣钱包、创建订单、创建流水 ...
    if err = tx.Create(order).Error; err != nil {
        return err
    }
    createdOrderID = order.ID

    if err = h.activatePackages(ctx, tx, order, packages, now); err != nil {
        return err
    }

    if err = tx.Model(&model.AssetRechargeRecord{}).
        Where("id = ?", rechargeRecord.ID).
        Update("auto_purchase_status", constants.AutoPurchaseStatusSuccess).Error; err != nil {
        return err
    }

    return nil
})
if err != nil {
    return err
}

// 事务提交成功后，触发佣金计算
if h.asynqClient != nil && createdOrderID > 0 {
    payloadBytes, err := sonic.Marshal(map[string]any{"order_id": createdOrderID})
    if err != nil {
        h.logger.Warn("佣金任务载荷序列化失败",
            zap.Uint("order_id", createdOrderID),
            zap.Error(err))
    } else {
        commissionTask := asynq.NewTask(constants.TaskTypeCommission, payloadBytes,
            asynq.Queue(constants.QueueLow),
            asynq.MaxRetry(3),
        )
        if _, err := h.asynqClient.EnqueueContext(ctx, commissionTask); err != nil {
            h.logger.Warn("自动购包后提交佣金任务失败",
                zap.Uint("order_id", createdOrderID),
                zap.Error(err))
        }
    }
}
```

注意：`AutoPurchaseHandler` 这里新增的是 `asynqClient *asynq.Client`，不是 `queueClient *queue.Client`。

**常见误区（必须避免）**

1. **不要新增第二套自动购包常量**
   - 错误：`TaskTypeAutoPurchase = "auto_purchase"`
   - 正确：复用 `TaskTypeAutoPurchaseAfterRecharge`

2. **不要在 Worker 里再注入 `*queue.Client`**
   - 当前 `pkg/queue/handler.go:Handler` 已经持有 `asynqClient *asynq.Client`
   - 对 Worker 来说，直接 `EnqueueContext()` 更直接，也与现有 `PackageActivationHandler` 注入方式一致

3. **不要在事务内先投递佣金任务**
   - 如果事务后续回滚，就会出现：佣金任务已提交，但订单 / 套餐激活其实未落库成功
   - 所以必须先 `Transaction(...)` 成功返回，再提交 `TaskTypeCommission`

4. **不要漏掉 handler 注册**
   - `internal/task/auto_purchase.go` 里虽然已有 `AutoPurchaseHandler`
   - 但如果 `pkg/queue/handler.go:RegisterHandlers()` 不调用 `h.registerAutoPurchaseHandler()`，任务仍然不会被消费

5. **Service 层调用 `queueClient.EnqueueTask(...)` 时，传的是对象，不是预先 Marshal 后的 `[]byte`**
   - `queue.Client` 内部会自行 `Marshal`
   - 这里应直接传 `task.AutoPurchasePayload{RechargeRecordID: recharge.ID}`

**验收要点（不打接口，只看日志 + DBHub）**

> 不要求额外写测试代码。按下面方式人工校验即可。

**日志关键点**

- 支付回调成功后，应看到自动购包任务入队成功相关日志（至少能看到 Asynq 客户端的任务提交日志，`task_type=task:auto_purchase_after_recharge`）
- Worker 消费成功后，应看到：`自动购包任务执行成功`
- 如佣金任务入队失败，应看到：`自动购包后提交佣金任务失败`

**DBHub 核对顺序**

1. 查 `tb_asset_recharge_record`
   - 目标记录的 `auto_purchase_status` 应从 `pending` 变成 `success`

2. 查 `tb_order` / `tb_order_item`
   - 应新增一笔由自动购包生成的订单
   - `payment_method=wallet`
   - `payment_status=paid`
   - `buyer_type=personal`

3. 查 `tb_package_usage`
   - 应存在该订单对应的套餐使用记录
   - 正式套餐 / 加油包状态应与现有激活规则一致

4. 查 `tb_commission_record`
   - 在 worker 正常消费佣金任务后，应出现该 `order_id` 的佣金记录
   - 如果没有记录，再回看 worker 日志是否出现佣金任务提交失败或佣金计算失败

**建议的 DBHub 查询顺序图**

```text
tb_asset_recharge_record
        │ auto_purchase_status = success ?
        ├─ 否 → 先查自动购包 worker 日志
        ▼
tb_order / tb_order_item
        │ 自动购包订单是否已生成？
        ├─ 否 → 看 auto_purchase handler 事务内失败点
        ▼
tb_package_usage
        │ 套餐是否已激活 / 排队？
        ├─ 否 → 看 activatePackages() 相关日志
        ▼
tb_commission_record
          佣金记录是否生成？
          ├─ 否 → 看佣金任务入队 / 佣金计算日志
          └─ 是 → A-4 验收通过
```

---

### A-5 · 代购订单金额修正（P0-5）

**业务背景：五种购买场景**

系统支持以下五种套餐购买场景，每种有不同的金额和佣金逻辑：

| # | 操作方 | 资产所属 | 付款来源 | 订单面值（totalAmount） | 实际扣款（actualPaidAmount） | 佣金产生 |
|---|--------|---------|---------|----------------------|----------------------------|---------|
| 1 | 平台 | 平台自己客户 | 线下（无钱包） | 建议售价 | N/A | 不产生（应有标记） |
| 2 | 平台 | 某代理客户 | 该代理钱包 | 该代理成本价 | 该代理成本价 | 如有上级代理产生差额 |
| 3 | 平台 | 某代理客户 | 线下 | 该代理成本价 | N/A | 如有上级代理产生差额 |
| 4 | 代理 | 自己客户 | 自己钱包 | 自己成本价 | 自己成本价 | 如有上级代理产生差额 |
| 5 | 代理 | 下级代理客户 | 自己钱包 | **下级代理成本价（面值）** | **自己（上级）成本价** | 如有更上级代理产生差额 |

**梯度式佣金**：所有场景都计入梯度条件计数。一次性固定佣金只在走充值场景才产生，不走充值不产生。

**Bug 位置**

`internal/service/order/service.go:525-548`（场景 5 分支，代理代购下级）：

```go
// 当前代码
buyerCostPrice, _ := s.getCostPrice(ctx, *resourceShopID, firstPackageID)   // 下级成本价
operatorCostPrice, _ := s.getCostPrice(ctx, operatorShopID, firstPackageID) // 自己成本价

totalAmount     = buyerCostPrice       // 下级成本价作为面值（正确）
actualPaidAmount = &operatorCostPrice  // 实际扣自己钱包（正确）
sellerCostPrice  = buyerCostPrice      // ← ❌ 应是 operatorCostPrice
```

`sellerCostPrice` 用于佣金差额计算基准。错误地使用下级成本价，导致整个佣金链的差额计算为 0，更上级代理无法获得应有的佣金。

**修改方式**

```go
sellerCostPrice = operatorCostPrice  // ✅ 改为自己的成本价
```

同时检查同文件里是否有相同模式的第二处（搜索 `sellerCostPrice = buyerCostPrice`，共修复所有出现位置）。

---

### A-6 · 设备导入补充 IMEI 字段（P0-6）

**业务背景**

设备（Device）Model 有 `imei` 字段（`internal/model/device.go:19`），用于调用 Gateway API。但设备批量导入（Excel）的解析结构 `DeviceRow` 没有 IMEI，导入后 IMEI 始终为空，无法通过 IMEI 调 Gateway。

**修改清单**

**① `pkg/utils/excel.go:35-43`（DeviceRow struct）**

```go
// 修改前
type DeviceRow struct {
    Line         int
    VirtualNo    string
    DeviceName   string
    DeviceModel  string
    DeviceType   string
    MaxSimSlots  int
    Manufacturer string
    ICCIDs       []string
}

// 修改后（新增 IMEI 字段）
type DeviceRow struct {
    Line         int
    VirtualNo    string
    IMEI         string   // 新增
    DeviceName   string
    DeviceModel  string
    DeviceType   string
    MaxSimSlots  int
    Manufacturer string
    ICCIDs       []string
}
```

**② `pkg/utils/excel.go`（Excel 列名映射处）**

找到 `ParseDeviceExcel` 中处理表头映射的代码（大约在 290-320 行），在已有的列名 map 中新增：

```go
case "imei", "IMEI", "设备IMEI", "IMEI号":
    row.IMEI = cellValue
```

**③ `internal/task/device_import.go`（processBatch 函数，约 178 行）**

在创建 `model.Device{}` 时新增 `IMEI: row.IMEI`。

---

### A-7 · 提现冻结并发校验（P0-7）

**业务背景**

代理申请提现时，先冻结佣金钱包余额（`balance -= amount, frozen_balance += amount`），再创建提现申请单。问题：两个并发请求可以同时通过 `balance >= amount` 的 WHERE 检查，第二个 UPDATE 实际 `RowsAffected=0`（余额已不够），但代码只检查 `.Error` 不检查 `RowsAffected`，导致冻结失败却继续创建了提现单。

**修改位置**：`internal/service/my_commission/service.go:162-170`

```go
// 修改前（只检查 Error）
if err := tx.WithContext(ctx).Model(&model.AgentWallet{}).
    Where("id = ? AND balance >= ?", wallet.ID, req.Amount).
    Updates(map[string]interface{}{
        "balance":        gorm.Expr("balance - ?", req.Amount),
        "frozen_balance": gorm.Expr("frozen_balance + ?", req.Amount),
    }).Error; err != nil {
    return errors.Wrap(errors.CodeInternalError, err, "冻结余额失败")
}

// 修改后（新增 RowsAffected 校验）
result := tx.WithContext(ctx).Model(&model.AgentWallet{}).
    Where("id = ? AND balance >= ?", wallet.ID, req.Amount).
    Updates(map[string]interface{}{
        "balance":        gorm.Expr("balance - ?", req.Amount),
        "frozen_balance": gorm.Expr("frozen_balance + ?", req.Amount),
    })
if result.Error != nil {
    return errors.Wrap(errors.CodeInternalError, result.Error, "冻结余额失败")
}
if result.RowsAffected == 0 {
    return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突，请稍后重试")
}
```

### 方案 A · 人工验收清单

- **A-1 实名状态常量统一**
  - 工具：`rg` + DBHub + polling 日志
  - 步骤：
    1. 搜索 `return 2`、`newRealnameStatus == 2`、`2=已实名` 是否仍残留在实名链路相关文件
    2. DBHub 执行：`SELECT DISTINCT real_name_status FROM tb_iot_card ORDER BY 1;`
    3. 观察一轮实名轮询日志，确认不再写入值 `2`
  - 预期结果：代码 / 注释统一为 `0=未实名, 1=已实名`；DB 中实名状态最终只有 `0/1`

- **A-2 C端实名校验修复**
  - 工具：`rg` + DBHub
  - 步骤：
    1. 搜索 `internal/service/client_order/service.go` 中实名判断是否改为 `constants.RealNameStatusVerified`
    2. DBHub 抽查普通卡记录：`SELECT id, card_category, real_name_status FROM tb_iot_card WHERE card_category='normal' LIMIT 20;`
  - 预期结果：普通卡实名判断不再依赖硬编码；未实名普通卡会被拦截，已实名普通卡可继续下单

- **A-3 实名激活任务断链修复**
  - 工具：`rg` + Worker 日志 + DBHub
  - 步骤：
    1. 搜索 `internal/task/polling_handler.go`，确认不再走 `RPush` 降级人工队列
    2. 观察首次实名触发日志，应出现首次实名激活任务入队 / 消费日志
    3. DBHub 抽查：`SELECT id, status, pending_realname_activation, activated_at FROM tb_package_usage WHERE pending_realname_activation=true ORDER BY id DESC LIMIT 20;`
  - 预期结果：首次实名后相关套餐进入 Asynq 正常链路；待实名套餐能被激活，`activated_at` 被写入

- **A-4 自动购包触发 + 佣金补全**
  - 详见本节上方 **A-4 详细验收要点**
  - 最低预期：`tb_asset_recharge_record.auto_purchase_status=success`、自动购包订单生成、套餐激活成功、对应 `order_id` 出现佣金记录

- **A-5 代购订单金额修正**
  - 工具：`rg` + DBHub
  - 步骤：
    1. 搜索 `sellerCostPrice = buyerCostPrice`，确认场景 5 的错误赋值已消失
    2. DBHub 选一笔“代理代购下级代理客户”的订单及其佣金记录，核对 `total_amount`、`actual_paid_amount` 与上级佣金金额是否不再全为 0
  - 预期结果：代购下级场景里，订单面值仍是下级成本价，但佣金链能继续向上计算，不再整链归零

- **A-6 设备导入补充 IMEI 字段**
  - 工具：`rg` + DBHub + 导入日志
  - 步骤：
    1. 搜索 `DeviceRow`、`ParseDeviceExcel`、`device_import.go`，确认 IMEI 已贯通到导入流程
    2. 完成一次设备导入后，DBHub 执行：`SELECT id, virtual_no, imei FROM tb_device ORDER BY id DESC LIMIT 20;`
  - 预期结果：导入后的设备 `imei` 不再长期为空；日志中无 IMEI 丢失相关告警

- **A-7 提现冻结并发校验**
  - 工具：日志 + DBHub
  - 步骤：
    1. 人工制造一次重复提交提现的场景（后台重复点击或相邻两次提交）
    2. DBHub 查询对应钱包和提现单：
       - `SELECT id, balance, frozen_balance, version FROM tb_agent_wallet WHERE id = ?;`
       - `SELECT id, amount, status, created_at FROM tb_commission_withdrawal_request WHERE shop_id = ? ORDER BY id DESC LIMIT 10;`
    3. 观察日志是否出现“余额不足或并发冲突”
  - 预期结果：余额冻结成功次数与提现单创建次数一致；失败的并发请求不会继续生成提现单

- **方案 A 收尾检查**
  - 工具：`go build ./...`
  - 预期结果：编译通过，无新增类型错误或引用错误

---

## 方案 B：企业端权限完善（P1-1 + P2-11）

**业务背景**

企业账号（`user_type = 4`）通过 AdminAuth JWT 登录，复用 `/api/admin/*` 路由（无需新建中间件）。企业被分配了代理的卡/设备后，可以查看资产信息，可以对被授权的资产执行停复机，但**不能购买套餐**，**没有钱包**。

**现状**

`internal/handler/admin/asset.go` 有 10 个方法，企业拦截现状：

| 方法 | 功能 | 当前状态 | 应有状态 |
|------|------|---------|---------|
| `Resolve` | 通过 ICCID/虚拟号查询资产 | ✅ 有企业拦截（**应移除**，企业应能查） | ✅ 可访问 |
| `RealtimeStatus` | 查看实时状态 | 无拦截 | ✅ 可访问 |
| `Packages` | 查看套餐列表 | 无拦截 | ✅ 可访问 |
| `CurrentPackage` | 查看当前套餐 | 无拦截 | ✅ 可访问 |
| `Refresh` | 主动触发运营商刷新 | 无拦截 | ❌ 需拦截 |
| `StopDevice` | 设备停机 | 无拦截 | ✅ 企业有权管控 |
| `StartDevice` | 设备复机 | 无拦截 | ✅ 企业有权管控 |
| `StopCard` | 卡停机 | 无拦截 | ✅ 企业有权管控 |
| `StartCard` | 卡复机 | 无拦截 | ✅ 企业有权管控 |
| Wallet 相关 (2个) | 钱包查看 | ✅ 已拦截（企业无钱包） | ✅ 已正确拦截 |

**修改清单**

**① `internal/handler/admin/asset.go:Resolve`（第 40-44 行）**

```go
// 移除错误的企业拦截（企业应该能查询被授权的资产）
// 删除以下代码：
if userType == constants.UserTypeEnterprise {
    return errors.New(errors.CodeForbidden, "企业账号暂不支持此接口")
}
```

**② `internal/handler/admin/asset.go:Refresh`（第 78 行附近）**

在 `Refresh` 方法开头新增企业拦截：

```go
func (h *AssetHandler) Refresh(c *fiber.Ctx) error {
    // 企业账号不允许主动触发刷新（只读访问）
    userType := middleware.GetUserTypeFromContext(c.UserContext())
    if userType == constants.UserTypeEnterprise {
        return errors.New(errors.CodeForbidden, "企业账号无权主动刷新资产状态")
    }
    // ... 其余代码不变
```

**③ 资源标签覆盖度（修正后的真实落点）**

当前仓库**不存在** `internal/service/tag/` 目录，不要再按这个路径搜索。标签相关的真实代码入口是：

- `internal/store/postgres/resource_tag_store.go:ListByResource()`
- `internal/model/tag.go`（`Tag` / `ResourceTag` 模型定义）
- 当前上层调用点：`internal/service/exchange/migration.go:copyResourceTagsWithTx()`

如果企业资产详情 / 查询接口后续需要返回标签，优先修改 `resource_tag_store.go:ListByResource()` 这一层，确保企业账号读取资源标签时不会越权。

建议搜索：

```bash
grep -rn "ResourceTagStore\|ListByResource\|enterprise_id\|shop_id" internal/store/postgres internal/service --include="*.go"
```

建议补法：

```go
query := s.db.WithContext(ctx).
    Where("resource_type = ? AND resource_id = ?", resourceType, resourceID)

query = middleware.ApplyShopFilter(ctx, query)

// 注意：pkg/middleware/data_scope.go:ApplyEnterpriseFilter() 只会加 enterprise_id = ?
// 如果企业账号还应看到全局标签（enterprise_id IS NULL），这里需要显式补 OR IS NULL 条件
if middleware.GetUserTypeFromContext(ctx) == constants.UserTypeEnterprise {
    enterpriseID := middleware.GetEnterpriseIDFromContext(ctx)
    query = query.Where("enterprise_id = ? OR enterprise_id IS NULL", enterpriseID)
} else {
    query = middleware.ApplyEnterpriseFilter(ctx, query)
}
```

如果当前企业资产接口根本还**没有**返回标签，这一项就作为“防未来越权回归”的检查项保留，不要求为了这次修复额外新增标签接口。

### 方案 B · 人工验收清单

- 工具：`rg` + DBHub + 管理端 / API 日志 + `go build ./...`
- 步骤：
  1. 搜索 `internal/handler/admin/asset.go`，确认 `Resolve` 中已删除企业账号禁用逻辑，`Refresh` 中新增企业账号拦截
  2. 搜索 `internal/store/postgres/resource_tag_store.go` 与其上层调用点，确认资源标签读取路径已定位到真实文件，而不是不存在的 `internal/service/tag/`
  3. 如当前资产接口会返回标签，则检查标签读取逻辑是否包含 `enterprise_id = ? OR enterprise_id IS NULL` 一类条件；如当前不返回标签，则仅记录“当前无标签读取链路，无需额外改接口”
  4. DBHub 抽查一条企业资产数据 / 资源标签数据，确认企业侧仅能命中本企业可见资产 / 标签
  5. 查看日志关键字：企业调用 `Refresh` 时应被拒绝；调用 `Resolve / RealtimeStatus / StopCard / StartCard / StopDevice / StartDevice` 不应被通用禁止逻辑拦住
- 预期结果：企业账号“可查看、可停复机、不可主动刷新、无钱包”，且标签查询不越权

---

## 方案 C：提现/佣金流程完善（P2-19 + P2-20 + P2-21 + P2-22）

> P1-3（已到账确认接口）已确认不需要实现，系统无法感知到账。

---

### C-1 · 审批人字段补全（P2-20）

**问题**：`internal/model/financial.go:27-30` 定义了 `ApprovedBy` 和 `ApprovedAt`，但 `commission_withdrawal/service.go:Approve()` 只写了 `processor_id` 和 `processed_at`，`approved_by`/`approved_at` 永远为空。

**修改位置**：`internal/service/commission_withdrawal/service.go:Approve()` 中 updates map，补充：

```go
updates["approved_by"] = currentUserID
updates["approved_at"] = now
```

---

### C-2 · 提现拒绝 remark 必填校验（P2-19）

**问题**：`internal/model/dto/commission_withdrawal_dto.go:61-65` 的 DTO 标注了 `validate:"required"`，但 `internal/handler/admin/commission_withdrawal.go:Reject()` 只 `BodyParser` 没有 `Validate`。

**修改位置**：`commission_withdrawal.go:Reject()` 中 BodyParser 后补充：

```go
if err := validator.Validate(&req); err != nil {
    return errors.New(errors.CodeInvalidParam)
}
```

（参考同文件或其他 Handler 的 Validate 调用方式）

---

### C-3 · 激活配置并发保护（P2-21 + P2-22）

**问题**：`commission_withdrawal_setting/service.go` 和 `wechat_config_store.go` 激活新配置时，是两步 UPDATE（先全部置 false，再置目标为 true），无锁保护，并发可产生两条 active=true 的记录。

**修改方式**：在事务内，第一步 UPDATE 前加 `FOR UPDATE` 锁：

```go
// commission_withdrawal_setting/service.go（两处都改）
err := s.db.Transaction(func(tx *gorm.DB) error {
    // 先锁定当前 active 记录
    var current model.CommissionWithdrawalSetting
    tx.Set("gorm:query_option", "FOR UPDATE").
        Where("is_active = ?", true).
        First(&current)  // 不管 err，只是锁住

    // 再执行原有的 deactivate → activate 逻辑
    ...
})

// wechat_config_store.go 同理
```

### 方案 C · 人工验收清单

- **C-1 审批人字段补全**
  - 工具：DBHub + 日志
  - 步骤：审批一笔提现后，执行：`SELECT id, processor_id, processed_at, approved_by, approved_at FROM tb_commission_withdrawal_request ORDER BY id DESC LIMIT 10;`
  - 预期结果：`approved_by` / `approved_at` 不再为空，且与 `processor_id` / `processed_at` 对应

- **C-2 拒绝提现 remark 必填**
  - 工具：`rg` + 管理端 / API 日志
  - 步骤：
    1. 搜索 `commission_withdrawal.go:Reject()`，确认存在 `validator.Validate(&req)`
    2. 人工提交一次空 remark 的拒绝操作
  - 预期结果：请求被拦截，返回参数错误；提现记录状态不发生变化

- **C-3 激活配置并发保护**
  - 工具：DBHub + 日志
  - 步骤：
    1. 连续切换一次佣金提现配置、一次微信支付配置
    2. DBHub 分别执行：
       - `SELECT COUNT(*) FROM tb_commission_withdrawal_setting WHERE is_active=true;`
       - `SELECT COUNT(*) FROM tb_wechat_config WHERE is_active=true;`
  - 预期结果：两个表任意时刻都最多只有一条 `is_active=true`

- **方案 C 收尾检查**
  - 工具：`go build ./...`
  - 预期结果：编译通过

---

## 方案 D：设备体系完善（P1-10 + P2-28 + P2-29 + 字段扩展）

> 设备定期轮询（P1-9）已确认为伪需求，取消。改为查详情时按需实时拉取。
> P2-30（轮询配置通用化）也随之取消。

---

### D-0 · 设备模型字段扩展

**业务背景**

Gateway `sync-info` 接口返回大量实时设备信息，当前 Device Model 几乎无对应字段。需要扩展存储"有离线意义"的字段，"只在线有意义"的字段只实时返回不存库。

**Gateway 接口文档**：`docs/第三方文档/gateway设备详情同步接口.md`
- URL：`POST /v1/iot/openApi/device/sync-info`
- 请求：`{"params": {"cardNo": "设备标识"}}` （cardNo > 15 位用 ICCID，= 15 位用 IMEI，= 11 位用 SN）
- 认证：`appId + sign` 签名（与现有 Gateway Client 一致）

**字段策略（不用 JSONB，用独立字段）**

需存 DB（有离线意义）：

| 字段名 | 类型 | Gateway 对应字段 | 说明 |
|--------|------|-----------------|------|
| `online_status` | INT DEFAULT 0 | `online_status` | 1=在线，2=离线 |
| `last_online_time` | TIMESTAMP NULL | `last_online_time` | 最后在线时间 |
| `software_version` | VARCHAR(100) DEFAULT '' | `software_version` | 固件版本 |
| `switch_mode` | VARCHAR(10) DEFAULT '0' | `switch_mode` | 切卡模式：0=自动，1=手动 |
| `last_gateway_sync_at` | TIMESTAMP NULL | — | 最后一次 sync-info 同步时间 |

只实时返回（不存 DB）：`rssi`, `battery_level`, `ssid`, `wifi_enabled`, `rsrp`, `sinr`, `run_time`, `connect_time` 等

**修改清单**

**① DB 迁移（`migrations/` 目录新建迁移文件）**

```sql
ALTER TABLE tb_device
    ADD COLUMN online_status INT NOT NULL DEFAULT 0,
    ADD COLUMN last_online_time TIMESTAMP NULL,
    ADD COLUMN software_version VARCHAR(100) NOT NULL DEFAULT '',
    ADD COLUMN switch_mode VARCHAR(10) NOT NULL DEFAULT '0',
    ADD COLUMN last_gateway_sync_at TIMESTAMP NULL;
```

**② `internal/model/device.go`**：新增以上 5 个 GORM 字段（参考现有字段的标签格式）。

**③ `internal/model/dto/device_dto.go`**：
- `DeviceResponse`（列表/详情基础信息）：新增 5 个 DB 字段
- `DeviceRealtimeInfo`（实时详情，从 sync-info 直接返回）：新增非存储字段（rssi, battery_level, ssid, wifi_enabled 等），标注 `description:"实时字段，设备在线时有效"`

---

### D-1 · Gateway sync-info 接口对接（P1-10，前置）

**为什么现在没有**：早期只对接了旧的异步接口 `/device/info`（调用后通过 callbackUrl 异步返回）。同步接口 `sync-info` 是 Gateway 后来提供的，直接返回全量数据，未同步接入。`internal/model/dto/client_asset_dto.go:102` 有 `CurrentIccid` 字段但注释写着"当前 Gateway 同步接口尚未对接，预留结构待后续填充"。

**修改清单**

**① `internal/gateway/device.go`**（当前只有 `GetDeviceInfo()` 和 `GetSlotInfo()`）：

新增方法：

```go
// SyncDeviceInfoReq sync-info 请求
type SyncDeviceInfoReq struct {
    CardNo string `json:"card_no"` // ICCID / IMEI / SN
}

// SyncDeviceInfoResp sync-info 响应（对应文档 data 字段）
type SyncDeviceInfoResp struct {
    DeviceID        string  `json:"device_id"`
    DeviceName      string  `json:"device_name"`
    IMEI            string  `json:"imei"`
    CurrentIccid    string  `json:"current_iccid"`
    DeviceType      string  `json:"device_type"`
    SoftwareVersion string  `json:"software_version"`
    MacAddress      string  `json:"mac_address"`
    SSID            string  `json:"ssid"`
    WifiEnabled     bool    `json:"wifi_enabled"`
    SwitchMode      string  `json:"switch_mode"`
    RSSI            string  `json:"rssi"`
    BatteryLevel    *int    `json:"battery_level"`
    OnlineStatus    int     `json:"online_status"` // 1=在线, 2=离线
    LastUpdateTime  string  `json:"last_update_time"`
    LastOnlineTime  string  `json:"last_online_time"`
    RunTime         string  `json:"run_time"`
    DailyUsage      string  `json:"daily_usage"`
}

// SyncDeviceInfo 同步查询设备信息（直接返回，无需回调）
func (c *Client) SyncDeviceInfo(ctx context.Context, req *SyncDeviceInfoReq) (*SyncDeviceInfoResp, error) {
    // 参考现有 doRequestWithResponse 的调用方式
    return doRequestWithResponse[SyncDeviceInfoResp](c, ctx, "/device/sync-info", req)
}
```

---

### D-2 · device_sim_binding 加 is_current（P2-28）

**业务背景**：设备绑定多张卡，需要知道"当前设备用哪张卡在传输数据"。现在用 `slot_position == 1` 猜测，不准。

**修改清单**

**① DB 迁移**：
```sql
ALTER TABLE tb_device_sim_binding ADD COLUMN is_current BOOLEAN NOT NULL DEFAULT FALSE;
```

**② `internal/model/device_sim_binding.go`**：新增字段：
```go
IsCurrent bool `gorm:"column:is_current;type:boolean;not null;default:false;comment:是否为当前使用的卡" json:"is_current"`
```

**③ 更新逻辑**（在 D-3 的 `updateDeviceFromSyncInfo` 函数中）：
- sync-info 返回 `current_iccid` → 在 `tb_device_sim_binding` 中找到对应行，设为 `is_current=true`，其余行设为 `is_current=false`
- 使用事务，两步原子更新

**④ DTO 补充**：`internal/model/dto/device_dto.go` 的 `BoundCardInfo` 中新增 `IsCurrent bool`。

---

### D-3 · 设备 Refresh + 详情接入 sync-info（P2-29）

**当前问题**：`POST /assets/device/:id/refresh` 只遍历绑定卡调卡网关，`gateway/device.go` 的 `SyncDeviceInfo` 方法（D-1 新增的）没有被使用。

**修改位置**：`internal/service/asset/service.go:RefreshDevice()` 分支（约 307-339 行）

在刷新卡数据之后，追加设备自身的 sync-info 调用：

```go
// 刷新设备自身信息（在线状态、当前卡、固件版本等）
if s.gatewayClient != nil {
    cardNo := device.IMEI  // 优先用 IMEI，无 IMEI 则用 SN
    if cardNo == "" {
        cardNo = device.SN
    }
    if cardNo != "" {
        if syncResp, err := s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{
            CardNo: cardNo,
        }); err == nil {
            s.updateDeviceFromSyncInfo(ctx, device.ID, syncResp)
        } else {
            s.logger.Warn("sync-info 调用失败", zap.Uint("device_id", device.ID), zap.Error(err))
        }
    }
}
```

新增私有函数 `updateDeviceFromSyncInfo(ctx, deviceID, syncResp)`：
1. 更新 device 表的 5 个 DB 字段（online_status, last_online_time, software_version, switch_mode, last_gateway_sync_at）
2. 根据 `syncResp.CurrentIccid` 更新 `device_sim_binding.is_current`（D-2 逻辑）

同样，查看设备详情接口（`GetDeviceDetail` 或 `RealtimeStatus`）中，如果调用了 `SyncDeviceInfo`，将 `syncResp` 中的实时字段追加到响应中（不存 DB 的字段）。

### 方案 D · 人工验收清单

- **D-0 设备模型字段扩展**
  - 工具：DBHub + `rg`
  - 步骤：
    1. DBHub 搜索 `tb_device` 列，确认新增 `online_status / last_online_time / software_version / switch_mode / last_gateway_sync_at`
    2. 搜索 `internal/model/device.go` 和 `internal/model/dto/device_dto.go`，确认模型 / DTO 已同步
  - 预期结果：库表、模型、DTO 三处字段一致

- **D-1 sync-info 接口对接**
  - 工具：`rg` + `go build ./...`
  - 步骤：搜索 `SyncDeviceInfoReq`、`SyncDeviceInfoResp`、`SyncDeviceInfo(` 是否已在 `internal/gateway/device.go` 定义并编译通过
  - 预期结果：Gateway Client 已具备同步查询设备详情的能力

- **D-2 is_current 字段**
  - 工具：DBHub
  - 步骤：
    1. DBHub 搜索 `tb_device_sim_binding` 列，确认存在 `is_current`
    2. 抽查一台绑定多卡设备：`SELECT device_id, iot_card_id, is_current FROM tb_device_sim_binding WHERE device_id = ? ORDER BY id;`
  - 预期结果：同一设备最多一条 `is_current=true`

- **D-3 Refresh / 详情接入 sync-info**
  - 工具：Worker / API 日志 + DBHub
  - 步骤：
    1. 人工触发一次设备刷新
    2. 日志应出现 `sync-info` 调用成功或失败关键字
    3. DBHub 查询 `tb_device` 与 `tb_device_sim_binding`，确认设备状态字段和当前卡标识已更新
  - 预期结果：设备刷新不再只刷新卡，还会刷新设备自身在线状态 / 固件版本 / 当前使用卡

- **方案 D 收尾检查**
  - 工具：`go build ./...`
  - 预期结果：编译通过

---

## 方案 E：流量体系改革（P2-26 + P2-27 + P2-31）

**注意**：这是架构改造，涉及 DB 迁移，建议低峰期发布。

---

### E-1 · 流量详单改为日粒度缓冲（P2-26）

**问题**：`internal/task/polling_handler.go:268` 每次轮询无条件调用 `insertDataUsageRecord()`，即便 `data_increase_mb=0` 也写记录（函数内部计算增量=0 后仍然 Create）。大量卡每日产生无意义记录。

**新设计**：
1. 新建 `tb_card_daily_usage` 表（每张卡每天一条）
2. 轮询时流量变化写 Redis 增量缓存（INCRBYFLOAT）
3. 每日落盘：定时任务 SCAN 昨日 key → UPSERT 到 `tb_card_daily_usage` → 删 key

**DB 迁移**：

```sql
CREATE TABLE tb_card_daily_usage (
    id BIGSERIAL PRIMARY KEY,
    iot_card_id BIGINT NOT NULL,
    date DATE NOT NULL,
    usage_mb BIGINT NOT NULL DEFAULT 0,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    CONSTRAINT uk_card_daily UNIQUE (iot_card_id, date)
);
CREATE INDEX idx_card_daily_date ON tb_card_daily_usage (date);
```

**Redis Key 格式**（在 `pkg/constants/redis.go` 新增）：

```go
// RedisCardDailyTrafficKey 卡日流量缓存 key
// 格式: traffic:daily:{card_id}:{YYYY-MM-DD}
func RedisCardDailyTrafficKey(cardID uint, date string) string {
    return fmt.Sprintf("traffic:daily:%d:%s", cardID, date)
}
```

**`insertDataUsageRecord` 改造**（`polling_handler.go:812`）：

```go
// 修改前：无条件写 DB
// 修改后：只在有增量时更新 Redis
func (h *PollingHandler) insertDataUsageRecord(ctx context.Context, cardID uint, currentUsageMB, previousUsageMB float64, ...) {
    increment := currentUsageMB - previousUsageMB
    if increment <= 0 {
        return // 无增量，不写
    }

    today := time.Now().Format("2006-01-02")
    key := constants.RedisCardDailyTrafficKey(cardID, today)
    h.redis.IncrByFloat(ctx, key, increment)
    h.redis.Expire(ctx, key, 48*time.Hour) // 保留 48h，落盘任务有时间消费
}
```

**新增每日落盘任务**（`pkg/constants/constants.go` 新增 `TaskTypeDailyTrafficFlush`）：
- Asynq Scheduler 每天凌晨 2 点触发
- SCAN `traffic:daily:*:{昨日}` → 批量 UPSERT `tb_card_daily_usage` → DELETE key

---

### E-2 · current_month_usage_mb 改增量累加（P2-27）

**问题**：`calculateFlowUpdates()` 直接 `current_month_usage_mb = gatewayValue` 覆盖。上游运营商按自然月重置（联通 27 号，移动/电信/广电 1 号），重置后 gateway 返回 0，我们的值也归零，但我们的套餐周期未必与自然月一致。

**运营商重置日规则**：存在 `tb_carrier.data_reset_day` 字段（方案 E-2 新增，运营建档时手动填写）：
- 中国联通：27
- 中国移动 / 中国电信 / 广电：1

**DB 迁移**：

```sql
-- 运营商表新增重置日
ALTER TABLE tb_carrier ADD COLUMN data_reset_day INT NOT NULL DEFAULT 1;

-- IoT 卡表新增上次网关读数（用于算增量）
ALTER TABLE tb_iot_card ADD COLUMN last_gateway_reading_mb FLOAT NOT NULL DEFAULT 0;
```

**运营商管理接口**：创建/编辑运营商时新增 `data_reset_day` 字段（1-28，前端显示为"每月 N 日重置流量"）。IoT 卡创建/导入时 `carrier_id` 改为必填（原来可为空）。

**`calculateFlowUpdates()` 改写**（`polling_handler.go:388`）：

```go
func (h *PollingHandler) calculateFlowUpdates(card *model.IotCard, gatewayFlowMB float64, now time.Time) map[string]any {
    updates := make(map[string]any)

    increment := gatewayFlowMB - card.LastGatewayReadingMB

    // 检测上游重置：当前值比上次读数小
    if increment < 0 {
        resetDay := h.getCarrierResetDay(card.CarrierID) // 从缓存/DB 读运营商重置日
        today := now.Day()
        // 在重置日窗口内（重置日 ±1 天容错）才认为是正常重置
        if isResetWindow(today, resetDay) {
            increment = gatewayFlowMB  // 上游重置，本次原始值就是增量
        } else {
            h.logger.Warn("流量异常：非重置日出现值下降", ...)
            increment = 0
        }
    }

    if increment > 0 {
        updates["current_month_usage_mb"] = gorm.Expr("current_month_usage_mb + ?", increment)
        updates["data_usage_mb"] = gorm.Expr("data_usage_mb + ?", int64(increment))
    }
    updates["last_gateway_reading_mb"] = gatewayFlowMB

    // 跨自然月重置（我们系统的重置跟套餐 data_reset_cycle 走）
    // ... 保留现有的 isCrossMonth 逻辑，但改为 +=，不再覆盖

    return updates
}
```

---

### E-3 · 流量查询层适配（P2-31）

**问题**：改造后数据在 Redis（今日）+ `tb_card_daily_usage`（历史），现有 `DataUsageRecordStore.ListByCardID()` 查旧表，需适配。

**新建 `TrafficQueryService`**（`internal/service/traffic/query_service.go`）：

```go
func (s *TrafficQueryService) GetDailyUsage(ctx context.Context, cardID uint, startDate, endDate time.Time) ([]*DailyUsageEntry, error) {
    var result []*DailyUsageEntry

    // 今天的数据 → Redis
    today := time.Now().Format("2006-01-02")
    if !endDate.Before(time.Now().Truncate(24*time.Hour)) {
        key := constants.RedisCardDailyTrafficKey(cardID, today)
        val, _ := s.redis.Get(ctx, key).Float64()
        result = append(result, &DailyUsageEntry{Date: today, UsageMB: val})
    }

    // 历史数据 → DB
    dbRecords, err := s.dailyUsageStore.ListByCardIDAndDateRange(ctx, cardID, startDate, endDate.AddDate(0, 0, -1))
    if err == nil {
        result = append(result, dbRecords...)
    }

    return sortAndMerge(result), nil
}
```

更新 `GetRealtimeStatus`、C 端流量详情接口等，改为调用 `TrafficQueryService`。

### 方案 E · 人工验收清单

- **E-1 日粒度缓冲**
  - 工具：DBHub + `rg` + Worker 日志
  - 步骤：
    1. DBHub 确认存在 `tb_card_daily_usage`
    2. 搜索 `RedisCardDailyTrafficKey` 与 `TaskTypeDailyTrafficFlush`
    3. 观察日落盘任务日志，确认执行了 flush / upsert
  - 预期结果：今日增量先缓存在 Redis，历史日流量落到 `tb_card_daily_usage`；不再无差别写 0 增量详单

- **E-2 流量增量累加**
  - 工具：DBHub + `rg` + polling 日志
  - 步骤：
    1. DBHub 确认新增 `tb_carrier.data_reset_day`、`tb_iot_card.last_gateway_reading_mb`
    2. 搜索 `calculateFlowUpdates()`，确认 `current_month_usage_mb` 改成 `gorm.Expr("current_month_usage_mb + ?", increment)` 一类累加写法
    3. 观察异常下降日志是否带“非重置日出现值下降”告警
  - 预期结果：系统不再被上游自然月清零直接覆盖；异常下降只告警不误扣

- **E-3 查询层适配**
  - 工具：`rg` + DBHub
  - 步骤：
    1. 搜索 `TrafficQueryService`、`GetDailyUsage`
    2. 搜索 `GetRealtimeStatus` / C 端流量详情是否改为依赖新查询服务
    3. DBHub 抽查历史日数据是否可查
  - 预期结果：查询层能同时兼容“今日 Redis + 历史 DB”两段数据

- **方案 E 收尾检查**
  - 工具：`go build ./...`
  - 预期结果：编译通过

---

## 方案 F：代码质量清理

---

### F-1 · 佣金链断裂改为零额待审记录（P2-4）

**业务规则**：一个代理卖出资产后，套餐系列未分配给该代理（无佣金配置），不应静默跳过，而是创建金额=0、状态=待人工修正的佣金记录，让平台管理员能发现并手动处理。

**修改清单**

**① `pkg/constants/` 中新增佣金状态常量**：

```go
CommissionStatusPendingReview = 99 // 待人工修正（链路断裂，需平台处理）
```

**② `internal/service/commission_calculation/service.go`** 断链处（`break` 之前）：

```go
// 原来：静默 break
// 修改后：创建零额待审记录 + 记日志
s.logger.Warn("佣金链断裂：代理缺少套餐系列授权，创建待审记录",
    zap.Uint("shop_id", shopID),
    zap.Uint("series_id", seriesID),
    zap.Uint("order_id", orderID))

pendingRecord := &model.CommissionRecord{
    OrderID:         orderID,
    ShopID:          shopID,
    SeriesID:        &seriesID,
    Amount:          0,
    Status:          constants.CommissionStatusPendingReview,
    Remark:          fmt.Sprintf("套餐系列[%d]未分配给该代理，请人工核查", seriesID),
}
_ = s.commissionRecordStore.Create(ctx, pendingRecord) // 失败不阻断主流程
break
```

**③ 管理端新增筛选**：佣金记录列表接口支持按 `status=99` 筛选，让平台管理员能看到所有待人工修正的记录。

---

### F-2 · 废弃代码删除（P2-8）

**背景**：`sim:status:sync` 任务功能已被 Refresh 接口 + 轮询系统完整覆盖，但留了一套完全无效的代码（消费者核心是 `time.Sleep`，生产者全局无调用点）。

**删除清单**（顺序执行，防止编译错误）：

1. `pkg/queue/handler.go:62-63`：删除注册行
   ```go
   // 删除这两行
   h.mux.HandleFunc(constants.TaskTypeSIMStatusSync, simHandler.HandleSIMStatusSync)
   h.logger.Info("注册 SIM 状态同步任务处理器", ...)
   ```

2. `pkg/queue/handler.go` 的 `import` 中，检查 `simHandler` 引用，如果只有这里用，也删掉 import

3. `pkg/constants/constants.go`：删除 `TaskTypeSIMStatusSync` 常量

4. `internal/task/sim.go`：整个文件删除

5. `internal/service/sync/service.go`：整个文件删除（检查是否有外部引用，无则直接删）

6. `go build ./...` 确认无编译错误

---

### F-3 · 错误不应被吞（P2-14）

`internal/service/asset/service.go:124` 和 `:273`：

```go
// 修改前（错误被 _ 忽略）
cards, _ := s.iotCardStore.GetByIDs(ctx, cardIDs)

// 修改后（记录日志，不中断主流程）
cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs)
if err != nil {
    s.logger.Warn("查询绑定卡信息失败，结果可能不完整",
        zap.Uints("card_ids", cardIDs),
        zap.Error(err))
}
```

---

### F-4 · C端 Handler 迁移到 Service 层（P2-15）

**问题**：`internal/handler/app/client_order.go:103` 直接 `h.db.WithContext(resolved.SkipPermissionCtx)` 查数据库，绕过了 Service 层。

**修改方式**：
1. 在 `internal/service/client_order/service.go` 新增 `ListOrders(ctx, req)` 和 `GetOrderDetail(ctx, orderID)` 方法（迁移 Handler 里的 DB 查询逻辑）
2. `client_order.go` Handler 改为调用 Service 方法，删除直接 DB 访问和 `SkipPermissionCtx`

---

### F-5 · Handler/Service 权限不一致（P2-16）

`internal/handler/admin/order.go`（创建订单 Handler）中补充：

```go
if req.PaymentMethod == model.PaymentMethodWallet {
    userType := middleware.GetUserTypeFromContext(c.UserContext())
    if userType != constants.UserTypeAgent {
        return errors.New(errors.CodeInvalidParam, "仅代理账号可使用钱包支付")
    }
}
```

---

### F-6 · StopResumeCallback / ResumeCallback 注入（P2-7）

**问题**：`SetStopResumeCallback` / `SetResumeCallback` 方法定义了但没有被 bootstrap 调用，停复机后套餐相关回调不触发。

**修改位置**：`internal/bootstrap/services.go`（或 `worker_services.go`，找到 `StopResumeService` 和 `ActivationService` 初始化处）

搜索：
```bash
grep -rn "NewStopResumeService\|NewActivationService\|ActivationService" internal/bootstrap/ --include="*.go"
```

在两者初始化完成后，追加：

```go
usageService.SetStopResumeCallback(stopResumeService)
activationService.SetResumeCallback(stopResumeService)
```

### 方案 F · 人工验收清单

- **F-1 零额待审佣金记录**
  - 工具：DBHub + 日志
  - 步骤：人工制造一笔“套餐系列未分配给代理”的订单后，执行：`SELECT order_id, shop_id, amount, status, remark FROM tb_commission_record WHERE status=99 ORDER BY id DESC LIMIT 20;`
  - 预期结果：出现 `status=99`、`amount=0` 的待审佣金记录，日志有“佣金链断裂”告警

- **F-2 废弃代码删除**
  - 工具：`rg` + `go build ./...`
  - 步骤：搜索 `TaskTypeSIMStatusSync`、`HandleSIMStatusSync`、`internal/task/sim.go`、`internal/service/sync/service.go`
  - 预期结果：无残留引用，相关文件已删，编译通过

- **F-3 错误不应被吞**
  - 工具：`rg` + 日志
  - 步骤：搜索 `cards, _ :=` 是否已替换；观察查询失败时日志是否出现“查询绑定卡信息失败，结果可能不完整”
  - 预期结果：错误被记录但不误中断主流程

- **F-4 C端 Handler 迁移到 Service**
  - 工具：`rg` + `go build ./...`
  - 步骤：
    1. 搜索 `internal/handler/app/client_order.go`，确认不再直接 `h.db.WithContext(...)`
    2. 搜索 `internal/service/client_order/service.go`，确认存在 `ListOrders` / `GetOrderDetail`
  - 预期结果：C 端订单查询重新回到 Handler → Service 分层

- **F-5 Handler/Service 权限一致**
  - 工具：`rg`
  - 步骤：搜索 `internal/handler/admin/order.go`，确认钱包支付前置校验存在“仅代理账号可使用钱包支付”
  - 预期结果：Handler 层和 Service 层权限前置一致，不再出现一个放行一个拒绝

- **F-6 StopResume / Resume 回调注入**
  - 工具：`rg` + 日志
  - 步骤：搜索 `SetStopResumeCallback`、`SetResumeCallback` 在 bootstrap 中的调用；观察停复机后相关联动日志
  - 预期结果：停机 / 复机后的套餐联动回调真正被触发

- **方案 F 收尾检查**
  - 工具：`go build ./...`
  - 预期结果：编译通过

---

## 方案 G：实名激活架构重构（P2-25 + P2-1 + P1-8）

**业务背景**

现有 `Package.enable_realname_activation` 字段同时混淆了两个独立的业务概念：

1. **"是否需要等实名才激活"** → 这应该由**卡的类型**决定，不是套餐决定
   - `industry` 卡（行业卡）：永远不需要实名，直接激活
   - `normal` 卡（普通卡）：C 端客户自购前就要求实名；后台囤货则等实名后激活

2. **"到期时间从哪里开始算"** → 这才是**套餐级配置**，需要新增字段

原字段的本来意图：平台/代理"囤货"给资产预购套餐时，套餐激活时间有两种模式：
- `from_activation`：等客户实名后激活，激活那一刻开始计时（当前行为）
- `from_purchase`：购买即开始计时，实名只是"解锁使用权"，不影响计时

**修改清单**

**① DB 迁移**：

```sql
-- 移除旧字段
ALTER TABLE tb_package DROP COLUMN enable_realname_activation;

-- 新增到期时间基准字段
ALTER TABLE tb_package ADD COLUMN expiry_base VARCHAR(30) NOT NULL DEFAULT 'from_activation';
-- 枚举值：from_activation（激活时起算）| from_purchase（购买时起算）
```

**② `internal/model/package.go:Package`**：

```go
// 删除
EnableRealnameActivation bool `gorm:"column:enable_realname_activation;..."`

// 新增
ExpiryBase string `gorm:"column:expiry_base;type:varchar(30);not null;default:'from_activation';comment:到期时间基准 from_activation-实名激活时起算 from_purchase-购买时起算"`
```

**③ `internal/service/order/service.go:activateMainPackage`（约 1869 行）**

改写套餐激活决策：

```go
// 修改前（只看套餐字段）
if pkg.EnableRealnameActivation {
    status = pending
    pendingRealnameActivation = true
} else {
    status = active; activatedAt = now
}

// 修改后（按卡类型 + 购买路径决定）
isCEndPurchase := 购买路径判断（可从 order.OrderType 或 buyerType 推断，C端 = "personal"）
if card.CardCategory == "industry" {
    // 行业卡永远直接激活
    status = constants.PackageUsageStatusActive
    activatedAt = now
} else if isCEndPurchase {
    // C端客户购买前已检查实名，直接激活
    status = constants.PackageUsageStatusActive
    activatedAt = now
} else {
    // 后台囤货（平台/代理）
    if pkg.ExpiryBase == "from_purchase" {
        // 购买即激活，立即开始计时
        status = constants.PackageUsageStatusActive
        activatedAt = now
    } else {
        // from_activation：等实名后激活
        status = constants.PackageUsageStatusPending
        pendingRealnameActivation = true
    }
}
```

**④ `internal/service/client_order/service.go`**（C端购买实名检查）

```go
// 修改前（依赖套餐字段）
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != constants.RealNameStatusVerified {

// 修改后（改为按卡类型判断）
if assetInfo.CardCategory == "normal" && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
    return errors.New(errors.CodeForbidden, "请先完成实名认证")
}
// 删除 packagesNeedRealname() 函数（不再需要）
```

**⑤ `internal/service/package/activation_service.go:ActivateByRealname`（约 117 行）**

```go
// 修改前（到期时间总是从当前时刻算）
activatedAt := now
expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, ...)

// 修改后（根据 ExpiryBase 选择基准时间）
var activatedAt time.Time
if pkg.ExpiryBase == "from_purchase" {
    activatedAt = packageUsage.CreatedAt  // 用订单/记录创建时间
} else {
    activatedAt = now  // 实名检测到的时刻
}
expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, ...)
```

**⑥ 套餐管理 API**：创建/更新套餐时，移除 `enable_realname_activation` 字段，新增 `expiry_base` 字段（枚举：`from_activation | from_purchase`，默认 `from_activation`）。更新 DTO 和接口文档。

### 方案 G · 人工验收清单

- 工具：DBHub + `rg` + polling / activation 日志 + `go build ./...`
- 步骤：
  1. DBHub 确认 `tb_package` 已删除 `enable_realname_activation`、新增 `expiry_base`
  2. 搜索全仓 `EnableRealnameActivation`，确认旧字段引用清空；搜索 `ExpiryBase`，确认至少在 `order/service.go`、`client_order/service.go`、`package/activation_service.go` 被使用
  3. 抽查普通卡 / 行业卡 / 囤货套餐的 `tb_package_usage`，核对 `status`、`pending_realname_activation`、`activated_at`、`expires_at`
  4. 观察首次实名激活日志，确认 `from_purchase` / `from_activation` 的到期时间基准已分流
- 预期结果：实名需求回归卡类型；套餐只负责到期时间基准；旧字段完全退出主流程

---

## 方案 H：轮询系统小修（P1-6 + P2-10 + P2-12 + P2-23）

---

### H-1 · polling:protect 接入调度（P1-6）

**问题**：`pkg/constants/constants.go:59` 定义了 `TaskTypePollingProtect = "polling:protect"`，处理器 `HandleProtectConsistencyCheck` 也已注册（`pkg/queue/handler.go:registerPollingHandlers`），但 Scheduler 从未调度此任务。

**修改位置**：`internal/polling/scheduler.go`（找到主调度循环，约 243-250 行）

```go
// 当前（缺少 protect 的调度）
s.processManualQueue(ctx, constants.TaskTypePollingRealname)
s.processManualQueue(ctx, constants.TaskTypePollingCarddata)
s.processManualQueue(ctx, constants.TaskTypePollingPackage)

s.processTimedQueue(ctx, constants.RedisPollingQueueRealnameKey(), constants.TaskTypePollingRealname, now)
s.processTimedQueue(ctx, constants.RedisPollingQueueCarddataKey(), constants.TaskTypePollingCarddata, now)
s.processTimedQueue(ctx, constants.RedisPollingQueuePackageKey(), constants.TaskTypePollingPackage, now)

// 修改后（新增 protect 的调度）
s.processManualQueue(ctx, constants.TaskTypePollingProtect)  // 新增
s.processTimedQueue(ctx, constants.RedisPollingQueueProtectKey(), constants.TaskTypePollingProtect, now)  // 新增
```

同时确认 `constants.RedisPollingQueueProtectKey()` 函数存在，以及 `initCardPolling()` 中是否已初始化 protect 队列（如没有则补充）。

---

### H-2 · gatewayClient=nil 停复机拒绝（P2-12）

**问题**：`internal/service/iot_card/stop_resume_service.go:178, 211`，`gatewayClient==nil` 时 `return nil`（成功），调用方继续更新本地 DB，导致 DB 状态与运营商不一致。

```go
// 修改前
if s.gatewayClient == nil {
    s.logger.Warn("Gateway 客户端未配置，跳过调用运营商接口", ...)
    return nil  // ← 假装成功

// 修改后
if s.gatewayClient == nil {
    return errors.New(errors.CodeInternalError, "Gateway 未配置，停复机操作不可用")
```

两处都改：`stopCardWithRetry` 和 `resumeCardWithRetry`。

---

### H-3 · packages 接口加分页（P2-10）

`internal/service/asset/service.go:GetPackages()`：新增 `page, pageSize` 参数，默认 page=1，pageSize=50，最大 100。对应 DTO 新增 `Page int`, `PageSize int`, `Total int64` 字段。

---

### H-4 · Series 删除前检查关联套餐（P2-23）

`internal/service/package_series/service.go:Delete()`：

```go
func (s *Service) Delete(ctx context.Context, id uint) error {
    // 新增：检查是否有关联套餐
    count, err := s.packageStore.CountBySeriesID(ctx, id)
    if err != nil {
        return errors.Wrap(errors.CodeDatabaseError, err, "查询关联套餐失败")
    }
    if count > 0 {
        return errors.New(errors.CodeInvalidParam, fmt.Sprintf("该系列下有 %d 个关联套餐，请先处理", count))
    }
    return s.packageSeriesStore.Delete(ctx, id)
}
```

`PackageStore` 需要新增 `CountBySeriesID(ctx, seriesID uint) (int64, error)` 方法。

### 方案 H · 人工验收清单

- **H-1 protect 调度接入**
  - 工具：`rg` + Worker 日志
  - 步骤：搜索 `scheduler.go` 是否已调度 `TaskTypePollingProtect`；观察调度日志是否出现 protect 队列处理
  - 预期结果：protect 任务不再只注册不调度

- **H-2 gatewayClient=nil 拒绝停复机**
  - 工具：`rg` + 日志
  - 步骤：搜索 `stop_resume_service.go`，确认 `gatewayClient==nil` 时返回业务错误而非 `nil`
  - 预期结果：未配置 Gateway 时操作失败并报错，本地 DB 不会误更新为成功

- **H-3 packages 接口分页**
  - 工具：`rg` + `go build ./...`
  - 步骤：搜索 `GetPackages()` 参数与 DTO，确认有 `page/pageSize/total`
  - 预期结果：套餐列表接口默认分页，单页最大 100

- **H-4 Series 删除前检查**
  - 工具：DBHub + 日志
  - 步骤：
    1. DBHub 查询某个有套餐关联的系列：`SELECT COUNT(*) FROM tb_package WHERE series_id = ?;`
    2. 人工触发删除
  - 预期结果：若 count > 0，则删除被拒绝并提示“请先处理关联套餐”

- **方案 H 收尾检查**
  - 工具：`go build ./...`
  - 预期结果：编译通过

---

## 方案 I：退款完整功能（P2-18 升级为完整功能）

**业务背景**

需要对资产套餐订单发起退款申请。退款不走自动化，由人工填写实收金额和申请退款金额，经审批后线下打款。审批通过后，系统自动从获得过佣金的代理佣金钱包中按比例扣减（允许为负数）。

---

### I-1 · 数据模型

新建 `tb_refund_request` 表（`migrations/` 新迁移文件）：

```sql
CREATE TABLE tb_refund_request (
    id BIGSERIAL PRIMARY KEY,
    refund_no VARCHAR(50) NOT NULL UNIQUE,
    order_id BIGINT NOT NULL,
    package_usage_id BIGINT,                   -- 关联的套餐使用记录
    applicant_id BIGINT NOT NULL,
    shop_id BIGINT,
    actual_received_amount BIGINT NOT NULL,    -- 实收金额（分，人工填写）
    requested_refund_amount BIGINT NOT NULL,   -- 申请退款金额（分）
    approved_refund_amount BIGINT,             -- 审批后实际退款金额（分）
    refund_reason TEXT NOT NULL,
    status INT NOT NULL DEFAULT 1,             -- 1-待审批 2-已通过 3-已拒绝 4-已退回（可重提）
    processor_id BIGINT,                       -- 审批人
    processed_at TIMESTAMP WITH TIME ZONE,
    commission_deducted BOOLEAN NOT NULL DEFAULT FALSE,
    remark TEXT,
    creator BIGINT NOT NULL,
    updater BIGINT NOT NULL,
    created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
    deleted_at TIMESTAMP WITH TIME ZONE
);
```

新建 `internal/model/refund.go` 定义 GORM Model（参考 `financial.go` 的风格）。

---

### I-2 · 接口设计

新建 Handler + Service + Store（参考 `commission_withdrawal` 的完整实现）：

| 接口 | 方法 | Handler 方法 |
|------|------|------------|
| `POST /api/admin/refunds` | Create | 发起退款申请 |
| `GET /api/admin/refunds` | List | 列表（分页+按状态筛选） |
| `GET /api/admin/refunds/:id` | Get | 详情 |
| `POST /api/admin/refunds/:id/approve` | Approve | 审批通过（可修改金额） |
| `POST /api/admin/refunds/:id/reject` | Reject | 审批拒绝（必填拒绝原因） |
| `POST /api/admin/refunds/:id/return` | Return | 退回申请（申请人可重提） |
| `POST /api/admin/refunds/:id/resubmit` | Resubmit | 重新提交（被退回后） |

状态流转：
```
1(待审批) → 2(已通过)  [Approve]  → 触发佣金回扣
1(待审批) → 3(已拒绝)  [Reject]
1(待审批) → 4(已退回)  [Return]   → 可重提
4(已退回) → 1(待审批)  [Resubmit]
```

---

### I-3 · 佣金回扣逻辑

`refund/service.go:deductCommissionAfterApprove(ctx, refundID uint)`：

```go
func (s *Service) deductCommissionAfterApprove(ctx context.Context, refundID uint) error {
    refund, _ := s.refundStore.GetByID(ctx, refundID)

    // 查找该订单的所有已结算佣金记录
    commissions, _ := s.commissionRecordStore.ListByOrderID(ctx, refund.OrderID)
    if len(commissions) == 0 {
        return nil // 没有佣金，直接标记
    }

    // 退款比例
    ratio := float64(refund.ApprovedRefundAmount) / float64(refund.ActualReceivedAmount)

    return s.db.Transaction(func(tx *gorm.DB) error {
        for _, commission := range commissions {
            deductAmount := int64(float64(commission.Amount) * ratio)
            if deductAmount == 0 {
                continue
            }

            // 从代理佣金钱包扣减（允许负数，直接 UPDATE，不检查余额）
            tx.Model(&model.AgentWallet{}).
                Where("shop_id = ? AND wallet_type = 'commission'", commission.ShopID).
                Updates(map[string]any{
                    "balance": gorm.Expr("balance - ?", deductAmount),
                })

            // 写交易流水
            refType := "refund"
            refID := refundID
            tx.Create(&model.AgentWalletTransaction{
                ShopID:          commission.ShopID,
                TransactionType: "commission_deduct",
                Amount:          -deductAmount,
                ReferenceType:   &refType,
                ReferenceID:     &refID,
                Remark:          "退款佣金回扣",
            })
        }

        // 标记退款佣金已回扣
        tx.Model(&model.RefundRequest{}).
            Where("id = ?", refundID).
            Update("commission_deducted", true)

        return nil
    })
}
```

`Approve()` 方法在更新 status=2 后，异步调用此函数（Goroutine，失败记日志但不影响审批结果）。

### 方案 I · 人工验收清单

- **I-1 数据模型**
  - 工具：DBHub
  - 步骤：搜索 `tb_refund_request` 表及其列
  - 预期结果：退款表存在，核心字段（`refund_no / order_id / actual_received_amount / requested_refund_amount / approved_refund_amount / commission_deducted`）齐全

- **I-2 接口设计**
  - 工具：`rg` + `go build ./...`
  - 步骤：搜索 refund 的 handler / service / store / route 注册，确认 7 个接口都已落地
  - 预期结果：退款功能具备完整 CRUD + 审批流转能力

- **I-3 佣金回扣逻辑**
  - 工具：DBHub + 日志
  - 步骤：审批通过一笔退款后，执行：
    - `SELECT id, status, approved_refund_amount, commission_deducted FROM tb_refund_request ORDER BY id DESC LIMIT 10;`
    - `SELECT shop_id, amount, transaction_type, reference_id FROM tb_agent_wallet_transaction WHERE transaction_type='commission_deduct' ORDER BY id DESC LIMIT 20;`
  - 预期结果：退款单变为已通过、`commission_deducted=true`，并产生负向佣金钱包流水

- **方案 I 收尾检查**
  - 工具：`go build ./...`
  - 预期结果：编译通过

---

## 方案 J：其他修复项

---

### J-1 · 富友支付实现（P1-4 + P1-5）

**背景**：`pkg/fuiou/` SDK 已完整实现，`tb_wechat_config` 模型已有富友字段，回调 `FuiouPayCallback` 已实现。唯一缺失：`FuiouPayJSAPI()` 和 `FuiouPayMiniApp()` 是留桩，返回"暂未实现"。

**凭证数据**（插入 `tb_wechat_config` 表，`provider_type='fuiou'`，`is_active=true`）：

| 字段 | 值 |
|------|----|
| `fy_ins_cd` | `08K0079562` |
| `fy_mchnt_cd` | `0005210F8785904` |
| `fy_term_id` | `88888888` |
| `fy_api_url` | `https://fundwx.fuiou.com` |
| `fy_notify_url` | `{你们服务器地址}/api/callback/fuiou-pay` |
| `fy_private_key` | ` ` |
| `fy_public_key` | `MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuQjOWl28A0JD/Erb6vOZSHjsv5BKII5U9SvJ0vsrUOYqWk9rZ4+OawsP/tZgr4fhJp3yzTyHFu5ScUGotCD7Sny0de5AXdNZ5g2npS2QBR0gI6kHOJLMgxxo/APBXvepGj3CtI/oXunoto+ixqwzZBKgE4GSkqIXYFeZ7RuexepThI2Pt4PKqS5gkl9Xb+454C5N6k+uc3+UVo6m0PylO1V6PxmqVbxIoNnc4CPUZiEPMjsxAgSvZCQt3uYZJcffIM+bEZMQEmvSB+PY6LcAl6Sl+Q4iyqIZ5T+cRZmCSLeIbbIkY0bzFT2SUZ5ZgGFu27+DQJ+hRTR/C/pcO0huZQIDAQAB` |

**修改清单**

**① `internal/service/order/service.go:FuiouPayJSAPI`（第 2355 行，替换留桩）**

```go
func (s *Service) FuiouPayJSAPI(ctx context.Context, orderID uint, openID string, buyerType string, buyerID uint) (*dto.FuiouPayJSAPIResponse, error) {
    // 1. 查询并校验订单
    order, err := s.orderStore.GetByID(ctx, orderID)
    if err != nil {
        return nil, errors.New(errors.CodeNotFound, "订单不存在")
    }
    if order.BuyerType != buyerType || order.BuyerID != buyerID {
        return nil, errors.New(errors.CodeForbidden, "无权操作此订单")
    }
    if order.PaymentStatus != model.PaymentStatusPending {
        return nil, errors.New(errors.CodeInvalidStatus, "订单状态不允许支付")
    }

    // 2. 加载激活的富友配置
    config, err := s.wechatConfigStore.GetActive(ctx)  // GetActive 已存在，只取 is_active=true 的那条
    if err != nil || config.ProviderType != model.ProviderTypeFuiou {
        return nil, errors.New(errors.CodeFuiouPayFailed, "富友支付未配置或未激活")
    }

    // 3. 构造 fuiou.Client
    client, err := fuiou.NewClient(
        config.FyInsCd, config.FyMchntCd, config.FyTermID,
        config.FyAPIURL, config.FyNotifyURL,
        config.FyPrivateKey, config.FyPublicKey,
        s.logger,
    )
    if err != nil {
        return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "初始化富友客户端失败")
    }

    // 4. 获取商品描述
    items, _ := s.orderItemStore.ListByOrderIDs(ctx, []uint{orderID})
    goodsDesc := "套餐购买"
    if len(items) > 0 && items[0] != nil {
        goodsDesc = items[0].PackageName
    }

    // 5. 调用富友预下单
    // subAppid = 公众号 AppID（config.OaAppID）
    // subOpenid = 用户在公众号下的 openID（入参 openID）
    termIP := middleware.GetIPFromContext(ctx)
    if termIP == "" {
        termIP = "127.0.0.1"
    }

    resp, err := client.WxPreCreate(
        order.OrderNo,
        strconv.FormatInt(order.TotalAmount, 10), // 单位：分
        goodsDesc,
        termIP,
        "JSAPI",         // 公众号支付
        config.OaAppID,  // 公众号 AppID
        openID,
    )
    if err != nil {
        s.logger.Error("富友 JSAPI 预下单失败",
            zap.Uint("order_id", orderID),
            zap.String("order_no", order.OrderNo),
            zap.Error(err))
        return nil, errors.Wrap(errors.CodeFuiouPayFailed, err, "富友预下单失败")
    }

    // 6. 返回前端调起支付所需参数
    return &dto.FuiouPayJSAPIResponse{
        AppID:     resp.SdkAppid,
        TimeStamp: resp.SdkTimestamp,
        NonceStr:  resp.SdkNoncestr,
        Package:   "prepay_id=" + resp.SdkPrepayid,
        SignType:  resp.SdkSigntype,
        PaySign:   resp.SdkPaysign,
    }, nil
}
```

**② `internal/service/order/service.go:FuiouPayMiniApp`（第 2361 行，替换留桩）**

与 FuiouPayJSAPI 完全相同，两处不同：
- `tradeType` 传 `"LETPAY"`（小程序支付）
- `subAppid` 传 `config.MiniappAppID`（小程序 AppID）

**③ 新增 DTO `FuiouPayJSAPIResponse`**（`internal/model/dto/`）：

```go
type FuiouPayJSAPIResponse struct {
    AppID     string `json:"appId"     description:"应用ID"`
    TimeStamp string `json:"timeStamp" description:"时间戳"`
    NonceStr  string `json:"nonceStr"  description:"随机字符串"`
    Package   string `json:"package"   description:"prepay_id=xxx"`
    SignType  string `json:"signType"  description:"签名类型"`
    PaySign   string `json:"paySign"   description:"支付签名"`
}
// 注：字段名与微信官方 wx.requestPayment() 参数一致，前端无需区分富友/微信
```

**④ 回调链路（已实现，无需修改）**

`POST /api/callback/fuiou-pay` → `FuiouPayCallback()` → 验签 → 解析订单号前缀 → 路由到对应 `HandlePaymentCallback()` → 更新支付状态 → 触发套餐激活/佣金

---

### J-2 · 平台钱包代购（P1-7）

**业务背景**：平台操作人员可以用某代理的钱包，以该代理的成本价，给该代理名下的资产购买套餐。当前 Service 层拒绝了平台使用钱包支付（要求 `buyerType == agent`）。

**修改位置**：`internal/service/order/service.go:CreateLegacy`（约 195 行）

```go
// 修改前（直接拒绝非代理的钱包支付）
} else if req.PaymentMethod == model.PaymentMethodWallet {
    if buyerType != model.BuyerTypeAgent {
        return nil, errors.New(errors.CodeInvalidParam, "只有代理账号可以使用钱包支付")
    }
    ...

// 修改后（平台用代理钱包代购：buyerType="" 但指定了 resourceShopID）
} else if req.PaymentMethod == model.PaymentMethodWallet {
    if buyerType == model.BuyerTypeAgent {
        // 代理用自己钱包，正常逻辑
        ...
    } else if buyerType == "" && resourceShopID != nil {
        // 平台用代理钱包代购：钱包来自资产所属代理
        orderBuyerType = model.BuyerTypeAgent
        // 成本价、钱包都使用 *resourceShopID 对应的代理
        costPrice, _ := s.getCostPrice(ctx, *resourceShopID, firstPackageID)
        totalAmount = costPrice
        actualPaidAmount = &costPrice
        // 钱包扣款指向 *resourceShopID 的代理钱包
    } else {
        return nil, errors.New(errors.CodeInvalidParam, "不支持的钱包支付场景")
    }
```

---

### J-3 · status 字段 3/4 业务触发（P2-3）

**业务含义澄清**：`status` 字段（在 `iot_card` 和 `device` 表上）跟踪客户使用状态：
- 1 = 在库（未分配）
- 2 = 已分销（分配给代理，等待客户）
- 3 = 已激活（有客户在使用，有 active 套餐）
- 4 = 已停用（套餐全部到期，无续套餐）

`asset_status` 是另一个维度（业务生命周期：在库/已销售/已换货/已停用），两者不合并。

**需要补充的触发逻辑**：

- **→ 3（已激活）**：在 `PackageActivationHandler` 激活套餐成功后，如果 `card.status < 3`，更新为 3
- **→ 4（已停用）**：在套餐到期处理逻辑中（`OrderExpireHandler` 或类似），当某资产最后一个 active 套餐过期时，更新为 4

**换货流程不影响**：换货代码 `exchange/service.go` 只操作 `asset_status`，不碰 `status` 字段。

---

### J-4 · payment_status 枚举统一（P2-17）

**现状**：管理端 `payment_status` 是 1/2/3/4（pending/paid/cancelled/refunded），C 端通过 `orderStatusToClientStatus()` 映射成 0/1/2。

**修改清单**：
1. `internal/service/client_order/service.go:652-659`：删除 `orderStatusToClientStatus()` 函数
2. C 端 DTO `ClientOrderResponse.PaymentStatus`：直接用 `int`，值与管理端一致（1/2/3/4）
3. **通知前端**：C 端解析状态枚举从 `0/1/2` 改为 `1/2/3`（4=退款，后续退款功能实现后前端处理）

### 方案 J · 人工验收清单

- **J-1 富友支付实现**
  - 工具：DBHub + 日志 + `rg` + `go build ./...`
  - 步骤：
    1. DBHub 查询 `tb_wechat_config`，确认存在 `provider_type='fuiou' AND is_active=true` 配置
    2. 搜索 `FuiouPayJSAPI` / `FuiouPayMiniApp`，确认不再返回“暂未实现”
    3. 联调一次预下单 / 回调，观察日志关键字“富友 JSAPI 预下单失败 / 成功”“FuiouPayCallback”
  - 预期结果：富友支付可正常走预下单 + 回调链路

- **J-2 平台钱包代购**
  - 工具：`rg` + DBHub
  - 步骤：
    1. 搜索 `CreateLegacy` 钱包支付分支，确认支持 `buyerType=="" && resourceShopID != nil`
    2. DBHub 抽查一笔平台代代理钱包订单，核对 `buyer_type / total_amount / actual_paid_amount / seller_shop_id`
  - 预期结果：平台可用代理钱包代购，金额口径与代理成本价体系一致

- **J-3 status 3/4 业务触发**
  - 工具：DBHub + 日志
  - 步骤：
    1. 激活一笔套餐后，查询 `tb_iot_card.status` / `tb_device.status`
    2. 最后一个 active 套餐到期后，再次查询状态
  - 预期结果：激活后状态进入 3；最后套餐过期后进入 4；换货流程不误改该字段

- **J-4 payment_status 枚举统一**
  - 工具：`rg` + `go build ./...`
  - 步骤：
    1. 搜索 `orderStatusToClientStatus()`，确认已删除
    2. 搜索 `ClientOrderResponse.PaymentStatus`，确认直接输出管理端同款整型枚举
  - 预期结果：C 端 / 管理端支付状态枚举语义统一

- **方案 J 收尾检查**
  - 工具：`go build ./...`
  - 预期结果：编译通过

---

## 附：取消 / 不做的项目

| 条目 | 原因 |
|------|------|
| P1-2 企业认证中间件 | 复用 AdminAuth，不需要新中间件 |
| P1-3 提现"已到账"接口 | 无法感知，不需要实现 |
| P1-9 设备定期轮询 | 伪需求，改为按需查询（D-3） |
| P2-6 告警通知渠道 | 无关紧要，暂不做 |
| P2-9 asset_type 校验 | 误判，Service 层已有校验兜底 |
| P2-24 Carrier→Series 层级 | 系列可用于任意运营商的卡，不需要强绑定 |
| P2-30 轮询配置通用化 | 设备轮询取消后，此问题不复存在 |
| P2-5 废弃字段写入 | 有意的向后兼容，不清理 |

---

## 附：优先级排序（最终）

```
立即（1-2天）
  A-1 常量统一      ← 所有 A 系修复的前置
  A-2 实名校验      ← 依赖 A-1
  A-3 实名激活断链
  A-4 自动购包触发+佣金
  A-5 代购金额
  A-6 设备导入IMEI
  A-7 提现并发校验

近期（3-5天）
  B   企业端权限
  C   提现/佣金流程（P2-19/20/21/22）
  G   实名激活重构（破坏性变更，单独提案）
  H   轮询小修（P1-6 / P2-10 / P2-12 / P2-23）
  J-4 payment_status 枚举统一

中期（1-2周）
  D   设备体系（D0→D1→D2→D3）
  I   退款完整功能
  J-1 富友支付（凭证已有，SDK 已有，写两个方法即可）
  J-2 平台钱包代购
  J-3 status 3/4 触发逻辑
  F   代码清理（废弃代码删除、错误处理补全等）

长期/谨慎（需低峰期发布）
  E   流量体系改革（DB 迁移 + Redis 架构）
```
