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

@@ -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
}