**新增功能:**
用户名编辑:输入框替换静态文本,白名单验证(中文/英文/数字/下划线/连字符),前端计数器(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
6.1 KiB
6.1 KiB
个人设置功能
实现日期:2026-05-27
概述
个人设置是用户修改公开资料和头像的功能模块,包含三个子功能:
- 用户名编辑 — 输入框替换静态文本,白名单字符 + XSS 过滤
- 个性签名编辑 — Textarea 控件,128 字符上限
- 头像上传与裁切 — 完整图片处理管线,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.1,clip 到 [1, 3] |
前端反馈
Toast 通知
| 位置 | 导航栏下方居中 |
|---|---|
| 成功 | 绿色 #2ecc71 |
| 失败 | 红色 #e74c3c |
| 持续时间 | 5 秒自动消失 |
| 动画 | opacity + translateY 进出场 |
字符计数器
- 用户名输入框右侧
n/16 - 个性签名字段下方
n/128 - 实时更新(
input事件)
保存按钮
- 点击后
disabled+ 文字变 "保存中..." - 请求完成恢复 "保存"
- 无变更时提示 "内容未变更,无需保存"
安全
| 措施 | 实现 |
|---|---|
| CSP 放宽 | img-src 'self' data: — 裁剪预览需要 data URI |
| 文件大小限制 | io.LimitReader 防止内存耗尽 |
| 原子写入 | .tmp → os.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