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

优化文件存储路径结构

日期: 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
  • 性能 ↑ - 路径简洁,避免单目录过多文件
  • 可维护性 ↑ - 结构清晰,运维友好
  • 向后兼容 ✓ - 旧文件继续可用,平滑过渡