feat(rich): 备注/工单说明/知识正文支持 Markdown 与图片附件
写一件事的时候,光有纯文本不够:要贴截图、要分段、要标重点。 这次把「正文」这条路补全,思路是**存 Markdown 而不是 HTML**—— 能导出、能全文检索、能直接喂给 LLM,也不必每次渲染都重新清洗一遍 HTML。 后端 - attachment 表 + RLS + 迁移 0005;本地磁盘存储,key 由内容 sha256 决定, 换对象存储只改 services/storage.py 一个文件 - 上传在**读流时**卡 20MB,不先落盘再判断;磁盘名由服务端生成,用户文件名只进库和响应头 - 列表带 attachment_count、详情带 attachments,避免一屏 N 次请求 - raw 直链给 <img src> 用(图片带不了 Authorization 头),只允许本工作区成员, 非成员一律 8001 不区分「不存在」与「没权限」 - 5 个用例:上传下载、超限、跨租户、删除确认与权限、raw 鉴权 前端 - 自研零依赖 Markdown 渲染器:先转义再上标签,链接协议白名单 - RichTextEditor:工具栏 + Ctrl+B/I + 预览,粘贴或拖入图片自动上传并插入引用 - AttachmentPanel:上传/下载/删除,下载走带 Authorization 的 fetch 转 blob - 任务备注、工单说明与验收结论、知识正文、评论全部接入 顺手修的 - TaskDetail.hydrate 漏了 setAttachments,导致附件面板永远是空的(e2e 抓到的) 71 passed;web tsc 0 错、构建 255.07 kB │ gzip 80.85 kB; e2e:richtext 9 项 + smoke 7 + tickets 8 + knowledge 7,全绿。
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
"""附件:上传、列表、下载、删除、跨租户与配额。
|
||||
|
||||
用例守的是三条约束(见 app/api/attachments.py 文件头):
|
||||
1. 只认本工作区的条目,别人的 item_id 一律 5001;
|
||||
2. 超限在读流时卡住,且不留垃圾文件;
|
||||
3. 磁盘名服务端生成,用户文件名只进库与 Content-Disposition。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
from .conftest import create_workspace, login, unwrap
|
||||
|
||||
BASE = "/api/v1"
|
||||
|
||||
|
||||
async def _ws_with_item(client: AsyncClient, name: str = "附件工作区") -> tuple[Any, Any, str]:
|
||||
user = await login(client)
|
||||
ws = await create_workspace(client, user, name)
|
||||
item = unwrap(
|
||||
await client.post(
|
||||
f"{BASE}/workspaces/{ws['id']}/items",
|
||||
json={"title": "整理发票原件"},
|
||||
headers=user["headers"],
|
||||
)
|
||||
)
|
||||
return user, ws, item["id"]
|
||||
|
||||
|
||||
async def _upload(
|
||||
client: AsyncClient, user: dict[str, Any], ws_id: str, item_id: str, content: bytes, name: str
|
||||
) -> Any:
|
||||
return await client.post(
|
||||
f"{BASE}/workspaces/{ws_id}/items/{item_id}/attachments",
|
||||
files={"file": (name, content, "text/plain")},
|
||||
headers=user["headers"],
|
||||
)
|
||||
|
||||
|
||||
async def test_upload_list_and_download(client: AsyncClient) -> None:
|
||||
user, ws, item_id = await _ws_with_item(client)
|
||||
body = "发票号 044031900111,金额 1280.00".encode()
|
||||
|
||||
created = unwrap(await _upload(client, user, ws["id"], item_id, body, "发票.txt"))
|
||||
assert created["filename"] == "发票.txt"
|
||||
assert created["size"] == len(body)
|
||||
assert created["sha256"]
|
||||
assert created["can_delete"] is True
|
||||
|
||||
listed = unwrap(
|
||||
await client.get(
|
||||
f"{BASE}/workspaces/{ws['id']}/items/{item_id}/attachments", headers=user["headers"]
|
||||
)
|
||||
)
|
||||
assert [a["id"] for a in listed["items"]] == [created["id"]]
|
||||
assert listed["items"][0]["content_type"] == "text/plain"
|
||||
|
||||
# 计数要出现在任务列表与详情里,否则前端没法显示「3 个附件」
|
||||
items = unwrap(
|
||||
await client.get(f"{BASE}/workspaces/{ws['id']}/items?view=all", headers=user["headers"])
|
||||
)
|
||||
row = next(i for i in items["items"] if i["id"] == item_id)
|
||||
assert row["attachment_count"] == 1
|
||||
|
||||
detail = unwrap(
|
||||
await client.get(f"{BASE}/workspaces/{ws['id']}/items/{item_id}", headers=user["headers"])
|
||||
)
|
||||
assert detail["attachment_count"] == 1
|
||||
assert detail["attachments"][0]["id"] == created["id"]
|
||||
|
||||
# 下载要能原样取回,并给出可辨识的文件名
|
||||
resp = await client.get(
|
||||
f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/download",
|
||||
headers=user["headers"],
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == body
|
||||
assert "filename*=UTF-8''" in resp.headers["content-disposition"]
|
||||
assert resp.headers["x-content-type-options"] == "nosniff"
|
||||
|
||||
|
||||
async def test_reject_oversized(client: AsyncClient, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
user, ws, item_id = await _ws_with_item(client)
|
||||
monkeypatch.setattr(get_settings(), "max_upload_mb", 0)
|
||||
|
||||
body = (await _upload(client, user, ws["id"], item_id, b"x" * 4096, "big.txt")).json()
|
||||
assert body["code"] == 8002
|
||||
assert "上限" in body["message"]
|
||||
|
||||
|
||||
async def test_cannot_attach_to_other_tenant_item(client: AsyncClient) -> None:
|
||||
"""跨租户的 item_id 不能成为落点——否则等于往别人的时间线上塞文件。"""
|
||||
user, _ws, item_id = await _ws_with_item(client, "甲的组")
|
||||
other, other_ws, other_item = await _ws_with_item(client, "乙的组")
|
||||
|
||||
# 拿别人工作区的 id 上传:自己不是成员
|
||||
resp = (await _upload(client, user, other_ws["id"], other_item, b"x", "x.txt")).json()
|
||||
assert resp["code"] in (3003, 5001)
|
||||
|
||||
# 在自己的工作区里塞别人的 item_id:条目找不到,也不该暴露它存在
|
||||
resp2 = (await _upload(client, user, _ws["id"], other_item, b"x", "x.txt")).json()
|
||||
assert resp2["code"] == 5001
|
||||
|
||||
|
||||
async def test_delete_requires_confirm_and_ownership(client: AsyncClient) -> None:
|
||||
owner, ws, item_id = await _ws_with_item(client)
|
||||
created = unwrap(await _upload(client, owner, ws["id"], item_id, b"abc", "a.txt"))
|
||||
|
||||
# 不确认不给删:删除不可逆,前端容易误触
|
||||
resp = (
|
||||
await client.delete(
|
||||
f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}", headers=owner["headers"]
|
||||
)
|
||||
).json()
|
||||
assert resp["code"] == 8004
|
||||
assert resp["data"] == {"confirm": True}
|
||||
|
||||
# 同工作区但非上传者的普通成员也删不了
|
||||
outsider = await login(client)
|
||||
resp = (
|
||||
await client.delete(
|
||||
f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}?confirm=true",
|
||||
headers=outsider["headers"],
|
||||
)
|
||||
).json()
|
||||
assert resp["code"] in (3003, 8003)
|
||||
|
||||
gone = unwrap(
|
||||
await client.delete(
|
||||
f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}?confirm=true",
|
||||
headers=owner["headers"],
|
||||
)
|
||||
)
|
||||
assert gone["deleted"] is True
|
||||
|
||||
after = await client.get(
|
||||
f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/download",
|
||||
headers=owner["headers"],
|
||||
)
|
||||
assert after.json()["code"] == 8001
|
||||
|
||||
|
||||
async def test_raw_image_url_takes_token_in_query(client: AsyncClient) -> None:
|
||||
"""Markdown 里的 `` 给 <img> 用:头带不了令牌,只能走 query。"""
|
||||
user, ws, item_id = await _ws_with_item(client)
|
||||
body = b"\x89PNG\r\n\x1a\n fake png"
|
||||
created = unwrap(await _upload(client, user, ws["id"], item_id, body, "图.png"))
|
||||
|
||||
resp = await client.get(
|
||||
f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw?token={user['token']}"
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
assert resp.content == body
|
||||
assert resp.headers["content-disposition"] == "inline"
|
||||
assert resp.headers["cache-control"].startswith("private")
|
||||
|
||||
# 不带 token 直接 422(参数必填),带了别人的 token 一律当不存在
|
||||
missing = await client.get(f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw")
|
||||
assert missing.status_code == 422
|
||||
outsider = await login(client)
|
||||
leaked = await client.get(
|
||||
f"{BASE}/workspaces/{ws['id']}/attachments/{created['id']}/raw?token={outsider['token']}"
|
||||
)
|
||||
assert leaked.json()["code"] == 8001
|
||||
Reference in New Issue
Block a user