Files
mce/docs/settings.md
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

6.1 KiB
Raw Permalink Blame History

个人设置功能

实现日期2026-05-27

概述

个人设置是用户修改公开资料和头像的功能模块,包含三个子功能:

  1. 用户名编辑 — 输入框替换静态文本,白名单字符 + XSS 过滤
  2. 个性签名编辑 — Textarea 控件128 字符上限
  3. 头像上传与裁切 — 完整图片处理管线WebP 编码 ≤100KB

页面结构

/settings/profile — 个人资料(用户名、头像、个性签名)
/settings/account — 账号信息只读UID、邮箱、状态、注册时间

左侧导航栏切换 profile / account 两个 tab通过 :tab 路由参数区分。

用户名编辑

规则

项目 规格
允许字符 中文、英文大小写、数字、下划线(_)、连字符(-
前后端正则 Go: ^[\p{Han}a-zA-Z0-9_-]+$ / JS: ^[\u4e00-\u9fffa-zA-Z0-9_-]+$
长度限制 1-16 字符Unicode utf8.RuneCountInString
XSS 防护 白名单排除 <>&"' 等 HTML 特殊字符Go html/template 自动转义
去重 变更时检查 ExistsByUsername,冲突返回 409

API

PUT /api/settings/profile
Content-Type: application/json
{ "username": "...", "bio": "..." }

成功: { "success": true, "message": "个人资料已更新" }
冲突: 409 { "message": "该用户名已被占用" }
无效: 400 { "message": "用户名格式无效..." }

个性签名编辑

项目 规格
控件 <textarea>maxlength="128"
上限 128 字符Unicode 语义)
前端 实时字符计数器 n/128
XSS Go html/template 自动转义,无需额外处理

头像上传与裁切

上传限制

项目 限制
格式 JPEG、PNG、WebP不支持 GIF
文件大小 ≤ 5MB
分辨率 128×128 ~ 3840×2160
接受标签 accept="image/jpeg,image/png,image/webp"

处理管线 (avatar_service.go)

1. 校验 Content-Type仅 JPEG/PNG/WebP
2. io.LimitReader + io.ReadAll校验文件大小 ≤ 5MB
3. image.Decode() 解码,校验分辨率 [128, 3840]
4. 自定义裁切(前端传入 crop_x/crop_y/crop_size
   └─ subImage() 或 centerCrop()(后备)
5. CatmullRom 高质量缩放至 512×512
6. WebP 编码二分查找质量参数1-80目标 ≤100KB
7. 原子写入:先写 .tmp 再 os.Rename 到最终路径
8. 文件名:{uid}_{timestamp}.webp
9. 清理旧头像:删除该用户所有 {uid}_*.webp
10. 更新 user.Avatar 字段 → 持久化

关键函数

函数 用途
ProcessAvatar(userID, file, contentType, cropX, cropY, cropSize) 主入口,返回 avatar URL
subImage(img, x, y, size) 自定义方形裁切
centerCrop(img) 默认中心裁切
encodeWebP(img, targetBytes) 二分查找最佳质量
cleanOldAvatars(userID) 删除旧文件

API

POST /api/settings/avatar
Content-Type: multipart/form-data

字段:
  avatar:    (file)     图片文件
  crop_x:    (int)      裁切起点 X原图坐标
  crop_y:    (int)      裁切起点 Y原图坐标
  crop_size: (int)      裁切方形边长(原图坐标)

成功: { "success": true, "data": { "url": "/uploads/avatars/1_1234567890.webp" } }
失败: 400 { "message": "..." }

前端裁切弹窗

设计

  • 浅色主题:白色卡片 + 浅灰舞台 (#edf0f4),与站点统一
  • 固定裁切框:框大小不变(短边填满可见区),缩放仅作用于图片
  • 透亮效果:裁切框外 box-shadow: 0 0 0 9999px rgba(0,0,0,.45) 半透明遮罩
  • 白色边框2px 实线边框标记裁切区域
  • 三等分网格background-image 线性参考线

交互

操作 行为
点击头像区域 打开文件选择器
选择文件后 打开裁切弹窗
拖拽图片 平移图片在裁切框后的位置
滚轮 缩放图片1× ~ 3×10% 步长)
取消 关闭弹窗,清除选择
确认 计算裁切坐标 → POST 上传

坐标映射JS

getCropParams():
  裁切框在 stage 中的固定位置 → 减去图片偏移pan→ 除以 zoom
  → 映射回 zoom=1 显示坐标 → 乘以 (原图宽/显示宽)
  → 返回 { x, y, size }(原图像素坐标,提交给后端 subImage

关键 JS 函数

函数 用途
openCrop(file) 加载图片,确定基准显示尺寸,计算裁切框初始位置
renderCrop() 更新图片/裁切框/遮罩的 CSS
clampPan() 约束图片平移范围(裁切框不露出图片外)
getCropParams() 映射回原图像素坐标
滚轮 handler zoom *= 1.1/= 1.1clip 到 [1, 3]

前端反馈

Toast 通知

位置 导航栏下方居中
成功 绿色 #2ecc71
失败 红色 #e74c3c
持续时间 5 秒自动消失
动画 opacity + translateY 进出场

字符计数器

  • 用户名输入框右侧 n/16
  • 个性签名字段下方 n/128
  • 实时更新(input 事件)

保存按钮

  • 点击后 disabled + 文字变 "保存中..."
  • 请求完成恢复 "保存"
  • 无变更时提示 "内容未变更,无需保存"

安全

措施 实现
CSP 放宽 img-src 'self' data: — 裁剪预览需要 data URI
文件大小限制 io.LimitReader 防止内存耗尽
原子写入 .tmpos.Rename 防止写入中断留下损坏文件
旧文件清理 每次上传删除同一用户旧头像文件
XSS 白名单 regex + Go 模板自动转义
认证 /api/settings/* 路由组绑定 authMdw.Required()

架构(接口隔离)

遵循 ISP

Controller.settings_controller.go
  ├── profileProvider (interface: GetProfile, UpdateProfile)
  └── avatarProvider (interface: ProcessAvatar)

Service.auth_service.go    → implements profileProvider
Service.avatar_service.go  → implements avatarProvider, depends on userAvatarStore (interface: FindByID, Update)

Repository.user_repo.go    → implements userAvatarStore