功能(文档与桌面):完善文件预览下载与客户端构建基线
- 保存文档版本原始文件名,规范下载响应并持久化上传目录\n- 增加 PDF.js 预览、桌面保存打开流程及统一错误反馈\n- 统一 Node.js 22.13 构建基线并收紧临时文件权限门禁\n- 补充迁移、单元测试、发布检查与运维文档
This commit is contained in:
@@ -47,6 +47,7 @@ class DocumentVersion(Base):
|
||||
effective_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
superseded_at: Mapped[Optional[datetime]] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
file_uri: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
original_filename: Mapped[Optional[str]] = mapped_column(String(255), nullable=True)
|
||||
file_hash: Mapped[str] = mapped_column(String(128), nullable=False)
|
||||
file_size: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
mime_type: Mapped[Optional[str]] = mapped_column(String(100), nullable=True)
|
||||
|
||||
@@ -36,6 +36,7 @@ class DocumentVersionRead(BaseModel):
|
||||
effective_at: Optional[datetime] = None
|
||||
superseded_at: Optional[datetime] = None
|
||||
file_uri: str
|
||||
original_filename: Optional[str] = None
|
||||
file_hash: str
|
||||
file_size: int
|
||||
mime_type: Optional[str] = None
|
||||
|
||||
@@ -6,6 +6,7 @@ import uuid
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
from urllib.parse import quote
|
||||
|
||||
import aiofiles
|
||||
from fastapi import HTTPException, UploadFile, status
|
||||
@@ -52,6 +53,29 @@ DOCUMENT_ACTION_PERMISSIONS = {
|
||||
}
|
||||
|
||||
|
||||
def _safe_original_filename(value: str | None) -> str:
|
||||
filename = (value or "").replace("\\", "/").rsplit("/", 1)[-1].strip()
|
||||
filename = "".join(character for character in filename if ord(character) >= 32 and ord(character) != 127)
|
||||
return filename[:255] or "document"
|
||||
|
||||
|
||||
def _content_disposition(filename: str, disposition: str = "attachment") -> str:
|
||||
fallback = "".join(character if 32 <= ord(character) < 127 and character not in {'"', "\\"} else "_" for character in filename)
|
||||
fallback = fallback or "download"
|
||||
encoded = quote(filename, safe="")
|
||||
return f'{disposition}; filename="{fallback}"; filename*=UTF-8\'\'{encoded}'
|
||||
|
||||
|
||||
def _legacy_download_filename(version: DocumentVersion, document: Document) -> str:
|
||||
"""Return a readable fallback for rows created before original_filename existed."""
|
||||
stored_name = Path(version.file_uri).name
|
||||
suffix = Path(stored_name).suffix
|
||||
title = _safe_original_filename(document.title)
|
||||
if suffix and title.lower().endswith(suffix.lower()):
|
||||
return title
|
||||
return f"{title}{suffix}"
|
||||
|
||||
|
||||
def _audit_detail(before: dict | None, after: dict | None) -> str:
|
||||
payload = {"before": before, "after": after}
|
||||
return json.dumps(payload, ensure_ascii=True)
|
||||
@@ -356,7 +380,8 @@ async def create_version(
|
||||
file_hash = hashlib.sha256(content).hexdigest()
|
||||
dest_dir = UPLOAD_ROOT / str(document_id)
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
unique_name = f"{uuid.uuid4()}{Path(file.filename).suffix}"
|
||||
original_filename = _safe_original_filename(file.filename)
|
||||
unique_name = f"{uuid.uuid4()}{Path(original_filename).suffix}"
|
||||
dest_path = dest_dir / unique_name
|
||||
async with aiofiles.open(dest_path, "wb") as out_file:
|
||||
await out_file.write(content)
|
||||
@@ -377,6 +402,7 @@ async def create_version(
|
||||
status=DocumentVersionStatus.EFFECTIVE,
|
||||
effective_at=effective_at,
|
||||
file_uri=str(dest_path),
|
||||
original_filename=original_filename,
|
||||
file_hash=file_hash,
|
||||
file_size=len(content),
|
||||
mime_type=file.content_type,
|
||||
@@ -552,12 +578,11 @@ async def get_version_download_response(
|
||||
file_path = Path(version.file_uri)
|
||||
if not file_path.exists():
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="文件不存在")
|
||||
filename = file_path.name
|
||||
filename = version.original_filename or _legacy_download_filename(version, doc)
|
||||
return FileResponse(
|
||||
path=str(file_path),
|
||||
filename=filename,
|
||||
media_type=version.mime_type or "application/octet-stream",
|
||||
headers={"Content-Disposition": f'inline; filename="{filename}"'},
|
||||
headers={"Content-Disposition": _content_disposition(filename)},
|
||||
)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user