Files
junhong_cmp_fiber/openspec/changes/package-system-upgrade/specs/package-usage-priority/spec.md
huang 353621d923
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m33s
移除所有测试代码和测试要求
**变更说明**:
- 删除所有 *_test.go 文件(单元测试、集成测试、验收测试、流程测试)
- 删除整个 tests/ 目录
- 更新 CLAUDE.md:用"测试禁令"章节替换所有测试要求
- 删除测试生成 Skill (openspec-generate-acceptance-tests)
- 删除测试生成命令 (opsx:gen-tests)
- 更新 tasks.md:删除所有测试相关任务

**新规范**:
-  禁止编写任何形式的自动化测试
-  禁止创建 *_test.go 文件
-  禁止在任务中包含测试相关工作
-  仅当用户明确要求时才编写测试

**原因**:
业务系统的正确性通过人工验证和生产环境监控保证,测试代码维护成本高于价值。

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-11 17:13:42 +08:00

18 KiB
Raw Blame History

Spec: 流量扣减优先级机制

业务背景

现有套餐系统在流量扣减时不区分主套餐和加油包,导致:

  1. 用户体验差:用户购买加油包后,主套餐仍在扣减,加油包未生效
  2. 停机逻辑错误:主套餐流量用完即停机,加油包剩余流量浪费
  3. 流量统计混乱:多套餐同时扣减,无法追溯流量消耗路径

本规范引入流量扣减优先级机制,确保:

  • 加油包优先扣减:购买加油包后,优先消耗加油包流量
  • 主套餐兜底:加油包用完后,再扣减主套餐流量
  • 全部用完停机:主套餐 + 所有加油包流量都用完才停机

业务规则

扣减优先级规则(多维度排序)

优先级(从高到低):
1. 加油包(按 priority ASC, expires_at ASC, activated_at ASC
2. 主套餐

多维度排序规则(按优先级递减):

  1. 主键priority ASC - 数字越小优先级越高1 > 2 > 3
  2. 次键expires_at ASC - 先到期的优先扣减(避免流量浪费)
  3. 兜底activated_at ASC - 先激活的优先扣减(相同到期时间时)

SQL 示例

SELECT * FROM tb_package_usage
WHERE card_id = ?
  AND status = 'active'
  AND remaining_data_amount > 0
ORDER BY
  priority ASC,           -- 加油包priority=1在正式套餐priority=10  expires_at ASC,         -- 同优先级3天后到期的在7天后到期的前
  activated_at ASC        -- 同到期时间:早激活的在晚激活的前
LIMIT 10;

业务意义

  • 先用即将到期的:避免流量过期浪费
  • 确定性排序:相同条件下结果稳定,便于问题排查

示例

载体有主套餐剩余10GB+ 加油包Apriority=1, 剩余5GB+ 加油包Bpriority=2, 剩余3GB
产生 12GB 流量:
1. 扣减加油包A5GB → 0GB用完
2. 扣减加油包B3GB → 0GB用完
3. 扣减主套餐4GB → 6GB剩余6GB

停机条件规则

  • 旧逻辑:主套餐流量用完即停机
  • 新逻辑:主套餐 + 所有加油包流量都用完才停机

判断逻辑

SELECT COUNT(*) FROM tb_package_usage
WHERE (iot_card_id/device_id)=? AND status=1
  AND data_usage_mb < data_limit_mb;

-- 如果 COUNT = 0则触发停机

流量扣减算法

输入上游返回的累计流量upstream_cumulative_mb
输出:更新各套餐的 data_usage_mb

1. 查询载体当前生效套餐status=1按优先级排序
   加油包priority ASC→ 主套餐
2. 计算本次流量增量:
   increment = upstream_cumulative_mb - 上次记录的累计流量
3. 依次扣减:
   FOR EACH 套餐 IN 优先级列表:
     可扣减量 = MIN(increment, 套餐剩余额度)
     UPDATE data_usage_mb += 可扣减量
     记录到 PackageUsageDailyRecord
     increment -= 可扣减量
     IF data_usage_mb >= data_limit_mb:
       UPDATE status=2已用完
     IF increment == 0:
       BREAK
4. 检查停机条件:
   IF 所有套餐 status=2:
     触发停机操作

并发控制

  • 场景:轮询系统同时检测到多张卡的流量增加
  • 机制:数据库事务 + 行锁SELECT FOR UPDATE
  • 保证:同一套餐不会被并发扣减导致负数流量

性能要求

  • 单次流量扣减 < 100ms包含数据库更新 + 日记录写入)
  • 批量扣减1000张卡< 10秒

