Files
gongxue-base/migrate.sh
wangziqi 3bcad138a1 fix(ops): 加固部署/代理/迁移脚本,生产环境安全默认
由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复:
- serve-proxy:API_TARGET 生效、SPA 404 语义、流式静态文件、hop-by-hop/超时/断连/穿越防护
- migrate.sh:密码不进 argv/不泄漏 pm2、原子锁防重入、DML-only 事务说明、就绪诊断
- deploy.sh/CI:保护 .env、总是 npm ci(迁移依赖 devDeps)、健康检查、并发锁
- ecosystem/oxlint/.env.example:优雅停机、React 版本对齐、TRUST_PROXY 说明

Reviewed-by: OCR (open-codereview.ai)
2026-08-09 21:29:22 +08:00

163 lines
7.0 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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 触发 SIGPIPEstderr 先收集,超时时输出便于诊断(如密码错误/容器未启动)
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 等 DDLMySQL 会隐式提交,该回滚保证不再成立,需拆分 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'"