Files
DadaoTask/2.Design/API.md
T
Steven 690779c5fb
CI / backend (push) Canceled after 0s
fix(wechat): 公众号新版页面抓不到正文,改从 cgiDataNew 里读
用户发来 https://mp.weixin.qq.com/s/ZEOsi4R4JcmZ_mhS6qpcww,存进库的正文只有 67 个字符
("原文链接 + 一个句号")。复盘发现公众号换了渲染方式:新版页面把整篇塞进
window.cgiDataNew 这个 JS 对象由前端渲染,页面里根本没有 id="js_content";
三种 UA 拿到的字节数完全一样,也不是 UA 的问题。

- 从 JS 字符串字面量里读正文(content_noencode / desc),按 \xNN、\uNNNN 反转义,
  话题标签是双重转义过的 HTML,先 unescape 再走 DOM。
- 配图字段排在文字之后,所以前后各找 2500 字符合图;剩下没用过的 mmbiz 图按
  12 张上限补在末尾(图文型文章靠就近配对会漏)。
- 解析顺序:旧 DOM → 新版 JS 数据 → 通用抽取,一层层退。

实测两篇:2373 字符 + 12 张图、2772 字符 + 13 张图,作者也都取到了。

同时补上用户提的两件:

- 知识能删了:DELETE /knowledge/{id}?confirm=true,软删 + 真删检索切片,
  只有作者或 owner/admin 能删;列表卡片(悬停出现)和阅读页动作行各一个入口。
  删完详情也回 7001,否则收藏的链接还能打开一篇已删除的知识。
- 分类下面有标签清单了:GET /knowledge/tags 按标签数"有多少条知识用了它",
  侧栏渲染成 #标签 + 条数,点一下筛选、再点取消。
2026-11-06 02:08:36 +08:00

41 KiB
Raw Blame History

接口约定(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_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(手工开单)

{ "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 与任务列表一致
{ "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:

{
  "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.2.1 GET /knowledge/tags — 侧栏那串标签

GET /knowledge/tags?status=published&status=in_review
{"items": [{"name": "发布", "count": 12}, {"name": "回滚", "count": 3}]}
  • count 是有多少条知识用了它(count(distinct item_id)),不是引用次数。
  • 只统计未删除的知识;带了 status 就只数这些状态,和列表的 tab 对得上。
  • 按条数从多到少排,同数按名字。标签是分类之外的第二根轴:分类回答"属于哪一块",标签回答"跟什么有关"。

5.2.2 DELETE /knowledge/{item_id}?confirm=true — 删除

  • 必须显式带 confirm=true,否则 1002「删除不能撤销,带 confirm=true 再来一次」(防手滑)。
  • 只有作者本人或 owner/admin 能删,否则 3004。
  • 软删:item.deleted_at 落时间戳,列表、详情、检索、标签统计里都不再出现; 检索切片真删(chunk 表),否则"删掉的知识还能被搜出来"比删不掉更吓人。
  • 重复删不回错误,回 message: "已经删过了",让重试的客户端不必区分。

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,删后知识变未分类

三条规则:

  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 塞进正文,而是走附件接口拿地址。库里存的是不带凭据的相对路径:

![票据截图.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,页面叫「机器人」)

微信里那个机器人连上本服务之后,发一句话就变成任务或知识草稿。 走的是腾讯 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 在微信里回一句「已保存任务:标题」/「已保存知识草稿:标题」。 回话里不带链接:站点地址对手机不一定打得开(内网 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 且页面有 #js_content:标题 #activity-name、作者 #js_name、正文 #js_content,图片取 data-src(通用抽取器会把公众号的图丢光)
公众号(新版页) 没有 #js_content 时(appmsg_type = '9' / '10002')整篇在 window.cgiDataNew 里:从 content_noencode / desc 的 JS 字符串字面量读正文,按 \xNN、\uNNNN 反转义,话题标签先 html.unescape 再走 DOM;配图字段(cdn_url/img_url/pic_url)排在文字之后,前后各找 2500 字符合图;作者兜底取 nick_name
清洗 导航/页脚按行边界裁掉(_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

免登录:老版本客户端可能连"登录"这一步都还没走到,检查更新不该需要令牌。

{
  "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。