Files
DadaoTask/backend/app/api/attachments.py
T
dadao a24a493403 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,全绿。
2026-10-29 01:53:46 +08:00

293 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""附件接口:上传 / 列表 / 下载 / 删除。
三条约束(都会有用例守):
1. 只认「本工作区的条目」。拿着别的租户的 item_id 上传,返回 5001 而不是 403——
对外不该暴露那条记录是否存在。
2. 大小上限在**读流时**卡住,不能先落盘再判断(否则等于给了别人一个写满磁盘的入口)。
3. 磁盘上的名字由服务端生成,用户文件名只进库和 Content-Disposition。
"""
from __future__ import annotations
import urllib.parse
import uuid
from typing import Annotated
from fastapi import APIRouter, File, Query, UploadFile
from fastapi.responses import FileResponse
from sqlalchemy import select
from starlette.concurrency import run_in_threadpool
from app.audit import record as audit_record
from app.config import get_settings
from app.db import bind_tenant
from app.deps import MemberDep, SessionDep
from app.errors import AppError, ErrorCode
from app.models import Member
from app.models_attachment import Attachment
from app.models_task import Item
from app.schemas import ok
from app.security import decode_token
from app.services import storage
router = APIRouter(prefix="/workspaces/{ws_id}", tags=["attachments"])
ALLOWED_KINDS = ("task", "ticket", "knowledge")
def _attachment_out(row: Attachment, viewer_id=None, role=None) -> dict:
return {
"id": str(row.id),
"item_id": str(row.item_id),
"filename": row.filename,
"content_type": row.content_type,
"size": row.size_bytes,
"sha256": row.sha256,
"uploader_id": str(row.uploader_id),
"created_at": row.created_at.isoformat(),
"can_delete": row.uploader_id == viewer_id or role in ("owner", "admin"),
}
async def _load_item(session, ws_id, item_id) -> Item:
item = await session.scalar(
select(Item).where(
Item.id == item_id,
Item.workspace_id == ws_id,
Item.deleted_at.is_(None),
Item.kind.in_(ALLOWED_KINDS),
)
)
if item is None:
raise AppError(ErrorCode.ITEM_NOT_FOUND, "事项不存在")
return item
def _safe_filename(name: str | None, fallback: str = "未命名文件") -> str:
"""只用于展示与下载响应头:去掉路径分隔符与控制字符,长度截断。"""
raw = (name or "").strip().replace("\\", "/").split("/")[-1]
cleaned = "".join(ch for ch in raw if ch.isprintable() and ch not in '\r\n"')
cleaned = cleaned.strip() or fallback
if len(cleaned) > 200:
stem, dot, ext = cleaned.rpartition(".")
cleaned = (stem[:180] + dot + ext[-16:]) if dot and ext else cleaned[:200]
return cleaned
@router.post("/items/{item_id}/attachments", status_code=201)
async def upload_attachment(
ctx: MemberDep,
session: SessionDep,
item_id: str,
file: Annotated[UploadFile, File()],
):
item = await _load_item(session, ctx.ws_id, item_id)
limit = get_settings().max_upload_mb * 1024 * 1024
# 先看声明的大小(可选,客户端不一定给),真正的限额在读流时兜底
if file.size is not None and file.size > limit:
raise AppError(
ErrorCode.ATTACHMENT_TOO_LARGE,
f"文件超过 {get_settings().max_upload_mb}MB 上限",
)
try:
stored = await run_in_threadpool(
storage.save, file.file, workspace_id=ctx.ws_id, item_id=item.id
)
if stored.size > limit:
await run_in_threadpool(storage.remove, stored.key)
raise AppError(
ErrorCode.ATTACHMENT_TOO_LARGE,
f"文件超过 {get_settings().max_upload_mb}MB 上限",
)
row = Attachment(
workspace_id=ctx.ws_id,
item_id=item.id,
uploader_id=ctx.user.id,
filename=_safe_filename(file.filename),
content_type=(file.content_type or "application/octet-stream")[:120],
size_bytes=stored.size,
sha256=stored.sha256,
storage_key=stored.key,
)
session.add(row)
await session.flush()
await audit_record(
session,
workspace_id=ctx.ws_id,
actor_user_id=ctx.user.id,
action="attachment_uploaded",
target_type="item",
target_id=str(item.id),
payload={"attachment_id": str(row.id), "filename": row.filename},
)
await session.commit()
finally:
await file.close()
return ok(_attachment_out(row, ctx.user.id, ctx.role))
@router.get("/items/{item_id}/attachments")
async def list_attachments(ctx: MemberDep, session: SessionDep, item_id: str):
item = await _load_item(session, ctx.ws_id, item_id)
rows = (
await session.scalars(
select(Attachment)
.where(Attachment.workspace_id == ctx.ws_id, Attachment.item_id == item.id)
.order_by(Attachment.created_at.asc())
)
).all()
return ok({"items": [_attachment_out(row, ctx.user.id, ctx.role) for row in rows]})
@router.get("/attachments/{attachment_id}/download")
async def download_attachment(ctx: MemberDep, session: SessionDep, attachment_id: str):
row = await session.scalar(
select(Attachment).where(
Attachment.id == attachment_id, Attachment.workspace_id == ctx.ws_id
)
)
if row is None:
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在")
path = storage.path_of(row.storage_key)
if not path.exists():
# 库里有记录、盘上没文件:说明备份漏了 blob,明确报错而不是返回空文件
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件文件已丢失,请联系管理员")
return FileResponse(
path,
media_type=row.content_type,
headers={
# 中文文件名必须按 RFC 5987 编码,否则浏览器拿到的是乱码
"Content-Disposition": "attachment; filename*=UTF-8''"
+ urllib.parse.quote(row.filename),
# 附件是不可信内容:禁止浏览器嗅探类型,否则 .txt 里的 HTML 会被执行
"X-Content-Type-Options": "nosniff",
},
)
@router.get("/attachments/{attachment_id}/raw")
async def raw_attachment(
ws_id: uuid.UUID,
session: SessionDep,
attachment_id: uuid.UUID,
token: Annotated[str, Query(description="access token(<img src> 带不了请求头)")],
):
"""给 Markdown 里的 `![](…/raw?token=…)` 用:浏览器 <img> 无法附加 Authorization 头。
token 走 query 是 Web 上的通行做法,但要接受它的代价:会进访问日志与浏览器历史。
所以这个接口**只读、只给本工作区成员**,且不返回 Content-Disposition(不当下载用)。
"""
try:
user_id = decode_token(token, "access")
except Exception: # noqa: BLE001 —— 任何解码失败都按未认证处理
raise AppError(ErrorCode.UNAUTHORIZED, "登录已过期,请重新登录") from None
# 这个接口没有走 MemberDep(令牌在 query 里),RLS 不会自动绑租户,得手动绑一次
await bind_tenant(session, ws_id=ws_id, user_id=user_id)
member = await session.scalar(
select(Member.id).where(
Member.workspace_id == ws_id,
Member.user_id == user_id,
Member.status == "active",
)
)
if member is None:
# 不是成员就当作不存在:不暴露"这里有个附件但你看不到"
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在")
row = await session.scalar(
select(Attachment).where(Attachment.id == attachment_id, Attachment.workspace_id == ws_id)
)
if row is None:
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在")
path = storage.path_of(row.storage_key)
if not path.exists():
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件文件已丢失,请联系管理员")
return FileResponse(
path,
media_type=row.content_type,
headers={
"Content-Disposition": "inline",
"X-Content-Type-Options": "nosniff",
# 附件内容不变(key 由内容哈希决定),可以放心长缓存;换了文件就是换了 id
"Cache-Control": "private, max-age=86400",
},
)
@router.delete("/attachments/{attachment_id}")
async def delete_attachment(
ctx: MemberDep, session: SessionDep, attachment_id: str, confirm: bool = Query(False)
):
row = await session.scalar(
select(Attachment).where(
Attachment.id == attachment_id, Attachment.workspace_id == ctx.ws_id
)
)
if row is None:
raise AppError(ErrorCode.ATTACHMENT_NOT_FOUND, "附件不存在")
is_uploader = row.uploader_id == ctx.user.id
if not is_uploader and ctx.role not in ("owner", "admin"):
raise AppError(ErrorCode.ATTACHMENT_FORBIDDEN, "只有上传者或管理员能删除附件")
if not confirm:
# 删除不可逆,要求显式确认,避免误点
raise AppError(
ErrorCode.CONFIRM_REQUIRED, "删除附件需要二次确认", details={"confirm": True}
)
await run_in_threadpool(storage.remove, row.storage_key)
await session.delete(row)
await audit_record(
session,
workspace_id=ctx.ws_id,
actor_user_id=ctx.user.id,
action="attachment_deleted",
target_type="item",
target_id=str(row.item_id),
payload={"attachment_id": str(row.id), "filename": row.filename},
)
await session.commit()
return ok({"deleted": True})
async def list_for_item(session, ws_id, item_id) -> list[dict]:
"""给其他接口内联用(列表/详情里带上附件摘要)。"""
rows = (
await session.scalars(
select(Attachment)
.where(Attachment.workspace_id == ws_id, Attachment.item_id == item_id)
.order_by(Attachment.created_at.asc())
)
).all()
return [_attachment_out(row) for row in rows]
async def count_for_items(session, ws_id, item_ids: list) -> dict[str, int]:
"""列表页只显示"几个附件",避免每条都拉全量。"""
if not item_ids:
return {}
rows = (
await session.execute(
select(Attachment.item_id).where(
Attachment.workspace_id == ws_id, Attachment.item_id.in_(item_ids)
)
)
).all()
counts: dict[str, int] = {}
for (item_id,) in rows:
counts[str(item_id)] = counts.get(str(item_id), 0) + 1
return counts
__all__ = ["router", "list_for_item", "count_for_items"]