3. Ent Schema 设计:定义 User、Task 等核心模型

在异步任务认证系统中,Schema 是数据层的契约。Ent 通过 Go 代码定义 Schema,自动生成类型安全的 CRUD 与迁移脚本。本章聚焦 User、Task、Credential 三个核心模型的字段设计、注解策略与索引约束。

3.1 核心模型概览

系统包含三个主要实体,其职责与关系如下表所示:

模型 职责 关联关系
User 系统用户,包含认证信息与状态 拥有多个 Credential 和 Task
Credential 第三方平台凭证(如 OAuth2 token) 属于一个 User
Task 异步任务定义,包含类型、状态与重试策略 属于一个 User,可选关联 Credential
设计原则:每个模型只承载与其生命周期一致的数据。Credential 独立于 User 存储,便于轮换与过期管理;Task 与 Credential 的关联为可选,支持匿名或系统级任务。

3.2 User Schema 详解

User 模型是认证体系的锚点,字段设计需兼顾登录、权限与审计需求。

// ent/schema/user.go
package schema

import (
    "entgo.io/ent"
    "entgo.io/ent/schema/field"
    "entgo.io/ent/schema/index"
    "entgo.io/ent/schema/edge"
)

type User struct {
    ent.Schema
}

func (User) Fields() []ent.Field {
    return []ent.Field{
        field.String("username").
            Unique().
            MinLen(3).
            MaxLen(32).
            Comment("登录用户名,全局唯一"),
        field.String("email").
            Unique().
            Comment("邮箱,用于找回密码与通知"),
        field.String("password_hash").
            Sensitive().
            Comment("bcrypt 哈希后的密码"),
        field.Enum("status").
            Values("active", "inactive", "suspended").
            Default("active").
            Comment("用户状态"),
        field.Time("created_at").
            Default(now).
            Immutable(),
        field.Time("updated_at").
            Default(now).
            UpdateDefault(now),
    }
}

func (User) Edges() []ent.Edge {
    return []ent.Edge{
        edge.To("credentials", Credential.Type),
        edge.To("tasks", Task.Type),
    }
}

func (User) Indexes() []ent.Index {
    return []ent.Index{
        index.Fields("email", "status").
            Unique().
            Comment("防止同一邮箱重复激活"),
    }
}
关键注解:
  • Sensitive() 标记密码字段,Ent 自动在日志与序列化中脱敏。
  • Immutable() 确保创建时间创建后不可修改。
  • UpdateDefault(now) 使更新时间在每次记录变更时自动刷新。

3.3 Credential Schema:凭证生命周期管理

第三方凭证具有时效性,Schema 需记录令牌、刷新令牌与过期时间。

// ent/schema/credential.go
type Credential struct {
    ent.Schema
}

func (Credential) Fields() []ent.Field {
    return []ent.Field{
        field.String("platform").
            Comment("平台标识,如 github、google"),
        field.String("access_token").
            Sensitive().
            MaxLen(2048).
            Comment("OAuth2 访问令牌"),
        field.String("refresh_token").
            Sensitive().
            MaxLen(2048).
            Optional().
            Nillable().
            Comment("刷新令牌,可选"),
        field.Time("expires_at").
            Optional().
            Nillable().
            Comment("令牌过期时间"),
        field.Time("created_at").
            Default(now).
            Immutable(),
    }
}

func (Credential) Edges() []ent.Edge {
    return []ent.Edge{
        edge.From("owner", User.Type).
            Ref("credentials").
            Unique().
            Required(),
    }
}

func (Credential) Indexes() []ent.Index {
    return []ent.Index{
        index.Fields("platform", "expires_at").
            Comment("按平台与过期时间筛选过期凭证"),
    }
}
避坑提示:refresh_token 使用 Optional().Nillable() 而非 Default(""),避免空字符串与 null 语义混淆。过期时间使用 Nillable() 支持永不过期的内部令牌。

3.4 Task Schema:异步任务核心

Task 是系统的执行单元,字段需覆盖任务类型、状态机、重试策略与上下文。

// ent/schema/task.go
type Task struct {
    ent.Schema
}

