diff --git a/API文档.md b/API文档.md index 00ada70..e8c21e7 100644 --- a/API文档.md +++ b/API文档.md @@ -1,943 +1,331 @@ -# CarrotSkin 后端 API 文档 +# CarrotSkin API 文档(基于后端代码,2026-07 修订) -## 概述 +> 本文档由后端 `internal/handler/*.go` + `internal/types/common.go` 直接整理而来, +> 是前后端契约的唯一事实来源。所有路径以 `/api/v1` 为前缀(Yggdrasil / CSL 除外)。 -本文档总结了 CarrotSkin 后端 API,主要关注前端需要的接口,不包括 Yggdrasil 相关接口(除了更换 Yggdrasil 密码)。 +## 通用约定 -## 基础信息 - -- **基础URL**: `/api/v1` -- **认证方式**: JWT Bearer Token -- **数据格式**: JSON -- **字符编码**: UTF-8 - -## 通用响应格式 - -所有API响应都遵循以下格式: +### 统一响应格式 +成功: ```json -{ - "code": 200, - "message": "操作成功", - "data": { - // 具体数据内容 - } -} +{ "code": 200, "message": "操作成功", "data": <任意> } ``` -分页响应格式: +错误: +```json +{ "code": <非200>, "message": "<错误描述>", "error": "<仅非生产环境返回>" } +``` +常用状态码:`200 成功` / `201 创建成功` / `400 参数错误` / `401 未授权` / `403 禁止` / `404 不存在` / `500 服务器错误`。 + +### 认证 + +- 除"无需认证"标注的接口外,所有 `/user/*`、`/profile` 写操作、`/texture` 写操作均需在请求头携带: + `Authorization: Bearer ` +- JWT 通过登录或注册接口获取。 + +### 分页 + +- 请求参数:`page`(页码,默认 1)、`page_size`(每页数量,默认 20)。 +- 响应数据形态(注意:后端返回的字段名是 **`per_page`**,且 **不含 `total_pages`**): ```json { "code": 200, "message": "操作成功", "data": { - "list": [], + "list": [ ... ], "total": 100, "page": 1, - "page_size": 20, - "total_pages": 5 + "per_page": 20 } } ``` -## 认证相关 API +--- + +## 一、认证 `/auth`(无需 JWT) ### 1. 用户注册 -- **URL**: `POST /api/v1/auth/register` -- **认证**: 无需认证 -- **请求参数**: +- `POST /api/v1/auth/register` +- Body: ```json { - "username": "newuser", // 用户名,3-50字符 - "email": "user@example.com", // 邮箱地址 - "password": "password123", // 密码,6-128字符 - "verification_code": "123456", // 邮箱验证码,6位数字 - "avatar": "https://example.com/avatar.png" // 可选,头像URL + "username": "newuser", + "email": "user@example.com", + "password": "password123", + "verification_code": "123456", + "captcha_id": "uuid-xxxx", + "avatar": "https://example.com/a.png" } ``` -- **响应数据**: + - `username` 3–50 字符;`password` 6–128;`verification_code` 必填 6 位; + - `captcha_id` 必填(来自 `POST /captcha/verify` 后透传的 `captchaId`); + - `avatar` 可选,必须是合法 URL。 +- 响应 `data`: ```json { - "code": 200, - "message": "注册成功", - "data": { - "token": "jwt_token_here", - "user_info": { - "id": 1, - "username": "newuser", - "email": "user@example.com", - "avatar": "https://example.com/avatar.png", - "points": 0, - "role": "user", - "status": 1, - "created_at": "2025-10-01T10:00:00Z", - "updated_at": "2025-10-01T10:00:00Z" - } + "token": "", + "user_info": { + "id": 1, "username": "newuser", "email": "user@example.com", + "avatar": "", "points": 0, "role": "user", "status": 1, + "last_login_at": null, + "created_at": "2026-07-08T10:00:00Z", + "updated_at": "2026-07-08T10:00:00Z" } } ``` ### 2. 用户登录 -- **URL**: `POST /api/v1/auth/login` -- **认证**: 无需认证 -- **请求参数**: +- `POST /api/v1/auth/login` +- Body: ```json -{ - "username": "testuser", // 用户名或邮箱 - "password": "password123" // 密码 -} -``` -- **响应数据**: -```json -{ - "code": 200, - "message": "登录成功", - "data": { - "token": "jwt_token_here", - "user_info": { - "id": 1, - "username": "testuser", - "email": "test@example.com", - "avatar": "https://example.com/avatar.png", - "points": 100, - "role": "user", - "status": 1, - "last_login_at": "2025-10-01T12:00:00Z", - "created_at": "2025-10-01T10:00:00Z", - "updated_at": "2025-10-01T10:00:00Z" - } - } -} +{ "username": "newuser 或 user@example.com", "password": "password123" } ``` +- 响应 `data`:同注册(`{ token, user_info }`)。 -### 3. 发送验证码 +### 3. 发送邮箱验证码 -- **URL**: `POST /api/v1/auth/send-code` -- **认证**: 无需认证 -- **请求参数**: +- `POST /api/v1/auth/send-code` +- Body: ```json -{ - "email": "user@example.com", // 邮箱地址 - "type": "register" // 类型: register/reset_password/change_email -} -``` -- **响应数据**: -```json -{ - "code": 200, - "message": "验证码已发送,请查收邮件", - "data": { - "message": "验证码已发送,请查收邮件" - } -} +{ "email": "user@example.com", "type": "register | reset_password | change_email" } ``` +- 响应 `data`:`{ "message": "验证码已发送,请查收邮件" }`。 ### 4. 重置密码 -- **URL**: `POST /api/v1/auth/reset-password` -- **认证**: 无需认证 -- **请求参数**: +- `POST /api/v1/auth/reset-password` +- Body: ```json { - "email": "user@example.com", // 邮箱地址 - "verification_code": "123456", // 邮箱验证码 - "new_password": "newpassword123" // 新密码 + "email": "user@example.com", + "verification_code": "123456", + "new_password": "newpassword123" } ``` -- **响应数据**: +- 响应 `data`:`{ "message": "密码重置成功" }`。 + +--- + +## 二、用户 `/user`(需 JWT) + +### 5. 获取当前用户信息 + +- `GET /api/v1/user/profile` +- 响应 `data`:`UserInfo`(同注册返回的 `user_info`)。 + +### 6. 更新用户信息(密码 / 头像 URL) + +- `PUT /api/v1/user/profile` +- Body: ```json { - "code": 200, - "message": "密码重置成功", - "data": { - "message": "密码重置成功" - } + "avatar": "https://example.com/a.png", // 可选,更新头像 URL + "old_password": "oldpassword123", // 修改密码时必填 + "new_password": "newpassword123" // 6–128 +} +``` +- 响应 `data`:更新后的 `UserInfo`。 + +### 7. 上传头像文件 + +- `POST /api/v1/user/avatar/upload`(multipart/form-data) +- 字段:`file`(图片文件,≤10MB) +- 响应 `data`: +```json +{ "avatar_url": "https://.../avatar.png", "user": { ...UserInfo } } +``` + +### 8. 更新头像 URL(外部 URL) + +- `PUT /api/v1/user/avatar?avatar_url=` +- 响应 `data`:`UserInfo`。 + +### 9. 更换邮箱 + +- `POST /api/v1/user/change-email` +- Body: +```json +{ "new_email": "new@example.com", "verification_code": "123456" } +``` +- 响应 `data`:更换后的 `UserInfo`(新邮箱已生效)。 + +### 10. 重置 Yggdrasil 密码 + +- `POST /api/v1/user/yggdrasil-password/reset` +- 响应 `data`:`{ "password": "<新的Yggdrasil密码>" }`。 + +### 11. 获取用户公开信息 + +- `GET /api/v1/users/public?username=` 或 `?id=`(无需 JWT) +- 响应 `data`: +```json +{ + "id": 1, "username": "testuser", "avatar": "", + "points": 100, "role": "user", "status": 1, + "created_at": "2026-07-01T10:00:00Z" } ``` -## 用户相关 API +--- -### 1. 获取用户信息 +## 三、材质 `/texture` -- **URL**: `GET /api/v1/user/profile` -- **认证**: 需要JWT认证 -- **请求参数**: 无 -- **响应数据**: +### 12. 搜索材质(公开,无需 JWT) + +- `GET /api/v1/texture` +- Query:`keyword` / `type=SKIN|CAPE` / `public_only=true` / `page` / `page_size` +- 响应 `data`:分页结构 `{ list: TextureInfo[], total, page, per_page }`。 + +### 13. 获取材质详情(公开) + +- `GET /api/v1/texture/:id` +- 响应 `data`:`TextureInfo`。 + +### 14. 上传材质(需 JWT,multipart) + +- `POST /api/v1/texture/upload` +- formData:`file`(PNG, ≤32MB)、`name`(必填)、`description`、`type=SKIN|CAPE`、`is_public`、`is_slim` +- 响应 `data`:`TextureInfo`。 + +### 15. 更新材质(需 JWT) + +- `PUT /api/v1/texture/:id` +- Body: ```json -{ - "code": 200, - "message": "操作成功", - "data": { - "id": 1, - "username": "testuser", - "email": "test@example.com", - "avatar": "https://example.com/avatar.png", - "points": 100, - "role": "user", - "status": 1, - "last_login_at": "2025-10-01T12:00:00Z", - "created_at": "2025-10-01T10:00:00Z", - "updated_at": "2025-10-01T10:00:00Z" - } -} +{ "name": "新名称", "description": "新描述", "is_public": true } ``` + - `is_public` 后端为 `*bool`,传 `true/false` 会更新;不传保持不变。 +- 响应 `data`:`TextureInfo`。 -### 2. 更新用户信息 +### 16. 删除材质(需 JWT) -- **URL**: `PUT /api/v1/user/profile` -- **认证**: 需要JWT认证 -- **请求参数**: +- `DELETE /api/v1/texture/:id` +- 响应 `data`:`null`。 + +### 17. 切换收藏(需 JWT) + +- `POST /api/v1/texture/:id/favorite` +- 响应 `data`:`{ "is_favorited": true }`。 + +### 18. 我的上传(需 JWT) + +- `GET /api/v1/texture/my?page=1&page_size=20` +- 响应 `data`:分页结构。 + +### 19. 我的收藏(需 JWT) + +- `GET /api/v1/texture/favorites?page=1&page_size=20` +- 响应 `data`:分页结构。 + +#### TextureInfo 字段 +`id, uploader_id, uploader_username, name, description(omitempty), type(SKIN|CAPE), url, hash, size, is_public, download_count, favorite_count, is_slim, status, created_at, updated_at` + +--- + +## 四、档案 `/profile` + +> ⚠️ **后端当前 `PUT /profile/:uuid` 不支持解除皮肤/披风绑定**: +> `UpdateProfileRequest.SkinID / CapeID` 为 `*int64`,仅当非 nil 时才更新; +> 传 `null` 与字段缺失都会被 Go 反序列化为 `nil`,二者无法区分,均被忽略。 +> 因此前端要"移除角色皮肤",需要后端先支持(详见下方《待补齐》)。 + +### 20. 获取档案列表(需 JWT) + +- `GET /api/v1/profile` 或 `GET /api/v1/profile/` +- 响应 `data`:`ProfileInfo[]`。 + +### 21. 创建档案(需 JWT) + +- `POST /api/v1/profile` 或 `POST /api/v1/profile/` +- Body:`{ "name": "PlayerName" }`(1–16 字符) +- 响应 `data`:`ProfileInfo`。 + +### 22. 获取档案详情(公开) + +- `GET /api/v1/profile/:uuid` +- 响应 `data`:`ProfileInfo`。 + +### 23. 更新档案(需 JWT) + +- `PUT /api/v1/profile/:uuid` +- Body: ```json -{ - "avatar": "https://example.com/new-avatar.png", // 可选,新头像URL - "old_password": "oldpassword123", // 可选,修改密码时需要 - "new_password": "newpassword123" // 可选,新密码 -} -``` -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": { - "id": 1, - "username": "testuser", - "email": "test@example.com", - "avatar": "https://example.com/new-avatar.png", - "points": 100, - "role": "user", - "status": 1, - "last_login_at": "2025-10-01T12:00:00Z", - "created_at": "2025-10-01T10:00:00Z", - "updated_at": "2025-10-01T10:00:00Z" - } -} +{ "name": "NewName", "skin_id": 1, "cape_id": 2 } ``` + - 当前实现:`skin_id/cape_id` 传数字会设置;传 null / 缺失均被忽略(无法清空)。 +- 响应 `data`:`ProfileInfo`。 -### 3. 生成头像上传URL +### 24. 删除档案(需 JWT) -- **URL**: `POST /api/v1/user/avatar/upload-url` -- **认证**: 需要JWT认证 -- **请求参数**: -```json -{ - "file_name": "avatar.png" // 文件名 -} -``` -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": { - "post_url": "https://rustfs.example.com/avatars", - "form_data": { - "key": "user_1/xxx.png", - "policy": "base64_policy", - "x-amz-signature": "signature" - }, - "avatar_url": "https://rustfs.example.com/avatars/user_1/xxx.png", - "expires_in": 900 - } -} -``` +- `DELETE /api/v1/profile/:uuid` +- 响应 `data`:`{ "message": "删除成功" }`。 -### 4. 更新头像URL +#### ProfileInfo 字段 +`uuid, user_id, name, skin_id(omitempty,可空), cape_id(omitempty,可空), last_used_at(omitempty), created_at, updated_at` -- **URL**: `PUT /api/v1/user/avatar` -- **认证**: 需要JWT认证 -- **请求参数**: - - Query参数: `avatar_url` - 头像URL -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": { - "id": 1, - "username": "testuser", - "email": "test@example.com", - "avatar": "https://example.com/new-avatar.png", - "points": 100, - "role": "user", - "status": 1, - "last_login_at": "2025-10-01T12:00:00Z", - "created_at": "2025-10-01T10:00:00Z", - "updated_at": "2025-10-01T10:00:00Z" - } -} -``` +--- -### 5. 更换邮箱 +## 五、滑动验证码 `/captcha`(无需 JWT) -- **URL**: `POST /api/v1/user/change-email` -- **认证**: 需要JWT认证 -- **请求参数**: -```json -{ - "new_email": "newemail@example.com", // 新邮箱地址 - "verification_code": "123456" // 邮箱验证码 -} -``` -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": { - "id": 1, - "username": "testuser", - "email": "newemail@example.com", - "avatar": "https://example.com/avatar.png", - "points": 100, - "role": "user", - "status": 1, - "last_login_at": "2025-10-01T12:00:00Z", - "created_at": "2025-10-01T10:00:00Z", - "updated_at": "2025-10-01T10:00:00Z" - } -} -``` +### 25. 生成滑动验证码 -### 6. 重置Yggdrasil密码 - -- **URL**: `POST /api/v1/user/yggdrasil-password/reset` -- **认证**: 需要JWT认证 -- **请求参数**: 无 -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": { - "password": "new_yggdrasil_password" - } -} -``` - -## 材质相关 API - -### 1. 搜索材质 - -- **URL**: `GET /api/v1/texture` -- **认证**: 无需认证 -- **请求参数**: - - Query参数: - - `keyword`: 搜索关键词 - - `type`: 材质类型 (SKIN/CAPE) - - `public_only`: 是否只搜索公开材质 (true/false) - - `page`: 页码,默认1 - - `page_size`: 每页数量,默认20 -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": { - "list": [ - { - "id": 1, - "uploader_id": 1, - "name": "My Skin", - "description": "A cool skin", - "type": "SKIN", - "url": "https://rustfs.example.com/textures/xxx.png", - "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "size": 2048, - "is_public": true, - "download_count": 100, - "favorite_count": 50, - "is_slim": false, - "status": 1, - "created_at": "2025-10-01T10:00:00Z", - "updated_at": "2025-10-01T10:00:00Z" - } - ], - "total": 100, - "page": 1, - "page_size": 20, - "total_pages": 5 - } -} -``` - -### 2. 获取材质详情 - -- **URL**: `GET /api/v1/texture/{id}` -- **认证**: 无需认证 -- **请求参数**: - - 路径参数: `id` - 材质ID -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": { - "id": 1, - "uploader_id": 1, - "name": "My Skin", - "description": "A cool skin", - "type": "SKIN", - "url": "https://rustfs.example.com/textures/xxx.png", - "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "size": 2048, - "is_public": true, - "download_count": 100, - "favorite_count": 50, - "is_slim": false, - "status": 1, - "created_at": "2025-10-01T10:00:00Z", - "updated_at": "2025-10-01T10:00:00Z" - } -} -``` - -### 3. 直接上传材质文件(推荐) - -- **URL**: `POST /api/v1/texture/upload` -- **认证**: 需要JWT认证 -- **Content-Type**: `multipart/form-data` -- **请求参数**: - - `file`: 材质文件(PNG格式,1KB-10MB) - - `name`: 材质名称(必填,1-100字符) - - `description`: 材质描述(可选,最多500字符) - - `type`: 材质类型(可选,默认SKIN,可选值:SKIN/CAPE) - - `is_public`: 是否公开(可选,默认false,true/false) - - `is_slim`: 是否为细臂模型(可选,默认false,true/false) -- **说明**: - - 后端会自动计算文件的SHA256哈希值 - - 如果已存在相同哈希的材质,会复用已存在的文件URL,不重复上传 - - 允许多次上传相同哈希的材质(包括同一用户),每次都会创建新的数据库记录 - - 文件存储路径格式:`{type}/{hash[:2]}/{hash[2:4]}/{hash}.png` -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": { - "id": 1, - "uploader_id": 1, - "name": "My Cool Skin", - "description": "A very cool skin", - "type": "SKIN", - "url": "https://rustfs.example.com/textures/skin/e3/b0/e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855.png", - "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "size": 2048, - "is_public": true, - "download_count": 0, - "favorite_count": 0, - "is_slim": false, - "status": 1, - "created_at": "2025-10-01T10:00:00Z", - "updated_at": "2025-10-01T10:00:00Z" - } -} -``` - -### 4. 生成材质上传URL(兼容接口) - -- **URL**: `POST /api/v1/texture/upload-url` -- **认证**: 需要JWT认证 -- **请求参数**: -```json -{ - "file_name": "skin.png", // 文件名 - "texture_type": "SKIN" // 材质类型: SKIN/CAPE -} -``` -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": { - "post_url": "https://rustfs.example.com/textures", - "form_data": { - "key": "user_1/skin/xxx.png", - "policy": "base64_policy", - "x-amz-signature": "signature" - }, - "texture_url": "https://rustfs.example.com/textures/user_1/skin/xxx.png", - "expires_in": 900 - } -} -``` - -### 5. 创建材质记录(配合预签名URL使用) - -- **URL**: `POST /api/v1/texture` -- **认证**: 需要JWT认证 -- **请求参数**: -```json -{ - "name": "My Cool Skin", // 材质名称,1-100字符 - "description": "A very cool skin", // 描述,最多500字符 - "type": "SKIN", // 材质类型: SKIN/CAPE - "url": "https://rustfs.example.com/textures/user_1/skin/xxx.png", // 材质URL - "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", // SHA256哈希 - "size": 2048, // 文件大小(字节) - "is_public": true, // 是否公开 - "is_slim": false // 是否为细臂模型(Alex) -} -``` -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": { - "id": 1, - "uploader_id": 1, - "name": "My Cool Skin", - "description": "A very cool skin", - "type": "SKIN", - "url": "https://rustfs.example.com/textures/user_1/skin/xxx.png", - "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "size": 2048, - "is_public": true, - "download_count": 0, - "favorite_count": 0, - "is_slim": false, - "status": 1, - "created_at": "2025-10-01T10:00:00Z", - "updated_at": "2025-10-01T10:00:00Z" - } -} -``` - -### 6. 更新材质 - -- **URL**: `PUT /api/v1/texture/{id}` -- **认证**: 需要JWT认证 -- **请求参数**: - - 路径参数: `id` - 材质ID - - 请求体: -```json -{ - "name": "Updated Skin Name", // 可选,新名称 - "description": "Updated description", // 可选,新描述 - "is_public": false // 可选,是否公开 -} -``` -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": { - "id": 1, - "uploader_id": 1, - "name": "Updated Skin Name", - "description": "Updated description", - "type": "SKIN", - "url": "https://rustfs.example.com/textures/user_1/skin/xxx.png", - "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "size": 2048, - "is_public": false, - "download_count": 100, - "favorite_count": 50, - "is_slim": false, - "status": 1, - "created_at": "2025-10-01T10:00:00Z", - "updated_at": "2025-10-01T10:00:00Z" - } -} -``` - -### 7. 删除材质 - -- **URL**: `DELETE /api/v1/texture/{id}` -- **认证**: 需要JWT认证 -- **请求参数**: - - 路径参数: `id` - 材质ID -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": null -} -``` - -### 8. 切换收藏状态 - -- **URL**: `POST /api/v1/texture/{id}/favorite` -- **认证**: 需要JWT认证 -- **请求参数**: - - 路径参数: `id` - 材质ID -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": { - "is_favorited": true - } -} -``` - -### 9. 获取用户上传的材质列表 - -- **URL**: `GET /api/v1/texture/my` -- **认证**: 需要JWT认证 -- **请求参数**: - - Query参数: - - `page`: 页码,默认1 - - `page_size`: 每页数量,默认20 -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": { - "list": [ - { - "id": 1, - "uploader_id": 1, - "name": "My Skin", - "description": "A cool skin", - "type": "SKIN", - "url": "https://rustfs.example.com/textures/xxx.png", - "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "size": 2048, - "is_public": true, - "download_count": 100, - "favorite_count": 50, - "is_slim": false, - "status": 1, - "created_at": "2025-10-01T10:00:00Z", - "updated_at": "2025-10-01T10:00:00Z" - } - ], - "total": 50, - "page": 1, - "page_size": 20, - "total_pages": 3 - } -} -``` - -### 10. 获取用户收藏的材质列表 - -- **URL**: `GET /api/v1/texture/favorites` -- **认证**: 需要JWT认证 -- **请求参数**: - - Query参数: - - `page`: 页码,默认1 - - `page_size`: 每页数量,默认20 -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": { - "list": [ - { - "id": 1, - "uploader_id": 2, - "name": "Cool Skin", - "description": "A very cool skin", - "type": "SKIN", - "url": "https://rustfs.example.com/textures/xxx.png", - "hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", - "size": 2048, - "is_public": true, - "download_count": 100, - "favorite_count": 50, - "is_slim": false, - "status": 1, - "created_at": "2025-10-01T10:00:00Z", - "updated_at": "2025-10-01T10:00:00Z" - } - ], - "total": 30, - "page": 1, - "page_size": 20, - "total_pages": 2 - } -} -``` - -## 档案相关 API - -### 1. 创建档案 - -- **URL**: `POST /api/v1/profile` -- **认证**: 需要JWT认证 -- **请求参数**: -```json -{ - "name": "PlayerName" // 角色名,1-16字符 -} -``` -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": { - "uuid": "550e8400-e29b-41d4-a716-446655440000", - "user_id": 1, - "name": "PlayerName", - "skin_id": null, - "cape_id": null, - "is_active": false, - "last_used_at": null, - "created_at": "2025-10-01T10:00:00Z", - "updated_at": "2025-10-01T10:00:00Z" - } -} -``` - -### 2. 获取档案列表 - -- **URL**: `GET /api/v1/profile` -- **认证**: 需要JWT认证 -- **请求参数**: 无 -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": [ - { - "uuid": "550e8400-e29b-41d4-a716-446655440000", - "user_id": 1, - "name": "PlayerName", - "skin_id": 1, - "cape_id": 2, - "is_active": true, - "last_used_at": "2025-10-01T12:00:00Z", - "created_at": "2025-10-01T10:00:00Z", - "updated_at": "2025-10-01T10:00:00Z" - } - ] -} -``` - -### 3. 获取档案详情 - -- **URL**: `GET /api/v1/profile/{uuid}` -- **认证**: 无需认证 -- **请求参数**: - - 路径参数: `uuid` - 档案UUID -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": { - "uuid": "550e8400-e29b-41d4-a716-446655440000", - "user_id": 1, - "name": "PlayerName", - "skin_id": 1, - "cape_id": 2, - "is_active": true, - "last_used_at": "2025-10-01T12:00:00Z", - "created_at": "2025-10-01T10:00:00Z", - "updated_at": "2025-10-01T10:00:00Z" - } -} -``` - -### 4. 更新档案 - -- **URL**: `PUT /api/v1/profile/{uuid}` -- **认证**: 需要JWT认证 -- **请求参数**: - - 路径参数: `uuid` - 档案UUID - - 请求体: -```json -{ - "name": "NewPlayerName", // 可选,新角色名 - "skin_id": 1, // 可选,皮肤ID - "cape_id": 2 // 可选,披风ID -} -``` -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": { - "uuid": "550e8400-e29b-41d4-a716-446655440000", - "user_id": 1, - "name": "NewPlayerName", - "skin_id": 1, - "cape_id": 2, - "is_active": true, - "last_used_at": "2025-10-01T12:00:00Z", - "created_at": "2025-10-01T10:00:00Z", - "updated_at": "2025-10-01T10:00:00Z" - } -} -``` - -### 5. 删除档案 - -- **URL**: `DELETE /api/v1/profile/{uuid}` -- **认证**: 需要JWT认证 -- **请求参数**: - - 路径参数: `uuid` - 档案UUID -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": { - "message": "删除成功" - } -} -``` - -### 6. 设置活跃档案 - -- **URL**: `POST /api/v1/profile/{uuid}/activate` -- **认证**: 需要JWT认证 -- **请求参数**: - - 路径参数: `uuid` - 档案UUID -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": { - "message": "设置成功" - } -} -``` - -## 验证码相关 API - -### 1. 生成验证码 - -- **URL**: `GET /api/v1/captcha/generate` -- **认证**: 无需认证 -- **请求参数**: 无 -- **响应数据**: +- `GET /api/v1/captcha/generate` +- 响应(注意:此接口的响应是直接对象,未包裹 `data`): ```json { "code": 200, "data": { - "masterImage": "base64_encoded_master_image", - "tileImage": "base64_encoded_tile_image", - "captchaId": "captcha_id_here", - "y": 100 + "masterImage": "", + "tileImage": "", + "captchaId": "<进程ID,注册时需要>", + "y": <缺口纵坐标> } } ``` -### 2. 验证验证码 +### 26. 校验滑动验证码 -- **URL**: `POST /api/v1/captcha/verify` -- **认证**: 无需认证 -- **请求参数**: -```json -{ - "captchaId": "captcha_id_here", - "dx": 150 // 滑动距离 -} -``` -- **响应数据**: -```json -{ - "code": 200, - "msg": "验证成功" -} -``` +- `POST /api/v1/captcha/verify` +- Body:`{ "captchaId": "", "dx": <横坐标> }` +- 响应:`{ "code": 200, "msg": "验证成功" }` 或 `{ "code": 400, "msg": "验证失败,请重试" }` -## 系统相关 API +--- -### 1. 获取系统配置 +## 六、Yggdrasil API `/api/yggdrasil`(独立前缀,供 MC 客户端使用) -- **URL**: `GET /api/v1/system/config` -- **认证**: 无需认证 -- **请求参数**: 无 -- **响应数据**: -```json -{ - "code": 200, - "message": "操作成功", - "data": { - "site_name": "CarrotSkin", - "site_description": "A Minecraft Skin Station", - "registration_enabled": true, - "max_textures_per_user": 100, - "max_profiles_per_user": 5 - } -} -``` +- `GET /api/yggdrasil` —— 元数据 +- `POST /api/yggdrasil/minecraftservices/player/certificates` +- `POST /api/yggdrasil/authserver/authenticate` +- `POST /api/yggdrasil/authserver/validate` +- `POST /api/yggdrasil/authserver/refresh` +- `POST /api/yggdrasil/authserver/invalidate` +- `POST /api/yggdrasil/authserver/signout` +- `GET /api/yggdrasil/sessionserver/session/minecraft/profile/:uuid` +- `POST /api/yggdrasil/sessionserver/session/minecraft/join` +- `GET /api/yggdrasil/sessionserver/session/minecraft/hasJoined` +- `POST /api/yggdrasil/api/profiles/minecraft` -## CustomSkin API +## 七、CustomSkinAPI `/api/v1/csl`(公开) -### 1. 获取玩家信息 +- `GET /api/v1/csl/:username` —— 获取玩家信息 JSON +- `GET /api/v1/csl/textures/:hash` —— 获取资源文件 -- **URL**: `GET /api/v1/csl/{username}` -- **认证**: 无需认证 -- **请求参数**: - - 路径参数: `username` - 玩家用户名 -- **响应数据**: -```json -{ - "username": "PlayerName", - "textures": { - "default": "skin_hash_here", - "slim": "skin_hash_here", - "cape": "cape_hash_here", - "elytra": "cape_hash_here" - } -} -``` -或简化格式: -```json -{ - "username": "PlayerName", - "skin": "skin_hash_here" -} -``` +## 八、管理员 `/api/v1/admin`(需 JWT + 管理员) -### 2. 获取资源文件 +- `GET /admin/users`、`GET /admin/users/:id` +- `PUT /admin/users/role`、`PUT /admin/users/status` +- `GET /admin/textures`、`DELETE /admin/textures/:id` +- `GET /admin/permissions` -- **URL**: `GET /api/v1/csl/textures/{hash}` -- **认证**: 无需认证 -- **请求参数**: - - 路径参数: `hash` - 资源哈希值 -- **响应数据**: 二进制文件内容 +--- -## 健康检查 +## 待补齐(后端缺陷,影响前端可用性) -### 1. 健康检查 - -- **URL**: `GET /health` -- **认证**: 无需认证 -- **请求参数**: 无 -- **响应数据**: -```json -{ - "status": "ok" -} -``` - -## 错误码说明 - -| 错误码 | 说明 | -|--------|------| -| 200 | 操作成功 | -| 400 | 请求参数错误 | -| 401 | 未认证或认证失败 | -| 403 | 无权限操作 | -| 404 | 资源不存在 | -| 500 | 服务器内部错误 | - -## 认证说明 - -需要JWT认证的API需要在请求头中添加: - -``` -Authorization: Bearer {jwt_token} -``` - -JWT Token在用户登录或注册成功后返回,有效期内可用于访问需要认证的API。 +1. **`PUT /profile/:uuid` 不支持清空 skin/cape** + 现状:`SkinID/CapeID *int64` 无法区分"未传"与"显式 null"。 + 建议:改用 `json.RawMessage` 区分三种语义(缺失=不修改 / null=清空 / 数字=设置), + 或新增 `remove_skin` / `remove_cape` 布尔字段。前端"移除角色皮肤"依赖此能力。 diff --git a/src/app/auth/page.tsx b/src/app/auth/page.tsx index 3667d22..f1fc428 100644 --- a/src/app/auth/page.tsx +++ b/src/app/auth/page.tsx @@ -2,12 +2,20 @@ import { useState, useEffect } from 'react'; import Link from 'next/link'; -import { useRouter } from 'next/navigation'; +import { useRouter, useSearchParams } from 'next/navigation'; import { motion, AnimatePresence } from 'framer-motion'; import { EyeIcon, EyeSlashIcon, CheckCircleIcon, XCircleIcon } from '@heroicons/react/24/outline'; import { useAuth } from '@/contexts/AuthContext'; import { errorManager } from '@/components/ErrorNotification'; import SliderCaptcha from '@/components/SliderCaptcha'; +import { XMarkIcon } from '@heroicons/react/24/outline'; +import { sendVerificationCode, resetPassword } from '@/lib/api'; + +// 邮箱格式校验:local@domain.tld +// - local 段:字母/数字/._%+-,首尾不能是点,不能连续点 +// - domain 段:字母/数字/-,每段 1-63 字符,至少一个点 +// - TLD:至少 2 位字母 +const EMAIL_REGEX = /^(?!.*\.\.)[a-zA-Z0-9._%+-]+(?(); - + + // 忘记密码弹窗相关状态 + const [showForgotPassword, setShowForgotPassword] = useState(false); + const [forgotForm, setForgotForm] = useState({ email: '', code: '', newPassword: '' }); + const [isSendingForgotCode, setIsSendingForgotCode] = useState(false); + const [forgotCodeTimer, setForgotCodeTimer] = useState(0); + const [isResettingPassword, setIsResettingPassword] = useState(false); + const { login, register } = useAuth(); const router = useRouter(); + const searchParams = useSearchParams(); + + // 支持 ?mode=register 直接进入注册模式(来自 /register、/signup 等重定向) + useEffect(() => { + const mode = searchParams.get('mode'); + if (mode === 'register') { + setIsLoginMode(false); + } else if (mode === 'login') { + setIsLoginMode(true); + } + }, [searchParams]); useEffect(() => { let interval: NodeJS.Timeout; @@ -88,7 +114,7 @@ export default function AuthPage() { if (!formData.email.trim()) { newErrors.email = '邮箱不能为空'; - } else if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + } else if (!EMAIL_REGEX.test(formData.email)) { newErrors.email = '请输入有效的邮箱地址'; } @@ -130,26 +156,15 @@ export default function AuthPage() { const passwordStrength = getPasswordStrength(); const handleSendCode = async () => { - if (!formData.email || !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(formData.email)) { + if (!formData.email || !EMAIL_REGEX.test(formData.email)) { setErrors(prev => ({ ...prev, email: '请输入有效的邮箱地址' })); return; } setIsSendingCode(true); try { - const response = await fetch('/api/v1/auth/send-code', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - }, - body: JSON.stringify({ - email: formData.email, - type: 'register' - }), - }); + const data = await sendVerificationCode(formData.email, 'register'); - const data = await response.json(); - if (data.code === 200) { setCodeTimer(60); errorManager.showSuccess('验证码已发送到您的邮箱'); @@ -165,9 +180,10 @@ export default function AuthPage() { } }; - const handleCaptchaVerify = (success: boolean) => { + const handleCaptchaVerify = (success: boolean, verifiedCaptchaId?: string) => { if (success) { setIsCaptchaVerified(true); + setCaptchaId(verifiedCaptchaId); setShowCaptcha(false); // 验证码验证成功后,继续注册流程 handleRegisterAfterCaptcha(); @@ -255,6 +271,80 @@ export default function AuthPage() { }); }; + const openForgotPassword = () => { + setForgotForm({ email: '', code: '', newPassword: '' }); + setForgotCodeTimer(0); + setShowForgotPassword(true); + }; + + const closeForgotPassword = () => { + if (isResettingPassword) return; + setShowForgotPassword(false); + }; + + const handleSendForgotCode = async () => { + const email = forgotForm.email.trim(); + if (!email || !EMAIL_REGEX.test(email)) { + errorManager.showError('请输入有效的邮箱地址'); + return; + } + setIsSendingForgotCode(true); + try { + const resp = await sendVerificationCode(email, 'reset_password'); + if (resp.code === 200) { + errorManager.showSuccess('验证码已发送到您的邮箱'); + setForgotCodeTimer(60); + const timer = setInterval(() => { + setForgotCodeTimer(prev => { + if (prev <= 1) { + clearInterval(timer); + return 0; + } + return prev - 1; + }); + }, 1000); + } else { + errorManager.showError(resp.message || '发送验证码失败'); + } + } catch (err) { + console.error('发送验证码失败:', err); + errorManager.showError('发送验证码失败,请稍后重试'); + } finally { + setIsSendingForgotCode(false); + } + }; + + const handleResetPassword = async () => { + const { email, code, newPassword } = forgotForm; + if (!email.trim() || !EMAIL_REGEX.test(email.trim())) { + errorManager.showError('请输入有效的邮箱地址'); + return; + } + if (!/^\d{6}$/.test(code.trim())) { + errorManager.showError('请输入6位数字验证码'); + return; + } + if (newPassword.length < 6 || newPassword.length > 128) { + errorManager.showError('新密码长度需在6-128位之间'); + return; + } + setIsResettingPassword(true); + try { + const resp = await resetPassword(email.trim(), code.trim(), newPassword); + if (resp.code === 200) { + errorManager.showSuccess('密码重置成功,请使用新密码登录'); + setShowForgotPassword(false); + } else { + errorManager.showError(resp.message || '重置密码失败'); + } + } catch (err) { + console.error('重置密码失败:', err); + errorManager.showError('重置密码失败,请稍后重试'); + } finally { + setIsResettingPassword(false); + } + }; + return (
{/* Left Side - Orange Section */} @@ -619,9 +709,13 @@ export default function AuthPage() { 记住我 - + )} @@ -646,13 +740,21 @@ export default function AuthPage() { /> 我已阅读并同意 - + 和 - + {errors.agreeToTerms && ( @@ -766,6 +868,94 @@ export default function AuthPage() { /> )} + {/* Forgot Password Modal */} + {showForgotPassword && ( +
+ e.stopPropagation()} + > +
+

找回密码

+ +
+ +
+
+ + setForgotForm(prev => ({ ...prev, email: e.target.value }))} + placeholder="请输入注册时使用的邮箱" + disabled={isResettingPassword} + className="w-full px-4 py-3 bg-white/50 dark:bg-gray-700/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all duration-200 disabled:opacity-50" + /> +
+
+ +
+ setForgotForm(prev => ({ ...prev, code: e.target.value.replace(/\D/g, '') }))} + placeholder="6位数字验证码" + disabled={isResettingPassword} + className="flex-1 px-4 py-3 bg-white/50 dark:bg-gray-700/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all duration-200 disabled:opacity-50" + /> + +
+
+
+ + setForgotForm(prev => ({ ...prev, newPassword: e.target.value }))} + placeholder="6-128位新密码" + disabled={isResettingPassword} + className="w-full px-4 py-3 bg-white/50 dark:bg-gray-700/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all duration-200 disabled:opacity-50" + /> +
+ +
+
+
+ )} +
); } \ No newline at end of file diff --git a/src/app/login/page.tsx b/src/app/login/page.tsx new file mode 100644 index 0000000..a5eb747 --- /dev/null +++ b/src/app/login/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from 'next/navigation'; + +// /login 兼容入口:重定向到统一认证页并默认进入登录模式 +export default function LoginPage() { + redirect('/auth?mode=login'); +} diff --git a/src/app/page.tsx b/src/app/page.tsx index 7579db7..72fc120 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -9,7 +9,6 @@ import { CloudArrowUpIcon, ShareIcon, CubeIcon, - UserGroupIcon, SparklesIcon, RocketLaunchIcon } from '@heroicons/react/24/outline'; @@ -282,23 +281,15 @@ export default function Home() {

