Files
junhong_cmp_fiber/internal/task/polling_cardstatus_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

200 lines
9.6 KiB
Go

package task
import (
"context"
"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/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// PollingCardStatusHandler 负责网络状态轮询编排,查询结果统一交给卡观测应用服务。
type PollingCardStatusHandler struct {
base *PollingBase
gateway *gateway.Client
observation *cardapp.Service
integration *integrationlog.Repository
}
// shouldStopPollingForRisk 保留旧测试与调用方的纯判断兼容入口,规则权威在卡观测领域层。
func shouldStopPollingForRisk(card *model.IotCard, extend string) bool {
if card == nil {
return false
}
return carddomain.ShouldStopPollingForRisk(strings.TrimSpace(extend))
}
// NewPollingCardStatusHandler 创建卡状态轮询任务处理器。
func NewPollingCardStatusHandler(base *PollingBase, gw *gateway.Client, observation *cardapp.Service, integration *integrationlog.Repository) *PollingCardStatusHandler {
return &PollingCardStatusHandler{base: base, gateway: gw, observation: observation, integration: integration}
}
// Handle 处理卡状态轮询任务(旧分片调度)。按运营商账户调度时直接结束。
func (h *PollingCardStatusHandler) 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.TaskTypePollingCardStatus) {
h.base.logger.Debug("并发已满,重新入队", zap.Uint("card_id", cardID))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingCardStatus)
}
defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingCardStatus)
outcome := h.Step(ctx, cardID)
if outcome.Result == StepSucceeded {
// 卡状态任务仍正常但套餐任务丢失时,仅补入缺失项;不改写已有套餐任务的执行时间。
h.base.invalidateCardCache(ctx, cardID)
if err := h.base.ensureMissingTask(ctx, cardID, constants.TaskTypePollingPackage); err != nil {
h.base.logger.Warn("卡状态轮询补齐套餐任务失败", zap.Uint("card_id", cardID), zap.Error(err))
}
}
return h.base.applyLegacyOutcome(ctx, cardID, constants.TaskTypePollingCardStatus, outcome)
}
// Step 执行一次卡状态检查步骤,返回结果分类与观测到的网络状态;不负责重入队。旧分片调度与按运营商账户调度共用。
func (h *PollingCardStatusHandler) Step(ctx context.Context, cardID uint) StepOutcome {
startedAt := time.Now()
// 优先轮询互斥接缝:存在活动优先项时本轮即优先执行或被跳过;无活动项时行为与既有实现一致。
priority, skip, priorityErr := h.base.beginPollingPriority(ctx, cardID, constants.TaskTypePollingCardStatus)
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.TaskTypePollingCardStatus))
return stepDefer(0)
}
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.IntegrationOperationGatewayNetwork, constants.CardObservationSceneNetworkPolling)
result, err := h.gateway.QueryCardStatus(gateway.WithCarrier(ctx, card.CarrierID), &gateway.CardStatusReq{CardNo: card.ICCID})
if gateway.IsRateLimited(err) {
priority.FailRetryable(ctx, "运营商账户网关限流,稍后重试")
return StepOutcome{Result: StepRateLimited}
}
if err != nil {
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultFailed, false, "查询卡状态失败", priority)
priority.FailRetryable(ctx, "查询卡状态失败")
return h.fail(ctx, cardID, startedAt, "查询卡状态失败", err)
}
if strings.TrimSpace(result.ICCID) == "" {
_ = 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.TaskTypePollingCardStatus, "卡网络状态轮询任务", attempt.IntegrationID)
if h.observation == nil {
priority.FailFinal(ctx, "卡网络观测能力未配置")
return h.fail(ctx, cardID, startedAt, "卡网络观测能力未配置", nil)
}
now := time.Now()
if h.base.publishIngest(ctx, priority, IngestMessage{
Kind: ingestKindNetwork, CardID: cardID, CarrierID: card.CarrierID, ICCID: card.ICCID,
ObservationID: attempt.IntegrationID, ObservedAt: now, Source: constants.CardObservationSourcePolling,
Scene: constants.CardObservationSceneNetworkPolling, CorrelationID: attempt.IntegrationID,
TaskType: constants.TaskTypePollingCardStatus,
Network: &IngestNetworkPayload{Status: result.CardStatus, Extend: result.Extend, IMEI: result.IMEI},
}) {
h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, true, time.Since(startedAt))
return asyncNetworkOutcome(card.EnablePolling, result.CardStatus, result.Extend)
}
decision, err := h.observation.ApplyNetworkObservation(ctx, carddomain.NetworkObservation{
CardID: cardID, GatewayStatus: result.CardStatus, GatewayExtend: result.Extend, GatewayIMEI: result.IMEI,
Metadata: carddomain.ObservationMetadata{
ObservationID: attempt.IntegrationID, Source: constants.CardObservationSourcePolling,
Scene: constants.CardObservationSceneNetworkPolling, 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)
}
changed := decision.StatusChanged || decision.StopReasonChanged || decision.StopPolling
if changed {
_ = recordPriorityGatewayAttempt(ctx, h.integration, attempt, constants.IntegrationResultSuccess, true, "网络状态发生变化", priority)
}
if h.base.verboseLog || !decision.StatusKnown {
h.base.logger.Info("卡状态轮询详情", zap.Uint("card_id", cardID), zap.String("iccid", card.ICCID),
zap.String("card_status", result.CardStatus), zap.String("extend", strings.TrimSpace(result.Extend)),
zap.Int("new_network_status", decision.AfterStatus), zap.Bool("status_known", decision.StatusKnown),
zap.Bool("changed", decision.StatusChanged), zap.Bool("stop_polling", decision.StopPolling))
}
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.TaskTypePollingCardStatus, true, time.Since(startedAt))
if decision.StopPolling {
h.base.logger.Info("卡命中风险停机或已销户,已关闭该卡轮询", zap.Uint("card_id", cardID), zap.String("gateway_extend", decision.GatewayExtend))
return StepOutcome{Result: StepStopPolling}
}
outcome := stepOK()
if decision.StatusKnown {
outcome.Observed = ObservedOffline
if decision.AfterStatus == constants.NetworkStatusOnline {
outcome.Observed = ObservedOnline
}
}
return outcome
}
func (h *PollingCardStatusHandler) 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.TaskTypePollingCardStatus, false, time.Since(startedAt))
return h.base.stepFail(ctx, constants.GatewayOpQueryStatus, message, err)
}
// asyncNetworkOutcome 在异步入库时按网关结果直接得出步骤结果:命中风险停机或已销户时停止轮询(入库段同时关闭卡轮询开关),
// 观测到的网络状态按网关状态映射,供延迟确认比较。
func asyncNetworkOutcome(enablePolling bool, gatewayStatus, gatewayExtend string) StepOutcome {
extend := strings.TrimSpace(gatewayExtend)
if enablePolling && carddomain.ShouldStopPollingForRisk(extend) {
return StepOutcome{Result: StepStopPolling}
}
outcome := stepOK()
if status, known := carddomain.MapGatewayNetworkStatus(gatewayStatus, extend); known {
outcome.Observed = ObservedOffline
if status == constants.NetworkStatusOnline {
outcome.Observed = ObservedOnline
}
}
return outcome
}