From 72c74803bec550c4527713c98aeccb014ab04a43 Mon Sep 17 00:00:00 2001 From: ryan Date: Mon, 8 Jun 2026 14:34:09 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/components/auth/login-form.tsx | 159 ++++++++++++-- .../components/common/settings/security.tsx | 12 + frontend/lib/services/auth/auth.service.ts | 4 + frontend/lib/services/auth/types.ts | 3 + frontend/lib/services/config/types.ts | 4 + .../apps/admin/system_config/routers_test.go | 10 +- internal/apps/config/routers.go | 50 +++-- internal/apps/user/controllers.go | 207 ++++++++++++++++++ internal/apps/user/tasks.go | 104 +++++++++ internal/db/migrator/migrator.go | 14 ++ internal/model/system_configs.go | 40 ++-- internal/model/users.go | 3 + internal/router/router.go | 1 + internal/task/constants.go | 12 + internal/task/worker/worker.go | 3 + internal/testhelper/test_helper.go | 12 + internal/util/strings.go | 16 ++ 17 files changed, 596 insertions(+), 58 deletions(-) create mode 100644 internal/apps/user/tasks.go diff --git a/frontend/components/auth/login-form.tsx b/frontend/components/auth/login-form.tsx index 81002140..ad772249 100644 --- a/frontend/components/auth/login-form.tsx +++ b/frontend/components/auth/login-form.tsx @@ -1,9 +1,9 @@ "use client" -import {useMemo, useRef, useState} from "react" +import {useEffect, useMemo, useRef, useState} from "react" import {useMutation, useQuery} from "@tanstack/react-query" import {useRouter, useSearchParams} from "next/navigation" -import {KeyRound, ShieldCheck, UserPlus} from "lucide-react" +import {KeyRound, UserPlus} from "lucide-react" import {toast} from "sonner" import {useAuth} from "@/components/providers/auth-provider" @@ -44,8 +44,28 @@ export function LoginForm() { const [username, setUsername] = useState("") const [password, setPassword] = useState("") const [nickname, setNickname] = useState("") + const [email, setEmail] = useState("") + const [code, setCode] = useState("") + const [showLoginCodeInput, setShowLoginCodeInput] = useState(false) + const [maskedEmail, setMaskedEmail] = useState("") + const [loginCooldown, setLoginCooldown] = useState(0) + const [registerCooldown, setRegisterCooldown] = useState(0) const [errorMessage, setErrorMessage] = useState("") + useEffect(() => { + if (loginCooldown > 0) { + const timer = setTimeout(() => setLoginCooldown(loginCooldown - 1), 1000) + return () => clearTimeout(timer) + } + }, [loginCooldown]) + + useEffect(() => { + if (registerCooldown > 0) { + const timer = setTimeout(() => setRegisterCooldown(registerCooldown - 1), 1000) + return () => clearTimeout(timer) + } + }, [registerCooldown]) + // Cap token management — ref to hold latest token without triggering re-render const capTokenRef = useRef(null) const [capReady, setCapReady] = useState(false) @@ -87,7 +107,22 @@ export function LoginForm() { router.replace(redirectTarget) }, onError: (error: Error) => { - toast.error(error.message || "登录失败,请重试") + const errorMsg = error.message || "" + if (errorMsg.startsWith("need_email_code:")) { + const emailMasked = errorMsg.substring("need_email_code:".length) + setMaskedEmail(emailMasked) + setShowLoginCodeInput(true) + setLoginCooldown(60) + toast.success("登录验证码已发送至您的邮箱,请注意查收") + if (capEnabled) { + capTokenRef.current = null + setCapReady(false) + setCapResetKey((key) => key + 1) + } + return + } + + toast.error(errorMsg || "登录失败,请重试") if (capEnabled) { capTokenRef.current = null setCapReady(false) @@ -107,6 +142,31 @@ export function LoginForm() { }, }) + const sendRegisterCodeMutation = useMutation({ + mutationFn: (targetEmail: string) => services.auth.sendEmailCode(targetEmail, "register"), + onSuccess: () => { + setRegisterCooldown(60) + toast.success("验证码已发送至您的邮箱,请查收") + }, + onError: (error: Error) => { + toast.error(error.message || "发送验证码失败,请重试") + }, + }) + + const handleSendRegisterCode = () => { + const trimmedEmail = email.trim() + if (!trimmedEmail) { + toast.error("请先输入邮箱地址") + return + } + const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/ + if (!emailRegex.test(trimmedEmail)) { + toast.error("请输入有效的邮箱地址") + return + } + sendRegisterCodeMutation.mutate(trimmedEmail) + } + const handlePasswordLogin = () => { setErrorMessage("") const trimmedUsername = username.trim() @@ -127,15 +187,33 @@ export function LoginForm() { loginMutation.mutate({ username: trimmedUsername, password, + code: showLoginCodeInput ? code.trim() : undefined, + }) + } + + const handleResendLoginCode = () => { + setCode("") + loginMutation.mutate({ + username: username.trim(), + password, }) } const handleRegister = () => { setErrorMessage("") + const emailRegisterEnabled = publicConfigQuery.data?.email_register_verification_enabled ?? false + if (emailRegisterEnabled) { + if (!email.trim() || !code.trim()) { + toast.error("邮箱和验证码不能为空") + return + } + } registerMutation.mutate({ username: username.trim(), password, nickname: nickname.trim() || undefined, + email: email.trim() || undefined, + code: code.trim() || undefined, }) } @@ -215,6 +293,33 @@ export function LoginForm() { autoComplete="current-password" onKeyDown={(e) => e.key === "Enter" && handlePasswordLogin()} /> + + {showLoginCodeInput && ( +
+

+ 已向您的安全邮箱 {maskedEmail} 发送了登录验证码。 +

+
+ setCode(e.target.value)} + placeholder="6 位邮箱验证码" + maxLength={6} + className="flex-1" + onKeyDown={(e) => e.key === "Enter" && handlePasswordLogin()} + /> + +
+
+ )} {/* Cap 人机验证 */} @@ -271,6 +376,32 @@ export function LoginForm() { placeholder="密码(至少 8 位)" autoComplete="new-password" /> + setEmail(e.target.value)} + placeholder={publicConfigQuery.data?.email_register_verification_enabled ? "电子邮箱" : "电子邮箱(可选)"} + autoComplete="email" + /> + {publicConfigQuery.data?.email_register_verification_enabled && ( +
+ setCode(e.target.value)} + placeholder="6 位邮箱验证码" + maxLength={6} + className="flex-1" + /> + +
+ )} {errorMessage ? ( @@ -307,20 +438,16 @@ export function LoginForm() { {authSources.length > 0 ? ( authSources.map((source) => (
-
- - 第三方认证源 -
- +
)) diff --git a/frontend/components/common/settings/security.tsx b/frontend/components/common/settings/security.tsx index 9780720f..eb152015 100644 --- a/frontend/components/common/settings/security.tsx +++ b/frontend/components/common/settings/security.tsx @@ -60,6 +60,18 @@ const SECURITY_KEYS = [ description: "关闭后所有的第三方 OIDC 认证登录入口都会被隐藏。", icon: Globe, }, + { + key: "email_login_verification_enabled", + title: "邮箱登录验证", + description: "开启后,使用账号密码登录时需要通过邮箱接收并验证 6 位验证码。", + icon: Mail, + }, + { + key: "email_register_verification_enabled", + title: "邮箱注册验证", + description: "开启后,用户注册账号时需要通过邮箱接收并验证 6 位验证码。", + icon: Mail, + }, ] as const type SecurityKey = (typeof SECURITY_KEYS)[number]["key"] diff --git a/frontend/lib/services/auth/auth.service.ts b/frontend/lib/services/auth/auth.service.ts index 6530955c..7d7660c3 100644 --- a/frontend/lib/services/auth/auth.service.ts +++ b/frontend/lib/services/auth/auth.service.ts @@ -111,6 +111,10 @@ export class AuthService extends BaseService { return this.post('/user/register', request); } + static async sendEmailCode(email: string, scene: 'register' | 'login'): Promise { + return this.post('/user/send-email-code', { email, scene }); + } + static async changePassword(request: ChangePasswordRequest): Promise { return this.post('/user/change-password', request); } diff --git a/frontend/lib/services/auth/types.ts b/frontend/lib/services/auth/types.ts index 47c468ba..17df127e 100644 --- a/frontend/lib/services/auth/types.ts +++ b/frontend/lib/services/auth/types.ts @@ -35,12 +35,15 @@ export interface OAuthCallbackRequest { export interface LoginRequest { username: string; password: string; + code?: string; } export interface RegisterRequest { username: string; password: string; nickname?: string; + email?: string; + code?: string; } export interface OAuthAuthorizeResponse { diff --git a/frontend/lib/services/config/types.ts b/frontend/lib/services/config/types.ts index ebdbcc70..4cfd267c 100644 --- a/frontend/lib/services/config/types.ts +++ b/frontend/lib/services/config/types.ts @@ -20,4 +20,8 @@ export interface PublicConfigResponse { cap_login_enabled: boolean; /** 是否自动解题 */ cap_auto_solve: boolean; + /** 是否启用邮箱登录验证 */ + email_login_verification_enabled: boolean; + /** 是否启用邮箱注册验证 */ + email_register_verification_enabled: boolean; } diff --git a/internal/apps/admin/system_config/routers_test.go b/internal/apps/admin/system_config/routers_test.go index 78d89b93..c18f3e1b 100644 --- a/internal/apps/admin/system_config/routers_test.go +++ b/internal/apps/admin/system_config/routers_test.go @@ -146,9 +146,9 @@ func TestListSystemConfigs(t *testing.T) { var configs []model.SystemConfig json.Unmarshal(dataBytes, &configs) - // Defaults seed 19 configurations - if len(configs) != 19 { - t.Errorf("expected 19 default configs, got %d", len(configs)) + // Defaults seed 21 configurations + if len(configs) != 21 { + t.Errorf("expected 21 default configs, got %d", len(configs)) } }) @@ -193,8 +193,8 @@ func TestGetSystemConfig(t *testing.T) { var cfg model.SystemConfig json.Unmarshal(dataBytes, &cfg) - if cfg.Value != "Antigravity Project" { - t.Errorf("expected 'Antigravity Project', got '%s'", cfg.Value) + if cfg.Value != "Wavelet" { + t.Errorf("expected 'Wavelet', got '%s'", cfg.Value) } }) diff --git a/internal/apps/config/routers.go b/internal/apps/config/routers.go index 0389e464..845b00e5 100644 --- a/internal/apps/config/routers.go +++ b/internal/apps/config/routers.go @@ -26,15 +26,17 @@ import ( // PublicConfigResponse 公共配置响应 type PublicConfigResponse struct { - UploadAllowedExtensions string `json:"upload_allowed_extensions"` // 允许上传的图片扩展名 - SiteName string `json:"site_name"` // 站点名称 - PasswordLoginEnabled bool `json:"password_login_enabled"` // 是否允许密码登录 - RegistrationEnabled bool `json:"registration_enabled"` // 是否允许注册 - PasswordRegisterEnabled bool `json:"password_register_enabled"` // 是否允许密码注册 - OIDCLoginEnabled bool `json:"oidc_login_enabled"` // 是否允许 OIDC 登录 - MaxAPIKeysPerUser int `json:"max_api_keys_per_user"` // 每个用户最大 API Key 数量 - CapLoginEnabled bool `json:"cap_login_enabled"` // 是否启用人机验证 - CapAutoSolve bool `json:"cap_auto_solve"` // 打开页面后是否自动开始计算 + UploadAllowedExtensions string `json:"upload_allowed_extensions"` // 允许上传的图片扩展名 + SiteName string `json:"site_name"` // 站点名称 + PasswordLoginEnabled bool `json:"password_login_enabled"` // 是否允许密码登录 + RegistrationEnabled bool `json:"registration_enabled"` // 是否允许注册 + PasswordRegisterEnabled bool `json:"password_register_enabled"` // 是否允许密码注册 + OIDCLoginEnabled bool `json:"oidc_login_enabled"` // 是否允许 OIDC 登录 + MaxAPIKeysPerUser int `json:"max_api_keys_per_user"` // 每个用户最大 API Key 数量 + CapLoginEnabled bool `json:"cap_login_enabled"` // 是否启用人机验证 + CapAutoSolve bool `json:"cap_auto_solve"` // 打开页面后是否自动开始计算 + EmailLoginVerificationEnabled bool `json:"email_login_verification_enabled"` // 是否启用邮箱登录验证 + EmailRegisterVerificationEnabled bool `json:"email_register_verification_enabled"` // 是否启用邮箱注册验证 } // GetPublicConfig 获取公共配置 @@ -103,16 +105,28 @@ func GetPublicConfig(c *gin.Context) { maxAPIKeys = val } + var emailLoginVerificationEnabled bool + if val, err := model.GetBoolByKey(ctx, model.ConfigKeyEmailLoginVerificationEnabled); err == nil { + emailLoginVerificationEnabled = val + } + + var emailRegisterVerificationEnabled bool + if val, err := model.GetBoolByKey(ctx, model.ConfigKeyEmailRegisterVerificationEnabled); err == nil { + emailRegisterVerificationEnabled = val + } + response := PublicConfigResponse{ - UploadAllowedExtensions: uploadExtensions, - SiteName: siteName, - PasswordLoginEnabled: passwordLoginEnabled, - RegistrationEnabled: registrationEnabled, - PasswordRegisterEnabled: passwordRegisterEnabled, - OIDCLoginEnabled: oidcLoginEnabled, - MaxAPIKeysPerUser: maxAPIKeys, - CapLoginEnabled: capLoginEnabled, - CapAutoSolve: capAutoSolve, + UploadAllowedExtensions: uploadExtensions, + SiteName: siteName, + PasswordLoginEnabled: passwordLoginEnabled, + RegistrationEnabled: registrationEnabled, + PasswordRegisterEnabled: passwordRegisterEnabled, + OIDCLoginEnabled: oidcLoginEnabled, + MaxAPIKeysPerUser: maxAPIKeys, + CapLoginEnabled: capLoginEnabled, + CapAutoSolve: capAutoSolve, + EmailLoginVerificationEnabled: emailLoginVerificationEnabled, + EmailRegisterVerificationEnabled: emailRegisterVerificationEnabled, } c.JSON(http.StatusOK, util.OK(response)) diff --git a/internal/apps/user/controllers.go b/internal/apps/user/controllers.go index 2657eb23..59147574 100644 --- a/internal/apps/user/controllers.go +++ b/internal/apps/user/controllers.go @@ -2,6 +2,10 @@ package user import ( "context" + "crypto/rand" + "encoding/json" + "fmt" + "math/big" "net/http" "strings" "time" @@ -12,12 +16,14 @@ import ( "github.com/linux-do/credit/internal/common" "github.com/linux-do/credit/internal/db" "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/task" "github.com/linux-do/credit/internal/util" ) type loginRequest struct { Username string `json:"username"` Password string `json:"password"` + Code string `json:"code"` } type registerRequest struct { @@ -25,6 +31,34 @@ type registerRequest struct { Password string `json:"password"` Nickname string `json:"nickname"` DisplayName string `json:"display_name"` + Email string `json:"email"` + Code string `json:"code"` +} + +type sendEmailCodeRequest struct { + Email string `json:"email" binding:"required,email"` + Scene string `json:"scene" binding:"required"` +} + +func isEmailLoginVerificationEnabled() bool { + enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyEmailLoginVerificationEnabled) + if err != nil { + return false + } + return enabled +} + +func isEmailRegisterVerificationEnabled() bool { + enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyEmailRegisterVerificationEnabled) + if err != nil { + return false + } + return enabled +} + +func generateVerificationCode() string { + n, _ := rand.Int(rand.Reader, big.NewInt(900000)) + return fmt.Sprintf("%06d", n.Int64()+100000) } func isPasswordLoginEnabled() bool { @@ -107,6 +141,65 @@ func Login(c *gin.Context) { return } + if isEmailLoginVerificationEnabled() { + if user.Email == "" { + c.JSON(http.StatusOK, util.Err("该账号未绑定邮箱,请联系管理员绑定邮箱后再登录")) + return + } + + if req.Code == "" { + // 校验 Redis 发送冷却时间 + cooldownKey := fmt.Sprintf("email_code:cooldown:%s", user.Email) + var temp string + err := db.GetJSON(ctx, cooldownKey, &temp) + if err != nil { + // 没有冷却,触发验证码发送 + code := generateVerificationCode() + codeKey := fmt.Sprintf("email_code:login:%s", user.Email) + // 存验证码,5分钟有效 + if err := db.SetJSON(ctx, codeKey, code, 5*time.Minute); err != nil { + c.JSON(http.StatusOK, util.Err("生成验证码失败,请重试")) + return + } + // 存冷却,60秒有效 + _ = db.SetJSON(ctx, cooldownKey, "1", 60*time.Second) + + // 构建异步邮件发送任务 + payload := SendEmailPayload{ + To: user.Email, + Subject: "Wavelet 登录验证码", + Body: fmt.Sprintf("

Wavelet 登录验证

您的登录验证码为:%s,5分钟内有效,请勿将验证码泄露给他人。

", code), + } + payloadBytes, _ := json.Marshal(payload) + _, err = task.DispatchTask(ctx, task.TaskTypeSendEmail, payloadBytes, "system") + if err != nil { + c.JSON(http.StatusOK, util.Err("投递验证邮件发送任务失败,请重试")) + return + } + } + + // 脱敏邮箱并返回错误,提示前端需要输入验证码 + maskedEmail := util.MaskEmail(user.Email) + c.JSON(http.StatusOK, util.Err("need_email_code:"+maskedEmail)) + return + } + + // 校验验证码 + codeKey := fmt.Sprintf("email_code:login:%s", user.Email) + var storedCode string + if err := db.GetJSON(ctx, codeKey, &storedCode); err != nil { + c.JSON(http.StatusOK, util.Err("验证码错误或已过期")) + return + } + if storedCode != req.Code { + c.JSON(http.StatusOK, util.Err("验证码错误或已过期")) + return + } + + // 验证成功,删除验证码 + _ = db.Redis.Del(ctx, db.PrefixedKey(codeKey)).Err() + } + session := sessions.Default(c) needChangePassword := false @@ -163,6 +256,8 @@ func Register(c *gin.Context) { req.Password = strings.TrimSpace(req.Password) req.Nickname = strings.TrimSpace(req.Nickname) req.DisplayName = strings.TrimSpace(req.DisplayName) + req.Email = strings.TrimSpace(req.Email) + req.Code = strings.TrimSpace(req.Code) if req.Username == "" || req.Password == "" { c.JSON(http.StatusOK, util.Err("无效的参数")) @@ -174,6 +269,29 @@ func Register(c *gin.Context) { } ctx := c.Request.Context() + + // 邮箱注册验证校验 + if isEmailRegisterVerificationEnabled() { + if req.Email == "" || req.Code == "" { + c.JSON(http.StatusOK, util.Err("邮箱或验证码未填写")) + return + } + + codeKey := fmt.Sprintf("email_code:register:%s", req.Email) + var storedCode string + if err := db.GetJSON(ctx, codeKey, &storedCode); err != nil { + c.JSON(http.StatusOK, util.Err("验证码错误或已过期")) + return + } + if storedCode != req.Code { + c.JSON(http.StatusOK, util.Err("验证码错误或已过期")) + return + } + + // 验证通过,删除 Redis 中的验证码 + _ = db.Redis.Del(ctx, db.PrefixedKey(codeKey)).Err() + } + var count int64 if err := db.DB(ctx).Model(&model.User{}).Where("username = ?", req.Username).Count(&count).Error; err != nil { c.JSON(http.StatusOK, util.Err(err.Error())) @@ -184,9 +302,23 @@ func Register(c *gin.Context) { return } + // 校验邮箱是否已被其他用户使用 + if req.Email != "" { + var emailCount int64 + if err := db.DB(ctx).Model(&model.User{}).Where("email = ?", req.Email).Count(&emailCount).Error; err != nil { + c.JSON(http.StatusOK, util.Err(err.Error())) + return + } + if emailCount > 0 { + c.JSON(http.StatusOK, util.Err("该邮箱已被其他账号绑定")) + return + } + } + user := model.User{ Username: req.Username, Nickname: req.Nickname, + Email: req.Email, AvatarUrl: "", IsActive: true, IsAdmin: false, @@ -308,3 +440,78 @@ func ChangePassword(c *gin.Context) { c.JSON(http.StatusOK, util.OK("密码修改成功")) } + +// SendEmailCode 发送邮箱验证码 +// @Summary 发送邮箱验证码 +// @Description 向指定邮箱发送验证码(用于注册场景) +// @Tags user +// @Accept json +// @Produce json +// @Param request body user.sendEmailCodeRequest true "发送验证码请求参数" +// @Success 200 {object} util.ResponseAny "发送成功" +// @Failure 400 {object} util.ResponseAny "参数错误" +// @Router /api/v1/user/send-email-code [post] +func SendEmailCode(c *gin.Context) { + var req sendEmailCodeRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, util.Err(err.Error())) + return + } + + req.Email = strings.TrimSpace(req.Email) + if req.Email == "" { + c.JSON(http.StatusOK, util.Err("邮箱地址不能为空")) + return + } + + if req.Scene != "register" { + c.JSON(http.StatusOK, util.Err("不支持的验证场景")) + return + } + + ctx := c.Request.Context() + + // 1. 检查邮箱是否已被注册 + var count int64 + if err := db.DB(ctx).Model(&model.User{}).Where("email = ?", req.Email).Count(&count).Error; err != nil { + c.JSON(http.StatusOK, util.Err(err.Error())) + return + } + if count > 0 { + c.JSON(http.StatusOK, util.Err("该邮箱已被注册")) + return + } + + // 2. 校验 Redis 发送冷却时间 + cooldownKey := fmt.Sprintf("email_code:cooldown:%s", req.Email) + var temp string + err := db.GetJSON(ctx, cooldownKey, &temp) + if err == nil { + c.JSON(http.StatusOK, util.Err("验证码发送频繁,请稍后再试")) + return + } + + // 3. 生成并缓存验证码 + code := generateVerificationCode() + codeKey := fmt.Sprintf("email_code:register:%s", req.Email) + if err := db.SetJSON(ctx, codeKey, code, 5*time.Minute); err != nil { + c.JSON(http.StatusOK, util.Err("生成验证码失败,请重试")) + return + } + _ = db.SetJSON(ctx, cooldownKey, "1", 60*time.Second) + + // 4. 投递异步邮件发送任务 + payload := SendEmailPayload{ + To: req.Email, + Subject: "Wavelet 注册验证码", + Body: fmt.Sprintf("

Wavelet 注册验证

您的注册验证码为:%s,5分钟内有效,请勿泄露给他人。

", code), + } + payloadBytes, _ := json.Marshal(payload) + _, err = task.DispatchTask(ctx, task.TaskTypeSendEmail, payloadBytes, "system") + if err != nil { + c.JSON(http.StatusOK, util.Err("投递验证邮件发送任务失败,请重试")) + return + } + + c.JSON(http.StatusOK, util.OKNil()) +} diff --git a/internal/apps/user/tasks.go b/internal/apps/user/tasks.go new file mode 100644 index 00000000..7a36bb08 --- /dev/null +++ b/internal/apps/user/tasks.go @@ -0,0 +1,104 @@ +/* +Copyright 2026 linux.do + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +package user + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strconv" + + "github.com/linux-do/credit/internal/model" + "github.com/linux-do/credit/internal/task" + "github.com/linux-do/credit/internal/util/mail" +) + +// SendEmailPayload 邮件发送任务载荷 +type SendEmailPayload struct { + To string `json:"to"` + Subject string `json:"subject"` + Body string `json:"body"` +} + +// SendEmailHandler 发送验证码邮件的异步任务处理器 +type SendEmailHandler struct{} + +// Execute 执行邮件异步发送逻辑 +func (h *SendEmailHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) { + var req SendEmailPayload + if err := json.Unmarshal(payload, &req); err != nil { + task.AppendLog(ctx, "解析邮件发送参数失败: %v", err) + return nil, fmt.Errorf("解析邮件发送参数失败: %w", err) + } + + task.AppendLog(ctx, "开始准备发送邮件到: %s, 主题: %s", req.To, req.Subject) + + // 从数据库读取最新的 SMTP 系统配置 + var smtpHost string + var smtpPortVal string + var smtpUsername string + var smtpPassword string + + var sc model.SystemConfig + if err := sc.GetByKey(ctx, model.ConfigKeySMTPHost); err == nil { + smtpHost = sc.Value + } + if err := sc.GetByKey(ctx, model.ConfigKeySMTPPort); err == nil { + smtpPortVal = sc.Value + } + if err := sc.GetByKey(ctx, model.ConfigKeySMTPUsername); err == nil { + smtpUsername = sc.Value + } + if err := sc.GetByKey(ctx, model.ConfigKeySMTPPassword); err == nil { + smtpPassword = sc.Value + } + + if smtpHost == "" || smtpPortVal == "" || smtpUsername == "" { + err := errors.New("系统 SMTP 邮件服务配置不完整") + task.AppendLog(ctx, "发送失败: %v", err) + return nil, err + } + + smtpPort, err := strconv.Atoi(smtpPortVal) + if err != nil { + smtpPort = 587 + } + + cfg := mail.Config{ + Host: smtpHost, + Port: smtpPort, + Username: smtpUsername, + Password: smtpPassword, + } + + task.AppendLog(ctx, "连接 SMTP 服务器: %s:%d, 用户名: %s", smtpHost, smtpPort, smtpUsername) + + // 调用 SendMailHTML 执行邮件发送,这里会有 5s 拨号超时和 10s 读写限制 + err = mail.SendMailHTML(cfg, req.To, req.Subject, req.Body) + if err != nil { + task.AppendLog(ctx, "邮件发送失败: %v", err) + return nil, fmt.Errorf("发送邮件失败: %w", err) + } + + msg := fmt.Sprintf("邮件成功发送至: %s", req.To) + task.AppendLog(ctx, "%s", msg) + + return &task.TaskResult{ + Message: msg, + }, nil +} diff --git a/internal/db/migrator/migrator.go b/internal/db/migrator/migrator.go index d77d33c0..0987d778 100644 --- a/internal/db/migrator/migrator.go +++ b/internal/db/migrator/migrator.go @@ -94,6 +94,8 @@ func initSystemConfigs() { ensureConfigKeyExists(model.ConfigKeySMTPPort, "587", "system", "SMTP 端口(例如 587 或 465)") ensureConfigKeyExists(model.ConfigKeySMTPUsername, "", "system", "SMTP 账户(如 sender@example.com)") ensureConfigKeyExists(model.ConfigKeySMTPPassword, "", "system", "SMTP 访问凭证(授权码/密码)") + ensureConfigKeyExists(model.ConfigKeyEmailLoginVerificationEnabled, "false", "system", "是否开启邮箱登录验证(true/false)") + ensureConfigKeyExists(model.ConfigKeyEmailRegisterVerificationEnabled, "false", "system", "是否开启邮箱注册验证(true/false)") return } @@ -212,6 +214,18 @@ func initSystemConfigs() { Type: "business", Description: "限制每个普通用户可以创建的 API Key 最大数量", }, + { + Key: model.ConfigKeyEmailLoginVerificationEnabled, + Value: "false", + Type: "system", + Description: "是否开启邮箱登录验证(true/false)", + }, + { + Key: model.ConfigKeyEmailRegisterVerificationEnabled, + Value: "false", + Type: "system", + Description: "是否开启邮箱注册验证(true/false)", + }, } if err := tx.Create(&defaultConfigs).Error; err != nil { diff --git a/internal/model/system_configs.go b/internal/model/system_configs.go index 925cc544..e441ac70 100644 --- a/internal/model/system_configs.go +++ b/internal/model/system_configs.go @@ -31,25 +31,27 @@ import ( // 配置键常量 - 所有系统配置的 key 定义 const ( - ConfigKeyUploadAllowedExtensions = "upload_allowed_extensions" // 允许上传的文件扩展名,逗号分隔 - ConfigKeySiteName = "site_name" // 站点名称 - ConfigKeyPasswordLoginEnabled = "password_login_enabled" // 是否允许密码登录 - ConfigKeyRegistrationEnabled = "registration_enabled" // 是否允许注册 - ConfigKeyPasswordRegisterEnabled = "password_register_enabled" // 是否允许密码注册 - ConfigKeyOIDCLoginEnabled = "oidc_login_enabled" // 是否允许 OIDC 登录 - ConfigKeyMaxAPIKeysPerUser = "max_api_keys_per_user" // 每个用户最大 API Key 数量 - ConfigKeyCapLoginEnabled = "cap_login_enabled" // 是否启用登录人机验证 - ConfigKeyCapAutoSolve = "cap_auto_solve" // 打开页面后是否自动开始计算(false 则需用户手动点击) - ConfigKeyCapChallengeCount = "cap_challenge_count" // 客户端需求解的 PoW 难题总数,默认 1,推荐 1~5 - ConfigKeyCapChallengeSize = "cap_challenge_size" // 人机验证盐值长度 - ConfigKeyCapChallengeDifficulty = "cap_challenge_difficulty" // 人机验证 PoW 难度(目标前缀长度) - ConfigKeyCapChallengeTTL = "cap_challenge_ttl_seconds" // 人机验证难题有效时间(秒) - ConfigKeyCapTokenTTL = "cap_token_ttl_seconds" // 人机验证兑换凭证有效时间(秒) - ConfigKeyServerAddress = "server_address" // 服务器地址 - ConfigKeySMTPHost = "smtp_host" // SMTP 服务器地址 - ConfigKeySMTPPort = "smtp_port" // SMTP 端口 - ConfigKeySMTPUsername = "smtp_username" // SMTP 账户 - ConfigKeySMTPPassword = "smtp_password" // SMTP 访问凭证 + ConfigKeyUploadAllowedExtensions = "upload_allowed_extensions" // 允许上传的文件扩展名,逗号分隔 + ConfigKeySiteName = "site_name" // 站点名称 + ConfigKeyPasswordLoginEnabled = "password_login_enabled" // 是否允许密码登录 + ConfigKeyRegistrationEnabled = "registration_enabled" // 是否允许注册 + ConfigKeyPasswordRegisterEnabled = "password_register_enabled" // 是否允许密码注册 + ConfigKeyOIDCLoginEnabled = "oidc_login_enabled" // 是否允许 OIDC 登录 + ConfigKeyMaxAPIKeysPerUser = "max_api_keys_per_user" // 每个用户最大 API Key 数量 + ConfigKeyCapLoginEnabled = "cap_login_enabled" // 是否启用登录人机验证 + ConfigKeyCapAutoSolve = "cap_auto_solve" // 打开页面后是否自动开始计算(false 则需用户手动点击) + ConfigKeyCapChallengeCount = "cap_challenge_count" // 客户端需求解的 PoW 难题总数,默认 1,推荐 1~5 + ConfigKeyCapChallengeSize = "cap_challenge_size" // 人机验证盐值长度 + ConfigKeyCapChallengeDifficulty = "cap_challenge_difficulty" // 人机验证 PoW 难度(目标前缀长度) + ConfigKeyCapChallengeTTL = "cap_challenge_ttl_seconds" // 人机验证难题有效时间(秒) + ConfigKeyCapTokenTTL = "cap_token_ttl_seconds" // 人机验证兑换凭证有效时间(秒) + ConfigKeyServerAddress = "server_address" // 服务器地址 + ConfigKeySMTPHost = "smtp_host" // SMTP 服务器地址 + ConfigKeySMTPPort = "smtp_port" // SMTP 端口 + ConfigKeySMTPUsername = "smtp_username" // SMTP 账户 + ConfigKeySMTPPassword = "smtp_password" // SMTP 访问凭证 + ConfigKeyEmailLoginVerificationEnabled = "email_login_verification_enabled" // 是否启用邮箱登录验证 + ConfigKeyEmailRegisterVerificationEnabled = "email_register_verification_enabled" // 是否启用邮箱注册验证 ) const ( diff --git a/internal/model/users.go b/internal/model/users.go index 0e171f34..56f3c9f4 100644 --- a/internal/model/users.go +++ b/internal/model/users.go @@ -58,6 +58,7 @@ type User struct { Username string `json:"username" gorm:"size:64;uniqueIndex"` Password string `json:"password,omitempty" gorm:"size:255"` Nickname string `json:"nickname" gorm:"size:255"` + Email string `json:"email" gorm:"size:255;index"` AvatarUrl string `json:"avatar_url" gorm:"size:255"` IsActive bool `json:"is_active" gorm:"default:true;index"` IsAdmin bool `json:"is_admin" gorm:"default:false"` @@ -106,6 +107,7 @@ func (u *User) GetByID(tx *gorm.DB, id uint64) error { func (u *User) UpdateFromOAuthInfo(oauthInfo *OAuthUserInfo) { u.Username = oauthInfo.Username u.Nickname = oauthInfo.Name + u.Email = oauthInfo.Email u.AvatarUrl = oauthInfo.AvatarUrl u.IsActive = oauthInfo.Active u.LastLoginAt = time.Now() @@ -126,6 +128,7 @@ func (u *User) CreateUser(tx *gorm.DB, oauthInfo *OAuthUserInfo) error { ID: oauthInfo.GetID(), Username: oauthInfo.Username, Nickname: oauthInfo.Name, + Email: oauthInfo.Email, AvatarUrl: oauthInfo.AvatarUrl, IsActive: oauthInfo.Active, LastLoginAt: now, diff --git a/internal/router/router.go b/internal/router/router.go index b19fc30c..3f6e999c 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -144,6 +144,7 @@ func Serve() { return enabled }), user.Login) userRouter.POST("/register", user.Register) + userRouter.POST("/send-email-code", user.SendEmailCode) userRouter.GET("/logout", user.Logout) userRouter.GET("/self", oauth.LoginRequired(), oauth.UserInfo) userRouter.POST("/change-password", oauth.LoginRequired(), user.ChangePassword) diff --git a/internal/task/constants.go b/internal/task/constants.go index 7c62618e..99a52d4c 100644 --- a/internal/task/constants.go +++ b/internal/task/constants.go @@ -18,6 +18,7 @@ package task const ( CleanupUnusedUploadsTask = "upload:cleanup_unused" + SendEmailTask = "mail:send" ) const ( @@ -27,6 +28,7 @@ const ( // 管理员可下发的任务类型标识 const ( TaskTypeCleanupUploads = "cleanup_unused_uploads" + TaskTypeSendEmail = "send_email" ) // TaskMeta 任务元数据 @@ -53,6 +55,16 @@ var DispatchableTasks = []TaskMeta{ Queue: QueueDefault, Retryable: true, }, + { + Type: TaskTypeSendEmail, + AsynqTask: SendEmailTask, + Name: "发送邮件", + Description: "异步发送系统邮件", + SupportsTime: false, + MaxRetry: 3, + Queue: QueueDefault, + Retryable: true, + }, } // GetTaskMeta 根据任务类型获取元数据 diff --git a/internal/task/worker/worker.go b/internal/task/worker/worker.go index a4cf5cc9..0198dc6c 100644 --- a/internal/task/worker/worker.go +++ b/internal/task/worker/worker.go @@ -21,6 +21,7 @@ import ( "github.com/hibiken/asynq" "github.com/linux-do/credit/internal/apps/upload" + "github.com/linux-do/credit/internal/apps/user" "github.com/linux-do/credit/internal/config" "github.com/linux-do/credit/internal/task" ) @@ -28,6 +29,7 @@ import ( func init() { // 注册所有任务处理器 task.RegisterHandler(task.CleanupUnusedUploadsTask, &upload.CleanupUnusedUploadsHandler{}) + task.RegisterHandler(task.SendEmailTask, &user.SendEmailHandler{}) } // StartWorker 启动任务处理服务器 @@ -49,6 +51,7 @@ func StartWorker() error { // 统一使用 task.ProcessTask 处理所有任务类型 // 框架内部自动分发到对应的 TaskHandler 实现 mux.HandleFunc(task.CleanupUnusedUploadsTask, task.ProcessTask) + mux.HandleFunc(task.SendEmailTask, task.ProcessTask) // 启动服务器 return asynqServer.Run(mux) diff --git a/internal/testhelper/test_helper.go b/internal/testhelper/test_helper.go index 257360da..6bba001e 100644 --- a/internal/testhelper/test_helper.go +++ b/internal/testhelper/test_helper.go @@ -206,6 +206,18 @@ func seedDefaultConfigs(t *testing.T, tx *gorm.DB) { Type: "system", Description: "SMTP 访问凭证(授权码/密码)", }, + { + Key: model.ConfigKeyEmailLoginVerificationEnabled, + Value: "false", + Type: "system", + Description: "是否开启邮箱登录验证(true/false)", + }, + { + Key: model.ConfigKeyEmailRegisterVerificationEnabled, + Value: "false", + Type: "system", + Description: "是否开启邮箱注册验证(true/false)", + }, } if err := tx.Create(&defaultConfigs).Error; err != nil { diff --git a/internal/util/strings.go b/internal/util/strings.go index 7c6264c3..39436a8f 100644 --- a/internal/util/strings.go +++ b/internal/util/strings.go @@ -16,6 +16,8 @@ limitations under the License. package util +import "strings" + // DerefString 安全地解引用字符串指针,nil 返回空字符串 func DerefString(s *string) string { if s == nil { @@ -23,3 +25,17 @@ func DerefString(s *string) string { } return *s } + +// MaskEmail 安全脱敏用户的邮箱地址(例如 us***@example.com) +func MaskEmail(email string) string { + parts := strings.Split(email, "@") + if len(parts) != 2 { + return email + } + local := parts[0] + domain := parts[1] + if len(local) <= 2 { + return "**@" + domain + } + return local[:2] + "***" + local[len(local)-1:] + "@" + domain +}