feat(settings): 用户名校验强化、个性签名编辑、字符计数器及提示优化

- 用户名校验改用白名单正则(中英文/数字/下划线/连字符),字符数计法统一
- 简介→个性签名,新增 textarea 可编辑,上限 128 字符
- 用户名+签名各增加实时字符计数器(x/16、x/128)
- API 合并为 PUT /api/settings/profile,一次提交两字段,按变更写入
- Toast 移至 NAV 下方,独立样式,5 秒自动消失
- 移除用户 ID 灰色底色,提示改为「个人资料已更新」
- 纯文本存储 + Go 模板自动转义防 XSS
This commit is contained in:
2026-05-27 00:03:09 +08:00
parent 3937945b43
commit debaa17127
6 changed files with 347 additions and 12 deletions

View File

@ -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("登录已过期,请重新登录")

View File

@ -9,9 +9,10 @@ import (
"github.com/gin-gonic/gin"
)
// profileProvider SettingsController 对 Service 层的最小依赖ISP1 个方法)
// profileProvider SettingsController 对 Service 层的最小依赖ISP2 个方法)
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, "操作失败")
}
}

View File

@ -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())

View File

@ -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)
}