# 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'`。 **修复方案**: ```python # 修复前 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 **用途**: 文件上传响应模型,支持同步和异步解析场景 **字段**: ```python 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 **用途**: 手动触发文件解析请求模型 **字段**: ```python class ParseFileRequest(BaseModel): force: bool = Field(False, description="是否强制重新解析") ``` **应用场景**: - 解析失败后重试 - 强制重新解析已解析的文件 #### ParseStatusResponse **用途**: 解析状态查询响应模型 **字段**: ```python 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): 剧本文件 **智能路由策略**: ```python # 同步解析(立即返回内容) 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) } ``` **响应示例**: ```json // 同步解析 { "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): 是否强制重新解析 **应用场景**: - 解析失败后重试 - 强制重新解析已解析的文件 **验证逻辑**: ```python # 检查是否为文件类型剧本 if screenplay.type != 2: # ScreenplayType.FILE raise ValidationError("只能解析文件类型的剧本") # 检查是否已经在解析中 if screenplay.parsing_status == 'parsing' and not force: raise ValidationError("剧本正在解析中,请稍后再试") ``` **响应示例**: ```json { "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 **状态映射**: ```python status_messages = { 'idle': '未开始解析', 'pending': '等待解析', 'parsing': '正在解析文件...', 'completed': '文件解析完成', 'failed': '文件解析失败' } ``` **进度计算**: - `parsing`: 50% - `completed`: 100% - `failed`: 0% **响应示例**: ```json // 解析中 { "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 的文件去重: ```python # 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 实现异步文件解析,支持重试机制: ```python @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 | ### 字数统计算法 支持中英文混合统计: ```python 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. **上传并解析流程** ```python # 测试同步解析(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. **状态查询流程** ```python # 查询解析状态 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. **手动触发解析** ```python # 测试强制重新解析 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