# TextShare API（轻湖文本快递柜后端）

文本快递柜（TextShare）后端 API——前端（BFF）与 AI Agent 对接的单一事实源。本文档的任何变更必须与对应的接口变更同版本发布。

- REST 基址（经 BFF 代理）：`https://textshare.litelake.com/api/**`
- 协同编辑（WebSocket，浏览器直连，不经 BFF）：
  `wss://ws.textshare.{env}/collab/{noteId}?token={editToken}`——`wsUrl` 基址由创建/verify 响应下发，调用方不自行拼接。

## 通用约定

- 除 `/docs/*` 与 WebSocket 端点为 `GET` 外，全部 REST 接口为 `POST` + `application/json`。
- 统一响应信封：`{ "code": number, "message": string, "data": T }`；成功 `code = 200`。
- 业务错误 HTTP 恒 200，靠 `body.code` 区分（4101/4103/4104）；限流用真实 HTTP 429 + `Retry-After`；存储故障（fail-closed）用真实 HTTP 503。
- 全部请求应携带 `X-Device-Id` 头（localStorage 生成的 UUID）。非法/缺失按「无设备号」处理，防爆破自动退化为仅 IP 维度（不报错）。

## POST /api/public/notes —— 创建文档并生成取件码

携带本地已书写内容创建文档（先写后生成码），创建即 `active`。

请求体：

```json
{
  "mode": "md",
  "initState": "<base64(本地 Y.Doc 的 V1 update)>"
}
```

- `mode`：`txt` | `md`（白名单，其余 → 4103）
- `initState`：本地 Y.Doc `Y.encodeStateAsUpdate()` 的 base64（V1）。空串表示空文档。服务端将 update 应用到空 doc，fold 后实测纯文本字节数。

响应 `data`：

```json
{
  "noteId": "cz9fkq2wm4xv...",
  "pickupCode": "K7M2XQ",
  "mode": "md",
  "expiresAt": "2026-09-16T08:00:00Z",
  "wsUrl": "wss://ws.textshare.{env}/collab",
  "editToken": "9f2c8e...64 位 hex"
}
```

错误：`4103` mode 非法 / initState 无法应用（HTTP 200）；`4104` 文本超过 128KB（HTTP 200）；`429` 创建限流（每 IP 12 次/小时 + 30 次/天，真实 HTTP 429 + `Retry-After`）；`503` 存储不可用（fail-closed）。

## POST /api/public/pickup/verify —— 凭码换取协同入口

请求体：

```json
{ "pickupCode": "K7M2XQ" }
```

服务端归一化：去空格与连字符、转大写、`O→0`、`I/L→1`。响应 `data` 与创建响应字段一致（`pickupCode` 回显归一化后的值）。

错误：`4101` 取件码无效或已过期——统一文案防枚举（HTTP 200；每次失败对 IP 与设备号双维度计数 +1）。防爆破：任一维度 5 次/10 分钟即封禁，返回真实 HTTP 429 + `Retry-After`；成功不清零。Redis 故障 → 真实 HTTP 503（fail-closed）。

## GET /collab/{noteId}?token={editToken} —— 协同 WebSocket

Yjs 协同端点，兼容 Hocuspocus 消息协议（`@hocuspocus/provider` 可直连；每帧带 VarString(docName) 前缀）。浏览器直连（不经 BFF；网关按域名转发）。

握手鉴权（在 WebSocket 升级之前执行）：

1. `noteId` 存在、`status = active` 且未过期（Redis 缓存 60s + MySQL 兜底；过期/不存在 → 401 拒绝，业务码 4102）
2. `editToken` 有效且绑定该 `noteId`（Redis；miss 或 Redis 故障 → 拒绝，fail-closed，业务码 4105）
3. 单 IP 连接数 < 10、单房间人数 < 16、单 update ≤ 768KB、单帧 ≤ 1MB

升级完成后服务端执行 y-protocols 同步握手（SyncStep1/2）、广播 awareness（光标/在线状态），并把每次 commit 以增量 V1 update 持久化（append-then-compact 写 MySQL：`notes.snapshot` + `note_updates` 流水）。文档创建后 48 小时过期；过期时强制关房（客户端收到 close 事件）。

没有只读连接：持 token 即得编辑权（v1 产品定义）。

## GET /docs/api.md、/docs/api.en.md、/docs/api.zhs.md

本文档（内嵌于二进制；en 为默认/兜底，zhs 为简体中文）。以 `text/markdown` 输出，`Cache-Control: no-cache`。

## GET /api/health、/api/ready

框架内置存活/就绪探针（网关 healthcheck 指向 `/api/health`）。

## 错误码表

| body.code | 语义 | HTTP 状态码 |
|---|---|---|
| 200 | 成功 | 200 |
| 400 | 参数/绑定错误 | 200 |
| 4101 | PICKUP_NOT_VALID（统一文案，防枚举） | 200 |
| 4102 | NOTE_NOT_FOUND / 已过期（WS Authorize 用，REST 一般不触达） | 200 |
| 4103 | INVALID_ARGUMENT（mode / initState） | 200 |
| 4104 | NOTE_TOO_LARGE（fold 后纯文本 > 128KB） | 200 |
| 4105 | EDIT_TOKEN_INVALID（WS 握手被拒） | 200 |
| 429 | RATE_LIMITED | **429 + `Retry-After`** |
| 503 | STORAGE_UNAVAILABLE（Redis 故障 fail-closed） | **503** |
| 500 | 内部错误 | 500 |

注：`/api/public` 写方法的基线限流中间件（30 次/min/IP）触发时响应体为中间件自拼格式；调用方对 429 一律按 HTTP 状态码处理，不依赖 body 形状。
