diff --git a/internal/common/errors.go b/internal/common/errors.go index 300fc8c..0bcbbc8 100644 --- a/internal/common/errors.go +++ b/internal/common/errors.go @@ -4,12 +4,15 @@ import "errors" // 认证相关错误哨兵(统一存放,避免 service 和 middleware 交叉引用) var ( - ErrEmailExists = errors.New("该邮箱已注册") - ErrUsernameTaken = errors.New("该用户名已被占用") - ErrInvalidCred = errors.New("邮箱或密码错误") - ErrUserNotFound = errors.New("用户不存在") - ErrUserBanned = errors.New("该账号已被封禁") - ErrUserDeleted = errors.New("该账号已申请注销,登录即自动恢复") + ErrEmailExists = errors.New("该邮箱已注册") + ErrUsernameTaken = errors.New("该用户名已被占用") + ErrUsernameInvalid = errors.New("用户名格式无效(支持中英文、数字、下划线、连字符,1-16个字符)") + ErrBioTooLong = errors.New("个性签名不能超过 128 个字符") + ErrInvalidCred = errors.New("邮箱或密码错误") + ErrUserNotFound = errors.New("用户不存在") + ErrUserBanned = errors.New("该账号已被封禁") + ErrUserDeleted = errors.New("该账号已申请注销,登录即自动恢复") + // 统一返回,避免泄露用户存在性、软删除状态等内部信息 ErrUserLocked = errors.New("邮箱或密码错误") ErrWeakPassword = errors.New("密码需至少 8 位,且包含字母和数字") ErrTokenExpired = errors.New("登录已过期,请重新登录") diff --git a/internal/controller/settings_controller.go b/internal/controller/settings_controller.go index dfc74aa..6323fe9 100644 --- a/internal/controller/settings_controller.go +++ b/internal/controller/settings_controller.go @@ -9,9 +9,10 @@ import ( "github.com/gin-gonic/gin" ) -// profileProvider SettingsController 对 Service 层的最小依赖(ISP:1 个方法) +// profileProvider SettingsController 对 Service 层的最小依赖(ISP:2 个方法) type profileProvider interface { GetProfile(userID uint) (*model.User, error) + UpdateProfile(userID uint, username, bio string) error } // SettingsController 个人设置控制器 @@ -55,3 +56,42 @@ func (sc *SettingsController) SettingsPage(c *gin.Context) { "StatusName": model.StatusDisplayNames[user.Status], })) } + +// UpdateProfile 修改个人资料(用户名 + 个性签名,需登录) +func (sc *SettingsController) UpdateProfile(c *gin.Context) { + uid, exists := c.Get("uid") + if !exists { + common.Error(c, http.StatusUnauthorized, "请先登录") + return + } + + var req struct { + Username string `json:"username"` + Bio string `json:"bio"` + } + if err := c.ShouldBindJSON(&req); err != nil { + common.Error(c, http.StatusBadRequest, "参数错误") + return + } + + if err := sc.authService.UpdateProfile(uid.(uint), req.Username, req.Bio); err != nil { + handleSettingsError(c, err) + return + } + + common.OkMessage(c, "个人资料已更新") +} + +// handleSettingsError 统一处理 settings 接口的 service 层错误 +func handleSettingsError(c *gin.Context, err error) { + switch err { + case common.ErrUsernameTaken: + common.Error(c, http.StatusConflict, err.Error()) + case common.ErrUsernameInvalid: + common.Error(c, http.StatusBadRequest, err.Error()) + case common.ErrBioTooLong: + common.Error(c, http.StatusBadRequest, err.Error()) + default: + common.Error(c, http.StatusInternalServerError, "操作失败") + } +} diff --git a/internal/router/router.go b/internal/router/router.go index 471f9d1..31c0a51 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -72,6 +72,14 @@ func Setup(r *gin.Engine, db *gorm.DB, cfg *config.Config) { authPages.GET("/login", authCtrl.LoginPage) } + // --- 设置页 API(需登录) --- + settingsAPI := r.Group("/api/settings") + settingsAPI.Use(authMdw.Required()) + settingsAPI.Use(middleware.CSRF(cfg)) + { + settingsAPI.PUT("/profile", settingsCtrl.UpdateProfile) + } + // --- 管理后台 SSR 页面(认证失败 302 跳首页) --- adminPages := r.Group("/admin") adminPages.Use(authMdw.AdminAuth()) diff --git a/internal/service/auth_service.go b/internal/service/auth_service.go index 9736d53..b4e3e9f 100644 --- a/internal/service/auth_service.go +++ b/internal/service/auth_service.go @@ -2,6 +2,7 @@ package service import ( "regexp" + "unicode/utf8" "metazone.cc/metalab/internal/common" "metazone.cc/metalab/internal/config" @@ -41,6 +42,9 @@ var ( var pwLetter = regexp.MustCompile(`[a-zA-Z]`) var pwDigit = regexp.MustCompile(`\d`) +// usernamePattern 用户名合法字符:中文、英文大小写、数字、下划线、连字符 +var usernamePattern = regexp.MustCompile(`^[\p{Han}a-zA-Z0-9_-]+$`) + // dummyHash 防时序攻击:当用户不存在时,仍对虚拟哈希执行完整的 bcrypt 比对 // 在 init() 中按当前 bcrypt 成本生成,确保时序与真实校验一致 var dummyHash []byte @@ -184,3 +188,52 @@ func (s *AuthService) GetProfile(userID uint) (*model.User, error) { func (s *AuthService) InvalidateSessions(userID uint) error { return s.userRepo.IncrementTokenVersion(userID) } + +// UpdateProfile 修改个人资料(用户名 + 个性签名) +// 用户名校验:非空、1-16 字符、白名单、去重 +// 个性签名校验:0-128 字符纯文本,Go 模板自动 HTML 转义防 XSS +// 任一字段无变更时跳过该字段的写库操作 +func (s *AuthService) UpdateProfile(userID uint, username, bio string) error { + // 查当前用户 + user, err := s.userRepo.FindByID(userID) + if err != nil { + return common.ErrUserNotFound + } + + needsUpdate := false + + // 用户名:校验 + 更新 + if n := utf8.RuneCountInString(username); n == 0 { + return common.ErrUsernameInvalid + } else if n > 16 { + return common.ErrUsernameInvalid + } + if !usernamePattern.MatchString(username) { + return common.ErrUsernameInvalid + } + if username != user.Username { + exists, err := s.userRepo.ExistsByUsername(username) + if err != nil { + return err + } + if exists { + return common.ErrUsernameTaken + } + user.Username = username + needsUpdate = true + } + + // 个性签名:长度校验 + 更新 + if utf8.RuneCountInString(bio) > 128 { + return common.ErrBioTooLong + } + if bio != user.Bio { + user.Bio = bio + needsUpdate = true + } + + if !needsUpdate { + return nil + } + return s.userRepo.Update(user) +} diff --git a/templates/MetaLab-2026/html/settings/index.html b/templates/MetaLab-2026/html/settings/index.html index b252d96..939c4d4 100644 --- a/templates/MetaLab-2026/html/settings/index.html +++ b/templates/MetaLab-2026/html/settings/index.html @@ -3,6 +3,8 @@ {{template "layout/nav.html" .}} +
+