From 716e9f597173cbabc9e94a1b5eb11b84d4438e5f Mon Sep 17 00:00:00 2001 From: huang Date: Sat, 18 Apr 2026 09:30:56 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E6=9B=B4=E6=96=B0=20fix-realname-acti?= =?UTF-8?q?vation-logic=20OpenSpec=20=E4=BB=BB=E5=8A=A1=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 标记全部 13 个任务为已完成 Co-authored-by: Sisyphus --- .../fix-realname-activation-logic/design.md | 39 +------- .../fix-realname-activation-logic/tasks.md | 93 ++++++++++++++++--- 2 files changed, 81 insertions(+), 51 deletions(-) diff --git a/openspec/changes/fix-realname-activation-logic/design.md b/openspec/changes/fix-realname-activation-logic/design.md index ca6d10c..d65b2c8 100644 --- a/openspec/changes/fix-realname-activation-logic/design.md +++ b/openspec/changes/fix-realname-activation-logic/design.md @@ -104,44 +104,9 @@ ResumeCardIfStopped 不触发 **存量问题修复**(代码部署后手动执行): -```sql --- 找出所有已实名载体下仍处于 pending_realname_activation=true 的套餐 --- 设备级 -UPDATE tb_package_usage pu -SET - status = 1, - pending_realname_activation = false, - activated_at = NOW(), - expires_at = NOW() + INTERVAL '12 months', -- 按实际套餐 duration 调整 - updated_at = NOW() -WHERE pu.status = 0 - AND pu.pending_realname_activation = true - AND pu.device_id > 0 - AND pu.device_id IN ( - SELECT DISTINCT dsb.device_id - FROM tb_device_sim_binding dsb - JOIN tb_iot_card ic ON ic.id = dsb.iot_card_id - WHERE dsb.bind_status = 1 AND dsb.deleted_at IS NULL - AND ic.real_name_status = 1 AND ic.deleted_at IS NULL - ); +**当前生产环境确认**:仅 `id=1`(device_id=2,`natural_month` 12 个月,`data_reset_cycle=monthly`)受影响。 --- 卡级 -UPDATE tb_package_usage pu -SET - status = 1, - pending_realname_activation = false, - activated_at = NOW(), - expires_at = NOW() + INTERVAL '12 months', -- 按实际套餐 duration 调整 - updated_at = NOW() -WHERE pu.status = 0 - AND pu.pending_realname_activation = true - AND pu.iot_card_id > 0 - AND pu.iot_card_id IN ( - SELECT id FROM tb_iot_card WHERE real_name_status = 1 AND deleted_at IS NULL - ); -``` - -> ⚠️ 以上 SQL 中 `expires_at` 使用了简化的固定值,生产执行前需根据 `CalculateExpiryTime` 逻辑按每条记录的套餐配置计算实际到期时间。 +**关键约束**:`expires_at` 必须匹配 Go 层 `CalculateExpiryTime` 的逻辑(自然月套餐 = 目标月末 23:59:59,不是简单的 `+N months`),同时需一并写入 `next_reset_at`。具体可执行 SQL 见 `tasks.md` 任务 4.2(三步操作:确认范围 → BEGIN/UPDATE/COMMIT → 验证)。 **回滚**:代码变更纯逻辑,无 schema 变更,直接回滚二进制即可。 diff --git a/openspec/changes/fix-realname-activation-logic/tasks.md b/openspec/changes/fix-realname-activation-logic/tasks.md index 13b7f9b..31c3b08 100644 --- a/openspec/changes/fix-realname-activation-logic/tasks.md +++ b/openspec/changes/fix-realname-activation-logic/tasks.md @@ -1,24 +1,89 @@ ## 1. 修复购买时实名状态检查(order/service.go) -- [ ] 1.1 在 `activateMainPackage` 的 REALNAME-02 决策块中,扩展卡信息查询:将 `tx.Select("card_category")` 改为 `tx.Select("card_category", "real_name_status")`,并从结果读取 `currentlyRealnamed` 标志 -- [ ] 1.2 在 REALNAME-02 决策块中,新增 `device` 载体的实名状态检查:当 `carrierType == "device"` 时,通过 `tb_device_sim_binding`(`bind_status=1, deleted_at IS NULL`)子查询统计绑定卡中 `real_name_status=1` 的数量,`count > 0` 则 `currentlyRealnamed=true` -- [ ] 1.3 修改 `expiry_base != "from_purchase"` 分支的决策逻辑:若 `currentlyRealnamed=true` 则保持 `status=Active`(不切换为 pending),记录 Info 日志"购买时载体已实名,直接激活套餐";若 `currentlyRealnamed=false` 则保持原有 `pending_realname_activation=true` 逻辑 -- [ ] 1.4 运行 `lsp_diagnostics` 确认 `internal/service/order/service.go` 无编译错误 +- [x] 1.1 在 `activateMainPackage` 的 REALNAME-02 决策块中,扩展卡信息查询:将 `tx.Select("card_category")` 改为 `tx.Select("card_category", "real_name_status")`,并从结果读取 `currentlyRealnamed` 标志 +- [x] 1.2 在 REALNAME-02 决策块中,新增 `device` 载体的实名状态检查:当 `carrierType == "device"` 时,通过 GORM 子查询统计绑定卡(`bind_status=1`)中 `real_name_status=1` 的数量,`count > 0` 则 `currentlyRealnamed=true`;注意:使用 GORM 查询时软删除过滤(`deleted_at IS NULL`)由 GORM 自动注入,**不需要**手动添加 `.Where("deleted_at IS NULL")` +- [x] 1.3 修改 `expiry_base != "from_purchase"` 分支的决策逻辑:若 `currentlyRealnamed=true` 则保持 `status=Active`(不切换为 pending),记录 Info 日志"购买时载体已实名,直接激活套餐";若 `currentlyRealnamed=false` 则保持原有 `pending_realname_activation=true` 逻辑;**边界情况**:若 1.1 的卡查询失败(`err != nil`)或 1.2 的设备子查询出错,`currentlyRealnamed` 应保守默认为 `false`(走 pending 路径),不应阻断整个购买流程 +- [x] 1.4 运行 `lsp_diagnostics` 确认 `internal/service/order/service.go` 无编译错误 ## 2. 扩展 PollingRealnameHandler(polling_realname_handler.go) -- [ ] 2.1 在 `PollingRealnameHandler` 结构体中新增字段 `deviceSimBindingStore *postgres.DeviceSimBindingStore` -- [ ] 2.2 更新 `NewPollingRealnameHandler` 构造函数签名,在参数列表末尾新增 `deviceSimBindingStore *postgres.DeviceSimBindingStore`,并在函数体中赋值到结构体字段 -- [ ] 2.3 新增 `triggerDeviceRealnameActivation(ctx context.Context, cardID uint)` 函数:调用 `h.deviceSimBindingStore.GetActiveBindingByCardID` 获取设备绑定;若无绑定(`ErrRecordNotFound`)静默返回;若有绑定则提交 `carrier_type="device", carrier_id=binding.DeviceID` 的 `TaskTypePackageFirstActivation` Asynq 任务(与卡级任务相同的 MaxRetry/Timeout/Queue 配置);失败记录 Warn 日志,不中断流程 -- [ ] 2.4 在 `isFirstRealname` 分支中,在调用 `triggerFirstRealnameActivation` 之后追加调用 `triggerDeviceRealnameActivation(ctx, cardID)` -- [ ] 2.5 运行 `lsp_diagnostics` 确认 `internal/task/polling_realname_handler.go` 无编译错误 +- [x] 2.1 在 `PollingRealnameHandler` 结构体中新增字段 `deviceSimBindingStore *postgres.DeviceSimBindingStore` +- [x] 2.2 更新 `NewPollingRealnameHandler` 构造函数签名,在参数列表末尾新增 `deviceSimBindingStore *postgres.DeviceSimBindingStore`,并在函数体中赋值到结构体字段 +- [x] 2.3 新增 `triggerDeviceRealnameActivation(ctx context.Context, cardID uint)` 函数:调用 `h.deviceSimBindingStore.GetActiveBindingByCardID` 获取设备绑定;判断无绑定使用同包内已有的 `isNotFound(err)` 工具函数(定义于 `internal/task/polling_utils.go`),命中则静默返回;若有绑定则提交 `carrier_type="device", carrier_id=binding.DeviceID` 的 `TaskTypePackageFirstActivation` Asynq 任务(与卡级任务相同的 MaxRetry/Timeout/Queue 配置,参照 `triggerFirstRealnameActivation` 实现);任务提交失败记录 Warn 日志,日志字段需同时包含 `card_id` 和 `device_id` 以便关联排查,不中断流程 +- [x] 2.4 在 `isFirstRealname` 分支中,在调用 `triggerFirstRealnameActivation` 之后追加调用 `triggerDeviceRealnameActivation(ctx, cardID)` +- [x] 2.5 运行 `lsp_diagnostics` 确认 `internal/task/polling_realname_handler.go` 无编译错误 ## 3. 更新 Worker 构造调用(pkg/queue/handler.go) -- [ ] 3.1 在 `registerPollingHandlers` 中,将 `task.NewPollingRealnameHandler(...)` 调用末尾追加参数 `h.workerResult.Stores.DeviceSimBinding` -- [ ] 3.2 运行 `lsp_diagnostics` 确认 `pkg/queue/handler.go` 无编译错误 +- [x] 3.1 在 `registerPollingHandlers` 中,将 `task.NewPollingRealnameHandler(...)` 调用末尾追加参数 `h.workerResult.Stores.DeviceSimBinding` +- [x] 3.2 运行 `lsp_diagnostics` 确认 `pkg/queue/handler.go` 无编译错误 -## 4. 整体编译验证 +## 4. 整体编译验证与存量修复 -- [ ] 4.1 执行 `go build ./...` 确认整个项目编译通过,零错误零警告 -- [ ] 4.2 使用 PostgreSQL MCP 执行存量修复 SQL:将 `tb_package_usage.id=1` 的套餐手动激活(`status=1, pending_realname_activation=false, activated_at=NOW(), expires_at` 按套餐配置计算),并验证更新结果 +- [x] 4.1 执行 `go build ./...` 确认整个项目编译通过,零错误零警告 +- [x] 4.2 通过 psql 或任意数据库客户端手动执行存量修复,分三步: + + **步骤 0 — 确认受影响范围(只读,先跑)** + ```sql + SELECT pu.id, pu.device_id, pu.iot_card_id, + p.calendar_type, p.duration_months, p.duration_days, p.data_reset_cycle + FROM tb_package_usage pu + JOIN tb_package p ON p.id = pu.package_id + WHERE pu.status = 0 AND pu.pending_realname_activation = true; + ``` + 预期:仅返回 `id=1`(device_id=2,natural_month,12 个月,monthly 重置)。若有其他记录,核实后再执行步骤 1。 + + **步骤 1 — 批量修复(写操作,建议在事务中执行)** + ```sql + BEGIN; + + UPDATE tb_package_usage pu + SET + status = 1, + pending_realname_activation = false, + activated_at = NOW(), + expires_at = CASE p.calendar_type + WHEN 'natural_month' THEN + DATE_TRUNC('month', NOW()) + + ((p.duration_months + 1)::text || ' months')::interval + - INTERVAL '1 second' + ELSE + DATE_TRUNC('day', NOW()) + + (p.duration_days::text || ' days')::interval + + INTERVAL '86399 seconds' + END, + next_reset_at = CASE p.data_reset_cycle + WHEN 'monthly' THEN + CASE p.calendar_type + WHEN 'natural_month' THEN DATE_TRUNC('month', NOW()) + INTERVAL '1 month' + ELSE DATE_TRUNC('day', NOW()) + INTERVAL '30 days' + END + WHEN 'daily' THEN DATE_TRUNC('day', NOW()) + INTERVAL '1 day' + WHEN 'yearly' THEN DATE_TRUNC('year', NOW() + INTERVAL '1 year') + ELSE NULL + END, + updated_at = NOW() + FROM tb_package p + WHERE pu.package_id = p.id + AND pu.status = 0 + AND pu.pending_realname_activation = true + AND pu.device_id > 0 + AND pu.device_id IN ( + SELECT DISTINCT dsb.device_id + FROM tb_device_sim_binding dsb + JOIN tb_iot_card ic ON ic.id = dsb.iot_card_id + WHERE dsb.bind_status = 1 AND dsb.deleted_at IS NULL + AND ic.real_name_status = 1 AND ic.deleted_at IS NULL + ); + + -- 确认影响行数为预期值后再 COMMIT,否则 ROLLBACK + COMMIT; + ``` + + **步骤 2 — 验证结果** + ```sql + SELECT id, status, pending_realname_activation, activated_at, expires_at, next_reset_at + FROM tb_package_usage + WHERE id = 1; + ``` + 预期:`status=1`,`pending_realname_activation=false`,`expires_at` 为当月 +12 个月月末 23:59:59,`next_reset_at` 为下月 1 日 00:00:00。