mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-07 08:06:37 +08:00
优化
This commit is contained in:
@@ -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)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user