#!/usr/bin/env node // scripts/seed-demo-data.mjs —— 演示假数据(demo seed)的注入脚本。 // // 用途(四个调用方,时序均为:空库重建 → 起后端让 Flyway 建 schema → 本脚本注入): // 1) 前端 e2e(Playwright)globalSetup —— e2e 基线 = 空库 + Flyway schema + 演示种子; // 2) coding.mjs 行为门 step2.3 —— 行为验收前注入演示数据; // 3) 里程碑后人工验收 / 演示 —— 手动跑一次即可复现演示态; // 4) coding.mjs Seed stage —— 模块种子生成后冷起栈真跑验证。 // // 前提:schema 必须已由 Flyway 在 Spring Boot 启动时建好——本脚本绝不建 schema,只灌数据。 // 幂等机制:已应用的种子文件记入账本表 _demo_seed_history(file 为主键),再次运行自动跳过。 // 主键区间约定:1–999=初始数据(admin_init 等)/ 1000–9999=演示种子 / ≥100000=行为门 sentinel。 // // DB 凭据从仓库根 config-vars.yaml 的 database: 段读取;host / user / password 信任该文件,port 仅校验范围。 // 纯 mysql CLI(spawnSync),零 npm 依赖。退出码:0 成功(含全跳过 / 无文件),1 失败。 import { spawnSync } from 'node:child_process' import { existsSync, readFileSync, readdirSync } from 'node:fs' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) const PROJECT_ROOT = join(SCRIPT_DIR, '..') const CONFIG_FILE = join(PROJECT_ROOT, 'config-vars.yaml') const SEED_DIR = join(PROJECT_ROOT, 'sql', 'seed') const LOG = '[seed-demo-data]' // 极简 YAML 读取(2 层 map + 标量;与 scripts/setup-test-db.mjs 同规则,内联以免运行时依赖)。 function parseScalar(raw) { let s = String(raw).trim() if (s === '' || s[0] === '#') return '' const q = s[0] if (q === '"' || q === "'") { const end = s.indexOf(q, 1) if (end !== -1) return s.slice(1, end) } const hash = s.indexOf(' #') if (hash !== -1) s = s.slice(0, hash).trim() return s } function parseYamlConfig(text) { const root = {} let section = null for (const rawLine of text.split('\n')) { const line = rawLine.replace(/\r$/, '') const trimmed = line.trim() if (trimmed === '' || trimmed[0] === '#') continue const colon = line.indexOf(':') if (colon === -1) continue const key = line.slice(0, colon).trim() if (key === '') continue const indent = line.length - line.replace(/^\s+/, '').length const value = parseScalar(line.slice(colon + 1)) if (indent === 0) { if (value === '') { section = {} root[key] = section } else { root[key] = value section = null } } else if (section) { section[key] = value } else { root[key] = value } } return root } // 单引号字符串字面量转义(用于喂给 mysql 的 SQL 值,如 table_schema / file 名)。 function quoteSqlString(value) { return "'" + String(value).replaceAll('\\', '\\\\').replaceAll("'", "''") + "'" } // ────────────────────────────────────────────────────────────────────────── // ① config-vars 校验(占位拒绝 / port 范围 / schema 非空,照抄 setup-test-db 模式) // 所有本地校验前置于任何 mysql 调用,保证离线可测。 // ────────────────────────────────────────────────────────────────────────── if (!existsSync(CONFIG_FILE)) { console.error(`${LOG} config-vars.yaml 不存在(${CONFIG_FILE})`) process.exit(1) } const db = parseYamlConfig(readFileSync(CONFIG_FILE, 'utf8')).database || {} const DB_HOST = db.host ?? '' const DB_PORT = db.port ?? '3306' const DB_USER = db.user ?? '' const DB_PASSWORD = db.password ?? '' const DB_SCHEMA = db.schema ?? '' function rejectPlaceholder(key, value) { if (typeof value === 'string' && value.includes('【人工填写')) { console.error(`${LOG} database.${key} 仍是占位,请先在 config-vars.yaml 填真实值(database.password 可填 '' 表示空密码)`) process.exit(1) } } for (const [key, value] of [['host', DB_HOST], ['port', DB_PORT], ['user', DB_USER], ['password', DB_PASSWORD], ['schema', DB_SCHEMA]]) { rejectPlaceholder(key, value) } if (!/^\d+$/.test(DB_PORT) || Number(DB_PORT) <= 0 || Number(DB_PORT) > 65535) { console.error(`${LOG} database.port 非法: ${DB_PORT}(必须是 1..65535 的整数)`) process.exit(1) } if (String(DB_SCHEMA).trim() === '') { console.error(`${LOG} database.schema 未填`) process.exit(1) } // ────────────────────────────────────────────────────────────────────────── // ② 列 sql/seed/*.sql 升序(确定性显式 .sort());校验文件名。 // ────────────────────────────────────────────────────────────────────────── if (!existsSync(SEED_DIR)) { console.log(`${LOG} 无种子文件(目录不存在: ${SEED_DIR}),无需注入`) process.exit(0) } // 文件名契约:__.sql —— NN 为两位序号、module_id 为 [A-Za-z0-9_]+,中间双下划线分隔。 const SEED_NAME_RE = /^[0-9]{2}__[A-Za-z0-9_]+\.sql$/ const seedFiles = readdirSync(SEED_DIR).filter((name) => name.toLowerCase().endsWith('.sql')).sort() if (seedFiles.length === 0) { console.log(`${LOG} 无种子文件(${SEED_DIR} 为空),无需注入`) process.exit(0) } const illegal = seedFiles.filter((name) => !SEED_NAME_RE.test(name)) if (illegal.length > 0) { console.error(`${LOG} 发现非法种子文件名(要求匹配 __.sql,即 /^[0-9]{2}__[A-Za-z0-9_]+\\.sql$/):`) for (const name of illegal) console.error(`${LOG} - ${name}`) process.exit(1) } // ────────────────────────────────────────────────────────────────────────── // 以下进入 DB 阶段。目标库名作为 mysql 位置参数原样传值。 // ────────────────────────────────────────────────────────────────────────── // 查询型调用:mysql -N -B -e,捕获 stdout(utf8);目标库作为位置参数。 function mysqlQuery(sql) { return spawnSync( 'mysql', [`--host=${DB_HOST}`, `--port=${DB_PORT}`, `--user=${DB_USER}`, `--password=${DB_PASSWORD}`, '-N', '-B', '-e', sql, DB_SCHEMA], { encoding: 'utf8' }, ) } // 应用型调用:把文件内容喂 stdin(--comments 防剥注释);目标库作为位置参数。 function mysqlApply(sqlText) { return spawnSync( 'mysql', [`--host=${DB_HOST}`, `--port=${DB_PORT}`, `--user=${DB_USER}`, `--password=${DB_PASSWORD}`, '--comments', DB_SCHEMA], { input: sqlText, encoding: 'utf8' }, ) } function fatalMysql(res, label) { if (res.error) { console.error(`${LOG} FATAL: 无法执行 mysql(请确认其在 PATH 中): ${res.error.message}`) process.exit(1) } if (res.status !== 0) { if (res.stderr) process.stderr.write(res.stderr) console.error(`${LOG} FAIL (${label}): mysql exit=${res.status}`) process.exit(res.status === null ? 1 : res.status) } } console.log(`${LOG} 目标库 ${DB_SCHEMA} on ${DB_HOST}:${DB_PORT},待处理种子 ${seedFiles.length} 个`) // ────────────────────────────────────────────────────────────────────────── // ③ 查 flyway_schema_history(information_schema)是否存在 —— 不存在说明 schema 未建。 // ────────────────────────────────────────────────────────────────────────── const flywayCheck = mysqlQuery( `SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = ${quoteSqlString(DB_SCHEMA)} AND table_name = 'flyway_schema_history'`, ) fatalMysql(flywayCheck, 'check-flyway') if (flywayCheck.stdout.trim() !== '1') { console.error(`${LOG} schema 未初始化(${DB_SCHEMA} 中找不到 flyway_schema_history)——请先起后端让 Flyway 建 schema,再注入种子`) process.exit(1) } // ────────────────────────────────────────────────────────────────────────── // ④ 账本表 _demo_seed_history(已应用文件账本,幂等核心)。 // ────────────────────────────────────────────────────────────────────────── const createLedger = mysqlApply( 'CREATE TABLE IF NOT EXISTS _demo_seed_history (' + ' file VARCHAR(255) NOT NULL PRIMARY KEY,' + ' applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP' + ') ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;', ) fatalMysql(createLedger, 'create-ledger') // 读出已应用文件集合。 const appliedRes = mysqlQuery('SELECT file FROM _demo_seed_history') fatalMysql(appliedRes, 'read-ledger') const applied = new Set( appliedRes.stdout.split('\n').map((s) => s.trim()).filter((s) => s !== ''), ) // ────────────────────────────────────────────────────────────────────────── // ⑤ 逐文件按文件名升序应用(已应用跳过;失败 exit 1 透传 stderr;成功后写账本)。 // ────────────────────────────────────────────────────────────────────────── let appliedCount = 0 let skippedCount = 0 for (const name of seedFiles) { if (applied.has(name)) { console.log(`${LOG} 跳过(已应用): ${name}`) skippedCount += 1 continue } console.log(`${LOG} 应用: ${name}`) const sqlText = readFileSync(join(SEED_DIR, name), 'utf8') // 账本 INSERT 拼到同一批 SQL 末尾、同一次 mysql 调用执行:mysql 批处理遇错即停, // 账本行只在前面全部种子语句成功后才落——杜绝「已应用未记账 → 重跑重复插入」的半截状态。 const body = sqlText.trimEnd() const sep = /;$/.test(body) ? '\n' : ';\n' const applyRes = mysqlApply(`${body}${sep}INSERT INTO _demo_seed_history (file) VALUES (${quoteSqlString(name)});`) fatalMysql(applyRes, `apply ${name}`) appliedCount += 1 } console.log(`${LOG} done — applied=${appliedCount} skipped=${skippedCount}(共 ${seedFiles.length} 个)`)