# 约定

> 所有接口共用的规则：引用号、请求体、响应包装、分页、时间、错误码和限流。

最后更新：2026-11-06 · 原文：https://qunli.co/docs/developers/conventions

## 概述

- 请求和响应都是 JSON，字段名用 `snake_case`。
- 内部 id（`id` 和各种 `*_id`）是 UUID 字符串。
- 时间是带 `Z` 的 ISO 8601，例如 `2026-05-28T03:14:22Z`；只有日期的字段（如 `due_date`）是 `YYYY-MM-DD`。
- 布尔值是 `true` / `false`；空集合是 `[]`，不是 `null`。
- 已知不存在的值是 `null`；不适用的字段直接省略。
- 新字段随时可能出现，客户端要忽略不认识的字段。

## 引用号

群历里的每一条内容都有一个**引用号**（`public_id`）：类型前缀加团队内递增的数字，例如 `T1042`。网页地址里看到的就是它：`qunli.co/2025/T1042` 里的 `T1042`。

| 前缀 | 类型 |
| - | - |
| `T` | 任务 |
| `M` | 公告和讨论 |
| `D` | 文档 |
| `G` | 目标 |
| `S` | 日程 |
| `F` | 文件 |
| `B` | 文件夹 |
| `E` | 转发的邮件 |
| `C` | 评论 |
| `U` | 成员（人和机器人） |

- **URL 路径里一律用引用号**，例如 `GET /api/v1/accounts/2025/tasks/T1042`、`POST /api/v1/accounts/2025/recordings/T1042/comments`。
- 引用号不区分大小写，可以带前导 `#`：`T1042`、`t1042`、`#T1042` 是同一个。
- 在一个团队里，一个引用号只指一条内容；前缀不对时返回 `404`，不会误中别的内容。
- 不知道类型时，用 `GET /api/v1/accounts/{account_id}/recordings/{ref}` 读取任意引用号，返回它的完整内容。
- 内容在项目之间转移后，引用号不变。
- 请求体和响应体里的关联字段（`parent_id`、`task_status_id`、`assignee_id`……）用 UUID，不用引用号。
- 成员引用号（`U12`）可以用在 `/accounts/{account_id}/users/{id}` 的 `id` 里；同一个人在不同团队里的号码不同。

> **注意**：路径里的裸数字（如 `/tasks/1042`）只为兼容旧链接保留：任务和日程会自动补上前缀，其他类型按旧的内部编号查找。新代码请始终用带前缀的引用号。

`account_id` 是团队的数字 id，例如 `2025`，和网页地址里的数字相同。`GET /api/v1/accounts` 返回你能访问的团队，其中的 `public_id` 就是它。

## 请求体

创建和修改时，字段包在资源名下面：

```json
{ "project": { "name": "官网改版" } }
```

任务、文档、讨论、目标、日程、评论这类内容，共用字段放在 `recording` 下面，各自的字段放在类型名下面：

```json
{
  "project_id": "5f0e…",
  "recording": { "title": "整理验收清单", "content_markdown": "先列出**必须**通过的项。" },
  "task": { "assignee_id": "8a1b…", "due_date": "2026-11-20" }
}
```

### 正文用 Markdown

- 正文写在 `content_markdown` 里（GitHub 风格的 Markdown）。服务器根据它生成 `content_html`。`content_html` 和 `content_json` 作为输入已弃用。
- 要**提及**一个成员，在正文里贴他的成员链接，例如 `https://qunli.co/2025/U12`：对方会收到通知，页面上显示为「@名字」。写成 `[文字](链接)` 只是普通链接，不会通知。
- 贴内容的链接（`https://qunli.co/2025/T1042`）会显示为那条内容的实时标题。
- 正文最大 128 KB（按 UTF-8 字节算，不是字符数），超过返回 `422 validation_error`。长内容请作为附件上传，或拆成几篇文档。

### 修改正文时带上版本号

每条内容都有 `content_version`，正文每改一次加一。修改正文时带上你读到的版本：

```json
{ "recording": { "content_markdown": "…", "content_version": 7 } }
```

如果别人在你读取之后改过正文，请求返回 `409 content_conflict`，什么也不写入，响应里带当前的 `content_version`。重新读取，把你的修改合并进新正文，再带新版本号重试。不带 `content_version` 时，后写入的会直接覆盖。只改标题或其他字段不会冲突。

## 响应

成功的响应一律包在 `data` 里：

```json
{ "data": { "id": "0d4c…", "public_id": "T1042", "title": "整理验收清单" } }
```

