refactor: 流量系统重构 — 增量累加算法 + 日粒度缓冲 + 旧详单清理
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m16s

核心改造:
- 增量算法:流量计算从覆盖式改为增量累加(gateway - lastReading),支持上游运营商重置检测
- 日流量缓冲:insertDataUsageRecord 改为 Redis INCRBYFLOAT,每日凌晨落盘到 tb_card_daily_usage
- 运营商:新增 data_reset_day 字段(联通=27,其余=1)
- IoT卡:新增 last_gateway_reading_mb 字段存储上次网关读数
- 查询层:新建 TrafficQueryService 合并 Redis(今日)+ DB(历史)数据源
- 清理:删除 DataUsageRecord model/store,移除 polling_handler 旧引用

迁移:000094-000097(carrier字段、iot_card字段、数据初始化、日流量表)
This commit is contained in:
2026-03-30 09:59:30 +08:00
parent f5dd2ce4ab
commit cebcada950
44 changed files with 1149 additions and 1613 deletions

View File

@@ -180,6 +180,10 @@ func main() {
if _, err := asynqScheduler.Register("0 2 * * *", asynq.NewTask(constants.TaskTypeDataCleanup, nil)); err != nil {
appLogger.Fatal("注册数据清理定时任务失败", zap.Error(err))
}
// 注册定时任务:每日流量落盘(每天凌晨 2 点,超时 5 分钟)
if _, err := asynqScheduler.Register("0 2 * * *", asynq.NewTask(constants.TaskTypeDailyTrafficFlush, nil, asynq.MaxRetry(3), asynq.Timeout(5*time.Minute))); err != nil {
appLogger.Fatal("注册每日流量落盘定时任务失败", zap.Error(err))
}
// 启动 Asynq Scheduler
go func() {

View File

@@ -41,6 +41,7 @@ import (
shopPackageBatchAllocationSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_allocation"
shopPackageBatchPricingSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_pricing"
shopSeriesGrantSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_series_grant"
trafficSvc "github.com/break/junhong_cmp_fiber/internal/service/traffic"
wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
)
@@ -94,6 +95,7 @@ type services struct {
AgentRecharge *agentRechargeSvc.Service
PackageActivation *packageSvc.ActivationService
Refund *refundSvc.Service
TrafficQuery *trafficSvc.QueryService
}
func initServices(s *stores, deps *Dependencies) *services {
@@ -210,6 +212,7 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.Logger,
),
PackageActivation: packageActivation,
TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage),
Refund: refundSvc.New(
deps.DB,
s.RefundRequest,

View File

@@ -61,6 +61,8 @@ type stores struct {
WechatConfig *postgres.WechatConfigStore
// 退款系统
RefundRequest *postgres.RefundStore
// 流量系统
CardDailyUsage *postgres.CardDailyUsageStore
}
func initStores(deps *Dependencies) *stores {
@@ -119,5 +121,6 @@ func initStores(deps *Dependencies) *stores {
AssetRecharge: postgres.NewAssetRechargeStore(deps.DB, deps.Redis),
WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis),
RefundRequest: postgres.NewRefundStore(deps.DB),
CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB),
}
}

View File

@@ -0,0 +1,18 @@
package model
import "time"
// CardDailyUsage 卡日流量记录模型
// 每卡每天一条记录,由每日落盘任务从 Redis 写入
type CardDailyUsage struct {
ID uint `gorm:"column:id;primaryKey;comment:记录ID" json:"id"`
IotCardID uint `gorm:"column:iot_card_id;not null;uniqueIndex:uk_card_daily;comment:IoT卡ID" json:"iot_card_id"`
Date time.Time `gorm:"column:date;type:date;not null;uniqueIndex:uk_card_daily;comment:日期" json:"date"`
UsageMB float64 `gorm:"column:usage_mb;type:numeric(12,2);not null;default:0;comment:当日流量使用量(MB)" json:"usage_mb"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
}
func (CardDailyUsage) TableName() string {
return "tb_card_daily_usage"
}

View File

@@ -15,6 +15,7 @@ type Carrier struct {
BillingDay int `gorm:"column:billing_day;type:int;default:1;comment:运营商计费日(用于流量查询接口的计费周期计算,联通=27其他=1" json:"billing_day"`
RealnameLinkType string `gorm:"column:realname_link_type;type:varchar(20);not null;default:'none';comment:实名链接类型 none-不支持 template-模板URL gateway-Gateway接口" json:"realname_link_type"`
RealnameLinkTemplate string `gorm:"column:realname_link_template;type:varchar(500);default:'';comment:实名链接模板URL" json:"realname_link_template"`
DataResetDay int `gorm:"column:data_reset_day;type:int;not null;default:1;comment:上游流量重置日(1-28) 运营商每月清零网关计数器的日期" json:"data_reset_day"`
}
// TableName 指定表名

View File

@@ -1,23 +0,0 @@
package model
import (
"time"
)
// DataUsageRecord 流量使用记录模型
// 记录卡的流量历史,支持流量查询和分析
// 注意:此模型是日志表,不需要软删除和审计字段
type DataUsageRecord struct {
ID uint `gorm:"column:id;primaryKey;comment:流量使用记录ID" json:"id"`
IotCardID uint `gorm:"column:iot_card_id;index;not null;comment:IoT卡ID" json:"iot_card_id"`
DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;not null;comment:流量使用量(MB)" json:"data_usage_mb"`
DataIncreaseMB int64 `gorm:"column:data_increase_mb;type:bigint;default:0;comment:相比上次的增量(MB)" json:"data_increase_mb"`
CheckTime time.Time `gorm:"column:check_time;not null;comment:检查时间" json:"check_time"`
Source string `gorm:"column:source;type:varchar(50);default:'polling';comment:数据来源 polling-轮询 manual-手动 gateway-回调" json:"source"`
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
}
// TableName 指定表名
func (DataUsageRecord) TableName() string {
return "tb_data_usage_record"
}

View File

@@ -76,7 +76,7 @@ type AssetRealtimeStatusResponse struct {
AssetID uint `json:"asset_id" description:"资产ID"`
NetworkStatus int `json:"network_status,omitempty" description:"网络状态asset_type=card时有效0停机 1开机"`
RealNameStatus int `json:"real_name_status,omitempty" description:"实名状态asset_type=card时有效0未实名 1已实名"`
CurrentMonthUsageMB float64 `json:"current_month_usage_mb,omitempty" description:"本月已用流量MBasset_type=card时有效"`
CurrentMonthUsageMB float64 `json:"current_month_usage_mb,omitempty" description:"系统累计的自然月流量MBasset_type=card时有效"`
LastSyncTime *time.Time `json:"last_sync_time,omitempty" description:"最后同步时间asset_type=card时有效"`
DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"保护期状态asset_type=device时有效none/stop/start"`
Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定卡状态列表asset_type=device时有效"`

View File

@@ -5,6 +5,7 @@ type CreateCarrierRequest struct {
CarrierName string `json:"carrier_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"运营商名称"`
CarrierType string `json:"carrier_type" validate:"required,oneof=CMCC CUCC CTCC CBN" required:"true" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
Description string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"运营商描述"`
DataResetDay *int `json:"data_reset_day" validate:"omitempty,min=1,max=28" minimum:"1" maximum:"28" description:"上游流量重置日(1-28)运营商每月清零网关计数器的日期默认1"`
RealnameLinkType *string `json:"realname_link_type" validate:"omitempty,oneof=none template gateway" description:"实名链接类型 none-不支持 template-模板URL gateway-Gateway接口"`
RealnameLinkTemplate *string `json:"realname_link_template" validate:"omitempty,max=500" maxLength:"500" description:"实名链接模板URL支持 {iccid}/{msisdn}/{virtual_no} 占位符"`
}
@@ -12,6 +13,7 @@ type CreateCarrierRequest struct {
type UpdateCarrierRequest struct {
CarrierName *string `json:"carrier_name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"运营商名称"`
Description *string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"运营商描述"`
DataResetDay *int `json:"data_reset_day" validate:"omitempty,min=1,max=28" minimum:"1" maximum:"28" description:"上游流量重置日(1-28),运营商每月清零网关计数器的日期"`
RealnameLinkType *string `json:"realname_link_type" validate:"omitempty,oneof=none template gateway" description:"实名链接类型 none-不支持 template-模板URL gateway-Gateway接口"`
RealnameLinkTemplate *string `json:"realname_link_template" validate:"omitempty,max=500" maxLength:"500" description:"实名链接模板URL支持 {iccid}/{msisdn}/{virtual_no} 占位符"`
}
@@ -34,6 +36,7 @@ type CarrierResponse struct {
CarrierName string `json:"carrier_name" description:"运营商名称"`
CarrierType string `json:"carrier_type" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
Description string `json:"description" description:"运营商描述"`
DataResetDay int `json:"data_reset_day" description:"上游流量重置日(1-28)"`
RealnameLinkType string `json:"realname_link_type" description:"实名链接类型 none-不支持 template-模板URL gateway-Gateway接口"`
RealnameLinkTemplate string `json:"realname_link_template" description:"实名链接模板URL"`
Status int `json:"status" description:"状态 (1:启用, 0:禁用)"`

View File

@@ -30,7 +30,7 @@ type IotCard struct {
RealNameStatus int `gorm:"column:real_name_status;type:int;default:0;not null;comment:实名状态 0-未实名 1-已实名" json:"real_name_status"`
NetworkStatus int `gorm:"column:network_status;type:int;default:0;not null;comment:网络状态 0-停机 1-开机" json:"network_status"`
DataUsageMB int64 `gorm:"column:data_usage_mb;type:bigint;default:0;comment:累计流量使用(MB)" json:"data_usage_mb"`
CurrentMonthUsageMB float64 `gorm:"column:current_month_usage_mb;type:decimal(10,2);default:0;comment:本月已用流量(MB) - Gateway返回的自然月流量总量" json:"current_month_usage_mb"`
CurrentMonthUsageMB float64 `gorm:"column:current_month_usage_mb;type:decimal(10,2);default:0;comment:系统累计的自然月流量(MB)" json:"current_month_usage_mb"`
CurrentMonthStartDate *time.Time `gorm:"column:current_month_start_date;type:date;comment:本月开始日期 - 用于检测跨月流量重置" json:"current_month_start_date"`
LastMonthTotalMB float64 `gorm:"column:last_month_total_mb;type:decimal(10,2);default:0;comment:上月结束时的总流量(MB) - 用于跨月流量计算" json:"last_month_total_mb"`
EnablePolling bool `gorm:"column:enable_polling;type:boolean;default:true;comment:是否参与轮询 true-参与 false-不参与" json:"enable_polling"`
@@ -44,14 +44,15 @@ type IotCard struct {
FirstRechargeTriggeredBySeriesJSON string `gorm:"column:first_recharge_triggered_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的首充触发状态" json:"-"`
// 任务 24.1: 停复机相关字段
FirstRealnameAt *time.Time `gorm:"column:first_realname_at;comment:首次实名时间(用于触发首次实名激活)" json:"first_realname_at,omitempty"`
StoppedAt *time.Time `gorm:"column:stopped_at;comment:停机时间" json:"stopped_at,omitempty"`
ResumedAt *time.Time `gorm:"column:resumed_at;comment:最近复机时间" json:"resumed_at,omitempty"`
StopReason string `gorm:"column:stop_reason;type:varchar(50);comment:停机原因(traffic_exhausted=流量耗尽,manual=手动停机,arrears=欠费)" json:"stop_reason,omitempty"`
AssetStatus int `gorm:"column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用" json:"asset_status"`
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
IsStandalone bool `gorm:"column:is_standalone;type:boolean;default:true;not null;comment:是否为独立卡(未绑定设备) 由触发器自动维护" json:"is_standalone"`
VirtualNo string `gorm:"column:virtual_no;type:varchar(50);uniqueIndex:idx_iot_card_virtual_no,where:deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> '';comment:虚拟号(可空,全局唯一)" json:"virtual_no,omitempty"`
FirstRealnameAt *time.Time `gorm:"column:first_realname_at;comment:首次实名时间(用于触发首次实名激活)" json:"first_realname_at,omitempty"`
StoppedAt *time.Time `gorm:"column:stopped_at;comment:停机时间" json:"stopped_at,omitempty"`
ResumedAt *time.Time `gorm:"column:resumed_at;comment:最近复机时间" json:"resumed_at,omitempty"`
StopReason string `gorm:"column:stop_reason;type:varchar(50);comment:停机原因(traffic_exhausted=流量耗尽,manual=手动停机,arrears=欠费)" json:"stop_reason,omitempty"`
AssetStatus int `gorm:"column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用" json:"asset_status"`
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
IsStandalone bool `gorm:"column:is_standalone;type:boolean;default:true;not null;comment:是否为独立卡(未绑定设备) 由触发器自动维护" json:"is_standalone"`
VirtualNo string `gorm:"column:virtual_no;type:varchar(50);uniqueIndex:idx_iot_card_virtual_no,where:deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> '';comment:虚拟号(可空,全局唯一)" json:"virtual_no,omitempty"`
LastGatewayReadingMB float64 `gorm:"column:last_gateway_reading_mb;type:float;not null;default:0;comment:上次轮询时网关返回的流量读数(MB)" json:"last_gateway_reading_mb"`
}
// TableName 指定表名

View File

@@ -35,11 +35,15 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*d
}
carrier := &model.Carrier{
CarrierCode: req.CarrierCode,
CarrierName: req.CarrierName,
CarrierType: req.CarrierType,
Description: req.Description,
Status: constants.StatusEnabled,
CarrierCode: req.CarrierCode,
CarrierName: req.CarrierName,
CarrierType: req.CarrierType,
Description: req.Description,
Status: constants.StatusEnabled,
DataResetDay: 1,
}
if req.DataResetDay != nil {
carrier.DataResetDay = *req.DataResetDay
}
if req.RealnameLinkType != nil {
carrier.RealnameLinkType = *req.RealnameLinkType
@@ -87,6 +91,9 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierReq
if req.Description != nil {
carrier.Description = *req.Description
}
if req.DataResetDay != nil {
carrier.DataResetDay = *req.DataResetDay
}
if req.RealnameLinkType != nil {
carrier.RealnameLinkType = *req.RealnameLinkType
}
@@ -189,6 +196,7 @@ func (s *Service) toResponse(c *model.Carrier) *dto.CarrierResponse {
CarrierName: c.CarrierName,
CarrierType: c.CarrierType,
Description: c.Description,
DataResetDay: c.DataResetDay,
RealnameLinkType: c.RealnameLinkType,
RealnameLinkTemplate: c.RealnameLinkTemplate,
Status: c.Status,

View File

@@ -0,0 +1,91 @@
// Package traffic 提供流量数据查询服务
// 合并 Redis 缓冲(今日)和 DB历史两段数据源对外提供统一的日粒度流量查询
package traffic
import (
"context"
"sort"
"strconv"
"time"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/redis/go-redis/v9"
)
// DailyUsageItem 日粒度流量记录
type DailyUsageItem struct {
Date string `json:"date"` // 日期,格式 YYYY-MM-DD
UsageMB float64 `json:"usage_mb"` // 当天累计使用流量(MB)
}
// QueryService 流量查询服务
// 合并 Redis当天实时和 DB历史落盘两段数据源
type QueryService struct {
redis *redis.Client
cardDailyUsageStore *postgres.CardDailyUsageStore
}
// NewQueryService 创建流量查询服务实例
func NewQueryService(redisClient *redis.Client, cardDailyUsageStore *postgres.CardDailyUsageStore) *QueryService {
return &QueryService{
redis: redisClient,
cardDailyUsageStore: cardDailyUsageStore,
}
}
// GetDailyUsage 查询卡在指定日期范围内的日粒度流量
// 今天的数据从 Redis 缓冲读取(可能尚未落盘),历史数据从 DB 读取,合并后按日期 ASC 排序返回
func (s *QueryService) GetDailyUsage(ctx context.Context, cardID uint, startDate, endDate time.Time) ([]*DailyUsageItem, error) {
now := time.Now()
todayStr := now.Format("2006-01-02")
// 判断结束日期是否包含今天
endDateStr := endDate.Format("2006-01-02")
includeToday := endDateStr >= todayStr
// 历史部分:从 DB 查询(排除今天,因为今天的数据可能还在 Redis 中)
dbEndDate := endDate
if includeToday {
// DB 只查到昨天,今天从 Redis 取
dbEndDate = now.AddDate(0, 0, -1)
}
var items []*DailyUsageItem
// 只有 startDate <= dbEndDate 时才查 DB
if !startDate.After(dbEndDate) {
records, err := s.cardDailyUsageStore.ListByCardIDAndDateRange(ctx, cardID, startDate, dbEndDate)
if err != nil {
return nil, err
}
for _, r := range records {
items = append(items, &DailyUsageItem{
Date: r.Date.Format("2006-01-02"),
UsageMB: r.UsageMB,
})
}
}
// 今天部分:从 Redis 读取
if includeToday && !startDate.After(now) {
key := constants.RedisCardDailyTrafficKey(cardID, todayStr)
val, err := s.redis.Get(ctx, key).Result()
if err == nil && val != "" {
if usageMB, parseErr := strconv.ParseFloat(val, 64); parseErr == nil && usageMB > 0 {
items = append(items, &DailyUsageItem{
Date: todayStr,
UsageMB: usageMB,
})
}
}
// Redis key 不存在redis.Nil时忽略表示今天暂无流量
}
// 按日期 ASC 排序
sort.Slice(items, func(i, j int) bool {
return items[i].Date < items[j].Date
})
return items, nil
}

View File

@@ -0,0 +1,57 @@
package postgres
import (
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// CardDailyUsageStore 卡日流量记录存储
// 同时供落盘任务和查询层使用
type CardDailyUsageStore struct {
db *gorm.DB
}
func NewCardDailyUsageStore(db *gorm.DB) *CardDailyUsageStore {
return &CardDailyUsageStore{db: db}
}
// Upsert 覆盖写入日流量记录(幂等安全)
// ON CONFLICT (iot_card_id, date) DO UPDATE SET usage_mb = EXCLUDED.usage_mb
func (s *CardDailyUsageStore) Upsert(ctx context.Context, record *model.CardDailyUsage) error {
return s.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "iot_card_id"}, {Name: "date"}},
DoUpdates: clause.AssignmentColumns([]string{"usage_mb", "updated_at"}),
}).
Create(record).Error
}
// UpsertBatch 批量覆盖写入日流量记录
func (s *CardDailyUsageStore) UpsertBatch(ctx context.Context, records []*model.CardDailyUsage) error {
if len(records) == 0 {
return nil
}
return s.db.WithContext(ctx).
Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "iot_card_id"}, {Name: "date"}},
DoUpdates: clause.AssignmentColumns([]string{"usage_mb", "updated_at"}),
}).
CreateInBatches(records, 200).Error
}
// ListByCardIDAndDateRange 查询卡在日期范围内的日流量记录
func (s *CardDailyUsageStore) ListByCardIDAndDateRange(ctx context.Context, cardID uint, startDate, endDate time.Time) ([]*model.CardDailyUsage, error) {
var records []*model.CardDailyUsage
if err := s.db.WithContext(ctx).
Where("iot_card_id = ? AND date >= ? AND date <= ?", cardID, startDate, endDate).
Order("date ASC").
Find(&records).Error; err != nil {
return nil, err
}
return records, nil
}

