# 剧本文件存储架构重新设计 > **文档版本**:v1.0 > **创建日期**:2025-01-27 > **状态**:已完成 --- ## 1. 背景 在初始设计中,剧本文件通过 `attachments` 表管理,使用 `attachments.script_id` 字段关联剧本。但这种设计存在以下问题: 1. **语义不清晰**:剧本文件是剧本的核心内容,不应该作为"附件"存在 2. **查询复杂**:获取剧本文件需要 JOIN attachments 表 3. **设计不一致**:videos、project_resources 表都是直接存储文件信息,只有剧本使用附件表 --- ## 2. 问题分析 ### 2.1 现有设计 ```sql -- 剧本表 CREATE TABLE scripts ( script_id BIGINT PRIMARY KEY, project_id BIGINT NOT NULL, name TEXT NOT NULL, type TEXT NOT NULL, -- 'text' | 'attachment' content TEXT, -- 文本剧本内容 attachment_id BIGINT REFERENCES attachments(attachment_id), -- 文件剧本关联 ... ); -- 附件表 CREATE TABLE attachments ( attachment_id BIGINT PRIMARY KEY, script_id BIGINT REFERENCES scripts(script_id), -- 反向关联 file_url TEXT NOT NULL, ... ); ``` **问题**: - 双向关联(scripts.attachment_id + attachments.script_id) - 剧本文件被当作"附件"处理 - 与 videos、project_resources 表设计不一致 ### 2.2 对比其他表 **videos 表**: ```sql CREATE TABLE videos ( video_id BIGINT PRIMARY KEY, video_url TEXT, file_size BIGINT, checksum TEXT NOT NULL, ... ); ``` **project_resources 表**: ```sql CREATE TABLE project_resources ( project_resource_id BIGINT PRIMARY KEY, file_url TEXT NOT NULL, file_size BIGINT, checksum TEXT NOT NULL, ... ); ``` **结论**:videos 和 project_resources 都是直接存储文件信息,不依赖 attachments 表。 --- ## 3. 方案设计 ### 3.1 方案对比 | 方案 | 描述 | 优点 | 缺点 | | ------------------- | ---------------------------------------------------------- | ---------------------- | -------------------------- | | **方案A:保持现状** | 继续使用 attachments 表 | 无需改动 | 语义不清晰,设计不一致 | | **方案B:完全独立** | scripts 表直接存储文件信息,不使用 FileStorageService | 完全独立,无依赖 | 无法复用去重逻辑,代码重复 | | **方案C:混合方案** | scripts 表直接存储文件信息,但使用 FileStorageService 上传 | 设计一致,复用去重逻辑 | 需要更新代码 | ### 3.2 最终选择:方案C(混合方案) **理由**: 1. **设计一致**:与 videos、project_resources 表保持一致 2. **语义清晰**:剧本文件是剧本的核心内容,不是"附件" 3. **复用去重**:使用 FileStorageService 上传文件,复用去重逻辑 4. **查询性能**:无需 JOIN attachments 表 --- ## 4. 实施方案 ### 4.1 数据库变更 #### 4.1.1 更新 scripts 表 ```sql -- 移除 attachment_id 字段 ALTER TABLE scripts DROP COLUMN attachment_id; -- 新增文件字段 ALTER TABLE scripts ADD COLUMN file_url TEXT; ALTER TABLE scripts ADD COLUMN file_size BIGINT; ALTER TABLE scripts ADD COLUMN mime_type TEXT; ALTER TABLE scripts ADD COLUMN checksum TEXT; ALTER TABLE scripts ADD COLUMN storage_path TEXT; -- 更新类型枚举 ALTER TYPE script_type RENAME VALUE 'attachment' TO 'file'; -- 新增索引 CREATE INDEX idx_scripts_checksum ON scripts (checksum) WHERE checksum IS NOT NULL; -- 更新约束 ALTER TABLE scripts DROP CONSTRAINT IF EXISTS scripts_content_check; ALTER TABLE scripts ADD CONSTRAINT scripts_content_check CHECK ( (type = 'text' AND content IS NOT NULL AND file_url IS NULL) OR (type = 'file' AND file_url IS NOT NULL AND content IS NULL) ); ``` #### 4.1.2 更新 attachments 表 ```sql -- 移除 script_id 字段 ALTER TABLE attachments DROP COLUMN script_id; -- 删除相关索引 DROP INDEX IF EXISTS idx_attachments_script_id; ``` ### 4.2 代码变更 #### 4.2.1 ScriptService 新增方法 ```python async def create_script_from_file( self, user_id: int, project_id: int, name: str, file: UploadFile ) -> Script: """创建文件剧本""" # 1. 验证文件类型(支持 TXT、DOC、DOCX、PDF、RTF、Markdown) if file.content_type not in self.ALLOWED_FILE_TYPES: raise ValidationError(f"不支持的文件类型: {file.content_type}") # 2. 读取文件内容 content = await file.read() file_size = len(content) # 3. 验证文件大小 if file_size > self.MAX_FILE_SIZE: raise ValidationError("文件大小超过限制") # 4. 使用 FileStorageService 上传(带去重) file_meta = await self.file_storage.upload_file( file_content=content, filename=file.filename, content_type=file.content_type, category='script', user_id=user_id ) # 5. 创建剧本记录 script = Script( project_id=project_id, name=name, type='file', file_url=file_meta.file_url, file_size=file_meta.file_size, mime_type=file.content_type, checksum=file_meta.checksum, storage_path=file_meta.storage_path, status='draft', created_by=user_id, updated_by=user_id ) return await self.repository.create(script) ``` #### 4.2.2 Script 模型更新 ```python class ScriptType(str, enum.Enum): TEXT = "text" FILE = "file" # 改名:attachment -> file class Script(Base): __tablename__ = "scripts" script_id = Column(Integer, primary_key=True) project_id = Column(Integer, ForeignKey('projects.id'), nullable=False) name = Column(String(255), nullable=False) type = Column(Enum(ScriptType), nullable=False) # 文本剧本字段 content = Column(Text) # 文件剧本字段(新增) file_url = Column(String(500)) file_size = Column(BigInteger) mime_type = Column(String(100)) checksum = Column(String(64)) storage_path = Column(String(500)) # 移除 attachment_id # attachment_id = Column(Integer, ForeignKey('attachments.id')) ``` ### 4.3 API 变更 #### 4.3.1 新增接口 ``` POST /api/v1/scripts/upload ``` **请求**(multipart/form-data): - `project_id`: 项目 ID(必填) - `name`: 剧本名称(必填) - `file`: 剧本文件(必填) **响应**: ```json { "script_id": 2, "project_id": 1, "name": "第一集剧本", "type": "file", "file_url": "https://storage.jointo.ai/scripts/1/abc123.pdf", "file_size": 1024000, "mime_type": "application/pdf", "checksum": "abc123...", "version": 1, "status": "draft", "created_at": "2025-01-27T10:00:00Z" } ``` #### 4.3.2 移除接口 从 AttachmentService 移除: ```python # 移除此方法 async def get_attachments_by_script( self, user_id: int, script_id: int, page: int = 1, page_size: int = 20 ) -> Dict[str, Any]: ... ``` --- ## 5. 优势总结 ### 5.1 设计一致性 | 表名 | 文件存储方式 | 去重机制 | | ----------------- | --------------------------- | ------------------ | | videos | 直接存储 file_url, checksum | FileStorageService | | project_resources | 直接存储 file_url, checksum | FileStorageService | | scripts | 直接存储 file_url, checksum | FileStorageService | | attachments | 直接存储 file_url, checksum | FileStorageService | **结论**:所有表都使用相同的设计模式,便于维护。 ### 5.2 语义清晰 - **剧本文件**:scripts 表自己管理(核心内容) - **项目素材**:project_resources 表管理(角色、场景、道具) - **项目附件**:attachments 表管理(合同、参考资料、设计稿) ### 5.3 查询性能 **旧设计**: ```sql -- 获取剧本文件需要 JOIN SELECT s.*, a.file_url, a.file_size FROM scripts s LEFT JOIN attachments a ON s.attachment_id = a.attachment_id WHERE s.script_id = 1; ``` **新设计**: ```sql -- 直接查询,无需 JOIN SELECT * FROM scripts WHERE script_id = 1; ``` ### 5.4 去重复用 所有表都使用 `FileStorageService` 上传文件,统一通过 `file_checksums` 表去重: ```python # 统一的上传流程 file_meta = await self.file_storage.upload_file( file_content=content, filename=file.filename, content_type=file.content_type, category='script', # 或 'video', 'resource', 'attachment' user_id=user_id ) ``` --- ## 6. 数据迁移 ### 6.1 迁移脚本 ```sql -- 1. 备份数据 CREATE TABLE scripts_backup AS SELECT * FROM scripts; CREATE TABLE attachments_backup AS SELECT * FROM attachments; -- 2. 迁移剧本文件数据 UPDATE scripts s SET file_url = a.file_url, file_size = a.file_size, mime_type = a.mime_type, checksum = a.checksum, storage_path = a.storage_path FROM attachments a WHERE s.attachment_id = a.attachment_id AND s.type = 'attachment'; -- 3. 更新类型枚举 UPDATE scripts SET type = 'file' WHERE type = 'attachment'; -- 4. 删除旧字段 ALTER TABLE scripts DROP COLUMN attachment_id; ALTER TABLE attachments DROP COLUMN script_id; -- 5. 验证数据 SELECT COUNT(*) FROM scripts WHERE type = 'file' AND file_url IS NULL; -- 应该为 0 ``` ### 6.2 回滚方案 ```sql -- 如果需要回滚 DROP TABLE scripts; DROP TABLE attachments; CREATE TABLE scripts AS SELECT * FROM scripts_backup; CREATE TABLE attachments AS SELECT * FROM attachments_backup; ``` --- ## 7. 影响范围 ### 7.1 受影响的文档 - ✅ `docs/需求/database-design.md`:已更新 scripts 表结构 - ✅ `docs/需求/backend/04-services/script-service.md`:已更新服务实现和 API - ✅ `docs/需求/backend/04-services/attachment-service.md`:已移除 script_id 相关内容 ### 7.2 受影响的代码 - ✅ `app/models/script.py`:Script 模型 - ✅ `app/services/script_service.py`:ScriptService 类 - ✅ `app/services/attachment_service.py`:AttachmentService 类 - ✅ `app/schemas/script.py`:ScriptCreate、ScriptResponse 等 - ✅ `app/api/v1/scripts.py`:API 路由 ### 7.3 受影响的测试 - `tests/services/test_script_service.py`:需要更新测试用例 - `tests/api/test_scripts.py`:需要更新 API 测试 --- ## 8. 总结 通过此次重构,我们实现了: 1. **设计一致性**:scripts、videos、project_resources 表使用相同的文件存储模式 2. **语义清晰**:剧本文件不再作为"附件",而是剧本的核心内容 3. **性能优化**:无需 JOIN attachments 表,查询更快 4. **代码复用**:统一使用 FileStorageService 实现文件上传和去重 此次调整符合 DRY(Don't Repeat Yourself)和 SOLID 原则,提升了系统的可维护性和扩展性。 --- **文档版本**:v1.0 **创建日期**:2025-01-27 **状态**:已完成