# Pitfalls Research

**Domain:** IoT 卡管理平台 — SIM 卡分销 + 多级代理佣金 + 异步套餐激活
**Researched:** 2026-03-27
**Confidence:** HIGH（基于真实代码库分析 + 完整规格书审查）

> **已知问题不重复**：实名状态常量不一致、Asynq 任务依赖注入不完整、并发乐观锁/悲观锁漏用、流量数据被上游重置覆盖——这四类已在修正方案中覆盖，不再列入本文档。

---

## Critical Pitfalls

### Pitfall 1: 佣金差额基准字段（sellerCostPrice）多处同类 Bug

**What goes wrong:**
`sellerCostPrice = buyerCostPrice` 在 `internal/service/order/service.go` 中出现 **6 次**（第 187、262、472、548、734、809 行），只有场景 5（代理代购下级）被规格书点名，其余 5 处是否也存在同样语义错误尚未被审查。每一处错误都会导致整个上级佣金链计算为 0。

**Why it happens:**
场景 5 是"代理帮下级代理购包"，此时 `sellerCostPrice` 表示"卖出方自己的成本价"（用于和再上级计算差额），但开发者误用了"买方（下级）的成本价"。五种购买场景共用的模板代码一起复制了这个错误。

**How to avoid:**
1. 修复 A-5 时，用 `grep` 全量检索 `sellerCostPrice = buyerCostPrice`，对每一行逐场景验证语义
2. 添加注释：`sellerCostPrice 含义 = 本订单"卖家"向其上级结算时使用的成本基准（不是买家看到的价格）`
3. 为每种购买场景（5 种）单独写一个私有方法，避免在同一个大函数里 if-else 六分支

**Warning signs:**
- 某代理参与了套餐分配（有授权），但佣金记录里上级代理的 `amount` 持续为 0
- DBHub 查询 `tb_commission_record WHERE amount = 0 AND commission_source = 'cost_diff'` 有大量记录

**Phase to address:** 方案 A（A-5）实施时，同步修复全部 6 处，不只修 1 处

---

### Pitfall 2: 退款佣金回扣的浮点精度丢失

**What goes wrong:**
方案 I-3 的退款比例计算：
```go
ratio := float64(refund.ApprovedRefundAmount) / float64(refund.ActualReceivedAmount)
deductAmount := int64(float64(commission.Amount) * ratio)
```
`float64` 乘除后截断到 `int64` 会丢失 1 分精度。多级佣金链叠加后，累计误差可能造成"批量退款后所有上级代理多扣或少扣总计数十元"。

**Why it happens:**
Go 没有内置 Decimal 类型，金融计算的精度问题容易被忽视。项目其他地方（如提现手续费）用的是整数基点计算（`int64`），但退款比例必须用小数，开发者自然选择了 `float64`。

**How to avoid:**
- 退款比例乘法使用整数算术代替：`deductAmount = commission.Amount * refund.ApprovedRefundAmount / refund.ActualReceivedAmount`（全用 `int64`，先乘后除，结果向下取整）
- 或引入 `math/big.Rat` 进行精确比例计算
- 绝对不要在金额计算链路中出现 `float64`（余额、成本价、佣金均以分为单位存储，应坚持整数运算）

**Warning signs:**
- 退款后批量查各级代理钱包，总扣减金额与退款金额比例不相符
- 极端案例：`ApprovedRefundAmount=1, ActualReceivedAmount=3` → `ratio=0.3333...` → `commission*0.3333` 截断

**Phase to address:** 方案 I（退款功能）实现时在 I-3 处预防

---

### Pitfall 3: 多级分佣树形遍历中的循环引用与孤岛

**What goes wrong:**
`CalculateCostDiffCommission` 通过 `shop.ParentID` 向上遍历代理链，直到 `ParentID == nil`。如果数据库中代理层级数据被人工修改，出现：
- **循环引用**：A 的父是 B，B 的父是 A → 无限循环直到超时
- **孤岛**：某代理的 `parent_id` 指向一个已删除的店铺（软删除残留）→ `GetByID` 返回 Not Found，`break` 掉链路，上游代理丢失佣金

**Why it happens:**
项目禁止外键约束（见 AGENTS.md），代理层级完全依赖代码维护。店铺软删除时没有检查是否有子代理仍在引用它。递归遍历没有深度上限（最多 7 级，但没有强制）。

**How to avoid:**
1. 在遍历循环加深度计数器：`for depth := 0; depth < 8 && currentShopID != nil; depth++`
2. 用 `visited map[uint]bool` 检测循环引用，发现后立即 `break` 并打告警日志
3. 店铺删除接口增加"有子代理时拒绝删除"的前置检查（参考 H-4 Series 的做法）
4. 每日定时任务扫描层级数据一致性（检查 `parent_id` 指向已删除记录）

