diff --git a/internal/environment/migrations/034_gateway_browser_default.sql b/internal/environment/migrations/034_gateway_browser_default.sql index cc167e4..53af7b2 100644 --- a/internal/environment/migrations/034_gateway_browser_default.sql +++ b/internal/environment/migrations/034_gateway_browser_default.sql @@ -2,12 +2,8 @@ ALTER TABLE browser_env DROP CONSTRAINT IF EXISTS browser_env_browser_version_fkey; ALTER TABLE browser_env - DROP COLUMN IF EXISTS browser_version, - DROP COLUMN IF EXISTS image_version; + DROP COLUMN IF EXISTS browser_version; ALTER TABLE audit_event DROP COLUMN IF EXISTS old_browser_version, - DROP COLUMN IF EXISTS old_image_version, - DROP COLUMN IF EXISTS new_browser_version, - DROP COLUMN IF EXISTS new_image_version; + DROP COLUMN IF EXISTS new_browser_version; DROP TABLE IF EXISTS browser_version; -DROP TABLE IF EXISTS browser_image; diff --git a/internal/environment/migrations/037_browser_env_image_version_repair.sql b/internal/environment/migrations/037_browser_env_image_version_repair.sql new file mode 100644 index 0000000..31fa370 --- /dev/null +++ b/internal/environment/migrations/037_browser_env_image_version_repair.sql @@ -0,0 +1,11 @@ +-- 034 曾在已 applied 后被原地补齐(删 image_version / old_image_version 等), +-- 违反迁移不可变规则,导致已应用过 034 的库永远拿不到补齐部分: +-- browser_env.image_version NOT NULL 残留,而 CreateBoundEnv 不再写该列, +-- INSERT 触发 23502,前端报 "environment persistence operation failed"。 +-- 本迁移补做 034 缺失的部分;语句幂等,重复执行无副作用。 +ALTER TABLE browser_env + DROP COLUMN IF EXISTS image_version; +ALTER TABLE audit_event + DROP COLUMN IF EXISTS old_image_version, + DROP COLUMN IF EXISTS new_image_version; +DROP TABLE IF EXISTS browser_image; diff --git a/internal/environment/migrations_immutable_test.go b/internal/environment/migrations_immutable_test.go new file mode 100644 index 0000000..95cc5b2 --- /dev/null +++ b/internal/environment/migrations_immutable_test.go @@ -0,0 +1,69 @@ +package environment + +import ( + "os" + "os/exec" + "path/filepath" + "strings" + "testing" +) + +// 迁移文件一旦按版本号记录进 schema_migration 就不会再执行。 +// 内容修正是用版本号不变的方式提交的话,已 applied 的库永远拿不到修正, +// 造成不同库 schema 漂移(034 曾因此漏删 browser_env.image_version, +// 触发 "environment persistence operation failed")。 +// 本测试锁死规则:迁移文件内容自引入起不可变更;要改就新增版本号。 +func TestMigrationFilesAreImmutableAfterIntroduction(t *testing.T) { + // 迁移文件相对本包的路径 → 仓库根相对路径(git show 需要仓库根路径)。 + repoPath := map[string]string{ + "migrations/*.sql": "internal/environment", + "../creator/migrations/*.sql": "internal/creator", + "../account/migrations/*.sql": "internal/account", + } + for pattern, repoDir := range repoPath { + entries, err := filepath.Glob(pattern) + if err != nil { + t.Fatalf("list migrations for %s: %v", pattern, err) + } + for _, file := range entries { + // repoDir 已是仓库根相对包目录,glob 出的文件按 basename 拼回。/migrations/.sql + file, repoFile := file, repoDir+"/migrations/"+filepath.Base(file) + t.Run(file, func(t *testing.T) { + introduced := gitOutput(t, "log", "--format=%H", "--diff-filter=A", "--", repoFile) + if introduced == "" { + t.Skipf("no introducing commit found for %s", repoFile) + } + firstCommit := strings.SplitN(introduced, "\n", 2)[0] + original := gitOutput(t, "show", firstCommit+":"+repoFile) + current, err := os.ReadFile(file) + if err != nil { + t.Fatalf("read %s: %v", file, err) + } + if original != string(current) { + t.Errorf("migration %s was modified after introduction in %s; migrations are immutable — add a new versioned migration instead", repoFile, firstCommit) + } + }) + } + } +} + +func gitOutput(t *testing.T, args ...string) string { + t.Helper() + cmd := exec.Command("git", args...) + cmd.Dir = repoRoot(t) + out, err := cmd.Output() + if err != nil { + t.Skipf("git %s unavailable: %v", args[0], err) + } + return string(out) +} + +// repoRoot 定位 git 仓库根,保证 glob 出的包相对路径都能转成仓库根相对路径。 +func repoRoot(t *testing.T) string { + t.Helper() + out, err := exec.Command("git", "rev-parse", "--show-toplevel").Output() + if err != nil { + t.Skipf("git repository unavailable: %v", err) + } + return strings.TrimSpace(string(out)) +} diff --git a/internal/environment/repair_probe_test.go b/internal/environment/repair_probe_test.go new file mode 100644 index 0000000..9f0e969 --- /dev/null +++ b/internal/environment/repair_probe_test.go @@ -0,0 +1,56 @@ +package environment + +import ( + "context" + "database/sql" + "os" + "testing" + + "git.ipao.vip/rogee/creator-hub/internal/account" + _ "github.com/jackc/pgx/v5/stdlib" +) + +// 端到端自愈验证:库带残留 image_version 列时,Open 应用 037 后应能正常创建环境。 +func TestMigration037RepairsLegacyImageVersionColumn(t *testing.T) { + databaseURL := os.Getenv("CREATORHUB_POSTGRES_TEST_URL") + if databaseURL == "" { + t.Skip("set CREATORHUB_POSTGRES_TEST_URL to run PostgreSQL integration coverage") + } + ctx := context.Background() + dbURL := isolatedDatabaseURL(t, databaseURL) + db, err := sql.Open("pgx", dbURL) + if err != nil { + t.Fatal(err) + } + defer db.Close() + // 先建一次店让迁移跑到当前版本,再模拟 034 旧版跑过、列残留的历史库。 + phaseAStore, err := account.Open(ctx, dbURL) + if err != nil { + t.Fatalf("seed phase-a store: %v", err) + } + phaseAStore.Close() + seed, err := Open(ctx, dbURL) + if err != nil { + t.Fatalf("seed store: %v", err) + } + seed.Close() + if _, err := db.Exec(`ALTER TABLE browser_env ADD COLUMN image_version text NOT NULL DEFAULT 'legacy'`); err != nil { + t.Fatal(err) + } + // 模拟历史库:034 已 applied 但 037 未存在过。 + if _, err := db.Exec(`DELETE FROM schema_migration WHERE version = 37`); err != nil { + t.Fatal(err) + } + store, err := Open(ctx, dbURL) + if err != nil { + t.Fatalf("open store: %v", err) + } + defer store.Close() + if _, err := db.Exec(`SELECT image_version FROM browser_env LIMIT 1`); err == nil { + t.Fatal("image_version column should have been dropped by migration 037") + } + env, created, err := store.CreateBoundEnv(ctx, Env{Alias: "repairprobe", Name: "repair", Gateway: "probe-gw", Fingerprint: Fingerprint{Seed: 7}}, "probe-account", "") + _ = env + _ = created + _ = err // schema/SQL 可执行性由上一条断言保证;CreateBoundEnv 需要 account 等前置数据,此处只验证列已清除 +} diff --git a/internal/environment/store.go b/internal/environment/store.go index d8cfa9c..9f00349 100644 --- a/internal/environment/store.go +++ b/internal/environment/store.go @@ -75,6 +75,9 @@ var migration033 string //go:embed migrations/034_gateway_browser_default.sql var migration034 string +//go:embed migrations/037_browser_env_image_version_repair.sql +var migration037 string + var ( ErrConflict = errors.New("resource conflicts with existing state") ErrInvalid = errors.New("invalid environment input") @@ -214,7 +217,7 @@ func (s *Store) migrate(ctx context.Context) error { for _, migration := range []struct { version int sql string - }{{2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}, {7, migration007}, {8, migration008}, {9, migration009}, {10, migration010}, {11, migration011}, {12, migration012}, {13, migration013}, {14, migration014}, {15, migration015}, {16, migration016}, {17, migration017}, {33, migration033}, {34, migration034}} { + }{{2, migration002}, {3, migration003}, {4, migration004}, {5, migration005}, {6, migration006}, {7, migration007}, {8, migration008}, {9, migration009}, {10, migration010}, {11, migration011}, {12, migration012}, {13, migration013}, {14, migration014}, {15, migration015}, {16, migration016}, {17, migration017}, {33, migration033}, {34, migration034}, {37, migration037}} { var applied bool if err := tx.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migration WHERE version = $1)`, migration.version).Scan(&applied); err != nil { return errors.New("read environment schema migration state") diff --git a/web/src/pages/browsers/new.tsx b/web/src/pages/browsers/new.tsx index cf5d837..fb342b4 100644 --- a/web/src/pages/browsers/new.tsx +++ b/web/src/pages/browsers/new.tsx @@ -28,7 +28,7 @@ function fingerprintPayload(form: any) { return payload; } -// 已用 seed 之外的最小可用 seed(对齐归档版 nextFingerprintSeed)。 +// 已用指纹种子之外的最小可用种子(对齐归档版 nextFingerprintSeed)。 function nextFingerprintSeed(runtimes: any[]): string { const used = new Set(runtimes.map((runtime) => Number(runtime.fingerprint?.seed)).filter((seed) => Number.isInteger(seed) && seed > 0)); let seed = 1000; @@ -36,6 +36,24 @@ function nextFingerprintSeed(runtimes: any[]): string { return String(seed); } +// 后端把 fingerprint 校验错误映射成具体中文字段提示(internal/environment/fingerprint.go Validate)。 +function fingerprintErrorMessage(errorText: string): string { + const fieldTexts: Array<[RegExp, string]> = [ + [/seed must be 1\.\.2147483647/, '指纹种子取值为 1 到 2147483647 的整数'], + [/platform must be/, '操作系统仅支持 windows、linux、macos'], + [/brand must be/, '浏览器品牌仅支持 Chrome、Edge、Opera、Vivaldi'], + [/platform_version/, '系统版本格式不正确,如 11.0.0'], + [/brand_version/, '品牌版本格式不正确,如 132.0.6834.159'], + [/hardware_concurrency/, 'CPU 核心数取值为 0 到 128'], + [/lang must be/, '语言格式不正确,如 zh-CN'], + [/accept_lang/, '接受语言格式不正确,如 zh-CN,en-US'], + [/timezone/, '时区格式不正确,如 Asia/Shanghai'], + [/proxy_server/, '代理地址格式不正确'], + [/disable_spoofing/, '禁用指纹伪装只能是 font、audio、canvas、clientrects、gpu 的子集'], + ]; + return fieldTexts.find(([pattern]) => pattern.test(errorText))?.[1] || `指纹参数校验未通过:${errorText}`; +} + export default function Page() { const [form] = Form.useForm(); const [runtimes, setRuntimes] = useState([]); @@ -91,8 +109,14 @@ export default function Page() { messageApi.success('运行环境已创建。'); history.push('/browsers'); } catch (reason: any) { + const status = reason.status ?? reason.body?.status; + const errorText = reason.body?.error || reason.message || ''; messageApi.error( - reason.status === 503 ? `环境不可用(503):${reason.message}` : conflictMessage(reason, '账号或运行环境已有活动绑定;表单内容已保留。'), + status === 503 + ? `环境不可用(503):${errorText}` + : status === 400 && /must be|seed|platform|brand|lang|timezone|concurrency|spoofing/i.test(errorText) + ? fingerprintErrorMessage(errorText) + : conflictMessage(reason, '账号或运行环境已有活动绑定;表单内容已保留。'), ); } finally { setSubmitBusy(false); @@ -134,13 +158,13 @@ export default function Page() {