Files
mce/internal/controller/settings_controller.go
Victor_Jay c3dec09dae feat(settings): 个人资料编辑 + 头像上传/裁切 + 自定义裁切弹窗
**新增功能:**

用户名编辑:输入框替换静态文本,白名单验证(中文/英文/数字/下划线/连字符),前端计数器(n/16),utf8对齐PG VARCHAR,XSS防控。

个性签名编辑:Textarea,128字符上限,实时计数器。

头像上传管线:校验→解码→裁切→CatmullRom缩放→WebP二分编码≤100KB→原子写入(.tmp→os.Rename)。限制5MB,128-3840px,JPEG/PNG/WebP。输出512x512 WebP。文件名 {uid}_{timestamp}.webp。清理旧头像。

自定义裁切弹窗:浅色主题,固定裁切框+图片平移/缩放(1×-3×滚轮),box-shadow遮罩,三等分网格。坐标映射pan/zoom→原图像素→subImage。

CSP修复:img-src允许data:URI(FileReader预览)。

**文件变更:**
修改: README, docs/{development,structure}.md, go.{mod,sum}, cmd/server/main.go, controller/settings, middleware/security, router, templates/settings/{index.html,css}
新增: internal/service/avatar_service.go, docs/settings.md
2026-05-27 01:03:16 +08:00

135 lines
3.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package controller
import (
"io"
"net/http"
"strconv"
"metazone.cc/metalab/internal/common"
"metazone.cc/metalab/internal/model"
"github.com/gin-gonic/gin"
)
// profileProvider SettingsController 对 Service 层的最小依赖ISP2 个方法)
type profileProvider interface {
GetProfile(userID uint) (*model.User, error)
UpdateProfile(userID uint, username, bio string) error
}
// avatarProvider 头像上传对 Service 层的最小依赖ISP1 个方法)
type avatarProvider interface {
ProcessAvatar(userID uint, file io.Reader, contentType string, cropX, cropY, cropSize int) (string, error)
}
// SettingsController 个人设置控制器
type SettingsController struct {
authService profileProvider
avatarService avatarProvider
}
// NewSettingsController 构造函数
func NewSettingsController(authService profileProvider, avatarService avatarProvider) *SettingsController {
return &SettingsController{authService: authService, avatarService: avatarService}
}
// SettingsPage 个人设置页面(需登录)
func (sc *SettingsController) SettingsPage(c *gin.Context) {
uidVal, exists := c.Get("uid")
if !exists {
c.Redirect(http.StatusFound, "/auth/login")
c.Abort()
return
}
uid := uidVal.(uint)
tab := c.Param("tab")
if tab != "profile" && tab != "account" {
c.String(http.StatusNotFound, "页面不存在")
return
}
user, err := sc.authService.GetProfile(uid)
if err != nil || user == nil {
c.String(http.StatusNotFound, "用户不存在")
return
}
c.HTML(http.StatusOK, "settings/index.html", common.BuildPageData(c, gin.H{
"Title": "个人设置",
"ExtraCSS": "/static/css/settings.css",
"ActiveTab": tab,
"User": user,
"RoleName": model.RoleDisplayNames[user.Role],
"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, "个人资料已更新")
}
// UploadAvatar 上传头像需登录multipart/form-data
func (sc *SettingsController) UploadAvatar(c *gin.Context) {
uid, exists := c.Get("uid")
if !exists {
common.Error(c, http.StatusUnauthorized, "请先登录")
return
}
file, header, err := c.Request.FormFile("avatar")
if err != nil {
common.Error(c, http.StatusBadRequest, "请选择文件")
return
}
defer file.Close()
// 裁切参数(可选,来自前端裁切弹窗)
cropX, _ := strconv.Atoi(c.PostForm("crop_x"))
cropY, _ := strconv.Atoi(c.PostForm("crop_y"))
cropSize, _ := strconv.Atoi(c.PostForm("crop_size"))
url, err := sc.avatarService.ProcessAvatar(uid.(uint), file, header.Header.Get("Content-Type"), cropX, cropY, cropSize)
if err != nil {
common.Error(c, http.StatusBadRequest, err.Error())
return
}
common.Ok(c, gin.H{"url": url})
}
// 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, "操作失败")
}
}