#!/bin/bash # 花魁短剧测试服:检查 → 构建 → dist.zip → 上传 → Supervisor 重启/健康检查。 # DEPLOY_DRY_RUN=1 ./deploy_test.sh 仅核对,不构建、不连接服务器。 set -euo pipefail SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) cd "$SCRIPT_DIR" HOST="${DEPLOY_HOST:-root@172.104.181.99}" SSH_PORT="${DEPLOY_SSH_PORT:-22889}" REMOTE_DIR="${DEPLOY_DIR:-/server/hkdj_net/h5}" CHECK_URL="${DEPLOY_CHECK_URL:-https://hkdjneth5.remoces.com}" PACKAGE="$SCRIPT_DIR/dist.zip" CHECKSUM="$SCRIPT_DIR/dist.zip.sha256" fail() { printf '错误:%s\n' "$*" >&2; exit 1; } step() { printf '\n▸ %s\n' "$*"; } for argument in "$@"; do case "$argument" in --dry-run) DEPLOY_DRY_RUN=1 ;; -h|--help) printf '用法:%s [--dry-run]\n默认目标 root@172.104.181.99:22889 /server/hkdj_net/h5/\n可覆盖 DEPLOY_HOST、DEPLOY_SSH_PORT、DEPLOY_DIR、DEPLOY_CHECK_URL;DEPLOY_DRY_RUN=1 等同 --dry-run。\n' "$0" exit 0 ;; *) fail "不支持的参数 $argument;使用 --help 查看用法" ;; esac done # 这些值会作为远端路径/命令使用,仅允许普通 SSH 目标和绝对路径。 [[ "$HOST" =~ ^[a-zA-Z0-9_][a-zA-Z0-9_.@-]*$ ]] || fail "DEPLOY_HOST 格式无效" [[ "$SSH_PORT" =~ ^[0-9]+$ ]] && [ "$SSH_PORT" -ge 1 ] && [ "$SSH_PORT" -le 65535 ] || fail "DEPLOY_SSH_PORT 无效" [[ "$REMOTE_DIR" =~ ^/[a-zA-Z0-9_-]+(/[a-zA-Z0-9_-]+)*/?$ ]] || fail "DEPLOY_DIR 必须是普通绝对路径" for tool in node npm zip unzip ssh scp; 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,推荐 Node.js 22" node -e 'try { const u=new URL(process.argv[1]); if (!["http:","https:"].includes(u.protocol) || u.username || u.password || u.search || u.hash || u.pathname!=="/") process.exit(1); } catch { process.exit(1); }' "$CHECK_URL" || fail "DEPLOY_CHECK_URL 必须是 HTTP(S) 站点根地址" for file in config/test.env config/secrets/m3u8.key deploy/up_next_h5.sh package-lock.json; do [ -s "$file" ] || fail "缺少 $file" done bash -n deploy/up_next_h5.sh if [ "${DEPLOY_DRY_RUN:-0}" = 1 ]; then printf 'DRY RUN:测试配置 config/test.env;上游与端口可由服务器 config/test.local.env 覆盖。\n' # 不输出本地覆盖/进程配置中的凭证;只展示仓库公共配置。 node --input-type=module -e 'import {readFileSync} from "node:fs"; import {parse} from "dotenv"; const c=parse(readFileSync("config/test.env")); console.log(` API=${c.UPSTREAM_API_URL} PORT=${c.PORT}`)' printf ' npm run check → npm run build:test → npm run bundle:test\n' printf ' dist.zip(不含 node_modules / 本地覆盖)+ SHA-256 → %s:%s/(SSH %s)\n' "$HOST" "$REMOTE_DIR" "$SSH_PORT" printf ' ssh 执行 %s/up_next_h5.sh:安装 Linux 运行依赖 → next/ → Supervisor hkdj_next_h5 → 健康检查\n' "$REMOTE_DIR" printf ' 公网检查 %s:/healthz(test)+ 首页(Next HTML);此模式不访问域名。\n' "$CHECK_URL" exit 0 fi step '检查类型和单元测试' npm run check step '构建并整理测试环境 standalone' npm run build:test npm run bundle:test for file in start.mjs server.js .next-test/BUILD_ID config/test.env config/secrets/m3u8.key; do [ -s "dist/test/$file" ] || fail "测试包缺少 $file" done [ -d dist/test/.next-test/static ] && [ -d dist/test/public ] || fail '测试包缺少静态资源' # Next 本机的原生模块不能直接用于 Linux:远端用 lockfile 安装运行依赖。 # dotenv 属于 dependencies,start.mjs 在 npm ci --omit=dev 后仍可加载配置。 cp package.json package-lock.json dist/test/ step '生成 dist.zip 和 SHA-256(保留本地包便于排查)' rm -f "$PACKAGE" "$CHECKSUM" ( cd dist/test COPYFILE_DISABLE=1 zip -q -r "$PACKAGE" . \ -x 'node_modules/*' '*/node_modules/*' '*.local.env' '.env*' '*/.env*' '._*' '*/._*' '*.log' ) chmod 600 "$PACKAGE" if command -v sha256sum >/dev/null; then sha256sum dist.zip > "$CHECKSUM" else shasum -a 256 dist.zip > "$CHECKSUM" fi unzip -tq "$PACKAGE" step "上传到 $HOST:$REMOTE_DIR/" ssh -p "$SSH_PORT" "$HOST" "mkdir -p '$REMOTE_DIR'" scp -P "$SSH_PORT" "$PACKAGE" "$CHECKSUM" deploy/up_next_h5.sh "$HOST:$REMOTE_DIR/" step '执行服务器更新脚本' ssh -p "$SSH_PORT" "$HOST" "/bin/bash '$REMOTE_DIR/up_next_h5.sh'" step "检查公网测试站 $CHECK_URL" if ! node --input-type=module - "$CHECK_URL" <<'NODE' const origin = process.argv[2]; async function read(path) { const response = await fetch(new URL(path, origin), { signal: AbortSignal.timeout(4000), redirect: 'error', cache: 'no-store' }); if (response.status !== 200) throw new Error(`${path} HTTP ${response.status}`); let text = '', size = 0; const decoder = new TextDecoder(); for await (const part of response.body) { size += part.length; if (size > 262144) throw new Error(`${path} 响应超过检查上限`); text += decoder.decode(part, { stream: true }); if (path === '/' && text.includes('/_next/')) break; } return { text, type: response.headers.get('content-type') || '' }; } for (let attempt = 1; attempt <= 3; attempt++) { try { const [health, home] = await Promise.all([read('/healthz'), read('/')]); let status; try { status = JSON.parse(health.text); } catch { throw new Error('/healthz 返回无效 JSON'); } if (status?.status !== 'ok' || status?.environment !== 'test') throw new Error('/healthz 未命中 Next 测试服务'); if (!/^text\/html(?:;|$)/i.test(home.type) || !home.text.includes('/_next/')) throw new Error('首页不是 Next HTML'); console.log('公网 /healthz 与 Next 首页检查通过'); process.exit(0); } catch (error) { console.error(`公网检查 ${attempt}/3 失败:${error.message}`); if (attempt < 3) await new Promise(resolve => setTimeout(resolve, 1000)); } } process.exit(1); NODE then fail '服务器服务已更新,但域名检查失败,请检查 Nginx 和 Cloudflare 回源配置。' fi printf '\n测试服更新完成:%s:%s/next(Supervisor:hkdj_next_h5)\n' "$HOST" "$REMOTE_DIR"