---
phase: 01-p0
plan: 05
subsystem: device-import, my-commission
tags: [bug-fix, critical, imei, concurrent-safety]
dependency_graph:
  requires: []
  provides: [CRITICAL-07-fixed, CRITICAL-08-fixed]
  affects: [pkg/utils/excel.go, internal/task/device_import.go, internal/service/my_commission/service.go]
tech_stack:
  added: []
  patterns: [conditional-update, rows-affected-check]
key_files:
  created: []
  modified:
    - pkg/utils/excel.go
    - internal/task/device_import.go
    - internal/service/my_commission/service.go
decisions:
  - "CRITICAL-07: DeviceRow 新增 IMEI 字段，buildDeviceColumnIndex 以 switch-case 支持多列名别名（imei/设备imei/imei号），ParseDeviceExcel 数据行提取 row.IMEI，device_import.go 创建 Device 时填充 IMEI: row.IMEI"
  - "CRITICAL-08: 冻结余额改为 result := tx...Updates() 分离结果对象，先检 result.Error，再检 result.RowsAffected == 0 返回 CodeInsufficientBalance，防止并发时 WHERE balance >= ? 条件不满足仍创建提现单"
metrics:
  duration: "~8min"
  completed_date: "2026-03-27"
  tasks_completed: 2
  files_modified: 3
requirements:
  - CRITICAL-07
  - CRITICAL-08
---

# Phase 01 Plan 05: 设备导入 IMEI 字段 + 提现冻结并发校验 Summary

**一句话总结：** 修复设备导入时 IMEI 字段始终为空（CRITICAL-07）及并发提现场景下余额冻结缺少 RowsAffected 校验（CRITICAL-08）两个独立 Bug。

---

## 完成的任务

### Task 1: 设备导入补充 IMEI 字段（CRITICAL-07）

**修改文件：** `pkg/utils/excel.go`、`internal/task/device_import.go`  
**提交：** `809cb2b`

#### pkg/utils/excel.go 改动

**DeviceRow struct 新增 IMEI 字段：**

```go
// 修改前
type DeviceRow struct {
    Line         int
    VirtualNo    string
    // IMEI 字段缺失
    DeviceName   string
    ...
}

// 修改后
type DeviceRow struct {
    Line         int
    VirtualNo    string
    IMEI         string   // 设备IMEI，对应 Excel IMEI 列，用于 Gateway API 调用
    DeviceName   string
    ...
}
```

**buildDeviceColumnIndex 新增 IMEI 列名支持：**

- 在 `index` map 中初始化 `"imei": -1`
- 表头遍历时用 switch-case 特殊处理 IMEI 的多种列名别名：`imei`、`设备imei`、`imei号`（不区分大小写，因为 `strings.ToLower` 已预处理）

**ParseDeviceExcel 数据行提取 row.IMEI：**

```go
if idx := colIndex["imei"]; idx >= 0 && idx < len(record) {
    row.IMEI = strings.TrimSpace(record[idx])
}
```

#### internal/task/device_import.go 改动

在 `processBatch` 函数创建 `model.Device` 时新增 `IMEI: row.IMEI`：

```go
device := &model.Device{
    VirtualNo:    row.VirtualNo,
    IMEI:         row.IMEI,   // 新增
    DeviceName:   row.DeviceName,
    ...
}
```

---

### Task 2: 提现冻结并发校验（CRITICAL-08）

**修改文件：** `internal/service/my_commission/service.go`  
**提交：** `db16680`

**改动前后对比（CreateWithdrawalRequest 第 162-170 行）：**

```go
// 修改前：只检查 Error，RowsAffected == 0 时无感知
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 == 0 的检查，并发场景下仍会继续创建提现单

// 修改后：分离 result 对象，同时检查 Error 和 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, "冻结余额失败")
}
// RowsAffected == 0 说明余额已不足（并发场景下被其他请求先行扣减）
if result.RowsAffected == 0 {
    return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突，请稍后重试")
}
```

**修复原理：** GORM 条件更新 `WHERE balance >= ?` 在余额不足时不更新任何行（返回 0 行），但不会返回 Error。原代码只检查 Error 故无法感知。修复后检查 RowsAffected == 0，正确返回 `CodeInsufficientBalance` 错误，阻断后续提现单创建。

---

## 编译验证

```
go build ./...  → 通过（无错误）
```

---

## CRITICAL-07 完成状态

- [x] DeviceRow struct 含 IMEI string 字段
- [x] ParseDeviceExcel 支持 imei/IMEI/设备IMEI/IMEI号 列名（不区分大小写）
- [x] buildDeviceColumnIndex 已初始化 imei 键并处理多别名
- [x] device_import.go processBatch 填充 IMEI: row.IMEI
- [x] go build ./... 编译通过

## CRITICAL-08 完成状态

- [x] my_commission/service.go 冻结余额使用 result := tx...Updates()
- [x] 检查 result.Error，处理数据库错误
- [x] 检查 result.RowsAffected == 0，返回 errors.New(CodeInsufficientBalance, ...)
- [x] go build ./... 编译通过

---

## 人工验收要求（生产环境）

- **CRITICAL-07：** 上传含 IMEI 列的设备导入 Excel 后，DBHub 查询 `SELECT id, virtual_no, imei FROM tb_device ORDER BY id DESC LIMIT 20;` 确认 IMEI 字段已填充
- **CRITICAL-08：** 并发提现场景下，DBHub 确认提现单数量与余额冻结成功次数一致（不存在重复提现单）

---

## Deviations from Plan

None - 计划按原样执行。两个修复完全独立，互无依赖。

---

## Self-Check: PASSED

- [x] `pkg/utils/excel.go` 存在且包含 IMEI 字段
- [x] `internal/task/device_import.go` 存在且包含 `IMEI: row.IMEI`
- [x] `internal/service/my_commission/service.go` 存在且包含 `result.RowsAffected == 0`
- [x] commit `809cb2b` 存在（CRITICAL-07）
- [x] commit `db16680` 存在（CRITICAL-08）
- [x] `go build ./...` 编译通过
