Files
junhong_cmp_fiber/openspec/specs/package-realname-activation/spec.md
huang c665f32976
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m54s
feat: 套餐系统升级 - Worker 重构、流量重置、文档与规范更新
- 重构 Worker 启动流程,引入 bootstrap 模块统一管理依赖注入
- 实现套餐流量重置服务(日/月/年周期重置)
- 新增套餐激活排队、加油包绑定、囤货待实名激活逻辑
- 新增订单创建幂等性防重(Redis 业务键 + 分布式锁)
- 更新 AGENTS.md/CLAUDE.md:新增注释规范、幂等性规范,移除测试要求
- 添加套餐系统升级完整文档(API文档、使用指南、功能总结、运维指南)
- 归档 OpenSpec package-system-upgrade 变更,同步 specs 到主目录
- 新增 queue types 抽象和 Redis 常量定义
2026-02-12 14:24:15 +08:00

973 lines
36 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Spec: 首次实名激活机制
## 业务背景
### 为什么需要首次实名激活机制
**现状问题**
- 运营商要求 IoT 卡必须实名认证后才能使用,但用户购买套餐时可能尚未实名
- 后台管理员需要为客户提前购买套餐(批量配置),但客户设备可能尚未实名
- 客户端购买套餐时强制实名会影响用户体验(需要先跳转实名流程再回来购买)
- 套餐立即生效但设备未实名会导致浪费(无法使用流量,有效期却在流失)
**业务目标**
- 后台管理端可以为未实名设备提前购买套餐(套餐待生效,等待实名激活)
- 客户端购买套餐必须先实名(确保用户可以立即使用)
- 设备首次实名时自动激活所有待生效套餐(无需手动操作)
- 支持灵活配置:部分套餐支持实名激活,部分套餐立即生效
---
## 业务规则
### 1. 购买前置检查规则
购买套餐时的实名检查规则:
```
后台管理端购买(/api/admin/orders
1. 不检查载体是否实名
2. 如果套餐 enable_realname_activation=true
- 创建 PackageUsage status=0待生效
- 设置 pending_realname_activation=true
3. 如果套餐 enable_realname_activation=false
- 创建 PackageUsage status=1生效中
- 立即激活,计算有效期
客户端购买(/api/h5/orders, /api/customer/orders
1. 必须检查载体是否实名
2. 如果未实名 → 返回错误 403"设备/卡必须先完成实名认证才能购买套餐"
3. 如果已实名 → 创建 PackageUsage status=1生效中立即激活
```
### 2. 首次实名判定规则
判断是否为"首次实名"的逻辑:
```
设备类型Device
- 查询该设备下所有 IoT 卡的实名状态
- 如果至少有1张卡已实名 → 不是首次实名
- 如果所有卡都未实名当前卡是第1张实名 → 是首次实名
单卡类型IotCard
- 查询该卡的实名状态
- 如果卡从未实名,本次实名成功 → 是首次实名
- 如果卡已实名(重新实名) → 不是首次实名
```
**实现方式**
-`Device` 模型中维护 `realname_status` 字段0-未实名, 1-已实名)
-`IotCard` 模型中维护 `realname_status` 字段
- 首次实名时更新对应模型的 `realname_status=1`
### 3. 激活触发规则
首次实名时触发套餐激活:
```
触发条件:
1. 载体首次实名成功realname_status 从 0 变为 1
2. 载体有待生效套餐status=0 AND pending_realname_activation=true
激活流程:
1. 实名成功后,入队 Asynq 任务 "realname_activation"
2. 任务 payload
{
"carrier_type": "device" | "iot_card",
"carrier_id": 123,
"realname_at": "2026-02-15T10:30:00Z"
}
3. Asynq Worker 处理任务:
- 查询该载体所有 pending_realname_activation=true 且 status=0 的套餐
- 批量更新 status=1, activated_at=realname_at
- 根据套餐 calendar_type 计算 expires_at
- 记录激活日志
```
### 4. 有效期计算规则
激活时根据 `calendar_type` 计算 `expires_at`
| calendar_type | 计算规则 | 示例 |
|---------------|---------|------|
| `natural_month` | `expires_at = 激活月份的最后一天 23:59:59` | 2026-02-15 激活 → 2026-02-28 23:59:59 |
| `by_day` | `expires_at = activated_at + duration_days 天 - 1秒` | 2026-02-15 10:30:00 激活30天 → 2026-03-16 23:59:59 |
**详细逻辑**见 `package-calendar-type/spec.md`
### 5. enable_realname_activation 配置规则
套餐是否支持实名激活:
| enable_realname_activation | 说明 | 后台购买行为 | 客户端购买行为 |
|---------------------------|------|-------------|---------------|
| `true` | 支持实名激活 | 未实名设备status=0等待激活<br>已实名设备status=1立即生效 | 必须实名status=1立即生效 |
| `false` | 立即生效 | 无论是否实名status=1立即生效 | 必须实名status=1立即生效 |
---
## ADDED Requirements
### Requirement: 支持未实名状态购买套餐
系统 SHALL 允许后台管理端为未实名的载体(设备/卡)购买套餐,套餐状态为"待生效"(status=0)。
#### Scenario: 后台为未实名设备购买套餐成功
- **GIVEN** 设备 ID=123realname_status=0未实名
- **AND** 套餐 enable_realname_activation=true
- **WHEN** 管理员通过 POST /api/admin/orders 为该设备购买套餐
- **THEN** 系统创建订单成功PackageUsage
- status=0待生效
- pending_realname_activation=true
- activated_at=NULL
- expires_at=NULL
#### Scenario: 后台为未实名设备购买不支持实名激活的套餐
- **GIVEN** 设备 ID=123realname_status=0未实名
- **AND** 套餐 enable_realname_activation=false
- **WHEN** 管理员通过 POST /api/admin/orders 为该设备购买套餐
- **THEN** 系统创建订单成功PackageUsage
- status=1生效中
- pending_realname_activation=false
- activated_at=订单支付时间
- expires_at=根据 calendar_type 计算
#### Scenario: 客户端未实名时购买套餐失败
- **GIVEN** 设备 ID=123realname_status=0未实名
- **WHEN** 客户通过 POST /api/h5/orders 为该设备购买套餐
- **THEN** 系统返回错误 403错误码 `REALNAME_REQUIRED`,错误消息:"设备/卡必须先完成实名认证才能购买套餐"
#### Scenario: 已实名设备购买套餐立即生效
- **GIVEN** 设备 ID=123realname_status=1已实名
- **AND** 套餐 enable_realname_activation=true
- **WHEN** 管理员或客户为该设备购买套餐
- **THEN** 系统创建订单成功PackageUsage
- status=1生效中
- pending_realname_activation=false
- activated_at=订单支付时间
- expires_at=根据 calendar_type 计算
#### Scenario: 后台批量购买套餐(部分未实名)
- **GIVEN** 设备Arealname_status=0设备Brealname_status=1
- **WHEN** 管理员批量为设备A和设备B购买套餐enable_realname_activation=true
- **THEN** 系统创建订单成功:
- 设备A套餐status=0pending_realname_activation=true
- 设备B套餐status=1pending_realname_activation=false
### Requirement: 首次实名时自动激活待生效套餐
系统 SHALL 在载体首次实名成功时,自动激活所有 pending_realname_activation=true 的待生效套餐。
#### Scenario: 设备首张卡实名触发套餐激活
- **GIVEN** 设备 ID=123realname_status=0有2个待生效套餐pending_realname_activation=true
- **AND** 该设备下所有 IoT 卡都未实名
- **WHEN** 设备的第1张卡在 2026-02-15 10:30:00 完成实名认证
- **THEN** 系统:
1. 更新设备 realname_status=1
2. 入队 Asynq 任务 "realname_activation"
3. 任务执行批量更新2个套餐 status=1activated_at=2026-02-15 10:30:00
4. 根据各套餐 calendar_type 计算 expires_at
#### Scenario: 设备后续卡实名不触发激活
- **GIVEN** 设备 ID=123realname_status=1已有1张卡实名
- **WHEN** 设备的第2张卡在 2026-02-20 10:00:00 完成实名认证
- **THEN** 系统不触发套餐激活,设备的套餐状态保持不变
#### Scenario: 单卡设备实名触发激活
- **GIVEN** IoT 卡 ICCID=123456realname_status=0有1个待生效套餐
- **WHEN** 该卡在 2026-02-15 10:30:00 完成实名认证
- **THEN** 系统:
1. 更新卡 realname_status=1
2. 入队 Asynq 任务 "realname_activation"
3. 任务执行:更新套餐 status=1activated_at=2026-02-15 10:30:00
#### Scenario: 激活时排除已生效的套餐
- **GIVEN** 设备 ID=123realname_status=0有2个套餐
- 套餐Astatus=0pending_realname_activation=true
- 套餐Bstatus=1已生效
- **WHEN** 设备在 2026-02-15 10:30:00 首次实名
- **THEN** 系统只激活套餐A套餐B 保持不变
#### Scenario: 无待激活套餐时不执行激活逻辑
- **GIVEN** 设备 ID=123realname_status=0无任何套餐
- **WHEN** 设备在 2026-02-15 10:30:00 首次实名
- **THEN** 系统入队 Asynq 任务,任务执行后发现无待激活套餐,直接返回
### Requirement: 激活时根据套餐类型计算有效期
系统 SHALL 在首次实名激活套餐时,根据套餐的 calendar_type 计算 expires_at。
#### Scenario: 实名激活自然月套餐
- **GIVEN** 套餐 calendar_type=natural_monthduration_months=1
- **WHEN** 2026-02-15 10:30:00 首次实名激活
- **THEN** 系统计算:
- activated_at=2026-02-15 10:30:00
- expires_at=2026-02-28 23:59:59当月最后一天
#### Scenario: 实名激活按天套餐
- **GIVEN** 套餐 calendar_type=by_dayduration_days=30
- **WHEN** 2026-02-15 10:30:00 首次实名激活
- **THEN** 系统计算:
- activated_at=2026-02-15 10:30:00
- expires_at=2026-03-16 23:59:59+30天-1秒
#### Scenario: 实名激活跨年自然月套餐
- **GIVEN** 套餐 calendar_type=natural_monthduration_months=2
- **WHEN** 2026-12-15 10:30:00 首次实名激活
- **THEN** 系统计算:
- activated_at=2026-12-15 10:30:00
- expires_at=2027-01-31 23:59:59跨年到次年1月最后一天
#### Scenario: 激活时有效期计算失败
- **GIVEN** 套餐 calendar_type=natural_monthduration_months=NULL数据异常
- **WHEN** 首次实名激活
- **THEN** 系统:
1. 激活失败,套餐 status 保持 0
2. 记录 Error 日志包含套餐ID、载体信息、错误原因
3. Asynq 重试最多3次
### Requirement: 支持配置是否启用实名激活
系统 SHALL 在套餐模型中提供 enable_realname_activation 字段,允许管理员配置是否需要实名激活。
#### Scenario: 创建需要实名激活的套餐
- **WHEN** 管理员创建套餐时指定 enable_realname_activation=true
- **THEN** 系统创建成功,该套餐:
- 后台购买未实名设备status=0等待激活
- 后台购买已实名设备status=1立即生效
- 客户端购买必须实名status=1立即生效
#### Scenario: 创建立即生效的套餐
- **WHEN** 管理员创建套餐时指定 enable_realname_activation=false
- **THEN** 系统创建成功,该套餐:
- 无论后台还是客户端购买status=1立即生效
- 不需要等待实名激活
#### Scenario: 更新套餐的实名激活配置
- **GIVEN** 套餐 ID=123enable_realname_activation=false
- **WHEN** 管理员更新套餐配置为 enable_realname_activation=true
- **THEN** 系统更新成功,该套餐后续购买行为遵循新配置
- **AND** 已有的 PackageUsage 不受影响
### Requirement: 实名激活异步处理
系统 SHALL 通过 Asynq 异步任务处理首次实名激活逻辑,避免阻塞实名认证流程。
#### Scenario: 实名成功后入队激活任务
- **GIVEN** 设备 ID=123 首次实名成功
- **WHEN** 系统更新设备 realname_status=1
- **THEN** 系统入队 Asynq 任务:
- task_type="realname_activation"
- payload={"carrier_type": "device", "carrier_id": 123, "realname_at": "2026-02-15T10:30:00Z"}
- queue="default"
- max_retry=3
#### Scenario: 激活任务在1分钟内完成
- **GIVEN** Asynq 任务 "realname_activation" 从队列取出
- **WHEN** Worker 执行任务
- **THEN** 系统在1分钟内完成套餐激活更新 PackageUsage 状态
- **AND** 任务标记为成功,从队列移除
#### Scenario: 激活任务失败后重试
- **GIVEN** Asynq 任务 "realname_activation" 执行时数据库连接失败
- **WHEN** 任务执行失败
- **THEN** 系统:
1. 记录 Error 日志包含载体ID、错误信息
2. Asynq 自动重试(间隔 10s/30s/60s
3. 3次失败后写入死信队列发送告警
#### Scenario: 激活任务幂等性
- **GIVEN** Asynq 任务 "realname_activation" 因网络波动重复执行
- **WHEN** Worker 第2次执行同一任务
- **THEN** 系统检查套餐 status
- 如果已是 status=1 → 跳过激活,直接返回成功
- 如果仍是 status=0 → 执行激活逻辑
---
## 边界条件
### 1. 并发首次实名
- **场景**设备的2张卡同时完成实名认证并发请求
- **处理**
- 使用数据库行锁:`SELECT * FROM device WHERE id=? FOR UPDATE`
- 第1个请求更新 realname_status=1触发激活
- 第2个请求发现 realname_status=1不触发激活
### 2. 激活任务部分失败
- **场景**设备有3个待激活套餐激活第2个时失败
- **处理**
- 使用事务:全部激活成功才提交
- 失败时回滚3个套餐保持 status=0
- Asynq 重试重新激活全部3个套餐
### 3. 实名时无待激活套餐
- **场景**:设备首次实名时,无任何套餐
- **处理**
- 仍然入队 Asynq 任务
- 任务执行时查询套餐数量=0直接返回成功
- 不记录错误日志
### 4. 套餐购买和实名并发
- **场景**:设备购买套餐的同时,完成首次实名
- **处理**
- 购买订单时检查 realname_status
- 如果未实名 → status=0pending_realname_activation=true
- 如果已实名 → status=1立即生效
- 实名激活任务执行时,再次检查套餐状态,只激活 status=0 的套餐
### 5. 有效期计算异常
- **场景**:套餐 calendar_type 或 duration_days/duration_months 为 NULL
- **处理**
- 激活失败,返回错误 500
- 记录 Error 日志包含套餐ID、载体ID、错误原因
- Asynq 重试最多3次
- 3次失败后写入死信队列发送告警
---
## 并发场景
### Scenario: 并发首次实名
- **GIVEN** 设备 ID=123realname_status=0有2张卡
- **WHEN** 两张卡同时在 2026-02-15 10:30:00 完成实名认证
- **THEN** 系统使用行锁:
```sql
SELECT * FROM device WHERE id=123 FOR UPDATE
```
- **AND** 第1个请求
- 更新 realname_status=1
- 入队 Asynq 任务
- **AND** 第2个请求
- 发现 realname_status=1
- 不入队任务
### Scenario: 并发购买套餐和首次实名
- **GIVEN** 设备 ID=123realname_status=0
- **WHEN** 同时发生:
- 请求1管理员购买套餐enable_realname_activation=true
- 请求2设备完成首次实名
- **THEN** 使用事务隔离:
- 如果请求1先完成 → 套餐 status=0然后被请求2激活
- 如果请求2先完成 → 设备 realname_status=1请求1创建套餐时 status=1立即生效
### Scenario: 并发激活任务(重复入队)
- **GIVEN** Asynq 任务 "realname_activation" 因网络抖动重复入队
- **WHEN** Worker 同时处理2个相同任务
- **THEN** 系统使用行锁:
```sql
SELECT * FROM package_usage WHERE carrier_id=? AND status=0 FOR UPDATE
```
- **AND** 第1个任务激活成功套餐 status=1
- **AND** 第2个任务发现 status=1跳过激活
---
## 异常处理
### 1. 激活任务失败
- **错误场景**Asynq 任务执行时数据库连接失败
- **处理流程**
1. 捕获错误,记录 Error 日志包含载体ID、错误信息
2. Asynq 自动重试最多3次间隔 10s/30s/60s
3. 重试前检查套餐 status避免重复激活
4. 3次失败后写入死信队列发送告警通知
- **返回错误**:不返回给用户(异步任务),仅记录日志
### 2. 有效期计算失败
- **错误场景**:套餐 calendar_type 或 duration_days 数据异常
- **处理流程**
1. 激活失败,套餐 status 保持 0
2. 记录 Error 日志包含套餐ID、载体ID、calendar_type、duration_days
3. Asynq 重试最多3次
4. 3次失败后写入死信队列发送告警
- **返回错误**:不返回给用户(异步任务),仅记录日志
### 3. 批量激活部分失败
- **错误场景**设备有3个待激活套餐激活第2个时失败
- **处理流程**
1. 使用事务包裹批量更新
2. 任何一个套餐激活失败 → 事务回滚,全部套餐保持 status=0
3. 记录 Error 日志包含设备ID、失败套餐ID、错误原因
4. Asynq 重试,重新激活全部套餐
- **返回错误**:不返回给用户(异步任务),仅记录日志
### 4. 首次实名判定失败
- **错误场景**:查询设备的 IoT 卡列表时超时
- **处理流程**
1. 实名认证流程继续(不阻塞)
2. Asynq 任务入队
3. 任务执行时再次尝试查询,失败则重试
4. 3次失败后写入死信队列
- **返回错误**:实名认证返回成功,激活任务在后台处理
---
## 数据一致性保证
### 1. 事务边界
- **首次实名 + 入队任务**:更新 realname_status 后再入队(确保任务执行时状态已更新)
- **批量激活套餐**:使用单个事务,全部成功或全部失败
- **并发首次实名检查**:使用 `SELECT FOR UPDATE` 行锁
### 2. 行锁机制
- **首次实名检查**`SELECT * FROM device WHERE id=? FOR UPDATE`
- **批量激活套餐**`SELECT * FROM package_usage WHERE carrier_id=? AND status=0 FOR UPDATE`
### 3. 幂等性保证
#### 使用 first_realname_at 字段确保首次实名幂等
系统使用 `tb_iot_card.first_realname_at` 字段(时间戳)确保首次实名激活只执行一次:
**数据库字段**
```sql
-- tb_iot_card 新增字段
ALTER TABLE tb_iot_card
ADD COLUMN first_realname_at TIMESTAMP NULL COMMENT '首次实名时间NULL=未实名非NULL=已实名(幂等标记)';
```
**幂等更新**
```sql
-- 首次实名触发时(原子操作)
UPDATE tb_iot_card
SET first_realname_at = NOW()
WHERE id = ? AND first_realname_at IS NULL;
-- 通过影响行数判断是否首次实名
-- rows_affected = 1 → 首次实名,执行激活逻辑
-- rows_affected = 0 → 已处理,跳过
```
**优势**
- **比 realname_status 更可靠**:状态字段可能被重置,时间戳不可逆
- **可追溯首次实名时间**:便于审计和问题排查
- **数据库层面保证唯一更新**WHERE 条件确保只有首次实名时更新成功
- **无需 Redis 锁**:数据库行级锁已足够,减少依赖
**实现示例**
```go
// Service 层:检查并标记首次实名
func (s *Service) MarkFirstRealname(ctx context.Context, cardID uint) (bool, error) {
result := s.db.WithContext(ctx).
Model(&model.IotCard{}).
Where("id = ? AND first_realname_at IS NULL", cardID).
Update("first_realname_at", time.Now())
if result.Error != nil {
return false, errors.Wrap(errors.CodeInternal, result.Error, "更新首次实名时间失败")
}
// 影响行数 = 1 表示首次实名
isFirstRealname := result.RowsAffected == 1
return isFirstRealname, nil
}
// 轮询系统:检测实名状态变更时
func (h *Handler) HandleRealnameCheck(ctx context.Context, task *asynq.Task) error {
// 1. 检测到卡实名状态变更realname_status: 0 → 2
// ...
// 2. 尝试标记首次实名
isFirstRealname, err := h.iotCardService.MarkFirstRealname(ctx, cardID)
if err != nil {
return err
}
// 3. 只有首次实名时才触发套餐激活
if isFirstRealname {
err := h.queueClient.Enqueue(TaskTypePackageFirstActivation, payload)
if err != nil {
return err
}
}
return nil
}
```
- **激活任务幂等**:执行前检查套餐 status如果已激活则跳过
- **实名状态幂等**:重复实名不触发激活(通过 first_realname_at 字段保证)
### 4. 数据校验
- **购买套餐前**:校验 enable_realname_activation 与 realname_status 的一致性
- **激活套餐前**:校验 calendar_type 和 duration_days/duration_months 是否有效
- **首次实名判定**:校验设备的 IoT 卡列表是否完整
---
## 性能指标
| 操作 | 目标响应时间 | 并发要求 | 数据量 |
|------|-------------|---------|--------|
| 后台购买套餐(实名检查) | < 50ms | 100 QPS | 单载体查询 |
| 客户端购买套餐(实名检查) | < 100ms | 200 QPS | 单载体查询 |
| 首次实名入队任务 | < 50ms | 100 QPS | 入队操作 |
| 激活任务执行(批量激活) | < 1000ms | 50 QPS | 批量更新平均5个套餐 |
---
## 错误码定义
| 错误码 | HTTP 状态码 | 错误消息 | 场景 |
|--------|------------|---------|------|
| `REALNAME_REQUIRED` | 403 | 设备/卡必须先完成实名认证才能购买套餐 | 客户端购买套餐时未实名 |
| `ACTIVATION_FAILED` | 500 | 套餐激活失败,请稍后重试 | 激活任务执行失败 |
| `EXPIRY_CALCULATION_FAILED` | 500 | 有效期计算失败,请联系管理员 | calendar_type 或 duration 数据异常 |
| `REALNAME_STATUS_UPDATE_FAILED` | 500 | 实名状态更新失败,请稍后重试 | 更新 realname_status 失败 |
---
## 数据迁移策略
**激进策略**(开发阶段,保证干净性):
### 1. ❌ 要删除的字段
目前 `package_usage` 表中可能存在的冗余字段(需确认后删除):
- 如果有 `realname_activated` 字段(旧的实名激活标志) → **删除**
- 如果有 `wait_realname` 字段(旧的等待实名标志) → **删除**
目前 `package` 表中可能存在的冗余字段(需确认后删除):
- 如果有 `require_realname` 字段(旧的实名要求标志) → **删除**
目前 `device` 和 `iot_card` 表中可能存在的冗余字段(需确认后删除):
- 如果有 `is_realname` 字段(旧的实名标志) → **删除**,统一使用 `realname_status`
### 2. ✅ 新增的字段
在 `package_usage` 表中新增:
```sql
ALTER TABLE package_usage
ADD COLUMN pending_realname_activation BOOLEAN DEFAULT false COMMENT '是否等待实名激活';
CREATE INDEX idx_pending_realname_activation ON package_usage(carrier_id, pending_realname_activation, status);
```
在 `package` 表中新增:
```sql
ALTER TABLE package
ADD COLUMN enable_realname_activation BOOLEAN DEFAULT false COMMENT '是否启用实名激活机制true=支持未实名购买并等待激活false=立即生效)';
```
在 `device` 表中新增(如果不存在):
```sql
ALTER TABLE device
ADD COLUMN realname_status TINYINT DEFAULT 0 COMMENT '实名状态0-未实名1-已实名)';
CREATE INDEX idx_realname_status ON device(realname_status);
```
在 `iot_card` 表中新增(如果不存在):
```sql
ALTER TABLE iot_card
ADD COLUMN realname_status TINYINT DEFAULT 0 COMMENT '实名状态0-未实名1-已实名)';
CREATE INDEX idx_realname_status ON iot_card(realname_status);
```
### 3. ❌ 要废弃的逻辑
- **废弃旧的实名检查逻辑**:如果代码中存在通过 `is_realname` 或 `require_realname` 字段检查实名的逻辑,全部删除
- **废弃旧的激活逻辑**:如果代码中存在手动激活套餐的逻辑(非首次实名触发),全部删除
- **废弃旧的实名状态字段**:统一使用 `realname_status`0/1删除其他相关字段
### 4. ✅ 历史数据强制转换
```sql
-- Step 1: 历史设备/卡的实名状态初始化
-- 根据实际业务规则确定历史数据的实名状态(假设有 realname_info 字段)
UPDATE device
SET realname_status = CASE
WHEN realname_info IS NOT NULL AND realname_info != '' THEN 1
ELSE 0
END
WHERE realname_status IS NULL;
UPDATE iot_card
SET realname_status = CASE
WHEN realname_info IS NOT NULL AND realname_info != '' THEN 1
ELSE 0
END
WHERE realname_status IS NULL;
-- Step 2: 历史套餐的实名激活配置初始化
-- 假设历史套餐默认不启用实名激活(立即生效)
UPDATE package
SET enable_realname_activation = false
WHERE enable_realname_activation IS NULL;
-- Step 3: 历史 PackageUsage 的 pending_realname_activation 初始化
-- 已生效的套餐pending_realname_activation=false
UPDATE package_usage
SET pending_realname_activation = false
WHERE status IN (1, 2, 3, 4) -- 生效中、已用完、已过期、已失效
AND pending_realname_activation IS NULL;
-- 待生效的套餐:根据载体实名状态判断
-- 如果载体未实名 → pending_realname_activation=true
-- 如果载体已实名 → 强制激活套餐status=1
-- 注意:需要根据 carrier_type 判断是 device 还是 iot_card
UPDATE package_usage pu
SET pending_realname_activation = true
WHERE pu.status = 0
AND pu.pending_realname_activation IS NULL
AND EXISTS (
SELECT 1 FROM device d
WHERE d.id = pu.carrier_id
AND pu.carrier_type = 'device'
AND d.realname_status = 0
);
UPDATE package_usage pu
SET pending_realname_activation = true
WHERE pu.status = 0
AND pu.pending_realname_activation IS NULL
AND EXISTS (
SELECT 1 FROM iot_card ic
WHERE ic.id = pu.carrier_id
AND pu.carrier_type = 'iot_card'
AND ic.realname_status = 0
);
-- Step 4: 已实名但待生效的套餐强制激活
-- (这些套餐应该在购买时就激活,现在补上)
UPDATE package_usage pu
SET status = 1,
activated_at = pu.created_at, -- 假设使用创建时间作为激活时间
pending_realname_activation = false
WHERE pu.status = 0
AND EXISTS (
SELECT 1 FROM device d
WHERE d.id = pu.carrier_id
AND pu.carrier_type = 'device'
AND d.realname_status = 1
);
UPDATE package_usage pu
SET status = 1,
activated_at = pu.created_at,
pending_realname_activation = false
WHERE pu.status = 0
AND EXISTS (
SELECT 1 FROM iot_card ic
WHERE ic.id = pu.carrier_id
AND pu.carrier_type = 'iot_card'
AND ic.realname_status = 1
);
-- 注意Step 4 强制激活的套餐需要重新计算 expires_at
-- 建议编写数据修复脚本,调用有效期计算逻辑
```
### 5. ❌ 删除遗留表/字段(确认后执行)
```sql
-- 如果存在旧的实名相关字段,删除
-- ALTER TABLE package_usage DROP COLUMN IF EXISTS realname_activated;
-- ALTER TABLE package_usage DROP COLUMN IF EXISTS wait_realname;
-- ALTER TABLE package DROP COLUMN IF EXISTS require_realname;
-- ALTER TABLE device DROP COLUMN IF EXISTS is_realname;
-- ALTER TABLE iot_card DROP COLUMN IF EXISTS is_realname;
```
### 6. 验证步骤
```sql
-- 验证1所有设备和卡都有 realname_status
SELECT COUNT(*)
FROM device
WHERE realname_status IS NULL;
-- 预期结果0
SELECT COUNT(*)
FROM iot_card
WHERE realname_status IS NULL;
-- 预期结果0
-- 验证2所有套餐都有 enable_realname_activation
SELECT COUNT(*)
FROM package
WHERE enable_realname_activation IS NULL;
-- 预期结果0
-- 验证3所有 PackageUsage 都有 pending_realname_activation
SELECT COUNT(*)
FROM package_usage
WHERE pending_realname_activation IS NULL;
-- 预期结果0
-- 验证4待生效套餐的载体必须未实名或有 pending_realname_activation=true
SELECT COUNT(*)
FROM package_usage pu
JOIN device d ON pu.carrier_id = d.id AND pu.carrier_type = 'device'
WHERE pu.status = 0
AND pu.pending_realname_activation = false
AND d.realname_status = 0;
-- 预期结果0不应该有未实名但又不等待激活的待生效套餐
-- 验证5已实名载体的套餐不应该待生效除非后续购买
-- (这个验证需要根据实际业务规则调整)
SELECT COUNT(*)
FROM package_usage pu
JOIN device d ON pu.carrier_id = d.id AND pu.carrier_type = 'device'
WHERE pu.status = 0
AND pu.pending_realname_activation = true
AND d.realname_status = 1;
-- 预期结果0已实名设备不应该有等待激活的套餐
-- 验证6检查是否还有遗留字段需根据实际情况调整
-- SELECT column_name FROM information_schema.columns
-- WHERE table_name = 'package_usage'
-- AND column_name IN ('realname_activated', 'wait_realname');
-- 预期结果0 rows
```
---
## 测试场景矩阵
| 场景分类 | 测试用例 | 预期结果 |
|---------|---------|---------|
| **购买套餐** | 后台购买套餐未实名设备enable_realname_activation=true | status=0pending_realname_activation=true |
| | 后台购买套餐未实名设备enable_realname_activation=false | status=1立即生效 |
| | 后台购买套餐(已实名设备) | status=1立即生效 |
| | 客户端购买套餐(未实名设备) | 返回错误 403REALNAME_REQUIRED |
| | 客户端购买套餐(已实名设备) | status=1立即生效 |
| **首次实名** | 设备首张卡实名 | 触发激活,套餐 status=1 |
| | 设备后续卡实名 | 不触发激活,套餐状态不变 |
| | 单卡设备实名 | 触发激活,套餐 status=1 |
| | 并发首次实名 | 使用行锁只触发1次激活 |
| **激活逻辑** | 激活自然月套餐 | expires_at=当月最后一天 23:59:59 |
| | 激活按天套餐 | expires_at=activated_at+duration_days-1秒 |
| | 激活时无待激活套餐 | 任务直接返回成功,不报错 |
| | 激活时排除已生效套餐 | 只激活 status=0 的套餐 |
| **异步任务** | 实名成功后入队任务 | 任务入队成功payload 包含载体信息 |
| | 激活任务在1分钟内完成 | 批量更新成功,任务标记为完成 |
| | 激活任务失败后重试 | Asynq 重试3次失败后进入死信队列 |
| | 激活任务幂等性 | 重复执行时检查状态,跳过已激活套餐 |
| **并发** | 并发购买套餐和首次实名 | 事务隔离,先完成的操作生效 |
| | 并发激活任务 | 使用行锁,避免重复激活 |
| **异常** | 有效期计算失败 | 激活失败记录日志Asynq 重试 |
| | 批量激活部分失败 | 事务回滚,全部套餐保持 status=0 |
| | 首次实名判定失败 | 不阻塞实名流程,任务重试 |
---
## 实现参考
### 购买套餐时的实名检查
```go
// Service 层CreateOrder
func (s *Service) CreateOrder(ctx context.Context, req *CreateOrderRequest) error {
// 1. 检查载体实名状态
realnameStatus, err := s.getCarrierRealnameStatus(ctx, req.CarrierType, req.CarrierID)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询实名状态失败")
}
// 2. 客户端购买必须实名
requestSource := middleware.GetRequestSourceFromContext(ctx) // "admin" or "customer"
if requestSource == "customer" && realnameStatus == 0 {
return errors.New(errors.CodeForbidden, "设备/卡必须先完成实名认证才能购买套餐")
}
// 3. 查询套餐配置
pkg, err := s.packageStore.GetByID(ctx, req.PackageID)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询套餐失败")
}
// 4. 确定套餐状态
var status int
var pendingRealnameActivation bool
if pkg.EnableRealnameActivation && realnameStatus == 0 && requestSource == "admin" {
// 后台购买未实名设备的实名激活套餐 → 待生效
status = constants.PackageStatusPending
pendingRealnameActivation = true
} else {
// 其他情况 → 立即生效
status = constants.PackageStatusActive
pendingRealnameActivation = false
}
// 5. 创建 PackageUsage
usage := &model.PackageUsage{
CarrierType: req.CarrierType,
CarrierID: req.CarrierID,
PackageID: req.PackageID,
Status: status,
PendingRealnameActivation: pendingRealnameActivation,
}
if status == constants.PackageStatusActive {
// 立即生效:计算 activated_at 和 expires_at
usage.ActivatedAt = time.Now()
usage.ExpiresAt = s.calculateExpiresAt(usage.ActivatedAt, pkg.CalendarType, pkg.DurationDays, pkg.DurationMonths)
}
if err := s.packageUsageStore.Create(ctx, usage); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建套餐使用记录失败")
}
return nil
}
```
### 首次实名时入队激活任务
```go
// Service 层HandleRealnameSuccess
func (s *Service) HandleRealnameSuccess(ctx context.Context, carrierType string, carrierID uint) error {
// 1. 检查是否为首次实名
isFirstRealname, err := s.checkFirstRealname(ctx, carrierType, carrierID)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "检查首次实名失败")
}
if !isFirstRealname {
s.logger.Info("非首次实名,跳过激活",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
return nil
}
// 2. 更新实名状态
if err := s.updateRealnameStatus(ctx, carrierType, carrierID, 1); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新实名状态失败")
}
// 3. 入队激活任务
payload := map[string]interface{}{
"carrier_type": carrierType,
"carrier_id": carrierID,
"realname_at": time.Now().Format(time.RFC3339),
}
if err := s.asynqClient.Enqueue("realname_activation", payload); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "入队激活任务失败")
}
s.logger.Info("首次实名成功,已入队激活任务",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
return nil
}
// Service 层checkFirstRealname
func (s *Service) checkFirstRealname(ctx context.Context, carrierType string, carrierID uint) (bool, error) {
if carrierType == "device" {
// 查询设备当前实名状态
device, err := s.deviceStore.GetByID(ctx, carrierID)
if err != nil {
return false, err
}
return device.RealnameStatus == 0, nil // 0=未实名,首次实名
} else if carrierType == "iot_card" {
// 查询卡当前实名状态
card, err := s.iotCardStore.GetByICCID(ctx, carrierID)
if err != nil {
return false, err
}
return card.RealnameStatus == 0, nil
}
return false, fmt.Errorf("unsupported carrier_type: %s", carrierType)
}
```
### Asynq Worker 处理激活任务
```go
// Handler: HandleRealnameActivation
func (h *RealnameActivationHandler) HandleRealnameActivation(ctx context.Context, task *asynq.Task) error {
var payload struct {
CarrierType string `json:"carrier_type"`
CarrierID uint `json:"carrier_id"`
RealnameAt string `json:"realname_at"`
}
if err := json.Unmarshal(task.Payload(), &payload); err != nil {
return fmt.Errorf("unmarshal payload failed: %w", err)
}
realnameAt, _ := time.Parse(time.RFC3339, payload.RealnameAt)
// 1. 查询待激活套餐
usages, err := h.packageUsageStore.ListPendingRealnameActivation(ctx, payload.CarrierType, payload.CarrierID)
if err != nil {
return fmt.Errorf("list pending activation failed: %w", err)
}
if len(usages) == 0 {
h.logger.Info("无待激活套餐,任务完成",
zap.String("carrier_type", payload.CarrierType),
zap.Uint("carrier_id", payload.CarrierID))
return nil
}
// 2. 批量激活(使用事务)
tx := h.db.Begin()
defer tx.Rollback()
for _, usage := range usages {
// 获取套餐配置
pkg, err := h.packageStore.GetByID(ctx, usage.PackageID)
if err != nil {
return fmt.Errorf("get package failed: %w", err)
}
// 计算有效期
expiresAt := h.calculateExpiresAt(realnameAt, pkg.CalendarType, pkg.DurationDays, pkg.DurationMonths)
// 更新套餐状态
if err := tx.Model(&usage).Updates(map[string]interface{}{
"status": constants.PackageStatusActive,
"activated_at": realnameAt,
"expires_at": expiresAt,
"pending_realname_activation": false,
}).Error; err != nil {
return fmt.Errorf("activate package failed: %w", err)
}
}
if err := tx.Commit().Error; err != nil {
return fmt.Errorf("commit transaction failed: %w", err)
}
h.logger.Info("套餐激活成功",
zap.String("carrier_type", payload.CarrierType),
zap.Uint("carrier_id", payload.CarrierID),
zap.Int("count", len(usages)))
return nil
}
```
---
**本 Spec 完成**,包含:
- ✅ 业务背景和业务规则
- ✅ 详细场景(购买套餐、首次实名、激活逻辑、异步任务)
- ✅ 边界条件和并发场景
- ✅ 异常处理和数据一致性保证
- ✅ 性能指标和错误码定义
-**激进的数据迁移策略**(明确删除字段、废弃逻辑、强制转换)
- ✅ 测试场景矩阵和实现参考