06. 数据库与模型

3 阅读8分钟

06. 数据库与模型

本章目标

  • 理解 PostgreSQL 表结构设计
  • 掌握 Bun ORM 的使用方法
  • 学会定义和映射数据模型
  • 理解 Repository 设计模式

数据库表结构

在分析代码之前,我们先了解 sco-commit 的数据库设计。项目使用 PostgreSQL 存储从 Gitea 同步的数据,主要包含以下核心表。

repositories 表(仓库表)

仓库表存储从 Gitea 同步的仓库基本信息:

CREATE TABLE repositories (
    id SERIAL PRIMARY KEY,
    gitea_id BIGINT UNIQUE NOT NULL,      -- Gitea 仓库 ID
    owner VARCHAR(255) NOT NULL,           -- 仓库所有者
    name VARCHAR(255) NOT NULL,           -- 仓库名称
    full_name VARCHAR(512) NOT NULL,       -- 完整名称 (owner/name)
    description TEXT,                      -- 仓库描述
    default_branch VARCHAR(255),            -- 默认分支
    stars_count INT DEFAULT 0,             -- Star 数量
    forks_count INT DEFAULT 0,              -- Fork 数量
    open_issues_count INT DEFAULT 0,       -- 未关闭 Issue 数量
    created_at TIMESTAMP NOT NULL,         -- Gitea 创建时间
    updated_at TIMESTAMP NOT NULL          -- Gitea 更新时间
);

这个表的设计遵循几个原则:使用 gitea_id 作为唯一标识来关联 Gitea 原数据,full_name 方便搜索和展示,同时保留 stars_count 等统计数据以减少实时计算。

commits 表(提交记录表)

提交记录表是核心数据表,存储所有仓库的 Git 提交历史:

CREATE TABLE commits (
    id SERIAL PRIMARY KEY,
    repo_id INT NOT NULL,                  -- 关联仓库
    sha VARCHAR(40) UNIQUE NOT NULL,       -- Git commit SHA
    author_name VARCHAR(255) NOT NULL,     -- 作者名称
    author_email VARCHAR(255) NOT NULL,    -- 作者邮箱
    author_avatar_url TEXT,                -- 作者头像 URL
    committer_name VARCHAR(255),            -- 提交者名称
    committer_email VARCHAR(255),          -- 提交者邮箱
    message TEXT NOT NULL,                 -- 提交信息
    additions INT DEFAULT 0,               -- 新增行数
    deletions INT DEFAULT 0,              -- 删除行数
    total_changes INT DEFAULT 0,           -- 总变更行数
    committed_at TIMESTAMP NOT NULL,       -- 提交时间
    created_at TIMESTAMP                   -- 记录创建时间
);

注意 sha 字段使用 UNIQUE 约束保证不重复存储同一提交。通过 author_email 可以关联到贡献者,additionsdeletions 用于后续的代码量统计分析。

contributors 表(贡献者表)

贡献者表存储从提交记录聚合的贡献者统计信息:

CREATE TABLE contributors (
    id SERIAL PRIMARY KEY,
    email VARCHAR(255) UNIQUE NOT NULL,    -- 贡献者邮箱(唯一标识)
    name VARCHAR(255) NOT NULL,             -- 贡献者名称
    login VARCHAR(255),                    -- 用户名(邮箱前缀)
    avatar_url TEXT,                       -- 头像 URL
    total_commits INT DEFAULT 0,          -- 总提交数
    total_additions INT DEFAULT 0,        -- 总新增行数
    total_deletions INT DEFAULT 0,        -- 总删除行数
    first_commit_at TIMESTAMP,              -- 首次提交时间
    last_commit_at TIMESTAMP,              -- 最后提交时间
    created_at TIMESTAMP,
    updated_at TIMESTAMP
);

这是一个典型的聚合表,数据来源于 commits 表的定期计算。预计算这些统计数据可以大幅提升前端查询性能。

branches 表(分支表)

分支表记录每个仓库的分支信息:

