From 4a7739acdf4ff12a1683c74a9e99163d15f7d11c Mon Sep 17 00:00:00 2001 From: shaw Date: Tue, 8 Sep 2026 09:15:23 +0800 Subject: [PATCH] fix(db): converge groups.model_allowlist on upgrade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 235 只在「models_list_config 存在且 model_allowlist 不存在」时重命名,而迁移一旦 写入 schema_migrations 就会按「文件名 + checksum」整份跳过,不再重跑。因此只要数据库 在 235 之后回到旧结构(为回滚旧版本镜像手工改回列名、或按旧结构做了部分恢复), 应用依旧能正常启动,但每个关联 groups 的查询都会失败: pq: column groups.model_allowlist does not exist 表现为「API 密钥」「我的订阅」「管理端订阅列表」等页面加载失败。 新增可重放的收敛迁移 236,升级即自愈:只有旧列则重命名(数据原样保留);两列并存 则在新列仍为默认空值时回填旧列配置;两列都没有则按默认值补建。收尾统一把 model_allowlist 归一成 NOT NULL DEFAULT '{}',与 ent schema 对齐。 判定列是否存在改用 attrelid = 'groups'::regclass(跟随 search_path), 不再写死 table_schema = 'public',避免与 ALTER TABLE 的解析路径分叉。 Fixes #6780 --- ...wlist_repair_migration_integration_test.go | 127 ++++++++++++++++++ .../236_group_model_allowlist_repair.sql | 52 +++++++ ...p_model_allowlist_repair_migration_test.go | 27 ++++ 3 files changed, 206 insertions(+) create mode 100644 backend/internal/repository/group_model_allowlist_repair_migration_integration_test.go create mode 100644 backend/migrations/236_group_model_allowlist_repair.sql create mode 100644 backend/migrations/group_model_allowlist_repair_migration_test.go diff --git a/backend/internal/repository/group_model_allowlist_repair_migration_integration_test.go b/backend/internal/repository/group_model_allowlist_repair_migration_integration_test.go new file mode 100644 index 0000000000..db66796771 --- /dev/null +++ b/backend/internal/repository/group_model_allowlist_repair_migration_integration_test.go @@ -0,0 +1,127 @@ +//go:build integration + +package repository + +import ( + "context" + "database/sql" + "testing" + + dbmigrations "github.com/Wei-Shaw/sub2api/migrations" + "github.com/stretchr/testify/require" +) + +const groupModelAllowlistRepairMigration = "236_group_model_allowlist_repair.sql" + +// 236 是可重放的修复迁移:235 的重命名一旦被记账就不会重跑,数据库若回到旧结构 +// (手工改回列名、按旧结构部分恢复)应用仍能启动,但所有关联 groups 的查询都会 +// 报 column groups.model_allowlist does not exist(issue #6780)。 +func TestMigration236RenamesLegacyModelsListConfigColumn(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + + _, err := tx.ExecContext(ctx, "ALTER TABLE groups RENAME COLUMN model_allowlist TO models_list_config") + require.NoError(t, err) + + var groupID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO groups (name, platform, rate_multiplier, status, models_list_config) +VALUES ('migration-236-rename', 'anthropic', 1, 'active', '{"enabled":true,"models":["claude-sonnet-5"]}'::jsonb) +RETURNING id +`).Scan(&groupID)) + + applyGroupModelAllowlistRepair(ctx, t, tx) + + // 重命名保留原数据,且新列恢复 NOT NULL DEFAULT '{}' 的形状。 + var allowlist string + require.NoError(t, tx.QueryRowContext(ctx, + "SELECT model_allowlist::text FROM groups WHERE id = $1", groupID).Scan(&allowlist)) + require.JSONEq(t, `{"enabled":true,"models":["claude-sonnet-5"]}`, allowlist) + requireModelAllowlistColumnShape(ctx, t, tx) + + // 可重放:重复执行不报错也不改变结果。 + applyGroupModelAllowlistRepair(ctx, t, tx) + require.NoError(t, tx.QueryRowContext(ctx, + "SELECT model_allowlist::text FROM groups WHERE id = $1", groupID).Scan(&allowlist)) + require.JSONEq(t, `{"enabled":true,"models":["claude-sonnet-5"]}`, allowlist) +} + +func TestMigration236BackfillsWhenBothColumnsExist(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + + _, err := tx.ExecContext(ctx, + "ALTER TABLE groups ADD COLUMN models_list_config JSONB NOT NULL DEFAULT '{}'::jsonb") + require.NoError(t, err) + + // 新列仍是默认空值:旧列里的配置应该被补回来。 + var staleID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO groups (name, platform, rate_multiplier, status, model_allowlist, models_list_config) +VALUES ('migration-236-backfill', 'anthropic', 1, 'active', '{}'::jsonb, '{"enabled":true,"models":["legacy-model"]}'::jsonb) +RETURNING id +`).Scan(&staleID)) + + // 新列已有配置:不能被旧列覆盖。 + var currentID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO groups (name, platform, rate_multiplier, status, model_allowlist, models_list_config) +VALUES ('migration-236-keep', 'anthropic', 1, 'active', '{"enabled":true,"models":["current-model"]}'::jsonb, '{"enabled":true,"models":["legacy-model"]}'::jsonb) +RETURNING id +`).Scan(¤tID)) + + applyGroupModelAllowlistRepair(ctx, t, tx) + + var backfilled, kept string + require.NoError(t, tx.QueryRowContext(ctx, + "SELECT model_allowlist::text FROM groups WHERE id = $1", staleID).Scan(&backfilled)) + require.JSONEq(t, `{"enabled":true,"models":["legacy-model"]}`, backfilled) + require.NoError(t, tx.QueryRowContext(ctx, + "SELECT model_allowlist::text FROM groups WHERE id = $1", currentID).Scan(&kept)) + require.JSONEq(t, `{"enabled":true,"models":["current-model"]}`, kept) +} + +func TestMigration236RecreatesMissingModelAllowlistColumn(t *testing.T) { + tx := testTx(t) + ctx := context.Background() + + _, err := tx.ExecContext(ctx, "ALTER TABLE groups DROP COLUMN model_allowlist") + require.NoError(t, err) + + var groupID int64 + require.NoError(t, tx.QueryRowContext(ctx, ` +INSERT INTO groups (name, platform, rate_multiplier, status) +VALUES ('migration-236-recreate', 'anthropic', 1, 'active') +RETURNING id +`).Scan(&groupID)) + + applyGroupModelAllowlistRepair(ctx, t, tx) + + var allowlist string + require.NoError(t, tx.QueryRowContext(ctx, + "SELECT model_allowlist::text FROM groups WHERE id = $1", groupID).Scan(&allowlist)) + require.JSONEq(t, `{}`, allowlist) + requireModelAllowlistColumnShape(ctx, t, tx) +} + +func applyGroupModelAllowlistRepair(ctx context.Context, t *testing.T, tx *sql.Tx) { + t.Helper() + + migrationSQL, err := dbmigrations.FS.ReadFile(groupModelAllowlistRepairMigration) + require.NoError(t, err) + _, err = tx.ExecContext(ctx, string(migrationSQL)) + require.NoError(t, err) +} + +func requireModelAllowlistColumnShape(ctx context.Context, t *testing.T, tx *sql.Tx) { + t.Helper() + + var isNullable, columnDefault string + require.NoError(t, tx.QueryRowContext(ctx, ` +SELECT is_nullable, COALESCE(column_default, '') +FROM information_schema.columns +WHERE table_name = 'groups' AND column_name = 'model_allowlist' +`).Scan(&isNullable, &columnDefault)) + require.Equal(t, "NO", isNullable) + require.Contains(t, columnDefault, "'{}'::jsonb") +} diff --git a/backend/migrations/236_group_model_allowlist_repair.sql b/backend/migrations/236_group_model_allowlist_repair.sql new file mode 100644 index 0000000000..0110225a06 --- /dev/null +++ b/backend/migrations/236_group_model_allowlist_repair.sql @@ -0,0 +1,52 @@ +-- 236: 收敛 groups 的模型白名单列,修复 235 未落地却已被记账的实例。 +-- +-- 235 只在「models_list_config 存在且 model_allowlist 不存在」时执行重命名, +-- 而迁移一旦写入 schema_migrations 就会按「文件名 + checksum」整份跳过,不再重跑。 +-- 因此只要数据库在 235 之后回到了旧结构——例如为了回滚到旧版本镜像而手工把列名 +-- 改回 models_list_config,或者用旧结构的备份做了部分恢复——应用依旧能正常启动, +-- 但每个关联 groups 的查询都会失败: +-- pq: column groups.model_allowlist does not exist +-- 表现为「API 密钥」「我的订阅」「管理端订阅列表」等页面加载失败(issue #6780)。 +-- +-- 本迁移可重放,并且用 regclass 解析表(跟随 search_path),不再假定 schema 为 public: +-- 1) 只有旧列 -> 重命名,数据原样保留; +-- 2) 两列并存 -> 新列仍是默认空值时,把旧列里的配置回填过来,旧列保留不动; +-- 3) 两列都没有 -> 按默认值补建新列。 +-- 结束后保证 groups.model_allowlist 一定存在,且为 NOT NULL DEFAULT '{}'。 +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 FROM pg_attribute + WHERE attrelid = 'groups'::regclass + AND attname = 'models_list_config' + AND NOT attisdropped + ) THEN + IF NOT EXISTS ( + SELECT 1 FROM pg_attribute + WHERE attrelid = 'groups'::regclass + AND attname = 'model_allowlist' + AND NOT attisdropped + ) THEN + ALTER TABLE groups RENAME COLUMN models_list_config TO model_allowlist; + ELSE + EXECUTE $backfill$ + UPDATE groups + SET model_allowlist = models_list_config + WHERE COALESCE(model_allowlist, '{}'::jsonb) = '{}'::jsonb + AND COALESCE(models_list_config, '{}'::jsonb) <> '{}'::jsonb + $backfill$; + END IF; + END IF; +END +$$; + +ALTER TABLE groups + ADD COLUMN IF NOT EXISTS model_allowlist JSONB NOT NULL DEFAULT '{}'::jsonb; + +UPDATE groups SET model_allowlist = '{}'::jsonb WHERE model_allowlist IS NULL; + +ALTER TABLE groups ALTER COLUMN model_allowlist SET DEFAULT '{}'::jsonb; +ALTER TABLE groups ALTER COLUMN model_allowlist SET NOT NULL; + +COMMENT ON COLUMN groups.model_allowlist IS + 'Group model allowlist: constrains both model listing responses and request admission'; diff --git a/backend/migrations/group_model_allowlist_repair_migration_test.go b/backend/migrations/group_model_allowlist_repair_migration_test.go new file mode 100644 index 0000000000..1486243445 --- /dev/null +++ b/backend/migrations/group_model_allowlist_repair_migration_test.go @@ -0,0 +1,27 @@ +package migrations + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestGroupModelAllowlistRepairMigration(t *testing.T) { + content, err := FS.ReadFile("236_group_model_allowlist_repair.sql") + require.NoError(t, err) + + sql := strings.Join(strings.Fields(string(content)), " ") + + // 三种残留状态都要收敛到 model_allowlist。 + require.Contains(t, sql, "ALTER TABLE groups RENAME COLUMN models_list_config TO model_allowlist") + require.Contains(t, sql, "ADD COLUMN IF NOT EXISTS model_allowlist JSONB NOT NULL DEFAULT '{}'::jsonb") + require.Contains(t, sql, "SET model_allowlist = models_list_config") + require.Contains(t, sql, "ALTER TABLE groups ALTER COLUMN model_allowlist SET NOT NULL") + require.Contains(t, sql, "COMMENT ON COLUMN groups.model_allowlist") + + // 235 用 table_schema = 'public' 判定列是否存在,而 ALTER TABLE 走的是 search_path; + // 修复迁移必须用 regclass 解析,两者才不会在非 public schema 上分叉。 + require.NotContains(t, sql, "table_schema = 'public'") + require.Contains(t, sql, "attrelid = 'groups'::regclass") +}