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" .}} +
+
{{else}} @@ -72,7 +81,7 @@
用户 ID - {{.User.ID}} + {{.User.ID}}
邮箱 @@ -101,5 +110,111 @@ {{template "layout/footer.html" .}} + diff --git a/templates/MetaLab-2026/static/css/settings.css b/templates/MetaLab-2026/static/css/settings.css index c5cce1b..b39e215 100644 --- a/templates/MetaLab-2026/static/css/settings.css +++ b/templates/MetaLab-2026/static/css/settings.css @@ -10,6 +10,37 @@ body { min-height: 100vh; } +/* ---------- Toast Notification ---------- */ +.settings-toast { + position: fixed; + top: 55px; /* 紧贴导航栏下方 */ + left: 50%; + transform: translateX(-50%) translateY(-20px); + padding: .6rem 1.5rem; + font-size: .88rem; + font-weight: 500; + color: #fff; + background: #2ecc71; + border-radius: 8px; + box-shadow: 0 4px 16px rgba(0, 0, 0, .15); + z-index: 1000; + opacity: 0; + pointer-events: none; + transition: opacity .25s ease, transform .25s ease; + max-width: calc(100% - 40px); + white-space: nowrap; +} + +.settings-toast.show { + opacity: 1; + transform: translateX(-50%) translateY(0); + pointer-events: auto; +} + +.settings-toast.error { + background: #e74c3c; +} + /* ---------- Layout ---------- */ .settings-layout { display: flex; @@ -104,6 +135,91 @@ body { padding: 1.25rem 2rem 1.5rem; } +/* ---------- Input Field ---------- */ +.settings-text-input { + width: 100%; + max-width: 260px; + padding: .45rem .7rem; + font-size: .92rem; + font-family: inherit; + color: var(--color-text); + background: #f9fafb; + border: 1px solid var(--color-border); + border-radius: 6px; + outline: none; + transition: border-color .15s ease, box-shadow .15s ease; +} + +.settings-text-input:focus { + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(52, 152, 219, .15); + background: #fff; +} + +/* ---------- Textarea Field ---------- */ +.settings-textarea { + width: 100%; + max-width: 460px; + height: 80px; + padding: .5rem .7rem; + font-size: .92rem; + font-family: inherit; + color: var(--color-text); + background: #f9fafb; + border: 1px solid var(--color-border); + border-radius: 6px; + outline: none; + resize: vertical; + transition: border-color .15s ease, box-shadow .15s ease; + line-height: 1.5; +} + +.settings-textarea:focus { + border-color: var(--color-accent); + box-shadow: 0 0 0 3px rgba(52, 152, 219, .15); + background: #fff; +} + +/* ---------- Character Counter ---------- */ +.char-counter { + font-size: .78rem; + color: #aaa; + margin-top: 4px; + display: block; +} + +/* ---------- Save Button ---------- */ +.settings-card-footer { + padding: 1rem 2rem 1.5rem; + border-top: 1px solid var(--color-border); + margin-top: .25rem; +} + +.settings-save-btn { + display: inline-flex; + align-items: center; + justify-content: center; + padding: .55rem 1.5rem; + font-size: .88rem; + font-weight: 600; + font-family: inherit; + color: #fff; + background: var(--color-accent); + border: none; + border-radius: 6px; + cursor: pointer; + transition: background .15s ease, opacity .15s ease; +} + +.settings-save-btn:hover { + background: #2980b9; +} + +.settings-save-btn:disabled { + opacity: .6; + cursor: not-allowed; +} + /* ---------- Info Rows ---------- */ .info-row { display: flex;