# 个人设置功能

> 实现日期：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
```
