Files
DadaoTask/2.Design/API.md
T
Steven 90b99f8253
CI / backend (push) Canceled after 0s
feat(integrations): 微信里发一句话,变成一条任务或知识
一条通道一张令牌,机器人把消息 POST 到 /integrations/clawbot/{token},
正文按前缀落到任务或知识草稿:没有前缀就是任务,「知识:」「笔记:」记成知识,
「帮助」只回用法。首行当标题、其余当正文,`#标签` 摘出来挂到事项上。
同一个 msg_id 只处理一次——微信会重推,重推不能变成两条任务。

为什么不塞进 system_setting 的 JSON:一个工作区可能挂多个通道,还得能单独停用。
令牌库里只存 sha256,只在创建/重新生成时显示一次;收消息时还不知道是哪个工作区,
所以 bot_channel 的 RLS 额外放行 token_hash = current_setting('app.bot_token_hash'),
读到通道再 bind_tenant 进上下文,按通道创建者记账。

- 后端:models_integrations.py(bot_channel / bot_message)、迁移 0012(RLS)、
  api/integrations.py(收消息 + 管理接口)、错误码 9002/9003
- Web:/w/:wsId/integrations「接入」页——建通道、复制回调地址与令牌、最近消息、
  停用/换令牌/删除,只有 owner/admin 能改
- 测试:test_clawbot.py 10 例;web/e2e/integrations.mjs 7 项(冒充 clawbot 真推一条)

验证:pytest 150 passed,ruff check/format 全绿,tsc 0,vite build 通过,e2e 7 项全绿。
2026-11-05 22:04:34 +08:00

