Files
junhong_cmp_fiber/internal/service/package/usage_service.go
Break 6a70713b30
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m0s
加分布式锁
2026-06-09 10:54:13 +08:00

449 lines
14 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}
if len(packages) == 0 {
return errors.New(errors.CodeNoAvailablePackage, "没有可用套餐")
}
// 按优先级扣减流量
remainingUsage := deductUsageMB
today := time.Now().Format("2006-01-02")
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)
}
}