Files
junhong_cmp_fiber/internal/task/polling_realname_handler.go
邹进 2fe7c1f8ac feat: 按运营商账户调度的分发器、卡任务执行器与模式切换
- 四个轮询处理器拆为步骤函数 Step(返回结果分类),旧 Asynq Handle 只做并发控制与原有重入队映射,行为不变;carrier 模式下旧 handler 直接结束
- 网关限流增加等待模式(ctx 标记),NSQ 消费者等名额到单步超时,超时按限流处理不计失败;记录限流等待次数
- 卡任务执行器:核对 token 并抢执行权,按配置步骤顺序串行执行到期检查(单步超时、总超时、panic 恢复、流量已触发评估时跳过套餐),执行中续期并 Touch,续期失败立即停止;收尾写回到期、连续失败、退避、延迟确认与告警
- 分发器:每秒按账户抢分发锁领取(执行上限按消费能力收紧、优先占比、按限流组剩余 QPS 估算),发布到 polling.card.{账户},发布失败释放;消费旧优先提示与手动触发;心跳与处理中标记到期计数
- NSQ 生产者与按账户动态订阅的消费者管理器,优雅退出宽限期
- 失败告警异步写 tb_polling_failure_alert(同卡同类型一条未处理,成功转已恢复),账户窗口内告警数超阈值产生账户级告警
- 模式监听:切 carrier 时旧分片调度停止出队,加锁初始化新结构后开始领取并订阅;回 legacy 时停止消费、重建旧分片队列;生命周期(API 与 Worker)在 carrier 模式写账户队列
2026-10-25 18:44:23 +09:00

159 lines
7.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/pkg/constants"
)
// PollingRealnameHandler 负责实名轮询编排,查询结果统一交给卡观测应用服务。
type PollingRealnameHandler struct {
base *PollingBase
gateway *gateway.Client
observation *cardapp.Service
integration *integrationlog.Repository
}
// NewPollingRealnameHandler 创建实名检查任务处理器。
func NewPollingRealnameHandler(base *PollingBase, gw *gateway.Client, observation *cardapp.Service, integration *integrationlog.Repository) *PollingRealnameHandler {
return &PollingRealnameHandler{base: base, gateway: gw, observation: observation, integration: integration}
}
// Handle 处理实名检查任务(旧分片调度)。按运营商账户调度时直接结束。
func (h *PollingRealnameHandler) 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.TaskTypePollingRealname) {
h.base.logger.Debug("并发已满,重新入队", zap.Uint("card_id", cardID))
return h.base.requeueCard(ctx, cardID, constants.TaskTypePollingRealname)
}
defer h.base.releaseConcurrency(ctx, constants.TaskTypePollingRealname)
return h.base.applyLegacyOutcome(ctx, cardID, constants.TaskTypePollingRealname, h.Step(ctx, cardID))
}
// Step 执行一次实名检查步骤,返回结果分类;不负责重入队。旧分片调度与按运营商账户调度共用。
func (h *PollingRealnameHandler) Step(ctx context.Context, cardID uint) StepOutcome {
startedAt := time.Now()
// 优先轮询互斥接缝:存在活动优先项时本轮即优先执行或被跳过;无活动项时行为与既有实现一致。
priority, skip, priorityErr := h.base.beginPollingPriority(ctx, cardID, constants.TaskTypePollingRealname)
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.TaskTypePollingRealname))
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()
}
// 所属运营商账户未开启实名管控时实名结果不影响停复机,跳过查询并按正常间隔重入队(不再按行业卡跳过)。
skippable, scopeErr := h.base.realnameQuerySkippable(ctx, card)
if scopeErr != nil {
priority.FailRetryable(ctx, "查询运营商账户实名管控失败")
return h.fail(ctx, cardID, startedAt, "查询运营商账户实名管控失败", scopeErr)
}
if skippable {
priority.FailFinal(ctx, "所属运营商账户未开启实名管控,跳过实名查询")
h.base.logger.Debug("所属运营商账户未开启实名管控,跳过实名查询", zap.Uint("card_id", cardID), zap.Uint("carrier_id", card.CarrierID))
return stepSkip()
}
priority.MarkExecution(ctx)
attempt := newGatewayAttempt(cardID, constants.IntegrationOperationGatewayRealname, constants.CardObservationSceneRealnamePolling)
result, err := h.gateway.QueryRealnameStatus(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.failOp(ctx, cardID, startedAt, constants.GatewayOpQueryRealname, "查询实名状态失败", 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.TaskTypePollingRealname, "实名状态轮询任务", attempt.IntegrationID)
if h.observation == nil {
priority.FailFinal(ctx, "卡实名观测能力未配置")
return h.fail(ctx, cardID, startedAt, "卡实名观测能力未配置", nil)
}
now := time.Now()
decision, err := h.observation.ApplyCardObservation(ctx, carddomain.RealnameObservation{
CardID: cardID, Verified: result.RealStatus,
Metadata: carddomain.ObservationMetadata{
ObservationID: attempt.IntegrationID, Source: constants.CardObservationSourcePolling,
Scene: constants.CardObservationSceneRealnamePolling, 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
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.Bool("real_status", result.RealStatus), zap.Int("new_status", decision.AfterStatus),
zap.Bool("changed", decision.StatusChanged), zap.Bool("reversal_pending", decision.ReversalPending))
}
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.TaskTypePollingRealname, true, time.Since(startedAt))
return stepOK()
}
func (h *PollingRealnameHandler) fail(ctx context.Context, cardID uint, startedAt time.Time, message string, err error) StepOutcome {
return h.failOp(ctx, cardID, startedAt, constants.GatewayOpQueryRealname, message, err)
}
func (h *PollingRealnameHandler) failOp(ctx context.Context, cardID uint, startedAt time.Time, op, 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.TaskTypePollingRealname, false, time.Since(startedAt))
return h.base.stepFail(ctx, op, message, err)
}