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