diff --git a/.gitignore b/.gitignore index 0a3a73e..79f463a 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,5 @@ web_hkdj_h5.md5 /template/pages/sitemaps/*.xml /template/pages/sitemap.xml /data/sitemaps/*.xml +web_huakui_h5.tar.gz +web_huakui_h5.md5 diff --git a/README.md b/README.md index 2c66abc..ebbb1e4 100644 --- a/README.md +++ b/README.md @@ -337,30 +337,16 @@ npm run deploy:test ## 正式服上传 -使用既有跳板机 `running.sh` 链路: +正式服为 `root@172.104.166.87`,Supervisor 程序 `web_huakui_h5` 在 `/web_huakui/h5/app` 执行 `node start.mjs`(端口 26935,Nginx `webhuakuih5.mfcg.me` 代理到该端口,`/enkey`、`/newenkey` 直接走 Go 服务 20031)。 ```bash npm run deploy:prod -- --dry-run # 只核对配置,不构建、不连接 npm run deploy:prod -- --package-only # 检查、构建、生成运行包与 MD5,不上传 -npm run deploy:prod # 构建、上传并执行正式服 running.sh +npm run deploy:prod # 构建、上传、切换并重启 ``` -脚本执行 `check` → `build` → `bundle` → `bundle:linux`,生成根目录 `web_hkdj_h5.tar.gz` 和 `web_hkdj_h5.md5`(仅一行 MD5 hash)。tar 根部直接包含 `start.mjs`、`server.js`、`.next/`(含静态资源)、`public/`(含 PWA Worker/图标)、`config/production.env`、媒体密钥、必要数据及 `node_modules/`,不套 `dist/production/` 外层目录。 +脚本执行 `check` → `build` → `bundle` → `bundle:linux`,生成根目录 `web_huakui_h5.tar.gz` 和 `web_huakui_h5.md5`。tar 根部直接包含 `start.mjs`、`server.js`、`.next/`、`public/`、`config/production.env`、媒体密钥和裁剪后的 `node_modules/`,服务器无需 `npm install`。本地缺少忽略提交的 `config/secrets/m3u8.key` 时,构建前从服务器当前版本复制一份。 -本机最低 Node.js 20.9、npm **10.9.2**,推荐 Node.js 22。正式包保留 Next standalone 已跟踪并裁剪的 `node_modules`;只在临时目录安装与 Linux 目标匹配的 Sharp/libvips 两个原生包,以替换 macOS 原生文件,版本、下载地址和 integrity 均取仓库 lockfile。最终输出为 `dist/production-linux-x64-glibc/`,包含裁剪后的 Next/React/Sharp 运行文件、`dotenv`、`@swc/helpers` 和已跟踪的 WASM 回退,不携带完整 Next 安装树、SWC 原生编译器、Playwright 或未跟踪的客户端依赖。包内无需执行 `npm install`,直接 `node start.mjs`;临时安装树不会进入发布包。 +上传到 `/web_huakui/h5/` 后,服务器校验 MD5,解压到 `app.new`,沿用当前 `app/` 中的 `config/secrets/m3u8.key` 和 `config/*.local.env`,再将 `app` 改名为 `app.prev`、`app.new` 改名为 `app`,执行 `supervisorctl restart web_huakui_h5`。随后检查本机 `/healthz`(`status=ok`)和首页;失败则把新版本移到 `app.failed`,恢复 `app.prev` 并重启。可用 `DEPLOY_HOST`、`DEPLOY_SSH_PORT`、`DEPLOY_DIR`、`DEPLOY_PROGRAM`、`DEPLOY_TARGET_ARCH`、`DEPLOY_TARGET_LIBC` 覆盖默认值。 -默认目标为 Linux **x64/glibc**;可设置 `DEPLOY_TARGET_ARCH=x64|arm64`、`DEPLOY_TARGET_LIBC=glibc|musl`。仓库 package/lock、本机依赖及 `dist/production/` 保持独立。运行包排除 `.env*`、`*.local.env`、日志及 macOS 元数据,密钥随包提供;生产私密覆盖配置需在服务器提供,并由服务器发布脚本保留。 - -默认上传及触发命令严格对应两种端口约定: - -```bash -scp -P 22889 web_hkdj_h5.tar.gz web_hkdj_h5.md5 \ - server@116.212.120.2:/home/server/web_hkdj/h5/nuxt/ -ssh -p 22 server@116.212.120.2 /home/server/web_hkdj/h5/nuxt/running.sh -``` - -可通过 `DEPLOY_JUMP_HOST`、`DEPLOY_JUMP_DIR`、`DEPLOY_SCP_PORT`、`DEPLOY_SSH_PORT` 覆盖连接参数。跳板机脚本只能按完整路径执行,不带参数或额外 shell 命令;脚本和正式机 `up_node_h5.sh` 由既有部署链路管理。同仓库使用发布锁防止并发构建/上传覆盖同名包,构建后显示压缩包体积。任何构建、上传或远端执行失败均返回非零,本地包留存;正式脚本输出保存到 `dist/production-running.*`,即使远端返回 0,出现 `BACKOFF`、`FATAL`、`ERROR` 或 `spawn error` 也会判定失败。端口和健康检查由服务器发布脚本负责。正式站 Nginx 应将页面及 Next/BFF/媒体资源代理至 `26935`,公网域名需在发布后另行验证。 - -正式机 `up_node_h5.sh` 必须完整复制隐藏目录,例如 `cp -rf "$STAGING/." "$APP_DIR/"`;使用 `$STAGING/*` 会漏掉必要的 `.next/`。覆盖前保留 `.local.env`、媒体密钥、`template/pages/sitemaps` 和 `template/pages/sitemap.xml`,服务器已有 XML 优先于包内同名文件;外置 `SITEMAP_DIR` 或挂载目录不得随版本目录删除。复制后应检查 `.next/BUILD_ID` 和 `start.mjs`,启动一次并检查 Supervisor 状态,复制或启动失败须返回非零状态。该远端脚本由既有链路管理,不包含在本仓库。 - -正式 Next 运行目录使用 `/server/web_hkdj/h5/next/`,Supervisor 的 `directory` 与更新脚本的 `APP_DIR` 必须一致;更新脚本先创建目录,再检查 `cd` 是否成功。不要直接运行在旧 Go 的目录里:Next 会优先读取根部遗留的 `static/`,遮盖新包的 `public/static/`。`start.mjs` 检测到这种混用会拒绝启动;迁移时保留服务器的本地环境覆盖和 sitemap 数据。 +本机最低 Node.js 20.9、npm **10.9.2**,推荐 Node.js 22。默认目标 Linux **x64/glibc**,只在临时目录补装目标平台的 Sharp/libvips 原生包,版本与 integrity 取自 lockfile。 diff --git a/config/production.env b/config/production.env index c362650..b11185d 100644 --- a/config/production.env +++ b/config/production.env @@ -1,6 +1,6 @@ APP_ENV=production -# Set your production API in config/production.local.env or deployment environment. -UPSTREAM_API_URL=http://127.0.0.1:26931 +# Production Go API on the same server (172.104.166.87); override in config/production.local.env if needed. +UPSTREAM_API_URL=http://127.0.0.1:20031 # Optional fallback when config/info does not supply an image CDN. # Primary public domain; overrides the backend foreverUrlAddr. SITE_URL=https://huakuidj.com diff --git a/deploy_prod.sh b/deploy_prod.sh index bfa0fb5..de7106f 100755 --- a/deploy_prod.sh +++ b/deploy_prod.sh @@ -1,7 +1,157 @@ -set -e -rm -rf dist dist.zip -npm run build:prod -zip -q -r dist.zip dist -# https://dis79itlh8uwv.cloudfront.net -scp dist.zip root@172.104.166.87:/tmp/dist.zip -ssh root@172.104.166.87 "set -e && rm -rf /tmp/extract && unzip -q /tmp/dist.zip -d /tmp/extract && cd /server/web_huakui/h5/dist/ && rm -rf ./* && mv /tmp/extract/dist/* . && rm -rf /tmp/extract /tmp/dist.zip && echo DEPLOY_OK" +#!/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_FILE" +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'