CREATE TABLE branches (
    id SERIAL PRIMARY KEY,
    repo_id INT NOT NULL,                  -- 关联仓库
    name VARCHAR(255) NOT NULL,            -- 分支名称
    is_default BOOLEAN DEFAULT false,      -- 是否为默认分支
    last_synced_sha VARCHAR(40),            -- 该分支最后同步的提交 SHA
    last_synced_at TIMESTAMP,              -- 最后同步时间
    created_at TIMESTAMP,
    updated_at TIMESTAMP,
    UNIQUE(repo_id, name)                  -- 仓库内分支名唯一
);

last_synced_sha 字段用于增量同步,当从 Gitea 拉取新提交时,只需要获取该 SHA 之后的提交。

commit_branches 表(提交-分支关联表)

一个 Git 提交可能出现在多个分支中(通过 merge 或 cherry-pick),因此需要多对多关联表:

CREATE TABLE commit_branches (
    commit_id INT NOT NULL,
    branch_id INT NOT NULL,
    PRIMARY KEY (commit_id, branch_id)
);

contributor_repo_stats 表(贡献者-仓库关联表)

这是另一个聚合表,记录每个贡献者在每个仓库的统计数据:

CREATE TABLE contributor_repo_stats (
    id SERIAL PRIMARY KEY,
    contributor_id INT NOT NULL,
    repo_id INT NOT NULL,
    commits_count INT DEFAULT 0,           -- 该仓库的提交数
    additions INT DEFAULT 0,              -- 该仓库的新增行数
    deletions INT DEFAULT 0,              -- 该仓库的删除行数
    first_commit_at TIMESTAMP,            -- 该仓库首次提交时间
    last_commit_at TIMESTAMP,             -- 该仓库最后提交时间
    UNIQUE(contributor_id, repo_id)       -- 确保唯一性
);

AI 相关表

项目还包含 AI 模块的数据库表(ai_agent_sessionsai_agent_messages),用于存储对话会话和消息。详细内容将在后续 AI 功能章节介绍。

Bun ORM 模型定义

sco-commit 使用 Bun 作为 ORM(对象关系映射)工具。Bun 是 Go 生态中性能优秀的 ORM,API 设计参考了 Python 的 Django 和 SQLAlchemy。

模型结构

每个数据库表都对应一个 Go 结构体,定义在 server/app/gitea/models/db/ 目录下:

// Repository 仓库表
type Repository struct {
    bun.BaseModel `bun:"table:repositories,alias:r"`

    ID              int       `bun:"id,pk,autoincrement" json:"id"`
    GiteaID         int64     `bun:"gitea_id,notnull,unique" json:"gitea_id"`
    Owner           string    `bun:"owner,notnull" json:"owner"`
    Name            string    `bun:"name,notnull" json:"name"`
    FullName        string    `bun:"full_name,notnull" json:"full_name"`
    Description     string    `bun:"description" json:"description"`
    DefaultBranch   string    `bun:"default_branch" json:"default_branch"`
    StarsCount      int       `bun:"stars_count" json:"stars_count"`
    ForksCount      int       `bun:"forks_count" json:"forks_count"`
    OpenIssuesCount int       `bun:"open_issues_count" json:"open_issues_count"`
    CreatedAt       time.Time `bun:"created_at,notnull" json:"created_at"`
    UpdatedAt       time.Time `bun:"updated_at,notnull" json:"updated_at"`
}

结构体中的 bun: 标签告诉 ORM 如何映射字段:

标签说明
table:xxx对应的数据库表名
alias:xxx查询时的表别名
pk主键字段
autoincrement自增字段
notnull非空约束
unique唯一约束

模型关联

Bun 支持定义表之间的关联关系:

// Commit 提交记录表
type Commit struct {
    bun.BaseModel `bun:"table:commits,alias:c"`

    ID             int       `bun:"id,pk,autoincrement"`
    RepoID         int       `bun:"repo_id,notnull"`
    SHA            string    `bun:"sha,notnull,unique"`
    AuthorName     string    `bun:"author_name,notnull"`
    AuthorEmail    string    `bun:"author_email,notnull"`
    Message        string    `bun:"message,notnull"`
    // ... 其他字段

    // 关联:belongs-to 表示多对一关系
    Repository *Repository `bun:"rel:belongs-to,join:repo_id=id"`
}

