You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 

35 KiB

剧本文件解析服务

文档版本:v1.1
最后更新:2026-01-31


目录

  1. 服务概述
  2. 核心功能
  3. 技术选型
  4. 服务实现
  5. API 接口
  6. 工作流程
  7. 错误处理

服务概述

剧本文件解析服务(Screenplay File Parser Service)负责将用户上传的各种格式的剧本文件(TXT、Markdown、DOC、DOCX、PDF、RTF 等)转换为统一的 Markdown 文本格式,以便后续进行 AI 解析和展示。

职责

  • 直接读取 TXT 文件内容(同步)
  • 直接读取 Markdown 文件内容(同步)
  • 解析 DOCX 文件,提取文本内容并转换为 Markdown(异步)
  • 解析 PDF 文件,提取文本内容并转换为 Markdown(异步)
  • 解析 RTF 文件,提取文本内容并转换为 Markdown(异步)
  • 解析 DOC 文件,提取文本内容并转换为 Markdown(异步)
  • 存储转换后的文本到 screenplays.content 字段
  • 返回解析后的文本给前端展示

设计原则

  1. 格式统一:所有文件格式最终转换为 Markdown(TXT/Markdown 直接使用)
  2. 智能路由:TXT/Markdown 同步处理,其他格式异步处理
  3. 快速响应:TXT/Markdown 文件立即返回内容,无需等待
  4. 异步处理:复杂格式(DOCX/PDF/RTF/DOC)使用 Celery 异步任务,避免阻塞用户请求
  5. 错误容错:解析失败时保留原文件,允许用户重新解析或手动输入
  6. 内容保留:同时保留原文件(file_url)和解析后的文本(content

核心功能

1. 文件格式支持

格式 MIME Type 解析库 优先级
TXT text/plain 直接读取
Markdown text/markdown 直接读取
DOCX application/vnd.openxmlformats-officedocument.wordprocessingml.document python-docx
DOC application/msword antiwordtextract
PDF application/pdf PyPDF2pdfplumber
RTF application/rtf, text/rtf striprtf

2. 解析流程

2.1 同步解析(TXT/Markdown)

用户上传文件 → 文件存储 → 直接读取内容 → 存储到 content 字段 → 立即返回结果

2.2 异步解析(DOCX/PDF/RTF/DOC)

用户上传文件 → 文件存储 → 触发解析任务 → 提取文本 → 转换为 Markdown → 存储到 content 字段 → 返回结果

3. 解析策略

3.1 TXT/Markdown 文件(同步)

  • 处理方式:同步读取,立即返回
  • 原因:文本文件无需格式转换,读取速度快
  • 流程
    1. 上传文件到 MinIO
    2. 直接读取文件内容
    3. 存储到 content 字段
    4. 立即返回给前端展示
  • 响应时间:< 1 秒

3.2 DOCX 文件(异步)

  • 处理方式:异步解析(Celery 任务)
  • 原因:需要格式转换,可能耗时较长
  • 解析库python-docx
  • 转换规则
    • 标题 → # 标题
    • 段落 → 普通文本
    • 列表 → - 列表项
    • 粗体 → **粗体**
    • 斜体 → *斜体*
  • 响应时间:5-30 秒

3.3 PDF 文件(异步)

  • 处理方式:异步解析(Celery 任务)
  • 原因:PDF 解析复杂,可能耗时较长
  • 解析库pdfplumber(推荐)
  • 特点
    • 按页提取文本
    • 保留段落结构
    • 处理多列布局
    • 注意:可能丢失部分格式信息
  • 响应时间:10-60 秒

3.4 RTF 文件(异步)

  • 处理方式:异步解析(Celery 任务)
  • 解析库striprtf
  • 特点
    • 提取纯文本内容
    • 转换为 Markdown 格式
  • 响应时间:5-20 秒

3.5 DOC 文件(异步)

  • 处理方式:异步解析(Celery 任务)
  • 解析库textract(可选)
  • 注意:旧版 Word 格式,建议用户转换为 DOCX
  • 响应时间:10-40 秒

技术选型

Python 库选择

库名 版本 用途 安装命令
python-docx ^1.1.2 解析 DOCX 文件 pip install python-docx
pdfplumber ^0.11.0 解析 PDF 文件 pip install pdfplumber
striprtf ^0.0.26 解析 RTF 文件 pip install striprtf
markdownify ^0.13.1 HTML 转 Markdown pip install markdownify
aiofiles ^24.1.0 异步文件 I/O pip install aiofiles

为什么选择这些库?

  1. python-docx

    • 官方推荐的 DOCX 解析库
    • 支持提取段落、表格、样式等
    • 文档完善,社区活跃
  2. pdfplumber

    • 比 PyPDF2 更强大
    • 支持表格提取
    • 支持多列布局
    • 文本提取质量更高
  3. striprtf

    • 轻量级 RTF 解析库
    • 专注于文本提取
    • 无需复杂依赖
  4. markdownify

    • 将 HTML 转换为 Markdown
    • 支持自定义转换规则
    • 用于处理富文本内容
  5. aiofiles

    • 异步文件 I/O 操作
    • 避免阻塞事件循环
    • 提升并发性能

服务实现

ScreenplayFileParserService 类

# app/services/screenplay_file_parser_service.py
from typing import Dict, Any, Optional
from uuid import UUID
import os
import tempfile
import asyncio
from pathlib import Path
from datetime import datetime, UTC
from sqlalchemy.ext.asyncio import AsyncSession
from docx import Document
import pdfplumber
from striprtf.striprtf import rtf_to_text
from markdownify import markdownify as md
import aiofiles
from app.core.logging import get_logger
from app.core.exceptions import ValidationError, FileParseError
from app.repositories.screenplay_repository import ScreenplayRepository

logger = get_logger(__name__)

class ScreenplayFileParserService:
    """剧本文件解析服务"""
    
    # 需要异步解析的文件类型
    ASYNC_PARSE_TYPES = {
        'application/vnd.openxmlformats-officedocument.wordprocessingml.document',  # DOCX
        'application/msword',                   # DOC
        'application/pdf',                      # PDF
        'application/rtf',                      # RTF
        'text/rtf'                              # RTF (alternative)
    }
    
    # 可以同步解析的文件类型
    SYNC_PARSE_TYPES = {
        'text/plain',                           # TXT
        'text/markdown'                         # Markdown
    }
    
    def __init__(self, db: AsyncSession):
        self.db = db
        self.repository = ScreenplayRepository(db)
    
    def should_parse_async(self, mime_type: str) -> bool:
        """判断是否需要异步解析"""
        return mime_type in self.ASYNC_PARSE_TYPES
    
    async def parse_file_sync(
        self,
        screenplay_id: UUID,
        file_path: str,
        mime_type: str
    ) -> Dict[str, Any]:
        """
        同步解析文件(仅用于 TXT/Markdown)
        
        Args:
            screenplay_id: 剧本 ID
            file_path: 文件路径(本地或 URL)
            mime_type: 文件 MIME 类型
        
        Returns:
            解析结果字典
        """
        logger.info(
            "同步解析剧本文件: 剧本=%s, 类型=%s",
            screenplay_id, mime_type
        )
        
        try:
            # 1. 下载文件到临时目录(如果是 URL)
            local_file_path = await self._download_file(file_path)
            
            # 2. 读取文件内容
            if mime_type == 'text/plain':
                content = await self._parse_txt(local_file_path)
            elif mime_type == 'text/markdown':
                content = await self._parse_markdown(local_file_path)
            else:
                raise ValidationError(f"不支持同步解析的文件类型: {mime_type}")
            
            # 3. 计算字数
            word_count = len(content)
            
            # 4. 更新剧本记录
            await self.repository.update(screenplay_id, {
                'content': content,
                'word_count': word_count,
                'parsing_status': 'completed',
                'parsed_at': datetime.now(UTC)
            })
            
            # 5. 清理临时文件
            if local_file_path != file_path:
                os.remove(local_file_path)
            
            logger.info(
                "剧本文件同步解析成功: 剧本=%s, 字数=%d",
                screenplay_id, word_count
            )
            
            return {
                'screenplay_id': str(screenplay_id),
                'content': content,
                'word_count': word_count,
                'parsing_status': 'completed',
                'parsed_at': datetime.now(UTC).isoformat()
            }
            
        except Exception as e:
            logger.error(
                "剧本文件同步解析失败: 剧本=%s, 错误=%s",
                screenplay_id, str(e),
                exc_info=True
            )
            
            # 更新剧本状态为解析失败
            await self.repository.update(screenplay_id, {
                'parsing_status': 'failed',
                'parsing_error': str(e)
            })
            
            raise FileParseError(f"文件解析失败: {str(e)}")
    
    async def parse_file(
        self,
        screenplay_id: UUID,
        file_path: str,
        mime_type: str
    ) -> Dict[str, Any]:
        """
        解析剧本文件并提取文本内容
        
        Args:
            screenplay_id: 剧本 ID
            file_path: 文件路径(本地或 URL)
            mime_type: 文件 MIME 类型
        
        Returns:
            解析结果字典
        """
        logger.info(
            "开始解析剧本文件: 剧本=%s, 类型=%s",
            screenplay_id, mime_type
        )
        
        try:
            # 1. 下载文件到临时目录(如果是 URL)
            local_file_path = await self._download_file(file_path)
            
            # 2. 根据 MIME 类型选择解析器
            if mime_type == 'text/plain':
                content = await self._parse_txt(local_file_path)
            elif mime_type == 'text/markdown':
                content = await self._parse_markdown(local_file_path)
            elif mime_type == 'application/vnd.openxmlformats-officedocument.wordprocessingml.document':
                content = await self._parse_docx(local_file_path)
            elif mime_type == 'application/pdf':
                content = await self._parse_pdf(local_file_path)
            elif mime_type in ['application/rtf', 'text/rtf']:
                content = await self._parse_rtf(local_file_path)
            elif mime_type == 'application/msword':
                content = await self._parse_doc(local_file_path)
            else:
                raise ValidationError(f"不支持的文件类型: {mime_type}")
            
            # 3. 计算字数
            word_count = len(content)
            
            # 4. 更新剧本记录
            await self.repository.update(screenplay_id, {
                'content': content,
                'word_count': word_count
            })
            
            # 5. 清理临时文件
            if local_file_path != file_path:
                os.remove(local_file_path)
            
            logger.info(
                "剧本文件解析成功: 剧本=%s, 字数=%d",
                screenplay_id, word_count
            )
            
            return {
                'screenplay_id': str(screenplay_id),
                'content': content,
                'word_count': word_count,
                'status': 'success'
            }
            
        except Exception as e:
            logger.error(
                "剧本文件解析失败: 剧本=%s, 错误=%s",
                screenplay_id, str(e),
                exc_info=True
            )
            
            # 更新剧本状态为解析失败
            await self.repository.update(screenplay_id, {
                'parsing_status': 'failed',
                'parsing_error': str(e)
            })
            
            raise FileParseError(f"文件解析失败: {str(e)}")
    
    async def _download_file(self, file_path: str) -> str:
        """下载文件到临时目录(如果是 URL)"""
        if file_path.startswith('http://') or file_path.startswith('https://'):
            # 从 MinIO 或其他存储下载文件
            import httpx
            async with httpx.AsyncClient() as client:
                response = await client.get(file_path)
                response.raise_for_status()
                
                # 保存到临时文件
                suffix = Path(file_path).suffix
                with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp_file:
                    tmp_file.write(response.content)
                    return tmp_file.name
        else:
            return file_path
    
    async def _parse_txt(self, file_path: str) -> str:
        """解析 TXT 文件"""
        logger.debug("解析 TXT 文件: %s", file_path)
        import aiofiles
        async with aiofiles.open(file_path, 'r', encoding='utf-8') as f:
            return await f.read()
    
    async def _parse_markdown(self, file_path: str) -> str:
        """解析 Markdown 文件"""
        logger.debug("解析 Markdown 文件: %s", file_path)
        import aiofiles
        async with aiofiles.open(file_path, 'r', encoding='utf-8') as f:
            return await f.read()
    
    async def _parse_docx(self, file_path: str) -> str:
        """解析 DOCX 文件"""
        logger.debug("解析 DOCX 文件: %s", file_path)
        
        # DOCX 解析是 CPU 密集型操作,使用 run_in_executor 避免阻塞
        import asyncio
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, self._parse_docx_sync, file_path)
    
    def _parse_docx_sync(self, file_path: str) -> str:
        """同步解析 DOCX 文件(在线程池中执行)"""
        doc = Document(file_path)
        markdown_lines = []
        
        for para in doc.paragraphs:
            # 处理标题
            if para.style.name.startswith('Heading'):
                level = int(para.style.name.replace('Heading ', ''))
                markdown_lines.append(f"{'#' * level} {para.text}\n")
            # 处理普通段落
            else:
                text = para.text.strip()
                if text:
                    # 处理粗体和斜体
                    formatted_text = self._format_runs(para.runs)
                    markdown_lines.append(f"{formatted_text}\n")
        
        return '\n'.join(markdown_lines)
    
    def _format_runs(self, runs) -> str:
        """格式化 DOCX 段落中的文本样式"""
        result = []
        for run in runs:
            text = run.text
            if run.bold and run.italic:
                text = f"***{text}***"
            elif run.bold:
                text = f"**{text}**"
            elif run.italic:
                text = f"*{text}*"
            result.append(text)
        return ''.join(result)
    
    async def _parse_pdf(self, file_path: str) -> str:
        """解析 PDF 文件"""
        logger.debug("解析 PDF 文件: %s", file_path)
        
        # PDF 解析是 CPU 密集型操作,使用 run_in_executor 避免阻塞
        import asyncio
        loop = asyncio.get_event_loop()
        return await loop.run_in_executor(None, self._parse_pdf_sync, file_path)
    
    def _parse_pdf_sync(self, file_path: str) -> str:
        """同步解析 PDF 文件(在线程池中执行)"""
        text_lines = []
        with pdfplumber.open(file_path) as pdf:
            for page in pdf.pages:
                text = page.extract_text()
                if text:
                    text_lines.append(text)
        
        return '\n\n'.join(text_lines)
    
    async def _parse_rtf(self, file_path: str) -> str:
        """解析 RTF 文件"""
        logger.debug("解析 RTF 文件: %s", file_path)
        
        import aiofiles
        async with aiofiles.open(file_path, 'r', encoding='utf-8') as f:
            rtf_content = await f.read()
        
        return rtf_to_text(rtf_content)
    
    async def _parse_doc(self, file_path: str) -> str:
        """解析 DOC 文件(旧版 Word 格式)"""
        logger.debug("解析 DOC 文件: %s", file_path)
        
        # DOC 格式较复杂,建议使用 textract 或 antiword
        # 这里提供一个简单的实现
        try:
            import textract
            text = textract.process(file_path).decode('utf-8')
            return text
        except ImportError:
            raise ValidationError(
                "DOC 格式解析需要安装 textract 库。"
                "请使用 DOCX 格式或手动输入剧本内容。"
            )

