diff --git a/AGENTS.md b/AGENTS.md index cf71aa2..208d606 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,44 +5,44 @@ - `npm run dev` — Vite dev server with HMR - `npm run lint` / `npm run lint:fix` — ESLint checks / automatic fixes for JS, TS, and Vue; warnings fail the check - `npm run format:check` / `npm run format` — Prettier check / formatting for source, config, and docs -- `npm run check` — lint, formatting, style contracts, and typecheck +- `npm run check` — lint, formatting, style contracts, typecheck, and communication tests - `npm run check:styles` — validates shared style contracts and blocks deprecated palettes/classes - `npm run build` — `vue-tsc -b && vite build` (typecheck must pass or build aborts) - `npm run typecheck` — standalone Vue / TypeScript typecheck - `npm run preview` — previews the production build on strict port `5173` -- ESLint uses `eslint.config.js`; Prettier uses `.prettierrc.json`. Keep formatting rules in Prettier. No general test runner exists; `check:styles` remains the project-specific visual-system check. +- ESLint uses `eslint.config.js`; Prettier uses `.prettierrc.json`. Keep formatting rules in Prettier. Communication tests use Node’s built-in runner (`npm test`, Node 22.18+); `check:styles` checks the visual system. ## Architecture -- **Vue 3 + Vite + Tailwind CSS + Vue Router + Pinia + FastAPI + AMap (高德地图) + Naive UI** +- **Vue 3 + Vite + Tailwind CSS + Vue Router + Pinia + Hono + Better Auth + AMap (高德地图) + Naive UI** - Path alias `@/` → `src/` (configured in both `vite.config.ts` and `tsconfig.app.json`) -- FastAPI client at `src/lib/api.ts`; API base URL comes from `VITE_API_URL` +- HTTP transport at `src/lib/api.ts`; business calls in `features/{clouds,profile,admin}/api.ts`; API base URL comes from `VITE_API_URL` - AMap loaded lazily via `src/shared/map/amap.ts` with type declarations in `src/shared/map/types/amap.d.ts` - UI language is Chinese (zh-CN) -- All persistence, storage, authorization, and admin operations go through the FastAPI backend +- All persistence, storage, authorization, and admin operations go through the Hono backend ## Directory Map -| Path | Purpose | -| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------- | -| `src/features/` | Business modules; each owns its route views, components, stores, and composables as needed | -| `src/features/auth/` | Auth views, auth store, and Better Auth client | -| `src/features/clouds/` | Shared cloud detail/edit/like components, cloud-type and likes stores, cloud-type constants | -| `src/features/upload/` | UploadView, QuickUploadModal, and useUpload | -| `src/features/profile/` | Profile views, profile store, and ContributionHeatmap | -| `src/features/encyclopedia/` | Encyclopedia views and encyclopedia store | -| `src/features/{map,gallery,community,admin,system}/views/` | Remaining route views grouped by feature | -| `src/shared/map/` | Reusable location picker and mini map, AMap loader, and AMap declarations | -| `src/lib/` | Shared infrastructure: API client and adapters, SEO meta builder, Naive UI theme | -| `src/components/layout/` | AppHeader (top nav bar with auth state) | -| `src/style.css` | Global stylesheet assembly point; imports Tailwind, tokens, base rules, and shared components | -| `src/styles/` | Visual system source: semantic tokens, global square-corner baseline, shared `oc-*` component contracts | -| `src/lib/theme.ts` | Naive UI theme overrides aligned with the semantic tokens | -| `src/router/` | Route registration, lazy view imports, guards, and per-route SEO | -| `src/types/` | Shared domain models (`database.ts`), API DTOs (`api.ts`), and router meta (`router.d.ts`) | -| `docs/project-structure.md` | File ownership, dependency conventions, and examples for placing new code | -| `docs/style-system.md` | Concise implementation and naming guide for the visual system | -| `scripts/check-style-system.mjs` | Automated guard against deprecated palettes and malformed shared classes | +| Path | Purpose | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- | +| `src/features/` | Business modules; each owns its route views, components, stores, and composables as needed | +| `src/features/auth/` | Auth views, auth store, and Better Auth client | +| `src/features/clouds/` | Shared cloud detail/edit/like components, cloud-type and likes stores, cloud-type constants | +| `src/features/upload/` | UploadView, QuickUploadModal, and useUpload | +| `src/features/profile/` | Profile views, profile store, and ContributionHeatmap | +| `src/features/encyclopedia/` | Encyclopedia views and encyclopedia store | +| `src/features/{map,gallery,community,admin,system}/views/` | Remaining route views grouped by feature | +| `src/shared/map/` | Reusable location picker and mini map, AMap loader, and AMap declarations | +| `src/lib/` | Shared infrastructure: API client and adapters, SEO meta builder, Naive UI theme | +| `src/components/layout/` | AppHeader (top nav bar with auth state) | +| `src/style.css` | Global stylesheet assembly point; imports Tailwind, tokens, base rules, and shared components | +| `src/styles/` | Visual system source: semantic tokens, global square-corner baseline, shared `oc-*` component contracts | +| `src/lib/theme.ts` | Naive UI theme overrides aligned with the semantic tokens | +| `src/router/` | Route registration, lazy view imports, guards, and per-route SEO | +| `src/types/` | Frontend models (`models.ts`, `view-models.ts`), generated HTTP DTOs (`api-contract.ts`), and router meta (`router.d.ts`) | +| `docs/project-structure.md` | File ownership, dependency conventions, and examples for placing new code | +| `docs/style-system.md` | Concise implementation and naming guide for the visual system | +| `scripts/check-style-system.mjs` | Automated guard against deprecated palettes and malformed shared classes | - When adding or moving source files, follow `docs/project-structure.md`: colocate business code under its owning feature; keep reusable location primitives in `shared/map` and application layout in `components/layout`. - Import concrete files directly; route views remain lazy imports in `src/router/index.ts`. @@ -71,45 +71,35 @@ ## Auth Flow -- **`main.ts` initializes auth before mounting**: `authStore.initialize()` exchanges the HttpOnly refresh cookie for an access token. The app only mounts after this initial check. -- Access tokens live in memory only. Refresh cookies are managed by the backend and sent with `credentials: 'include'`. -- `src/lib/api.ts` performs one automatic refresh-and-retry after an authenticated request receives `401`. -- API auth state changes are synchronized back to the Pinia store through `opencloud:auth-updated` and `opencloud:auth-expired` window events. -- Email confirmation and password reset pages consume the opaque `token` query parameter generated by the backend. -- Passwords must contain at least 8 characters in registration, settings, and reset flows. -- Registration, email confirmation, login, password reset, profile creation, and uniqueness checks are backend responsibilities. +- `main.ts` calls `authStore.initialize()` before mounting: Better Auth checks the session cookie and then loads `/profiles/me`. +- The backend manages HttpOnly session cookies; browser requests use `credentials: 'include'`. +- `features/auth/authClient.ts` owns Better Auth SDK configuration and error adaptation. No custom access-token refresh or automatic mutation retry. +- Required business requests receiving 401 dispatch `opencloud:auth-expired`, which clears Pinia auth state. Public requests use `{ auth: false }` to suppress that event. +- Email confirmation and password reset consume the backend’s opaque `token` query parameter. Passwords require at least 8 characters. ## Stores - **auth** — User session, profile, login/register/logout, username/password update, password reset. `initialize()` must be called before app mounts. - **clouds** — Simple cache of cloud types returned by `GET /cloud-types`. Fetched once and shared across views. -- **encyclopedia** — Cloud types + user's collection (unlock state). Tracks `unlockPercent` for progress display. Depends on `authStore`. +- **encyclopedia** — Cloud-type catalog cache for encyclopedia pages; numeric rarity comes from the backend. - **profile** — User profile pages. Fetches profile + cloud list per-user. Supports update/delete/visibility-toggle with optimistic cache patching; backend deletion also removes stored files. -## FastAPI Backend +## Backend and API Conventions -- Backend project lives in the sibling directory `../opencloud-backend`. -- Default development API URL is `http://localhost:8000/api/v1`; set `VITE_API_URL` for deployed environments. -- Frontend route guards are for navigation UX only. The backend enforces ownership, authentication, admin roles, and disabled-account rules. -- Images are uploaded as multipart form data. The backend stores originals, creates thumbnails, blurs coordinates, writes database records, and atomically unlocks badges. - -## API Conventions - -- All requests must go through `src/lib/api.ts`; do not call `fetch` directly from views, stores, or composables. -- API paths passed to `apiRequest()` are relative to `VITE_API_URL`, for example `/clouds` rather than `/api/v1/clouds`. -- Public requests explicitly use `{ auth: false }`. Authenticated requests use the default and receive a Bearer access token. -- JSON request bodies are plain objects. Image uploads use `FormData`; do not set the multipart `Content-Type` header manually. -- FastAPI errors use `detail`; `ApiError` converts string and validation-array details into a user-facing message. -- Backend DTO fields use `snake_case`. Page/store adapters may expose display-only camelCase fields such as `cloudTypeName`. -- Paginated endpoints return `items`, `page`, `page_size`, `total`, and `total_pages`. -- Cloud batch mutation endpoints accept at most 100 IDs. Profile and admin code split larger selections into chunks of 100. +- Current backend: sibling `../hono-api` (Hono + Better Auth + PostgreSQL + object storage), default `http://localhost:3000`. +- When changing requests, DTOs, pagination, or authentication, read `docs/api-architecture.md`; the backend `API.md` lists endpoints and migration details. +- Views/stores call their feature API module; only feature API modules call `lib/api.ts`. Better Auth SDK calls stay in the auth module. +- Backend `src/schemas/` generates `contracts/api.ts`; run frontend `npm run api:sync` to update `src/types/api-contract.ts`. For coordinated changes, run backend `contract:check` and frontend `api:check`; do not hand-edit generated DTOs. +- Use `query` objects, plain JSON bodies, and typed upload input. The API module handles URL encoding and FormData construction. +- Business JSON uses snake_case and ISO UTC date strings. Pagination returns `items/page/page_size/has_more`; use the explicit flag for navigation. +- Business errors use `{message, issues?}`; Better Auth keeps its SDK protocol. `ApiError` preserves HTTP status and error details. +- Batch mutations accept at most 100 UUID strings; feature API modules split larger selections. Completed batches remain applied after later failures. +- Backend permissions enforce ownership, verified sessions, roles, and disabled accounts; route guards provide navigation UX only. ## Gallery Pagination -- Page-based navigation (50 items/page), not infinite scroll. -- The backend resolves cloud-type/custom-type searches and `@username` searches. -- Each response includes `items`, `total`, `page`, `page_size`, and `total_pages`. -- Search debounced at 250ms. +- Page-based navigation, 50 items/page, with a 250ms search debounce. +- Backend resolves cloud-type names and `@username` searches. Next-page availability comes from `has_more`. ## Map Timeline @@ -119,14 +109,14 @@ ## Upload Flow -- `useUpload` handles file selection, previews, EXIF date extraction, validation, sequential multipart uploads, progress, and badge results. Thumbnail generation, coordinate blurring, storage, database insertion, and badge unlocking run on the backend. +- `useUpload` handles selection, previews, EXIF dates, validation, sequential uploads and progress. The cloud API module encodes multipart data; the backend creates previews, stores variants and inserts records. Coordinates are blurred by the current frontend forms. - Batch items upload sequentially. If a later item fails, earlier successful uploads remain saved; there is no transactional rollback or resumable upload in the frontend. - `UploadView` is the full-page batch uploader. `QuickUploadModal` is a single-image shortcut from the map page. ## Build & Deploy - **Vercel** with `vercel.json`: SPA rewrites, security headers (X-Frame-Options, X-Content-Type-Options, Referrer-Policy, Permissions-Policy). No CSP or HSTS configured. -- Vite dev and preview servers both use strict port `5173`, matching the backend's default `CORS_ORIGINS` and `FRONTEND_URL`. +- Vite dev and preview servers both use strict port `5173`, matching the backend’s `CORS_ORIGIN` development setting. - **SEO plugin** in `vite.config.ts`: generates `robots.txt` and `sitemap.xml` at build time. Auth-only routes, including `/encyclopedia`, are excluded from the sitemap and disallowed in `robots.txt`. ## Environment Variables @@ -137,10 +127,10 @@ Required for map features: Optional: -- `VITE_API_URL` — FastAPI base URL including `/api/v1` (defaults to `http://localhost:8000/api/v1`) +- `VITE_API_URL` — Hono root URL (defaults to `http://localhost:3000`, without `/api/v1`) - `VITE_SITE_URL` — canonical frontend URL used by route SEO and generated sitemap; set it explicitly in deployed environments -Backend-only variables such as database credentials, JWT secrets, SMTP settings, cookie policy, CORS origins, and upload paths belong in `../opencloud-backend/.env`, never in the Vite environment. +Backend-only variables such as database credentials, Better Auth secrets, email-provider settings, cookie policy, CORS origins, and object-storage settings belong in `../hono-api/.env`, never in the Vite environment. ## Visual System diff --git a/API.md b/API.md index a3abe90..7a6f104 100644 --- a/API.md +++ b/API.md @@ -1,1088 +1,9 @@ -# OpenCloud API 前端开发文档 +# OpenCloud API 文档入口 -> **读者**:前端开发者。本文档覆盖 API 的全部端点:请求参数、响应结构、可能出现的错误与已知坑点。 -> -> **事实来源**:业务接口以 `src/schema/*.ts` 与 `src/router/*.ts` 为准;认证以 `src/auth.ts` 中的 Better Auth 配置、`src/index.ts` 中的挂载路径及 `src/middleware/auth.ts` 中的业务鉴权规则为准。若文档与代码不一致,以代码为准。 -> -> **重要免责**:错误消息文案可能调整。**前端逻辑应依赖 HTTP 状态码及需要时的机器可读错误码,严禁匹配错误消息字符串**。文档中列出消息原文仅供调试对照。 +当前前端接入 Hono 后端 `hono-api`,默认 API 根地址为 `http://localhost:3000`。 -## 目录 +- 前端分层、Cookie 认证、分页和契约同步:[通信架构](docs/api-architecture.md)。 +- 完整端点、请求字段、权限与迁移表:相邻后端仓库的 [API.md](../hono-api/API.md)。 +- 前端使用的生成契约:[api-contract.ts](src/types/api-contract.ts)。 -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)。Better Auth 的 `/auth/*` 有独立响应格式,部分操作也可能重定向,见第 4 章。响应携带两个调试响应头: - -- `X-Request-Id`:请求唯一 ID,反馈问题时请附上 -- `X-Response-Time`:服务端处理耗时,如 `12ms` - -### 1.2 命名与格式 - -- 业务接口使用 **snake_case**(如 `cloud_id`、`page_size`);Better Auth 的 `/auth/*` 接口使用其原生 **camelCase** 字段 -- 日期时间一律为 **ISO 8601 字符串**(如 `2026-08-15T08:30:00.000Z`),可空的日期字段值为 `null`。唯一的例外见 2.3 第 5 条 -- UUID 字段为标准 UUID 字符串 - -### 1.3 响应形态 - -业务成功响应**没有统一包装**,分两种形态;`/auth/*` 不使用下表中的业务信封: - -| 场景 | 形态 | 示例 | -| -------------------------------------------- | -------- | ----------------------------------------------- | -| 写操作成功(POST/PATCH/DELETE 的动作类端点) | 信封对象 | `{ "status_code": 200, "message": "删除成功" }` | -| 读操作成功(业务 GET) | 裸数据 | 单资源为对象、列表为**数组** | - -### 1.4 分页 - -所有分页端点使用相同的 query 参数: - -| 参数 | 类型 | 必填 | 约束 | 说明 | -| ----------- | ---- | ---- | ---------------- | -------- | -| `page` | int | 否 | ≥ 1,默认 `1` | 页码 | -| `page_size` | int | 否 | 1–100,默认 `50` | 每页数量 | - -⚠️ **响应不返回总数**,也没有 `has_more` 字段。判断「是否还有下一页」只能依靠「返回数组长度 < `page_size`」。超出数据范围的页返回 `200` + 空数组 `[]`,不是 404。 - -云图列表排序为 `uploaded_at` 倒序(同刻按 `id` 倒序);点赞列表按点赞时间倒序(同刻按云图 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 条的暴露范围问题) | -| `image` | string\|null | 用户头像 URL,由 Better Auth 管理;未设置时为 `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 两种错误响应形状 - -业务接口存在**两种**错误响应形状,前端必须分别处理。`/auth/*` 使用 Better Auth 自己的错误格式,见 3.5。 - -**① 业务错误**(绝大多数错误)——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 | 业务接口没有有效 Better Auth 会话,或邮箱未验证、帐号被禁用 | 重新获取会话,必要时引导登录或验证邮箱 | -| 403 | 业务接口已认证但权限不足,如角色不足或越权访问他人资源 | 按场景提示 | -| 404 | 资源不存在(形状①);**也可能是纯文本**(见下方警告) | 区分 content-type 后处理 | -| 405 | HTTP 方法不受端点支持 | 读取 `Allow` 响应头 | -| 409 | 部分业务接口发生唯一性冲突,如管理员创建用户或修改用户名 | 提示用户更换;普通注册见 3.5 | -| 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) 未匹配任何路由的路径。Better Auth 认证端点使用自己的错误格式。 -3. **认证端点使用 Better Auth 格式**:`/auth/*` 的字段、状态码与错误响应由 Better Auth 定义,不能用业务错误信封解析;见第 4 章。 -4. **管理员创建用户需验证邮箱**:`POST /admin/user` 会通过 Better Auth 创建用户并尝试发送验证邮件;验证前不能登录。 -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. **用户名/邮箱数据库长度限制**:用户名 ≤ 16 字符、邮箱 ≤ 64 字符;前端应限制输入长度。 -7. **`GET /profile/:user_id` 向任意登录用户暴露对方邮箱**:任何登录用户都能查到任意用户的 `email`。前端展示他人资料时不应展示邮箱,也不应假设该接口未来仍返回邮箱。 -8. **用户名大小写**:数据库唯一约束区分大小写,注册和改名均受该约束。 - ---- - -## 3. 认证 - -### 3.1 从旧认证接口迁移 - -后端现在由 Better Auth 处理注册、登录、邮箱验证、会话和密码。旧认证路由已移除;请替换所有旧请求与响应解析: - -| 旧接口 | 当前接口 / 前端动作 | 主要变化 | -| -------------------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------ | -| `POST /auth/register` | `POST /auth/sign-up/email` | 请求仍为 `name`、`email`、`password`;成功为 Better Auth 的 `200`,不会自动登录 | -| `POST /auth/login` | `POST /auth/sign-in/email` | 使用 Better Auth 会话 Cookie,不再保存旧 JWT | -| `POST /auth/logout` | `POST /auth/sign-out` | 撤销当前会话;不要只清除前端状态 | -| `POST /auth/resend-confirmation` | `POST /auth/send-verification-email` | 请求 `{ "email": "..." }` | -| `POST /auth/confirm-email` | `GET /auth/verify-email?token=...` | token 从前端验证页的 URL 读取,放入查询参数 | -| `POST /auth/forgot-password` | `POST /auth/request-password-reset` | 请求 `{ "email": "..." }` | -| `POST /auth/reset-password` | `POST /auth/reset-password` | 路径相同,请求改为 `{ "token": "...", "newPassword": "..." }`;不再提交 `email` / `new_password` | -| `PATCH /auth/password` | `POST /auth/change-password` | 请求改为 `currentPassword`、`newPassword`,可选 `revokeOtherSessions` | - -旧路由的 `status_code` / `message` 信封、JWT 及认证相关的 snake_case 请求字段不适用于新端点。普通注册不要提交 `role`:角色由服务端设置为 `user`;创建管理员用户走第 8 章的管理接口。业务接口的路径与 snake_case 字段仍按后续章节使用。 - -### 3.2 浏览器接入 - -前端安装与后端当前版本一致的 `better-auth@1.7.6`,创建客户端时将 **API 地址连同 `/auth` 路径**传入 `baseURL`。服务端挂载路径是 `/auth`,不是 Better Auth 默认的 `/api/auth`;不要把业务 API 根地址直接用作认证客户端的完整 `baseURL`。下面是框架无关的示例;React 可从 `better-auth/react` 导入 `createAuthClient`。这与 [Better Auth 客户端的自定义路径配置](https://better-auth.com/docs/concepts/client)一致。 - -```ts -import { createAuthClient } from 'better-auth/client' - -const API_ORIGIN = 'http://localhost:3000' // 生产环境改成实际 API origin -export const authClient = createAuthClient({ - baseURL: `${API_ORIGIN}/auth`, - fetchOptions: { credentials: 'include' }, -}) -``` - -Better Auth 浏览器客户端默认会携带凭证;上面显式写出该选项,便于与业务请求保持一致。业务接口使用原生 `fetch` 或其他 HTTP 客户端时,也要单独配置: - -```ts -const response = await fetch(`${API_ORIGIN}/profile/me`, { - credentials: 'include', -}) -``` - -浏览器会自动保存并发送 HttpOnly 会话 Cookie。不要尝试在 JavaScript 中读取 Cookie,也不要将登录响应中的 token 当作旧 JWT 存储。服务端 `CORS_ORIGIN` 必须与前端 origin(协议、域名、端口)精确匹配;生产环境 Cookie 为 `SameSite=None; Secure`,本地开发为 `SameSite=Lax`。`BETTER_AUTH_URL` 是后端公开地址;前端客户端仍需使用上面含 `/auth` 的 URL。跨源接入和凭证设置也见 [Better Auth 的 Hono 集成文档](https://better-auth.com/docs/integrations/hono)。 - -### 3.3 会话与业务权限 - -登录成功后,使用 `authClient.getSession()` 或对应框架的 `useSession()` 获取会话: - -```ts -const { error } = await authClient.signIn.email({ email, password }) -if (error) throw error - -const { data: session } = await authClient.getSession() -// session 为 { user, session } 或 null;退出时调用 await authClient.signOut()。 -``` - -需要业务资料、统计及角色时,再请求 `GET /profile/me`,不要将 Better Auth 的 `user` 对象当作第 1.5 节的 UserProfile。完整客户端方法见 [Better Auth 会话管理](https://better-auth.com/docs/concepts/session-management)。 - -头像使用 Better Auth 的 `user.image` 字段。前端可调用 `authClient.updateUser({ image: "https://example.com/avatar.png" })` 设置头像 URL;业务资料接口和管理员用户列表均返回同一 `image` 值。原 `avatar_id` 字段已移除。 - -所有受保护的业务接口都再次检查会话、邮箱验证状态、禁用状态及角色。**业务接口**在无会话、邮箱未验证或帐号被禁用时返回 `401`;会话有效但角色或资源权限不足时返回 `403`。管理员修改角色或密码后会撤销目标用户的全部会话,前端应刷新登录状态。 - -两种可选认证端点行为不同:`GET /cloud/:cloud_id` 对无效/过期凭证按匿名处理;`GET|HEAD /image/:cloud_id/:variant` 在显式提供无效凭证时返回 `401`。同时发送 `Authorization` 和 Cookie 时,业务接口优先使用 Bearer 凭证。 - -### 3.4 邮件与密码页面 - -注册后会发送验证邮件,用户需先验证邮箱再登录;验证成功后也不会自动登录。当前邮箱验证 token 有效期为 24 小时,密码重置 token 默认有效期为 1 小时。后端发出的邮件仍指向前端 `/verify-email?token=...` 与 `/reset-password?email=...&token=...` 页面;它们是**前端页面**,不是 API。验证页应将 token 交给 `GET /auth/verify-email?token=...`,重置页应以 token 和新密码调用 `POST /auth/reset-password`。邮件中的 `email` 可用于页面展示,重置请求不需要它。密码重置成功会撤销该用户的全部会话。 - -重发验证邮件使用 `authClient.sendVerificationEmail({ email })`;申请密码重置使用 `authClient.requestPasswordReset({ email })`。用户不存在时,密码重置申请仍返回通用成功结果,不要据此判断邮箱是否已注册。验证及重置流程见 [Better Auth 邮箱密码文档](https://better-auth.com/docs/authentication/email-password)。 - -### 3.5 认证错误与非浏览器客户端 - -Better Auth 端点的错误是其原生结构,通常包含 `code`、`message`;客户端调用返回 `{ data, error }`。按 HTTP 状态、客户端的 `error` 和必要时的 `error.code` 处理,不要匹配英文错误文案,也不要按业务接口的 `status_code` 或 Zod 形状解析。未验证邮箱登录会被 Better Auth 拒绝(当前版本的错误码为 `EMAIL_NOT_VERIFIED`),此时可显示验证页并提供重发邮件操作。 - -当前配置要求邮箱验证,且注册后不自动登录。Better Auth 对重复邮箱注册可能返回与新邮箱相同的 **200** 响应和临时生成的用户对象;因此注册成功仅表示「请查收验证邮件」,**不能仅凭响应中的 `user.id` 判断账户已创建,也不能将其用作已登录用户 ID**。应在验证后重新登录。该行为来自当前安装的 Better Auth `1.7.6`。 - -非浏览器客户端可使用已启用的 Bearer 插件。登录成功响应头 `set-auth-token` 暴露会话 token,后续请求使用 `Authorization: Bearer `;这不是旧 JWT,也不是密码重置或邮箱验证 token。前端浏览器仍使用 Cookie。[Better Auth Bearer 插件说明](https://better-auth.com/docs/plugins/bearer)。 - -## 4. 端点 · 认证 `/auth` - -以下为本项目已启用的 Better Auth 邮箱密码功能,密码至少 8 字符。请求 JSON 使用原生 **camelCase**,响应不带业务 `status_code` 信封。前端推荐调用第 3.2 节的客户端;直接请求时使用表中方法和路径。Better Auth 还会暴露其他内置或 Admin 插件端点,其输入、权限和响应以 [Better Auth 官方文档](https://better-auth.com/docs)及当前安装版本为准。 - -| 操作 | 方法与路径 | Better Auth 客户端方法 | 请求与结果要点 | -| ------------ | ------------------------------------ | ---------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | -| 注册 | `POST /auth/sign-up/email` | `authClient.signUp.email({ name, email, password })` | 成功 `200`,`{ token: null, user }`;发送验证邮件,不建立会话 | -| 登录 | `POST /auth/sign-in/email` | `authClient.signIn.email({ email, password })` | 验证邮箱后才可登录;成功 `200` 并设置会话 Cookie | -| 当前会话 | `GET /auth/get-session` | `authClient.getSession()` | 有效会话返回 `{ user, session }`;无会话返回 `null` | -| 登出当前会话 | `POST /auth/sign-out` | `authClient.signOut()` | 需携带当前 Cookie 或 Bearer;撤销当前会话 | -| 重发验证邮件 | `POST /auth/send-verification-email` | `authClient.sendVerificationEmail({ email })` | `{ email }`;匿名请求对不存在或已验证邮箱也可能返回 `{ status: true }` | -| 确认邮箱 | `GET /auth/verify-email?token=...` | 可用 `fetch` 发 GET | 查询参数 `token`;无 `callbackURL` 时成功返回包含 `status: true` 的 JSON | -| 申请密码重置 | `POST /auth/request-password-reset` | `authClient.requestPasswordReset({ email })` | `{ email }`;返回通用结果,不泄露账户是否存在 | -| 重置密码 | `POST /auth/reset-password` | `authClient.resetPassword({ token, newPassword })` | `{ token, newPassword }`;成功 `{ status: true }`,撤销该用户全部会话 | -| 修改当前密码 | `POST /auth/change-password` | `authClient.changePassword({ currentPassword, newPassword, revokeOtherSessions })` | 需登录;可选择撤销其他会话 | - -验证邮箱页面的请求示例: - -```ts -const token = new URLSearchParams(location.search).get('token') -if (!token) throw new Error('缺少验证 token') - -const response = await fetch(`${API_ORIGIN}/auth/verify-email?token=${encodeURIComponent(token)}`, { - credentials: 'include', -}) -if (!response.ok) throw new Error('邮箱验证失败') -// 验证成功后引导用户登录;当前配置不会自动创建会话。 -``` - -上面的 `API_ORIGIN` 与第 3.2 节相同。直接调用 `GET /auth/verify-email` 时不需要旧接口的 JSON 请求体;若传入 `callbackURL`,Better Auth 可能改为重定向,前端应按重定向处理。 - -管理员的业务管理操作继续使用第 8 章 `/admin/*` 路由。它们保持原业务响应格式,并在修改角色或密码后撤销目标会话;直接调用 Better Auth Admin 插件端点不会自动执行这些业务路由的附加操作。 - ---- - -## 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 可见全部 -- 无效/过期会话凭证按匿名处理,**不会**返回 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 `PUT /cloud/:cloud_id/like` — 点赞 - -- **认证**:需登录(任意角色) -- **请求体**:无 - -**成功响应**:`200` - -```json -{ - "status_code": 200, - "message": "点赞成功" -} -``` - -**错误**: - -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -| ------ | ------------------------------ | -------------------- | -------------------------------------------- | -| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 | -| 404 | 云图不存在,或不是公开可见状态 | `图片不存在` | 提示云图不可点赞;与「不存在」共用是设计意图 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | - -**注意事项**:**幂等**——重复点赞返回相同的 200,点赞数不会增加。旧 `POST /cloud/:cloud_id/like` 已移除;前端改用 `PUT`。前端无需在点击前查询是否已赞,但仍建议本地置灰防止连点。 - -### 5.6 `DELETE /cloud/:cloud_id/like` — 取消点赞 - -- **认证**:需登录(任意角色) -- **请求体**:无 - -**成功响应**:`200` - -```json -{ - "status_code": 200, - "message": "取消点赞成功" -} -``` - -**错误**: - -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -| ------ | ------------------- | -------------------- | ------------ | -| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 | -| 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.3 | 引导登录 | -| 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.3 | 引导登录 | -| 404 | 云图不存在或不属于当前用户 | `图片不存在` | 提示云图不存在 | -| 500 | 服务端异常(含 `type_id` 不存在) | `服务器发生内部错误` | 稍后重试 | - -### 5.9 `DELETE /cloud/:cloud_id` — 删除云图 - -- **认证**:需登录,且为上传者本人(非本人返回 404,同 5.8) - -**成功响应**:`200`,`{ "status_code": 200, "message": "删除成功" }` - -**错误**: - -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -| ------ | -------------------------- | -------------------- | -------------- | -| 401 | 未登录 / token 失效 | 见 3.3 | 引导登录 | -| 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.3 | 引导登录 | -| 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 | 显式提供的 Better Auth 会话无效或过期 | 清除本地登录态 | -| 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.3),下文错误表不再重复列出。 - -### 6.1 `GET /profile/me` — 我的资料 - -- **认证**:需登录 - -**成功响应**:`200`,UserProfile 对象(字段见 1.5) - -```json -{ - "id": "a1b2c3d4-e5f6-4a5b-8c9d-0e1f2a3b4c5d", - "name": "cloudwatcher", - "email": "watcher@example.com", - "image": "https://example.com/avatar.png", - "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` — 我点赞过的云图 - -- **认证**:需登录 -- **Query 参数**:分页参数 `page`、`page_size`,见 1.4 - -**成功响应**:`200`,CloudInfo 数组(字段见 1.5),按点赞时间倒序、同刻按云图 ID 倒序;默认返回第 1 页的 50 条,最多每页 100 条。超出范围的页返回 `[]`。 - -**注意事项**:包含**所有状态**的云图(含待审核/已驳回/已隐藏的),因为这些是你自己点过赞的——展示时如需隐藏非公开项请自行过滤 `status` 与 `is_hidden`。 - -**错误**:分页参数非法时返回 400(见 2.1);服务端异常返回 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/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) | 检查参数 | -| 400 | 管理员尝试撤销自己的 admin 角色 | `不能撤销自己的管理员角色` | 保持当前管理员角色 | -| 404 | 用户不存在 | `用户不存在` | 提示用户不存在 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | - -**注意事项**:修改角色会撤销目标用户的全部 Better Auth 会话,必须重新登录。前端改完角色后可提示目标用户重新登录。 - -### 8.4 `PATCH /admin/users/:user_id/password` — 设置用户密码 - -- **认证**:admin - -**路径参数**:`user_id`(uuid)。 - -**请求体**(JSON): - -| 参数 | 类型 | 必填 | 约束 | 说明 | -| -------------- | ------ | ---- | ----------- | ---------------------------------- | -| `new_password` | string | 是 | 至少 8 字符 | 新密码;管理员直接覆盖,无需旧密码 | - -```json -{ - "new_password": "new-s3cret-password" -} -``` - -**成功响应**:`200` - -```json -{ - "status_code": 200, - "message": "密码修改成功" -} -``` - -**错误**: - -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -| ------ | --------------------------------------------------- | -------------------- | -------------- | -| 400 | 参数校验失败(`user_id` 非 uuid 或密码少于 8 字符) | zod 形状(2.1) | 检查参数 | -| 404 | 用户不存在(含凭据记录缺失) | `用户不存在` | 提示用户不存在 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | - -**注意事项**:成功后会撤销目标用户的全部 Better Auth 会话,必须重新登录。 - -### 8.5 `POST /admin/user` — 创建用户 - -管理员认证后提交 `{ "name": "Alice", "email": "alice@example.com", "password": "password123", "role": "user" }`。通过 Better Auth Admin 插件创建用户和密码凭据,再尝试发送邮箱验证邮件。成功返回 `201`,包含 `status_code`、`message`、`user_id`、`email_sent`;用户名或邮箱冲突返回 `409`。密码至少 8 字符。 - -### 8.6 `POST /admin/cloudtypes` — 添加云类型 - -- **认证**:admin - -**请求体**(JSON): - -| 参数 | 类型 | 必填 | 约束 | 说明 | -| ------------- | ------------ | ---- | ------------------ | ----------------------------- | -| `name` | string | 是 | 去除首尾空白后非空 | 云类型名称 | -| `genus` | string\|null | 否 | 默认 `null` | 属;传 `null` 表示未设置 | -| `icon_id` | uuid\|null | 否 | 默认 `null` | 图标 ID;传 `null` 表示未设置 | -| `rarity` | int | 是 | — | 稀有度 | -| `description` | string | 是 | 最长 128 字符 | 描述,可为空字符串 | - -```json -{ - "name": "积云", - "genus": "Cumulus", - "icon_id": null, - "rarity": 1, - "description": "底部平坦、顶部蓬松的白色云块" -} -``` - -**成功响应**:`201` - -```json -{ - "status_code": 201, - "message": "云类型添加成功", - "cloud_type_id": 12 -} -``` - -云类型 ID 由服务端生成;并发添加不会分配重复 ID。参数校验失败返回 400,数据库异常返回 500。 - -### 8.7 `PATCH /admin/cloudtypes/:cloud_type_id` — 修改云类型 - -- **认证**:admin - -**路径参数**:`cloud_type_id`(int)。请求体字段与 8.6 相同,但均为可选;至少须提供一个字段。仅提交的字段会被修改,`genus` 和 `icon_id` 可显式传 `null` 清空。 - -**成功响应**:`200` - -```json -{ - "status_code": 200, - "message": "云类型修改成功" -} -``` - -**错误**:参数或空请求体无效返回 400;云类型不存在返回 404 `云类型不存在`;数据库异常返回 500。 - -### 8.8 `DELETE /admin/cloudtypes/:cloud_type_id` — 删除云类型 - -- **认证**:admin - -**路径参数**:`cloud_type_id`(int)。 - -**成功响应**:`200` - -```json -{ - "status_code": 200, - "message": "云类型删除成功" -} -``` - -**错误**:参数无效返回 400;云类型不存在返回 404 `云类型不存在`;仍有云图使用该类型时返回 409 `云类型仍被云图使用,无法删除`;其他数据库异常返回 500。 - -### 8.9 `GET /admin/clouds` — 全状态云图列表 - -- **认证**:admin;可见任意状态与隐藏的云图 - -**Query 参数**(在分页参数 1.4 基础上): - -| 参数 | 类型 | 必填 | 约束 | 说明 | -| -------- | ---- | ---- | ----------------------------------- | -------------------------------- | -| `status` | enum | 否 | `pending` / `approved` / `rejected` | 按审核状态过滤;不传返回全部状态 | - -**成功响应**:`200`,CloudInfo 数组(字段见 1.5),按上传时间倒序。 - -**错误**: - -| 状态码 | 触发条件 | 消息原文 | 前端建议动作 | -| ------ | ----------------------- | -------------------- | ------------ | -| 400 | `status` 非法等参数错误 | zod 形状(2.1) | 检查参数 | -| 500 | 服务端异常 | `服务器发生内部错误` | 稍后重试 | - -### 8.10 `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.11 `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 中的图片对象;清理失败不报错,可能残留孤儿对象(可接受)。删除后前端应立即刷新相关列表。 - -### 8.12 `GET /admin/users/:user_id/likes` — 查看用户点赞记录 - -- **认证**:admin -- **路径参数**:`user_id`(uuid) -- **Query 参数**:分页参数 `page`、`page_size`,见 1.4 - -**成功响应**:`200`,与 6.3 相同的 CloudInfo 数组和排序,包含该用户已点赞但后来被隐藏或改判的云图;没有点赞或页码超出范围时返回 `[]`。 - -**错误**:`user_id` 或分页参数非法时返回 400(见 2.1);用户不存在时返回 404 `{ "status_code": 404, "error": "用户不存在" }`;服务端异常返回 500 `服务器发生内部错误`。非管理员统一返回 403(本章规则)。旧 `GET /profile/:user_id/likes` 已移除;本人使用 6.3,管理员使用本端点。 - ---- - -## 9. 端点 · 系统 - -### 9.1 `ALL /health` · 9.2 `ALL /status` - -> ⚠️ **均未实现**(占位端点,任意 HTTP 方法)。当前返回纯文本 `404 Not Found`。健康检查请直接探测任意已实现端点(如 `GET /info/cloudtype`)。 +前端不再维护后端手册的副本。修改后端 schema 后先生成契约,再执行 `npm run api:sync` 和 `npm run api:check`;前后端配套检查通过后一起发布。本次资源路径、字段名与分页格式有破坏性变化,迁移明细以当前后端文档为准。 diff --git a/README.md b/README.md index eab26f0..db55f6d 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # OpenCloud 前端 -Vue 3 + TypeScript + Vite 前端,通过 FastAPI 提供认证、云图、图鉴、个人主页和管理后台数据。 +Vue 3 + TypeScript + Vite 前端,通过 Hono 后端和 Better Auth 提供认证、云图、图鉴、个人主页和管理后台数据。 ## 本地开发 -1. 在相邻的 `opencloud-backend` 项目中启动 FastAPI 服务。 +1. 在相邻的 `hono-api` 项目中启动后端服务(该仓库的本地启动方式见其 README)。 2. 复制 `.env.example` 为 `.env`,配置高德地图 Key;如果后端不在默认地址,修改 `VITE_API_URL`。 3. 安装依赖并启动前端: @@ -13,7 +13,7 @@ npm install npm run dev ``` -默认后端地址为 `http://localhost:8000/api/v1`。 +默认后端地址为 `http://localhost:3000`。 开发服务器和本地 Preview 都固定使用 `http://localhost:5173`,与后端默认 CORS 配置保持一致。运行 Preview 前请先停止开发服务器。 ## 检查与构建 @@ -23,7 +23,7 @@ npm run check npm run build ``` -`check` 依次执行 ESLint、Prettier 格式检查、项目样式检查和 TypeScript 类型检查。也可以单独运行: +`check` 依次执行 ESLint、Prettier 格式检查、项目样式检查、TypeScript 类型检查和通信层测试。也可以单独运行: ```bash npm run lint # 检查 JS、TS 和 Vue 代码,警告也会导致失败 @@ -45,3 +45,5 @@ VS Code 用户可安装工作区推荐的 Vue、ESLint、Prettier 扩展,保 业务代码按功能集中在 `src/features/`:页面、组件、store 和 composable 放入各自所属模块。跨业务的地图控件集中在 `src/shared/map/`,应用导航位于 `src/components/layout/`,API 客户端、全局样式和公共类型保留独立目录。 新增或移动文件时,参见[目录结构与依赖约定](docs/project-structure.md)。 + +前后端配套修改时,参见[通信架构与契约同步](docs/api-architecture.md),先生成并同步契约,再运行 `npm run api:check`。该检查需要相邻的后端仓库;前端独立构建使用已提交的类型快照。 diff --git a/docs/api-architecture.md b/docs/api-architecture.md new file mode 100644 index 0000000..a1a4eec --- /dev/null +++ b/docs/api-architecture.md @@ -0,0 +1,55 @@ +# 前后端通信约定 + +当前后端是相邻的 `hono-api` 仓库,默认根地址 `http://localhost:3000`。业务接口由 Hono 提供,认证由 Better Auth 提供;旧 `opencloud-backend` FastAPI 项目不参与当前调用。 + +## 职责 + +| 层 | 文件 | 负责内容 | +| ------------ | ------------------------------------------------- | ----------------------------------------------------------- | +| HTTP 传输 | `src/lib/api.ts` | Cookie、查询编码、JSON/FormData、HTTP 错误和 401 事件 | +| 业务调用 | `src/features/{clouds,profile,admin}/api.ts` | 资源路径、输入/输出类型、批量分块、分页收集、响应适配 | +| 响应适配 | `src/lib/api-adapters.ts` | 生成 DTO 到前端视图模型的明确映射,图片 URL | +| 后端契约 | `src/types/api-contract.ts` | 从后端 schema 生成并同步,禁止手改 | +| 前端模型 | `src/types/models.ts`、`src/types/view-models.ts` | 页面需要的 Cloud、CloudType、Profile、CloudDetail、AuthUser | +| 认证客户端 | `src/features/auth/authClient.ts` | Better Auth SDK 与其独立错误协议 | +| 页面与 store | `src/features/` | 交互、缓存、业务展示;调用业务 API 模块 | + +页面不再直接调用 `apiRequest`;ESLint 会检查该规则。传输层不引用云图/用户模型,也不承担认证 SDK 的职责。后端数据库命名、HTTP 字段命名和页面显示字段各自有明确适配位置。稀有度统一使用后端数值,不再伪造 `common` 分类。 + +## 请求与响应 + +`apiRequest` 的路径相对于后端根地址,如 `/clouds`,不要加入 `/api/v1`。查询条件通过 `query` 对象传入,由传输层编码一次。JSON body 是普通对象,上传模块接受类型化参数并构建 FormData,浏览器生成 multipart Content-Type。 + +所有请求携带 Cookie。`auth: false` 表示该公开请求的 401 不触发清空登录状态,并不禁止携带 Cookie。需要登录的请求默认派发 `opencloud:auth-expired`。传输层不会重试写操作;网络错误、非法成功响应及结构化 HTTP 错误会抛给调用者。 + +业务错误为 `{message, issues?}`,保存在 `ApiError.message/status/detail`。成功 JSON 响应必须能解析;204 返回 undefined。图片 URL 直接用于媒体元素,不通过 JSON 请求函数。 + +云图分页使用 `{items, page, page_size, has_more}`。页面通过 `has_more` 控制下一页;热力图、点赞集合与当前管理统计需要完整集合时,由业务 API 使用 `collectPages` 收集。地图为上限 1000 条的时间范围数组,用户管理与云类型目录目前为非分页数组。没有实时订阅或跨页快照。 + +批量变更每次最多 100 个 UUID。已完成的删除批次立即修补缓存,后续批次失败不会回滚。上传仍逐张进行,失败前已上传的云图保留;没有恢复上传或自动整批重试。 + +## 契约同步与验证 + +后端 `src/schemas/` 是单一事实来源。后端生成的 `contracts/api.ts` 只含 HTTP 类型,不引入数据库、SDK、服务端配置或运行时包。前端保存类型快照,独立构建不依赖旁边存在后端仓库。 + +两仓库配套修改时: + +```bash +# 在 hono-api 中 +npm run contract:generate +npm run check + +# 在 opencloud 中 +npm run api:sync +npm run api:check +npm run check +npm run build +``` + +后端不在默认位置时,设置 `OPENCLOUD_API_DIR` 为实际路径。`api:check` 比较已生成的后端类型与前端快照,不会替代后端的 `contract:check`;两者都通过才能确认契约一致。前端 `npm test` 使用 Node 内置测试与 TypeScript 类型剥离,需要 Node 22.18+,不新增测试框架依赖。 + +## 部署与迁移 + +此次改动需要两端一起发布或回滚。旧路径 `/cloud`、`/profile`、`/image`、`/info/cloudtype` 改为 `/clouds`、`/profiles`、`/images`、`/cloud-types`;旧请求字段 `type_id/user_name/user_role` 改为 `cloud_type_id/username/role`。云图响应使用 `cloud_type/review_status`,日期统一 ISO UTC,批量 ID 为字符串数组。 + +认证仍在 `/auth/*`,VITE_API_URL 仍为后端根地址。完整端点与迁移表在后端 `API.md`。本次不需要数据库迁移,TypeScript 字段改名显式映射原数据库列。 diff --git a/docs/diagrams/current-architecture.svg b/docs/diagrams/current-architecture.svg new file mode 100644 index 0000000..375e78b --- /dev/null +++ b/docs/diagrams/current-architecture.svg @@ -0,0 +1,159 @@ + +OpenCloud 当前架构 +前端 Vue 页面和 Pinia store 经业务 API 模块、HTTP 传输层访问 Hono 后端;后端路由使用校验、鉴权、Drizzle 和复用服务,集中序列化响应。Better Auth 独立处理认证,认证与业务共用 PostgreSQL,图片存于对象存储,邮件由 Resend 发送。下方展示从后端 Zod schema 生成并同步前端 TypeScript 契约的开发流程。 + + + + + + + + + + + +OpenCloud · 当前系统架构 +运行时请求、认证与开发期契约同步 / 2026-09-30 + +调用 / 请求 + +JSON 响应 + +契约生成 / 同步 + +01 浏览器 · opencloud +Vue 3 · Pinia · Vue Router · Vite + +02 API 服务 · hono-api +Hono · Better Auth · Zod · Drizzle + +03 数据与外部服务 +持久化、图片与邮件 + + +页面 / 组件 / Pinia stores +交互、加载状态、业务缓存 +src/features/ + + +业务 API 模块 +clouds/api.ts · profile/api.ts · admin/api.ts +路径、分页、批量分块、响应适配 +api-adapters.ts → 前端视图模型 + + +HTTP 传输层 +src/lib/api.ts +Cookie · 查询编码 · JSON / FormData +ApiError · 401 认证失效事件 + + +浏览器媒体元素直接请求 /images/:id/:variant + + +独立认证客户端 +authClient.ts → Better Auth SDK +登录 / 注册 / 会话 / 邮箱验证 / 密码 +AMap:浏览器按需加载地图 SDK + + +统一响应序列化 +src/serializers.ts +Cloud / CloudType / User → JSON · ISO UTC + + +业务路由 + 按需复用服务 +/clouds · /cloud-types · /profiles · /admin +Zod 请求校验 · 会话、角色与所有权校验 +Drizzle 查询 · 分页 / 点赞 / 压缩 / 存储 + + +Hono HTTP 入口 +src/index.ts +CORS · request ID · 计时 · 错误处理 +资源路由分发;/images 代理图片流 + +分发 / 校验 + +查询结果 + +HTTPS +携带 Cookie + +响应 +JSON 业务协议与 Better Auth 原生协议分别处理 + + +Better Auth · /auth/* +HttpOnly session cookie · Drizzle adapter +认证表与业务表共用同一个 PostgreSQL + +/auth/* +事务外图片存储:串行写入,上传失败执行补偿清理 + + +PostgreSQL +用户 / 会话 / 凭据 +云类型 / 云图 / 点赞 +业务与认证共同使用 + +Drizzle + + +对象存储 +MinIO / S3 兼容接口 +original + preview +上传、删除、图片读取 + +storage + + +Resend +验证邮箱 / 密码重置 +services/email.ts + +发送邮件 +公开可见规则 +审核通过且未隐藏 +上传者 / 管理员另行授权 + +04 开发期 · 生成契约,阻止前后端类型漂移 +只同步 TypeScript 类型;前端独立构建使用已提交快照,不加载后端运行时依赖。 + + +后端 Zod 定义 +hono-api/src/schemas/ +请求校验 + JSON 响应 + + +后端生成物 +hono-api/contracts/api.ts +contract:check 检查过期 + + +前端契约快照 +src/types/api-contract.ts +业务 API 模块引用这些类型 + +contract:generate + +api:sync +跨仓库核对 +api:check +比对两端契约 +通信约定 +snake_case · ISO UTC · 分页 { items, page, page_size, has_more } · 错误 { message, issues? } +当前实现:刷新式加载;批量每次最多 100 个 ID;写请求不自动重试。图中省略页面内部组件与单个数据库索引。 + \ No newline at end of file diff --git a/docs/project-structure.md b/docs/project-structure.md index 8160ccc..dffb4b8 100644 --- a/docs/project-structure.md +++ b/docs/project-structure.md @@ -1,6 +1,6 @@ # 目录结构与依赖约定 -按业务功能组织文件,使一次功能修改涉及的页面、组件和状态尽量集中。文件迁移只改变源码路径,接口请求、DTO、store 导出与 ID、组件 props/events、路由 URL 和页面行为保持不变。 +按业务功能组织文件,使一次功能修改涉及的页面、组件和状态尽量集中。通信层按下文分工;接口变更与后端配套验证,见[通信架构](api-architecture.md)。 ## 目录职责 @@ -35,25 +35,25 @@ src/ │ ├── types/amap.d.ts # 高德类型声明,与同名加载器分开放置 │ └── components/ # MapPickerModal、MiniLocationMap ├── components/layout/ # 应用外壳:AppHeader -├── lib/ # api.ts、seo.ts、theme.ts -├── types/ # 公共 DTO、领域类型、路由元数据 +├── lib/ # api.ts、api-adapters.ts、pagination.ts、seo.ts、theme.ts +├── types/ # 生成的 API 契约、前端模型、路由元数据 ├── styles/ # 全局设计令牌、基线、共享样式 └── style.css # 全局样式入口 ``` ## 文件归属 -| 修改内容 | 归属位置 | -| -------------------------------------- | ---------------------------- | -| 登录、会话、验证邮件 | `features/auth/` | -| 多个页面复用的云图详情、编辑、点赞 | `features/clouds/` | -| 上传表单、上传弹窗、文件处理与上传状态 | `features/upload/` | -| 用户主页、设置、贡献热力图 | `features/profile/` | -| 图鉴列表、云型详情及其状态 | `features/encyclopedia/` | -| 首页地图时间轴、标记交互 | `features/map/` | -| 通用选点、位置预览、高德加载 | `shared/map/` | -| 应用导航和整体布局 | `components/layout/` | -| HTTP 请求与响应适配、公共 API 类型 | `lib/api.ts`、`types/api.ts` | +| 修改内容 | 归属位置 | +| -------------------------------------- | ------------------------------------------------------------ | +| 登录、会话、验证邮件 | `features/auth/` | +| 多个页面复用的云图详情、编辑、点赞 | `features/clouds/` | +| 上传表单、上传弹窗、文件处理与上传状态 | `features/upload/` | +| 用户主页、设置、贡献热力图 | `features/profile/` | +| 图鉴列表、云型详情及其状态 | `features/encyclopedia/` | +| 首页地图时间轴、标记交互 | `features/map/` | +| 通用选点、位置预览、高德加载 | `shared/map/` | +| 应用导航和整体布局 | `components/layout/` | +| HTTP 请求与响应适配、公共 API 类型 | `lib/api.ts`、`lib/api-adapters.ts`、`types/api-contract.ts` | 按职责归属文件:快捷上传虽然从地图打开,仍属于上传模块;云图点赞虽然在多个页面使用,仍属于云图业务;选点和小地图仅提供位置能力,放在公共地图模块。 @@ -63,7 +63,7 @@ src/ - 业务组件放在所属功能模块。`components/layout/` 仅存放应用布局,`lib/` 存放公共基础设施。 - 功能模块可以依赖公共地图能力、基础设施、公共类型和其他模块的具体组件、store 或 composable。`shared/map/`、`lib/` 和 `types/` 保持独立于业务模块;新增跨模块依赖时避免循环引用。 - 使用 `@/features/...`、`@/shared/...` 等具体文件路径,不增加统一导出所有页面和 store 的入口。路由继续在 `router/index.ts` 中动态导入各模块页面,保持按页面懒加载。 -- API 请求继续使用 `lib/api.ts`,认证 SDK 调用集中在 `features/auth/authClient.ts` 和认证 store。DTO 与领域类型仍由 `types/` 统一提供。 +- 页面与 store 调用所属功能的 `api.ts`,由该模块通过 `lib/api.ts` 发送请求。认证 SDK 调用集中在 `features/auth/authClient.ts` 和认证 store。生成 DTO 在 `types/api-contract.ts`,前端模型在 `types/models.ts` 与 `types/view-models.ts`。 - 现有 `clouds` 与 `encyclopedia` store 保留各自的缓存行为;后续如需合并缓存,应作为独立行为变更验证。 - 共享视觉规则遵循[样式体系](style-system.md),组件专属样式随组件迁移。 diff --git a/eslint.config.js b/eslint.config.js index 2658084..d2cbba5 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -43,11 +43,33 @@ export default defineConfig([ }, }, { - files: ['*.{js,mjs,cjs,ts,mts,cts}', 'scripts/**/*.{js,mjs,cjs,ts,mts,cts}'], + files: [ + '*.{js,mjs,cjs,ts,mts,cts}', + 'scripts/**/*.{js,mjs,cjs,ts,mts,cts}', + 'tests/**/*.mjs', + ], languageOptions: { globals: globals.node, }, }, + { + files: ['src/features/**/*.{ts,vue}'], + ignores: ['src/features/*/api.ts', 'src/features/auth/authClient.ts'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: '@/lib/api', + importNames: ['apiRequest'], + message: '请通过所属业务的 api.ts 调用接口。', + }, + ], + }, + ], + }, + }, // Keep formatting in Prettier; this must follow the rule presets. prettier, ]) diff --git a/package.json b/package.json index 75397a0..e3bf4a6 100644 --- a/package.json +++ b/package.json @@ -10,10 +10,13 @@ "format": "prettier --write .", "format:check": "prettier --check .", "typecheck": "vue-tsc -b", - "check": "npm run lint && npm run format:check && npm run check:styles && npm run typecheck", + "check": "npm run lint && npm run format:check && npm run check:styles && npm run typecheck && npm test", "check:styles": "node scripts/check-style-system.mjs", "build": "vue-tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "api:sync": "node scripts/sync-api-contract.mjs", + "api:check": "node scripts/sync-api-contract.mjs --check", + "test": "node --import ./scripts/test-resolver.mjs --test tests/*.test.mjs" }, "dependencies": { "@amap/amap-jsapi-loader": "^1.0.1", diff --git a/scripts/sync-api-contract.mjs b/scripts/sync-api-contract.mjs new file mode 100644 index 0000000..6795534 --- /dev/null +++ b/scripts/sync-api-contract.mjs @@ -0,0 +1,17 @@ +import { readFile, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' +import { format, resolveConfig } from 'prettier' + +const source = resolve(process.env.OPENCLOUD_API_DIR || '../hono-api', 'contracts/api.ts') +const target = resolve('src/types/api-contract.ts') +const output = await format(await readFile(source, 'utf8'), { + ...(await resolveConfig(target)), + filepath: target, +}) +if (process.argv.includes('--check')) { + if ((await readFile(target, 'utf8')) !== output) { + throw new Error('API 契约已变化,请先在后端生成,再运行 npm run api:sync') + } +} else { + await writeFile(target, output) +} diff --git a/scripts/test-resolver.mjs b/scripts/test-resolver.mjs new file mode 100644 index 0000000..c5a0974 --- /dev/null +++ b/scripts/test-resolver.mjs @@ -0,0 +1,16 @@ +import { registerHooks } from 'node:module' +import { existsSync } from 'node:fs' + +const source = new URL('../src/', import.meta.url) +registerHooks({ + resolve(specifier, context, nextResolve) { + if (specifier.startsWith('@/')) { + return nextResolve(new URL(`${specifier.slice(2)}.ts`, source).href, context) + } + if (specifier.startsWith('.') && context.parentURL?.startsWith(source.href)) { + const candidate = new URL(`${specifier}.ts`, context.parentURL) + if (existsSync(candidate)) return nextResolve(candidate.href, context) + } + return nextResolve(specifier, context) + }, +}) diff --git a/src/features/admin/api.ts b/src/features/admin/api.ts new file mode 100644 index 0000000..a9e6ca0 --- /dev/null +++ b/src/features/admin/api.ts @@ -0,0 +1,68 @@ +import { apiRequest } from '@/lib/api' +import { toCloudDetail, toAuthUser } from '@/lib/api-adapters' +import { collectPages } from '@/lib/pagination' +import type { + CloudPageResponse, + UserProfileResponse, + MessageResponse, + CreateCloudTypeBody, + UpdateCloudTypeBody, + CloudTypeCreateResponse, + ReviewCloudsBody, + SetUserRoleBody, +} from '@/types/api-contract' + +export async function listUsers() { + return (await apiRequest('/admin/users')).map(toAuthUser) +} + +export function listAdminClouds() { + return collectPages(async page => { + const result = await apiRequest('/admin/clouds', { + query: { page, page_size: 100 }, + }) + return { ...result, items: result.items.map(toCloudDetail) } + }) +} + +export async function reviewClouds(ids: string[], reviewStatus: ReviewCloudsBody['review_status']) { + for (let offset = 0; offset < ids.length; offset += 100) { + const body: ReviewCloudsBody = { + cloud_ids: ids.slice(offset, offset + 100), + review_status: reviewStatus, + } + await apiRequest('/admin/clouds/review', { method: 'POST', body }) + } +} + +export function deleteAdminCloud(id: string) { + return apiRequest(`/admin/clouds/${encodeURIComponent(id)}`, { + method: 'DELETE', + }) +} + +export function setUserRole(id: string, role: SetUserRoleBody['role']) { + return apiRequest(`/admin/users/${encodeURIComponent(id)}`, { + method: 'PATCH', + body: { role }, + }) +} + +export function setUserPassword(id: string, newPassword: string) { + return apiRequest(`/admin/users/${encodeURIComponent(id)}/password`, { + method: 'PATCH', + body: { new_password: newPassword }, + }) +} + +export function createCloudType(body: CreateCloudTypeBody) { + return apiRequest('/admin/cloud-types', { method: 'POST', body }) +} + +export function updateCloudType(id: number, body: UpdateCloudTypeBody) { + return apiRequest(`/admin/cloud-types/${id}`, { method: 'PATCH', body }) +} + +export function deleteCloudTypeRecord(id: number) { + return apiRequest(`/admin/cloud-types/${id}`, { method: 'DELETE' }) +} diff --git a/src/features/admin/views/AdminView.vue b/src/features/admin/views/AdminView.vue index c003bab..88445fb 100644 --- a/src/features/admin/views/AdminView.vue +++ b/src/features/admin/views/AdminView.vue @@ -36,19 +36,24 @@ import { import { useRouter } from 'vue-router' import ImageDetailModal from '@/features/clouds/components/ImageDetailModal.vue' import MiniLocationMap from '@/shared/map/components/MiniLocationMap.vue' -import { apiRequest, toApiCloud, toAuthUser, toCloudType } from '@/lib/api' +import { toCloudType } from '@/lib/api-adapters' +import { listCloudTypeRecords } from '@/features/clouds/api' +import { + listUsers, + listAdminClouds, + reviewClouds, + deleteAdminCloud, + setUserRole, + setUserPassword, + createCloudType, + updateCloudType, + deleteCloudTypeRecord, +} from '@/features/admin/api' +import type { CloudTypeResponse } from '@/types/api-contract' import { useAuthStore } from '@/features/auth/stores/auth' import { useCloudsStore } from '@/features/clouds/stores/clouds' import { useEncyclopediaStore } from '@/features/encyclopedia/stores/encyclopedia' -import type { - ActionResponse, - ApiCloud, - AuthUser, - BackendCloudInfo, - BackendCloudType, - BackendUserProfile, - CloudTypeCreateResponse, -} from '@/types/api' +import type { CloudDetail, AuthUser } from '@/types/view-models' type AdminTab = 'dashboard' | 'review' | 'users' | 'cloud-types' | 'images' type CloudStatus = 'pending' | 'approved' | 'rejected' @@ -113,7 +118,7 @@ const dashboardStats = ref({ }) const users = ref([]) const images = ref([]) -const cloudTypes = ref([]) +const cloudTypes = ref([]) const selectedReviewIds = ref>(new Set()) const activeReviewId = ref(null) const selectedImageIds = ref>(new Set()) @@ -124,7 +129,7 @@ const newPassword = ref('') const confirmPassword = ref('') const passwordError = ref('') const cloudTypeEditorOpen = ref(false) -const editingCloudType = ref(null) +const editingCloudType = ref(null) const cloudTypeForm = ref(emptyCloudTypeForm()) const cloudTypeFormError = ref('') @@ -217,7 +222,7 @@ const reviewStatusStats = computed(() => [ const selectedReviewCount = computed(() => selectedReviewIds.value.size) -function toAdminCloud(row: ApiCloud): AdminCloud { +function toAdminCloud(row: CloudDetail): AdminCloud { return { id: row.id, user_id: row.user_id, @@ -232,7 +237,7 @@ function toAdminCloud(row: ApiCloud): AdminCloud { status: row.status, is_hidden: row.is_hidden, cloudTypeName: row.cloud_type_name || '未知云型', - cloudTypeRarity: row.cloud_type_rarity_value ?? 0, + cloudTypeRarity: row.cloud_type_rarity ?? 0, username: row.username || '匿名用户', } } @@ -290,23 +295,12 @@ function syncDashboardStats() { } async function fetchUsers() { - const rows = await apiRequest('/admin/users') - users.value = rows.map(toAuthUser) + const rows = await listUsers() + users.value = rows } async function fetchImages() { - const rows: BackendCloudInfo[] = [] - const pageSize = 100 - - for (let page = 1; ; page++) { - const current = await apiRequest( - `/admin/clouds?page=${page}&page_size=${pageSize}`, - ) - rows.push(...current) - if (current.length < pageSize) break - } - - images.value = rows.map(toApiCloud).map(toAdminCloud) + images.value = (await listAdminClouds()).map(toAdminCloud) selectedReviewIds.value = new Set( [...selectedReviewIds.value].filter(id => images.value.some(item => item.id === id)), ) @@ -316,7 +310,7 @@ async function fetchImages() { } async function fetchCloudTypes() { - const rows = await apiRequest('/info/cloudtype', { auth: false }) + const rows = await listCloudTypeRecords() cloudTypes.value = rows const normalized = rows.map(toCloudType) @@ -382,12 +376,6 @@ function toggleAllFilteredImageSelection() { setSelectedImageIds(filteredImages.value.map(item => item.id)) } -function chunkIds(ids: string[]) { - return Array.from({ length: Math.ceil(ids.length / 100) }, (_, index) => - ids.slice(index * 100, (index + 1) * 100), - ) -} - async function updateCloudStatus(ids: string[], status: Exclude) { if (!ids.length) return @@ -395,15 +383,7 @@ async function updateCloudStatus(ids: string[], status: Exclude('/admin/clouds/review', { - method: 'POST', - body: { - cloud_ids: chunk.map(cloudId => ({ cloud_id: cloudId })), - status, - }, - }) - } + await reviewClouds(ids, status) await fetchImages() syncDashboardStats() @@ -438,7 +418,7 @@ async function deleteImages(clouds: AdminCloud[]) { for (const cloud of clouds) { try { - await apiRequest(`/admin/clouds/${cloud.id}`, { method: 'DELETE' }) + await deleteAdminCloud(cloud.id) deletedIds.push(cloud.id) } catch (error) { firstError ||= getErrorMessage(error, '图片删除失败') @@ -483,10 +463,7 @@ async function updateUserRole(user: AuthUser, role: AuthUser['role']) { loadError.value = '' try { - await apiRequest(`/admin/users/${user.id}`, { - method: 'PATCH', - body: { user_role: role }, - }) + await setUserRole(user.id, role) users.value = users.value.map(item => (item.id === user.id ? { ...item, role } : item)) message.success('用户角色已更新;该用户需要重新登录') } catch (error) { @@ -526,10 +503,7 @@ async function resetUserPassword() { actionLoading.value = true try { - await apiRequest(`/admin/users/${user.id}/password`, { - method: 'PATCH', - body: { new_password: newPassword.value }, - }) + await setUserPassword(user.id, newPassword.value) passwordTarget.value = null message.success('密码已重置;该用户需要重新登录') } catch (error) { @@ -549,7 +523,7 @@ function emptyCloudTypeForm(): CloudTypeForm { } } -function openCloudTypeEditor(cloudType?: BackendCloudType) { +function openCloudTypeEditor(cloudType?: CloudTypeResponse) { editingCloudType.value = cloudType ?? null cloudTypeForm.value = cloudType ? { @@ -613,16 +587,10 @@ async function saveCloudType() { loadError.value = '' try { if (editingCloudType.value) { - await apiRequest(`/admin/cloudtypes/${editingCloudType.value.id}`, { - method: 'PATCH', - body: payload, - }) + await updateCloudType(editingCloudType.value.id, payload) message.success('云类型已更新') } else { - await apiRequest('/admin/cloudtypes', { - method: 'POST', - body: payload, - }) + await createCloudType(payload) message.success('云类型已添加') } @@ -643,7 +611,7 @@ function cloudTypeUsageCount(cloudTypeId: number) { return images.value.filter(image => image.cloud_type_id === cloudTypeId).length } -async function deleteCloudType(cloudType: BackendCloudType) { +async function deleteCloudType(cloudType: CloudTypeResponse) { const usageCount = cloudTypeUsageCount(cloudType.id) if (usageCount > 0) { message.warning(`“${cloudType.name}”仍被 ${usageCount} 张云图使用,无法删除`) @@ -655,7 +623,7 @@ async function deleteCloudType(cloudType: BackendCloudType) { actionLoading.value = true loadError.value = '' try { - await apiRequest(`/admin/cloudtypes/${cloudType.id}`, { method: 'DELETE' }) + await deleteCloudTypeRecord(cloudType.id) await fetchCloudTypes() message.success('云类型已删除') } catch (error) { diff --git a/src/features/auth/stores/auth.ts b/src/features/auth/stores/auth.ts index eaec592..11169c9 100644 --- a/src/features/auth/stores/auth.ts +++ b/src/features/auth/stores/auth.ts @@ -1,12 +1,13 @@ import { computed, ref } from 'vue' import { defineStore } from 'pinia' -import { AUTH_EXPIRED_EVENT, apiRequest, toAuthUser } from '@/lib/api' +import { AUTH_EXPIRED_EVENT } from '@/lib/api' import { assertAuthResult, authClient } from '@/features/auth/authClient' -import type { BackendUserProfile } from '@/types/api' -import type { Profile } from '@/types/database' +import { getMyProfile, updateMyUsername } from '@/features/profile/api' +import type { AuthUser } from '@/types/view-models' +import type { Profile } from '@/types/models' export const useAuthStore = defineStore('auth', () => { - const user = ref | null>(null) + const user = ref(null) const profile = ref(null) const loading = ref(true) let listenerAttached = false @@ -14,8 +15,7 @@ export const useAuthStore = defineStore('auth', () => { const isLoggedIn = computed(() => !!user.value) const isAdmin = computed(() => profile.value?.role === 'admin') - function applyProfile(backendProfile: BackendUserProfile) { - const normalized = toAuthUser(backendProfile) + function applyProfile(normalized: AuthUser) { user.value = normalized profile.value = { id: normalized.id, @@ -33,7 +33,7 @@ export const useAuthStore = defineStore('auth', () => { } async function fetchMe() { - const backendProfile = await apiRequest('/profile/me') + const backendProfile = await getMyProfile() applyProfile(backendProfile) } @@ -98,10 +98,7 @@ export const useAuthStore = defineStore('auth', () => { } async function updateUsername(username: string) { - const backendProfile = await apiRequest('/profile/me', { - method: 'PATCH', - body: { user_name: username }, - }) + const backendProfile = await updateMyUsername(username) applyProfile(backendProfile) } diff --git a/src/features/clouds/api.ts b/src/features/clouds/api.ts new file mode 100644 index 0000000..288fa4b --- /dev/null +++ b/src/features/clouds/api.ts @@ -0,0 +1,87 @@ +import { apiRequest } from '@/lib/api' +import { imageUrl, toCloudDetail, toCloudType } from '@/lib/api-adapters' +import { collectPages } from '@/lib/pagination' +import type { + CloudResponse, + CloudPageResponse, + CloudTypeResponse, + CloudCreateResponse, + MessageResponse, + PublicCloudListQuery, + CloudMapQuery, + UpdateCloudBody, + UploadCloudForm, + PaginationQuery, +} from '@/types/api-contract' + +export { imageUrl } + +export async function listClouds(query: PublicCloudListQuery) { + const page = await apiRequest('/clouds', { query, auth: false }) + return { ...page, items: page.items.map(toCloudDetail) } +} + +export async function listCloudsByType(cloudTypeId: number, query: PaginationQuery) { + const page = await apiRequest(`/cloud-types/${cloudTypeId}/clouds`, { + query, + auth: false, + }) + return { ...page, items: page.items.map(toCloudDetail) } +} + +export async function listMapClouds(query: CloudMapQuery) { + const rows = await apiRequest('/clouds/map', { query, auth: false }) + return rows.map(toCloudDetail) +} + +export async function getCloud(cloudId: string, auth = true) { + return toCloudDetail( + await apiRequest(`/clouds/${encodeURIComponent(cloudId)}`, { auth }), + ) +} + +export async function listCloudTypeRecords() { + return apiRequest('/cloud-types', { auth: false }) +} + +export async function listCloudTypes() { + return (await listCloudTypeRecords()).map(toCloudType) +} + +export function updateCloud(cloudId: string, body: UpdateCloudBody) { + return apiRequest(`/clouds/${encodeURIComponent(cloudId)}`, { + method: 'PATCH', + body, + }) +} + +export async function deleteClouds(cloudIds: string[], onDeleted?: (ids: string[]) => void) { + for (let offset = 0; offset < cloudIds.length; offset += 100) { + const ids = cloudIds.slice(offset, offset + 100) + await apiRequest('/clouds', { method: 'DELETE', body: { cloud_ids: ids } }) + onDeleted?.(ids) + } +} + +export function uploadCloud(input: UploadCloudForm) { + const form = new FormData() + for (const [key, value] of Object.entries(input)) { + if (value !== undefined) form.append(key, value instanceof Blob ? value : String(value)) + } + return apiRequest('/clouds', { method: 'POST', body: form }) +} + +export function setCloudLike(cloudId: string, liked: boolean) { + return apiRequest(`/clouds/${encodeURIComponent(cloudId)}/like`, { + method: liked ? 'PUT' : 'DELETE', + }) +} + +export async function listLikedClouds() { + return collectPages(async page => { + const result = await apiRequest('/profiles/me/likes', { + query: { page, page_size: 100 }, + }) + return { ...result, items: result.items.map(toCloudDetail) } + }) +} diff --git a/src/features/clouds/components/CloudEditModal.vue b/src/features/clouds/components/CloudEditModal.vue index 9a77785..ca7543b 100644 --- a/src/features/clouds/components/CloudEditModal.vue +++ b/src/features/clouds/components/CloudEditModal.vue @@ -3,7 +3,7 @@ import { ref, watch } from 'vue' import { NAlert, NButton, NIcon } from 'naive-ui' import { Map as MapIcon, X } from '@vicons/tabler' import MapPickerModal from '@/shared/map/components/MapPickerModal.vue' -import type { CloudType } from '@/types/database' +import type { CloudType } from '@/types/models' export interface CloudEditFormValue { cloudCategoryId: number | null diff --git a/src/features/clouds/stores/clouds.ts b/src/features/clouds/stores/clouds.ts index b6e02c1..f0f74b2 100644 --- a/src/features/clouds/stores/clouds.ts +++ b/src/features/clouds/stores/clouds.ts @@ -1,8 +1,7 @@ import { ref } from 'vue' import { defineStore } from 'pinia' -import { apiRequest, toCloudType } from '@/lib/api' -import type { BackendCloudType } from '@/types/api' -import type { CloudType } from '@/types/database' +import { listCloudTypes } from '@/features/clouds/api' +import type { CloudType } from '@/types/models' export const useCloudsStore = defineStore('clouds', () => { const cloudTypes = ref([]) @@ -12,8 +11,7 @@ export const useCloudsStore = defineStore('clouds', () => { if (cloudTypes.value.length > 0) return loading.value = true try { - const rows = await apiRequest('/info/cloudtype', { auth: false }) - cloudTypes.value = rows.map(toCloudType) + cloudTypes.value = await listCloudTypes() } finally { loading.value = false } diff --git a/src/features/clouds/stores/likes.ts b/src/features/clouds/stores/likes.ts index aaec463..88f7677 100644 --- a/src/features/clouds/stores/likes.ts +++ b/src/features/clouds/stores/likes.ts @@ -1,10 +1,7 @@ import { ref, watch } from 'vue' import { defineStore } from 'pinia' -import { apiRequest } from '@/lib/api' +import { getCloud, listLikedClouds, setCloudLike } from '@/features/clouds/api' import { useAuthStore } from '@/features/auth/stores/auth' -import type { ActionResponse, BackendCloudInfo } from '@/types/api' - -const PAGE_SIZE = 100 export const useLikesStore = defineStore('likes', () => { const authStore = useAuthStore() @@ -46,16 +43,8 @@ export const useLikesStore = defineStore('likes', () => { loadingForUser = userId const request = (async () => { - const nextIds = new Set() - for (let page = 1; ; page++) { - const params = new URLSearchParams({ - page: String(page), - page_size: String(PAGE_SIZE), - }) - const rows = await apiRequest(`/profile/me/likes?${params}`) - rows.forEach(row => nextIds.add(row.id)) - if (rows.length < PAGE_SIZE) break - } + const rows = await listLikedClouds() + const nextIds = new Set(rows.map(row => row.id)) if (authStore.user?.id === userId) { likedIds.value = nextIds loadedForUser.value = userId @@ -82,9 +71,7 @@ export const useLikesStore = defineStore('likes', () => { const wasLiked = isLiked(cloudId) pendingIds.value = new Set(pendingIds.value).add(cloudId) try { - await apiRequest(`/cloud/${cloudId}/like`, { - method: wasLiked ? 'DELETE' : 'PUT', - }) + await setCloudLike(cloudId, !wasLiked) if (authStore.user?.id !== userId) return const nextIds = new Set(likedIds.value) @@ -97,11 +84,10 @@ export const useLikesStore = defineStore('likes', () => { ) try { - const cloud = await apiRequest(`/cloud/${cloudId}`, { - auth: false, - }) + const cloud = await getCloud(cloudId, false) + if (authStore.user?.id !== userId) return counts.value[cloudId] = cloud.received_like_count - if (cloud.owner.id === authStore.user?.id) await authStore.refreshProfile() + if (cloud.owner?.id === authStore.user?.id) await authStore.refreshProfile() } catch { // The mutation succeeded; keep the local count if a refresh fails. } diff --git a/src/features/encyclopedia/stores/encyclopedia.ts b/src/features/encyclopedia/stores/encyclopedia.ts index 223a71a..01b4590 100644 --- a/src/features/encyclopedia/stores/encyclopedia.ts +++ b/src/features/encyclopedia/stores/encyclopedia.ts @@ -1,8 +1,7 @@ import { ref } from 'vue' import { defineStore } from 'pinia' -import { apiRequest, toCloudType } from '@/lib/api' -import type { BackendCloudType } from '@/types/api' -import type { CloudType } from '@/types/database' +import { listCloudTypes } from '@/features/clouds/api' +import type { CloudType } from '@/types/models' export const useEncyclopediaStore = defineStore('encyclopedia', () => { const cloudTypes = ref([]) @@ -13,8 +12,7 @@ export const useEncyclopediaStore = defineStore('encyclopedia', () => { if (cloudTypesLoaded.value && !force) return loadingCloudTypes.value = true try { - const rows = await apiRequest('/info/cloudtype', { auth: false }) - cloudTypes.value = rows.map(toCloudType) + cloudTypes.value = await listCloudTypes() cloudTypesLoaded.value = true } finally { loadingCloudTypes.value = false diff --git a/src/features/encyclopedia/views/CloudTypeView.vue b/src/features/encyclopedia/views/CloudTypeView.vue index 3924eda..0ac4fbe 100644 --- a/src/features/encyclopedia/views/CloudTypeView.vue +++ b/src/features/encyclopedia/views/CloudTypeView.vue @@ -5,17 +5,17 @@ import { RouterLink, useRoute } from 'vue-router' import ImageDetailModal from '@/features/clouds/components/ImageDetailModal.vue' import CloudLikeButton from '@/features/clouds/components/CloudLikeButton.vue' import MiniLocationMap from '@/shared/map/components/MiniLocationMap.vue' -import { apiRequest, imageUrl, toApiCloud } from '@/lib/api' +import { imageUrl, listCloudsByType } from '@/features/clouds/api' import { useEncyclopediaStore } from '@/features/encyclopedia/stores/encyclopedia' -import type { ApiCloud, BackendCloudInfo } from '@/types/api' +import type { CloudDetail } from '@/types/view-models' const PAGE_SIZE = 24 const route = useRoute() const encyclopediaStore = useEncyclopediaStore() const loading = ref(true) const loadError = ref('') -const gallery = ref([]) -const selectedItem = ref(null) +const gallery = ref([]) +const selectedItem = ref(null) const currentPage = ref(1) const hasNextPage = ref(false) @@ -39,16 +39,10 @@ function formatDateTime(iso: string | null) { } async function loadGallery(page: number) { - const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) }) - const rows = await apiRequest( - `/cloud/type/${cloudTypeId.value}?${params}`, - { - auth: false, - }, - ) - gallery.value = rows.map(toApiCloud) + const result = await listCloudsByType(cloudTypeId.value, { page, page_size: PAGE_SIZE }) + gallery.value = result.items currentPage.value = page - hasNextPage.value = rows.length === PAGE_SIZE + hasNextPage.value = result.has_more } async function loadPage(page = 1) { @@ -144,7 +138,7 @@ watch( ? 'border-white/35 bg-slate-950/25 backdrop-blur' : 'border-slate-200 bg-white' " - >稀有度 {{ cloudType.rarity_value }}稀有度 {{ cloudType.rarity }}

