[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,286 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { canonicalizePageURLs, ROUTE_MIGRATIONS, toLegacyPath, toPublicPath, toPublicURL } from "../src/lib/routes";
|
||||
import { createContext } from "../src/server/context";
|
||||
import { canonicalRedirect } from "../src/server/redirects";
|
||||
import { matchPageRoute } from "../src/server/pages";
|
||||
import { pageRoutes, publicPageRoutes } from "../src/server/pages/routes";
|
||||
import { preparePage } from "../src/server/react/page-data";
|
||||
import { trackerPage } from "../src/server/services/shared";
|
||||
|
||||
test("Huakui route prefixes preserve encoded slugs, raw queries and fragments", () => {
|
||||
const suffix = "/%E7%9F%AD%E5%89%A7%2F%E9%9B%86%252F1/?x=a+b&x=%2b&token=a%2FB#episode%202";
|
||||
assert.equal(toPublicPath("/play" + suffix), "/drama" + suffix);
|
||||
assert.equal(toLegacyPath("/drama" + suffix), "/play" + suffix);
|
||||
assert.equal(toLegacyPath("/series/watch/video/recommendations/page/2/"), "/series-play/video/recommendations/page/2/");
|
||||
for (const path of ["/account/settings/", "/settings/", "/profile/", "/account/login/"]) {
|
||||
assert.equal(toPublicPath(path), path, "removed account path is not migrated " + path);
|
||||
assert.equal(toLegacyPath(path), path, "removed account path is not migrated " + path);
|
||||
}
|
||||
assert.equal(toPublicPath("/dh/"), "/navigation/");
|
||||
assert.equal(toPublicPath("/home-road/"), "/return-home/");
|
||||
assert.equal(toLegacyPath("/return-home/"), "/home-road/");
|
||||
for (const path of ["/", "/series/anime/all/1/", "/search/word/", "/listings/", "/api/playlet/list", "/h5/m3u8/signed", "/static/images/play/foo.svg", "//external.example/play/a/"]) {
|
||||
assert.equal(toPublicPath(path), path, path);
|
||||
}
|
||||
for (const { legacy, public: current } of ROUTE_MIGRATIONS) {
|
||||
assert.equal(toPublicPath(legacy + "/"), current + "/");
|
||||
assert.equal(toPublicPath(current + "/"), current + "/", "idempotent " + current);
|
||||
}
|
||||
});
|
||||
|
||||
test("all registered HTML routes resolve under new public URLs with identical parameters", () => {
|
||||
const values: Record<string, string> = { q: "短剧/a", theme: "romance", slug: "剧名/a", configId: "recommend", media: "anime", topic: "12", updateStatus: "2", sortType: "3", sortOrder: "2", rankType: "3", timeRange: "1", page: "2", guessPage: "2", ep: "100", filter: "chapter" };
|
||||
assert.equal(pageRoutes.length, 59);
|
||||
assert.equal(publicPageRoutes.length, pageRoutes.length);
|
||||
for (const [pattern, handler] of pageRoutes) {
|
||||
const params: Record<string, string> = {};
|
||||
const legacy = pattern.replace(/:(\w+)/g, (_, key: string) => {
|
||||
params[key] = values[key];
|
||||
return encodeURIComponent(values[key]);
|
||||
});
|
||||
const current = toPublicPath(legacy);
|
||||
assert.deepEqual(matchPageRoute(current), { handler, params }, pattern);
|
||||
assert.deepEqual(matchPageRoute(legacy), { handler, params }, "legacy " + pattern);
|
||||
for (const method of ["GET", "HEAD"]) {
|
||||
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example" + current, { method }))), null, "canonical " + current);
|
||||
const response = canonicalRedirect(createContext(new Request("https://huakui.example" + legacy, { method })));
|
||||
if (current === legacy) assert.equal(response, null, "unchanged " + legacy);
|
||||
else {
|
||||
assert.equal(response?.status, 301, "legacy " + legacy);
|
||||
assert.equal(response?.headers.get("Location"), current, "single-hop " + legacy);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const path of ["/dramas//", "/dramas/theme/extra/", "/series/watch/", "/account/unknown/", "/account/", "/account/settings/", "/account/favorites/chapter/", "/profile/", "/login/", "/collection/a/page/2/extra/", "/curated/dramas/", "/discover/search/extra/", "/return-home//", "/return-home/extra/", "/home-road/extra/"]) {
|
||||
assert.equal(matchPageRoute(path), null, path);
|
||||
}
|
||||
});
|
||||
|
||||
test("legacy pages redirect once with 301 to new canonical path and keep request suffix bytes", () => {
|
||||
const saved = process.env.NAVIGATION_ONLY;
|
||||
process.env.NAVIGATION_ONLY = "false";
|
||||
try {
|
||||
const suffix = "?x=a+b&x=%2b&return=%2Fplay%2Fa%2F#part%202";
|
||||
for (const [legacy, current] of [["/play/a%2Fb", "/drama/a%2Fb/"], ["/list/romance/2/1/page/2/", "/dramas/romance/2/1/page/2/"], ["/dh", "/navigation/"], ["/home-road", "/return-home/"], ["/home-road/", "/return-home/"]]) {
|
||||
for (const method of ["GET", "HEAD"]) {
|
||||
const response = canonicalRedirect(createContext(new Request("https://huakui.example" + legacy + suffix, { method })));
|
||||
assert.equal(response?.status, 301, legacy);
|
||||
assert.equal(response?.headers.get("Location"), current + suffix, legacy);
|
||||
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example" + current + suffix, { method }))), null, "single hop " + current);
|
||||
}
|
||||
}
|
||||
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example/dramas")))?.headers.get("Location"), "/dramas/");
|
||||
// Removed account surfaces permanently return home without carrying the query or fragment.
|
||||
for (const path of ["/account", "/account/", "/account/login/", "/account/register/", "/account/history/page/2/", "/account/favorites/chapter/page/3/", "/account/purchases/", "/account/recharge/", "/account/settings/", "/login", "/login/", "/register/", "/profile", "/profile/", "/history/page/2/", "/favorites/chapter/", "/purchased/", "/setting/", "/settings", "/settings/", "/recharge/"]) {
|
||||
for (const method of ["GET", "HEAD"]) {
|
||||
const response = canonicalRedirect(createContext(new Request("https://huakui.example" + path + suffix, { method })));
|
||||
assert.equal(response?.status, 301, method + " " + path);
|
||||
assert.equal(response?.headers.get("Location"), "/", method + " " + path);
|
||||
}
|
||||
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example" + path, { method: "POST" }))), null, "POST " + path);
|
||||
}
|
||||
for (const path of ["/accounts/", "/account-settings/", "/loginx/", "/history-of-drama/", "/settingsx"]) {
|
||||
assert.notEqual(canonicalRedirect(createContext(new Request("https://huakui.example" + path)))?.headers.get("Location"), "/", "segment boundary " + path);
|
||||
}
|
||||
for (const path of ["/list//", "/list/theme/extra/", "/static/css/list.css", "/h5/m3u8/signed?sign=%2B", "/api/feedback/add", "/api/playlet/collect/set/"]) {
|
||||
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example" + path))), null, path);
|
||||
}
|
||||
for (const path of ["/play/a", "/drama/a", "/profile", "/account", "/home-road/", "/return-home/"]) {
|
||||
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example" + path, { method: "POST" }))), null, path);
|
||||
}
|
||||
const post = canonicalRedirect(createContext(new Request("https://huakui.example/api/feedback/add/?sign=%2B", { method: "POST" })));
|
||||
assert.equal(post?.status, 307);
|
||||
assert.equal(post?.headers.get("Location"), "/api/feedback/add?sign=%2B");
|
||||
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example/api/playlet/collect/set/?sign=%2B", { method: "POST" }))), null);
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.NAVIGATION_ONLY;
|
||||
else process.env.NAVIGATION_ONLY = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test("DTO link canonicalization leaves external, media, SDK, payment and SEO inputs intact", () => {
|
||||
const external = "https://cdn.example/play/signed.m3u8?sign=%2B";
|
||||
const data = {
|
||||
requestPath: "/play/a/1/", canonicalPath: "/play/a/",
|
||||
works: [{ URL: "/play/a%2Fb/", url: "https://huakui.example/topic-detail/a/?q=%2b" }],
|
||||
pagination: { PrevURL: "/list/", nextURL: "/list/romance/1/1/page/2/" },
|
||||
categoryList: [{ urlValue: "/rank/" }, { urlValue: "https://external.example/list/" }],
|
||||
chapter: { url: "/play/signed-media.m3u8?sign=%2B" },
|
||||
video: { video: { url: "/play/signed-video.m3u8?sign=%2B" } },
|
||||
advitise: { url: "https://huakui.example/play/ad/" },
|
||||
config: { url: "/play/config/" },
|
||||
mainURL: external, previewURL: "/play/signed-preview.m3u8?sign=%2B",
|
||||
payment: { url: "/play/payment/" },
|
||||
canonicalURL: "https://huakui.example/play/a/",
|
||||
seoInfo: { canonicalUrl: "https://huakui.example/play/a/" },
|
||||
jsonLD: '{"url":"https://huakui.example/play/a/"}',
|
||||
};
|
||||
assert.equal(canonicalizePageURLs(data, "https://huakui.example"), data);
|
||||
assert.equal(data.requestPath, "/drama/a/1/");
|
||||
assert.equal(data.canonicalPath, "/drama/a/");
|
||||
assert.equal(data.works[0].URL, "/drama/a%2Fb/");
|
||||
assert.equal(data.works[0].url, "https://huakui.example/collection/a/?q=%2b");
|
||||
assert.equal(data.pagination.PrevURL, "/dramas/");
|
||||
assert.equal(data.pagination.nextURL, "/dramas/romance/1/1/page/2/");
|
||||
assert.equal(data.categoryList[0].urlValue, "/rankings/");
|
||||
assert.equal(data.categoryList[1].urlValue, "https://external.example/list/");
|
||||
assert.equal(data.chapter.url, "/play/signed-media.m3u8?sign=%2B");
|
||||
assert.equal(data.video.video.url, "/play/signed-video.m3u8?sign=%2B");
|
||||
assert.equal(data.advitise.url, "https://huakui.example/play/ad/");
|
||||
assert.equal(data.config.url, "/play/config/");
|
||||
assert.equal(data.mainURL, external);
|
||||
assert.equal(data.previewURL, "/play/signed-preview.m3u8?sign=%2B");
|
||||
assert.equal(data.payment.url, "/play/payment/");
|
||||
assert.equal(data.canonicalURL, "https://huakui.example/play/a/");
|
||||
assert.equal(data.seoInfo.canonicalUrl, "https://huakui.example/play/a/");
|
||||
assert.equal(data.jsonLD, '{"url":"https://huakui.example/play/a/"}');
|
||||
assert.equal(toPublicURL("https://huakui.example/play/a%2Fb/?sign=a+b#e", "https://huakui.example/"), "https://huakui.example/drama/a%2Fb/?sign=a+b#e");
|
||||
assert.equal(toPublicURL(external, "https://huakui.example"), external);
|
||||
});
|
||||
|
||||
test("navigation-only mode recognizes the new navigation path without enabling other page redirects", () => {
|
||||
const saved = process.env.NAVIGATION_ONLY;
|
||||
process.env.NAVIGATION_ONLY = "true";
|
||||
try {
|
||||
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example/nav?channel=%2B")))?.headers.get("Location"), "/navigation/?channel=%2B");
|
||||
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example/navigation/"))), null);
|
||||
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example/list/"))), null);
|
||||
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example/home-road/"))), null);
|
||||
assert.equal(canonicalRedirect(createContext(new Request("https://huakui.example/account/settings/"))), null);
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env.NAVIGATION_ONLY;
|
||||
else process.env.NAVIGATION_ONLY = saved;
|
||||
}
|
||||
});
|
||||
|
||||
test("new content paths reach unchanged upstream loaders and preserve navigation-only isolation", async () => {
|
||||
const savedFetch = globalThis.fetch;
|
||||
const savedUpstream = process.env.UPSTREAM_API_URL;
|
||||
const savedNavigation = process.env.NAVIGATION_ONLY;
|
||||
process.env.UPSTREAM_API_URL = "http://huakui-route-loader-test";
|
||||
process.env.NAVIGATION_ONLY = "false";
|
||||
const calls: URL[] = [];
|
||||
globalThis.fetch = async (input) => {
|
||||
const url = new URL(String(input));
|
||||
calls.push(url);
|
||||
let data: Record<string, unknown> = {};
|
||||
if (url.pathname === "/api/app/config/info") data = { config: { foreverUrlAddr: "https://huakui.example" } };
|
||||
if (url.pathname === "/api/app/theme/list") data = { list: [{ id: 12, name: "爱情", slug: "romance", status: true }] };
|
||||
if (url.pathname === "/api/app/playlet/all/list") data = { list: [{ id: 10, playName: "作品", slug: "story" }], totalCount: 100, pageNum: Number(url.searchParams.get("pageNum")), pageSize: 24 };
|
||||
return Response.json({ code: 200, data });
|
||||
};
|
||||
const context = (path: string) => {
|
||||
const ctx = createContext(new Request("https://huakui.example" + path, { headers: { cookie: "token=route-test; isRegister=1" } }));
|
||||
ctx.user = { userId: 7, nickName: "读者", type: 1, isRegister: true };
|
||||
ctx.state.set("auth:fresh", true);
|
||||
return ctx;
|
||||
};
|
||||
try {
|
||||
const list = await preparePage(context("/dramas/romance/1/1/page/2/"));
|
||||
assert.equal(list.status || 200, 200);
|
||||
assert.equal(list.view, "list");
|
||||
assert.equal(list.data.themeId, 12);
|
||||
assert.equal(list.data.updateStatus, 1);
|
||||
assert.equal(list.data.requestPath, "/dramas/romance/1/1/page/2/");
|
||||
assert.equal(list.data.canonicalPath, "/dramas/romance/1/1/page/2/");
|
||||
assert.equal(list.data.pagination.NextURL || list.data.pagination.nextURL, "/dramas/romance/1/1/page/3/");
|
||||
assert.equal(list.data.canonicalURL, "https://huakui.example/dramas/romance/1/1/page/2/");
|
||||
assert.match(list.data.robots, /^index,follow/);
|
||||
assert.equal(list.data.paginationNextURL, "https://huakui.example/dramas/romance/1/1/page/3/");
|
||||
const sorted = await preparePage(context("/dramas/romance/1/2/"));
|
||||
assert.equal(sorted.data.robots, "noindex,follow");
|
||||
assert.equal(sorted.data.canonicalURL, undefined);
|
||||
const upstream = calls.find((url) => url.pathname === "/api/app/playlet/all/list");
|
||||
assert.equal(upstream?.searchParams.get("themeSlug"), "romance");
|
||||
assert.equal(upstream?.searchParams.get("pageNum"), "2");
|
||||
for (const path of ["/account/", "/account/settings/", "/account/favorites/chapter/", "/setting/"])
|
||||
assert.equal((await preparePage(context(path))).status, 404, path);
|
||||
const homeRoad = await preparePage(context("/return-home/"));
|
||||
assert.equal(homeRoad.status || 200, 200);
|
||||
assert.equal(homeRoad.view, "homeRoad");
|
||||
assert.equal(homeRoad.data.requestPath, "/return-home/");
|
||||
assert.equal(homeRoad.data.pageKey, "home_road");
|
||||
assert.match(homeRoad.data.metaTitle, /回家的路.*花魁短剧/);
|
||||
assert.equal(homeRoad.data.robots, "noindex,follow");
|
||||
assert.equal(homeRoad.data.canonicalURL, undefined);
|
||||
assert.equal(homeRoad.data.jsonLD, undefined);
|
||||
assert.ok(homeRoad.data.faqItems.length > 0);
|
||||
assert.equal((await preparePage(context("/navigation/"))).data.homeRoadURL, "/return-home/");
|
||||
process.env.NAVIGATION_ONLY = "true";
|
||||
const navigation = await preparePage(context("/navigation/"));
|
||||
assert.equal(navigation.view, "nav");
|
||||
assert.equal(navigation.data.isNavPage, true);
|
||||
assert.equal(navigation.data.homeRoadURL, "https://huakui.example/return-home/");
|
||||
assert.equal((await preparePage(context("/dramas/"))).status, 404);
|
||||
assert.equal((await preparePage(context("/return-home/"))).status, 404);
|
||||
} finally {
|
||||
globalThis.fetch = savedFetch;
|
||||
if (savedUpstream === undefined) delete process.env.UPSTREAM_API_URL;
|
||||
else process.env.UPSTREAM_API_URL = savedUpstream;
|
||||
if (savedNavigation === undefined) delete process.env.NAVIGATION_ONLY;
|
||||
else process.env.NAVIGATION_ONLY = savedNavigation;
|
||||
}
|
||||
});
|
||||
|
||||
test("navigation-only home road links use a configured main-site origin, then the saved PDF fallback", async () => {
|
||||
const savedFetch = globalThis.fetch;
|
||||
const savedUpstream = process.env.UPSTREAM_API_URL;
|
||||
const savedNavigation = process.env.NAVIGATION_ONLY;
|
||||
process.env.NAVIGATION_ONLY = "true";
|
||||
let config: Record<string, string> = {};
|
||||
globalThis.fetch = async (input) => {
|
||||
const url = new URL(String(input));
|
||||
return Response.json({ code: 200, data: url.pathname === "/api/app/config/info" ? { config } : {} });
|
||||
};
|
||||
const cases: [Record<string, string>, string][] = [
|
||||
[{ foreverUrlAddr: "https://main.huakui.test/old/path?ch=main", newUrlAddr: "https://latest.huakui.test/" }, "https://main.huakui.test/return-home/"],
|
||||
[{ foreverUrlAddr: "https://example.com/", newUrlAddr: "https://latest.huakui.test/old/path" }, "https://latest.huakui.test/return-home/"],
|
||||
[{ foreverUrlAddr: "javascript:invalid", newUrlAddr: "/", pdfAddr: "https://files.huakui.test/access.pdf" }, "https://files.huakui.test/access.pdf"],
|
||||
[{}, ""],
|
||||
];
|
||||
try {
|
||||
for (const [index, [configured, expected]] of cases.entries()) {
|
||||
// Isolate public-config caches while testing each configured deployment.
|
||||
process.env.UPSTREAM_API_URL = `http://home-road-navigation-test-${index}`;
|
||||
config = configured;
|
||||
const context = createContext(new Request("https://navigation.huakui.test/navigation/", { headers: { cookie: "token=nav-test; isRegister=1" } }));
|
||||
context.user = { userId: 7, type: 1, isRegister: true };
|
||||
context.state.set("auth:fresh", true);
|
||||
const page = await preparePage(context);
|
||||
assert.equal(page.status || 200, 200);
|
||||
assert.equal(page.view, "nav");
|
||||
assert.equal(page.data.homeRoadURL, expected);
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = savedFetch;
|
||||
if (savedUpstream === undefined) delete process.env.UPSTREAM_API_URL;
|
||||
else process.env.UPSTREAM_API_URL = savedUpstream;
|
||||
if (savedNavigation === undefined) delete process.env.NAVIGATION_ONLY;
|
||||
else process.env.NAVIGATION_ONLY = savedNavigation;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
test("semantic page paths retain their original analytics classification", () => {
|
||||
const paths: [string, string, string][] = [
|
||||
["/list/romance/", "/dramas/romance/", "list"],
|
||||
["/play/a%2Fb/123/", "/drama/a%2Fb/123/", "play"],
|
||||
["/series-play/film/", "/series/watch/film/", "series_play"],
|
||||
["/series/all/all/1/", "/series/all/all/1/", "series"],
|
||||
["/topic/", "/collections/", "topic"],
|
||||
["/topic-detail/story/", "/collection/story/", "topic_detail"],
|
||||
["/rank/2/", "/rankings/2/", "rank"],
|
||||
["/popular-recommend/", "/featured/", "popular_recommend"],
|
||||
["/recently-added/", "/latest/", "recently_added"],
|
||||
["/more-list/recommend/", "/curated/dramas/recommend/", "more_list"],
|
||||
["/more-series-list/recommend/", "/curated/series/recommend/", "more_series_list"],
|
||||
["/search-mobile/", "/discover/search/", "search"],
|
||||
["/home-road/", "/return-home/", "home_road"],
|
||||
];
|
||||
for (const [legacy, current, key] of paths) {
|
||||
assert.deepEqual(trackerPage(current), trackerPage(legacy), current);
|
||||
assert.equal(trackerPage(current)[0], key, current);
|
||||
}
|
||||
assert.equal(trackerPage("/unknown/")[0], "internal_other");
|
||||
});
|
||||
Reference in New Issue
Block a user