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) } h.base.invalidateCardCache(ctx, cardID) card, err := h.base.iotCardStore.GetByID(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))} 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() decision, err := h.observation.ApplyTrafficObservation(ctx, carddomain.TrafficObservation{ CardID: cardID, GatewayReadingMB: float64(result.Used), ReadingResult: gatewayReadingResult(result), ResetDay: h.carrier.GetDataResetDay(ctx, card.CarrierID), Raw: unreadable.raw, Failure: &carddomain.CollectionFailure{DurationMS: int(time.Since(queryStartedAt).Milliseconds())}, 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 } 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, 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 }