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.
 

8.9 KiB

RFC 130: 枚举类型重构 - PostgreSQL ENUM 改为 SMALLINT

状态: 已实施
创建时间: 2026-01-21
作者: System
类型: 重构


概述

将项目模块中所有 PostgreSQL ENUM 类型改为 SMALLINT 类型,并在后端 Python 代码中使用 IntEnum 进行映射。

背景

问题

PostgreSQL ENUM 类型存在以下问题:

  1. 迁移复杂: 修改 ENUM 值需要复杂的 ALTER TYPE 操作
  2. 扩展困难: 添加新值需要数据库迁移,无法动态扩展
  3. 跨数据库兼容性差: 不同数据库对 ENUM 的支持不一致
  4. 可读性问题: 数据库中存储字符串,占用空间较大

目标

  • 简化数据库结构,使用 SMALLINT 存储枚举值
  • 在后端代码中使用 IntEnum 提供类型安全
  • 保持 API 接口向后兼容(对外仍使用字符串)
  • 提升性能和扩展性

设计方案

数据库层

修改前(PostgreSQL ENUM):

CREATE TYPE project_type AS ENUM ('mine', 'collab');
CREATE TYPE project_content_type AS ENUM ('ad', 'movie', 'series', 'anime', 'short', 'concept');
CREATE TYPE aspect_ratio_type AS ENUM ('16:9', '9:16', '4:3', '21:9', '1:1', '2.35:1', '2.39:1');
CREATE TYPE share_permission AS ENUM ('viewer', 'editor');
CREATE TYPE member_role AS ENUM ('owner', 'editor', 'viewer');

CREATE TABLE projects (
    type project_type NOT NULL DEFAULT 'mine',
    content_type project_content_type,
    aspect_ratio aspect_ratio_type,
    ...
);

修改后(SMALLINT):

CREATE TABLE projects (
    type SMALLINT NOT NULL DEFAULT 1,  -- 1=mine, 2=collab
    content_type SMALLINT,              -- 1=ad, 2=movie, 3=series, 4=anime, 5=short, 6=concept
    aspect_ratio SMALLINT,              -- 1=16:9, 2=9:16, 3=4:3, 4=21:9, 5=1:1, 6=2.35:1, 7=2.39:1
    ...
);

COMMENT ON COLUMN projects.type IS '项目类型:1=mine(个人项目), 2=collab(协作项目)';
COMMENT ON COLUMN projects.content_type IS '项目内容类型:1=ad(广告片), 2=movie(电影), 3=series(剧集), 4=anime(动画), 5=short(短视频), 6=concept(概念片)';

Python 模型层

修改前(str Enum):

class ProjectType(str, Enum):
    MINE = "mine"
    COLLAB = "collab"

class Project(SQLModel, table=True):
    type: ProjectType = Field(
        sa_column=Column(SQLEnum(ProjectType), nullable=False)
    )

修改后(IntEnum + 转换方法):

class ProjectType(IntEnum):
    """项目类型枚举"""
    MINE = 1
    COLLAB = 2

    @classmethod
    def from_string(cls, value: str) -> int:
        """字符串转数字"""
        mapping = {'mine': cls.MINE, 'collab': cls.COLLAB}
        return mapping.get(value.lower(), cls.MINE)

    @classmethod
    def to_string(cls, value: int) -> str:
        """数字转字符串"""
        mapping = {cls.MINE: 'mine', cls.COLLAB: 'collab'}
        return mapping.get(value, 'mine')

class Project(SQLModel, table=True):
    type: int = Field(
        sa_column=Column(SmallInteger, nullable=False, default=ProjectType.MINE)
    )
    
    @property
    def type_str(self) -> str:
        """获取项目类型字符串"""
        return ProjectType.to_string(self.type)

枚举值映射表

枚举类型 数值 字符串值 说明
project_type 1 mine 个人项目
2 collab 协作项目
content_type 1 ad 广告片
2 movie 电影
3 series 剧集
4 anime 动画
5 short 短视频
6 concept 概念片
aspect_ratio 1 16:9 标准宽屏
2 9:16 竖屏
3 4:3 传统电视
4 21:9 超宽屏
5 1:1 正方形
6 2.35:1 电影宽屏
7 2.39:1 电影宽屏
share_permission 1 viewer 查看权限
2 editor 编辑权限
member_role 1 owner 所有者
2 editor 编辑者
3 viewer 查看者

仓储层(Repository)

async def get_by_user(
    self,
    user_id: str,
    project_type: Optional[str] = None,  # API 传入字符串
    ...
) -> List[Project]:
    """获取用户的项目列表"""
    from app.models.project import ProjectType
    
    conditions = [...]
    
    # 字符串转数字进行查询
    if project_type:
        type_int = ProjectType.from_string(project_type)
        conditions.append(Project.type == type_int)
    
    ...

