fix(environment): 迁移不可变规则落地——034 违规补齐改为新迁移 037,修复 browser_env.image_version 残留导致的创建环境 500
douyin-release-gate / verify (push) Failing after 3m10s
douyin-release-gate / verify (push) Failing after 3m10s
- 迁移 037(幂等):补删 browser_env.image_version、audit_event 镜像列、browser_image 表
- 恢复 034 为引入时原版(撤销 6cd4cdc 的原地修改)
- 新增 migrations immutable 单测:迁移文件引入后不得改内容,要改就新增版本号
- web: 指纹种子字段中文化 + 指纹参数 400 错误映射为具体中文提示
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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 拼回。<pkg>/migrations/<name>.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))
|
||||
}
|
||||
@@ -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 等前置数据,此处只验证列已清除
|
||||
}
|
||||
@@ -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")
|
||||
|
||||
@@ -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<any[]>([]);
|
||||
@@ -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() {
|
||||
<Col xs={24} md={12} xl={8}>
|
||||
<Form.Item
|
||||
name="seed"
|
||||
label="Fingerprint Seed"
|
||||
label="指纹种子"
|
||||
required
|
||||
rules={[
|
||||
{ required: true, message: '请输入 seed' },
|
||||
{ type: 'integer', min: 1, max: 2147483647, message: 'Fingerprint Seed 取值为 1 到 2147483647 的整数' },
|
||||
{ required: true, message: '请输入指纹种子' },
|
||||
{ type: 'integer', min: 1, max: 2147483647, message: '指纹种子取值为 1 到 2147483647 的整数' },
|
||||
]}
|
||||
extra="1..2147483647 的整数,派生指纹参数"
|
||||
extra="1..2147483647 的整数,不同种子派生不同浏览器指纹"
|
||||
>
|
||||
<InputNumber min={1} max={2147483647} style={{ width: 180 }} />
|
||||
</Form.Item>
|
||||
|
||||
Reference in New Issue
Block a user