Files
junhong_cmp_fiber/internal/application/cardobservation/traffic_flow.go
邹进 9c3c258f16
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m27s
feat: 卡流量读数先过采集队列再入账,扣减按采集时刻确定套餐
- 每卡 Redis 采集队列(默认 N=3、跨天清空、回退容差 1 MB),攒满且不下降才写最后一个点
- 测试环境全量切换日快照计量,下线灰度名单、切换水位与卡表基线扣费;全部卡置待建起点
- 读数区分没给值与 0;有套餐的卡读数 0 也写快照与日用量 0 行;重置日比较基准为 0 照常防旧值
- 新增采集记录(按月分区保留 90 天)、人工冲正流水与后台接口、采集告警
- 扣减按采集时刻有效套餐查找,只对生效中套餐做状态迁移,查不到套餐按成功跳过
- 换货新卡(新设备下的卡)打待建起点;采集间隔上限只校验启用中的轮询配置

Change: add-card-traffic-queue-and-observed-deduction

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-29 12:18:47 +09:00

119 lines
5.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"
"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"
)
// flowOutcome 是一个写入点的日快照计量结果。
type flowOutcome struct {
// Written 为 true 表示本次写入了日快照(没套餐且读数为 0 时为 false)。
Written bool
// Decision 是写入判断:已写入或建起点。
Decision string
// EmitMB 是本次写入流量事件的增量,0 表示不写事件。
EmitMB float64
// DeltaMB 是本次按月对账计入已结算的增量(含没套餐、建起点时只记已结算的部分)。
DeltaMB float64
// MonthUsedMB 是本次读数换算后的本月累计;MonthMaxMB 是写入后的本月最大快照。
MonthUsedMB float64
MonthMaxMB float64
// StaleGuarded 为 true 表示本次读数在重置日触发防旧值、按 0 计。
StaleGuarded bool
// CrossMonth 为 true 表示卡表本月用量跨入新的自然月。
CrossMonth bool
// EventID 是本次写入的流量事件 ID。
EventID string
}
// flowWrite 是一次快照写入与按月对账的结果。
type flowWrite struct {
flowDate time.Time
conversion cardflow.Conversion
monthMax float64
delta float64
}
// 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(tx)
monthCtx, err := days.MonthContext(ctx, card.ID, flowDate, resetDay)
if err != nil {
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 flowWrite{}, errors.Wrap(errors.CodeDatabaseError, err, "写入卡日流量快照失败")
}
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 读取上游流量重置日,不使用调用方预先计算的值;
// 查询失败、为空或非法时按卡的运营商类型兜底(联通 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
}