package assetautorenewal import ( "context" "sort" "time" "go.uber.org/zap" "gorm.io/gorm" "github.com/break/junhong_cmp_fiber/internal/infrastructure/audit" "github.com/break/junhong_cmp_fiber/internal/model" "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/middleware" ) // ConfigView 是自动续费配置的读取视图。 type ConfigView struct { Enabled int `json:"enabled"` Scope string `json:"scope"` PackageIDs []uint `json:"package_ids"` DaysBeforeExpiry int `json:"days_before_expiry"` ConfigVersion int64 `json:"config_version"` Updater uint `json:"updater"` UpdatedAt time.Time `json:"updated_at"` } // ConfigRequest 是保存自动续费配置的请求。 type ConfigRequest struct { Enabled int `json:"enabled"` Scope string `json:"scope"` PackageIDs []uint `json:"package_ids"` DaysBeforeExpiry int `json:"days_before_expiry"` } // GetConfig 读取唯一的自动续费配置;仅超级管理员与平台账号可见。 func (s *Service) GetConfig(ctx context.Context) (*ConfigView, error) { if _, err := requirePlatformOperator(ctx); err != nil { return nil, err } config, err := s.configStore.Get(ctx) if err != nil { if err == gorm.ErrRecordNotFound { return nil, errors.New(errors.CodeNotFound, "自动续费配置不存在") } return nil, errors.Wrap(errors.CodeDatabaseError, err, "读取自动续费配置失败") } return toConfigView(config), nil } // SaveConfig 保存自动续费配置:单行事务锁串行化、事务内自增配置版本,并与审计同事务写入。 // // 保存只影响后续扫描:已产生的尝试记录保留触发时的配置版本快照,不重算。 func (s *Service) SaveConfig(ctx context.Context, request ConfigRequest) (*ConfigView, error) { operatorID, err := requirePlatformOperator(ctx) if err != nil { return nil, err } packageIDs, err := normalizeConfigRequest(&request) if err != nil { return nil, err } if len(packageIDs) > 0 { if err := s.validateSellableMainPackages(ctx, packageIDs); err != nil { return nil, err } } if s.auditWriter == nil { return nil, errors.New(errors.CodeInvalidStatus, "自动续费配置审计接缝未配置") } saved := &model.AssetAutoRenewalConfig{} err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { current, lockErr := s.configStore.LockInTx(ctx, tx) if lockErr != nil { if lockErr == gorm.ErrRecordNotFound { return errors.New(errors.CodeNotFound, "自动续费配置不存在") } return errors.Wrap(errors.CodeDatabaseError, lockErr, "锁定自动续费配置失败") } before := configSnapshot(current) saved.Enabled = request.Enabled saved.Scope = request.Scope saved.PackageIDs = model.UintJSONBArray(packageIDs) saved.DaysBeforeExpiry = request.DaysBeforeExpiry saved.ConfigVersion = current.ConfigVersion + 1 saved.Creator = current.Creator saved.Updater = operatorID if saveErr := s.configStore.SaveInTx(ctx, tx, saved, operatorID); saveErr != nil { return errors.Wrap(errors.CodeDatabaseError, saveErr, "保存自动续费配置失败") } if auditErr := s.auditWriter.WriteAssetAutoRenewalConfigChange(ctx, tx, audit.AssetAutoRenewalConfigAudit{ OperatorID: operatorID, OperationType: constants.AuditOperationAssetAutoRenewalConfigUpdate, Description: "保存资产钱包自动续费配置", BeforeData: before, AfterData: configSnapshot(saved), RequestID: derefString(middleware.GetRequestIDFromContext(ctx)), CorrelationID: derefString(middleware.GetRequestIDFromContext(ctx)), }); auditErr != nil { return auditErr } return nil }) if err != nil { return nil, err } view := &ConfigView{ Enabled: saved.Enabled, Scope: saved.Scope, PackageIDs: packageIDs, DaysBeforeExpiry: saved.DaysBeforeExpiry, ConfigVersion: saved.ConfigVersion, Updater: saved.Updater, UpdatedAt: s.now(), } s.logger.Info("资产钱包自动续费配置已保存", zap.Int("enabled", view.Enabled), zap.String("scope", view.Scope), zap.Int("days_before_expiry", view.DaysBeforeExpiry), zap.Int64("config_version", view.ConfigVersion)) return view, nil } // derefString 安全解引用可空字符串,供审计上下文可选字段复用。 func derefString(value *string) string { if value == nil { return "" } return *value } // requirePlatformOperator 复核调用者仅限超级管理员与平台账号,并返回其账号 ID。 // // 路由组已做粗粒度门禁,这里在业务边界再复核一次账号类型(ENG-AUTHZ-001): // 代理、企业与个人客户一律按「无权限或不存在」统一拒绝,不形成可枚举差异。 func requirePlatformOperator(ctx context.Context) (uint, error) { userType := middleware.GetUserTypeFromContext(ctx) if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform { return 0, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage) } operatorID := middleware.GetUserIDFromContext(ctx) if operatorID == 0 { return 0, errors.New(errors.CodeForbidden, constants.PlatformManagementForbiddenMessage) } return operatorID, nil } // normalizeConfigRequest 归一化并校验保存请求,返回去重升序的指定套餐集合。 func normalizeConfigRequest(request *ConfigRequest) ([]uint, error) { if request.Enabled != constants.AssetAutoRenewalConfigEnabledOff && request.Enabled != constants.AssetAutoRenewalConfigEnabledOn { return nil, errors.New(errors.CodeInvalidParam, "自动续费总开关取值非法") } if request.Scope != constants.AssetAutoRenewalScopeAll && request.Scope != constants.AssetAutoRenewalScopeSpecified { return nil, errors.New(errors.CodeInvalidParam, "自动续费适用范围取值非法") } if request.DaysBeforeExpiry < constants.AssetAutoRenewalMinDaysBeforeExpiry || request.DaysBeforeExpiry > constants.AssetAutoRenewalMaxDaysBeforeExpiry { return nil, errors.New(errors.CodeInvalidParam, "自动续费到期前天数必须在 1 至 90 之间") } seen := make(map[uint]struct{}, len(request.PackageIDs)) packageIDs := make([]uint, 0, len(request.PackageIDs)) for _, packageID := range request.PackageIDs { if packageID == 0 { return nil, errors.New(errors.CodeInvalidParam, "自动续费指定套餐包含无效 ID") } if _, exists := seen[packageID]; exists { continue } seen[packageID] = struct{}{} packageIDs = append(packageIDs, packageID) } sort.Slice(packageIDs, func(i, j int) bool { return packageIDs[i] < packageIDs[j] }) if request.Scope == constants.AssetAutoRenewalScopeSpecified && len(packageIDs) == 0 { return nil, errors.New(errors.CodeInvalidParam, "指定范围必须至少选择一个主套餐") } if request.Scope == constants.AssetAutoRenewalScopeAll { packageIDs = nil } return packageIDs, nil } // validateSellableMainPackages 校验指定集合只能选择当前可售主套餐。 // // 可售口径与购买校验的平台分支一致:套餐为正式套餐、全局启用且上架。 // 运行时不因后来下架而拒绝(交由续费豁免判定),因此下架只在此处拦截配置保存。 func (s *Service) validateSellableMainPackages(ctx context.Context, packageIDs []uint) error { packages, err := s.loadPackagesByIDs(ctx, packageIDs) if err != nil { return err } for _, packageID := range packageIDs { pkg, exists := packages[packageID] if !exists { return errors.New(errors.CodeInvalidParam, "指定套餐不存在") } if pkg.PackageType != constants.PackageTypeFormal { return errors.New(errors.CodeInvalidParam, "指定范围只能选择主套餐") } if pkg.Status != constants.StatusEnabled { return errors.New(errors.CodeInvalidParam, "指定套餐已禁用") } if pkg.ShelfStatus != constants.ShelfStatusOn { return errors.New(errors.CodeInvalidParam, "指定套餐已下架") } } return nil } // configSnapshot 生成配置前后值快照,字段口径固定,便于审计比对。 func configSnapshot(config *model.AssetAutoRenewalConfig) map[string]any { return map[string]any{ "enabled": config.Enabled, "scope": config.Scope, "package_ids": []uint(config.PackageIDs), "days_before_expiry": config.DaysBeforeExpiry, "config_version": config.ConfigVersion, } } func toConfigView(config *model.AssetAutoRenewalConfig) *ConfigView { return &ConfigView{ Enabled: config.Enabled, Scope: config.Scope, PackageIDs: []uint(config.PackageIDs), DaysBeforeExpiry: config.DaysBeforeExpiry, ConfigVersion: config.ConfigVersion, Updater: config.Updater, UpdatedAt: config.UpdatedAt, } }