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. 数据库架构概览 ​

系统包含 13 张业务表(songs、playlists、playlist_songs、play_history、configs、auth_tokens、js_plugins、plugin_storage、theme_packs、song_tags、song_tag_links、artists、song_artists),形成以歌曲(songs)和歌单(playlists)为核心的数据模型。

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

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

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

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


3. ER 关系图 ​

图表来源: internal/database/migrations/0001_init.sql、0007_*.sql ~ 0038_*.sql(plugin_storage 见 0016、play_history 见 0030、theme_packs 见 0031、song_tags 见 0036、artists/song_artists 见 0038)


4. 表结构详解 ​

4.1 songs -- 歌曲表 ​

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

字段类型说明
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)
cue_start_seconds / cue_end_secondsREALCUE 分轨播放起止秒数(迁移 0027)
fingerprint_attempted_atINTEGER指纹计算已尝试标记(unix 秒,0=未尝试,迁移 0029)
fingerprint_errorTEXT指纹提取失败错误信息(迁移 0037)
added_at / updated_atDATETIME入库 / 更新时间(触发器维护)

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

章节来源: internal/database/migrations/0001_init.sql、0005_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)
sort_byTEXT视图排序字段(默认 position,迁移 0034)
sort_orderTEXT视图排序方向(默认 asc,迁移 0034)
positionINTEGER排序位置
pinned_atDATETIME置顶时间(nullable,迁移 0035)
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_path、cover_storage_path、scan_config、ffprobe_path、jwt_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 < NOW。CleanExpired 定期清理过期记录。

4.6 js_plugins -- JS 插件表 ​

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

章节来源: internal/database/migrations/0001_init.sql、0010_*.sql、0011_*.sql、0013_*.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.sql、internal/database/queries/plugin_storage.sql

4.8 play_history -- 播放历史表 ​

按「播放上下文」记录最近播放过的歌曲(迁移 0030)。上下文由 (context_type, context_key) 二元组标识:歌单 ("playlist", "<ID>")、自定义标签 ("tag", "<ID>")、分面维度 ("artist", "...") 等。

字段类型说明
idINTEGER PK自增主键
context_typeTEXT上下文类型(playlist / tag / artist / album / genre / year / decade / language / style)
context_keyTEXT上下文键(歌单 ID 或分面取值)
song_idINTEGER FK→ songs(id) ON DELETE CASCADE
played_atDATETIME播放时间
play_countINTEGER累计播放次数

UNIQUE(context_type, context_key, song_id) 去重,写入用 upsert(ON CONFLICT DO UPDATE 刷新 played_at、累加 play_count)。索引 (context_type, context_key, played_at DESC, id DESC) 保证读取与裁剪顺序确定。

章节来源: internal/database/migrations/0030_play_history.sql

4.9 theme_packs -- 主题包表 ​

声明式 JSON 主题包(迁移 0031)。raw_json 存储完整原始 JSON,前端直接读取渲染;顶层字段做索引/查询用。

字段类型说明
idINTEGER PK自增主键
theme_idTEXT, UNIQUE全局唯一标识
nameTEXT主题名称
versionTEXT版本号(默认 1.0.0)
authorTEXT作者
descriptionTEXT描述
schema_versionINTEGER主题 JSON schema 版本(默认 1)
raw_jsonTEXT完整主题 JSON
created_at / updated_atDATETIME时间戳(updated_at 由 TRIGGER 维护)

章节来源: internal/database/migrations/0031_theme_packs.sql

用户自定义歌曲标签(迁移 0036)。song_tags 存储标签实体,song_tag_links 实现歌曲与标签的 M:N 多对多关联。

song_tags:id(PK), name(UNIQUE NOCASE), color, created_at。

song_tag_links:song_id(FK) + tag_id(FK) 复合主键,双向 CASCADE。idx_song_tag_links_tag 索引加速“某标签下所有歌曲”查询。

章节来源: internal/database/migrations/0036_song_tags.sql

4.11 artists / song_artists -- 歌手表 ​

规范化多值歌手(迁移 0038)。artists 是歌手实体(按 normalized_key = lower(trim(name)) 去重),song_artists 是歌曲与歌手的 M:N 多对多关联。

artists:id(PK), name, normalized_key(UNIQUE), created_at。

song_artists:song_id(FK) + artist_id(FK) + role 复合主键,role CHECK(artist / album_artist),position 排序位置。双向 CASCADE。迁移 0038 自动回填现有 songs.artist 整串为单行关联。

章节来源: internal/database/migrations/0038_song_artists.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普通状态和路径查询
idx_play_history_*play_history(context_type, context_key, played_at DESC, id DESC)普通按上下文查询与裁剪(迁移 0030)
idx_song_tag_links_tagsong_tag_linkstag_id普通标签下歌曲查询(迁移 0036)
idx_song_artists_*song_artistsartist_id / song_id普通歌手关联查询(迁移 0038)
idx_playlist_songs_song_idplaylist_songssong_id普通歌曲所属歌单查询(迁移 0033)

