Files
junhong_cmp_fiber/internal/application/packagesilence/extension_execute.go
邹进 62ec94adec
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 38s
refactor: 出库时间更名为首次购买套餐时间,换货新资产不再按换货完成时间记录
- 字段 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>
2026-10-27 16:47:02 +09:00

232 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}