API 接口

1. 上传并解析剧本文件(智能路由)

POST /api/v1/screenplays/upload-and-parse

请求(multipart/form-data):

  • project_id: 项目 ID(必填)
  • name: 剧本名称(必填)
  • file: 剧本文件(必填)

响应(TXT/Markdown - 同步)

{
  "success": true,
  "message": "文件上传成功",
  "data": {
    "screenplay_id": "019d1234-5678-7abc-def0-111111111111",
    "name": "第一集剧本",
    "type": "file",
    "file_url": "https://storage.jointo.ai/screenplays/1/abc123.md",
    "content": "# 第一场 咖啡厅 下午\n\n张三坐在咖啡厅里...",
    "word_count": 5000,
    "parsing_status": "completed",
    "parsed_at": "2026-01-30T10:00:00Z"
  }
}

响应(DOCX/PDF/RTF - 异步)

{
  "success": true,
  "message": "文件上传成功,正在解析...",
  "data": {
    "screenplay_id": "019d1234-5678-7abc-def0-111111111111",
    "name": "第一集剧本",
    "type": "file",
    "file_url": "https://storage.jointo.ai/screenplays/1/abc123.pdf",
    "parsing_status": "parsing",
    "task_id": "abc123-def456-ghi789"
  }
}

2. 手动触发文件解析

