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:
222
internal/application/assetautorenewal/event.go
Normal file
222
internal/application/assetautorenewal/event.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user