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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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