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 }