CI / backend (push) Canceled after 0s
公众号的文章也能抓:走 mp.weixin.qq.com 的 DOM(#js_content),
按 data-src 取图,不靠通用抽取器——它会把公众号的图全丢掉。
正文里的外链图下载进我们自己的存储(最多 12 张、单张 8 MB),
地址改成 /attachments/{id}/raw?签名:公众号那边一换图,外链就是一排红叉。
抓取入口谁都能触发,所以只允许公网地址(内网段、回环、链路本地、
保留段、解析不出的域名一律拒绝),否则就是个 SSRF 跳板。
抓不到要说人话:"这个链接没抓下来(原因)。想直接记成任务的话,
加「任务:」前缀再发一次。" 反爬验证页、SPA 空壳都走这条路。
- 新增 services/article.py:extract_url / parse / fetch / store_images / compose_body
- wechat_intake:整条消息是链接时走抓取,落知识草稿,回「已存成知识草稿:标题」
- 依赖 trafilatura==2.0.0(装法见 requirements.txt 注释:必须逐个装再 --no-deps)
- 测试 9 例:链接判定、公众号解析、验证页、通用页裁导航、SSRF 拒绝、图片本地化、失败提示
858 lines
40 KiB
Markdown
858 lines
40 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 认证/收件人/网络) | 去「系统设置」核对授权码 |
|
||
| 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
|
||

