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
8.9 KiB
RFC 130: 枚举类型重构 - PostgreSQL ENUM 改为 SMALLINT
状态: 已实施
创建时间: 2026-01-21
作者: System
类型: 重构
概述
将项目模块中所有 PostgreSQL ENUM 类型改为 SMALLINT 类型,并在后端 Python 代码中使用 IntEnum 进行映射。
背景
问题
PostgreSQL ENUM 类型存在以下问题:
- 迁移复杂: 修改 ENUM 值需要复杂的 ALTER TYPE 操作
- 扩展困难: 添加新值需要数据库迁移,无法动态扩展
- 跨数据库兼容性差: 不同数据库对 ENUM 的支持不一致
- 可读性问题: 数据库中存储字符串,占用空间较大
目标
- 简化数据库结构,使用 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 - ✅ 添加
_strproperty 方法
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 响应格式测试
性能测试
- 查询性能对比
- 索引效率测试
- 存储空间对比
后续工作
- 其他模块迁移: 将相同模式应用到其他模块(用户、文件夹等)
- 生产环境迁移: 编写数据迁移脚本,支持平滑升级
- 监控优化: 监控性能提升效果,优化查询
- 文档完善: 更新 API 文档和开发指南
参考资料
实施完成时间: 2026-01-21
影响范围: 项目模块(projects, project_members, project_shares)
向后兼容: 是(API 接口保持不变)