package iot_card import ( "context" "time" "github.com/google/uuid" "go.uber.org/zap" 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/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // AutoRenewalResumeOutcome 是资产钱包自动续费成功后一次复机调用可安全记录的结果摘要。 // // 它只承载可靠任务回填所需的事实:是否真的发起过复机、Integration Log 标识、结果分类与可安全 // 展示的原因,不携带渠道报文原文、凭证或内部错误细节。 type AutoRenewalResumeOutcome struct { // Applied 为 false 表示本次未满足可复机条件,没有发起任何复机调用。 Applied bool // IntegrationID 是本次复机 Gateway 调用的 Integration Log 标识,未发起时为空。 IntegrationID string // Result 取值 constants.AuditResultSuccess / Failed / Unknown。 Result string // SafeReason 是可安全记录的不复机原因或失败原因,成功时为空。 SafeReason string } // AutoRenewalResumeReady 判断续费成功后的资产是否满足自动复机条件。 // // 判定规则全部复用既有单一来源(风险网关扩展、可轮询停因、通道阈值锁、有效主套餐、流量未耗尽、 // 实名),不复制任何规则;本入口与既有「若已停机则复机」入口的区别只在于:它明确区分 // 「条件不成立」与「已发起复机」,因此可作为可靠任务的结果来源。 // 卡资产按该卡自身判定;设备资产按设备下因轮询原因停机的卡逐个判定,任一卡满足即返回可复机。 func (s *StopResumeService) AutoRenewalResumeReady(ctx context.Context, assetType string, assetID uint) (bool, string, error) { cards, err := s.autoRenewalResumeCards(ctx, assetType, assetID) if err != nil { return false, "", err } if len(cards) == 0 { return false, "该资产当前没有可恢复停机的卡", nil } reason := "" for _, card := range cards { ready, cardReason, readyErr := s.autoRenewalCardReady(ctx, card) if readyErr != nil { return false, "", readyErr } if ready { return true, "", nil } if reason == "" { reason = cardReason } } if reason == "" { reason = "该资产当前没有可恢复停机的卡" } return false, reason, nil } // ResumeAssetForAutoRenewal 在可复机判定成立时执行复机,并返回结果分类。 // // 复用既有复机重试、Integration Log、统一审计与观测序列:成功时在同一事务写回 network_status=online、 // resumed_at,并清除可轮询停因。判定不成立时返回 Applied=false 且不发起任何调用; // 判定成立但调用失败或结果未知时,续费事实不回滚,结果交可靠任务与恢复扫描收敛。 func (s *StopResumeService) ResumeAssetForAutoRenewal(ctx context.Context, assetType string, assetID uint) (AutoRenewalResumeOutcome, error) { cards, err := s.autoRenewalResumeCards(ctx, assetType, assetID) if err != nil { return AutoRenewalResumeOutcome{}, err } reason := "该资产当前没有可恢复停机的卡" applied := false outcome := AutoRenewalResumeOutcome{Result: constants.AuditResultSuccess} for _, card := range cards { ready, cardReason, readyErr := s.autoRenewalCardReady(ctx, card) if readyErr != nil { return AutoRenewalResumeOutcome{}, readyErr } if !ready { if reason == "该资产当前没有可恢复停机的卡" && cardReason != "" { reason = cardReason } continue } applied = true cardOutcome, resumeErr := s.autoRenewalResumeCard(ctx, card) if cardOutcome.IntegrationID != "" { outcome.IntegrationID = cardOutcome.IntegrationID } switch cardOutcome.Result { case constants.AuditResultFailed: outcome.Result = constants.AuditResultFailed outcome.SafeReason = cardOutcome.SafeReason case constants.AuditResultUnknown: if outcome.Result == constants.AuditResultSuccess { outcome.Result = constants.AuditResultUnknown outcome.SafeReason = cardOutcome.SafeReason } } if resumeErr != nil { s.logger.Warn("自动续费成功后复机未完成", zap.String("asset_type", assetType), zap.Uint("asset_id", assetID), zap.Uint("card_id", card.ID), zap.Error(resumeErr)) } if outcome.Result == constants.AuditResultFailed { break } } if !applied { return AutoRenewalResumeOutcome{Applied: false, Result: constants.AuditResultSuccess, SafeReason: reason}, nil } outcome.Applied = true return outcome, nil } // QueryAutoRenewalResumeState 只查询运营商卡状态,供恢复扫描回填自动续费的复机结果。 // // 不发起任何复机调用:只做状态查询,并按既有网关状态映射规则给出是否已复机。 // 查询失败把 known 置 false 并返回错误,调用方必须按「仍未确认」处理,不得判为失败终态。 func (s *StopResumeService) QueryAutoRenewalResumeState(ctx context.Context, assetType string, assetID uint) (bool, bool, string, error) { cards, err := s.autoRenewalResumeCards(ctx, assetType, assetID) if err != nil { return false, false, "", err } if len(cards) == 0 { return false, false, "", errors.New(errors.CodeNotFound, "该资产当前没有可查询的卡") } if s.gatewayClient == nil { return false, false, "", errors.New(errors.CodeInternalError, "Gateway 未配置,无法查询卡状态") } integrationID := "" online := true for _, card := range cards { attempt, startErr := s.startCardCommandAttempt(ctx, card, constants.IntegrationOperationGatewayNetwork, constants.CardObservationSceneAssetAutoRenewalResumeRecovery, cardCommandSeriesKey(ctx), 1) if startErr != nil { return false, false, integrationID, startErr } integrationID = attempt.log.IntegrationID response, callErr := s.gatewayClient.QueryCardStatus(ctx, &gateway.CardStatusReq{CardNo: card.ICCID}) if callErr != nil { if completeErr := s.completeCardCommandAttempt(ctx, attempt, callErr, false); completeErr != nil { s.logger.Error("终结自动续费复机状态查询 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(completeErr)) } return false, false, integrationID, callErr } if completeErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); completeErr != nil { s.logger.Error("终结自动续费复机状态查询 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(completeErr)) return false, false, integrationID, completeErr } status, known := carddomain.MapGatewayNetworkStatus(response.CardStatus, response.Extend) if !known { return false, false, integrationID, nil } if status != constants.NetworkStatusOnline { online = false } } return online, true, integrationID, nil } // autoRenewalResumeCards 解析自动续费资产对应的待复机卡:卡资产取自身,设备资产取该设备下 // 因轮询原因停机的绑定卡(复用既有设备卡查询,不另立绑定关系口径)。 func (s *StopResumeService) autoRenewalResumeCards(ctx context.Context, assetType string, assetID uint) ([]*model.IotCard, error) { if assetID == 0 { return nil, errors.New(errors.CodeInvalidParam, "自动续费复机资产无效") } switch assetType { case constants.AssetWalletResourceTypeIotCard: card, err := s.iotCardStore.GetByID(ctx, assetID) if err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费复机卡事实失败") } return []*model.IotCard{card}, nil case constants.AssetWalletResourceTypeDevice: cards, err := s.iotCardStore.ListByDeviceIDAndPollingStopReasons(ctx, assetID) if err != nil { return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备待复机卡失败") } return cards, nil default: return nil, errors.New(errors.CodeInvalidParam, "资产类型无效") } } // autoRenewalCardReady 判断单卡是否满足自动复机条件;返回的 reason 是可安全记录的不满足原因。 func (s *StopResumeService) autoRenewalCardReady(ctx context.Context, card *model.IotCard) (bool, string, error) { if card == nil || card.ID == 0 { return false, "", errors.New(errors.CodeInvalidParam, "自动续费复机卡无效") } if card.NetworkStatus == constants.NetworkStatusOnline { return false, "卡当前已在线,无需复机", nil } if isRiskGatewayExtend(card.GatewayExtend) { return false, "网关扩展状态为风险停机或已销户,不自动复机", nil } if !isPollingStopReason(card.StopReason) { return false, "停因不属于可轮询复机范围,不自动复机", nil } blocked, err := s.channelThresholdBlocked(ctx, card) if err != nil { return false, "", err } if blocked { return false, "卡当前计费周期持有通道阈值停机锁,不自动复机", nil } hasPackage, err := s.hasValidPackage(ctx, card) if err != nil { return false, "", err } if !hasPackage { return false, "无有效主套餐,不自动复机", nil } exhausted, err := s.isTrafficExhausted(ctx, card) if err != nil { return false, "", err } if exhausted { return false, "套餐流量已耗尽,不自动复机", nil } realnameOK, err := s.isRealnameOK(ctx, card) if err != nil { return false, "", err } if !realnameOK { return false, "实名要求未满足,不自动复机", nil } return true, "", nil } // autoRenewalResumeCard 对单卡执行自动续费后的复机并返回结果分类。 // // 复用既有每卡复机分布式锁(constants.RedisCardResumeLockKey,与 resumeSingleCard 同一把锁与 TTL): // 认领字段只保证「同一尝试至多一次」,跨链路(轮询/套餐激活/流量重置 vs 自动续费)仍可能同时复机, // 该锁是既有链路共用的幂等闸门。抢不到锁表示另一条链路正在复机,本次不重复调用运营商,按未发起处理。 func (s *StopResumeService) autoRenewalResumeCard(ctx context.Context, card *model.IotCard) (AutoRenewalResumeOutcome, error) { if s.redis != nil { lockKey := constants.RedisCardResumeLockKey(card.ID) locked, lockErr := s.redis.SetNX(ctx, lockKey, "1", 30*time.Second).Result() if lockErr != nil { return AutoRenewalResumeOutcome{}, errors.Wrap(errors.CodeRedisError, lockErr, "获取复机分布式锁失败") } if !locked { return AutoRenewalResumeOutcome{ Applied: false, Result: constants.AuditResultSuccess, SafeReason: "该卡正在被其他链路复机,本次不重复调用", }, nil } defer func() { _ = s.redis.Del(ctx, lockKey) }() } actionCode, summary := constants.AuditActionIotCardAutoStarted, "自动续费成功后恢复 IoT 卡网络" attempt, integrationID, err := s.resumeCardWithRetry(ctx, card, actionCode, summary) if err != nil { return AutoRenewalResumeOutcome{ Applied: true, IntegrationID: integrationID, Result: cardCommandAuditResult(err), SafeReason: "Gateway 复机请求未成功", }, err } fields := map[string]any{ "network_status": constants.NetworkStatusOnline, "resumed_at": time.Now(), } // 判定已限定停因属于可轮询复机范围,复机成功后清除停因,与既有轮询复机口径一致。 if isPollingStopReason(card.StopReason) { fields["stop_reason"] = "" } if err := s.updateCardAndAppendNetworkSeries(ctx, card, fields, constants.CardObservationSceneBusinessResume, "online", uuid.NewString(), actionCode, summary, attempt.log.IntegrationID); err != nil { if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, false); logErr != nil { s.logger.Error("终结自动续费复机 Integration Log 失败", zap.String("integration_id", attempt.log.IntegrationID), zap.Error(logErr)) } s.recordCardCommandAudit(ctx, card, actionCode, summary+"结果待核对", constants.AuditResultUnknown, attempt.log.IntegrationID, cardSnapshot(card), map[string]any{"requested_network_status": constants.NetworkStatusOnline}, err) // 运营商已成功但本地回写失败:按结果未知交恢复扫描查询确认,续费事实不回滚。 return AutoRenewalResumeOutcome{ Applied: true, IntegrationID: attempt.log.IntegrationID, Result: constants.AuditResultUnknown, SafeReason: "本地状态回写失败,结果待核对", }, err } s.reschedulePolling(ctx, card.ID) if logErr := s.completeCardCommandAttempt(ctx, attempt, nil, true); logErr != nil { return AutoRenewalResumeOutcome{ Applied: true, IntegrationID: attempt.log.IntegrationID, Result: constants.AuditResultSuccess, }, errors.Wrap(errors.CodeDatabaseError, logErr, "终结自动续费复机 Integration Log 失败") } s.logger.Info("自动续费成功后复机完成", zap.Uint("card_id", card.ID), zap.String("iccid", card.ICCID)) return AutoRenewalResumeOutcome{ Applied: true, IntegrationID: attempt.log.IntegrationID, Result: constants.AuditResultSuccess, }, nil }