- 原生 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>
297 lines
15 KiB
Bash
Executable File
297 lines
15 KiB
Bash
Executable File
#!/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。"
|