Files
web_huakui_web/deploy/up_next_h5.sh
T
谢宇宁andClaude Opus 5.5 d2ddb1c321 [Ai] feat: 花魁短剧 Next.js 站点初始提交
- 原生 Next.js App Router + 严格 TypeScript 实现花魁短剧 PC/H5 页面,按 src/features/{catalog,content,playback} 组织,保留完整 SEO(metadata、JSON-LD、canonical、robots、llms、sitemap、IndexNow)。
- 主域名通过 SITE_URL=https://huakuidj.com 强制覆盖后端 foreverUrlAddr(config/*.env、src/server/config.ts、src/server/seo/shared.ts、src/server/services/shared.ts),支持邮箱改为 support@huakuidj.com。
- 移除登录与账户体系:删除 /account 全部页面、登录/注册/充值弹窗、收藏点赞、评论、金币解锁及对应 BFF 接口;旧账户地址 GET/HEAD 统一 301 到首页(src/lib/routes.ts REMOVED_ACCOUNT_PATHS),保留游客会话、反馈、图片上传、观看上报与搜索联想。
- 播放页:评论位改为同类推荐 + 热播榜单,锁定集改为 App 观看遮罩与底部弹层,新增简介标签、分集图例与独立视觉皮肤(src/features/playback、src/styles/pages/playback.css)。
- 卡片:短剧/视频卡状态与时长移至信息行胶囊标签,封面角标移至左上角(src/components/catalog/Cards.tsx、src/styles/foundations/base.css)。
- 同步更新单元测试、浏览器冒烟脚本、路由清单与 README。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 11:33:31 +07:00

