Skip to content

数据库设计

本文档基于以下源文件编写:

目录

  1. 简介
  2. 数据库架构概览
  3. ER 关系图
  4. 表结构详解
  5. 索引与约束设计
  6. 迁移系统
  7. sqlc 代码生成
  8. Repository 模式
  9. UnitOfWork 事务模式
  10. 动态 SQL 与过滤系统
  11. 测试策略
  12. 设计决策与约定

1. 简介

Songloft 的数据层基于 SQLite,采用 WAL(Write-Ahead Logging)模式实现读写并发。整个数据访问栈由四个层次组成:goose 嵌入式迁移管理 schema 演进、sqlc 从注释化 SQL 生成类型安全的 Go 代码处理固定查询、squirrel 构建动态 WHERE/ORDER 条件、Repository + UnitOfWork 封装业务级数据操作与跨表事务。

数据库驱动使用 modernc.org/sqlite(纯 Go 实现),无需 CGO,支持 CGO_ENABLED=0 静态编译,适配 Docker scratch/alpine 等极简镜像。

章节来源: internal/database/sqlite.go(Open 函数、DSN 参数)、AGENTS.md(数据库规范)


2. 数据库架构概览

系统包含 7 张业务表,形成以歌曲(songs)和歌单(playlists)为核心的数据模型。

连接配置(sqlite.go Open 函数):

参数说明
journal_modeWAL读写并发,读不被写阻塞
busy_timeout10000ms遇锁等待 10 秒,避免 SQLITE_BUSY
synchronousNORMALWAL 模式下已足够安全
cache_size10000 页 (~40MB)页缓存
foreign_keys1启用外键约束(CASCADE 依赖此项)

Go 连接池:MaxOpenConns=10MaxIdleConns=5ConnMaxLifetime=30min

章节来源: internal/database/sqlite.go:28-55


3. ER 关系图

图表来源: internal/database/migrations/0001_init.sql0007_*.sql ~ 0025_*.sqlplugin_storage0016_plugin_storage.sql


4. 表结构详解

4.1 songs -- 歌曲表

存储本地、远程、电台三种类型的歌曲元数据,是系统最大的表(37 列)。

字段类型说明
idINTEGER PK自增主键
typeTEXT, CHECKlocal / remote / radio
title / artist / albumTEXT基础元数据
durationREAL时长(秒)
file_pathTEXT本地文件路径(仅 local)
urlTEXT远程/电台 URL
cover_path / cover_urlTEXT封面本地路径 / 远程 URL
lyricTEXT歌词 JSON(LyricPayload)
lyric_sourceTEXT, CHECKfile / embedded / scraped / url / cached / manual
lyric_remote_urlTEXT歌词远程 URL(迁移 0003)
file_size / format / bit_rate / sample_rate混合音频技术参数
is_liveINTEGER是否直播流(0/1)
plugin_entry_pathTEXT来源插件标识
source_dataTEXT插件特有的源数据
dedup_keyTEXT去重键(同插件下唯一)
year / genreINTEGER / TEXT年份 / 流派(迁移 0007)
fingerprint / fingerprint_durationTEXT / REAL音频指纹(迁移 0008)
isrcTEXT国际标准录音编码(迁移 0009)
cache_pathTEXT远程歌曲本地缓存路径(迁移 0014)
cue_source_path / cue_track_index / cue_audio_pathTEXT / INTEGER / TEXTCUE 分轨来源(迁移 0018)
file_modified_atDATETIME文件修改时间,扫描增量判定用(迁移 0019)
trackTEXT音轨号(迁移 0020)
language / styleTEXT语言 / 风格(迁移 0022)
is_videoINTEGER是否视频(0/1,迁移 0024)
added_at / updated_atDATETIME入库 / 更新时间(触发器维护)

关键设计:(plugin_entry_path, dedup_key) 联合唯一索引(部分索引,WHERE dedup_key != '')用于远程歌曲按插件去重。UpsertRemote 利用此索引实现"已存在则更新、不存在则插入"。

章节来源: internal/database/migrations/0001_init.sql0005_lyric_source_manual.sql(重建表)、0007~0024 增量 ALTER、internal/database/song_repository.go

4.2 playlists -- 歌单表