POST /api/v1/screenplays/{screenplay_id}/parse-file

请求体

{
  "force": false  // 是否强制重新解析(默认 false
}

响应

{
  "success": true,
  "message": "文件解析任务已提交",
  "data": {
    "screenplay_id": "019d1234-5678-7abc-def0-111111111111",
    "parsing_status": "parsing",
    "task_id": "abc123-def456-ghi789"
  }
}

3. 查询解析状态

GET /api/v1/screenplays/{screenplay_id}/parse-status

响应(解析中)

{
  "success": true,
  "data": {
    "screenplay_id": "019d1234-5678-7abc-def0-111111111111",
    "parsing_status": "parsing",
    "progress": 50,
    "message": "正在解析文件..."
  }
}

响应(解析完成)

{
  "success": true,
  "data": {
    "screenplay_id": "019d1234-5678-7abc-def0-111111111111",
    "parsing_status": "completed",
    "progress": 100,
    "content": "# 第一场 咖啡厅 下午\n\n张三坐在咖啡厅里...",
    "word_count": 5000,
    "message": "文件解析完成"
  }
}

响应(解析失败)

{
  "success": false,
  "message": "文件解析失败",
  "data": {
    "screenplay_id": "019d1234-5678-7abc-def0-111111111111",
    "parsing_status": "failed",
    "error": "PDF 文件损坏,无法解析"
  }
}

