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.
 

23 KiB

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 表变更

-- ❌ 不需要新增字段!使用 attachments 的多态关联即可

-- 字段语义变更(无需修改表结构,仅调整使用方式)
-- file_url: 从 "原始文件 URL" 改为 "解析后的 Markdown 文件 URL"
-- content: 保持不变,存储解析后的文本内容

2. attachments 表使用(利用现有多态关联)

# 原始文件记录(利用现有多态关联)
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} 响应

{
  "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 表完美支持

现有表结构无需任何修改:

class Attachment(SQLModel, table=True):
    related_id: UUID          # ✅ 已存在
    related_type: SMALLINT    # ✅ 已存在 (1-7)
    attachment_purpose: SMALLINT  # ✅ 已存在 (1-6)
    # 已有索引: idx_attachments_related

⚠️ 只需补充 2 个枚举值

# RelatedType 枚举
RelatedType.SCREENPLAY = 8  # 新增:剧本类型

# AttachmentPurpose 枚举
AttachmentPurpose.SOURCE = 7  # 新增:原始文件标识

📌 开发阶段说明

当前处于开发阶段,数据库中无生产数据,无需数据迁移

实施步骤

Phase 1: 枚举值补充(无数据库变更)

# 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()

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

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 调整

# 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() 自动判断输入类型

# 向后兼容:如果是完整 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() 返回相对路径

file_url = await storage.upload_bytes(...)  # 返回 "screenplays/xxx.md"

文件去重统一返回相对路径FileStorageService 修复

# 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

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 切换、多区域存储

未来优化(可选)

可以通过数据库迁移脚本批量更新旧数据:

-- 示例:将完整 URL 转换为相对路径
UPDATE attachments 
SET file_url = regexp_replace(file_url, '^https?://[^/]+/', '')
WHERE file_url ~ '^https?://';

决策

  • 已批准
  • 已实施(渐进式迁移方案)
  • 已废弃

参考