**Warning signs:**
- 某个订单的佣金任务卡在 Worker 里超时重试
- `tb_shop` 中存在 `parent_id` 指向 `deleted_at IS NOT NULL` 的记录

**Phase to address:** 方案 A（A-5 佣金修复）+ 方案 H 小修阶段

---

### Pitfall 4: 异步任务幂等键缺失导致佣金重复计算

**What goes wrong:**
`enqueueCommissionCalculation` 在 service 层多处调用（第 303、618、864、1121、1565、1617 行），但没有业务层面的幂等防护。Asynq 默认重试策略 + 多个触发点并发时，同一个 `order_id` 的佣金计算任务会被入队多次。`CalculateCommission` 开头虽然检查了 `CommissionStatusCalculated`，但在高并发下两个 Worker 可能同时读到 `status != calculated`，然后同时写入两份佣金记录。

**Why it happens:**
Order Service 的支付回调（微信/富友）与自动购包任务（A-4 新增）在成功时都会调用 `enqueueCommissionCalculation`，单个订单存在多条触发路径。`CalculateCommission` 的幂等检查不是原子操作（读 status + 写 records 之间有窗口）。

**How to avoid:**
1. 给 `TaskTypeCommission` 加 Asynq 唯一任务选项：`asynq.TaskID(fmt.Sprintf("commission:%d", orderID))` → Asynq 保证相同 TaskID 只入队一次
2. 在 `CalculateCommission` 内部，将"读取 status + 写入 records + 更新 status"放在同一个事务内，利用 `UPDATE tb_order SET commission_status=? WHERE id=? AND commission_status != 'calculated'` 的 RowsAffected 做原子幂等

**Warning signs:**
- `tb_commission_record` 中同一个 `order_id` 出现两条或多条记录
- 代理钱包余额远高于预期

**Phase to address:** 方案 A（A-4 自动购包）实施时同步处理

---

### Pitfall 5: 运营商 API 回调重放与支付状态对齐

**What goes wrong:**
微信支付回调（`HandlePaymentCallback`）使用条件更新做幂等：`WHERE payment_status = 'pending'`，这是正确的。但富友支付回调（方案 J-1，当前是留桩）一旦实现，如果直接复制微信回调逻辑而忘记验签，攻击者可以伪造回调通知触发充值到账。另一个场景：运营商 Gateway 的停复机回调（`StopResumeCallback / ResumeCallback`，方案 F-6）目前 bootstrap 中未注入，一旦注入后，回调的幂等保护是否健全尚未验证。

**Why it happens:**
第三方 API 回调通常有以下特点：(1) 会重复发送直到收到成功响应，(2) 需要验证签名，(3) 业务状态变更必须是幂等的。开发者经常在实现新回调时遗漏验签步骤，尤其是"复制微信回调改改"时。

**How to avoid:**
1. 富友支付回调（J-1）实现时：先验签（`pkg/fuiou/` 已有签名工具），再做业务处理
2. 所有回调 Handler 必须在最开头验签，验签失败直接返回 `400`，不进入业务逻辑
3. 停复机回调（F-6 注入后）：检查 `WHERE status = 'stopping'` 条件更新，RowsAffected 为 0 则幂等跳过

**Warning signs:**
- 回调接口没有签名验证中间件或函数调用
- 日志中同一笔充值/停复机出现两次"处理成功"

**Phase to address:** 方案 F（F-6 注入）、方案 J（J-1 富友支付实现）

---

## Technical Debt Patterns

| Shortcut | Immediate Benefit | Long-term Cost | When Acceptable |
|----------|-------------------|----------------|-----------------|
| Handler 层直接访问 DB（见 F-4） | 少写 Service 方法，快速出货 | 绕过数据权限过滤层，越权漏洞温床；Service 层单测无法覆盖 | Never（项目明确禁止） |
| `cards, _ :=` 忽略 error（见 F-3） | 代码简洁 | 上游查询失败时静默返回空数据，下游表现为"数据丢失"而非报错，极难定位 | Never |
| 任务断链用 `RPush` 降级（见 A-3 临时方案） | 临时绕过依赖注入问题 | 形成两套并行队列机制，消费者只消费 Asynq，Redis List 堆积无人处理 | Never（应立即修复） |
| `sellerCostPrice = buyerCostPrice` 5 处场景（见 Pitfall 1） | 逻辑统一，代码复制快 | 多个购买场景佣金链全部归零，代理分佣核算错误 | Never |
| 留桩函数（FuiouPayJSAPI 等）返回业务错误 | 功能模块先占位 | 在线上环境直接暴露"功能未实现"给用户，影响信任度 | MVP 阶段可以，但必须有 TODO 标记和发版前检查清单 |
| 激活配置两步 UPDATE 无锁（见 C-3） | 代码简单 | 并发切换配置时可出现两条 `active=true`，支付配置/提现配置混乱 | Never |
| `gatewayClient=nil` 时返回成功（见 H-2） | 开发环境无 Gateway 时流程走通 | 本地 DB 标记为停机成功，但运营商侧未操作；数据一致性破坏 | Never（测试环境应 mock，不应用 nil 返回成功） |

