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

196 lines
6.1 KiB
Markdown
Raw Permalink 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.

# 个人设置功能
> 实现日期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.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
```