# Spec: 套餐流量日记录 ## 业务背景 ### 为什么需要流量日记录 **现状问题**: - 用户需要查看每日流量使用明细(哪天用了多少流量) - 套餐流量重置后,历史使用数据丢失 - 无法统计和分析用户流量使用趋势 - 计费对账需要每日流量记录 **业务目标**: - 按套餐维度记录每日流量增量 - 支持按日期范围查询流量详单 - 流量重置后历史记录仍可查询 - 为计费对账和数据分析提供基础数据 --- ## 业务规则 ### 1. 日记录写入规则 每次流量扣减后,写入或更新当日记录: ``` 写入流量日记录: 1. 获取当前日期(date=today) 2. 查询是否已有今日记录: SELECT * FROM package_usage_daily_record WHERE package_usage_id=? AND date=today 3. 如果存在 → UPDATE daily_usage_mb += increment 4. 如果不存在 → INSERT (package_usage_id, date, daily_usage_mb, cumulative_usage_mb) 5. 使用 UPSERT(ON CONFLICT UPDATE)确保幂等性 ``` ### 2. 流量增量计算 ``` 每日流量增量 = 今日上游返回的累计流量 - 昨日记录的累计流量 特殊情况: - 如果昨日无记录 → 增量 = 今日上游累计流量 - 如果上游重置(今日累计 < 昨日累计)→ 增量 = 今日上游累计流量 ``` ### 3. cumulative_usage_mb 字段 - **定义**:截止到当日的累计流量 - **计算规则**:cumulative_usage_mb = 昨日 cumulative_usage_mb + 今日 daily_usage_mb - **首日规则**:首日 cumulative_usage_mb = daily_usage_mb ### 4. 数据保留策略 - **保留期限**:永久保留(或根据业务需求保留1年/2年) - **流量重置不删除**:套餐流量重置后,日记录仍保留 - **套餐过期不删除**:套餐过期后,日记录仍保留 --- ## ADDED Requirements ### Requirement: 按套餐维度记录每日流量 系统 SHALL 为每个 PackageUsage 创建每日流量记录(PackageUsageDailyRecord),记录每天的流量增量。 #### Scenario: 首次记录当日流量 - **GIVEN** 套餐 ID=123 在 2026-02-10 首次产生流量 1.5GB - **WHEN** 流量扣减完成 - **THEN** 系统创建 PackageUsageDailyRecord: - package_usage_id=123 - date=2026-02-10 - daily_usage_mb=1536 (1.5GB) - cumulative_usage_mb=1536 #### Scenario: 同一天多次流量更新 - **GIVEN** 套餐在 2026-02-10 已记录 1GB 流量 - **WHEN** 再产生 0.5GB 流量 - **THEN** 系统更新 PackageUsageDailyRecord: - daily_usage_mb=1536(1GB+0.5GB) - cumulative_usage_mb=1536 #### Scenario: 跨天流量记录 - **GIVEN** 套餐在 2026-02-10 使用 2GB - **AND** 2026-02-11 使用 3GB - **WHEN** 流量扣减完成 - **THEN** 系统创建两条记录: - 2月10日:daily_usage_mb=2GB, cumulative_usage_mb=2GB - 2月11日:daily_usage_mb=3GB, cumulative_usage_mb=5GB #### Scenario: 流量重置后日记录仍保留 - **GIVEN** 套餐在 2月1日至2月28日有28条日记录 - **WHEN** 3月1日 00:00:00 触发流量重置 - **THEN** 套餐 data_usage_mb 重置为 0 - **AND** 2月的28条日记录仍存在且可查询 ### Requirement: 流量增量基于上游查询计算 系统 SHALL 根据上游返回的累计流量,减去昨日记录的累计流量,计算每日增量。 #### Scenario: 计算每日流量增量 - **GIVEN** 昨日(2月9日)记录 cumulative_usage_mb=10GB - **WHEN** 今日(2月10日)上游返回 cumulative=13GB - **THEN** 今日 daily_usage_mb=3GB(13GB - 10GB) - **AND** 今日 cumulative_usage_mb=13GB #### Scenario: 上游周期重置后流量计算 - **GIVEN** 联通卡在 2月27日 00:00:00 上游重置 - **AND** 昨日(2月26日)记录 cumulative_usage_mb=15GB - **WHEN** 今日(2月27日)上游返回 cumulative=2GB - **THEN** 今日 daily_usage_mb=2GB(上游重置,取新增量) - **AND** 今日 cumulative_usage_mb=2GB #### Scenario: 首日无昨日记录 - **GIVEN** 套餐首次激活,无任何日记录 - **WHEN** 上游返回 cumulative=5GB - **THEN** 今日 daily_usage_mb=5GB - **AND** 今日 cumulative_usage_mb=5GB ### Requirement: 支持按日期查询套餐流量详单 系统 SHALL 提供 API 查询指定套餐的每日流量记录。 #### Scenario: 查询套餐流量详单 - **WHEN** 用户通过 GET /api/admin/package-usage/:id/daily-records 查询套餐流量详单 - **THEN** 系统返回按日期排序的流量记录列表: ```json { "code": 200, "data": [ { "date": "2026-02-01", "daily_usage_mb": 1024, "cumulative_usage_mb": 1024 }, { "date": "2026-02-02", "daily_usage_mb": 2048, "cumulative_usage_mb": 3072 } ] } ``` #### Scenario: 查询指定日期范围 - **GIVEN** 套餐有 2月1日 至 2月28日 的流量记录 - **WHEN** 用户查询流量详单,参数 start_date=2026-02-01, end_date=2026-02-10 - **THEN** 系统返回 2月1日 至 2月10日 的流量记录(10条) #### Scenario: 客户端查询自己的流量详单 - **WHEN** 客户通过 GET /api/customer/package-usage/:id/daily-records 查询 - **THEN** 系统校验套餐归属后,返回流量记录列表 ### Requirement: 日记录索引优化 系统 SHALL 在 PackageUsageDailyRecord 表创建 (package_usage_id, date) 联合唯一索引。 #### Scenario: 同一套餐同一天只有一条记录 - **WHEN** 系统尝试为同一 package_usage_id=123 和 date=2026-02-10 创建第二条记录 - **THEN** 数据库返回唯一约束冲突错误 - **AND** 使用 UPSERT 自动转为 UPDATE 操作 #### Scenario: 查询性能达标 - **GIVEN** 套餐 ID=123 有 365 条日记录(一年数据) - **WHEN** 查询全部流量详单 - **THEN** 查询响应时间 < 50ms --- ## 边界条件 ### 1. 套餐过期后的日记录 - **场景**:套餐在 2月28日过期,3月1日仍可查询历史日记录 - **处理**:日记录永久保留,不随套餐过期删除 ### 2. 并发写入同一天记录 - **场景**:同一套餐在同一天有多个并发流量扣减请求 - **处理**:使用 UPSERT(ON CONFLICT UPDATE)确保幂等性 ### 3. 跨月查询日记录 - **场景**:查询 1月15日 至 2月15日 的日记录(跨月) - **处理**:按日期范围查询,返回跨月数据 --- ## 并发场景 ### Scenario: 并发写入同一天记录 - **GIVEN** 套餐 ID=123 在 2026-02-10 10:00:00 和 10:00:01 同时扣减流量 - **WHEN** 两个请求同时写入日记录 - **THEN** 使用 UPSERT(ON CONFLICT UPDATE): ```sql INSERT INTO package_usage_daily_record (package_usage_id, date, daily_usage_mb, cumulative_usage_mb) VALUES (123, '2026-02-10', 1024, 1024) ON CONFLICT (package_usage_id, date) DO UPDATE SET daily_usage_mb = package_usage_daily_record.daily_usage_mb + EXCLUDED.daily_usage_mb, cumulative_usage_mb = package_usage_daily_record.cumulative_usage_mb + EXCLUDED.daily_usage_mb; ``` - **AND** 两个请求的流量累加到同一条记录 --- ## 异常处理 ### 1. 日记录写入失败 - **错误场景**:流量扣减成功,但日记录写入失败(数据库连接断开) - **处理流程**: 1. 不回滚流量扣减(已提交) 2. 记录 Error 日志(包含套餐ID、日期、流量增量) 3. 通过定时任务补录日记录 - **返回错误**:不影响用户,日记录补录在后台进行 ### 2. 查询日记录超时 - **错误场景**:查询大量日记录时超时(如查询3年数据) - **处理流程**: 1. 限制单次查询最多返回 365 条记录 2. 如果超过限制,返回错误 400:"查询日期范围过大,最多查询1年" - **返回错误**:`{"code": "DATE_RANGE_TOO_LARGE", "msg": "查询日期范围过大,最多查询1年"}` --- ## 数据一致性保证 ### 1. 事务边界 - **流量扣减 + 写入日记录**:使用单个事务(可选,根据业务需求) - **查询日记录**:使用只读事务 ### 2. 唯一索引 - **联合唯一索引**:`UNIQUE INDEX idx_package_usage_daily_record (package_usage_id, date)` - **确保同一套餐同一天只有一条记录** ### 3. UPSERT 幂等性 - **使用 ON CONFLICT UPDATE**:确保并发写入时累加流量而非覆盖 --- ## 性能指标 | 操作 | 目标响应时间 | 并发要求 | 数据量 | |------|-------------|---------|--------| | 写入日记录(UPSERT) | < 10ms | 1000 QPS | 单条插入/更新 | | 查询日记录(单套餐) | < 50ms | 100 QPS | 查询365条记录 | | 查询日记录(日期范围) | < 100ms | 100 QPS | 查询指定范围 | --- ## 错误码定义 | 错误码 | HTTP 状态码 | 错误消息 | 场景 | |--------|------------|---------|------| | `DATE_RANGE_TOO_LARGE` | 400 | 查询日期范围过大,最多查询1年 | 查询日记录日期范围超过365天 | | `DAILY_RECORD_NOT_FOUND` | 404 | 未找到流量记录 | 查询不存在的日记录 | --- ## 数据迁移策略 **激进策略**(开发阶段,保证干净性): ### 1. ❌ 要删除的字段 目前 `package_usage_daily_record` 表中可能存在的冗余字段(需确认后删除): - 如果有 `daily_increment` 字段(旧的增量字段) → **删除**,统一使用 `daily_usage_mb` - 如果有 `total_usage` 字段(旧的累计字段) → **删除**,统一使用 `cumulative_usage_mb` ### 2. ✅ 新增的字段 在 `package_usage_daily_record` 表中确保有以下字段: ```sql CREATE TABLE IF NOT EXISTS package_usage_daily_record ( id BIGSERIAL PRIMARY KEY, package_usage_id BIGINT NOT NULL COMMENT '套餐使用记录ID', date DATE NOT NULL COMMENT '日期', daily_usage_mb INT DEFAULT 0 COMMENT '当日流量使用量(MB)', cumulative_usage_mb BIGINT DEFAULT 0 COMMENT '截止当日的累计流量(MB)', created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, UNIQUE KEY idx_package_usage_daily_record (package_usage_id, date) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='套餐流量日记录'; CREATE INDEX idx_date ON package_usage_daily_record(date); ``` ### 3. ❌ 要废弃的逻辑 - **废弃旧的日记录写入逻辑**:如果代码中存在不使用 UPSERT 的写入逻辑,全部删除 - **废弃旧的日记录查询逻辑**:统一使用新的查询接口 ### 4. ✅ 历史数据强制转换 ```sql -- Step 1: 如果有旧的字段名,重命名 -- ALTER TABLE package_usage_daily_record CHANGE daily_increment daily_usage_mb INT; -- ALTER TABLE package_usage_daily_record CHANGE total_usage cumulative_usage_mb BIGINT; -- Step 2: 修复 cumulative_usage_mb(如果历史数据不准确) -- 重新计算每个套餐的 cumulative_usage_mb -- (需要按套餐ID分组,按日期排序,累加 daily_usage_mb) -- Step 3: 确保唯一索引存在 CREATE UNIQUE INDEX IF NOT EXISTS idx_package_usage_daily_record ON package_usage_daily_record(package_usage_id, date); ``` ### 5. ❌ 删除遗留表/字段(确认后执行) ```sql -- 如果存在旧的日记录表,删除 -- DROP TABLE IF EXISTS iot_card_usage_daily; -- 如果存在旧的字段,删除 -- ALTER TABLE package_usage_daily_record DROP COLUMN IF EXISTS daily_increment; -- ALTER TABLE package_usage_daily_record DROP COLUMN IF EXISTS total_usage; ``` ### 6. 验证步骤 ```sql -- 验证1:所有日记录都有 daily_usage_mb 和 cumulative_usage_mb SELECT COUNT(*) FROM package_usage_daily_record WHERE daily_usage_mb IS NULL OR cumulative_usage_mb IS NULL; -- 预期结果:0 -- 验证2:同一套餐同一天只有一条记录 SELECT package_usage_id, date, COUNT(*) FROM package_usage_daily_record GROUP BY package_usage_id, date HAVING COUNT(*) > 1; -- 预期结果:0 rows -- 验证3:累计流量单调递增(同一套餐) -- (需要编写复杂查询验证,略) ``` --- ## 测试场景矩阵 | 场景分类 | 测试用例 | 预期结果 | |---------|---------|---------| | **写入日记录** | 首次记录当日流量 | 创建新记录 | | | 同一天多次流量更新 | 更新已有记录(UPSERT) | | | 跨天流量记录 | 创建多条记录 | | **流量增量计算** | 计算每日流量增量 | daily_usage_mb = 今日累计 - 昨日累计 | | | 上游周期重置后计算 | daily_usage_mb = 今日累计(重置后) | | | 首日无昨日记录 | daily_usage_mb = 今日累计 | | **查询日记录** | 查询套餐流量详单 | 返回按日期排序的记录列表 | | | 查询指定日期范围 | 返回指定范围内的记录 | | | 客户端查询自己的详单 | 校验归属后返回 | | **索引和性能** | 同一套餐同一天只有一条记录 | 唯一约束保证 | | | 查询365条记录 | 响应时间 < 50ms | | **并发** | 并发写入同一天记录 | UPSERT 确保累加 | | **异常** | 日记录写入失败 | 不回滚流量扣减,后台补录 | | | 查询日记录超时 | 限制日期范围,返回错误 | --- ## 实现参考 ### 写入日记录(UPSERT) ```go // Service 层:RecordDailyUsage func (s *Service) RecordDailyUsage(ctx context.Context, usageID uint, date time.Time, dailyUsageMB int, cumulativeUsageMB int64) error { record := &model.PackageUsageDailyRecord{ PackageUsageID: usageID, Date: date, DailyUsageMB: dailyUsageMB, CumulativeUsageMB: cumulativeUsageMB, } if err := s.store.UpsertDailyRecord(ctx, record); err != nil { return errors.Wrap(errors.CodeInternalError, err, "写入流量日记录失败") } return nil } // Store 层:UpsertDailyRecord func (s *Store) UpsertDailyRecord(ctx context.Context, record *model.PackageUsageDailyRecord) error { // PostgreSQL UPSERT return s.db.WithContext(ctx).Exec(` INSERT INTO package_usage_daily_record (package_usage_id, date, daily_usage_mb, cumulative_usage_mb, created_at, updated_at) VALUES (?, ?, ?, ?, NOW(), NOW()) ON CONFLICT (package_usage_id, date) DO UPDATE SET daily_usage_mb = package_usage_daily_record.daily_usage_mb + EXCLUDED.daily_usage_mb, cumulative_usage_mb = package_usage_daily_record.cumulative_usage_mb + (EXCLUDED.daily_usage_mb), updated_at = NOW() `, record.PackageUsageID, record.Date, record.DailyUsageMB, record.CumulativeUsageMB).Error } ``` ### 查询日记录 ```go // Handler: GetDailyRecords func (h *Handler) GetDailyRecords(c *fiber.Ctx) error { usageID, _ := c.ParamsInt("id") startDate := c.Query("start_date", "") endDate := c.Query("end_date", "") // 查询日记录 records, err := h.service.GetDailyRecords(c.UserContext(), uint(usageID), startDate, endDate) if err != nil { return err } return response.Success(c, records) } // Service 层:GetDailyRecords func (s *Service) GetDailyRecords(ctx context.Context, usageID uint, startDate, endDate string) ([]*model.PackageUsageDailyRecord, error) { // 参数校验 start, err := time.Parse("2006-01-02", startDate) if err != nil { return nil, errors.New(errors.CodeInvalidParam, "起始日期格式错误") } end, err := time.Parse("2006-01-02", endDate) if err != nil { return nil, errors.New(errors.CodeInvalidParam, "结束日期格式错误") } // 限制查询范围 if end.Sub(start).Hours() > 365*24 { return nil, errors.New(errors.CodeInvalidParam, "查询日期范围过大,最多查询1年") } // 查询日记录 return s.store.ListDailyRecords(ctx, usageID, start, end) } // Store 层:ListDailyRecords func (s *Store) ListDailyRecords(ctx context.Context, usageID uint, startDate, endDate time.Time) ([]*model.PackageUsageDailyRecord, error) { var records []*model.PackageUsageDailyRecord err := s.db.WithContext(ctx). Where("package_usage_id = ? AND date >= ? AND date <= ?", usageID, startDate, endDate). Order("date ASC"). Find(&records).Error return records, err } ``` --- **本 Spec 完成**,包含: - ✅ 业务背景和业务规则 - ✅ 详细场景(写入、查询、增量计算) - ✅ 边界条件和并发场景 - ✅ 异常处理和数据一致性保证 - ✅ 性能指标和错误码定义 - ✅ **激进的数据迁移策略**(明确删除字段、废弃逻辑、强制转换) - ✅ 测试场景矩阵和实现参考