Files
DadaoTask/2.Design/API.md
T
Steven 7668799040
CI / backend (push) Canceled after 0s
feat(app): 安卓应用内升级,安装包由后端发布
需求是"改完 Android 端怎么送到手机上"。不发应用商店,也不想每次手动传包,
于是做了最小闭环:后端存一份安装包清单,App 启动时查、下载、交给系统安装器。

后端
- 新增 /app/release/latest 与 /app/release/download/{file},都免登录:
  还没装上新包的客户端未必带着有效令牌,检查更新不该被登录挡住
- download_url 用相对路径,客户端拿自己配置的 API 地址拼——同一份清单要在
  内网、公网、模拟器之间共用,写死域名只会让其中一个坏掉
- has_update 只比对 version_code,不比版本名字符串(0.10.0 与 0.9.0 的字典序是坑)
- 下载只认清单里登记的文件名,含 ..%2F 一律 9001
- scripts/publish_apk.py 负责复制 APK 并写 latest.json(sha256/size/published_at),
  backend/static/apk/ 不进版本库

安卓
- AppUpdater:查清单 → 下到应用自己的 updates 目录(先写 .part 再改名)→
  校验 sha256 → 经 FileProvider 交给系统安装器
- UpdateDialog:版本号、更新说明、下载进度、失败重试;「允许安装未知应用」
  是系统开关,只跳设置页,不代点
- 启动时自动查一次,首页底部另有「检查更新」入口
- 图标换成和 Web 的 <Logo/> 同一套图形:墨绿圆角底、三条米白圆头线、陶土点;
  同时出 mipmap 各密度 PNG、自适应图标与 512px 预览
- versionCode=2 / versionName=0.2.0;发布签名从 android/keystore.properties 读,
  没有该文件时回落调试签名,保证别人 clone 下来也能 assembleRelease

文档
- API.md 第 7 节:两个接口、清单字段、签名与 sha256 的约定、9001
- DEPLOY.md 第 5 节:出包命令(JDK 17 + Gradle 8.9 + 干净的 GRADLE_USER_HOME)、
  发布清单、传到服务器、验证
- ROADMAP 第 6 节记录本次范围与本机踩过的构建环境坑

验证
- 后端 pytest 103 passed;ruff check / format --check 全绿
- assembleRelease 成功,app-release.apk 7.4 MB,apksigner 验签显示 CN=Dadao Task
- 本地与生产 GET /app/release/latest?version_code=1 → has_update=true,
  version_code=2 → false;下载返回 200 / application/vnd.android.package-archive
2026-10-31 03:15:00 +08:00

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

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。