feat: 卡流量读数先过采集队列再入账,扣减按采集时刻确定套餐
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>
This commit is contained in:
邹进
2026-10-29 12:18:47 +09:00
parent c33cc6f6c3
commit 9c3c258f16
55 changed files with 3142 additions and 881 deletions

View File

@@ -74,9 +74,11 @@ type Service struct {
auditWriter StateAuditWriter
// channelThreshold 为可选的通道阈值判定能力;未注入时流量观测不做阈值判定。
channelThreshold ChannelThresholdEvaluator
// trafficCalc 判定卡是否走日快照计量算法;未注入时全部卡走卡表基线算法且不写快照。
trafficCalc *TrafficCalcPolicy
// logger 记录快照写入失败等不影响观测结果的告警;未注入时不输出。
// trafficQueue 是卡流量采集队列;未注入时流量观测报错。
trafficQueue TrafficQueuePort
// trafficQueueSettings 返回采集队列配置;未注入时使用代码默认值。
trafficQueueSettings TrafficQueueSettingsSource
// logger 记录采集记录写入失败等不影响观测结果的告警;未注入时不输出。
logger *zap.Logger
// commandGrace 返回停复机成功后的状态保护窗口;未注入时不保护。
commandGrace func(ctx context.Context) time.Duration
@@ -94,9 +96,10 @@ func (s *Service) currentCommandGrace(ctx context.Context) time.Duration {
return s.commandGrace(ctx)
}
// SetTrafficCalcPolicy 注入卡流量计量切换策略与告警日志。
func (s *Service) SetTrafficCalcPolicy(policy *TrafficCalcPolicy, logger *zap.Logger) {
s.trafficCalc = policy
// SetTrafficQueue 注入卡流量采集队列、队列配置与告警日志。
func (s *Service) SetTrafficQueue(queue TrafficQueuePort, settings TrafficQueueSettingsSource, logger *zap.Logger) {
s.trafficQueue = queue
s.trafficQueueSettings = settings
s.logger = logger
}
@@ -305,11 +308,3 @@ func realnameChangedEventID(cardID uint, observationID string) string {
prefix := "card-realname:"
return outboxid.Stable(prefix, strconv.FormatUint(uint64(cardID), 10)+":"+observationID+":changed")
}
// TrafficSwitched 判定卡是否已切换到日快照计量(激活前流量结算只对已切换的卡执行)。
func (s *Service) TrafficSwitched(cardID uint) bool {
if s == nil {
return false
}
return s.trafficCalc.Switched(cardID)
}

View File

@@ -5,6 +5,7 @@ import (
"strconv"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
@@ -12,6 +13,7 @@ import (
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
carrierthresholddomain "github.com/break/junhong_cmp_fiber/internal/domain/carrierthreshold"
"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/break/junhong_cmp_fiber/pkg/outboxid"
@@ -19,24 +21,56 @@ import (
// TrafficIncrementedEvent 是卡流量正增量的可靠领域事实。
type TrafficIncrementedEvent struct {
EventID string `json:"event_id"`
CardID uint `json:"card_id"`
IncrementMB float64 `json:"increment_mb"`
ObservedAt time.Time `json:"observed_at"`
Source string `json:"source"`
Scene string `json:"scene"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
EventID string `json:"event_id"`
CardID uint `json:"card_id"`
IncrementMB float64 `json:"increment_mb"`
ObservedAt time.Time `json:"observed_at"`
// ObservationID 是采集 ID,扣费审计据此追溯到采集记录。
ObservationID string `json:"observation_id,omitempty"`
Source string `json:"source"`
Scene string `json:"scene"`
RequestID string `json:"request_id,omitempty"`
CorrelationID string `json:"correlation_id,omitempty"`
}
// ApplyTrafficObservation 串行应用流量读数,并在正增量时同事务写 Outbox。
// TrafficQueuePort 是卡流量采集队列:原子入队并返回入队后的队列与新点是否在队尾。
type TrafficQueuePort interface {
Push(ctx context.Context, cardID uint, point cardflow.QueuePoint, settings TrafficQueueSettings) ([]cardflow.QueuePoint, bool, error)
}
// TrafficQueueSettingsSource 返回当前采集队列配置。
type TrafficQueueSettingsSource interface {
Current(ctx context.Context) TrafficQueueSettings
}
// ApplyTrafficObservation 应用一次流量采集:读数先进采集队列,满足写入条件才写日快照、按月对账、
// 在采集时刻有套餐时同事务写流量事件;有套餐的卡每次采集写一条采集记录。
func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domain.TrafficObservation) (domain.TrafficDecision, error) {
if s == nil || s.db == nil || s.eventWriter == nil {
if s == nil || s.db == nil || s.eventWriter == nil || s.trafficQueue == nil {
return domain.TrafficDecision{}, errors.New(errors.CodeInternalError, "卡流量观测能力未完整配置")
}
var decision domain.TrafficDecision
var auditedCard *model.IotCard
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := domain.ValidateTrafficObservation(observation); err != nil {
return domain.TrafficDecision{}, err
}
record := newCollectionRecord(observation)
decision := domain.TrafficDecision{Result: record.Result}
if record.Result != constants.TrafficCollectionResultOK {
s.writeCollectionRecord(ctx, record, observation.Metadata.ObservedAt)
return decision, nil
}
reading := observation.GatewayReadingMB
decision.ReadingMB = &reading
settings := s.queueSettings()
points, tail, err := s.trafficQueue.Push(ctx, observation.CardID,
cardflow.QueuePoint{ObservedAt: observation.Metadata.ObservedAt, ReadingMB: reading}, settings)
if err != nil {
return decision, err
}
record.setQueue(points)
judge := cardflow.JudgeQueue(points, tail, settings.Size, settings.DropToleranceMB)
record.Decision = judge
var outcome *flowOutcome
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var card model.IotCard
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", observation.CardID).First(&card).Error; err != nil {
if err == gorm.ErrRecordNotFound {
@@ -44,164 +78,177 @@ func (s *Service) ApplyTrafficObservation(ctx context.Context, observation domai
}
return errors.Wrap(errors.CodeDatabaseError, err, "锁定IoT卡流量事实失败")
}
auditedCard = &card
nextDecision, decisionErr := domain.ApplyTraffic(domain.CardTrafficSnapshot{
CardID: card.ID, DataUsageMB: card.DataUsageMB, CurrentMonthUsageMB: card.CurrentMonthUsageMB,
CurrentMonthStartDate: card.CurrentMonthStartDate, LastMonthTotalMB: card.LastMonthTotalMB,
LastGatewayReadingMB: card.LastGatewayReadingMB,
}, observation)
if decisionErr != nil {
return decisionErr
decision.CurrentMonthUsageMB, decision.LastGatewayReadingMB = card.CurrentMonthUsageMB, card.LastGatewayReadingMB
packages, err := postgres.FindEffectivePackagesAt(ctx, tx, card.ID, observation.Metadata.ObservedAt, false)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询卡采集时刻有效套餐失败")
}
decision = nextDecision
// 已切换日快照算法的卡:增量、通道阈值与本月用量改由快照决定;读数为 0 或无效时不写快照、不产生增量。
switched := s.trafficCalc.Switched(card.ID)
var flow *switchedFlowOutcome
if switched && cardflow.ValidReading(observation.GatewayReadingMB) {
outcome, flowErr := s.applySwitchedFlow(ctx, tx, &card, observation, decision)
if flowErr != nil {
return flowErr
record.HasPackage = len(packages.Packages) > 0
if judge != cardflow.QueueWrite {
if observation.Metadata.Scene == constants.CardObservationScenePreActivationSettlement {
// 激活前结算没写入:用已有本月最大快照结算,不新写快照。
if err := s.settleExisting(ctx, tx, &card, observation.Metadata.ObservedAt); err != nil {
return err
}
record.Decision = constants.TrafficDecisionSettledExisting
}
flow = outcome
return touchTrafficCheck(tx, card.ID, observation.Metadata.ObservedAt)
}
if switched {
decision.IncrementMB = 0
if flow != nil {
decision.IncrementMB = flow.EmitMB
decision.CurrentMonthUsageMB = flow.MonthMaxMB
}
}
updates := map[string]any{
"last_data_check_at": observation.Metadata.ObservedAt, "last_sync_time": observation.Metadata.ObservedAt,
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
"current_month_usage_mb": decision.CurrentMonthUsageMB, "data_usage_mb": decision.DataUsageMB,
}
if decision.ReadingAccepted {
updates["last_gateway_reading_mb"] = decision.LastGatewayReadingMB
}
result := tx.Model(&model.IotCard{}).
Where("id = ? AND last_gateway_reading_mb = ?", card.ID, card.LastGatewayReadingMB).
Updates(updates)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新卡流量事实失败")
}
if result.RowsAffected != 1 {
return errors.New(errors.CodeConflict, "卡流量基线已被其他请求更新")
}
if decision.IncrementMB > 0 {
// 未切换卡沿用卡表基线算法的增量;已切换卡的增量已替换为按月对账结果,每次观测最多一条 V1 事件。
eventID := outboxid.Stable("card-traffic:", strconv.FormatUint(uint64(card.ID), 10)+":"+observation.Metadata.ObservationID+":incremented")
if err := s.eventWriter.AppendTraffic(ctx, tx, TrafficIncrementedEvent{
EventID: eventID, CardID: card.ID, IncrementMB: decision.IncrementMB,
ObservedAt: observation.Metadata.ObservedAt, Source: observation.Metadata.Source,
Scene: observation.Metadata.Scene, RequestID: observation.Metadata.RequestID,
CorrelationID: observation.Metadata.CorrelationID,
}); err != nil {
return err
}
}
evaluateThreshold := decision.ReadingAccepted
if switched {
evaluateThreshold = switchedThresholdEvaluable(flow, decision.ReadingAccepted)
}
if observation.Metadata.Scene == constants.CardObservationScenePreActivationSettlement {
// 激活前结算只记已结算用量,不触发停复机评估;下一次正常轮询观测照常判定。
evaluateThreshold = false
}
if evaluateThreshold && s.channelThreshold != nil {
// 未切换卡只使用本次已接受的网关累计读数,异常下降保护命中的观测不参与判定;
// 已切换卡读数有效且未被重置日防旧值处理即判定,不再受卡表基线影响。
// 判定失败必须回滚整个流量事务,与既有流量事实保持同事务语义。
if err := s.channelThreshold.EvaluateInTx(ctx, tx, carrierthresholddomain.Evaluation{
CardID: card.ID, CarrierID: card.CarrierID,
ReadingMB: thresholdReading(switched, flow, observation, decision), ObservedAt: observation.Metadata.ObservedAt,
// 观测来源是判定端获取触发来源的唯一位置:命中留痕据此区分自动判定、人工同步与回调。
Source: observation.Metadata.Source,
}); err != nil {
return err
}
}
stateChanged := decision.IncrementMB != 0 || decision.CrossMonth || decision.LastGatewayReadingMB != card.LastGatewayReadingMB
if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && stateChanged && audited {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: actionCode,
Summary: "人工刷新 IoT 卡流量",
Card: &card,
BeforeData: map[string]any{
"data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB,
"current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB,
"last_gateway_reading_mb": card.LastGatewayReadingMB,
},
AfterData: map[string]any{
"data_usage_mb": decision.DataUsageMB, "current_month_usage_mb": decision.CurrentMonthUsageMB,
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
"last_gateway_reading_mb": decision.LastGatewayReadingMB, "increment_mb": decision.IncrementMB,
},
}); err != nil {
return err
}
} else if workerObservationAudited(ctx) && stateChanged {
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
if err := s.auditWriter.WriteCardStateAudit(ctx, tx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerTrafficSynced,
Summary: "Worker 同步 IoT 卡流量事实", Card: &card,
IntegrationID: observation.Metadata.ObservationID,
BeforeData: map[string]any{
"data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB,
"current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB,
"last_gateway_reading_mb": card.LastGatewayReadingMB,
},
AfterData: map[string]any{
"data_usage_mb": decision.DataUsageMB, "current_month_usage_mb": decision.CurrentMonthUsageMB,
"current_month_start_date": decision.CurrentMonthStartDate, "last_month_total_mb": decision.LastMonthTotalMB,
"last_gateway_reading_mb": decision.LastGatewayReadingMB, "increment_mb": decision.IncrementMB,
},
}); err != nil {
return err
}
next, err := s.applyWrittenPoint(ctx, tx, &card, observation, packages)
if err != nil {
return err
}
outcome = next
return nil
})
if err != nil {
if workerObservationAudited(ctx) && auditedCard != nil && s.auditWriter != nil {
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerTrafficSynced,
Summary: "Worker 同步 IoT 卡流量事实失败", Card: auditedCard,
IntegrationID: observation.Metadata.ObservationID,
}, err)
}
s.writeTrafficFailureAudit(ctx, observation, err)
return domain.TrafficDecision{}, err
}
if outcome != nil {
record.applyOutcome(outcome)
decision.ReadingAccepted = outcome.Written
decision.IncrementMB = outcome.EmitMB
decision.CrossMonth = outcome.CrossMonth
decision.CurrentMonthUsageMB = outcome.MonthMaxMB
}
decision.Decision = record.Decision
s.writeCollectionRecord(ctx, record, observation.Metadata.ObservedAt)
if s.cache != nil {
s.cache.Invalidate(ctx, observation.CardID)
}
if auditedCard != nil && !s.trafficCalc.Switched(auditedCard.ID) {
s.writeUnswitchedSnapshot(ctx, auditedCard, observation)
}
return decision, nil
}
// switchedThresholdEvaluable 判定已切换卡本次是否做通道阈值判定:读数有效且本次未被防旧值按 0 处理;
// 第一次切换观测仍按卡表基线算法是否接受读数判定。
func switchedThresholdEvaluable(flow *switchedFlowOutcome, oldAccepted bool) bool {
if flow == nil {
return false
// applyWrittenPoint 在观测事务内处理一个通过队列判断的写入点。
func (s *Service) applyWrittenPoint(ctx context.Context, tx *gorm.DB, card *model.IotCard, observation domain.TrafficObservation, packages postgres.EffectivePackages) (*flowOutcome, error) {
hasPackage := len(packages.Packages) > 0
reading := observation.GatewayReadingMB
observedAt := observation.Metadata.ObservedAt
outcome := &flowOutcome{MonthMaxMB: card.CurrentMonthUsageMB, Decision: constants.TrafficDecisionWritten}
if reading == 0 && !hasPackage {
// 没套餐的卡读数为 0 不写快照。
return outcome, touchTrafficCheck(tx, card.ID, observedAt)
}
if flow.FirstSwitch {
return oldAccepted
flow, err := s.writeFlow(ctx, tx, card, reading, observedAt, hasPackage)
if err != nil {
return nil, err
}
return !flow.StaleGuarded
outcome.Written, outcome.MonthMaxMB, outcome.StaleGuarded = true, flow.monthMax, flow.conversion.StaleGuarded
outcome.MonthUsedMB, outcome.DeltaMB = flow.conversion.MonthUsedMB, flow.delta
if card.TrafficBaselinePending {
outcome.Decision = constants.TrafficDecisionBaseline
} else if hasPackage && flow.delta > 0 {
outcome.EmitMB = flow.delta
}
if hasPackage {
if err := ensureDailyUsageRows(ctx, tx, packages.Packages, flow.flowDate); err != nil {
return nil, err
}
}
monthStart := cardflow.MonthStart(flow.flowDate)
outcome.CrossMonth = card.CurrentMonthStartDate == nil || card.CurrentMonthStartDate.Before(monthStart)
updates := map[string]any{
"last_data_check_at": observedAt, "last_sync_time": observedAt,
"current_month_usage_mb": flow.monthMax, "current_month_start_date": monthStart,
"data_usage_mb": card.DataUsageMB + int64(flow.delta), "traffic_baseline_pending": false,
}
if outcome.CrossMonth {
updates["last_month_total_mb"] = card.CurrentMonthUsageMB
}
if err := tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(updates).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新卡流量事实失败")
}
if outcome.EmitMB > 0 {
outcome.EventID = outboxid.Stable("card-traffic:", strconv.FormatUint(uint64(card.ID), 10)+":"+observation.Metadata.ObservationID+":incremented")
if err := s.eventWriter.AppendTraffic(ctx, tx, TrafficIncrementedEvent{
EventID: outcome.EventID, CardID: card.ID, IncrementMB: outcome.EmitMB,
ObservedAt: observedAt, ObservationID: observation.Metadata.ObservationID,
Source: observation.Metadata.Source, Scene: observation.Metadata.Scene,
RequestID: observation.Metadata.RequestID, CorrelationID: observation.Metadata.CorrelationID,
}); err != nil {
return nil, err
}
}
// 通道阈值只在写入点判断:读数大于 0 且未被防旧值按 0 处理;激活前结算不触发停复机评估。
if reading > 0 && !outcome.StaleGuarded && s.channelThreshold != nil &&
observation.Metadata.Scene != constants.CardObservationScenePreActivationSettlement {
if err := s.channelThreshold.EvaluateInTx(ctx, tx, carrierthresholddomain.Evaluation{
CardID: card.ID, CarrierID: card.CarrierID, ReadingMB: reading, ObservedAt: observedAt,
// 观测来源是判定端获取触发来源的唯一位置:命中留痕据此区分自动判定、人工同步与回调。
Source: observation.Metadata.Source,
}); err != nil {
return nil, err
}
}
if err := s.writeTrafficStateAudit(ctx, tx, card, observation, outcome); err != nil {
return nil, err
}
return outcome, nil
}
// thresholdReading 返回通道阈值判定使用的网关累计读数:已切换卡(非第一次切换观测)用本次读数。
func thresholdReading(switched bool, flow *switchedFlowOutcome, observation domain.TrafficObservation, decision domain.TrafficDecision) float64 {
if switched && flow != nil && !flow.FirstSwitch {
return observation.GatewayReadingMB
// touchTrafficCheck 只刷新卡的最近流量检查时间(本次没有写入快照)。
func touchTrafficCheck(tx *gorm.DB, cardID uint, observedAt time.Time) error {
if err := tx.Model(&model.IotCard{}).Where("id = ?", cardID).
Updates(map[string]any{"last_data_check_at": observedAt, "last_sync_time": observedAt}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡流量检查时间失败")
}
return decision.LastGatewayReadingMB
return nil
}
// writeTrafficStateAudit 在本次写入改变了卡流量事实时写统一审计(人工刷新或 Worker 观测)。
func (s *Service) writeTrafficStateAudit(ctx context.Context, tx *gorm.DB, card *model.IotCard, observation domain.TrafficObservation, outcome *flowOutcome) error {
if outcome.DeltaMB <= 0 && !outcome.CrossMonth {
return nil
}
before := map[string]any{
"data_usage_mb": card.DataUsageMB, "current_month_usage_mb": card.CurrentMonthUsageMB,
"current_month_start_date": card.CurrentMonthStartDate, "last_month_total_mb": card.LastMonthTotalMB,
}
after := map[string]any{
"data_usage_mb": card.DataUsageMB + int64(outcome.DeltaMB), "current_month_usage_mb": outcome.MonthMaxMB,
"increment_mb": outcome.EmitMB, "delta_mb": outcome.DeltaMB, "decision": outcome.Decision,
"observation_id": observation.Metadata.ObservationID,
}
input := StateAudit{Card: card, BeforeData: before, AfterData: after}
if actionCode, audited := manualRefreshAuditAction(ctx); observation.Metadata.Source == constants.CardObservationSourceManualSync && audited {
input.ActionCode, input.Summary = actionCode, "人工刷新 IoT 卡流量"
} else if workerObservationAudited(ctx) {
input.ActionCode, input.Summary = constants.AuditActionIotCardWorkerTrafficSynced, "Worker 同步 IoT 卡流量事实"
input.IntegrationID = observation.Metadata.ObservationID
} else {
return nil
}
if s.auditWriter == nil {
return errors.New(errors.CodeInternalError, "卡状态统一审计能力未配置")
}
return s.auditWriter.WriteCardStateAudit(ctx, tx, input)
}
// writeTrafficFailureAudit 记录 Worker 观测事务失败的审计。
func (s *Service) writeTrafficFailureAudit(ctx context.Context, observation domain.TrafficObservation, err error) {
if !workerObservationAudited(ctx) || s.auditWriter == nil {
return
}
var card model.IotCard
if loadErr := s.db.WithContext(ctx).Where("id = ?", observation.CardID).First(&card).Error; loadErr != nil {
return
}
s.auditWriter.WriteCardStateFailure(ctx, StateAudit{
ActionCode: constants.AuditActionIotCardWorkerTrafficSynced,
Summary: "Worker 同步 IoT 卡流量事实失败", Card: &card,
IntegrationID: observation.Metadata.ObservationID,
}, err)
}
func (s *Service) queueSettings() TrafficQueueSettings {
if s.trafficQueueSettings == nil {
return DefaultTrafficQueueSettings()
}
return s.trafficQueueSettings.Current(context.Background())
}
// logTrafficWarn 记录不影响观测结果的告警。
func (s *Service) logTrafficWarn(message string, fields ...zap.Field) {
s.warnLogger().Warn(message, fields...)
}

View File

@@ -1,171 +0,0 @@
package cardobservation
import (
"time"
"context"
"strconv"
"strings"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// 灰度名单中扣减载体的前缀。
const (
trafficCalcCarrierDevice = "device"
trafficCalcCarrierIotCard = "iot_card"
)
// TrafficCalcPolicy 判定卡是否走日快照计量算法,以及未切换卡是否写快照。
//
// 名单在进程启动时展开为内存卡集合,观测事务内只查内存、不查库;API 与全部 Worker 必须使用一致的配置。
// 零值策略等同于 off 模式且不写快照。
type TrafficCalcPolicy struct {
mode string
cards map[uint]struct{}
snapshotEnabled bool
}
// Switched 判定卡是否走日快照算法。
func (p *TrafficCalcPolicy) Switched(cardID uint) bool {
if p == nil {
return false
}
switch p.mode {
case config.TrafficCalcModeFull:
return true
case config.TrafficCalcModeGray:
_, ok := p.cards[cardID]
return ok
default:
return false
}
}
// SnapshotEnabled 判定未切换卡是否写日快照。
func (p *TrafficCalcPolicy) SnapshotEnabled() bool {
return p != nil && p.snapshotEnabled
}
// BuildTrafficCalcPolicy 按配置展开灰度名单:device 展开为当前绑定的全部卡;iot_card 实际使用设备套餐时
// 记录错误并跳过该项(不阻止启动)。展开查询失败时整体退回 off 模式,保证判定出错一律走卡表基线算法。
func BuildTrafficCalcPolicy(ctx context.Context, db *gorm.DB, cfg config.TrafficCalcConfig, logger *zap.Logger) *TrafficCalcPolicy {
if logger == nil {
logger = zap.NewNop()
}
policy := &TrafficCalcPolicy{mode: cfg.Mode, cards: map[uint]struct{}{}, snapshotEnabled: cfg.FlowSnapshotEnabled}
if cfg.Mode != config.TrafficCalcModeGray {
logger.Info("卡流量计量模式", zap.String("mode", cfg.Mode), zap.Bool("flow_snapshot_enabled", cfg.FlowSnapshotEnabled))
return policy
}
for _, item := range strings.Split(cfg.Cards, ",") {
item = strings.TrimSpace(item)
if item == "" {
continue
}
if err := policy.expandItem(ctx, db, item, logger); err != nil {
logger.Error("展开卡流量计量灰度名单失败,本进程全部卡走卡表基线算法", zap.String("item", item), zap.Error(err))
return &TrafficCalcPolicy{mode: config.TrafficCalcModeOff, snapshotEnabled: cfg.FlowSnapshotEnabled}
}
}
logger.Info("卡流量计量灰度名单已展开", zap.String("cards", cfg.Cards), zap.Int("card_count", len(policy.cards)))
return policy
}
// expandItem 展开一项扣减载体;格式非法或单卡实际用设备套餐时只记录错误并跳过,查询失败时返回错误。
func (p *TrafficCalcPolicy) expandItem(ctx context.Context, db *gorm.DB, item string, logger *zap.Logger) error {
kind, rawID, found := strings.Cut(item, ":")
id, parseErr := strconv.ParseUint(strings.TrimSpace(rawID), 10, 64)
if !found || parseErr != nil || id == 0 {
logger.Error("卡流量计量灰度名单项格式非法,已跳过", zap.String("item", item))
return nil
}
switch strings.TrimSpace(kind) {
case trafficCalcCarrierDevice:
var cardIDs []uint
if err := db.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Where("device_id = ? AND bind_status = ?", id, constants.BindStatusBound).
Pluck("iot_card_id", &cardIDs).Error; err != nil {
return err
}
for _, cardID := range cardIDs {
p.cards[cardID] = struct{}{}
}
return nil
case trafficCalcCarrierIotCard:
usesDevice, err := cardUsesDevicePackage(ctx, db, uint(id))
if err != nil {
return err
}
if usesDevice {
logger.Error("灰度名单中的单卡实际从设备套餐扣减,已跳过;请改为按设备整台切换", zap.String("item", item))
return nil
}
p.cards[uint(id)] = struct{}{}
return nil
default:
logger.Error("卡流量计量灰度名单项载体类型非法,已跳过", zap.String("item", item))
return nil
}
}
// cardUsesDevicePackage 与套餐扣减的载体解析同口径:卡自身没有生效中套餐、且当前绑定设备有生效中套餐时,
// 卡的流量扣到设备套餐上。
func cardUsesDevicePackage(ctx context.Context, db *gorm.DB, cardID uint) (bool, error) {
ownActive, err := countActivePackages(ctx, db, "iot_card_id = ?", cardID, time.Time{})
if err != nil || ownActive > 0 {
return false, err
}
deviceID, bound, err := activeBoundDeviceID(ctx, db, cardID)
if err != nil || !bound {
return false, err
}
deviceActive, err := countActivePackages(ctx, db, "device_id = ?", deviceID, time.Time{})
return deviceActive > 0, err
}
// hasActivePackageForCard 判定卡本次增量是否有可扣的生效中套餐:卡自身或当前绑定设备存在生效中套餐。
// observedAt 非零时只认激活时间不晚于观测时间的生效套餐(流量按观测时间归属):
// 观测早于套餐激活的用量属于激活前,只记已结算、不扣新套餐;激活时间为空的历史套餐视为早已生效。
func hasActivePackageForCard(ctx context.Context, db *gorm.DB, cardID uint, observedAt time.Time) (bool, error) {
ownActive, err := countActivePackages(ctx, db, "iot_card_id = ?", cardID, observedAt)
if err != nil || ownActive > 0 {
return ownActive > 0, err
}
deviceID, bound, err := activeBoundDeviceID(ctx, db, cardID)
if err != nil || !bound {
return false, err
}
deviceActive, err := countActivePackages(ctx, db, "device_id = ?", deviceID, observedAt)
return deviceActive > 0, err
}
func countActivePackages(ctx context.Context, db *gorm.DB, carrierCondition string, carrierID uint, observedAt time.Time) (int64, error) {
var count int64
query := db.WithContext(ctx).Model(&model.PackageUsage{}).
Where(carrierCondition, carrierID).
Where("status = ?", constants.PackageUsageStatusActive)
if !observedAt.IsZero() {
query = query.Where("activated_at IS NULL OR activated_at <= ?", observedAt)
}
err := query.Count(&count).Error
return count, err
}
func activeBoundDeviceID(ctx context.Context, db *gorm.DB, cardID uint) (uint, bool, error) {
var deviceIDs []uint
if err := db.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Where("iot_card_id = ? AND bind_status = ?", cardID, constants.BindStatusBound).
Limit(1).Pluck("device_id", &deviceIDs).Error; err != nil {
return 0, false, err
}
if len(deviceIDs) == 0 {
return 0, false, nil
}
return deviceIDs[0], true, nil
}

View File

@@ -0,0 +1,74 @@
package cardobservation
import (
"context"
"encoding/json"
"time"
"go.uber.org/zap"
cardflow "github.com/break/junhong_cmp_fiber/internal/domain/cardflow"
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"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"
)
// collectionRecordTimeout 是事务提交后写采集记录的超时;采集记录只用于排查,写失败只告警。
const collectionRecordTimeout = 3 * time.Second
// collectionRecord 是一次采集的留痕内容;HasPackage 在观测事务内判断,读数无效的观测写记录前再查。
type collectionRecord struct {
model.CardTrafficCollection
HasPackage bool
}
func newCollectionRecord(observation domain.TrafficObservation) *collectionRecord {
record := &collectionRecord{CardTrafficCollection: model.CardTrafficCollection{
IotCardID: observation.CardID, ObservedAt: observation.Metadata.ObservedAt,
ObservationID: observation.Metadata.ObservationID, Source: observation.Metadata.Source,
Scene: observation.Metadata.Scene, Result: domain.ResolveReadingResult(observation),
}}
if record.Result == constants.TrafficCollectionResultOK {
reading := observation.GatewayReadingMB
record.VendorUsedMB = &reading
}
return record
}
func (r *collectionRecord) setQueue(points []cardflow.QueuePoint) {
if encoded, err := json.Marshal(points); err == nil {
r.QueuePoints = encoded
}
}
func (r *collectionRecord) applyOutcome(outcome *flowOutcome) {
r.Decision = outcome.Decision
if !outcome.Written {
return
}
monthUsed, delta, stale := outcome.MonthUsedMB, outcome.DeltaMB, outcome.StaleGuarded
r.MonthUsedMB, r.DeltaMB, r.StaleGuarded, r.EventID = &monthUsed, &delta, &stale, outcome.EventID
}
// writeCollectionRecord 在观测事务之外为采集时刻有套餐的卡写采集记录;没套餐的卡不写。
func (s *Service) writeCollectionRecord(ctx context.Context, record *collectionRecord, observedAt time.Time) {
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), collectionRecordTimeout)
defer cancel()
hasPackage := record.HasPackage
if record.Result != constants.TrafficCollectionResultOK {
packages, err := postgres.FindEffectivePackagesAt(writeCtx, s.db, record.IotCardID, observedAt, false)
if err != nil {
s.logTrafficWarn("查询卡采集时刻有效套餐失败,跳过采集记录", zap.Uint("card_id", record.IotCardID), zap.Error(err))
return
}
hasPackage = len(packages.Packages) > 0
}
if !hasPackage {
return
}
if err := postgres.NewCardTrafficCollectionStore(s.db).Create(writeCtx, &record.CardTrafficCollection); err != nil {
s.logTrafficWarn("写卡流量采集记录失败", zap.Uint("card_id", record.IotCardID),
zap.String("observation_id", record.ObservationID), zap.Error(err))
}
}

View File

@@ -8,50 +8,93 @@ import (
"gorm.io/gorm"
cardflow "github.com/break/junhong_cmp_fiber/internal/domain/cardflow"
domain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// unswitchedSnapshotTimeout 是未切换卡在观测事务提交后写快照的超时,超时只告警。
const unswitchedSnapshotTimeout = 3 * time.Second
// switchedFlowOutcome 是已切换卡一次观测的日快照计量结果。
type switchedFlowOutcome struct {
// EmitMB 是本次应写入 V1 流量事件的增量,0 表示不写事件。
// flowOutcome 是一个写入点的日快照计量结果。
type flowOutcome struct {
// Written 为 true 表示本次写入了日快照(没套餐且读数为 0 时为 false)。
Written bool
// Decision 是写入判断:已写入或建起点。
Decision string
// EmitMB 是本次写入流量事件的增量,0 表示不写事件。
EmitMB float64
// MonthMaxMB 是写入本次快照后的本月最大快照,用于覆盖卡表本月用量。
MonthMaxMB float64
// DeltaMB 是本次按月对账计入已结算的增量(含没套餐、建起点时只记已结算的部分)。
DeltaMB float64
// MonthUsedMB 是本次读数换算后的本月累计;MonthMaxMB 是写入后的本月最大快照。
MonthUsedMB float64
MonthMaxMB float64
// StaleGuarded 为 true 表示本次读数在重置日触发防旧值、按 0 计。
StaleGuarded bool
// FirstSwitch 为 true 表示这是卡的第一次切换观测(本次写入了切换水位)。
FirstSwitch bool
// CrossMonth 为 true 表示卡表本月用量跨入新的自然月。
CrossMonth bool
// EventID 是本次写入的流量事件 ID。
EventID string
}
// snapshotResult 是一次快照换算与写入的结果。
type snapshotResult struct {
// flowWrite 是一次快照写入与按月对账的结果。
type flowWrite struct {
flowDate time.Time
conversion cardflow.Conversion
monthMax float64
delta float64
}
// writeSnapshot 按卡所属通道的重置日换算并原子写入当天快照;读数必须已通过 cardflow.ValidReading。
func (s *Service) writeSnapshot(ctx context.Context, db *gorm.DB, card *model.IotCard, readingMB float64, observedAt time.Time) (snapshotResult, error) {
resetDay := s.strictResetDay(ctx, db, card)
// writeFlow 按卡所属通道的重置日换算并原子写入当天快照,再按月对账把增量计入当天已结算。
func (s *Service) writeFlow(ctx context.Context, tx *gorm.DB, card *model.IotCard, readingMB float64, observedAt time.Time, hasPackage bool) (flowWrite, error) {
resetDay := s.strictResetDay(ctx, tx, card)
flowDate := cardflow.ShanghaiDate(observedAt)
days := postgres.NewCardFlowDayStore(db)
days := postgres.NewCardFlowDayStore(tx)
monthCtx, err := days.MonthContext(ctx, card.ID, flowDate, resetDay)
if err != nil {
return snapshotResult{}, errors.Wrap(errors.CodeDatabaseError, err, "查询卡日流量快照失败")
return flowWrite{}, errors.Wrap(errors.CodeDatabaseError, err, "查询卡日流量快照失败")
}
conversion := cardflow.Convert(readingMB, flowDate.Day(), resetDay, monthCtx)
if err := days.Upsert(ctx, postgres.CardFlowSnapshotWrite{
CardID: card.ID, FlowDate: flowDate, MonthUsedMB: conversion.MonthUsedMB,
VendorUsedMB: readingMB, StaleGuarded: conversion.StaleGuarded,
}); err != nil {
return snapshotResult{}, errors.Wrap(errors.CodeDatabaseError, err, "写入卡日流量快照失败")
return flowWrite{}, errors.Wrap(errors.CodeDatabaseError, err, "写入卡日流量快照失败")
}
return snapshotResult{flowDate: flowDate, conversion: conversion}, nil
monthMax, settled, err := days.MonthTotals(ctx, card.ID, flowDate)
if err != nil {
return flowWrite{}, errors.Wrap(errors.CodeDatabaseError, err, "查询卡本月流量结算失败")
}
delta := cardflow.MonthlyDelta(monthMax, settled)
if err := days.AddSettled(ctx, card.ID, flowDate, delta); err != nil {
return flowWrite{}, errors.Wrap(errors.CodeDatabaseError, err, "结算卡流量增量失败")
}
return flowWrite{flowDate: flowDate, conversion: conversion, monthMax: monthMax, delta: delta}, nil
}
// settleExisting 用卡上已有的本月最大快照结算(激活前结算本次没写入时):差额全部记为已结算,不新写快照。
func (s *Service) settleExisting(ctx context.Context, tx *gorm.DB, card *model.IotCard, observedAt time.Time) error {
flowDate := cardflow.ShanghaiDate(observedAt)
days := postgres.NewCardFlowDayStore(tx)
monthMax, settled, err := days.MonthTotals(ctx, card.ID, flowDate)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询卡本月流量结算失败")
}
if err := days.AddSettledToLatest(ctx, card.ID, flowDate, cardflow.MonthlyDelta(monthMax, settled)); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "激活前结算已有快照失败")
}
return nil
}
// ensureDailyUsageRows 为采集时刻每个有效套餐确保当天一行套餐日用量(没扣也写 0)。
func ensureDailyUsageRows(ctx context.Context, tx *gorm.DB, packages []*model.PackageUsage, flowDate time.Time) error {
ids := make([]uint, 0, len(packages))
cumulative := make(map[uint]int64, len(packages))
for _, pkg := range packages {
ids = append(ids, pkg.ID)
cumulative[pkg.ID] = pkg.DataUsageMB
}
if err := postgres.EnsureDailyUsageRows(ctx, tx, ids, flowDate, cumulative); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入套餐当天日用量失败")
}
return nil
}
// strictResetDay 在当前连接内按卡的 carrier_id 读取上游流量重置日,不使用调用方预先计算的值;
@@ -73,78 +116,3 @@ func (s *Service) strictResetDay(ctx context.Context, db *gorm.DB, card *model.I
}
return resetDay
}
// applySwitchedFlow 在观测事务内为已切换卡写快照、按月对账结算增量,并在第一次切换观测时写切换水位。
//
// oldDecision 是卡表基线算法对本次观测的结果:只在第一次切换观测时沿用其增量,此后不再使用。
func (s *Service) applySwitchedFlow(ctx context.Context, tx *gorm.DB, card *model.IotCard, observation domain.TrafficObservation, oldDecision domain.TrafficDecision) (*switchedFlowOutcome, error) {
readingMB := observation.GatewayReadingMB
snapshot, err := s.writeSnapshot(ctx, tx, card, readingMB, observation.Metadata.ObservedAt)
if err != nil {
return nil, err
}
days := postgres.NewCardFlowDayStore(tx)
monthMax, settled, err := days.MonthTotals(ctx, card.ID, snapshot.flowDate)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡本月流量结算失败")
}
hasPackage, pkgErr := hasActivePackageForCard(ctx, tx, card.ID, observation.Metadata.ObservedAt)
if pkgErr != nil {
// 判断出错按有套餐处理,保持不丢量。
s.warnLogger().Warn("判断卡是否有生效中套餐失败,按有套餐处理", zap.Uint("card_id", card.ID), zap.Error(pkgErr))
hasPackage = true
}
outcome := &switchedFlowOutcome{MonthMaxMB: monthMax, StaleGuarded: snapshot.conversion.StaleGuarded}
switches := postgres.NewCardFlowSwitchStore(tx)
watermark, err := switches.Get(ctx, card.ID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡流量切换水位失败")
}
if watermark == nil {
return outcome, s.firstSwitch(ctx, tx, card, observation, oldDecision, snapshot.flowDate, monthMax, settled, hasPackage, outcome)
}
delta := cardflow.MonthlyDelta(monthMax, settled)
if delta <= 0 {
return outcome, nil
}
if err := days.AddSettled(ctx, card.ID, snapshot.flowDate, delta); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "结算卡流量增量失败")
}
if hasPackage {
outcome.EmitMB = delta
}
return outcome, nil
}
// firstSwitch 处理第一次切换观测:本次沿用卡表基线算法的增量,此前部分记为已结算,并写入切换水位。
func (s *Service) firstSwitch(ctx context.Context, tx *gorm.DB, card *model.IotCard, observation domain.TrafficObservation,
oldDecision domain.TrafficDecision, flowDate time.Time, monthMax, settled float64, hasPackage bool, outcome *switchedFlowOutcome) error {
outcome.FirstSwitch = true
if oldDecision.IncrementMB > 0 && hasPackage {
outcome.EmitMB = oldDecision.IncrementMB
}
if err := postgres.NewCardFlowDayStore(tx).AddSettled(ctx, card.ID, flowDate, cardflow.MonthlyDelta(monthMax, settled)); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "结算卡流量切换前用量失败")
}
if err := postgres.NewCardFlowSwitchStore(tx).Create(ctx, &model.CardFlowSwitch{
IotCardID: card.ID, SwitchedAt: observation.Metadata.ObservedAt, MonthUsedMB: monthMax,
VendorUsedMB: observation.GatewayReadingMB, OldBaselineMB: card.LastGatewayReadingMB,
OldMonthUsageMB: card.CurrentMonthUsageMB, OldReadingAccepted: oldDecision.ReadingAccepted,
OldIncrementMB: oldDecision.IncrementMB,
}); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入卡流量切换水位失败")
}
return nil
}
// writeUnswitchedSnapshot 在观测事务提交后为未切换卡写快照;失败只告警,不影响本次观测结果。
func (s *Service) writeUnswitchedSnapshot(ctx context.Context, card *model.IotCard, observation domain.TrafficObservation) {
if card == nil || !s.trafficCalc.SnapshotEnabled() || !cardflow.ValidReading(observation.GatewayReadingMB) {
return
}
writeCtx, cancel := context.WithTimeout(ctx, unswitchedSnapshotTimeout)
defer cancel()
if _, err := s.writeSnapshot(writeCtx, s.db, card, observation.GatewayReadingMB, observation.Metadata.ObservedAt); err != nil {
s.warnLogger().Warn("未切换卡写日流量快照失败,不影响本次观测", zap.Uint("card_id", card.ID), zap.Error(err))
}
}

View File

@@ -0,0 +1,193 @@
package cardobservation
import (
"context"
stderrors "errors"
"math"
"strconv"
"sync"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// trafficQueueSettingsTTL 是队列配置在进程内的缓存时长;后台修改数秒内生效。
const trafficQueueSettingsTTL = 5 * time.Second
// TrafficQueueSettings 是卡流量采集队列的运行参数。
type TrafficQueueSettings struct {
Size int
ClearOnNewDay bool
MaxPointAgeHours int
DropToleranceMB float64
}
// DefaultTrafficQueueSettings 返回代码默认值。
func DefaultTrafficQueueSettings() TrafficQueueSettings {
return TrafficQueueSettings{
Size: constants.TrafficQueueDefaultSize, ClearOnNewDay: true,
MaxPointAgeHours: constants.TrafficQueueDefaultMaxPointAgeHours, DropToleranceMB: constants.TrafficQueueDefaultDropToleranceMB,
}
}
// TrafficIntervalChecker 校验队列配置变更后启用中轮询配置的流量采集间隔是否仍在上限内。
type TrafficIntervalChecker func(settings TrafficQueueSettings) error
// RegisterTrafficQueueConfigDefinitions 注册采集队列系统配置。checker 非空时,队列长度、跨天清空、最长点龄
// 修改前用它校验启用中的轮询配置(读取其余三项的当前值组合成修改后的配置)。
func RegisterTrafficQueueConfigDefinitions(registry *systemconfig.Registry, logger *zap.Logger, current func() TrafficQueueSettings, checker TrafficIntervalChecker) {
if registry == nil {
return
}
minSize, maxSize := int64(constants.TrafficQueueMinSize), int64(constants.TrafficQueueMaxSize)
minAge, maxAge := int64(0), int64(24*30)
withCheck := func(apply func(*TrafficQueueSettings, string) error) func(string) error {
return func(value string) error {
next := DefaultTrafficQueueSettings()
if current != nil {
next = current()
}
if err := apply(&next, value); err != nil {
return err
}
if checker == nil {
return nil
}
return checker(next)
}
}
definitions := []systemconfig.Definition{
{Key: constants.SystemConfigTrafficQueueSize, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeInt,
DefaultValue: strconv.Itoa(constants.TrafficQueueDefaultSize), Min: &minSize, Max: &maxSize,
Description: "卡流量采集队列长度 N:攒满 N 个不下降的读数才写最后一个点",
Validator: withCheck(func(s *TrafficQueueSettings, v string) error {
n, err := strconv.Atoi(v)
s.Size = n
return err
})},
{Key: constants.SystemConfigTrafficQueueClearOnNewDay, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeBool,
DefaultValue: "true", Description: "卡流量采集队列跨天清空:新点与队列中的点不在同一上海日期时先清空",
Validator: withCheck(func(s *TrafficQueueSettings, v string) error {
s.ClearOnNewDay = v == "true"
return nil
})},
{Key: constants.SystemConfigTrafficQueueMaxPointAgeHours, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeInt,
DefaultValue: strconv.Itoa(constants.TrafficQueueDefaultMaxPointAgeHours), Min: &minAge, Max: &maxAge,
Description: "卡流量采集队列最长点龄(小时):入队时丢弃与新点相差超过该时长的点,0 表示不限",
Validator: withCheck(func(s *TrafficQueueSettings, v string) error {
n, err := strconv.Atoi(v)
s.MaxPointAgeHours = n
return err
})},
{Key: constants.SystemConfigTrafficQueueDropToleranceMB, Module: constants.SystemConfigModuleTraffic, ValueType: constants.SystemConfigTypeString,
DefaultValue: "1", Description: "卡流量采集队列回退容差(MB):相邻两点回退不超过该值算持平,0 表示不允许回退",
Validator: func(v string) error {
_, err := parseDropTolerance(v)
return err
}},
}
for _, definition := range definitions {
if _, exists := registry.Get(definition.Key); exists {
continue
}
if err := registry.Register(definition); err != nil && logger != nil {
logger.Error("注册卡流量采集队列系统配置失败,将使用代码默认值", zap.String("config_key", definition.Key), zap.Error(err))
}
}
}
func parseDropTolerance(value string) (float64, error) {
tolerance, err := strconv.ParseFloat(value, 64)
if err != nil || math.IsNaN(tolerance) || math.IsInf(tolerance, 0) || tolerance < 0 || tolerance > 1024 {
return 0, stderrors.New("回退容差必须是 0~1024 之间的数字(MB)")
}
return tolerance, nil
}
// trafficConfigReader 读取系统配置的最小接缝。
type trafficConfigReader interface {
Get(ctx context.Context, key string) (string, error)
}
// TrafficQueueSettingsProvider 读取采集队列配置,带短缓存;读取失败时使用代码默认值。
type TrafficQueueSettingsProvider struct {
reader trafficConfigReader
logger *zap.Logger
mu sync.Mutex
cached TrafficQueueSettings
expiresAt time.Time
}
// NewTrafficQueueSettingsProvider 创建采集队列配置读取器。
func NewTrafficQueueSettingsProvider(reader trafficConfigReader, logger *zap.Logger) *TrafficQueueSettingsProvider {
if logger == nil {
logger = zap.NewNop()
}
return &TrafficQueueSettingsProvider{reader: reader, logger: logger}
}
// NewDefaultTrafficQueueSettingsProvider 用独立注册表与读取器创建采集队列配置读取器(API 与 Worker 共用同一份数据)。
func NewDefaultTrafficQueueSettingsProvider(db *gorm.DB, redisClient *redis.Client, logger *zap.Logger) *TrafficQueueSettingsProvider {
registry := systemconfig.NewRegistry()
RegisterTrafficQueueConfigDefinitions(registry, logger, nil, nil)
var cache systemconfig.Cache
if redisClient != nil {
cache = systemconfig.NewRedisCache(redisClient)
}
reader := systemconfig.NewReader(db, registry, cache, systemconfig.NewLogAlertSink(logger))
return NewTrafficQueueSettingsProvider(reader, logger)
}
// Current 返回当前采集队列配置。
func (p *TrafficQueueSettingsProvider) Current(ctx context.Context) TrafficQueueSettings {
if p == nil || p.reader == nil {
return DefaultTrafficQueueSettings()
}
p.mu.Lock()
defer p.mu.Unlock()
now := time.Now()
if now.Before(p.expiresAt) {
return p.cached
}
settings, err := p.load(ctx)
if err != nil {
p.logger.Warn("读取卡流量采集队列配置失败,使用代码默认值", zap.Error(err))
settings = DefaultTrafficQueueSettings()
}
p.cached, p.expiresAt = settings, now.Add(trafficQueueSettingsTTL)
return settings
}
func (p *TrafficQueueSettingsProvider) load(ctx context.Context) (TrafficQueueSettings, error) {
settings := DefaultTrafficQueueSettings()
raw, err := p.reader.Get(ctx, constants.SystemConfigTrafficQueueSize)
if err != nil {
return settings, err
}
if settings.Size, err = strconv.Atoi(raw); err != nil || settings.Size < constants.TrafficQueueMinSize {
return DefaultTrafficQueueSettings(), stderrors.New("采集队列长度配置非法")
}
if raw, err = p.reader.Get(ctx, constants.SystemConfigTrafficQueueClearOnNewDay); err != nil {
return DefaultTrafficQueueSettings(), err
}
settings.ClearOnNewDay = raw == "true"
if raw, err = p.reader.Get(ctx, constants.SystemConfigTrafficQueueMaxPointAgeHours); err != nil {
return DefaultTrafficQueueSettings(), err
}
if settings.MaxPointAgeHours, err = strconv.Atoi(raw); err != nil || settings.MaxPointAgeHours < 0 {
return DefaultTrafficQueueSettings(), stderrors.New("采集队列最长点龄配置非法")
}
if raw, err = p.reader.Get(ctx, constants.SystemConfigTrafficQueueDropToleranceMB); err != nil {
return DefaultTrafficQueueSettings(), err
}
if settings.DropToleranceMB, err = parseDropTolerance(raw); err != nil {
return DefaultTrafficQueueSettings(), err
}
return settings, nil
}

View File

@@ -1,8 +1,10 @@
package bootstrap
import (
"context"
agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge"
businessUserGroupApp "github.com/break/junhong_cmp_fiber/internal/application/businessusergroup"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
employeecollectionApp "github.com/break/junhong_cmp_fiber/internal/application/employeecollection"
h5PopupApp "github.com/break/junhong_cmp_fiber/internal/application/h5popup"
merchantPaymentApp "github.com/break/junhong_cmp_fiber/internal/application/merchantpayment"
@@ -129,6 +131,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
gatewaylimit.RegisterConfigDefinitions(systemConfigRegistry, deps.Logger)
carrierqueue.RegisterConfigDefinitions(systemConfigRegistry, deps.Logger)
packagesilenceApp.RegisterConfigDefinitions(systemConfigRegistry, deps.Logger)
registerTrafficQueueConfigDefinitions(systemConfigRegistry, deps)
systemConfigAlerts := systemConfigInfra.NewLogAlertSink(deps.Logger)
var systemConfigCache systemConfigInfra.Cache
if deps.Redis != nil {
@@ -437,5 +440,19 @@ func newAssetPollingService(svc *services, deps *Dependencies) *pollingSvcPkg.As
)
svc.Order.SetPollingRestorer(assetPollingSvc)
svc.Asset.SetPollingRestorer(assetPollingSvc, svc.StopResumeService, deps.Logger)
svc.Asset.SetTrafficAdjustmentEvaluator(svc.StopResumeService)
return assetPollingSvc
}
// registerTrafficQueueConfigDefinitions 注册卡流量采集队列配置:修改队列长度、跨天清空、最长点龄前
// 校验全部启用中的轮询配置的流量采集间隔仍在上限内,否则拒绝修改。
func registerTrafficQueueConfigDefinitions(registry *systemConfigInfra.Registry, deps *Dependencies) {
settings := cardObservationApp.NewDefaultTrafficQueueSettingsProvider(deps.DB, deps.Redis, deps.Logger)
current := func() cardObservationApp.TrafficQueueSettings { return settings.Current(context.Background()) }
checker := func(next cardObservationApp.TrafficQueueSettings) error {
return pollingSvcPkg.CheckEnabledTrafficIntervals(context.Background(), deps.DB, pollingSvcPkg.TrafficQueueLimits{
Size: next.Size, ClearOnNewDay: next.ClearOnNewDay, MaxPointAgeHours: next.MaxPointAgeHours,
})
}
cardObservationApp.RegisterTrafficQueueConfigDefinitions(registry, deps.Logger, current, checker)
}

View File

@@ -207,12 +207,9 @@ func initServices(s *stores, deps *Dependencies) *services {
cardObservationService.SetStateAuditWriter(iotCard)
// 运营商通道流量阈值达量判定嵌入流量观测事务:与卡流量事实同事务写周期锁与停机事件。
cardObservationService.SetChannelThresholdEvaluator(carrierThresholdService)
// 卡流量计量切换策略:启动时展开灰度名单为内存卡集合,API 与全部 Worker 必须使用一致配置。
var trafficCalcPolicy *cardObservationApp.TrafficCalcPolicy
if cfg := config.Get(); cfg != nil {
trafficCalcPolicy = cardObservationApp.BuildTrafficCalcPolicy(context.Background(), deps.DB, cfg.TrafficCalc, deps.Logger)
}
cardObservationService.SetTrafficCalcPolicy(trafficCalcPolicy, deps.Logger)
// 卡流量采集队列:读数先进每卡 Redis 队列,满足写入条件才写日快照;队列配置为受控系统配置。
cardObservationService.SetTrafficQueue(cardObservationInfra.NewTrafficQueue(deps.Redis),
cardObservationApp.NewDefaultTrafficQueueSettingsProvider(deps.DB, deps.Redis, deps.Logger), deps.Logger)
cardObservationService.SetCommandGrace(commandGraceProvider(deps.DB, deps.Redis, deps.Logger))
iotCard.SetCardObservationService(cardObservationService)
iotCard.SetSpeedTierIntegrationLog(integrationlog.NewRepository(deps.DB))
@@ -509,6 +506,11 @@ func initServices(s *stores, deps *Dependencies) *services {
pollingConfigService := pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger)
pollingConfigService.SetAudit(deps.DB, auditWriter)
pollingConfigService.SetTierDeps(pollingConfigMgr, deps.QueueClient)
trafficQueueSettings := cardObservationApp.NewDefaultTrafficQueueSettingsProvider(deps.DB, deps.Redis, deps.Logger)
pollingConfigService.SetTrafficQueueLimits(func(ctx context.Context) pollingSvc.TrafficQueueLimits {
current := trafficQueueSettings.Current(ctx)
return pollingSvc.TrafficQueueLimits{Size: current.Size, ClearOnNewDay: current.ClearOnNewDay, MaxPointAgeHours: current.MaxPointAgeHours}
})
pollingConcurrencyService := pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis)
pollingConcurrencyService.SetAudit(deps.DB, auditWriter)
pollingAlertService := pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger)
@@ -604,7 +606,7 @@ func initServices(s *stores, deps *Dependencies) *services {
AgentRechargePaymentConfirm: agentRechargePaymentConfirm,
AgentRechargeVoucherOCR: agentRechargeVoucherOCR,
PackageActivation: packageActivation,
TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage).WithFlowSnapshots(trafficCalcPolicy.Switched, postgres.NewCardFlowDayStore(deps.DB)),
TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage).WithFlowSnapshots(func(uint) bool { return true }, postgres.NewCardFlowDayStore(deps.DB)),
OperationPassword: operationPassword,
AgentOpenAPI: agentOpenAPI,
Refund: refundService,

View File

@@ -1,13 +1,10 @@
package bootstrap
import (
"context"
pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/config"
assetAutoRenewalApp "github.com/break/junhong_cmp_fiber/internal/application/assetautorenewal"
cardObservationApp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carrierThresholdApp "github.com/break/junhong_cmp_fiber/internal/application/carrierthreshold"
@@ -140,11 +137,9 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
// 运营商通道流量阈值达量判定嵌入流量观测事务:与卡流量事实同事务写周期锁与停机事件。
cardObservationService.SetChannelThresholdEvaluator(carrierThresholdService)
cardObservationService.SetCommandGrace(commandGraceProvider(deps.DB, deps.Redis, deps.Logger))
// 卡流量计量切换策略:启动时展开灰度名单为内存卡集合,API 与全部 Worker 必须使用一致配置。
if cfg := config.Get(); cfg != nil {
cardObservationService.SetTrafficCalcPolicy(
cardObservationApp.BuildTrafficCalcPolicy(context.Background(), deps.DB, cfg.TrafficCalc, deps.Logger), deps.Logger)
}
// 卡流量采集队列:读数先进每卡 Redis 队列,满足写入条件才写日快照;队列配置为受控系统配置。
cardObservationService.SetTrafficQueue(cardObservationInfra.NewTrafficQueue(deps.Redis),
cardObservationApp.NewDefaultTrafficQueueSettingsProvider(deps.DB, deps.Redis, deps.Logger), deps.Logger)
// 激活前流量结算:与 API 进程一致,注入激活服务与订单服务。
preActivationSettler := cardObservationInfra.NewPreActivationSettler(deps.DB, deps.GatewayClient, cardObservationService, postgres.NewCarrierStore(deps.DB), deps.Logger)
activationService.SetPreActivationSettler(preActivationSettler)

View File

@@ -0,0 +1,50 @@
package cardflow
import (
"math"
"time"
)
// 队列写入判断结果,与采集记录的写入判断取值一致。
const (
QueueWrite = "written"
QueueNotFull = "queue_not_full"
QueueSlopeNegative = "slope_negative"
QueueOutOfOrder = "out_of_order"
)
// QueuePoint 是采集队列中的一个点:采集时间与运营商原始读数。
type QueuePoint struct {
ObservedAt time.Time `json:"observed_at"`
ReadingMB float64 `json:"reading_mb"`
}
// JudgeQueue 判断本次新点能否写入:新点在队尾、队列满 size 个点、相邻两点后一个 ≥ 前一个 − 回退容差。
// 不满足时返回不写原因;满足时写入的是队列最后一个点(即本次新点)。
func JudgeQueue(points []QueuePoint, newPointIsTail bool, size int, dropToleranceMB float64) string {
if !newPointIsTail {
return QueueOutOfOrder
}
if len(points) < size {
return QueueNotFull
}
for i := 1; i < len(points); i++ {
if points[i].ReadingMB < points[i-1].ReadingMB-dropToleranceMB {
return QueueSlopeNegative
}
}
return QueueWrite
}
// MaxCollectionInterval 返回启用中轮询配置流量采集间隔的上限:留 2 次冗余,
// 跨天清空开启时为 24 小时 ÷ (N+2),关闭时为最长点龄 ÷ (N+2);最长点龄为 0(不限)时不设上限,返回 0。
func MaxCollectionInterval(size int, clearOnNewDay bool, maxPointAgeHours int, spare int) time.Duration {
window := 24 * time.Hour
if !clearOnNewDay {
if maxPointAgeHours <= 0 {
return 0
}
window = time.Duration(maxPointAgeHours) * time.Hour
}
return time.Duration(math.Floor(float64(window) / float64(size+spare)))
}

View File

@@ -87,7 +87,8 @@ func Convert(readingMB float64, day int, resetDay int, ctx MonthContext) Convers
return Conversion{MonthUsedMB: nonNegative(round3(month)), StaleGuarded: guarded}
}
// isStale 判断重置日当天的读数是否仍接近上周期总量。
// isStale 判断重置日当天的读数是否仍接近上周期总量;只看配置的重置日,当天全天判断。
// 比较基准为 0(没有快照可比)时,不论重置日是几号,当天任何正读数都按旧值处理,次日如实写入。
func isStale(readingMB float64, day int, resetDay int, ctx MonthContext) bool {
if day != resetDay || readingMB <= 0 {
return false
@@ -95,7 +96,7 @@ func isStale(readingMB float64, day int, resetDay int, ctx MonthContext) bool {
if resetDay <= 1 {
return readingMB > ctx.PrevMonthMax*StaleGuardRatio
}
return ctx.ThisMonthBeforeResetMax > 0 && readingMB > ctx.ThisMonthBeforeResetMax*StaleGuardRatio
return readingMB > ctx.ThisMonthBeforeResetMax*StaleGuardRatio
}
// MonthlyDelta 返回按月对账的待结算增量:本月最大快照 − 本月已结算合计,不大于 0 时返回 0。
@@ -108,9 +109,10 @@ func DailyUsage(dayMaxMB float64, priorMaxMB float64) float64 {
return nonNegative(round3(dayMaxMB - priorMaxMB))
}
// ValidReading 判断运营商读数能否写入快照:必须为有限正数。
// ValidReading 判断运营商读数是否有效:必须为有限且不小于 0 的数。
// 读数为 0 写不写快照由卡在采集时刻有没有套餐决定,不在这里拦。
func ValidReading(readingMB float64) bool {
return !math.IsNaN(readingMB) && !math.IsInf(readingMB, 0) && readingMB > 0
return !math.IsNaN(readingMB) && !math.IsInf(readingMB, 0) && readingMB >= 0
}
func nonNegative(v float64) float64 {

View File

@@ -2,8 +2,8 @@ package cardobservation
import (
"math"
"time"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
@@ -11,82 +11,59 @@ import (
type TrafficObservation struct {
CardID uint
GatewayReadingMB float64
ResetDay int
Metadata ObservationMetadata
// ReadingResult 是读数结果(constants.TrafficCollectionResult*);为空表示运营商给出了读数,
// 是否有效(有限且 ≥ 0)由观测用例判断。非 ok 的观测只写采集记录,不入队、不写快照。
ReadingResult string
ResetDay int
Metadata ObservationMetadata
}
// CardTrafficSnapshot 是流量规则需要的最小卡快照。
type CardTrafficSnapshot struct {
CardID uint
DataUsageMB int64
CurrentMonthUsageMB float64
CurrentMonthStartDate *time.Time
LastMonthTotalMB float64
LastGatewayReadingMB float64
}
// TrafficDecision 描述一次流量观测应持久化的最终值。
// TrafficDecision 描述一次流量观测的处理结果。
type TrafficDecision struct {
IncrementMB float64
ReadingAccepted bool
CrossMonth bool
DataUsageMB int64
CurrentMonthUsageMB float64
CurrentMonthStartDate time.Time
LastMonthTotalMB float64
LastGatewayReadingMB float64
// IncrementMB 是本次写入流量事件的增量,0 表示没有写事件。
IncrementMB float64
// ReadingAccepted 为 true 表示本次读数通过采集队列判断并写入了日快照。
ReadingAccepted bool
// Decision 是写入判断(constants.TrafficDecision*);读数无效时为空。
Decision string
// Result 是读数结果(constants.TrafficCollectionResult*)。
Result string
// ReadingMB 是本次运营商读数,没有给值时为 nil。
ReadingMB *float64
// CrossMonth 为 true 表示本次写入使卡表本月用量跨入新的自然月。
CrossMonth bool
// CurrentMonthUsageMB 是写入后的本月最大快照(未写入时为卡表原值)。
CurrentMonthUsageMB float64
// LastGatewayReadingMB 保留卡表原值,卡表基线算法已下线,不再更新。
LastGatewayReadingMB float64
}
// ApplyTraffic 应用运营商重置、跨月和异常下降保护规则。
func ApplyTraffic(snapshot CardTrafficSnapshot, observation TrafficObservation) (TrafficDecision, error) {
// ValidateTrafficObservation 校验观测元数据与读数结果;给出读数时读数必须是数字(有效性由用例判断)。
func ValidateTrafficObservation(observation TrafficObservation) error {
if err := validateObservationMetadata(observation.CardID, observation.Metadata); err != nil {
return TrafficDecision{}, err
return err
}
if math.IsNaN(observation.GatewayReadingMB) || math.IsInf(observation.GatewayReadingMB, 0) || observation.GatewayReadingMB < 0 {
return TrafficDecision{}, errors.New(errors.CodeInvalidParam, "流量观测读数无效")
switch observation.ReadingResult {
case "", constants.TrafficCollectionResultOK, constants.TrafficCollectionResultFailed, constants.TrafficCollectionResultRateLimited,
constants.TrafficCollectionResultInvalidPayload, constants.TrafficCollectionResultNoReading, constants.TrafficCollectionResultInvalidReading:
return nil
default:
return errors.New(errors.CodeInvalidParam, "流量观测读数结果无效")
}
if observation.ResetDay < 1 || observation.ResetDay > 31 {
return TrafficDecision{}, errors.New(errors.CodeInvalidParam, "运营商流量重置日无效")
}
// ResolveReadingResult 返回观测的最终读数结果:给出了读数但不是有限且 ≥ 0 的数时为读数无效。
func ResolveReadingResult(observation TrafficObservation) string {
if observation.ReadingResult != "" && observation.ReadingResult != constants.TrafficCollectionResultOK {
return observation.ReadingResult
}
now := observation.Metadata.ObservedAt
monthStart := time.Date(now.Year(), now.Month(), 1, 0, 0, 0, 0, now.Location())
decision := TrafficDecision{
ReadingAccepted: true, DataUsageMB: snapshot.DataUsageMB,
CurrentMonthUsageMB: snapshot.CurrentMonthUsageMB, CurrentMonthStartDate: monthStart,
LastMonthTotalMB: snapshot.LastMonthTotalMB, LastGatewayReadingMB: observation.GatewayReadingMB,
reading := observation.GatewayReadingMB
if math.IsNaN(reading) || math.IsInf(reading, 0) || reading < 0 {
return constants.TrafficCollectionResultInvalidReading
}
increment := observation.GatewayReadingMB - snapshot.LastGatewayReadingMB
if increment < 0 {
if isTrafficResetWindow(now, observation.ResetDay) {
increment = observation.GatewayReadingMB
} else {
increment = 0
decision.ReadingAccepted = false
decision.LastGatewayReadingMB = snapshot.LastGatewayReadingMB
}
}
decision.IncrementMB = increment
decision.CrossMonth = snapshot.CurrentMonthStartDate == nil || snapshot.CurrentMonthStartDate.Before(monthStart)
if decision.CrossMonth {
decision.LastMonthTotalMB = snapshot.CurrentMonthUsageMB
decision.CurrentMonthUsageMB = increment
} else if increment > 0 {
decision.CurrentMonthUsageMB += increment
}
if increment > 0 {
decision.DataUsageMB += int64(increment)
}
return decision, nil
return constants.TrafficCollectionResultOK
}
func validateObservationMetadata(cardID uint, metadata ObservationMetadata) error {
return validateObservation(RealnameObservation{CardID: cardID, Verified: true, Metadata: metadata})
}
func isTrafficResetWindow(now time.Time, resetDay int) bool {
if now.Day() == resetDay {
return true
}
resetDate := time.Date(now.Year(), now.Month(), resetDay, 0, 0, 0, 0, now.Location())
return now.Day() == resetDate.AddDate(0, 0, -1).Day()
}

View File

@@ -138,6 +138,64 @@ type FlowUsageResp struct {
ICCID string `json:"iccid" description:"ICCID"`
Used FlexFloat `json:"used" description:"当月已用流量(MB,兼容数字或字符串)"`
Unit string `json:"unit" description:"流量单位(MB)"`
// UsedState 区分运营商"没有给值"与真实读数;零值表示没有给值(响应缺少 used 字段时不会调用解析)。
UsedState FlowReadingState `json:"-"`
}
// FlowReadingState 是流量读数的解析结果。
type FlowReadingState int
const (
// FlowReadingMissing 表示 used 缺失、为 null、为空或无法解析。
FlowReadingMissing FlowReadingState = iota
// FlowReadingPresent 表示 used 解析为数字(可能为负数、NaN、Inf,由调用方判断是否有效)。
FlowReadingPresent
)
// UnmarshalJSON 解析流量响应,并记录 used 是否给出了可解析的数字。
func (r *FlowUsageResp) UnmarshalJSON(data []byte) error {
type plain FlowUsageResp
var raw struct {
plain
Used json.RawMessage `json:"used"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return err
}
*r = FlowUsageResp(raw.plain)
r.Used, r.UsedState = 0, FlowReadingMissing
if value, ok := parseFlowReading(raw.Used); ok {
r.Used, r.UsedState = FlexFloat(value), FlowReadingPresent
}
return nil
}
// HasReading 返回运营商是否给出了可解析的读数。
func (r *FlowUsageResp) HasReading() bool {
return r != nil && r.UsedState == FlowReadingPresent
}
// parseFlowReading 按 FlexFloat 的兼容规则解析读数,null、空、无法解析时返回 false。
func parseFlowReading(data json.RawMessage) (float64, bool) {
raw := strings.TrimSpace(string(data))
if raw == "" || raw == "null" {
return 0, false
}
if strings.HasPrefix(raw, "\"") && strings.HasSuffix(raw, "\"") {
unquoted, err := strconv.Unquote(raw)
if err != nil {
return 0, false
}
raw = strings.TrimSpace(unquoted)
}
if raw == "" {
return 0, false
}
value, err := strconv.ParseFloat(raw, 64)
if err != nil {
return 0, false
}
return value, true
}
// CardOperationReq 是停机/复机请求

View File

@@ -0,0 +1,87 @@
package admin
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/validation"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// AdjustPackageTraffic 人工冲正套餐流量
// POST /api/admin/assets/:identifier/packages/:package_usage_id/traffic-adjustments
func (h *AssetHandler) AdjustPackageTraffic(c *fiber.Ctx) error {
if err := rejectEnterpriseAssetAdmin(c); err != nil {
return err
}
asset, packageUsageID, err := h.resolveAssetPackageUsagePath(c)
if err != nil {
return err
}
var req dto.AdjustAssetPackageTrafficRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
req.PackageUsageID = packageUsageID
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("冲正参数不合法", &req, err))
}
result, err := h.assetService.AdjustPackageTraffic(c.UserContext(), asset.AssetType, asset.AssetID, assetIdentifierForAudit(asset), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ListPackageTrafficAdjustments 查询套餐冲正流水
// GET /api/admin/assets/:identifier/packages/:package_usage_id/traffic-adjustments
func (h *AssetHandler) ListPackageTrafficAdjustments(c *fiber.Ctx) error {
if err := rejectEnterpriseAssetAdmin(c); err != nil {
return err
}
asset, packageUsageID, err := h.resolveAssetPackageUsagePath(c)
if err != nil {
return err
}
var req dto.PackageTrafficAdjustmentListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
req.PackageUsageID = packageUsageID
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("冲正流水查询参数不合法", &req, err))
}
result, err := h.assetService.ListPackageTrafficAdjustments(c.UserContext(), asset.AssetType, asset.AssetID, &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// ListTrafficCollections 查询卡流量采集记录
// GET /api/admin/assets/:identifier/traffic-collections
func (h *AssetHandler) ListTrafficCollections(c *fiber.Ctx) error {
if err := rejectEnterpriseAssetAdmin(c); err != nil {
return err
}
asset, err := h.resolveByIdentifier(c)
if err != nil {
return err
}
if asset.AssetType != "card" {
return errors.New(errors.CodeInvalidParam, "采集记录只支持按卡查询")
}
var req dto.CardTrafficCollectionListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数格式不正确")
}
if err := h.validator.Struct(&req); err != nil {
return errors.New(errors.CodeInvalidParam, validation.Message("采集记录查询参数不合法", &req, err))
}
result, err := h.assetService.ListCardTrafficCollections(c.UserContext(), asset.AssetID, &req)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -14,6 +14,7 @@ import (
"gorm.io/gorm/clause"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/domain/cardflow"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
@@ -111,7 +112,7 @@ type RealnameActivator interface {
// TrafficDeductor 按卡流量正增量扣减套餐流量。
type TrafficDeductor interface {
DeductDataUsage(ctx context.Context, carrierType string, carrierID uint, usageMB float64) error
DeductDataUsage(ctx context.Context, cardID uint, usageMB float64, observedAt time.Time, observationID string) error
}
// StopResumeEvaluator 根据卡的当前事实执行停复机评估。
@@ -258,7 +259,7 @@ func (c *TrafficIncrementedConsumer) Consume(ctx context.Context, envelope outbo
return nil
}
if receipt.Status == constants.CardObservationEffectStatusProcessing {
dailyKey := constants.RedisCardDailyTrafficKey(event.CardID, event.ObservedAt.Format("2006-01-02"))
dailyKey := constants.RedisCardDailyTrafficKey(event.CardID, cardflow.ShanghaiDate(event.ObservedAt).Format("2006-01-02"))
pipe := c.redis.TxPipeline()
pipe.IncrByFloat(ctx, dailyKey, event.IncrementMB)
pipe.Expire(ctx, dailyKey, 48*time.Hour)
@@ -274,7 +275,7 @@ func (c *TrafficIncrementedConsumer) Consume(ctx context.Context, envelope outbo
if err := c.updateTrafficEffectStatus(ctx, receipt.ID, constants.CardObservationEffectStatusDailySaved, constants.CardObservationEffectStatusProcessing); err != nil {
return err
}
if err := c.deductor.DeductDataUsage(ctx, constants.AssetTypeIotCard, event.CardID, event.IncrementMB); err != nil {
if err := c.deductor.DeductDataUsage(ctx, event.CardID, event.IncrementMB, event.ObservedAt, event.ObservationID); err != nil {
return err
}
if err := c.updateTrafficEffectStatus(ctx, receipt.ID, constants.CardObservationEffectStatusProcessing, constants.CardObservationEffectStatusDeducted); err != nil {

View File

@@ -55,9 +55,6 @@ func (s *PreActivationSettler) SettleBeforeActivation(ctx context.Context, carri
}
deadline := time.Now().Add(constants.PreActivationSettlementTotalTimeoutSeconds * time.Second)
for _, card := range cards {
if !s.observation.TrafficSwitched(card.ID) {
continue
}
if time.Now().After(deadline) {
s.alert(card, carrierType, carrierID, "整次结算达到总超时,跳过该卡")
continue
@@ -88,7 +85,7 @@ func (s *PreActivationSettler) settleCard(ctx context.Context, card *model.IotCa
}
observedAt := time.Now().UTC()
decision, err := s.observation.ApplyTrafficObservation(stepCtx, carddomain.TrafficObservation{
CardID: card.ID, GatewayReadingMB: float64(response.Used), ResetDay: resetDay,
CardID: card.ID, GatewayReadingMB: float64(response.Used), ReadingResult: flowReadingResult(response), ResetDay: resetDay,
Metadata: carddomain.ObservationMetadata{
ObservationID: "pre-activation-" + card.ICCID + "-" + observedAt.Format("20060102150405.000"),
Source: constants.CardObservationSourceBusinessEvent, Scene: constants.CardObservationScenePreActivationSettlement,
@@ -102,6 +99,7 @@ func (s *PreActivationSettler) settleCard(ctx context.Context, card *model.IotCa
s.logger.Info("激活前流量结算完成", zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID),
zap.String("carrier_type", carrierType), zap.Uint("asset_id", carrierID),
zap.Float64("gateway_flow_mb", float64(response.Used)), zap.Float64("increment_mb", decision.IncrementMB),
zap.String("result", decision.Result), zap.String("decision", decision.Decision),
zap.String("metric", "package.pre_activation_settlement.done"))
}

View File

@@ -558,7 +558,8 @@ func (r *SeriesRunner) queryAndApply(ctx context.Context, payload cardapp.Series
return cardapp.RunResult{}, err
}
decision, err := r.observation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{
CardID: card.ID, GatewayReadingMB: float64(response.Used), ResetDay: r.carrier.GetDataResetDay(ctx, card.CarrierID), Metadata: metadata,
CardID: card.ID, GatewayReadingMB: float64(response.Used), ReadingResult: flowReadingResult(response),
ResetDay: r.carrier.GetDataResetDay(ctx, card.CarrierID), Metadata: metadata,
})
return cardapp.RunResult{StateChanged: decision.IncrementMB > 0 || decision.CrossMonth}, err
case constants.CardObservationSyncTypeNetwork:
@@ -640,3 +641,11 @@ func initialResult(result string) string {
var _ cardapp.SeriesAttemptLogger = (*SeriesAttemptLogger)(nil)
var _ cardapp.SeriesRunner = (*SeriesRunner)(nil)
// flowReadingResult 把运营商流量响应映射为读数结果:没给出可解析读数时为无读数。
func flowReadingResult(response *gateway.FlowUsageResp) string {
if !response.HasReading() {
return constants.TrafficCollectionResultNoReading
}
return constants.TrafficCollectionResultOK
}

View File

@@ -0,0 +1,120 @@
package cardobservation
import (
"context"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/domain/cardflow"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// 采集告警阈值。
const (
// trafficAlertSlopeRatio 是单卡当天"读数下降"拦截占比告警阈值。
trafficAlertSlopeRatio = 0.5
// trafficAlertSlopeMinRecords 是单卡当天参与拦截占比统计的最少采集记录数。
trafficAlertSlopeMinRecords = 6
// trafficAlertSampleLimit 是每类告警日志里列出的卡数上限。
trafficAlertSampleLimit = 50
)
// TrafficAlertScanner 按天扫描采集记录与日快照,输出卡流量采集告警日志(只看开启轮询的卡)。
type TrafficAlertScanner struct {
db *gorm.DB
logger *zap.Logger
}
// NewTrafficAlertScanner 创建卡流量采集告警扫描器。
func NewTrafficAlertScanner(db *gorm.DB, logger *zap.Logger) *TrafficAlertScanner {
if logger == nil {
logger = zap.NewNop()
}
return &TrafficAlertScanner{db: db, logger: logger}
}
// Scan 扫描 day(上海日期)一整天:有套餐的卡全天没有写入、待建起点仍未清除、单卡读数下降占比过高、
// 乱序数量与重置日防旧值命中数。当天没有写入时不补写快照,只告警。
func (s *TrafficAlertScanner) Scan(ctx context.Context, day time.Time) error {
from := cardflow.ShanghaiDate(day)
to := from.AddDate(0, 0, 1)
db := s.db.WithContext(ctx)
var notWritten []uint
if err := db.Raw(`
SELECT c.iot_card_id FROM tb_card_traffic_collection c
JOIN tb_iot_card card ON card.id = c.iot_card_id AND card.enable_polling = TRUE AND card.deleted_at IS NULL
WHERE c.observed_at >= ? AND c.observed_at < ?
GROUP BY c.iot_card_id
HAVING COUNT(*) FILTER (WHERE c.decision IN (?, ?)) = 0
LIMIT ?`, from, to, constants.TrafficDecisionWritten, constants.TrafficDecisionBaseline, trafficAlertSampleLimit).
Scan(&notWritten).Error; err != nil {
return err
}
if len(notWritten) > 0 {
s.logger.Error("有套餐的卡一整天没有写入流量快照,请核对运营商读数与采集间隔",
zap.String("date", from.Format("2006-01-02")), zap.Uints("card_ids", notWritten),
zap.String("metric", "card_traffic.collection.day_not_written"))
}
var baselinePending []uint
if err := db.Raw(`
SELECT id FROM tb_iot_card
WHERE traffic_baseline_pending = TRUE AND enable_polling = TRUE AND deleted_at IS NULL
AND last_data_check_at IS NOT NULL AND last_data_check_at < ? AND last_data_check_at >= ?
LIMIT ?`, to, from, trafficAlertSampleLimit).Scan(&baselinePending).Error; err != nil {
return err
}
if len(baselinePending) > 0 {
s.logger.Error("开启轮询的卡已被采集一天仍未建起点,请核对采集队列是否一直不满足写入条件",
zap.String("date", from.Format("2006-01-02")), zap.Uints("card_ids", baselinePending),
zap.String("metric", "card_traffic.collection.baseline_pending"))
}
var slopeCards []uint
if err := db.Raw(`
SELECT c.iot_card_id FROM tb_card_traffic_collection c
JOIN tb_iot_card card ON card.id = c.iot_card_id AND card.enable_polling = TRUE AND card.deleted_at IS NULL
WHERE c.observed_at >= ? AND c.observed_at < ? AND c.result = ?
GROUP BY c.iot_card_id
HAVING COUNT(*) >= ? AND COUNT(*) FILTER (WHERE c.decision = ?)::float / COUNT(*) > ?
LIMIT ?`, from, to, constants.TrafficCollectionResultOK, trafficAlertSlopeMinRecords,
constants.TrafficDecisionSlopeNegative, trafficAlertSlopeRatio, trafficAlertSampleLimit).Scan(&slopeCards).Error; err != nil {
return err
}
if len(slopeCards) > 0 {
s.logger.Error("卡当天读数下降拦截占比过高,请核对运营商读数",
zap.String("date", from.Format("2006-01-02")), zap.Uints("card_ids", slopeCards),
zap.Float64("ratio_threshold", trafficAlertSlopeRatio), zap.String("metric", "card_traffic.collection.slope_negative_high"))
}
var totals struct {
Total int64
Slope int64
OutOfOrder int64
}
if err := db.Raw(`
SELECT COUNT(*) AS total,
COUNT(*) FILTER (WHERE decision = ?) AS slope,
COUNT(*) FILTER (WHERE decision = ?) AS out_of_order
FROM tb_card_traffic_collection WHERE observed_at >= ? AND observed_at < ?`,
constants.TrafficDecisionSlopeNegative, constants.TrafficDecisionOutOfOrder, from, to).Scan(&totals).Error; err != nil {
return err
}
var staleHits int64
if err := db.Raw(`SELECT COUNT(*) FROM tb_card_flow_day WHERE flow_date = ?::date AND stale_guarded = TRUE`,
from.Format("2006-01-02")).Scan(&staleHits).Error; err != nil {
return err
}
level := s.logger.Info
if totals.OutOfOrder > 0 {
level = s.logger.Warn
}
level("卡流量采集日统计",
zap.String("date", from.Format("2006-01-02")), zap.Int64("collections", totals.Total),
zap.Int64("slope_negative", totals.Slope), zap.Int64("out_of_order", totals.OutOfOrder),
zap.Int64("stale_guarded_cards", staleHits), zap.String("metric", "card_traffic.collection.daily"))
return nil
}

View File

@@ -0,0 +1,130 @@
package cardobservation
import (
"context"
"encoding/json"
"strconv"
"time"
"github.com/redis/go-redis/v9"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/domain/cardflow"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// trafficQueueScript 原子更新一张卡的采集队列:跨天清空(新点日期晚于队尾日期时)、丢弃过期点、
// 按采集时间插入并对同一时间去重、只保留最后 N 个、设置过期,返回队列与新点是否在队尾。
// 新点日期早于队尾日期(迟到的前一天点)时不改队列,按乱序处理。日期由调用方按上海时区算好传入。
//
// KEYS[1] 队列 key;ARGV: 采集时间毫秒、读数、日期(YYYYMMDD)、N、是否跨天清空(1/0)、最长点龄毫秒(0 不限)、过期毫秒。
var trafficQueueScript = redis.NewScript(`
local raw = redis.call('GET', KEYS[1])
local pts = {}
if raw then pts = cjson.decode(raw) end
local t = tonumber(ARGV[1])
local v = tonumber(ARGV[2])
local d = ARGV[3]
local n = tonumber(ARGV[4])
local clear = ARGV[5] == '1'
local maxAge = tonumber(ARGV[6])
local ttl = tonumber(ARGV[7])
if clear and #pts > 0 then
local lastDay = pts[#pts].d
if d < lastDay then
return {cjson.encode(pts), 0}
end
if d > lastDay then pts = {} end
end
if maxAge > 0 then
local kept = {}
for _, p in ipairs(pts) do
if t - p.t <= maxAge then table.insert(kept, p) end
end
pts = kept
end
local out = {}
local inserted = false
for _, p in ipairs(pts) do
if not inserted and t <= p.t then
table.insert(out, {t = t, v = v, d = d})
inserted = true
end
if p.t ~= t then table.insert(out, p) end
end
if not inserted then table.insert(out, {t = t, v = v, d = d}) end
while #out > n do table.remove(out, 1) end
local tail = 0
if #out > 0 and out[#out].t == t then tail = 1 end
redis.call('SET', KEYS[1], cjson.encode(out), 'PX', ttl)
return {cjson.encode(out), tail}
`)
// TrafficQueue 是基于 Redis 的卡流量采集队列。队列只是写入前的过滤器:key 丢失时从空开始,只晚写不错账。
type TrafficQueue struct {
redis *redis.Client
}
// NewTrafficQueue 创建卡流量采集队列。
func NewTrafficQueue(redisClient *redis.Client) *TrafficQueue {
return &TrafficQueue{redis: redisClient}
}
type queuedPoint struct {
T int64 `json:"t"`
V float64 `json:"v"`
D string `json:"d"`
}
// Push 把新点放入卡的采集队列,返回入队后的队列(按采集时间升序)与新点是否在队尾。
func (q *TrafficQueue) Push(ctx context.Context, cardID uint, point cardflow.QueuePoint, settings cardapp.TrafficQueueSettings) ([]cardflow.QueuePoint, bool, error) {
if q == nil || q.redis == nil {
return nil, false, errors.New(errors.CodeInternalError, "卡流量采集队列未配置")
}
day := cardflow.ShanghaiDate(point.ObservedAt)
clear := "0"
if settings.ClearOnNewDay {
clear = "1"
}
maxAgeMS := int64(settings.MaxPointAgeHours) * int64(time.Hour/time.Millisecond)
result, err := trafficQueueScript.Run(ctx, q.redis, []string{constants.RedisCardTrafficQueueKey(cardID)},
point.ObservedAt.UnixMilli(), strconv.FormatFloat(point.ReadingMB, 'f', -1, 64), day.Format("20060102"),
settings.Size, clear, maxAgeMS, queueTTL(point.ObservedAt, settings).Milliseconds(),
).Slice()
if err != nil {
return nil, false, errors.Wrap(errors.CodeRedisError, err, "更新卡流量采集队列失败")
}
if len(result) != 2 {
return nil, false, errors.New(errors.CodeRedisError, "卡流量采集队列返回格式异常")
}
encoded, _ := result[0].(string)
var stored []queuedPoint
if encoded != "" && encoded != "{}" {
if err := json.Unmarshal([]byte(encoded), &stored); err != nil {
return nil, false, errors.Wrap(errors.CodeRedisError, err, "解析卡流量采集队列失败")
}
}
points := make([]cardflow.QueuePoint, 0, len(stored))
for _, p := range stored {
points = append(points, cardflow.QueuePoint{ObservedAt: time.UnixMilli(p.T), ReadingMB: p.V})
}
tail, _ := result[1].(int64)
return points, tail == 1, nil
}
// queueTTL 返回队列 key 的过期时长:跨天清空开启时到次日 01:00(上海时间),关闭时为最长点龄(不限时 7 天)。
func queueTTL(observedAt time.Time, settings cardapp.TrafficQueueSettings) time.Duration {
if settings.ClearOnNewDay {
expireAt := cardflow.ShanghaiDate(observedAt).AddDate(0, 0, 1).Add(time.Hour)
if ttl := time.Until(expireAt); ttl > time.Minute {
return ttl
}
return time.Hour
}
hours := settings.MaxPointAgeHours
if hours <= 0 {
hours = constants.TrafficQueueUnlimitedTTLHours
}
return time.Duration(hours) * time.Hour
}

View File

@@ -0,0 +1,54 @@
package model
import (
"time"
"gorm.io/datatypes"
)
// CardTrafficCollection 卡流量采集记录:采集时刻有套餐的卡每次采集一条,按月分区保留 90 天。
type CardTrafficCollection struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
IotCardID uint `gorm:"column:iot_card_id;not null" json:"iot_card_id"`
ObservedAt time.Time `gorm:"column:observed_at;primaryKey;not null" json:"observed_at"`
ObservationID string `gorm:"column:observation_id;type:varchar(128);not null;default:''" json:"observation_id"`
Source string `gorm:"column:source;type:varchar(32);not null;default:''" json:"source"`
Scene string `gorm:"column:scene;type:varchar(64);not null;default:''" json:"scene"`
Result string `gorm:"column:result;type:varchar(32);not null" json:"result"`
VendorUsedMB *float64 `gorm:"column:vendor_used_mb;type:numeric(14,3)" json:"vendor_used_mb"`
QueuePoints datatypes.JSON `gorm:"column:queue_points;type:jsonb" json:"queue_points"`
Decision string `gorm:"column:decision;type:varchar(32);not null;default:''" json:"decision"`
MonthUsedMB *float64 `gorm:"column:month_used_mb;type:numeric(14,3)" json:"month_used_mb"`
StaleGuarded *bool `gorm:"column:stale_guarded" json:"stale_guarded"`
DeltaMB *float64 `gorm:"column:delta_mb;type:numeric(14,3)" json:"delta_mb"`
EventID string `gorm:"column:event_id;type:varchar(128);not null;default:''" json:"event_id"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at"`
}
// TableName 指定表名。
func (CardTrafficCollection) TableName() string {
return "tb_card_traffic_collection"
}
// PackageUsageAdjustment 套餐流量人工冲正流水:只新增,不修改、不删除。
type PackageUsageAdjustment struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
PackageUsageID uint `gorm:"column:package_usage_id;not null" json:"package_usage_id"`
IotCardID uint `gorm:"column:iot_card_id;not null;default:0" json:"iot_card_id"`
DeviceID uint `gorm:"column:device_id;not null;default:0" json:"device_id"`
DeltaMB int64 `gorm:"column:delta_mb;not null" json:"delta_mb"`
BeforeUsageMB int64 `gorm:"column:before_usage_mb;not null" json:"before_usage_mb"`
AfterUsageMB int64 `gorm:"column:after_usage_mb;not null" json:"after_usage_mb"`
BeforeStatus int `gorm:"column:before_status;not null" json:"before_status"`
AfterStatus int `gorm:"column:after_status;not null" json:"after_status"`
Reason string `gorm:"column:reason;type:varchar(500);not null" json:"reason"`
RefObservationID string `gorm:"column:ref_observation_id;type:varchar(128);not null;default:''" json:"ref_observation_id"`
RefEventID string `gorm:"column:ref_event_id;type:varchar(128);not null;default:''" json:"ref_event_id"`
OperatorID uint `gorm:"column:operator_id;not null" json:"operator_id"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:now()" json:"created_at"`
}
// TableName 指定表名。
func (PackageUsageAdjustment) TableName() string {
return "tb_package_usage_adjustment"
}

View File

@@ -1,6 +1,9 @@
package dto
import "time"
import (
"encoding/json"
"time"
)
// AssetResolveResponse 统一资产解析响应
type AssetResolveResponse struct {
@@ -129,22 +132,32 @@ type BoundCardInfo struct {
// AssetRealtimeStatusResponse 资产实时状态(只读DB/Redis)
type AssetRealtimeStatusResponse struct {
AssetType string `json:"asset_type" description:"资产类型:card 或 device"`
AssetID uint `json:"asset_id" description:"资产ID"`
NetworkStatus int `json:"network_status" description:"网络状态(asset_type=card时有效):0停机 1开机"`
GatewayExtend string `json:"gateway_extend" description:"Gateway 卡状态扩展字段,原样返回上游 extend,用于展示运营商侧实际停机原因"`
GatewayCardIMEI string `json:"gateway_card_imei" description:"插拔卡业务 IMEI(由 Gateway 卡状态接口同步,非设备自身 IMEI,无业务含义,仅供查看)"`
RealNameStatus int `json:"real_name_status" description:"实名状态(asset_type=card时有效):0未实名 1已实名"`
RealnamePolicy string `json:"realname_policy,omitempty" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
CurrentMonthUsageMB float64 `json:"current_month_usage_mb,omitempty" description:"系统自然月累计流量(MB,asset_type=card时有效,不等于运营商周期口径)"`
LastGatewayReadingMB float64 `json:"last_gateway_reading_mb,omitempty" description:"运营商当前周期累计流量读数(MB,asset_type=card时有效,运营商口径展示建议读取该字段)"`
LastSyncTime *time.Time `json:"last_sync_time" description:"最后同步时间(asset_type=card时有效)"`
LastDataCheckAt *time.Time `json:"last_data_check_at" description:"最后一次流量检查时间(asset_type=card时有效)"`
LastRealNameCheckAt *time.Time `json:"last_real_name_check_at" description:"最后一次实名检查时间(asset_type=card时有效)"`
LastCardStatusCheckAt *time.Time `json:"last_card_status_check_at" description:"最后一次卡状态检查时间(asset_type=card时有效)"`
DeviceProtectStatus string `json:"device_protect_status,omitempty" deprecated:"true" description:"已废弃:设备保护期已下线,固定返回 none,仅为兼容保留(asset_type=device时有效)"`
Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定卡状态列表(asset_type=device时有效)"`
DeviceRealtime *DeviceGatewayInfo `json:"device_realtime" description:"设备实时状态(来自 Gateway sync-info,始终返回,失败时仅含 gateway_msg)"`
AssetType string `json:"asset_type" description:"资产类型:card 或 device"`
AssetID uint `json:"asset_id" description:"资产ID"`
NetworkStatus int `json:"network_status" description:"网络状态(asset_type=card时有效):0停机 1开机"`
GatewayExtend string `json:"gateway_extend" description:"Gateway 卡状态扩展字段,原样返回上游 extend,用于展示运营商侧实际停机原因"`
GatewayCardIMEI string `json:"gateway_card_imei" description:"插拔卡业务 IMEI(由 Gateway 卡状态接口同步,非设备自身 IMEI,无业务含义,仅供查看)"`
RealNameStatus int `json:"real_name_status" description:"实名状态(asset_type=card时有效):0未实名 1已实名"`
RealnamePolicy string `json:"realname_policy,omitempty" description:"实名认证策略 (none:无需实名, before_order:先实名后充值/购买, after_order:先充值/购买后实名)"`
CurrentMonthUsageMB float64 `json:"current_month_usage_mb,omitempty" description:"系统自然月累计流量(MB,asset_type=card时有效,不等于运营商周期口径)"`
LastGatewayReadingMB float64 `json:"last_gateway_reading_mb,omitempty" description:"运营商当前周期累计流量读数(MB,asset_type=card时有效,运营商口径展示建议读取该字段)"`
LastSyncTime *time.Time `json:"last_sync_time" description:"最后同步时间(asset_type=card时有效)"`
LastDataCheckAt *time.Time `json:"last_data_check_at" description:"最后一次流量检查时间(asset_type=card时有效)"`
LastRealNameCheckAt *time.Time `json:"last_real_name_check_at" description:"最后一次实名检查时间(asset_type=card时有效)"`
LastCardStatusCheckAt *time.Time `json:"last_card_status_check_at" description:"最后一次卡状态检查时间(asset_type=card时有效)"`
DeviceProtectStatus string `json:"device_protect_status,omitempty" deprecated:"true" description:"已废弃:设备保护期已下线,固定返回 none,仅为兼容保留(asset_type=device时有效)"`
Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定卡状态列表(asset_type=device时有效)"`
DeviceRealtime *DeviceGatewayInfo `json:"device_realtime" description:"设备实时状态(来自 Gateway sync-info,始终返回,失败时仅含 gateway_msg)"`
TrafficRefresh *CardTrafficRefreshResult `json:"traffic_refresh,omitempty" description:"本次刷新的流量写入结果(仅刷新单卡接口返回)"`
}
// CardTrafficRefreshResult 人工刷新卡流量的写入结果。
type CardTrafficRefreshResult struct {
ReadingMB *float64 `json:"reading_mb" description:"本次运营商读数(MB),没有给值时为空"`
Result string `json:"result" description:"读数结果:ok/failed/rate_limited/invalid_payload/no_reading/invalid_reading"`
Written bool `json:"written" description:"本次读数是否写入日快照"`
Decision string `json:"decision" description:"写入判断:written/baseline/queue_not_full/slope_negative/out_of_order,读数无效时为空"`
DeltaMB float64 `json:"delta_mb" description:"本次写入流量事件的增量(MB)"`
}
// AssetPackageResponse 资产套餐信息
@@ -252,6 +265,78 @@ type UpdateAssetPackageExpiresAtRequest struct {
}
// UpdateAssetPackageUsageRequest 修改资产套餐已用量请求
// AdjustAssetPackageTrafficRequest 人工冲正套餐流量请求。
type AdjustAssetPackageTrafficRequest struct {
Identifier string `path:"identifier" description:"资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN)" required:"true"`
PackageUsageID uint `path:"package_usage_id" description:"套餐使用记录ID" required:"true"`
DeltaMB int64 `json:"delta_mb" validate:"required,ne=0" required:"true" description:"调整量(MB):正数补扣,负数退回,不能为 0"`
Reason string `json:"reason" validate:"required,max=500" required:"true" maxLength:"500" description:"冲正原因"`
RefObservationID string `json:"ref_observation_id" validate:"omitempty,max=128" maxLength:"128" description:"关联的采集 ID(可选)"`
RefEventID string `json:"ref_event_id" validate:"omitempty,max=128" maxLength:"128" description:"关联的流量事件 ID(可选)"`
}
// PackageTrafficAdjustmentListRequest 冲正流水查询请求。
type PackageTrafficAdjustmentListRequest struct {
Identifier string `path:"identifier" description:"资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN)" required:"true"`
PackageUsageID uint `path:"package_usage_id" description:"套餐使用记录ID" required:"true"`
Page int `query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认 1"`
PageSize int `query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数,默认 20"`
}
// PackageTrafficAdjustmentItem 冲正流水。
type PackageTrafficAdjustmentItem struct {
ID uint `json:"id" description:"流水ID"`
PackageUsageID uint `json:"package_usage_id" description:"套餐使用记录ID"`
DeltaMB int64 `json:"delta_mb" description:"调整量(MB):正数补扣,负数退回"`
BeforeUsageMB int64 `json:"before_usage_mb" description:"调整前已用量(MB)"`
AfterUsageMB int64 `json:"after_usage_mb" description:"调整后已用量(MB)"`
BeforeStatus int `json:"before_status" description:"调整前套餐状态"`
AfterStatus int `json:"after_status" description:"调整后套餐状态"`
Reason string `json:"reason" description:"冲正原因"`
RefObservationID string `json:"ref_observation_id" description:"关联的采集 ID"`
RefEventID string `json:"ref_event_id" description:"关联的流量事件 ID"`
OperatorID uint `json:"operator_id" description:"操作人账号ID"`
CreatedAt time.Time `json:"created_at" description:"冲正时间"`
}
// PackageTrafficAdjustmentListResponse 冲正流水分页结果。
type PackageTrafficAdjustmentListResponse struct {
Items []PackageTrafficAdjustmentItem `json:"items" description:"冲正流水"`
Total int64 `json:"total" description:"总数"`
}
// CardTrafficCollectionListRequest 卡流量采集记录查询请求。
type CardTrafficCollectionListRequest struct {
Identifier string `path:"identifier" description:"卡标识符(虚拟号/ICCID/MSISDN)" required:"true"`
StartTime string `query:"start_time" validate:"required" required:"true" description:"开始时间(RFC3339 或 2006-01-02 15:04:05,上海时间)"`
EndTime string `query:"end_time" validate:"required" required:"true" description:"结束时间(不含),最长跨度 31 天"`
Page int `query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认 1"`
PageSize int `query:"page_size" validate:"omitempty,min=1,max=200" minimum:"1" maximum:"200" description:"每页条数,默认 50"`
}
// CardTrafficCollectionItem 卡流量采集记录。
type CardTrafficCollectionItem struct {
ID uint `json:"id" description:"记录ID"`
ObservedAt time.Time `json:"observed_at" description:"采集时间"`
ObservationID string `json:"observation_id" description:"采集 ID"`
Source string `json:"source" description:"来源:polling/manual_sync/business_event"`
Scene string `json:"scene" description:"场景"`
Result string `json:"result" description:"读数结果:ok/failed/rate_limited/invalid_payload/no_reading/invalid_reading"`
VendorUsedMB *float64 `json:"vendor_used_mb" description:"运营商原始读数(MB)"`
QueuePoints json.RawMessage `json:"queue_points" description:"入队后的采集队列"`
Decision string `json:"decision" description:"写入判断:written/baseline/queue_not_full/slope_negative/out_of_order/settled_existing"`
MonthUsedMB *float64 `json:"month_used_mb" description:"写入时换算后的本月累计(MB)"`
StaleGuarded *bool `json:"stale_guarded" description:"写入时是否触发重置日防旧值"`
DeltaMB *float64 `json:"delta_mb" description:"写入时本次增量(MB)"`
EventID string `json:"event_id" description:"流量事件 ID"`
}
// CardTrafficCollectionListResponse 卡流量采集记录分页结果。
type CardTrafficCollectionListResponse struct {
Items []CardTrafficCollectionItem `json:"items" description:"采集记录"`
Total int64 `json:"total" description:"总数"`
}
type UpdateAssetPackageUsageRequest struct {
Identifier string `path:"identifier" description:"资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN)" required:"true"`
PackageUsageID uint `path:"package_usage_id" description:"套餐使用记录ID" required:"true"`

View File

@@ -45,18 +45,19 @@ type IotCard struct {
FirstRechargeTriggeredBySeriesJSON string `gorm:"column:first_recharge_triggered_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的首充触发状态" json:"-"`
// 任务 24.1: 停复机相关字段
FirstRealnameAt *time.Time `gorm:"column:first_realname_at;comment:首次实名时间(用于触发首次实名激活)" json:"first_realname_at,omitempty"`
StoppedAt *time.Time `gorm:"column:stopped_at;comment:停机时间" json:"stopped_at,omitempty"`
ResumedAt *time.Time `gorm:"column:resumed_at;comment:最近复机时间" json:"resumed_at,omitempty"`
StopReason string `gorm:"column:stop_reason;type:varchar(50);comment:停机原因(traffic_exhausted=流量耗尽,manual=手动停机,arrears=欠费)" json:"stop_reason,omitempty"`
AssetStatus int `gorm:"column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用" json:"asset_status"`
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
FirstPurchaseAt *time.Time `gorm:"column:first_purchase_at;comment:首次购买套餐时间(当前世代第一笔套餐订单的支付时间)" json:"first_purchase_at,omitempty"`
IsStandalone bool `gorm:"column:is_standalone;type:boolean;default:true;not null;comment:是否为独立卡(未绑定设备) 由触发器自动维护" json:"is_standalone"`
VirtualNo string `gorm:"column:virtual_no;type:varchar(50);uniqueIndex:idx_iot_card_virtual_no,where:deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> '';comment:虚拟号(可选,非空时全局唯一)" json:"virtual_no,omitempty"`
DeviceVirtualNo string `gorm:"column:device_virtual_no;type:varchar(100);not null;default:'';comment:绑定设备的虚拟号快照(未绑定时为空字符串)" json:"device_virtual_no"`
LastGatewayReadingMB float64 `gorm:"column:last_gateway_reading_mb;type:float;not null;default:0;comment:运营商当前周期累计流量读数(MB,来自网关)" json:"last_gateway_reading_mb"`
GatewayExtend string `gorm:"column:gateway_extend;type:text;not null;default:'';comment:Gateway 卡状态扩展字段,原样保存上游 extend,用于展示运营商侧实际停机原因" json:"gateway_extend"`
FirstRealnameAt *time.Time `gorm:"column:first_realname_at;comment:首次实名时间(用于触发首次实名激活)" json:"first_realname_at,omitempty"`
StoppedAt *time.Time `gorm:"column:stopped_at;comment:停机时间" json:"stopped_at,omitempty"`
ResumedAt *time.Time `gorm:"column:resumed_at;comment:最近复机时间" json:"resumed_at,omitempty"`
StopReason string `gorm:"column:stop_reason;type:varchar(50);comment:停机原因(traffic_exhausted=流量耗尽,manual=手动停机,arrears=欠费)" json:"stop_reason,omitempty"`
AssetStatus int `gorm:"column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用" json:"asset_status"`
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
FirstPurchaseAt *time.Time `gorm:"column:first_purchase_at;comment:首次购买套餐时间(当前世代第一笔套餐订单的支付时间)" json:"first_purchase_at,omitempty"`
IsStandalone bool `gorm:"column:is_standalone;type:boolean;default:true;not null;comment:是否为独立卡(未绑定设备) 由触发器自动维护" json:"is_standalone"`
VirtualNo string `gorm:"column:virtual_no;type:varchar(50);uniqueIndex:idx_iot_card_virtual_no,where:deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> '';comment:虚拟号(可选,非空时全局唯一)" json:"virtual_no,omitempty"`
DeviceVirtualNo string `gorm:"column:device_virtual_no;type:varchar(100);not null;default:'';comment:绑定设备的虚拟号快照(未绑定时为空字符串)" json:"device_virtual_no"`
LastGatewayReadingMB float64 `gorm:"column:last_gateway_reading_mb;type:float;not null;default:0;comment:运营商当前周期累计流量读数(MB,来自网关)" json:"last_gateway_reading_mb"`
TrafficBaselinePending bool `gorm:"column:traffic_baseline_pending;type:boolean;not null;default:false;comment:卡流量待建起点:第一次通过采集队列写入时只建起点不扣费" json:"traffic_baseline_pending"`
GatewayExtend string `gorm:"column:gateway_extend;type:text;not null;default:'';comment:Gateway 卡状态扩展字段,原样保存上游 extend,用于展示运营商侧实际停机原因" json:"gateway_extend"`
// GatewayCardIMEI 为插拔卡业务中网关上报的设备 IMEI,非设备自身 IMEI,仅用于数据同步落库
GatewayCardIMEI string `gorm:"column:gateway_card_imei;type:varchar(50);not null;default:'';comment:插拔卡业务 IMEI,网关卡状态接口同步,非设备 IMEI" json:"gateway_card_imei"`
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)" json:"realname_policy"`

View File

@@ -67,13 +67,40 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walle
Register(assets, doc, groupPath, "PATCH", "/:identifier/packages/:package_usage_id/used-data", handler.UpdatePackageUsage, RouteSpec{
Summary: "修改资产套餐已用量",
Description: "修改指定资产下某条套餐使用记录的真实已用量(MB)。仅允许已认证账号调用,并保留资产与套餐记录归属校验。企业账号无权调用。",
Description: "修改指定资产下某条套餐使用记录的真实已用量(MB):按与当前已用量的差额写一条人工冲正流水(原因记为后台修改资产套餐已用量),提交后走停复机判断。仅允许已认证账号调用,并保留资产与套餐记录归属校验。企业账号无权调用。",
Tags: []string{"资产管理"},
Input: new(dto.UpdateAssetPackageUsageRequest),
Output: new(dto.AssetPackageResponse),
Auth: true,
})
Register(assets, doc, groupPath, "POST", "/:identifier/packages/:package_usage_id/traffic-adjustments", handler.AdjustPackageTraffic, RouteSpec{
Summary: "人工冲正套餐流量",
Description: "按调整量冲正套餐已用量(正数补扣、负数退回),原因必填;锁套餐行按当前值加减,退回后不得小于 0,同事务写冲正流水与审计,提交后走停复机判断,不直接复机。不修改卡日快照。企业账号无权调用。",
Tags: []string{"资产管理"},
Input: new(dto.AdjustAssetPackageTrafficRequest),
Output: new(dto.PackageTrafficAdjustmentItem),
Auth: true,
})
Register(assets, doc, groupPath, "GET", "/:identifier/packages/:package_usage_id/traffic-adjustments", handler.ListPackageTrafficAdjustments, RouteSpec{
Summary: "查询套餐冲正流水",
Description: "分页查询指定套餐使用记录的人工冲正流水,按时间倒序。企业账号无权调用。",
Tags: []string{"资产管理"},
Input: new(dto.PackageTrafficAdjustmentListRequest),
Output: new(dto.PackageTrafficAdjustmentListResponse),
Auth: true,
})
Register(assets, doc, groupPath, "GET", "/:identifier/traffic-collections", handler.ListTrafficCollections, RouteSpec{
Summary: "查询卡流量采集记录",
Description: "按卡与采集时间范围分页查询流量采集记录(仅采集时刻有套餐的卡有记录,保留 90 天),含原始读数、队列与写入判断,按采集时间倒序;单次跨度不超过 31 天。企业账号无权调用。",
Tags: []string{"资产管理"},
Input: new(dto.CardTrafficCollectionListRequest),
Output: new(dto.CardTrafficCollectionListResponse),
Auth: true,
})
Register(assets, doc, groupPath, "POST", "/:identifier/stop", handler.Stop, RouteSpec{
Summary: "停机",
Description: "设备批量停机设备下所有已实名卡;单卡手动停机。",

View File

@@ -30,6 +30,7 @@ import (
// IotCardRefresher 用于调用 RefreshCardDataFromGateway,避免循环依赖
type IotCardRefresher interface {
RefreshCardDataFromGateway(ctx context.Context, iccid string) error
RefreshCardDataWithTraffic(ctx context.Context, iccid string) (*dto.CardTrafficRefreshResult, error)
}
// PackageExpiryResolver 查询资产的套餐最终到期推算结果。
@@ -60,7 +61,9 @@ type Service struct {
packageExpiryQuery PackageExpiryResolver
pollingRestorer PollingRestorer
resumeEvaluator AssetResumeEvaluator
logger *zap.Logger
// trafficAdjustmentEvaluator 在冲正提交后评估停复机;未注入时冲正后不评估。
trafficAdjustmentEvaluator TrafficAdjustmentEvaluator
logger *zap.Logger
}
// SetPackageExpiryQuery 注入套餐最终到期查询,供资产详情统一投影使用。
@@ -681,10 +684,16 @@ func (s *Service) Refresh(ctx context.Context, assetType string, id uint) (*dto.
if err != nil {
return nil, errors.Wrap(errors.CodeNotFound, err, "卡不存在")
}
if err := s.iotCardService.RefreshCardDataFromGateway(ctx, card.ICCID); err != nil {
trafficResult, err := s.iotCardService.RefreshCardDataWithTraffic(ctx, card.ICCID)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "刷新卡数据失败")
}
return s.GetRealtimeStatus(ctx, "card", id)
status, err := s.GetRealtimeStatus(ctx, "card", id)
if err != nil {
return nil, err
}
status.TrafficRefresh = trafficResult
return status, nil
case "device":
totalStart := time.Now()
@@ -1017,44 +1026,23 @@ func (s *Service) UpdatePackageExpiresAt(ctx context.Context, assetType string,
return s.buildAssetPackageResponse(ctx, updated, constants.OwnerTypePlatform), nil
}
// UpdatePackageUsage 修改资产套餐已用量。
// UpdatePackageUsage 修改资产套餐已用量:改为经冲正流水进行,调整量 = 新已用量 − 锁定后的当前已用量。
func (s *Service) UpdatePackageUsage(ctx context.Context, assetType string, assetID uint, assetIdentifier string, packageUsageID uint, dataUsageMB int64) (*dto.AssetPackageResponse, error) {
if dataUsageMB < 0 {
return nil, errors.New(errors.CodeInvalidParam)
}
carrierType, err := normalizeAssetCarrierType(assetType)
if err != nil {
return nil, err
}
before, err := s.packageUsageStore.GetByIDForCarrier(ctx, packageUsageID, carrierType, assetID)
if _, err := s.adjustPackageTraffic(ctx, assetType, assetID, assetIdentifier,
trafficAdjustment{PackageUsageID: packageUsageID, Reason: legacyUsageEditReason}, &dataUsageMB); err != nil {
return nil, err
}
updated, err := s.packageUsageStore.GetByIDForCarrier(ctx, packageUsageID, carrierType, assetID)
if err != nil {
return nil, wrapPackageUsageNotFound(err)
}
beforeData := packageUsageTrafficAuditData(before)
nextStatus := statusForManualDataUsage(before, dataUsageMB)
var updated *model.PackageUsage
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := postgres.NewPackageUsageStore(tx, nil)
rows, updateErr := store.UpdateDataUsageForCarrier(ctx, packageUsageID, carrierType, assetID, dataUsageMB, nextStatus)
if updateErr != nil {
return errors.Wrap(errors.CodeDatabaseError, updateErr, "修改套餐已用量失败")
}
if rows == 0 {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
updated, updateErr = store.GetByIDForCarrier(ctx, packageUsageID, carrierType, assetID)
if updateErr != nil {
return errors.Wrap(errors.CodeDatabaseError, updateErr, "查询套餐使用记录失败")
}
return s.appendPackageAdjustmentAudit(ctx, tx, constants.AuditActionPackageUsageTrafficAdjusted, "修改资产套餐已用量", constants.AuditResultSuccess, updated, assetType, assetID, assetIdentifier, beforeData, packageUsageTrafficAuditData(updated), nil)
})
if err != nil {
s.recordPackageAdjustmentFailure(ctx, constants.AuditActionPackageUsageTrafficAdjusted, "修改资产套餐已用量失败", before, assetType, assetID, assetIdentifier, beforeData, map[string]any{"package_usage_id": packageUsageID, "data_usage_mb": dataUsageMB}, err)
return nil, err
}
return s.buildAssetPackageResponse(ctx, updated, constants.OwnerTypePlatform), nil
}

View File

@@ -0,0 +1,201 @@
package asset
import (
"context"
"strings"
"go.uber.org/zap"
"gorm.io/gorm"
stopresumedomain "github.com/break/junhong_cmp_fiber/internal/domain/stopresume"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"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/break/junhong_cmp_fiber/pkg/middleware"
)
// legacyUsageEditReason 是旧"修改资产套餐已用量"接口改走冲正流水时记录的原因。
const legacyUsageEditReason = "后台修改资产套餐已用量"
// TrafficAdjustmentEvaluator 在冲正提交后按最新套餐、流量、实名事实评估卡的停复机。
type TrafficAdjustmentEvaluator interface {
EvaluateAndAct(ctx context.Context, card *model.IotCard) error
}
// SetTrafficAdjustmentEvaluator 注入冲正提交后的停复机判断。
func (s *Service) SetTrafficAdjustmentEvaluator(evaluator TrafficAdjustmentEvaluator) {
s.trafficAdjustmentEvaluator = evaluator
}
// trafficAdjustment 是一次冲正的输入。
type trafficAdjustment struct {
PackageUsageID uint
DeltaMB int64
Reason string
RefObservationID string
RefEventID string
}
// AdjustPackageTraffic 人工冲正套餐流量:锁套餐行、按锁定后的当前已用量加减、退回后不得小于 0,
// 同事务写冲正流水、更新已用量并按阈值重算状态(已过期、已失效只改已用量)、写审计;提交后走停复机判断。
// 冲正不修改卡日快照与已结算。
func (s *Service) AdjustPackageTraffic(ctx context.Context, assetType string, assetID uint, assetIdentifier string, req *dto.AdjustAssetPackageTrafficRequest) (*dto.PackageTrafficAdjustmentItem, error) {
if req == nil || req.DeltaMB == 0 || strings.TrimSpace(req.Reason) == "" {
return nil, errors.New(errors.CodeInvalidParam, "冲正调整量不能为 0,原因必填")
}
return s.adjustPackageTraffic(ctx, assetType, assetID, assetIdentifier, trafficAdjustment{
PackageUsageID: req.PackageUsageID, DeltaMB: req.DeltaMB, Reason: strings.TrimSpace(req.Reason),
RefObservationID: strings.TrimSpace(req.RefObservationID), RefEventID: strings.TrimSpace(req.RefEventID),
}, nil)
}
// adjustPackageTraffic 执行冲正;targetUsageMB 非空时按"改成该已用量"计算调整量(旧接口兼容)。
func (s *Service) adjustPackageTraffic(ctx context.Context, assetType string, assetID uint, assetIdentifier string, input trafficAdjustment, targetUsageMB *int64) (*dto.PackageTrafficAdjustmentItem, error) {
operatorID := middleware.GetUserIDFromContext(ctx)
if operatorID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
carrierType, err := normalizeAssetCarrierType(assetType)
if err != nil {
return nil, err
}
var record model.PackageUsageAdjustment
var before, updated *model.PackageUsage
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
store := postgres.NewPackageUsageStore(tx, nil)
locked, lockErr := store.LockByIDForCarrier(ctx, input.PackageUsageID, carrierType, assetID)
if lockErr != nil {
return wrapPackageUsageNotFound(lockErr)
}
before = locked
delta := input.DeltaMB
if targetUsageMB != nil {
delta = *targetUsageMB - locked.DataUsageMB
}
if delta == 0 {
if targetUsageMB != nil {
// 旧接口改成与当前相同的已用量:不产生冲正流水。
return nil
}
return errors.New(errors.CodeInvalidParam, "冲正调整量不能为 0")
}
after := locked.DataUsageMB + delta
if after < 0 {
return errors.New(errors.CodeInvalidParam, "退回后套餐已用量不能小于 0")
}
nextStatus := statusForManualDataUsage(locked, after)
if _, updateErr := store.UpdateDataUsageForCarrier(ctx, locked.ID, carrierType, assetID, after, nextStatus); updateErr != nil {
return errors.Wrap(errors.CodeDatabaseError, updateErr, "冲正套餐已用量失败")
}
afterStatus := locked.Status
if nextStatus != nil {
afterStatus = *nextStatus
}
record = model.PackageUsageAdjustment{
PackageUsageID: locked.ID, IotCardID: locked.IotCardID, DeviceID: locked.DeviceID,
DeltaMB: delta, BeforeUsageMB: locked.DataUsageMB, AfterUsageMB: after,
BeforeStatus: locked.Status, AfterStatus: afterStatus, Reason: input.Reason,
RefObservationID: input.RefObservationID, RefEventID: input.RefEventID, OperatorID: operatorID,
}
if createErr := tx.WithContext(ctx).Create(&record).Error; createErr != nil {
return errors.Wrap(errors.CodeDatabaseError, createErr, "写入冲正流水失败")
}
var loadErr error
if updated, loadErr = store.GetByIDForCarrier(ctx, locked.ID, carrierType, assetID); loadErr != nil {
return errors.Wrap(errors.CodeDatabaseError, loadErr, "查询套餐使用记录失败")
}
afterData := packageUsageTrafficAuditData(updated)
afterData["adjustment_id"], afterData["delta_mb"], afterData["reason"] = record.ID, delta, input.Reason
return s.appendPackageAdjustmentAudit(ctx, tx, constants.AuditActionPackageUsageTrafficAdjusted, "人工冲正套餐流量", constants.AuditResultSuccess,
updated, assetType, assetID, assetIdentifier, packageUsageTrafficAuditData(locked), afterData, nil)
})
if err != nil {
if before != nil {
s.recordPackageAdjustmentFailure(ctx, constants.AuditActionPackageUsageTrafficAdjusted, "人工冲正套餐流量失败", before, assetType, assetID, assetIdentifier,
packageUsageTrafficAuditData(before), map[string]any{"package_usage_id": input.PackageUsageID, "delta_mb": input.DeltaMB, "reason": input.Reason}, err)
}
return nil, err
}
if record.ID == 0 {
return nil, nil
}
s.evaluateAfterAdjustment(ctx, carrierType, assetID)
item := toTrafficAdjustmentItem(record)
return &item, nil
}
// evaluateAfterAdjustment 冲正提交后对载体下的卡调用停复机判断,不直接复机或停机。
func (s *Service) evaluateAfterAdjustment(ctx context.Context, carrierType string, carrierID uint) {
if s.trafficAdjustmentEvaluator == nil {
return
}
evalCtx := stopresumedomain.WithScene(context.WithoutCancel(ctx), constants.StopResumeSceneUsage)
var cards []*model.IotCard
query := s.db.WithContext(evalCtx).Model(&model.IotCard{})
if carrierType == constants.AssetTypeDevice {
query = query.Where("id IN (?)", s.db.Model(&model.DeviceSimBinding{}).Select("iot_card_id").
Where("device_id = ? AND bind_status = ?", carrierID, constants.BindStatusBound))
} else {
query = query.Where("id = ?", carrierID)
}
if err := query.Find(&cards).Error; err != nil {
s.adjustmentLogger().Warn("冲正后查询载体下的卡失败,未做停复机判断", zap.String("carrier_type", carrierType), zap.Uint("carrier_id", carrierID), zap.Error(err))
return
}
for _, card := range cards {
if err := s.trafficAdjustmentEvaluator.EvaluateAndAct(evalCtx, card); err != nil {
s.adjustmentLogger().Warn("冲正后停复机判断失败", zap.Uint("card_id", card.ID), zap.Error(err))
}
}
}
func (s *Service) adjustmentLogger() *zap.Logger {
if s.logger == nil {
return zap.NewNop()
}
return s.logger
}
// ListPackageTrafficAdjustments 分页查询套餐的冲正流水,按时间倒序。
func (s *Service) ListPackageTrafficAdjustments(ctx context.Context, assetType string, assetID uint, req *dto.PackageTrafficAdjustmentListRequest) (*dto.PackageTrafficAdjustmentListResponse, error) {
carrierType, err := normalizeAssetCarrierType(assetType)
if err != nil {
return nil, err
}
if _, err := s.packageUsageStore.GetByIDForCarrier(ctx, req.PackageUsageID, carrierType, assetID); err != nil {
return nil, wrapPackageUsageNotFound(err)
}
page, size := req.Page, req.PageSize
if page <= 0 {
page = 1
}
if size <= 0 {
size = 20
}
query := s.db.WithContext(ctx).Model(&model.PackageUsageAdjustment{}).Where("package_usage_id = ?", req.PackageUsageID)
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询冲正流水失败")
}
var rows []model.PackageUsageAdjustment
if err := query.Order("created_at DESC, id DESC").Offset((page - 1) * size).Limit(size).Find(&rows).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询冲正流水失败")
}
items := make([]dto.PackageTrafficAdjustmentItem, 0, len(rows))
for _, row := range rows {
items = append(items, toTrafficAdjustmentItem(row))
}
return &dto.PackageTrafficAdjustmentListResponse{Items: items, Total: total}, nil
}
func toTrafficAdjustmentItem(row model.PackageUsageAdjustment) dto.PackageTrafficAdjustmentItem {
return dto.PackageTrafficAdjustmentItem{
ID: row.ID, PackageUsageID: row.PackageUsageID, DeltaMB: row.DeltaMB,
BeforeUsageMB: row.BeforeUsageMB, AfterUsageMB: row.AfterUsageMB,
BeforeStatus: row.BeforeStatus, AfterStatus: row.AfterStatus, Reason: row.Reason,
RefObservationID: row.RefObservationID, RefEventID: row.RefEventID,
OperatorID: row.OperatorID, CreatedAt: row.CreatedAt,
}
}

View File

@@ -0,0 +1,66 @@
package asset
import (
"context"
"encoding/json"
"time"
"github.com/break/junhong_cmp_fiber/internal/domain/cardflow"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// maxCollectionQuerySpan 是采集记录单次查询的最长时间跨度。
const maxCollectionQuerySpan = 31 * 24 * time.Hour
// collectionTimeLayouts 是采集记录查询时间参数接受的格式(无时区时按上海时间解析)。
var collectionTimeLayouts = []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"}
// ListCardTrafficCollections 按卡与采集时间范围分页查询卡流量采集记录,按采集时间倒序。
func (s *Service) ListCardTrafficCollections(ctx context.Context, cardID uint, req *dto.CardTrafficCollectionListRequest) (*dto.CardTrafficCollectionListResponse, error) {
from, err := parseCollectionTime(req.StartTime)
if err != nil {
return nil, err
}
to, err := parseCollectionTime(req.EndTime)
if err != nil {
return nil, err
}
if !to.After(from) || to.Sub(from) > maxCollectionQuerySpan {
return nil, errors.New(errors.CodeInvalidParam, "结束时间必须晚于开始时间,且跨度不超过 31 天")
}
page, size := req.Page, req.PageSize
if page <= 0 {
page = 1
}
if size <= 0 {
size = 50
}
rows, total, err := postgres.NewCardTrafficCollectionStore(s.db).List(ctx, postgres.CardTrafficCollectionQuery{
CardID: cardID, From: from, To: to, Page: page, PageSize: size,
})
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询卡流量采集记录失败")
}
items := make([]dto.CardTrafficCollectionItem, 0, len(rows))
for _, row := range rows {
items = append(items, dto.CardTrafficCollectionItem{
ID: row.ID, ObservedAt: row.ObservedAt, ObservationID: row.ObservationID, Source: row.Source,
Scene: row.Scene, Result: row.Result, VendorUsedMB: row.VendorUsedMB, QueuePoints: json.RawMessage(row.QueuePoints),
Decision: row.Decision, MonthUsedMB: row.MonthUsedMB, StaleGuarded: row.StaleGuarded,
DeltaMB: row.DeltaMB, EventID: row.EventID,
})
}
return &dto.CardTrafficCollectionListResponse{Items: items, Total: total}, nil
}
func parseCollectionTime(value string) (time.Time, error) {
location := cardflow.ShanghaiDate(time.Now()).Location()
for _, layout := range collectionTimeLayouts {
if t, err := time.ParseInLocation(layout, value, location); err == nil {
return t, nil
}
}
return time.Time{}, errors.New(errors.CodeInvalidParam, "时间格式无效")
}

View File

@@ -32,6 +32,9 @@ func (s *Service) executeMigrationWithTx(ctx context.Context, tx *gorm.DB, order
if err = s.copyResourceTagsWithTx(ctx, tx, oldAsset, newAsset); err != nil {
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "复制资产标签失败")
}
if err = markTrafficBaselinePendingWithTx(ctx, tx, newAsset); err != nil {
return nil, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "标记新资产流量待建起点失败")
}
return &exchangeMigrationResult{Balance: migrationBalance, PackageUsageIDs: usageIDs}, nil
}
@@ -136,6 +139,19 @@ func (s *Service) migratePackageUsageWithTx(ctx context.Context, tx *gorm.DB, ol
return usageIDs, nil
}
// markTrafficBaselinePendingWithTx 给换货新卡(设备换货时为新设备当前绑定的每张卡)打"待建起点"标记:
// 新卡第一次通过采集队列写入时只建起点不扣费,出库前已有的读数不算给迁过来的套餐。不调用运营商接口。
func markTrafficBaselinePendingWithTx(ctx context.Context, tx *gorm.DB, newAsset *resolvedExchangeAsset) error {
query := tx.WithContext(ctx).Model(&model.IotCard{})
if newAsset.AssetType == constants.ExchangeAssetTypeIotCard {
query = query.Where("id = ?", newAsset.AssetID)
} else {
query = query.Where("id IN (?)", tx.Model(&model.DeviceSimBinding{}).Select("iot_card_id").
Where("device_id = ? AND bind_status = ?", newAsset.AssetID, constants.BindStatusBound))
}
return query.Update("traffic_baseline_pending", true).Error
}
func (s *Service) copyAccumulatedFieldsWithTx(tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error {
if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard {
if oldAsset.Card == nil {

View File

@@ -1408,6 +1408,18 @@ func (s *Service) RefreshCardDataByID(ctx context.Context, cardID uint) error {
// 调用网关查询网络状态、实名状态、本月流量,并写回数据库
// 流量采用增量计算(与轮询逻辑一致):increment = 当前网关读数 - 上次网关读数
func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string) error {
_, err := s.RefreshCardDataWithTraffic(ctx, iccid)
return err
}
// RefreshCardDataWithTraffic 同 RefreshCardDataFromGateway,并返回本次流量读数、是否写入与原因。
func (s *Service) RefreshCardDataWithTraffic(ctx context.Context, iccid string) (*dto.CardTrafficRefreshResult, error) {
var trafficResult *dto.CardTrafficRefreshResult
err := s.refreshCardDataFromGateway(ctx, iccid, &trafficResult)
return trafficResult, err
}
func (s *Service) refreshCardDataFromGateway(ctx context.Context, iccid string, trafficResult **dto.CardTrafficRefreshResult) error {
card, err := s.iotCardStore.GetByICCID(ctx, iccid)
if err != nil {
if err == gorm.ErrRecordNotFound {
@@ -1644,6 +1656,7 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string)
resetDay := postgres.NewCarrierStore(s.db).GetDataResetDay(ctx, card.CarrierID)
decision, applyErr := s.cardObservation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{
CardID: card.ID, GatewayReadingMB: float64(flowResp.Used), ResetDay: resetDay,
ReadingResult: refreshReadingResult(flowResp),
Metadata: carddomain.ObservationMetadata{
ObservationID: uuid.NewString(), Source: constants.CardObservationSourceManualSync,
Scene: constants.CardObservationSceneManualRefresh, ObservedAt: syncTime,
@@ -1663,6 +1676,10 @@ func (s *Service) RefreshCardDataFromGateway(ctx context.Context, iccid string)
}
refreshSuccess++
flowIncrementMB = decision.IncrementMB
*trafficResult = &dto.CardTrafficRefreshResult{
ReadingMB: decision.ReadingMB, Result: decision.Result, Written: decision.ReadingAccepted,
Decision: decision.Decision, DeltaMB: decision.IncrementMB,
}
}
}
}
@@ -2096,3 +2113,11 @@ func applyFirstPurchaseFilters(filters map[string]any, start, end string) error
}
return nil
}
// refreshReadingResult 把运营商流量响应映射为读数结果:没给出可解析读数时为无读数。
func refreshReadingResult(response *gateway.FlowUsageResp) string {
if !response.HasReading() {
return constants.TrafficCollectionResultNoReading
}
return constants.TrafficCollectionResultOK
}

View File

@@ -7,6 +7,7 @@ import (
"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"
@@ -15,7 +16,6 @@ import (
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
const (
@@ -72,16 +72,19 @@ 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 {
// 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, carrierType, carrierID, usageMB)
deductUsageMB := s.convertToDeductMB(ctx, constants.AssetTypeIotCard, cardID, usageMB)
if deductUsageMB <= 0 {
return nil
}
@@ -92,78 +95,60 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
var auditChanges []packageUsageAuditChange
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
targetCarrierType, targetCarrierID, packages, err := s.resolveActivePackages(ctx, tx, carrierType, carrierID)
effective, err := postgres.FindEffectivePackagesAt(ctx, tx, cardID, observedAt, true)
if err != nil {
return err
return errors.Wrap(errors.CodeDatabaseError, err, "查询采集时刻有效套餐失败")
}
today := time.Now().Format("2006-01-02")
packages := effective.Packages
if len(packages) == 0 {
// 无生效套餐:尝试找 Depleted 套餐记录溢出流量详单,避免详单断档
if recordErr := s.recordOverflowToDepletedPackage(ctx, tx, targetCarrierType, targetCarrierID, deductUsageMB, today); recordErr != nil {
s.logger.Warn("记录溢出流量到已耗尽套餐失败",
zap.String("carrier_type", targetCarrierType),
zap.Uint("carrier_id", targetCarrierID),
zap.Int64("usage_mb", deductUsageMB),
zap.Error(recordErr))
}
return errors.New(errors.CodeNoAvailablePackage, "没有可用套餐")
// 采集时刻有套餐、扣费前被退款或作废:记告警并按成功处理,不重试;需要时人工冲正。
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
if remainingQuota <= 0 && !isLastPackage {
// 套餐已用完,标记为已用完
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
}
// 本次从该套餐扣减的流量
var deductFromPkg int64
if isLastPackage {
switch {
case isLastPackage:
deductFromPkg = remainingUsage
} else if remainingUsage <= remainingQuota {
case remainingQuota <= 0:
deductFromPkg = 0
case remainingUsage <= remainingQuota:
deductFromPkg = remainingUsage
} else {
default:
deductFromPkg = remainingQuota
}
if deductFromPkg <= 0 {
beforeData := packageUsageStateData(pkg)
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusDepleted).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新套餐状态失败")
// 余量已用完且不是最后一个套餐:跳过;只有当前生效中的套餐顺带改为已用完。
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)})
}
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 effectiveThresholdMB > 0 && newUsage >= effectiveThresholdMB {
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, "更新套餐使用量失败")
@@ -172,42 +157,42 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
if status, ok := updates["status"].(int); ok {
pkg.Status = status
}
// 任务 10.6: 写入日记录
if err := s.updateDailyRecord(ctx, tx, pkg.ID, today, deductFromPkg, newUsage); err != nil {
return err
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))
}
// 任务 10.5: 检查是否所有套餐都用完(触发停机)
shouldSuspendCurrent, suspensionChanges, err := s.checkAndTriggerSuspension(ctx, tx, targetCarrierType, targetCarrierID)
// 检查是否所有套餐都用完(触发停机),按套餐当前状态判断
shouldSuspendCurrent, suspensionChanges, err := s.checkAndTriggerSuspension(ctx, tx, effective.CarrierType, effective.CarrierID)
if err != nil {
return err
}
auditChanges = append(auditChanges, suspensionChanges...)
shouldSuspend = shouldSuspendCurrent
suspendCarrierType = targetCarrierType
suspendCarrierID = targetCarrierID
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": targetCarrierType, "carrier_id": targetCarrierID, "usage_mb": deductUsageMB,
"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 {
@@ -224,6 +209,22 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
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 {
@@ -290,107 +291,6 @@ func (s *UsageService) persistUsageRemainder(ctx context.Context, key string, re
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, []packageUsageAuditChange, error) {
query := tx.Model(&model.PackageUsage{}).
@@ -490,45 +390,3 @@ func (s *UsageService) triggerSuspensionAfterCommit(ctx context.Context, carrier
})
}
}
// recordOverflowToDepletedPackage 在所有套餐已耗尽时,将溢出流量写入最近的 Depleted 套餐日记录。
// 目的:保持流量详单连续性,即便套餐用完期间上游仍有流量使用,详单不会断档。
// 只写日记录和累加 data_usage_mb,不改变套餐 status,不触发停机。
func (s *UsageService) recordOverflowToDepletedPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint, overflowMB int64, today string) error {
query := tx.Where("status = ?", constants.PackageUsageStatusDepleted)
switch carrierType {
case constants.AssetTypeIotCard:
query = query.Where("iot_card_id = ?", carrierID)
case constants.AssetTypeDevice:
query = query.Where("device_id = ?", carrierID)
default:
return nil
}
// 优先选主套餐(master_usage_id IS NULL),主套餐不存在时再退而求其次取加油包
var pkg model.PackageUsage
err := query.Where("master_usage_id IS NULL").Order("id DESC").First(&pkg).Error
if err == gorm.ErrRecordNotFound {
err = query.Order("id DESC").First(&pkg).Error
}
if err != nil {
// 找不到任何 Depleted 套餐,静默忽略
return nil
}
newUsage := pkg.DataUsageMB + overflowMB
if err := tx.Model(&pkg).Update("data_usage_mb", newUsage).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新溢出流量失败")
}
if err := s.updateDailyRecord(ctx, tx, pkg.ID, today, overflowMB, newUsage); err != nil {
return err
}
s.logger.Info("记录溢出流量到已耗尽套餐",
zap.Uint("package_usage_id", pkg.ID),
zap.Int64("overflow_mb", overflowMB),
zap.Int64("new_usage_mb", newUsage))
return nil
}

View File

@@ -28,6 +28,8 @@ type ConfigService struct {
logger *zap.Logger
configMgr *pollingcore.PollingConfigManager
queueClient TaskEnqueuer
// trafficQueueLimits 返回当前采集队列配置,用于校验启用中配置的流量采集间隔上限。
trafficQueueLimits func(ctx context.Context) TrafficQueueLimits
}
// SetAudit 注入轮询配置事务与统一审计 Writer。
@@ -92,6 +94,9 @@ func (s *ConfigService) Create(ctx context.Context, req *dto.CreatePollingConfig
if err := validatePollingConfig(config); err != nil {
return nil, err
}
if err := s.validateTrafficInterval(ctx, config); err != nil {
return nil, err
}
err := runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
if err := s.configStore.WithTx(tx).Create(ctx, config); err != nil {
@@ -211,6 +216,9 @@ func (s *ConfigService) Update(ctx context.Context, id uint, req *dto.UpdatePoll
if err := validatePollingConfig(config); err != nil {
return nil, err
}
if err := s.validateTrafficInterval(ctx, config); err != nil {
return nil, err
}
config.UpdatedBy = &currentUserID
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
@@ -343,6 +351,13 @@ func (s *ConfigService) UpdateStatus(ctx context.Context, id uint, status int16)
}
before := pollingConfigState(config)
if status == 1 {
enabled := *config
enabled.Status = status
if err := s.validateTrafficInterval(ctx, &enabled); err != nil {
return err
}
}
err = runPollingTransaction(ctx, s.db, s.auditWriter, func(tx *gorm.DB) error {
if err := s.configStore.WithTx(tx).UpdateStatus(ctx, id, status, currentUserID); err != nil {
return err

View File

@@ -0,0 +1,64 @@
package polling
import (
"context"
"fmt"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/domain/cardflow"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// TrafficQueueLimits 是校验流量采集间隔需要的采集队列配置。
type TrafficQueueLimits struct {
Size int
ClearOnNewDay bool
MaxPointAgeHours int
}
// SetTrafficQueueLimits 注入当前采集队列配置;未注入时不校验流量采集间隔。
func (s *ConfigService) SetTrafficQueueLimits(limits func(ctx context.Context) TrafficQueueLimits) {
s.trafficQueueLimits = limits
}
// validateTrafficInterval 只对启用中的轮询配置校验流量采集间隔:不超过 24h ÷ (N+2)(跨天清空开启)
// 或最长点龄 ÷ (N+2)(关闭);停用的配置不校验。
func (s *ConfigService) validateTrafficInterval(ctx context.Context, config *model.PollingConfig) error {
if s.trafficQueueLimits == nil || config.Status != 1 {
return nil
}
return checkTrafficInterval(config, s.trafficQueueLimits(ctx))
}
func checkTrafficInterval(config *model.PollingConfig, limits TrafficQueueLimits) error {
limit := cardflow.MaxCollectionInterval(limits.Size, limits.ClearOnNewDay, limits.MaxPointAgeHours, constants.TrafficQueueIntervalSpare)
if limit <= 0 || config.CarddataCheckInterval == nil || *config.CarddataCheckInterval <= 0 {
return nil
}
interval := time.Duration(*config.CarddataCheckInterval) * time.Second
if interval <= limit {
return nil
}
return errors.New(errors.CodeInvalidParam, fmt.Sprintf(
"轮询配置「%s」流量检查间隔 %d 秒超过上限 %d 秒(采集队列长度 %d,留 %d 次冗余),请调小间隔",
config.ConfigName, *config.CarddataCheckInterval, int64(limit/time.Second), limits.Size, constants.TrafficQueueIntervalSpare))
}
// CheckEnabledTrafficIntervals 校验全部启用中的轮询配置在给定队列配置下是否都满足流量采集间隔上限,
// 用于修改队列长度、跨天清空、最长点龄前的拦截。
func CheckEnabledTrafficIntervals(ctx context.Context, db *gorm.DB, limits TrafficQueueLimits) error {
var configs []*model.PollingConfig
if err := db.WithContext(ctx).Where("status = ? AND carddata_check_interval > 0", 1).Find(&configs).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询启用中的轮询配置失败")
}
for _, config := range configs {
if err := checkTrafficInterval(config, limits); err != nil {
return err
}
}
return nil
}

View File

@@ -88,6 +88,19 @@ func (s *CardFlowDayStore) AddSettled(ctx context.Context, cardID uint, flowDate
).Error
}
// AddSettledToLatest 把增量计入本月截至 flowDate 最近一天快照的已结算;本月没有快照时不做任何事。
func (s *CardFlowDayStore) AddSettledToLatest(ctx context.Context, cardID uint, flowDate time.Time, deltaMB float64) error {
if deltaMB <= 0 {
return nil
}
return s.db.WithContext(ctx).Exec(`
UPDATE tb_card_flow_day SET settled_mb = settled_mb + ?, updated_at = NOW()
WHERE id = (SELECT id FROM tb_card_flow_day WHERE iot_card_id = ? AND flow_date >= ?::date AND flow_date <= ?::date
ORDER BY flow_date DESC LIMIT 1)`,
deltaMB, cardID, cardflow.MonthStart(flowDate).Format(cardFlowDateLayout), flowDate.Format(cardFlowDateLayout),
).Error
}
// MonthContext 查询换算与防旧值需要的已有快照取值:本月重置日前最大、上月最大与上月重置日前最大。
func (s *CardFlowDayStore) MonthContext(ctx context.Context, cardID uint, flowDate time.Time, resetDay int) (cardflow.MonthContext, error) {
monthStart := cardflow.MonthStart(flowDate)

View File

@@ -1,43 +0,0 @@
package postgres
import (
"context"
stderrors "errors"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// CardFlowSwitchStore 卡流量计量切换水位存储。
type CardFlowSwitchStore struct {
db *gorm.DB
}
// NewCardFlowSwitchStore 创建切换水位存储。
func NewCardFlowSwitchStore(db *gorm.DB) *CardFlowSwitchStore {
return &CardFlowSwitchStore{db: db}
}
// WithTx 返回复用当前事务的切换水位存储。
func (s *CardFlowSwitchStore) WithTx(tx *gorm.DB) *CardFlowSwitchStore {
return &CardFlowSwitchStore{db: tx}
}
// Get 返回卡的切换水位;不存在时返回 nil、nil。
func (s *CardFlowSwitchStore) Get(ctx context.Context, cardID uint) (*model.CardFlowSwitch, error) {
var row model.CardFlowSwitch
err := s.db.WithContext(ctx).Where("iot_card_id = ?", cardID).First(&row).Error
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
if err != nil {
return nil, err
}
return &row, nil
}
// Create 写入切换水位;同卡已存在时由唯一索引拒绝,调用方随事务回滚后重试。
func (s *CardFlowSwitchStore) Create(ctx context.Context, row *model.CardFlowSwitch) error {
return s.db.WithContext(ctx).Create(row).Error
}

View File

@@ -0,0 +1,97 @@
package postgres
import (
"context"
"fmt"
"strings"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/domain/cardflow"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// collectionPartitionPrefix 是采集记录月分区表名前缀,后接 YYYYMM。
const collectionPartitionPrefix = "tb_card_traffic_collection_"
// CardTrafficCollectionStore 卡流量采集记录存储。
type CardTrafficCollectionStore struct {
db *gorm.DB
}
// NewCardTrafficCollectionStore 创建采集记录存储。
func NewCardTrafficCollectionStore(db *gorm.DB) *CardTrafficCollectionStore {
return &CardTrafficCollectionStore{db: db}
}
// Create 写入一条采集记录。
func (s *CardTrafficCollectionStore) Create(ctx context.Context, record *model.CardTrafficCollection) error {
return s.db.WithContext(ctx).Create(record).Error
}
// CardTrafficCollectionQuery 是采集记录查询条件。
type CardTrafficCollectionQuery struct {
CardID uint
From, To time.Time
Page int
PageSize int
}
// List 按卡与采集时间范围分页查询采集记录,按采集时间倒序。
func (s *CardTrafficCollectionStore) List(ctx context.Context, query CardTrafficCollectionQuery) ([]model.CardTrafficCollection, int64, error) {
db := s.db.WithContext(ctx).Model(&model.CardTrafficCollection{}).
Where("iot_card_id = ? AND observed_at >= ? AND observed_at < ?", query.CardID, query.From, query.To)
var total int64
if err := db.Count(&total).Error; err != nil {
return nil, 0, err
}
var rows []model.CardTrafficCollection
err := db.Order("observed_at DESC, id DESC").Offset((query.Page - 1) * query.PageSize).Limit(query.PageSize).Find(&rows).Error
return rows, total, err
}
// FindByObservationID 按采集 ID 查询采集记录。
func (s *CardTrafficCollectionStore) FindByObservationID(ctx context.Context, observationID string) ([]model.CardTrafficCollection, error) {
var rows []model.CardTrafficCollection
err := s.db.WithContext(ctx).Where("observation_id = ?", observationID).Order("observed_at DESC").Find(&rows).Error
return rows, err
}
// MaintainPartitions 预建本月起 aheadMonths 个月的分区,并删除整月都早于保留期的分区。
func (s *CardTrafficCollectionStore) MaintainPartitions(ctx context.Context, now time.Time, aheadMonths int, retentionDays int) error {
monthStart := cardflow.MonthStart(now)
for i := 0; i <= aheadMonths; i++ {
from := monthStart.AddDate(0, i, 0)
to := from.AddDate(0, 1, 0)
sql := fmt.Sprintf(`CREATE TABLE IF NOT EXISTS %s%s PARTITION OF tb_card_traffic_collection FOR VALUES FROM ('%s') TO ('%s')`,
collectionPartitionPrefix, from.Format("200601"), from.Format(time.RFC3339), to.Format(time.RFC3339))
if err := s.db.WithContext(ctx).Exec(sql).Error; err != nil {
return err
}
}
cutoff := cardflow.ShanghaiDate(now).AddDate(0, 0, -retentionDays)
var partitions []string
if err := s.db.WithContext(ctx).Raw(`
SELECT c.relname FROM pg_inherits i
JOIN pg_class c ON c.oid = i.inhrelid
JOIN pg_class p ON p.oid = i.inhparent
WHERE p.relname = 'tb_card_traffic_collection'`).Scan(&partitions).Error; err != nil {
return err
}
for _, name := range partitions {
if len(name) != len(collectionPartitionPrefix)+6 || !strings.HasPrefix(name, collectionPartitionPrefix) {
continue
}
start, err := time.ParseInLocation("200601", name[len(collectionPartitionPrefix):], cutoff.Location())
if err != nil {
continue
}
if !start.AddDate(0, 1, 0).After(cutoff) {
if err := s.db.WithContext(ctx).Exec(fmt.Sprintf("DROP TABLE IF EXISTS %s", name)).Error; err != nil {
return err
}
}
}
return nil
}

View File

@@ -0,0 +1,94 @@
package postgres
import (
"context"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// effectivePackageStatuses 是"采集时刻有效套餐"认可的套餐状态:状态可能在采集之后才变为已用完或已过期,
// 是否在采集时刻有效由激活时间与到期时间判断。
var effectivePackageStatuses = []int{
constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted, constants.PackageUsageStatusExpired,
}
// EffectivePackages 是一次"采集时刻有效套餐"查询的结果。
type EffectivePackages struct {
// CarrierType 与 CarrierID 是实际扣减载体:卡自身没有有效套餐时回退到卡当前绑定的设备。
CarrierType string
CarrierID uint
// Packages 按扣减优先级排序:加油包优先,再按 priority、id。
Packages []*model.PackageUsage
}
// FindEffectivePackagesAt 按"有套餐"共用口径查询卡在采集时刻的有效套餐:状态为生效中、已用完或已过期,
// 激活时间不晚于采集时间(为空按已生效),到期时间为空或晚于采集时间,未退款;
// 卡自身一个都没有时看卡当前绑定设备的套餐。lock 为 true 时对查到的套餐行加 FOR UPDATE。
// 采集侧(写入范围、是否发事件、采集记录、日用量 0 行)与扣费侧共用本函数,避免两边口径不一致。
func FindEffectivePackagesAt(ctx context.Context, db *gorm.DB, cardID uint, observedAt time.Time, lock bool) (EffectivePackages, error) {
own, err := queryEffectivePackages(ctx, db, "iot_card_id = ?", cardID, observedAt, lock)
if err != nil || len(own) > 0 {
return EffectivePackages{CarrierType: constants.AssetTypeIotCard, CarrierID: cardID, Packages: own}, err
}
var deviceIDs []uint
if err := db.WithContext(ctx).Model(&model.DeviceSimBinding{}).
Where("iot_card_id = ? AND bind_status = ?", cardID, constants.BindStatusBound).
Limit(1).Pluck("device_id", &deviceIDs).Error; err != nil {
return EffectivePackages{}, err
}
if len(deviceIDs) == 0 {
return EffectivePackages{CarrierType: constants.AssetTypeIotCard, CarrierID: cardID}, nil
}
devicePackages, err := queryEffectivePackages(ctx, db, "device_id = ?", deviceIDs[0], observedAt, lock)
if err != nil {
return EffectivePackages{}, err
}
if len(devicePackages) == 0 {
return EffectivePackages{CarrierType: constants.AssetTypeIotCard, CarrierID: cardID}, nil
}
return EffectivePackages{CarrierType: constants.AssetTypeDevice, CarrierID: deviceIDs[0], Packages: devicePackages}, nil
}
func queryEffectivePackages(ctx context.Context, db *gorm.DB, carrierCondition string, carrierID uint, observedAt time.Time, lock bool) ([]*model.PackageUsage, error) {
query := db.WithContext(ctx).Model(&model.PackageUsage{}).
Where(carrierCondition, carrierID).
Where("status IN ?", effectivePackageStatuses).
Where("activated_at IS NULL OR activated_at <= ?", observedAt).
Where("expires_at IS NULL OR expires_at > ?", observedAt).
Where("refund_id IS NULL")
if lock {
query = query.Clauses(clause.Locking{Strength: "UPDATE"})
}
var packages []*model.PackageUsage
err := query.Order("CASE WHEN master_usage_id IS NOT NULL THEN 0 ELSE 1 END, priority ASC, id ASC").Find(&packages).Error
return packages, err
}
// EnsureDailyUsageRows 为每个套餐确保 date 当天一行套餐日用量(没有则写 0,已有不动)。
func EnsureDailyUsageRows(ctx context.Context, db *gorm.DB, packageUsageIDs []uint, date time.Time, cumulative map[uint]int64) error {
for _, id := range packageUsageIDs {
if err := db.WithContext(ctx).Exec(`
INSERT INTO tb_package_usage_daily_record (package_usage_id, date, daily_usage_mb, cumulative_usage_mb, created_at, updated_at)
VALUES (?, ?::date, 0, ?, NOW(), NOW())
ON CONFLICT (package_usage_id, date) DO NOTHING`, id, date.Format(cardFlowDateLayout), cumulative[id]).Error; err != nil {
return err
}
}
return nil
}
// AddDailyUsage 原子累加套餐 date 当天用量并覆盖累计用量;当天没有行时新建。
func AddDailyUsage(ctx context.Context, db *gorm.DB, packageUsageID uint, date time.Time, dailyMB int64, cumulativeMB int64) error {
return db.WithContext(ctx).Exec(`
INSERT INTO tb_package_usage_daily_record (package_usage_id, date, daily_usage_mb, cumulative_usage_mb, created_at, updated_at)
VALUES (?, ?::date, ?, ?, NOW(), NOW())
ON CONFLICT (package_usage_id, date) DO UPDATE SET
daily_usage_mb = tb_package_usage_daily_record.daily_usage_mb + EXCLUDED.daily_usage_mb,
cumulative_usage_mb = EXCLUDED.cumulative_usage_mb,
updated_at = NOW()`, packageUsageID, date.Format(cardFlowDateLayout), dailyMB, cumulativeMB).Error
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type PackageUsageStore struct {
@@ -53,6 +54,17 @@ func (s *PackageUsageStore) GetByIDForCarrier(ctx context.Context, id uint, carr
return &usage, nil
}
// LockByIDForCarrier 在当前事务内按载体锁定套餐使用记录(FOR UPDATE),与流量扣减同一把行锁。
func (s *PackageUsageStore) LockByIDForCarrier(ctx context.Context, id uint, carrierType string, carrierID uint) (*model.PackageUsage, error) {
var usage model.PackageUsage
query := s.db.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", id)
query = applyPackageUsageCarrierFilter(query, carrierType, carrierID)
if err := query.First(&usage).Error; err != nil {
return nil, err
}
return &usage, nil
}
// Update 更新套餐使用记录
func (s *PackageUsageStore) Update(ctx context.Context, usage *model.PackageUsage) error {
return s.db.WithContext(ctx).Save(usage).Error

View File

@@ -92,15 +92,18 @@ func (h *PollingCarddataHandler) Step(ctx context.Context, cardID uint) StepOutc
attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayTraffic, constants.CardObservationSceneTrafficPolling)
result, err := h.gateway.QueryFlow(gateway.WithCarrier(ctx, card.CarrierID), &gateway.FlowQueryReq{CardNo: card.ICCID})
if gateway.IsRateLimited(err) {
h.recordUnreadable(ctx, cardID, attempt.IntegrationID, constants.TrafficCollectionResultRateLimited)
priority.FailRetryable(ctx, "运营商账户网关限流,稍后重试")
return StepOutcome{Result: StepRateLimited}
}
if err != nil {
h.recordUnreadable(ctx, cardID, attempt.IntegrationID, constants.TrafficCollectionResultFailed)
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询流量失败", priority)
priority.FailRetryable(ctx, "查询流量失败")
return h.fail(ctx, cardID, startedAt, "查询流量失败", err)
}
if strings.TrimSpace(result.ICCID) == "" {
h.recordUnreadable(ctx, cardID, attempt.IntegrationID, constants.TrafficCollectionResultInvalidPayload)
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultInvalidPayload, false, "流量查询响应缺少 ICCID", priority)
priority.FailRetryable(ctx, "流量查询响应缺少 ICCID")
return h.fail(ctx, cardID, startedAt, "流量查询响应缺少 ICCID", nil)
@@ -112,7 +115,8 @@ func (h *PollingCarddataHandler) Step(ctx context.Context, cardID uint) StepOutc
}
now := time.Now()
decision, err := h.observation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{
CardID: cardID, GatewayReadingMB: float64(result.Used), ResetDay: h.carrier.GetDataResetDay(ctx, card.CarrierID),
CardID: cardID, GatewayReadingMB: float64(result.Used), ReadingResult: gatewayReadingResult(result),
ResetDay: h.carrier.GetDataResetDay(ctx, card.CarrierID),
Metadata: carddomain.ObservationMetadata{
ObservationID: attempt.IntegrationID, Source: constants.CardObservationSourcePolling,
Scene: constants.CardObservationSceneTrafficPolling, ObservedAt: now, CorrelationID: attempt.IntegrationID,
@@ -131,7 +135,8 @@ func (h *PollingCarddataHandler) Step(ctx context.Context, cardID uint) StepOutc
if h.base.verboseLog {
h.base.logger.Info("流量轮询详情", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID),
zap.Float64("gateway_flow_mb", float64(result.Used)), zap.Float64("increment_mb", decision.IncrementMB),
zap.Bool("is_cross_month", decision.CrossMonth), zap.Bool("reading_accepted", decision.ReadingAccepted))
zap.Bool("is_cross_month", decision.CrossMonth), zap.Bool("reading_accepted", decision.ReadingAccepted),
zap.String("result", decision.Result), zap.String("decision", decision.Decision))
}
metric := "polling.observation.unchanged"
if changed {
@@ -152,3 +157,27 @@ func (h *PollingCarddataHandler) fail(ctx context.Context, cardID uint, startedA
h.base.updateStats(ctx, constants.TaskTypePollingCarddata, false, time.Since(startedAt))
return h.base.stepFail(ctx, constants.GatewayOpQueryFlow, message, err)
}
// recordUnreadable 为没拿到有效读数的采集写采集记录(只记采集时刻有套餐的卡),失败只影响留痕。
func (h *PollingCarddataHandler) recordUnreadable(ctx context.Context, cardID uint, observationID, result string) {
if h.observation == nil {
return
}
if _, err := h.observation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{
CardID: cardID, ReadingResult: result, ResetDay: 1,
Metadata: carddomain.ObservationMetadata{
ObservationID: observationID, Source: constants.CardObservationSourcePolling,
Scene: constants.CardObservationSceneTrafficPolling, ObservedAt: time.Now(), CorrelationID: observationID,
},
}); err != nil {
h.base.logger.Warn("记录卡流量采集失败结果失败", zap.Uint("card_id", cardID), zap.String("result", result), zap.Error(err))
}
}
// gatewayReadingResult 把运营商流量响应映射为读数结果:used 缺失、为 null、为空或无法解析时为无读数。
func gatewayReadingResult(result *gateway.FlowUsageResp) string {
if !result.HasReading() {
return constants.TrafficCollectionResultNoReading
}
return constants.TrafficCollectionResultOK
}