mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 13:18:18 +08:00
refactor(service): 纯移动拆分 setting_service.go(5471→263行)
This commit is contained in:
@@ -0,0 +1,912 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// 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"
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
|
||||
// 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"
|
||||
}
|
||||
|
||||
// IsTotpEncryptionKeyConfigured 检查 TOTP 加密密钥是否已手动配置
|
||||
// 只有手动配置了密钥才允许在管理后台启用 TOTP 功能
|
||||
func (s *SettingService) IsTotpEncryptionKeyConfigured() bool {
|
||||
return s.cfg.Totp.EncryptionKeyConfigured
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
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 key(default_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,保持与旧实现一致的下游契约
|
||||
}
|
||||
|
||||
// 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 按字段级 patch:src 中非 nil 字段覆盖 dst。
|
||||
// 区分 nil("未配置",保留 dst)vs &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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,892 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
"golang.org/x/sync/singleflight"
|
||||
)
|
||||
|
||||
// cachedVersionBounds 缓存 Claude Code 版本号上下限(进程内缓存,60s TTL)
|
||||
type cachedVersionBounds struct {
|
||||
min string // 空字符串 = 不检查
|
||||
max string // 空字符串 = 不检查
|
||||
expiresAt int64 // unix nano
|
||||
}
|
||||
|
||||
// versionBoundsCache 版本号上下限进程内缓存
|
||||
var versionBoundsCache atomic.Value // *cachedVersionBounds
|
||||
|
||||
// versionBoundsSF 防止缓存过期时 thundering herd
|
||||
var versionBoundsSF singleflight.Group
|
||||
|
||||
// versionBoundsCacheTTL 缓存有效期
|
||||
const versionBoundsCacheTTL = 60 * time.Second
|
||||
|
||||
// versionBoundsErrorTTL DB 错误时的短缓存,快速重试
|
||||
const versionBoundsErrorTTL = 5 * time.Second
|
||||
|
||||
// versionBoundsDBTimeout singleflight 内 DB 查询超时,独立于请求 context
|
||||
const versionBoundsDBTimeout = 5 * time.Second
|
||||
|
||||
// cachedBackendMode Backend Mode cache (in-process, 60s TTL)
|
||||
type cachedBackendMode struct {
|
||||
value bool
|
||||
expiresAt int64 // unix nano
|
||||
}
|
||||
|
||||
var backendModeCache atomic.Value // *cachedBackendMode
|
||||
var backendModeSF singleflight.Group
|
||||
|
||||
const backendModeCacheTTL = 60 * time.Second
|
||||
const backendModeErrorTTL = 5 * time.Second
|
||||
const backendModeDBTimeout = 5 * time.Second
|
||||
|
||||
// cachedGatewayForwardingSettings 缓存网关转发行为设置(进程内缓存,60s TTL)
|
||||
type cachedGatewayForwardingSettings struct {
|
||||
fingerprintUnification bool
|
||||
metadataPassthrough bool
|
||||
cchSigning bool
|
||||
claudeOAuthSystemPromptInjection bool
|
||||
claudeOAuthSystemPrompt string
|
||||
claudeOAuthSystemPromptBlocks string
|
||||
anthropicCacheTTL1hInjection bool
|
||||
rewriteMessageCacheControl bool
|
||||
clientDatelineNormalization bool
|
||||
expiresAt int64 // unix nano
|
||||
}
|
||||
|
||||
var gatewayForwardingCache atomic.Value // *cachedGatewayForwardingSettings
|
||||
var gatewayForwardingSF singleflight.Group
|
||||
|
||||
const gatewayForwardingCacheTTL = 60 * time.Second
|
||||
const gatewayForwardingErrorTTL = 5 * time.Second
|
||||
const gatewayForwardingDBTimeout = 5 * time.Second
|
||||
|
||||
// cachedAntigravityUserAgentVersion 缓存 Antigravity UA 版本号(进程内缓存,60s TTL)
|
||||
type cachedAntigravityUserAgentVersion struct {
|
||||
version string
|
||||
expiresAt int64 // unix nano
|
||||
}
|
||||
|
||||
const antigravityUserAgentVersionCacheTTL = 60 * time.Second
|
||||
const antigravityUserAgentVersionErrorTTL = 5 * time.Second
|
||||
const antigravityUserAgentVersionDBTimeout = 5 * time.Second
|
||||
|
||||
// DefaultOpenAICodexUserAgent OpenAI Codex 默认 User-Agent(用于规避 Cloudflare 对浏览器 UA 的质询)
|
||||
const DefaultOpenAICodexUserAgent = "codex-tui/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.125.0)"
|
||||
|
||||
// cachedOpenAICodexUserAgent 缓存 OpenAI Codex UA(进程内缓存,60s TTL)
|
||||
type cachedOpenAICodexUserAgent struct {
|
||||
value string
|
||||
expiresAt int64 // unix nano
|
||||
}
|
||||
|
||||
type cachedOpenAIQuotaAutoPauseSettings struct {
|
||||
settings OpsOpenAIAccountQuotaAutoPauseSettings
|
||||
expiresAt int64
|
||||
}
|
||||
|
||||
const openAICodexUserAgentCacheTTL = 60 * time.Second
|
||||
const openAICodexUserAgentErrorTTL = 5 * time.Second
|
||||
const openAICodexUserAgentDBTimeout = 5 * time.Second
|
||||
|
||||
const codexRestrictionPolicyCacheTTL = 60 * time.Second
|
||||
const codexRestrictionPolicyDBTimeout = 5 * time.Second
|
||||
|
||||
// cachedCodexRestrictionPolicy codex_cli_only 全局加固策略缓存(进程内,60s TTL)。
|
||||
// GetCodexRestrictionPolicy 在每个 codex_cli_only 账号的网关请求热路径上被调用,避免每次访问 DB。
|
||||
type cachedCodexRestrictionPolicy struct {
|
||||
value CodexRestrictionPolicy
|
||||
expiresAt int64 // unix nano
|
||||
}
|
||||
|
||||
// cachedCyberSessionBlockRuntime cyber 会话屏蔽开关+TTL 进程内缓存(60s TTL)。
|
||||
// GetCyberSessionBlockRuntime 在网关请求热路径上被调用,避免每次访问 DB。
|
||||
type cachedCyberSessionBlockRuntime struct {
|
||||
enabled bool
|
||||
ttl time.Duration
|
||||
expiresAt int64 // unix nano
|
||||
}
|
||||
|
||||
const cyberSessionBlockRuntimeCacheTTL = 60 * time.Second
|
||||
const cyberSessionBlockRuntimeErrorTTL = 5 * time.Second
|
||||
const cyberSessionBlockRuntimeDBTimeout = 5 * time.Second
|
||||
|
||||
const openAIQuotaAutoPauseSettingsCacheTTL = 60 * time.Second
|
||||
const openAIQuotaAutoPauseSettingsErrorTTL = 5 * time.Second
|
||||
const openAIQuotaAutoPauseSettingsDBTimeout = 5 * time.Second
|
||||
|
||||
const openAIQuotaAutoPauseSettingsRefreshKey = "openai_quota_auto_pause_settings"
|
||||
|
||||
// GetCyberSessionBlockRuntime 返回 (开关, TTL),进程内缓存 ~60s,
|
||||
// 供网关热路径读取时避免 DB 往返。
|
||||
// 两个 setting key 在单次 singleflight 里一起读取,减少 DB 往返。
|
||||
// 默认值:开关 false,TTL 1h(与粘性会话对齐)。
|
||||
func (s *SettingService) GetCyberSessionBlockRuntime(ctx context.Context) (bool, time.Duration) {
|
||||
if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.enabled, cached.ttl
|
||||
}
|
||||
}
|
||||
result, _, _ := s.cyberSessionBlockRuntimeSF.Do("cyber_session_block_runtime", func() (any, error) {
|
||||
if cached, ok := s.cyberSessionBlockRuntimeCache.Load().(*cachedCyberSessionBlockRuntime); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached, nil
|
||||
}
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cyberSessionBlockRuntimeDBTimeout)
|
||||
defer cancel()
|
||||
|
||||
enabledVal, enabledErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberSessionBlockEnabled)
|
||||
ttlVal, ttlErr := s.settingRepo.GetValue(dbCtx, SettingKeyCyberSessionBlockTTLSeconds)
|
||||
|
||||
if enabledErr != nil && !errors.Is(enabledErr, ErrSettingNotFound) {
|
||||
slog.Warn("failed to get cyber_session_block_enabled setting", "error", enabledErr)
|
||||
entry := &cachedCyberSessionBlockRuntime{
|
||||
enabled: false,
|
||||
ttl: time.Hour,
|
||||
expiresAt: time.Now().Add(cyberSessionBlockRuntimeErrorTTL).UnixNano(),
|
||||
}
|
||||
s.cyberSessionBlockRuntimeCache.Store(entry)
|
||||
return entry, nil
|
||||
}
|
||||
|
||||
enabled := enabledErr == nil && strings.TrimSpace(enabledVal) == "true"
|
||||
|
||||
ttl := time.Hour
|
||||
if ttlErr == nil {
|
||||
if n, perr := strconv.Atoi(strings.TrimSpace(ttlVal)); perr == nil && n > 0 {
|
||||
ttl = time.Duration(n) * time.Second
|
||||
}
|
||||
}
|
||||
|
||||
entry := &cachedCyberSessionBlockRuntime{
|
||||
enabled: enabled,
|
||||
ttl: ttl,
|
||||
expiresAt: time.Now().Add(cyberSessionBlockRuntimeCacheTTL).UnixNano(),
|
||||
}
|
||||
s.cyberSessionBlockRuntimeCache.Store(entry)
|
||||
return entry, nil
|
||||
})
|
||||
if entry, ok := result.(*cachedCyberSessionBlockRuntime); ok && entry != nil {
|
||||
return entry.enabled, entry.ttl
|
||||
}
|
||||
return false, time.Hour
|
||||
}
|
||||
|
||||
// GetAntigravityUserAgentVersion 返回 Antigravity 上游请求使用的版本号。
|
||||
// 后台设置优先;为空、缺失或非法时回退到 ANTIGRAVITY_USER_AGENT_VERSION / 内置默认值。
|
||||
func (s *SettingService) GetAntigravityUserAgentVersion(ctx context.Context) string {
|
||||
fallback := antigravity.GetDefaultUserAgentVersion()
|
||||
if s == nil || s.settingRepo == nil {
|
||||
return fallback
|
||||
}
|
||||
if cached, ok := s.antigravityUAVersionCache.Load().(*cachedAntigravityUserAgentVersion); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.version
|
||||
}
|
||||
}
|
||||
|
||||
result, _, _ := s.antigravityUAVersionSF.Do("antigravity_user_agent_version", func() (any, error) {
|
||||
if cached, ok := s.antigravityUAVersionCache.Load().(*cachedAntigravityUserAgentVersion); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.version, nil
|
||||
}
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), antigravityUserAgentVersionDBTimeout)
|
||||
defer cancel()
|
||||
value, err := s.settingRepo.GetValue(dbCtx, SettingKeyAntigravityUserAgentVersion)
|
||||
if err != nil && !errors.Is(err, ErrSettingNotFound) {
|
||||
slog.Warn("failed to get antigravity user agent version setting", "error", err)
|
||||
s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{
|
||||
version: fallback,
|
||||
expiresAt: time.Now().Add(antigravityUserAgentVersionErrorTTL).UnixNano(),
|
||||
})
|
||||
return fallback, nil
|
||||
}
|
||||
version := antigravity.NormalizeUserAgentVersion(value)
|
||||
if version == "" {
|
||||
version = fallback
|
||||
}
|
||||
s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{
|
||||
version: version,
|
||||
expiresAt: time.Now().Add(antigravityUserAgentVersionCacheTTL).UnixNano(),
|
||||
})
|
||||
return version, nil
|
||||
})
|
||||
if version, ok := result.(string); ok && version != "" {
|
||||
return version
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
// GetOpenAICodexUserAgent 返回 OpenAI Codex 上游请求使用的 User-Agent。
|
||||
// 后台设置优先;为空时回退到内置默认值。
|
||||
func (s *SettingService) GetOpenAICodexUserAgent(ctx context.Context) string {
|
||||
fallback := DefaultOpenAICodexUserAgent
|
||||
if s == nil || s.settingRepo == nil {
|
||||
return fallback
|
||||
}
|
||||
if cached, ok := s.openAICodexUACache.Load().(*cachedOpenAICodexUserAgent); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.value
|
||||
}
|
||||
}
|
||||
|
||||
result, _, _ := s.openAICodexUASF.Do("openai_codex_user_agent", func() (any, error) {
|
||||
if cached, ok := s.openAICodexUACache.Load().(*cachedOpenAICodexUserAgent); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.value, nil
|
||||
}
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAICodexUserAgentDBTimeout)
|
||||
defer cancel()
|
||||
value, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAICodexUserAgent)
|
||||
if err != nil && !errors.Is(err, ErrSettingNotFound) {
|
||||
slog.Warn("failed to get openai codex user agent setting", "error", err)
|
||||
s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{
|
||||
value: fallback,
|
||||
expiresAt: time.Now().Add(openAICodexUserAgentErrorTTL).UnixNano(),
|
||||
})
|
||||
return fallback, nil
|
||||
}
|
||||
ua := strings.TrimSpace(value)
|
||||
if ua == "" {
|
||||
ua = fallback
|
||||
}
|
||||
s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{
|
||||
value: ua,
|
||||
expiresAt: time.Now().Add(openAICodexUserAgentCacheTTL).UnixNano(),
|
||||
})
|
||||
return ua, nil
|
||||
})
|
||||
if ua, ok := result.(string); ok && ua != "" {
|
||||
return ua
|
||||
}
|
||||
return fallback
|
||||
}
|
||||
|
||||
var legacyClaudeCodeCodexWhitelistEntry = openai.AllowedClientEntry{
|
||||
Originator: "Claude Code",
|
||||
UAContains: []string{"Claude Code/"},
|
||||
}
|
||||
|
||||
// MigrateOpenAIAllowClaudeCodeCodexPluginSetting folds the deprecated global Claude Code
|
||||
// plugin allow switch into codex_cli_only_whitelist. The app-server identity model is the
|
||||
// same originator + UA marker pair, so runtime checks no longer need a separate flag.
|
||||
func (s *SettingService) MigrateOpenAIAllowClaudeCodeCodexPluginSetting(ctx context.Context) error {
|
||||
if s == nil || s.settingRepo == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout)
|
||||
defer cancel()
|
||||
|
||||
legacyValue, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpenAIAllowClaudeCodeCodexPlugin)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSettingNotFound) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("get deprecated %s setting: %w", SettingKeyOpenAIAllowClaudeCodeCodexPlugin, err)
|
||||
}
|
||||
if strings.TrimSpace(legacyValue) != "true" {
|
||||
return nil
|
||||
}
|
||||
|
||||
rawWhitelist, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyWhitelist)
|
||||
if err != nil && !errors.Is(err, ErrSettingNotFound) {
|
||||
return fmt.Errorf("get %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err)
|
||||
}
|
||||
|
||||
var entries []openai.AllowedClientEntry
|
||||
if strings.TrimSpace(rawWhitelist) != "" {
|
||||
if err := json.Unmarshal([]byte(rawWhitelist), &entries); err != nil {
|
||||
return fmt.Errorf("parse %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err)
|
||||
}
|
||||
}
|
||||
if codexClientEntriesContain(entries, legacyClaudeCodeCodexWhitelistEntry) {
|
||||
return nil
|
||||
}
|
||||
|
||||
entries = append(entries, legacyClaudeCodeCodexWhitelistEntry)
|
||||
encoded, err := json.Marshal(entries)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err)
|
||||
}
|
||||
if err := s.settingRepo.Set(dbCtx, SettingKeyCodexCLIOnlyWhitelist, string(encoded)); err != nil {
|
||||
return fmt.Errorf("set %s setting: %w", SettingKeyCodexCLIOnlyWhitelist, err)
|
||||
}
|
||||
s.codexRestrictionPolicySF.Forget("codex_restriction_policy")
|
||||
s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0})
|
||||
return nil
|
||||
}
|
||||
|
||||
// MigrateCodexBodyFingerprintToSignals 把已废弃的 codex_cli_only_allow_body_engine_fingerprint
|
||||
// 开关并入引擎指纹信号列表。幂等:信号键已存在(非空)则不动;缺失时写默认种子,
|
||||
// 并把 body 路径行的 Required 设为旧 body 开关的值(旧 true ⇒ 勾上 body 行)。
|
||||
func (s *SettingService) MigrateCodexBodyFingerprintToSignals(ctx context.Context) error {
|
||||
if s == nil || s.settingRepo == nil {
|
||||
return nil
|
||||
}
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout)
|
||||
defer cancel()
|
||||
|
||||
if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyEngineFingerprintSignals); err == nil && strings.TrimSpace(v) != "" {
|
||||
return nil // 已配置/已迁移
|
||||
} else if err != nil && !errors.Is(err, ErrSettingNotFound) {
|
||||
return fmt.Errorf("get %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err)
|
||||
}
|
||||
|
||||
bodyOn := false
|
||||
if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint); err == nil {
|
||||
bodyOn = strings.TrimSpace(v) == "true"
|
||||
} else if !errors.Is(err, ErrSettingNotFound) {
|
||||
return fmt.Errorf("get deprecated %s setting: %w", SettingKeyCodexCLIOnlyAllowBodyEngineFingerprint, err)
|
||||
}
|
||||
|
||||
seed := make([]openai.EngineFingerprintSignal, len(openai.DefaultEngineFingerprintSignals))
|
||||
copy(seed, openai.DefaultEngineFingerprintSignals)
|
||||
if bodyOn {
|
||||
for i := range seed {
|
||||
if seed[i].Type == openai.FingerprintSignalBodyPath {
|
||||
seed[i].Required = true
|
||||
}
|
||||
}
|
||||
}
|
||||
encoded, err := json.Marshal(seed)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err)
|
||||
}
|
||||
if err := s.settingRepo.Set(dbCtx, SettingKeyCodexCLIOnlyEngineFingerprintSignals, string(encoded)); err != nil {
|
||||
return fmt.Errorf("set %s setting: %w", SettingKeyCodexCLIOnlyEngineFingerprintSignals, err)
|
||||
}
|
||||
s.codexRestrictionPolicySF.Forget("codex_restriction_policy")
|
||||
s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0})
|
||||
return nil
|
||||
}
|
||||
|
||||
func codexClientEntriesContain(entries []openai.AllowedClientEntry, want openai.AllowedClientEntry) bool {
|
||||
wantOriginator := strings.TrimSpace(want.Originator)
|
||||
if wantOriginator == "" {
|
||||
return false
|
||||
}
|
||||
wantMarkers := normalizedCodexClientMarkers(want.UAContains)
|
||||
if len(wantMarkers) == 0 {
|
||||
return false
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if !strings.EqualFold(strings.TrimSpace(entry.Originator), wantOriginator) {
|
||||
continue
|
||||
}
|
||||
gotMarkers := normalizedCodexClientMarkers(entry.UAContains)
|
||||
if len(gotMarkers) != len(wantMarkers) {
|
||||
continue
|
||||
}
|
||||
matched := true
|
||||
for marker := range wantMarkers {
|
||||
if _, ok := gotMarkers[marker]; !ok {
|
||||
matched = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if matched {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizedCodexClientMarkers(markers []string) map[string]struct{} {
|
||||
normalized := make(map[string]struct{}, len(markers))
|
||||
for _, marker := range markers {
|
||||
marker = strings.TrimSpace(marker)
|
||||
if marker == "" {
|
||||
continue
|
||||
}
|
||||
normalized[strings.ToLower(marker)] = struct{}{}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
// GetCodexRestrictionPolicy 读取 codex_cli_only 全局加固策略(黑/白名单、最低版本、引擎指纹门)。
|
||||
// 仅在调用方已确认账号 codex_cli_only 开启时读取;进程内 atomic.Value 缓存(60s TTL)避免热路径访问 DB。
|
||||
// 任意键缺失/解析失败 → 安全默认:空名单、空版本、默认种子指纹信号。
|
||||
func (s *SettingService) GetCodexRestrictionPolicy(ctx context.Context) CodexRestrictionPolicy {
|
||||
if cached, ok := s.codexRestrictionPolicyCache.Load().(*cachedCodexRestrictionPolicy); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.value
|
||||
}
|
||||
}
|
||||
result, _, _ := s.codexRestrictionPolicySF.Do("codex_restriction_policy", func() (any, error) {
|
||||
if cached, ok := s.codexRestrictionPolicyCache.Load().(*cachedCodexRestrictionPolicy); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.value, nil
|
||||
}
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), codexRestrictionPolicyDBTimeout)
|
||||
defer cancel()
|
||||
|
||||
pol := CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals} // 安全默认:默认种子指纹信号
|
||||
if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyMinCodexVersion); err == nil {
|
||||
pol.MinCodexVersion = strings.TrimSpace(v)
|
||||
}
|
||||
if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyMaxCodexVersion); err == nil {
|
||||
pol.MaxCodexVersion = strings.TrimSpace(v)
|
||||
}
|
||||
if v, err := s.settingRepo.GetValue(dbCtx, SettingKeyCodexCLIOnlyAllowAppServerClients); err == nil {
|
||||
pol.AllowAppServerClients = strings.TrimSpace(v) == "true" // 仅显式 "true" 开启
|
||||
}
|
||||
pol.EngineFingerprintSignals = s.loadEngineFingerprintSignals(dbCtx)
|
||||
pol.Whitelist = s.loadCodexClientEntries(dbCtx, SettingKeyCodexCLIOnlyWhitelist)
|
||||
pol.Blacklist = s.loadCodexClientEntries(dbCtx, SettingKeyCodexCLIOnlyBlacklist)
|
||||
|
||||
s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{
|
||||
value: pol,
|
||||
expiresAt: time.Now().Add(codexRestrictionPolicyCacheTTL).UnixNano(),
|
||||
})
|
||||
return pol, nil
|
||||
})
|
||||
if pol, ok := result.(CodexRestrictionPolicy); ok {
|
||||
return pol
|
||||
}
|
||||
return CodexRestrictionPolicy{EngineFingerprintSignals: openai.DefaultEngineFingerprintSignals}
|
||||
}
|
||||
|
||||
// loadCodexClientEntries 读取并解析 []openai.AllowedClientEntry JSON 设置;缺失/空/非法 → nil(安全忽略)。
|
||||
func (s *SettingService) loadCodexClientEntries(ctx context.Context, key string) []openai.AllowedClientEntry {
|
||||
v, err := s.settingRepo.GetValue(ctx, key)
|
||||
if err != nil || strings.TrimSpace(v) == "" {
|
||||
return nil
|
||||
}
|
||||
var entries []openai.AllowedClientEntry
|
||||
if json.Unmarshal([]byte(v), &entries) != nil {
|
||||
return nil
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
// loadEngineFingerprintSignals 读取引擎指纹信号列表;缺失/空/非法 → 默认种子。
|
||||
func (s *SettingService) loadEngineFingerprintSignals(ctx context.Context) []openai.EngineFingerprintSignal {
|
||||
v, err := s.settingRepo.GetValue(ctx, SettingKeyCodexCLIOnlyEngineFingerprintSignals)
|
||||
if err != nil || strings.TrimSpace(v) == "" {
|
||||
return openai.DefaultEngineFingerprintSignals
|
||||
}
|
||||
sigs, ok := openai.ParseEngineFingerprintSignals(v)
|
||||
if !ok {
|
||||
return openai.DefaultEngineFingerprintSignals
|
||||
}
|
||||
return sigs
|
||||
}
|
||||
|
||||
// ValidateCodexClientEntriesJSON 校验 codex_cli_only 名单 JSON 配置(黑名单语义):
|
||||
// 空=合法(禁用);非空须为 []AllowedClientEntry 的 JSON 数组。黑名单是 OR 宽 deny,
|
||||
// 允许 originator-only 条目,故不校验 ua_contains。白名单请用 ValidateCodexWhitelistEntriesJSON。
|
||||
func ValidateCodexClientEntriesJSON(raw string) error {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
var entries []openai.AllowedClientEntry
|
||||
if err := json.Unmarshal([]byte(trimmed), &entries); err != nil {
|
||||
return fmt.Errorf("must be empty or a valid JSON array of {originator, ua_contains}")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateCodexWhitelistEntriesJSON 在 ValidateCodexClientEntriesJSON 的数组结构校验之上,额外要求
|
||||
// 每条白名单条目「有可能命中」(openai.AllowedClientEntry.IsWhitelistable)。白名单是双因子 AND:
|
||||
// originator-only、空或含空白 ua_contains 的条目会在运行时静默失效——这里让管理员在写入时即收到反馈,
|
||||
// 而非存入永不命中的死规则。黑名单(OR 宽 deny)仍用 ValidateCodexClientEntriesJSON。
|
||||
func ValidateCodexWhitelistEntriesJSON(raw string) error {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" {
|
||||
return nil
|
||||
}
|
||||
var entries []openai.AllowedClientEntry
|
||||
if err := json.Unmarshal([]byte(trimmed), &entries); err != nil {
|
||||
return fmt.Errorf("must be empty or a valid JSON array of {originator, ua_contains}")
|
||||
}
|
||||
for i, e := range entries {
|
||||
if !e.IsWhitelistable() {
|
||||
return fmt.Errorf("entry %d: whitelist requires a non-empty originator and at least one non-empty ua_contains (double-factor AND; otherwise the rule never matches)", i)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidateEngineFingerprintSignalsJSON 服务层包装,复用 openai 校验逻辑。
|
||||
func ValidateEngineFingerprintSignalsJSON(raw string) error {
|
||||
return openai.ValidateEngineFingerprintSignalsJSON(raw)
|
||||
}
|
||||
|
||||
// IsBackendModeEnabled checks if backend mode is enabled
|
||||
// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path
|
||||
func (s *SettingService) IsBackendModeEnabled(ctx context.Context) bool {
|
||||
if cached, ok := backendModeCache.Load().(*cachedBackendMode); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.value
|
||||
}
|
||||
}
|
||||
result, _, _ := backendModeSF.Do("backend_mode", func() (any, error) {
|
||||
if cached, ok := backendModeCache.Load().(*cachedBackendMode); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.value, nil
|
||||
}
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), backendModeDBTimeout)
|
||||
defer cancel()
|
||||
value, err := s.settingRepo.GetValue(dbCtx, SettingKeyBackendModeEnabled)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrSettingNotFound) {
|
||||
// Setting not yet created (fresh install) - default to disabled with full TTL
|
||||
backendModeCache.Store(&cachedBackendMode{
|
||||
value: false,
|
||||
expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(),
|
||||
})
|
||||
return false, nil
|
||||
}
|
||||
slog.Warn("failed to get backend_mode_enabled setting", "error", err)
|
||||
backendModeCache.Store(&cachedBackendMode{
|
||||
value: false,
|
||||
expiresAt: time.Now().Add(backendModeErrorTTL).UnixNano(),
|
||||
})
|
||||
return false, nil
|
||||
}
|
||||
enabled := value == "true"
|
||||
backendModeCache.Store(&cachedBackendMode{
|
||||
value: enabled,
|
||||
expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(),
|
||||
})
|
||||
return enabled, nil
|
||||
})
|
||||
if val, ok := result.(bool); ok {
|
||||
return val
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type gatewayForwardingSettingsResult struct {
|
||||
fp, mp, cch, claudeOAuthSystemPromptInjection, cacheTTL1h, rewriteMessageCacheControl bool
|
||||
clientDatelineNormalization bool
|
||||
claudeOAuthSystemPrompt, claudeOAuthSystemPromptBlocks string
|
||||
}
|
||||
|
||||
func (s *SettingService) getGatewayForwardingSettingsCached(ctx context.Context) gatewayForwardingSettingsResult {
|
||||
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return gatewayForwardingSettingsResult{
|
||||
fp: cached.fingerprintUnification,
|
||||
mp: cached.metadataPassthrough,
|
||||
cch: cached.cchSigning,
|
||||
claudeOAuthSystemPromptInjection: cached.claudeOAuthSystemPromptInjection,
|
||||
claudeOAuthSystemPrompt: cached.claudeOAuthSystemPrompt,
|
||||
claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks,
|
||||
cacheTTL1h: cached.anthropicCacheTTL1hInjection,
|
||||
rewriteMessageCacheControl: cached.rewriteMessageCacheControl,
|
||||
clientDatelineNormalization: cached.clientDatelineNormalization,
|
||||
}
|
||||
}
|
||||
}
|
||||
val, _, _ := gatewayForwardingSF.Do("gateway_forwarding", func() (any, error) {
|
||||
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return gatewayForwardingSettingsResult{
|
||||
fp: cached.fingerprintUnification,
|
||||
mp: cached.metadataPassthrough,
|
||||
cch: cached.cchSigning,
|
||||
claudeOAuthSystemPromptInjection: cached.claudeOAuthSystemPromptInjection,
|
||||
claudeOAuthSystemPrompt: cached.claudeOAuthSystemPrompt,
|
||||
claudeOAuthSystemPromptBlocks: cached.claudeOAuthSystemPromptBlocks,
|
||||
cacheTTL1h: cached.anthropicCacheTTL1hInjection,
|
||||
rewriteMessageCacheControl: cached.rewriteMessageCacheControl,
|
||||
clientDatelineNormalization: cached.clientDatelineNormalization,
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout)
|
||||
defer cancel()
|
||||
values, err := s.settingRepo.GetMultiple(dbCtx, []string{
|
||||
SettingKeyEnableFingerprintUnification,
|
||||
SettingKeyEnableMetadataPassthrough,
|
||||
SettingKeyEnableCCHSigning,
|
||||
SettingKeyEnableClaudeOAuthSystemPromptInjection,
|
||||
SettingKeyClaudeOAuthSystemPrompt,
|
||||
SettingKeyClaudeOAuthSystemPromptBlocks,
|
||||
SettingKeyEnableAnthropicCacheTTL1hInjection,
|
||||
SettingKeyRewriteMessageCacheControl,
|
||||
SettingKeyEnableClientDatelineNormalization,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("failed to get gateway forwarding settings", "error", err)
|
||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
||||
fingerprintUnification: true,
|
||||
metadataPassthrough: false,
|
||||
cchSigning: false,
|
||||
claudeOAuthSystemPromptInjection: true,
|
||||
anthropicCacheTTL1hInjection: false,
|
||||
rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(),
|
||||
clientDatelineNormalization: true,
|
||||
expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(),
|
||||
})
|
||||
return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, rewriteMessageCacheControl: s.defaultRewriteMessageCacheControl(), clientDatelineNormalization: true}, nil
|
||||
}
|
||||
fp := true
|
||||
if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" {
|
||||
fp = v == "true"
|
||||
}
|
||||
mp := values[SettingKeyEnableMetadataPassthrough] == "true"
|
||||
cch := values[SettingKeyEnableCCHSigning] == "true"
|
||||
systemPromptInjection := true
|
||||
if v, ok := values[SettingKeyEnableClaudeOAuthSystemPromptInjection]; ok && v != "" {
|
||||
systemPromptInjection = v == "true"
|
||||
}
|
||||
systemPrompt := values[SettingKeyClaudeOAuthSystemPrompt]
|
||||
systemPromptBlocks := values[SettingKeyClaudeOAuthSystemPromptBlocks]
|
||||
cacheTTL1h := values[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true"
|
||||
rewriteMessageCacheControl := s.defaultRewriteMessageCacheControl()
|
||||
if v, ok := values[SettingKeyRewriteMessageCacheControl]; ok && v != "" {
|
||||
rewriteMessageCacheControl = v == "true"
|
||||
}
|
||||
clientDatelineNormalization := true
|
||||
if v, ok := values[SettingKeyEnableClientDatelineNormalization]; ok && v != "" {
|
||||
clientDatelineNormalization = v == "true"
|
||||
}
|
||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
||||
fingerprintUnification: fp,
|
||||
metadataPassthrough: mp,
|
||||
cchSigning: cch,
|
||||
claudeOAuthSystemPromptInjection: systemPromptInjection,
|
||||
claudeOAuthSystemPrompt: systemPrompt,
|
||||
claudeOAuthSystemPromptBlocks: systemPromptBlocks,
|
||||
anthropicCacheTTL1hInjection: cacheTTL1h,
|
||||
rewriteMessageCacheControl: rewriteMessageCacheControl,
|
||||
clientDatelineNormalization: clientDatelineNormalization,
|
||||
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
|
||||
})
|
||||
return gatewayForwardingSettingsResult{
|
||||
fp: fp,
|
||||
mp: mp,
|
||||
cch: cch,
|
||||
claudeOAuthSystemPromptInjection: systemPromptInjection,
|
||||
claudeOAuthSystemPrompt: systemPrompt,
|
||||
claudeOAuthSystemPromptBlocks: systemPromptBlocks,
|
||||
cacheTTL1h: cacheTTL1h,
|
||||
rewriteMessageCacheControl: rewriteMessageCacheControl,
|
||||
clientDatelineNormalization: clientDatelineNormalization,
|
||||
}, nil
|
||||
})
|
||||
if r, ok := val.(gatewayForwardingSettingsResult); ok {
|
||||
return r
|
||||
}
|
||||
return gatewayForwardingSettingsResult{fp: true, claudeOAuthSystemPromptInjection: true, clientDatelineNormalization: true}
|
||||
}
|
||||
|
||||
// GetGatewayForwardingSettings returns cached gateway forwarding settings.
|
||||
// Uses in-process atomic.Value cache with 60s TTL, zero-lock hot path.
|
||||
// Returns (fingerprintUnification, metadataPassthrough, cchSigning).
|
||||
func (s *SettingService) GetGatewayForwardingSettings(ctx context.Context) (fingerprintUnification, metadataPassthrough, cchSigning bool) {
|
||||
result := s.getGatewayForwardingSettingsCached(ctx)
|
||||
return result.fp, result.mp, result.cch
|
||||
}
|
||||
|
||||
// IsAnthropicCacheTTL1hInjectionEnabled 检查是否对 Anthropic OAuth/SetupToken 请求体注入 1h cache_control ttl。
|
||||
func (s *SettingService) IsAnthropicCacheTTL1hInjectionEnabled(ctx context.Context) bool {
|
||||
return s.getGatewayForwardingSettingsCached(ctx).cacheTTL1h
|
||||
}
|
||||
|
||||
// IsRewriteMessageCacheControlEnabled 检查是否启用 messages cache_control 改写。
|
||||
func (s *SettingService) IsRewriteMessageCacheControlEnabled(ctx context.Context) bool {
|
||||
return s.getGatewayForwardingSettingsCached(ctx).rewriteMessageCacheControl
|
||||
}
|
||||
|
||||
// IsClientDatelineNormalizationEnabled 检查是否启用 Anthropic OAuth/SetupToken 请求体
|
||||
// 的客户端 dateline 归一化。默认开启。
|
||||
func (s *SettingService) IsClientDatelineNormalizationEnabled(ctx context.Context) bool {
|
||||
return s.getGatewayForwardingSettingsCached(ctx).clientDatelineNormalization
|
||||
}
|
||||
|
||||
// GetClaudeOAuthSystemPromptInjectionSettings returns the Claude OAuth mimic
|
||||
// system block switch, legacy custom expansion prompt, and configurable blocks JSON.
|
||||
// Empty values mean use the built-in Claude Code default blocks.
|
||||
func (s *SettingService) GetClaudeOAuthSystemPromptInjectionSettings(ctx context.Context) (enabled bool, prompt string, blocks string) {
|
||||
result := s.getGatewayForwardingSettingsCached(ctx)
|
||||
return result.claudeOAuthSystemPromptInjection, result.claudeOAuthSystemPrompt, result.claudeOAuthSystemPromptBlocks
|
||||
}
|
||||
|
||||
// GetClaudeCodeVersionBounds 获取 Claude Code 版本号上下限要求
|
||||
// 使用进程内 atomic.Value 缓存,60 秒 TTL,热路径零锁开销
|
||||
// singleflight 防止缓存过期时 thundering herd
|
||||
// 返回空字符串表示不做对应方向的版本检查
|
||||
func (s *SettingService) GetClaudeCodeVersionBounds(ctx context.Context) (min, max string) {
|
||||
if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return cached.min, cached.max
|
||||
}
|
||||
}
|
||||
// singleflight: 同一时刻只有一个 goroutine 查询 DB,其余复用结果
|
||||
type bounds struct{ min, max string }
|
||||
result, err, _ := versionBoundsSF.Do("version_bounds", func() (any, error) {
|
||||
// 二次检查,避免排队的 goroutine 重复查询
|
||||
if cached, ok := versionBoundsCache.Load().(*cachedVersionBounds); ok {
|
||||
if time.Now().UnixNano() < cached.expiresAt {
|
||||
return bounds{cached.min, cached.max}, nil
|
||||
}
|
||||
}
|
||||
// 使用独立 context:断开请求取消链,避免客户端断连导致空值被长期缓存
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), versionBoundsDBTimeout)
|
||||
defer cancel()
|
||||
values, err := s.settingRepo.GetMultiple(dbCtx, []string{
|
||||
SettingKeyMinClaudeCodeVersion,
|
||||
SettingKeyMaxClaudeCodeVersion,
|
||||
})
|
||||
if err != nil {
|
||||
// fail-open: DB 错误时不阻塞请求,但记录日志并使用短 TTL 快速重试
|
||||
slog.Warn("failed to get claude code version bounds setting, skipping version check", "error", err)
|
||||
versionBoundsCache.Store(&cachedVersionBounds{
|
||||
min: "",
|
||||
max: "",
|
||||
expiresAt: time.Now().Add(versionBoundsErrorTTL).UnixNano(),
|
||||
})
|
||||
return bounds{"", ""}, nil
|
||||
}
|
||||
b := bounds{
|
||||
min: values[SettingKeyMinClaudeCodeVersion],
|
||||
max: values[SettingKeyMaxClaudeCodeVersion],
|
||||
}
|
||||
versionBoundsCache.Store(&cachedVersionBounds{
|
||||
min: b.min,
|
||||
max: b.max,
|
||||
expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(),
|
||||
})
|
||||
return b, nil
|
||||
})
|
||||
if err != nil {
|
||||
return "", ""
|
||||
}
|
||||
b, ok := result.(bounds)
|
||||
if !ok {
|
||||
return "", ""
|
||||
}
|
||||
return b.min, b.max
|
||||
}
|
||||
|
||||
// GetOpenAIQuotaAutoPauseSettings returns the current global default quota auto-pause
|
||||
// settings. It is invoked on the OpenAI scheduling hot path (once per request) and is
|
||||
// therefore designed to never block on the DB:
|
||||
//
|
||||
// - Fresh cached value → returned immediately.
|
||||
// - Stale or empty cache → the last known value is returned, and a background
|
||||
// goroutine refreshes the cache via singleflight (stale-while-revalidate).
|
||||
// - First call with no cache yet → zero defaults are returned and the same async
|
||||
// refresh is kicked off; the next call gets the freshly populated value.
|
||||
//
|
||||
// Callers that need the freshly persisted value synchronously (tests, post-update
|
||||
// confirmation, optional startup warm-up) should call WarmOpenAIQuotaAutoPauseSettings.
|
||||
func (s *SettingService) GetOpenAIQuotaAutoPauseSettings(ctx context.Context) OpsOpenAIAccountQuotaAutoPauseSettings {
|
||||
if s == nil {
|
||||
return OpsOpenAIAccountQuotaAutoPauseSettings{}
|
||||
}
|
||||
cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings)
|
||||
now := time.Now().UnixNano()
|
||||
if cached != nil && now < cached.expiresAt {
|
||||
return cached.settings
|
||||
}
|
||||
// Stale or unset: trigger background refresh without blocking this request.
|
||||
// singleflight.DoChan dedupes concurrent refreshes; we deliberately ignore the
|
||||
// returned channel — the result is observable via the atomic cache.
|
||||
s.openAIQuotaAutoPauseSettingsSF.DoChan(openAIQuotaAutoPauseSettingsRefreshKey, func() (any, error) {
|
||||
s.refreshOpenAIQuotaAutoPauseSettings(context.Background())
|
||||
return nil, nil
|
||||
})
|
||||
if cached != nil {
|
||||
return cached.settings // serve stale value while revalidating
|
||||
}
|
||||
return OpsOpenAIAccountQuotaAutoPauseSettings{}
|
||||
}
|
||||
|
||||
// WarmOpenAIQuotaAutoPauseSettings synchronously loads the quota auto-pause settings
|
||||
// into the in-memory cache. Useful for application startup (so the first request hits
|
||||
// a warm cache) and for tests that need deterministic reads immediately after
|
||||
// constructing the service.
|
||||
func (s *SettingService) WarmOpenAIQuotaAutoPauseSettings(ctx context.Context) OpsOpenAIAccountQuotaAutoPauseSettings {
|
||||
if s == nil {
|
||||
return OpsOpenAIAccountQuotaAutoPauseSettings{}
|
||||
}
|
||||
s.refreshOpenAIQuotaAutoPauseSettings(ctx)
|
||||
cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings)
|
||||
if cached == nil {
|
||||
return OpsOpenAIAccountQuotaAutoPauseSettings{}
|
||||
}
|
||||
return cached.settings
|
||||
}
|
||||
|
||||
// refreshOpenAIQuotaAutoPauseSettings reads the latest settings from the DB and stores
|
||||
// them into the in-memory cache. On error it stores the prior value (or zero defaults
|
||||
// if nothing is cached yet) with the shorter error TTL so the next refresh comes
|
||||
// sooner. Always uses its own timeout-bounded context to keep refresh latency
|
||||
// predictable regardless of the caller.
|
||||
func (s *SettingService) refreshOpenAIQuotaAutoPauseSettings(ctx context.Context) {
|
||||
if s == nil || s.settingRepo == nil {
|
||||
return
|
||||
}
|
||||
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAIQuotaAutoPauseSettingsDBTimeout)
|
||||
defer cancel()
|
||||
|
||||
settings := OpsOpenAIAccountQuotaAutoPauseSettings{}
|
||||
ttl := openAIQuotaAutoPauseSettingsCacheTTL
|
||||
raw, err := s.settingRepo.GetValue(dbCtx, SettingKeyOpsAdvancedSettings)
|
||||
if err == nil {
|
||||
cfg := defaultOpsAdvancedSettings()
|
||||
if strings.TrimSpace(raw) != "" {
|
||||
if jsonErr := json.Unmarshal([]byte(raw), cfg); jsonErr == nil {
|
||||
normalizeOpsAdvancedSettings(cfg)
|
||||
}
|
||||
}
|
||||
settings = cfg.OpenAIAccountQuotaAutoPause
|
||||
} else if !errors.Is(err, ErrSettingNotFound) {
|
||||
// Real error: keep serving prior value but refresh sooner.
|
||||
if prior, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings); prior != nil {
|
||||
settings = prior.settings
|
||||
}
|
||||
ttl = openAIQuotaAutoPauseSettingsErrorTTL
|
||||
}
|
||||
|
||||
s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{
|
||||
settings: settings,
|
||||
expiresAt: time.Now().Add(ttl).UnixNano(),
|
||||
})
|
||||
}
|
||||
|
||||
// SetOpenAIQuotaAutoPauseSettings writes the given settings directly into the in-memory
|
||||
// cache. Called from settings-write code paths so that the next read reflects the new
|
||||
// value immediately, without waiting for the background refresh.
|
||||
func (s *SettingService) SetOpenAIQuotaAutoPauseSettings(settings OpsOpenAIAccountQuotaAutoPauseSettings) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{
|
||||
settings: settings,
|
||||
expiresAt: time.Now().Add(openAIQuotaAutoPauseSettingsCacheTTL).UnixNano(),
|
||||
})
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,688 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
|
||||
)
|
||||
|
||||
func normalizeLoginAgreementMode(raw string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(raw)) {
|
||||
case "checkbox":
|
||||
return "checkbox"
|
||||
default:
|
||||
return defaultLoginAgreementMode
|
||||
}
|
||||
}
|
||||
|
||||
func defaultLoginAgreementDocuments() []LoginAgreementDocument {
|
||||
return []LoginAgreementDocument{
|
||||
{
|
||||
ID: "terms",
|
||||
Title: "服务条款",
|
||||
ContentMD: "",
|
||||
},
|
||||
{
|
||||
ID: "usage-policy",
|
||||
Title: "使用政策",
|
||||
ContentMD: "",
|
||||
},
|
||||
{
|
||||
ID: "supported-regions",
|
||||
Title: "支持的国家和地区",
|
||||
ContentMD: "",
|
||||
},
|
||||
{
|
||||
ID: "service-specific-terms",
|
||||
Title: "服务特定条款",
|
||||
ContentMD: "",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func normalizeLoginAgreementDocumentID(raw string) string {
|
||||
raw = strings.ToLower(strings.TrimSpace(raw))
|
||||
var b strings.Builder
|
||||
lastSeparator := false
|
||||
for _, r := range raw {
|
||||
if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') {
|
||||
_, _ = b.WriteRune(r)
|
||||
lastSeparator = false
|
||||
continue
|
||||
}
|
||||
if r == '-' || r == '_' || r == ' ' || r == '.' || r == '/' {
|
||||
if !lastSeparator && b.Len() > 0 {
|
||||
if r == '_' {
|
||||
_, _ = b.WriteRune('_')
|
||||
} else {
|
||||
_, _ = b.WriteRune('-')
|
||||
}
|
||||
lastSeparator = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Trim(b.String(), "-_")
|
||||
}
|
||||
|
||||
func normalizeLoginAgreementDocuments(docs []LoginAgreementDocument) []LoginAgreementDocument {
|
||||
normalized := make([]LoginAgreementDocument, 0, len(docs))
|
||||
seen := make(map[string]int, len(docs))
|
||||
for i, doc := range docs {
|
||||
title := strings.TrimSpace(doc.Title)
|
||||
content := strings.TrimSpace(doc.ContentMD)
|
||||
if title == "" && content == "" {
|
||||
continue
|
||||
}
|
||||
id := normalizeLoginAgreementDocumentID(doc.ID)
|
||||
if id == "" {
|
||||
sum := sha256.Sum256([]byte(fmt.Sprintf("%d:%s:%s", i, title, content)))
|
||||
id = hex.EncodeToString(sum[:])[:12]
|
||||
}
|
||||
baseID := id
|
||||
for suffix := 2; seen[id] > 0; suffix++ {
|
||||
id = fmt.Sprintf("%s-%d", baseID, suffix)
|
||||
}
|
||||
seen[id]++
|
||||
normalized = append(normalized, LoginAgreementDocument{
|
||||
ID: id,
|
||||
Title: title,
|
||||
ContentMD: content,
|
||||
})
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func parseLoginAgreementDocuments(raw string) []LoginAgreementDocument {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return defaultLoginAgreementDocuments()
|
||||
}
|
||||
var docs []LoginAgreementDocument
|
||||
if err := json.Unmarshal([]byte(raw), &docs); err != nil {
|
||||
return defaultLoginAgreementDocuments()
|
||||
}
|
||||
docs = normalizeLoginAgreementDocuments(docs)
|
||||
if len(docs) == 0 {
|
||||
return defaultLoginAgreementDocuments()
|
||||
}
|
||||
return docs
|
||||
}
|
||||
|
||||
func marshalLoginAgreementDocuments(docs []LoginAgreementDocument) (string, error) {
|
||||
normalized := normalizeLoginAgreementDocuments(docs)
|
||||
if len(normalized) == 0 {
|
||||
normalized = defaultLoginAgreementDocuments()
|
||||
}
|
||||
b, err := json.Marshal(normalized)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal login agreement documents: %w", err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
func buildLoginAgreementRevision(updatedAt string, docs []LoginAgreementDocument) string {
|
||||
normalized := normalizeLoginAgreementDocuments(docs)
|
||||
payload, err := json.Marshal(struct {
|
||||
UpdatedAt string `json:"updated_at"`
|
||||
Documents []LoginAgreementDocument `json:"documents"`
|
||||
}{
|
||||
UpdatedAt: strings.TrimSpace(updatedAt),
|
||||
Documents: normalized,
|
||||
})
|
||||
if err != nil {
|
||||
payload = []byte(strings.TrimSpace(updatedAt))
|
||||
}
|
||||
sum := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(sum[:])[:16]
|
||||
}
|
||||
|
||||
// GetFrontendURL 获取前端基础URL(数据库优先,fallback 到配置文件)
|
||||
func (s *SettingService) GetFrontendURL(ctx context.Context) string {
|
||||
val, err := s.settingRepo.GetValue(ctx, SettingKeyFrontendURL)
|
||||
if err == nil && strings.TrimSpace(val) != "" {
|
||||
return strings.TrimSpace(val)
|
||||
}
|
||||
return s.cfg.Server.FrontendURL
|
||||
}
|
||||
|
||||
// GetPublicSettings 获取公开设置(无需登录)
|
||||
func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings, error) {
|
||||
keys := []string{
|
||||
SettingKeyRegistrationEnabled,
|
||||
SettingKeyEmailVerifyEnabled,
|
||||
SettingKeyForceEmailOnThirdPartySignup,
|
||||
SettingKeyRegistrationEmailSuffixWhitelist,
|
||||
SettingKeyPromoCodeEnabled,
|
||||
SettingKeyPasswordResetEnabled,
|
||||
SettingKeyInvitationCodeEnabled,
|
||||
SettingKeyTotpEnabled,
|
||||
SettingKeyLoginAgreementEnabled,
|
||||
SettingKeyLoginAgreementMode,
|
||||
SettingKeyLoginAgreementUpdatedAt,
|
||||
SettingKeyLoginAgreementDocuments,
|
||||
SettingKeyTurnstileEnabled,
|
||||
SettingKeyTurnstileSiteKey,
|
||||
SettingKeyAPIKeyACLTrustForwardedIP,
|
||||
SettingKeySiteName,
|
||||
SettingKeySiteLogo,
|
||||
SettingKeySiteSubtitle,
|
||||
SettingKeyAPIBaseURL,
|
||||
SettingKeyContactInfo,
|
||||
SettingKeyDocURL,
|
||||
SettingKeyHomeContent,
|
||||
SettingKeyHideCcsImportButton,
|
||||
SettingKeyPurchaseSubscriptionEnabled,
|
||||
SettingKeyPurchaseSubscriptionURL,
|
||||
SettingKeyTableDefaultPageSize,
|
||||
SettingKeyTablePageSizeOptions,
|
||||
SettingKeyCustomMenuItems,
|
||||
SettingKeyCustomEndpoints,
|
||||
SettingKeyLinuxDoConnectEnabled,
|
||||
SettingKeyDingTalkConnectEnabled,
|
||||
SettingKeyWeChatConnectEnabled,
|
||||
SettingKeyWeChatConnectAppID,
|
||||
SettingKeyWeChatConnectAppSecret,
|
||||
SettingKeyWeChatConnectOpenAppID,
|
||||
SettingKeyWeChatConnectOpenAppSecret,
|
||||
SettingKeyWeChatConnectMPAppID,
|
||||
SettingKeyWeChatConnectMPAppSecret,
|
||||
SettingKeyWeChatConnectMobileAppID,
|
||||
SettingKeyWeChatConnectMobileAppSecret,
|
||||
SettingKeyWeChatConnectOpenEnabled,
|
||||
SettingKeyWeChatConnectMPEnabled,
|
||||
SettingKeyWeChatConnectMobileEnabled,
|
||||
SettingKeyWeChatConnectMode,
|
||||
SettingKeyWeChatConnectScopes,
|
||||
SettingKeyWeChatConnectRedirectURL,
|
||||
SettingKeyWeChatConnectFrontendRedirectURL,
|
||||
SettingKeyBackendModeEnabled,
|
||||
SettingPaymentEnabled,
|
||||
SettingKeyOIDCConnectEnabled,
|
||||
SettingKeyOIDCConnectProviderName,
|
||||
SettingKeyGitHubOAuthEnabled,
|
||||
SettingKeyGitHubOAuthClientID,
|
||||
SettingKeyGitHubOAuthClientSecret,
|
||||
SettingKeyGoogleOAuthEnabled,
|
||||
SettingKeyGoogleOAuthClientID,
|
||||
SettingKeyGoogleOAuthClientSecret,
|
||||
SettingKeyBalanceLowNotifyEnabled,
|
||||
SettingKeyBalanceLowNotifyThreshold,
|
||||
SettingKeyBalanceLowNotifyRechargeURL,
|
||||
SettingKeyAccountQuotaNotifyEnabled,
|
||||
SettingKeyChannelMonitorEnabled,
|
||||
SettingKeyChannelMonitorDefaultIntervalSeconds,
|
||||
SettingKeyAvailableChannelsEnabled,
|
||||
SettingKeyAffiliateEnabled,
|
||||
SettingKeyRiskControlEnabled,
|
||||
SettingKeyAllowUserViewErrorRequests,
|
||||
}
|
||||
|
||||
settings, err := s.settingRepo.GetMultiple(ctx, keys)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get public settings: %w", err)
|
||||
}
|
||||
|
||||
linuxDoEnabled := false
|
||||
if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok {
|
||||
linuxDoEnabled = raw == "true"
|
||||
} else {
|
||||
linuxDoEnabled = s.cfg != nil && s.cfg.LinuxDo.Enabled
|
||||
}
|
||||
dingTalkEnabled := false
|
||||
if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok {
|
||||
dingTalkEnabled = raw == "true"
|
||||
} else {
|
||||
dingTalkEnabled = s.cfg != nil && s.cfg.DingTalk.Enabled
|
||||
}
|
||||
oidcEnabled := false
|
||||
if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok {
|
||||
oidcEnabled = raw == "true"
|
||||
} else {
|
||||
oidcEnabled = s.cfg != nil && s.cfg.OIDC.Enabled
|
||||
}
|
||||
oidcProviderName := strings.TrimSpace(settings[SettingKeyOIDCConnectProviderName])
|
||||
if oidcProviderName == "" && s.cfg != nil {
|
||||
oidcProviderName = strings.TrimSpace(s.cfg.OIDC.ProviderName)
|
||||
}
|
||||
if oidcProviderName == "" {
|
||||
oidcProviderName = "OIDC"
|
||||
}
|
||||
gitHubEnabled := s.emailOAuthPublicEnabled(settings, "github")
|
||||
googleEnabled := s.emailOAuthPublicEnabled(settings, "google")
|
||||
weChatEnabled, weChatOpenEnabled, weChatMPEnabled, weChatMobileEnabled := s.weChatOAuthCapabilitiesFromSettings(settings)
|
||||
|
||||
// Password reset requires email verification to be enabled
|
||||
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
|
||||
passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true"
|
||||
registrationEmailSuffixWhitelist := ParseRegistrationEmailSuffixWhitelist(
|
||||
settings[SettingKeyRegistrationEmailSuffixWhitelist],
|
||||
)
|
||||
tableDefaultPageSize, tablePageSizeOptions := parseTablePreferences(
|
||||
settings[SettingKeyTableDefaultPageSize],
|
||||
settings[SettingKeyTablePageSizeOptions],
|
||||
)
|
||||
loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments])
|
||||
loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt])
|
||||
if loginAgreementUpdatedAt == "" {
|
||||
loginAgreementUpdatedAt = defaultLoginAgreementDate
|
||||
}
|
||||
|
||||
var balanceLowNotifyThreshold float64
|
||||
if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
|
||||
balanceLowNotifyThreshold = v
|
||||
}
|
||||
|
||||
return &PublicSettings{
|
||||
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
|
||||
EmailVerifyEnabled: emailVerifyEnabled,
|
||||
ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true",
|
||||
RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist,
|
||||
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
|
||||
PasswordResetEnabled: passwordResetEnabled,
|
||||
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
|
||||
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
|
||||
LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0,
|
||||
LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]),
|
||||
LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
|
||||
LoginAgreementRevision: buildLoginAgreementRevision(loginAgreementUpdatedAt, loginAgreementDocuments),
|
||||
LoginAgreementDocuments: loginAgreementDocuments,
|
||||
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
|
||||
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
|
||||
SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
|
||||
SiteLogo: settings[SettingKeySiteLogo],
|
||||
SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
|
||||
APIBaseURL: settings[SettingKeyAPIBaseURL],
|
||||
ContactInfo: settings[SettingKeyContactInfo],
|
||||
DocURL: settings[SettingKeyDocURL],
|
||||
HomeContent: settings[SettingKeyHomeContent],
|
||||
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
|
||||
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
|
||||
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
||||
TableDefaultPageSize: tableDefaultPageSize,
|
||||
TablePageSizeOptions: tablePageSizeOptions,
|
||||
CustomMenuItems: settings[SettingKeyCustomMenuItems],
|
||||
CustomEndpoints: settings[SettingKeyCustomEndpoints],
|
||||
LinuxDoOAuthEnabled: linuxDoEnabled,
|
||||
DingTalkOAuthEnabled: dingTalkEnabled,
|
||||
WeChatOAuthEnabled: weChatEnabled,
|
||||
WeChatOAuthOpenEnabled: weChatOpenEnabled,
|
||||
WeChatOAuthMPEnabled: weChatMPEnabled,
|
||||
WeChatOAuthMobileEnabled: weChatMobileEnabled,
|
||||
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
|
||||
PaymentEnabled: settings[SettingPaymentEnabled] == "true",
|
||||
OIDCOAuthEnabled: oidcEnabled,
|
||||
OIDCOAuthProviderName: oidcProviderName,
|
||||
GitHubOAuthEnabled: gitHubEnabled,
|
||||
GoogleOAuthEnabled: googleEnabled,
|
||||
BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true",
|
||||
AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true",
|
||||
BalanceLowNotifyThreshold: balanceLowNotifyThreshold,
|
||||
BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL],
|
||||
|
||||
ChannelMonitorEnabled: !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]),
|
||||
ChannelMonitorDefaultIntervalSeconds: parseChannelMonitorInterval(settings[SettingKeyChannelMonitorDefaultIntervalSeconds]),
|
||||
|
||||
AvailableChannelsEnabled: settings[SettingKeyAvailableChannelsEnabled] == "true",
|
||||
|
||||
AffiliateEnabled: settings[SettingKeyAffiliateEnabled] == "true",
|
||||
|
||||
RiskControlEnabled: settings[SettingKeyRiskControlEnabled] == "true",
|
||||
|
||||
AllowUserViewErrorRequests: settings[SettingKeyAllowUserViewErrorRequests] == "true",
|
||||
}, nil
|
||||
}
|
||||
|
||||
// channelMonitorIntervalMin / channelMonitorIntervalMax bound the default interval
|
||||
// (mirrors the monitor-level constraint but lives here so setting_service stays decoupled).
|
||||
const (
|
||||
channelMonitorIntervalMin = 15
|
||||
channelMonitorIntervalMax = 3600
|
||||
channelMonitorIntervalFallback = 60
|
||||
)
|
||||
|
||||
// parseChannelMonitorInterval parses the stored string and clamps to [15, 3600].
|
||||
// Empty / invalid input falls back to channelMonitorIntervalFallback.
|
||||
func parseChannelMonitorInterval(raw string) int {
|
||||
v, err := strconv.Atoi(strings.TrimSpace(raw))
|
||||
if err != nil {
|
||||
return channelMonitorIntervalFallback
|
||||
}
|
||||
return clampChannelMonitorInterval(v)
|
||||
}
|
||||
|
||||
// clampChannelMonitorInterval clamps v to the allowed range. 0 means "not provided".
|
||||
func clampChannelMonitorInterval(v int) int {
|
||||
if v <= 0 {
|
||||
return 0
|
||||
}
|
||||
if v < channelMonitorIntervalMin {
|
||||
return channelMonitorIntervalMin
|
||||
}
|
||||
if v > channelMonitorIntervalMax {
|
||||
return channelMonitorIntervalMax
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ChannelMonitorRuntime is the lightweight view of the channel monitor feature
|
||||
// consumed by the runner and user-facing handlers.
|
||||
type ChannelMonitorRuntime struct {
|
||||
Enabled bool
|
||||
DefaultIntervalSeconds int
|
||||
}
|
||||
|
||||
// GetChannelMonitorRuntime reads the channel monitor feature flags directly from
|
||||
// the settings store. Fail-open: on error returns Enabled=true with the default interval.
|
||||
func (s *SettingService) GetChannelMonitorRuntime(ctx context.Context) ChannelMonitorRuntime {
|
||||
vals, err := s.settingRepo.GetMultiple(ctx, []string{
|
||||
SettingKeyChannelMonitorEnabled,
|
||||
SettingKeyChannelMonitorDefaultIntervalSeconds,
|
||||
})
|
||||
if err != nil {
|
||||
return ChannelMonitorRuntime{Enabled: true, DefaultIntervalSeconds: channelMonitorIntervalFallback}
|
||||
}
|
||||
return ChannelMonitorRuntime{
|
||||
Enabled: !isFalseSettingValue(vals[SettingKeyChannelMonitorEnabled]),
|
||||
DefaultIntervalSeconds: parseChannelMonitorInterval(vals[SettingKeyChannelMonitorDefaultIntervalSeconds]),
|
||||
}
|
||||
}
|
||||
|
||||
// AvailableChannelsRuntime is the lightweight view of the available-channels feature
|
||||
// switch consumed by the user-facing handler.
|
||||
type AvailableChannelsRuntime struct {
|
||||
Enabled bool
|
||||
}
|
||||
|
||||
// GetAvailableChannelsRuntime reads the available-channels feature switch directly
|
||||
// from the settings store. Fail-closed: on error returns Enabled=false, matching
|
||||
// the opt-in default (unknown ↔ disabled).
|
||||
func (s *SettingService) GetAvailableChannelsRuntime(ctx context.Context) AvailableChannelsRuntime {
|
||||
vals, err := s.settingRepo.GetMultiple(ctx, []string{SettingKeyAvailableChannelsEnabled})
|
||||
if err != nil {
|
||||
return AvailableChannelsRuntime{Enabled: false}
|
||||
}
|
||||
return AvailableChannelsRuntime{
|
||||
Enabled: vals[SettingKeyAvailableChannelsEnabled] == "true",
|
||||
}
|
||||
}
|
||||
|
||||
// IsUserErrorViewAllowed reads the user-facing error-requests visibility switch
|
||||
// directly from the settings store. Fail-closed: on error returns false (opt-in default).
|
||||
func (s *SettingService) IsUserErrorViewAllowed(ctx context.Context) bool {
|
||||
vals, err := s.settingRepo.GetMultiple(ctx, []string{SettingKeyAllowUserViewErrorRequests})
|
||||
if err != nil {
|
||||
slog.Warn("failed to get allow_user_view_error_requests setting, defaulting to false", "error", err)
|
||||
return false
|
||||
}
|
||||
return vals[SettingKeyAllowUserViewErrorRequests] == "true"
|
||||
}
|
||||
|
||||
// PublicSettingsInjectionPayload is the JSON shape embedded into HTML as
|
||||
// `window.__APP_CONFIG__` so the frontend can hydrate feature flags & site
|
||||
// config before the first XHR finishes.
|
||||
//
|
||||
// INVARIANT: every `json` tag here MUST also exist on handler/dto.PublicSettings.
|
||||
// If you forget a feature-flag field here, the frontend's
|
||||
// `cachedPublicSettings.xxx_enabled` will be `undefined` on refresh until the
|
||||
// async `/api/v1/settings/public` call returns — which causes opt-in menus
|
||||
// (strict `=== true`) to flicker off/on. See
|
||||
// frontend/src/utils/featureFlags.ts for the matching registry.
|
||||
//
|
||||
// A unit test diffs this struct's JSON keys against dto.PublicSettings to catch
|
||||
// drift automatically (see setting_service_injection_test.go).
|
||||
type PublicSettingsInjectionPayload struct {
|
||||
RegistrationEnabled bool `json:"registration_enabled"`
|
||||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||||
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
|
||||
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
||||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||||
TotpEnabled bool `json:"totp_enabled"`
|
||||
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
|
||||
LoginAgreementMode string `json:"login_agreement_mode"`
|
||||
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
|
||||
LoginAgreementRevision string `json:"login_agreement_revision"`
|
||||
LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
|
||||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||||
SiteName string `json:"site_name"`
|
||||
SiteLogo string `json:"site_logo"`
|
||||
SiteSubtitle string `json:"site_subtitle"`
|
||||
APIBaseURL string `json:"api_base_url"`
|
||||
ContactInfo string `json:"contact_info"`
|
||||
DocURL string `json:"doc_url"`
|
||||
HomeContent string `json:"home_content"`
|
||||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||||
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
||||
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
|
||||
TableDefaultPageSize int `json:"table_default_page_size"`
|
||||
TablePageSizeOptions []int `json:"table_page_size_options"`
|
||||
CustomMenuItems json.RawMessage `json:"custom_menu_items"`
|
||||
CustomEndpoints json.RawMessage `json:"custom_endpoints"`
|
||||
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
|
||||
DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"`
|
||||
WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
|
||||
WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
|
||||
WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
|
||||
WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
|
||||
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
|
||||
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
|
||||
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
|
||||
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
|
||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||
PaymentEnabled bool `json:"payment_enabled"`
|
||||
Version string `json:"version"`
|
||||
// 服务器全局时区(IANA 名称与当前 UTC 偏移),高峰时段等服务端本地时间窗口的展示标注用
|
||||
ServerTimezone string `json:"server_timezone"`
|
||||
ServerUTCOffset string `json:"server_utc_offset"`
|
||||
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
|
||||
AccountQuotaNotifyEnabled bool `json:"account_quota_notify_enabled"`
|
||||
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
|
||||
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
|
||||
|
||||
// Feature flags — MUST match the opt-in/opt-out registry in
|
||||
// frontend/src/utils/featureFlags.ts. Missing a field here is the bug
|
||||
// that hid the "可用渠道" menu on page refresh.
|
||||
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
|
||||
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
AffiliateEnabled bool `json:"affiliate_enabled"`
|
||||
RiskControlEnabled bool `json:"risk_control_enabled"`
|
||||
AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
|
||||
}
|
||||
|
||||
// GetPublicSettingsForInjection returns public settings in a format suitable for HTML injection.
|
||||
// This implements the web.PublicSettingsProvider interface.
|
||||
func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any, error) {
|
||||
settings, err := s.GetPublicSettings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &PublicSettingsInjectionPayload{
|
||||
RegistrationEnabled: settings.RegistrationEnabled,
|
||||
EmailVerifyEnabled: settings.EmailVerifyEnabled,
|
||||
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
|
||||
PromoCodeEnabled: settings.PromoCodeEnabled,
|
||||
PasswordResetEnabled: settings.PasswordResetEnabled,
|
||||
InvitationCodeEnabled: settings.InvitationCodeEnabled,
|
||||
TotpEnabled: settings.TotpEnabled,
|
||||
LoginAgreementEnabled: settings.LoginAgreementEnabled,
|
||||
LoginAgreementMode: settings.LoginAgreementMode,
|
||||
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
|
||||
LoginAgreementRevision: settings.LoginAgreementRevision,
|
||||
LoginAgreementDocuments: settings.LoginAgreementDocuments,
|
||||
TurnstileEnabled: settings.TurnstileEnabled,
|
||||
TurnstileSiteKey: settings.TurnstileSiteKey,
|
||||
SiteName: settings.SiteName,
|
||||
SiteLogo: settings.SiteLogo,
|
||||
SiteSubtitle: settings.SiteSubtitle,
|
||||
APIBaseURL: settings.APIBaseURL,
|
||||
ContactInfo: settings.ContactInfo,
|
||||
DocURL: settings.DocURL,
|
||||
HomeContent: settings.HomeContent,
|
||||
HideCcsImportButton: settings.HideCcsImportButton,
|
||||
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
||||
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
||||
TableDefaultPageSize: settings.TableDefaultPageSize,
|
||||
TablePageSizeOptions: settings.TablePageSizeOptions,
|
||||
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
|
||||
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
|
||||
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
|
||||
DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled,
|
||||
WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
|
||||
WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled,
|
||||
WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled,
|
||||
WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
|
||||
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
|
||||
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
|
||||
GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
|
||||
GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
|
||||
BackendModeEnabled: settings.BackendModeEnabled,
|
||||
PaymentEnabled: settings.PaymentEnabled,
|
||||
Version: s.version,
|
||||
ServerTimezone: timezone.Name(),
|
||||
ServerUTCOffset: timezone.UTCOffset(),
|
||||
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
|
||||
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
|
||||
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
|
||||
BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
|
||||
|
||||
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
|
||||
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
||||
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
|
||||
AffiliateEnabled: settings.AffiliateEnabled,
|
||||
RiskControlEnabled: settings.RiskControlEnabled,
|
||||
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// filterUserVisibleMenuItems filters out admin-only menu items from a raw JSON
|
||||
// array string, returning only items with visibility != "admin".
|
||||
func filterUserVisibleMenuItems(raw string) json.RawMessage {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "[]" {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
var items []struct {
|
||||
Visibility string `json:"visibility"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
|
||||
// Parse full items to preserve all fields
|
||||
var fullItems []json.RawMessage
|
||||
if err := json.Unmarshal([]byte(raw), &fullItems); err != nil {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
|
||||
var filtered []json.RawMessage
|
||||
for i, item := range items {
|
||||
if item.Visibility != "admin" {
|
||||
filtered = append(filtered, fullItems[i])
|
||||
}
|
||||
}
|
||||
if len(filtered) == 0 {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
result, err := json.Marshal(filtered)
|
||||
if err != nil {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// safeRawJSONArray returns raw as json.RawMessage if it's valid JSON, otherwise "[]".
|
||||
func safeRawJSONArray(raw string) json.RawMessage {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" {
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
if json.Valid([]byte(raw)) {
|
||||
return json.RawMessage(raw)
|
||||
}
|
||||
return json.RawMessage("[]")
|
||||
}
|
||||
|
||||
// GetFrameSrcOrigins returns deduplicated http(s) origins from home_content URL,
|
||||
// purchase_subscription_url, and all custom_menu_items URLs. Used by the router layer for CSP frame-src injection.
|
||||
func (s *SettingService) GetFrameSrcOrigins(ctx context.Context) ([]string, error) {
|
||||
settings, err := s.GetPublicSettings(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{})
|
||||
var origins []string
|
||||
|
||||
addOrigin := func(rawURL string) {
|
||||
if origin := extractOriginFromURL(rawURL); origin != "" {
|
||||
if _, ok := seen[origin]; !ok {
|
||||
seen[origin] = struct{}{}
|
||||
origins = append(origins, origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// home content URL (when home_content is set to a URL for iframe embedding)
|
||||
addOrigin(settings.HomeContent)
|
||||
|
||||
// purchase subscription URL
|
||||
if settings.PurchaseSubscriptionEnabled {
|
||||
addOrigin(settings.PurchaseSubscriptionURL)
|
||||
}
|
||||
|
||||
// all custom menu items (including admin-only, since CSP must allow all iframes)
|
||||
for _, item := range parseCustomMenuItemURLs(settings.CustomMenuItems) {
|
||||
addOrigin(item)
|
||||
}
|
||||
|
||||
return origins, nil
|
||||
}
|
||||
|
||||
// extractOriginFromURL returns the scheme+host origin from rawURL.
|
||||
// Only http and https schemes are accepted.
|
||||
func extractOriginFromURL(rawURL string) string {
|
||||
rawURL = strings.TrimSpace(rawURL)
|
||||
if rawURL == "" {
|
||||
return ""
|
||||
}
|
||||
u, err := url.Parse(rawURL)
|
||||
if err != nil || u.Host == "" {
|
||||
return ""
|
||||
}
|
||||
if u.Scheme != "http" && u.Scheme != "https" {
|
||||
return ""
|
||||
}
|
||||
return u.Scheme + "://" + u.Host
|
||||
}
|
||||
|
||||
// parseCustomMenuItemURLs extracts URLs from a raw JSON array of custom menu items.
|
||||
func parseCustomMenuItemURLs(raw string) []string {
|
||||
raw = strings.TrimSpace(raw)
|
||||
if raw == "" || raw == "[]" {
|
||||
return nil
|
||||
}
|
||||
var items []struct {
|
||||
URL string `json:"url"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return nil
|
||||
}
|
||||
urls := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.URL != "" {
|
||||
urls = append(urls, item.URL)
|
||||
}
|
||||
}
|
||||
return urls
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,623 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
)
|
||||
|
||||
// UpdateSettings 更新系统设置
|
||||
func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error {
|
||||
updates, err := s.buildSystemSettingsUpdates(ctx, settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = s.settingRepo.SetMultiple(ctx, updates)
|
||||
if err == nil {
|
||||
s.refreshCachedSettings(settings)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
// UpdateSettingsWithAuthSourceDefaults persists system settings and auth-source defaults in a single write.
|
||||
func (s *SettingService) UpdateSettingsWithAuthSourceDefaults(ctx context.Context, settings *SystemSettings, authDefaults *AuthSourceDefaultSettings) error {
|
||||
updates, err := s.buildSystemSettingsUpdates(ctx, settings)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
authSourceUpdates, err := s.buildAuthSourceDefaultUpdates(ctx, authDefaults)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for key, value := range authSourceUpdates {
|
||||
updates[key] = value
|
||||
}
|
||||
|
||||
err = s.settingRepo.SetMultiple(ctx, updates)
|
||||
if err == nil {
|
||||
s.refreshCachedSettings(settings)
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, settings *SystemSettings) (map[string]string, error) {
|
||||
if err := s.validateDefaultSubscriptionGroups(ctx, settings.DefaultSubscriptions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
normalizedWhitelist, err := NormalizeRegistrationEmailSuffixWhitelist(settings.RegistrationEmailSuffixWhitelist)
|
||||
if err != nil {
|
||||
return nil, infraerrors.BadRequest("INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", err.Error())
|
||||
}
|
||||
if normalizedWhitelist == nil {
|
||||
normalizedWhitelist = []string{}
|
||||
}
|
||||
settings.RegistrationEmailSuffixWhitelist = normalizedWhitelist
|
||||
alipaySource, err := normalizeVisibleMethodSettingSource("alipay", settings.PaymentVisibleMethodAlipaySource, settings.PaymentVisibleMethodAlipayEnabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
wxpaySource, err := normalizeVisibleMethodSettingSource("wxpay", settings.PaymentVisibleMethodWxpaySource, settings.PaymentVisibleMethodWxpayEnabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := s.normalizeOpenAIAdvancedSchedulerOverrides(settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
settings.PaymentVisibleMethodAlipaySource = alipaySource
|
||||
settings.PaymentVisibleMethodWxpaySource = wxpaySource
|
||||
settings.WeChatConnectAppID = strings.TrimSpace(settings.WeChatConnectAppID)
|
||||
settings.WeChatConnectAppSecret = strings.TrimSpace(settings.WeChatConnectAppSecret)
|
||||
settings.WeChatConnectOpenAppID = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectOpenAppID, settings.WeChatConnectAppID))
|
||||
settings.WeChatConnectOpenAppSecret = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectOpenAppSecret, settings.WeChatConnectAppSecret))
|
||||
settings.WeChatConnectMPAppID = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMPAppID, settings.WeChatConnectAppID))
|
||||
settings.WeChatConnectMPAppSecret = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMPAppSecret, settings.WeChatConnectAppSecret))
|
||||
settings.WeChatConnectMobileAppID = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMobileAppID, settings.WeChatConnectAppID))
|
||||
settings.WeChatConnectMobileAppSecret = strings.TrimSpace(firstNonEmpty(settings.WeChatConnectMobileAppSecret, settings.WeChatConnectAppSecret))
|
||||
settings.WeChatConnectMode = normalizeWeChatConnectStoredMode(
|
||||
settings.WeChatConnectOpenEnabled,
|
||||
settings.WeChatConnectMPEnabled,
|
||||
settings.WeChatConnectMobileEnabled,
|
||||
settings.WeChatConnectMode,
|
||||
)
|
||||
settings.WeChatConnectScopes = normalizeWeChatConnectScopeSetting(settings.WeChatConnectScopes, settings.WeChatConnectMode)
|
||||
settings.WeChatConnectRedirectURL = strings.TrimSpace(settings.WeChatConnectRedirectURL)
|
||||
settings.WeChatConnectFrontendRedirectURL = strings.TrimSpace(settings.WeChatConnectFrontendRedirectURL)
|
||||
if settings.WeChatConnectFrontendRedirectURL == "" {
|
||||
settings.WeChatConnectFrontendRedirectURL = defaultWeChatConnectFrontend
|
||||
}
|
||||
settings.GitHubOAuthRedirectURL = strings.TrimSpace(settings.GitHubOAuthRedirectURL)
|
||||
settings.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(settings.GitHubOAuthFrontendRedirectURL)
|
||||
if settings.GitHubOAuthFrontendRedirectURL == "" {
|
||||
settings.GitHubOAuthFrontendRedirectURL = defaultGitHubOAuthFrontend
|
||||
}
|
||||
settings.GoogleOAuthRedirectURL = strings.TrimSpace(settings.GoogleOAuthRedirectURL)
|
||||
settings.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(settings.GoogleOAuthFrontendRedirectURL)
|
||||
if settings.GoogleOAuthFrontendRedirectURL == "" {
|
||||
settings.GoogleOAuthFrontendRedirectURL = defaultGoogleOAuthFrontend
|
||||
}
|
||||
|
||||
updates := make(map[string]string)
|
||||
|
||||
// 注册设置
|
||||
updates[SettingKeyRegistrationEnabled] = strconv.FormatBool(settings.RegistrationEnabled)
|
||||
updates[SettingKeyEmailVerifyEnabled] = strconv.FormatBool(settings.EmailVerifyEnabled)
|
||||
registrationEmailSuffixWhitelistJSON, err := json.Marshal(settings.RegistrationEmailSuffixWhitelist)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal registration email suffix whitelist: %w", err)
|
||||
}
|
||||
updates[SettingKeyRegistrationEmailSuffixWhitelist] = string(registrationEmailSuffixWhitelistJSON)
|
||||
updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled)
|
||||
updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled)
|
||||
updates[SettingKeyFrontendURL] = settings.FrontendURL
|
||||
updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled)
|
||||
updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled)
|
||||
settings.LoginAgreementMode = normalizeLoginAgreementMode(settings.LoginAgreementMode)
|
||||
settings.LoginAgreementUpdatedAt = strings.TrimSpace(settings.LoginAgreementUpdatedAt)
|
||||
if settings.LoginAgreementUpdatedAt == "" {
|
||||
settings.LoginAgreementUpdatedAt = defaultLoginAgreementDate
|
||||
}
|
||||
loginAgreementDocumentsJSON, err := marshalLoginAgreementDocuments(settings.LoginAgreementDocuments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updates[SettingKeyLoginAgreementEnabled] = strconv.FormatBool(settings.LoginAgreementEnabled)
|
||||
updates[SettingKeyLoginAgreementMode] = settings.LoginAgreementMode
|
||||
updates[SettingKeyLoginAgreementUpdatedAt] = settings.LoginAgreementUpdatedAt
|
||||
updates[SettingKeyLoginAgreementDocuments] = loginAgreementDocumentsJSON
|
||||
|
||||
// 邮件服务设置(只有非空才更新密码)
|
||||
updates[SettingKeySMTPHost] = settings.SMTPHost
|
||||
updates[SettingKeySMTPPort] = strconv.Itoa(settings.SMTPPort)
|
||||
updates[SettingKeySMTPUsername] = settings.SMTPUsername
|
||||
if settings.SMTPPassword != "" {
|
||||
updates[SettingKeySMTPPassword] = settings.SMTPPassword
|
||||
}
|
||||
updates[SettingKeySMTPFrom] = settings.SMTPFrom
|
||||
updates[SettingKeySMTPFromName] = settings.SMTPFromName
|
||||
updates[SettingKeySMTPUseTLS] = strconv.FormatBool(settings.SMTPUseTLS)
|
||||
|
||||
// Cloudflare Turnstile 设置(只有非空才更新密钥)
|
||||
updates[SettingKeyTurnstileEnabled] = strconv.FormatBool(settings.TurnstileEnabled)
|
||||
updates[SettingKeyTurnstileSiteKey] = settings.TurnstileSiteKey
|
||||
if settings.TurnstileSecretKey != "" {
|
||||
updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey
|
||||
}
|
||||
updates[SettingKeyAPIKeyACLTrustForwardedIP] = strconv.FormatBool(settings.APIKeyACLTrustForwardedIP)
|
||||
|
||||
// LinuxDo Connect OAuth 登录
|
||||
updates[SettingKeyLinuxDoConnectEnabled] = strconv.FormatBool(settings.LinuxDoConnectEnabled)
|
||||
updates[SettingKeyLinuxDoConnectClientID] = settings.LinuxDoConnectClientID
|
||||
updates[SettingKeyLinuxDoConnectRedirectURL] = settings.LinuxDoConnectRedirectURL
|
||||
if settings.LinuxDoConnectClientSecret != "" {
|
||||
updates[SettingKeyLinuxDoConnectClientSecret] = settings.LinuxDoConnectClientSecret
|
||||
}
|
||||
|
||||
// DingTalk Connect OAuth 登录
|
||||
updates[SettingKeyDingTalkConnectEnabled] = strconv.FormatBool(settings.DingTalkConnectEnabled)
|
||||
updates[SettingKeyDingTalkConnectClientID] = settings.DingTalkConnectClientID
|
||||
updates[SettingKeyDingTalkConnectRedirectURL] = settings.DingTalkConnectRedirectURL
|
||||
if settings.DingTalkConnectClientSecret != "" {
|
||||
updates[SettingKeyDingTalkConnectClientSecret] = settings.DingTalkConnectClientSecret
|
||||
}
|
||||
updates[SettingKeyDingTalkConnectCorpRestrictionPolicy] = settings.DingTalkConnectCorpRestrictionPolicy
|
||||
updates[SettingKeyDingTalkConnectInternalCorpID] = settings.DingTalkConnectInternalCorpID
|
||||
updates[SettingKeyDingTalkConnectBypassRegistration] = strconv.FormatBool(settings.DingTalkConnectBypassRegistration)
|
||||
updates[SettingKeyDingTalkConnectSyncCorpEmail] = strconv.FormatBool(settings.DingTalkConnectSyncCorpEmail)
|
||||
updates[SettingKeyDingTalkConnectSyncDisplayName] = strconv.FormatBool(settings.DingTalkConnectSyncDisplayName)
|
||||
updates[SettingKeyDingTalkConnectSyncDept] = strconv.FormatBool(settings.DingTalkConnectSyncDept)
|
||||
updates[SettingKeyDingTalkConnectSyncCorpEmailAttrKey] = settings.DingTalkConnectSyncCorpEmailAttrKey
|
||||
updates[SettingKeyDingTalkConnectSyncDisplayNameAttrKey] = settings.DingTalkConnectSyncDisplayNameAttrKey
|
||||
updates[SettingKeyDingTalkConnectSyncDeptAttrKey] = settings.DingTalkConnectSyncDeptAttrKey
|
||||
updates[SettingKeyDingTalkConnectSyncCorpEmailAttrName] = settings.DingTalkConnectSyncCorpEmailAttrName
|
||||
updates[SettingKeyDingTalkConnectSyncDisplayNameAttrName] = settings.DingTalkConnectSyncDisplayNameAttrName
|
||||
updates[SettingKeyDingTalkConnectSyncDeptAttrName] = settings.DingTalkConnectSyncDeptAttrName
|
||||
|
||||
// Generic OIDC OAuth 登录
|
||||
updates[SettingKeyOIDCConnectEnabled] = strconv.FormatBool(settings.OIDCConnectEnabled)
|
||||
updates[SettingKeyOIDCConnectProviderName] = settings.OIDCConnectProviderName
|
||||
updates[SettingKeyOIDCConnectClientID] = settings.OIDCConnectClientID
|
||||
updates[SettingKeyOIDCConnectIssuerURL] = settings.OIDCConnectIssuerURL
|
||||
updates[SettingKeyOIDCConnectDiscoveryURL] = settings.OIDCConnectDiscoveryURL
|
||||
updates[SettingKeyOIDCConnectAuthorizeURL] = settings.OIDCConnectAuthorizeURL
|
||||
updates[SettingKeyOIDCConnectTokenURL] = settings.OIDCConnectTokenURL
|
||||
updates[SettingKeyOIDCConnectUserInfoURL] = settings.OIDCConnectUserInfoURL
|
||||
updates[SettingKeyOIDCConnectJWKSURL] = settings.OIDCConnectJWKSURL
|
||||
updates[SettingKeyOIDCConnectScopes] = settings.OIDCConnectScopes
|
||||
updates[SettingKeyOIDCConnectRedirectURL] = settings.OIDCConnectRedirectURL
|
||||
updates[SettingKeyOIDCConnectFrontendRedirectURL] = settings.OIDCConnectFrontendRedirectURL
|
||||
updates[SettingKeyOIDCConnectTokenAuthMethod] = settings.OIDCConnectTokenAuthMethod
|
||||
updates[SettingKeyOIDCConnectUsePKCE] = strconv.FormatBool(settings.OIDCConnectUsePKCE)
|
||||
updates[SettingKeyOIDCConnectValidateIDToken] = strconv.FormatBool(settings.OIDCConnectValidateIDToken)
|
||||
updates[SettingKeyOIDCConnectAllowedSigningAlgs] = settings.OIDCConnectAllowedSigningAlgs
|
||||
updates[SettingKeyOIDCConnectClockSkewSeconds] = strconv.Itoa(settings.OIDCConnectClockSkewSeconds)
|
||||
updates[SettingKeyOIDCConnectRequireEmailVerified] = strconv.FormatBool(settings.OIDCConnectRequireEmailVerified)
|
||||
updates[SettingKeyOIDCConnectUserInfoEmailPath] = settings.OIDCConnectUserInfoEmailPath
|
||||
updates[SettingKeyOIDCConnectUserInfoIDPath] = settings.OIDCConnectUserInfoIDPath
|
||||
updates[SettingKeyOIDCConnectUserInfoUsernamePath] = settings.OIDCConnectUserInfoUsernamePath
|
||||
if settings.OIDCConnectClientSecret != "" {
|
||||
updates[SettingKeyOIDCConnectClientSecret] = settings.OIDCConnectClientSecret
|
||||
}
|
||||
|
||||
// GitHub / Google 邮箱快捷登录
|
||||
updates[SettingKeyGitHubOAuthEnabled] = strconv.FormatBool(settings.GitHubOAuthEnabled)
|
||||
updates[SettingKeyGitHubOAuthClientID] = strings.TrimSpace(settings.GitHubOAuthClientID)
|
||||
updates[SettingKeyGitHubOAuthRedirectURL] = settings.GitHubOAuthRedirectURL
|
||||
updates[SettingKeyGitHubOAuthFrontendRedirectURL] = settings.GitHubOAuthFrontendRedirectURL
|
||||
if settings.GitHubOAuthClientSecret != "" {
|
||||
updates[SettingKeyGitHubOAuthClientSecret] = strings.TrimSpace(settings.GitHubOAuthClientSecret)
|
||||
}
|
||||
updates[SettingKeyGoogleOAuthEnabled] = strconv.FormatBool(settings.GoogleOAuthEnabled)
|
||||
updates[SettingKeyGoogleOAuthClientID] = strings.TrimSpace(settings.GoogleOAuthClientID)
|
||||
updates[SettingKeyGoogleOAuthRedirectURL] = settings.GoogleOAuthRedirectURL
|
||||
updates[SettingKeyGoogleOAuthFrontendRedirectURL] = settings.GoogleOAuthFrontendRedirectURL
|
||||
if settings.GoogleOAuthClientSecret != "" {
|
||||
updates[SettingKeyGoogleOAuthClientSecret] = strings.TrimSpace(settings.GoogleOAuthClientSecret)
|
||||
}
|
||||
|
||||
// WeChat Connect OAuth 登录
|
||||
updates[SettingKeyWeChatConnectEnabled] = strconv.FormatBool(settings.WeChatConnectEnabled)
|
||||
updates[SettingKeyWeChatConnectAppID] = settings.WeChatConnectAppID
|
||||
updates[SettingKeyWeChatConnectOpenAppID] = settings.WeChatConnectOpenAppID
|
||||
updates[SettingKeyWeChatConnectMPAppID] = settings.WeChatConnectMPAppID
|
||||
updates[SettingKeyWeChatConnectMobileAppID] = settings.WeChatConnectMobileAppID
|
||||
updates[SettingKeyWeChatConnectOpenEnabled] = strconv.FormatBool(settings.WeChatConnectOpenEnabled)
|
||||
updates[SettingKeyWeChatConnectMPEnabled] = strconv.FormatBool(settings.WeChatConnectMPEnabled)
|
||||
updates[SettingKeyWeChatConnectMobileEnabled] = strconv.FormatBool(settings.WeChatConnectMobileEnabled)
|
||||
updates[SettingKeyWeChatConnectMode] = settings.WeChatConnectMode
|
||||
updates[SettingKeyWeChatConnectScopes] = settings.WeChatConnectScopes
|
||||
updates[SettingKeyWeChatConnectRedirectURL] = settings.WeChatConnectRedirectURL
|
||||
updates[SettingKeyWeChatConnectFrontendRedirectURL] = settings.WeChatConnectFrontendRedirectURL
|
||||
if settings.WeChatConnectAppSecret != "" {
|
||||
updates[SettingKeyWeChatConnectAppSecret] = settings.WeChatConnectAppSecret
|
||||
}
|
||||
if settings.WeChatConnectOpenAppSecret != "" {
|
||||
updates[SettingKeyWeChatConnectOpenAppSecret] = settings.WeChatConnectOpenAppSecret
|
||||
}
|
||||
if settings.WeChatConnectMPAppSecret != "" {
|
||||
updates[SettingKeyWeChatConnectMPAppSecret] = settings.WeChatConnectMPAppSecret
|
||||
}
|
||||
if settings.WeChatConnectMobileAppSecret != "" {
|
||||
updates[SettingKeyWeChatConnectMobileAppSecret] = settings.WeChatConnectMobileAppSecret
|
||||
}
|
||||
|
||||
// OEM设置
|
||||
updates[SettingKeySiteName] = settings.SiteName
|
||||
updates[SettingKeySiteLogo] = settings.SiteLogo
|
||||
updates[SettingKeySiteSubtitle] = settings.SiteSubtitle
|
||||
updates[SettingKeyAPIBaseURL] = settings.APIBaseURL
|
||||
updates[SettingKeyContactInfo] = settings.ContactInfo
|
||||
updates[SettingKeyDocURL] = settings.DocURL
|
||||
updates[SettingKeyHomeContent] = settings.HomeContent
|
||||
updates[SettingKeyHideCcsImportButton] = strconv.FormatBool(settings.HideCcsImportButton)
|
||||
updates[SettingKeyPurchaseSubscriptionEnabled] = strconv.FormatBool(settings.PurchaseSubscriptionEnabled)
|
||||
updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL)
|
||||
tableDefaultPageSize, tablePageSizeOptions := normalizeTablePreferences(
|
||||
settings.TableDefaultPageSize,
|
||||
settings.TablePageSizeOptions,
|
||||
)
|
||||
updates[SettingKeyTableDefaultPageSize] = strconv.Itoa(tableDefaultPageSize)
|
||||
tablePageSizeOptionsJSON, err := json.Marshal(tablePageSizeOptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal table page size options: %w", err)
|
||||
}
|
||||
updates[SettingKeyTablePageSizeOptions] = string(tablePageSizeOptionsJSON)
|
||||
updates[SettingKeyCustomMenuItems] = settings.CustomMenuItems
|
||||
updates[SettingKeyCustomEndpoints] = settings.CustomEndpoints
|
||||
|
||||
// 默认配置
|
||||
updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency)
|
||||
updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64)
|
||||
settings.AffiliateRebateRate = clampAffiliateRebateRate(settings.AffiliateRebateRate)
|
||||
updates[SettingKeyAffiliateRebateRate] = strconv.FormatFloat(settings.AffiliateRebateRate, 'f', 8, 64)
|
||||
if settings.AffiliateRebateFreezeHours < 0 {
|
||||
settings.AffiliateRebateFreezeHours = AffiliateRebateFreezeHoursDefault
|
||||
}
|
||||
if settings.AffiliateRebateFreezeHours > AffiliateRebateFreezeHoursMax {
|
||||
settings.AffiliateRebateFreezeHours = AffiliateRebateFreezeHoursMax
|
||||
}
|
||||
updates[SettingKeyAffiliateRebateFreezeHours] = strconv.Itoa(settings.AffiliateRebateFreezeHours)
|
||||
if settings.AffiliateRebateDurationDays < 0 {
|
||||
settings.AffiliateRebateDurationDays = AffiliateRebateDurationDaysDefault
|
||||
}
|
||||
if settings.AffiliateRebateDurationDays > AffiliateRebateDurationDaysMax {
|
||||
settings.AffiliateRebateDurationDays = AffiliateRebateDurationDaysMax
|
||||
}
|
||||
updates[SettingKeyAffiliateRebateDurationDays] = strconv.Itoa(settings.AffiliateRebateDurationDays)
|
||||
if settings.AffiliateRebatePerInviteeCap < 0 {
|
||||
settings.AffiliateRebatePerInviteeCap = AffiliateRebatePerInviteeCapDefault
|
||||
}
|
||||
updates[SettingKeyAffiliateRebatePerInviteeCap] = strconv.FormatFloat(settings.AffiliateRebatePerInviteeCap, 'f', 8, 64)
|
||||
updates[SettingKeyDefaultUserRPMLimit] = strconv.Itoa(settings.DefaultUserRPMLimit)
|
||||
defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal default subscriptions: %w", err)
|
||||
}
|
||||
updates[SettingKeyDefaultSubscriptions] = string(defaultSubsJSON)
|
||||
|
||||
// Model fallback configuration
|
||||
updates[SettingKeyEnableModelFallback] = strconv.FormatBool(settings.EnableModelFallback)
|
||||
updates[SettingKeyFallbackModelAnthropic] = settings.FallbackModelAnthropic
|
||||
updates[SettingKeyFallbackModelOpenAI] = settings.FallbackModelOpenAI
|
||||
updates[SettingKeyFallbackModelGemini] = settings.FallbackModelGemini
|
||||
updates[SettingKeyFallbackModelAntigravity] = settings.FallbackModelAntigravity
|
||||
|
||||
// Identity patch configuration (Claude -> Gemini)
|
||||
updates[SettingKeyEnableIdentityPatch] = strconv.FormatBool(settings.EnableIdentityPatch)
|
||||
updates[SettingKeyIdentityPatchPrompt] = settings.IdentityPatchPrompt
|
||||
|
||||
// Ops monitoring (vNext)
|
||||
updates[SettingKeyOpsMonitoringEnabled] = strconv.FormatBool(settings.OpsMonitoringEnabled)
|
||||
updates[SettingKeyOpsRealtimeMonitoringEnabled] = strconv.FormatBool(settings.OpsRealtimeMonitoringEnabled)
|
||||
updates[SettingKeyOpsQueryModeDefault] = string(ParseOpsQueryMode(settings.OpsQueryModeDefault))
|
||||
if settings.OpsMetricsIntervalSeconds > 0 {
|
||||
updates[SettingKeyOpsMetricsIntervalSeconds] = strconv.Itoa(settings.OpsMetricsIntervalSeconds)
|
||||
}
|
||||
|
||||
// Channel monitor feature switch
|
||||
updates[SettingKeyChannelMonitorEnabled] = strconv.FormatBool(settings.ChannelMonitorEnabled)
|
||||
if v := clampChannelMonitorInterval(settings.ChannelMonitorDefaultIntervalSeconds); v > 0 {
|
||||
updates[SettingKeyChannelMonitorDefaultIntervalSeconds] = strconv.Itoa(v)
|
||||
}
|
||||
|
||||
// Available channels feature switch
|
||||
updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled)
|
||||
|
||||
// Affiliate (邀请返利) feature switch
|
||||
updates[SettingKeyAffiliateEnabled] = strconv.FormatBool(settings.AffiliateEnabled)
|
||||
|
||||
// 风控中心功能开关
|
||||
updates[SettingKeyRiskControlEnabled] = strconv.FormatBool(settings.RiskControlEnabled)
|
||||
|
||||
// cyber 会话屏蔽开关 + TTL
|
||||
updates[SettingKeyCyberSessionBlockEnabled] = strconv.FormatBool(settings.CyberSessionBlockEnabled)
|
||||
if settings.CyberSessionBlockTTLSeconds > 0 {
|
||||
updates[SettingKeyCyberSessionBlockTTLSeconds] = strconv.Itoa(settings.CyberSessionBlockTTLSeconds)
|
||||
}
|
||||
|
||||
// Claude Code version check
|
||||
updates[SettingKeyMinClaudeCodeVersion] = settings.MinClaudeCodeVersion
|
||||
updates[SettingKeyMaxClaudeCodeVersion] = settings.MaxClaudeCodeVersion
|
||||
|
||||
// 分组隔离
|
||||
updates[SettingKeyAllowUngroupedKeyScheduling] = strconv.FormatBool(settings.AllowUngroupedKeyScheduling)
|
||||
|
||||
// Backend Mode
|
||||
updates[SettingKeyBackendModeEnabled] = strconv.FormatBool(settings.BackendModeEnabled)
|
||||
|
||||
// Gateway forwarding behavior
|
||||
updates[SettingKeyEnableFingerprintUnification] = strconv.FormatBool(settings.EnableFingerprintUnification)
|
||||
updates[SettingKeyEnableMetadataPassthrough] = strconv.FormatBool(settings.EnableMetadataPassthrough)
|
||||
updates[SettingKeyEnableCCHSigning] = strconv.FormatBool(settings.EnableCCHSigning)
|
||||
updates[SettingKeyEnableClaudeOAuthSystemPromptInjection] = strconv.FormatBool(settings.EnableClaudeOAuthSystemPromptInjection)
|
||||
updates[SettingKeyClaudeOAuthSystemPrompt] = settings.ClaudeOAuthSystemPrompt
|
||||
if err := ValidateClaudeOAuthSystemPromptBlocksConfig(settings.ClaudeOAuthSystemPromptBlocks); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
updates[SettingKeyClaudeOAuthSystemPromptBlocks] = settings.ClaudeOAuthSystemPromptBlocks
|
||||
updates[SettingKeyEnableAnthropicCacheTTL1hInjection] = strconv.FormatBool(settings.EnableAnthropicCacheTTL1hInjection)
|
||||
updates[SettingKeyRewriteMessageCacheControl] = strconv.FormatBool(settings.RewriteMessageCacheControl)
|
||||
updates[SettingKeyEnableClientDatelineNormalization] = strconv.FormatBool(settings.EnableClientDatelineNormalization)
|
||||
updates[SettingKeyAntigravityUserAgentVersion] = antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion)
|
||||
updates[SettingKeyOpenAICodexUserAgent] = strings.TrimSpace(settings.OpenAICodexUserAgent)
|
||||
// codex_cli_only 加固
|
||||
updates[SettingKeyMinCodexVersion] = strings.TrimSpace(settings.MinCodexVersion)
|
||||
updates[SettingKeyMaxCodexVersion] = strings.TrimSpace(settings.MaxCodexVersion)
|
||||
updates[SettingKeyCodexCLIOnlyBlacklist] = strings.TrimSpace(settings.CodexCLIOnlyBlacklist)
|
||||
updates[SettingKeyCodexCLIOnlyWhitelist] = strings.TrimSpace(settings.CodexCLIOnlyWhitelist)
|
||||
updates[SettingKeyCodexCLIOnlyAllowAppServerClients] = strconv.FormatBool(settings.CodexCLIOnlyAllowAppServerClients)
|
||||
updates[SettingKeyCodexCLIOnlyEngineFingerprintSignals] = strings.TrimSpace(settings.CodexCLIOnlyEngineFingerprintSignals)
|
||||
updates[SettingPaymentVisibleMethodAlipaySource] = settings.PaymentVisibleMethodAlipaySource
|
||||
updates[SettingPaymentVisibleMethodWxpaySource] = settings.PaymentVisibleMethodWxpaySource
|
||||
updates[SettingPaymentVisibleMethodAlipayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodAlipayEnabled)
|
||||
updates[SettingPaymentVisibleMethodWxpayEnabled] = strconv.FormatBool(settings.PaymentVisibleMethodWxpayEnabled)
|
||||
updates[openAIAdvancedSchedulerSettingKey] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerEnabled)
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerStickyWeightedEnabled)
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled] = strconv.FormatBool(settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled)
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerLBTopK] = settings.OpenAIAdvancedSchedulerLBTopK
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerWeightPriority] = settings.OpenAIAdvancedSchedulerWeightPriority
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerWeightLoad] = settings.OpenAIAdvancedSchedulerWeightLoad
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerWeightQueue] = settings.OpenAIAdvancedSchedulerWeightQueue
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerWeightErrorRate] = settings.OpenAIAdvancedSchedulerWeightErrorRate
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerWeightTTFT] = settings.OpenAIAdvancedSchedulerWeightTTFT
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerWeightReset] = settings.OpenAIAdvancedSchedulerWeightReset
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom] = settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse] = settings.OpenAIAdvancedSchedulerWeightPreviousResponse
|
||||
updates[SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky] = settings.OpenAIAdvancedSchedulerWeightSessionSticky
|
||||
|
||||
// 余额、订阅到期与账号限额通知
|
||||
updates[SettingKeyBalanceLowNotifyEnabled] = strconv.FormatBool(settings.BalanceLowNotifyEnabled)
|
||||
updates[SettingKeyBalanceLowNotifyThreshold] = strconv.FormatFloat(settings.BalanceLowNotifyThreshold, 'f', 8, 64)
|
||||
updates[SettingKeyBalanceLowNotifyRechargeURL] = settings.BalanceLowNotifyRechargeURL
|
||||
updates[SettingKeySubscriptionExpiryNotifyEnabled] = strconv.FormatBool(settings.SubscriptionExpiryNotifyEnabled)
|
||||
updates[SettingKeyAccountQuotaNotifyEnabled] = strconv.FormatBool(settings.AccountQuotaNotifyEnabled)
|
||||
updates[SettingKeyAccountQuotaNotifyEmails] = MarshalNotifyEmails(settings.AccountQuotaNotifyEmails)
|
||||
|
||||
// 系统全局 platform quota:整体替换语义(null/缺省 = 不限制)。
|
||||
if settings.DefaultPlatformQuotas != nil {
|
||||
if err := validateDefaultPlatformQuotaMap(settings.DefaultPlatformQuotas); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blob, err := json.Marshal(settings.DefaultPlatformQuotas)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal default platform quotas: %w", err)
|
||||
}
|
||||
updates[SettingKeyDefaultPlatformQuotas] = string(blob)
|
||||
}
|
||||
|
||||
updates[SettingKeyAllowUserViewErrorRequests] = strconv.FormatBool(settings.AllowUserViewErrorRequests)
|
||||
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
// validateDefaultPlatformQuotaMap 校验 platform quota map 的合法性:
|
||||
// 平台名须在 AllowedQuotaPlatforms 白名单内,每个非 nil 上限须 finite 且 >= 0。
|
||||
// 系统层和 auth-source 层共用此 helper。
|
||||
func validateDefaultPlatformQuotaMap(m map[string]*DefaultPlatformQuotaSetting) error {
|
||||
for platform, pq := range m {
|
||||
if !IsAllowedQuotaPlatform(platform) {
|
||||
return infraerrors.BadRequest("INVALID_DEFAULT_PLATFORM_QUOTA", fmt.Sprintf("unknown platform %q", platform))
|
||||
}
|
||||
if pq == nil {
|
||||
continue
|
||||
}
|
||||
for _, v := range []*float64{pq.DailyLimitUSD, pq.WeeklyLimitUSD, pq.MonthlyLimitUSD} {
|
||||
if v != nil && (*v < 0 || math.IsNaN(*v) || math.IsInf(*v, 0)) {
|
||||
return infraerrors.BadRequest("INVALID_DEFAULT_PLATFORM_QUOTA", "platform quota limit must be a finite non-negative number")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *SettingService) buildAuthSourceDefaultUpdates(ctx context.Context, settings *AuthSourceDefaultSettings) (map[string]string, error) {
|
||||
if settings == nil {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
for _, subscriptions := range [][]DefaultSubscriptionSetting{
|
||||
settings.Email.Subscriptions,
|
||||
settings.LinuxDo.Subscriptions,
|
||||
settings.OIDC.Subscriptions,
|
||||
settings.WeChat.Subscriptions,
|
||||
settings.GitHub.Subscriptions,
|
||||
settings.Google.Subscriptions,
|
||||
settings.DingTalk.Subscriptions,
|
||||
} {
|
||||
if err := s.validateDefaultSubscriptionGroups(ctx, subscriptions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
// 校验各 auth source 的 platform quota map(改动 C:对等系统层校验)
|
||||
for _, pgs := range []struct {
|
||||
name string
|
||||
pq map[string]*DefaultPlatformQuotaSetting
|
||||
}{
|
||||
{"email", settings.Email.PlatformQuotas},
|
||||
{"linuxdo", settings.LinuxDo.PlatformQuotas},
|
||||
{"oidc", settings.OIDC.PlatformQuotas},
|
||||
{"wechat", settings.WeChat.PlatformQuotas},
|
||||
{"github", settings.GitHub.PlatformQuotas},
|
||||
{"google", settings.Google.PlatformQuotas},
|
||||
{"dingtalk", settings.DingTalk.PlatformQuotas},
|
||||
} {
|
||||
if pgs.pq != nil {
|
||||
if err := validateDefaultPlatformQuotaMap(pgs.pq); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
updates := make(map[string]string, 36)
|
||||
writeProviderDefaultGrantUpdates(updates, emailAuthSourceDefaultKeys, settings.Email)
|
||||
writeProviderDefaultGrantUpdates(updates, linuxDoAuthSourceDefaultKeys, settings.LinuxDo)
|
||||
writeProviderDefaultGrantUpdates(updates, oidcAuthSourceDefaultKeys, settings.OIDC)
|
||||
writeProviderDefaultGrantUpdates(updates, weChatAuthSourceDefaultKeys, settings.WeChat)
|
||||
writeProviderDefaultGrantUpdates(updates, gitHubAuthSourceDefaultKeys, settings.GitHub)
|
||||
writeProviderDefaultGrantUpdates(updates, googleAuthSourceDefaultKeys, settings.Google)
|
||||
writeProviderDefaultGrantUpdates(updates, dingTalkAuthSourceDefaultKeys, settings.DingTalk)
|
||||
updates[SettingKeyForceEmailOnThirdPartySignup] = strconv.FormatBool(settings.ForceEmailOnThirdPartySignup)
|
||||
return updates, nil
|
||||
}
|
||||
|
||||
func (s *SettingService) refreshCachedSettings(settings *SystemSettings) {
|
||||
if settings == nil {
|
||||
return
|
||||
}
|
||||
|
||||
// 先使 inflight singleflight 失效,再刷新缓存,缩小旧值覆盖新值的竞态窗口
|
||||
versionBoundsSF.Forget("version_bounds")
|
||||
versionBoundsCache.Store(&cachedVersionBounds{
|
||||
min: settings.MinClaudeCodeVersion,
|
||||
max: settings.MaxClaudeCodeVersion,
|
||||
expiresAt: time.Now().Add(versionBoundsCacheTTL).UnixNano(),
|
||||
})
|
||||
backendModeSF.Forget("backend_mode")
|
||||
backendModeCache.Store(&cachedBackendMode{
|
||||
value: settings.BackendModeEnabled,
|
||||
expiresAt: time.Now().Add(backendModeCacheTTL).UnixNano(),
|
||||
})
|
||||
gatewayForwardingSF.Forget("gateway_forwarding")
|
||||
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
|
||||
fingerprintUnification: settings.EnableFingerprintUnification,
|
||||
metadataPassthrough: settings.EnableMetadataPassthrough,
|
||||
cchSigning: settings.EnableCCHSigning,
|
||||
claudeOAuthSystemPromptInjection: settings.EnableClaudeOAuthSystemPromptInjection,
|
||||
claudeOAuthSystemPrompt: settings.ClaudeOAuthSystemPrompt,
|
||||
claudeOAuthSystemPromptBlocks: settings.ClaudeOAuthSystemPromptBlocks,
|
||||
anthropicCacheTTL1hInjection: settings.EnableAnthropicCacheTTL1hInjection,
|
||||
rewriteMessageCacheControl: settings.RewriteMessageCacheControl,
|
||||
clientDatelineNormalization: settings.EnableClientDatelineNormalization,
|
||||
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
|
||||
})
|
||||
s.antigravityUAVersionSF.Forget("antigravity_user_agent_version")
|
||||
antigravityUserAgentVersion := antigravity.NormalizeUserAgentVersion(settings.AntigravityUserAgentVersion)
|
||||
if antigravityUserAgentVersion == "" {
|
||||
antigravityUserAgentVersion = antigravity.GetDefaultUserAgentVersion()
|
||||
}
|
||||
s.antigravityUAVersionCache.Store(&cachedAntigravityUserAgentVersion{
|
||||
version: antigravityUserAgentVersion,
|
||||
expiresAt: time.Now().Add(antigravityUserAgentVersionCacheTTL).UnixNano(),
|
||||
})
|
||||
s.openAICodexUASF.Forget("openai_codex_user_agent")
|
||||
codexUA := strings.TrimSpace(settings.OpenAICodexUserAgent)
|
||||
if codexUA == "" {
|
||||
codexUA = DefaultOpenAICodexUserAgent
|
||||
}
|
||||
s.openAICodexUACache.Store(&cachedOpenAICodexUserAgent{
|
||||
value: codexUA,
|
||||
expiresAt: time.Now().Add(openAICodexUserAgentCacheTTL).UnixNano(),
|
||||
})
|
||||
openAIAdvancedSchedulerSettingSF.Forget(openAIAdvancedSchedulerSettingKey)
|
||||
openAIAdvancedSchedulerSettingCache.Store(&cachedOpenAIAdvancedSchedulerSetting{
|
||||
enabled: settings.OpenAIAdvancedSchedulerEnabled,
|
||||
stickyWeightedEnabled: settings.OpenAIAdvancedSchedulerStickyWeightedEnabled,
|
||||
subscriptionPriorityEnabled: settings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled,
|
||||
lbTopKOverride: parsePositiveIntOverride(settings.OpenAIAdvancedSchedulerLBTopK),
|
||||
weightOverrides: parseOpenAIAdvancedSchedulerWeightOverrides(map[string]string{
|
||||
SettingKeyOpenAIAdvancedSchedulerWeightPriority: settings.OpenAIAdvancedSchedulerWeightPriority,
|
||||
SettingKeyOpenAIAdvancedSchedulerWeightLoad: settings.OpenAIAdvancedSchedulerWeightLoad,
|
||||
SettingKeyOpenAIAdvancedSchedulerWeightQueue: settings.OpenAIAdvancedSchedulerWeightQueue,
|
||||
SettingKeyOpenAIAdvancedSchedulerWeightErrorRate: settings.OpenAIAdvancedSchedulerWeightErrorRate,
|
||||
SettingKeyOpenAIAdvancedSchedulerWeightTTFT: settings.OpenAIAdvancedSchedulerWeightTTFT,
|
||||
SettingKeyOpenAIAdvancedSchedulerWeightReset: settings.OpenAIAdvancedSchedulerWeightReset,
|
||||
SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom: settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom,
|
||||
SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse: settings.OpenAIAdvancedSchedulerWeightPreviousResponse,
|
||||
SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky: settings.OpenAIAdvancedSchedulerWeightSessionSticky,
|
||||
}),
|
||||
expiresAt: time.Now().Add(openAIAdvancedSchedulerSettingCacheTTL).UnixNano(),
|
||||
})
|
||||
// Invalidate the quota auto-pause cache and let the next read trigger a fresh load.
|
||||
// We can't know from here whether ops_advanced_settings was also touched, so be
|
||||
// defensive: store an expired entry — GetOpenAIQuotaAutoPauseSettings will serve
|
||||
// stale and kick off an async refresh, never blocking the request that follows.
|
||||
s.openAIQuotaAutoPauseSettingsSF.Forget(openAIQuotaAutoPauseSettingsRefreshKey)
|
||||
if cached, _ := s.openAIQuotaAutoPauseSettingsCache.Load().(*cachedOpenAIQuotaAutoPauseSettings); cached != nil {
|
||||
s.openAIQuotaAutoPauseSettingsCache.Store(&cachedOpenAIQuotaAutoPauseSettings{
|
||||
settings: cached.settings,
|
||||
expiresAt: 0,
|
||||
})
|
||||
}
|
||||
if s.cfg != nil {
|
||||
s.cfg.SetTrustForwardedIPForAPIKeyACL(settings.APIKeyACLTrustForwardedIP)
|
||||
}
|
||||
// codex_cli_only 加固策略缓存:设置更新后强制下次重载(涉及 4 个键 + JSON 解析,直接置过期)。
|
||||
s.codexRestrictionPolicySF.Forget("codex_restriction_policy")
|
||||
s.codexRestrictionPolicyCache.Store(&cachedCodexRestrictionPolicy{expiresAt: 0})
|
||||
if s.onUpdate != nil {
|
||||
s.onUpdate() // Invalidate cache after settings update
|
||||
}
|
||||
}
|
||||
|
||||
func (s *SettingService) defaultRewriteMessageCacheControl() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (s *SettingService) validateDefaultSubscriptionGroups(ctx context.Context, items []DefaultSubscriptionSetting) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
checked := make(map[int64]struct{}, len(items))
|
||||
for _, item := range items {
|
||||
if item.GroupID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := checked[item.GroupID]; ok {
|
||||
return ErrDefaultSubGroupDuplicate.WithMetadata(map[string]string{
|
||||
"group_id": strconv.FormatInt(item.GroupID, 10),
|
||||
})
|
||||
}
|
||||
checked[item.GroupID] = struct{}{}
|
||||
if s.defaultSubGroupReader == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
group, err := s.defaultSubGroupReader.GetByID(ctx, item.GroupID)
|
||||
if err != nil {
|
||||
if errors.Is(err, ErrGroupNotFound) {
|
||||
return ErrDefaultSubGroupInvalid.WithMetadata(map[string]string{
|
||||
"group_id": strconv.FormatInt(item.GroupID, 10),
|
||||
})
|
||||
}
|
||||
return fmt.Errorf("get default subscription group %d: %w", item.GroupID, err)
|
||||
}
|
||||
if !group.IsSubscriptionType() {
|
||||
return ErrDefaultSubGroupInvalid.WithMetadata(map[string]string{
|
||||
"group_id": strconv.FormatInt(item.GroupID, 10),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
Reference in New Issue
Block a user