You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
8.8 KiB
8.8 KiB
优化文件存储路径结构
日期: 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
代码实现:
# 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
代码实现:
# 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()正常访问 - ✅ 数据库中存储的相对路径仍然有效
新旧路径共存
# 旧路径(仍可访问)
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. 管理效率提升
按日期清理:
# 删除 2025 年 1 月的所有文件
aws s3 rm s3://jointo/screenplays/parsed/2025/01/ --recursive
aws s3 rm s3://jointo/screenplays/source/2025/01/ --recursive
按日期统计:
# 统计每天的文件数量和大小
aws s3 ls s3://jointo/screenplays/parsed/2026/02/ --recursive --summarize
2. 性能优化
- ✅ 避免单个目录文件过多(按日期分散)
- ✅ 减少路径层级,提升访问速度
- ✅ 更高效的 OSS prefix 查询
3. 安全性提升
- ✅ 解析文件使用随机 UUID,不暴露业务 ID
- ✅ 原始文件使用校验和,支持去重
- ✅ 路径不包含敏感信息(用户 ID、项目 ID)
4. 运维友好
- ✅ 路径结构清晰,易于理解
- ✅ 支持按日期归档和备份
- ✅ 便于监控和统计分析
📝 数据库映射
虽然文件名是随机 UUID,但通过数据库可以追溯:
screenplays 表
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 表(原始文件)
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. 上传新剧本
curl -X POST "http://localhost:6170/api/v1/screenplays/file" \
-H "Authorization: Bearer <token>" \
-F "file=@test.pdf" \
-F "name=测试剧本" \
-F "projectId=<project_id>"
2. 检查路径
解析完成后,检查返回的 fileUrl:
{
"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. 验证文件存在
# 检查解析后的 Markdown 文件
aws s3 ls s3://jointo/screenplays/parsed/2026/02/06/
# 检查原始文件
aws s3 ls s3://jointo/screenplays/source/2026/02/06/
🔗 相关文档
✨ 总结
通过优化文件存储路径结构:
- ✅ 管理效率 ↑ - 按时间组织,易于清理和归档
- ✅ 安全性 ↑ - 随机文件名,不暴露业务 ID
- ✅ 性能 ↑ - 路径简洁,避免单目录过多文件
- ✅ 可维护性 ↑ - 结构清晰,运维友好
- ✅ 向后兼容 ✓ - 旧文件继续可用,平滑过渡