CI / backend (push) Canceled after 0s
两个都是"看着不对劲"的问题: 1. 侧栏把二级分类全堆在一级分类后面(按 depth 排序的副作用), 「技术」和它的上级「我的备忘」隔着十几行,看着像一棵假树。 后端改成按树的先序返回(父分类后紧跟它的子分类,兄弟仍按 sort_order、name), 前端照 depth 缩进就是个真树;二级以下再加一个「·」标记,小屏也分得清。 2. 点开已发布的知识只有「归档」,没有改的入口——因为后端原来硬性拒绝 修改已发布内容(7003)。但"发布"是给团队看的,写错了总要能改: 现在已发布的可以直接 PATCH,改完立刻重建检索切片(chunk_count 当场更新), 状态和版本号照常推进;归档的仍然要先「恢复成草稿」再改。 Web 与安卓同步:抽屉/详情里已发布也显示「编辑」,安卓分类标签区不再锁。 验证:pytest 95 passed(新增"分类按树返回"用例、发布后可改用例)、 ruff 全绿、tsc 0、vite build ✓;`knowledge-notebook.mjs` 17 项、 `knowledge.mjs` 9 项 e2e 全绿。安卓仍未编译(JDK 25 / Gradle 8.7 不兼容)。
576 lines
25 KiB
Markdown
576 lines
25 KiB
Markdown
# 接口约定(M0–M3 已实现)
|
||
|
||
> 只写**已经在跑**的接口。写完没验证的东西不进这份文档。
|
||
> 前缀 `/api/v1`。所有响应都是同一个信封,**HTTP 200 不代表业务成功**。
|
||
|
||
## 0. 通用
|
||
|
||
### 0.1 信封
|
||
|
||
```json
|
||
{ "code": 0, "message": "ok", "data": { }, "request_id": "c4a8…" }
|
||
```
|
||
|
||
- `code == 0` 才是成功;非 0 时 `data` 里放结构化细节(例如冲突时的 `current_version`)。
|
||
- `request_id` 出现在响应头 `X-Request-Id` 与日志里,报障时带上它。
|
||
- 分页统一 `{items, next_cursor, has_more}`;`next_cursor` 为空即到底。
|
||
|
||
### 0.2 错误码
|
||
|
||
| 码 | HTTP | 含义 | 客户端该怎么处理 |
|
||
|---|---|---|---|
|
||
| 1001 | 400 | 参数不合法 | 提示并标红字段 |
|
||
| 1002 | 401 | 未认证 | 清 token → 登录页 |
|
||
| 1004 | 404 | 资源不存在 | — |
|
||
| 1009 | 409 | 唯一冲突(如 slug 已存在) | 换个名字 |
|
||
| 2001 | 401 | 缺少访问令牌 | 登录页 |
|
||
| 2002 | 401 | 令牌无效/过期 | 先试 refresh,失败再登录 |
|
||
| 2003 | 403 | 令牌类型不符(refresh 当 access 用) | 程序 bug |
|
||
| 2010 | 429 | 验证码请求太频繁 | 倒计时 60 秒 |
|
||
| 2011 | 400 | 验证码错误/已过期 | 重发 |
|
||
| 2012 | 403 | 账号被禁用 | 联系管理员 |
|
||
| 3001 | 404 | 工作区不存在 | — |
|
||
| 3003 | 403 | 不是该工作区成员 | **不要**提示"工作区不存在" |
|
||
| 3004 | 403 | 角色权限不足 | 隐藏对应按钮 |
|
||
| 3005 | 409 | 已经是成员 | 直接进工作区 |
|
||
| 3010 | 400 | 邀请码无效/过期 | 提示重新索取 |
|
||
| 4009 | 409 | 版本冲突(乐观锁) | 用 `data.current_version` 重试或刷新 |
|
||
| 5001 | 404 | 任务不存在 | 刷新列表 |
|
||
| 5002 | 409 | 非法状态流转 | 用 `data.allowed` 决定下一步给什么按钮 |
|
||
| 5003 | 404 | 标签不存在 | — |
|
||
|
||
> 错误码发布后**不许改号**,客户端靠它做分支。
|
||
|
||
### 0.3 认证
|
||
|
||
`Authorization: Bearer <access_token>`。`access` 30 分钟,`refresh` 14 天。
|
||
`refresh_token` 只能用于 `/auth/refresh`(服务端校验 `typ`)。
|
||
|
||
### 0.4 幂等
|
||
|
||
所有 `POST/PATCH/DELETE` 都可带 `Idempotency-Key: <uuid>`。
|
||
24 小时内同用户 + 同 key 返回首次结果,并带响应头 `X-Idempotent-Replay: 1`。
|
||
**只缓存 2xx/3xx**,失败不缓存,所以重试是安全的。
|
||
|
||
### 0.5 并发
|
||
|
||
改动类接口(`PATCH /items/{id}`、`POST /items/{id}/status`、`DELETE`)必须带 `version`。
|
||
拿不到 `version` 就别改——先 `GET` 一次。
|
||
|
||
---
|
||
|
||
## 1. 认证 `/auth`
|
||
|
||
### `POST /auth/sms/send`
|
||
|
||
```json
|
||
{ "phone": "13800000000" }
|
||
```
|
||
|
||
响应 `data: { dev_code?: "123456" }`。`ENV=dev` 时才回显验证码,生产没有这个字段。
|
||
60 秒内重复请求 → `2010`。
|
||
|
||
### `POST /auth/login`
|
||
|
||
```json
|
||
{ "phone": "13800000000", "code": "123456" }
|
||
```
|
||
|
||
```json
|
||
{ "user": { "id": "…", "phone": "138****0000", "nickname": "用户0000" },
|
||
"tokens": { "access_token": "…", "refresh_token": "…", "token_type": "bearer", "expires_in": 1800 } }
|
||
```
|
||
|
||
手机号**首次登录即注册**(昵称取"用户" + 后四位)。手机号回显永远是打码的。
|
||
|
||
### `POST /auth/refresh`
|
||
|
||
```json
|
||
{ "refresh_token": "…" }
|
||
```
|
||
|
||
返回同形状的 `tokens`。无状态 JWT,**服务端不维护黑名单**——token 泄露只能等过期,这是 M0 已知取舍。
|
||
|
||
### `GET /auth/me`
|
||
|
||
返回 `{ user, workspaces[] }`,`workspaces[].role` 是当前用户在该工作区的角色。
|
||
|
||
---
|
||
|
||
## 2. 工作区 `/workspaces`
|
||
|
||
| 方法 | 路径 | 权限 | 说明 |
|
||
|---|---|---|---|
|
||
| POST | `/workspaces` | 登录 | 建工作区,创建者自动成为 `owner` |
|
||
| GET | `/workspaces` | 登录 | 我加入的工作区 |
|
||
| GET | `/workspaces/{ws_id}` | 成员 | 详情 |
|
||
| GET | `/workspaces/{ws_id}/members` | 成员 | 游标分页 |
|
||
| POST | `/workspaces/{ws_id}/invitations` | admin+ | 生成邀请码,`{role}`;**只有 owner 能邀请 admin** |
|
||
| POST | `/workspaces/{ws_id}/join` | 登录 | `{code}` 凭码加入,邀请码一次性 |
|
||
|
||
角色等级:`owner(4) > admin(3) > member(2) > guest(1)`。
|
||
|
||
---
|
||
|
||
## 3. 任务 `/workspaces/{ws_id}/items`
|
||
|
||
所有接口都要求**是该工作区成员**;跨租户一律 `3003`,任务不存在一律 `5001`(不区分"没有"和"不是你的")。
|
||
|
||
### 3.1 `GET /items`
|
||
|
||
| 参数 | 取值 | 说明 |
|
||
|---|---|---|
|
||
| `view` | `inbox` `active` `today` `week` `done` `canceled` `all` `deleted` | 默认 `active`(todo+doing+blocked) |
|
||
| `status` | 可重复 | 与 `view` 叠加时取更严的 |
|
||
| `assignee` | `me` 或 uuid | |
|
||
| `tag` | 标签名 | |
|
||
| `q` | 关键词 | 标题 + 正文,`ILIKE %q%` |
|
||
| `sort` | `updated`(默认) `due` `created` | `due` 不分页 |
|
||
| `include_deleted` | 布尔 | 与 `view=all` 一起用 |
|
||
| `limit` | 1–100,默认 20 | |
|
||
| `cursor` | 上一页的 `next_cursor` | |
|
||
|
||
`today` / `week` 的边界按 `settings.default_timezone`(默认 `Asia/Shanghai`)算,
|
||
用**半开区间** `[今天 00:00, 明天 00:00)`,不用 UTC 切日。
|
||
|
||
### 3.2 `POST /items`
|
||
|
||
```json
|
||
{ "title": "今天要交的周报", "body_md": "", "due_at": "2026-10-28T18:00:00+08:00",
|
||
"priority": 1, "assignee_id": null, "status": "inbox", "tag_names": ["周报"] }
|
||
```
|
||
|
||
`status` 默认 `inbox`(进收件箱,先归类再排期)。返回完整 `item`(含 `tags[]`、`version`)。
|
||
|
||
### 3.3 `PATCH /items/{item_id}`
|
||
|
||
**必须带 `version`**。`null` 与"没传"含义不同:
|
||
|
||
- 传 `due_at: null` → 表示"清空"要用 `"clear_due_at": true`
|
||
- 同理 `clear_assignee`、`clear_tags`
|
||
- `tag_names` 一旦传就是**整体替换**
|
||
|
||
冲突回 `4009` 且 `data.current_version` 给最新版本号。
|
||
|
||
### 3.4 `POST /items/{item_id}/status`
|
||
|
||
```json
|
||
{ "status": "doing", "version": 3 }
|
||
```
|
||
|
||
状态机(`backend/app/domain/task.py`):
|
||
|
||
```
|
||
inbox → todo, canceled
|
||
todo → inbox, doing, done, canceled
|
||
doing → todo, done, blocked, canceled
|
||
blocked → doing, canceled
|
||
done → todo (重开)
|
||
canceled→ todo (恢复)
|
||
```
|
||
|
||
非法流转回 `5002`,`data = { from, to, allowed }`。
|
||
**前端可以据此直接渲染"下一步能点什么",不用自己维护一份规则。**
|
||
目标状态与当前相同:直接返回,版本号不变。
|
||
|
||
### 3.5 `POST /items/batch-status`(看板多选拖拽)
|
||
|
||
```json
|
||
{ "status": "done",
|
||
"items": [{ "id": "…", "version": 3 }, { "id": "…", "version": 7 }] }
|
||
```
|
||
|
||
```json
|
||
{ "updated": [{ "id": "…", "version": 4, "unchanged": false }],
|
||
"failed": [{ "id": "…", "code": 4009, "message": "…", "current_version": 8 }] }
|
||
```
|
||
|
||
- **不是全成功或全回滚**:拖 10 条里有 1 条被同事改过,其余 9 条照样生效。
|
||
全回滚会让用户白干一次,而且更难解释"为什么什么都没变"。
|
||
- 单次最多 50 条;`failed[].code` 只会是 `4009`(版本冲突)、`5002`(状态不允许)、`5001`(不存在)。
|
||
- 已经是目标状态的条目返回 `unchanged: true`,计入 `updated` 但不产生事件。
|
||
|
||
### 3.6 `GET /items/board`(看板一屏数据)
|
||
|
||
```json
|
||
{ "columns": { "todo": [...], "doing": [...], "blocked": [...], "done": [...] },
|
||
"order": ["todo", "doing", "blocked", "done"],
|
||
"labels": { "todo": "待办", "doing": "进行中", "blocked": "被阻塞", "done": "已完成" } }
|
||
```
|
||
|
||
- 每列默认 30 条(`per_column` 1–100),按"截止早的在前、无截止靠后",再按更新时间。
|
||
- **收件箱不在看板上**:没归类的东西不该占着"正在做的事"的位置。
|
||
- 一次请求拿全,避免拖拽时四列数据不同步。
|
||
|
||
### 3.7 `DELETE /items/{item_id}?version=3`
|
||
|
||
软删(写 `deleted_at`),只在 `view=deleted` / `include_deleted=true` 时可见。
|
||
|
||
### 3.8 评论
|
||
|
||
| 方法 | 路径 | 说明 |
|
||
|---|---|---|
|
||
| GET | `/items/{item_id}/comments` | 时间正序,游标分页 |
|
||
| POST | `/items/{item_id}/comments` | `{body_md}` |
|
||
|
||
### 3.9 标签 `/workspaces/{ws_id}/tags`
|
||
|
||
| 方法 | 路径 | 说明 |
|
||
|---|---|---|
|
||
| GET | `/tags` | 工作区内标签,`{id,name,color}` |
|
||
| POST | `/tags` | `{name, color?}`;同名直接返回已有的(幂等,不报错) |
|
||
|
||
---
|
||
|
||
## 4. 工单 `/workspaces/{ws_id}`
|
||
|
||
工单**不是**另一张表里的另一种东西,它是一条 `kind='ticket'` 的 item 加一行 `ticket_ext`。
|
||
所以详情仍然走 `GET /items/{item_id}`,响应里多一个 `ticket` 字段。
|
||
|
||
### 4.0 `POST /tickets`(手工开单)
|
||
|
||
```json
|
||
{ "title": "核对 9 月发票", "body_md": "可选说明",
|
||
"assignee_id": "uuid", "priority": 2, "due_at": "2026-10-30T10:00:00+08:00" }
|
||
```
|
||
|
||
返回与详情同构的工单对象(item + `ticket`)。与 4.1 的区别只有一处:**没有来源任务**,
|
||
`ticket.parent_item_id` 为 `null`,因此验收通过后不会回流任何任务(但同样会沉淀一份知识草稿)。
|
||
|
||
- `title` 必填、`assignee_id` 必须是工作区内 `active` 成员,否则 `1001`。
|
||
- 初始状态 `open`,被指派人立刻收到 `ticket_assigned` 通知。
|
||
- 手工单与升级单共用同一条 `item`,所以看板、评论、验收、知识沉淀全部复用,不需要另一套接口。
|
||
|
||
### 4.1 `POST /items/{item_id}/promote`(任务升级成工单)
|
||
|
||
```json
|
||
{ "assignee_id": "uuid", "title": "可选覆盖", "body_md": "可选补充",
|
||
"due_at": "2026-10-30T10:00:00+08:00", "priority": 2, "version": 3 }
|
||
```
|
||
|
||
返回 `{ "task": {...}, "ticket": {...} }`。
|
||
|
||
- 原任务**留在原地**变成 `blocked`,不搬数据——时间线完整保留。
|
||
- 一个任务只能升级一次,重复升级 → `6002`。
|
||
- `assignee_id` 必须是工作区内 `active` 成员,否则 `1001`。
|
||
- 传 `version` 走乐观锁,`4009` 表示任务刚被别人改过。
|
||
- 被指派人立刻收到 `ticket_assigned` 通知。
|
||
|
||
### 4.2 工单动作
|
||
|
||
| 方法 | 路径 | 谁可以 | 前置状态 → 结果 |
|
||
|---|---|---|---|
|
||
| POST | `/items/{item_id}/ticket/accept` | 办理人 / admin+ | open → accepted |
|
||
| POST | `/items/{item_id}/ticket/start` | 办理人 / admin+ | accepted → in_progress |
|
||
| POST | `/items/{item_id}/ticket/submit` | 办理人 / admin+ | in_progress → pending_verify |
|
||
| POST | `/items/{item_id}/ticket/verify` | **需求人** / admin+ | pending_verify → done / in_progress |
|
||
| POST | `/items/{item_id}/ticket/reassign` | 需求人 / admin+ | 任意未完成 → open(清空受理时间) |
|
||
| POST | `/items/{item_id}/ticket/cancel` | 需求人 / 办理人 / admin+ | 未完成 → canceled |
|
||
|
||
`verify` 的请求体:`{ "approve": true, "note": "可选说明", "resolution_md": "验收结论" }`。
|
||
|
||
- 驳回时 `approve=false`,回到 `in_progress`(不是 `open`——人还在做,不必重新认领),`reject_count` 加一。
|
||
- **办理人不能验收自己的工单**,否则 `6004`。这是唯一一条"就算 owner 也不通融"的规则之外的角色限制。
|
||
- `resolution_md` 是 M3 知识草稿的原料,尽量写。
|
||
- 前置状态不对 → `6003`,`details` 同时给 `allowed`(当前状态还能去哪)与 `allowed_from`(该动作要求的前置状态)。
|
||
|
||
### 4.3 工单列表 `GET /tickets`
|
||
|
||
| 参数 | 说明 |
|
||
|---|---|
|
||
| `role` | `assigned`(派给我的,默认)/ `requested`(我发起的)/ `all` |
|
||
| `status` | 可重复,如 `?status=open&status=accepted` |
|
||
| `limit` / `cursor` | 与任务列表一致 |
|
||
|
||
### 4.4 事项关系 `GET|POST /items/{item_id}/links`
|
||
|
||
```json
|
||
{ "to_item_id": "uuid", "link_type": "parent" }
|
||
```
|
||
|
||
`GET` 返回 `{ "children": [...], "parents": [...], "related": [...] }`。
|
||
`link_type` 只有 `parent` / `related`,**方向是 from 是 to 的子项**;自环 `5005`、重复挂 `5005`、对方不存在 `5004`、跨租户 `5001`。
|
||
|
||
### 4.5 通知 `/workspaces/{ws_id}/notifications`
|
||
|
||
| 方法 | 路径 | 说明 |
|
||
|---|---|---|
|
||
| GET | `/notifications?unread_only=true` | 我的通知,新的在前 |
|
||
| GET | `/notifications/unread-count` | `{ "unread": 3 }`,轮询用 |
|
||
| POST | `/notifications/{id}/read` | 标一条 |
|
||
| POST | `/notifications/read-all` | 全标已读 |
|
||
|
||
`kind` 一共七种,都是"某人在某件事上做了什么":
|
||
|
||
| kind | 什么时候发 | 发给谁 |
|
||
|---|---|---|
|
||
| `task_assigned` | 建任务/改任务时指派了人 | 被指派人 |
|
||
| `task_status_changed` | 任务状态变化 | 创建人 + 办理人(除操作者本人) |
|
||
| `item_commented` | 有人评论 | 创建人 + 办理人(除评论者本人) |
|
||
| `ticket_assigned` | 升级成工单 / 手工开单 / 改派 | 办理人 |
|
||
| `ticket_submitted` | 办理人提交验收 | 需求人 |
|
||
| `ticket_approved` / `ticket_rejected` | 验收通过 / 退回 | 办理人 |
|
||
| `task_unblocked` | 工单验收通过、原任务回流待办 | 原任务创建人 |
|
||
|
||
三条硬规则:
|
||
|
||
1. **不通知自己**:谁操作就跳过谁,否则每个人都会收到自己刚做的事。
|
||
2. 通知里必须写清"谁 + 做了什么 + 在哪件事上",否则点开等于重新看一遍列表。
|
||
3. 通知只是提示,不是凭证——点开之后目标接口会重新校验权限、重新取数据。
|
||
|
||
Web 端未读数由 `AppShell` 每 30 秒轮询一次;在通知页标已读后立刻派发 `notifications:changed` 事件刷新角标,不必等下一轮。
|
||
安卓端同样按 30 秒/切页刷新。
|
||
|
||
### 4.6 工单的错误码
|
||
|
||
| 码 | HTTP | 含义 |
|
||
|---|---|---|
|
||
| 6001 | 404 | 工单不存在,或不是工单 |
|
||
| 6002 | 409 | 这个任务已经升级过了 |
|
||
| 6003 | 409 | 当前状态不允许这个动作 |
|
||
| 6004 | 403 | 你没有权限做这个动作(例如自验) |
|
||
| 6005 | 404 | 通知不存在,或不是你的 |
|
||
|
||
---
|
||
|
||
## 5. 知识 `/workspaces/{ws_id}`
|
||
|
||
### 5.1 `GET /knowledge`
|
||
|
||
| 参数 | 说明 |
|
||
|---|---|
|
||
| `status` | 可重复;`draft`/`in_review`/`published`/`archived`,不传为全部 |
|
||
| `doc_type` | `note`/`howto`/`faq`/`postmortem`/`decision` |
|
||
| `source_item_id` | 按来源事项筛 |
|
||
| `category_id` | 按分类筛,**包含子分类** |
|
||
| `uncategorized` | `true` 只看未分类 |
|
||
| `tag` | 按标签名筛(不区分大小写) |
|
||
| `q` | 标题 / 正文 / 摘要子串 |
|
||
| `limit` | 1–100,默认 30 |
|
||
| `cursor` | 上一页的 `next_cursor` |
|
||
|
||
返回 `{items, next_cursor, has_more}`。每条是 `item` 字段 + `knowledge` + `category` + `source`:
|
||
|
||
```json
|
||
{
|
||
"id": "…", "kind": "knowledge", "title": "发票原件必须一起寄",
|
||
"body_md": "…", "status": "published", "tags": [{"id": "…", "name": "财务"}],
|
||
"knowledge": {
|
||
"doc_type": "howto", "summary": "…", "status_hint": "published",
|
||
"source_kind": "task", "source_item_id": "…", "generated_by": "human",
|
||
"published_at": "2026-10-28T05:54:44Z", "archived_at": null,
|
||
"chunk_count": 0, "author_id": "…"
|
||
},
|
||
"category": {
|
||
"id": "…", "parent_id": "…", "name": "回滚", "depth": 3,
|
||
"sort_order": 0, "version": 1,
|
||
"path": ["研发", "部署", "回滚"], "full_path": "研发 / 部署 / 回滚"
|
||
},
|
||
"source": {"id": "…", "kind": "task", "title": "发票原件必须一起寄", "status": "done"}
|
||
}
|
||
```
|
||
|
||
> `status` 与 `knowledge.status_hint` 是同一件事:前者在 `item` 上(复用事项表),
|
||
> 后者在 `knowledge_doc` 上(列表按知识口径筛时不必回连 `item`)。**两者必须同步**,
|
||
> 状态动作接口会一起写。
|
||
|
||
### 5.2 `GET /knowledge/{item_id}`
|
||
|
||
详情,附最近 50 条 `activities`。不存在或跨租户统一 `7001`。
|
||
|
||
### 5.3 `POST /knowledge` — 手记或指定来源
|
||
|
||
```json
|
||
{"title": "报销单要贴发票原件", "body_md": "…", "doc_type": "note",
|
||
"tag_names": ["财务", "报销"], "category_id": "可选的分类 id",
|
||
"source_item_id": "可选的来源事项 id"}
|
||
```
|
||
|
||
- 不带 `source_item_id` → `source_kind=manual`(手记),允许没有来源。
|
||
- 带 → 来源必须是同工作区的 `task`/`ticket`,否则 `7005`。
|
||
- `category_id` 可省(未分类);跨工作区的分类一律按不存在处理(`7006`)。
|
||
- `tag_names` 按名字去重创建,空格/逗号分隔由前端拆好再传。
|
||
- 新知识一律 `draft`,不会直接对外可见。
|
||
|
||
### 5.4 `PATCH /knowledge/{item_id}` — 修改内容
|
||
|
||
必须带 `version`(乐观锁,冲突 `4009`)。可改 `title`/`body_md`/`summary`/`doc_type`/`tag_names`/`category_id`;
|
||
传 `clear_category: true` 表示移到未分类。
|
||
|
||
**已发布的可以直接改**:改的是"团队现在看到的这一版",改完立刻重建检索切片(`chunk_count` 当场更新),
|
||
避免"正文改了、搜出来还是旧句子"。版本号照旧 `+1`,历史仍能靠审计追。
|
||
**已归档的不许直接改**(`7003`,`allowed_actions: ["restore"]`):先恢复成草稿再改,
|
||
免得改完还挂着"现役"的样子。
|
||
|
||
### 5.5 知识分类 `/knowledge/categories`
|
||
|
||
分类是工作区级的三级小树,用来给知识安一个位置;它**不是权限边界**,只是归类。
|
||
|
||
| 方法 | 路径 | 说明 |
|
||
|---|---|---|
|
||
| GET | `/knowledge/categories` | 扁平列表,但按**树的先序**排好(父分类后紧跟它的子分类,兄弟按 `sort_order`、`name`),前端照 `depth` 缩进即可;每条带 `doc_count`、`path`、`full_path`;另有 `max_depth=3` |
|
||
| POST | `/knowledge/categories` | `{name, parent_id?, sort_order?}` |
|
||
| PATCH | `/knowledge/categories/{id}` | `{name?, parent_id?, move?, sort_order?, version}` |
|
||
| DELETE | `/knowledge/categories/{id}?force=false` | 有子分类不让删;有知识要 `force=true`,删后知识变未分类 |
|
||
|
||
三条规则:
|
||
|
||
1. **最多三级**。第四级不是"更细",是"找不到",创建/移动越界一律 `7007`。
|
||
2. **同一层不能重名**(不区分大小写),否则 `7008`;不同层可以同名。
|
||
3. **移动带着子树走**:把一棵三层树挪到第二层下面会让最深节点到第五层,这种移动直接拒绝(`7007`),
|
||
不静默改层级。
|
||
|
||
`PATCH` 里 `move=true` 才处理 `parent_id`,避免"只改名字"被误当成移动。改名的 `version` 冲突是 `4009`。
|
||
|
||
### 5.6 `POST /knowledge/{item_id}/status` — 状态动作
|
||
|
||
| action | 允许的前置状态 | 结果 | 谁能做 |
|
||
|---|---|---|---|
|
||
| `submit_review` | draft | in_review | 成员 |
|
||
| `withdraw` | in_review | draft | 成员 |
|
||
| `publish` | draft / in_review | published | owner / admin |
|
||
| `archive` | draft / in_review / published | archived | 成员 |
|
||
| `restore` | archived | draft | 成员 |
|
||
|
||
非法动作返回 `7003`,`details.allowed_from` 是动作要求的前置状态,`details.allowed_actions`
|
||
是当前状态下还能做什么——前端据此渲染按钮,不用自己维护一份状态机。
|
||
|
||
**发布要 owner/admin 的理由**:发布等于团队为这条结论背书,不能谁都能推。
|
||
|
||
### 5.7 `POST /items/{item_id}/knowledge` — 把一件事沉淀成知识
|
||
|
||
Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;工单会带出验收结论。
|
||
返回与 5.2 相同的知识对象;同一来源重复沉淀 → `7002`。
|
||
|
||
**工单验收通过时,后端会自动调用这条逻辑**(`generated_by=rule`,`doc_type=postmortem`,
|
||
正文开头固定一句"这份草稿由系统根据来源自动整理,请先核对再发布"),所以用户不需要记得点。
|
||
|
||
### 5.8 检索 `GET /knowledge/search`
|
||
|
||
Query:
|
||
| 参数 | 说明 |
|
||
|---|---|
|
||
| `q` | 必填,1–200 字 |
|
||
| `limit` | 1–30,默认 10 |
|
||
| `mode` | `hybrid`(默认)/ `vector` / `keyword`,后两个用于对比效果 |
|
||
|
||
返回 `{query, mode, items[]}`,每项:
|
||
|
||
| 字段 | 说明 |
|
||
|---|---|
|
||
| `item_id` / `title` | 来源知识,前端"打开原文"用 |
|
||
| `doc_type` / `heading` | 类型与所在小节 |
|
||
| `idx` / `content` | 第几片、切片正文 |
|
||
| `score` | 加权总分,低于 `SEARCH_MIN_SCORE`(0.06)的结果**不返回** |
|
||
| `vector_score` / `lexical_score` | 向量分与 trigram 词法分,便于排查"为什么没搜到" |
|
||
|
||
三条约定:
|
||
|
||
1. **只检索 `published`**。草稿是还没被人认可的话,进检索等于把猜测当结论引用出去——过滤写在 SQL 里,调用方改不了。
|
||
2. **结果必须带来源**,能点回原文。这是 D3(知识必须有来源)的延伸:不给"出处不存在"的答案。
|
||
3. **宁可返回空,也不凑数**。检索失败比误导便宜。
|
||
|
||
发布(`5.6` 的 `publish`)时会自动重建索引,用户发布完立刻能搜到;
|
||
但改完正文不会自动重建,需要调用下面这条。
|
||
|
||
### `POST /knowledge/{item_id}/reindex` — 重建切片
|
||
|
||
返回 `{item_id, chunk_count, searchable}`。`searchable=false` 表示内容已建好切片但还不是 `published`,
|
||
搜不到——这不是错误。
|
||
|
||
实现:切片存 `chunk` 表(`content` + `embedding real[]`),相似度用 SQL 函数 `app_cosine()` 计算。
|
||
本机没有可用的 pgvector 实例(Docker 起不来,fnsvr-01 明确不动),所以先用 `real[]` 把链路跑通;
|
||
将来换 pgvector 只改列类型与索引,接口不变。换 embedding 模型(`EMBEDDING_PROVIDER`)后
|
||
**必须整体重建**,否则新旧向量混在一张表里,分数没法比较。
|
||
|
||
### 5.9 知识的错误码
|
||
|
||
| 码 | HTTP | 含义 |
|
||
|---|---|---|
|
||
| 7001 | 404 | 知识不存在,或不是知识 |
|
||
| 7002 | 409 | 这个来源已经有知识了 |
|
||
| 7003 | 409 | 当前状态不允许这个动作 |
|
||
| 7004 | 403 | 权限不足(如非管理员发布) |
|
||
| 7005 | 409 | 来源事项无效或缺失 |
|
||
| 7006 | 404 | 分类不存在,或不是本工作区的 |
|
||
| 7007 | 422 | 分类层级超过三级,或移动后会让子树超过三级 |
|
||
| 7008 | 409 | 分类重名 / 还有子分类 / 分类下还有知识(需 `force`) |
|
||
|
||
---
|
||
|
||
## 6. 附件与富文本
|
||
|
||
### 6.1 存储格式:Markdown + 附件表
|
||
|
||
正文类字段(任务 `body_md`、工单说明、知识正文、评论、验收结论)**一律存 Markdown**,不存 HTML。
|
||
理由:能导出、能全文检索、能直接喂给 LLM;HTML 每次渲染都要重新清洗一遍,是隐患。
|
||
|
||
图片不是 base64 塞进正文,而是走附件接口拿地址。**库里存的是不带凭据的相对路径**:
|
||
|
||
```markdown
|
||

|
||
```
|
||
|
||
| 方法 | 路径 | 说明 |
|
||
|---|---|---|
|
||
| POST | `/workspaces/{ws_id}/items/{item_id}/attachments` | multipart,字段名 `file`;返回 201 + 附件对象 |
|
||
| GET | `/workspaces/{ws_id}/items/{item_id}/attachments` | `{items: [...]}`,每条含 `raw_url` |
|
||
| GET | `/workspaces/{ws_id}/attachments/{id}/download` | 下载(`Content-Disposition: attachment`) |
|
||
| GET | `/workspaces/{ws_id}/attachments/{id}/raw?exp=&sig=` | 直链,给 `<img src>` 用(签名见 6.2) |
|
||
| DELETE | `/workspaces/{ws_id}/attachments/{id}?confirm=true` | 上传者或管理员可删;不传 confirm 报 `8004` |
|
||
|
||
附件对象的字段:`id / item_id / filename / content_type / size / sha256 / uploader_id / created_at / raw_url / can_delete`。
|
||
`raw_url` 是**后端签发的短期直链**(默认 30 分钟),客户端直接用它渲染,不需要自己拼令牌。
|
||
附件可挂在 `kind` 为 `task / ticket / knowledge` 的事项上(`ALLOWED_KINDS`),知识与任务、工单共用同一套接口;
|
||
事项与知识的列表接口会附带 `attachment_count`,详情接口附带 `attachments` 全量。
|
||
|
||
新建知识时条目还不存在,前端用「懒建草稿」:点上传(或往正文里贴图)时先 `POST /knowledge` 存一条草稿拿到 `item_id`,
|
||
再上传到 `/items/{item_id}/attachments`,之后的保存一律走 `PATCH /knowledge/{item_id}`。没传过附件就取消的人,库里不会留下空条目。
|
||
|
||
### 6.2 图片直链:短期签名,而不是把令牌写进正文
|
||
|
||
`<img>` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文膨胀几十倍,破坏检索),
|
||
或者给一个短期签名地址。取后者:
|
||
|
||
- `raw_url` 形如 `…/raw?exp=1730000000&sig=<hmac>`,签名载荷是 `workspace_id:attachment_id:exp`,
|
||
密钥从 `JWT_SECRET` 域分离派生(`attachment-raw-v1`),不与登录令牌共用。
|
||
- 校验只看签名与过期时间,**不需要用户已登录**;签名里绑了 `workspace_id`,A 工作区签的链接
|
||
打到 B 工作区会被拒(`8003`)。
|
||
- 过期不宽限,刷新页面即可拿到新链接。
|
||
- 兼容旧路径:不带 `sig` 时仍可用 `?token=<access>` 走成员校验,供老客户端过渡。
|
||
|
||
为什么非要这样:令牌写进正文会落库,几天后过期就是满篇红叉;用户换设备后旧令牌失效,图片全挂。
|
||
把凭据放在"每次渲染时现签"的 URL 上,正文才能长期存活。
|
||
|
||
### 6.3 附件错误码
|
||
|
||
### 6.3 附件错误码
|
||
|
||
| 码 | HTTP | 含义 |
|
||
|---|---|---|
|
||
| 8001 | 404 | 附件不存在,或不属于这个工作区 |
|
||
| 8002 | 413 | 超过 `MAX_UPLOAD_MB`(默认 20) |
|
||
| 8003 | 403 | 不是上传者也不是管理员 |
|
||
| 8004 | 409 | 需要 `?confirm=true`(删除是破坏性动作) |
|
||
|
||
### 6.4 前端约定
|
||
|
||
- 编辑器写 Markdown,工具栏/快捷键 `Ctrl+B`、`Ctrl+I` 只管插入语法;预览用 `renderMarkdown()`
|
||
渲染(先转义再上标签,链接协议白名单 `http(s)/mailto//`)。
|
||
- 粘贴或拖入图片时:先上传 → 拿到 `id` → 在光标处插入 `)`。
|
||
- 上传接口自己带 `Idempotency-Key`,**不要**手动设 `Content-Type`(浏览器要自己带 boundary)。
|
||
|
||
---
|
||
|
||
## 7. 健康与版本
|
||
|
||
| 方法 | 路径 | 说明 |
|
||
|---|---|---|
|
||
| GET | `/health` | `{status, db}`,DB 探活用 `SELECT 1` |
|
||
| GET | `/version` | `{commit, build_time, db_role}` |
|
||
|
||
`db_role` 里的 `is_super / bypass_rls / read_all` 全为 `false` 才说明 RLS 真的生效——
|
||
这是部署后的第一道检查,比"能登录"重要得多。
|
||
|
||
部署校验:`curl /version` 的 `commit` 必须等于 `git rev-parse --short HEAD`。
|