补齐 Redis ZSET 前缀处理,确保并发释放计数正确 删除时改用 Client().Mutate 走更新逻辑,保留软删除记录 测试: make test-integration
140 lines
4.4 KiB
Go
140 lines
4.4 KiB
Go
// 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"
|
||
dbent "github.com/Wei-Shaw/sub2api/ent"
|
||
"github.com/Wei-Shaw/sub2api/ent/intercept"
|
||
)
|
||
|
||
// 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{
|
||
intercept.TraverseFunc(func(ctx context.Context, q intercept.Query) error {
|
||
// 检查是否需要跳过软删除过滤
|
||
if skip, _ := ctx.Value(softDeleteKey{}).(bool); skip {
|
||
return nil
|
||
}
|
||
// 为查询添加 deleted_at IS NULL 条件
|
||
d.applyPredicate(q)
|
||
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)
|
||
SetDeletedAt(time.Time)
|
||
WhereP(...func(*sql.Selector))
|
||
Client() *dbent.Client
|
||
})
|
||
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),
|
||
)
|
||
}
|