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,零外部渠道调用)
246 lines
9.4 KiB
Go
246 lines
9.4 KiB
Go
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)
|
||
})
|
||
}
|