Files
junhong_cmp_fiber/internal/application/cardobservation/traffic_flow.go
邹进 98017ac053
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m37s
feat: 分层轮询、重置日前加密与激活前流量结算
- 轮询配置新增套餐状态、资产状态、重置日前窗口与窗口内流量间隔(迁移 248),间隔三态:正数按间隔、0 不检查、空继承
- 配置按 priority, id 排序合并;在库与已销售无套餐未匹配时告警不入队
- 新增优先级整体重排、匹配预览、立即重算到期时间(异步只提前)、分层统计与重置日前加密统计接口
- 已切换日快照计量的卡流量按观测时间归属,激活前用量只记已结算
- 订单购买与实名激活补齐资产级激活锁,激活前对已切换卡结算一次流量,失败只告警不阻塞,结算不触发通道阈值判定
2026-10-24 17:06:38 +09:00

151 lines
7.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package cardobservation
import (
"context"
"time"
"go.uber.org/zap"
"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 表示不写事件。
EmitMB float64
// MonthMaxMB 是写入本次快照后的本月最大快照,用于覆盖卡表本月用量。
MonthMaxMB float64
// StaleGuarded 为 true 表示本次读数在重置日触发防旧值、按 0 计。
StaleGuarded bool
// FirstSwitch 为 true 表示这是卡的第一次切换观测(本次写入了切换水位)。
FirstSwitch bool
}
// snapshotResult 是一次快照换算与写入的结果。
type snapshotResult struct {
flowDate time.Time
conversion cardflow.Conversion
}
// 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)
flowDate := cardflow.ShanghaiDate(observedAt)
days := postgres.NewCardFlowDayStore(db)
monthCtx, err := days.MonthContext(ctx, card.ID, flowDate, resetDay)
if err != nil {
return snapshotResult{}, 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 snapshotResult{flowDate: flowDate, conversion: conversion}, nil
}
// strictResetDay 在当前连接内按卡的 carrier_id 读取上游流量重置日,不使用调用方预先计算的值;
// 查询失败、为空或非法时按卡的运营商类型兜底(联通 27,其余 1)并记录日志。
func (s *Service) strictResetDay(ctx context.Context, db *gorm.DB, card *model.IotCard) int {
var days []int
queryErr := db.WithContext(ctx).Model(&model.Carrier{}).
Where("id = ?", card.CarrierID).Limit(1).Pluck("data_reset_day", &days).Error
queried := 0
if len(days) > 0 {
queried = days[0]
}
resetDay, fallback := cardflow.ResolveResetDay(queried, queryErr == nil && len(days) > 0, card.CarrierType)
if fallback {
s.warnLogger().Warn("通道上游流量重置日不可用,按运营商类型兜底",
zap.Uint("card_id", card.ID), zap.Uint("carrier_id", card.CarrierID),
zap.String("carrier_type", card.CarrierType), zap.Int("queried", queried),
zap.Int("reset_day", resetDay), zap.Error(queryErr))
}
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))
}
}