Files
DadaoTask/2.Design/API.md
T
dadao 17be9d1d42
CI / backend (push) Canceled after 0s
feat(rich): 安卓能编辑富文本了,图片直链换成短期签名,表格不再撑破抽屉
三件欠账一次清:

1. 图片直链签名化
   - 新增 `backend/app/services/signing.py`:HMAC-SHA256,密钥由 `JWT_SECRET` 域分离派生
     (`attachment-raw-v1`),载荷 `workspace_id:attachment_id:exp`,默认 30 分钟
   - `/raw` 支持 `?exp=&sig=`,不需要登录态;签名里绑了工作区,跨租户链接直接拒
   - 附件对象多一个 `raw_url`,前端每次渲染现签,正文里只存不带凭据的 `/raw`
   - 保留旧 `?token=` 通道,给未升级的客户端过渡

2. 安卓富文本编辑器
   - `ui/RichTextEditorScreen.kt`:Markdown 工具栏(粗体/斜体/标题/列表/引用/代码/链接/图片),
     选区包裹与行首前缀都按光标位置来;贴图走附件接口,插入签名直链
   - 预览用 Markwon,图片用 Coil;正文落在一张白纸卡片里,不再贴屏幕边
   - 从任务详情与知识点都能进,保存按 `body_md` 回写(PATCH 带 version)

3. 表格
   - 只读渲染走 markdown-it 的 GFM 表,`display:block + overflow-x:auto`:
     宽表横向滚动、窄表铺满,都不撑破抽屉

验证:pytest 74 passed;richtext 15 / smoke 7 / tickets 8 / knowledge 7 全绿;
tsc -b 0 错;vite build 通过(主包 299.42 kB / gzip 74.96 kB);
安卓 assembleDebug 成功并在 API 35 模拟器走通编辑与预览。

仍欠:保存后回列表而非回详情抽屉、图片压缩与缩略图、HTML 片段按文本显示。
2026-10-29 03:32:53 +08:00

19 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 账号被禁用 联系管理员
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_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.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: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 塞进正文,而是走附件接口拿地址。库里存的是不带凭据的相对路径:

![票据截图.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 分钟),客户端直接用它渲染,不需要自己拼令牌。 事项与知识的列表接口会附带 attachment_count,详情接口附带 attachments 全量。

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 附件错误码

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)。

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。