From c6eea8111d38d848d36a79bd6ac93abc1e6a8a6e Mon Sep 17 00:00:00 2001 From: ryan Date: Tue, 9 Jun 2026 13:46:15 +0800 Subject: [PATCH] fix(revive): rename unused parameters to _ for lint compliance --- Agents.md | 28 +- Makefile | 2 +- internal/apps/admin/logs/utils.go | 2 +- internal/apps/config/routers.go | 1 + internal/apps/oauth/middlewares.go | 7 +- internal/apps/oauth/routers.go | 6 +- internal/apps/oauth/sources.go | 190 ++++---- internal/apps/upload/tasks.go | 4 +- internal/apps/user/access_tokens.go | 1 + internal/apps/user/constants.go | 10 + internal/apps/user/controllers.go | 691 ---------------------------- internal/apps/user/routers.go | 669 ++++++++++++++++++++++++++- internal/cmd/all.go | 3 +- internal/cmd/api.go | 2 +- internal/cmd/root.go | 5 +- internal/cmd/scheduler.go | 2 +- internal/cmd/worker.go | 2 +- internal/config/model.go | 2 +- internal/db/migrator/migrator.go | 4 +- internal/model/users.go | 20 +- internal/router/frontend.go | 3 +- internal/router/router.go | 8 +- internal/util/cap/store.go | 16 +- internal/util/session.go | 3 +- 24 files changed, 851 insertions(+), 830 deletions(-) delete mode 100644 internal/apps/user/controllers.go 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, } }