20 KiB
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:
- 修复 A-5 时,用
grep全量检索sellerCostPrice = buyerCostPrice,对每一行逐场景验证语义 - 添加注释:
sellerCostPrice 含义 = 本订单"卖家"向其上级结算时使用的成本基准(不是买家看到的价格) - 为每种购买场景(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 的退款比例计算:
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:
- 在遍历循环加深度计数器:
for depth := 0; depth < 8 && currentShopID != nil; depth++ - 用
visited map[uint]bool检测循环引用,发现后立即break并打告警日志 - 店铺删除接口增加"有子代理时拒绝删除"的前置检查(参考 H-4 Series 的做法)
- 每日定时任务扫描层级数据一致性(检查
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:
- 给
TaskTypeCommission加 Asynq 唯一任务选项:asynq.TaskID(fmt.Sprintf("commission:%d", orderID))→ Asynq 保证相同 TaskID 只入队一次 - 在
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:
- 富友支付回调(J-1)实现时:先验签(
pkg/fuiou/已有签名工具),再做业务处理 - 所有回调 Handler 必须在最开头验签,验签失败直接返回
400,不进入业务逻辑 - 停复机回调(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