package assetautorenewal import ( "context" "time" "go.uber.org/zap" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // RecoveryResult 是一次复机结果恢复扫描的可观察结果。 // // Scanned 为扫到的未收敛尝试数;Confirmed 为本次回填为已确认结果的尝试数; // Pending 为结果仍未确认、等待下次扫描的尝试数;Anomaly 为超过查询窗口仍不可确认、 // 本次标记转人工的尝试数。 type RecoveryResult struct { Scanned int Confirmed int Pending int Anomaly int } // RecoverResumeResults 扫描未收敛的复机子结果:只查询运营商状态回填,绝不重复发起复机调用。 // // 收敛口径与既有停复机恢复一致(internal/application/carrierthreshold/cycle.go:246-296): // - 查询确认已复机 → 回填成功; // - 「已知但未复机」或不可判定 → 仍算未确认,等到下一次扫描; // - 自提交起超过查询窗口仍不可确认 → 标记异常并退出自动扫描转人工核对。 // // 恢复扫描**不**据此判定「复机失败」:续购后新主套餐多为待生效,卡在此期间本就可能仍处于停机, // 把「未复机」当失败会发出误报通知。复机失败只由消费者在网关明确返回失败时确认(「仅确认失败才通知」)。 // 单条失败不中断整批,但会作为首个错误返回,交既有任务重试。 func (s *Service) RecoverResumeResults(ctx context.Context) (RecoveryResult, error) { result := RecoveryResult{} if s.resume == nil { return result, errors.New(errors.CodeServiceUnavailable, "自动续费复机执行端口未配置") } now := s.now() attempts, err := s.attemptStore.ScanUnresolvedResumes(ctx, now, constants.AssetAutoRenewalRecoveryBatchSize) if err != nil { return result, err } result.Scanned = len(attempts) var firstErr error for index := range attempts { if err := s.recoverResumeResult(ctx, &attempts[index], now, &result); err != nil { s.logger.Warn("自动续费复机结果恢复单条失败", zap.Uint("attempt_id", attempts[index].ID), zap.Error(err)) if firstErr == nil { firstErr = err } } } s.logger.Info("自动续费复机结果恢复扫描完成", zap.Int("scanned", result.Scanned), zap.Int("confirmed", result.Confirmed), zap.Int("pending", result.Pending), zap.Int("anomaly", result.Anomaly)) return result, firstErr } // recoverResumeResult 处理单条未收敛的复机子结果。 func (s *Service) recoverResumeResult(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, now time.Time, result *RecoveryResult) error { online, known, integrationID, err := s.resume.QueryAutoRenewalResumeState(ctx, attempt.AssetType, attempt.AssetID) if err != nil || !known || !online { // 查询失败、状态不可判定、或已知仍未复机:一律按「仍未确认」处理, // 绝不误判为失败终态,也绝不据此发出失败通知。 result.Pending++ if !expiredResumeQueryWindow(attempt.ResumeSubmittedAt, now) { return nil } marked, markErr := s.attemptStore.MarkResumeAnomaly(ctx, attempt.ID, "复机结果超过确认窗口仍不可查,请人工核对") if markErr != nil { return markErr } if marked { result.Anomaly++ s.logger.Warn("自动续费复机结果超期不可确认,已标记异常转人工", zap.Uint("attempt_id", attempt.ID), zap.String("asset_type", attempt.AssetType), zap.Uint("asset_id", attempt.AssetID)) } return nil } expected := []int{ constants.AssetAutoRenewalResumeStatusRequested, constants.AssetAutoRenewalResumeStatusUnknown, } marked, markErr := s.attemptStore.MarkResumeOutcome(ctx, attempt.ID, expected, constants.AssetAutoRenewalResumeStatusSucceeded, integrationID, "") if markErr != nil { return markErr } if marked { result.Confirmed++ } return nil } // expiredResumeQueryWindow 判断复机子任务自提交起是否已超过自动查询窗口。 // 未提交(提交认领时刻为空)表示尚未发起复机,不算超期。 func expiredResumeQueryWindow(submittedAt *time.Time, now time.Time) bool { if submittedAt == nil { return false } return now.Sub(*submittedAt) >= constants.AssetAutoRenewalResumeQueryWindow }