func (Task) Fields() []ent.Field {
    return []ent.Field{
        field.String("type").
            Comment("任务类型,如 send_email、sync_data"),
        field.Enum("status").
            Values("pending", "running", "success", "failed", "cancelled").
            Default("pending").
            Comment("任务状态"),
        field.JSON("payload").
            Comment("任务输入参数,JSON 格式"),
        field.JSON("result").
            Optional().
            Comment("任务执行结果,JSON 格式"),
        field.Int("retry_count").
            Default(0).
            Comment("已重试次数"),
        field.Int("max_retries").
            Default(3).
            Comment("最大重试次数"),
        field.Time("scheduled_at").
            Optional().
            Nillable().
            Comment("计划执行时间,为空则立即执行"),
        field.Time("started_at").
            Optional().
            Nillable().
            Comment("实际开始时间"),
        field.Time("completed_at").
            Optional().
            Nillable().
            Comment("完成时间"),
        field.Time("created_at").
            Default(now).
            Immutable(),
    }
}

func (Task) Edges() []ent.Edge {
    return []ent.Edge{
        edge.From("owner", User.Type).
            Ref("tasks").
            Unique().
            Required(),
        edge.From("credential", Credential.Type).
            Ref("tasks").
            Unique().
            Optional(),
    }
}

func (Task) Indexes() []ent.Index {
    return []ent.Index{
        index.Fields("status", "scheduled_at").
            Comment("调度器按状态与计划时间拉取任务"),
        index.Fields("type", "status").
            Comment("按类型与状态统计任务分布"),
    }
}

3.5 模型关系与索引策略

下图展示了三个模型之间的关联关系与关键索引路径:

Ent 核心模型关系图 User username (唯一) email + status (联合唯一) Credential platform + expires_at access_token (敏感) Task status + scheduled_at type + status 1:N 1:N 可选 1:N 索引策略说明 • User: username 唯一索引 + email+status 联合唯一索引 • Credential: platform+expires_at 复合索引 → 快速定位过期凭证 • Task: status+scheduled_at 复合索引 → 调度器拉取待执行任务

3.6 字段类型与注解对照

Ent 提供丰富的字段类型与注解,以下为本章使用的核心组合:

字段类型 适用场景 常用注解
field.String 用户名、邮箱、令牌 Unique()、Sensitive()、MinLen()
field.Enum 状态字段(status) Values()、Default()
field.JSON 任务 payload / result Optional()
field.Time 时间戳字段 Default()、Immutable()、UpdateDefault()
field.Int 重试计数、最大重试 Default()、Positive()

3.7 索引与唯一约束最佳实践

索引设计直接影响查询性能与数据完整性。以下是本系统的索引策略总结:

  • 唯一索引:username、email 各自独立唯一,确保登录标识不冲突。
  • 联合唯一索引:email + status 防止同一邮箱存在多个激活账户。
  • 复合索引:status + scheduled_at 是任务调度器的核心查询路径,必须覆盖。
  • 过滤索引:platform + expires_at 用于定时清理过期凭证,减少全表扫描。
  • 避免冗余:不要为 created_at 单独建索引,除非有范围查询硬需求。
迁移建议:在生产环境添加索引时,使用 Ent 的 Atlas 迁移模式,生成增量 SQL 而非全量重建。对于大表,考虑 CONCURRENTLY 选项避免锁表。

3.8 Schema 设计检查清单

完成 Schema 定义后,逐项核对以下要点:

  1. 所有敏感字段(密码、令牌)已标记 Sensitive()。
  2. 时间戳字段正确使用 Immutable() 与 UpdateDefault()。
  3. 枚举状态值覆盖完整生命周期,无遗漏分支。
  4. JSON 字段明确标注 Optional(),避免非空约束导致写入失败。
  5. 索引覆盖所有高频查询 WHERE 条件,且复合索引遵循最左前缀原则。
  6. 边缘(Edge)关系方向正确,Required() 与 Optional() 语义准确。

完成上述 Schema 定义后,运行 go generate ./ent 即可生成类型安全的客户端代码,为后续的认证流程与异步任务调度奠定数据基础。