服务层(Service)

async def create_project(
    self,
    user_id: str,
    project_data: ProjectCreate  # Schema 使用字符串
) -> Project:
    """创建项目"""
    from app.models.project import ProjectType, ProjectContentType
    
    # 转换枚举值(字符串 → 数字)
    type_int = ProjectType.from_string(project_data.type.value)
    content_type_int = ProjectContentType.from_string(
        project_data.content_type.value
    ) if project_data.content_type else None
    
    project = Project(
        type=type_int,
        content_type=content_type_int,
        ...
    )
    ...

API 层(Schema)

保持对外接口使用字符串:

class ProjectCreate(BaseModel):
    type: str = Field(..., pattern="^(mine|collab)$")
    content_type: Optional[str] = None  # 'ad', 'movie', etc.
    
    @field_validator('content_type')
    @classmethod
    def validate_content_type(cls, v: Optional[str]) -> Optional[str]:
        if v is not None:
            valid_types = ['ad', 'movie', 'series', 'anime', 'short', 'concept']
            if v.lower() not in valid_types:
                raise ValueError(f'content_type must be one of {valid_types}')
        return v

class ProjectResponse(BaseModel):
    type: str  # 返回字符串 'mine' 或 'collab'
    content_type: Optional[str]  # 返回字符串 'ad', 'movie' 等
    
    @classmethod
    def from_orm(cls, obj):
        """从 ORM 对象转换,自动处理枚举转换"""
        return cls(
            type=obj.type_str,  # 使用模型的 property 方法
            content_type=obj.content_type_str,
            ...
        )

实施步骤

1. 修改模型层

  • str Enum 改为 IntEnum
  • 添加 from_string()to_string() 转换方法
  • 修改模型字段类型为 SmallInteger
  • 添加 _str property 方法

2. 修改数据库迁移

  • 移除 CREATE TYPE ... AS ENUM 语句
  • 修改字段类型为 SMALLINT
  • 更新注释说明数值映射

3. 修改仓储层

  • 添加枚举转换逻辑(字符串 → 数字)
  • 查询时处理枚举过滤

4. 修改服务层

  • 处理创建/更新时的枚举转换
  • 确保返回数据使用字符串

5. 修改 Schema 层

  • 保持 API 使用字符串
  • 添加 field_validator 验证
  • 修改 ProjectResponse.from_orm() 处理枚举转换

6. 数据库重建

  • 删除现有数据库
  • 运行新迁移脚本
  • 验证数据结构

优势

1. 简化数据库

  • 无需 ENUM 类型定义
  • 字段类型统一为 SMALLINT
  • 迁移脚本更简洁

2. 扩展灵活

  • 添加新值无需 ALTER TYPE
  • 可以动态扩展枚举值
  • 便于版本管理

3. 性能提升

  • SMALLINT 比 TEXT 更高效
  • 索引性能更好
  • 存储空间更小

4. 跨数据库兼容

  • SMALLINT 是标准 SQL 类型
  • 便于迁移到其他数据库
  • 减少数据库特定依赖

5. 向后兼容

  • API 接口保持不变
  • 对外仍使用字符串
  • 客户端无需修改

风险与缓解

风险 1: 数据库可读性降低

缓解:

  • 添加详细的 COMMENT 注释
  • 文档中维护完整的映射表
  • 使用有意义的数值(1, 2, 3...)

风险 2: 代码复杂度增加

缓解:

  • 封装转换逻辑到枚举类
  • 使用 property 方法简化访问
  • 统一转换模式

风险 3: 数据迁移

缓解:

  • 当前为开发阶段,可以删除重建
  • 生产环境需要编写数据迁移脚本
  • 提供回滚方案

测试计划

单元测试

  • 枚举转换方法测试
  • 模型 property 方法测试
  • Schema 验证测试

集成测试

  • 项目 CRUD 操作测试
  • 枚举筛选查询测试
  • API 响应格式测试

性能测试

  • 查询性能对比
  • 索引效率测试
  • 存储空间对比

后续工作

  1. 其他模块迁移: 将相同模式应用到其他模块(用户、文件夹等)
  2. 生产环境迁移: 编写数据迁移脚本,支持平滑升级
  3. 监控优化: 监控性能提升效果,优化查询
  4. 文档完善: 更新 API 文档和开发指南

参考资料


实施完成时间: 2026-01-21
影响范围: 项目模块(projects, project_members, project_shares)
向后兼容: 是(API 接口保持不变)