All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m54s
- 重构 Worker 启动流程,引入 bootstrap 模块统一管理依赖注入 - 实现套餐流量重置服务(日/月/年周期重置) - 新增套餐激活排队、加油包绑定、囤货待实名激活逻辑 - 新增订单创建幂等性防重(Redis 业务键 + 分布式锁) - 更新 AGENTS.md/CLAUDE.md:新增注释规范、幂等性规范,移除测试要求 - 添加套餐系统升级完整文档(API文档、使用指南、功能总结、运维指南) - 归档 OpenSpec package-system-upgrade 变更,同步 specs 到主目录 - 新增 queue types 抽象和 Redis 常量定义
973 lines
36 KiB
Markdown
973 lines
36 KiB
Markdown
# 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=123,realname_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=123,realname_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=123,realname_status=0(未实名)
|
||
- **WHEN** 客户通过 POST /api/h5/orders 为该设备购买套餐
|
||
- **THEN** 系统返回错误 403,错误码 `REALNAME_REQUIRED`,错误消息:"设备/卡必须先完成实名认证才能购买套餐"
|
||
|
||
#### Scenario: 已实名设备购买套餐立即生效
|
||
- **GIVEN** 设备 ID=123,realname_status=1(已实名)
|
||
- **AND** 套餐 enable_realname_activation=true
|
||
- **WHEN** 管理员或客户为该设备购买套餐
|
||
- **THEN** 系统创建订单成功,PackageUsage:
|
||
- status=1(生效中)
|
||
- pending_realname_activation=false
|
||
- activated_at=订单支付时间
|
||
- expires_at=根据 calendar_type 计算
|
||
|
||
#### Scenario: 后台批量购买套餐(部分未实名)
|
||
- **GIVEN** 设备A(realname_status=0),设备B(realname_status=1)
|
||
- **WHEN** 管理员批量为设备A和设备B购买套餐(enable_realname_activation=true)
|
||
- **THEN** 系统创建订单成功:
|
||
- 设备A套餐:status=0,pending_realname_activation=true
|
||
- 设备B套餐:status=1,pending_realname_activation=false
|
||
|
||
### Requirement: 首次实名时自动激活待生效套餐
|
||
|
||
系统 SHALL 在载体首次实名成功时,自动激活所有 pending_realname_activation=true 的待生效套餐。
|
||
|
||
#### Scenario: 设备首张卡实名触发套餐激活
|
||
- **GIVEN** 设备 ID=123,realname_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=1,activated_at=2026-02-15 10:30:00
|
||
4. 根据各套餐 calendar_type 计算 expires_at
|
||
|
||
#### Scenario: 设备后续卡实名不触发激活
|
||
- **GIVEN** 设备 ID=123,realname_status=1(已有1张卡实名)
|
||
- **WHEN** 设备的第2张卡在 2026-02-20 10:00:00 完成实名认证
|
||
- **THEN** 系统不触发套餐激活,设备的套餐状态保持不变
|
||
|
||
#### Scenario: 单卡设备实名触发激活
|
||
- **GIVEN** IoT 卡 ICCID=123456,realname_status=0,有1个待生效套餐
|
||
- **WHEN** 该卡在 2026-02-15 10:30:00 完成实名认证
|
||
- **THEN** 系统:
|
||
1. 更新卡 realname_status=1
|
||
2. 入队 Asynq 任务 "realname_activation"
|
||
3. 任务执行:更新套餐 status=1,activated_at=2026-02-15 10:30:00
|
||
|
||
#### Scenario: 激活时排除已生效的套餐
|
||
- **GIVEN** 设备 ID=123,realname_status=0,有2个套餐:
|
||
- 套餐A:status=0,pending_realname_activation=true
|
||
- 套餐B:status=1(已生效)
|
||
- **WHEN** 设备在 2026-02-15 10:30:00 首次实名
|
||
- **THEN** 系统只激活套餐A,套餐B 保持不变
|
||
|
||
#### Scenario: 无待激活套餐时不执行激活逻辑
|
||
- **GIVEN** 设备 ID=123,realname_status=0,无任何套餐
|
||
- **WHEN** 设备在 2026-02-15 10:30:00 首次实名
|
||
- **THEN** 系统入队 Asynq 任务,任务执行后发现无待激活套餐,直接返回
|
||
|
||
### Requirement: 激活时根据套餐类型计算有效期
|
||
|
||
系统 SHALL 在首次实名激活套餐时,根据套餐的 calendar_type 计算 expires_at。
|
||
|
||
#### Scenario: 实名激活自然月套餐
|
||
- **GIVEN** 套餐 calendar_type=natural_month,duration_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_day,duration_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_month,duration_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_month,duration_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=123,enable_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=0,pending_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=123,realname_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=123,realname_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=0,pending_realname_activation=true |
|
||
| | 后台购买套餐(未实名设备,enable_realname_activation=false) | status=1,立即生效 |
|
||
| | 后台购买套餐(已实名设备) | status=1,立即生效 |
|
||
| | 客户端购买套餐(未实名设备) | 返回错误 403:REALNAME_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 完成**,包含:
|
||
- ✅ 业务背景和业务规则
|
||
- ✅ 详细场景(购买套餐、首次实名、激活逻辑、异步任务)
|
||
- ✅ 边界条件和并发场景
|
||
- ✅ 异常处理和数据一致性保证
|
||
- ✅ 性能指标和错误码定义
|
||
- ✅ **激进的数据迁移策略**(明确删除字段、废弃逻辑、强制转换)
|
||
- ✅ 测试场景矩阵和实现参考
|