package assetautorenewal import ( "context" "time" "go.uber.org/zap" "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/internal/model" assetquery "github.com/break/junhong_cmp_fiber/internal/query/assetautorenewal" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // ScanResult 汇总一次每日扫描的可观察结果。 type ScanResult struct { Converged int64 Candidates int Attempted int Succeeded int Failed int Skipped int Duplicated int } // RunDailyScan 执行一次每日自动续费扫描:先收敛历史非终态尝试,再按资产扫描候选并逐项执行。 // // 只有扫描级失败(配置读取、候选读取、数据库不可用)返回错误交既有任务重试;单个资产执行失败 // 在用例内捕获并落终态后继续处理其余资产。总开关关闭时不创建任何新尝试与订单,既有记录保留。 func (s *Service) RunDailyScan(ctx context.Context) (ScanResult, error) { result := ScanResult{} if s.db == nil { return result, errors.New(errors.CodeServiceUnavailable, "自动续费用例未配置") } converged, err := s.attemptStore.ConvergeUnfinished(ctx, s.today()) if err != nil { return result, err } result.Converged = converged if converged > 0 { s.logger.Info("自动续费历史非终态尝试已收敛", zap.Int64("converged", converged)) } config, err := s.configStore.Get(ctx) if err != nil { if err == gorm.ErrRecordNotFound { return result, errors.New(errors.CodeNotFound, "自动续费配置不存在") } return result, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费配置失败") } if config.Enabled != 1 { s.logger.Info("自动续费总开关关闭,本次扫描不创建尝试与订单") return result, nil } if config.Scope != constants.AssetAutoRenewalScopeAll && config.Scope != constants.AssetAutoRenewalScopeSpecified { return result, errors.New(errors.CodeInvalidStatus, "自动续费配置范围取值非法") } candidates, err := s.candidates.Candidates(ctx, config.DaysBeforeExpiry) if err != nil { return result, err } result.Candidates = len(candidates) scope := newScopeMatcher(config) for _, candidate := range candidates { if !scope.matches(candidate.CurrentPackageID) { continue } outcome, processErr := s.processCandidate(ctx, candidate, config) if processErr != nil { // 单资产失败已落终态,继续处理其余资产;扫描任务本身不因该资产失败而失败。 s.logger.Error("自动续费单资产执行失败,继续处理其余资产", zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID), zap.Error(processErr)) result.Failed++ continue } switch outcome { case candidateSucceeded: result.Attempted++ result.Succeeded++ case candidateFailed: result.Attempted++ result.Failed++ case candidateSkipped: result.Attempted++ result.Skipped++ default: result.Duplicated++ } } s.logger.Info("自动续费每日扫描完成", zap.Int64("converged", result.Converged), zap.Int("candidates", result.Candidates), zap.Int("succeeded", result.Succeeded), zap.Int("failed", result.Failed), zap.Int("skipped", result.Skipped), zap.Int("duplicated", result.Duplicated)) return result, nil } // scanOutcome 是一次候选处理的终态归属,用于汇总扫描结果。 type scanOutcome int const ( candidateDuplicated scanOutcome = iota candidateSucceeded candidateFailed candidateSkipped ) // scopeMatcher 表达配置的适用范围:全部主套餐,或指定主套餐集合。 // // 运行时只按当前主套餐商品是否在集合内判定,不因后来下架而拒绝——下架交给续费豁免判定。 type scopeMatcher struct { all bool packageIDs map[uint]struct{} } func newScopeMatcher(config *model.AssetAutoRenewalConfig) scopeMatcher { if config.Scope == constants.AssetAutoRenewalScopeAll { return scopeMatcher{all: true} } ids := make(map[uint]struct{}, len(config.PackageIDs)) for _, packageID := range config.PackageIDs { ids[packageID] = struct{}{} } return scopeMatcher{packageIDs: ids} } func (m scopeMatcher) matches(packageID uint) bool { if m.all { return true } _, exists := m.packageIDs[packageID] return exists } // processCandidate 处理单个候选:占位写入、执行、落终态与通知。 // // 占位冲突表示该资产当日已尝试,直接跳过且不重复扣款;执行阶段的失败与跳过各以独立短事务落终态。 func (s *Service) processCandidate(ctx context.Context, candidate assetquery.Candidate, config *model.AssetAutoRenewalConfig) (scanOutcome, error) { triggerDate := s.today() attempt, err := s.buildAttempt(ctx, candidate, config, triggerDate) if err != nil { return candidateFailed, err } created, err := s.attemptStore.CreatePlaceholder(ctx, attempt) if err != nil { return candidateFailed, err } if !created { s.logger.Info("该资产当日已存在自动续费尝试,跳过", zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID)) return candidateDuplicated, nil } facts, halt, err := s.executeRenewal(ctx, candidate, attempt, config.DaysBeforeExpiry) if halt != nil { if halt.SkipReason != "" { return candidateSkipped, s.finalizeSkip(ctx, attempt, halt) } return candidateFailed, s.finalizeFailure(ctx, attempt, halt.FailureReason, halt.Detail) } if err != nil { s.logger.Error("自动续费事务失败并已整体回滚", zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID), zap.Error(err)) return candidateFailed, s.finalizeFailure(ctx, attempt, constants.AssetAutoRenewalFailureOrderFailed, "续购事务执行失败并已整体回滚,未产生订单、扣款与套餐事实") } s.logger.Info("自动续费续购成功", zap.String("asset_type", candidate.AssetType), zap.Uint("asset_id", candidate.AssetID), zap.Uint("order_id", facts.OrderID), zap.String("order_no", facts.OrderNo), zap.Int64("renew_price", facts.RenewPrice)) return candidateSucceeded, nil } // buildAttempt 组装占位尝试记录,冻结触发时的客户、店铺、配置窗口与套餐快照。 func (s *Service) buildAttempt(ctx context.Context, candidate assetquery.Candidate, config *model.AssetAutoRenewalConfig, triggerDate time.Time) (*model.AssetAutoRenewalAttempt, error) { sequence, err := s.attemptStore.CountByAsset(ctx, candidate.AssetType, candidate.AssetID, triggerDate) if err != nil { return nil, err } finalExpiresAt := candidate.FinalExpiresAt attempt := &model.AssetAutoRenewalAttempt{ AssetType: candidate.AssetType, AssetID: candidate.AssetID, TriggerDate: triggerDate, Status: constants.AssetAutoRenewalAttemptStatusProcessing, CustomerID: candidate.CustomerID, ShopID: candidate.ShopID, ConfigVersion: config.ConfigVersion, WindowDays: config.DaysBeforeExpiry, FinalExpiresAt: &finalExpiresAt, CurrentUsageID: candidate.CurrentUsageID, CurrentPackageID: candidate.CurrentPackageID, RenewPackageID: candidate.CurrentPackageID, OperatorType: constants.AssetAutoRenewalOperatorTypeSystemTask, OperatorID: constants.TaskTypeAssetAutoRenewalScan, AttemptSeq: int(sequence) + 1, } return attempt, nil } // finalizeSkip 以独立短事务落跳过终态:不扣款、不建订单、不发送通知。 func (s *Service) finalizeSkip(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, halt *renewalHalt) error { _, err := s.attemptStore.Finalize(ctx, attempt.ID, map[string]any{ "status": constants.AssetAutoRenewalAttemptStatusSkipped, "skip_reason": halt.SkipReason, "failure_reason": "", "failure_detail": halt.Detail, }) if err != nil { return err } s.logger.Info("自动续费跳过该资产", zap.String("asset_type", attempt.AssetType), zap.Uint("asset_id", attempt.AssetID), zap.String("skip_reason", halt.SkipReason)) return nil } // finalizeFailure 以独立短事务落失败终态,并在同一事务内投递通知与写失败审计。 // // 该短事务与已回滚的续费事务不共用连接或事务(ENG-TX-001 例外),条件更新依据尝试记录仍非终态; // 已被并发收敛时不再投递通知与审计。中断收敛(interrupted)不属于通知口径,不会被通知。 func (s *Service) finalizeFailure(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, reason, detail string) error { return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { updated, err := s.attemptStore.FinalizeInTx(ctx, tx, attempt.ID, map[string]any{ "status": constants.AssetAutoRenewalAttemptStatusFailed, "failure_reason": reason, "failure_detail": detail, "skip_reason": "", }) if err != nil { return err } if !updated { s.logger.Info("自动续费尝试已被并发收敛,跳过通知与审计", zap.Uint("attempt_id", attempt.ID)) return nil } if notifyErr := s.appendFailureNotifications(ctx, tx, failureNotification{ AttemptID: attempt.ID, AssetType: attempt.AssetType, AssetID: attempt.AssetID, Identifier: s.assetIdentifier(ctx, attempt.AssetType, attempt.AssetID), ShopID: attempt.ShopID, CustomerID: attempt.CustomerID, TriggerDate: attempt.TriggerDate, Reason: reason, PackageName: s.packageName(ctx, attempt.RenewPackageID), FinalExpiresAt: attempt.FinalExpiresAt, }); notifyErr != nil { return notifyErr } return s.appendFailureAudit(ctx, tx, attempt, reason, detail) }) }