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 }