Files
DadaoTask/2.Design/API.md
T
Steven a5510cdb79 feat(auth): 手机号也能用密码登录,验证码之外多一条路
短信通道挂了人就进不来,所以给账号加第二把自助的钥匙:设过密码的用密码进,
没设过的照旧只用验证码,不强制。

- app_user.password_hash(迁移 0008 加列)+ scrypt 哈希(标准库实现,
  n=2^14/r=8/p=1,存 scrypt$n$r$p$salt$hash),不引 passlib
- POST /auth/login/password:没设过密码与密码错都回 2013,不区分账号是否存在;
  15 分钟内连错 10 次 → 2016,期间正确密码也挡住
- POST /auth/password:设/改共用,改要先验旧密码,新旧相同算弱密码 2015
- PATCH /auth/me 改昵称;用户输出走白名单 _user_out,密码哈希一个字节都不出去
- 登录页顶部「验证码 / 密码」两段切换

tests/test_account.py 10 项;全量 pytest 113 passed,ruff 全绿。
2026-10-31 04:04:55 +08:00

668 lines
29 KiB
Markdown
Raw 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 | 账号被禁用 | 联系管理员 |
| 2013 | 400 | 密码错误,或这个号还没设过密码 | 提示改用验证码登录 |
| 2014 | 400 | 该账号没有密码(保留码) | 先设密码 |
| 2015 | 422 | 密码太弱(少于 8 位 / 缺字母或数字 / 与旧密码相同) | 就地提示规则 |
| 2016 | 429 | 密码连错太多次(15 分钟内 10 次) | 提示稍后再试或用验证码 |
| 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 已知取舍。
### `POST /auth/login/password`
```json
{ "phone": "13800000000", "password": "dadao2026" }
```
返回与验证码登录同形状的 `{ user, tokens }`。**没设过密码的账号一律回 `2013`**,与"密码错"同一个码
(不区分账号是否存在,少一个探测面)。15 分钟内连错 10 次 → `2016`,期间正确密码也挡住。
密码校验是 scrypt(`n=2^14, r=8, p=1`,标准库实现,不引 passlib),库里存
`scrypt$n$r$p$salt$hash`。`password_hash` 这一列**永不出现在任何响应里**。
### `POST /auth/password`
```json
{ "password": "dadao2026", "old_password": "old-one-1" }
```
设密码 / 改密码共用。已经有密码时 `old_password` 必填且必须正确;新旧密码相同 → `2015`。
强度:8–64 位,至少一个字母 + 一个数字。返回 `{ "has_password": true }`。
### `PATCH /auth/me`
```json
{ "nickname": "大道" }
```
返回 `{ "user": { … } }`。空昵称(strip 后为空)→ `1002`。手机号暂不支持自助换号。
### `GET /auth/me`
返回 `{ user, workspaces[] }`,`workspaces[].role` 是当前用户在该工作区的角色。
`user` 里带 `has_password`,前端据此决定账号页显示"设置密码"还是"修改密码"。
---
## 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
![票据截图.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 附件错误码
| 码 | 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. 安卓发布与升级 `/app`
安装包不进版本库:`backend/static/apk/` 由 `publish_apk.py` 写入,里面有 APK 与一份
`latest.json` 清单。接口只读这份清单,**没有清单就等于没发布过**(9001)。
### 7.1 `GET /app/release/latest`
| 参数 | 说明 |
|---|---|
| `version_code` | 可选,客户端当前版本号;给了才会算 `has_update` |
免登录:老版本客户端可能连"登录"这一步都还没走到,检查更新不该需要令牌。
```json
{
"code": 0,
"data": {
"platform": "android",
"version_code": 2,
"version_name": "0.2.0",
"notes": "新增应用内升级;图标换成和 Web 一样",
"file": "dadaotask-0.2.0.apk",
"size": 8123456,
"sha256": "…",
"min_sdk": 26,
"published_at": "2026-10-31T03:20:00Z",
"download_url": "/api/v1/app/release/download/dadaotask-0.2.0.apk",
"has_update": true
}
}
```
`download_url` 是**相对路径**:同一份清单要被内网、公网、模拟器三个入口共用,
写死域名只会让其中一个坏掉;客户端用自己配置的 API 地址拼。
`has_update` 的判据只有 `version_code`,不看版本名字符串——字符串比较会把
`0.10.0` 判成比 `0.9.0` 旧。
### 7.2 `GET /app/release/download/{file}`
只认清单里登记过的文件名,其它一律 9001(含 `..%2F` 这类路径穿越尝试)。
返回 `application/vnd.android.package-archive`。同样免登录:下载地址本身就是
清单里给出的,能拿到清单就说明已经过了一道。
### 7.3 发布包的约定
- APK 必须用**同一把证书**签名,否则新包装不上去(系统只会报"应用未安装")。
证书放在发布者本机(`android/keystore.properties` 指向它),不进仓库。
- 上传清单带 `sha256`,客户端下完先校验再交给系统安装器:内网代理缓存旧包时,
这是唯一能挡住"装了个旧版本"的地方。
- 「允许安装未知应用」是系统开关,App 只能把用户送到设置页,不能代点。
### 7.4 错误码
| 码 | HTTP | 含义 |
|---|---|---|
| 9001 | 404 | 还没发布过安装包,或下载的文件不在清单里 |
---
## 8. 健康与版本
| 方法 | 路径 | 说明 |
|---|---|---|
| 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`。