Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 38s
- 字段 outbound_at → first_purchase_at,记录表 tb_asset_outbound_record → tb_asset_first_purchase_record,接口 /outbound-records → /first-purchase-records,筛选参数与导出列同步更名 - 换货后的新资产不因换货写入,在当前世代真正支付套餐订单时才记录;记录来源只保留 order / backfill,去掉换货单字段 - 迁移 000266 重命名列、表、索引与约束;同步 Spec、设计、任务与前端接口变更说明 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
232 lines
10 KiB
Go
232 lines
10 KiB
Go
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
|
||
}
|