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

161 lines
7.3 KiB
Go

package packagesilence
import (
"context"
"time"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"github.com/break/junhong_cmp_fiber/internal/infrastructure/audit"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"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"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
)
// SwitchService 切换沉默期总开关(技术方案 §3.7):仅超级管理员;打开前做 4 项检查;每次切换写审计。
type SwitchService struct {
db *gorm.DB
redis *redis.Client
sw *Switch
audit *audit.Writer
now func() time.Time
}
// NewSwitchService 创建总开关切换用例。
func NewSwitchService(db *gorm.DB, redisClient *redis.Client, sw *Switch, writer *audit.Writer) *SwitchService {
return &SwitchService{db: db, redis: redisClient, sw: sw, audit: writer, now: time.Now}
}
// Status 返回总开关当前值与打开前检查结果(页面展示用)。
func (s *SwitchService) Status(ctx context.Context) (*dto.SilenceSwitchResponse, error) {
items, err := s.Precheck(ctx)
if err != nil {
return nil, err
}
return &dto.SilenceSwitchResponse{Enabled: s.sw.Enabled(ctx), Precheck: items, PrecheckPassed: precheckPassed(items)}, nil
}
// Precheck 执行打开前检查,返回每一项的结果(未满足项带数量)。
func (s *SwitchService) Precheck(ctx context.Context) ([]dto.SilenceSwitchPrecheckItem, error) {
db := s.db.WithContext(ctx)
var unconfigured int64
if err := db.Model(&model.Package{}).Where(postgres.SilenceUnconfiguredPackageCondition("tb_package")).Count(&unconfigured).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计未配置默认沉默期的套餐失败")
}
var unbackfilled int64
if err := db.Model(&model.PackageUsage{}).
Where("status = ? AND pending_realname_activation = ? AND master_usage_id IS NULL AND expiry_base_snapshot = ? AND silence_status IS NULL",
constants.PackageUsageStatusPending, true, constants.PackageExpiryBaseFromActivation).
Count(&unbackfilled).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计未回填沉默期的套餐记录失败")
}
now := s.now()
var defaultPolicies int64
if err := db.Model(&model.PackageSilenceExtensionPolicy{}).
Where("scope_type = ? AND status = ? AND effective_from <= ? AND (effective_to IS NULL OR effective_to > ?)",
constants.SilencePolicyScopeAll, constants.SilencePolicyStatusEnabled, now, now).
Count(&defaultPolicies).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询全部套餐延期政策失败")
}
var scenes int64
if err := db.Model(&model.WeComApprovalScene{}).
Where("business_type = ? AND status = ? AND template_id <> ''", constants.ApprovalBusinessTypePackageSilence, constants.StatusEnabled).
Count(&scenes).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询沉默期审批场景失败")
}
return []dto.SilenceSwitchPrecheckItem{
{Code: constants.SilenceSwitchPrecheckUnconfiguredPackages, Name: "未配置默认沉默期的套餐", Passed: unconfigured == 0, Count: unconfigured},
{Code: constants.SilenceSwitchPrecheckUnbackfilledUsages, Name: "未回填沉默期的待实名套餐记录", Passed: unbackfilled == 0, Count: unbackfilled},
{Code: constants.SilenceSwitchPrecheckDefaultPolicyMissing, Name: "启用中的全部套餐延期政策", Passed: defaultPolicies > 0, Count: defaultPolicies},
{Code: constants.SilenceSwitchPrecheckApprovalSceneMissing, Name: "沉默期企微审批场景已配置并启用", Passed: scenes > 0, Count: scenes},
}, nil
}
func precheckPassed(items []dto.SilenceSwitchPrecheckItem) bool {
for _, item := range items {
if !item.Passed {
return false
}
}
return true
}
// Toggle 打开或关闭总开关。打开前检查未通过时拒绝,返回 silence_switch_precheck_failed 与未满足项。
// 关闭(应急)不做检查:规则暂停、数据保留,已发生的支付回调与审批回调照常处理。
func (s *SwitchService) Toggle(ctx context.Context, enable bool) (*dto.SilenceSwitchResponse, error) {
if middleware.GetUserTypeFromContext(ctx) != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden, "只有超级管理员可以切换沉默期总开关")
}
operatorID := middleware.GetUserIDFromContext(ctx)
items, err := s.Precheck(ctx)
if err != nil {
return nil, err
}
if enable && !precheckPassed(items) {
failed := make([]dto.SilenceSwitchPrecheckItem, 0, len(items))
for _, item := range items {
if !item.Passed {
failed = append(failed, item)
}
}
return nil, errors.NewWithData(errors.CodeSilenceSwitchPrecheckFailed, map[string]any{"failed_items": failed},
"沉默期总开关打开前检查未通过")
}
value := "false"
if enable {
value = "true"
}
now := s.now()
var before string
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Exec("SELECT pg_advisory_xact_lock(hashtext(?))", constants.SystemConfigSilenceEnabled).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "锁定沉默期总开关失败")
}
var existing model.SystemConfig
findErr := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("config_key = ?", constants.SystemConfigSilenceEnabled).First(&existing).Error
switch {
case findErr == gorm.ErrRecordNotFound:
before = "false"
existing = model.SystemConfig{
ConfigKey: constants.SystemConfigSilenceEnabled, ConfigValue: value, ValueType: constants.SystemConfigTypeBool,
Module: constants.SystemConfigModulePackage, Description: "沉默期总开关", IsReadonly: true,
Creator: operatorID, Updater: operatorID, CreatedAt: now, UpdatedAt: now,
}
if err := tx.Create(&existing).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "写入沉默期总开关失败")
}
case findErr != nil:
return errors.Wrap(errors.CodeDatabaseError, findErr, "查询沉默期总开关失败")
default:
before = existing.ConfigValue
if err := tx.Model(&model.SystemConfig{}).Where("id = ?", existing.ID).
Updates(map[string]any{"config_value": value, "updater": operatorID, "updated_at": now}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新沉默期总开关失败")
}
}
if s.audit == nil {
return nil
}
id := constants.SystemConfigSilenceEnabled
return s.audit.AppendSilenceResource(ctx, tx, constants.AuditActionPackageSilenceSwitchChanged, "切换沉默期总开关",
audit.ResourceInput{
Type: constants.AuditResourceSystemConfig, ID: &id, Key: id, DisplayName: "沉默期总开关",
Role: constants.AuditResourceRoleConfig, IdentitySnapshot: map[string]any{"config_key": id, "module": constants.SystemConfigModulePackage},
BeforeData: map[string]any{"value": before}, AfterData: map[string]any{"value": value},
}, map[string]any{"precheck": items, "operator_id": operatorID})
})
if err != nil {
return nil, err
}
if s.redis != nil {
_ = s.redis.Del(context.WithoutCancel(ctx), constants.RedisSystemConfigKey(constants.SystemConfigSilenceEnabled)).Err()
}
s.sw.Invalidate()
return &dto.SilenceSwitchResponse{Enabled: enable, Precheck: items, PrecheckPassed: precheckPassed(items)}, nil
}