工作流程

完整流程图(UML 时序图)

流程 A:同步处理(TXT/Markdown)

sequenceDiagram
    actor User as 用户
    participant Frontend as 前端
    participant API as API 路由
    participant ScreenplayService as ScreenplayService
    participant FileStorage as FileStorageService
    participant ParserService as ParserService
    participant MinIO as MinIO 存储
    participant DB as PostgreSQL

    User->>Frontend: 上传 TXT/Markdown 文件
    Frontend->>API: POST /api/v1/screenplays/upload-and-parse
    
    API->>ScreenplayService: create_screenplay_from_file()
    ScreenplayService->>FileStorage: upload_file()
    FileStorage->>MinIO: 存储文件
    MinIO-->>FileStorage: 返回 file_url
    FileStorage-->>ScreenplayService: 返回文件元数据
    
    ScreenplayService->>DB: 创建剧本记录 (type=file)
    DB-->>ScreenplayService: screenplay_id
    
    API->>ParserService: should_parse_async(mime_type)
    ParserService-->>API: false (TXT/Markdown)
    
    API->>ParserService: parse_file_sync()
    ParserService->>MinIO: 下载文件
    MinIO-->>ParserService: 文件内容
    ParserService->>ParserService: 直接读取文本 (< 1秒)
    ParserService->>DB: 更新 content, word_count<br/>parsing_status=completed
    DB-->>ParserService: 更新成功
    ParserService-->>API: 返回解析结果 (含 content)
    
    API-->>Frontend: 200 OK (含 content 字段)
    Frontend->>Frontend: 立即展示剧本内容
    Frontend-->>User: 显示剧本文本

