Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
2467 lines
136 KiB
Go
2467 lines
136 KiB
Go
package admin
|
||
|
||
import (
|
||
"context"
|
||
"encoding/json"
|
||
"errors"
|
||
"log/slog"
|
||
"net/http"
|
||
"reflect"
|
||
"strconv"
|
||
"strings"
|
||
|
||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||
|
||
"github.com/gin-gonic/gin"
|
||
"github.com/gin-gonic/gin/binding"
|
||
)
|
||
|
||
// UpdateSettingsRequest 更新设置请求
|
||
type UpdateSettingsRequest struct {
|
||
// 注册设置
|
||
RegistrationEnabled bool `json:"registration_enabled"`
|
||
EmailVerifyEnabled bool `json:"email_verify_enabled"`
|
||
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
|
||
RegistrationEmailDomainQuotaEnabled *bool `json:"registration_email_domain_quota_enabled"` // 非白名单域名限量注册开关(省略=保持现值)
|
||
PromoCodeEnabled bool `json:"promo_code_enabled"`
|
||
PasswordResetEnabled bool `json:"password_reset_enabled"`
|
||
FrontendURL string `json:"frontend_url"`
|
||
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
|
||
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
|
||
PasskeyEnabled *bool `json:"passkey_enabled"` // Passkey 登录(省略=保持现值)
|
||
SessionBindingEnabled *bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定(省略=保持现值)
|
||
StepUpEnabled *bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA(省略=保持现值)
|
||
AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数
|
||
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
|
||
LoginAgreementMode string `json:"login_agreement_mode"`
|
||
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
|
||
LoginAgreementDocuments []dto.LoginAgreementDocument `json:"login_agreement_documents"`
|
||
|
||
// 邮件服务设置
|
||
SMTPHost string `json:"smtp_host"`
|
||
SMTPPort int `json:"smtp_port"`
|
||
SMTPUsername string `json:"smtp_username"`
|
||
SMTPPassword string `json:"smtp_password"`
|
||
SMTPFrom string `json:"smtp_from_email"`
|
||
SMTPFromName string `json:"smtp_from_name"`
|
||
SMTPUseTLS bool `json:"smtp_use_tls"`
|
||
|
||
// Cloudflare Turnstile 设置
|
||
TurnstileEnabled bool `json:"turnstile_enabled"`
|
||
TurnstileSiteKey string `json:"turnstile_site_key"`
|
||
TurnstileSecretKey string `json:"turnstile_secret_key"`
|
||
|
||
// 腾讯天御验证码设置
|
||
TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
|
||
TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
|
||
TencentCaptchaAppSecretKey string `json:"tencent_captcha_app_secret_key"`
|
||
TencentCaptchaCloudSecretID string `json:"tencent_captcha_cloud_secret_id"`
|
||
TencentCaptchaCloudSecretKey string `json:"tencent_captcha_cloud_secret_key"`
|
||
TencentCaptchaRegion string `json:"tencent_captcha_region"`
|
||
|
||
// 阿里云验证码 2.0 设置
|
||
AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
|
||
AliyunCaptchaAccessKeyID string `json:"aliyun_captcha_access_key_id"`
|
||
AliyunCaptchaAccessKeySecret string `json:"aliyun_captcha_access_key_secret"`
|
||
AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"`
|
||
AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"`
|
||
AliyunCaptchaRegion string `json:"aliyun_captcha_region"`
|
||
|
||
// API Key IP 访问控制设置
|
||
APIKeyACLTrustForwardedIP *bool `json:"api_key_acl_trust_forwarded_ip"`
|
||
ForwardedClientIPHeaders *[]string `json:"forwarded_client_ip_headers"`
|
||
|
||
// LinuxDo Connect OAuth 登录
|
||
LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"`
|
||
LinuxDoConnectClientID string `json:"linuxdo_connect_client_id"`
|
||
LinuxDoConnectClientSecret string `json:"linuxdo_connect_client_secret"`
|
||
LinuxDoConnectRedirectURL string `json:"linuxdo_connect_redirect_url"`
|
||
|
||
// DingTalk Connect OAuth 登录
|
||
DingTalkConnectEnabled bool `json:"dingtalk_connect_enabled"`
|
||
DingTalkConnectClientID string `json:"dingtalk_connect_client_id"`
|
||
DingTalkConnectClientSecret string `json:"dingtalk_connect_client_secret"`
|
||
DingTalkConnectRedirectURL string `json:"dingtalk_connect_redirect_url"`
|
||
DingTalkConnectCorpRestrictionPolicy string `json:"dingtalk_connect_corp_restriction_policy"`
|
||
DingTalkConnectInternalCorpID string `json:"dingtalk_connect_internal_corp_id"`
|
||
DingTalkConnectBypassRegistration bool `json:"dingtalk_connect_bypass_registration"`
|
||
DingTalkConnectSyncCorpEmail bool `json:"dingtalk_connect_sync_corp_email"`
|
||
DingTalkConnectSyncDisplayName bool `json:"dingtalk_connect_sync_display_name"`
|
||
DingTalkConnectSyncDept bool `json:"dingtalk_connect_sync_dept"`
|
||
DingTalkConnectSyncCorpEmailAttrKey string `json:"dingtalk_connect_sync_corp_email_attr_key"`
|
||
DingTalkConnectSyncDisplayNameAttrKey string `json:"dingtalk_connect_sync_display_name_attr_key"`
|
||
DingTalkConnectSyncDeptAttrKey string `json:"dingtalk_connect_sync_dept_attr_key"`
|
||
DingTalkConnectSyncCorpEmailAttrName string `json:"dingtalk_connect_sync_corp_email_attr_name"`
|
||
DingTalkConnectSyncDisplayNameAttrName string `json:"dingtalk_connect_sync_display_name_attr_name"`
|
||
DingTalkConnectSyncDeptAttrName string `json:"dingtalk_connect_sync_dept_attr_name"`
|
||
|
||
// WeChat Connect OAuth 登录
|
||
WeChatConnectEnabled bool `json:"wechat_connect_enabled"`
|
||
WeChatConnectAppID string `json:"wechat_connect_app_id"`
|
||
WeChatConnectAppSecret string `json:"wechat_connect_app_secret"`
|
||
WeChatConnectOpenAppID string `json:"wechat_connect_open_app_id"`
|
||
WeChatConnectOpenAppSecret string `json:"wechat_connect_open_app_secret"`
|
||
WeChatConnectMPAppID string `json:"wechat_connect_mp_app_id"`
|
||
WeChatConnectMPAppSecret string `json:"wechat_connect_mp_app_secret"`
|
||
WeChatConnectMobileAppID string `json:"wechat_connect_mobile_app_id"`
|
||
WeChatConnectMobileAppSecret string `json:"wechat_connect_mobile_app_secret"`
|
||
WeChatConnectOpenEnabled bool `json:"wechat_connect_open_enabled"`
|
||
WeChatConnectMPEnabled bool `json:"wechat_connect_mp_enabled"`
|
||
WeChatConnectMobileEnabled bool `json:"wechat_connect_mobile_enabled"`
|
||
WeChatConnectMode string `json:"wechat_connect_mode"`
|
||
WeChatConnectScopes string `json:"wechat_connect_scopes"`
|
||
WeChatConnectRedirectURL string `json:"wechat_connect_redirect_url"`
|
||
WeChatConnectFrontendRedirectURL string `json:"wechat_connect_frontend_redirect_url"`
|
||
|
||
// Generic OIDC OAuth 登录
|
||
OIDCConnectEnabled bool `json:"oidc_connect_enabled"`
|
||
OIDCConnectProviderName string `json:"oidc_connect_provider_name"`
|
||
OIDCConnectClientID string `json:"oidc_connect_client_id"`
|
||
OIDCConnectClientSecret string `json:"oidc_connect_client_secret"`
|
||
OIDCConnectIssuerURL string `json:"oidc_connect_issuer_url"`
|
||
OIDCConnectDiscoveryURL string `json:"oidc_connect_discovery_url"`
|
||
OIDCConnectAuthorizeURL string `json:"oidc_connect_authorize_url"`
|
||
OIDCConnectTokenURL string `json:"oidc_connect_token_url"`
|
||
OIDCConnectUserInfoURL string `json:"oidc_connect_userinfo_url"`
|
||
OIDCConnectJWKSURL string `json:"oidc_connect_jwks_url"`
|
||
OIDCConnectScopes string `json:"oidc_connect_scopes"`
|
||
OIDCConnectRedirectURL string `json:"oidc_connect_redirect_url"`
|
||
OIDCConnectFrontendRedirectURL string `json:"oidc_connect_frontend_redirect_url"`
|
||
OIDCConnectTokenAuthMethod string `json:"oidc_connect_token_auth_method"`
|
||
OIDCConnectUsePKCE *bool `json:"oidc_connect_use_pkce"`
|
||
OIDCConnectValidateIDToken *bool `json:"oidc_connect_validate_id_token"`
|
||
OIDCConnectAllowedSigningAlgs string `json:"oidc_connect_allowed_signing_algs"`
|
||
OIDCConnectClockSkewSeconds int `json:"oidc_connect_clock_skew_seconds"`
|
||
OIDCConnectRequireEmailVerified bool `json:"oidc_connect_require_email_verified"`
|
||
OIDCConnectUserInfoEmailPath string `json:"oidc_connect_userinfo_email_path"`
|
||
OIDCConnectUserInfoIDPath string `json:"oidc_connect_userinfo_id_path"`
|
||
OIDCConnectUserInfoUsernamePath string `json:"oidc_connect_userinfo_username_path"`
|
||
|
||
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
|
||
GitHubOAuthClientID string `json:"github_oauth_client_id"`
|
||
GitHubOAuthClientSecret string `json:"github_oauth_client_secret"`
|
||
GitHubOAuthRedirectURL string `json:"github_oauth_redirect_url"`
|
||
GitHubOAuthFrontendRedirectURL string `json:"github_oauth_frontend_redirect_url"`
|
||
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
|
||
GoogleOAuthClientID string `json:"google_oauth_client_id"`
|
||
GoogleOAuthClientSecret string `json:"google_oauth_client_secret"`
|
||
GoogleOAuthRedirectURL string `json:"google_oauth_redirect_url"`
|
||
GoogleOAuthFrontendRedirectURL string `json:"google_oauth_frontend_redirect_url"`
|
||
|
||
// OEM设置
|
||
SiteName string `json:"site_name"`
|
||
SiteLogo string `json:"site_logo"`
|
||
SiteSubtitle string `json:"site_subtitle"`
|
||
APIBaseURL string `json:"api_base_url"`
|
||
ContactInfo string `json:"contact_info"`
|
||
DocURL string `json:"doc_url"`
|
||
HomeContent string `json:"home_content"`
|
||
CompactHomeEnabled bool `json:"compact_home_enabled"`
|
||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||
PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"`
|
||
PurchaseSubscriptionURL *string `json:"purchase_subscription_url"`
|
||
TableDefaultPageSize int `json:"table_default_page_size"`
|
||
TablePageSizeOptions []int `json:"table_page_size_options"`
|
||
CustomMenuItems *[]dto.CustomMenuItem `json:"custom_menu_items"`
|
||
CustomEndpoints *[]dto.CustomEndpoint `json:"custom_endpoints"`
|
||
|
||
// 默认配置
|
||
DefaultConcurrency int `json:"default_concurrency"`
|
||
DefaultBalance float64 `json:"default_balance"`
|
||
AffiliateRebateRate *float64 `json:"affiliate_rebate_rate"`
|
||
AffiliateRebateFreezeHours *int `json:"affiliate_rebate_freeze_hours"`
|
||
AffiliateRebateDurationDays *int `json:"affiliate_rebate_duration_days"`
|
||
AffiliateRebatePerInviteeCap *float64 `json:"affiliate_rebate_per_invitee_cap"`
|
||
AdminRechargeRebateEnabled *bool `json:"affiliate_admin_recharge_enabled"`
|
||
DefaultUserRPMLimit int `json:"default_user_rpm_limit"`
|
||
DefaultSubscriptions []dto.DefaultSubscriptionSetting `json:"default_subscriptions"`
|
||
AuthSourceDefaultEmailBalance *float64 `json:"auth_source_default_email_balance"`
|
||
AuthSourceDefaultEmailConcurrency *int `json:"auth_source_default_email_concurrency"`
|
||
AuthSourceDefaultEmailSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_email_subscriptions"`
|
||
AuthSourceDefaultEmailGrantOnSignup *bool `json:"auth_source_default_email_grant_on_signup"`
|
||
AuthSourceDefaultEmailGrantOnFirstBind *bool `json:"auth_source_default_email_grant_on_first_bind"`
|
||
AuthSourceDefaultLinuxDoBalance *float64 `json:"auth_source_default_linuxdo_balance"`
|
||
AuthSourceDefaultLinuxDoConcurrency *int `json:"auth_source_default_linuxdo_concurrency"`
|
||
AuthSourceDefaultLinuxDoSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_linuxdo_subscriptions"`
|
||
AuthSourceDefaultLinuxDoGrantOnSignup *bool `json:"auth_source_default_linuxdo_grant_on_signup"`
|
||
AuthSourceDefaultLinuxDoGrantOnFirstBind *bool `json:"auth_source_default_linuxdo_grant_on_first_bind"`
|
||
AuthSourceDefaultOIDCBalance *float64 `json:"auth_source_default_oidc_balance"`
|
||
AuthSourceDefaultOIDCConcurrency *int `json:"auth_source_default_oidc_concurrency"`
|
||
AuthSourceDefaultOIDCSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_oidc_subscriptions"`
|
||
AuthSourceDefaultOIDCGrantOnSignup *bool `json:"auth_source_default_oidc_grant_on_signup"`
|
||
AuthSourceDefaultOIDCGrantOnFirstBind *bool `json:"auth_source_default_oidc_grant_on_first_bind"`
|
||
AuthSourceDefaultWeChatBalance *float64 `json:"auth_source_default_wechat_balance"`
|
||
AuthSourceDefaultWeChatConcurrency *int `json:"auth_source_default_wechat_concurrency"`
|
||
AuthSourceDefaultWeChatSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_wechat_subscriptions"`
|
||
AuthSourceDefaultWeChatGrantOnSignup *bool `json:"auth_source_default_wechat_grant_on_signup"`
|
||
AuthSourceDefaultWeChatGrantOnFirstBind *bool `json:"auth_source_default_wechat_grant_on_first_bind"`
|
||
AuthSourceDefaultGitHubBalance *float64 `json:"auth_source_default_github_balance"`
|
||
AuthSourceDefaultGitHubConcurrency *int `json:"auth_source_default_github_concurrency"`
|
||
AuthSourceDefaultGitHubSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_github_subscriptions"`
|
||
AuthSourceDefaultGitHubGrantOnSignup *bool `json:"auth_source_default_github_grant_on_signup"`
|
||
AuthSourceDefaultGitHubGrantOnFirstBind *bool `json:"auth_source_default_github_grant_on_first_bind"`
|
||
AuthSourceDefaultGoogleBalance *float64 `json:"auth_source_default_google_balance"`
|
||
AuthSourceDefaultGoogleConcurrency *int `json:"auth_source_default_google_concurrency"`
|
||
AuthSourceDefaultGoogleSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_google_subscriptions"`
|
||
AuthSourceDefaultGoogleGrantOnSignup *bool `json:"auth_source_default_google_grant_on_signup"`
|
||
AuthSourceDefaultGoogleGrantOnFirstBind *bool `json:"auth_source_default_google_grant_on_first_bind"`
|
||
AuthSourceDefaultDingTalkBalance *float64 `json:"auth_source_default_dingtalk_balance"`
|
||
AuthSourceDefaultDingTalkConcurrency *int `json:"auth_source_default_dingtalk_concurrency"`
|
||
AuthSourceDefaultDingTalkSubscriptions *[]dto.DefaultSubscriptionSetting `json:"auth_source_default_dingtalk_subscriptions"`
|
||
AuthSourceDefaultDingTalkGrantOnSignup *bool `json:"auth_source_default_dingtalk_grant_on_signup"`
|
||
AuthSourceDefaultDingTalkGrantOnFirstBind *bool `json:"auth_source_default_dingtalk_grant_on_first_bind"`
|
||
ForceEmailOnThirdPartySignup *bool `json:"force_email_on_third_party_signup"`
|
||
|
||
// Model fallback configuration
|
||
EnableModelFallback bool `json:"enable_model_fallback"`
|
||
FallbackModelAnthropic string `json:"fallback_model_anthropic"`
|
||
FallbackModelOpenAI string `json:"fallback_model_openai"`
|
||
FallbackModelGemini string `json:"fallback_model_gemini"`
|
||
FallbackModelAntigravity string `json:"fallback_model_antigravity"`
|
||
|
||
// Identity patch configuration (Claude -> Gemini)
|
||
EnableIdentityPatch bool `json:"enable_identity_patch"`
|
||
IdentityPatchPrompt string `json:"identity_patch_prompt"`
|
||
|
||
// Ops monitoring (vNext)
|
||
OpsMonitoringEnabled *bool `json:"ops_monitoring_enabled"`
|
||
OpsRealtimeMonitoringEnabled *bool `json:"ops_realtime_monitoring_enabled"`
|
||
OpsQueryModeDefault *string `json:"ops_query_mode_default"`
|
||
OpsMetricsIntervalSeconds *int `json:"ops_metrics_interval_seconds"`
|
||
|
||
MinClaudeCodeVersion string `json:"min_claude_code_version"`
|
||
MaxClaudeCodeVersion string `json:"max_claude_code_version"`
|
||
|
||
// 分组隔离
|
||
AllowUngroupedKeyScheduling bool `json:"allow_ungrouped_key_scheduling"`
|
||
|
||
// Backend Mode
|
||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||
|
||
// Gateway forwarding behavior
|
||
EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"`
|
||
EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
|
||
EnableCCHSigning *bool `json:"enable_cch_signing"`
|
||
EnableClaudeOAuthSystemPromptInjection *bool `json:"enable_claude_oauth_system_prompt_injection"`
|
||
ClaudeOAuthSystemPrompt *string `json:"claude_oauth_system_prompt"`
|
||
ClaudeOAuthSystemPromptBlocks *string `json:"claude_oauth_system_prompt_blocks"`
|
||
EnableAnthropicCacheTTL1hInjection *bool `json:"enable_anthropic_cache_ttl_1h_injection"`
|
||
RewriteMessageCacheControl *bool `json:"rewrite_message_cache_control"`
|
||
EnableClientDatelineNormalization *bool `json:"enable_client_dateline_normalization"`
|
||
AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"`
|
||
OpenAICodexUserAgent *string `json:"openai_codex_user_agent"`
|
||
OpenAICodexClientVersion *string `json:"openai_codex_client_version"`
|
||
OpenAICodexVersionAutoSyncEnabled *bool `json:"openai_codex_version_auto_sync_enabled"`
|
||
|
||
// codex_cli_only 加固(global-only)
|
||
MinCodexVersion string `json:"min_codex_version"`
|
||
MaxCodexVersion string `json:"max_codex_version"`
|
||
CodexCLIOnlyBlacklist string `json:"codex_cli_only_blacklist"`
|
||
CodexCLIOnlyWhitelist string `json:"codex_cli_only_whitelist"`
|
||
CodexCLIOnlyAllowAppServerClients *bool `json:"codex_cli_only_allow_app_server_clients"`
|
||
CodexCLIOnlyEngineFingerprintSignals string `json:"codex_cli_only_engine_fingerprint_signals"`
|
||
|
||
// Payment visible method routing
|
||
PaymentVisibleMethodAlipaySource *string `json:"payment_visible_method_alipay_source"`
|
||
PaymentVisibleMethodWxpaySource *string `json:"payment_visible_method_wxpay_source"`
|
||
PaymentVisibleMethodAlipayEnabled *bool `json:"payment_visible_method_alipay_enabled"`
|
||
PaymentVisibleMethodWxpayEnabled *bool `json:"payment_visible_method_wxpay_enabled"`
|
||
|
||
// OpenAI account scheduling
|
||
OpenAILowUpstreamRatePriorityEnabled *bool `json:"openai_low_upstream_rate_priority_enabled"`
|
||
OpenAIOAuthSchedulingRateMultiplier *float64 `json:"openai_oauth_scheduling_rate_multiplier"`
|
||
OpenAIAdvancedSchedulerEnabled *bool `json:"openai_advanced_scheduler_enabled"`
|
||
OpenAIAdvancedSchedulerStickyWeightedEnabled *bool `json:"openai_advanced_scheduler_sticky_weighted_enabled"`
|
||
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled *bool `json:"openai_advanced_scheduler_subscription_priority_enabled"`
|
||
OpenAIAdvancedSchedulerLBTopK *string `json:"openai_advanced_scheduler_lb_top_k"`
|
||
OpenAIAdvancedSchedulerWeightPriority *string `json:"openai_advanced_scheduler_weight_priority"`
|
||
OpenAIAdvancedSchedulerWeightLoad *string `json:"openai_advanced_scheduler_weight_load"`
|
||
OpenAIAdvancedSchedulerWeightQueue *string `json:"openai_advanced_scheduler_weight_queue"`
|
||
OpenAIAdvancedSchedulerWeightErrorRate *string `json:"openai_advanced_scheduler_weight_error_rate"`
|
||
OpenAIAdvancedSchedulerWeightTTFT *string `json:"openai_advanced_scheduler_weight_ttft"`
|
||
OpenAIAdvancedSchedulerWeightReset *string `json:"openai_advanced_scheduler_weight_reset"`
|
||
OpenAIAdvancedSchedulerWeightQuotaHeadroom *string `json:"openai_advanced_scheduler_weight_quota_headroom"`
|
||
OpenAIAdvancedSchedulerWeightUpstreamCost *string `json:"openai_advanced_scheduler_weight_upstream_cost"`
|
||
OpenAIAdvancedSchedulerWeightPreviousResponse *string `json:"openai_advanced_scheduler_weight_previous_response"`
|
||
OpenAIAdvancedSchedulerWeightSessionSticky *string `json:"openai_advanced_scheduler_weight_session_sticky"`
|
||
|
||
// 余额不足提醒
|
||
BalanceLowNotifyEnabled *bool `json:"balance_low_notify_enabled"`
|
||
BalanceLowNotifyThreshold *float64 `json:"balance_low_notify_threshold"`
|
||
BalanceLowNotifyRechargeURL *string `json:"balance_low_notify_recharge_url"`
|
||
SubscriptionExpiryNotifyEnabled *bool `json:"subscription_expiry_notify_enabled"`
|
||
AccountQuotaNotifyEnabled *bool `json:"account_quota_notify_enabled"`
|
||
AccountQuotaNotifyEmails *[]dto.NotifyEmailEntry `json:"account_quota_notify_emails"`
|
||
|
||
// Payment configuration (integrated into settings, full replace)
|
||
PaymentEnabled *bool `json:"payment_enabled"`
|
||
PaymentMinAmount *float64 `json:"payment_min_amount"`
|
||
PaymentMaxAmount *float64 `json:"payment_max_amount"`
|
||
PaymentDailyLimit *float64 `json:"payment_daily_limit"`
|
||
PaymentOrderTimeoutMin *int `json:"payment_order_timeout_minutes"`
|
||
PaymentMaxPendingOrders *int `json:"payment_max_pending_orders"`
|
||
PaymentEnabledTypes []string `json:"payment_enabled_types"`
|
||
PaymentBalanceDisabled *bool `json:"payment_balance_disabled"`
|
||
PaymentBalanceRechargeMultiplier *float64 `json:"payment_balance_recharge_multiplier"`
|
||
PaymentSubscriptionUSDToCNYRate *float64 `json:"payment_subscription_usd_to_cny_rate"`
|
||
PaymentRechargeFeeRate *float64 `json:"payment_recharge_fee_rate"`
|
||
PaymentLoadBalanceStrat *string `json:"payment_load_balance_strategy"`
|
||
PaymentProductNamePrefix *string `json:"payment_product_name_prefix"`
|
||
PaymentProductNameSuffix *string `json:"payment_product_name_suffix"`
|
||
PaymentHelpImageURL *string `json:"payment_help_image_url"`
|
||
PaymentHelpText *string `json:"payment_help_text"`
|
||
|
||
// Cancel rate limit
|
||
PaymentCancelRateLimitEnabled *bool `json:"payment_cancel_rate_limit_enabled"`
|
||
PaymentCancelRateLimitMax *int `json:"payment_cancel_rate_limit_max"`
|
||
PaymentCancelRateLimitWindow *int `json:"payment_cancel_rate_limit_window"`
|
||
PaymentCancelRateLimitUnit *string `json:"payment_cancel_rate_limit_unit"`
|
||
PaymentCancelRateLimitMode *string `json:"payment_cancel_rate_limit_window_mode"`
|
||
|
||
// Force Alipay mobile clients to use QR code payment instead of mobile redirect
|
||
PaymentAlipayForceQRCode *bool `json:"payment_alipay_force_qrcode"`
|
||
// Use Alipay face-to-face precreate and an app deep link on mobile clients.
|
||
PaymentAlipayMobilePrecreateDeepLink *bool `json:"payment_alipay_mobile_precreate_deep_link"`
|
||
|
||
// Channel Monitor feature switch
|
||
ChannelMonitorEnabled *bool `json:"channel_monitor_enabled"`
|
||
ChannelMonitorMode *string `json:"channel_monitor_mode"`
|
||
ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"`
|
||
ChannelMonitorHideThroughput *bool `json:"channel_monitor_hide_throughput"`
|
||
ChannelMonitorShowQuota *bool `json:"channel_monitor_show_quota"`
|
||
|
||
// Grok model mapping policy
|
||
GrokDefaultTextModel *string `json:"grok_default_text_model"`
|
||
GrokCrossClientModelMapEnabled *bool `json:"grok_cross_client_model_map_enabled"`
|
||
GrokDefaultBaseURLMode *string `json:"grok_default_base_url_mode"`
|
||
|
||
// Available Channels feature switch (user-facing)
|
||
AvailableChannelsEnabled *bool `json:"available_channels_enabled"`
|
||
|
||
// Model Plaza feature switches + description
|
||
ModelPlazaEnabled *bool `json:"model_plaza_enabled"`
|
||
ModelPlazaRequireAuth *bool `json:"model_plaza_require_auth"`
|
||
ModelPlazaDescription *string `json:"model_plaza_description"`
|
||
|
||
// Affiliate (邀请返利) feature switch
|
||
AffiliateEnabled *bool `json:"affiliate_enabled"`
|
||
|
||
// 风控中心功能开关
|
||
RiskControlEnabled *bool `json:"risk_control_enabled"`
|
||
|
||
// cyber 会话屏蔽开关 + TTL
|
||
CyberSessionBlockEnabled *bool `json:"cyber_session_block_enabled"`
|
||
CyberSessionBlockTTLSeconds *int `json:"cyber_session_block_ttl_seconds"`
|
||
|
||
// OpenAI fast/flex policy (optional, only updated when provided)
|
||
OpenAIFastPolicySettings *dto.OpenAIFastPolicySettings `json:"openai_fast_policy_settings,omitempty"`
|
||
|
||
// 系统全局 platform quota 默认值(整体替换语义:nil = 不修改,non-nil = 整体覆盖)。
|
||
DefaultPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"default_platform_quotas"`
|
||
|
||
// 各平台账号自动停调阈值(整体替换语义:nil = 不修改,non-nil = 整体覆盖)。
|
||
AccountSchedulingThresholds map[string]int `json:"account_scheduling_thresholds"`
|
||
|
||
// auth-source 层 platform quota 覆盖(override 语义:nil = 不修改,non-nil = 整体覆盖该 source 的 quota 配置)。
|
||
AuthSourceEmailPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_email_platform_quotas"`
|
||
AuthSourceLinuxDoPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_linuxdo_platform_quotas"`
|
||
AuthSourceOIDCPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_oidc_platform_quotas"`
|
||
AuthSourceWeChatPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_wechat_platform_quotas"`
|
||
AuthSourceGitHubPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_github_platform_quotas"`
|
||
AuthSourceGooglePlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_google_platform_quotas"`
|
||
AuthSourceDingTalkPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_dingtalk_platform_quotas"`
|
||
|
||
AllowUserViewErrorRequests *bool `json:"allow_user_view_error_requests"`
|
||
}
|
||
|
||
// UpdateSettings 更新系统设置
|
||
// PUT /api/v1/admin/settings
|
||
// ensureActorTotpForStepUp 校验当前操作者具备开启 step-up 门控的条件:
|
||
// 必须是真人管理员会话(admin API key 无法完成 TOTP step-up,拒绝)且本人已启用 TOTP。
|
||
// 校验失败时写入错误响应并返回 false。
|
||
func (h *SettingHandler) ensureActorTotpForStepUp(c *gin.Context) bool {
|
||
if c.GetString("auth_method") == service.AuditAuthMethodAdminAPIKey {
|
||
response.ErrorWithDetails(c, http.StatusForbidden,
|
||
"Admin API key cannot enable step-up verification; use an admin session with TOTP enabled",
|
||
"STEP_UP_ADMIN_API_KEY_FORBIDDEN", nil)
|
||
return false
|
||
}
|
||
subject, ok := middleware.GetAuthSubjectFromContext(c)
|
||
if !ok || subject.UserID <= 0 {
|
||
response.ErrorWithDetails(c, http.StatusForbidden,
|
||
"Enabling step-up verification requires an authenticated admin session",
|
||
"STEP_UP_ENABLE_REQUIRES_TOTP", nil)
|
||
return false
|
||
}
|
||
if h.userService == nil {
|
||
response.InternalError(c, "Step-up precondition check unavailable")
|
||
return false
|
||
}
|
||
user, err := h.userService.GetByID(c.Request.Context(), subject.UserID)
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return false
|
||
}
|
||
if !user.TotpEnabled {
|
||
response.ErrorWithDetails(c, http.StatusBadRequest,
|
||
"Enable two-factor authentication (TOTP) for your account before turning on step-up verification",
|
||
"STEP_UP_ENABLE_REQUIRES_TOTP", nil)
|
||
return false
|
||
}
|
||
return true
|
||
}
|
||
|
||
// settingKeyJSONAliases covers the request fields whose JSON name differs from
|
||
// the setting key they persist to. Every other field of UpdateSettingsRequest
|
||
// is named after its setting key.
|
||
var settingKeyJSONAliases = map[string]string{
|
||
"smtp_from_email": service.SettingKeySMTPFrom,
|
||
}
|
||
|
||
// settingKeyByJSONName maps the value-typed top-level JSON fields of
|
||
// UpdateSettingsRequest to the setting key each one writes. Resolved once from
|
||
// the struct tags so new fields are covered without touching this file.
|
||
//
|
||
// Pointer-typed fields are deliberately excluded: they already carry their own
|
||
// "omitted = keep the stored value" merge in UpdateSettings, and some of them
|
||
// rely on being rewritten on every save to re-normalize fail-closed security
|
||
// state (see TestUpdateSettingsMalformedForwardedClientIPHeadersRemainFailClosedWhenOmitted).
|
||
// Only the value-typed fields are indistinguishable from a deliberate clear.
|
||
var settingKeyByJSONName = buildSettingKeyByJSONName()
|
||
|
||
func buildSettingKeyByJSONName() map[string]string {
|
||
t := reflect.TypeOf(UpdateSettingsRequest{})
|
||
out := make(map[string]string, t.NumField())
|
||
for i := 0; i < t.NumField(); i++ {
|
||
field := t.Field(i)
|
||
if field.Type.Kind() == reflect.Ptr {
|
||
continue
|
||
}
|
||
name, _, _ := strings.Cut(field.Tag.Get("json"), ",")
|
||
if name == "" || name == "-" {
|
||
continue
|
||
}
|
||
if alias, ok := settingKeyJSONAliases[name]; ok {
|
||
out[name] = alias
|
||
continue
|
||
}
|
||
out[name] = name
|
||
}
|
||
return out
|
||
}
|
||
|
||
// omittedSettingKeys reports the setting keys this payload never mentioned.
|
||
// Saving settings is a whole-document PUT, so without this a client that sends
|
||
// only the one field it cares about resets every other field to a zero value.
|
||
func omittedSettingKeys(sentFields map[string]json.RawMessage) service.OmittedSettingKeys {
|
||
omitted := make(service.OmittedSettingKeys, len(settingKeyByJSONName))
|
||
for jsonName, settingKey := range settingKeyByJSONName {
|
||
if _, sent := sentFields[jsonName]; !sent {
|
||
omitted[settingKey] = struct{}{}
|
||
}
|
||
}
|
||
return omitted
|
||
}
|
||
|
||
func settingsAuditRequest(req UpdateSettingsRequest) UpdateSettingsRequest {
|
||
req.TencentCaptchaAppSecretKey = strings.TrimSpace(req.TencentCaptchaAppSecretKey)
|
||
req.TencentCaptchaCloudSecretID = strings.TrimSpace(req.TencentCaptchaCloudSecretID)
|
||
req.TencentCaptchaCloudSecretKey = strings.TrimSpace(req.TencentCaptchaCloudSecretKey)
|
||
req.AliyunCaptchaAccessKeySecret = strings.TrimSpace(req.AliyunCaptchaAccessKeySecret)
|
||
return req
|
||
}
|
||
|
||
func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||
var sentFields map[string]json.RawMessage
|
||
if err := c.ShouldBindBodyWith(&sentFields, binding.JSON); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
var req UpdateSettingsRequest
|
||
if err := c.ShouldBindBodyWith(&req, binding.JSON); err != nil {
|
||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||
return
|
||
}
|
||
auditReq := settingsAuditRequest(req)
|
||
omitted := omittedSettingKeys(sentFields)
|
||
|
||
previousSettings, err := h.settingService.GetAllSettings(c.Request.Context())
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
previousAuthSourceDefaults, err := h.settingService.GetAuthSourceDefaultSettings(c.Request.Context())
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
|
||
// 两个安全开关的请求字段为指针:省略字段=保持现值,避免旧客户端/脚本
|
||
// 用不含新字段的全量 payload 保存设置时把安全开关静默重置。
|
||
sessionBindingEnabled := previousSettings.SessionBindingEnabled
|
||
if req.SessionBindingEnabled != nil {
|
||
sessionBindingEnabled = *req.SessionBindingEnabled
|
||
}
|
||
stepUpEnabled := previousSettings.StepUpEnabled
|
||
if req.StepUpEnabled != nil {
|
||
stepUpEnabled = *req.StepUpEnabled
|
||
}
|
||
passkeyEnabled := previousSettings.PasskeyEnabled
|
||
if req.PasskeyEnabled != nil {
|
||
passkeyEnabled = *req.PasskeyEnabled
|
||
}
|
||
registrationEmailDomainQuotaEnabled := previousSettings.RegistrationEmailDomainQuotaEnabled
|
||
if req.RegistrationEmailDomainQuotaEnabled != nil {
|
||
registrationEmailDomainQuotaEnabled = *req.RegistrationEmailDomainQuotaEnabled
|
||
}
|
||
if passkeyEnabled {
|
||
configured, _, _ := h.settingService.PasskeyConfiguration()
|
||
if !configured {
|
||
response.BadRequest(c, "Passkey sign-in requires a valid WebAuthn RP ID and allowed HTTPS origins in the deployment configuration")
|
||
return
|
||
}
|
||
}
|
||
forwardedClientIPHeaders := append([]string(nil), previousSettings.ForwardedClientIPHeaders...)
|
||
if req.ForwardedClientIPHeaders != nil {
|
||
forwardedClientIPHeaders = append([]string(nil), (*req.ForwardedClientIPHeaders)...)
|
||
}
|
||
|
||
// 开启敏感操作 step-up 门控属自锁风险操作:仅允许本人已启用 TOTP 的管理员会话开启,
|
||
// 否则开启后操作者立即被挡在所有敏感操作之外。仅在 false→true 的开启瞬间校验,
|
||
// 保持开启状态的常规设置保存不受影响。
|
||
if stepUpEnabled && !previousSettings.StepUpEnabled {
|
||
if !h.ensureActorTotpForStepUp(c) {
|
||
return
|
||
}
|
||
}
|
||
// 关闭 step-up 门控本身就是敏感操作:防止拿到管理员会话的攻击者先关闸再执行导出/备份。
|
||
// previousSettings 已证实开关处于开启状态,使用无条件门控变体,
|
||
// 避免门控内部二次读取开关时因存储故障 fail-open(前端捕获 STEP_UP_REQUIRED 弹码重试)。
|
||
if !stepUpEnabled && previousSettings.StepUpEnabled {
|
||
if !middleware.EnforceStepUpAlways(c, h.totpService, h.userService) {
|
||
return
|
||
}
|
||
}
|
||
|
||
// 验证参数
|
||
if req.DefaultConcurrency < 1 {
|
||
req.DefaultConcurrency = 1
|
||
}
|
||
if req.DefaultBalance < 0 {
|
||
req.DefaultBalance = 0
|
||
}
|
||
affiliateRebateRate := previousSettings.AffiliateRebateRate
|
||
if req.AffiliateRebateRate != nil {
|
||
affiliateRebateRate = *req.AffiliateRebateRate
|
||
}
|
||
if affiliateRebateRate < service.AffiliateRebateRateMin {
|
||
affiliateRebateRate = service.AffiliateRebateRateMin
|
||
}
|
||
if affiliateRebateRate > service.AffiliateRebateRateMax {
|
||
affiliateRebateRate = service.AffiliateRebateRateMax
|
||
}
|
||
affiliateRebateFreezeHours := previousSettings.AffiliateRebateFreezeHours
|
||
if req.AffiliateRebateFreezeHours != nil {
|
||
affiliateRebateFreezeHours = *req.AffiliateRebateFreezeHours
|
||
}
|
||
if affiliateRebateFreezeHours < 0 {
|
||
affiliateRebateFreezeHours = service.AffiliateRebateFreezeHoursDefault
|
||
}
|
||
if affiliateRebateFreezeHours > service.AffiliateRebateFreezeHoursMax {
|
||
affiliateRebateFreezeHours = service.AffiliateRebateFreezeHoursMax
|
||
}
|
||
affiliateRebateDurationDays := previousSettings.AffiliateRebateDurationDays
|
||
if req.AffiliateRebateDurationDays != nil {
|
||
affiliateRebateDurationDays = *req.AffiliateRebateDurationDays
|
||
}
|
||
if affiliateRebateDurationDays < 0 {
|
||
affiliateRebateDurationDays = service.AffiliateRebateDurationDaysDefault
|
||
}
|
||
if affiliateRebateDurationDays > service.AffiliateRebateDurationDaysMax {
|
||
affiliateRebateDurationDays = service.AffiliateRebateDurationDaysMax
|
||
}
|
||
affiliateRebatePerInviteeCap := previousSettings.AffiliateRebatePerInviteeCap
|
||
if req.AffiliateRebatePerInviteeCap != nil {
|
||
affiliateRebatePerInviteeCap = *req.AffiliateRebatePerInviteeCap
|
||
}
|
||
if affiliateRebatePerInviteeCap < 0 {
|
||
affiliateRebatePerInviteeCap = service.AffiliateRebatePerInviteeCapDefault
|
||
}
|
||
adminRechargeRebateEnabled := previousSettings.AdminRechargeRebateEnabled
|
||
if req.AdminRechargeRebateEnabled != nil {
|
||
adminRechargeRebateEnabled = *req.AdminRechargeRebateEnabled
|
||
}
|
||
// 通用表格配置:兼容旧客户端未传字段时保留当前值。
|
||
if req.TableDefaultPageSize <= 0 {
|
||
req.TableDefaultPageSize = previousSettings.TableDefaultPageSize
|
||
}
|
||
if req.TablePageSizeOptions == nil {
|
||
req.TablePageSizeOptions = previousSettings.TablePageSizeOptions
|
||
}
|
||
req.SMTPHost = strings.TrimSpace(req.SMTPHost)
|
||
req.SMTPUsername = strings.TrimSpace(req.SMTPUsername)
|
||
req.SMTPPassword = strings.TrimSpace(req.SMTPPassword)
|
||
req.SMTPFrom = strings.TrimSpace(req.SMTPFrom)
|
||
req.SMTPFromName = strings.TrimSpace(req.SMTPFromName)
|
||
req.TencentCaptchaAppID = strings.TrimSpace(req.TencentCaptchaAppID)
|
||
req.TencentCaptchaAppSecretKey = strings.TrimSpace(req.TencentCaptchaAppSecretKey)
|
||
req.TencentCaptchaCloudSecretID = strings.TrimSpace(req.TencentCaptchaCloudSecretID)
|
||
req.TencentCaptchaCloudSecretKey = strings.TrimSpace(req.TencentCaptchaCloudSecretKey)
|
||
if req.SMTPPort <= 0 {
|
||
req.SMTPPort = 587
|
||
}
|
||
req.DefaultSubscriptions = normalizeDefaultSubscriptions(req.DefaultSubscriptions)
|
||
req.AuthSourceDefaultEmailSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultEmailSubscriptions)
|
||
req.AuthSourceDefaultLinuxDoSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultLinuxDoSubscriptions)
|
||
req.AuthSourceDefaultOIDCSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultOIDCSubscriptions)
|
||
req.AuthSourceDefaultWeChatSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultWeChatSubscriptions)
|
||
req.AuthSourceDefaultDingTalkSubscriptions = normalizeOptionalDefaultSubscriptions(req.AuthSourceDefaultDingTalkSubscriptions)
|
||
|
||
// SMTP 配置保护:如果请求中 smtp_host 为空但数据库中已有配置,则保留已有 SMTP 配置
|
||
// 防止前端加载设置失败时空表单覆盖已保存的 SMTP 配置
|
||
if req.SMTPHost == "" && previousSettings.SMTPHost != "" {
|
||
req.SMTPHost = previousSettings.SMTPHost
|
||
req.SMTPPort = previousSettings.SMTPPort
|
||
req.SMTPUsername = previousSettings.SMTPUsername
|
||
req.SMTPFrom = previousSettings.SMTPFrom
|
||
req.SMTPFromName = previousSettings.SMTPFromName
|
||
req.SMTPUseTLS = previousSettings.SMTPUseTLS
|
||
}
|
||
|
||
turnstileEnabled := req.TurnstileEnabled
|
||
if _, sent := sentFields["turnstile_enabled"]; !sent {
|
||
turnstileEnabled = previousSettings.TurnstileEnabled
|
||
}
|
||
tencentCaptchaEnabled := req.TencentCaptchaEnabled
|
||
if _, sent := sentFields["tencent_captcha_enabled"]; !sent {
|
||
tencentCaptchaEnabled = previousSettings.TencentCaptchaEnabled
|
||
}
|
||
aliyunCaptchaEnabled := req.AliyunCaptchaEnabled
|
||
if _, sent := sentFields["aliyun_captcha_enabled"]; !sent {
|
||
aliyunCaptchaEnabled = previousSettings.AliyunCaptchaEnabled
|
||
}
|
||
enabledCaptchaProviders := 0
|
||
for _, enabled := range []bool{turnstileEnabled, tencentCaptchaEnabled, aliyunCaptchaEnabled} {
|
||
if enabled {
|
||
enabledCaptchaProviders++
|
||
}
|
||
}
|
||
if enabledCaptchaProviders > 1 {
|
||
response.BadRequest(c, "Multiple captcha providers (Cloudflare Turnstile / Tencent Captcha / Aliyun Captcha) cannot be enabled at the same time")
|
||
return
|
||
}
|
||
// 阿里云地域 normalize:未发送保留已存值,非法值一律按中国内地落库
|
||
if _, sent := sentFields["aliyun_captcha_region"]; !sent {
|
||
req.AliyunCaptchaRegion = previousSettings.AliyunCaptchaRegion
|
||
}
|
||
if req.AliyunCaptchaRegion != service.AliyunCaptchaRegionSGP {
|
||
req.AliyunCaptchaRegion = service.AliyunCaptchaRegionCN
|
||
}
|
||
// 天御站点 normalize:未发送保留已存值,非法值一律按中国站落库
|
||
if _, sent := sentFields["tencent_captcha_region"]; !sent {
|
||
req.TencentCaptchaRegion = previousSettings.TencentCaptchaRegion
|
||
}
|
||
if req.TencentCaptchaRegion != service.TencentCaptchaRegionINTL {
|
||
req.TencentCaptchaRegion = service.TencentCaptchaRegionCN
|
||
}
|
||
|
||
// Turnstile 参数验证
|
||
if req.TurnstileEnabled {
|
||
// 检查必填字段
|
||
if req.TurnstileSiteKey == "" {
|
||
response.BadRequest(c, "Turnstile Site Key is required when enabled")
|
||
return
|
||
}
|
||
// 如果未提供 secret key,使用已保存的值(留空保留当前值)
|
||
if req.TurnstileSecretKey == "" {
|
||
if previousSettings.TurnstileSecretKey == "" {
|
||
response.BadRequest(c, "Turnstile Secret Key is required when enabled")
|
||
return
|
||
}
|
||
req.TurnstileSecretKey = previousSettings.TurnstileSecretKey
|
||
}
|
||
|
||
// 当 site_key 或 secret_key 任一变化时验证(避免配置错误导致无法登录)
|
||
siteKeyChanged := previousSettings.TurnstileSiteKey != req.TurnstileSiteKey
|
||
secretKeyChanged := previousSettings.TurnstileSecretKey != req.TurnstileSecretKey
|
||
if siteKeyChanged || secretKeyChanged {
|
||
if err := h.turnstileService.ValidateSecretKey(c.Request.Context(), req.TurnstileSecretKey); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
if tencentCaptchaEnabled {
|
||
if _, sent := sentFields["tencent_captcha_app_id"]; !sent {
|
||
req.TencentCaptchaAppID = previousSettings.TencentCaptchaAppID
|
||
}
|
||
appID, err := strconv.ParseUint(req.TencentCaptchaAppID, 10, 64)
|
||
if err != nil || appID == 0 {
|
||
response.BadRequest(c, "Tencent Captcha CaptchaAppId must be a positive integer when enabled")
|
||
return
|
||
}
|
||
if req.TencentCaptchaAppSecretKey == "" {
|
||
req.TencentCaptchaAppSecretKey = previousSettings.TencentCaptchaAppSecretKey
|
||
}
|
||
if req.TencentCaptchaCloudSecretID == "" {
|
||
req.TencentCaptchaCloudSecretID = previousSettings.TencentCaptchaCloudSecretID
|
||
}
|
||
if req.TencentCaptchaCloudSecretKey == "" {
|
||
req.TencentCaptchaCloudSecretKey = previousSettings.TencentCaptchaCloudSecretKey
|
||
}
|
||
if req.TencentCaptchaAppSecretKey == "" {
|
||
response.BadRequest(c, "Tencent Captcha AppSecretKey is required when enabled")
|
||
return
|
||
}
|
||
if req.TencentCaptchaCloudSecretID == "" {
|
||
response.BadRequest(c, "Tencent Cloud SecretId is required when Tencent Captcha is enabled")
|
||
return
|
||
}
|
||
if req.TencentCaptchaCloudSecretKey == "" {
|
||
response.BadRequest(c, "Tencent Cloud SecretKey is required when Tencent Captcha is enabled")
|
||
return
|
||
}
|
||
}
|
||
|
||
// 阿里云验证码 2.0 参数验证
|
||
if aliyunCaptchaEnabled {
|
||
if _, sent := sentFields["aliyun_captcha_scene_id"]; !sent {
|
||
req.AliyunCaptchaSceneID = previousSettings.AliyunCaptchaSceneID
|
||
}
|
||
if _, sent := sentFields["aliyun_captcha_prefix"]; !sent {
|
||
req.AliyunCaptchaPrefix = previousSettings.AliyunCaptchaPrefix
|
||
}
|
||
if _, sent := sentFields["aliyun_captcha_access_key_id"]; !sent {
|
||
req.AliyunCaptchaAccessKeyID = previousSettings.AliyunCaptchaAccessKeyID
|
||
}
|
||
if req.AliyunCaptchaSceneID == "" {
|
||
response.BadRequest(c, "Aliyun Captcha Scene ID is required when enabled")
|
||
return
|
||
}
|
||
if req.AliyunCaptchaPrefix == "" {
|
||
response.BadRequest(c, "Aliyun Captcha Prefix is required when enabled")
|
||
return
|
||
}
|
||
if req.AliyunCaptchaAccessKeyID == "" {
|
||
response.BadRequest(c, "Aliyun Captcha AccessKey ID is required when enabled")
|
||
return
|
||
}
|
||
// 如果未提供 AccessKey Secret,使用已保存的值(留空保留当前值)
|
||
if req.AliyunCaptchaAccessKeySecret == "" {
|
||
if previousSettings.AliyunCaptchaAccessKeySecret == "" {
|
||
response.BadRequest(c, "Aliyun Captcha AccessKey Secret is required when enabled")
|
||
return
|
||
}
|
||
req.AliyunCaptchaAccessKeySecret = previousSettings.AliyunCaptchaAccessKeySecret
|
||
}
|
||
|
||
// 凭证任一变化时真实调用一次阿里云校验(避免配置错误导致无法登录)
|
||
credentialsChanged := previousSettings.AliyunCaptchaAccessKeyID != req.AliyunCaptchaAccessKeyID ||
|
||
previousSettings.AliyunCaptchaAccessKeySecret != req.AliyunCaptchaAccessKeySecret ||
|
||
previousSettings.AliyunCaptchaSceneID != req.AliyunCaptchaSceneID ||
|
||
previousSettings.AliyunCaptchaRegion != req.AliyunCaptchaRegion
|
||
if credentialsChanged {
|
||
if err := h.aliyunCaptchaService.ValidateCredentials(c.Request.Context(), req.AliyunCaptchaAccessKeyID, req.AliyunCaptchaAccessKeySecret, req.AliyunCaptchaSceneID, req.AliyunCaptchaRegion); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// TOTP 双因素认证参数验证
|
||
// 只有手动配置了加密密钥才允许启用 TOTP 功能
|
||
if req.TotpEnabled && !previousSettings.TotpEnabled {
|
||
// 尝试启用 TOTP,检查加密密钥是否已手动配置
|
||
if !h.settingService.IsTotpEncryptionKeyConfigured() {
|
||
response.BadRequest(c, "Cannot enable TOTP: TOTP_ENCRYPTION_KEY environment variable must be configured first. Generate a key with 'openssl rand -hex 32' and set it in your environment.")
|
||
return
|
||
}
|
||
}
|
||
loginAgreementMode := strings.ToLower(strings.TrimSpace(req.LoginAgreementMode))
|
||
if loginAgreementMode == "" {
|
||
loginAgreementMode = strings.ToLower(strings.TrimSpace(previousSettings.LoginAgreementMode))
|
||
}
|
||
switch loginAgreementMode {
|
||
case "", "modal":
|
||
loginAgreementMode = "modal"
|
||
case "checkbox":
|
||
default:
|
||
response.BadRequest(c, "Login agreement mode must be modal or checkbox")
|
||
return
|
||
}
|
||
loginAgreementUpdatedAt := strings.TrimSpace(req.LoginAgreementUpdatedAt)
|
||
if loginAgreementUpdatedAt == "" {
|
||
loginAgreementUpdatedAt = strings.TrimSpace(previousSettings.LoginAgreementUpdatedAt)
|
||
}
|
||
loginAgreementDocuments := loginAgreementDocumentsToService(req.LoginAgreementDocuments)
|
||
if len(loginAgreementDocuments) == 0 {
|
||
loginAgreementDocuments = previousSettings.LoginAgreementDocuments
|
||
}
|
||
for _, doc := range loginAgreementDocuments {
|
||
if strings.TrimSpace(doc.Title) == "" {
|
||
response.BadRequest(c, "Login agreement document title is required")
|
||
return
|
||
}
|
||
if len(doc.Title) > 80 {
|
||
response.BadRequest(c, "Login agreement document title is too long (max 80 characters)")
|
||
return
|
||
}
|
||
if len(doc.ContentMD) > 200*1024 {
|
||
response.BadRequest(c, "Login agreement document content is too large (max 200KB)")
|
||
return
|
||
}
|
||
}
|
||
if req.LoginAgreementEnabled && len(loginAgreementDocuments) == 0 {
|
||
response.BadRequest(c, "Login agreement documents are required when enabled")
|
||
return
|
||
}
|
||
|
||
// LinuxDo Connect 参数验证
|
||
if req.LinuxDoConnectEnabled {
|
||
req.LinuxDoConnectClientID = strings.TrimSpace(req.LinuxDoConnectClientID)
|
||
req.LinuxDoConnectClientSecret = strings.TrimSpace(req.LinuxDoConnectClientSecret)
|
||
req.LinuxDoConnectRedirectURL = strings.TrimSpace(req.LinuxDoConnectRedirectURL)
|
||
|
||
if req.LinuxDoConnectClientID == "" {
|
||
response.BadRequest(c, "LinuxDo Client ID is required when enabled")
|
||
return
|
||
}
|
||
if req.LinuxDoConnectRedirectURL == "" {
|
||
response.BadRequest(c, "LinuxDo Redirect URL is required when enabled")
|
||
return
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(req.LinuxDoConnectRedirectURL); err != nil {
|
||
response.BadRequest(c, "LinuxDo Redirect URL must be an absolute http(s) URL")
|
||
return
|
||
}
|
||
|
||
// 如果未提供 client_secret,则保留现有值(如有)。
|
||
if req.LinuxDoConnectClientSecret == "" {
|
||
if previousSettings.LinuxDoConnectClientSecret == "" {
|
||
response.BadRequest(c, "LinuxDo Client Secret is required when enabled")
|
||
return
|
||
}
|
||
req.LinuxDoConnectClientSecret = previousSettings.LinuxDoConnectClientSecret
|
||
}
|
||
}
|
||
|
||
// DingTalk Connect 参数验证
|
||
// 防御性:任何写入路径上把已废弃的 corp_restriction_policy=whitelist 入参 coerce 为 none,
|
||
// 避免任何直连 admin API 的客户端把死值写回 DB(前端 UI 已无此选项)。
|
||
req.DingTalkConnectCorpRestrictionPolicy = service.CoerceDingTalkCorpPolicyForWrite(req.DingTalkConnectCorpRestrictionPolicy)
|
||
|
||
if req.DingTalkConnectEnabled {
|
||
req.DingTalkConnectClientID = strings.TrimSpace(req.DingTalkConnectClientID)
|
||
req.DingTalkConnectClientSecret = strings.TrimSpace(req.DingTalkConnectClientSecret)
|
||
req.DingTalkConnectRedirectURL = strings.TrimSpace(req.DingTalkConnectRedirectURL)
|
||
req.DingTalkConnectCorpRestrictionPolicy = strings.TrimSpace(req.DingTalkConnectCorpRestrictionPolicy)
|
||
req.DingTalkConnectInternalCorpID = strings.TrimSpace(req.DingTalkConnectInternalCorpID)
|
||
|
||
if req.DingTalkConnectClientID == "" {
|
||
response.BadRequest(c, "DingTalk Client ID is required when enabled")
|
||
return
|
||
}
|
||
if req.DingTalkConnectRedirectURL == "" {
|
||
response.BadRequest(c, "DingTalk Redirect URL is required when enabled")
|
||
return
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(req.DingTalkConnectRedirectURL); err != nil {
|
||
response.BadRequest(c, "DingTalk Redirect URL must be an absolute http(s) URL")
|
||
return
|
||
}
|
||
|
||
// 如果未提供 client_secret,则保留现有值(如有)。
|
||
if req.DingTalkConnectClientSecret == "" {
|
||
if previousSettings.DingTalkConnectClientSecret == "" {
|
||
response.BadRequest(c, "DingTalk Client Secret is required when enabled")
|
||
return
|
||
}
|
||
req.DingTalkConnectClientSecret = previousSettings.DingTalkConnectClientSecret
|
||
}
|
||
|
||
// Corp 策略校验(V1/V4 fail-closed)
|
||
dingTalkCfg := config.DingTalkConnectConfig{
|
||
Enabled: true,
|
||
DingTalkAppKind: "internal_app", // 硬编码:settings 层仅支持 internal_app
|
||
AppType: "internal", // 对于 internal_only 策略的默认值
|
||
CorpRestrictionPolicy: req.DingTalkConnectCorpRestrictionPolicy,
|
||
InternalCorpID: req.DingTalkConnectInternalCorpID,
|
||
}
|
||
// 若未填 corp_restriction_policy,保留已有配置
|
||
if dingTalkCfg.CorpRestrictionPolicy == "" {
|
||
dingTalkCfg.CorpRestrictionPolicy = previousSettings.DingTalkConnectCorpRestrictionPolicy
|
||
}
|
||
// 对于 internal_only 策略,app_type 必须为 internal(V1 校验)
|
||
if dingTalkCfg.CorpRestrictionPolicy == "internal_only" {
|
||
dingTalkCfg.AppType = "internal"
|
||
} else {
|
||
dingTalkCfg.AppType = "public"
|
||
}
|
||
if err := config.ValidateDingTalkConfig(dingTalkCfg); err != nil {
|
||
response.ErrorWithDetails(c, http.StatusBadRequest, err.Error(), mapDingTalkValidateError(err), nil)
|
||
return
|
||
}
|
||
|
||
// bypass_registration 仅在 internal_only 模式下有意义;其它策略下强制为 false,
|
||
// 防止 admin 在切换 policy 时把 bypass 残留在 DB 中(前端 UI 也已隐藏该开关)。
|
||
if dingTalkCfg.CorpRestrictionPolicy != "internal_only" {
|
||
req.DingTalkConnectBypassRegistration = false
|
||
// 身份同步三开关同理:仅 internal_only 模式下有意义,其它策略强制 false。
|
||
req.DingTalkConnectSyncCorpEmail = false
|
||
req.DingTalkConnectSyncDisplayName = false
|
||
req.DingTalkConnectSyncDept = false
|
||
}
|
||
// 身份同步目标 attr key:trimSpace + 空值 fallback 到默认值
|
||
req.DingTalkConnectSyncCorpEmailAttrKey = strings.TrimSpace(req.DingTalkConnectSyncCorpEmailAttrKey)
|
||
if req.DingTalkConnectSyncCorpEmailAttrKey == "" {
|
||
req.DingTalkConnectSyncCorpEmailAttrKey = "dingtalk_email"
|
||
}
|
||
req.DingTalkConnectSyncDisplayNameAttrKey = strings.TrimSpace(req.DingTalkConnectSyncDisplayNameAttrKey)
|
||
if req.DingTalkConnectSyncDisplayNameAttrKey == "" {
|
||
req.DingTalkConnectSyncDisplayNameAttrKey = "dingtalk_name"
|
||
}
|
||
req.DingTalkConnectSyncDeptAttrKey = strings.TrimSpace(req.DingTalkConnectSyncDeptAttrKey)
|
||
if req.DingTalkConnectSyncDeptAttrKey == "" {
|
||
req.DingTalkConnectSyncDeptAttrKey = "dingtalk_department"
|
||
}
|
||
// 身份同步目标 attr 显示名称:trim + 空值 fallback 到默认中文名
|
||
req.DingTalkConnectSyncCorpEmailAttrName = strings.TrimSpace(req.DingTalkConnectSyncCorpEmailAttrName)
|
||
if req.DingTalkConnectSyncCorpEmailAttrName == "" {
|
||
req.DingTalkConnectSyncCorpEmailAttrName = "钉钉企业邮箱"
|
||
}
|
||
req.DingTalkConnectSyncDisplayNameAttrName = strings.TrimSpace(req.DingTalkConnectSyncDisplayNameAttrName)
|
||
if req.DingTalkConnectSyncDisplayNameAttrName == "" {
|
||
req.DingTalkConnectSyncDisplayNameAttrName = "钉钉姓名"
|
||
}
|
||
req.DingTalkConnectSyncDeptAttrName = strings.TrimSpace(req.DingTalkConnectSyncDeptAttrName)
|
||
if req.DingTalkConnectSyncDeptAttrName == "" {
|
||
req.DingTalkConnectSyncDeptAttrName = "钉钉部门"
|
||
}
|
||
}
|
||
|
||
if req.WeChatConnectEnabled {
|
||
req.WeChatConnectAppID = strings.TrimSpace(req.WeChatConnectAppID)
|
||
req.WeChatConnectAppSecret = strings.TrimSpace(req.WeChatConnectAppSecret)
|
||
req.WeChatConnectOpenAppID = strings.TrimSpace(req.WeChatConnectOpenAppID)
|
||
req.WeChatConnectOpenAppSecret = strings.TrimSpace(req.WeChatConnectOpenAppSecret)
|
||
req.WeChatConnectMPAppID = strings.TrimSpace(req.WeChatConnectMPAppID)
|
||
req.WeChatConnectMPAppSecret = strings.TrimSpace(req.WeChatConnectMPAppSecret)
|
||
req.WeChatConnectMobileAppID = strings.TrimSpace(req.WeChatConnectMobileAppID)
|
||
req.WeChatConnectMobileAppSecret = strings.TrimSpace(req.WeChatConnectMobileAppSecret)
|
||
req.WeChatConnectMode = strings.ToLower(strings.TrimSpace(req.WeChatConnectMode))
|
||
req.WeChatConnectScopes = strings.TrimSpace(req.WeChatConnectScopes)
|
||
req.WeChatConnectRedirectURL = strings.TrimSpace(req.WeChatConnectRedirectURL)
|
||
req.WeChatConnectFrontendRedirectURL = strings.TrimSpace(req.WeChatConnectFrontendRedirectURL)
|
||
req.WeChatConnectAppID = strings.TrimSpace(firstNonEmpty(req.WeChatConnectAppID, previousSettings.WeChatConnectAppID))
|
||
req.WeChatConnectRedirectURL = strings.TrimSpace(firstNonEmpty(req.WeChatConnectRedirectURL, previousSettings.WeChatConnectRedirectURL))
|
||
req.WeChatConnectFrontendRedirectURL = strings.TrimSpace(firstNonEmpty(req.WeChatConnectFrontendRedirectURL, previousSettings.WeChatConnectFrontendRedirectURL))
|
||
if req.WeChatConnectMode == "" {
|
||
req.WeChatConnectMode = strings.ToLower(strings.TrimSpace(previousSettings.WeChatConnectMode))
|
||
}
|
||
if req.WeChatConnectScopes == "" {
|
||
req.WeChatConnectScopes = strings.TrimSpace(previousSettings.WeChatConnectScopes)
|
||
}
|
||
|
||
if req.WeChatConnectMPEnabled && req.WeChatConnectMobileEnabled {
|
||
response.BadRequest(c, "WeChat Official Account and Mobile App cannot be enabled at the same time")
|
||
return
|
||
}
|
||
if req.WeChatConnectMode != "" {
|
||
switch req.WeChatConnectMode {
|
||
case "open", "mp", "mobile":
|
||
default:
|
||
response.BadRequest(c, "WeChat mode must be open, mp, or mobile")
|
||
return
|
||
}
|
||
}
|
||
if !req.WeChatConnectOpenEnabled && !req.WeChatConnectMPEnabled && !req.WeChatConnectMobileEnabled {
|
||
switch req.WeChatConnectMode {
|
||
case "mp":
|
||
req.WeChatConnectMPEnabled = true
|
||
case "mobile":
|
||
req.WeChatConnectMobileEnabled = true
|
||
default:
|
||
req.WeChatConnectOpenEnabled = true
|
||
}
|
||
}
|
||
if req.WeChatConnectMode == "" {
|
||
if req.WeChatConnectMPEnabled {
|
||
req.WeChatConnectMode = "mp"
|
||
} else if req.WeChatConnectMobileEnabled {
|
||
req.WeChatConnectMode = "mobile"
|
||
} else {
|
||
req.WeChatConnectMode = "open"
|
||
}
|
||
}
|
||
|
||
req.WeChatConnectOpenAppID = strings.TrimSpace(firstNonEmpty(req.WeChatConnectOpenAppID, req.WeChatConnectAppID, previousSettings.WeChatConnectOpenAppID, previousSettings.WeChatConnectAppID))
|
||
req.WeChatConnectMPAppID = strings.TrimSpace(firstNonEmpty(req.WeChatConnectMPAppID, req.WeChatConnectAppID, previousSettings.WeChatConnectMPAppID, previousSettings.WeChatConnectAppID))
|
||
req.WeChatConnectMobileAppID = strings.TrimSpace(firstNonEmpty(req.WeChatConnectMobileAppID, req.WeChatConnectAppID, previousSettings.WeChatConnectMobileAppID, previousSettings.WeChatConnectAppID))
|
||
|
||
if req.WeChatConnectOpenAppSecret == "" {
|
||
req.WeChatConnectOpenAppSecret = strings.TrimSpace(firstNonEmpty(previousSettings.WeChatConnectOpenAppSecret, previousSettings.WeChatConnectAppSecret, req.WeChatConnectAppSecret))
|
||
}
|
||
if req.WeChatConnectMPAppSecret == "" {
|
||
req.WeChatConnectMPAppSecret = strings.TrimSpace(firstNonEmpty(previousSettings.WeChatConnectMPAppSecret, previousSettings.WeChatConnectAppSecret, req.WeChatConnectAppSecret))
|
||
}
|
||
if req.WeChatConnectMobileAppSecret == "" {
|
||
req.WeChatConnectMobileAppSecret = strings.TrimSpace(firstNonEmpty(previousSettings.WeChatConnectMobileAppSecret, previousSettings.WeChatConnectAppSecret, req.WeChatConnectAppSecret))
|
||
}
|
||
if req.WeChatConnectAppSecret == "" {
|
||
req.WeChatConnectAppSecret = strings.TrimSpace(firstNonEmpty(req.WeChatConnectOpenAppSecret, req.WeChatConnectMPAppSecret, req.WeChatConnectMobileAppSecret, previousSettings.WeChatConnectAppSecret))
|
||
}
|
||
|
||
if req.WeChatConnectOpenEnabled {
|
||
if req.WeChatConnectOpenAppID == "" {
|
||
response.BadRequest(c, "WeChat PC App ID is required when enabled")
|
||
return
|
||
}
|
||
if req.WeChatConnectOpenAppSecret == "" {
|
||
response.BadRequest(c, "WeChat PC App Secret is required when enabled")
|
||
return
|
||
}
|
||
}
|
||
if req.WeChatConnectMPEnabled {
|
||
if req.WeChatConnectMPAppID == "" {
|
||
response.BadRequest(c, "WeChat Official Account App ID is required when enabled")
|
||
return
|
||
}
|
||
if req.WeChatConnectMPAppSecret == "" {
|
||
response.BadRequest(c, "WeChat Official Account App Secret is required when enabled")
|
||
return
|
||
}
|
||
}
|
||
if req.WeChatConnectMobileEnabled {
|
||
if req.WeChatConnectMobileAppID == "" {
|
||
response.BadRequest(c, "WeChat Mobile App ID is required when enabled")
|
||
return
|
||
}
|
||
if req.WeChatConnectMobileAppSecret == "" {
|
||
response.BadRequest(c, "WeChat Mobile App Secret is required when enabled")
|
||
return
|
||
}
|
||
}
|
||
|
||
if req.WeChatConnectScopes == "" {
|
||
if req.WeChatConnectMPEnabled {
|
||
req.WeChatConnectScopes = service.DefaultWeChatConnectScopesForMode("mp")
|
||
} else {
|
||
req.WeChatConnectScopes = service.DefaultWeChatConnectScopesForMode(req.WeChatConnectMode)
|
||
}
|
||
}
|
||
if req.WeChatConnectOpenEnabled || req.WeChatConnectMPEnabled {
|
||
if req.WeChatConnectRedirectURL == "" {
|
||
response.BadRequest(c, "WeChat Redirect URL is required when web oauth is enabled")
|
||
return
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(req.WeChatConnectRedirectURL); err != nil {
|
||
response.BadRequest(c, "WeChat Redirect URL must be an absolute http(s) URL")
|
||
return
|
||
}
|
||
if req.WeChatConnectFrontendRedirectURL == "" {
|
||
req.WeChatConnectFrontendRedirectURL = "/auth/wechat/callback"
|
||
}
|
||
if err := config.ValidateFrontendRedirectURL(req.WeChatConnectFrontendRedirectURL); err != nil {
|
||
response.BadRequest(c, "WeChat Frontend Redirect URL is invalid")
|
||
return
|
||
}
|
||
}
|
||
}
|
||
|
||
// Generic OIDC 参数验证
|
||
oidcUsePKCE, oidcValidateIDToken, err := h.settingService.OIDCSecurityWriteDefaults(c.Request.Context())
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
if req.OIDCConnectEnabled {
|
||
req.OIDCConnectProviderName = strings.TrimSpace(req.OIDCConnectProviderName)
|
||
req.OIDCConnectClientID = strings.TrimSpace(req.OIDCConnectClientID)
|
||
req.OIDCConnectClientSecret = strings.TrimSpace(req.OIDCConnectClientSecret)
|
||
req.OIDCConnectIssuerURL = strings.TrimSpace(req.OIDCConnectIssuerURL)
|
||
req.OIDCConnectDiscoveryURL = strings.TrimSpace(req.OIDCConnectDiscoveryURL)
|
||
req.OIDCConnectAuthorizeURL = strings.TrimSpace(req.OIDCConnectAuthorizeURL)
|
||
req.OIDCConnectTokenURL = strings.TrimSpace(req.OIDCConnectTokenURL)
|
||
req.OIDCConnectUserInfoURL = strings.TrimSpace(req.OIDCConnectUserInfoURL)
|
||
req.OIDCConnectJWKSURL = strings.TrimSpace(req.OIDCConnectJWKSURL)
|
||
req.OIDCConnectScopes = strings.TrimSpace(req.OIDCConnectScopes)
|
||
req.OIDCConnectRedirectURL = strings.TrimSpace(req.OIDCConnectRedirectURL)
|
||
req.OIDCConnectFrontendRedirectURL = strings.TrimSpace(req.OIDCConnectFrontendRedirectURL)
|
||
req.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(req.OIDCConnectTokenAuthMethod))
|
||
req.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(req.OIDCConnectAllowedSigningAlgs)
|
||
req.OIDCConnectUserInfoEmailPath = strings.TrimSpace(req.OIDCConnectUserInfoEmailPath)
|
||
req.OIDCConnectUserInfoIDPath = strings.TrimSpace(req.OIDCConnectUserInfoIDPath)
|
||
req.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(req.OIDCConnectUserInfoUsernamePath)
|
||
req.OIDCConnectProviderName = strings.TrimSpace(firstNonEmpty(req.OIDCConnectProviderName, previousSettings.OIDCConnectProviderName, "OIDC"))
|
||
req.OIDCConnectClientID = strings.TrimSpace(firstNonEmpty(req.OIDCConnectClientID, previousSettings.OIDCConnectClientID))
|
||
req.OIDCConnectIssuerURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectIssuerURL, previousSettings.OIDCConnectIssuerURL))
|
||
req.OIDCConnectDiscoveryURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectDiscoveryURL, previousSettings.OIDCConnectDiscoveryURL))
|
||
req.OIDCConnectAuthorizeURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectAuthorizeURL, previousSettings.OIDCConnectAuthorizeURL))
|
||
req.OIDCConnectTokenURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectTokenURL, previousSettings.OIDCConnectTokenURL))
|
||
req.OIDCConnectUserInfoURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoURL, previousSettings.OIDCConnectUserInfoURL))
|
||
req.OIDCConnectJWKSURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectJWKSURL, previousSettings.OIDCConnectJWKSURL))
|
||
req.OIDCConnectScopes = strings.TrimSpace(firstNonEmpty(req.OIDCConnectScopes, previousSettings.OIDCConnectScopes, "openid email profile"))
|
||
req.OIDCConnectRedirectURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectRedirectURL, previousSettings.OIDCConnectRedirectURL))
|
||
req.OIDCConnectFrontendRedirectURL = strings.TrimSpace(firstNonEmpty(req.OIDCConnectFrontendRedirectURL, previousSettings.OIDCConnectFrontendRedirectURL, "/auth/oidc/callback"))
|
||
req.OIDCConnectTokenAuthMethod = strings.ToLower(strings.TrimSpace(firstNonEmpty(req.OIDCConnectTokenAuthMethod, previousSettings.OIDCConnectTokenAuthMethod, "client_secret_post")))
|
||
req.OIDCConnectAllowedSigningAlgs = strings.TrimSpace(firstNonEmpty(req.OIDCConnectAllowedSigningAlgs, previousSettings.OIDCConnectAllowedSigningAlgs, "RS256,ES256,PS256"))
|
||
req.OIDCConnectUserInfoEmailPath = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoEmailPath, previousSettings.OIDCConnectUserInfoEmailPath))
|
||
req.OIDCConnectUserInfoIDPath = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoIDPath, previousSettings.OIDCConnectUserInfoIDPath))
|
||
req.OIDCConnectUserInfoUsernamePath = strings.TrimSpace(firstNonEmpty(req.OIDCConnectUserInfoUsernamePath, previousSettings.OIDCConnectUserInfoUsernamePath))
|
||
if req.OIDCConnectUsePKCE != nil {
|
||
oidcUsePKCE = *req.OIDCConnectUsePKCE
|
||
}
|
||
if req.OIDCConnectValidateIDToken != nil {
|
||
oidcValidateIDToken = *req.OIDCConnectValidateIDToken
|
||
}
|
||
if req.OIDCConnectClockSkewSeconds == 0 {
|
||
req.OIDCConnectClockSkewSeconds = previousSettings.OIDCConnectClockSkewSeconds
|
||
if req.OIDCConnectClockSkewSeconds == 0 {
|
||
req.OIDCConnectClockSkewSeconds = 120
|
||
}
|
||
}
|
||
|
||
if req.OIDCConnectClientID == "" {
|
||
response.BadRequest(c, "OIDC Client ID is required when enabled")
|
||
return
|
||
}
|
||
if req.OIDCConnectIssuerURL == "" {
|
||
response.BadRequest(c, "OIDC Issuer URL is required when enabled")
|
||
return
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectIssuerURL); err != nil {
|
||
response.BadRequest(c, "OIDC Issuer URL must be an absolute http(s) URL")
|
||
return
|
||
}
|
||
if req.OIDCConnectDiscoveryURL != "" {
|
||
if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectDiscoveryURL); err != nil {
|
||
response.BadRequest(c, "OIDC Discovery URL must be an absolute http(s) URL")
|
||
return
|
||
}
|
||
}
|
||
if req.OIDCConnectAuthorizeURL != "" {
|
||
if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectAuthorizeURL); err != nil {
|
||
response.BadRequest(c, "OIDC Authorize URL must be an absolute http(s) URL")
|
||
return
|
||
}
|
||
}
|
||
if req.OIDCConnectTokenURL != "" {
|
||
if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectTokenURL); err != nil {
|
||
response.BadRequest(c, "OIDC Token URL must be an absolute http(s) URL")
|
||
return
|
||
}
|
||
}
|
||
if req.OIDCConnectUserInfoURL != "" {
|
||
if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectUserInfoURL); err != nil {
|
||
response.BadRequest(c, "OIDC UserInfo URL must be an absolute http(s) URL")
|
||
return
|
||
}
|
||
}
|
||
if req.OIDCConnectRedirectURL == "" {
|
||
response.BadRequest(c, "OIDC Redirect URL is required when enabled")
|
||
return
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectRedirectURL); err != nil {
|
||
response.BadRequest(c, "OIDC Redirect URL must be an absolute http(s) URL")
|
||
return
|
||
}
|
||
if req.OIDCConnectFrontendRedirectURL == "" {
|
||
response.BadRequest(c, "OIDC Frontend Redirect URL is required when enabled")
|
||
return
|
||
}
|
||
if err := config.ValidateFrontendRedirectURL(req.OIDCConnectFrontendRedirectURL); err != nil {
|
||
response.BadRequest(c, "OIDC Frontend Redirect URL is invalid")
|
||
return
|
||
}
|
||
if !scopesContainOpenID(req.OIDCConnectScopes) {
|
||
response.BadRequest(c, "OIDC scopes must contain openid")
|
||
return
|
||
}
|
||
switch req.OIDCConnectTokenAuthMethod {
|
||
case "", "client_secret_post", "client_secret_basic", "none":
|
||
default:
|
||
response.BadRequest(c, "OIDC Token Auth Method must be one of client_secret_post/client_secret_basic/none")
|
||
return
|
||
}
|
||
if req.OIDCConnectClockSkewSeconds < 0 || req.OIDCConnectClockSkewSeconds > 600 {
|
||
response.BadRequest(c, "OIDC clock skew seconds must be between 0 and 600")
|
||
return
|
||
}
|
||
if oidcValidateIDToken && req.OIDCConnectAllowedSigningAlgs == "" {
|
||
response.BadRequest(c, "OIDC Allowed Signing Algs is required when validate_id_token=true")
|
||
return
|
||
}
|
||
if req.OIDCConnectJWKSURL != "" {
|
||
if err := config.ValidateAbsoluteHTTPURL(req.OIDCConnectJWKSURL); err != nil {
|
||
response.BadRequest(c, "OIDC JWKS URL must be an absolute http(s) URL")
|
||
return
|
||
}
|
||
}
|
||
if req.OIDCConnectTokenAuthMethod == "" || req.OIDCConnectTokenAuthMethod == "client_secret_post" || req.OIDCConnectTokenAuthMethod == "client_secret_basic" {
|
||
if req.OIDCConnectClientSecret == "" {
|
||
if previousSettings.OIDCConnectClientSecret == "" {
|
||
response.BadRequest(c, "OIDC Client Secret is required when enabled")
|
||
return
|
||
}
|
||
req.OIDCConnectClientSecret = previousSettings.OIDCConnectClientSecret
|
||
}
|
||
}
|
||
}
|
||
|
||
// “购买订阅”页面配置验证
|
||
purchaseEnabled := previousSettings.PurchaseSubscriptionEnabled
|
||
if req.PurchaseSubscriptionEnabled != nil {
|
||
purchaseEnabled = *req.PurchaseSubscriptionEnabled
|
||
}
|
||
purchaseURL := previousSettings.PurchaseSubscriptionURL
|
||
if req.PurchaseSubscriptionURL != nil {
|
||
purchaseURL = strings.TrimSpace(*req.PurchaseSubscriptionURL)
|
||
}
|
||
|
||
// - 启用时要求 URL 合法且非空
|
||
// - 禁用时允许为空;若提供了 URL 也做基本校验,避免误配置
|
||
if purchaseEnabled {
|
||
if purchaseURL == "" {
|
||
response.BadRequest(c, "Purchase Subscription URL is required when enabled")
|
||
return
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(purchaseURL); err != nil {
|
||
response.BadRequest(c, "Purchase Subscription URL must be an absolute http(s) URL")
|
||
return
|
||
}
|
||
} else if purchaseURL != "" {
|
||
if err := config.ValidateAbsoluteHTTPURL(purchaseURL); err != nil {
|
||
response.BadRequest(c, "Purchase Subscription URL must be an absolute http(s) URL")
|
||
return
|
||
}
|
||
}
|
||
|
||
// Frontend URL 验证
|
||
req.FrontendURL = strings.TrimSpace(req.FrontendURL)
|
||
if req.FrontendURL != "" {
|
||
if err := config.ValidateAbsoluteHTTPURL(req.FrontendURL); err != nil {
|
||
response.BadRequest(c, "Frontend URL must be an absolute http(s) URL")
|
||
return
|
||
}
|
||
}
|
||
|
||
// 自定义菜单项验证
|
||
const (
|
||
maxCustomMenuItems = 20
|
||
maxMenuItemLabelLen = 50
|
||
maxMenuItemURLLen = 2048
|
||
maxMenuItemIconSVGLen = 10 * 1024 // 10KB
|
||
maxMenuItemIDLen = 32
|
||
)
|
||
|
||
customMenuJSON := previousSettings.CustomMenuItems
|
||
if req.CustomMenuItems != nil {
|
||
items := *req.CustomMenuItems
|
||
if len(items) > maxCustomMenuItems {
|
||
response.BadRequest(c, "Too many custom menu items (max 20)")
|
||
return
|
||
}
|
||
for i, item := range items {
|
||
if strings.TrimSpace(item.Label) == "" {
|
||
response.BadRequest(c, "Custom menu item label is required")
|
||
return
|
||
}
|
||
if len(item.Label) > maxMenuItemLabelLen {
|
||
response.BadRequest(c, "Custom menu item label is too long (max 50 characters)")
|
||
return
|
||
}
|
||
urlTrimmed := strings.TrimSpace(item.URL)
|
||
if strings.HasPrefix(urlTrimmed, "md:") {
|
||
// Markdown page mode: URL = "md:<slug>"
|
||
slug := strings.TrimPrefix(urlTrimmed, "md:")
|
||
if slug == "" {
|
||
response.BadRequest(c, "Custom menu item markdown slug cannot be empty (use md:slug format)")
|
||
return
|
||
}
|
||
} else {
|
||
if urlTrimmed == "" {
|
||
response.BadRequest(c, "Custom menu item URL is required (use md:slug for markdown pages)")
|
||
return
|
||
}
|
||
if len(item.URL) > maxMenuItemURLLen {
|
||
response.BadRequest(c, "Custom menu item URL is too long (max 2048 characters)")
|
||
return
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(urlTrimmed); err != nil {
|
||
response.BadRequest(c, "Custom menu item URL must be an absolute http(s) URL or md:<slug>")
|
||
return
|
||
}
|
||
}
|
||
if item.Visibility != "user" && item.Visibility != "admin" {
|
||
response.BadRequest(c, "Custom menu item visibility must be 'user' or 'admin'")
|
||
return
|
||
}
|
||
if len(item.IconSVG) > maxMenuItemIconSVGLen {
|
||
response.BadRequest(c, "Custom menu item icon SVG is too large (max 10KB)")
|
||
return
|
||
}
|
||
// Auto-generate ID if missing
|
||
if strings.TrimSpace(item.ID) == "" {
|
||
id, err := generateMenuItemID()
|
||
if err != nil {
|
||
response.Error(c, http.StatusInternalServerError, "Failed to generate menu item ID")
|
||
return
|
||
}
|
||
items[i].ID = id
|
||
} else if len(item.ID) > maxMenuItemIDLen {
|
||
response.BadRequest(c, "Custom menu item ID is too long (max 32 characters)")
|
||
return
|
||
} else if !menuItemIDPattern.MatchString(item.ID) {
|
||
response.BadRequest(c, "Custom menu item ID contains invalid characters (only a-z, A-Z, 0-9, - and _ are allowed)")
|
||
return
|
||
}
|
||
}
|
||
// ID uniqueness check
|
||
seen := make(map[string]struct{}, len(items))
|
||
for _, item := range items {
|
||
if _, exists := seen[item.ID]; exists {
|
||
response.BadRequest(c, "Duplicate custom menu item ID: "+item.ID)
|
||
return
|
||
}
|
||
seen[item.ID] = struct{}{}
|
||
}
|
||
menuBytes, err := json.Marshal(items)
|
||
if err != nil {
|
||
response.BadRequest(c, "Failed to serialize custom menu items")
|
||
return
|
||
}
|
||
customMenuJSON = string(menuBytes)
|
||
}
|
||
|
||
// 自定义端点验证
|
||
const (
|
||
maxCustomEndpoints = 10
|
||
maxEndpointNameLen = 50
|
||
maxEndpointURLLen = 2048
|
||
maxEndpointDescriptionLen = 200
|
||
)
|
||
|
||
customEndpointsJSON := previousSettings.CustomEndpoints
|
||
if req.CustomEndpoints != nil {
|
||
endpoints := *req.CustomEndpoints
|
||
if len(endpoints) > maxCustomEndpoints {
|
||
response.BadRequest(c, "Too many custom endpoints (max 10)")
|
||
return
|
||
}
|
||
for _, ep := range endpoints {
|
||
if strings.TrimSpace(ep.Name) == "" {
|
||
response.BadRequest(c, "Custom endpoint name is required")
|
||
return
|
||
}
|
||
if len(ep.Name) > maxEndpointNameLen {
|
||
response.BadRequest(c, "Custom endpoint name is too long (max 50 characters)")
|
||
return
|
||
}
|
||
if strings.TrimSpace(ep.Endpoint) == "" {
|
||
response.BadRequest(c, "Custom endpoint URL is required")
|
||
return
|
||
}
|
||
if len(ep.Endpoint) > maxEndpointURLLen {
|
||
response.BadRequest(c, "Custom endpoint URL is too long (max 2048 characters)")
|
||
return
|
||
}
|
||
if err := config.ValidateAbsoluteHTTPURL(strings.TrimSpace(ep.Endpoint)); err != nil {
|
||
response.BadRequest(c, "Custom endpoint URL must be an absolute http(s) URL")
|
||
return
|
||
}
|
||
if len(ep.Description) > maxEndpointDescriptionLen {
|
||
response.BadRequest(c, "Custom endpoint description is too long (max 200 characters)")
|
||
return
|
||
}
|
||
}
|
||
endpointBytes, err := json.Marshal(endpoints)
|
||
if err != nil {
|
||
response.BadRequest(c, "Failed to serialize custom endpoints")
|
||
return
|
||
}
|
||
customEndpointsJSON = string(endpointBytes)
|
||
}
|
||
|
||
// Ops metrics collector interval validation (seconds).
|
||
if req.OpsMetricsIntervalSeconds != nil {
|
||
v := *req.OpsMetricsIntervalSeconds
|
||
if v < 60 {
|
||
v = 60
|
||
}
|
||
if v > 3600 {
|
||
v = 3600
|
||
}
|
||
req.OpsMetricsIntervalSeconds = &v
|
||
}
|
||
defaultSubscriptions := make([]service.DefaultSubscriptionSetting, 0, len(req.DefaultSubscriptions))
|
||
for _, sub := range req.DefaultSubscriptions {
|
||
defaultSubscriptions = append(defaultSubscriptions, service.DefaultSubscriptionSetting{
|
||
GroupID: sub.GroupID,
|
||
ValidityDays: sub.ValidityDays,
|
||
})
|
||
}
|
||
|
||
// 验证最低版本号格式(空字符串=禁用,或合法 semver)
|
||
if req.MinClaudeCodeVersion != "" {
|
||
if !semverPattern.MatchString(req.MinClaudeCodeVersion) {
|
||
response.Error(c, http.StatusBadRequest, "min_claude_code_version must be empty or a valid semver (e.g. 2.1.63)")
|
||
return
|
||
}
|
||
}
|
||
|
||
// 验证最高版本号格式(空字符串=禁用,或合法 semver)
|
||
if req.MaxClaudeCodeVersion != "" {
|
||
if !semverPattern.MatchString(req.MaxClaudeCodeVersion) {
|
||
response.Error(c, http.StatusBadRequest, "max_claude_code_version must be empty or a valid semver (e.g. 3.0.0)")
|
||
return
|
||
}
|
||
}
|
||
if req.AntigravityUserAgentVersion != nil {
|
||
normalized := strings.TrimSpace(*req.AntigravityUserAgentVersion)
|
||
req.AntigravityUserAgentVersion = &normalized
|
||
if normalized != "" && !semverPattern.MatchString(normalized) {
|
||
response.Error(c, http.StatusBadRequest, "antigravity_user_agent_version must be empty or a valid semver (e.g. 1.23.2)")
|
||
return
|
||
}
|
||
}
|
||
if req.OpenAICodexUserAgent != nil {
|
||
normalized := strings.TrimSpace(*req.OpenAICodexUserAgent)
|
||
req.OpenAICodexUserAgent = &normalized
|
||
// 仅做长度上限保护,不限制具体格式(运维需要可自由调整 codex 版本号)
|
||
if len(normalized) > 512 {
|
||
response.Error(c, http.StatusBadRequest, "openai_codex_user_agent must be at most 512 characters")
|
||
return
|
||
}
|
||
}
|
||
if req.OpenAICodexClientVersion != nil {
|
||
// 该值会被拼进出站 User-Agent 与 version 头,必须是合法版本号;空串表示跟随自动同步。
|
||
normalized := strings.TrimSpace(*req.OpenAICodexClientVersion)
|
||
if normalized != "" && service.NormalizeCodexClientVersion(normalized) == "" {
|
||
response.Error(c, http.StatusBadRequest, "openai_codex_client_version must be empty or a valid version (e.g. 0.146.0)")
|
||
return
|
||
}
|
||
req.OpenAICodexClientVersion = &normalized
|
||
}
|
||
|
||
// codex_cli_only 加固:最低/最高 Codex 版本(空=禁用,或合法 semver;max>=min)
|
||
if req.MinCodexVersion != "" && !semverPattern.MatchString(req.MinCodexVersion) {
|
||
response.Error(c, http.StatusBadRequest, "min_codex_version must be empty or a valid semver (e.g. 0.141.0)")
|
||
return
|
||
}
|
||
if req.MaxCodexVersion != "" && !semverPattern.MatchString(req.MaxCodexVersion) {
|
||
response.Error(c, http.StatusBadRequest, "max_codex_version must be empty or a valid semver (e.g. 0.200.0)")
|
||
return
|
||
}
|
||
if req.MinCodexVersion != "" && req.MaxCodexVersion != "" && service.CompareVersions(req.MaxCodexVersion, req.MinCodexVersion) < 0 {
|
||
response.Error(c, http.StatusBadRequest, "max_codex_version must be greater than or equal to min_codex_version")
|
||
return
|
||
}
|
||
// codex_cli_only 黑/白名单:非空须为合法 []AllowedClientEntry JSON。
|
||
// 黑名单 OR 宽 deny(允许 originator-only);白名单双因子 AND,额外要求每条可命中(非空 originator + ua_contains)。
|
||
if err := service.ValidateCodexClientEntriesJSON(req.CodexCLIOnlyBlacklist); err != nil {
|
||
response.Error(c, http.StatusBadRequest, "codex_cli_only_blacklist "+err.Error())
|
||
return
|
||
}
|
||
if err := service.ValidateCodexWhitelistEntriesJSON(req.CodexCLIOnlyWhitelist); err != nil {
|
||
response.Error(c, http.StatusBadRequest, "codex_cli_only_whitelist "+err.Error())
|
||
return
|
||
}
|
||
if err := service.ValidateEngineFingerprintSignalsJSON(req.CodexCLIOnlyEngineFingerprintSignals); err != nil {
|
||
response.Error(c, http.StatusBadRequest, "codex_cli_only_engine_fingerprint_signals "+err.Error())
|
||
return
|
||
}
|
||
|
||
// 交叉验证:如果同时设置了最低和最高版本号,最高版本号必须 >= 最低版本号
|
||
if req.MinClaudeCodeVersion != "" && req.MaxClaudeCodeVersion != "" {
|
||
if service.CompareVersions(req.MaxClaudeCodeVersion, req.MinClaudeCodeVersion) < 0 {
|
||
response.Error(c, http.StatusBadRequest, "max_claude_code_version must be greater than or equal to min_claude_code_version")
|
||
return
|
||
}
|
||
}
|
||
|
||
// cyber 会话屏蔽 TTL 校验:提供时必须 > 0
|
||
if req.CyberSessionBlockTTLSeconds != nil && *req.CyberSessionBlockTTLSeconds <= 0 {
|
||
response.BadRequest(c, "cyber_session_block_ttl_seconds must be > 0")
|
||
return
|
||
}
|
||
|
||
settings := &service.SystemSettings{
|
||
// 系统全局 platform quota 默认值(整体替换语义)
|
||
DefaultPlatformQuotas: req.DefaultPlatformQuotas,
|
||
AccountSchedulingThresholds: req.AccountSchedulingThresholds,
|
||
|
||
RegistrationEnabled: req.RegistrationEnabled,
|
||
EmailVerifyEnabled: req.EmailVerifyEnabled,
|
||
RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist,
|
||
RegistrationEmailDomainQuotaEnabled: registrationEmailDomainQuotaEnabled,
|
||
PromoCodeEnabled: req.PromoCodeEnabled,
|
||
PasswordResetEnabled: req.PasswordResetEnabled,
|
||
FrontendURL: req.FrontendURL,
|
||
InvitationCodeEnabled: req.InvitationCodeEnabled,
|
||
TotpEnabled: req.TotpEnabled,
|
||
PasskeyEnabled: passkeyEnabled,
|
||
SessionBindingEnabled: sessionBindingEnabled,
|
||
StepUpEnabled: stepUpEnabled,
|
||
AuditLogRetentionDays: req.AuditLogRetentionDays,
|
||
LoginAgreementEnabled: req.LoginAgreementEnabled,
|
||
LoginAgreementMode: loginAgreementMode,
|
||
LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
|
||
LoginAgreementDocuments: loginAgreementDocuments,
|
||
SMTPHost: req.SMTPHost,
|
||
SMTPPort: req.SMTPPort,
|
||
SMTPUsername: req.SMTPUsername,
|
||
SMTPPassword: req.SMTPPassword,
|
||
SMTPFrom: req.SMTPFrom,
|
||
SMTPFromName: req.SMTPFromName,
|
||
SMTPUseTLS: req.SMTPUseTLS,
|
||
TurnstileEnabled: req.TurnstileEnabled,
|
||
TurnstileSiteKey: req.TurnstileSiteKey,
|
||
TurnstileSecretKey: req.TurnstileSecretKey,
|
||
TencentCaptchaEnabled: req.TencentCaptchaEnabled,
|
||
TencentCaptchaAppID: req.TencentCaptchaAppID,
|
||
TencentCaptchaAppSecretKey: req.TencentCaptchaAppSecretKey,
|
||
TencentCaptchaCloudSecretID: req.TencentCaptchaCloudSecretID,
|
||
TencentCaptchaCloudSecretKey: req.TencentCaptchaCloudSecretKey,
|
||
TencentCaptchaRegion: req.TencentCaptchaRegion,
|
||
AliyunCaptchaEnabled: req.AliyunCaptchaEnabled,
|
||
AliyunCaptchaAccessKeyID: req.AliyunCaptchaAccessKeyID,
|
||
AliyunCaptchaAccessKeySecret: req.AliyunCaptchaAccessKeySecret,
|
||
AliyunCaptchaSceneID: req.AliyunCaptchaSceneID,
|
||
AliyunCaptchaPrefix: req.AliyunCaptchaPrefix,
|
||
AliyunCaptchaRegion: req.AliyunCaptchaRegion,
|
||
APIKeyACLTrustForwardedIP: func() bool {
|
||
if req.APIKeyACLTrustForwardedIP != nil {
|
||
return *req.APIKeyACLTrustForwardedIP
|
||
}
|
||
return previousSettings.APIKeyACLTrustForwardedIP
|
||
}(),
|
||
ForwardedClientIPHeaders: forwardedClientIPHeaders,
|
||
LinuxDoConnectEnabled: req.LinuxDoConnectEnabled,
|
||
LinuxDoConnectClientID: req.LinuxDoConnectClientID,
|
||
LinuxDoConnectClientSecret: req.LinuxDoConnectClientSecret,
|
||
LinuxDoConnectRedirectURL: req.LinuxDoConnectRedirectURL,
|
||
DingTalkConnectEnabled: req.DingTalkConnectEnabled,
|
||
DingTalkConnectClientID: req.DingTalkConnectClientID,
|
||
DingTalkConnectClientSecret: req.DingTalkConnectClientSecret,
|
||
DingTalkConnectRedirectURL: req.DingTalkConnectRedirectURL,
|
||
DingTalkConnectCorpRestrictionPolicy: req.DingTalkConnectCorpRestrictionPolicy,
|
||
DingTalkConnectInternalCorpID: req.DingTalkConnectInternalCorpID,
|
||
DingTalkConnectBypassRegistration: req.DingTalkConnectBypassRegistration,
|
||
DingTalkConnectSyncCorpEmail: req.DingTalkConnectSyncCorpEmail,
|
||
DingTalkConnectSyncDisplayName: req.DingTalkConnectSyncDisplayName,
|
||
DingTalkConnectSyncDept: req.DingTalkConnectSyncDept,
|
||
DingTalkConnectSyncCorpEmailAttrKey: req.DingTalkConnectSyncCorpEmailAttrKey,
|
||
DingTalkConnectSyncDisplayNameAttrKey: req.DingTalkConnectSyncDisplayNameAttrKey,
|
||
DingTalkConnectSyncDeptAttrKey: req.DingTalkConnectSyncDeptAttrKey,
|
||
DingTalkConnectSyncCorpEmailAttrName: req.DingTalkConnectSyncCorpEmailAttrName,
|
||
DingTalkConnectSyncDisplayNameAttrName: req.DingTalkConnectSyncDisplayNameAttrName,
|
||
DingTalkConnectSyncDeptAttrName: req.DingTalkConnectSyncDeptAttrName,
|
||
WeChatConnectEnabled: req.WeChatConnectEnabled,
|
||
WeChatConnectAppID: req.WeChatConnectAppID,
|
||
WeChatConnectAppSecret: req.WeChatConnectAppSecret,
|
||
WeChatConnectOpenAppID: req.WeChatConnectOpenAppID,
|
||
WeChatConnectOpenAppSecret: req.WeChatConnectOpenAppSecret,
|
||
WeChatConnectMPAppID: req.WeChatConnectMPAppID,
|
||
WeChatConnectMPAppSecret: req.WeChatConnectMPAppSecret,
|
||
WeChatConnectMobileAppID: req.WeChatConnectMobileAppID,
|
||
WeChatConnectMobileAppSecret: req.WeChatConnectMobileAppSecret,
|
||
WeChatConnectOpenEnabled: req.WeChatConnectOpenEnabled,
|
||
WeChatConnectMPEnabled: req.WeChatConnectMPEnabled,
|
||
WeChatConnectMobileEnabled: req.WeChatConnectMobileEnabled,
|
||
WeChatConnectMode: req.WeChatConnectMode,
|
||
WeChatConnectScopes: req.WeChatConnectScopes,
|
||
WeChatConnectRedirectURL: req.WeChatConnectRedirectURL,
|
||
WeChatConnectFrontendRedirectURL: req.WeChatConnectFrontendRedirectURL,
|
||
OIDCConnectEnabled: req.OIDCConnectEnabled,
|
||
OIDCConnectProviderName: req.OIDCConnectProviderName,
|
||
OIDCConnectClientID: req.OIDCConnectClientID,
|
||
OIDCConnectClientSecret: req.OIDCConnectClientSecret,
|
||
OIDCConnectIssuerURL: req.OIDCConnectIssuerURL,
|
||
OIDCConnectDiscoveryURL: req.OIDCConnectDiscoveryURL,
|
||
OIDCConnectAuthorizeURL: req.OIDCConnectAuthorizeURL,
|
||
OIDCConnectTokenURL: req.OIDCConnectTokenURL,
|
||
OIDCConnectUserInfoURL: req.OIDCConnectUserInfoURL,
|
||
OIDCConnectJWKSURL: req.OIDCConnectJWKSURL,
|
||
OIDCConnectScopes: req.OIDCConnectScopes,
|
||
OIDCConnectRedirectURL: req.OIDCConnectRedirectURL,
|
||
OIDCConnectFrontendRedirectURL: req.OIDCConnectFrontendRedirectURL,
|
||
OIDCConnectTokenAuthMethod: req.OIDCConnectTokenAuthMethod,
|
||
OIDCConnectUsePKCE: oidcUsePKCE,
|
||
OIDCConnectValidateIDToken: oidcValidateIDToken,
|
||
OIDCConnectAllowedSigningAlgs: req.OIDCConnectAllowedSigningAlgs,
|
||
OIDCConnectClockSkewSeconds: req.OIDCConnectClockSkewSeconds,
|
||
OIDCConnectRequireEmailVerified: req.OIDCConnectRequireEmailVerified,
|
||
OIDCConnectUserInfoEmailPath: req.OIDCConnectUserInfoEmailPath,
|
||
OIDCConnectUserInfoIDPath: req.OIDCConnectUserInfoIDPath,
|
||
OIDCConnectUserInfoUsernamePath: req.OIDCConnectUserInfoUsernamePath,
|
||
GitHubOAuthEnabled: req.GitHubOAuthEnabled,
|
||
GitHubOAuthClientID: req.GitHubOAuthClientID,
|
||
GitHubOAuthClientSecret: req.GitHubOAuthClientSecret,
|
||
GitHubOAuthRedirectURL: req.GitHubOAuthRedirectURL,
|
||
GitHubOAuthFrontendRedirectURL: req.GitHubOAuthFrontendRedirectURL,
|
||
GoogleOAuthEnabled: req.GoogleOAuthEnabled,
|
||
GoogleOAuthClientID: req.GoogleOAuthClientID,
|
||
GoogleOAuthClientSecret: req.GoogleOAuthClientSecret,
|
||
GoogleOAuthRedirectURL: req.GoogleOAuthRedirectURL,
|
||
GoogleOAuthFrontendRedirectURL: req.GoogleOAuthFrontendRedirectURL,
|
||
SiteName: req.SiteName,
|
||
SiteLogo: req.SiteLogo,
|
||
SiteSubtitle: req.SiteSubtitle,
|
||
APIBaseURL: req.APIBaseURL,
|
||
ContactInfo: req.ContactInfo,
|
||
DocURL: req.DocURL,
|
||
HomeContent: req.HomeContent,
|
||
CompactHomeEnabled: req.CompactHomeEnabled,
|
||
HideCcsImportButton: req.HideCcsImportButton,
|
||
PurchaseSubscriptionEnabled: purchaseEnabled,
|
||
PurchaseSubscriptionURL: purchaseURL,
|
||
TableDefaultPageSize: req.TableDefaultPageSize,
|
||
TablePageSizeOptions: req.TablePageSizeOptions,
|
||
CustomMenuItems: customMenuJSON,
|
||
CustomEndpoints: customEndpointsJSON,
|
||
DefaultConcurrency: req.DefaultConcurrency,
|
||
DefaultBalance: req.DefaultBalance,
|
||
AffiliateRebateRate: affiliateRebateRate,
|
||
AffiliateRebateFreezeHours: affiliateRebateFreezeHours,
|
||
AffiliateRebateDurationDays: affiliateRebateDurationDays,
|
||
AffiliateRebatePerInviteeCap: affiliateRebatePerInviteeCap,
|
||
AdminRechargeRebateEnabled: adminRechargeRebateEnabled,
|
||
DefaultUserRPMLimit: req.DefaultUserRPMLimit,
|
||
DefaultSubscriptions: defaultSubscriptions,
|
||
EnableModelFallback: req.EnableModelFallback,
|
||
FallbackModelAnthropic: req.FallbackModelAnthropic,
|
||
FallbackModelOpenAI: req.FallbackModelOpenAI,
|
||
FallbackModelGemini: req.FallbackModelGemini,
|
||
FallbackModelAntigravity: req.FallbackModelAntigravity,
|
||
EnableIdentityPatch: req.EnableIdentityPatch,
|
||
IdentityPatchPrompt: req.IdentityPatchPrompt,
|
||
MinClaudeCodeVersion: req.MinClaudeCodeVersion,
|
||
MaxClaudeCodeVersion: req.MaxClaudeCodeVersion,
|
||
AllowUngroupedKeyScheduling: req.AllowUngroupedKeyScheduling,
|
||
BackendModeEnabled: req.BackendModeEnabled,
|
||
AllowUserViewErrorRequests: func() bool {
|
||
if req.AllowUserViewErrorRequests != nil {
|
||
return *req.AllowUserViewErrorRequests
|
||
}
|
||
return previousSettings.AllowUserViewErrorRequests
|
||
}(),
|
||
OpsMonitoringEnabled: func() bool {
|
||
if req.OpsMonitoringEnabled != nil {
|
||
return *req.OpsMonitoringEnabled
|
||
}
|
||
return previousSettings.OpsMonitoringEnabled
|
||
}(),
|
||
OpsRealtimeMonitoringEnabled: func() bool {
|
||
if req.OpsRealtimeMonitoringEnabled != nil {
|
||
return *req.OpsRealtimeMonitoringEnabled
|
||
}
|
||
return previousSettings.OpsRealtimeMonitoringEnabled
|
||
}(),
|
||
OpsQueryModeDefault: func() string {
|
||
if req.OpsQueryModeDefault != nil {
|
||
return *req.OpsQueryModeDefault
|
||
}
|
||
return previousSettings.OpsQueryModeDefault
|
||
}(),
|
||
OpsMetricsIntervalSeconds: func() int {
|
||
if req.OpsMetricsIntervalSeconds != nil {
|
||
return *req.OpsMetricsIntervalSeconds
|
||
}
|
||
return previousSettings.OpsMetricsIntervalSeconds
|
||
}(),
|
||
EnableFingerprintUnification: func() bool {
|
||
if req.EnableFingerprintUnification != nil {
|
||
return *req.EnableFingerprintUnification
|
||
}
|
||
return previousSettings.EnableFingerprintUnification
|
||
}(),
|
||
EnableMetadataPassthrough: func() bool {
|
||
if req.EnableMetadataPassthrough != nil {
|
||
return *req.EnableMetadataPassthrough
|
||
}
|
||
return previousSettings.EnableMetadataPassthrough
|
||
}(),
|
||
EnableCCHSigning: func() bool {
|
||
if req.EnableCCHSigning != nil {
|
||
return *req.EnableCCHSigning
|
||
}
|
||
return previousSettings.EnableCCHSigning
|
||
}(),
|
||
EnableClaudeOAuthSystemPromptInjection: func() bool {
|
||
if req.EnableClaudeOAuthSystemPromptInjection != nil {
|
||
return *req.EnableClaudeOAuthSystemPromptInjection
|
||
}
|
||
return previousSettings.EnableClaudeOAuthSystemPromptInjection
|
||
}(),
|
||
ClaudeOAuthSystemPrompt: func() string {
|
||
if req.ClaudeOAuthSystemPrompt != nil {
|
||
return *req.ClaudeOAuthSystemPrompt
|
||
}
|
||
return previousSettings.ClaudeOAuthSystemPrompt
|
||
}(),
|
||
ClaudeOAuthSystemPromptBlocks: func() string {
|
||
if req.ClaudeOAuthSystemPromptBlocks != nil {
|
||
return *req.ClaudeOAuthSystemPromptBlocks
|
||
}
|
||
return previousSettings.ClaudeOAuthSystemPromptBlocks
|
||
}(),
|
||
EnableAnthropicCacheTTL1hInjection: func() bool {
|
||
if req.EnableAnthropicCacheTTL1hInjection != nil {
|
||
return *req.EnableAnthropicCacheTTL1hInjection
|
||
}
|
||
return previousSettings.EnableAnthropicCacheTTL1hInjection
|
||
}(),
|
||
RewriteMessageCacheControl: func() bool {
|
||
if req.RewriteMessageCacheControl != nil {
|
||
return *req.RewriteMessageCacheControl
|
||
}
|
||
return previousSettings.RewriteMessageCacheControl
|
||
}(),
|
||
EnableClientDatelineNormalization: func() bool {
|
||
if req.EnableClientDatelineNormalization != nil {
|
||
return *req.EnableClientDatelineNormalization
|
||
}
|
||
return previousSettings.EnableClientDatelineNormalization
|
||
}(),
|
||
AntigravityUserAgentVersion: func() string {
|
||
if req.AntigravityUserAgentVersion != nil {
|
||
return *req.AntigravityUserAgentVersion
|
||
}
|
||
return previousSettings.AntigravityUserAgentVersion
|
||
}(),
|
||
OpenAICodexUserAgent: func() string {
|
||
if req.OpenAICodexUserAgent != nil {
|
||
return *req.OpenAICodexUserAgent
|
||
}
|
||
return previousSettings.OpenAICodexUserAgent
|
||
}(),
|
||
OpenAICodexClientVersion: func() string {
|
||
if req.OpenAICodexClientVersion != nil {
|
||
return *req.OpenAICodexClientVersion
|
||
}
|
||
return previousSettings.OpenAICodexClientVersion
|
||
}(),
|
||
// 同步值由自动同步任务独占写入,面板保存时原样带回,避免被清空。
|
||
OpenAICodexClientVersionSynced: previousSettings.OpenAICodexClientVersionSynced,
|
||
OpenAICodexVersionAutoSyncEnabled: func() bool {
|
||
if req.OpenAICodexVersionAutoSyncEnabled != nil {
|
||
return *req.OpenAICodexVersionAutoSyncEnabled
|
||
}
|
||
return previousSettings.OpenAICodexVersionAutoSyncEnabled
|
||
}(),
|
||
MinCodexVersion: strings.TrimSpace(req.MinCodexVersion),
|
||
MaxCodexVersion: strings.TrimSpace(req.MaxCodexVersion),
|
||
CodexCLIOnlyBlacklist: strings.TrimSpace(req.CodexCLIOnlyBlacklist),
|
||
CodexCLIOnlyWhitelist: strings.TrimSpace(req.CodexCLIOnlyWhitelist),
|
||
CodexCLIOnlyAllowAppServerClients: func() bool {
|
||
if req.CodexCLIOnlyAllowAppServerClients != nil {
|
||
return *req.CodexCLIOnlyAllowAppServerClients
|
||
}
|
||
return previousSettings.CodexCLIOnlyAllowAppServerClients
|
||
}(),
|
||
CodexCLIOnlyEngineFingerprintSignals: strings.TrimSpace(req.CodexCLIOnlyEngineFingerprintSignals),
|
||
PaymentVisibleMethodAlipaySource: func() string {
|
||
if req.PaymentVisibleMethodAlipaySource != nil {
|
||
return strings.TrimSpace(*req.PaymentVisibleMethodAlipaySource)
|
||
}
|
||
return previousSettings.PaymentVisibleMethodAlipaySource
|
||
}(),
|
||
PaymentVisibleMethodWxpaySource: func() string {
|
||
if req.PaymentVisibleMethodWxpaySource != nil {
|
||
return strings.TrimSpace(*req.PaymentVisibleMethodWxpaySource)
|
||
}
|
||
return previousSettings.PaymentVisibleMethodWxpaySource
|
||
}(),
|
||
PaymentVisibleMethodAlipayEnabled: func() bool {
|
||
if req.PaymentVisibleMethodAlipayEnabled != nil {
|
||
return *req.PaymentVisibleMethodAlipayEnabled
|
||
}
|
||
return previousSettings.PaymentVisibleMethodAlipayEnabled
|
||
}(),
|
||
PaymentVisibleMethodWxpayEnabled: func() bool {
|
||
if req.PaymentVisibleMethodWxpayEnabled != nil {
|
||
return *req.PaymentVisibleMethodWxpayEnabled
|
||
}
|
||
return previousSettings.PaymentVisibleMethodWxpayEnabled
|
||
}(),
|
||
OpenAILowUpstreamRatePriorityEnabled: func() bool {
|
||
if req.OpenAILowUpstreamRatePriorityEnabled != nil {
|
||
return *req.OpenAILowUpstreamRatePriorityEnabled
|
||
}
|
||
return previousSettings.OpenAILowUpstreamRatePriorityEnabled
|
||
}(),
|
||
OpenAIOAuthSchedulingRateMultiplier: func() float64 {
|
||
if req.OpenAIOAuthSchedulingRateMultiplier != nil {
|
||
return *req.OpenAIOAuthSchedulingRateMultiplier
|
||
}
|
||
return previousSettings.OpenAIOAuthSchedulingRateMultiplier
|
||
}(),
|
||
OpenAIAdvancedSchedulerEnabled: func() bool {
|
||
if req.OpenAIAdvancedSchedulerEnabled != nil {
|
||
return *req.OpenAIAdvancedSchedulerEnabled
|
||
}
|
||
return previousSettings.OpenAIAdvancedSchedulerEnabled
|
||
}(),
|
||
OpenAIAdvancedSchedulerStickyWeightedEnabled: func() bool {
|
||
if req.OpenAIAdvancedSchedulerStickyWeightedEnabled != nil {
|
||
return *req.OpenAIAdvancedSchedulerStickyWeightedEnabled
|
||
}
|
||
return previousSettings.OpenAIAdvancedSchedulerStickyWeightedEnabled
|
||
}(),
|
||
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: func() bool {
|
||
if req.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled != nil {
|
||
return *req.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled
|
||
}
|
||
return previousSettings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled
|
||
}(),
|
||
OpenAIAdvancedSchedulerLBTopK: stringSetting(req.OpenAIAdvancedSchedulerLBTopK, previousSettings.OpenAIAdvancedSchedulerLBTopK),
|
||
OpenAIAdvancedSchedulerWeightPriority: stringSetting(req.OpenAIAdvancedSchedulerWeightPriority, previousSettings.OpenAIAdvancedSchedulerWeightPriority),
|
||
OpenAIAdvancedSchedulerWeightLoad: stringSetting(req.OpenAIAdvancedSchedulerWeightLoad, previousSettings.OpenAIAdvancedSchedulerWeightLoad),
|
||
OpenAIAdvancedSchedulerWeightQueue: stringSetting(req.OpenAIAdvancedSchedulerWeightQueue, previousSettings.OpenAIAdvancedSchedulerWeightQueue),
|
||
OpenAIAdvancedSchedulerWeightErrorRate: stringSetting(req.OpenAIAdvancedSchedulerWeightErrorRate, previousSettings.OpenAIAdvancedSchedulerWeightErrorRate),
|
||
OpenAIAdvancedSchedulerWeightTTFT: stringSetting(req.OpenAIAdvancedSchedulerWeightTTFT, previousSettings.OpenAIAdvancedSchedulerWeightTTFT),
|
||
OpenAIAdvancedSchedulerWeightReset: stringSetting(req.OpenAIAdvancedSchedulerWeightReset, previousSettings.OpenAIAdvancedSchedulerWeightReset),
|
||
OpenAIAdvancedSchedulerWeightQuotaHeadroom: stringSetting(req.OpenAIAdvancedSchedulerWeightQuotaHeadroom, previousSettings.OpenAIAdvancedSchedulerWeightQuotaHeadroom),
|
||
OpenAIAdvancedSchedulerWeightUpstreamCost: stringSetting(req.OpenAIAdvancedSchedulerWeightUpstreamCost, previousSettings.OpenAIAdvancedSchedulerWeightUpstreamCost),
|
||
OpenAIAdvancedSchedulerWeightPreviousResponse: stringSetting(req.OpenAIAdvancedSchedulerWeightPreviousResponse, previousSettings.OpenAIAdvancedSchedulerWeightPreviousResponse),
|
||
OpenAIAdvancedSchedulerWeightSessionSticky: stringSetting(req.OpenAIAdvancedSchedulerWeightSessionSticky, previousSettings.OpenAIAdvancedSchedulerWeightSessionSticky),
|
||
BalanceLowNotifyEnabled: func() bool {
|
||
if req.BalanceLowNotifyEnabled != nil {
|
||
return *req.BalanceLowNotifyEnabled
|
||
}
|
||
return previousSettings.BalanceLowNotifyEnabled
|
||
}(),
|
||
BalanceLowNotifyThreshold: func() float64 {
|
||
if req.BalanceLowNotifyThreshold != nil {
|
||
return *req.BalanceLowNotifyThreshold
|
||
}
|
||
return previousSettings.BalanceLowNotifyThreshold
|
||
}(),
|
||
BalanceLowNotifyRechargeURL: func() string {
|
||
if req.BalanceLowNotifyRechargeURL != nil {
|
||
return *req.BalanceLowNotifyRechargeURL
|
||
}
|
||
return previousSettings.BalanceLowNotifyRechargeURL
|
||
}(),
|
||
SubscriptionExpiryNotifyEnabled: func() bool {
|
||
if req.SubscriptionExpiryNotifyEnabled != nil {
|
||
return *req.SubscriptionExpiryNotifyEnabled
|
||
}
|
||
return previousSettings.SubscriptionExpiryNotifyEnabled
|
||
}(),
|
||
AccountQuotaNotifyEnabled: func() bool {
|
||
if req.AccountQuotaNotifyEnabled != nil {
|
||
return *req.AccountQuotaNotifyEnabled
|
||
}
|
||
return previousSettings.AccountQuotaNotifyEnabled
|
||
}(),
|
||
AccountQuotaNotifyEmails: func() []service.NotifyEmailEntry {
|
||
if req.AccountQuotaNotifyEmails != nil {
|
||
return dto.NotifyEmailEntriesToService(*req.AccountQuotaNotifyEmails)
|
||
}
|
||
return previousSettings.AccountQuotaNotifyEmails
|
||
}(),
|
||
ChannelMonitorEnabled: func() bool {
|
||
if req.ChannelMonitorEnabled != nil {
|
||
return *req.ChannelMonitorEnabled
|
||
}
|
||
return previousSettings.ChannelMonitorEnabled
|
||
}(),
|
||
ChannelMonitorMode: func() string {
|
||
if req.ChannelMonitorMode != nil {
|
||
return *req.ChannelMonitorMode
|
||
}
|
||
return previousSettings.ChannelMonitorMode
|
||
}(),
|
||
ChannelMonitorDefaultIntervalSeconds: func() int {
|
||
if req.ChannelMonitorDefaultIntervalSeconds != nil {
|
||
return *req.ChannelMonitorDefaultIntervalSeconds
|
||
}
|
||
return previousSettings.ChannelMonitorDefaultIntervalSeconds
|
||
}(),
|
||
ChannelMonitorHideThroughput: func() bool {
|
||
if req.ChannelMonitorHideThroughput != nil {
|
||
return *req.ChannelMonitorHideThroughput
|
||
}
|
||
return previousSettings.ChannelMonitorHideThroughput
|
||
}(),
|
||
ChannelMonitorShowQuota: func() bool {
|
||
if req.ChannelMonitorShowQuota != nil {
|
||
return *req.ChannelMonitorShowQuota
|
||
}
|
||
return previousSettings.ChannelMonitorShowQuota
|
||
}(),
|
||
GrokDefaultTextModel: func() string {
|
||
if req.GrokDefaultTextModel != nil {
|
||
return *req.GrokDefaultTextModel
|
||
}
|
||
return previousSettings.GrokDefaultTextModel
|
||
}(),
|
||
GrokCrossClientModelMapEnabled: func() bool {
|
||
if req.GrokCrossClientModelMapEnabled != nil {
|
||
return *req.GrokCrossClientModelMapEnabled
|
||
}
|
||
return previousSettings.GrokCrossClientModelMapEnabled
|
||
}(),
|
||
GrokDefaultBaseURLMode: func() string {
|
||
if req.GrokDefaultBaseURLMode != nil {
|
||
return strings.TrimSpace(*req.GrokDefaultBaseURLMode)
|
||
}
|
||
return previousSettings.GrokDefaultBaseURLMode
|
||
}(),
|
||
AvailableChannelsEnabled: func() bool {
|
||
if req.AvailableChannelsEnabled != nil {
|
||
return *req.AvailableChannelsEnabled
|
||
}
|
||
return previousSettings.AvailableChannelsEnabled
|
||
}(),
|
||
ModelPlazaEnabled: func() bool {
|
||
if req.ModelPlazaEnabled != nil {
|
||
return *req.ModelPlazaEnabled
|
||
}
|
||
return previousSettings.ModelPlazaEnabled
|
||
}(),
|
||
ModelPlazaRequireAuth: func() bool {
|
||
if req.ModelPlazaRequireAuth != nil {
|
||
return *req.ModelPlazaRequireAuth
|
||
}
|
||
return previousSettings.ModelPlazaRequireAuth
|
||
}(),
|
||
ModelPlazaDescription: func() string {
|
||
if req.ModelPlazaDescription != nil {
|
||
return *req.ModelPlazaDescription
|
||
}
|
||
return previousSettings.ModelPlazaDescription
|
||
}(),
|
||
AffiliateEnabled: func() bool {
|
||
if req.AffiliateEnabled != nil {
|
||
return *req.AffiliateEnabled
|
||
}
|
||
return previousSettings.AffiliateEnabled
|
||
}(),
|
||
RiskControlEnabled: func() bool {
|
||
if req.RiskControlEnabled != nil {
|
||
return *req.RiskControlEnabled
|
||
}
|
||
return previousSettings.RiskControlEnabled
|
||
}(),
|
||
CyberSessionBlockEnabled: func() bool {
|
||
if req.CyberSessionBlockEnabled != nil {
|
||
return *req.CyberSessionBlockEnabled
|
||
}
|
||
return previousSettings.CyberSessionBlockEnabled
|
||
}(),
|
||
CyberSessionBlockTTLSeconds: func() int {
|
||
if req.CyberSessionBlockTTLSeconds != nil {
|
||
return *req.CyberSessionBlockTTLSeconds
|
||
}
|
||
return previousSettings.CyberSessionBlockTTLSeconds
|
||
}(),
|
||
}
|
||
|
||
// req.AuthSourceXxxPlatformQuotas 为 nil 表示本次请求未包含该 source 的 quota 配置(保留 previousAuthSourceDefaults 中的值);
|
||
// non-nil(含 empty map)表示整体覆盖:empty map = 清空该 source 的所有 quota 配置。
|
||
authSourceDefaults := &service.AuthSourceDefaultSettings{
|
||
Email: service.ProviderDefaultGrantSettings{
|
||
Balance: float64ValueOrDefault(req.AuthSourceDefaultEmailBalance, previousAuthSourceDefaults.Email.Balance),
|
||
Concurrency: intValueOrDefault(req.AuthSourceDefaultEmailConcurrency, previousAuthSourceDefaults.Email.Concurrency),
|
||
Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultEmailSubscriptions, previousAuthSourceDefaults.Email.Subscriptions),
|
||
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultEmailGrantOnSignup, previousAuthSourceDefaults.Email.GrantOnSignup),
|
||
GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultEmailGrantOnFirstBind, previousAuthSourceDefaults.Email.GrantOnFirstBind),
|
||
PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceEmailPlatformQuotas, previousAuthSourceDefaults.Email.PlatformQuotas),
|
||
},
|
||
LinuxDo: service.ProviderDefaultGrantSettings{
|
||
Balance: float64ValueOrDefault(req.AuthSourceDefaultLinuxDoBalance, previousAuthSourceDefaults.LinuxDo.Balance),
|
||
Concurrency: intValueOrDefault(req.AuthSourceDefaultLinuxDoConcurrency, previousAuthSourceDefaults.LinuxDo.Concurrency),
|
||
Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultLinuxDoSubscriptions, previousAuthSourceDefaults.LinuxDo.Subscriptions),
|
||
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultLinuxDoGrantOnSignup, previousAuthSourceDefaults.LinuxDo.GrantOnSignup),
|
||
GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultLinuxDoGrantOnFirstBind, previousAuthSourceDefaults.LinuxDo.GrantOnFirstBind),
|
||
PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceLinuxDoPlatformQuotas, previousAuthSourceDefaults.LinuxDo.PlatformQuotas),
|
||
},
|
||
OIDC: service.ProviderDefaultGrantSettings{
|
||
Balance: float64ValueOrDefault(req.AuthSourceDefaultOIDCBalance, previousAuthSourceDefaults.OIDC.Balance),
|
||
Concurrency: intValueOrDefault(req.AuthSourceDefaultOIDCConcurrency, previousAuthSourceDefaults.OIDC.Concurrency),
|
||
Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultOIDCSubscriptions, previousAuthSourceDefaults.OIDC.Subscriptions),
|
||
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultOIDCGrantOnSignup, previousAuthSourceDefaults.OIDC.GrantOnSignup),
|
||
GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultOIDCGrantOnFirstBind, previousAuthSourceDefaults.OIDC.GrantOnFirstBind),
|
||
PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceOIDCPlatformQuotas, previousAuthSourceDefaults.OIDC.PlatformQuotas),
|
||
},
|
||
WeChat: service.ProviderDefaultGrantSettings{
|
||
Balance: float64ValueOrDefault(req.AuthSourceDefaultWeChatBalance, previousAuthSourceDefaults.WeChat.Balance),
|
||
Concurrency: intValueOrDefault(req.AuthSourceDefaultWeChatConcurrency, previousAuthSourceDefaults.WeChat.Concurrency),
|
||
Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultWeChatSubscriptions, previousAuthSourceDefaults.WeChat.Subscriptions),
|
||
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultWeChatGrantOnSignup, previousAuthSourceDefaults.WeChat.GrantOnSignup),
|
||
GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultWeChatGrantOnFirstBind, previousAuthSourceDefaults.WeChat.GrantOnFirstBind),
|
||
PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceWeChatPlatformQuotas, previousAuthSourceDefaults.WeChat.PlatformQuotas),
|
||
},
|
||
GitHub: service.ProviderDefaultGrantSettings{
|
||
Balance: float64ValueOrDefault(req.AuthSourceDefaultGitHubBalance, previousAuthSourceDefaults.GitHub.Balance),
|
||
Concurrency: intValueOrDefault(req.AuthSourceDefaultGitHubConcurrency, previousAuthSourceDefaults.GitHub.Concurrency),
|
||
Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultGitHubSubscriptions, previousAuthSourceDefaults.GitHub.Subscriptions),
|
||
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultGitHubGrantOnSignup, previousAuthSourceDefaults.GitHub.GrantOnSignup),
|
||
GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultGitHubGrantOnFirstBind, previousAuthSourceDefaults.GitHub.GrantOnFirstBind),
|
||
PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceGitHubPlatformQuotas, previousAuthSourceDefaults.GitHub.PlatformQuotas),
|
||
},
|
||
Google: service.ProviderDefaultGrantSettings{
|
||
Balance: float64ValueOrDefault(req.AuthSourceDefaultGoogleBalance, previousAuthSourceDefaults.Google.Balance),
|
||
Concurrency: intValueOrDefault(req.AuthSourceDefaultGoogleConcurrency, previousAuthSourceDefaults.Google.Concurrency),
|
||
Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultGoogleSubscriptions, previousAuthSourceDefaults.Google.Subscriptions),
|
||
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultGoogleGrantOnSignup, previousAuthSourceDefaults.Google.GrantOnSignup),
|
||
GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultGoogleGrantOnFirstBind, previousAuthSourceDefaults.Google.GrantOnFirstBind),
|
||
PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceGooglePlatformQuotas, previousAuthSourceDefaults.Google.PlatformQuotas),
|
||
},
|
||
DingTalk: service.ProviderDefaultGrantSettings{
|
||
Balance: float64ValueOrDefault(req.AuthSourceDefaultDingTalkBalance, previousAuthSourceDefaults.DingTalk.Balance),
|
||
Concurrency: intValueOrDefault(req.AuthSourceDefaultDingTalkConcurrency, previousAuthSourceDefaults.DingTalk.Concurrency),
|
||
Subscriptions: defaultSubscriptionsValueOrDefault(req.AuthSourceDefaultDingTalkSubscriptions, previousAuthSourceDefaults.DingTalk.Subscriptions),
|
||
GrantOnSignup: boolValueOrDefault(req.AuthSourceDefaultDingTalkGrantOnSignup, previousAuthSourceDefaults.DingTalk.GrantOnSignup),
|
||
GrantOnFirstBind: boolValueOrDefault(req.AuthSourceDefaultDingTalkGrantOnFirstBind, previousAuthSourceDefaults.DingTalk.GrantOnFirstBind),
|
||
PlatformQuotas: platformQuotasValueOrDefault(req.AuthSourceDingTalkPlatformQuotas, previousAuthSourceDefaults.DingTalk.PlatformQuotas),
|
||
},
|
||
ForceEmailOnThirdPartySignup: boolValueOrDefault(req.ForceEmailOnThirdPartySignup, previousAuthSourceDefaults.ForceEmailOnThirdPartySignup),
|
||
}
|
||
if err := h.settingService.UpdateSettingsWithAuthSourceDefaultsOmitting(c.Request.Context(), settings, authSourceDefaults, omitted); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
if h.opsService != nil {
|
||
h.opsService.SetMonitoringEnabled(settings.OpsMonitoringEnabled)
|
||
}
|
||
|
||
// Update OpenAI fast policy (stored under dedicated key, only when provided).
|
||
if req.OpenAIFastPolicySettings != nil {
|
||
if err := h.settingService.SetOpenAIFastPolicySettings(c.Request.Context(), openaiFastPolicySettingsFromDTO(req.OpenAIFastPolicySettings)); err != nil {
|
||
response.BadRequest(c, err.Error())
|
||
return
|
||
}
|
||
}
|
||
|
||
// Update payment configuration (integrated into system settings).
|
||
// Skip if no payment fields were provided (prevents accidental wipe).
|
||
if h.paymentConfigService != nil && hasPaymentFields(req) {
|
||
paymentReq := service.UpdatePaymentConfigRequest{
|
||
Enabled: req.PaymentEnabled,
|
||
MinAmount: req.PaymentMinAmount,
|
||
MaxAmount: req.PaymentMaxAmount,
|
||
DailyLimit: req.PaymentDailyLimit,
|
||
OrderTimeoutMin: req.PaymentOrderTimeoutMin,
|
||
MaxPendingOrders: req.PaymentMaxPendingOrders,
|
||
EnabledTypes: req.PaymentEnabledTypes,
|
||
BalanceDisabled: req.PaymentBalanceDisabled,
|
||
BalanceRechargeMultiplier: req.PaymentBalanceRechargeMultiplier,
|
||
SubscriptionUSDToCNYRate: req.PaymentSubscriptionUSDToCNYRate,
|
||
RechargeFeeRate: req.PaymentRechargeFeeRate,
|
||
LoadBalanceStrategy: req.PaymentLoadBalanceStrat,
|
||
ProductNamePrefix: req.PaymentProductNamePrefix,
|
||
ProductNameSuffix: req.PaymentProductNameSuffix,
|
||
HelpImageURL: req.PaymentHelpImageURL,
|
||
HelpText: req.PaymentHelpText,
|
||
CancelRateLimitEnabled: req.PaymentCancelRateLimitEnabled,
|
||
CancelRateLimitMax: req.PaymentCancelRateLimitMax,
|
||
CancelRateLimitWindow: req.PaymentCancelRateLimitWindow,
|
||
CancelRateLimitUnit: req.PaymentCancelRateLimitUnit,
|
||
CancelRateLimitMode: req.PaymentCancelRateLimitMode,
|
||
AlipayForceQRCode: req.PaymentAlipayForceQRCode,
|
||
AlipayMobilePrecreateDeepLink: req.PaymentAlipayMobilePrecreateDeepLink,
|
||
}
|
||
if err := h.paymentConfigService.UpdatePaymentConfig(c.Request.Context(), paymentReq); err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
// Refresh in-memory provider registry so config changes take effect immediately
|
||
if h.paymentService != nil {
|
||
h.paymentService.RefreshProviders(c.Request.Context())
|
||
}
|
||
}
|
||
|
||
h.auditSettingsUpdate(c, previousSettings, settings, previousAuthSourceDefaults, authSourceDefaults, auditReq)
|
||
|
||
// 重新获取设置返回
|
||
updatedSettings, err := h.settingService.GetAllSettings(c.Request.Context())
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
h.ensureDingTalkSyncAttributes(c.Request.Context(), updatedSettings)
|
||
updatedAuthSourceDefaults, err := h.settingService.GetAuthSourceDefaultSettings(c.Request.Context())
|
||
if err != nil {
|
||
response.ErrorFrom(c, err)
|
||
return
|
||
}
|
||
updatedDefaultSubscriptions := make([]dto.DefaultSubscriptionSetting, 0, len(updatedSettings.DefaultSubscriptions))
|
||
for _, sub := range updatedSettings.DefaultSubscriptions {
|
||
updatedDefaultSubscriptions = append(updatedDefaultSubscriptions, dto.DefaultSubscriptionSetting{
|
||
GroupID: sub.GroupID,
|
||
ValidityDays: sub.ValidityDays,
|
||
})
|
||
}
|
||
|
||
// Reload payment config for response
|
||
var updatedPaymentCfg *service.PaymentConfig
|
||
if h.paymentConfigService != nil {
|
||
updatedPaymentCfg, _ = h.paymentConfigService.GetPaymentConfig(c.Request.Context())
|
||
}
|
||
if updatedPaymentCfg == nil {
|
||
updatedPaymentCfg = &service.PaymentConfig{}
|
||
}
|
||
passkeyConfigured, passkeyRPID, passkeyRPOrigins := h.settingService.PasskeyConfiguration()
|
||
|
||
payload := dto.SystemSettings{
|
||
RegistrationEnabled: updatedSettings.RegistrationEnabled,
|
||
EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled,
|
||
RegistrationEmailSuffixWhitelist: updatedSettings.RegistrationEmailSuffixWhitelist,
|
||
RegistrationEmailDomainQuotaEnabled: updatedSettings.RegistrationEmailDomainQuotaEnabled,
|
||
PromoCodeEnabled: updatedSettings.PromoCodeEnabled,
|
||
PasswordResetEnabled: updatedSettings.PasswordResetEnabled,
|
||
FrontendURL: updatedSettings.FrontendURL,
|
||
InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled,
|
||
TotpEnabled: updatedSettings.TotpEnabled,
|
||
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
|
||
PasskeyEnabled: updatedSettings.PasskeyEnabled,
|
||
PasskeyConfigured: passkeyConfigured,
|
||
PasskeyRPID: passkeyRPID,
|
||
PasskeyRPOrigins: passkeyRPOrigins,
|
||
SessionBindingEnabled: updatedSettings.SessionBindingEnabled,
|
||
StepUpEnabled: updatedSettings.StepUpEnabled,
|
||
AuditLogRetentionDays: updatedSettings.AuditLogRetentionDays,
|
||
LoginAgreementEnabled: updatedSettings.LoginAgreementEnabled,
|
||
LoginAgreementMode: updatedSettings.LoginAgreementMode,
|
||
LoginAgreementUpdatedAt: updatedSettings.LoginAgreementUpdatedAt,
|
||
LoginAgreementDocuments: loginAgreementDocumentsToDTO(updatedSettings.LoginAgreementDocuments),
|
||
SMTPHost: updatedSettings.SMTPHost,
|
||
SMTPPort: updatedSettings.SMTPPort,
|
||
SMTPUsername: updatedSettings.SMTPUsername,
|
||
SMTPPasswordConfigured: updatedSettings.SMTPPasswordConfigured,
|
||
SMTPFrom: updatedSettings.SMTPFrom,
|
||
SMTPFromName: updatedSettings.SMTPFromName,
|
||
SMTPUseTLS: updatedSettings.SMTPUseTLS,
|
||
TurnstileEnabled: updatedSettings.TurnstileEnabled,
|
||
TurnstileSiteKey: updatedSettings.TurnstileSiteKey,
|
||
TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured,
|
||
TencentCaptchaEnabled: updatedSettings.TencentCaptchaEnabled,
|
||
TencentCaptchaAppID: updatedSettings.TencentCaptchaAppID,
|
||
TencentCaptchaAppSecretKeyConfigured: updatedSettings.TencentCaptchaAppSecretKeyConfigured,
|
||
TencentCaptchaCloudSecretIDConfigured: updatedSettings.TencentCaptchaCloudSecretIDConfigured,
|
||
TencentCaptchaCloudSecretKeyConfigured: updatedSettings.TencentCaptchaCloudSecretKeyConfigured,
|
||
TencentCaptchaRegion: updatedSettings.TencentCaptchaRegion,
|
||
AliyunCaptchaEnabled: updatedSettings.AliyunCaptchaEnabled,
|
||
AliyunCaptchaAccessKeyID: updatedSettings.AliyunCaptchaAccessKeyID,
|
||
AliyunCaptchaAccessKeySecretConfigured: updatedSettings.AliyunCaptchaAccessKeySecretConfigured,
|
||
AliyunCaptchaSceneID: updatedSettings.AliyunCaptchaSceneID,
|
||
AliyunCaptchaPrefix: updatedSettings.AliyunCaptchaPrefix,
|
||
AliyunCaptchaRegion: updatedSettings.AliyunCaptchaRegion,
|
||
APIKeyACLTrustForwardedIP: updatedSettings.APIKeyACLTrustForwardedIP,
|
||
ForwardedClientIPHeaders: updatedSettings.ForwardedClientIPHeaders,
|
||
LinuxDoConnectEnabled: updatedSettings.LinuxDoConnectEnabled,
|
||
LinuxDoConnectClientID: updatedSettings.LinuxDoConnectClientID,
|
||
LinuxDoConnectClientSecretConfigured: updatedSettings.LinuxDoConnectClientSecretConfigured,
|
||
LinuxDoConnectRedirectURL: updatedSettings.LinuxDoConnectRedirectURL,
|
||
DingTalkConnectEnabled: updatedSettings.DingTalkConnectEnabled,
|
||
DingTalkConnectClientID: updatedSettings.DingTalkConnectClientID,
|
||
DingTalkConnectClientSecretConfigured: updatedSettings.DingTalkConnectClientSecretConfigured,
|
||
DingTalkConnectRedirectURL: updatedSettings.DingTalkConnectRedirectURL,
|
||
DingTalkConnectCorpRestrictionPolicy: updatedSettings.DingTalkConnectCorpRestrictionPolicy,
|
||
DingTalkConnectInternalCorpID: updatedSettings.DingTalkConnectInternalCorpID,
|
||
DingTalkConnectBypassRegistration: updatedSettings.DingTalkConnectBypassRegistration,
|
||
DingTalkConnectSyncCorpEmail: updatedSettings.DingTalkConnectSyncCorpEmail,
|
||
DingTalkConnectSyncDisplayName: updatedSettings.DingTalkConnectSyncDisplayName,
|
||
DingTalkConnectSyncDept: updatedSettings.DingTalkConnectSyncDept,
|
||
DingTalkConnectSyncCorpEmailAttrKey: updatedSettings.DingTalkConnectSyncCorpEmailAttrKey,
|
||
DingTalkConnectSyncDisplayNameAttrKey: updatedSettings.DingTalkConnectSyncDisplayNameAttrKey,
|
||
DingTalkConnectSyncDeptAttrKey: updatedSettings.DingTalkConnectSyncDeptAttrKey,
|
||
DingTalkConnectSyncCorpEmailAttrName: updatedSettings.DingTalkConnectSyncCorpEmailAttrName,
|
||
DingTalkConnectSyncDisplayNameAttrName: updatedSettings.DingTalkConnectSyncDisplayNameAttrName,
|
||
DingTalkConnectSyncDeptAttrName: updatedSettings.DingTalkConnectSyncDeptAttrName,
|
||
WeChatConnectEnabled: updatedSettings.WeChatConnectEnabled,
|
||
WeChatConnectAppID: updatedSettings.WeChatConnectAppID,
|
||
WeChatConnectAppSecretConfigured: updatedSettings.WeChatConnectAppSecretConfigured,
|
||
WeChatConnectOpenAppID: updatedSettings.WeChatConnectOpenAppID,
|
||
WeChatConnectOpenAppSecretConfigured: updatedSettings.WeChatConnectOpenAppSecretConfigured,
|
||
WeChatConnectMPAppID: updatedSettings.WeChatConnectMPAppID,
|
||
WeChatConnectMPAppSecretConfigured: updatedSettings.WeChatConnectMPAppSecretConfigured,
|
||
WeChatConnectMobileAppID: updatedSettings.WeChatConnectMobileAppID,
|
||
WeChatConnectMobileAppSecretConfigured: updatedSettings.WeChatConnectMobileAppSecretConfigured,
|
||
WeChatConnectOpenEnabled: updatedSettings.WeChatConnectOpenEnabled,
|
||
WeChatConnectMPEnabled: updatedSettings.WeChatConnectMPEnabled,
|
||
WeChatConnectMobileEnabled: updatedSettings.WeChatConnectMobileEnabled,
|
||
WeChatConnectMode: updatedSettings.WeChatConnectMode,
|
||
WeChatConnectScopes: updatedSettings.WeChatConnectScopes,
|
||
WeChatConnectRedirectURL: updatedSettings.WeChatConnectRedirectURL,
|
||
WeChatConnectFrontendRedirectURL: updatedSettings.WeChatConnectFrontendRedirectURL,
|
||
OIDCConnectEnabled: updatedSettings.OIDCConnectEnabled,
|
||
OIDCConnectProviderName: updatedSettings.OIDCConnectProviderName,
|
||
OIDCConnectClientID: updatedSettings.OIDCConnectClientID,
|
||
OIDCConnectClientSecretConfigured: updatedSettings.OIDCConnectClientSecretConfigured,
|
||
OIDCConnectIssuerURL: updatedSettings.OIDCConnectIssuerURL,
|
||
OIDCConnectDiscoveryURL: updatedSettings.OIDCConnectDiscoveryURL,
|
||
OIDCConnectAuthorizeURL: updatedSettings.OIDCConnectAuthorizeURL,
|
||
OIDCConnectTokenURL: updatedSettings.OIDCConnectTokenURL,
|
||
OIDCConnectUserInfoURL: updatedSettings.OIDCConnectUserInfoURL,
|
||
OIDCConnectJWKSURL: updatedSettings.OIDCConnectJWKSURL,
|
||
OIDCConnectScopes: updatedSettings.OIDCConnectScopes,
|
||
OIDCConnectRedirectURL: updatedSettings.OIDCConnectRedirectURL,
|
||
OIDCConnectFrontendRedirectURL: updatedSettings.OIDCConnectFrontendRedirectURL,
|
||
OIDCConnectTokenAuthMethod: updatedSettings.OIDCConnectTokenAuthMethod,
|
||
OIDCConnectUsePKCE: updatedSettings.OIDCConnectUsePKCE,
|
||
OIDCConnectValidateIDToken: updatedSettings.OIDCConnectValidateIDToken,
|
||
OIDCConnectAllowedSigningAlgs: updatedSettings.OIDCConnectAllowedSigningAlgs,
|
||
OIDCConnectClockSkewSeconds: updatedSettings.OIDCConnectClockSkewSeconds,
|
||
OIDCConnectRequireEmailVerified: updatedSettings.OIDCConnectRequireEmailVerified,
|
||
OIDCConnectUserInfoEmailPath: updatedSettings.OIDCConnectUserInfoEmailPath,
|
||
OIDCConnectUserInfoIDPath: updatedSettings.OIDCConnectUserInfoIDPath,
|
||
OIDCConnectUserInfoUsernamePath: updatedSettings.OIDCConnectUserInfoUsernamePath,
|
||
GitHubOAuthEnabled: updatedSettings.GitHubOAuthEnabled,
|
||
GitHubOAuthClientID: updatedSettings.GitHubOAuthClientID,
|
||
GitHubOAuthClientSecretConfigured: updatedSettings.GitHubOAuthClientSecretConfigured,
|
||
GitHubOAuthRedirectURL: updatedSettings.GitHubOAuthRedirectURL,
|
||
GitHubOAuthFrontendRedirectURL: updatedSettings.GitHubOAuthFrontendRedirectURL,
|
||
GoogleOAuthEnabled: updatedSettings.GoogleOAuthEnabled,
|
||
GoogleOAuthClientID: updatedSettings.GoogleOAuthClientID,
|
||
GoogleOAuthClientSecretConfigured: updatedSettings.GoogleOAuthClientSecretConfigured,
|
||
GoogleOAuthRedirectURL: updatedSettings.GoogleOAuthRedirectURL,
|
||
GoogleOAuthFrontendRedirectURL: updatedSettings.GoogleOAuthFrontendRedirectURL,
|
||
SiteName: updatedSettings.SiteName,
|
||
SiteLogo: updatedSettings.SiteLogo,
|
||
SiteSubtitle: updatedSettings.SiteSubtitle,
|
||
APIBaseURL: updatedSettings.APIBaseURL,
|
||
ContactInfo: updatedSettings.ContactInfo,
|
||
DocURL: updatedSettings.DocURL,
|
||
HomeContent: updatedSettings.HomeContent,
|
||
CompactHomeEnabled: updatedSettings.CompactHomeEnabled,
|
||
HideCcsImportButton: updatedSettings.HideCcsImportButton,
|
||
PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled,
|
||
PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL,
|
||
TableDefaultPageSize: updatedSettings.TableDefaultPageSize,
|
||
TablePageSizeOptions: updatedSettings.TablePageSizeOptions,
|
||
CustomMenuItems: dto.ParseCustomMenuItems(updatedSettings.CustomMenuItems),
|
||
CustomEndpoints: dto.ParseCustomEndpoints(updatedSettings.CustomEndpoints),
|
||
DefaultConcurrency: updatedSettings.DefaultConcurrency,
|
||
DefaultBalance: updatedSettings.DefaultBalance,
|
||
AffiliateRebateRate: updatedSettings.AffiliateRebateRate,
|
||
AffiliateRebateFreezeHours: updatedSettings.AffiliateRebateFreezeHours,
|
||
AffiliateRebateDurationDays: updatedSettings.AffiliateRebateDurationDays,
|
||
AffiliateRebatePerInviteeCap: updatedSettings.AffiliateRebatePerInviteeCap,
|
||
AdminRechargeRebateEnabled: updatedSettings.AdminRechargeRebateEnabled,
|
||
DefaultUserRPMLimit: updatedSettings.DefaultUserRPMLimit,
|
||
DefaultSubscriptions: updatedDefaultSubscriptions,
|
||
EnableModelFallback: updatedSettings.EnableModelFallback,
|
||
FallbackModelAnthropic: updatedSettings.FallbackModelAnthropic,
|
||
FallbackModelOpenAI: updatedSettings.FallbackModelOpenAI,
|
||
FallbackModelGemini: updatedSettings.FallbackModelGemini,
|
||
FallbackModelAntigravity: updatedSettings.FallbackModelAntigravity,
|
||
EnableIdentityPatch: updatedSettings.EnableIdentityPatch,
|
||
IdentityPatchPrompt: updatedSettings.IdentityPatchPrompt,
|
||
OpsMonitoringEnabled: updatedSettings.OpsMonitoringEnabled,
|
||
OpsRealtimeMonitoringEnabled: updatedSettings.OpsRealtimeMonitoringEnabled,
|
||
OpsQueryModeDefault: updatedSettings.OpsQueryModeDefault,
|
||
OpsMetricsIntervalSeconds: updatedSettings.OpsMetricsIntervalSeconds,
|
||
MinClaudeCodeVersion: updatedSettings.MinClaudeCodeVersion,
|
||
MaxClaudeCodeVersion: updatedSettings.MaxClaudeCodeVersion,
|
||
AllowUngroupedKeyScheduling: updatedSettings.AllowUngroupedKeyScheduling,
|
||
BackendModeEnabled: updatedSettings.BackendModeEnabled,
|
||
EnableFingerprintUnification: updatedSettings.EnableFingerprintUnification,
|
||
EnableMetadataPassthrough: updatedSettings.EnableMetadataPassthrough,
|
||
EnableCCHSigning: updatedSettings.EnableCCHSigning,
|
||
EnableClaudeOAuthSystemPromptInjection: updatedSettings.EnableClaudeOAuthSystemPromptInjection,
|
||
ClaudeOAuthSystemPrompt: updatedSettings.ClaudeOAuthSystemPrompt,
|
||
ClaudeOAuthSystemPromptBlocks: updatedSettings.ClaudeOAuthSystemPromptBlocks,
|
||
EnableAnthropicCacheTTL1hInjection: updatedSettings.EnableAnthropicCacheTTL1hInjection,
|
||
RewriteMessageCacheControl: updatedSettings.RewriteMessageCacheControl,
|
||
EnableClientDatelineNormalization: updatedSettings.EnableClientDatelineNormalization,
|
||
AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion,
|
||
OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent,
|
||
OpenAICodexClientVersion: updatedSettings.OpenAICodexClientVersion,
|
||
OpenAICodexClientVersionSynced: updatedSettings.OpenAICodexClientVersionSynced,
|
||
OpenAICodexVersionAutoSyncEnabled: updatedSettings.OpenAICodexVersionAutoSyncEnabled,
|
||
MinCodexVersion: updatedSettings.MinCodexVersion,
|
||
MaxCodexVersion: updatedSettings.MaxCodexVersion,
|
||
CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist,
|
||
CodexCLIOnlyWhitelist: updatedSettings.CodexCLIOnlyWhitelist,
|
||
CodexCLIOnlyAllowAppServerClients: updatedSettings.CodexCLIOnlyAllowAppServerClients,
|
||
CodexCLIOnlyEngineFingerprintSignals: updatedSettings.CodexCLIOnlyEngineFingerprintSignals,
|
||
PaymentVisibleMethodAlipaySource: updatedSettings.PaymentVisibleMethodAlipaySource,
|
||
PaymentVisibleMethodWxpaySource: updatedSettings.PaymentVisibleMethodWxpaySource,
|
||
PaymentVisibleMethodAlipayEnabled: updatedSettings.PaymentVisibleMethodAlipayEnabled,
|
||
PaymentVisibleMethodWxpayEnabled: updatedSettings.PaymentVisibleMethodWxpayEnabled,
|
||
OpenAILowUpstreamRatePriorityEnabled: updatedSettings.OpenAILowUpstreamRatePriorityEnabled,
|
||
OpenAIOAuthSchedulingRateMultiplier: updatedSettings.OpenAIOAuthSchedulingRateMultiplier,
|
||
OpenAIAdvancedSchedulerEnabled: updatedSettings.OpenAIAdvancedSchedulerEnabled,
|
||
OpenAIAdvancedSchedulerStickyWeightedEnabled: updatedSettings.OpenAIAdvancedSchedulerStickyWeightedEnabled,
|
||
OpenAIAdvancedSchedulerSubscriptionPriorityEnabled: updatedSettings.OpenAIAdvancedSchedulerSubscriptionPriorityEnabled,
|
||
OpenAIAdvancedSchedulerLBTopK: updatedSettings.OpenAIAdvancedSchedulerLBTopK,
|
||
OpenAIAdvancedSchedulerWeightPriority: updatedSettings.OpenAIAdvancedSchedulerWeightPriority,
|
||
OpenAIAdvancedSchedulerWeightLoad: updatedSettings.OpenAIAdvancedSchedulerWeightLoad,
|
||
OpenAIAdvancedSchedulerWeightQueue: updatedSettings.OpenAIAdvancedSchedulerWeightQueue,
|
||
OpenAIAdvancedSchedulerWeightErrorRate: updatedSettings.OpenAIAdvancedSchedulerWeightErrorRate,
|
||
OpenAIAdvancedSchedulerWeightTTFT: updatedSettings.OpenAIAdvancedSchedulerWeightTTFT,
|
||
OpenAIAdvancedSchedulerWeightReset: updatedSettings.OpenAIAdvancedSchedulerWeightReset,
|
||
OpenAIAdvancedSchedulerWeightQuotaHeadroom: updatedSettings.OpenAIAdvancedSchedulerWeightQuotaHeadroom,
|
||
OpenAIAdvancedSchedulerWeightUpstreamCost: updatedSettings.OpenAIAdvancedSchedulerWeightUpstreamCost,
|
||
OpenAIAdvancedSchedulerWeightPreviousResponse: updatedSettings.OpenAIAdvancedSchedulerWeightPreviousResponse,
|
||
OpenAIAdvancedSchedulerWeightSessionSticky: updatedSettings.OpenAIAdvancedSchedulerWeightSessionSticky,
|
||
OpenAIAdvancedSchedulerEffectiveLBTopK: updatedSettings.OpenAIAdvancedSchedulerEffectiveLBTopK,
|
||
OpenAIAdvancedSchedulerEffectiveWeightPriority: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightPriority,
|
||
OpenAIAdvancedSchedulerEffectiveWeightLoad: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightLoad,
|
||
OpenAIAdvancedSchedulerEffectiveWeightQueue: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightQueue,
|
||
OpenAIAdvancedSchedulerEffectiveWeightErrorRate: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightErrorRate,
|
||
OpenAIAdvancedSchedulerEffectiveWeightTTFT: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightTTFT,
|
||
OpenAIAdvancedSchedulerEffectiveWeightReset: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightReset,
|
||
OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightQuotaHeadroom,
|
||
OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightUpstreamCost,
|
||
OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightPreviousResponse,
|
||
OpenAIAdvancedSchedulerEffectiveWeightSessionSticky: updatedSettings.OpenAIAdvancedSchedulerEffectiveWeightSessionSticky,
|
||
BalanceLowNotifyEnabled: updatedSettings.BalanceLowNotifyEnabled,
|
||
BalanceLowNotifyThreshold: updatedSettings.BalanceLowNotifyThreshold,
|
||
BalanceLowNotifyRechargeURL: updatedSettings.BalanceLowNotifyRechargeURL,
|
||
SubscriptionExpiryNotifyEnabled: updatedSettings.SubscriptionExpiryNotifyEnabled,
|
||
AccountQuotaNotifyEnabled: updatedSettings.AccountQuotaNotifyEnabled,
|
||
AccountQuotaNotifyEmails: dto.NotifyEmailEntriesFromService(updatedSettings.AccountQuotaNotifyEmails),
|
||
PaymentEnabled: updatedPaymentCfg.Enabled,
|
||
PaymentMinAmount: updatedPaymentCfg.MinAmount,
|
||
PaymentMaxAmount: updatedPaymentCfg.MaxAmount,
|
||
PaymentDailyLimit: updatedPaymentCfg.DailyLimit,
|
||
PaymentOrderTimeoutMin: updatedPaymentCfg.OrderTimeoutMin,
|
||
PaymentMaxPendingOrders: updatedPaymentCfg.MaxPendingOrders,
|
||
PaymentEnabledTypes: updatedPaymentCfg.EnabledTypes,
|
||
PaymentBalanceDisabled: updatedPaymentCfg.BalanceDisabled,
|
||
PaymentBalanceRechargeMultiplier: updatedPaymentCfg.BalanceRechargeMultiplier,
|
||
PaymentSubscriptionUSDToCNYRate: updatedPaymentCfg.SubscriptionUSDToCNYRate,
|
||
PaymentRechargeFeeRate: updatedPaymentCfg.RechargeFeeRate,
|
||
PaymentLoadBalanceStrat: updatedPaymentCfg.LoadBalanceStrategy,
|
||
PaymentProductNamePrefix: updatedPaymentCfg.ProductNamePrefix,
|
||
PaymentProductNameSuffix: updatedPaymentCfg.ProductNameSuffix,
|
||
PaymentHelpImageURL: updatedPaymentCfg.HelpImageURL,
|
||
PaymentHelpText: updatedPaymentCfg.HelpText,
|
||
PaymentCancelRateLimitEnabled: updatedPaymentCfg.CancelRateLimitEnabled,
|
||
PaymentCancelRateLimitMax: updatedPaymentCfg.CancelRateLimitMax,
|
||
PaymentCancelRateLimitWindow: updatedPaymentCfg.CancelRateLimitWindow,
|
||
PaymentCancelRateLimitUnit: updatedPaymentCfg.CancelRateLimitUnit,
|
||
PaymentCancelRateLimitMode: updatedPaymentCfg.CancelRateLimitMode,
|
||
PaymentAlipayForceQRCode: updatedPaymentCfg.AlipayForceQRCode,
|
||
PaymentAlipayMobilePrecreateDeepLink: updatedPaymentCfg.AlipayMobilePrecreateDeepLink,
|
||
|
||
ChannelMonitorEnabled: updatedSettings.ChannelMonitorEnabled,
|
||
ChannelMonitorMode: updatedSettings.ChannelMonitorMode,
|
||
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
|
||
ChannelMonitorHideThroughput: updatedSettings.ChannelMonitorHideThroughput,
|
||
ChannelMonitorShowQuota: updatedSettings.ChannelMonitorShowQuota,
|
||
|
||
GrokDefaultTextModel: updatedSettings.GrokDefaultTextModel,
|
||
GrokCrossClientModelMapEnabled: updatedSettings.GrokCrossClientModelMapEnabled,
|
||
GrokDefaultBaseURLMode: updatedSettings.GrokDefaultBaseURLMode,
|
||
|
||
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
|
||
|
||
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
|
||
ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth,
|
||
ModelPlazaDescription: updatedSettings.ModelPlazaDescription,
|
||
|
||
AffiliateEnabled: updatedSettings.AffiliateEnabled,
|
||
|
||
RiskControlEnabled: updatedSettings.RiskControlEnabled,
|
||
CyberSessionBlockEnabled: updatedSettings.CyberSessionBlockEnabled,
|
||
CyberSessionBlockTTLSeconds: updatedSettings.CyberSessionBlockTTLSeconds,
|
||
AccountSchedulingThresholds: updatedSettings.AccountSchedulingThresholds,
|
||
AllowUserViewErrorRequests: updatedSettings.AllowUserViewErrorRequests,
|
||
}
|
||
if fastPolicy, err := h.settingService.GetOpenAIFastPolicySettings(c.Request.Context()); err != nil {
|
||
slog.Error("openai_fast_policy_settings_get_failed", "error", err)
|
||
} else if fastPolicy != nil {
|
||
payload.OpenAIFastPolicySettings = openaiFastPolicySettingsToDTO(fastPolicy)
|
||
}
|
||
|
||
// Default platform quotas(JSON map)—— 与 GetSettings 一致,避免保存后响应缺失该字段
|
||
if platformQuotas, err := h.settingService.GetDefaultPlatformQuotas(c.Request.Context()); err != nil {
|
||
slog.Error("default_platform_quotas_get_failed", "error", err)
|
||
} else {
|
||
payload.DefaultPlatformQuotas = platformQuotas
|
||
}
|
||
response.Success(c, systemSettingsResponseData(payload, updatedAuthSourceDefaults))
|
||
}
|
||
|
||
// hasPaymentFields returns true if any payment-related field was explicitly provided.
|
||
// mapDingTalkValidateError maps ValidateDingTalkConfig errors to machine-readable reason codes.
|
||
func mapDingTalkValidateError(err error) string {
|
||
switch {
|
||
case errors.Is(err, config.ErrDingTalkV1AppTypeMismatch):
|
||
return "dingtalk_apptype_mismatch"
|
||
case errors.Is(err, config.ErrDingTalkV4InvalidAppKind):
|
||
return "dingtalk_app_kind_invalid"
|
||
default:
|
||
return "dingtalk_corp_config_invalid"
|
||
}
|
||
}
|
||
|
||
func hasPaymentFields(req UpdateSettingsRequest) bool {
|
||
return req.PaymentEnabled != nil || req.PaymentMinAmount != nil ||
|
||
req.PaymentMaxAmount != nil || req.PaymentDailyLimit != nil ||
|
||
req.PaymentOrderTimeoutMin != nil || req.PaymentMaxPendingOrders != nil ||
|
||
req.PaymentEnabledTypes != nil || req.PaymentBalanceDisabled != nil ||
|
||
req.PaymentBalanceRechargeMultiplier != nil || req.PaymentSubscriptionUSDToCNYRate != nil ||
|
||
req.PaymentRechargeFeeRate != nil ||
|
||
req.PaymentLoadBalanceStrat != nil || req.PaymentProductNamePrefix != nil ||
|
||
req.PaymentProductNameSuffix != nil || req.PaymentHelpImageURL != nil ||
|
||
req.PaymentHelpText != nil || req.PaymentCancelRateLimitEnabled != nil ||
|
||
req.PaymentCancelRateLimitMax != nil || req.PaymentCancelRateLimitWindow != nil ||
|
||
req.PaymentCancelRateLimitUnit != nil || req.PaymentCancelRateLimitMode != nil ||
|
||
req.PaymentAlipayForceQRCode != nil || req.PaymentAlipayMobilePrecreateDeepLink != nil
|
||
}
|
||
|
||
// ensureDingTalkSyncAttributes 在保存 settings 后,按 admin 配置的 (attr key, attr name)
|
||
// 兜底 upsert 对应 user attribute definition:不存在则创建;存在但 name 不同则更新 name
|
||
// (type/options/required 不变)。仅 internal_only + 对应 sync 开关开启时执行。
|
||
// 失败仅记录日志,不阻塞 settings 保存。
|
||
func (h *SettingHandler) ensureDingTalkSyncAttributes(ctx context.Context, settings *service.SystemSettings) {
|
||
if h.userAttributeService == nil || settings == nil {
|
||
return
|
||
}
|
||
if settings.DingTalkConnectCorpRestrictionPolicy != "internal_only" {
|
||
return
|
||
}
|
||
if settings.DingTalkConnectSyncDisplayName {
|
||
h.ensureUserAttributeDefinition(ctx, settings.DingTalkConnectSyncDisplayNameAttrKey, settings.DingTalkConnectSyncDisplayNameAttrName, "钉钉 internal_only 登录时同步的钉钉姓名", service.AttributeTypeText)
|
||
}
|
||
if settings.DingTalkConnectSyncCorpEmail {
|
||
h.ensureUserAttributeDefinition(ctx, settings.DingTalkConnectSyncCorpEmailAttrKey, settings.DingTalkConnectSyncCorpEmailAttrName, "钉钉 internal_only 登录时同步的企业邮箱", service.AttributeTypeEmail)
|
||
}
|
||
if settings.DingTalkConnectSyncDept {
|
||
h.ensureUserAttributeDefinition(ctx, settings.DingTalkConnectSyncDeptAttrKey, settings.DingTalkConnectSyncDeptAttrName, "钉钉 internal_only 登录时同步的完整部门路径(如:公司/研发部)", service.AttributeTypeText)
|
||
}
|
||
}
|
||
|
||
func (h *SettingHandler) ensureUserAttributeDefinition(ctx context.Context, key, name, description string, attrType service.UserAttributeType) {
|
||
key = strings.TrimSpace(key)
|
||
if key == "" {
|
||
return
|
||
}
|
||
existing, err := h.userAttributeService.GetDefinitionByKey(ctx, key)
|
||
if err == nil && existing != nil {
|
||
if strings.TrimSpace(name) != "" && existing.Name != name {
|
||
if _, err := h.userAttributeService.UpdateDefinition(ctx, existing.ID, service.UpdateAttributeDefinitionInput{
|
||
Name: &name,
|
||
}); err != nil {
|
||
slog.Warn("dingtalk: update user attribute definition name failed", "key", key, "err", err.Error())
|
||
return
|
||
}
|
||
slog.Info("dingtalk: updated user attribute definition name", "key", key, "name", name)
|
||
}
|
||
return
|
||
}
|
||
if _, err := h.userAttributeService.CreateDefinition(ctx, service.CreateAttributeDefinitionInput{
|
||
Key: key,
|
||
Name: name,
|
||
Description: description,
|
||
Type: attrType,
|
||
Enabled: true,
|
||
}); err != nil {
|
||
slog.Warn("dingtalk: ensure user attribute definition failed", "key", key, "err", err.Error())
|
||
return
|
||
}
|
||
slog.Info("dingtalk: created user attribute definition", "key", key, "name", name, "type", attrType)
|
||
}
|