Files
junhong_cmp_fiber/internal/service/package/usage_service.go
huang 8980e6d999
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m11s
fix: 补全三处轮询覆盖缺口
问题一:protect 轮询任务从未被调度器初始化入队
PollingConfig 新增 protect_check_interval 字段(NULL=不参与),
调度器 initCardsBatch/initCardPolling/requeueCard 补全 protect 队列
初始化逻辑,IotCard 新增 last_protect_check_at 记录上次检查时间。
迁移文件:000102_add_polling_protect_fields

问题二:设备套餐流量耗尽时绑定卡未被停机
UsageService.checkAndTriggerSuspension 新增 carrier_type=device 分支,
通过 DeviceSimBindingStore.ListByDeviceID 查询绑定卡,对每张卡异步
触发 CheckAndStopCard,同步注入 Bootstrap 依赖。

问题三:主套餐过期后不立即停机,依赖下次轮询兜底
PackageActivationHandler.processExpiredPackage 在 updateCarrierSuspended
Status 后,若载体无后续生效套餐,立即异步调用 CheckAndStopCard(iot_card
类型直接触发,device 类型遍历绑定卡),消除停机延迟窗口。

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-02 14:36:24 +08:00

269 lines
8.6 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"
"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"
)
// 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 int64) error {
if usageMB <= 0 {
return errors.New(errors.CodeInvalidParam, "扣减流量必须大于0")
}
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, "查询生效套餐失败")
}
if len(packages) == 0 {
return errors.New(errors.CodeNoAvailablePackage, "没有可用套餐")
}
// 按优先级扣减流量
remainingUsage := usageMB
today := time.Now().Format("2006-01-02")
for _, pkg := range packages {
if remainingUsage <= 0 {
break
}
// 计算当前套餐剩余额度
remainingQuota := pkg.DataLimitMB - pkg.DataUsageMB
if remainingQuota <= 0 {
// 套餐已用完,标记为已用完
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐状态失败")
}
continue
}
// 本次从该套餐扣减的流量
var deductFromPkg int64
if remainingUsage <= remainingQuota {
deductFromPkg = remainingUsage
} else {
deductFromPkg = remainingQuota
}
// 更新套餐使用量
newUsage := pkg.DataUsageMB + deductFromPkg
updates := map[string]interface{}{
"data_usage_mb": newUsage,
}
// 检查是否用完
if newUsage >= pkg.DataLimitMB {
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("data_limit_mb", pkg.DataLimitMB))
}
// 如果流量扣减未完成,说明所有套餐都不够
if remainingUsage > 0 {
s.logger.Warn("流量不足",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.Int64("requested_mb", usageMB),
zap.Int64("remaining_mb", remainingUsage))
return errors.New(errors.CodeInsufficientQuota, "流量不足")
}
// 任务 10.5: 检查是否所有套餐都用完(触发停机)
if err := s.checkAndTriggerSuspension(ctx, tx, carrierType, carrierID); err != nil {
return err
}
return 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) error {
// 查询是否还有生效中的套餐
var activeCount int64
query := tx.Model(&model.PackageUsage{}).
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)
}
if err := query.Count(&activeCount).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询生效套餐数量失败")
}
// 如果没有生效中的套餐,触发停机操作
if activeCount == 0 {
s.logger.Warn("所有套餐已用完,触发停机",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID))
if carrierType == "iot_card" {
if s.stopResumeCallback != nil {
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))
}
}()
}
} else if carrierType == "device" {
if s.stopResumeCallback == nil {
s.logger.Warn("停复机回调未注入,跳过设备绑定卡停机",
zap.Uint("device_id", carrierID))
} else {
bindings, err := s.deviceSimBindingStore.ListByDeviceID(context.Background(), carrierID)
if err != nil {
s.logger.Error("查询设备绑定卡失败",
zap.Uint("device_id", carrierID),
zap.Error(err))
} else if len(bindings) == 0 {
s.logger.Debug("设备无绑定卡,跳过停机",
zap.Uint("device_id", carrierID))
} else {
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)
}
}
}
}
}
return nil
}