scripts-seed-demo-data-template.mjs 11.3 KB
#!/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)
}

// 文件名契约:<NN>__<module_id>.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} 发现非法种子文件名(要求匹配 <NN>__<module_id>.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} 个)`)