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