View File

@@ -1,91 +0,0 @@
package postgres
import (
"context"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// DataUsageRecordStore 流量使用记录存储
type DataUsageRecordStore struct {
db *gorm.DB
}
// NewDataUsageRecordStore 创建流量使用记录存储实例
func NewDataUsageRecordStore(db *gorm.DB) *DataUsageRecordStore {
return &DataUsageRecordStore{db: db}
}
// Create 创建流量使用记录
func (s *DataUsageRecordStore) Create(ctx context.Context, record *model.DataUsageRecord) error {
return s.db.WithContext(ctx).Create(record).Error
}
// CreateBatch 批量创建流量使用记录
func (s *DataUsageRecordStore) CreateBatch(ctx context.Context, records []*model.DataUsageRecord) error {
if len(records) == 0 {
return nil
}
return s.db.WithContext(ctx).CreateInBatches(records, 100).Error
}
// GetLatestByCardID 获取卡的最新流量记录
func (s *DataUsageRecordStore) GetLatestByCardID(ctx context.Context, cardID uint) (*model.DataUsageRecord, error) {
var record model.DataUsageRecord
if err := s.db.WithContext(ctx).
Where("iot_card_id = ?", cardID).
Order("check_time DESC").
First(&record).Error; err != nil {
return nil, err
}
return &record, nil
}
// ListByCardID 获取卡的流量记录列表
func (s *DataUsageRecordStore) ListByCardID(ctx context.Context, cardID uint, startTime, endTime *time.Time, page, pageSize int) ([]*model.DataUsageRecord, int64, error) {
var records []*model.DataUsageRecord
var total int64
query := s.db.WithContext(ctx).Model(&model.DataUsageRecord{}).Where("iot_card_id = ?", cardID)
if startTime != nil {
query = query.Where("check_time >= ?", *startTime)
}
if endTime != nil {
query = query.Where("check_time <= ?", *endTime)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
offset := (page - 1) * pageSize
if err := query.Order("check_time DESC").Offset(offset).Limit(pageSize).Find(&records).Error; err != nil {
return nil, 0, err
}
return records, total, nil
}
// DeleteOlderThan 删除指定时间之前的记录(用于数据清理)
func (s *DataUsageRecordStore) DeleteOlderThan(ctx context.Context, before time.Time, batchSize int) (int64, error) {
result := s.db.WithContext(ctx).
Where("check_time < ?", before).
Limit(batchSize).
Delete(&model.DataUsageRecord{})
return result.RowsAffected, result.Error
}
// CountByCardID 统计卡的流量记录数量
func (s *DataUsageRecordStore) CountByCardID(ctx context.Context, cardID uint) (int64, error) {
var count int64
if err := s.db.WithContext(ctx).Model(&model.DataUsageRecord{}).
Where("iot_card_id = ?", cardID).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}

View File

@@ -0,0 +1,151 @@
package task
import (
"context"
"fmt"
"strconv"
"strings"
"time"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
)
// DailyTrafficFlushHandler 每日流量落盘任务处理器
// 凌晨 2 点分批 SCAN 昨日 Redis key → UPSERT 到 tb_card_daily_usage → 删 key
type DailyTrafficFlushHandler struct {
redis *redis.Client
cardDailyUsageStore *postgres.CardDailyUsageStore
logger *zap.Logger
}
func NewDailyTrafficFlushHandler(
redisClient *redis.Client,
cardDailyUsageStore *postgres.CardDailyUsageStore,
logger *zap.Logger,
) *DailyTrafficFlushHandler {
return &DailyTrafficFlushHandler{
redis: redisClient,
cardDailyUsageStore: cardDailyUsageStore,
logger: logger,
}
}
func (h *DailyTrafficFlushHandler) HandleDailyTrafficFlush(ctx context.Context, _ *asynq.Task) error {
startTime := time.Now()
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
pattern := fmt.Sprintf("traffic:daily:*:%s", yesterday)
h.logger.Info("开始每日流量落盘",
zap.String("date", yesterday),
zap.String("pattern", pattern))
// 分批 SCAN 收集所有昨日 key
var allKeys []string
var cursor uint64
for {
keys, nextCursor, err := h.redis.Scan(ctx, cursor, pattern, 500).Result()
if err != nil {
h.logger.Error("SCAN Redis key 失败", zap.Error(err))
return err
}
allKeys = append(allKeys, keys...)
cursor = nextCursor
if cursor == 0 {
break
}
}
if len(allKeys) == 0 {
h.logger.Info("无需落盘,昨日无流量数据", zap.String("date", yesterday))
return nil
}
h.logger.Info("收集到待落盘 key",
zap.String("date", yesterday),
zap.Int("count", len(allKeys)))
yesterdayDate, _ := time.Parse("2006-01-02", yesterday)
// 分批处理:每批 200 条
flushedCount := 0
for i := 0; i < len(allKeys); i += 200 {
end := i + 200
if end > len(allKeys) {
end = len(allKeys)
}
batch := allKeys[i:end]
var records []*model.CardDailyUsage
var flushedKeys []string
for _, key := range batch {
cardID, err := parseCardIDFromKey(key)
if err != nil {
h.logger.Warn("解析 Redis key 失败", zap.String("key", key), zap.Error(err))
continue
}
val, err := h.redis.Get(ctx, key).Float64()
if err != nil {
h.logger.Warn("读取 Redis 值失败", zap.String("key", key), zap.Error(err))
continue
}
records = append(records, &model.CardDailyUsage{
IotCardID: cardID,
Date: yesterdayDate,
UsageMB: val,
})
flushedKeys = append(flushedKeys, key)
}
// 批量 UPSERT 到 DB
if len(records) > 0 {
if err := h.cardDailyUsageStore.UpsertBatch(ctx, records); err != nil {
h.logger.Error("批量 UPSERT 失败",
zap.Int("batch_size", len(records)),
zap.Error(err))
continue
}
}
// 批量删除已落盘的 Redis key
if len(flushedKeys) > 0 {
pipe := h.redis.Pipeline()
for _, key := range flushedKeys {
pipe.Del(ctx, key)
}
if _, err := pipe.Exec(ctx); err != nil {
h.logger.Warn("批量删除 Redis key 失败", zap.Error(err))
}
}
flushedCount += len(records)
}
h.logger.Info("每日流量落盘完成",
zap.String("date", yesterday),
zap.Int("flushed_count", flushedCount),
zap.Duration("duration", time.Since(startTime)))
return nil
}
// parseCardIDFromKey 从 Redis key "traffic:daily:{cardID}:{date}" 中解析 cardID
func parseCardIDFromKey(key string) (uint, error) {
parts := strings.Split(key, ":")
if len(parts) != 4 {
return 0, fmt.Errorf("invalid key format: %s", key)
}
id, err := strconv.ParseUint(parts[2], 10, 64)
if err != nil {
return 0, err
}
return uint(id), nil
}

View File

@@ -35,7 +35,6 @@ type PollingHandler struct {
iotCardStore *postgres.IotCardStore
concurrencyStore *postgres.PollingConcurrencyConfigStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
dataUsageRecordStore *postgres.DataUsageRecordStore
packageUsageStore *postgres.PackageUsageStore
usageService *packagepkg.UsageService
logger *zap.Logger
@@ -58,7 +57,6 @@ func NewPollingHandler(
iotCardStore: postgres.NewIotCardStore(db, redis),
concurrencyStore: postgres.NewPollingConcurrencyConfigStore(db),
deviceSimBindingStore: postgres.NewDeviceSimBindingStore(db, redis),
dataUsageRecordStore: postgres.NewDataUsageRecordStore(db),
packageUsageStore: postgres.NewPackageUsageStore(db, redis),
usageService: usageService,
logger: logger,
@@ -253,14 +251,10 @@ func (h *PollingHandler) HandleCarddataCheck(ctx context.Context, t *asynq.Task)
zap.Uint64("card_id", cardID))
}
// 计算流量增量(处理跨月)
now := time.Now()
updates := h.calculateFlowUpdates(card, gatewayFlowMB, now)
updates, flowIncrementMB := h.calculateFlowUpdates(card, gatewayFlowMB, now)
updates["last_data_check_at"] = now
// 计算本次流量增量(用于套餐扣减)
flowIncrementMB := h.calculateFlowIncrement(card, gatewayFlowMB, now)
// 更新数据库
if err := h.db.Model(&model.IotCard{}).
Where("id = ?", cardID).
@@ -268,8 +262,7 @@ func (h *PollingHandler) HandleCarddataCheck(ctx context.Context, t *asynq.Task)
h.logger.Error("更新卡流量信息失败", zap.Uint64("card_id", cardID), zap.Error(err))
}
// 插入流量历史记录(异步,不阻塞主流程)
go h.insertDataUsageRecord(context.Background(), uint(cardID), gatewayFlowMB, card.CurrentMonthUsageMB, now, payload.IsManual)
go h.insertDataUsageRecord(context.Background(), uint(cardID), flowIncrementMB, now)
// 更新 Redis 缓存
h.updateCardCache(ctx, uint(cardID), map[string]any{
@@ -308,26 +301,28 @@ func (h *PollingHandler) HandleCarddataCheck(ctx context.Context, t *asynq.Task)
return h.requeueCard(ctx, uint(cardID), constants.TaskTypePollingCarddata)
}
// calculateFlowIncrement 任务 18.2: 计算本次流量增量
func (h *PollingHandler) calculateFlowIncrement(card *model.IotCard, gatewayFlowMB float64, now time.Time) float64 {
// 获取本月1号
currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
// 判断是否跨月
isCrossMonth := card.CurrentMonthStartDate == nil ||
card.CurrentMonthStartDate.Before(currentMonthStart)
if isCrossMonth {
// 跨月了:本月流量就是增量
return gatewayFlowMB
// getCarrierResetDay 读取运营商的上游流量重置日
func (h *PollingHandler) getCarrierResetDay(carrierID uint) int {
var carrier model.Carrier
if err := h.db.Select("data_reset_day").First(&carrier, carrierID).Error; err != nil {
return 1
}
// 同月内:计算增量
increment := gatewayFlowMB - card.CurrentMonthUsageMB
if increment < 0 {
return 0
if carrier.DataResetDay == 0 {
return 1
}
return increment
return carrier.DataResetDay
}
// isResetWindow 判断今天是否在运营商重置日窗口内
// 窗口 = 重置日当天 + 前一天(容错网关数据延迟)
func isResetWindow(now time.Time, resetDay int) bool {
today := now.Day()
if today == resetDay {
return true
}
resetDate := time.Date(now.Year(), now.Month(), resetDay, 0, 0, 0, 0, now.Location())
prevDay := resetDate.AddDate(0, 0, -1).Day()
return today == prevDay
}
// shouldStopCard 任务 18.4: 检查是否应该停机(所有套餐用完)
@@ -389,47 +384,64 @@ func (h *PollingHandler) stopCardByUsageExhausted(ctx context.Context, card *mod
zap.String("iccid", card.ICCID))
}
// calculateFlowUpdates 计算流量更新值(处理跨月逻辑
func (h *PollingHandler) calculateFlowUpdates(card *model.IotCard, gatewayFlowMB float64, now time.Time) map[string]any {
// calculateFlowUpdates 计算流量更新值和增量(合并原 calculateFlowUpdates 和 calculateFlowIncrement
// 增量算法increment = 当前网关读数 - 上次网关读数,检测上游重置和跨自然月
func (h *PollingHandler) calculateFlowUpdates(card *model.IotCard, gatewayFlowMB float64, now time.Time) (map[string]any, float64) {
updates := make(map[string]any)
// 获取本月1号
currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
// 增量 = 当前读数 - 上次读数
increment := gatewayFlowMB - card.LastGatewayReadingMB
// 判断是否跨月
if increment < 0 {
// 当前值比上次小,检查是否为上游运营商正常重置
resetDay := h.getCarrierResetDay(card.CarrierID)
if isResetWindow(now, resetDay) {
// 在重置日窗口内,视为正常上游重置,本次原始值即为增量
increment = gatewayFlowMB
h.logger.Info("检测到上游运营商重置",
zap.Uint("card_id", card.ID),
zap.Float64("gateway_flow", gatewayFlowMB),
zap.Float64("last_reading", card.LastGatewayReadingMB),
zap.Int("reset_day", resetDay))
} else {
// 非重置日出现值下降,异常,不计入
h.logger.Warn("流量异常:非重置日出现值下降",
zap.Uint("card_id", card.ID),
zap.Float64("gateway_flow", gatewayFlowMB),
zap.Float64("last_reading", card.LastGatewayReadingMB))
increment = 0
}
}
// 始终更新网关读数
updates["last_gateway_reading_mb"] = gatewayFlowMB
// 检测跨自然月
currentMonthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
isCrossMonth := card.CurrentMonthStartDate == nil ||
card.CurrentMonthStartDate.Before(currentMonthStart)
if isCrossMonth {
// 跨月了:保存上月总量,重置本月
h.logger.Info("检测到跨月,重置流量计数",
zap.Uint("card_id", card.ID),
zap.Float64("last_month_total", card.CurrentMonthUsageMB),
zap.Float64("new_month_usage", gatewayFlowMB))
// 计算本次增量:上月最后值 + 本月当前值
increment := card.CurrentMonthUsageMB + gatewayFlowMB
zap.Float64("last_month_total", card.CurrentMonthUsageMB))
updates["last_month_total_mb"] = card.CurrentMonthUsageMB
updates["current_month_start_date"] = currentMonthStart
updates["current_month_usage_mb"] = gatewayFlowMB
updates["data_usage_mb"] = card.DataUsageMB + int64(increment)
} else {
// 同月内:计算增量
increment := gatewayFlowMB - card.CurrentMonthUsageMB
if increment < 0 {
// Gateway 返回值比记录的小,可能是数据异常,不更新
h.logger.Warn("流量异常Gateway返回值小于记录值",
zap.Uint("card_id", card.ID),
zap.Float64("gateway_flow", gatewayFlowMB),
zap.Float64("recorded_flow", card.CurrentMonthUsageMB))
increment = 0
}
updates["current_month_usage_mb"] = gatewayFlowMB
// 跨月重置为 0再加上本次增量
if increment > 0 {
updates["data_usage_mb"] = card.DataUsageMB + int64(increment)
updates["current_month_usage_mb"] = increment
} else {
updates["current_month_usage_mb"] = float64(0)
}
} else if increment > 0 {
// 同月内,增量累加(使用 gorm.Expr 保证原子性)
updates["current_month_usage_mb"] = gorm.Expr("current_month_usage_mb + ?", increment)
}
// 更新卡生命周期总用量
if increment > 0 {
updates["data_usage_mb"] = gorm.Expr("data_usage_mb + ?", int64(increment))
}
// 首次流量查询初始化
@@ -437,7 +449,7 @@ func (h *PollingHandler) calculateFlowUpdates(card *model.IotCard, gatewayFlowMB
updates["current_month_start_date"] = currentMonthStart
}
return updates
return updates, increment
}
// HandlePackageCheck 处理套餐检查任务
@@ -813,44 +825,24 @@ func (h *PollingHandler) updateStats(ctx context.Context, taskType string, succe
_, _ = pipe.Exec(ctx)
}
// insertDataUsageRecord 插入流量历史记录
func (h *PollingHandler) insertDataUsageRecord(ctx context.Context, cardID uint, currentUsageMB, previousUsageMB float64, checkTime time.Time, isManual bool) {
// 计算流量增量
var dataIncreaseMB int64
if currentUsageMB > previousUsageMB {
dataIncreaseMB = int64(currentUsageMB - previousUsageMB)
// insertDataUsageRecord 写入日流量缓冲到 Redis
// 增量 <= 0 时直接跳过;增量 > 0 时 INCRBYFLOAT 写 Redis + 48h TTL
func (h *PollingHandler) insertDataUsageRecord(ctx context.Context, cardID uint, incrementMB float64, checkTime time.Time) {
if incrementMB <= 0 {
return
}
// 确定数据来源
source := "polling"
if isManual {
source = "manual"
}
dateStr := checkTime.Format("2006-01-02")
key := constants.RedisCardDailyTrafficKey(cardID, dateStr)
// 创建流量记录
record := &model.DataUsageRecord{
IotCardID: cardID,
DataUsageMB: int64(currentUsageMB),
DataIncreaseMB: dataIncreaseMB,
CheckTime: checkTime,
Source: source,
}
// 插入记录
if err := h.dataUsageRecordStore.Create(ctx, record); err != nil {
h.logger.Warn("插入流量历史记录失败",
if err := h.redis.IncrByFloat(ctx, key, incrementMB).Err(); err != nil {
h.logger.Warn("写入日流量缓冲失败",
zap.Uint("card_id", cardID),
zap.Int64("data_usage_mb", record.DataUsageMB),
zap.Int64("data_increase_mb", record.DataIncreaseMB),
zap.String("source", source),
zap.Float64("increment_mb", incrementMB),
zap.Error(err))
} else {
h.logger.Debug("流量历史记录已插入",
zap.Uint("card_id", cardID),
zap.Int64("data_usage_mb", record.DataUsageMB),
zap.Int64("data_increase_mb", record.DataIncreaseMB),
zap.String("source", source))
return
}
h.redis.Expire(ctx, key, 48*time.Hour)
}
// updateCardCache 更新卡缓存
@@ -910,6 +902,16 @@ func (h *PollingHandler) getCardWithCache(ctx context.Context, cardID uint) (*mo
card.CurrentMonthUsageMB = usage
}
}
if v, ok := result["last_gateway_reading_mb"]; ok {
if reading, err := strconv.ParseFloat(v, 64); err == nil {
card.LastGatewayReadingMB = reading
}
}
if v, ok := result["data_usage_mb"]; ok {
if usage, err := strconv.ParseInt(v, 10, 64); err == nil {
card.DataUsageMB = usage
}
}
if v, ok := result["series_id"]; ok {
if id, err := strconv.ParseUint(v, 10, 64); err == nil {
seriesID := uint(id)
@@ -930,14 +932,16 @@ func (h *PollingHandler) getCardWithCache(ctx context.Context, cardID uint) (*mo
go func() {
cacheCtx := context.Background()
cacheData := map[string]any{
"id": card.ID,
"iccid": card.ICCID,
"card_category": card.CardCategory,
"real_name_status": card.RealNameStatus,
"network_status": card.NetworkStatus,
"carrier_id": card.CarrierID,
"current_month_usage_mb": card.CurrentMonthUsageMB,
"cached_at": time.Now().Unix(),
"id": card.ID,
"iccid": card.ICCID,
"card_category": card.CardCategory,
"real_name_status": card.RealNameStatus,
"network_status": card.NetworkStatus,
"carrier_id": card.CarrierID,
"current_month_usage_mb": card.CurrentMonthUsageMB,
"last_gateway_reading_mb": card.LastGatewayReadingMB,
"data_usage_mb": card.DataUsageMB,
"cached_at": time.Now().Unix(),
}
if card.SeriesID != nil {
cacheData["series_id"] = *card.SeriesID

View File

@@ -0,0 +1 @@
ALTER TABLE tb_carrier DROP COLUMN IF EXISTS data_reset_day;

View File

@@ -0,0 +1,8 @@
-- 运营商新增上游流量重置日字段
-- 用于检测上游网关流量值下降是否为正常自然月重置(与套餐级 data_reset_cycle 完全独立)
ALTER TABLE tb_carrier ADD COLUMN data_reset_day INT NOT NULL DEFAULT 1;
COMMENT ON COLUMN tb_carrier.data_reset_day IS '上游流量重置日(1-28),运营商每月清零网关计数器的日期';
-- 初始化已有运营商:联通=27其余=1
UPDATE tb_carrier SET data_reset_day = 27 WHERE carrier_type = 'CUCC';
UPDATE tb_carrier SET data_reset_day = 1 WHERE carrier_type != 'CUCC';

View File

@@ -0,0 +1 @@
ALTER TABLE tb_iot_card DROP COLUMN IF EXISTS last_gateway_reading_mb;

View File

@@ -0,0 +1,4 @@
-- IoT 卡新增上次网关读数字段
-- 用于增量算法increment = 当前读数 - 上次读数
ALTER TABLE tb_iot_card ADD COLUMN last_gateway_reading_mb FLOAT NOT NULL DEFAULT 0;
COMMENT ON COLUMN tb_iot_card.last_gateway_reading_mb IS '上次轮询时网关返回的流量读数(MB),用于计算增量';

View File

@@ -0,0 +1,2 @@
-- 回滚:重置所有卡的 last_gateway_reading_mb 为 0
UPDATE tb_iot_card SET last_gateway_reading_mb = 0 WHERE last_gateway_reading_mb > 0;

View File

@@ -0,0 +1,5 @@
-- 数据初始化:将所有卡的 last_gateway_reading_mb 设为当前 current_month_usage_mb
-- 避免上线后第一次轮询把整个已用量当作增量
UPDATE tb_iot_card
SET last_gateway_reading_mb = current_month_usage_mb
WHERE last_gateway_reading_mb = 0 AND current_month_usage_mb > 0;

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS tb_card_daily_usage;

View File

@@ -0,0 +1,10 @@
CREATE TABLE IF NOT EXISTS tb_card_daily_usage (
id BIGSERIAL PRIMARY KEY,
iot_card_id BIGINT NOT NULL,
date DATE NOT NULL,
usage_mb NUMERIC(12,2) NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT uk_card_daily UNIQUE (iot_card_id, date)
);
CREATE INDEX IF NOT EXISTS idx_card_daily_date ON tb_card_daily_usage (date);

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-28

View File

@@ -0,0 +1,188 @@
## Context
轮询系统每 5-15 分钟调用网关获取所有卡的流量数据,写入 DB。当前问题
1. 每次轮询无条件写详单记录,增量=0 也写DB 膨胀严重P2-26
2. `current_month_usage_mb` 直接用网关值覆盖上游自然月重置时我们的值也归零P2-27
核心改造对象:`internal/task/polling_handler.go` 中的 `calculateFlowUpdates()``calculateFlowIncrement()``insertDataUsageRecord()`
## Goals / Non-Goals
**Goals:**
- 月流量改为增量累加不受上游自然月重置影响E-2
- 流量详单减少 90%+ 的无效写入E-1
- 查询层无缝兼容新数据源E-3
- 清理旧流量详单代码路径(`tb_data_usage_record` 相关代码)
- 提供数据初始化脚本保证上线安全
**Non-Goals:**
- 不修改轮询调度逻辑(频率、并发控制不变)
- 不新增自动化测试
- 不修改套餐级重置逻辑(`ResetService` 不受影响,套餐的 `data_reset_cycle` 机制独立运行)
## Decisions
### 1. E-2 先于 E-1 的原因
**决策**先改增量算法E-2再改存储介质E-1
**原因**E-2 修的是"数据正确性"(覆盖 vs 累加E-1 修的是"写入效率"DB vs Redis。正确性比效率更紧迫。且 E-2 的 `increment` 变量是 E-1 Redis 写入的前提。
### 2. 合并 `calculateFlowUpdates()` 和 `calculateFlowIncrement()`
**决策**:合并为一个函数,返回 `(updates map[string]any, increment float64)`
**原因**:当前两个函数各自独立计算增量,用途不同:
- `calculateFlowUpdates()` → 更新卡的 `current_month_usage_mb``data_usage_mb` 等字段
- `calculateFlowIncrement()` → 算增量给 `DeductDataUsage()`(将增量记录到套餐已用量)
改造后两者使用同一个增量公式(`gatewayFlowMB - card.LastGatewayReadingMB`),维护两份重复逻辑有不一致风险。合并后调用方式:
```go
updates, increment := h.calculateFlowUpdates(card, gatewayFlowMB, now)
// updates 用于更新卡字段
// increment 用于套餐已用量记录
```
### 3. 运营商重置日检测算法(上游重置)
`data_reset_day` 是**运营商级别**字段(`tb_carrier.data_reset_day`),标识上游运营商每月清零网关计数器的日期。这跟我们系统套餐的 `data_reset_cycle`daily/monthly/yearly是两个完全独立的维度。
```go
// 增量 = 当前读数 - 上次读数
increment := gatewayFlowMB - card.LastGatewayReadingMB
if increment < 0 {
// 当前值比上次小 → 可能是上游运营商重置了
resetDay := getCarrierResetDay(card.CarrierID)
if isResetWindow(time.Now().Day(), resetDay) {
// 在重置日窗口内,视为正常上游重置
increment = gatewayFlowMB // 重置后原始值就是增量
} else {
// 非重置日出现值下降,异常,不计入
logger.Warn("流量异常:非重置日出现值下降")
increment = 0
}
}
```
**`isResetWindow` 精确定义**
```go
// isResetWindow 判断今天是否在运营商重置日窗口内
// 窗口 = 重置日当天 + 前一天(容错网关数据延迟)
// 例resetDay=27 → 窗口包含 26、27
// 例resetDay=1 → 窗口包含上月最后一天、1号
func isResetWindow(now time.Time, resetDay int) bool {
today := now.Day()
if today == resetDay {
return true
}
// 前一天容错:计算 resetDay 的前一天
resetDate := time.Date(now.Year(), now.Month(), resetDay, 0, 0, 0, 0, now.Location())
prevDay := resetDate.AddDate(0, 0, -1).Day()
return today == prevDay
}
```
### 4. 跨自然月重置逻辑
`current_month_usage_mb` 是**卡级字段**,记录这张卡本自然月的系统累计用量。跨自然月时需要重置。
改造后的跨月处理:
```go
if isCrossMonth {
updates["last_month_total_mb"] = card.CurrentMonthUsageMB
updates["current_month_start_date"] = currentMonthStart
updates["current_month_usage_mb"] = 0 // 新月从 0 开始累计(不再 = gatewayFlowMB
// increment 仍然正常计算(由 LastGatewayReadingMB 决定)
}
```
**注意**:这里的"自然月重置"是我们**系统级别**对 `current_month_usage_mb` 的重置。跟套餐级别的 `data_reset_cycle` 完全独立——套餐的已用量重置由 `ResetService``reset_service.go`)负责,重置的是 `PackageUsage.DataUsageMB`,不涉及卡级字段。
### 5. Redis 缓冲策略
- **Key 格式**`traffic:daily:{card_id}:{YYYY-MM-DD}`
- **操作**`INCRBYFLOAT`(原子增量)
- **TTL**48 小时(给落盘任务足够消费时间)
- **落盘频率**:每天凌晨 2 点Asynq SchedulerSCAN 昨日 key → UPSERT → DELETE
**为什么不用 Sorted Set** 每张卡每天只有一个值,简单 KV + INCRBYFLOAT 最高效。
### 6. 落盘 UPSERT 语义:覆盖(幂等安全)
```sql
INSERT INTO tb_card_daily_usage (iot_card_id, date, usage_mb)
VALUES ($1, $2, $3)
ON CONFLICT (iot_card_id, date)
DO UPDATE SET usage_mb = EXCLUDED.usage_mb, updated_at = NOW();
```
选择**覆盖**而非**累加**Redis `INCRBYFLOAT` 保证值是正确的累积值,落盘只是"从 Redis 搬到 DB"。重试时覆盖写结果不变,天然幂等。如果用累加,任务重试会导致数据翻倍。
### 7. 落盘分批策略
10 万张活跃卡 ≈ 10 万个 Redis key。分批处理避免单次操作过大
1. **SCAN 阶段**`SCAN cursor pattern COUNT 500`,分批收集所有昨日 key
2. **UPSERT 阶段**:每批 200 条,批量 UPSERT 到 DB
3. **DELETE 阶段**:每批 200 条Pipeline 批量删除已落盘 key
预估耗时10 万 key ≈ 30-60 秒。Asynq 任务超时设为 5 分钟。
### 8. 数据初始化脚本
上线前必须运行:
```sql
-- 将所有卡的 last_gateway_reading_mb 初始化为当前月用量
-- 避免上线后第一次轮询把整个已用量当作增量
UPDATE tb_iot_card
SET last_gateway_reading_mb = current_month_usage_mb
WHERE last_gateway_reading_mb = 0 AND current_month_usage_mb > 0;
```
此脚本放在迁移文件中但标注为"上线前人工确认执行"。
**新卡首次轮询**`last_gateway_reading_mb` 默认 0首次轮询增量 = 全量(网关返回值)。对于新入库的卡(从没用过)这是正确的。批量导入已有使用量的卡时,导入脚本应同步设置 `last_gateway_reading_mb = current_month_usage_mb`
### 9. 查询层兼容策略
`TrafficQueryService.GetDailyUsage()` 合并两段数据:
- 今天 → 从 Redis 读(可能还没落盘)
- 历史 → 从 `tb_card_daily_usage`
- 排序合并后返回
### 10. 旧流量详单代码清理
`tb_data_usage_record` 不再写入后,相关代码路径全部删除:
- `internal/model/data_usage.go`DataUsageRecord model
- `internal/store/postgres/data_usage_record_store.go`Store
- `internal/bootstrap/` 中的引用
- `scripts/cleanup/main.go` 中的引用
旧表数据暂保留在 DB后续可通过数据清理功能清除但代码层完全去除依赖。
### 11. 运营商管理接口变更
`tb_carrier` 新增 `data_reset_day INT NOT NULL DEFAULT 1`。创建/编辑运营商时新增此字段1-28前端显示为"每月 N 日重置流量"。
建议初始值:联通=27移动/电信/广电=1。可通过迁移 SQL 直接 UPDATE 已有运营商记录。
## Risks / Trade-offs
- **[Redis 丢数据]** Redis 重启导致今日缓冲丢失 → TTL 48h + 落盘任务可补偿;极端情况丢一天数据,业务可接受
- **[首次轮询增量暴增]** `last_gateway_reading_mb` 未初始化时第一次轮询增量=全量 → 通过初始化脚本消除
- **[重置日误判]** 运营商非预期时间重置 → 前一天容错 + Warn 日志,人工可介入
- **[落盘任务失败]** Redis key 在 48h TTL 内未消费 → 数据丢失。Asynq 有重试机制MaxRetry=3且可手动触发
- **[跨自然月边界]** 轮询恰好在 0 点附近:先检测跨月 → 重置 `current_month_usage_mb = 0` → 再计算增量。两步原子完成
## Migration Plan
1. **迁移 1**`ALTER TABLE tb_carrier ADD COLUMN data_reset_day`UPDATE 已有运营商初始值
2. **迁移 2**`ALTER TABLE tb_iot_card ADD COLUMN last_gateway_reading_mb`
3. **迁移 3**`CREATE TABLE tb_card_daily_usage``usage_mb` 使用 `NUMERIC(12,2)` 类型)
4. **数据初始化**:执行 `UPDATE tb_iot_card SET last_gateway_reading_mb = current_month_usage_mb`
5. **部署代码**:低峰期发布
6. **回滚策略**:如果增量算法异常,可临时切回覆盖写(保留旧代码为注释或 feature flag

View File

@@ -0,0 +1,57 @@
## Why
当前流量数据处理存在两个架构性问题:
1. **流量详单无差别写 DB**P2-26每次轮询无条件调用 `insertDataUsageRecord()`,即使增量为 0 也写记录,大量卡每日产生大量无意义记录,数据库膨胀。
2. **月流量直接覆盖**P2-27`calculateFlowUpdates()` 直接用网关返回值覆盖 `current_month_usage_mb`。上游运营商按自然月重置(联通 27 号,移动/电信/广电 1 号),重置后网关返回 0我们的套餐月用量也归零——但套餐周期未必与自然月一致导致用量数据错误。
这是架构改造,涉及 DB 迁移 + Redis 引入 + 轮询核心路径修改,**建议低峰期发布**。
## What Changes
- **E-2 月流量改增量累加**(先做):`tb_iot_card` 新增 `last_gateway_reading_mb` 字段记录上次网关读数,`tb_carrier` 新增 `data_reset_day` 字段记录运营商月重置日。合并 `calculateFlowUpdates()``calculateFlowIncrement()` 为一个函数,改为增量算法:`increment = 当前读数 - 上次读数`,检测到上游重置时特殊处理。`current_month_usage_mb` 从直接覆盖改为 `+= increment`。跨自然月时 `current_month_usage_mb` 重置为 0不再 `= gatewayFlowMB`)。**⚠️ 上线前需运行数据初始化脚本**,将所有卡的 `last_gateway_reading_mb` 设为当前 `current_month_usage_mb`
- **E-1 流量详单改日粒度缓冲**:新建 `tb_card_daily_usage` 表(每卡每天一条)。`insertDataUsageRecord()` 改为只有增量 > 0 时写 Redis`INCRBYFLOAT`**直接替换旧写入路径,不再写 `tb_data_usage_record`**。新增每日落盘 Asynq 定时任务,凌晨 2 点分批 SCAN 昨日 Redis key → 覆盖 UPSERT 到 `tb_card_daily_usage` → 删 key。
- **E-3 流量查询层适配**:新建 `TrafficQueryService`,查询时合并"今日 Redis + 历史 DB"两段数据。更新所有受影响的查询入口。
- **清理**:删除 `tb_data_usage_record` 相关代码model、store、bootstrap 引用)。
## Capabilities
### New Capabilities
- `traffic-daily-buffer`: 流量日粒度 Redis 缓冲 + 每日落盘机制
- `traffic-query-service`: 统一流量查询服务Redis + DB 合并)
### Modified Capabilities
- `package-usage-daily-record`: 流量详单从无差别写 DB 改为 Redis 缓冲 + 日落盘(直接替换旧路径)
- `carrier`: 运营商新增 `data_reset_day` 字段(上游重置日)
- `iot-card`: IoT 卡新增 `last_gateway_reading_mb` 字段,月流量改为增量累加,跨自然月重置为 0
## Impact
- **DB 迁移**3 个迁移文件(`tb_carrier` 加字段、`tb_iot_card` 加字段、新建 `tb_card_daily_usage` 表)
- **数据初始化**:上线前需运行脚本初始化 `last_gateway_reading_mb`
- **修改文件**`internal/task/polling_handler.go`(核心改动,合并两个增量计算函数)、`internal/model/iot_card.go``internal/model/carrier.go`、相关 DTO、运营商管理接口
- **新增文件**`internal/model/card_daily_usage.go``internal/store/postgres/card_daily_usage_store.go`(落盘+查询共用)、`internal/service/traffic/query_service.go`、日落盘任务 handler
- **删除文件**`internal/model/data_usage.go``internal/store/postgres/data_usage_record_store.go`(旧流量详单代码)
- **新增常量**Redis key`traffic:daily:{card_id}:{date}`)、任务类型(`TaskTypeDailyTrafficFlush`
- **Redis 新增用途**流量增量缓存INCRBYFLOAT + 48h TTL
- **轮询核心路径变更**:高风险,需低峰期发布
### 受影响的查询接口E-3 需逐一适配)
| # | 接口路径 | 文件 | 影响方式 |
|---|---------|------|---------|
| 1 | `GET /api/admin/assets/:type/:id/realtime-status` | `service/asset/service.go` | `CurrentMonthUsageMB` 语义变化 |
| 2 | `GET /api/admin/assets/resolve/:identifier` | `handler/admin/asset.go` | resolve 返回流量概况 |
| 3 | `GET /api/admin/assets/device/:id/realtime-status` | `service/asset/service.go` | 设备级聚合绑定卡用量 |
| 4 | `GET /api/c/v1/asset/info` | `handler/app/client_asset.go` | C端首页流量数据 |
| 5 | `GET /api/c/v1/asset/packages` | `handler/app/client_asset.go` | C端套餐用量展示 |
| 6 | `GET /api/c/v1/asset/refresh` | `handler/app/client_asset.go` | C端手动刷新 |
| 7 | `GET /api/admin/assets/:type/:id/packages` | `service/asset/service.go` | 管理端套餐列表 |
### 不受影响的模块(确认独立)
- **套餐级流量重置**`ResetService`):操作 `PackageUsage.DataUsageMB`,与卡级字段无关
- **套餐日记录**`tb_package_usage_daily_record`):套餐级日记录,由 `UsageService.updateDailyRecord()` 写入,与卡级 `tb_card_daily_usage` 是两个不同维度
- **套餐创建/管理**`data_reset_cycle` 配置不受影响

View File

@@ -0,0 +1,16 @@
## MODIFIED Requirements
### Requirement: 运营商上游流量重置日配置
`tb_carrier` SHALL 包含 `data_reset_day` 字段INT, 1-28表示该运营商每月上游流量重置日即上游运营商清零网关计数器的日期。创建/编辑运营商时 SHALL 支持设置此字段。
注意:此字段与套餐级别的 `data_reset_cycle`daily/monthly/yearly是**完全独立的两个维度**。`data_reset_day` 用于检测上游网关值下降是否为正常重置,`data_reset_cycle` 用于我们系统套餐的已用量定时归零。
#### Scenario: 创建运营商时指定重置日
- **WHEN** 管理员创建运营商,指定 `data_reset_day = 27`
- **THEN** 该运营商记录的 `data_reset_day` SHALL 为 27
#### Scenario: 轮询时读取重置日判断上游重置
- **WHEN** 轮询系统检测到网关流量值下降(`increment < 0`
- **THEN** 系统 SHALL 读取该卡对应运营商的 `data_reset_day`
- **AND** 使用 `isResetWindow(now, resetDay)` 判断是否在重置日窗口内(重置日当天 + 前一天容错)
- **AND** 窗口内视为正常上游重置,窗口外记录 Warn 日志并丢弃异常值

View File

@@ -0,0 +1,37 @@
## MODIFIED Requirements
### Requirement: IoT 卡网关读数记录
`tb_iot_card` SHALL 包含 `last_gateway_reading_mb` 字段FLOAT, 默认 0记录上次轮询时网关返回的流量读数用于计算增量。
#### Scenario: 轮询更新网关读数
- **WHEN** 轮询系统获取到网关流量值
- **THEN** 系统 SHALL 将 `last_gateway_reading_mb` 更新为本次网关返回值(无论增量是否 > 0
### Requirement: 月流量增量累加
`current_month_usage_mb` SHALL 使用增量累加(`+= increment`)而非直接覆盖(`= gatewayValue`)。增量 = 当前网关读数 - 上次网关读数(`last_gateway_reading_mb`)。
#### Scenario: 正常流量增长
- **WHEN** 上次读数 100MB本次读数 105MB
- **THEN** `current_month_usage_mb` SHALL 增加 5MB而非被覆盖为 105MB
- **AND** `data_usage_mb`卡生命周期总用量SHALL 同步增加 5MB
#### Scenario: 上游自然月重置
- **WHEN** 上次读数 500MB本次读数 10MB且在运营商重置日窗口内
- **THEN** `increment` SHALL 为 10MB本次原始值即为增量`current_month_usage_mb += 10`
#### Scenario: 非重置日异常下降
- **WHEN** 上次读数 500MB本次读数 10MB但不在重置日窗口内
- **THEN** `increment` SHALL 为 0记录 Warn 日志,`current_month_usage_mb` 不变
### Requirement: 跨自然月重置
当检测到系统跨自然月时,`current_month_usage_mb` SHALL 重置为 0不再等于 `gatewayFlowMB``last_month_total_mb` SHALL 记录上月累计值。
#### Scenario: 跨月轮询
- **WHEN** 上次轮询在 3 月,本次轮询在 4 月
- **THEN** `last_month_total_mb` = 原 `current_month_usage_mb``current_month_usage_mb` = 0`current_month_start_date` 更新为本月 1 日
### Requirement: 新卡首次轮询
新入库的卡 `last_gateway_reading_mb` 默认为 0首次轮询的增量 = 网关返回的全量值。这是预期行为。批量导入已有使用量的卡时,导入脚本应同步设置 `last_gateway_reading_mb`
### Requirement: 增量函数合并
`calculateFlowUpdates()``calculateFlowIncrement()` SHALL 合并为一个函数,返回 `(updates map[string]any, increment float64)`。消除两个独立增量计算函数的不一致风险。

View File

@@ -0,0 +1,16 @@
## MODIFIED Requirements
### Requirement: 流量详单存储方式
卡级流量详单 SHALL 从无差别直接写 DB 改为 Redis 缓冲 + 日落盘。`insertDataUsageRecord()` SHALL NOT 再创建 `DataUsageRecord` 记录,直接替换为 Redis 写入。
#### Scenario: 轮询写流量数据
- **WHEN** 轮询系统检测到卡的流量变化(增量 > 0
- **THEN** 系统 SHALL 仅写 Redis 缓冲(`INCRBYFLOAT`),不写 DB
#### Scenario: 旧代码清理
- **WHEN** 新存储路径完全就绪
- **THEN** SHALL 删除 `DataUsageRecord` model、`DataUsageRecordStore`、bootstrap 中的相关引用
- **AND** `tb_data_usage_record` 旧表数据暂保留在 DB后续通过数据清理功能清除代码层完全去除依赖
### Clarification: 套餐级日记录不受影响
`tb_package_usage_daily_record`(套餐级日记录)由 `UsageService.updateDailyRecord()` 在轮询将增量记录到套餐已用量时同步写入,与本次改造的卡级 `tb_card_daily_usage` 是两个完全不同的维度,互不影响。

View File

@@ -0,0 +1,30 @@
## ADDED Requirements
### Requirement: 流量增量写入 Redis 缓冲
轮询检测到流量增量 > 0 时,系统 SHALL 使用 `INCRBYFLOAT` 将增量写入 Redis key格式 `traffic:daily:{card_id}:{YYYY-MM-DD}`TTL 48 小时。增量 <= 0 时 SHALL NOT 写入。**直接替换旧的 `insertDataUsageRecord()` 写 DB 路径,不双写。**
#### Scenario: 有流量增量时写 Redis
- **WHEN** 轮询检测到卡 ID=100 今日流量增量为 5.2 MB
- **THEN** 系统 SHALL 执行 `INCRBYFLOAT traffic:daily:100:2026-03-28 5.2`,并设置 48h TTL
#### Scenario: 无流量增量时跳过
- **WHEN** 轮询检测到流量增量 <= 0
- **THEN** 系统 SHALL NOT 执行任何 Redis 或 DB 写入
### Requirement: 每日落盘定时任务
系统 SHALL 每天凌晨 2 点Asia/Shanghai通过 Asynq Scheduler 触发落盘任务。
#### Scenario: 正常落盘(分批处理)
- **WHEN** 凌晨 2 点落盘任务执行
- **THEN** 系统 SHALL 分批 SCAN 所有 `traffic:daily:*:{昨日}` key每批 COUNT 500
- **AND** 分批 UPSERT 到 `tb_card_daily_usage`(每批 200 条,**覆盖语义**保证幂等:`ON CONFLICT DO UPDATE SET usage_mb = EXCLUDED.usage_mb`
- **AND** 分批 Pipeline 删除已落盘的 Redis key
- **AND** 记录日志:落盘条数、耗时
#### Scenario: 落盘失败重试
- **WHEN** 落盘任务执行失败
- **THEN** Asynq SHALL 自动重试MaxRetry=3超时 5 分钟Redis key 因 48h TTL 仍可用
- **AND** 因 UPSERT 使用覆盖语义,重试时不会导致数据翻倍
### Requirement: tb_card_daily_usage 表结构
`usage_mb` 字段 SHALL 使用 `NUMERIC(12,2)` 类型(匹配 Redis `INCRBYFLOAT` 的浮点精度和现有 `current_month_usage_mb``decimal(10,2)` 类型),不使用 BIGINT。

View File

@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: 统一流量查询服务
`TrafficQueryService.GetDailyUsage()` SHALL 合并 Redis今日和 DB历史两段数据返回指定日期范围内的每日流量列表。
#### Scenario: 查询含今日的日期范围
- **WHEN** 查询 2026-03-25 到 2026-03-28今天
- **THEN** 系统 SHALL 从 `tb_card_daily_usage` 查 3/25~3/27从 Redis 查 3/28合并排序后返回
#### Scenario: 查询纯历史日期范围
- **WHEN** 查询 2026-03-01 到 2026-03-20
- **THEN** 系统 SHALL 仅从 `tb_card_daily_usage` 查询,不访问 Redis
### Requirement: CardDailyUsageStore 共用
落盘任务(`daily_traffic_flush.go`)和查询服务(`TrafficQueryService`SHALL 共用同一个 `CardDailyUsageStore`,避免重复实现 UPSERT 和查询逻辑。

View File

@@ -0,0 +1,125 @@
# 任务清单refactor-traffic-system
> ⚠️ 架构改造,涉及 DB 迁移 + 轮询核心路径修改,建议低峰期发布。
> 严格串行E-2增量算法→ E-1Redis 缓冲)→ E-3查询适配→ 清理。
## 任务组 1E-2 月流量改增量累加P2-27
### 1.1 DB 迁移 + Model 扩展
- [x] 1.1.1 新建迁移:`ALTER TABLE tb_carrier ADD COLUMN data_reset_day INT NOT NULL DEFAULT 1;`
- [x] 1.1.2 在同一迁移中 UPDATE 已有运营商:联通 `data_reset_day=27`,其余 `=1`(需确认现有运营商数据)
- [x] 1.1.3 新建迁移:`ALTER TABLE tb_iot_card ADD COLUMN last_gateway_reading_mb FLOAT NOT NULL DEFAULT 0;`
- [x] 1.1.4 `internal/model/carrier.go` 新增 `DataResetDay int` 字段
- [x] 1.1.5 `internal/model/iot_card.go` 新增 `LastGatewayReadingMB float64` 字段
- [x] 1.1.6 运营商相关 DTO 新增 `DataResetDay` 字段
- [x] 1.1.7 运营商 Create/Update Handler/Service 支持 `data_reset_day` 参数1-28 范围校验)
- [x] 1.1.8 执行迁移,通过 DBHub 确认字段已添加
- [x] 1.1.9 验证:`go build ./...` 编译通过
### 1.2 增量算法改写
- [x] 1.2.1 新增辅助方法 `getCarrierResetDay(carrierID uint) int`,从 DB/缓存读取运营商重置日
- [x] 1.2.2 新增辅助函数 `isResetWindow(now time.Time, resetDay int) bool`:窗口 = 重置日当天 + 前一天(容错网关延迟)。`resetDay=27` 时窗口含 26、27`resetDay=1` 时窗口含上月最后一天和 1 号
- [x] 1.2.3 **合并** `calculateFlowUpdates()``calculateFlowIncrement()` 为一个函数,签名改为 `calculateFlowUpdates(card, gatewayFlowMB, now) (map[string]any, float64)`,返回 `(updates, increment)`
- 计算增量 `increment = gatewayFlowMB - card.LastGatewayReadingMB`
- 检测上游运营商重置increment < 0 时用 `isResetWindow` 判断)
- 检测跨自然月:跨月时 `current_month_usage_mb = 0`(不再 `= gatewayFlowMB``last_month_total_mb = 旧值`
- 正常增量:`current_month_usage_mb` 改为 `gorm.Expr("current_month_usage_mb + ?", increment)`
- 更新 `data_usage_mb`(卡生命周期总用量):`gorm.Expr("data_usage_mb + ?", int64(increment))`
- 始终更新 `last_gateway_reading_mb = gatewayFlowMB`
- [x] 1.2.4 删除旧的 `calculateFlowIncrement()` 函数
- [x] 1.2.5 更新调用方:`HandleCarddataCheck()` 中改为使用合并后的返回值
```go
updates, flowIncrementMB := h.calculateFlowUpdates(card, gatewayFlowMB, now)
```
- [x] 1.2.6 验证:`go build ./...` 编译通过
### 1.3 数据初始化脚本
- [x] 1.3.1 新建迁移文件(或独立 SQL 脚本):`UPDATE tb_iot_card SET last_gateway_reading_mb = current_month_usage_mb WHERE last_gateway_reading_mb = 0 AND current_month_usage_mb > 0;`
- [x] 1.3.2 通过 DBHub 验证执行结果:`SELECT COUNT(*) FROM tb_iot_card WHERE last_gateway_reading_mb = 0 AND current_month_usage_mb > 0;` 应为 0
## 任务组 2E-1 流量详单改日粒度缓冲P2-26
### 2.1 日流量表 + Model + Store + Redis Key
- [x] 2.1.1 新建迁移:
```sql
CREATE TABLE tb_card_daily_usage (
id BIGSERIAL PRIMARY KEY,
iot_card_id BIGINT NOT NULL,
date DATE NOT NULL,
usage_mb NUMERIC(12,2) NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW(),
CONSTRAINT uk_card_daily UNIQUE (iot_card_id, date)
);
CREATE INDEX idx_card_daily_date ON tb_card_daily_usage (date);
```
- [x] 2.1.2 新建 `internal/model/card_daily_usage.go` GORM Model`UsageMB` 使用 `float64` 类型,对应 DB `NUMERIC(12,2)`
- [x] 2.1.3 新建 `internal/store/postgres/card_daily_usage_store.go`,实现 `Upsert()`覆盖语义ON CONFLICT DO UPDATE SET usage_mb = EXCLUDED.usage_mb和 `ListByCardIDAndDateRange()` 方法。**此 Store 同时供落盘任务2.3和查询层3.x使用**
- [x] 2.1.4 在 `pkg/constants/redis.go` 新增 `RedisCardDailyTrafficKey(cardID uint, date string) string`
- [x] 2.1.5 在 `pkg/constants/constants.go` 新增 `TaskTypeDailyTrafficFlush` 常量
- [x] 2.1.6 执行迁移,通过 DBHub 确认表已创建
- [x] 2.1.7 验证:`go build ./...` 编译通过
### 2.2 insertDataUsageRecord 改造
- [x] 2.2.1 改写 `insertDataUsageRecord()`:增量 <= 0 直接 return增量 > 0 时 `INCRBYFLOAT` 写 Redis + `Expire` 48h。**不再写 `tb_data_usage_record`**(直接替换,不双写)
- [x] 2.2.2 验证:`go build ./...` 编译通过
### 2.3 每日落盘任务
- [x] 2.3.1 新建 `internal/task/daily_traffic_flush.go`,实现落盘 Handler
- 分批 SCAN `traffic:daily:*:{昨日}`(每批 COUNT 500
- 解析 card_id 和 date收集所有 entry
- 分批 UPSERT 到 `tb_card_daily_usage`(每批 200 条,使用 2.1.3 的 `CardDailyUsageStore.Upsert()`,覆盖语义保证幂等)
- 分批 Pipeline 删除已落盘的 Redis key
- 记录日志:落盘条数、耗时
- [x] 2.3.2 在 `pkg/queue/handler.go` 中注册落盘 Handler
- [x] 2.3.3 在 Asynq Scheduler 中配置每天凌晨 2 点Asia/Shanghai触发 `TaskTypeDailyTrafficFlush`,超时设为 5 分钟
- [x] 2.3.4 验证:`go build ./...` 编译通过
## 任务组 3E-3 流量查询层适配P2-31
### 3.1 TrafficQueryService + bootstrap 注册
- [x] 3.1.1 新建 `internal/service/traffic/query_service.go`,实现 `GetDailyUsage(ctx, cardID, startDate, endDate)` 方法:今天 → Redis历史 → DB使用 2.1.3 的 `CardDailyUsageStore`),合并排序返回
- [x] 3.1.2 在 bootstrap 中注册 `TrafficQueryService`
### 3.2 管理端接口适配
- [x] 3.2.1 更新 `GET /api/admin/assets/:type/:id/realtime-status``service/asset/service.go``CurrentMonthUsageMB` 语义从"上游原始值"变为"系统累计值",确认注释和 DTO description 更新
- [x] 3.2.2 更新 `GET /api/admin/assets/resolve/:identifier``handler/admin/asset.go`resolve 返回的流量概况使用新语义
- [x] 3.2.3 更新 `GET /api/admin/assets/device/:id/realtime-status``service/asset/service.go`):设备级聚合绑定卡的 `CurrentMonthUsageMB`,确认聚合逻辑和注释更新
### 3.3 C端接口适配
- [x] 3.3.1 更新 `GET /api/c/v1/asset/info``handler/app/client_asset.go`C端首页资产信息中的流量数据使用新语义
- [x] 3.3.2 更新 `GET /api/c/v1/asset/packages``handler/app/client_asset.go`):套餐列表中的用量展示
- [x] 3.3.3 更新 `GET /api/c/v1/asset/refresh``handler/app/client_asset.go`):刷新后返回的流量数据
- [x] 3.3.4 更新 `GET /api/admin/assets/:type/:id/packages``service/asset/service.go`):管理端套餐列表
### 3.4 DTO 和注释更新
- [x] 3.4.1 更新 `AssetRealtimeStatusResponse.CurrentMonthUsageMB` 的 description 标签:从"Gateway返回的自然月流量总量"改为"系统累计的自然月流量"
- [x] 3.4.2 更新 `IotCard.CurrentMonthUsageMB` 的 GORM comment同上
- [x] 3.4.3 验证:`go build ./...` 编译通过
## 任务组 4旧流量详单代码清理
- [x] 4.1 删除 `internal/model/data_usage.go``DataUsageRecord` model
- [x] 4.2 删除 `internal/store/postgres/data_usage_record_store.go`
- [x] 4.3 清理 `internal/bootstrap/stores.go`、`services.go`、`worker_stores.go`、`worker_services.go`、`handlers.go` 中 `DataUsageRecord` 相关引用
- [x] 4.4 清理 `pkg/queue/types.go` 中 `DataUsageRecord` 相关字段
- [x] 4.5 清理 `scripts/cleanup/main.go` 中 `tb_data_usage_record` 引用(改为 `tb_card_daily_usage`
- [x] 4.6 删除 `internal/task/polling_handler.go` 中旧的 `insertDataUsageRecord` 相关的已废弃代码(如有残留)
- [x] 4.7 验证:`go build ./...` 编译通过
## 收尾验证
- [x] 5.1 执行 `go build ./...`,确认全量编译通过
- [x] 5.2 通过 DBHub 确认新增表和字段存在
- [x] 5.3 通过 `rg "RedisCardDailyTrafficKey\|TaskTypeDailyTrafficFlush\|TrafficQueryService\|CardDailyUsageStore"` 确认新组件已连通
- [x] 5.4 通过 `rg "DataUsageRecord\|data_usage_record"` 确认旧代码已清理(只允许出现在迁移文件或注释中)

View File

@@ -246,3 +246,18 @@ This capability supports:
- **WHEN** 开发人员查看代码
- **THEN** 方法命名(`CreateAdminOrder` vs `CreateH5Order`)清楚表明了后台和 H5 端的差异,防止误用
---
## MODIFIED Requirements
### Requirement: 后台订单钱包支付权限校验
后台创建订单时若支付方式为钱包支付Handler 层 SHALL 校验当前操作人为代理账号(`user_type = agent`)。非代理账号使用钱包支付 SHALL 在 Handler 层即被拦截,返回参数错误。
#### Scenario: 非代理账号使用钱包支付被拦截
- **WHEN** 平台用户或企业用户提交订单且 `payment_method = wallet`
- **THEN** Handler SHALL 返回 `CodeInvalidParam` 错误,提示"仅代理账号可使用钱包支付",请求不会到达 Service 层
#### Scenario: 代理账号使用钱包支付正常通过
- **WHEN** 代理账号提交订单且 `payment_method = wallet`
- **THEN** Handler SHALL 放行,由 Service 层继续处理钱包扣款逻辑

View File

@@ -170,3 +170,14 @@
- **WHEN** 调用 `RefreshCardDataFromGateway(ctx, "89860123456789012345")`
- **THEN** 系统调用网关接口,将 network_status、real_name_status、current_month_usage_mb、last_sync_time 写回 DB
---
## MODIFIED Requirements
### Requirement: 资产查询错误处理
资产查询 Service 在获取绑定卡信息时,数据库查询错误 SHALL 被记录到日志而非被静默忽略。查询失败 SHALL NOT 中断主查询流程,但 SHALL 记录 Warn 级别日志,包含失败的卡 ID 列表和错误详情。
#### Scenario: 绑定卡查询失败时记录日志
- **WHEN** `iotCardStore.GetByIDs()` 返回错误
- **THEN** 系统 SHALL 记录 Warn 日志(含 card_ids 和 error继续返回已有数据不返回错误给调用方

View File

@@ -172,3 +172,18 @@
- **WHEN** 前端调用 `POST /api/admin/enterprises/:id/cards/:card_id/suspend`
- **THEN** 系统返回 HTTP 404路由已不存在
---
## MODIFIED Requirements
### Requirement: 停复机回调注入
系统启动时 SHALL 在 bootstrap 阶段注入停复机回调,确保停机/复机操作完成后自动触发套餐联动逻辑(暂停/恢复套餐计时)。
#### Scenario: 系统启动后回调已注入
- **WHEN** API 或 Worker 服务完成 bootstrap 初始化
- **THEN** `usageService.SetStopResumeCallback(stopResumeService)``activationService.SetResumeCallback(stopResumeService)` SHALL 已被调用
#### Scenario: 停机触发套餐暂停
- **WHEN** 管理员对卡执行停机操作且回调已注入
- **THEN** 停机成功后 SHALL 自动触发套餐暂停联动逻辑

View File

@@ -1,151 +1,16 @@
# carrier Specification
## MODIFIED Requirements
## Purpose
管理运营商(Carrier)实体,支持四大固定运营商(中国移动、中国联通、中国电信、广电)的 CRUD 操作
### Requirement: 运营商上游流量重置日配置
`tb_carrier` SHALL 包含 `data_reset_day` 字段INT, 1-28表示该运营商每月上游流量重置日即上游运营商清零网关计数器的日期。创建/编辑运营商时 SHALL 支持设置此字段
## Requirements
注意:此字段与套餐级别的 `data_reset_cycle`daily/monthly/yearly是**完全独立的两个维度**。`data_reset_day` 用于检测上游网关值下降是否为正常重置,`data_reset_cycle` 用于我们系统套餐的已用量定时归零。
### Requirement: 运营商实体定义
系统 SHALL 定义运营商(Carrier)实体,管理四大固定运营商(中国移动、中国联通、中国电信、广电)。
**四大运营商固定枚举**
- **CMCC**:中国移动
- **CUCC**:中国联通
- **CTCC**:中国电信
- **CBN**:广电
**实体字段**
- `id`:运营商 ID主键BIGINT
- `carrier_code`运营商编码VARCHAR(50),唯一约束)
- `carrier_name`运营商名称VARCHAR(100),如"中国移动"
- `carrier_type`运营商类型VARCHAR(20),枚举值:"CMCC" | "CUCC" | "CTCC" | "CBN"
- `description`运营商描述VARCHAR(500),可选)
- `status`状态INT1-启用 0-禁用)
- `creator`:创建人 IDBIGINT
- `updater`:更新人 IDBIGINT
- `created_at`创建时间TIMESTAMP自动填充
- `updated_at`更新时间TIMESTAMP自动填充
- `deleted_at`删除时间TIMESTAMP可空软删除
**唯一约束**`carrier_code``deleted_at IS NULL` 条件下唯一
---
### Requirement: 创建运营商
系统 SHALL 允许管理员创建新的运营商记录。创建时必须指定 carrier_code唯一编码、carrier_name显示名称、carrier_type运营商类型枚举值。description 为选填字段。创建成功后默认状态为启用status=1
#### Scenario: 成功创建运营商
- **WHEN** 管理员提交有效的创建请求carrier_code 不重复carrier_type 为有效枚举值
- **THEN** 系统创建运营商记录,返回完整的运营商信息
#### Scenario: carrier_code 重复
- **WHEN** 管理员提交的 carrier_code 已存在
- **THEN** 系统返回错误"运营商编码已存在"
#### Scenario: carrier_type 无效
- **WHEN** 管理员提交的 carrier_type 不是 CMCC/CUCC/CTCC/CBN 之一
- **THEN** 系统返回参数校验错误
---
### Requirement: 查询运营商列表
系统 SHALL 提供分页查询运营商列表的接口,支持按 carrier_type、status、carrier_name模糊搜索筛选。
#### Scenario: 无筛选条件查询
- **WHEN** 管理员请求列表,不带筛选条件
- **THEN** 系统返回所有运营商的分页列表,按 ID 降序排列
#### Scenario: 按运营商类型筛选
- **WHEN** 管理员指定 carrier_type=CMCC
- **THEN** 系统仅返回 carrier_type 为 CMCC 的记录
#### Scenario: 按名称模糊搜索
- **WHEN** 管理员指定 carrier_name=移动
- **THEN** 系统返回 carrier_name 包含"移动"的记录
---
### Requirement: 获取运营商详情
系统 SHALL 允许管理员通过 ID 获取单个运营商的详细信息。
#### Scenario: 成功获取详情
- **WHEN** 管理员请求存在的运营商 ID
- **THEN** 系统返回该运营商的完整信息
#### Scenario: 运营商不存在
- **WHEN** 管理员请求不存在的运营商 ID
- **THEN** 系统返回错误"运营商不存在"
---
### Requirement: 更新运营商
系统 SHALL 允许管理员更新运营商的 carrier_name 和 description 字段。carrier_code 和 carrier_type 创建后不可修改。
#### Scenario: 成功更新运营商
- **WHEN** 管理员提交有效的更新请求
- **THEN** 系统更新运营商信息,返回更新后的完整信息
#### Scenario: 尝试修改 carrier_code
- **WHEN** 管理员尝试修改 carrier_code
- **THEN** 系统忽略该字段(不报错,但不修改)
---
### Requirement: 删除运营商
系统 SHALL 允许管理员软删除运营商记录。
#### Scenario: 成功删除运营商
- **WHEN** 管理员请求删除存在的运营商
- **THEN** 系统软删除该记录(设置 deleted_at
#### Scenario: 删除不存在的运营商
- **WHEN** 管理员请求删除不存在的运营商 ID
- **THEN** 系统返回错误"运营商不存在"
---
### Requirement: 更新运营商状态
系统 SHALL 允许管理员启用或禁用运营商。状态值1=启用0=禁用。
#### Scenario: 启用运营商
- **WHEN** 管理员将状态设置为 1
- **THEN** 系统更新运营商状态为启用
#### Scenario: 禁用运营商
- **WHEN** 管理员将状态设置为 0
- **THEN** 系统更新运营商状态为禁用
#### Scenario: 无效状态值
- **WHEN** 管理员提交的状态值不是 0 或 1
- **THEN** 系统返回参数校验错误
---
### Requirement: 运营商数据校验
系统 SHALL 对运营商数据进行校验,确保数据完整性和一致性。
**校验规则**
- `carrier_type`:必填,枚举值 "CMCC" | "CUCC" | "CTCC" | "CBN"
- `carrier_name`:必填,长度 1-100 字符
- `carrier_code`:必填,长度 1-50 字符
- `description`:可选,长度 0-500 字符
- `status`:必填,枚举值 0 或 1
#### Scenario: 创建运营商时 carrier_type 无效
- **WHEN** 创建运营商,`carrier_type` 为 "INVALID"
- **THEN** 系统拒绝创建,返回错误信息"运营商类型无效"
#### Scenario: 创建运营商时 carrier_name 为空
- **WHEN** 创建运营商,`carrier_name` 为空
- **THEN** 系统拒绝创建,返回错误信息"运营商名称不能为空"
#### Scenario: 创建运营商时指定重置日
- **WHEN** 管理员创建运营商,指定 `data_reset_day = 27`
- **THEN** 该运营商记录的 `data_reset_day` SHALL 为 27
#### Scenario: 轮询时读取重置日判断上游重置
- **WHEN** 轮询系统检测到网关流量值下降(`increment < 0`
- **THEN** 系统 SHALL 读取该卡对应运营商的 `data_reset_day`
- **AND** 使用 `isResetWindow(now, resetDay)` 判断是否在重置日窗口内(重置日当天 + 前一天容错)
- **AND** 窗口内视为正常上游重置,窗口外记录 Warn 日志并丢弃异常值

View File

@@ -44,3 +44,20 @@
#### Scenario: 异步任务连续失败
- **WHEN** AutoPurchaseAfterRecharge 连续执行失败且达到最大重试次数
- **THEN** 系统将充值记录 `auto_purchase_status` 更新为 `failed`
---
## MODIFIED Requirements
### Requirement: C端支付状态枚举统一
C端订单查询接口返回的 `payment_status` SHALL 直接使用管理端统一枚举值1=待支付, 2=已支付, 3=已取消, 4=已退款),不再通过映射函数转换为 0/1/2。
#### Scenario: C端查询订单返回统一枚举
- **WHEN** C端客户查询订单列表或订单详情
- **THEN** 返回的 `payment_status` SHALL 为 1/2/3/4 之一,与管理端一致
## REMOVED Requirements
### Requirement: C端支付状态映射
**Reason**: `orderStatusToClientStatus()` 函数将管理端 1/2/3/4 映射为 C端 0/1/2造成前后端枚举不一致增加维护负担。
**Migration**: C端前端需更新支付状态枚举解析1=待支付, 2=已支付, 3=已取消, 4=已退款。

View File

@@ -1,789 +1,37 @@
# IoT Card Management
## MODIFIED Requirements
## Purpose
### Requirement: IoT 卡网关读数记录
`tb_iot_card` SHALL 包含 `last_gateway_reading_mb` 字段FLOAT, 默认 0记录上次轮询时网关返回的流量读数用于计算增量。
Manage IoT cards (SIM cards) for the IoT management system, including inventory management, distribution, activation, status tracking, and Gateway integration.
#### Scenario: 轮询更新网关读数
- **WHEN** 轮询系统获取到网关流量值
- **THEN** 系统 SHALL 将 `last_gateway_reading_mb` 更新为本次网关返回值(无论增量是否 > 0
This capability supports:
- IoT card entity definition and lifecycle management
- Platform self-operation and agent distribution models
- Integration with Gateway project for real-time status synchronization
- Batch import and multi-dimensional querying
- Support for normal cards (require real-name verification) and industry cards (no real-name required)
## Requirements
### Requirement: IoT 卡实体定义
### Requirement: 月流量增量累加
`current_month_usage_mb` SHALL 使用增量累加(`+= increment`)而非直接覆盖(`= gatewayValue`)。增量 = 当前网关读数 - 上次网关读数(`last_gateway_reading_mb`)。
系统 SHALL 定义 IoT 卡(IotCard)实体,包含 IoT 卡(物联网卡/流量卡/SIM卡)的商品属性、状态属性、所有权信息和 Gateway 集成字段。
#### Scenario: 正常流量增长
- **WHEN** 上次读数 100MB本次读数 105MB
- **THEN** `current_month_usage_mb` SHALL 增加 5MB而非被覆盖为 105MB
- **AND** `data_usage_mb`卡生命周期总用量SHALL 同步增加 5MB
**核心概念**: IoT 卡 = 物联网卡 = SIM 卡 = 网卡 = 流量卡(同一个东西,不同叫法)。系统使用 ICCID 作为 IoT 卡的唯一标识。
#### Scenario: 上游自然月重置
- **WHEN** 上次读数 500MB本次读数 10MB且在运营商重置日窗口内
- **THEN** `increment` SHALL 为 10MB本次原始值即为增量`current_month_usage_mb += 10`
**卡业务类型**:
- **普通卡(normal)**: 需要实名认证才能激活使用,遵循运营商实名制要求
- **行业卡(industry)**: 不需要实名认证,可以直接激活使用,适用于企业/行业客户批量采购场景
#### Scenario: 非重置日异常下降
- **WHEN** 上次读数 500MB本次读数 10MB但不在重置日窗口内
- **THEN** `increment` SHALL 为 0记录 Warn 日志,`current_month_usage_mb` 不变
**实体字段**:
### Requirement: 跨自然月重置
当检测到系统跨自然月时,`current_month_usage_mb` SHALL 重置为 0不再等于 `gatewayFlowMB``last_month_total_mb` SHALL 记录上月累计值。
**商品属性**:
- `id`: IoT 卡 ID(主键,BIGINT)
- `iccid`: ICCID(VARCHAR(50),唯一,国际移动用户识别码,IoT卡的唯一标识)
- `card_type`: 卡类型(VARCHAR(50),如 "4G"、"5G"、"NB-IoT")
- `card_category`: 卡业务类型(VARCHAR(20),枚举值:"normal"-普通卡 | "industry"-行业卡,默认 "normal")
- `carrier_id`: 运营商 ID(BIGINT,关联 carriers 表,如中国移动、中国联通、中国电信)
- `imsi`: IMSI(VARCHAR(50),可选,国际移动用户识别码)
- `msisdn`: 手机号码(VARCHAR(20),可选)
- `batch_no`: 批次号(VARCHAR(100),用于批量导入追溯)
- `supplier`: 供应商名称(VARCHAR(255),可选)
- `cost_price`: 成本价(DECIMAL(10,2),平台进货价)
- `distribute_price`: 分销价(DECIMAL(10,2),分销给代理的价格,仅当 owner_type 为 agent 时有值)
#### Scenario: 跨月轮询
- **WHEN** 上次轮询在 3 月,本次轮询在 4 月
- **THEN** `last_month_total_mb` = 原 `current_month_usage_mb``current_month_usage_mb` = 0`current_month_start_date` 更新为本月 1 日
**所有权和状态**:
- `status`: IoT 卡状态(INT,1-在库 2-已分销 3-已激活 4-已停用)
- `owner_type`: 所有者类型(VARCHAR(20),"platform"-平台自营 | "agent"-代理商 | "user"-用户 | "device"-设备)
- `owner_id`: 所有者 ID(BIGINT,platform 时为 0,agent/user/device 时为对应的 ID)
- `activated_at`: 激活时间(TIMESTAMP,可空)
**Gateway 集成字段**(从 Gateway 项目同步):
- `activation_status`: 激活状态(INT,0-未激活 1-已激活)
- `real_name_status`: 实名状态(INT,0-未实名 1-已实名)
- `network_status`: 网络状态(INT,0-停机 1-开机)
- `data_usage_mb`: 累计流量使用(BIGINT,MB 为单位,默认 0)
- `last_sync_time`: 最后一次与 Gateway 同步时间(TIMESTAMP,可空)
**轮询控制字段**:
- `enable_polling`: 是否参与轮询(BOOLEAN,默认 true,用于控制是否对该卡进行定时轮询)
- `last_data_check_at`: 最后一次卡流量检查时间(TIMESTAMP,可空,记录上次轮询卡流量的时间)
- `last_real_name_check_at`: 最后一次实名检查时间(TIMESTAMP,可空,记录上次轮询实名状态的时间)
**系统字段**:
- `created_at`: 创建时间(TIMESTAMP,自动填充)
- `updated_at`: 更新时间(TIMESTAMP,自动填充)
#### Scenario: 创建平台自营 IoT 卡
- **WHEN** 平台批量导入 IoT 卡数据,ICCID 为 "89860123456789012345"
- **THEN** 系统创建 IoT 卡记录,`owner_type` 为 "platform",`owner_id` 为 0,状态为 1(在库),`activation_status` 为 0(未激活)
#### Scenario: 平台分销 IoT 卡给代理
- **WHEN** 平台将在库 IoT 卡分销给代理商(用户 ID 为 123),设置分销价为 50.00 元
- **THEN** 系统将 IoT 卡状态从 1(在库) 变更为 2(已分销),`owner_type` 变更为 "agent",`owner_id` 设置为 123,`distribute_price` 设置为 50.00
#### Scenario: IoT 卡绑定到设备
- **WHEN** 用户将 IoT 卡(ICCID 为 "8986...")绑定到设备(ID 为 1001)
- **THEN** 系统在 `device_sim_bindings` 表创建绑定记录,IoT 卡的 `owner_type` 变更为 "device",`owner_id` 变更为 1001
#### Scenario: IoT 卡直接销售给用户
- **WHEN** 平台或代理将 IoT 卡直接销售给用户(用户 ID 为 2001)
- **THEN** 系统创建套餐订单记录,IoT 卡的 `owner_type` 变更为 "user",`owner_id` 变更为 2001
#### Scenario: 行业卡无需实名认证
- **WHEN** 创建卡业务类型为 "industry"(行业卡)的 IoT 卡
- **THEN** 系统允许该卡在 `real_name_status` 为 0(未实名)的情况下激活使用,不强制要求实名认证
#### Scenario: 普通卡需要实名认证
- **WHEN** 创建卡业务类型为 "normal"(普通卡)的 IoT 卡
- **THEN** 系统要求该卡必须先完成实名认证(`real_name_status` 为 1)才能激活使用
---
### Requirement: IoT 卡状态流转
系统 SHALL 管理 IoT 卡的状态流转,确保状态变更符合业务规则。
**状态定义**:
- **1-在库**: IoT 卡在平台库存中,未分销
- **2-已分销**: IoT 卡已分销给代理商,代理可销售
- **3-已激活**: IoT 卡已被终端用户激活使用
- **4-已停用**: IoT 卡已停用,不可使用
**状态流转规则**:
- 在库(1) → 已分销(2): 平台分销给代理
- 在库(1) → 已激活(3): 平台自营直接销售给用户并激活
- 已分销(2) → 已激活(3): 代理销售给用户并激活
- 已激活(3) → 已停用(4): 用户或平台主动停用
- 已停用(4) → 已激活(3): 用户或平台主动复机(仅在符合业务规则时)
#### Scenario: 代理销售 IoT 卡给用户
- **WHEN** 代理商销售已分销 IoT 卡给终端用户并激活
- **THEN** 系统将 IoT 卡状态从 2(已分销) 变更为 3(已激活),`activated_at` 记录激活时间,`activation_status` 从 Gateway 同步后变更为 1
#### Scenario: 平台自营销售 IoT 卡
- **WHEN** 平台直接销售在库 IoT 卡给终端用户并激活
- **THEN** 系统将 IoT 卡状态从 1(在库) 变更为 3(已激活),`owner_type` 保持 "platform",`activated_at` 记录激活时间
#### Scenario: 停用已激活 IoT 卡
- **WHEN** 用户或平台停用已激活 IoT 卡
- **THEN** 系统将 IoT 卡状态从 3(已激活) 变更为 4(已停用),通过 Gateway API 执行停机操作
---
### Requirement: IoT 卡平台自营和代理分销
系统 SHALL 支持 IoT 卡的平台自营销售和代理分销两种模式,通过 `owner_type``owner_id` 区分所有者。
**平台自营**:
- `owner_type` 为 "platform"
- `owner_id` 为 0
- 平台直接销售给终端用户
- 销售价格由平台自主定价
**代理分销**:
- `owner_type` 为 "agent"
- `owner_id` 为代理用户 ID
- 代理商可以销售给终端用户或下级代理
- 分销价格由平台设置(`distribute_price`),代理商可在分销价基础上加价(但不能超过 2 倍)
#### Scenario: 查询平台自营 IoT 卡库存
- **WHEN** 查询平台自营 IoT 卡库存
- **THEN** 系统返回 `owner_type` 为 "platform" 且 `status` 为 1(在库) 的 IoT 卡列表
#### Scenario: 查询代理分销 IoT 卡库存
- **WHEN** 代理商(用户 ID 为 123)查询自己的 IoT 卡库存
- **THEN** 系统返回 `owner_type` 为 "agent" 且 `owner_id` 为 123 且 `status` 为 2(已分销) 的 IoT 卡列表
#### Scenario: 代理加价销售 IoT 卡套餐
- **WHEN** 代理商为已分销 IoT 卡设置套餐售价
- **THEN** 系统校验套餐售价不超过分销价的 2 倍,校验通过后允许销售
---
### Requirement: IoT 卡批量导入
系统 SHALL 支持通过 CSV 文件批量导入 IoT 卡 ICCID,支持大批量数据(几万条),异步处理并跟踪导入进度。
**导入方式**:
- 上传 CSV 文件,每行一个 ICCID
- 在界面选择运营商、批次号等公共参数
- 不支持一次导入多种运营商的卡
**导入参数**:
- CSV 文件(必填): 仅包含 ICCID 列
- 运营商 ID(必填): 在界面选择
- 批次号(可选): 在界面填写
**校验规则**:
- ICCID 格式校验: 字母数字混合,长度根据运营商(电信19位,其他20位)
- ICCID 唯一性校验: 重复 ICCID 跳过,不中断导入
**处理规则**:
- 异步处理: 创建导入任务后立即返回任务 ID
- 分批处理: 每批 1000 条
- 重复处理: 跳过已存在的 ICCID,记录跳过原因
- 格式错误: 记录失败原因,继续处理其他行
**导入结果**:
- 总数(total_count)
- 成功数(success_count)
- 跳过数(skip_count): 因重复等原因跳过
- 失败数(fail_count): 因格式错误等原因失败
- 跳过详情: 包含行号、ICCID、原因
- 失败详情: 包含行号、ICCID、原因
#### Scenario: 发起 IoT 卡批量导入
- **WHEN** 管理员上传包含 10000 个 ICCID 的 CSV 文件,选择运营商为电信,批次号为 "BATCH-2025-001"
- **THEN** 系统创建导入任务,返回任务 ID,后台异步处理导入
#### Scenario: 导入时跳过重复 ICCID
- **WHEN** CSV 文件中的 ICCID "8986001234567890123" 已存在于系统中
- **THEN** 系统跳过该 ICCID,记录跳过原因为"ICCID 已存在",继续处理其他 ICCID
#### Scenario: 导入时记录格式错误
- **WHEN** CSV 文件第 100 行的 ICCID "12345" 长度不符合电信卡要求(19位)
- **THEN** 系统记录失败,原因为"电信 ICCID 必须为 19 位",行号为 100,继续处理其他 ICCID
#### Scenario: 查询导入任务进度
- **WHEN** 管理员查询导入任务(ID 为 1)的进度
- **THEN** 系统返回任务状态、总数、成功数、跳过数、失败数、开始时间、完成时间
#### Scenario: 查询导入任务失败详情
- **WHEN** 管理员查询导入任务(ID 为 1)的失败详情
- **THEN** 系统返回失败记录列表,每条包含行号、ICCID、失败原因
### Requirement: IoT 卡查询和筛选
系统 SHALL 支持多维度查询和筛选 IoT 卡,包括状态、所有者、批次号、卡类型等。
**查询条件**:
- ICCID(精确匹配或模糊匹配)
- IoT 卡状态(单选或多选)
- 所有者类型(platform | agent | user | device)
- 所有者 ID(仅当所有者类型为 agent/user/device 时有效)
- 批次号(精确匹配)
- 卡类型(单选或多选)
- 运营商 ID(单选或多选,从 carriers 表选择)
- 激活状态(0-未激活 | 1-已激活)
- 实名状态(0-未实名 | 1-已实名)
- 网络状态(0-停机 | 1-开机)
- 是否参与轮询(true | false)
- 激活时间范围(开始时间 - 结束时间)
- 创建时间范围(开始时间 - 结束时间)
**分页**:
- 默认每页 20 条,最大每页 100 条
- 返回总记录数和总页数
#### Scenario: 查询特定批次的在库 IoT 卡
- **WHEN** 平台查询批次号为 "BATCH-2025-001" 且状态为 1(在库) 的 IoT 卡
- **THEN** 系统返回符合条件的 IoT 卡列表,包含 ICCID、类型、运营商、成本价等信息
#### Scenario: 代理查询自己的已分销 IoT 卡
- **WHEN** 代理商(用户 ID 为 123)查询自己的已分销 IoT 卡
- **THEN** 系统返回 `owner_type` 为 "agent" 且 `owner_id` 为 123 且 `status` 为 2(已分销) 的 IoT 卡列表
#### Scenario: 分页查询 IoT 卡
- **WHEN** 平台查询在库 IoT 卡,指定每页 50 条,查询第 2 页
- **THEN** 系统返回第 51-100 条 IoT 卡记录,以及总记录数和总页数
---
### Requirement: Gateway 集成
系统 SHALL 预留 IoT 卡状态相关字段,用于后续与 Gateway 项目集成。
**集成字段**:
- `activation_status`: 激活状态(从 Gateway 同步)
- `real_name_status`: 实名状态(从 Gateway 同步)
- `network_status`: 网络状态(从 Gateway 同步)
- `data_usage_mb`: 累计流量使用(从 Gateway 同步)
- `last_sync_time`: 最后同步时间
**集成说明**:
- 本阶段只设计数据模型字段,不实现 Gateway HTTP 客户端代码
- 后续 Service 层将调用 Gateway API 获取 IoT 卡状态并更新这些字段
- Gateway 使用 AES 加密 + MD5 签名的统一传输协议(参考 design.md)
**Gateway API 功能**:
- 查询 IoT 卡状态(激活状态、实名状态、网络状态)
- 查询流量详情(累计流量使用、剩余流量)
- 停复机操作(停机、复机)
- 实名认证操作
#### Scenario: 预留 Gateway 集成字段
- **WHEN** 创建 IoT 卡记录
- **THEN** 系统初始化 Gateway 相关字段为默认值:`activation_status` 为 0,`real_name_status` 为 0,`network_status` 为 0,`data_usage_mb` 为 0,`last_sync_time` 为空
#### Scenario: 从 Gateway 同步 IoT 卡状态
- **WHEN** Service 层调用 Gateway API 查询 IoT 卡状态
- **THEN** 系统更新 IoT 卡的 `activation_status``real_name_status``network_status``data_usage_mb``last_sync_time` 字段
---
### Requirement: IoT 卡数据校验
系统 SHALL 对 IoT 卡数据进行校验,确保数据完整性和一致性。
**校验规则**:
- ICCID(iccid):必填,长度 19-20 字符,唯一
- 卡类型(card_type):必填,长度 1-50 字符
- 卡业务类型(card_category):必填,枚举值 "normal"(普通卡) | "industry"(行业卡),默认 "normal"
- 运营商 ID(carrier_id):必填,≥ 1,必须是有效的运营商 ID
- 成本价(cost_price):必填,≥ 0,最多 2 位小数
- 分销价(distribute_price):可选,≥ 0,最多 2 位小数,≥ 成本价
- 所有者类型(owner_type):必填,枚举值 "platform" | "agent" | "user" | "device"
- 所有者 ID(owner_id):必填,≥ 0,当 owner_type 为 "platform" 时必须为 0
- 激活状态(activation_status):必填,枚举值 0(未激活) | 1(已激活)
- 实名状态(real_name_status):必填,枚举值 0(未实名) | 1(已实名),当 card_category 为 "industry"(行业卡)时可以保持 0
- 网络状态(network_status):必填,枚举值 0(停机) | 1(开机)
- 轮询开关(enable_polling):必填,布尔值 true | false
#### Scenario: 创建 IoT 卡时 ICCID 格式错误
- **WHEN** 平台创建 IoT 卡,ICCID 长度为 15(小于 19)
- **THEN** 系统拒绝创建,返回错误信息"ICCID 长度必须为 19-20 字符"
#### Scenario: 创建 IoT 卡时 ICCID 重复
- **WHEN** 平台创建 IoT 卡,ICCID 为已存在的 "89860123456789012345"
- **THEN** 系统拒绝创建,返回错误信息"ICCID 已存在"
#### Scenario: 创建 IoT 卡时成本价为负数
- **WHEN** 平台创建 IoT 卡,成本价为 -10.00
- **THEN** 系统拒绝创建,返回错误信息"成本价必须 ≥ 0"
#### Scenario: 创建 IoT 卡时分销价低于成本价
- **WHEN** 平台创建 IoT 卡,成本价为 50.00,分销价为 40.00
- **THEN** 系统拒绝创建,返回错误信息"分销价不能低于成本价"
### Requirement: 单卡列表查询
系统 SHALL 提供单卡列表查询功能,用于管理未绑定设备的 IoT 卡资产。
**单卡定义**: 单卡是指未绑定到任何设备的 IoT 卡,即在 `device_sim_bindings` 表中不存在 `bind_status = 1`(已绑定) 的记录。
**查询条件**:
- 套餐 ID(package_id): 可选,筛选已购买指定套餐的卡
- 是否分销(is_distributed): 可选,true-已分销 false-未分销
- 卡号状态(status): 可选,1-在库 2-已分销 3-已激活 4-已停用
- 运营商(carrier_id): 可选,运营商 ID
- 分销商 ID(shop_id): 可选,店铺 ID
- 网卡号段(iccid_range): 可选,格式 "起始ICCID-结束ICCID"
- ICCID: 可选,模糊匹配
- 卡接入号(msisdn): 可选,模糊匹配
- 是否换卡(is_replaced): 可选,true-有换卡记录 false-无换卡记录
**分页**:
- 默认每页 20 条,最大每页 100 条
- 返回总记录数和总页数
**数据权限**:
- 基于 shop_id 自动应用数据权限过滤
- 代理只能看到自己店铺及下级店铺的卡
#### Scenario: 查询未绑定设备的单卡列表
- **WHEN** 管理员查询单卡列表
- **THEN** 系统返回所有未绑定设备的 IoT 卡(在 device_sim_bindings 中无 bind_status=1 记录的卡)
#### Scenario: 按运营商筛选单卡
- **WHEN** 管理员查询运营商 ID 为 1(电信)的单卡
- **THEN** 系统返回 carrier_id = 1 且未绑定设备的 IoT 卡列表
#### Scenario: 按网卡号段筛选单卡
- **WHEN** 管理员查询 ICCID 号段为 "8986001000000000000-8986001999999999999" 的单卡
- **THEN** 系统返回 ICCID 在该号段范围内且未绑定设备的 IoT 卡列表
#### Scenario: 按是否换卡筛选单卡
- **WHEN** 管理员查询有换卡记录的单卡(is_replaced=true)
- **THEN** 系统返回在 card_replacement_records 表中有记录的 IoT 卡列表
---
### Requirement: 单卡分配功能
系统 SHALL 支持将单卡(未绑定设备的 IoT 卡)分配给直属下级店铺,实现资产所有权的层级流转。
**分配规则**:
- 只能分配给直属下级店铺,不可跨级分配
- 平台shop_id=NULL只能分配状态为在库(status=1)的卡
- 代理店铺可以分配状态为已分销(status=2)的卡(继续往下分销)
- 分配后状态变更:在库(1)→已分销(2),已分销(2)保持不变
- 分配后 shop_id 变更为目标店铺 ID
- 分配不涉及费用,纯资产所有权转移
- 分配后上级仍能看到和管理(通过数据权限机制)
**选卡方式**(三选一):
- ICCID 列表:指定具体的 ICCID 列表
- 号段范围:指定起始 ICCID 和结束 ICCID
- 筛选条件:按运营商、批次号、状态等条件批量选择
**API 端点**: `POST /api/admin/iot-cards/standalone/allocate`
**请求参数**:
- `to_shop_id`(必填): 目标店铺 ID
- `selection_type`(必填): 选卡方式,枚举值 "list" | "range" | "filter"
- `iccids`selection_type=list 时必填): ICCID 列表
- `iccid_start`selection_type=range 时必填): 起始 ICCID
- `iccid_end`selection_type=range 时必填): 结束 ICCID
- `carrier_id`selection_type=filter 时可选): 运营商 ID
- `batch_no`selection_type=filter 时可选): 批次号
- `status`selection_type=filter 时可选): 卡状态
- `remark`(可选): 备注
**响应**:
- `total_count`: 待分配总数
- `success_count`: 成功数
- `fail_count`: 失败数
- `failed_items`: 失败项列表(包含 ICCID 和失败原因)
#### Scenario: 平台通过 ICCID 列表分配单卡给一级代理
- **WHEN** 平台管理员选择 3 张在库单卡ICCID 列表分配给一级代理店铺ID=10
- **THEN** 系统将这 3 张卡的 shop_id 更新为 10status 从 1 变为 2创建分配记录返回成功数 3
#### Scenario: 平台通过号段范围批量分配单卡
- **WHEN** 平台管理员指定 ICCID 范围 "8986001000000000000" 至 "8986001000000000099"分配给一级代理店铺ID=10
- **THEN** 系统查询该范围内的所有在库单卡,批量更新 shop_id 和 status创建分配记录
#### Scenario: 代理通过筛选条件分配单卡给下级
- **WHEN** 一级代理(店铺 ID=10按条件筛选运营商=电信,批次号=BATCH-001自己的已分销卡分配给二级代理店铺ID=20
- **THEN** 系统查询符合条件的卡,校验店铺 20 是店铺 10 的直属下级,批量更新 shop_id 为 20status 保持 2
#### Scenario: 拒绝跨级分配
- **WHEN** 平台尝试将卡直接分配给二级代理店铺(非直属下级)
- **THEN** 系统拒绝分配,返回错误"只能分配给直属下级店铺"
#### Scenario: 拒绝平台分配已分销的卡
- **WHEN** 平台尝试分配状态为已分销(status=2)的卡
- **THEN** 系统拒绝分配,返回错误"在库状态的卡才能分配,请先回收"
#### Scenario: 拒绝分配已绑定设备的卡
- **WHEN** 用户尝试分配已绑定设备的卡(在 device_sim_bindings 中 bind_status=1
- **THEN** 系统拒绝分配,返回错误"已绑定设备的卡不能单独分配"
---
### Requirement: 单卡回收功能
系统 SHALL 支持上级回收已分配给直属下级的单卡,将卡的所有权收回。
**回收规则**:
- 只有上级可以回收,代理不能主动退回给上级
- 只能回收直属下级的卡,不可跨级回收
- 平台回收shop_id 变为 NULLstatus 变为 1在库
- 店铺回收shop_id 变为执行回收的店铺 IDstatus 保持 2已分销
- 只能回收单卡(未绑定设备的卡)
**选卡方式**(与分配相同,三选一):
- ICCID 列表
- 号段范围
- 筛选条件
**API 端点**: `POST /api/admin/iot-cards/standalone/recall`
**请求参数**:
- `from_shop_id`(必填): 来源店铺 ID被回收方
- `selection_type`(必填): 选卡方式,枚举值 "list" | "range" | "filter"
- `iccids`selection_type=list 时必填): ICCID 列表
- `iccid_start`selection_type=range 时必填): 起始 ICCID
- `iccid_end`selection_type=range 时必填): 结束 ICCID
- `carrier_id`selection_type=filter 时可选): 运营商 ID
- `batch_no`selection_type=filter 时可选): 批次号
- `remark`(可选): 备注
**响应**:
- `total_count`: 待回收总数
- `success_count`: 成功数
- `fail_count`: 失败数
- `failed_items`: 失败项列表
#### Scenario: 平台回收一级代理的单卡
- **WHEN** 平台管理员选择一级代理店铺ID=10的 5 张单卡进行回收
- **THEN** 系统将这 5 张卡的 shop_id 更新为 NULLstatus 从 2 变为 1创建回收记录
#### Scenario: 一级代理回收二级代理的单卡
- **WHEN** 一级代理(店铺 ID=10选择二级代理店铺ID=20的 3 张单卡进行回收
- **THEN** 系统将这 3 张卡的 shop_id 更新为 10status 保持 2创建回收记录
#### Scenario: 拒绝回收非直属下级的卡
- **WHEN** 一级代理(店铺 ID=10尝试回收非直属下级店铺ID=30归属于店铺 ID=20的卡
- **THEN** 系统拒绝回收,返回错误"只能回收直属下级店铺的卡"
#### Scenario: 拒绝代理主动退回
- **WHEN** 二级代理(店铺 ID=20尝试将卡退回给上级店铺ID=10
- **THEN** 系统拒绝操作,返回错误"不能主动退回卡给上级,请联系上级进行回收"
#### Scenario: 拒绝回收已绑定设备的卡
- **WHEN** 用户尝试回收已绑定设备的卡
- **THEN** 系统拒绝回收,返回错误"已绑定设备的卡不能单独回收"
---
### Requirement: 企业用户 IoT 卡查询权限控制
系统 SHALL 支持基于用户类型和授权关系的 IoT 卡查询权限控制。
**查询权限规则**
- **超级管理员/平台用户**:可以查询所有 IoT 卡
- **代理用户**:可以查询自己店铺和下级店铺的 IoT 卡
- **企业用户**
- 可以查询分配给自己企业的卡owner_type="enterprise" 且 owner_id=自己的企业ID
- 可以查询授权给自己企业的卡(通过 enterprise_card_authorization 表关联)
- **个人客户**:只能查询自己拥有的卡
**数据过滤**
- 企业用户查询时自动过滤敏感商业信息cost_price、distribute_price、supplier
- 其他用户类型可以看到完整信息
#### Scenario: 企业用户查询自己拥有的卡
- **WHEN** 企业用户查询 IoT 卡列表,且存在 owner_type="enterprise" 且 owner_id=该企业ID 的卡
- **THEN** 系统返回这些卡的信息,但隐藏 cost_price、distribute_price、supplier 字段
#### Scenario: 企业用户查询被授权的卡
- **WHEN** 企业用户查询 IoT 卡列表,且存在通过 enterprise_card_authorization 授权给该企业的卡
- **THEN** 系统返回这些授权卡的信息,但隐藏商业敏感字段,同时包含授权人和授权时间信息
#### Scenario: 企业用户无法查询未授权的卡
- **WHEN** 企业用户尝试查询既不属于自己也未被授权的卡
- **THEN** 系统在查询结果中不包含这些卡,如同它们不存在
#### Scenario: 代理用户正常查询
- **WHEN** 代理用户查询 IoT 卡
- **THEN** 系统返回该代理店铺及其下级店铺的所有卡,包含完整信息
#### Scenario: 授权被回收后企业无法查询
- **WHEN** 卡的授权被回收后revoked_at 不为空),企业用户查询该卡
- **THEN** 系统不返回该卡信息,企业无法再看到该卡
---
### Requirement: 查询物联网卡时返回运营商信息
系统 SHALL 在查询物联网卡列表/详情时,直接从 IotCard 记录的冗余字段返回 carrier_type 和 carrier_name无需 JOIN Carrier 表。
#### Scenario: 列表查询返回运营商信息
- **WHEN** 管理员查询物联网卡列表
- **THEN** 响应中的 carrier_type 和 carrier_name 直接来自 IotCard 记录的冗余字段
#### Scenario: 详情查询返回运营商信息
- **WHEN** 管理员查询单张物联网卡详情
- **THEN** 响应中的 carrier_type 和 carrier_name 直接来自 IotCard 记录的冗余字段
---
### Requirement: 查询导入任务时返回运营商名称
系统 SHALL 在查询导入任务列表/详情时,直接从 IotCardImportTask 记录的冗余字段返回 carrier_name无需 JOIN Carrier 表。
#### Scenario: 导入任务列表返回运营商名称
- **WHEN** 管理员查询导入任务列表
- **THEN** 响应中的 carrier_name 直接来自 IotCardImportTask 记录的冗余字段
---
### Requirement: 设备绑定卡查询返回运营商信息
系统 SHALL 在查询设备绑定的物联网卡时,直接从 IotCard 记录的冗余字段返回 carrier_name无需 JOIN Carrier 表。
#### Scenario: 设备绑定卡列表返回运营商名称
- **WHEN** 管理员查询设备绑定的物联网卡列表
- **THEN** 响应中的 carrier_name 直接来自 IotCard 记录的冗余字段
---
### Requirement: 流量检查任务支持新的扣减优先级
系统 SHALL 在轮询系统的流量检查任务HandleCarddataCheck实现新的流量扣减优先级机制。
#### Scenario: 优先扣减加油包流量
- **WHEN** 轮询系统检测到卡流量增加,卡有主套餐和加油包
- **THEN** 系统优先更新加油包的 data_usage_mb再更新主套餐
#### Scenario: 按 Priority 顺序扣减多个加油包
- **WHEN** 卡有多个加油包,流量增加
- **THEN** 系统按 priority 从小到大顺序扣减流量
### Requirement: 停机条件检查调整
系统 SHALL 在轮询系统中,仅当主套餐和所有加油包流量都用完时触发停机。
#### Scenario: 主套餐用完但加油包有剩余不停机
- **WHEN** 主套餐 data_usage_mb >= data_limit_mb但加油包有剩余流量
- **THEN** 系统不触发停机操作
#### Scenario: 所有套餐流量用完触发停机
- **WHEN** 主套餐和所有加油包 data_usage_mb >= data_limit_mb
- **THEN** 系统触发停机操作
### Requirement: 套餐激活检查任务
系统 SHALL 新增套餐激活检查任务HandlePackageActivation定期检查待激活的主套餐。
#### Scenario: 定期检查待激活主套餐
- **WHEN** 轮询系统每分钟执行一次套餐激活检查
- **THEN** 系统查询所有已过期主套餐,激活 priority 最小的待生效主套餐
#### Scenario: 激活延迟小于1分钟
- **WHEN** 主套餐在 00:00:00 过期
- **THEN** 系统在 00:01:00 之前完成下一个主套餐的激活
### Requirement: 流量重置调度任务
系统 SHALL 新增流量重置调度任务HandleDataReset根据套餐的 data_reset_cycle 定期重置流量。
#### Scenario: 每日0点触发日重置任务
- **WHEN** 系统时间到达 00:00:00
- **THEN** 系统重置所有 data_reset_cycle=daily 的套餐 data_usage_mb=0
#### Scenario: 每月1号触发月重置任务
- **WHEN** 系统时间到达每月1号 00:00:00
- **THEN** 系统重置所有 data_reset_cycle=monthly 的套餐 data_usage_mb=0
---
### Requirement: IotCard Handler 分层修复
IotCard Handler SHALL 不再直接持有 `gateway.Client` 引用。所有 Gateway API 调用 SHALL 通过 IotCard Service 层发起。
#### Scenario: IotCardHandler 不持有 gatewayClient
- **WHEN** 创建 `IotCardHandler` 实例
- **THEN** `NewIotCardHandler` 构造函数不接收 `gateway.Client` 参数
- **AND** Handler 结构体不包含 `gatewayClient` 字段
#### Scenario: 查询卡实时状态通过 Service 调用
- **WHEN** Handler 的 `GetGatewayStatus` 方法被调用
- **THEN** Handler 调用 `service.QueryGatewayStatus(ctx, iccid)`
- **AND** Service 内部完成权限检查 + Gateway API 调用
#### Scenario: 查询流量使用通过 Service 调用
- **WHEN** Handler 的 `GetGatewayFlow` 方法被调用
- **THEN** Handler 调用 `service.QueryGatewayFlow(ctx, iccid)`
#### Scenario: 查询实名状态通过 Service 调用
- **WHEN** Handler 的 `GetGatewayRealname` 方法被调用
- **THEN** Handler 调用 `service.QueryGatewayRealname(ctx, iccid)`
#### Scenario: 获取实名链接通过 Service 调用
- **WHEN** Handler 的 `GetRealnameLink` 方法被调用
- **THEN** Handler 调用 `service.GetGatewayRealnameLink(ctx, iccid)`
#### Scenario: 停卡通过 Service 调用
- **WHEN** Handler 的 `StopCard` 方法被调用
- **THEN** Handler 调用 `service.GatewayStopCard(ctx, iccid)`
#### Scenario: 复机通过 Service 调用
- **WHEN** Handler 的 `StartCard` 方法被调用
- **THEN** Handler 调用 `service.GatewayStartCard(ctx, iccid)`
### Requirement: IotCard Service Gateway 代理方法
IotCard Service SHALL 提供 Gateway API 的代理方法,封装权限检查和 Gateway 调用。
#### Scenario: QueryGatewayStatus 方法
- **WHEN** 调用 `service.QueryGatewayStatus(ctx, iccid)`
- **THEN** 先通过 `GetByICCID` 验证卡存在且用户有权限
- **AND** 然后调用 `gatewayClient.QueryCardStatus`
- **AND** 返回 `*gateway.CardStatusResp`
#### Scenario: QueryGatewayFlow 方法
- **WHEN** 调用 `service.QueryGatewayFlow(ctx, iccid)`
- **THEN** 先验证权限,再调用 `gatewayClient.QueryFlow`
- **AND** 返回 `*gateway.FlowUsageResp`
#### Scenario: QueryGatewayRealname 方法
- **WHEN** 调用 `service.QueryGatewayRealname(ctx, iccid)`
- **THEN** 先验证权限,再调用 `gatewayClient.QueryRealnameStatus`
- **AND** 返回 `*gateway.RealnameStatusResp`
#### Scenario: GetGatewayRealnameLink 方法
- **WHEN** 调用 `service.GetGatewayRealnameLink(ctx, iccid)`
- **THEN** 先验证权限,再调用 `gatewayClient.GetRealnameLink`
- **AND** 返回 `*gateway.RealnameLinkResp`
#### Scenario: GatewayStopCard 方法
- **WHEN** 调用 `service.GatewayStopCard(ctx, iccid)`
- **THEN** 先验证权限,再调用 `gatewayClient.StopCard`
- **AND** 返回 error
#### Scenario: GatewayStartCard 方法
- **WHEN** 调用 `service.GatewayStartCard(ctx, iccid)`
- **THEN** 先验证权限,再调用 `gatewayClient.StartCard`
- **AND** 返回 error
#### Scenario: 卡不存在或无权限
- **WHEN** 调用任意 Gateway 代理方法且 ICCID 对应的卡不存在或用户无权限
- **THEN** 返回 `CodeNotFound` 错误
- **AND** 错误信息为 "卡不存在或无权限访问"
---
### Requirement: IoT 卡虚拟号字段
系统 SHALL 在 `tb_iot_card` 表新增 `virtual_no` 字段,与设备的虚拟号概念对等,供客服和客户通过统一虚拟号查找资产。
**字段定义**:
- 字段名:`virtual_no`VARCHAR(50),可空)
- 全局唯一索引:`CREATE UNIQUE INDEX idx_iot_card_virtual_no ON tb_iot_card (virtual_no) WHERE deleted_at IS NULL`
- 老数据:`virtual_no` 为 NULL已有卡不强制要求有虚拟号
- 允许手动修改
**唯一性规则**:
- 在所有未软删除的卡中唯一部分索引deleted_at IS NULL
- 导入时与数据库现存数据重复则整批失败,响应中包含冲突的具体 virtual_no 列表
**虚拟号的使用场景**:
- resolve 接口:支持通过 virtual_no 查找卡
- 客服工单:客服将虚拟号告知客户,客户通过虚拟号自助查询
#### Scenario: 为卡设置唯一虚拟号
- **WHEN** 管理员为 ICCID 为 "898601234..." 的卡设置 virtual_no = "CARD-001"
- **THEN** 系统保存成功,`idx_iot_card_virtual_no` 确保全局唯一
#### Scenario: 导入批次中有重复虚拟号
- **WHEN** ICCID 导入批次中,有 1 条记录的 virtual_no 与数据库现存卡的 virtual_no 重复
- **THEN** 系统拒绝整批导入,响应中返回冲突的 virtual_no 及所属行号
#### Scenario: virtual_no 为空的老卡
- **WHEN** 系统中有历史导入的卡,没有 virtual_no
- **THEN** 这些卡的 virtual_no = NULL不影响唯一索引部分索引跳过 NULL 值)
### Requirement: IoT 卡资产生命周期字段
系统 SHALL 在 `IotCard` 模型新增以下资产生命周期追踪字段:
- `asset_status int NOT NULL DEFAULT 1`
- `generation int NOT NULL DEFAULT 1`
#### Scenario: 新建 IoT 卡默认资产状态
- **WHEN** 创建新的 IoT 卡记录
- **THEN** `asset_status` MUST 默认为 `1`(在库)
#### Scenario: 新建 IoT 卡默认代际
- **WHEN** 创建新的 IoT 卡记录
- **THEN** `generation` MUST 默认为 `1`
---
### Requirement: IoT 卡换货状态语义扩展
系统 SHALL 将 `asset_status=3` 定义为"已换货",用于标记已被换出、不可继续作为当前代际在售资产的 IoT 卡。
#### Scenario: 换货完成后旧卡标记为已换货
- **WHEN** H5 确认完成且旧资产为 IoT 卡
- **THEN** 系统 MUST 将旧卡 `asset_status` 更新为 `3`
---
### Requirement: IoT 卡转新重置规则
系统 SHALL 在 H7 转新时对 IoT 卡执行以下重置:
- `generation = generation + 1`
- `asset_status = 1`(在库)
- 清空累计充值与首充触发相关状态(含 `AccumulatedRecharge``FirstCommissionPaid`、系列首充/累计字段)
- 清除个人客户绑定关系
#### Scenario: 转新后进入新代际
- **WHEN** 对旧卡执行转新
- **THEN** 系统 MUST 使该卡进入新代际并以在库状态重新销售
### Requirement: 新卡首次轮询
新入库的卡 `last_gateway_reading_mb` 默认为 0首次轮询的增量 = 网关返回的全量值。这是预期行为。批量导入已有使用量的卡时,导入脚本应同步设置 `last_gateway_reading_mb`
### Requirement: 增量函数合并
`calculateFlowUpdates()``calculateFlowIncrement()` SHALL 合并为一个函数,返回 `(updates map[string]any, increment float64)`。消除两个独立增量计算函数的不一致风险。

