CI / backend (push) Canceled after 0s
用户要求:内测的固定验证码 123456 等于"知道手机号就能进任何账号",先关掉; 登录改用账号密码,并多一种"邮箱当账号名"的方式;发信邮箱要能在后台改。 短信那条路(默认关): - `SMS_LOGIN_ENABLED=false`,`/auth/sms/send` 与 `/auth/login` 统一回 2017,不是静默失败; - 生产校验改成"开着短信登录就必须配真通道",不开时 provider 是 dev 也不拦; - 新增 `GET /auth/methods`,登录页据此渲染,关着的路不出现(少一次"点进去才发现不能用")。 邮箱登录: - `app_user.email` + `uq_app_user_email`(`lower(email)` 部分索引),登录与绑定都归一成小写; - `POST /auth/login/password` 收 `phone` 或 `email`(二选一),失败统一 2013、连错 10 次锁 15 分钟; - 绑定在 `PATCH /auth/me`:格式错 2018、被占用 2019、留空串表示解绑。 系统设置(跨租户,只有平台管理员能改): - 新表 `system_setting`(不挂租户、无 RLS,写权限由 `deps.platform_admin` 把关); - `GET/PUT /settings/smtp`、`POST /settings/smtp/test`,授权码只写不读,留空表示沿用旧值; - `services/mail.py` 只用标准库 smtplib:465 走 SSL、587 走 STARTTLS,认证/拒收/网络失败都归 2020 但消息不同; - Web 新增「系统设置」页(状态卡 + 测试发信),侧栏只对平台管理员显示入口;账号页补「邮箱」。 验证:pytest 130 passed、ruff check/format 全绿、tsc 通过、vite build 通过; `web/e2e/login-email-smtp.mjs` 15 项全绿(含用 126 授权码真发一封测试邮件)。 顺带记下的两个坑:`platform_admin_phones` 是 list,pydantic-settings 会按 JSON 解析, 要用 `NoDecode` 交给 before-validator 拆逗号;迁移号写成 0009 会撞既有的 0009,一并改成 0010。
728 lines
32 KiB
Markdown
728 lines
32 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 | 账号被禁用 | 联系管理员 |
|
||
| 2013 | 400 | 密码错误,或这个号还没设过密码 | 提示核对账号与密码 |
|
||
| 2014 | 400 | 该账号没有密码(保留码) | 先设密码 |
|
||
| 2015 | 422 | 密码太弱(少于 8 位 / 缺字母或数字 / 与旧密码相同) | 就地提示规则 |
|
||
| 2016 | 429 | 密码连错太多次(15 分钟内 10 次) | 提示稍后再试 |
|
||
| 2017 | 403 | 这条登录方式没开(默认只开密码) | 换密码登录 |
|
||
| 2018 | 422 | 邮箱格式不对 | 就地提示 |
|
||
| 2019 | 409 | 邮箱已被别的账号占用 | 换一个 |
|
||
| 2020 | 502 | 发信失败(SMTP 认证/收件人/网络) | 去「系统设置」核对授权码 |
|
||
| 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`
|
||
|
||
登录方式有三种,**默认只开密码那一种**。服务端把"现在开着哪些"告诉前端,前端据此渲染,
|
||
免得用户点进一条关着的路:
|
||
|
||
### `GET /auth/methods`
|
||
|
||
```json
|
||
{ "password": true, "email_password": true, "sms_code": false }
|
||
```
|
||
|
||
`SMS_LOGIN_ENABLED=false`(默认)时,`POST /auth/sms/send` 与 `POST /auth/login` 都回 `2017`
|
||
「短信验证码登录暂未开放」。理由:dev 环境的固定码等于"知道手机号就能进任何账号",
|
||
这条路必须显式打开,生产打开时还会强制要求真短信通道(见 `deploy` 文档的启动校验)。
|
||
|
||
### `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`。无状态 JWT,**服务端不维护黑名单**——token 泄露只能等过期,这是 M0 已知取舍。
|
||
|
||
### `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`、`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
|
||

|
||
```
|
||
|
||
| 方法 | 路径 | 说明 |
|
||
|---|---|---|
|
||
| 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` → 在光标处插入 `)`。
|
||
- 上传接口自己带 `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`。
|