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
23 KiB
RFC 140: 剧本文件存储架构重构
元数据
- RFC 编号: 140
- 标题: 剧本文件存储架构重构
- 状态: 草案
- 创建日期: 2026-02-06
- 作者: AI Assistant
- 类型: 重构
背景
当前问题
- 职责混乱:
screenplays.file_url既存储原始上传文件(DOCX/PDF),又在某些场景下可能指向解析后的内容 - 缺少原始文件管理: 原始文件信息分散,难以管理和追溯
- 附件表未充分利用: 项目已有
attachments表但未用于存储剧本原始文件
目标
- 明确文件存储职责
- 原始文件统一通过
attachments表管理 screenplays.file_url专门存储解析后的 Markdown 文件- 保持向后兼容,支持渐进式迁移
核心改进
架构对比
当前架构(混乱)❌
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. 存储空间增加
影响: 原始文件 + 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?://';
决策
- 已批准
- 已实施(渐进式迁移方案)
- 已废弃