ADDED Requirements

Requirement: 流量优先扣减加油包

系统 SHALL 在扣减流量时,优先扣减加油包流量,再扣减主套餐流量。

业务价值:用户购买加油包后,立即生效,优先消耗加油包流量,避免浪费。

技术实现

  • 查询时按 master_usage_id IS NOT NULL, priority ASC 排序
  • 主套餐master_usage_id=NULL排在最后

Scenario: 存在加油包时优先扣减

  • GIVEN 载体有主套餐data_usage_mb=0, data_limit_mb=10240和加油包data_usage_mb=0, data_limit_mb=5120, priority=1
  • WHEN 上游返回累计流量 3072MB本次增量 3GB
  • THEN 系统执行:
    1. 扣减加油包data_usage_mb=3072
    2. 主套餐不扣减data_usage_mb=0
  • AND PackageUsageDailyRecord 记录加油包增量 3072MB

Scenario: 加油包用完后扣减主套餐

  • GIVEN 载体有主套餐data_usage_mb=0, data_limit_mb=10240和加油包data_usage_mb=3072, data_limit_mb=5120
  • WHEN 上游返回累计流量 8192MB本次增量 5GB
  • THEN 系统执行:
    1. 扣减加油包5120 - 3072 = 2048MB 可用,扣减 2048MB → data_usage_mb=5120用完
    2. 更新加油包 status=2已用完
    3. 剩余流量 5GB - 2GB = 3GB
    4. 扣减主套餐data_usage_mb=3072
  • AND PackageUsageDailyRecord 记录加油包增量 2048MB、主套餐增量 3072MB

Scenario: 只有主套餐时直接扣减

  • GIVEN 载体只有主套餐data_usage_mb=0, data_limit_mb=10240无加油包
  • WHEN 上游返回累计流量 3072MB
  • THEN 系统直接扣减主套餐data_usage_mb=3072
  • AND PackageUsageDailyRecord 记录主套餐增量 3072MB

Scenario: 加油包已用完自动跳过(边界条件)

  • GIVEN 载体有主套餐data_usage_mb=0, data_limit_mb=10240和加油包data_usage_mb=5120, data_limit_mb=5120, status=2
  • WHEN 上游返回累计流量 3072MB
  • THEN 系统跳过已用完的加油包直接扣减主套餐data_usage_mb=3072
  • AND 加油包 data_usage_mb 保持 5120不再扣减

