Files
junhong_cmp_fiber/internal/application/assetautorenewal/scan.go
break d52be16802
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m15s
feat(资产钱包自动续费): 新增全局配置、每日扫描续购与可靠复机
- 新增单行配置表 tb_asset_auto_renewal_config 与尝试记录表 tb_asset_auto_renewal_attempt(迁移 000229/000230)
- 每日按上海自然日扫描,窗口内以同一资产钱包可用余额续购当前主套餐,资金/订单/套餐/审计同一事务闭合
- 唯一键保证每资产每日至多一次尝试,占位中断由后续扫描收敛,当日不重试
- 四类失败原因向客户与店铺各投递每日至多一条站内通知,并注册通知类型与个人客户白名单
- 续费成功后按条件经 Outbox 可靠投递复机,新增恢复扫描只查询回填,不使用即发即弃调用
- 配置读写仅超级管理员与平台账号,保存记录操作者、前后值快照并登记统一审计
- tasks 7.1–7.15 全部验证通过(本机隔离 PostgreSQL/Redis,零外部渠道调用)
2026-09-17 16:39:26 +08:00

246 lines
9.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
})
}