[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
+180
View File
@@ -0,0 +1,180 @@
import test, { after, before } from 'node:test';
import assert from 'node:assert/strict';
import { createServer, type Server } from 'node:http';
import { createContext, responseHeaders } from '../src/server/context';
import { buildUpstreamHeaders, upstreamGet } from '../src/server/upstream';
import { handleRequest } from '../src/server/handler';
type Received = { path: string; headers: Record<string, string | string[] | undefined>; body: string };
let server: Server; let upstream = ''; let scenario = '';
const received: Received[] = [];
const originalURL = process.env.UPSTREAM_API_URL;
before(async () => {
server = createServer(async (request, response) => {
let body = ''; for await (const chunk of request) body += chunk;
const path = request.url || '';
received.push({ path, headers: request.headers, body });
if (scenario === 'offline') { response.writeHead(503).end('unavailable'); return; }
if (path === '/api/app/redirect') { response.writeHead(302, { Location: '/api/app/config/info' }).end(); return; }
if (scenario === 'media-redirects' && (path.startsWith('/h5/m3u8/redirect.m3u8?') || path.startsWith('/enkey/?'))) {
const query = path.slice(path.indexOf('?'));
response.writeHead(path.startsWith('/enkey/') ? 301 : 302, { Location: (path.startsWith('/enkey/') ? '/media-key-target' : '/h5/m3u8/playlist.m3u8') + query }).end(); return;
}
if (path.startsWith('/media-key-target?')) { response.writeHead(200, { 'Content-Type': 'application/octet-stream' }).end('1234567890123456'); return; }
if (scenario === 'media-business-error' && path.startsWith('/h5/m3u8/')) { response.writeHead(200, { 'Content-Type': 'application/json' }).end(JSON.stringify({ code: 6020, msg: '' })); return; }
if (path.startsWith('/h5/m3u8/')) { response.writeHead(200, { 'Content-Type': 'application/vnd.apple.mpegurl' }).end('#EXTM3U\nsegment.ts\n'); return; }
if (path.startsWith('/enkey/')) { response.writeHead(200, { 'Content-Type': 'application/octet-stream' }).end(Buffer.from(scenario === 'binary-brace' ? '{234567890123456' : '1234567890123456')); return; }
if (path.startsWith('/api/app/coin/trans/export')) { response.writeHead(200, { 'Content-Type': 'text/csv', 'Content-Disposition': 'attachment; filename="records.csv"' }).end('id,amount\n1,2\n'); return; }
if (path === '/api/app/upload/image') { response.setHeader('Content-Type', 'application/json'); response.end(JSON.stringify({ code: 200, data: { id: 'asset-1', sourceNameFs: 'https://cdn.example/image.png' } })); return; }
if (scenario === 'cookies') response.setHeader('Set-Cookie', ['upstream_a=a; Path=/; HttpOnly', 'upstream_b=b; Path=/; SameSite=Lax']);
let data: unknown = { ok: true }; let code = 200;
if (path === '/api/app/auth/guest' || path === '/api/app/auth/login') data = { token: 'guest-fresh', userId: 12345, nickName: '测试 用户', accountNo: 'guest', type: 0, recharge: 1020 };
if (scenario === 'auth-rejected' && path === '/api/app/auth/login') { code = 1001; data = 'upstream-error'; }
if (path.startsWith('/api/app/playlet/search/suggest')) {
if (scenario === 'expired' && request.headers.authorization === 'stale') code = 6001;
else data = { keyword: '真实', list: [{ id: 1, slug: 'real', playName: '真实响应' }] };
}
response.setHeader('Content-Type', 'application/json'); response.end(JSON.stringify({ code, data, msg: 'ok', tip: '' }));
});
await new Promise<void>(resolve => server.listen(0, '127.0.0.1', resolve));
const address = server.address(); if (!address || typeof address === 'string') throw new Error('No upstream address');
upstream = `http://127.0.0.1:${address.port}`; process.env.UPSTREAM_API_URL = upstream;
});
after(async () => { await new Promise<void>(resolve => server.close(() => resolve())); if (originalURL === undefined) delete process.env.UPSTREAM_API_URL; else process.env.UPSTREAM_API_URL = originalURL; });
const request = (path: string, init: RequestInit = {}) => new Request('http://local.example' + path, init);
const post = (path: string, body: unknown, headers: HeadersInit = {}) => request(path, { method: 'POST', headers: { 'Content-Type': 'application/json', ...Object.fromEntries(new Headers(headers)) }, body: JSON.stringify(body) });
test('SSR/upstream preserve client cookies, cookie-token priority, IP, custom UA and SDK headers', async () => {
scenario = ''; received.length = 0;
const ctx = createContext(request('/list/', { headers: { Cookie: 'token=cookie-token; extra=keep%20me', Authorization: 'header-token', 'User-Agent': 'browser-agent', 'X-User-Agent': 'client-UA', 'X-Real-IP': '203.0.113.12', 'X-SDK-Common': 'sdk-snapshot' } }));
await upstreamGet(ctx, '/api/app/config/info');
const headers = received[0].headers;
assert.equal(headers.authorization, 'cookie-token'); assert.equal(headers.cookie, 'token=cookie-token; extra=keep%20me');
assert.equal(headers['user-agent'], 'browser-agent'); assert.equal(headers['x-user-agent'], 'client-UA'); assert.equal(headers['x-real-ip'], '203.0.113.12'); assert.equal(headers['x-sdk-common'], 'sdk-snapshot');
});
test('public data requests isolate both stale Authorization and auth cookies', async () => {
scenario = ''; received.length = 0;
await upstreamGet(createContext(request('/', { headers: { Cookie: 'token=stale', Authorization: 'stale' } })), '/api/app/config/info', { public: true });
assert.equal(received[0].headers.authorization, undefined); assert.equal(received[0].headers.cookie, undefined);
});
test('JSON APIs retain the original follow-redirect behavior', async () => {
scenario = ''; received.length = 0;
const result = await upstreamGet<{ code: number }>(createContext(request('/')), '/api/app/redirect');
assert.equal(result.code, 200); assert.deepEqual(received.map(item => item.path), ['/api/app/redirect', '/api/app/config/info']);
});
test('hop-by-hop, Connection-nominated and internal headers never cross the BFF', () => {
const ctx = createContext(request('/', { headers: { Connection: 'keep-alive, x-internal', 'X-Internal': 'no', 'X-Origin-Head': '1', 'X-Tracker-Request-Id': 'forged', 'X-DC-Request-Context': 'forged' } }));
const headers = buildUpstreamHeaders(ctx);
for (const name of ['connection', 'x-internal', 'x-origin-head', 'x-tracker-request-id', 'x-dc-request-context']) assert.equal(headers.has(name), false);
});
test('canonical attribution snapshot forwards only on the original allowed action', () => {
const id = 'a'.repeat(32);
const envelope = { v: 1, request_id: id, event_name: 'drama_like', common: { app_id: 'JHA-4001', uid: '12345', channel: 'campaign', sid: 'session', client_ts: 1791550000, device: 'PC', device_id: 'device', device_brand: '', device_model: '', device_fingerprint: '', fp_version: '', user_agent: 'browser', sdk_version: '1.2.0', app_version: '1.0.0' }, drama: { media_id: '', drama_id: '123', episode: 1, video_id: '', recommend_trace_id: '' } };
const headers = { 'X-Tracker-Request-Id': id, 'X-Tracker-Event-Name': 'drama_like', 'X-DC-Request-Context': Buffer.from(JSON.stringify(envelope)).toString('base64url') };
const allowed = buildUpstreamHeaders(createContext(post('/api/playlet/like/set', { id: 123, isLike: true }, headers)));
assert.deepEqual(JSON.parse(Buffer.from(allowed.get('X-DC-Request-Context')!, 'base64url').toString()), envelope);
assert.equal(buildUpstreamHeaders(createContext(post('/api/video/like/set', {}, headers))).has('X-DC-Request-Context'), false);
});
test('Set-Cookie values remain separate and updated same-name cookies are written once', async () => {
scenario = 'cookies'; received.length = 0;
const ctx = createContext(request('/api/auth/login'));
await upstreamGet(ctx, '/api/app/config/info');
ctx.setCookie('nickName', '中文 名称', { maxAge: 100 }); ctx.setCookie('nickName', '新名称', { maxAge: 100 });
const headers = responseHeaders(ctx, responseHeaders(ctx));
assert.equal(headers.getSetCookie().length, 3);
assert.equal(headers.getSetCookie().filter(value => value.startsWith('nickName=')).length, 1);
assert.ok(headers.getSetCookie().some(value => value.startsWith('nickName=' + encodeURIComponent('新名称'))));
scenario = '';
});
test('guest authentication establishes SSR-compatible cookies with its success tip', async () => {
scenario = '';
const guest = await handleRequest(post('/api/auth/guest', { devID: 'device-1' }));
assert.equal(guest.status, 200);
const result = await guest.json(); assert.equal(result.code, 200); assert.equal(result.tip, '游客登录成功');
assert.ok(guest.headers.getSetCookie().some(value => value.startsWith('token=guest-fresh;')));
assert.ok(guest.headers.getSetCookie().some(value => value.startsWith('nickName=' + encodeURIComponent('测试 用户'))));
});
test('removed account APIs are unregistered and never reach the upstream', async () => {
scenario = ''; received.length = 0;
for (const [path, init] of [
['/api/auth/login', { method: 'POST', body: JSON.stringify({ account: 'account', password: 'secret' }) }],
['/api/auth/logout', { method: 'POST', body: '{}' }],
['/api/playlet/history/clear', { method: 'POST' }],
['/api/playlet/chapter/like/set', { method: 'POST', body: JSON.stringify({ chapterId: 1, isLike: true }) }],
['/api/coin/trans/export?type=1', {}],
] as [string, RequestInit][]) {
const response = await handleRequest(request(path, { ...init, headers: { Cookie: 'token=session', 'Content-Type': 'application/json' } }));
assert.equal(response.status, 404, path);
assert.equal(response.headers.get('Content-Disposition'), null, path);
assert.equal(response.headers.getSetCookie().some(value => value.startsWith('token=')), false, path);
}
assert.equal(received.some(item => /\/api\/app\/(?:auth\/(?:login|logout)|playlet\/history|playlet\/chapter\/like|coin\/trans)/.test(item.path)), false);
});
test('watch reports require a session before reaching the upstream', async () => {
scenario = ''; received.length = 0;
const unauthorized = await handleRequest(post('/api/playlet/chapter/watch/report', { chapterId: 1 }));
assert.equal(unauthorized.status, 200); assert.equal((await unauthorized.json()).code, 401);
assert.equal(received.some(item => item.path === '/api/app/playlet/chapter/watch/report'), false);
});
test('expired content sessions recover once and the same request uses the newly issued token', async () => {
scenario = 'expired'; received.length = 0;
const response = await handleRequest(request('/api/web/search/suggest?keyword=' + encodeURIComponent('真实'), { headers: { Cookie: 'token=stale' } }));
assert.match(JSON.stringify(await response.json()), /真实响应/);
assert.equal(received.filter(item => item.path === '/api/app/auth/guest').length, 1);
assert.deepEqual(received.filter(item => item.path.startsWith('/api/app/playlet/search/suggest')).map(item => item.headers.authorization), ['stale', 'guest-fresh']);
scenario = '';
});
test('invalid action payloads never reach the upstream; network failures remain JSON responses', async () => {
scenario = ''; received.length = 0;
const invalid = await handleRequest(post('/api/playlet/chapter/watch/report', { chapterId: 'x' }, { Cookie: 'token=session' }));
assert.equal((await invalid.json()).code, 400); assert.equal(received.length, 0);
scenario = 'offline';
const response = await handleRequest(post('/api/playlet/chapter/watch/report', { chapterId: 1 }, { Cookie: 'token=session' }));
assert.equal(response.status, 200); assert.equal((await response.json()).code, 500); assert.equal(response.headers.get('x-robots-tag'), 'noindex, nofollow');
scenario = '';
});
test('multipart uploads retain generated boundary, file contents and the original frontend URL field', async () => {
scenario = ''; received.length = 0;
const form = new FormData(); form.set('file', new File(['PNG-data'], 'test.png', { type: 'image/png' }));
const response = await handleRequest(request('/api/upload/image', { method: 'POST', body: form, headers: { Cookie: 'token=session' } }));
assert.equal((await response.json()).data.url, 'https://cdn.example/image.png');
assert.match(String(received[0].headers['content-type']), /^multipart\/form-data; boundary=/);
assert.match(received[0].body, /name="watermark"\r\n\r\n0/); assert.match(received[0].body, /PNG-data/);
});
test('signed HLS and encryption key proxy preserve query parameters without session authentication', async () => {
scenario = ''; received.length = 0;
const response = await handleRequest(request('/h5/m3u8/playlist.m3u8?token=signed&quality=1080', { headers: { Cookie: 'token=session', Authorization: 'session', 'X-User-Agent': 'player-UA' } }));
assert.equal(response.status, 200); assert.equal(await response.text(), '#EXTM3U\nsegment.ts\n');
assert.equal(received[0].path, '/h5/m3u8/playlist.m3u8?token=signed&quality=1080'); assert.equal(received[0].headers.authorization, undefined); assert.equal(received[0].headers.cookie, undefined);
assert.equal(received[0].headers['x-user-agent'], 'player-UA');
const key = await handleRequest(request('/enkey?token=signed'));
assert.equal((await key.arrayBuffer()).byteLength, 16); assert.equal(key.headers.get('Access-Control-Allow-Origin'), '*'); assert.equal(received.at(-1)!.path, '/enkey/?token=signed');
scenario = 'binary-brace';
const binary = await handleRequest(request('/enkey?token=signed'));
assert.equal(await binary.text(), '{234567890123456');
scenario = '';
});
test('signed media and key redirects retain the original follow behavior and signature bytes', async () => {
scenario = 'media-redirects';
const query = '?ticket=a%2Bb%3D&path=%2Ffoo&keep=1';
try {
for (const [path, target, body] of [
['/h5/m3u8/redirect.m3u8', '/h5/m3u8/playlist.m3u8', '#EXTM3U\nsegment.ts\n'],
['/enkey', '/media-key-target', '1234567890123456'],
]) {
received.length = 0;
const response = await handleRequest(request(path + query, { headers: { Cookie: 'token=session', Authorization: 'session' } }));
assert.equal(response.status, 200); assert.equal(await response.text(), body);
assert.equal(received.at(-1)?.path, target + query);
for (const call of received) { assert.equal(call.headers.authorization, undefined); assert.equal(call.headers.cookie, undefined); }
}
} finally { scenario = ''; }
});
test('upstream media business errors become 502 and preserve the original error envelope', async () => {
scenario = 'media-business-error';
try {
const response = await handleRequest(request('/h5/m3u8/error.m3u8?ticket=original'));
assert.equal(response.status, 502); assert.deepEqual(await response.json(), { code: 6020, msg: '' });
assert.equal(response.headers.get('Content-Type'), 'application/json; charset=utf-8');
} finally { scenario = ''; }
});