846 lines
38 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 次) | 提示稍后再试 |
| 2017 | 403 | 这条登录方式没开(默认只开密码) | 换密码登录 |
| 2018 | 422 | 邮箱格式不对 | 就地提示 |
| 2019 | 409 | 邮箱已被别的账号占用 | 换一个 |
| 2020 | 502 | 发信失败(SMTP 认证/收件人/网络) | 去「系统设置」核对授权码 |
| 2021 | 400 | 邮箱验证码不对 / 已过期 / 试错次数用尽 | 重新获取验证码 |
| 2022 | 429 | 验证码发送过于频繁 | 等 60 秒再发 |
| 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`)。
**客户端要自己续期,别等用户被踢出去**:access 剩不到 90 秒就先换一对,
撞上 `401/2002` 也先用 refresh 换一对、把原请求重放一次,换不动才清令牌回登录页。
每次 `/auth/refresh` 都会返回新的 refresh,所以只要 14 天内用过一次,登录态就一直往后顺延。
### 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`
登录方式有四种,**默认只开「密码」和「邮箱注册」**。服务端把"现在开着哪些"告诉前端,
前端据此渲染,免得用户点进一条关着的路:
### `GET /auth/methods`
```json
{ "password": true, "email_password": true, "sms_code": false, "email_register": true }
```
`SMS_LOGIN_ENABLED=false`(默认)时,`POST /auth/sms/send` 与 `POST /auth/login` 都回 `2017`
「短信验证码登录暂未开放」。理由:dev 环境的固定码等于"知道手机号就能进任何账号",
这条路必须显式打开,生产打开时还会强制要求真短信通道(见 `deploy` 文档的启动校验)。
### `POST /auth/email/code`
```json
{ "email": "someone@example.com" }
```
发一封注册验证码(6 位数字,`EMAIL_CODE_TTL_SECONDS` 默认 600 秒有效)。
邮箱统一按小写处理;格式不对 → `2018`;**这个邮箱已经注册过 → `2019`**(让用户在填完密码前就知道);
60 秒内重复请求 → `2022`;发信配置没填或发不出去 → `2020`(不会假装发出去);
`EMAIL_REGISTER_ENABLED=false` → `2017`。
响应 `data: { sent: true, expires_in: 600 }`。**真发信时不回显验证码**;
只有本机联调(`EMAIL_CODE_DEV_ECHO=true`)时才带 `dev_code` 且不发信——这个开关在生产会直接拒绝启动。
### `POST /auth/register`
```json
{ "email": "someone@example.com", "code": "123456", "password": "dadao2026", "nickname": "大道" }
```
```json
{ "user": { "id": "…", "phone": "", "email": "someone@example.com", "email_verified": true, … },
"tokens": { "access_token": "…", "refresh_token": "…", "token_type": "bearer", "expires_in": 1800 } }
```
注册成功即登录态(直接返回 `tokens`)。规则:
- 验证码不对 / 过期 / 试错超过 `EMAIL_CODE_MAX_ATTEMPTS`(默认 5)次 → `2021`;用完即作废,不能重放
- 密码强度不够 → `2015`;邮箱已被占用 → `2019`;`EMAIL_REGISTER_ENABLED=false` → `2017`
- 昵称不填就取邮箱 `@` 前面那段
- 建出来的账号**没有手机号**(`app_user.phone` 可空),所以 `user.phone` 回空串,不是星号串
邮箱是这类账号的唯一账号名与联系方式,所以注册必须真验一次码——"随手填一个"会让账号变成没人能找回的孤岛。
### `POST /auth/sms/send`
```json
{ "phone": "13800000000" }
```
响应 `data: { dev_code?: "123456" }`。`ENV=dev` 时才回显验证码,生产没有这个字段。
60 秒内重复请求 → `2010`。短信登录关着时 → `2017`。
### `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`(**扁平**,没有 `tokens` 信封)。无状态 JWT,**服务端不维护黑名单**——
token 泄露只能等过期,这是 M0 已知取舍;换来的是刷新不需要查库。
换的时候会看一眼账号状态:账号被停用 → `2012`(403),停用的人不能靠 refresh 一直续下去。
### `POST /auth/login/password`
手机号和邮箱都走这一个入口,`phone` 与 `email` **二选一**(都给或都不给 → 422):
```json
{ "phone": "13800000000", "password": "dadao2026" }
{ "email": "shufen@126.com", "password": "dadao2026" }
```
返回与验证码登录同形状的 `{ user, tokens }`。**没设过密码的账号一律回 `2013`**,与"密码错"同一个码
(不区分账号是否存在,少一个探测面;手机号与邮箱两种账号也不会互相暴露)。15 分钟内连错 10 次 → `2016`,
期间正确密码也挡住,计数键是"手机号或邮箱"本身。
邮箱按 `lower(email)` 比较,绑定与登录都做 `strip` + 小写归一,所以 `Shufen@126.com` 与
`shufen@126.com` 是同一个账号。
密码校验是 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": "大道", "email": "shufen@126.com" }
```
字段都可选,只改传上来的。邮箱留空串表示**解绑**(之后不能再用邮箱登录)。
格式不对 → `2018`,已被别的账号占用 → `2019`。返回 `{ "user": { … } }`。
空昵称(strip 后为空)→ `1002`。手机号暂不支持自助换号。
### `GET /auth/me`
返回 `{ user, workspaces[] }`,`workspaces[].role` 是当前用户在该工作区的角色。
`user` 里带 `has_password`(前端据此显示"设置密码"还是"修改密码")、`email`、
`email_verified`(这个邮箱是不是真验过)、`platform_admin`(能不能进「系统设置」)。
---
## 1.5 系统设置 `/settings`
平台级配置,**全站一份、不挂租户**,只有 `PLATFORM_ADMIN_PHONES` 里的人能读写(否则 `3004`)。
目前只有发信邮箱一项——它承载找回密码与以后的邮件通知,属于"改一次全站受影响"的东西,
放后台可改而不用登服务器改 env。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/settings/smtp` | 当前配置(**不含授权码**) |
| PUT | `/settings/smtp` | 保存,立即生效,不重启 |
| POST | `/settings/smtp/test` | 发一封测试邮件,确认通道通不通 |
```json
// PUT /settings/smtp
{ "host": "smtp.126.com", "port": 465, "username": "shufenhf@126.com",
"password": "客户端授权码", "from_name": "达道Task", "use_ssl": true }
```
`password` 传 `null` 或空串表示**沿用旧值**(改别的字段时不用重填授权码)。
```json
// GET /settings/smtp
{ "host": "smtp.126.com", "port": 465, "username": "shufenhf@126.com",
"password_set": true, "from_name": "达道Task", "use_ssl": true,
"configured": true, "source": "db", "updated_at": "2026-11-02T12:00:00Z" }
```
`source` 说明当前值来自数据库(`db`)、服务器环境变量兜底(`env`),还是根本没配(`none`)。
发信失败统一 `2020`(`502`),消息里写清是认证失败还是收件人被拒——授权码要用邮箱服务商的
**客户端授权码**,不是登录密码。
---
## 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)。
---
## 6.5 外部接入(微信 clawbot)
微信里那个机器人把消息 POST 到本服务,消息就变成任务或知识草稿。
**这一组接口不带登录态**:机器人不会登我们的账号,它只照着配置好的地址发。
### 6.5.1 建通道(工作区 admin 以上)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/workspaces/{ws}/integrations` | 列出这个工作区的通道 |
| POST | `/workspaces/{ws}/integrations` | `{"name":"微信"}`,**响应里带一次性令牌** |
| PATCH | `/workspaces/{ws}/integrations/{id}` | `{"name"?}`、`{"status":"active"|"disabled"}` |
| POST | `/workspaces/{ws}/integrations/{id}/rotate` | 换令牌,旧地址立刻失效 |
| DELETE | `/workspaces/{ws}/integrations/{id}` | 删通道(历史消息级联删掉) |
| GET | `/workspaces/{ws}/integrations/{id}/messages?limit=20` | 最近的收信记录 |
创建响应:
```json
{
"code": 0, "message": "ok",
"data": {
"channel": { "id": "…", "kind": "wechat_clawbot", "name": "微信", "status": "active",
"last_seen_at": null, "created_at": "2026-11-05T14:02:11Z" },
"token": "lS7…(43 字符)",
"webhook_url": "https://example.com/api/v1/integrations/clawbot/lS7…"
}
}
```
> 令牌只在这里出现一次。库里存的是 `sha256(令牌)`,页面刷新之后连管理员也看不到;
> 丢了就 `rotate` 重新生成。
### 6.5.2 收消息(clawbot → 本服务)
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | `/integrations/clawbot/{token}?echostr=xxx` | 地址校验:有 `echostr` 就原样回纯文本 |
| POST | `/integrations/clawbot/{token}` | 收一条消息,回 `{"reply": "...", "action": "...", "duplicate": false}` |
请求体**宽松解析**(不同机器人的壳子不一样),按顺序找第一个非空字符串:
- 正文:`text` / `content` / `msg` / `message` / `query` / `body` / `data`(嵌套对象也会往里找 `text`、`content`、`body`、`message`)
- 消息 id:`msg_id` / `message_id` / `msgid` / `id` / `uuid` / `event_id`
- 发送者:`from` / `sender` / `from_user` / `user` / `uid` / `sender_id`
正文的写法:
| 开头 | 结果 |
|---|---|
| 直接说 | 任务(`status=inbox`) |
| `任务:` / `待办:` / `todo:` | 任务 |
| `知识:` / `笔记:` / `note:` / `kb:` | 知识草稿(`doc_type=note`、`status_hint=draft`) |
| `?` / `帮助` / `help` / `用法` | 只回一段用法说明,不建事项 |
首行是标题(截到 180 字),其余行是正文;标题与正文里的 `#标签` 会被摘出来挂到事项上
(纯数字的 `#123` 不算标签,避免把编号当成标签)。
**幂等**:带 `msg_id` 的请求重复到达只处理一次,第二次直接回上次的 `reply` 且 `duplicate: true`
——微信会重推,重推不能变成两条任务。
### 6.5.3 错误码
| code | HTTP | 场景 |
|---|---|---|
| 9002 | 404 | 令牌不存在或已被换掉;对外一律"通道不存在",不区分工作区 |
| 9003 | 403 | 通道被停用(`status=disabled`) |
### 6.5.4 租户隔离
`bot_channel` / `bot_message` 都带 `workspace_id NOT NULL` + RLS。收消息时还不知道它属于哪个
工作区,所以 `bot_channel` 的策略额外允许 `token_hash = current_setting('app.bot_token_hash')`:
请求先把 `app.bot_token_hash` 设成本次令牌的哈希,策略只放行命中那一行,其余行照旧看不见。
读到通道之后再 `bind_tenant()` 进这个工作区的上下文,任务是按**通道创建者**记账的。
## 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`。