All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 16m2s
- 轮询步骤异步模式下只调网关并发布 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>
248 lines
12 KiB
Go
248 lines
12 KiB
Go
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
|
||
}
|