package packagepkg import ( "context" "math" "strconv" "time" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/redis/go-redis/v9" "go.uber.org/zap" "gorm.io/gorm" "gorm.io/gorm/clause" ) const ( // usageRemainderTTL 流量小数余量缓存时长 usageRemainderTTL = 7 * 24 * time.Hour // usageRemainderEpsilon 浮点运算容差 usageRemainderEpsilon = 1e-9 ) // StopResumeCallback 任务 24.6: 停复机回调接口 // 用于在流量用完时触发停机操作 type StopResumeCallback interface { // CheckAndStopCard 检查流量耗尽并停机 CheckAndStopCard(ctx context.Context, cardID uint) error } type UsageService struct { db *gorm.DB redis *redis.Client packageUsageStore *postgres.PackageUsageStore packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore deviceSimBindingStore *postgres.DeviceSimBindingStore logger *zap.Logger stopResumeCallback StopResumeCallback // 停复机回调,可选 } func NewUsageService( db *gorm.DB, redis *redis.Client, packageUsageStore *postgres.PackageUsageStore, packageUsageDailyRecord *postgres.PackageUsageDailyRecordStore, deviceSimBindingStore *postgres.DeviceSimBindingStore, logger *zap.Logger, ) *UsageService { return &UsageService{ db: db, redis: redis, packageUsageStore: packageUsageStore, packageUsageDailyRecord: packageUsageDailyRecord, deviceSimBindingStore: deviceSimBindingStore, logger: logger, } } // SetStopResumeCallback 任务 24.6: 设置停复机回调 // 在应用启动时由 bootstrap 调用,注入停复机服务 func (s *UsageService) SetStopResumeCallback(callback StopResumeCallback) { s.stopResumeCallback = callback } // DeductDataUsage 任务 10.2-10.6: 按优先级扣减流量 // 扣减顺序:加油包(按 priority ASC) → 主套餐 // 真实用量允许超过套餐阈值,超额部分记入最后一个生效套餐,用于后续虚流量阈值评估。 // 流量用完时自动标记 status=2,所有套餐用完时触发停机。 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 } shouldSuspend := false suspendCarrierType := "" var suspendCarrierID uint err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { targetCarrierType, targetCarrierID, packages, err := s.resolveActivePackages(ctx, tx, carrierType, carrierID) if err != nil { return err } today := time.Now().Format("2006-01-02") if len(packages) == 0 { // 无生效套餐:尝试找 Depleted 套餐记录溢出流量详单,避免详单断档 if recordErr := s.recordOverflowToDepletedPackage(ctx, tx, targetCarrierType, targetCarrierID, deductUsageMB, today); recordErr != nil { s.logger.Warn("记录溢出流量到已耗尽套餐失败", zap.String("carrier_type", targetCarrierType), zap.Uint("carrier_id", targetCarrierID), zap.Int64("usage_mb", deductUsageMB), zap.Error(recordErr)) } return errors.New(errors.CodeNoAvailablePackage, "没有可用套餐") } // 按优先级扣减流量 remainingUsage := deductUsageMB for index, pkg := range packages { if remainingUsage <= 0 { break } // 计算当前套餐剩余额度 effectiveThresholdMB := pkg.EffectiveThresholdMB() remainingQuota := pkg.RemainingQuotaMB() isLastPackage := index == len(packages)-1 if remainingQuota <= 0 && !isLastPackage { // 套餐已用完,标记为已用完 if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐状态失败") } continue } // 本次从该套餐扣减的流量 var deductFromPkg int64 if isLastPackage { deductFromPkg = remainingUsage } else if remainingUsage <= remainingQuota { deductFromPkg = remainingUsage } else { deductFromPkg = remainingQuota } if deductFromPkg <= 0 { if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐状态失败") } continue } // 更新套餐使用量 newUsage := pkg.DataUsageMB + deductFromPkg updates := map[string]interface{}{ "data_usage_mb": newUsage, } // 检查是否用完 if effectiveThresholdMB > 0 && newUsage >= effectiveThresholdMB { updates["status"] = constants.PackageUsageStatusDepleted } if err := tx.Model(pkg).Updates(updates).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐使用量失败") } // 任务 10.6: 写入日记录 if err := s.updateDailyRecord(ctx, tx, pkg.ID, today, deductFromPkg, newUsage); err != nil { return err } remainingUsage -= deductFromPkg s.logger.Info("扣减套餐流量", zap.Uint("usage_id", pkg.ID), zap.Int64("deduct_mb", deductFromPkg), zap.Int64("new_usage_mb", newUsage), zap.Int64("effective_threshold_mb", effectiveThresholdMB), zap.Int64("data_limit_mb", pkg.DataLimitMB), zap.Int64("remaining_quota_mb", remainingQuota), zap.Bool("over_threshold", effectiveThresholdMB > 0 && newUsage > effectiveThresholdMB)) } // 任务 10.5: 检查是否所有套餐都用完(触发停机) shouldSuspendCurrent, err := s.checkAndTriggerSuspension(ctx, tx, targetCarrierType, targetCarrierID) if err != nil { return err } shouldSuspend = shouldSuspendCurrent suspendCarrierType = targetCarrierType suspendCarrierID = targetCarrierID return nil }) if err != nil { return err } if shouldSuspend { s.triggerSuspensionAfterCommit(suspendCarrierType, suspendCarrierID) } return nil } // 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. Clauses(clause.Locking{Strength: "UPDATE"}). Order("CASE WHEN master_usage_id IS NOT NULL THEN 0 ELSE 1 END, priority ASC, id 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 { // 解析日期字符串 date, err := time.Parse("2006-01-02", dateStr) if err != nil { return errors.Wrap(errors.CodeInvalidParam, err, "日期格式错误") } // 查询是否已有今日记录 var record model.PackageUsageDailyRecord err = tx.Where("package_usage_id = ? AND date = ?", packageUsageID, date). First(&record).Error if err == gorm.ErrRecordNotFound { // 创建新记录 record = model.PackageUsageDailyRecord{ PackageUsageID: packageUsageID, Date: date, DailyUsageMB: int(dailyUsageMB), CumulativeUsageMB: cumulativeUsageMB, } if err := tx.Create(&record).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "创建日流量记录失败") } } else if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "查询日流量记录失败") } else { // 更新现有记录 updates := map[string]interface{}{ "daily_usage_mb": record.DailyUsageMB + int(dailyUsageMB), "cumulative_usage_mb": cumulativeUsageMB, } if err := tx.Model(&record).Updates(updates).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新日流量记录失败") } } return nil } // checkAndTriggerSuspension 任务 10.5: 检查停机条件 func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) { query := tx.Model(&model.PackageUsage{}). Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}) if carrierType == constants.AssetTypeIotCard { query = query.Where("iot_card_id = ?", carrierID) } else if carrierType == constants.AssetTypeDevice { query = query.Where("device_id = ?", carrierID) } else { return false, errors.New(errors.CodeInvalidParam, "无效的载体类型") } var packages []*model.PackageUsage if err := query.Find(&packages).Error; err != nil { return false, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐状态失败") } hasAvailablePackage := false for _, pkg := range packages { if pkg == nil { continue } if pkg.IsTrafficDepleted() { if pkg.Status != constants.PackageUsageStatusDepleted { if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil { return false, errors.Wrap(errors.CodeDatabaseError, err, "更新套餐耗尽状态失败") } } continue } hasAvailablePackage = true break } if !hasAvailablePackage { s.logger.Warn("所有套餐已用完,触发停机", zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID)) return true, nil } return false, nil } // triggerSuspensionAfterCommit 在事务提交后触发停机检查,避免回调读到未提交状态。 func (s *UsageService) triggerSuspensionAfterCommit(carrierType string, carrierID uint) { if s.stopResumeCallback == nil { if carrierType == constants.AssetTypeDevice { s.logger.Warn("停复机回调未注入,跳过设备绑定卡停机", zap.Uint("device_id", carrierID)) } return } if carrierType == constants.AssetTypeIotCard { go func() { stopCtx := context.Background() if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, carrierID); err != nil { s.logger.Error("调用停机服务失败", zap.Uint("card_id", carrierID), zap.Error(err)) } }() return } if carrierType != constants.AssetTypeDevice { return } bindings, err := s.deviceSimBindingStore.ListByDeviceID(context.Background(), carrierID) if err != nil { s.logger.Error("查询设备绑定卡失败", zap.Uint("device_id", carrierID), zap.Error(err)) return } if len(bindings) == 0 { s.logger.Debug("设备无绑定卡,跳过停机", zap.Uint("device_id", carrierID)) return } for _, b := range bindings { cardID := b.IotCardID go func(cID uint) { stopCtx := context.Background() if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, cID); err != nil { s.logger.Error("调用停机服务失败", zap.Uint("card_id", cID), zap.Error(err)) } }(cardID) } } // recordOverflowToDepletedPackage 在所有套餐已耗尽时,将溢出流量写入最近的 Depleted 套餐日记录。 // 目的:保持流量详单连续性,即便套餐用完期间上游仍有流量使用,详单不会断档。 // 只写日记录和累加 data_usage_mb,不改变套餐 status,不触发停机。 func (s *UsageService) recordOverflowToDepletedPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint, overflowMB int64, today string) error { query := tx.Where("status = ?", constants.PackageUsageStatusDepleted) switch carrierType { case constants.AssetTypeIotCard: query = query.Where("iot_card_id = ?", carrierID) case constants.AssetTypeDevice: query = query.Where("device_id = ?", carrierID) default: return nil } // 优先选主套餐(master_usage_id IS NULL),主套餐不存在时再退而求其次取加油包 var pkg model.PackageUsage err := query.Where("master_usage_id IS NULL").Order("id DESC").First(&pkg).Error if err == gorm.ErrRecordNotFound { err = query.Order("id DESC").First(&pkg).Error } if err != nil { // 找不到任何 Depleted 套餐,静默忽略 return nil } newUsage := pkg.DataUsageMB + overflowMB if err := tx.Model(&pkg).Update("data_usage_mb", newUsage).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新溢出流量失败") } if err := s.updateDailyRecord(ctx, tx, pkg.ID, today, overflowMB, newUsage); err != nil { return err } s.logger.Info("记录溢出流量到已耗尽套餐", zap.Uint("package_usage_id", pkg.ID), zap.Int64("overflow_mb", overflowMB), zap.Int64("new_usage_mb", newUsage)) return nil }