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