流程 B:异步处理(DOCX/PDF/RTF/DOC)

sequenceDiagram
    actor User as 用户
    participant Frontend as 前端
    participant API as API 路由
    participant ScreenplayService as ScreenplayService
    participant FileStorage as FileStorageService
    participant ParserService as ParserService
    participant Celery as Celery Broker
    participant Worker as Celery Worker
    participant MinIO as MinIO 存储
    participant DB as PostgreSQL

    User->>Frontend: 上传 DOCX/PDF/RTF 文件
    Frontend->>API: POST /api/v1/screenplays/upload-and-parse
    
    API->>ScreenplayService: create_screenplay_from_file()
    ScreenplayService->>FileStorage: upload_file()
    FileStorage->>MinIO: 存储文件
    MinIO-->>FileStorage: 返回 file_url
    FileStorage-->>ScreenplayService: 返回文件元数据
    
    ScreenplayService->>DB: 创建剧本记录<br/>(type=file, parsing_status=pending)
    DB-->>ScreenplayService: screenplay_id
    
    API->>ParserService: should_parse_async(mime_type)
    ParserService-->>API: true (DOCX/PDF/RTF)
    
    API->>Celery: 提交异步任务<br/>parse_screenplay_file_task.delay()
    Celery-->>API: task_id
    
    API-->>Frontend: 200 OK<br/>(parsing_status=parsing, task_id)
    Frontend->>Frontend: 显示解析进度条
    Frontend-->>User: "正在解析文件..."
    
    Note over Worker,DB: 后台异步处理
    
    Worker->>Celery: 获取任务
    Worker->>DB: 更新 parsing_status=parsing
    Worker->>MinIO: 下载文件到临时目录
    MinIO-->>Worker: 文件内容
    
    Worker->>Worker: 根据类型选择解析器<br/>(python-docx/pdfplumber/striprtf)
    Worker->>Worker: 提取文本内容 (5-60秒)
    Worker->>Worker: 转换为 Markdown 格式
    
    Worker->>DB: 更新 content, word_count<br/>parsing_status=completed<br/>parsed_at
    DB-->>Worker: 更新成功
    
    Worker->>Celery: 任务完成
    
    Note over Frontend,DB: 前端轮询获取结果
    
    Frontend->>API: GET /api/v1/screenplays/{id}/parse-status
    API->>DB: 查询 parsing_status
    DB-->>API: parsing_status=completed, content
    API-->>Frontend: 200 OK (含 content)
    
    Frontend->>Frontend: 展示剧本内容
    Frontend-->>User: 显示剧本文本

