1d26646a96
- 新增协作文件夹、文件、不可变修订、成员、会话、回调回执、编辑申请与分享链接数据模型。 - 补齐新建、导入、复制、下载、回收站、恢复、成员授权、所有权转让及文件级权限接口。 - 接入 ONLYOFFICE 共同编辑、历史版本预览与恢复、修订另存副本、导出下载审计和幂等回调保存。 - 增加编辑权限申请、审批通知、项目提醒聚合、通知 Feed、已读处理及历史待办数据回填。 - 支持公开分享的查看或编辑模式、有效期、密码哈希、失败锁定、短时访问凭证与固定分享地址。 - 增加协作者导出、申请编辑、工作表结构保护和所有权管理策略,并纳入项目接口权限矩阵。 - 新增协作文件库、编辑工作区、公开分享页、下载与另存为对话框,以及导航、路由和权限入口。 - 统一网页端与桌面端通知布局,增加沉浸式工作区和浏览器、Tauri 双端全屏能力。 - 扩展运行时文件下载适配、Tauri 环境识别和原生全屏命令,继续保持业务代码运行时边界。 - 加固 ONLYOFFICE 消息桥的同源下载、签名地址隔离和保存为能力校验,并更新桌面发布检查。 - 增加连续数据库迁移、50MB 上传限制、OnlyOffice 中文文案与开发启动路由校验。 - 补充协作、通知、权限、路由、运行时、布局和 OnlyOffice 相关测试及模块说明文档。
235 lines
9.0 KiB
Python
235 lines
9.0 KiB
Python
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.models.collaboration import CollaborationRevision
|
|
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)},
|
|
)
|
|
|
|
|
|
@internal_router.get("/internal/onlyoffice/collaboration-revisions/{revision_id}/content")
|
|
async def get_internal_collaboration_revision_content(
|
|
revision_id: uuid.UUID,
|
|
request: Request,
|
|
db: AsyncSession = Depends(get_db_session),
|
|
) -> FileResponse:
|
|
expected_url = onlyoffice_service.onlyoffice_content_url("collaboration_revision", revision_id)
|
|
_authorize_internal_file_request(request, expected_url)
|
|
revision = await db.get(CollaborationRevision, revision_id)
|
|
file_path = Path(revision.file_uri) if revision else None
|
|
if not revision or getattr(revision, "deleted_at", None) is not None or not file_path or not file_path.exists():
|
|
raise onlyoffice_service.onlyoffice_error(
|
|
"COLLABORATION_REVISION_NOT_FOUND", "协作修订不存在", status.HTTP_404_NOT_FOUND
|
|
)
|
|
return FileResponse(
|
|
path=str(file_path),
|
|
media_type=revision.mime_type or "application/octet-stream",
|
|
headers={"Content-Disposition": _content_disposition(revision.original_filename)},
|
|
)
|