Files
junhong_cmp_fiber/internal/task/polling_carddata_handler.go
邹进 1214181e78
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 16m2s
feat: 轮询观测入库异步化,调网关与入库分离,采集记录经消息攒批写入
- 轮询步骤异步模式下只调网关并发布 card.ingest 入库消息,入库消费者固定并发 30、按采集 ID 去重、可重试错误不限次数退避重投
- 采集记录经 card.collection 攒批写入,失败整批重投,新增 (observation_id, observed_at) 唯一索引
- 网络、实名观测增加乱序保护;轮询读查询走缓存,套餐状态查询各自计时且失败不缓存
- 入库积压告警与反压;系统配置 polling.ingest.mode 默认 sync 可回退;测试环境 nsqd 消息全部落盘

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 16:33:29 +09:00

248 lines
12 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 task
import (
"context"
"strconv"
"strings"
"time"
"github.com/hibiken/asynq"
"go.uber.org/zap"
cardapp "github.com/break/junhong_cmp_fiber/internal/application/cardobservation"
carddomain "github.com/break/junhong_cmp_fiber/internal/domain/cardobservation"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// PollingCarddataHandler 负责流量轮询编排,查询结果统一交给卡观测应用服务。
type PollingCarddataHandler struct {
base *PollingBase
gateway *gateway.Client
carrier *postgres.CarrierStore
observation *cardapp.Service
integration *integrationlog.Repository
}
// NewPollingCarddataHandler 创建流量检查任务处理器。
func NewPollingCarddataHandler(base *PollingBase, gw *gateway.Client, carrier *postgres.CarrierStore, observation *cardapp.Service, integration *integrationlog.Repository) *PollingCarddataHandler {
return &PollingCarddataHandler{base: base, gateway: gw, carrier: carrier, observation: observation, integration: integration}
}
// Handle 处理流量检查任务(旧分片调度)。按运营商账户调度时直接结束。
func (h *PollingCarddataHandler) Handle(ctx context.Context, task *asynq.Task) error {
cardID, ok := parseTaskPayload(task.Payload(), h.base.logger)
if !ok {
return nil
}
if h.base.carrierSchedulerActive(ctx) {
return nil
}
if !h.base.acquireConcurrency(ctx, constants.TaskTypePollingCarddata) {
h.base.logger.Debug("并发已满,重新入队", zap.Uint("card_id", cardID))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCarddata)
}
defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingCarddata)
return h.base.applyLegacyOutcome(ctx, cardID, constants.TaskTypePollingCarddata, h.Step(ctx, cardID))
}
// Step 执行一次流量检查步骤,返回结果分类;不负责重入队。旧分片调度与按运营商账户调度共用。
func (h *PollingCarddataHandler) Step(ctx context.Context, cardID uint) StepOutcome {
startedAt := time.Now()
lockToken, locked, err := h.base.acquireCardTrafficSyncLock(ctx, cardID)
if err != nil || !locked {
h.base.logger.Warn("卡流量同步锁不可用,延迟重入队", zap.Uint("card_id", cardID), zap.Error(err))
return stepDefer(5 * time.Second)
}
defer h.base.releaseCardTrafficSyncLock(ctx, cardID, lockToken)
// 优先轮询互斥接缝:放在流量同步锁获取成功之后,避免认领后拿不到锁导致优先项空占。
priority, skip, priorityErr := h.base.beginPollingPriority(ctx, cardID, constants.TaskTypePollingCarddata)
if priorityErr != nil {
return h.fail(ctx, cardID, startedAt, "查询活动优先轮询项失败", priorityErr)
}
if skip {
h.base.logger.Debug("卡轮询优先项已被其它执行持有,本轮跳过并延后",
zap.Uint("card_id", cardID), zap.String("task_type", constants.TaskTypePollingCarddata))
return stepDefer(0)
}
// 卡任务只用到 ICCID、运营商账户与轮询开关;读缓存即可,入库段写库后会失效缓存。
card, err := h.base.getCardWithCache(ctx, cardID)
if err != nil {
if isNotFound(err) {
priority.FailFinal(ctx, "卡不存在")
return StepOutcome{Result: StepCardGone}
}
// 本地读卡失败属可恢复失败:先按真实执行累加尝试次数,再按上限收敛,避免加急事实被静默丢弃。
priority.MarkExecution(ctx)
priority.FailRetryable(ctx, "获取卡信息失败")
return h.fail(ctx, cardID, startedAt, "获取卡信息失败", err)
}
// 执行前轮询范围校验:优先通道不得重启已停止轮询的卡。
if !h.base.priorityInPollingScope(ctx, priority, card) {
return stepSkip()
}
if h.gateway == nil {
priority.FailFinal(ctx, "流量查询能力未配置")
return stepSkip()
}
priority.MarkExecution(ctx)
attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayTraffic, constants.CardObservationSceneTrafficPolling)
queryCtx, rawCapture := gateway.WithRawCapture(gateway.WithCarrier(ctx, card.CarrierID))
queryStartedAt := time.Now()
result, err := h.gateway.QueryFlow(queryCtx, &gateway.FlowQueryReq{CardNo: card.ICCID})
unreadable := unreadableDetail{raw: domainGatewayRaw(rawCapture), failure: collectionFailure(err, time.Since(queryStartedAt))}
unreadable.iccid, unreadable.carrierID = card.ICCID, card.CarrierID
if gateway.IsRateLimited(err) {
h.recordUnreadable(ctx, cardID, attempt.IntegrationID, constants.TrafficCollectionResultRateLimited, unreadable)
priority.FailRetryable(ctx, "运营商账户网关限流,稍后重试")
return StepOutcome{Result: StepRateLimited}
}
if err != nil {
h.recordUnreadable(ctx, cardID, attempt.IntegrationID, constants.TrafficCollectionResultFailed, unreadable)
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询流量失败", priority)
priority.FailRetryable(ctx, "查询流量失败")
return h.fail(ctx, cardID, startedAt, "查询流量失败", err)
}
if strings.TrimSpace(result.ICCID) == "" {
unreadable.failure = &carddomain.CollectionFailure{ErrorCategory: constants.GatewayErrorInvalidResponse,
ProviderMessage: "流量查询响应缺少 ICCID", DurationMS: int(time.Since(queryStartedAt).Milliseconds())}
h.recordUnreadable(ctx, cardID, attempt.IntegrationID, constants.TrafficCollectionResultInvalidPayload, unreadable)
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultInvalidPayload, false, "流量查询响应缺少 ICCID", priority)
priority.FailRetryable(ctx, "流量查询响应缺少 ICCID")
return h.fail(ctx, cardID, startedAt, "流量查询响应缺少 ICCID", nil)
}
ctx = withPollingWorkerAuditContext(ctx, constants.TaskTypePollingCarddata, "卡流量轮询任务", attempt.IntegrationID)
if h.observation == nil || h.carrier == nil {
priority.FailFinal(ctx, "卡流量观测能力未配置")
return h.fail(ctx, cardID, startedAt, "卡流量观测能力未配置", nil)
}
now := time.Now()
resetDay := h.base.carrierResetDay(ctx, card.CarrierID)
durationMS := int(time.Since(queryStartedAt).Milliseconds())
if h.base.publishIngest(ctx, priority, IngestMessage{
Kind: ingestKindTraffic, CardID: cardID, CarrierID: card.CarrierID, ICCID: card.ICCID,
ObservationID: attempt.IntegrationID, ObservedAt: now, Source: constants.CardObservationSourcePolling,
Scene: constants.CardObservationSceneTrafficPolling, CorrelationID: attempt.IntegrationID,
TaskType: constants.TaskTypePollingCarddata, Raw: unreadable.raw,
Traffic: &IngestTrafficPayload{ReadingMB: float64(result.Used), ReadingResult: gatewayReadingResult(result),
ResetDay: resetDay, DurationMS: durationMS},
}) {
// 异步入库:网关已成功,入库由入库消费者完成与重试,本步骤按成功结束。
h.base.configMgr.RecordPreResetCheck(ctx, card, now)
h.base.updateStats(ctx, constants.TaskTypePollingCarddata, true, time.Since(startedAt))
return stepOK()
}
decision, err := h.observation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{
CardID: cardID, GatewayReadingMB: float64(result.Used), ReadingResult: gatewayReadingResult(result),
ResetDay: resetDay, Raw: unreadable.raw, ICCID: card.ICCID, CarrierID: card.CarrierID,
Failure: &carddomain.CollectionFailure{DurationMS: durationMS},
Metadata: carddomain.ObservationMetadata{
ObservationID: attempt.IntegrationID, Source: constants.CardObservationSourcePolling,
Scene: constants.CardObservationSceneTrafficPolling, ObservedAt: now, CorrelationID: attempt.IntegrationID,
},
})
if err != nil {
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultUnknown, false, "应用流量观测失败", priority)
priority.FailRetryable(ctx, "应用流量观测失败")
return h.fail(ctx, cardID, startedAt, "应用流量观测失败", err)
}
h.base.configMgr.RecordPreResetCheck(ctx, card, now)
changed := decision.IncrementMB > 0 || decision.CrossMonth
if changed {
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultSuccess, true, "流量业务事实发生变化", priority)
}
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.String("result", decision.Result), zap.String("decision", decision.Decision))
}
metric := "polling.observation.unchanged"
if changed {
metric = "polling.observation.persisted"
}
h.base.logger.Info("流量轮询观测完成", zap.Uint("card_id", cardID), zap.Bool("changed", changed), zap.String("metric", metric))
priority.Complete(ctx)
h.base.updateStats(ctx, constants.TaskTypePollingCarddata, true, time.Since(startedAt))
return StepOutcome{Result: StepSucceeded, TriggeredEvaluation: changed}
}
func (h *PollingCarddataHandler) fail(ctx context.Context, cardID uint, startedAt time.Time, message string, err error) StepOutcome {
fields := []zap.Field{zap.Uint("card_id", cardID)}
if err != nil {
fields = append(fields, zap.Error(err))
}
h.base.logger.Warn(message, fields...)
h.base.updateStats(ctx, constants.TaskTypePollingCarddata, false, time.Since(startedAt))
return h.base.stepFail(ctx, constants.GatewayOpQueryFlow, message, err)
}
// recordUnreadable 为没拿到有效读数的采集写采集记录(只记采集时刻有套餐的卡),失败只影响留痕。
// unreadableDetail 是读数不可用时随采集失败记录保存的原始数据与错误详情。
type unreadableDetail struct {
raw *carddomain.GatewayRaw
failure *carddomain.CollectionFailure
iccid string
carrierID uint
}
func (h *PollingCarddataHandler) recordUnreadable(ctx context.Context, cardID uint, observationID, result string, detail unreadableDetail) {
if h.observation == nil {
return
}
if _, err := h.observation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{
CardID: cardID, ReadingResult: result, ResetDay: 1, Raw: detail.raw, Failure: detail.failure,
ICCID: detail.iccid, CarrierID: detail.carrierID,
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
}
// domainGatewayRaw 把网关原始数据收集器转为领域层原始数据。
func domainGatewayRaw(capture *gateway.RawCapture) *carddomain.GatewayRaw {
if capture == nil || (capture.Attempts == 0 && len(capture.Request) == 0) {
return nil
}
return &carddomain.GatewayRaw{
Request: capture.Request, HTTPStatus: capture.HTTPStatus, Response: capture.Response,
GatewayCode: capture.GatewayCode, GatewayMsg: capture.GatewayMsg, TraceID: capture.TraceID, Attempts: capture.Attempts,
}
}
// collectionFailure 从网关错误提取采集失败详情;无错误时只带耗时。
func collectionFailure(err error, duration time.Duration) *carddomain.CollectionFailure {
failure := &carddomain.CollectionFailure{DurationMS: int(duration.Milliseconds())}
if err == nil {
return failure
}
failure.ProviderMessage = err.Error()
if gwErr, ok := gateway.AsGatewayError(err); ok {
failure.ErrorCategory = gwErr.Category
if gwErr.ProviderCode != 0 {
failure.ProviderCode = strconv.Itoa(gwErr.ProviderCode)
}
if gwErr.ProviderMessage != "" {
failure.ProviderMessage = gwErr.ProviderMessage
}
} else if gateway.IsRateLimited(err) {
failure.ErrorCategory = constants.TrafficCollectionResultRateLimited
}
return failure
}