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.
 

11 KiB

剧本文件存储架构重新设计

文档版本:v1.0
创建日期:2025-01-27
状态:已完成


1. 背景

在初始设计中,剧本文件通过 attachments 表管理,使用 attachments.script_id 字段关联剧本。但这种设计存在以下问题:

  1. 语义不清晰:剧本文件是剧本的核心内容,不应该作为"附件"存在
  2. 查询复杂:获取剧本文件需要 JOIN attachments 表
  3. 设计不一致:videos、project_resources 表都是直接存储文件信息,只有剧本使用附件表

2. 问题分析

2.1 现有设计

-- 剧本表
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 表

CREATE TABLE videos (
    video_id BIGINT PRIMARY KEY,
    video_url TEXT,
    file_size BIGINT,
    checksum TEXT NOT NULL,
    ...
);

project_resources 表

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 表

-- 移除 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 表

-- 移除 script_id 字段
ALTER TABLE attachments DROP COLUMN script_id;

-- 删除相关索引
DROP INDEX IF EXISTS idx_attachments_script_id;

4.2 代码变更

4.2.1 ScriptService 新增方法

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 模型更新

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: 剧本文件(必填)

响应

{
  "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 移除:

# 移除此方法
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 查询性能

旧设计

-- 获取剧本文件需要 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;

新设计

-- 直接查询,无需 JOIN
SELECT * FROM scripts WHERE script_id = 1;

5.4 去重复用

所有表都使用 FileStorageService 上传文件,统一通过 file_checksums 表去重:

# 统一的上传流程
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 迁移脚本

-- 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 回滚方案

-- 如果需要回滚
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
状态:已完成