# 修复剧本解析状态更新时序 **日期**: 2026-02-06 **类型**: Bug 修复 **关联**: RFC 140 - 剧本文件存储重构 --- ## 🐛 问题描述 在真实上传剧本文件时,前端轮询 `parse-status` 接口时**从未看到 `parsing` 状态**,直接从 `pending` 跳转到 `completed` 或 `failed`。 ### 根本原因 `ScreenplayFileParserService.parse_file()` 和 `parse_file_sync()` 方法在开始解析时,**没有更新状态为 `PARSING`**,导致状态流转缺失: ```python # ❌ 原代码(缺少状态更新) async def parse_file(self, screenplay_id: str, file_path: str, mime_type: str): logger.info("开始解析剧本文件...") try: # 直接开始下载和解析 local_file_path = await self._download_file(file_path) content = await self._parse_docx(local_file_path) # ... # 直接跳到 COMPLETED await self.repository.update(screenplay_id, { 'parsing_status': ParsingStatus.COMPLETED }) ``` ### 正确的状态流转 ``` 创建剧本 → PENDING ↓ Celery 任务启动 ↓ parse_file() 开始 → PARSING ← ⚠️ 缺失此步骤 ↓ 解析成功/失败 ↓ COMPLETED / FAILED ``` --- ## ✅ 解决方案 ### 1. 修改 `ScreenplayFileParserService` **文件**: `server/app/services/screenplay_file_parser_service.py` 在两个解析方法(`parse_file()` 和 `parse_file_sync()`)的**开始处**,先更新状态为 `PARSING`,**并立即 commit**: ```python async def parse_file(self, screenplay_id: str, file_path: str, mime_type: str): logger.info("开始解析剧本文件 | 剧本ID: %s | 类型: %s", screenplay_id, mime_type) try: # ✅ 0. 更新状态为"解析中" (RFC 140) await self.repository.update(screenplay_id, { 'parsing_status': ParsingStatus.PARSING }) await self.db.commit() # ✅ 立即提交,使状态对外部可见 logger.debug("状态已更新为 PARSING 并已提交 | 剧本ID: %s", screenplay_id) # 1. 下载文件到临时目录(如果是 URL) local_file_path = await self._download_file(file_path) # ... 后续解析逻辑 ``` #### 🔑 关键点:立即 commit **为什么需要 `commit()`?** 如果只更新状态而不 commit,外部查询(如前端轮询 `parse-status` 接口)会因为事务隔离而看不到 `PARSING` 状态: ```python # ❌ 没有 commit - 外部看不到状态 await self.repository.update({'parsing_status': ParsingStatus.PARSING}) # ... 长时间解析 ... await self.repository.update({'parsing_status': ParsingStatus.COMPLETED}) await db.commit() # 外层才 commit,外部直接从 PENDING 跳到 COMPLETED # ✅ 立即 commit - 外部能看到状态 await self.repository.update({'parsing_status': ParsingStatus.PARSING}) await self.db.commit() # ← 立即提交,状态对外可见 # ... 长时间解析 ... await self.repository.update({'parsing_status': ParsingStatus.COMPLETED}) await db.commit() # 最终状态提交 ``` ### 2. 修复硬编码状态值 在 `parse_file_sync()` 中还发现一个硬编码的 `parsing_status: 3`,已改为使用枚举: ```python # ❌ 硬编码 'parsing_status': 3, # 3=completed # ✅ 使用枚举 'parsing_status': ParsingStatus.COMPLETED, ``` --- ## 🔍 影响范围 ### 涉及文件 - `server/app/services/screenplay_file_parser_service.py` - `parse_file()` 方法 - 添加状态更新 - `parse_file_sync()` 方法 - 添加状态更新 + 修复硬编码 ### 数据库变更 无需数据迁移。 ### API 变更 无 API 变更,但现在 `GET /api/v1/screenplays/{screenplay_id}/parse-status` 接口能够正常返回 `parsing` 状态了。 --- ## 🧪 验证方法 ### 1. 上传剧本文件 ```bash curl -X POST "http://localhost:8000/api/v1/screenplays/file" \ -H "Authorization: Bearer " \ -F "file=@test.docx" \ -F "name=测试剧本" \ -F "projectId=" ``` **预期响应**: ```json { "data": { "screenplayId": "019c3248-ce62-7f72-a19f-7246551a7497", "parsingStatus": "pending", // ✅ 初始状态 "taskId": "d030a2a4-2993-41c5-86de-f95eb0da4fc0" } } ``` ### 2. 立即轮询状态 ```bash curl "http://localhost:8000/api/v1/screenplays/019c3248-ce62-7f72-a19f-7246551a7497/parse-status" \ -H "Authorization: Bearer " ``` **预期响应(Celery 任务开始后 1-2 秒内)**: ```json { "data": { "screenplayId": "019c3248-ce62-7f72-a19f-7246551a7497", "parsingStatus": "parsing", // ✅ 现在能看到此状态 "message": "正在解析中...", "progress": null } } ``` ### 3. 等待解析完成(约 5-20 秒) 再次轮询: ```json { "data": { "screenplayId": "019c3248-ce62-7f72-a19f-7246551a7497", "parsingStatus": "completed", // ✅ 最终状态 "wordCount": 8524, "fileUrl": "http://localhost:9000/jointo/screenplays/parsed/019c3248-ce62-7f72-a19f-7246551a7497.md", "message": "解析完成" } } ``` > **字段命名统一**: `fileUrl` 字段名与列表接口保持一致,统一返回解析后 Markdown 文件的完整访问 URL。 --- ## 📊 状态流转时序图 ### 修复后的完整流程 ``` 时间轴 | 用户操作 | 后端状态 | 前端显示 --------|---------------------|----------------|------------------ T+0s | 上传文件 | PENDING | "等待解析" T+1s | Celery 任务启动 | PARSING | "正在解析中..." ✅ T+2s | 下载文件 | PARSING | "正在解析中..." ✅ T+5s | 解析 DOCX | PARSING | "正在解析中..." ✅ T+8s | 上传 Markdown | PARSING | "正在解析中..." ✅ T+10s | 更新数据库 | COMPLETED | "解析完成" ✅ ``` --- ## 🎯 最佳实践 ### 1. 状态更新原则 **在长时间操作开始前,立即更新状态**: ```python # ✅ 正确做法 await update_status(PARSING) # 先更新状态 result = await long_running_task() # 再执行任务 # ❌ 错误做法 result = await long_running_task() # 任务完成才更新状态 await update_status(COMPLETED) ``` ### 2. 状态粒度设计 对于文件处理,推荐的状态流转: | 状态 | 时机 | 用户提示 | |------------|--------------------------|-----------------| | `IDLE` | 文本剧本,无需解析 | - | | `PENDING` | 文件已上传,等待 Celery 调度 | "等待解析" | | `PARSING` | Celery 任务开始执行 | "正在解析中..." | | `COMPLETED`| 解析成功,数据已保存 | "解析完成" | | `FAILED` | 解析失败(含错误消息) | "解析失败:xxx" | ### 3. 日志记录 在状态转换时记录调试日志,便于排查: ```python logger.debug("状态已更新为 PARSING | 剧本ID: %s", screenplay_id) ``` --- ## 🔗 相关文档 - RFC 140: 剧本文件存储重构 - `ParsingStatus` 枚举定义: `server/app/models/screenplay.py` - 解析服务: `server/app/services/screenplay_file_parser_service.py` - Celery 任务: `server/app/tasks/screenplay_tasks.py` - 状态查询 API: `GET /api/v1/screenplays/{screenplay_id}/parse-status` --- ## ✨ 总结 - **问题**: 解析状态从 `pending` 直接跳到 `completed`,缺少 `parsing` 中间状态 - **原因**: 解析服务开始时未更新状态 - **解决**: 在 `parse_file()` 和 `parse_file_sync()` 开始处添加状态更新 - **收益**: 前端能正确显示"正在解析中",用户体验更好 - **附加修复**: 消除硬编码状态值 `3`,改用枚举 `ParsingStatus.COMPLETED`