Files
Steven 7c84ff3b8d
CI / backend (push) Canceled after 0s
feat(knowledge): 知识能挂附件,新建时就能传
知识是备忘录,备忘录不能只有字:手册、日志、导出表格都得能挂上去。
附件本来就落在 item 上(`ALLOWED_KINDS` 早含 knowledge),缺的是"新建时
还没有条目 id"这一段。

- 懒建草稿:`KnowledgeComposer` 第一次传附件(或往正文贴图)时先 `POST /knowledge`
  存一条草稿拿到 id,`draftingRef` 去重防止贴图与上传同时打出两条草稿;
  之后的保存走 `PATCH /knowledge/{id}`。没传过附件就取消,库里不留空条目。
- `RichTextEditor` 增 `ensureItemId`:没有 itemId 时先把草稿存下来再传图,
  于是"新建时不能贴图"这条限制也没了;工具栏上传按钮按是否有落点显隐。
- 新建面板加了附件区:有草稿 id 就是正常的 `AttachmentPanel`(上传/下载/删),
  没有就先给一个「添加附件」按钮,点一下建草稿。
- 安卓知识详情加附件区:列表(文件名 + 大小)、`OpenDocument` 上传任意文件、
  点「打开」用浏览器看签名直链。编辑器原本只能插图,现在手册/日志也能传。
- 文档:API.md 说明附件三类事项共用同一套接口,以及懒建草稿的先后顺序;
  ROADMAP 加 3.13。

验证:后端 pytest 94 passed、ruff 全绿;Web `tsc --noEmit` 通过、`vite build` 通过;
`knowledge-notebook.mjs` 15 项全绿(新增「新建面板里能上传附件」「详情抽屉里能看到
刚传的附件」「附件挂在知识上,详情回读得到」)。安卓端仍是 JDK 25 + Gradle 8.7
不兼容,本次未编译验证。
2026-10-30 16:18:32 +08:00

572 lines
25 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 接口约定(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` 表示移到未分类。
**已发布的不许直接改**(`7003`,先撤回审议或归档),避免"发布后被悄悄改写"。
### 5.5 知识分类 `/knowledge/categories`
分类是工作区级的三级小树,用来给知识安一个位置;它**不是权限边界**,只是归类。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/knowledge/categories` | 扁平列表(按 depth/sort_order 排好),每条带 `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
![票据截图.png](/api/v1/workspaces/{ws_id}/attachments/{attachment_id}/raw)
```
| 方法 | 路径 | 说明 |
|---|---|---|
| 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` → 在光标处插入 `![文件名](attachmentUrl(...))`。
- 上传接口自己带 `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`。