18 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 | 账号被禁用 | 联系管理员 |
| 3001 | 404 | 工作区不存在 | — |
| 3003 | 403 | 不是该工作区成员 | 不要提示"工作区不存在" |
| 3004 | 403 | 角色权限不足 | 隐藏对应按钮 |
| 3005 | 409 | 已经是成员 | 直接进工作区 |
| 3010 | 400 | 邀请码无效/过期 | 提示重新索取 |
| 4009 | 409 | 版本冲突(乐观锁) | 用 data.current_version 重试或刷新 |
| 5001 | 404 | 任务不存在 | 刷新列表 |
| 5002 | 409 | 非法状态流转 | 用 data.allowed 决定下一步给什么按钮 |
| 5003 | 404 | 标签不存在 | — |
错误码发布后不许改号,客户端靠它做分支。
0.3 认证
Authorization: Bearer <access_token>。access 30 分钟,refresh 14 天。
refresh_token 只能用于 /auth/refresh(服务端校验 typ)。
0.4 幂等
所有 POST/PATCH/DELETE 都可带 Idempotency-Key: <uuid>。
24 小时内同用户 + 同 key 返回首次结果,并带响应头 X-Idempotent-Replay: 1。
只缓存 2xx/3xx,失败不缓存,所以重试是安全的。
0.5 并发
改动类接口(PATCH /items/{id}、POST /items/{id}/status、DELETE)必须带 version。
拿不到 version 就别改——先 GET 一次。
1. 认证 /auth
POST /auth/sms/send
{ "phone": "13800000000" }
响应 data: { dev_code?: "123456" }。ENV=dev 时才回显验证码,生产没有这个字段。
60 秒内重复请求 → 2010。
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。无状态 JWT,服务端不维护黑名单——token 泄露只能等过期,这是 M0 已知取舍。
GET /auth/me
返回 { user, workspaces[] },workspaces[].role 是当前用户在该工作区的角色。
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.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:ticket_assigned / ticket_submitted / ticket_approved / ticket_rejected / task_unblocked。
通知只发给当事人,同工作区里其他人看不到——这是"协同"与"广播"的区别。
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 |
按来源事项筛 |
q |
标题 / 正文 / 摘要子串 |
limit |
1–100,默认 30 |
cursor |
上一页的 next_cursor |
返回 {items, next_cursor, has_more}。每条是 item 字段 + knowledge + source:
{
"id": "…", "kind": "knowledge", "title": "发票原件必须一起寄",
"body_md": "…", "status": "published", "tags": [],
"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": "…"
},
"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",
"source_item_id": "可选的来源事项 id"}
- 不带
source_item_id→source_kind=manual(手记),允许没有来源。 - 带 → 来源必须是同工作区的
task/ticket,否则7005。 - 新知识一律
draft,不会直接对外可见。
5.4 PATCH /knowledge/{item_id} — 修改内容
必须带 version(乐观锁,冲突 4009)。可改 title/body_md/summary/doc_type。
已发布的不许直接改(7003,先撤回审议或归档),避免"发布后被悄悄改写"。
5.5 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.6 POST /items/{item_id}/knowledge — 把一件事沉淀成知识
Query:doc_type(默认 postmortem)。来源可以是任务或工单;工单会带出验收结论。
返回与 5.2 相同的知识对象;同一来源重复沉淀 → 7002。
工单验收通过时,后端会自动调用这条逻辑(generated_by=rule,doc_type=postmortem,
正文开头固定一句"这份草稿由系统根据来源自动整理,请先核对再发布"),所以用户不需要记得点。
5.7 知识的错误码
| 码 | HTTP | 含义 |
|---|---|---|
| 7001 | 404 | 知识不存在,或不是知识 |
| 7002 | 409 | 这个来源已经有知识了 |
| 7003 | 409 | 当前状态不允许这个动作 |
| 7004 | 403 | 权限不足(如非管理员发布) |
| 7005 | 409 | 来源事项无效或缺失 |
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: [...]} |
| GET | /workspaces/{ws_id}/attachments/{id}/download |
下载(Content-Disposition: attachment) |
| GET | /workspaces/{ws_id}/attachments/{id}/raw?token=<access> |
直链,给 <img src> 用 |
| DELETE | /workspaces/{ws_id}/attachments/{id}?confirm=true |
上传者或管理员可删;不传 confirm 报 8004 |
附件对象的字段:id / item_id / filename / content_type / size / sha256 / uploader_id / created_at / can_delete。
事项与知识的列表接口会附带 attachment_count,详情接口附带 attachments 全量。
6.2 为什么图片直链要带 token
<img> 发不出 Authorization 头。两种解法:把图片读成 base64 内联(正文会膨胀几十倍,且破坏检索),
或者给一个生命周期短的签名地址。
当前实现取的是"query 里带 access token",并且只允许本工作区成员访问(非成员一律 8001,
不区分"不存在"与"没权限",避免探测他人附件)。这是本地/内网阶段的折中,上生产前应换成
带过期时间的一次性签名 URL,见 ROADMAP 的 M7 安全清单。
6.3 附件错误码
| 码 | HTTP | 含义 |
|---|---|---|
| 8001 | 404 | 附件不存在,或不属于这个工作区 |
| 8002 | 413 | 超过 MAX_UPLOAD_MB(默认 20) |
| 8003 | 403 | 不是上传者也不是管理员 |
| 8004 | 409 | 需要 ?confirm=true(删除是破坏性动作) |
6.4 前端约定
- 编辑器写 Markdown,工具栏/快捷键
Ctrl+B、Ctrl+I只管插入语法;预览用renderMarkdown()渲染(先转义再上标签,链接协议白名单http(s)/mailto//)。 - 粘贴或拖入图片时:先上传 → 拿到
id→ 在光标处插入)。 - 上传接口自己带
Idempotency-Key,不要手动设Content-Type(浏览器要自己带 boundary)。
7. 健康与版本
| 方法 | 路径 | 说明 |
|---|---|---|
| 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。