package packagesilence import ( "context" "time" "gorm.io/gorm" "gorm.io/gorm/clause" packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" ) // executeKind 描述延期执行时的计数口径。 type executeKind struct { paid bool // 付费延期(在线与线下):受政策快照上限约束并计入付费累计天数 free bool // 免费延期:受免费次数约束并计数 } // executeOutcome 是一次延期执行结果。 type executeOutcome struct { executed bool failureCode string failureText string afterAt time.Time } // paidSnapshot 是付费延期执行时使用的政策快照上限。 type paidSnapshot struct { paidMaxMonths int singleMaxMonths *int freeLimit int } // executeExtensionInTx 执行一条延期明细(技术方案 §8.6),调用方在同一事务内: // 1. 锁载体、锁套餐记录并重读;2. 校验仍为过期、新截止晚于当天,付费另校验上限、免费校验次数; // 3. 更新套餐记录沉默期字段;4. 写明细状态、流水与审计。 // // 校验不通过时不改套餐记录,返回失败码由调用方把明细置为 exception(付费)或 failed(批量特殊延期)。 // 事务内不调用支付渠道、Gateway 或企微接口;以明细状态条件更新保证幂等。 func (s *ExtensionService) executeExtensionInTx(ctx context.Context, tx *gorm.DB, extension *model.PackageSilenceExtension, kind executeKind, snapshot paidSnapshot, fromStatuses []string) (executeOutcome, error) { now := s.now() var usage model.PackageUsage if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", extension.PackageUsageID).First(&usage).Error; err != nil { if err == gorm.ErrRecordNotFound { return executeOutcome{failureCode: constants.SilenceFailureUsageInvalid, failureText: "套餐记录不存在"}, nil } return executeOutcome{}, errors.Wrap(errors.CodeDatabaseError, err, "锁定套餐记录失败") } if err := lockCarrier(ctx, tx, &usage); err != nil { return executeOutcome{}, err } status := "" if usage.SilenceStatus != nil { status = *usage.SilenceStatus } if usage.Status != constants.PackageUsageStatusPending || usage.SilenceDeadlineAt == nil || !packagedomain.IsSilenceEffectivelyExpired(status, usage.SilenceDeadlineAt, usage.SilenceAdjustPending, now) { return executeOutcome{failureCode: constants.SilenceFailureNotExpired, failureText: "套餐不是沉默期已过期状态(已激活、已退款或未到期)"}, nil } before := *usage.SilenceDeadlineAt after := packagedomain.SilenceDeadline(before, extension.ExtensionDays) if !after.After(packagedomain.SilenceDeadline(now, 0)) { return executeOutcome{failureCode: constants.SilenceFailureDeadlineNotAfter, failureText: "延期后的截止日期不晚于今天"}, nil } if kind.paid { if snapshot.singleMaxMonths != nil && extension.Months > *snapshot.singleMaxMonths { return executeOutcome{failureCode: constants.SilenceFailureExceedsLimit, failureText: "超过单次付费延期月数上限"}, nil } if usage.SilencePaidExtendedDays/constants.SilenceDaysPerMonth+extension.Months > snapshot.paidMaxMonths { return executeOutcome{failureCode: constants.SilenceFailureExceedsLimit, failureText: "超过付费延期累计月数上限"}, nil } } if kind.free && usage.SilenceFreeExtensionUsed >= snapshot.freeLimit { return executeOutcome{failureCode: constants.SilenceFailureFreeUsedUp, failureText: "免费延期次数已用完"}, nil } updates := map[string]any{ "silence_status": constants.SilenceStatusWaiting, "silence_deadline_at": after, "silence_extended_days": gorm.Expr("silence_extended_days + ?", extension.ExtensionDays), "silence_extension_count": gorm.Expr("silence_extension_count + 1"), "updated_at": now, } if kind.paid { updates["silence_paid_extended_days"] = gorm.Expr("silence_paid_extended_days + ?", extension.ExtensionDays) } if kind.free { updates["silence_free_extension_used"] = gorm.Expr("silence_free_extension_used + 1") } result := tx.WithContext(ctx).Model(&model.PackageUsage{}). Where("id = ? AND status = ? AND silence_deadline_at = ?", usage.ID, constants.PackageUsageStatusPending, before). Updates(updates) if result.Error != nil { return executeOutcome{}, errors.Wrap(errors.CodeDatabaseError, result.Error, "更新套餐沉默期失败") } if result.RowsAffected != 1 { return executeOutcome{failureCode: constants.SilenceFailureUsageInvalid, failureText: "套餐沉默期已变化"}, nil } affected, err := postgres.NewPackageSilenceExtensionStore(tx).TransitionStatus(ctx, extension.ID, fromStatuses, constants.SilenceExtensionStatusCompleted, map[string]any{ "before_deadline_at": before, "after_deadline_at": after, "executed_at": now, "failure_code": "", "failure_summary": "", }) if err != nil { return executeOutcome{}, errors.Wrap(errors.CodeDatabaseError, err, "更新延期明细失败") } if affected != 1 { // 明细已被其他事件执行:回滚本次对套餐记录的更新,保证只执行一次。 return executeOutcome{}, errAlreadyExecuted } extension.Status = constants.SilenceExtensionStatusCompleted extension.BeforeDeadlineAt, extension.AfterDeadlineAt, extension.ExecutedAt = &before, &after, &now beforeStatus := stringPtr(status) usage.SilenceStatus = stringPtr(constants.SilenceStatusWaiting) usage.SilenceDeadlineAt = &after usage.SilenceExtendedDays += extension.ExtensionDays usage.SilenceExtensionCount++ if kind.paid { usage.SilencePaidExtendedDays += extension.ExtensionDays } if kind.free { usage.SilenceFreeExtensionUsed++ } batchID, extensionID := extension.BatchID, extension.ID if err := writeRecord(ctx, tx, s.audit, recordInput{ Usage: &usage, EventType: constants.SilenceRecordEventExtend, Trigger: constants.SilenceTriggerExtension, BeforeStatus: beforeStatus, BeforeDeadline: &before, ChangeDays: extension.ExtensionDays, BatchID: &batchID, ExtensionID: &extensionID, Reason: "沉默期延期生效", Extra: map[string]any{ "extension_type": extension.ExtensionType, "months": extension.Months, "amount": extension.Amount, "extension_order_id": extension.ExtensionOrderID, "paid": kind.paid, "free": kind.free, }, AuditAction: constants.AuditActionUsageSilenceExtensionApplied, AuditSummary: "沉默期延期生效", }); err != nil { return executeOutcome{}, err } return executeOutcome{executed: true, afterAt: after}, nil } // errAlreadyExecuted 表示明细已被并发事件执行,调用方按幂等成功处理。 var errAlreadyExecuted = errors.New(errors.CodeConflict, "延期明细已执行") func isAlreadyExecuted(err error) bool { return err == errAlreadyExecuted } // markExtensionFailedInTx 把明细置为 exception / failed 并写审计(不改套餐记录)。 func (s *ExtensionService) markExtensionFailedInTx(ctx context.Context, tx *gorm.DB, extension *model.PackageSilenceExtension, to string, fromStatuses []string, code, text string) error { affected, err := postgres.NewPackageSilenceExtensionStore(tx).TransitionStatus(ctx, extension.ID, fromStatuses, to, map[string]any{ "failure_code": code, "failure_summary": truncate(text, 500), }) if err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "更新延期明细失败状态失败") } if affected != 1 { return nil } extension.Status, extension.FailureCode, extension.FailureSummary = to, code, text if s.audit == nil { return nil } var usage model.PackageUsage if err := tx.WithContext(ctx).Where("id = ?", extension.PackageUsageID).First(&usage).Error; err != nil { return nil } return s.audit.AppendSilenceUsage(ctx, tx, auditInputForException(&usage, extension, code, text)) } // afterExecuted 在事务提交后按延期结果判定沉默期:资产在新截止内已实名则立即激活(或排队)。 func (s *ExtensionService) afterExecuted(ctx context.Context, usageIDs []uint) { for _, id := range usageIDs { if _, err := s.resolver.ResolveAndActivate(ctx, id, ResolveOptions{Trigger: constants.SilenceTriggerExtension, AllowExpire: s.sw.Enabled(ctx)}); err != nil { s.logger.Warn("延期生效后判定沉默期失败,等待扫描或实名接续") } } } // lockCarrier 锁载体行,避免与换货、实名、激活并发改变载体关系。 func lockCarrier(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage) error { assetType, assetID := usageAsset(usage) if assetID == 0 { return nil } var id uint if err := tx.WithContext(ctx).Table(firstPurchaseTable(assetType)).Clauses(clause.Locking{Strength: "UPDATE"}). Select("id").Where("id = ?", assetID).Scan(&id).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "锁定套餐载体失败") } return nil } // refreshBatchCountsInTx 按明细结果刷新批次计数与状态(部分失败不影响其他明细)。 func refreshBatchCountsInTx(ctx context.Context, tx *gorm.DB, batchID uint, now time.Time) error { type countRow struct { Status string Count int } var rows []countRow if err := tx.WithContext(ctx).Model(&model.PackageSilenceExtension{}).Select("status, COUNT(*) AS count"). Where("batch_id = ?", batchID).Group("status").Scan(&rows).Error; err != nil { return errors.Wrap(errors.CodeDatabaseError, err, "统计延期明细失败") } total, succeeded, failed, open := 0, 0, 0, 0 for _, row := range rows { total += row.Count switch row.Status { case constants.SilenceExtensionStatusCompleted: succeeded += row.Count case constants.SilenceExtensionStatusFailed, constants.SilenceExtensionStatusException, constants.SilenceExtensionStatusClosed, constants.SilenceExtensionStatusRejected: failed += row.Count default: open += row.Count } } values := map[string]any{"requested_count": total, "succeeded_count": succeeded, "failed_count": failed, "updated_at": now} if open == 0 { switch { case succeeded == total && total > 0: values["status"] = constants.SilenceBatchStatusCompleted case succeeded > 0: values["status"] = constants.SilenceBatchStatusPartiallyCompleted default: values["status"] = constants.SilenceBatchStatusFailed } values["completed_at"] = now } return tx.WithContext(ctx).Model(&model.PackageSilenceExtensionBatch{}). Where("id = ? AND status NOT IN ?", batchID, []string{constants.SilenceBatchStatusRejected, constants.SilenceBatchStatusClosed}). Updates(values).Error }