#!/usr/bin/env bash # 恭学教育 — 数据迁移脚本(仅首次运行) # 用法: # 1. 确认 MySQL 已启动:docker compose up -d mysql # 2. ./migrate.sh /path/to/dump.sql.gz set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" # 数据库名:旧库(导入源)与新库(应用目标)。 # 注意:migrate-legacy.sql 内部仍固定写入 gongxue(未改 SQL 文件), # 若修改 TARGET_DB 需同步调整该 SQL 文件,保持两者一致。 LEGACY_DB="dorm_billing" TARGET_DB="gongxue" DUMP="${1:-}" if [ -z "$DUMP" ]; then echo "用法: ./migrate.sh /path/to/dump.sql.gz" echo "示例: ./migrate.sh ~/Downloads/dorm_billing_2026-07-06_01-30-02_mysql_data.sql.gz" exit 1 fi # 相对路径转绝对路径:必须在 cd 之前转换,否则 cd 到脚本目录后相对路径会失效 if [[ "$DUMP" != /* ]]; then DUMP="$(pwd)/$DUMP" fi # 校验 dump 文件确实存在,避免 gunzip 报出难以理解的错误 if [ ! -f "$DUMP" ]; then echo "错误: dump 文件不存在: $DUMP" >&2 exit 1 fi # 先切到脚本目录:docker compose exec 与 pm2 startOrReload 都按调用者 cwd 解析, # 不 cd 的话从其他目录调用会找不到 compose 文件 / ecosystem.config.cjs。 cd "$SCRIPT_DIR" # 关键文件存在性预检,避免 set -euo pipefail 下出现难懂的错误 if [ ! -f "$SCRIPT_DIR/migrate-legacy.sql" ]; then echo "错误: 缺少 $SCRIPT_DIR/migrate-legacy.sql" >&2 exit 1 fi if [ ! -f "$SCRIPT_DIR/ecosystem.config.cjs" ]; then echo "错误: 缺少 $SCRIPT_DIR/ecosystem.config.cjs" >&2 exit 1 fi # .migrate.done 仅作为「已完成」提示:脚本成功结束时 touch; # 开头若已存在则提示并退出(真正的防重入由下面的原子锁保证)。 if [ -f "$SCRIPT_DIR/.migrate.done" ]; then echo "检测到 $SCRIPT_DIR/.migrate.done,迁移已完成。如需重新迁移,请先删除该标记文件。" >&2 # “已完成”是正常提示,不是错误,返回 0 exit 0 fi # 防重入:mkdir 原子锁。mkdir 创建目录是原子的,同一路径已存在即失败, # 失败即代表已有实例正在运行。macOS 不自带 flock(1)(flock 是 Linux 专有命令), # 所以不用 flock,改用目录锁;trap EXIT 保证成功/失败(含 ERR/INT/TERM 后的退出)都 rmdir 释放。 if ! mkdir "$SCRIPT_DIR/.migrate.lock" 2>/dev/null; then echo "检测到 $SCRIPT_DIR/.migrate.lock,已有迁移实例在运行。若确认无实例,请手动删除该锁目录。" >&2 exit 1 fi release_lock() { rmdir "$SCRIPT_DIR/.migrate.lock" 2>/dev/null || true } trap release_lock EXIT # 密码只通过 MYSQL_PWD 环境变量传递,绝不拼进命令行(避免出现在 ps aux / 日志) if [ -z "${MYSQL_ROOT_PASSWORD:-}" ]; then echo "错误: 请先设置环境变量 MYSQL_ROOT_PASSWORD(不要使用硬编码兜底密码)" >&2 exit 1 fi # .env 缺失时 sed 会非零退出,在 set -euo pipefail 下会直接终止脚本; # 用 || true 兜底,缺失时回退到默认 ${LEGACY_DB}(现有逻辑保持不变)。 # 用子 shell 限定 MYSQL_PWD 作用域: # - 不拼进命令行(避免出现在 ps aux / cmdline) # - 不全局 export(避免泄漏给 pm2 启动的后端进程) run_mysql() { ( export MYSQL_PWD="$MYSQL_ROOT_PASSWORD" docker compose exec -T -e MYSQL_PWD mysql mysql -u root "$@" ) } APP_STARTED=0 # 失败时确保应用停止,避免留下半启动状态 cleanup_on_error() { if [ "$APP_STARTED" = "1" ]; then echo "检测到失败,停止 gongxue-backend..." >&2 pm2 stop gongxue-backend 2>/dev/null || true fi } trap cleanup_on_error ERR INT TERM echo "=== 1/4 导入旧 dump ===" # 全新 MySQL 可能还没有 ${LEGACY_DB} schema,直接 mysql "${LEGACY_DB}" 会失败; # run_mysql 是连 mysql 不带库名,先确保库存在再导入。 run_mysql -e "CREATE DATABASE IF NOT EXISTS ${LEGACY_DB}; CREATE DATABASE IF NOT EXISTS ${TARGET_DB}" gunzip -c "$DUMP" | run_mysql "$LEGACY_DB" IMPORTED_COUNT="$(run_mysql -N -e "SELECT COUNT(*) FROM ${LEGACY_DB}.students" 2>/dev/null || true)" if [ -z "${IMPORTED_COUNT}" ] || ! [[ "${IMPORTED_COUNT}" =~ ^[0-9]+$ ]]; then echo "警告: 无法读取 ${LEGACY_DB}.students 计数(dump 可能不含该表或查询失败)" >&2 IMPORTED_COUNT="未知" fi echo "旧库导入完成: ${IMPORTED_COUNT}" echo "=== 2/4 启动应用(创建新表结构) ===" # 短暂启动让 TypeORM synchronize 创建表,然后停掉。 # --update-env 确保 DB_SYNCHRONIZE=true 真正传给已在运行的 pm2 进程(startOrReload 默认不刷新环境变量)。 APP_STARTED=1 DB_SYNCHRONIZE=true pm2 startOrReload ecosystem.config.cjs --only gongxue-backend --update-env wait_for_tables() { local tries=40 # migrate-legacy.sql 内部仍固定写入 gongxue(未改 SQL 文件),默认值必须与 TARGET_DB 一致; # 就绪检查只校验 ${TARGET_DB},不回退到 DB_DATABASE。 echo "等待应用创建表结构(${TARGET_DB})..." local i table_count err_file err_file="$SCRIPT_DIR/.migrate.err" : > "$err_file" for i in $(seq 1 "$tries"); do # 读入变量判断,避免 grep -q 触发 SIGPIPE;stderr 先收集,超时时输出便于诊断(如密码错误/容器未启动) table_count="$(run_mysql -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='${TARGET_DB}' AND table_name IN ('users','tenants','operation_logs','students','rooms')" 2>>"$err_file" || true)" if [ "$table_count" = "5" ]; then echo "表结构就绪(${TARGET_DB}: users/tenants/operation_logs/students/rooms)" return 0 fi sleep 2 done echo "错误: 等待应用创建表结构超时(${TARGET_DB})" >&2 if [ -s "$err_file" ]; then echo "--- 期间 MySQL 错误(可能原因:密码错误/容器未启动/连接失败)---" >&2 cat "$err_file" >&2 fi return 1 } wait_for_tables pm2 stop gongxue-backend 2>/dev/null || true APP_STARTED=0 echo "=== 3/4 执行数据迁移 ===" # 包进事务,中途失败时自动回滚(连接断开未提交即回滚)。 # 注意:事务只对 DML 生效;migrate-legacy.sql 目前全部是 INSERT(无 DDL), # 若将来加入 CREATE/ALTER/DROP 等 DDL,MySQL 会隐式提交,该回滚保证不再成立,需拆分 DDL/DML。 { echo 'START TRANSACTION;' cat "$SCRIPT_DIR/migrate-legacy.sql" # 用 printf 保证 COMMIT 前有换行,避免粘到 SQL 最后一行 printf '\nCOMMIT;\n' } | run_mysql # 数据已提交:立即写「已完成」标记,之后重启失败也不应重跑导入(重跑会重复导入/重复 INSERT) touch "$SCRIPT_DIR/.migrate.done" echo "=== 4/4 重启应用 ===" # --update-env 确保 .env / 环境变量变更(如 DB_SYNCHRONIZE=false)对已在运行的进程生效 pm2 startOrReload ecosystem.config.cjs --update-env pm2 save echo "" echo "迁移完成!运行以下验证(密码通过 MYSQL_PWD 传入,不会出现在进程列表):" echo " export MYSQL_PWD=\"\$MYSQL_ROOT_PASSWORD\"; docker compose exec -T -e MYSQL_PWD mysql mysql -u root "${TARGET_DB}" -e 'SELECT COUNT(*) FROM students'"