- item/tag/item_tag/comment/activity 五表迁移,RLS 全覆盖,pg_trgm 子串索引
- 状态机 domain/task.py:非法流转回 5002 并给 allowed,done 写 completed_at
- /workspaces/{ws}/items 列表/详情/新建/改/状态/软删/评论/标签
- 乐观锁 UPDATE...WHERE version,冲突 4009 + current_version
- Web 任务页:视图 tab、快速新建、勾选完成、开工/阻塞、详情事件流、游标分页
- 测试 43 全绿,ruff 干净,Web 构建通过
- 文档:2.Design/API.md 新增,ROADMAP 按已验证口径重写
7.0 KiB
接口约定(M0–M1 已实现)
只写已经在跑的接口。写完没验证的东西不进这份文档。 前缀
/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 DELETE /items/{item_id}?version=3
软删(写 deleted_at),只在 view=deleted / include_deleted=true 时可见。
3.6 评论
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /items/{item_id}/comments |
时间正序,游标分页 |
| POST | /items/{item_id}/comments |
{body_md} |
3.7 标签 /workspaces/{ws_id}/tags
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /tags |
工作区内标签,{id,name,color} |
| POST | /tags |
{name, color?};同名直接返回已有的(幂等,不报错) |
4. 健康与版本
| 方法 | 路径 | 说明 |
|---|---|---|
| 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。