Files
sub2api/backend/internal/service/setting_parse.go
T
shaw 0ddd58aaf9 feat(security): 操作审计日志 + 会话IP/UA绑定 + 敏感操作 step-up 2FA
应对管理员访问凭证失守导致的数据外泄风险,新增三层防护:

审计日志(admin-only 可见,用户不可见)
- 新增 append-only audit_logs 表(migration 180)+ 异步批量写入 + 保留期清理
- 审计中间件挂在 admin/user/auth/admin-payment 组认证之后:记录所有变更类
  请求 + 白名单敏感读取(账号/代理导出、备份下载、admin/user API key 读取)
- 请求头凭证首尾掩码;请求体 JSON 递归脱敏(api_key/password 等擦除,base_url
  保留以便追责);非 JSON body 不入库
- 无单条删除;全量清空需现场 TOTP 校验、拒绝 admin API key、未启用 2FA 不允许,
  清空后同步写入留痕记录

会话 IP/UA 绑定(默认开启,可在系统设置关闭)
- JWT 携带 session id + IP/UA 指纹哈希;IP 或 UA 任一变化即撤销会话家族并要求
  重新登录;旧 token 无指纹时放行以平滑升级

敏感操作 step-up 2FA(sudo 窗口 15 分钟)
- 账号/代理导出、DB 备份创建/下载、S3 目标修改要求近期 TOTP 二次验证;admin API
  key 一律拒绝;前端 useStepUp 组合式 + TotpStepUpDialog 弹码后自动重试
- API key 查看按需求暂不加强管控

前端:新增 /admin/audit-logs 操作日志页面(筛选/详情/2FA 清空)、侧边栏入口、
step-up 弹窗接入导出与备份流程、安全设置项(绑定开关 + 日志保留天数)、zh/en i18n
2026-07-16 13:47:50 +08:00

