[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:
@@ -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(); }
|
||||
});
|
||||
Reference in New Issue
Block a user