Celery 异步任务

# app/tasks/screenplay_tasks.py
import asyncio
from celery import shared_task
from app.core.database import async_session_maker
from app.services.screenplay_file_parser_service import ScreenplayFileParserService
from app.core.logging import get_logger

logger = get_logger(__name__)

@shared_task(bind=True, max_retries=3)
def parse_screenplay_file_task(
    self,
    screenplay_id: str,
    file_path: str,
    mime_type: str
):
    """
    异步解析剧本文件(Celery 任务)
    
    Args:
        screenplay_id: 剧本 ID
        file_path: 文件路径(MinIO URL)
        mime_type: 文件 MIME 类型
    """
    try:
        logger.info(
            "开始异步解析剧本文件: 剧本=%s, 类型=%s",
            screenplay_id, mime_type
        )
        
        # 使用 asyncio.run() 运行异步代码
        result = asyncio.run(_parse_file_async(screenplay_id, file_path, mime_type))
        
        logger.info(
            "剧本文件解析完成: 剧本=%s, 字数=%d",
            screenplay_id, result['word_count']
        )
        
        return result
        
    except Exception as e:
        logger.error(
            "剧本文件解析失败: 剧本=%s, 错误=%s",
            screenplay_id, str(e),
            exc_info=True
        )
        
        # 重试机制
        if self.request.retries < self.max_retries:
            raise self.retry(exc=e, countdown=60)  # 1 分钟后重试
        else:
            # 达到最大重试次数,标记为失败
            return {
                'screenplay_id': screenplay_id,
                'status': 'failed',
                'error': str(e)
            }

async def _parse_file_async(screenplay_id: str, file_path: str, mime_type: str):
    """
    内部异步函数,执行实际的文件解析
    
    Args:
        screenplay_id: 剧本 ID
        file_path: 文件路径(MinIO URL)
        mime_type: 文件 MIME 类型
    
    Returns:
        解析结果字典
    """
    async with async_session_maker() as db:
        parser_service = ScreenplayFileParserService(db)
        result = await parser_service.parse_file(
            screenplay_id=screenplay_id,
            file_path=file_path,
            mime_type=mime_type
        )
        await db.commit()  # 确保事务提交
        return result

错误处理

1. 文件下载失败

场景:MinIO 服务不可用、网络超时

处理逻辑

  1. 记录错误日志
  2. 更新 parsing_status = 'failed'
  3. 重试 3 次(间隔 1 分钟)
  4. 达到最大重试次数后,通知用户

2. 文件格式不支持

场景:用户上传了不支持的文件格式

处理逻辑

  1. 在上传阶段检查 MIME 类型
  2. 如果不支持,返回 HTTP 400 错误
  3. 提示用户支持的文件格式

3. 文件损坏或无法解析

场景:PDF 文件损坏、DOCX 文件加密

处理逻辑

  1. 捕获解析异常
  2. 更新 parsing_status = 'failed'
  3. 记录详细错误信息到 parsing_error 字段
  4. 提示用户重新上传或手动输入

