Files
junhong_cmp_fiber/internal/application/packagesilence/extension_execute.go
邹进 909f4d0ec2
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 8m39s
feat: 实名即生效套餐沉默期、延期办理与资产出库时间
- 迁移 000259~000265:套餐记录沉默期字段与扫描索引、套餐默认沉默期、延期政策/流水/订单/批次/明细 5 张表、默认政策与系统配置(总开关默认关闭)、企微审批白名单、卡与设备出库时间及出库记录
- 沉默期设置(订单发放、自动购包、钱包续费)、后台购买时调整(超默认值按政策免审或企微审批)、统一判定与激活闸门、排队与孤儿扫描跳过过期套餐、过期套餐禁止退款、每 10 分钟扫描
- 总开关专用接口与打开前 4 项检查;延期政策;延期资格与计费;H5 自助与后台二维码在线支付(商户池路由)、支付回调与查单恢复、线下登记、特殊/批量延期、异常重试与原路退款;沉默期审批场景与终态处理
- 换货沉默期流水与出库时间;卡/设备列表、详情、导出与出库记录接口;延期订单与即将过期套餐列表及导出;财务流水纳入延期支付
- 沉默期与出库时间回填工具 cmd/silence-backfill;前端接口变更说明

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-27 16:32:58 +09:00

232 lines
10 KiB
Go
Raw 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(outboundTable(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
}