nex_docus/backend/app/api/v1/preview.py

279 lines
9.5 KiB
Python
Raw Normal View History

2025-12-20 11:18:59 +00:00
"""
2025-12-29 12:53:50 +00:00
项目预览相关 API支持公开和私密项目
2025-12-20 11:18:59 +00:00
"""
from fastapi import APIRouter, Depends, HTTPException, Header
2026-02-26 09:52:09 +00:00
from fastapi.responses import FileResponse, StreamingResponse
2025-12-31 05:44:03 +00:00
from fastapi.security import HTTPAuthorizationCredentials
2025-12-20 11:18:59 +00:00
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from typing import Optional
2025-12-31 05:44:03 +00:00
import mimetypes
2026-02-26 09:52:09 +00:00
from pathlib import Path
2025-12-20 11:18:59 +00:00
from app.core.database import get_db
2025-12-31 05:44:03 +00:00
from app.core.deps import get_current_user_optional, security_optional
from app.core.security import decode_access_token
from app.core.redis_client import TokenCache
2025-12-29 12:53:50 +00:00
from app.models.user import User
2025-12-20 11:18:59 +00:00
from app.schemas.response import success_response
2026-04-08 17:03:57 +00:00
from app.services.project_service import (
get_project_or_404,
require_project_read_access,
)
2025-12-20 11:18:59 +00:00
from app.services.storage import storage_service
router = APIRouter()
2026-04-08 17:03:57 +00:00
def verify_project_password(project, provided_password: Optional[str]) -> None:
"""校验预览密码。"""
if project.access_pass and project.access_pass != provided_password:
raise HTTPException(status_code=403, detail="需要提供正确的访问密码")
2025-12-29 12:53:50 +00:00
2025-12-20 11:18:59 +00:00
@router.get("/{project_id}/info", response_model=dict)
async def get_preview_info(
project_id: int,
2025-12-29 12:53:50 +00:00
current_user: Optional[User] = Depends(get_current_user_optional),
2025-12-20 11:18:59 +00:00
db: AsyncSession = Depends(get_db)
):
2025-12-29 12:53:50 +00:00
"""获取预览项目基本信息"""
2026-04-08 17:03:57 +00:00
project, _ = await require_project_read_access(
db,
project_id,
current_user,
allow_public=True,
unauthenticated_detail="私密项目需要登录才能访问",
forbidden_detail="无权访问该私密项目",
)
2025-12-29 12:53:50 +00:00
2025-12-20 11:18:59 +00:00
# 返回基本信息
info = {
"id": project.id,
"name": project.name,
"description": project.description,
2025-12-29 12:53:50 +00:00
"is_public": project.is_public,
2025-12-20 11:18:59 +00:00
"has_password": bool(project.access_pass),
}
return success_response(data=info)
@router.post("/{project_id}/verify", response_model=dict)
async def verify_access_password(
project_id: int,
password: str = Header(..., alias="X-Access-Password"),
2025-12-29 12:53:50 +00:00
current_user: Optional[User] = Depends(get_current_user_optional),
2025-12-20 11:18:59 +00:00
db: AsyncSession = Depends(get_db)
):
"""验证访问密码"""
2026-04-08 17:03:57 +00:00
project, _ = await require_project_read_access(
db,
project_id,
current_user,
allow_public=True,
unauthenticated_detail="私密项目需要登录才能访问",
forbidden_detail="无权访问该私密项目",
)
2025-12-29 12:53:50 +00:00
2025-12-20 11:18:59 +00:00
# 验证密码
if not project.access_pass:
return success_response(message="该项目无需密码访问")
if project.access_pass != password:
raise HTTPException(status_code=403, detail="访问密码错误")
return success_response(message="验证成功")
@router.get("/{project_id}/tree", response_model=dict)
async def get_preview_tree(
project_id: int,
password: Optional[str] = Header(None, alias="X-Access-Password"),
2025-12-29 12:53:50 +00:00
current_user: Optional[User] = Depends(get_current_user_optional),
2025-12-20 11:18:59 +00:00
db: AsyncSession = Depends(get_db)
):
2025-12-29 12:53:50 +00:00
"""获取预览项目的文档树"""
2026-04-08 17:03:57 +00:00
project, _ = await require_project_read_access(
db,
project_id,
current_user,
allow_public=True,
unauthenticated_detail="私密项目需要登录才能访问",
forbidden_detail="无权访问该私密项目",
)
verify_project_password(project, password)
2025-12-20 11:18:59 +00:00
# 获取文档树
project_path = storage_service.get_secure_path(project.storage_key)
tree = storage_service.generate_tree(project_path)
return success_response(data=tree)
@router.get("/{project_id}/file", response_model=dict)
async def get_preview_file(
project_id: int,
path: str,
password: Optional[str] = Header(None, alias="X-Access-Password"),
2025-12-29 12:53:50 +00:00
current_user: Optional[User] = Depends(get_current_user_optional),
2025-12-20 11:18:59 +00:00
db: AsyncSession = Depends(get_db)
):
2025-12-29 12:53:50 +00:00
"""获取预览项目的文件内容"""
2026-04-08 17:03:57 +00:00
project, _ = await require_project_read_access(
db,
project_id,
current_user,
allow_public=True,
unauthenticated_detail="私密项目需要登录才能访问",
forbidden_detail="无权访问该私密项目",
)
verify_project_password(project, password)
2025-12-20 11:18:59 +00:00
# 获取文件内容
file_path = storage_service.get_secure_path(project.storage_key, path)
content = await storage_service.read_file(file_path)
return success_response(data={"content": content})
2025-12-31 05:44:03 +00:00
@router.get("/{project_id}/document/{path:path}")
async def get_preview_document(
project_id: int,
path: str,
password: Optional[str] = Header(None, alias="X-Access-Password"),
access_pass: Optional[str] = None, # 支持密码查询参数
token: Optional[str] = None, # 支持token查询参数
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_optional),
db: AsyncSession = Depends(get_db)
):
"""获取预览项目的文档文件PDF等- 返回文件流"""
# 获取当前用户支持header或query参数
current_user = None
token_str = None
if credentials:
token_str = credentials.credentials
elif token:
token_str = token
if token_str:
try:
user_id_from_redis = await TokenCache.get_user_id(token_str)
if user_id_from_redis:
payload = decode_access_token(token_str)
if payload:
user_id_str = payload.get("sub")
if user_id_str:
user_id = int(user_id_str)
result = await db.execute(select(User).where(User.id == user_id))
current_user = result.scalar_one_or_none()
except Exception:
pass # 忽略token验证失败继续作为未登录用户
2026-04-08 17:03:57 +00:00
project, _ = await require_project_read_access(
db,
project_id,
current_user,
allow_public=True,
unauthenticated_detail="私密项目需要登录才能访问",
forbidden_detail="无权访问该私密项目",
)
2025-12-31 05:44:03 +00:00
# 如果设置了密码需要验证优先使用header其次使用query参数
provided_password = password or access_pass
2026-04-08 17:03:57 +00:00
verify_project_password(project, provided_password)
2025-12-31 05:44:03 +00:00
# 获取文件
file_path = storage_service.get_secure_path(project.storage_key, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
content_type, _ = mimetypes.guess_type(str(file_path))
return FileResponse(path=str(file_path), media_type=content_type, filename=file_path.name)
2026-02-26 09:52:09 +00:00
@router.get("/{project_id}/export-pdf")
async def export_preview_pdf(
project_id: int,
path: str,
password: Optional[str] = Header(None, alias="X-Access-Password"),
access_pass: Optional[str] = None, # 支持密码查询参数
token: Optional[str] = None, # 支持token查询参数
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security_optional),
db: AsyncSession = Depends(get_db)
):
"""导出预览项目的文档为 PDF"""
2026-04-08 17:03:57 +00:00
from app.services.pdf_service import pdf_service
2026-02-26 09:52:09 +00:00
# 获取当前用户支持header或query参数
current_user = None
token_str = None
if credentials:
token_str = credentials.credentials
elif token:
token_str = token
if token_str:
try:
user_id_from_redis = await TokenCache.get_user_id(token_str)
if user_id_from_redis:
payload = decode_access_token(token_str)
if payload:
user_id_str = payload.get("sub")
if user_id_str:
user_id = int(user_id_str)
result = await db.execute(select(User).where(User.id == user_id))
current_user = result.scalar_one_or_none()
except Exception:
pass
2026-04-08 17:03:57 +00:00
project, _ = await require_project_read_access(
db,
project_id,
current_user,
allow_public=True,
unauthenticated_detail="私密项目需要登录才能访问",
forbidden_detail="无权访问该私密项目",
)
2026-02-26 09:52:09 +00:00
# 验证密码
provided_password = password or access_pass
2026-04-08 17:03:57 +00:00
verify_project_password(project, provided_password)
2026-02-26 09:52:09 +00:00
# 获取文件
file_path = storage_service.get_secure_path(project.storage_key, path)
if not file_path.exists() or not file_path.is_file():
raise HTTPException(status_code=404, detail="文件不存在")
# 读取 Markdown 内容并转换为 PDF
content = await storage_service.read_file(file_path)
filename = Path(path).stem + ".pdf"
# 将 markdown 中的 API 图片 URL 转为文件系统相对路径,以便 WeasyPrint 解析
import re
content = re.sub(r'/api/v1/files/\d+/assets/', '_assets/', content)
# 生成 PDF 字节流,传入项目根目录作为 base_url
project_root = storage_service.get_secure_path(project.storage_key)
2026-04-08 17:03:57 +00:00
try:
pdf_buffer = await pdf_service.md_to_pdf(content, title=filename, base_url=str(project_root))
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
2026-02-26 09:52:09 +00:00
# 中文文件名需要 RFC 5987 编码
from urllib.parse import quote
encoded_filename = quote(filename)
return StreamingResponse(
pdf_buffer,
media_type="application/pdf",
headers={
"Content-Disposition": f"attachment; filename*=UTF-8''{encoded_filename}",
"Content-Type": "application/pdf"
}
)