[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
+167
View File
@@ -0,0 +1,167 @@
import test, { after, before } from 'node:test';
import assert from 'node:assert/strict';
import { createServer, type Server } from 'node:http';
import { apiRoutes, handleAPI } from '../src/server/api';
import { createContext } from '../src/server/context';
import { resolveChannel } from '../src/server/auth';
import { handleRequest } from '../src/server/handler';
// Source contracts: app/api/web/{history,recharge,setting,comment}.go and
// app/service/playletser/chapter_watch.go. All requests stay on a local server.
type Received = { path: string; body: string };
let server: Server;
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, body });
response.setHeader('Content-Type', 'application/json');
if (scenario === 'offline') { response.writeHead(503).end('unavailable'); return; }
if (path === '/api/app/recharge/package/list') {
response.end(JSON.stringify({ code: 200, data: { coinList: [{ id: 9, payMethods: [{ type: 'wechat' }] }] } })); return;
}
if (path.startsWith('/api/app/playlet/search/suggest?')) {
response.end(JSON.stringify({ code: 200, data: { keyword: '短剧搜索', list: [
{ id: 1, slug: '恋/爱%2F?片#段', playName: '搜索主推荐', cover: '/play/signed-cover.webp?signature=a%2Bb' },
{ id: 42, title: '无 slug 作品' },
] } })); return;
}
if (path === '/api/app/auth/getInfo') {
response.end(JSON.stringify({ code: 200, data: { token: 'session', userId: 12345, nickName: '用户', type: 1 } })); return;
}
if (path === '/api/app/coin/buy') {
response.end(JSON.stringify(scenario === 'expired'
? { code: 6001, msg: 'expired', tip: '', data: { internal: true } }
: { code: 200, msg: 'ok', tip: '', data: { payUrl: 'https://pay.example/order' } })); return;
}
if (path.startsWith('/api/app/coin/trans/export')) {
response.end(JSON.stringify({ code: 6001, msg: 'expired', data: null })); return;
}
if (path === '/api/app/comment/send') {
response.end(JSON.stringify({ code: 1001, msg: '评论暂不可用', tip: '', data: null })); return;
}
response.end(JSON.stringify({ code: 200, msg: 'backend message', tip: '', data: { ok: true } }));
});
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 address');
process.env.UPSTREAM_API_URL = `http://127.0.0.1:${address.port}`;
});
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 ctx = (pathname: string, body?: unknown) => createContext(new Request('http://local.example' + pathname, {
method: body === undefined ? 'GET' : 'POST',
headers: { Cookie: 'token=session; userType=1; isRegister=1', ...(body === undefined ? {} : { 'Content-Type': 'application/json' }) },
...(body === undefined ? {} : { body: JSON.stringify(body) }),
}));
async function api(path: string, body?: unknown) {
const response = await handleAPI(ctx(path, body)); assert.ok(response); return response;
}
test('surviving BFF routes retain their registered request method and removed account routes are unregistered', () => {
const posts = ['auth/guest', 'upload/image', 'feedback/add', 'playlet/chapter/watch/report'];
const gets = ['auth/session', 'playlet/guess-like/list', 'web/search/suggest'];
for (const path of posts) assert.equal(apiRoutes['/api/' + path], 'POST', path);
for (const path of gets) assert.equal(apiRoutes['/api/' + path], 'GET', path);
assert.deepEqual(Object.keys(apiRoutes).sort(), [...posts, ...gets].map(path => '/api/' + path).sort());
const removed = ['comment/list', 'auth/login', 'auth/register', 'auth/logout', 'auth/resetPassword', 'auth/profile', 'recharge/coin/buy', 'coin/buy', 'playlet/history/clear', 'playlet/history/delete', 'video/history/clear', 'video/history/delete', 'comment/send', 'comment/like/set', 'playlet/chapter/like/set', 'playlet/chapter/collect/set', 'playlet/chapter/unlock', 'playlet/like/set', 'playlet/collect/set', 'video/like/set', 'video/collect/set', 'recharge/transactions', 'coin/trans/list', 'coin/trans/export', 'recharge/transactions/export'];
for (const path of removed) assert.equal(apiRoutes['/api/' + path], undefined, path);
});
test('removed account BFF routes fall through without reaching the upstream', async () => {
scenario = '';
for (const [path, body] of [['auth/login', { account: 'a', password: 'b' }], ['comment/send', { objectId: 2, text: '评论' }], ['coin/buy', { packageId: 9 }], ['coin/trans/export?type=1', undefined]] as const) {
received.length = 0;
assert.equal(await handleAPI(ctx('/api/' + path, body)), null, path);
assert.equal(received.length, 0, path);
}
});
test('watch report payload preserves IDs and omitted optional watch identifiers', async () => {
scenario = '';
received.length = 0;
assert.equal((await api('/api/playlet/chapter/watch/report', { chapterId: 5, ignored: 'discard' })).status, 200);
assert.equal(received[0].path, '/api/app/playlet/chapter/watch/report');
assert.deepEqual(JSON.parse(received[0].body), { chapterId: 5 });
});
test('Go DTO type errors are HTTP 400 before forwarding any mutation', async () => {
scenario = '';
const cases: [string, unknown][] = [
['playlet/chapter/watch/report', { chapterId: 1.5 }],
['feedback/add', { type: 1, content: '反馈', screenshots: [123] }],
];
for (const [path, body] of cases) {
received.length = 0;
assert.equal((await api('/api/' + path, body)).status, 400, path);
assert.equal(received.length, 0, path);
}
});
test('Set-Cookie replacement respects Path and Domain scopes', () => {
const context = ctx('/');
context.responseHeaders.append('Set-Cookie', 'sid=sub; Path=/sub; HttpOnly');
context.responseHeaders.append('Set-Cookie', 'sid=domain; Path=/; Domain=example.com');
context.setCookie('sid', 'root'); context.setCookie('sid', 'updated');
const cookies = context.responseHeaders.getSetCookie();
assert.equal(cookies.length, 3);
assert.ok(cookies.includes('sid=sub; Path=/sub; HttpOnly'));
assert.ok(cookies.includes('sid=domain; Path=/; Domain=example.com'));
assert.equal(cookies.filter(value => value.startsWith('sid=updated;')).length, 1);
});
test('first attribution keeps the source seven-day cookie lifetime', () => {
const context = ctx('/list/?dc=campaign');
assert.equal(resolveChannel(context), 'campaign');
assert.match(context.responseHeaders.getSetCookie().find(value => value.startsWith('channel=')) || '', /Max-Age=604800(?:;|$)/);
});
test('tool and API trailing-slash redirects retain the original query and POST method', async () => {
for (const path of ['/enkey/', '/newenkey/', '/api/web/search/suggest/', '/robots.txt/', '/sitemap.xml/']) {
const response = await handleRequest(new Request('http://local.example' + path + '?sig=abc%2B123'));
assert.equal(response.status, 301, path);
assert.equal(response.headers.get('location'), path.slice(0, -1) + '?sig=abc%2B123');
}
const response = await handleRequest(new Request('http://local.example/api/feedback/add/?x=1', { method: 'POST', body: '{}' }));
assert.equal(response.status, 307);
assert.equal(response.headers.get('location'), '/api/feedback/add?x=1');
});
test('navigation-only deployment exposes navigation and denies application API and media routes', async () => {
const previous = process.env.NAVIGATION_ONLY;
process.env.NAVIGATION_ONLY = 'true';
try {
for (const path of ['/api/web/search/suggest?keyword=a', '/enkey', '/list/']) {
const response = await handleRequest(new Request('http://local.example' + path));
assert.equal(response.status, 404, path);
assert.match(response.headers.get('content-type') || '', /text\/html/);
}
const robots = await handleRequest(new Request('https://public.drama.test/robots.txt'));
assert.equal(robots.status, 404);
assert.match(robots.headers.get('content-type') || '', /text\/html/);
assert.deepEqual(robots.headers.getSetCookie(), []);
const nav = await handleRequest(new Request('http://local.example/nav'));
assert.equal(nav.status, 301); assert.equal(nav.headers.get('location'), '/navigation/');
} finally {
if (previous === undefined) delete process.env.NAVIGATION_ONLY; else process.env.NAVIGATION_ONLY = previous;
}
});
test('search suggestions expose semantic page URLs while retaining encoded slugs, ID fallback and media addresses', async () => {
scenario = '';
received.length = 0;
const response = await api('/api/web/search/suggest?keyword=' + encodeURIComponent('短剧'));
const data = await response.json();
assert.equal(response.status, 200);
assert.equal(data.feature.url, '/drama/%E6%81%8B%2F%E7%88%B1%252F%3F%E7%89%87%23%E6%AE%B5/');
assert.equal(data.feature.cover, '/play/signed-cover.webp?signature=a%2Bb');
assert.equal(data.items[0].url, '/drama/42/');
assert.equal(data.items[1].url, '/search/' + encodeURIComponent('短剧搜索') + '/');
assert.equal(new URL(received[0].path, 'http://local.example').pathname, '/api/app/playlet/search/suggest');
});