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

36 KiB
Raw Blame History

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等待激活
已实名设备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 系统使用行锁:
    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 系统使用行锁:
    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 字段(时间戳)确保首次实名激活只执行一次:

数据库字段

-- tb_iot_card 新增字段
ALTER TABLE tb_iot_card
ADD COLUMN first_realname_at TIMESTAMP NULL COMMENT '首次实名时间NULL=未实名非NULL=已实名(幂等标记)';

幂等更新

-- 首次实名触发时(原子操作)
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 锁:数据库行级锁已足够,减少依赖

实现示例

// 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 字段(旧的实名要求标志) → 删除

目前 deviceiot_card 表中可能存在的冗余字段(需确认后删除):

  • 如果有 is_realname 字段(旧的实名标志) → 删除,统一使用 realname_status

2. 新增的字段

package_usage 表中新增:

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 表中新增:

ALTER TABLE package
ADD COLUMN enable_realname_activation BOOLEAN DEFAULT false COMMENT '是否启用实名激活机制true=支持未实名购买并等待激活false=立即生效)';

device 表中新增(如果不存在):

ALTER TABLE device
ADD COLUMN realname_status TINYINT DEFAULT 0 COMMENT '实名状态0-未实名1-已实名)';

CREATE INDEX idx_realname_status ON device(realname_status);

iot_card 表中新增(如果不存在):

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_realnamerequire_realname 字段检查实名的逻辑,全部删除
  • 废弃旧的激活逻辑:如果代码中存在手动激活套餐的逻辑(非首次实名触发),全部删除
  • 废弃旧的实名状态字段:统一使用 realname_status0/1删除其他相关字段

4. 历史数据强制转换

-- 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. 删除遗留表/字段(确认后执行)

-- 如果存在旧的实名相关字段,删除
-- 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. 验证步骤

-- 验证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
首次实名判定失败 不阻塞实名流程,任务重试

实现参考

购买套餐时的实名检查

// 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
}

首次实名时入队激活任务

// 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 处理激活任务

// 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 完成,包含:

  • 业务背景和业务规则
  • 详细场景(购买套餐、首次实名、激活逻辑、异步任务)
  • 边界条件和并发场景
  • 异常处理和数据一致性保证
  • 性能指标和错误码定义
  • 激进的数据迁移策略(明确删除字段、废弃逻辑、强制转换)
  • 测试场景矩阵和实现参考