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.
 

13 KiB

Changelog: 剧本文件解析器实现

日期: 2026-02-03 模块: server (后端) 类型: Feature Implementation 影响范围: Screenplay Service, API Routes, Schemas


概述

完成剧本文件解析器功能的实现,支持多种文件格式(TXT、Markdown、DOCX、PDF、RTF、DOC)的上传和解析,实现智能路由策略(同步/异步解析)和完整的状态跟踪机制。


变更详情

1. 🐛 Bug 修复

ScreenplayFileParserService Repository 初始化问题

问题描述: ScreenplayFileParserService.__init__ 方法中 self.repository 被设置为 None,导致在 parse_file()parse_file_sync() 方法中调用 self.repository.update() 时抛出 AttributeError: 'NoneType' object has no attribute 'update'

修复方案:

# 修复前
def __init__(self, db: AsyncSession):
    self.db = db
    self.repository = None  # ❌ Repository 将在需要时创建

# 修复后
from app.repositories.screenplay_repository import ScreenplayRepository

def __init__(self, db: Optional[AsyncSession]):
    self.db = db
    self.repository = ScreenplayRepository(db) if db else None  # ✅ 正确初始化

影响:

  • 修复了文件解析功能无法更新数据库状态的关键问题
  • 确保解析状态(parsing_status)能够正确更新为 completed/failed

文件: server/app/services/screenplay_file_parser_service.py:42-44


2. 新增 Schema 定义

FileUploadResponse

用途: 文件上传响应模型,支持同步和异步解析场景

字段:

class FileUploadResponse(BaseModel):
    screenplay_id: UUID          # 剧本 ID
    name: str                    # 剧本名称
    type: str                    # 剧本类型(text/file)
    file_url: str                # 文件 URL
    file_name: Optional[str]     # 原始文件名
    file_size: Optional[int]     # 文件大小(字节)
    mime_type: Optional[str]     # 文件 MIME 类型
    content: Optional[str]       # 剧本内容(同步解析时返回)
    word_count: Optional[int]    # 字数统计(同步解析时返回)
    parsing_status: str          # 解析状态: idle/pending/parsing/completed/failed
    parsed_at: Optional[datetime] # 解析完成时间
    task_id: Optional[str]       # Celery 任务 ID(异步解析时返回)

特点:

  • 支持同步解析场景(返回 content 和 word_count)
  • 支持异步解析场景(返回 task_id)
  • 统一的响应格式,便于前端处理

ParseFileRequest

用途: 手动触发文件解析请求模型

字段:

class ParseFileRequest(BaseModel):
    force: bool = Field(False, description="是否强制重新解析")

应用场景:

  • 解析失败后重试
  • 强制重新解析已解析的文件

ParseStatusResponse

用途: 解析状态查询响应模型

字段:

class ParseStatusResponse(BaseModel):
    screenplay_id: UUID              # 剧本 ID
    parsing_status: str              # 解析状态
    progress: Optional[int]          # 解析进度(0-100)
    content: Optional[str]           # 剧本内容(解析完成时返回)
    word_count: Optional[int]        # 字数统计(解析完成时返回)
    parsing_error: Optional[str]     # 解析错误信息(失败时返回)
    message: str                     # 状态描述信息

特点:

  • 提供进度指示(parsing: 50%, completed: 100%, failed: 0%)
  • 根据状态返回不同内容(completed 返回 content,failed 返回 error)
  • 友好的状态描述信息

文件: server/app/schemas/screenplay.py:199-229


3. 🚀 新增 API 端点

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

功能: 上传并解析剧本文件,智能路由到同步/异步解析

请求参数:

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

智能路由策略:

# 同步解析(立即返回内容)
SYNC_PARSE_TYPES = {
    'text/plain',        # TXT
    'text/markdown'      # Markdown
}

# 异步解析(返回任务 ID)
ASYNC_PARSE_TYPES = {
    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',  # DOCX
    'application/msword',                   # DOC
    'application/pdf',                      # PDF
    'application/rtf',                      # RTF
    'text/rtf'                              # RTF (alternative)
}

响应示例:

// 同步解析
{
  "code": 200,
  "message": "文件上传成功",
  "data": {
    "screenplay_id": "uuid",
    "content": "剧本内容...",
    "word_count": 1500,
    "parsing_status": "completed"
  }
}