{{ cloudType.name }}

@@ -236,7 +230,7 @@ watch( ? `拍摄于 ${formatDateTime(selectedItem.captured_at)}` : `上传于 ${formatDateTime(selectedItem.created_at)}` " - :badge-label="`稀有度 ${cloudType.rarity_value}`" + :badge-label="`稀有度 ${cloudType.rarity}`" badge-class="border-slate-200 bg-slate-50 text-slate-600" @close="selectedItem = null" > diff --git a/src/features/encyclopedia/views/EncyclopediaView.vue b/src/features/encyclopedia/views/EncyclopediaView.vue index 7ac0c92..2b3b92f 100644 --- a/src/features/encyclopedia/views/EncyclopediaView.vue +++ b/src/features/encyclopedia/views/EncyclopediaView.vue @@ -2,7 +2,7 @@ import { onMounted } from 'vue' import { NEmpty, NSkeleton } from 'naive-ui' import { useRouter } from 'vue-router' -import { imageUrl } from '@/lib/api' +import { imageUrl } from '@/features/clouds/api' import { useEncyclopediaStore } from '@/features/encyclopedia/stores/encyclopedia' const router = useRouter() @@ -99,7 +99,7 @@ onMounted(() => encyclopediaStore.fetchCloudTypes()) : 'border-slate-200 bg-white text-slate-600' " > - 稀有度 {{ cloudType.rarity_value }} + 稀有度 {{ cloudType.rarity }}

searchQuery.value.trim()) const isUserSearch = computed(() => normalizedSearch.value.startsWith('@')) -function toGalleryCloud(row: ApiCloud) { +function toGalleryCloud(row: CloudDetail) { return { id: row.id, user_id: row.user_id, @@ -100,7 +102,7 @@ function toGalleryCloud(row: ApiCloud) { status: row.status, is_hidden: row.is_hidden, cloudTypeName: row.cloud_type_name || '未知', - cloudTypeRarity: row.cloud_type_rarity_value ?? 0, + cloudTypeRarity: row.cloud_type_rarity ?? 0, username: row.username || '匿名', receivedLikeCount: row.received_like_count, } satisfies GalleryCloud @@ -111,23 +113,23 @@ async function loadPage(page: number) { loadError.value = '' try { - const params = new URLSearchParams({ page: String(page), page_size: String(PAGE_SIZE) }) + const query: PublicCloudListQuery = { page, page_size: PAGE_SIZE } if (normalizedSearch.value) { - params.set('filter', isUserSearch.value ? 'owner' : 'type') - params.set('value', normalizedSearch.value) + query.filter = isUserSearch.value ? 'owner' : 'type' + query.value = normalizedSearch.value } else if (selectedTypeId.value !== 'all') { const typeName = cloudsStore.cloudTypes.find( type => type.id === selectedTypeId.value, )?.name if (typeName) { - params.set('filter', 'type') - params.set('value', typeName) + query.filter = 'type' + query.value = typeName } } - const rows = await apiRequest(`/cloud?${params}`, { auth: false }) - galleryItems.value = rows.map(toApiCloud).map(toGalleryCloud) - hasNextPage.value = rows.length === PAGE_SIZE + const result = await listClouds(query) + galleryItems.value = result.items.map(toGalleryCloud) + hasNextPage.value = result.has_more currentPage.value = page } catch (error) { if (error instanceof ApiError && error.status === 404 && isUserSearch.value) { @@ -232,7 +234,7 @@ async function submitEditModal(value: CloudEditFormValue) { try { const updated = await profileStore.updateCloud(authStore.user.id, current.id, { - type_id: value.cloudCategoryId, + cloud_type_id: value.cloudCategoryId, latitude: blurCoordinate(value.latitude as number), longitude: blurCoordinate(value.longitude as number), description: value.description.trim(), diff --git a/src/features/map/views/MapView.vue b/src/features/map/views/MapView.vue index 2d41326..75cd5d5 100644 --- a/src/features/map/views/MapView.vue +++ b/src/features/map/views/MapView.vue @@ -4,10 +4,10 @@ import ImageDetailModal from '@/features/clouds/components/ImageDetailModal.vue' import CloudLikeButton from '@/features/clouds/components/CloudLikeButton.vue' import MiniLocationMap from '@/shared/map/components/MiniLocationMap.vue' import QuickUploadModal from '@/features/upload/components/QuickUploadModal.vue' -import { apiRequest, toApiCloud } from '@/lib/api' +import { listMapClouds } from '@/features/clouds/api' import { loadAMap } from '@/shared/map/amap' import { useAuthStore } from '@/features/auth/stores/auth' -import type { ApiCloud, BackendCloudInfo } from '@/types/api' +import type { CloudDetail } from '@/types/view-models' import { NIcon } from 'naive-ui' import { Adjustments, @@ -421,7 +421,7 @@ function handleZoomClick(direction: -1 | 1, event: MouseEvent) { } } -function toCloudMarker(row: ApiCloud): CloudMarkerData { +function toCloudMarker(row: CloudDetail): CloudMarkerData { return { id: row.id, latitude: row.latitude as number, @@ -430,7 +430,7 @@ function toCloudMarker(row: ApiCloud): CloudMarkerData { thumbnailUrl: row.thumbnail_url, description: row.description, cloudTypeName: row.cloud_type_name || '未知', - rarity: row.cloud_type_rarity_value ?? 0, + rarity: row.cloud_type_rarity ?? 0, username: row.username || '匿名', capturedAt: row.captured_at || row.created_at, createdAt: row.created_at, @@ -443,14 +443,14 @@ async function fetchCloudsByRange( start: Date, end: Date, ): Promise { - const params = new URLSearchParams({ + const query = { start: start.toISOString(), end: end.toISOString(), time_field: field, - }) + } try { - const rows = await apiRequest(`/cloud/map?${params}`, { auth: false }) - return rows.map(toApiCloud).map(toCloudMarker) + const rows = await listMapClouds(query) + return rows.map(toCloudMarker) } catch (error) { statusText.value = `查询失败: ${error instanceof Error ? error.message : '未知错误'}` return [] diff --git a/src/features/profile/api.ts b/src/features/profile/api.ts new file mode 100644 index 0000000..f97344d --- /dev/null +++ b/src/features/profile/api.ts @@ -0,0 +1,44 @@ +import { apiRequest, ApiError } from '@/lib/api' +import { toCloudDetail, toAuthUser } from '@/lib/api-adapters' +import { collectPages } from '@/lib/pagination' +import { listClouds } from '@/features/clouds/api' +import type { CloudPageResponse, UserProfileResponse } from '@/types/api-contract' + +export async function getMyProfile() { + return toAuthUser(await apiRequest('/profiles/me')) +} + +export async function updateMyUsername(username: string) { + return toAuthUser( + await apiRequest('/profiles/me', { + method: 'PATCH', + body: { username }, + }), + ) +} + +export async function getUserProfile(userId: string) { + return toAuthUser( + await apiRequest(`/profiles/${encodeURIComponent(userId)}`), + ) +} + +export function listMyClouds() { + return collectPages(async page => { + const result = await apiRequest('/profiles/me/clouds', { + query: { page, page_size: 100 }, + }) + return { ...result, items: result.items.map(toCloudDetail) } + }) +} + +export async function listPublicClouds(username: string) { + try { + return await collectPages(page => + listClouds({ page, page_size: 100, filter: 'owner', value: `@${username}` }), + ) + } catch (error) { + if (error instanceof ApiError && error.status === 404) return [] + throw error + } +} diff --git a/src/features/profile/stores/profile.ts b/src/features/profile/stores/profile.ts index f0e7d8a..fe30d35 100644 --- a/src/features/profile/stores/profile.ts +++ b/src/features/profile/stores/profile.ts @@ -1,9 +1,15 @@ import { ref } from 'vue' import { defineStore } from 'pinia' -import { ApiError, apiRequest, toApiCloud } from '@/lib/api' +import { getUserProfile, listMyClouds, listPublicClouds } from '@/features/profile/api' +import { + getCloud, + updateCloud as patchCloud, + deleteClouds as deleteCloudRecords, +} from '@/features/clouds/api' +import type { UpdateCloudBody } from '@/types/api-contract' import { useAuthStore } from '@/features/auth/stores/auth' -import type { ActionResponse, ApiCloud, BackendCloudInfo, BackendUserProfile } from '@/types/api' -import type { Profile } from '@/types/database' +import type { CloudDetail, AuthUser } from '@/types/view-models' +import type { Profile } from '@/types/models' export interface ProfileCloudItem { id: string @@ -22,7 +28,7 @@ export interface ProfileCloudItem { receivedLikeCount: number } -function toProfileCloud(row: ApiCloud): ProfileCloudItem { +function toProfileCloud(row: CloudDetail): ProfileCloudItem { return { id: row.id, cloud_type_id: row.cloud_type_id as number, @@ -36,7 +42,7 @@ function toProfileCloud(row: ApiCloud): ProfileCloudItem { status: row.status, is_hidden: row.is_hidden, cloudTypeName: row.cloud_type_name || '未知', - cloudTypeRarity: row.cloud_type_rarity_value ?? 0, + cloudTypeRarity: row.cloud_type_rarity ?? 0, receivedLikeCount: row.received_like_count, } } @@ -67,27 +73,6 @@ export const useProfileStore = defineStore('profile-page', () => { return !!identifier && makeKey(identifier, isOwnProfile) in cloudsByKey.value } - async function fetchAllPublicClouds(username: string) { - const rows: BackendCloudInfo[] = [] - for (let page = 1; ; page++) { - const params = new URLSearchParams({ - page: String(page), - page_size: '100', - filter: 'owner', - value: `@${username}`, - }) - let current: BackendCloudInfo[] - try { - current = await apiRequest(`/cloud?${params}`, { auth: false }) - } catch (error) { - if (error instanceof ApiError && error.status === 404) return rows - throw error - } - rows.push(...current) - if (current.length < 100) return rows - } - } - async function fetchProfilePage(identifier: string, isOwnProfile: boolean, force = false) { const key = makeKey(identifier, isOwnProfile) if (!force && key in cloudsByKey.value && profilesByKey.value[identifier]) return @@ -96,9 +81,9 @@ export const useProfileStore = defineStore('profile-page', () => { errorByKey.value[key] = '' try { const backendRows = isOwnProfile - ? await apiRequest('/profile/me/clouds') - : await fetchAllPublicClouds(identifier) - const cloudRows = backendRows.map(toApiCloud).map(toProfileCloud) + ? await listMyClouds() + : await listPublicClouds(identifier) + const cloudRows = backendRows.map(toProfileCloud) if (isOwnProfile && authStore.profile) { profilesByKey.value[identifier] = authStore.profile @@ -106,17 +91,17 @@ export const useProfileStore = defineStore('profile-page', () => { profilesByKey.value[identifier] = authStore.profile } else { const owner = backendRows[0]?.owner - let publicProfile: BackendUserProfile | null = null + let publicProfile: AuthUser | null = null if (owner?.id && authStore.isLoggedIn) { try { - publicProfile = await apiRequest(`/profile/${owner.id}`) + publicProfile = await getUserProfile(owner.id) } catch { // Public clouds can still be displayed if the profile lookup fails. } } profilesByKey.value[identifier] = { id: owner?.id || identifier, - username: owner?.name || identifier, + username: owner?.username || identifier, image: publicProfile?.image ?? null, role: 'user', is_disabled: false, @@ -165,56 +150,22 @@ export const useProfileStore = defineStore('profile-page', () => { delete cloudsByKey.value[makeKey(identifier, false)] } - async function updateCloud( - identifier: string, - cloudId: string, - patch: { - type_id: number - latitude: number - longitude: number - description?: string - captured_at?: string - is_hidden: boolean - }, - ) { - await apiRequest(`/cloud/${cloudId}`, { - method: 'PATCH', - body: patch, - }) - const updated = toProfileCloud( - toApiCloud(await apiRequest(`/cloud/${cloudId}`)), - ) + async function updateCloud(identifier: string, cloudId: string, patch: UpdateCloudBody) { + await patchCloud(cloudId, patch) + const updated = toProfileCloud(await getCloud(cloudId)) patchCachedCloud(identifier, cloudId, updated) return updated } async function updateCloudVisibility(identifier: string, cloudId: string, isHidden: boolean) { - await apiRequest(`/cloud/${cloudId}`, { - method: 'PATCH', - body: { is_hidden: isHidden }, - }) + await patchCloud(cloudId, { is_hidden: isHidden }) patchCachedCloud(identifier, cloudId, { is_hidden: isHidden }) } async function deleteClouds(identifier: string, cloudIds: string[]) { if (!cloudIds.length) return 0 - if (cloudIds.length === 1) { - await apiRequest(`/cloud/${cloudIds[0]}`, { method: 'DELETE' }) - } else { - for (let offset = 0; offset < cloudIds.length; offset += 100) { - await apiRequest('/cloud', { - method: 'DELETE', - body: { - cloud_ids: cloudIds - .slice(offset, offset + 100) - .map(cloudId => ({ cloud_id: cloudId })), - }, - }) - } - } - - removeCachedClouds(identifier, cloudIds) + await deleteCloudRecords(cloudIds, deletedIds => removeCachedClouds(identifier, deletedIds)) return cloudIds.length } diff --git a/src/features/profile/views/ProfileView.vue b/src/features/profile/views/ProfileView.vue index 9d53480..68bec3c 100644 --- a/src/features/profile/views/ProfileView.vue +++ b/src/features/profile/views/ProfileView.vue @@ -13,7 +13,7 @@ import ContributionHeatmap from '@/features/profile/components/ContributionHeatm import { useAuthStore } from '@/features/auth/stores/auth' import { useCloudsStore } from '@/features/clouds/stores/clouds' import { useProfileStore, type ProfileCloudItem } from '@/features/profile/stores/profile' -import type { Profile } from '@/types/database' +import type { Profile } from '@/types/models' interface TimelineGroup { key: string @@ -325,7 +325,7 @@ async function submitEditModal(value: CloudEditFormValue) { viewedIdentifier.value, selectedCloud.value.id, { - type_id: value.cloudCategoryId, + cloud_type_id: value.cloudCategoryId, latitude: blurCoordinate(value.latitude as number), longitude: blurCoordinate(value.longitude as number), description: value.description.trim(), diff --git a/src/features/upload/composables/useUpload.ts b/src/features/upload/composables/useUpload.ts index c3c1ac4..8803631 100644 --- a/src/features/upload/composables/useUpload.ts +++ b/src/features/upload/composables/useUpload.ts @@ -1,8 +1,7 @@ import { ref } from 'vue' -import { apiRequest } from '@/lib/api' +import { uploadCloud } from '@/features/clouds/api' import { useAuthStore } from '@/features/auth/stores/auth' import { useProfileStore } from '@/features/profile/stores/profile' -import type { CloudCreateResponse } from '@/types/api' export interface UploadItem { id: string @@ -78,10 +77,6 @@ function extractExifDate(buffer: ArrayBuffer): string | null { return null } -function appendOptional(form: FormData, key: string, value: string) { - if (value.trim()) form.append(key, value.trim()) -} - export function useUpload() { const authStore = useAuthStore() const profileStore = useProfileStore() @@ -173,18 +168,14 @@ export function useUpload() { currentItemIndex.value = index + 1 overallProgress.value = Math.round((index / items.value.length) * 100) - const form = new FormData() - form.append('image', item.file) - form.append('type_id', String(item.cloudCategoryId)) - form.append('latitude', String(item.latitude)) - form.append('longitude', String(item.longitude)) - appendOptional(form, 'description', item.description) - appendOptional(form, 'captured_at', item.capturedAt) - form.append('is_hidden', String(item.isHidden)) - - await apiRequest('/cloud', { - method: 'POST', - body: form, + await uploadCloud({ + image: item.file, + cloud_type_id: item.cloudCategoryId!, + latitude: item.latitude!, + longitude: item.longitude!, + description: item.description.trim() || undefined, + captured_at: item.capturedAt.trim() || undefined, + is_hidden: item.isHidden, }) overallProgress.value = Math.round(((index + 1) / items.value.length) * 100) } diff --git a/src/lib/api-adapters.ts b/src/lib/api-adapters.ts new file mode 100644 index 0000000..3131a3f --- /dev/null +++ b/src/lib/api-adapters.ts @@ -0,0 +1,62 @@ +import { API_URL } from './api' +import type { CloudResponse, CloudTypeResponse, UserProfileResponse } from '@/types/api-contract' +import type { CloudDetail, AuthUser } from '@/types/view-models' +import type { CloudType } from '@/types/models' + +export function imageUrl(id: string, variant: 'preview' | 'original' = 'preview') { + return `${API_URL}/images/${encodeURIComponent(id)}/${variant}` +} + +export function toAuthUser(profile: UserProfileResponse): AuthUser { + return { + id: profile.id, + email: profile.email, + username: profile.username, + image: profile.image, + role: profile.role, + is_disabled: profile.is_disabled, + cloud_count: profile.cloud_count, + received_like_count: profile.received_like_count, + created_at: profile.created_at, + } +} + +export function toCloudType(row: CloudTypeResponse): CloudType { + return { + id: row.id, + name: row.name, + genus: row.genus || '', + icon_id: row.icon_id, + rarity: row.rarity, + description: row.description, + created_at: row.created_at, + } +} + +export function toCloudDetail(row: CloudResponse): CloudDetail { + return { + id: row.id, + user_id: row.owner.id, + cloud_type_id: row.cloud_type.id, + image_url: imageUrl(row.id, 'original'), + thumbnail_url: imageUrl(row.id, 'preview'), + latitude: row.latitude, + longitude: row.longitude, + description: row.description, + captured_at: row.captured_at, + status: row.review_status, + is_hidden: row.is_hidden, + created_at: row.uploaded_at, + updated_at: row.updated_at, + cloud_type: { + id: row.cloud_type.id, + name: row.cloud_type.name, + rarity: row.cloud_type.rarity, + }, + owner: { id: row.owner.id, username: row.owner.username }, + cloud_type_name: row.cloud_type.name, + cloud_type_rarity: row.cloud_type.rarity, + username: row.owner.username, + received_like_count: row.received_like_count, + } +} diff --git a/src/lib/api.ts b/src/lib/api.ts index 0f42e04..7f48ebb 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -1,15 +1,6 @@ -import type { - ApiCloud, - AuthUser, - BackendCloudInfo, - BackendCloudType, - BackendUserProfile, -} from '@/types/api' -import type { CloudType } from '@/types/database' - const DEFAULT_API_URL = 'http://localhost:3000' -export const API_URL = (import.meta.env.VITE_API_URL || DEFAULT_API_URL).replace(/\/$/, '') +export const API_URL = (import.meta.env?.VITE_API_URL || DEFAULT_API_URL).replace(/\/$/, '') export const AUTH_EXPIRED_EVENT = 'opencloud:auth-expired' export class ApiError extends Error { @@ -26,64 +17,23 @@ export class ApiError extends Error { interface ApiRequestOptions extends Omit { body?: BodyInit | Record | null + query?: Record auth?: boolean } function isJsonBody(body: ApiRequestOptions['body']): body is Record { - return ( - !!body && - typeof body === 'object' && - !(body instanceof FormData) && - !(body instanceof Blob) && - !(body instanceof URLSearchParams) && - !(body instanceof ArrayBuffer) - ) -} - -function validationMessage(value: unknown) { - if (typeof value !== 'string') return '' - - try { - const issues = JSON.parse(value) as unknown - if (!Array.isArray(issues)) return value - const messages = issues - .map(issue => - issue && typeof issue === 'object' && 'message' in issue - ? String(issue.message) - : '', - ) - .filter(Boolean) - return messages.join(';') || value - } catch { - return value - } + if (!body || typeof body !== 'object') return false + const prototype = Object.getPrototypeOf(body) + return prototype === Object.prototype || prototype === null } function errorMessage(payload: unknown, fallback: string) { - if (!payload || typeof payload !== 'object') return fallback - - if ('detail' in payload) { - const detail = payload.detail - if (typeof detail === 'string') return detail - if (Array.isArray(detail)) { - const messages = detail - .map(item => - item && typeof item === 'object' && 'msg' in item ? String(item.msg) : '', - ) - .filter(Boolean) - if (messages.length) return messages.join(';') - } - } - - if ('error' in payload) { - const error = payload.error - if (typeof error === 'string') return error - if (error && typeof error === 'object' && 'message' in error) { - return validationMessage(error.message) || fallback - } - } - - return fallback + return payload && + typeof payload === 'object' && + 'message' in payload && + typeof payload.message === 'string' + ? payload.message + : fallback } async function readPayload(response: Response) { @@ -111,7 +61,7 @@ async function toApiError(response: Response) { } async function request(path: string, options: ApiRequestOptions = {}) { - const { body, auth = true, ...requestOptions } = options + const { body, query, auth = true, ...requestOptions } = options const headers = new Headers(requestOptions.headers) let requestBody: BodyInit | null | undefined = body as BodyInit | null | undefined @@ -120,7 +70,12 @@ async function request(path: string, options: ApiRequestOptions = {}) { requestBody = JSON.stringify(body) } - const response = await fetch(`${API_URL}${path}`, { + const search = new URLSearchParams() + for (const [key, value] of Object.entries(query ?? {})) { + if (value !== undefined) search.set(key, String(value)) + } + const url = `${API_URL}${path}${search.size ? `?${search}` : ''}` + const response = await fetch(url, { ...requestOptions, body: requestBody, headers, @@ -132,72 +87,18 @@ async function request(path: string, options: ApiRequestOptions = {}) { } if (!response.ok) throw await toApiError(response) - return (await readPayload(response)) as T + if (response.status === 204) return undefined as T + const payload = await readPayload(response) + if ( + !response.headers.get('content-type')?.includes('json') || + typeof payload === 'string' || + payload === undefined + ) { + throw new ApiError('服务器返回了无效的 JSON 响应', response.status, payload) + } + return payload as T } export function apiRequest(path: string, options: ApiRequestOptions = {}) { return request(path, options) } - -export function imageUrl(id: string, variant: 'preview' | 'original' = 'preview') { - return `${API_URL}/image/${encodeURIComponent(id)}/${variant}` -} - -export function toAuthUser(profile: BackendUserProfile): AuthUser { - return { - id: profile.id, - email: profile.email, - username: profile.name, - image: profile.image, - role: profile.role, - is_disabled: profile.is_disabled, - cloud_count: profile.cloud_count, - received_like_count: profile.received_like_count, - created_at: profile.created_at, - } -} - -export function toCloudType(row: BackendCloudType): CloudType { - return { - id: row.id, - name: row.name, - genus: row.genus || '', - icon_id: row.icon_id, - // Compatibility value for the excluded legacy admin view. Migrated pages - // display rarity_value directly because API.md defines no semantic mapping. - rarity: 'common', - rarity_value: row.rarity, - description: row.description, - created_at: row.created_at, - } -} - -export function toApiCloud(row: BackendCloudInfo): ApiCloud { - return { - id: row.id, - user_id: row.owner.id, - cloud_type_id: row.type.id, - image_url: imageUrl(row.id, 'original'), - thumbnail_url: imageUrl(row.id, 'preview'), - latitude: row.latitude, - longitude: row.longitude, - description: row.description, - captured_at: row.captured_at, - status: row.status, - is_hidden: row.is_hidden, - created_at: row.uploaded_at, - updated_at: row.updated_at, - cloud_type: { - id: row.type.id, - name: row.type.name, - rarity: 'common', - rarity_value: row.type.rarity, - }, - owner: { id: row.owner.id, username: row.owner.name }, - cloud_type_name: row.type.name, - cloud_type_rarity: 'common', - cloud_type_rarity_value: row.type.rarity, - username: row.owner.name, - received_like_count: row.received_like_count, - } -} diff --git a/src/lib/pagination.ts b/src/lib/pagination.ts new file mode 100644 index 0000000..40b1d90 --- /dev/null +++ b/src/lib/pagination.ts @@ -0,0 +1,16 @@ +export interface Page { + items: T[] + page: number + page_size: number + has_more: boolean +} + +/** Collect only for screens that need a complete set (heatmaps, likes, admin). */ +export async function collectPages(load: (page: number) => Promise>): Promise { + const items: T[] = [] + for (let page = 1; ; page++) { + const result = await load(page) + items.push(...result.items) + if (!result.has_more) return items + } +} diff --git a/src/types/api-contract.ts b/src/types/api-contract.ts new file mode 100644 index 0000000..e7764be --- /dev/null +++ b/src/types/api-contract.ts @@ -0,0 +1,191 @@ +// Generated from hono-api/src/schemas. Do not edit manually. +export type AdminCloudListQuery = { + page?: number + page_size?: number + review_status?: 'pending' | 'approved' | 'rejected' +} +export type CloudCreateResponse = { message: string; cloud_id: string } +export type CloudIdParams = { cloud_id: string } +export type CloudIdsBody = { cloud_ids: Array } +export type CloudImageParams = { + cloud_id: string + variant: 'original' | 'preview' +} +export type CloudListResponse = Array<{ + id: string + owner: { id: string; username: string } + cloud_type: { + id: number + name: string + genus: string | null + icon_id: string | null + rarity: number + description: string + } + latitude: number + longitude: number + description: string | null + captured_at: string | null + uploaded_at: string + updated_at: string + received_like_count: number + review_status: 'pending' | 'approved' | 'rejected' + is_hidden: boolean +}> +export type CloudMapQuery = { + start: string + end: string + time_field?: 'captured_at' | 'uploaded_at' + limit?: number +} +export type CloudPageResponse = { + items: Array<{ + id: string + owner: { id: string; username: string } + cloud_type: { + id: number + name: string + genus: string | null + icon_id: string | null + rarity: number + description: string + } + latitude: number + longitude: number + description: string | null + captured_at: string | null + uploaded_at: string + updated_at: string + received_like_count: number + review_status: 'pending' | 'approved' | 'rejected' + is_hidden: boolean + }> + page: number + page_size: number + has_more: boolean +} +export type CloudResponse = { + id: string + owner: { id: string; username: string } + cloud_type: { + id: number + name: string + genus: string | null + icon_id: string | null + rarity: number + description: string + } + latitude: number + longitude: number + description: string | null + captured_at: string | null + uploaded_at: string + updated_at: string + received_like_count: number + review_status: 'pending' | 'approved' | 'rejected' + is_hidden: boolean +} +export type CloudTypeCreateResponse = { + message: string + cloud_type_id: number +} +export type CloudTypeIdParams = { cloud_type_id: number } +export type CloudTypeResponse = { + id: number + name: string + genus: string | null + icon_id: string | null + rarity: number + description: string + created_at: string +} +export type CloudTypeSummary = { + id: number + name: string + genus: string | null + icon_id: string | null + rarity: number + description: string +} +export type CreateCloudTypeBody = { + name: string + genus?: string | null + icon_id?: string | null + rarity: number + description: string +} +export type CreateUserBody = { + username: string + email: string + password: string + role: 'user' | 'admin' +} +export type ErrorResponse = { + issues?: Array<{ path: string; message: string }> + message: string +} +export type MessageResponse = { message: string } +export type PaginationQuery = { page?: number; page_size?: number } +export type PublicCloudListQuery = { + page?: number + page_size?: number + filter?: 'type' | 'owner' + value?: string +} +export type ReviewCloudsBody = { + cloud_ids: Array + review_status: 'approved' | 'rejected' +} +export type ReviewStatus = 'pending' | 'approved' | 'rejected' +export type SetUserPasswordBody = { new_password: string } +export type SetUserRoleBody = { role: 'user' | 'admin' } +export type UpdateCloudBody = { + latitude?: number + longitude?: number + cloud_type_id?: number + description?: string + captured_at?: string + is_hidden?: boolean +} +export type UpdateCloudTypeBody = { + name?: string + genus?: string | null + icon_id?: string | null + rarity?: number + description?: string +} +export type UpdateUsernameBody = { username: string } +export type UploadCloudForm = { + image: File + cloud_type_id: number + latitude: number + longitude: number + description?: string + captured_at?: string + is_hidden: boolean | '0' | '1' | 'true' | 'false' +} +export type UserIdParams = { user_id: string } +export type UserListResponse = Array<{ + id: string + username: string + email: string + image: string | null + cloud_count: number + received_like_count: number + last_online: string | null + role: 'user' | 'admin' + is_disabled: boolean + created_at: string +}> +export type UserProfileResponse = { + id: string + username: string + email: string + image: string | null + cloud_count: number + received_like_count: number + last_online: string | null + role: 'user' | 'admin' + is_disabled: boolean + created_at: string +} diff --git a/src/types/api.ts b/src/types/api.ts deleted file mode 100644 index c3cb752..0000000 --- a/src/types/api.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { Cloud, CloudType, Profile } from '@/types/database' - -export interface BackendUserProfile { - id: string - name: string - email: string - image: string | null - cloud_count: number - received_like_count: number - last_online: string | null - role: Profile['role'] - is_disabled: boolean - created_at: string -} - -export interface BackendCloudType { - id: number - name: string - genus: string | null - icon_id: string | null - rarity: number - description: string | null - created_at: string -} - -export interface BackendCloudOwner { - id: string - name: string -} - -export interface BackendCloudInfo { - id: string - owner: BackendCloudOwner - type: BackendCloudType - latitude: number - longitude: number - description: string | null - captured_at: string | null - uploaded_at: string - updated_at: string - received_like_count: number - status: Cloud['status'] - is_hidden: boolean -} - -export interface ActionResponse { - status_code: number - message: string -} - -export interface CloudCreateResponse extends ActionResponse { - cloud_id: string -} - -export interface CloudTypeCreateResponse extends ActionResponse { - cloud_type_id: number -} - -// Internal view model. The backend profile is normalized here so existing -// layout/admin consumers do not need to understand the wire representation. -export interface AuthUser { - id: string - email: string - username: string - image: string | null - role: Profile['role'] - is_disabled: boolean - cloud_count: number - received_like_count: number - created_at: string -} - -export interface ApiCloudTypeSummary { - id: number - name: string - rarity: CloudType['rarity'] - rarity_value: number -} - -export interface ApiCloudOwner { - id: string - username: string -} - -export interface ApiCloud extends Cloud { - updated_at: string - cloud_type: ApiCloudTypeSummary | null - owner: ApiCloudOwner | null - cloud_type_name: string | null - cloud_type_rarity: CloudType['rarity'] | null - cloud_type_rarity_value: number | null - username: string | null - received_like_count: number -} diff --git a/src/types/database.ts b/src/types/models.ts similarity index 91% rename from src/types/database.ts rename to src/types/models.ts index ed30fa0..bd8450f 100644 --- a/src/types/database.ts +++ b/src/types/models.ts @@ -3,8 +3,7 @@ export interface CloudType { name: string genus: string icon_id: string | null - rarity: 'common' | 'uncommon' | 'rare' - rarity_value: number + rarity: number description: string | null created_at: string } diff --git a/src/types/view-models.ts b/src/types/view-models.ts new file mode 100644 index 0000000..0a741cc --- /dev/null +++ b/src/types/view-models.ts @@ -0,0 +1,36 @@ +import type { Cloud, Profile } from '@/types/models' + +// Internal view model. The backend profile is normalized here so existing +// layout/admin consumers do not need to understand the wire representation. +export interface AuthUser { + id: string + email: string + username: string + image: string | null + role: Profile['role'] + is_disabled: boolean + cloud_count: number + received_like_count: number + created_at: string +} + +export interface CloudTypeSummary { + id: number + name: string + rarity: number +} + +export interface CloudOwner { + id: string + username: string +} + +export interface CloudDetail extends Cloud { + updated_at: string + cloud_type: CloudTypeSummary | null + owner: CloudOwner | null + cloud_type_name: string | null + cloud_type_rarity: number | null + username: string | null + received_like_count: number +} diff --git a/tests/api.test.mjs b/tests/api.test.mjs new file mode 100644 index 0000000..490bf65 --- /dev/null +++ b/tests/api.test.mjs @@ -0,0 +1,134 @@ +import assert from 'node:assert/strict' +import { afterEach, mock, test } from 'node:test' +import { apiRequest, ApiError, AUTH_EXPIRED_EVENT } from '../src/lib/api.ts' +import { listClouds, uploadCloud, deleteClouds } from '../src/features/clouds/api.ts' +import { collectPages } from '../src/lib/pagination.ts' + +afterEach(() => mock.restoreAll()) + +const cloud = { + id: 'cloud-1', + owner: { id: 'owner-1', username: '观云者' }, + cloud_type: { id: 1, name: '积云', genus: null, icon_id: null, rarity: 2, description: '' }, + latitude: 0, + longitude: 0, + description: '', + captured_at: null, + uploaded_at: '2026-09-30T00:00:00.000Z', + updated_at: '2026-09-30T00:00:00.000Z', + received_like_count: 3, + review_status: 'approved', + is_hidden: false, +} + +test('cloud API encodes search once and maps the server page into view models', async () => { + mock.method(globalThis, 'fetch', async (url, options) => { + const parsed = new URL(url) + assert.equal(parsed.pathname, '/clouds') + assert.equal(parsed.searchParams.get('value'), '@云 & +') + assert.equal(parsed.searchParams.has('filter'), false) + assert.equal(options.credentials, 'include') + return Response.json({ items: [cloud], page: 1, page_size: 1, has_more: false }) + }) + const page = await listClouds({ page: 1, page_size: 1, value: '@云 & +' }) + assert.equal(page.has_more, false) + assert.equal(page.items[0].status, 'approved') + assert.equal(page.items[0].username, '观云者') + assert.equal(page.items[0].cloud_type_id, 1) + assert.match(page.items[0].image_url, /\/images\/cloud-1\/original$/) +}) + +test('JSON, typed arrays and multipart preserve their distinct encodings', async () => { + const calls = [] + mock.method(globalThis, 'fetch', async (url, options) => { + calls.push({ url, ...options }) + return Response.json({ message: '成功', cloud_id: 'new' }, { status: 201 }) + }) + await apiRequest('/example', { method: 'PATCH', body: { is_hidden: false } }) + assert.equal(calls[0].headers.get('content-type'), 'application/json') + assert.equal(calls[0].body, '{"is_hidden":false}') + const bytes = new Uint8Array([1, 2]) + await apiRequest('/example', { method: 'POST', body: bytes }) + assert.equal(calls[1].body, bytes) + const file = new File(['image'], 'cloud.png', { type: 'image/png' }) + await uploadCloud({ + image: file, + cloud_type_id: 1, + latitude: 0, + longitude: 0, + is_hidden: false, + }) + assert.equal(calls[2].headers.has('content-type'), false) + assert.equal(calls[2].body.get('is_hidden'), 'false') + assert.equal(calls[2].body.get('cloud_type_id'), '1') + assert.equal(calls[2].body.has('type_id'), false) + assert.equal(calls[2].body.get('image').name, file.name) +}) + +test('errors retain status and validation issues; invalid success responses fail early', async () => { + const payload = { message: '用户名已被占用', issues: [{ path: 'username', message: '重复' }] } + mock.method(globalThis, 'fetch', async () => Response.json(payload, { status: 409 })) + await assert.rejects(apiRequest('/profiles/me'), error => { + assert.ok(error instanceof ApiError) + assert.equal(error.status, 409) + assert.equal(error.message, payload.message) + assert.deepEqual(error.detail, payload) + return true + }) + globalThis.fetch.mock.mockImplementation(async () => new Response('gateway')) + await assert.rejects(apiRequest('/clouds'), /无效的 JSON/) + globalThis.fetch.mock.mockImplementation( + async () => new Response('{oops', { headers: { 'content-type': 'application/json' } }), + ) + await assert.rejects(apiRequest('/clouds'), /无效的 JSON/) + globalThis.fetch.mock.mockImplementation(async () => new Response(null, { status: 204 })) + assert.equal(await apiRequest('/clouds'), undefined) +}) + +test('401 expires required sessions only; transport never retries a mutation', async () => { + const events = new EventTarget() + let expired = 0 + events.addEventListener(AUTH_EXPIRED_EVENT, () => expired++) + globalThis.window = events + try { + const fetch = mock.method(globalThis, 'fetch', async () => + Response.json({ message: '失效' }, { status: 401 }), + ) + await assert.rejects(apiRequest('/clouds', { method: 'POST', body: {} })) + assert.equal(expired, 1) + await assert.rejects(apiRequest('/clouds', { auth: false })) + assert.equal(expired, 1) + assert.equal(fetch.mock.callCount(), 2) + } finally { + delete globalThis.window + } +}) + +test('pagination follows explicit has_more even on a full final page', async () => { + const pages = [] + const result = await collectPages(async page => { + pages.push(page) + return { items: [page], page, page_size: 1, has_more: page < 2 } + }) + assert.deepEqual(result, [1, 2]) + assert.deepEqual(pages, [1, 2]) +}) + +test('batch deletion reports completed batches when a later request fails', async () => { + const completed = [] + let calls = 0 + mock.method(globalThis, 'fetch', async (_url, options) => { + const ids = JSON.parse(options.body).cloud_ids + assert.ok(ids.length <= 100) + assert.ok(ids.every(id => typeof id === 'string')) + return ++calls === 1 + ? Response.json({ message: '完成' }) + : Response.json({ message: '失败' }, { status: 500 }) + }) + const ids = Array.from({ length: 101 }, (_, index) => String(index)) + await assert.rejects( + deleteClouds(ids, batch => completed.push(...batch)), + /失败/, + ) + assert.deepEqual(completed, ids.slice(0, 100)) +})