4. 解析超时

场景:文件过大,解析时间超过 5 分钟

处理逻辑

  1. 设置 Celery 任务超时时间(5 分钟)
  2. 超时后自动终止任务
  3. 更新 parsing_status = 'failed'
  4. 提示用户文件过大,建议拆分或手动输入

数据库表结构

screenplays 表已创建

完整的 screenplays 表结构已在数据库迁移中创建,包含以下关键字段:

字段名 类型 说明
screenplay_id UUID 主键(UUID v7)
project_id UUID 所属项目 ID
name TEXT 剧本名称
type SMALLINT 剧本类型:1=file, 2=manual
file_url TEXT 文件 URL(type=1 时)
file_name TEXT 原始文件名
file_size BIGINT 文件大小(字节)
mime_type TEXT 文件 MIME 类型
content TEXT 剧本文本内容(Markdown)
word_count INTEGER 字数统计
parsing_status SMALLINT 解析状态:0=idle, 1=pending, 2=parsing, 3=completed, 4=failed
parsing_error TEXT 解析错误信息
parsed_at TIMESTAMPTZ 解析完成时间
display_order INTEGER 显示顺序
created_at TIMESTAMPTZ 创建时间
updated_at TIMESTAMPTZ 更新时间
deleted_at TIMESTAMPTZ 软删除时间

迁移文件server/alembic/versions/20260203_1200_create_screenplays_tables.py

详细文档:参见 剧本表结构创建 Changelog


使用示例

API 路由实现

# app/api/v1/screenplays.py
from fastapi import APIRouter, UploadFile, File, Form, Depends
from sqlalchemy.ext.asyncio import AsyncSession
from app.core.database import get_db
from app.services.screenplay_service import ScreenplayService
from app.services.screenplay_file_parser_service import ScreenplayFileParserService
from app.tasks.screenplay_tasks import parse_screenplay_file_task
from app.core.logging import get_logger

router = APIRouter()
logger = get_logger(__name__)

@router.post("/upload-and-parse")
async def upload_and_parse_screenplay(
    project_id: str = Form(...),
    name: str = Form(...),
    file: UploadFile = File(...),
    db: AsyncSession = Depends(get_db),
    current_user = Depends(get_current_user)
):
    """
    上传并解析剧本文件
    - TXT/Markdown: 同步解析,立即返回内容
    - DOCX/PDF/RTF/DOC: 异步解析,返回任务 ID
    """
    logger.info(
        "上传剧本文件: 用户=%s, 项目=%s, 文件=%s, 类型=%s",
        current_user.user_id, project_id, file.filename, file.content_type
    )
    
    try:
        # 1. 创建服务实例
        screenplay_service = ScreenplayService(db)
        parser_service = ScreenplayFileParserService(db)
        
        # 2. 上传文件并创建剧本记录
        screenplay = await screenplay_service.create_screenplay_from_file(
            user_id=current_user.user_id,
            project_id=UUID(project_id),
            name=name,
            file=file
        )
        
        # 3. 判断是否需要异步解析
        if parser_service.should_parse_async(file.content_type):
            # 异步解析:提交 Celery 任务
            logger.info("提交异步解析任务: 剧本=%s", screenplay.screenplay_id)
            
            task = parse_screenplay_file_task.delay(
                screenplay_id=str(screenplay.screenplay_id),
                file_path=screenplay.file_url,
                mime_type=file.content_type
            )
            
            return {
                "success": True,
                "message": "文件上传成功,正在解析...",
                "data": {
                    "screenplay_id": str(screenplay.screenplay_id),
                    "name": screenplay.name,
                    "type": "file",
                    "file_url": screenplay.file_url,
                    "parsing_status": "parsing",
                    "task_id": task.id
                }
            }
        else:
            # 同步解析:立即返回内容
            logger.info("执行同步解析: 剧本=%s", screenplay.screenplay_id)
            
            result = await parser_service.parse_file_sync(
                screenplay_id=screenplay.screenplay_id,
                file_path=screenplay.file_url,
                mime_type=file.content_type
            )
            
            return {
                "success": True,
                "message": "文件上传成功",
                "data": {
                    "screenplay_id": str(screenplay.screenplay_id),
                    "name": screenplay.name,
                    "type": "file",
                    "file_url": screenplay.file_url,
                    "content": result['content'],
                    "word_count": result['word_count'],
                    "parsing_status": "completed",
                    "parsed_at": result['parsed_at']
                }
            }
            
    except Exception as e:
        logger.error(
            "上传剧本文件失败: 用户=%s, 错误=%s",
            current_user.user_id, str(e),
            exc_info=True
        )
        raise

