diff --git a/.env.example b/.env.example index dea8a32..cf88bd4 100644 --- a/.env.example +++ b/.env.example @@ -1,3 +1,3 @@ -VITE_API_URL=http://localhost:8000/api/v1 +VITE_API_URL=http://localhost:3000 VITE_AMAP_KEY= VITE_SITE_URL=http://localhost:5173 diff --git a/API.md b/API.md new file mode 100644 index 0000000..986c55f --- /dev/null +++ b/API.md @@ -0,0 +1,1239 @@ +# OpenCloud API 前端开发文档 + +> **读者**:前端开发者。本文档覆盖 API 的全部端点:请求参数、响应结构、可能出现的错误与已知坑点。 +> +> **事实来源**:本文档的字段名、约束与错误消息以 `src/schema/*.ts`(zod 校验定义)与 `src/router/*.ts`(路由实现)为最终依据整理。若文档与代码不一致,以代码为准。 +> +> **重要免责**:错误消息文案(`error` 字段的中文内容)可能随时调整。**前端逻辑只能依赖 HTTP 状态码,严禁匹配错误消息字符串**。文档中列出消息原文仅供调试对照。 + +## 目录 + +1. 通用约定 +2. 错误处理与已知不一致 +3. 认证 +4. 端点 · 认证 `/auth` +5. 端点 · 云图 `/cloud` +6. 端点 · 个人资料 `/profile` +7. 端点 · 信息 `/info` +8. 端点 · 管理 `/admin` +9. 端点 · 系统 + +--- + +## 1. 通用约定 + +### 1.1 Base URL + +| 环境 | 地址 | +|---|---| +| 本地开发 | `http://localhost:3000`(`vc dev` 启动) | +| 生产 | 以部署地址为准 | + +除 `/image` 成功响应直接返回图片字节外,响应均为 `application/json`(个别异常情形返回纯文本,见 2.2)。所有响应携带两个调试响应头: + +- `X-Request-Id`:请求唯一 ID,反馈问题时请附上 +- `X-Response-Time`:服务端处理耗时,如 `12ms` + +### 1.2 命名与格式 + +- 请求与响应字段一律 **snake_case**(如 `cloud_id`、`page_size`、`received_like_count`) +- 日期时间一律为 **ISO 8601 字符串**(如 `2026-08-15T08:30:00.000Z`),可空的日期字段值为 `null`。唯一的例外见 2.3 第 5 条 +- UUID 字段为标准 UUID 字符串 + +### 1.3 响应形态 + +响应**没有统一包装**,分两种形态: + +| 场景 | 形态 | 示例 | +|---|---|---| +| 写操作成功(POST/PATCH/DELETE 的动作类端点) | 信封对象 | `{ "status_code": 200, "message": "删除成功" }` | +| 读操作成功(GET 及登录) | 裸数据 | 单资源为对象、列表为**数组**、登录为 `{ "access_token" }` | + +### 1.4 分页 + +所有分页端点使用相同的 query 参数: + +| 参数 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `page` | int | 否 | ≥ 1,默认 `1` | 页码 | +| `page_size` | int | 否 | 1–100,默认 `50` | 每页数量 | + +⚠️ **响应不返回总数**,也没有 `has_more` 字段。判断「是否还有下一页」只能依靠「返回数组长度 < `page_size`」。超出数据范围的页返回 `200` + 空数组 `[]`,不是 404。 + +云图列表排序为 `uploaded_at` 倒序(同刻按 `id` 倒序);点赞列表按点赞时间倒序。 + +### 1.5 共享数据模型 + +以下模型在多个端点复用,端点章节中直接引用名称。 + +**CloudInfo(云图)** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | uuid | 云图 ID | +| `owner` | object | 上传者:`{ id: uuid, name: string }` | +| `type` | object | 云类型:`{ id: int, name: string, genus: string\|null, icon_id: uuid\|null, rarity: int, description: string }` | +| `latitude` | number | 纬度,-90 ~ 90 | +| `longitude` | number | 经度,-180 ~ 180 | +| `description` | string | 描述,可能为空字符串 | +| `captured_at` | string\|null | 拍摄时间(ISO 8601) | +| `uploaded_at` | string | 上传时间(ISO 8601) | +| `updated_at` | string | 最后更新时间(ISO 8601) | +| `received_like_count` | int | 收到点赞数 | +| `status` | enum | 审核状态:`pending`(待审核)/ `approved`(已通过)/ `rejected`(已驳回) | +| `is_hidden` | boolean | 是否隐藏(上传者控制,与审核状态独立) | + +**公开可见规则**:未登录访客与无关用户只能看到 `status === "approved"` 且 `is_hidden === false` 的云图;上传者本人与 admin 不受限。 + +**UserProfile(用户)** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | uuid | 用户 ID | +| `name` | string | 用户名 | +| `email` | string | 邮箱(注意 2.3 第 7 条的暴露范围问题) | +| `avatar_id` | uuid\|null | 头像 ID(当前恒为 `null`,头像功能未上线) | +| `cloud_count` | int | 云图数(该用户上传的云图总数) | +| `received_like_count` | int | 收到点赞数(名下云图获赞总和) | +| `last_online` | string\|null | 最后在线时间(ISO 8601) | +| `role` | enum | `user` / `admin` | +| `is_disabled` | boolean | 是否被禁用 | +| `created_at` | string | 注册时间(ISO 8601) | + +**CloudTypeInfo(云类型)** + +| 字段 | 类型 | 说明 | +|---|---|---| +| `id` | int | 云类型 ID | +| `name` | string | 名称,如「积云」 | +| `genus` | string\|null | 属(拉丁名),如 `Cumulus` | +| `icon_id` | uuid\|null | 图标 ID | +| `rarity` | int | 稀有度 | +| `description` | string | 描述 | +| `created_at` | string | 创建时间。⚠️ 列表与详情端点的格式不一致,见 2.3 第 5 条 | + +### 1.6 上传约束(`POST /cloud`) + +- 请求体总大小上限 **21 MiB**,超出返回 413 +- 图片文件上限 **20 MB**;仅支持 **JPEG / PNG / WebP**;**不支持动图**(含动态 WebP、GIF) +- 图片分辨率过高会被拒绝(像素上限) +- multipart 表单字段均为字符串,注意 `is_hidden` 的编码规则(见 5.7) + +--- + +## 2. 错误处理与已知不一致 + +### 2.1 两种错误响应形状 + +本 API 存在**两种**错误响应形状,前端必须分别处理: + +**① 业务错误**(绝大多数错误)——ErrorMessage 信封: + +```json +{ + "status_code": 404, + "error": "未找到图片" +} +``` + +**② 参数校验失败(400)**——zod 原始结构,**不是**上面的信封: + +```json +{ + "success": false, + "error": { + "name": "ZodError", + "message": "[{\"code\":\"invalid_format\",\"path\":[\"email\"],\"message\":\"Invalid email address\"}]" + } +} +``` + +注意 `error.message` 是一个 **JSON 字符串**(issues 数组的序列化结果),需要二次 `JSON.parse` 才能拿到字段级错误明细;每个 issue 含 `path`(出错字段路径)、`code`、`message`。不需要逐字段提示时,直接弹「参数错误」即可。 + +**判别方式**:响应体含 `success: false` → 形状②;含 `status_code` → 形状①。 + +### 2.2 状态码总表 + +| 状态码 | 含义 | 前端通用动作 | +|---|---|---| +| 200 | 成功 | — | +| 201 | 创建成功(注册、上传云图) | — | +| 304 | 图片 ETag 未变化,无响应 body | 继续使用本地缓存 | +| 400 | 参数校验失败(形状②),或业务规则拒绝(形状①,如「新密码不能与当前密码相同」) | 检查参数;形状②可解析 `error.message` 定位字段 | +| 401 | 未认证:未携带 token,或 token 无效/过期/已失效 | 清除本地登录态,跳转登录页 | +| 403 | 已认证但无权限:角色不足、邮箱未确认、帐号被禁用、越权访问他人资源 | 按消息场景提示 | +| 404 | 资源不存在(形状①);**也可能是纯文本**(见下方警告) | 区分 content-type 后处理 | +| 405 | HTTP 方法不受端点支持 | 读取 `Allow` 响应头 | +| 409 | 唯一性冲突:邮箱/用户名已被占用 | 提示用户更换 | +| 413 | 上传请求体超过 21 MiB | 提示压缩图片后重试 | +| 500 | 服务器内部错误 | 提示稍后重试,反馈时附 `X-Request-Id` | +| 503 | 图片存储服务暂时不可用 | 稍后重试,反馈时附 `X-Request-Id` | + +⚠️ **404 有两种 body**:业务 404(资源不存在)是 JSON 信封;但「未实现的占位端点」和「个别端点的数据库异常」返回的是 **纯文本 `404 Not Found`**(`text/plain`),见 2.3 第 2 条。前端解析 404 响应前必须先判断 content-type。 + +### 2.3 ⚠️ 已知不一致清单 + +以下是文档写作时核实的现状描述,**未来可能修复**。前端如需为之写防御代码,请做好移除准备。 + +1. **两种错误形状并存**:400 校验错误不是业务错误信封(见 2.1)。 +2. **部分 404 是纯文本而非 JSON**,来源有三:(a) 未实现的占位端点(各章已标注);(b) `GET /profile/me` 与 `GET /admin/users` 的数据库异常会从空 `catch` 落到纯文本 404;(c) 未匹配任何路由的路径。注册与邮箱确认失败始终返回 JSON 信封。 +3. **静默续签只写 Cookie**:token 到期前的自动续签仅通过 `Set-Cookie` 下发新 token(见 3.4)。使用 Bearer header 模式的客户端**拿不到新 token**,30 分钟后必然 401,需重新登录。 +4. **`POST /admin/user` 是假端点**:它原样回显请求体(**含明文密码**),并不创建用户。 +5. **`GET /info/cloudtype`(列表)的 `created_at` 格式与其他端点不同**:是 PostgreSQL 原生字符串(如 `2026-08-15 08:30:00.123456+00`),而非 ISO 8601(`2026-08-15T08:30:00.000Z`)。详情端点 `/info/cloudtype/:id` 及其他所有端点均为 ISO 8601。前端解析该字段时需兼容两种格式。 +6. **用户名/邮箱长度不在 API 层校验**:数据库限制用户名 ≤ 16 字符、邮箱 ≤ 64 字符,但 zod 校验不拦截。超长注册与超长改名(`PATCH /profile/me`)会返回 JSON 500。前端应自行限制输入长度。 +7. **`GET /profile/:user_id` 向任意登录用户暴露对方邮箱**:任何登录用户都能查到任意用户的 `email`。前端展示他人资料时不应展示邮箱,也不应假设该接口未来仍返回邮箱。 +8. **用户名唯一性的大小写规则不一致**:注册时的查重是大小写不敏感的(`Alice`/`alice` 视为重复),但数据库唯一约束与 `PATCH /profile/me` 改名是大小写敏感的(可改成仅大小写不同的名字)。 + +--- + +## 3. 认证 + +### 3.1 机制概览 + +- JWT(HS256),有效期 **30 分钟** +- 获取方式:`POST /auth/login` 成功后在响应体返回 `access_token`,**同时**通过 `Set-Cookie` 写入 HttpOnly Cookie +- 受保护端点接受两种携带方式(服务端优先读 header): + 1. 请求头 `Authorization: Bearer ` + 2. Cookie `token=`(`HttpOnly; SameSite=Strict; Path=/; Max-Age=1800`;非开发/测试环境附加 `Secure`) + +### 3.2 Bearer 还是 Cookie? + +| 模式 | 适用 | 注意事项 | +|---|---|---| +| Cookie | 浏览器前端(推荐) | 登录后浏览器自动携带;`fetch` 需设 `credentials: "include"`;HttpOnly 使 JS 无法读取 token,天然防 XSS 窃取;可享受静默续签 | +| Bearer | 非浏览器客户端(App、脚本) | 自行存储 `access_token` 并注入 header;**无法静默续签**(2.3 第 3 条),30 分钟后需重新登录 | + +### 3.3 CORS + +- 服务端仅放行**单个** origin(环境变量 `CORS_ORIGIN`,本地默认应为 `http://localhost:5173`),且 `credentials: true` +- 前端本地开发的服务端口/origin 必须与后端配置**完全一致**(协议、主机、端口),否则 Cookie 模式的请求会被浏览器拦截 +- 放行的请求头:`Content-Type`、`Authorization` +- 私有图片通过 `` 直接加载时依赖 Cookie;生产环境前端与 API 必须位于同一 site(例如 `app.example.com` 与 `api.example.com`),否则 `SameSite=Strict` Cookie 不会随图片请求发送 + +### 3.4 有效期、续签与失效 + +- token 有效期 30 分钟(Cookie 的 `Max-Age` 同为 1800 秒) +- **静默续签**:携带剩余有效期 ≤ 300 秒的 token 访问受保护端点时,服务端会在响应中**追加 `Set-Cookie` 写入新 token**。Cookie 模式下浏览器自动替换,前端无感知;Bearer 模式收不到新 token +- 续签不适用的路径:`POST /auth/logout`、`PATCH /auth/password` +- `/image` 请求只验证身份,不触发静默续签,避免一个页面的并发图片请求重复写 Cookie +- 认证中间件每次请求都会重新检查用户仍已确认邮箱且未被管理员禁用;任一条件不满足时,已有 JWT 也会被拒绝 +- **立即失效**的情况:调用 `/auth/logout`、调用 `PATCH /auth/password` 改密、通过 `/auth/reset-password` 重置密码、用户被禁用,以及管理员修改了该用户的角色或密码(`PATCH /admin/users/:user_id` 见 8.3、`PATCH /admin/users/:user_id/password` 见 8.4)。除「用户被禁用」由中间件每次请求重新校验外,其余情况都会递增 `tokenVersion`,使该用户**所有已签发的 token 全部作废**(包括其他设备上的);用户自己改密与密码重置成功时还会写入过期 Cookie + +### 3.5 认证相关错误消息对照 + +| 状态码 | 消息原文 | 场景 | +|---|---|---| +| 401 | `未登录` | 受保护端点未携带任何 token | +| 401 | `Token 无效或已过期` | token 签名无效、过期、已被吊销(登出、改密或密码重置后),或用户不再同时满足「邮箱已确认且未禁用」 | +| 403 | `权限不足` | 已登录但角色不满足(如非 admin 访问 `/admin`) | + +注意「帐号被禁用」在**登录时**报 403「帐号已被禁用」,但已登录后被禁用,后续请求报的是 401「Token 无效或已过期」。 + +--- + +## 4. 端点 · 认证 `/auth` + +### 4.1 `POST /auth/register` — 注册 + +- **认证**:无(公开) + +**请求体**(JSON): + +| 参数 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `name` | string | 是 | ⚠️ 数据库限 ≤ 16 字符,API 层不校验(2.3 第 6 条) | 用户名,大小写不敏感查重 | +| `email` | string | 是 | 合法邮箱格式;⚠️ 数据库限 ≤ 64 字符 | 服务端去除首尾空白并转为小写后存储 | +| `password` | string | 是 | 无强度校验 | 密码 | + +```json +{ + "name": "cloudwatcher", + "email": "watcher@example.com", + "password": "s3cret-password" +} +``` + +**成功响应**:`201` + +```json +{ + "status_code": 201, + "message": "注册成功,请查收邮件并确认邮箱", + "email_sent": true +} +``` + +数据库提交成功但邮件暂时未发送时仍返回 `201`,`email_sent` 为 `false`,消息会提示稍后使用重发验证流程。用户、密码凭据和 24 小时有效的确认记录已经保留,不应重试注册。 + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 | +| 409 | 已确认邮箱重复注册(大小写不敏感) | `该邮箱已被注册` | 提示直接登录 | +| 409 | 未确认邮箱重复注册 | `该邮箱已被注册,请使用重发验证流程` | 引导至重发验证流程,不会覆盖原用户名或密码 | +| 409 | 用户名已使用(大小写不敏感) | `该用户名已被使用` | 提示换用户名 | +| 500 | 数据库、密码散列或服务端配置异常 | `注册失败` | 稍后重试;若此前已收到 201,不要重复注册 | + +**注意事项**: + +- 新用户的 `is_disabled` 为 `false`,`email_verified_at` 为空;两个状态相互独立。注册后必须先确认邮箱,再调用 `/auth/login` +- 注册响应**不返回 token、不写 Cookie** +- 确认邮件由 `Opencloud ` 发出,同时包含 `https://cloud.catpl.top/verify-email?token=...` 链接和原始 token 备用文本。数据库仅保存使用 `JWT_SECRET` 生成的 HMAC 摘要 +- ⚠️ 并发注册(同一邮箱/用户名几乎同时提交)命中数据库唯一约束时,冲突消息文案与上方表格不同(邮箱冲突会返回 `该邮箱已被注册;若尚未验证,请使用重发验证流程`)。按本文档免责声明,前端仍只依据状态码 `409` 处理 + +### 4.2 `POST /auth/resend-confirmation` — 重发确认邮件 + +- **认证**:无(公开) + +**请求体**(JSON): + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `email` | string | 是 | 与注册、登录相同:服务端去除首尾空白并转为小写;不接受旧字段 `user_email` | + +```json +{ + "email": "watcher@example.com" +} +``` + +**成功响应**:`200` + +```json +{ + "status_code": 200, + "message": "如果该邮箱符合条件,我们将发送验证邮件" +} +``` + +未知邮箱、已确认邮箱、被管理员禁用、处于 60 秒冷却期、符合发送条件以及邮件供应商失败,都会收到完全相同的状态码和响应体。前端不能根据该响应判断邮件是否实际发送,也不应显示「邮箱存在」之类的提示。 + +只有尚未确认、未被管理员禁用且不在冷却期内的用户会触发邮件发送。成功签发新 token 后,同一用户此前未消费的邮箱确认 token 全部失效;新 token 仍有 24 小时有效期,邮件内容与注册邮件相同。已消费记录会保留作为审计信息。 + +重叠请求会按用户串行处理,至多签发一个可用 token。数据库签发或 Resend 失败不会改变上述公共响应。 + +**错误**:请求体校验失败返回 `400` zod 形状(2.1)。 + +### 4.3 `POST /auth/confirm-email` — 确认邮箱 + +- **认证**:无(公开) + +前端从确认链接读取 `token`,再提交: + +```json +{ + "token": "0123456789abcdef0123456789abcdef" +} +``` + +**成功响应**:`200` + +```json +{ + "status_code": 200, + "message": "邮箱确认成功,请重新登录" +} +``` + +确认会在一个数据库事务中消费未过期、未消费且用途为 `email_confirmation` 的记录,并写入用户的邮箱确认时间。它不会签发 JWT、不会写认证 Cookie,也绝不修改管理员禁用状态。 + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | token 未知、过期、已消费,或来自其他用途 | `确认链接无效或已过期` | 统一提示链接无效,并引导重发 | +| 500 | 数据库或服务端配置异常 | `确认邮箱失败` | 稍后重试 | + +### 4.4 `POST /auth/login` — 登录 + +- **认证**:无(公开) + +**请求体**(JSON): + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `email` | string | 是 | 服务端去除首尾空白、转为小写后匹配 | +| `password` | string | 是 | 密码 | + +```json +{ + "email": "watcher@example.com", + "password": "s3cret-password" +} +``` + +**成功响应**:`200`,同时写入认证 Cookie(见 3.1) + +```json +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." +} +``` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 | +| 401 | 邮箱不存在或密码错误(两者共用,防枚举) | `邮箱或密码错误` | 统一提示「邮箱或密码错误」 | +| 403 | 帐号被管理员禁用 | `帐号已被禁用` | 提示联系管理员 | +| 403 | 密码正确但邮箱尚未确认 | `邮箱尚未验证` | 引导至重发验证流程 | +| 500 | 服务端异常 | `登录失败` | 稍后重试 | + +### 4.5 `POST /auth/logout` — 登出 + +- **认证**:需登录(任意角色) +- **请求体**:无 + +**成功响应**:`200`,同时写入过期 Cookie + +```json +{ + "status_code": 200, + "message": "已登出" +} +``` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 401 | 未登录 / token 失效 | 见 3.5 | 本地直接清理登录态即可 | +| 500 | 数据库异常 | `连接数据库发生错误,登出失败` | 提示重试;⚠️ 此时服务端 token 可能仍有效 | + +**注意事项**:登出会使该用户**所有设备**的 token 立即失效(`tokenVersion` 递增),不只是当前会话。 + +### 4.6 `POST /auth/forgot-password` — 申请重置密码 + +- **认证**:无(公开) + +**请求体**(JSON): + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `email` | string | 是 | 服务端去除首尾空白并转为小写;不接受旧字段 `user_email` | + +```json +{ + "email": "watcher@example.com" +} +``` + +**成功响应**:`200` + +```json +{ + "status_code": 200, + "message": "如果该邮箱符合条件,我们将发送密码重置邮件" +} +``` + +未知邮箱、邮箱未确认、被管理员禁用、处于 60 秒冷却期、符合发送条件以及邮件供应商失败,都会收到完全相同的状态码和响应体。只有邮箱已确认、未被禁用且不在冷却期内的用户会触发邮件发送。 + +新签发的 `password_reset` token 有效期为 30 分钟,并使此前未消费的密码重置 token 失效;它与邮箱确认 token 用 purpose 隔离。邮件由 `Opencloud ` 发出,链接到 `https://cloud.catpl.top/reset-password?email=...&token=...`。数据库和 Resend 失败不改变公共响应。 + +**错误**:请求体校验失败返回 `400` zod 形状(2.1)。没有 `/auth/forget-password` 兼容路径。 + +### 4.7 `POST /auth/reset-password` — 重置密码 + +- **认证**:无(公开) + +**请求体**(JSON): + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `email` | string | 是 | 邮件链接中的邮箱;服务端再次规范化 | +| `token` | string | 是 | 邮件链接中的原始 token | +| `new_password` | string | 是 | 新密码;不能与当前密码相同,不额外扩大密码策略 | + +```json +{ + "email": "watcher@example.com", + "token": "0123456789abcdef0123456789abcdef", + "new_password": "new-s3cret-password" +} +``` + +**成功响应**:`200`,同时写入过期认证 Cookie + +```json +{ + "status_code": 200, + "message": "密码重置成功,请使用新密码重新登录" +} +``` + +成功时,服务端在同一数据库事务中消费 token、更新密码哈希和凭据更新时间,并递增 `tokenVersion`。此前在所有设备签发的 JWT 随即失效;响应不会签发新 JWT,用户必须重新登录。 + +token 必须同时匹配规范化邮箱、HMAC 摘要、`password_reset` purpose、有效期、未消费状态和目标用户;用户在消费时仍须邮箱已确认且未被管理员禁用。重置不会修改邮箱确认时间或管理员禁用状态。 + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | token 未知、过期、已消费、已被替换、用途错误、邮箱不匹配,或用户不再符合状态要求 | `重置链接无效或已过期` | 统一提示链接无效,重新申请 | +| 400 | 新密码与当前密码相同 | `新密码不能与当前密码相同` | 要求输入不同密码 | +| 500 | 数据库或服务端配置异常 | `重置密码失败` | 稍后重试 | + +### 4.8 `PATCH /auth/password` — 修改密码 + +- **认证**:需登录(任意角色) + +**请求体**(JSON): + +| 参数 | 类型 | 必填 | 说明 | +|---|---|---|---| +| `current_password` | string | 是 | 当前密码 | +| `new_password` | string | 是 | 新密码,无强度校验 | + +```json +{ + "current_password": "s3cret-password", + "new_password": "new-s3cret-password" +} +``` + +**成功响应**:`200`,同时写入过期 Cookie + +```json +{ + "status_code": 200, + "message": "密码修改成功,请重新登录" +} +``` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | 新旧密码相同 | `新密码不能与当前密码相同` | 表单前置校验可避免 | +| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 | +| 401 | 当前密码错误 | `当前密码错误` | 提示重新输入 | +| 401 | 未登录 / token 失效 | 见 3.5 | 跳登录 | +| 404 | 用户无凭据记录(数据异常) | `未找到用户凭据` | 反馈后端,附 `X-Request-Id` | +| 500 | 服务端异常 | `修改密码时发生错误` | 稍后重试 | + +**注意事项**:改密成功后**所有已签发 token 立即失效**(含当前使用的这个),前端必须清理登录态并跳转登录页。 + +--- + +## 5. 端点 · 云图 `/cloud` + +### 5.1 `GET /cloud` — 云图列表(公开搜索) + +- **认证**:无(公开);仅返回公开可见(`approved` 且未隐藏)的云图 + +**Query 参数**(在分页参数 1.4 基础上): + +| 参数 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `page` | int | 否 | 默认 `1` | 见 1.4 | +| `page_size` | int | 否 | 默认 `50`,最大 `100` | 见 1.4 | +| `filter` | enum | 否 | `type` / `owner`,默认 `type` | 搜索维度 | +| `value` | string | 否 | 默认 `""`(空 = 不过滤) | 搜索词 | + +搜索逻辑: + +- `value` 为空 → 不过滤,返回全部公开云图 +- `filter=owner`,**或** `value` 以 `@` 开头 → 按**用户名精确匹配**搜索该用户的公开云图(`@` 前缀会被去掉) +- 其他情况 → 按**云类型名称精确匹配**搜索 +- 均为精确匹配,不支持模糊搜索 + +**成功响应**:`200`,CloudInfo 数组(字段见 1.5) + +```json +[ + { + "id": "3f8a2c1e-7b9d-4e5f-a1c2-9d8e7f6a5b4c", + "owner": { + "id": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d", + "name": "cloudwatcher" + }, + "type": { + "id": 3, + "name": "积云", + "genus": "Cumulus", + "icon_id": null, + "rarity": 2, + "description": "底部平坦、顶部蓬松的白色云块" + }, + "latitude": 39.9042, + "longitude": 116.4074, + "description": "午后拍到的淡积云", + "captured_at": "2026-08-14T07:20:00.000Z", + "uploaded_at": "2026-08-15T08:30:00.000Z", + "updated_at": "2026-08-15T08:30:00.000Z", + "received_like_count": 12, + "status": "approved", + "is_hidden": false + } +] +``` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | 参数校验失败(如 `page_size=0`) | zod 形状(2.1) | 检查参数 | +| 404 | 按用户名搜索但该用户不存在 | `该用户不存在` | 提示「没有找到这个用户」,不要显示为空列表 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +**注意事项**:按云类型搜索时类型名不存在**不报错**,返回空数组(与用户名搜索的 404 行为不同)。 + +### 5.2 `GET /cloud/map` — 时间范围地图数据 + +- **认证**:无(公开);仅返回公开可见的云图 + +**Query 参数**: + +| 参数 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `start` | date string | 是 | 须 ≤ `end` | 范围起点,如 `2026-08-01T00:00:00Z` | +| `end` | date string | 是 | — | 范围终点 | +| `time_field` | enum | 否 | `captured_at` / `uploaded_at`,默认 `captured_at` | 按拍摄时间还是上传时间过滤 | +| `limit` | int | 否 | 1–1000,默认 `1000` | 返回上限(不分页) | + +**成功响应**:`200`,CloudInfo 数组,按 `time_field` 倒序(同刻按 `id` 倒序),结构同 5.1。 + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | `start` 晚于 `end` | zod 形状,issues 中含 `开始时间不能晚于结束时间` | 表单前置校验 | +| 400 | 其他参数校验失败 | zod 形状(2.1) | 检查参数 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +**注意事项**:`time_field=captured_at`(默认)时,`captured_at` 为 `null` 的云图**不会出现在结果中**;需要全量数据时请用 `uploaded_at`。 + +### 5.3 `GET /cloud/:cloud_id` — 云图详情 + +- **认证**:可选。匿名访客按公开可见规则过滤;上传者本人可见自己的全部云图;admin 可见全部 +- 无效/过期 token 按匿名处理,**不会**返回 401 + +**路径参数**: + +| 参数 | 类型 | 说明 | +|---|---|---| +| `cloud_id` | uuid | 云图 ID | + +**成功响应**:`200`,单个 CloudInfo 对象(字段见 1.5),结构同 5.1 的数组元素。 + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | `cloud_id` 不是合法 uuid | zod 形状(2.1) | 检查链接 | +| 404 | 云图不存在,**或**对当前用户不可见(待审核/已驳回/已隐藏) | `未找到图片` | 统一提示「云图不存在或不可见」——服务端故意不区分,防止泄露私有云图的存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +### 5.4 `GET /cloud/type/:cloud_type_id` — 按云类型列出云图 + +- **认证**:无(公开);仅返回公开可见的云图 + +**路径参数**: + +| 参数 | 类型 | 说明 | +|---|---|---| +| `cloud_type_id` | int | 云类型 ID(非数字返回 400) | + +**Query 参数**:分页参数,见 1.4。 + +**成功响应**:`200`,CloudInfo 数组,结构同 5.1。 + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | 参数校验失败 | zod 形状(2.1) | 检查参数 | +| 404 | 云类型不存在 | `云类型不存在` | 提示类型不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +### 5.5 `POST /cloud/:cloud_id/like` — 点赞 + +- **认证**:需登录(任意角色) +- **请求体**:无 + +**成功响应**:`200` + +```json +{ + "status_code": 200, + "message": "点赞成功" +} +``` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 | +| 404 | 云图不存在,或不是公开可见状态 | `图片不存在` | 提示云图不可点赞;与「不存在」共用是设计意图 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +**注意事项**:**幂等**——重复点赞返回相同的 200,点赞数不会增加。前端无需在点击前查询是否已赞,但仍建议本地置灰防止连点。 + +### 5.6 `DELETE /cloud/:cloud_id/like` — 取消点赞 + +- **认证**:需登录(任意角色) +- **请求体**:无 + +**成功响应**:`200` + +```json +{ + "status_code": 200, + "message": "取消点赞成功" +} +``` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +**注意事项**:**完全幂等**——从未点赞、云图不存在,同样返回 200。没有 404 分支。 + +### 5.7 `POST /cloud` — 上传云图 + +- **认证**:需登录(任意角色) +- **Content-Type**:`multipart/form-data`;请求体总上限 21 MiB(图片约束见 1.6) + +**表单字段**(multipart 字段均为字符串): + +| 字段 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `image` | file | 是 | 1 B – 20 MB,JPEG/PNG/WebP,非动图 | 图片文件 | +| `type_id` | int | 是 | 正整数 | 云类型 ID(从 `/info/cloudtype` 获取) | +| `latitude` | number | 是 | -90 ~ 90 | 纬度 | +| `longitude` | number | 是 | -180 ~ 180 | 经度 | +| `description` | string | 否 | ≤ 128 字符 | 描述 | +| `captured_at` | date string | 否 | 合法日期 | 拍摄时间 | +| `is_hidden` | string | **是** | 仅接受 `"true"` / `"false"` / `"1"` / `"0"` | 是否隐藏;其他写法(如 `"yes"`)返回 400 | + +**成功响应**:`201` + +```json +{ + "status_code": 201, + "message": "上传成功", + "cloud_id": "3f8a2c1e-7b9d-4e5f-a1c2-9d8e7f6a5b4c" +} +``` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | 图片为空文件 | `图片为空` | 提示重新选择 | +| 400 | 图片超过 20 MB | `图片大小超过 20MB 限制` | 提示压缩 | +| 400 | 格式不支持 | `仅支持 JPEG、PNG、WebP 格式` | 提示转换格式 | +| 400 | 动图 | `不支持动图` | 提示使用静态图 | +| 400 | 分辨率超上限 | `图片分辨率超过限制` | 提示缩小尺寸 | +| 400 | 其他图片处理失败 | `压缩参数无效` / `图片处理失败` | 提示换图重试 | +| 400 | 表单字段校验失败(如 `is_hidden` 编码非法、缺字段) | zod 形状(2.1) | 检查表单 | +| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 | +| 413 | 请求体超过 21 MiB | `上传文件过大` | 提示压缩图片 | +| 500 | 服务端异常(含存储/数据库失败) | `服务器发生内部错误` | 稍后重试;服务端已做失败补偿清理,可安全重试 | + +**注意事项**: + +- 上传成功后云图为 `pending`(待审核),**不会出现在公开列表**,本人可在「我的云图」(6.4)中看到 +- `type_id` 指向不存在的云类型会导致 500(外键约束),前端应确保类型 ID 来自 `/info/cloudtype` + +### 5.8 `PATCH /cloud/:cloud_id` — 更新云图 + +- **认证**:需登录,且为上传者本人。**非本人的云图返回 404 而非 403**(防止泄露存在性) + +**路径参数**:`cloud_id`(uuid)。 + +**请求体**(JSON,至少提供一个字段,否则 400): + +| 参数 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `latitude` | number | 否 | -90 ~ 90 | 纬度 | +| `longitude` | number | 否 | -180 ~ 180 | 经度 | +| `type_id` | int | 否 | 正整数 | ⚠️ 不校验存在性,非法值导致 500 | +| `description` | string | 否 | ≤ 128 字符 | 描述 | +| `captured_at` | date string | 否 | 合法日期 | 拍摄时间 | +| `is_hidden` | boolean | 否 | — | 是否隐藏(JSON 布尔值) | + +```json +{ + "description": "补充:拍摄于景山公园", + "is_hidden": false +} +``` + +**成功响应**:`200`,`{ "status_code": 200, "message": "更新成功" }` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | 未提供任何修改字段 | zod 形状,issues 中含 `至少需要提供一个修改字段` | 表单前置校验 | +| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 | +| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 | +| 404 | 云图不存在或不属于当前用户 | `图片不存在` | 提示云图不存在 | +| 500 | 服务端异常(含 `type_id` 不存在) | `服务器发生内部错误` | 稍后重试 | + +### 5.9 `DELETE /cloud/:cloud_id` — 删除云图 + +- **认证**:需登录,且为上传者本人(非本人返回 404,同 5.8) + +**成功响应**:`200`,`{ "status_code": 200, "message": "删除成功" }` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 | +| 404 | 云图不存在或不属于当前用户 | `图片不存在` | 提示云图不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +### 5.10 `DELETE /cloud` — 批量删除云图 + +- **认证**:需登录;仅删除属于当前用户的云图 + +**请求体**(JSON): + +| 参数 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `cloud_ids` | array | 是 | 1–100 个元素,不允许重复 | 元素为 `{ "cloud_id": "" }` 对象 | + +```json +{ + "cloud_ids": [ + { "cloud_id": "3f8a2c1e-7b9d-4e5f-a1c2-9d8e7f6a5b4c" }, + { "cloud_id": "b4c5d6e7-f8a9-4b5c-9d0e-1f2a3b4c5d6e" } + ] +} +``` + +**成功响应**:`200` + +```json +{ + "status_code": 200, + "message": "成功删除 2 张图片" +} +``` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | ID 重复 / 数量超出 1–100 / 格式非法 | zod 形状,ID 重复时 issues 含 `图片 ID 不能重复` | 前端去重后提交 | +| 401 | 未登录 / token 失效 | 见 3.5 | 引导登录 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +**注意事项**:**部分成功语义**——只删除属于当前用户的云图,他人或不存在的 ID 被静默跳过,消息中的 `N` 是实际删除数(可能小于请求数,甚至为 0)。前端应以 `N` 为准刷新列表,而不是假设全部删除成功。 + +### 5.11 `GET|HEAD /image/:cloud_id/:variant` — 读取图片变体 + +- **认证**:可选。匿名访客与无关用户只能读取具备 PublicVisibility 的 Cloud;上传者可读取自己的全部 Cloud;admin 可读取全部 Cloud +- **严格凭证**:完全不携带凭证时按匿名处理;一旦携带无效/过期 Bearer 或 Cookie,返回 401,不会降级为匿名。Bearer 与 Cookie 同时存在时 Bearer 优先 +- **响应**:应用先查询 Cloud 权限,再从 MinIO 流式代理图片;客户端不能提交 bucket 或 object key + +**路径参数**: + +| 参数 | 类型 | 说明 | +|---|---|---| +| `cloud_id` | uuid | Cloud ID,由上传响应、列表或详情接口取得 | +| `variant` | enum | `preview`(最大 640×640 WebP)或 `original`(上传原图) | + +两种图片变体采用相同权限规则。示例: + +```html +云图 +``` + +**成功响应**: + +- `GET` 返回 200 和图片字节,`Content-Type` 仅可能为 `image/jpeg`、`image/png` 或 `image/webp` +- `HEAD` 返回与 GET 相同的授权结果和响应头,但没有 body,也不会读取 MinIO 对象 body +- 返回 `Content-Length`、`Content-Disposition: inline`、`ETag`、`Last-Modified` 与 `X-Content-Type-Options: nosniff` +- 携带匹配的 `If-None-Match` 时返回 304;认证和 PublicVisibility 检查仍会执行 + +**缓存**: + +| 调用方式 | `Cache-Control` | +|---|---| +| 匿名读取具备 PublicVisibility 的 Cloud | `public, no-cache` | +| 任何有效 Cookie/Bearer 请求 | `private, no-store` | + +响应同时包含 `Vary: Authorization, Cookie`。公开缓存每次复用前都必须回源验证,因此 Cloud 被隐藏或改判后立即失效。 + +**错误**: + +| 状态码 | 触发条件 | 前端建议动作 | +|---|---|---| +| 400 | Cloud ID 或 ImageVariant 非法 | 检查 URL | +| 401 | 显式提供的 JWT 无效、过期或已失效 | 清除本地登录态 | +| 404 | Cloud 不存在、当前用户不可见,或对应 MinIO 对象不存在 | 统一显示占位图,不推断具体原因 | +| 405 | 使用 GET/HEAD 之外的业务方法;响应含 `Allow: GET, HEAD` | 修正请求方法 | +| 500 | 数据库或应用内部错误 | 稍后重试 | +| 503 | MinIO 暂时不可用或对象 metadata 异常 | 稍后重试 | + +具备 PublicVisibility 的 Cloud 允许被其他网站嵌入;流量限制由部署网关/CDN/WAF 负责。端点不支持 Range 请求。CORS 预检 `OPTIONS` 是协议级例外,由全局中间件返回 204,并只声明 `GET, HEAD`。 + +--- + +## 6. 端点 · 个人资料 `/profile` + +本章所有端点**都需要登录**(任意角色)。未登录统一返回 401(见 3.5),下文错误表不再重复列出。 + +### 6.1 `GET /profile/me` — 我的资料 + +- **认证**:需登录 + +**成功响应**:`200`,UserProfile 对象(字段见 1.5) + +```json +{ + "id": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d", + "name": "cloudwatcher", + "email": "watcher@example.com", + "avatar_id": null, + "cloud_count": 7, + "received_like_count": 42, + "last_online": "2026-08-15T08:30:00.000Z", + "role": "user", + "is_disabled": false, + "created_at": "2026-07-01T10:00:00.000Z" +} +``` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 404 | ⚠️ 数据库异常(空 `catch`,2.3 第 2 条) | 纯文本 `404 Not Found` | 视为服务异常,稍后重试 | + +### 6.2 `PATCH /profile/me` — 修改用户名 + +- **认证**:需登录 + +**请求体**(JSON): + +| 参数 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `user_name` | string | 是 | ⚠️ 数据库限 ≤ 16 字符,API 层不校验(超长返回 500) | 新用户名 | + +```json +{ + "user_name": "newname" +} +``` + +**成功响应**:`200`,更新后的 UserProfile 对象(结构同 6.1)。 + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | 参数校验失败 | zod 形状(2.1) | 检查字段 | +| 409 | 用户名已被占用(数据库唯一约束,大小写敏感) | `用户名已被占用` | 提示换用户名 | +| 500 | 服务端异常(含用户名超长) | `服务器发生内部错误` | 前端应限制 ≤ 16 字符避免误报 | + +### 6.3 `GET /profile/me/likes` — 我点赞过的云图 + +- **认证**:需登录 + +**成功响应**:`200`,CloudInfo 数组(字段见 1.5),按点赞时间倒序。 + +**注意事项**:包含**所有状态**的云图(含待审核/已驳回/已隐藏的),因为这些是你自己点过赞的——展示时如需隐藏非公开项请自行过滤 `status` 与 `is_hidden`。 + +**错误**:500 `服务器发生内部错误`。 + +### 6.4 `GET /profile/me/clouds` — 我上传的云图 + +- **认证**:需登录 + +**成功响应**:`200`,CloudInfo 数组(字段见 1.5),按上传时间倒序,包含所有状态(`pending`/`approved`/`rejected`)与隐藏的云图。这是上传后查看审核进度的入口。 + +**错误**:500 `服务器发生内部错误`。 + +### 6.5 `GET /profile/:user_id` — 查看用户资料 + +- **认证**:需登录(任意登录用户可查看任意用户) + +**路径参数**:`user_id`(uuid)。 + +**成功响应**:`200`,UserProfile 对象(结构同 6.1)。 + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | `user_id` 不是合法 uuid | zod 形状(2.1) | 检查链接 | +| 404 | 用户不存在 | `该用户不存在` | 提示用户不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +**注意事项**:⚠️ 响应包含对方 `email`(2.3 第 7 条)。展示他人资料页时不应展示邮箱字段。 + +### 6.6 `GET /profile/:user_id/likes` — 查看用户点赞记录 + +- **认证**:需登录,且为**本人或 admin** + +**路径参数**:`user_id`(uuid)。 + +**成功响应**:`200`,CloudInfo 数组,按点赞时间倒序,结构同 5.1。 + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 403 | 查看他人且非 admin | `无权查看该用户的点赞记录` | 前端应在进入页面前判断身份,避免触发 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +### 6.7 `GET /profile/:user_id/clouds` — 查看用户上传的云图 + +- **认证**:需登录,且为**本人或 admin** + +**路径参数**:`user_id`(uuid)。 + +**成功响应**:`200`,CloudInfo 数组,按上传时间倒序,结构同 5.1。 + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 403 | 查看他人且非 admin | `无权查看该用户的图片` | 同上,前置判断 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +**注意事项**:想查看某个用户的**公开**云图,应使用 `GET /cloud?filter=owner&value=<用户名>`(5.1)而非本端点——本端点是管理/个人视角,返回含非公开状态的完整列表。 + +--- + +## 7. 端点 · 信息 `/info` + +### 7.1 `GET /info/cloudtype` — 云类型列表 + +- **认证**:无(公开) + +**成功响应**:`200`,CloudTypeInfo 数组(字段见 1.5),按 `id` 升序: + +```json +[ + { + "id": 1, + "name": "积云", + "genus": "Cumulus", + "icon_id": null, + "rarity": 1, + "description": "底部平坦、顶部蓬松的白色云块", + "created_at": "2026-01-01 00:00:00+00" + } +] +``` + +**注意事项**:⚠️ 本端点的 `created_at` 是 PostgreSQL 原生格式(上例),**不是** ISO 8601(2.3 第 5 条),解析时自行兼容。 + +**错误**:500 `服务器发生内部错误`。 + +### 7.2 `GET /info/cloudtype/:cloud_type_id` — 云类型详情 + +- **认证**:无(公开) + +**路径参数**:`cloud_type_id`(int)。 + +**成功响应**:`200`,单个 CloudTypeInfo 对象;本端点的 `created_at` 为 ISO 8601 格式。 + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | `cloud_type_id` 非数字 | zod 形状(2.1) | 检查链接 | +| 404 | 云类型不存在 | `未找到` | 提示类型不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +--- + +## 8. 端点 · 管理 `/admin` + +本章所有端点**仅 admin 角色可用**。未登录返回 401;已登录但非 admin 统一返回 403 `权限不足`,下文错误表不再重复列出。 + +### 8.1 `GET /admin/stats` — 统计数据 + +> ⚠️ **未实现**(占位端点,无 handler)。admin 请求返回纯文本 `404 Not Found`;未登录/非 admin 仍按本章规则先返回 401/403。请勿接入。 + +### 8.2 `GET /admin/users` — 用户列表 + +- **认证**:admin + +**成功响应**:`200`,UserProfile 数组(字段见 1.5)。 + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 404 | ⚠️ 数据库异常(空 `catch`,2.3 第 2 条) | 纯文本 `404 Not Found` | 视为服务异常,稍后重试 | + +**注意事项**:**无分页参数**,一次返回全部用户。用户量大时注意性能。 + +### 8.3 `PATCH /admin/users/:user_id` — 修改用户角色 + +- **认证**:admin + +**路径参数**:`user_id`(uuid)。 + +**请求体**(JSON): + +| 参数 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `user_role` | enum | 是 | `user` / `admin` | 目标角色 | + +```json +{ + "user_role": "admin" +} +``` + +**成功响应**:`200` + +```json +{ + "status_code": 200, + "message": "角色修改成功" +} +``` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | 参数校验失败(`user_role` 非法 / `user_id` 非 uuid) | zod 形状(2.1) | 检查参数 | +| 404 | 用户不存在 | `用户不存在` | 提示用户不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +**注意事项**:修改角色会同时递增 `tokenVersion`,**该用户所有已签发 token 立即失效,必须重新登录**(见 3.4)。前端改完角色后可提示目标用户重新登录。 + +### 8.4 `PATCH /admin/users/:user_id/password` — 设置用户密码 + +- **认证**:admin + +**路径参数**:`user_id`(uuid)。 + +**请求体**(JSON): + +| 参数 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `new_password` | string | 是 | 无强度校验 | 新密码;管理员直接覆盖,无需旧密码 | + +```json +{ + "new_password": "new-s3cret-password" +} +``` + +**成功响应**:`200` + +```json +{ + "status_code": 200, + "message": "密码修改成功" +} +``` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | 参数校验失败(`user_id` 非 uuid) | zod 形状(2.1) | 检查参数 | +| 404 | 用户不存在(含凭据记录缺失) | `用户不存在` | 提示用户不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +**注意事项**:成功后同样递增 `tokenVersion`,该用户所有已签发 token 立即失效,必须重新登录(见 3.4)。 + +### 8.5 `POST /admin/user` — 创建用户 + +> ⚠️ **假端点**(2.3 第 4 条):通过 zod 校验后**原样回显请求体(含明文密码)**,不创建任何用户。响应为 `200 { name, email, password, role }`。请勿接入,更不要在任何持久化日志中记录其响应。 + +### 8.6 `GET /admin/clouds` — 全状态云图列表 + +- **认证**:admin;可见任意状态与隐藏的云图 + +**Query 参数**(在分页参数 1.4 基础上): + +| 参数 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `status` | enum | 否 | `pending` / `approved` / `rejected` | 按审核状态过滤;不传返回全部状态 | + +**成功响应**:`200`,CloudInfo 数组(字段见 1.5),按上传时间倒序。 + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | `status` 非法等参数错误 | zod 形状(2.1) | 检查参数 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +### 8.7 `POST /admin/clouds/review` — 批量审核云图 + +- **认证**:admin + +**请求体**(JSON): + +| 参数 | 类型 | 必填 | 约束 | 说明 | +|---|---|---|---|---| +| `cloud_ids` | array | 是 | 1–100 个元素,不允许重复 | 元素为 `{ "cloud_id": "" }` 对象 | +| `status` | enum | 是 | `approved` / `rejected` | 目标审核状态;**不支持打回 `pending`** | + +```json +{ + "cloud_ids": [ + { "cloud_id": "3f8a2c1e-7b9d-4e5f-a1c2-9d8e7f6a5b4c" } + ], + "status": "approved" +} +``` + +**成功响应**:`200` + +```json +{ + "status_code": 200, + "message": "成功审核 1 张" +} +``` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | ID 重复 / `status` 非法 / 数量超限 | zod 形状(2.1) | 检查请求 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +**注意事项**:**部分成功语义**——消息中的 `N` 只统计状态**实际发生变化**的云图;已是目标状态的与不存在的一律静默跳过。审核只改变审核状态,不影响 `is_hidden` 与点赞数据。`pending ↔ approved/rejected`、`approved ↔ rejected` 均可。 + +### 8.8 `DELETE /admin/clouds/:cloud_id` — 删除云图 + +- **认证**:admin + +**路径参数**:`cloud_id`(uuid)。 + +**成功响应**:`200` + +```json +{ + "status_code": 200, + "message": "删除成功" +} +``` + +**错误**: + +| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | +|---|---|---|---| +| 400 | `cloud_id` 非 uuid | zod 形状(2.1) | 检查链接 | +| 404 | 云图不存在 | `图片不存在` | 提示云图不存在 | +| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | + +**注意事项**:管理员删除不受审核状态或隐藏状态限制。删除会级联删除该云图的点赞记录,并尽力清理 MinIO 中的图片对象;清理失败不报错,可能残留孤儿对象(可接受)。删除后前端应立即刷新相关列表。 + +--- + +## 9. 端点 · 系统 + +### 9.1 `ALL /health` · 9.2 `ALL /status` + +> ⚠️ **均未实现**(占位端点,任意 HTTP 方法)。当前返回纯文本 `404 Not Found`。健康检查请直接探测任意已实现端点(如 `GET /info/cloudtype`)。 diff --git a/src/components/cloud/CloudEditModal.vue b/src/components/cloud/CloudEditModal.vue index bf1eab5..936fe71 100644 --- a/src/components/cloud/CloudEditModal.vue +++ b/src/components/cloud/CloudEditModal.vue @@ -1,17 +1,15 @@ - - - - diff --git a/src/components/cloud/ImageDetailModal.vue b/src/components/cloud/ImageDetailModal.vue index 182df62..4394349 100644 --- a/src/components/cloud/ImageDetailModal.vue +++ b/src/components/cloud/ImageDetailModal.vue @@ -49,10 +49,10 @@ const layout = computed(() => { ? naturalWidth.value / naturalHeight.value : 4 / 3 const maxModalWidth = viewportWidth.value > 0 - ? Math.max(Math.min(viewportWidth.value * 0.92, viewportWidth.value - 32), 280) + ? Math.max(Math.min(viewportWidth.value * 0.95, viewportWidth.value - 32), 280) : 320 const maxImageHeight = viewportHeight.value > 0 - ? Math.max(viewportHeight.value * 0.86, 240) + ? Math.max(viewportHeight.value * 0.9, 240) : 360 const imageWidth = Math.min(maxModalWidth, maxImageHeight * aspectRatio) @@ -187,11 +187,11 @@ onBeforeUnmount(() => { @@ -301,13 +301,7 @@ onBeforeUnmount(() => { transform: translateY(10px) scale(0.97); } -.image-detail-shell.image-detail-shell { - border-radius: 30px !important; -} - -.image-detail-close-button.image-detail-close-button, -.image-detail-info-button.image-detail-info-button, -.image-detail-panel-close.image-detail-panel-close { +.image-detail-info-button.image-detail-info-button { border-radius: 9999px !important; } diff --git a/src/components/cloud/QuickUploadModal.vue b/src/components/cloud/QuickUploadModal.vue index 66fcebb..f4249d6 100644 --- a/src/components/cloud/QuickUploadModal.vue +++ b/src/components/cloud/QuickUploadModal.vue @@ -91,9 +91,7 @@ function handleRemove(id: string) { function onCategoryChange(event: Event) { if (!activeItem.value) return const value = (event.target as HTMLSelectElement).value - if (value === 'other') { - activeItem.value.cloudCategoryId = 'other' - } else if (value === '') { + if (value === '') { activeItem.value.cloudCategoryId = null } else { activeItem.value.cloudCategoryId = Number(value) @@ -102,6 +100,7 @@ function onCategoryChange(event: Event) { } function formatDatetimeLocal(iso: string): string { + if (!iso) return '' const date = new Date(iso) const pad = (value: number) => String(value).padStart(2, '0') return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}T${pad(date.getHours())}:${pad(date.getMinutes())}` @@ -110,8 +109,7 @@ function formatDatetimeLocal(iso: string): string { function onCapturedAtChange(event: Event) { if (!activeItem.value) return const value = (event.target as HTMLInputElement).value - if (!value) return - activeItem.value.capturedAt = new Date(value).toISOString() + activeItem.value.capturedAt = value ? new Date(value).toISOString() : '' delete activeItem.value.errors.capturedAt } @@ -169,7 +167,7 @@ async function handleSubmit() { const result = await uploadAll() if (!result.ok) { - errorMsg.value = '上传失败,请稍后重试' + errorMsg.value = result.errorMessage || '上传失败,请稍后重试' return } @@ -217,7 +215,7 @@ onBeforeUnmount(() => {
- + 图片审核通过后会出现在画廊和地图中。 @@ -248,7 +246,7 @@ onBeforeUnmount(() => { >

选择一张云图

-

支持 JPG、PNG,可点击或拖拽上传。

+

支持 JPEG、PNG、WebP,图片不超过 20MB。