|
||
```
|
||
|
||
| 方法 | 路径 | 说明 |
|
||
|---|---|---|
|
||
| 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)。
|
||
|
||
---
|
||
|
||
## 6.5 外部接入(微信 clawbot,页面叫「机器人」)
|
||
|
||
微信里那个机器人连上本服务之后,发一句话就变成任务或知识草稿。
|
||
走的是腾讯 iLink Bot 协议:**扫码登录 → 后端长轮询收消息 → 用 context_token 回复**。
|
||
所以这一组里只有「扫码」是免登录的(机器人自己不带我们的令牌),其余都要工作区 admin 以上。
|
||
|
||
### 6.5.1 扫码与通道(工作区 admin 以上)
|
||
|
||
| 方法 | 路径 | 说明 |
|
||
|---|---|---|
|
||
| GET | `/workspaces/{ws}/integrations` | 列出通道(含 `status`、`connected`、`polling`、`last_seen_at`、`last_error`) |
|
||
| POST | `/workspaces/{ws}/integrations/wechat/qr` | 取一张二维码,返回 `{channel_id, qrcode_id, payload, image, expires_in}` |
|
||
| GET | `/workspaces/{ws}/integrations/wechat/qr/{qrcode_id}` | 问扫码结果:`waiting`/`scaned`/`confirmed`/`expired` |
|
||
| PATCH | `/workspaces/{ws}/integrations/{id}` | `{"status":"active"|"disabled"}`,停用就停轮询 |
|
||
| DELETE | `/workspaces/{ws}/integrations/{id}?confirm=true` | 断开:清掉登录态与游标,回到 `pending` |
|
||
| GET | `/workspaces/{ws}/integrations/{id}/messages?limit=5` | 最近收到的消息与它变成了什么 |
|
||
|
||
取二维码的响应(`image` 是可以直接塞进 `<img>` 的 data URL,本地渲染,不是图片地址):
|
||
|
||
```json
|
||
{
|
||
"channel_id": "4f0a…",
|
||
"qrcode_id": "30c97db83291e779217cbe20c5373e91",
|
||
"payload": "https://liteapp.weixin.qq.com/q/7GiQu1?qrcode=30c97…&bot_type=3",
|
||
"image": "data:image/svg+xml;base64,PD94bWw…",
|
||
"expires_in": 300
|
||
}
|
||
```
|
||
|
||
扫码成功那一次(`status == "confirmed"`)除了回 `status`,还带 `channel`:登录态已加密存进
|
||
`bot_channel.bot_token_enc`(Fernet,密钥由 `JWT_SECRET` 派生),**不会回给任何接口**,轮询任务同时被拉起。
|
||
|
||
`status` 的四个值:`pending`(还没连)、`active`(连上了、正在收)、`disabled`(管理员停用)、
|
||
`expired`(登录态失效,需要重新扫码)。`polling` 说明后台任务此刻是否在跑。
|
||
|
||
### 6.5.2 消息怎么变成事项(不分接口,轮询里做)
|
||
|
||
给机器人发的话这样理解:
|
||
|
||
| 开头 | 落成什么 |
|
||
|---|---|
|
||
| `任务:`/`待办:`/`todo:` 或不带前缀 | 任务(`status = "inbox"`) |
|
||
| `知识:`/`笔记:`/`note:`/`kb:` | 知识草稿(`status = "draft"`,`doc_type = "note"`) |
|
||
| `帮助`/`?`/`help` | 只回一段用法,不建任何东西 |
|
||
|
||
正文第一行当标题(超过 180 字截断),其余当正文;`#标签` 会被摘出来加到事项上(纯数字的不算标签)。
|
||
建完之后会用 `context_token` 在微信里回一句「已保存任务:标题」/「已保存知识草稿:标题」。
|
||
**回话里不带链接**:站点地址对手机不一定打得开(内网 IP、端口、还没上 HTTPS 的域名),
|
||
发过去只会让人以为崩了。
|
||
同一条消息(按 `message_id`)重复推送只会落一次,重推直接跳过。
|
||
|
||
图片/文件/视频目前只在 `bot_message.raw` 里留痕,正文不搬;语音如果服务端给了转写就用转写文本。
|
||
|
||
### 6.5.2.1 发一条链接:抓正文存成知识
|
||
|
||
整个消息就是一条 `http://` / `https://` 链接时(前后带标点不算),不当任务,而是去把文章抓下来:
|
||
|
||
| 环节 | 行为 |
|
||
|---|---|
|
||
| 判断 | `extract_url()`:去掉首尾标点后只剩一个链接;带了别的话("看下 https://… 这个")仍按任务处理 |
|
||
| 抓取 | `httpx` 取页面(25 秒超时、8 MB 上限),`trafilatura` 抽正文;标题取 `og:title`/`<h1>`/`<title>` |
|
||
| 公众号 | `mp.weixin.qq.com` 单独走 DOM:标题 `#activity-name`、作者 `#js_name`、正文 `#js_content`,图片取 `data-src`(通用抽取器会把公众号的图丢光) |
|
||
| 清洗 | 导航/页脚按行边界裁掉(`_trim_chrome`);`trafilatura` 结果太短(<120 字)就退到容器打分兜底 |
|
||
| 配图 | 正文里的外链图下载进我们自己的存储,最多 12 张、单张 8 MB,正文改写成 `/attachments/{id}/raw?签名`——公众号一换图,外链就是一排红叉 |
|
||
| 落库 | 知识草稿(`status="draft"`,`doc_type="note"`),正文首行是 `> 原文:<url>` |
|
||
| 回话 | `已存成知识草稿:标题`(存了图再加「,连图 N 张」) |
|
||
| 失败 | `这个链接没抓下来(原因)。想直接记成任务的话,加「任务:」前缀再发一次。` |
|
||
|
||
安全:抓取入口对任何能给机器人发消息的人开放,所以**只允许公网地址**——`127.0.0.1`、内网段、
|
||
链路本地、保留地址、解析不出来的域名一律拒绝(`ArticleError`),否则就是个 SSRF 跳板。
|
||
反爬/需登录的页面(微信"环境异常"验证页、Cloudflare 302、SPA 空壳)会回上面那句失败提示,不静默吞掉。
|
||
|
||
### 6.5.3 错误码
|
||
|
||
| 场景 | code | HTTP |
|
||
|---|---|---|
|
||
| 取二维码 / 问状态时微信返回错误 | `9002` | 404 |
|
||
| 通道不存在、或不属于这个工作区 | `9002` | 404 |
|
||
| 登录态失效(微信 errcode `-14`) | —(通道转 `expired`,接口正常返回) | 200 |
|
||
|
||
### 6.5.4 表与隔离
|
||
|
||
`bot_channel`(通道、登录态、游标)与 `bot_message`(收到的消息留痕)都挂 `workspace_id`,
|
||
走标准 RLS。轮询任务从 `channel_id` 起步、此刻还没有工作区上下文,所以迁移 `0014`
|
||
额外给了一条 `app.bot_channel` 的按主键口子(permissive 策略,与原隔离策略 OR)。
|
||
|
||
|
||
## 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`。
|