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 可以关联到贡献者,additions 和 deletions 用于后续的代码量统计分析。
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_sessions 和 ai_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 的查询构建器采用链式调用风格,非常直观。支持 Where、Order、Limit、Offset 等常见操作。
关联查询
使用 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 专门为「查询某仓库的最近提交」这一高频操作优化。