---
title: "Posts API Contract (for backend)"
type: api-contract
date: 2026-05-25
audience: backend
status: draft
---

# Posts API Contract

> 这份文档是从 `2026-05-25-telegram-style-resource-stream-design.md` §1–§2 抽出，供后端实现使用。前端已用 mock data 完成视觉；上线时把 `VITE_USE_MOCK_POSTS=false` 即可切真接口。

## 1. 数据模型

```ts
type AttachmentKind = "image" | "video" | "document";

type Attachment = {
  id: string; // 唯一 id
  kind: AttachmentKind; // 三大类，前端按此分支渲染
  url: string; // 原始文件地址
  mime: string; // image/jpeg, application/pdf, video/mp4, ...
  filename: string; // 显示用文件名，含扩展名
  sizeBytes: number; // 字节数；前端格式化为 "3.5 MB"
  width?: number; // image/video 用于占位比例（CLS 优化）
  height?: number;
  durationSec?: number; // video 专用
  posterUrl?: string; // video 海报缩略图
  thumbnailUrl?: string; // image 缩略，列表用减少流量
};

type Post = {
  id: string;
  categoryId: number;
  categorySlug: string;
  language: string; // "zh-CN" | "en" | "ja" | "ko" | "vi" | "id" | "ms"
  text?: string; // 可选，纯文本/图说；前端做 https → 链接自动识别
  attachments: Attachment[]; // 0~N；text-only post 时为 []
  isRecommended: boolean;
  publishedAt: string; // ISO 8601；用于排序 + 日期分组
  updatedAt: string;
};

type PostListResponse = {
  items: Post[];
  nextCursor?: string; // 不透明 cursor；undefined = 没有下一页
};
```

### 关键约定

- **图片当文档**（在前端显示为「文件下载卡」）：`kind === "document"` 且 `mime.startsWith("image/")`。Admin 上传时通过开关决定走 image 还是 document 通道。
- **图片当图片**（前端显示为图片预览）：`kind === "image"`。
- **多图相册**：一个 Post 带多个 `kind === "image"` 的 attachments。前端会在 2-4 grid 中渲染，attachments.length > 4 时第 4 格模糊 + `+N`。
- **图片 + 文字**：Post 同时有 `text` 与 attachments。
- **纯文本 / 链接**：Post 仅有 `text`，`attachments: []`。
- **视频**：`kind === "video"` 单 attachment。`posterUrl` 用于预览，`durationSec` 用于角标。
- Attachment 内不携带任何「上传者头像 / 管理员标签」等社交字段（前端已下线）。

## 2. Endpoints

### 2.1 列表（核心）

```
GET /api/posts
```

Query 参数：

| 参数       | 必填 | 说明                                                                               |
| ---------- | ---- | ---------------------------------------------------------------------------------- |
| `lang`     | 是   | UI 语言；后端可据此选择不同语言版本的 `text`                                       |
| `category` | 否   | category slug；不传 = 全部分类                                                     |
| `type`     | 否   | `all` / `image` / `video` / `pdf` / `ppt` / `text` / `link` / `archive`；语义见 §3 |
| `language` | 否   | 资源语言：`zh-CN` / `en` / `ja` / `ko` / `vi` / `id` / `ms`                        |
| `cursor`   | 否   | 上一次返回的 `nextCursor`；不传 = 第一页                                           |
| `limit`    | 否   | 默认 20，最大 50                                                                   |

返回：`PostListResponse`

排序：`publishedAt DESC`。

### 2.2 Home 用聚合接口（可选，沿用现状）

```
GET /api/posts/recommended?lang=&limit=
GET /api/posts/latest?lang=&limit=
```

返回：`{ items: Post[] }`（不分页）

### 2.3 单条（用于老链接 301 落地）

```
GET /api/posts/:id
```

返回：`Post`（或 404）

前端 `/resource/:id` 现在是轻量重定向：拿到 `categorySlug` → `/category/<slug>#post-<id>` 锚点滚动。

### 2.4 分类（不变）

```
GET /api/categories?lang=
```

返回：现有 `Category[]`。

### 2.5 Admin CRUD

```
POST   /api/admin/posts
PUT    /api/admin/posts/:id
DELETE /api/admin/posts/:id
GET    /api/admin/posts?...  (含未发布草稿)
```

需求：

- 支持多附件上传（一次 multipart 或先 `POST /api/admin/upload` 拿到 url 再创建 Post）。
- Admin UI 需要一个开关：「图片以图片形式呈现 / 以文档形式呈现」，对应 attachment.kind 的 image vs document。
- 支持发布/隐藏、置顶/官方推荐。

> Admin UI 改造单独建 spec / plan，本契约仅说明后端必须支持这些字段。

## 3. `type` 参数语义

一个 Post 命中某个 `type`，规则：

| type      | 命中条件                                                                   |
| --------- | -------------------------------------------------------------------------- |
| `all`     | 全部                                                                       |
| `image`   | `attachments` 中至少一个 `kind === "image"` 或 `mime.startsWith("image/")` |
| `video`   | 至少一个 `kind === "video"` 或 `mime.startsWith("video/")`                 |
| `pdf`     | 至少一个 `mime === "application/pdf"` 或扩展名为 `pdf`                     |
| `ppt`     | 至少一个扩展名为 `ppt` / `pptx` / `key` 或 mime 含 `presentation`          |
| `archive` | 至少一个扩展名为 `zip` / `rar` / `7z` / `tar` / `gz`                       |
| `text`    | `text` 非空                                                                |
| `link`    | `text` 非空且匹配 `https?://`                                              |

前端 mock 已按此规则过滤，便于切真接口时口径一致。

## 4. 删除 / 废弃

| 项                                             | 处理                                                                                                                                                                                        |
| ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST /api/resources/:id/favorite`             | 删除                                                                                                                                                                                        |
| `GET /api/favorites` / 收藏列表                | 删除（前端 `/favorites` 路由已移除）                                                                                                                                                        |
| `/r/:id` 老前端路由                            | 已合并到 `/resource/:id` 重定向                                                                                                                                                             |
| 老 `/api/resources*` 系列                      | 后端可保留过渡期。建议提供数据迁移脚本：每个老 Resource → 一个 Post（带 1 个 attachment 或 text-only）。`isRecommended` / `language` / `categorySlug` 字段迁移；`favorite count` 字段丢弃。 |
| Resource.coverImage 与 Resource.fileUrl 二选一 | 转为 attachments[0]（kind 由后端判断 image vs document）                                                                                                                                    |

## 5. Search

`GET /api/resources?q=...` 当前仍被 SearchPage 使用（在新 schema 上线前过渡）。后端可视情况：

- 短期：保留旧接口
- 长期：新增 `GET /api/posts/search?q=...` 返回 `PostListResponse`，前端再切

## 6. 错误格式

沿用现状（HTTP 状态码 + 文本 body）。前端 `getJSON` 会把非 2xx 当作 `Error(text)` 抛出，`MessageStream` 显示红色横幅 + 重试按钮。

## 7. 兼容性 / 灰度

后端 ready 时步骤：

1. 把示例数据导入到 Posts 表
2. `/api/posts` 在 staging 通过
3. 前端 staging 部署设 `VITE_USE_MOCK_POSTS=false`，跑通后再 prod
4. 前端代码层面：删除 `src/mocks/mockPosts.ts` 与 `usePostStream.ts` 中的 mock 分支，或保留 mock 用于本地离线开发

## 8. 联系

前端：Terry。Spec 主文档：`.unipi/docs/specs/2026-05-25-telegram-style-resource-stream-design.md`。
