功能(文档预览):集成 ONLYOFFICE 安全只读预览与工作台体验
Client Quality Gates / Shared client and Web (push) Has been cancelled
Client Quality Gates / macOS Desktop (push) Has been cancelled

新增 ONLYOFFICE 配置签名、内部内容接口、容器编排与反向代理。

打通网页端和桌面端独立预览工作区,完善文档入口、布局及帮助体验。

补充桌面安全发布门禁、开发脚本、使用文档和前后端测试。
This commit is contained in:
Cheng Zhou
2026-07-14 14:19:17 +08:00
parent 44db5db838
commit c68dddfc01
50 changed files with 2429 additions and 677 deletions
+212
View File
@@ -0,0 +1,212 @@
from __future__ import annotations
import json
import os
import uuid
from pathlib import Path
from urllib.parse import quote
from fastapi import APIRouter, Depends, Request, Response, status
from fastapi.responses import FileResponse
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.v1.attachments import _ensure_attachment_permission, _ensure_study_exists
from app.core.deps import get_current_user, get_db_session, get_operator_role_label
from app.crud import attachment as attachment_crud
from app.crud import audit as audit_crud
from app.crud import document as document_crud
from app.crud import document_version as version_crud
from app.schemas.onlyoffice import OnlyOfficePreviewConfigRead
from app.services import document_service, onlyoffice_service
router = APIRouter()
internal_router = APIRouter(include_in_schema=False)
def _content_disposition(filename: str) -> str:
fallback = "".join(
character if 32 <= ord(character) < 127 and character not in {'"', "\\"} else "_"
for character in filename
) or "document"
encoded = quote(filename, safe="")
return f'inline; filename="{fallback}"; filename*=UTF-8\'\'{encoded}'
async def _log_preview_open(
db: AsyncSession,
*,
study_id: uuid.UUID,
resource_type: str,
resource_id: uuid.UUID,
current_user,
) -> None:
await audit_crud.log_action(
db,
study_id=study_id,
entity_type="ATTACHMENT" if resource_type == "attachment" else "DOCUMENT_VERSION",
entity_id=resource_id,
action="OFFICE_PREVIEW_OPEN",
detail=json.dumps(
{"resource_type": resource_type, "resource_id": str(resource_id), "result": "issued"},
ensure_ascii=True,
),
operator_id=current_user.id,
operator_role=await get_operator_role_label(db, study_id, current_user),
)
@router.get(
"/attachments/{attachment_id}/config",
response_model=OnlyOfficePreviewConfigRead,
)
async def get_attachment_preview_config(
attachment_id: uuid.UUID,
response: Response,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> OnlyOfficePreviewConfigRead:
attachment = await attachment_crud.get_attachment(db, attachment_id)
if not attachment:
raise onlyoffice_service.onlyoffice_error(
"ATTACHMENT_NOT_FOUND", "附件不存在", status.HTTP_404_NOT_FOUND
)
await _ensure_study_exists(db, attachment.study_id)
await _ensure_attachment_permission(
db,
attachment.study_id,
attachment.entity_type,
attachment.entity_id,
"read",
current_user,
)
if not os.path.exists(attachment.file_path):
raise onlyoffice_service.onlyoffice_error(
"ATTACHMENT_FILE_NOT_FOUND", "服务器未找到文件", status.HTTP_404_NOT_FOUND
)
if not onlyoffice_service.office_format_for_filename(attachment.filename):
raise onlyoffice_service.onlyoffice_error(
"ONLYOFFICE_FORMAT_UNSUPPORTED",
"该文件格式不支持 Office 在线预览",
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
)
await onlyoffice_service.ensure_onlyoffice_available()
result = onlyoffice_service.build_preview_config(
resource_type="attachment",
resource_id=attachment.id,
file_name=attachment.filename,
user_id=current_user.id,
user_name=current_user.full_name,
)
await _log_preview_open(
db,
study_id=attachment.study_id,
resource_type="attachment",
resource_id=attachment.id,
current_user=current_user,
)
response.headers["Cache-Control"] = "no-store"
return result
@router.get(
"/versions/{version_id}/config",
response_model=OnlyOfficePreviewConfigRead,
)
async def get_version_preview_config(
version_id: uuid.UUID,
response: Response,
db: AsyncSession = Depends(get_db_session),
current_user=Depends(get_current_user),
) -> OnlyOfficePreviewConfigRead:
version = await version_crud.get(db, version_id)
if not version:
raise onlyoffice_service.onlyoffice_error(
"DOCUMENT_VERSION_NOT_FOUND", "版本不存在", status.HTTP_404_NOT_FOUND
)
document = await document_crud.get(db, version.document_id)
if not document:
raise onlyoffice_service.onlyoffice_error(
"DOCUMENT_NOT_FOUND", "文档不存在", status.HTTP_404_NOT_FOUND
)
await document_service._ensure_study_access(db, document.trial_id, current_user, action="view")
file_path = Path(version.file_uri)
if not file_path.exists():
raise onlyoffice_service.onlyoffice_error(
"DOCUMENT_FILE_NOT_FOUND", "文件不存在", status.HTTP_404_NOT_FOUND
)
file_name = version.original_filename or document_service._legacy_download_filename(version, document)
if not onlyoffice_service.office_format_for_filename(file_name):
raise onlyoffice_service.onlyoffice_error(
"ONLYOFFICE_FORMAT_UNSUPPORTED",
"该文件格式不支持 Office 在线预览",
status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
)
await onlyoffice_service.ensure_onlyoffice_available()
result = onlyoffice_service.build_preview_config(
resource_type="version",
resource_id=version.id,
file_name=file_name,
file_hash=version.file_hash,
user_id=current_user.id,
user_name=current_user.full_name,
)
await _log_preview_open(
db,
study_id=document.trial_id,
resource_type="version",
resource_id=version.id,
current_user=current_user,
)
response.headers["Cache-Control"] = "no-store"
return result
def _authorize_internal_file_request(request: Request, expected_url: str) -> None:
onlyoffice_service.validate_outbox_token(request.headers.get("AuthorizationJwt"), expected_url)
@internal_router.get("/internal/onlyoffice/attachments/{attachment_id}/content")
async def get_internal_attachment_content(
attachment_id: uuid.UUID,
request: Request,
db: AsyncSession = Depends(get_db_session),
) -> FileResponse:
expected_url = onlyoffice_service.onlyoffice_content_url("attachment", attachment_id)
_authorize_internal_file_request(request, expected_url)
attachment = await attachment_crud.get_attachment(db, attachment_id)
if not attachment or not os.path.exists(attachment.file_path):
raise onlyoffice_service.onlyoffice_error(
"ATTACHMENT_FILE_NOT_FOUND", "文件不存在", status.HTTP_404_NOT_FOUND
)
return FileResponse(
path=attachment.file_path,
media_type=attachment.content_type or "application/octet-stream",
headers={"Content-Disposition": _content_disposition(attachment.filename)},
)
@internal_router.get("/internal/onlyoffice/versions/{version_id}/content")
async def get_internal_version_content(
version_id: uuid.UUID,
request: Request,
db: AsyncSession = Depends(get_db_session),
) -> FileResponse:
expected_url = onlyoffice_service.onlyoffice_content_url("version", version_id)
_authorize_internal_file_request(request, expected_url)
version = await version_crud.get(db, version_id)
if not version:
raise onlyoffice_service.onlyoffice_error(
"DOCUMENT_VERSION_NOT_FOUND", "版本不存在", status.HTTP_404_NOT_FOUND
)
document = await document_crud.get(db, version.document_id)
file_path = Path(version.file_uri)
if not document or not file_path.exists():
raise onlyoffice_service.onlyoffice_error(
"DOCUMENT_FILE_NOT_FOUND", "文件不存在", status.HTTP_404_NOT_FOUND
)
file_name = version.original_filename or document_service._legacy_download_filename(version, document)
return FileResponse(
path=str(file_path),
media_type=version.mime_type or "application/octet-stream",
headers={"Content-Disposition": _content_disposition(file_name)},
)