mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-11 09:46:37 +08:00
优化
This commit is contained in:
@@ -1,9 +1,9 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import {useMemo, useRef, useState} from "react"
|
import {useEffect, useMemo, useRef, useState} from "react"
|
||||||
import {useMutation, useQuery} from "@tanstack/react-query"
|
import {useMutation, useQuery} from "@tanstack/react-query"
|
||||||
import {useRouter, useSearchParams} from "next/navigation"
|
import {useRouter, useSearchParams} from "next/navigation"
|
||||||
import {KeyRound, ShieldCheck, UserPlus} from "lucide-react"
|
import {KeyRound, UserPlus} from "lucide-react"
|
||||||
import {toast} from "sonner"
|
import {toast} from "sonner"
|
||||||
|
|
||||||
import {useAuth} from "@/components/providers/auth-provider"
|
import {useAuth} from "@/components/providers/auth-provider"
|
||||||
@@ -44,8 +44,28 @@ export function LoginForm() {
|
|||||||
const [username, setUsername] = useState("")
|
const [username, setUsername] = useState("")
|
||||||
const [password, setPassword] = useState("")
|
const [password, setPassword] = useState("")
|
||||||
const [nickname, setNickname] = 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("")
|
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
|
// Cap token management — ref to hold latest token without triggering re-render
|
||||||
const capTokenRef = useRef<string | null>(null)
|
const capTokenRef = useRef<string | null>(null)
|
||||||
const [capReady, setCapReady] = useState(false)
|
const [capReady, setCapReady] = useState(false)
|
||||||
@@ -87,7 +107,22 @@ export function LoginForm() {
|
|||||||
router.replace(redirectTarget)
|
router.replace(redirectTarget)
|
||||||
},
|
},
|
||||||
onError: (error: Error) => {
|
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) {
|
if (capEnabled) {
|
||||||
capTokenRef.current = null
|
capTokenRef.current = null
|
||||||
setCapReady(false)
|
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 = () => {
|
const handlePasswordLogin = () => {
|
||||||
setErrorMessage("")
|
setErrorMessage("")
|
||||||
const trimmedUsername = username.trim()
|
const trimmedUsername = username.trim()
|
||||||
@@ -127,15 +187,33 @@ export function LoginForm() {
|
|||||||
loginMutation.mutate({
|
loginMutation.mutate({
|
||||||
username: trimmedUsername,
|
username: trimmedUsername,
|
||||||
password,
|
password,
|
||||||
|
code: showLoginCodeInput ? code.trim() : undefined,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleResendLoginCode = () => {
|
||||||
|
setCode("")
|
||||||
|
loginMutation.mutate({
|
||||||
|
username: username.trim(),
|
||||||
|
password,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleRegister = () => {
|
const handleRegister = () => {
|
||||||
setErrorMessage("")
|
setErrorMessage("")
|
||||||
|
const emailRegisterEnabled = publicConfigQuery.data?.email_register_verification_enabled ?? false
|
||||||
|
if (emailRegisterEnabled) {
|
||||||
|
if (!email.trim() || !code.trim()) {
|
||||||
|
toast.error("邮箱和验证码不能为空")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
registerMutation.mutate({
|
registerMutation.mutate({
|
||||||
username: username.trim(),
|
username: username.trim(),
|
||||||
password,
|
password,
|
||||||
nickname: nickname.trim() || undefined,
|
nickname: nickname.trim() || undefined,
|
||||||
|
email: email.trim() || undefined,
|
||||||
|
code: code.trim() || undefined,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +293,33 @@ export function LoginForm() {
|
|||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
onKeyDown={(e) => e.key === "Enter" && handlePasswordLogin()}
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Cap 人机验证 */}
|
{/* Cap 人机验证 */}
|
||||||
@@ -271,6 +376,32 @@ export function LoginForm() {
|
|||||||
placeholder="密码(至少 8 位)"
|
placeholder="密码(至少 8 位)"
|
||||||
autoComplete="new-password"
|
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>
|
</div>
|
||||||
|
|
||||||
{errorMessage ? (
|
{errorMessage ? (
|
||||||
@@ -307,20 +438,16 @@ export function LoginForm() {
|
|||||||
{authSources.length > 0 ? (
|
{authSources.length > 0 ? (
|
||||||
authSources.map((source) => (
|
authSources.map((source) => (
|
||||||
<div className="space-y-3" key={source.id}>
|
<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">
|
<div className="grid gap-2">
|
||||||
<Button
|
<Button
|
||||||
key={source.id}
|
key={source.id}
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
className="justify-start"
|
className="justify-start"
|
||||||
onClick={() => void handleOAuthLogin(source.name)}
|
onClick={() => void handleOAuthLogin(source.name)}
|
||||||
>
|
>
|
||||||
{source.display_name || source.name} 登录
|
{source.display_name || source.name} 登录
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
|
|||||||
@@ -60,6 +60,18 @@ const SECURITY_KEYS = [
|
|||||||
description: "关闭后所有的第三方 OIDC 认证登录入口都会被隐藏。",
|
description: "关闭后所有的第三方 OIDC 认证登录入口都会被隐藏。",
|
||||||
icon: Globe,
|
icon: Globe,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: "email_login_verification_enabled",
|
||||||
|
title: "邮箱登录验证",
|
||||||
|
description: "开启后,使用账号密码登录时需要通过邮箱接收并验证 6 位验证码。",
|
||||||
|
icon: Mail,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "email_register_verification_enabled",
|
||||||
|
title: "邮箱注册验证",
|
||||||
|
description: "开启后,用户注册账号时需要通过邮箱接收并验证 6 位验证码。",
|
||||||
|
icon: Mail,
|
||||||
|
},
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
type SecurityKey = (typeof SECURITY_KEYS)[number]["key"]
|
type SecurityKey = (typeof SECURITY_KEYS)[number]["key"]
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ export class AuthService extends BaseService {
|
|||||||
return this.post<User>('/user/register', request);
|
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> {
|
static async changePassword(request: ChangePasswordRequest): Promise<void> {
|
||||||
return this.post<void>('/user/change-password', request);
|
return this.post<void>('/user/change-password', request);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,12 +35,15 @@ export interface OAuthCallbackRequest {
|
|||||||
export interface LoginRequest {
|
export interface LoginRequest {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
|
code?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterRequest {
|
export interface RegisterRequest {
|
||||||
username: string;
|
username: string;
|
||||||
password: string;
|
password: string;
|
||||||
nickname?: string;
|
nickname?: string;
|
||||||
|
email?: string;
|
||||||
|
code?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OAuthAuthorizeResponse {
|
export interface OAuthAuthorizeResponse {
|
||||||
|
|||||||
@@ -20,4 +20,8 @@ export interface PublicConfigResponse {
|
|||||||
cap_login_enabled: boolean;
|
cap_login_enabled: boolean;
|
||||||
/** 是否自动解题 */
|
/** 是否自动解题 */
|
||||||
cap_auto_solve: 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
|
var configs []model.SystemConfig
|
||||||
json.Unmarshal(dataBytes, &configs)
|
json.Unmarshal(dataBytes, &configs)
|
||||||
|
|
||||||
// Defaults seed 19 configurations
|
// Defaults seed 21 configurations
|
||||||
if len(configs) != 19 {
|
if len(configs) != 21 {
|
||||||
t.Errorf("expected 19 default configs, got %d", len(configs))
|
t.Errorf("expected 21 default configs, got %d", len(configs))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -193,8 +193,8 @@ func TestGetSystemConfig(t *testing.T) {
|
|||||||
var cfg model.SystemConfig
|
var cfg model.SystemConfig
|
||||||
json.Unmarshal(dataBytes, &cfg)
|
json.Unmarshal(dataBytes, &cfg)
|
||||||
|
|
||||||
if cfg.Value != "Antigravity Project" {
|
if cfg.Value != "Wavelet" {
|
||||||
t.Errorf("expected 'Antigravity Project', got '%s'", cfg.Value)
|
t.Errorf("expected 'Wavelet', got '%s'", cfg.Value)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -26,15 +26,17 @@ import (
|
|||||||
|
|
||||||
// PublicConfigResponse 公共配置响应
|
// PublicConfigResponse 公共配置响应
|
||||||
type PublicConfigResponse struct {
|
type PublicConfigResponse struct {
|
||||||
UploadAllowedExtensions string `json:"upload_allowed_extensions"` // 允许上传的图片扩展名
|
UploadAllowedExtensions string `json:"upload_allowed_extensions"` // 允许上传的图片扩展名
|
||||||
SiteName string `json:"site_name"` // 站点名称
|
SiteName string `json:"site_name"` // 站点名称
|
||||||
PasswordLoginEnabled bool `json:"password_login_enabled"` // 是否允许密码登录
|
PasswordLoginEnabled bool `json:"password_login_enabled"` // 是否允许密码登录
|
||||||
RegistrationEnabled bool `json:"registration_enabled"` // 是否允许注册
|
RegistrationEnabled bool `json:"registration_enabled"` // 是否允许注册
|
||||||
PasswordRegisterEnabled bool `json:"password_register_enabled"` // 是否允许密码注册
|
PasswordRegisterEnabled bool `json:"password_register_enabled"` // 是否允许密码注册
|
||||||
OIDCLoginEnabled bool `json:"oidc_login_enabled"` // 是否允许 OIDC 登录
|
OIDCLoginEnabled bool `json:"oidc_login_enabled"` // 是否允许 OIDC 登录
|
||||||
MaxAPIKeysPerUser int `json:"max_api_keys_per_user"` // 每个用户最大 API Key 数量
|
MaxAPIKeysPerUser int `json:"max_api_keys_per_user"` // 每个用户最大 API Key 数量
|
||||||
CapLoginEnabled bool `json:"cap_login_enabled"` // 是否启用人机验证
|
CapLoginEnabled bool `json:"cap_login_enabled"` // 是否启用人机验证
|
||||||
CapAutoSolve bool `json:"cap_auto_solve"` // 打开页面后是否自动开始计算
|
CapAutoSolve bool `json:"cap_auto_solve"` // 打开页面后是否自动开始计算
|
||||||
|
EmailLoginVerificationEnabled bool `json:"email_login_verification_enabled"` // 是否启用邮箱登录验证
|
||||||
|
EmailRegisterVerificationEnabled bool `json:"email_register_verification_enabled"` // 是否启用邮箱注册验证
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetPublicConfig 获取公共配置
|
// GetPublicConfig 获取公共配置
|
||||||
@@ -103,16 +105,28 @@ func GetPublicConfig(c *gin.Context) {
|
|||||||
maxAPIKeys = val
|
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{
|
response := PublicConfigResponse{
|
||||||
UploadAllowedExtensions: uploadExtensions,
|
UploadAllowedExtensions: uploadExtensions,
|
||||||
SiteName: siteName,
|
SiteName: siteName,
|
||||||
PasswordLoginEnabled: passwordLoginEnabled,
|
PasswordLoginEnabled: passwordLoginEnabled,
|
||||||
RegistrationEnabled: registrationEnabled,
|
RegistrationEnabled: registrationEnabled,
|
||||||
PasswordRegisterEnabled: passwordRegisterEnabled,
|
PasswordRegisterEnabled: passwordRegisterEnabled,
|
||||||
OIDCLoginEnabled: oidcLoginEnabled,
|
OIDCLoginEnabled: oidcLoginEnabled,
|
||||||
MaxAPIKeysPerUser: maxAPIKeys,
|
MaxAPIKeysPerUser: maxAPIKeys,
|
||||||
CapLoginEnabled: capLoginEnabled,
|
CapLoginEnabled: capLoginEnabled,
|
||||||
CapAutoSolve: capAutoSolve,
|
CapAutoSolve: capAutoSolve,
|
||||||
|
EmailLoginVerificationEnabled: emailLoginVerificationEnabled,
|
||||||
|
EmailRegisterVerificationEnabled: emailRegisterVerificationEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, util.OK(response))
|
c.JSON(http.StatusOK, util.OK(response))
|
||||||
|
|||||||
@@ -2,6 +2,10 @@ package user
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -12,12 +16,14 @@ import (
|
|||||||
"github.com/linux-do/credit/internal/common"
|
"github.com/linux-do/credit/internal/common"
|
||||||
"github.com/linux-do/credit/internal/db"
|
"github.com/linux-do/credit/internal/db"
|
||||||
"github.com/linux-do/credit/internal/model"
|
"github.com/linux-do/credit/internal/model"
|
||||||
|
"github.com/linux-do/credit/internal/task"
|
||||||
"github.com/linux-do/credit/internal/util"
|
"github.com/linux-do/credit/internal/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
|
Code string `json:"code"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type registerRequest struct {
|
type registerRequest struct {
|
||||||
@@ -25,6 +31,34 @@ type registerRequest struct {
|
|||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
Nickname string `json:"nickname"`
|
Nickname string `json:"nickname"`
|
||||||
DisplayName string `json:"display_name"`
|
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 {
|
func isPasswordLoginEnabled() bool {
|
||||||
@@ -107,6 +141,65 @@ func Login(c *gin.Context) {
|
|||||||
return
|
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)
|
session := sessions.Default(c)
|
||||||
needChangePassword := false
|
needChangePassword := false
|
||||||
|
|
||||||
@@ -163,6 +256,8 @@ func Register(c *gin.Context) {
|
|||||||
req.Password = strings.TrimSpace(req.Password)
|
req.Password = strings.TrimSpace(req.Password)
|
||||||
req.Nickname = strings.TrimSpace(req.Nickname)
|
req.Nickname = strings.TrimSpace(req.Nickname)
|
||||||
req.DisplayName = strings.TrimSpace(req.DisplayName)
|
req.DisplayName = strings.TrimSpace(req.DisplayName)
|
||||||
|
req.Email = strings.TrimSpace(req.Email)
|
||||||
|
req.Code = strings.TrimSpace(req.Code)
|
||||||
|
|
||||||
if req.Username == "" || req.Password == "" {
|
if req.Username == "" || req.Password == "" {
|
||||||
c.JSON(http.StatusOK, util.Err("无效的参数"))
|
c.JSON(http.StatusOK, util.Err("无效的参数"))
|
||||||
@@ -174,6 +269,29 @@ func Register(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
ctx := c.Request.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
|
var count int64
|
||||||
if err := db.DB(ctx).Model(&model.User{}).Where("username = ?", req.Username).Count(&count).Error; err != nil {
|
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()))
|
c.JSON(http.StatusOK, util.Err(err.Error()))
|
||||||
@@ -184,9 +302,23 @@ func Register(c *gin.Context) {
|
|||||||
return
|
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{
|
user := model.User{
|
||||||
Username: req.Username,
|
Username: req.Username,
|
||||||
Nickname: req.Nickname,
|
Nickname: req.Nickname,
|
||||||
|
Email: req.Email,
|
||||||
AvatarUrl: "",
|
AvatarUrl: "",
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
IsAdmin: false,
|
IsAdmin: false,
|
||||||
@@ -308,3 +440,78 @@ func ChangePassword(c *gin.Context) {
|
|||||||
|
|
||||||
c.JSON(http.StatusOK, util.OK("密码修改成功"))
|
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())
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -94,6 +94,8 @@ func initSystemConfigs() {
|
|||||||
ensureConfigKeyExists(model.ConfigKeySMTPPort, "587", "system", "SMTP 端口(例如 587 或 465)")
|
ensureConfigKeyExists(model.ConfigKeySMTPPort, "587", "system", "SMTP 端口(例如 587 或 465)")
|
||||||
ensureConfigKeyExists(model.ConfigKeySMTPUsername, "", "system", "SMTP 账户(如 sender@example.com)")
|
ensureConfigKeyExists(model.ConfigKeySMTPUsername, "", "system", "SMTP 账户(如 sender@example.com)")
|
||||||
ensureConfigKeyExists(model.ConfigKeySMTPPassword, "", "system", "SMTP 访问凭证(授权码/密码)")
|
ensureConfigKeyExists(model.ConfigKeySMTPPassword, "", "system", "SMTP 访问凭证(授权码/密码)")
|
||||||
|
ensureConfigKeyExists(model.ConfigKeyEmailLoginVerificationEnabled, "false", "system", "是否开启邮箱登录验证(true/false)")
|
||||||
|
ensureConfigKeyExists(model.ConfigKeyEmailRegisterVerificationEnabled, "false", "system", "是否开启邮箱注册验证(true/false)")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -212,6 +214,18 @@ func initSystemConfigs() {
|
|||||||
Type: "business",
|
Type: "business",
|
||||||
Description: "限制每个普通用户可以创建的 API Key 最大数量",
|
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 {
|
if err := tx.Create(&defaultConfigs).Error; err != nil {
|
||||||
|
|||||||
@@ -31,25 +31,27 @@ import (
|
|||||||
|
|
||||||
// 配置键常量 - 所有系统配置的 key 定义
|
// 配置键常量 - 所有系统配置的 key 定义
|
||||||
const (
|
const (
|
||||||
ConfigKeyUploadAllowedExtensions = "upload_allowed_extensions" // 允许上传的文件扩展名,逗号分隔
|
ConfigKeyUploadAllowedExtensions = "upload_allowed_extensions" // 允许上传的文件扩展名,逗号分隔
|
||||||
ConfigKeySiteName = "site_name" // 站点名称
|
ConfigKeySiteName = "site_name" // 站点名称
|
||||||
ConfigKeyPasswordLoginEnabled = "password_login_enabled" // 是否允许密码登录
|
ConfigKeyPasswordLoginEnabled = "password_login_enabled" // 是否允许密码登录
|
||||||
ConfigKeyRegistrationEnabled = "registration_enabled" // 是否允许注册
|
ConfigKeyRegistrationEnabled = "registration_enabled" // 是否允许注册
|
||||||
ConfigKeyPasswordRegisterEnabled = "password_register_enabled" // 是否允许密码注册
|
ConfigKeyPasswordRegisterEnabled = "password_register_enabled" // 是否允许密码注册
|
||||||
ConfigKeyOIDCLoginEnabled = "oidc_login_enabled" // 是否允许 OIDC 登录
|
ConfigKeyOIDCLoginEnabled = "oidc_login_enabled" // 是否允许 OIDC 登录
|
||||||
ConfigKeyMaxAPIKeysPerUser = "max_api_keys_per_user" // 每个用户最大 API Key 数量
|
ConfigKeyMaxAPIKeysPerUser = "max_api_keys_per_user" // 每个用户最大 API Key 数量
|
||||||
ConfigKeyCapLoginEnabled = "cap_login_enabled" // 是否启用登录人机验证
|
ConfigKeyCapLoginEnabled = "cap_login_enabled" // 是否启用登录人机验证
|
||||||
ConfigKeyCapAutoSolve = "cap_auto_solve" // 打开页面后是否自动开始计算(false 则需用户手动点击)
|
ConfigKeyCapAutoSolve = "cap_auto_solve" // 打开页面后是否自动开始计算(false 则需用户手动点击)
|
||||||
ConfigKeyCapChallengeCount = "cap_challenge_count" // 客户端需求解的 PoW 难题总数,默认 1,推荐 1~5
|
ConfigKeyCapChallengeCount = "cap_challenge_count" // 客户端需求解的 PoW 难题总数,默认 1,推荐 1~5
|
||||||
ConfigKeyCapChallengeSize = "cap_challenge_size" // 人机验证盐值长度
|
ConfigKeyCapChallengeSize = "cap_challenge_size" // 人机验证盐值长度
|
||||||
ConfigKeyCapChallengeDifficulty = "cap_challenge_difficulty" // 人机验证 PoW 难度(目标前缀长度)
|
ConfigKeyCapChallengeDifficulty = "cap_challenge_difficulty" // 人机验证 PoW 难度(目标前缀长度)
|
||||||
ConfigKeyCapChallengeTTL = "cap_challenge_ttl_seconds" // 人机验证难题有效时间(秒)
|
ConfigKeyCapChallengeTTL = "cap_challenge_ttl_seconds" // 人机验证难题有效时间(秒)
|
||||||
ConfigKeyCapTokenTTL = "cap_token_ttl_seconds" // 人机验证兑换凭证有效时间(秒)
|
ConfigKeyCapTokenTTL = "cap_token_ttl_seconds" // 人机验证兑换凭证有效时间(秒)
|
||||||
ConfigKeyServerAddress = "server_address" // 服务器地址
|
ConfigKeyServerAddress = "server_address" // 服务器地址
|
||||||
ConfigKeySMTPHost = "smtp_host" // SMTP 服务器地址
|
ConfigKeySMTPHost = "smtp_host" // SMTP 服务器地址
|
||||||
ConfigKeySMTPPort = "smtp_port" // SMTP 端口
|
ConfigKeySMTPPort = "smtp_port" // SMTP 端口
|
||||||
ConfigKeySMTPUsername = "smtp_username" // SMTP 账户
|
ConfigKeySMTPUsername = "smtp_username" // SMTP 账户
|
||||||
ConfigKeySMTPPassword = "smtp_password" // SMTP 访问凭证
|
ConfigKeySMTPPassword = "smtp_password" // SMTP 访问凭证
|
||||||
|
ConfigKeyEmailLoginVerificationEnabled = "email_login_verification_enabled" // 是否启用邮箱登录验证
|
||||||
|
ConfigKeyEmailRegisterVerificationEnabled = "email_register_verification_enabled" // 是否启用邮箱注册验证
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ type User struct {
|
|||||||
Username string `json:"username" gorm:"size:64;uniqueIndex"`
|
Username string `json:"username" gorm:"size:64;uniqueIndex"`
|
||||||
Password string `json:"password,omitempty" gorm:"size:255"`
|
Password string `json:"password,omitempty" gorm:"size:255"`
|
||||||
Nickname string `json:"nickname" 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"`
|
AvatarUrl string `json:"avatar_url" gorm:"size:255"`
|
||||||
IsActive bool `json:"is_active" gorm:"default:true;index"`
|
IsActive bool `json:"is_active" gorm:"default:true;index"`
|
||||||
IsAdmin bool `json:"is_admin" gorm:"default:false"`
|
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) {
|
func (u *User) UpdateFromOAuthInfo(oauthInfo *OAuthUserInfo) {
|
||||||
u.Username = oauthInfo.Username
|
u.Username = oauthInfo.Username
|
||||||
u.Nickname = oauthInfo.Name
|
u.Nickname = oauthInfo.Name
|
||||||
|
u.Email = oauthInfo.Email
|
||||||
u.AvatarUrl = oauthInfo.AvatarUrl
|
u.AvatarUrl = oauthInfo.AvatarUrl
|
||||||
u.IsActive = oauthInfo.Active
|
u.IsActive = oauthInfo.Active
|
||||||
u.LastLoginAt = time.Now()
|
u.LastLoginAt = time.Now()
|
||||||
@@ -126,6 +128,7 @@ func (u *User) CreateUser(tx *gorm.DB, oauthInfo *OAuthUserInfo) error {
|
|||||||
ID: oauthInfo.GetID(),
|
ID: oauthInfo.GetID(),
|
||||||
Username: oauthInfo.Username,
|
Username: oauthInfo.Username,
|
||||||
Nickname: oauthInfo.Name,
|
Nickname: oauthInfo.Name,
|
||||||
|
Email: oauthInfo.Email,
|
||||||
AvatarUrl: oauthInfo.AvatarUrl,
|
AvatarUrl: oauthInfo.AvatarUrl,
|
||||||
IsActive: oauthInfo.Active,
|
IsActive: oauthInfo.Active,
|
||||||
LastLoginAt: now,
|
LastLoginAt: now,
|
||||||
|
|||||||
@@ -144,6 +144,7 @@ func Serve() {
|
|||||||
return enabled
|
return enabled
|
||||||
}), user.Login)
|
}), user.Login)
|
||||||
userRouter.POST("/register", user.Register)
|
userRouter.POST("/register", user.Register)
|
||||||
|
userRouter.POST("/send-email-code", user.SendEmailCode)
|
||||||
userRouter.GET("/logout", user.Logout)
|
userRouter.GET("/logout", user.Logout)
|
||||||
userRouter.GET("/self", oauth.LoginRequired(), oauth.UserInfo)
|
userRouter.GET("/self", oauth.LoginRequired(), oauth.UserInfo)
|
||||||
userRouter.POST("/change-password", oauth.LoginRequired(), user.ChangePassword)
|
userRouter.POST("/change-password", oauth.LoginRequired(), user.ChangePassword)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ package task
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
CleanupUnusedUploadsTask = "upload:cleanup_unused"
|
CleanupUnusedUploadsTask = "upload:cleanup_unused"
|
||||||
|
SendEmailTask = "mail:send"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -27,6 +28,7 @@ const (
|
|||||||
// 管理员可下发的任务类型标识
|
// 管理员可下发的任务类型标识
|
||||||
const (
|
const (
|
||||||
TaskTypeCleanupUploads = "cleanup_unused_uploads"
|
TaskTypeCleanupUploads = "cleanup_unused_uploads"
|
||||||
|
TaskTypeSendEmail = "send_email"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TaskMeta 任务元数据
|
// TaskMeta 任务元数据
|
||||||
@@ -53,6 +55,16 @@ var DispatchableTasks = []TaskMeta{
|
|||||||
Queue: QueueDefault,
|
Queue: QueueDefault,
|
||||||
Retryable: true,
|
Retryable: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
Type: TaskTypeSendEmail,
|
||||||
|
AsynqTask: SendEmailTask,
|
||||||
|
Name: "发送邮件",
|
||||||
|
Description: "异步发送系统邮件",
|
||||||
|
SupportsTime: false,
|
||||||
|
MaxRetry: 3,
|
||||||
|
Queue: QueueDefault,
|
||||||
|
Retryable: true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetTaskMeta 根据任务类型获取元数据
|
// GetTaskMeta 根据任务类型获取元数据
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import (
|
|||||||
|
|
||||||
"github.com/hibiken/asynq"
|
"github.com/hibiken/asynq"
|
||||||
"github.com/linux-do/credit/internal/apps/upload"
|
"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/config"
|
||||||
"github.com/linux-do/credit/internal/task"
|
"github.com/linux-do/credit/internal/task"
|
||||||
)
|
)
|
||||||
@@ -28,6 +29,7 @@ import (
|
|||||||
func init() {
|
func init() {
|
||||||
// 注册所有任务处理器
|
// 注册所有任务处理器
|
||||||
task.RegisterHandler(task.CleanupUnusedUploadsTask, &upload.CleanupUnusedUploadsHandler{})
|
task.RegisterHandler(task.CleanupUnusedUploadsTask, &upload.CleanupUnusedUploadsHandler{})
|
||||||
|
task.RegisterHandler(task.SendEmailTask, &user.SendEmailHandler{})
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartWorker 启动任务处理服务器
|
// StartWorker 启动任务处理服务器
|
||||||
@@ -49,6 +51,7 @@ func StartWorker() error {
|
|||||||
// 统一使用 task.ProcessTask 处理所有任务类型
|
// 统一使用 task.ProcessTask 处理所有任务类型
|
||||||
// 框架内部自动分发到对应的 TaskHandler 实现
|
// 框架内部自动分发到对应的 TaskHandler 实现
|
||||||
mux.HandleFunc(task.CleanupUnusedUploadsTask, task.ProcessTask)
|
mux.HandleFunc(task.CleanupUnusedUploadsTask, task.ProcessTask)
|
||||||
|
mux.HandleFunc(task.SendEmailTask, task.ProcessTask)
|
||||||
|
|
||||||
// 启动服务器
|
// 启动服务器
|
||||||
return asynqServer.Run(mux)
|
return asynqServer.Run(mux)
|
||||||
|
|||||||
@@ -206,6 +206,18 @@ func seedDefaultConfigs(t *testing.T, tx *gorm.DB) {
|
|||||||
Type: "system",
|
Type: "system",
|
||||||
Description: "SMTP 访问凭证(授权码/密码)",
|
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 {
|
if err := tx.Create(&defaultConfigs).Error; err != nil {
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ limitations under the License.
|
|||||||
|
|
||||||
package util
|
package util
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
// DerefString 安全地解引用字符串指针,nil 返回空字符串
|
// DerefString 安全地解引用字符串指针,nil 返回空字符串
|
||||||
func DerefString(s *string) string {
|
func DerefString(s *string) string {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
@@ -23,3 +25,17 @@ func DerefString(s *string) string {
|
|||||||
}
|
}
|
||||||
return *s
|
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
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user