diff --git a/cmd/worker/main.go b/cmd/worker/main.go index 0625091..2b6b1c7 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -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() { diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index ccf0d31..cf9070b 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -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, diff --git a/internal/bootstrap/stores.go b/internal/bootstrap/stores.go index 405f57c..b50a3e8 100644 --- a/internal/bootstrap/stores.go +++ b/internal/bootstrap/stores.go @@ -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), } } diff --git a/internal/model/card_daily_usage.go b/internal/model/card_daily_usage.go new file mode 100644 index 0000000..33ccef0 --- /dev/null +++ b/internal/model/card_daily_usage.go @@ -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" +} diff --git a/internal/model/carrier.go b/internal/model/carrier.go index db480ce..da36df3 100644 --- a/internal/model/carrier.go +++ b/internal/model/carrier.go @@ -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 指定表名 diff --git a/internal/model/data_usage.go b/internal/model/data_usage.go deleted file mode 100644 index e23a9a2..0000000 --- a/internal/model/data_usage.go +++ /dev/null @@ -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" -} diff --git a/internal/model/dto/asset_dto.go b/internal/model/dto/asset_dto.go index cf38afd..68d94cc 100644 --- a/internal/model/dto/asset_dto.go +++ b/internal/model/dto/asset_dto.go @@ -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:"本月已用流量MB(asset_type=card时有效)"` + CurrentMonthUsageMB float64 `json:"current_month_usage_mb,omitempty" description:"系统累计的自然月流量MB(asset_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时有效)"` diff --git a/internal/model/dto/carrier_dto.go b/internal/model/dto/carrier_dto.go index f726e9c..63a7a3c 100644 --- a/internal/model/dto/carrier_dto.go +++ b/internal/model/dto/carrier_dto.go @@ -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:禁用)"` diff --git a/internal/model/iot_card.go b/internal/model/iot_card.go index 29e6e77..df8d5cc 100644 --- a/internal/model/iot_card.go +++ b/internal/model/iot_card.go @@ -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 指定表名 diff --git a/internal/service/carrier/service.go b/internal/service/carrier/service.go index 7255a07..dfcb376 100644 --- a/internal/service/carrier/service.go +++ b/internal/service/carrier/service.go @@ -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, diff --git a/internal/service/traffic/query_service.go b/internal/service/traffic/query_service.go new file mode 100644 index 0000000..e901f3f --- /dev/null +++ b/internal/service/traffic/query_service.go @@ -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 +} diff --git a/internal/store/postgres/card_daily_usage_store.go b/internal/store/postgres/card_daily_usage_store.go new file mode 100644 index 0000000..7bc275b --- /dev/null +++ b/internal/store/postgres/card_daily_usage_store.go @@ -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 +} diff --git a/internal/store/postgres/data_usage_record_store.go b/internal/store/postgres/data_usage_record_store.go deleted file mode 100644 index 218d308..0000000 --- a/internal/store/postgres/data_usage_record_store.go +++ /dev/null @@ -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 -} diff --git a/internal/task/daily_traffic_flush.go b/internal/task/daily_traffic_flush.go new file mode 100644 index 0000000..6946743 --- /dev/null +++ b/internal/task/daily_traffic_flush.go @@ -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 +} diff --git a/internal/task/polling_handler.go b/internal/task/polling_handler.go index deee55a..beaa751 100644 --- a/internal/task/polling_handler.go +++ b/internal/task/polling_handler.go @@ -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 diff --git a/migrations/000094_add_data_reset_day_to_carrier.down.sql b/migrations/000094_add_data_reset_day_to_carrier.down.sql new file mode 100644 index 0000000..028ef89 --- /dev/null +++ b/migrations/000094_add_data_reset_day_to_carrier.down.sql @@ -0,0 +1 @@ +ALTER TABLE tb_carrier DROP COLUMN IF EXISTS data_reset_day; diff --git a/migrations/000094_add_data_reset_day_to_carrier.up.sql b/migrations/000094_add_data_reset_day_to_carrier.up.sql new file mode 100644 index 0000000..d121734 --- /dev/null +++ b/migrations/000094_add_data_reset_day_to_carrier.up.sql @@ -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'; diff --git a/migrations/000095_add_last_gateway_reading_mb_to_iot_card.down.sql b/migrations/000095_add_last_gateway_reading_mb_to_iot_card.down.sql new file mode 100644 index 0000000..5235362 --- /dev/null +++ b/migrations/000095_add_last_gateway_reading_mb_to_iot_card.down.sql @@ -0,0 +1 @@ +ALTER TABLE tb_iot_card DROP COLUMN IF EXISTS last_gateway_reading_mb; diff --git a/migrations/000095_add_last_gateway_reading_mb_to_iot_card.up.sql b/migrations/000095_add_last_gateway_reading_mb_to_iot_card.up.sql new file mode 100644 index 0000000..b9cc1a8 --- /dev/null +++ b/migrations/000095_add_last_gateway_reading_mb_to_iot_card.up.sql @@ -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),用于计算增量'; diff --git a/migrations/000096_init_last_gateway_reading_mb.down.sql b/migrations/000096_init_last_gateway_reading_mb.down.sql new file mode 100644 index 0000000..be4225d --- /dev/null +++ b/migrations/000096_init_last_gateway_reading_mb.down.sql @@ -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; diff --git a/migrations/000096_init_last_gateway_reading_mb.up.sql b/migrations/000096_init_last_gateway_reading_mb.up.sql new file mode 100644 index 0000000..5b5e325 --- /dev/null +++ b/migrations/000096_init_last_gateway_reading_mb.up.sql @@ -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; diff --git a/migrations/000097_create_card_daily_usage.down.sql b/migrations/000097_create_card_daily_usage.down.sql new file mode 100644 index 0000000..0a4647d --- /dev/null +++ b/migrations/000097_create_card_daily_usage.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS tb_card_daily_usage; diff --git a/migrations/000097_create_card_daily_usage.up.sql b/migrations/000097_create_card_daily_usage.up.sql new file mode 100644 index 0000000..c9654fa --- /dev/null +++ b/migrations/000097_create_card_daily_usage.up.sql @@ -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); diff --git a/openspec/changes/archive/2026-03-30-refactor-traffic-system/.openspec.yaml b/openspec/changes/archive/2026-03-30-refactor-traffic-system/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/archive/2026-03-30-refactor-traffic-system/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/archive/2026-03-30-refactor-traffic-system/design.md b/openspec/changes/archive/2026-03-30-refactor-traffic-system/design.md new file mode 100644 index 0000000..3fefd56 --- /dev/null +++ b/openspec/changes/archive/2026-03-30-refactor-traffic-system/design.md @@ -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 Scheduler),SCAN 昨日 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) diff --git a/openspec/changes/archive/2026-03-30-refactor-traffic-system/proposal.md b/openspec/changes/archive/2026-03-30-refactor-traffic-system/proposal.md new file mode 100644 index 0000000..fcd4d98 --- /dev/null +++ b/openspec/changes/archive/2026-03-30-refactor-traffic-system/proposal.md @@ -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` 配置不受影响 diff --git a/openspec/changes/archive/2026-03-30-refactor-traffic-system/specs/carrier/spec.md b/openspec/changes/archive/2026-03-30-refactor-traffic-system/specs/carrier/spec.md new file mode 100644 index 0000000..82580ad --- /dev/null +++ b/openspec/changes/archive/2026-03-30-refactor-traffic-system/specs/carrier/spec.md @@ -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 日志并丢弃异常值 diff --git a/openspec/changes/archive/2026-03-30-refactor-traffic-system/specs/iot-card/spec.md b/openspec/changes/archive/2026-03-30-refactor-traffic-system/specs/iot-card/spec.md new file mode 100644 index 0000000..7d45620 --- /dev/null +++ b/openspec/changes/archive/2026-03-30-refactor-traffic-system/specs/iot-card/spec.md @@ -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)`。消除两个独立增量计算函数的不一致风险。 diff --git a/openspec/changes/archive/2026-03-30-refactor-traffic-system/specs/package-usage-daily-record/spec.md b/openspec/changes/archive/2026-03-30-refactor-traffic-system/specs/package-usage-daily-record/spec.md new file mode 100644 index 0000000..8e5fc3d --- /dev/null +++ b/openspec/changes/archive/2026-03-30-refactor-traffic-system/specs/package-usage-daily-record/spec.md @@ -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` 是两个完全不同的维度,互不影响。 diff --git a/openspec/changes/archive/2026-03-30-refactor-traffic-system/specs/traffic-daily-buffer/spec.md b/openspec/changes/archive/2026-03-30-refactor-traffic-system/specs/traffic-daily-buffer/spec.md new file mode 100644 index 0000000..bd1f67c --- /dev/null +++ b/openspec/changes/archive/2026-03-30-refactor-traffic-system/specs/traffic-daily-buffer/spec.md @@ -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。 diff --git a/openspec/changes/archive/2026-03-30-refactor-traffic-system/specs/traffic-query-service/spec.md b/openspec/changes/archive/2026-03-30-refactor-traffic-system/specs/traffic-query-service/spec.md new file mode 100644 index 0000000..5fa9379 --- /dev/null +++ b/openspec/changes/archive/2026-03-30-refactor-traffic-system/specs/traffic-query-service/spec.md @@ -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 和查询逻辑。 diff --git a/openspec/changes/archive/2026-03-30-refactor-traffic-system/tasks.md b/openspec/changes/archive/2026-03-30-refactor-traffic-system/tasks.md new file mode 100644 index 0000000..7fa7fc2 --- /dev/null +++ b/openspec/changes/archive/2026-03-30-refactor-traffic-system/tasks.md @@ -0,0 +1,125 @@ +# 任务清单:refactor-traffic-system + +> ⚠️ 架构改造,涉及 DB 迁移 + 轮询核心路径修改,建议低峰期发布。 +> 严格串行:E-2(增量算法)→ E-1(Redis 缓冲)→ E-3(查询适配)→ 清理。 + +## 任务组 1:E-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 + +## 任务组 2:E-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 ./...` 编译通过 + +## 任务组 3:E-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"` 确认旧代码已清理(只允许出现在迁移文件或注释中) diff --git a/openspec/specs/admin-order-creation/spec.md b/openspec/specs/admin-order-creation/spec.md index 25e540e..4a94c3b 100644 --- a/openspec/specs/admin-order-creation/spec.md +++ b/openspec/specs/admin-order-creation/spec.md @@ -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 层继续处理钱包扣款逻辑 diff --git a/openspec/specs/asset-queries/spec.md b/openspec/specs/asset-queries/spec.md index 688fbc9..989e012 100644 --- a/openspec/specs/asset-queries/spec.md +++ b/openspec/specs/asset-queries/spec.md @@ -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),继续返回已有数据,不返回错误给调用方 diff --git a/openspec/specs/asset-suspend-resume/spec.md b/openspec/specs/asset-suspend-resume/spec.md index a231c15..c841fce 100644 --- a/openspec/specs/asset-suspend-resume/spec.md +++ b/openspec/specs/asset-suspend-resume/spec.md @@ -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 自动触发套餐暂停联动逻辑 diff --git a/openspec/specs/carrier/spec.md b/openspec/specs/carrier/spec.md index af7c8e8..82580ad 100644 --- a/openspec/specs/carrier/spec.md +++ b/openspec/specs/carrier/spec.md @@ -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`:状态(INT,1-启用 0-禁用) -- `creator`:创建人 ID(BIGINT) -- `updater`:更新人 ID(BIGINT) -- `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 日志并丢弃异常值 diff --git a/openspec/specs/client-order-purchase/spec.md b/openspec/specs/client-order-purchase/spec.md index a9db784..9179649 100644 --- a/openspec/specs/client-order-purchase/spec.md +++ b/openspec/specs/client-order-purchase/spec.md @@ -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=已退款。 diff --git a/openspec/specs/iot-card/spec.md b/openspec/specs/iot-card/spec.md index d1c386a..7d45620 100644 --- a/openspec/specs/iot-card/spec.md +++ b/openspec/specs/iot-card/spec.md @@ -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 更新为 10,status 从 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 为 20,status 保持 2 - -#### Scenario: 拒绝跨级分配 - -- **WHEN** 平台尝试将卡直接分配给二级代理店铺(非直属下级) -- **THEN** 系统拒绝分配,返回错误"只能分配给直属下级店铺" - -#### Scenario: 拒绝平台分配已分销的卡 - -- **WHEN** 平台尝试分配状态为已分销(status=2)的卡 -- **THEN** 系统拒绝分配,返回错误"在库状态的卡才能分配,请先回收" - -#### Scenario: 拒绝分配已绑定设备的卡 - -- **WHEN** 用户尝试分配已绑定设备的卡(在 device_sim_bindings 中 bind_status=1) -- **THEN** 系统拒绝分配,返回错误"已绑定设备的卡不能单独分配" - ---- - -### Requirement: 单卡回收功能 - -系统 SHALL 支持上级回收已分配给直属下级的单卡,将卡的所有权收回。 - -**回收规则**: -- 只有上级可以回收,代理不能主动退回给上级 -- 只能回收直属下级的卡,不可跨级回收 -- 平台回收:shop_id 变为 NULL,status 变为 1(在库) -- 店铺回收:shop_id 变为执行回收的店铺 ID,status 保持 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 更新为 NULL,status 从 2 变为 1,创建回收记录 - -#### Scenario: 一级代理回收二级代理的单卡 - -- **WHEN** 一级代理(店铺 ID=10)选择二级代理店铺(ID=20)的 3 张单卡进行回收 -- **THEN** 系统将这 3 张卡的 shop_id 更新为 10,status 保持 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)`。消除两个独立增量计算函数的不一致风险。 diff --git a/openspec/specs/package-usage-daily-record/spec.md b/openspec/specs/package-usage-daily-record/spec.md index 1b8edd8..8e5fc3d 100644 --- a/openspec/specs/package-usage-daily-record/spec.md +++ b/openspec/specs/package-usage-daily-record/spec.md @@ -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. 使用 UPSERT(ON CONFLICT UPDATE)确保幂等性 -``` - -### 2. 流量增量计算 - -``` -每日流量增量 = 今日上游返回的累计流量 - 昨日记录的累计流量 - -特殊情况: -- 如果昨日无记录 → 增量 = 今日上游累计流量 -- 如果上游重置(今日累计 < 昨日累计)→ 增量 = 今日上游累计流量 -``` - -### 3. cumulative_usage_mb 字段 - -- **定义**:截止到当日的累计流量 -- **计算规则**:cumulative_usage_mb = 昨日 cumulative_usage_mb + 今日 daily_usage_mb -- **首日规则**:首日 cumulative_usage_mb = daily_usage_mb - -### 4. 数据保留策略 - -- **保留期限**:永久保留(或根据业务需求保留1年/2年) -- **流量重置不删除**:套餐流量重置后,日记录仍保留 -- **套餐过期不删除**:套餐过期后,日记录仍保留 - ---- - -## ADDED Requirements - -### Requirement: 按套餐维度记录每日流量 - -系统 SHALL 为每个 PackageUsage 创建每日流量记录(PackageUsageDailyRecord),记录每天的流量增量。 - -#### Scenario: 首次记录当日流量 -- **GIVEN** 套餐 ID=123 在 2026-02-10 首次产生流量 1.5GB -- **WHEN** 流量扣减完成 -- **THEN** 系统创建 PackageUsageDailyRecord: - - package_usage_id=123 - - date=2026-02-10 - - daily_usage_mb=1536 (1.5GB) - - cumulative_usage_mb=1536 - -#### Scenario: 同一天多次流量更新 -- **GIVEN** 套餐在 2026-02-10 已记录 1GB 流量 -- **WHEN** 再产生 0.5GB 流量 -- **THEN** 系统更新 PackageUsageDailyRecord: - - daily_usage_mb=1536(1GB+0.5GB) - - cumulative_usage_mb=1536 - -#### Scenario: 跨天流量记录 -- **GIVEN** 套餐在 2026-02-10 使用 2GB -- **AND** 2026-02-11 使用 3GB -- **WHEN** 流量扣减完成 -- **THEN** 系统创建两条记录: - - 2月10日:daily_usage_mb=2GB, cumulative_usage_mb=2GB - - 2月11日:daily_usage_mb=3GB, cumulative_usage_mb=5GB - -#### Scenario: 流量重置后日记录仍保留 -- **GIVEN** 套餐在 2月1日至2月28日有28条日记录 -- **WHEN** 3月1日 00:00:00 触发流量重置 -- **THEN** 套餐 data_usage_mb 重置为 0 -- **AND** 2月的28条日记录仍存在且可查询 - -### Requirement: 流量增量基于上游查询计算 - -系统 SHALL 根据上游返回的累计流量,减去昨日记录的累计流量,计算每日增量。 - -#### Scenario: 计算每日流量增量 -- **GIVEN** 昨日(2月9日)记录 cumulative_usage_mb=10GB -- **WHEN** 今日(2月10日)上游返回 cumulative=13GB -- **THEN** 今日 daily_usage_mb=3GB(13GB - 10GB) -- **AND** 今日 cumulative_usage_mb=13GB - -#### Scenario: 上游周期重置后流量计算 -- **GIVEN** 联通卡在 2月27日 00:00:00 上游重置 -- **AND** 昨日(2月26日)记录 cumulative_usage_mb=15GB -- **WHEN** 今日(2月27日)上游返回 cumulative=2GB -- **THEN** 今日 daily_usage_mb=2GB(上游重置,取新增量) -- **AND** 今日 cumulative_usage_mb=2GB - -#### Scenario: 首日无昨日记录 -- **GIVEN** 套餐首次激活,无任何日记录 -- **WHEN** 上游返回 cumulative=5GB -- **THEN** 今日 daily_usage_mb=5GB -- **AND** 今日 cumulative_usage_mb=5GB - -### Requirement: 支持按日期查询套餐流量详单 - -系统 SHALL 提供 API 查询指定套餐的每日流量记录。 - -#### Scenario: 查询套餐流量详单 -- **WHEN** 用户通过 GET /api/admin/package-usage/:id/daily-records 查询套餐流量详单 -- **THEN** 系统返回按日期排序的流量记录列表: - ```json - { - "code": 200, - "data": [ - { - "date": "2026-02-01", - "daily_usage_mb": 1024, - "cumulative_usage_mb": 1024 - }, - { - "date": "2026-02-02", - "daily_usage_mb": 2048, - "cumulative_usage_mb": 3072 - } - ] - } - ``` - -#### Scenario: 查询指定日期范围 -- **GIVEN** 套餐有 2月1日 至 2月28日 的流量记录 -- **WHEN** 用户查询流量详单,参数 start_date=2026-02-01, end_date=2026-02-10 -- **THEN** 系统返回 2月1日 至 2月10日 的流量记录(10条) - -#### Scenario: 客户端查询自己的流量详单 -- **WHEN** 客户通过 GET /api/customer/package-usage/:id/daily-records 查询 -- **THEN** 系统校验套餐归属后,返回流量记录列表 - -### Requirement: 日记录索引优化 - -系统 SHALL 在 PackageUsageDailyRecord 表创建 (package_usage_id, date) 联合唯一索引。 - -#### Scenario: 同一套餐同一天只有一条记录 -- **WHEN** 系统尝试为同一 package_usage_id=123 和 date=2026-02-10 创建第二条记录 -- **THEN** 数据库返回唯一约束冲突错误 -- **AND** 使用 UPSERT 自动转为 UPDATE 操作 - -#### Scenario: 查询性能达标 -- **GIVEN** 套餐 ID=123 有 365 条日记录(一年数据) -- **WHEN** 查询全部流量详单 -- **THEN** 查询响应时间 < 50ms - ---- - -## 边界条件 - -### 1. 套餐过期后的日记录 - -- **场景**:套餐在 2月28日过期,3月1日仍可查询历史日记录 -- **处理**:日记录永久保留,不随套餐过期删除 - -### 2. 并发写入同一天记录 - -- **场景**:同一套餐在同一天有多个并发流量扣减请求 -- **处理**:使用 UPSERT(ON CONFLICT UPDATE)确保幂等性 - -### 3. 跨月查询日记录 - -- **场景**:查询 1月15日 至 2月15日 的日记录(跨月) -- **处理**:按日期范围查询,返回跨月数据 - ---- - -## 并发场景 - -### Scenario: 并发写入同一天记录 -- **GIVEN** 套餐 ID=123 在 2026-02-10 10:00:00 和 10:00:01 同时扣减流量 -- **WHEN** 两个请求同时写入日记录 -- **THEN** 使用 UPSERT(ON CONFLICT UPDATE): - ```sql - INSERT INTO package_usage_daily_record (package_usage_id, date, daily_usage_mb, cumulative_usage_mb) - VALUES (123, '2026-02-10', 1024, 1024) - ON CONFLICT (package_usage_id, date) - DO UPDATE SET - daily_usage_mb = package_usage_daily_record.daily_usage_mb + EXCLUDED.daily_usage_mb, - cumulative_usage_mb = package_usage_daily_record.cumulative_usage_mb + EXCLUDED.daily_usage_mb; - ``` -- **AND** 两个请求的流量累加到同一条记录 - ---- - -## 异常处理 - -### 1. 日记录写入失败 - -- **错误场景**:流量扣减成功,但日记录写入失败(数据库连接断开) -- **处理流程**: - 1. 不回滚流量扣减(已提交) - 2. 记录 Error 日志(包含套餐ID、日期、流量增量) - 3. 通过定时任务补录日记录 -- **返回错误**:不影响用户,日记录补录在后台进行 - -### 2. 查询日记录超时 - -- **错误场景**:查询大量日记录时超时(如查询3年数据) -- **处理流程**: - 1. 限制单次查询最多返回 365 条记录 - 2. 如果超过限制,返回错误 400:"查询日期范围过大,最多查询1年" -- **返回错误**:`{"code": "DATE_RANGE_TOO_LARGE", "msg": "查询日期范围过大,最多查询1年"}` - ---- - -## 数据一致性保证 - -### 1. 事务边界 - -- **流量扣减 + 写入日记录**:使用单个事务(可选,根据业务需求) -- **查询日记录**:使用只读事务 - -### 2. 唯一索引 - -- **联合唯一索引**:`UNIQUE INDEX idx_package_usage_daily_record (package_usage_id, date)` -- **确保同一套餐同一天只有一条记录** - -### 3. UPSERT 幂等性 - -- **使用 ON CONFLICT UPDATE**:确保并发写入时累加流量而非覆盖 - ---- - -## 性能指标 - -| 操作 | 目标响应时间 | 并发要求 | 数据量 | -|------|-------------|---------|--------| -| 写入日记录(UPSERT) | < 10ms | 1000 QPS | 单条插入/更新 | -| 查询日记录(单套餐) | < 50ms | 100 QPS | 查询365条记录 | -| 查询日记录(日期范围) | < 100ms | 100 QPS | 查询指定范围 | - ---- - -## 错误码定义 - -| 错误码 | HTTP 状态码 | 错误消息 | 场景 | -|--------|------------|---------|------| -| `DATE_RANGE_TOO_LARGE` | 400 | 查询日期范围过大,最多查询1年 | 查询日记录日期范围超过365天 | -| `DAILY_RECORD_NOT_FOUND` | 404 | 未找到流量记录 | 查询不存在的日记录 | - ---- - -## 数据迁移策略 - -**激进策略**(开发阶段,保证干净性): - -### 1. ❌ 要删除的字段 - -目前 `package_usage_daily_record` 表中可能存在的冗余字段(需确认后删除): -- 如果有 `daily_increment` 字段(旧的增量字段) → **删除**,统一使用 `daily_usage_mb` -- 如果有 `total_usage` 字段(旧的累计字段) → **删除**,统一使用 `cumulative_usage_mb` - -### 2. ✅ 新增的字段 - -在 `package_usage_daily_record` 表中确保有以下字段: -```sql -CREATE TABLE IF NOT EXISTS package_usage_daily_record ( - id BIGSERIAL PRIMARY KEY, - package_usage_id BIGINT NOT NULL COMMENT '套餐使用记录ID', - date DATE NOT NULL COMMENT '日期', - daily_usage_mb INT DEFAULT 0 COMMENT '当日流量使用量(MB)', - cumulative_usage_mb BIGINT DEFAULT 0 COMMENT '截止当日的累计流量(MB)', - created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, - updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, - UNIQUE KEY idx_package_usage_daily_record (package_usage_id, date) -) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='套餐流量日记录'; - -CREATE INDEX idx_date ON package_usage_daily_record(date); -``` - -### 3. ❌ 要废弃的逻辑 - -- **废弃旧的日记录写入逻辑**:如果代码中存在不使用 UPSERT 的写入逻辑,全部删除 -- **废弃旧的日记录查询逻辑**:统一使用新的查询接口 - -### 4. ✅ 历史数据强制转换 - -```sql --- Step 1: 如果有旧的字段名,重命名 --- ALTER TABLE package_usage_daily_record CHANGE daily_increment daily_usage_mb INT; --- ALTER TABLE package_usage_daily_record CHANGE total_usage cumulative_usage_mb BIGINT; - --- Step 2: 修复 cumulative_usage_mb(如果历史数据不准确) --- 重新计算每个套餐的 cumulative_usage_mb --- (需要按套餐ID分组,按日期排序,累加 daily_usage_mb) - --- Step 3: 确保唯一索引存在 -CREATE UNIQUE INDEX IF NOT EXISTS idx_package_usage_daily_record -ON package_usage_daily_record(package_usage_id, date); -``` - -### 5. ❌ 删除遗留表/字段(确认后执行) - -```sql --- 如果存在旧的日记录表,删除 --- DROP TABLE IF EXISTS iot_card_usage_daily; - --- 如果存在旧的字段,删除 --- ALTER TABLE package_usage_daily_record DROP COLUMN IF EXISTS daily_increment; --- ALTER TABLE package_usage_daily_record DROP COLUMN IF EXISTS total_usage; -``` - -### 6. 验证步骤 - -```sql --- 验证1:所有日记录都有 daily_usage_mb 和 cumulative_usage_mb -SELECT COUNT(*) -FROM package_usage_daily_record -WHERE daily_usage_mb IS NULL OR cumulative_usage_mb IS NULL; --- 预期结果:0 - --- 验证2:同一套餐同一天只有一条记录 -SELECT package_usage_id, date, COUNT(*) -FROM package_usage_daily_record -GROUP BY package_usage_id, date -HAVING COUNT(*) > 1; --- 预期结果:0 rows - --- 验证3:累计流量单调递增(同一套餐) --- (需要编写复杂查询验证,略) -``` - ---- - -## 测试场景矩阵 - -| 场景分类 | 测试用例 | 预期结果 | -|---------|---------|---------| -| **写入日记录** | 首次记录当日流量 | 创建新记录 | -| | 同一天多次流量更新 | 更新已有记录(UPSERT) | -| | 跨天流量记录 | 创建多条记录 | -| **流量增量计算** | 计算每日流量增量 | daily_usage_mb = 今日累计 - 昨日累计 | -| | 上游周期重置后计算 | daily_usage_mb = 今日累计(重置后) | -| | 首日无昨日记录 | daily_usage_mb = 今日累计 | -| **查询日记录** | 查询套餐流量详单 | 返回按日期排序的记录列表 | -| | 查询指定日期范围 | 返回指定范围内的记录 | -| | 客户端查询自己的详单 | 校验归属后返回 | -| **索引和性能** | 同一套餐同一天只有一条记录 | 唯一约束保证 | -| | 查询365条记录 | 响应时间 < 50ms | -| **并发** | 并发写入同一天记录 | UPSERT 确保累加 | -| **异常** | 日记录写入失败 | 不回滚流量扣减,后台补录 | -| | 查询日记录超时 | 限制日期范围,返回错误 | - ---- - -## 实现参考 - -### 写入日记录(UPSERT) - -```go -// Service 层:RecordDailyUsage -func (s *Service) RecordDailyUsage(ctx context.Context, usageID uint, date time.Time, dailyUsageMB int, cumulativeUsageMB int64) error { - record := &model.PackageUsageDailyRecord{ - PackageUsageID: usageID, - Date: date, - DailyUsageMB: dailyUsageMB, - CumulativeUsageMB: cumulativeUsageMB, - } - - if err := s.store.UpsertDailyRecord(ctx, record); err != nil { - return errors.Wrap(errors.CodeInternalError, err, "写入流量日记录失败") - } - - return nil -} - -// Store 层:UpsertDailyRecord -func (s *Store) UpsertDailyRecord(ctx context.Context, record *model.PackageUsageDailyRecord) error { - // PostgreSQL UPSERT - return s.db.WithContext(ctx).Exec(` - INSERT INTO package_usage_daily_record (package_usage_id, date, daily_usage_mb, cumulative_usage_mb, created_at, updated_at) - VALUES (?, ?, ?, ?, NOW(), NOW()) - ON CONFLICT (package_usage_id, date) - DO UPDATE SET - daily_usage_mb = package_usage_daily_record.daily_usage_mb + EXCLUDED.daily_usage_mb, - cumulative_usage_mb = package_usage_daily_record.cumulative_usage_mb + (EXCLUDED.daily_usage_mb), - updated_at = NOW() - `, record.PackageUsageID, record.Date, record.DailyUsageMB, record.CumulativeUsageMB).Error -} -``` - -### 查询日记录 - -```go -// Handler: GetDailyRecords -func (h *Handler) GetDailyRecords(c *fiber.Ctx) error { - usageID, _ := c.ParamsInt("id") - startDate := c.Query("start_date", "") - endDate := c.Query("end_date", "") - - // 查询日记录 - records, err := h.service.GetDailyRecords(c.UserContext(), uint(usageID), startDate, endDate) - if err != nil { - return err - } - - return response.Success(c, records) -} - -// Service 层:GetDailyRecords -func (s *Service) GetDailyRecords(ctx context.Context, usageID uint, startDate, endDate string) ([]*model.PackageUsageDailyRecord, error) { - // 参数校验 - start, err := time.Parse("2006-01-02", startDate) - if err != nil { - return nil, errors.New(errors.CodeInvalidParam, "起始日期格式错误") - } - - end, err := time.Parse("2006-01-02", endDate) - if err != nil { - return nil, errors.New(errors.CodeInvalidParam, "结束日期格式错误") - } - - // 限制查询范围 - if end.Sub(start).Hours() > 365*24 { - return nil, errors.New(errors.CodeInvalidParam, "查询日期范围过大,最多查询1年") - } - - // 查询日记录 - return s.store.ListDailyRecords(ctx, usageID, start, end) -} - -// Store 层:ListDailyRecords -func (s *Store) ListDailyRecords(ctx context.Context, usageID uint, startDate, endDate time.Time) ([]*model.PackageUsageDailyRecord, error) { - var records []*model.PackageUsageDailyRecord - err := s.db.WithContext(ctx). - Where("package_usage_id = ? AND date >= ? AND date <= ?", usageID, startDate, endDate). - Order("date ASC"). - Find(&records).Error - return records, err -} -``` - ---- - -**本 Spec 完成**,包含: -- ✅ 业务背景和业务规则 -- ✅ 详细场景(写入、查询、增量计算) -- ✅ 边界条件和并发场景 -- ✅ 异常处理和数据一致性保证 -- ✅ 性能指标和错误码定义 -- ✅ **激进的数据迁移策略**(明确删除字段、废弃逻辑、强制转换) -- ✅ 测试场景矩阵和实现参考 +### Clarification: 套餐级日记录不受影响 +`tb_package_usage_daily_record`(套餐级日记录)由 `UsageService.updateDailyRecord()` 在轮询将增量记录到套餐已用量时同步写入,与本次改造的卡级 `tb_card_daily_usage` 是两个完全不同的维度,互不影响。 diff --git a/openspec/specs/traffic-daily-buffer/spec.md b/openspec/specs/traffic-daily-buffer/spec.md new file mode 100644 index 0000000..bd1f67c --- /dev/null +++ b/openspec/specs/traffic-daily-buffer/spec.md @@ -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。 diff --git a/openspec/specs/traffic-query-service/spec.md b/openspec/specs/traffic-query-service/spec.md new file mode 100644 index 0000000..5fa9379 --- /dev/null +++ b/openspec/specs/traffic-query-service/spec.md @@ -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 和查询逻辑。 diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 89ba877..db0e779 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -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" // 每日流量落盘 ) // 用户状态常量 diff --git a/pkg/constants/redis.go b/pkg/constants/redis.go index e4ff800..cb77cd4 100644 --- a/pkg/constants/redis.go +++ b/pkg/constants/redis.go @@ -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) +} diff --git a/pkg/queue/handler.go b/pkg/queue/handler.go index 829202e..09b11ec 100644 --- a/pkg/queue/handler.go +++ b/pkg/queue/handler.go @@ -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