297 lines
15 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# 测试服专用:dist.zip 为 dist/test 内容(不含 node_modules),与校验文件放在本脚本旁。
# 安装到 next/,保留原 Go 文件;仅管理独立的 hkdj_next_h5 Supervisor 程序。
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=hkdj_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 '检查失败,恢复上一版应用与 Supervisor 配置。' >&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
echo '上一版已恢复;失败版本和上传包已保留。' >&2
}
finish() {
local rc=$?
trap - EXIT
if [ "$rc" -ne 0 ]; then
if [ "$SWITCHED" = 1 ]; then
rollback || {
echo "自动回滚未完成,请检查 ${CURRENT}、${PREVIOUS} 和 ${CONF_BACKUP}。" >&2
printf '手动恢复:supervisorctl stop %s; mv %q %q; mv %q %q\n' \
"$PROGRAM" "$CURRENT" "$DIR/.${PROGRAM}.failed.manual.$STAMP" "$PREVIOUS" "$CURRENT" >&2
[ -z "$CONF_BACKUP" ] || printf 'cp -p %q %q\n' "$CONF_BACKUP" "$CONF" >&2
echo "然后 supervisorctl reread && supervisorctl update && supervisorctl start $PROGRAM" >&2
}
fi
echo "发布未完成,保留 $PACKAGE 便于排查。" >&2
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'
# SCP 本身不持有发布锁:先冻结上传文件,后续校验与解包始终使用同一份快照。
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 \
/usr/local/lib/nodejs/*/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,推荐 Node.js 22;可用 NODE_BIN 指定路径'
export PATH="$(dirname "$NODE_BIN"):$PATH"
NPM_BIN="$(dirname "$NODE_BIN")/npm"
[ -x "$NPM_BIN" ] || NPM_BIN=$(command -v npm || true)
[ -n "$NPM_BIN" ] && [ -x "$NPM_BIN" ] || fail "选定 Node 缺少 npm:$NODE_BIN"
supervisorctl pid >/dev/null || fail 'Supervisor 未运行或无法连接'
STATUS=$(supervisorctl status "$PROGRAM" 2>/dev/null || true)
[[ "$STATUS" != *RUNNING* ]] || WAS_RUNNING=1
[ ! -L "$CURRENT" ] && [ ! -L "$PREVIOUS" ] || fail 'next/next.prev 不允许是符号链接'
echo "使用 Node $("$NODE_BIN" -v),在 Linux 暂存目录安装运行依赖。"
STAGING=$(mktemp -d "$DIR/.${PROGRAM}.staging.XXXXXX")
# 不接受绝对路径、父目录跳转或 node_modules;运行依赖必须由目标机器安装。
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 package-lock.json .next-test/BUILD_ID \
config/test.env config/secrets/m3u8.key; do
[ -s "$STAGING/$required" ] || fail "包中缺少非空文件 $required"
done
for required in .next-test/static public; do
[ -d "$STAGING/$required" ] || fail "包中缺少目录 $required"
done
[ ! -L "$STAGING/config" ] && [ ! -L "$STAGING/node_modules" ] || fail '包内配置或依赖目录不能是符号链接'
# 服务器的运行配置、密钥和已生成 sitemap 优先,始终不覆盖原 Go 应用目录。
cp "$STAGING/config/secrets/m3u8.key" "$UPLOAD_COPY/packaged-m3u8.key"
if [ -f "$CURRENT/config/test.local.env" ]; then
cp -p "$CURRENT/config/test.local.env" "$STAGING/config/test.local.env"
fi
if [ -d "$CURRENT/config/secrets" ]; then
cp -a "$CURRENT/config/secrets/." "$STAGING/config/secrets/"
fi
# 空旧密钥不覆盖包中有效密钥,随后再选择服务器已有的非空密钥。
cp "$UPLOAD_COPY/packaged-m3u8.key" "$STAGING/config/secrets/m3u8.key"
for existing_key in "$CURRENT/config/secrets/m3u8.key" "$DIR/config/secrets/m3u8.key" "$DIR/conf/m3u8.key"; do
if [ -s "$existing_key" ]; then cp -p "$existing_key" "$STAGING/config/secrets/m3u8.key"; break; fi
done
(cd "$STAGING" && "$NPM_BIN" ci --omit=dev --include=optional --ignore-scripts --no-audit --no-fund)
# BEGIN preserve backend sitemaps
(cd "$STAGING" && "$NODE_BIN" --input-type=module - "$CURRENT" "$STAGING" "$DIR" <<'NODE'
import { cpSync, existsSync, lstatSync, mkdirSync, readFileSync, readdirSync, readlinkSync, rmSync, statSync, symlinkSync } from 'node:fs';
import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
import { parse } from 'dotenv';
const [current, staging, original] = process.argv.slice(2).map(value => resolve(value));
const read = file => existsSync(file) ? parse(readFileSync(file)) : {};
const config = { ...read(join(staging, 'config/test.env')), ...read(join(staging, 'config/test.local.env')), ...process.env };
const configured = (config.SITEMAP_DIR || './template/pages/sitemaps').trim() || './template/pages/sitemaps';
const source = resolve(current, configured), suffix = relative(current, source);
const insideCurrent = suffix !== '..' && !suffix.startsWith('..' + '/') && !isAbsolute(suffix);
const nameAllowed = name => name === 'sitemap.xml' || /^sitemap-[a-z0-9-]+\.xml$/.test(name);
const info = file => { try { return lstatSync(file); } catch (error) { if (error.code === 'ENOENT') return null; throw error; } };
const withinCurrent = target => { const part = relative(current, target); return part !== '..' && !part.startsWith('../') && !isAbsolute(part); };
const ensureParent = destination => {
if (destination === staging) return;
const suffix = relative(staging, dirname(destination));
if (suffix === '..' || suffix.startsWith('../') || isAbsolute(suffix)) throw new Error('Sitemap destination escapes staging');
let cursor = staging;
for (const segment of suffix.split('/').filter(Boolean)) {
cursor = join(cursor, segment);
if (info(cursor)?.isSymbolicLink()) throw new Error('Sitemap destination parent cannot be a symbolic link');
mkdirSync(cursor, { recursive: true });
}
};
const available = directory => {
const value = info(directory);
// An explicitly empty current directory is authoritative; do not revive older XML.
return value?.isSymbolicLink() || value?.isDirectory();
};
const visited = new Set();
const preserveDirectory = (existing, destination) => {
if (visited.has(existing)) throw new Error('Sitemap mount contains a symbolic-link cycle');
visited.add(existing);
ensureParent(destination);
if (info(existing)?.isSymbolicLink()) {
const link = readlinkSync(existing), target = resolve(dirname(existing), link);
// Keep release-local link targets as well as their link; external writers stay external.
if (withinCurrent(target) && available(target)) preserveDirectory(target, resolve(staging, relative(current, target)));
rmSync(destination, { recursive: true, force: true });
symlinkSync(link, destination);
return;
}
if (info(destination)?.isSymbolicLink()) throw new Error('Packaged sitemap destination cannot be a symbolic link');
mkdirSync(destination, { recursive: true });
// Server files, including intentional absence, take precedence over stale build copies.
for (const name of readdirSync(destination).filter(nameAllowed)) {
const target = join(destination, name);
if (info(target)?.isFile() || info(target)?.isSymbolicLink()) rmSync(target);
}
for (const name of readdirSync(existing).filter(nameAllowed)) {
const file = join(existing, name);
if (!statSync(file, { throwIfNoEntry: false })?.isFile()) continue;
cpSync(file, join(destination, name), { dereference: true, preserveTimestamps: true });
}
};
// External mounts remain untouched. Absolute paths inside next/ rebase into the new release.
if (insideCurrent) {
const destination = resolve(staging, suffix);
const candidates = [source, join(current, 'template/pages/sitemaps'), join(current, 'data/sitemaps'), join(original, 'template/pages/sitemaps')];
const existing = candidates.find(available);
if (existing && existing !== destination) preserveDirectory(existing, destination);
}
// The original router's second index candidate is independent of SITEMAP_DIR.
const fallback = [join(current, 'template/pages/sitemap.xml'), join(original, 'template/pages/sitemap.xml')].find(file => existsSync(file) && statSync(file).isFile());
if (fallback) {
const target = join(staging, 'template/pages/sitemap.xml');
ensureParent(target);
if (info(target)?.isSymbolicLink()) rmSync(target);
cpSync(fallback, target, { dereference: true, preserveTimestamps: true });
}
// Preserve the same optional robots overrides that the original Go helper reads.
for (const file of ['template/pages/robots.txt', 'templates/pages/robots.txt', 'app/template/pages/robots.txt', 'app/templates/pages/robots.txt']) {
const existing = [join(current, file), join(original, file)].find(source => existsSync(source) && statSync(source).isFile());
if (!existing) continue;
const target = join(staging, file);
ensureParent(target);
if (info(target)?.isSymbolicLink()) rmSync(target);
cpSync(existing, target, { dereference: true, preserveTimestamps: true });
}
NODE
)
# END preserve backend sitemaps
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 || '30334';
if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) throw new Error('Invalid PORT');
if (!/^https?:\/\//.test(config.UPSTREAM_API_URL?.trim() || '')) throw new Error('UPSTREAM_API_URL is required');
console.log(Number(port));
NODE
)
mkdir -p "$SUP_CONF_DIR" "$SUP_LOG_DIR"
NEW_CONF=$(mktemp "$DIR/.${PROGRAM}.supervisor.XXXXXX")
# Supervisor 会解析百分号;路径中的引号与反斜杠同时按其 command 语法转义。
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
# 依赖与配置检查全部通过后才停止当前 Node;切换后的任何失败都会自动恢复。
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" && value.environment==="test" ? 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"
CODE=$(curl -sS --connect-timeout 2 --max-time 25 -o "$HEALTH_FILE" -w '%{http_code}' "$BASE/enkey" || true)
[ "$CODE" = 200 ] && [ -s "$HEALTH_FILE" ] || fail "/enkey 检查失败,HTTP $CODE 或密钥为空"
supervisorctl status "$PROGRAM"
SWITCHED=0
# 新上传若已覆盖固定文件名,留给下一次发布;清理失败也不改变已通过检查的结果。
LATEST=$(sha256sum "$PACKAGE" 2>/dev/null | awk '{print $1}' || true)
if [ "$LATEST" = "$ACTUAL" ]; then
rm -f "$PACKAGE" "$CHECKSUM" || echo '提示:服务已更新,但上传包清理失败。' >&2
else echo '提示:上传包已变更或不存在,保留当前文件供下次发布。'; fi
echo "测试更新完成:${CURRENT};/healthz、首页、/enkey 检查通过。"
echo "请确认 NGINX 将页面及 /enkey、/newenkey、/h5/m3u8/ 代理到 127.0.0.1:${APP_PORT};脚本未修改 NGINX。"