# RFC 140: 剧本文件存储架构重构 ## 元数据 - **RFC 编号**: 140 - **标题**: 剧本文件存储架构重构 - **状态**: 草案 - **创建日期**: 2026-02-06 - **作者**: AI Assistant - **类型**: 重构 ## 背景 ### 当前问题 1. **职责混乱**: `screenplays.file_url` 既存储原始上传文件(DOCX/PDF),又在某些场景下可能指向解析后的内容 2. **缺少原始文件管理**: 原始文件信息分散,难以管理和追溯 3. **附件表未充分利用**: 项目已有 `attachments` 表但未用于存储剧本原始文件 ### 目标 1. 明确文件存储职责 2. 原始文件统一通过 `attachments` 表管理 3. `screenplays.file_url` 专门存储解析后的 Markdown 文件 4. 保持向后兼容,支持渐进式迁移 ## 核心改进 ### 架构对比 #### 当前架构(混乱)❌ ``` screenplays 表 ├── file_url → 原始 DOCX/PDF ❌ (职责不清) └── content → 解析后文本 ``` **问题**: - `file_url` 既存储原始文件,又可能存储解析后文件 - 原始文件无法追溯和管理 - 无法重新解析原始文件 #### 改进后架构(清晰)✅ ``` attachments 表 └── file_url → 原始 DOCX/PDF ✅ (专职管理原始文件) ├── related_type: SCREENPLAY ├── related_id: screenplay_id └── attachment_purpose: SOURCE screenplays 表 ├── file_url → 解析后的 Markdown 文件 ✅ (专职管理解析结果) └── content → 解析后文本 ``` **优势**: - 职责清晰:attachments 管理原始文件,screenplays 管理解析结果 - 可追溯:原始文件永久保留,支持重新解析 - 可扩展:利用现有多态关联,无需新增字段 ### 数据流 #### 完整流程图 ``` 用户上传文件 (user.docx) ↓ ┌──────────────────────────────────────────┐ │ 1. 创建 Attachment (原始文件) │ │ file_url: minio/.../user.docx │ │ attachment_purpose: SOURCE │ │ related_type: SCREENPLAY │ │ related_id: screenplay_id │ └──────────────────────────────────────────┘ ↓ ┌──────────────────────────────────────────┐ │ 2. 创建 Screenplay (待解析) │ │ file_url: NULL │ │ content: NULL │ │ parsing_status: PENDING │ └──────────────────────────────────────────┘ ↓ ┌──────────────────────────────────────────┐ │ 3. Celery 任务触发 │ │ - 通过多态关联查询 Attachment │ │ - 下载原始文件 │ │ - 解析为文本 │ └──────────────────────────────────────────┘ ↓ ┌──────────────────────────────────────────┐ │ 4. 生成 Markdown 文件 │ │ - 格式化为 Markdown │ │ - 上传到 MinIO │ │ markdown_url: minio/.../parsed.md │ └──────────────────────────────────────────┘ ↓ ┌──────────────────────────────────────────┐ │ 5. 更新 Screenplay (解析完成) │ │ file_url: markdown_url ✅ │ │ content: "解析后的文本" │ │ parsing_status: COMPLETED │ └──────────────────────────────────────────┘ ``` #### 关键变化 | 阶段 | 当前实现 | 改进后实现 | 变化说明 | |------|---------|-----------|---------| | **上传** | `screenplay.file_url` = 原始文件 URL | `attachment.file_url` = 原始文件 URL | 原始文件移至 attachments | | **解析中** | `screenplay.file_url` 不变 | `screenplay.file_url` = NULL | 明确待解析状态 | | **解析完成** | `screenplay.file_url` 被覆盖 ❌ | `screenplay.file_url` = Markdown URL ✅ | 原始文件得以保留 | | **查询原始文件** | 无法查询(已覆盖)❌ | 通过多态关联查询 attachment ✅ | 支持重新解析 | ## 方案设计 ### 数据模型调整 #### 1. `screenplays` 表变更 ```sql -- ❌ 不需要新增字段!使用 attachments 的多态关联即可 -- 字段语义变更(无需修改表结构,仅调整使用方式) -- file_url: 从 "原始文件 URL" 改为 "解析后的 Markdown 文件 URL" -- content: 保持不变,存储解析后的文本内容 ``` #### 2. `attachments` 表使用(利用现有多态关联) ```python # 原始文件记录(利用现有多态关联) Attachment( related_type=RelatedType.SCREENPLAY, # ✅ 已存在:关联类型 related_id=screenplay_id, # ✅ 已存在:关联 ID file_url="http://minio/jointo/screenplays/{user_id}/{hash}.docx", file_name="剧本原稿.docx", file_size=1024000, mime_type="application/vnd.openxmlformats-officedocument.wordprocessingml.document", category=AttachmentCategory.DOCUMENT, attachment_purpose=AttachmentPurpose.SOURCE, # ✅ 关键:标识为源文件 ... ) # 查询原始文件 source_attachment = await db.exec( select(Attachment) .where(Attachment.related_type == RelatedType.SCREENPLAY) .where(Attachment.related_id == screenplay_id) .where(Attachment.attachment_purpose == AttachmentPurpose.SOURCE) ).first() ``` ### 数据流程 #### 上传并解析流程 ``` ┌─────────────────────────────────────────────────────────────┐ │ 1. 用户上传文件 (upload-and-parse) │ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ 2. 创建 Attachment 记录(原始文件) │ │ - file_url: MinIO 原始文件 URL │ │ - attachment_purpose: SOURCE │ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ 3. 创建 Screenplay 记录 │ │ - file_url: NULL (待解析后生成) │ │ - content: NULL │ │ - parsing_status: PENDING │ │ (通过 attachments 多态关联查找原始文件) │ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ 4. Celery 任务解析文件 │ │ - 从 attachment.file_url 下载原始文件 │ │ - 解析为文本内容 │ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ 5. 生成 Markdown 文件并上传 │ │ - 格式化为 Markdown │ │ - 上传到 MinIO: screenplays/{id}/parsed.md │ └─────────────────────────────────────────────────────────────┘ ↓ ┌─────────────────────────────────────────────────────────────┐ │ 6. 更新 Screenplay 记录 │ │ - file_url: Markdown 文件 URL │ │ - content: 解析后的文本 │ │ - parsing_status: COMPLETED │ └─────────────────────────────────────────────────────────────┘ ``` ### API 响应格式调整 #### `GET /api/v1/screenplays/{id}` 响应 ```json { "screenplayId": "xxx", "name": "我的剧本", "content": "解析后的文本内容...", "fileUrl": "http://minio/jointo/screenplays/xxx/parsed.md", // Markdown 文件 "sourceFile": { // 新增:原始文件信息 "attachmentId": "yyy", "fileName": "剧本原稿.docx", "fileSize": 1024000, "mimeType": "application/vnd.openxmlformats-officedocument.wordprocessingml.document", "fileUrl": "http://minio/jointo/attachments/yyy.docx" }, "parsingStatus": "completed" } ``` ## 现状分析 ### ✅ Attachments 表完美支持 现有表结构无需任何修改: ```python class Attachment(SQLModel, table=True): related_id: UUID # ✅ 已存在 related_type: SMALLINT # ✅ 已存在 (1-7) attachment_purpose: SMALLINT # ✅ 已存在 (1-6) # 已有索引: idx_attachments_related ``` ### ⚠️ 只需补充 2 个枚举值 ```python # RelatedType 枚举 RelatedType.SCREENPLAY = 8 # 新增:剧本类型 # AttachmentPurpose 枚举 AttachmentPurpose.SOURCE = 7 # 新增:原始文件标识 ``` ### 📌 开发阶段说明 当前处于开发阶段,数据库中无生产数据,**无需数据迁移**。 ## 实施步骤 ### Phase 1: 枚举值补充(无数据库变更) ```python # app/models/attachment.py class RelatedType(IntEnum): """关联实体类型枚举""" USER = 1 PROJECT = 2 STORYBOARD = 3 CHARACTER = 4 SCENE = 5 PROP = 6 LOCATION = 7 SCREENPLAY = 8 # ✅ 新增 @classmethod def from_string(cls, value: str) -> "RelatedType": mapping = { 'user': cls.USER, 'project': cls.PROJECT, 'storyboard': cls.STORYBOARD, 'character': cls.CHARACTER, 'scene': cls.SCENE, 'prop': cls.PROP, 'location': cls.LOCATION, 'screenplay': cls.SCREENPLAY, # ✅ 新增 } return mapping.get(value.lower(), cls.PROJECT) def to_string(self) -> str: mapping = { self.USER: 'user', self.PROJECT: 'project', self.STORYBOARD: 'storyboard', self.CHARACTER: 'character', self.SCENE: 'scene', self.PROP: 'prop', self.LOCATION: 'location', self.SCREENPLAY: 'screenplay', # ✅ 新增 } return mapping[self] @classmethod def get_display_name(cls, value: int) -> str: names = { cls.USER: '用户', cls.PROJECT: '项目', cls.STORYBOARD: '分镜', cls.CHARACTER: '角色', cls.SCENE: '场景', cls.PROP: '道具', cls.LOCATION: '地点', cls.SCREENPLAY: '剧本', # ✅ 新增 } return names.get(value, '未知类型') class AttachmentPurpose(IntEnum): """附件用途枚举""" AVATAR = 1 COVER = 2 THUMBNAIL = 3 DOCUMENT = 4 REFERENCE = 5 ATTACHMENT = 6 SOURCE = 7 # ✅ 新增:原始文件(用于剧本等需要保留原始文件的场景) @classmethod def from_string(cls, value: str) -> "AttachmentPurpose": mapping = { 'avatar': cls.AVATAR, 'cover': cls.COVER, 'thumbnail': cls.THUMBNAIL, 'document': cls.DOCUMENT, 'reference': cls.REFERENCE, 'attachment': cls.ATTACHMENT, 'source': cls.SOURCE, # ✅ 新增 } return mapping.get(value.lower(), cls.ATTACHMENT) def to_string(self) -> str: mapping = { self.AVATAR: 'avatar', self.COVER: 'cover', self.THUMBNAIL: 'thumbnail', self.DOCUMENT: 'document', self.REFERENCE: 'reference', self.ATTACHMENT: 'attachment', self.SOURCE: 'source', # ✅ 新增 } return mapping[self] @classmethod def get_display_name(cls, value: int) -> str: names = { cls.AVATAR: '头像', cls.COVER: '封面', cls.THUMBNAIL: '缩略图', cls.DOCUMENT: '文档', cls.REFERENCE: '参考资料', cls.ATTACHMENT: '附件', cls.SOURCE: '原始文件', # ✅ 新增 } return names.get(value, '未知用途') ``` ### Phase 2: 代码重构 #### 2.1 修改 `ScreenplayService.create_screenplay_from_file()` ```python async def create_screenplay_from_file( self, user_id: UUID, project_id: UUID, name: str, file_content: bytes, file_name: str, mime_type: str ) -> Screenplay: """从上传的文件创建剧本""" # 1. 创建剧本记录(file_url 暂时为空) screenplay = Screenplay( project_id=project_id, name=name, type=ScreenplayType.FILE, file_url=None, # ✅ 待解析后生成 Markdown URL parsing_status=ParsingStatus.PENDING, status=ScreenplayStatus.DRAFT, created_by=user_id, updated_by=user_id ) created_screenplay = await self.repository.create(screenplay) await self.db.flush() # 获取 screenplay_id # 2. 上传原始文件并创建 Attachment 记录(多态关联) from app.services.attachment_service import AttachmentService attachment_service = AttachmentService(self.db) attachment = await attachment_service.create_attachment( file_content=file_content, file_name=file_name, mime_type=mime_type, category=AttachmentCategory.DOCUMENT, attachment_purpose=AttachmentPurpose.SOURCE, # ✅ 标识为原始文件 related_type=RelatedType.SCREENPLAY, # ✅ 多态关联 related_id=created_screenplay.screenplay_id, # ✅ 关联剧本 user_id=user_id ) await self.db.commit() return created_screenplay ``` #### 2.2 修改 `parse_screenplay_file_task` ```python async def _parse_file_async(screenplay_id: str): async_session_maker, engine = get_async_session() try: async with async_session_maker() as db: # 1. 查询原始文件附件(通过多态关联) source_attachment = await db.exec( select(Attachment) .where(Attachment.related_type == RelatedType.SCREENPLAY) .where(Attachment.related_id == screenplay_id) .where(Attachment.attachment_purpose == AttachmentPurpose.SOURCE) ).first() if not source_attachment: raise ValueError("剧本未关联原始文件") # 2. 从 attachment 获取原始文件 URL file_url = source_attachment.file_url mime_type = source_attachment.mime_type # 3. 解析文件 parser_service = ScreenplayFileParserService(db) content = await parser_service.parse_file_content(file_url, mime_type) # 4. 生成 Markdown 文件 markdown_content = format_as_markdown(content) markdown_file_url = await upload_parsed_markdown( screenplay_id=screenplay_id, content=markdown_content ) # 5. 更新剧本记录 await update_screenplay( db=db, screenplay_id=screenplay_id, content=content, file_url=markdown_file_url, # ✅ Markdown 文件 URL parsing_status=ParsingStatus.COMPLETED ) await db.commit() return { 'screenplayId': screenplay_id, 'content': content, 'markdownUrl': markdown_file_url, 'wordCount': len(content), 'status': 'success' } finally: await engine.dispose() ``` ### Phase 3: Schema 调整 ```python # app/schemas/screenplay.py class SourceFileInfo(BaseModel): """原始文件信息""" attachment_id: UUID = Field(..., alias="attachmentId") file_name: str = Field(..., alias="fileName") file_size: int = Field(..., alias="fileSize") mime_type: str = Field(..., alias="mimeType") file_url: str = Field(..., alias="fileUrl") model_config = ConfigDict(populate_by_name=True) class ScreenplayResponse(BaseModel): """剧本响应模型""" screenplay_id: UUID = Field(..., alias="screenplayId") name: str content: Optional[str] = None file_url: Optional[str] = Field(None, alias="fileUrl") # Markdown URL source_file: Optional[SourceFileInfo] = Field(None, alias="sourceFile") # ✅ 新增 parsing_status: str = Field(..., alias="parsingStatus") # ... 其他字段 ``` ## 优势 ### 1. 职责清晰 - **Attachments**: 管理所有原始上传文件 - **Screenplays**: 管理解析后的内容和 Markdown 文件 ### 2. 可追溯性 - 始终保留原始文件 - 可以重新解析(从 `source_attachment_id` 获取原始文件) ### 3. 文件管理统一 - 所有上传文件通过 `attachments` 表统一管理 - 文件去重、引用计数等功能复用 ### 4. 向后兼容 - 通过数据迁移脚本平滑过渡 - 现有 API 可以继续工作(增加字段,不删除) ## 风险评估 ### ✅ 低风险项 1. **表结构变更**: 无(零表结构变更) 2. **数据迁移**: 无(开发阶段无历史数据) 3. **部署停机**: 无(代码热更新) ### ⚠️ 需注意的风险 #### 1. 存储空间增加 **影响**: 原始文件 + Markdown 文件双份存储 **缓解措施**: - Markdown 文件很小(纯文本),增加有限(约 5-10% 额外空间) - 原始文件通过 `file_checksums` 表去重 - 可选:设置 Markdown 文件过期策略 #### 2. API 响应字段变化 **影响**: 前端需要适配新增的 `sourceFile` 字段 **缓解措施**: - 新增字段而非修改现有字段(向后兼容) - `sourceFile` 字段可选,不影响现有功能 - 前端可以渐进式适配(先忽略,后使用) #### 3. AttachmentService 调用变化 **影响**: `create_attachment()` 新增 `attachment_purpose` 参数 **缓解措施**: - 提供默认值 `attachment_purpose=AttachmentPurpose.ATTACHMENT` - 不破坏现有调用 - 逐步更新其他调用点 ## 执行建议 ### 推荐执行顺序 ``` Phase 1: 枚举值补充 (10分钟) ↓ Phase 2: Service 重构 (1-2小时) ↓ Phase 3: Schema 调整 (30分钟) ↓ 测试验证 (30分钟) ``` ### 方案优势 ✅ **零表结构变更** - 完全利用现有多态关联设计 - 无需 ALTER TABLE - 无需新增索引 - 零停机部署 ✅ **最小代码改动** - 只需补充 2 个枚举值 - 核心逻辑清晰易懂 - 易于维护和扩展 ✅ **架构清晰** - 职责分离:attachments 管原始文件,screenplays 管解析结果 - 可追溯:原始文件永久保留 - 可重新解析:支持文件重新处理 ✅ **开发阶段友好** - 无历史数据迁移负担 - 快速实施(半天完成) - 低风险部署 ### 预估时间 **总计: 2-3 小时** - Phase 1 (枚举值): 10 分钟 - Phase 2 (Service): 1-2 小时 - Phase 3 (Schema): 30 分钟 - 测试验证: 30 分钟 ## URL 存储策略(渐进式迁移) ### 当前实施方案(2026-02-06) ✅ **智能 URL 构建**:`build_file_url()` 自动判断输入类型 ```python # 向后兼容:如果是完整 URL,直接返回 build_file_url("https://oss.example.com/xxx.md") # → "https://oss.example.com/xxx.md" # 新数据:如果是相对路径,拼接域名 build_file_url("screenplays/xxx.md") # → "https://oss.example.com/screenplays/xxx.md" ``` ✅ **新数据存储相对路径**:`StorageService.upload_bytes()` 返回相对路径 ```python file_url = await storage.upload_bytes(...) # 返回 "screenplays/xxx.md" ``` ✅ **文件去重统一返回相对路径**:`FileStorageService` 修复 ```python # app/services/file_storage_service.py async def upload_file(self, ...) -> FileMetadata: existing = await self.checksum_repo.get_by_checksum(checksum) if existing: # ✅ 使用 storage_path(相对路径),而不是 file_url(可能是旧的完整 URL) return FileMetadata( file_url=existing.storage_path, # 统一返回相对路径 ... ) ``` ✅ **Schema 计算字段**:API 响应自动生成完整 URL ```python class ScreenplayResponse(BaseModel): file_url: str # 存储相对路径 "screenplays/xxx.md" @computed_field(alias="parsedFileUrl") @property def parsed_file_url(self) -> str: return build_file_url(self.file_url) # 返回完整 URL ``` ### 优势 - ✅ **渐进式迁移**:旧数据仍然可用(完整 URL),新数据使用相对路径 - ✅ **零停机**:无需数据库迁移,部署即生效 - ✅ **域名无关**:便于多环境部署(dev/staging/prod) - ✅ **未来可扩展**:支持 CDN 切换、多区域存储 ### 未来优化(可选) 可以通过数据库迁移脚本批量更新旧数据: ```sql -- 示例:将完整 URL 转换为相对路径 UPDATE attachments SET file_url = regexp_replace(file_url, '^https?://[^/]+/', '') WHERE file_url ~ '^https?://'; ``` ## 决策 - [x] 已批准 - [x] 已实施(渐进式迁移方案) - [ ] 已废弃 ## 参考 - [Attachment 表设计](../../../requirements/backend/03-models/attachment.md) - [文件存储服务](../../../requirements/backend/04-services/file-storage.md) - [URL 管理辅助函数文档](../../../server/app/core/STORAGE_URL_HELPERS.md)