Compare commits
21 Commits
876151ec1c
...
c083596ef6
| Author | SHA1 | Date | |
|---|---|---|---|
| c083596ef6 | |||
| fb14c51fc2 | |||
| dbeed92ed9 | |||
| 030425be33 | |||
| 0ed804c4b3 | |||
| 20b4b6d6bf | |||
| 9a4d87a0d4 | |||
| 0df992c98a | |||
| f74b7da25f | |||
| 6de830fcc4 | |||
| da50a35a62 | |||
| 1fd0072f69 | |||
| bfcea1c18f | |||
| db166807c7 | |||
| 809cb2b8a8 | |||
| f48f11beb4 | |||
| 5862018e7c | |||
| dc9bf0c9b7 | |||
| 7f2e978288 | |||
| 3078bc71c8 | |||
| cf59380784 |
166
.planning/PROJECT.md
Normal file
166
.planning/PROJECT.md
Normal file
@@ -0,0 +1,166 @@
|
||||
# 君鸿卡管系统
|
||||
|
||||
## What This Is
|
||||
|
||||
物联网卡(IoT SIM)+ 号卡 + 设备全生命周期管理平台,支持多级代理商体系和分佣结算。平台以 B 端代理商为核心分销渠道,兼顾 C 端个人客户和企业客户,覆盖从卡/设备采购、分配、激活、套餐购买到佣金结算的完整业务链路。当前处于 MVP 阶段,核心功能模块已完成,目标是修复所有已知业务链路断点、补全缺失功能,将系统调整至可生产上线状态。
|
||||
|
||||
## Core Value
|
||||
|
||||
**业务链路完整可用**——代理能采购分配卡/设备,C端客户能充值购包激活套餐,佣金能正确计算并可提现。这条主链路必须端到端跑通。
|
||||
|
||||
## Requirements
|
||||
|
||||
### Validated
|
||||
|
||||
*以下功能已在代码库中实现,视为已验证的基础能力:*
|
||||
|
||||
**Validated in Phase 1: P0 紧急修复**
|
||||
- ✓ 实名状态常量统一(DB 写入值统一为 constants.RealNameStatusVerified=1)— CRITICAL-01
|
||||
- ✓ C 端实名校验修复(依赖常量统一)— CRITICAL-02
|
||||
- ✓ 实名激活任务断链修复(PollingHandler 注入 asynqClient,RPush 降级方案删除)— CRITICAL-03
|
||||
- ✓ 充值回调触发自动购包(TaskTypeAutoPurchaseAfterRecharge 入队)— CRITICAL-04
|
||||
- ✓ 自动购包后佣金链补全(TaskTypeCommission 正确触发)— CRITICAL-05
|
||||
- ✓ 代购订单 sellerCostPrice 修正(场景5使用 operatorCostPrice,6处全部审查)— CRITICAL-06
|
||||
- ✓ 设备导入补充 IMEI 字段(DeviceRow.IMEI + buildDeviceColumnIndex 多别名)— CRITICAL-07
|
||||
- ✓ 提现冻结并发校验(RowsAffected==0 检查,防止重复提现单)— CRITICAL-08
|
||||
|
||||
- ✓ IoT 卡全生命周期管理(开卡/激活/停复机/销户)— 已实现
|
||||
- ✓ 设备管理(绑卡/解绑/批量导入 Excel)— 已实现
|
||||
- ✓ 代理商多级体系(最多 7 层层级,parent_id 维护)— 已实现
|
||||
- ✓ RBAC 权限系统(角色/权限/菜单,基于店铺层级数据过滤)— 已实现
|
||||
- ✓ B 端认证(Admin JWT + Redis Token,SuperAdmin/Platform/Agent/Enterprise)— 已实现
|
||||
- ✓ C 端认证(微信 OAuth + 手机号绑定,JWT 单独中间件)— 已实现
|
||||
- ✓ 套餐系统(主套餐/加油包/排队激活/囤货待实名激活/流量计费)— 已实现
|
||||
- ✓ 订单系统(五种购买场景,钱包/微信/富友支付)— 已实现
|
||||
- ✓ 微信支付(JSAPI + H5,PowerWeChat v3 SDK)— 已实现
|
||||
- ✓ 资产钱包(绑定卡/设备,转手后钱包跟着走)— 已实现
|
||||
- ✓ 代理佣金钱包(差价佣金 + 一次性佣金,多级分链)— 已实现
|
||||
- ✓ 代理佣金提现申请流程(申请/审批/拒绝)— 已实现(待修复)
|
||||
- ✓ 轮询系统(实名状态/流量/套餐余额,Asynq + Redis Sorted Set)— 已实现
|
||||
- ✓ 标签系统(平台/企业/店铺三级隔离)— 已实现
|
||||
- ✓ 对象存储(联通云 OSS,S3 兼容,批量导入/导出)— 已实现
|
||||
- ✓ 审计日志(账号操作:create/update/delete/assign_roles)— 已实现
|
||||
- ✓ 企业账号管理(B 端大客户,被分配卡/设备,无钱包)— 已实现(待修复权限)
|
||||
- ✓ 订单超时自动取消(Asynq Scheduler,30 分钟)— 已实现
|
||||
- ✓ 富友支付 SDK(pkg/fuiou/ 完整实现 + 回调已实现)— SDK 已实现,接口留桩待补全
|
||||
|
||||
### Active
|
||||
|
||||
*当前阶段需要完成的工作,来自修正业务完整方案(.sisyphus/plans/修正业务-完整方案.md):*
|
||||
|
||||
**方案 A — 紧急 Bug 修复(P0)** ✅ *Validated in Phase 1*
|
||||
- [x] A-1:实名状态常量统一(DB 写入值 2 vs 常量 1,所有链路断裂)
|
||||
- [x] A-2:C 端实名校验修复(依赖 A-1)
|
||||
- [x] A-3:实名激活任务断链修复(PollingHandler 缺少 asynqClient,RPush 降级)
|
||||
- [x] A-4:充值回调后自动购包触发 + 佣金补全(两个断点:无调用 + 佣金不计算)
|
||||
- [x] A-5:代购订单金额修正(场景 5 sellerCostPrice 用错字段,佣金链归零)
|
||||
- [x] A-6:设备导入补充 IMEI 字段(DeviceRow 缺 IMEI,导入后 Gateway 无法调用)
|
||||
- [x] A-7:提现冻结并发校验(只检查 Error 不检查 RowsAffected,并发创建重复提现单)
|
||||
|
||||
**方案 B — 企业端权限完善**
|
||||
- [ ] B-1:Resolve 接口错误拦截企业账号(应可查,需移除拦截)
|
||||
- [ ] B-2:Refresh 接口缺少企业账号拦截(应只读)
|
||||
|
||||
**方案 C — 提现/佣金流程完善**
|
||||
- [ ] C-1:审批人字段补全(approved_by/approved_at 永远为空)
|
||||
- [ ] C-2:提现拒绝 remark 必填校验(Reject 缺少 Validate 调用)
|
||||
- [ ] C-3:激活配置并发保护(两步 UPDATE 无锁,可出现两条 active=true)
|
||||
|
||||
**方案 D — 设备体系完善**
|
||||
- [ ] D-0:设备模型字段扩展(online_status / last_online_time / software_version 等)
|
||||
- [ ] D-1:Gateway sync-info 同步接口对接(CurrentIccid 预留结构待填充)
|
||||
- [ ] D-2:device_sim_binding 新增 is_current 字段(当前使用卡标识)
|
||||
- [ ] D-3:设备 Refresh + 详情接入 sync-info(在线状态/固件版本/当前卡实时更新)
|
||||
|
||||
**方案 E — 流量体系改革(破坏性,低峰期)**
|
||||
- [ ] E-1:流量详单改为日粒度缓冲(新建 tb_card_daily_usage,Redis 增量缓存)
|
||||
- [ ] E-2:current_month_usage_mb 改增量累加(不再被上游自然月归零覆盖)
|
||||
- [ ] E-3:流量查询层适配(兼容 Redis 今日 + DB 历史两段数据)
|
||||
|
||||
**方案 F — 代码质量清理**
|
||||
- [ ] F-1:佣金链断裂改为零额待审记录(status=99,不再静默跳过)
|
||||
- [ ] F-2:废弃 sim:status:sync 任务代码删除
|
||||
- [ ] F-3:错误不应被吞(cards, _ := 改为记日志)
|
||||
- [ ] F-4:C 端 Handler 迁移到 Service 层(直接 DB 访问改回分层)
|
||||
- [ ] F-5:Handler/Service 权限不一致修复
|
||||
- [ ] F-6:StopResumeCallback / ResumeCallback 注入(bootstrap 未调用)
|
||||
|
||||
**方案 G — 实名激活架构重构**
|
||||
- [ ] G:enable_realname_activation 字段拆分为 expiry_base(from_activation/from_purchase)
|
||||
|
||||
**方案 H — 轮询系统小修**
|
||||
- [ ] H-1:polling:protect 接入调度(已注册未调度)
|
||||
- [ ] H-2:gatewayClient=nil 时停复机应返回错误(而非假装成功)
|
||||
- [ ] H-3:packages 接口加分页
|
||||
- [ ] H-4:Series 删除前检查关联套餐
|
||||
|
||||
**方案 I — 退款完整功能(新功能)**
|
||||
- [ ] I-1:新建 tb_refund_request 数据模型
|
||||
- [ ] I-2:7 个退款接口(申请/列表/详情/审批/拒绝/退回/重提)
|
||||
- [ ] I-3:审批通过后按比例扣减代理佣金钱包
|
||||
|
||||
**方案 J — 其他修复**
|
||||
- [ ] J-1:富友支付 JSAPI + 小程序接口实现(替换留桩)
|
||||
- [ ] J-2:平台代理钱包代购支持(platform buyerType 绕过代理检查)
|
||||
- [ ] J-3:status 3/4 业务触发逻辑(激活→3,最后套餐到期→4)
|
||||
- [ ] J-4:payment_status 枚举统一(删除 C 端映射函数,与管理端统一)
|
||||
|
||||
### Out of Scope
|
||||
|
||||
- SaaS 多租户架构 — 当前阶段不影响,v2+ 规划
|
||||
- 自动化测试(unit/integration/e2e) — 项目明确不做,人工验收
|
||||
- 设备定期轮询(P1-9) — 确认为伪需求,改为按需实时拉取
|
||||
- P1-2 企业认证中间件 — 企业账号复用 AdminAuth,不需要新中间件
|
||||
- P1-3 提现"已到账"接口 — 线下打款,系统无法感知
|
||||
- P2-6 告警通知渠道 — 无关紧要,暂不做
|
||||
- P2-9 asset_type 校验 — Service 层已有兜底,非问题
|
||||
- P2-24 Carrier→Series 绑定 — 系列可跨运营商,不需要强绑定
|
||||
- P2-30 轮询配置通用化 — 设备轮询取消后此问题不存在
|
||||
|
||||
## Context
|
||||
|
||||
- **技术栈**:Go + Fiber v2 + GORM + Asynq + PostgreSQL + Redis + sonic JSON + Viper + Zap
|
||||
- **架构分层**:Handler → Service → Store → Model,双进程(API + Worker),依赖集中在 bootstrap 装配
|
||||
- **路由注册**:统一经过 internal/routes/registry.go 的 Register(),同时驱动 OpenAPI 文档
|
||||
- **数据权限**:Store 层显式调用 ApplyShopFilter / ApplyEnterpriseFilter,不依赖 GORM Callback(README 描述已过时)
|
||||
- **支付体系**:微信(PowerWeChat v3 JSAPI/H5)+ 富友(自研 SDK,SDK 完整但主调接口留桩)+ 钱包余额
|
||||
- **修复文档**:完整规格书位于 .sisyphus/plans/修正业务-完整方案.md,包含每个 Bug 的文件路径和修改代码片段
|
||||
- **代码库地图**:.planning/codebase/ 包含 ARCHITECTURE.md / STACK.md / STRUCTURE.md 等
|
||||
|
||||
## Constraints
|
||||
|
||||
- **Tech Stack**: 严格使用 Fiber v2 + GORM + Asynq + PostgreSQL + Redis,不引入替代框架
|
||||
- **No Tests**: 项目不写自动化测试,人工验收(rg + go build + DBHub + 日志)
|
||||
- **Language**: 所有注释/日志/错误消息/文档使用中文,变量/函数名使用英文
|
||||
- **No Foreign Keys**: 禁止数据库外键约束,关联通过 ID 字段在代码层维护
|
||||
- **方案 E 风险**: 流量体系改革涉及 DB 迁移 + Redis 架构,必须低峰期发布,单独规划
|
||||
|
||||
## Key Decisions
|
||||
|
||||
| Decision | Rationale | Outcome |
|
||||
|----------|-----------|---------|
|
||||
| 不新建企业认证中间件 | 企业账号 user_type=4 已可复用 AdminAuth JWT | — Pending |
|
||||
| SaaS 方向不影响当前阶段 | 先把 MVP 跑通上线,SaaS 是 v2+ 的事 | — Pending |
|
||||
| 修正业务文档为完整范围 | 文档覆盖所有已知断点和缺失功能,无额外遗漏 | — Pending |
|
||||
| 设备体系不做定期轮询 | 按需实时拉取(sync-info)取代定期后台轮询 | — Pending |
|
||||
| 流量体系改革独立发布 | 破坏性变更(DB 迁移 + Redis 架构),风险隔离 | — Pending |
|
||||
|
||||
---
|
||||
*Last updated: 2026-03-28 after Phase 1 (P0 紧急修复) completion — CRITICAL-01~08 all validated*
|
||||
|
||||
## Evolution
|
||||
|
||||
This document evolves at phase transitions and milestone boundaries.
|
||||
|
||||
**After each phase transition** (via `/gsd-transition`):
|
||||
1. Requirements invalidated? → Move to Out of Scope with reason
|
||||
2. Requirements validated? → Move to Validated with phase reference
|
||||
3. New requirements emerged? → Add to Active
|
||||
4. Decisions to log? → Add to Key Decisions
|
||||
5. "What This Is" still accurate? → Update if drifted
|
||||
|
||||
**After each milestone** (via `/gsd-complete-milestone`):
|
||||
1. Full review of all sections
|
||||
2. Core Value check — still the right priority?
|
||||
3. Audit Out of Scope — reasons still valid?
|
||||
4. Update Context with current state
|
||||
168
.planning/REQUIREMENTS.md
Normal file
168
.planning/REQUIREMENTS.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Requirements
|
||||
|
||||
**Project:** 君鸿卡管系统
|
||||
**Milestone:** v1.0 — MVP 上线准备
|
||||
**Date:** 2026-03-27
|
||||
|
||||
---
|
||||
|
||||
## v1 Requirements
|
||||
|
||||
### CRITICAL — P0 紧急 Bug 修复(方案 A)
|
||||
|
||||
- [x] **CRITICAL-01**: 实名状态常量统一 — `parseRealnameStatus` 写入值 2 改为常量 1,Model 注释统一,全链路使用 `constants.RealNameStatusVerified`
|
||||
- [x] **CRITICAL-02**: C 端实名校验修复 — `client_order/service.go` 实名判断改用 `constants.RealNameStatusVerified`(依赖 CRITICAL-01)
|
||||
- [x] **CRITICAL-03**: 实名激活任务断链修复 — `PollingHandler` 注入 `asynq.Client`,删除 RPush 降级方案,改用 `asynq.Client.EnqueueContext()`
|
||||
- [x] **CRITICAL-04**: 充值回调后自动购包触发 — `recharge/service.go` 在 HandlePaymentCallback 成功后入队 `TaskTypeAutoPurchaseAfterRecharge`;注册 `AutoPurchaseHandler`
|
||||
- [x] **CRITICAL-05**: 自动购包佣金链补全 — `AutoPurchaseHandler.ProcessTask()` 事务提交成功后入队 `TaskTypeCommission`
|
||||
- [x] **CRITICAL-06**: 代购订单金额修正 — 场景 5(代理代购下级)`sellerCostPrice` 全部 6 处逐一审查,确保使用 `operatorCostPrice`(非 `buyerCostPrice`)
|
||||
- [x] **CRITICAL-07**: 设备导入补充 IMEI 字段 — `DeviceRow` 新增 IMEI,`ParseDeviceExcel` 列名映射新增 IMEI,`device_import.go` 导入时填充
|
||||
- [x] **CRITICAL-08**: 提现冻结并发校验 — `my_commission/service.go` 冻结余额后检查 `RowsAffected == 0`,余额不足并发场景返回错误而非静默继续
|
||||
|
||||
### PERM — 企业端权限完善(方案 B)
|
||||
|
||||
- [ ] **PERM-01**: 移除 Resolve 接口对企业账号的错误拦截 — 企业账号应能查询被授权的资产
|
||||
- [ ] **PERM-02**: Refresh 接口新增企业账号拦截 — 企业只读,不允许主动触发运营商刷新
|
||||
|
||||
### FIN — 财务/提现流程完善(方案 C)
|
||||
|
||||
- [ ] **FIN-01**: 提现审批人字段补全 — `Approve()` 写入 `approved_by` / `approved_at`(当前永远为空)
|
||||
- [ ] **FIN-02**: 提现拒绝 remark 必填校验 — `Reject()` 在 BodyParser 后补充 `validator.Validate()`
|
||||
- [ ] **FIN-03**: 激活配置并发保护 — 提现配置和微信配置激活时,两步 UPDATE 前加 `FOR UPDATE` 行锁
|
||||
|
||||
### REALNAME — 实名激活架构重构(方案 G)
|
||||
|
||||
- [ ] **REALNAME-01**: 移除 `tb_package.enable_realname_activation` 字段,新增 `expiry_base VARCHAR(30)` 字段(`from_activation` / `from_purchase`)
|
||||
- [ ] **REALNAME-02**: `activateMainPackage` 改写激活决策逻辑 — 按卡类型(industry/normal)+ 购买路径(C端/后台囤货)+ `expiry_base` 三维决策
|
||||
- [ ] **REALNAME-03**: `client_order/service.go` 实名检查改为按卡类型判断(`card_category == "normal"`),删除 `packagesNeedRealname()` 函数
|
||||
- [ ] **REALNAME-04**: `ActivateByRealname()` 按 `ExpiryBase` 选择激活时间基准(`from_purchase` 用 CreatedAt,`from_activation` 用当前时刻)
|
||||
- [ ] **REALNAME-05**: 套餐管理 API DTO 更新 — 移除 `enable_realname_activation`,新增 `expiry_base` 枚举字段
|
||||
|
||||
### POLL — 轮询系统小修(方案 H)
|
||||
|
||||
- [ ] **POLL-01**: `polling:protect` 接入调度 — `scheduler.go` 补充 processManualQueue + processTimedQueue 对 protect 任务的调度
|
||||
- [ ] **POLL-02**: gatewayClient=nil 时停复机返回错误 — `stopCardWithRetry` 和 `resumeCardWithRetry` 两处改为返回业务错误而非 nil
|
||||
- [ ] **POLL-03**: packages 接口新增分页 — `GetPackages()` 默认 page=1, pageSize=50,最大 100
|
||||
- [ ] **POLL-04**: Series 删除前检查关联套餐 — `package_series/service.go:Delete()` 前置 `CountBySeriesID()` 检查
|
||||
|
||||
### DEVICE — 设备体系完善(方案 D)
|
||||
|
||||
- [ ] **DEVICE-01**: 设备模型字段扩展 — DB 迁移新增 `online_status / last_online_time / software_version / switch_mode / last_gateway_sync_at`
|
||||
- [ ] **DEVICE-02**: Gateway sync-info 同步接口对接 — `internal/gateway/device.go` 新增 `SyncDeviceInfo()` 方法
|
||||
- [ ] **DEVICE-03**: `tb_device_sim_binding` 新增 `is_current` 字段 — DB 迁移 + Model + 更新逻辑
|
||||
- [ ] **DEVICE-04**: 设备 Refresh + 详情接入 sync-info — `RefreshDevice()` 在刷新卡数据后调用 `SyncDeviceInfo()`,更新设备在线状态/固件版本/当前卡标识
|
||||
|
||||
### REFUND — 退款完整功能(方案 I)
|
||||
|
||||
- [ ] **REFUND-01**: 新建 `tb_refund_request` 数据模型 — DB 迁移 + GORM Model
|
||||
- [ ] **REFUND-02**: 退款接口实现 — Handler + Service + Store + 路由注册(Create / List / Get / Approve / Reject / Return / Resubmit 共 7 个接口)
|
||||
- [ ] **REFUND-03**: 审批通过后按比例扣减代理佣金钱包 — 使用整数算术(`amount * approvedRefund / actualReceived`),禁用 float64,写负向交易流水
|
||||
|
||||
### PAY — 支付功能补全(方案 J-1 + J-2)
|
||||
|
||||
- [ ] **PAY-01**: 富友支付 JSAPI 接口实现 — 替换留桩,实现预下单 + 返回前端调起支付参数
|
||||
- [ ] **PAY-02**: 富友支付小程序接口实现 — 与 JSAPI 相同逻辑,tradeType 改为 `LETPAY`
|
||||
- [ ] **PAY-03**: 新增 `FuiouPayJSAPIResponse` DTO,字段与微信官方 `wx.requestPayment()` 参数对齐
|
||||
- [ ] **PAY-04**: 平台代理钱包代购 — `CreateLegacy` 支持 `buyerType="" && resourceShopID != nil` 场景,使用资产所属代理的成本价和钱包
|
||||
|
||||
### OPS — 运营逻辑修复(方案 J-3 + J-4)
|
||||
|
||||
- [ ] **OPS-01**: IoT 卡/设备 status 字段 3/4 业务触发 — 套餐激活成功后更新为 3,最后一个 active 套餐到期后更新为 4
|
||||
- [ ] **OPS-02**: payment_status 枚举统一 — 删除 C 端 `orderStatusToClientStatus()` 映射函数,C 端直接输出与管理端一致的 1/2/3/4 枚举
|
||||
|
||||
### CLEAN — 代码质量清理(方案 F)
|
||||
|
||||
- [ ] **CLEAN-01**: 佣金链断裂改为零额待审记录 — 新增 `CommissionStatusPendingReview = 99`,断链时创建 amount=0 的待审记录,不再静默 break
|
||||
- [ ] **CLEAN-02**: 废弃 sim:status:sync 任务代码删除 — 移除注册行 + 常量 + `internal/task/sim.go` + `internal/service/sync/service.go`
|
||||
- [ ] **CLEAN-03**: 错误不应被吞 — `asset/service.go` 中 `cards, _ :=` 改为记录 Warn 日志
|
||||
- [ ] **CLEAN-04**: C 端 Handler 迁移到 Service 层 — `handler/app/client_order.go` 订单查询改为调用 `client_order/service.go`,删除直接 DB 访问
|
||||
- [ ] **CLEAN-05**: Handler/Service 权限不一致修复 — `handler/admin/order.go` 创建订单时补充钱包支付仅允许代理账号的前置校验
|
||||
- [ ] **CLEAN-06**: StopResumeCallback / ResumeCallback 注入 — `internal/bootstrap/services.go` 中初始化后调用 `SetStopResumeCallback` 和 `SetResumeCallback`
|
||||
|
||||
### TRAFFIC — 流量体系改革(方案 E,破坏性变更)
|
||||
|
||||
- [ ] **TRAFFIC-01**: 新建 `tb_card_daily_usage` 日粒度流量表 — DB 迁移 + 唯一约束 `(iot_card_id, date)`
|
||||
- [ ] **TRAFFIC-02**: 轮询流量写入改为 Redis 增量缓存 — `insertDataUsageRecord()` 仅在有增量时写 `RedisCardDailyTrafficKey`
|
||||
- [ ] **TRAFFIC-03**: 每日落盘定时任务 — Asynq Scheduler 凌晨 2 点 SCAN Redis Key → UPSERT `tb_card_daily_usage` → 删 Key
|
||||
- [ ] **TRAFFIC-04**: 流量增量累加 — `calculateFlowUpdates()` 改为 `current_month_usage_mb += increment`,检测运营商重置日,新增 `tb_carrier.data_reset_day` 和 `tb_iot_card.last_gateway_reading_mb`
|
||||
- [ ] **TRAFFIC-05**: 流量查询层适配 — 新建 `TrafficQueryService.GetDailyUsage()` 兼容"今日 Redis + 历史 DB"两段数据
|
||||
|
||||
---
|
||||
|
||||
## v2 Requirements(延后)
|
||||
|
||||
- 卡/资产批量操作(停/复/注销)— 运营场景必须,当前仅有单卡接口
|
||||
- 卡/订单批量导出(Excel/CSV)— 财务对账标配,已有导入缺导出
|
||||
- 套餐到期前微信模板消息提醒 — 用户体验,竞品标配
|
||||
- 代理商完整资金流水账单(按时间倒序,标注来源类型)
|
||||
- 下级代理业绩报表(订单数/金额/活跃卡数)
|
||||
- 卡状态变更历史记录(溯源谁什么时候停了哪张卡)
|
||||
- SaaS 多租户架构 — v2+ 规划
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 自动化测试(unit/integration/e2e)— 项目明确不做
|
||||
- 设备定期轮询(P1-9)— 伪需求,改为按需实时拉取
|
||||
- P1-2 企业认证中间件 — 复用 AdminAuth,无需新中间件
|
||||
- P1-3 提现"已到账"确认接口 — 线下打款,系统无法感知
|
||||
- P2-6 告警通知渠道 — 优先级低,暂不做
|
||||
- P2-9 asset_type 校验 — Service 层已有兜底
|
||||
- P2-24 Carrier→Series 强绑定 — 系列可跨运营商
|
||||
- P2-30 轮询配置通用化 — 设备轮询取消后此问题不存在
|
||||
|
||||
---
|
||||
|
||||
## Traceability
|
||||
|
||||
*(由 roadmapper 在创建 ROADMAP.md 时填充 — 2026-03-27)*
|
||||
|
||||
| REQ-ID | Phase | Status |
|
||||
|--------|-------|--------|
|
||||
| CRITICAL-01 | Phase 1 | Complete |
|
||||
| CRITICAL-02 | Phase 1 | Complete |
|
||||
| CRITICAL-03 | Phase 1 | Complete |
|
||||
| CRITICAL-04 | Phase 1 | Complete |
|
||||
| CRITICAL-05 | Phase 1 | Complete |
|
||||
| CRITICAL-06 | Phase 1 | Complete |
|
||||
| CRITICAL-07 | Phase 1 | Complete |
|
||||
| CRITICAL-08 | Phase 1 | Complete |
|
||||
| PERM-01 | Phase 2 | Pending |
|
||||
| PERM-02 | Phase 2 | Pending |
|
||||
| FIN-01 | Phase 2 | Pending |
|
||||
| FIN-02 | Phase 2 | Pending |
|
||||
| FIN-03 | Phase 2 | Pending |
|
||||
| REALNAME-01 | Phase 2 | Pending |
|
||||
| REALNAME-02 | Phase 2 | Pending |
|
||||
| REALNAME-03 | Phase 2 | Pending |
|
||||
| REALNAME-04 | Phase 2 | Pending |
|
||||
| REALNAME-05 | Phase 2 | Pending |
|
||||
| POLL-01 | Phase 2 | Pending |
|
||||
| POLL-02 | Phase 2 | Pending |
|
||||
| POLL-03 | Phase 2 | Pending |
|
||||
| POLL-04 | Phase 2 | Pending |
|
||||
| DEVICE-01 | Phase 3 | Pending |
|
||||
| DEVICE-02 | Phase 3 | Pending |
|
||||
| DEVICE-03 | Phase 3 | Pending |
|
||||
| DEVICE-04 | Phase 3 | Pending |
|
||||
| REFUND-01 | Phase 4 | Pending |
|
||||
| REFUND-02 | Phase 4 | Pending |
|
||||
| REFUND-03 | Phase 4 | Pending |
|
||||
| PAY-01 | Phase 4 | Pending |
|
||||
| PAY-02 | Phase 4 | Pending |
|
||||
| PAY-03 | Phase 4 | Pending |
|
||||
| PAY-04 | Phase 4 | Pending |
|
||||
| OPS-01 | Phase 4 | Pending |
|
||||
| OPS-02 | Phase 4 | Pending |
|
||||
| CLEAN-01 | Phase 5 | Pending |
|
||||
| CLEAN-02 | Phase 5 | Pending |
|
||||
| CLEAN-03 | Phase 5 | Pending |
|
||||
| CLEAN-04 | Phase 5 | Pending |
|
||||
| CLEAN-05 | Phase 5 | Pending |
|
||||
| CLEAN-06 | Phase 5 | Pending |
|
||||
| TRAFFIC-01 | Phase 6 | Pending |
|
||||
| TRAFFIC-02 | Phase 6 | Pending |
|
||||
| TRAFFIC-03 | Phase 6 | Pending |
|
||||
| TRAFFIC-04 | Phase 6 | Pending |
|
||||
| TRAFFIC-05 | Phase 6 | Pending |
|
||||
150
.planning/ROADMAP.md
Normal file
150
.planning/ROADMAP.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# ROADMAP — 君鸿卡管系统 v1.0
|
||||
|
||||
**Project:** 君鸿卡管系统
|
||||
**Milestone:** v1.0 — MVP 上线准备
|
||||
**Created:** 2026-03-27
|
||||
**Granularity:** Standard (6 phases)
|
||||
**Coverage:** 46/46 requirements mapped ✓
|
||||
|
||||
---
|
||||
|
||||
## Phases
|
||||
|
||||
- [x] **Phase 1: P0 紧急修复** — 修复所有阻塞主链路的 P0 Bug,使充值→购包→佣金→实名激活全链路可跑通 (completed 2026-03-27)
|
||||
- [ ] **Phase 2: 权限/财务/实名/轮询修复** — 补全企业权限、提现财务流程、实名激活架构重构、轮询系统小修
|
||||
- [ ] **Phase 3: 设备体系完善** — 扩展设备模型字段、对接 Gateway sync-info、完善绑卡标识
|
||||
- [ ] **Phase 4: 退款 + 支付 + 运营修复** — 实现退款完整流程、补全富友支付接口、修复运营逻辑
|
||||
- [ ] **Phase 5: 代码质量清理** — 消除技术债务:佣金断链可观测、删废弃代码、修复权限不一致、迁移分层
|
||||
- [ ] **Phase 6: 流量体系改革(低峰期)** — 破坏性变更:日粒度流量表 + Redis 增量缓存 + 每日落盘任务
|
||||
|
||||
---
|
||||
|
||||
## Phase Details
|
||||
|
||||
### Phase 1: P0 紧急修复
|
||||
**Goal**: 修复所有已知的主链路断点,使"充值→自动购包→佣金计算→实名激活"的完整业务链路可端到端跑通,消除数据写入混乱(实名状态常量)和并发安全漏洞(提现重复创建)
|
||||
**Depends on**: 无(基础)
|
||||
**Requirements**: CRITICAL-01, CRITICAL-02, CRITICAL-03, CRITICAL-04, CRITICAL-05, CRITICAL-06, CRITICAL-07, CRITICAL-08
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. C 端用户完成实名后,实名状态正确写入 DB(值为常量 1,不再是 2),实名激活任务正确触发
|
||||
2. C 端用户钱包充值成功后,自动购包任务入队并被消费,佣金计算任务被正确触发
|
||||
3. 代理代购下级场景(场景 5)的订单金额正确使用 operatorCostPrice,佣金链计算正常
|
||||
4. 批量导入设备 Excel 后,设备记录中含 IMEI 字段,Gateway 调用不再因 IMEI 缺失而失败
|
||||
5. 并发提交提现申请时,RowsAffected 检查生效,不会创建重复提现单
|
||||
**Plans**: 5 plans
|
||||
|
||||
Plans:
|
||||
- [x] 01-01-PLAN.md — CRITICAL-01/02:实名状态常量统一 + C 端实名校验修复(Wave 1)
|
||||
- [x] 01-02-PLAN.md — CRITICAL-03:实名激活任务断链(PollingHandler 注入 asynqClient)(Wave 2)
|
||||
- [x] 01-03-PLAN.md — CRITICAL-04/05:充值回调触发自动购包 + 佣金链补全(Wave 2,parallel)
|
||||
- [x] 01-04-PLAN.md — CRITICAL-06:代购订单 sellerCostPrice 6 处语义审查修正(Wave 1,parallel)
|
||||
- [x] 01-05-PLAN.md — CRITICAL-07/08:设备导入 IMEI + 提现冻结并发校验(Wave 1,parallel)
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 权限/财务/实名/轮询修复
|
||||
**Goal**: 补全企业账号权限边界(可查但不可刷新)、修复提现流程数据完整性(审批人字段/remark 必填/激活配置并发锁)、重构实名激活判断架构(从布尔字段改为 expiry_base 枚举)、修复轮询调度和安全漏洞
|
||||
**Depends on**: Phase 1(CRITICAL-01 先统一实名常量,REALNAME 重构再依此展开)
|
||||
**Requirements**: PERM-01, PERM-02, FIN-01, FIN-02, FIN-03, REALNAME-01, REALNAME-02, REALNAME-03, REALNAME-04, REALNAME-05, POLL-01, POLL-02, POLL-03, POLL-04
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. 企业账号可以调用 Resolve 接口查询被授权资产,但无法触发 Refresh 刷新运营商数据
|
||||
2. 提现审批通过后,approved_by 和 approved_at 字段正确填充;拒绝时若 remark 为空则返回校验错误
|
||||
3. 同时激活两个提现配置时,数据库行锁生效,不会出现两条 active=true 的记录
|
||||
4. 数据库中 tb_package 表不再有 enable_realname_activation 字段,改为 expiry_base 字段(值为 from_activation 或 from_purchase)
|
||||
5. C 端购买普通号卡套餐时,实名检查按卡类型(card_category == "normal")判断,逻辑简洁正确
|
||||
6. polling:protect 任务正确进入调度器,停复机时若 gatewayClient 为 nil 返回业务错误而非静默成功
|
||||
**Plans**: TBD
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 设备体系完善
|
||||
**Goal**: 扩展设备数据模型,对接 Gateway sync-info 接口实现设备在线状态/固件版本实时同步,建立"当前使用卡"标识,使设备详情页展示真实状态
|
||||
**Depends on**: Phase 1(设备导入 IMEI 修复在 CRITICAL-07 完成)
|
||||
**Requirements**: DEVICE-01, DEVICE-02, DEVICE-03, DEVICE-04
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. tb_device 表含 online_status / last_online_time / software_version / switch_mode / last_gateway_sync_at 字段(DB 迁移已执行)
|
||||
2. 调用设备刷新接口后,设备的在线状态、固件版本、当前使用卡(is_current)从 Gateway 实时更新
|
||||
3. tb_device_sim_binding 表含 is_current 字段,同一设备同一时刻只有一条 is_current=true 的绑定记录
|
||||
4. 设备详情接口返回数据包含 online_status、software_version 等字段,值与 Gateway 同步结果一致
|
||||
**Plans**: TBD
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 退款 + 支付 + 运营修复
|
||||
**Goal**: 实现完整退款流程(申请→审批→佣金回扣),补全富友支付 JSAPI/小程序接口(替换留桩),修复运营逻辑断点(卡状态 3/4 触发、payment_status 枚举统一)
|
||||
**Depends on**: Phase 2(佣金链路稳定后再做退款佣金回扣)
|
||||
**Requirements**: REFUND-01, REFUND-02, REFUND-03, PAY-01, PAY-02, PAY-03, PAY-04, OPS-01, OPS-02
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. tb_refund_request 表存在,退款申请/列表/详情/审批/拒绝/退回/重提共 7 个接口可正常调用
|
||||
2. 退款审批通过后,代理佣金钱包按比例扣减(使用整数算术,无 float64 精度误差),负向交易流水写入
|
||||
3. 富友支付 JSAPI 和小程序接口可正常发起支付,返回前端所需调起参数(FuiouPayJSAPIResponse DTO)
|
||||
4. 平台代理使用钱包代购资产时,正确使用资产所属代理成本价,绕过代理身份检查
|
||||
5. IoT 卡/设备在套餐激活成功后 status 更新为 3,最后一个 active 套餐到期后 status 更新为 4
|
||||
6. C 端和管理端 payment_status 枚举值一致(1/2/3/4),不再存在独立的 C 端映射函数
|
||||
**Plans**: TBD
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: 代码质量清理
|
||||
**Goal**: 消除技术债务:使佣金断链可观测(创建零额待审记录),删除废弃的 sim:status:sync 任务代码,修复错误被吞、分层违规、权限不一致等问题,确保 bootstrap 完整注入所有回调
|
||||
**Depends on**: Phase 4(所有业务功能稳定后集中清理)
|
||||
**Requirements**: CLEAN-01, CLEAN-02, CLEAN-03, CLEAN-04, CLEAN-05, CLEAN-06
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. 佣金链计算遇到断链时,创建 status=99(amount=0)的待审记录,日志可见,不再静默跳过
|
||||
2. 代码库中不再有 sim:status:sync 任务相关代码(注册行、常量、sim.go、sync/service.go)
|
||||
3. asset/service.go 中的 cards 错误被记录为 Warn 日志,不再被 `_` 吞掉
|
||||
4. client_order Handler 订单查询通过 Service 层调用,不再直接访问 DB
|
||||
5. 停复机回调(StopResumeCallback / ResumeCallback)在 bootstrap 中正确注入,套餐联动触发正常
|
||||
**Plans**: TBD
|
||||
|
||||
---
|
||||
|
||||
### Phase 6: 流量体系改革(低峰期)
|
||||
**Goal**: 重构流量统计架构为"日粒度 DB 落盘 + Redis 增量缓存"模式,解决运营商自然月归零覆盖问题,提供今日 Redis + 历史 DB 两段数据的统一查询层
|
||||
**Depends on**: Phase 5(代码清理完成,系统稳定,降低破坏性变更风险)
|
||||
**Requirements**: TRAFFIC-01, TRAFFIC-02, TRAFFIC-03, TRAFFIC-04, TRAFFIC-05
|
||||
**Success Criteria** (what must be TRUE):
|
||||
1. tb_card_daily_usage 表存在,含唯一约束 (iot_card_id, date)(DB 迁移已执行)
|
||||
2. 流量轮询写入时,仅在有增量时写 Redis,不再直接覆盖 current_month_usage_mb
|
||||
3. 每日凌晨 2 点 Asynq Scheduler 任务执行:SCAN Redis Key → UPSERT tb_card_daily_usage → 删 Key,日志可验证
|
||||
4. current_month_usage_mb 使用增量累加而非覆盖,运营商自然月归零不会影响 DB 中的历史累计值
|
||||
5. GetDailyUsage 接口返回"今日 Redis 数据 + 历史 DB 日粒度数据"合并结果,兼容两段数据
|
||||
**Plans**: TBD
|
||||
**⚠️ 部署警告**: 此 Phase 涉及 DB 迁移 + Redis 架构变更,必须在低峰期(凌晨 2~5 点)独立部署
|
||||
|
||||
---
|
||||
|
||||
## Progress
|
||||
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
|-------|----------------|--------|-----------|
|
||||
| 1. P0 紧急修复 | 5/5 | Complete | 2026-03-27 |
|
||||
| 2. 权限/财务/实名/轮询修复 | 0/1 | Not started | - |
|
||||
| 3. 设备体系完善 | 0/1 | Not started | - |
|
||||
| 4. 退款 + 支付 + 运营修复 | 0/1 | Not started | - |
|
||||
| 5. 代码质量清理 | 0/1 | Not started | - |
|
||||
| 6. 流量体系改革(低峰期)| 0/1 | Not started | - |
|
||||
|
||||
---
|
||||
|
||||
## Requirement Coverage
|
||||
|
||||
**Total v1 requirements:** 46
|
||||
**Mapped:** 46 ✓
|
||||
|
||||
| Category | Count | Phase |
|
||||
|----------|-------|-------|
|
||||
| CRITICAL | 8 | Phase 1 |
|
||||
| PERM | 2 | Phase 2 |
|
||||
| FIN | 3 | Phase 2 |
|
||||
| REALNAME | 5 | Phase 2 |
|
||||
| POLL | 4 | Phase 2 |
|
||||
| DEVICE | 4 | Phase 3 |
|
||||
| REFUND | 3 | Phase 4 |
|
||||
| PAY | 4 | Phase 4 |
|
||||
| OPS | 2 | Phase 4 |
|
||||
| CLEAN | 6 | Phase 5 |
|
||||
| TRAFFIC | 5 | Phase 6 |
|
||||
|
||||
---
|
||||
*Roadmap created: 2026-03-27*
|
||||
119
.planning/STATE.md
Normal file
119
.planning/STATE.md
Normal file
@@ -0,0 +1,119 @@
|
||||
---
|
||||
gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: milestone
|
||||
status: unknown
|
||||
last_updated: "2026-03-28T01:47:47.024Z"
|
||||
progress:
|
||||
total_phases: 6
|
||||
completed_phases: 1
|
||||
total_plans: 5
|
||||
completed_plans: 5
|
||||
---
|
||||
|
||||
# Project State — 君鸿卡管系统
|
||||
|
||||
**Project:** 君鸿卡管系统 v1.0
|
||||
**Milestone:** v1.0 — MVP 上线准备
|
||||
**Last Updated:** 2026-03-27
|
||||
|
||||
---
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 2
|
||||
Plan: Not started
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Current Phase** | 1 — P0 紧急修复 |
|
||||
| **Current Plan** | None (planning phase) |
|
||||
| **Status** | Planning |
|
||||
| **Phase Progress** | 0/8 requirements complete |
|
||||
|
||||
```
|
||||
Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
|
||||
▲
|
||||
Planning
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Project Reference
|
||||
|
||||
**Core Value:** 业务链路完整可用——代理能采购分配卡/设备,C端客户能充值购包激活套餐,佣金能正确计算并可提现。这条主链路必须端到端跑通。
|
||||
|
||||
**Current Focus:** Phase 01 — p0
|
||||
|
||||
---
|
||||
|
||||
## Phase Summary
|
||||
|
||||
| Phase | Name | Requirements | Status |
|
||||
|-------|------|--------------|--------|
|
||||
| 1 | P0 紧急修复 | CRITICAL-01~08(8项) | Not started |
|
||||
| 2 | 权限/财务/实名/轮询修复 | PERM-01~02, FIN-01~03, REALNAME-01~05, POLL-01~04(14项) | Not started |
|
||||
| 3 | 设备体系完善 | DEVICE-01~04(4项) | Not started |
|
||||
| 4 | 退款 + 支付 + 运营修复 | REFUND-01~03, PAY-01~04, OPS-01~02(9项) | Not started |
|
||||
| 5 | 代码质量清理 | CLEAN-01~06(6项) | Not started |
|
||||
| 6 | 流量体系改革(低峰期)| TRAFFIC-01~05(5项) | Not started |
|
||||
|
||||
---
|
||||
|
||||
## Performance Metrics
|
||||
|
||||
| Metric | Value |
|
||||
|--------|-------|
|
||||
| Phases total | 6 |
|
||||
| Requirements v1 | 46 |
|
||||
| Requirements mapped | 46/46 (100%) |
|
||||
| Plans created | 0 |
|
||||
| Plans complete | 0 |
|
||||
| Phases complete | 0/6 |
|
||||
|
||||
---
|
||||
| Phase 01-p0 P05 | 8 | 2 tasks | 3 files |
|
||||
| Phase 01-p0 P01 | 15 | 2 tasks | 7 files |
|
||||
| Phase 01-p0 P04 | 4min | 1 tasks | 1 files |
|
||||
| Phase 01-p0 P02 | 3min | 1 tasks | 2 files |
|
||||
| Phase 01-p0 P03 | 8min | 2 tasks | 4 files |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
### Key Decisions
|
||||
|
||||
- TRAFFIC(Phase 6)必须独立部署,低峰期(凌晨 2~5 点),涉及 DB 迁移 + Redis 架构变更
|
||||
- REALNAME-01~05 是原子重构,必须在一个 plan 内完整执行,不可拆分
|
||||
- REFUND 退款佣金回扣(REFUND-03)必须使用整数算术,禁用 float64
|
||||
- CRITICAL-02 依赖 CRITICAL-01,CRITICAL-05 依赖 CRITICAL-04
|
||||
- DEVICE-02/03/04 依赖 DEVICE-01(字段扩展先行)
|
||||
- REFUND-02/03 依赖 REFUND-01(数据模型先行)
|
||||
|
||||
### Architecture Notes
|
||||
|
||||
- 双进程(API + Worker),依赖集中在 bootstrap 装配
|
||||
- PollingHandler 缺 asynqClient(CRITICAL-03 修复),AutoPurchaseHandler 未注册(CRITICAL-04 修复)
|
||||
- StopResumeCallback / ResumeCallback 未注入(CLEAN-06 修复)
|
||||
- 流量体系当前用直接覆盖写,Phase 6 改为增量累加 + 日粒度 DB
|
||||
|
||||
### Known Risks
|
||||
|
||||
- sellerCostPrice 错误赋值可能不止场景 5(CRITICAL-06 需逐一检查 6 处)
|
||||
- 分佣树形遍历无循环检测(不在当前 scope,v2 考量)
|
||||
- 店铺软删除无级联检查(v2 考量)
|
||||
|
||||
---
|
||||
|
||||
## Session Continuity
|
||||
|
||||
**Next Action:** Run `/gsd-plan-phase 1` to create detailed plan for Phase 1 (P0 紧急修复)
|
||||
|
||||
**Context Files:**
|
||||
|
||||
- `.planning/ROADMAP.md` — 完整路线图和成功标准
|
||||
- `.planning/REQUIREMENTS.md` — 46 个 v1 需求(含 Traceability 表)
|
||||
- `.planning/PROJECT.md` — 项目背景和约束
|
||||
- `.planning/research/SUMMARY.md` — 技术研究和陷阱分析
|
||||
- `.sisyphus/plans/修正业务-完整方案.md` — 每个 Bug 的完整修复规格(含代码片段)
|
||||
|
||||
---
|
||||
*State initialized: 2026-03-27*
|
||||
224
.planning/phases/01-p0/01-01-PLAN.md
Normal file
224
.planning/phases/01-p0/01-01-PLAN.md
Normal file
@@ -0,0 +1,224 @@
|
||||
---
|
||||
phase: 01-p0
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- internal/task/polling_handler.go
|
||||
- internal/model/iot_card.go
|
||||
- internal/service/client_order/service.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- CRITICAL-01
|
||||
- CRITICAL-02
|
||||
must_haves:
|
||||
truths:
|
||||
- "parseRealnameStatus 返回 constants.RealNameStatusVerified(=1),不再返回硬编码 2"
|
||||
- "isFirstRealname 判断使用常量比较,不再使用 == 2"
|
||||
- "Model 注释与常量一致:0=未实名, 1=已实名"
|
||||
- "client_order 实名校验使用 constants.RealNameStatusVerified,不再硬编码 1"
|
||||
artifacts:
|
||||
- path: internal/task/polling_handler.go
|
||||
provides: "parseRealnameStatus 返回常量值,isFirstRealname 使用常量"
|
||||
contains: "constants.RealNameStatusVerified"
|
||||
- path: internal/model/iot_card.go
|
||||
provides: "Model 字段注释统一"
|
||||
contains: "0-未实名 1-已实名"
|
||||
- path: internal/service/client_order/service.go
|
||||
provides: "实名校验改为常量比较"
|
||||
contains: "constants.RealNameStatusVerified"
|
||||
key_links:
|
||||
- from: internal/task/polling_handler.go
|
||||
to: pkg/constants/iot.go
|
||||
via: "constants.RealNameStatusVerified"
|
||||
pattern: "constants\\.RealNameStatusVerified"
|
||||
- from: internal/service/client_order/service.go
|
||||
to: pkg/constants/iot.go
|
||||
via: "constants.RealNameStatusVerified"
|
||||
pattern: "constants\\.RealNameStatusVerified"
|
||||
---
|
||||
|
||||
<objective>
|
||||
统一实名状态常量(CRITICAL-01),并修复 C 端实名校验(CRITICAL-02)。
|
||||
|
||||
Purpose: 这是整个修复链路的前置条件。轮询写入 2、常量定义 1、校验用 1 三处不一致造成实名链路全面断裂。修复后实名状态全链路统一为 0=未实名、1=已实名。CRITICAL-02 依赖本 Plan 完成才能自动正确。
|
||||
|
||||
Output:
|
||||
- polling_handler.go 写入值改为常量 1
|
||||
- iot_card.go 注释与常量统一
|
||||
- client_order/service.go 校验使用常量
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/01-p0/01-CONTEXT.md
|
||||
|
||||
# 修复规格书(核心参考)
|
||||
@.sisyphus/plans/修正业务-完整方案.md
|
||||
|
||||
<interfaces>
|
||||
<!-- 从 pkg/constants/iot.go 提取的常量,executor 直接使用 -->
|
||||
|
||||
```go
|
||||
// pkg/constants/iot.go(已确认存在,第 47-48 行附近)
|
||||
const (
|
||||
RealNameStatusNotVerified = 0 // 未实名
|
||||
RealNameStatusVerified = 1 // 已实名
|
||||
)
|
||||
```
|
||||
|
||||
<!-- polling_handler.go 当前错误代码(需改掉) -->
|
||||
<!-- 第 696-700 行:parseRealnameStatus 返回 2(错误) -->
|
||||
<!-- 第 170 行:isFirstRealname 使用 == 2(错误) -->
|
||||
|
||||
<!-- client_order/service.go 当前代码(第 115 行附近) -->
|
||||
<!-- assetInfo.RealNameStatus != 1(硬编码,需改为常量) -->
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: 修复 polling_handler.go 实名状态写入值(CRITICAL-01 核心)</name>
|
||||
<files>internal/task/polling_handler.go, internal/model/iot_card.go</files>
|
||||
<action>
|
||||
**修改 `internal/task/polling_handler.go`:**
|
||||
|
||||
① `parseRealnameStatus` 函数(第 696-700 行附近):
|
||||
```go
|
||||
// 修改前
|
||||
func (h *PollingHandler) parseRealnameStatus(realStatus bool) int {
|
||||
if realStatus {
|
||||
return 2 // 已实名
|
||||
}
|
||||
return 0 // 未实名
|
||||
}
|
||||
|
||||
// 修改后(使用常量,per D-01)
|
||||
func (h *PollingHandler) parseRealnameStatus(realStatus bool) int {
|
||||
if realStatus {
|
||||
return constants.RealNameStatusVerified // = 1,已实名
|
||||
}
|
||||
return constants.RealNameStatusNotVerified // = 0,未实名
|
||||
}
|
||||
```
|
||||
|
||||
② `isFirstRealname` 判断(第 170 行附近):
|
||||
```go
|
||||
// 修改前
|
||||
isFirstRealname := (card.RealNameStatus == 0 || card.RealNameStatus == 1) && newRealnameStatus == 2
|
||||
|
||||
// 修改后(使用常量比较,per D-01)
|
||||
isFirstRealname := card.RealNameStatus != constants.RealNameStatusVerified &&
|
||||
newRealnameStatus == constants.RealNameStatusVerified
|
||||
```
|
||||
|
||||
③ 全局搜索 `polling_handler.go` 及 `internal/model/dto/` 下 DTO 注释,将所有 `2=已实名`、`1实名中` 改为 `0=未实名, 1=已实名`(执行:`grep -rn "2=已实名\|1实名中\|RealNameStatus.*2" internal/model/dto/ --include="*.go"`)。
|
||||
|
||||
**修改 `internal/model/iot_card.go`:**
|
||||
|
||||
找到第 30 行附近 `RealNameStatus` 字段的 gorm comment:
|
||||
```go
|
||||
// 修改前
|
||||
RealNameStatus int `gorm:"...; comment:实名状态 0-未实名 1-已实名(行业卡可以保持0)"`
|
||||
|
||||
// 修改后(统一,per D-01)
|
||||
RealNameStatus int `gorm:"...; comment:实名状态 0-未实名 1-已实名"`
|
||||
```
|
||||
|
||||
修改完成后执行:`go build ./...`,确认编译通过,提交一个独立 commit(per D-05)。
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./... && rg "return 2" internal/task/polling_handler.go && echo "FAIL: 仍存在 return 2" || echo "PASS: return 2 已消除"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- polling_handler.go 中 parseRealnameStatus 不再 return 2,改为 return constants.RealNameStatusVerified
|
||||
- isFirstRealname 不再用 == 2,改为 == constants.RealNameStatusVerified
|
||||
- iot_card.go 注释统一为 0-未实名 1-已实名
|
||||
- go build ./... 编译通过
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: 修复 C 端实名校验(CRITICAL-02)</name>
|
||||
<files>internal/service/client_order/service.go</files>
|
||||
<action>
|
||||
**修改 `internal/service/client_order/service.go`(第 115 行附近):**
|
||||
|
||||
找到实名校验判断:
|
||||
```go
|
||||
// 修改前(硬编码 1,本质上是 A-1 修完后 DB 里是 1 才会正确,但语义要用常量)
|
||||
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != 1 {
|
||||
return errors.New(errors.CodeForbidden, "请先完成实名认证")
|
||||
}
|
||||
|
||||
// 修改后(使用常量,per CRITICAL-02 规格)
|
||||
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
|
||||
return errors.New(errors.CodeForbidden, "请先完成实名认证")
|
||||
}
|
||||
```
|
||||
|
||||
同时检查同文件和 `polling_handler.go:982` 是否有其他硬编码 `!= 1` 的实名判断(`rg "RealNameStatus.*!= 1\|!= 1.*RealNameStatus" internal/`),若有,同样改为常量。
|
||||
|
||||
确认 constants 包已正确 import(若未 import,在 import 块补充 `pkgpath/constants`)。
|
||||
|
||||
修改完成后执行:`go build ./...`,编译通过后提交独立 commit(per D-05)。
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./... && rg "RealNameStatus != 1" internal/service/client_order/service.go && echo "FAIL: 仍有硬编码" || echo "PASS: 硬编码已消除"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- client_order/service.go 实名校验改为 constants.RealNameStatusVerified
|
||||
- go build ./... 编译通过
|
||||
- rg 确认不再有 != 1 的硬编码实名判断
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
整体验收(两个任务完成后):
|
||||
|
||||
```bash
|
||||
# 1. 编译验证
|
||||
go build ./...
|
||||
|
||||
# 2. 确认 parseRealnameStatus 不再写 2
|
||||
rg "return 2" internal/task/polling_handler.go
|
||||
|
||||
# 3. 确认实名判断全部使用常量
|
||||
rg "RealNameStatusVerified" internal/task/polling_handler.go internal/service/client_order/service.go
|
||||
|
||||
# 4. 搜索遗漏的硬编码
|
||||
rg "newRealnameStatus == 2\|RealNameStatus == 2\|RealNameStatus != 1" internal/ --include="*.go"
|
||||
```
|
||||
|
||||
人工验收(参见修正业务-完整方案.md 方案 A 验收清单 A-1、A-2):
|
||||
- DBHub 执行:`SELECT DISTINCT real_name_status FROM tb_iot_card ORDER BY 1;`
|
||||
- 预期:只存在 0 和 1,不存在 2
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. `go build ./...` 编译通过,无错误
|
||||
2. `parseRealnameStatus(true)` 返回 1(constants.RealNameStatusVerified),不再返回 2
|
||||
3. `isFirstRealname` 不再使用 == 2 判断
|
||||
4. `client_order/service.go` 实名校验使用常量,不再硬编码 1
|
||||
5. Model 注释与常量定义一致
|
||||
6. 两个独立 commit 已提交
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
完成后创建 `.planning/phases/01-p0/01-01-SUMMARY.md`,记录:
|
||||
- 修改了哪些文件(文件路径 + 改动说明)
|
||||
- 关键代码片段(修改前后对比)
|
||||
- 编译验证结果
|
||||
- CRITICAL-01 和 CRITICAL-02 完成状态
|
||||
</output>
|
||||
233
.planning/phases/01-p0/01-01-SUMMARY.md
Normal file
233
.planning/phases/01-p0/01-01-SUMMARY.md
Normal file
@@ -0,0 +1,233 @@
|
||||
---
|
||||
phase: 01-p0
|
||||
plan: 01
|
||||
subsystem: realname-status
|
||||
tags: [bug-fix, constants, critical, realname]
|
||||
dependency_graph:
|
||||
requires: []
|
||||
provides: [CRITICAL-01, CRITICAL-02]
|
||||
affects: [polling_handler, client_order, scheduler, iot_card_service, asset_dto]
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns: [constants-over-magic-numbers]
|
||||
key_files:
|
||||
created: []
|
||||
modified:
|
||||
- internal/task/polling_handler.go
|
||||
- internal/model/iot_card.go
|
||||
- internal/model/dto/asset_dto.go
|
||||
- internal/polling/scheduler.go
|
||||
- internal/service/iot_card/service.go
|
||||
- internal/service/client_order/service.go
|
||||
- internal/handler/app/client_realname.go
|
||||
decisions:
|
||||
- 统一使用 constants.RealNameStatusVerified(=1) 替代所有硬编码 1、2 的实名状态判断
|
||||
- scheduler.go 中 0/1/2 三态判断简化为已实名/未实名两态(符合常量设计)
|
||||
metrics:
|
||||
duration_minutes: 15
|
||||
completed_date: "2026-03-27"
|
||||
tasks_completed: 2
|
||||
files_modified: 7
|
||||
---
|
||||
|
||||
# Phase 01 Plan 01: 统一实名状态常量 Summary
|
||||
|
||||
**一句话总结:** 将全链路 7 处硬编码实名状态值(包括错误的 `return 2`)统一改为 `constants.RealNameStatusVerified(=1)` / `constants.RealNameStatusNotVerified(=0)`,修复 CRITICAL-01 和 CRITICAL-02。
|
||||
|
||||
---
|
||||
|
||||
## Objective Achieved
|
||||
|
||||
统一实名状态常量(CRITICAL-01),并修复 C 端实名校验(CRITICAL-02)。
|
||||
|
||||
实名链路三处不一致(轮询写入 2、常量定义 1、C 端校验 1)造成实名状态链路全面断裂,修复后全链路统一为 `0=未实名、1=已实名`。
|
||||
|
||||
---
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
|------|------|--------|-------|
|
||||
| 1 | 修复 polling_handler.go 实名状态写入值(CRITICAL-01) | `1fd0072` | polling_handler.go, iot_card.go, asset_dto.go, scheduler.go, iot_card/service.go |
|
||||
| 2 | 修复 C 端实名校验(CRITICAL-02) | `da50a35` | client_order/service.go, client_realname.go |
|
||||
|
||||
---
|
||||
|
||||
## Key Changes
|
||||
|
||||
### Task 1 — CRITICAL-01 核心修复
|
||||
|
||||
**① `internal/task/polling_handler.go` — `parseRealnameStatus`**
|
||||
|
||||
```go
|
||||
// 修改前(错误:写入 2 到数据库)
|
||||
func (h *PollingHandler) parseRealnameStatus(realStatus bool) int {
|
||||
if realStatus {
|
||||
return 2 // 已实名
|
||||
}
|
||||
return 0 // 未实名
|
||||
}
|
||||
|
||||
// 修改后(正确:写入常量值 1)
|
||||
func (h *PollingHandler) parseRealnameStatus(realStatus bool) int {
|
||||
if realStatus {
|
||||
return constants.RealNameStatusVerified // = 1,已实名
|
||||
}
|
||||
return constants.RealNameStatusNotVerified // = 0,未实名
|
||||
}
|
||||
```
|
||||
|
||||
**② `internal/task/polling_handler.go` — `isFirstRealname`**
|
||||
|
||||
```go
|
||||
// 修改前(错误:硬编码 == 2 判断)
|
||||
isFirstRealname := (card.RealNameStatus == 0 || card.RealNameStatus == 1) && newRealnameStatus == 2
|
||||
|
||||
// 修改后(正确:使用常量比较,逻辑等价但语义清晰)
|
||||
isFirstRealname := card.RealNameStatus != constants.RealNameStatusVerified &&
|
||||
newRealnameStatus == constants.RealNameStatusVerified
|
||||
```
|
||||
|
||||
**③ `internal/model/iot_card.go` — Model gorm comment**
|
||||
|
||||
```go
|
||||
// 修改前
|
||||
RealNameStatus int `gorm:"...comment:实名状态 0-未实名 1-已实名(行业卡可以保持0)" json:"real_name_status"`
|
||||
|
||||
// 修改后(统一注释,去除误导性括号说明)
|
||||
RealNameStatus int `gorm:"...comment:实名状态 0-未实名 1-已实名" json:"real_name_status"`
|
||||
```
|
||||
|
||||
**④ `internal/model/dto/asset_dto.go` — DTO description**
|
||||
|
||||
```go
|
||||
// 修改前(错误:包含 1实名中 2已实名 的旧三态描述)
|
||||
RealNameStatus int `json:"real_name_status" description:"实名状态:0未实名 1实名中 2已实名"`
|
||||
|
||||
// 修改后(正确:两态)
|
||||
RealNameStatus int `json:"real_name_status" description:"实名状态:0未实名 1已实名"`
|
||||
```
|
||||
|
||||
**⑤ `internal/polling/scheduler.go` — `getCardCondition`**
|
||||
|
||||
```go
|
||||
// 修改前(错误:使用硬编码 0/1/2 三态判断)
|
||||
if card.RealNameStatus == 0 || card.RealNameStatus == 1 {
|
||||
return "not_real_name"
|
||||
}
|
||||
if card.RealNameStatus == 2 {
|
||||
// ...
|
||||
}
|
||||
|
||||
// 修改后(正确:两态常量判断)
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified {
|
||||
return "not_real_name"
|
||||
}
|
||||
if card.RealNameStatus == constants.RealNameStatusVerified {
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**⑥ `internal/service/iot_card/service.go` — `parseGatewayRealnameStatus`**
|
||||
|
||||
```go
|
||||
// 修改前(错误:返回硬编码 2)
|
||||
// true=已实名(2),false=未实名(0)
|
||||
func parseGatewayRealnameStatus(realStatus bool) int {
|
||||
if realStatus { return 2 }
|
||||
return 0
|
||||
}
|
||||
|
||||
// 修改后(正确:返回常量)
|
||||
// true=已实名(1),false=未实名(0)
|
||||
func parseGatewayRealnameStatus(realStatus bool) int {
|
||||
if realStatus { return constants.RealNameStatusVerified }
|
||||
return constants.RealNameStatusNotVerified
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2 — CRITICAL-02 修复
|
||||
|
||||
**① `internal/service/client_order/service.go`**
|
||||
|
||||
```go
|
||||
// 修改前(硬编码 1,语义不清晰)
|
||||
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != 1 {
|
||||
|
||||
// 修改后(常量比较,语义明确)
|
||||
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
|
||||
```
|
||||
|
||||
**② `internal/handler/app/client_realname.go`**
|
||||
|
||||
```go
|
||||
// 修改前(硬编码 1)
|
||||
if targetCard.RealNameStatus == 1 {
|
||||
|
||||
// 修改后(常量)
|
||||
if targetCard.RealNameStatus == constants.RealNameStatusVerified {
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Verification Results
|
||||
|
||||
```
|
||||
✅ go build ./... — 编译通过,无错误
|
||||
✅ return 2 已消除 — polling_handler.go 中不再有 return 2
|
||||
✅ 常量使用确认 — polling_handler.go 和 client_order/service.go 均使用 constants.RealNameStatusVerified
|
||||
✅ 无遗漏硬编码 — rg 未发现 newRealnameStatus==2 / RealNameStatus==2 / RealNameStatus!=1
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 2 - 缺失修复] 修复 asset_dto.go DTO description**
|
||||
- **Found during:** Task 1 全局搜索
|
||||
- **Issue:** `asset_dto.go` 的 `RealNameStatus` description 仍含旧三态描述 `1实名中 2已实名`
|
||||
- **Fix:** 更新为 `0未实名 1已实名`
|
||||
- **Files modified:** `internal/model/dto/asset_dto.go`
|
||||
- **Commit:** `1fd0072`
|
||||
|
||||
**2. [Rule 2 - 缺失修复] 修复 scheduler.go 硬编码三态判断**
|
||||
- **Found during:** Task 1 全局搜索
|
||||
- **Issue:** `scheduler.go:getCardCondition` 使用 `RealNameStatus == 0 || == 1 || == 2` 硬编码
|
||||
- **Fix:** 改为 `!= constants.RealNameStatusVerified` / `== constants.RealNameStatusVerified`
|
||||
- **Files modified:** `internal/polling/scheduler.go`
|
||||
- **Commit:** `1fd0072`
|
||||
|
||||
**3. [Rule 2 - 缺失修复] 修复 iot_card/service.go 另一处 return 2**
|
||||
- **Found during:** Task 1 全局搜索
|
||||
- **Issue:** `parseGatewayRealnameStatus` 函数也返回硬编码 `2`,与 polling_handler.go 同等问题
|
||||
- **Fix:** 改为常量返回
|
||||
- **Files modified:** `internal/service/iot_card/service.go`
|
||||
- **Commit:** `1fd0072`
|
||||
|
||||
**4. [Rule 2 - 缺失修复] 修复 client_realname.go Handler 层硬编码**
|
||||
- **Found during:** Task 2 全局搜索
|
||||
- **Issue:** `client_realname.go:128` Handler 层 `== 1` 硬编码实名判断
|
||||
- **Fix:** 改为 `constants.RealNameStatusVerified`
|
||||
- **Files modified:** `internal/handler/app/client_realname.go`
|
||||
- **Commit:** `da50a35`
|
||||
|
||||
---
|
||||
|
||||
## Requirements Completed
|
||||
|
||||
- ✅ **CRITICAL-01**: 实名状态常量统一(DB 写入值 2 vs 常量 1,所有链路断裂)— 已修复
|
||||
- ✅ **CRITICAL-02**: C 端实名校验修复(依赖 CRITICAL-01)— 已修复
|
||||
|
||||
---
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- ✅ `internal/task/polling_handler.go` — 已修改并提交
|
||||
- ✅ `internal/model/iot_card.go` — 已修改并提交
|
||||
- ✅ `internal/service/client_order/service.go` — 已修改并提交
|
||||
- ✅ commit `1fd0072` — 存在(Task 1)
|
||||
- ✅ commit `da50a35` — 存在(Task 2)
|
||||
225
.planning/phases/01-p0/01-02-PLAN.md
Normal file
225
.planning/phases/01-p0/01-02-PLAN.md
Normal file
@@ -0,0 +1,225 @@
|
||||
---
|
||||
phase: 01-p0
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- 01-01
|
||||
files_modified:
|
||||
- internal/task/polling_handler.go
|
||||
- pkg/queue/handler.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- CRITICAL-03
|
||||
must_haves:
|
||||
truths:
|
||||
- "PollingHandler struct 有 asynqClient *asynq.Client 字段"
|
||||
- "NewPollingHandler 构造函数接受 asynqClient 参数"
|
||||
- "triggerFirstRealnameActivation 使用 asynqClient.EnqueueContext 入队,不再 RPush"
|
||||
- "_ = task 这行被删除"
|
||||
- "registerPollingHandlers 传入 h.asynqClient 给 NewPollingHandler"
|
||||
artifacts:
|
||||
- path: internal/task/polling_handler.go
|
||||
provides: "PollingHandler 注入 asynq.Client,triggerFirstRealnameActivation 正确入队"
|
||||
contains: "asynqClient.EnqueueContext"
|
||||
- path: pkg/queue/handler.go
|
||||
provides: "registerPollingHandlers 传入 asynqClient"
|
||||
contains: "h.asynqClient"
|
||||
key_links:
|
||||
- from: pkg/queue/handler.go
|
||||
to: internal/task/polling_handler.go
|
||||
via: "NewPollingHandler(h.db, h.redis, h.asynqClient, ...)"
|
||||
pattern: "NewPollingHandler"
|
||||
- from: internal/task/polling_handler.go
|
||||
to: asynq.Client
|
||||
via: "h.asynqClient.EnqueueContext(ctx, task)"
|
||||
pattern: "asynqClient\\.EnqueueContext"
|
||||
---
|
||||
|
||||
<objective>
|
||||
修复实名激活任务断链(CRITICAL-03):为 PollingHandler 注入 asynq.Client,删除 RPush 降级方案,改用正式 Asynq 入队。
|
||||
|
||||
Purpose: 首次实名后自动激活"囤货套餐"的完整链路当前完全断裂——task 被 `_ = task` 丢弃,实际走 Redis RPush 降级,但无消费者消费这个列表。修复后套餐激活任务正常进入 Asynq 队列被消费。
|
||||
|
||||
Output:
|
||||
- polling_handler.go: 新增 asynqClient 字段,triggerFirstRealnameActivation 改为 EnqueueContext
|
||||
- pkg/queue/handler.go: registerPollingHandlers 传入 h.asynqClient
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/01-p0/01-01-SUMMARY.md
|
||||
@.planning/phases/01-p0/01-CONTEXT.md
|
||||
|
||||
# 修复规格书(方案 A-3,第 165-299 行)
|
||||
@.sisyphus/plans/修正业务-完整方案.md
|
||||
|
||||
<interfaces>
|
||||
<!-- pkg/queue/handler.go Handler struct 已有 asynqClient 字段 -->
|
||||
```go
|
||||
// pkg/queue/handler.go Handler struct(已存在)
|
||||
type Handler struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
asynqClient *asynq.Client // ← 已有此字段,直接传给 NewPollingHandler
|
||||
// ...
|
||||
}
|
||||
|
||||
// registerPollingHandlers 当前调用(需修改)
|
||||
pollingHandler := task.NewPollingHandler(
|
||||
h.db,
|
||||
h.redis,
|
||||
// h.asynqClient ← 缺少此行
|
||||
h.gatewayClient,
|
||||
h.workerResult.Services.UsageService,
|
||||
h.logger,
|
||||
)
|
||||
```
|
||||
|
||||
<!-- polling_handler.go 当前错误代码(triggerFirstRealnameActivation 第 1100 行) -->
|
||||
```go
|
||||
// 当前降级代码(需删除 RPush 部分,直接用 EnqueueContext)
|
||||
task := asynq.NewTask(constants.TaskTypePackageFirstActivation, payloadBytes, ...)
|
||||
|
||||
activationKey := constants.RedisPollingManualQueueKey(constants.TaskTypePackageFirstActivation)
|
||||
h.redis.RPush(ctx, activationKey, string(payloadBytes)) // ← 删掉
|
||||
|
||||
_ = task // ← 删掉
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: PollingHandler 注入 asynqClient(CRITICAL-03)</name>
|
||||
<files>internal/task/polling_handler.go, pkg/queue/handler.go</files>
|
||||
<action>
|
||||
**步骤 1:修改 `internal/task/polling_handler.go` struct 定义**
|
||||
|
||||
在 PollingHandler struct(第 31-42 行附近)新增字段:
|
||||
```go
|
||||
type PollingHandler struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
asynqClient *asynq.Client // 新增
|
||||
gatewayClient *gateway.Client
|
||||
// ... 其余字段不变
|
||||
}
|
||||
```
|
||||
|
||||
**步骤 2:修改 NewPollingHandler 构造函数签名(第 44 行附近)**
|
||||
|
||||
新增 `asynqClient *asynq.Client` 参数(位置:redis 之后,gatewayClient 之前):
|
||||
```go
|
||||
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,
|
||||
// ... 其余字段不变
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**步骤 3:修改 triggerFirstRealnameActivation(第 1100 行附近)**
|
||||
|
||||
删除 RPush 降级代码,改为 EnqueueContext:
|
||||
```go
|
||||
// 删除这两行:
|
||||
// activationKey := constants.RedisPollingManualQueueKey(constants.TaskTypePackageFirstActivation)
|
||||
// h.redis.RPush(ctx, activationKey, string(payloadBytes))
|
||||
// _ = task
|
||||
|
||||
// 替换为:
|
||||
if _, err := h.asynqClient.EnqueueContext(ctx, task); err != nil {
|
||||
h.logger.Warn("提交首次实名激活任务失败",
|
||||
zap.Uint("package_usage_id", pkg.ID),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
```
|
||||
|
||||
注意:`task` 变量已在上方声明,不需要重新创建。
|
||||
|
||||
**步骤 4:修改 `pkg/queue/handler.go:registerPollingHandlers`(第 151 行附近)**
|
||||
|
||||
在 NewPollingHandler 调用中补充 `h.asynqClient` 参数:
|
||||
```go
|
||||
pollingHandler := task.NewPollingHandler(
|
||||
h.db,
|
||||
h.redis,
|
||||
h.asynqClient, // 新增,Handler 结构体已有此字段
|
||||
h.gatewayClient,
|
||||
h.workerResult.Services.UsageService,
|
||||
h.logger,
|
||||
)
|
||||
```
|
||||
|
||||
修改完成后执行 `go build ./...`,确认编译通过,提交独立 commit(per D-05)。
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./... && rg "_ = task" internal/task/polling_handler.go && echo "FAIL: _ = task 仍存在" || echo "PASS: _ = task 已删除"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- PollingHandler struct 含 asynqClient 字段
|
||||
- NewPollingHandler 接受 asynqClient 参数
|
||||
- triggerFirstRealnameActivation 使用 asynqClient.EnqueueContext,不再 RPush
|
||||
- _ = task 这行已被删除
|
||||
- pkg/queue/handler.go 传入 h.asynqClient
|
||||
- go build ./... 编译通过
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
整体验收:
|
||||
|
||||
```bash
|
||||
# 1. 编译验证
|
||||
go build ./...
|
||||
|
||||
# 2. 确认降级代码已删除
|
||||
rg "_ = task" internal/task/polling_handler.go
|
||||
rg "RPush.*TaskTypePackageFirstActivation\|RedisPollingManualQueueKey" internal/task/polling_handler.go
|
||||
|
||||
# 3. 确认正确入队代码存在
|
||||
rg "asynqClient.EnqueueContext" internal/task/polling_handler.go
|
||||
|
||||
# 4. 确认 registerPollingHandlers 传了 asynqClient
|
||||
rg "NewPollingHandler" pkg/queue/handler.go
|
||||
```
|
||||
|
||||
人工验收(参见修正业务-完整方案.md A-3 验收清单):
|
||||
- Worker 日志:首次实名触发时应出现实名激活任务入队日志
|
||||
- DBHub:`SELECT id, status, pending_realname_activation, activated_at FROM tb_package_usage WHERE pending_realname_activation=true ORDER BY id DESC LIMIT 20;`
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. `go build ./...` 编译通过
|
||||
2. PollingHandler 有 asynqClient 字段且被正确初始化
|
||||
3. triggerFirstRealnameActivation 使用 EnqueueContext,不再 RPush
|
||||
4. `_ = task` 行不存在
|
||||
5. 独立 commit 已提交
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
完成后创建 `.planning/phases/01-p0/01-02-SUMMARY.md`,记录:
|
||||
- 修改文件及改动说明
|
||||
- 关键代码对比(修改前后)
|
||||
- 编译验证结果
|
||||
- CRITICAL-03 完成状态
|
||||
</output>
|
||||
131
.planning/phases/01-p0/01-02-SUMMARY.md
Normal file
131
.planning/phases/01-p0/01-02-SUMMARY.md
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
phase: 01-p0
|
||||
plan: 02
|
||||
subsystem: task/polling
|
||||
tags: [bug-fix, asynq, realname-activation, critical]
|
||||
one_liner: "修复 PollingHandler 缺失 asynq.Client 导致实名激活任务进入 RPush 降级而非 Asynq 队列的断链问题"
|
||||
dependency_graph:
|
||||
requires:
|
||||
- 01-01 (CRITICAL-02 实名状态常量统一)
|
||||
provides:
|
||||
- CRITICAL-03: PollingHandler 正确通过 Asynq 入队实名激活任务
|
||||
affects:
|
||||
- internal/task/polling_handler.go
|
||||
- pkg/queue/handler.go
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- "依赖注入:通过构造函数参数传入 asynq.Client"
|
||||
- "asynq.Client.EnqueueContext 正式入队"
|
||||
key_files:
|
||||
modified:
|
||||
- internal/task/polling_handler.go
|
||||
- pkg/queue/handler.go
|
||||
decisions:
|
||||
- "删除 RPush 降级方案及 _ = task 废弃代码,直接使用 EnqueueContext 入队"
|
||||
- "asynqClient 参数位置:redis 之后、gatewayClient 之前,与其他 Handler 构造函数风格一致"
|
||||
metrics:
|
||||
duration: "3min"
|
||||
completed: "2026-03-27"
|
||||
tasks: 1
|
||||
files: 2
|
||||
requirements:
|
||||
- CRITICAL-03
|
||||
---
|
||||
|
||||
# Phase 01 Plan 02: PollingHandler 注入 asynq.Client 修复实名激活断链 Summary
|
||||
|
||||
## Objective
|
||||
|
||||
修复 CRITICAL-03:`triggerFirstRealnameActivation` 因缺少 `asynq.Client` 注入,降级使用 Redis RPush 将任务写入无消费者的列表,导致首次实名后套餐激活任务永远不被消费。
|
||||
|
||||
## What Was Built
|
||||
|
||||
### 修改文件
|
||||
|
||||
**`internal/task/polling_handler.go`**
|
||||
|
||||
1. `PollingHandler` struct 新增 `asynqClient *asynq.Client` 字段
|
||||
2. `NewPollingHandler` 构造函数新增 `asynqClient *asynq.Client` 参数(位置:redis 之后,gatewayClient 之前)
|
||||
3. `triggerFirstRealnameActivation` 改为 `h.asynqClient.EnqueueContext(ctx, task)`,删除 RPush 降级代码和 `_ = task`
|
||||
|
||||
**`pkg/queue/handler.go`**
|
||||
|
||||
`registerPollingHandlers` 调用 `task.NewPollingHandler` 时补充 `h.asynqClient` 参数(Handler struct 已有此字段,直接传入)
|
||||
|
||||
### 关键代码对比
|
||||
|
||||
**修改前(降级代码):**
|
||||
|
||||
```go
|
||||
// polling_handler.go
|
||||
type PollingHandler struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
// asynqClient 缺失
|
||||
gatewayClient *gateway.Client
|
||||
...
|
||||
}
|
||||
|
||||
// triggerFirstRealnameActivation 内
|
||||
task := asynq.NewTask(...)
|
||||
activationKey := constants.RedisPollingManualQueueKey(constants.TaskTypePackageFirstActivation)
|
||||
h.redis.RPush(ctx, activationKey, string(payloadBytes)) // 写入无消费者的列表
|
||||
_ = task // task 被丢弃
|
||||
```
|
||||
|
||||
**修改后(正确入队):**
|
||||
|
||||
```go
|
||||
// polling_handler.go
|
||||
type PollingHandler struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
asynqClient *asynq.Client // 新增,用于提交 Asynq 任务
|
||||
gatewayClient *gateway.Client
|
||||
...
|
||||
}
|
||||
|
||||
// triggerFirstRealnameActivation 内
|
||||
task := asynq.NewTask(...)
|
||||
if _, err := h.asynqClient.EnqueueContext(ctx, task); err != nil {
|
||||
h.logger.Warn("提交首次实名激活任务失败", ...)
|
||||
continue
|
||||
}
|
||||
```
|
||||
|
||||
## Verification Results
|
||||
|
||||
```
|
||||
✅ go build ./... — 编译通过
|
||||
✅ rg "_ = task" internal/task/polling_handler.go → PASS: _ = task 已删除
|
||||
✅ rg "asynqClient.EnqueueContext" internal/task/polling_handler.go → PASS: EnqueueContext 已存在
|
||||
✅ registerPollingHandlers 传入 h.asynqClient 参数
|
||||
```
|
||||
|
||||
## Commits
|
||||
|
||||
| Task | Commit | Files |
|
||||
|------|--------|-------|
|
||||
| Task 1: PollingHandler 注入 asynqClient(CRITICAL-03)| `20b4b6d` | internal/task/polling_handler.go, pkg/queue/handler.go |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None - 计划执行完全按规格书执行,无偏差。
|
||||
|
||||
## CRITICAL-03 完成状态
|
||||
|
||||
✅ **CRITICAL-03 已修复**
|
||||
|
||||
- PollingHandler struct 含 asynqClient 字段 ✅
|
||||
- NewPollingHandler 接受 asynqClient 参数 ✅
|
||||
- triggerFirstRealnameActivation 使用 EnqueueContext,不再 RPush ✅
|
||||
- `_ = task` 已删除 ✅
|
||||
- pkg/queue/handler.go 传入 h.asynqClient ✅
|
||||
- go build ./... 编译通过 ✅
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `internal/task/polling_handler.go` — FOUND ✅
|
||||
- `pkg/queue/handler.go` — FOUND ✅
|
||||
- Commit `20b4b6d` — FOUND ✅
|
||||
314
.planning/phases/01-p0/01-03-PLAN.md
Normal file
314
.planning/phases/01-p0/01-03-PLAN.md
Normal file
@@ -0,0 +1,314 @@
|
||||
---
|
||||
phase: 01-p0
|
||||
plan: 03
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- 01-01
|
||||
files_modified:
|
||||
- internal/service/recharge/service.go
|
||||
- internal/bootstrap/services.go
|
||||
- pkg/queue/handler.go
|
||||
- internal/task/auto_purchase.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- CRITICAL-04
|
||||
- CRITICAL-05
|
||||
must_haves:
|
||||
truths:
|
||||
- "recharge.Service struct 有 queueClient *queue.Client 字段"
|
||||
- "HandlePaymentCallback 在充值成功后,若 LinkedPackageIDs 非空,入队 TaskTypeAutoPurchaseAfterRecharge"
|
||||
- "AutoPurchaseHandler struct 有 asynqClient *asynq.Client 字段"
|
||||
- "AutoPurchaseHandler.ProcessTask 事务提交成功后入队 TaskTypeCommission"
|
||||
- "pkg/queue/handler.go 注册了 AutoPurchaseHandler(registerAutoPurchaseHandler 被调用)"
|
||||
- "internal/bootstrap/services.go 的 recharge.New() 传入 queueClient 参数"
|
||||
artifacts:
|
||||
- path: internal/service/recharge/service.go
|
||||
provides: "queueClient 注入,HandlePaymentCallback 触发自动购包"
|
||||
contains: "queueClient.EnqueueTask"
|
||||
- path: pkg/queue/handler.go
|
||||
provides: "registerAutoPurchaseHandler 方法和注册调用"
|
||||
contains: "registerAutoPurchaseHandler"
|
||||
- path: internal/task/auto_purchase.go
|
||||
provides: "asynqClient 注入,ProcessTask 触发佣金"
|
||||
contains: "TaskTypeCommission"
|
||||
- path: internal/bootstrap/services.go
|
||||
provides: "recharge.New() 传入 queueClient"
|
||||
contains: "queueClient"
|
||||
key_links:
|
||||
- from: internal/service/recharge/service.go
|
||||
to: pkg/queue/handler.go
|
||||
via: "queueClient.EnqueueTask(TaskTypeAutoPurchaseAfterRecharge)"
|
||||
pattern: "TaskTypeAutoPurchaseAfterRecharge"
|
||||
- from: internal/task/auto_purchase.go
|
||||
to: asynq.Client
|
||||
via: "h.asynqClient.EnqueueContext(ctx, commissionTask)"
|
||||
pattern: "TaskTypeCommission"
|
||||
---
|
||||
|
||||
<objective>
|
||||
修复充值回调后自动购包触发(CRITICAL-04)和自动购包后佣金链补全(CRITICAL-05)。
|
||||
|
||||
Purpose: C 端"强充绑套餐"功能当前完全断裂——充值成功后无任何代码触发自动购包任务;即使购包完成,佣金链也不会触发。本 Plan 打通完整链路:充值回调 → 自动购包任务入队 → 执行购包 → 佣金计算任务入队。
|
||||
|
||||
Output:
|
||||
- recharge/service.go: 注入 queueClient,HandlePaymentCallback 触发自动购包入队
|
||||
- auto_purchase.go: 注入 asynqClient,ProcessTask 事务成功后触发佣金入队
|
||||
- pkg/queue/handler.go: 注册 AutoPurchaseHandler
|
||||
- bootstrap/services.go: recharge.New() 传入 queueClient
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/01-p0/01-01-SUMMARY.md
|
||||
@.planning/phases/01-p0/01-CONTEXT.md
|
||||
|
||||
# 修复规格书(方案 A-4,第 301-575 行)
|
||||
@.sisyphus/plans/修正业务-完整方案.md
|
||||
|
||||
<interfaces>
|
||||
<!-- 关键事实(来自规格书和 CONTEXT.md) -->
|
||||
<!--
|
||||
- pkg/queue/Client 有 EnqueueTask(ctx, taskType, payload, opts...) 方法 (pkg/queue/client.go:38)
|
||||
适合 Service 层调用(自动 Marshal)
|
||||
- pkg/queue/handler.go Handler struct 持有 asynqClient *asynq.Client
|
||||
适合 Worker 任务处理器使用(直接 EnqueueContext)
|
||||
- TaskTypeAutoPurchaseAfterRecharge = "task:auto_purchase_after_recharge" 常量已存在
|
||||
- AutoPurchaseHandler 已在 internal/task/auto_purchase.go,但未在 handler.go 注册
|
||||
- QueueClient 已在 internal/bootstrap/dependencies.go 定义,已传给 Order/IotCardImport/DeviceImport
|
||||
- AssetRechargeRecord.LinkedPackageIDs 字段:internal/model/asset_wallet.go:91(JSONB)
|
||||
-->
|
||||
|
||||
<!-- recharge.Service 当前结构(需新增 queueClient) -->
|
||||
```go
|
||||
// internal/service/recharge/service.go(当前,无 queueClient)
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
// ... 其他字段
|
||||
}
|
||||
|
||||
func New(db *gorm.DB, /* ... */) *Service {
|
||||
return &Service{db: db, /* ... */}
|
||||
}
|
||||
```
|
||||
|
||||
<!-- auto_purchase.go NewAutoPurchaseHandler 当前签名(需新增 asynqClient) -->
|
||||
<!-- 注意:规格书里的 nil 列表可能与当前签名不完全一致 -->
|
||||
<!-- 务必先读实际文件确认参数列表,再决定如何传入 asynqClient -->
|
||||
|
||||
<!-- 常量(直接使用) -->
|
||||
```go
|
||||
const (
|
||||
TaskTypeAutoPurchaseAfterRecharge = "task:auto_purchase_after_recharge"
|
||||
TaskTypeCommission = "..." // 已存在,从 pkg/constants/constants.go 确认
|
||||
QueueDefault = "..."
|
||||
QueueLow = "..." // 佣金任务用低优先级队列
|
||||
)
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: recharge.Service 注入 queueClient,触发自动购包(CRITICAL-04 Part 1)</name>
|
||||
<files>internal/service/recharge/service.go, internal/bootstrap/services.go</files>
|
||||
<action>
|
||||
**步骤 1:先读 `internal/service/recharge/service.go` 确认当前 struct 和 New() 参数列表。**
|
||||
|
||||
在 Service struct 新增字段:
|
||||
```go
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
// ... 其余字段不变 ...
|
||||
queueClient *queue.Client // 新增
|
||||
}
|
||||
```
|
||||
|
||||
在 New() 新增参数(位于最后):
|
||||
```go
|
||||
func New(
|
||||
db *gorm.DB,
|
||||
// ... 其余参数 ...
|
||||
queueClient *queue.Client, // 新增
|
||||
) *Service {
|
||||
return &Service{
|
||||
// ... 其余字段 ...
|
||||
queueClient: queueClient,
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
确认 import 中有 `"pkgpath/pkg/queue"`(按实际包路径补充)。
|
||||
|
||||
**步骤 2:在 HandlePaymentCallback 充值成功后添加自动购包入队(第 272 行附近)**
|
||||
|
||||
在事务成功返回、记录充值成功日志之前,添加:
|
||||
```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))
|
||||
// 不影响充值成功,仅记录日志
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**步骤 3:修改 `internal/bootstrap/services.go`**
|
||||
|
||||
找到 `recharge.New(...)` 调用位置(执行 `rg "recharge.New\|recharge\.New" internal/bootstrap/`),在参数列表末尾补充 `deps.QueueClient`:
|
||||
```go
|
||||
rechargeSvc := recharge.New(
|
||||
deps.DB,
|
||||
// ... 其他已有参数 ...
|
||||
deps.QueueClient, // 新增
|
||||
)
|
||||
```
|
||||
|
||||
`QueueClient` 已在 `dependencies.go:24` 定义,无需新增字段。
|
||||
|
||||
完成后 `go build ./...`,提交 commit(per D-05)。
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./... && rg "queueClient" internal/service/recharge/service.go | grep -c "queueClient" | grep -v "^0$" && echo "PASS: queueClient 已注入" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- recharge.Service struct 含 queueClient 字段
|
||||
- HandlePaymentCallback 在充值成功、LinkedPackageIDs 非空时触发入队
|
||||
- bootstrap/services.go 的 recharge.New() 传入 deps.QueueClient
|
||||
- go build ./... 编译通过
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: AutoPurchaseHandler 注入 asynqClient + 注册 + 触发佣金(CRITICAL-04 Part 2 + CRITICAL-05)</name>
|
||||
<files>internal/task/auto_purchase.go, pkg/queue/handler.go</files>
|
||||
<action>
|
||||
**步骤 1:先读 `internal/task/auto_purchase.go` 确认 AutoPurchaseHandler struct 和 NewAutoPurchaseHandler 的真实参数列表。**
|
||||
|
||||
在 AutoPurchaseHandler struct 新增字段:
|
||||
```go
|
||||
type AutoPurchaseHandler struct {
|
||||
// ... 已有字段 ...
|
||||
asynqClient *asynq.Client // 新增,用于事务后触发佣金任务
|
||||
}
|
||||
```
|
||||
|
||||
在 NewAutoPurchaseHandler 函数签名中新增 `asynqClient *asynq.Client` 参数(位于 logger 参数之前),并在返回时赋值 `asynqClient: asynqClient`。
|
||||
|
||||
**⚠️ 重要:** 不要盲目照搬规格书的 nil 列表。必须按实际签名决定在哪里插入 `asynqClient`。
|
||||
|
||||
**步骤 2:修改 ProcessTask,事务成功后触发佣金(CRITICAL-05)**
|
||||
|
||||
在 `ProcessTask()` 函数中:
|
||||
① 在事务前声明 `var createdOrderID uint`
|
||||
② 在事务内创建 order 后赋值:`createdOrderID = order.ID`
|
||||
③ 事务提交成功 `return nil` 后,添加佣金任务入队:
|
||||
```go
|
||||
// 事务提交成功后,触发佣金计算(不在事务内,防止任务提交后事务回滚的数据一致性问题)
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
注意:使用 `sonic.Marshal`(项目规范,不用 `encoding/json`)。
|
||||
|
||||
**步骤 3:修改 `pkg/queue/handler.go` 注册 AutoPurchaseHandler**
|
||||
|
||||
新增方法 `registerAutoPurchaseHandler()`,并在 `RegisterHandlers()` 中调用:
|
||||
```go
|
||||
func (h *Handler) registerAutoPurchaseHandler() {
|
||||
autoPurchaseHandler := task.NewAutoPurchaseHandler(
|
||||
// 按 auto_purchase.go 实际签名传参
|
||||
// 在合适位置传 h.asynqClient
|
||||
// 其他 store 参数若已有传参规律则沿用;若为 nil 则查看其他 handler 的注册方式
|
||||
)
|
||||
h.mux.HandleFunc(constants.TaskTypeAutoPurchaseAfterRecharge, autoPurchaseHandler.ProcessTask)
|
||||
h.logger.Info("注册自动购包任务处理器", zap.String("task_type", constants.TaskTypeAutoPurchaseAfterRecharge))
|
||||
}
|
||||
```
|
||||
|
||||
在 `RegisterHandlers()` 中调用:`h.registerAutoPurchaseHandler()`
|
||||
|
||||
**参考现有 handler 注册**:先读 `pkg/queue/handler.go` 中其他 register 方法(如 registerCommissionHandler)确认 store 参数传递规律。
|
||||
|
||||
完成后 `go build ./...`,提交 commit(per D-05)。
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./... && rg "TaskTypeAutoPurchaseAfterRecharge" pkg/queue/handler.go | grep "HandleFunc" && echo "PASS: AutoPurchaseHandler 已注册" || echo "FAIL: 未注册"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- AutoPurchaseHandler struct 含 asynqClient 字段
|
||||
- ProcessTask 事务成功后入队 TaskTypeCommission(不在事务内)
|
||||
- pkg/queue/handler.go 注册了 AutoPurchaseHandler
|
||||
- RegisterHandlers() 调用了 registerAutoPurchaseHandler()
|
||||
- go build ./... 编译通过
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
整体验收:
|
||||
|
||||
```bash
|
||||
# 1. 编译验证
|
||||
go build ./...
|
||||
|
||||
# 2. 确认入队代码存在
|
||||
rg "TaskTypeAutoPurchaseAfterRecharge" internal/service/recharge/service.go
|
||||
rg "TaskTypeCommission" internal/task/auto_purchase.go
|
||||
rg "registerAutoPurchaseHandler\|TaskTypeAutoPurchaseAfterRecharge" pkg/queue/handler.go
|
||||
|
||||
# 3. 确认 bootstrap 传入了 queueClient
|
||||
rg "recharge.New\|recharge\.New" internal/bootstrap/services.go
|
||||
```
|
||||
|
||||
人工验收(参见修正业务-完整方案.md A-4 验收清单):
|
||||
- DBHub 验证顺序:tb_asset_recharge_record(auto_purchase_status=success) → tb_order(自动购包订单) → tb_package_usage(套餐激活) → tb_commission_record(佣金记录)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. `go build ./...` 编译通过,无错误
|
||||
2. recharge.Service.HandlePaymentCallback 在 LinkedPackageIDs 非空时触发自动购包入队
|
||||
3. AutoPurchaseHandler.ProcessTask 事务成功后触发佣金入队(事务外)
|
||||
4. AutoPurchaseHandler 在 RegisterHandlers 中注册
|
||||
5. bootstrap/services.go recharge.New() 传入 queueClient
|
||||
6. 两个独立 commit 已提交(CRITICAL-04 一个,CRITICAL-05 一个)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
完成后创建 `.planning/phases/01-p0/01-03-SUMMARY.md`,记录:
|
||||
- 修改文件及改动说明
|
||||
- 关键代码路径(充值回调 → 入队 → 消费 → 佣金)
|
||||
- 编译验证结果
|
||||
- CRITICAL-04 和 CRITICAL-05 完成状态
|
||||
</output>
|
||||
132
.planning/phases/01-p0/01-03-SUMMARY.md
Normal file
132
.planning/phases/01-p0/01-03-SUMMARY.md
Normal file
@@ -0,0 +1,132 @@
|
||||
---
|
||||
phase: 01-p0
|
||||
plan: 03
|
||||
subsystem: 充值回调链路 / 自动购包任务
|
||||
tags: [bug-fix, queue, asynq, recharge, commission, auto-purchase]
|
||||
dependency_graph:
|
||||
requires: [01-01]
|
||||
provides: [充值回调→自动购包入队, 自动购包→佣金任务入队, AutoPurchaseHandler注册]
|
||||
affects: [internal/service/recharge, internal/task, pkg/queue]
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns: [asynq任务入队, 事务外触发异步任务, 依赖注入]
|
||||
key_files:
|
||||
created: []
|
||||
modified:
|
||||
- internal/service/recharge/service.go
|
||||
- internal/bootstrap/services.go
|
||||
- internal/task/auto_purchase.go
|
||||
- pkg/queue/handler.go
|
||||
decisions:
|
||||
- "佣金任务在事务提交成功后入队(事务外),防止任务提交后事务回滚引发数据一致性问题"
|
||||
- "自动购包入队失败不影响充值成功,仅记录 Error 日志"
|
||||
- "AutoPurchaseHandler 的 AssetRechargeStore / AssetWalletTransactionStore 传 nil,由 NewAutoPurchaseHandler 内部按需初始化"
|
||||
metrics:
|
||||
duration: "~8 分钟"
|
||||
completed_date: "2026-03-27"
|
||||
tasks: 2
|
||||
files_changed: 4
|
||||
---
|
||||
|
||||
# Phase 01 Plan 03: 充值回调触发自动购包 + 佣金链补全 Summary
|
||||
|
||||
打通完整链路:充值回调 → 自动购包任务入队 → 执行购包 → 佣金计算任务入队(CRITICAL-04 + CRITICAL-05)。
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
|------|------|--------|-------|
|
||||
| 1 | recharge.Service 注入 queueClient,触发自动购包(CRITICAL-04 Part 1) | 9a4d87a | service/recharge/service.go, bootstrap/services.go |
|
||||
| 2 | AutoPurchaseHandler 注入 asynqClient + 注册 + 触发佣金(CRITICAL-04 Part 2 + CRITICAL-05) | 030425b | task/auto_purchase.go, pkg/queue/handler.go |
|
||||
|
||||
## Changes Summary
|
||||
|
||||
### Task 1 — CRITICAL-04 Part 1
|
||||
|
||||
**`internal/service/recharge/service.go`**
|
||||
- `Service` struct 新增字段:`queueClient *queue.Client`
|
||||
- `New()` 函数末尾新增参数:`queueClient *queue.Client`
|
||||
- `HandlePaymentCallback` 事务成功后,检查 `LinkedPackageIDs` 非空时,调用 `queueClient.EnqueueTask(TaskTypeAutoPurchaseAfterRecharge)`
|
||||
- 入队失败不影响充值成功,仅记录 Error 日志
|
||||
|
||||
**`internal/bootstrap/services.go`**
|
||||
- `rechargeSvc.New(...)` 末尾追加 `deps.QueueClient` 参数
|
||||
|
||||
### Task 2 — CRITICAL-04 Part 2 + CRITICAL-05
|
||||
|
||||
**`internal/task/auto_purchase.go`**
|
||||
- `AutoPurchaseHandler` struct 新增字段:`asynqClient *asynq.Client`
|
||||
- `NewAutoPurchaseHandler()` 在 `redisClient` 参数之后新增 `asynqClient *asynq.Client`
|
||||
- `ProcessTask` 事务前声明 `var createdOrderID uint`,事务内 `tx.Create(order)` 成功后赋值
|
||||
- 事务提交成功后(事务外)入队 `TaskTypeCommission`(QueueLow,MaxRetry=3)
|
||||
- 使用 `sonic.Marshal` 序列化(符合项目规范)
|
||||
- 入队失败仅 Warn 日志,不影响购包结果
|
||||
|
||||
**`pkg/queue/handler.go`**
|
||||
- `RegisterHandlers()` 末尾调用 `h.registerAutoPurchaseHandler()`
|
||||
- 新增方法 `registerAutoPurchaseHandler()`:
|
||||
- 调用 `task.NewAutoPurchaseHandler(...)` 传入 `h.asynqClient`
|
||||
- `h.mux.HandleFunc(TaskTypeAutoPurchaseAfterRecharge, ...)`
|
||||
|
||||
## 关键代码路径
|
||||
|
||||
```
|
||||
个人客户充值微信支付回调
|
||||
└─ recharge.HandlePaymentCallback()
|
||||
├─ 事务:更新状态、增加余额、触发一次性佣金
|
||||
└─ 事务成功后:
|
||||
└─ queueClient.EnqueueTask(TaskTypeAutoPurchaseAfterRecharge, {recharge_id})
|
||||
└─ Worker: AutoPurchaseHandler.ProcessTask()
|
||||
├─ 扣款、创建订单、激活套餐(事务内)
|
||||
└─ 事务成功后:
|
||||
└─ asynqClient.EnqueueContext(TaskTypeCommission, {order_id})
|
||||
└─ Worker: CommissionCalculationHandler.HandleCommissionCalculation()
|
||||
```
|
||||
|
||||
## Verification Results
|
||||
|
||||
```bash
|
||||
# 编译验证
|
||||
go build ./... → 通过,无错误
|
||||
|
||||
# 入队代码存在
|
||||
rg "TaskTypeAutoPurchaseAfterRecharge" internal/service/recharge/service.go
|
||||
→ s.queueClient.EnqueueTask(ctx, constants.TaskTypeAutoPurchaseAfterRecharge, ...)
|
||||
|
||||
rg "TaskTypeCommission" internal/task/auto_purchase.go
|
||||
→ asynq.NewTask(constants.TaskTypeCommission, ...)
|
||||
|
||||
# handler 注册
|
||||
rg "registerAutoPurchaseHandler|TaskTypeAutoPurchaseAfterRecharge" pkg/queue/handler.go
|
||||
→ h.registerAutoPurchaseHandler()
|
||||
→ h.mux.HandleFunc(constants.TaskTypeAutoPurchaseAfterRecharge, ...)
|
||||
|
||||
# bootstrap 传入 queueClient
|
||||
rg "rechargeSvc.New" internal/bootstrap/services.go
|
||||
→ rechargeSvc.New(..., deps.QueueClient)
|
||||
```
|
||||
|
||||
## CRITICAL Requirements Status
|
||||
|
||||
| 需求 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| CRITICAL-04 | ✅ 完成 | 充值回调后自动购包入队 + AutoPurchaseHandler 注册 |
|
||||
| CRITICAL-05 | ✅ 完成 | 自动购包成功后佣金链触发(事务外入队) |
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None — 按计划执行,无偏差。
|
||||
|
||||
## Known Stubs
|
||||
|
||||
None — 所有代码路径均已实现,无占位符。
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- [x] `internal/service/recharge/service.go` 存在 ✓
|
||||
- [x] `internal/bootstrap/services.go` 存在 ✓
|
||||
- [x] `internal/task/auto_purchase.go` 存在 ✓
|
||||
- [x] `pkg/queue/handler.go` 存在 ✓
|
||||
- [x] commit 9a4d87a 存在 ✓
|
||||
- [x] commit 030425b 存在 ✓
|
||||
- [x] `go build ./...` 通过 ✓
|
||||
164
.planning/phases/01-p0/01-04-PLAN.md
Normal file
164
.planning/phases/01-p0/01-04-PLAN.md
Normal file
@@ -0,0 +1,164 @@
|
||||
---
|
||||
phase: 01-p0
|
||||
plan: 04
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- internal/service/order/service.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- CRITICAL-06
|
||||
must_haves:
|
||||
truths:
|
||||
- "场景 5 分支(代理代购下级)的 sellerCostPrice 使用 operatorCostPrice,不再是 buyerCostPrice"
|
||||
- "其他 5 处 sellerCostPrice 赋值经过语义审查,确认正确或按需修正"
|
||||
- "佣金差额计算基准正确,场景 5 的上级代理能获得应有佣金"
|
||||
artifacts:
|
||||
- path: internal/service/order/service.go
|
||||
provides: "场景 5 sellerCostPrice 修正"
|
||||
contains: "operatorCostPrice"
|
||||
key_links:
|
||||
- from: internal/service/order/service.go
|
||||
to: "佣金计算链路"
|
||||
via: "sellerCostPrice 作为佣金差额基准传递"
|
||||
pattern: "sellerCostPrice"
|
||||
---
|
||||
|
||||
<objective>
|
||||
逐一审查并修正代购订单中 sellerCostPrice 的 6 处赋值(CRITICAL-06),确保场景 5(代理代购下级)使用正确的成本价基准。
|
||||
|
||||
Purpose: sellerCostPrice 是佣金差额计算的基准。场景 5 中错误使用 buyerCostPrice(下级成本价)导致整个佣金链差额为 0,所有上级代理无法获得应有佣金。修复后代购场景佣金链正常工作。
|
||||
|
||||
Output:
|
||||
- order/service.go: 场景 5 的 sellerCostPrice 改为 operatorCostPrice,其余 5 处经语义审查确认或按需修正
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/01-p0/01-CONTEXT.md
|
||||
|
||||
# 修复规格书(方案 A-5,第 579-618 行)
|
||||
@.sisyphus/plans/修正业务-完整方案.md
|
||||
|
||||
<interfaces>
|
||||
<!-- 五种购买场景对应表(来自规格书 A-5)
|
||||
| # | 操作方 | 资产所属 | sellerCostPrice 应为 |
|
||||
|---|--------|------------|--------------------------|
|
||||
| 1 | 平台 | 平台自己客户 | N/A(线下,不计佣金) |
|
||||
| 2 | 平台 | 某代理客户 | 该代理成本价(operatorCostPrice 即代理价)|
|
||||
| 3 | 平台 | 某代理客户 | 该代理成本价(线下场景) |
|
||||
| 4 | 代理 | 自己客户 | 自己成本价(即 operatorCostPrice)|
|
||||
| 5 | 代理 | 下级代理客户 | **operatorCostPrice(自己的成本价,非下级的)** ← Bug 在此 |
|
||||
-->
|
||||
|
||||
<!-- 6 处确认位置(来自 CONTEXT.md code_context) -->
|
||||
<!--
|
||||
internal/service/order/service.go 第 187, 262, 472, 548, 734, 809 行
|
||||
其中 525-548 行是场景 5,sellerCostPrice = buyerCostPrice 是错误的
|
||||
-->
|
||||
|
||||
<!-- 审查决策依据(per D-01, D-02, D-03) -->
|
||||
<!--
|
||||
- 逐一语义审查,按场景对照表判断
|
||||
- 不做模式匹配式全量替换
|
||||
- 有些 buyerCostPrice 赋值是正确的(如场景 1/2/3/4 的某些分支)
|
||||
-->
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: 逐一审查并修正 sellerCostPrice 6 处赋值(CRITICAL-06)</name>
|
||||
<files>internal/service/order/service.go</files>
|
||||
<action>
|
||||
**步骤 1:定位 6 处赋值**
|
||||
|
||||
```bash
|
||||
rg -n "sellerCostPrice" internal/service/order/service.go
|
||||
```
|
||||
|
||||
确认规格书所述的 6 处(第 187, 262, 472, 548, 734, 809 行)是否与实际一致(行号可能有偏移)。
|
||||
|
||||
**步骤 2:对照五种场景表逐一审查**
|
||||
|
||||
对每一处 `sellerCostPrice = buyerCostPrice`,阅读其所在分支的上下文,确认:
|
||||
- 这里的"操作方"是谁?(平台/代理)
|
||||
- 资产归属是谁?(自己客户/下级代理客户)
|
||||
- 对照上方场景表,sellerCostPrice 应该是哪个变量?
|
||||
|
||||
**步骤 3:修正确认有问题的位置**
|
||||
|
||||
规格书明确指出场景 5(代理代购下级代理客户)约在第 525-548 行:
|
||||
|
||||
```go
|
||||
// 当前错误(sellerCostPrice 用了下级成本价)
|
||||
buyerCostPrice, _ := s.getCostPrice(ctx, *resourceShopID, firstPackageID) // 下级成本价
|
||||
operatorCostPrice, _ := s.getCostPrice(ctx, operatorShopID, firstPackageID) // 自己成本价
|
||||
|
||||
totalAmount = buyerCostPrice // 下级成本价作为面值(正确)
|
||||
actualPaidAmount = &operatorCostPrice // 实际扣自己钱包(正确)
|
||||
sellerCostPrice = buyerCostPrice // ← 错误!应是 operatorCostPrice
|
||||
|
||||
// 修正后
|
||||
sellerCostPrice = operatorCostPrice // ✅ 自己(代理)的成本价作为佣金差额基准
|
||||
```
|
||||
|
||||
对其余 5 处,若语义审查确认正确(即该场景下 sellerCostPrice 本就应等于 buyerCostPrice),则**不修改**,并在 SUMMARY 中记录每处审查结论。
|
||||
|
||||
**步骤 4:执行 `go build ./...`,确认编译通过,提交独立 commit(per D-05)。**
|
||||
|
||||
注意:只改确认有问题的位置,禁止全量替换(per D-03)。
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./... && rg -n "sellerCostPrice" internal/service/order/service.go</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- 6 处 sellerCostPrice 赋值全部经过语义审查
|
||||
- 场景 5 分支已将 sellerCostPrice = buyerCostPrice 改为 sellerCostPrice = operatorCostPrice
|
||||
- 其余各处审查结论已记录
|
||||
- go build ./... 编译通过
|
||||
- 独立 commit 已提交
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
整体验收:
|
||||
|
||||
```bash
|
||||
# 1. 编译验证
|
||||
go build ./...
|
||||
|
||||
# 2. 查看所有 sellerCostPrice 赋值(人工确认场景 5 已修正)
|
||||
rg -n "sellerCostPrice" internal/service/order/service.go
|
||||
|
||||
# 3. 搜索场景 5 分支(代购下级)是否仍然存在 sellerCostPrice = buyerCostPrice
|
||||
# 需人工确认第 525-548 行附近已修改
|
||||
```
|
||||
|
||||
人工验收(参见修正业务-完整方案.md A-5 验收清单):
|
||||
- DBHub 抽查"代理代购下级代理客户"的订单和佣金记录
|
||||
- 预期:tb_order.total_amount = 下级成本价,佣金链不再全部为 0
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. `go build ./...` 编译通过
|
||||
2. 场景 5(代理代购下级)sellerCostPrice = operatorCostPrice(不再是 buyerCostPrice)
|
||||
3. 其余 5 处经语义审查,结论记录在 SUMMARY 中
|
||||
4. 独立 commit 已提交
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
完成后创建 `.planning/phases/01-p0/01-04-SUMMARY.md`,记录:
|
||||
- 6 处 sellerCostPrice 逐一审查结论(行号 + 场景判断 + 是否修改)
|
||||
- 场景 5 修改前后代码对比
|
||||
- 编译验证结果
|
||||
- CRITICAL-06 完成状态
|
||||
</output>
|
||||
126
.planning/phases/01-p0/01-04-SUMMARY.md
Normal file
126
.planning/phases/01-p0/01-04-SUMMARY.md
Normal file
@@ -0,0 +1,126 @@
|
||||
---
|
||||
phase: 01-p0
|
||||
plan: "04"
|
||||
subsystem: order-service
|
||||
tags: [bug-fix, commission, seller-cost-price, critical]
|
||||
dependency_graph:
|
||||
requires: []
|
||||
provides: [CRITICAL-06]
|
||||
affects: [commission-calculation]
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns: [semantic-review]
|
||||
key_files:
|
||||
created: []
|
||||
modified:
|
||||
- internal/service/order/service.go
|
||||
decisions:
|
||||
- "逐一语义审查 6 处 sellerCostPrice 赋值,仅修改场景 5(代理代购下级)三处,其余场景确认正确"
|
||||
- "scene5 赋值从 buyerCostPrice 改为 operatorCostPrice(操作方成本价),佣金差额计算基准正确"
|
||||
metrics:
|
||||
duration: "4min"
|
||||
completed_date: "2026-03-27"
|
||||
tasks_completed: 1
|
||||
files_changed: 1
|
||||
requirements: [CRITICAL-06]
|
||||
---
|
||||
|
||||
# Phase 01 Plan 04: sellerCostPrice 修正(CRITICAL-06)Summary
|
||||
|
||||
**一句话总结:** 修正三个下单函数中代理代购下级场景(scene 5)的 sellerCostPrice 赋值,由 buyerCostPrice(下级成本价)改为 operatorCostPrice(操作方自己成本价),佣金链差额计算恢复正常。
|
||||
|
||||
---
|
||||
|
||||
## 任务完成情况
|
||||
|
||||
| 任务 | 名称 | Commit | 状态 |
|
||||
|------|------|--------|------|
|
||||
| 1 | 逐一审查并修正 sellerCostPrice 6 处赋值 | f74b7da | ✅ 完成 |
|
||||
|
||||
---
|
||||
|
||||
## sellerCostPrice 6 处逐一审查结论
|
||||
|
||||
| # | 行号(修改后) | 所在函数/场景 | 操作方 | 资产归属 | 赋值 | 结论 |
|
||||
|---|-------------|-------------|--------|---------|------|------|
|
||||
| 1 | ~187 | 函数1(普通下单)/ 场景1:平台offline代购 | 平台 | 代理客户 | `buyerCostPrice`(代理成本价) | ✅ 正确:sellerCostPrice = 代理方成本价 |
|
||||
| 2 | ~234 | 函数1 / 子场景2.1:代理自购 | 代理 | 自己 | `costPrice`(自己成本价) | ✅ 正确:sellerCostPrice = 自己成本价 |
|
||||
| 3 | ~264 | 函数1 / 子场景2.2:**代理代购下级** | 代理 | 下级代理 | ~~buyerCostPrice~~ → **operatorCostPrice** | ⚠️ **已修正** |
|
||||
| 4 | ~474 | 函数2(后台下单)/ 子场景1.2:平台代购 | 平台 | 代理客户 | `buyerCostPrice`(代理成本价) | ✅ 正确:sellerCostPrice = 代理方成本价 |
|
||||
| 5 | ~551 | 函数2 / 子场景2.2:**代理代购下级** | 代理 | 下级代理 | ~~buyerCostPrice~~ → **operatorCostPrice** | ⚠️ **已修正** |
|
||||
| 6 | ~813 | 函数3(H5下单)/ 子场景2.2:**代理代购下级** | 代理 | 下级代理 | ~~buyerCostPrice~~ → **operatorCostPrice** | ⚠️ **已修正** |
|
||||
|
||||
> **注意**:规格书 A-5 提到"6 处 sellerCostPrice = buyerCostPrice",但实际代码中 `#2/#4` 已是 `costPrice`(自购场景),不是 `buyerCostPrice`。实际有 5 处 `sellerCostPrice = buyerCostPrice`(1+1+1+1+1),其中 3 处正确、3 处错误需修正。
|
||||
|
||||
---
|
||||
|
||||
## 修改前后代码对比
|
||||
|
||||
### 场景 5(三个函数相同结构,以函数1为例)
|
||||
|
||||
**修改前(错误):**
|
||||
```go
|
||||
// ==== 子场景 2.2:代理代购(给下级购买)====
|
||||
buyerCostPrice, _ := s.getCostPrice(ctx, *resourceShopID, firstPackageID) // 下级成本价
|
||||
operatorCostPrice, _ := s.getCostPrice(ctx, operatorShopID, firstPackageID) // 自己成本价
|
||||
|
||||
totalAmount = buyerCostPrice // 下级成本价作为面值(正确)
|
||||
actualPaidAmount = &operatorCostPrice // 实际扣自己钱包(正确)
|
||||
sellerCostPrice = buyerCostPrice // ← 错误!下级成本价和自己成本价相同,差额为 0
|
||||
```
|
||||
|
||||
**修改后(正确):**
|
||||
```go
|
||||
// 代理代购下级时,佣金差额基准应是操作方(代理自己)的成本价,而非下级的成本价
|
||||
sellerCostPrice = operatorCostPrice // ✅ 自己(代理)的成本价作为佣金差额基准
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 编译验证
|
||||
|
||||
```
|
||||
go build ./... → 通过(无输出,无错误)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL-06 完成状态
|
||||
|
||||
- [x] 6 处 sellerCostPrice 赋值全部经过语义审查
|
||||
- [x] 场景 5 三处(三个下单函数中各一处)已将 `sellerCostPrice = buyerCostPrice` 改为 `sellerCostPrice = operatorCostPrice`
|
||||
- [x] 其余各处审查结论已记录(见上表)
|
||||
- [x] `go build ./...` 编译通过
|
||||
- [x] 独立 commit 已提交(`f74b7da`)
|
||||
|
||||
---
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
无自动修正偏差,但有一处与规格书的差异说明:
|
||||
|
||||
**规格书描述:** "6 处 sellerCostPrice = buyerCostPrice" 需逐一审查,仅第 525-548 行的场景 5 有错。
|
||||
|
||||
**实际情况:** 代码中有三个独立的下单函数(普通/后台/H5),每个函数中都存在相同的场景 5 代理代购下级分支,各有一处错误赋值。三处全部修正,而非只修正规格书提到的一处。
|
||||
|
||||
**影响评估:** 修复更彻底,场景 5 的佣金链在三种下单入口均恢复正常。
|
||||
|
||||
---
|
||||
|
||||
## Known Stubs
|
||||
|
||||
无。
|
||||
|
||||
---
|
||||
|
||||
## Self-Check
|
||||
|
||||
- [x] `internal/service/order/service.go` — FOUND
|
||||
- [x] commit `f74b7da` — FOUND
|
||||
- [x] `sellerCostPrice = operatorCostPrice` 出现 3 次(三个函数各一处)— VERIFIED
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
### 检查修改文件
|
||||
233
.planning/phases/01-p0/01-05-PLAN.md
Normal file
233
.planning/phases/01-p0/01-05-PLAN.md
Normal file
@@ -0,0 +1,233 @@
|
||||
---
|
||||
phase: 01-p0
|
||||
plan: 05
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- pkg/utils/excel.go
|
||||
- internal/task/device_import.go
|
||||
- internal/service/my_commission/service.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- CRITICAL-07
|
||||
- CRITICAL-08
|
||||
must_haves:
|
||||
truths:
|
||||
- "DeviceRow struct 含 IMEI string 字段"
|
||||
- "ParseDeviceExcel 中表头映射支持 IMEI 列名(imei/IMEI/设备IMEI/IMEI号)"
|
||||
- "device_import.go 创建 model.Device 时赋值 IMEI: row.IMEI"
|
||||
- "my_commission/service.go 冻结余额后检查 RowsAffected == 0,不满足时返回余额不足错误"
|
||||
artifacts:
|
||||
- path: pkg/utils/excel.go
|
||||
provides: "DeviceRow 含 IMEI 字段,ParseDeviceExcel 列名映射新增 IMEI"
|
||||
contains: "IMEI"
|
||||
- path: internal/task/device_import.go
|
||||
provides: "导入时填充 IMEI 字段"
|
||||
contains: "IMEI: row.IMEI"
|
||||
- path: internal/service/my_commission/service.go
|
||||
provides: "RowsAffected 校验,并发冻结失败返回错误"
|
||||
contains: "RowsAffected == 0"
|
||||
key_links:
|
||||
- from: pkg/utils/excel.go
|
||||
to: internal/task/device_import.go
|
||||
via: "DeviceRow.IMEI → model.Device.IMEI"
|
||||
pattern: "DeviceRow"
|
||||
- from: internal/service/my_commission/service.go
|
||||
to: "tx.Model(&AgentWallet{}).Updates()"
|
||||
via: "result.RowsAffected == 0 → 返回余额不足错误"
|
||||
pattern: "RowsAffected"
|
||||
---
|
||||
|
||||
<objective>
|
||||
修复设备导入缺失 IMEI 字段(CRITICAL-07)和提现冻结并发校验缺失(CRITICAL-08)。这两个 Bug 互相独立,也与前序 Plan 无依赖,可以与 Plan 01/04 并行执行。
|
||||
|
||||
Purpose:
|
||||
- CRITICAL-07: 设备导入后 IMEI 为空,导致 Gateway 调用失败(IMEI 是调 Gateway 必填字段)
|
||||
- CRITICAL-08: 并发提现请求中,余额冻结失败(RowsAffected=0)未检测到,导致余额不足时仍创建重复提现单
|
||||
|
||||
Output:
|
||||
- excel.go: DeviceRow 新增 IMEI 字段,ParseDeviceExcel 支持 IMEI 列名
|
||||
- device_import.go: 创建 Device 时填充 IMEI
|
||||
- my_commission/service.go: RowsAffected 校验,并发失败返回错误
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/phases/01-p0/01-CONTEXT.md
|
||||
|
||||
# 修复规格书(方案 A-6 第 621-670 行,方案 A-7 第 673-705 行)
|
||||
@.sisyphus/plans/修正业务-完整方案.md
|
||||
|
||||
<interfaces>
|
||||
<!-- DeviceRow 当前结构(pkg/utils/excel.go:35-43)-->
|
||||
```go
|
||||
type DeviceRow struct {
|
||||
Line int
|
||||
VirtualNo string
|
||||
// IMEI 字段缺失 ← 需新增
|
||||
DeviceName string
|
||||
DeviceModel string
|
||||
DeviceType string
|
||||
MaxSimSlots int
|
||||
Manufacturer string
|
||||
ICCIDs []string
|
||||
}
|
||||
```
|
||||
|
||||
<!-- my_commission/service.go 当前错误代码(第 162-170 行)-->
|
||||
```go
|
||||
// 当前:只检查 Error,不检查 RowsAffected
|
||||
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 的检查
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: 设备导入补充 IMEI 字段(CRITICAL-07)</name>
|
||||
<files>pkg/utils/excel.go, internal/task/device_import.go</files>
|
||||
<action>
|
||||
**修改 `pkg/utils/excel.go`:**
|
||||
|
||||
① 在 DeviceRow struct(第 35-43 行)新增 IMEI 字段:
|
||||
```go
|
||||
type DeviceRow struct {
|
||||
Line int
|
||||
VirtualNo string
|
||||
IMEI string // 新增,对应 Excel IMEI 列
|
||||
DeviceName string
|
||||
DeviceModel string
|
||||
DeviceType string
|
||||
MaxSimSlots int
|
||||
Manufacturer string
|
||||
ICCIDs []string
|
||||
}
|
||||
```
|
||||
|
||||
② 在 `ParseDeviceExcel` 函数的表头列名映射 switch-case(约第 290-320 行)中,新增 IMEI 的 case:
|
||||
```go
|
||||
case "imei", "IMEI", "设备IMEI", "IMEI号":
|
||||
row.IMEI = cellValue
|
||||
```
|
||||
|
||||
找到现有 case 的位置(如 `case "虚拟号", "virtual_no"`),在同一 switch 结构内添加 IMEI 的 case。
|
||||
|
||||
**修改 `internal/task/device_import.go`:**
|
||||
|
||||
在 `processBatch` 函数(约第 178 行)创建 `model.Device{}` 的位置,新增 `IMEI: row.IMEI`:
|
||||
```go
|
||||
device := &model.Device{
|
||||
// ... 已有字段 ...
|
||||
IMEI: row.IMEI, // 新增
|
||||
}
|
||||
```
|
||||
|
||||
执行 `go build ./...`,编译通过后提交独立 commit(per D-05)。
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./... && rg "IMEI" pkg/utils/excel.go internal/task/device_import.go | grep -c "IMEI" | grep -v "^0$" && echo "PASS: IMEI 字段已添加" || echo "FAIL"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- DeviceRow struct 含 IMEI string 字段
|
||||
- ParseDeviceExcel 支持 imei/IMEI/设备IMEI/IMEI号 列名
|
||||
- device_import.go processBatch 填充 IMEI: row.IMEI
|
||||
- go build ./... 编译通过
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: 提现冻结并发校验(CRITICAL-08)</name>
|
||||
<files>internal/service/my_commission/service.go</files>
|
||||
<action>
|
||||
**修改 `internal/service/my_commission/service.go`(第 162-170 行):**
|
||||
|
||||
将只检查 Error 的写法改为同时检查 RowsAffected:
|
||||
|
||||
```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 校验,per CRITICAL-08 规格)
|
||||
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, "余额不足或并发冲突,请稍后重试")
|
||||
}
|
||||
```
|
||||
|
||||
执行 `go build ./...`,编译通过后提交独立 commit(per D-05)。
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./... && rg "RowsAffected" internal/service/my_commission/service.go && echo "PASS: RowsAffected 校验已添加" || echo "FAIL: RowsAffected 校验缺失"</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- my_commission/service.go 冻结余额使用 result := tx...Updates(),检查 result.Error 和 result.RowsAffected == 0
|
||||
- RowsAffected == 0 时返回 errors.New(errors.CodeInsufficientBalance, ...)
|
||||
- go build ./... 编译通过
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
整体验收:
|
||||
|
||||
```bash
|
||||
# 1. 编译验证
|
||||
go build ./...
|
||||
|
||||
# 2. CRITICAL-07 验证
|
||||
rg "IMEI" pkg/utils/excel.go # 应有 struct 字段和 case 映射
|
||||
rg "IMEI" internal/task/device_import.go # 应有 IMEI: row.IMEI
|
||||
|
||||
# 3. CRITICAL-08 验证
|
||||
rg "RowsAffected" internal/service/my_commission/service.go # 应有 == 0 检查
|
||||
```
|
||||
|
||||
人工验收(参见修正业务-完整方案.md A-6、A-7 验收清单):
|
||||
- CRITICAL-07: 设备导入后 DBHub 查询 `SELECT id, virtual_no, imei FROM tb_device ORDER BY id DESC LIMIT 20;` 确认 IMEI 已填充
|
||||
- CRITICAL-08: 并发提现场景下,DBHub 确认提现单数量与余额冻结成功次数一致
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
1. `go build ./...` 编译通过
|
||||
2. DeviceRow struct 含 IMEI 字段,ParseDeviceExcel 支持 IMEI 列名,device_import.go 填充 IMEI
|
||||
3. my_commission/service.go 冻结余额后检查 RowsAffected,为 0 时返回余额不足错误
|
||||
4. 两个独立 commit 已提交(CRITICAL-07 一个,CRITICAL-08 一个)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
完成后创建 `.planning/phases/01-p0/01-05-SUMMARY.md`,记录:
|
||||
- CRITICAL-07: DeviceRow 改动说明,ParseDeviceExcel 改动说明,device_import.go 改动说明
|
||||
- CRITICAL-08: my_commission/service.go 改动前后对比
|
||||
- 编译验证结果
|
||||
- CRITICAL-07 和 CRITICAL-08 完成状态
|
||||
</output>
|
||||
181
.planning/phases/01-p0/01-05-SUMMARY.md
Normal file
181
.planning/phases/01-p0/01-05-SUMMARY.md
Normal file
@@ -0,0 +1,181 @@
|
||||
---
|
||||
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 ./...` 编译通过
|
||||
127
.planning/phases/01-p0/01-CONTEXT.md
Normal file
127
.planning/phases/01-p0/01-CONTEXT.md
Normal file
@@ -0,0 +1,127 @@
|
||||
# Phase 1: P0 紧急修复 - Context
|
||||
|
||||
**Gathered:** 2026-03-27
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
修复 8 个已知的代码路径断点(CRITICAL-01~08),使"充值→自动购包→佣金计算→实名激活"的主链路可端到端跑通。无新功能,无 API 新增,只改现有文件。
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### CRITICAL-06 修复范围(sellerCostPrice)
|
||||
|
||||
- **D-01:** 对 `internal/service/order/service.go` 中 6 处 `sellerCostPrice = buyerCostPrice` 逐一语义审查,按场景决定是否修改
|
||||
- **D-02:** 语义审查依据:修正业务完整方案中的五种购买场景对应表(`.sisyphus/plans/修正业务-完整方案.md` 方案 A-5 段落)
|
||||
- **D-03:** 不做模式匹配式全量替换,有些场景使用 `buyerCostPrice` 可能是正确的
|
||||
|
||||
### 执行顺序与分组
|
||||
|
||||
- **D-04:** 分 2 批执行:
|
||||
- 第 1 批(主链路):CRITICAL-01 → CRITICAL-02 → CRITICAL-03 → CRITICAL-04 → CRITICAL-05
|
||||
- 第 2 批(独立 Bug):CRITICAL-06 → CRITICAL-07 → CRITICAL-08
|
||||
- **D-05:** 每个 Bug 修复后独立一个 commit(回滚粒度细,便于定位问题)
|
||||
- **D-06:** 依赖约束:CRITICAL-02 必须在 CRITICAL-01 之后执行;CRITICAL-05 必须在 CRITICAL-04 之后执行
|
||||
|
||||
### Bootstrap 依赖注入
|
||||
|
||||
- **D-07:** CRITICAL-03(PollingHandler 注入 asynqClient)和 CRITICAL-04(recharge.Service 注入 queueClient + 注册 AutoPurchaseHandler)分开处理,各自一个 commit
|
||||
- **D-08:** CRITICAL-04 涉及两个修改点:
|
||||
- API 侧:`internal/bootstrap/services.go` 的 `rechargeSvc.New()` 调用新增 `deps.QueueClient` 参数(`QueueClient` 已在 `deps` 中,无需新增字段)
|
||||
- Worker 侧:`pkg/queue/handler.go` 新增 `registerAutoPurchaseHandler()` 方法并在 `RegisterHandlers()` 中调用
|
||||
- **D-09:** 无需修改 `worker_services.go`(recharge.Service 不在 Worker bootstrap 链路中)
|
||||
|
||||
### 验证策略
|
||||
|
||||
- **D-10:** 每个 Bug 修复后执行 `go build ./...` 确认编译通过,然后提交
|
||||
- **D-11:** 不要求自动化测试,人工验收方式:`rg` 确认代码路径 + DBHub 验证数据 + 日志关键字
|
||||
- **D-12:** 方案文档中每个 Bug 都有"人工验收清单",按清单逐一核对
|
||||
|
||||
### the Agent's Discretion
|
||||
|
||||
- 具体代码片段已在 `.sisyphus/plans/修正业务-完整方案.md` 中提供,agent 应直接按文档实现,不需要重新设计
|
||||
- 日志消息使用中文(项目规范)
|
||||
- 所有注释使用中文
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### 修复规格(核心参考)
|
||||
- `.sisyphus/plans/修正业务-完整方案.md` — 完整修复规格书:每个 Bug 的业务背景、代码现状(含文件路径+行号)、完整代码片段、人工验收清单
|
||||
- 方案 A-1(CRITICAL-01):第 59-128 行
|
||||
- 方案 A-2(CRITICAL-02):第 132-163 行
|
||||
- 方案 A-3(CRITICAL-03):第 165-299 行
|
||||
- 方案 A-4 P0-3(CRITICAL-04):第 301-441 行
|
||||
- 方案 A-4 P0-4(CRITICAL-05):第 442-575 行
|
||||
- 方案 A-5(CRITICAL-06):第 579-618 行
|
||||
- 方案 A-6(CRITICAL-07):第 621-670 行
|
||||
- 方案 A-7(CRITICAL-08):第 673-705 行
|
||||
|
||||
### 需要修改的核心文件
|
||||
- `internal/task/polling_handler.go` — CRITICAL-01 (parseRealnameStatus), CRITICAL-03 (asynqClient 注入)
|
||||
- `internal/model/iot_card.go` — CRITICAL-01 (注释修正)
|
||||
- `pkg/constants/iot.go` — CRITICAL-01 (常量确认)
|
||||
- `internal/service/client_order/service.go` — CRITICAL-02 (实名判断)
|
||||
- `internal/bootstrap/services.go` — CRITICAL-04 (rechargeSvc.New() 添加 queueClient)
|
||||
- `internal/service/recharge/service.go` — CRITICAL-04 (HandlePaymentCallback 触发购包)
|
||||
- `pkg/queue/handler.go` — CRITICAL-03 (NewPollingHandler asynqClient), CRITICAL-04 (registerAutoPurchaseHandler)
|
||||
- `internal/task/auto_purchase.go` — CRITICAL-05 (ProcessTask 触发佣金)
|
||||
- `internal/service/order/service.go` — CRITICAL-06 (sellerCostPrice 6 处审查)
|
||||
- `pkg/utils/excel.go` — CRITICAL-07 (DeviceRow IMEI 字段)
|
||||
- `internal/task/device_import.go` — CRITICAL-07 (导入时填充 IMEI)
|
||||
- `internal/service/my_commission/service.go` — CRITICAL-08 (RowsAffected 校验)
|
||||
|
||||
### 项目规范
|
||||
- `AGENTS.md` — 语言要求(中文注释/日志)、错误处理规范、架构分层规范
|
||||
- `.planning/research/PITFALLS.md` — 已知陷阱:sellerCostPrice 6 处全量检查要求
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### 已确认的代码现状
|
||||
- `sellerCostPrice = buyerCostPrice`:6 处确认(order/service.go 第 187, 262, 472, 548, 734, 809 行)
|
||||
- RPush 降级 + `_ = task`:polling_handler.go 第 1101-1114 行确认
|
||||
- `AutoPurchaseHandler`:已存在于 `internal/task/auto_purchase.go`,但 `pkg/queue/handler.go` 未注册
|
||||
- `QueueClient`:已在 `internal/bootstrap/dependencies.go:24` 中定义,已传入 Order、IotCardImport、DeviceImport 服务
|
||||
|
||||
### Established Patterns
|
||||
- 错误处理:Service 层用 `errors.New(code)` / `errors.Wrap(code, err)`,禁止 `fmt.Errorf`
|
||||
- Asynq 任务提交:Worker 内直接用 `h.asynqClient.EnqueueContext()`;Service 层用 `deps.QueueClient.EnqueueTask()`
|
||||
- 日志:`zap.Logger`,字段用具体类型(`zap.Uint`、`zap.Error` 等),消息用中文
|
||||
|
||||
### Integration Points
|
||||
- Worker bootstrap:`pkg/queue/handler.go:RegisterHandlers()` → 任务注册
|
||||
- API bootstrap:`internal/bootstrap/services.go:initServices()` → 服务初始化
|
||||
- 轮询调度:`internal/polling/scheduler.go` 驱动,不直接受此次改动影响
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- CRITICAL-04 的 AutoPurchaseHandler 注入参数列表中有多个 nil 占位符(见方案 A-4 代码片段),agent 应检查当前 `NewAutoPurchaseHandler` 的签名,确保传入参数与现有定义匹配,而非照搬规格书里可能过时的 nil 列表
|
||||
- CRITICAL-06 修复时,需对照"五种购买场景表"逐一确认每个分支的"卖家"身份(场景 1/2/3/4/5),不要只看变量名
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
None — discussion stayed within phase scope
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 01-p0*
|
||||
*Context gathered: 2026-03-27*
|
||||
69
.planning/phases/01-p0/01-DISCUSSION-LOG.md
Normal file
69
.planning/phases/01-p0/01-DISCUSSION-LOG.md
Normal file
@@ -0,0 +1,69 @@
|
||||
# Phase 1: P0 紧急修复 - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-03-27
|
||||
**Phase:** 01-P0 紧急修复
|
||||
**Areas discussed:** CRITICAL-06 修复范围, Bug 执行顺序与分组, Bootstrap 动态限制
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL-06 修复范围
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 逐一语义审查,按场景修复 | 对每处确认"卖家是谁"再决定是否改;有些场景 buyerCostPrice 可能是正确的 | ✓ |
|
||||
| 模式匹配全量改为 operatorCostPrice | 出现即改,快但风险高;场景 1/2/3 可能并不需要改 | |
|
||||
|
||||
**User's choice:** 逐一语义审查
|
||||
**Notes:** 审查依据为修正业务文档中的五种购买场景对应表
|
||||
|
||||
---
|
||||
|
||||
## Bug 执行顺序与分组
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 按优先级顺序分 2 批 | 第 1 批主链路(01→02→03→04→05),第 2 批独立 Bug(06/07/08) | ✓ |
|
||||
| 全部串行,一个 agent 按顶部清单执行 | 简单,但失败时难定位哪一步 | |
|
||||
|
||||
**每个 Bug 一个 commit:**
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 每个 Bug 一个 commit | 回滚粒度细 | ✓ |
|
||||
| 每批 Bug 一个 commit | 提交记录少 | |
|
||||
|
||||
**User's choice:** 分 2 批,每个 Bug 独立 commit
|
||||
**Notes:** 无
|
||||
|
||||
---
|
||||
|
||||
## Bootstrap 动态限制
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 分开处理,各自一个 commit | CRITICAL-03 改 polling handler 注入;CRITICAL-04 改 recharge service + AutoPurchase 注册 | ✓ |
|
||||
| 合并处理,一个 commit | Worker 初始化路径相关 | |
|
||||
|
||||
**API vs Worker bootstrap 确认:**
|
||||
经代码确认:`recharge.Service` 只在 API 侧 `services.go` 初始化,`QueueClient` 已在 `deps` 中。Worker 侧 `worker_services.go` 不需要改。
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 只改 worker bootstrap(worker_services.go) | | |
|
||||
| 确认 API 侧是否也用到 recharge.Service | 经查:recharge 在 API services.go,不在 worker | ✓ |
|
||||
|
||||
**User's choice:** API services.go + worker handler.go 各改一处,两个独立 commit
|
||||
**Notes:** `QueueClient` 已在 `deps` 中,无需新增字段
|
||||
|
||||
---
|
||||
|
||||
## the Agent's Discretion
|
||||
|
||||
- 日志消息使用中文
|
||||
- 具体代码实现参照规格书提供的代码片段
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
None
|
||||
234
.planning/phases/01-p0/01-VERIFICATION.md
Normal file
234
.planning/phases/01-p0/01-VERIFICATION.md
Normal file
@@ -0,0 +1,234 @@
|
||||
---
|
||||
phase: 01-p0
|
||||
verified: 2026-03-28T00:00:00+08:00
|
||||
status: passed
|
||||
score: 5/5 must-haves verified
|
||||
re_verification: false
|
||||
---
|
||||
|
||||
# Phase 1: P0 紧急修复 Verification Report
|
||||
|
||||
**Phase Goal:** 修复所有已知的主链路断点,使"充值→自动购包→佣金计算→实名激活"的完整业务链路可端到端跑通,消除数据写入混乱(实名状态常量)和并发安全漏洞(提现重复创建)
|
||||
**Verified:** 2026-03-28
|
||||
**Status:** ✅ PASSED
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
---
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths (来自 ROADMAP Success Criteria)
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|----------|
|
||||
| 1 | C 端用户完成实名后,实名状态正确写入 DB(值为常量 1,不再是 2),实名激活任务正确触发 | ✓ VERIFIED | `parseRealnameStatus` 返回 `constants.RealNameStatusVerified(=1)`;`isFirstRealname` 使用常量比较;`triggerFirstRealnameActivation` 使用 `asynqClient.EnqueueContext` 正确入队 |
|
||||
| 2 | C 端用户钱包充值成功后,自动购包任务入队并被消费,佣金计算任务被正确触发 | ✓ VERIFIED | `HandlePaymentCallback` 在 `LinkedPackageIDs` 非空时调用 `queueClient.EnqueueTask(TaskTypeAutoPurchaseAfterRecharge)`;`AutoPurchaseHandler` 已注册;`ProcessTask` 事务外触发 `TaskTypeCommission` 入队 |
|
||||
| 3 | 代理代购下级场景(场景 5)的订单金额正确使用 operatorCostPrice,佣金链计算正常 | ✓ VERIFIED | `sellerCostPrice = operatorCostPrice` 出现 3 次(普通/后台/H5 三个下单函数,各一处),三处均已修正 |
|
||||
| 4 | 批量导入设备 Excel 后,设备记录中含 IMEI 字段,Gateway 调用不再因 IMEI 缺失而失败 | ✓ VERIFIED | `DeviceRow.IMEI` 字段存在;`buildDeviceColumnIndex` 支持 `imei/设备imei/imei号` 多别名;`device_import.go` 填充 `IMEI: row.IMEI` |
|
||||
| 5 | 并发提交提现申请时,RowsAffected 检查生效,不会创建重复提现单 | ✓ VERIFIED | `result.RowsAffected == 0` 检查存在,返回 `errors.New(errors.CodeInsufficientBalance, ...)` |
|
||||
|
||||
**Score:** 5/5 truths verified
|
||||
|
||||
---
|
||||
|
||||
## Required Artifacts
|
||||
|
||||
### Plan 01 (CRITICAL-01/02)
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `internal/task/polling_handler.go` | `parseRealnameStatus` 返回常量值,`isFirstRealname` 使用常量 | ✓ VERIFIED | 行 702: `return constants.RealNameStatusVerified`;行 704: `return constants.RealNameStatusNotVerified`;行 173-174: `isFirstRealname` 使用常量 |
|
||||
| `internal/model/iot_card.go` | Model 字段注释统一 `0-未实名 1-已实名` | ✓ VERIFIED | 行 30: `comment:实名状态 0-未实名 1-已实名` |
|
||||
| `internal/service/client_order/service.go` | 实名校验改为常量比较 | ✓ VERIFIED | 行 115: `assetInfo.RealNameStatus != constants.RealNameStatusVerified` |
|
||||
|
||||
**额外修复(超出 PLAN 范围,SUMMARY 已记录):**
|
||||
|
||||
| Artifact | Status | Details |
|
||||
|----------|--------|---------|
|
||||
| `internal/model/dto/asset_dto.go` | ✓ VERIFIED | DTO description 更新为 `0未实名 1已实名`(旧三态 `1实名中 2已实名` 已消除) |
|
||||
| `internal/polling/scheduler.go` | ✓ VERIFIED | `getCardCondition` 改为 `!= constants.RealNameStatusVerified`(旧硬编码 0/1/2 三态) |
|
||||
| `internal/service/iot_card/service.go` | ✓ VERIFIED | `parseGatewayRealnameStatus` 返回 `constants.RealNameStatusVerified`(旧 `return 2` 已消除) |
|
||||
| `internal/handler/app/client_realname.go` | ✓ VERIFIED | 行 128: `== constants.RealNameStatusVerified`(旧 `== 1` 已消除) |
|
||||
|
||||
### Plan 02 (CRITICAL-03)
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `internal/task/polling_handler.go` | `PollingHandler` 注入 `asynq.Client`,`triggerFirstRealnameActivation` 正确入队 | ✓ VERIFIED | 行 33: `asynqClient *asynq.Client` 字段;行 1103: `h.asynqClient.EnqueueContext(ctx, task)` |
|
||||
| `pkg/queue/handler.go` | `registerPollingHandlers` 传入 `h.asynqClient` | ✓ VERIFIED | 行 155: `h.asynqClient` 作为第三参数传入 `NewPollingHandler` |
|
||||
|
||||
### Plan 03 (CRITICAL-04/05)
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `internal/service/recharge/service.go` | `queueClient` 注入,`HandlePaymentCallback` 触发自动购包 | ✓ VERIFIED | 行 52: `queueClient *queue.Client` 字段;行 394-404: `EnqueueTask` 触发自动购包 |
|
||||
| `pkg/queue/handler.go` | `registerAutoPurchaseHandler` 方法和注册调用 | ✓ VERIFIED | 行 74: `h.registerAutoPurchaseHandler()`;行 208-221: `HandleFunc` 注册 |
|
||||
| `internal/task/auto_purchase.go` | `asynqClient` 注入,`ProcessTask` 触发佣金 | ✓ VERIFIED | 行 34: `asynqClient *asynq.Client` 字段;行 204-218: 事务外 `EnqueueContext(TaskTypeCommission)` |
|
||||
| `internal/bootstrap/services.go` | `recharge.New()` 传入 `queueClient` | ✓ VERIFIED | 行 173: `rechargeSvc.New(..., deps.QueueClient)` |
|
||||
|
||||
### Plan 04 (CRITICAL-06)
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `internal/service/order/service.go` | 场景 5 `sellerCostPrice` 修正 | ✓ VERIFIED | 行 264, 551, 813: 三处 `sellerCostPrice = operatorCostPrice`(三个下单函数各一处) |
|
||||
|
||||
### Plan 05 (CRITICAL-07/08)
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|----------|----------|--------|---------|
|
||||
| `pkg/utils/excel.go` | `DeviceRow` 含 `IMEI` 字段,`ParseDeviceExcel` 列名映射新增 IMEI | ✓ VERIFIED | 行 38: `IMEI string` 字段;行 324-350: `buildDeviceColumnIndex` 支持 `imei/设备imei/imei号` |
|
||||
| `internal/task/device_import.go` | 导入时填充 `IMEI` 字段 | ✓ VERIFIED | 行 267: `IMEI: row.IMEI` |
|
||||
| `internal/service/my_commission/service.go` | `RowsAffected` 校验,并发冻结失败返回错误 | ✓ VERIFIED | 行 162-174: `result := tx...Updates()`,`result.Error` 和 `result.RowsAffected == 0` 双重检查 |
|
||||
|
||||
---
|
||||
|
||||
## Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|-----|-----|--------|---------|
|
||||
| `internal/task/polling_handler.go` | `pkg/constants/iot.go` | `constants.RealNameStatusVerified` | ✓ WIRED | 行 702, 704, 173-174, 986 均使用常量 |
|
||||
| `internal/service/client_order/service.go` | `pkg/constants/iot.go` | `constants.RealNameStatusVerified` | ✓ WIRED | 行 115 使用常量 |
|
||||
| `pkg/queue/handler.go` | `internal/task/polling_handler.go` | `NewPollingHandler(h.db, h.redis, h.asynqClient, ...)` | ✓ WIRED | 行 152-157 传入 `h.asynqClient` |
|
||||
| `internal/task/polling_handler.go` | `asynq.Client` | `h.asynqClient.EnqueueContext(ctx, task)` | ✓ WIRED | 行 1103 |
|
||||
| `internal/service/recharge/service.go` | `pkg/queue/handler.go` | `queueClient.EnqueueTask(TaskTypeAutoPurchaseAfterRecharge)` | ✓ WIRED | 行 394-404 入队;`pkg/queue/handler.go` 行 74/208-221 注册消费 |
|
||||
| `internal/task/auto_purchase.go` | `asynq.Client` | `h.asynqClient.EnqueueContext(ctx, commissionTask)` | ✓ WIRED | 行 215,事务外触发 |
|
||||
| `internal/bootstrap/services.go` | `internal/service/recharge/service.go` | `rechargeSvc.New(..., deps.QueueClient)` | ✓ WIRED | 行 173 |
|
||||
| `pkg/utils/excel.go` | `internal/task/device_import.go` | `DeviceRow.IMEI → model.Device.IMEI` | ✓ WIRED | `device_import.go` 行 267 填充 |
|
||||
| `internal/service/my_commission/service.go` | `model.AgentWallet` | `result.RowsAffected == 0 → 返回余额不足错误` | ✓ WIRED | 行 162-174 |
|
||||
|
||||
---
|
||||
|
||||
## Data-Flow Trace (Level 4)
|
||||
|
||||
所有 Phase 1 修复均为业务逻辑链路修复(非前端渲染组件),Level 4 关注任务链路数据流的完整性。
|
||||
|
||||
| 链路段 | 数据变量 | 数据源 | 产生真实数据 | Status |
|
||||
|--------|----------|--------|------------|--------|
|
||||
| 实名状态写入 | `newRealnameStatus` | Gateway 返回 `RealStatus bool` → `parseRealnameStatus()` | ✓ 非静态(依赖真实 Gateway 响应) | ✓ FLOWING |
|
||||
| 实名激活任务入队 | `task payload (cardID, packageUsageIDs)` | `triggerFirstRealnameActivation` 从 DB 查询真实套餐 | ✓ 非空(前置条件:待激活套餐存在) | ✓ FLOWING |
|
||||
| 自动购包入队 | `AutoPurchasePayload{RechargeRecordID}` | `recharge.LinkedPackageIDs` 来自 DB 充值记录 | ✓ 非静态 | ✓ FLOWING |
|
||||
| 自动购包消费 | `createdOrderID` | 事务内 `tx.Create(order)` 生成真实订单 | ✓ DB 写入 | ✓ FLOWING |
|
||||
| 佣金任务入队 | `{order_id: createdOrderID}` | 事务提交后 `createdOrderID > 0` 才触发 | ✓ 有效订单 ID | ✓ FLOWING |
|
||||
| sellerCostPrice | `operatorCostPrice` | `getCostPrice(ctx, operatorShopID, ...)` 查 DB | ✓ 非静态(真实成本价) | ✓ FLOWING |
|
||||
| IMEI 写入 | `DeviceRow.IMEI` | Excel 文件列解析 | ✓ 非静态(依赖 Excel 数据) | ✓ FLOWING |
|
||||
| 提现冻结 | `result.RowsAffected` | GORM 条件更新返回影响行数 | ✓ 数据库真实返回 | ✓ FLOWING |
|
||||
|
||||
---
|
||||
|
||||
## Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|----------|---------|--------|--------|
|
||||
| 编译通过(无错误) | `go build ./...` | 无输出,无错误 | ✓ PASS |
|
||||
| `parseRealnameStatus(true)` 不再返回 2 | `grep "return 2" internal/task/polling_handler.go` | 无匹配 | ✓ PASS |
|
||||
| 无遗留实名硬编码 2 | `grep -rn "RealNameStatus == 2\|newRealnameStatus == 2\|return 2" internal/` | `client_order/service.go` 的 `return 2` 为 `PaymentStatusCancelled`(与实名无关,见 `orderStatusToClientStatus` 函数) | ✓ PASS |
|
||||
| `_ = task` 已删除 | `grep "_ = task" internal/task/polling_handler.go` | 无匹配 | ✓ PASS |
|
||||
| `registerAutoPurchaseHandler` 已注册 | `grep "registerAutoPurchaseHandler" pkg/queue/handler.go` | 行 74: 在 `RegisterHandlers` 中调用;行 208: 方法定义 | ✓ PASS |
|
||||
| `sellerCostPrice = operatorCostPrice` 出现 3 次 | `grep -n "sellerCostPrice = operatorCostPrice" internal/service/order/service.go` | 行 264, 551, 813 — 共 3 处 | ✓ PASS |
|
||||
| `result.RowsAffected == 0` 校验存在 | `grep "RowsAffected" internal/service/my_commission/service.go` | 行 173: `if result.RowsAffected == 0` | ✓ PASS |
|
||||
| `IMEI: row.IMEI` 存在 | `grep "IMEI: row.IMEI" internal/task/device_import.go` | 行 267 | ✓ PASS |
|
||||
| 所有 commits 存在 | `git log --oneline 1fd0072 da50a35 20b4b6d 9a4d87a 030425b f74b7da 809cb2b db16680` | 全部 8 个 commit 存在 | ✓ PASS |
|
||||
|
||||
---
|
||||
|
||||
## Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|------------|-------------|--------|----------|
|
||||
| CRITICAL-01 | 01-01-PLAN.md | 实名状态常量统一(`parseRealnameStatus` 写入 1,Model 注释统一) | ✓ SATISFIED | `polling_handler.go` 行 702-704 使用常量;`iot_card.go` 行 30 注释正确 |
|
||||
| CRITICAL-02 | 01-01-PLAN.md | C 端实名校验修复(`client_order/service.go` 改用常量) | ✓ SATISFIED | `client_order/service.go` 行 115 使用 `constants.RealNameStatusVerified` |
|
||||
| CRITICAL-03 | 01-02-PLAN.md | 实名激活任务断链修复(`PollingHandler` 注入 `asynq.Client`,删除 RPush 降级) | ✓ SATISFIED | `PollingHandler` 有 `asynqClient` 字段;`EnqueueContext` 调用存在;RPush 降级和 `_ = task` 已消除 |
|
||||
| CRITICAL-04 | 01-03-PLAN.md | 充值回调后自动购包触发(`recharge/service.go` 入队 `TaskTypeAutoPurchaseAfterRecharge`;注册 `AutoPurchaseHandler`) | ✓ SATISFIED | `recharge/service.go` 行 394-404 入队;`pkg/queue/handler.go` 行 208-221 注册 |
|
||||
| CRITICAL-05 | 01-03-PLAN.md | 自动购包佣金链补全(`AutoPurchaseHandler.ProcessTask` 事务提交成功后入队 `TaskTypeCommission`) | ✓ SATISFIED | `auto_purchase.go` 行 204-218 事务外入队佣金任务 |
|
||||
| CRITICAL-06 | 01-04-PLAN.md | 代购订单金额修正(场景 5 `sellerCostPrice` 使用 `operatorCostPrice`) | ✓ SATISFIED | `order/service.go` 行 264, 551, 813 三处均已修正 |
|
||||
| CRITICAL-07 | 01-05-PLAN.md | 设备导入补充 IMEI 字段 | ✓ SATISFIED | `excel.go` 行 38 字段定义,行 342-349 列名映射;`device_import.go` 行 267 填充 |
|
||||
| CRITICAL-08 | 01-05-PLAN.md | 提现冻结并发校验(`RowsAffected == 0` 检查) | ✓ SATISFIED | `my_commission/service.go` 行 162-174 双重校验 |
|
||||
|
||||
**所有 8 个 CRITICAL 需求均已满足,REQUIREMENTS.md 中已标记为 `[x]` 完成状态。**
|
||||
|
||||
**孤立需求检查:** REQUIREMENTS.md 中 Phase 1 映射的需求仅为 CRITICAL-01 ~ CRITICAL-08,与 PLANs 声明完全一致,无孤立需求。
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| `internal/service/recharge/service.go` | 277 | `TODO: 按 payment_config_id 加载配置验签(当前留桩,验签由外层处理)` | ℹ️ Info | 预存 TODO,属于 PAY 系列(Phase 4),不影响 Phase 1 目标 |
|
||||
| `internal/service/order/service.go` | 2360 | `TODO: 实现富友支付发起逻辑` | ℹ️ Info | 预存 TODO,属于 PAY-01(Phase 4),不影响 Phase 1 目标 |
|
||||
| `internal/service/order/service.go` | 2366 | `TODO: 实现富友小程序支付发起逻辑` | ℹ️ Info | 预存 TODO,属于 PAY-02(Phase 4),不影响 Phase 1 目标 |
|
||||
|
||||
> **注意:** `internal/service/client_order/service.go` 行 659/672 的 `return 2` 及 `internal/handler/app/client_order.go` 行 383 的 `return 2` 均属于 `orderStatusToClientStatus` 函数中 `PaymentStatusCancelled` 的映射,与实名状态常量无关,**不是 CRITICAL-01 遗留问题**。
|
||||
|
||||
**无阻塞性 Anti-Pattern。**
|
||||
|
||||
---
|
||||
|
||||
## Human Verification Required
|
||||
|
||||
### 1. 实名激活端到端验证
|
||||
|
||||
**Test:** 模拟 IoT 卡实名成功回调:触发轮询后,Gateway 返回 `RealStatus=true`
|
||||
**Expected:** `tb_iot_card.real_name_status = 1`(不是 2);Asynq 队列中出现 `task:package:first_activation` 任务;待激活套餐(`pending_realname_activation=true`)被处理
|
||||
**Why human:** 需要真实 Gateway 连接或 Mock 环境才能端到端验证
|
||||
|
||||
### 2. 自动购包完整链路验证
|
||||
|
||||
**Test:** C 端用户充值,支付回调携带含 `LinkedPackageIDs` 的充值记录
|
||||
**Expected:** `tb_asset_recharge_record.auto_purchase_status=success` → `tb_order` 生成订单 → `tb_package_usage` 激活套餐 → `tb_commission_record` 生成佣金记录
|
||||
**Why human:** 需要完整 Worker 运行环境和真实支付回调数据
|
||||
|
||||
### 3. 代购场景佣金差额验证
|
||||
|
||||
**Test:** 代理代购下级代理客户套餐
|
||||
**Expected:** `tb_order.seller_cost_price` = 操作代理自己的成本价(非下级成本价),佣金差额 > 0
|
||||
**Why human:** 需要特定账号体系(代理+下级代理)的测试数据
|
||||
|
||||
### 4. 设备导入 IMEI 字段验证
|
||||
|
||||
**Test:** 上传含 IMEI 列的设备导入 Excel
|
||||
**Expected:** `SELECT id, virtual_no, imei FROM tb_device ORDER BY id DESC LIMIT 20;` — `imei` 字段非空
|
||||
**Why human:** 需要数据库直接查询验证(PostgreSQL MCP 工具)
|
||||
|
||||
### 5. 并发提现防重复验证
|
||||
|
||||
**Test:** 并发发起多笔提现申请(余额仅够一笔)
|
||||
**Expected:** 只有一笔提现单创建成功,其余返回 `CodeInsufficientBalance` 错误,无重复提现单
|
||||
**Why human:** 需要并发压测工具触发竞争条件
|
||||
|
||||
---
|
||||
|
||||
## Gaps Summary
|
||||
|
||||
**无 Gap。** 所有 5 个 Success Criteria、所有 8 个 CRITICAL 需求均通过代码层面验证。
|
||||
|
||||
Phase 1 目标("充值→自动购包→佣金计算→实名激活" 主链路端到端可跑通)在代码层面全部完成:
|
||||
|
||||
1. **实名状态一致性**:全链路(轮询写入、轮询激活判断、C 端校验、DTO、scheduler、iot_card service、handler)统一使用 `constants.RealNameStatusVerified(=1)`,旧错误值 `2` 彻底消除。
|
||||
2. **实名激活任务链路**:`PollingHandler` 正确注入 `asynq.Client`,`triggerFirstRealnameActivation` 从 RPush 降级方案改为 `EnqueueContext`,任务可被 Worker 消费。
|
||||
3. **充值→购包→佣金链路**:`recharge.HandlePaymentCallback` → `AutoPurchaseHandler.ProcessTask`(注册并消费)→ 事务外 `EnqueueContext(TaskTypeCommission)`,三段链路均已打通。
|
||||
4. **代购佣金差额**:三个下单函数(普通/后台/H5)的场景 5 均使用 `operatorCostPrice` 作为 `sellerCostPrice`,佣金差额计算基准已修正。
|
||||
5. **并发安全**:提现冻结余额 `RowsAffected == 0` 校验防止并发重复创建提现单。
|
||||
6. **IMEI 字段**:设备导入链路(Excel 解析 → device 创建)IMEI 字段完整传递。
|
||||
|
||||
---
|
||||
|
||||
## Commit Traceability
|
||||
|
||||
| Plan | Commit | Description | Files Changed |
|
||||
|------|--------|-------------|---------------|
|
||||
| 01-01 (CRITICAL-01) | `1fd0072` | 统一实名状态常量 | polling_handler, iot_card, asset_dto, scheduler, iot_card/service |
|
||||
| 01-01 (CRITICAL-02) | `da50a35` | C 端实名校验改用常量 | client_order/service, client_realname |
|
||||
| 01-02 (CRITICAL-03) | `20b4b6d` | PollingHandler 注入 asynq.Client | polling_handler, queue/handler |
|
||||
| 01-03 (CRITICAL-04) | `9a4d87a` | 充值回调触发自动购包入队 | recharge/service, bootstrap/services |
|
||||
| 01-03 (CRITICAL-04/05) | `030425b` | AutoPurchaseHandler 注入 asynqClient + 佣金触发 | auto_purchase, queue/handler |
|
||||
| 01-04 (CRITICAL-06) | `f74b7da` | sellerCostPrice 三处修正 | order/service |
|
||||
| 01-05 (CRITICAL-07) | `809cb2b` | 设备导入 IMEI 字段 | excel, device_import |
|
||||
| 01-05 (CRITICAL-08) | `db16680` | 提现冻结 RowsAffected 校验 | my_commission/service |
|
||||
|
||||
所有 8 个 commit 已通过 `git log` 验证存在。
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-03-28_
|
||||
_Verifier: the agent (gsd-verifier)_
|
||||
474
.planning/research/ARCHITECTURE.md
Normal file
474
.planning/research/ARCHITECTURE.md
Normal file
@@ -0,0 +1,474 @@
|
||||
# Architecture Research
|
||||
|
||||
**Domain:** IoT 卡管理 + 多级分销 + 分佣结算平台(Brownfield 单体)
|
||||
**Researched:** 2026-03-27
|
||||
**Confidence:** HIGH(基于当前代码库深度分析 + 该领域已知模式验证)
|
||||
|
||||
---
|
||||
|
||||
## 当前架构概述
|
||||
|
||||
### 系统总览
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ API 进程(cmd/api) │
|
||||
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
|
||||
│ │ /auth │ │ /admin │ │ /c/v1 │ │ /callback│ │
|
||||
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
|
||||
│ │ │ │ │ │
|
||||
│ └─────────────┴──── Handler 层 ────────────┘ │
|
||||
│ │ │
|
||||
│ Service 层(~55 个服务包) │
|
||||
│ │ │
|
||||
│ Store 层(54 个 Store 文件) │
|
||||
│ │ │
|
||||
│ PostgreSQL + Redis │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
┌──────────────────────────────────────────────────────────────────────┐
|
||||
│ Worker 进程(cmd/worker) │
|
||||
│ ┌────────────────┐ ┌──────────────────┐ ┌─────────────────────┐ │
|
||||
│ │ Asynq Worker │ │ 轮询调度器 │ │ Asynq Scheduler │ │
|
||||
│ │ (任务消费) │ │ (Redis SortedSet)│ │ (定时任务) │ │
|
||||
│ └────────┬───────┘ └────────┬─────────┘ └──────────┬──────────┘ │
|
||||
│ └──────────────────┴────────────────────────┘ │
|
||||
│ │ │
|
||||
│ Task Handler 层(16 个任务) │
|
||||
│ │ │
|
||||
│ Service / Store 层(复用 API 侧) │
|
||||
└──────────────────────────────────────────────────────────────────────┘
|
||||
|
||||
外部集成:
|
||||
Gateway(运营商接口) ← HTTP Client → IoT 卡/设备状态同步
|
||||
微信支付(JSAPI/H5) ← PowerWeChat SDK
|
||||
富友支付 ← 自研 SDK(pkg/fuiou)
|
||||
联通云 OSS ← S3 兼容(批量导入/导出)
|
||||
```
|
||||
|
||||
### 组件职责
|
||||
|
||||
| 组件 | 职责 | 关键文件 |
|
||||
|------|------|---------|
|
||||
| Bootstrap | 依赖装配,进程启动唯一入口 | `internal/bootstrap/*.go` |
|
||||
| RouteSpec + Register() | 路由即文档,单次声明驱动 Fiber + OpenAPI | `internal/routes/registry.go` |
|
||||
| Auth Middleware | JWT/Redis Token 解析,写入 UserContextInfo | `pkg/middleware/auth.go` |
|
||||
| DataScope Filter | Store 层显式过滤,多租户数据隔离 | `pkg/middleware/data_scope.go` |
|
||||
| Service(业务核心)| 幂等、事务编排、业务规则、任务提交 | `internal/service/**` |
|
||||
| Store(数据访问)| SQL、分页、显式权限过滤 | `internal/store/postgres/*.go` |
|
||||
| Task Handler | Asynq 任务处理(轮询/佣金/导入/过期) | `internal/task/*.go` |
|
||||
| Polling Scheduler | Redis SortedSet 驱动卡片轮询队列 | `internal/polling/scheduler.go` |
|
||||
| Commission Calculation | 多级分佣链计算(差价佣金 + 一次性佣金) | `internal/service/commission_calculation/` |
|
||||
|
||||
---
|
||||
|
||||
## 组件边界分析
|
||||
|
||||
### 当前已正确划分的边界
|
||||
|
||||
**① 双进程隔离(API vs Worker)**
|
||||
- API 进程:HTTP 快速响应,无阻塞 I/O
|
||||
- Worker 进程:轮询、批量导入、佣金计算、定时任务
|
||||
- 边界清晰,独立部署、独立扩容 ✅
|
||||
|
||||
**② 数据权限隔离(Shop 层级过滤)**
|
||||
- 用户身份 → UserContextInfo → SubordinateShopIDs(Redis 缓存)
|
||||
- Store 层显式调用 ApplyShopFilter / ApplyEnterpriseFilter
|
||||
- 不依赖 GORM Callback,权限过滤显式可见 ✅
|
||||
|
||||
**③ 任务队列解耦(Asynq)**
|
||||
- 同步调用路径:Handler → Service → 提交 Asynq 任务
|
||||
- 异步执行路径:Worker → Task Handler → Service/Store
|
||||
- 支付回调触发的佣金计算走异步路径,不阻塞回调响应 ✅
|
||||
|
||||
**④ 外部服务封装(Gateway / 微信 / 富友)**
|
||||
- Gateway HTTP Client 封装在 `internal/gateway/`
|
||||
- 微信 SDK 封装在 `pkg/wechat/`
|
||||
- 富友 SDK 封装在 `pkg/fuiou/`
|
||||
- 外部依赖注入,可以独立替换 ✅
|
||||
|
||||
### 当前边界薄弱点(需要关注)
|
||||
|
||||
**⑤ Order Service 超大文件(Critical)**
|
||||
- `internal/service/order/service.go` = **2365 行**
|
||||
- 支付发起、支付回调、订单状态流转、幂等锁、钱包扣款全部耦合
|
||||
- 任何一处改动都有全链路回归风险
|
||||
- **建议:** 按职责拆分为子文件:`payment_service.go`、`callback_service.go`、`order_state.go`、`wallet_deduction.go`
|
||||
|
||||
**⑥ 轮询系统 asynqClient 依赖缺失(High)**
|
||||
- `internal/task/polling_handler.go` 中实名激活通过 `Redis RPush` 降级实现,而非 Asynq 任务
|
||||
- 架构偏差:同一 Worker 进程内用两套机制投递任务(Asynq + RPush)
|
||||
- **建议:** 在 Worker Bootstrap 中注入 asynqClient,统一走 Asynq 路径
|
||||
|
||||
**⑦ C 端 Handler 直接访问 DB(Medium)**
|
||||
- 方案 F-4:部分 C 端 Handler 绕过 Service 层直接访问 DB
|
||||
- 破坏了分层边界,导致业务逻辑散落在 Handler 层
|
||||
- **建议:** 迁回 Service 层,Handler 只做 HTTP 入参解析和响应封装
|
||||
|
||||
---
|
||||
|
||||
## 数据流方向
|
||||
|
||||
### 关键流 1:订单创建 + 支付 + 佣金链
|
||||
|
||||
```
|
||||
用户发起购买
|
||||
│
|
||||
▼
|
||||
Handler → 解析 DTO → 调用 OrderService.Create()
|
||||
│
|
||||
▼
|
||||
OrderService.Create()
|
||||
├── Redis 幂等键检查(业务键防重)
|
||||
├── 分布式锁(RedisOrderCreateLockKey)
|
||||
├── 二次检查(加锁后再查 Redis)
|
||||
├── DB 事务:创建订单 + 冻结钱包余额
|
||||
└── 提交 Asynq 支付超时任务(30min)
|
||||
│
|
||||
▼
|
||||
支付回调(微信/富友)
|
||||
│
|
||||
▼
|
||||
CallbackHandler → PaymentService.HandleCallback()
|
||||
├── WHERE status=pending 条件更新(防重)
|
||||
├── 更新订单状态为已支付
|
||||
├── 解冻钱包余额 / 扣款
|
||||
└── 提交 CommissionCalculation Asynq 任务
|
||||
│
|
||||
▼
|
||||
Worker: CommissionCalculationTask
|
||||
├── 查询代理层级路径(WITH RECURSIVE / parent_id 链)
|
||||
├── 计算差价佣金链(每层代理差价 × 数量)
|
||||
├── 计算一次性佣金(绑定规则)
|
||||
├── DB 事务:写入 commission_record(幂等:unique(order_id, agent_id))
|
||||
└── 更新代理钱包余额
|
||||
```
|
||||
|
||||
### 关键流 2:IoT 卡轮询 + 实名激活
|
||||
|
||||
```
|
||||
Polling Scheduler(独立 Goroutine)
|
||||
│
|
||||
├── 从 Redis Sorted Set 取到期卡片
|
||||
│ key: polling:realname:queue
|
||||
│
|
||||
▼
|
||||
投递 Asynq 任务 → Worker: PollingHandler
|
||||
│
|
||||
├── 调用 Gateway HTTP 接口拉取实名状态
|
||||
├── 更新 iot_card.realname_status
|
||||
│
|
||||
▼
|
||||
实名状态变为已认证 → 触发套餐激活
|
||||
│
|
||||
▼ (当前路径:RPush,待修复为 Asynq)
|
||||
激活任务 → PackageService.ActivateByRealname()
|
||||
├── 查找 status=3(待实名激活)套餐
|
||||
├── 第一个主套餐 → status=1(激活)
|
||||
├── 其余主套餐 → status=4(排队)
|
||||
└── 加油包检查并激活
|
||||
```
|
||||
|
||||
### 关键流 3:流量计费(当前方案 vs 改革方案)
|
||||
|
||||
```
|
||||
当前(方案 E 改革前):
|
||||
Gateway 上报流量 → 直接覆盖写 current_month_usage_mb
|
||||
问题:自然月由上游归零,历史数据被破坏
|
||||
|
||||
改革后(方案 E):
|
||||
Gateway 上报流量增量
|
||||
│
|
||||
▼
|
||||
Redis 增量累加(今日流量缓冲)
|
||||
│
|
||||
每日凌晨批任务 → 写入 tb_card_daily_usage(日粒度记录)
|
||||
│
|
||||
查询层:Redis 今日数据 + DB 历史数据 合并返回
|
||||
优点:历史数据不可变,查询范围可控
|
||||
```
|
||||
|
||||
### 关键流 4:多租户数据权限
|
||||
|
||||
```
|
||||
HTTP 请求 → Auth Middleware
|
||||
│
|
||||
├── 解析 JWT/Redis Token
|
||||
├── 查询用户 user_type, shop_id, enterprise_id
|
||||
├── GetSubordinateShopIDs()(WITH RECURSIVE,Redis 缓存 30min)
|
||||
└── 写入 UserContextInfo 到 context.Context
|
||||
│
|
||||
▼
|
||||
Store 层查询
|
||||
├── Agent 用户 → ApplyShopFilter(shopIDs)
|
||||
│ WHERE shop_id IN (当前+下级)
|
||||
├── Enterprise 用户 → ApplyEnterpriseFilter(enterpriseID)
|
||||
│ WHERE enterprise_id = X
|
||||
└── SuperAdmin/Platform → 不过滤
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 架构模式
|
||||
|
||||
### 模式 1:两阶段幂等防重(订单创建)
|
||||
|
||||
**What:** Redis 业务键 + 分布式锁双重防护,防止并发创建重复订单
|
||||
**When:** 任何创建类写操作(订单、提现、激活任务)
|
||||
**Trade-offs:** 增加 2~3 次 Redis 往返,换取金融级安全
|
||||
|
||||
```go
|
||||
// Step 1: Redis GET 快速检测
|
||||
val, err := redis.Get(ctx, idempotencyKey).Result()
|
||||
if err == nil && val != "" { return existingResult }
|
||||
|
||||
// Step 2: SetNX 分布式锁
|
||||
locked, _ := redis.SetNX(ctx, lockKey, now, lockTTL).Result()
|
||||
if !locked { return ErrTooManyRequests }
|
||||
defer redis.Del(ctx, lockKey)
|
||||
|
||||
// Step 3: 加锁后二次检测(防止锁等待期间重复创建)
|
||||
val, err = redis.Get(ctx, idempotencyKey).Result()
|
||||
if err == nil && val != "" { return existingResult }
|
||||
|
||||
// Step 4: 执行业务逻辑...
|
||||
// Step 5: 标记成功
|
||||
redis.Set(ctx, idempotencyKey, resultID, ttl)
|
||||
```
|
||||
|
||||
### 模式 2:条件更新防并发(状态流转)
|
||||
|
||||
**What:** `WHERE status = expected_status` 的条件 UPDATE,RowsAffected 判断
|
||||
**When:** 支付回调、套餐激活、订单状态变更等有明确状态机的场景
|
||||
**Trade-offs:** 需要数据库层有索引支持,效率高但依赖状态设计的正确性
|
||||
|
||||
```go
|
||||
result := tx.Model(&model.Order{}).
|
||||
Where("id = ? AND payment_status = ?", orderID, PaymentStatusPending).
|
||||
Updates(map[string]any{"payment_status": PaymentStatusPaid})
|
||||
if result.RowsAffected == 0 {
|
||||
// 已被处理或并发冲突,根据当前状态决定返回
|
||||
}
|
||||
```
|
||||
|
||||
### 模式 3:Redis Sorted Set 驱动轮询调度
|
||||
|
||||
**What:** 以下次轮询时间为 Score,ICCID 为 Member,定时扫描到期项投递 Asynq 任务
|
||||
**When:** 需要"不同间隔、海量卡片"的轮询调度场景
|
||||
**Trade-offs:** 比 cron 更灵活,内存占用与卡片数量正相关;Redis 重启需重建队列
|
||||
|
||||
```go
|
||||
// 投入轮询队列(Score = 下次轮询 Unix 时间戳)
|
||||
redis.ZAdd(ctx, "polling:realname:queue", redis.Z{
|
||||
Score: float64(nextPollAt.Unix()),
|
||||
Member: iccid,
|
||||
})
|
||||
|
||||
// Scheduler 每秒扫描
|
||||
members := redis.ZRangeByScore(ctx, key, &redis.ZRangeBy{
|
||||
Min: "-inf",
|
||||
Max: strconv.FormatInt(time.Now().Unix(), 10),
|
||||
Limit: &redis.Limit{Count: batchSize},
|
||||
})
|
||||
// 投递 Asynq 任务...
|
||||
```
|
||||
|
||||
### 模式 4:多级佣金链计算(向上遍历)
|
||||
|
||||
**What:** 从发生订单的代理出发,沿 parent_id 向上遍历,逐级计算差价或固定佣金
|
||||
**When:** 每笔订单支付成功后触发(异步任务)
|
||||
**Trade-offs:** 当前 parent_id 邻接表实现,层级 7 层时 WITH RECURSIVE 性能尚可;超 10 层或高并发时需物化路径优化
|
||||
|
||||
```go
|
||||
// 当前实现:DB 递归查询代理链(WITH RECURSIVE 或逐级查询)
|
||||
// 建议:超 1000 TPS 时改为物化路径 + Redis 缓存代理链
|
||||
func (s *Service) CalculateCommissionChain(ctx context.Context, orderID uint) error {
|
||||
order := fetchOrder(ctx, orderID)
|
||||
agentChain := fetchAncestorChain(ctx, order.SellerShopID) // 向上遍历
|
||||
for i, agent := range agentChain {
|
||||
diff := agent.SellPrice - agent.CostPrice
|
||||
writeCommissionRecord(ctx, tx, order.ID, agent.ShopID, diff)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 构建顺序建议
|
||||
|
||||
**原则:** 优先修复已存在但断裂的主链路,再扩展新功能,最后优化架构
|
||||
|
||||
```
|
||||
阶段 0 — 主链路修复(P0,当前 MVP 阶段的首要目标)
|
||||
│
|
||||
├── 1. 实名常量统一(A-1)→ 解锁整个实名链路
|
||||
├── 2. C 端实名校验(A-2)依赖 A-1
|
||||
├── 3. 轮询 Handler 注入 asynqClient(A-3)→ 修复架构偏差
|
||||
├── 4. 充值回调购包触发 + 佣金补全(A-4)→ 核心收入链路
|
||||
├── 5. 代购订单金额修正(A-5)→ 佣金归零问题
|
||||
└── 6. 并发校验修复(A-7, C-3)→ 提现/激活配置并发
|
||||
|
||||
阶段 1 — 功能补全(参考方案 B/C/D/H/J)
|
||||
│
|
||||
├── 企业端权限完善(B-1, B-2)
|
||||
├── 提现/审批流程完善(C-1, C-2)
|
||||
├── 设备体系完善(D-0 ~ D-3)
|
||||
└── 轮询系统小修(H-1 ~ H-4)
|
||||
|
||||
阶段 2 — 流量计费改革(方案 E,独立窗口)
|
||||
│
|
||||
├── 新建 tb_card_daily_usage(迁移)
|
||||
├── Redis 增量缓存层
|
||||
└── 查询层适配(双段数据合并)
|
||||
|
||||
阶段 3 — 退款体系(方案 I)
|
||||
│
|
||||
├── 退款模型 + 7 个接口
|
||||
└── 佣金钱包反向扣减
|
||||
|
||||
阶段 4 — Order Service 拆分(架构改善)
|
||||
│
|
||||
├── payment_service.go(支付发起)
|
||||
├── callback_service.go(回调处理)
|
||||
├── order_state.go(状态流转)
|
||||
└── wallet_deduction.go(钱包操作)
|
||||
```
|
||||
|
||||
**组件依赖关系:**
|
||||
|
||||
```
|
||||
IoT卡状态 → 实名认证 → 套餐激活 → 流量计费 → 订单计算
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
Gateway 数据轮询 Commission
|
||||
集成层 调度层 计算层
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 扩展挑战与 SaaS 化瓶颈
|
||||
|
||||
### SaaS 化时会成为瓶颈的当前决策
|
||||
|
||||
| 当前决策 | SaaS 化面临的问题 | 迁移路径 |
|
||||
|---------|-----------------|---------|
|
||||
| 单一 PostgreSQL 数据库 | 所有"租户"(shop)共享同一 DB | 添加 `tenant_id` 列 + RLS,或 Schema 隔离 |
|
||||
| GORM 数据过滤基于 shop_id | 过滤逻辑散布于 54 个 Store 文件 | 统一 Tenant Context 注入层,确保覆盖率 |
|
||||
| Config 嵌入二进制 | 多租户需动态配置(微信AppID/支付商户号) | 已有 `tb_wechat_config`,需完全消除环境变量硬依赖 |
|
||||
| 单进程 Worker | 一个 Worker 处理所有租户的轮询/佣金 | 按租户分队列;大租户独立 Worker |
|
||||
| parent_id 邻接表代理层级 | 跨租户代理关系查询膨胀 | 加 `tenant_id` 隔离;超 5 万代理考虑物化路径 |
|
||||
| commission_calculation 单服务 | 高 TPS 下单文件 653 行线性计算 | 异步 + 批量写入;超 1000 TPS 考虑 Flink |
|
||||
|
||||
### 各规模架构调整
|
||||
|
||||
| 规模 | 架构方案 |
|
||||
|------|---------|
|
||||
| 当前(< 100 代理,< 1000 卡)| 当前单体完全够用,重点是修复断链 |
|
||||
| 成长期(1000+ 代理,10万+ 卡)| Worker 分队列 + 优化轮询 SortedSet 分片 |
|
||||
| SaaS 初期(多客户,共享平台)| 添加 tenant_id 隔离层;动态支付配置;统一 OpenAPI 网关 |
|
||||
| SaaS 规模(每客户数万卡)| Schema 隔离 or DB 隔离;独立 Worker 进程 per 大客户 |
|
||||
| 高并发计费(> 5000 TPS)| 引入消息队列(Kafka)缓冲计费事件;离线批处理结算 |
|
||||
|
||||
### 第一个会触发的瓶颈:轮询调度
|
||||
|
||||
- **现状:** Redis Sorted Set + Asynq 混合投递,Worker 单机并发
|
||||
- **触发条件:** 超过 5 万张活跃卡,每卡 5 分钟轮询一次 = ~167 TPS 持续 Gateway 请求
|
||||
- **修复方向:** Gateway 并发限速配置 + 批量 API 合并 + Sorted Set 分片(多个 queue key)
|
||||
|
||||
### 第二个会触发的瓶颈:佣金计算的 DB 递归查询
|
||||
|
||||
- **现状:** `WITH RECURSIVE` 或逐级 parent_id 查询,最深 7 层
|
||||
- **触发条件:** 单日 > 1 万笔订单,并发处理时 DB CPU 飙升
|
||||
- **修复方向:** 代理链 Redis 缓存(shop_id → ancestor_chain);写入时物化路径 `path` 字段
|
||||
|
||||
### 第三个会触发的瓶颈:Order Service 耦合带来的发布风险
|
||||
|
||||
- **现状:** 2365 行,支付/回调/钱包全耦合
|
||||
- **触发条件:** 添加新支付方式(富友小程序)或退款逻辑时,改动影响范围不可控
|
||||
- **修复方向:** 拆分子文件(见构建顺序阶段 4),不需要跨进程拆分
|
||||
|
||||
---
|
||||
|
||||
## 反模式警告
|
||||
|
||||
### 反模式 1:在支付回调中同步计算佣金
|
||||
|
||||
**What:** 支付回调 Handler 直接在回调请求的生命周期内计算完整的多级佣金链
|
||||
**Why bad:** 回调超时 → 第三方重推 → 佣金重复计算;而且 Gateway 通常要求快速响应(3s 内)
|
||||
**Do this instead:** 回调只更新订单状态,通过 Asynq 任务异步计算佣金(当前架构已正确实现)
|
||||
|
||||
### 反模式 2:代理层级递归查询不加缓存
|
||||
|
||||
**What:** 每次数据权限过滤都调用 `WITH RECURSIVE` 查下级 shop_id
|
||||
**Why bad:** 高并发下递归查询是 DB 杀手;7 层 × 每层 N 个子节点 = 指数级扫描
|
||||
**Do this instead:** `GetSubordinateShopIDs` 结果 Redis 缓存 30 分钟(当前已实现),注意缓存失效策略
|
||||
|
||||
### 反模式 3:流量数据直接被上游覆盖写
|
||||
|
||||
**What:** Gateway 上报当月累计流量 → 直接 UPDATE current_month_usage_mb
|
||||
**Why bad:** 上游自然月归零时,历史用量记录丢失;无法回查历史;无法审计计费
|
||||
**Do this instead:** 日粒度缓冲(方案 E):增量写入 `tb_card_daily_usage`,查询层合并 Redis + DB
|
||||
|
||||
### 反模式 4:把租户隔离硬编码在每个 Store 文件
|
||||
|
||||
**What:** 54 个 Store 文件各自调用 ApplyShopFilter,过滤逻辑分散
|
||||
**Why bad:** 新增 Store 文件时容易遗漏;SaaS 化时难以统一改造
|
||||
**Do this instead:** MVP 阶段保持现状(显式>隐式);SaaS 化时考虑统一 TenantContext 注入层 + 强制 lint 检测
|
||||
|
||||
### 反模式 5:Worker 内两套任务投递机制
|
||||
|
||||
**What:** 轮询实名激活通过 `Redis RPush` 投递,其他任务走 Asynq
|
||||
**Why bad:** 两套消费机制,Asynq 的可观测性/重试/超时管理对 RPush 任务无效
|
||||
**Do this instead:** Worker Bootstrap 注入 `asynq.Client`,统一走 Asynq(方案 A-3)
|
||||
|
||||
---
|
||||
|
||||
## 集成点分析
|
||||
|
||||
### 外部服务
|
||||
|
||||
| 服务 | 集成模式 | 关键注意点 |
|
||||
|------|---------|-----------|
|
||||
| 运营商 Gateway | 同步 HTTP 调用(`internal/gateway/`) | Gateway 宕机时需降级;速率限制;超时设置 |
|
||||
| 微信支付(JSAPI/H5)| PowerWeChat SDK | 配置已迁移至 `tb_wechat_config`,但 OAuth 仍读环境变量(待修复) |
|
||||
| 富友支付 | 自研 SDK(pkg/fuiou/) | SDK 完整,主调接口仍留桩(方案 J-1)|
|
||||
| 联通云 OSS | S3 兼容 SDK | 预签名 URL 上传,异步导入任务 |
|
||||
|
||||
### 内部模块边界
|
||||
|
||||
| 边界 | 通信方式 | 注意事项 |
|
||||
|------|---------|---------|
|
||||
| API → Worker | Asynq 任务队列(Redis) | 任务投递幂等;失败重试需要业务幂等保证 |
|
||||
| Order ↔ Commission | 异步(支付回调完成后投递任务) | 不能同步等待佣金计算完成 |
|
||||
| Package ↔ IoT Card | 同步(Service 层直接调用) | 套餐激活时需持有卡信息,单事务 |
|
||||
| Polling ↔ Gateway | 同步 HTTP(Worker 内) | 限速;超时;Gateway 不可用时不应导致整个轮询系统崩溃 |
|
||||
| Auth ↔ RBAC | 同步 Redis + DB(中间件层) | SubordinateShopIDs 缓存是性能关键 |
|
||||
|
||||
---
|
||||
|
||||
## 架构变更的高风险区
|
||||
|
||||
基于当前代码分析,以下区域变更风险最高,需要在 roadmap 中单独规划:
|
||||
|
||||
| 区域 | 风险原因 | 建议处理方式 |
|
||||
|------|---------|------------|
|
||||
| `internal/service/order/service.go` | 2365 行,全链路耦合 | 先修复 Bug,再在低风险时拆分 |
|
||||
| 流量计费体系(方案 E) | 涉及 DB 迁移 + Redis 架构 + 查询兼容 | 单独发布窗口,充分测试迁移脚本 |
|
||||
| 支付配置(微信/富友切换)| 半迁移状态(环境变量 vs DB 配置同时存在)| 明确单一配置源后再添加新支付方式 |
|
||||
| 轮询系统(Polling Scheduler)| RPush 降级路径 + asynqClient 缺失 | A-3 修复后,逐步迁移到统一 Asynq 路径 |
|
||||
| 提现并发逻辑(A-7, C-3)| 并发锁逻辑不完整,线上可能已有脏数据 | 修复时需同步修复历史数据 |
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- 当前代码库深度分析(2026-03-27):`internal/service/`, `internal/store/postgres/`, `internal/task/`, `internal/routing/`
|
||||
- 多层级佣金系统架构设计(TechNova,2026-02-28):物化路径 vs 邻接表 vs 闭包表对比,CAP 权衡,批流一体
|
||||
- Multi-Tenant SaaS Isolation(AverageDevs,2026-01-25):pooled tables vs schema per tenant,Row Level Security,noisy neighbor 防护
|
||||
- Postgres Multitenancy 2025(DebuggAI):RLS vs Schema vs 独立 DB 性能对比
|
||||
- Go Worker Pool + Asynq 并发控制(BackendBytes,OneUptime,2026):任务队列分区,并发控制
|
||||
|
||||
---
|
||||
|
||||
*Architecture research for: IoT 卡管理平台(君鸿卡管系统)*
|
||||
*Researched: 2026-03-27*
|
||||
263
.planning/research/FEATURES.md
Normal file
263
.planning/research/FEATURES.md
Normal file
@@ -0,0 +1,263 @@
|
||||
# Feature Research
|
||||
|
||||
**Domain:** 物联网卡/号卡/设备全生命周期管理平台(面向代理商 B 端 + C 端个人用户)
|
||||
**Researched:** 2026-03-27
|
||||
**Confidence:** HIGH(主要来源:广梦云/SIMBOSS/Simbest/iotcard 实际竞品研究 + 项目现有功能盘点)
|
||||
|
||||
---
|
||||
|
||||
## 背景:已实现功能(Brownfield 基线)
|
||||
|
||||
以下功能已在代码库中落地,本文档**不重复列出**,仅聚焦**缺口分析**:
|
||||
|
||||
| 已实现模块 | 关键能力 |
|
||||
|-----------|---------|
|
||||
| IoT 卡生命周期 | 开卡/激活/停复机/销户 |
|
||||
| 套餐系统 | 主套餐/加油包/排队/囤货待实名激活/流量扣减 |
|
||||
| 订单系统 | 5 种购买场景,微信/富友/钱包支付 |
|
||||
| 代理商体系 | 7 级层级,差价佣金 + 一次性佣金,提现申请流程 |
|
||||
| RBAC 权限 | 角色/权限/菜单,基于店铺层级数据过滤 |
|
||||
| C 端认证 | 微信 OAuth + 手机号绑定 |
|
||||
| 设备管理 | 绑卡/解绑/批量 Excel 导入 |
|
||||
| 流量轮询 | 实名状态/流量/套餐余额,Asynq + Redis Sorted Set |
|
||||
| 退款流程 | tb_refund_request 模型,7 个退款接口(方案 I,已规划) |
|
||||
|
||||
---
|
||||
|
||||
## Feature Landscape
|
||||
|
||||
### Table Stakes(用户期望有,缺了就感觉产品不完整)
|
||||
|
||||
#### 卡/资产管理
|
||||
|
||||
| Feature | Why Expected | Complexity | 当前状态 | Notes |
|
||||
|---------|--------------|------------|---------|-------|
|
||||
| 卡批量操作(停/复/注销) | 运营场景必须,一次管几百张卡不能单张操作 | MEDIUM | ❌ 缺失 | 已有单卡接口,缺批量接口;广梦云/SIMBOSS 均有 |
|
||||
| 卡信息批量导出(Excel/CSV) | 资产台账、对账、交接必须 | LOW | ❌ 缺失 | 已有导入;竞品均有导出;格式含 ICCID/IMEI/状态/套餐/流量 |
|
||||
| 实名状态手动刷新(单卡/批量) | 运营商实名状态有延迟,客服需要手动触发同步 | LOW | ⚠️ 部分 | 有轮询系统但无手动触发单卡实名刷新接口 |
|
||||
| 卡状态变更历史记录 | 出现纠纷时需要溯源"什么时候谁停了这张卡" | MEDIUM | ❌ 缺失 | 审计日志只覆盖账号操作,不覆盖卡操作 |
|
||||
| 流量使用日粒度详单 | C 端用户最常问"今天用了多少流量" | MEDIUM | ⚠️ 规划中 | 方案 E(流量体系改革)已规划 tb_card_daily_usage |
|
||||
|
||||
#### 套餐/订单
|
||||
|
||||
| Feature | Why Expected | Complexity | 当前状态 | Notes |
|
||||
|---------|--------------|------------|---------|-------|
|
||||
| 订单搜索与筛选(多维度) | 运营/客服必须能按 ICCID/手机号/订单号/状态/时间段查单 | LOW | ⚠️ 部分 | 基础筛选存在,高级组合筛选可能不完整 |
|
||||
| 订单批量导出 | 财务对账、月度报告 | LOW | ❌ 缺失 | 竞品标配;广梦云最近大幅提升导出速度 |
|
||||
| 退款完整流程(申请/审批/退回) | 用户错购、套餐未生效退款场景必须有 | HIGH | ⚠️ 规划中 | 方案 I 已规划 7 个退款接口,尚未实现 |
|
||||
| 订单超时提醒/催付 | 代理采购大额预存时需要提醒 | LOW | ❌ 缺失 | 已有 30 分钟自动取消,无催付通知 |
|
||||
|
||||
#### 代理/分销
|
||||
|
||||
| Feature | Why Expected | Complexity | 当前状态 | Notes |
|
||||
|---------|--------------|------------|---------|-------|
|
||||
| 代理商资金流水(充值/消费/佣金/提现完整账单) | 代理商需要对自己的每一笔资金进来去出有清晰记录 | MEDIUM | ⚠️ 部分 | 有钱包余额,但完整流水账单(按时间倒序,标注来源类型)不确定是否完整 |
|
||||
| 套餐分配记录(谁分配了给谁,什么时候) | 分配纠纷溯源必须 | LOW | ⚠️ 部分 | asset_allocation_record 已实现,但可能不全面 |
|
||||
| 下级代理业绩报表 | 代理商管理下级时必须知道业绩 | MEDIUM | ❌ 缺失 | 有佣金统计 commission_stats,但完整业绩报表(订单数/金额/活跃卡数)缺失 |
|
||||
| 代理商余额充值(线上转账充值) | 代理预存款是核心资金流入,不能只靠线下 | MEDIUM | ⚠️ 部分 | agent_recharge 存在,但富友支付 JSAPI 接口为留桩(方案 J-1)|
|
||||
|
||||
#### C 端体验
|
||||
|
||||
| Feature | Why Expected | Complexity | 当前状态 | Notes |
|
||||
|---------|--------------|------------|---------|-------|
|
||||
| C 端查询套餐列表与详情 | 用户购套餐前必须看清楚买的是什么 | LOW | ⚠️ 部分 | customer_view_service 存在,但套餐展示完整性待确认 |
|
||||
| 套餐到期提醒(微信通知/短信) | 用户断网会直接差评,预告到期是行业标配 | MEDIUM | ❌ 缺失 | 无主动推送机制;竞品用微信模板消息/公众号推送 |
|
||||
| 充值记录/消费历史 | 用户自助查账必须 | LOW | ⚠️ 部分 | 有钱包,但消费历史接口(按时间倒序列表)待确认 |
|
||||
| 卡/设备转手后钱包自然转移展示 | 钱包绑卡而非绑人的设计已实现,需 C 端有清晰提示 | LOW | ✅ 基本实现 | 设计已对 C 端透出 |
|
||||
|
||||
#### 实名认证(中国市场法规强制)
|
||||
|
||||
| Feature | Why Expected | Complexity | 当前状态 | Notes |
|
||||
|---------|--------------|------------|---------|-------|
|
||||
| 二要素身份验证(姓名+身份证) | 工信部实名制要求,物联网卡/号卡必须实名 | MEDIUM | ⚠️ 部分 | 有实名状态轮询,但主动二要素接口验证待确认 |
|
||||
| 三要素身份验证(姓名+身份证+手机号) | 号卡开户严格场景 | MEDIUM | ❌ 可能缺失 | 广梦云 v2.14.8 专门完善了三要素验证 |
|
||||
| 实名认证状态全链路可见(平台→代理→C端) | 合规审计必须 | LOW | ⚠️ 部分 | 实名状态字段存在,但链路完整性待确认 |
|
||||
|
||||
---
|
||||
|
||||
### Differentiators(竞争差异化功能)
|
||||
|
||||
| Feature | Value Proposition | Complexity | 优先级 | Notes |
|
||||
|---------|-------------------|------------|-------|-------|
|
||||
| 流量池(多卡共享流量) | 降低单卡超额风险,SIMBOSS/广梦云核心卖点,运营商原生支持 | HIGH | P2 | 当前每卡独立流量;实现需运营商 API 支持 |
|
||||
| 数据大屏/经营报表(首页工作台) | 让代理一眼看清业绩,销售额/活跃卡数/今日新增 | MEDIUM | P2 | 广梦云 v2.7.13 增加工作台;核心商业决策支持 |
|
||||
| 开放 API 平台(供企业客户对接) | 企业客户有自有系统,需要 API 接入卡信息/套餐/流量 | HIGH | P2 | Simbest/SIMBOSS 均有 SDK;广梦云有开放平台 |
|
||||
| 短信通知(套餐到期/流量告警/提现审批) | 微信通知依赖公众号关注,短信是兜底保障 | MEDIUM | P2 | 广梦云 v0.1.6 就支持;触达率关键 |
|
||||
| 微信公众号模板消息推送 | 在微信生态内操作,公众号消息触达率最高 | MEDIUM | P2 | 广梦云/SIMBOSS 标配;需绑定公众号 APPID |
|
||||
| 流量告警(达量预警/超量告警) | 避免客户意外断网,自动预警降低客服工单 | MEDIUM | P2 | 轮询系统框架存在 polling_alert;但告警通知渠道未接通 |
|
||||
| AI 推广文案生成 | 代理商自动生成商品推广素材,降低运营门槛 | MEDIUM | P3 | 广梦云 v2.11 增加 AI 推广文案;ToB 差异化 |
|
||||
| 电子签约合同(代理商签约) | 合规合同管理,法律保障 | HIGH | P3 | 广梦云 v0.12 增加;降低纠纷风险 |
|
||||
| LBS 基站定位(卡位置查询) | IoT 场景设备追踪 | HIGH | P3 | iotcard/SIMBOSS 支持;需运营商 API |
|
||||
| 卡在线诊断(信号/状态/异常) | 客服自助排查工单,减少人工 | HIGH | P3 | iotcard 有"在线诊断"功能 |
|
||||
| 多支付方式(支付宝 + 微信) | 部分用户无微信支付,支付宝是标配 | MEDIUM | P2 | 当前仅微信 + 富友 + 钱包;缺支付宝 |
|
||||
| 代理商独立品牌域名/店铺定制 | 白标分销,代理商可用自己品牌 | HIGH | P3 | SIMBOSS/广梦云核心卖点;v2 方向 |
|
||||
|
||||
---
|
||||
|
||||
### Anti-Features(看起来好,实际容易踩坑的功能)
|
||||
|
||||
| Feature | Why Requested | Why Problematic | Alternative |
|
||||
|---------|---------------|-----------------|-------------|
|
||||
| 多租户 SaaS 架构(现阶段) | 想一套系统卖给多个卡商 | 当前 MVP 阶段做 SaaS 重构会推迟上线 6-12 个月;数据隔离模型完全不同 | 单租户先上线验证,v2 规划 SaaS;已在 Out of Scope |
|
||||
| 设备定期后台轮询 | 想实时掌握设备在线状态 | 高频轮询对运营商 API 有调用限额,极易触发封禁;成本高 | 按需实时拉取(sync-info),已决策 |
|
||||
| 自动化测试覆盖 | 提升代码质量信心 | 项目明确不做,IoT 卡状态依赖运营商真实回调,Mock 成本极高 | 人工验收 + 生产日志监控;已在项目约束中 |
|
||||
| 实时所有卡流量推送 | 想让用户实时看到流量 | WebSocket 长连接并发维护成本极高;百万卡量级时不可行 | 轮询拉取 + 流量超阈值主动告警即可 |
|
||||
| 全球/国际物联网卡管理 | 扩展市场 | 需要对接 Jasper/SIM Alliance 等国际 CMP,运营商 API 完全不同,开发量是现有的 2-3 倍 | 先做国内三大运营商 + 广电,国际方向单独立项 |
|
||||
| 卡号靓号系统(号卡场景) | 号卡分销平台都有 | 物联网卡无靓号概念;号卡业务涉及选号/归属地/运营商配号,与物联网卡逻辑完全不同 | 物联网卡和号卡维护分离的套餐/渠道体系 |
|
||||
|
||||
---
|
||||
|
||||
## Feature Dependencies
|
||||
|
||||
```
|
||||
退款完整流程(方案I)
|
||||
└──requires──> 订单系统(已完成)
|
||||
└──requires──> 代理佣金计算(已完成)
|
||||
|
||||
批量操作(停/复/导出)
|
||||
└──requires──> 单卡操作接口(已完成)
|
||||
|
||||
套餐到期提醒(微信/短信)
|
||||
└──requires──> 微信公众号配置(wechat_config 已实现)
|
||||
└──requires──> 短信服务商接入(❌ 未接入)
|
||||
|
||||
流量告警通知
|
||||
└──requires──> 轮询系统 alert_service(已实现,告警规则存在)
|
||||
└──requires──> 通知渠道(短信/微信,❌ 未打通)
|
||||
|
||||
数据报表/工作台
|
||||
└──enhances──> 代理佣金统计(commission_stats 已实现)
|
||||
└──enhances──> 订单系统(已完成)
|
||||
|
||||
流量池(共享池)
|
||||
└──requires──> 运营商 API 支持流量池管理(独立对接)
|
||||
└──conflicts──> 当前每卡独立流量计费模型
|
||||
|
||||
开放 API 平台
|
||||
└──requires──> 认证体系(已完成)
|
||||
└──enhances──> 企业客户管理(已完成)
|
||||
|
||||
二/三要素实名验证
|
||||
└──enhances──> 实名认证轮询(已完成)
|
||||
└──requires──> 第三方实名验证服务商接入(国政通/公安部接口)
|
||||
```
|
||||
|
||||
### Dependency Notes
|
||||
|
||||
- **退款 requires 佣金计算**:退款审批通过后需按比例扣减代理佣金钱包,佣金必须已正确落账
|
||||
- **告警 requires 通知渠道**:alert_service 框架已有,但告警结果只写日志,未接短信/微信推送
|
||||
- **流量池 conflicts 当前计费模型**:流量池是设备级/卡群组共享,与当前每套餐独立计费逻辑有冲突,需专项设计
|
||||
- **二要素验证 requires 第三方接口**:国政通/华道数据/阿里实人认证等,需采购接入
|
||||
|
||||
---
|
||||
|
||||
## MVP Definition
|
||||
|
||||
### 当前阶段:修复上线(v1 Production Ready)
|
||||
|
||||
必须完成(对应 PROJECT.md Active 方案 A-J):
|
||||
|
||||
- [x] 实名常量修复(A-1 ~ A-3)— 主链路断裂,P0
|
||||
- [x] 充值回调 + 佣金补全(A-4 ~ A-5)— 资金链路,P0
|
||||
- [x] 退款完整流程(方案 I)— 用户期望有,缺了影响信任
|
||||
- [x] 订单批量导出 — 财务对账必须
|
||||
- [x] 卡批量操作(停/复) — 运营必须
|
||||
- [x] 富友支付 JSAPI(J-1)— 支付链路完整性
|
||||
|
||||
### Add After Validation(v1.x,上线后 1-2 月内)
|
||||
|
||||
- [ ] 套餐到期提醒(微信模板消息) — 核心留存功能,上线第一周就会收到投诉
|
||||
- [ ] 流量告警通知渠道打通(短信/微信) — polling_alert 框架已有,补上通知
|
||||
- [ ] 代理商业绩报表(工作台首页) — 增加代理黏性
|
||||
- [ ] 卡状态变更历史 — 减少客服纠纷
|
||||
|
||||
### Future Consideration(v2+)
|
||||
|
||||
- [ ] 流量池(共享池)— 需运营商 API 合作,独立立项
|
||||
- [ ] 开放 API 平台 — 企业客户集成需求
|
||||
- [ ] 多租户 SaaS — 卖给其他卡商
|
||||
- [ ] LBS 基站定位 — IoT 定位场景
|
||||
- [ ] 支付宝支付 — 扩大支付覆盖,目前微信+富友已覆盖主流
|
||||
|
||||
---
|
||||
|
||||
## Feature Prioritization Matrix
|
||||
|
||||
### 缺口功能优先级
|
||||
|
||||
| Feature | User Value | Implementation Cost | Priority | 对应方案 |
|
||||
|---------|------------|---------------------|----------|---------|
|
||||
| 退款流程(申请/审批/退回) | HIGH | HIGH | P1 | 方案 I |
|
||||
| 富友支付 JSAPI 实现 | HIGH | MEDIUM | P1 | 方案 J-1 |
|
||||
| 卡批量操作(停/复/导出) | HIGH | LOW | P1 | 新增 |
|
||||
| 订单批量导出 | HIGH | LOW | P1 | 新增 |
|
||||
| 套餐到期微信推送 | HIGH | MEDIUM | P1 | 新增 |
|
||||
| 流量告警通知渠道 | HIGH | MEDIUM | P1 | 补全 alert_service |
|
||||
| 代理商资金流水完整账单 | MEDIUM | LOW | P2 | 补全 |
|
||||
| 代理商业绩报表(工作台) | MEDIUM | MEDIUM | P2 | 新增 |
|
||||
| 卡状态变更历史 | MEDIUM | LOW | P2 | 新增 |
|
||||
| 二要素实名验证接口 | MEDIUM | MEDIUM | P2 | 新增 |
|
||||
| 流量池(共享池) | HIGH | HIGH | P3 | v2 |
|
||||
| 开放 API 平台 | MEDIUM | HIGH | P3 | v2 |
|
||||
| LBS 定位 | LOW | HIGH | P3 | v2 |
|
||||
|
||||
**Priority key:**
|
||||
- P1: 必须有,影响上线质量
|
||||
- P2: 上线后优先补充,影响留存
|
||||
- P3: 竞争差异化,长期方向
|
||||
|
||||
---
|
||||
|
||||
## Competitor Feature Analysis
|
||||
|
||||
| Feature | 广梦云(竞品) | SIMBOSS(竞品) | Simbest(竞品) | 君鸿当前状态 |
|
||||
|---------|--------------|---------------|---------------|------------|
|
||||
| 批量停/复/注销 | ✅ 有 | ✅ 有 | ✅ 有 | ❌ 缺 |
|
||||
| 批量数据导出 | ✅ 有(高速) | ✅ 有 | ✅ 有 | ❌ 缺 |
|
||||
| 流量告警 + 通知 | ✅ 微信公众号告警 | ✅ 达量告警 | ✅ 有 | ⚠️ 框架有,通知未打通 |
|
||||
| 套餐到期通知 | ✅ 微信模板消息 | ✅ 公众号推送 | ✅ 有 | ❌ 缺 |
|
||||
| 退款流程 | ✅ 一键退款(卡板/套餐) | ✅ 有 | ✅ 有 | ⚠️ 规划中(方案 I) |
|
||||
| 代理资金流水 | ✅ 完整账单 | ✅ 有 | ✅ 有 | ⚠️ 基础有,完整性待确认 |
|
||||
| 多支付宝支付 | ✅ 支付宝/微信/当面付 | ✅ 有 | ✅ 有 | ⚠️ 微信+富友,缺支付宝 |
|
||||
| 流量池共享 | ✅ 共享池 + 月账单 | ✅ 流量共享 | ✅ 有 | ❌ 缺(v2) |
|
||||
| 开放 API | ✅ 开放平台 + SDK | ✅ 多语言 SDK | ✅ 有 | ❌ 缺(v2) |
|
||||
| 业绩报表/工作台 | ✅ v2.7 增加工作台 | ✅ 数据助力运营 | ✅ 营收成本核算 | ❌ 缺 |
|
||||
| 代理商独立域名 | ✅ 品牌定制 | ✅ 个性化域名 | ✅ 有 | ❌ 缺(v2/SaaS) |
|
||||
| 设备 WiFi 远程控制 | ✅ v2.13 增加 | — | — | ⚠️ 设备 sync-info 规划中 |
|
||||
| 实名二/三要素验证 | ✅ 国政通对接 | ✅ 有 | ✅ 有 | ⚠️ 部分 |
|
||||
|
||||
---
|
||||
|
||||
## 中国市场特殊要求
|
||||
|
||||
### 监管合规
|
||||
- **物联网实名制**(工信部要求):所有物联网卡必须实名,平台需支持实名状态追踪和验证
|
||||
- **号卡实名**(2021 年起更严):号卡开户必须姓名+身份证+手机号三要素,平台需有三证上传
|
||||
- **广电运营商支持**:2022 年起广电(第四运营商)加入物联网卡市场,竞品已快速对接
|
||||
- **运营商 API 依赖**:
|
||||
- 移动:CT-Boss、PBoss
|
||||
- 电信:CMP、DCP
|
||||
- 联通:Jasper、CMP
|
||||
- 广电:广电直连 API
|
||||
|
||||
### 支付合规
|
||||
- 提现需支持**灵活用工平台**(代理商收入合规开票)
|
||||
- 大额打款需支持**对公转账**或**企业支付宝批量打款**
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
- **广梦云更新日志**(https://docs.facms.cn)— 2019-2026 完整版本历史,揭示了市场真实功能演进路径
|
||||
- **SIMBOSS 产品页**(https://www.simboss.com)— 核心竞品,国内头部物联网卡平台
|
||||
- **Simbest 平台**(https://www.simbest.cn)— 专注三网对接的分销管理平台
|
||||
- **iotcard IOT CardBOSS**(https://www.iotcard.com/platform)— 运营商系统深度融合的企业级方案
|
||||
- **项目 PROJECT.md + ARCHITECTURE.md** — 已实现功能基线,方案 A-J 规划
|
||||
- **置信度评估**:
|
||||
- Table Stakes 功能:HIGH(多个竞品交叉验证)
|
||||
- Differentiators:MEDIUM(竞品分析 + 市场观察,非用户调研)
|
||||
- 合规要求:HIGH(工信部政策 + 竞品普遍实现)
|
||||
|
||||
---
|
||||
*Feature research for: 物联网卡/号卡/设备全生命周期管理平台(中国市场)*
|
||||
*Researched: 2026-03-27*
|
||||
245
.planning/research/PITFALLS.md
Normal file
245
.planning/research/PITFALLS.md
Normal file
@@ -0,0 +1,245 @@
|
||||
# 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*
|
||||
353
.planning/research/STACK.md
Normal file
353
.planning/research/STACK.md
Normal file
@@ -0,0 +1,353 @@
|
||||
# Stack Research
|
||||
|
||||
**Domain:** IoT 卡管理平台 — SIM 卡全生命周期 + 代理分销 + 分佣结算
|
||||
**Researched:** 2026-03-27
|
||||
**Confidence:** MEDIUM-HIGH(核心技术栈 HIGH,外围选项 MEDIUM)
|
||||
|
||||
---
|
||||
|
||||
## 结论先行:当前技术栈评估
|
||||
|
||||
**整体结论:当前技术选型对于该领域是合理的,无需大规模替换。**
|
||||
存在 3 个明确的技术债和 1 个值得关注的升级时机。
|
||||
|
||||
| 组件 | 当前版本 | 最新版本 | 状态 | 建议 |
|
||||
|------|---------|---------|------|------|
|
||||
| Fiber | v2.52.9 | **v3.1.0** | ⚠️ 大版本落后 | 保持 v2,不升级(见分析) |
|
||||
| GORM | v1.31.1 | v1.31.x | ✅ 最新 | 继续使用 |
|
||||
| Asynq | v0.25.1 | **v0.26.0** | ⚠️ 落后一个版本 | 可选升级(安全依赖升级) |
|
||||
| go-redis | v9.7.0 | v9.18.0 | ⚠️ 落后较多 | 建议升级(无 API 破坏性变更) |
|
||||
| sonic | v1.14.2 | v1.14.x | ✅ 近期版本 | 继续使用 |
|
||||
| PowerWeChat | v3.4.38 | v3.4.38 | ✅ 最新 | 继续使用 |
|
||||
| Go runtime | 1.25.x | 1.25.x | ✅ 最新 | 继续使用 |
|
||||
|
||||
---
|
||||
|
||||
## 核心技术栈
|
||||
|
||||
### 一、HTTP 框架 — Fiber v2(**继续坚守**)
|
||||
|
||||
| 项目 | 内容 |
|
||||
|------|------|
|
||||
| 当前版本 | v2.52.9(已是 v2.x 最新) |
|
||||
| 最新大版本 | v3.1.0(2026-02-02 正式发布) |
|
||||
| **不升级的原因** | v3 有重大破坏性变更,迁移成本高,且本项目有大量存量代码 |
|
||||
|
||||
**v3 核心破坏性变更(对本项目影响分析):**
|
||||
|
||||
1. **`c.UserContext()` 被移除** → `Ctx` 本身现在实现 `context.Context`,直接传 `c` 即可。
|
||||
- **影响:**本项目全代码库大量使用 `c.UserContext()` 传递用户 Context 到 Service/Store 层,这是最大的迁移成本。
|
||||
- **估算:**全局搜索 `UserContext()` 调用点,预估 100+ 处改动。
|
||||
|
||||
2. **`c.BodyParser()` 移除** → 改用 `c.Bind().Body()`
|
||||
- **影响:**所有 Handler 层的请求解析代码需要改写。
|
||||
|
||||
3. **路由注册 `Add` 方法签名变更** → 影响 `internal/routes/registry.go` 的统一注册逻辑。
|
||||
|
||||
4. **`app.Mount()` 移除** → 使用 `app.Use()`。
|
||||
|
||||
**置信度:HIGH**(官方文档 https://docs.gofiber.io/whats_new/ 已确认)
|
||||
|
||||
**建议:** 保持 v2.52.x,待 v2 EOL 公告或下一个重大重构机会再考虑升级。v2 安全维护至少到 2026 年底,风险可控。
|
||||
|
||||
---
|
||||
|
||||
### 二、ORM — GORM v2(**继续使用,无更好替代方案**)
|
||||
|
||||
**2026 年 Go ORM 格局:**
|
||||
|
||||
| ORM | 定位 | 适合本项目 |
|
||||
|-----|------|-----------|
|
||||
| **GORM** | 全功能 ORM,code-first,ActiveRecord 风格 | ✅ **最适合** |
|
||||
| sqlc | 编译时类型安全,从 SQL 生成 Go 代码 | ❌ 需要从头重写所有查询 |
|
||||
| sqlx | SQL 轻量封装,接近原生 database/sql | ❌ 需要全部改写,收益有限 |
|
||||
| ent | 图模型 ORM,compile-time type safety | ❌ 学习成本高,编译慢,不适合现有代码库 |
|
||||
|
||||
**为什么 GORM 对本项目是正确选择:**
|
||||
- CRUD 密集型应用(卡管理、套餐管理、订单、佣金),GORM 的约定优于配置大幅减少样板代码
|
||||
- 软删除、钩子(Hooks)、事务等特性本项目大量依赖
|
||||
- 当前 v1.31.x 已是最新,无需升级
|
||||
|
||||
**GORM 的已知性能问题(针对本项目的建议):**
|
||||
- 多级代理佣金树查询(最深 7 层)不应使用 GORM `Preload`,而应使用原生 `WITH RECURSIVE` CTE
|
||||
- 批量导入(ICCID Excel)应使用 `db.CreateInBatches()` 而非逐行 `Create()`
|
||||
|
||||
**置信度:HIGH**(多个 2026 年对比文章,主流观点一致)
|
||||
|
||||
---
|
||||
|
||||
### 三、任务队列 — Asynq(**继续使用,考虑升级到 v0.26.0**)
|
||||
|
||||
**最新版本:** v0.26.0(2026-02-03 发布)
|
||||
|
||||
| 版本 | 关键变更 |
|
||||
|------|---------|
|
||||
| v0.25.1(当前) | 安全依赖升级 + 增加 `RedisUniversalClient` 支持 |
|
||||
| **v0.26.0** | 增加 Task Headers 支持(PR #1070)+ TLS 选项 + 最低 Go 版本升至 1.24 |
|
||||
|
||||
**为什么不替换 Asynq:**
|
||||
在 IoT 卡管理场景中,Asynq 的优势:
|
||||
- 基于 Redis(与现有基础设施共享,无额外运维成本)
|
||||
- 内置定时任务调度(Scheduler)适合轮询任务场景
|
||||
- 任务可视化(Asynqmon Web UI)在生产调试中有价值
|
||||
- 竞品(Machinery、Watermill)依赖更重,对单节点场景没有额外收益
|
||||
|
||||
**建议:** 升级到 v0.26.0(`go get github.com/hibiken/asynq@v0.26.0`),主要收益是上游安全依赖修复,API 完全兼容。
|
||||
|
||||
**置信度:HIGH**(GitHub Releases 直接验证)
|
||||
|
||||
---
|
||||
|
||||
### 四、Redis 客户端 — go-redis v9(**建议升级**)
|
||||
|
||||
**当前版本:** v9.7.0
|
||||
**最新版本:** v9.18.0(2026-02-16)
|
||||
|
||||
v9.7.0 → v9.18.0 跨越 11 个 patch 版本,均为向后兼容。建议升级原因:
|
||||
- 包含若干 bug 修复和性能改进
|
||||
- 升级命令:`go get github.com/redis/go-redis/v9@v9.18.0`
|
||||
|
||||
**置信度:MEDIUM**(pkg.go.dev 直接确认版本)
|
||||
|
||||
---
|
||||
|
||||
### 五、JSON 序列化 — sonic(**继续使用**)
|
||||
|
||||
**sonic(bytedance/sonic)** 在 2025 年基准测试中仍是 Go 生态最快的 JSON 库:
|
||||
|
||||
| 库 | 相对速度 | 适用场景 |
|
||||
|----|---------|---------|
|
||||
| **sonic** | ~3-4x 快于 std | 高并发 API,大 payload(如 Excel 导入响应) |
|
||||
| encoding/json v2(Go 1.25 内置) | ~1.8x 快于 std | 标准库,无需引入依赖 |
|
||||
| encoding/json(std) | 1x 基线 | 无额外依赖场景 |
|
||||
|
||||
**注意:** Go 1.25 引入了 `encoding/json/v2`,性能约为 sonic 的 50%,但消除了历史行为不一致问题。对于本项目,sonic 仍是正确选择:本项目已集成且使用 Fiber 的 sonic 替换,迁移收益不大。
|
||||
|
||||
**置信度:HIGH**(2025-09 Reddit benchmark + Trendyol 生产迁移案例)
|
||||
|
||||
---
|
||||
|
||||
## 领域特有技术栈缺口分析
|
||||
|
||||
### 缺口 1:金融精度问题 ⚠️(**重大技术债**)
|
||||
|
||||
**问题描述:**
|
||||
IoT 卡管理平台涉及两类精度敏感计算:
|
||||
1. **货币金额**:钱包余额、佣金金额(差价佣金、一次性佣金)、套餐价格
|
||||
2. **流量计费**:MB/GB 流量用量计算、按比例退款
|
||||
|
||||
**当前实现风险:**
|
||||
- 余额字段使用 `int64`(存储"分")—— **钱包余额做法正确**
|
||||
- 但佣金链计算(差价 = 上层价格 - 下层成本)中,如果涉及百分比分佣(如"提成 15%"),用 `float64` 计算后再转 `int64` 会有精度损失
|
||||
|
||||
**标准做法(2025 年行业共识):**
|
||||
|
||||
```
|
||||
选项 A:纯整数(分/厘)存储,避免所有浮点运算
|
||||
- 适合固定金额计算
|
||||
- 本项目钱包余额已是此做法 ✅
|
||||
|
||||
选项 B:shopspring/decimal(用于百分比/比例计算)
|
||||
- 适合佣金比例计算、退款按比例扣减
|
||||
- 7000+ GitHub stars,已是 Go 社区财务计算标准库
|
||||
|
||||
选项 C:go-money(Rhymond/go-money)
|
||||
- 适合多币种场景,本项目不需要
|
||||
```
|
||||
|
||||
**建议:**
|
||||
引入 `github.com/shopspring/decimal` v1.4.0,**仅用于**:
|
||||
- 佣金比例计算(如方案 I-3:退款按比例扣减代理佣金)
|
||||
- 流量按比例计费(方案 E 的流量体系改革)
|
||||
|
||||
不需要全局替换 int64 余额字段,仅在需要乘除法的计算环节使用 decimal,最终结果转 int64 存储。
|
||||
|
||||
**置信度:HIGH**(Modern Treasury 官方博客 2025-08 + shopspring/decimal 7286 stars)
|
||||
|
||||
---
|
||||
|
||||
### 缺口 2:多级代理查询性能 ⚠️(**架构风险**)
|
||||
|
||||
**问题描述:**
|
||||
本项目代理层级最多 7 层,`GetSubordinateShopIDs()` 使用 PostgreSQL `WITH RECURSIVE` CTE 查询,结果缓存 30 分钟。这个架构是正确的。
|
||||
|
||||
**潜在风险:**
|
||||
- 佣金链计算时,N 层递归分佣可能退化为 N 次单独 DB 查询(N+1 问题)
|
||||
- 7 层 × 每层查询 = 最坏 7 次 DB 往返,而 DB 查询要求 < 50ms
|
||||
|
||||
**2025 年标准方案:**
|
||||
对于多级分佣树计算,行业标准是一次性将整条链路数据加载到内存,而非逐层查询:
|
||||
|
||||
```sql
|
||||
-- 一次性获取完整佣金链(从叶子节点到根节点)
|
||||
WITH RECURSIVE commission_chain AS (
|
||||
SELECT shop_id, parent_id, level, 0 AS depth
|
||||
FROM tb_shop WHERE id = $1
|
||||
UNION ALL
|
||||
SELECT s.shop_id, s.parent_id, s.level, cc.depth + 1
|
||||
FROM tb_shop s
|
||||
INNER JOIN commission_chain cc ON s.id = cc.parent_id
|
||||
)
|
||||
SELECT * FROM commission_chain ORDER BY depth;
|
||||
```
|
||||
|
||||
当前代码已经有 `WITH RECURSIVE` 基础,但需要确认佣金计算是否利用了这个能力。
|
||||
|
||||
**置信度:MEDIUM**(基于代码库 PROJECT.md 分析 + PostgreSQL 官方文档)
|
||||
|
||||
---
|
||||
|
||||
### 缺口 3:可观测性 ⚠️(**生产上线前需解决**)
|
||||
|
||||
**问题描述:**
|
||||
当前技术栈的可观测性仅有:
|
||||
- 应用日志(Zap)
|
||||
- 访问日志
|
||||
- Asynq 任务状态(via Asynqmon 可选)
|
||||
- 健康检查 `GET /health`
|
||||
|
||||
**缺失:**
|
||||
- 没有指标(Metrics)收集
|
||||
- 没有链路追踪(Tracing)
|
||||
- 没有告警(Alerting)
|
||||
|
||||
**对于当前阶段(MVP 修复上线):** 可以暂时接受,PROJECT.md 的 Out of Scope 已明确 "P2-6 告警通知渠道 — 无关紧要,暂不做"。
|
||||
|
||||
**但如果平台上线后扩展:**
|
||||
- 轻量级方案:Prometheus + Grafana(已是 Go 社区标准)
|
||||
- 零侵入方案:OpenTelemetry Go SDK(标准化,支持后续接入任意后端)
|
||||
|
||||
**置信度:MEDIUM**(基于 Go 社区 2025 年可观测性实践)
|
||||
|
||||
---
|
||||
|
||||
### 缺口 4(IoT 领域特有):运营商网关客户端 — 无通用标准库
|
||||
|
||||
**问题描述:**
|
||||
本项目有 `internal/gateway/client.go`(外部 Gateway 服务 HTTP 客户端)。IoT SIM 卡管理平台通常需要对接:
|
||||
- GSMA M2M 标准(SM-DP+/SM-DS for eSIM)
|
||||
- 运营商私有 API(中国联通、移动、电信)
|
||||
|
||||
**2025 年实际情况:**
|
||||
国内运营商 IoT 平台(移动 OneNET、电信 CTWing、联通 Open)均为私有 REST/SOAP API,**没有标准化的 Go SDK**。
|
||||
|
||||
当前项目自研 `internal/gateway/client.go` 是唯一可行路径,符合行业惯例。
|
||||
|
||||
**置信度:HIGH**(行业调研确认,无通用 Go SDK 存在)
|
||||
|
||||
---
|
||||
|
||||
## 当前栈中已弃用/有问题的依赖
|
||||
|
||||
| 依赖 | 问题 | 建议 |
|
||||
|------|------|------|
|
||||
| `aws/aws-sdk-go` (v1) | AWS SDK Go v1 已于 2025-07-31 进入 Maintenance Mode,不再接收功能更新 | 低优先级,当前对接联通云 OSS,可继续使用;如有大范围重构可迁移到 `aws/aws-sdk-go-v2` |
|
||||
| `excelize/v2` | 无严重问题,版本跟进即可 | — |
|
||||
|
||||
**AWS SDK v1 注意:** 仅 Maintenance Mode,不是 End of Life,安全补丁仍会发布。迁移到 v2 需要较大代码改动,不建议在当前 MVP 阶段操作。
|
||||
|
||||
**置信度:MEDIUM**(AWS 官方公告,2025-07-31)
|
||||
|
||||
---
|
||||
|
||||
## 可选补充库
|
||||
|
||||
以下库不在当前技术栈中,但对特定需求有价值:
|
||||
|
||||
| 库 | 版本 | 用途 | 引入时机 |
|
||||
|----|------|------|---------|
|
||||
| `shopspring/decimal` | v1.4.0 | 佣金比例计算、退款按比例计算 | **方案 I(退款)实现时** |
|
||||
| `golang-migrate/migrate` | v4.18.x | 数据库迁移(已在项目使用) | — 已用 |
|
||||
| `hibiken/asynqmon` | latest | Asynq 任务可视化 Web UI | 生产环境调试时 |
|
||||
|
||||
---
|
||||
|
||||
## 替代方案对比
|
||||
|
||||
| 类别 | 当前选择 | 备选 | 不替换的理由 |
|
||||
|------|---------|------|------------|
|
||||
| HTTP 框架 | Fiber v2 | Gin, Echo, Chi, Fiber v3 | 大量存量代码;v3 破坏性变更成本高 |
|
||||
| ORM | GORM | sqlc, sqlx, ent | 项目已深度集成,CRUD 场景 GORM 是最优解 |
|
||||
| 任务队列 | Asynq | Machinery, Watermill, Temporal | Redis 复用优势;Temporal 对单节点过重 |
|
||||
| 消息队列 | 无(Asynq 兼任) | NATS, Kafka | 当前规模不需要独立 MQ |
|
||||
| 缓存 | Redis | Memcached | Redis 功能更丰富,已是基础设施 |
|
||||
| 数据库 | PostgreSQL | MySQL, CockroachDB | PostgreSQL 的递归 CTE 对层级数据有优势 |
|
||||
| JSON | sonic | encoding/json, json/v2 | sonic 性能优势在高并发场景显著 |
|
||||
|
||||
---
|
||||
|
||||
## 版本兼容性注意事项
|
||||
|
||||
| 依赖组合 | 状态 | 注意 |
|
||||
|---------|------|------|
|
||||
| Fiber v2 + Go 1.25 | ✅ 兼容 | Fiber v2.52.9 已适配 Go 1.25 |
|
||||
| Fiber v3 + Go 1.25 | ⚠️ 要求 Go 1.25+ | v3 以 Go 1.25 为最低版本 |
|
||||
| Asynq v0.26.0 + Go 1.25 | ✅ 兼容 | v0.26.0 要求 Go 1.24+,1.25 满足 |
|
||||
| GORM v1.31.1 + PostgreSQL 14+ | ✅ 兼容 | — |
|
||||
| PowerWeChat v3.4.38 + Go 1.25 | ✅ 兼容 | 官方声明 Go 1.23+ |
|
||||
|
||||
---
|
||||
|
||||
## 关键技术债总结(优先级排序)
|
||||
|
||||
### P0:必须在上线前解决
|
||||
|
||||
**金融精度风险**(佣金计算)
|
||||
- 检查所有涉及百分比/比例的佣金计算代码,确认没有 `float64` 中间步骤
|
||||
- 方案 I(退款按比例扣减佣金)实现时必须引入 `shopspring/decimal`
|
||||
|
||||
### P1:上线后尽快处理
|
||||
|
||||
**go-redis 版本落后**
|
||||
- 从 v9.7.0 升级到 v9.18.0,无 API 破坏性变更,安全隐患修复
|
||||
|
||||
**Asynq 版本落后**
|
||||
- 从 v0.25.1 升级到 v0.26.0,获得安全修复
|
||||
|
||||
### P2:中期规划
|
||||
|
||||
**AWS SDK v1 → v2 迁移**(对象存储)
|
||||
- 不紧急,v1 仍有安全支持,规划下一个重大重构时处理
|
||||
|
||||
**Fiber v2 → v3 迁移评估**
|
||||
- v2 仍在维护,不需要立即升级
|
||||
- 建议在明年底(2027)重新评估是否升级
|
||||
|
||||
---
|
||||
|
||||
## 安装/升级命令参考
|
||||
|
||||
```bash
|
||||
# 升级 Asynq(推荐)
|
||||
go get github.com/hibiken/asynq@v0.26.0
|
||||
|
||||
# 升级 go-redis(推荐)
|
||||
go get github.com/redis/go-redis/v9@v9.18.0
|
||||
|
||||
# 引入 decimal(P0 佣金精度修复时)
|
||||
go get github.com/shopspring/decimal@v1.4.0
|
||||
|
||||
# 升级所有兼容依赖(谨慎,逐一验证)
|
||||
go get -u ./...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Sources
|
||||
|
||||
| 来源 | 内容 | 置信度 |
|
||||
|------|------|--------|
|
||||
| https://docs.gofiber.io/whats_new/ | Fiber v3 破坏性变更完整列表 | HIGH |
|
||||
| https://newreleases.io/project/github/hibiken/asynq/release/v0.26.0 | Asynq v0.26.0 发布说明 | HIGH |
|
||||
| https://pkg.go.dev/github.com/redis/go-redis/v9 | go-redis 最新版本 v9.18.0 | HIGH |
|
||||
| https://reintech.io/blog/sqlc-vs-gorm-vs-sqlx-go-database-libraries-compared-2026 | GORM vs sqlc vs sqlx 2026 深度对比 | MEDIUM |
|
||||
| https://encore.cloud/resources/go-orms | Go ORM 2026 对比(GORM/sqlc/ent)| MEDIUM |
|
||||
| https://pkg.go.dev/github.com/shopspring/decimal@v1.4.0 | shopspring/decimal v1.4.0 | HIGH |
|
||||
| https://www.moderntreasury.com/journal/floats-dont-work-for-storing-cents | 金融精度使用整数的行业实践 | HIGH |
|
||||
| Reddit r/golang 2025-09 JSON benchmark | sonic vs std vs json/v2 基准 | MEDIUM |
|
||||
| https://pkg.go.dev/github.com/ArtisanCloud/PowerWeChat/v3@v3.4.38 | PowerWeChat 最新版本确认 | HIGH |
|
||||
|
||||
---
|
||||
|
||||
*Stack research for: IoT SIM 卡管理平台(Go + Fiber + GORM + Asynq + PostgreSQL + Redis)*
|
||||
*Researched: 2026-03-27*
|
||||
121
.planning/research/SUMMARY.md
Normal file
121
.planning/research/SUMMARY.md
Normal file
@@ -0,0 +1,121 @@
|
||||
# Research Summary
|
||||
|
||||
**Domain:** 君鸿卡管系统 — IoT 卡/号卡/设备全生命周期管理平台
|
||||
**Synthesized:** 2026-03-27
|
||||
**Sources:** STACK.md · FEATURES.md · ARCHITECTURE.md · PITFALLS.md
|
||||
|
||||
---
|
||||
|
||||
## 技术栈评估
|
||||
|
||||
**结论:当前技术选型合理,无需大规模替换。**
|
||||
|
||||
| 组件 | 状态 | 行动建议 |
|
||||
|------|------|---------|
|
||||
| Fiber v2.52.9 | ✅ 继续使用 | 不升级 v3(破坏性变更 100+ 处,迁移成本过高) |
|
||||
| GORM v1.31.x | ✅ 最新 | 继续使用;多级递归查询用原生 `WITH RECURSIVE` |
|
||||
| Asynq v0.24.x | ⚠️ 落后 | 可选升级到 v0.26.0(无破坏性变更) |
|
||||
| go-redis v9.7.0 | ⚠️ 落后 | 建议升级到 v9.18.x(无破坏性变更) |
|
||||
| sonic / PowerWeChat | ✅ 正常 | 继续使用 |
|
||||
|
||||
**技术债**:佣金计算全程使用 `int64` 整数运算是正确的;退款比例计算(方案 I-3)禁止用 `float64`,应使用整数算术 `a * b / c`。
|
||||
|
||||
---
|
||||
|
||||
## 功能完整性分析
|
||||
|
||||
### 已覆盖的 Table Stakes ✅
|
||||
- IoT 卡全生命周期(开卡/激活/停复机/销户)
|
||||
- 套餐系统(主套餐/加油包/排队/囤货待实名/流量扣减)
|
||||
- 五种订单购买场景 + 三种支付方式
|
||||
- 代理商多级分销(7 级层级,差价佣金+一次性佣金)
|
||||
- RBAC 权限系统 + 多租户数据隔离
|
||||
- C 端微信认证 + 钱包充值购包
|
||||
|
||||
### 缺失的 Table Stakes ⚠️(修复阶段应关注)
|
||||
|
||||
| 功能 | 优先级 | 说明 |
|
||||
|------|--------|------|
|
||||
| 卡/资产批量操作(停/复/注销) | HIGH | 运营场景必须,单卡操作无法应对批量场景 |
|
||||
| 卡/订单批量导出(Excel) | HIGH | 财务对账必须,已有导入但缺导出 |
|
||||
| 退款完整流程 | HIGH | 方案 I 已规划,必须实现 |
|
||||
| 卡状态变更历史记录 | MEDIUM | 出现纠纷时需要溯源 |
|
||||
| 套餐到期提醒(微信模板消息) | MEDIUM | 用户断网会差评,竞品标配 |
|
||||
| 代理商完整资金流水账单 | MEDIUM | 代理需要对账 |
|
||||
| 富友支付 JSAPI/小程序接口补全 | HIGH | 方案 J-1,SDK 已实现但接口是留桩 |
|
||||
|
||||
### 竞品差异化(v2+ 方向)
|
||||
- 智能套餐推荐(基于历史用量)
|
||||
- 告警推送(流量耗尽前预警)
|
||||
- 数据大屏(代理商业绩可视化)
|
||||
|
||||
---
|
||||
|
||||
## 架构风险
|
||||
|
||||
### 当前正确的设计决策 ✅
|
||||
- 双进程隔离(API vs Worker)— 独立扩容
|
||||
- 数据权限显式过滤(非 GORM Callback)— 可见性强
|
||||
- Asynq 任务队列解耦同步/异步链路
|
||||
- Bootstrap 集中依赖装配
|
||||
|
||||
### 需要立即处理的架构问题 🔴
|
||||
|
||||
**1. PollingHandler 缺少 asynqClient(方案 A-3)**
|
||||
- 实名激活任务通过 RPush Redis List 降级,绕过 Asynq
|
||||
- 修复后才能保证异步任务链路可靠性
|
||||
|
||||
**2. 多个 Service 实例依赖注入残缺(方案 F-6)**
|
||||
- StopResumeCallback / ResumeCallback 未在 bootstrap 注入
|
||||
- 停复机后套餐联动回调不触发
|
||||
|
||||
**3. Worker 注册遗漏(方案 A-4)**
|
||||
- AutoPurchaseHandler 未在 handler.go 注册
|
||||
- 充值回调后自动购包任务入队但永远不消费
|
||||
|
||||
### SaaS 化时的潜在瓶颈(供参考,v2+ 考量)
|
||||
- 代理层级 7 级限制需松绑为配置化
|
||||
- tb_shop/tb_enterprise 的 owner_shop_id 设计需调整为 tenant_id
|
||||
- 套餐成本价体系(每个代理一套)将成为最大多租户难点
|
||||
|
||||
---
|
||||
|
||||
## 关键陷阱(超出修正方案范围)
|
||||
|
||||
### 🔴 高危
|
||||
|
||||
**1. sellerCostPrice 赋值错误可能不止 1 处(A-5 相关)**
|
||||
- 修正方案只点名场景 5,但 order/service.go 中有 6 处 `sellerCostPrice = buyerCostPrice`
|
||||
- 修复时必须逐一验证每个场景的语义正确性
|
||||
|
||||
**2. 退款佣金回扣禁用 float64(方案 I-3)**
|
||||
- `ratio := float64(x) / float64(y)` 会引入精度误差
|
||||
- 应使用:`deductAmount = commission.Amount * approvedAmount / actualAmount`(纯整数)
|
||||
|
||||
**3. 分佣树形遍历可能循环引用**
|
||||
- `shop.ParentID` 向上遍历没有深度上限和循环检测
|
||||
- 人工修改数据库可能导致 Worker 死循环
|
||||
|
||||
### 🟡 中危
|
||||
|
||||
**4. 店铺软删除没有级联检查**
|
||||
- 删除有子代理的店铺后,子代理 parent_id 指向软删除记录
|
||||
- 佣金计算时找不到父链接,上级代理静默丢失佣金
|
||||
|
||||
**5. 实名状态轮询无限重试设计**
|
||||
- 卡在"未实名"状态的卡每次轮询都入队,没有最大重试次数限制
|
||||
- 大量长期未实名的卡会持续占用轮询资源
|
||||
|
||||
---
|
||||
|
||||
## 构建优先级建议
|
||||
|
||||
```
|
||||
立即(1-2 天):方案 A(7 个 P0 Bug)
|
||||
近期(3-5 天):方案 B/C/G/H + J-4(企业权限/提现/实名重构/轮询小修)
|
||||
中期(1-2 周):方案 D/I/J-1/J-2/J-3/F(设备/退款/富友支付/代码清理)
|
||||
长期(低峰期):方案 E(流量体系改革,破坏性 DB 迁移)
|
||||
```
|
||||
|
||||
---
|
||||
*Research synthesized: 2026-03-27*
|
||||
324
CLAUDE.md
324
CLAUDE.md
@@ -624,3 +624,327 @@ func RedisOrderCreateLockKey(carrierType string, carrierID uint) string
|
||||
big deal of it.
|
||||
|
||||
4. **Never interrupt the flow** to give grammar lessons. Corrections are silent and brief — the user's focus is on the task, not the language.
|
||||
|
||||
<!-- GSD:project-start source:PROJECT.md -->
|
||||
## Project
|
||||
|
||||
**君鸿卡管系统**
|
||||
|
||||
物联网卡(IoT SIM)+ 号卡 + 设备全生命周期管理平台,支持多级代理商体系和分佣结算。平台以 B 端代理商为核心分销渠道,兼顾 C 端个人客户和企业客户,覆盖从卡/设备采购、分配、激活、套餐购买到佣金结算的完整业务链路。当前处于 MVP 阶段,核心功能模块已完成,目标是修复所有已知业务链路断点、补全缺失功能,将系统调整至可生产上线状态。
|
||||
|
||||
**Core Value:** **业务链路完整可用**——代理能采购分配卡/设备,C端客户能充值购包激活套餐,佣金能正确计算并可提现。这条主链路必须端到端跑通。
|
||||
|
||||
### Constraints
|
||||
|
||||
- **Tech Stack**: 严格使用 Fiber v2 + GORM + Asynq + PostgreSQL + Redis,不引入替代框架
|
||||
- **No Tests**: 项目不写自动化测试,人工验收(rg + go build + DBHub + 日志)
|
||||
- **Language**: 所有注释/日志/错误消息/文档使用中文,变量/函数名使用英文
|
||||
- **No Foreign Keys**: 禁止数据库外键约束,关联通过 ID 字段在代码层维护
|
||||
- **方案 E 风险**: 流量体系改革涉及 DB 迁移 + Redis 架构,必须低峰期发布,单独规划
|
||||
<!-- GSD:project-end -->
|
||||
|
||||
<!-- GSD:stack-start source:codebase/STACK.md -->
|
||||
## Technology Stack
|
||||
|
||||
## Languages
|
||||
- Go 1.25.x - 应用代码、API、Worker、OpenAPI 生成和基础设施脚本,依据 `go.mod`、`cmd/api/main.go`、`cmd/worker/main.go`、`cmd/gendocs/main.go`
|
||||
- YAML - 配置与部署编排,见 `pkg/config/defaults/config.yaml`、`docker-compose.prod.yml`、`.gitea/workflows/deploy.yaml`
|
||||
- Dockerfile - API/Worker 容器构建,见 `Dockerfile.api`、`Dockerfile.worker`
|
||||
- Makefile - 本地构建、文档生成、迁移命令,见 `Makefile`
|
||||
## Runtime
|
||||
- Go runtime;API 服务基于 Fiber HTTP 服务器,见 `cmd/api/main.go`
|
||||
- 独立 Worker 进程处理异步任务与调度,见 `cmd/worker/main.go`
|
||||
- 生产容器基础镜像为 Alpine,构建镜像使用 Go 1.25.6 Alpine,见 `Dockerfile.api`、`Dockerfile.worker`
|
||||
- Go Modules,见 `go.mod`
|
||||
- Lockfile: `go.sum` present
|
||||
## Frameworks
|
||||
- Fiber v2.52.9 - HTTP 框架与路由宿主,见 `go.mod`、`cmd/api/main.go`
|
||||
- GORM v1.31.1 + `gorm.io/driver/postgres` v1.6.0 - PostgreSQL ORM 与连接层,见 `go.mod`、`pkg/database/postgres.go`
|
||||
- Viper v1.21.0 - 嵌入式默认配置 + 环境变量覆盖,见 `go.mod`、`pkg/config/loader.go`
|
||||
- Asynq v0.25.1 - 任务提交、Worker 消费、定时任务,见 `go.mod`、`pkg/queue/client.go`、`pkg/queue/server.go`、`cmd/worker/main.go`
|
||||
- sonic v1.14.2 - Fiber JSON 编解码与任务载荷序列化,见 `go.mod`、`cmd/api/main.go`、`pkg/queue/client.go`
|
||||
- validator/v10 v10.28.0 - DTO 校验依赖,见 `go.mod`
|
||||
- zap v1.27.1 - 应用日志与 SQL 日志,见 `go.mod`、`pkg/logger/logger.go`、`pkg/database/postgres.go`
|
||||
- lumberjack.v2 v2.2.1 - 日志轮转,见 `go.mod`、`pkg/logger/logger.go`
|
||||
- swaggest/openapi-go v0.2.60 - OpenAPI 3.0.3 文档生成,见 `go.mod`、`pkg/openapi/generator.go`
|
||||
## Build / Dev / Deploy
|
||||
- `Makefile` 定义 `build`、`run`、`run-worker`、`docs`、`migrate-*` 目标,见 `Makefile`
|
||||
- API 与 Worker 使用多阶段 Docker 构建,编译产物分别来自 `./cmd/api` 与 `./cmd/worker`,见 `Dockerfile.api`、`Dockerfile.worker`
|
||||
- 生产编排使用 Docker Compose,运行 `api` 与 `worker` 两个服务,见 `docker-compose.prod.yml`
|
||||
- Gitea Actions 工作流构建镜像、推送私有镜像仓库并在主分支执行 `docker compose up -d`,见 `.gitea/workflows/deploy.yaml`
|
||||
- API 启动时会生成 `logs/openapi.yaml`,见 `cmd/api/main.go`、`cmd/api/docs.go`
|
||||
- 手动文档命令 `make docs` 运行 `cmd/gendocs/main.go`,输出 `docs/admin-openapi.yaml`,见 `Makefile`、`cmd/gendocs/main.go`
|
||||
## Storage, Data, Messaging
|
||||
- PostgreSQL 是唯一检测到的主数据库;连接、连接池、GORM logger、Ping 校验位于 `pkg/database/postgres.go`
|
||||
- Redis 用于认证、缓存、队列后端、限流存储和配置缓存,见 `pkg/database/redis.go`、`cmd/api/main.go`、`internal/service/wechat_config/service.go`
|
||||
- Asynq 直接复用 Redis 连接配置作为消息后端,见 `pkg/queue/client.go`、`pkg/queue/server.go`
|
||||
- Worker 内注册订单超时、告警检查、数据清理等调度任务,见 `cmd/worker/main.go`
|
||||
- S3 兼容对象存储通过 AWS SDK v1 实现,支持上传、下载、预签名 URL、临时文件下载,见 `pkg/storage/s3.go`、`pkg/storage/service.go`
|
||||
## Configuration
|
||||
- 默认配置从嵌入文件 `pkg/config/defaults/config.yaml` 读取,见 `pkg/config/embedded.go`
|
||||
- 运行时使用 `JUNHONG_` 前缀环境变量覆盖,见 `pkg/config/loader.go`
|
||||
- 必填配置校验包括数据库、Redis、JWT,见 `pkg/config/config.go`
|
||||
- 服务与超时:`server.*`,见 `pkg/config/config.go`
|
||||
- PostgreSQL:`database.*`,见 `pkg/config/config.go`
|
||||
- Redis:`redis.*`,见 `pkg/config/config.go`
|
||||
- Asynq:`queue.*`,见 `pkg/config/config.go`
|
||||
- 日志:`logging.*`,见 `pkg/config/config.go`
|
||||
- 短信:`sms.*`,见 `pkg/config/config.go`
|
||||
- 对象存储:`storage.*`,见 `pkg/config/config.go`
|
||||
- 外部 Gateway:`gateway.*`,见 `pkg/config/config.go`
|
||||
- 微信公众号/小程序/支付配置不走 Viper 主配置,业务侧从数据库表 `tb_wechat_config` 读取并缓存到 Redis,见 `internal/model/wechat_config.go`、`internal/service/wechat_config/service.go`、`pkg/wechat/config.go`
|
||||
## Logging & Observability
|
||||
- `pkg/logger/logger.go` 初始化 app/access 双 logger;生产模式写 JSON,开发模式 app logger 同时输出控制台
|
||||
- `pkg/logger/middleware.go` 记录 method、path、query、status、duration、request_id、ip、user_agent、user_id、请求/响应 body
|
||||
- `pkg/database/postgres.go` 自定义 GORM logger,记录错误、慢查询和普通 SQL trace
|
||||
- `Dockerfile.api` 与 `docker-compose.prod.yml` 均通过 `GET /health` 做容器健康检查
|
||||
## Notable Dependencies
|
||||
- `github.com/gofiber/fiber/v2` - API 服务核心框架,见 `go.mod`、`cmd/api/main.go`
|
||||
- `github.com/google/uuid` - 请求 ID 生成,见 `cmd/api/main.go`
|
||||
- `gorm.io/gorm`、`gorm.io/driver/postgres` - ORM 与 PostgreSQL 驱动,见 `go.mod`、`pkg/database/postgres.go`
|
||||
- `github.com/redis/go-redis/v9` - Redis 客户端,见 `go.mod`、`pkg/database/redis.go`
|
||||
- `github.com/hibiken/asynq` - 队列与调度器,见 `go.mod`、`pkg/queue/*.go`、`cmd/worker/main.go`
|
||||
- `github.com/bytedance/sonic` - JSON 编解码,见 `cmd/api/main.go`、`pkg/queue/client.go`
|
||||
- `github.com/xuri/excelize/v2` - Excel 导入处理依赖,见 `go.mod`; 任务消费方在 `internal/task/iot_card_import.go`、`internal/task/device_import.go` 调用 Excel 解析
|
||||
- `github.com/ArtisanCloud/PowerWeChat/v3` - 微信公众号与微信支付 SDK,见 `go.mod`、`pkg/wechat/official_account.go`、`pkg/wechat/payment.go`
|
||||
- `github.com/aws/aws-sdk-go` - S3 兼容对象存储客户端,见 `go.mod`、`pkg/storage/s3.go`
|
||||
## Platform Requirements
|
||||
- 需要 PostgreSQL、Redis、JWT 密钥与 `JUNHONG_` 环境变量;`config.Load()` 在启动时强校验,见 `pkg/config/config.go`
|
||||
- `make docs` 需要 Go 环境;`migrate-*` 目标需要系统安装 `migrate` 命令,见 `Makefile`
|
||||
- 目标运行形态是 Docker 化 API + Worker 双进程部署,见 `Dockerfile.api`、`Dockerfile.worker`、`docker-compose.prod.yml`
|
||||
- 镜像发布到私有仓库 `registry.boss160.cn`,见 `.gitea/workflows/deploy.yaml`
|
||||
## Evidence
|
||||
- `go.mod`
|
||||
- `README.md`
|
||||
- `cmd/api/main.go`
|
||||
- `cmd/api/docs.go`
|
||||
- `cmd/worker/main.go`
|
||||
- `cmd/gendocs/main.go`
|
||||
- `pkg/config/config.go`
|
||||
- `pkg/config/loader.go`
|
||||
- `pkg/config/embedded.go`
|
||||
- `pkg/config/defaults/config.yaml`
|
||||
- `pkg/database/postgres.go`
|
||||
- `pkg/database/redis.go`
|
||||
- `pkg/queue/client.go`
|
||||
- `pkg/queue/server.go`
|
||||
- `pkg/queue/handler.go`
|
||||
- `pkg/logger/logger.go`
|
||||
- `pkg/logger/middleware.go`
|
||||
- `pkg/openapi/generator.go`
|
||||
- `pkg/openapi/handlers.go`
|
||||
- `pkg/storage/s3.go`
|
||||
- `pkg/storage/service.go`
|
||||
- `pkg/wechat/config.go`
|
||||
- `pkg/wechat/official_account.go`
|
||||
- `pkg/wechat/payment.go`
|
||||
- `pkg/sms/client.go`
|
||||
- `internal/gateway/client.go`
|
||||
- `Makefile`
|
||||
- `Dockerfile.api`
|
||||
- `Dockerfile.worker`
|
||||
- `docker-compose.prod.yml`
|
||||
- `.gitea/workflows/deploy.yaml`
|
||||
<!-- GSD:stack-end -->
|
||||
|
||||
<!-- GSD:conventions-start source:CONVENTIONS.md -->
|
||||
## Conventions
|
||||
|
||||
## Naming Patterns
|
||||
- Go 源码使用 `snake_case.go` 或按领域拆分的普通小写命名;路由文件按模块命名放在 `internal/routes/*.go`,Handler 按域放在 `internal/handler/admin/*.go`、`internal/handler/auth/*.go`、`internal/handler/app/*.go`。
|
||||
- OpenAPI 与规范文档使用明确用途命名,例如 `docs/api-documentation-guide.md`、`docs/admin-openapi.yaml`、`cmd/api/docs.go`、`cmd/gendocs/main.go`。
|
||||
- 规范脚本使用动词前缀命名,例如 `scripts/check-all.sh`、`scripts/check-service-errors.sh`、`scripts/check-comment-paths.sh`、`scripts/verify_migration/main.go`。
|
||||
- 导出函数/方法使用 Go 的 `PascalCase`,例如 `NewAccountHandler()`、`Create()`、`Success()`、`RedisOrderIdempotencyKey()`,证据见 `internal/handler/admin/account.go`、`pkg/response/response.go`、`pkg/constants/redis.go`。
|
||||
- 未导出帮助函数使用 `camelCase`,例如 `generateOpenAPIDocs()`、`generateAdminDocs()`、`handleError()`、`safeLogWithLevel()`,证据见 `cmd/api/docs.go`、`cmd/gendocs/main.go`、`pkg/errors/handler.go`。
|
||||
- 局部变量使用英文 `camelCase`,例如 `outputPath`、`httpStatus`、`roleID`、`groupPath`,证据见 `cmd/api/docs.go`、`pkg/errors/handler.go`、`internal/handler/admin/account.go`。
|
||||
- 错误变量统一使用 `err`,成功返回数据常用业务名,例如 `account`、`accounts`、`roles`,证据见 `internal/handler/admin/account.go`。
|
||||
- 结构体和接口使用 `PascalCase`,接口语义化命名,配合 `-er` 后缀规则;项目规范明确要求接口名使用 `-er`,见 `AGENTS.md`。
|
||||
- 路由文档元数据类型使用语义名,如 `RouteSpec`、`FileUploadField`,证据见 `internal/routes/registry.go`。
|
||||
## Code Style
|
||||
- 使用 `gofmt`;项目在 `AGENTS.md` 明确要求“使用 gofmt 格式化”。
|
||||
- 代码风格遵循 Go 惯用法,避免 Java 式过度抽象;证据见 `AGENTS.md` 的“Go 惯用法 vs Java 风格”。
|
||||
- 仓库未检测到 `.golangci.yml`、`golangci-lint`、`eslint`、`prettier` 之类自动 lint 配置;当前质量门主要依赖 shell 脚本和人工审查。
|
||||
- `scripts/check-service-errors.sh`:扫描 `internal/service/**/*.go` 中的 `fmt.Errorf`,要求改用 `errors.New()` / `errors.Wrap()`。
|
||||
- `scripts/check-comment-paths.sh`:扫描 `internal/handler/` 中残留的 `/api/v1` 注释,要求改成真实路径 `/api/admin`、`/api/h5`、`/api/c/v1`。
|
||||
- `scripts/check-all.sh`:串行执行以上两个检查。
|
||||
- 当前仓库与脚本规则存在冲突:`internal/service/polling/alert_service.go` 仍存在 4 处 `fmt.Errorf(...)`,按 `scripts/check-service-errors.sh` 的规则属于违规现状。
|
||||
## Language & Comment Requirements
|
||||
- 用户可见内容、日志、注释、文档、提交信息都使用中文;变量名、函数名、类型名使用英文,证据见 `AGENTS.md` 的“语言要求”。
|
||||
- 导出包、结构体、接口、函数、方法、常量、变量必须有中文文档注释,证据见 `AGENTS.md`。
|
||||
- Handler 方法注释必须包含 HTTP 方法与真实路径;实际示例见 `internal/handler/admin/account.go`、`internal/handler/callback/payment.go`。
|
||||
- 注释解释“为什么”,不要复述代码;复杂逻辑必须有实现注释,证据见 `AGENTS.md`。
|
||||
- 常量必须带中文注释;实际示例见 `pkg/constants/constants.go`、`pkg/constants/redis.go`。
|
||||
## Import Organization
|
||||
- 多数组件按“标准库 → 项目包/第三方包”分组,并保留空行分隔,证据见 `internal/handler/admin/account.go`、`pkg/errors/handler.go`、`cmd/gendocs/main.go`。
|
||||
- 项目使用完整 module import path,未体现短别名路径;必要时用语义别名,例如 `apphandler`、`accountService`,证据见 `cmd/gendocs/main.go`、`internal/handler/admin/account.go`。
|
||||
- 未检测到 TS/JS 式路径别名;Go 代码通过 module path `github.com/break/junhong_cmp_fiber/...` 引用。
|
||||
## Layering Rules
|
||||
- 必须遵循 `Handler → Service → Store → Model`,证据见 `AGENTS.md` 与 `README.md`。
|
||||
- Handler 只做参数解析、上下文读取、调用 service、返回统一响应;示例见 `internal/handler/admin/account.go`。
|
||||
- Service 承载业务逻辑,并向下调用 Store;项目规范在 `AGENTS.md` 明确禁止在 Handler 中写业务逻辑。
|
||||
- Store 负责数据访问和事务;规范说明见 `AGENTS.md`、`README.md`。
|
||||
- Model/DTO 定义请求、响应和持久化结构;OpenAPI 文档也依赖 DTO 元数据,证据见 `docs/api-documentation-guide.md`。
|
||||
## Error Handling
|
||||
- 所有错误码与应用错误类型集中在 `pkg/errors/`,证据见 `pkg/errors/codes.go`、`pkg/errors/errors.go`、`pkg/errors/handler.go`、`AGENTS.md`。
|
||||
- Handler 层不要把底层错误直接暴露给客户端;参数校验失败统一返回 `errors.New(errors.CodeInvalidParam)` 或其中文变体,证据见 `AGENTS.md`、`internal/handler/admin/account.go`。
|
||||
- Service 层对外不要返回 `fmt.Errorf(...)`,要返回 `errors.New(...)` 或 `errors.Wrap(...)`;脚本 `scripts/check-service-errors.sh` 专门检查这一点。
|
||||
- 全局错误处理使用 `pkg/errors/handler.go` 的 `SafeErrorHandler()` / `handleError()`,统一输出 `{code, data, msg, timestamp}`,并按错误码映射 HTTP 状态码与日志级别。
|
||||
- 5xx 响应统一走通用中文消息,避免泄露敏感信息,证据见 `pkg/errors/handler.go`。
|
||||
- `pkg/errors/codes.go` 定义 1000-1999 客户端错误、2000-2999 服务端错误。
|
||||
- `pkg/errors/codes.go` 在 `init()` 校验全部错误码都必须映射消息,新增错误码时要同步维护 `allErrorCodes` 和 `errorMessages`。
|
||||
## Response Format
|
||||
- 所有 API 成功响应统一使用 `pkg/response/response.go`:`{code, data, msg, timestamp}`。
|
||||
- `Success()` 返回 `msg: "success"`;`SuccessWithMessage()` 允许覆盖消息;`SuccessWithPagination()` 将分页数据包进 `PaginationData`。
|
||||
- Handler 成功路径直接调用 `response.Success()` / `response.SuccessWithPagination()`;实际示例见 `internal/handler/admin/account.go`、`internal/handler/admin/iot_card.go`、`internal/handler/admin/shop.go`。
|
||||
- 错误路径直接 `return err` 交给全局 ErrorHandler,不在 Handler 内手动拼接 JSON,证据见 `internal/handler/admin/account.go`、`pkg/errors/handler.go`。
|
||||
## Constant Management
|
||||
- 所有常量统一放在 `pkg/constants/`,证据见 `AGENTS.md` 与 `pkg/constants/constants.go`、`pkg/constants/redis.go`。
|
||||
- 禁止硬编码字符串与 magic numbers;公共业务值放进 `pkg/constants/constants.go`。
|
||||
- Redis Key 必须通过函数生成,而不是手写字符串,命名格式使用 `Redis{Module}{Purpose}Key(...)`,证据见 `pkg/constants/redis.go` 与 `AGENTS.md`。
|
||||
- 常量要配中文注释;`pkg/constants/constants.go` 中的用户类型、任务类型、分页上限、默认管理员信息都按此方式组织。
|
||||
## API / OpenAPI Conventions
|
||||
- 所有 HTTP 路由都应在 `internal/routes/*.go` 中通过 `Register()` 注册,不要直接 `router.Get/Post/...`,证据见 `docs/api-documentation-guide.md`、`internal/routes/registry.go`。
|
||||
- `Register()` 同时负责 Fiber 路由注册和 OpenAPI 生成;当 `doc != nil` 时,会把 `/:id` 转为 OpenAPI 的 `/{id}`,证据见 `internal/routes/registry.go`。
|
||||
- 每个接口需要提供中文 `Summary`,可选 Markdown `Description`,并声明 `Input`、`Output`、`Tags`、`Auth`;证据见 `internal/routes/registry.go`、`docs/api-documentation-guide.md`。
|
||||
- DTO 字段必须使用 `description` 标签,不依赖行尾注释;枚举字段要在 `description` 中列出可选值,证据见 `docs/api-documentation-guide.md`。
|
||||
- 新增 Handler 时,除业务接线外,还必须同步更新 `internal/bootstrap/types.go`、`internal/bootstrap/handlers.go`、`internal/routes/admin.go`、`cmd/api/docs.go`、`cmd/gendocs/main.go`,证据见 `docs/api-documentation-guide.md` 与 `AGENTS.md`。
|
||||
- 文档生成命令使用 `go run cmd/gendocs/main.go` 或 `make docs`;代码证据见 `cmd/gendocs/main.go`、`Makefile`。
|
||||
## Documentation Expectations
|
||||
- 每个功能应在 `docs/{feature-id}/` 创建总结文档,并同步更新 `README.md`,证据见 `AGENTS.md`。
|
||||
- 文档和说明统一使用中文。
|
||||
- API 文档规范集中在 `docs/api-documentation-guide.md`;开发总规范集中在 `AGENTS.md`。
|
||||
- `README.md` 仍保留旧的自动化测试与覆盖率章节、旧项目结构中的 `tests/` 目录描述、以及“CI/CD 自动执行检查”的表述。
|
||||
- 当前仓库未发现任何 `*_test.go` 文件,也未发现 `tests/` 目录内容;因此后续文档和执行应优先遵循 `AGENTS.md` 的现行规则,而不是 `README.md` 中这些过期段落。
|
||||
## Operational Scripts
|
||||
- `bash scripts/check-service-errors.sh`:检查 Service 层是否违规使用 `fmt.Errorf`。
|
||||
- `bash scripts/check-comment-paths.sh`:检查 Handler 注释路径是否残留 `/api/v1`。
|
||||
- `bash scripts/check-all.sh`:运行上述两个检查。
|
||||
- `make docs` → `go run cmd/gendocs/main.go`,用于生成 `docs/admin-openapi.yaml`,证据见 `Makefile`、`cmd/gendocs/main.go`。
|
||||
- `go run scripts/verify_migration/main.go`:这是一个面向数据库迁移结果的人工验证脚本,会直接连接数据库并查询字段,不是自动测试框架,证据见 `scripts/verify_migration/main.go`。
|
||||
- `Makefile` 的 `test` 目标仍定义为 `go test -v ./...`,但这反映的是旧工具入口,不代表当前团队允许自动化测试。
|
||||
## Module Design
|
||||
- 构造函数使用 `NewXxx...`,例如 `NewAccountHandler()`、`NewGenerator()`。
|
||||
- Handler、response、errors、constants 都以小包单职责方式导出少量明确入口,证据见 `internal/handler/admin/account.go`、`pkg/response/response.go`、`pkg/errors/errors.go`。
|
||||
- 未检测到 JS/TS 式 barrel file;Go 通过包目录与导出符号组织模块。
|
||||
## Prescriptive Summary
|
||||
- 写新 Handler 时:在 `internal/handler/...` 保持中文注释 + 英文标识符,只解析请求并调用 Service,成功统一走 `pkg/response/response.go`,失败直接返回 `error`。
|
||||
- 写新业务错误时:先在 `pkg/errors/codes.go` 注册错误码与中文消息,再用 `errors.New()` / `errors.Wrap()`。
|
||||
- 写新常量或 Redis Key 时:放入 `pkg/constants/`,并补中文注释与 Key 生成函数。
|
||||
- 写新 API 时:在 `internal/routes/*.go` 用 `Register()` + `RouteSpec` 注册,并同步更新 `cmd/api/docs.go` 与 `cmd/gendocs/main.go` 的文档 Handler 装配。
|
||||
- 运行规范检查时:优先使用 `scripts/check-*.sh` 与 `make docs`;不要把 `README.md` 中的测试/覆盖率描述当成当前执行标准。
|
||||
<!-- GSD:conventions-end -->
|
||||
|
||||
<!-- GSD:architecture-start source:ARCHITECTURE.md -->
|
||||
## Architecture
|
||||
|
||||
## Pattern Overview
|
||||
- HTTP 入口与异步任务入口分离:`cmd/api/main.go` 负责 Fiber API,`cmd/worker/main.go` 负责 Asynq Worker 与轮询调度。
|
||||
- 依赖集中在 `internal/bootstrap/` 组装,再分发给 `internal/routes/`、`internal/handler/`、`internal/task/`。
|
||||
- 数据访问主要走 `internal/store/postgres/*.go`,模型与 DTO 分别位于 `internal/model/*.go` 与 `internal/model/dto/*.go`。
|
||||
- 路由注册统一经过 `internal/routes/registry.go` 的 `Register()`,同时驱动 Fiber 路由与 OpenAPI 元数据。
|
||||
- 多租户与权限控制主要依赖认证上下文 + Store 层显式过滤函数,而不是数据库外键或 ORM 关联。
|
||||
## Layers
|
||||
- Purpose: 进程启动、基础设施初始化、依赖装配、生命周期管理。
|
||||
- Location: `cmd/api/main.go`, `cmd/worker/main.go`, `internal/bootstrap/*.go`
|
||||
- Contains: 配置加载、日志、数据库、Redis、队列、对象存储、Gateway 客户端、Bootstrap 编排。
|
||||
- Depends on: `pkg/config`, `pkg/database`, `pkg/logger`, `pkg/queue`, `pkg/storage`, `internal/bootstrap`
|
||||
- Used by: 整个应用进程。
|
||||
- Purpose: 路由域划分、认证挂载、HTTP 入参与响应封装、OpenAPI 文档注册。
|
||||
- Location: `internal/routes/*.go`, `internal/handler/**/*.go`
|
||||
- Contains: `RegisterRoutesWithDoc()` 总入口、Admin/Auth/Personal 域路由、Fiber Handler。
|
||||
- Depends on: `internal/bootstrap.Handlers`, `internal/bootstrap.Middlewares`, `pkg/openapi`, `pkg/response`
|
||||
- Used by: `cmd/api/main.go`
|
||||
- Purpose: 业务规则、权限判断、事务编排、幂等、异步任务提交、跨模块协作。
|
||||
- Location: `internal/service/**/*.go`
|
||||
- Contains: 账号、订单、套餐、轮询、设备、卡、认证、充值、佣金等服务。
|
||||
- Depends on: `internal/store/postgres`, `pkg/errors`, `pkg/middleware`, `pkg/queue`, `pkg/wechat`, `gorm.DB`, `redis.Client`
|
||||
- Used by: Handler 层、Worker Bootstrap、Task Handler。
|
||||
- Purpose: SQL 查询、分页、显式数据权限过滤、缓存辅助、事务基础能力。
|
||||
- Location: `internal/store/store.go`, `internal/store/options.go`, `internal/store/postgres/*.go`
|
||||
- Contains: 每个聚合的 PostgreSQL Store,例如 `internal/store/postgres/account_store.go`, `internal/store/postgres/order_store.go`。
|
||||
- Depends on: `gorm`, `redis`, `pkg/middleware/data_scope.go`
|
||||
- Used by: Service 层、部分 Handler 组装期专用读模型。
|
||||
- Purpose: 持久化模型、DTO、常量化状态值、表名映射。
|
||||
- Location: `internal/model/*.go`, `internal/model/dto/*.go`
|
||||
- Contains: `model.Account`, `model.Order`, `model.Shop`, `model.PollingConfig` 以及请求/响应 DTO。
|
||||
- Depends on: GORM tags、标准库类型。
|
||||
- Used by: Store、Service、Handler、OpenAPI 生成。
|
||||
- Purpose: Asynq 任务处理、轮询调度、定时任务、批处理导入。
|
||||
- Location: `internal/task/*.go`, `internal/polling/*.go`, `pkg/queue/*.go`
|
||||
- Contains: `pkg/queue/handler.go` 任务注册器、`internal/task/polling_handler.go`、`internal/task/order_expire.go`、`internal/polling/scheduler.go`。
|
||||
- Depends on: Worker bootstrap 产出的 stores/services、Redis、Asynq、Gateway。
|
||||
- Used by: `cmd/worker/main.go`
|
||||
## Data Flow
|
||||
- HTTP 侧状态主要放在 PostgreSQL;请求态身份与数据权限范围放在 `context.Context`。
|
||||
- 高速状态、Token、轮询队列、并发信号量、下级店铺缓存放在 Redis,如 `pkg/auth/token.go`、`internal/polling/scheduler.go`、`internal/store/postgres/shop_store.go`。
|
||||
- 异步处理采用 Redis-backed Asynq 队列,由 `pkg/queue/client.go` 提交、`pkg/queue/server.go` 消费。
|
||||
## Key Abstractions
|
||||
- Purpose: 启动期依赖编排结果。
|
||||
- Examples: `internal/bootstrap/bootstrap.go`, `internal/bootstrap/worker.go`
|
||||
- Pattern: 显式依赖注入,不使用外部 DI 框架。
|
||||
- Purpose: 路由注册时的装配清单。
|
||||
- Examples: `internal/bootstrap/types.go`, `internal/bootstrap/handlers.go`, `internal/bootstrap/middlewares.go`
|
||||
- Pattern: 结构体聚合所有已构造的 Handler 与 Middleware。
|
||||
- Purpose: 单次声明同时完成真实路由注册和 OpenAPI 元数据注册。
|
||||
- Examples: `internal/routes/registry.go`, `internal/routes/account.go`, `internal/routes/personal.go`
|
||||
- Pattern: 代码即文档,DTO 驱动文档生成。
|
||||
- Purpose: Store 通用分页与排序选项。
|
||||
- Examples: `internal/store/options.go`, `internal/service/account/service.go`, `internal/service/order/service.go`
|
||||
- Pattern: Service 先构造查询选项,再下传到 Store。
|
||||
- Purpose: 承载用户身份与可管理店铺范围,并在 Store 查询时执行过滤。
|
||||
- Examples: `pkg/middleware/auth.go`, `pkg/middleware/data_scope.go`, `internal/store/postgres/shop_store.go`
|
||||
- Pattern: 中间件预计算 `SubordinateShopIDs`,Store 按字段类型显式调用 `ApplyShopFilter` / `ApplyEnterpriseFilter` / `ApplySellerShopFilter`。
|
||||
## Entry Points
|
||||
- Location: `cmd/api/main.go`
|
||||
- Triggers: `go run cmd/api/main.go`、API 容器启动。
|
||||
- Responsibilities: 加载配置、初始化基础设施、执行 `bootstrap.Bootstrap()`、创建 Fiber、注册中间件和路由、生成运行时 OpenAPI 文件 `logs/openapi.yaml`。
|
||||
- Location: `cmd/worker/main.go`
|
||||
- Triggers: `go run cmd/worker/main.go`、Worker 容器启动。
|
||||
- Responsibilities: 初始化 Worker 依赖、执行 `bootstrap.BootstrapWorker()`、启动 Asynq Worker、启动轮询调度器和 Asynq Scheduler、优雅关闭。
|
||||
- Location: `cmd/gendocs/main.go`
|
||||
- Triggers: 手动执行文档生成。
|
||||
- Responsibilities: 通过 `openapi.BuildDocHandlers()` + `routes.RegisterRoutesWithDoc()` 输出 `docs/admin-openapi.yaml`。
|
||||
## Routing Strategy
|
||||
- `/api/auth`:统一后台/H5 认证,见 `internal/routes/auth.go`
|
||||
- `/api/admin`:管理后台业务域,见 `internal/routes/admin.go`
|
||||
- `/api/c/v1`:个人客户端域,见 `internal/routes/personal.go`
|
||||
- `/api/callback`:支付回调,无认证,见 `internal/routes/order.go` 与 `internal/handler/callback/payment.go`
|
||||
- 所有 HTTP 接口使用 `internal/routes/registry.go` 的 `Register()`,文档规范见 `docs/api-documentation-guide.md`。
|
||||
- `internal/routes/admin.go` 以 handler 是否为 `nil` 决定是否挂载模块,便于文档生成和裁剪。
|
||||
- `internal/routes/personal.go` 明确区分公开路由与认证路由,并依赖注册顺序确保公开接口不被 `Use()` 拦截。
|
||||
## Error Handling
|
||||
- Handler 层只做参数解析与转发,返回 `pkg/errors` 中的 `AppError`,如 `internal/handler/admin/account.go`。
|
||||
- Service 层封装业务错误,不直接向外泄露底层错误文本,如 `internal/service/account/service.go`、`internal/service/order/service.go`。
|
||||
- Worker 任务记录结构化日志,并将可重试与不可重试逻辑写在处理器内部,如 `internal/task/order_expire.go`、`internal/task/polling_handler.go`。
|
||||
## Cross-Cutting Concerns
|
||||
- `pkg/logger/logger.go` 初始化 App/Access 日志;`pkg/logger/middleware.go` 记录完整请求/响应摘要。
|
||||
- Fiber `BodyParser` / `QueryParser` + `validator.v10`,示例见 `internal/handler/auth/handler.go`。
|
||||
- 后台/H5 使用 Redis Token + `pkg/middleware/auth.go`;个人客户使用单独中间件,由 `internal/bootstrap/middlewares.go` 创建。
|
||||
- 路由元数据写在 `RouteSpec`;文档生成器依赖 `cmd/api/docs.go`、`cmd/gendocs/main.go`、`pkg/openapi/handlers.go` 三处手工维护 Handler 清单。
|
||||
- 模型显式声明 `TableName()` 与字段列名,如 `internal/model/account.go`, `internal/model/order.go`, `internal/model/shop.go`。
|
||||
- 数据库结构通过 `migrations/*.sql` 管理,`pkg/database/postgres.go` 明确禁用自动建表。
|
||||
## Architectural Deviations
|
||||
- `README.md` 与 `openspec/config.yaml` 多处描述“GORM Callback 自动注入数据权限过滤”,但当前 `internal/bootstrap/bootstrap.go` 第 67 行明确写明“数据权限过滤已移至 Store 层显式调用 ApplyXxxFilter 函数”。当前保留的 GORM Callback 只有 `pkg/gorm/callback.go` 中的创建人/更新人自动填充。
|
||||
- `internal/bootstrap/handlers.go` 为客户端场景直接新建多个 Store 和一个 `client_order` Service,而不是完全只消费 `services` 聚合;这说明 Handler 装配阶段存在读模型/适配层级的额外拼装。
|
||||
- `internal/task/polling_handler.go` 在首次实名激活流程中先构造 Asynq 任务,但实际通过 Redis List `RPush` 提交,文件内注释明确写出“实际应该通过依赖注入 asynq.Client”;这是 Worker 流程中的特殊机制与待收敛点。
|
||||
<!-- GSD:architecture-end -->
|
||||
|
||||
<!-- GSD:workflow-start source:GSD defaults -->
|
||||
## GSD Workflow Enforcement
|
||||
|
||||
Before using Edit, Write, or other file-changing tools, start work through a GSD command so planning artifacts and execution context stay in sync.
|
||||
|
||||
Use these entry points:
|
||||
- `/gsd:quick` for small fixes, doc updates, and ad-hoc tasks
|
||||
- `/gsd:debug` for investigation and bug fixing
|
||||
- `/gsd:execute-phase` for planned phase work
|
||||
|
||||
Do not make direct repo edits outside a GSD workflow unless the user explicitly asks to bypass it.
|
||||
<!-- GSD:workflow-end -->
|
||||
|
||||
<!-- GSD:profile-start -->
|
||||
## Developer Profile
|
||||
|
||||
> Profile not yet configured. Run `/gsd:profile-user` to generate your developer profile.
|
||||
> This section is managed by `generate-claude-profile` -- do not edit manually.
|
||||
<!-- GSD:profile-end -->
|
||||
|
||||
@@ -170,7 +170,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
PurchaseValidation: purchaseValidation,
|
||||
Order: orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.AssetWallet, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, wechatConfig, deps.WechatPayment, deps.QueueClient, deps.Logger),
|
||||
Exchange: exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, s.PersonalCustomerDevice, deps.Logger),
|
||||
Recharge: rechargeSvc.New(deps.DB, s.AssetRecharge, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, deps.Logger),
|
||||
Recharge: rechargeSvc.New(deps.DB, s.AssetRecharge, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, deps.Logger, deps.QueueClient),
|
||||
PollingConfig: pollingSvc.NewConfigService(s.PollingConfig),
|
||||
PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis),
|
||||
PollingMonitoring: pollingSvc.NewMonitoringService(deps.Redis),
|
||||
|
||||
@@ -125,7 +125,7 @@ func (h *ClientRealnameHandler) GetRealnameLink(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
// 6. 检查实名状态
|
||||
if targetCard.RealNameStatus == 1 {
|
||||
if targetCard.RealNameStatus == constants.RealNameStatusVerified {
|
||||
return errors.New(errors.CodeInvalidStatus, "该卡已完成实名")
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ type AssetResolveResponse struct {
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
|
||||
// 状态聚合字段
|
||||
RealNameStatus int `json:"real_name_status" description:"实名状态:0未实名 1实名中 2已实名"`
|
||||
RealNameStatus int `json:"real_name_status" description:"实名状态:0未实名 1已实名"`
|
||||
CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"`
|
||||
PackageTotalMB int64 `json:"package_total_mb" description:"当前套餐总虚流量(MB),已按virtual_ratio换算"`
|
||||
PackageUsedMB float64 `json:"package_used_mb" description:"当前已用虚流量(MB),已按virtual_ratio换算"`
|
||||
@@ -77,24 +77,24 @@ type AssetRealtimeStatusResponse struct {
|
||||
|
||||
// AssetPackageResponse 资产套餐信息
|
||||
type AssetPackageResponse struct {
|
||||
PackageUsageID uint `json:"package_usage_id" description:"套餐使用记录ID"`
|
||||
PackageID uint `json:"package_id" description:"套餐ID"`
|
||||
PackageName string `json:"package_name" description:"套餐名称"`
|
||||
PackageType string `json:"package_type" description:"套餐类型:formal/addon"`
|
||||
UsageType string `json:"usage_type" description:"使用类型:single_card/device"`
|
||||
Status int `json:"status" description:"状态:0待生效 1生效中 2已用完 3已过期 4已失效"`
|
||||
StatusName string `json:"status_name" description:"状态名称"`
|
||||
DataLimitMB int64 `json:"data_limit_mb" description:"套餐真流量总量(MB)"`
|
||||
VirtualLimitMB int64 `json:"virtual_limit_mb" description:"套餐虚流量总量(MB),按virtual_ratio换算"`
|
||||
DataUsageMB int64 `json:"data_usage_mb" description:"已用真流量(MB)"`
|
||||
VirtualUsedMB float64 `json:"virtual_used_mb" description:"已用虚流量(MB),按virtual_ratio换算"`
|
||||
VirtualRemainMB float64 `json:"virtual_remain_mb" description:"剩余虚流量(MB),按virtual_ratio换算"`
|
||||
VirtualRatio float64 `json:"virtual_ratio" description:"虚流量比例(real/virtual)"`
|
||||
PackageUsageID uint `json:"package_usage_id" description:"套餐使用记录ID"`
|
||||
PackageID uint `json:"package_id" description:"套餐ID"`
|
||||
PackageName string `json:"package_name" description:"套餐名称"`
|
||||
PackageType string `json:"package_type" description:"套餐类型:formal/addon"`
|
||||
UsageType string `json:"usage_type" description:"使用类型:single_card/device"`
|
||||
Status int `json:"status" description:"状态:0待生效 1生效中 2已用完 3已过期 4已失效"`
|
||||
StatusName string `json:"status_name" description:"状态名称"`
|
||||
DataLimitMB int64 `json:"data_limit_mb" description:"套餐真流量总量(MB)"`
|
||||
VirtualLimitMB int64 `json:"virtual_limit_mb" description:"套餐虚流量总量(MB),按virtual_ratio换算"`
|
||||
DataUsageMB int64 `json:"data_usage_mb" description:"已用真流量(MB)"`
|
||||
VirtualUsedMB float64 `json:"virtual_used_mb" description:"已用虚流量(MB),按virtual_ratio换算"`
|
||||
VirtualRemainMB float64 `json:"virtual_remain_mb" description:"剩余虚流量(MB),按virtual_ratio换算"`
|
||||
VirtualRatio float64 `json:"virtual_ratio" description:"虚流量比例(real/virtual)"`
|
||||
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间(待生效套餐为空)"`
|
||||
ExpiresAt *time.Time `json:"expires_at,omitempty" description:"到期时间(待生效套餐为空)"`
|
||||
MasterUsageID *uint `json:"master_usage_id,omitempty" description:"主套餐ID(加油包时有值)"`
|
||||
Priority int `json:"priority" description:"优先级"`
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
MasterUsageID *uint `json:"master_usage_id,omitempty" description:"主套餐ID(加油包时有值)"`
|
||||
Priority int `json:"priority" description:"优先级"`
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
}
|
||||
|
||||
// AssetResolveRequest 资产解析请求(路径参数)
|
||||
|
||||
@@ -27,7 +27,7 @@ type IotCard struct {
|
||||
ShopID *uint `gorm:"column:shop_id;index;comment:店铺ID(NULL=平台所有,有值=店铺所有)" json:"shop_id,omitempty"`
|
||||
ActivatedAt *time.Time `gorm:"column:activated_at;comment:激活时间" json:"activated_at"`
|
||||
ActivationStatus int `gorm:"column:activation_status;type:int;default:0;not null;comment:激活状态 0-未激活 1-已激活" json:"activation_status"`
|
||||
RealNameStatus int `gorm:"column:real_name_status;type:int;default:0;not null;comment:实名状态 0-未实名 1-已实名(行业卡可以保持0)" json:"real_name_status"`
|
||||
RealNameStatus int `gorm:"column:real_name_status;type:int;default:0;not null;comment:实名状态 0-未实名 1-已实名" json:"real_name_status"`
|
||||
NetworkStatus int `gorm:"column:network_status;type:int;default:0;not null;comment:网络状态 0-停机 1-开机" json:"network_status"`
|
||||
DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;default:0;comment:累计流量使用(MB)" json:"data_usage_mb"`
|
||||
CurrentMonthUsageMB float64 `gorm:"column:current_month_usage_mb;type:decimal(10,2);default:0;comment:本月已用流量(MB) - Gateway返回的自然月流量总量" json:"current_month_usage_mb"`
|
||||
|
||||
@@ -629,10 +629,10 @@ func (s *Scheduler) matchConfigConditions(cfg *model.PollingConfig, card *model.
|
||||
// getCardCondition 获取卡的状态条件
|
||||
func (s *Scheduler) getCardCondition(card *model.IotCard) string {
|
||||
// 根据卡的实名状态和激活状态确定条件
|
||||
if card.RealNameStatus == 0 || card.RealNameStatus == 1 {
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified {
|
||||
return "not_real_name" // 未实名
|
||||
}
|
||||
if card.RealNameStatus == 2 {
|
||||
if card.RealNameStatus == constants.RealNameStatusVerified {
|
||||
if card.NetworkStatus == 1 {
|
||||
return "activated" // 已激活
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != 1 {
|
||||
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
|
||||
return nil, errors.New(errors.CodeNeedRealname)
|
||||
}
|
||||
|
||||
|
||||
@@ -870,12 +870,12 @@ func parseNetworkStatus(cardStatus string) int {
|
||||
}
|
||||
|
||||
// parseGatewayRealnameStatus 将网关返回的实名状态布尔值转换为 real_name_status 数值
|
||||
// true=已实名(2),false=未实名(0)
|
||||
// true=已实名(1),false=未实名(0)
|
||||
func parseGatewayRealnameStatus(realStatus bool) int {
|
||||
if realStatus {
|
||||
return 2
|
||||
return constants.RealNameStatusVerified
|
||||
}
|
||||
return 0
|
||||
return constants.RealNameStatusNotVerified
|
||||
}
|
||||
|
||||
// UpdatePollingStatus 更新卡的轮询状态
|
||||
|
||||
@@ -159,14 +159,19 @@ func (s *Service) CreateWithdrawalRequest(ctx context.Context, req *dto.CreateMy
|
||||
var withdrawalRequest *model.CommissionWithdrawalRequest
|
||||
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
// 冻结余额
|
||||
if err := tx.WithContext(ctx).Model(&model.AgentWallet{}).
|
||||
// 冻结余额(使用条件更新 + 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),
|
||||
}).Error; err != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, err, "冻结余额失败")
|
||||
})
|
||||
if result.Error != nil {
|
||||
return errors.Wrap(errors.CodeInternalError, result.Error, "冻结余额失败")
|
||||
}
|
||||
// RowsAffected == 0 说明余额已不足(并发场景下被其他请求先行扣减)
|
||||
if result.RowsAffected == 0 {
|
||||
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突,请稍后重试")
|
||||
}
|
||||
|
||||
// 创建提现申请
|
||||
|
||||
@@ -259,7 +259,9 @@ func (s *Service) CreateLegacy(ctx context.Context, req *dto.CreateOrderRequest,
|
||||
operatorType = "agent"
|
||||
actualPaidAmount = &operatorCostPrice
|
||||
purchaseRole = model.PurchaseRolePurchaseForSubordinate
|
||||
sellerCostPrice = buyerCostPrice
|
||||
// 代理代购下级时,佣金差额基准应是操作方(代理自己)的成本价,而非下级的成本价
|
||||
// 使用 buyerCostPrice 会导致差额为 0,上级代理无法获得应有佣金
|
||||
sellerCostPrice = operatorCostPrice
|
||||
}
|
||||
}
|
||||
|
||||
@@ -545,7 +547,8 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
|
||||
operatorType = "agent"
|
||||
actualPaidAmount = &operatorCostPrice
|
||||
purchaseRole = model.PurchaseRolePurchaseForSubordinate
|
||||
sellerCostPrice = buyerCostPrice
|
||||
// 代理代购下级时,佣金差额基准应是操作方(代理自己)的成本价,而非下级的成本价
|
||||
sellerCostPrice = operatorCostPrice
|
||||
}
|
||||
} else {
|
||||
// 兜底检查:后台不支持其他支付方式(DTO 验证已拒绝,此为防御性编程)
|
||||
@@ -806,7 +809,8 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest
|
||||
operatorType = "agent"
|
||||
actualPaidAmount = &operatorCostPrice
|
||||
purchaseRole = model.PurchaseRolePurchaseForSubordinate
|
||||
sellerCostPrice = buyerCostPrice
|
||||
// 代理代购下级时,佣金差额基准应是操作方(代理自己)的成本价,而非下级的成本价
|
||||
sellerCostPrice = operatorCostPrice
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,11 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/internal/task"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||
"github.com/hibiken/asynq"
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
@@ -46,6 +49,7 @@ type Service struct {
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
commissionRecordStore *postgres.CommissionRecordStore
|
||||
wechatConfigService WechatConfigServiceInterface
|
||||
queueClient *queue.Client // 任务队列客户端,用于触发自动购包任务
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
@@ -62,6 +66,7 @@ func New(
|
||||
commissionRecordStore *postgres.CommissionRecordStore,
|
||||
wechatConfigService WechatConfigServiceInterface,
|
||||
logger *zap.Logger,
|
||||
queueClient *queue.Client,
|
||||
) *Service {
|
||||
return &Service{
|
||||
db: db,
|
||||
@@ -74,6 +79,7 @@ func New(
|
||||
packageSeriesStore: packageSeriesStore,
|
||||
commissionRecordStore: commissionRecordStore,
|
||||
wechatConfigService: wechatConfigService,
|
||||
queueClient: queueClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
@@ -382,6 +388,23 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string,
|
||||
return err
|
||||
}
|
||||
|
||||
// 充值成功后,如有关联套餐则触发自动购包(强充绑套餐功能)
|
||||
linkedIDs := recharge.LinkedPackageIDs
|
||||
if len(linkedIDs) > 0 && string(linkedIDs) != "[]" && string(linkedIDs) != "null" {
|
||||
if s.queueClient != nil {
|
||||
taskPayload := task.AutoPurchasePayload{RechargeRecordID: recharge.ID}
|
||||
if enqueueErr := s.queueClient.EnqueueTask(ctx, constants.TaskTypeAutoPurchaseAfterRecharge, taskPayload,
|
||||
asynq.Queue(constants.QueueDefault),
|
||||
asynq.MaxRetry(3),
|
||||
); enqueueErr != nil {
|
||||
s.logger.Error("自动购包任务入队失败",
|
||||
zap.Uint("recharge_id", recharge.ID),
|
||||
zap.Error(enqueueErr))
|
||||
// 不影响充值成功,仅记录日志
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
s.logger.Info("充值支付回调处理成功",
|
||||
zap.String("recharge_no", rechargeNo),
|
||||
zap.Int64("amount", recharge.Amount),
|
||||
|
||||
@@ -31,6 +31,7 @@ type AutoPurchaseHandler struct {
|
||||
walletTransactionStore *postgres.AssetWalletTransactionStore
|
||||
packageUsageStore *postgres.PackageUsageStore
|
||||
redis *redis.Client
|
||||
asynqClient *asynq.Client // 用于事务提交成功后触发佣金计算任务
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
@@ -43,6 +44,7 @@ func NewAutoPurchaseHandler(
|
||||
walletTransactionStore *postgres.AssetWalletTransactionStore,
|
||||
packageUsageStore *postgres.PackageUsageStore,
|
||||
redisClient *redis.Client,
|
||||
asynqClient *asynq.Client,
|
||||
logger *zap.Logger,
|
||||
) *AutoPurchaseHandler {
|
||||
if orderStore == nil {
|
||||
@@ -69,6 +71,7 @@ func NewAutoPurchaseHandler(
|
||||
walletTransactionStore: walletTransactionStore,
|
||||
packageUsageStore: packageUsageStore,
|
||||
redis: redisClient,
|
||||
asynqClient: asynqClient,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
@@ -121,6 +124,7 @@ func (h *AutoPurchaseHandler) ProcessTask(ctx context.Context, task *asynq.Task)
|
||||
return err
|
||||
}
|
||||
|
||||
var createdOrderID uint
|
||||
if err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
wallet, walletErr := h.walletStore.GetByID(ctx, rechargeRecord.AssetWalletID)
|
||||
if walletErr != nil {
|
||||
@@ -146,6 +150,7 @@ func (h *AutoPurchaseHandler) ProcessTask(ctx context.Context, task *asynq.Task)
|
||||
if err = tx.Create(order).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
createdOrderID = order.ID
|
||||
|
||||
for _, item := range orderItems {
|
||||
item.OrderID = order.ID
|
||||
@@ -195,6 +200,26 @@ func (h *AutoPurchaseHandler) ProcessTask(ctx context.Context, task *asynq.Task)
|
||||
return err
|
||||
}
|
||||
|
||||
// 事务提交成功后触发佣金计算(不在事务内,防止任务提交后事务回滚的数据一致性问题)
|
||||
if h.asynqClient != nil && createdOrderID > 0 {
|
||||
payloadBytes, marshalErr := sonic.Marshal(map[string]any{"order_id": createdOrderID})
|
||||
if marshalErr != nil {
|
||||
h.logger.Warn("佣金任务载荷序列化失败",
|
||||
zap.Uint("order_id", createdOrderID),
|
||||
zap.Error(marshalErr))
|
||||
} else {
|
||||
commissionTask := asynq.NewTask(constants.TaskTypeCommission, payloadBytes,
|
||||
asynq.Queue(constants.QueueLow),
|
||||
asynq.MaxRetry(3),
|
||||
)
|
||||
if _, enqueueErr := h.asynqClient.EnqueueContext(ctx, commissionTask); enqueueErr != nil {
|
||||
h.logger.Warn("自动购包后提交佣金任务失败",
|
||||
zap.Uint("order_id", createdOrderID),
|
||||
zap.Error(enqueueErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
h.logger.Info("自动购包任务执行成功", zap.Uint("recharge_record_id", rechargeRecord.ID))
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -264,6 +264,7 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi
|
||||
|
||||
device := &model.Device{
|
||||
VirtualNo: row.VirtualNo,
|
||||
IMEI: row.IMEI,
|
||||
DeviceName: row.DeviceName,
|
||||
DeviceModel: row.DeviceModel,
|
||||
DeviceType: row.DeviceType,
|
||||
|
||||
@@ -30,6 +30,7 @@ type PollingTaskPayload struct {
|
||||
type PollingHandler struct {
|
||||
db *gorm.DB
|
||||
redis *redis.Client
|
||||
asynqClient *asynq.Client // 用于提交 Asynq 任务(如首次实名激活)
|
||||
gatewayClient *gateway.Client
|
||||
iotCardStore *postgres.IotCardStore
|
||||
concurrencyStore *postgres.PollingConcurrencyConfigStore
|
||||
@@ -44,6 +45,7 @@ type PollingHandler struct {
|
||||
func NewPollingHandler(
|
||||
db *gorm.DB,
|
||||
redis *redis.Client,
|
||||
asynqClient *asynq.Client,
|
||||
gatewayClient *gateway.Client,
|
||||
usageService *packagepkg.UsageService,
|
||||
logger *zap.Logger,
|
||||
@@ -51,6 +53,7 @@ func NewPollingHandler(
|
||||
return &PollingHandler{
|
||||
db: db,
|
||||
redis: redis,
|
||||
asynqClient: asynqClient,
|
||||
gatewayClient: gatewayClient,
|
||||
iotCardStore: postgres.NewIotCardStore(db, redis),
|
||||
concurrencyStore: postgres.NewPollingConcurrencyConfigStore(db),
|
||||
@@ -166,8 +169,9 @@ func (h *PollingHandler) HandleRealnameCheck(ctx context.Context, t *asynq.Task)
|
||||
zap.Int("old_status", card.RealNameStatus),
|
||||
zap.Int("new_status", newRealnameStatus))
|
||||
|
||||
// 任务 21.2-21.4: 检测首次实名(0/1 → 2),触发待激活套餐激活
|
||||
isFirstRealname := (card.RealNameStatus == 0 || card.RealNameStatus == 1) && newRealnameStatus == 2
|
||||
// 检测首次实名(之前未实名 → 现在已实名),触发待激活套餐激活
|
||||
isFirstRealname := card.RealNameStatus != constants.RealNameStatusVerified &&
|
||||
newRealnameStatus == constants.RealNameStatusVerified
|
||||
if isFirstRealname {
|
||||
h.triggerFirstRealnameActivation(ctx, uint(cardID))
|
||||
}
|
||||
@@ -695,9 +699,9 @@ func (h *PollingHandler) stopCards(ctx context.Context, cards []*model.IotCard,
|
||||
// Gateway 返回 bool 类型:true=已实名, false=未实名
|
||||
func (h *PollingHandler) parseRealnameStatus(realStatus bool) int {
|
||||
if realStatus {
|
||||
return 2 // 已实名
|
||||
return constants.RealNameStatusVerified // = 1,已实名
|
||||
}
|
||||
return 0 // 未实名
|
||||
return constants.RealNameStatusNotVerified // = 0,未实名
|
||||
}
|
||||
|
||||
// extractTaskType 从完整的任务类型中提取简短的类型名
|
||||
@@ -948,7 +952,7 @@ func (h *PollingHandler) getCardWithCache(ctx context.Context, cardID uint) (*mo
|
||||
}
|
||||
|
||||
// HandleProtectConsistencyCheck 保护期一致性检查
|
||||
// 检查绑定设备(is_standalone=false)且已实名(real_name_status=2)的卡
|
||||
// 检查绑定设备(is_standalone=false)且已实名(real_name_status=1)的卡
|
||||
// stop 保护期 + 开机 → 调网关停机;start 保护期 + 停机 → 调网关复机
|
||||
func (h *PollingHandler) HandleProtectConsistencyCheck(ctx context.Context, t *asynq.Task) error {
|
||||
var payload PollingTaskPayload
|
||||
@@ -1096,11 +1100,8 @@ func (h *PollingHandler) triggerFirstRealnameActivation(ctx context.Context, car
|
||||
asynq.Queue(constants.QueueDefault),
|
||||
)
|
||||
|
||||
// 这里需要访问 Asynq Client,暂时使用 Redis 队列
|
||||
// 实际应该通过依赖注入 asynq.Client
|
||||
activationKey := constants.RedisPollingManualQueueKey(constants.TaskTypePackageFirstActivation)
|
||||
if err := h.redis.RPush(ctx, activationKey, string(payloadBytes)).Err(); err != nil {
|
||||
h.logger.Warn("提交激活任务失败",
|
||||
if _, err := h.asynqClient.EnqueueContext(ctx, task); err != nil {
|
||||
h.logger.Warn("提交首次实名激活任务失败",
|
||||
zap.Uint("package_usage_id", pkg.ID),
|
||||
zap.Error(err))
|
||||
continue
|
||||
@@ -1109,8 +1110,5 @@ func (h *PollingHandler) triggerFirstRealnameActivation(ctx context.Context, car
|
||||
h.logger.Info("已提交首次实名激活任务",
|
||||
zap.Uint("package_usage_id", pkg.ID),
|
||||
zap.Uint("card_id", cardID))
|
||||
|
||||
// 避免未使用变量警告
|
||||
_ = task
|
||||
}
|
||||
}
|
||||
|
||||
@@ -71,6 +71,7 @@ func (h *Handler) RegisterHandlers() *asynq.ServeMux {
|
||||
h.registerOrderExpireHandler()
|
||||
h.registerAlertCheckHandler()
|
||||
h.registerDataCleanupHandler()
|
||||
h.registerAutoPurchaseHandler()
|
||||
|
||||
h.logger.Info("所有任务处理器注册完成")
|
||||
return h.mux
|
||||
@@ -151,6 +152,7 @@ func (h *Handler) registerPollingHandlers() {
|
||||
pollingHandler := task.NewPollingHandler(
|
||||
h.db,
|
||||
h.redis,
|
||||
h.asynqClient,
|
||||
h.gatewayClient,
|
||||
h.workerResult.Services.UsageService,
|
||||
h.logger,
|
||||
@@ -203,6 +205,22 @@ func (h *Handler) registerDataCleanupHandler() {
|
||||
h.logger.Info("注册数据清理任务处理器", zap.String("task_type", constants.TaskTypeDataCleanup))
|
||||
}
|
||||
|
||||
func (h *Handler) registerAutoPurchaseHandler() {
|
||||
autoPurchaseHandler := task.NewAutoPurchaseHandler(
|
||||
h.db,
|
||||
h.workerResult.Stores.Order,
|
||||
nil, // AssetRechargeStore:在 NewAutoPurchaseHandler 内按需初始化
|
||||
h.workerResult.Stores.AssetWallet,
|
||||
nil, // AssetWalletTransactionStore:在 NewAutoPurchaseHandler 内按需初始化
|
||||
h.workerResult.Stores.PackageUsage,
|
||||
h.redis,
|
||||
h.asynqClient,
|
||||
h.logger,
|
||||
)
|
||||
h.mux.HandleFunc(constants.TaskTypeAutoPurchaseAfterRecharge, autoPurchaseHandler.ProcessTask)
|
||||
h.logger.Info("注册自动购包任务处理器", zap.String("task_type", constants.TaskTypeAutoPurchaseAfterRecharge))
|
||||
}
|
||||
|
||||
// GetMux 获取 ServeMux(用于启动 Worker 服务器)
|
||||
func (h *Handler) GetMux() *asynq.ServeMux {
|
||||
return h.mux
|
||||
|
||||
@@ -35,6 +35,7 @@ type CSVParseError struct {
|
||||
type DeviceRow struct {
|
||||
Line int
|
||||
VirtualNo string
|
||||
IMEI string // 设备IMEI,对应 Excel IMEI 列,用于 Gateway API 调用
|
||||
DeviceName string
|
||||
DeviceModel string
|
||||
DeviceType string
|
||||
@@ -132,6 +133,9 @@ func ParseDeviceExcel(filePath string) ([]DeviceRow, int, error) {
|
||||
if idx := colIndex["virtual_no"]; idx >= 0 && idx < len(record) {
|
||||
row.VirtualNo = strings.TrimSpace(record[idx])
|
||||
}
|
||||
if idx := colIndex["imei"]; idx >= 0 && idx < len(record) {
|
||||
row.IMEI = strings.TrimSpace(record[idx])
|
||||
}
|
||||
if idx := colIndex["device_name"]; idx >= 0 && idx < len(record) {
|
||||
row.DeviceName = strings.TrimSpace(record[idx])
|
||||
}
|
||||
@@ -317,9 +321,11 @@ func findCardColumns(header []string) (iccidCol, msisdnCol, virtualNoCol int) {
|
||||
|
||||
// buildDeviceColumnIndex 构建设备导入列索引
|
||||
// 识别表头中的列名,返回列名到列索引的映射
|
||||
// IMEI 列支持多种列名格式:imei/IMEI/设备IMEI/IMEI号
|
||||
func buildDeviceColumnIndex(header []string) map[string]int {
|
||||
index := map[string]int{
|
||||
"virtual_no": -1,
|
||||
"imei": -1,
|
||||
"device_name": -1,
|
||||
"device_model": -1,
|
||||
"device_type": -1,
|
||||
@@ -332,9 +338,17 @@ func buildDeviceColumnIndex(header []string) map[string]int {
|
||||
}
|
||||
|
||||
for i, col := range header {
|
||||
col = strings.ToLower(strings.TrimSpace(col))
|
||||
if _, exists := index[col]; exists {
|
||||
index[col] = i
|
||||
normalized := strings.ToLower(strings.TrimSpace(col))
|
||||
// 支持 IMEI 多种列名格式
|
||||
switch normalized {
|
||||
case "imei", "设备imei", "imei号":
|
||||
if index["imei"] == -1 {
|
||||
index["imei"] = i
|
||||
}
|
||||
default:
|
||||
if _, exists := index[normalized]; exists {
|
||||
index[normalized] = i
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user