这里 rel:belongs-to 表示多对一关系,即一个提交属于一个仓库。

数据库初始化

server/infrastructure/database/database.go 中完成数据库连接和模型注册:

func NewDatabaseService(dsn string) (*DatabaseService, error) {
    // 创建 PostgreSQL 连接
    sqldb := sql.OpenDB(pgdriver.NewConnector(pgdriver.WithDSN(dsn)))

    // 配置连接池
    sqldb.SetMaxOpenConns(25)
    sqldb.SetMaxIdleConns(10)
    sqldb.SetConnMaxLifetime(30 * time.Minute)

    // 创建 Bun DB 实例
    db := bun.NewDB(sqldb, pgdialect.New())

    // 注册模型(必须步骤)
    db.RegisterModel((*gitea_db.Repository)(nil))
    db.RegisterModel((*gitea_db.Commit)(nil))
    db.RegisterModel((*gitea_db.Contributor)(nil))
    db.RegisterModel((*gitea_db.ContributorRepoStats)(nil))

    // 测试连接
    if err := db.PingContext(ctx); err != nil {
        return nil, fmt.Errorf("数据库连接失败: %w", err)
    }

    return &DatabaseService{db: db}, nil
}

连接池配置是生产环境的重要优化:MaxOpenConns 控制最大并发连接数,MaxIdleConns 控制空闲连接数,ConnMaxLifetime 防止长期连接导致的数据库端连接泄漏。

Repository 数据访问层

项目采用 Repository 模式封装数据访问逻辑,将数据库操作与业务逻辑分离。

创建 Repository

// RepoRepository 仓库数据访问
type RepoRepository struct {
    db *bun.DB
}

func NewRepoRepository(db *bun.DB) *RepoRepository {
    return &RepoRepository{db: db}
}

插入数据

使用 NewInsert() 进行数据插入:

// Upsert 创建或更新仓库(冲突时更新)
func (r *RepoRepository) Upsert(ctx context.Context, repo *gitea_db.Repository) error {
    _, err := r.db.NewInsert().
        Model(repo).
        On("CONFLICT (gitea_id) DO UPDATE").
        Set("owner = EXCLUDED.owner").
        Set("name = EXCLUDED.name").
        Set("updated_at = EXCLUDED.updated_at").
        Exec(ctx)
    return err
}

// BatchInsert 批量插入(忽略已存在的)
func (r *CommitRepository) BatchInsert(ctx context.Context, commits []gitea_db.Commit) (int, error) {
    result, err := r.db.NewInsert().
        Model(&commits).
        On("CONFLICT (sha) DO NOTHING").
        Exec(ctx)
    // ...
}

Upsert 操作是同步 Gitea 数据时的常用模式:当仓库已存在时更新,不存在时插入。PostgreSQL 的 ON CONFLICT 语法配合 Bun ORM 非常简洁。

查询数据

使用 NewSelect() 进行数据查询:

// 根据 ID 查询单条记录
func (r *RepoRepository) FindOne(ctx context.Context, id int) (*gitea_db.Repository, error) {
    repo := new(gitea_db.Repository)
    err := r.db.NewSelect().
        Model(repo).
        Where("id = ?", id).
        Scan(ctx)
    return repo, err
}

// 查询列表
func (r *RepoRepository) List(ctx context.Context) ([]gitea_db.Repository, error) {
    var repos []gitea_db.Repository
    err := r.db.NewSelect().
        Model(&repos).
        Order("full_name ASC").
        Scan(ctx)
    return repos, err
}

// 分页查询
func (r *RepoRepository) ListWithPage(ctx context.Context, page, pageSize int, keywords string) ([]gitea_db.Repository, int, error) {
    var repos []gitea_db.Repository

    baseQuery := r.db.NewSelect().Model(&repos)

    // 条件搜索
    if keywords != "" {
        pattern := "%" + keywords + "%"
        baseQuery = baseQuery.Where("name ILIKE ? OR full_name ILIKE ?", pattern, pattern)
    }

    // 查询总数
    total, _ := baseQuery.Count(ctx)

    // 分页
    offset := (page - 1) * pageSize
    err := baseQuery.
        Order("full_name ASC").
        Offset(offset).
        Limit(pageSize).
        Scan(ctx)

    return repos, total, err
}

