All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m27s
- 每卡 Redis 采集队列(默认 N=3、跨天清空、回退容差 1 MB),攒满且不下降才写最后一个点 - 测试环境全量切换日快照计量,下线灰度名单、切换水位与卡表基线扣费;全部卡置待建起点 - 读数区分没给值与 0;有套餐的卡读数 0 也写快照与日用量 0 行;重置日比较基准为 0 照常防旧值 - 新增采集记录(按月分区保留 90 天)、人工冲正流水与后台接口、采集告警 - 扣减按采集时刻有效套餐查找,只对生效中套餐做状态迁移,查不到套餐按成功跳过 - 换货新卡(新设备下的卡)打待建起点;采集间隔上限只校验启用中的轮询配置 Change: add-card-traffic-queue-and-observed-deduction Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
393 lines
14 KiB
Go
393 lines
14 KiB
Go
package packagepkg
|
||
|
||
import (
|
||
"context"
|
||
stopresumedomain "github.com/break/junhong_cmp_fiber/internal/domain/stopresume"
|
||
"math"
|
||
"strconv"
|
||
"time"
|
||
|
||
"github.com/break/junhong_cmp_fiber/internal/domain/cardflow"
|
||
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
|
||
"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"
|
||
)
|
||
|
||
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 // 停复机回调,可选
|
||
auditWriter *audit.Writer
|
||
}
|
||
|
||
// SetLifecycleAudit 注入套餐权益流量扣减统一审计 Writer。
|
||
func (s *UsageService) SetLifecycleAudit(writer *audit.Writer) {
|
||
s.auditWriter = writer
|
||
}
|
||
|
||
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 按采集时刻扣减卡的流量增量。
|
||
// 扣减套餐按"有套餐"共用口径(postgres.FindEffectivePackagesAt)在采集时刻查找:采集时刻有效、扣费时已用完或已到期的套餐照扣;
|
||
// 扣减顺序:加油包(按 priority ASC) → 主套餐,超额部分记入最后一个套餐,用于后续虚流量阈值评估。
|
||
// 只对当前"生效中"的套餐做"扣到阈值改为已用完";一个套餐都查不到时记跳过审计并按成功返回;全部用完时触发停机。
|
||
func (s *UsageService) DeductDataUsage(ctx context.Context, cardID uint, usageMB float64, observedAt time.Time, observationID string) error {
|
||
if usageMB <= 0 {
|
||
return errors.New(errors.CodeInvalidParam, "扣减流量必须大于0")
|
||
}
|
||
if observedAt.IsZero() {
|
||
observedAt = time.Now()
|
||
}
|
||
|
||
deductUsageMB := s.convertToDeductMB(ctx, constants.AssetTypeIotCard, cardID, usageMB)
|
||
if deductUsageMB <= 0 {
|
||
return nil
|
||
}
|
||
|
||
shouldSuspend := false
|
||
suspendCarrierType := ""
|
||
var suspendCarrierID uint
|
||
var auditChanges []packageUsageAuditChange
|
||
|
||
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||
effective, err := postgres.FindEffectivePackagesAt(ctx, tx, cardID, observedAt, true)
|
||
if err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "查询采集时刻有效套餐失败")
|
||
}
|
||
packages := effective.Packages
|
||
if len(packages) == 0 {
|
||
// 采集时刻有套餐、扣费前被退款或作废:记告警并按成功处理,不重试;需要时人工冲正。
|
||
s.logger.Warn("扣减卡流量时查不到采集时刻有效套餐,按成功跳过",
|
||
zap.Uint("card_id", cardID), zap.Int64("usage_mb", deductUsageMB),
|
||
zap.Time("observed_at", observedAt), zap.String("observation_id", observationID),
|
||
zap.String("metric", "package.traffic_deduct.skipped_no_package"))
|
||
return nil
|
||
}
|
||
flowDate := cardflow.ShanghaiDate(observedAt)
|
||
|
||
// 按优先级扣减流量
|
||
remainingUsage := deductUsageMB
|
||
for index, pkg := range packages {
|
||
if remainingUsage <= 0 {
|
||
break
|
||
}
|
||
effectiveThresholdMB := pkg.EffectiveThresholdMB()
|
||
remainingQuota := pkg.RemainingQuotaMB()
|
||
isLastPackage := index == len(packages)-1
|
||
|
||
var deductFromPkg int64
|
||
switch {
|
||
case isLastPackage:
|
||
deductFromPkg = remainingUsage
|
||
case remainingQuota <= 0:
|
||
deductFromPkg = 0
|
||
case remainingUsage <= remainingQuota:
|
||
deductFromPkg = remainingUsage
|
||
default:
|
||
deductFromPkg = remainingQuota
|
||
}
|
||
if deductFromPkg <= 0 {
|
||
// 余量已用完且不是最后一个套餐:跳过;只有当前生效中的套餐顺带改为已用完。
|
||
if pkg.Status == constants.PackageUsageStatusActive {
|
||
beforeData := packageUsageStateData(pkg)
|
||
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐状态失败")
|
||
}
|
||
pkg.Status = constants.PackageUsageStatusDepleted
|
||
auditChanges = append(auditChanges, packageUsageAuditChange{Usage: pkg, BeforeData: beforeData, AfterData: packageUsageStateData(pkg)})
|
||
}
|
||
continue
|
||
}
|
||
|
||
newUsage := pkg.DataUsageMB + deductFromPkg
|
||
updates := map[string]interface{}{"data_usage_mb": newUsage}
|
||
if pkg.Status == constants.PackageUsageStatusActive && effectiveThresholdMB > 0 && newUsage >= effectiveThresholdMB {
|
||
updates["status"] = constants.PackageUsageStatusDepleted
|
||
}
|
||
beforeData := packageUsageStateData(pkg)
|
||
if err := tx.Model(pkg).Updates(updates).Error; err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐使用量失败")
|
||
}
|
||
pkg.DataUsageMB = newUsage
|
||
if status, ok := updates["status"].(int); ok {
|
||
pkg.Status = status
|
||
}
|
||
if err := postgres.AddDailyUsage(ctx, tx, pkg.ID, flowDate, deductFromPkg, newUsage); err != nil {
|
||
return errors.Wrap(errors.CodeDatabaseError, err, "写入套餐日用量失败")
|
||
}
|
||
auditChanges = append(auditChanges, packageUsageAuditChange{Usage: pkg, BeforeData: beforeData, AfterData: packageUsageStateData(pkg)})
|
||
remainingUsage -= deductFromPkg
|
||
|
||
s.logger.Info("扣减套餐流量",
|
||
zap.Uint("usage_id", pkg.ID),
|
||
zap.Int("status", pkg.Status),
|
||
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("crossed_reset", crossedReset(pkg, observedAt)),
|
||
zap.Bool("over_threshold", effectiveThresholdMB > 0 && newUsage > effectiveThresholdMB))
|
||
}
|
||
|
||
// 检查是否所有套餐都用完(触发停机),按套餐当前状态判断
|
||
shouldSuspendCurrent, suspensionChanges, err := s.checkAndTriggerSuspension(ctx, tx, effective.CarrierType, effective.CarrierID)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
auditChanges = append(auditChanges, suspensionChanges...)
|
||
shouldSuspend = shouldSuspendCurrent
|
||
suspendCarrierType = effective.CarrierType
|
||
suspendCarrierID = effective.CarrierID
|
||
if len(auditChanges) > 0 {
|
||
if err := appendPackageUsageAudit(ctx, tx, s.auditWriter, constants.AuditActionPackageUsageTrafficDeducted, "扣减套餐权益流量", normalizePackageUsageAuditChanges(auditChanges), nil, map[string]any{
|
||
"carrier_type": effective.CarrierType, "carrier_id": effective.CarrierID, "usage_mb": deductUsageMB,
|
||
"card_id": cardID, "observed_at": observedAt, "observation_id": observationID,
|
||
"crossed_reset_usage_ids": crossedResetUsageIDs(packages, observedAt),
|
||
}); err != nil {
|
||
return err
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
if len(auditChanges) > 0 {
|
||
recordPackageUsageFailure(ctx, s.db, s.auditWriter, constants.AuditActionPackageUsageTrafficDeducted, "扣减套餐权益流量失败", auditChanges[0].Usage, err)
|
||
}
|
||
return err
|
||
}
|
||
|
||
if shouldSuspend {
|
||
s.triggerSuspensionAfterCommit(ctx, suspendCarrierType, suspendCarrierID)
|
||
}
|
||
|
||
return nil
|
||
}
|
||
|
||
// crossedReset 判断采集时间是否早于套餐最近一次流量重置:这笔用量会扣在重置后的周期。
|
||
func crossedReset(pkg *model.PackageUsage, observedAt time.Time) bool {
|
||
return pkg.LastResetAt != nil && observedAt.Before(*pkg.LastResetAt)
|
||
}
|
||
|
||
// crossedResetUsageIDs 返回采集时间早于最近一次流量重置的套餐,写入扣费审计便于排查跨重置扣费。
|
||
func crossedResetUsageIDs(packages []*model.PackageUsage, observedAt time.Time) []uint {
|
||
var ids []uint
|
||
for _, pkg := range packages {
|
||
if crossedReset(pkg, observedAt) {
|
||
ids = append(ids, pkg.ID)
|
||
}
|
||
}
|
||
return ids
|
||
}
|
||
|
||
// 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()
|
||
}
|
||
|
||
// checkAndTriggerSuspension 任务 10.5: 检查停机条件
|
||
func (s *UsageService) checkAndTriggerSuspension(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, []packageUsageAuditChange, 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, nil, errors.New(errors.CodeInvalidParam, "无效的载体类型")
|
||
}
|
||
|
||
var packages []*model.PackageUsage
|
||
if err := query.Find(&packages).Error; err != nil {
|
||
return false, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐状态失败")
|
||
}
|
||
|
||
hasAvailablePackage := false
|
||
var changes []packageUsageAuditChange
|
||
for _, pkg := range packages {
|
||
if pkg == nil {
|
||
continue
|
||
}
|
||
if pkg.IsTrafficDepleted() {
|
||
if pkg.Status != constants.PackageUsageStatusDepleted {
|
||
beforeData := packageUsageStateData(pkg)
|
||
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil {
|
||
return false, nil, errors.Wrap(errors.CodeDatabaseError, err, "更新套餐耗尽状态失败")
|
||
}
|
||
pkg.Status = constants.PackageUsageStatusDepleted
|
||
changes = append(changes, packageUsageAuditChange{Usage: pkg, BeforeData: beforeData, AfterData: packageUsageStateData(pkg)})
|
||
}
|
||
continue
|
||
}
|
||
hasAvailablePackage = true
|
||
break
|
||
}
|
||
|
||
if !hasAvailablePackage {
|
||
s.logger.Warn("所有套餐已用完,触发停机",
|
||
zap.String("carrier_type", carrierType),
|
||
zap.Uint("carrier_id", carrierID))
|
||
return true, changes, nil
|
||
}
|
||
|
||
return false, changes, nil
|
||
}
|
||
|
||
// triggerSuspensionAfterCommit 在事务提交后触发停机检查,避免回调读到未提交状态。
|
||
func (s *UsageService) triggerSuspensionAfterCommit(ctx context.Context, carrierType string, carrierID uint) {
|
||
if s.stopResumeCallback == nil {
|
||
if carrierType == constants.AssetTypeDevice {
|
||
s.logger.Warn("停复机回调未注入,跳过设备绑定卡停机",
|
||
zap.Uint("device_id", carrierID))
|
||
}
|
||
return
|
||
}
|
||
|
||
stopCtx := stopresumedomain.WithScene(context.WithoutCancel(ctx), constants.StopResumeSceneUsage)
|
||
if carrierType == constants.AssetTypeIotCard {
|
||
stopresumedomain.RunAfterCommit(stopCtx, s.stopResumeCallback, func() {
|
||
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(stopCtx, 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 {
|
||
cID := b.IotCardID
|
||
stopresumedomain.RunAfterCommit(stopCtx, s.stopResumeCallback, func() {
|
||
if err := s.stopResumeCallback.CheckAndStopCard(stopCtx, cID); err != nil {
|
||
s.logger.Error("调用停机服务失败",
|
||
zap.Uint("card_id", cID),
|
||
zap.Error(err))
|
||
}
|
||
})
|
||
}
|
||
}
|