归档提案

This commit is contained in:
2026-04-18 09:35:27 +08:00
parent 716e9f5971
commit e52671eaed
8 changed files with 155 additions and 0 deletions

View File

@@ -970,3 +970,86 @@ func (h *RealnameActivationHandler) HandleRealnameActivation(ctx context.Context
- ✅ 性能指标和错误码定义
- ✅ **激进的数据迁移策略**(明确删除字段、废弃逻辑、强制转换)
- ✅ 测试场景矩阵和实现参考
---
## 变更历史fix-realname-activation-logic
### MODIFIED: 支持未实名状态购买套餐
系统 SHALL 允许后台管理端为未实名的载体(设备/卡)购买套餐,套餐状态为"待生效"(status=0)。对于 `expiry_base=from_activation` 的套餐,系统 SHALL 在创建 `PackageUsage` 时检查载体当前实名状态:若已实名则直接激活;若未实名则标记 `pending_realname_activation=true` 等待实名触发。
**实名状态判定规则**
- `iot_card` 载体:查询该卡的 `real_name_status` 字段
- `device` 载体:通过 `tb_device_sim_binding``bind_status=1`)查询是否存在任意已实名(`real_name_status=1`)的绑定卡;存在任意一张即视为设备已实名
#### Scenario: 后台为未实名设备购买 from_activation 套餐——待生效
- **GIVEN** 设备 ID=2绑定的所有卡 `real_name_status=0`(均未实名)
- **AND** 套餐 `expiry_base=from_activation`,购买者为代理商(非 C 端)
- **WHEN** 管理员通过后台为该设备购买套餐
- **THEN** 系统创建 PackageUsage`status=0``pending_realname_activation=true``activated_at=NULL``expires_at=NULL`
#### Scenario: 后台为已有实名卡的设备购买 from_activation 套餐——直接激活并触发复机
- **GIVEN** 设备 ID=2至少一张绑定卡 `real_name_status=1`(已实名),且设备下有卡因无套餐停机(`stop_reason=no_package`
- **AND** 套餐 `expiry_base=from_activation`,购买者为代理商(非 C 端)
- **WHEN** 管理员通过后台为该设备购买套餐
- **THEN** 系统创建 PackageUsage`status=1``pending_realname_activation=false``activated_at=购买时间``expires_at` 按套餐配置计算
- **AND** `tryResumeAfterPayment` 检测到 `activatedCount=1`,异步调用 `ResumeCardIfStopped("device", 2)`
- **AND** 设备下已实名且因 `no_package` 停机的卡自动开机
#### Scenario: 后台为已实名单卡购买 from_activation 套餐——直接激活
- **GIVEN** IoT 卡 ID=10`real_name_status=1`(已实名)
- **AND** 套餐 `expiry_base=from_activation`,购买者为代理商(非 C 端)
- **WHEN** 管理员通过后台为该卡购买套餐
- **THEN** 系统创建 PackageUsage`status=1``pending_realname_activation=false``activated_at=购买时间``expires_at` 按套餐配置计算
#### Scenario: 后台为未实名单卡购买 from_activation 套餐——待生效
- **GIVEN** IoT 卡 ID=10`real_name_status=0`(未实名)
- **AND** 套餐 `expiry_base=from_activation`,购买者为代理商(非 C 端)
- **WHEN** 管理员通过后台为该卡购买套餐
- **THEN** 系统创建 PackageUsage`status=0``pending_realname_activation=true``activated_at=NULL``expires_at=NULL`
#### Scenario: from_purchase 套餐不检查实名状态——始终直接激活
- **GIVEN** 设备 ID=2所有绑定卡均未实名`real_name_status=0`
- **AND** 套餐 `expiry_base=from_purchase`
- **WHEN** 管理员通过后台为该设备购买套餐
- **THEN** 系统创建 PackageUsage`status=1``pending_realname_activation=false`,立即生效
#### Scenario: 客户端购买套餐——必须先实名(不受本次变更影响)
- **GIVEN** 设备 ID=2购买者为个人客户C 端)
- **WHEN** 客户通过 H5/小程序购买套餐
- **THEN** 系统前置检查已实名,行为不变
---
### ADDED: 卡首次实名时同时触发所属设备的设备级套餐激活
当轮询系统检测到某张 IoT 卡的实名状态从 0 变为 1首次实名系统 SHALL 除提交该卡的 `TaskTypePackageFirstActivation` 任务外,额外查询该卡是否属于某个设备(通过 `tb_device_sim_binding`),若存在有效绑定则同时提交以该设备为载体(`carrier_type=device`)的 `TaskTypePackageFirstActivation` 任务。
**设计约束**
- 若卡未绑定任何设备(`GetActiveBindingByCardID` 返回 `ErrRecordNotFound`)则静默跳过,不报错
- 提交任务失败记录 Warn 日志,不阻塞卡级激活流程
- 任务幂等:`ActivateByRealname(ctx, "device", deviceID)` 若查不到 `pending_realname_activation=true` 的套餐则直接返回 nil
#### Scenario: 设备下某张卡首次实名——同时触发设备级套餐激活与复机
- **GIVEN** IoT 卡 ID=4绑定于设备 ID=2`tb_device_sim_binding` 中存在有效绑定)
- **AND** 设备 ID=2 存在 `status=0, pending_realname_activation=true` 的设备级 PackageUsage
- **AND** 设备下有卡因 `not_realname` 停机(`stop_reason=not_realname`
- **WHEN** 轮询系统检测到卡 ID=4 实名状态 0→1
- **THEN** 系统提交两个 Asynq 任务:
1. `carrier_type=iot_card, carrier_id=4`(卡级激活)
2. `carrier_type=device, carrier_id=2`(设备级激活)
- **AND** 设备级 PackageUsage 最终 `status=1, pending_realname_activation=false, activated_at` 已设置
- **AND** `ActivateByRealname` 激活成功后异步调用 `ResumeCardIfStopped("device", 2)`,设备下已实名的停机卡自动开机
#### Scenario: 独立卡(未绑定设备)首次实名——仅触发卡级激活
- **GIVEN** IoT 卡 ID=10未绑定任何设备`tb_device_sim_binding` 中无有效记录)
- **WHEN** 轮询系统检测到卡 ID=10 实名状态 0→1
- **THEN** 系统仅提交卡级 Asynq 任务(`carrier_type=iot_card, carrier_id=10`
- **AND** 不报错,不记录 Error 日志
#### Scenario: 设备下某张卡实名但设备无待激活套餐——任务执行幂等返回
- **GIVEN** IoT 卡 ID=4绑定于设备 ID=2
- **AND** 设备 ID=2 无 `pending_realname_activation=true` 的套餐
- **WHEN** 轮询系统检测到卡 ID=4 实名状态 0→1并提交设备级激活任务
- **THEN** 任务 Worker 执行 `ActivateByRealname(ctx, "device", 2)`,查询结果为空,直接返回 nil无错误

View File

@@ -347,3 +347,75 @@ func NewPollingProtectHandler(
#### Scenario: verboseLog 关闭时保护期检查不输出额外日志
- **WHEN** `verboseLog = false`
- **THEN** 不输出 verbose 日志
---
## 变更历史fix-realname-activation-logic
### MODIFIED: polling_realname_handler.go——实名数据采集
**文件**`internal/task/polling_realname_handler.go`< 220行
**构造函数依赖**(通过构造函数注入,禁止全局变量):
```go
func NewPollingRealnameHandler(
base *PollingBase,
gateway *gateway.Client,
iotCardStore *postgres.IotCardStore,
asynqClient *asynq.Client,
stopResumeSvc iot_card_svc.StopResumeServiceInterface,
deviceSimBindingStore *postgres.DeviceSimBindingStore, // 新增:用于查询卡所属设备
) *PollingRealnameHandler
```
**方法列表**
- `Handle(ctx, task) error`Asynq 任务入口
- `triggerFirstRealnameActivation(ctx, cardID)`:提交卡级首次实名激活任务(已有,不变)
- `triggerDeviceRealnameActivation(ctx, cardID)`**新增**,查询卡所属设备并提交设备级激活任务
**处理流程0→1 路径新增步骤)**
1.`base.acquireConcurrency("realname")`,获取失败则 requeue 后返回
2.`base.getCardWithCache(ctx, cardID)` 获取卡信息
3. 调 Gateway 查询实名状态
4. 写 Store更新 `real_name_status``last_real_name_check_at`,首次实名时写 `first_realname_at`
5. 若实名状态变为已实名0→1
a. 调 `triggerFirstRealnameActivation(ctx, cardID)` — 提交卡级激活任务
b. **调 `triggerDeviceRealnameActivation(ctx, cardID)` — 提交设备级激活任务(新增)**
c. 调 `stopResumeService.EvaluateAndAct(ctx, freshCard)` — 触发复机判断
6.`base.requeueCard(ctx, cardID, "realname")` 重新入队
7.`base.releaseConcurrency("realname")`
#### Scenario: 实名状态由未实名变为已实名——同时触发卡级和设备级激活
- **GIVEN** cardID=4`real_name_status=0`,该卡通过 `tb_device_sim_binding` 绑定于设备 ID=2
- **WHEN** Gateway 返回实名已完成Handler 检测到 0→1 变化
- **THEN** Store 更新 `real_name_status=1, first_realname_at=NOW()`
- **AND** 提交卡级 Asynq 任务(`carrier_type=iot_card, carrier_id=4`
- **AND** 提交设备级 Asynq 任务(`carrier_type=device, carrier_id=2`
- **AND** 调用 `EvaluateAndAct` 触发复机评估
#### Scenario: 独立卡实名——仅触发卡级激活
- **GIVEN** cardID=10`real_name_status=0`,未绑定任何设备
- **WHEN** Gateway 返回实名已完成Handler 检测到 0→1 变化
- **THEN** 提交卡级 Asynq 任务(`carrier_type=iot_card, carrier_id=10`
- **AND** `triggerDeviceRealnameActivation` 调用 `GetActiveBindingByCardID` 返回 not found静默跳过不报错
#### Scenario: 实名状态未变化——不触发任何激活
- **GIVEN** cardID=200`real_name_status=1`已实名Gateway 返回仍已实名
- **WHEN** Handler 执行
- **THEN** Store 不执行实名更新;不提交任何激活任务;按正常间隔 requeue
---
### ADDED: PollingRealnameHandler 注入 DeviceSimBindingStore
`PollingRealnameHandler` 结构体 SHALL 包含 `deviceSimBindingStore *postgres.DeviceSimBindingStore` 字段,由 `NewPollingRealnameHandler` 构造函数注入。`pkg/queue/handler.go``registerPollingHandlers` SHALL 将已可用的 `h.workerResult.Stores.DeviceSimBinding` 作为最后一个参数传入。
#### Scenario: Worker 启动时 DeviceSimBindingStore 正确注入
- **WHEN** Worker 进程启动,调用 `registerPollingHandlers()`
- **THEN** `NewPollingRealnameHandler` 接收 6 个参数,第 6 个为 `h.workerResult.Stores.DeviceSimBinding`
- **AND** handler 的 `deviceSimBindingStore` 字段非 nil
#### Scenario: DeviceSimBindingStore 为 nil 时 triggerDeviceRealnameActivation 静默跳过
- **GIVEN** handler 的 `deviceSimBindingStore` 为 nil测试或未注入场景
- **WHEN** 调用 `triggerDeviceRealnameActivation`
- **THEN** 函数立即返回,不 panic不报错