Scenario: 流量增量为 0 不扣减(边界条件)

  • GIVEN 载体有主套餐和加油包
  • WHEN 上游返回累计流量与上次记录相同(增量=0
  • THEN 系统不更新任何套餐的 data_usage_mb
  • AND 不创建 PackageUsageDailyRecord

Scenario: 流量增量为负数拒绝扣减(异常处理)

  • GIVEN 载体上次记录累计流量 10GB
  • WHEN 上游返回累计流量 8GB负增量异常情况
  • THEN 系统记录 Warning 日志:"上游流量异常,累计流量减少"
  • AND 不更新套餐 data_usage_mb
  • AND 告警通知运维团队

Requirement: 多个加油包按多维度排序扣减

系统 SHALL 当存在多个加油包时,按 priority ASC, expires_at ASC, activated_at ASC 多维度排序扣减流量。

业务价值

  • 按购买顺序消耗加油包priority
  • 优先消耗即将到期的流量expires_at
  • 确定性排序便于问题排查activated_at

技术实现

  • 查询时:ORDER BY (master_usage_id IS NOT NULL) DESC, priority ASC, expires_at ASC, activated_at ASC
  • 确保加油包按多维度排序排在主套餐前

Scenario: 按到期时间优先扣减(多维度排序验证)

  • GIVEN 载体有2个加油包相同 priority
    • 加油包Apriority=1, data_limit_mb=5120, expires_at=2026-02-15 23:59:59
    • 加油包Bpriority=1, data_limit_mb=3072, expires_at=2026-02-12 23:59:59先到期
  • WHEN 上游返回累计流量 4096MB本次增量 4GB
  • THEN 系统执行:
    1. 扣减加油包B先到期3072MB → data_usage_mb=3072用完status=2
    2. 剩余流量 4GB - 3GB = 1GB
    3. 扣减加油包A1024MB → data_usage_mb=1024
  • AND PackageUsageDailyRecord 记录加油包B增量 3072MB、加油包A增量 1024MB

Scenario: 完整多维度排序示例

  • GIVEN 载体有:
    • 主套餐priority=10, data_limit_mb=10240, expires_at=2026-03-31
    • 加油包Apriority=1, data_limit_mb=2048, expires_at=2026-02-15, activated_at=2026-02-01
    • 加油包Bpriority=2, data_limit_mb=3072, expires_at=2026-02-20, activated_at=2026-02-03
    • 加油包Cpriority=1, data_limit_mb=4096, expires_at=2026-02-15, activated_at=2026-02-05与A同priority和expires_at但晚激活
  • WHEN 上游返回累计流量 12288MB本次增量 12GB
  • THEN 系统按以下顺序扣减:
    1. 加油包Apriority=1, expires_at=2026-02-15, activated_at=2026-02-01 最早)
    2. 加油包Cpriority=1, expires_at=2026-02-15, activated_at=2026-02-05
    3. 加油包Bpriority=2
    4. 主套餐priority=10
  • AND 扣减结果:
    • 加油包A2048MB → status=2用完
    • 加油包C4096MB → status=2用完
    • 加油包B3072MB → status=2用完
    • 主套餐3072MB剩余 12GB - 2GB - 4GB - 3GB

Scenario: 按购买顺序扣减多个加油包

  • GIVEN 载体有加油包Apriority=1, data_usage_mb=0, data_limit_mb=3072和加油包Bpriority=2, data_usage_mb=0, data_limit_mb=5120
  • WHEN 上游返回累计流量 4096MB本次增量 4GB
  • THEN 系统执行:
    1. 扣减加油包A3072MB → data_usage_mb=3072用完status=2
    2. 剩余流量 4GB - 3GB = 1GB
    3. 扣减加油包B1024MB → data_usage_mb=1024
  • AND PackageUsageDailyRecord 记录加油包A增量 3072MB、加油包B增量 1024MB

Scenario: Priority 最小的加油包用完后扣减下一个

  • GIVEN 载体有3个加油包priority=1/2/3priority=1 已用完status=2
  • WHEN 上游返回累计流量增量 2GB
  • THEN 系统跳过 priority=1扣减 priority=2 的加油包 2GB

Scenario: 所有加油包用完后扣减主套餐

  • GIVEN 载体有主套餐和2个加油包priority=1/2两个加油包都已用完status=2
  • WHEN 上游返回累计流量增量 5GB
  • THEN 系统跳过所有加油包,扣减主套餐 5GB

Scenario: 3个加油包和主套餐的完整扣减流程

  • GIVEN 载体有:
    • 主套餐data_limit_mb=10240, data_usage_mb=0
    • 加油包Apriority=1, data_limit_mb=2048, data_usage_mb=0
    • 加油包Bpriority=2, data_limit_mb=3072, data_usage_mb=0
    • 加油包Cpriority=3, data_limit_mb=4096, data_usage_mb=0
  • WHEN 上游返回累计流量 12288MB本次增量 12GB
  • THEN 系统执行:
    1. 扣减加油包A2048MB → status=2用完
    2. 扣减加油包B3072MB → status=2用完
    3. 扣减加油包C4096MB → status=2用完
    4. 扣减主套餐3072MB剩余 12GB - 2GB - 3GB - 4GB
  • AND PackageUsageDailyRecord 记录 4 条记录

Scenario: 并发扣减同一套餐(并发控制)

  • GIVEN 两个轮询任务同时检测到同一张卡的流量增加
  • WHEN 两个任务同时尝试扣减加油包A
  • THEN 第一个任务获取行锁SELECT FOR UPDATE执行扣减
  • AND 第二个任务等待锁释放,检测到已扣减,跳过(幂等性保证)
  • AND 加油包A的 data_usage_mb 只增加一次

Requirement: 所有流量用完时触发停机

系统 SHALL 在主套餐和所有加油包流量都用完时,触发停机操作。

