release(main): 同步 dev 最新候选改动
Storage Persistence Guard / storage-persistence-audit (push) Has been cancelled
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled
Client Quality Gates / Shared client and Web (pull_request) Has been cancelled
Client Quality Gates / macOS Desktop (pull_request) Has been cancelled
Storage Persistence Guard / storage-persistence-audit (pull_request) Has been cancelled

This commit is contained in:
Cheng Zhou
2026-07-16 17:15:50 +08:00
parent 32167fba02
commit d5279b124f
393 changed files with 51630 additions and 9711 deletions
+51 -6
View File
@@ -6,11 +6,12 @@ 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
from fastapi.responses import FileResponse
from sqlalchemy import and_, delete as sa_delete, or_, select, update as sa_update
from sqlalchemy import String, and_, cast, delete as sa_delete, or_, select, update as sa_update
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.deps import get_cra_site_scope
@@ -31,6 +32,7 @@ from app.models.distribution import Distribution, DistributionStatus, Distributi
from app.models.document import Document, DocumentScopeType, DocumentStatus
from app.models.document_version import DocumentVersion, DocumentVersionStatus
from app.models.desktop_notification import DesktopNotificationDelivery
from app.models.notification import Notification
from app.models.study import Study
from app.schemas.acknowledgement import AcknowledgementCreate
from app.schemas.distribution import DistributionCreate, DistributionRead, DistributionStats
@@ -38,6 +40,7 @@ from app.schemas.document import DocumentCreate, DocumentDetail, DocumentSummary
from app.schemas.document_version import DocumentVersionRead, DocumentVersionSummary
from app.schemas.notification import NotificationItem
from app.schemas.user import UserDisplay
from app.services import notification_service
UPLOAD_ROOT = Path(__file__).resolve().parent.parent / "uploads" / "documents"
@@ -52,6 +55,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 +382,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 +404,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 +580,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)},
)
@@ -663,6 +690,10 @@ async def create_acknowledgement(
if distribution.target_type == DistributionTargetType.ROLE:
if distribution.target_id not in ("ADMIN" if is_system_admin(current_user) else "", getattr(membership, "role_in_study", "")):
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发范围内")
if distribution.target_type == DistributionTargetType.SITE and not is_system_admin(current_user):
site_ids = await site_crud.list_ids_by_contact_user(db, doc.trial_id, current_user.id)
if distribution.target_id not in {str(site_id) for site_id in site_ids}:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="不在分发中心范围内")
if payload.ack_type != AcknowledgementType.RECEIVED:
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="仅支持已接收回执")
@@ -697,6 +728,12 @@ async def create_acknowledgement(
operator_role=await get_operator_role_label(db, doc.trial_id, current_user),
)
)
await notification_service.resolve_source_notifications(
db,
source_type="DOCUMENT_DISTRIBUTION",
source_id=str(distribution.id),
recipient_id=current_user.id,
)
await db.commit()
await db.refresh(ack)
return ack
@@ -787,10 +824,18 @@ async def list_distribution_notifications(
.join(DocumentVersion, Distribution.version_id == DocumentVersion.id)
.join(Document, Distribution.document_id == Document.id)
.join(Study, Document.trial_id == Study.id)
.outerjoin(
Notification,
and_(
Notification.source_type == "DOCUMENT_DISTRIBUTION",
Notification.source_id == cast(Distribution.id, String),
Notification.recipient_id == current_user.id,
),
)
.outerjoin(
DesktopNotificationDelivery,
and_(
DesktopNotificationDelivery.distribution_id == Distribution.id,
DesktopNotificationDelivery.notification_id == Notification.id,
DesktopNotificationDelivery.user_id == current_user.id,
),
)