修复设备套餐流量不同步的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m32s

This commit is contained in:
2026-04-22 18:04:53 +08:00
parent 6eb6d381f7
commit a207c51bfb
8 changed files with 182 additions and 42 deletions

View File

@@ -79,16 +79,17 @@ type BoundCardInfo struct {
// AssetRealtimeStatusResponse 资产实时状态只读DB/Redis
type AssetRealtimeStatusResponse struct {
AssetType string `json:"asset_type" description:"资产类型card 或 device"`
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已实名"`
RealnamePolicy string `json:"realname_policy,omitempty" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
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时有效"`
DeviceRealtime *DeviceGatewayInfo `json:"device_realtime" description:"设备实时状态(来自 Gateway sync-info始终返回失败时仅含 gateway_msg"`
AssetType string `json:"asset_type" description:"资产类型card 或 device"`
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已实名"`
RealnamePolicy string `json:"realname_policy,omitempty" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
CurrentMonthUsageMB float64 `json:"current_month_usage_mb,omitempty" description:"系统自然月累计流量(MBasset_type=card时有效,不等于运营商周期口径)"`
LastGatewayReadingMB float64 `json:"last_gateway_reading_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时有效"`
DeviceRealtime *DeviceGatewayInfo `json:"device_realtime" description:"设备实时状态(来自 Gateway sync-info始终返回失败时仅含 gateway_msg"`
}
// AssetPackageResponse 资产套餐信息

View File

@@ -45,7 +45,8 @@ type StandaloneIotCardResponse struct {
NetworkStatus int `json:"network_status" description:"网络状态 (0:停机, 1:开机)"`
NetworkStatusName string `json:"network_status_name" description:"网络状态名称(中文)"`
DataUsageMB int64 `json:"data_usage_mb" description:"累计流量使用(MB)"`
CurrentMonthUsageMB float64 `json:"current_month_usage_mb" description:"本月已用流量(MB)"`
CurrentMonthUsageMB float64 `json:"current_month_usage_mb" description:"系统自然月累计流量(MB不等于运营商周期口径)"`
LastGatewayReadingMB float64 `json:"last_gateway_reading_mb" description:"运营商当前周期累计流量读数(MB展示运营商口径时使用)"`
CurrentMonthStartDate *time.Time `json:"current_month_start_date" description:"本月开始日期"`
LastMonthTotalMB float64 `json:"last_month_total_mb" description:"上月流量总量(MB)"`
LastDataCheckAt *time.Time `json:"last_data_check_at" description:"最后流量检查时间"`

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)" 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"`
@@ -53,7 +53,7 @@ type IotCard struct {
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"`
DeviceVirtualNo string `gorm:"column:device_virtual_no;type:varchar(100);not null;default:'';comment:绑定设备的虚拟号快照(未绑定时为空字符串)" json:"device_virtual_no"`
LastGatewayReadingMB float64 `gorm:"column:last_gateway_reading_mb;type:float;not null;default:0;comment:上次轮询时网关返回的流量读数(MB)" json:"last_gateway_reading_mb"`
LastGatewayReadingMB float64 `gorm:"column:last_gateway_reading_mb;type:float;not null;default:0;comment:运营商当前周期累计流量读数(MB,来自网关)" json:"last_gateway_reading_mb"`
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)" json:"realname_policy"`
// ICCID 双列存储,用于支持 19/20 位 ICCID 精确路由查询

View File

@@ -347,6 +347,7 @@ func (s *Service) GetRealtimeStatus(ctx context.Context, assetType string, id ui
resp.NetworkStatus = card.NetworkStatus
resp.RealNameStatus = card.RealNameStatus
resp.CurrentMonthUsageMB = card.CurrentMonthUsageMB
resp.LastGatewayReadingMB = card.LastGatewayReadingMB
resp.LastSyncTime = card.LastSyncTime
case "device":

View File

@@ -35,7 +35,7 @@ type PollingCallback interface {
// 用于在手动刷新流量后触发套餐扣减,避免循环依赖
type DataDeductor interface {
// DeductDataUsage 按优先级扣减套餐流量
DeductDataUsage(ctx context.Context, carrierType string, carrierID uint, usageMB int64) error
DeductDataUsage(ctx context.Context, carrierType string, carrierID uint, usageMB float64) error
}
type Service struct {
@@ -279,6 +279,7 @@ func (s *Service) toStandaloneResponse(card *model.IotCard, shopMap map[uint]str
NetworkStatusName: constants.GetNetworkStatusName(card.NetworkStatus),
DataUsageMB: card.DataUsageMB,
CurrentMonthUsageMB: card.CurrentMonthUsageMB,
LastGatewayReadingMB: card.LastGatewayReadingMB,
CurrentMonthStartDate: card.CurrentMonthStartDate,
LastMonthTotalMB: card.LastMonthTotalMB,
LastDataCheckAt: card.LastDataCheckAt,
@@ -884,7 +885,7 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string)
// 有增量时触发套餐流量扣减
if flowIncrementMB > 0 && s.dataDeductor != nil {
if err := s.dataDeductor.DeductDataUsage(ctx, "iot_card", card.ID, int64(flowIncrementMB)); err != nil {
if err := s.dataDeductor.DeductDataUsage(ctx, constants.AssetTypeIotCard, card.ID, flowIncrementMB); err != nil {
s.logger.Warn("手动刷新:套餐流量扣减失败",
zap.Uint("card_id", card.ID),
zap.Float64("increment_mb", flowIncrementMB),

View File

@@ -2,6 +2,8 @@ package packagepkg
import (
"context"
"math"
"strconv"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
@@ -13,6 +15,13 @@ import (
"gorm.io/gorm"
)
const (
// usageRemainderTTL 流量小数余量缓存时长
usageRemainderTTL = 7 * 24 * time.Hour
// usageRemainderEpsilon 浮点运算容差
usageRemainderEpsilon = 1e-9
)
// StopResumeCallback 任务 24.6: 停复机回调接口
// 用于在流量用完时触发停机操作
type StopResumeCallback interface {
@@ -57,28 +66,20 @@ func (s *UsageService) SetStopResumeCallback(callback StopResumeCallback) {
// DeductDataUsage 任务 10.2-10.6: 按优先级扣减流量
// 扣减顺序:加油包(按 priority ASC → 主套餐
// 流量用完时自动标记 status=2所有套餐用完时触发停机
func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string, carrierID uint, usageMB int64) error {
func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string, carrierID uint, usageMB float64) error {
if usageMB <= 0 {
return errors.New(errors.CodeInvalidParam, "扣减流量必须大于0")
}
deductUsageMB := s.convertToDeductMB(ctx, carrierType, carrierID, usageMB)
if deductUsageMB <= 0 {
return nil
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 查询所有生效中的套餐(按优先级排序)
var packages []*model.PackageUsage
query := tx.Where("status = ?", constants.PackageUsageStatusActive)
if carrierType == "iot_card" {
query = query.Where("iot_card_id = ?", carrierID)
} else if carrierType == "device" {
query = query.Where("device_id = ?", carrierID)
} else {
return errors.New(errors.CodeInvalidParam, "无效的载体类型")
}
// 加油包按 priority ASC 排序,主套餐在后
if err := query.Order("CASE WHEN master_usage_id IS NOT NULL THEN 0 ELSE 1 END, priority ASC").
Find(&packages).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询生效套餐失败")
targetCarrierType, targetCarrierID, packages, err := s.resolveActivePackages(ctx, tx, carrierType, carrierID)
if err != nil {
return err
}
if len(packages) == 0 {
@@ -86,7 +87,7 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
}
// 按优先级扣减流量
remainingUsage := usageMB
remainingUsage := deductUsageMB
today := time.Now().Format("2006-01-02")
for _, pkg := range packages {
@@ -144,15 +145,15 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
// 如果流量扣减未完成,说明所有套餐都不够
if remainingUsage > 0 {
s.logger.Warn("流量不足",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.Int64("requested_mb", usageMB),
zap.String("carrier_type", targetCarrierType),
zap.Uint("carrier_id", targetCarrierID),
zap.Int64("requested_mb", deductUsageMB),
zap.Int64("remaining_mb", remainingUsage))
return errors.New(errors.CodeInsufficientQuota, "流量不足")
}
// 任务 10.5: 检查是否所有套餐都用完(触发停机)
if err := s.checkAndTriggerSuspension(ctx, tx, carrierType, carrierID); err != nil {
if err := s.checkAndTriggerSuspension(ctx, tx, targetCarrierType, targetCarrierID); err != nil {
return err
}
@@ -160,6 +161,132 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
})
}
// convertToDeductMB 将浮点增量转换为可扣减的整数 MB
// 规则:先合并 Redis 中的小数余量,再按整数 MB 扣减;余数回写 Redis
func (s *UsageService) convertToDeductMB(ctx context.Context, carrierType string, carrierID uint, usageMB float64) int64 {
deductUsageMB := int64(math.Floor(usageMB + usageRemainderEpsilon))
remainder := usageMB - float64(deductUsageMB)
if s.redis == nil {
return deductUsageMB
}
remainderKey := constants.RedisPackageUsageRemainderKey(carrierType, carrierID)
cachedRemainder, err := s.getUsageRemainder(ctx, remainderKey)
if err != nil {
s.logger.Warn("读取流量小数余量失败,降级为本次直接取整扣减",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.Float64("usage_mb", usageMB),
zap.Error(err))
return deductUsageMB
}
totalUsage := usageMB + cachedRemainder
deductUsageMB = int64(math.Floor(totalUsage + usageRemainderEpsilon))
remainder = totalUsage - float64(deductUsageMB)
if remainder < usageRemainderEpsilon {
remainder = 0
}
if err := s.persistUsageRemainder(ctx, remainderKey, remainder); err != nil {
s.logger.Warn("写入流量小数余量失败,本次仅按整数扣减",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.Float64("remainder_mb", remainder),
zap.Error(err))
}
return deductUsageMB
}
// getUsageRemainder 读取载体流量小数余量
func (s *UsageService) getUsageRemainder(ctx context.Context, key string) (float64, error) {
value, err := s.redis.Get(ctx, key).Result()
if err == redis.Nil {
return 0, nil
}
if err != nil {
return 0, err
}
remainder, parseErr := strconv.ParseFloat(value, 64)
if parseErr != nil {
// 脏值直接删除,避免持续影响后续扣减
_ = s.redis.Del(ctx, key).Err()
return 0, parseErr
}
return remainder, nil
}
// persistUsageRemainder 持久化载体流量小数余量
func (s *UsageService) persistUsageRemainder(ctx context.Context, key string, remainder float64) error {
if remainder <= usageRemainderEpsilon {
return s.redis.Del(ctx, key).Err()
}
value := strconv.FormatFloat(remainder, 'f', 6, 64)
return s.redis.Set(ctx, key, value, usageRemainderTTL).Err()
}
// resolveActivePackages 统一解析可扣减载体
// 优先按请求载体查找;当 iot_card 无生效套餐且存在设备绑定时,回退到 device 载体
func (s *UsageService) resolveActivePackages(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (string, uint, []*model.PackageUsage, error) {
packages, err := s.queryActivePackages(tx, carrierType, carrierID)
if err != nil {
return "", 0, nil, err
}
if len(packages) > 0 {
return carrierType, carrierID, packages, nil
}
if carrierType != constants.AssetTypeIotCard {
return carrierType, carrierID, packages, nil
}
binding, err := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, carrierID)
if err == gorm.ErrRecordNotFound {
return carrierType, carrierID, packages, nil
}
if err != nil {
return "", 0, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡绑定设备失败")
}
devicePackages, queryErr := s.queryActivePackages(tx, constants.AssetTypeDevice, binding.DeviceID)
if queryErr != nil {
return "", 0, nil, queryErr
}
if len(devicePackages) == 0 {
return carrierType, carrierID, packages, nil
}
s.logger.Info("卡载体无生效套餐,回退设备载体扣减流量",
zap.Uint("card_id", carrierID),
zap.Uint("device_id", binding.DeviceID))
return constants.AssetTypeDevice, binding.DeviceID, devicePackages, nil
}
// queryActivePackages 查询指定载体下的生效套餐(按扣减优先级排序)
func (s *UsageService) queryActivePackages(tx *gorm.DB, carrierType string, carrierID uint) ([]*model.PackageUsage, error) {
query := tx.Where("status = ?", constants.PackageUsageStatusActive)
switch carrierType {
case constants.AssetTypeIotCard:
query = query.Where("iot_card_id = ?", carrierID)
case constants.AssetTypeDevice:
query = query.Where("device_id = ?", carrierID)
default:
return nil, errors.New(errors.CodeInvalidParam, "无效的载体类型")
}
var packages []*model.PackageUsage
if err := query.
Order("CASE WHEN master_usage_id IS NOT NULL THEN 0 ELSE 1 END, priority ASC").
Find(&packages).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询生效套餐失败")
}
return packages, nil
}
// updateDailyRecord 任务 10.6: 更新日流量记录
func (s *UsageService) updateDailyRecord(ctx context.Context, tx *gorm.DB, packageUsageID uint, dateStr string, dailyUsageMB, cumulativeUsageMB int64) error {
// 解析日期字符串
@@ -207,10 +334,12 @@ func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.D
query := tx.Model(&model.PackageUsage{}).
Where("status = ?", constants.PackageUsageStatusActive)
if carrierType == "iot_card" {
if carrierType == constants.AssetTypeIotCard {
query = query.Where("iot_card_id = ?", carrierID)
} else if carrierType == "device" {
} else if carrierType == constants.AssetTypeDevice {
query = query.Where("device_id = ?", carrierID)
} else {
return errors.New(errors.CodeInvalidParam, "无效的载体类型")
}
if err := query.Count(&activeCount).Error; err != nil {
@@ -223,7 +352,7 @@ func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.D
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
if carrierType == "iot_card" {
if carrierType == constants.AssetTypeIotCard {
if s.stopResumeCallback != nil {
go func() {
stopCtx := context.Background()
@@ -234,7 +363,7 @@ func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.D
}
}()
}
} else if carrierType == "device" {
} else if carrierType == constants.AssetTypeDevice {
if s.stopResumeCallback == nil {
s.logger.Warn("停复机回调未注入,跳过设备绑定卡停机",
zap.Uint("device_id", carrierID))

View File

@@ -120,7 +120,7 @@ func (h *PollingCarddataHandler) Handle(ctx context.Context, t *asynq.Task) erro
h.base.updateCardCache(ctx, cardID, cacheUpdates)
if flowIncrementMB > 0 && h.usageService != nil {
if deductErr := h.usageService.DeductDataUsage(ctx, "iot_card", cardID, int64(flowIncrementMB)); deductErr != nil {
if deductErr := h.usageService.DeductDataUsage(ctx, constants.AssetTypeIotCard, cardID, flowIncrementMB); deductErr != nil {
h.base.logger.Warn("套餐流量扣减失败",
zap.Uint("card_id", cardID), zap.Float64("increment_mb", flowIncrementMB), zap.Error(deductErr))
}

View File

@@ -408,6 +408,13 @@ func RedisCardDailyTrafficKey(cardID uint, date string) string {
return fmt.Sprintf("traffic:daily:%d:%s", cardID, date)
}
// RedisPackageUsageRemainderKey 生成套餐扣减流量小数余量缓存键
// 用途:缓存载体维度的 <1MB 小数余量,累计后再触发整数 MB 扣减
// 过期时间7 天
func RedisPackageUsageRemainderKey(carrierType string, carrierID uint) string {
return fmt.Sprintf("package:usage:remainder:%s:%d", carrierType, carrierID)
}
// ========================================
// 轮询分片队列相关 Redis Key
// ========================================