Files
junhong_cmp_fiber/.planning/research/PITFALLS.md
2026-03-27 19:04:17 +08:00

246 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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:** 方案 AA-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 的父是 BB 的父是 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:** 方案 AA-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:** 方案 AA-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:** 方案 FF-6 注入)、方案 JJ-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 临时方案) | 临时绕过依赖注入问题 | 形成两套并行队列机制,消费者只消费 AsynqRedis 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 |
|-------------|----------------|------------------|
| 运营商 Gatewaysync-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 TTLE-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 | 方案 AA-5 实施)| `grep -n "sellerCostPrice = buyerCostPrice"` 结果为 0 |
| 退款浮点精度丢失 | 方案 II-3 实现)| Code Review 检查不含 `float64` 金额运算 |
| 佣金树形遍历循环引用/孤岛 | 方案 A + 方案 F | 深度计数器 + visited map 存在于 `CalculateCostDiffCommission`;店铺删除有前置检查 |
| 佣金任务重复入队 | 方案 AA-4 实施)| `asynq.TaskID` 选项用于 commission 任务;`task_id` 可在 Asynq 监控台验证唯一性 |
| 运营商回调未验签 | 方案 JJ-1 富友支付)、方案 FF-6| Code Review 确认每个回调 Handler 首行有签名验证 |
| Handler 直接访问 DB | 方案 FF-4 迁移)| `grep -rn "h\.db\." internal/handler` 结果为 0 |
| 任务"注册未调度"模式 | 每次新增 Asynq 任务 | 新增任务时 Code Review 检查清单4 项全勾选 |
| bootstrap 回调未注入 | 方案 FF-6| `grep -rn "SetStopResumeCallback\|SetResumeCallback" internal/bootstrap` 有调用 |
| Gateway nil 假装成功 | 方案 HH-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*