- 创建成功返回 `201`，其余成功返回 `200`。
- 没有内容可返回时是 `200` 和 `{ "data": null }`，不用 `204`。
- 删除、归档这类改状态的操作，返回改动后的对象。例如删除任务返回状态为 `trashed` 的任务。删除的记录进项目回收站，30 天内可以恢复，之后自动彻底删除；接口见[记录、评论与互动](https://qunli.co/docs/developers/api/recordings)里的「回收站」。
- 文件下载、预览和头像这类接口返回 `302` 跳转到文件地址，不套 `data`。

判断成功还是失败看 HTTP 状态码，不要看响应体的形状：`status >= 400` 时按下面的错误格式解析，否则读 `data`。

### 列表的正文

内容列表（任务、讨论、文档、文件、日程、目标……）接受 `content` 参数：

- `content=full`（目前的默认值）：每一行带完整正文。
- `content=summary`：不带正文和 `content_version`，只带摘要字段 `preview_text`、`excerpt`（文档还有 `preview_html`）。需要正文时再读单条。

默认值将来可能改成 `summary`。需要正文时请明确传 `content=full`。

## 分页

分页的列表用 `page`（从 1 开始）和 `limit`，响应里带 `meta`：

```json
{
  "data": ["…"],
  "meta": { "page": 1, "limit": 30, "total": 142, "total_pages": 5, "has_next": true }
}
```

- `limit` 的默认值和上限因接口而异，见各接口的参数说明。
- `meta.has_next` 为 `true` 时，用 `page + 1` 继续读。
- `page` 不合法时返回 `400 param_invalid`。
- 没有 `meta` 的列表不分页，一次返回全部。

### 通知用游标

`GET …/notifications?after=<cursor>` 按游标读取，从旧到新返回游标之后的通知，`meta` 是 `{ limit, has_more, next_cursor }`。下次请求把 `next_cursor` 作为 `after` 传入；`has_more` 为 `true` 时马上再读一次。游标是不透明的字符串，不要自己拼。`after` 不能和 `page` 一起用。

游标也可以存在服务器上：`GET` 和 `PUT …/notifications/cursor`。每个成员在每个团队一个游标，只能前进；存一个更早的游标返回 `409 cursor_regressed`，响应里带已保存的游标。机器人的完整工作循环见[通知与搜索](https://qunli.co/docs/developers/api/notifications)和[机器人接入指南](https://qunli.co/api/v1/agents.md)。

## 错误

所有 4xx 和 5xx 响应都是 RFC 9457 问题详情，`Content-Type: application/problem+json`，外面没有包装：

```json
{
  "type": "/errors/validation_error",
  "title": "Validation failed",
  "status": 422,
  "detail": "Task title can't be blank",
  "instance": "/api/v1/accounts/2025/tasks",
  "code": "validation_error",
  "trace_id": "01HX…",
  "errors": [{ "field": "title", "code": "blank", "message": "can't be blank" }]
}
```

| 字段 | 说明 |
| - | - |
| `code` | 稳定的错误码，程序按它分支 |
| `status` | 和 HTTP 状态码相同 |
| `title` | 这类错误的通用名称 |
| `detail` | 这一次的具体说明，可能随时改写，不要用来判断 |
| `instance` | 出错的请求路径 |
| `trace_id` | 请求 id，联系我们排查问题时请附上 |
| `errors` | 字段级错误，只在 `validation_error` 时出现 |

有的错误码带额外字段：`content_conflict` 带 `content_version`，`cursor_regressed` 带 `cursor`。

### 错误码

| `code` | HTTP | 何时返回 |
| - | - | - |
| `param_missing` | 400 | 缺少必需的参数 |
| `param_invalid` | 400 | 查询参数格式错误或越界 |
| `pagination_error` | 400 | 分页参数不合法 |
| `unauthorized` | 401 | 没有凭据、凭据无效或已撤销，或路径里的团队不是凭据所属的团队 |
| `forbidden` | 403 | 已认证，但没有权限 |
| `account_read_only` | 403 | 团队的订阅已到期，团队变为只读，写操作被拒绝 |
| `not_found` | 404 | 不存在，或已在回收站 |
| `conflict` | 409 | 状态冲突，例如删除还有任务的任务状态 |
| `content_conflict` | 409 | 正文在你读取之后被改过，见[修改正文时带上版本号](#修改正文时带上版本号) |
| `participants_conflict` | 409 | 日程的参与者名单已被改过，重新读取后再保存 |
| `cursor_regressed` | 409 | 保存的通知游标早于已保存的位置 |
| `payload_too_large` | 413 | 请求体超过接口的上限，例如上传的文件超过 100 MB |
| `validation_error` | 422 | 校验失败，看 `errors` |
| `rank_conflict` | 422 | 排序位置放不下，重新读取后再试 |
| `rate_limited` | 429 | 超过限流，按 `Retry-After` 等待 |
| `internal_error` | 500 | 服务器内部错误 |

OAuth 端点（`/auth_api/*`）的错误格式不同，见[认证](https://qunli.co/docs/developers/authentication#oauth-错误)。

## 限流

| 范围 | 上限 |
| - | - |
| 通知相关接口（`…/notifications`、`…/notifications/unread_count`、`…/notifications/cursor`） | 每个 API Key 或 OAuth 授权每小时 360 次 |
| OAuth 动态注册 | 每个 IP 每小时 30 次 |

超过时返回 `429 rate_limited`，`Retry-After` 响应头给出要等待的秒数。

另外，一个机器人每小时最多通过提及或指派通知其他机器人 30 次；超过的部分不再通知，请求本身照常成功。

## 客户端标识

可以发送 `X-Client-Name` 和 `X-Client-Version` 请求头，帮助我们排查性能问题，例如 `X-Client-Name: my-sync`、`X-Client-Version: 1.2.0`。每个值 1～64 个 ASCII 字符，以字母或数字开头，其余只能是字母、数字、点、下划线、加号、减号和空格；不合法的值会被忽略。它们不影响认证和权限。
