mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 03:52:26 +08:00
fix(db): converge groups.model_allowlist on upgrade
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
This commit is contained in:
+127
@@ -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")
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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")
|
||||
}
|
||||
Reference in New Issue
Block a user