---

## Integration Gotchas

| Integration | Common Mistake | Correct Approach |
|-------------|----------------|------------------|
| 运营商 Gateway（sync-info） | 按 IMEI 调用时长度判断逻辑（>15 位用 ICCID，=15 位用 IMEI，=11 位用 SN）写反或遗漏，导致用错标识调用 API | 严格按文档：`len > 15 → ICCID`，`len == 15 → IMEI`，`len == 11 → SN`；写单独函数 `resolveCardNo(device)` 封装判断，避免散落多处 |
| 运营商 Gateway（停复机） | `gatewayClient == nil` 时假装成功（见 H-2），或 Gateway 超时时直接 `return err` 而不记录"本地已标记停机但运营商未执行"的中间态 | 停复机必须是原子的：要么 Gateway + DB 一起成功，要么一起失败；Gateway 失败时 DB 不更新，并告警 |
| 微信支付回调 | 回调成功响应后再做业务处理 → 微信认为"回调已处理"，但业务处理失败时没有重试机会 | 先做幂等业务处理，成功后再返回 `{"code": "SUCCESS"}`；失败时返回非 SUCCESS，触发微信重发 |
| 富友支付（J-1 待实现） | 直接复制微信支付回调，遗漏富友私钥验签步骤 | 富友使用 RSA 私钥签名，`pkg/fuiou/` 已有 `VerifySign()` 方法，回调首行必须调用 |
| 运营商流量数据（联通 27 号重置） | 用当前值直接覆盖 `current_month_usage_mb`（E-2 问题），重置日后数据归零 | 改为增量累加；重置检测窗口：`当天 ∈ [reset_day-1, reset_day+1]` 才认为是正常重置，否则告警 |
| Asynq 任务注册 | 任务处理器注册（`mux.HandleFunc`）和 Scheduler 调度是两回事，注册后不一定会被调度（见 H-1 polling:protect） | 新增任务类型时检查清单：① 定义常量 → ② 实现 Handler → ③ 在 `RegisterHandlers()` 里注册 → ④ 在 Scheduler 里调度 |

---

## Performance Traps

| Trap | Symptoms | Prevention | When It Breaks |
|------|----------|------------|----------------|
| 递归查询下级店铺（`GetSubordinateShopIDs`）没有缓存 | 每次 HTTP 请求触发 PostgreSQL WITH RECURSIVE 查询，7 层 × N 个代理时极慢 | 已有 Redis 缓存 30 分钟机制，确保所有数据权限过滤路径都走缓存 | 代理商数量超过 100 时 P99 开始劣化 |
| 轮询任务批量处理时 N+1 查询 | Worker CPU 高但 DB 慢，`polling_handler.go` 里对每张卡单独查套餐 | 轮询任务改为批量拉取卡的套餐信息，一次 `WHERE iot_card_id IN (?)` | 轮询卡数量超过 1000 时 |
| `tb_card_daily_usage` 缺少分区 | 流量历史查询（跨月范围）全表扫描 | 建立 `idx_card_daily_date` 索引（E-1 已规划）；如果日后数据量大，按月分区 | 单卡每日一条，100 万卡 × 365 天 = 3.65 亿行 |
| 流量 Redis key 不设 TTL | `traffic:daily:{card_id}:{date}` 无 TTL 永久占用内存 | 设置 48h TTL（E-1 已规划 `Expire(ctx, key, 48*time.Hour)`）；落盘任务消费后 `DEL` key | Redis 内存超限时所有业务 key 被 evict |
| 套餐列表接口无分页（见 H-3） | C 端用户请求套餐列表时一次返回全量，单卡绑 100+ 套餐时响应体超大 | 强制分页，`pageSize` 默认 50 最大 100 | 套餐历史积累超过 100 条时 |
| 1 个设备绑 4 张卡，佣金计算 4 次 | 设备订单的一次性佣金被触发 4 次（每张绑定卡各触发一次） | 设备级一次性佣金必须以设备为单位去重（README 已说明"代理分佣只计算一次"）；检查 `triggerOneTimeCommissionForDeviceInTx` 是否有设备级幂等 | 销售设备套餐时 |

