# 剧本文件解析服务
> **文档版本**:v1.1
> **最后更新**:2026-01-31
---
## 目录
1. [服务概述](#服务概述)
2. [核心功能](#核心功能)
3. [技术选型](#技术选型)
4. [服务实现](#服务实现)
5. [API 接口](#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` | `antiword` 或 `textract` | 中 |
| PDF | `application/pdf` | `PyPDF2` 或 `pdfplumber` | 高 |
| 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 类
```python
# 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 - 同步)**:
```json
{
"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 - 异步)**:
```json
{
"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
```
**请求体**:
```json
{
"force": false // 是否强制重新解析(默认 false)
}
```
**响应**:
```json
{
"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
```
**响应(解析中)**:
```json
{
"success": true,
"data": {
"screenplay_id": "019d1234-5678-7abc-def0-111111111111",
"parsing_status": "parsing",
"progress": 50,
"message": "正在解析文件..."
}
}
```
**响应(解析完成)**:
```json
{
"success": true,
"data": {
"screenplay_id": "019d1234-5678-7abc-def0-111111111111",
"parsing_status": "completed",
"progress": 100,
"content": "# 第一场 咖啡厅 下午\n\n张三坐在咖啡厅里...",
"word_count": 5000,
"message": "文件解析完成"
}
}
```
**响应(解析失败)**:
```json
{
"success": false,
"message": "文件解析失败",
"data": {
"screenplay_id": "019d1234-5678-7abc-def0-111111111111",
"parsing_status": "failed",
"error": "PDF 文件损坏,无法解析"
}
}
```
---
## 工作流程
### 完整流程图(UML 时序图)
#### 流程 A:同步处理(TXT/Markdown)
```mermaid
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
parsing_status=completed
DB-->>ParserService: 更新成功
ParserService-->>API: 返回解析结果 (含 content)
API-->>Frontend: 200 OK (含 content 字段)
Frontend->>Frontend: 立即展示剧本内容
Frontend-->>User: 显示剧本文本
```
#### 流程 B:异步处理(DOCX/PDF/RTF/DOC)
```mermaid
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: 创建剧本记录
(type=file, parsing_status=pending)
DB-->>ScreenplayService: screenplay_id
API->>ParserService: should_parse_async(mime_type)
ParserService-->>API: true (DOCX/PDF/RTF)
API->>Celery: 提交异步任务
parse_screenplay_file_task.delay()
Celery-->>API: task_id
API-->>Frontend: 200 OK
(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: 根据类型选择解析器
(python-docx/pdfplumber/striprtf)
Worker->>Worker: 提取文本内容 (5-60秒)
Worker->>Worker: 转换为 Markdown 格式
Worker->>DB: 更新 content, word_count
parsing_status=completed
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 异步任务
```python
# 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](../../../architecture/changelogs/2026-02-03-screenplays-tables-creation.md)
---
## 使用示例
### API 路由实现
```python
# 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
```
### 前端使用示例
```typescript
// 上传剧本文件
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 秒轮询一次
}
```
---
## 相关文档
- [剧本管理服务](./screenplay-service.md)
- [AI 解析剧本工作流](../../workflows/screenplay-ai-parse-workflow.md)
- [文件存储服务](../resource/file-storage-service.md)
---
## 技术栈符合性说明
本服务设计完全符合 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