加入CarrotSkin,体验新一代Minecraft皮肤管理平台,让你的创意无限绽放

- +
- 免费注册 - - - 查看API文档 - -
diff --git a/src/app/profile/page.tsx b/src/app/profile/page.tsx index e756a94..de0c4a7 100644 --- a/src/app/profile/page.tsx +++ b/src/app/profile/page.tsx @@ -22,23 +22,24 @@ import { ArrowDownTrayIcon, ArrowLeftOnRectangleIcon } from '@heroicons/react/24/outline'; -import { - getMyTextures, - getFavoriteTextures, - toggleFavorite, +import { + getMyTextures, + getFavoriteTextures, + toggleFavorite, getProfiles, createProfile, updateProfile, deleteProfile, - setActiveProfile, getUserProfile, updateUserProfile, uploadTexture, getTexture, - generateAvatarUploadUrl, - updateAvatarUrl, + uploadAvatar, resetYggdrasilPassword, deleteTexture, + updateTexture, + sendVerificationCode, + changeEmail, type Texture, type Profile } from '@/lib/api'; @@ -97,8 +98,15 @@ export default function ProfilePage() { const [yggdrasilPassword, setYggdrasilPassword] = useState(''); const [showYggdrasilPassword, setShowYggdrasilPassword] = useState(false); const [isResettingYggdrasilPassword, setIsResettingYggdrasilPassword] = useState(false); - - const { user, isAuthenticated, logout } = useAuth(); + + // 更换邮箱流程相关状态 + const [showChangeEmailModal, setShowChangeEmailModal] = useState(false); + const [changeEmailForm, setChangeEmailForm] = useState({ newEmail: '', code: '' }); + const [isSendingEmailCode, setIsSendingEmailCode] = useState(false); + const [emailCodeCountdown, setEmailCodeCountdown] = useState(0); + const [isChangingEmail, setIsChangingEmail] = useState(false); + + const { user, isAuthenticated, logout, updateUser } = useAuth(); // 加载用户数据 useEffect(() => { @@ -179,16 +187,36 @@ export default function ProfilePage() { }; const handleToggleSkinVisibility = async (skinId: number) => { + const skin = mySkins.find(s => s.id === skinId); + if (!skin) return; + + const newIsPublic = !skin.is_public; + // 先乐观更新本地态,失败再回滚 + setMySkins(prev => prev.map(s => + s.id === skinId ? { ...s, is_public: newIsPublic } : s + )); + try { - const skin = mySkins.find(s => s.id === skinId); - if (!skin) return; - - // TODO: 添加更新皮肤API调用 - setMySkins(prev => prev.map(skin => - skin.id === skinId ? { ...skin, is_public: !skin.is_public } : skin - )); + const response = await updateTexture(skinId, { is_public: newIsPublic }); + if (response.code === 200) { + // 以后端返回为准 + setMySkins(prev => prev.map(s => + s.id === skinId ? { ...s, is_public: response.data.is_public } : s + )); + messageManager.success(newIsPublic ? '已设为公开' : '已设为隐藏', { duration: 2000 }); + } else { + // 回滚 + setMySkins(prev => prev.map(s => + s.id === skinId ? { ...s, is_public: skin.is_public } : s + )); + messageManager.error(response.message || '切换可见性失败', { duration: 3000 }); + } } catch (error) { console.error('切换皮肤可见性失败:', error); + setMySkins(prev => prev.map(s => + s.id === skinId ? { ...s, is_public: skin.is_public } : s + )); + messageManager.error('切换可见性失败,请稍后重试', { duration: 3000 }); } }; @@ -308,52 +336,31 @@ export default function ProfilePage() { const handleUploadAvatar = async () => { if (!avatarFile) return; - + setIsUploadingAvatar(true); setAvatarUploadProgress(0); - + try { - // 获取上传URL - const uploadUrlResponse = await generateAvatarUploadUrl(avatarFile.name); - if (uploadUrlResponse.code !== 200) { - throw new Error(uploadUrlResponse.message || '获取上传URL失败'); - } - - const { post_url, form_data, avatar_url } = uploadUrlResponse.data; - // 模拟上传进度 const progressInterval = setInterval(() => { setAvatarUploadProgress(prev => Math.min(prev + 20, 80)); }, 200); - - // 上传文件到预签名URL - const formData = new FormData(); - Object.entries(form_data).forEach(([key, value]) => { - formData.append(key, value as string); - }); - formData.append('file', avatarFile); - - const uploadResponse = await fetch(post_url, { - method: 'POST', - body: formData, - }); - - if (!uploadResponse.ok) { - throw new Error('文件上传失败'); - } - + + // 直接上传文件到后端,后端写入对象存储并更新用户头像 + const response = await uploadAvatar(avatarFile); + clearInterval(progressInterval); setAvatarUploadProgress(100); - - // 更新用户头像URL - const response = await updateAvatarUrl(avatar_url); + if (response.code === 200) { - setUserProfile(prev => prev ? { ...prev, avatar: avatar_url } : null); + const avatarUrl = response.data.avatar_url; + setUserProfile(prev => prev ? { ...prev, avatar: avatarUrl } : null); + updateUser({ avatar: avatarUrl }); messageManager.success('头像上传成功!', { duration: 3000 }); } else { - throw new Error(response.message || '更新头像URL失败'); + throw new Error(response.message || '头像上传失败'); } - + } catch (error) { console.error('头像上传失败:', error); messageManager.error(error instanceof Error ? error.message : '头像上传失败,请稍后重试', { duration: 3000 }); @@ -405,6 +412,86 @@ export default function ProfilePage() { } }; + // 发送更换邮箱的验证码(type=change_email) + const handleSendChangeEmailCode = async () => { + const email = changeEmailForm.newEmail.trim(); + const EMAIL_REGEX = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + if (!email) { + messageManager.warning('请输入新邮箱地址', { duration: 3000 }); + return; + } + if (!EMAIL_REGEX.test(email)) { + messageManager.warning('请输入有效的邮箱地址', { duration: 3000 }); + return; + } + if (email === userProfile?.email) { + messageManager.warning('新邮箱不能与当前邮箱相同', { duration: 3000 }); + return; + } + + setIsSendingEmailCode(true); + try { + const resp = await sendVerificationCode(email, 'change_email'); + if (resp.code === 200) { + messageManager.success('验证码已发送到新邮箱', { duration: 3000 }); + setEmailCodeCountdown(60); + const timer = setInterval(() => { + setEmailCodeCountdown(prev => { + if (prev <= 1) { + clearInterval(timer); + return 0; + } + return prev - 1; + }); + }, 1000); + } else { + messageManager.error(resp.message || '发送验证码失败', { duration: 3000 }); + } + } catch (err) { + console.error('发送验证码失败:', err); + messageManager.error('发送验证码失败,请稍后重试', { duration: 3000 }); + } finally { + setIsSendingEmailCode(false); + } + }; + + // 提交更换邮箱 + const handleChangeEmail = async () => { + const newEmail = changeEmailForm.newEmail.trim(); + const code = changeEmailForm.code.trim(); + if (!newEmail) { + messageManager.warning('请输入新邮箱地址', { duration: 3000 }); + return; + } + if (!/^\d{6}$/.test(code)) { + messageManager.warning('请输入6位数字验证码', { duration: 3000 }); + return; + } + + setIsChangingEmail(true); + try { + const resp = await changeEmail(newEmail, code); + if (resp.code === 200) { + messageManager.success('邮箱更换成功', { duration: 3000 }); + // 同步本地用户信息(email 已变化) + setUserProfile(prev => prev ? { ...prev, email: resp.data.email } : prev); + if (updateUser) { + updateUser({ email: resp.data.email }); + } + setShowChangeEmailModal(false); + setChangeEmailForm({ newEmail: '', code: '' }); + setEmailCodeCountdown(0); + } else { + messageManager.error(resp.message || '更换邮箱失败', { duration: 3000 }); + } + } catch (err) { + console.error('更换邮箱失败:', err); + messageManager.error('更换邮箱失败,请稍后重试', { duration: 3000 }); + } finally { + setIsChangingEmail(false); + } + }; + const handleCreateCharacter = async () => { if (!newCharacterName.trim()) { messageManager.warning('请输入角色名称', { duration: 3000 }); @@ -448,25 +535,6 @@ export default function ProfilePage() { } }; - const handleSetActiveCharacter = async (uuid: string) => { - try { - const response = await setActiveProfile(uuid); - if (response.code === 200) { - setProfiles(prev => prev.map(profile => ({ - ...profile, - is_active: profile.uuid === uuid - }))); - messageManager.success('角色切换成功!', { duration: 3000 }); - } else { - throw new Error(response.message || '设置活跃角色失败'); - } - } catch (error) { - console.error('设置活跃角色失败:', error); - messageManager.error(error instanceof Error ? error.message : '设置活跃角色失败,请稍后重试', { duration: 3000 }); - } finally { - } - }; - const handleEditCharacter = async () => { if (!editProfileName.trim()) { messageManager.warning('请输入角色名称', { duration: 3000 }); @@ -604,7 +672,6 @@ export default function ProfilePage() { onSave={handleEditCharacter} onCancel={onCancelEdit} onDelete={handleDeleteCharacter} - onSetActive={handleSetActiveCharacter} onSelectSkin={setShowSkinSelector} onEditNameChange={setEditProfileName} /> @@ -847,10 +914,15 @@ export default function ProfilePage() { 账户操作
- { + setChangeEmailForm({ newEmail: '', code: '' }); + setEmailCodeCountdown(0); + setShowChangeEmailModal(true); + }} > 更换邮箱地址 @@ -927,19 +999,35 @@ export default function ProfilePage() { { - // 移除皮肤 - if (currentProfile) { - updateProfile(currentProfile.uuid, { skin_id: undefined }); - setProfiles(prev => prev.map(p => - p.uuid === currentProfile.uuid ? { ...p, skin_id: undefined } : p + onClick={async () => { + if (!currentProfile) return; + const prevSkinId = currentProfile.skin_id; + // 乐观更新本地态 + setProfiles(prev => prev.map(p => + p.uuid === currentProfile.uuid ? { ...p, skin_id: undefined } : p + )); + setProfileSkins(prev => { + const next = { ...prev }; + delete next[currentProfile.uuid]; + return next; + }); + setShowSkinSelector(null); + try { + // 显式传 null(区别于 undefined 被 JSON.stringify 丢弃),后端据此清空皮肤关联 + const resp = await updateProfile(currentProfile.uuid, { skin_id: null }); + if (resp.code !== 200) { + // 回滚 + setProfiles(prev => prev.map(p => + p.uuid === currentProfile.uuid ? { ...p, skin_id: prevSkinId } : p + )); + messageManager.error(resp.message || '移除皮肤失败', { duration: 3000 }); + } + } catch (err) { + console.error('移除皮肤失败:', err); + setProfiles(prev => prev.map(p => + p.uuid === currentProfile.uuid ? { ...p, skin_id: prevSkinId } : p )); - setProfileSkins(prev => { - const newSkins = { ...prev }; - delete newSkins[currentProfile.uuid]; - return newSkins; - }); - setShowSkinSelector(null); + messageManager.error('移除皮肤失败,请稍后重试', { duration: 3000 }); } }} > @@ -948,24 +1036,38 @@ export default function ProfilePage() {

移除皮肤

- + {availableSkins.map((skin) => ( { - // 分配皮肤给角色 - if (currentProfile) { - updateProfile(currentProfile.uuid, { skin_id: skin.id }); - setProfiles(prev => prev.map(p => - p.uuid === currentProfile.uuid ? { ...p, skin_id: skin.id } : p + onClick={async () => { + if (!currentProfile) return; + const prevSkinId = currentProfile.skin_id; + // 乐观更新 + setProfiles(prev => prev.map(p => + p.uuid === currentProfile.uuid ? { ...p, skin_id: skin.id } : p + )); + setProfileSkins(prev => ({ + ...prev, + [currentProfile.uuid]: { url: skin.url, isSlim: skin.is_slim } + })); + setShowSkinSelector(null); + try { + const resp = await updateProfile(currentProfile.uuid, { skin_id: skin.id }); + if (resp.code !== 200) { + setProfiles(prev => prev.map(p => + p.uuid === currentProfile.uuid ? { ...p, skin_id: prevSkinId } : p + )); + messageManager.error(resp.message || '设置皮肤失败', { duration: 3000 }); + } + } catch (err) { + console.error('设置皮肤失败:', err); + setProfiles(prev => prev.map(p => + p.uuid === currentProfile.uuid ? { ...p, skin_id: prevSkinId } : p )); - setProfileSkins(prev => ({ - ...prev, - [currentProfile.uuid]: { url: skin.url, isSlim: skin.is_slim } - })); - setShowSkinSelector(null); + messageManager.error('设置皮肤失败,请稍后重试', { duration: 3000 }); } }} > @@ -1143,6 +1245,116 @@ export default function ProfilePage() { {/* Skin Selector Modal */} {renderSkinSelector()} + + {/* Change Email Modal */} + {showChangeEmailModal && ( + + !isChangingEmail && setShowChangeEmailModal(false)} + > + e.stopPropagation()} + > +
+

+ + 更换邮箱地址 +

+ +
+ +
+
+ + +
+
+ + setChangeEmailForm(prev => ({ ...prev, newEmail: e.target.value }))} + placeholder="请输入新邮箱" + disabled={isChangingEmail} + className="w-full px-4 py-3 bg-white/50 dark:bg-gray-700/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all duration-200 disabled:opacity-50" + /> +
+
+ +
+ setChangeEmailForm(prev => ({ ...prev, code: e.target.value.replace(/\D/g, '') }))} + placeholder="6位数字验证码" + disabled={isChangingEmail} + className="flex-1 px-4 py-3 bg-white/50 dark:bg-gray-700/50 border border-gray-300 dark:border-gray-600 rounded-xl focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all duration-200 disabled:opacity-50" + /> + 0 || isChangingEmail} + className="bg-gradient-to-r from-orange-500 to-amber-500 text-white px-4 py-2 rounded-xl shadow-lg hover:shadow-xl transition-all duration-200 disabled:opacity-50 disabled:cursor-not-allowed whitespace-nowrap" + whileHover={{ scale: 1.05 }} + whileTap={{ scale: 0.95 }} + > + {emailCodeCountdown > 0 ? `${emailCodeCountdown}s` : isSendingEmailCode ? '发送中...' : '发送验证码'} + +
+

+ 验证码将发送到新邮箱,请查收邮件 +

+
+
+ setShowChangeEmailModal(false)} + disabled={isChangingEmail} + className="flex-1 border border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 px-4 py-2 rounded-xl transition-all duration-200 disabled:opacity-50" + whileHover={{ scale: 1.02 }} + whileTap={{ scale: 0.98 }} + > + 取消 + + + {isChangingEmail ? '更换中...' : '确认更换'} + +
+
+
+
+
+ )} ); } diff --git a/src/app/register/page.tsx b/src/app/register/page.tsx new file mode 100644 index 0000000..468d849 --- /dev/null +++ b/src/app/register/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from 'next/navigation'; + +// /register 兼容入口:重定向到统一认证页并默认进入注册模式 +export default function RegisterPage() { + redirect('/auth?mode=register'); +} diff --git a/src/app/signup/page.tsx b/src/app/signup/page.tsx new file mode 100644 index 0000000..abb42dd --- /dev/null +++ b/src/app/signup/page.tsx @@ -0,0 +1,6 @@ +import { redirect } from 'next/navigation'; + +// /signup 兼容入口:重定向到统一认证页并默认进入注册模式 +export default function SignupPage() { + redirect('/auth?mode=register'); +} diff --git a/src/components/SliderCaptcha.tsx b/src/components/SliderCaptcha.tsx index e4947fb..8c30093 100644 --- a/src/components/SliderCaptcha.tsx +++ b/src/components/SliderCaptcha.tsx @@ -6,11 +6,11 @@ import { API_BASE_URL } from '@/lib/api'; /** * 滑块验证码组件属性接口定义 * @interface SliderCaptchaProps - * @property {function} onVerify - 验证结果回调函数,参数为验证是否成功 + * @property {function} onVerify - 验证结果回调函数,参数为验证是否成功及验证码ID(成功时) * @property {function} onClose - 关闭验证码组件的回调函数 */ interface SliderCaptchaProps { - onVerify: (success: boolean) => void; + onVerify: (success: boolean, captchaId?: string) => void; onClose: () => void; } @@ -184,8 +184,8 @@ export const SliderCaptcha: React.FC = ({ onVerify, onClose setVerifyResult(false); // 直接设置验证成功状态,不使用异步更新 setIsVerified(true); - // 延迟1.2秒后调用验证成功回调 - setTimeout(() => onVerify(true), 1200); + // 延迟1.2秒后调用验证成功回调,透传后端返回的 captchaId 供注册接口使用 + setTimeout(() => onVerify(true, processId), 1200); } // 验证失败:code=400 else if (code === 400) { diff --git a/src/components/profile/CharacterCard.tsx b/src/components/profile/CharacterCard.tsx index 7add921..6f61b2b 100644 --- a/src/components/profile/CharacterCard.tsx +++ b/src/components/profile/CharacterCard.tsx @@ -1,7 +1,7 @@ 'use client'; import { motion } from 'framer-motion'; -import { UserIcon, PencilIcon, TrashIcon, CheckIcon } from '@heroicons/react/24/outline'; +import { UserIcon, PencilIcon, TrashIcon } from '@heroicons/react/24/outline'; import SkinViewer from '@/components/SkinViewer'; import type { Profile } from '@/lib/api'; @@ -15,7 +15,6 @@ interface CharacterCardProps { onSave: (uuid: string) => void; onCancel: () => void; onDelete: (uuid: string) => void; - onSetActive: (uuid: string) => void; onSelectSkin: (uuid: string) => void; onEditNameChange: (name: string) => void; } @@ -30,7 +29,6 @@ export default function CharacterCard({ onSave, onCancel, onDelete, - onSetActive, onSelectSkin, onEditNameChange }: CharacterCardProps) { @@ -68,12 +66,6 @@ export default function CharacterCard({ )} - {profile.is_active && ( - - - 当前使用 - - )}
diff --git a/src/contexts/AuthContext.tsx b/src/contexts/AuthContext.tsx index 6f2aa72..bc68d0a 100644 --- a/src/contexts/AuthContext.tsx +++ b/src/contexts/AuthContext.tsx @@ -1,6 +1,7 @@ 'use client'; import React, { createContext, useContext, useState, useEffect, ReactNode } from 'react'; +import { API_BASE_URL } from '@/lib/api'; interface User { id: number; @@ -28,8 +29,6 @@ interface AuthContextType { refreshUser: () => Promise; } -const API_BASE_URL = 'http://localhost:8080/api/v1'; - const AuthContext = createContext(undefined); export function AuthProvider({ children }: { children: ReactNode }) { diff --git a/src/lib/api.ts b/src/lib/api.ts index 59df7f7..8b9ff69 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -24,7 +24,7 @@ export interface Profile { name: string; skin_id?: number; cape_id?: number; - is_active: boolean; + is_active?: boolean; last_used_at?: string; created_at: string; updated_at: string; @@ -38,6 +38,32 @@ export interface PaginatedResponse { total_pages: number; } +// 后端原始分页响应结构(统一返回 { list, total, page, per_page },不含 total_pages/page_size) +type RawPaginatedData = { + list?: T[]; + total?: number; + page?: number; + per_page?: number; + page_size?: number; +}; + +// 在前端补齐 page_size 与 total_pages(由 total / page_size 向上取整),以便 UI 分页使用 +function normalizePaginatedData(raw: RawPaginatedData): PaginatedResponse { + const list = raw.list ?? []; + const total = raw.total ?? 0; + const page = raw.page ?? 1; + const page_size = raw.per_page ?? raw.page_size ?? 20; + const total_pages = page_size > 0 ? Math.max(1, Math.ceil(total / page_size)) : 1; + return { list, total, page, page_size, total_pages }; +} + +// 后端错误响应的 data 可能不是分页结构(code !== 200),用此类型宽松接收 +type RawApiResponse = { + code: number; + message: string; + data: T; +}; + export interface ApiResponse { code: number; message: string; @@ -75,7 +101,11 @@ export async function searchTextures(params: { }, }); - return response.json(); + const result: RawApiResponse> = await response.json(); + if (result.code === 200 && result.data) { + return { ...result, data: normalizePaginatedData(result.data) }; + } + return result as ApiResponse>; } // 获取材质详情 @@ -114,7 +144,11 @@ export async function getMyTextures(params: { headers: getAuthHeaders(), }); - return response.json(); + const result: RawApiResponse> = await response.json(); + if (result.code === 200 && result.data) { + return { ...result, data: normalizePaginatedData(result.data) }; + } + return result as ApiResponse>; } // 获取用户收藏的材质列表 @@ -131,7 +165,11 @@ export async function getFavoriteTextures(params: { headers: getAuthHeaders(), }); - return response.json(); + const result: RawApiResponse> = await response.json(); + if (result.code === 200 && result.data) { + return { ...result, data: normalizePaginatedData(result.data) }; + } + return result as ApiResponse>; } // 获取用户档案列表 @@ -156,10 +194,11 @@ export async function createProfile(name: string): Promise> } // 更新档案 +// skin_id / cape_id: 显式传 null 表示移除当前皮肤的关联;传 number 表示设置;不传表示不修改。 export async function updateProfile(uuid: string, data: { name?: string; - skin_id?: number; - cape_id?: number; + skin_id?: number | null; + cape_id?: number | null; }): Promise> { const response = await fetch(`${API_BASE_URL}/profile/${uuid}`, { method: 'PUT', @@ -180,16 +219,6 @@ export async function deleteProfile(uuid: string): Promise> { return response.json(); } -// 设置活跃档案 -export async function setActiveProfile(uuid: string): Promise> { - const response = await fetch(`${API_BASE_URL}/profile/${uuid}/activate`, { - method: 'POST', - headers: getAuthHeaders(), - }); - - return response.json(); -} - // 获取用户信息 export async function getUserProfile(): Promise; +// 直接上传头像文件到后端(multipart/form-data) +// 后端接口 POST /user/avatar/upload,由后端负责写入对象存储并更新用户头像 +export async function uploadAvatar(file: File): Promise> { - const response = await fetch(`${API_BASE_URL}/user/avatar/upload-url`, { + const formData = new FormData(); + formData.append('file', file); + + const token = typeof window !== 'undefined' ? localStorage.getItem('authToken') : null; + const response = await fetch(`${API_BASE_URL}/user/avatar/upload`, { method: 'POST', - headers: getAuthHeaders(), - body: JSON.stringify({ file_name: fileName }), + headers: { + ...(token && { Authorization: `Bearer ${token}` }), + }, + body: formData, }); return response.json(); } -// 更新头像URL +// 更新头像URL(用于外部URL场景,区别于文件直传) export async function updateAvatarUrl(avatarUrl: string): Promise> { return response.json(); } +// 更新材质信息(名称、描述、公开性) +// 对应后端 PUT /texture/{id}:is_public 为布尔值时会更新该字段,会影响公开/隐藏状态 +export async function updateTexture(id: number, data: { + name?: string; + description?: string; + is_public?: boolean; +}): Promise> { + const response = await fetch(`${API_BASE_URL}/texture/${id}`, { + method: 'PUT', + headers: getAuthHeaders(), + body: JSON.stringify(data), + }); + + return response.json(); +} + +// 发送邮箱验证码 +// type: register | reset_password | change_email +export async function sendVerificationCode(email: string, type: 'register' | 'reset_password' | 'change_email'): Promise> { + const response = await fetch(`${API_BASE_URL}/auth/send-code`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, type }), + }); + + return response.json(); +} + +// 更换邮箱 +export async function changeEmail(newEmail: string, verificationCode: string): Promise> { + const response = await fetch(`${API_BASE_URL}/user/change-email`, { + method: 'POST', + headers: getAuthHeaders(), + body: JSON.stringify({ new_email: newEmail, verification_code: verificationCode }), + }); + + return response.json(); +} + +// 重置密码(通过邮箱验证码) +// 对应后端 POST /auth/reset-password,无需 JWT +export async function resetPassword(email: string, verificationCode: string, newPassword: string): Promise> { + const response = await fetch(`${API_BASE_URL}/auth/reset-password`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email, + verification_code: verificationCode, + new_password: newPassword, + }), + }); + + return response.json(); +} +