数据库:迁移和「内容的真身在库里」 · znzryb
## 迁移:表结构的变更记录
表结构不是手动在数据库里建的,而是一个个 SQL 文件,放在 `backend/internal/server/db/migrations/`:
```text
00001_create_users.sql
00005_ladder_gamification.sql
...
00085_user_code_templates.sql
20260929160956_stage_kind_blog.sql ← 新的一律用 14 位时间戳命名
20261001143834_blog_read_xp.sql
```
每个文件分两半:`-- +goose Up`(怎么改)和 `-- +goose Down`(怎么撤回)。工具叫 **goose**。
这些文件**被编译进了后端程序**(Go 的 `embed`,见 `db/embed.go`),后端启动时 `db.Migrate` 会把还没跑过的那几个按顺序跑掉。所以部署新版本后端 = 顺便升级了表结构,不用单独去数据库里执行什么。
为什么新文件用时间戳不用顺序号:两个人在两条分支上同时加迁移,各自取了「下一个号」86,合并之后就撞号了。时间戳几乎不会撞。
## 内容的真身在库里,git 里是记录
这是这个项目跟一般网站不太一样的地方。专题、课文、题面、系统帖、比赛这些「内容」:
- **真身在数据库里**。在页面上点保存的那一刻就上线了,没有「部署内容」这一步;
- 每次保存,`content_revisions` 表里会多一条完整的修订记录,带修订号,可以回滚;
- 保存时要带上「我是基于第几版改的」(`expected_revision`)。别人先存了一版,你这次就会收到 `409`,不会悄悄把别人的改动盖掉;
- 仓库里的 `content/` 目录,是用 `tools/content.py export` **从库里导出来的记录**,方便在 git 里看历史。手改那些 JSON 文件,线上不会变。
```text
页面上点保存 / tools/content.py put
↓
content_revisions(每版一条)+ 各张表(stages / chapters / ...)←── 线上读的是这里
↓ tools/content.py export
content/*.json(git 里的记录)
```
代码就不一样了:代码改完要走 PR,合并之后再用 `scripts/deploy.sh` 部署到生产机。
## 博客也是这套
你现在读的这篇博客,在库里就是 `stages` 表里一行 `kind = 'blog'` 的记录,每一节是 `chapters` 表里的一行 —— 和专题用的是同一套表,所以博客也能像专题一样分节、挂选择题。