[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>
This commit is contained in:
谢宇宁
2026-10-16 11:33:31 +07:00
co-authored by Claude Opus 5.5
commit d2ddb1c321
423 changed files with 52152 additions and 0 deletions
+296
View File
@@ -0,0 +1,296 @@
#!/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。"