四张核心表(songs/playlists/configs/js_plugins)及 theme_packs 各有 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")
}

迁移文件列表(39 个) ​

编号说明
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新增 year、genre 字段
0008新增 fingerprint、fingerprint_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 新增 language、style
0023扫描支持 .mp4 格式
0024视频支持:songs 新增 is_video
0025新增 facet 索引
0026扫描支持 .mka 格式
0027songs 新增 cue_start_seconds/cue_end_seconds(CUE 实时分轨)
0028扩展扫描支持格式(opus/m4b/oga/mpg/mpeg/m4v/flv/wmv/rm/rmvb/3gp)
0029songs 新增 fingerprint_attempted_at(指纹失败不重试)
0030新增 play_history 表(播放历史)
0031新增 theme_packs 表(主题包)
0032js_plugins 新增 render_engine 字段(插件页渲染引擎声明)
0033新增 idx_playlist_songs_song_id 索引
0034playlists 新增 sort_by/sort_order(歌单视图排序偏好)
0035playlists 新增 pinned_at(置顶时间)
0036新增 song_tags + song_tag_links 表(自定义标签)
0037songs 新增 fingerprint_error(指纹提取错误信息)
0038新增 artists + song_artists 表(规范化多值歌手,含回填逻辑)
0039扫描支持 .dsf(DSD)格式,保留自定义配置并幂等追加

注意事项 ​

  • 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-67、internal/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。

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

文件查询数代表性查询
songs.sql38GetSongByID, CreateSong, UpdateRemoteSongMutable, FindSongByDedupKey, ListDuplicateFingerprints
playlists.sql15GetPlaylistByID, 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 等)
play_history.sql8RecordPlay (upsert), TrimPlayHistory, ListPlayHistory, ClearByPlaylist/ByTag
song_tags.sql10CreateSongTag, LinkSongTag, UnlinkSongTag, GetTagsBySongID, CountSongsByTag
theme_packs.sql5CreateThemePack, GetThemePackByThemeID, ListThemePacks, DeleteThemePack
song_artists.sql7UpsertArtist, LinkSongArtist, GetArtistsBySongID, DeleteSongArtistsBySong

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

章节来源: sqlc.yaml、internal/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 PlayHistoryRepository -- 播放历史仓储 ​

纯 sqlc 仓储,用于播放历史的 upsert、裁剪、查询与清理。

方法SQL 方式说明
RecordPlaysqlcupsert 播放记录(ON CONFLICT 刷新 played_at、累加 play_count)
TrimPlayHistorysqlc按上下文裁剪至上限
ListPlayHistorysqlc按上下文查询历史列表
ClearPlayHistorysqlc清空指定上下文
ClearByPlaylist / ClearByTagsqlc按歌单/标签清理

8.9 SongTagRepository -- 自定义标签仓储 ​

混合 sqlc + squirrel。sqlc 处理基础 CRUD 和链接操作,squirrel 用于动态查询。

方法SQL 方式说明
Create / GetByID / Update / Deletesqlc标签 CRUD
Link / Unlink / UnlinkAllBySongsqlc歌曲-标签链接操作
GetTagsBySongIDsqlc查询歌曲的所有标签
ListSongs / ListSongIDssquirrel指定标签下的歌曲动态查询

8.10 ThemePackRepository -- 主题包仓储 ​

纯 sqlc 仓储,管理主题包的增删改查。

方法SQL 方式说明
Create / Updatesqlc创建/更新主题包
GetByThemeIDsqlc按 theme_id 查询
Listsqlc列出所有主题包(按 created_at DESC)
Deletesqlc按 theme_id 删除

8.11 SongArtistRepository -- 歌手关联仓储 ​

纯 sqlc 仓储,管理歌手实体与歌曲-歌手关联。

方法SQL 方式说明
UpsertArtistsqlc按 normalized_key 去重写入歌手
GetArtistByNormalizedKey / GetArtistByNamesqlc查询歌手
DeleteSongArtistsBySongsqlc删除歌曲的所有歌手关联
LinkSongArtistsqlc链接歌曲与歌手
GetArtistsBySongIDsqlc查询歌曲的所有歌手

8.12 错误语义 ​

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.go、internal/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.go、internal/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.go、AGENTS.md


12. 设计决策与约定 ​

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

JSON 字段存储:SQLite TEXT 字段存 JSON,利用 json_each()/json_extract() 查询。Go 层在 Create/Update 时 json.Marshal,RowToModel 时 json.Unmarshal。涉及字段:playlists.labels、js_plugins.permissions/public_paths/external_paths、configs.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 源码