# 接口约定(M0–M3 已实现) > 只写**已经在跑**的接口。写完没验证的东西不进这份文档。 > 前缀 `/api/v1`。所有响应都是同一个信封,**HTTP 200 不代表业务成功**。 ## 0. 通用 ### 0.1 信封 ```json { "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` 30 分钟,`refresh` 14 天。 `refresh_token` 只能用于 `/auth/refresh`(服务端校验 `typ`)。 ### 0.4 幂等 所有 `POST/PATCH/DELETE` 都可带 `Idempotency-Key: `。 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` ```json { "phone": "13800000000" } ``` 响应 `data: { dev_code?: "123456" }`。`ENV=dev` 时才回显验证码,生产没有这个字段。 60 秒内重复请求 → `2010`。 ### `POST /auth/login` ```json { "phone": "13800000000", "code": "123456" } ``` ```json { "user": { "id": "…", "phone": "138****0000", "nickname": "用户0000" }, "tokens": { "access_token": "…", "refresh_token": "…", "token_type": "bearer", "expires_in": 1800 } } ``` 手机号**首次登录即注册**(昵称取"用户" + 后四位)。手机号回显永远是打码的。 ### `POST /auth/refresh` ```json { "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` ```json { "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` ```json { "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`(看板多选拖拽) ```json { "status": "done", "items": [{ "id": "…", "version": 3 }, { "id": "…", "version": 7 }] } ``` ```json { "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`(看板一屏数据) ```json { "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`(手工开单) ```json { "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`(任务升级成工单) ```json { "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` ```json { "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`: ```json { "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` — 手记或指定来源 ```json {"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`,删后知识变未分类 | 三条规则: 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 塞进正文,而是走附件接口拿地址。**库里存的是不带凭据的相对路径**: ```markdown ![票据截图.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=` | 直链,给 `` 用(签名见 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 图片直链:短期签名,而不是把令牌写进正文 `` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文膨胀几十倍,破坏检索), 或者给一个短期签名地址。取后者: - `raw_url` 形如 `…/raw?exp=1730000000&sig=`,签名载荷是 `workspace_id:attachment_id:exp`, 密钥从 `JWT_SECRET` 域分离派生(`attachment-raw-v1`),不与登录令牌共用。 - 校验只看签名与过期时间,**不需要用户已登录**;签名里绑了 `workspace_id`,A 工作区签的链接 打到 B 工作区会被拒(`8003`)。 - 过期不宽限,刷新页面即可拿到新链接。 - 兼容旧路径:不带 `sig` 时仍可用 `?token=` 走成员校验,供老客户端过渡。 为什么非要这样:令牌写进正文会落库,几天后过期就是满篇红叉;用户换设备后旧令牌失效,图片全挂。 把凭据放在"每次渲染时现签"的 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`。