feat(rich): 安卓能编辑富文本了,图片直链换成短期签名,表格不再撑破抽屉
CI / backend (push) Canceled after 0s

三件欠账一次清:

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 片段按文本显示。
This commit is contained in:
dadao
2026-10-29 03:32:53 +08:00
parent fbdf54cb4c
commit 17be9d1d42
24 changed files with 923 additions and 99 deletions
+20 -11
View File
@@ -396,31 +396,40 @@ Query:`doc_type`(默认 `postmortem`)。来源可以是任务或工单;
正文类字段(任务 `body_md`、工单说明、知识正文、评论、验收结论)**一律存 Markdown**,不存 HTML。
理由:能导出、能全文检索、能直接喂给 LLM;HTML 每次渲染都要重新清洗一遍,是隐患。
图片不是 base64 塞进正文,而是走附件接口拿一个稳定地址:
图片不是 base64 塞进正文,而是走附件接口拿地址。**库里存的是不带凭据的相对路径**:
```markdown
![票据截图.png](/api/v1/workspaces/{ws_id}/attachments/{attachment_id}/raw?token=...)
![票据截图.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: [...]}` |
| 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?token=<access>` | 直链,给 `<img src>` 用 |
| 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 / can_delete`。
附件对象的字段:`id / item_id / filename / content_type / size / sha256 / uploader_id / created_at / raw_url / can_delete`。
`raw_url` 是**后端签发的短期直链**(默认 30 分钟),客户端直接用它渲染,不需要自己拼令牌。
事项与知识的列表接口会附带 `attachment_count`,详情接口附带 `attachments` 全量。
### 6.2 为什么图片直链要带 token
### 6.2 图片直链:短期签名,而不是把令牌写进正文
`<img>` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文会膨胀几十倍,且破坏检索),
或者给一个生命周期短的签名地址。
`<img>` 发不出 `Authorization` 头。两种解法:把图片读成 base64 内联(正文膨胀几十倍,破坏检索),
或者给一个短期签名地址。取后者:
当前实现取的是"query 里带 access token",**并且只允许本工作区成员访问**(非成员一律 `8001`,
不区分"不存在"与"没权限",避免探测他人附件)。这是本地/内网阶段的折中,上生产前应换成
**带过期时间的一次性签名 URL**,见 `ROADMAP` 的 M7 安全清单。
- `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 附件错误码
+10 -7
View File
@@ -360,7 +360,8 @@
- [x] **编辑器不自研**(最初那版 textarea + 正则工具栏已删除):改用 [Vditor](https://github.com/Vanessa219/vditor) 4.0.1(MIT,中文优先,`ir` 模式所见即所得),落盘仍是 Markdown
- [x] 选型依据:Vditor 11.4k★ / 依赖仅 `diff-match-patch` / 中文界面与文档齐全;TipTap 是 headless 且存 JSON 要另做序列化;`@toast-ui/react-editor` 停更在 2023 且 peer 依赖锁 React 17
- [x] 运行时资源自托管(`web/scripts/vendor-vditor.mjs` → `public/vditor`,由 `predev`/`prebuild` 自动生成),**不走 unpkg CDN**——内网/离线环境下编辑器会一直卡在加载
- [ ] 表格与 HTML 片段:暂不支持(Vditor 支持表格,只是我们的只读渲染器还没开)
- [x] 表格:Vditor 里能插能编;只读渲染(评论、动态、预览)走 `markdown-it` 的 GFM 表,`display:block + overflow-x:auto` 让宽表在抽屉里横向滚动、不撑破布局(e2e 断言表格宽度 ≤ 900px,实测 561px)
- [ ] HTML 片段:仍按文本显示(不开放 raw HTML 是有意的,避免把消毒责任交给调用方)
### 5.2 附件
@@ -368,8 +369,8 @@
- [x] 本地磁盘存储(`backend/app/services/storage.py`),key 由内容 sha256 决定;换对象存储只改这一个文件
- [x] 上限 `MAX_UPLOAD_MB=20`,声明大小与流式读取双重拦截,超限 `8002`
- [x] 列表带 `attachment_count`,详情带 `attachments`,避免一屏 N 次请求
- [x] 下载走带 `Authorization` 的 fetch 转 blob;`<img>` 直链走 `raw?token=`,只允许本工作区成员
- [ ] 图片直链改一次性签名 URL(当前 token 放 query,上生产前必须换)
- [x] 下载走带 `Authorization` 的 fetch 转 blob;`<img>` 直链走**短期签名** `raw?exp=&sig=`(HMAC 密钥由 `JWT_SECRET` 域分离派生,默认 30 分钟,跨工作区拒绝)
- [x] 图片直链改成签名 URL 后:正文里只存不带凭据的相对路径 `/raw`,凭据每次渲染现签,不再把会过期的令牌写进库
- [ ] 缩略图、图片压缩、病毒扫描:未做
### 5.3 编辑器
@@ -377,10 +378,12 @@
- [x] 粘贴与拖拽上传图片:`upload.handler` 里逐张落到附件接口,再 `insertMD` 插入 `![](attachmentUrl(...))`。**Vditor 不会替我们插入 handler 的返回值**(它只把字符串当提示语 `tip.show`),这点踩过坑
- [x] 令牌只在浏览器内存里存在:落库前 `stripAttachmentTokens`,渲染/装载前补回;`<img>` 带不了 Authorization 头,所以 `raw?token=` 是必要折中
- [x] `AttachmentPanel`:上传/下载/删除(删除二次确认,对应后端 `?confirm=true`)
- [x] 安卓:本轮未做富文本编辑,只做只读渲染 + 附件下载(M5 再议)
- [x] 安卓:`RichTextEditorScreen`(Kotlin 手写 Markdown 工具栏:B/I/标题/列表/引用/代码/链接/图片,选区包裹与行首前缀;预览用 **Markwon** 渲染,图片用 **Coil** 加载签名直链),从任务详情与知识点打开,保存按 `body_md` 回写
- [ ] 安卓编辑器保存后回到任务列表而不是原详情抽屉(体验缺口:用户要重新点开那条任务)
### 5.4 验证
- [x] 后端 `pytest` → **70 passed**(新增附件 5 个用例:上传下载、超限、跨租户、删除确认与权限、raw 直链鉴权)
- [x] `web/e2e/richtext.mjs` → **13 项全绿**(Vditor 与 lute 就绪、`**粗体**`/`` `代码` ``/`- 列表` 就地渲染、落库正文不含 token、贴图后 `/raw` 返回 200、附件重开仍在、控制台无报错)
- [x] 后端 `pytest` → **74 passed**(附件 8 个用例:上传下载、超限、跨租户、删除确认与权限、raw 签名校验、签名篡改与过期、跨工作区签名)
- [x] `web/e2e/richtext.mjs` → **15 项全绿**(Vditor 与 lute 就绪、`**粗体**`/`` `代码` ``/`- 列表` 就地渲染、落库正文不含凭据、贴图后 `/raw` 带签名且返回 200、附件重开仍在、表格渲染且不撑破抽屉、控制台无报错)
- [x] 既有三套 e2e 回归通过(smoke 7 / tickets 8 / knowledge 7)
- [x] `tsc -b` 0 错;`vite build` 主包 299.42 kB │ gzip 74.96 kB,Vditor 独立 chunk 384.73 kB │ gzip 135.82 kB(按需加载,只进详情抽屉)
- [x] `tsc -b` 0 错;`vite build` 主包 299.42 kB │ gzip 74.96 kB,Vditor 独立 chunk 385.24 kB │ gzip 136.05 kB(按需加载,只进详情抽屉)
- [x] 安卓 `assembleDebug` 成功(APK 10.1 MB),模拟器上走通「详情 → 编辑正文 → 预览 → 保存」,截图 `2.Design/screens/android-editor{,-preview}.png`