#!/usr/bin/env node // scripts/setup-test-db.mjs — 数据库重置脚本:drop + create 空库。 // schema apply 由 Flyway 在 Spring Boot 启动时自动处理(见 docs/04 技术栈 + sql/migrations/V*.sql)。 // seed 数据由测试框架负责(Spring @Sql / Flyway R__seed.sql / data.sql)。 // // 使用场景: // - scripts/test.mjs 开头:清空库,让 Spring 启动时 Flyway 从 V1 开始重放所有 migration // - scripts/test.mjs 结尾:清空库,避免测试遗留污染下次运行 // - 手动调试时:reset 到零状态 // // 跨平台:用纯 JS 解析 .env.local(dotenv 风格,逐行 KEY=VALUE),**绝不** shell-source, // 因此 mac / Windows 原生 node 均可运行,且消除 shell 注入 / 变量展开隐患。 // DROP/CREATE 通过 `mysql` 客户端以 argv 数组方式执行(不经 shell),密码不进命令行解析层。 // // 防护:本脚本只允许在本地 host + 测试库名上执行;非预期目标会被拒绝, // 避免 .env.local 误指向 staging/prod 时触发不可逆 DROP。 import { spawnSync } from 'node:child_process' import { existsSync, readFileSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const ENV_FILE = join(SCRIPT_DIR, '..', '.env.local') // dotenv 风格解析:逐行 KEY=VALUE,跳过空行与 # 注释,去除两侧空白, // 可选地剥离一层成对单/双引号。**不做**变量展开,特殊字符按字面保留。 function parseEnv(text) { const env = {} for (const rawLine of text.split(/\r?\n/)) { const line = rawLine.trim() if (line === '' || line.startsWith('#')) continue const eq = line.indexOf('=') if (eq === -1) continue const key = line.slice(0, eq).trim() if (!key) continue let value = line.slice(eq + 1).trim() if ( value.length >= 2 && ((value.startsWith("'") && value.endsWith("'")) || (value.startsWith('"') && value.endsWith('"'))) ) { value = value.slice(1, -1) } env[key] = value } return env } if (!existsSync(ENV_FILE)) { console.error(`[setup-test-db] .env.local 不存在(${ENV_FILE})`) process.exit(1) } const env = parseEnv(readFileSync(ENV_FILE, 'utf8')) const DB_HOST = env.DB_HOST ?? '' const DB_PORT = env.DB_PORT ?? '3306' const DB_USER = env.DB_USER ?? '' const DB_PASSWORD = env.DB_PASSWORD ?? '' const DB_SCHEMA = env.DB_SCHEMA ?? '' const TEST_DB_ALLOW_REMOTE = env.TEST_DB_ALLOW_REMOTE ?? process.env.TEST_DB_ALLOW_REMOTE ?? '0' const TEST_DB_ALLOW_PROD_NAME = env.TEST_DB_ALLOW_PROD_NAME ?? process.env.TEST_DB_ALLOW_PROD_NAME ?? '0' // 防护 1:默认只允许本地 host(localhost / 127.0.0.1 / ::1)。 // 额外允许的远程 host 在 .env.local 的 TEST_DB_ALLOWED_HOSTS 中(空格或逗号分隔)。 const extraHosts = (env.TEST_DB_ALLOWED_HOSTS ?? '') .split(/[\s,]+/) .filter(Boolean) const allowedHosts = ['localhost', '127.0.0.1', '::1', ...extraHosts] if (!allowedHosts.includes(DB_HOST)) { console.error(`[setup-test-db] 拒绝在非白名单 host (${DB_HOST}) 上执行 DROP DATABASE`) console.error(` 当前白名单:${allowedHosts.join(' ')}`) console.error(' 加入 host:在 .env.local 追加 TEST_DB_ALLOWED_HOSTS=" "') console.error(' 一次性绕过:在 .env.local 设 TEST_DB_ALLOW_REMOTE=1') if (TEST_DB_ALLOW_REMOTE !== '1') process.exit(1) } // 防护 2:schema 名需像测试/开发库(含 test / _dev / _local / _ci),否则要求显式确认。 const schemaLooksLikeTest = /test/.test(DB_SCHEMA) || /_dev$/.test(DB_SCHEMA) || /_local$/.test(DB_SCHEMA) || /_ci$/.test(DB_SCHEMA) if (!schemaLooksLikeTest) { console.error( `[setup-test-db] schema '${DB_SCHEMA}' 不像测试库(期望命名含 test / _dev / _local / _ci)` ) console.error(' 如确为期望行为,请显式声明:在 .env.local 设 TEST_DB_ALLOW_PROD_NAME=1') if (TEST_DB_ALLOW_PROD_NAME !== '1') process.exit(1) } // 防护 3:显式 banner,让人看见自己在 drop 什么;远程 host 额外提示白名单内容。 console.log(`[setup-test-db] 即将 DROP + CREATE \`${DB_SCHEMA}\` on ${DB_HOST}:${DB_PORT}`) if (!['localhost', '127.0.0.1', '::1'].includes(DB_HOST)) { console.log( '[setup-test-db] 目标是 **远程** host(已在 TEST_DB_ALLOWED_HOSTS 白名单中,每次 test.mjs 都会 DROP)' ) console.log(`[setup-test-db] 当前白名单: ${allowedHosts.join(' ')}`) console.log( '[setup-test-db] 若不希望每次自动 DROP,从 .env.local 的 TEST_DB_ALLOWED_HOSTS 删掉此 host' ) } const sql = `DROP DATABASE IF EXISTS \`${DB_SCHEMA}\`; ` + `CREATE DATABASE \`${DB_SCHEMA}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;` // 以 argv 数组调用 mysql(不经 shell):密码不进 shell 解析,跨平台一致。 const mysqlArgs = [ `-h${DB_HOST}`, `-P${DB_PORT}`, `-u${DB_USER}`, `-p${DB_PASSWORD}`, '-e', sql, ] const res = spawnSync('mysql', mysqlArgs, { stdio: 'inherit' }) if (res.error) { console.error(`[setup-test-db] FATAL: 无法执行 mysql(请确认其在 PATH 中): ${res.error.message}`) process.exit(1) } if (res.status !== 0) { console.error(`[setup-test-db] FAIL: mysql exit=${res.status}`) process.exit(res.status === null ? 1 : res.status) } console.log('[setup-test-db] done — schema will be applied by Flyway when Spring Boot starts')