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:
@@ -33,3 +33,11 @@ AI_CONFIG_ENCRYPTION_KEY=
|
|||||||
|
|
||||||
# 允许内网地址作为 OPENAI_COMPATIBLE 的 baseUrl(仅内网部署使用)
|
# 允许内网地址作为 OPENAI_COMPATIBLE 的 baseUrl(仅内网部署使用)
|
||||||
# AI_ALLOW_PRIVATE_BASE_URL=true
|
# AI_ALLOW_PRIVATE_BASE_URL=true
|
||||||
|
|
||||||
|
# ---- 安全 ----
|
||||||
|
# 是否信任反向代理的 X-Forwarded-For / X-Real-IP(仅当部署在可信代理/Nginx 后时才设 true;
|
||||||
|
# 不设置时服务端只用 TCP socket 地址,防止伪造客户端 IP)
|
||||||
|
# TRUST_PROXY=true
|
||||||
|
|
||||||
|
# 说明:第三方集成配置(钉钉/企微 appSecret)的静态加密复用 AI_CONFIG_ENCRYPTION_KEY,
|
||||||
|
# 存量明文可用 `cd apps/server && npm run encrypt:integration-secrets` 一次性加密。
|
||||||
|
|||||||
@@ -16,6 +16,10 @@ name: PM2 部署
|
|||||||
on:
|
on:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: deploy
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -37,7 +41,10 @@ jobs:
|
|||||||
- name: 配置 SSH
|
- name: 配置 SSH
|
||||||
run: |
|
run: |
|
||||||
mkdir -p ~/.ssh
|
mkdir -p ~/.ssh
|
||||||
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key
|
# 单引号 EOF 防止 key 中的 $ / 反引号被 shell 插值(YAML 会剥掉缩进,bash 实际收到列首 EOF)
|
||||||
|
cat > ~/.ssh/deploy_key <<'EOF'
|
||||||
|
${{ secrets.SSH_PRIVATE_KEY }}
|
||||||
|
EOF
|
||||||
chmod 600 ~/.ssh/deploy_key
|
chmod 600 ~/.ssh/deploy_key
|
||||||
cat >> ~/.ssh/config <<'EOF'
|
cat >> ~/.ssh/config <<'EOF'
|
||||||
Host deploy-server
|
Host deploy-server
|
||||||
@@ -59,17 +66,20 @@ jobs:
|
|||||||
--exclude='.turbo/' \
|
--exclude='.turbo/' \
|
||||||
--exclude='.claude/' \
|
--exclude='.claude/' \
|
||||||
--exclude='.codegraph/' \
|
--exclude='.codegraph/' \
|
||||||
|
--exclude='.env*' \
|
||||||
|
--exclude='data.sql' \
|
||||||
|
--exclude='uploads/' \
|
||||||
./ deploy-server:${{ secrets.REMOTE_DIR }}/
|
./ deploy-server:${{ secrets.REMOTE_DIR }}/
|
||||||
|
|
||||||
- name: 安装依赖 → 迁移 → PM2 重载
|
- name: 安装依赖 → 迁移 → PM2 重载
|
||||||
run: |
|
run: |
|
||||||
ssh deploy-server "
|
ssh deploy-server "
|
||||||
|
set -e
|
||||||
cd ${{ secrets.REMOTE_DIR }}
|
cd ${{ secrets.REMOTE_DIR }}
|
||||||
mkdir -p logs
|
mkdir -p logs
|
||||||
if [ ! -d node_modules ]; then
|
echo '安装依赖...'
|
||||||
echo '首次部署,安装生产依赖...'
|
# 注意:migration:run 依赖 ts-node/tsconfig-paths(devDependencies),不能 --omit=dev
|
||||||
npm ci --omit=dev
|
npm ci
|
||||||
fi
|
|
||||||
echo '执行数据库迁移...'
|
echo '执行数据库迁移...'
|
||||||
npm run migration:run -w @gongxue/server
|
npm run migration:run -w @gongxue/server
|
||||||
echo 'PM2 重载...'
|
echo 'PM2 重载...'
|
||||||
|
|||||||
92
deploy.sh
92
deploy.sh
@@ -5,40 +5,112 @@
|
|||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
|
|
||||||
SSH_HOST="${1:-tencent}"
|
SSH_HOST="${1:-tencent}"
|
||||||
|
|
||||||
|
# 校验 SSH_HOST:非空、不以 - 开头(防被 ssh/rsync 当作选项)、仅允许安全字符(防命令注入)
|
||||||
|
if [[ -z "${SSH_HOST}" ]]; then
|
||||||
|
echo "错误:SSH_HOST 不能为空" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ "${SSH_HOST}" == -* ]]; then
|
||||||
|
echo "错误:SSH_HOST 不能以 - 开头(会被 ssh/rsync 当作命令行选项)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
if [[ ! "${SSH_HOST}" =~ ^[A-Za-z0-9._:@%+=,-]+$ ]]; then
|
||||||
|
echo "错误:SSH_HOST 含空白或 shell 元字符(仅允许字母、数字及 . _ : @ % + = , -)" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
REMOTE_DIR="/opt/gongxue"
|
REMOTE_DIR="/opt/gongxue"
|
||||||
|
|
||||||
echo "=== 1/4 本地构建后端 ==="
|
# 调用锁:mkdir 原子创建,已存在即代表已有部署在跑,直接退出
|
||||||
|
LOCK_DIR="/tmp/gongxue-deploy.lock"
|
||||||
|
if ! mkdir "$LOCK_DIR" 2>/dev/null; then
|
||||||
|
echo "检测到 $LOCK_DIR,已有部署在运行。若确认没有其他部署,请手动删除该锁目录。" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
release_deploy_lock() {
|
||||||
|
rmdir "$LOCK_DIR" 2>/dev/null || true
|
||||||
|
}
|
||||||
|
trap release_deploy_lock EXIT
|
||||||
|
|
||||||
|
echo "=== 1/5 本地构建后端 ==="
|
||||||
npm run build -w @gongxue/server
|
npm run build -w @gongxue/server
|
||||||
|
|
||||||
echo "=== 2/4 本地构建前端 ==="
|
echo "=== 2/5 本地构建前端 ==="
|
||||||
npm run build -w @gongxue/admin
|
npm run build -w @gongxue/admin
|
||||||
|
|
||||||
echo "=== 3/4 同步到 ${SSH_HOST} ==="
|
echo "=== 3/5 同步到 ${SSH_HOST} ==="
|
||||||
|
# --delete 保留,但必须排除服务器端独有文件(.env、数据、上传目录),防止被清掉
|
||||||
rsync -avz --delete \
|
rsync -avz --delete \
|
||||||
|
-e "ssh -o BatchMode=yes -o ConnectTimeout=10" \
|
||||||
--exclude='node_modules' \
|
--exclude='node_modules' \
|
||||||
--exclude='.git' \
|
--exclude='.git' \
|
||||||
--exclude='*.db' \
|
--exclude='*.db' \
|
||||||
--exclude='.DS_Store' \
|
--exclude='.DS_Store' \
|
||||||
--exclude='logs/' \
|
--exclude='logs/' \
|
||||||
--exclude='.turbo/' \
|
--exclude='.turbo/' \
|
||||||
|
--exclude='.env*' \
|
||||||
|
--exclude='data.sql' \
|
||||||
|
--exclude='uploads/' \
|
||||||
./ "${SSH_HOST}:${REMOTE_DIR}/"
|
./ "${SSH_HOST}:${REMOTE_DIR}/"
|
||||||
|
|
||||||
echo "=== 4/4 安装依赖 → 迁移 → PM2 重载 ==="
|
echo "=== 4/5 安装依赖 → 迁移 → PM2 重载 ==="
|
||||||
ssh "${SSH_HOST}" "
|
ssh -o BatchMode=yes -o ConnectTimeout=10 "${SSH_HOST}" "
|
||||||
|
set -e
|
||||||
cd ${REMOTE_DIR}
|
cd ${REMOTE_DIR}
|
||||||
mkdir -p logs
|
mkdir -p logs
|
||||||
if [ ! -d node_modules ]; then
|
echo '安装依赖...'
|
||||||
echo '首次部署,安装依赖...'
|
# 注意:migration:run 依赖 ts-node/tsconfig-paths(devDependencies),不能 --omit=dev
|
||||||
npm ci --omit=dev
|
npm ci
|
||||||
fi
|
|
||||||
echo '执行数据库迁移...'
|
echo '执行数据库迁移...'
|
||||||
npm run migration:run -w @gongxue/server
|
npm run migration:run -w @gongxue/server
|
||||||
echo 'PM2 重载...'
|
echo 'PM2 重载...'
|
||||||
pm2 startOrReload ecosystem.config.cjs --update-env
|
pm2 startOrReload ecosystem.config.cjs --update-env
|
||||||
pm2 save
|
pm2 save
|
||||||
|
echo '=== PM2 状态 ==='
|
||||||
pm2 status
|
pm2 status
|
||||||
"
|
"
|
||||||
|
|
||||||
|
echo "=== 5/5 健康检查 ==="
|
||||||
|
# 后端未提供 /api/health 等专用健康端点,改用 pm2 jlist(JSON)按 name 查找 gongxue-backend:
|
||||||
|
# 判断 status === 'online' 且 unstable_restarts === 0。
|
||||||
|
# 说明:
|
||||||
|
# - restart_time 是累计重启次数(正常滚动/长期运行也会累计),不适合做健康阈值,改用 unstable_restarts。
|
||||||
|
# - online ≠ healthy:online 只代表 PM2 认为进程存活;若后续后端提供健康端点,应优先 curl 探测。
|
||||||
|
# 带重试以覆盖 PM2 reload 的 listen_timeout(8s)窗口;应用不在线则 exit 1,部署失败。
|
||||||
|
if ! ssh -o BatchMode=yes -o ConnectTimeout=10 "${SSH_HOST}" bash -s <<'REMOTE_HEALTH'
|
||||||
|
set -euo pipefail
|
||||||
|
HEALTHY=0
|
||||||
|
for _i in $(seq 1 15); do
|
||||||
|
if pm2 jlist 2>/dev/null | node -e '
|
||||||
|
let data = "";
|
||||||
|
process.stdin.on("data", (c) => (data += c));
|
||||||
|
process.stdin.on("end", () => {
|
||||||
|
const apps = JSON.parse(data || "[]");
|
||||||
|
const app = apps.find((a) => a && a.name === "gongxue-backend");
|
||||||
|
const env = (app || {}).pm2_env || {};
|
||||||
|
if (env.status === "online" && Number(env.unstable_restarts || 0) === 0) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
|
'; then
|
||||||
|
HEALTHY=1
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 2
|
||||||
|
done
|
||||||
|
if [ "${HEALTHY}" -ne 1 ]; then
|
||||||
|
echo "错误:后端进程未就绪(gongxue-backend 非 online 或存在不稳定重启)" >&2
|
||||||
|
pm2 status >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "健康检查通过:gongxue-backend 在线"
|
||||||
|
REMOTE_HEALTH
|
||||||
|
then
|
||||||
|
echo "错误:健康检查失败,部署中止" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "部署完成!"
|
echo "部署完成!"
|
||||||
echo "访问: http://$(ssh "${SSH_HOST}" 'hostname -I 2>/dev/null | awk "{print \$1}" || curl -s ifconfig.me')"
|
echo "访问: http://$(ssh -o BatchMode=yes -o ConnectTimeout=10 "${SSH_HOST}" 'hostname -I 2>/dev/null | awk "{print \$1}" || curl -s ifconfig.me')"
|
||||||
|
|||||||
@@ -4,7 +4,10 @@
|
|||||||
// 生产环境配置写在项目根目录 .env,由 Nest ConfigModule 读取
|
// 生产环境配置写在项目根目录 .env,由 Nest ConfigModule 读取
|
||||||
// 前端构建产物 apps/admin/dist 交给 Nginx 托管,不再用 PM2 启动前端
|
// 前端构建产物 apps/admin/dist 交给 Nginx 托管,不再用 PM2 启动前端
|
||||||
|
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
const DEPLOY_DIR = process.env.DEPLOY_DIR || __dirname;
|
const DEPLOY_DIR = process.env.DEPLOY_DIR || __dirname;
|
||||||
|
const LOG_DIR = path.join(DEPLOY_DIR, 'logs');
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
apps: [
|
apps: [
|
||||||
@@ -21,9 +24,12 @@ module.exports = {
|
|||||||
max_memory_restart: '512M',
|
max_memory_restart: '512M',
|
||||||
max_restarts: 10,
|
max_restarts: 10,
|
||||||
restart_delay: 5000,
|
restart_delay: 5000,
|
||||||
// 日志
|
// 优雅停机:给 Nest 时间排空请求/连接
|
||||||
error_file: 'logs/backend-error.log',
|
kill_timeout: 5000,
|
||||||
out_file: 'logs/backend-out.log',
|
listen_timeout: 8000,
|
||||||
|
// 日志(绝对路径,与启动目录无关)
|
||||||
|
error_file: path.join(LOG_DIR, 'backend-error.log'),
|
||||||
|
out_file: path.join(LOG_DIR, 'backend-out.log'),
|
||||||
log_date_format: 'YYYY-MM-DD HH:mm:ss',
|
log_date_format: 'YYYY-MM-DD HH:mm:ss',
|
||||||
autorestart: true,
|
autorestart: true,
|
||||||
watch: false,
|
watch: false,
|
||||||
|
|||||||
147
migrate.sh
147
migrate.sh
@@ -5,33 +5,158 @@
|
|||||||
# 2. ./migrate.sh /path/to/dump.sql.gz
|
# 2. ./migrate.sh /path/to/dump.sql.gz
|
||||||
set -euo pipefail
|
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:-}"
|
DUMP="${1:-}"
|
||||||
if [ -z "$DUMP" ]; then
|
if [ -z "$DUMP" ]; then
|
||||||
echo "用法: ./migrate.sh /path/to/dump.sql.gz"
|
echo "用法: ./migrate.sh /path/to/dump.sql.gz"
|
||||||
echo "示例: ./migrate.sh ~/Downloads/dorm_billing_2026-07-06_01-30-02_mysql_data.sql.gz"
|
echo "示例: ./migrate.sh ~/Downloads/dorm_billing_2026-07-06_01-30-02_mysql_data.sql.gz"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
# 相对路径转绝对路径:必须在 cd 之前转换,否则 cd 到脚本目录后相对路径会失效
|
||||||
|
if [[ "$DUMP" != /* ]]; then
|
||||||
|
DUMP="$(pwd)/$DUMP"
|
||||||
|
fi
|
||||||
|
# 校验 dump 文件确实存在,避免 gunzip 报出难以理解的错误
|
||||||
|
if [ ! -f "$DUMP" ]; then
|
||||||
|
echo "错误: dump 文件不存在: $DUMP" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
# 从 .env 读取密码
|
# 先切到脚本目录:docker compose exec 与 pm2 startOrReload 都按调用者 cwd 解析,
|
||||||
MYSQL_PASS="${MYSQL_ROOT_PASSWORD:-gongxue_2024}"
|
# 不 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 ==="
|
echo "=== 1/4 导入旧 dump ==="
|
||||||
gunzip -c "$DUMP" | docker compose exec -T mysql mysql -u root -p"${MYSQL_PASS}" dorm_billing
|
# 全新 MySQL 可能还没有 ${LEGACY_DB} schema,直接 mysql "${LEGACY_DB}" 会失败;
|
||||||
echo "旧库导入完成: $(docker compose exec -T mysql mysql -u root -p"${MYSQL_PASS}" -e 'SELECT COUNT(*) AS cnt FROM dorm_billing.students' -N)"
|
# 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 启动应用(创建新表结构) ==="
|
echo "=== 2/4 启动应用(创建新表结构) ==="
|
||||||
# 短暂启动让 TypeORM synchronize 创建表,然后停掉
|
# 短暂启动让 TypeORM synchronize 创建表,然后停掉。
|
||||||
DB_SYNCHRONIZE=true pm2 start ecosystem.config.cjs --only gongxue-backend
|
# --update-env 确保 DB_SYNCHRONIZE=true 真正传给已在运行的 pm2 进程(startOrReload 默认不刷新环境变量)。
|
||||||
sleep 8
|
APP_STARTED=1
|
||||||
pm2 stop gongxue-backend
|
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 执行数据迁移 ==="
|
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 等 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 重启应用 ==="
|
echo "=== 4/4 重启应用 ==="
|
||||||
|
# --update-env 确保 .env / 环境变量变更(如 DB_SYNCHRONIZE=false)对已在运行的进程生效
|
||||||
pm2 startOrReload ecosystem.config.cjs --update-env
|
pm2 startOrReload ecosystem.config.cjs --update-env
|
||||||
pm2 save
|
pm2 save
|
||||||
|
|
||||||
|
|
||||||
echo ""
|
echo ""
|
||||||
echo "迁移完成!运行以下验证:"
|
echo "迁移完成!运行以下验证(密码通过 MYSQL_PWD 传入,不会出现在进程列表):"
|
||||||
echo " docker compose exec mysql mysql -u root -p${MYSQL_PASS} gongxue -e 'SELECT COUNT(*) FROM students'"
|
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'"
|
||||||
|
|||||||
@@ -3,12 +3,13 @@ import type { OxlintConfig } from 'oxlint';
|
|||||||
const config: OxlintConfig = {
|
const config: OxlintConfig = {
|
||||||
plugins: ['typescript', 'react', 'import'],
|
plugins: ['typescript', 'react', 'import'],
|
||||||
rules: {
|
rules: {
|
||||||
'typescript/no-explicit-any': 'off',
|
'typescript/no-explicit-any': 'warn',
|
||||||
'typescript/no-non-null-assertion': 'warn',
|
'typescript/no-non-null-assertion': 'warn',
|
||||||
},
|
},
|
||||||
settings: {
|
settings: {
|
||||||
react: {
|
react: {
|
||||||
version: '19.0.0',
|
// 与仓库实际安装的 React 版本保持一致(apps/admin: ^19.2.5 → 19.2.7)
|
||||||
|
version: '19.2.7',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
249
serve-proxy.js
249
serve-proxy.js
@@ -1,12 +1,28 @@
|
|||||||
// 轻量静态文件 + API 代理服务器
|
// 轻量静态文件 + API 代理服务器
|
||||||
// PM2 启动: node serve-proxy.js
|
// PM2 启动: node serve-proxy.js
|
||||||
const http = require('http');
|
const http = require('http');
|
||||||
const fs = require('fs');
|
const https = require('https');
|
||||||
|
const fs = require('fs/promises');
|
||||||
|
const fsStream = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
|
||||||
const PORT = process.env.FRONTEND_PORT || 5173;
|
// FRONTEND_PORT 非法(非整数)或 ≤0 时回退 5173,不抛错
|
||||||
const API_TARGET = process.env.API_TARGET || 'http://127.0.0.1:3000';
|
const parsedPort = Number(process.env.FRONTEND_PORT || 5173);
|
||||||
const STATIC_DIR = path.join(__dirname, 'apps/admin/dist');
|
const PORT = Number.isInteger(parsedPort) && parsedPort > 0 ? parsedPort : 5173;
|
||||||
|
// API_TARGET 解析失败或协议不是 http/https 时回退默认值,不抛错(与 FRONTEND_PORT 回退风格一致)
|
||||||
|
let API_TARGET;
|
||||||
|
try {
|
||||||
|
const parsed = new URL(process.env.API_TARGET || 'http://127.0.0.1:3000');
|
||||||
|
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
|
||||||
|
throw new Error(`API_TARGET 协议仅支持 http/https: ${parsed.protocol}`);
|
||||||
|
}
|
||||||
|
API_TARGET = parsed;
|
||||||
|
} catch {
|
||||||
|
API_TARGET = new URL('http://127.0.0.1:3000');
|
||||||
|
}
|
||||||
|
const STATIC_DIR = process.env.FRONTEND_DIR
|
||||||
|
? path.resolve(process.env.FRONTEND_DIR)
|
||||||
|
: path.join(__dirname, 'apps/admin/dist');
|
||||||
|
|
||||||
const MIME = {
|
const MIME = {
|
||||||
'.html': 'text/html; charset=utf-8',
|
'.html': 'text/html; charset=utf-8',
|
||||||
@@ -19,49 +35,230 @@ const MIME = {
|
|||||||
'.woff2': 'font/woff2',
|
'.woff2': 'font/woff2',
|
||||||
};
|
};
|
||||||
|
|
||||||
function serveStatic(res, filePath) {
|
// RFC 7230 hop-by-hop headers — 不能透传给客户端
|
||||||
|
const HOP_BY_HOP = new Set([
|
||||||
|
'connection',
|
||||||
|
'keep-alive',
|
||||||
|
'proxy-authenticate',
|
||||||
|
'proxy-authorization',
|
||||||
|
'te',
|
||||||
|
'trailer',
|
||||||
|
'transfer-encoding',
|
||||||
|
'upgrade',
|
||||||
|
]);
|
||||||
|
|
||||||
|
function stripHopByHop(headers) {
|
||||||
|
const connection = headers.connection;
|
||||||
|
const out = { ...headers };
|
||||||
|
for (const name of HOP_BY_HOP) delete out[name];
|
||||||
|
if (typeof connection === 'string') {
|
||||||
|
for (const name of connection.split(',')) delete out[name.trim().toLowerCase()];
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function serveStatic(res, filePath) {
|
||||||
|
// 客户端断开/写失败兜底:所有分支(成功/404/SPA fallback/500)共用,避免 uncaughtException
|
||||||
|
res.on('error', () => {});
|
||||||
const ext = path.extname(filePath);
|
const ext = path.extname(filePath);
|
||||||
const mime = MIME[ext] || 'application/octet-stream';
|
const mime = MIME[ext] || 'application/octet-stream';
|
||||||
|
let stat;
|
||||||
try {
|
try {
|
||||||
const content = fs.readFileSync(filePath);
|
stat = await fs.stat(filePath);
|
||||||
res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=604800' });
|
|
||||||
res.end(content);
|
|
||||||
} catch {
|
} catch {
|
||||||
// SPA fallback: return index.html
|
// SPA fallback 只用于导航请求(无扩展名或 index.html);缺失的静态资源(.js/.css 等)必须 404
|
||||||
const index = fs.readFileSync(path.join(STATIC_DIR, 'index.html'));
|
const isNavigation = ext === '' || path.basename(filePath) === 'index.html';
|
||||||
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
if (!isNavigation) {
|
||||||
res.end(index);
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end('Not Found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SPA fallback 的 index.html 单文件较小,保留 readFile 全量读取
|
||||||
|
let content;
|
||||||
|
try {
|
||||||
|
content = await fs.readFile(path.join(STATIC_DIR, 'index.html'));
|
||||||
|
} catch {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end('Not Found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// SPA fallback 固定按 HTML 返回,且不能长缓存
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': 'text/html; charset=utf-8',
|
||||||
|
'Cache-Control': 'no-cache',
|
||||||
|
});
|
||||||
|
res.end(content);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
// 目录请求:stat 会成功但 createReadStream 会 EISDIR,必须在写头前拦截
|
||||||
|
if (!stat.isFile()) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end('Not Found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 防 symlink 穿越:stat/createReadStream 会跟随符号链接,realpath 解析后必须仍落在 STATIC_DIR 内
|
||||||
|
try {
|
||||||
|
const [realRoot, realFile] = await Promise.all([
|
||||||
|
fs.realpath(STATIC_DIR),
|
||||||
|
fs.realpath(filePath),
|
||||||
|
]);
|
||||||
|
if (realFile !== realRoot && !realFile.startsWith(realRoot + path.sep)) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end('Not Found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end('Not Found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 静态文件流式化:先 stat 拿大小并写头(含 Content-Length),再 pipe 读流,避免全量缓冲
|
||||||
|
res.writeHead(200, {
|
||||||
|
'Content-Type': mime,
|
||||||
|
'Content-Length': stat.size,
|
||||||
|
'Cache-Control': ext === '.html' ? 'no-cache' : 'public, max-age=604800',
|
||||||
|
});
|
||||||
|
const stream = fsStream.createReadStream(filePath);
|
||||||
|
// 读流中途出错:头未发送时回 404/500,已发送则只能销毁连接
|
||||||
|
stream.on('error', () => {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end('Not Found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.destroy();
|
||||||
|
});
|
||||||
|
// 客户端提前断开/连接关闭:销毁底层读流,避免 fd/socket 泄漏
|
||||||
|
res.on('close', () => stream.destroy());
|
||||||
|
stream.pipe(res);
|
||||||
}
|
}
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
|
// /api 精确匹配(不带尾斜杠):不代理,也不走 SPA fallback。
|
||||||
|
// 若落到 SPA fallback 会返回 HTML 200,误导 API 客户端以为存在资源,直接 404 更明确。
|
||||||
|
if (req.url.split('?')[0] === '/api') {
|
||||||
|
res.writeHead(404, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end('Not Found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// API 代理
|
// API 代理
|
||||||
if (req.url.startsWith('/api/')) {
|
if (req.url.startsWith('/api/')) {
|
||||||
|
const client = API_TARGET.protocol === 'https:' ? https : http;
|
||||||
const opts = {
|
const opts = {
|
||||||
hostname: '127.0.0.1',
|
hostname: API_TARGET.hostname,
|
||||||
port: 3000,
|
port: API_TARGET.port || (API_TARGET.protocol === 'https:' ? 443 : 80),
|
||||||
path: req.url,
|
// 去掉 API_TARGET.pathname 的尾斜杠,避免拼出 /base//api/... 双斜杠;pathname 为 '/' 时得到空串
|
||||||
|
path: (API_TARGET.pathname.replace(/\/+$/, '') || '') + req.url,
|
||||||
method: req.method,
|
method: req.method,
|
||||||
headers: { ...req.headers, host: '127.0.0.1:3000' },
|
headers: {
|
||||||
|
...stripHopByHop(req.headers),
|
||||||
|
host: API_TARGET.host,
|
||||||
|
// 代理是部署网络内的可信边界:补充客户端真实 IP/协议供后端审计与限流使用
|
||||||
|
// 注意:不能赋 undefined(Node setHeader 会抛 ERR_HTTP_INVALID_HEADER_VALUE),为空时干脆不设
|
||||||
|
...(req.socket?.remoteAddress
|
||||||
|
? { 'x-forwarded-for': String(req.socket.remoteAddress).split(',')[0].trim() }
|
||||||
|
: {}),
|
||||||
|
'x-forwarded-proto': 'http',
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const proxy = http.request(opts, (proxyRes) => {
|
let proxy;
|
||||||
res.writeHead(proxyRes.statusCode, proxyRes.headers);
|
let timedOut = false;
|
||||||
proxyRes.pipe(res);
|
try {
|
||||||
});
|
proxy = client.request(opts, (proxyRes) => {
|
||||||
|
// 响应头一到就清除 30s 超时:超时只在「等待响应头」阶段生效,
|
||||||
|
// 避免大文件下载中途被空闲超时截断
|
||||||
|
proxy.setTimeout(0);
|
||||||
|
// 上游中途断开:避免未监听 error 事件导致进程崩溃。
|
||||||
|
// res 可能已被 proxy.on('error') 分支销毁(双触发),先判断避免二次 destroy。
|
||||||
|
proxyRes.on('error', () => {
|
||||||
|
if (res.destroyed) return;
|
||||||
|
res.destroy();
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
// 上游响应头含非法字符(如 ERR_INVALID_CHAR)时 writeHead 会抛错,需兜底
|
||||||
|
res.writeHead(proxyRes.statusCode, stripHopByHop(proxyRes.headers));
|
||||||
|
proxyRes.pipe(res);
|
||||||
|
} catch {
|
||||||
|
// 异常时若头未发送回 502,已发送则销毁连接;同时释放上游 socket
|
||||||
|
proxyRes.unpipe(res);
|
||||||
|
proxy.destroy();
|
||||||
|
proxyRes.destroy();
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end('API unavailable');
|
||||||
|
} else {
|
||||||
|
res.destroy();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end('API unavailable');
|
||||||
|
} else {
|
||||||
|
res.destroy();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// 客户端断开/上游异常时 res 可能抛错,兜底监听防 uncaughtException
|
||||||
|
res.on('error', () => {});
|
||||||
proxy.on('error', () => {
|
proxy.on('error', () => {
|
||||||
res.writeHead(502);
|
// 超时回调已自行回 504 并 destroy,这里直接忽略,避免竞态下重复写响应
|
||||||
res.end('API unavailable');
|
if (timedOut) return;
|
||||||
|
// proxyRes.on('error') 分支可能已销毁 res(双触发),避免对已销毁响应二次写/destroy
|
||||||
|
if (res.destroyed) return;
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end('API unavailable');
|
||||||
|
} else {
|
||||||
|
res.destroy();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
// 客户端断开:销毁上游请求,避免 socket 泄漏。
|
||||||
|
// 注意:Node 中 req 在「请求体正常接收完毕」时也会触发 close(并非只有客户端断开),
|
||||||
|
// 无条件销毁会把仍在等待上游响应的正常请求误杀(上游 ECONNRESET → 502),
|
||||||
|
// 因此仅在连接确实已关闭(socket 已销毁或响应侧已销毁)时才销毁上游。
|
||||||
|
req.on('close', () => {
|
||||||
|
if (req.socket?.destroyed || res.destroyed) proxy.destroy();
|
||||||
|
});
|
||||||
|
req.on('error', () => proxy.destroy());
|
||||||
|
// 响应侧兜底:连接在响应写完前关闭(writableFinished=false)即客户端提前断开,
|
||||||
|
// 这是最可靠的断开信号(覆盖 req close/aborted 场景),此时销毁上游避免 socket 泄漏。
|
||||||
|
res.on('close', () => {
|
||||||
|
if (!res.writableFinished) proxy.destroy();
|
||||||
|
});
|
||||||
|
// 上游 30s 无响应视为超时:先标记并 destroy(不带 error,避免触发 error 处理器二次写响应),再显式回 504
|
||||||
|
proxy.setTimeout(30000, () => {
|
||||||
|
timedOut = true;
|
||||||
|
proxy.destroy();
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(504, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end('API timeout');
|
||||||
|
} else {
|
||||||
|
res.destroy();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
req.pipe(proxy);
|
req.pipe(proxy);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 静态文件
|
// 静态文件(防目录穿越)
|
||||||
const urlPath = req.url === '/' ? '/index.html' : req.url.split('?')[0];
|
const urlPath = req.url === '/' ? '/index.html' : req.url.split('?')[0];
|
||||||
const safePath = path.normalize(urlPath).replace(/^(\.\.(\/|\\|$))+/, '');
|
const filePath = path.normalize(path.join(STATIC_DIR, urlPath));
|
||||||
serveStatic(res, path.join(STATIC_DIR, safePath));
|
if (filePath !== STATIC_DIR && !filePath.startsWith(STATIC_DIR + path.sep)) {
|
||||||
|
res.writeHead(403, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
res.end('Forbidden');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
serveStatic(res, filePath).catch(() => {
|
||||||
|
if (!res.headersSent) {
|
||||||
|
res.writeHead(500, { 'Content-Type': 'text/plain; charset=utf-8' });
|
||||||
|
}
|
||||||
|
res.end('Internal Server Error');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
server.listen(PORT, () => {
|
server.listen(PORT, () => {
|
||||||
process.stdout.write(`Frontend proxy running on http://0.0.0.0:${PORT} → API: ${API_TARGET}\n`);
|
process.stdout.write(`Frontend proxy running on http://0.0.0.0:${PORT} → API: ${API_TARGET.href}\n`);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user