refactor(数据库): 迁移持久层到 Ent 并清理 GORM
将仓储层/基础设施改为 Ent + 原生 SQL 执行路径,并移除 AutoMigrate 与 GORM 依赖。 重构内容包括: - 仓储层改用 Ent/SQL(含 usage_log/account 等复杂查询),统一错误映射 - 基础设施与 setup 初始化切换为 Ent + SQL migrations - 集成测试与 fixtures 迁移到 Ent 事务模型 - 清理遗留 GORM 模型/依赖,补充迁移与文档说明 - 增加根目录 Makefile 便于前后端编译 测试: - go test -tags unit ./... - go test -tags integration ./...
This commit is contained in:
142
backend/ent/schema/mixins/soft_delete.go
Normal file
142
backend/ent/schema/mixins/soft_delete.go
Normal file
@@ -0,0 +1,142 @@
|
||||
// Package mixins 提供 Ent schema 的可复用混入组件。
|
||||
// 包括时间戳混入、软删除混入等通用功能。
|
||||
package mixins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect"
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"entgo.io/ent/schema/field"
|
||||
"entgo.io/ent/schema/mixin"
|
||||
)
|
||||
|
||||
// SoftDeleteMixin 实现基于 deleted_at 时间戳的软删除功能。
|
||||
//
|
||||
// 软删除特性:
|
||||
// - 删除操作不会真正删除数据库记录,而是设置 deleted_at 时间戳
|
||||
// - 所有查询默认自动过滤 deleted_at IS NULL,只返回"未删除"的记录
|
||||
// - 通过 SkipSoftDelete(ctx) 可以绕过软删除过滤器,查询或真正删除记录
|
||||
//
|
||||
// 实现原理:
|
||||
// - 使用 Ent 的 Interceptor 拦截所有查询,自动添加 deleted_at IS NULL 条件
|
||||
// - 使用 Ent 的 Hook 拦截删除操作,将 DELETE 转换为 UPDATE SET deleted_at = NOW()
|
||||
//
|
||||
// 使用示例:
|
||||
//
|
||||
// func (User) Mixin() []ent.Mixin {
|
||||
// return []ent.Mixin{
|
||||
// mixins.SoftDeleteMixin{},
|
||||
// }
|
||||
// }
|
||||
type SoftDeleteMixin struct {
|
||||
mixin.Schema
|
||||
}
|
||||
|
||||
// Fields 定义软删除所需的字段。
|
||||
// deleted_at 字段:
|
||||
// - 类型为 TIMESTAMPTZ,精确记录删除时间
|
||||
// - Optional 和 Nillable 确保新记录时该字段为 NULL
|
||||
// - NULL 表示记录未被删除,非 NULL 表示已软删除
|
||||
func (SoftDeleteMixin) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.Time("deleted_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{
|
||||
dialect.Postgres: "timestamptz",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// softDeleteKey 是用于在 context 中标记跳过软删除的键类型。
|
||||
// 使用空结构体作为键可以避免与其他包的键冲突。
|
||||
type softDeleteKey struct{}
|
||||
|
||||
// SkipSoftDelete 返回一个新的 context,用于跳过软删除的拦截器和变更器。
|
||||
//
|
||||
// 使用场景:
|
||||
// - 查询已软删除的记录(如管理员查看回收站)
|
||||
// - 执行真正的物理删除(如彻底清理数据)
|
||||
// - 恢复软删除的记录
|
||||
//
|
||||
// 示例:
|
||||
//
|
||||
// // 查询包含已删除记录的所有用户
|
||||
// users, err := client.User.Query().All(mixins.SkipSoftDelete(ctx))
|
||||
//
|
||||
// // 真正删除记录
|
||||
// client.User.DeleteOneID(id).Exec(mixins.SkipSoftDelete(ctx))
|
||||
func SkipSoftDelete(parent context.Context) context.Context {
|
||||
return context.WithValue(parent, softDeleteKey{}, true)
|
||||
}
|
||||
|
||||
// Interceptors 返回查询拦截器列表。
|
||||
// 拦截器会自动为所有查询添加 deleted_at IS NULL 条件,
|
||||
// 确保软删除的记录不会出现在普通查询结果中。
|
||||
func (d SoftDeleteMixin) Interceptors() []ent.Interceptor {
|
||||
return []ent.Interceptor{
|
||||
ent.TraverseFunc(func(ctx context.Context, q ent.Query) error {
|
||||
// 检查是否需要跳过软删除过滤
|
||||
if skip, _ := ctx.Value(softDeleteKey{}).(bool); skip {
|
||||
return nil
|
||||
}
|
||||
// 为查询添加 deleted_at IS NULL 条件
|
||||
w, ok := q.(interface{ WhereP(...func(*sql.Selector)) })
|
||||
if ok {
|
||||
d.applyPredicate(w)
|
||||
}
|
||||
return nil
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
// Hooks 返回变更钩子列表。
|
||||
// 钩子会拦截 DELETE 操作,将其转换为 UPDATE SET deleted_at = NOW()。
|
||||
// 这样删除操作实际上只是标记记录为已删除,而不是真正删除。
|
||||
func (d SoftDeleteMixin) Hooks() []ent.Hook {
|
||||
return []ent.Hook{
|
||||
func(next ent.Mutator) ent.Mutator {
|
||||
return ent.MutateFunc(func(ctx context.Context, m ent.Mutation) (ent.Value, error) {
|
||||
// 只处理删除操作
|
||||
if m.Op() != ent.OpDelete && m.Op() != ent.OpDeleteOne {
|
||||
return next.Mutate(ctx, m)
|
||||
}
|
||||
// 检查是否需要执行真正的删除
|
||||
if skip, _ := ctx.Value(softDeleteKey{}).(bool); skip {
|
||||
return next.Mutate(ctx, m)
|
||||
}
|
||||
// 类型断言,获取 mutation 的扩展接口
|
||||
mx, ok := m.(interface {
|
||||
SetOp(ent.Op)
|
||||
Client() interface {
|
||||
Mutate(context.Context, ent.Mutation) (ent.Value, error)
|
||||
}
|
||||
SetDeletedAt(time.Time)
|
||||
WhereP(...func(*sql.Selector))
|
||||
})
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("unexpected mutation type %T", m)
|
||||
}
|
||||
// 添加软删除过滤条件,确保不会影响已删除的记录
|
||||
d.applyPredicate(mx)
|
||||
// 将 DELETE 操作转换为 UPDATE 操作
|
||||
mx.SetOp(ent.OpUpdate)
|
||||
// 设置删除时间为当前时间
|
||||
mx.SetDeletedAt(time.Now())
|
||||
return mx.Client().Mutate(ctx, m)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// applyPredicate 为查询添加 deleted_at IS NULL 条件。
|
||||
// 这是软删除过滤的核心实现。
|
||||
func (d SoftDeleteMixin) applyPredicate(w interface{ WhereP(...func(*sql.Selector)) }) {
|
||||
w.WhereP(
|
||||
sql.FieldIsNull(d.Fields()[0].Descriptor().Name),
|
||||
)
|
||||
}
|
||||
32
backend/ent/schema/mixins/time.go
Normal file
32
backend/ent/schema/mixins/time.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package mixins
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect"
|
||||
"entgo.io/ent/schema/field"
|
||||
"entgo.io/ent/schema/mixin"
|
||||
)
|
||||
|
||||
// TimeMixin provides created_at and updated_at fields compatible with the existing schema.
|
||||
type TimeMixin struct {
|
||||
mixin.Schema
|
||||
}
|
||||
|
||||
func (TimeMixin) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.Time("created_at").
|
||||
Immutable().
|
||||
Default(time.Now).
|
||||
SchemaType(map[string]string{
|
||||
dialect.Postgres: "timestamptz",
|
||||
}),
|
||||
field.Time("updated_at").
|
||||
Default(time.Now).
|
||||
UpdateDefault(time.Now).
|
||||
SchemaType(map[string]string{
|
||||
dialect.Postgres: "timestamptz",
|
||||
}),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user