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