832 lines
43 KiB
Go
832 lines
43 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[SettingKeyRegistrationEmailDomainQuotaEnabled] = strconv.FormatBool(settings.RegistrationEmailDomainQuotaEnabled)
|
||
|
|
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[SettingKeyPasskeyEnabled] = strconv.FormatBool(settings.PasskeyEnabled)
|
||
|
|
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[SettingKeyTencentCaptchaEnabled] = strconv.FormatBool(settings.TencentCaptchaEnabled)
|
||
|
|
updates[SettingKeyTencentCaptchaAppID] = settings.TencentCaptchaAppID
|
||
|
|
if settings.TencentCaptchaAppSecretKey != "" {
|
||
|
|
updates[SettingKeyTencentCaptchaAppSecretKey] = settings.TencentCaptchaAppSecretKey
|
||
|
|
}
|
||
|
|
if settings.TencentCaptchaCloudSecretID != "" {
|
||
|
|
updates[SettingKeyTencentCaptchaCloudSecretID] = settings.TencentCaptchaCloudSecretID
|
||
|
|
}
|
||
|
|
if settings.TencentCaptchaCloudSecretKey != "" {
|
||
|
|
updates[SettingKeyTencentCaptchaCloudSecretKey] = settings.TencentCaptchaCloudSecretKey
|
||
|
|
}
|
||
|
|
updates[SettingKeyTencentCaptchaRegion] = normalizeTencentCaptchaRegion(settings.TencentCaptchaRegion)
|
||
|
|
// 阿里云验证码 2.0 设置(只有非空才更新密钥)
|
||
|
|
updates[SettingKeyAliyunCaptchaEnabled] = strconv.FormatBool(settings.AliyunCaptchaEnabled)
|
||
|
|
updates[SettingKeyAliyunCaptchaAccessKeyID] = settings.AliyunCaptchaAccessKeyID
|
||
|
|
if settings.AliyunCaptchaAccessKeySecret != "" {
|
||
|
|
updates[SettingKeyAliyunCaptchaAccessKeySecret] = settings.AliyunCaptchaAccessKeySecret
|
||
|
|
}
|
||
|
|
updates[SettingKeyAliyunCaptchaSceneID] = settings.AliyunCaptchaSceneID
|
||
|
|
updates[SettingKeyAliyunCaptchaPrefix] = settings.AliyunCaptchaPrefix
|
||
|
|
updates[SettingKeyAliyunCaptchaRegion] = normalizeAliyunCaptchaRegion(settings.AliyunCaptchaRegion)
|
||
|
|
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[SettingKeyCompactHomeEnabled] = strconv.FormatBool(settings.CompactHomeEnabled)
|
||
|
|
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)
|
||
|
|
updates[SettingKeyChannelMonitorMode] = normalizeChannelMonitorMode(settings.ChannelMonitorMode)
|
||
|
|
if v := clampChannelMonitorInterval(settings.ChannelMonitorDefaultIntervalSeconds); v > 0 {
|
||
|
|
updates[SettingKeyChannelMonitorDefaultIntervalSeconds] = strconv.Itoa(v)
|
||
|
|
}
|
||
|
|
updates[SettingKeyChannelMonitorHideThroughput] = strconv.FormatBool(settings.ChannelMonitorHideThroughput)
|
||
|
|
updates[SettingKeyChannelMonitorShowQuota] = strconv.FormatBool(settings.ChannelMonitorShowQuota)
|
||
|
|
|
||
|
|
// Grok model mapping policy
|
||
|
|
if v := strings.TrimSpace(settings.GrokDefaultTextModel); v != "" {
|
||
|
|
updates[SettingKeyGrokDefaultTextModel] = v
|
||
|
|
} else {
|
||
|
|
updates[SettingKeyGrokDefaultTextModel] = "grok-4.5"
|
||
|
|
}
|
||
|
|
updates[SettingKeyGrokCrossClientModelMapEnabled] = strconv.FormatBool(settings.GrokCrossClientModelMapEnabled)
|
||
|
|
updates[SettingKeyGrokDefaultBaseURLMode] = normalizeGrokDefaultBaseURLMode(settings.GrokDefaultBaseURLMode)
|
||
|
|
|
||
|
|
// Available channels feature switch
|
||
|
|
updates[SettingKeyAvailableChannelsEnabled] = strconv.FormatBool(settings.AvailableChannelsEnabled)
|
||
|
|
|
||
|
|
// Model plaza feature switches + description
|
||
|
|
updates[SettingKeyModelPlazaEnabled] = strconv.FormatBool(settings.ModelPlazaEnabled)
|
||
|
|
updates[SettingKeyModelPlazaRequireAuth] = strconv.FormatBool(settings.ModelPlazaRequireAuth)
|
||
|
|
updates[SettingKeyModelPlazaDescription] = settings.ModelPlazaDescription
|
||
|
|
|
||
|
|
// 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)
|
||
|
|
updates[SettingKeyOpenAICodexClientVersion] = NormalizeCodexClientVersion(settings.OpenAICodexClientVersion)
|
||
|
|
updates[SettingKeyOpenAICodexVersionAutoSyncEnabled] = strconv.FormatBool(settings.OpenAICodexVersionAutoSyncEnabled)
|
||
|
|
// SettingKeyOpenAICodexClientVersionSynced 由自动同步任务独占写入,此处不得覆盖,
|
||
|
|
// 否则面板保存会把同步结果清空。
|
||
|
|
// 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)
|
||
|
|
}
|
||
|
|
if settings.AccountSchedulingThresholds != nil {
|
||
|
|
normalized, err := validateAndNormalizeAccountSchedulingThresholds(settings.AccountSchedulingThresholds)
|
||
|
|
if err != nil {
|
||
|
|
return nil, err
|
||
|
|
}
|
||
|
|
blob, err := json.Marshal(normalized)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("marshal account scheduling thresholds: %w", err)
|
||
|
|
}
|
||
|
|
updates[SettingKeyAccountSchedulingThresholds] = string(blob)
|
||
|
|
}
|
||
|
|
|
||
|
|
updates[SettingKeyAllowUserViewErrorRequests] = strconv.FormatBool(settings.AllowUserViewErrorRequests)
|
||
|
|
|
||
|
|
return updates, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func defaultAccountSchedulingThresholds() map[string]int {
|
||
|
|
return map[string]int{
|
||
|
|
PlatformOpenAI: 100,
|
||
|
|
PlatformAnthropic: 100,
|
||
|
|
PlatformGrok: 100,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func validateAndNormalizeAccountSchedulingThresholds(input map[string]int) (map[string]int, error) {
|
||
|
|
normalized := defaultAccountSchedulingThresholds()
|
||
|
|
for platform, value := range input {
|
||
|
|
allowed := false
|
||
|
|
for _, item := range AllowedSchedulingThresholdPlatforms {
|
||
|
|
if item == platform {
|
||
|
|
allowed = true
|
||
|
|
break
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if !allowed {
|
||
|
|
return nil, infraerrors.BadRequest("INVALID_ACCOUNT_SCHEDULING_THRESHOLDS", fmt.Sprintf("unknown platform %q", platform))
|
||
|
|
}
|
||
|
|
if value < 1 || value > 100 {
|
||
|
|
return nil, infraerrors.BadRequest("INVALID_ACCOUNT_SCHEDULING_THRESHOLDS", "platform scheduling threshold must be between 1 and 100")
|
||
|
|
}
|
||
|
|
normalized[platform] = value
|
||
|
|
}
|
||
|
|
return normalized, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func parseAccountSchedulingThresholdsSetting(raw string) (map[string]int, error) {
|
||
|
|
thresholds := defaultAccountSchedulingThresholds()
|
||
|
|
raw = strings.TrimSpace(raw)
|
||
|
|
if raw == "" {
|
||
|
|
return thresholds, nil
|
||
|
|
}
|
||
|
|
parsed := map[string]int{}
|
||
|
|
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
|
||
|
|
return thresholds, err
|
||
|
|
}
|
||
|
|
for _, platform := range AllowedSchedulingThresholdPlatforms {
|
||
|
|
if value, ok := parsed[platform]; ok {
|
||
|
|
thresholds[platform] = boundedIntOrDefault(value, 1, 100, 100)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return thresholds, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func boundedIntOrDefault(value, minValue, maxValue, defaultValue int) int {
|
||
|
|
if value < minValue || value > maxValue {
|
||
|
|
return defaultValue
|
||
|
|
}
|
||
|
|
return value
|
||
|
|
}
|
||
|
|
|
||
|
|
func cloneAccountSchedulingThresholds(input map[string]int) map[string]int {
|
||
|
|
if len(input) == 0 {
|
||
|
|
return defaultAccountSchedulingThresholds()
|
||
|
|
}
|
||
|
|
cloned := make(map[string]int, len(input))
|
||
|
|
for key, value := range input {
|
||
|
|
cloned[key] = value
|
||
|
|
}
|
||
|
|
return cloned
|
||
|
|
}
|
||
|
|
|
||
|
|
// 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(),
|
||
|
|
})
|
||
|
|
// 版本号缓存只做失效,不在此重算:生效值还取决于自动同步写入的 synced 键,
|
||
|
|
// 这里没有它的最新值,重算会把同步结果覆盖成陈旧值。
|
||
|
|
s.InvalidateOpenAICodexClientVersionCache()
|
||
|
|
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,
|
||
|
|
})
|
||
|
|
}
|
||
|
|
accountSchedulingThresholdsSF.Forget(SettingKeyAccountSchedulingThresholds)
|
||
|
|
if settings.AccountSchedulingThresholds != nil {
|
||
|
|
normalizedThresholds, err := validateAndNormalizeAccountSchedulingThresholds(settings.AccountSchedulingThresholds)
|
||
|
|
if err != nil {
|
||
|
|
normalizedThresholds = defaultAccountSchedulingThresholds()
|
||
|
|
}
|
||
|
|
accountSchedulingThresholdsCache.Store(&cachedAccountSchedulingThresholds{
|
||
|
|
thresholds: cloneAccountSchedulingThresholds(normalizedThresholds),
|
||
|
|
expiresAt: time.Now().Add(accountSchedulingThresholdsCacheTTL).UnixNano(),
|
||
|
|
})
|
||
|
|
} else {
|
||
|
|
// Partial/omitted payload: clear cache so the next hot-path read reloads from DB.
|
||
|
|
accountSchedulingThresholdsCache.Store(&cachedAccountSchedulingThresholds{})
|
||
|
|
}
|
||
|
|
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
|
||
|
|
}
|
||
|
|
s.notifyChannelMonitorRuntimeListeners()
|
||
|
|
}
|
||
|
|
|
||
|
|
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
|
||
|
|
}
|