阻断数据库不安全降级并校验三套迁移完整性
This commit is contained in:
parent
a2dfd5ef69
commit
8b33282a6e
@ -23,13 +23,9 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error {
|
|||||||
if _, err := db.ExecContext(ctx, migrationTableSQL(driver)); err != nil {
|
if _, err := db.ExecContext(ctx, migrationTableSQL(driver)); err != nil {
|
||||||
return fmt.Errorf("创建迁移表失败:%w", err)
|
return fmt.Errorf("创建迁移表失败:%w", err)
|
||||||
}
|
}
|
||||||
var version int
|
version, err := currentMigrationVersion(ctx, db)
|
||||||
err := db.QueryRowContext(ctx, "SELECT version FROM schema_migrations ORDER BY version DESC LIMIT 1").Scan(&version)
|
if err != nil {
|
||||||
if err != nil && err != sql.ErrNoRows {
|
return err
|
||||||
return fmt.Errorf("读取迁移版本失败:%w", err)
|
|
||||||
}
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
version = 0
|
|
||||||
}
|
}
|
||||||
for nextVersion := version + 1; nextVersion <= latestMigrationVersion; nextVersion++ {
|
for nextVersion := version + 1; nextVersion <= latestMigrationVersion; nextVersion++ {
|
||||||
if err := applyMigration(ctx, db, driver, nextVersion); err != nil {
|
if err := applyMigration(ctx, db, driver, nextVersion); err != nil {
|
||||||
@ -41,6 +37,33 @@ func Migrate(ctx context.Context, db *sql.DB, driver string) error {
|
|||||||
|
|
||||||
const latestMigrationVersion = 9
|
const latestMigrationVersion = 9
|
||||||
|
|
||||||
|
// currentMigrationVersion 在执行业务迁移前拒绝新版数据库及不连续的迁移历史。
|
||||||
|
func currentMigrationVersion(ctx context.Context, db *sql.DB) (int, error) {
|
||||||
|
rows, err := db.QueryContext(ctx, "SELECT version FROM schema_migrations ORDER BY version")
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("读取迁移版本失败:%w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
current := 0
|
||||||
|
for rows.Next() {
|
||||||
|
var version int
|
||||||
|
if err := rows.Scan(&version); err != nil {
|
||||||
|
return 0, fmt.Errorf("读取迁移版本失败:%w", err)
|
||||||
|
}
|
||||||
|
if version > latestMigrationVersion {
|
||||||
|
return 0, fmt.Errorf("数据库版本 %d 高于当前程序支持的版本 %d,拒绝启动;请使用兼容版本程序,不要删除迁移记录", version, latestMigrationVersion)
|
||||||
|
}
|
||||||
|
if version != current+1 {
|
||||||
|
return 0, fmt.Errorf("数据库迁移历史不连续:期望版本 %d,实际版本 %d,拒绝继续迁移", current+1, version)
|
||||||
|
}
|
||||||
|
current = version
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return 0, fmt.Errorf("读取迁移版本失败:%w", err)
|
||||||
|
}
|
||||||
|
return current, nil
|
||||||
|
}
|
||||||
|
|
||||||
func applyMigration(ctx context.Context, db *sql.DB, driver string, version int) error {
|
func applyMigration(ctx context.Context, db *sql.DB, driver string, version int) error {
|
||||||
path := fmt.Sprintf("migrations/%s/%04d_%s.sql", driver, version, migrationName(version))
|
path := fmt.Sprintf("migrations/%s/%04d_%s.sql", driver, version, migrationName(version))
|
||||||
content, err := migrationFiles.ReadFile(path)
|
content, err := migrationFiles.ReadFile(path)
|
||||||
|
|||||||
98
backend/internal/database/migration_catalog_test.go
Normal file
98
backend/internal/database/migration_catalog_test.go
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func checkMigrationCatalog(source, embedded fs.FS) error {
|
||||||
|
for _, driver := range []string{"sqlite", "mysql", "postgres"} {
|
||||||
|
directory := "migrations/" + driver
|
||||||
|
for _, catalog := range []fs.FS{source, embedded} {
|
||||||
|
entries, err := fs.ReadDir(catalog, directory)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(entries) != latestMigrationVersion {
|
||||||
|
return fmt.Errorf("%s 的文件数 %d 与登记版本 %d 不一致", directory, len(entries), latestMigrationVersion)
|
||||||
|
}
|
||||||
|
for version, entry := range entries {
|
||||||
|
expected := fmt.Sprintf("%04d_%s.sql", version+1, migrationName(version+1))
|
||||||
|
if entry.IsDir() || entry.Name() != expected {
|
||||||
|
return fmt.Errorf("%s 的迁移文件应为 %s,实际为 %s", directory, expected, entry.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for version := 1; version <= latestMigrationVersion; version++ {
|
||||||
|
name := path.Join(directory, fmt.Sprintf("%04d_%s.sql", version, migrationName(version)))
|
||||||
|
content, err := fs.ReadFile(source, name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
packed, err := fs.ReadFile(embedded, name)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(bytes.TrimSpace(content)) == 0 || !bytes.Equal(content, packed) {
|
||||||
|
return fmt.Errorf("%s 为空或与嵌入内容不一致", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrationCatalogMatchesSourceAndVersionRegistry(t *testing.T) {
|
||||||
|
if err := checkMigrationCatalog(os.DirFS("."), migrationFiles); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMigrationCatalogDetectsIncompleteChanges(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
mutate func(fstest.MapFS, fstest.MapFS)
|
||||||
|
}{
|
||||||
|
{"unregistered_source", func(source, embedded fstest.MapFS) {
|
||||||
|
source["migrations/mysql/0010_extra.sql"] = &fstest.MapFile{Data: []byte("SELECT 1;")}
|
||||||
|
}},
|
||||||
|
{"missing_embed", func(source, embedded fstest.MapFS) { delete(embedded, "migrations/postgres/0001_initial.sql") }},
|
||||||
|
{"missing_source", func(source, embedded fstest.MapFS) { delete(source, "migrations/sqlite/0001_initial.sql") }},
|
||||||
|
{"changed_embed", func(source, embedded fstest.MapFS) {
|
||||||
|
embedded["migrations/mysql/0001_initial.sql"] = &fstest.MapFile{Data: []byte("SELECT 2;")}
|
||||||
|
}},
|
||||||
|
{"empty_file", func(source, embedded fstest.MapFS) {
|
||||||
|
source["migrations/sqlite/0001_initial.sql"] = &fstest.MapFile{Data: []byte(" \n")}
|
||||||
|
}},
|
||||||
|
{"wrong_name", func(source, embedded fstest.MapFS) {
|
||||||
|
source["migrations/mysql/0001_wrong.sql"] = source["migrations/mysql/0001_initial.sql"]
|
||||||
|
delete(source, "migrations/mysql/0001_initial.sql")
|
||||||
|
}},
|
||||||
|
{"unregistered_embed", func(source, embedded fstest.MapFS) {
|
||||||
|
embedded["migrations/postgres/0010_extra.sql"] = &fstest.MapFile{Data: []byte("SELECT 1;")}
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
source, embedded := fstest.MapFS{}, fstest.MapFS{}
|
||||||
|
for _, driver := range []string{"sqlite", "mysql", "postgres"} {
|
||||||
|
for version := 1; version <= latestMigrationVersion; version++ {
|
||||||
|
name := fmt.Sprintf("migrations/%s/%04d_%s.sql", driver, version, migrationName(version))
|
||||||
|
source[name] = &fstest.MapFile{Data: []byte("SELECT 1;")}
|
||||||
|
embedded[name] = &fstest.MapFile{Data: []byte("SELECT 1;")}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := checkMigrationCatalog(source, embedded); err != nil {
|
||||||
|
t.Fatalf("测试基线无效:%v", err)
|
||||||
|
}
|
||||||
|
tc.mutate(source, embedded)
|
||||||
|
if err := checkMigrationCatalog(source, embedded); err == nil {
|
||||||
|
t.Fatal("未检出不完整的迁移变更")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
100
backend/internal/database/migration_history_test.go
Normal file
100
backend/internal/database/migration_history_test.go
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.pcux.cn/xuans/Scert/backend/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMigrateRejectsInvalidHistoryWithoutChangingDatabase(t *testing.T) {
|
||||||
|
newerHistory := make([]int, latestMigrationVersion+1)
|
||||||
|
for index := range newerHistory {
|
||||||
|
newerHistory[index] = index + 1
|
||||||
|
}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
versions []int
|
||||||
|
message string
|
||||||
|
}{
|
||||||
|
{"newer_database", newerHistory, "高于当前程序支持"},
|
||||||
|
{"missing_first", []int{2}, "迁移历史不连续"},
|
||||||
|
{"missing_middle", []int{1, 3}, "迁移历史不连续"},
|
||||||
|
{"zero", []int{0}, "迁移历史不连续"},
|
||||||
|
{"negative", []int{-1}, "迁移历史不连续"},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
db, err := Open(t.Context(), config.Default(t.TempDir()).Database)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if _, err := db.DB.ExecContext(t.Context(), migrationTableSQL("sqlite")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, version := range tc.versions {
|
||||||
|
if _, err := db.DB.ExecContext(t.Context(), "INSERT INTO schema_migrations (version) VALUES (?)", version); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := db.DB.ExecContext(t.Context(), "CREATE TABLE preserved_data (value TEXT); INSERT INTO preserved_data VALUES ('unchanged')"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
err = Migrate(t.Context(), db.DB, "sqlite")
|
||||||
|
if err == nil || !strings.Contains(err.Error(), tc.message) {
|
||||||
|
t.Fatalf("未拒绝异常迁移历史:%v", err)
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := db.DB.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != len(tc.versions) {
|
||||||
|
t.Fatalf("拒绝迁移后版本记录被修改:%d", count)
|
||||||
|
}
|
||||||
|
if err := db.DB.QueryRow("SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'").Scan(&count); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if count != 2 {
|
||||||
|
t.Fatalf("拒绝迁移后创建了业务表:%d", count)
|
||||||
|
}
|
||||||
|
var value string
|
||||||
|
if err := db.DB.QueryRow("SELECT value FROM preserved_data").Scan(&value); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if value != "unchanged" {
|
||||||
|
t.Fatal("拒绝迁移后原数据被修改")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSQLiteUpgradeFromEverySupportedVersion(t *testing.T) {
|
||||||
|
for version := 0; version <= latestMigrationVersion; version++ {
|
||||||
|
t.Run(fmt.Sprintf("version_%d", version), func(t *testing.T) {
|
||||||
|
db, err := Open(t.Context(), config.Default(t.TempDir()).Database)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if _, err := db.DB.ExecContext(t.Context(), migrationTableSQL("sqlite")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for next := 1; next <= version; next++ {
|
||||||
|
if err := applyMigration(t.Context(), db.DB, "sqlite", next); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for repeat := 0; repeat < 2; repeat++ {
|
||||||
|
if err := Migrate(t.Context(), db.DB, "sqlite"); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
actual, err := currentMigrationVersion(t.Context(), db.DB)
|
||||||
|
if err != nil || actual != latestMigrationVersion {
|
||||||
|
t.Fatalf("迁移历史不完整:%d:%v", actual, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -129,3 +129,12 @@
|
|||||||
- 已将 `desktop/desktop.exe` 和 `desktop/frontend/dist/index.html` 从 Git 索引移除;本地文件仍保留,未物理删除。
|
- 已将 `desktop/desktop.exe` 和 `desktop/frontend/dist/index.html` 从 Git 索引移除;本地文件仍保留,未物理删除。
|
||||||
- `deploy/tests/install-linux-test.py` 保留为可复用测试代码。
|
- `deploy/tests/install-linux-test.py` 保留为可复用测试代码。
|
||||||
- 本项仍需在独立克隆中验证桌面构建入口和发布包内容后,才能标记为 `[✓]`。
|
- 本项仍需在独立克隆中验证桌面构建入口和发布包内容后,才能标记为 `[✓]`。
|
||||||
|
|
||||||
|
### 2026-09-27:P0-04、P0-05、P2-07 版本和迁移保护
|
||||||
|
|
||||||
|
- 纠正工具链误报:`backend` 模块内实际为 Go `1.26.6`,`GOTOOLCHAIN=auto`。没有降低模块版本要求。
|
||||||
|
- 新增《构建版本与数据库支持矩阵》,区分本地代码检查、SQLite 实测和外部数据库实机验收。
|
||||||
|
- 新增迁移目录/嵌入/版本登记一致性检查,以及七类反例测试。
|
||||||
|
- `Migrate` 拒绝新版数据库和不连续的迁移历史,避免旧程序误用更新后的数据库。
|
||||||
|
- 数据库定向测试通过:五类异常历史拒绝后原数据不变;SQLite 从 0~9 版本升级并重复执行通过。
|
||||||
|
- 仅使用测试临时库,没有访问或清空外部业务数据库。三种数据库兼容性不能据此标记全部通过。
|
||||||
|
|||||||
70
doc/构建版本与数据库支持矩阵.md
Normal file
70
doc/构建版本与数据库支持矩阵.md
Normal file
@ -0,0 +1,70 @@
|
|||||||
|
# 构建版本与数据库支持矩阵
|
||||||
|
|
||||||
|
核查日期:2026 年 9 月 27 日。本文区分代码支持、隔离测试和正式生产准入,不把驱动存在视为兼容性验收。
|
||||||
|
|
||||||
|
## 一、工具链与依赖
|
||||||
|
|
||||||
|
| 项目 | 当前固定来源 | 当前核查值 | 发布要求 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Go | `backend/go.mod`、`backend/go.sum` | 模块内 `go1.26.6 windows/amd64` | 在模块内记录实际版本,不使用仓库根目录的版本推断 |
|
||||||
|
| 工具链选择 | `go env GOTOOLCHAIN` | `auto` | 自动选择仍必须与模块要求一致,不得降低版本绕过失败 |
|
||||||
|
| Node.js | 本机 `node --version` | `v24.19.0` | 当前验证基线;后续升级需重跑测试和构建 |
|
||||||
|
| npm | 本机 `npm.cmd --version` | `11.17.0` | 使用锁文件安装;Windows 使用 `npm.cmd` |
|
||||||
|
| Vue | `frontend/package-lock.json` | `3.5.41` | 发布以锁文件为准,不以 package.json 的范围猜测实际版本 |
|
||||||
|
| Vite | 同上 | `6.4.3` | 同上 |
|
||||||
|
| Vue 构建插件 | 同上 | `5.2.4` | 同上 |
|
||||||
|
| SQLite Go 驱动 | `backend/go.mod` | `modernc.org/sqlite v1.37.0` | 驱动版本不等于数据库引擎版本 |
|
||||||
|
| MySQL Go 驱动 | 同上 | `github.com/go-sql-driver/mysql v1.9.3` | 不代表 MySQL 所有服务端版本均通过验收 |
|
||||||
|
| PostgreSQL Go 驱动 | 同上 | `github.com/jackc/pgx/v5 v5.9.2` | 同上 |
|
||||||
|
| 编译目标 | `build/build-release.ps1` | `windows/amd64`、`linux/amd64` | 核心二进制 `CGO_ENABLED=0`、`-trimpath` |
|
||||||
|
|
||||||
|
核查命令:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd backend
|
||||||
|
go version
|
||||||
|
go env GOVERSION GOTOOLCHAIN GOOS GOARCH
|
||||||
|
go mod verify
|
||||||
|
go test -p 1 -count=1 -timeout=3m ./...
|
||||||
|
go vet -p=1 ./...
|
||||||
|
cd ../frontend
|
||||||
|
node --version
|
||||||
|
npm.cmd --version
|
||||||
|
npm.cmd ci
|
||||||
|
npm.cmd test
|
||||||
|
npm.cmd run build
|
||||||
|
```
|
||||||
|
|
||||||
|
本轮未重新执行 `npm.cmd ci`;已执行现有依赖下的前端测试、构建和生产依赖审计。发布前仍需在独立干净环境按锁文件安装,记录操作系统实际版本和发布清单。
|
||||||
|
|
||||||
|
## 二、数据库支持矩阵
|
||||||
|
|
||||||
|
| 数据库 | 预期定位 | 代码现状 | 本轮验证 | 尚未满足的正式验收 |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| SQLite | 单机、开发、轻量部署 | 驱动、初始化、迁移 1~9、CLI 备份恢复 | 临时独立库从版本 0~9 升级、重复迁移、非法历史和新版数据库拒绝 | 并发读写/锁、服务重启、完整业务数据升级恢复 |
|
||||||
|
| MySQL 5.7 | 历史兼容目标,仍保留实现 | 独立方言、迁移 1~9、部分 DDL 重入检查 | 文件/嵌入/登记一致性、已有单元测试 | 指定版本真实实例的初始化、升级、失败重入、备份恢复和业务回归 |
|
||||||
|
| MySQL 8.0 | 正式生产支持目标 | 同上 | 同上 | 同上;不能用 5.7 或 SQLite 测试替代 |
|
||||||
|
| PostgreSQL | 正式生产支持目标,实际服务端版本待本轮复核 | 独立方言、迁移 1~9、严格 TLS 和官方备份恢复工具接入 | 文件/嵌入/登记一致性、凭据/TLS 单元测试 | 当前版本真实集群、初始化、升级、失败回滚、备份恢复和业务回归 |
|
||||||
|
|
||||||
|
正式支持目标不等于已经生产放行。旧文档中的 PostgreSQL 实机记录仅作为线索,不替代当前候选版本复测;本轮没有连接外部 MySQL/PostgreSQL,也没有清空生产数据库。
|
||||||
|
|
||||||
|
## 三、迁移与降级边界
|
||||||
|
|
||||||
|
1. 三种数据库必须提供相同编号和名称的迁移,SQL 内容允许因方言不同而不同。
|
||||||
|
2. `migration_catalog_test.go` 检查源码目录、嵌入资源和 `latestMigrationVersion`;新增文件忘记登记或嵌入时必须失败。
|
||||||
|
3. `Migrate` 在执行后续迁移前读取完整有序版本历史;版本高于程序支持范围或历史不连续时拒绝启动,不自动删除或修补版本记录。
|
||||||
|
4. MySQL 的 DDL 重入和 PostgreSQL 的事务回滚必须用对应引擎验证,不能通过文件数量一致推断行为一致。
|
||||||
|
5. 当前不存在通用向下迁移承诺。降级须使用兼容程序及对应已验证备份,在隔离环境验证后再操作,禁止通过删除 `schema_migrations` 绕过拒绝。
|
||||||
|
|
||||||
|
## 四、真实实例验收步骤
|
||||||
|
|
||||||
|
每个数据库和声明支持的服务端版本独立执行以下步骤,保存版本、候选提交、命令退出码和脱敏证据:
|
||||||
|
|
||||||
|
1. 建立明确归属的隔离空库和最小权限账户,验证 TLS 与主机身份;不使用现有业务库。
|
||||||
|
2. 按正式初始化入口完成配置;创建用户、域名、DNS 账户、服务器和任务,验证权限隔离。
|
||||||
|
3. 从支持的旧版本数据库升级,检查数据、索引、默认值、归属和任务状态;重复执行不产生额外变更。
|
||||||
|
4. 注入迁移中断,在恢复后确认重入/回滚;新版数据库被旧程序拒绝且原数据保持不变。
|
||||||
|
5. 按该数据库的 CLI 备份恢复流程在另一隔离空库恢复;验证敏感配置和证书关联,篡改备份必须拒绝。
|
||||||
|
6. 重启服务验证持久化,完成前后端业务回归;记录未执行、跳过和失败项,不汇总为通过。
|
||||||
|
|
||||||
|
MySQL/PostgreSQL 没有当前实机证据的格子保持待验收。本矩阵不授权清理共享数据库或同机其他服务。
|
||||||
Loading…
Reference in New Issue
Block a user