View File

@@ -1,465 +1,16 @@
# Spec: 套餐流量日记录
## MODIFIED Requirements
## 业务背景
### Requirement: 流量详单存储方式
卡级流量详单 SHALL 从无差别直接写 DB 改为 Redis 缓冲 + 日落盘。`insertDataUsageRecord()` SHALL NOT 再创建 `DataUsageRecord` 记录,直接替换为 Redis 写入。
### 为什么需要流量日记录
#### Scenario: 轮询写流量数据
- **WHEN** 轮询系统检测到卡的流量变化(增量 > 0
- **THEN** 系统 SHALL 仅写 Redis 缓冲(`INCRBYFLOAT`),不写 DB
**现状问题**
- 用户需要查看每日流量使用明细(哪天用了多少流量)
- 套餐流量重置后,历史使用数据丢失
- 无法统计和分析用户流量使用趋势
- 计费对账需要每日流量记录
#### Scenario: 旧代码清理
- **WHEN** 新存储路径完全就绪
- **THEN** SHALL 删除 `DataUsageRecord` model、`DataUsageRecordStore`、bootstrap 中的相关引用
- **AND** `tb_data_usage_record` 旧表数据暂保留在 DB后续通过数据清理功能清除代码层完全去除依赖
**业务目标**
- 按套餐维度记录每日流量增量
- 支持按日期范围查询流量详单
- 流量重置后历史记录仍可查询
- 为计费对账和数据分析提供基础数据
---
## 业务规则
### 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. 使用 UPSERTON 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=15361GB+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=3GB13GB - 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. 并发写入同一天记录
- **场景**:同一套餐在同一天有多个并发流量扣减请求
- **处理**:使用 UPSERTON CONFLICT UPDATE确保幂等性
### 3. 跨月查询日记录
- **场景**:查询 1月15日 至 2月15日 的日记录(跨月)
- **处理**:按日期范围查询,返回跨月数据
---
## 并发场景
### Scenario: 并发写入同一天记录
- **GIVEN** 套餐 ID=123 在 2026-02-10 10:00:00 和 10:00:01 同时扣减流量
- **WHEN** 两个请求同时写入日记录
- **THEN** 使用 UPSERTON 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 完成**,包含:
- ✅ 业务背景和业务规则
- ✅ 详细场景(写入、查询、增量计算)
- ✅ 边界条件和并发场景
- ✅ 异常处理和数据一致性保证
- ✅ 性能指标和错误码定义
-**激进的数据迁移策略**(明确删除字段、废弃逻辑、强制转换)
- ✅ 测试场景矩阵和实现参考
### Clarification: 套餐级日记录不受影响
`tb_package_usage_daily_record`(套餐级日记录)由 `UsageService.updateDailyRecord()` 在轮询将增量记录到套餐已用量时同步写入,与本次改造的卡级 `tb_card_daily_usage` 是两个完全不同的维度,互不影响。

View File

@@ -0,0 +1,30 @@
## ADDED Requirements
### Requirement: 流量增量写入 Redis 缓冲
轮询检测到流量增量 > 0 时,系统 SHALL 使用 `INCRBYFLOAT` 将增量写入 Redis key格式 `traffic:daily:{card_id}:{YYYY-MM-DD}`TTL 48 小时。增量 <= 0 时 SHALL NOT 写入。**直接替换旧的 `insertDataUsageRecord()` 写 DB 路径,不双写。**
#### Scenario: 有流量增量时写 Redis
- **WHEN** 轮询检测到卡 ID=100 今日流量增量为 5.2 MB
- **THEN** 系统 SHALL 执行 `INCRBYFLOAT traffic:daily:100:2026-03-28 5.2`,并设置 48h TTL
#### Scenario: 无流量增量时跳过
- **WHEN** 轮询检测到流量增量 <= 0
- **THEN** 系统 SHALL NOT 执行任何 Redis 或 DB 写入
### Requirement: 每日落盘定时任务
系统 SHALL 每天凌晨 2 点Asia/Shanghai通过 Asynq Scheduler 触发落盘任务。
#### Scenario: 正常落盘(分批处理)
- **WHEN** 凌晨 2 点落盘任务执行
- **THEN** 系统 SHALL 分批 SCAN 所有 `traffic:daily:*:{昨日}` key每批 COUNT 500
- **AND** 分批 UPSERT 到 `tb_card_daily_usage`(每批 200 条,**覆盖语义**保证幂等:`ON CONFLICT DO UPDATE SET usage_mb = EXCLUDED.usage_mb`
- **AND** 分批 Pipeline 删除已落盘的 Redis key
- **AND** 记录日志:落盘条数、耗时
#### Scenario: 落盘失败重试
- **WHEN** 落盘任务执行失败
- **THEN** Asynq SHALL 自动重试MaxRetry=3超时 5 分钟Redis key 因 48h TTL 仍可用
- **AND** 因 UPSERT 使用覆盖语义,重试时不会导致数据翻倍
### Requirement: tb_card_daily_usage 表结构
`usage_mb` 字段 SHALL 使用 `NUMERIC(12,2)` 类型(匹配 Redis `INCRBYFLOAT` 的浮点精度和现有 `current_month_usage_mb``decimal(10,2)` 类型),不使用 BIGINT。

View File

@@ -0,0 +1,15 @@
## ADDED Requirements
### Requirement: 统一流量查询服务
`TrafficQueryService.GetDailyUsage()` SHALL 合并 Redis今日和 DB历史两段数据返回指定日期范围内的每日流量列表。
#### Scenario: 查询含今日的日期范围
- **WHEN** 查询 2026-03-25 到 2026-03-28今天
- **THEN** 系统 SHALL 从 `tb_card_daily_usage` 查 3/25~3/27从 Redis 查 3/28合并排序后返回
#### Scenario: 查询纯历史日期范围
- **WHEN** 查询 2026-03-01 到 2026-03-20
- **THEN** 系统 SHALL 仅从 `tb_card_daily_usage` 查询,不访问 Redis
### Requirement: CardDailyUsageStore 共用
落盘任务(`daily_traffic_flush.go`)和查询服务(`TrafficQueryService`SHALL 共用同一个 `CardDailyUsageStore`,避免重复实现 UPSERT 和查询逻辑。

View File

@@ -67,8 +67,9 @@ const (
TaskTypeAutoPurchaseAfterRecharge = "task:auto_purchase_after_recharge" // 充值后自动购包
// 定时任务类型(由 Asynq Scheduler 调度)
TaskTypeAlertCheck = "alert:check" // 告警检查
TaskTypeDataCleanup = "data:cleanup" // 数据清理
TaskTypeAlertCheck = "alert:check" // 告警检查
TaskTypeDataCleanup = "data:cleanup" // 数据清理
TaskTypeDailyTrafficFlush = "traffic:daily:flush" // 每日流量落盘
)
// 用户状态常量

View File

@@ -385,3 +385,14 @@ func RedisPollingQueueProtectKey() string {
func RedisWechatConfigActiveKey() string {
return "wechat:config:active"
}
// ========================================
// 流量日缓冲相关 Redis Key
// ========================================
// RedisCardDailyTrafficKey 生成卡日流量缓冲的 Redis 键
// 用途INCRBYFLOAT 累计当日流量增量,每日落盘任务消费后删除
// 过期时间48 小时
func RedisCardDailyTrafficKey(cardID uint, date string) string {
return fmt.Sprintf("traffic:daily:%d:%s", cardID, date)
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/polling"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/internal/task"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/storage"
@@ -68,6 +69,7 @@ func (h *Handler) RegisterHandlers() *asynq.ServeMux {
h.registerAlertCheckHandler()
h.registerDataCleanupHandler()
h.registerAutoPurchaseHandler()
h.registerDailyTrafficFlushHandler()
h.logger.Info("所有任务处理器注册完成")
return h.mux
@@ -217,6 +219,13 @@ func (h *Handler) registerAutoPurchaseHandler() {
h.logger.Info("注册自动购包任务处理器", zap.String("task_type", constants.TaskTypeAutoPurchaseAfterRecharge))
}
func (h *Handler) registerDailyTrafficFlushHandler() {
cardDailyUsageStore := postgres.NewCardDailyUsageStore(h.db)
dailyTrafficFlushHandler := task.NewDailyTrafficFlushHandler(h.redis, cardDailyUsageStore, h.logger)
h.mux.HandleFunc(constants.TaskTypeDailyTrafficFlush, dailyTrafficFlushHandler.HandleDailyTrafficFlush)
h.logger.Info("注册每日流量落盘任务处理器", zap.String("task_type", constants.TaskTypeDailyTrafficFlush))
}
// GetMux 获取 ServeMux用于启动 Worker 服务器)
func (h *Handler) GetMux() *asynq.ServeMux {
return h.mux