|
@@ -0,0 +1,895 @@
|
|
|
|
|
+# api — HTTP 接口契约
|
|
|
|
|
+
|
|
|
|
|
+> 本文件回答:每个 HTTP 接口的路径、方法、请求、响应、鉴权要求、错误码。
|
|
|
|
|
+> 依赖:data-model.md、auth.md
|
|
|
|
|
+
|
|
|
|
|
+## 一、通用约定
|
|
|
|
|
+
|
|
|
|
|
+### 1.1 路径规则
|
|
|
|
|
+
|
|
|
|
|
+- 公开只读接口统一前缀 `/api/public/`,匿名可访问;
|
|
|
|
|
+- 其余接口一律要求登录,数据按 `user_id = current_user_id` 过滤;
|
|
|
|
|
+- 路径中不使用版本号。
|
|
|
|
|
+
|
|
|
|
|
+### 1.2 请求与响应格式
|
|
|
|
|
+
|
|
|
|
|
+- 请求体与响应体统一为 JSON,`Content-Type: application/json`;
|
|
|
|
|
+- 时间字段统一用 RFC 3339 字符串,如 `"2026-10-26T12:00:00Z"`;
|
|
|
|
|
+- id 字段统一为 uuid 字符串;
|
|
|
|
|
+- 字段命名统一 `snake_case`(前后端 JSON 不转 camelCase)。
|
|
|
|
|
+
|
|
|
|
|
+### 1.3 成功响应
|
|
|
|
|
+
|
|
|
|
|
+- 直接返回数据本体,不包 `{code, data}` 外壳;
|
|
|
|
|
+- 列表接口返回 `{ "items": [...], "total": <int> }`,空列表为 `{"items": [], "total": 0}`,不返回 null;
|
|
|
|
|
+- 创建成功返回 `201` + 创建后的完整对象;
|
|
|
|
|
+- 更新成功返回 `200` + 更新后的完整对象;
|
|
|
|
|
+- 删除成功返回 `204`,无响应体。
|
|
|
|
|
+
|
|
|
|
|
+### 1.4 错误响应
|
|
|
|
|
+
|
|
|
|
|
+统一格式:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "error": "unauthorized", "message": "登录已过期" }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+| HTTP | error | 使用场景 |
|
|
|
|
|
+|---|---|---|
|
|
|
|
|
+| 400 | `bad_request` | 请求体格式错误、JSON 解析失败 |
|
|
|
|
|
+| 401 | `unauthorized` | 未登录、会话过期、Token 无效 |
|
|
|
|
|
+| 403 | `forbidden` | 已登录但权限不足(如 read Token 做写操作) |
|
|
|
|
|
+| 404 | `not_found` | 资源不存在,或跨用户访问 |
|
|
|
|
|
+| 409 | `conflict` | 唯一性冲突(如标签名重复) |
|
|
|
|
|
+| 422 | `validation_failed` | 字段校验失败,额外带 `fields` |
|
|
|
|
|
+| 500 | `internal_error` | 服务端错误 |
|
|
|
|
|
+
|
|
|
|
|
+`422` 响应额外字段:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "error": "validation_failed", "message": "字段校验失败", "fields": { "title": "不能为空" } }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+### 1.5 分页
|
|
|
|
|
+
|
|
|
|
|
+- 列表接口统一用 `?limit=&offset=`;
|
|
|
|
|
+- `limit` 默认 20,最大 100;`offset` 默认 0;
|
|
|
|
|
+- 响应体带 `total` 表示过滤后的总数。
|
|
|
|
|
+
|
|
|
|
|
+### 1.6 鉴权标注含义
|
|
|
|
|
+
|
|
|
|
|
+- **匿名**:任何人可访问;
|
|
|
|
|
+- **登录**:需会话 cookie 或有效 Token(`read` / `read_write` 均可);
|
|
|
|
|
+- **登录 + write**:需会话 cookie,或 `scope = read_write` 的 Token;
|
|
|
|
|
+- **登录 + TOTP**:仅会话 cookie,且会话创建时通过过 TOTP。Token 不可用。
|
|
|
|
|
+
|
|
|
|
|
+`Authorization: Bearer <token>` 优先于会话 cookie。
|
|
|
|
|
+
|
|
|
|
|
+### 1.7 更新语义
|
|
|
|
|
+
|
|
|
|
|
+- `PATCH` 为部分更新:只更新请求体中出现的字段,未出现的字段保持不变;
|
|
|
|
|
+- 数组类字段(如笔记的 `tag_ids`)如出现,为**全量替换**,不是追加。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 二、认证 `/api/auth`
|
|
|
|
|
+
|
|
|
|
|
+### 2.1 POST `/api/auth/login`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:匿名
|
|
|
|
|
+
|
|
|
|
|
+**请求**:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "username": "kairo", "password": "xxx", "trust_device": false }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- `trust_device`:可选,默认 false。仅在本次登录不需要第二步时立即生效;若返回 `totp_required: true`,此参数暂存于 `pending_logins`,第二步成功后才创建授信设备。
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:
|
|
|
|
|
+
|
|
|
|
|
+情况一,登录完成(未启用 TOTP 或授信设备有效):
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "user": { "id": "...", "username": "kairo" }, "totp_required": false }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+同时设置会话 cookie(`haven_session`);若 `trust_device=true`,同时设置授信设备 cookie。
|
|
|
|
|
+
|
|
|
|
|
+情况二,需要 TOTP:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "user": { "id": "...", "username": "kairo" }, "totp_required": true, "temp_token": "..." }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+不设置会话 cookie。
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `401 unauthorized` — 用户名或密码错误,`message` 统一为“用户名或密码错误”(不区分两者,防枚举)。
|
|
|
|
|
+
|
|
|
|
|
+**备注**:情况一成功后写 `login_success` 审计事件(`via` 为 `password+totp` 或 `password+trusted_device`);情况二不算登录成功,不写 `login_success`,第二步成功时才写;密码错误写 `login_failure` 审计事件。
|
|
|
|
|
+
|
|
|
|
|
+### 2.2 POST `/api/auth/login/totp`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:匿名(凭 `temp_token`)
|
|
|
|
|
+
|
|
|
|
|
+**请求**:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "temp_token": "...", "code": "123456" }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "user": { "id": "...", "username": "kairo" } }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+设置会话 cookie;若第一步请求了 `trust_device=true`,同时设置授信设备 cookie。
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `401 unauthorized` — `temp_token` 无效或已过期,`message` 为“登录已超时,请重新登录”;
|
|
|
|
|
+- `401 unauthorized` — code 错误,`message` 为“动态码错误”。
|
|
|
|
|
+
|
|
|
|
|
+**备注**:成功后删除 `pending_logins` 记录;写 `login_success` 审计事件(`via` 为 `password+totp`);失败写 `login_failure` 审计事件(`reason` 为 `bad_totp`)。
|
|
|
|
|
+
|
|
|
|
|
+### 2.3 POST `/api/auth/logout`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录
|
|
|
|
|
+
|
|
|
|
|
+**请求**:无 body
|
|
|
|
|
+
|
|
|
|
|
+**响应** `204`
|
|
|
|
|
+
|
|
|
|
|
+删除当前会话记录,清除会话 cookie;授信设备 cookie 保留。
|
|
|
|
|
+
|
|
|
|
|
+**备注**:写 `logout` 审计事件。Token 调用此接口无效(Token 无会话可登出),返回 204 但无实际操作。
|
|
|
|
|
+
|
|
|
|
|
+### 2.4 POST `/api/auth/totp/setup`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录
|
|
|
|
|
+
|
|
|
|
|
+**请求**:无 body
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "secret": "JBSWY3DPEHPK3PXP", "otpauth_uri": "otpauth://totp/Haven:kairo?secret=...&issuer=Haven" }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `409 conflict` — 用户已启用 TOTP,不能重复 setup。
|
|
|
|
|
+
|
|
|
|
|
+**备注**:secret 此时**未落库**,仅返回给前端。用户扫码后调 2.5 提交 code 确认,校验通过才落库并置 `totp_enabled=true`。
|
|
|
|
|
+
|
|
|
|
|
+### 2.5 POST `/api/auth/totp/verify`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录
|
|
|
|
|
+
|
|
|
|
|
+**请求**:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "secret": "JBSWY3DPEHPK3PXP", "code": "123456" }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- `secret`:2.4 返回的密钥,原样回传;
|
|
|
|
|
+- `code`:验证器生成的 6 位码。
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "totp_enabled": true }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `422 validation_failed` — code 格式错误或校验不通过,`fields.code` 说明原因;
|
|
|
|
|
+- `409 conflict` — 已启用 TOTP。
|
|
|
|
|
+
|
|
|
|
|
+**备注**:校验通过后 secret 落库到 `users.totp_secret`,`totp_enabled` 置 true;写 `totp_bound` 审计事件。
|
|
|
|
|
+
|
|
|
|
|
+### 2.6 POST `/api/auth/totp/disable`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录 + TOTP
|
|
|
|
|
+
|
|
|
|
|
+**请求**:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "code": "123456" }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "totp_enabled": false }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `401 unauthorized` — code 校验不通过;
|
|
|
|
|
+- `422 validation_failed` — 用户尚未启用 TOTP。
|
|
|
|
|
+
|
|
|
|
|
+**备注**:清空 `users.totp_secret`,`totp_enabled` 置 false,**删除该用户所有授信设备**(否则授信设备失去意义);写 `totp_disabled` 审计事件。
|
|
|
|
|
+
|
|
|
|
|
+### 2.7 POST `/api/auth/password`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录 + TOTP
|
|
|
|
|
+
|
|
|
|
|
+**请求**:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "old_password": "xxx", "new_password": "yyy", "totp_code": "123456" }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**响应** `204`
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `401 unauthorized` — 原密码错误或 TOTP 错误;
|
|
|
|
|
+- `422 validation_failed` — 新密码不符合要求(≥12 位,且不能与旧密码相同)。
|
|
|
|
|
+
|
|
|
|
|
+**备注**:成功后在同一事务内删除该用户所有 `sessions` 和 `trusted_devices`,**不删除** `tokens`;写 `password_changed` 审计事件;当前会话也被删除,用户需重新登录。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 三、会话 `/api/sessions`
|
|
|
|
|
+
|
|
|
|
|
+### 3.1 GET `/api/sessions`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "items": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": "...",
|
|
|
|
|
+ "user_agent": "Mozilla/5.0 ...",
|
|
|
|
|
+ "created_at": "2026-10-26T12:00:00Z",
|
|
|
|
|
+ "last_used_at": "2026-10-26T12:30:00Z",
|
|
|
|
|
+ "is_current": true
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ "total": 1
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- `is_current`:是否当前请求所用的会话,前端据此标注“当前设备”,不可撤销。
|
|
|
|
|
+
|
|
|
|
|
+**备注**:Token 调用返回 403(Token 无法查看会话)。
|
|
|
|
|
+
|
|
|
|
|
+### 3.2 DELETE `/api/sessions/:id`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录
|
|
|
|
|
+
|
|
|
|
|
+**响应** `204`
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `404 not_found` — 会话不存在或不属于当前用户;
|
|
|
|
|
+- `422 validation_failed` — 试图撤销当前会话(请用 `POST /api/auth/logout`)。
|
|
|
|
|
+
|
|
|
|
|
+**备注**:写 `session_revoked` 审计事件。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 四、授信设备 `/api/trusted-devices`
|
|
|
|
|
+
|
|
|
|
|
+### 4.1 GET `/api/trusted-devices`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "items": [
|
|
|
|
|
+ { "id": "...", "device_name": "家里台式机", "created_at": "...", "last_used_at": "...", "expires_at": "..." }
|
|
|
|
|
+ ],
|
|
|
|
|
+ "total": 1
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**备注**:Token 调用返回 403。
|
|
|
|
|
+
|
|
|
|
|
+### 4.2 DELETE `/api/trusted-devices/:id`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录
|
|
|
|
|
+
|
|
|
|
|
+**响应** `204`
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `404 not_found` — 设备不存在或不属于当前用户。
|
|
|
|
|
+
|
|
|
|
|
+**备注**:撤销即删除记录;写 `trusted_device_revoked` 审计事件。
|
|
|
|
|
+
|
|
|
|
|
+**说明**:**不提供 `POST /api/trusted-devices`**。授信设备只能在登录时通过 `trust_device=true` 在那台设备上创建——授信的本质是“这台浏览器可信”,脱离那台浏览器创建设备没有意义。手动添加接口会产生“创建的设备凭证怎么传给目标设备”的难题,本期不做。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 五、API Token `/api/tokens`
|
|
|
|
|
+
|
|
|
|
|
+### 5.1 GET `/api/tokens`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录 + TOTP
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "items": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": "...",
|
|
|
|
|
+ "name": "TUI @ 家里",
|
|
|
|
|
+ "token_prefix": "haven_ro_",
|
|
|
|
|
+ "scope": "read",
|
|
|
|
|
+ "expires_at": null,
|
|
|
|
|
+ "last_used_at": "2026-10-26T12:30:00Z",
|
|
|
|
|
+ "revoked_at": null,
|
|
|
|
|
+ "created_at": "..."
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ "total": 1
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**备注**:只返回前缀,不返回明文;已撤销的 Token 也列出(`revoked_at` 非 null)。
|
|
|
|
|
+
|
|
|
|
|
+### 5.2 POST `/api/tokens`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录 + TOTP
|
|
|
|
|
+
|
|
|
|
|
+**请求**:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "name": "TUI @ 家里", "scope": "read", "expires_at": null }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- `scope`:必填,`read` 或 `read_write`;
|
|
|
|
|
+- `expires_at`:可选,RFC 3339 时间;null 表示永不过期。
|
|
|
|
|
+
|
|
|
|
|
+**响应** `201`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "id": "...",
|
|
|
|
|
+ "name": "TUI @ 家里",
|
|
|
|
|
+ "token": "haven_ro_AbCdEfGh...",
|
|
|
|
|
+ "token_prefix": "haven_ro_",
|
|
|
|
|
+ "scope": "read",
|
|
|
|
|
+ "expires_at": null,
|
|
|
|
|
+ "created_at": "..."
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- `token` 为明文,**仅此次返回,之后无法再获取**。
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `422 validation_failed` — name 为空、scope 非法等。
|
|
|
|
|
+
|
|
|
|
|
+**备注**:写 `token_created` 审计事件(metadata 含 `name`、`scope`、`token_prefix`,**不含明文**)。
|
|
|
|
|
+
|
|
|
|
|
+### 5.3 DELETE `/api/tokens/:id`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录 + TOTP
|
|
|
|
|
+
|
|
|
|
|
+**响应** `204`
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `404 not_found` — Token 不存在或不属于当前用户;
|
|
|
|
|
+- `422 validation_failed` — Token 已撤销。
|
|
|
|
|
+
|
|
|
|
|
+**备注**:撤销 = 写 `revoked_at`,不删除记录;写 `token_revoked` 审计事件。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 六、笔记(私有侧)`/api/notes`
|
|
|
|
|
+
|
|
|
|
|
+### 6.1 GET `/api/notes`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录
|
|
|
|
|
+
|
|
|
|
|
+**查询参数**:
|
|
|
|
|
+
|
|
|
|
|
+- `limit`、`offset`:分页;
|
|
|
|
|
+- `visibility`:可选,`private` / `public`,按可见性过滤;
|
|
|
|
|
+- `tag_id`:可选,按标签过滤。
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "items": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": "...",
|
|
|
|
|
+ "title": "...",
|
|
|
|
|
+ "visibility": "private",
|
|
|
|
|
+ "published_at": null,
|
|
|
|
|
+ "created_at": "...",
|
|
|
|
|
+ "updated_at": "...",
|
|
|
|
|
+ "tags": [{ "id": "...", "name": "编程" }]
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ "total": 1
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**备注**:列表不返回 `content`(正文),正文只在详情接口返回。按 `updated_at DESC` 排序。
|
|
|
|
|
+
|
|
|
|
|
+### 6.2 GET `/api/notes/:id`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "id": "...",
|
|
|
|
|
+ "title": "...",
|
|
|
|
|
+ "content": "...",
|
|
|
|
|
+ "visibility": "private",
|
|
|
|
|
+ "published_at": null,
|
|
|
|
|
+ "created_at": "...",
|
|
|
|
|
+ "updated_at": "...",
|
|
|
|
|
+ "tags": [{ "id": "...", "name": "编程" }]
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `404 not_found` — 笔记不存在或不属于当前用户。
|
|
|
|
|
+
|
|
|
|
|
+### 6.3 POST `/api/notes`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录 + write
|
|
|
|
|
+
|
|
|
|
|
+**请求**:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "title": "...", "content": "...", "tag_ids": ["...", "..."] }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- `title`:必填;
|
|
|
|
|
+- `content`:可选,默认空串;
|
|
|
|
|
+- `tag_ids`:可选,标签 id 数组;后端校验每个 tag 属于当前用户。
|
|
|
|
|
+
|
|
|
|
|
+**响应** `201`:同 6.2 的笔记对象。
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `422 validation_failed` — title 为空、tag_ids 含无效 id。
|
|
|
|
|
+
|
|
|
|
|
+**备注**:**不接受 `visibility` 参数**,新建一律 private;写操作成功不写审计事件(笔记 CRUD 不审计)。
|
|
|
|
|
+
|
|
|
|
|
+### 6.4 PATCH `/api/notes/:id`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录 + write
|
|
|
|
|
+
|
|
|
|
|
+**请求**(部分更新):
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "title": "...", "content": "...", "tag_ids": ["..."] }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- 只更新出现的字段;
|
|
|
|
|
+- `tag_ids` 如出现,为**全量替换**。
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:同 6.2 的笔记对象。
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `404 not_found` — 笔记不存在或不属于当前用户;
|
|
|
|
|
+- `422 validation_failed` — 字段校验失败。
|
|
|
|
|
+
|
|
|
|
|
+**备注**:`updated_at` 由应用层更新。
|
|
|
|
|
+
|
|
|
|
|
+### 6.5 DELETE `/api/notes/:id`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录 + write
|
|
|
|
|
+
|
|
|
|
|
+**响应** `204`
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `404 not_found` — 笔记不存在或不属于当前用户。
|
|
|
|
|
+
|
|
|
|
|
+### 6.6 POST `/api/notes/:id/publish`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录 + write
|
|
|
|
|
+
|
|
|
|
|
+**请求**:无 body
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:同 6.2 的笔记对象,`visibility` 为 `public`,`published_at` 非空。
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `404 not_found` — 笔记不存在或不属于当前用户;
|
|
|
|
|
+- `409 conflict` — 笔记已是 public。
|
|
|
|
|
+
|
|
|
|
|
+**备注**:若 `published_at` 为 NULL,写入当前时间;否则保留首次发布时间;写 `note_published` 审计事件。
|
|
|
|
|
+
|
|
|
|
|
+### 6.7 POST `/api/notes/:id/unpublish`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录 + write
|
|
|
|
|
+
|
|
|
|
|
+**请求**:无 body
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:同 6.2 的笔记对象,`visibility` 为 `private`,`published_at` 保留不变。
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `404 not_found` — 笔记不存在或不属于当前用户;
|
|
|
|
|
+- `409 conflict` — 笔记已是 private。
|
|
|
|
|
+
|
|
|
|
|
+**备注**:写 `note_unpublished` 审计事件。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 七、笔记(公开侧)`/api/public/notes`
|
|
|
|
|
+
|
|
|
|
|
+### 7.1 GET `/api/public/notes`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:匿名
|
|
|
|
|
+
|
|
|
|
|
+**查询参数**:`limit`、`offset`
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "items": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": "...",
|
|
|
|
|
+ "title": "...",
|
|
|
|
|
+ "published_at": "...",
|
|
|
|
|
+ "updated_at": "...",
|
|
|
|
|
+ "tags": [{ "id": "...", "name": "编程" }]
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ "total": 1
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**备注**:只返回 `visibility = public AND published_at IS NOT NULL` 的笔记,跨用户。按 `published_at DESC` 排序。列表不返回正文。
|
|
|
|
|
+
|
|
|
|
|
+### 7.2 GET `/api/public/notes/:id`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:匿名
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "id": "...",
|
|
|
|
|
+ "title": "...",
|
|
|
|
|
+ "content": "...",
|
|
|
|
|
+ "published_at": "...",
|
|
|
|
|
+ "updated_at": "...",
|
|
|
|
|
+ "tags": [{ "id": "...", "name": "编程" }]
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `404 not_found` — 笔记不存在、非 public、或 `published_at` 为空。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 八、标签 `/api/tags`
|
|
|
|
|
+
|
|
|
|
|
+### 8.1 GET `/api/tags`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "items": [
|
|
|
|
|
+ { "id": "...", "name": "编程", "note_count": 5, "created_at": "..." }
|
|
|
|
|
+ ],
|
|
|
|
|
+ "total": 1
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- `note_count`:该标签关联的笔记数(含私有)。
|
|
|
|
|
+
|
|
|
|
|
+### 8.2 POST `/api/tags`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录 + write
|
|
|
|
|
+
|
|
|
|
|
+**请求**:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "name": "编程" }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**响应** `201`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "id": "...", "name": "编程", "note_count": 0, "created_at": "..." }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `409 conflict` — 当前用户下已有同名标签(`lower(name)` 相同);
|
|
|
|
|
+- `422 validation_failed` — name 为空或过长。
|
|
|
|
|
+
|
|
|
|
|
+### 8.3 DELETE `/api/tags/:id`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录 + write
|
|
|
|
|
+
|
|
|
|
|
+**响应** `204`
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `404 not_found` — 标签不存在或不属于当前用户。
|
|
|
|
|
+
|
|
|
|
|
+**备注**:关联由外键 `ON DELETE CASCADE` 自动清理。
|
|
|
|
|
+
|
|
|
|
|
+### 8.4 GET `/api/tags/:id/notes`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录
|
|
|
|
|
+
|
|
|
|
|
+**查询参数**:`limit`、`offset`
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:结构同 6.1(含私有和公开)。
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `404 not_found` — 标签不存在或不属于当前用户。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 九、搜索 `/api/search`
|
|
|
|
|
+
|
|
|
|
|
+### 9.1 GET `/api/search`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录
|
|
|
|
|
+
|
|
|
|
|
+**查询参数**:
|
|
|
|
|
+
|
|
|
|
|
+- `q`:必填,关键词;
|
|
|
|
|
+- `limit`、`offset`:分页,只作用于笔记结果。
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "notes": {
|
|
|
|
|
+ "items": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": "...",
|
|
|
|
|
+ "title": "...",
|
|
|
|
|
+ "visibility": "private",
|
|
|
|
|
+ "published_at": null,
|
|
|
|
|
+ "updated_at": "...",
|
|
|
|
|
+ "tags": [{ "id": "...", "name": "编程" }]
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ "total": 1
|
|
|
|
|
+ },
|
|
|
|
|
+ "tags": {
|
|
|
|
|
+ "items": [{ "id": "...", "name": "编程", "note_count": 3 }],
|
|
|
|
|
+ "total": 1
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**备注**:
|
|
|
|
|
+
|
|
|
|
|
+- 笔记按 `search_text ILIKE '%q%'` 匹配,范围限当前用户;
|
|
|
|
|
+- 标签按 `name ILIKE '%q%'` 匹配,范围限当前用户;
|
|
|
|
|
+- 不返回正文,只返回标题与元信息。
|
|
|
|
|
+
|
|
|
|
|
+### 9.2 GET `/api/public/search`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:匿名
|
|
|
|
|
+
|
|
|
|
|
+**查询参数**:`q`、`limit`、`offset`
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "items": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": "...",
|
|
|
|
|
+ "title": "...",
|
|
|
|
|
+ "published_at": "...",
|
|
|
|
|
+ "updated_at": "...",
|
|
|
|
|
+ "tags": [{ "id": "...", "name": "编程" }]
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ "total": 1
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**备注**:只搜 `visibility = public AND published_at IS NOT NULL` 的笔记,跨用户。**不返回标签列表**——公开页的标签只作为笔记属性出现,点标签跳转到本接口用标签名搜索。本期不提供 `/api/public/tags`。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 十、作品集 `/api/portfolio`
|
|
|
|
|
+
|
|
|
|
|
+### 10.1 GET `/api/public/portfolio`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:匿名
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "items": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": "...",
|
|
|
|
|
+ "title": "Haven",
|
|
|
|
|
+ "description": "...",
|
|
|
|
|
+ "sort_order": 0,
|
|
|
|
|
+ "repos": [
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": "...",
|
|
|
|
|
+ "platform": "gogs",
|
|
|
|
|
+ "repo_path": "kairo/haven",
|
|
|
|
|
+ "url": "https://gogs.example.com/kairo/haven",
|
|
|
|
|
+ "is_primary": true
|
|
|
|
|
+ },
|
|
|
|
|
+ {
|
|
|
|
|
+ "id": "...",
|
|
|
|
|
+ "platform": "github",
|
|
|
|
|
+ "repo_path": "kairo/haven",
|
|
|
|
|
+ "url": "https://github.com/kairo/haven",
|
|
|
|
|
+ "is_primary": false
|
|
|
|
|
+ }
|
|
|
|
|
+ ]
|
|
|
|
|
+ }
|
|
|
|
|
+ ],
|
|
|
|
|
+ "total": 1
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+- `url` 由后端按 `platform` + 配置的 base URL 拼接,前端直接用;
|
|
|
|
|
+- 按 `sort_order` 升序排序。
|
|
|
|
|
+
|
|
|
|
|
+### 10.2 POST `/api/portfolio`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录 + write
|
|
|
|
|
+
|
|
|
|
|
+**请求**:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "title": "Haven", "description": "...", "sort_order": 0 }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**响应** `201`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "id": "...", "title": "Haven", "description": "...", "sort_order": 0, "repos": [] }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `422 validation_failed` — title 为空。
|
|
|
|
|
+
|
|
|
|
|
+### 10.3 PATCH `/api/portfolio/:id`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录 + write
|
|
|
|
|
+
|
|
|
|
|
+**请求**(部分更新):
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "title": "...", "description": "...", "sort_order": 1 }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:同 10.2,含当前 `repos`。
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `404 not_found` — 作品不存在或不属于当前用户。
|
|
|
|
|
+
|
|
|
|
|
+### 10.4 DELETE `/api/portfolio/:id`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录 + write
|
|
|
|
|
+
|
|
|
|
|
+**响应** `204`
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `404 not_found` — 作品不存在或不属于当前用户。
|
|
|
|
|
+
|
|
|
|
|
+**备注**:关联仓库由外键 `ON DELETE CASCADE` 自动清理。
|
|
|
|
|
+
|
|
|
|
|
+### 10.5 POST `/api/portfolio/:id/repos`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录 + write
|
|
|
|
|
+
|
|
|
|
|
+**请求**:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "platform": "gogs", "repo_path": "kairo/haven", "is_primary": true }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**响应** `201`:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "id": "...", "platform": "gogs", "repo_path": "kairo/haven", "url": "https://gogs.example.com/kairo/haven", "is_primary": true }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `404 not_found` — 作品不存在或不属于当前用户;
|
|
|
|
|
+- `409 conflict` — 该作品下已有相同 `platform + repo_path`;
|
|
|
|
|
+- `409 conflict` — 该作品已有主链接且本次 `is_primary=true`;
|
|
|
|
|
+- `422 validation_failed` — platform 非法、repo_path 为空。
|
|
|
|
|
+
|
|
|
|
|
+### 10.6 PATCH `/api/portfolio/:id/repos/:repoId`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录 + write
|
|
|
|
|
+
|
|
|
|
|
+**请求**(部分更新):
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "repo_path": "...", "is_primary": true }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:同 10.5 的仓库对象。
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `404 not_found` — 作品或仓库不存在、不属于当前用户;
|
|
|
|
|
+- `409 conflict` — 唯一性或主链接冲突。
|
|
|
|
|
+
|
|
|
|
|
+### 10.7 DELETE `/api/portfolio/:id/repos/:repoId`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:登录 + write
|
|
|
|
|
+
|
|
|
|
|
+**响应** `204`
|
|
|
|
|
+
|
|
|
|
|
+**错误**:
|
|
|
|
|
+
|
|
|
|
|
+- `404 not_found` — 作品或仓库不存在、不属于当前用户。
|
|
|
|
|
+
|
|
|
|
|
+---
|
|
|
|
|
+
|
|
|
|
|
+## 十一、其他 `/api/me`
|
|
|
|
|
+
|
|
|
|
|
+### 11.1 GET `/api/me`
|
|
|
|
|
+
|
|
|
|
|
+**鉴权**:匿名
|
|
|
|
|
+
|
|
|
|
|
+**响应** `200`:
|
|
|
|
|
+
|
|
|
|
|
+未登录:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{ "anonymous": true }
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+登录:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "anonymous": false,
|
|
|
|
|
+ "auth_type": "session",
|
|
|
|
|
+ "user": { "id": "...", "username": "kairo", "totp_enabled": true }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+Token 调用:
|
|
|
|
|
+
|
|
|
|
|
+```json
|
|
|
|
|
+{
|
|
|
|
|
+ "anonymous": false,
|
|
|
|
|
+ "auth_type": "token",
|
|
|
|
|
+ "scope": "read",
|
|
|
|
|
+ "user": { "id": "...", "username": "kairo", "totp_enabled": true }
|
|
|
|
|
+}
|
|
|
|
|
+```
|
|
|
|
|
+
|
|
|
|
|
+**备注**:供前端判断登录态与权限范围,不做其他事。
|