Initial commit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
谢宇宁
2026-10-26 17:51:31 +07:00
co-authored by Claude Opus 5.5
commit 61d0a11dfa
371 changed files with 35836 additions and 0 deletions
+186
View File
@@ -0,0 +1,186 @@
#!/usr/bin/env bash
# 测试服:dist.zip 为 dist/test 内容(不含 node_modules),安装到 next/,Supervisor:hjsq_next_h5。
set -Eeuo pipefail
DIR=$(cd "$(dirname "$0")" && pwd)
PACKAGE="$DIR/dist.zip"
CHECKSUM="$DIR/dist.zip.sha256"
CURRENT="$DIR/next"
PREVIOUS="$DIR/next.prev"
PROGRAM=hjsq_next_h5
SUP_CONF_DIR=${SUP_CONF_DIR:-/etc/supervisor/conf.d}
SUP_LOG_DIR=${SUP_LOG_DIR:-/var/log/supervisor}
CONF="$SUP_CONF_DIR/$PROGRAM.conf"
LOCK="$DIR/.$PROGRAM.lock"
STAMP=$(date +%Y%m%d%H%M%S)-$$
STAGING= UPLOAD_COPY= HEALTH_FILE= CONF_BACKUP= NEW_CONF=
LOCKED=0 SWITCHED=0 OLD_MOVED=0 INSTALLED=0 HAD_CONF=0 CONF_CHANGED=0 WAS_RUNNING=0
fail() { printf '发布失败:%s\n' "$*" >&2; exit 1; }
rollback() {
echo '检查失败,恢复上一版。' >&2
supervisorctl stop "$PROGRAM" >/dev/null 2>&1 || true
if [ "$INSTALLED" = 1 ] && [ -d "$CURRENT" ]; then
mv "$CURRENT" "$DIR/.${PROGRAM}.failed.$STAMP" || return 1
fi
if [ "$OLD_MOVED" = 1 ]; then mv "$PREVIOUS" "$CURRENT" || return 1; fi
if [ "$CONF_CHANGED" = 1 ]; then
if [ "$HAD_CONF" = 1 ]; then cp -p "$CONF_BACKUP" "$CONF" || return 1
else rm -f "$CONF" || return 1; fi
fi
supervisorctl reread >/dev/null && supervisorctl update >/dev/null || return 1
if [ "$WAS_RUNNING" = 1 ]; then
supervisorctl restart "$PROGRAM" >/dev/null || return 1
else supervisorctl stop "$PROGRAM" >/dev/null 2>&1 || true; fi
}
finish() {
local rc=$?
trap - EXIT
if [ "$rc" -ne 0 ] && [ "$SWITCHED" = 1 ]; then rollback || true; fi
[ -z "$STAGING" ] || rm -rf "$STAGING"
[ -z "$UPLOAD_COPY" ] || rm -rf "$UPLOAD_COPY"
[ -z "$HEALTH_FILE" ] || rm -f "$HEALTH_FILE"
[ -z "$NEW_CONF" ] || rm -f "$NEW_CONF"
[ "$LOCKED" = 0 ] || rmdir "$LOCK" 2>/dev/null || true
exit "$rc"
}
trap finish EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
for command_name in sha256sum unzip supervisorctl curl mktemp; do
command -v "$command_name" >/dev/null || fail "缺少 $command_name"
done
mkdir "$LOCK" 2>/dev/null || fail "另一次发布正在执行($LOCK)"
LOCKED=1
[ -f "$PACKAGE" ] && [ -f "$CHECKSUM" ] || fail '缺少 dist.zip 或 dist.zip.sha256'
UPLOAD_COPY=$(mktemp -d "$DIR/.${PROGRAM}.upload.XXXXXX")
cp "$PACKAGE" "$UPLOAD_COPY/dist.zip"
cp "$CHECKSUM" "$UPLOAD_COPY/dist.zip.sha256"
EXPECTED=$(awk 'NR == 1 { print tolower($1) }' "$UPLOAD_COPY/dist.zip.sha256")
[[ "$EXPECTED" =~ ^[[:xdigit:]]{64}$ ]] || fail 'SHA-256 校验文件格式无效'
ACTUAL=$(sha256sum "$UPLOAD_COPY/dist.zip" | awk '{print $1}')
[ "$EXPECTED" = "$ACTUAL" ] || fail 'dist.zip SHA-256 不匹配'
node_ok() {
"$1" -e 'const [a,b]=process.versions.node.split(".").map(Number); process.exit(a>20 || a===20 && b>=9 ? 0 : 1)' 2>/dev/null
}
candidates=("${NODE_BIN:-}" /usr/local/bin/node /usr/bin/node /opt/node/bin/node)
for candidate in /usr/local/node*/bin/node /opt/node*/bin/node \
"${HOME:-/root}"/.nvm/versions/node/v*/bin/node; do
[ ! -x "$candidate" ] || candidates+=("$candidate")
done
candidates+=("$(command -v node || true)")
NODE_BIN=
for candidate in "${candidates[@]}"; do
if [ -x "$candidate" ] && node_ok "$candidate"; then NODE_BIN=$candidate; break; fi
done
[ -n "$NODE_BIN" ] || fail '需要 Node.js >=20.9'
export PATH="$(dirname "$NODE_BIN"):$PATH"
PNPM_BIN=$(command -v pnpm || true)
NPM_BIN=$(command -v npm || true)
[ -n "$PNPM_BIN" ] || [ -n "$NPM_BIN" ] || fail '需要 pnpm 或 npm'
supervisorctl pid >/dev/null || fail 'Supervisor 未运行'
STATUS=$(supervisorctl status "$PROGRAM" 2>/dev/null || true)
[[ "$STATUS" != *RUNNING* ]] || WAS_RUNNING=1
STAGING=$(mktemp -d "$DIR/.${PROGRAM}.staging.XXXXXX")
unzip -Z1 "$UPLOAD_COPY/dist.zip" | while IFS= read -r entry; do
case "$entry" in
/*|../*|*/../*|*/..|node_modules|node_modules/*|*/node_modules/*)
echo "包内包含不允许的路径:$entry" >&2; exit 1 ;;
esac
done || fail 'ZIP 路径检查失败'
unzip -q "$UPLOAD_COPY/dist.zip" -d "$STAGING"
for required in start.mjs server.js package.json .next/BUILD_ID config/test.env; do
[ -s "$STAGING/$required" ] || fail "包中缺少 $required"
done
for required in .next/static public; do
[ -d "$STAGING/$required" ] || fail "包中缺少目录 $required"
done
if [ -f "$CURRENT/config/test.local.env" ]; then
cp -p "$CURRENT/config/test.local.env" "$STAGING/config/test.local.env"
fi
echo "安装生产依赖…"
(
cd "$STAGING"
if [ -n "$PNPM_BIN" ]; then
"$PNPM_BIN" install --prod --frozen-lockfile
else
"$NPM_BIN" install --omit=dev --no-audit --no-fund
fi
)
APP_PORT=$(cd "$STAGING" && "$NODE_BIN" --input-type=module <<'NODE'
import { existsSync, readFileSync } from 'node:fs';
import { parse } from 'dotenv';
const read = (path) => (existsSync(path) ? parse(readFileSync(path)) : {});
const config = { ...read('config/test.env'), ...read('config/test.local.env'), ...process.env };
const port = config.PORT || '3000';
if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) throw new Error('Invalid PORT');
const api = (config.API_PROXY_TARGET || config.NEXT_PUBLIC_API_BASE_URL || '').trim();
if (!api) throw new Error('API_PROXY_TARGET 或 NEXT_PUBLIC_API_BASE_URL 必填');
console.log(Number(port));
NODE
)
mkdir -p "$SUP_CONF_DIR" "$SUP_LOG_DIR"
NEW_CONF=$(mktemp "$DIR/.${PROGRAM}.supervisor.XXXXXX")
supervisor_quote() { local value=${1//\\/\\\\}; value=${value//\"/\\\"}; printf '%s' "${value//%/%%}"; }
cat > "$NEW_CONF" <<CONF
[program:$PROGRAM]
directory=$(supervisor_quote "$CURRENT")
command="$(supervisor_quote "$NODE_BIN")" "$(supervisor_quote "$CURRENT/start.mjs")"
environment=HOSTNAME="0.0.0.0",PORT="$APP_PORT",APP_ENV="test",NODE_ENV="production",PATH="$(supervisor_quote "$PATH")"
autostart=true
autorestart=true
startsecs=3
stopasgroup=true
killasgroup=true
stopwaitsecs=15
stdout_logfile=$(supervisor_quote "$SUP_LOG_DIR/$PROGRAM.log")
stderr_logfile=$(supervisor_quote "$SUP_LOG_DIR/${PROGRAM}_error.log")
CONF
if [ -f "$CONF" ]; then
HAD_CONF=1
CONF_BACKUP="$CONF.bak.$STAMP"
cp -p "$CONF" "$CONF_BACKUP"
fi
SWITCHED=1
if [ "$WAS_RUNNING" = 1 ]; then supervisorctl stop "$PROGRAM"; fi
if [ -d "$CURRENT" ]; then
rm -rf "$PREVIOUS"
mv "$CURRENT" "$PREVIOUS"
OLD_MOVED=1
fi
mv "$STAGING" "$CURRENT"
INSTALLED=1
STAGING=
CONF_CHANGED=1
cp "$NEW_CONF" "$CONF"
chmod 644 "$CONF"
supervisorctl reread
supervisorctl update
supervisorctl restart "$PROGRAM"
BASE="http://127.0.0.1:$APP_PORT"
HEALTH_FILE=$(mktemp "$DIR/.${PROGRAM}.health.XXXXXX")
HEALTH_OK=0
for attempt in $(seq 1 20); do
CODE=$(curl -sS --connect-timeout 2 --max-time 5 -o "$HEALTH_FILE" -w '%{http_code}' "$BASE/healthz" 2>/dev/null || true)
if [ "$CODE" = 200 ] && "$NODE_BIN" -e '
try { const value=JSON.parse(require("fs").readFileSync(process.argv[1],"utf8"));
process.exit(value.status==="ok" ? 0 : 1);
} catch { process.exit(1); }' "$HEALTH_FILE"; then HEALTH_OK=1; break; fi
sleep 1
done
[ "$HEALTH_OK" = 1 ] || fail '/healthz 未返回 HTTP 200'
CODE=$(curl -sS --connect-timeout 2 --max-time 45 -o /dev/null -w '%{http_code}' "$BASE/" || true)
[ "$CODE" = 200 ] || fail "首页检查失败,HTTP $CODE"
supervisorctl status "$PROGRAM"
SWITCHED=0
echo "测试更新完成:${CURRENT};请确认 Nginx 反代到 127.0.0.1:${APP_PORT}"