From f48f11beb41271aab886c0956a4a00336ff07a3f Mon Sep 17 00:00:00 2001 From: huang Date: Fri, 27 Mar 2026 22:35:28 +0800 Subject: [PATCH] =?UTF-8?q?docs(01-p0):=20=E5=88=9B=E5=BB=BA=20Phase=201?= =?UTF-8?q?=20P0=20=E7=B4=A7=E6=80=A5=E4=BF=AE=E5=A4=8D=E6=89=A7=E8=A1=8C?= =?UTF-8?q?=E8=AE=A1=E5=88=92=EF=BC=885=20=E4=B8=AA=20Plan=EF=BC=8C?= =?UTF-8?q?=E6=B6=B5=E7=9B=96=20CRITICAL-01~08=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .planning/ROADMAP.md | 11 +- .planning/phases/01-p0/01-01-PLAN.md | 224 +++++++++++++++++++ .planning/phases/01-p0/01-02-PLAN.md | 225 +++++++++++++++++++ .planning/phases/01-p0/01-03-PLAN.md | 314 +++++++++++++++++++++++++++ .planning/phases/01-p0/01-04-PLAN.md | 164 ++++++++++++++ .planning/phases/01-p0/01-05-PLAN.md | 233 ++++++++++++++++++++ 6 files changed, 1169 insertions(+), 2 deletions(-) create mode 100644 .planning/phases/01-p0/01-01-PLAN.md create mode 100644 .planning/phases/01-p0/01-02-PLAN.md create mode 100644 .planning/phases/01-p0/01-03-PLAN.md create mode 100644 .planning/phases/01-p0/01-04-PLAN.md create mode 100644 .planning/phases/01-p0/01-05-PLAN.md 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 已提交 + + + +完成后创建 `.planning/phases/01-p0/01-01-SUMMARY.md`,记录: +- 修改了哪些文件(文件路径 + 改动说明) +- 关键代码片段(修改前后对比) +- 编译验证结果 +- CRITICAL-01 和 CRITICAL-02 完成状态 + 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 已提交 + + + +完成后创建 `.planning/phases/01-p0/01-02-SUMMARY.md`,记录: +- 修改文件及改动说明 +- 关键代码对比(修改前后) +- 编译验证结果 +- CRITICAL-03 完成状态 + 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 一个) + + + +完成后创建 `.planning/phases/01-p0/01-03-SUMMARY.md`,记录: +- 修改文件及改动说明 +- 关键代码路径(充值回调 → 入队 → 消费 → 佣金) +- 编译验证结果 +- 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 已提交 + + + +完成后创建 `.planning/phases/01-p0/01-04-SUMMARY.md`,记录: +- 6 处 sellerCostPrice 逐一审查结论(行号 + 场景判断 + 是否修改) +- 场景 5 修改前后代码对比 +- 编译验证结果 +- CRITICAL-06 完成状态 + 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 一个) + + + +完成后创建 `.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 完成状态 +