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.
14 KiB
14 KiB
附件服务多态关联设计
变更类型:架构升级
影响范围:附件服务
日期:2026-01-28
变更概述
将附件服务从单一 project_id 关联升级为多态关联设计,使用 related_id + related_type + attachment_purpose 三元组,支持任意实体的附件管理。
变更动机
当前设计问题
- 扩展性差:每增加一个业务场景(如角色头像、场景图片),需要修改表结构
- 查询复杂:无法统一查询"某个实体的所有附件"
- 用途不明:无法区分同一实体的不同用途附件(封面 vs 缩略图)
- 关联分散:一部分在 attachments 表(project_id),一部分在业务表(avatar_id)
多态关联优势
✅ 高度可扩展:新增业务场景(如道具、地点)无需修改表结构
✅ 查询统一:所有附件查询使用相同接口
✅ 用途明确:attachment_purpose 清晰区分用途
✅ 关联一致:统一由 attachments 表管理关联
✅ 性能优化:组合索引 (related_id, related_type, attachment_purpose) 高效查询
核心变更
1. 数据库表结构
新增字段
-- ✅ 多态关联字段
related_id UUID NOT NULL, -- 关联实体 ID(通用)
related_type SMALLINT NOT NULL, -- 关联实体类型:1=user, 2=project, 3=storyboard, 4=character, 5=scene, 6=prop, 7=location
attachment_purpose SMALLINT NOT NULL, -- 附件用途:1=avatar, 2=cover, 3=thumbnail, 4=document, 5=reference, 6=attachment
移除字段
-- ❌ 移除单一关联字段
project_id UUID, -- 改为 related_id + related_type=2
核心索引
-- 组合索引(多态关联必须)
CREATE INDEX idx_attachments_related ON attachments (related_id, related_type, attachment_purpose)
WHERE deleted_at IS NULL;
2. 枚举定义
RelatedType(关联实体类型)
class RelatedType(IntEnum):
USER = 1 # 用户
PROJECT = 2 # 项目
STORYBOARD = 3 # 分镜
CHARACTER = 4 # 角色
SCENE = 5 # 场景
PROP = 6 # 道具
LOCATION = 7 # 地点
AttachmentPurpose(附件用途)
class AttachmentPurpose(IntEnum):
AVATAR = 1 # 头像
COVER = 2 # 封面
THUMBNAIL = 3 # 缩略图
DOCUMENT = 4 # 文档
REFERENCE = 5 # 参考资料
ATTACHMENT = 6 # 通用附件
3. Model 层变更
class Attachment(SQLModel, table=True):
# ✅ 新增多态关联字段
related_id: UUID = Field(
sa_column=Column(PG_UUID(as_uuid=True), nullable=False, index=True),
description="关联实体 ID(多态关联)"
)
related_type: int = Field(
sa_column=Column(SMALLINT, nullable=False, index=True),
description="关联实体类型"
)
attachment_purpose: int = Field(
sa_column=Column(SMALLINT, nullable=False, index=True),
description="附件用途"
)
# ❌ 移除单一关联字段
# project_id: Optional[UUID] = Field(...)
4. Service 层变更
上传附件
# ✅ 新接口(多态关联)
async def upload_attachment(
self,
user_id: UUID,
file: UploadFile,
category: str = 'document',
related_id: UUID = None,
related_type: str = 'project',
attachment_purpose: str = 'attachment',
is_public: bool = False
) -> Attachment:
...
# ❌ 旧接口(单一关联)
# async def upload_attachment(
# self,
# user_id: UUID,
# file: UploadFile,
# category: str = 'document',
# project_id: Optional[UUID] = None,
# is_public: bool = False
# ) -> Attachment:
查询附件
# ✅ 新接口(多态查询)
async def get_attachments_by_related(
self,
user_id: UUID,
related_id: UUID,
related_type: str,
attachment_purpose: Optional[str] = None,
category: Optional[str] = None,
page: int = 1,
page_size: int = 20
) -> Dict[str, Any]:
...
# ❌ 旧接口(单一查询)
# async def get_attachments_by_project(
# self,
# user_id: UUID,
# project_id: UUID,
# category: Optional[str] = None,
# page: int = 1,
# page_size: int = 20
# ) -> Dict[str, Any]:
新增接口
# ✅ 获取指定用途的附件(如封面、头像)
async def get_attachment_by_purpose(
self,
user_id: UUID,
related_id: UUID,
related_type: str,
purpose: str
) -> Optional[Attachment]:
...
5. Repository 层变更
# ✅ 多态实体验证
async def exists_related_entity(
self,
related_id: UUID,
related_type: RelatedType
) -> bool:
...
# ✅ 多态权限检查
async def check_related_permission(
self,
user_id: UUID,
related_id: UUID,
related_type: RelatedType,
required_role: str = 'viewer'
) -> bool:
...
# ✅ 多态查询
async def get_by_related(
self,
related_id: UUID,
related_type: RelatedType,
attachment_purpose: Optional[AttachmentPurpose] = None,
category: Optional[AttachmentCategory] = None,
page: int = 1,
page_size: int = 20
) -> Tuple[List[Attachment], int]:
...
6. API 接口变更
上传附件
POST /api/v1/attachments
新增参数:
relatedId: 关联实体 ID(必填)relatedType: 关联实体类型(user/project/storyboard/character/scene,默认 project)attachmentPurpose: 附件用途(avatar/cover/thumbnail/document/reference/attachment,默认 attachment)
移除参数:
projectId: 项目 ID(改为 relatedId + relatedType=project)
查询附件
GET /api/v1/attachments?relatedId={id}&relatedType={type}&attachmentPurpose={purpose}
新增参数:
relatedId: 关联实体 ID(必填)relatedType: 关联实体类型(必填)attachmentPurpose: 附件用途筛选(可选)
移除参数:
projectId: 项目 ID
新增端点
GET /api/v1/attachments/purpose/{related_type}/{related_id}/{purpose}
获取指定用途的附件(如项目封面、用户头像)。
数据迁移
迁移脚本
# server/app/migrations/013_attachment_polymorphic_association.py
from sqlalchemy import text
from app.core.database import engine
async def upgrade():
"""升级到多态关联设计"""
async with engine.begin() as conn:
# 1. 添加新字段
await conn.execute(text("""
ALTER TABLE attachments
ADD COLUMN related_id UUID,
ADD COLUMN related_type SMALLINT,
ADD COLUMN attachment_purpose SMALLINT;
"""))
# 2. 迁移现有数据(project_id → related_id + related_type=2)
await conn.execute(text("""
UPDATE attachments
SET
related_id = project_id,
related_type = 2, -- PROJECT
attachment_purpose = 4 -- DOCUMENT
WHERE project_id IS NOT NULL;
"""))
# 3. 迁移用户头像(users.avatar_id → attachments 反向记录)
await conn.execute(text("""
INSERT INTO attachments (
attachment_id, name, original_name, file_url, file_size,
mime_type, category, checksum, related_id, related_type,
attachment_purpose, uploaded_by, is_public, created_at, updated_at
)
SELECT
gen_random_uuid(),
'avatar.jpg',
'avatar.jpg',
-- 从 users 表获取头像 URL(假设存在)
u.avatar_url,
0,
'image/jpeg',
2, -- IMAGE
'',
u.user_id, -- related_id
1, -- USER
1, -- AVATAR
u.user_id, -- uploaded_by
true,
now(),
now()
FROM users u
WHERE u.avatar_id IS NOT NULL;
"""))
# 4. 设置 NOT NULL 约束
await conn.execute(text("""
ALTER TABLE attachments
ALTER COLUMN related_id SET NOT NULL,
ALTER COLUMN related_type SET NOT NULL,
ALTER COLUMN attachment_purpose SET NOT NULL;
"""))
# 5. 创建索引
await conn.execute(text("""
CREATE INDEX idx_attachments_related
ON attachments (related_id, related_type, attachment_purpose)
WHERE deleted_at IS NULL;
CREATE INDEX idx_attachments_related_type
ON attachments (related_type, related_id)
WHERE deleted_at IS NULL;
"""))
# 6. 移除旧字段
await conn.execute(text("""
DROP INDEX IF EXISTS idx_attachments_project_id;
DROP INDEX IF EXISTS idx_attachments_project_category;
ALTER TABLE attachments DROP COLUMN project_id;
"""))
# 7. 移除业务表的反向关联字段
await conn.execute(text("""
ALTER TABLE users DROP COLUMN IF EXISTS avatar_id;
ALTER TABLE projects DROP COLUMN IF EXISTS cover_image_id;
ALTER TABLE storyboards DROP COLUMN IF EXISTS thumbnail_id;
"""))
async def downgrade():
"""回滚到单一关联设计"""
async with engine.begin() as conn:
# 1. 添加旧字段
await conn.execute(text("""
ALTER TABLE attachments ADD COLUMN project_id UUID;
"""))
# 2. 迁移数据(related_type=2 → project_id)
await conn.execute(text("""
UPDATE attachments
SET project_id = related_id
WHERE related_type = 2;
"""))
# 3. 创建旧索引
await conn.execute(text("""
CREATE INDEX idx_attachments_project_id
ON attachments (project_id)
WHERE deleted_at IS NULL AND project_id IS NOT NULL;
"""))
# 4. 移除新字段
await conn.execute(text("""
DROP INDEX IF EXISTS idx_attachments_related;
DROP INDEX IF EXISTS idx_attachments_related_type;
ALTER TABLE attachments
DROP COLUMN related_id,
DROP COLUMN related_type,
DROP COLUMN attachment_purpose;
"""))
使用示例
上传用户头像
await attachment_service.upload_attachment(
user_id=current_user_id,
file=file,
category='image',
related_id=current_user_id,
related_type='user',
attachment_purpose='avatar'
)
上传项目封面
await attachment_service.upload_attachment(
user_id=current_user_id,
file=file,
category='image',
related_id=project_id,
related_type='project',
attachment_purpose='cover'
)
查询项目的所有附件
attachments = await attachment_service.get_attachments_by_related(
user_id=current_user_id,
related_id=project_id,
related_type='project'
)
获取项目封面
cover = await attachment_service.get_attachment_by_purpose(
user_id=current_user_id,
related_id=project_id,
related_type='project',
purpose='cover'
)
影响范围
后端
- ✅
app/models/attachment.py- Model 定义 - ✅
app/schemas/attachment.py- Schema 定义 - ✅
app/repositories/attachment_repository.py- Repository 层 - ✅
app/services/attachment_service.py- Service 层 - ✅
app/api/v1/attachments.py- API 路由 - ✅
app/migrations/013_attachment_polymorphic_association.py- 数据迁移
前端
- ⚠️ 需要更新附件上传组件,传递
relatedId、relatedType、attachmentPurpose参数 - ⚠️ 需要更新附件查询接口调用
- ⚠️ 需要更新头像、封面等组件的附件获取逻辑
数据库
- ✅ attachments 表结构变更
- ✅ 索引重建
- ✅ 数据迁移
测试要点
-
上传测试:
- 上传用户头像
- 上传项目封面
- 上传分镜缩略图
- 上传项目文档
-
查询测试:
- 查询用户的所有附件
- 查询项目的所有附件
- 按用途筛选附件
- 获取指定用途的附件
-
权限测试:
- 验证用户只能访问自己的头像
- 验证项目成员可以访问项目附件
- 验证非成员无法访问私有附件
-
数据迁移测试:
- 验证旧数据正确迁移
- 验证索引正确创建
- 验证查询性能
部署步骤
- 备份数据库
- 执行数据迁移:
docker exec jointo-server-app python scripts/db_migrate.py upgrade - 验证数据迁移:
docker exec jointo-server-postgres psql -U jointoAI -d jointo -c "SELECT COUNT(*) FROM attachments WHERE related_id IS NOT NULL;" - 重启服务
- 前端更新部署
回滚方案
如果出现问题,可以执行回滚:
docker exec jointo-server-app python scripts/db_migrate.py downgrade
注意:回滚会丢失多态关联的数据(如用户头像、分镜缩略图等),只保留项目附件。
相关文档
变更日期:2026-01-28
影响版本:v4.0
状态:已完成