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

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

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

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

38 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.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)

微信里那个机器人把消息 POST 到本服务,消息就变成任务或知识草稿。 这一组接口不带登录态:机器人不会登我们的账号,它只照着配置好的地址发。

6.5.1 建通道(工作区 admin 以上)

方法 路径 说明
GET /workspaces/{ws}/integrations 列出这个工作区的通道
POST /workspaces/{ws}/integrations {"name":"微信"},响应里带一次性令牌
PATCH /workspaces/{ws}/integrations/{id} {"name"?}、{"status":"active"|"disabled"}
POST /workspaces/{ws}/integrations/{id}/rotate 换令牌,旧地址立刻失效
DELETE /workspaces/{ws}/integrations/{id} 删通道(历史消息级联删掉)
GET /workspaces/{ws}/integrations/{id}/messages?limit=20 最近的收信记录

创建响应:

{
  "code": 0, "message": "ok",
  "data": {
    "channel": { "id": "…", "kind": "wechat_clawbot", "name": "微信", "status": "active",
                 "last_seen_at": null, "created_at": "2026-11-05T14:02:11Z" },
    "token": "lS7…(43 字符)",
    "webhook_url": "https://example.com/api/v1/integrations/clawbot/lS7…"
  }
}

令牌只在这里出现一次。库里存的是 sha256(令牌),页面刷新之后连管理员也看不到; 丢了就 rotate 重新生成。

6.5.2 收消息(clawbot → 本服务)

方法 路径 说明
GET /integrations/clawbot/{token}?echostr=xxx 地址校验:有 echostr 就原样回纯文本
POST /integrations/clawbot/{token} 收一条消息,回 {"reply": "...", "action": "...", "duplicate": false}

请求体宽松解析(不同机器人的壳子不一样),按顺序找第一个非空字符串:

  • 正文:text / content / msg / message / query / body / data(嵌套对象也会往里找 text、content、body、message)
  • 消息 id:msg_id / message_id / msgid / id / uuid / event_id
  • 发送者:from / sender / from_user / user / uid / sender_id

正文的写法:

开头 结果
直接说 任务(status=inbox)
任务: / 待办: / todo: 任务
知识: / 笔记: / note: / kb: 知识草稿(doc_type=note、status_hint=draft)
? / 帮助 / help / 用法 只回一段用法说明,不建事项

首行是标题(截到 180 字),其余行是正文;标题与正文里的 #标签 会被摘出来挂到事项上 (纯数字的 #123 不算标签,避免把编号当成标签)。

幂等:带 msg_id 的请求重复到达只处理一次,第二次直接回上次的 reply 且 duplicate: true ——微信会重推,重推不能变成两条任务。

6.5.3 错误码

code HTTP 场景
9002 404 令牌不存在或已被换掉;对外一律"通道不存在",不区分工作区
9003 403 通道被停用(status=disabled)

6.5.4 租户隔离

bot_channel / bot_message 都带 workspace_id NOT NULL + RLS。收消息时还不知道它属于哪个 工作区,所以 bot_channel 的策略额外允许 token_hash = current_setting('app.bot_token_hash'): 请求先把 app.bot_token_hash 设成本次令牌的哈希,策略只放行命中那一行,其余行照旧看不见。 读到通道之后再 bind_tenant() 进这个工作区的上下文,任务是按通道创建者记账的。

7. 安卓发布与升级 /app

安装包不进版本库:backend/static/apk/ 由 publish_apk.py 写入,里面有 APK 与一份 latest.json 清单。接口只读这份清单,没有清单就等于没发布过(9001)。

7.1 GET /app/release/latest

参数 说明
version_code 可选,客户端当前版本号;给了才会算 has_update

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

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