1188 lines
56 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"math"
"sort"
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
)
// InitializeDefaultSettings 初始化默认设置
func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
// 检查是否已有设置
_, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEnabled)
if err == nil {
// 已有设置,不需要初始化
return nil
}
if !errors.Is(err, ErrSettingNotFound) {
return fmt.Errorf("check existing settings: %w", err)
}
oidcUsePKCEDefault := true
oidcValidateIDTokenDefault := true
if s != nil && s.cfg != nil {
if s.cfg.OIDC.UsePKCEExplicit {
oidcUsePKCEDefault = s.cfg.OIDC.UsePKCE
}
if s.cfg.OIDC.ValidateIDTokenExplicit {
oidcValidateIDTokenDefault = s.cfg.OIDC.ValidateIDToken
}
}
loginAgreementDocumentsJSON, err := marshalLoginAgreementDocuments(defaultLoginAgreementDocuments())
if err != nil {
return err
}
// 初始化默认设置
defaults := map[string]string{
SettingKeyRegistrationEnabled: "true",
SettingKeyEmailVerifyEnabled: "false",
SettingKeyRegistrationEmailSuffixWhitelist: "[]",
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
SettingKeyLoginAgreementEnabled: "false",
SettingKeyLoginAgreementMode: defaultLoginAgreementMode,
SettingKeyLoginAgreementUpdatedAt: defaultLoginAgreementDate,
SettingKeyLoginAgreementDocuments: loginAgreementDocumentsJSON,
SettingKeyAPIKeyACLTrustForwardedIP: "false",
SettingKeySiteName: "Sub2API",
SettingKeySiteLogo: "",
SettingKeyPurchaseSubscriptionEnabled: "false",
SettingKeyPurchaseSubscriptionURL: "",
SettingKeyTableDefaultPageSize: "20",
SettingKeyTablePageSizeOptions: "[10,20,50,100]",
SettingKeyCustomMenuItems: "[]",
SettingKeyCustomEndpoints: "[]",
SettingKeyWeChatConnectEnabled: "false",
SettingKeyWeChatConnectAppID: "",
SettingKeyWeChatConnectAppSecret: "",
SettingKeyWeChatConnectOpenAppID: "",
SettingKeyWeChatConnectOpenAppSecret: "",
SettingKeyWeChatConnectMPAppID: "",
SettingKeyWeChatConnectMPAppSecret: "",
SettingKeyWeChatConnectMobileAppID: "",
SettingKeyWeChatConnectMobileAppSecret: "",
SettingKeyWeChatConnectOpenEnabled: "false",
SettingKeyWeChatConnectMPEnabled: "false",
SettingKeyWeChatConnectMobileEnabled: "false",
SettingKeyWeChatConnectMode: "open",
SettingKeyWeChatConnectScopes: "snsapi_login",
SettingKeyWeChatConnectRedirectURL: "",
SettingKeyWeChatConnectFrontendRedirectURL: defaultWeChatConnectFrontend,
SettingKeyGitHubOAuthEnabled: "false",
SettingKeyGitHubOAuthClientID: "",
SettingKeyGitHubOAuthClientSecret: "",
SettingKeyGitHubOAuthRedirectURL: "",
SettingKeyGitHubOAuthFrontendRedirectURL: defaultGitHubOAuthFrontend,
SettingKeyGoogleOAuthEnabled: "false",
SettingKeyGoogleOAuthClientID: "",
SettingKeyGoogleOAuthClientSecret: "",
SettingKeyGoogleOAuthRedirectURL: "",
SettingKeyGoogleOAuthFrontendRedirectURL: defaultGoogleOAuthFrontend,
SettingKeyOIDCConnectEnabled: "false",
SettingKeyOIDCConnectProviderName: "OIDC",
SettingKeyOIDCConnectClientID: "",
SettingKeyOIDCConnectClientSecret: "",
SettingKeyOIDCConnectIssuerURL: "",
SettingKeyOIDCConnectDiscoveryURL: "",
SettingKeyOIDCConnectAuthorizeURL: "",
SettingKeyOIDCConnectTokenURL: "",
SettingKeyOIDCConnectUserInfoURL: "",
SettingKeyOIDCConnectJWKSURL: "",
SettingKeyOIDCConnectScopes: "openid email profile",
SettingKeyOIDCConnectRedirectURL: "",
SettingKeyOIDCConnectFrontendRedirectURL: "/auth/oidc/callback",
SettingKeyOIDCConnectTokenAuthMethod: "client_secret_post",
SettingKeyOIDCConnectUsePKCE: strconv.FormatBool(oidcUsePKCEDefault),
SettingKeyOIDCConnectValidateIDToken: strconv.FormatBool(oidcValidateIDTokenDefault),
SettingKeyOIDCConnectAllowedSigningAlgs: "RS256,ES256,PS256",
SettingKeyOIDCConnectClockSkewSeconds: "120",
SettingKeyOIDCConnectRequireEmailVerified: "false",
SettingKeyOIDCConnectUserInfoEmailPath: "",
SettingKeyOIDCConnectUserInfoIDPath: "",
SettingKeyOIDCConnectUserInfoUsernamePath: "",
SettingKeyDefaultConcurrency: strconv.Itoa(s.cfg.Default.UserConcurrency),
SettingKeyDefaultBalance: strconv.FormatFloat(s.cfg.Default.UserBalance, 'f', 8, 64),
SettingKeyAffiliateRebateRate: strconv.FormatFloat(AffiliateRebateRateDefault, 'f', 8, 64),
SettingKeyAffiliateRebateFreezeHours: strconv.Itoa(AffiliateRebateFreezeHoursDefault),
SettingKeyAffiliateRebateDurationDays: strconv.Itoa(AffiliateRebateDurationDaysDefault),
SettingKeyAffiliateRebatePerInviteeCap: strconv.FormatFloat(AffiliateRebatePerInviteeCapDefault, 'f', 2, 64),
SettingKeyDefaultUserRPMLimit: "0",
SettingKeyDefaultSubscriptions: "[]",
SettingKeyAuthSourceDefaultEmailBalance: "0",
SettingKeyAuthSourceDefaultEmailConcurrency: "5",
SettingKeyAuthSourceDefaultEmailSubscriptions: "[]",
SettingKeyAuthSourceDefaultEmailGrantOnSignup: "false",
SettingKeyAuthSourceDefaultEmailGrantOnFirstBind: "false",
SettingKeyAuthSourceDefaultLinuxDoBalance: "0",
SettingKeyAuthSourceDefaultLinuxDoConcurrency: "5",
SettingKeyAuthSourceDefaultLinuxDoSubscriptions: "[]",
SettingKeyAuthSourceDefaultLinuxDoGrantOnSignup: "false",
SettingKeyAuthSourceDefaultLinuxDoGrantOnFirstBind: "false",
SettingKeyAuthSourceDefaultOIDCBalance: "0",
SettingKeyAuthSourceDefaultOIDCConcurrency: "5",
SettingKeyAuthSourceDefaultOIDCSubscriptions: "[]",
SettingKeyAuthSourceDefaultOIDCGrantOnSignup: "false",
SettingKeyAuthSourceDefaultOIDCGrantOnFirstBind: "false",
SettingKeyAuthSourceDefaultWeChatBalance: "0",
SettingKeyAuthSourceDefaultWeChatConcurrency: "5",
SettingKeyAuthSourceDefaultWeChatSubscriptions: "[]",
SettingKeyAuthSourceDefaultWeChatGrantOnSignup: "false",
SettingKeyAuthSourceDefaultWeChatGrantOnFirstBind: "false",
SettingKeyAuthSourceDefaultGitHubBalance: "0",
SettingKeyAuthSourceDefaultGitHubConcurrency: "5",
SettingKeyAuthSourceDefaultGitHubSubscriptions: "[]",
SettingKeyAuthSourceDefaultGitHubGrantOnSignup: "false",
SettingKeyAuthSourceDefaultGitHubGrantOnFirstBind: "false",
SettingKeyAuthSourceDefaultGoogleBalance: "0",
SettingKeyAuthSourceDefaultGoogleConcurrency: "5",
SettingKeyAuthSourceDefaultGoogleSubscriptions: "[]",
SettingKeyAuthSourceDefaultGoogleGrantOnSignup: "false",
SettingKeyAuthSourceDefaultGoogleGrantOnFirstBind: "false",
SettingKeyAuthSourceDefaultDingTalkBalance: "0",
SettingKeyAuthSourceDefaultDingTalkConcurrency: "5",
SettingKeyAuthSourceDefaultDingTalkSubscriptions: "[]",
SettingKeyAuthSourceDefaultDingTalkGrantOnSignup: "false",
SettingKeyAuthSourceDefaultDingTalkGrantOnFirstBind: "false",
SettingKeyForceEmailOnThirdPartySignup: "false",
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",
// Channel monitor defaults (enabled, 60s)
SettingKeyChannelMonitorEnabled: "true",
SettingKeyChannelMonitorDefaultIntervalSeconds: "60",
// Available channels feature (default disabled; opt-in)
SettingKeyAvailableChannelsEnabled: "false",
// Affiliate (邀请返利) feature (default disabled; opt-in)
SettingKeyAffiliateEnabled: "false",
SettingKeyAffiliateAdminRechargeEnabled: strconv.FormatBool(AdminRechargeRebateEnabledDefault),
// 风控中心功能(默认关闭,显式启用)
SettingKeyRiskControlEnabled: "false",
// cyber 会话屏蔽(默认关闭,TTL 默认 3600s)
SettingKeyCyberSessionBlockEnabled: "false",
SettingKeyCyberSessionBlockTTLSeconds: "3600",
// Claude Code version check (default: empty = disabled)
SettingKeyMinClaudeCodeVersion: "",
SettingKeyMaxClaudeCodeVersion: "",
// codex_cli_only 加固(默认:版本不检查、名单空、默认种子指纹信号)
SettingKeyMinCodexVersion: "",
SettingKeyMaxCodexVersion: "",
SettingKeyCodexCLIOnlyBlacklist: "",
SettingKeyCodexCLIOnlyWhitelist: "",
SettingKeyCodexCLIOnlyAllowAppServerClients: "false",
SettingKeyCodexCLIOnlyEngineFingerprintSignals: openai.DefaultEngineFingerprintSignalsJSON(),
// 分组隔离(默认不允许未分组 Key 调度)
SettingKeyAllowUngroupedKeyScheduling: "false",
SettingKeyOpenAILowUpstreamRatePriorityEnabled: "false",
SettingKeyOpenAIOAuthSchedulingRateMultiplier: "1",
SettingKeyEnableAnthropicCacheTTL1hInjection: "false",
SettingKeyRewriteMessageCacheControl: strconv.FormatBool(s.defaultRewriteMessageCacheControl()),
SettingKeyEnableClientDatelineNormalization: "true",
SettingKeyAntigravityUserAgentVersion: "",
SettingKeyOpenAICodexUserAgent: "",
SettingPaymentVisibleMethodAlipaySource: "",
SettingPaymentVisibleMethodWxpaySource: "",
SettingPaymentVisibleMethodAlipayEnabled: "false",
SettingPaymentVisibleMethodWxpayEnabled: "false",
openAIAdvancedSchedulerSettingKey: "false",
SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled: "false",
SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled: "false",
SettingKeyOpenAIAdvancedSchedulerLBTopK: "",
SettingKeyOpenAIAdvancedSchedulerWeightPriority: "",
SettingKeyOpenAIAdvancedSchedulerWeightLoad: "",
SettingKeyOpenAIAdvancedSchedulerWeightQueue: "",
SettingKeyOpenAIAdvancedSchedulerWeightErrorRate: "",
SettingKeyOpenAIAdvancedSchedulerWeightTTFT: "",
SettingKeyOpenAIAdvancedSchedulerWeightReset: "",
SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom: "",
SettingKeyOpenAIAdvancedSchedulerWeightUpstreamCost: "",
SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse: "",
SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky: "",
SettingKeyAllowUserViewErrorRequests: "false",
}
return s.settingRepo.SetMultiple(ctx, defaults)
}
// parseSettings 解析设置到结构体
func (s *SettingService) parseSettings(settings map[string]string) *SystemSettings {
emailVerifyEnabled := settings[SettingKeyEmailVerifyEnabled] == "true"
loginAgreementDocuments := parseLoginAgreementDocuments(settings[SettingKeyLoginAgreementDocuments])
loginAgreementUpdatedAt := strings.TrimSpace(settings[SettingKeyLoginAgreementUpdatedAt])
if loginAgreementUpdatedAt == "" {
loginAgreementUpdatedAt = defaultLoginAgreementDate
}
apiKeyACLTrustForwardedIP := false
if value, ok := settings[SettingKeyAPIKeyACLTrustForwardedIP]; ok {
apiKeyACLTrustForwardedIP = value == "true"
} else if s != nil && s.cfg != nil {
apiKeyACLTrustForwardedIP = s.cfg.Security.TrustForwardedIPForAPIKeyACL
}
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",
SessionBindingEnabled: settings[SettingKeySessionBindingEnabled] != "false", // 默认开启
AuditLogRetentionDays: parseAuditLogRetentionDays(settings[SettingKeyAuditLogRetentionDays]),
LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true",
LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]),
LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
LoginAgreementDocuments: loginAgreementDocuments,
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] != "",
APIKeyACLTrustForwardedIP: apiKeyACLTrustForwardedIP,
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",
}
result.TableDefaultPageSize, result.TablePageSizeOptions = parseTablePreferences(
settings[SettingKeyTableDefaultPageSize],
settings[SettingKeyTablePageSizeOptions],
)
// 解析整数类型
if port, err := strconv.Atoi(settings[SettingKeySMTPPort]); err == nil {
result.SMTPPort = port
} else {
result.SMTPPort = 587
}
if concurrency, err := strconv.Atoi(settings[SettingKeyDefaultConcurrency]); err == nil {
result.DefaultConcurrency = concurrency
} else {
result.DefaultConcurrency = s.cfg.Default.UserConcurrency
}
if rpm, err := strconv.Atoi(settings[SettingKeyDefaultUserRPMLimit]); err == nil && rpm >= 0 {
result.DefaultUserRPMLimit = rpm
}
// 解析浮点数类型
if balance, err := strconv.ParseFloat(settings[SettingKeyDefaultBalance], 64); err == nil {
result.DefaultBalance = balance
} else {
result.DefaultBalance = s.cfg.Default.UserBalance
}
if rebateRate, err := strconv.ParseFloat(settings[SettingKeyAffiliateRebateRate], 64); err == nil {
result.AffiliateRebateRate = clampAffiliateRebateRate(rebateRate)
} else {
result.AffiliateRebateRate = AffiliateRebateRateDefault
}
if freezeHours, err := strconv.Atoi(settings[SettingKeyAffiliateRebateFreezeHours]); err == nil && freezeHours >= 0 {
if freezeHours > AffiliateRebateFreezeHoursMax {
freezeHours = AffiliateRebateFreezeHoursMax
}
result.AffiliateRebateFreezeHours = freezeHours
}
if durationDays, err := strconv.Atoi(settings[SettingKeyAffiliateRebateDurationDays]); err == nil && durationDays >= 0 {
if durationDays > AffiliateRebateDurationDaysMax {
durationDays = AffiliateRebateDurationDaysMax
}
result.AffiliateRebateDurationDays = durationDays
}
if perInviteeCap, err := strconv.ParseFloat(settings[SettingKeyAffiliateRebatePerInviteeCap], 64); err == nil && perInviteeCap >= 0 {
result.AffiliateRebatePerInviteeCap = perInviteeCap
}
result.AdminRechargeRebateEnabled = settings[SettingKeyAffiliateAdminRechargeEnabled] == "true"
result.DefaultSubscriptions = parseDefaultSubscriptions(settings[SettingKeyDefaultSubscriptions])
// 敏感信息直接返回,方便测试连接时使用
result.SMTPPassword = settings[SettingKeySMTPPassword]
result.TurnstileSecretKey = settings[SettingKeyTurnstileSecretKey]
// 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 != ""
// DingTalk Connect 设置:
// - 兼容 config.yaml/env
// - 支持后台系统设置覆盖并持久化(存储于 DB)
dingTalkBase := config.DingTalkConnectConfig{}
if s.cfg != nil {
dingTalkBase = s.cfg.DingTalk
}
if raw, ok := settings[SettingKeyDingTalkConnectEnabled]; ok {
result.DingTalkConnectEnabled = raw == "true"
} else {
result.DingTalkConnectEnabled = dingTalkBase.Enabled
}
if v, ok := settings[SettingKeyDingTalkConnectClientID]; ok && strings.TrimSpace(v) != "" {
result.DingTalkConnectClientID = strings.TrimSpace(v)
} else {
result.DingTalkConnectClientID = dingTalkBase.ClientID
}
if v, ok := settings[SettingKeyDingTalkConnectRedirectURL]; ok && strings.TrimSpace(v) != "" {
result.DingTalkConnectRedirectURL = strings.TrimSpace(v)
} else {
result.DingTalkConnectRedirectURL = dingTalkBase.RedirectURL
}
result.DingTalkConnectClientSecret = strings.TrimSpace(settings[SettingKeyDingTalkConnectClientSecret])
if result.DingTalkConnectClientSecret == "" {
result.DingTalkConnectClientSecret = strings.TrimSpace(dingTalkBase.ClientSecret)
}
result.DingTalkConnectClientSecretConfigured = result.DingTalkConnectClientSecret != ""
if v, ok := settings[SettingKeyDingTalkConnectCorpRestrictionPolicy]; ok && strings.TrimSpace(v) != "" {
result.DingTalkConnectCorpRestrictionPolicy = strings.TrimSpace(v)
} else {
result.DingTalkConnectCorpRestrictionPolicy = dingTalkBase.CorpRestrictionPolicy
}
result.DingTalkConnectCorpRestrictionPolicy = coerceDeprecatedDingTalkCorpPolicy(result.DingTalkConnectCorpRestrictionPolicy)
if v, ok := settings[SettingKeyDingTalkConnectInternalCorpID]; ok && strings.TrimSpace(v) != "" {
result.DingTalkConnectInternalCorpID = strings.TrimSpace(v)
} else {
result.DingTalkConnectInternalCorpID = dingTalkBase.InternalCorpID
}
if v, ok := settings[SettingKeyDingTalkConnectBypassRegistration]; ok && strings.TrimSpace(v) != "" {
result.DingTalkConnectBypassRegistration = strings.EqualFold(strings.TrimSpace(v), "true")
} else {
result.DingTalkConnectBypassRegistration = dingTalkBase.BypassRegistration
}
// bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制 false,
// 以保证加载出的 effective config 永远是一致状态。
if result.DingTalkConnectCorpRestrictionPolicy != "internal_only" {
result.DingTalkConnectBypassRegistration = false
}
if v, ok := settings[SettingKeyDingTalkConnectSyncCorpEmail]; ok && strings.TrimSpace(v) != "" {
result.DingTalkConnectSyncCorpEmail = strings.EqualFold(strings.TrimSpace(v), "true")
} else {
result.DingTalkConnectSyncCorpEmail = dingTalkBase.SyncCorpEmail
}
if v, ok := settings[SettingKeyDingTalkConnectSyncDisplayName]; ok && strings.TrimSpace(v) != "" {
result.DingTalkConnectSyncDisplayName = strings.EqualFold(strings.TrimSpace(v), "true")
} else {
result.DingTalkConnectSyncDisplayName = dingTalkBase.SyncDisplayName
}
if v, ok := settings[SettingKeyDingTalkConnectSyncDept]; ok && strings.TrimSpace(v) != "" {
result.DingTalkConnectSyncDept = strings.EqualFold(strings.TrimSpace(v), "true")
} else {
result.DingTalkConnectSyncDept = dingTalkBase.SyncDept
}
// 身份同步三开关仅在 internal_only 模式下有意义;其它策略强制 false。
if result.DingTalkConnectCorpRestrictionPolicy != "internal_only" {
result.DingTalkConnectSyncCorpEmail = false
result.DingTalkConnectSyncDisplayName = false
result.DingTalkConnectSyncDept = false
}
// 身份同步目标 attr key(DB 空 → fallback 默认值)
result.DingTalkConnectSyncCorpEmailAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrKey])
if result.DingTalkConnectSyncCorpEmailAttrKey == "" {
if v := strings.TrimSpace(dingTalkBase.SyncCorpEmailAttrKey); v != "" {
result.DingTalkConnectSyncCorpEmailAttrKey = v
} else {
result.DingTalkConnectSyncCorpEmailAttrKey = "dingtalk_email"
}
}
result.DingTalkConnectSyncDisplayNameAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrKey])
if result.DingTalkConnectSyncDisplayNameAttrKey == "" {
if v := strings.TrimSpace(dingTalkBase.SyncDisplayNameAttrKey); v != "" {
result.DingTalkConnectSyncDisplayNameAttrKey = v
} else {
result.DingTalkConnectSyncDisplayNameAttrKey = "dingtalk_name"
}
}
result.DingTalkConnectSyncDeptAttrKey = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrKey])
if result.DingTalkConnectSyncDeptAttrKey == "" {
if v := strings.TrimSpace(dingTalkBase.SyncDeptAttrKey); v != "" {
result.DingTalkConnectSyncDeptAttrKey = v
} else {
result.DingTalkConnectSyncDeptAttrKey = "dingtalk_department"
}
}
// 身份同步目标 attr 显示名称(DB 空 → fallback 默认中文)
result.DingTalkConnectSyncCorpEmailAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncCorpEmailAttrName])
if result.DingTalkConnectSyncCorpEmailAttrName == "" {
if v := strings.TrimSpace(dingTalkBase.SyncCorpEmailAttrName); v != "" {
result.DingTalkConnectSyncCorpEmailAttrName = v
} else {
result.DingTalkConnectSyncCorpEmailAttrName = "钉钉企业邮箱"
}
}
result.DingTalkConnectSyncDisplayNameAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDisplayNameAttrName])
if result.DingTalkConnectSyncDisplayNameAttrName == "" {
if v := strings.TrimSpace(dingTalkBase.SyncDisplayNameAttrName); v != "" {
result.DingTalkConnectSyncDisplayNameAttrName = v
} else {
result.DingTalkConnectSyncDisplayNameAttrName = "钉钉姓名"
}
}
result.DingTalkConnectSyncDeptAttrName = strings.TrimSpace(settings[SettingKeyDingTalkConnectSyncDeptAttrName])
if result.DingTalkConnectSyncDeptAttrName == "" {
if v := strings.TrimSpace(dingTalkBase.SyncDeptAttrName); v != "" {
result.DingTalkConnectSyncDeptAttrName = v
} else {
result.DingTalkConnectSyncDeptAttrName = "钉钉部门"
}
}
// 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 = oidcUsePKCECompatibilityDefault(oidcBase)
}
if raw, ok := settings[SettingKeyOIDCConnectValidateIDToken]; ok {
result.OIDCConnectValidateIDToken = raw == "true"
} else {
result.OIDCConnectValidateIDToken = oidcValidateIDTokenCompatibilityDefault(oidcBase)
}
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 != ""
gitHubEffective := s.effectiveEmailOAuthConfig(settings, "github")
result.GitHubOAuthEnabled = gitHubEffective.Enabled
result.GitHubOAuthClientID = strings.TrimSpace(gitHubEffective.ClientID)
result.GitHubOAuthClientSecret = strings.TrimSpace(gitHubEffective.ClientSecret)
result.GitHubOAuthClientSecretConfigured = result.GitHubOAuthClientSecret != ""
result.GitHubOAuthRedirectURL = strings.TrimSpace(gitHubEffective.RedirectURL)
result.GitHubOAuthFrontendRedirectURL = strings.TrimSpace(gitHubEffective.FrontendRedirectURL)
googleEffective := s.effectiveEmailOAuthConfig(settings, "google")
result.GoogleOAuthEnabled = googleEffective.Enabled
result.GoogleOAuthClientID = strings.TrimSpace(googleEffective.ClientID)
result.GoogleOAuthClientSecret = strings.TrimSpace(googleEffective.ClientSecret)
result.GoogleOAuthClientSecretConfigured = result.GoogleOAuthClientSecret != ""
result.GoogleOAuthRedirectURL = strings.TrimSpace(googleEffective.RedirectURL)
result.GoogleOAuthFrontendRedirectURL = strings.TrimSpace(googleEffective.FrontendRedirectURL)
// WeChat Connect 设置:
// - 优先读取 DB 系统设置
// - 缺失时回退到 config/env,保持升级兼容
weChatEffective := s.effectiveWeChatConnectOAuthConfig(settings)
result.WeChatConnectEnabled = weChatEffective.Enabled
result.WeChatConnectAppID = weChatEffective.LegacyAppID
result.WeChatConnectAppSecret = weChatEffective.LegacyAppSecret
result.WeChatConnectAppSecretConfigured = weChatEffective.LegacyAppSecret != ""
result.WeChatConnectOpenAppID = weChatEffective.OpenAppID
result.WeChatConnectOpenAppSecret = weChatEffective.OpenAppSecret
result.WeChatConnectOpenAppSecretConfigured = weChatEffective.OpenAppSecret != ""
result.WeChatConnectMPAppID = weChatEffective.MPAppID
result.WeChatConnectMPAppSecret = weChatEffective.MPAppSecret
result.WeChatConnectMPAppSecretConfigured = weChatEffective.MPAppSecret != ""
result.WeChatConnectMobileAppID = weChatEffective.MobileAppID
result.WeChatConnectMobileAppSecret = weChatEffective.MobileAppSecret
result.WeChatConnectMobileAppSecretConfigured = weChatEffective.MobileAppSecret != ""
result.WeChatConnectOpenEnabled = weChatEffective.OpenEnabled
result.WeChatConnectMPEnabled = weChatEffective.MPEnabled
result.WeChatConnectMobileEnabled = weChatEffective.MobileEnabled
result.WeChatConnectMode = weChatEffective.Mode
result.WeChatConnectScopes = weChatEffective.Scopes
result.WeChatConnectRedirectURL = weChatEffective.RedirectURL
result.WeChatConnectFrontendRedirectURL = weChatEffective.FrontendRedirectURL
// 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
}
}
// Channel monitor feature (default: enabled, 60s)
result.ChannelMonitorEnabled = !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled])
result.ChannelMonitorDefaultIntervalSeconds = parseChannelMonitorInterval(
settings[SettingKeyChannelMonitorDefaultIntervalSeconds],
)
// Available channels feature (default: disabled; strict true)
result.AvailableChannelsEnabled = settings[SettingKeyAvailableChannelsEnabled] == "true"
// Affiliate (邀请返利) feature (default: disabled; strict true)
result.AffiliateEnabled = settings[SettingKeyAffiliateEnabled] == "true"
// 风控中心功能(默认关闭,严格 true 才启用)
result.RiskControlEnabled = settings[SettingKeyRiskControlEnabled] == "true"
// cyber 会话屏蔽(默认关闭,TTL 默认 3600s)
result.CyberSessionBlockEnabled = settings[SettingKeyCyberSessionBlockEnabled] == "true"
if v, err := strconv.Atoi(strings.TrimSpace(settings[SettingKeyCyberSessionBlockTTLSeconds])); err == nil && v > 0 {
result.CyberSessionBlockTTLSeconds = v
} else {
result.CyberSessionBlockTTLSeconds = 3600
}
// 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, claude_oauth_system_prompt_injection=true)
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"
if v, ok := settings[SettingKeyEnableClaudeOAuthSystemPromptInjection]; ok && v != "" {
result.EnableClaudeOAuthSystemPromptInjection = v == "true"
} else {
result.EnableClaudeOAuthSystemPromptInjection = true
}
result.ClaudeOAuthSystemPrompt = settings[SettingKeyClaudeOAuthSystemPrompt]
result.ClaudeOAuthSystemPromptBlocks = settings[SettingKeyClaudeOAuthSystemPromptBlocks]
result.EnableAnthropicCacheTTL1hInjection = settings[SettingKeyEnableAnthropicCacheTTL1hInjection] == "true"
if v, ok := settings[SettingKeyRewriteMessageCacheControl]; ok && v != "" {
result.RewriteMessageCacheControl = v == "true"
} else {
result.RewriteMessageCacheControl = s.defaultRewriteMessageCacheControl()
}
if v, ok := settings[SettingKeyEnableClientDatelineNormalization]; ok && v != "" {
result.EnableClientDatelineNormalization = v == "true"
} else {
result.EnableClientDatelineNormalization = true
}
result.AntigravityUserAgentVersion = antigravity.NormalizeUserAgentVersion(settings[SettingKeyAntigravityUserAgentVersion])
result.OpenAICodexUserAgent = strings.TrimSpace(settings[SettingKeyOpenAICodexUserAgent])
// codex_cli_only 加固
result.MinCodexVersion = settings[SettingKeyMinCodexVersion]
result.MaxCodexVersion = settings[SettingKeyMaxCodexVersion]
result.CodexCLIOnlyBlacklist = settings[SettingKeyCodexCLIOnlyBlacklist]
result.CodexCLIOnlyWhitelist = settings[SettingKeyCodexCLIOnlyWhitelist]
result.CodexCLIOnlyAllowAppServerClients = settings[SettingKeyCodexCLIOnlyAllowAppServerClients] == "true"
if raw := strings.TrimSpace(settings[SettingKeyCodexCLIOnlyEngineFingerprintSignals]); raw != "" {
result.CodexCLIOnlyEngineFingerprintSignals = raw
} else {
result.CodexCLIOnlyEngineFingerprintSignals = openai.DefaultEngineFingerprintSignalsJSON() // 缺失/空 → 展示默认种子
}
// Web search emulation: quick enabled check from the JSON config
if raw := settings[SettingKeyWebSearchEmulationConfig]; raw != "" {
var wsCfg WebSearchEmulationConfig
if err := json.Unmarshal([]byte(raw), &wsCfg); err == nil {
result.WebSearchEmulationEnabled = wsCfg.Enabled && len(wsCfg.Providers) > 0
}
}
result.PaymentVisibleMethodAlipaySource = NormalizeVisibleMethodSource("alipay", settings[SettingPaymentVisibleMethodAlipaySource])
result.PaymentVisibleMethodWxpaySource = NormalizeVisibleMethodSource("wxpay", settings[SettingPaymentVisibleMethodWxpaySource])
result.PaymentVisibleMethodAlipayEnabled = settings[SettingPaymentVisibleMethodAlipayEnabled] == "true"
result.PaymentVisibleMethodWxpayEnabled = settings[SettingPaymentVisibleMethodWxpayEnabled] == "true"
result.OpenAILowUpstreamRatePriorityEnabled = settings[SettingKeyOpenAILowUpstreamRatePriorityEnabled] == "true"
result.OpenAIOAuthSchedulingRateMultiplier = parseOpenAIOAuthSchedulingRateMultiplier(settings[SettingKeyOpenAIOAuthSchedulingRateMultiplier])
result.OpenAIAdvancedSchedulerEnabled = settings[openAIAdvancedSchedulerSettingKey] == "true"
result.OpenAIAdvancedSchedulerStickyWeightedEnabled = settings[SettingKeyOpenAIAdvancedSchedulerStickyWeightedEnabled] == "true"
result.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled = settings[SettingKeyOpenAIAdvancedSchedulerSubscriptionPriorityEnabled] == "true"
result.OpenAIAdvancedSchedulerLBTopK = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerLBTopK])
result.OpenAIAdvancedSchedulerWeightPriority = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightPriority])
result.OpenAIAdvancedSchedulerWeightLoad = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightLoad])
result.OpenAIAdvancedSchedulerWeightQueue = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightQueue])
result.OpenAIAdvancedSchedulerWeightErrorRate = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightErrorRate])
result.OpenAIAdvancedSchedulerWeightTTFT = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightTTFT])
result.OpenAIAdvancedSchedulerWeightReset = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightReset])
result.OpenAIAdvancedSchedulerWeightQuotaHeadroom = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightQuotaHeadroom])
result.OpenAIAdvancedSchedulerWeightUpstreamCost = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightUpstreamCost])
result.OpenAIAdvancedSchedulerWeightPreviousResponse = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightPreviousResponse])
result.OpenAIAdvancedSchedulerWeightSessionSticky = strings.TrimSpace(settings[SettingKeyOpenAIAdvancedSchedulerWeightSessionSticky])
result.OpenAIAdvancedSchedulerEffectiveLBTopK = s.openAIAdvancedSchedulerEffectiveLBTopK()
effectiveWeights := s.openAIAdvancedSchedulerEffectiveWeights()
result.OpenAIAdvancedSchedulerEffectiveWeightPriority = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Priority)
result.OpenAIAdvancedSchedulerEffectiveWeightLoad = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Load)
result.OpenAIAdvancedSchedulerEffectiveWeightQueue = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Queue)
result.OpenAIAdvancedSchedulerEffectiveWeightErrorRate = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.ErrorRate)
result.OpenAIAdvancedSchedulerEffectiveWeightTTFT = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.TTFT)
result.OpenAIAdvancedSchedulerEffectiveWeightReset = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.Reset)
result.OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.QuotaHeadroom)
result.OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.UpstreamCost)
result.OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.PreviousResponse)
result.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky = formatOpenAIAdvancedSchedulerFloat(effectiveWeights.SessionSticky)
// 余额、订阅到期与账号限额通知
result.BalanceLowNotifyEnabled = settings[SettingKeyBalanceLowNotifyEnabled] == "true"
if v, err := strconv.ParseFloat(settings[SettingKeyBalanceLowNotifyThreshold], 64); err == nil && v >= 0 {
result.BalanceLowNotifyThreshold = v
}
result.BalanceLowNotifyRechargeURL = settings[SettingKeyBalanceLowNotifyRechargeURL]
result.SubscriptionExpiryNotifyEnabled = !isFalseSettingValue(settings[SettingKeySubscriptionExpiryNotifyEnabled])
// 账号限额通知
result.AccountQuotaNotifyEnabled = settings[SettingKeyAccountQuotaNotifyEnabled] == "true"
if raw := strings.TrimSpace(settings[SettingKeyAccountQuotaNotifyEmails]); raw != "" {
result.AccountQuotaNotifyEmails = ParseNotifyEmails(raw)
}
if result.AccountQuotaNotifyEmails == nil {
result.AccountQuotaNotifyEmails = []NotifyEmailEntry{}
}
// 系统层默认 platform quota(修复 Bug B:parseSettings 不填充导致回显恒为 nil)
if raw := settings[SettingKeyDefaultPlatformQuotas]; raw != "" {
parsed := map[string]*DefaultPlatformQuotaSetting{}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
slog.Warn("[Setting] parseSettings: unmarshal default_platform_quotas failed", "error", err)
} else {
result.DefaultPlatformQuotas = parsed
}
}
result.AllowUserViewErrorRequests = settings[SettingKeyAllowUserViewErrorRequests] == "true" // default false
return result
}
func clampAffiliateRebateRate(value float64) float64 {
if math.IsNaN(value) || math.IsInf(value, 0) {
return AffiliateRebateRateDefault
}
if value < AffiliateRebateRateMin {
return AffiliateRebateRateMin
}
if value > AffiliateRebateRateMax {
return AffiliateRebateRateMax
}
return value
}
func isFalseSettingValue(value string) bool {
switch strings.ToLower(strings.TrimSpace(value)) {
case "false", "0", "off", "disabled":
return true
default:
return false
}
}
func normalizeVisibleMethodSettingSource(method, source string, enabled bool) (string, error) {
_ = enabled
source = strings.TrimSpace(source)
if source == "" {
return "", nil
}
normalized := NormalizeVisibleMethodSource(method, source)
if normalized == "" {
return "", infraerrors.BadRequest(
"INVALID_PAYMENT_VISIBLE_METHOD_SOURCE",
fmt.Sprintf("%s source must be one of the supported payment providers", method),
)
}
return normalized, nil
}
func (s *SettingService) openAIAdvancedSchedulerEffectiveLBTopK() string {
if s != nil && s.cfg != nil && s.cfg.Gateway.OpenAIWS.LBTopK > 0 {
return strconv.Itoa(s.cfg.Gateway.OpenAIWS.LBTopK)
}
return "7"
}
func (s *SettingService) openAIAdvancedSchedulerEffectiveWeights() config.GatewayOpenAIWSSchedulerScoreWeights {
defaults := config.GatewayOpenAIWSSchedulerScoreWeights{
Priority: 1.0,
Load: 1.0,
Queue: 0.7,
ErrorRate: 0.8,
TTFT: 0.5,
Reset: 0.0,
QuotaHeadroom: 0.0,
UpstreamCost: 0.0,
PreviousResponse: 5.0,
SessionSticky: 3.0,
}
if s == nil || s.cfg == nil {
return defaults
}
weights := s.cfg.Gateway.OpenAIWS.SchedulerScoreWeights
if !weights.IsValid() {
return defaults
}
return weights
}
func formatOpenAIAdvancedSchedulerFloat(value float64) string {
return strconv.FormatFloat(value, 'f', -1, 64)
}
func (s *SettingService) normalizeOpenAIAdvancedSchedulerOverrides(settings *SystemSettings) error {
if rate := settings.OpenAIOAuthSchedulingRateMultiplier; rate < 0 || math.IsNaN(rate) || math.IsInf(rate, 0) {
return infraerrors.BadRequest("INVALID_OPENAI_OAUTH_SCHEDULING_RATE_MULTIPLIER", "OpenAI OAuth scheduling rate multiplier must be a finite non-negative number")
}
lbTopK, err := normalizeOptionalPositiveIntString(settings.OpenAIAdvancedSchedulerLBTopK)
if err != nil {
return infraerrors.BadRequest("INVALID_OPENAI_ADVANCED_SCHEDULER_LB_TOP_K", "openai advanced scheduler TopK must be a positive integer or empty")
}
settings.OpenAIAdvancedSchedulerLBTopK = lbTopK
weights := []*string{
&settings.OpenAIAdvancedSchedulerWeightPriority,
&settings.OpenAIAdvancedSchedulerWeightLoad,
&settings.OpenAIAdvancedSchedulerWeightQueue,
&settings.OpenAIAdvancedSchedulerWeightErrorRate,
&settings.OpenAIAdvancedSchedulerWeightTTFT,
&settings.OpenAIAdvancedSchedulerWeightReset,
&settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom,
&settings.OpenAIAdvancedSchedulerWeightUpstreamCost,
&settings.OpenAIAdvancedSchedulerWeightPreviousResponse,
&settings.OpenAIAdvancedSchedulerWeightSessionSticky,
}
for _, target := range weights {
normalized, err := normalizeOptionalNonNegativeFloatString(*target)
if err != nil {
return infraerrors.BadRequest("INVALID_OPENAI_ADVANCED_SCHEDULER_WEIGHT", "openai advanced scheduler weights must be non-negative numbers or empty")
}
*target = normalized
}
// 与 config.Validate 的 "scheduler_score_weights must not all be zero" 保持一致:
// 覆盖值(空则回退到生效的配置值)叠加后的基础权重和不允许为 0,
// 否则调度会静默退化为 TopK 内均匀随机。
effective := s.openAIAdvancedSchedulerEffectiveWeights()
resolved := config.GatewayOpenAIWSSchedulerScoreWeights{
Priority: resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightPriority, effective.Priority),
Load: resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightLoad, effective.Load),
Queue: resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightQueue, effective.Queue),
ErrorRate: resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightErrorRate, effective.ErrorRate),
TTFT: resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightTTFT, effective.TTFT),
Reset: resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightReset, effective.Reset),
QuotaHeadroom: resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightQuotaHeadroom, effective.QuotaHeadroom),
UpstreamCost: resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightUpstreamCost, effective.UpstreamCost),
PreviousResponse: resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightPreviousResponse, effective.PreviousResponse),
SessionSticky: resolveOpenAIAdvancedSchedulerWeight(settings.OpenAIAdvancedSchedulerWeightSessionSticky, effective.SessionSticky),
}
if !resolved.IsValid() {
return infraerrors.BadRequest("INVALID_OPENAI_ADVANCED_SCHEDULER_WEIGHT", "openai advanced scheduler weights must have finite non-zero base and total sums")
}
return nil
}
func parseOpenAIOAuthSchedulingRateMultiplier(raw string) float64 {
value, err := strconv.ParseFloat(strings.TrimSpace(raw), 64)
if err != nil || value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
return defaultOpenAIOAuthSchedulingRateMultiplier
}
return value
}
// resolveOpenAIAdvancedSchedulerWeight 返回覆盖值(已归一化的非空字符串),空则回退默认值。
func resolveOpenAIAdvancedSchedulerWeight(normalized string, fallback float64) float64 {
if normalized == "" {
return fallback
}
value, err := strconv.ParseFloat(normalized, 64)
if err != nil {
return fallback
}
return value
}
func normalizeOptionalPositiveIntString(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
value, err := strconv.Atoi(raw)
if err != nil || value <= 0 {
return "", fmt.Errorf("invalid positive integer")
}
return strconv.Itoa(value), nil
}
func normalizeOptionalNonNegativeFloatString(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
value, err := strconv.ParseFloat(raw, 64)
if err != nil || value < 0 || math.IsNaN(value) || math.IsInf(value, 0) {
return "", fmt.Errorf("invalid non-negative float")
}
return strconv.FormatFloat(value, 'f', -1, 64), nil
}
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
}
func parseProviderDefaultGrantSettings(settings map[string]string, keys authSourceDefaultKeySet) ProviderDefaultGrantSettings {
result := ProviderDefaultGrantSettings{
Balance: defaultAuthSourceBalance,
Concurrency: defaultAuthSourceConcurrency,
Subscriptions: []DefaultSubscriptionSetting{},
GrantOnSignup: false,
GrantOnFirstBind: false,
}
if v, err := strconv.ParseFloat(strings.TrimSpace(settings[keys.balance]), 64); err == nil {
result.Balance = v
}
if v, err := strconv.Atoi(strings.TrimSpace(settings[keys.concurrency])); err == nil {
result.Concurrency = v
}
if items := parseDefaultSubscriptions(settings[keys.subscriptions]); items != nil {
result.Subscriptions = items
}
if raw, ok := settings[keys.grantOnSignup]; ok {
result.GrantOnSignup = raw == "true"
}
if raw, ok := settings[keys.grantOnFirstBind]; ok {
result.GrantOnFirstBind = raw == "true"
}
if raw := settings[keys.platformQuotas]; raw != "" {
parsed := map[string]*DefaultPlatformQuotaSetting{}
if err := json.Unmarshal([]byte(raw), &parsed); err != nil {
slog.Warn("[Setting] parseProviderDefaultGrantSettings: unmarshal auth source platform quotas failed", "source", keys.source, "error", err)
} else {
result.PlatformQuotas = parsed
}
}
return result
}
func writeProviderDefaultGrantUpdates(updates map[string]string, keys authSourceDefaultKeySet, settings ProviderDefaultGrantSettings) {
updates[keys.balance] = strconv.FormatFloat(settings.Balance, 'f', 8, 64)
updates[keys.concurrency] = strconv.Itoa(settings.Concurrency)
subscriptions := settings.Subscriptions
if subscriptions == nil {
subscriptions = []DefaultSubscriptionSetting{}
}
raw, err := json.Marshal(subscriptions)
if err != nil {
raw = []byte("[]")
}
updates[keys.subscriptions] = string(raw)
updates[keys.grantOnSignup] = strconv.FormatBool(settings.GrantOnSignup)
updates[keys.grantOnFirstBind] = strconv.FormatBool(settings.GrantOnFirstBind)
// auth source platform quota:整体替换语义。
// nil = 请求未携带该字段,跳过写入以保留既有配置(与系统层 buildSystemSettingsUpdates 的
// DefaultPlatformQuotas nil 守卫一致);非 nil(含空 map)才整体替换。二者语义不可混同。
if keys.platformQuotas != "" && settings.PlatformQuotas != nil {
blob, err := json.Marshal(settings.PlatformQuotas)
if err != nil {
blob = []byte("{}")
}
updates[keys.platformQuotas] = string(blob)
}
}
func mergeProviderDefaultGrantSettings(globalDefaults ProviderDefaultGrantSettings, providerDefaults ProviderDefaultGrantSettings) ProviderDefaultGrantSettings {
result := ProviderDefaultGrantSettings{
Balance: globalDefaults.Balance,
Concurrency: globalDefaults.Concurrency,
Subscriptions: append([]DefaultSubscriptionSetting(nil), globalDefaults.Subscriptions...),
GrantOnSignup: providerDefaults.GrantOnSignup,
GrantOnFirstBind: providerDefaults.GrantOnFirstBind,
}
// 注意:不能把 parse 默认值 (defaultAuthSourceBalance / defaultAuthSourceConcurrency)
// 当作"未配置"哨兵——admin 完全有权显式设成相同的值,那时仍应覆盖 globalDefaults。
// 旧实现的 `!= defaultAuthSourceConcurrency` 会把 admin 设的 5 与 fallback 5 混淆,
// 导致渠道发放退回到全局默认(如 1),表现为"管理员设 5、新用户实际拿 1"。
if providerDefaults.Balance >= 0 {
result.Balance = providerDefaults.Balance
}
if providerDefaults.Concurrency > 0 {
result.Concurrency = providerDefaults.Concurrency
}
if len(providerDefaults.Subscriptions) > 0 {
result.Subscriptions = append([]DefaultSubscriptionSetting(nil), providerDefaults.Subscriptions...)
}
return result
}
func parseTablePreferences(defaultPageSizeRaw, optionsRaw string) (int, []int) {
defaultPageSize := 20
if v, err := strconv.Atoi(strings.TrimSpace(defaultPageSizeRaw)); err == nil {
defaultPageSize = v
}
var options []int
if strings.TrimSpace(optionsRaw) != "" {
_ = json.Unmarshal([]byte(optionsRaw), &options)
}
return normalizeTablePreferences(defaultPageSize, options)
}
func normalizeTablePreferences(defaultPageSize int, options []int) (int, []int) {
const minPageSize = 5
const maxPageSize = 1000
const fallbackPageSize = 20
seen := make(map[int]struct{}, len(options))
normalizedOptions := make([]int, 0, len(options))
for _, option := range options {
if option < minPageSize || option > maxPageSize {
continue
}
if _, ok := seen[option]; ok {
continue
}
seen[option] = struct{}{}
normalizedOptions = append(normalizedOptions, option)
}
sort.Ints(normalizedOptions)
if defaultPageSize < minPageSize || defaultPageSize > maxPageSize {
defaultPageSize = fallbackPageSize
}
if len(normalizedOptions) == 0 {
normalizedOptions = []int{10, 20, 50}
}
return defaultPageSize, normalizedOptions
}