Bun 的查询构建器采用链式调用风格,非常直观。支持 WhereOrderLimitOffset 等常见操作。

关联查询

使用 Relation() 方法加载关联数据:

// 查询提交并关联仓库信息
func (r *CommitRepository) List(ctx context.Context, repoID int) ([]gitea_db.Commit, error) {
    var commits []gitea_db.Commit
    err := r.db.NewSelect().
        Model(&commits).
        Relation("Repository").
        Where("repo_id = ?", repoID).
        Scan(ctx)
    return commits, err
}

聚合查询

对于统计分析,通常需要使用原生 SQL 或 TableExpr

// 获取提交趋势(按日期分组)
func (r *CommitRepository) GetTrend(ctx context.Context, repoID int, startDate, endDate string) ([]struct {
    Date    string `bun:"date"`
    Commits int    `bun:"commits"`
}, error) {
    var trend []struct {
        Date    string `bun:"date"`
        Commits int    `bun:"commits"`
    }

    err := r.db.NewSelect().
        TableExpr("commits AS c").
        ColumnExpr("TO_CHAR(c.committed_at, 'YYYY-MM-DD') AS date").
        ColumnExpr("COUNT(*) AS commits").
        Where("c.repo_id = ?", repoID).
        Where("c.committed_at >= ?", startDate).
        GroupExpr("TO_CHAR(c.committed_at, 'YYYY-MM-DD')").
        OrderExpr("date ASC").
        Scan(ctx, &trend)

    return trend, err
}

// 获取热力图数据(按星期和小时分组)
func (r *CommitRepository) GetHeatmap(ctx context.Context, repoID int) ([]struct {
    DayOfWeek int `bun:"day_of_week"`
    Hour      int `bun:"hour"`
    Count     int `bun:"count"`
}, error) {
    var heatmap []struct {
        DayOfWeek int `bun:"day_of_week"`
        Hour      int `bun:"hour"`
        Count     int `bun:"count"`
    }

    err := r.db.NewSelect().
        TableExpr("commits AS c").
        ColumnExpr("EXTRACT(DOW FROM c.committed_at)::int AS day_of_week").
        ColumnExpr("EXTRACT(HOUR FROM c.committed_at)::int AS hour").
        ColumnExpr("COUNT(*) AS count").
        Where("c.repo_id = ?", repoID).
        GroupExpr("day_of_week, hour").
        Scan(ctx, &heatmap)

    return heatmap, err
}

这些聚合查询展示了 Bun ORM 与原生 SQL 的混合使用方式。对于复杂的统计需求,直接写 SQL 表达式更高效。

索引设计

数据库性能很大程度上取决于索引设计。项目中创建的索引包括:

-- 仓库表索引
CREATE INDEX idx_repositories_gitea_id ON repositories(gitea_id);
CREATE INDEX idx_repositories_full_name ON repositories(full_name);

-- 提交表索引
CREATE INDEX idx_commits_repo_id ON commits(repo_id);
CREATE INDEX idx_commits_sha ON commits(sha);
CREATE INDEX idx_commits_repo_committed ON commits(repo_id, committed_at DESC);
CREATE INDEX idx_commits_author_email ON commits(author_email);
CREATE INDEX idx_commits_committed_at ON commits(committed_at DESC);

-- 贡献者表索引
CREATE INDEX idx_contributors_email ON contributors(email);
CREATE INDEX idx_contributors_total_commits ON contributors(total_commits DESC);

-- 分支表索引
CREATE INDEX idx_branches_repo_id ON branches(repo_id);
CREATE INDEX idx_branches_repo_default ON branches(repo_id, is_default);

复合索引 idx_commits_repo_committed 专门为「查询某仓库的最近提交」这一高频操作优化。