业务价值:充分利用加油包流量,避免提前停机,提升用户体验。

技术实现

-- 停机条件检查
SELECT COUNT(*) FROM tb_package_usage
WHERE (iot_card_id/device_id)=? AND status=1 AND master_usage_id IS NULL;

-- 如果 COUNT=0主套餐已过期或用完检查加油包
SELECT COUNT(*) FROM tb_package_usage
WHERE (iot_card_id/device_id)=? AND status=1 AND master_usage_id IS NOT NULL
  AND data_usage_mb < data_limit_mb;

-- 如果两个 COUNT 都=0触发停机

Scenario: 主套餐和加油包都用完触发停机

  • GIVEN 主套餐 data_usage_mb=10240, data_limit_mb=10240用完加油包 data_usage_mb=5120, data_limit_mb=5120用完
  • WHEN 轮询系统检查停机条件
  • THEN 系统查询生效中套餐剩余流量,结果为 0
  • AND 触发停机操作:
    1. 调用运营商 API 停机
    2. 更新 IotCard.network_status=0已停机
    3. 记录操作日志
  • AND 主套餐和加油包 status 更新为 2已用完

Scenario: 有加油包剩余流量时不停机

  • GIVEN 主套餐 data_usage_mb=10240, data_limit_mb=10240用完加油包 data_usage_mb=4096, data_limit_mb=5120剩余1GB
  • WHEN 轮询系统检查停机条件
  • THEN 系统查询生效中套餐剩余流量,结果 > 0
  • AND 不触发停机,继续提供服务

Scenario: 主套餐未用完但加油包都用完(不停机)

  • GIVEN 主套餐 data_usage_mb=8192, data_limit_mb=10240剩余2GB所有加油包都用完status=2
  • WHEN 轮询系统检查停机条件
  • THEN 系统查询主套餐剩余流量 > 0
  • AND 不触发停机

Scenario: 主套餐过期但加油包有剩余(不停机)

  • GIVEN 主套餐 status=3已过期加油包 data_usage_mb=2048, data_limit_mb=5120剩余3GB, status=1
  • WHEN 轮询系统检查停机条件
  • THEN 系统查询生效中加油包剩余流量 > 0
  • AND 不触发停机

Scenario: 停机后续费加油包自动复机(业务理解)

  • GIVEN 载体已停机(所有套餐流量用完)
  • WHEN 用户购买新加油包立即激活status=1
  • THEN 下次轮询检查时,发现有剩余流量 > 0
  • AND 自动触发复机操作:
    1. 调用运营商 API 复机
    2. 更新 IotCard.network_status=1已开机
    3. 记录操作日志

Scenario: 停机 API 调用失败(异常处理)

  • GIVEN 载体所有套餐流量用完,需要停机
  • WHEN 调用运营商停机 API 失败(例如网络超时)
  • THEN 系统记录 Error 日志,包含卡号、错误信息
  • AND 停机任务进入重试队列Asynq 重试 3 次,间隔 10 秒)
  • AND 如果 3 次重试都失败进入死信队列DLQ
  • AND 告警通知运维团队

Requirement: 流量扣减记录到日记录表

系统 SHALL 在扣减流量时,更新 PackageUsage 的 data_usage_mb并创建或更新 PackageUsageDailyRecord。

业务价值

  • 精细化流量统计(按套餐、按日)
  • 支持流量详单查询
  • 数据可追溯、可审计

技术实现

  • 扣减流量后,创建或更新当日 PackageUsageDailyRecord
  • 使用 UPSERTON CONFLICT UPDATE避免重复记录
  • 记录字段:package_usage_id, date, daily_usage_mb, cumulative_usage_mb

Scenario: 扣减主套餐流量并记录

  • GIVEN 主套餐 data_usage_mb=0, data_limit_mb=10240
  • WHEN 扣减主套餐 2048MB 流量
  • THEN PackageUsage 更新data_usage_mb=2048
  • AND PackageUsageDailyRecord 创建记录:
    • package_usage_id=主套餐ID
    • date=2026-02-10
    • daily_usage_mb=2048
    • cumulative_usage_mb=2048

