Files
junhong_cmp_fiber/internal/application/packagesilence/setting.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

258 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"
"strings"
"time"
"github.com/google/uuid"
"gorm.io/gorm"
approvalapp "github.com/break/junhong_cmp_fiber/internal/application/approval"
packagedomain "github.com/break/junhong_cmp_fiber/internal/domain/package"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"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"
)
// PermissionChecker 校验后台账号权限点(由权限服务实现)。
type PermissionChecker interface {
CheckPermission(ctx context.Context, userID uint, permCode string, platform string) (bool, error)
}
// Setter 在订单支付成功、创建套餐记录的同一事务内设置沉默期(技术方案 §4),并处理后台购买时调整。
type Setter struct {
db *gorm.DB
audit *audit.Writer
approval approvalapp.Port
perms PermissionChecker
now func() time.Time
}
// NewSetter 创建沉默期设置用例。
func NewSetter(db *gorm.DB, writer *audit.Writer) *Setter {
return &Setter{db: db, audit: writer, now: time.Now}
}
// SetApprovalPort 注入审批创建接缝(购买时调整超默认值需审批时使用)。
func (s *Setter) SetApprovalPort(port approvalapp.Port) {
s.approval = port
}
// SetPermissionChecker 注入权限校验。
func (s *Setter) SetPermissionChecker(checker PermissionChecker) {
s.perms = checker
}
// PurchaseOverride 是后台购买时由有权限运营设置的本次沉默期。
// 无人参与的路径(自动购包、钱包自动续费等)不会携带,只用默认值。
type PurchaseOverride struct {
Days int
Reason string
OperatorID uint
submitter approvalSubmitter
// needApproval 表示本次订单中至少一笔套餐的设置值超过默认值且未免审批,preparation 已在事务前完成。
needApproval bool
preparation approvalapp.Preparation
exempt map[uint]bool // 套餐 ID → 政策是否勾选 purchase_adjust 免审批
defaults map[uint]int // 套餐 ID → 默认沉默期
}
type overrideContextKey struct{}
// WithPurchaseOverride 把购买时调整值放入上下文,由支付成功发放套餐时读取。
func WithPurchaseOverride(ctx context.Context, override *PurchaseOverride) context.Context {
if override == nil {
return ctx
}
return context.WithValue(ctx, overrideContextKey{}, override)
}
func overrideFromContext(ctx context.Context) *PurchaseOverride {
value, _ := ctx.Value(overrideContextKey{}).(*PurchaseOverride)
return value
}
// PrepareOverride 在下单事务前校验购买时调整:权限、天数、原因,并在需要审批时提前完成审批渠道检查。
// days 为空表示不调整。
func (s *Setter) PrepareOverride(ctx context.Context, operatorID uint, days *int, reason string, packages []*model.Package) (*PurchaseOverride, error) {
if days == nil {
return nil, nil
}
if s.perms == nil {
return nil, errors.New(errors.CodeSilenceOverrideForbidden)
}
allowed, err := s.perms.CheckPermission(ctx, operatorID, constants.PermissionPackageSilenceOverride, constants.PlatformWeb)
if err != nil {
return nil, err
}
if !allowed {
return nil, errors.New(errors.CodeSilenceOverrideForbidden)
}
if *days < constants.SilencePackageDaysMin || *days > constants.SilencePurchaseAdjustDaysMax {
return nil, errors.New(errors.CodeSilencePeriodOutOfRange, "本次沉默期至少 1 天")
}
reason = strings.TrimSpace(reason)
if reason == "" {
return nil, errors.New(errors.CodeInvalidParam, "调整沉默期必须填写原因")
}
override := &PurchaseOverride{
Days: *days, Reason: truncate(reason, 500), OperatorID: operatorID,
exempt: map[uint]bool{}, defaults: map[uint]int{},
}
now := s.now()
for _, pkg := range packages {
if pkg == nil || pkg.SilencePeriodDays == nil || pkg.PackageType == constants.PackageTypeAddon {
continue
}
override.defaults[pkg.ID] = *pkg.SilencePeriodDays
if *days <= *pkg.SilencePeriodDays {
continue
}
match, err := matchPolicy(ctx, s.db, pkg.ID, pkg.SeriesID, now)
if err != nil {
return nil, err
}
if packagedomain.IsSilenceApprovalExempt(match.ExemptTypes(), constants.SilenceExtensionTypePurchaseAdjust) {
override.exempt[pkg.ID] = true
continue
}
override.needApproval = true
}
if override.needApproval {
submitter, err := loadSubmitter(ctx, s.db, operatorID)
if err != nil {
return nil, err
}
preparation, err := prepareApproval(ctx, s.approval, operatorID, "silence-adjust-"+uuid.NewString())
if err != nil {
return nil, err
}
override.submitter = submitter
override.preparation = preparation
}
return override, nil
}
// Eligible 判断新建套餐记录是否进入沉默期(技术方案 §1)。
func Eligible(usage *model.PackageUsage, pkg *model.Package) bool {
return usage != nil && pkg != nil && usage.MasterUsageID == nil && usage.PendingRealnameActivation &&
usage.Status == constants.PackageUsageStatusPending &&
usage.ExpiryBaseSnapshot == constants.PackageExpiryBaseFromActivation && pkg.SilencePeriodDays != nil
}
// ApplyOnUsageCreatedInTx 为刚创建的套餐记录设置沉默期并写流水(总开关关闭也写,只记录)。
// paidAt 为订单支付成功时间;trigger 为订单支付 / 自动购包等来源。
func (s *Setter) ApplyOnUsageCreatedInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, paidAt time.Time, trigger string) error {
if s == nil || !Eligible(usage, pkg) {
return nil
}
defaultDays := *pkg.SilencePeriodDays
days := defaultDays
adjusted := false
needApproval := false
reason := ""
override := overrideFromContext(ctx)
if override != nil {
reason = override.Reason
switch {
case override.Days <= defaultDays || override.exempt[pkg.ID]:
days = override.Days
adjusted = override.Days != defaultDays
case override.needApproval:
needApproval = true
}
}
deadline := packagedomain.SilenceDeadline(paidAt, days)
start := paidAt
values := map[string]any{
"silence_status": constants.SilenceStatusWaiting, "silence_start_at": start,
"silence_period_days": days, "silence_deadline_at": deadline, "silence_adjust_pending": needApproval,
}
if err := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("id = ? AND silence_status IS NULL", usage.ID).
Updates(values).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "设置套餐沉默期失败")
}
usage.SilenceStatus = stringPtr(constants.SilenceStatusWaiting)
usage.SilenceStartAt = &start
usage.SilencePeriodDays = &days
usage.SilenceDeadlineAt = &deadline
usage.SilenceAdjustPending = needApproval
if err := writeRecord(ctx, tx, s.audit, recordInput{
Usage: usage, EventType: constants.SilenceRecordEventSet, Trigger: trigger,
DefaultDays: &defaultDays, IsAdjusted: adjusted, ChangeDays: days - defaultDays, Reason: reason,
Extra: map[string]any{"requested_days": overrideDays(override), "adjust_pending": needApproval},
AuditAction: constants.AuditActionUsageSilenceSet, AuditSummary: "设置套餐沉默期",
}); err != nil {
return err
}
if needApproval {
return s.submitAdjustInTx(ctx, tx, usage, pkg, override, defaultDays)
}
return nil
}
func overrideDays(override *PurchaseOverride) any {
if override == nil {
return nil
}
return override.Days
}
// submitAdjustInTx 为超默认值的调整创建批次(path = adjust)与企微审批,审批期间套餐不过期。
func (s *Setter) submitAdjustInTx(ctx context.Context, tx *gorm.DB, usage *model.PackageUsage, pkg *model.Package, override *PurchaseOverride, defaultDays int) error {
now := s.now()
requested := override.Days
batch := &model.PackageSilenceExtensionBatch{
BatchNo: generateNo(constants.SilenceExtensionBatchNoPrefix, now), Path: constants.SilencePathAdjust,
ExtensionType: constants.SilenceExtensionTypePurchaseAdjust, ApprovalRequired: true,
Reason: override.Reason, AdjustPackageUsageID: &usage.ID, AdjustDefaultDays: &defaultDays,
AdjustRequestedDays: &requested, RequestedBy: override.OperatorID, RequestedByType: constants.SilenceOperatorAccount,
RequestedAt: now, Status: constants.SilenceBatchStatusPendingApproval, RequestedCount: 1,
}
if err := postgres.NewPackageSilenceBatchStore(tx).Create(ctx, batch); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建沉默期调整申请失败")
}
identifier, err := assetIdentifier(ctx, tx, usage)
if err != nil {
return err
}
instanceID, err := createApprovalInTx(ctx, tx, s.approval, override.preparation, batch, override.submitter, ApplyTypePurchaseAdjust, map[string]any{
ApprovalFieldAssetIdentifier: identifier, ApprovalFieldPackageName: pkg.PackageName,
ApprovalFieldOrderNo: usage.OrderNo, ApprovalFieldDefaultDays: defaultDays,
ApprovalFieldRequestedDays: requested, ApprovalFieldReason: override.Reason,
ApprovalFieldOriginalDeadline: formatDate(usage.SilenceDeadlineAt),
})
if err != nil {
return err
}
return writeRecord(ctx, tx, s.audit, recordInput{
Usage: usage, EventType: constants.SilenceRecordEventAdjust, Trigger: constants.SilenceTriggerOrderPaid,
BeforeStatus: usage.SilenceStatus, BeforeDeadline: usage.SilenceDeadlineAt, DefaultDays: &defaultDays,
ChangeDays: requested - defaultDays, Reason: override.Reason, ApprovalID: &instanceID, BatchID: &batch.ID,
Extra: map[string]any{"requested_days": requested, "stage": "requested"},
AuditAction: constants.AuditActionUsageSilenceAdjustRequested, AuditSummary: "申请购买时调整沉默期",
})
}
// assetIdentifier 返回套餐记录当前载体的标识(卡 ICCID / 设备虚拟号或 IMEI)。
func assetIdentifier(ctx context.Context, db *gorm.DB, usage *model.PackageUsage) (string, error) {
if usage.DeviceID > 0 {
var device model.Device
if err := db.WithContext(ctx).Unscoped().Select("id", "virtual_no", "imei").Where("id = ?", usage.DeviceID).First(&device).Error; err != nil {
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询设备标识失败")
}
if device.VirtualNo != "" {
return device.VirtualNo, nil
}
return device.IMEI, nil
}
var card model.IotCard
if err := db.WithContext(ctx).Unscoped().Select("id", "iccid").Where("id = ?", usage.IotCardID).First(&card).Error; err != nil {
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询卡标识失败")
}
return card.ICCID, nil
}