// 异步解析
{
  "code": 200,
  "message": "文件上传成功,正在解析...",
  "data": {
    "screenplay_id": "uuid",
    "parsing_status": "parsing",
    "task_id": "celery-task-id"
  }
}

权限要求: 需要项目编辑权限(editor)

文件: server/app/api/v1/screenplays.py:373-489


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

功能: 手动触发文件解析任务

请求参数:

  • screenplay_id (Path): 剧本 ID
  • force (Body, Optional): 是否强制重新解析

应用场景:

  • 解析失败后重试
  • 强制重新解析已解析的文件

验证逻辑:

# 检查是否为文件类型剧本
if screenplay.type != 2:  # ScreenplayType.FILE
    raise ValidationError("只能解析文件类型的剧本")

# 检查是否已经在解析中
if screenplay.parsing_status == 'parsing' and not force:
    raise ValidationError("剧本正在解析中,请稍后再试")

响应示例:

{
  "code": 200,
  "message": "文件解析任务已提交",
  "data": {
    "screenplay_id": "uuid",
    "parsing_status": "parsing",
    "task_id": "celery-task-id"
  }
}

权限要求: 需要项目编辑权限(editor)

文件: server/app/api/v1/screenplays.py:491-582


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

功能: 查询剧本文件解析状态和进度

请求参数:

  • screenplay_id (Path): 剧本 ID

状态映射:

status_messages = {
    'idle': '未开始解析',
    'pending': '等待解析',
    'parsing': '正在解析文件...',
    'completed': '文件解析完成',
    'failed': '文件解析失败'
}

进度计算:

  • parsing: 50%
  • completed: 100%
  • failed: 0%

响应示例:

// 解析中
{
  "code": 200,
  "data": {
    "screenplay_id": "uuid",
    "parsing_status": "parsing",
    "progress": 50,
    "message": "正在解析文件..."
  }
}

// 解析完成
{
  "code": 200,
  "data": {
    "screenplay_id": "uuid",
    "parsing_status": "completed",
    "progress": 100,
    "content": "剧本内容...",
    "word_count": 1500,
    "message": "文件解析完成"
  }
}

// 解析失败
{
  "code": 200,
  "data": {
    "screenplay_id": "uuid",
    "parsing_status": "failed",
    "progress": 0,
    "parsing_error": "不支持的文件格式",
    "message": "文件解析失败"
  }
}

权限要求: 需要项目查看权限(viewer)

文件: server/app/api/v1/screenplays.py:584-661


技术实现细节

文件上传与去重

使用 FileStorageService 实现基于 SHA256 的文件去重:

# 1. 计算文件校验和
checksum = hashlib.sha256(file_content).hexdigest()

# 2. 检查是否已存在
existing = await checksum_repo.get_by_checksum(checksum)
if existing:
    # 文件已存在,增加引用计数
    await increase_reference_count(existing.id)
    return existing.file_url

# 3. 上传新文件到 MinIO
object_name = f"screenplays/{user_id}/{checksum}{extension}"
file_url = await storage.upload_bytes(data=file_content, object_name=object_name)

优势:

  • 避免重复存储相同文件
  • 节省存储空间
  • 提高上传速度(重复文件直接返回)

异步解析任务

使用 Celery 实现异步文件解析,支持重试机制:

@shared_task(bind=True, max_retries=3)
def parse_screenplay_file_task(self, screenplay_id: str, file_path: str, mime_type: str):
    try:
        result = asyncio.run(_parse_file_async(screenplay_id, file_path, mime_type))
        return result
    except Exception as e:
        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)}

特点:

  • 最多重试 3 次
  • 重试间隔 60 秒
  • 达到最大重试次数后标记为失败

文件解析支持

文件类型 MIME Type 解析方式 解析库
TXT text/plain 同步 内置
Markdown text/markdown 同步 内置
DOCX application/vnd.openxmlformats-officedocument.wordprocessingml.document 异步 python-docx
DOC application/msword 异步 python-docx
PDF application/pdf 异步 pdfplumber
RTF application/rtf, text/rtf 异步 striprtf

字数统计算法

支持中英文混合统计:

