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