This commit is contained in:
ryan
2026-06-08 14:34:09 +08:00
parent db68a130ce
commit 72c74803be
17 changed files with 596 additions and 58 deletions
+143 -16
View File
@@ -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<string | null>(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 && (
<div className="space-y-1.5 pt-1">
<p className="text-[11px] text-muted-foreground leading-normal">
已向您的安全邮箱 <span className="font-medium text-foreground">{maskedEmail}</span> 发送了登录验证码。
</p>
<div className="flex gap-2">
<Input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="6 位邮箱验证码"
maxLength={6}
className="flex-1"
onKeyDown={(e) => e.key === "Enter" && handlePasswordLogin()}
/>
<Button
type="button"
variant="outline"
onClick={handleResendLoginCode}
disabled={loginCooldown > 0 || loginMutation.isPending}
className="w-[120px] text-xs"
>
{loginCooldown > 0 ? `${loginCooldown}秒后重发` : "重新发送"}
</Button>
</div>
</div>
)}
</div>
{/* Cap 人机验证 */}
@@ -271,6 +376,32 @@ export function LoginForm() {
placeholder="密码(至少 8 位)"
autoComplete="new-password"
/>
<Input
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={publicConfigQuery.data?.email_register_verification_enabled ? "电子邮箱" : "电子邮箱(可选)"}
autoComplete="email"
/>
{publicConfigQuery.data?.email_register_verification_enabled && (
<div className="flex gap-2">
<Input
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="6 位邮箱验证码"
maxLength={6}
className="flex-1"
/>
<Button
type="button"
variant="outline"
onClick={handleSendRegisterCode}
disabled={registerCooldown > 0 || sendRegisterCodeMutation.isPending}
className="w-[120px] text-xs"
>
{registerCooldown > 0 ? `${registerCooldown}秒后重发` : "获取验证码"}
</Button>
</div>
)}
</div>
{errorMessage ? (
@@ -307,20 +438,16 @@ export function LoginForm() {
{authSources.length > 0 ? (
authSources.map((source) => (
<div className="space-y-3" key={source.id}>
<div className="flex items-center gap-2 text-sm font-medium text-foreground">
<ShieldCheck className="size-4" />
第三方认证源
</div>
<div className="grid gap-2">
<Button
key={source.id}
type="button"
variant="outline"
className="justify-start"
onClick={() => void handleOAuthLogin(source.name)}
>
{source.display_name || source.name} 登录
</Button>
<Button
key={source.id}
type="button"
variant="outline"
className="justify-start"
onClick={() => void handleOAuthLogin(source.name)}
>
{source.display_name || source.name} 登录
</Button>
</div>
</div>
))
@@ -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"]
@@ -111,6 +111,10 @@ export class AuthService extends BaseService {
return this.post<User>('/user/register', request);
}
static async sendEmailCode(email: string, scene: 'register' | 'login'): Promise<void> {
return this.post<void>('/user/send-email-code', { email, scene });
}
static async changePassword(request: ChangePasswordRequest): Promise<void> {
return this.post<void>('/user/change-password', request);
}
+3
View File
@@ -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 {
+4
View File
@@ -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;
}
@@ -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)
}
})
+32 -18
View File
@@ -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))
+207
View File
@@ -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("<h3>Wavelet 登录验证</h3><p>您的登录验证码为:<strong>%s</strong>,5分钟内有效,请勿将验证码泄露给他人。</p>", 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("<h3>Wavelet 注册验证</h3><p>您的注册验证码为:<strong>%s</strong>,5分钟内有效,请勿泄露给他人。</p>", 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())
}
+104
View File
@@ -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
}
+14
View File
@@ -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 {
+21 -19
View File
@@ -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 (
+3
View File
@@ -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,
+1
View File
@@ -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)
+12
View File
@@ -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 根据任务类型获取元数据
+3
View File
@@ -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)
+12
View File
@@ -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 {
+16
View File
@@ -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
}