All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m11s
问题一: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>
269 lines
8.6 KiB
Go
269 lines
8.6 KiB
Go
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
|
||
}
|