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
This commit is contained in:
@ -378,6 +378,37 @@ RememberMe bool `json:"remember_me"`
|
||||
|
||||
---
|
||||
|
||||
### 14. 个人设置页与头像上传
|
||||
|
||||
**涉及文件**:参考 [`docs/settings.md`](settings.md) 获取完整实现文档。
|
||||
|
||||
**功能概要**:
|
||||
|
||||
| 功能 | 实现文件 |
|
||||
|------|----------|
|
||||
| 用户名编辑 | `auth_service.go` — `UpdateProfile()`,正则 `[\p{Han}a-zA-Z0-9_-]` 白名单 |
|
||||
| 个性签名编辑 | 同上,0-128 字符 |
|
||||
| 头像处理管线 | `avatar_service.go`(新文件)— 解码→裁切→CatmullRom缩放→WebP二分编码→原子写入 |
|
||||
| 前端裁切弹窗 | `settings/index.html` — 固定框 + 图片平移/缩放 + 坐标映射 |
|
||||
| CSS样式 | `settings.css` — 浅色主题裁切弹窗、Toast通知、输入框样式 |
|
||||
| CSP放宽 | `security.go` — `img-src 'self' data:`(裁切预览用data URI) |
|
||||
| 静态路由 | `main.go` — `/uploads/avatars` → `./storage/uploads/avatars` |
|
||||
|
||||
**设计决策**:
|
||||
|
||||
| 决策 | 选择 | 原因 |
|
||||
|------|------|------|
|
||||
| 用户名校验 | 白名单 regex | 比黑名单更安全,直接排除 `<>&"'` 等 XSS 向量 |
|
||||
| 字符计数 | `utf8.RuneCountInString` | 确保与 PostgreSQL `varchar(16)` 语义一致 |
|
||||
| 头像编码 | WebP + 二分查找质量 | 目标 ≤100KB,兼顾质量与体积 |
|
||||
| 原子写入 | `.tmp` → `os.Rename` | 防止写入中断产生损坏文件 |
|
||||
| 裁切方式 | 固定框 + 图片平移/缩放 | 框大小不变,zoom使框覆盖更小区域,真正支持精确定位 |
|
||||
| GIF支持 | 不支持 | 裁切后无法保持动画,无实际用途 |
|
||||
| 最小分辨率 | 128×128 | 防止马赛克图被拉升到 512×512 |
|
||||
| CSP data: URI | 显式允许 img-src | 裁切预览使用 FileReader → data URI |
|
||||
|
||||
---
|
||||
|
||||
## 二、解决方案建议
|
||||
|
||||
### 2.1 PostgreSQL 密码认证(问题一记录)
|
||||
@ -505,7 +536,7 @@ config.yaml # 主配置(服务/数据库/JWT 含 remember_expire/BCry
|
||||
| ~~**P0**~~ | ~~登出功能~~ | ✅ 已完成 | `POST /api/auth/logout` → 清除三个 Cookie + 跳转首页 |
|
||||
| **P1** | 首页完善 | 待做 | 当前仅有 Hero 区域,需添加帖子列表、话题导航等 |
|
||||
| **P1** | 帖子系统 | 待做 | CRUD + 话题分类 + Markdown 编辑 |
|
||||
| **P1** | 用户设置页 | 待做 | `/settings` — 修改用户名/头像/密码/个人简介 |
|
||||
| **P1** | 用户设置页 | ✅ 已完成 | `/settings/profile`、`/settings/account` — 用户名修改、个性签名、头像上传与裁切 |
|
||||
| **P2** | 管理后台 | 待做 | `/admin` — 用户管理、内容审核、主题切换 |
|
||||
| ~~**P2**~~ | ~~刷新令牌~~ | ✅ 已完成 | 记住我 + access/refresh 双 Token + 自动续期 + 登出 |
|
||||
| **P3** | 多主题支持 | 待做 | `MetaLab-2026` → `MetaLab-2027` 切换 |
|
||||
|
||||
195
docs/settings.md
Normal file
195
docs/settings.md
Normal file
@ -0,0 +1,195 @@
|
||||
# 个人设置功能
|
||||
|
||||
> 实现日期: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
|
||||
```
|
||||
@ -9,8 +9,11 @@ METAZONE.FAN/
|
||||
│ ├── config/ # 配置加载(结构体 + yaml/file)
|
||||
│ ├── router/ # 路由注册(按路由组拆文件)
|
||||
│ ├── controller/ # 控制器(瘦)— 接参数 → 调服务 → 返回
|
||||
│ │ ├── interfaces.go # 控制器层接口定义(ISP)
|
||||
│ │ └── admin/ # 后台子包(功能多,独立拆)
|
||||
│ ├── service/ # 业务逻辑(厚)— 核心逻辑、校验、编排
|
||||
│ │ ├── repository.go # Service 层仓储接口定义(ISP)
|
||||
│ │ └── avatar_service.go # 头像上传处理管线
|
||||
│ ├── repository/ # 数据访问(DAO)— 纯数据库操作
|
||||
│ ├── model/ # 数据模型 — 纯结构体 + DTO
|
||||
│ ├── middleware/ # 中间件 — 每个中间件一个文件
|
||||
@ -22,13 +25,17 @@ METAZONE.FAN/
|
||||
│ │ ├── guidelines.html # 准则等长文本(非模板,纯内容文件)
|
||||
│ │ ├── html/ # 页面模板
|
||||
│ │ │ ├── layout/ # 布局组件(header/nav/footer)
|
||||
│ │ │ ├── home/ post/ comment/ user/ topic/ ...
|
||||
│ │ │ ├── home/ post/ comment/ user/ topic/ settings/
|
||||
│ │ │ └── partials/ # 可复用组件(卡片/分页/侧栏)
|
||||
│ │ └── static/ # 静态资源(css/js/img)
|
||||
│ │ ├── css/
|
||||
│ │ │ ├── common.css # 全局样式 + 导航
|
||||
│ │ │ ├── home.css auth.css settings.css
|
||||
│ │ └── js/
|
||||
│ ├── MetaLab-2027/ # 未来主题(相同结构)
|
||||
│ └── system/ # 系统模板(email 等,与主题无关)
|
||||
├── storage/ # 运行时数据(.gitignore)
|
||||
│ ├── uploads/avatars/ attachments/
|
||||
│ ├── uploads/avatars/ # 用户头像(WebP)
|
||||
│ └── logs/
|
||||
└── docs/ # 项目文档
|
||||
```
|
||||
|
||||
Reference in New Issue
Block a user