# 优化文件存储路径结构 **日期**: 2026-02-06 **类型**: 优化 **关联**: RFC 140 - 剧本文件存储重构 --- ## 🎯 目标 优化 OSS 文件存储路径,从基于用户/项目 ID 的复杂层级,改为基于时间的扁平化结构,提升文件管理效率。 --- ## 📋 路径结构对比 ### 修改前(复杂) ``` screenplays/ └── 019c3221-b39f-7123-976f-7a0ab09eac55/ ← project_id └── 019c2d43-59a9-7e62-b325-b2bd786624d5/ ← user_id ├── b26dabc7756e23e25b722b686d537416eeebd82c14e07bd40978cf3e6ba10d5e.md ← 解析后文件 └── 1e410eab35241bcbd19a5a095c0bac42826b9176e161dd36e9fe2c56f54fa828.pdf ← 原始文件 ``` **问题**: - ❌ 路径层级深(3-4 层) - ❌ 难以按时间清理旧文件 - ❌ 单个用户目录可能有大量文件 - ❌ 不易于运维管理和监控 ### 修改后(清晰) ``` screenplays/ ├── parsed/ ← 解析后的 Markdown 文件 │ ├── 2026/ │ │ ├── 02/ │ │ │ ├── 06/ │ │ │ │ ├── 3f8a9b2c-4d1e-5f6a-7b8c-9d0e1f2a3b4c.md ← 随机 UUID │ │ │ │ ├── 7c2d4e6f-8a9b-0c1d-2e3f-4a5b6c7d8e9f.md │ │ │ │ └── a1b2c3d4-e5f6-7890-abcd-ef1234567890.md │ │ │ └── 07/ │ │ └── 03/ │ └── 2027/ └── source/ ← 原始文件(PDF/DOCX) ├── 2026/ │ ├── 02/ │ │ └── 06/ │ │ ├── 1e410eab35241bcbd19a5a095c0bac42826b9176e161dd36e9fe2c56f54fa828.pdf ← 校验和(去重) │ │ └── 3a520fab25341bcad29b6a196d648c53827e0287f272ee47fa0d3d67e65fb929.docx │ └── 03/ └── 2027/ ``` **优势**: - ✅ 按时间自然分组(年/月/日) - ✅ 易于按日期清理历史文件 - ✅ 路径简洁,层级合理 - ✅ 原始文件和解析文件分离 - ✅ 解析文件使用随机 UUID(不暴露业务 ID) - ✅ 原始文件使用校验和(支持去重) --- ## 🔧 实现细节 ### 1. 解析后文件(Markdown) **路径模式**:`screenplays/parsed/{year}/{month}/{day}/{uuid}.md` **示例**:`screenplays/parsed/2026/02/06/3f8a9b2c-4d1e-5f6a-7b8c-9d0e1f2a3b4c.md` **代码实现**: ```python # server/app/services/screenplay_file_parser_service.py async def _upload_markdown_file( self, screenplay_id: UUID, markdown_content: str, user_id: UUID ) -> str: content_bytes = markdown_content.encode('utf-8') # 按日期组织路径 + 随机唯一文件名 now = datetime.now(timezone.utc) unique_filename = str(uuid.uuid4()) # ✅ 随机 UUID object_name = f"screenplays/parsed/{now.year}/{now.month:02d}/{now.day:02d}/{unique_filename}.md" storage = StorageService() file_url = await storage.upload_bytes( data=content_bytes, object_name=object_name, content_type='text/markdown' ) return file_url # 返回相对路径 ``` **特点**: - 使用 **UUID4** 生成随机文件名,避免暴露业务 ID - 按 **UTC 时间** 组织目录结构 - 每个剧本生成唯一的 Markdown 文件(不去重) ### 2. 原始文件(PDF/DOCX) **路径模式**:`screenplays/source/{year}/{month}/{day}/{checksum}.{ext}` **示例**:`screenplays/source/2026/02/06/1e410eab35241bcbd19a5a095c0bac42826b9176e161dd36e9fe2c56f54fa828.pdf` **代码实现**: ```python # server/app/services/file_storage_service.py async def upload_file( self, file_content: bytes, filename: str, content_type: str, category: str, user_id: UUID ) -> FileMetadata: checksum = self._calculate_checksum(file_content) # 检查是否已存在(去重) existing = await self.checksum_repo.get_by_checksum(checksum) if existing: # 文件已存在,增加引用计数 updated = await self.increase_reference_count(existing.id) return FileMetadata( file_url=existing.storage_path, file_size=existing.file_size, checksum=existing.checksum, # ... ) # 按日期组织路径 extension = os.path.splitext(filename)[1] now = datetime.now(timezone.utc) date_path = f"{now.year}/{now.month:02d}/{now.day:02d}" object_name = f"{category}/source/{date_path}/{checksum}{extension}" # ✅ 校验和命名 file_url = await self.storage.upload_bytes( data=file_content, object_name=object_name, content_type=content_type ) return FileMetadata(file_url=file_url, ...) ``` **特点**: - 使用 **SHA256 校验和** 作为文件名,支持去重 - 相同内容的文件只存储一次 - 通过 `file_checksums` 表管理引用计数 --- ## 📊 路径对比表 | 文件类型 | 修改前 | 修改后 | 文件名策略 | |---------|-------|-------|----------| | 原始 PDF | `screenplays/{project_id}/{user_id}/{checksum}.pdf` | `screenplays/source/2026/02/06/{checksum}.pdf` | 校验和(去重) | | 原始 DOCX | `screenplays/{project_id}/{user_id}/{checksum}.docx` | `screenplays/source/2026/02/06/{checksum}.docx` | 校验和(去重) | | 解析 Markdown | `screenplays/{screenplay_id}/{user_id}/{checksum}.md` | `screenplays/parsed/2026/02/06/{uuid}.md` | 随机 UUID | --- ## 🔄 向后兼容性 ### 旧文件不受影响 - ✅ 已上传的旧文件保持原路径不变 - ✅ 可通过 `build_file_url()` 正常访问 - ✅ 数据库中存储的相对路径仍然有效 ### 新旧路径共存 ```python # 旧路径(仍可访问) screenplays/019c3221-b39f-7123-976f-7a0ab09eac55/019c2d43-59a9-7e62-b325-b2bd786624d5/xxx.md # 新路径(新上传的文件) screenplays/parsed/2026/02/06/3f8a9b2c-4d1e-5f6a-7b8c-9d0e1f2a3b4c.md ``` ### 渐进式迁移 新上传的文件使用新路径,旧文件保持不变,实现平滑过渡。 --- ## 🎯 优势总结 ### 1. 管理效率提升 **按日期清理**: ```bash # 删除 2025 年 1 月的所有文件 aws s3 rm s3://jointo/screenplays/parsed/2025/01/ --recursive aws s3 rm s3://jointo/screenplays/source/2025/01/ --recursive ``` **按日期统计**: ```bash # 统计每天的文件数量和大小 aws s3 ls s3://jointo/screenplays/parsed/2026/02/ --recursive --summarize ``` ### 2. 性能优化 - ✅ 避免单个目录文件过多(按日期分散) - ✅ 减少路径层级,提升访问速度 - ✅ 更高效的 OSS prefix 查询 ### 3. 安全性提升 - ✅ 解析文件使用随机 UUID,不暴露业务 ID - ✅ 原始文件使用校验和,支持去重 - ✅ 路径不包含敏感信息(用户 ID、项目 ID) ### 4. 运维友好 - ✅ 路径结构清晰,易于理解 - ✅ 支持按日期归档和备份 - ✅ 便于监控和统计分析 --- ## 📝 数据库映射 虽然文件名是随机 UUID,但通过数据库可以追溯: ### screenplays 表 ```sql SELECT screenplay_id, name, file_url, -- 存储:screenplays/parsed/2026/02/06/{uuid}.md created_at FROM screenplays WHERE screenplay_id = '019c3272-3975-7372-a428-4790c30b7658'; ``` ### attachments 表(原始文件) ```sql SELECT attachment_id, original_name, file_url, -- 存储:screenplays/source/2026/02/06/{checksum}.pdf related_id, -- 关联到 screenplay_id created_at FROM attachments WHERE related_type = 'screenplay' AND related_id = '019c3272-3975-7372-a428-4790c30b7658'; ``` --- ## 🧪 验证方法 ### 1. 上传新剧本 ```bash curl -X POST "http://localhost:6170/api/v1/screenplays/file" \ -H "Authorization: Bearer " \ -F "file=@test.pdf" \ -F "name=测试剧本" \ -F "projectId=" ``` ### 2. 检查路径 解析完成后,检查返回的 `fileUrl`: ```json { "data": { "screenplayId": "019c3278-1234-5678-90ab-cdef12345678", "parsingStatus": "completed", "fileUrl": "http://localhost:9000/jointo/screenplays/parsed/2026/02/06/a1b2c3d4-e5f6-7890-abcd-ef1234567890.md" } } ``` ### 3. 验证文件存在 ```bash # 检查解析后的 Markdown 文件 aws s3 ls s3://jointo/screenplays/parsed/2026/02/06/ # 检查原始文件 aws s3 ls s3://jointo/screenplays/source/2026/02/06/ ``` --- ## 🔗 相关文档 - [RFC 140: 剧本文件存储重构](../rfcs/140-screenplay-file-storage-refactor.md) - [URL 存储策略](../../app/core/STORAGE_URL_HELPERS.md) - [文件存储服务](../../app/services/file_storage_service.py) - [剧本解析服务](../../app/services/screenplay_file_parser_service.py) --- ## ✨ 总结 通过优化文件存储路径结构: - ✅ **管理效率** ↑ - 按时间组织,易于清理和归档 - ✅ **安全性** ↑ - 随机文件名,不暴露业务 ID - ✅ **性能** ↑ - 路径简洁,避免单目录过多文件 - ✅ **可维护性** ↑ - 结构清晰,运维友好 - ✅ **向后兼容** ✓ - 旧文件继续可用,平滑过渡