Files
sub2api/backend/internal/service/setting_service.go

2049 lines
79 KiB
Go
Raw Normal View History

2025-12-18 13:50:39 +08:00
package service
import (
"context"
2025-12-20 15:11:43 +08:00
"crypto/rand"
"encoding/hex"
"encoding/json"
2025-12-18 13:50:39 +08:00
"errors"
"fmt"
"log/slog"
"net/url"
2025-12-25 17:15:01 +08:00
"strconv"
"strings"
"sync/atomic"
"time"
2025-12-25 17:15:01 +08:00
2025-12-24 21:07:21 +08:00
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
2026-03-13 23:38:58 +08:00
"github.com/imroc/req/v3"
"golang.org/x/sync/singleflight"
2025-12-18 13:50:39 +08:00
)
var (
ErrRegistrationDisabled = infraerrors.Forbidden("REGISTRATION_DISABLED", "registration is currently disabled")
ErrSettingNotFound = infraerrors.NotFound("SETTING_NOT_FOUND", "setting not found")
ErrDefaultSubGroupInvalid = infraerrors.BadRequest(
"DEFAULT_SUBSCRIPTION_GROUP_INVALID",
"default subscription group must exist and be subscription type",
)
ErrDefaultSubGroupDuplicate = infraerrors.BadRequest(
"DEFAULT_SUBSCRIPTION_GROUP_DUPLICATE",
"default subscription group cannot be duplicated",
)
2025-12-18 13:50:39 +08:00
)
2025-12-25 17:15:01 +08:00
type SettingRepository interface {
Get(ctx context.Context, key string) (*Setting, error)
2025-12-25 17:15:01 +08:00
GetValue(ctx context.Context, key string) (string, error)
Set(ctx context.Context, key, value string) error
GetMultiple(ctx context.Context, keys []string) (map[string]string, error)
SetMultiple(ctx context.Context, settings map[string]string) error
GetAll(ctx context.Context) (map[string]string, error)
Delete(ctx context.Context, key string) error
}
// 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
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
// DefaultSubscriptionGroupReader validates group references used by default subscriptions.
type DefaultSubscriptionGroupReader interface {
GetByID(ctx context.Context, id int64) (*Group, error)
}
2025-12-18 13:50:39 +08:00
// SettingService 系统设置服务
type SettingService struct {
settingRepo SettingRepository
defaultSubGroupReader DefaultSubscriptionGroupReader
cfg *config.Config
onUpdate func() // Callback when settings are updated (for cache invalidation)
version string // Application version
2025-12-18 13:50:39 +08:00
}
// NewSettingService 创建系统设置服务实例
2025-12-25 17:15:01 +08:00
func NewSettingService(settingRepo SettingRepository, cfg *config.Config) *SettingService {
2025-12-18 13:50:39 +08:00
return &SettingService{
settingRepo: settingRepo,
cfg: cfg,
}
}
// SetDefaultSubscriptionGroupReader injects an optional group reader for default subscription validation.
func (s *SettingService) SetDefaultSubscriptionGroupReader(reader DefaultSubscriptionGroupReader) {
s.defaultSubGroupReader = reader
}
2025-12-18 13:50:39 +08:00
// GetAllSettings 获取所有系统设置
func (s *SettingService) GetAllSettings(ctx context.Context) (*SystemSettings, error) {
2025-12-18 13:50:39 +08:00
settings, err := s.settingRepo.GetAll(ctx)
if err != nil {
return nil, fmt.Errorf("get all settings: %w", err)
}
return s.parseSettings(settings), nil
}
// 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
}
2025-12-18 13:50:39 +08:00
// GetPublicSettings 获取公开设置(无需登录)
func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings, error) {
2025-12-18 13:50:39 +08:00
keys := []string{
SettingKeyRegistrationEnabled,
SettingKeyEmailVerifyEnabled,
SettingKeyRegistrationEmailSuffixWhitelist,
SettingKeyPromoCodeEnabled,
SettingKeyPasswordResetEnabled,
2026-01-29 16:29:59 +08:00
SettingKeyInvitationCodeEnabled,
SettingKeyTotpEnabled,
SettingKeyTurnstileEnabled,
SettingKeyTurnstileSiteKey,
SettingKeySiteName,
SettingKeySiteLogo,
SettingKeySiteSubtitle,
SettingKeyAPIBaseURL,
SettingKeyContactInfo,
SettingKeyDocURL,
SettingKeyHomeContent,
SettingKeyHideCcsImportButton,
SettingKeyPurchaseSubscriptionEnabled,
SettingKeyPurchaseSubscriptionURL,
SettingKeyCustomMenuItems,
SettingKeyCustomEndpoints,
SettingKeyLinuxDoConnectEnabled,
SettingKeyBackendModeEnabled,
2026-03-13 23:38:58 +08:00
SettingKeyOIDCConnectEnabled,
SettingKeyOIDCConnectProviderName,
2025-12-18 13:50:39 +08:00
}
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
}
2026-03-13 23:38:58 +08:00
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"
}
// Password reset requires email verification to be enabled
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
passwordResetEnabled := emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true"
registrationEmailSuffixWhitelist := ParseRegistrationEmailSuffixWhitelist(
settings[SettingKeyRegistrationEmailSuffixWhitelist],
)
return &PublicSettings{
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
EmailVerifyEnabled: emailVerifyEnabled,
RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist,
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
PasswordResetEnabled: passwordResetEnabled,
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
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]),
CustomMenuItems: settings[SettingKeyCustomMenuItems],
CustomEndpoints: settings[SettingKeyCustomEndpoints],
LinuxDoOAuthEnabled: linuxDoEnabled,
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
2026-03-13 23:38:58 +08:00
OIDCOAuthEnabled: oidcEnabled,
OIDCOAuthProviderName: oidcProviderName,
2025-12-18 13:50:39 +08:00
}, nil
}
// SetOnUpdateCallback sets a callback function to be called when settings are updated
// This is used for cache invalidation (e.g., HTML cache in frontend server)
func (s *SettingService) SetOnUpdateCallback(callback func()) {
s.onUpdate = callback
}
// SetVersion sets the application version for injection into public settings
func (s *SettingService) SetVersion(version string) {
s.version = version
}
// 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 a struct that matches the frontend's expected format
return &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"`
TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key,omitempty"`
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo,omitempty"`
SiteSubtitle string `json:"site_subtitle,omitempty"`
APIBaseURL string `json:"api_base_url,omitempty"`
ContactInfo string `json:"contact_info,omitempty"`
DocURL string `json:"doc_url,omitempty"`
HomeContent string `json:"home_content,omitempty"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url,omitempty"`
CustomMenuItems json.RawMessage `json:"custom_menu_items"`
CustomEndpoints json.RawMessage `json:"custom_endpoints"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
BackendModeEnabled bool `json:"backend_mode_enabled"`
2026-03-13 23:38:58 +08:00
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
Version string `json:"version,omitempty"`
}{
RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled,
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,
CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
BackendModeEnabled: settings.BackendModeEnabled,
2026-03-13 23:38:58 +08:00
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
Version: s.version,
2025-12-18 13:50:39 +08:00
}, 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 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)
}
}
}
// 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
}
2025-12-18 13:50:39 +08:00
// UpdateSettings 更新系统设置
func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error {
if err := s.validateDefaultSubscriptionGroups(ctx, settings.DefaultSubscriptions); err != nil {
return err
}
normalizedWhitelist, err := NormalizeRegistrationEmailSuffixWhitelist(settings.RegistrationEmailSuffixWhitelist)
if err != nil {
return infraerrors.BadRequest("INVALID_REGISTRATION_EMAIL_SUFFIX_WHITELIST", err.Error())
}
if normalizedWhitelist == nil {
normalizedWhitelist = []string{}
}
settings.RegistrationEmailSuffixWhitelist = normalizedWhitelist
2025-12-18 13:50:39 +08:00
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 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
2026-01-29 16:29:59 +08:00
updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled)
updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled)
2025-12-18 13:50:39 +08:00
// 邮件服务设置(只有非空才更新密码)
updates[SettingKeySMTPHost] = settings.SMTPHost
updates[SettingKeySMTPPort] = strconv.Itoa(settings.SMTPPort)
updates[SettingKeySMTPUsername] = settings.SMTPUsername
if settings.SMTPPassword != "" {
updates[SettingKeySMTPPassword] = settings.SMTPPassword
2025-12-18 13:50:39 +08:00
}
updates[SettingKeySMTPFrom] = settings.SMTPFrom
updates[SettingKeySMTPFromName] = settings.SMTPFromName
updates[SettingKeySMTPUseTLS] = strconv.FormatBool(settings.SMTPUseTLS)
2025-12-18 13:50:39 +08:00
// Cloudflare Turnstile 设置(只有非空才更新密钥)
updates[SettingKeyTurnstileEnabled] = strconv.FormatBool(settings.TurnstileEnabled)
updates[SettingKeyTurnstileSiteKey] = settings.TurnstileSiteKey
2025-12-18 13:50:39 +08:00
if settings.TurnstileSecretKey != "" {
updates[SettingKeyTurnstileSecretKey] = settings.TurnstileSecretKey
2025-12-18 13:50:39 +08:00
}
// LinuxDo Connect OAuth 登录
updates[SettingKeyLinuxDoConnectEnabled] = strconv.FormatBool(settings.LinuxDoConnectEnabled)
updates[SettingKeyLinuxDoConnectClientID] = settings.LinuxDoConnectClientID
updates[SettingKeyLinuxDoConnectRedirectURL] = settings.LinuxDoConnectRedirectURL
if settings.LinuxDoConnectClientSecret != "" {
updates[SettingKeyLinuxDoConnectClientSecret] = settings.LinuxDoConnectClientSecret
}
2026-03-13 23:38:58 +08:00
// 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
}
2025-12-18 13:50:39 +08:00
// 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)
updates[SettingKeyCustomMenuItems] = settings.CustomMenuItems
updates[SettingKeyCustomEndpoints] = settings.CustomEndpoints
2025-12-18 13:50:39 +08:00
// 默认配置
updates[SettingKeyDefaultConcurrency] = strconv.Itoa(settings.DefaultConcurrency)
updates[SettingKeyDefaultBalance] = strconv.FormatFloat(settings.DefaultBalance, 'f', 8, 64)
defaultSubsJSON, err := json.Marshal(settings.DefaultSubscriptions)
if err != nil {
return fmt.Errorf("marshal default subscriptions: %w", err)
}
updates[SettingKeyDefaultSubscriptions] = string(defaultSubsJSON)
2025-12-18 13:50:39 +08:00
// 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)
}
// 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)
err = s.settingRepo.SetMultiple(ctx, updates)
if err == nil {
// 先使 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,
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
})
if s.onUpdate != nil {
s.onUpdate() // Invalidate cache after settings update
}
}
return err
2025-12-18 13:50:39 +08:00
}
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
}
2025-12-18 13:50:39 +08:00
// IsRegistrationEnabled 检查是否开放注册
func (s *SettingService) IsRegistrationEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled)
2025-12-18 13:50:39 +08:00
if err != nil {
// 安全默认:如果设置不存在或查询出错,默认关闭注册
return false
2025-12-18 13:50:39 +08:00
}
return value == "true"
}
// 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
}
// 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) {
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
if time.Now().UnixNano() < cached.expiresAt {
return cached.fingerprintUnification, cached.metadataPassthrough, cached.cchSigning
}
}
type gwfResult struct {
fp, mp, cch bool
}
val, _, _ := gatewayForwardingSF.Do("gateway_forwarding", func() (any, error) {
if cached, ok := gatewayForwardingCache.Load().(*cachedGatewayForwardingSettings); ok && cached != nil {
if time.Now().UnixNano() < cached.expiresAt {
return gwfResult{cached.fingerprintUnification, cached.metadataPassthrough, cached.cchSigning}, nil
}
}
dbCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), gatewayForwardingDBTimeout)
defer cancel()
values, err := s.settingRepo.GetMultiple(dbCtx, []string{
SettingKeyEnableFingerprintUnification,
SettingKeyEnableMetadataPassthrough,
SettingKeyEnableCCHSigning,
})
if err != nil {
slog.Warn("failed to get gateway forwarding settings", "error", err)
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
fingerprintUnification: true,
metadataPassthrough: false,
cchSigning: false,
expiresAt: time.Now().Add(gatewayForwardingErrorTTL).UnixNano(),
})
return gwfResult{true, false, false}, nil
}
fp := true
if v, ok := values[SettingKeyEnableFingerprintUnification]; ok && v != "" {
fp = v == "true"
}
mp := values[SettingKeyEnableMetadataPassthrough] == "true"
cch := values[SettingKeyEnableCCHSigning] == "true"
gatewayForwardingCache.Store(&cachedGatewayForwardingSettings{
fingerprintUnification: fp,
metadataPassthrough: mp,
cchSigning: cch,
expiresAt: time.Now().Add(gatewayForwardingCacheTTL).UnixNano(),
})
return gwfResult{fp, mp, cch}, nil
})
if r, ok := val.(gwfResult); ok {
return r.fp, r.mp, r.cch
}
return true, false, false // fail-open defaults
}
2025-12-18 13:50:39 +08:00
// IsEmailVerifyEnabled 检查是否开启邮件验证
func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyEmailVerifyEnabled)
2025-12-18 13:50:39 +08:00
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"
}
2026-01-29 16:29:59 +08:00
// IsInvitationCodeEnabled 检查是否启用邀请码注册功能
func (s *SettingService) IsInvitationCodeEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyInvitationCodeEnabled)
if err != nil {
return false // 默认关闭
}
return value == "true"
}
// 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
}
2025-12-18 13:50:39 +08:00
// GetSiteName 获取网站名称
func (s *SettingService) GetSiteName(ctx context.Context) string {
value, err := s.settingRepo.GetValue(ctx, SettingKeySiteName)
2025-12-18 13:50:39 +08:00
if err != nil || value == "" {
return "Sub2API"
}
return value
}
// GetDefaultConcurrency 获取默认并发量
func (s *SettingService) GetDefaultConcurrency(ctx context.Context) int {
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultConcurrency)
2025-12-18 13:50:39 +08:00
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)
2025-12-18 13:50:39 +08:00
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
}
// GetDefaultSubscriptions 获取新用户默认订阅配置列表。
func (s *SettingService) GetDefaultSubscriptions(ctx context.Context) []DefaultSubscriptionSetting {
value, err := s.settingRepo.GetValue(ctx, SettingKeyDefaultSubscriptions)
if err != nil {
return nil
}
return parseDefaultSubscriptions(value)
}
2025-12-18 13:50:39 +08:00
// InitializeDefaultSettings 初始化默认设置
func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
// 检查是否已有设置
_, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled)
2025-12-18 13:50:39 +08:00
if err == nil {
// 已有设置,不需要初始化
return nil
}
2025-12-25 20:52:47 +08:00
if !errors.Is(err, ErrSettingNotFound) {
2025-12-18 13:50:39 +08:00
return fmt.Errorf("check existing settings: %w", err)
}
// 初始化默认设置
defaults := map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyEmailVerifyEnabled: "false",
SettingKeyRegistrationEmailSuffixWhitelist: "[]",
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
SettingKeySiteName: "Sub2API",
SettingKeySiteLogo: "",
SettingKeyPurchaseSubscriptionEnabled: "false",
SettingKeyPurchaseSubscriptionURL: "",
SettingKeyCustomMenuItems: "[]",
SettingKeyCustomEndpoints: "[]",
2026-03-13 23:38:58 +08:00
SettingKeyOIDCConnectEnabled: "false",
SettingKeyOIDCConnectProviderName: "OIDC",
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
SettingKeyDefaultSubscriptions: "[]",
SettingKeySMTPPort: "587",
SettingKeySMTPUseTLS: "false",
// Model fallback defaults
SettingKeyEnableModelFallback: "false",
SettingKeyFallbackModelAnthropic: "claude-3-5-sonnet-20241022",
SettingKeyFallbackModelOpenAI: "gpt-4o",
SettingKeyFallbackModelGemini: "gemini-2.5-pro",
SettingKeyFallbackModelAntigravity: "gemini-2.5-pro",
// Identity patch defaults
SettingKeyEnableIdentityPatch: "true",
SettingKeyIdentityPatchPrompt: "",
// Ops monitoring defaults (vNext)
SettingKeyOpsMonitoringEnabled: "true",
SettingKeyOpsRealtimeMonitoringEnabled: "true",
SettingKeyOpsQueryModeDefault: "auto",
SettingKeyOpsMetricsIntervalSeconds: "60",
// Claude Code version check (default: empty = disabled)
SettingKeyMinClaudeCodeVersion: "",
SettingKeyMaxClaudeCodeVersion: "",
// 分组隔离(默认不允许未分组 Key 调度)
SettingKeyAllowUngroupedKeyScheduling: "false",
2025-12-18 13:50:39 +08:00
}
return s.settingRepo.SetMultiple(ctx, defaults)
}
// parseSettings 解析设置到结构体
func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings {
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
result := &SystemSettings{
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
EmailVerifyEnabled: emailVerifyEnabled,
RegistrationEmailSuffixWhitelist: ParseRegistrationEmailSuffixWhitelist(settings[SettingKeyRegistrationEmailSuffixWhitelist]),
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true",
FrontendURL: settings[SettingKeyFrontendURL],
InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
SMTPHost: settings[SettingKeySMTPHost],
SMTPUsername: settings[SettingKeySMTPUsername],
SMTPFrom: settings[SettingKeySMTPFrom],
SMTPFromName: settings[SettingKeySMTPFromName],
SMTPUseTLS: settings[SettingKeySMTPUseTLS] == "true",
SMTPPasswordConfigured: settings[SettingKeySMTPPassword] != "",
TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
TurnstileSecretKeyConfigured: settings[SettingKeyTurnstileSecretKey] != "",
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]),
CustomMenuItems: settings[SettingKeyCustomMenuItems],
CustomEndpoints: settings[SettingKeyCustomEndpoints],
BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
2025-12-18 13:50:39 +08:00
}
// 解析整数类型
if port, err := strconv.Atoi(settings[SettingKeySMTPPort]); err == nil {
result.SMTPPort = port
2025-12-18 13:50:39 +08:00
} else {
result.SMTPPort = 587
2025-12-18 13:50:39 +08:00
}
if concurrency, err := strconv.Atoi(settings[SettingKeyDefaultConcurrency]); err == nil {
2025-12-18 13:50:39 +08:00
result.DefaultConcurrency = concurrency
} else {
result.DefaultConcurrency = s.cfg.Default.UserConcurrency
}
// 解析浮点数类型
if balance, err := strconv.ParseFloat(settings[SettingKeyDefaultBalance], 64); err == nil {
2025-12-18 13:50:39 +08:00
result.DefaultBalance = balance
} else {
result.DefaultBalance = s.cfg.Default.UserBalance
}
result.DefaultSubscriptions = parseDefaultSubscriptions(settings[SettingKeyDefaultSubscriptions])
2025-12-18 13:50:39 +08:00
// 敏感信息直接返回,方便测试连接时使用
result.SMTPPassword = settings[SettingKeySMTPPassword]
result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey]
2025-12-18 13:50:39 +08:00
// LinuxDo Connect 设置:
// - 兼容 config.yaml/env避免老部署因为未迁移到数据库设置而被意外关闭
// - 支持在后台“系统设置”中覆盖并持久化(存储于 DB
linuxDoBase := config.LinuxDoConnectConfig{}
if s.cfg != nil {
linuxDoBase = s.cfg.LinuxDo
}
if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok {
result.LinuxDoConnectEnabled = raw == "true"
} else {
result.LinuxDoConnectEnabled = linuxDoBase.Enabled
}
if v, ok := settings[SettingKeyLinuxDoConnectClientID]; ok && strings.TrimSpace(v) != "" {
result.LinuxDoConnectClientID = strings.TrimSpace(v)
} else {
result.LinuxDoConnectClientID = linuxDoBase.ClientID
}
if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
result.LinuxDoConnectRedirectURL = strings.TrimSpace(v)
} else {
result.LinuxDoConnectRedirectURL = linuxDoBase.RedirectURL
}
result.LinuxDoConnectClientSecret = strings.TrimSpace(settings[SettingKeyLinuxDoConnectClientSecret])
if result.LinuxDoConnectClientSecret == "" {
result.LinuxDoConnectClientSecret = strings.TrimSpace(linuxDoBase.ClientSecret)
}
result.LinuxDoConnectClientSecretConfigured = result.LinuxDoConnectClientSecret != ""
2026-03-13 23:38:58 +08:00
// Generic OIDC 设置:
// - 兼容 config.yaml/env
// - 支持后台系统设置覆盖并持久化(存储于 DB
oidcBase := config.OIDCConnectConfig{}
if s.cfg != nil {
oidcBase = s.cfg.OIDC
}
if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok {
result.OIDCConnectEnabled = raw == "true"
} else {
result.OIDCConnectEnabled = oidcBase.Enabled
}
if v, ok := settings[SettingKeyOIDCConnectProviderName]; ok && strings.TrimSpace(v) != "" {
result.OIDCConnectProviderName = strings.TrimSpace(v)
} else {
result.OIDCConnectProviderName = strings.TrimSpace(oidcBase.ProviderName)
}
if result.OIDCConnectProviderName == "" {
result.OIDCConnectProviderName = "OIDC"
}
if v, ok := settings[SettingKeyOIDCConnectClientID]; ok && strings.TrimSpace(v) != "" {
result.OIDCConnectClientID = strings.TrimSpace(v)
} else {
result.OIDCConnectClientID = strings.TrimSpace(oidcBase.ClientID)
}
if v, ok := settings[SettingKeyOIDCConnectIssuerURL]; ok && strings.TrimSpace(v) != "" {
result.OIDCConnectIssuerURL = strings.TrimSpace(v)
} else {
result.OIDCConnectIssuerURL = strings.TrimSpace(oidcBase.IssuerURL)
}
if v, ok := settings[SettingKeyOIDCConnectDiscoveryURL]; ok && strings.TrimSpace(v) != "" {
result.OIDCConnectDiscoveryURL = strings.TrimSpace(v)
} else {
result.OIDCConnectDiscoveryURL = strings.TrimSpace(oidcBase.DiscoveryURL)
}
if v, ok := settings[SettingKeyOIDCConnectAuthorizeURL]; ok && strings.TrimSpace(v) != "" {
result.OIDCConnectAuthorizeURL = strings.TrimSpace(v)
} else {
result.OIDCConnectAuthorizeURL = strings.TrimSpace(oidcBase.AuthorizeURL)
}
if v, ok := settings[SettingKeyOIDCConnectTokenURL]; ok && strings.TrimSpace(v) != "" {
result.OIDCConnectTokenURL = strings.TrimSpace(v)
} else {
result.OIDCConnectTokenURL = strings.TrimSpace(oidcBase.TokenURL)
}
if v, ok := settings[SettingKeyOIDCConnectUserInfoURL]; ok && strings.TrimSpace(v) != "" {
result.OIDCConnectUserInfoURL = strings.TrimSpace(v)
} else {
result.OIDCConnectUserInfoURL = strings.TrimSpace(oidcBase.UserInfoURL)
}
if v, ok := settings[SettingKeyOIDCConnectJWKSURL]; ok && strings.TrimSpace(v) != "" {
result.OIDCConnectJWKSURL = strings.TrimSpace(v)
} else {
result.OIDCConnectJWKSURL = strings.TrimSpace(oidcBase.JWKSURL)
}
if v, ok := settings[SettingKeyOIDCConnectScopes]; ok && strings.TrimSpace(v) != "" {
result.OIDCConnectScopes = strings.TrimSpace(v)
} else {
result.OIDCConnectScopes = strings.TrimSpace(oidcBase.Scopes)
}
if v, ok := settings[SettingKeyOIDCConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
result.OIDCConnectRedirectURL = strings.TrimSpace(v)
} else {
result.OIDCConnectRedirectURL = strings.TrimSpace(oidcBase.RedirectURL)
}
if v, ok := settings[SettingKeyOIDCConnectFrontendRedirectURL]; ok && strings.TrimSpace(v) != "" {
result.OIDCConnectFrontendRedirectURL = strings.TrimSpace(v)
} else {
result.OIDCConnectFrontendRedirectURL = strings.TrimSpace(oidcBase.FrontendRedirectURL)
}
if v, ok := settings[SettingKeyOIDCConnectTokenAuthMethod]; ok && strings.TrimSpace(v) != "" {
result.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(v))
} else {
result.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(oidcBase.TokenAuthMethod))
}
if raw, ok := settings[SettingKeyOIDCConnectUsePKCE]; ok {
result.OIDCConnectUsePKCE = raw == "true"
} else {
result.OIDCConnectUsePKCE = oidcBase.UsePKCE
}
if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok {
result.OIDCConnectValidateIDToken = raw == "true"
} else {
result.OIDCConnectValidateIDToken = oidcBase.ValidateIDToken
}
if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" {
result.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(v)
} else {
result.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(oidcBase.AllowedSigningAlgs)
}
clockSkewSet := false
if raw, ok := settings[SettingKeyOIDCConnectClockSkewSeconds]; ok && strings.TrimSpace(raw) != "" {
if parsed, err := strconv.Atoi(strings.TrimSpace(raw)); err == nil {
result.OIDCConnectClockSkewSeconds = parsed
clockSkewSet = true
}
}
if !clockSkewSet {
result.OIDCConnectClockSkewSeconds = oidcBase.ClockSkewSeconds
}
if !clockSkewSet && result.OIDCConnectClockSkewSeconds == 0 {
result.OIDCConnectClockSkewSeconds = 120
}
if raw, ok := settings[SettingKeyOIDCConnectRequireEmailVerified]; ok {
result.OIDCConnectRequireEmailVerified = raw == "true"
} else {
result.OIDCConnectRequireEmailVerified = oidcBase.RequireEmailVerified
}
if v, ok := settings[SettingKeyOIDCConnectUserInfoEmailPath]; ok {
result.OIDCConnectUserInfoEmailPath = strings.TrimSpace(v)
} else {
result.OIDCConnectUserInfoEmailPath = strings.TrimSpace(oidcBase.UserInfoEmailPath)
}
if v, ok := settings[SettingKeyOIDCConnectUserInfoIDPath]; ok {
result.OIDCConnectUserInfoIDPath = strings.TrimSpace(v)
} else {
result.OIDCConnectUserInfoIDPath = strings.TrimSpace(oidcBase.UserInfoIDPath)
}
if v, ok := settings[SettingKeyOIDCConnectUserInfoUsernamePath]; ok {
result.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(v)
} else {
result.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(oidcBase.UserInfoUsernamePath)
}
result.OIDCConnectClientSecret = strings.TrimSpace(settings[SettingKeyOIDCConnectClientSecret])
if result.OIDCConnectClientSecret == "" {
result.OIDCConnectClientSecret = strings.TrimSpace(oidcBase.ClientSecret)
}
result.OIDCConnectClientSecretConfigured = result.OIDCConnectClientSecret != ""
// Model fallback settings
result.EnableModelFallback = settings[SettingKeyEnableModelFallback] == "true"
result.FallbackModelAnthropic = s.getStringOrDefault(settings, SettingKeyFallbackModelAnthropic, "claude-3-5-sonnet-20241022")
result.FallbackModelOpenAI = s.getStringOrDefault(settings, SettingKeyFallbackModelOpenAI, "gpt-4o")
result.FallbackModelGemini = s.getStringOrDefault(settings, SettingKeyFallbackModelGemini, "gemini-2.5-pro")
result.FallbackModelAntigravity = s.getStringOrDefault(settings, SettingKeyFallbackModelAntigravity, "gemini-2.5-pro")
// Identity patch settings (default: enabled, to preserve existing behavior)
if v, ok := settings[SettingKeyEnableIdentityPatch]; ok && v != "" {
result.EnableIdentityPatch = v == "true"
} else {
result.EnableIdentityPatch = true
}
result.IdentityPatchPrompt = settings[SettingKeyIdentityPatchPrompt]
// Ops monitoring settings (default: enabled, fail-open)
result.OpsMonitoringEnabled = !isFalseSettingValue(settings[SettingKeyOpsMonitoringEnabled])
result.OpsRealtimeMonitoringEnabled = !isFalseSettingValue(settings[SettingKeyOpsRealtimeMonitoringEnabled])
result.OpsQueryModeDefault = string(ParseOpsQueryMode(settings[SettingKeyOpsQueryModeDefault]))
result.OpsMetricsIntervalSeconds = 60
if raw := strings.TrimSpace(settings[SettingKeyOpsMetricsIntervalSeconds]); raw != "" {
if v, err := strconv.Atoi(raw); err == nil {
if v < 60 {
v = 60
}
if v > 3600 {
v = 3600
}
result.OpsMetricsIntervalSeconds = v
}
}
// Claude Code version check
result.MinClaudeCodeVersion = settings[SettingKeyMinClaudeCodeVersion]
result.MaxClaudeCodeVersion = settings[SettingKeyMaxClaudeCodeVersion]
// 分组隔离
result.AllowUngroupedKeyScheduling = settings[SettingKeyAllowUngroupedKeyScheduling] == "true"
// Gateway forwarding behavior (defaults: fingerprint=true, metadata_passthrough=false, cch_signing=false)
if v, ok := settings[SettingKeyEnableFingerprintUnification]; ok && v != "" {
result.EnableFingerprintUnification = v == "true"
} else {
result.EnableFingerprintUnification = true // default: enabled (current behavior)
}
result.EnableMetadataPassthrough = settings[SettingKeyEnableMetadataPassthrough] == "true"
result.EnableCCHSigning = settings[SettingKeyEnableCCHSigning] == "true"
2025-12-18 13:50:39 +08:00
return result
}
func isFalseSettingValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "false", "0", "off", "disabled":
return true
default:
return false
}
}
func parseDefaultSubscriptions(raw string) []DefaultSubscriptionSetting {
raw = strings.TrimSpace(raw)
if raw == "" {
return nil
}
var items []DefaultSubscriptionSetting
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil
}
normalized := make([]DefaultSubscriptionSetting, 0, len(items))
for _, item := range items {
if item.GroupID <= 0 || item.ValidityDays <= 0 {
continue
}
if item.ValidityDays > MaxValidityDays {
item.ValidityDays = MaxValidityDays
}
normalized = append(normalized, item)
}
return normalized
}
2025-12-18 13:50:39 +08:00
// getStringOrDefault 获取字符串值或默认值
func (s *SettingService) getStringOrDefault(settings map[string]string, key, defaultValue string) string {
if value, ok := settings[key]; ok && value != "" {
return value
}
return defaultValue
}
// IsTurnstileEnabled 检查是否启用 Turnstile 验证
func (s *SettingService) IsTurnstileEnabled(ctx context.Context) bool {
value, err := s.settingRepo.GetValue(ctx, SettingKeyTurnstileEnabled)
2025-12-18 13:50:39 +08:00
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)
2025-12-18 13:50:39 +08:00
if err != nil {
return ""
}
return value
}
2025-12-20 15:11:43 +08:00
// 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) {
2025-12-20 15:11:43 +08:00
// 生成 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)
2025-12-20 15:11:43 +08:00
// 存储到 settings 表
if err := s.settingRepo.Set(ctx, SettingKeyAdminAPIKey, key); err != nil {
2025-12-20 15:11:43 +08:00
return "", fmt.Errorf("save admin api key: %w", err)
}
return key, nil
}
// GetAdminAPIKeyStatus 获取管理员 API Key 状态
2025-12-20 15:11:43 +08:00
// 返回脱敏的 key、是否存在、错误
func (s *SettingService) GetAdminAPIKeyStatus(ctx context.Context) (maskedKey string, exists bool, err error) {
key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey)
2025-12-20 15:11:43 +08:00
if err != nil {
2025-12-25 20:52:47 +08:00
if errors.Is(err, ErrSettingNotFound) {
2025-12-20 15:11:43 +08:00
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仅供内部验证使用
2025-12-20 15:11:43 +08:00
// 如果未配置返回空字符串和 nil 错误,只有数据库错误时才返回 error
func (s *SettingService) GetAdminAPIKey(ctx context.Context) (string, error) {
key, err := s.settingRepo.GetValue(ctx, SettingKeyAdminAPIKey)
2025-12-20 15:11:43 +08:00
if err != nil {
2025-12-25 20:52:47 +08:00
if errors.Is(err, ErrSettingNotFound) {
2025-12-20 15:11:43 +08:00
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
2025-12-20 15:11:43 +08:00
}
// GetLinuxDoConnectOAuthConfig 返回用于登录的"最终生效" LinuxDo Connect 配置。
//
// 优先级:
// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值
// - 否则回退到 config.yaml/env 的值
func (s *SettingService) GetLinuxDoConnectOAuthConfig(ctx context.Context) (config.LinuxDoConnectConfig, error) {
if s == nil || s.cfg == nil {
return config.LinuxDoConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
}
effective := s.cfg.LinuxDo
keys := []string{
SettingKeyLinuxDoConnectEnabled,
SettingKeyLinuxDoConnectClientID,
SettingKeyLinuxDoConnectClientSecret,
SettingKeyLinuxDoConnectRedirectURL,
}
settings, err := s.settingRepo.GetMultiple(ctx, keys)
if err != nil {
return config.LinuxDoConnectConfig{}, fmt.Errorf("get linuxdo connect settings: %w", err)
}
if raw, ok := settings[SettingKeyLinuxDoConnectEnabled]; ok {
effective.Enabled = raw == "true"
}
if v, ok := settings[SettingKeyLinuxDoConnectClientID]; ok && strings.TrimSpace(v) != "" {
effective.ClientID = strings.TrimSpace(v)
}
if v, ok := settings[SettingKeyLinuxDoConnectClientSecret]; ok && strings.TrimSpace(v) != "" {
effective.ClientSecret = strings.TrimSpace(v)
}
if v, ok := settings[SettingKeyLinuxDoConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
effective.RedirectURL = strings.TrimSpace(v)
}
if !effective.Enabled {
return config.LinuxDoConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
}
// 基础健壮性校验(避免把用户重定向到一个必然失败或不安全的 OAuth 流程里)。
if strings.TrimSpace(effective.ClientID) == "" {
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured")
}
if strings.TrimSpace(effective.AuthorizeURL) == "" {
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured")
}
if strings.TrimSpace(effective.TokenURL) == "" {
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured")
}
if strings.TrimSpace(effective.UserInfoURL) == "" {
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url not configured")
}
if strings.TrimSpace(effective.RedirectURL) == "" {
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured")
}
if strings.TrimSpace(effective.FrontendRedirectURL) == "" {
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured")
}
if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil {
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid")
}
if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil {
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid")
}
if err := config.ValidateAbsoluteHTTPURL(effective.UserInfoURL); err != nil {
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid")
}
if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil {
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid")
}
if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil {
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid")
}
method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod))
switch method {
case "", "client_secret_post", "client_secret_basic":
if strings.TrimSpace(effective.ClientSecret) == "" {
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured")
}
case "none":
if !effective.UsePKCE {
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth pkce must be enabled when token_auth_method=none")
}
default:
return config.LinuxDoConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid")
}
return effective, nil
}
// 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))
}
2026-03-13 23:38:58 +08:00
// GetOIDCConnectOAuthConfig 返回用于登录的“最终生效” OIDC 配置。
//
// 优先级:
// - 若对应系统设置键存在,则覆盖 config.yaml/env 的值
// - 否则回退到 config.yaml/env 的值
func (s *SettingService) GetOIDCConnectOAuthConfig(ctx context.Context) (config.OIDCConnectConfig, error) {
if s == nil || s.cfg == nil {
return config.OIDCConnectConfig{}, infraerrors.ServiceUnavailable("CONFIG_NOT_READY", "config not loaded")
}
effective := s.cfg.OIDC
keys := []string{
SettingKeyOIDCConnectEnabled,
SettingKeyOIDCConnectProviderName,
SettingKeyOIDCConnectClientID,
SettingKeyOIDCConnectClientSecret,
SettingKeyOIDCConnectIssuerURL,
SettingKeyOIDCConnectDiscoveryURL,
SettingKeyOIDCConnectAuthorizeURL,
SettingKeyOIDCConnectTokenURL,
SettingKeyOIDCConnectUserInfoURL,
SettingKeyOIDCConnectJWKSURL,
SettingKeyOIDCConnectScopes,
SettingKeyOIDCConnectRedirectURL,
SettingKeyOIDCConnectFrontendRedirectURL,
SettingKeyOIDCConnectTokenAuthMethod,
SettingKeyOIDCConnectUsePKCE,
SettingKeyOIDCConnectValidateIDToken,
SettingKeyOIDCConnectAllowedSigningAlgs,
SettingKeyOIDCConnectClockSkewSeconds,
SettingKeyOIDCConnectRequireEmailVerified,
SettingKeyOIDCConnectUserInfoEmailPath,
SettingKeyOIDCConnectUserInfoIDPath,
SettingKeyOIDCConnectUserInfoUsernamePath,
}
settings, err := s.settingRepo.GetMultiple(ctx, keys)
if err != nil {
return config.OIDCConnectConfig{}, fmt.Errorf("get oidc connect settings: %w", err)
}
if raw, ok := settings[SettingKeyOIDCConnectEnabled]; ok {
effective.Enabled = raw == "true"
}
if v, ok := settings[SettingKeyOIDCConnectProviderName]; ok && strings.TrimSpace(v) != "" {
effective.ProviderName = strings.TrimSpace(v)
}
if v, ok := settings[SettingKeyOIDCConnectClientID]; ok && strings.TrimSpace(v) != "" {
effective.ClientID = strings.TrimSpace(v)
}
if v, ok := settings[SettingKeyOIDCConnectClientSecret]; ok && strings.TrimSpace(v) != "" {
effective.ClientSecret = strings.TrimSpace(v)
}
if v, ok := settings[SettingKeyOIDCConnectIssuerURL]; ok && strings.TrimSpace(v) != "" {
effective.IssuerURL = strings.TrimSpace(v)
}
if v, ok := settings[SettingKeyOIDCConnectDiscoveryURL]; ok && strings.TrimSpace(v) != "" {
effective.DiscoveryURL = strings.TrimSpace(v)
}
if v, ok := settings[SettingKeyOIDCConnectAuthorizeURL]; ok && strings.TrimSpace(v) != "" {
effective.AuthorizeURL = strings.TrimSpace(v)
}
if v, ok := settings[SettingKeyOIDCConnectTokenURL]; ok && strings.TrimSpace(v) != "" {
effective.TokenURL = strings.TrimSpace(v)
}
if v, ok := settings[SettingKeyOIDCConnectUserInfoURL]; ok && strings.TrimSpace(v) != "" {
effective.UserInfoURL = strings.TrimSpace(v)
}
if v, ok := settings[SettingKeyOIDCConnectJWKSURL]; ok && strings.TrimSpace(v) != "" {
effective.JWKSURL = strings.TrimSpace(v)
}
if v, ok := settings[SettingKeyOIDCConnectScopes]; ok && strings.TrimSpace(v) != "" {
effective.Scopes = strings.TrimSpace(v)
}
if v, ok := settings[SettingKeyOIDCConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
effective.RedirectURL = strings.TrimSpace(v)
}
if v, ok := settings[SettingKeyOIDCConnectFrontendRedirectURL]; ok && strings.TrimSpace(v) != "" {
effective.FrontendRedirectURL = strings.TrimSpace(v)
}
if v, ok := settings[SettingKeyOIDCConnectTokenAuthMethod]; ok && strings.TrimSpace(v) != "" {
effective.TokenAuthMethod = strings.ToLower(strings.TrimSpace(v))
}
if raw, ok := settings[SettingKeyOIDCConnectUsePKCE]; ok {
effective.UsePKCE = raw == "true"
}
if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok {
effective.ValidateIDToken = raw == "true"
}
if v, ok := settings[SettingKeyOIDCConnectAllowedSigningAlgs]; ok && strings.TrimSpace(v) != "" {
effective.AllowedSigningAlgs = strings.TrimSpace(v)
}
if raw, ok := settings[SettingKeyOIDCConnectClockSkewSeconds]; ok && strings.TrimSpace(raw) != "" {
if parsed, parseErr := strconv.Atoi(strings.TrimSpace(raw)); parseErr == nil {
effective.ClockSkewSeconds = parsed
}
}
if raw, ok := settings[SettingKeyOIDCConnectRequireEmailVerified]; ok {
effective.RequireEmailVerified = raw == "true"
}
if v, ok := settings[SettingKeyOIDCConnectUserInfoEmailPath]; ok {
effective.UserInfoEmailPath = strings.TrimSpace(v)
}
if v, ok := settings[SettingKeyOIDCConnectUserInfoIDPath]; ok {
effective.UserInfoIDPath = strings.TrimSpace(v)
}
if v, ok := settings[SettingKeyOIDCConnectUserInfoUsernamePath]; ok {
effective.UserInfoUsernamePath = strings.TrimSpace(v)
}
if !effective.Enabled {
return config.OIDCConnectConfig{}, infraerrors.NotFound("OAUTH_DISABLED", "oauth login is disabled")
}
if strings.TrimSpace(effective.ProviderName) == "" {
effective.ProviderName = "OIDC"
}
if strings.TrimSpace(effective.ClientID) == "" {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client id not configured")
}
if strings.TrimSpace(effective.IssuerURL) == "" {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url not configured")
}
if strings.TrimSpace(effective.RedirectURL) == "" {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url not configured")
}
if strings.TrimSpace(effective.FrontendRedirectURL) == "" {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url not configured")
}
if !scopesContainOpenID(effective.Scopes) {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth scopes must contain openid")
}
if effective.ClockSkewSeconds < 0 || effective.ClockSkewSeconds > 600 {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth clock skew must be between 0 and 600")
}
if err := config.ValidateAbsoluteHTTPURL(effective.IssuerURL); err != nil {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth issuer url invalid")
}
discoveryURL := strings.TrimSpace(effective.DiscoveryURL)
if discoveryURL == "" {
discoveryURL = oidcDefaultDiscoveryURL(effective.IssuerURL)
effective.DiscoveryURL = discoveryURL
}
if discoveryURL != "" {
if err := config.ValidateAbsoluteHTTPURL(discoveryURL); err != nil {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery url invalid")
}
}
needsDiscovery := strings.TrimSpace(effective.AuthorizeURL) == "" ||
strings.TrimSpace(effective.TokenURL) == "" ||
(effective.ValidateIDToken && strings.TrimSpace(effective.JWKSURL) == "")
if needsDiscovery && discoveryURL != "" {
metadata, resolveErr := oidcResolveProviderMetadata(ctx, discoveryURL)
if resolveErr != nil {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth discovery resolve failed").WithCause(resolveErr)
}
if strings.TrimSpace(effective.AuthorizeURL) == "" {
effective.AuthorizeURL = strings.TrimSpace(metadata.AuthorizationEndpoint)
}
if strings.TrimSpace(effective.TokenURL) == "" {
effective.TokenURL = strings.TrimSpace(metadata.TokenEndpoint)
}
if strings.TrimSpace(effective.UserInfoURL) == "" {
effective.UserInfoURL = strings.TrimSpace(metadata.UserInfoEndpoint)
}
if strings.TrimSpace(effective.JWKSURL) == "" {
effective.JWKSURL = strings.TrimSpace(metadata.JWKSURI)
}
}
if strings.TrimSpace(effective.AuthorizeURL) == "" {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url not configured")
}
if strings.TrimSpace(effective.TokenURL) == "" {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url not configured")
}
if err := config.ValidateAbsoluteHTTPURL(effective.AuthorizeURL); err != nil {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth authorize url invalid")
}
if err := config.ValidateAbsoluteHTTPURL(effective.TokenURL); err != nil {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token url invalid")
}
if v := strings.TrimSpace(effective.UserInfoURL); v != "" {
if err := config.ValidateAbsoluteHTTPURL(v); err != nil {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth userinfo url invalid")
}
}
if effective.ValidateIDToken {
if strings.TrimSpace(effective.JWKSURL) == "" {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url not configured")
}
if strings.TrimSpace(effective.AllowedSigningAlgs) == "" {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth signing algs not configured")
}
}
if v := strings.TrimSpace(effective.JWKSURL); v != "" {
if err := config.ValidateAbsoluteHTTPURL(v); err != nil {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth jwks url invalid")
}
}
if err := config.ValidateAbsoluteHTTPURL(effective.RedirectURL); err != nil {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth redirect url invalid")
}
if err := config.ValidateFrontendRedirectURL(effective.FrontendRedirectURL); err != nil {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth frontend redirect url invalid")
}
method := strings.ToLower(strings.TrimSpace(effective.TokenAuthMethod))
switch method {
case "", "client_secret_post", "client_secret_basic":
if strings.TrimSpace(effective.ClientSecret) == "" {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth client secret not configured")
}
case "none":
if !effective.UsePKCE {
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth pkce must be enabled when token_auth_method=none")
}
default:
return config.OIDCConnectConfig{}, infraerrors.InternalServer("OAUTH_CONFIG_INVALID", "oauth token_auth_method invalid")
}
return effective, nil
}
func scopesContainOpenID(scopes string) bool {
for _, scope := range strings.Fields(strings.ToLower(strings.TrimSpace(scopes))) {
if scope == "openid" {
return true
}
}
return false
}
type oidcProviderMetadata struct {
AuthorizationEndpoint string `json:"authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
UserInfoEndpoint string `json:"userinfo_endpoint"`
JWKSURI string `json:"jwks_uri"`
}
func oidcDefaultDiscoveryURL(issuerURL string) string {
issuerURL = strings.TrimSpace(issuerURL)
if issuerURL == "" {
return ""
}
return strings.TrimRight(issuerURL, "/") + "/.well-known/openid-configuration"
}
func oidcResolveProviderMetadata(ctx context.Context, discoveryURL string) (*oidcProviderMetadata, error) {
discoveryURL = strings.TrimSpace(discoveryURL)
if discoveryURL == "" {
return nil, fmt.Errorf("discovery url is empty")
}
resp, err := req.C().
SetTimeout(15*time.Second).
R().
SetContext(ctx).
SetHeader("Accept", "application/json").
Get(discoveryURL)
if err != nil {
return nil, fmt.Errorf("request discovery document: %w", err)
}
if !resp.IsSuccessState() {
return nil, fmt.Errorf("discovery request failed: status=%d", resp.StatusCode)
}
metadata := &oidcProviderMetadata{}
if err := json.Unmarshal(resp.Bytes(), metadata); err != nil {
return nil, fmt.Errorf("parse discovery document: %w", err)
}
return metadata, nil
}
// 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"
}
// 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
}
// 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))
}
// 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))
}