mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-28 08:08:32 +00:00
PUT /api/v1/admin/settings is a whole-document write. The admin UI always sends
the complete document, so saving from the settings page is unaffected both
before and after this change. The bug is only reachable when an API client calls
the endpoint directly and sends just the fields it wants to change, which is the
natural assumption for a PUT on a settings resource.
Value-typed fields of UpdateSettingsRequest bind to their zero value when the
payload omits them, and buildSystemSettingsUpdates writes every key
unconditionally, so such a caller has no way to say "leave this one alone".
Omitting a field and explicitly clearing it are indistinguishable on the wire.
A caller that sends only the field it wants to change, e.g.
{"risk_control_enabled": true}
sets that flag and clears every other unguarded field in the same request.
Measured against a fully configured store, one such call empties site_name,
site_subtitle, api_base_url, contact_info and doc_url, and turns
registration_enabled, email_verify_enabled, invitation_code_enabled and
turnstile_enabled off. turnstile_enabled alone gates the captcha on login,
register, forgot-password and both verify-code endpoints, and
email_verify_enabled is a precondition of IsPasswordResetEnabled.
The damage is easy to miss. site_name has a built-in fallback, so
getStringOrDefault renders the cleared value as the default product name and the
login page visibly changes, while the toggles just go quiet. Reopening the
settings page reads the already-cleared state back into the form, so correcting
the one visible field and saving persists the rest of the damage.
Fields that grew their own guard already survive this: the SMTP block falls back
to the previous values when smtp_host arrives empty, secret fields are written
only when non-empty, and 132 request fields are pointers whose handler merges an
omitted field with the stored value. This generalizes that pattern rather than
adding a fourth ad-hoc guard.
The handler now decodes the payload a second time as a raw field map, resolves
the setting key each absent field would have written, and hands that set to the
service, which drops those keys before SetMultiple, so the stored value is never
touched. Fields the payload does carry are written as before, giving the caller
the partial-update semantics it was already assuming. The mapping is reflected off
the request's json tags so new fields are covered without maintaining a list;
smtp_from_email is the only field whose json name differs from its setting key
and is aliased explicitly.
Only value-typed fields are filtered. Pointer fields keep whole-document
behaviour on purpose: forwarded_client_ip_headers and
api_key_acl_trust_forwarded_ip depend on being rewritten on every save to
re-normalize fail-closed state, which the malformed forwarded-client-IP header
test pins down.
An explicitly sent empty value is still a deliberate clear; only absent fields
are preserved. A partial write refreshes the in-process caches from storage
instead of from the request struct, which holds zero values for whatever the
caller omitted.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
692 lines
37 KiB
Go
692 lines
37 KiB
Go
package service
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"log/slog"
|
|
"math"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/config"
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
|
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
|
)
|
|
|
|
// OmittedSettingKeys marks setting keys the caller's payload never carried.
|
|
// SystemSettings is a plain struct, so a field the caller omitted arrives as a
|
|
// zero value and is indistinguishable from a deliberate clear. Listing the key
|
|
// here drops it from the write, leaving the stored value in place.
|
|
//
|
|
// A nil or empty set keeps whole-document semantics: every key is written.
|
|
type OmittedSettingKeys map[string]struct{}
|
|
|
|
func (o OmittedSettingKeys) dropFrom(updates map[string]string) {
|
|
for key := range o {
|
|
delete(updates, key)
|
|
}
|
|
}
|
|
|
|
// UpdateSettings 更新系统设置
|
|
func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSettings) error {
|
|
return s.UpdateSettingsOmitting(ctx, settings, nil)
|
|
}
|
|
|
|
// UpdateSettingsOmitting persists system settings, leaving the keys in omitted
|
|
// at their stored value.
|
|
func (s *SettingService) UpdateSettingsOmitting(ctx context.Context, settings *SystemSettings, omitted OmittedSettingKeys) error {
|
|
updates, err := s.buildSystemSettingsUpdates(ctx, settings)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
omitted.dropFrom(updates)
|
|
|
|
if err := s.settingRepo.SetMultiple(ctx, updates); err != nil {
|
|
return err
|
|
}
|
|
s.refreshCachedSettingsAfterWrite(ctx, settings, omitted)
|
|
return nil
|
|
}
|
|
|
|
// UpdateSettingsWithAuthSourceDefaults persists system settings and auth-source defaults in a single write.
|
|
func (s *SettingService) UpdateSettingsWithAuthSourceDefaults(ctx context.Context, settings *SystemSettings, authDefaults *AuthSourceDefaultSettings) error {
|
|
return s.UpdateSettingsWithAuthSourceDefaultsOmitting(ctx, settings, authDefaults, nil)
|
|
}
|
|
|
|
// UpdateSettingsWithAuthSourceDefaultsOmitting persists system settings and
|
|
// auth-source defaults in a single write, leaving the keys in omitted at their
|
|
// stored value.
|
|
func (s *SettingService) UpdateSettingsWithAuthSourceDefaultsOmitting(ctx context.Context, settings *SystemSettings, authDefaults *AuthSourceDefaultSettings, omitted OmittedSettingKeys) 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
|
|
}
|
|
omitted.dropFrom(updates)
|
|
|
|
if err := s.settingRepo.SetMultiple(ctx, updates); err != nil {
|
|
return err
|
|
}
|
|
s.refreshCachedSettingsAfterWrite(ctx, settings, omitted)
|
|
return nil
|
|
}
|
|
|
|
// refreshCachedSettingsAfterWrite keeps the in-process caches in step with the
|
|
// write that just landed. A partial payload carries zero values for the fields
|
|
// it omitted, so in that case the caches are rebuilt from storage rather than
|
|
// from the request struct.
|
|
func (s *SettingService) refreshCachedSettingsAfterWrite(ctx context.Context, settings *SystemSettings, omitted OmittedSettingKeys) {
|
|
if len(omitted) == 0 {
|
|
s.refreshCachedSettings(settings)
|
|
return
|
|
}
|
|
stored, err := s.GetAllSettings(ctx)
|
|
if err != nil {
|
|
slog.Warn("refresh cached settings after partial update failed", "error", err)
|
|
return
|
|
}
|
|
s.refreshCachedSettings(stored)
|
|
}
|
|
|
|
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
|
|
normalizedForwardedClientIPHeaders, err := config.NormalizeForwardedClientIPHeaders(settings.ForwardedClientIPHeaders)
|
|
if err != nil {
|
|
return nil, infraerrors.BadRequest("INVALID_FORWARDED_CLIENT_IP_HEADERS", err.Error())
|
|
}
|
|
settings.ForwardedClientIPHeaders = normalizedForwardedClientIPHeaders
|
|
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)
|
|
updates[SettingKeySessionBindingEnabled] = strconv.FormatBool(settings.SessionBindingEnabled)
|
|
updates[SettingKeyStepUpEnabled] = strconv.FormatBool(settings.StepUpEnabled)
|
|
updates[SettingKeyAuditLogRetentionDays] = strconv.Itoa(settings.AuditLogRetentionDays)
|
|
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)
|
|
forwardedClientIPHeadersJSON, err := json.Marshal(settings.ForwardedClientIPHeaders)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("marshal forwarded client IP headers: %w", err)
|
|
}
|
|
updates[SettingKeyForwardedClientIPHeaders] = string(forwardedClientIPHeadersJSON)
|
|
|
|
// 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[SettingKeyAffiliateAdminRechargeEnabled] = strconv.FormatBool(settings.AdminRechargeRebateEnabled)
|
|
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[SettingKeyOpenAILowUpstreamRatePriorityEnabled] = strconv.FormatBool(settings.OpenAILowUpstreamRatePriorityEnabled)
|
|
updates[SettingKeyOpenAIOAuthSchedulingRateMultiplier] = strconv.FormatFloat(settings.OpenAIOAuthSchedulingRateMultiplier, 'f', -1, 64)
|
|
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[SettingKeyOpenAIAdvancedSchedulerWeightUpstreamCost] = settings.OpenAIAdvancedSchedulerWeightUpstreamCost
|
|
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{
|
|
lowUpstreamRatePriorityEnabled: settings.OpenAILowUpstreamRatePriorityEnabled,
|
|
oauthSchedulingRateMultiplier: settings.OpenAIOAuthSchedulingRateMultiplier,
|
|
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,
|
|
SettingKeyOpenAIAdvancedSchedulerWeightUpstreamCost: settings.OpenAIAdvancedSchedulerWeightUpstreamCost,
|
|
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.SetForwardedClientIPSettings(settings.APIKeyACLTrustForwardedIP, settings.ForwardedClientIPHeaders)
|
|
}
|
|
// 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
|
|
}
|