#!/bin/bash # 花魁短剧正式服:检查 → 构建 → Linux 运行包 + MD5 → 上传 → 切换 app/ → Supervisor 重启 → 健康检查(失败回滚)。 set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) cd "$SCRIPT_DIR" HOST="${DEPLOY_HOST:-root@172.104.166.87}" SSH_PORT="${DEPLOY_SSH_PORT:-22}" REMOTE_DIR="${DEPLOY_DIR:-/web_huakui/h5}" PROGRAM="${DEPLOY_PROGRAM:-web_huakui_h5}" TARGET_ARCH="${DEPLOY_TARGET_ARCH:-x64}" TARGET_LIBC="${DEPLOY_TARGET_LIBC:-glibc}" PACKAGE_NAME=web_huakui_h5.tar.gz MD5_NAME=web_huakui_h5.md5 PACKAGE="$SCRIPT_DIR/$PACKAGE_NAME" MD5_FILE="$SCRIPT_DIR/$MD5_NAME" RELEASE="$SCRIPT_DIR/dist/production-linux-$TARGET_ARCH-$TARGET_LIBC" KEY_FILE=config/secrets/m3u8.key PACKAGE_ONLY=0 DRY_RUN=0 fail() { printf '错误:%s\n' "$*" >&2; exit 1; } step() { printf '\n▸ %s\n' "$*"; } for argument in "$@"; do case "$argument" in --dry-run) DRY_RUN=1 ;; --package-only) PACKAGE_ONLY=1 ;; -h|--help) printf '用法:%s [--dry-run | --package-only]\n' "$0" printf '默认上传 root@172.104.166.87:/web_huakui/h5/,切换 app/ 后重启 Supervisor 程序 web_huakui_h5。\n' printf '可覆盖 DEPLOY_HOST、DEPLOY_SSH_PORT、DEPLOY_DIR、DEPLOY_PROGRAM、DEPLOY_TARGET_ARCH、DEPLOY_TARGET_LIBC。\n' exit 0 ;; *) fail "不支持的参数 ${argument};使用 --help 查看用法" ;; esac done [[ "$HOST" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.@-]*$ ]] || fail 'DEPLOY_HOST 格式无效' [[ "$REMOTE_DIR" =~ ^/[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*/?$ ]] || fail 'DEPLOY_DIR 必须是普通绝对路径' REMOTE_DIR="${REMOTE_DIR%/}" [[ "$PROGRAM" =~ ^[a-zA-Z0-9_-]+$ ]] || fail 'DEPLOY_PROGRAM 格式无效' [[ "$SSH_PORT" =~ ^[0-9]+$ ]] && [ "$SSH_PORT" -ge 1 ] && [ "$SSH_PORT" -le 65535 ] || fail 'SSH 端口必须在 1–65535 内' case "$TARGET_ARCH" in x64|arm64) ;; *) fail 'DEPLOY_TARGET_ARCH 仅支持 x64 或 arm64' ;; esac case "$TARGET_LIBC" in glibc|musl) ;; *) fail 'DEPLOY_TARGET_LIBC 仅支持 glibc 或 musl' ;; esac for tool in node npm tar; do command -v "$tool" >/dev/null || fail "缺少命令 $tool"; done node -e 'const [a,b]=process.versions.node.split(".").map(Number); if(a<20 || a===20 && b<9) process.exit(1)' || fail '需要 Node.js >=20.9,推荐 22' node -e 'const [a,b,c]=process.argv[1].split(".").map(Number); if(a<10 || a===10 && (b<9 || b===9 && c<2)) process.exit(1)' "$(npm --version)" || fail '跨平台运行依赖打包需要 npm >=10.9.2' command -v md5sum >/dev/null || command -v md5 >/dev/null || fail '缺少 md5sum 或 macOS md5' for file in config/production.env package-lock.json scripts/prepare-production-runtime.ts; do [ -s "$file" ] || fail "缺少 $file" done [ -x node_modules/.bin/tsx ] || fail '请先执行 npm ci 安装本地依赖' if [ "$DRY_RUN" = 1 ]; then printf 'DRY RUN:正式环境;Linux %s/%s;只核对,不构建、不连接服务器。\n' "$TARGET_ARCH" "$TARGET_LIBC" node --input-type=module -e 'import {readFileSync} from "node:fs"; import {parse} from "dotenv"; const c=parse(readFileSync("config/production.env")); console.log(` PORT=${c.PORT};UPSTREAM_API_URL=${c.UPSTREAM_API_URL}`)' printf ' npm run check → build → bundle → bundle:linux -- %s %s → %s + %s\n' "$TARGET_ARCH" "$TARGET_LIBC" "$PACKAGE_NAME" "$MD5_NAME" [ -s "$KEY_FILE" ] || printf ' 本地缺少 %s,构建前从服务器当前版本只读复制(忽略提交)。\n' "$KEY_FILE" if [ "$PACKAGE_ONLY" = 0 ]; then printf ' scp → %s:%s/;解压到 app.new,保留服务器密钥与 *.local.env\n' "$HOST" "$REMOTE_DIR" printf ' app → app.prev,app.new → app;supervisorctl restart %s;/healthz 失败则回滚\n' "$PROGRAM" fi exit 0 fi for tool in ssh scp; do command -v "$tool" >/dev/null || fail "缺少命令 $tool"; done SSH=(ssh -p "$SSH_PORT" -o ConnectTimeout=15 "$HOST") # 同仓库只允许一次构建/上传,避免并发覆盖同名包。 mkdir -p "$SCRIPT_DIR/dist" DEPLOY_LOCK="$SCRIPT_DIR/dist/.production-deploy.lock" mkdir "$DEPLOY_LOCK" 2>/dev/null || fail "已有正式发布进程或残留锁:${DEPLOY_LOCK};请先确认该进程已结束" trap 'rmdir "$DEPLOY_LOCK" 2>/dev/null || true' EXIT trap 'exit 130' INT trap 'exit 143' TERM if [ ! -s "$KEY_FILE" ]; then step "本地缺少 ${KEY_FILE},从服务器当前版本复制(目录已忽略提交)" mkdir -p "$(dirname "$KEY_FILE")" scp -P "$SSH_PORT" "$HOST:$REMOTE_DIR/app/$KEY_FILE" "$KEY_FILE" chmod 600 "$KEY_FILE" [ -s "$KEY_FILE" ] || fail "无法获取 $KEY_FILE" fi step '检查类型和单元测试' npm run check step '构建并整理正式环境 standalone' npm run build npm run bundle step "准备 Linux $TARGET_ARCH/$TARGET_LIBC 精简运行包" npm run bundle:linux -- "$TARGET_ARCH" "$TARGET_LIBC" for file in start.mjs server.js .next/BUILD_ID config/production.env "$KEY_FILE" runtime-target.json; do [ -s "$RELEASE/$file" ] || fail "正式运行包缺少 $file" done for directory in node_modules .next/static public; do [ -d "$RELEASE/$directory" ] || fail "正式运行包缺少目录 $directory" done step "打包 $PACKAGE_NAME" # -C 保持产物直接位于 tar 根部;禁止 AppleDouble、环境覆盖和日志混入。 COPYFILE_DISABLE=1 COPY_EXTENDED_ATTRIBUTES_DISABLE=1 tar \ --no-xattrs \ --exclude='._*' --exclude='.DS_Store' --exclude='.env*' \ --exclude='*.local.env' --exclude='*.log' \ -czf "$PACKAGE" -C "$RELEASE" . chmod 600 "$PACKAGE" if command -v md5sum >/dev/null; then md5sum "$PACKAGE" | awk '{print $1}' > "$MD5_FIL E" else md5 -q "$PACKAGE" > "$MD5_FILE" fi tar -tzf "$PACKAGE" >/dev/null node -e 'console.log(`运行包已生成,体积 ${(require("node:fs").statSync(process.argv[1]).size / 1024 / 1024).toFixed(2)} MiB`)' "$PACKAGE" if [ "$PACKAGE_ONLY" = 1 ]; then exit 0; fi step "上传到 $HOST:$REMOTE_DIR/" scp -P "$SSH_PORT" "$PACKAGE" "$MD5_FILE" "$HOST:$REMOTE_DIR/" step "切换版本并重启 $PROGRAM" "${SSH[@]}" "bash -s -- '$REMOTE_DIR' '$PROGRAM' '$PACKAGE_NAME' '$MD5_NAME'" <<'REMOTE' set -euo pipefail DIR=$1 PROGRAM=$2 PACKAGE=$3 MD5=$4 cd "$DIR" [ "$(md5sum "$PACKAGE" | awk '{print $1}')" = "$(tr -d '[:space:]' < "$MD5")" ] || { echo "MD5 校验失败"; exit 1; } rm -rf app.new && mkdir app.new tar -xzf "$PACKAGE" -C app.new [ -s app.new/start.mjs ] && [ -s app.new/.next/BUILD_ID ] || { echo "运行包内容不完整"; exit 1; } # 服务器上的密钥和本地覆盖配置优先于包内文件,且不随发布丢失。 if [ -d app ]; then [ -s app/config/secrets/m3u8.key ] && install -m 600 app/config/secrets/m3u8.key app.new/config/secrets/m3u8.key for file in app/config/*.local.env; do [ -f "$file" ] && cp -p "$file" app.new/config/; done fi PORT=$(sed -n 's/^PORT=//p' app.new/config/production.env | tail -n 1) PORT=${PORT:-26935} rm -rf app.prev [ -d app ] && mv app app.prev mv app.new app supervisorctl restart "$PROGRAM" >/dev/null || true healthy() { for _ in $(seq 1 30); do if curl -fsS -m 4 "http://127.0.0.1:$PORT/healthz" | grep -q '"status":"ok"'; then return 0; fi sleep 2 done return 1 } if healthy && curl -fsS -m 10 -o /dev/null "http://127.0.0.1:$PORT/"; then supervisorctl status "$PROGRAM" echo "DEPLOY_OK BUILD_ID=$(cat app/.next/BUILD_ID)" exit 0 fi echo "新版本健康检查失败,回滚到 app.prev" tail -n 40 "/var/log/$PROGRAM.log" 2>/dev/null || true if [ -d app.prev ]; then rm -rf app.failed && mv app app.failed && mv app.prev app supervisorctl restart "$PROGRAM" >/dev/null || true healthy && echo "已回滚并恢复服务;失败版本保留在 app.failed" || echo "回滚后服务仍未恢复,请立即人工检查" fi exit 1 REMOTE printf '\n正式服发布完成。\n'