Files
sub2api/backend/internal/service/setting_features.go
T

1185 lines
41 KiB
Go
Raw Normal View History

package service
import (
"context"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math"
"strconv"
"strings"
"time"
)
// IsRegistrationEnabled 检查是否开放注册
func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled)
if err != nil {
// 安全默认:如果设置不存在或查询出错,默认关闭注册
return false
}
return value == "true"
}
// IsEmailVerifyEnabled 检查是否开启邮件验证
func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled)
if err != nil {
return false
}
return value == "true"
}
// IsRegistrationEmailDomainQuotaEnabled 检查白名单非空时是否放行非白名单域名限量注册。
// 安全默认:设置缺失或查询出错时按关闭处理(保持白名单严格模式)。
func (s *SettingService) IsRegistrationEmailDomainQuotaEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEmailDomainQuotaEnabled)
if err != nil {
return false
}
return value == "true"
}
// GetRegistrationEmailSuffixWhitelist returns normalized registration email suffix whitelist.
func (s *SettingService) GetRegistrationEmailSuffixWhitelist(ctx context.Context) []string {
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEmailSuffixWhitelist)
if err != nil {
return []string{}
}
return ParseRegistrationEmailSuffixWhitelist(value)
}
// IsPromoCodeEnabled 检查是否启用优惠码功能
func (s *SettingService) IsPromoCodeEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyPromoCodeEnabled)
if err != nil {
return true // 默认启用
}
return value != "false"
}
// IsInvitationCodeEnabled 检查是否启用邀请码注册功能
func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyInvitationCodeEnabled)
if err != nil {
return false // 默认关闭
}
return value == "true"
}
// GetCustomMenuItemsRaw returns the raw JSON string of custom_menu_items setting.
func (s *SettingService) GetCustomMenuItemsRaw(ctx context.Context) string {
value, err := s.settingRepo.GetValue(ctx, SettingKeyCustomMenuItems)
if err != nil {
return "[]"
}
return value
}
// IsAffiliateEnabled 检查是否启用邀请返利功能(总开关)
func (s *SettingService) IsAffiliateEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateEnabled)
if err != nil {
return false // 默认关闭
}
return value == "true"
}
// IsAffiliateAdminRechargeEnabled reports whether admin balance
// deposits should participate in the affiliate rebate program.
func (s *SettingService) IsAffiliateAdminRechargeEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateAdminRechargeEnabled)
if err != nil {
return AdminRechargeRebateEnabledDefault
}
return value == "true"
}
// GetAffiliateRebateRatePercent 读取并 clamp 全局返利比例。
// 解析失败、缺失或越界都回退到 AffiliateRebateRateDefault — 该比例从不抛错,
// 调用方只关心一个可用的数值。
func (s *SettingService) GetAffiliateRebateRatePercent(ctx context.Context) float64 {
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateRate)
if err != nil {
return AffiliateRebateRateDefault
}
rate, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
if err != nil || math.IsNaN(rate) || math.IsInf(rate, 0) {
return AffiliateRebateRateDefault
}
return clampAffiliateRebateRate(rate)
}
// GetAffiliateRebateFreezeHours 返回返利冻结期(小时)。
// 返回 0 表示不冻结(向后兼容)。
func (s *SettingService) GetAffiliateRebateFreezeHours(ctx context.Context) int {
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateFreezeHours)
if err != nil {
return AffiliateRebateFreezeHoursDefault
}
hours, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || hours < 0 {
return AffiliateRebateFreezeHoursDefault
}
if hours > AffiliateRebateFreezeHoursMax {
return AffiliateRebateFreezeHoursMax
}
return hours
}
// GetAffiliateRebateDurationDays 返回返利有效期(天)。
// 返回 0 表示永久有效。
func (s *SettingService) GetAffiliateRebateDurationDays(ctx context.Context) int {
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebateDurationDays)
if err != nil {
return AffiliateRebateDurationDaysDefault
}
days, err := strconv.Atoi(strings.TrimSpace(raw))
if err != nil || days < 0 {
return AffiliateRebateDurationDaysDefault
}
if days > AffiliateRebateDurationDaysMax {
return AffiliateRebateDurationDaysMax
}
return days
}
// GetAffiliateRebatePerInviteeCap 返回单人返利上限。
// 返回 0 表示无上限。
func (s *SettingService) GetAffiliateRebatePerInviteeCap(ctx context.Context) float64 {
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAffiliateRebatePerInviteeCap)
if err != nil {
return AffiliateRebatePerInviteeCapDefault
}
cap, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
if err != nil || cap < 0 || math.IsNaN(cap) || math.IsInf(cap, 0) {
return AffiliateRebatePerInviteeCapDefault
}
return cap
}
// IsPasswordResetEnabled 检查是否启用密码重置功能
// 要求:必须同时开启邮件验证
func (s *SettingService) IsPasswordResetEnabled(ctx context.Context) bool {
// Password reset requires email verification to be enabled
if !s.IsEmailVerifyEnabled(ctx) {
return false
}
value, err := s.settingRepo.GetValue(ctx, SettingKeyPasswordResetEnabled)
if err != nil {
return false // 默认关闭
}
return value == "true"
}
// IsTotpEnabled 检查是否启用 TOTP 双因素认证功能
func (s *SettingService) IsTotpEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyTotpEnabled)
if err != nil {
return false // 默认关闭
}
return value == "true"
}
// PasskeyEnabled reports the effective runtime switch. WebAuthn deployment
// configuration remains the security boundary; the database setting can only
// disable a valid configured relying party, never replace or weaken it.
func (s *SettingService) PasskeyEnabled(ctx context.Context) (bool, error) {
if !s.passkeyConfigured() {
return false, nil
}
value, err := s.settingRepo.GetValue(ctx, SettingKeyPasskeyEnabled)
if errors.Is(err, ErrSettingNotFound) {
return true, nil // configured deployments default to enabled until the admin persists the switch
}
if err != nil {
return false, fmt.Errorf("read passkey setting: %w", err)
}
return value == "true", nil
}
// PasskeyConfiguration returns non-secret relying-party configuration for the
// admin status UI. Enabled configurations have already passed Config.Validate.
func (s *SettingService) PasskeyConfiguration() (configured bool, rpID string, origins []string) {
if s == nil || s.cfg == nil {
return false, "", []string{}
}
origins = append([]string{}, s.cfg.WebAuthn.RPOrigins...)
return s.cfg.WebAuthn.Enabled,
strings.TrimSpace(s.cfg.WebAuthn.RPID),
origins
}
func (s *SettingService) passkeyConfigured() bool {
return s != nil && s.cfg != nil && s.cfg.WebAuthn.Enabled
}
// passkeySettingEnabled must stay ANDed with passkeyConfigured: a stale
// "true" row after the WebAuthn config is removed would otherwise make the
// admin update gate reject every settings save while the UI toggle is locked.
func (s *SettingService) passkeySettingEnabled(settings map[string]string) bool {
if !s.passkeyConfigured() {
return false
}
value, ok := settings[SettingKeyPasskeyEnabled]
if !ok {
return true
}
return value == "true"
}
// IsTotpEncryptionKeyConfigured 检查 TOTP 加密密钥是否已手动配置
// 只有手动配置了密钥才允许在管理后台启用 TOTP 功能
func (s *SettingService) IsTotpEncryptionKeyConfigured() bool {
return s.cfg.Totp.EncryptionKeyConfigured
}
// IsSessionBindingEnabled 检查会话 IP/UA 绑定是否启用(默认关闭)。
// 开启时会话与登录时的 IP/User-Agent 绑定,任一变化立即失效并撤销该会话。
// 默认关闭:移动网络/多出口 IP 场景下 IP 频繁变化会导致登录后立即掉线。
func (s *SettingService) IsSessionBindingEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeySessionBindingEnabled)
if err != nil {
return false // 默认关闭
}
return value == "true"
}
// IsStepUpEnabled 检查敏感操作 step-up 2FA 门控是否启用(默认关闭)。
// 开启时账号/代理导出、备份创建/下载、S3 配置修改、提升管理员等操作
// 要求当前会话在有效期内完成过 TOTP step-up 验证。
func (s *SettingService) IsStepUpEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyStepUpEnabled)
if err != nil {
return false // 默认关闭
}
return value == "true"
}
// defaultAuditLogRetentionDays 审计日志默认保留天数。
const defaultAuditLogRetentionDays = 180
// GetAuditLogRetentionDays 审计日志保留天数(<=0 表示永久保留,仅支持手动清空)。
func (s *SettingService) GetAuditLogRetentionDays(ctx context.Context) int {
value, err := s.settingRepo.GetValue(ctx, SettingKeyAuditLogRetentionDays)
if err != nil {
return defaultAuditLogRetentionDays
}
return parseAuditLogRetentionDays(value)
}
// parseAuditLogRetentionDays 解析保留天数配置,空/非法值回退默认值。
func parseAuditLogRetentionDays(value string) int {
value = strings.TrimSpace(value)
if value == "" {
return defaultAuditLogRetentionDays
}
n, err := strconv.Atoi(value)
if err != nil {
return defaultAuditLogRetentionDays
}
if n < 0 {
return 0
}
return n
}
// GetSiteName 获取网站名称
func (s *SettingService) GetSiteName(ctx context.Context) string {
value, err := s.settingRepo.GetValue(ctx, SettingKeySiteName)
if err != nil || value == "" {
return "Sub2API"
}
return value
}
// GetDefaultConcurrency 获取默认并发量
func (s *SettingService) GetDefaultConcurrency(ctx context.Context) int {
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultConcurrency)
if err != nil {
return s.cfg.Default.UserConcurrency
}
if v, err := strconv.Atoi(value); err == nil && v > 0 {
return v
}
return s.cfg.Default.UserConcurrency
}
// GetDefaultBalance 获取默认余额
func (s *SettingService) GetDefaultBalance(ctx context.Context) float64 {
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultBalance)
if err != nil {
return s.cfg.Default.UserBalance
}
if v, err := strconv.ParseFloat(value, 64); err == nil && v >= 0 {
return v
}
return s.cfg.Default.UserBalance
}
// GetDefaultUserRPMLimit 获取新用户默认 RPM 限制(0 = 不限制)。未配置则返回 0。
func (s *SettingService) GetDefaultUserRPMLimit(ctx context.Context) int {
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultUserRPMLimit)
if err != nil || value == "" {
return 0
}
if v, err := strconv.Atoi(value); err == nil && v >= 0 {
return v
}
return 0
}
// GetDefaultSubscriptions 获取新用户默认订阅配置列表。
func (s *SettingService) GetDefaultSubscriptions(ctx context.Context) []DefaultSubscriptionSetting {
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultSubscriptions)
if err != nil {
return nil
}
return parseDefaultSubscriptions(value)
}
func (s *SettingService) GetAuthSourceDefaultSettings(ctx context.Context) (*AuthSourceDefaultSettings, error) {
keys := []string{
SettingKeyAuthSourceDefaultEmailBalance,
SettingKeyAuthSourceDefaultEmailConcurrency,
SettingKeyAuthSourceDefaultEmailSubscriptions,
SettingKeyAuthSourceDefaultEmailGrantOnSignup,
SettingKeyAuthSourceDefaultEmailGrantOnFirstBind,
SettingKeyAuthSourceDefaultLinuxDoBalance,
SettingKeyAuthSourceDefaultLinuxDoConcurrency,
SettingKeyAuthSourceDefaultLinuxDoSubscriptions,
SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup,
SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind,
SettingKeyAuthSourceDefaultOIDCBalance,
SettingKeyAuthSourceDefaultOIDCConcurrency,
SettingKeyAuthSourceDefaultOIDCSubscriptions,
SettingKeyAuthSourceDefaultOIDCGrantOnSignup,
SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind,
SettingKeyAuthSourceDefaultWeChatBalance,
SettingKeyAuthSourceDefaultWeChatConcurrency,
SettingKeyAuthSourceDefaultWeChatSubscriptions,
SettingKeyAuthSourceDefaultWeChatGrantOnSignup,
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind,
SettingKeyAuthSourceDefaultGitHubBalance,
SettingKeyAuthSourceDefaultGitHubConcurrency,
SettingKeyAuthSourceDefaultGitHubSubscriptions,
SettingKeyAuthSourceDefaultGitHubGrantOnSignup,
SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind,
SettingKeyAuthSourceDefaultGoogleBalance,
SettingKeyAuthSourceDefaultGoogleConcurrency,
SettingKeyAuthSourceDefaultGoogleSubscriptions,
SettingKeyAuthSourceDefaultGoogleGrantOnSignup,
SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind,
SettingKeyAuthSourceDefaultDingTalkBalance,
SettingKeyAuthSourceDefaultDingTalkConcurrency,
SettingKeyAuthSourceDefaultDingTalkSubscriptions,
SettingKeyAuthSourceDefaultDingTalkGrantOnSignup,
SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind,
SettingKeyAuthSourcePlatformQuotas("email"),
SettingKeyAuthSourcePlatformQuotas("linuxdo"),
SettingKeyAuthSourcePlatformQuotas("oidc"),
SettingKeyAuthSourcePlatformQuotas("wechat"),
SettingKeyAuthSourcePlatformQuotas("github"),
SettingKeyAuthSourcePlatformQuotas("google"),
SettingKeyAuthSourcePlatformQuotas("dingtalk"),
SettingKeyForceEmailOnThirdPartySignup,
}
settings, err := s.settingRepo.GetMultiple(ctx, keys)
if err != nil {
return nil, fmt.Errorf("get auth source default settings: %w", err)
}
return &AuthSourceDefaultSettings{
Email: parseProviderDefaultGrantSettings(settings, emailAuthSourceDefaultKeys),
LinuxDo: parseProviderDefaultGrantSettings(settings, linuxDoAuthSourceDefaultKeys),
OIDC: parseProviderDefaultGrantSettings(settings, oidcAuthSourceDefaultKeys),
WeChat: parseProviderDefaultGrantSettings(settings, weChatAuthSourceDefaultKeys),
GitHub: parseProviderDefaultGrantSettings(settings, gitHubAuthSourceDefaultKeys),
Google: parseProviderDefaultGrantSettings(settings, googleAuthSourceDefaultKeys),
DingTalk: parseProviderDefaultGrantSettings(settings, dingTalkAuthSourceDefaultKeys),
ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true",
}, nil
}
func (s *SettingService) ResolveAuthSourceGrantSettings(ctx context.Context, signupSource string, firstBind bool) (ProviderDefaultGrantSettings, bool, error) {
result := ProviderDefaultGrantSettings{
Balance: s.GetDefaultBalance(ctx),
Concurrency: s.GetDefaultConcurrency(ctx),
Subscriptions: s.GetDefaultSubscriptions(ctx),
}
defaults, err := s.GetAuthSourceDefaultSettings(ctx)
if err != nil {
return result, false, err
}
providerDefaults, ok := authSourceSignupSettings(defaults, signupSource)
if !ok {
return result, false, nil
}
enabled := providerDefaults.GrantOnSignup
if firstBind {
enabled = providerDefaults.GrantOnFirstBind
}
if !enabled {
return result, false, nil
}
return mergeProviderDefaultGrantSettings(result, providerDefaults), true, nil
}
func (s *SettingService) UpdateAuthSourceDefaultSettings(ctx context.Context, settings *AuthSourceDefaultSettings) error {
updates, err := s.buildAuthSourceDefaultUpdates(ctx, settings)
if err != nil {
return err
}
if len(updates) == 0 {
return nil
}
if err := s.settingRepo.SetMultiple(ctx, updates); err != nil {
return fmt.Errorf("update auth source default settings: %w", err)
}
return nil
}
// IsTurnstileEnabled 检查是否启用 Turnstile 验证
func (s *SettingService) IsTurnstileEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyTurnstileEnabled)
if err != nil {
return false
}
return value == "true"
}
// GetTurnstileSecretKey 获取 Turnstile Secret Key
func (s *SettingService) GetTurnstileSecretKey(ctx context.Context) string {
value, err := s.settingRepo.GetValue(ctx, SettingKeyTurnstileSecretKey)
if err != nil {
return ""
}
return value
}
// TencentCaptchaConfig contains the credentials required by Tencent Cloud's
// ticket verification API. It must never be returned by a public handler.
type TencentCaptchaConfig struct {
Enabled bool
AppID string
AppSecretKey string
CloudSecretID string
CloudSecretKey string
Region string
}
// AliyunCaptchaConfig contains the credentials required by Aliyun Captcha 2.0's
// server-side verification API. It must never be returned by a public handler.
type AliyunCaptchaConfig struct {
Enabled bool
AccessKeyID string
AccessKeySecret string
SceneID string
Region string
}
type CaptchaProviderConfig struct {
TurnstileEnabled bool
TurnstileSecretKey string
Tencent TencentCaptchaConfig
Aliyun AliyunCaptchaConfig
}
func (s *SettingService) GetCaptchaProviderConfig(ctx context.Context) (CaptchaProviderConfig, error) {
values, err := s.settingRepo.GetMultiple(ctx, []string{
SettingKeyTurnstileEnabled,
SettingKeyTurnstileSecretKey,
SettingKeyTencentCaptchaEnabled,
SettingKeyTencentCaptchaAppID,
SettingKeyTencentCaptchaAppSecretKey,
SettingKeyTencentCaptchaCloudSecretID,
SettingKeyTencentCaptchaCloudSecretKey,
SettingKeyTencentCaptchaRegion,
SettingKeyAliyunCaptchaEnabled,
SettingKeyAliyunCaptchaAccessKeyID,
SettingKeyAliyunCaptchaAccessKeySecret,
SettingKeyAliyunCaptchaSceneID,
SettingKeyAliyunCaptchaRegion,
})
if err != nil {
return CaptchaProviderConfig{}, fmt.Errorf("read captcha provider settings: %w", err)
}
return CaptchaProviderConfig{
TurnstileEnabled: values[SettingKeyTurnstileEnabled] == "true",
TurnstileSecretKey: values[SettingKeyTurnstileSecretKey],
Tencent: TencentCaptchaConfig{
Enabled: values[SettingKeyTencentCaptchaEnabled] == "true",
AppID: values[SettingKeyTencentCaptchaAppID],
AppSecretKey: values[SettingKeyTencentCaptchaAppSecretKey],
CloudSecretID: values[SettingKeyTencentCaptchaCloudSecretID],
CloudSecretKey: values[SettingKeyTencentCaptchaCloudSecretKey],
Region: normalizeTencentCaptchaRegion(values[SettingKeyTencentCaptchaRegion]),
},
Aliyun: AliyunCaptchaConfig{
Enabled: values[SettingKeyAliyunCaptchaEnabled] == "true",
AccessKeyID: values[SettingKeyAliyunCaptchaAccessKeyID],
AccessKeySecret: values[SettingKeyAliyunCaptchaAccessKeySecret],
SceneID: values[SettingKeyAliyunCaptchaSceneID],
Region: normalizeAliyunCaptchaRegion(values[SettingKeyAliyunCaptchaRegion]),
},
}, nil
}
func (s *SettingService) IsTencentCaptchaEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyTencentCaptchaEnabled)
return err == nil && value == "true"
}
func (s *SettingService) GetTencentCaptchaConfig(ctx context.Context) TencentCaptchaConfig {
config, err := s.GetCaptchaProviderConfig(ctx)
if err != nil {
return TencentCaptchaConfig{}
}
return config.Tencent
}
// IsIdentityPatchEnabled 检查是否启用身份补丁(Claude -> Gemini systemInstruction 注入)
func (s *SettingService) IsIdentityPatchEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyEnableIdentityPatch)
if err != nil {
// 默认开启,保持兼容
return true
}
return value == "true"
}
// GetIdentityPatchPrompt 获取自定义身份补丁提示词(为空表示使用内置默认模板)
func (s *SettingService) GetIdentityPatchPrompt(ctx context.Context) string {
value, err := s.settingRepo.GetValue(ctx, SettingKeyIdentityPatchPrompt)
if err != nil {
return ""
}
return value
}
// GenerateAdminAPIKey 生成新的管理员 API Key
func (s *SettingService) GenerateAdminAPIKey(ctx context.Context) (string, error) {
// 生成 32 字节随机数 = 64 位十六进制字符
bytes := make([]byte, 32)
if _, err := rand.Read(bytes); err != nil {
return "", fmt.Errorf("generate random bytes: %w", err)
}
key := AdminAPIKeyPrefix + hex.EncodeToString(bytes)
// 存储到 settings 表
if err := s.settingRepo.Set(ctx, SettingKeyAdminAPIKey, key); err != nil {
return "", fmt.Errorf("save admin api key: %w", err)
}
return key, nil
}
// GetAdminAPIKeyStatus 获取管理员 API Key 状态
// 返回脱敏的 key、是否存在、错误
func (s *SettingService) GetAdminAPIKeyStatus(ctx context.Context) (maskedKey string, exists bool, err error) {
key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey)
if err != nil {
if errors.Is(err, ErrSettingNotFound) {
return "", false, nil
}
return "", false, err
}
if key == "" {
return "", false, nil
}
// 脱敏:显示前 10 位和后 4 位
if len(key) > 14 {
maskedKey = key[:10] + "..." + key[len(key)-4:]
} else {
maskedKey = key
}
return maskedKey, true, nil
}
// GetAdminAPIKey 获取完整的管理员 API Key(仅供内部验证使用)
// 如果未配置返回空字符串和 nil 错误,只有数据库错误时才返回 error
func (s *SettingService) GetAdminAPIKey(ctx context.Context) (string, error) {
key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey)
if err != nil {
if errors.Is(err, ErrSettingNotFound) {
return "", nil // 未配置,返回空字符串
}
return "", err // 数据库错误
}
return key, nil
}
// DeleteAdminAPIKey 删除管理员 API Key
func (s *SettingService) DeleteAdminAPIKey(ctx context.Context) error {
return s.settingRepo.Delete(ctx, SettingKeyAdminAPIKey)
}
// IsModelFallbackEnabled 检查是否启用模型兜底机制
func (s *SettingService) IsModelFallbackEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyEnableModelFallback)
if err != nil {
return false // Default: disabled
}
return value == "true"
}
// GetFallbackModel 获取指定平台的兜底模型
func (s *SettingService) GetFallbackModel(ctx context.Context, platform string) string {
var key string
var defaultModel string
switch platform {
case PlatformAnthropic:
key = SettingKeyFallbackModelAnthropic
defaultModel = "claude-3-5-sonnet-20241022"
case PlatformOpenAI:
key = SettingKeyFallbackModelOpenAI
defaultModel = "gpt-4o"
case PlatformGemini:
key = SettingKeyFallbackModelGemini
defaultModel = "gemini-2.5-pro"
case PlatformAntigravity:
key = SettingKeyFallbackModelAntigravity
defaultModel = "gemini-2.5-pro"
default:
return ""
}
value, err := s.settingRepo.GetValue(ctx, key)
if err != nil || value == "" {
return defaultModel
}
return value
}
// GetOverloadCooldownSettings 获取529过载冷却配置
func (s *SettingService) GetOverloadCooldownSettings(ctx context.Context) (*OverloadCooldownSettings, error) {
value, err := s.settingRepo.GetValue(ctx, SettingKeyOverloadCooldownSettings)
if err != nil {
if errors.Is(err, ErrSettingNotFound) {
return DefaultOverloadCooldownSettings(), nil
}
return nil, fmt.Errorf("get overload cooldown settings: %w", err)
}
if value == "" {
return DefaultOverloadCooldownSettings(), nil
}
var settings OverloadCooldownSettings
if err := json.Unmarshal([]byte(value), &settings); err != nil {
return DefaultOverloadCooldownSettings(), nil
}
// 修正配置值范围
if settings.CooldownMinutes < 1 {
settings.CooldownMinutes = 1
}
if settings.CooldownMinutes > 120 {
settings.CooldownMinutes = 120
}
return &settings, nil
}
// SetOverloadCooldownSettings 设置529过载冷却配置
func (s *SettingService) SetOverloadCooldownSettings(ctx context.Context, settings *OverloadCooldownSettings) error {
if settings == nil {
return fmt.Errorf("settings cannot be nil")
}
// 禁用时修正为合法值即可,不拒绝请求
if settings.CooldownMinutes < 1 || settings.CooldownMinutes > 120 {
if settings.Enabled {
return fmt.Errorf("cooldown_minutes must be between 1-120")
}
settings.CooldownMinutes = 10 // 禁用状态下归一化为默认值
}
data, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("marshal overload cooldown settings: %w", err)
}
return s.settingRepo.Set(ctx, SettingKeyOverloadCooldownSettings, string(data))
}
// GetRateLimit429CooldownSettings 获取429默认回避配置
func (s *SettingService) GetRateLimit429CooldownSettings(ctx context.Context) (*RateLimit429CooldownSettings, error) {
value, err := s.settingRepo.GetValue(ctx, SettingKeyRateLimit429CooldownSettings)
if err != nil {
if errors.Is(err, ErrSettingNotFound) {
return DefaultRateLimit429CooldownSettings(), nil
}
return nil, fmt.Errorf("get 429 cooldown settings: %w", err)
}
if value == "" {
return DefaultRateLimit429CooldownSettings(), nil
}
var settings RateLimit429CooldownSettings
if err := json.Unmarshal([]byte(value), &settings); err != nil {
return DefaultRateLimit429CooldownSettings(), nil
}
if settings.CooldownSeconds < 1 {
settings.CooldownSeconds = 1
}
if settings.CooldownSeconds > 7200 {
settings.CooldownSeconds = 7200
}
return &settings, nil
}
// SetRateLimit429CooldownSettings 设置429默认回避配置
func (s *SettingService) SetRateLimit429CooldownSettings(ctx context.Context, settings *RateLimit429CooldownSettings) error {
if settings == nil {
return fmt.Errorf("settings cannot be nil")
}
if settings.CooldownSeconds < 1 || settings.CooldownSeconds > 7200 {
if settings.Enabled {
return fmt.Errorf("cooldown_seconds must be between 1-7200")
}
settings.CooldownSeconds = 5
}
data, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("marshal 429 cooldown settings: %w", err)
}
return s.settingRepo.Set(ctx, SettingKeyRateLimit429CooldownSettings, string(data))
}
// GetStreamTimeoutSettings 获取流超时处理配置
func (s *SettingService) GetStreamTimeoutSettings(ctx context.Context) (*StreamTimeoutSettings, error) {
value, err := s.settingRepo.GetValue(ctx, SettingKeyStreamTimeoutSettings)
if err != nil {
if errors.Is(err, ErrSettingNotFound) {
return DefaultStreamTimeoutSettings(), nil
}
return nil, fmt.Errorf("get stream timeout settings: %w", err)
}
if value == "" {
return DefaultStreamTimeoutSettings(), nil
}
var settings StreamTimeoutSettings
if err := json.Unmarshal([]byte(value), &settings); err != nil {
return DefaultStreamTimeoutSettings(), nil
}
// 验证并修正配置值
if settings.TempUnschedMinutes < 1 {
settings.TempUnschedMinutes = 1
}
if settings.TempUnschedMinutes > 60 {
settings.TempUnschedMinutes = 60
}
if settings.ThresholdCount < 1 {
settings.ThresholdCount = 1
}
if settings.ThresholdCount > 10 {
settings.ThresholdCount = 10
}
if settings.ThresholdWindowMinutes < 1 {
settings.ThresholdWindowMinutes = 1
}
if settings.ThresholdWindowMinutes > 60 {
settings.ThresholdWindowMinutes = 60
}
// 验证 action
switch settings.Action {
case StreamTimeoutActionTempUnsched, StreamTimeoutActionError, StreamTimeoutActionNone:
// valid
default:
settings.Action = StreamTimeoutActionTempUnsched
}
return &settings, nil
}
// IsUngroupedKeySchedulingAllowed 查询是否允许未分组 Key 调度
func (s *SettingService) IsUngroupedKeySchedulingAllowed(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyAllowUngroupedKeyScheduling)
if err != nil {
return false // fail-closed: 查询失败时默认不允许
}
return value == "true"
}
// GetRectifierSettings 获取请求整流器配置
func (s *SettingService) GetRectifierSettings(ctx context.Context) (*RectifierSettings, error) {
value, err := s.settingRepo.GetValue(ctx, SettingKeyRectifierSettings)
if err != nil {
if errors.Is(err, ErrSettingNotFound) {
return DefaultRectifierSettings(), nil
}
return nil, fmt.Errorf("get rectifier settings: %w", err)
}
if value == "" {
return DefaultRectifierSettings(), nil
}
var settings RectifierSettings
if err := json.Unmarshal([]byte(value), &settings); err != nil {
return DefaultRectifierSettings(), nil
}
return &settings, nil
}
// SetRectifierSettings 设置请求整流器配置
func (s *SettingService) SetRectifierSettings(ctx context.Context, settings *RectifierSettings) error {
if settings == nil {
return fmt.Errorf("settings cannot be nil")
}
data, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("marshal rectifier settings: %w", err)
}
return s.settingRepo.Set(ctx, SettingKeyRectifierSettings, string(data))
}
// IsSignatureRectifierEnabled 判断签名整流是否启用(总开关 && 签名子开关)
func (s *SettingService) IsSignatureRectifierEnabled(ctx context.Context) bool {
settings, err := s.GetRectifierSettings(ctx)
if err != nil {
return true // fail-open: 查询失败时默认启用
}
return settings.Enabled && settings.ThinkingSignatureEnabled
}
// IsBudgetRectifierEnabled 判断 Budget 整流是否启用(总开关 && Budget 子开关)
func (s *SettingService) IsBudgetRectifierEnabled(ctx context.Context) bool {
settings, err := s.GetRectifierSettings(ctx)
if err != nil {
return true // fail-open: 查询失败时默认启用
}
return settings.Enabled && settings.ThinkingBudgetEnabled
}
// GetBetaPolicySettings 获取 Beta 策略配置
func (s *SettingService) GetBetaPolicySettings(ctx context.Context) (*BetaPolicySettings, error) {
value, err := s.settingRepo.GetValue(ctx, SettingKeyBetaPolicySettings)
if err != nil {
if errors.Is(err, ErrSettingNotFound) {
return DefaultBetaPolicySettings(), nil
}
return nil, fmt.Errorf("get beta policy settings: %w", err)
}
if value == "" {
return DefaultBetaPolicySettings(), nil
}
var settings BetaPolicySettings
if err := json.Unmarshal([]byte(value), &settings); err != nil {
return DefaultBetaPolicySettings(), nil
}
return &settings, nil
}
// SetBetaPolicySettings 设置 Beta 策略配置
func (s *SettingService) SetBetaPolicySettings(ctx context.Context, settings *BetaPolicySettings) error {
if settings == nil {
return fmt.Errorf("settings cannot be nil")
}
validActions := map[string]bool{
BetaPolicyActionPass: true, BetaPolicyActionFilter: true, BetaPolicyActionBlock: true,
}
validScopes := map[string]bool{
BetaPolicyScopeAll: true, BetaPolicyScopeOAuth: true, BetaPolicyScopeAPIKey: true, BetaPolicyScopeBedrock: true,
}
for i, rule := range settings.Rules {
if rule.BetaToken == "" {
return fmt.Errorf("rule[%d]: beta_token cannot be empty", i)
}
if !validActions[rule.Action] {
return fmt.Errorf("rule[%d]: invalid action %q", i, rule.Action)
}
if !validScopes[rule.Scope] {
return fmt.Errorf("rule[%d]: invalid scope %q", i, rule.Scope)
}
// Validate model_whitelist patterns
for j, pattern := range rule.ModelWhitelist {
trimmed := strings.TrimSpace(pattern)
if trimmed == "" {
return fmt.Errorf("rule[%d]: model_whitelist[%d] cannot be empty", i, j)
}
settings.Rules[i].ModelWhitelist[j] = trimmed
}
// Validate fallback_action
if rule.FallbackAction != "" && !validActions[rule.FallbackAction] {
return fmt.Errorf("rule[%d]: invalid fallback_action %q", i, rule.FallbackAction)
}
}
data, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("marshal beta policy settings: %w", err)
}
return s.settingRepo.Set(ctx, SettingKeyBetaPolicySettings, string(data))
}
// GetOpenAIFastPolicySettings 获取 OpenAI fast 策略配置
func (s *SettingService) GetOpenAIFastPolicySettings(ctx context.Context) (*OpenAIFastPolicySettings, error) {
value, err := s.settingRepo.GetValue(ctx, SettingKeyOpenAIFastPolicySettings)
if err != nil {
if errors.Is(err, ErrSettingNotFound) {
return DefaultOpenAIFastPolicySettings(), nil
}
return nil, fmt.Errorf("get openai fast policy settings: %w", err)
}
if value == "" {
return DefaultOpenAIFastPolicySettings(), nil
}
var settings OpenAIFastPolicySettings
if err := json.Unmarshal([]byte(value), &settings); err != nil {
// JSON 损坏时静默 fallback 到默认配置会让策略意外失效(管理员配
// 置的 block/filter 规则被忽略)。记录 Warn 让运维能在出现异常
// 行为时定位到 settings 表里的脏数据。
slog.Warn("failed to unmarshal openai fast policy settings, falling back to defaults",
"error", err,
"key", SettingKeyOpenAIFastPolicySettings)
return DefaultOpenAIFastPolicySettings(), nil
}
return &settings, nil
}
// SetOpenAIFastPolicySettings 设置 OpenAI fast 策略配置
func (s *SettingService) SetOpenAIFastPolicySettings(ctx context.Context, settings *OpenAIFastPolicySettings) error {
if settings == nil {
return fmt.Errorf("settings cannot be nil")
}
validActions := map[string]bool{
BetaPolicyActionPass: true, BetaPolicyActionFilter: true, BetaPolicyActionBlock: true,
OpenAIFastPolicyActionForcePriority: true,
}
validScopes := map[string]bool{
BetaPolicyScopeAll: true, BetaPolicyScopeOAuth: true, BetaPolicyScopeAPIKey: true, BetaPolicyScopeBedrock: true,
}
validTiers := map[string]bool{
OpenAIFastTierAny: true, OpenAIFastTierPriority: true, OpenAIFastTierFlex: true,
}
for i, rule := range settings.Rules {
tier := strings.ToLower(strings.TrimSpace(rule.ServiceTier))
if tier == "" {
tier = OpenAIFastTierAny
}
if !validTiers[tier] {
return fmt.Errorf("rule[%d]: invalid service_tier %q", i, rule.ServiceTier)
}
settings.Rules[i].ServiceTier = tier
if !validActions[rule.Action] {
return fmt.Errorf("rule[%d]: invalid action %q", i, rule.Action)
}
if !validScopes[rule.Scope] {
return fmt.Errorf("rule[%d]: invalid scope %q", i, rule.Scope)
}
seenUserIDs := make(map[int64]struct{}, len(rule.UserIDs))
for j, userID := range rule.UserIDs {
if userID <= 0 {
return fmt.Errorf("rule[%d]: user_ids[%d] must be positive", i, j)
}
if _, exists := seenUserIDs[userID]; exists {
return fmt.Errorf("rule[%d]: user_ids[%d] duplicates user_id %d", i, j, userID)
}
seenUserIDs[userID] = struct{}{}
}
for j, pattern := range rule.ModelWhitelist {
trimmed := strings.TrimSpace(pattern)
if trimmed == "" {
return fmt.Errorf("rule[%d]: model_whitelist[%d] cannot be empty", i, j)
}
settings.Rules[i].ModelWhitelist[j] = trimmed
}
if rule.FallbackAction != "" && !validActions[rule.FallbackAction] {
return fmt.Errorf("rule[%d]: invalid fallback_action %q", i, rule.FallbackAction)
}
}
data, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("marshal openai fast policy settings: %w", err)
}
return s.settingRepo.Set(ctx, SettingKeyOpenAIFastPolicySettings, string(data))
}
// SetStreamTimeoutSettings 设置流超时处理配置
func (s *SettingService) SetStreamTimeoutSettings(ctx context.Context, settings *StreamTimeoutSettings) error {
if settings == nil {
return fmt.Errorf("settings cannot be nil")
}
// 验证配置值
if settings.TempUnschedMinutes < 1 || settings.TempUnschedMinutes > 60 {
return fmt.Errorf("temp_unsched_minutes must be between 1-60")
}
if settings.ThresholdCount < 1 || settings.ThresholdCount > 10 {
return fmt.Errorf("threshold_count must be between 1-10")
}
if settings.ThresholdWindowMinutes < 1 || settings.ThresholdWindowMinutes > 60 {
return fmt.Errorf("threshold_window_minutes must be between 1-60")
}
switch settings.Action {
case StreamTimeoutActionTempUnsched, StreamTimeoutActionError, StreamTimeoutActionNone:
// valid
default:
return fmt.Errorf("invalid action: %s", settings.Action)
}
data, err := json.Marshal(settings)
if err != nil {
return fmt.Errorf("marshal stream timeout settings: %w", err)
}
return s.settingRepo.Set(ctx, SettingKeyStreamTimeoutSettings, string(data))
}
// GetDefaultPlatformQuotas 读取系统全局 platform quota JSON key,返回全部允许平台 x 3 window 的设置。
// 永远返回包含全部允许 platform key 的 map(值可能为零值/nil 字段,表示"上层未配置 = 不限制")。
//
// 使用单个 JSON keydefault_platform_quotas),一次 DB roundtrip,消除旧 12-KV 格式的 N+1 问题。
// 容错语义:取值失败或 unmarshal 失败 → 返回补齐全部允许平台 key 的空 map(fail-open,注册不被阻断)。
func (s *SettingService) GetDefaultPlatformQuotas(ctx context.Context) (map[string]*DefaultPlatformQuotaSetting, error) {
out := make(map[string]*DefaultPlatformQuotaSetting, len(AllowedQuotaPlatforms))
for _, platform := range AllowedQuotaPlatforms {
out[platform] = &DefaultPlatformQuotaSetting{}
}
raw, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultPlatformQuotas)
if err != nil || raw == "" {
return out, nil // 无配置 = 全部不限制
}
parsed := map[string]*DefaultPlatformQuotaSetting{}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
slog.Warn("[Setting] unmarshal default_platform_quotas failed (fail-open)", "error", err)
return out, nil
}
for _, platform := range AllowedQuotaPlatforms {
if v := parsed[platform]; v != nil {
out[platform] = v
}
}
return out, nil // 补齐全部允许 platform key,保持与旧实现一致的下游契约
}
// GetAccountSchedulingThresholds returns per-platform auto-pause thresholds (1..100).
// 100 disables the threshold for that platform. Hot-path cached with singleflight.
func (s *SettingService) GetAccountSchedulingThresholds(ctx context.Context) map[string]int {
if s == nil || s.settingRepo == nil {
return defaultAccountSchedulingThresholds()
}
if cached, ok := accountSchedulingThresholdsCache.Load().(*cachedAccountSchedulingThresholds); ok {
if cached != nil && len(cached.thresholds) > 0 && time.Now().UnixNano() < cached.expiresAt {
return cloneAccountSchedulingThresholds(cached.thresholds)
}
}
result, err, _ := accountSchedulingThresholdsSF.Do(SettingKeyAccountSchedulingThresholds, func() (any, error) {
if cached, ok := accountSchedulingThresholdsCache.Load().(*cachedAccountSchedulingThresholds); ok {
if cached != nil && len(cached.thresholds) > 0 && time.Now().UnixNano() < cached.expiresAt {
return cloneAccountSchedulingThresholds(cached.thresholds), nil
}
}
thresholds := defaultAccountSchedulingThresholds()
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), accountSchedulingThresholdsDBTimeout)
defer cancel()
raw, err := s.settingRepo.GetValue(dbCtx, SettingKeyAccountSchedulingThresholds)
if err != nil {
if errors.Is(err, ErrSettingNotFound) {
accountSchedulingThresholdsCache.Store(&cachedAccountSchedulingThresholds{
thresholds: cloneAccountSchedulingThresholds(thresholds),
expiresAt: time.Now().Add(accountSchedulingThresholdsCacheTTL).UnixNano(),
})
return cloneAccountSchedulingThresholds(thresholds), nil
}
slog.Warn("failed to get account scheduling thresholds, falling back to defaults", "error", err)
accountSchedulingThresholdsCache.Store(&cachedAccountSchedulingThresholds{
thresholds: cloneAccountSchedulingThresholds(thresholds),
expiresAt: time.Now().Add(accountSchedulingThresholdsErrorTTL).UnixNano(),
})
return cloneAccountSchedulingThresholds(thresholds), nil
}
if trimmed := strings.TrimSpace(raw); trimmed != "" {
if parsed, err := parseAccountSchedulingThresholdsSetting(trimmed); err != nil {
slog.Warn("failed to parse account scheduling thresholds, falling back to defaults", "error", err)
} else {
thresholds = parsed
}
}
accountSchedulingThresholdsCache.Store(&cachedAccountSchedulingThresholds{
thresholds: cloneAccountSchedulingThresholds(thresholds),
expiresAt: time.Now().Add(accountSchedulingThresholdsCacheTTL).UnixNano(),
})
return cloneAccountSchedulingThresholds(thresholds), nil
})
if err != nil {
return defaultAccountSchedulingThresholds()
}
if thresholds, ok := result.(map[string]int); ok {
return cloneAccountSchedulingThresholds(thresholds)
}
return defaultAccountSchedulingThresholds()
}
// GetAuthSourcePlatformQuotas 读取指定 auth source 的 platform quota 覆盖(仅返回有配置的平台,override 语义)。
func (s *SettingService) GetAuthSourcePlatformQuotas(ctx context.Context, source string) map[string]*DefaultPlatformQuotaSetting {
out := map[string]*DefaultPlatformQuotaSetting{}
raw, err := s.settingRepo.GetValue(ctx, SettingKeyAuthSourcePlatformQuotas(source))
if err != nil || raw == "" {
return out // 无 override
}
if err := json.Unmarshal([]byte(raw), &out); err != nil {
slog.Warn("[Setting] unmarshal auth source platform quotas failed (fail-open)", "source", source, "error", err)
return map[string]*DefaultPlatformQuotaSetting{}
}
return out // 仅含已配置平台,保持 override 语义
}
// mergePlatformQuotaDefaults 按字段级 patchsrc 中非 nil 字段覆盖 dst。
// 区分 nil"未配置",保留 dstvs &0.0"显式禁用",覆盖 dst 为 0)
func mergePlatformQuotaDefaults(dst, src *DefaultPlatformQuotaSetting) {
if src == nil || dst == nil {
return
}
if src.DailyLimitUSD != nil {
dst.DailyLimitUSD = src.DailyLimitUSD
}
if src.WeeklyLimitUSD != nil {
dst.WeeklyLimitUSD = src.WeeklyLimitUSD
}
if src.MonthlyLimitUSD != nil {
dst.MonthlyLimitUSD = src.MonthlyLimitUSD
}
}