refactor: 流量系统重构 — 增量累加算法 + 日粒度缓冲 + 旧详单清理
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m16s
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m16s
核心改造: - 增量算法:流量计算从覆盖式改为增量累加(gateway - lastReading),支持上游运营商重置检测 - 日流量缓冲:insertDataUsageRecord 改为 Redis INCRBYFLOAT,每日凌晨落盘到 tb_card_daily_usage - 运营商:新增 data_reset_day 字段(联通=27,其余=1) - IoT卡:新增 last_gateway_reading_mb 字段存储上次网关读数 - 查询层:新建 TrafficQueryService 合并 Redis(今日)+ DB(历史)数据源 - 清理:删除 DataUsageRecord model/store,移除 polling_handler 旧引用 迁移:000094-000097(carrier字段、iot_card字段、数据初始化、日流量表)
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
18
internal/model/card_daily_usage.go
Normal file
18
internal/model/card_daily_usage.go
Normal file
@@ -0,0 +1,18 @@
|
||||
package model
|
||||
|
||||
import "time"
|
||||
|
||||
// CardDailyUsage 卡日流量记录模型
|
||||
// 每卡每天一条记录,由每日落盘任务从 Redis 写入
|
||||
type CardDailyUsage struct {
|
||||
ID uint `gorm:"column:id;primaryKey;comment:记录ID" json:"id"`
|
||||
IotCardID uint `gorm:"column:iot_card_id;not null;uniqueIndex:uk_card_daily;comment:IoT卡ID" json:"iot_card_id"`
|
||||
Date time.Time `gorm:"column:date;type:date;not null;uniqueIndex:uk_card_daily;comment:日期" json:"date"`
|
||||
UsageMB float64 `gorm:"column:usage_mb;type:numeric(12,2);not null;default:0;comment:当日流量使用量(MB)" json:"usage_mb"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;autoCreateTime;comment:创建时间" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime;comment:更新时间" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (CardDailyUsage) TableName() string {
|
||||
return "tb_card_daily_usage"
|
||||
}
|
||||
@@ -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 指定表名
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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时有效)"`
|
||||
|
||||
@@ -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:禁用)"`
|
||||
|
||||
@@ -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 指定表名
|
||||
|
||||
@@ -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,
|
||||
|
||||
91
internal/service/traffic/query_service.go
Normal file
91
internal/service/traffic/query_service.go
Normal file
@@ -0,0 +1,91 @@
|
||||
// Package traffic 提供流量数据查询服务
|
||||
// 合并 Redis 缓冲(今日)和 DB(历史)两段数据源,对外提供统一的日粒度流量查询
|
||||
package traffic
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sort"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/redis/go-redis/v9"
|
||||
)
|
||||
|
||||
// DailyUsageItem 日粒度流量记录
|
||||
type DailyUsageItem struct {
|
||||
Date string `json:"date"` // 日期,格式 YYYY-MM-DD
|
||||
UsageMB float64 `json:"usage_mb"` // 当天累计使用流量(MB)
|
||||
}
|
||||
|
||||
// QueryService 流量查询服务
|
||||
// 合并 Redis(当天实时)和 DB(历史落盘)两段数据源
|
||||
type QueryService struct {
|
||||
redis *redis.Client
|
||||
cardDailyUsageStore *postgres.CardDailyUsageStore
|
||||
}
|
||||
|
||||
// NewQueryService 创建流量查询服务实例
|
||||
func NewQueryService(redisClient *redis.Client, cardDailyUsageStore *postgres.CardDailyUsageStore) *QueryService {
|
||||
return &QueryService{
|
||||
redis: redisClient,
|
||||
cardDailyUsageStore: cardDailyUsageStore,
|
||||
}
|
||||
}
|
||||
|
||||
// GetDailyUsage 查询卡在指定日期范围内的日粒度流量
|
||||
// 今天的数据从 Redis 缓冲读取(可能尚未落盘),历史数据从 DB 读取,合并后按日期 ASC 排序返回
|
||||
func (s *QueryService) GetDailyUsage(ctx context.Context, cardID uint, startDate, endDate time.Time) ([]*DailyUsageItem, error) {
|
||||
now := time.Now()
|
||||
todayStr := now.Format("2006-01-02")
|
||||
|
||||
// 判断结束日期是否包含今天
|
||||
endDateStr := endDate.Format("2006-01-02")
|
||||
includeToday := endDateStr >= todayStr
|
||||
|
||||
// 历史部分:从 DB 查询(排除今天,因为今天的数据可能还在 Redis 中)
|
||||
dbEndDate := endDate
|
||||
if includeToday {
|
||||
// DB 只查到昨天,今天从 Redis 取
|
||||
dbEndDate = now.AddDate(0, 0, -1)
|
||||
}
|
||||
|
||||
var items []*DailyUsageItem
|
||||
|
||||
// 只有 startDate <= dbEndDate 时才查 DB
|
||||
if !startDate.After(dbEndDate) {
|
||||
records, err := s.cardDailyUsageStore.ListByCardIDAndDateRange(ctx, cardID, startDate, dbEndDate)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range records {
|
||||
items = append(items, &DailyUsageItem{
|
||||
Date: r.Date.Format("2006-01-02"),
|
||||
UsageMB: r.UsageMB,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 今天部分:从 Redis 读取
|
||||
if includeToday && !startDate.After(now) {
|
||||
key := constants.RedisCardDailyTrafficKey(cardID, todayStr)
|
||||
val, err := s.redis.Get(ctx, key).Result()
|
||||
if err == nil && val != "" {
|
||||
if usageMB, parseErr := strconv.ParseFloat(val, 64); parseErr == nil && usageMB > 0 {
|
||||
items = append(items, &DailyUsageItem{
|
||||
Date: todayStr,
|
||||
UsageMB: usageMB,
|
||||
})
|
||||
}
|
||||
}
|
||||
// Redis key 不存在(redis.Nil)时忽略,表示今天暂无流量
|
||||
}
|
||||
|
||||
// 按日期 ASC 排序
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
return items[i].Date < items[j].Date
|
||||
})
|
||||
|
||||
return items, nil
|
||||
}
|
||||
57
internal/store/postgres/card_daily_usage_store.go
Normal file
57
internal/store/postgres/card_daily_usage_store.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package postgres
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
)
|
||||
|
||||
// CardDailyUsageStore 卡日流量记录存储
|
||||
// 同时供落盘任务和查询层使用
|
||||
type CardDailyUsageStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewCardDailyUsageStore(db *gorm.DB) *CardDailyUsageStore {
|
||||
return &CardDailyUsageStore{db: db}
|
||||
}
|
||||
|
||||
// Upsert 覆盖写入日流量记录(幂等安全)
|
||||
// ON CONFLICT (iot_card_id, date) DO UPDATE SET usage_mb = EXCLUDED.usage_mb
|
||||
func (s *CardDailyUsageStore) Upsert(ctx context.Context, record *model.CardDailyUsage) error {
|
||||
return s.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "iot_card_id"}, {Name: "date"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"usage_mb", "updated_at"}),
|
||||
}).
|
||||
Create(record).Error
|
||||
}
|
||||
|
||||
// UpsertBatch 批量覆盖写入日流量记录
|
||||
func (s *CardDailyUsageStore) UpsertBatch(ctx context.Context, records []*model.CardDailyUsage) error {
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "iot_card_id"}, {Name: "date"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{"usage_mb", "updated_at"}),
|
||||
}).
|
||||
CreateInBatches(records, 200).Error
|
||||
}
|
||||
|
||||
// ListByCardIDAndDateRange 查询卡在日期范围内的日流量记录
|
||||
func (s *CardDailyUsageStore) ListByCardIDAndDateRange(ctx context.Context, cardID uint, startDate, endDate time.Time) ([]*model.CardDailyUsage, error) {
|
||||
var records []*model.CardDailyUsage
|
||||
if err := s.db.WithContext(ctx).
|
||||
Where("iot_card_id = ? AND date >= ? AND date <= ?", cardID, startDate, endDate).
|
||||
Order("date ASC").
|
||||
Find(&records).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return records, nil
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
151
internal/task/daily_traffic_flush.go
Normal file
151
internal/task/daily_traffic_flush.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package task
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hibiken/asynq"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"go.uber.org/zap"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||
)
|
||||
|
||||
// DailyTrafficFlushHandler 每日流量落盘任务处理器
|
||||
// 凌晨 2 点分批 SCAN 昨日 Redis key → UPSERT 到 tb_card_daily_usage → 删 key
|
||||
type DailyTrafficFlushHandler struct {
|
||||
redis *redis.Client
|
||||
cardDailyUsageStore *postgres.CardDailyUsageStore
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func NewDailyTrafficFlushHandler(
|
||||
redisClient *redis.Client,
|
||||
cardDailyUsageStore *postgres.CardDailyUsageStore,
|
||||
logger *zap.Logger,
|
||||
) *DailyTrafficFlushHandler {
|
||||
return &DailyTrafficFlushHandler{
|
||||
redis: redisClient,
|
||||
cardDailyUsageStore: cardDailyUsageStore,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *DailyTrafficFlushHandler) HandleDailyTrafficFlush(ctx context.Context, _ *asynq.Task) error {
|
||||
startTime := time.Now()
|
||||
|
||||
yesterday := time.Now().AddDate(0, 0, -1).Format("2006-01-02")
|
||||
pattern := fmt.Sprintf("traffic:daily:*:%s", yesterday)
|
||||
|
||||
h.logger.Info("开始每日流量落盘",
|
||||
zap.String("date", yesterday),
|
||||
zap.String("pattern", pattern))
|
||||
|
||||
// 分批 SCAN 收集所有昨日 key
|
||||
var allKeys []string
|
||||
var cursor uint64
|
||||
for {
|
||||
keys, nextCursor, err := h.redis.Scan(ctx, cursor, pattern, 500).Result()
|
||||
if err != nil {
|
||||
h.logger.Error("SCAN Redis key 失败", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
allKeys = append(allKeys, keys...)
|
||||
cursor = nextCursor
|
||||
if cursor == 0 {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if len(allKeys) == 0 {
|
||||
h.logger.Info("无需落盘,昨日无流量数据", zap.String("date", yesterday))
|
||||
return nil
|
||||
}
|
||||
|
||||
h.logger.Info("收集到待落盘 key",
|
||||
zap.String("date", yesterday),
|
||||
zap.Int("count", len(allKeys)))
|
||||
|
||||
yesterdayDate, _ := time.Parse("2006-01-02", yesterday)
|
||||
|
||||
// 分批处理:每批 200 条
|
||||
flushedCount := 0
|
||||
for i := 0; i < len(allKeys); i += 200 {
|
||||
end := i + 200
|
||||
if end > len(allKeys) {
|
||||
end = len(allKeys)
|
||||
}
|
||||
batch := allKeys[i:end]
|
||||
|
||||
var records []*model.CardDailyUsage
|
||||
var flushedKeys []string
|
||||
|
||||
for _, key := range batch {
|
||||
cardID, err := parseCardIDFromKey(key)
|
||||
if err != nil {
|
||||
h.logger.Warn("解析 Redis key 失败", zap.String("key", key), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
val, err := h.redis.Get(ctx, key).Float64()
|
||||
if err != nil {
|
||||
h.logger.Warn("读取 Redis 值失败", zap.String("key", key), zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
records = append(records, &model.CardDailyUsage{
|
||||
IotCardID: cardID,
|
||||
Date: yesterdayDate,
|
||||
UsageMB: val,
|
||||
})
|
||||
flushedKeys = append(flushedKeys, key)
|
||||
}
|
||||
|
||||
// 批量 UPSERT 到 DB
|
||||
if len(records) > 0 {
|
||||
if err := h.cardDailyUsageStore.UpsertBatch(ctx, records); err != nil {
|
||||
h.logger.Error("批量 UPSERT 失败",
|
||||
zap.Int("batch_size", len(records)),
|
||||
zap.Error(err))
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// 批量删除已落盘的 Redis key
|
||||
if len(flushedKeys) > 0 {
|
||||
pipe := h.redis.Pipeline()
|
||||
for _, key := range flushedKeys {
|
||||
pipe.Del(ctx, key)
|
||||
}
|
||||
if _, err := pipe.Exec(ctx); err != nil {
|
||||
h.logger.Warn("批量删除 Redis key 失败", zap.Error(err))
|
||||
}
|
||||
}
|
||||
|
||||
flushedCount += len(records)
|
||||
}
|
||||
|
||||
h.logger.Info("每日流量落盘完成",
|
||||
zap.String("date", yesterday),
|
||||
zap.Int("flushed_count", flushedCount),
|
||||
zap.Duration("duration", time.Since(startTime)))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// parseCardIDFromKey 从 Redis key "traffic:daily:{cardID}:{date}" 中解析 cardID
|
||||
func parseCardIDFromKey(key string) (uint, error) {
|
||||
parts := strings.Split(key, ":")
|
||||
if len(parts) != 4 {
|
||||
return 0, fmt.Errorf("invalid key format: %s", key)
|
||||
}
|
||||
id, err := strconv.ParseUint(parts[2], 10, 64)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return uint(id), nil
|
||||
}
|
||||
@@ -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
|
||||
|
||||
1
migrations/000094_add_data_reset_day_to_carrier.down.sql
Normal file
1
migrations/000094_add_data_reset_day_to_carrier.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE tb_carrier DROP COLUMN IF EXISTS data_reset_day;
|
||||
8
migrations/000094_add_data_reset_day_to_carrier.up.sql
Normal file
8
migrations/000094_add_data_reset_day_to_carrier.up.sql
Normal file
@@ -0,0 +1,8 @@
|
||||
-- 运营商新增上游流量重置日字段
|
||||
-- 用于检测上游网关流量值下降是否为正常自然月重置(与套餐级 data_reset_cycle 完全独立)
|
||||
ALTER TABLE tb_carrier ADD COLUMN data_reset_day INT NOT NULL DEFAULT 1;
|
||||
COMMENT ON COLUMN tb_carrier.data_reset_day IS '上游流量重置日(1-28),运营商每月清零网关计数器的日期';
|
||||
|
||||
-- 初始化已有运营商:联通=27,其余=1
|
||||
UPDATE tb_carrier SET data_reset_day = 27 WHERE carrier_type = 'CUCC';
|
||||
UPDATE tb_carrier SET data_reset_day = 1 WHERE carrier_type != 'CUCC';
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE tb_iot_card DROP COLUMN IF EXISTS last_gateway_reading_mb;
|
||||
@@ -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),用于计算增量';
|
||||
2
migrations/000096_init_last_gateway_reading_mb.down.sql
Normal file
2
migrations/000096_init_last_gateway_reading_mb.down.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
-- 回滚:重置所有卡的 last_gateway_reading_mb 为 0
|
||||
UPDATE tb_iot_card SET last_gateway_reading_mb = 0 WHERE last_gateway_reading_mb > 0;
|
||||
5
migrations/000096_init_last_gateway_reading_mb.up.sql
Normal file
5
migrations/000096_init_last_gateway_reading_mb.up.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- 数据初始化:将所有卡的 last_gateway_reading_mb 设为当前 current_month_usage_mb
|
||||
-- 避免上线后第一次轮询把整个已用量当作增量
|
||||
UPDATE tb_iot_card
|
||||
SET last_gateway_reading_mb = current_month_usage_mb
|
||||
WHERE last_gateway_reading_mb = 0 AND current_month_usage_mb > 0;
|
||||
1
migrations/000097_create_card_daily_usage.down.sql
Normal file
1
migrations/000097_create_card_daily_usage.down.sql
Normal file
@@ -0,0 +1 @@
|
||||
DROP TABLE IF EXISTS tb_card_daily_usage;
|
||||
10
migrations/000097_create_card_daily_usage.up.sql
Normal file
10
migrations/000097_create_card_daily_usage.up.sql
Normal file
@@ -0,0 +1,10 @@
|
||||
CREATE TABLE IF NOT EXISTS tb_card_daily_usage (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
iot_card_id BIGINT NOT NULL,
|
||||
date DATE NOT NULL,
|
||||
usage_mb NUMERIC(12,2) NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ DEFAULT NOW(),
|
||||
CONSTRAINT uk_card_daily UNIQUE (iot_card_id, date)
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_card_daily_date ON tb_card_daily_usage (date);
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-28
|
||||
@@ -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)
|
||||
@@ -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` 配置不受影响
|
||||
@@ -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 日志并丢弃异常值
|
||||
@@ -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)`。消除两个独立增量计算函数的不一致风险。
|
||||
@@ -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` 是两个完全不同的维度,互不影响。
|
||||
@@ -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。
|
||||
@@ -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 和查询逻辑。
|
||||
@@ -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"` 确认旧代码已清理(只允许出现在迁移文件或注释中)
|
||||
@@ -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 层继续处理钱包扣款逻辑
|
||||
|
||||
@@ -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),继续返回已有数据,不返回错误给调用方
|
||||
|
||||
@@ -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 自动触发套餐暂停联动逻辑
|
||||
|
||||
@@ -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 日志并丢弃异常值
|
||||
|
||||
@@ -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=已退款。
|
||||
|
||||
@@ -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)`。消除两个独立增量计算函数的不一致风险。
|
||||
|
||||
@@ -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` 是两个完全不同的维度,互不影响。
|
||||
|
||||
30
openspec/specs/traffic-daily-buffer/spec.md
Normal file
30
openspec/specs/traffic-daily-buffer/spec.md
Normal file
@@ -0,0 +1,30 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 流量增量写入 Redis 缓冲
|
||||
轮询检测到流量增量 > 0 时,系统 SHALL 使用 `INCRBYFLOAT` 将增量写入 Redis key(格式 `traffic:daily:{card_id}:{YYYY-MM-DD}`),TTL 48 小时。增量 <= 0 时 SHALL NOT 写入。**直接替换旧的 `insertDataUsageRecord()` 写 DB 路径,不双写。**
|
||||
|
||||
#### Scenario: 有流量增量时写 Redis
|
||||
- **WHEN** 轮询检测到卡 ID=100 今日流量增量为 5.2 MB
|
||||
- **THEN** 系统 SHALL 执行 `INCRBYFLOAT traffic:daily:100:2026-03-28 5.2`,并设置 48h TTL
|
||||
|
||||
#### Scenario: 无流量增量时跳过
|
||||
- **WHEN** 轮询检测到流量增量 <= 0
|
||||
- **THEN** 系统 SHALL NOT 执行任何 Redis 或 DB 写入
|
||||
|
||||
### Requirement: 每日落盘定时任务
|
||||
系统 SHALL 每天凌晨 2 点(Asia/Shanghai)通过 Asynq Scheduler 触发落盘任务。
|
||||
|
||||
#### Scenario: 正常落盘(分批处理)
|
||||
- **WHEN** 凌晨 2 点落盘任务执行
|
||||
- **THEN** 系统 SHALL 分批 SCAN 所有 `traffic:daily:*:{昨日}` key(每批 COUNT 500)
|
||||
- **AND** 分批 UPSERT 到 `tb_card_daily_usage`(每批 200 条,**覆盖语义**保证幂等:`ON CONFLICT DO UPDATE SET usage_mb = EXCLUDED.usage_mb`)
|
||||
- **AND** 分批 Pipeline 删除已落盘的 Redis key
|
||||
- **AND** 记录日志:落盘条数、耗时
|
||||
|
||||
#### Scenario: 落盘失败重试
|
||||
- **WHEN** 落盘任务执行失败
|
||||
- **THEN** Asynq SHALL 自动重试(MaxRetry=3,超时 5 分钟),Redis key 因 48h TTL 仍可用
|
||||
- **AND** 因 UPSERT 使用覆盖语义,重试时不会导致数据翻倍
|
||||
|
||||
### Requirement: tb_card_daily_usage 表结构
|
||||
`usage_mb` 字段 SHALL 使用 `NUMERIC(12,2)` 类型(匹配 Redis `INCRBYFLOAT` 的浮点精度和现有 `current_month_usage_mb` 的 `decimal(10,2)` 类型),不使用 BIGINT。
|
||||
15
openspec/specs/traffic-query-service/spec.md
Normal file
15
openspec/specs/traffic-query-service/spec.md
Normal file
@@ -0,0 +1,15 @@
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: 统一流量查询服务
|
||||
`TrafficQueryService.GetDailyUsage()` SHALL 合并 Redis(今日)和 DB(历史)两段数据,返回指定日期范围内的每日流量列表。
|
||||
|
||||
#### Scenario: 查询含今日的日期范围
|
||||
- **WHEN** 查询 2026-03-25 到 2026-03-28(今天)
|
||||
- **THEN** 系统 SHALL 从 `tb_card_daily_usage` 查 3/25~3/27,从 Redis 查 3/28,合并排序后返回
|
||||
|
||||
#### Scenario: 查询纯历史日期范围
|
||||
- **WHEN** 查询 2026-03-01 到 2026-03-20
|
||||
- **THEN** 系统 SHALL 仅从 `tb_card_daily_usage` 查询,不访问 Redis
|
||||
|
||||
### Requirement: CardDailyUsageStore 共用
|
||||
落盘任务(`daily_traffic_flush.go`)和查询服务(`TrafficQueryService`)SHALL 共用同一个 `CardDailyUsageStore`,避免重复实现 UPSERT 和查询逻辑。
|
||||
@@ -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" // 每日流量落盘
|
||||
)
|
||||
|
||||
// 用户状态常量
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user