上一版按"webhook + URL 里带令牌"自己编了一套,用户当场指出微信必须先扫码配对。 现在照腾讯 iLink Bot 协议自己做客户端:取二维码 → 手机扫码确认 → 存加密登录态 → 后端长轮询收消息 → 用 context_token 回话。独立实现,不依赖 DSH 的运行环境。 后端 - wechat_ilink.py:get_bot_qrcode / get_qrcode_status / getupdates / sendmessage, bot_token 用 Fernet(密钥由 JWT_SECRET 派生)加密入库,日志与接口都不回明文 - wechat_intake.py:一句话 → 任务或知识草稿,前缀识别、#标签、首行标题 - wechat_bot.py:每条通道一个轮询任务,启动时按 status='active' 恢复; message_id 去重;errcode -14 转 expired 让用户重新扫 - api/integrations.py 重写成扫码三接口 + 停用/断开/最近消息 - 迁移 0013(登录态列)、0014(轮询入口按 app.bot_channel 主键的 RLS 口子) Web - 接入页改成扫码流程:页面里画出二维码、每 2 秒问一次状态、连上显示微信用户与最近消息 验证 - pytest 149 passed(新增 test_wechat_bot 9 例) - ruff check / ruff format --check 干净;tsc 0;vite build ✓ - web/e2e/integrations.mjs 7 项 ALL PASS(拿的是微信真二维码) 教训:接入第三方协议先去已有实现里找协议,别从直觉倒推。
38 KiB
接口约定(M0–M3 已实现)
只写已经在跑的接口。写完没验证的东西不进这份文档。 前缀
/api/v1。所有响应都是同一个信封,HTTP 200 不代表业务成功。
0. 通用
0.1 信封
{ "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
{ "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
{ "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
{ "email": "someone@example.com", "code": "123456", "password": "dadao2026", "nickname": "大道" }
{ "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
{ "phone": "13800000000" }
响应 data: { dev_code?: "123456" }。ENV=dev 时才回显验证码,生产没有这个字段。
60 秒内重复请求 → 2010。短信登录关着时 → 2017。
POST /auth/login
{ "phone": "13800000000", "code": "123456" }
{ "user": { "id": "…", "phone": "138****0000", "nickname": "用户0000" },
"tokens": { "access_token": "…", "refresh_token": "…", "token_type": "bearer", "expires_in": 1800 } }
手机号首次登录即注册(昵称取"用户" + 后四位)。手机号回显永远是打码的。
POST /auth/refresh
{ "refresh_token": "…" }
返回同形状的 tokens(扁平,没有 tokens 信封)。无状态 JWT,服务端不维护黑名单——
token 泄露只能等过期,这是 M0 已知取舍;换来的是刷新不需要查库。
换的时候会看一眼账号状态:账号被停用 → 2012(403),停用的人不能靠 refresh 一直续下去。
POST /auth/login/password
手机号和邮箱都走这一个入口,phone 与 email 二选一(都给或都不给 → 422):
{ "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
{ "password": "dadao2026", "old_password": "old-one-1" }
设密码 / 改密码共用。已经有密码时 old_password 必填且必须正确;新旧密码相同 → 2015。
强度:8–64 位,至少一个字母 + 一个数字。返回 { "has_password": true }。
PATCH /auth/me
{ "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 |
发一封测试邮件,确认通道通不通 |
// PUT /settings/smtp
{ "host": "smtp.126.com", "port": 465, "username": "shufenhf@126.com",
"password": "客户端授权码", "from_name": "达道Task", "use_ssl": true }
password 传 null 或空串表示沿用旧值(改别的字段时不用重填授权码)。
// 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
{ "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
{ "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(看板多选拖拽)
{ "status": "done",
"items": [{ "id": "…", "version": 3 }, { "id": "…", "version": 7 }] }
{ "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(看板一屏数据)
{ "columns": { "todo": [...], "doing": [...], "blocked": [...], "done": [...] },
"order": ["todo", "doing", "blocked", "done"],
"labels": { "todo": "待办", "doing": "进行中", "blocked": "被阻塞", "done": "已完成" } }
- 每列默认 30 条(
per_column1–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(手工开单)
{ "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(任务升级成工单)
{ "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
{ "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 |
工单验收通过、原任务回流待办 | 原任务创建人 |
三条硬规则:
- 不通知自己:谁操作就跳过谁,否则每个人都会收到自己刚做的事。
- 通知里必须写清"谁 + 做了什么 + 在哪件事上",否则点开等于重新看一遍列表。
- 通知只是提示,不是凭证——点开之后目标接口会重新校验权限、重新取数据。
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:
{
"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 — 手记或指定来源
{"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,删后知识变未分类 |
三条规则:
- 最多三级。第四级不是"更细",是"找不到",创建/移动越界一律
7007。 - 同一层不能重名(不区分大小写),否则
7008;不同层可以同名。 - 移动带着子树走:把一棵三层树挪到第二层下面会让最深节点到第五层,这种移动直接拒绝(
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 词法分,便于排查"为什么没搜到" |
三条约定:
- 只检索
published。草稿是还没被人认可的话,进检索等于把猜测当结论引用出去——过滤写在 SQL 里,调用方改不了。 - 结果必须带来源,能点回原文。这是 D3(知识必须有来源)的延伸:不给"出处不存在"的答案。
- 宁可返回空,也不凑数。检索失败比误导便宜。
发布(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 塞进正文,而是走附件接口拿地址。库里存的是不带凭据的相对路径:

| 方法 | 路径 | 说明 |
|---|---|---|
| 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,本地渲染,不是图片地址):
{
"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 在微信里回一句,附上链接(PUBLIC_BASE_URL 或 CORS 里的第一个来源)。
同一条消息(按 message_id)重复推送只会落一次,重推直接跳过。
图片/文件/视频目前只在 bot_message.raw 里留痕,正文不搬;语音如果服务端给了转写就用转写文本。
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 |
免登录:老版本客户端可能连"登录"这一步都还没走到,检查更新不该需要令牌。
{
"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。