[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
+253
View File
@@ -0,0 +1,253 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, readFileSync } from 'node:fs';
import { lstat, mkdir, mkdtemp, readFile, readlink, rm, stat, symlink, writeFile } from 'node:fs/promises';
import { join, resolve } from 'node:path';
import { tmpdir } from 'node:os';
import { spawnSync } from 'node:child_process';
import { parse } from 'dotenv';
const script = readFileSync(new URL('../deploy/up_next_h5.sh', import.meta.url), 'utf8');
const program = script.match(/# BEGIN preserve backend sitemaps[\s\S]*?<<'NODE'\n([\s\S]*?)\nNODE\n\)/)?.[1];
assert.ok(program, 'deploy script must expose the actual pre-switch sitemap preservation step');
async function fixture() {
const root = await mkdtemp(join(tmpdir(), 'huakui-sitemap-deploy-'));
const current = join(root, 'next'), staging = join(root, 'staging'), original = join(root, 'go');
for (const directory of [current, original, join(staging, 'config'), join(staging, 'node_modules')]) await mkdir(directory, { recursive: true });
await symlink(resolve('node_modules/dotenv'), join(staging, 'node_modules/dotenv'), 'dir');
await writeFile(join(staging, 'config/test.env'), 'SITEMAP_DIR=./template/pages/sitemaps\n');
const put = async (file: string, contents: string | Buffer) => { await mkdir(resolve(file, '..'), { recursive: true }); await writeFile(file, contents); };
const run = (configured?: string) => {
const env = { ...process.env }; delete env.SITEMAP_DIR;
if (configured !== undefined) env.SITEMAP_DIR = configured;
const result = spawnSync(process.execPath, ['--input-type=module', '-', current, staging, original], { input: program, cwd: staging, env, encoding: 'utf8' });
assert.equal(result.status, 0, result.stderr);
};
return { root, current, staging, original, put, run, close: () => rm(root, { recursive: true, force: true }) };
}
test('development uses a local snapshot while deployed Next reads the backend parent directory', () => {
for (const environment of ['development', 'test', 'production']) {
const config = parse(readFileSync(new URL(`../config/${environment}.env`, import.meta.url)));
assert.equal(config.SITEMAP_DIR, environment === 'development' ? './template/pages/sitemaps' : '../template/pages/sitemaps', environment);
assert.equal(Object.hasOwn(config, 'PUBLIC_SITE_URL'), false, environment);
assert.equal(Object.hasOwn(config, 'SITEMAP_UPSTREAM_URL'), false, environment);
}
});
test('deployment preserves original XML bytes and fallback index over packaged files', async () => {
const f = await fixture();
try {
const xml = Buffer.from('\ufeff<?xml version="1.0"?>\r\n<urlset><url><loc>https://old.test/play/a/</loc><priority>0.80</priority></url></urlset>\r\n');
await f.put(join(f.current, 'template/pages/sitemaps/sitemap.xml'), xml);
await f.put(join(f.current, 'template/pages/sitemaps/sitemap-playlet-1.xml'), xml);
await f.put(join(f.current, 'template/pages/sitemaps/sitemap-empty.xml'), Buffer.alloc(0));
await f.put(join(f.current, 'template/pages/sitemap.xml'), 'original second index');
await f.put(join(f.current, 'template/pages/robots.txt'), 'User-agent: Custom\r\nDisallow: /private/\r\n');
await f.put(join(f.staging, 'template/pages/sitemaps/sitemap.xml'), 'stale build copy');
f.run();
assert.deepEqual(await readFile(join(f.staging, 'template/pages/sitemaps/sitemap.xml')), xml);
assert.deepEqual(await readFile(join(f.staging, 'template/pages/sitemaps/sitemap-playlet-1.xml')), xml);
assert.equal((await readFile(join(f.staging, 'template/pages/sitemaps/sitemap-empty.xml'))).length, 0);
assert.equal(await readFile(join(f.staging, 'template/pages/sitemap.xml'), 'utf8'), 'original second index');
assert.equal(await readFile(join(f.staging, 'template/pages/robots.txt'), 'utf8'), 'User-agent: Custom\r\nDisallow: /private/\r\n');
assert.deepEqual(await readFile(join(f.current, 'template/pages/sitemaps/sitemap.xml')), xml);
} finally { await f.close(); }
});
test('deployment migrates the previous data directory and can copy the original Go output on first release', async () => {
for (const previous of ['legacy', 'go'] as const) {
const f = await fixture();
try {
const source = previous === 'legacy' ? join(f.current, 'data/sitemaps') : join(f.original, 'template/pages/sitemaps');
await f.put(join(source, 'sitemap.xml'), previous);
await f.put(join(source, 'sitemap-series-1.xml'), previous + '-shard');
f.run();
assert.equal(await readFile(join(f.staging, 'template/pages/sitemaps/sitemap.xml'), 'utf8'), previous);
assert.equal(await readFile(join(f.staging, 'template/pages/sitemaps/sitemap-series-1.xml'), 'utf8'), previous + '-shard');
assert.equal(await readFile(join(source, 'sitemap.xml'), 'utf8'), previous);
} finally { await f.close(); }
}
});
test('an existing empty Go directory remains empty instead of reviving stale legacy or packaged XML', async () => {
const f = await fixture();
try {
await mkdir(join(f.current, 'template/pages/sitemaps'), { recursive: true });
await f.put(join(f.current, 'data/sitemaps/sitemap.xml'), 'old removed index');
await f.put(join(f.staging, 'template/pages/sitemaps/sitemap.xml'), 'old packaged index');
f.run();
assert.equal(existsSync(join(f.staging, 'template/pages/sitemaps/sitemap.xml')), false);
assert.equal(await readFile(join(f.current, 'data/sitemaps/sitemap.xml'), 'utf8'), 'old removed index');
} finally { await f.close(); }
});
test('deployment preserves custom relative and in-release absolute directory mappings', async () => {
for (const absolute of [false, true]) {
const f = await fixture();
try {
const source = join(f.current, 'runtime/xml');
await f.put(join(source, 'sitemap.xml'), 'custom source');
await f.put(join(source, 'application.js'), 'do not copy unrelated files');
const configured = absolute ? source : './runtime/xml';
await writeFile(join(f.staging, 'config/test.local.env'), 'SITEMAP_DIR=' + configured + '\n');
f.run();
assert.equal(await readFile(join(f.staging, 'runtime/xml/sitemap.xml'), 'utf8'), 'custom source');
assert.equal(existsSync(join(f.staging, 'runtime/xml/application.js')), false);
} finally { await f.close(); }
}
});
test('deployment leaves external backend directories untouched and preserves default mount symlinks', async () => {
const f = await fixture();
try {
const external = join(f.root, 'backend-owned');
await f.put(join(external, 'sitemap.xml'), 'backend continues writing here');
await f.put(join(f.current, 'template/pages/sitemaps/sitemap.xml'), 'unused local snapshot');
f.run(external);
assert.equal(existsSync(join(f.staging, 'template/pages/sitemaps')), false);
assert.equal(await readFile(join(external, 'sitemap.xml'), 'utf8'), 'backend continues writing here');
await rm(join(f.current, 'template/pages/sitemaps'), { recursive: true });
await symlink(external, join(f.current, 'template/pages/sitemaps'), 'dir');
f.run();
assert.equal((await lstat(join(f.staging, 'template/pages/sitemaps'))).isSymbolicLink(), true);
assert.equal(await readlink(join(f.staging, 'template/pages/sitemaps')), external);
assert.equal(await readFile(join(external, 'sitemap.xml'), 'utf8'), 'backend continues writing here');
} finally { await f.close(); }
});
test('the deployed parent-relative default keeps the live backend directory external to each frontend release', async () => {
const f = await fixture();
try {
const backend = join(f.root, 'template/pages/sitemaps');
const xml = Buffer.from('\ufeff<?xml version="1.0"?>\r\n<urlset><url><loc>https://original.test/play/backend/</loc></url></urlset>\r\n');
await f.put(join(backend, 'sitemap.xml'), xml);
await f.put(join(backend, 'sitemap-pages.xml'), xml);
await f.put(join(f.current, 'template/pages/sitemaps/sitemap.xml'), 'unused release-local copy');
await writeFile(join(f.staging, 'config/test.env'), readFileSync(new URL('../config/test.env', import.meta.url)));
const config = parse(await readFile(join(f.staging, 'config/test.env')));
assert.equal(config.SITEMAP_DIR, '../template/pages/sitemaps');
assert.equal(resolve(f.current, config.SITEMAP_DIR!), backend);
assert.equal(resolve(f.staging, config.SITEMAP_DIR!), backend);
const before = await stat(join(backend, 'sitemap.xml'));
f.run();
assert.deepEqual(await readFile(join(backend, 'sitemap.xml')), xml);
assert.deepEqual(await readFile(join(backend, 'sitemap-pages.xml')), xml);
assert.equal((await stat(join(backend, 'sitemap.xml'))).mtimeMs, before.mtimeMs);
assert.equal(existsSync(join(f.staging, 'template/pages/sitemaps')), false);
assert.equal(existsSync(join(f.staging, 'data/sitemaps')), false);
assert.equal(await readFile(join(f.current, 'template/pages/sitemaps/sitemap.xml'), 'utf8'), 'unused release-local copy');
} finally { await f.close(); }
});
test('release-local symlink targets are preserved so the mounted sitemap does not become dangling', async () => {
const f = await fixture();
try {
await f.put(join(f.current, 'data/sitemaps/sitemap.xml'), 'linked index');
await mkdir(join(f.current, 'template/pages'), { recursive: true });
await symlink('../../data/sitemaps', join(f.current, 'template/pages/sitemaps'), 'dir');
f.run();
assert.equal(await readlink(join(f.staging, 'template/pages/sitemaps')), '../../data/sitemaps');
assert.equal(await readFile(join(f.staging, 'template/pages/sitemaps/sitemap.xml'), 'utf8'), 'linked index');
assert.equal(await readFile(join(f.current, 'data/sitemaps/sitemap.xml'), 'utf8'), 'linked index');
} finally { await f.close(); }
});
test('standalone packaging refuses to remove a configured backend sitemap inside replaceable output', async () => {
const root = await mkdtemp(join(tmpdir(), 'huakui-sitemap-package-'));
try {
for (const directory of ['scripts', 'config', 'dist/test/backend']) await mkdir(join(root, directory), { recursive: true });
await symlink(resolve('node_modules'), join(root, 'node_modules'), 'dir');
await writeFile(join(root, 'package.json'), '{"type":"module"}');
await writeFile(join(root, 'scripts/package-standalone.ts'), readFileSync(new URL('../scripts/package-standalone.ts', import.meta.url)));
await writeFile(join(root, 'config/test.env'), 'SITEMAP_DIR=./template/pages/sitemaps\n');
const backend = join(root, 'dist/test/backend');
await writeFile(join(backend, 'sitemap.xml'), 'must survive packaging');
const result = spawnSync(resolve('node_modules/.bin/tsx'), [join(root, 'scripts/package-standalone.ts'), 'test'], { cwd: root, env: { ...process.env, SITEMAP_DIR: backend }, encoding: 'utf8' });
assert.notEqual(result.status, 0);
assert.match(result.stderr, /SITEMAP_DIR must live outside the replaceable standalone package directory/);
assert.equal(await readFile(join(backend, 'sitemap.xml'), 'utf8'), 'must survive packaging');
} finally { await rm(root, { recursive: true, force: true }); }
});
async function packageFixture() {
const root = await mkdtemp(join(tmpdir(), 'huakui-sitemap-bundle-'));
const standalone = join(root, '.next-test/standalone'), output = join(root, 'dist/test'), backend = join(root, 'backend');
const put = async (file: string, contents: string | Buffer) => {
await mkdir(resolve(file, '..'), { recursive: true });
await writeFile(file, contents);
};
for (const directory of ['scripts', 'config', 'public', '.next-test/static', '.next-test/standalone', 'backend']) await mkdir(join(root, directory), { recursive: true });
await symlink(resolve('node_modules'), join(root, 'node_modules'), 'dir');
await put(join(root, 'package.json'), '{"type":"module"}');
await put(join(root, 'scripts/package-standalone.ts'), readFileSync(new URL('../scripts/package-standalone.ts', import.meta.url)));
await put(join(root, 'config/test.env'), 'APP_ENV=test\nSITEMAP_DIR=../template/pages/sitemaps\nUPSTREAM_API_URL=http://127.0.0.1:1\n');
await put(join(root, '.next-test/BUILD_ID'), 'fixture-build');
await put(join(root, '.next-test/static/app.js'), 'fixture-static');
await put(join(standalone, 'server.js'), '// fixture server, never executed');
const run = () => spawnSync(resolve('node_modules/.bin/tsx'), [join(root, 'scripts/package-standalone.ts'), 'test'], {
cwd: root, env: { ...process.env, SITEMAP_DIR: backend }, encoding: 'utf8',
});
return { root, standalone, output, backend, put, run, close: () => rm(root, { recursive: true, force: true }) };
}
test('standalone packaging removes Proxy-traced XML copies while retaining backend bytes, robots, media keys and unknown files', async () => {
const f = await packageFixture();
try {
const xml = Buffer.from('\ufeff<?xml version="1.0"?>\r\n<urlset><url><loc>https://original.test/play/untouched/</loc></url></urlset>\r\n');
await f.put(join(f.backend, 'sitemap.xml'), xml);
for (const path of ['template/pages/sitemaps/sitemap.xml', 'template/pages/sitemaps/sitemap-pages.xml', 'data/sitemaps/sitemap.xml', 'data/sitemaps/sitemap-seriesplay.xml', 'template/pages/sitemap.xml']) await f.put(join(f.standalone, path), xml);
await symlink(join(f.backend, 'sitemap.xml'), join(f.standalone, 'data/sitemaps/sitemap-linked.xml'));
await f.put(join(f.standalone, 'template/pages/robots.txt'), 'User-agent: Preserved\r\n');
await f.put(join(f.standalone, 'config/secrets/m3u8.key'), 'fixture-media-key');
await f.put(join(f.standalone, 'template/pages/sitemaps/README.md'), 'preserve documentation');
await f.put(join(f.standalone, 'data/sitemaps/custom.xml'), 'unknown XML is not generated sitemap');
await f.put(join(f.standalone, 'data/sitemaps/application.js'), 'preserve unknown application data');
const before = await stat(join(f.backend, 'sitemap.xml'));
const result = f.run();
assert.equal(result.status, 0, result.stderr);
for (const path of ['template/pages/sitemaps/sitemap.xml', 'template/pages/sitemaps/sitemap-pages.xml', 'data/sitemaps/sitemap.xml', 'data/sitemaps/sitemap-seriesplay.xml', 'data/sitemaps/sitemap-linked.xml', 'template/pages/sitemap.xml']) assert.equal(existsSync(join(f.output, path)), false, path);
assert.deepEqual(await readFile(join(f.backend, 'sitemap.xml')), xml);
assert.equal((await stat(join(f.backend, 'sitemap.xml'))).mtimeMs, before.mtimeMs);
assert.deepEqual(await readFile(join(f.standalone, 'template/pages/sitemaps/sitemap.xml')), xml);
assert.equal((await lstat(join(f.standalone, 'data/sitemaps/sitemap-linked.xml'))).isSymbolicLink(), true);
assert.equal(await readFile(join(f.output, 'template/pages/robots.txt'), 'utf8'), 'User-agent: Preserved\r\n');
assert.equal(await readFile(join(f.output, 'config/secrets/m3u8.key'), 'utf8'), 'fixture-media-key');
assert.equal(await readFile(join(f.output, 'template/pages/sitemaps/README.md'), 'utf8'), 'preserve documentation');
assert.equal(await readFile(join(f.output, 'data/sitemaps/custom.xml'), 'utf8'), 'unknown XML is not generated sitemap');
assert.equal(await readFile(join(f.output, 'data/sitemaps/application.js'), 'utf8'), 'preserve unknown application data');
assert.ok(existsSync(join(f.output, '.next-test/static/app.js')));
assert.ok(existsSync(join(f.output, 'start.mjs')));
} finally { await f.close(); }
});
test('standalone XML cleanup refuses every symlinked parent instead of following a backend mount', async () => {
for (const suffix of ['template', 'template/pages', 'template/pages/sitemaps', 'data', 'data/sitemaps']) {
const f = await packageFixture();
try {
const bytes = Buffer.from('backend XML remains untouched: ' + suffix);
await f.put(join(f.backend, 'sitemap.xml'), bytes);
await mkdir(resolve(join(f.standalone, suffix), '..'), { recursive: true });
await symlink(f.backend, join(f.standalone, suffix), 'dir');
const result = f.run();
assert.notEqual(result.status, 0, suffix);
assert.match(result.stderr, /Sitemap cleanup requires ordinary directories inside the standalone package/);
assert.deepEqual(await readFile(join(f.backend, 'sitemap.xml')), bytes);
assert.equal((await lstat(join(f.standalone, suffix))).isSymbolicLink(), true);
} finally { await f.close(); }
}
});
test('standalone XML cleanup refuses sitemap-named directories before deleting any copied file', async () => {
const f = await packageFixture();
try {
await f.put(join(f.standalone, 'template/pages/sitemaps/sitemap.xml'), 'copied index');
await mkdir(join(f.standalone, 'data/sitemaps/sitemap-pages.xml'), { recursive: true });
const result = f.run();
assert.notEqual(result.status, 0);
assert.match(result.stderr, /Packaged sitemap XML must be a file/);
assert.equal(await readFile(join(f.output, 'template/pages/sitemaps/sitemap.xml'), 'utf8'), 'copied index');
assert.equal((await stat(join(f.output, 'data/sitemaps/sitemap-pages.xml'))).isDirectory(), true);
} finally { await f.close(); }
});