Files
junhong_cmp_fiber/internal/application/assetautorenewal/recovery.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

109 lines
4.3 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"
"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
}