Files
012-kaopeilian/backend/app/api/v1/preview.py
111 998211c483 feat: 初始化考培练系统项目
- 从服务器拉取完整代码
- 按框架规范整理项目结构
- 配置 Drone CI 测试环境部署
- 包含后端(FastAPI)、前端(Vue3)、管理端

技术栈: Vue3 + TypeScript + FastAPI + MySQL
2026-01-24 19:33:28 +08:00

286 lines
9.6 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
文件预览API
提供课程资料的在线预览功能
"""
import logging
from pathlib import Path
from typing import Optional
from fastapi import APIRouter, Depends, HTTPException, status
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select
from app.core.deps import get_db, get_current_user
from app.schemas.base import ResponseModel
from app.core.config import settings
from app.models.user import User
from app.models.course import CourseMaterial
from app.services.document_converter import document_converter
logger = logging.getLogger(__name__)
router = APIRouter()
class PreviewType:
"""预览类型常量
支持格式TXT、Markdown、MDX、PDF、HTML、Excel、Word、CSV、VTT、Properties
"""
PDF = "pdf"
TEXT = "text"
HTML = "html"
EXCEL_HTML = "excel_html" # Excel转HTML预览
VIDEO = "video"
AUDIO = "audio"
IMAGE = "image"
DOWNLOAD = "download"
# 文件类型到预览类型的映射
FILE_TYPE_MAPPING = {
# PDF - 直接预览
'.pdf': PreviewType.PDF,
# 文本 - 直接显示内容
'.txt': PreviewType.TEXT,
'.md': PreviewType.TEXT,
'.mdx': PreviewType.TEXT,
'.csv': PreviewType.TEXT,
'.vtt': PreviewType.TEXT,
'.properties': PreviewType.TEXT,
# HTML - 在iframe中预览
'.html': PreviewType.HTML,
'.htm': PreviewType.HTML,
}
def get_preview_type(file_ext: str) -> str:
"""
根据文件扩展名获取预览类型
Args:
file_ext: 文件扩展名(带点,如 .pdf
Returns:
预览类型
"""
file_ext_lower = file_ext.lower()
# 直接映射的类型
if file_ext_lower in FILE_TYPE_MAPPING:
return FILE_TYPE_MAPPING[file_ext_lower]
# Excel文件使用HTML预览避免分页问题
if file_ext_lower in {'.xlsx', '.xls'}:
return PreviewType.EXCEL_HTML
# 其他Office文档需要转换为PDF预览
if document_converter.is_convertible(file_ext_lower):
return PreviewType.PDF
# 其他类型,只提供下载
return PreviewType.DOWNLOAD
def get_file_path_from_url(file_url: str) -> Optional[Path]:
"""
从文件URL获取本地文件路径
Args:
file_url: 文件URL如 /static/uploads/courses/1/xxx.pdf
Returns:
本地文件路径如果无效返回None
"""
try:
# 移除 /static/uploads/ 前缀
if file_url.startswith('/static/uploads/'):
relative_path = file_url.replace('/static/uploads/', '')
full_path = Path(settings.UPLOAD_PATH) / relative_path
return full_path
return None
except Exception:
return None
@router.get("/material/{material_id}", response_model=ResponseModel[dict])
async def get_material_preview(
material_id: int,
current_user: User = Depends(get_current_user),
db: AsyncSession = Depends(get_db),
):
"""
获取资料预览信息
Args:
material_id: 资料ID
Returns:
预览信息包括预览类型、预览URL等
"""
try:
# 查询资料信息
stmt = select(CourseMaterial).where(
CourseMaterial.id == material_id,
CourseMaterial.is_deleted == False
)
result = await db.execute(stmt)
material = result.scalar_one_or_none()
if not material:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="资料不存在"
)
# TODO: 权限检查 - 确认当前用户是否有权访问该课程的资料
# 可以通过查询 position_courses 表和用户的岗位关系来判断
# 获取文件扩展名
file_ext = Path(material.name).suffix.lower()
# 确定预览类型
preview_type = get_preview_type(file_ext)
logger.info(
f"资料预览请求 - material_id: {material_id}, "
f"file_type: {file_ext}, preview_type: {preview_type}, "
f"user_id: {current_user.id}"
)
# 构建响应数据
response_data = {
"preview_type": preview_type,
"file_name": material.name,
"original_url": material.file_url,
"file_size": material.file_size,
}
# 根据预览类型处理
if preview_type == PreviewType.TEXT:
# 文本类型,读取文件内容
file_path = get_file_path_from_url(material.file_url)
if file_path and file_path.exists():
try:
with open(file_path, 'r', encoding='utf-8') as f:
content = f.read()
response_data["content"] = content
response_data["preview_url"] = None
except Exception as e:
logger.error(f"读取文本文件失败: {str(e)}")
# 读取失败,改为下载模式
response_data["preview_type"] = PreviewType.DOWNLOAD
response_data["preview_url"] = material.file_url
else:
response_data["preview_type"] = PreviewType.DOWNLOAD
response_data["preview_url"] = material.file_url
elif preview_type == PreviewType.EXCEL_HTML:
# Excel文件转换为HTML预览
file_path = get_file_path_from_url(material.file_url)
if file_path and file_path.exists():
converted_url = document_converter.convert_excel_to_html(
str(file_path),
material.course_id,
material.id
)
if converted_url:
response_data["preview_url"] = converted_url
response_data["preview_type"] = "html" # 前端使用html类型渲染
response_data["is_converted"] = True
else:
logger.warning(f"Excel转HTML失败改为下载模式 - material_id: {material_id}")
response_data["preview_type"] = PreviewType.DOWNLOAD
response_data["preview_url"] = material.file_url
response_data["is_converted"] = False
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="文件不存在"
)
elif preview_type == PreviewType.PDF and document_converter.is_convertible(file_ext):
# Office文档需要转换为PDF
file_path = get_file_path_from_url(material.file_url)
if file_path and file_path.exists():
# 执行转换
converted_url = document_converter.convert_to_pdf(
str(file_path),
material.course_id,
material.id
)
if converted_url:
response_data["preview_url"] = converted_url
response_data["is_converted"] = True
else:
# 转换失败,改为下载模式
logger.warning(f"文档转换失败,改为下载模式 - material_id: {material_id}")
response_data["preview_type"] = PreviewType.DOWNLOAD
response_data["preview_url"] = material.file_url
response_data["is_converted"] = False
else:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="文件不存在"
)
else:
# 其他类型直接返回原始URL
response_data["preview_url"] = material.file_url
return ResponseModel(data=response_data, message="获取预览信息成功")
except HTTPException:
raise
except Exception as e:
logger.error(f"获取资料预览信息失败: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="获取预览信息失败"
)
@router.get("/check-converter", response_model=ResponseModel[dict])
async def check_converter_status(
current_user: User = Depends(get_current_user),
):
"""
检查文档转换服务状态(用于调试)
Returns:
转换服务状态信息
"""
try:
import subprocess
# 检查 LibreOffice 是否安装
try:
result = subprocess.run(
['libreoffice', '--version'],
capture_output=True,
text=True,
timeout=5
)
libreoffice_installed = result.returncode == 0
libreoffice_version = result.stdout.strip() if libreoffice_installed else None
except Exception:
libreoffice_installed = False
libreoffice_version = None
return ResponseModel(
data={
"libreoffice_installed": libreoffice_installed,
"libreoffice_version": libreoffice_version,
"supported_formats": list(document_converter.SUPPORTED_FORMATS),
"converted_path": str(document_converter.converted_path),
},
message="转换服务状态检查完成"
)
except Exception as e:
logger.error(f"检查转换服务状态失败: {str(e)}", exc_info=True)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="检查转换服务状态失败"
)