功能(文档与桌面):完善文件预览下载与客户端构建基线
- 保存文档版本原始文件名,规范下载响应并持久化上传目录\n- 增加 PDF.js 预览、桌面保存打开流程及统一错误反馈\n- 统一 Node.js 22.13 构建基线并收紧临时文件权限门禁\n- 补充迁移、单元测试、发布检查与运维文档
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
"""Store original filenames for document versions.
|
||||
|
||||
Revision ID: 20260713_02
|
||||
Revises: 20260713_01
|
||||
"""
|
||||
|
||||
import sqlalchemy as sa
|
||||
from alembic import op
|
||||
|
||||
|
||||
revision = "20260713_02"
|
||||
down_revision = "20260713_01"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
op.add_column(
|
||||
"document_versions",
|
||||
sa.Column("original_filename", sa.String(length=255), nullable=True),
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
op.drop_column("document_versions", "original_filename")
|
||||
@@ -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)},
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
from types import SimpleNamespace
|
||||
|
||||
from app.services.document_service import _content_disposition, _legacy_download_filename, _safe_original_filename
|
||||
|
||||
|
||||
def test_safe_original_filename_strips_paths_and_control_characters():
|
||||
assert _safe_original_filename(r"C:\fakepath\研究方案.xlsx") == "研究方案.xlsx"
|
||||
assert _safe_original_filename("../研究\n方案.xlsx") == "研究方案.xlsx"
|
||||
|
||||
|
||||
def test_content_disposition_preserves_unicode_filename():
|
||||
header = _content_disposition("研究方案 V1.0.xlsx")
|
||||
|
||||
assert header.startswith('attachment; filename="')
|
||||
assert "filename*=UTF-8''%E7%A0%94%E7%A9%B6%E6%96%B9%E6%A1%88%20V1.0.xlsx" in header
|
||||
|
||||
|
||||
def test_legacy_download_filename_uses_document_title_instead_of_storage_uuid():
|
||||
version = SimpleNamespace(file_uri="/uploads/fa6ab7ab-f4f5-4d4a-a052-693f780010fd.pdf")
|
||||
|
||||
assert _legacy_download_filename(version, SimpleNamespace(title="研究方案")) == "研究方案.pdf"
|
||||
assert _legacy_download_filename(version, SimpleNamespace(title="研究方案.PDF")) == "研究方案.PDF"
|
||||
Reference in New Issue
Block a user