字段类型说明
idINTEGER PK自增主键
typeTEXT, CHECKnormal / radio
nameTEXT, UNIQUE歌单名(全局唯一)
descriptionTEXT描述
cover_path / cover_urlTEXT封面
labelsTEXT标签 JSON 数组(built_in / auto_created
positionINTEGER排序位置
created_at / updated_atDATETIME时间戳

预置数据:id=1「收藏」(labels=["built_in"])、id=2「电台收藏」。labels 通过 json_each() 查询,如批量删除时跳过内置歌单。

章节来源: internal/database/migrations/0001_init.sql:30-42,163-166

4.3 playlist_songs -- 歌单-歌曲关联表

字段类型说明
idINTEGER PK自增主键
playlist_idINTEGER FK→ playlists(id) ON DELETE CASCADE
song_idINTEGER FK→ songs(id) ON DELETE CASCADE
positionINTEGER歌曲在歌单内的排序位置
added_atDATETIME加入时间

UNIQUE(playlist_id, song_id) 防止重复。双向 CASCADE 确保删除歌曲/歌单时自动清理。

4.4 configs -- 配置表

key TEXT UNIQUE + value TEXT(通常 JSON)+ updated_at(触发器维护)。写入使用 UPSERT:INSERT ... ON CONFLICT(key) DO UPDATE SET value = excluded.value

预置 9 条配置:迁移 0001 写入 8 条(music_pathcover_storage_pathscan_configffprobe_pathjwt_secret(随机生成)、source_validation/source_fallback/source_metrics),迁移 0006 追加 plugin_registries

4.5 auth_tokens -- 认证令牌表

存储 JWT access/refresh token 记录。token_id UNIQUE,revoked_at NULLABLE(NULL 表示未撤销)。撤销判定:revoked_at IS NOT NULL OR expires_at < NOWCleanExpired 定期清理过期记录。

4.6 js_plugins -- JS 插件表

存储已安装插件的元数据(23 列)。entry_path UNIQUE 作为路由标识。permissionspublic_pathsexternal_paths 三个字段为 JSON 数组。status CHECK 约束 active/inactive/error

章节来源: internal/database/migrations/0001_init.sql0010_*.sql0011_*.sql0013_*.sql

4.7 plugin_storage -- 插件持久化 KV 表

供 JS 插件通过 storage Bridge API 读写的键值存储(迁移 0016 新增)。plugin_entry_path + key 组成 UNIQUE 约束,实现按插件隔离的命名空间。value TEXT(通常 JSON),created_at / updated_at 时间戳。查询定义在 internal/database/queries/plugin_storage.sql

字段类型说明
idINTEGER PK自增主键
plugin_entry_pathTEXT插件标识(命名空间)
keyTEXT
valueTEXT值(默认 '',通常 JSON)
created_at / updated_atDATETIME时间戳

章节来源: internal/database/migrations/0016_plugin_storage.sqlinternal/database/queries/plugin_storage.sql


5. 索引与约束设计

索引名类型说明
idx_songs_typesongstype普通按类型筛选
idx_songs_titlesongstitle普通标题搜索
idx_songs_artistsongsartist普通艺术家搜索
idx_songs_added_atsongsadded_at DESC普通默认排序优化
idx_songs_file_pathsongsfile_path普通文件夹前缀 LIKE(迁移 0004)
idx_songs_plugin_entry_pathsongsplugin_entry_path部分WHERE != ''
idx_songs_dedup_key_uniquesongs(plugin_entry_path, dedup_key)唯一+部分远程歌曲去重
idx_songs_fingerprintsongsfingerprint部分重复检测(迁移 0008)
idx_playlists_name_uniqueplaylistsname唯一歌单名全局唯一
idx_playlist_songs_positionplaylist_songs(playlist_id, position)普通歌单内排序
idx_auth_tokens_*auth_tokenstoken_id/type/expires/revoked普通各维度查询
idx_js_plugins_*js_pluginsstatus/entry_path普通状态和路径查询

四张核心表(songs/playlists/configs/js_plugins)各有 AFTER UPDATE 触发器,自动维护 updated_at = CURRENT_TIMESTAMP

章节来源: internal/database/migrations/0001_init.sql:106-160


6. 迁移系统

迁移文件通过 //go:embed migrations/*.sql 嵌入 Go 二进制,启动时 goose.Up 自动执行所有未应用的迁移,无需外部工具。

go
//go:embed migrations/*.sql
var migrationsFS embed.FS

func runMigrations(db *sql.DB) error {
    goose.SetBaseFS(migrationsFS)
    goose.SetDialect("sqlite3")
    return goose.Up(db, "migrations")
}

迁移文件列表(25 个)

编号说明
0001初始 schema:6 张表 + 索引 + 触发器 + 内置歌单 + 默认配置
0002新增 scan_auto_create_include_subdirs 配置项
0003新增 lyric_remote_url 列,裸 LRC 迁移为 JSON
0004新增 file_path 索引(文件夹前缀查询)
0005放宽 lyric_source CHECK 约束新增 manual(重建表)
0006预置官方插件注册表 URL
0007新增 yeargenre 字段
0008新增 fingerprintfingerprint_duration + 部分索引
0009新增 isrc 字段
0010js_plugins 新增 public_paths JSON 数组
0011js_plugins 新增 icon 字段
0012新增 scan_auto_create_playlists 配置项
0013js_plugins 新增 external_paths JSON 数组
0014songs 新增 cache_path(远程歌曲本地缓存路径)
0015新增 scan playlist 模式配置
0016新增 plugin_storage 表(插件 KV 存储)
0017修正 cover 存储路径
0018songs 新增 CUE 支持(cue_source_path/cue_track_index/cue_audio_path
0019songs 新增 file_modified_at
0020songs 新增 track(音轨号)
0021扫描支持 .mov 格式
0022songs 新增 languagestyle
0023扫描支持 .mp4 格式
0024视频支持:songs 新增 is_video
0025新增 facet 索引

注意事项

  • SQLite 不支持 ALTER TABLE ... MODIFY COLUMN 或修改 CHECK 约束,因此迁移 0005 采用"重建表"策略:CREATE TABLE songs_new → INSERT INTO ... SELECT → DROP TABLE songs → ALTER TABLE songs_new RENAME TO songs → 重建全部索引和触发器。
  • 每条 SQL 必须独立包裹在 -- +goose StatementBegin / -- +goose StatementEnd 中,否则 SQLite 驱动在单次 Exec 里只会执行第一条 prepared statement,后续 SQL 静默丢失。
  • 禁止直接 ALTER data/songloft.db,所有 schema 变更必须通过迁移文件完成。
  • 迁移文件命名遵循 000N_描述.sql 格式,goose 按数字顺序执行。

章节来源: internal/database/sqlite.go:16,58-67internal/database/migrations/ 全部文件


7. sqlc 代码生成

sqlc.yaml 配置 engine=sqlite,从 queries/ 读取 SQL、从 migrations/ 推导 schema,生成到 internal/database/sqlc/。关键选项:emit_interface: true 生成 DBTX 接口使 *sql.DB*sql.Tx 可互换;emit_empty_slices: true 空结果返回 []T 而非 nil。

7 个查询文件对应 7 张表:

文件查询数代表性查询
songs.sql30GetSongByID, CreateSong, UpdateRemoteSongMutable, FindSongByDedupKey, ListDuplicateFingerprints
playlists.sql13GetPlaylistByID, CreatePlaylist, FindPlaylistByName, InsertAutoCreatedPlaylist
playlist_songs.sql11AddSongToPlaylist, GetPlaylistSongs(Paginated), AddSongToPlaylistIgnore
configs.sql3GetConfig, SetConfig (UPSERT), DeleteConfig
tokens.sql5CreateToken, RevokeToken, IsTokenRevoked, CleanExpiredTokens
js_plugins.sql8ListJSPlugins, CreateJSPlugin, UpdateJSPluginStatus, UpdateJSPluginHashes
plugin_storage.sql7插件 KV 读写(GetPluginStorage/SetPluginStorage 等)

sqlc 注释约定::one 返回单行、:many 返回切片、:exec 只执行、:execrows 返回影响行数、:execlastid 返回 last insert ID。修改查询后须 make sqlc 并提交生成产物。

章节来源: sqlc.yamlinternal/database/queries/*.sql


8. Repository 模式

8.1 架构

每个 Repository 持有 sqlc.DBTX 接口,固定查询委托 *sqlc.Queries,动态查询用 squirrel。通过 SQLiteDB 工厂方法获取。

go
type SongRepository struct {
    db      sqlc.DBTX      // *sql.DB 或 *sql.Tx
    queries *sqlc.Queries  // sqlc 生成的查询
}

8.2 SongRepository -- 歌曲仓储

最大的仓储,涵盖歌曲全生命周期。关键方法:

方法SQL 方式说明
GetByID / Create / Update / Deletesqlc基础 CRUD
UpdateLyrics / UpdateDuration / UpdateFingerprintsqlc局部字段更新
UpsertRemotesqlc按 dedup_key 去重写入远程歌曲
List / ListIDs / Countsquirrel动态过滤 + 排序 + 分页
BatchCreate / BatchDelete混合批量操作,自动管理事务
ListLocalPathssqlc扫描去重用
ListDuplicateGroupssqlc指纹重复检测

8.3 PlaylistRepository -- 歌单仓储

方法SQL 方式说明
Create / Updatesqlc (事务)含同名冲突检测
List / CountsquirrelLEFT JOIN 统计歌曲数
AutoCreate混合 (事务)按目录结构批量生成歌单
BatchDelete手工 SQL跳过 built_in 歌单
BatchUpdatePositionssqlc (事务)批量更新排序

8.4 PlaylistSongRepository -- 关联仓储

管理歌单与歌曲的多对多关联。

方法SQL 方式说明
AddSong / AddSongIgnoresqlc添加歌曲,后者已存在时静默跳过
AddSongsBatchsqlc (事务)批量追加,自动跳过已有,返回 (added, skipped)
RemoveSongsqlc移除歌曲
ReplaceSongsqlc (事务)用新歌曲替换旧歌曲并保留 position
GetSongs / GetSongsPaginatedsqlc按 position 排序获取歌曲
CountSongs / MaxPositionsqlc统计和位置查询
BatchUpdatePositionssqlc (事务)按给定顺序重写所有 position
ListPlaylistsContainingSongsqlc查找包含指定歌曲的所有歌单(转换服务用)

8.5 ConfigRepository -- 配置仓储

方法SQL 方式说明
Getsqlc按 key 读取,未找到返回 ErrNotFound
SetsqlcUPSERT 写入(INSERT ON CONFLICT DO UPDATE)
Deletesqlc按 key 删除
List / Countsquirrel关键词过滤 + 白名单排序 + 分页

8.6 TokenRepository -- 令牌仓储

方法SQL 方式说明
Createsqlc写入新令牌并回填 ID
GetByIDsqlc按 token_id 查找
Revokesqlc标记为已撤销(设置 revoked_at)
IsRevokedsqlc判断是否撤销或过期
ListActivesquirrel列出活跃令牌(动态过滤 + 排序)
CleanExpiredsqlc批量清理过期记录

8.7 JSPluginRepository -- 插件仓储

纯 sqlc 仓储,无动态 SQL 需求。

方法SQL 方式说明
GetAll / GetByID / GetByEntryPathsqlc查询(JSON 字段自动反序列化)
Create / Updatesqlc全字段操作(JSON 字段序列化)
Deletesqlc删除
UpdateStatussqlc仅更新 status 字段
UpdateHashessqlc更新 zip_hash/entry_hash/file_mod_time

8.8 错误语义

go
var ErrNotFound = errors.New("database: record not found")  // sql.ErrNoRows 或 RowsAffected()==0
var ErrConflict = errors.New("database: conflict")          // UNIQUE 约束冲突

Service 层用 errors.Is 判断:ErrNotFound → HTTP 404,ErrConflict → 业务语义(如 ErrPlaylistNameConflict)。

章节来源: internal/database/errors.go、各 *_repository.go


9. UnitOfWork 事务模式

SQLite 单 writer 架构下,跨表写必须在同一 *sql.Tx 完成。UnitOfWork 将三个写密集型 Repository 绑定到同一事务:

go
type UnitOfWork struct {
    Songs         *SongRepository
    Playlists     *PlaylistRepository
    PlaylistSongs *PlaylistSongRepository
}

SQLiteDB.RunInTx 自动管理事务生命周期,fn 返回 error 时回滚,panic 时也回滚后重新 panic:

go
db.RunInTx(ctx, func(ctx context.Context, uow *database.UnitOfWork) error {
    if err := uow.Songs.Create(ctx, newSong); err != nil {
        return err
    }
    return uow.PlaylistSongs.ReplaceSong(ctx, playlistID, oldSongID, newSong.ID)
})

部分 Repository 方法内部也需事务(如 Create 的同名检查 + INSERT)。通过 runInTx 辅助函数实现智能嵌套:底层是 *sql.DB 时自启动事务,底层已是 *sql.Tx 时直接复用。

章节来源: internal/database/unit_of_work.gointernal/database/sqlite.go:80-102


10. 动态 SQL 与过滤系统

Filter 结构体

每种实体定义对应的 Filter:SongFilter(Type/Keyword/PathPrefix/分页/排序)、PlaylistFilter(Type/Labels/Keyword)、ConfigFilter(Keyword)、TokenFilter(TokenType/IsActive)。

排序白名单

防止 SQL 注入,排序字段必须在白名单内,否则回退默认排序:

go
var songOrderWhitelist = map[string]struct{}{
    "id": {}, "title": {}, "artist": {}, "album": {},
    "duration": {}, "added_at": {}, "updated_at": {},
}

applyOrder 统一处理排序逻辑,支持带表前缀的 JOIN 查询(如 "p." 用于 playlist 的 LEFT JOIN)。applyPagination 统一处理 LIMIT/OFFSET(limit<=0 视为不分页)。

squirrel 构建流程

go
sb := songSelectBuilder()           // 基础 SELECT
sb = applySongFilter(sb, filter)    // 动态 WHERE
sb = applyOrder(sb, ...)            // 白名单排序
sb = applyPagination(sb, ...)       // 分页
query, args, _ := sb.ToSql()        // 生成 SQL

PathPrefix 过滤使用 escapeLikeLiteral 转义通配符 + ESCAPE '\' 子句。歌单列表使用 LEFT JOIN 子查询统计歌曲数。

章节来源: internal/database/filters.gointernal/database/song_repository.go:280-311,448-464


11. 测试策略

testutil.OpenMemoryDB 提供零配置测试数据库:

go
func OpenMemoryDB(t *testing.T) *database.SQLiteDB {
    db, err := database.Open(":memory:")  // 自动执行全部 goose 迁移
    if err != nil { t.Fatalf(...) }
    t.Cleanup(func() { _ = db.Close() })
    return db
}
  • 真实 SQLite :memory:,不是 mock,schema 与生产完全一致
  • 每个测试独立实例,无状态污染
  • 禁止手写 mock DB,所有测试走真实 Repository
  • 行数断言需扣除内置数据(2 条歌单 + 9 条 configs)

章节来源: internal/database/testutil/memdb.goAGENTS.md


12. 设计决策与约定

不使用 ORM:固定查询 → sqlc 编译时类型检查;动态查询 → squirrel 显式构建(禁止字符串拼接);跨表写 → RunInTx + UnitOfWork。

JSON 字段存储:SQLite TEXT 字段存 JSON,利用 json_each()/json_extract() 查询。Go 层在 Create/Update 时 json.Marshal,RowToModel 时 json.Unmarshal。涉及字段:playlists.labelsjs_plugins.permissions/public_paths/external_pathsconfigs.value

CASCADE 删除playlist_songs 双向 CASCADE 是核心设计 -- 删除歌曲自动清理该歌曲在所有歌单中的记录,删除歌单自动清理该歌单下所有关联。Repository 方法的注释中明确标注这一依赖(如 SongRepository.Delete 注释:"playlist_songs 由 FK ON DELETE CASCADE 自动清理")。依赖 foreign_keys=1 PRAGMA 开启。

Service 层禁止直接操作事务:必须通过 DB.RunInTx 获取 UnitOfWork,禁止自行 BeginTx,避免 SQLite 单 writer 下的 SQLITE_BUSY 死锁。

封面引用计数:封面按内容哈希共享存储,SongRepository.CountCoverPathReferences 统计 songs + playlists 两表中引用同一 cover_path 的总行数,调用方在物理删除封面文件前必须确认计数为 0。

章节来源: AGENTS.md(数据库规范)、各 Repository 源码