Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,253 @@
|
||||
// Package schema 定义 Ent ORM 的数据库 schema。
|
||||
// 每个文件对应一个数据库实体(表),定义其字段、边(关联)和索引。
|
||||
package schema
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
|
||||
"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(),
|
||||
// notes: 管理员备注(可为空)
|
||||
field.String("notes").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
|
||||
// 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(),
|
||||
field.Int64("proxy_fallback_origin_id").
|
||||
Optional().Nillable().
|
||||
Comment("Original proxy id replaced by expiry-fallback; for manual revert. NULL = not in fallback."),
|
||||
|
||||
// concurrency: 账户最大并发请求数
|
||||
// 用于限制同一时间对该账户发起的请求数量
|
||||
field.Int("concurrency").
|
||||
Default(3),
|
||||
|
||||
field.Int("load_factor").Optional().Nillable(),
|
||||
|
||||
// priority: 账户优先级,数值越小优先级越高
|
||||
// 调度器会优先使用高优先级的账户
|
||||
field.Int("priority").
|
||||
Default(50),
|
||||
|
||||
// rate_multiplier: 账号计费倍率(>=0,允许 0 表示该账号计费为 0)
|
||||
// 仅影响账号维度计费口径,不影响用户/API Key 扣费(分组倍率)
|
||||
field.Float("rate_multiplier").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
|
||||
Default(1.0),
|
||||
|
||||
// status: 账户状态,如 "active", "error", "disabled"
|
||||
field.String("status").
|
||||
MaxLen(20).
|
||||
Default(domain.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"}),
|
||||
// expires_at: 账户过期时间(可为空)
|
||||
field.Time("expires_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
Comment("Account expiration time (NULL means no expiration).").
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
// auto_pause_on_expired: 过期后自动暂停调度
|
||||
field.Bool("auto_pause_on_expired").
|
||||
Default(true).
|
||||
Comment("Auto pause scheduling when account expires."),
|
||||
|
||||
// ========== 调度和速率限制相关字段 ==========
|
||||
// 这些字段在 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"}),
|
||||
|
||||
// temp_unschedulable_until: 临时不可调度状态解除时间
|
||||
// 当命中临时不可调度规则时设置,在此时间前调度器应跳过该账号
|
||||
field.Time("temp_unschedulable_until").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
|
||||
// temp_unschedulable_reason: 临时不可调度原因,便于排障审计
|
||||
field.String("temp_unschedulable_reason").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
|
||||
// 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),
|
||||
|
||||
field.Int64("parent_account_id").Optional().Nillable().
|
||||
Comment("Parent account id for a linked spark shadow (NULL = normal)."),
|
||||
field.Enum("quota_dimension").Values("global", "spark").Default("global").
|
||||
Comment("'global' (default) or 'spark' (shadow reads codex_bengalfox)."),
|
||||
}
|
||||
}
|
||||
|
||||
// Edges 定义账户实体的关联关系。
|
||||
func (Account) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
// groups: 账户所属的分组(多对多关系)
|
||||
// 通过 account_groups 中间表实现
|
||||
// 一个账户可以属于多个分组,一个分组可以包含多个账户
|
||||
edge.To("groups", Group.Type).
|
||||
Through("account_groups", AccountGroup.Type),
|
||||
// proxy: 账户使用的代理配置(可选的一对一关系)
|
||||
// 使用已有的 proxy_id 外键字段
|
||||
edge.To("proxy", Proxy.Type).
|
||||
Field("proxy_id").
|
||||
Unique(),
|
||||
// children/parent: linked spark shadow relationship.
|
||||
// parent_account_id is nullable, and the active one-shadow-per-parent rule
|
||||
// is enforced by the partial unique index in migration 154a.
|
||||
edge.To("children", Account.Type).
|
||||
Annotations(entsql.OnDelete(entsql.Restrict)).
|
||||
From("parent").
|
||||
Field("parent_account_id").
|
||||
Unique(),
|
||||
// usage_logs: 该账户的使用日志
|
||||
edge.To("usage_logs", UsageLog.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"), // 筛选过载账户
|
||||
// 调度热路径复合索引(线上由 SQL 迁移创建部分索引,schema 仅用于模型可读性对齐)
|
||||
index.Fields("platform", "priority"),
|
||||
index.Fields("priority", "status"),
|
||||
index.Fields("deleted_at"), // 软删除查询优化
|
||||
index.Fields("parent_account_id"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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).
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
}
|
||||
}
|
||||
|
||||
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"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// Announcement holds the schema definition for the Announcement entity.
|
||||
//
|
||||
// 删除策略:硬删除(已读记录通过外键级联删除)
|
||||
type Announcement struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (Announcement) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "announcements"},
|
||||
}
|
||||
}
|
||||
|
||||
func (Announcement) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.String("title").
|
||||
MaxLen(200).
|
||||
NotEmpty().
|
||||
Comment("公告标题"),
|
||||
field.String("content").
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}).
|
||||
NotEmpty().
|
||||
Comment("公告内容(支持 Markdown)"),
|
||||
field.String("status").
|
||||
MaxLen(20).
|
||||
Default(domain.AnnouncementStatusDraft).
|
||||
Comment("状态: draft, active, archived"),
|
||||
field.String("notify_mode").
|
||||
MaxLen(20).
|
||||
Default(domain.AnnouncementNotifyModeSilent).
|
||||
Comment("通知模式: silent(仅铃铛), popup(弹窗提醒)"),
|
||||
field.JSON("targeting", domain.AnnouncementTargeting{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("展示条件(JSON 规则)"),
|
||||
field.Time("starts_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}).
|
||||
Comment("开始展示时间(为空表示立即生效)"),
|
||||
field.Time("ends_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}).
|
||||
Comment("结束展示时间(为空表示永久生效)"),
|
||||
field.Int64("created_by").
|
||||
Optional().
|
||||
Nillable().
|
||||
Comment("创建人用户ID(管理员)"),
|
||||
field.Int64("updated_by").
|
||||
Optional().
|
||||
Nillable().
|
||||
Comment("更新人用户ID(管理员)"),
|
||||
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"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (Announcement) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.To("reads", AnnouncementRead.Type),
|
||||
}
|
||||
}
|
||||
|
||||
func (Announcement) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("status"),
|
||||
index.Fields("created_at"),
|
||||
index.Fields("starts_at"),
|
||||
index.Fields("ends_at"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// AnnouncementRead holds the schema definition for the AnnouncementRead entity.
|
||||
//
|
||||
// 记录用户对公告的已读状态(首次已读时间)。
|
||||
type AnnouncementRead struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (AnnouncementRead) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "announcement_reads"},
|
||||
}
|
||||
}
|
||||
|
||||
func (AnnouncementRead) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.Int64("announcement_id"),
|
||||
field.Int64("user_id"),
|
||||
field.Time("read_at").
|
||||
Default(time.Now).
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}).
|
||||
Comment("用户首次已读时间"),
|
||||
field.Time("created_at").
|
||||
Immutable().
|
||||
Default(time.Now).
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (AnnouncementRead) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.From("announcement", Announcement.Type).
|
||||
Ref("reads").
|
||||
Field("announcement_id").
|
||||
Unique().
|
||||
Required(),
|
||||
edge.From("user", User.Type).
|
||||
Ref("announcement_reads").
|
||||
Field("user_id").
|
||||
Unique().
|
||||
Required(),
|
||||
}
|
||||
}
|
||||
|
||||
func (AnnouncementRead) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("announcement_id"),
|
||||
index.Fields("user_id"),
|
||||
index.Fields("read_at"),
|
||||
index.Fields("announcement_id", "user_id").Unique(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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(domain.StatusActive),
|
||||
field.Time("last_used_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
Comment("Last usage time of this API key"),
|
||||
field.JSON("ip_whitelist", []string{}).
|
||||
Optional().
|
||||
Comment("Allowed IPs/CIDRs, e.g. [\"192.168.1.100\", \"10.0.0.0/8\"]"),
|
||||
field.JSON("ip_blacklist", []string{}).
|
||||
Optional().
|
||||
Comment("Blocked IPs/CIDRs"),
|
||||
|
||||
// ========== Quota fields ==========
|
||||
// Quota limit in USD (0 = unlimited)
|
||||
field.Float("quota").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Default(0).
|
||||
Comment("Quota limit in USD for this API key (0 = unlimited)"),
|
||||
// Used quota amount
|
||||
field.Float("quota_used").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Default(0).
|
||||
Comment("Used quota amount in USD"),
|
||||
// Expiration time (nil = never expires)
|
||||
field.Time("expires_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
Comment("Expiration time for this API key (null = never expires)"),
|
||||
|
||||
// ========== Rate limit fields ==========
|
||||
// Rate limit configuration (0 = unlimited)
|
||||
field.Float("rate_limit_5h").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Default(0).
|
||||
Comment("Rate limit in USD per 5 hours (0 = unlimited)"),
|
||||
field.Float("rate_limit_1d").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Default(0).
|
||||
Comment("Rate limit in USD per day (0 = unlimited)"),
|
||||
field.Float("rate_limit_7d").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Default(0).
|
||||
Comment("Rate limit in USD per 7 days (0 = unlimited)"),
|
||||
// Rate limit usage tracking
|
||||
field.Float("usage_5h").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Default(0).
|
||||
Comment("Used amount in USD for the current 5h window"),
|
||||
field.Float("usage_1d").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Default(0).
|
||||
Comment("Used amount in USD for the current 1d window"),
|
||||
field.Float("usage_7d").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Default(0).
|
||||
Comment("Used amount in USD for the current 7d window"),
|
||||
// Window start times
|
||||
field.Time("window_5h_start").
|
||||
Optional().
|
||||
Nillable().
|
||||
Comment("Start time of the current 5h rate limit window"),
|
||||
field.Time("window_1d_start").
|
||||
Optional().
|
||||
Nillable().
|
||||
Comment("Start time of the current 1d rate limit window"),
|
||||
field.Time("window_7d_start").
|
||||
Optional().
|
||||
Nillable().
|
||||
Comment("Start time of the current 7d rate limit window"),
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
edge.To("usage_logs", UsageLog.Type),
|
||||
}
|
||||
}
|
||||
|
||||
func (APIKey) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
// key 字段已在 Fields() 中声明 Unique(),无需重复索引
|
||||
index.Fields("user_id"),
|
||||
index.Fields("group_id"),
|
||||
index.Fields("status"),
|
||||
index.Fields("deleted_at"),
|
||||
index.Fields("last_used_at"),
|
||||
// Index for quota queries
|
||||
index.Fields("quota", "quota_used"),
|
||||
index.Fields("expires_at"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
var authProviderTypes = map[string]struct{}{
|
||||
"email": {},
|
||||
"github": {},
|
||||
"google": {},
|
||||
"linuxdo": {},
|
||||
"oidc": {},
|
||||
"wechat": {},
|
||||
"dingtalk": {},
|
||||
}
|
||||
|
||||
func validateAuthProviderType(value string) error {
|
||||
if _, ok := authProviderTypes[value]; ok {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("invalid auth provider type %q", value)
|
||||
}
|
||||
|
||||
// AuthIdentity stores the canonical login identity for an account.
|
||||
type AuthIdentity struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (AuthIdentity) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "auth_identities"},
|
||||
}
|
||||
}
|
||||
|
||||
func (AuthIdentity) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
func (AuthIdentity) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.Int64("user_id"),
|
||||
field.String("provider_type").
|
||||
MaxLen(20).
|
||||
NotEmpty().
|
||||
Validate(validateAuthProviderType),
|
||||
field.String("provider_key").
|
||||
NotEmpty().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.String("provider_subject").
|
||||
NotEmpty().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.Time("verified_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.String("issuer").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.JSON("metadata", map[string]any{}).
|
||||
Default(func() map[string]any { return map[string]any{} }).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (AuthIdentity) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.From("user", User.Type).
|
||||
Ref("auth_identities").
|
||||
Field("user_id").
|
||||
Required().
|
||||
Unique(),
|
||||
edge.To("channels", AuthIdentityChannel.Type).
|
||||
Annotations(entsql.OnDelete(entsql.Cascade)),
|
||||
edge.To("adoption_decisions", IdentityAdoptionDecision.Type),
|
||||
}
|
||||
}
|
||||
|
||||
func (AuthIdentity) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("provider_type", "provider_key", "provider_subject").Unique(),
|
||||
index.Fields("user_id"),
|
||||
index.Fields("user_id", "provider_type"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// AuthIdentityChannel stores channel-scoped identifiers for a canonical identity.
|
||||
type AuthIdentityChannel struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (AuthIdentityChannel) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "auth_identity_channels"},
|
||||
}
|
||||
}
|
||||
|
||||
func (AuthIdentityChannel) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
func (AuthIdentityChannel) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.Int64("identity_id"),
|
||||
field.String("provider_type").
|
||||
MaxLen(20).
|
||||
NotEmpty().
|
||||
Validate(validateAuthProviderType),
|
||||
field.String("provider_key").
|
||||
NotEmpty().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.String("channel").
|
||||
MaxLen(20).
|
||||
NotEmpty(),
|
||||
field.String("channel_app_id").
|
||||
NotEmpty().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.String("channel_subject").
|
||||
NotEmpty().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.JSON("metadata", map[string]any{}).
|
||||
Default(func() map[string]any { return map[string]any{} }).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (AuthIdentityChannel) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.From("identity", AuthIdentity.Type).
|
||||
Ref("channels").
|
||||
Field("identity_id").
|
||||
Required().
|
||||
Unique(),
|
||||
}
|
||||
}
|
||||
|
||||
func (AuthIdentityChannel) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("provider_type", "provider_key", "channel", "channel_app_id", "channel_subject").Unique(),
|
||||
index.Fields("identity_id"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/entc/load"
|
||||
"entgo.io/ent/schema/field"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAuthIdentityFoundationSchemas(t *testing.T) {
|
||||
spec, err := (&load.Config{Path: "."}).Load()
|
||||
require.NoError(t, err)
|
||||
|
||||
schemas := map[string]*load.Schema{}
|
||||
for _, schema := range spec.Schemas {
|
||||
schemas[schema.Name] = schema
|
||||
}
|
||||
|
||||
authIdentity := requireSchema(t, schemas, "AuthIdentity")
|
||||
requireSchemaFields(t, authIdentity,
|
||||
"user_id",
|
||||
"provider_type",
|
||||
"provider_key",
|
||||
"provider_subject",
|
||||
"verified_at",
|
||||
"issuer",
|
||||
"metadata",
|
||||
)
|
||||
requireHasUniqueIndex(t, authIdentity, "provider_type", "provider_key", "provider_subject")
|
||||
|
||||
authIdentityChannel := requireSchema(t, schemas, "AuthIdentityChannel")
|
||||
requireSchemaFields(t, authIdentityChannel,
|
||||
"identity_id",
|
||||
"provider_type",
|
||||
"provider_key",
|
||||
"channel",
|
||||
"channel_app_id",
|
||||
"channel_subject",
|
||||
"metadata",
|
||||
)
|
||||
requireHasUniqueIndex(t, authIdentityChannel, "provider_type", "provider_key", "channel", "channel_app_id", "channel_subject")
|
||||
|
||||
pendingAuthSession := requireSchema(t, schemas, "PendingAuthSession")
|
||||
requireSchemaFields(t, pendingAuthSession,
|
||||
"intent",
|
||||
"provider_type",
|
||||
"provider_key",
|
||||
"provider_subject",
|
||||
"target_user_id",
|
||||
"redirect_to",
|
||||
"resolved_email",
|
||||
"registration_password_hash",
|
||||
"upstream_identity_claims",
|
||||
"local_flow_state",
|
||||
"browser_session_key",
|
||||
"completion_code_hash",
|
||||
"completion_code_expires_at",
|
||||
"email_verified_at",
|
||||
"password_verified_at",
|
||||
"totp_verified_at",
|
||||
"expires_at",
|
||||
"consumed_at",
|
||||
)
|
||||
|
||||
adoptionDecision := requireSchema(t, schemas, "IdentityAdoptionDecision")
|
||||
requireSchemaFields(t, adoptionDecision,
|
||||
"pending_auth_session_id",
|
||||
"identity_id",
|
||||
"adopt_display_name",
|
||||
"adopt_avatar",
|
||||
"decided_at",
|
||||
)
|
||||
requireHasUniqueIndex(t, adoptionDecision, "pending_auth_session_id")
|
||||
|
||||
userSchema := requireSchema(t, schemas, "User")
|
||||
requireSchemaFields(t, userSchema, "signup_source", "last_login_at", "last_active_at")
|
||||
signupSource := requireSchemaField(t, userSchema, "signup_source")
|
||||
require.Equal(t, field.TypeString, signupSource.Info.Type)
|
||||
require.True(t, signupSource.Default)
|
||||
require.Equal(t, "email", signupSource.DefaultValue)
|
||||
require.Equal(t, 1, signupSource.Validators)
|
||||
|
||||
validator := requireStringFieldValidator(t, User{}.Fields(), "signup_source")
|
||||
for _, value := range []string{"email", "linuxdo", "wechat", "oidc", "github", "google", "dingtalk"} {
|
||||
require.NoError(t, validator(value))
|
||||
}
|
||||
require.Error(t, validator("unknown"))
|
||||
}
|
||||
|
||||
func requireSchema(t *testing.T, schemas map[string]*load.Schema, name string) *load.Schema {
|
||||
t.Helper()
|
||||
|
||||
schema, ok := schemas[name]
|
||||
require.True(t, ok, "schema %s should exist", name)
|
||||
return schema
|
||||
}
|
||||
|
||||
func requireSchemaFields(t *testing.T, schema *load.Schema, names ...string) {
|
||||
t.Helper()
|
||||
|
||||
fields := map[string]struct{}{}
|
||||
for _, field := range schema.Fields {
|
||||
fields[field.Name] = struct{}{}
|
||||
}
|
||||
|
||||
for _, name := range names {
|
||||
_, ok := fields[name]
|
||||
require.True(t, ok, "schema %s should include field %s", schema.Name, name)
|
||||
}
|
||||
}
|
||||
|
||||
func requireSchemaField(t *testing.T, schema *load.Schema, name string) *load.Field {
|
||||
t.Helper()
|
||||
|
||||
for _, schemaField := range schema.Fields {
|
||||
if schemaField.Name == name {
|
||||
return schemaField
|
||||
}
|
||||
}
|
||||
|
||||
require.Failf(t, "missing schema field", "schema %s should include field %s", schema.Name, name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireStringFieldValidator(t *testing.T, fields []ent.Field, name string) func(string) error {
|
||||
t.Helper()
|
||||
|
||||
for _, entField := range fields {
|
||||
descriptor := entField.Descriptor()
|
||||
if descriptor.Name != name {
|
||||
continue
|
||||
}
|
||||
require.NotEmpty(t, descriptor.Validators, "field %s should include a validator", name)
|
||||
validator, ok := descriptor.Validators[0].(func(string) error)
|
||||
require.True(t, ok, "field %s validator should be func(string) error", name)
|
||||
return validator
|
||||
}
|
||||
|
||||
require.Failf(t, "missing field validator", "schema should include field %s", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func requireHasUniqueIndex(t *testing.T, schema *load.Schema, fields ...string) {
|
||||
t.Helper()
|
||||
|
||||
for _, index := range schema.Indexes {
|
||||
if !index.Unique {
|
||||
continue
|
||||
}
|
||||
if len(index.Fields) != len(fields) {
|
||||
continue
|
||||
}
|
||||
match := true
|
||||
for i := range fields {
|
||||
if index.Fields[i] != fields[i] {
|
||||
match = false
|
||||
break
|
||||
}
|
||||
}
|
||||
if match {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
require.Failf(t, "missing unique index", "schema %s should include unique index on %v", schema.Name, fields)
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// BatchImageEvent records append-only operational events for batch image jobs.
|
||||
type BatchImageEvent struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (BatchImageEvent) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "batch_image_events"},
|
||||
}
|
||||
}
|
||||
|
||||
func (BatchImageEvent) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.String("job_id").MaxLen(64),
|
||||
field.String("event_type").MaxLen(64),
|
||||
field.JSON("payload", map[string]any{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
field.String("event_hash").Optional().Nillable().MaxLen(128),
|
||||
field.Time("created_at").Immutable().Default(time.Now).SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (BatchImageEvent) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("job_id", "created_at"),
|
||||
index.Fields("event_type"),
|
||||
index.Fields("job_id", "event_hash").Unique().Annotations(entsql.IndexWhere("event_hash IS NOT NULL AND event_hash <> ''")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// BatchImageItem holds indexed output rows for a batch image job.
|
||||
type BatchImageItem struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (BatchImageItem) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "batch_image_items"},
|
||||
}
|
||||
}
|
||||
|
||||
func (BatchImageItem) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.String("job_id").MaxLen(64),
|
||||
field.String("custom_id").MaxLen(255),
|
||||
field.String("status").MaxLen(32),
|
||||
field.String("request_hash").Optional().Nillable().MaxLen(128),
|
||||
field.String("prompt_preview").Optional().Nillable().SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.String("provider_source_object").Optional().Nillable().MaxLen(1024),
|
||||
field.Int("source_line_number").Optional().Nillable(),
|
||||
field.Int64("source_byte_offset").Optional().Nillable(),
|
||||
field.Int64("source_byte_length").Optional().Nillable(),
|
||||
field.String("mime_type").Optional().Nillable().MaxLen(128),
|
||||
field.String("file_extension").Optional().Nillable().MaxLen(32),
|
||||
field.Int("image_count").Default(0),
|
||||
field.String("error_code").Optional().Nillable().MaxLen(128),
|
||||
field.String("error_message").Optional().Nillable().SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.Float("billed_amount").Optional().Nillable().SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}),
|
||||
field.Time("created_at").Immutable().Default(time.Now).SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("indexed_at").Optional().Nillable().SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (BatchImageItem) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("job_id", "custom_id").Unique(),
|
||||
index.Fields("job_id", "status"),
|
||||
index.Fields("provider_source_object"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// BatchImageJob holds the schema definition for asynchronous image batch jobs.
|
||||
//
|
||||
// 删除策略:账务源保留
|
||||
// 这张表是批量生图任务的账务和状态源;用户侧删除仅通过 user_deleted_at
|
||||
// 从列表隐藏,输出清理通过 output_deleted 状态和删除时间字段表达。
|
||||
type BatchImageJob struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (BatchImageJob) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "batch_image_jobs"},
|
||||
}
|
||||
}
|
||||
|
||||
func (BatchImageJob) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.String("batch_id").MaxLen(64).Immutable(),
|
||||
field.Int64("user_id"),
|
||||
field.Int64("api_key_id").Optional().Nillable(),
|
||||
field.Int64("account_id").Optional().Nillable(),
|
||||
field.String("provider").MaxLen(32),
|
||||
field.String("model").MaxLen(128),
|
||||
field.String("task_name").MaxLen(255).Default(""),
|
||||
field.String("status").MaxLen(32).Default("created"),
|
||||
field.String("provider_job_name").Optional().Nillable().MaxLen(512),
|
||||
field.String("provider_input_ref").Optional().Nillable().MaxLen(1024),
|
||||
field.String("provider_output_ref").Optional().Nillable().MaxLen(1024),
|
||||
field.String("gcs_input_uri").Optional().Nillable().MaxLen(1024),
|
||||
field.String("gcs_output_uri").Optional().Nillable().MaxLen(1024),
|
||||
field.Int("item_count"),
|
||||
field.Int("success_count").Default(0),
|
||||
field.Int("fail_count").Default(0),
|
||||
field.Int("cancelled_count").Default(0),
|
||||
field.Float("estimated_cost").SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}).Default(0),
|
||||
field.Float("hold_amount").Optional().Nillable().SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}),
|
||||
field.Float("actual_cost").Optional().Nillable().SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}),
|
||||
field.String("currency").MaxLen(16).Default("USD"),
|
||||
field.String("hold_id").Optional().Nillable().MaxLen(128),
|
||||
field.String("idempotency_key").Optional().Nillable().MaxLen(255),
|
||||
field.String("request_hash").Optional().Nillable().MaxLen(128),
|
||||
field.String("manifest_hash").Optional().Nillable().MaxLen(128),
|
||||
field.Int("retry_count").Default(0),
|
||||
field.Int("version").Default(0),
|
||||
field.Time("output_expires_at").Optional().Nillable().SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("input_deleted_at").Optional().Nillable().SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("output_deleted_at").Optional().Nillable().SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("downloaded_at").Optional().Nillable().SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("user_deleted_at").Optional().Nillable().SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.String("last_error_code").Optional().Nillable().MaxLen(128),
|
||||
field.String("last_error_message").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.Time("updated_at").Default(time.Now).UpdateDefault(time.Now).SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("submitted_at").Optional().Nillable().SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("started_at").Optional().Nillable().SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("finished_at").Optional().Nillable().SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("settled_at").Optional().Nillable().SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (BatchImageJob) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("batch_id").Unique(),
|
||||
index.Fields("user_id", "created_at"),
|
||||
index.Fields("status"),
|
||||
index.Fields("provider", "status"),
|
||||
index.Fields("idempotency_key").Annotations(entsql.IndexWhere("idempotency_key IS NOT NULL AND idempotency_key <> ''")),
|
||||
index.Fields("manifest_hash").Unique().Annotations(entsql.IndexWhere("manifest_hash IS NOT NULL AND manifest_hash <> ''")),
|
||||
index.Fields("output_expires_at"),
|
||||
index.Fields("downloaded_at"),
|
||||
index.Fields("user_deleted_at"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
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/edge"
|
||||
"entgo.io/ent/schema/field"
|
||||
"entgo.io/ent/schema/index"
|
||||
)
|
||||
|
||||
// ChannelMonitor holds the schema definition for the ChannelMonitor entity.
|
||||
// 渠道监控配置:定期对指定 provider/endpoint/api_key 下的模型做心跳测试。
|
||||
type ChannelMonitor struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (ChannelMonitor) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "channel_monitors"},
|
||||
}
|
||||
}
|
||||
|
||||
func (ChannelMonitor) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
func (ChannelMonitor) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.String("name").
|
||||
NotEmpty().
|
||||
MaxLen(100),
|
||||
field.Enum("provider").
|
||||
Values("openai", "anthropic", "gemini", "grok",
|
||||
"antigravity", "kimi", "zhipu", "deepseek"),
|
||||
// check_mode: 'probe' | 'quota' | 'quota_probe'
|
||||
// probe - LLM 探活(默认,原有行为)
|
||||
// quota - 仅查关联账号的用量/余额(零 LLM 成本;endpoint/api_key 可空)
|
||||
// quota_probe - 探活 + 配额并存(配额快照挂到主模型历史行)
|
||||
// antigravity 无探活 adapter,仅允许 quota。
|
||||
field.String("check_mode").
|
||||
Default("probe").
|
||||
MaxLen(32).
|
||||
Comment("probe = LLM probe (default); quota = account usage only; quota_probe = both"),
|
||||
// account_id: 配额模式的数据源账号(复用账号侧用量服务,不直接对接上游)。
|
||||
// 普通字段而非 edge(FK 由 SQL 迁移管理);账号删除时数据库置空,
|
||||
// 监控保留并报「账号未关联」。
|
||||
field.Int64("account_id").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.String("api_mode").
|
||||
Default("chat_completions").
|
||||
MaxLen(32).
|
||||
Comment("OpenAI request protocol: chat_completions or responses; non-OpenAI uses chat_completions"),
|
||||
// endpoint: 探活模式必填(service 层校验);quota 模式存空串
|
||||
// (列保持 NOT NULL,去掉 NotEmpty 校验器即可)。
|
||||
field.String("endpoint").
|
||||
MaxLen(500).
|
||||
Comment("Provider base origin, e.g. https://api.openai.com; empty for quota-only monitors"),
|
||||
field.String("api_key_encrypted").
|
||||
NotEmpty().
|
||||
Sensitive().
|
||||
Comment("AES-256-GCM encrypted API key"),
|
||||
field.String("primary_model").
|
||||
NotEmpty().
|
||||
MaxLen(200),
|
||||
field.JSON("extra_models", []string{}).
|
||||
Default([]string{}).
|
||||
Comment("Additional model names to test alongside primary_model"),
|
||||
field.String("group_name").
|
||||
Optional().
|
||||
Default("").
|
||||
MaxLen(100),
|
||||
field.Bool("enabled").
|
||||
Default(true),
|
||||
field.Int("interval_seconds").
|
||||
Range(15, 3600),
|
||||
field.Int("jitter_seconds").
|
||||
Default(0).
|
||||
Range(0, 3600).
|
||||
Comment("每次调度在 interval 基础上 ± [0, jitter] 的均匀随机偏移(秒);0 表示固定间隔。service 层另保证 interval - jitter >= 15"),
|
||||
field.Time("last_checked_at").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.Int64("created_by"),
|
||||
|
||||
// ---- 自定义请求快照字段(来自模板 / 手动编辑) ----
|
||||
|
||||
// template_id: 关联的请求模板 ID(仅用于 UI 分组 + 一键应用)。
|
||||
// 实际运行时 checker 只读下面 3 个快照字段,**不再回查模板表**。
|
||||
// 模板被删除时此字段会被 SET NULL(见 Edges 的 OnDelete 注解)。
|
||||
field.Int64("template_id").
|
||||
Optional().
|
||||
Nillable(),
|
||||
// extra_headers: 自定义 HTTP 头快照(来自模板 or 用户手填)。
|
||||
// 运行时 merge 进 adapter 默认 headers。
|
||||
field.JSON("extra_headers", map[string]string{}).
|
||||
Default(map[string]string{}),
|
||||
// body_override_mode: 同 ChannelMonitorRequestTemplate.body_override_mode
|
||||
field.String("body_override_mode").
|
||||
Default("off").
|
||||
MaxLen(10),
|
||||
// body_override: 同 ChannelMonitorRequestTemplate.body_override
|
||||
field.JSON("body_override", map[string]any{}).
|
||||
Optional(),
|
||||
}
|
||||
}
|
||||
|
||||
func (ChannelMonitor) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.To("history", ChannelMonitorHistory.Type).
|
||||
Annotations(entsql.OnDelete(entsql.Cascade)),
|
||||
edge.To("daily_rollups", ChannelMonitorDailyRollup.Type).
|
||||
Annotations(entsql.OnDelete(entsql.Cascade)),
|
||||
// 关联请求模板:模板被删除时 template_id 自动置空,
|
||||
// 监控本身保留(继续用快照字段跑)。
|
||||
edge.To("request_template", ChannelMonitorRequestTemplate.Type).
|
||||
Field("template_id").
|
||||
Unique().
|
||||
Annotations(entsql.OnDelete(entsql.SetNull)),
|
||||
}
|
||||
}
|
||||
|
||||
func (ChannelMonitor) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("enabled", "last_checked_at"),
|
||||
index.Fields("provider"),
|
||||
index.Fields("provider", "api_mode"),
|
||||
index.Fields("group_name"),
|
||||
index.Fields("template_id"),
|
||||
index.Fields("account_id"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// ChannelMonitorDailyRollup 按 (monitor_id, model, bucket_date) 维度聚合的渠道监控日统计。
|
||||
// 每天的明细被收敛为一行(保留 status 分布 + 延迟和),用于 7d/15d/30d 窗口的可用率
|
||||
// 加权计算(avg_latency = sum_latency_ms / count_latency;availability = ok_count / total_checks)。
|
||||
// 超过保留期由每日维护任务分批物理删(不用软删除,理由同 channel_monitor_history)。
|
||||
type ChannelMonitorDailyRollup struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (ChannelMonitorDailyRollup) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "channel_monitor_daily_rollups"},
|
||||
}
|
||||
}
|
||||
|
||||
func (ChannelMonitorDailyRollup) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.Int64("monitor_id"),
|
||||
field.String("model").
|
||||
NotEmpty().
|
||||
MaxLen(200),
|
||||
field.Time("bucket_date").
|
||||
SchemaType(map[string]string{dialect.Postgres: "date"}),
|
||||
field.Int("total_checks").Default(0),
|
||||
field.Int("ok_count").Default(0),
|
||||
field.Int("operational_count").Default(0),
|
||||
field.Int("degraded_count").Default(0),
|
||||
field.Int("failed_count").Default(0),
|
||||
field.Int("error_count").Default(0),
|
||||
field.Int64("sum_latency_ms").Default(0),
|
||||
field.Int("count_latency").Default(0),
|
||||
field.Int64("sum_ping_latency_ms").Default(0),
|
||||
field.Int("count_ping_latency").Default(0),
|
||||
field.Time("computed_at").Default(time.Now).UpdateDefault(time.Now),
|
||||
}
|
||||
}
|
||||
|
||||
func (ChannelMonitorDailyRollup) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.From("monitor", ChannelMonitor.Type).
|
||||
Ref("daily_rollups").
|
||||
Field("monitor_id").
|
||||
Unique().
|
||||
Required(),
|
||||
}
|
||||
}
|
||||
|
||||
func (ChannelMonitorDailyRollup) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("monitor_id", "model", "bucket_date").Unique(),
|
||||
index.Fields("bucket_date"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// ChannelMonitorHistory holds the schema definition for the ChannelMonitorHistory entity.
|
||||
// 渠道监控历史:每次检测每个模型一行记录。明细只保留 1 天,超过 1 天由每日维护任务
|
||||
// 先聚合到 channel_monitor_daily_rollups,再分批物理删(不用软删除:日志类表无恢复
|
||||
// 需求,软删会让行和索引只增不减,徒增磁盘和查询开销)。
|
||||
type ChannelMonitorHistory struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (ChannelMonitorHistory) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "channel_monitor_histories"},
|
||||
}
|
||||
}
|
||||
|
||||
func (ChannelMonitorHistory) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.Int64("monitor_id"),
|
||||
field.String("model").
|
||||
NotEmpty().
|
||||
MaxLen(200),
|
||||
field.Enum("status").
|
||||
Values("operational", "degraded", "failed", "error"),
|
||||
field.Int("latency_ms").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.Int("ping_latency_ms").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.String("message").
|
||||
Optional().
|
||||
Default("").
|
||||
MaxLen(500),
|
||||
// quota: 配额模式(check_mode = quota / quota_probe)检测时附带的
|
||||
// 归一化配额快照(domain.MonitorQuotaSnapshot,JSONB);探活模式为 NULL。
|
||||
field.JSON("quota", &domain.MonitorQuotaSnapshot{}).
|
||||
Optional(),
|
||||
field.Time("checked_at").
|
||||
Default(time.Now),
|
||||
}
|
||||
}
|
||||
|
||||
func (ChannelMonitorHistory) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.From("monitor", ChannelMonitor.Type).
|
||||
Ref("history").
|
||||
Field("monitor_id").
|
||||
Unique().
|
||||
Required(),
|
||||
}
|
||||
}
|
||||
|
||||
func (ChannelMonitorHistory) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("monitor_id", "model", "checked_at"),
|
||||
index.Fields("checked_at"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
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/edge"
|
||||
"entgo.io/ent/schema/field"
|
||||
"entgo.io/ent/schema/index"
|
||||
)
|
||||
|
||||
// ChannelMonitorRequestTemplate 请求模板:一组可复用的 headers + 可选 body 覆盖配置。
|
||||
//
|
||||
// 语义为快照:模板被"应用"到监控时,extra_headers / body_override_mode / body_override
|
||||
// 会被**拷贝**到 channel_monitors 同名字段;后续模板变动不会自动影响已应用的监控——
|
||||
// 必须用户主动在模板编辑 Dialog 里点「应用到关联监控」才会覆盖快照。
|
||||
// 这样模板改错不会瞬间打挂所有已经跑起来的监控。
|
||||
type ChannelMonitorRequestTemplate struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (ChannelMonitorRequestTemplate) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "channel_monitor_request_templates"},
|
||||
}
|
||||
}
|
||||
|
||||
func (ChannelMonitorRequestTemplate) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
func (ChannelMonitorRequestTemplate) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.String("name").
|
||||
NotEmpty().
|
||||
MaxLen(100),
|
||||
field.Enum("provider").
|
||||
Values("openai", "anthropic", "gemini", "grok",
|
||||
"antigravity", "kimi", "zhipu", "deepseek"),
|
||||
field.String("api_mode").
|
||||
Default("chat_completions").
|
||||
MaxLen(32).
|
||||
Comment("OpenAI request protocol: chat_completions or responses; non-OpenAI uses chat_completions"),
|
||||
field.String("description").
|
||||
Optional().
|
||||
Default("").
|
||||
MaxLen(500),
|
||||
// extra_headers: 用户自定义 HTTP 头(如 User-Agent 伪装)。
|
||||
// 运行时 merge 进 adapter 默认 headers,用户值优先;
|
||||
// hop-by-hop 黑名单(Host/Content-Length/...)由 checker 过滤。
|
||||
field.JSON("extra_headers", map[string]string{}).
|
||||
Default(map[string]string{}),
|
||||
// body_override_mode: 'off' | 'merge' | 'replace'
|
||||
// off - 用 adapter 默认 body(忽略 body_override)
|
||||
// merge - adapter 默认 body 与 body_override 浅合并(body_override 优先,
|
||||
// model/messages/contents 等关键字段在 checker 里走黑名单跳过)
|
||||
// replace - 直接用 body_override 作为完整 body;此时跳过 challenge 校验,
|
||||
// 改为 HTTP 2xx + 响应文本非空即视为可用
|
||||
field.String("body_override_mode").
|
||||
Default("off").
|
||||
MaxLen(10),
|
||||
// body_override: JSON 对象,根据 body_override_mode 使用。
|
||||
// 用 map[string]any 以便前端传任意结构(含嵌套)。
|
||||
field.JSON("body_override", map[string]any{}).
|
||||
Optional(),
|
||||
}
|
||||
}
|
||||
|
||||
func (ChannelMonitorRequestTemplate) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.From("monitors", ChannelMonitor.Type).
|
||||
Ref("request_template"),
|
||||
}
|
||||
}
|
||||
|
||||
func (ChannelMonitorRequestTemplate) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
// 同一 provider 内 name 唯一:允许 Anthropic + OpenAI 重名 "伪装官方客户端"。
|
||||
index.Fields("provider", "name").Unique(),
|
||||
index.Fields("provider", "api_mode"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// CompositeModelRoute holds model routing aliases for composite groups.
|
||||
type CompositeModelRoute struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (CompositeModelRoute) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "composite_model_routes"},
|
||||
}
|
||||
}
|
||||
|
||||
func (CompositeModelRoute) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
mixins.SoftDeleteMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
func (CompositeModelRoute) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.Int64("group_id"),
|
||||
field.String("public_model").
|
||||
MaxLen(200).
|
||||
NotEmpty().
|
||||
Comment("Client-facing model identifier or prefix."),
|
||||
field.String("match_type").
|
||||
MaxLen(20).
|
||||
Default("exact").
|
||||
Comment("exact or prefix."),
|
||||
field.String("target_platform").
|
||||
MaxLen(50).
|
||||
Default(domain.PlatformOpenAI).
|
||||
Comment("Concrete provider platform."),
|
||||
field.String("upstream_model").
|
||||
MaxLen(200).
|
||||
Default("").
|
||||
Comment("Provider model identifier; empty means public_model."),
|
||||
field.String("endpoint").
|
||||
MaxLen(50).
|
||||
Default("any").
|
||||
Comment("Endpoint scope such as any, messages, responses, chat_completions."),
|
||||
field.Int("priority").
|
||||
Default(100).
|
||||
Comment("Lower values win within the same match strength."),
|
||||
field.Bool("enabled").
|
||||
Default(true),
|
||||
field.String("notes").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (CompositeModelRoute) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.To("group", Group.Type).
|
||||
Unique().
|
||||
Required().
|
||||
Field("group_id"),
|
||||
}
|
||||
}
|
||||
|
||||
func (CompositeModelRoute) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("group_id"),
|
||||
index.Fields("group_id", "enabled"),
|
||||
index.Fields("group_id", "endpoint"),
|
||||
index.Fields("group_id", "target_platform"),
|
||||
index.Fields("deleted_at"),
|
||||
index.Fields("priority"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// Package schema 定义 Ent ORM 的数据库 schema。
|
||||
package schema
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// ErrorPassthroughRule 定义全局错误透传规则的 schema。
|
||||
//
|
||||
// 错误透传规则用于控制上游错误如何返回给客户端:
|
||||
// - 匹配条件:错误码 + 关键词组合
|
||||
// - 响应行为:透传原始信息 或 自定义错误信息
|
||||
// - 响应状态码:可指定返回给客户端的状态码
|
||||
// - 平台范围:规则适用的平台(Anthropic、OpenAI、Gemini、Antigravity)
|
||||
type ErrorPassthroughRule struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
// Annotations 返回 schema 的注解配置。
|
||||
func (ErrorPassthroughRule) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "error_passthrough_rules"},
|
||||
}
|
||||
}
|
||||
|
||||
// Mixin 返回该 schema 使用的混入组件。
|
||||
func (ErrorPassthroughRule) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
// Fields 定义错误透传规则实体的所有字段。
|
||||
func (ErrorPassthroughRule) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
// name: 规则名称,用于在界面中标识规则
|
||||
field.String("name").
|
||||
MaxLen(100).
|
||||
NotEmpty(),
|
||||
|
||||
// enabled: 是否启用该规则
|
||||
field.Bool("enabled").
|
||||
Default(true),
|
||||
|
||||
// priority: 规则优先级,数值越小优先级越高
|
||||
// 匹配时按优先级顺序检查,命中第一个匹配的规则
|
||||
field.Int("priority").
|
||||
Default(0),
|
||||
|
||||
// error_codes: 匹配的错误码列表(OR关系)
|
||||
// 例如:[422, 400] 表示匹配 422 或 400 错误码
|
||||
field.JSON("error_codes", []int{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// keywords: 匹配的关键词列表(OR关系)
|
||||
// 例如:["context limit", "model not supported"]
|
||||
// 关键词匹配不区分大小写
|
||||
field.JSON("keywords", []string{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// match_mode: 匹配模式
|
||||
// - "any": 错误码匹配 OR 关键词匹配(任一条件满足即可)
|
||||
// - "all": 错误码匹配 AND 关键词匹配(所有条件都必须满足)
|
||||
field.String("match_mode").
|
||||
MaxLen(10).
|
||||
Default("any"),
|
||||
|
||||
// platforms: 适用平台列表
|
||||
// 例如:["anthropic", "openai", "gemini", "antigravity"]
|
||||
// 空列表表示适用于所有平台
|
||||
field.JSON("platforms", []string{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// passthrough_code: 是否透传上游原始状态码
|
||||
// true: 使用上游返回的状态码
|
||||
// false: 使用 response_code 指定的状态码
|
||||
field.Bool("passthrough_code").
|
||||
Default(true),
|
||||
|
||||
// response_code: 自定义响应状态码
|
||||
// 当 passthrough_code=false 时使用此状态码
|
||||
field.Int("response_code").
|
||||
Optional().
|
||||
Nillable(),
|
||||
|
||||
// passthrough_body: 是否透传上游原始错误信息
|
||||
// true: 使用上游返回的错误信息
|
||||
// false: 使用 custom_message 指定的错误信息
|
||||
field.Bool("passthrough_body").
|
||||
Default(true),
|
||||
|
||||
// custom_message: 自定义错误信息
|
||||
// 当 passthrough_body=false 时使用此错误信息
|
||||
field.Text("custom_message").
|
||||
Optional().
|
||||
Nillable(),
|
||||
|
||||
// skip_monitoring: 是否跳过运维监控记录
|
||||
// true: 匹配此规则的错误不会被记录到 ops_error_logs
|
||||
// false: 正常记录到运维监控(默认行为)
|
||||
field.Bool("skip_monitoring").
|
||||
Default(false),
|
||||
|
||||
// description: 规则描述,用于说明规则的用途
|
||||
field.Text("description").
|
||||
Optional().
|
||||
Nillable(),
|
||||
}
|
||||
}
|
||||
|
||||
// Indexes 定义数据库索引,优化查询性能。
|
||||
func (ErrorPassthroughRule) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("enabled"), // 筛选启用的规则
|
||||
index.Fields("priority"), // 按优先级排序
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
|
||||
"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{
|
||||
// 唯一约束通过部分索引实现(WHERE deleted_at IS NULL),支持软删除后重用
|
||||
// 见迁移文件 016_soft_delete_partial_unique_indexes.sql
|
||||
field.String("name").
|
||||
MaxLen(100).
|
||||
NotEmpty(),
|
||||
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),
|
||||
// 高峰时段倍率(added by migration 158)
|
||||
field.Bool("peak_rate_enabled").
|
||||
Default(false).
|
||||
Comment("是否启用高峰时段倍率"),
|
||||
field.String("peak_start").
|
||||
MaxLen(5).
|
||||
Default("").
|
||||
Comment("高峰开始时间 HH:MM(含),如 14:00;空表示未配置;不支持跨天"),
|
||||
field.String("peak_end").
|
||||
MaxLen(5).
|
||||
Default("").
|
||||
Comment("高峰结束时间 HH:MM(不含),必须大于 peak_start;不支持跨天,如 22:00-02:00"),
|
||||
field.Float("peak_rate_multiplier").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
|
||||
Default(1.0).
|
||||
Comment("高峰时段叠加倍率,仅在 peak_rate_enabled 且处于 [peak_start, peak_end) 时乘入文本倍率"),
|
||||
field.Bool("is_exclusive").
|
||||
Default(false),
|
||||
field.String("status").
|
||||
MaxLen(20).
|
||||
Default(domain.StatusActive),
|
||||
field.String("duplicate_operation_id").
|
||||
MaxLen(64).
|
||||
Optional().
|
||||
Nillable().
|
||||
Immutable().
|
||||
Comment("内部幂等恢复标识,不对 API 暴露"),
|
||||
|
||||
// Subscription-related fields (added by migration 003)
|
||||
field.String("platform").
|
||||
MaxLen(50).
|
||||
Default(domain.PlatformAnthropic),
|
||||
field.String("subscription_type").
|
||||
MaxLen(20).
|
||||
Default(domain.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)"}),
|
||||
field.Int("default_validity_days").
|
||||
Default(30),
|
||||
|
||||
// 图片生成计费配置(antigravity 和 gemini 平台使用)
|
||||
field.Bool("allow_image_generation").
|
||||
Default(false).
|
||||
Comment("是否允许该分组使用图片生成能力"),
|
||||
field.Bool("allow_batch_image_generation").
|
||||
Default(false).
|
||||
Comment("是否允许该分组使用批量图片生成能力"),
|
||||
field.Bool("image_rate_independent").
|
||||
Default(false).
|
||||
Comment("图片生成是否使用独立倍率;false 表示共享分组有效倍率"),
|
||||
field.Float("image_rate_multiplier").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
|
||||
Default(1.0).
|
||||
Comment("图片生成独立倍率,仅 image_rate_independent=true 时生效"),
|
||||
field.Float("image_price_1k").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}),
|
||||
field.Float("image_price_2k").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}),
|
||||
field.Float("image_price_4k").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}),
|
||||
field.Float("batch_image_discount_multiplier").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
|
||||
Default(0.5).
|
||||
Comment("批量图片生成折扣倍率,最终单价会乘以该值;0 表示免费"),
|
||||
field.Float("batch_image_hold_multiplier").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
|
||||
Default(0.6).
|
||||
Comment("批量图片生成冻结价格比例,按普通生图原价乘以该比例冻结,结算后释放差额"),
|
||||
field.Bool("video_rate_independent").
|
||||
Default(false).
|
||||
Comment("视频生成是否使用独立倍率;false 表示共享分组有效倍率"),
|
||||
field.Float("video_rate_multiplier").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
|
||||
Default(1.0).
|
||||
Comment("视频生成独立倍率,仅 video_rate_independent=true 时生效"),
|
||||
field.Float("video_price_480p").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}),
|
||||
field.Float("video_price_720p").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}),
|
||||
field.Float("video_price_1080p").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}),
|
||||
field.JSON("video_model_prices", map[string]map[string]float64{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("按模型族和分辨率覆盖视频每秒价格"),
|
||||
field.Float("web_search_price_per_call").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Comment("Codex alpha/search 网页搜索单次价格(USD/次);nil 表示使用默认价 0.01(官方 $10/1000 次)"),
|
||||
|
||||
// 搜索/工具调用显式定价(per 1k calls),用于 Grok web_search 等。
|
||||
field.Float("search_price_per_1k").
|
||||
Optional().
|
||||
Nillable().
|
||||
Min(0).
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Comment("搜索工具价格 per 1000 calls(web_search 等)"),
|
||||
|
||||
// Grok Voice 显式定价(realtime / TTS / STT),不按文本 RateMultiplier。
|
||||
field.Float("audio_realtime_price_per_min").
|
||||
Optional().
|
||||
Nillable().
|
||||
Min(0).
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Comment("Voice realtime 每分钟价格(USD)"),
|
||||
field.Float("audio_tts_price_per_million_chars").
|
||||
Optional().
|
||||
Nillable().
|
||||
Min(0).
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Comment("TTS 每百万字符价格(USD)"),
|
||||
field.Float("audio_stt_price_per_hour").
|
||||
Optional().
|
||||
Nillable().
|
||||
Min(0).
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Comment("STT 每小时价格(USD)"),
|
||||
field.Bool("long_context_pricing_enabled").
|
||||
Default(true).
|
||||
Comment("是否按上下文长度应用模型阶梯价格;默认开启以保持官方/渠道长上下文价"),
|
||||
field.JSON("model_pricing", json.RawMessage{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("分组逐模型定价;优先级高于渠道和内置定价"),
|
||||
|
||||
// Claude Code 客户端限制 (added by migration 029)
|
||||
field.Bool("claude_code_only").
|
||||
Default(false).
|
||||
Comment("是否仅允许 Claude Code 客户端"),
|
||||
field.Int64("fallback_group_id").
|
||||
Optional().
|
||||
Nillable().
|
||||
Comment("非 Claude Code 请求降级使用的分组 ID"),
|
||||
field.Int64("fallback_group_id_on_invalid_request").
|
||||
Optional().
|
||||
Nillable().
|
||||
Comment("无效请求兜底使用的分组 ID"),
|
||||
|
||||
// 模型路由配置 (added by migration 040)
|
||||
field.JSON("model_routing", map[string][]int64{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("模型路由配置:模型模式 -> 优先账号ID列表"),
|
||||
|
||||
// 模型路由开关 (added by migration 041)
|
||||
field.Bool("model_routing_enabled").
|
||||
Default(false).
|
||||
Comment("是否启用模型路由配置"),
|
||||
|
||||
// MCP XML 协议注入开关 (added by migration 042)
|
||||
field.Bool("mcp_xml_inject").
|
||||
Default(true).
|
||||
Comment("是否注入 MCP XML 调用协议提示词(仅 antigravity 平台)"),
|
||||
|
||||
// 支持的模型系列 (added by migration 046)
|
||||
field.JSON("supported_model_scopes", []string{}).
|
||||
Default([]string{"claude", "gemini_text", "gemini_image"}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("支持的模型系列:claude, gemini_text, gemini_image"),
|
||||
|
||||
// 分组排序 (added by migration 052)
|
||||
field.Int("sort_order").
|
||||
Default(0).
|
||||
Comment("分组显示排序,数值越小越靠前"),
|
||||
|
||||
// OpenAI Messages 调度配置 (added by migration 069)
|
||||
field.Bool("allow_messages_dispatch").
|
||||
Default(false).
|
||||
Comment("是否允许 /v1/messages 调度到此 OpenAI 分组"),
|
||||
field.Bool("allow_live").
|
||||
Default(false).
|
||||
Comment("是否允许此 OpenAI 分组访问 Live 接口"),
|
||||
field.Bool("require_oauth_only").
|
||||
Default(false).
|
||||
Comment("仅允许非 apikey 类型账号关联到此分组"),
|
||||
field.Bool("require_privacy_set").
|
||||
Default(false).
|
||||
Comment("调度时仅允许 privacy 已成功设置的账号"),
|
||||
field.String("default_mapped_model").
|
||||
MaxLen(100).
|
||||
Default("").
|
||||
Comment("默认映射模型 ID,当账号级映射找不到时使用此值"),
|
||||
field.JSON("messages_dispatch_model_config", domain.OpenAIMessagesDispatchModelConfig{}).
|
||||
Default(domain.OpenAIMessagesDispatchModelConfig{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型"),
|
||||
field.JSON("models_list_config", domain.GroupModelsListConfig{}).
|
||||
Default(domain.GroupModelsListConfig{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("自定义 /v1/models 展示列表配置;仅影响模型列表响应,不影响调度"),
|
||||
|
||||
// 分组级每分钟请求数上限(0 = 不限制)。设置后优先于用户级兜底生效。
|
||||
field.Int("rpm_limit").
|
||||
Default(0).
|
||||
Comment("分组 RPM 上限,0 表示不限制;设置后接管该分组用户的限流"),
|
||||
|
||||
// OpenAI/Codex 请求的推理强度上限(空字符串表示不限制)。
|
||||
field.String("max_reasoning_effort").
|
||||
MaxLen(20).
|
||||
Default("").
|
||||
Comment("OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max"),
|
||||
field.JSON("reasoning_effort_mappings", []domain.ReasoningEffortMapping{}).
|
||||
Default([]domain.ReasoningEffortMapping{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("OpenAI reasoning effort 自定义精确映射;先映射再应用上限"),
|
||||
|
||||
// 分组利润控制(migration 192/193):openai/anthropic/gemini/grok/antigravity
|
||||
// 的 token 分组可启用,composite 分组不能直接启用。
|
||||
field.Bool("profit_control_enabled").
|
||||
Default(false).
|
||||
Comment("是否启用利润控制:调度时仅允许账号计费倍率满足毛利率要求的账号进入候选池"),
|
||||
field.Float("profit_min_margin").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
|
||||
Default(0).
|
||||
Comment("最低毛利率,小数(0.30=30%);账号准入条件为 U <= D*(1-margin-buffer)"),
|
||||
field.Float("profit_safety_buffer").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
|
||||
Default(0).
|
||||
Comment("安全缓冲,小数;与 margin 相加后从下游倍率中扣除,默认 0"),
|
||||
}
|
||||
}
|
||||
|
||||
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.To("usage_logs", UsageLog.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),
|
||||
// 注意:fallback_group_id 直接作为字段使用,不定义 edge
|
||||
// 这样允许多个分组指向同一个降级分组(M2O 关系)
|
||||
}
|
||||
}
|
||||
|
||||
func (Group) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
// name 字段已在 Fields() 中声明 Unique(),无需重复索引
|
||||
index.Fields("status"),
|
||||
index.Fields("platform"),
|
||||
index.Fields("subscription_type"),
|
||||
index.Fields("is_exclusive"),
|
||||
index.Fields("deleted_at"),
|
||||
index.Fields("sort_order"),
|
||||
index.Fields("duplicate_operation_id").
|
||||
Unique().
|
||||
StorageKey("idx_groups_duplicate_operation_id_active").
|
||||
Annotations(entsql.IndexWhere("duplicate_operation_id IS NOT NULL AND deleted_at IS NULL")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// IdempotencyRecord 幂等请求记录表。
|
||||
type IdempotencyRecord struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (IdempotencyRecord) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "idempotency_records"},
|
||||
}
|
||||
}
|
||||
|
||||
func (IdempotencyRecord) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
func (IdempotencyRecord) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.String("scope").MaxLen(128),
|
||||
field.String("idempotency_key_hash").MaxLen(64),
|
||||
field.String("request_fingerprint").MaxLen(64),
|
||||
field.String("status").MaxLen(32),
|
||||
field.Int("response_status").Optional().Nillable(),
|
||||
field.String("response_body").Optional().Nillable(),
|
||||
field.String("error_reason").MaxLen(128).Optional().Nillable(),
|
||||
field.Time("locked_until").Optional().Nillable(),
|
||||
field.Time("expires_at"),
|
||||
}
|
||||
}
|
||||
|
||||
func (IdempotencyRecord) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("scope", "idempotency_key_hash").Unique(),
|
||||
index.Fields("expires_at"),
|
||||
index.Fields("status", "locked_until"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// IdentityAdoptionDecision stores the one-time profile adoption choice captured during a pending auth flow.
|
||||
type IdentityAdoptionDecision struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (IdentityAdoptionDecision) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "identity_adoption_decisions"},
|
||||
}
|
||||
}
|
||||
|
||||
func (IdentityAdoptionDecision) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
func (IdentityAdoptionDecision) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.Int64("pending_auth_session_id"),
|
||||
field.Int64("identity_id").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.Bool("adopt_display_name").
|
||||
Default(false),
|
||||
field.Bool("adopt_avatar").
|
||||
Default(false),
|
||||
field.Time("decided_at").
|
||||
Immutable().
|
||||
Default(time.Now).
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (IdentityAdoptionDecision) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.From("pending_auth_session", PendingAuthSession.Type).
|
||||
Ref("adoption_decision").
|
||||
Field("pending_auth_session_id").
|
||||
Required().
|
||||
Unique(),
|
||||
edge.From("identity", AuthIdentity.Type).
|
||||
Ref("adoption_decisions").
|
||||
Field("identity_id").
|
||||
Unique(),
|
||||
}
|
||||
}
|
||||
|
||||
func (IdentityAdoptionDecision) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("pending_auth_session_id").Unique(),
|
||||
index.Fields("identity_id"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
// Package mixins 提供 Ent schema 的可复用混入组件。
|
||||
// 包括时间戳混入、软删除混入等通用功能。
|
||||
package mixins
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"time"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect"
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"entgo.io/ent/schema/field"
|
||||
"entgo.io/ent/schema/mixin"
|
||||
"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))
|
||||
})
|
||||
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 mutateWithClient(ctx, m, next)
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// applyPredicate 为查询添加 deleted_at IS NULL 条件。
|
||||
// 这是软删除过滤的核心实现。
|
||||
func (d SoftDeleteMixin) applyPredicate(w interface{ WhereP(...func(*sql.Selector)) }) {
|
||||
w.WhereP(
|
||||
sql.FieldIsNull(d.Fields()[0].Descriptor().Name),
|
||||
)
|
||||
}
|
||||
|
||||
func mutateWithClient(ctx context.Context, m ent.Mutation, fallback ent.Mutator) (ent.Value, error) {
|
||||
clientMethod := reflect.ValueOf(m).MethodByName("Client")
|
||||
if !clientMethod.IsValid() || clientMethod.Type().NumIn() != 0 || clientMethod.Type().NumOut() != 1 {
|
||||
return nil, fmt.Errorf("soft delete: mutation client method not found for %T", m)
|
||||
}
|
||||
client := clientMethod.Call(nil)[0]
|
||||
mutateMethod := client.MethodByName("Mutate")
|
||||
if !mutateMethod.IsValid() {
|
||||
return nil, fmt.Errorf("soft delete: mutation client missing Mutate for %T", m)
|
||||
}
|
||||
if mutateMethod.Type().NumIn() != 2 || mutateMethod.Type().NumOut() != 2 {
|
||||
return nil, fmt.Errorf("soft delete: mutation client signature mismatch for %T", m)
|
||||
}
|
||||
|
||||
results := mutateMethod.Call([]reflect.Value{reflect.ValueOf(ctx), reflect.ValueOf(m)})
|
||||
value := results[0].Interface()
|
||||
var err error
|
||||
if !results[1].IsNil() {
|
||||
errValue := results[1].Interface()
|
||||
typedErr, ok := errValue.(error)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("soft delete: unexpected error type %T for %T", errValue, m)
|
||||
}
|
||||
err = typedErr
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value == nil {
|
||||
return nil, fmt.Errorf("soft delete: mutation client returned nil for %T", m)
|
||||
}
|
||||
v, ok := value.(ent.Value)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("soft delete: unexpected value type %T for %T", value, m)
|
||||
}
|
||||
return v, nil
|
||||
}
|
||||
@@ -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",
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// PaymentAuditLog holds the schema definition for the PaymentAuditLog entity.
|
||||
//
|
||||
// 删除策略:硬删除
|
||||
// PaymentAuditLog 使用硬删除而非软删除,原因如下:
|
||||
// - 审计日志本身即为不可变记录,通常只追加不修改
|
||||
// - 如需清理历史日志,直接按时间范围批量删除即可
|
||||
// - 保持表结构简洁,提升插入和查询性能
|
||||
type PaymentAuditLog struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (PaymentAuditLog) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "payment_audit_logs"},
|
||||
}
|
||||
}
|
||||
|
||||
func (PaymentAuditLog) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.String("order_id").
|
||||
MaxLen(64),
|
||||
field.String("action").
|
||||
MaxLen(50),
|
||||
field.String("detail").
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}).
|
||||
Default(""),
|
||||
field.String("operator").
|
||||
MaxLen(100).
|
||||
Default("system"),
|
||||
field.Time("created_at").
|
||||
Immutable().
|
||||
Default(time.Now).
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (PaymentAuditLog) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("order_id"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// PaymentOrder holds the schema definition for the PaymentOrder entity.
|
||||
//
|
||||
// 删除策略:硬删除
|
||||
// PaymentOrder 使用硬删除而非软删除,原因如下:
|
||||
// - 订单通过 status 字段追踪完整生命周期,无需依赖软删除
|
||||
// - 订单审计通过 PaymentAuditLog 表记录,删除前可归档
|
||||
// - 减少查询复杂度,避免软删除过滤开销
|
||||
type PaymentOrder struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (PaymentOrder) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "payment_orders"},
|
||||
}
|
||||
}
|
||||
|
||||
func (PaymentOrder) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
// 用户信息(冗余存储,避免关联查询)
|
||||
field.Int64("user_id"),
|
||||
field.String("user_email").
|
||||
MaxLen(255),
|
||||
field.String("user_name").
|
||||
MaxLen(100),
|
||||
field.String("user_notes").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
|
||||
// 金额信息
|
||||
field.Float("amount").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,2)"}),
|
||||
field.Float("pay_amount").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,2)"}),
|
||||
field.Float("fee_rate").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
|
||||
Default(0),
|
||||
field.String("recharge_code").
|
||||
MaxLen(64),
|
||||
|
||||
// 支付信息
|
||||
field.String("out_trade_no").
|
||||
MaxLen(64).
|
||||
Default(""),
|
||||
field.String("payment_type").
|
||||
MaxLen(30),
|
||||
field.String("payment_trade_no").
|
||||
MaxLen(128),
|
||||
field.String("pay_url").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.String("qr_code").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.String("qr_code_img").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
|
||||
// 订单类型 & 订阅关联
|
||||
field.String("order_type").
|
||||
MaxLen(20).
|
||||
Default("balance"),
|
||||
field.Int64("plan_id").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.Int64("subscription_group_id").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.Int("subscription_days").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.String("provider_instance_id").
|
||||
Optional().
|
||||
Nillable().
|
||||
MaxLen(64),
|
||||
field.String("provider_key").
|
||||
Optional().
|
||||
Nillable().
|
||||
MaxLen(30),
|
||||
field.JSON("provider_snapshot", map[string]any{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// 状态
|
||||
field.String("status").
|
||||
MaxLen(30).
|
||||
Default("PENDING"),
|
||||
|
||||
// 退款信息
|
||||
field.Float("refund_amount").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,2)"}).
|
||||
Default(0),
|
||||
field.String("refund_reason").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.Time("refund_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Bool("force_refund").
|
||||
Default(false),
|
||||
field.Time("refund_requested_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.String("refund_request_reason").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.String("refund_requested_by").
|
||||
Optional().
|
||||
Nillable().
|
||||
MaxLen(20),
|
||||
|
||||
// 时间节点
|
||||
field.Time("expires_at").
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("paid_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("completed_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("failed_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.String("failed_reason").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
|
||||
// 来源信息
|
||||
field.String("client_ip").
|
||||
MaxLen(50),
|
||||
field.String("src_host").
|
||||
MaxLen(255),
|
||||
field.String("src_url").
|
||||
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.Time("updated_at").
|
||||
Default(time.Now).
|
||||
UpdateDefault(time.Now).
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (PaymentOrder) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.From("user", User.Type).
|
||||
Ref("payment_orders").
|
||||
Field("user_id").
|
||||
Unique().
|
||||
Required(),
|
||||
}
|
||||
}
|
||||
|
||||
func (PaymentOrder) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("out_trade_no").
|
||||
Unique().
|
||||
Annotations(entsql.IndexWhere("out_trade_no <> ''")),
|
||||
index.Fields("user_id"),
|
||||
index.Fields("status"),
|
||||
index.Fields("expires_at"),
|
||||
index.Fields("created_at"),
|
||||
index.Fields("paid_at"),
|
||||
index.Fields("payment_type", "paid_at"),
|
||||
index.Fields("order_type"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// PaymentProviderInstance holds the schema definition for the PaymentProviderInstance entity.
|
||||
//
|
||||
// 删除策略:硬删除
|
||||
// PaymentProviderInstance 使用硬删除而非软删除,原因如下:
|
||||
// - 服务商实例为管理员配置的支付通道,删除即表示废弃
|
||||
// - 通过 enabled 字段控制是否启用,删除仅用于彻底移除
|
||||
// - config 字段存储加密后的密钥信息,删除时应彻底清除
|
||||
type PaymentProviderInstance struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (PaymentProviderInstance) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "payment_provider_instances"},
|
||||
}
|
||||
}
|
||||
|
||||
func (PaymentProviderInstance) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.String("provider_key").
|
||||
MaxLen(30).
|
||||
NotEmpty(),
|
||||
field.String("name").
|
||||
MaxLen(100).
|
||||
Default(""),
|
||||
field.String("config").
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.String("supported_types").
|
||||
MaxLen(200).
|
||||
Default(""),
|
||||
field.Bool("enabled").
|
||||
Default(true),
|
||||
field.String("payment_mode").
|
||||
MaxLen(20).
|
||||
Default(""),
|
||||
field.Int("sort_order").
|
||||
Default(0),
|
||||
field.String("limits").
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}).
|
||||
Default(""),
|
||||
field.Bool("refund_enabled").
|
||||
Default(false),
|
||||
field.Bool("allow_user_refund").
|
||||
Default(false),
|
||||
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"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (PaymentProviderInstance) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("provider_key"),
|
||||
index.Fields("enabled"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
var pendingAuthIntents = map[string]struct{}{
|
||||
"login": {},
|
||||
"bind_current_user": {},
|
||||
"adopt_existing_user_by_email": {},
|
||||
}
|
||||
|
||||
func validatePendingAuthIntent(value string) error {
|
||||
if _, ok := pendingAuthIntents[value]; ok {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("invalid pending auth intent %q", value)
|
||||
}
|
||||
|
||||
// PendingAuthSession stores a short-lived post-auth decision session.
|
||||
type PendingAuthSession struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (PendingAuthSession) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "pending_auth_sessions"},
|
||||
}
|
||||
}
|
||||
|
||||
func (PendingAuthSession) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
func (PendingAuthSession) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.String("session_token").
|
||||
MaxLen(255).
|
||||
NotEmpty(),
|
||||
field.String("intent").
|
||||
MaxLen(40).
|
||||
NotEmpty().
|
||||
Validate(validatePendingAuthIntent),
|
||||
field.String("provider_type").
|
||||
MaxLen(20).
|
||||
NotEmpty().
|
||||
Validate(validateAuthProviderType),
|
||||
field.String("provider_key").
|
||||
NotEmpty().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.String("provider_subject").
|
||||
NotEmpty().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.Int64("target_user_id").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.String("redirect_to").
|
||||
Default("").
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.String("resolved_email").
|
||||
Default("").
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.String("registration_password_hash").
|
||||
Default("").
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.JSON("upstream_identity_claims", map[string]any{}).
|
||||
Default(func() map[string]any { return map[string]any{} }).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
field.JSON("local_flow_state", map[string]any{}).
|
||||
Default(func() map[string]any { return map[string]any{} }).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
field.String("browser_session_key").
|
||||
Default("").
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.String("completion_code_hash").
|
||||
Default("").
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}),
|
||||
field.Time("completion_code_expires_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("email_verified_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("password_verified_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("totp_verified_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("expires_at").
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("consumed_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (PendingAuthSession) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.From("target_user", User.Type).
|
||||
Ref("pending_auth_sessions").
|
||||
Field("target_user_id").
|
||||
Unique(),
|
||||
edge.To("adoption_decision", IdentityAdoptionDecision.Type).
|
||||
Annotations(entsql.OnDelete(entsql.Cascade)).
|
||||
Unique(),
|
||||
}
|
||||
}
|
||||
|
||||
func (PendingAuthSession) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("session_token").Unique(),
|
||||
index.Fields("target_user_id"),
|
||||
index.Fields("expires_at"),
|
||||
index.Fields("provider_type", "provider_key", "provider_subject"),
|
||||
index.Fields("completion_code_hash"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// PromoCode holds the schema definition for the PromoCode entity.
|
||||
//
|
||||
// 注册优惠码:用户注册时使用,可获得赠送余额
|
||||
// 与 RedeemCode 不同,PromoCode 支持多次使用(有使用次数限制)
|
||||
//
|
||||
// 删除策略:硬删除
|
||||
type PromoCode struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (PromoCode) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "promo_codes"},
|
||||
}
|
||||
}
|
||||
|
||||
func (PromoCode) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.String("code").
|
||||
MaxLen(32).
|
||||
NotEmpty().
|
||||
Unique().
|
||||
Comment("优惠码"),
|
||||
field.Float("bonus_amount").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Default(0).
|
||||
Comment("赠送余额金额"),
|
||||
field.Int("max_uses").
|
||||
Default(0).
|
||||
Comment("最大使用次数,0表示无限制"),
|
||||
field.Int("used_count").
|
||||
Default(0).
|
||||
Comment("已使用次数"),
|
||||
field.String("status").
|
||||
MaxLen(20).
|
||||
Default(domain.PromoCodeStatusActive).
|
||||
Comment("状态: active, disabled"),
|
||||
field.Time("expires_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}).
|
||||
Comment("过期时间,null表示永不过期"),
|
||||
field.String("notes").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}).
|
||||
Comment("备注"),
|
||||
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"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (PromoCode) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.To("usage_records", PromoCodeUsage.Type),
|
||||
}
|
||||
}
|
||||
|
||||
func (PromoCode) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
// code 字段已在 Fields() 中声明 Unique(),无需重复索引
|
||||
index.Fields("status"),
|
||||
index.Fields("expires_at"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// PromoCodeUsage holds the schema definition for the PromoCodeUsage entity.
|
||||
//
|
||||
// 优惠码使用记录:记录每个用户使用优惠码的情况
|
||||
type PromoCodeUsage struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (PromoCodeUsage) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "promo_code_usages"},
|
||||
}
|
||||
}
|
||||
|
||||
func (PromoCodeUsage) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.Int64("promo_code_id").
|
||||
Comment("优惠码ID"),
|
||||
field.Int64("user_id").
|
||||
Comment("使用用户ID"),
|
||||
field.Float("bonus_amount").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Comment("实际赠送金额"),
|
||||
field.Time("used_at").
|
||||
Default(time.Now).
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}).
|
||||
Comment("使用时间"),
|
||||
}
|
||||
}
|
||||
|
||||
func (PromoCodeUsage) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.From("promo_code", PromoCode.Type).
|
||||
Ref("usage_records").
|
||||
Field("promo_code_id").
|
||||
Required().
|
||||
Unique(),
|
||||
edge.From("user", User.Type).
|
||||
Ref("promo_code_usages").
|
||||
Field("user_id").
|
||||
Required().
|
||||
Unique(),
|
||||
}
|
||||
}
|
||||
|
||||
func (PromoCodeUsage) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("promo_code_id"),
|
||||
index.Fields("user_id"),
|
||||
// 每个用户每个优惠码只能使用一次
|
||||
index.Fields("promo_code_id", "user_id").Unique(),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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/edge"
|
||||
"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"),
|
||||
field.Time("expires_at").
|
||||
Optional().Nillable().
|
||||
Comment("Proxy expiration time (NULL means never expires)."),
|
||||
field.String("fallback_mode").
|
||||
MaxLen(20).Default("none").
|
||||
Comment("Fallback target on expiry: none | proxy | direct."),
|
||||
field.Int64("backup_proxy_id").
|
||||
Optional().Nillable().
|
||||
Comment("Backup proxy id when fallback_mode=proxy (self-reference)."),
|
||||
field.Int("expiry_warn_days").
|
||||
Default(7).
|
||||
Comment("Days before expiry to flag as expiring-soon (per proxy)."),
|
||||
}
|
||||
}
|
||||
|
||||
// Edges 定义代理实体的关联关系。
|
||||
func (Proxy) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
// accounts: 使用此代理的账户(反向边)
|
||||
edge.From("accounts", Account.Type).
|
||||
Ref("proxy"),
|
||||
edge.To("backup_proxy", Proxy.Type).
|
||||
Field("backup_proxy_id").
|
||||
Unique(),
|
||||
}
|
||||
}
|
||||
|
||||
func (Proxy) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("status"),
|
||||
index.Fields("deleted_at"),
|
||||
index.Fields("expires_at"),
|
||||
index.Fields("backup_proxy_id"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
|
||||
"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.
|
||||
//
|
||||
// 删除策略:硬删除
|
||||
// RedeemCode 使用硬删除而非软删除,原因如下:
|
||||
// - 兑换码具有一次性使用特性,删除后无需保留历史记录
|
||||
// - 已使用的兑换码通过 status 和 used_at 字段追踪,无需依赖软删除
|
||||
// - 减少数据库存储压力和查询复杂度
|
||||
//
|
||||
// 如需审计已删除的兑换码,建议在删除前将关键信息写入审计日志表。
|
||||
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(domain.RedeemTypeBalance),
|
||||
field.Float("value").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Default(0),
|
||||
field.String("status").
|
||||
MaxLen(20).
|
||||
Default(domain.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.Time("expires_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
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{
|
||||
// code 字段已在 Fields() 中声明 Unique(),无需重复索引
|
||||
index.Fields("status"),
|
||||
index.Fields("used_by"),
|
||||
index.Fields("group_id"),
|
||||
index.Fields("expires_at"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect"
|
||||
"entgo.io/ent/dialect/entsql"
|
||||
"entgo.io/ent/schema"
|
||||
"entgo.io/ent/schema/field"
|
||||
)
|
||||
|
||||
// SecuritySecret 存储系统级安全密钥(如 JWT 签名密钥、TOTP 加密密钥)。
|
||||
type SecuritySecret struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (SecuritySecret) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "security_secrets"},
|
||||
}
|
||||
}
|
||||
|
||||
func (SecuritySecret) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
func (SecuritySecret) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.String("key").
|
||||
MaxLen(100).
|
||||
NotEmpty().
|
||||
Unique(),
|
||||
field.String("value").
|
||||
NotEmpty().
|
||||
SchemaType(map[string]string{
|
||||
dialect.Postgres: "text",
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// Setting holds the schema definition for the Setting entity.
|
||||
//
|
||||
// 删除策略:硬删除
|
||||
// Setting 使用硬删除而非软删除,原因如下:
|
||||
// - 系统设置是简单的键值对,删除即意味着恢复默认值
|
||||
// - 设置变更通常通过应用日志追踪,无需在数据库层面保留历史
|
||||
// - 保持表结构简洁,避免无效数据积累
|
||||
//
|
||||
// 如需设置变更审计,建议在更新/删除前将变更记录写入审计日志表。
|
||||
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").
|
||||
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 {
|
||||
// key 字段已在 Fields() 中声明 Unique(),无需额外索引
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
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"
|
||||
)
|
||||
|
||||
// SubscriptionPlan holds the schema definition for the SubscriptionPlan entity.
|
||||
//
|
||||
// 删除策略:硬删除
|
||||
// SubscriptionPlan 使用硬删除而非软删除,原因如下:
|
||||
// - 套餐为管理员维护的商品配置,删除即表示下架移除
|
||||
// - 通过 for_sale 字段控制是否在售,删除仅用于彻底移除
|
||||
// - 已购买的订阅记录保存在 UserSubscription 中,不受套餐删除影响
|
||||
type SubscriptionPlan struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (SubscriptionPlan) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "subscription_plans"},
|
||||
}
|
||||
}
|
||||
|
||||
func (SubscriptionPlan) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.Int64("group_id"),
|
||||
field.String("name").
|
||||
MaxLen(100).
|
||||
NotEmpty(),
|
||||
field.String("description").
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}).
|
||||
Default(""),
|
||||
field.Float("price").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,2)"}),
|
||||
field.Float("original_price").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,2)"}).
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.String("currency").
|
||||
MaxLen(3).
|
||||
Default(""),
|
||||
field.Int("validity_days").
|
||||
Default(30),
|
||||
field.String("validity_unit").
|
||||
MaxLen(10).
|
||||
Default("day"),
|
||||
field.String("features").
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}).
|
||||
Default(""),
|
||||
field.String("product_name").
|
||||
MaxLen(100).
|
||||
Default(""),
|
||||
field.Bool("for_sale").
|
||||
Default(true),
|
||||
field.Int("sort_order").
|
||||
Default(0),
|
||||
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"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (SubscriptionPlan) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("group_id"),
|
||||
index.Fields("for_sale"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
// Package schema 定义 Ent ORM 的数据库 schema。
|
||||
package schema
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
|
||||
"entgo.io/ent"
|
||||
"entgo.io/ent/dialect"
|
||||
"entgo.io/ent/dialect/entsql"
|
||||
"entgo.io/ent/schema"
|
||||
"entgo.io/ent/schema/field"
|
||||
)
|
||||
|
||||
// TLSFingerprintProfile 定义 TLS 指纹配置模板的 schema。
|
||||
//
|
||||
// TLS 指纹模板用于模拟特定客户端(如 Claude Code / Node.js)的 TLS 握手特征。
|
||||
// 每个模板包含完整的 ClientHello 参数:加密套件、曲线、扩展等。
|
||||
// 通过 Account.Extra.tls_fingerprint_profile_id 绑定到具体账号。
|
||||
type TLSFingerprintProfile struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
// Annotations 返回 schema 的注解配置。
|
||||
func (TLSFingerprintProfile) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "tls_fingerprint_profiles"},
|
||||
}
|
||||
}
|
||||
|
||||
// Mixin 返回该 schema 使用的混入组件。
|
||||
func (TLSFingerprintProfile) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
// Fields 定义 TLS 指纹模板实体的所有字段。
|
||||
func (TLSFingerprintProfile) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
// name: 模板名称,唯一标识
|
||||
field.String("name").
|
||||
MaxLen(100).
|
||||
NotEmpty().
|
||||
Unique(),
|
||||
|
||||
// description: 模板描述
|
||||
field.Text("description").
|
||||
Optional().
|
||||
Nillable(),
|
||||
|
||||
// enable_grease: 是否启用 GREASE 扩展(Chrome 使用,Node.js 不使用)
|
||||
field.Bool("enable_grease").
|
||||
Default(false),
|
||||
|
||||
// cipher_suites: TLS 加密套件列表(顺序敏感,影响 JA3)
|
||||
field.JSON("cipher_suites", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// curves: 椭圆曲线/支持的组列表
|
||||
field.JSON("curves", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// point_formats: EC 点格式列表
|
||||
field.JSON("point_formats", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// signature_algorithms: 签名算法列表
|
||||
field.JSON("signature_algorithms", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// alpn_protocols: ALPN 协议列表(如 ["http/1.1"])
|
||||
field.JSON("alpn_protocols", []string{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// supported_versions: 支持的 TLS 版本列表(如 [0x0304, 0x0303])
|
||||
field.JSON("supported_versions", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// key_share_groups: Key Share 中发送的曲线组(如 [29] 即 X25519)
|
||||
field.JSON("key_share_groups", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// psk_modes: PSK 密钥交换模式(如 [1] 即 psk_dhe_ke)
|
||||
field.JSON("psk_modes", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// extensions: TLS 扩展类型 ID 列表,按发送顺序排列
|
||||
field.JSON("extensions", []uint16{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// UsageCleanupTask 定义使用记录清理任务的 schema。
|
||||
type UsageCleanupTask struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (UsageCleanupTask) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "usage_cleanup_tasks"},
|
||||
}
|
||||
}
|
||||
|
||||
func (UsageCleanupTask) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
func (UsageCleanupTask) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.String("status").
|
||||
MaxLen(20).
|
||||
Validate(validateUsageCleanupStatus),
|
||||
field.JSON("filters", json.RawMessage{}),
|
||||
field.Int64("created_by"),
|
||||
field.Int64("deleted_rows").
|
||||
Default(0),
|
||||
field.String("error_message").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.Int64("canceled_by").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.Time("canceled_at").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.Time("started_at").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.Time("finished_at").
|
||||
Optional().
|
||||
Nillable(),
|
||||
}
|
||||
}
|
||||
|
||||
func (UsageCleanupTask) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("status", "created_at"),
|
||||
index.Fields("created_at"),
|
||||
index.Fields("canceled_at"),
|
||||
}
|
||||
}
|
||||
|
||||
func validateUsageCleanupStatus(status string) error {
|
||||
switch status {
|
||||
case "pending", "running", "succeeded", "failed", "canceled":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("invalid usage cleanup status: %s", status)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
// Package schema 定义 Ent ORM 的数据库 schema。
|
||||
package schema
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// UsageLog 定义使用日志实体的 schema。
|
||||
//
|
||||
// 使用日志记录每次 API 调用的详细信息,包括 token 使用量、成本计算等。
|
||||
// 这是一个只追加的表,不支持更新和删除。
|
||||
type UsageLog struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
// Annotations 返回 schema 的注解配置。
|
||||
func (UsageLog) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "usage_logs"},
|
||||
}
|
||||
}
|
||||
|
||||
// Fields 定义使用日志实体的所有字段。
|
||||
func (UsageLog) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
// 关联字段
|
||||
field.Int64("user_id"),
|
||||
field.Int64("api_key_id"),
|
||||
field.Int64("account_id"),
|
||||
field.String("request_id").
|
||||
MaxLen(64).
|
||||
NotEmpty(),
|
||||
field.String("model").
|
||||
MaxLen(100).
|
||||
NotEmpty(),
|
||||
// RequestedModel stores the client-requested model name for stable display and analytics.
|
||||
// NULL means historical rows written before requested_model dual-write was introduced.
|
||||
field.String("requested_model").
|
||||
MaxLen(100).
|
||||
Optional().
|
||||
Nillable(),
|
||||
// UpstreamModel stores the actual upstream model name when model mapping
|
||||
// is applied. NULL means no mapping — the requested model was used as-is.
|
||||
field.String("upstream_model").
|
||||
MaxLen(100).
|
||||
Optional().
|
||||
Nillable(),
|
||||
// UpstreamResponseModel stores the model name declared by the upstream
|
||||
// response before any protocol conversion or client-facing rewrite.
|
||||
field.String("upstream_response_model").
|
||||
MaxLen(200).
|
||||
Optional().
|
||||
Nillable(),
|
||||
// UpstreamModelMismatch is tri-state: NULL means the upstream response did
|
||||
// not declare a model (or predates this field); false/true means observed.
|
||||
field.Bool("upstream_model_mismatch").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.Int64("channel_id").Optional().Nillable().Comment("渠道 ID"),
|
||||
field.String("model_mapping_chain").MaxLen(500).Optional().Nillable().Comment("模型映射链"),
|
||||
field.String("billing_tier").MaxLen(50).Optional().Nillable().Comment("计费层级标签"),
|
||||
field.String("billing_mode").MaxLen(20).Optional().Nillable().Comment("计费模式:token/per_request/image"),
|
||||
field.Int64("group_id").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.Int64("subscription_id").
|
||||
Optional().
|
||||
Nillable(),
|
||||
|
||||
// Token 计数字段
|
||||
field.Int("input_tokens").
|
||||
Default(0),
|
||||
field.Int("output_tokens").
|
||||
Default(0),
|
||||
field.Int("cache_creation_tokens").
|
||||
Default(0),
|
||||
field.Int("cache_read_tokens").
|
||||
Default(0),
|
||||
field.Int("cache_creation_5m_tokens").
|
||||
Default(0),
|
||||
field.Int("cache_creation_1h_tokens").
|
||||
Default(0),
|
||||
|
||||
// 成本字段
|
||||
field.Float("input_cost").
|
||||
Default(0).
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}),
|
||||
field.Float("output_cost").
|
||||
Default(0).
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}),
|
||||
field.Float("cache_creation_cost").
|
||||
Default(0).
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}),
|
||||
field.Float("cache_read_cost").
|
||||
Default(0).
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}),
|
||||
field.Float("total_cost").
|
||||
Default(0).
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}),
|
||||
field.Float("actual_cost").
|
||||
Default(0).
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}),
|
||||
field.Float("rate_multiplier").
|
||||
Default(1).
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}),
|
||||
field.Bool("long_context_billing_applied").
|
||||
Default(false).
|
||||
Comment("Whether long-context pricing changed token prices for this request"),
|
||||
|
||||
// account_rate_multiplier: 账号计费倍率快照(NULL 表示按 1.0 处理)
|
||||
field.Float("account_rate_multiplier").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}),
|
||||
|
||||
// 其他字段
|
||||
field.Int8("billing_type").
|
||||
Default(0),
|
||||
field.Bool("stream").
|
||||
Default(false),
|
||||
field.Int("duration_ms").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.Int("first_token_ms").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.String("user_agent").
|
||||
MaxLen(512).
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.String("ip_address").
|
||||
MaxLen(45). // 支持 IPv6
|
||||
Optional().
|
||||
Nillable(),
|
||||
|
||||
// 图片生成字段(仅 gemini-3-pro-image 等图片模型使用)
|
||||
field.Int("image_count").
|
||||
Default(0),
|
||||
field.String("image_size").
|
||||
MaxLen(10).
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.String("image_input_size").
|
||||
MaxLen(32).
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.String("image_output_size").
|
||||
MaxLen(32).
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.String("image_size_source").
|
||||
MaxLen(16).
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.JSON("image_size_breakdown", map[string]int{}).
|
||||
Optional().
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// 视频生成字段(Grok 视频按秒计费;billing_mode 走 token/其他模式时这些列仍标记视频用量)
|
||||
field.Int("video_count").
|
||||
Default(0).
|
||||
Comment("视频生成数量;>0 表示本行是视频生成用量"),
|
||||
field.String("video_resolution").
|
||||
MaxLen(10).
|
||||
Optional().
|
||||
Nillable().
|
||||
Comment("计费用视频分辨率 480p/720p/1080p"),
|
||||
field.Int("video_duration_seconds").
|
||||
Optional().
|
||||
Nillable().
|
||||
Comment("提交时请求的视频时长(秒),按秒计费的乘数"),
|
||||
// Cache TTL Override 标记(管理员强制替换了缓存 TTL 计费)
|
||||
field.Bool("cache_ttl_overridden").
|
||||
Default(false),
|
||||
|
||||
// 时间戳(只有 created_at,日志不可修改)
|
||||
field.Time("created_at").
|
||||
Default(time.Now).
|
||||
Immutable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
}
|
||||
}
|
||||
|
||||
// Edges 定义使用日志实体的关联关系。
|
||||
func (UsageLog) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.From("user", User.Type).
|
||||
Ref("usage_logs").
|
||||
Field("user_id").
|
||||
Required().
|
||||
Unique(),
|
||||
edge.From("api_key", APIKey.Type).
|
||||
Ref("usage_logs").
|
||||
Field("api_key_id").
|
||||
Required().
|
||||
Unique(),
|
||||
edge.From("account", Account.Type).
|
||||
Ref("usage_logs").
|
||||
Field("account_id").
|
||||
Required().
|
||||
Unique(),
|
||||
edge.From("group", Group.Type).
|
||||
Ref("usage_logs").
|
||||
Field("group_id").
|
||||
Unique(),
|
||||
edge.From("subscription", UserSubscription.Type).
|
||||
Ref("usage_logs").
|
||||
Field("subscription_id").
|
||||
Unique(),
|
||||
}
|
||||
}
|
||||
|
||||
// Indexes 定义数据库索引,优化查询性能。
|
||||
func (UsageLog) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("user_id"),
|
||||
index.Fields("api_key_id"),
|
||||
index.Fields("account_id"),
|
||||
index.Fields("group_id"),
|
||||
index.Fields("subscription_id"),
|
||||
index.Fields("created_at"),
|
||||
index.Fields("model"),
|
||||
index.Fields("requested_model"),
|
||||
index.Fields("request_id"),
|
||||
// 复合索引用于时间范围查询
|
||||
index.Fields("user_id", "created_at"),
|
||||
index.Fields("api_key_id", "created_at"),
|
||||
// 分组维度时间范围查询(线上由 SQL 迁移创建 group_id IS NOT NULL 的部分索引)
|
||||
index.Fields("group_id", "created_at"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
|
||||
"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{
|
||||
// 唯一约束通过部分索引实现(WHERE deleted_at IS NULL),支持软删除后重用
|
||||
// 见迁移文件 016_soft_delete_partial_unique_indexes.sql
|
||||
field.String("email").
|
||||
MaxLen(255).
|
||||
NotEmpty(),
|
||||
field.String("password_hash").
|
||||
MaxLen(255).
|
||||
NotEmpty(),
|
||||
field.String("role").
|
||||
MaxLen(20).
|
||||
Default(domain.RoleUser),
|
||||
field.Float("balance").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Default(0),
|
||||
field.Float("frozen_balance").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Default(0),
|
||||
field.Int("concurrency").
|
||||
Default(5),
|
||||
field.String("status").
|
||||
MaxLen(20).
|
||||
Default(domain.StatusActive),
|
||||
|
||||
// Optional profile fields (added later; default '' in DB migration)
|
||||
field.String("username").
|
||||
MaxLen(100).
|
||||
Default(""),
|
||||
// wechat field migrated to user_attribute_values (see migration 019)
|
||||
field.String("notes").
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}).
|
||||
Default(""),
|
||||
|
||||
// TOTP 双因素认证字段
|
||||
field.String("totp_secret_encrypted").
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}).
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.Bool("totp_enabled").
|
||||
Default(false),
|
||||
field.Time("totp_enabled_at").
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.String("signup_source").
|
||||
Validate(func(value string) error {
|
||||
switch value {
|
||||
case "email", "linuxdo", "wechat", "oidc", "github", "google", "dingtalk":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("must be one of email, linuxdo, wechat, oidc, github, google, dingtalk")
|
||||
}
|
||||
}).
|
||||
Default("email"),
|
||||
field.Time("last_login_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
field.Time("last_active_at").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
|
||||
// 余额不足通知
|
||||
field.Bool("balance_notify_enabled").
|
||||
Default(true),
|
||||
field.String("balance_notify_threshold_type").
|
||||
Default("fixed"), // "fixed" | "percentage"
|
||||
field.Float("balance_notify_threshold").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Optional().
|
||||
Nillable(),
|
||||
field.String("balance_notify_extra_emails").
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}).
|
||||
Default("[]"),
|
||||
field.Float("total_recharged").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
|
||||
Default(0),
|
||||
|
||||
// 用户级每分钟请求数上限(0 = 不限制)。仅当所在分组未设置 rpm_limit 时作为兜底生效。
|
||||
field.Int("rpm_limit").
|
||||
Default(0),
|
||||
}
|
||||
}
|
||||
|
||||
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("announcement_reads", AnnouncementRead.Type),
|
||||
edge.To("allowed_groups", Group.Type).
|
||||
Through("user_allowed_groups", UserAllowedGroup.Type),
|
||||
edge.To("usage_logs", UsageLog.Type),
|
||||
edge.To("attribute_values", UserAttributeValue.Type),
|
||||
edge.To("promo_code_usages", PromoCodeUsage.Type),
|
||||
edge.To("payment_orders", PaymentOrder.Type),
|
||||
edge.To("auth_identities", AuthIdentity.Type).
|
||||
Annotations(entsql.OnDelete(entsql.Cascade)),
|
||||
edge.To("pending_auth_sessions", PendingAuthSession.Type),
|
||||
edge.To("platform_quotas", UserPlatformQuota.Type),
|
||||
}
|
||||
}
|
||||
|
||||
func (User) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
// email 字段已在 Fields() 中声明 Unique(),无需重复索引
|
||||
index.Fields("status"),
|
||||
index.Fields("deleted_at"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// 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).
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
}
|
||||
}
|
||||
|
||||
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"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// UserAttributeDefinition holds the schema definition for custom user attributes.
|
||||
//
|
||||
// This entity defines the metadata for user attributes, such as:
|
||||
// - Attribute key (unique identifier like "company_name")
|
||||
// - Display name shown in forms
|
||||
// - Field type (text, number, select, etc.)
|
||||
// - Validation rules
|
||||
// - Whether the field is required or enabled
|
||||
type UserAttributeDefinition struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (UserAttributeDefinition) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "user_attribute_definitions"},
|
||||
}
|
||||
}
|
||||
|
||||
func (UserAttributeDefinition) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
mixins.SoftDeleteMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
func (UserAttributeDefinition) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
// key: Unique identifier for the attribute (e.g., "company_name")
|
||||
// Used for programmatic reference
|
||||
field.String("key").
|
||||
MaxLen(100).
|
||||
NotEmpty(),
|
||||
|
||||
// name: Display name shown in forms (e.g., "Company Name")
|
||||
field.String("name").
|
||||
MaxLen(255).
|
||||
NotEmpty(),
|
||||
|
||||
// description: Optional description/help text for the attribute
|
||||
field.String("description").
|
||||
SchemaType(map[string]string{dialect.Postgres: "text"}).
|
||||
Default(""),
|
||||
|
||||
// type: Attribute type - text, textarea, number, email, url, date, select, multi_select
|
||||
field.String("type").
|
||||
MaxLen(20).
|
||||
NotEmpty(),
|
||||
|
||||
// options: Select options for select/multi_select types (stored as JSONB)
|
||||
// Format: [{"value": "xxx", "label": "XXX"}, ...]
|
||||
field.JSON("options", []map[string]any{}).
|
||||
Default([]map[string]any{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// required: Whether this attribute is required when editing a user
|
||||
field.Bool("required").
|
||||
Default(false),
|
||||
|
||||
// validation: Validation rules for the attribute value (stored as JSONB)
|
||||
// Format: {"min_length": 1, "max_length": 100, "min": 0, "max": 100, "pattern": "^[a-z]+$", "message": "..."}
|
||||
field.JSON("validation", map[string]any{}).
|
||||
Default(map[string]any{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}),
|
||||
|
||||
// placeholder: Placeholder text shown in input fields
|
||||
field.String("placeholder").
|
||||
MaxLen(255).
|
||||
Default(""),
|
||||
|
||||
// display_order: Order in which attributes are displayed (lower = first)
|
||||
field.Int("display_order").
|
||||
Default(0),
|
||||
|
||||
// enabled: Whether this attribute is active and shown in forms
|
||||
field.Bool("enabled").
|
||||
Default(true),
|
||||
}
|
||||
}
|
||||
|
||||
func (UserAttributeDefinition) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
// values: All user values for this attribute definition
|
||||
edge.To("values", UserAttributeValue.Type),
|
||||
}
|
||||
}
|
||||
|
||||
func (UserAttributeDefinition) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
// Partial unique index on key (WHERE deleted_at IS NULL) via migration
|
||||
index.Fields("key"),
|
||||
index.Fields("enabled"),
|
||||
index.Fields("display_order"),
|
||||
index.Fields("deleted_at"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
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/edge"
|
||||
"entgo.io/ent/schema/field"
|
||||
"entgo.io/ent/schema/index"
|
||||
)
|
||||
|
||||
// UserAttributeValue holds a user's value for a specific attribute.
|
||||
//
|
||||
// This entity stores the actual values that users have for each attribute definition.
|
||||
// Values are stored as strings and converted to the appropriate type by the application.
|
||||
type UserAttributeValue struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (UserAttributeValue) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "user_attribute_values"},
|
||||
}
|
||||
}
|
||||
|
||||
func (UserAttributeValue) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
// Only use TimeMixin, no soft delete - values are hard deleted
|
||||
mixins.TimeMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
func (UserAttributeValue) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
// user_id: References the user this value belongs to
|
||||
field.Int64("user_id"),
|
||||
|
||||
// attribute_id: References the attribute definition
|
||||
field.Int64("attribute_id"),
|
||||
|
||||
// value: The actual value stored as a string
|
||||
// For multi_select, this is a JSON array string
|
||||
field.Text("value").
|
||||
Default(""),
|
||||
}
|
||||
}
|
||||
|
||||
func (UserAttributeValue) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
// user: The user who owns this attribute value
|
||||
edge.From("user", User.Type).
|
||||
Ref("attribute_values").
|
||||
Field("user_id").
|
||||
Required().
|
||||
Unique(),
|
||||
|
||||
// definition: The attribute definition this value is for
|
||||
edge.From("definition", UserAttributeDefinition.Type).
|
||||
Ref("values").
|
||||
Field("attribute_id").
|
||||
Required().
|
||||
Unique(),
|
||||
}
|
||||
}
|
||||
|
||||
func (UserAttributeValue) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
// Unique index on (user_id, attribute_id)
|
||||
index.Fields("user_id", "attribute_id").Unique(),
|
||||
index.Fields("attribute_id"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"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"
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
)
|
||||
|
||||
// UserPlatformQuota holds the schema definition for per-user per-platform quota.
|
||||
type UserPlatformQuota struct {
|
||||
ent.Schema
|
||||
}
|
||||
|
||||
func (UserPlatformQuota) Annotations() []schema.Annotation {
|
||||
return []schema.Annotation{
|
||||
entsql.Annotation{Table: "user_platform_quotas"},
|
||||
}
|
||||
}
|
||||
|
||||
func (UserPlatformQuota) Mixin() []ent.Mixin {
|
||||
return []ent.Mixin{
|
||||
mixins.TimeMixin{},
|
||||
mixins.SoftDeleteMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
func (UserPlatformQuota) Fields() []ent.Field {
|
||||
return []ent.Field{
|
||||
field.Int64("user_id"),
|
||||
field.String("platform").
|
||||
MaxLen(32).
|
||||
NotEmpty().
|
||||
Validate(func(s string) error {
|
||||
// 注意:平台列表的单一权威源为 service.AllowedQuotaPlatforms;
|
||||
// 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。
|
||||
switch s {
|
||||
case "anthropic", "openai", "gemini", "antigravity", "grok",
|
||||
"kimi", "zhipu", "deepseek":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("platform %q is not allowed", s)
|
||||
}
|
||||
}),
|
||||
|
||||
// 日 / 周 / 月 USD 上限:
|
||||
// nil / not set → 无限额(完全放行)
|
||||
// 0 → 完全禁用(任何请求都会被拒绝,因为 usage >= 0 恒成立)
|
||||
// > 0 → USD 限额上限
|
||||
field.Float("daily_limit_usd").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}),
|
||||
field.Float("weekly_limit_usd").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}),
|
||||
field.Float("monthly_limit_usd").
|
||||
Optional().
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}),
|
||||
|
||||
// 当前窗口已用量(USD,preflight 时与 limit 比较)
|
||||
field.Float("daily_usage_usd").
|
||||
Default(0).
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}),
|
||||
field.Float("weekly_usage_usd").
|
||||
Default(0).
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}),
|
||||
field.Float("monthly_usage_usd").
|
||||
Default(0).
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(20,10)"}),
|
||||
|
||||
// 窗口起点(NULL = 首次还未初始化,由 InitWindowStarts 用 COALESCE 兜底)
|
||||
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"}),
|
||||
}
|
||||
}
|
||||
|
||||
func (UserPlatformQuota) Edges() []ent.Edge {
|
||||
return []ent.Edge{
|
||||
edge.From("user", User.Type).
|
||||
Ref("platform_quotas").
|
||||
Field("user_id").
|
||||
Unique().
|
||||
Required(),
|
||||
}
|
||||
}
|
||||
|
||||
func (UserPlatformQuota) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
// 软删除友好:只对未删记录唯一
|
||||
index.Fields("user_id", "platform").
|
||||
Unique().
|
||||
Annotations(entsql.IndexWhere("deleted_at IS NULL")),
|
||||
index.Fields("user_id"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package schema
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
|
||||
"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{},
|
||||
mixins.SoftDeleteMixin{},
|
||||
}
|
||||
}
|
||||
|
||||
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(domain.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(),
|
||||
edge.To("usage_logs", UsageLog.Type),
|
||||
}
|
||||
}
|
||||
|
||||
func (UserSubscription) Indexes() []ent.Index {
|
||||
return []ent.Index{
|
||||
index.Fields("user_id"),
|
||||
index.Fields("group_id"),
|
||||
index.Fields("status"),
|
||||
index.Fields("expires_at"),
|
||||
// 活跃订阅查询复合索引(线上由 SQL 迁移创建部分索引,schema 仅用于模型可读性对齐)
|
||||
index.Fields("user_id", "status", "expires_at"),
|
||||
index.Fields("assigned_by"),
|
||||
// 唯一约束通过部分索引实现(WHERE deleted_at IS NULL),支持软删除后重新订阅
|
||||
// 见迁移文件 016_soft_delete_partial_unique_indexes.sql
|
||||
index.Fields("user_id", "group_id"),
|
||||
index.Fields("deleted_at"),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user