# 附件服务多态关联设计 > **变更类型**:架构升级 > **影响范围**:附件服务 > **日期**:2026-01-28 ## 变更概述 将附件服务从单一 `project_id` 关联升级为**多态关联设计**,使用 `related_id` + `related_type` + `attachment_purpose` 三元组,支持任意实体的附件管理。 ## 变更动机 ### 当前设计问题 1. **扩展性差**:每增加一个业务场景(如角色头像、场景图片),需要修改表结构 2. **查询复杂**:无法统一查询"某个实体的所有附件" 3. **用途不明**:无法区分同一实体的不同用途附件(封面 vs 缩略图) 4. **关联分散**:一部分在 attachments 表(project_id),一部分在业务表(avatar_id) ### 多态关联优势 ✅ **高度可扩展**:新增业务场景(如道具、地点)无需修改表结构 ✅ **查询统一**:所有附件查询使用相同接口 ✅ **用途明确**:attachment_purpose 清晰区分用途 ✅ **关联一致**:统一由 attachments 表管理关联 ✅ **性能优化**:组合索引 `(related_id, related_type, attachment_purpose)` 高效查询 ## 核心变更 ### 1. 数据库表结构 #### 新增字段 ```sql -- ✅ 多态关联字段 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 ``` #### 移除字段 ```sql -- ❌ 移除单一关联字段 project_id UUID, -- 改为 related_id + related_type=2 ``` #### 核心索引 ```sql -- 组合索引(多态关联必须) CREATE INDEX idx_attachments_related ON attachments (related_id, related_type, attachment_purpose) WHERE deleted_at IS NULL; ``` ### 2. 枚举定义 #### RelatedType(关联实体类型) ```python class RelatedType(IntEnum): USER = 1 # 用户 PROJECT = 2 # 项目 STORYBOARD = 3 # 分镜 CHARACTER = 4 # 角色 SCENE = 5 # 场景 PROP = 6 # 道具 LOCATION = 7 # 地点 ``` #### AttachmentPurpose(附件用途) ```python class AttachmentPurpose(IntEnum): AVATAR = 1 # 头像 COVER = 2 # 封面 THUMBNAIL = 3 # 缩略图 DOCUMENT = 4 # 文档 REFERENCE = 5 # 参考资料 ATTACHMENT = 6 # 通用附件 ``` ### 3. Model 层变更 ```python 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 层变更 #### 上传附件 ```python # ✅ 新接口(多态关联) 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: ``` #### 查询附件 ```python # ✅ 新接口(多态查询) 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]: ``` #### 新增接口 ```python # ✅ 获取指定用途的附件(如封面、头像) async def get_attachment_by_purpose( self, user_id: UUID, related_id: UUID, related_type: str, purpose: str ) -> Optional[Attachment]: ... ``` ### 5. Repository 层变更 ```python # ✅ 多态实体验证 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} ``` 获取指定用途的附件(如项目封面、用户头像)。 ## 数据迁移 ### 迁移脚本 ```python # 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; """)) ``` ## 使用示例 ### 上传用户头像 ```python 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' ) ``` ### 上传项目封面 ```python await attachment_service.upload_attachment( user_id=current_user_id, file=file, category='image', related_id=project_id, related_type='project', attachment_purpose='cover' ) ``` ### 查询项目的所有附件 ```python attachments = await attachment_service.get_attachments_by_related( user_id=current_user_id, related_id=project_id, related_type='project' ) ``` ### 获取项目封面 ```python 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 表结构变更 - ✅ 索引重建 - ✅ 数据迁移 ## 测试要点 1. **上传测试**: - 上传用户头像 - 上传项目封面 - 上传分镜缩略图 - 上传项目文档 2. **查询测试**: - 查询用户的所有附件 - 查询项目的所有附件 - 按用途筛选附件 - 获取指定用途的附件 3. **权限测试**: - 验证用户只能访问自己的头像 - 验证项目成员可以访问项目附件 - 验证非成员无法访问私有附件 4. **数据迁移测试**: - 验证旧数据正确迁移 - 验证索引正确创建 - 验证查询性能 ## 部署步骤 1. **备份数据库** 2. **执行数据迁移**: ```bash docker exec jointo-server-app python scripts/db_migrate.py upgrade ``` 3. **验证数据迁移**: ```bash docker exec jointo-server-postgres psql -U jointoAI -d jointo -c "SELECT COUNT(*) FROM attachments WHERE related_id IS NOT NULL;" ``` 4. **重启服务** 5. **前端更新部署** ## 回滚方案 如果出现问题,可以执行回滚: ```bash docker exec jointo-server-app python scripts/db_migrate.py downgrade ``` **注意**:回滚会丢失多态关联的数据(如用户头像、分镜缩略图等),只保留项目附件。 ## 相关文档 - [附件服务文档](../../requirements/backend/04-services/resource/attachment-service.md) - [数据库设计规范](../../architecture/tech-stack.md#数据库设计) - [多态关联设计模式](../../architecture/adrs/006-polymorphic-association-pattern.md) --- **变更日期**:2026-01-28 **影响版本**:v4.0 **状态**:已完成