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:
190
backend/ent/schema/account.go
Normal file
190
backend/ent/schema/account.go
Normal file
@@ -0,0 +1,190 @@
|
||||
// Package schema 定义 Ent ORM 的数据库 schema。
|
||||
// 每个文件对应一个数据库实体(表),定义其字段、边(关联)和索引。
|
||||
package schema
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect"
|
||||
"entgo.io/ent/dialect/entsql"
|
||||
"entgo.io/ent/schema"
|
||||
"entgo.io/ent/schema/edge"
|
||||
"entgo.io/ent/schema/field"
|
||||
"entgo.io/ent/schema/index"
|
||||
)
|
||||
|
||||
// Account 定义 AI API 账户实体的 schema。
|
||||
//
|
||||
// 账户是系统的核心资源,代表一个可用于调用 AI API 的凭证。
|
||||
// 例如:一个 Claude API 账户、一个 Gemini OAuth 账户等。
|
||||
//
|
||||
// 主要功能:
|
||||
// - 存储不同平台(Claude、Gemini、OpenAI 等)的 API 凭证
|
||||
// - 支持多种认证类型(api_key、oauth、cookie 等)
|
||||
// - 管理账户的调度状态(可调度、速率限制、过载等)
|
||||
// - 通过分组机制实现账户的灵活分配
|
||||
type Account struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
// Annotations 返回 schema 的注解配置。
|
||||
// 这里指定数据库表名为 "accounts"。
|
||||
func (Account) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "accounts"},
|
||||
}
|
||||
}
|
||||
|
||||
// Mixin 返回该 schema 使用的混入组件。
|
||||
// - TimeMixin: 自动管理 created_at 和 updated_at 时间戳
|
||||
// - SoftDeleteMixin: 提供软删除功能(deleted_at)
|
||||
func (Account) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
mixins.SoftDeleteMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
// Fields 定义账户实体的所有字段。
|
||||
func (Account) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
// name: 账户显示名称,用于在界面中标识账户
|
||||
field.String("name").
|
||||
MaxLen(100).
|
||||
NotEmpty(),
|
||||
|
||||
// platform: 所属平台,如 "claude", "gemini", "openai" 等
|
||||
field.String("platform").
|
||||
MaxLen(50).
|
||||
NotEmpty(),
|
||||
|
||||
// type: 认证类型,如 "api_key", "oauth", "cookie" 等
|
||||
// 不同类型决定了 credentials 中存储的数据结构
|
||||
field.String("type").
|
||||
MaxLen(20).
|
||||
NotEmpty(),
|
||||
|
||||
// credentials: 认证凭证,以 JSONB 格式存储
|
||||
// 结构取决于 type 字段:
|
||||
// - api_key: {"api_key": "sk-xxx"}
|
||||
// - oauth: {"access_token": "...", "refresh_token": "...", "expires_at": "..."}
|
||||
// - cookie: {"session_key": "..."}
|
||||
field.JSON("credentials", map[string]any{}).
|
||||
Default(func() map[string]any { return map[string]any{} }).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// extra: 扩展数据,存储平台特定的额外信息
|
||||
// 如 CRS 账户的 crs_account_id、组织信息等
|
||||
field.JSON("extra", map[string]any{}).
|
||||
Default(func() map[string]any { return map[string]any{} }).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// proxy_id: 关联的代理配置 ID(可选)
|
||||
// 用于需要通过特定代理访问 API 的场景
|
||||
field.Int64("proxy_id").
|
||||
Optional().
|
||||
Nillable(),
|
||||
|
||||
// concurrency: 账户最大并发请求数
|
||||
// 用于限制同一时间对该账户发起的请求数量
|
||||
field.Int("concurrency").
|
||||
Default(3),
|
||||
|
||||
// priority: 账户优先级,数值越小优先级越高
|
||||
// 调度器会优先使用高优先级的账户
|
||||
field.Int("priority").
|
||||
Default(50),
|
||||
|
||||
// status: 账户状态,如 "active", "error", "disabled"
|
||||
field.String("status").
|
||||
MaxLen(20).
|
||||
Default(service.StatusActive),
|
||||
|
||||
// error_message: 错误信息,记录账户异常时的详细信息
|
||||
field.String("error_message").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
|
||||
// last_used_at: 最后使用时间,用于统计和调度
|
||||
field.Time("last_used_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
|
||||
// ========== 调度和速率限制相关字段 ==========
|
||||
// 这些字段在 migrations/005_schema_parity.sql 中添加
|
||||
|
||||
// schedulable: 是否可被调度器选中
|
||||
// false 表示账户暂时不参与请求分配(如正在刷新 token)
|
||||
field.Bool("schedulable").
|
||||
Default(true),
|
||||
|
||||
// rate_limited_at: 触发速率限制的时间
|
||||
// 当收到 429 错误时记录
|
||||
field.Time("rate_limited_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
|
||||
// rate_limit_reset_at: 速率限制预计解除的时间
|
||||
// 调度器会在此时间之前避免使用该账户
|
||||
field.Time("rate_limit_reset_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
|
||||
// overload_until: 过载状态解除时间
|
||||
// 当收到 529 错误(API 过载)时设置
|
||||
field.Time("overload_until").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
|
||||
// session_window_*: 会话窗口相关字段
|
||||
// 用于管理某些需要会话时间窗口的 API(如 Claude Pro)
|
||||
field.Time("session_window_start").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("session_window_end").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.String("session_window_status").
|
||||
Optional().
|
||||
Nillable().
|
||||
MaxLen(20),
|
||||
}
|
||||
}
|
||||
|
||||
// Edges 定义账户实体的关联关系。
|
||||
func (Account) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
// groups: 账户所属的分组(多对多关系)
|
||||
// 通过 account_groups 中间表实现
|
||||
// 一个账户可以属于多个分组,一个分组可以包含多个账户
|
||||
edge.To("groups", Group.Type).
|
||||
Through("account_groups", AccountGroup.Type),
|
||||
}
|
||||
}
|
||||
|
||||
// Indexes 定义数据库索引,优化查询性能。
|
||||
// 每个索引对应一个常用的查询条件。
|
||||
func (Account) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("platform"), // 按平台筛选
|
||||
index.Fields("type"), // 按认证类型筛选
|
||||
index.Fields("status"), // 按状态筛选
|
||||
index.Fields("proxy_id"), // 按代理筛选
|
||||
index.Fields("priority"), // 按优先级排序
|
||||
index.Fields("last_used_at"), // 按最后使用时间排序
|
||||
index.Fields("schedulable"), // 筛选可调度账户
|
||||
index.Fields("rate_limited_at"), // 筛选速率限制账户
|
||||
index.Fields("rate_limit_reset_at"), // 筛选速率限制解除时间
|
||||
index.Fields("overload_until"), // 筛选过载账户
|
||||
index.Fields("deleted_at"), // 软删除查询优化
|
||||
}
|
||||
}
|
||||
58
backend/ent/schema/account_group.go
Normal file
58
backend/ent/schema/account_group.go
Normal file
@@ -0,0 +1,58 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect/entsql"
|
||||
"entgo.io/ent/schema"
|
||||
"entgo.io/ent/schema/edge"
|
||||
"entgo.io/ent/schema/field"
|
||||
"entgo.io/ent/schema/index"
|
||||
)
|
||||
|
||||
// AccountGroup holds the edge schema definition for the account_groups relationship.
|
||||
// It stores extra fields (priority, created_at) and uses a composite primary key.
|
||||
type AccountGroup struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (AccountGroup) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "account_groups"},
|
||||
// Composite primary key: (account_id, group_id).
|
||||
field.ID("account_id", "group_id"),
|
||||
}
|
||||
}
|
||||
|
||||
func (AccountGroup) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.Int64("account_id"),
|
||||
field.Int64("group_id"),
|
||||
field.Int("priority").
|
||||
Default(50),
|
||||
field.Time("created_at").
|
||||
Immutable().
|
||||
Default(time.Now),
|
||||
}
|
||||
}
|
||||
|
||||
func (AccountGroup) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.To("account", Account.Type).
|
||||
Unique().
|
||||
Required().
|
||||
Field("account_id"),
|
||||
edge.To("group", Group.Type).
|
||||
Unique().
|
||||
Required().
|
||||
Field("group_id"),
|
||||
}
|
||||
}
|
||||
|
||||
func (AccountGroup) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("group_id"),
|
||||
index.Fields("priority"),
|
||||
}
|
||||
}
|
||||
74
backend/ent/schema/api_key.go
Normal file
74
backend/ent/schema/api_key.go
Normal file
@@ -0,0 +1,74 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect/entsql"
|
||||
"entgo.io/ent/schema"
|
||||
"entgo.io/ent/schema/edge"
|
||||
"entgo.io/ent/schema/field"
|
||||
"entgo.io/ent/schema/index"
|
||||
)
|
||||
|
||||
// ApiKey holds the schema definition for the ApiKey entity.
|
||||
type ApiKey struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (ApiKey) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "api_keys"},
|
||||
}
|
||||
}
|
||||
|
||||
func (ApiKey) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
mixins.SoftDeleteMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
func (ApiKey) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.Int64("user_id"),
|
||||
field.String("key").
|
||||
MaxLen(128).
|
||||
NotEmpty().
|
||||
Unique(),
|
||||
field.String("name").
|
||||
MaxLen(100).
|
||||
NotEmpty(),
|
||||
field.Int64("group_id").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.String("status").
|
||||
MaxLen(20).
|
||||
Default(service.StatusActive),
|
||||
}
|
||||
}
|
||||
|
||||
func (ApiKey) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.From("user", User.Type).
|
||||
Ref("api_keys").
|
||||
Field("user_id").
|
||||
Unique().
|
||||
Required(),
|
||||
edge.From("group", Group.Type).
|
||||
Ref("api_keys").
|
||||
Field("group_id").
|
||||
Unique(),
|
||||
}
|
||||
}
|
||||
|
||||
func (ApiKey) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("key").Unique(),
|
||||
index.Fields("user_id"),
|
||||
index.Fields("group_id"),
|
||||
index.Fields("status"),
|
||||
index.Fields("deleted_at"),
|
||||
}
|
||||
}
|
||||
98
backend/ent/schema/group.go
Normal file
98
backend/ent/schema/group.go
Normal file
@@ -0,0 +1,98 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect"
|
||||
"entgo.io/ent/dialect/entsql"
|
||||
"entgo.io/ent/schema"
|
||||
"entgo.io/ent/schema/edge"
|
||||
"entgo.io/ent/schema/field"
|
||||
"entgo.io/ent/schema/index"
|
||||
)
|
||||
|
||||
// Group holds the schema definition for the Group entity.
|
||||
type Group struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (Group) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "groups"},
|
||||
}
|
||||
}
|
||||
|
||||
func (Group) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
mixins.SoftDeleteMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
func (Group) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.String("name").
|
||||
MaxLen(100).
|
||||
NotEmpty().
|
||||
Unique(),
|
||||
field.String("description").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.Float("rate_multiplier").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
|
||||
Default(1.0),
|
||||
field.Bool("is_exclusive").
|
||||
Default(false),
|
||||
field.String("status").
|
||||
MaxLen(20).
|
||||
Default(service.StatusActive),
|
||||
|
||||
// Subscription-related fields (added by migration 003)
|
||||
field.String("platform").
|
||||
MaxLen(50).
|
||||
Default(service.PlatformAnthropic),
|
||||
field.String("subscription_type").
|
||||
MaxLen(20).
|
||||
Default(service.SubscriptionTypeStandard),
|
||||
field.Float("daily_limit_usd").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}),
|
||||
field.Float("weekly_limit_usd").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}),
|
||||
field.Float("monthly_limit_usd").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (Group) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.To("api_keys", ApiKey.Type),
|
||||
edge.To("redeem_codes", RedeemCode.Type),
|
||||
edge.To("subscriptions", UserSubscription.Type),
|
||||
edge.From("accounts", Account.Type).
|
||||
Ref("groups").
|
||||
Through("account_groups", AccountGroup.Type),
|
||||
edge.From("allowed_users", User.Type).
|
||||
Ref("allowed_groups").
|
||||
Through("user_allowed_groups", UserAllowedGroup.Type),
|
||||
}
|
||||
}
|
||||
|
||||
func (Group) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("name").Unique(),
|
||||
index.Fields("status"),
|
||||
index.Fields("platform"),
|
||||
index.Fields("subscription_type"),
|
||||
index.Fields("is_exclusive"),
|
||||
index.Fields("deleted_at"),
|
||||
}
|
||||
}
|
||||
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",
|
||||
}),
|
||||
}
|
||||
}
|
||||
62
backend/ent/schema/proxy.go
Normal file
62
backend/ent/schema/proxy.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect/entsql"
|
||||
"entgo.io/ent/schema"
|
||||
"entgo.io/ent/schema/field"
|
||||
"entgo.io/ent/schema/index"
|
||||
)
|
||||
|
||||
// Proxy holds the schema definition for the Proxy entity.
|
||||
type Proxy struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (Proxy) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "proxies"},
|
||||
}
|
||||
}
|
||||
|
||||
func (Proxy) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
mixins.SoftDeleteMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
func (Proxy) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.String("name").
|
||||
MaxLen(100).
|
||||
NotEmpty(),
|
||||
field.String("protocol").
|
||||
MaxLen(20).
|
||||
NotEmpty(),
|
||||
field.String("host").
|
||||
MaxLen(255).
|
||||
NotEmpty(),
|
||||
field.Int("port"),
|
||||
field.String("username").
|
||||
MaxLen(100).
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.String("password").
|
||||
MaxLen(100).
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.String("status").
|
||||
MaxLen(20).
|
||||
Default("active"),
|
||||
}
|
||||
}
|
||||
|
||||
func (Proxy) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("status"),
|
||||
index.Fields("deleted_at"),
|
||||
}
|
||||
}
|
||||
86
backend/ent/schema/redeem_code.go
Normal file
86
backend/ent/schema/redeem_code.go
Normal file
@@ -0,0 +1,86 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect"
|
||||
"entgo.io/ent/dialect/entsql"
|
||||
"entgo.io/ent/schema"
|
||||
"entgo.io/ent/schema/edge"
|
||||
"entgo.io/ent/schema/field"
|
||||
"entgo.io/ent/schema/index"
|
||||
)
|
||||
|
||||
// RedeemCode holds the schema definition for the RedeemCode entity.
|
||||
type RedeemCode struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (RedeemCode) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "redeem_codes"},
|
||||
}
|
||||
}
|
||||
|
||||
func (RedeemCode) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.String("code").
|
||||
MaxLen(32).
|
||||
NotEmpty().
|
||||
Unique(),
|
||||
field.String("type").
|
||||
MaxLen(20).
|
||||
Default(service.RedeemTypeBalance),
|
||||
field.Float("value").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Default(0),
|
||||
field.String("status").
|
||||
MaxLen(20).
|
||||
Default(service.StatusUnused),
|
||||
field.Int64("used_by").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.Time("used_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.String("notes").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.Time("created_at").
|
||||
Immutable().
|
||||
Default(time.Now).
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Int64("group_id").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.Int("validity_days").
|
||||
Default(30),
|
||||
}
|
||||
}
|
||||
|
||||
func (RedeemCode) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.From("user", User.Type).
|
||||
Ref("redeem_codes").
|
||||
Field("used_by").
|
||||
Unique(),
|
||||
edge.From("group", Group.Type).
|
||||
Ref("redeem_codes").
|
||||
Field("group_id").
|
||||
Unique(),
|
||||
}
|
||||
}
|
||||
|
||||
func (RedeemCode) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("code").Unique(),
|
||||
index.Fields("status"),
|
||||
index.Fields("used_by"),
|
||||
index.Fields("group_id"),
|
||||
}
|
||||
}
|
||||
49
backend/ent/schema/setting.go
Normal file
49
backend/ent/schema/setting.go
Normal file
@@ -0,0 +1,49 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect"
|
||||
"entgo.io/ent/dialect/entsql"
|
||||
"entgo.io/ent/schema"
|
||||
"entgo.io/ent/schema/field"
|
||||
"entgo.io/ent/schema/index"
|
||||
)
|
||||
|
||||
// Setting holds the schema definition for the Setting entity.
|
||||
type Setting struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (Setting) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "settings"},
|
||||
}
|
||||
}
|
||||
|
||||
func (Setting) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.String("key").
|
||||
MaxLen(100).
|
||||
NotEmpty().
|
||||
Unique(),
|
||||
field.String("value").
|
||||
NotEmpty().
|
||||
SchemaType(map[string]string{
|
||||
dialect.Postgres: "text",
|
||||
}),
|
||||
field.Time("updated_at").
|
||||
Default(time.Now).
|
||||
UpdateDefault(time.Now).
|
||||
SchemaType(map[string]string{
|
||||
dialect.Postgres: "timestamptz",
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
func (Setting) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("key").Unique(),
|
||||
}
|
||||
}
|
||||
85
backend/ent/schema/user.go
Normal file
85
backend/ent/schema/user.go
Normal file
@@ -0,0 +1,85 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect"
|
||||
"entgo.io/ent/dialect/entsql"
|
||||
"entgo.io/ent/schema"
|
||||
"entgo.io/ent/schema/edge"
|
||||
"entgo.io/ent/schema/field"
|
||||
"entgo.io/ent/schema/index"
|
||||
)
|
||||
|
||||
// User holds the schema definition for the User entity.
|
||||
type User struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (User) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "users"},
|
||||
}
|
||||
}
|
||||
|
||||
func (User) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
mixins.SoftDeleteMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
func (User) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.String("email").
|
||||
MaxLen(255).
|
||||
NotEmpty().
|
||||
Unique(),
|
||||
field.String("password_hash").
|
||||
MaxLen(255).
|
||||
NotEmpty(),
|
||||
field.String("role").
|
||||
MaxLen(20).
|
||||
Default(service.RoleUser),
|
||||
field.Float("balance").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Default(0),
|
||||
field.Int("concurrency").
|
||||
Default(5),
|
||||
field.String("status").
|
||||
MaxLen(20).
|
||||
Default(service.StatusActive),
|
||||
|
||||
// Optional profile fields (added later; default '' in DB migration)
|
||||
field.String("username").
|
||||
MaxLen(100).
|
||||
Default(""),
|
||||
field.String("wechat").
|
||||
MaxLen(100).
|
||||
Default(""),
|
||||
field.String("notes").
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}).
|
||||
Default(""),
|
||||
}
|
||||
}
|
||||
|
||||
func (User) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.To("api_keys", ApiKey.Type),
|
||||
edge.To("redeem_codes", RedeemCode.Type),
|
||||
edge.To("subscriptions", UserSubscription.Type),
|
||||
edge.To("assigned_subscriptions", UserSubscription.Type),
|
||||
edge.To("allowed_groups", Group.Type).
|
||||
Through("user_allowed_groups", UserAllowedGroup.Type),
|
||||
}
|
||||
}
|
||||
|
||||
func (User) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("email").Unique(),
|
||||
index.Fields("status"),
|
||||
index.Fields("deleted_at"),
|
||||
}
|
||||
}
|
||||
55
backend/ent/schema/user_allowed_group.go
Normal file
55
backend/ent/schema/user_allowed_group.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect/entsql"
|
||||
"entgo.io/ent/schema"
|
||||
"entgo.io/ent/schema/edge"
|
||||
"entgo.io/ent/schema/field"
|
||||
"entgo.io/ent/schema/index"
|
||||
)
|
||||
|
||||
// UserAllowedGroup holds the edge schema definition for the user_allowed_groups relationship.
|
||||
// It replaces the legacy users.allowed_groups BIGINT[] column.
|
||||
type UserAllowedGroup struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (UserAllowedGroup) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "user_allowed_groups"},
|
||||
// Composite primary key: (user_id, group_id).
|
||||
field.ID("user_id", "group_id"),
|
||||
}
|
||||
}
|
||||
|
||||
func (UserAllowedGroup) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.Int64("user_id"),
|
||||
field.Int64("group_id"),
|
||||
field.Time("created_at").
|
||||
Immutable().
|
||||
Default(time.Now),
|
||||
}
|
||||
}
|
||||
|
||||
func (UserAllowedGroup) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.To("user", User.Type).
|
||||
Unique().
|
||||
Required().
|
||||
Field("user_id"),
|
||||
edge.To("group", Group.Type).
|
||||
Unique().
|
||||
Required().
|
||||
Field("group_id"),
|
||||
}
|
||||
}
|
||||
|
||||
func (UserAllowedGroup) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("group_id"),
|
||||
}
|
||||
}
|
||||
113
backend/ent/schema/user_subscription.go
Normal file
113
backend/ent/schema/user_subscription.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect"
|
||||
"entgo.io/ent/dialect/entsql"
|
||||
"entgo.io/ent/schema"
|
||||
"entgo.io/ent/schema/edge"
|
||||
"entgo.io/ent/schema/field"
|
||||
"entgo.io/ent/schema/index"
|
||||
)
|
||||
|
||||
// UserSubscription holds the schema definition for the UserSubscription entity.
|
||||
type UserSubscription struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (UserSubscription) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "user_subscriptions"},
|
||||
}
|
||||
}
|
||||
|
||||
func (UserSubscription) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
func (UserSubscription) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.Int64("user_id"),
|
||||
field.Int64("group_id"),
|
||||
|
||||
field.Time("starts_at").
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("expires_at").
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.String("status").
|
||||
MaxLen(20).
|
||||
Default(service.SubscriptionStatusActive),
|
||||
|
||||
field.Time("daily_window_start").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("weekly_window_start").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("monthly_window_start").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
|
||||
field.Float("daily_usage_usd").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}).
|
||||
Default(0),
|
||||
field.Float("weekly_usage_usd").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}).
|
||||
Default(0),
|
||||
field.Float("monthly_usage_usd").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}).
|
||||
Default(0),
|
||||
|
||||
field.Int64("assigned_by").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.Time("assigned_at").
|
||||
Default(time.Now).
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.String("notes").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (UserSubscription) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.From("user", User.Type).
|
||||
Ref("subscriptions").
|
||||
Field("user_id").
|
||||
Unique().
|
||||
Required(),
|
||||
edge.From("group", Group.Type).
|
||||
Ref("subscriptions").
|
||||
Field("group_id").
|
||||
Unique().
|
||||
Required(),
|
||||
edge.From("assigned_by_user", User.Type).
|
||||
Ref("assigned_subscriptions").
|
||||
Field("assigned_by").
|
||||
Unique(),
|
||||
}
|
||||
}
|
||||
|
||||
func (UserSubscription) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("user_id"),
|
||||
index.Fields("group_id"),
|
||||
index.Fields("status"),
|
||||
index.Fields("expires_at"),
|
||||
index.Fields("assigned_by"),
|
||||
index.Fields("user_id", "group_id").Unique(),
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user