feat(资产钱包自动续费): 新增全局配置、每日扫描续购与可靠复机
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:
2026-09-17 16:39:26 +08:00
parent 70e6b186df
commit d52be16802
54 changed files with 5164 additions and 97 deletions

View File

@@ -0,0 +1,222 @@
package assetautorenewal
import (
"context"
"strconv"
"github.com/bytedance/sonic"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/auditcontext"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/outboxid"
)
// assetAutoRenewalResumePayloadVersion 是自动续费复机事件的载荷版本。
const assetAutoRenewalResumePayloadVersion = 1
// resumePayload 是自动续费复机事件的载荷,只携带尝试与资产标识,消费者按尝试 ID 认领执行权。
type resumePayload struct {
AttemptID uint `json:"attempt_id"`
AssetType string `json:"asset_type"`
AssetID uint `json:"asset_id"`
}
// AppendResumeRequested 在续费事务内幂等写入复机事件。
//
// 事件 ID 由尝试记录 ID 派生:续费成功与复机状态同事务写入,重复投递不会创建第二个事件
// ENG-OUTBOX-001。调用方必须已确认可复机条件成立本函数不做条件判定。
func AppendResumeRequested(ctx context.Context, tx *gorm.DB, repository *outbox.Repository, attempt *model.AssetAutoRenewalAttempt) error {
if repository == nil {
return gorm.ErrInvalidDB
}
if attempt == nil || attempt.ID == 0 {
return gorm.ErrInvalidData
}
value := strconv.FormatUint(uint64(attempt.ID), 10)
_, err := repository.AppendIdempotent(ctx, tx, outbox.Envelope{
EventID: outboxid.Stable(constants.OutboxEventTypeAssetAutoRenewalResumeRequested+":", value),
EventType: constants.OutboxEventTypeAssetAutoRenewalResumeRequested,
PayloadVersion: assetAutoRenewalResumePayloadVersion,
AggregateType: "asset_auto_renewal_attempt",
AggregateID: value,
ResourceType: attempt.AssetType,
ResourceID: strconv.FormatUint(uint64(attempt.AssetID), 10),
BusinessKey: constants.OutboxEventTypeAssetAutoRenewalResumeRequested + ":" + value,
Payload: resumePayload{
AttemptID: attempt.ID, AssetType: attempt.AssetType, AssetID: attempt.AssetID,
},
})
return err
}
// ResumeConsumer 把自动续费复机事件转成一次复机动作。
type ResumeConsumer struct {
service *Service
}
// NewResumeConsumer 创建自动续费复机事件消费者。
func NewResumeConsumer(service *Service) *ResumeConsumer {
return &ResumeConsumer{service: service}
}
// Consume 按尝试记录认领执行权后执行复机;重复投递由认领字段兜住,不会产生第二次外部调用。
func (c *ResumeConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error {
if c == nil || c.service == nil {
return errors.New(errors.CodeServiceUnavailable, "自动续费复机执行能力未配置")
}
if envelope.EventType != constants.OutboxEventTypeAssetAutoRenewalResumeRequested {
return outbox.Permanent(gorm.ErrInvalidData)
}
if envelope.PayloadVersion != assetAutoRenewalResumePayloadVersion {
return outbox.Permanent(errors.New(errors.CodeInvalidParam, "自动续费复机事件载荷版本不受支持"))
}
var payload resumePayload
if err := sonic.Unmarshal(envelope.Payload, &payload); err != nil {
return outbox.Permanent(errors.Wrap(errors.CodeInvalidParam, err, "自动续费复机事件载荷格式错误"))
}
if payload.AttemptID == 0 {
return outbox.Permanent(errors.New(errors.CodeInvalidParam, "自动续费复机事件载荷不完整"))
}
// 消费者不经过计划任务入口必须自带操作者与来源否则失败审计会因审计上下文缺失被拒fail-closed
ctx = auditcontext.With(ctx, auditcontext.Context{
ActorKind: constants.AuditActorSystemTask, ActorID: constants.OutboxEventTypeAssetAutoRenewalResumeRequested,
ActorName: "资产钱包自动续费复机结果消费者", Source: constants.AuditSourceWorker,
CorrelationID: envelope.CorrelationID, ParentEventID: envelope.EventID,
})
return c.service.ExecuteResume(ctx, payload.AttemptID)
}
// ExecuteResume 认领并执行一次自动续费复机,回写尝试记录的复机状态、外部交互号与失败原因。
//
// 「回写复机终态 + 投递失败通知 + 失败审计」在同一个短事务内闭合:任一失败整体回滚,
// 记录退回「已投递且已提交」,由恢复扫描按只读查询继续收敛,因此通知不会因一次写入抖动而永久丢失。
// 复机失败或结果未知时绝不回滚续费事实:订单、套餐生效与钱包扣款保持已提交状态。
func (s *Service) ExecuteResume(ctx context.Context, attemptID uint) error {
if s.resume == nil {
return errors.New(errors.CodeServiceUnavailable, "自动续费复机执行端口未配置")
}
attempt, err := s.attemptStore.Load(ctx, attemptID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil
}
return errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费尝试记录失败")
}
if attempt.ResumeStatus != constants.AssetAutoRenewalResumeStatusRequested {
// 已收敛或未投递复机:重复投递与非复机尝试都按幂等结束。
return nil
}
claimed, err := s.attemptStore.ClaimResumeSubmission(ctx, attemptID, s.now())
if err != nil {
return err
}
if !claimed {
// 认领已被占用:可能是并发重复投递,也可能是上次「已调用但未回写」的进程中断。
// 两种情况都不得再次调用运营商,留给恢复扫描按只读查询收敛。
s.logger.Info("自动续费复机已被并发执行,跳过重复调用", zap.Uint("attempt_id", attemptID))
return nil
}
outcome, resumeErr := s.resume.ResumeAssetForAutoRenewal(ctx, attempt.AssetType, attempt.AssetID)
status := constants.AssetAutoRenewalResumeStatusUnknown
reason := outcome.SafeReason
switch {
case !outcome.Applied:
// 判定在执行时已不成立:按跳过记录,不通知,也不改写任何续费事实。
status = constants.AssetAutoRenewalResumeStatusSkipped
reason = ""
case outcome.Result == constants.AuditResultSuccess:
status = constants.AssetAutoRenewalResumeStatusSucceeded
reason = ""
case outcome.Result == constants.AuditResultFailed:
status = constants.AssetAutoRenewalResumeStatusFailed
reason = resumeFailureDetail(outcome.SafeReason)
default:
status = constants.AssetAutoRenewalResumeStatusUnknown
reason = resumeFailureDetail(outcome.SafeReason)
}
if err := s.finalizeResumeOutcome(ctx, attempt, status, reason, outcome.IntegrationID); err != nil {
return err
}
if resumeErr != nil {
s.logger.Warn("自动续费复机执行未确认完成",
zap.Uint("attempt_id", attemptID), zap.String("result", outcome.Result), zap.Error(resumeErr))
}
return nil
}
// finalizeResumeOutcome 在同一短事务内回写复机终态;确认失败时同事务投递通知并写失败审计。
func (s *Service) finalizeResumeOutcome(ctx context.Context, attempt *model.AssetAutoRenewalAttempt, status int, reason, integrationID string) error {
expected := []int{constants.AssetAutoRenewalResumeStatusRequested}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
updated, err := s.attemptStore.MarkResumeOutcomeInTx(ctx, tx, attempt.ID, expected, status, integrationID, reason)
if err != nil {
return err
}
if !updated {
// 已被并发收敛:不重复投递通知与审计。
s.logger.Info("自动续费复机结果已被并发收敛,跳过通知与审计", zap.Uint("attempt_id", attempt.ID))
return nil
}
if status != constants.AssetAutoRenewalResumeStatusFailed {
return nil
}
if err := 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: constants.AssetAutoRenewalFailureResumeFailed,
PackageName: s.packageName(ctx, attempt.RenewPackageID), FinalExpiresAt: attempt.FinalExpiresAt,
}); err != nil {
return err
}
return s.appendResumeFailureAudit(ctx, tx, attempt, reason)
})
}
// appendResumeFailureAudit 在复机失败终态事务内写统一审计,主资源为本次尝试记录。
func (s *Service) appendResumeFailureAudit(ctx context.Context, tx *gorm.DB, attempt *model.AssetAutoRenewalAttempt, reason string) error {
if s.auditWriter == nil {
return errors.New(errors.CodeInvalidStatus, "自动续费统一审计接缝未配置")
}
attemptID := strconv.FormatUint(uint64(attempt.ID), 10)
return s.auditWriter.Append(ctx, tx, audit.AppendInput{
ActionCode: constants.AuditActionAssetAutoRenewalFailed, Summary: "资产钱包自动续费复机失败",
ScopeType: constants.AuditScopePlatform, Result: constants.AuditResultFailed,
ErrorSummary: reason,
CorrelationID: attemptID,
Metadata: map[string]any{
"asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
"failure_kind": constants.AssetAutoRenewalFailureResumeFailed,
},
Resources: []audit.ResourceInput{{
Type: constants.AuditResourceAssetAutoRenewalAttempt, ID: &attemptID, Key: attemptID,
DisplayName: "自动续费尝试 " + attemptID,
Relation: constants.AuditResourceRelationPrimary,
Role: constants.AuditResourceRoleAssetAutoRenewalAttemptTarget,
IdentitySnapshot: map[string]any{
"id": attempt.ID, "asset_type": attempt.AssetType, "asset_id": attempt.AssetID,
"trigger_date": formatShanghaiDate(&attempt.TriggerDate, s.now()),
"resume_status": constants.AssetAutoRenewalResumeStatusFailed,
"failure_reason": constants.AssetAutoRenewalFailureResumeFailed,
},
BeforeData: map[string]any{"resume_status": constants.AssetAutoRenewalResumeStatusRequested},
AfterData: map[string]any{"resume_status": constants.AssetAutoRenewalResumeStatusFailed},
SubjectVisibility: constants.AuditSubjectInternalOnly,
}},
})
}
// resumeFailureDetail 组装可安全展示的复机失败原因,不写渠道报文原文。
func resumeFailureDetail(safeReason string) string {
if safeReason == "" {
return "复机结果确认为失败"
}
return safeReason
}