def _count_words(self, text: str) -> int:
    # 统计中文字符
    chinese_chars = re.findall(r'[\u4e00-\u9fff]', text)
    chinese_count = len(chinese_chars)

    # 移除中文字符后统计英文单词
    text_without_chinese = re.sub(r'[\u4e00-\u9fff]', '', text)
    english_words = re.findall(r'\b[a-zA-Z]+\b', text_without_chinese)
    english_count = len(english_words)

    return chinese_count + english_count

数据库变更

无新增表或字段,使用现有 screenplays 表的以下字段:

  • file_url: 文件访问 URL
  • file_size: 文件大小(字节)
  • mime_type: 文件 MIME 类型
  • checksum: 文件 SHA256 校验和
  • storage_path: 文件存储路径
  • parsing_status: 解析状态(idle/pending/parsing/completed/failed)
  • parsing_error: 解析错误信息
  • parsed_at: 解析完成时间
  • content: 解析后的文本内容
  • word_count: 字数统计

测试建议

单元测试

  1. ScreenplayFileParserService

    • 测试 should_parse_async() 方法的 MIME 类型判断
    • 测试 parse_file_sync() 同步解析逻辑
    • 测试 _count_words() 中英文混合统计
    • 测试各种文件格式的解析(TXT, DOCX, PDF, RTF)
  2. Schema 验证

    • 测试 FileUploadResponse 的字段验证
    • 测试 ParseStatusResponse 的条件字段返回

集成测试

  1. 上传并解析流程

    # 测试同步解析(TXT)
    response = await client.post(
        "/api/v1/screenplays/upload-and-parse",
        data={"project_id": project_id, "name": "test.txt"},
        files={"file": ("test.txt", b"content", "text/plain")}
    )
    assert response.json()["data"]["parsing_status"] == "completed"
    assert "content" in response.json()["data"]
    
    # 测试异步解析(DOCX)
    response = await client.post(
        "/api/v1/screenplays/upload-and-parse",
        data={"project_id": project_id, "name": "test.docx"},
        files={"file": ("test.docx", docx_bytes, "application/vnd.openxmlformats-officedocument.wordprocessingml.document")}
    )
    assert response.json()["data"]["parsing_status"] == "parsing"
    assert "task_id" in response.json()["data"]
    
  2. 状态查询流程

    # 查询解析状态
    response = await client.get(f"/api/v1/screenplays/{screenplay_id}/parse-status")
    assert response.json()["data"]["parsing_status"] in ["idle", "pending", "parsing", "completed", "failed"]
    
  3. 手动触发解析

    # 测试强制重新解析
    response = await client.post(
        f"/api/v1/screenplays/{screenplay_id}/parse-file",
        json={"force": True}
    )
    assert response.status_code == 202
    

边界测试

  1. 大文件上传(> 10MB)
  2. 不支持的文件格式
  3. 损坏的文件
  4. 空文件
  5. 并发上传相同文件(测试去重)
  6. 解析中重复触发解析(测试防重)

依赖项

无新增依赖,使用现有库:

  • python-docx: DOCX 文件解析
  • pdfplumber: PDF 文件解析
  • striprtf: RTF 文件解析
  • aiofiles: 异步文件操作
  • httpx: HTTP 客户端(下载文件)
  • celery: 异步任务队列

后续优化建议

  1. 性能优化

    • 实现文件解析进度实时推送(WebSocket)
    • 优化大文件解析性能(分块处理)
    • 添加解析结果缓存
  2. 功能增强

    • 支持更多文件格式(PPTX, ODT, Pages)
    • 支持文件预览(前 N 行)
    • 支持批量上传和解析
  3. 监控与告警

    • 添加解析任务监控(成功率、耗时)
    • 添加文件存储监控(空间使用率)
    • 添加异常告警(解析失败率过高)
  4. 安全加固

    • 添加文件类型白名单验证
    • 添加文件大小限制
    • 添加病毒扫描

相关文档

  • 需求文档: docs/requirements/backend/04-services/project/screenplay-file-parser-service.md
  • API 文档: server/app/api/v1/screenplays.py
  • Service 文档: server/app/services/screenplay_file_parser_service.py
  • Schema 文档: server/app/schemas/screenplay.py

作者

  • 实现者: Claude Sonnet 4.5
  • 审核者: 待定
  • 日期: 2026-02-03