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

附件服务多态关联设计

变更类型:架构升级
影响范围:附件服务
日期: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. 数据库表结构

新增字段

-- ✅ 多态关联字段
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 - 数据迁移

前端

  • ⚠️ 需要更新附件上传组件,传递 relatedIdrelatedTypeattachmentPurpose 参数
  • ⚠️ 需要更新附件查询接口调用
  • ⚠️ 需要更新头像、封面等组件的附件获取逻辑

数据库

  • attachments 表结构变更
  • 索引重建
  • 数据迁移

测试要点

  1. 上传测试

    • 上传用户头像
    • 上传项目封面
    • 上传分镜缩略图
    • 上传项目文档
  2. 查询测试

    • 查询用户的所有附件
    • 查询项目的所有附件
    • 按用途筛选附件
    • 获取指定用途的附件
  3. 权限测试

    • 验证用户只能访问自己的头像
    • 验证项目成员可以访问项目附件
    • 验证非成员无法访问私有附件
  4. 数据迁移测试

    • 验证旧数据正确迁移
    • 验证索引正确创建
    • 验证查询性能

部署步骤

  1. 备份数据库
  2. 执行数据迁移
    docker exec jointo-server-app python scripts/db_migrate.py upgrade
    
  3. 验证数据迁移
    docker exec jointo-server-postgres psql -U jointoAI -d jointo -c "SELECT COUNT(*) FROM attachments WHERE related_id IS NOT NULL;"
    
  4. 重启服务
  5. 前端更新部署

回滚方案

如果出现问题,可以执行回滚:

docker exec jointo-server-app python scripts/db_migrate.py downgrade

注意:回滚会丢失多态关联的数据(如用户头像、分镜缩略图等),只保留项目附件。

相关文档


变更日期:2026-01-28
影响版本:v4.0
状态:已完成