# 读端点（v1）

> **API 版本**：v1 ｜ **文档版本**：v1.0 / 2026-11-10
> 返回索引：[index.md](https://r2.2libra.com/openapi/v1/index.md) ｜ 通用约定（信封、错误码、分页、配额）都在那里

本文件覆盖全部**读**端点。基地址 `https://2libra.com/api`，下文的路径都省去这个前缀。

- 所有读端点**允许匿名**：不带 `Authorization` 头就能调用。
- 带令牌时会多返回个性化字段（`is_following`、`voted`、`favorited`、`is_bookmarked` 等）。
- 带了令牌但不合法 → `401`（不会静默降级为匿名）。
- 响应只列出**稳定字段**。实际可能更多，请忽略不认识的字段 —— 我们随时会新增。

**通用分页**：`?page=1&limit=20`。各区段会标注该端点的 `limit` 上限。
**通用大小限制**：单次响应通常不超过几百 KB；列表类端点请老实分页，不要一次拉 `limit=9999`。

---

## 节点

### GET /v1/nodes

节点（板块）列表。带 `?parent=<slug>` 时返回该节点的子节点，否则返回顶层节点。

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `parent` | string | 否 | 父节点 `slug` |

```bash
curl 'https://2libra.com/api/v1/nodes'
```

```json
{
  "c": 0,
  "d": [
    {
      "id": "uuid",
      "slug": "work",
      "name": "职场",
      "description": "...",
      "post_count": 1234,
      "parent_slug": null,
      "sort_order": 1,
      "dimension": "work"
    }
  ],
  "m": "请求成功",
  "t": 1730000000000
}
```

返回的是**数组**，不是分页对象。节点列表变化极少，建议本地缓存 10 分钟以上。

### GET /v1/nodes/:slug

单个节点详情，结构同上（单个对象）。

---

## 帖子

### GET /v1/topics

帖子列表。支持按节点 / 标签 / 维度 / 排序组合筛选。

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `page` | int | **是** | 从 1 开始。**这个端点必须显式传** |
| `limit` | int | **是** | 1～100。**这个端点必须显式传** |
| `node_id` | uuid | 否 | 节点内部 uuid（不是 slug） |
| `parent_slug` | string | 否 | 按父节点筛选（如 `parent_slug=work`） |
| `tag` | string | 否 | 标签名，如 `tag=前端` |
| `sort` | string | 否 | `latest`（默认）或 `value`（按价值） |
| `dimensions` | string | 否 | `work` / `life` / `special` |
| `rss` | bool | 否 | 只要 RSS 聚合来的文章 |

> ⚠️ `page` 与 `limit` 在本端点是**必填**（其它端点可省略走默认值）。
> 漏传会得到 `400 invalid_request`。

```bash
curl 'https://2libra.com/api/v1/topics?page=1&limit=20&parent_slug=work&sort=latest'
```

响应是**分页对象**，注意列表字段名是 `posts` 而不是 `items`：

```json
{
  "c": 0,
  "d": {
    "posts": [
      {
        "id": "uuid",
        "short_id": "a1b2c3d4",
        "title": "标题",
        "type": "normal",
        "is_pinned": false,
        "comment_count": 12,
        "views": 340,
        "created_at": "2026-11-01T09:00:00.000Z",
        "updated_at": "2026-11-01T09:00:00.000Z",
        "author": {
          "id": "uuid",
          "username": "jimmy",
          "avatar_url": "https://r2.2libra.com/avatars/ab12/cd34/<uuid>.jpg?t=1730000000000",
          "level": 4,
          "is_pro": false,
          "role": "user"
        },
        "node": { "id": "uuid", "slug": "work", "name": "职场" },
        "tags": [{ "name": "职场" }]
      }
    ],
    "total": 1234,
    "page": 1,
    "limit": 20,
    "total_pages": 62
  },
  "m": "请求成功",
  "t": 1730000000000
}
```

匿名帖的 `author` 为 `null`。必须判空，不要直接取 `post.author.username`。

### GET /v1/topics/latest

最新帖子流（首页 feed）。

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `page` | int | 否 | 默认 1 |
| `limit` | int | 否 | 默认 50，最大 50 |

响应结构同 `GET /v1/topics`（`posts` + 分页字段）。

### GET /v1/topics/hot

热门帖。

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `page` | int | 否 | 默认 1 |
| `limit` | int | 否 | 默认 50，最大 50 |
| `period` | string | 否 | `24h`（默认）或 `7d` |

响应结构同上。

### GET /v1/topics/:short_id

帖子详情。这是内容最丰富的一个端点，服务端已经把互动块**结构化**了，
客户端不需要自己解析正文去还原投票 / 竞猜 / 附言。

```bash
curl 'https://2libra.com/api/v1/topics/a1b2c3d4'
```

稳定字段：

| 字段 | 说明 |
| --- | --- |
| `short_id` | 帖子对外 ID |
| `title` | 标题 |
| `content` | 正文 markdown。表情是 `:code:` 短码，需用 `GET /v1/emojis` 渲染 |
| `type` | `normal` / `anonymous` / `moment` 等 |
| `is_pinned` | 是否置顶 |
| `comments_closed` | 评论是否已关闭 |
| `views` / `comment_count` | 浏览数 / 评论数 |
| `created_at` / `updated_at` | ISO 8601（UTC） |
| `author` | 作者对象，匿名帖为 `null`；含 `id` / `username` / `avatar_url` / `level` / `is_pro` / `role` / 佩戴徽章 |
| `node` | `{ id, slug, name }`，可能带 `main_emojis` / `sub_emojis`（推荐表情） |
| `tags` | `[{ name }]` |
| `extra` | **互动块**，见下 |
| `postscripts` | 附言列表（可能不存在） |
| `reactions` | 表情表态聚合 |

`extra` 下可能出现（有哪个用哪个，不要假设都存在）：

| 键 | 内容 |
| --- | --- |
| `extra.vote` | 投票：`type`、`title`、`options[]{id, text, vote_count}`、`vote_deadline`、`voting_finished`、`result_visible`、`max_choices`、当前用户是否已投 |
| `extra.bet` | 竞猜：`title`、`status`、`participate_deadline`、`options[]{id, text, total_coins}`、`settled_option_id` |
| `extra.reward_pool` | 金币池状态（余额、参与人数等） |
| `extra.redeem_codes` | 兑换码列表，**只有 `masked_code` 与 `is_redeemed`**；已领取的才给完整 `code` |
| `extra.lottery` | 抽奖结果：中奖名单（`username` / 楼层） |
| `extra.restricted` | 隐藏内容的可见性门槛（等级 / 属性 / 付费要求） |
| `extra.scores` | 评分数据 |

详尽的互动块字段与写操作见 [api-write.md](https://r2.2libra.com/openapi/v1/api-write.md) 与
[api-economy.md](https://r2.2libra.com/openapi/v1/api-economy.md)。

---

## 评论

### GET /v1/topics/:short_id/replies

帖子的评论。

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `page` | int | 否 | 默认 1 |
| `limit` | int | 否 | 默认 30，最大 100 |
| `flat` | string | 否 | `1` 或 `true` 时返回**扁平时间序**（不嵌套楼层） |

```bash
# 楼层视图
curl 'https://2libra.com/api/v1/topics/a1b2c3d4/replies?page=1&limit=30'

# 扁平视图
curl 'https://2libra.com/api/v1/topics/a1b2c3d4/replies?flat=1&limit=50'
```

响应：

```json
{
  "c": 0,
  "d": {
    "items": [
      {
        "id": "uuid",                    // 评论 ID（编辑 / 表态 / 书签都用它）
        "content": "评论正文",
        "floor": 3,                      // 楼层号
        "flat_floor": 3,                 // 扁平楼层号
        "located_floor": 3,
        "is_deleted": false,
        "created_at": "2026-11-01T10:00:00.000Z",
        "author": { "id": "uuid", "username": "jimmy", "avatar_url": "...", "level": 4 },
        "parent": { "id": "uuid", "content": "...", "floor": 2 },
        "children": [],                  // 楼层视图下的嵌套回复
        "reactions": [ { "emoji": "👍", "count": 2, "mine": false } ],
        "is_bookmarked": false           // 带令牌时才有意义
      }
    ],
    "pinned": [],
    "total": 42,
    "page": 1,
    "limit": 30,
    "total_pages": 2
  },
  "m": "请求成功",
  "t": 1730000000000
}
```

- 帖子不存在或不可见时 `d` 为 `null`，请判空。
- 楼层视图（默认）的 `children` 是嵌套回复；扁平视图（`flat=1`）用 `located_floor` / `flat_floor` 排序展示。
- 分身评论的 `author` 已经被替换成分身信息，**服务端不会下发真实用户名** —— 直接展示即可。

### GET /v1/comments/latest

全站最新评论时间线。

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `page` | int | 否 | 默认 1（该端点实际只取前 N 条，分页含义有限） |
| `limit` | int | 否 | 默认 20，最大 50 |

返回 `{ items: [...] }`，每条评论带上所属帖子的 `short_id` / `title` 与节点信息，
可以直接做出「最新回复」列表并跳转到帖子。守护者节点的评论不在其中。

---

## 用户

### GET /v1/users/:userName

用户主页资料。`userName` 长度 3～36。

```bash
curl 'https://2libra.com/api/v1/users/jimmy'
```

稳定字段：

| 字段 | 说明 |
| --- | --- |
| `id` | 用户 uuid（做外键用） |
| `username` | 昵称（公开标识，做链接用） |
| `user_number` | 展示用的用户号 |
| `display_name` | 展示名（可能为空） |
| `avatar_url` | 已是完整地址 |
| `level` | 等级 |
| `is_pro` | 是否 PRO |
| `pro_sponsor_amount_365d` | 近 365 天 Pro 赞助额 |
| `role` | `user` / `moderator` / `admin` 等 |
| `created_at` | 注册时间 |
| `follower_count` / `following_count` | 粉丝数 / 关注数 |
| `is_following` | 当前用户是否已关注他（匿名时为 `false`） |
| `equipped_badges` | 当前佩戴的徽章（`item.badge` 里是徽章本体，含 `icon_url` / `icon_url_large`） |
| `user_attributes` | 六维属性：`experience_attr` / `diligence_attr` / `karma_attr` / `charm_attr` / `craft_attr` / `luck_attr`（字符串数字） |
| `pandora_info` | 盲盒头像特效信息（可能为 `null`） |
| `has_readme` | 是否填写了个人简介 —— **只有布尔値，不返回简介正文** |
| `home_view` | 主页访问量 |
| `show_setting` | 是否本人（`id === 当前用户`） |

**不会返回**：`email`、`coins`、`exp` 原始值、简介正文、风控与登录信息。

用户不存在返回 `404 not_found`。

### GET /v1/users/:userName/topics

该用户发布的帖子。

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `page` | int | 否 | 默认 1 |
| `limit` | int | 否 | 默认 50，最大 50 |
| `collection` | string | 否 | 按该用户的合集名筛选 |
| `rss` | string | 否 | `0` / `1` / `true` / `false` |

响应结构同 `GET /v1/topics`（`posts` + 分页字段）。

### GET /v1/users/:userName/replies

该用户发布的评论。

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `page` | int | 否 | 默认 1 |
| `limit` | int | 否 | 默认 20，最大 50 |

## 徽章

### GET /v1/users/:userNameOrId/badges

用户徽章墙。路径参数可以传 `username`，也可以传用户 uuid。

本人访问时（带令牌且是自己）会多返回隐私字段（`custom_fields` 里标了 `private` 的条目）。

响应是 **`user_badge` 包 `badge`** 的嵌套结构：

```json
{
  "c": 0,
  "d": [
    {
      "id": "user_badge 的 uuid",
      "user_id": "uuid",
      "badge_id": "uuid",
      "achieved_at": "2026-01-01T00:00:00.000Z",
      "custom_fields": null,
      "badge": {
        "id": "uuid",
        "code": "early_bird",
        "name": "早起鸟",
        "description": "...",
        "icon_url": "https://r2.2libra.com/badge/svg/early_bird.svg",
        "icon_url_large": "https://r2.2libra.com/badge/early_bird.svg",
        "level": 1,
        "attributes": { "exp": 10 },
        "acquisition_condition": "...",
        "user_count": 1234,
        "user_percentage": 12.3
      }
    }
  ],
  "m": "请求成功",
  "t": 1730000000000
}
```

注意徽章信息在 `item.badge` 里，**不在顶层**。

**展示尺寸规则**：≤32px 的槽位（用户名旁）用 `icon_url`；
弹窗 80px、卡片 64px 用 `icon_url_large`。小图固有尺寸只有 22×15，放大必糊。
`icon_url_large` 可能不存在（部分徽章只有小图），此时回退 `icon_url`。

**不要用 `code` 去拼图片地址** —— 有徽章的 `code` 与文件名不一致，会 404。始终用返回的 URL。

### GET /v1/users/:userNameOrId/badges/equipped

同上，但只返回**当前佩戴中**的徽章，结构是 `user_equipped_badge` 包 `badge`：

```json
[
  {
    "id": "uuid",
    "user_id": "uuid",
    "badge_id": "uuid",
    "equipped_at": "2026-01-01T00:00:00.000Z",
    "badge": { "id": "uuid", "code": "...", "name": "...", "icon_url": "...", "icon_url_large": "..." }
  }
]
```

## 表情

### GET /v1/emojis

站内**自定义图片表情**的短码到图片地址映射。

```json
{
  "c": 0,
  "d": [
    {
      "id": "uuid",
      "code": ":sigh:",
      "name": "叹气",
      "img_url": "https://r2.2libra.com/emojis/sigh.png",
      "names": ["叹气", "sigh"],
      "keywords": ["sigh"]
    }
  ],
  "m": "请求成功",
  "t": 1730000000000
}
```

- `code` **带两端冒号**（`:sigh:`），正文里就是这个形式。渲染时把 `:sigh:` 替换成 `<img>`。
- `img_url` 已是完整地址。
- `names` / `keywords` 用于选择器的搜索。

**这是渲染正确性的前提，不是可选功能**：正文里的 `:sigh:` 如果没有这张表，
只能把短码原样显示出来。建议启动时拉一次并缓存（变更频率极低）。

---

## 合集

### GET /v1/users/:userName/collections

某用户的**公开**合集列表。

### GET /v1/collections/:collectionId/topics

合集内的帖子。带令牌时会返回收藏状态之类的个性化字段。

合集 ID 从上一个端点拿。

---

## 标签

### GET /v1/tags/hot

今日热门标签。返回数组，每项含 `name` 与热度信息。

```bash
curl 'https://2libra.com/api/v1/tags/hot'
```

---

## 搜索

### GET /v1/search

全站搜索。

| 参数 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| `q` | string | **是** | 关键词，**至少 2 个字符**，最长 100 |
| `type` | string | **是** | `posts` / `users` / `nodes` / `tags` / `favorites` |
| `page` | int | 否 | 默认 1 |
| `limit` | int | 否 | 默认 20，最大 50 |

```bash
curl 'https://2libra.com/api/v1/search?q=职场&type=posts&page=1&limit=20'
```

⚠️ **这个端点单独限流**：匿名 5 次/分，登录 20 次/分。不要用它做「输入即搜索」，
请加 300ms 以上的防抖，也不要把它当成列表翻页的来源。

关键词不足 2 字符返回 `400 invalid_request`，带 `field: "q"`。

---

## 我的（需要令牌）

以下都在 `https://2libra.com/api/v1/me` 下。

| 端点 | scope | 关键参数 | 说明 |
| --- | --- | --- | --- |
| `GET /me` | `profile` | — | 当前用户资料。结构与 `GET /v1/users/:username` 一致，`show_setting` 为 `true` |
| `GET /me/summary` | `profile` | — | 精简身份快照。**启动时校验登录态请用它**，比 `/me` 便宜 |
| `GET /me/badges` | `profile` | — | 我拥有的徽章（含隐私字段） |
| `GET /me/badges/equipped` | `profile` | — | 我佩戴中的徽章 |
| `GET /me/topics` | `profile` | `q` | 我的帖子（选择器用，`q` 做标题过滤，不分页） |
| `GET /me/notifications` | `read:me` | `page` `limit`(≤50) `type` `unread_only=true` | 通知列表 |
| `GET /me/notifications/unread-count` | `read:me` | — | `{ unread_count }`，做角标用 |
| `GET /me/notifications/stream` | `read:me` | — | **SSE 长连接**，有新通知时收到事件（见下） |
| `POST /me/notifications/read` | `read:me` | — | 全部标记已读，返回 `{ ok: true }` |
| `GET /me/collections` | `read:me` | — | 我的合集 |
| `GET /me/follows/timeline` | `read:me` | `page` `limit`(≤50) | 关注流 |
| `GET /me/follows/unread-count` | `read:me` | `last_read_at`(ISO) | 关注流未读数；不传返回 `0` |
| `GET /me/favorites` | `read:me` | `page` `limit`(≤50) | 我收藏的帖子（列表字段是 `posts`） |
| `GET /me/bookmarks` | `read:me` | `page` `limit`(≤50) | 我的评论书签 |

**通知端点的说明**

- `type` 用于过滤通知类别，取值与 web 一致，按需传。
- `unread_only=true` 只返回未读。
- 列表字段名是 **`list`**（不是 `items`）：

```json
{
  "c": 0,
  "d": {
    "list": [
      {
        "id": "uuid",
        "type": "reply",
        "payload": { "...": "..." },
        "is_read": false,
        "created_at": "2026-11-01T10:00:00.000Z",
        "from_user": { "id": "uuid", "username": "jimmy", "avatar_url": "..." },
        "emojis": { ":sigh:": "https://r2.2libra.com/emojis/sigh.png" }
      }
    ],
    "total": 12,
    "page": 1,
    "limit": 50,
    "total_pages": 1
  }
}
```

- 想要实时提醒，用下面的 **SSE 长连接**，不要轮询整个通知列表。
  没有长连接的场景（或在后台）请轮询未读数（建议 60 秒一次），不要轮询列表。

### GET /v1/me/notifications/stream（SSE）

`text/event-stream` 长连接。**令牌失效或连接建立失败时才会有正常 HTTP 响应**，
一旦返回 200 就只剩事件流，不会再有 JSON 信封 —— 这个端点是全套 API 里
**唯一不返回 `{c,m,d,t}`** 的（流式端点没有信封）。

```
Authorization: Bearer <access_token>      ← 只认请求头，不接受 ?access_token=
Accept: text/event-stream
```

事件契约（只会出现这四种帧，未知 `type` 请忽略）：

| 帧 | 含义 | 客户端动作 |
| --- | --- | --- |
| `data: {"type":"ready"}` | 握手成功，连接已生效 | 停止轮询 |
| `data: {"type":"notification_update"}` | 有新通知（回复 / 提及 / 系统 / 打赏 …） | 拉一次 `/me/notifications/unread-count`，或按需刷列表 |
| `data: {"type":"reconnect"}` | 服务端要主动断开（令牌即将过期 / 连接寿命到顶） | **不是错误**，带（刷新后的）令牌重连 |
| `retry: 25000`（无 `data` 行） | 心跳，防中间层空闲超时 | 忽略 |

连接上的原始字节长这样（`data:` 之后才是 JSON；空行分隔帧）：

```
data: {"type":"ready"}

retry: 25000

data: {"type":"notification_update"}

retry: 25000
```

**必须知道的几条**：

- 事件里**只有事件类型，没有通知内容**。收到后自己拉未读数 / 列表，不要期待
  推送里带正文（第三方应用无权缓存他人内容，见 terms.md）。
- 连接**最长活 15 分钟**，到点会先发 `reconnect` 再断开；令牌剩余寿命更短时按令牌过期断开。
  重连是正常节奏，不是故障。请带**指数退避**（5s → 10s → 20s → 40s → 60s 封顶）。
- 同一用户**最多 10 条并发连接**（含网页端），超限返回 `429 rate_limited`，
  错误体里 `dimension: "connections"`。旧连接不关就开新连接是最常见的踩雷方式。
- 建连频率：每 (应用, 用户) **每分钟 6 次**，超限 `429 rate_limited`（`dimension: "user_route"`）。
- 收到 `401`：刷新令牌后重连；刷新失败则重新走授权流程。
- 服务端每 25 秒发一次 `retry:` 心跳；`fetch()` 读流超过 45 秒没有任何字节时可以认为连接已死，
  主动断开重连（不要无限等待）。

**浏览器里怎么接**：`EventSource` 不能设置请求头，而本端点不接受查询参数令牌，
所以浏览器请用 `fetch()` 读流：

```js
const res = await fetch("https://2libra.com/api/v1/me/notifications/stream", {
  headers: { Authorization: `Bearer ${accessToken}`, Accept: "text/event-stream" },
  signal: controller.signal,
});
if (res.status === 401) { /* 刷新令牌或重新授权 */ }
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
  const { done, value } = await reader.read();
  if (done) break;
  buffer += decoder.decode(value, { stream: true });
  const frames = buffer.split("\n\n");
  buffer = frames.pop() ?? "";
  for (const frame of frames) {
    const dataLine = frame.split("\n").find((l) => l.startsWith("data:"));
    if (!dataLine) continue;                       // 心跳帧
    const { type } = JSON.parse(dataLine.slice(5).trim());
    if (type === "notification_update") refreshUnreadCount();
    if (type === "reconnect") scheduleReconnect();
  }
}
```

> 写操作本来就必须走你自己的服务端转发（见 index.md 三、浏览器 SPA 的限制），
> 长连接只解决「读」，不改变这条边界。

**列表字段名不统一，这是本 API 的一个已知事实**：

| 端点组 | 列表字段名 |
| --- | --- |
| `/v1/topics`、`/v1/topics/latest`、`/v1/topics/hot`、`/v1/users/:name/topics`、`/v1/me/favorites` | `posts` |
| `/v1/me/notifications` | `list` |
| `/v1/topics/:id/replies`、`/v1/me/bookmarks`、`/v1/me/follows/timeline`、`/v1/comments/latest` | `items` |
| `/v1/nodes`、`/v1/tags/hot`、`/v1/emojis`、`/v1/users/:name/badges` | 直接是数组 |

**不要写一个通用的「取 items」函数** —— 它会在三个端点上默默返回 `undefined`。

**日期参数**：`start_date` / `end_date` 统一 `YYYY-MM-DD`，按东八区自然日解释。

---

## 效率建议

1. **节点、表情、徽章图鉴**：启动时拉一次，本地缓存，不必每次请求都取。
2. **列表页**：老老实实分页，不要一次拉 100 条然后前端截断 —— 配额是按请求次数算的。
3. **匿名请求**：只读场景不要带令牌。带了令牌会走另一套配额桶（每小时 600 次），
   匿名是每分钟 60 次，对高并发只读场景反而更宽松。
4. **头像 / 徽章图片**：`r2.2libra.com` 有 CDN，直接用返回的 URL，不要下载后再分发。
