diff --git a/backend/internal/handler/admin/setting_handler.go b/backend/internal/handler/admin/setting_handler.go index ff76edda..c966cb7d 100644 --- a/backend/internal/handler/admin/setting_handler.go +++ b/backend/internal/handler/admin/setting_handler.go @@ -80,6 +80,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) { RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist, PromoCodeEnabled: settings.PromoCodeEnabled, PasswordResetEnabled: settings.PasswordResetEnabled, + FrontendURL: settings.FrontendURL, InvitationCodeEnabled: settings.InvitationCodeEnabled, TotpEnabled: settings.TotpEnabled, TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), @@ -137,6 +138,7 @@ type UpdateSettingsRequest struct { 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 双因素认证 @@ -326,6 +328,15 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { } } + // Frontend URL 验证 + req.FrontendURL = strings.TrimSpace(req.FrontendURL) + if req.FrontendURL != "" { + if err := config.ValidateAbsoluteHTTPURL(req.FrontendURL); err != nil { + response.BadRequest(c, "Frontend URL must be an absolute http(s) URL") + return + } + } + // 自定义菜单项验证 const ( maxCustomMenuItems = 20 @@ -437,6 +448,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist, PromoCodeEnabled: req.PromoCodeEnabled, PasswordResetEnabled: req.PasswordResetEnabled, + FrontendURL: req.FrontendURL, InvitationCodeEnabled: req.InvitationCodeEnabled, TotpEnabled: req.TotpEnabled, SMTPHost: req.SMTPHost, @@ -531,6 +543,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) { RegistrationEmailSuffixWhitelist: updatedSettings.RegistrationEmailSuffixWhitelist, PromoCodeEnabled: updatedSettings.PromoCodeEnabled, PasswordResetEnabled: updatedSettings.PasswordResetEnabled, + FrontendURL: updatedSettings.FrontendURL, InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled, TotpEnabled: updatedSettings.TotpEnabled, TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(), @@ -614,6 +627,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings, if before.PasswordResetEnabled != after.PasswordResetEnabled { changed = append(changed, "password_reset_enabled") } + if before.FrontendURL != after.FrontendURL { + changed = append(changed, "frontend_url") + } if before.TotpEnabled != after.TotpEnabled { changed = append(changed, "totp_enabled") } diff --git a/backend/internal/handler/auth_handler.go b/backend/internal/handler/auth_handler.go index 3b257189..f4ddf890 100644 --- a/backend/internal/handler/auth_handler.go +++ b/backend/internal/handler/auth_handler.go @@ -459,9 +459,9 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) { return } - frontendBaseURL := strings.TrimSpace(h.cfg.Server.FrontendURL) + frontendBaseURL := strings.TrimSpace(h.settingSvc.GetFrontendURL(c.Request.Context())) if frontendBaseURL == "" { - slog.Error("server.frontend_url not configured; cannot build password reset link") + slog.Error("frontend_url not configured in settings or config; cannot build password reset link") response.InternalError(c, "Password reset is not configured") return } diff --git a/backend/internal/handler/dto/settings.go b/backend/internal/handler/dto/settings.go index 3df54fe9..29b00bb8 100644 --- a/backend/internal/handler/dto/settings.go +++ b/backend/internal/handler/dto/settings.go @@ -22,6 +22,7 @@ type SystemSettings struct { 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 加密密钥是否已配置 diff --git a/backend/internal/server/api_contract_test.go b/backend/internal/server/api_contract_test.go index bd93e1a0..549e635a 100644 --- a/backend/internal/server/api_contract_test.go +++ b/backend/internal/server/api_contract_test.go @@ -493,6 +493,7 @@ func TestAPIContracts(t *testing.T) { "registration_email_suffix_whitelist": [], "promo_code_enabled": true, "password_reset_enabled": false, + "frontend_url": "", "totp_enabled": false, "totp_encryption_key_configured": false, "smtp_host": "smtp.example.com", diff --git a/backend/internal/service/domain_constants.go b/backend/internal/service/domain_constants.go index c605f67a..2d8681d4 100644 --- a/backend/internal/service/domain_constants.go +++ b/backend/internal/service/domain_constants.go @@ -80,6 +80,7 @@ const ( SettingKeyRegistrationEmailSuffixWhitelist = "registration_email_suffix_whitelist" // 注册邮箱后缀白名单(JSON 数组) SettingKeyPromoCodeEnabled = "promo_code_enabled" // 是否启用优惠码功能 SettingKeyPasswordResetEnabled = "password_reset_enabled" // 是否启用忘记密码功能(需要先开启邮件验证) + SettingKeyFrontendURL = "frontend_url" // 前端基础URL,用于生成邮件中的重置密码链接 SettingKeyInvitationCodeEnabled = "invitation_code_enabled" // 是否启用邀请码注册 // 邮件服务设置 diff --git a/backend/internal/service/setting_service.go b/backend/internal/service/setting_service.go index a8710b59..141cdb39 100644 --- a/backend/internal/service/setting_service.go +++ b/backend/internal/service/setting_service.go @@ -116,6 +116,15 @@ func (s *SettingService) GetAllSettings(ctx context.Context) (*SystemSettings, e return s.parseSettings(settings), nil } +// GetFrontendURL 获取前端基础URL(数据库优先,fallback 到配置文件) +func (s *SettingService) GetFrontendURL(ctx context.Context) string { + val, err := s.settingRepo.GetValue(ctx, SettingKeyFrontendURL) + if err == nil && strings.TrimSpace(val) != "" { + return strings.TrimSpace(val) + } + return s.cfg.Server.FrontendURL +} + // GetPublicSettings 获取公开设置(无需登录) func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings, error) { keys := []string{ @@ -401,6 +410,7 @@ func (s *SettingService) UpdateSettings(ctx context.Context, settings *SystemSet updates[SettingKeyRegistrationEmailSuffixWhitelist] = string(registrationEmailSuffixWhitelistJSON) updates[SettingKeyPromoCodeEnabled] = strconv.FormatBool(settings.PromoCodeEnabled) updates[SettingKeyPasswordResetEnabled] = strconv.FormatBool(settings.PasswordResetEnabled) + updates[SettingKeyFrontendURL] = settings.FrontendURL updates[SettingKeyInvitationCodeEnabled] = strconv.FormatBool(settings.InvitationCodeEnabled) updates[SettingKeyTotpEnabled] = strconv.FormatBool(settings.TotpEnabled) @@ -767,6 +777,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin RegistrationEmailSuffixWhitelist: ParseRegistrationEmailSuffixWhitelist(settings[SettingKeyRegistrationEmailSuffixWhitelist]), PromoCodeEnabled: settings[SettingKeyPromoCodeEnabled] != "false", // 默认启用 PasswordResetEnabled: emailVerifyEnabled && settings[SettingKeyPasswordResetEnabled] == "true", + FrontendURL: settings[SettingKeyFrontendURL], InvitationCodeEnabled: settings[SettingKeyInvitationCodeEnabled] == "true", TotpEnabled: settings[SettingKeyTotpEnabled] == "true", SMTPHost: settings[SettingKeySMTPHost], diff --git a/backend/internal/service/settings_view.go b/backend/internal/service/settings_view.go index 29eb0a36..71c2e7aa 100644 --- a/backend/internal/service/settings_view.go +++ b/backend/internal/service/settings_view.go @@ -6,6 +6,7 @@ type SystemSettings struct { RegistrationEmailSuffixWhitelist []string PromoCodeEnabled bool PasswordResetEnabled bool + FrontendURL string InvitationCodeEnabled bool TotpEnabled bool // TOTP 双因素认证 diff --git a/frontend/src/api/admin/settings.ts b/frontend/src/api/admin/settings.ts index b14390f3..040cf71e 100644 --- a/frontend/src/api/admin/settings.ts +++ b/frontend/src/api/admin/settings.ts @@ -21,6 +21,7 @@ export interface SystemSettings { registration_email_suffix_whitelist: string[] promo_code_enabled: boolean password_reset_enabled: boolean + frontend_url: string invitation_code_enabled: boolean totp_enabled: boolean // TOTP 双因素认证 totp_encryption_key_configured: boolean // TOTP 加密密钥是否已配置 @@ -91,6 +92,7 @@ export interface UpdateSettingsRequest { registration_email_suffix_whitelist?: string[] promo_code_enabled?: boolean password_reset_enabled?: boolean + frontend_url?: string invitation_code_enabled?: boolean totp_enabled?: boolean // TOTP 双因素认证 default_balance?: number diff --git a/frontend/src/i18n/locales/en.ts b/frontend/src/i18n/locales/en.ts index 7f96f63c..4ac03f3f 100644 --- a/frontend/src/i18n/locales/en.ts +++ b/frontend/src/i18n/locales/en.ts @@ -3966,6 +3966,9 @@ export default { invitationCodeHint: 'When enabled, users must enter a valid invitation code to register', passwordReset: 'Password Reset', passwordResetHint: 'Allow users to reset their password via email', + frontendUrl: 'Frontend URL', + frontendUrlPlaceholder: 'https://example.com', + frontendUrlHint: 'Used to generate password reset links in emails. Example: https://example.com', totp: 'Two-Factor Authentication (2FA)', totpHint: 'Allow users to use authenticator apps like Google Authenticator', totpKeyNotConfigured: diff --git a/frontend/src/i18n/locales/zh.ts b/frontend/src/i18n/locales/zh.ts index c25ff211..7e1660ec 100644 --- a/frontend/src/i18n/locales/zh.ts +++ b/frontend/src/i18n/locales/zh.ts @@ -4140,6 +4140,9 @@ export default { invitationCodeHint: '开启后,用户注册时需要填写有效的邀请码', passwordReset: '忘记密码', passwordResetHint: '允许用户通过邮箱重置密码', + frontendUrl: '前端地址', + frontendUrlPlaceholder: 'https://example.com', + frontendUrlHint: '用于生成邮件中的密码重置链接,例如 https://example.com', totp: '双因素认证 (2FA)', totpHint: '允许用户使用 Google Authenticator 等应用进行二次验证', totpKeyNotConfigured: diff --git a/frontend/src/views/admin/SettingsView.vue b/frontend/src/views/admin/SettingsView.vue index 7a4706ad..dfa30215 100644 --- a/frontend/src/views/admin/SettingsView.vue +++ b/frontend/src/views/admin/SettingsView.vue @@ -653,6 +653,24 @@ + +
+ + +

+ {{ t('admin.settings.registration.frontendUrlHint') }} +

+
+ @@ -1820,6 +1839,7 @@ const form = reactive({ purchase_subscription_url: '', sora_client_enabled: false, custom_menu_items: [] as Array<{id: string; label: string; icon_svg: string; url: string; visibility: 'user' | 'admin'; sort_order: number}>, + frontend_url: '', smtp_host: '', smtp_port: 587, smtp_username: '', @@ -2097,6 +2117,7 @@ async function saveSettings() { purchase_subscription_url: form.purchase_subscription_url, sora_client_enabled: form.sora_client_enabled, custom_menu_items: form.custom_menu_items, + frontend_url: form.frontend_url, smtp_host: form.smtp_host, smtp_port: form.smtp_port, smtp_username: form.smtp_username,