Scenario: 扣减加油包流量并记录

  • GIVEN 加油包 data_usage_mb=0, data_limit_mb=5120
  • WHEN 扣减加油包 3072MB 流量
  • THEN PackageUsage 更新data_usage_mb=3072
  • AND PackageUsageDailyRecord 创建记录:
    • package_usage_id=加油包ID
    • date=2026-02-10
    • daily_usage_mb=3072
    • cumulative_usage_mb=3072

Scenario: 同一天多次扣减更新日记录

  • GIVEN PackageUsageDailyRecord 已有记录date=2026-02-10, daily_usage_mb=2048, cumulative_usage_mb=2048
  • WHEN 再次扣减主套餐 1024MB 流量
  • THEN PackageUsage 更新data_usage_mb=3072
  • AND PackageUsageDailyRecord 更新记录:
    • daily_usage_mb=30722048 + 1024
    • cumulative_usage_mb=3072
  • AND 使用 UPSERT 更新而非插入新记录

Scenario: 跨天扣减创建新日记录

  • GIVEN PackageUsageDailyRecord 有 2026-02-10 的记录daily_usage_mb=5120, cumulative_usage_mb=5120
  • WHEN 2026-02-11 扣减主套餐 2048MB 流量
  • THEN PackageUsageDailyRecord 创建新记录:
    • date=2026-02-11
    • daily_usage_mb=2048
    • cumulative_usage_mb=71685120 + 2048

Scenario: 日记录写入失败不影响扣减(容错性)

  • GIVEN 数据库主表正常,日记录表存在问题(例如磁盘满)
  • WHEN 扣减主套餐流量PackageUsage 更新成功,但 PackageUsageDailyRecord 写入失败
  • THEN 系统记录 Error 日志包含套餐ID、日期、增量
  • AND PackageUsage 的 data_usage_mb 仍然更新(不回滚)
  • AND 告警通知运维团队修复日记录表

Scenario: 批量扣减写入日记录(性能优化)

  • GIVEN 轮询系统同时检测到 1000 张卡的流量增加
  • WHEN 批量扣减流量
  • THEN 使用批量 INSERT ON CONFLICT UPDATE 写入日记录
  • AND 1000 条记录写入时间 < 5 秒

数据一致性保证

1. 扣减流量事务保证

  • 机制:数据库事务包含:
    1. UPDATE PackageUsage SET data_usage_mb += increment
    2. INSERT/UPDATE PackageUsageDailyRecord
  • 回滚条件:任一步骤失败,整个事务回滚

2. 并发扣减行锁

  • 机制SELECT * FROM tb_package_usage WHERE id=? FOR UPDATE
  • 保证:同一套餐不会被并发扣减

3. 负数流量保护

  • 机制:数据库约束 CHECK (data_usage_mb >= 0)
  • 保证:扣减后不会出现负数流量

4. 日记录唯一索引

  • 机制UNIQUE INDEX (package_usage_id, date) WHERE deleted_at IS NULL
  • 保证:同一套餐同一天只有一条记录

性能指标

操作 性能要求 监控指标
单次流量扣减 < 100ms 数据库事务耗时
批量扣减1000张卡 < 10秒 轮询任务执行时间
日记录写入 < 50ms INSERT/UPDATE 耗时
停机条件检查 < 50ms SELECT 查询耗时

错误码定义

错误码 HTTP 状态码 错误消息 场景
CodeInternal 500 流量扣减失败,请重试 数据库更新失败
CodeInternal 500 停机操作失败,请重试 运营商 API 调用失败

测试场景矩阵

维度 场景 预期结果
基础扣减 只有主套餐 直接扣减主套餐
有1个加油包 优先扣减加油包
有3个加油包 按 priority 顺序扣减
扣减完整流程 加油包用完 → 主套餐 先扣完所有加油包,再扣主套餐
所有套餐用完 触发停机
边界条件 流量增量=0 不扣减
流量增量<0异常 拒绝扣减,告警
加油包已用完 自动跳过
并发场景 并发扣减同一套餐 行锁保证只扣减一次
停机条件 主套餐用完+加油包剩余 不停机
所有套餐用完 停机
停机后购买加油包 自动复机
日记录 首次扣减 创建日记录
同一天多次扣减 更新日记录
跨天扣减 创建新日记录
异常处理 停机 API 失败 重试 3 次,失败进 DLQ
日记录写入失败 告警,不影响扣减