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)
This commit is contained in:
2026-08-09 21:29:22 +08:00
parent 9565a0f23c
commit 3bcad138a1
7 changed files with 476 additions and 57 deletions

View File

@@ -5,33 +5,158 @@
# 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
# 从 .env 读取密码
MYSQL_PASS="${MYSQL_ROOT_PASSWORD:-gongxue_2024}"
# 先切到脚本目录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 ==="
gunzip -c "$DUMP" | docker compose exec -T mysql mysql -u root -p"${MYSQL_PASS}" dorm_billing
echo "旧库导入完成: $(docker compose exec -T mysql mysql -u root -p"${MYSQL_PASS}" -e 'SELECT COUNT(*) AS cnt FROM dorm_billing.students' -N)"
# 全新 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 创建表,然后停掉
DB_SYNCHRONIZE=true pm2 start ecosystem.config.cjs --only gongxue-backend
sleep 8
pm2 stop gongxue-backend
# 短暂启动让 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 执行数据迁移 ==="
docker compose exec -T mysql mysql -u root -p"${MYSQL_PASS}" < migrate-legacy.sql
# 包进事务,中途失败时自动回滚(连接断开未提交即回滚)。
# 注意:事务只对 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 "迁移完成!运行以下验证:"
echo " docker compose exec mysql mysql -u root -p${MYSQL_PASS} gongxue -e 'SELECT COUNT(*) FROM students'"
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'"