FastAPI 如何从物理删除迁移到逻辑删除
在后台管理系统中,删除通常不是简单的 delete from table where id = ?。
业务数据一旦被物理删除,就很难再做审计、恢复、关联排查和历史追踪。对于用户、角色、菜单、部门、字典、任务调度这类核心数据来说,物理删除的成本往往高于它带来的“干净”。
因此,fba 将核心业务表从物理删除迁移到了逻辑删除。
这次迁移看起来只是多了两个字段:deleted 和 deleted_time,但真正需要处理的是一整条链路:模型、CRUD、业务校验、唯一约束、关联查询、调度任务和迁移脚本都必须保持一致。
逻辑删除字段
fba 在 backend/common/model.py 中定义了 LogicalDeleteMixin:
class LogicalDeleteMixin(MappedAsDataclass):
"""逻辑删除 Mixin 数据类"""
deleted: Mapped[int] = mapped_column(
BigInteger,
init=False,
default=0,
server_default='0',
sort_order=999,
comment='是否已删除(0:否;id:是)',
)
deleted_time: Mapped[datetime | None] = mapped_column(
TimeZone,
init=False,
default=None,
sort_order=999,
comment='删除时间',
)这里没有使用常见的 is_deleted: bool,而是使用了 deleted: int:
deleted = 0:未删除deleted = id:已删除
这个设计是为了配合唯一约束。
如果使用布尔值,删除一条用户名为 admin 的用户后,再创建一个新的 admin 用户没有问题;但如果再次删除新的 admin,表里会出现多条 username = admin, deleted = 1 的记录,唯一约束仍然会冲突。
使用自身 id 作为删除标记后,每条已删除记录的 deleted 值都不同,因此可以同时满足两个目标:
- 未删除数据保持唯一
- 已删除数据保留历史,不影响新数据创建
哪些模型启用逻辑删除
fba 的 Base 继承了 DateTimeMixin 和 LogicalDeleteMixin:
class Base(DataClassBase, DateTimeMixin, LogicalDeleteMixin):
"""声明式数据类基类"""
__abstract__ = True因此,所有继承 Base 的模型都会自动拥有 created_time、updated_time、deleted、deleted_time。
当前采用逻辑删除的典型模型包括:
User:用户Role:角色Menu:菜单Dept:部门DataScope:数据范围DataRule:数据规则TaskScheduler:任务调度Config:参数配置DictType:字典类型DictData:字典数据Notice:通知公告UserSocial:OAuth2 社交账号绑定GenBusiness:代码生成业务
日志、任务结果、代码生成列等模型没有继承 Base,它们不参与逻辑删除。
删除操作
逻辑删除不是删除记录,而是更新记录。
在 CRUD 层,删除操作通过 delete_model_by_column() 完成:
return await self.delete_model_by_column(
db,
logical_deletion=True,
deleted_flag_column='deleted',
deleted_flag_value=self.model.id,
deleted_at_column='deleted_time',
deleted_at_factory=timezone.now(),
id=pk,
deleted=0,
)这里有几个关键点:
logical_deletion=True:启用逻辑删除deleted_flag_column='deleted':删除标记字段deleted_flag_value=self.model.id:删除后写入当前记录 IDdeleted_at_column='deleted_time':记录删除时间deleted=0:只允许删除未删除数据
最后一个条件很重要。它可以避免重复删除,也能防止把已删除数据再次参与业务操作。
查询必须过滤 deleted=0
逻辑删除完成后,查询层必须统一遵守一个规则:业务查询只看未删除数据。
例如用户详情:
return await self.select_model(db, user_id, deleted=0)角色名称查重:
return await self.select_model_by_column(db, name=name, deleted=0)列表查询:
filters = {'deleted': 0}
return await self.select_order('id', **filters)关联查询也一样。比如用户关联部门、角色、菜单时,关联表本身可能没有逻辑删除,但被关联的业务模型需要过滤:
JoinConfig(
model=Dept,
join_on=and_(Dept.id == self.model.dept_id, Dept.deleted == 0),
fill_result=True,
)逻辑删除最容易遗漏的地方往往不是单表查询,而是关联查询、批量查询、权限缓存查询和后台任务查询。
唯一约束要和逻辑删除一起改
如果表中仍然保留单列唯一约束,逻辑删除是不完整的。
例如用户表原本可能有这样的约束:
username: Mapped[str] = mapped_column(sa.String(64), unique=True)删除用户后,旧记录仍然留在表里,新用户就无法继续使用相同用户名。
因此需要改成联合唯一约束:
__table_args__ = (
sa.UniqueConstraint('username', 'deleted', name='uk_sys_user_username_deleted'),
sa.UniqueConstraint('email', 'deleted', name='uk_sys_user_email_deleted'),
{'comment': '用户表'},
)这样,数据库只会限制未删除数据中的用户名和邮箱唯一。已删除数据因为 deleted 值不同,不会影响新数据。
同样的思路也适用于角色、部门、数据范围、数据规则、任务调度、参数配置、字典类型、代码生成业务等模型。
迁移中的几个坑
1. 菜单标题这类规则不能强行套联合唯一
并不是所有业务查重都适合直接加数据库唯一约束。
例如菜单标题的业务规则是:非按钮菜单标题唯一。也就是说按钮类型可以重复,但目录、菜单、内嵌、外链不允许重复。
这种规则不是简单的 title + deleted 能表达的。如果直接加约束,就会改变现有业务语义。
跨 MySQL 和 PostgreSQL 时,部分索引、表达式索引、条件唯一索引的能力也不同。因此这类规则更适合先保留在业务层,等规则稳定后再设计数据库层的约束方案。
2. 加唯一约束前要清理历史重复数据
迁移脚本创建唯一约束前,数据库中不能存在冲突数据。
例如添加:
sa.UniqueConstraint('name', 'deleted', name='uk_sys_dept_name_deleted')如果表中已经有多条未删除的同名部门,迁移会失败。
因此正式迁移前应该先检查历史数据:
select name, deleted, count(*)
from sys_dept
group by name, deleted
having count(*) > 1;如果有重复数据,需要先清理或合并,再执行迁移。
检查清单
从物理删除迁移到逻辑删除时,可以按下面的顺序检查:
- 模型是否继承
Base - 唯一字段是否改成
业务字段 + deleted - CRUD 查询是否默认过滤
deleted=0 - 更新和删除是否只操作未删除数据
- 关联查询是否过滤被关联业务表的
deleted=0 - 业务查重是否排除了已删除数据
- 更新查重是否避免把当前记录误判为重复
- 批量删除是否写入
deleted=id和deleted_time - 缓存、权限、调度任务是否仍会读到已删除数据
- 构造 ORM 对象时是否误传
deleted、deleted_time - 迁移前是否清理了历史重复数据
逻辑删除不是一个字段改造,而是一套数据生命周期约定。只有模型、查询、约束和业务校验都对齐,删除后的数据才不会继续污染业务,同时也不会阻止新数据正常创建。
