Files
junhong_cmp_fiber/internal/service/package/usage_service.go
邹进 9c3c258f16
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m27s
feat: 卡流量读数先过采集队列再入账,扣减按采集时刻确定套餐
- 每卡 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>
2026-10-29 12:18:47 +09:00

393 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"
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))
}
})
}
}