知识是备忘录,备忘录不能只有字:手册、日志、导出表格都得能挂上去。
附件本来就落在 item 上(`ALLOWED_KINDS` 早含 knowledge),缺的是"新建时
还没有条目 id"这一段。
- 懒建草稿:`KnowledgeComposer` 第一次传附件(或往正文贴图)时先 `POST /knowledge`
存一条草稿拿到 id,`draftingRef` 去重防止贴图与上传同时打出两条草稿;
之后的保存走 `PATCH /knowledge/{id}`。没传过附件就取消,库里不留空条目。
- `RichTextEditor` 增 `ensureItemId`:没有 itemId 时先把草稿存下来再传图,
于是"新建时不能贴图"这条限制也没了;工具栏上传按钮按是否有落点显隐。
- 新建面板加了附件区:有草稿 id 就是正常的 `AttachmentPanel`(上传/下载/删),
没有就先给一个「添加附件」按钮,点一下建草稿。
- 安卓知识详情加附件区:列表(文件名 + 大小)、`OpenDocument` 上传任意文件、
点「打开」用浏览器看签名直链。编辑器原本只能插图,现在手册/日志也能传。
- 文档:API.md 说明附件三类事项共用同一套接口,以及懒建草稿的先后顺序;
ROADMAP 加 3.13。
验证:后端 pytest 94 passed、ruff 全绿;Web `tsc --noEmit` 通过、`vite build` 通过;
`knowledge-notebook.mjs` 15 项全绿(新增「新建面板里能上传附件」「详情抽屉里能看到
刚传的附件」「附件挂在知识上,详情回读得到」)。安卓端仍是 JDK 25 + Gradle 8.7
不兼容,本次未编译验证。
25 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.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 |
与任务列表一致 |
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 一共七种,都是"某人在某件事上做了什么":
| kind | 什么时候发 | 发给谁 |
|---|---|---|
task_assigned |
建任务/改任务时指派了人 | 被指派人 |
task_status_changed |
任务状态变化 | 创建人 + 办理人(除操作者本人) |
item_commented |
有人评论 | 创建人 + 办理人(除评论者本人) |
ticket_assigned |
升级成工单 / 手工开单 / 改派 | 办理人 |
ticket_submitted |
办理人提交验收 | 需求人 |
ticket_approved / ticket_rejected |
验收通过 / 退回 | 办理人 |
task_unblocked |
工单验收通过、原任务回流待办 | 原任务创建人 |
三条硬规则:
- 不通知自己:谁操作就跳过谁,否则每个人都会收到自己刚做的事。
- 通知里必须写清"谁 + 做了什么 + 在哪件事上",否则点开等于重新看一遍列表。
- 通知只是提示,不是凭证——点开之后目标接口会重新校验权限、重新取数据。
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 表示移到未分类。
已发布的不许直接改(7003,先撤回审议或归档),避免"发布后被悄悄改写"。
5.5 知识分类 /knowledge/categories
分类是工作区级的三级小树,用来给知识安一个位置;它不是权限边界,只是归类。
| 方法 | 路径 | 说明 |
|---|---|---|
| GET | /knowledge/categories |
扁平列表(按 depth/sort_order 排好),每条带 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,删后知识变未分类 |
三条规则:
- 最多三级。第四级不是"更细",是"找不到",创建/移动越界一律
7007。 - 同一层不能重名(不区分大小写),否则
7008;不同层可以同名。 - 移动带着子树走:把一棵三层树挪到第二层下面会让最深节点到第五层,这种移动直接拒绝(
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 词法分,便于排查"为什么没搜到" |
三条约定:
- 只检索
published。草稿是还没被人认可的话,进检索等于把猜测当结论引用出去——过滤写在 SQL 里,调用方改不了。 - 结果必须带来源,能点回原文。这是 D3(知识必须有来源)的延伸:不给"出处不存在"的答案。
- 宁可返回空,也不凑数。检索失败比误导便宜。
发布(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 塞进正文,而是走附件接口拿地址。库里存的是不带凭据的相对路径:

| 方法 | 路径 | 说明 |
|---|---|---|
| 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 附件错误码
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。