mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-29 14:06:36 +08:00
953af7d8db
- Relocated generic HTTP response helpers (Response, OK, Err, etc.) from internal/util/ to a dedicated internal/common/response/ package. - Renamed ResponseAny to Any to resolve revive stuttering warnings. - Moved session building options and cookie headers logic from internal/util/ to internal/apps/oauth/. - Removed all direct imports of Gin/Sessions/HTTP frameworks from internal/util/ to keep general utilities 100% pure. - Regenerated Swagger API documentation via make swagger. - All tests and make code-check compile and pass with 0 issues.
331 lines
9.6 KiB
Go
331 lines
9.6 KiB
Go
// Copyright 2026 Arctel.net
|
|
// SPDX-License-Identifier: Apache-2.0
|
|
|
|
package user
|
|
|
|
import ("context"
|
|
"crypto/rand"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"math/big"
|
|
"net/http"
|
|
"strings"
|
|
|
|
"github.com/gin-contrib/sessions"
|
|
|
|
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
|
|
"github.com/Rain-kl/Wavelet/internal/db"
|
|
"github.com/Rain-kl/Wavelet/internal/model"
|
|
"github.com/Rain-kl/Wavelet/internal/task"
|
|
"github.com/Rain-kl/Wavelet/internal/util"
|
|
pkgu "github.com/Rain-kl/Wavelet/pkg/util"
|
|
"github.com/gin-gonic/gin"
|
|
|
|
"github.com/Rain-kl/Wavelet/internal/common/response")
|
|
|
|
type sendEmailCodeRequest struct {
|
|
Email string `json:"email" binding:"required,email"`
|
|
Scene string `json:"scene" binding:"required"`
|
|
}
|
|
|
|
func isEmailLoginVerificationEnabled(ctx context.Context) bool {
|
|
enabled, err := model.GetBoolByKey(ctx, model.ConfigKeyEmailLoginVerificationEnabled)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return enabled
|
|
}
|
|
|
|
func isEmailRegisterVerificationEnabled(ctx context.Context) bool {
|
|
enabled, err := model.GetBoolByKey(ctx, model.ConfigKeyEmailRegisterVerificationEnabled)
|
|
if err != nil {
|
|
return false
|
|
}
|
|
return enabled
|
|
}
|
|
|
|
func isSMTPConfigured(ctx context.Context) bool {
|
|
var host, port, username, password string
|
|
|
|
var scHost model.SystemConfig
|
|
if err := scHost.GetByKey(ctx, model.ConfigKeySMTPHost); err == nil {
|
|
host = scHost.Value
|
|
}
|
|
var scPort model.SystemConfig
|
|
if err := scPort.GetByKey(ctx, model.ConfigKeySMTPPort); err == nil {
|
|
port = scPort.Value
|
|
}
|
|
var scUser model.SystemConfig
|
|
if err := scUser.GetByKey(ctx, model.ConfigKeySMTPUsername); err == nil {
|
|
username = scUser.Value
|
|
}
|
|
var scPass model.SystemConfig
|
|
if err := scPass.GetByKey(ctx, model.ConfigKeySMTPPassword); err == nil {
|
|
password = scPass.Value
|
|
}
|
|
|
|
return host != "" && port != "" && username != "" && password != ""
|
|
}
|
|
|
|
func generateVerificationCode() (string, error) {
|
|
n, err := rand.Int(rand.Reader, big.NewInt(verificationCodeRange))
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
return fmt.Sprintf("%06d", n.Int64()+verificationCodeOffset), nil
|
|
}
|
|
|
|
func getEmailCodeKey(scene, email string) string {
|
|
return fmt.Sprintf("email_code:%s:%s", scene, email)
|
|
}
|
|
|
|
func getEmailCooldownKey(scene, email string) string {
|
|
return fmt.Sprintf("email_code:cooldown:%s:%s", scene, email)
|
|
}
|
|
|
|
func sendEmailVerificationCode(ctx context.Context, email, scene, templateName string) error {
|
|
if !isSMTPConfigured(ctx) {
|
|
return errors.New(errSMTPConfigIncomplete)
|
|
}
|
|
|
|
code, err := generateVerificationCode()
|
|
if err != nil {
|
|
return errors.New(errGenerateEmailCodeFailed)
|
|
}
|
|
codeKey := getEmailCodeKey(scene, email)
|
|
cooldownKey := getEmailCooldownKey(scene, email)
|
|
|
|
emailSubject, emailBody, err := model.RenderTemplate(
|
|
ctx,
|
|
templateName,
|
|
map[string]any{"Code": code},
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf(errRenderEmailTemplateFailed, err)
|
|
}
|
|
|
|
if err := db.SetJSON(ctx, codeKey, code, emailCodeExpiry); err != nil {
|
|
return errors.New(errGenerateEmailCodeFailed)
|
|
}
|
|
_ = db.SetJSON(ctx, cooldownKey, "1", emailCodeCooldown)
|
|
|
|
payload := SendEmailPayload{
|
|
To: email,
|
|
Subject: emailSubject,
|
|
Body: emailBody,
|
|
}
|
|
payloadBytes, _ := json.Marshal(payload)
|
|
_, err = task.DispatchTask(ctx, TaskTypeSendEmail, payloadBytes, "system")
|
|
if err != nil {
|
|
return errors.New(errDispatchEmailTaskFailed)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func verifyEmailCode(ctx context.Context, email, scene, code string) bool {
|
|
codeKey := getEmailCodeKey(scene, email)
|
|
var storedCode string
|
|
if err := db.GetJSON(ctx, codeKey, &storedCode); err != nil {
|
|
return false
|
|
}
|
|
if storedCode != code {
|
|
return false
|
|
}
|
|
_ = db.Redis.Del(ctx, db.PrefixedKey(codeKey)).Err()
|
|
return true
|
|
}
|
|
|
|
func handleLoginEmailVerification(ctx context.Context, c *gin.Context, req *loginRequest, user *model.User) error {
|
|
if req.Code != "" {
|
|
if !verifyEmailCode(ctx, user.Email, "login", req.Code) {
|
|
c.JSON(http.StatusOK, response.Err(errEmailCodeInvalidOrExpired))
|
|
return errors.New("handled")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// 如果 SMTP 未配置,或者用户没有绑定邮箱(无法发送验证码),则使用临时码 888888
|
|
if !isSMTPConfigured(ctx) || user.Email == "" {
|
|
codeKey := getEmailCodeKey("login", user.Email)
|
|
if err := db.SetJSON(ctx, codeKey, "888888", emailCodeExpiry); err != nil {
|
|
c.JSON(http.StatusOK, response.Err(errGenerateEmailCodeFailed))
|
|
return errors.New("handled")
|
|
}
|
|
var msg string
|
|
if !isSMTPConfigured(ctx) {
|
|
msg = errSMTPInvalidUseTempCodePrefix + errSMTPInvalidUseTempCode
|
|
} else {
|
|
msg = errSMTPInvalidUseTempCodePrefix + "该账号未绑定邮箱,使用临时码登录"
|
|
}
|
|
c.JSON(http.StatusOK, response.Err(msg))
|
|
return errors.New("handled")
|
|
}
|
|
|
|
cooldownKey := getEmailCooldownKey("login", user.Email)
|
|
var temp string
|
|
err := db.GetJSON(ctx, cooldownKey, &temp)
|
|
if err != nil {
|
|
if err := sendEmailVerificationCode(ctx, user.Email, "login", "login_email"); err != nil {
|
|
c.JSON(http.StatusOK, response.Err(err.Error()))
|
|
return errors.New("handled")
|
|
}
|
|
}
|
|
|
|
maskedEmail := pkgu.MaskEmail(user.Email)
|
|
c.JSON(http.StatusOK, response.Err(errNeedEmailCodePrefix+maskedEmail))
|
|
return errors.New("handled")
|
|
}
|
|
|
|
// SendEmailCode 发送邮箱验证码
|
|
// @Summary 发送邮箱验证码
|
|
// @Description 向指定邮箱发送验证码(用于注册场景)
|
|
// @Tags user
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body user.sendEmailCodeRequest true "发送验证码请求参数"
|
|
// @Success 200 {object} response.Any "发送成功"
|
|
// @Failure 400 {object} response.Any "参数错误"
|
|
// @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, response.Err(err.Error()))
|
|
return
|
|
}
|
|
|
|
req.Email = strings.TrimSpace(req.Email)
|
|
if req.Email == "" {
|
|
c.JSON(http.StatusOK, response.Err(errEmailRequired))
|
|
return
|
|
}
|
|
|
|
if req.Scene != "register" {
|
|
c.JSON(http.StatusOK, response.Err(errUnsupportedEmailScene))
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
|
|
var count int64
|
|
if err := db.DB(ctx).Model(&model.User{}).Where("email = ?", req.Email).Count(&count).Error; err != nil {
|
|
c.JSON(http.StatusOK, response.Err(err.Error()))
|
|
return
|
|
}
|
|
if count > 0 {
|
|
c.JSON(http.StatusOK, response.Err(errEmailAlreadyRegistered))
|
|
return
|
|
}
|
|
|
|
cooldownKey := getEmailCooldownKey("register", req.Email)
|
|
var temp string
|
|
err := db.GetJSON(ctx, cooldownKey, &temp)
|
|
if err == nil {
|
|
c.JSON(http.StatusOK, response.Err(errEmailCodeCooldown))
|
|
return
|
|
}
|
|
|
|
if err := sendEmailVerificationCode(ctx, req.Email, "register", "register_email"); err != nil {
|
|
c.JSON(http.StatusOK, response.Err(err.Error()))
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, response.OKNil())
|
|
}
|
|
|
|
func validateRegisterEmailVerification(ctx context.Context, req *registerRequest) error {
|
|
if !isEmailRegisterVerificationEnabled(ctx) {
|
|
return nil
|
|
}
|
|
if req.Email == "" || req.Code == "" {
|
|
return errors.New(errEmailOrCodeRequired)
|
|
}
|
|
if !verifyEmailCode(ctx, req.Email, "register", req.Code) {
|
|
return errors.New(errEmailCodeInvalidOrExpired)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type updateProfileRequest struct {
|
|
Nickname string `json:"nickname"`
|
|
Email string `json:"email"`
|
|
AvatarURL string `json:"avatar_url"`
|
|
Bio string `json:"bio"`
|
|
Phone string `json:"phone"`
|
|
Gender string `json:"gender"`
|
|
Website string `json:"website"`
|
|
Location string `json:"location"`
|
|
}
|
|
|
|
// UpdateProfile 修改当前登录用户的个人资料
|
|
// @Summary 修改当前登录用户的个人资料
|
|
// @Description 修改当前登录用户的昵称、邮箱、头像、简介、电话、性别、个人网站和所在地。
|
|
// @Tags user
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body user.updateProfileRequest true "更新请求参数"
|
|
// @Success 200 {object} response.Any{data=oauth.BasicUserInfo} "修改成功,返回更新后的用户信息"
|
|
// @Failure 400 {object} response.Any "邮箱已被占用或参数错误"
|
|
// @Failure 401 {object} response.Any "未登录"
|
|
// @Router /api/v1/user/profile [put]
|
|
func UpdateProfile(c *gin.Context) {
|
|
var req updateProfileRequest
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, response.Err(err.Error()))
|
|
return
|
|
}
|
|
|
|
userObj, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
|
|
if userObj == nil {
|
|
c.JSON(http.StatusUnauthorized, response.Err(errLoginRequired))
|
|
return
|
|
}
|
|
|
|
ctx := c.Request.Context()
|
|
var dbUser model.User
|
|
if err := db.DB(ctx).Where("id = ?", userObj.ID).First(&dbUser).Error; err != nil {
|
|
c.JSON(http.StatusOK, response.Err(errUserNotFound))
|
|
return
|
|
}
|
|
|
|
req.Email = strings.TrimSpace(req.Email)
|
|
if req.Email != "" && req.Email != dbUser.Email {
|
|
if !strings.Contains(req.Email, "@") || !strings.Contains(req.Email, ".") {
|
|
c.JSON(http.StatusOK, response.Err(errEmailFormatInvalid))
|
|
return
|
|
}
|
|
|
|
var count int64
|
|
if err := db.DB(ctx).Model(&model.User{}).Where("email = ? AND id != ?", req.Email, dbUser.ID).Count(&count).Error; err != nil {
|
|
c.JSON(http.StatusOK, response.Err(err.Error()))
|
|
return
|
|
}
|
|
if count > 0 {
|
|
c.JSON(http.StatusOK, response.Err(errEmailAlreadyBound))
|
|
return
|
|
}
|
|
}
|
|
|
|
dbUser.Nickname = strings.TrimSpace(req.Nickname)
|
|
if dbUser.Nickname == "" {
|
|
dbUser.Nickname = dbUser.Username
|
|
}
|
|
dbUser.Email = req.Email
|
|
dbUser.AvatarURL = req.AvatarURL
|
|
dbUser.Bio = req.Bio
|
|
dbUser.Phone = strings.TrimSpace(req.Phone)
|
|
dbUser.Gender = strings.TrimSpace(req.Gender)
|
|
dbUser.Website = strings.TrimSpace(req.Website)
|
|
dbUser.Location = strings.TrimSpace(req.Location)
|
|
|
|
if err := db.DB(ctx).Save(&dbUser).Error; err != nil {
|
|
c.JSON(http.StatusOK, response.Err(err.Error()))
|
|
return
|
|
}
|
|
|
|
session := sessions.Default(c)
|
|
needChange := session.Get("need_change_password") == true
|
|
|
|
c.JSON(http.StatusOK, response.OK(oauth.BuildBasicUserInfo(&dbUser, needChange)))
|
|
}
|