diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md
index e3260f5..df3a242 100644
--- a/.planning/ROADMAP.md
+++ b/.planning/ROADMAP.md
@@ -31,7 +31,14 @@
3. 代理代购下级场景(场景 5)的订单金额正确使用 operatorCostPrice,佣金链计算正常
4. 批量导入设备 Excel 后,设备记录中含 IMEI 字段,Gateway 调用不再因 IMEI 缺失而失败
5. 并发提交提现申请时,RowsAffected 检查生效,不会创建重复提现单
-**Plans**: TBD
+**Plans**: 5 plans
+
+Plans:
+- [ ] 01-01-PLAN.md — CRITICAL-01/02:实名状态常量统一 + C 端实名校验修复(Wave 1)
+- [ ] 01-02-PLAN.md — CRITICAL-03:实名激活任务断链(PollingHandler 注入 asynqClient)(Wave 2)
+- [ ] 01-03-PLAN.md — CRITICAL-04/05:充值回调触发自动购包 + 佣金链补全(Wave 2,parallel)
+- [ ] 01-04-PLAN.md — CRITICAL-06:代购订单 sellerCostPrice 6 处语义审查修正(Wave 1,parallel)
+- [ ] 01-05-PLAN.md — CRITICAL-07/08:设备导入 IMEI + 提现冻结并发校验(Wave 1,parallel)
---
@@ -111,7 +118,7 @@
| Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------|
-| 1. P0 紧急修复 | 0/1 | Not started | - |
+| 1. P0 紧急修复 | 0/5 | Planning complete | - |
| 2. 权限/财务/实名/轮询修复 | 0/1 | Not started | - |
| 3. 设备体系完善 | 0/1 | Not started | - |
| 4. 退款 + 支付 + 运营修复 | 0/1 | Not started | - |
diff --git a/.planning/phases/01-p0/01-01-PLAN.md b/.planning/phases/01-p0/01-01-PLAN.md
new file mode 100644
index 0000000..629f2be
--- /dev/null
+++ b/.planning/phases/01-p0/01-01-PLAN.md
@@ -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"
+---
+
+
+统一实名状态常量(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 校验使用常量
+
+
+
+@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
+@$HOME/.config/opencode/get-shit-done/templates/summary.md
+
+
+
+@.planning/PROJECT.md
+@.planning/ROADMAP.md
+@.planning/STATE.md
+@.planning/phases/01-p0/01-CONTEXT.md
+
+# 修复规格书(核心参考)
+@.sisyphus/plans/修正业务-完整方案.md
+
+
+
+
+```go
+// pkg/constants/iot.go(已确认存在,第 47-48 行附近)
+const (
+ RealNameStatusNotVerified = 0 // 未实名
+ RealNameStatusVerified = 1 // 已实名
+)
+```
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Task 1: 修复 polling_handler.go 实名状态写入值(CRITICAL-01 核心)
+ internal/task/polling_handler.go, internal/model/iot_card.go
+
+**修改 `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)。
+
+
+ go build ./... && rg "return 2" internal/task/polling_handler.go && echo "FAIL: 仍存在 return 2" || echo "PASS: return 2 已消除"
+
+
+ - polling_handler.go 中 parseRealnameStatus 不再 return 2,改为 return constants.RealNameStatusVerified
+ - isFirstRealname 不再用 == 2,改为 == constants.RealNameStatusVerified
+ - iot_card.go 注释统一为 0-未实名 1-已实名
+ - go build ./... 编译通过
+
+
+
+
+ Task 2: 修复 C 端实名校验(CRITICAL-02)
+ internal/service/client_order/service.go
+
+**修改 `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)。
+
+
+ go build ./... && rg "RealNameStatus != 1" internal/service/client_order/service.go && echo "FAIL: 仍有硬编码" || echo "PASS: 硬编码已消除"
+
+
+ - client_order/service.go 实名校验改为 constants.RealNameStatusVerified
+ - go build ./... 编译通过
+ - rg 确认不再有 != 1 的硬编码实名判断
+
+
+
+
+
+
+整体验收(两个任务完成后):
+
+```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
+
+
+
+1. `go build ./...` 编译通过,无错误
+2. `parseRealnameStatus(true)` 返回 1(constants.RealNameStatusVerified),不再返回 2
+3. `isFirstRealname` 不再使用 == 2 判断
+4. `client_order/service.go` 实名校验使用常量,不再硬编码 1
+5. Model 注释与常量定义一致
+6. 两个独立 commit 已提交
+
+
+
diff --git a/.planning/phases/01-p0/01-02-PLAN.md b/.planning/phases/01-p0/01-02-PLAN.md
new file mode 100644
index 0000000..865a906
--- /dev/null
+++ b/.planning/phases/01-p0/01-02-PLAN.md
@@ -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"
+---
+
+
+修复实名激活任务断链(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
+
+
+
+@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
+@$HOME/.config/opencode/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/01-p0/01-01-SUMMARY.md
+@.planning/phases/01-p0/01-CONTEXT.md
+
+# 修复规格书(方案 A-3,第 165-299 行)
+@.sisyphus/plans/修正业务-完整方案.md
+
+
+
+```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,
+)
+```
+
+
+```go
+// 当前降级代码(需删除 RPush 部分,直接用 EnqueueContext)
+task := asynq.NewTask(constants.TaskTypePackageFirstActivation, payloadBytes, ...)
+
+activationKey := constants.RedisPollingManualQueueKey(constants.TaskTypePackageFirstActivation)
+h.redis.RPush(ctx, activationKey, string(payloadBytes)) // ← 删掉
+
+_ = task // ← 删掉
+```
+
+
+
+
+
+
+ Task 1: PollingHandler 注入 asynqClient(CRITICAL-03)
+ internal/task/polling_handler.go, pkg/queue/handler.go
+
+**步骤 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)。
+
+
+ go build ./... && rg "_ = task" internal/task/polling_handler.go && echo "FAIL: _ = task 仍存在" || echo "PASS: _ = task 已删除"
+
+
+ - PollingHandler struct 含 asynqClient 字段
+ - NewPollingHandler 接受 asynqClient 参数
+ - triggerFirstRealnameActivation 使用 asynqClient.EnqueueContext,不再 RPush
+ - _ = task 这行已被删除
+ - pkg/queue/handler.go 传入 h.asynqClient
+ - go build ./... 编译通过
+
+
+
+
+
+
+整体验收:
+
+```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;`
+
+
+
+1. `go build ./...` 编译通过
+2. PollingHandler 有 asynqClient 字段且被正确初始化
+3. triggerFirstRealnameActivation 使用 EnqueueContext,不再 RPush
+4. `_ = task` 行不存在
+5. 独立 commit 已提交
+
+
+
diff --git a/.planning/phases/01-p0/01-03-PLAN.md b/.planning/phases/01-p0/01-03-PLAN.md
new file mode 100644
index 0000000..cc351b4
--- /dev/null
+++ b/.planning/phases/01-p0/01-03-PLAN.md
@@ -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"
+---
+
+
+修复充值回调后自动购包触发(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
+
+
+
+@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
+@$HOME/.config/opencode/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/01-p0/01-01-SUMMARY.md
+@.planning/phases/01-p0/01-CONTEXT.md
+
+# 修复规格书(方案 A-4,第 301-575 行)
+@.sisyphus/plans/修正业务-完整方案.md
+
+
+
+
+
+
+```go
+// internal/service/recharge/service.go(当前,无 queueClient)
+type Service struct {
+ db *gorm.DB
+ // ... 其他字段
+}
+
+func New(db *gorm.DB, /* ... */) *Service {
+ return &Service{db: db, /* ... */}
+}
+```
+
+
+
+
+
+
+```go
+const (
+ TaskTypeAutoPurchaseAfterRecharge = "task:auto_purchase_after_recharge"
+ TaskTypeCommission = "..." // 已存在,从 pkg/constants/constants.go 确认
+ QueueDefault = "..."
+ QueueLow = "..." // 佣金任务用低优先级队列
+)
+```
+
+
+
+
+
+
+ Task 1: recharge.Service 注入 queueClient,触发自动购包(CRITICAL-04 Part 1)
+ internal/service/recharge/service.go, internal/bootstrap/services.go
+
+**步骤 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)。
+
+
+ go build ./... && rg "queueClient" internal/service/recharge/service.go | grep -c "queueClient" | grep -v "^0$" && echo "PASS: queueClient 已注入" || echo "FAIL"
+
+
+ - recharge.Service struct 含 queueClient 字段
+ - HandlePaymentCallback 在充值成功、LinkedPackageIDs 非空时触发入队
+ - bootstrap/services.go 的 recharge.New() 传入 deps.QueueClient
+ - go build ./... 编译通过
+
+
+
+
+ Task 2: AutoPurchaseHandler 注入 asynqClient + 注册 + 触发佣金(CRITICAL-04 Part 2 + CRITICAL-05)
+ internal/task/auto_purchase.go, pkg/queue/handler.go
+
+**步骤 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)。
+
+
+ go build ./... && rg "TaskTypeAutoPurchaseAfterRecharge" pkg/queue/handler.go | grep "HandleFunc" && echo "PASS: AutoPurchaseHandler 已注册" || echo "FAIL: 未注册"
+
+
+ - AutoPurchaseHandler struct 含 asynqClient 字段
+ - ProcessTask 事务成功后入队 TaskTypeCommission(不在事务内)
+ - pkg/queue/handler.go 注册了 AutoPurchaseHandler
+ - RegisterHandlers() 调用了 registerAutoPurchaseHandler()
+ - go build ./... 编译通过
+
+
+
+
+
+
+整体验收:
+
+```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(佣金记录)
+
+
+
+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 一个)
+
+
+
diff --git a/.planning/phases/01-p0/01-04-PLAN.md b/.planning/phases/01-p0/01-04-PLAN.md
new file mode 100644
index 0000000..a4c20a4
--- /dev/null
+++ b/.planning/phases/01-p0/01-04-PLAN.md
@@ -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"
+---
+
+
+逐一审查并修正代购订单中 sellerCostPrice 的 6 处赋值(CRITICAL-06),确保场景 5(代理代购下级)使用正确的成本价基准。
+
+Purpose: sellerCostPrice 是佣金差额计算的基准。场景 5 中错误使用 buyerCostPrice(下级成本价)导致整个佣金链差额为 0,所有上级代理无法获得应有佣金。修复后代购场景佣金链正常工作。
+
+Output:
+- order/service.go: 场景 5 的 sellerCostPrice 改为 operatorCostPrice,其余 5 处经语义审查确认或按需修正
+
+
+
+@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
+@$HOME/.config/opencode/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/01-p0/01-CONTEXT.md
+
+# 修复规格书(方案 A-5,第 579-618 行)
+@.sisyphus/plans/修正业务-完整方案.md
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Task 1: 逐一审查并修正 sellerCostPrice 6 处赋值(CRITICAL-06)
+ internal/service/order/service.go
+
+**步骤 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)。
+
+
+ go build ./... && rg -n "sellerCostPrice" internal/service/order/service.go
+
+
+ - 6 处 sellerCostPrice 赋值全部经过语义审查
+ - 场景 5 分支已将 sellerCostPrice = buyerCostPrice 改为 sellerCostPrice = operatorCostPrice
+ - 其余各处审查结论已记录
+ - go build ./... 编译通过
+ - 独立 commit 已提交
+
+
+
+
+
+
+整体验收:
+
+```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
+
+
+
+1. `go build ./...` 编译通过
+2. 场景 5(代理代购下级)sellerCostPrice = operatorCostPrice(不再是 buyerCostPrice)
+3. 其余 5 处经语义审查,结论记录在 SUMMARY 中
+4. 独立 commit 已提交
+
+
+
diff --git a/.planning/phases/01-p0/01-05-PLAN.md b/.planning/phases/01-p0/01-05-PLAN.md
new file mode 100644
index 0000000..e18d288
--- /dev/null
+++ b/.planning/phases/01-p0/01-05-PLAN.md
@@ -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"
+---
+
+
+修复设备导入缺失 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 校验,并发失败返回错误
+
+
+
+@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
+@$HOME/.config/opencode/get-shit-done/templates/summary.md
+
+
+
+@.planning/phases/01-p0/01-CONTEXT.md
+
+# 修复规格书(方案 A-6 第 621-670 行,方案 A-7 第 673-705 行)
+@.sisyphus/plans/修正业务-完整方案.md
+
+
+
+```go
+type DeviceRow struct {
+ Line int
+ VirtualNo string
+ // IMEI 字段缺失 ← 需新增
+ DeviceName string
+ DeviceModel string
+ DeviceType string
+ MaxSimSlots int
+ Manufacturer string
+ ICCIDs []string
+}
+```
+
+
+```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 的检查
+```
+
+
+
+
+
+
+ Task 1: 设备导入补充 IMEI 字段(CRITICAL-07)
+ pkg/utils/excel.go, internal/task/device_import.go
+
+**修改 `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)。
+
+
+ go build ./... && rg "IMEI" pkg/utils/excel.go internal/task/device_import.go | grep -c "IMEI" | grep -v "^0$" && echo "PASS: IMEI 字段已添加" || echo "FAIL"
+
+
+ - DeviceRow struct 含 IMEI string 字段
+ - ParseDeviceExcel 支持 imei/IMEI/设备IMEI/IMEI号 列名
+ - device_import.go processBatch 填充 IMEI: row.IMEI
+ - go build ./... 编译通过
+
+
+
+
+ Task 2: 提现冻结并发校验(CRITICAL-08)
+ internal/service/my_commission/service.go
+
+**修改 `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)。
+
+
+ go build ./... && rg "RowsAffected" internal/service/my_commission/service.go && echo "PASS: RowsAffected 校验已添加" || echo "FAIL: RowsAffected 校验缺失"
+
+
+ - my_commission/service.go 冻结余额使用 result := tx...Updates(),检查 result.Error 和 result.RowsAffected == 0
+ - RowsAffected == 0 时返回 errors.New(errors.CodeInsufficientBalance, ...)
+ - go build ./... 编译通过
+
+
+
+
+
+
+整体验收:
+
+```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 确认提现单数量与余额冻结成功次数一致
+
+
+
+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 一个)
+
+
+