From 563a72ca734774a7744f39876dbff4d3019c9be4 Mon Sep 17 00:00:00 2001
From: shaw
Date: Sun, 9 Aug 2026 15:53:40 +0800
Subject: [PATCH] feat: add default-off switch for email domain registration
quota
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
PR #5423 relaxed the email suffix whitelist: once a whitelist is
configured, non-whitelisted registrable domains are each allowed to
register one account. That behavior activated unconditionally.
Add registration_email_domain_quota_enabled (default false) to gate it:
- Off (default): restore pre-#5423 strict whitelist semantics — with a
non-empty whitelist, non-whitelisted domains are rejected with
EMAIL_SUFFIX_NOT_ALLOWED; the register/verify views restore the
client-side whitelist pre-check and allowed-domain hint.
- On: keep #5423 behavior — one account per non-whitelisted registrable
domain (EMAIL_DOMAIN_REGISTRATION_LIMIT).
- Empty whitelist keeps allowing all domains in both states.
Gating lives in validateRegistrationEmailQuota and (as a race-safety
backstop) createUserWithRegistrationEmailGuard; the repository-level
domain lock + in-tx recheck is unchanged. The admin update field is
*bool (omitted = keep current) so stale full-payload saves cannot
silently flip the switch. Email binding and OAuth auto-signup keep
their strict policy, and pending-OAuth bind-login for existing
accounts is unaffected because the handler resolves existing emails
before the quota check.
Frontend adds the toggle to admin settings (zh/en copy; whitelist hint
restored to strict wording, quota wording moved to the new toggle) and
exposes the flag via public settings + SSR injection payload.
Tests: #5423 quota tests now enable the switch explicitly; new
default-off regression tests cover register/send-code/async/pending
OAuth/OIDC create-account plus both register views; API contract JSON
and the injection drift guard are updated.
---
.../internal/handler/admin/setting_handler.go | 1 +
.../handler/admin/setting_handler_audit.go | 3 +
.../handler/admin/setting_handler_update.go | 115 ++++---
.../handler/auth_oauth_pending_flow_test.go | 57 ++-
backend/internal/handler/dto/settings.go | 146 ++++----
backend/internal/handler/setting_handler.go | 115 ++++---
backend/internal/server/api_contract_test.go | 2 +
.../service/auth_oauth_email_flow_test.go | 34 +-
backend/internal/service/auth_service.go | 8 +
.../service/auth_service_register_test.go | 96 +++++-
backend/internal/service/domain_constants.go | 39 ++-
.../service/registration_email_policy_test.go | 5 +-
backend/internal/service/setting_features.go | 10 +
backend/internal/service/setting_parse.go | 2 +
backend/internal/service/setting_public.go | 324 +++++++++---------
backend/internal/service/setting_update.go | 1 +
backend/internal/service/settings_view.go | 98 +++---
frontend/src/api/admin/settings.ts | 2 +
.../src/i18n/locales/en/admin/settings.ts | 7 +-
.../src/i18n/locales/zh/admin/settings.ts | 7 +-
frontend/src/types/index.ts | 1 +
frontend/src/views/admin/SettingsView.vue | 20 ++
.../admin/__tests__/SettingsView.spec.ts | 18 +-
frontend/src/views/auth/EmailVerifyView.vue | 49 ++-
frontend/src/views/auth/RegisterView.vue | 37 +-
.../auth/__tests__/EmailVerifyView.spec.ts | 36 ++
.../views/auth/__tests__/RegisterView.spec.ts | 47 ++-
27 files changed, 836 insertions(+), 444 deletions(-)
diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go
index 2c12398baa..a5f3d41d90 100644
--- a/backend/internal/handler/admin/setting_handler.go
+++ b/backend/internal/handler/admin/setting_handler.go
@@ -136,6 +136,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
+ RegistrationEmailDomainQuotaEnabled: settings.RegistrationEmailDomainQuotaEnabled,
PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled,
FrontendURL: settings.FrontendURL,
diff --git a/backend/internal/handler/admin/setting_handler_audit.go b/backend/internal/handler/admin/setting_handler_audit.go
index e5370bf299..d6110f43d5 100644
--- a/backend/internal/handler/admin/setting_handler_audit.go
+++ b/backend/internal/handler/admin/setting_handler_audit.go
@@ -41,6 +41,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if !equalStringSlice(before.RegistrationEmailSuffixWhitelist, after.RegistrationEmailSuffixWhitelist) {
changed = append(changed, "registration_email_suffix_whitelist")
}
+ if before.RegistrationEmailDomainQuotaEnabled != after.RegistrationEmailDomainQuotaEnabled {
+ changed = append(changed, "registration_email_domain_quota_enabled")
+ }
if before.PromoCodeEnabled != after.PromoCodeEnabled {
changed = append(changed, "promo_code_enabled")
}
diff --git a/backend/internal/handler/admin/setting_handler_update.go b/backend/internal/handler/admin/setting_handler_update.go
index 37971eb826..5884dc392e 100644
--- a/backend/internal/handler/admin/setting_handler_update.go
+++ b/backend/internal/handler/admin/setting_handler_update.go
@@ -23,22 +23,23 @@ import (
// UpdateSettingsRequest 更新设置请求
type UpdateSettingsRequest struct {
// 注册设置
- RegistrationEnabled bool `json:"registration_enabled"`
- EmailVerifyEnabled bool `json:"email_verify_enabled"`
- RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
- 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"`
+ 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"`
@@ -512,6 +513,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
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 {
@@ -1489,44 +1494,45 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
DefaultPlatformQuotas: req.DefaultPlatformQuotas,
AccountSchedulingThresholds: req.AccountSchedulingThresholds,
- RegistrationEnabled: req.RegistrationEnabled,
- EmailVerifyEnabled: req.EmailVerifyEnabled,
- RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist,
- 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,
+ 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
@@ -2102,6 +2108,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
RegistrationEnabled: updatedSettings.RegistrationEnabled,
EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: updatedSettings.RegistrationEmailSuffixWhitelist,
+ RegistrationEmailDomainQuotaEnabled: updatedSettings.RegistrationEmailDomainQuotaEnabled,
PromoCodeEnabled: updatedSettings.PromoCodeEnabled,
PasswordResetEnabled: updatedSettings.PasswordResetEnabled,
FrontendURL: updatedSettings.FrontendURL,
diff --git a/backend/internal/handler/auth_oauth_pending_flow_test.go b/backend/internal/handler/auth_oauth_pending_flow_test.go
index 20beb21f69..91a65d5f11 100644
--- a/backend/internal/handler/auth_oauth_pending_flow_test.go
+++ b/backend/internal/handler/auth_oauth_pending_flow_test.go
@@ -1473,7 +1473,8 @@ func TestCreateOIDCOAuthAccountRejectsSecondEmailOutsideRegistrationSuffixWhitel
},
},
settingValues: map[string]string{
- service.SettingKeyRegistrationEmailSuffixWhitelist: `["@qq.com"]`,
+ service.SettingKeyRegistrationEmailSuffixWhitelist: `["@qq.com"]`,
+ service.SettingKeyRegistrationEmailDomainQuotaEnabled: "true",
},
})
ctx := context.Background()
@@ -1520,6 +1521,60 @@ func TestCreateOIDCOAuthAccountRejectsSecondEmailOutsideRegistrationSuffixWhitel
require.Zero(t, count)
}
+// 域名限量注册开关默认关闭:白名单外域名保持 PR5423 之前的严格拒绝语义,
+// 即使该域名下还没有任何账户也不放行。
+func TestCreateOIDCOAuthAccountRejectsEmailOutsideWhitelistWhenQuotaDisabled(t *testing.T) {
+ handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
+ emailVerifyEnabled: true,
+ emailCache: &oauthPendingFlowEmailCacheStub{
+ verificationCodes: map[string]*service.VerificationCodeData{
+ "foo@gmail.com": {
+ Code: "135790",
+ CreatedAt: time.Now().UTC(),
+ ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
+ },
+ },
+ },
+ settingValues: map[string]string{
+ service.SettingKeyRegistrationEmailSuffixWhitelist: `["@qq.com"]`,
+ },
+ })
+ ctx := context.Background()
+
+ session, err := client.PendingAuthSession.Create().
+ SetSessionToken("suffix-strict-session-token").
+ SetIntent("login").
+ SetProviderType("oidc").
+ SetProviderKey("https://issuer.example").
+ SetProviderSubject("oidc-suffix-strict-123").
+ SetBrowserSessionKey("suffix-strict-browser-session-key").
+ SetUpstreamIdentityClaims(map[string]any{
+ "username": "oidc_user",
+ }).
+ SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
+ Save(ctx)
+ require.NoError(t, err)
+
+ body := bytes.NewBufferString(`{"email":"foo@gmail.com","verify_code":"135790","password":"secret-123"}`)
+ recorder := httptest.NewRecorder()
+ ginCtx, _ := gin.CreateTestContext(recorder)
+ req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", body)
+ req.Header.Set("Content-Type", "application/json")
+ req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
+ req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("suffix-strict-browser-session-key")})
+ ginCtx.Request = req
+
+ handler.CreateOIDCOAuthAccount(ginCtx)
+
+ require.Equal(t, http.StatusBadRequest, recorder.Code)
+ payload := decodeJSONBody(t, recorder)
+ require.Equal(t, "EMAIL_SUFFIX_NOT_ALLOWED", payload["reason"])
+
+ count, err := client.User.Query().Where(dbuser.EmailEQ("foo@gmail.com")).Count(ctx)
+ require.NoError(t, err)
+ require.Zero(t, count)
+}
+
func TestSendPendingOAuthVerifyCodeExistingEmailReturnsBindLoginState(t *testing.T) {
handler, client := newOAuthPendingFlowTestHandlerWithEmailVerification(t, false, "owner@example.com", "135790")
ctx := context.Background()
diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go
index 6e67cd2ac5..5ab5eaa68b 100644
--- a/backend/internal/handler/dto/settings.go
+++ b/backend/internal/handler/dto/settings.go
@@ -27,26 +27,27 @@ type CustomEndpoint struct {
// SystemSettings represents the admin settings API response payload.
type SystemSettings struct {
- RegistrationEnabled bool `json:"registration_enabled"`
- EmailVerifyEnabled bool `json:"email_verify_enabled"`
- RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
- 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 双因素认证
- TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置
- PasskeyEnabled bool `json:"passkey_enabled"`
- PasskeyConfigured bool `json:"passkey_configured"`
- PasskeyRPID string `json:"passkey_rp_id"`
- PasskeyRPOrigins []string `json:"passkey_rp_origins"`
- 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 []LoginAgreementDocument `json:"login_agreement_documents"`
+ 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 双因素认证
+ TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置
+ PasskeyEnabled bool `json:"passkey_enabled"`
+ PasskeyConfigured bool `json:"passkey_configured"`
+ PasskeyRPID string `json:"passkey_rp_id"`
+ PasskeyRPOrigins []string `json:"passkey_rp_origins"`
+ 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 []LoginAgreementDocument `json:"login_agreement_documents"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
@@ -347,58 +348,59 @@ type DefaultSubscriptionSetting struct {
}
type PublicSettings struct {
- RegistrationEnabled bool `json:"registration_enabled"`
- EmailVerifyEnabled bool `json:"email_verify_enabled"`
- ForceEmailOnThirdPartySignup bool `json:"force_email_on_third_party_signup"`
- RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
- PromoCodeEnabled bool `json:"promo_code_enabled"`
- PasswordResetEnabled bool `json:"password_reset_enabled"`
- InvitationCodeEnabled bool `json:"invitation_code_enabled"`
- TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
- PasskeyEnabled bool `json:"passkey_enabled"`
- LoginAgreementEnabled bool `json:"login_agreement_enabled"`
- LoginAgreementMode string `json:"login_agreement_mode"`
- LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
- LoginAgreementRevision string `json:"login_agreement_revision"`
- LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
- TurnstileEnabled bool `json:"turnstile_enabled"`
- TurnstileSiteKey string `json:"turnstile_site_key"`
- TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
- TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
- TencentCaptchaRegion string `json:"tencent_captcha_region"`
- AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
- AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"`
- AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"`
- AliyunCaptchaRegion string `json:"aliyun_captcha_region"`
- 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 []CustomMenuItem `json:"custom_menu_items"`
- CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
- DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"`
- LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
- WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
- WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
- WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
- WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
- OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
- OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
- GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
- GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
- SoraClientEnabled bool `json:"sora_client_enabled"`
- BackendModeEnabled bool `json:"backend_mode_enabled"`
- PaymentEnabled bool `json:"payment_enabled"`
- Version string `json:"version"`
+ RegistrationEnabled bool `json:"registration_enabled"`
+ EmailVerifyEnabled bool `json:"email_verify_enabled"`
+ ForceEmailOnThirdPartySignup bool `json:"force_email_on_third_party_signup"`
+ 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"`
+ InvitationCodeEnabled bool `json:"invitation_code_enabled"`
+ TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
+ PasskeyEnabled bool `json:"passkey_enabled"`
+ LoginAgreementEnabled bool `json:"login_agreement_enabled"`
+ LoginAgreementMode string `json:"login_agreement_mode"`
+ LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
+ LoginAgreementRevision string `json:"login_agreement_revision"`
+ LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
+ TurnstileEnabled bool `json:"turnstile_enabled"`
+ TurnstileSiteKey string `json:"turnstile_site_key"`
+ TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
+ TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
+ TencentCaptchaRegion string `json:"tencent_captcha_region"`
+ AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
+ AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"`
+ AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"`
+ AliyunCaptchaRegion string `json:"aliyun_captcha_region"`
+ 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 []CustomMenuItem `json:"custom_menu_items"`
+ CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
+ DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"`
+ LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
+ WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
+ WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
+ WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
+ WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
+ OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
+ OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
+ GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
+ GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
+ SoraClientEnabled bool `json:"sora_client_enabled"`
+ BackendModeEnabled bool `json:"backend_mode_enabled"`
+ PaymentEnabled bool `json:"payment_enabled"`
+ Version string `json:"version"`
// 服务器全局时区(IANA 名称与当前 UTC 偏移,如 "Asia/Shanghai" / "+08:00")。
// 高峰时段等按服务器本地时间判定的窗口,前端展示时据此标注,避免用户按浏览器本地时间误读。
ServerTimezone string `json:"server_timezone"`
diff --git a/backend/internal/handler/setting_handler.go b/backend/internal/handler/setting_handler.go
index 3886f82c2a..a415d67e34 100644
--- a/backend/internal/handler/setting_handler.go
+++ b/backend/internal/handler/setting_handler.go
@@ -44,63 +44,64 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
}
response.Success(c, dto.PublicSettings{
- RegistrationEnabled: settings.RegistrationEnabled,
- EmailVerifyEnabled: settings.EmailVerifyEnabled,
- ForceEmailOnThirdPartySignup: settings.ForceEmailOnThirdPartySignup,
- RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
- PromoCodeEnabled: settings.PromoCodeEnabled,
- PasswordResetEnabled: settings.PasswordResetEnabled,
- InvitationCodeEnabled: settings.InvitationCodeEnabled,
- TotpEnabled: settings.TotpEnabled,
- PasskeyEnabled: settings.PasskeyEnabled,
- LoginAgreementEnabled: settings.LoginAgreementEnabled,
- LoginAgreementMode: settings.LoginAgreementMode,
- LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
- LoginAgreementRevision: settings.LoginAgreementRevision,
- LoginAgreementDocuments: publicLoginAgreementDocumentsToDTO(settings.LoginAgreementDocuments),
- TurnstileEnabled: settings.TurnstileEnabled,
- TurnstileSiteKey: settings.TurnstileSiteKey,
- TencentCaptchaEnabled: settings.TencentCaptchaEnabled,
- TencentCaptchaAppID: settings.TencentCaptchaAppID,
- TencentCaptchaRegion: settings.TencentCaptchaRegion,
- AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled,
- AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID,
- AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix,
- AliyunCaptchaRegion: settings.AliyunCaptchaRegion,
- SiteName: settings.SiteName,
- SiteLogo: settings.SiteLogo,
- SiteSubtitle: settings.SiteSubtitle,
- APIBaseURL: settings.APIBaseURL,
- ContactInfo: settings.ContactInfo,
- DocURL: settings.DocURL,
- HomeContent: settings.HomeContent,
- CompactHomeEnabled: settings.CompactHomeEnabled,
- HideCcsImportButton: settings.HideCcsImportButton,
- PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
- PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
- TableDefaultPageSize: settings.TableDefaultPageSize,
- TablePageSizeOptions: settings.TablePageSizeOptions,
- CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems),
- CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
- DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled,
- LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
- WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
- WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled,
- WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled,
- WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
- OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
- OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
- GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
- GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
- BackendModeEnabled: settings.BackendModeEnabled,
- PaymentEnabled: settings.PaymentEnabled,
- Version: h.version,
- ServerTimezone: timezone.Name(),
- ServerUTCOffset: timezone.UTCOffset(),
- BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
- AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
- BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
- BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
+ RegistrationEnabled: settings.RegistrationEnabled,
+ EmailVerifyEnabled: settings.EmailVerifyEnabled,
+ ForceEmailOnThirdPartySignup: settings.ForceEmailOnThirdPartySignup,
+ RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
+ RegistrationEmailDomainQuotaEnabled: settings.RegistrationEmailDomainQuotaEnabled,
+ PromoCodeEnabled: settings.PromoCodeEnabled,
+ PasswordResetEnabled: settings.PasswordResetEnabled,
+ InvitationCodeEnabled: settings.InvitationCodeEnabled,
+ TotpEnabled: settings.TotpEnabled,
+ PasskeyEnabled: settings.PasskeyEnabled,
+ LoginAgreementEnabled: settings.LoginAgreementEnabled,
+ LoginAgreementMode: settings.LoginAgreementMode,
+ LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
+ LoginAgreementRevision: settings.LoginAgreementRevision,
+ LoginAgreementDocuments: publicLoginAgreementDocumentsToDTO(settings.LoginAgreementDocuments),
+ TurnstileEnabled: settings.TurnstileEnabled,
+ TurnstileSiteKey: settings.TurnstileSiteKey,
+ TencentCaptchaEnabled: settings.TencentCaptchaEnabled,
+ TencentCaptchaAppID: settings.TencentCaptchaAppID,
+ TencentCaptchaRegion: settings.TencentCaptchaRegion,
+ AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled,
+ AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID,
+ AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix,
+ AliyunCaptchaRegion: settings.AliyunCaptchaRegion,
+ SiteName: settings.SiteName,
+ SiteLogo: settings.SiteLogo,
+ SiteSubtitle: settings.SiteSubtitle,
+ APIBaseURL: settings.APIBaseURL,
+ ContactInfo: settings.ContactInfo,
+ DocURL: settings.DocURL,
+ HomeContent: settings.HomeContent,
+ CompactHomeEnabled: settings.CompactHomeEnabled,
+ HideCcsImportButton: settings.HideCcsImportButton,
+ PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
+ PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
+ TableDefaultPageSize: settings.TableDefaultPageSize,
+ TablePageSizeOptions: settings.TablePageSizeOptions,
+ CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems),
+ CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
+ DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled,
+ LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
+ WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
+ WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled,
+ WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled,
+ WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
+ OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
+ OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
+ GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
+ GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
+ BackendModeEnabled: settings.BackendModeEnabled,
+ PaymentEnabled: settings.PaymentEnabled,
+ Version: h.version,
+ ServerTimezone: timezone.Name(),
+ ServerUTCOffset: timezone.UTCOffset(),
+ BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
+ AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
+ BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
+ BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
ChannelMonitorMode: settings.ChannelMonitorMode,
diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go
index 196b081985..9ac40fb556 100644
--- a/backend/internal/server/api_contract_test.go
+++ b/backend/internal/server/api_contract_test.go
@@ -714,6 +714,7 @@ func TestAPIContracts(t *testing.T) {
"registration_enabled": true,
"email_verify_enabled": false,
"registration_email_suffix_whitelist": [],
+ "registration_email_domain_quota_enabled": false,
"promo_code_enabled": true,
"password_reset_enabled": false,
"frontend_url": "",
@@ -1059,6 +1060,7 @@ func TestAPIContracts(t *testing.T) {
"registration_enabled": true,
"email_verify_enabled": false,
"registration_email_suffix_whitelist": [],
+ "registration_email_domain_quota_enabled": false,
"promo_code_enabled": true,
"password_reset_enabled": false,
"frontend_url": "",
diff --git a/backend/internal/service/auth_oauth_email_flow_test.go b/backend/internal/service/auth_oauth_email_flow_test.go
index 4bdae10dff..6e57e95016 100644
--- a/backend/internal/service/auth_oauth_email_flow_test.go
+++ b/backend/internal/service/auth_oauth_email_flow_test.go
@@ -206,8 +206,9 @@ func TestRegisterOAuthEmailAccount_NonWhitelistDomainLimit(t *testing.T) {
&redeemCodeRepoStub{},
&refreshTokenCacheStub{},
map[string]string{
- SettingKeyRegistrationEnabled: "true",
- SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ SettingKeyRegistrationEnabled: "true",
+ SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ SettingKeyRegistrationEmailDomainQuotaEnabled: "true",
},
&emailCacheStub{data: &VerificationCodeData{
Code: "246810",
@@ -236,8 +237,9 @@ func TestRegisterVerifiedOAuthEmailAccount_NonWhitelistDomainLimit(t *testing.T)
nil,
&refreshTokenCacheStub{},
map[string]string{
- SettingKeyRegistrationEnabled: "true",
- SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ SettingKeyRegistrationEnabled: "true",
+ SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ SettingKeyRegistrationEmailDomainQuotaEnabled: "true",
},
&emailCacheStub{},
nil,
@@ -261,8 +263,9 @@ func TestSendPendingOAuthVerifyCode_NonWhitelistDomainLimit(t *testing.T) {
nil,
nil,
map[string]string{
- SettingKeyRegistrationEnabled: "true",
- SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ SettingKeyRegistrationEnabled: "true",
+ SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ SettingKeyRegistrationEmailDomainQuotaEnabled: "true",
},
&emailCacheStub{},
nil,
@@ -272,6 +275,25 @@ func TestSendPendingOAuthVerifyCode_NonWhitelistDomainLimit(t *testing.T) {
require.ErrorIs(t, err, ErrEmailDomainRegistrationLimit)
}
+// 域名限量注册开关默认关闭:白名单外域名在 pending OAuth 发码阶段即被严格拒绝。
+func TestSendPendingOAuthVerifyCode_NonWhitelistDomainRejectedWhenQuotaDisabled(t *testing.T) {
+ userRepo := &userRepoStub{domainCounts: map[string]int{"custom.example": 0}}
+ authService := newOAuthEmailFlowAuthService(
+ userRepo,
+ nil,
+ nil,
+ map[string]string{
+ SettingKeyRegistrationEnabled: "true",
+ SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ },
+ &emailCacheStub{},
+ nil,
+ )
+
+ _, err := authService.SendPendingOAuthVerifyCode(context.Background(), "first@custom.example")
+ require.ErrorIs(t, err, ErrEmailSuffixNotAllowed)
+}
+
func TestSendPendingOAuthVerifyCode_NilServiceReturnsUnavailable(t *testing.T) {
var authService *AuthService
diff --git a/backend/internal/service/auth_service.go b/backend/internal/service/auth_service.go
index 72ada60155..2beba0d402 100644
--- a/backend/internal/service/auth_service.go
+++ b/backend/internal/service/auth_service.go
@@ -1207,6 +1207,7 @@ func (s *AuthService) validateRegistrationEmailPolicy(ctx context.Context, email
}
// validateRegistrationEmailQuota 保留白名单为空时的全放行行为;配置白名单后,
+// 非白名单域名默认直接拒绝(严格白名单模式);仅当域名限量注册开关开启时,
// 非白名单域名每个最多允许一个账户。
func (s *AuthService) validateRegistrationEmailQuota(ctx context.Context, email string) error {
if s.settingService == nil {
@@ -1216,6 +1217,9 @@ func (s *AuthService) validateRegistrationEmailQuota(ctx context.Context, email
if !IsRegistrationEmailSuffixLimited(email, whitelist) {
return nil
}
+ if !s.settingService.IsRegistrationEmailDomainQuotaEnabled(ctx) {
+ return buildEmailSuffixNotAllowedError(whitelist)
+ }
domain := RegistrationEmailDomain(email)
if domain == "" {
@@ -1253,6 +1257,10 @@ func (s *AuthService) createUserWithRegistrationEmailGuard(ctx context.Context,
if !IsRegistrationEmailSuffixLimited(user.Email, whitelist) {
return s.userRepo.CreateWithEmailAliasGuard(ctx, user)
}
+ // 开关关闭时非白名单域名在校验阶段已被拒绝;此处兜底防御设置竞态变更。
+ if s.settingService == nil || !s.settingService.IsRegistrationEmailDomainQuotaEnabled(ctx) {
+ return buildEmailSuffixNotAllowedError(whitelist)
+ }
if domain == "" {
return buildEmailSuffixNotAllowedError(whitelist)
}
diff --git a/backend/internal/service/auth_service_register_test.go b/backend/internal/service/auth_service_register_test.go
index 7325ca06dd..04d7efaeb9 100644
--- a/backend/internal/service/auth_service_register_test.go
+++ b/backend/internal/service/auth_service_register_test.go
@@ -416,8 +416,9 @@ func TestAuthService_Register_ReservedEmail(t *testing.T) {
func TestAuthService_Register_EmailSuffixNotAllowed(t *testing.T) {
repo := &userRepoStub{domainCounts: map[string]int{"other.com": 1}}
service := newAuthService(repo, map[string]string{
- SettingKeyRegistrationEnabled: "true",
- SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`,
+ SettingKeyRegistrationEnabled: "true",
+ SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`,
+ SettingKeyRegistrationEmailDomainQuotaEnabled: "true",
}, nil, nil)
_, _, err := service.Register(context.Background(), "user@other.com", "password")
@@ -430,8 +431,9 @@ func TestAuthService_Register_EmailSuffixNotAllowed(t *testing.T) {
func TestAuthService_Register_NonWhitelistDomainAllowsFirstAccount(t *testing.T) {
repo := &userRepoStub{nextID: 9, domainCounts: map[string]int{"custom.example": 0}}
svc := newAuthService(repo, map[string]string{
- SettingKeyRegistrationEnabled: "true",
- SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ SettingKeyRegistrationEnabled: "true",
+ SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ SettingKeyRegistrationEmailDomainQuotaEnabled: "true",
}, nil, nil)
_, user, err := svc.Register(context.Background(), "first@custom.example", "password")
@@ -442,14 +444,81 @@ func TestAuthService_Register_NonWhitelistDomainAllowsFirstAccount(t *testing.T)
func TestAuthService_Register_NonWhitelistDomainRejectsSecondAccount(t *testing.T) {
repo := &userRepoStub{domainCounts: map[string]int{"custom.example": 1}}
svc := newAuthService(repo, map[string]string{
- SettingKeyRegistrationEnabled: "true",
- SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ SettingKeyRegistrationEnabled: "true",
+ SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ SettingKeyRegistrationEmailDomainQuotaEnabled: "true",
}, nil, nil)
_, _, err := svc.Register(context.Background(), "second@sub.custom.example", "password")
require.ErrorIs(t, err, ErrEmailDomainRegistrationLimit)
}
+// 域名限量注册开关默认关闭:白名单外域名保持 PR5423 之前的严格拒绝语义,
+// 即使该域名下还没有任何账户也不放行。
+func TestAuthService_Register_NonWhitelistDomainRejectedWhenQuotaDisabledByDefault(t *testing.T) {
+ repo := &userRepoStub{nextID: 9, domainCounts: map[string]int{"custom.example": 0}}
+ svc := newAuthService(repo, map[string]string{
+ SettingKeyRegistrationEnabled: "true",
+ SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ }, nil, nil)
+
+ _, _, err := svc.Register(context.Background(), "first@custom.example", "password")
+ require.ErrorIs(t, err, ErrEmailSuffixNotAllowed)
+ appErr := infraerrors.FromError(err)
+ require.Equal(t, "EMAIL_SUFFIX_NOT_ALLOWED", appErr.Reason)
+ require.Empty(t, repo.created)
+ require.Zero(t, repo.domainLimitedCreates)
+}
+
+func TestAuthService_Register_NonWhitelistDomainRejectedWhenQuotaExplicitlyDisabled(t *testing.T) {
+ repo := &userRepoStub{nextID: 9, domainCounts: map[string]int{"custom.example": 0}}
+ svc := newAuthService(repo, map[string]string{
+ SettingKeyRegistrationEnabled: "true",
+ SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ SettingKeyRegistrationEmailDomainQuotaEnabled: "false",
+ }, nil, nil)
+
+ _, _, err := svc.Register(context.Background(), "first@custom.example", "password")
+ require.ErrorIs(t, err, ErrEmailSuffixNotAllowed)
+ require.Empty(t, repo.created)
+}
+
+// 开关关闭不影响白名单命中域名的正常注册。
+func TestAuthService_Register_WhitelistDomainAllowedWhenQuotaDisabled(t *testing.T) {
+ repo := &userRepoStub{nextID: 12}
+ svc := newAuthService(repo, map[string]string{
+ SettingKeyRegistrationEnabled: "true",
+ SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ }, nil, nil)
+
+ _, user, err := svc.Register(context.Background(), "user@example.com", "password")
+ require.NoError(t, err)
+ require.Equal(t, int64(12), user.ID)
+ require.Zero(t, repo.domainLimitedCreates)
+}
+
+func TestAuthService_SendVerifyCode_NonWhitelistDomainRejectedWhenQuotaDisabled(t *testing.T) {
+ repo := &userRepoStub{domainCounts: map[string]int{"custom.example": 0}}
+ svc := newAuthService(repo, map[string]string{
+ SettingKeyRegistrationEnabled: "true",
+ SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ }, nil, nil)
+
+ err := svc.SendVerifyCode(context.Background(), "user@custom.example")
+ require.ErrorIs(t, err, ErrEmailSuffixNotAllowed)
+}
+
+func TestAuthService_SendVerifyCodeAsync_NonWhitelistDomainRejectedWhenQuotaDisabled(t *testing.T) {
+ repo := &userRepoStub{domainCounts: map[string]int{"custom.example": 0}}
+ svc := newAuthService(repo, map[string]string{
+ SettingKeyRegistrationEnabled: "true",
+ SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ }, nil, nil)
+
+ _, err := svc.SendVerifyCodeAsync(context.Background(), "user@custom.example")
+ require.ErrorIs(t, err, ErrEmailSuffixNotAllowed)
+}
+
func TestAuthService_Register_EmptyWhitelistAllowsAllDomains(t *testing.T) {
repo := &userRepoStub{nextID: 10}
svc := newAuthService(repo, map[string]string{
@@ -477,8 +546,9 @@ func TestAuthService_Register_EmailSuffixAllowed(t *testing.T) {
func TestAuthService_SendVerifyCode_EmailSuffixNotAllowed(t *testing.T) {
repo := &userRepoStub{domainCounts: map[string]int{"other.com": 1}}
service := newAuthService(repo, map[string]string{
- SettingKeyRegistrationEnabled: "true",
- SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`,
+ SettingKeyRegistrationEnabled: "true",
+ SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com","@company.com"]`,
+ SettingKeyRegistrationEmailDomainQuotaEnabled: "true",
}, nil, nil)
err := service.SendVerifyCode(context.Background(), "user@other.com")
@@ -490,8 +560,9 @@ func TestAuthService_SendVerifyCode_EmailSuffixNotAllowed(t *testing.T) {
func TestAuthService_SendVerifyCode_NonWhitelistDomainLimit(t *testing.T) {
repo := &userRepoStub{domainCounts: map[string]int{"custom.example": 1}}
svc := newAuthService(repo, map[string]string{
- SettingKeyRegistrationEnabled: "true",
- SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ SettingKeyRegistrationEnabled: "true",
+ SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ SettingKeyRegistrationEmailDomainQuotaEnabled: "true",
}, nil, nil)
err := svc.SendVerifyCode(context.Background(), "user@custom.example")
@@ -501,8 +572,9 @@ func TestAuthService_SendVerifyCode_NonWhitelistDomainLimit(t *testing.T) {
func TestAuthService_SendVerifyCodeAsync_NonWhitelistDomainLimit(t *testing.T) {
repo := &userRepoStub{domainCounts: map[string]int{"custom.example": 1}}
svc := newAuthService(repo, map[string]string{
- SettingKeyRegistrationEnabled: "true",
- SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ SettingKeyRegistrationEnabled: "true",
+ SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ SettingKeyRegistrationEmailDomainQuotaEnabled: "true",
}, nil, nil)
_, err := svc.SendVerifyCodeAsync(context.Background(), "user@custom.example")
diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go
index 1e38b12216..2d5311bb24 100644
--- a/backend/internal/service/domain_constants.go
+++ b/backend/internal/service/domain_constants.go
@@ -142,24 +142,27 @@ const (
SettingKeyRegistrationEnabled = "registration_enabled" // 是否开放注册
SettingKeyEmailVerifyEnabled = "email_verify_enabled" // 是否开启邮件验证
SettingKeyRegistrationEmailSuffixWhitelist = "registration_email_suffix_whitelist" // 注册邮箱后缀白名单(JSON 数组)
- SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能
- SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
- SettingKeyFrontendURL = "frontend_url" // 前端基础URL,用于生成邮件中的重置密码链接
- SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
- SettingKeyAffiliateEnabled = "affiliate_enabled" // 邀请返利功能总开关
- SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例(百分比,0-100)
- SettingKeyAffiliateRebateFreezeHours = "affiliate_rebate_freeze_hours" // 返利冻结期(小时,0=不冻结)
- SettingKeyAffiliateRebateDurationDays = "affiliate_rebate_duration_days" // 返利有效期(天,0=永久)
- SettingKeyAffiliateRebatePerInviteeCap = "affiliate_rebate_per_invitee_cap" // 单人返利上限(0=无上限)
- SettingKeyAffiliateAdminRechargeEnabled = "affiliate_admin_recharge_enabled" // 管理员充值是否产生返利
- SettingKeyRiskControlEnabled = "risk_control_enabled" // 是否启用风控中心入口与审计链路
- SettingKeyContentModerationConfig = "content_moderation_config" // 内容审计配置(JSON)
- SettingKeyCyberSessionBlockEnabled = "cyber_session_block_enabled" // cyber 命中后会话级自动屏蔽总开关(默认关)
- SettingKeyCyberSessionBlockTTLSeconds = "cyber_session_block_ttl_seconds" // 会话屏蔽 TTL 秒数(默认 3600)
- SettingKeyLoginAgreementEnabled = "login_agreement_enabled" // 登录前是否要求同意条款
- SettingKeyLoginAgreementMode = "login_agreement_mode" // 条款确认展示模式:modal / checkbox
- SettingKeyLoginAgreementUpdatedAt = "login_agreement_updated_at" // 条款更新日期(展示用)
- SettingKeyLoginAgreementDocuments = "login_agreement_documents" // 条款文档列表(JSON,Markdown 内容)
+ // 白名单非空时,是否放行非白名单域名按主域名限量注册(每域名 1 个账户)。
+ // 默认 false:非白名单域名直接拒绝(白名单严格模式)。
+ SettingKeyRegistrationEmailDomainQuotaEnabled = "registration_email_domain_quota_enabled"
+ SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能
+ SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证)
+ SettingKeyFrontendURL = "frontend_url" // 前端基础URL,用于生成邮件中的重置密码链接
+ SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册
+ SettingKeyAffiliateEnabled = "affiliate_enabled" // 邀请返利功能总开关
+ SettingKeyAffiliateRebateRate = "affiliate_rebate_rate" // 邀请返利比例(百分比,0-100)
+ SettingKeyAffiliateRebateFreezeHours = "affiliate_rebate_freeze_hours" // 返利冻结期(小时,0=不冻结)
+ SettingKeyAffiliateRebateDurationDays = "affiliate_rebate_duration_days" // 返利有效期(天,0=永久)
+ SettingKeyAffiliateRebatePerInviteeCap = "affiliate_rebate_per_invitee_cap" // 单人返利上限(0=无上限)
+ SettingKeyAffiliateAdminRechargeEnabled = "affiliate_admin_recharge_enabled" // 管理员充值是否产生返利
+ SettingKeyRiskControlEnabled = "risk_control_enabled" // 是否启用风控中心入口与审计链路
+ SettingKeyContentModerationConfig = "content_moderation_config" // 内容审计配置(JSON)
+ SettingKeyCyberSessionBlockEnabled = "cyber_session_block_enabled" // cyber 命中后会话级自动屏蔽总开关(默认关)
+ SettingKeyCyberSessionBlockTTLSeconds = "cyber_session_block_ttl_seconds" // 会话屏蔽 TTL 秒数(默认 3600)
+ SettingKeyLoginAgreementEnabled = "login_agreement_enabled" // 登录前是否要求同意条款
+ SettingKeyLoginAgreementMode = "login_agreement_mode" // 条款确认展示模式:modal / checkbox
+ SettingKeyLoginAgreementUpdatedAt = "login_agreement_updated_at" // 条款更新日期(展示用)
+ SettingKeyLoginAgreementDocuments = "login_agreement_documents" // 条款文档列表(JSON,Markdown 内容)
// 邮件服务设置
SettingKeySMTPHost = "smtp_host" // SMTP服务器地址
diff --git a/backend/internal/service/registration_email_policy_test.go b/backend/internal/service/registration_email_policy_test.go
index b3e3ad6008..a3067b8379 100644
--- a/backend/internal/service/registration_email_policy_test.go
+++ b/backend/internal/service/registration_email_policy_test.go
@@ -48,8 +48,9 @@ func TestIsRegistrationEmailSuffixAllowed(t *testing.T) {
func TestRegistrationEmailQuotaRejectsMalformedDomainWhenWhitelistConfigured(t *testing.T) {
repo := &userRepoStub{}
svc := newAuthService(repo, map[string]string{
- SettingKeyRegistrationEnabled: "true",
- SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ SettingKeyRegistrationEnabled: "true",
+ SettingKeyRegistrationEmailSuffixWhitelist: `["@example.com"]`,
+ SettingKeyRegistrationEmailDomainQuotaEnabled: "true",
}, nil, nil)
_, _, err := svc.Register(context.Background(), "malformed-email", "password")
diff --git a/backend/internal/service/setting_features.go b/backend/internal/service/setting_features.go
index cfb83f0b5f..7cd647e9fe 100644
--- a/backend/internal/service/setting_features.go
+++ b/backend/internal/service/setting_features.go
@@ -33,6 +33,16 @@ func (s *SettingService) IsEmailVerifyEnabled(ctx context.Context) bool {
return value == "true"
}
+// IsRegistrationEmailDomainQuotaEnabled 检查白名单非空时是否放行非白名单域名限量注册。
+// 安全默认:设置缺失或查询出错时按关闭处理(保持白名单严格模式)。
+func (s *SettingService) IsRegistrationEmailDomainQuotaEnabled(ctx context.Context) bool {
+ value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEmailDomainQuotaEnabled)
+ if err != nil {
+ return false
+ }
+ return value == "true"
+}
+
// GetRegistrationEmailSuffixWhitelist returns normalized registration email suffix whitelist.
func (s *SettingService) GetRegistrationEmailSuffixWhitelist(ctx context.Context) []string {
value, err := s.settingRepo.GetValue(ctx, SettingKeyRegistrationEmailSuffixWhitelist)
diff --git a/backend/internal/service/setting_parse.go b/backend/internal/service/setting_parse.go
index 7701e50e83..283c76f8f4 100644
--- a/backend/internal/service/setting_parse.go
+++ b/backend/internal/service/setting_parse.go
@@ -58,6 +58,7 @@ func (s *SettingService) InitializeDefaultSettings(ctx context.Context) error {
SettingKeyRegistrationEnabled: "true",
SettingKeyEmailVerifyEnabled: "false",
SettingKeyRegistrationEmailSuffixWhitelist: "[]",
+ SettingKeyRegistrationEmailDomainQuotaEnabled: "false",
SettingKeyPromoCodeEnabled: "true", // 默认启用优惠码功能
SettingKeyLoginAgreementEnabled: "false",
SettingKeyLoginAgreementMode: defaultLoginAgreementMode,
@@ -310,6 +311,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
EmailVerifyEnabled: emailVerifyEnabled,
RegistrationEmailSuffixWhitelist: ParseRegistrationEmailSuffixWhitelist(settings[SettingKeyRegistrationEmailSuffixWhitelist]),
+ RegistrationEmailDomainQuotaEnabled: settings[SettingKeyRegistrationEmailDomainQuotaEnabled] == "true",
PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true",
FrontendURL: settings[SettingKeyFrontendURL],
diff --git a/backend/internal/service/setting_public.go b/backend/internal/service/setting_public.go
index f81717b11e..f292e8ac57 100644
--- a/backend/internal/service/setting_public.go
+++ b/backend/internal/service/setting_public.go
@@ -160,6 +160,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
SettingKeyEmailVerifyEnabled,
SettingKeyForceEmailOnThirdPartySignup,
SettingKeyRegistrationEmailSuffixWhitelist,
+ SettingKeyRegistrationEmailDomainQuotaEnabled,
SettingKeyPromoCodeEnabled,
SettingKeyPasswordResetEnabled,
SettingKeyInvitationCodeEnabled,
@@ -294,60 +295,61 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
}
return &PublicSettings{
- RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
- EmailVerifyEnabled: emailVerifyEnabled,
- ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true",
- RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist,
- PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
- PasswordResetEnabled: passwordResetEnabled,
- InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
- TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
- PasskeyEnabled: s.passkeyConfigured() && s.passkeySettingEnabled(settings),
- LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0,
- LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]),
- LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
- LoginAgreementRevision: buildLoginAgreementRevision(loginAgreementUpdatedAt, loginAgreementDocuments),
- LoginAgreementDocuments: loginAgreementDocuments,
- TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
- TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
- TencentCaptchaEnabled: settings[SettingKeyTencentCaptchaEnabled] == "true",
- TencentCaptchaAppID: settings[SettingKeyTencentCaptchaAppID],
- TencentCaptchaRegion: normalizeTencentCaptchaRegion(settings[SettingKeyTencentCaptchaRegion]),
- AliyunCaptchaEnabled: settings[SettingKeyAliyunCaptchaEnabled] == "true",
- AliyunCaptchaSceneID: settings[SettingKeyAliyunCaptchaSceneID],
- AliyunCaptchaPrefix: settings[SettingKeyAliyunCaptchaPrefix],
- AliyunCaptchaRegion: normalizeAliyunCaptchaRegion(settings[SettingKeyAliyunCaptchaRegion]),
- SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
- SiteLogo: settings[SettingKeySiteLogo],
- SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
- APIBaseURL: settings[SettingKeyAPIBaseURL],
- ContactInfo: settings[SettingKeyContactInfo],
- DocURL: settings[SettingKeyDocURL],
- HomeContent: settings[SettingKeyHomeContent],
- CompactHomeEnabled: settings[SettingKeyCompactHomeEnabled] == "true",
- HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
- PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
- PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
- TableDefaultPageSize: tableDefaultPageSize,
- TablePageSizeOptions: tablePageSizeOptions,
- CustomMenuItems: settings[SettingKeyCustomMenuItems],
- CustomEndpoints: settings[SettingKeyCustomEndpoints],
- LinuxDoOAuthEnabled: linuxDoEnabled,
- DingTalkOAuthEnabled: dingTalkEnabled,
- WeChatOAuthEnabled: weChatEnabled,
- WeChatOAuthOpenEnabled: weChatOpenEnabled,
- WeChatOAuthMPEnabled: weChatMPEnabled,
- WeChatOAuthMobileEnabled: weChatMobileEnabled,
- BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
- PaymentEnabled: settings[SettingPaymentEnabled] == "true",
- OIDCOAuthEnabled: oidcEnabled,
- OIDCOAuthProviderName: oidcProviderName,
- GitHubOAuthEnabled: gitHubEnabled,
- GoogleOAuthEnabled: googleEnabled,
- BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true",
- AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true",
- BalanceLowNotifyThreshold: balanceLowNotifyThreshold,
- BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL],
+ RegistrationEnabled: settings[SettingKeyRegistrationEnabled] == "true",
+ EmailVerifyEnabled: emailVerifyEnabled,
+ ForceEmailOnThirdPartySignup: settings[SettingKeyForceEmailOnThirdPartySignup] == "true",
+ RegistrationEmailSuffixWhitelist: registrationEmailSuffixWhitelist,
+ RegistrationEmailDomainQuotaEnabled: settings[SettingKeyRegistrationEmailDomainQuotaEnabled] == "true",
+ PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用
+ PasswordResetEnabled: passwordResetEnabled,
+ InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true",
+ TotpEnabled: settings[SettingKeyTotpEnabled] == "true",
+ PasskeyEnabled: s.passkeyConfigured() && s.passkeySettingEnabled(settings),
+ LoginAgreementEnabled: settings[SettingKeyLoginAgreementEnabled] == "true" && len(loginAgreementDocuments) > 0,
+ LoginAgreementMode: normalizeLoginAgreementMode(settings[SettingKeyLoginAgreementMode]),
+ LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
+ LoginAgreementRevision: buildLoginAgreementRevision(loginAgreementUpdatedAt, loginAgreementDocuments),
+ LoginAgreementDocuments: loginAgreementDocuments,
+ TurnstileEnabled: settings[SettingKeyTurnstileEnabled] == "true",
+ TurnstileSiteKey: settings[SettingKeyTurnstileSiteKey],
+ TencentCaptchaEnabled: settings[SettingKeyTencentCaptchaEnabled] == "true",
+ TencentCaptchaAppID: settings[SettingKeyTencentCaptchaAppID],
+ TencentCaptchaRegion: normalizeTencentCaptchaRegion(settings[SettingKeyTencentCaptchaRegion]),
+ AliyunCaptchaEnabled: settings[SettingKeyAliyunCaptchaEnabled] == "true",
+ AliyunCaptchaSceneID: settings[SettingKeyAliyunCaptchaSceneID],
+ AliyunCaptchaPrefix: settings[SettingKeyAliyunCaptchaPrefix],
+ AliyunCaptchaRegion: normalizeAliyunCaptchaRegion(settings[SettingKeyAliyunCaptchaRegion]),
+ SiteName: s.getStringOrDefault(settings, SettingKeySiteName, "Sub2API"),
+ SiteLogo: settings[SettingKeySiteLogo],
+ SiteSubtitle: s.getStringOrDefault(settings, SettingKeySiteSubtitle, "Subscription to API Conversion Platform"),
+ APIBaseURL: settings[SettingKeyAPIBaseURL],
+ ContactInfo: settings[SettingKeyContactInfo],
+ DocURL: settings[SettingKeyDocURL],
+ HomeContent: settings[SettingKeyHomeContent],
+ CompactHomeEnabled: settings[SettingKeyCompactHomeEnabled] == "true",
+ HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
+ PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
+ PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
+ TableDefaultPageSize: tableDefaultPageSize,
+ TablePageSizeOptions: tablePageSizeOptions,
+ CustomMenuItems: settings[SettingKeyCustomMenuItems],
+ CustomEndpoints: settings[SettingKeyCustomEndpoints],
+ LinuxDoOAuthEnabled: linuxDoEnabled,
+ DingTalkOAuthEnabled: dingTalkEnabled,
+ WeChatOAuthEnabled: weChatEnabled,
+ WeChatOAuthOpenEnabled: weChatOpenEnabled,
+ WeChatOAuthMPEnabled: weChatMPEnabled,
+ WeChatOAuthMobileEnabled: weChatMobileEnabled,
+ BackendModeEnabled: settings[SettingKeyBackendModeEnabled] == "true",
+ PaymentEnabled: settings[SettingPaymentEnabled] == "true",
+ OIDCOAuthEnabled: oidcEnabled,
+ OIDCOAuthProviderName: oidcProviderName,
+ GitHubOAuthEnabled: gitHubEnabled,
+ GoogleOAuthEnabled: googleEnabled,
+ BalanceLowNotifyEnabled: settings[SettingKeyBalanceLowNotifyEnabled] == "true",
+ AccountQuotaNotifyEnabled: settings[SettingKeyAccountQuotaNotifyEnabled] == "true",
+ BalanceLowNotifyThreshold: balanceLowNotifyThreshold,
+ BalanceLowNotifyRechargeURL: settings[SettingKeyBalanceLowNotifyRechargeURL],
ChannelMonitorEnabled: !isFalseSettingValue(settings[SettingKeyChannelMonitorEnabled]),
ChannelMonitorMode: normalizeChannelMonitorMode(settings[SettingKeyChannelMonitorMode]),
@@ -536,56 +538,57 @@ func (s *SettingService) IsUserErrorViewAllowed(ctx context.Context) bool {
// A unit test diffs this struct's JSON keys against dto.PublicSettings to catch
// drift automatically (see setting_service_injection_test.go).
type PublicSettingsInjectionPayload struct {
- RegistrationEnabled bool `json:"registration_enabled"`
- EmailVerifyEnabled bool `json:"email_verify_enabled"`
- RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
- PromoCodeEnabled bool `json:"promo_code_enabled"`
- PasswordResetEnabled bool `json:"password_reset_enabled"`
- InvitationCodeEnabled bool `json:"invitation_code_enabled"`
- TotpEnabled bool `json:"totp_enabled"`
- PasskeyEnabled bool `json:"passkey_enabled"`
- LoginAgreementEnabled bool `json:"login_agreement_enabled"`
- LoginAgreementMode string `json:"login_agreement_mode"`
- LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
- LoginAgreementRevision string `json:"login_agreement_revision"`
- LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
- TurnstileEnabled bool `json:"turnstile_enabled"`
- TurnstileSiteKey string `json:"turnstile_site_key"`
- TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
- TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
- TencentCaptchaRegion string `json:"tencent_captcha_region"`
- AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
- AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"`
- AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"`
- AliyunCaptchaRegion string `json:"aliyun_captcha_region"`
- 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 json.RawMessage `json:"custom_menu_items"`
- CustomEndpoints json.RawMessage `json:"custom_endpoints"`
- LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
- DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"`
- WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
- WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
- WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
- WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
- OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
- OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
- GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
- GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
- BackendModeEnabled bool `json:"backend_mode_enabled"`
- PaymentEnabled bool `json:"payment_enabled"`
- Version string `json:"version"`
+ 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"`
+ InvitationCodeEnabled bool `json:"invitation_code_enabled"`
+ TotpEnabled bool `json:"totp_enabled"`
+ PasskeyEnabled bool `json:"passkey_enabled"`
+ LoginAgreementEnabled bool `json:"login_agreement_enabled"`
+ LoginAgreementMode string `json:"login_agreement_mode"`
+ LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
+ LoginAgreementRevision string `json:"login_agreement_revision"`
+ LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
+ TurnstileEnabled bool `json:"turnstile_enabled"`
+ TurnstileSiteKey string `json:"turnstile_site_key"`
+ TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
+ TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
+ TencentCaptchaRegion string `json:"tencent_captcha_region"`
+ AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
+ AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"`
+ AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"`
+ AliyunCaptchaRegion string `json:"aliyun_captcha_region"`
+ 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 json.RawMessage `json:"custom_menu_items"`
+ CustomEndpoints json.RawMessage `json:"custom_endpoints"`
+ LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
+ DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"`
+ WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
+ WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
+ WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
+ WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
+ OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
+ OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
+ GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
+ GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
+ BackendModeEnabled bool `json:"backend_mode_enabled"`
+ PaymentEnabled bool `json:"payment_enabled"`
+ Version string `json:"version"`
// 服务器全局时区(IANA 名称与当前 UTC 偏移),高峰时段等服务端本地时间窗口的展示标注用
ServerTimezone string `json:"server_timezone"`
ServerUTCOffset string `json:"server_utc_offset"`
@@ -620,62 +623,63 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
}
return &PublicSettingsInjectionPayload{
- RegistrationEnabled: settings.RegistrationEnabled,
- EmailVerifyEnabled: settings.EmailVerifyEnabled,
- RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
- PromoCodeEnabled: settings.PromoCodeEnabled,
- PasswordResetEnabled: settings.PasswordResetEnabled,
- InvitationCodeEnabled: settings.InvitationCodeEnabled,
- TotpEnabled: settings.TotpEnabled,
- PasskeyEnabled: settings.PasskeyEnabled,
- LoginAgreementEnabled: settings.LoginAgreementEnabled,
- LoginAgreementMode: settings.LoginAgreementMode,
- LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
- LoginAgreementRevision: settings.LoginAgreementRevision,
- LoginAgreementDocuments: settings.LoginAgreementDocuments,
- TurnstileEnabled: settings.TurnstileEnabled,
- TurnstileSiteKey: settings.TurnstileSiteKey,
- TencentCaptchaEnabled: settings.TencentCaptchaEnabled,
- TencentCaptchaAppID: settings.TencentCaptchaAppID,
- TencentCaptchaRegion: settings.TencentCaptchaRegion,
- AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled,
- AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID,
- AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix,
- AliyunCaptchaRegion: settings.AliyunCaptchaRegion,
- SiteName: settings.SiteName,
- SiteLogo: settings.SiteLogo,
- SiteSubtitle: settings.SiteSubtitle,
- APIBaseURL: settings.APIBaseURL,
- ContactInfo: settings.ContactInfo,
- DocURL: settings.DocURL,
- HomeContent: settings.HomeContent,
- CompactHomeEnabled: settings.CompactHomeEnabled,
- HideCcsImportButton: settings.HideCcsImportButton,
- PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
- PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
- TableDefaultPageSize: settings.TableDefaultPageSize,
- TablePageSizeOptions: settings.TablePageSizeOptions,
- CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
- CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
- LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
- DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled,
- WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
- WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled,
- WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled,
- WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
- OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
- OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
- GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
- GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
- BackendModeEnabled: settings.BackendModeEnabled,
- PaymentEnabled: settings.PaymentEnabled,
- Version: s.version,
- ServerTimezone: timezone.Name(),
- ServerUTCOffset: timezone.UTCOffset(),
- BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
- AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
- BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
- BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
+ RegistrationEnabled: settings.RegistrationEnabled,
+ EmailVerifyEnabled: settings.EmailVerifyEnabled,
+ RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
+ RegistrationEmailDomainQuotaEnabled: settings.RegistrationEmailDomainQuotaEnabled,
+ PromoCodeEnabled: settings.PromoCodeEnabled,
+ PasswordResetEnabled: settings.PasswordResetEnabled,
+ InvitationCodeEnabled: settings.InvitationCodeEnabled,
+ TotpEnabled: settings.TotpEnabled,
+ PasskeyEnabled: settings.PasskeyEnabled,
+ LoginAgreementEnabled: settings.LoginAgreementEnabled,
+ LoginAgreementMode: settings.LoginAgreementMode,
+ LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
+ LoginAgreementRevision: settings.LoginAgreementRevision,
+ LoginAgreementDocuments: settings.LoginAgreementDocuments,
+ TurnstileEnabled: settings.TurnstileEnabled,
+ TurnstileSiteKey: settings.TurnstileSiteKey,
+ TencentCaptchaEnabled: settings.TencentCaptchaEnabled,
+ TencentCaptchaAppID: settings.TencentCaptchaAppID,
+ TencentCaptchaRegion: settings.TencentCaptchaRegion,
+ AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled,
+ AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID,
+ AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix,
+ AliyunCaptchaRegion: settings.AliyunCaptchaRegion,
+ SiteName: settings.SiteName,
+ SiteLogo: settings.SiteLogo,
+ SiteSubtitle: settings.SiteSubtitle,
+ APIBaseURL: settings.APIBaseURL,
+ ContactInfo: settings.ContactInfo,
+ DocURL: settings.DocURL,
+ HomeContent: settings.HomeContent,
+ CompactHomeEnabled: settings.CompactHomeEnabled,
+ HideCcsImportButton: settings.HideCcsImportButton,
+ PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
+ PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
+ TableDefaultPageSize: settings.TableDefaultPageSize,
+ TablePageSizeOptions: settings.TablePageSizeOptions,
+ CustomMenuItems: filterUserVisibleMenuItems(settings.CustomMenuItems),
+ CustomEndpoints: safeRawJSONArray(settings.CustomEndpoints),
+ LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
+ DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled,
+ WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
+ WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled,
+ WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled,
+ WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
+ OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
+ OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
+ GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
+ GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
+ BackendModeEnabled: settings.BackendModeEnabled,
+ PaymentEnabled: settings.PaymentEnabled,
+ Version: s.version,
+ ServerTimezone: timezone.Name(),
+ ServerUTCOffset: timezone.UTCOffset(),
+ BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
+ AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
+ BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
+ BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
ChannelMonitorMode: settings.ChannelMonitorMode,
diff --git a/backend/internal/service/setting_update.go b/backend/internal/service/setting_update.go
index 1d40f20ca8..f77a914fd7 100644
--- a/backend/internal/service/setting_update.go
+++ b/backend/internal/service/setting_update.go
@@ -169,6 +169,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
return nil, fmt.Errorf("marshal registration email suffix whitelist: %w", err)
}
updates[SettingKeyRegistrationEmailSuffixWhitelist] = string(registrationEmailSuffixWhitelistJSON)
+ updates[SettingKeyRegistrationEmailDomainQuotaEnabled] = strconv.FormatBool(settings.RegistrationEmailDomainQuotaEnabled)
updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled)
updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled)
updates[SettingKeyFrontendURL] = settings.FrontendURL
diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go
index 3ccdd02398..8580f0c8fb 100644
--- a/backend/internal/service/settings_view.go
+++ b/backend/internal/service/settings_view.go
@@ -12,22 +12,23 @@ func firstNonEmpty(values ...string) string {
}
type SystemSettings struct {
- RegistrationEnabled bool
- EmailVerifyEnabled bool
- RegistrationEmailSuffixWhitelist []string
- PromoCodeEnabled bool
- PasswordResetEnabled bool
- FrontendURL string
- InvitationCodeEnabled bool
- TotpEnabled bool // TOTP 双因素认证
- PasskeyEnabled bool // Passkey 登录
- SessionBindingEnabled bool // 会话 IP/UA 绑定(变更即失效)
- StepUpEnabled bool // 敏感操作 step-up 2FA 门控
- AuditLogRetentionDays int // 审计日志保留天数(<=0 永久保留)
- LoginAgreementEnabled bool
- LoginAgreementMode string
- LoginAgreementUpdatedAt string
- LoginAgreementDocuments []LoginAgreementDocument
+ RegistrationEnabled bool
+ EmailVerifyEnabled bool
+ RegistrationEmailSuffixWhitelist []string
+ RegistrationEmailDomainQuotaEnabled bool // 白名单非空时放行非白名单域名限量注册(默认关闭)
+ PromoCodeEnabled bool
+ PasswordResetEnabled bool
+ FrontendURL string
+ InvitationCodeEnabled bool
+ TotpEnabled bool // TOTP 双因素认证
+ PasskeyEnabled bool // Passkey 登录
+ SessionBindingEnabled bool // 会话 IP/UA 绑定(变更即失效)
+ StepUpEnabled bool // 敏感操作 step-up 2FA 门控
+ AuditLogRetentionDays int // 审计日志保留天数(<=0 永久保留)
+ LoginAgreementEnabled bool
+ LoginAgreementMode string
+ LoginAgreementUpdatedAt string
+ LoginAgreementDocuments []LoginAgreementDocument
SMTPHost string
SMTPPort int
@@ -312,38 +313,39 @@ type DefaultSubscriptionSetting struct {
}
type PublicSettings struct {
- RegistrationEnabled bool
- EmailVerifyEnabled bool
- ForceEmailOnThirdPartySignup bool
- RegistrationEmailSuffixWhitelist []string
- PromoCodeEnabled bool
- PasswordResetEnabled bool
- InvitationCodeEnabled bool
- TotpEnabled bool // TOTP 双因素认证
- PasskeyEnabled bool
- LoginAgreementEnabled bool
- LoginAgreementMode string
- LoginAgreementUpdatedAt string
- LoginAgreementRevision string
- LoginAgreementDocuments []LoginAgreementDocument
- TurnstileEnabled bool
- TurnstileSiteKey string
- TencentCaptchaEnabled bool
- TencentCaptchaAppID string
- TencentCaptchaRegion string
- AliyunCaptchaEnabled bool
- AliyunCaptchaSceneID string
- AliyunCaptchaPrefix string
- AliyunCaptchaRegion string
- SiteName string
- SiteLogo string
- SiteSubtitle string
- APIBaseURL string
- ContactInfo string
- DocURL string
- HomeContent string
- CompactHomeEnabled bool
- HideCcsImportButton bool
+ RegistrationEnabled bool
+ EmailVerifyEnabled bool
+ ForceEmailOnThirdPartySignup bool
+ RegistrationEmailSuffixWhitelist []string
+ RegistrationEmailDomainQuotaEnabled bool
+ PromoCodeEnabled bool
+ PasswordResetEnabled bool
+ InvitationCodeEnabled bool
+ TotpEnabled bool // TOTP 双因素认证
+ PasskeyEnabled bool
+ LoginAgreementEnabled bool
+ LoginAgreementMode string
+ LoginAgreementUpdatedAt string
+ LoginAgreementRevision string
+ LoginAgreementDocuments []LoginAgreementDocument
+ TurnstileEnabled bool
+ TurnstileSiteKey string
+ TencentCaptchaEnabled bool
+ TencentCaptchaAppID string
+ TencentCaptchaRegion string
+ AliyunCaptchaEnabled bool
+ AliyunCaptchaSceneID string
+ AliyunCaptchaPrefix string
+ AliyunCaptchaRegion string
+ SiteName string
+ SiteLogo string
+ SiteSubtitle string
+ APIBaseURL string
+ ContactInfo string
+ DocURL string
+ HomeContent string
+ CompactHomeEnabled bool
+ HideCcsImportButton bool
PurchaseSubscriptionEnabled bool
PurchaseSubscriptionURL string
diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts
index 08629df300..b176024ae6 100644
--- a/frontend/src/api/admin/settings.ts
+++ b/frontend/src/api/admin/settings.ts
@@ -392,6 +392,7 @@ export interface SystemSettings {
registration_enabled: boolean;
email_verify_enabled: boolean;
registration_email_suffix_whitelist: string[];
+ registration_email_domain_quota_enabled: boolean;
promo_code_enabled: boolean;
password_reset_enabled: boolean;
frontend_url: string;
@@ -734,6 +735,7 @@ export interface UpdateSettingsRequest {
registration_enabled?: boolean;
email_verify_enabled?: boolean;
registration_email_suffix_whitelist?: string[];
+ registration_email_domain_quota_enabled?: boolean;
promo_code_enabled?: boolean;
password_reset_enabled?: boolean;
frontend_url?: string;
diff --git a/frontend/src/i18n/locales/en/admin/settings.ts b/frontend/src/i18n/locales/en/admin/settings.ts
index 32a282c5ca..b20e2ebeee 100644
--- a/frontend/src/i18n/locales/en/admin/settings.ts
+++ b/frontend/src/i18n/locales/en/admin/settings.ts
@@ -128,9 +128,12 @@ export default {
emailVerificationHint: 'Require email verification for new registrations',
emailSuffixWhitelist: 'Email Domain Whitelist',
emailSuffixWhitelistHint:
- "Emails from allowlist domains can register without a quota. When the allowlist is not empty, every other registrable domain can register one account. Empty the allowlist to remove the quota for all domains (for example, {'@'}qq.com, {'@'}gmail.com, *.edu.cn).",
+ "Only email addresses from the specified domains can register; leave empty for no restriction (for example, {'@'}qq.com, {'@'}gmail.com, *.edu.cn)",
emailSuffixWhitelistPlaceholder: "{'@'}example.com, *.edu.cn",
- emailSuffixWhitelistInputHint: 'Empty the allowlist to remove the registration quota. Use *.edu.cn to match edu.cn and its subdomains.',
+ emailSuffixWhitelistInputHint: 'Leave empty for no restriction. Use *.edu.cn to match edu.cn and its subdomains.',
+ emailDomainQuota: 'Non-allowlist Domain Quota',
+ emailDomainQuotaHint:
+ 'When enabled and the allowlist is not empty, every other registrable domain can register one account. When disabled, non-allowlist domains are rejected. Has no effect while the allowlist is empty',
promoCode: 'Promo Code',
promoCodeHint: 'Allow users to use promo codes during registration',
invitationCode: 'Invitation Code Registration',
diff --git a/frontend/src/i18n/locales/zh/admin/settings.ts b/frontend/src/i18n/locales/zh/admin/settings.ts
index 2782dae7c3..3967f25ed7 100644
--- a/frontend/src/i18n/locales/zh/admin/settings.ts
+++ b/frontend/src/i18n/locales/zh/admin/settings.ts
@@ -128,9 +128,12 @@ export default {
emailVerificationHint: '新用户注册时需要验证邮箱',
emailSuffixWhitelist: '邮箱域名白名单',
emailSuffixWhitelistHint:
- "白名单域名的邮箱可无限注册;白名单非空时,其他可注册主域名各限注册一个账户。清空白名单后,所有域名均不限制注册数量(例如 {'@'}qq.com, {'@'}gmail.com, *.edu.cn)",
+ "仅允许使用指定域名的邮箱注册账号;留空则不限制(例如 {'@'}qq.com, {'@'}gmail.com, *.edu.cn)",
emailSuffixWhitelistPlaceholder: "{'@'}example.com, *.edu.cn",
- emailSuffixWhitelistInputHint: '清空白名单后不限制注册数量。使用 *.edu.cn 可匹配 edu.cn 及其子域名。',
+ emailSuffixWhitelistInputHint: '留空则不限制。使用 *.edu.cn 可匹配 edu.cn 及其子域名。',
+ emailDomainQuota: '非白名单域名限量注册',
+ emailDomainQuotaHint:
+ '开启后,白名单非空时,其他可注册主域名各限注册一个账户;关闭时非白名单域名直接拒绝注册。白名单为空时本开关无效果',
promoCode: '优惠码',
promoCodeHint: '允许用户在注册时使用优惠码',
invitationCode: '邀请码注册',
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index a1a248a5af..24498cf7ca 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -209,6 +209,7 @@ export interface PublicSettings {
email_verify_enabled: boolean
force_email_on_third_party_signup: boolean
registration_email_suffix_whitelist: string[]
+ registration_email_domain_quota_enabled?: boolean
promo_code_enabled: boolean
password_reset_enabled: boolean
invitation_code_enabled: boolean
diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue
index 609e4260c2..b153865ff3 100644
--- a/frontend/src/views/admin/SettingsView.vue
+++ b/frontend/src/views/admin/SettingsView.vue
@@ -1486,6 +1486,23 @@
+
+
+
+
+
+ {{ t("admin.settings.registration.emailDomainQuotaHint") }}
+
+
+
+
+
({
registration_enabled: true,
email_verify_enabled: false,
registration_email_suffix_whitelist: [],
+ registration_email_domain_quota_enabled: false,
promo_code_enabled: true,
invitation_code_enabled: false,
password_reset_enabled: false,
@@ -11023,6 +11041,8 @@ async function saveSettings() {
registrationEmailSuffixWhitelistTags.value.map((suffix) =>
suffix.startsWith("*.") ? suffix : `@${suffix}`,
),
+ registration_email_domain_quota_enabled:
+ form.registration_email_domain_quota_enabled,
promo_code_enabled: form.promo_code_enabled,
invitation_code_enabled: form.invitation_code_enabled,
password_reset_enabled: form.password_reset_enabled,
diff --git a/frontend/src/views/admin/__tests__/SettingsView.spec.ts b/frontend/src/views/admin/__tests__/SettingsView.spec.ts
index a6c520ebe3..aa81d8f476 100644
--- a/frontend/src/views/admin/__tests__/SettingsView.spec.ts
+++ b/frontend/src/views/admin/__tests__/SettingsView.spec.ts
@@ -605,12 +605,18 @@ describe("admin SettingsView email domain quota copy", () => {
expect(enCommon.auth.emailDomainRegistrationLimit).toContain("mainstream email");
expect(enCommon.auth.emailDomainRegistrationLimit).toContain("contact support");
- const zhHint = zhSettings.settings.registration.emailSuffixWhitelistHint;
- const enHint = enSettings.settings.registration.emailSuffixWhitelistHint;
- expect(zhHint).toContain("其他可注册主域名各限注册一个账户");
- expect(zhHint).toContain("清空白名单");
- expect(enHint).toContain("one account");
- expect(enHint).toContain("empty");
+ // 白名单 hint 描述严格默认语义;额度语义移入独立开关的 hint。
+ const zhWhitelistHint = zhSettings.settings.registration.emailSuffixWhitelistHint;
+ const enWhitelistHint = enSettings.settings.registration.emailSuffixWhitelistHint;
+ expect(zhWhitelistHint).toContain("留空则不限制");
+ expect(enWhitelistHint).toContain("leave empty for no restriction");
+
+ const zhQuotaHint = zhSettings.settings.registration.emailDomainQuotaHint;
+ const enQuotaHint = enSettings.settings.registration.emailDomainQuotaHint;
+ expect(zhQuotaHint).toContain("其他可注册主域名各限注册一个账户");
+ expect(zhQuotaHint).toContain("关闭时非白名单域名直接拒绝");
+ expect(enQuotaHint).toContain("one account");
+ expect(enQuotaHint).toContain("When disabled");
});
});
diff --git a/frontend/src/views/auth/EmailVerifyView.vue b/frontend/src/views/auth/EmailVerifyView.vue
index eab23e43c3..52198961e6 100644
--- a/frontend/src/views/auth/EmailVerifyView.vue
+++ b/frontend/src/views/auth/EmailVerifyView.vue
@@ -196,13 +196,18 @@ import {
import { apiClient } from '@/api/client'
import { buildAuthErrorMessage } from '@/utils/authError'
import { extractApiErrorCode } from '@/utils/apiError'
+import {
+ formatRegistrationEmailSuffixWhitelistForMessage,
+ isRegistrationEmailSuffixAllowed,
+ normalizeRegistrationEmailSuffixWhitelist
+} from '@/utils/registrationEmailPolicy'
import {
clearAllAffiliateReferralCodes,
loadAffiliateReferralCode,
oauthAffiliatePayload
} from '@/utils/oauthAffiliate'
-const { t } = useI18n()
+const { t, locale } = useI18n()
// ==================== Router & Stores ====================
@@ -266,6 +271,9 @@ const aliyunCaptchaSceneId = ref
('')
const aliyunCaptchaPrefix = ref('')
const aliyunCaptchaRegion = ref('cn')
const siteName = ref('Sub2API')
+const registrationEmailSuffixWhitelist = ref([])
+// 域名限量注册开关:开启时非白名单域名可注册 1 个账户(由后端判定),前端不做白名单预检。
+const emailDomainQuotaEnabled = ref(false)
// Turnstile for resend
const turnstileRef = ref | null>(null)
@@ -365,6 +373,10 @@ onMounted(async () => {
aliyunCaptchaPrefix.value = settings.aliyun_captcha_prefix || ''
aliyunCaptchaRegion.value = settings.aliyun_captcha_region || 'cn'
siteName.value = settings.site_name || 'Sub2API'
+ registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
+ settings.registration_email_suffix_whitelist || []
+ )
+ emailDomainQuotaEnabled.value = settings.registration_email_domain_quota_enabled === true
} catch (error) {
console.error('Failed to load public settings:', error)
}
@@ -473,6 +485,13 @@ function isPendingOAuthFlow(): boolean {
return Boolean(pendingProvider.value.trim())
}
+// 域名限量注册开启时交由后端按额度判定;pending OAuth / 换绑流程沿用后端策略,前端不预检。
+function shouldBypassRegistrationEmailPolicy(): boolean {
+ return (
+ emailDomainQuotaEnabled.value || isPendingOAuthFlow() || Boolean(pendingAuthToken.value.trim())
+ )
+}
+
function resolvePendingOAuthCallbackRoute(provider: string): string {
switch (provider.trim().toLowerCase()) {
case 'linuxdo':
@@ -514,6 +533,12 @@ async function sendCode(): Promise {
let captchaProofUsed = false
try {
+ if (!shouldBypassRegistrationEmailPolicy() && !isRegistrationEmailSuffixAllowed(email.value, registrationEmailSuffixWhitelist.value)) {
+ errorMessage.value = buildEmailSuffixNotAllowedMessage()
+ appStore.showError(errorMessage.value)
+ return
+ }
+
const requestPayload = {
email: email.value,
[pendingAuthTokenField.value]: pendingAuthToken.value || undefined,
@@ -635,6 +660,12 @@ async function handleVerify(): Promise {
return
}
+ if (!shouldBypassRegistrationEmailPolicy() && !isRegistrationEmailSuffixAllowed(email.value, registrationEmailSuffixWhitelist.value)) {
+ errorMessage.value = buildEmailSuffixNotAllowedMessage()
+ appStore.showError(errorMessage.value)
+ return
+ }
+
if (!(await acquireCreateAccountActionProof())) {
return
}
@@ -735,6 +766,22 @@ function handleBack(): void {
router.push('/register')
}
+function buildEmailSuffixNotAllowedMessage(): string {
+ const normalizedWhitelist = normalizeRegistrationEmailSuffixWhitelist(
+ registrationEmailSuffixWhitelist.value
+ )
+ if (normalizedWhitelist.length === 0) {
+ return t('auth.emailSuffixNotAllowed')
+ }
+ const separator = String(locale.value || '').toLowerCase().startsWith('zh') ? '、' : ', '
+ return t('auth.emailSuffixNotAllowedWithAllowed', {
+ suffixes: formatRegistrationEmailSuffixWhitelistForMessage(normalizedWhitelist, {
+ separator,
+ more: (count) => t('auth.emailSuffixAllowedMore', { count })
+ })
+ })
+}
+
function buildRegistrationErrorMessage(error: unknown, fallback: string): string {
if (extractApiErrorCode(error) === 'EMAIL_DOMAIN_REGISTRATION_LIMIT') {
return t('auth.emailDomainRegistrationLimit')
diff --git a/frontend/src/views/auth/RegisterView.vue b/frontend/src/views/auth/RegisterView.vue
index d6a19213aa..c4ccd9c98f 100644
--- a/frontend/src/views/auth/RegisterView.vue
+++ b/frontend/src/views/auth/RegisterView.vue
@@ -354,6 +354,11 @@ import {
} from '@/api/auth'
import { buildAuthErrorMessage } from '@/utils/authError'
import { extractApiErrorCode, extractI18nErrorMessage } from '@/utils/apiError'
+import {
+ formatRegistrationEmailSuffixWhitelistForMessage,
+ isRegistrationEmailSuffixAllowed,
+ normalizeRegistrationEmailSuffixWhitelist
+} from '@/utils/registrationEmailPolicy'
import {
clearAffiliateReferralCode,
loadAffiliateReferralCode,
@@ -361,7 +366,7 @@ import {
} from '@/utils/oauthAffiliate'
import type { LoginAgreementDocument } from '@/types'
-const { t } = useI18n()
+const { t, locale } = useI18n()
const LOGIN_AGREEMENT_STORAGE_KEY = 'sub2api_login_agreement_consent'
// ==================== Router & Stores ====================
@@ -400,6 +405,9 @@ const oidcOAuthEnabled = ref(false)
const oidcOAuthProviderName = ref('OIDC')
const githubOAuthEnabled = ref(false)
const googleOAuthEnabled = ref(false)
+const registrationEmailSuffixWhitelist = ref([])
+// 域名限量注册开关:开启时非白名单域名可注册 1 个账户(由后端判定),前端不做白名单预检。
+const emailDomainQuotaEnabled = ref(false)
const loginAgreementEnabled = ref(false)
const loginAgreementMode = ref<'modal' | 'checkbox' | string>('modal')
const loginAgreementUpdatedAt = ref('')
@@ -532,6 +540,10 @@ onMounted(async () => {
oidcOAuthProviderName.value = settings.oidc_oauth_provider_name || 'OIDC'
githubOAuthEnabled.value = settings.github_oauth_enabled
googleOAuthEnabled.value = settings.google_oauth_enabled
+ registrationEmailSuffixWhitelist.value = normalizeRegistrationEmailSuffixWhitelist(
+ settings.registration_email_suffix_whitelist || []
+ )
+ emailDomainQuotaEnabled.value = settings.registration_email_domain_quota_enabled === true
applyLoginAgreementSettings(settings)
// Read promo code from URL parameter only if promo code is enabled
@@ -850,6 +862,22 @@ function validateEmail(email: string): boolean {
return emailRegex.test(email)
}
+function buildEmailSuffixNotAllowedMessage(): string {
+ const normalizedWhitelist = normalizeRegistrationEmailSuffixWhitelist(
+ registrationEmailSuffixWhitelist.value
+ )
+ if (normalizedWhitelist.length === 0) {
+ return t('auth.emailSuffixNotAllowed')
+ }
+ const separator = String(locale.value || '').toLowerCase().startsWith('zh') ? '、' : ', '
+ return t('auth.emailSuffixNotAllowedWithAllowed', {
+ suffixes: formatRegistrationEmailSuffixWhitelistForMessage(normalizedWhitelist, {
+ separator,
+ more: (count) => t('auth.emailSuffixAllowedMore', { count })
+ })
+ })
+}
+
function validateForm(): boolean {
// Reset errors
errors.email = ''
@@ -874,6 +902,13 @@ function validateForm(): boolean {
} else if (!validateEmail(formData.email)) {
errors.email = t('auth.invalidEmail')
isValid = false
+ } else if (
+ !emailDomainQuotaEnabled.value &&
+ !isRegistrationEmailSuffixAllowed(formData.email, registrationEmailSuffixWhitelist.value)
+ ) {
+ // 域名限量注册关闭时保持严格白名单预检;开启时交给后端按域名额度判定
+ errors.email = buildEmailSuffixNotAllowedMessage()
+ isValid = false
}
// Password validation
diff --git a/frontend/src/views/auth/__tests__/EmailVerifyView.spec.ts b/frontend/src/views/auth/__tests__/EmailVerifyView.spec.ts
index ad45e1d01c..fc2d9715af 100644
--- a/frontend/src/views/auth/__tests__/EmailVerifyView.spec.ts
+++ b/frontend/src/views/auth/__tests__/EmailVerifyView.spec.ts
@@ -316,6 +316,7 @@ describe('EmailVerifyView', () => {
turnstile_site_key: '',
site_name: 'Sub2API',
registration_email_suffix_whitelist: ['allowed.com'],
+ registration_email_domain_quota_enabled: true,
})
sessionStorage.setItem(
'register_data',
@@ -350,6 +351,7 @@ describe('EmailVerifyView', () => {
turnstile_site_key: '',
site_name: 'Sub2API',
registration_email_suffix_whitelist: ['allowed.com'],
+ registration_email_domain_quota_enabled: true,
})
sendVerifyCodeMock.mockRejectedValueOnce({
reason: 'EMAIL_DOMAIN_REGISTRATION_LIMIT',
@@ -387,6 +389,7 @@ describe('EmailVerifyView', () => {
turnstile_site_key: '',
site_name: 'Sub2API',
registration_email_suffix_whitelist: ['allowed.com'],
+ registration_email_domain_quota_enabled: true,
})
sessionStorage.setItem(
'register_data',
@@ -422,6 +425,39 @@ describe('EmailVerifyView', () => {
)
})
+ // 域名限量注册开关默认关闭:恢复 PR5423 之前的客户端白名单预检,非白名单域名不发送验证码。
+ it('blocks sending a verification code for a non-whitelist email domain when the quota switch is disabled', async () => {
+ getPublicSettingsMock.mockResolvedValue({
+ turnstile_enabled: false,
+ turnstile_site_key: '',
+ site_name: 'Sub2API',
+ registration_email_suffix_whitelist: ['allowed.com'],
+ })
+ sessionStorage.setItem(
+ 'register_data',
+ JSON.stringify({
+ email: 'first@custom.example',
+ password: 'secret-123',
+ })
+ )
+
+ mount(EmailVerifyView, {
+ global: {
+ stubs: {
+ AuthLayout: { template: '
' },
+ Icon: true,
+ TurnstileWidget: true,
+ transition: false,
+ },
+ },
+ })
+
+ await flushPromises()
+
+ expect(sendVerifyCodeMock).not.toHaveBeenCalled()
+ expect(showErrorMock).toHaveBeenCalledWith('auth.emailSuffixNotAllowedWithAllowed')
+ })
+
it('uses the pending oauth verify-code endpoint when auth store only carries the pending provider', async () => {
authStoreState.pendingAuthSession = {
token: '',
diff --git a/frontend/src/views/auth/__tests__/RegisterView.spec.ts b/frontend/src/views/auth/__tests__/RegisterView.spec.ts
index 0dc7640da9..acd45c2728 100644
--- a/frontend/src/views/auth/__tests__/RegisterView.spec.ts
+++ b/frontend/src/views/auth/__tests__/RegisterView.spec.ts
@@ -122,7 +122,8 @@ describe('RegisterView invitation layout', () => {
getPublicSettingsMock.mockResolvedValueOnce({
...publicSettings,
turnstile_enabled: false,
- registration_email_suffix_whitelist: ['allowed.com']
+ registration_email_suffix_whitelist: ['allowed.com'],
+ registration_email_domain_quota_enabled: true
})
const wrapper = mountRegister()
@@ -142,7 +143,8 @@ describe('RegisterView invitation layout', () => {
getPublicSettingsMock.mockResolvedValueOnce({
...publicSettings,
turnstile_enabled: false,
- registration_email_suffix_whitelist: ['allowed.com']
+ registration_email_suffix_whitelist: ['allowed.com'],
+ registration_email_domain_quota_enabled: true
})
registerMock.mockRejectedValueOnce({
reason: 'EMAIL_DOMAIN_REGISTRATION_LIMIT',
@@ -160,4 +162,45 @@ describe('RegisterView invitation layout', () => {
'该邮箱域名无法注册新账户。请使用主流邮箱注册;如需使用企业邮箱,请联系客服添加域名白名单。'
)
})
+
+ // 域名限量注册开关默认关闭:恢复 PR5423 之前的客户端白名单预检,非白名单域名不发起注册请求。
+ it('rejects a non-whitelist email domain locally when the domain quota switch is disabled', async () => {
+ getPublicSettingsMock.mockResolvedValueOnce({
+ ...publicSettings,
+ turnstile_enabled: false,
+ registration_email_suffix_whitelist: ['allowed.com']
+ })
+
+ const wrapper = mountRegister()
+ await flushPromises()
+ await wrapper.get('#email').setValue('first@custom.example')
+ await wrapper.get('#password').setValue('secret-123')
+ await wrapper.get('form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(registerMock).not.toHaveBeenCalled()
+ // 校验失败通过 validationToastMessage watcher 弹 toast
+ expect(showErrorMock).toHaveBeenCalledWith('auth.emailSuffixNotAllowedWithAllowed')
+ expect(wrapper.get('#email').classes()).toContain('input-error')
+ })
+
+ it('still submits whitelisted email domains when the domain quota switch is disabled', async () => {
+ getPublicSettingsMock.mockResolvedValueOnce({
+ ...publicSettings,
+ turnstile_enabled: false,
+ registration_email_suffix_whitelist: ['allowed.com']
+ })
+
+ const wrapper = mountRegister()
+ await flushPromises()
+ await wrapper.get('#email').setValue('user@allowed.com')
+ await wrapper.get('#password').setValue('secret-123')
+ await wrapper.get('form').trigger('submit.prevent')
+ await flushPromises()
+
+ expect(registerMock).toHaveBeenCalledWith(
+ expect.objectContaining({ email: 'user@allowed.com' })
+ )
+ expect(showErrorMock).not.toHaveBeenCalled()
+ })
})