---

## Security Mistakes

| Mistake | Risk | Prevention |
|---------|------|------------|
| 企业账号 `Resolve` 接口被错误拦截（见 B-1）→ 修复时移除拦截，但未验证越权范围 | 企业账号能查询到不属于自己的卡（只要知道 ICCID） | `Resolve` 恢复后，必须在 Store 层加企业数据范围过滤：`WHERE enterprise_id = ? OR shop_id IN (?)` |
| C 端 Handler 直接 `h.db.SkipPermissionCtx` 访问 DB（见 F-4） | 个人客户能构造请求查看他人订单 | Handler 绑上游数据权限强制走 Service 层；Service 层验证 `buyer_id = 当前用户绑定资产 ID` |
| 金额字段以"分"为单位存储，但前端传来 float（如 "0.01" 元）未乘 100 | 金额精度丢失，1 分变 0 分 | 所有金额 API 明确文档"单位：分，整数"；DTO 验证标签加 `min=1`；前端统一在传参前乘 100 |
| 停复机操作无 RBAC 权限检查，任何登录的代理账号都可操作 | 代理 A 可以通过 ICCID 停掉代理 B 的卡 | 停复机前检查：`卡的 shop_id ∈ 当前代理的下级店铺范围`；企业账号只能操作被分配给自己企业的卡 |
| 提现申请中拒绝 remark 前端可传空（见 C-2） | 审批人拒绝时没有填写拒绝原因，代理投诉无凭据 | `Reject` Handler 必须调用 `validator.Validate(&req)` 且 DTO 字段 `validate:"required"` |

---

## UX Pitfalls

| Pitfall | User Impact | Better Approach |
|---------|-------------|-----------------|
| 套餐激活失败静默（`break` 不创建记录，见 F-1）| 代理看到"下单成功"但实际套餐没有激活，多次联系客服才发现 | 改为创建 `status=99` 待审记录并通知平台，代理端展示"套餐待激活，平台处理中" |
| 自动购包任务失败后 `auto_purchase_status` 停留在 `pending` | 客户充值后一直看到"余额已到账，套餐未开通"，不知是否需要手动购买 | Worker 失败超出最大重试次数后，将 `auto_purchase_status` 更新为 `failed`，前端展示"自动购包失败，请手动购买"并推送通知 |
| 退款状态"已退回（可重提）"语义不清 | 用户不知道"退回"意味着"申请被退回请你修改重提"还是"钱退回给你了" | 状态名改为"已驳回（可重提）"或"需修改重提"，避免与"金钱退回"混淆 |
| 停复机 Gateway 未配置时返回错误（H-2 修复后）| 已有卡的用户突然发现"停机功能不可用"且错误信息是技术语言 | 错误消息对外返回"停机功能暂时不可用，请联系客服"，内部日志详细记录"Gateway 未配置" |
| 提现手续费在 `FeeRate` 字段注释中用"万分比"但代码里有地方用"基点（100=1%）"（两种不同描述）| 代理计算实际到账金额与系统计算不符，投诉频发 | 统一文档：全部使用"基点（100=1%）"；前端展示时换算为百分比："手续费率：1%（100基点）" |

---

## "Looks Done But Isn't" Checklist

- [ ] **佣金计算完整性**：任务注册（`mux.HandleFunc`）+ Scheduler 调度 + 入队调用 + 幂等检查——四项缺一不可，逐项确认
- [ ] **Asynq 任务处理器注入**：每个新增的 Task Handler struct，其构造函数的所有参数是否都在 `pkg/queue/handler.go` 的对应 `registerXxx()` 里传入（A-3、A-4 教训）
- [ ] **富友支付回调**：实现后验签是否在第一行，是否有幂等检查，是否有超时重试机制
- [ ] **设备批量导入**：Excel 解析 → DeviceRow → model.Device 的每个字段贯通（IMEI 教训，见 A-6）；未来新增 Device 字段时同步更新 Excel 解析规则
- [ ] **回调注入到 bootstrap**：新增的回调方法（StopResumeCallback、ResumeCallback）是否在 `internal/bootstrap/services.go` 中真正调用了（F-6 教训）
- [ ] **停复机 Gateway nil 检查**：所有调用 `gatewayClient` 方法的地方，`nil` 情况是否明确拒绝而非静默成功（H-2 教训）
- [ ] **金额精度**：所有含 `float64` 转 `int64` 的金额运算，是否改为纯整数运算（退款比例 Pitfall 2 教训）
- [ ] **佣金链遍历深度上限**：`CalculateCostDiffCommission` 遍历循环是否有深度检测和循环引用检测（Pitfall 3 教训）