前端使用示例

// 上传剧本文件
async function uploadScreenplayFile(
  projectId: string,
  name: string,
  file: File
) {
  const formData = new FormData();
  formData.append('project_id', projectId);
  formData.append('name', name);
  formData.append('file', file);
  
  const response = await fetch('/api/v1/screenplays/upload-and-parse', {
    method: 'POST',
    body: formData,
    headers: {
      'Authorization': `Bearer ${token}`
    }
  });
  
  const result = await response.json();
  
  if (result.data.parsing_status === 'completed') {
    // TXT/Markdown: 立即展示内容
    console.log('剧本内容:', result.data.content);
    displayScreenplayContent(result.data.content);
  } else if (result.data.parsing_status === 'parsing') {
    // DOCX/PDF/RTF: 显示解析进度,轮询状态
    console.log('任务 ID:', result.data.task_id);
    pollParsingStatus(result.data.screenplay_id);
  }
}

// 轮询解析状态
async function pollParsingStatus(screenplayId: string) {
  const interval = setInterval(async () => {
    const response = await fetch(
      `/api/v1/screenplays/${screenplayId}/parse-status`,
      {
        headers: {
          'Authorization': `Bearer ${token}`
        }
      }
    );
    
    const result = await response.json();
    
    if (result.data.parsing_status === 'completed') {
      clearInterval(interval);
      console.log('解析完成:', result.data.content);
      displayScreenplayContent(result.data.content);
    } else if (result.data.parsing_status === 'failed') {
      clearInterval(interval);
      console.error('解析失败:', result.data.error);
      showErrorMessage(result.data.error);
    }
  }, 2000); // 每 2 秒轮询一次
}

相关文档


技术栈符合性说明

本服务设计完全符合 jointo-tech-stack 规范:

  1. 异步编程

    • 所有数据库操作使用 async/await
    • 文件 I/O 使用 aiofiles 异步读取
    • CPU 密集型操作(DOCX/PDF 解析)使用 run_in_executor 避免阻塞
  2. Celery 任务

    • 使用 asyncio.run() 在 Celery 任务中运行异步代码
    • 使用 async_session_maker() 创建独立数据库会话
    • 避免使用 FastAPI 的 get_db() 依赖注入
  3. 日志规范

    • 使用 get_logger(__name__) 获取模块级 logger
    • 使用 %-formatting 格式化日志消息
    • 异常日志使用 exc_info=True
  4. 时间戳

    • parsed_at 字段使用 TIMESTAMPTZ 类型
    • 使用 datetime.now(UTC) 生成 UTC 时间
  5. API 响应格式

    • 统一使用 {"success": bool, "message": str, "data": dict} 格式

变更记录

v1.1 (2026-01-31)

  • 修正 Celery 任务中的异步调用问题(使用 asyncio.run()
  • 修正数据库会话获取方式(使用 async_session_maker()
  • 文件 I/O 操作改为异步(使用 aiofiles
  • CPU 密集型操作使用 run_in_executor 避免阻塞事件循环
  • 添加技术栈符合性说明
  • 完全符合 jointo-tech-stack 规范

v1.0 (2026-01-30)

  • 初始版本
  • 定义剧本文件解析服务的核心功能
  • 支持 TXT、Markdown、DOCX、PDF、RTF、DOC 格式
  • 智能路由:TXT/Markdown 同步解析,DOCX/PDF/RTF/DOC 异步解析
  • TXT/Markdown 文件直接读取并立即返回,无需 Celery 任务
  • DOCX/PDF/RTF/DOC 使用 Celery 异步任务处理文件解析
  • 提供完整的 API 接口和错误处理机制
  • 包含前端集成示例代码

文档版本:v1.1
最后更新:2026-01-31