[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,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');
|
||||
});
|
||||
Reference in New Issue
Block a user