---

## Recovery Strategies

| Pitfall | Recovery Cost | Recovery Steps |
|---------|---------------|----------------|
| 佣金差额多处 `sellerCostPrice` 错误 | HIGH | 逐笔订单重新计算差价佣金；DBHub 查出所有 `amount=0` 的 cost_diff 记录；人工复算并补入差额；通知代理补发佣金 |
| 浮点金额精度错误（退款回扣）| MEDIUM | 查出所有已执行的退款佣金扣减记录；计算正确值与实际扣减值之差；通过管理端手动调整代理佣金钱包余额 |
| 佣金任务重复入队导致重复发放 | HIGH | 按 `order_id` 分组查 `tb_commission_record`，找出 count > 正常值的订单；手动删除多余记录并扣减对应代理钱包；加 `asynq.TaskID` 幂等后部署 |
| 流量数据被运营商重置归零（已发生） | MEDIUM | 从 Gateway 历史接口补拉历史流量数据（如有）；未来改为增量累加后运营商重置不再影响（方案 E） |
| 激活配置出现两条 `is_active=true`（并发问题） | LOW | DBHub 找出多余记录，手动 UPDATE 置 `is_active=false`；加锁后重部署 |
| 停复机 Gateway nil 假装成功（DB 与运营商不一致）| HIGH | DBHub 查出 `status='stopped'` 但 Gateway 实际在运行的卡；逐卡对比并重发停机指令；F-6 注入后历史不一致需人工修正 |

---

## Pitfall-to-Phase Mapping

| Pitfall | Prevention Phase | Verification |
|---------|------------------|--------------|
| `sellerCostPrice` 多处同类 Bug | 方案 A（A-5 实施）| `grep -n "sellerCostPrice = buyerCostPrice"` 结果为 0 |
| 退款浮点精度丢失 | 方案 I（I-3 实现）| Code Review 检查不含 `float64` 金额运算 |
| 佣金树形遍历循环引用/孤岛 | 方案 A + 方案 F | 深度计数器 + visited map 存在于 `CalculateCostDiffCommission`；店铺删除有前置检查 |
| 佣金任务重复入队 | 方案 A（A-4 实施）| `asynq.TaskID` 选项用于 commission 任务；`task_id` 可在 Asynq 监控台验证唯一性 |
| 运营商回调未验签 | 方案 J（J-1 富友支付）、方案 F（F-6）| Code Review 确认每个回调 Handler 首行有签名验证 |
| Handler 直接访问 DB | 方案 F（F-4 迁移）| `grep -rn "h\.db\." internal/handler` 结果为 0 |
| 任务"注册未调度"模式 | 每次新增 Asynq 任务 | 新增任务时 Code Review 检查清单：4 项全勾选 |
| bootstrap 回调未注入 | 方案 F（F-6）| `grep -rn "SetStopResumeCallback\|SetResumeCallback" internal/bootstrap` 有调用 |
| Gateway nil 假装成功 | 方案 H（H-2）| `grep -n "return nil" internal/service/iot_card/stop_resume_service.go` 不出现在 `gatewayClient == nil` 分支 |
| 金额 `float64` 误用 | 全程 Code Review | `grep -rn "float64.*[Aa]mount\|[Aa]mount.*float64" internal/service` 结果为 0 |

---

## Sources

- `internal/service/commission_calculation/service.go`：佣金树形遍历实现（直接代码审查）
- `internal/service/order/service.go`：5 种购买场景 + sellerCostPrice 赋值（grep 分析，第 187、262、472、548、734、809 行）
- `internal/task/polling_handler.go`：流量计算逻辑（直接代码审查，第 388-437 行）
- `internal/service/iot_card/stop_resume_service.go`：停复机 nil 问题（直接代码审查）
- `.sisyphus/plans/修正业务-完整方案.md`：方案 A-J 完整规格书（主要信息来源）
- `pkg/queue/handler.go`：Asynq 任务注册 vs 调度模式（直接代码审查）
- `internal/model/financial.go`：金额字段均以 `int64` 分为单位（设计确认）
- Go 官方 `float64` 精度限制文档（内置知识，HIGH 置信度）

---
*Pitfalls research for: IoT 卡管理平台（SIM 卡分销 + 多级代理佣金 + 异步套餐激活）*
*Researched: 2026-03-27*
