diff --git a/Agents.md b/Agents.md
index afe35f5d..e83aac52 100644
--- a/Agents.md
+++ b/Agents.md
@@ -353,33 +353,7 @@ func ListUsers(c *gin.Context) {
### 4.1 组件样式规范
-**基础组件必须遵循系统的色彩主题系统。** 所有基于 shadcn/ui 的基础组件(Button、Dialog、Input 等)应使用组件内置 of `variant` 属性来控制样式,禁止通过 `className` 手写颜色或背景等样式。
-
-**错误示例(禁止)**:
-```tsx
-// ❌ 禁止通过 className 手写颜色、背景、阴影等样式
-
-```
-
-**正确示例**:
-```tsx
-// ✅ 使用 variant 属性,让组件遵循系统主题
-
-```
+**基础组件必须遵循系统的色彩主题系统。** 所有基于 shadcn/ui 的基础组件(Button、Dialog、Input 等)应使用组件内置 of `variant` 属性来控制样式,禁止通过 className 手写颜色、背景、阴影等样式。
> **原则**:组件的视觉表现由 shadcn/ui 的 variant 系统和全局 CSS 变量统一控制,保持应用内所有页面风格一致。如现有 variant 无法满足需求,应扩展 shadcn/ui 组件的 variant 定义,而非在业务代码中硬编码颜色值。
diff --git a/Makefile b/Makefile
index 3adad7b5..dcc969de 100644
--- a/Makefile
+++ b/Makefile
@@ -24,7 +24,7 @@ build-test:
@PIDS=""; \
STATUS=0; \
( cd frontend && pnpm build 2>&1 | sed 's/^/[frontend] /' ) & PIDS="$$PIDS $$!"; \
- ( go build -o /dev/null ./... 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \
+ ( go test ./... && go build -o /dev/null ./... 2>&1 | sed 's/^/[backend] /' ) & PIDS="$$PIDS $$!"; \
for PID in $$PIDS; do \
wait $$PID || STATUS=1; \
done; \
diff --git a/internal/apps/admin/logs/utils.go b/internal/apps/admin/logs/utils.go
index 0bb4e667..2c9e210e 100644
--- a/internal/apps/admin/logs/utils.go
+++ b/internal/apps/admin/logs/utils.go
@@ -27,7 +27,7 @@ import (
// getUpgrader 返回 WebSocket 升级器
func getUpgrader() *websocket.Upgrader {
return &websocket.Upgrader{
- CheckOrigin: func(r *http.Request) bool {
+ CheckOrigin: func(_ *http.Request) bool {
return true // CORS 由 Gin 中间件处理
},
}
diff --git a/internal/apps/config/routers.go b/internal/apps/config/routers.go
index 582d67ee..4c16739f 100644
--- a/internal/apps/config/routers.go
+++ b/internal/apps/config/routers.go
@@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
+// Package config 提供公开配置查询接口
package config
import (
diff --git a/internal/apps/oauth/middlewares.go b/internal/apps/oauth/middlewares.go
index 719ce507..d0a180f7 100644
--- a/internal/apps/oauth/middlewares.go
+++ b/internal/apps/oauth/middlewares.go
@@ -40,6 +40,7 @@ type loginRequiredAuditLog struct {
Referer string `json:"referer"`
}
+// LoginRequired 返回登录鉴权中间件,校验 Access Token 或 Session
func LoginRequired() gin.HandlerFunc {
return func(c *gin.Context) {
// init trace
@@ -73,14 +74,14 @@ func LoginRequired() gin.HandlerFunc {
if !authenticated {
// load user from session
- userId := GetUserIDFromContext(c)
- if userId <= 0 {
+ userID := GetUserIDFromContext(c)
+ if userID <= 0 {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil})
return
}
// load user from db to make sure is active
- tx := db.DB(ctx).Where("id = ? AND is_active = ?", userId, true).First(&user)
+ tx := db.DB(ctx).Where("id = ? AND is_active = ?", userID, true).First(&user)
if tx.Error != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error_msg": common.UnAuthorized, "data": nil})
return
diff --git a/internal/apps/oauth/routers.go b/internal/apps/oauth/routers.go
index 657d1a36..e5e95678 100644
--- a/internal/apps/oauth/routers.go
+++ b/internal/apps/oauth/routers.go
@@ -26,12 +26,13 @@ import (
"github.com/gin-gonic/gin"
)
+// BasicUserInfo 用户基本信息结构体
type BasicUserInfo struct {
ID uint64 `json:"id"`
Username string `json:"username"`
Nickname string `json:"nickname"`
Email string `json:"email"`
- AvatarUrl string `json:"avatar_url"`
+ AvatarURL string `json:"avatar_url"`
IsAdmin bool `json:"is_admin"`
NeedChangePassword bool `json:"need_change_password"`
Bio string `json:"bio"`
@@ -41,13 +42,14 @@ type BasicUserInfo struct {
Location string `json:"location"`
}
+// BuildBasicUserInfo 将 User 模型转换为 BasicUserInfo
func BuildBasicUserInfo(user *model.User, needChange bool) BasicUserInfo {
return BasicUserInfo{
ID: user.ID,
Username: user.Username,
Nickname: user.Nickname,
Email: user.Email,
- AvatarUrl: user.AvatarUrl,
+ AvatarURL: user.AvatarURL,
IsAdmin: user.IsAdmin,
NeedChangePassword: needChange,
Bio: user.Bio,
diff --git a/internal/apps/oauth/sources.go b/internal/apps/oauth/sources.go
index 6a9f7539..b76062b3 100644
--- a/internal/apps/oauth/sources.go
+++ b/internal/apps/oauth/sources.go
@@ -49,12 +49,14 @@ type AuthSourceView struct {
ClientSecretConfigured bool `json:"client_secret_configured"`
}
-// OAuthAuthorizeResponse 授权 URL 响应
+// AuthorizeResponse 授权 URL 响应
+//nolint:revive // OAuth 前缀保持包内语义清晰
type OAuthAuthorizeResponse struct {
AuthorizeURL string `json:"authorize_url"`
}
-// OAuthCallbackResult 回调处理结果
+// CallbackResult 回调处理结果
+//nolint:revive // OAuth 前缀保持包内语义清晰
type OAuthCallbackResult struct {
Status string `json:"status"`
User *BasicUserInfo `json:"user,omitempty"`
@@ -66,6 +68,7 @@ type CallbackRequest struct {
Code string `json:"code" binding:"required"`
}
+// GetUserIDFromSession 从 Session 中提取用户 ID
func GetUserIDFromSession(s sessions.Session) uint64 {
userID, ok := s.Get(UserIDKey).(uint64)
if !ok {
@@ -74,6 +77,7 @@ func GetUserIDFromSession(s sessions.Session) uint64 {
return userID
}
+// GetUserIDFromContext 从 Gin Context 的 Session 中提取用户 ID
func GetUserIDFromContext(c *gin.Context) uint64 {
session := sessions.Default(c)
return GetUserIDFromSession(session)
@@ -210,17 +214,8 @@ func buildOAuthUserInfo(ctx context.Context, source *model.AuthSource, code stri
userInfo := &model.OAuthUserInfo{Active: true}
if verifier != nil {
- if rawIDToken, ok := token.Extra("id_token").(string); ok {
- idToken, verifyErr := verifier.Verify(ctx, rawIDToken)
- if verifyErr != nil {
- return nil, fmt.Errorf(IDTokenVerifyFailedFormat, IDTokenVerifyFailed, verifyErr)
- }
- if nonce != "" && idToken.Nonce != nonce {
- return nil, errors.New(NonceMismatch)
- }
- if claimsErr := idToken.Claims(userInfo); claimsErr != nil {
- return nil, claimsErr
- }
+ if verifyErr := verifyIDToken(ctx, verifier, token, nonce, userInfo); verifyErr != nil {
+ return nil, verifyErr
}
}
@@ -240,12 +235,31 @@ func buildOAuthUserInfo(ctx context.Context, source *model.AuthSource, code stri
return userInfo, nil
}
+// verifyIDToken 验证 OIDC ID Token 并将 Claims 解析到 userInfo
+func verifyIDToken(ctx context.Context, verifier *oidc.IDTokenVerifier, token *oauth2.Token, nonce string, userInfo *model.OAuthUserInfo) error {
+ rawIDToken, ok := token.Extra("id_token").(string)
+ if !ok {
+ return nil
+ }
+ idToken, verifyErr := verifier.Verify(ctx, rawIDToken)
+ if verifyErr != nil {
+ return fmt.Errorf(IDTokenVerifyFailedFormat, IDTokenVerifyFailed, verifyErr)
+ }
+ if nonce != "" && idToken.Nonce != nonce {
+ return errors.New(NonceMismatch)
+ }
+ if claimsErr := idToken.Claims(userInfo); claimsErr != nil {
+ return claimsErr
+ }
+ return nil
+}
+
func normalizeOAuthUserInfo(userInfo *model.OAuthUserInfo) error {
userInfo.Username = strings.TrimSpace(userInfo.Username)
userInfo.PreferredUsername = strings.TrimSpace(userInfo.PreferredUsername)
userInfo.Email = strings.TrimSpace(userInfo.Email)
userInfo.Name = strings.TrimSpace(userInfo.Name)
- userInfo.AvatarUrl = strings.TrimSpace(userInfo.AvatarUrl)
+ userInfo.AvatarURL = strings.TrimSpace(userInfo.AvatarURL)
if userInfo.Username == "" && userInfo.PreferredUsername != "" {
userInfo.Username = userInfo.PreferredUsername
@@ -446,33 +460,45 @@ func Callback(c *gin.Context) {
userInfo.Sub = userInfo.Username
}
- var user model.User
if payload.Purpose == OAuthPurposeBind {
- userID := GetUserIDFromContext(c)
- if userID == 0 {
- c.JSON(http.StatusUnauthorized, util.Err(common.UnAuthorized))
- return
- }
- if err := db.DB(ctx).First(&user, "id = ?", userID).Error; err != nil {
- c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
- return
- }
- if err := model.BindExternalAccount(&model.ExternalAccount{
- AuthSourceID: source.ID,
- UserID: user.ID,
- ExternalID: userInfo.Sub,
- ExternalUsername: userInfo.Username,
- Email: userInfo.Email,
- }); err != nil {
- c.JSON(http.StatusBadRequest, util.Err(err.Error()))
- return
- }
- user.LastLoginAt = time.Now()
- _ = db.DB(ctx).Model(&user).Update("last_login_at", user.LastLoginAt).Error
- c.JSON(http.StatusOK, util.OK(buildCallbackResult(&user, "bound")))
+ handleCallbackBind(ctx, c, source, userInfo)
return
}
+ handleCallbackLogin(ctx, c, source, userInfo)
+}
+
+// handleCallbackBind 处理 OAuth 回调中的帐号绑定流程
+func handleCallbackBind(ctx context.Context, c *gin.Context, source *model.AuthSource, userInfo *model.OAuthUserInfo) {
+ userID := GetUserIDFromContext(c)
+ if userID == 0 {
+ c.JSON(http.StatusUnauthorized, util.Err(common.UnAuthorized))
+ return
+ }
+ var user model.User
+ if err := db.DB(ctx).First(&user, "id = ?", userID).Error; err != nil {
+ c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
+ return
+ }
+ if err := model.BindExternalAccount(&model.ExternalAccount{
+ AuthSourceID: source.ID,
+ UserID: user.ID,
+ ExternalID: userInfo.Sub,
+ ExternalUsername: userInfo.Username,
+ Email: userInfo.Email,
+ }); err != nil {
+ c.JSON(http.StatusBadRequest, util.Err(err.Error()))
+ return
+ }
+ user.LastLoginAt = time.Now()
+ _ = db.DB(ctx).Model(&user).Update("last_login_at", user.LastLoginAt).Error
+ c.JSON(http.StatusOK, util.OK(buildCallbackResult(&user, "bound")))
+}
+
+// handleCallbackLogin 处理 OAuth 回调中的登录流程(查找已有帐号或自动注册)
+func handleCallbackLogin(ctx context.Context, c *gin.Context, source *model.AuthSource, userInfo *model.OAuthUserInfo) {
+ var user model.User
+
account, err := model.FindExternalAccount(source.ID, userInfo.Sub)
switch {
case err == nil:
@@ -481,47 +507,11 @@ func Callback(c *gin.Context) {
return
}
case errors.Is(err, gorm.ErrRecordNotFound):
- // 检查系统是否允许注册
- registrationEnabled, regErr := model.GetBoolByKey(ctx, model.ConfigKeyRegistrationEnabled)
- if regErr != nil {
- registrationEnabled = true // 默认允许注册
- }
-
- if !registrationEnabled {
- // 如果不允许注册,临时记录到 session 并向前端返回 "need_bind" 状态
- session := sessions.Default(c)
- session.Set(PendingOAuthSourceIDKey, source.ID)
- session.Set(PendingOAuthExternalIDKey, userInfo.Sub)
- session.Set(PendingOAuthExternalUsernameKey, userInfo.Username)
- session.Set(PendingOAuthEmailKey, userInfo.Email)
- if err := session.Save(); err != nil {
- c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
- return
- }
- c.JSON(http.StatusOK, util.OK(buildCallbackResult(nil, "need_bind")))
- return
- }
-
- username, uniqueErr := uniqueUsername(ctx, userInfo.Username)
- if uniqueErr != nil {
- c.JSON(http.StatusInternalServerError, util.Err(uniqueErr.Error()))
- return
- }
- userInfo.Username = username
- if err := user.CreateUser(ctx, db.DB(ctx), userInfo); err != nil {
- c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
- return
- }
- if err := model.BindExternalAccount(&model.ExternalAccount{
- AuthSourceID: source.ID,
- UserID: user.ID,
- ExternalID: userInfo.Sub,
- ExternalUsername: userInfo.Username,
- Email: userInfo.Email,
- }); err != nil {
- c.JSON(http.StatusBadRequest, util.Err(err.Error()))
+ newUser, ok := handleCallbackRegister(ctx, c, source, userInfo)
+ if !ok {
return
}
+ user = newUser
default:
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
@@ -533,10 +523,56 @@ func Callback(c *gin.Context) {
c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
return
}
-
c.JSON(http.StatusOK, util.OK(buildCallbackResult(&user, "logged_in")))
}
+// handleCallbackRegister 处理 OAuth 回调中的自动注册流程
+// 若注册被禁用则保存 pending 信息并返回 false;若注册成功则返回新用户;若出错则返回 false
+func handleCallbackRegister(ctx context.Context, c *gin.Context, source *model.AuthSource, userInfo *model.OAuthUserInfo) (model.User, bool) {
+ registrationEnabled, regErr := model.GetBoolByKey(ctx, model.ConfigKeyRegistrationEnabled)
+ if regErr != nil {
+ registrationEnabled = true
+ }
+
+ if !registrationEnabled {
+ session := sessions.Default(c)
+ session.Set(PendingOAuthSourceIDKey, source.ID)
+ session.Set(PendingOAuthExternalIDKey, userInfo.Sub)
+ session.Set(PendingOAuthExternalUsernameKey, userInfo.Username)
+ session.Set(PendingOAuthEmailKey, userInfo.Email)
+ if err := session.Save(); err != nil {
+ c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
+ return model.User{}, false
+ }
+ c.JSON(http.StatusOK, util.OK(buildCallbackResult(nil, "need_bind")))
+ return model.User{}, false
+ }
+
+ username, uniqueErr := uniqueUsername(ctx, userInfo.Username)
+ if uniqueErr != nil {
+ c.JSON(http.StatusInternalServerError, util.Err(uniqueErr.Error()))
+ return model.User{}, false
+ }
+ userInfo.Username = username
+
+ var user model.User
+ if err := user.CreateUser(ctx, db.DB(ctx), userInfo); err != nil {
+ c.JSON(http.StatusInternalServerError, util.Err(err.Error()))
+ return model.User{}, false
+ }
+ if err := model.BindExternalAccount(&model.ExternalAccount{
+ AuthSourceID: source.ID,
+ UserID: user.ID,
+ ExternalID: userInfo.Sub,
+ ExternalUsername: userInfo.Username,
+ Email: userInfo.Email,
+ }); err != nil {
+ c.JSON(http.StatusBadRequest, util.Err(err.Error()))
+ return model.User{}, false
+ }
+ return user, true
+}
+
// ListExternalAccounts 获取当前用户的外部帐号绑定列表
// @Summary 获取外部帐号列表
// @Description 返回当前登录用户已绑定的所有外部 OAuth 帐号信息,需要登录
diff --git a/internal/apps/upload/tasks.go b/internal/apps/upload/tasks.go
index cb2ffd12..7a5590cb 100644
--- a/internal/apps/upload/tasks.go
+++ b/internal/apps/upload/tasks.go
@@ -33,9 +33,9 @@ import (
type CleanupUnusedUploadsHandler struct{}
// Execute 执行清理未使用上传文件的业务逻辑
-func (h *CleanupUnusedUploadsHandler) Execute(ctx context.Context, payload []byte) (*task.TaskResult, error) {
+func (h *CleanupUnusedUploadsHandler) Execute(ctx context.Context, _ []byte) (*task.TaskResult, error) {
const batchSize = 100 // 每批处理100个文件
- var lastID uint64 = 0
+ var lastID uint64
var totalProcessed int
var totalDeleted int
diff --git a/internal/apps/user/access_tokens.go b/internal/apps/user/access_tokens.go
index a465c7e0..7917dcde 100644
--- a/internal/apps/user/access_tokens.go
+++ b/internal/apps/user/access_tokens.go
@@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
+// Package user 提供用户认证与帐户管理功能
package user
import (
diff --git a/internal/apps/user/constants.go b/internal/apps/user/constants.go
index a715ff87..13a2f8b7 100644
--- a/internal/apps/user/constants.go
+++ b/internal/apps/user/constants.go
@@ -16,3 +16,13 @@ limitations under the License.
*/
package user
+
+import "time"
+
+const (
+ verificationCodeRange = 900000 // 验证码随机范围
+ verificationCodeOffset = 100000 // 验证码偏移量(保证 6 位)
+ emailCodeExpiry = 5 * time.Minute // 验证码有效期
+ emailCodeCooldown = 60 * time.Second // 验证码发送冷却时间
+ minPasswordLength = 8 // 密码最小长度
+)
diff --git a/internal/apps/user/controllers.go b/internal/apps/user/controllers.go
deleted file mode 100644
index 2fbc0a61..00000000
--- a/internal/apps/user/controllers.go
+++ /dev/null
@@ -1,691 +0,0 @@
-/*
-Copyright 2026 Arctel.net
-
-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"
- "crypto/rand"
- "encoding/json"
- "errors"
- "fmt"
- "math/big"
- "net/http"
- "strings"
- "time"
-
- "github.com/Rain-kl/Wavelet/internal/apps/oauth"
- "github.com/Rain-kl/Wavelet/internal/common"
- "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"
- "github.com/gin-contrib/sessions"
- "github.com/gin-gonic/gin"
-)
-
-const (
- verificationCodeRange = 900000 // 验证码随机范围
- verificationCodeOffset = 100000 // 验证码偏移量(保证 6 位)
- emailCodeExpiry = 5 * time.Minute // 验证码有效期
- emailCodeCooldown = 60 * time.Second // 验证码发送冷却时间
- minPasswordLength = 8 // 密码最小长度
-)
-
-type loginRequest struct {
- Username string `json:"username"`
- Password string `json:"password"`
- Code string `json:"code"`
-}
-
-type registerRequest struct {
- Username string `json:"username"`
- 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 isSMTPConfigured(ctx context.Context) bool {
- var sc model.SystemConfig
- var host, port, username string
-
- if err := sc.GetByKey(ctx, model.ConfigKeySMTPHost); err == nil {
- host = sc.Value
- }
- if err := sc.GetByKey(ctx, model.ConfigKeySMTPPort); err == nil {
- port = sc.Value
- }
- if err := sc.GetByKey(ctx, model.ConfigKeySMTPUsername); err == nil {
- username = sc.Value
- }
-
- return host != "" && port != "" && username != ""
-}
-
-func generateVerificationCode() string {
- n, _ := rand.Int(rand.Reader, big.NewInt(verificationCodeRange))
- return fmt.Sprintf("%06d", n.Int64()+verificationCodeOffset)
-}
-
-func getEmailCodeKey(scene, email string) string {
- return fmt.Sprintf("email_code:%s:%s", scene, email)
-}
-
-func getEmailCooldownKey(email string) string {
- return fmt.Sprintf("email_code:cooldown:%s", email)
-}
-
-func sendEmailVerificationCode(ctx context.Context, email, scene, templateName string) error {
- // 校验 SMTP 配置是否完整
- if !isSMTPConfigured(ctx) {
- return errors.New(errSMTPConfigIncomplete)
- }
-
- code := generateVerificationCode()
- codeKey := getEmailCodeKey(scene, email)
- cooldownKey := getEmailCooldownKey(email)
-
- // 使用模板管理获取并渲染邮件标题和正文。模板缺失或渲染失败时不发送验证码。
- emailSubject, emailBody, err := model.RenderTemplate(
- ctx,
- templateName,
- map[string]any{"Code": code},
- )
- if err != nil {
- return fmt.Errorf(errRenderEmailTemplateFailed, err)
- }
-
- // 存验证码,5分钟有效
- if err := db.SetJSON(ctx, codeKey, code, emailCodeExpiry); err != nil {
- return errors.New(errGenerateEmailCodeFailed)
- }
- // 存冷却,60秒有效
- _ = db.SetJSON(ctx, cooldownKey, "1", emailCodeCooldown)
-
- // 构建异步邮件发送任务
- payload := SendEmailPayload{
- To: email,
- Subject: emailSubject,
- Body: emailBody,
- }
- payloadBytes, _ := json.Marshal(payload)
- _, err = task.DispatchTask(ctx, task.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 isPasswordLoginEnabled() bool {
- enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyPasswordLoginEnabled)
- if err != nil {
- return true
- }
- return enabled
-}
-
-func isPasswordRegisterEnabled() bool {
- enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyPasswordRegisterEnabled)
- if err != nil {
- return true
- }
- return enabled
-}
-
-func isRegistrationEnabled() bool {
- enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyRegistrationEnabled)
- if err != nil {
- return true
- }
- return enabled
-}
-
-func setLoginSession(c *gin.Context, user *model.User) error {
- session := sessions.Default(c)
- session.Set(oauth.UserIDKey, user.ID)
- session.Set(oauth.UserNameKey, user.Username)
- if err := session.Save(); err != nil {
- return err
- }
- return nil
-}
-
-// handleLoginEmailVerification 处理登录时的邮箱验证码校验流程
-func handleLoginEmailVerification(ctx context.Context, c *gin.Context, req *loginRequest, user *model.User) error {
- if user.Email == "" {
- c.JSON(http.StatusOK, util.Err(errLoginEmailMissing))
- return errors.New("handled")
- }
-
- if req.Code == "" {
- // 校验 Redis 发送冷却时间
- cooldownKey := getEmailCooldownKey(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, util.Err(err.Error()))
- return errors.New("handled")
- }
- }
-
- // 脱敏邮箱并返回错误,提示前端需要输入验证码
- maskedEmail := util.MaskEmail(user.Email)
- c.JSON(http.StatusOK, util.Err(errNeedEmailCodePrefix+maskedEmail))
- return errors.New("handled")
- }
-
- // 校验验证码
- if !verifyEmailCode(ctx, user.Email, "login", req.Code) {
- c.JSON(http.StatusOK, util.Err(errEmailCodeInvalidOrExpired))
- return errors.New("handled")
- }
- return nil
-}
-
-// Login 用户密码登录
-// @Summary 用户密码登录
-// @Description 使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。
-// @Tags user
-// @Accept json
-// @Produce json
-// @Param request body user.loginRequest true "登录请求参数"
-// @Success 200 {object} util.ResponseAny{data=oauth.BasicUserInfo} "登录成功,返回用户信息"
-// @Failure 400 {object} util.ResponseAny "用户名或密码错误、帐号已禁用等"
-// @Failure 500 {object} util.ResponseAny "服务内部错误"
-// @Router /api/v1/user/login [post]
-func Login(c *gin.Context) {
- if !isPasswordLoginEnabled() {
- c.JSON(http.StatusOK, util.Err(errPasswordLoginDisabled))
- return
- }
- var req loginRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, util.Err(err.Error()))
- return
- }
- req.Username = strings.TrimSpace(req.Username)
- if req.Username == "" || req.Password == "" {
- c.JSON(http.StatusOK, util.Err(errInvalidParams))
- return
- }
-
- var user model.User
- ctx := c.Request.Context()
- if err := db.DB(ctx).Where("username = ?", req.Username).First(&user).Error; err != nil {
- c.JSON(http.StatusOK, util.Err(errUsernameOrPasswordWrong))
- return
- }
- if !user.IsActive {
- c.JSON(http.StatusOK, util.Err(common.BannedAccount))
- return
- }
-
- // 判定是否是明文密码存储
- isPlaintext := !user.IsPasswordEncrypted()
-
- if !user.CheckPassword(req.Password) {
- c.JSON(http.StatusOK, util.Err(errUsernameOrPasswordWrong))
- return
- }
-
- if isEmailLoginVerificationEnabled() {
- if emailErr := handleLoginEmailVerification(ctx, c, &req, &user); emailErr != nil {
- return
- }
- }
-
- session := sessions.Default(c)
- needChangePassword := false
-
- // 如果是以明文密码登录,在数据库中置换为加密密码
- if isPlaintext {
- if err := user.SetEncryptedPassword(req.Password); err == nil {
- if err := db.DB(ctx).Model(&user).Update("password", user.Password).Error; err != nil {
- c.JSON(http.StatusOK, util.Err(errPasswordUpgradeFailed))
- return
- }
- needChangePassword = true
- session.Set("need_change_password", true)
- _ = session.Save()
- }
- }
-
- user.LastLoginAt = time.Now()
- if err := db.DB(ctx).Model(&user).Update("last_login_at", user.LastLoginAt).Error; err != nil {
- c.JSON(http.StatusOK, util.Err(err.Error()))
- return
- }
- if err := setLoginSession(c, &user); err != nil {
- c.JSON(http.StatusOK, util.Err(errSaveSessionFailed))
- return
- }
-
- // 检查是否有未完成 of OAuth/OIDC 绑定
- completePendingOAuthBinding(session, &user)
-
- c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&user, needChangePassword)))
-}
-
-// Register 用户注册
-// @Summary 用户注册
-// @Description 使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。
-// @Tags user
-// @Accept json
-// @Produce json
-// @Param request body user.registerRequest true "注册请求参数"
-// @Success 200 {object} util.ResponseAny{data=oauth.BasicUserInfo} "注册并登录成功,返回用户信息"
-// @Failure 400 {object} util.ResponseAny "参数错误、用户名已存在或注册已关闭"
-// @Failure 500 {object} util.ResponseAny "服务内部错误"
-// @Router /api/v1/user/register [post]
-func Register(c *gin.Context) {
- if !isRegistrationEnabled() || !isPasswordRegisterEnabled() {
- c.JSON(http.StatusOK, util.Err(errRegistrationDisabled))
- return
- }
-
- var req registerRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, util.Err(err.Error()))
- return
- }
-
- req.Username = strings.TrimSpace(req.Username)
- 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(errInvalidParams))
- return
- }
- if len(req.Password) < minPasswordLength {
- c.JSON(http.StatusOK, util.Err(errPasswordTooShort))
- return
- }
-
- ctx := c.Request.Context()
-
- // 邮箱注册验证校验
- if err := validateRegisterEmailVerification(ctx, &req); err != nil {
- c.JSON(http.StatusOK, util.Err(err.Error()))
- return
- }
-
- user := model.User{
- Username: req.Username,
- Nickname: req.Nickname,
- Email: req.Email,
- AvatarURL: "",
- IsActive: true,
- IsAdmin: false,
- LastLoginAt: time.Now(),
- }
- if user.Nickname == "" {
- user.Nickname = req.DisplayName
- }
- if user.Nickname == "" {
- user.Nickname = req.Username
- }
- if err := user.SetPassword(req.Password); err != nil {
- c.JSON(http.StatusOK, util.Err(err.Error()))
- return
- }
-
- if err := user.RegisterUser(ctx, db.DB(ctx)); err != nil {
- c.JSON(http.StatusOK, util.Err(err.Error()))
- return
- }
-
- if err := setLoginSession(c, &user); err != nil {
- c.JSON(http.StatusOK, util.Err(errSaveSessionFailed))
- return
- }
-
- c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&user, false)))
-}
-
-// Logout 用户退出登录
-// @Summary 用户退出登录
-// @Description 清除用户登录 Session,完成退出
-// @Tags user
-// @Produce json
-// @Security SessionCookie
-// @Success 200 {object} util.ResponseAny{data=string} "退出成功"
-// @Failure 500 {object} util.ResponseAny "Session 清除失败"
-// @Router /api/v1/user/logout [get]
-func Logout(c *gin.Context) {
- session := sessions.Default(c)
- session.Options(util.GetSessionOptions(-1))
- session.Clear()
- if err := session.Save(); err != nil {
- c.JSON(http.StatusOK, util.Err(err.Error()))
- return
- }
- c.JSON(http.StatusOK, util.OK(""))
-}
-
-type changePasswordRequest struct {
- OldPassword string `json:"old_password"`
- NewPassword string `json:"new_password"`
-}
-
-// ChangePassword 修改用户密码
-// @Summary 修改用户密码
-// @Description 修改当前登录用户的密码。修改成功后,如果是首次明文登录的升级提示,则清除修改密码的提示状态。
-// @Tags user
-// @Accept json
-// @Produce json
-// @Param request body user.changePasswordRequest true "修改密码请求参数"
-// @Success 200 {object} util.ResponseAny{data=string} "修改密码成功"
-// @Failure 400 {object} util.ResponseAny "原密码错误或新密码不符合要求"
-// @Failure 401 {object} util.ResponseAny "请先登录"
-// @Router /api/v1/user/change-password [post]
-func ChangePassword(c *gin.Context) {
- var req changePasswordRequest
- if err := c.ShouldBindJSON(&req); err != nil {
- c.JSON(http.StatusBadRequest, util.Err(err.Error()))
- return
- }
-
- req.OldPassword = strings.TrimSpace(req.OldPassword)
- req.NewPassword = strings.TrimSpace(req.NewPassword)
-
- if req.OldPassword == "" || req.NewPassword == "" {
- c.JSON(http.StatusOK, util.Err(errInvalidParams))
- return
- }
- if len(req.NewPassword) < minPasswordLength {
- c.JSON(http.StatusOK, util.Err(errNewPasswordTooShort))
- return
- }
-
- userObj, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
- if userObj == nil {
- c.JSON(http.StatusUnauthorized, util.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, util.Err(errUserNotFound))
- return
- }
-
- // 校验旧密码
- if !dbUser.CheckPassword(req.OldPassword) {
- c.JSON(http.StatusOK, util.Err(errOldPasswordIncorrect))
- return
- }
-
- // 加密并更新为新密码
- if err := dbUser.SetEncryptedPassword(req.NewPassword); err != nil {
- c.JSON(http.StatusOK, util.Err(errPasswordEncryptFailed))
- return
- }
-
- if err := db.DB(ctx).Model(&dbUser).Update("password", dbUser.Password).Error; err != nil {
- c.JSON(http.StatusOK, util.Err(err.Error()))
- return
- }
-
- // 清除 Session 中修改密码提示状态
- session := sessions.Default(c)
- session.Delete("need_change_password")
- _ = session.Save()
-
- 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(errEmailRequired))
- return
- }
-
- if req.Scene != "register" {
- c.JSON(http.StatusOK, util.Err(errUnsupportedEmailScene))
- 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(errEmailAlreadyRegistered))
- return
- }
-
- // 2. 校验 Redis 发送冷却时间
- cooldownKey := getEmailCooldownKey(req.Email)
- var temp string
- err := db.GetJSON(ctx, cooldownKey, &temp)
- if err == nil {
- c.JSON(http.StatusOK, util.Err(errEmailCodeCooldown))
- return
- }
-
- // 3. 发送验证码
- if err := sendEmailVerificationCode(ctx, req.Email, "register", "register_email"); err != nil {
- c.JSON(http.StatusOK, util.Err(err.Error()))
- return
- }
-
- c.JSON(http.StatusOK, util.OKNil())
-}
-
-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} util.ResponseAny{data=oauth.BasicUserInfo} "修改成功,返回更新后的用户信息"
-// @Failure 400 {object} util.ResponseAny "邮箱已被占用或参数错误"
-// @Failure 401 {object} util.ResponseAny "未登录"
-// @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, util.Err(err.Error()))
- return
- }
-
- userObj, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
- if userObj == nil {
- c.JSON(http.StatusUnauthorized, util.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, util.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, util.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, util.Err(err.Error()))
- return
- }
- if count > 0 {
- c.JSON(http.StatusOK, util.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, util.Err(err.Error()))
- return
- }
-
- session := sessions.Default(c)
- needChange := session.Get("need_change_password") == true
-
- c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&dbUser, needChange)))
-}
-
-// validateRegisterEmailVerification 校验注册时的邮箱验证码
-func validateRegisterEmailVerification(ctx context.Context, req *registerRequest) error {
- if !isEmailRegisterVerificationEnabled() {
- 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
-}
-
-// completePendingOAuthBinding 完成登录后的 OAuth 待绑定绑定流程
-func completePendingOAuthBinding(session sessions.Session, user *model.User) {
- pendingSourceID := session.Get(oauth.PendingOAuthSourceIDKey)
- pendingExternalID := session.Get(oauth.PendingOAuthExternalIDKey)
- pendingExternalUsername := session.Get(oauth.PendingOAuthExternalUsernameKey)
- pendingEmail := session.Get(oauth.PendingOAuthEmailKey)
-
- if pendingSourceID == nil || pendingExternalID == nil {
- return
- }
-
- var sourceID uint64
- switch v := pendingSourceID.(type) {
- case uint64:
- sourceID = v
- case int:
- sourceID = uint64(v)
- case float64:
- sourceID = uint64(v)
- }
- externalID, _ := pendingExternalID.(string)
- externalUsername, _ := pendingExternalUsername.(string)
- email, _ := pendingEmail.(string)
-
- if sourceID != 0 && externalID != "" {
- _ = model.BindExternalAccount(&model.ExternalAccount{
- AuthSourceID: sourceID,
- UserID: user.ID,
- ExternalID: externalID,
- ExternalUsername: externalUsername,
- Email: email,
- })
- }
- // 清除 pending 信息
- session.Delete(oauth.PendingOAuthSourceIDKey)
- session.Delete(oauth.PendingOAuthExternalIDKey)
- session.Delete(oauth.PendingOAuthExternalUsernameKey)
- session.Delete(oauth.PendingOAuthEmailKey)
- _ = session.Save()
-}
diff --git a/internal/apps/user/routers.go b/internal/apps/user/routers.go
index a715ff87..0415f443 100644
--- a/internal/apps/user/routers.go
+++ b/internal/apps/user/routers.go
@@ -1,6 +1,5 @@
/*
-Copyright 2025 linux.do
-Modified by Arctel.net, 2026
+Copyright 2026 Arctel.net
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
@@ -16,3 +15,669 @@ limitations under the License.
*/
package user
+
+import (
+ "context"
+ "crypto/rand"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "math/big"
+ "net/http"
+ "strings"
+ "time"
+
+ "github.com/Rain-kl/Wavelet/internal/apps/oauth"
+ "github.com/Rain-kl/Wavelet/internal/common"
+ "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"
+ "github.com/gin-contrib/sessions"
+ "github.com/gin-gonic/gin"
+)
+
+type loginRequest struct {
+ Username string `json:"username"`
+ Password string `json:"password"`
+ Code string `json:"code"`
+}
+
+type registerRequest struct {
+ Username string `json:"username"`
+ 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 isSMTPConfigured(ctx context.Context) bool {
+ var sc model.SystemConfig
+ var host, port, username string
+
+ if err := sc.GetByKey(ctx, model.ConfigKeySMTPHost); err == nil {
+ host = sc.Value
+ }
+ if err := sc.GetByKey(ctx, model.ConfigKeySMTPPort); err == nil {
+ port = sc.Value
+ }
+ if err := sc.GetByKey(ctx, model.ConfigKeySMTPUsername); err == nil {
+ username = sc.Value
+ }
+
+ return host != "" && port != "" && username != ""
+}
+
+func generateVerificationCode() string {
+ n, _ := rand.Int(rand.Reader, big.NewInt(verificationCodeRange))
+ return fmt.Sprintf("%06d", n.Int64()+verificationCodeOffset)
+}
+
+func getEmailCodeKey(scene, email string) string {
+ return fmt.Sprintf("email_code:%s:%s", scene, email)
+}
+
+func getEmailCooldownKey(email string) string {
+ return fmt.Sprintf("email_code:cooldown:%s", email)
+}
+
+func sendEmailVerificationCode(ctx context.Context, email, scene, templateName string) error {
+ // 校验 SMTP 配置是否完整
+ if !isSMTPConfigured(ctx) {
+ return errors.New(errSMTPConfigIncomplete)
+ }
+
+ code := generateVerificationCode()
+ codeKey := getEmailCodeKey(scene, email)
+ cooldownKey := getEmailCooldownKey(email)
+
+ // 使用模板管理获取并渲染邮件标题和正文。模板缺失或渲染失败时不发送验证码。
+ emailSubject, emailBody, err := model.RenderTemplate(
+ ctx,
+ templateName,
+ map[string]any{"Code": code},
+ )
+ if err != nil {
+ return fmt.Errorf(errRenderEmailTemplateFailed, err)
+ }
+
+ // 存验证码,5分钟有效
+ if err := db.SetJSON(ctx, codeKey, code, emailCodeExpiry); err != nil {
+ return errors.New(errGenerateEmailCodeFailed)
+ }
+ // 存冷却,60秒有效
+ _ = db.SetJSON(ctx, cooldownKey, "1", emailCodeCooldown)
+
+ // 构建异步邮件发送任务
+ payload := SendEmailPayload{
+ To: email,
+ Subject: emailSubject,
+ Body: emailBody,
+ }
+ payloadBytes, _ := json.Marshal(payload)
+ _, err = task.DispatchTask(ctx, task.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 isPasswordLoginEnabled() bool {
+ enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyPasswordLoginEnabled)
+ if err != nil {
+ return true
+ }
+ return enabled
+}
+
+func isPasswordRegisterEnabled() bool {
+ enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyPasswordRegisterEnabled)
+ if err != nil {
+ return true
+ }
+ return enabled
+}
+
+func isRegistrationEnabled() bool {
+ enabled, err := model.GetBoolByKey(context.Background(), model.ConfigKeyRegistrationEnabled)
+ if err != nil {
+ return true
+ }
+ return enabled
+}
+
+func setLoginSession(c *gin.Context, user *model.User) error {
+ session := sessions.Default(c)
+ session.Set(oauth.UserIDKey, user.ID)
+ session.Set(oauth.UserNameKey, user.Username)
+ if err := session.Save(); err != nil {
+ return err
+ }
+ return nil
+}
+
+// handleLoginEmailVerification 处理登录时的邮箱验证码校验流程
+func handleLoginEmailVerification(ctx context.Context, c *gin.Context, req *loginRequest, user *model.User) error {
+ if user.Email == "" {
+ c.JSON(http.StatusOK, util.Err(errLoginEmailMissing))
+ return errors.New("handled")
+ }
+
+ if req.Code == "" {
+ // 校验 Redis 发送冷却时间
+ cooldownKey := getEmailCooldownKey(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, util.Err(err.Error()))
+ return errors.New("handled")
+ }
+ }
+
+ // 脱敏邮箱并返回错误,提示前端需要输入验证码
+ maskedEmail := util.MaskEmail(user.Email)
+ c.JSON(http.StatusOK, util.Err(errNeedEmailCodePrefix+maskedEmail))
+ return errors.New("handled")
+ }
+
+ // 校验验证码
+ if !verifyEmailCode(ctx, user.Email, "login", req.Code) {
+ c.JSON(http.StatusOK, util.Err(errEmailCodeInvalidOrExpired))
+ return errors.New("handled")
+ }
+ return nil
+}
+
+// Login 用户密码登录
+// @Summary 用户密码登录
+// @Description 使用用户名和密码登录,登录成功后建立 Session。若管理员已关闭密码登录功能则返回错误。
+// @Tags user
+// @Accept json
+// @Produce json
+// @Param request body user.loginRequest true "登录请求参数"
+// @Success 200 {object} util.ResponseAny{data=oauth.BasicUserInfo} "登录成功,返回用户信息"
+// @Failure 400 {object} util.ResponseAny "用户名或密码错误、帐号已禁用等"
+// @Failure 500 {object} util.ResponseAny "服务内部错误"
+// @Router /api/v1/user/login [post]
+func Login(c *gin.Context) {
+ if !isPasswordLoginEnabled() {
+ c.JSON(http.StatusOK, util.Err(errPasswordLoginDisabled))
+ return
+ }
+ var req loginRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, util.Err(err.Error()))
+ return
+ }
+ req.Username = strings.TrimSpace(req.Username)
+ if req.Username == "" || req.Password == "" {
+ c.JSON(http.StatusOK, util.Err(errInvalidParams))
+ return
+ }
+
+ var user model.User
+ ctx := c.Request.Context()
+ if err := db.DB(ctx).Where("username = ?", req.Username).First(&user).Error; err != nil {
+ c.JSON(http.StatusOK, util.Err(errUsernameOrPasswordWrong))
+ return
+ }
+ if !user.IsActive {
+ c.JSON(http.StatusOK, util.Err(common.BannedAccount))
+ return
+ }
+
+ // 判定是否是明文密码存储
+ isPlaintext := !user.IsPasswordEncrypted()
+
+ if !user.CheckPassword(req.Password) {
+ c.JSON(http.StatusOK, util.Err(errUsernameOrPasswordWrong))
+ return
+ }
+
+ if isEmailLoginVerificationEnabled() {
+ if emailErr := handleLoginEmailVerification(ctx, c, &req, &user); emailErr != nil {
+ return
+ }
+ }
+
+ session := sessions.Default(c)
+ needChangePassword := false
+
+ // 如果是以明文密码登录,在数据库中置换为加密密码
+ if isPlaintext {
+ if err := user.SetEncryptedPassword(req.Password); err == nil {
+ if err := db.DB(ctx).Model(&user).Update("password", user.Password).Error; err != nil {
+ c.JSON(http.StatusOK, util.Err(errPasswordUpgradeFailed))
+ return
+ }
+ needChangePassword = true
+ session.Set("need_change_password", true)
+ _ = session.Save()
+ }
+ }
+
+ user.LastLoginAt = time.Now()
+ if err := db.DB(ctx).Model(&user).Update("last_login_at", user.LastLoginAt).Error; err != nil {
+ c.JSON(http.StatusOK, util.Err(err.Error()))
+ return
+ }
+ if err := setLoginSession(c, &user); err != nil {
+ c.JSON(http.StatusOK, util.Err(errSaveSessionFailed))
+ return
+ }
+
+ // 检查是否有未完成 of OAuth/OIDC 绑定
+ completePendingOAuthBinding(session, &user)
+
+ c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&user, needChangePassword)))
+}
+
+// Register 用户注册
+// @Summary 用户注册
+// @Description 使用用户名和密码注册新账号,注册成功后自动登录并建立 Session。密码长度不能少于 8 位。
+// @Tags user
+// @Accept json
+// @Produce json
+// @Param request body user.registerRequest true "注册请求参数"
+// @Success 200 {object} util.ResponseAny{data=oauth.BasicUserInfo} "注册并登录成功,返回用户信息"
+// @Failure 400 {object} util.ResponseAny "参数错误、用户名已存在或注册已关闭"
+// @Failure 500 {object} util.ResponseAny "服务内部错误"
+// @Router /api/v1/user/register [post]
+func Register(c *gin.Context) {
+ if !isRegistrationEnabled() || !isPasswordRegisterEnabled() {
+ c.JSON(http.StatusOK, util.Err(errRegistrationDisabled))
+ return
+ }
+
+ var req registerRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, util.Err(err.Error()))
+ return
+ }
+
+ req.Username = strings.TrimSpace(req.Username)
+ 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(errInvalidParams))
+ return
+ }
+ if len(req.Password) < minPasswordLength {
+ c.JSON(http.StatusOK, util.Err(errPasswordTooShort))
+ return
+ }
+
+ ctx := c.Request.Context()
+
+ // 邮箱注册验证校验
+ if err := validateRegisterEmailVerification(ctx, &req); err != nil {
+ c.JSON(http.StatusOK, util.Err(err.Error()))
+ return
+ }
+
+ user := model.User{
+ Username: req.Username,
+ Nickname: req.Nickname,
+ Email: req.Email,
+ AvatarURL: "",
+ IsActive: true,
+ IsAdmin: false,
+ LastLoginAt: time.Now(),
+ }
+ if user.Nickname == "" {
+ user.Nickname = req.DisplayName
+ }
+ if user.Nickname == "" {
+ user.Nickname = req.Username
+ }
+ if err := user.SetPassword(req.Password); err != nil {
+ c.JSON(http.StatusOK, util.Err(err.Error()))
+ return
+ }
+
+ if err := user.RegisterUser(ctx, db.DB(ctx)); err != nil {
+ c.JSON(http.StatusOK, util.Err(err.Error()))
+ return
+ }
+
+ if err := setLoginSession(c, &user); err != nil {
+ c.JSON(http.StatusOK, util.Err(errSaveSessionFailed))
+ return
+ }
+
+ c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&user, false)))
+}
+
+// Logout 用户退出登录
+// @Summary 用户退出登录
+// @Description 清除用户登录 Session,完成退出
+// @Tags user
+// @Produce json
+// @Security SessionCookie
+// @Success 200 {object} util.ResponseAny{data=string} "退出成功"
+// @Failure 500 {object} util.ResponseAny "Session 清除失败"
+// @Router /api/v1/user/logout [get]
+func Logout(c *gin.Context) {
+ session := sessions.Default(c)
+ session.Options(util.GetSessionOptions(-1))
+ session.Clear()
+ if err := session.Save(); err != nil {
+ c.JSON(http.StatusOK, util.Err(err.Error()))
+ return
+ }
+ c.JSON(http.StatusOK, util.OK(""))
+}
+
+type changePasswordRequest struct {
+ OldPassword string `json:"old_password"`
+ NewPassword string `json:"new_password"`
+}
+
+// ChangePassword 修改用户密码
+// @Summary 修改用户密码
+// @Description 修改当前登录用户的密码。修改成功后,如果是首次明文登录的升级提示,则清除修改密码的提示状态。
+// @Tags user
+// @Accept json
+// @Produce json
+// @Param request body user.changePasswordRequest true "修改密码请求参数"
+// @Success 200 {object} util.ResponseAny{data=string} "修改密码成功"
+// @Failure 400 {object} util.ResponseAny "原密码错误或新密码不符合要求"
+// @Failure 401 {object} util.ResponseAny "请先登录"
+// @Router /api/v1/user/change-password [post]
+func ChangePassword(c *gin.Context) {
+ var req changePasswordRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, util.Err(err.Error()))
+ return
+ }
+
+ req.OldPassword = strings.TrimSpace(req.OldPassword)
+ req.NewPassword = strings.TrimSpace(req.NewPassword)
+
+ if req.OldPassword == "" || req.NewPassword == "" {
+ c.JSON(http.StatusOK, util.Err(errInvalidParams))
+ return
+ }
+ if len(req.NewPassword) < minPasswordLength {
+ c.JSON(http.StatusOK, util.Err(errNewPasswordTooShort))
+ return
+ }
+
+ userObj, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
+ if userObj == nil {
+ c.JSON(http.StatusUnauthorized, util.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, util.Err(errUserNotFound))
+ return
+ }
+
+ // 校验旧密码
+ if !dbUser.CheckPassword(req.OldPassword) {
+ c.JSON(http.StatusOK, util.Err(errOldPasswordIncorrect))
+ return
+ }
+
+ // 加密并更新为新密码
+ if err := dbUser.SetEncryptedPassword(req.NewPassword); err != nil {
+ c.JSON(http.StatusOK, util.Err(errPasswordEncryptFailed))
+ return
+ }
+
+ if err := db.DB(ctx).Model(&dbUser).Update("password", dbUser.Password).Error; err != nil {
+ c.JSON(http.StatusOK, util.Err(err.Error()))
+ return
+ }
+
+ // 清除 Session 中修改密码提示状态
+ session := sessions.Default(c)
+ session.Delete("need_change_password")
+ _ = session.Save()
+
+ 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(errEmailRequired))
+ return
+ }
+
+ if req.Scene != "register" {
+ c.JSON(http.StatusOK, util.Err(errUnsupportedEmailScene))
+ 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(errEmailAlreadyRegistered))
+ return
+ }
+
+ // 2. 校验 Redis 发送冷却时间
+ cooldownKey := getEmailCooldownKey(req.Email)
+ var temp string
+ err := db.GetJSON(ctx, cooldownKey, &temp)
+ if err == nil {
+ c.JSON(http.StatusOK, util.Err(errEmailCodeCooldown))
+ return
+ }
+
+ // 3. 发送验证码
+ if err := sendEmailVerificationCode(ctx, req.Email, "register", "register_email"); err != nil {
+ c.JSON(http.StatusOK, util.Err(err.Error()))
+ return
+ }
+
+ c.JSON(http.StatusOK, util.OKNil())
+}
+
+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} util.ResponseAny{data=oauth.BasicUserInfo} "修改成功,返回更新后的用户信息"
+// @Failure 400 {object} util.ResponseAny "邮箱已被占用或参数错误"
+// @Failure 401 {object} util.ResponseAny "未登录"
+// @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, util.Err(err.Error()))
+ return
+ }
+
+ userObj, _ := util.GetFromContext[*model.User](c, oauth.UserObjKey)
+ if userObj == nil {
+ c.JSON(http.StatusUnauthorized, util.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, util.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, util.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, util.Err(err.Error()))
+ return
+ }
+ if count > 0 {
+ c.JSON(http.StatusOK, util.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, util.Err(err.Error()))
+ return
+ }
+
+ session := sessions.Default(c)
+ needChange := session.Get("need_change_password") == true
+
+ c.JSON(http.StatusOK, util.OK(oauth.BuildBasicUserInfo(&dbUser, needChange)))
+}
+
+// validateRegisterEmailVerification 校验注册时的邮箱验证码
+func validateRegisterEmailVerification(ctx context.Context, req *registerRequest) error {
+ if !isEmailRegisterVerificationEnabled() {
+ 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
+}
+
+// completePendingOAuthBinding 完成登录后的 OAuth 待绑定绑定流程
+func completePendingOAuthBinding(session sessions.Session, user *model.User) {
+ pendingSourceID := session.Get(oauth.PendingOAuthSourceIDKey)
+ pendingExternalID := session.Get(oauth.PendingOAuthExternalIDKey)
+ pendingExternalUsername := session.Get(oauth.PendingOAuthExternalUsernameKey)
+ pendingEmail := session.Get(oauth.PendingOAuthEmailKey)
+
+ if pendingSourceID == nil || pendingExternalID == nil {
+ return
+ }
+
+ var sourceID uint64
+ switch v := pendingSourceID.(type) {
+ case uint64:
+ sourceID = v
+ case int:
+ sourceID = uint64(v)
+ case float64:
+ sourceID = uint64(v)
+ }
+ externalID, _ := pendingExternalID.(string)
+ externalUsername, _ := pendingExternalUsername.(string)
+ email, _ := pendingEmail.(string)
+
+ if sourceID != 0 && externalID != "" {
+ _ = model.BindExternalAccount(&model.ExternalAccount{
+ AuthSourceID: sourceID,
+ UserID: user.ID,
+ ExternalID: externalID,
+ ExternalUsername: externalUsername,
+ Email: email,
+ })
+ }
+ // 清除 pending 信息
+ session.Delete(oauth.PendingOAuthSourceIDKey)
+ session.Delete(oauth.PendingOAuthExternalIDKey)
+ session.Delete(oauth.PendingOAuthExternalUsernameKey)
+ session.Delete(oauth.PendingOAuthEmailKey)
+ _ = session.Save()
+}
diff --git a/internal/cmd/all.go b/internal/cmd/all.go
index dcabf51d..1085c3c2 100644
--- a/internal/cmd/all.go
+++ b/internal/cmd/all.go
@@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
+// Package cmd 提供 CLI 命令入口
package cmd
import (
@@ -30,7 +31,7 @@ import (
var allCmd = &cobra.Command{
Use: "all",
Short: "以融合模式同时启动 API、Worker 和 Scheduler",
- Run: func(cmd *cobra.Command, args []string) {
+ Run: func(_ *cobra.Command, _ []string) {
log.Println("[All] 融合模式启动")
var wg sync.WaitGroup
diff --git a/internal/cmd/api.go b/internal/cmd/api.go
index 5cfe2d70..229d90af 100644
--- a/internal/cmd/api.go
+++ b/internal/cmd/api.go
@@ -25,7 +25,7 @@ import (
var apiCmd = &cobra.Command{
Use: "api",
Short: "wavelet API",
- Run: func(cmd *cobra.Command, args []string) {
+ Run: func(_ *cobra.Command, _ []string) {
router.Serve()
},
}
diff --git a/internal/cmd/root.go b/internal/cmd/root.go
index 69586a80..e455a6e4 100644
--- a/internal/cmd/root.go
+++ b/internal/cmd/root.go
@@ -26,10 +26,10 @@ import (
var rootCmd = &cobra.Command{
Use: "wavelet",
- PreRun: func(cmd *cobra.Command, args []string) {
+ PreRun: func(_ *cobra.Command, _ []string) {
migrator.Migrate()
},
- Run: func(cmd *cobra.Command, args []string) {
+ Run: func(_ *cobra.Command, args []string) {
// 无参数时默认以融合模式启动所有服务
if len(args) == 0 {
allCmd.Run(allCmd, args)
@@ -55,6 +55,7 @@ func init() {
rootCmd.CompletionOptions.DisableDefaultCmd = true
}
+// Execute 执行根命令
func Execute() {
if err := rootCmd.Execute(); err != nil {
log.Fatalf("[CMD] execute failed; %s\n", err)
diff --git a/internal/cmd/scheduler.go b/internal/cmd/scheduler.go
index 363d3db8..4a186080 100644
--- a/internal/cmd/scheduler.go
+++ b/internal/cmd/scheduler.go
@@ -28,7 +28,7 @@ import (
var schedulerCmd = &cobra.Command{
Use: "scheduler",
Short: "wavelet Scheduler",
- Run: func(cmd *cobra.Command, args []string) {
+ Run: func(_ *cobra.Command, _ []string) {
log.Println("[Scheduler] 启动定时任务调度服务")
if err := scheduler.StartScheduler(); err != nil {
log.Fatalf("[调度器] 启动失败: %v", err)
diff --git a/internal/cmd/worker.go b/internal/cmd/worker.go
index e7fa9273..a36a872b 100644
--- a/internal/cmd/worker.go
+++ b/internal/cmd/worker.go
@@ -28,7 +28,7 @@ import (
var workerCmd = &cobra.Command{
Use: "worker",
Short: "wavelet Worker",
- Run: func(cmd *cobra.Command, args []string) {
+ Run: func(_ *cobra.Command, _ []string) {
log.Println("[Worker] 启动任务处理服务")
if err := worker.StartWorker(); err != nil {
log.Fatalf("[工作器] 启动失败: %v", err)
diff --git a/internal/config/model.go b/internal/config/model.go
index 2c15e68e..7f8934d6 100644
--- a/internal/config/model.go
+++ b/internal/config/model.go
@@ -43,7 +43,7 @@ type appConfig struct {
SessionSecret string `mapstructure:"session_secret"`
SessionDomain string `mapstructure:"session_domain"`
SessionAge int `mapstructure:"session_age"`
- SessionHttpOnly bool `mapstructure:"session_http_only"`
+ SessionHTTPOnly bool `mapstructure:"session_http_only"`
SessionSecure bool `mapstructure:"session_secure"`
}
diff --git a/internal/db/migrator/migrator.go b/internal/db/migrator/migrator.go
index f19d1e98..6d34edef 100644
--- a/internal/db/migrator/migrator.go
+++ b/internal/db/migrator/migrator.go
@@ -15,6 +15,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
+// Package migrator 提供数据库自动迁移功能
package migrator
import (
@@ -37,6 +38,7 @@ func dbType() string {
return "PostgreSQL"
}
+// Migrate 执行数据库自动迁移,初始化系统配置、默认管理员和内置模板
func Migrate() {
if err := db.DB(context.Background()).AutoMigrate(
&model.User{},
@@ -276,7 +278,7 @@ func initDefaultAdmin() {
Username: "admin",
Password: "12345678", // 密码使用明文存储
Nickname: "Administrator",
- AvatarUrl: "",
+ AvatarURL: "",
IsActive: true,
IsAdmin: true,
LastLoginAt: time.Now(),
diff --git a/internal/model/users.go b/internal/model/users.go
index bb21836f..3a521388 100644
--- a/internal/model/users.go
+++ b/internal/model/users.go
@@ -31,20 +31,20 @@ import (
// OAuthUserInfo 用户信息结构(同时支持 OIDC ID Token claims 和 UserEndpoint 响应)
type OAuthUserInfo struct {
- Id uint64 `json:"id"`
+ ID uint64 `json:"id"`
Sub string `json:"sub"`
Username string `json:"username"`
PreferredUsername string `json:"preferred_username"`
Email string `json:"email"`
Name string `json:"name"`
Active bool `json:"active"`
- AvatarUrl string `json:"avatar_url"`
+ AvatarURL string `json:"avatar_url"`
}
// GetID 获取用户 ID
func (u *OAuthUserInfo) GetID() uint64 {
- if u.Id != 0 {
- return u.Id
+ if u.ID != 0 {
+ return u.ID
}
// 从 sub 解析(OIDC 格式)
if u.Sub != "" {
@@ -55,13 +55,14 @@ func (u *OAuthUserInfo) GetID() uint64 {
return 0
}
+// User 用户表实体
type User struct {
ID uint64 `json:"id" gorm:"primaryKey"`
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"`
+ 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"`
Bio string `json:"bio" gorm:"size:500"`
@@ -74,11 +75,13 @@ type User struct {
UpdatedAt time.Time `json:"updated_at" gorm:"autoUpdateTime;index"`
}
+// SetPassword 设置明文密码
func (u *User) SetPassword(password string) error {
u.Password = password
return nil
}
+// SetEncryptedPassword 设置加密密码
func (u *User) SetEncryptedPassword(password string) error {
if password == "" {
u.Password = ""
@@ -92,10 +95,12 @@ func (u *User) SetEncryptedPassword(password string) error {
return nil
}
+// IsPasswordEncrypted 检查密码是否已加密
func (u *User) IsPasswordEncrypted() bool {
return strings.HasPrefix(u.Password, "$2a$") || strings.HasPrefix(u.Password, "$2b$") || strings.HasPrefix(u.Password, "$2y$")
}
+// CheckPassword 验证密码是否匹配
func (u *User) CheckPassword(password string) bool {
if u.Password == "" || password == "" {
return false
@@ -106,6 +111,7 @@ func (u *User) CheckPassword(password string) bool {
return u.Password == password
}
+// GetByID 根据 ID 查询用户
func (u *User) GetByID(tx *gorm.DB, id uint64) error {
if err := tx.Where("id = ?", id).First(u).Error; err != nil {
return err
@@ -118,7 +124,7 @@ func (u *User) UpdateFromOAuthInfo(oauthInfo *OAuthUserInfo) {
u.Username = oauthInfo.Username
u.Nickname = oauthInfo.Name
u.Email = oauthInfo.Email
- u.AvatarUrl = oauthInfo.AvatarUrl
+ u.AvatarURL = oauthInfo.AvatarURL
u.IsActive = oauthInfo.Active
u.LastLoginAt = time.Now()
}
@@ -144,7 +150,7 @@ func (u *User) CreateUser(ctx context.Context, tx *gorm.DB, oauthInfo *OAuthUser
Username: oauthInfo.Username,
Nickname: oauthInfo.Name,
Email: oauthInfo.Email,
- AvatarUrl: oauthInfo.AvatarUrl,
+ AvatarURL: oauthInfo.AvatarURL,
IsActive: oauthInfo.Active,
LastLoginAt: now,
IsAdmin: false,
diff --git a/internal/router/frontend.go b/internal/router/frontend.go
index a2b86dfd..b1bf08a8 100644
--- a/internal/router/frontend.go
+++ b/internal/router/frontend.go
@@ -17,10 +17,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
+// Package router 提供 HTTP 路由注册与服务启动
package router
import "github.com/gin-gonic/gin"
-func registerFrontend(r *gin.Engine) {
+func registerFrontend(_ *gin.Engine) {
// No-op when not embedding frontend
}
diff --git a/internal/router/router.go b/internal/router/router.go
index b55f93d4..0a836b1c 100644
--- a/internal/router/router.go
+++ b/internal/router/router.go
@@ -45,6 +45,7 @@ import (
"github.com/Rain-kl/Wavelet/internal/util"
capUtil "github.com/Rain-kl/Wavelet/internal/util/cap"
+ // Swagger 文档生成
_ "github.com/Rain-kl/Wavelet/docs"
"github.com/Rain-kl/Wavelet/internal/apps/admin/system_config"
"github.com/Rain-kl/Wavelet/internal/apps/oauth"
@@ -58,6 +59,7 @@ import (
"go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin"
)
+// Serve 启动 HTTP API 服务
func Serve() {
// 运行模式
if config.Config.App.IsProduction() {
@@ -267,13 +269,15 @@ func Serve() {
<-quit
shutdownCtx, cancel := context.WithTimeout(context.Background(), time.Duration(config.Config.App.GracefulShutdownTimeout)*time.Second)
- defer cancel()
otel_trace.Shutdown(shutdownCtx)
if err := srv.Shutdown(shutdownCtx); err != nil {
- log.Fatalf("[API] server forced to shutdown: %v\n", err)
+ log.Printf("[API] server forced to shutdown: %v\n", err)
+ cancel()
+ os.Exit(1)
}
+ cancel()
log.Println("[API] server exited")
}
diff --git a/internal/util/cap/store.go b/internal/util/cap/store.go
index e08cd0c7..623cffd5 100644
--- a/internal/util/cap/store.go
+++ b/internal/util/cap/store.go
@@ -60,7 +60,8 @@ func NewMemoryStore(cleanupInterval time.Duration) *MemoryStore {
return store
}
-func (s *MemoryStore) Get(ctx context.Context, key string) (string, bool, error) {
+// Get 从 MemoryStore 获取指定 key 的值
+func (s *MemoryStore) Get(_ context.Context, key string) (string, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
return s.getLocked(key)
@@ -79,7 +80,8 @@ func (s *MemoryStore) getLocked(key string) (string, bool, error) {
return item.value, true, nil
}
-func (s *MemoryStore) Set(ctx context.Context, key string, val string, ttl time.Duration) error {
+// Set 向 MemoryStore 写入指定 key 的值
+func (s *MemoryStore) Set(_ context.Context, key string, val string, ttl time.Duration) error {
s.mu.Lock()
defer s.mu.Unlock()
s.items[key] = memoryItem{
@@ -89,7 +91,8 @@ func (s *MemoryStore) Set(ctx context.Context, key string, val string, ttl time.
return nil
}
-func (s *MemoryStore) Delete(ctx context.Context, key string) error {
+// Delete 从 MemoryStore 删除指定 key
+func (s *MemoryStore) Delete(_ context.Context, key string) error {
s.mu.Lock()
defer s.mu.Unlock()
delete(s.items, key)
@@ -98,7 +101,7 @@ func (s *MemoryStore) Delete(ctx context.Context, key string) error {
// SetNX atomically sets key only when it is absent (or expired).
// Returns true if the key was written by this call.
-func (s *MemoryStore) SetNX(ctx context.Context, key string, val string, ttl time.Duration) (bool, error) {
+func (s *MemoryStore) SetNX(_ context.Context, key string, val string, ttl time.Duration) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -114,7 +117,7 @@ func (s *MemoryStore) SetNX(ctx context.Context, key string, val string, ttl tim
}
// GetAndDelete atomically retrieves and removes key in one critical section.
-func (s *MemoryStore) GetAndDelete(ctx context.Context, key string) (string, bool, error) {
+func (s *MemoryStore) GetAndDelete(_ context.Context, key string) (string, bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
@@ -156,6 +159,7 @@ func NewRedisStore(client redis.UniversalClient) *RedisStore {
}
}
+// Get 从 RedisStore 获取指定 key 的值
func (s *RedisStore) Get(ctx context.Context, key string) (string, bool, error) {
val, err := s.client.Get(ctx, key).Result()
if err == redis.Nil {
@@ -167,10 +171,12 @@ func (s *RedisStore) Get(ctx context.Context, key string) (string, bool, error)
return val, true, nil
}
+// Set 向 RedisStore 写入指定 key 的值
func (s *RedisStore) Set(ctx context.Context, key string, val string, ttl time.Duration) error {
return s.client.Set(ctx, key, val, ttl).Err()
}
+// Delete 从 RedisStore 删除指定 key
func (s *RedisStore) Delete(ctx context.Context, key string) error {
return s.client.Del(ctx, key).Err()
}
diff --git a/internal/util/session.go b/internal/util/session.go
index 7125ba42..1d4cd784 100644
--- a/internal/util/session.go
+++ b/internal/util/session.go
@@ -22,12 +22,13 @@ import (
"github.com/gin-contrib/sessions"
)
+// GetSessionOptions 根据配置构建 Session 选项
func GetSessionOptions(maxAge int) sessions.Options {
return sessions.Options{
Path: "/",
Domain: config.Config.App.SessionDomain,
MaxAge: maxAge,
- HttpOnly: config.Config.App.SessionHttpOnly,
+ HttpOnly: config.Config.App.SessionHTTPOnly,
Secure: config.Config.App.SessionSecure,
}
}