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)
|
|||
|
|
}
|