完成50%

This commit is contained in:
2026-07-23 08:23:59 +08:00
parent 9b0ad62df4
commit f1edc6b533
218 changed files with 24318 additions and 5514 deletions
+293
View File
@@ -0,0 +1,293 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$pagesPath = Join-Path $root 'pages.json'
$routesPath = Join-Path $root 'utils/navigation-routes.js'
if (-not (Test-Path -LiteralPath $routesPath -PathType Leaf)) {
throw '缺少路由语义注册表:utils/navigation-routes.js'
}
<# NODE-CONTRACT-BEGIN
"use strict";
const assert = require("assert");
const fs = require("fs");
(async () => {
const [pagesPath, routesPath] = process.argv.slice(2);
const pages = JSON.parse(fs.readFileSync(pagesPath, "utf8"));
const routesSource = fs.readFileSync(routesPath, "utf8");
const moduleUrl = `data:text/javascript;base64,${Buffer.from(routesSource).toString("base64")}`;
const { NOTICE_TARGETS, ROUTES, ROOT_ROUTE_KEYS, getRoute, getRouteKeyByPath } = await import(moduleUrl);
const registeredPaths = pages.pages.map(({ path }) => `/${path}`);
const expectedRouteKeys = registeredPaths.map((path) => {
const filename = path.split("/").pop();
const match = /^([a-z]\d{2})-/i.exec(filename);
assert(match, `页面文件名缺少语义编号:${path}`);
return match[1].toUpperCase();
});
const routeKeys = Object.keys(ROUTES);
const routePaths = routeKeys.map((routeKey) => ROUTES[routeKey].path);
assert.strictEqual(routeKeys.length, 52, "路由注册表必须恰好包含 52 条活动路由");
assert.deepStrictEqual(routeKeys, expectedRouteKeys, "路由键集合和顺序必须来自 pages.json 的页面编号");
assert.deepStrictEqual(routePaths, registeredPaths, "路由路径集合和顺序必须与 pages.json 精确一致");
assert(Object.isFrozen(ROUTES), "ROUTES 必须冻结");
assert(Object.isFrozen(ROOT_ROUTE_KEYS), "ROOT_ROUTE_KEYS 必须冻结");
assert(Object.isFrozen(NOTICE_TARGETS), "NOTICE_TARGETS 必须冻结");
assert.deepStrictEqual(Object.keys(NOTICE_TARGETS), ["GENEALOGY_REVIEW", "GENEALOGY_HOME"], "通知目标类型必须是封闭白名单");
assert.deepStrictEqual(
Object.fromEntries(Object.entries(NOTICE_TARGETS).map(([key, value]) => [key, { routeKey: value.routeKey, params: Array.from(value.params) }])),
{
GENEALOGY_REVIEW: { routeKey: "G10", params: ["genealogyId"] },
GENEALOGY_HOME: { routeKey: "G01", params: ["genealogyId"] },
},
"通知只能映射到登记业务语义及精确参数",
);
for (const target of Object.values(NOTICE_TARGETS)) {
assert(Object.isFrozen(target), "通知目标定义必须冻结");
assert(Object.isFrozen(target.params), "通知目标参数表必须冻结");
}
const routeFields = [
"path", "kind", "parent", "parentParamMap", "requiredParams", "optionalParams",
"allowedSources", "resultOperations",
];
const routeKinds = new Set(["auth-root", "root", "page", "flow", "single"]);
const forbiddenParams = new Set([
"previous", "returnUrl", "fallbackUrl", "targetUrl", "state", "count",
"saveResult", "step", "genealogyName", "sourceKey", "personName",
"giftId", "ritualId",
]);
const discoveredRoots = routeKeys.filter((routeKey) => ROUTES[routeKey].parent === null);
assert.strictEqual(discoveredRoots.length, 4, "活动路由必须保持四个互不伪造历史的根语义");
assert.deepStrictEqual(Array.from(ROOT_ROUTE_KEYS), ["A01", "G01", "F01", "M01"], "根安全边界必须固定");
assert.deepStrictEqual(Array.from(ROOT_ROUTE_KEYS), discoveredRoots, "ROOT_ROUTE_KEYS 必须精确列出 parent=null 的路由");
assert.strictEqual(ROUTES.A01.kind, "auth-root", "A01 必须是认证根语义");
assert.deepStrictEqual(Array.from(ROUTES.G03.optionalParams), [], "G03 不得保留未消费的 genealogyId 或 step 参数");
for (const routeKey of ["G01", "G05", "G09", "T01"]) {
assert.deepStrictEqual(Array.from(ROUTES[routeKey].resultOperations), [], `${routeKey} 不得预留没有真实 API 生产者的结果能力`);
}
assert.deepStrictEqual(
Array.from(ROUTES.T03.resultOperations),
["member-open-requested"],
"T03 当前只能保留 single 页面激活实际使用的页内打开结果",
);
const expectedFamilyParams = {
F01: { required: [], optional: ["genealogyId"] },
F02: { required: ["genealogyId"], optional: [] },
F03: { required: ["genealogyId", "feedId"], optional: [] },
F04: { required: ["genealogyId"], optional: [] },
F05: { required: ["genealogyId", "articleId"], optional: [] },
F06: { required: ["genealogyId", "mode"], optional: ["articleId"] },
F07: { required: ["genealogyId"], optional: [] },
F08: { required: ["genealogyId", "albumId"], optional: [] },
F09: { required: ["genealogyId", "albumId"], optional: [] },
F10: { required: ["genealogyId"], optional: [] },
};
for (const [routeKey, expected] of Object.entries(expectedFamilyParams)) {
assert.deepStrictEqual(
Array.from(ROUTES[routeKey].requiredParams),
expected.required,
`${routeKey} 必填参数必须精确表达家谱复合身份`,
);
assert.deepStrictEqual(
Array.from(ROUTES[routeKey].optionalParams),
expected.optional,
`${routeKey} 可选参数不得保留身份旁路`,
);
assert.deepStrictEqual(
Array.from(ROUTES[routeKey].resultOperations),
[],
`${routeKey} 在真实写接口落地前不得预留伪成功结果`,
);
}
assert.deepStrictEqual(
Array.from(ROUTES.F08.allowedSources),
["F07"],
"F09 无结果返回 F08,不得伪装成新的 F08 打开来源",
);
const expectedRecordParams = {
R01: { required: ["genealogyId"], optional: [] },
R02: { required: ["genealogyId", "mode"], optional: ["personId"] },
R03: { required: ["genealogyId"], optional: [] },
R04: { required: ["genealogyId", "mode"], optional: ["relativeId"] },
R05: { required: ["genealogyId"], optional: [] },
R06: { required: ["genealogyId", "ceremonyId"], optional: [] },
R07: { required: ["genealogyId", "mode"], optional: ["ceremonyId"] },
R08: { required: ["genealogyId", "personId"], optional: [] },
R09: { required: ["genealogyId", "personId"], optional: [] },
R10: { required: ["genealogyId"], optional: [] },
R11: { required: ["genealogyId"], optional: [] },
};
for (const [routeKey, expected] of Object.entries(expectedRecordParams)) {
assert.deepStrictEqual(
Array.from(ROUTES[routeKey].requiredParams),
expected.required,
`${routeKey} 必填参数必须精确表达 R 系列复合身份`,
);
assert.deepStrictEqual(
Array.from(ROUTES[routeKey].optionalParams),
expected.optional,
`${routeKey} 可选参数不得保留旧 ID 或状态旁路`,
);
assert.deepStrictEqual(
Array.from(ROUTES[routeKey].resultOperations),
[],
`${routeKey} 在真实写接口落地前不得预留伪成功结果`,
);
}
for (const routeKey of ["N01", "N02", "M01", "M02", "M03", "M04", "M05", "M06", "M07", "M08", "M09", "M10"]) {
assert.deepStrictEqual(
Array.from(ROUTES[routeKey].resultOperations),
[],
`${routeKey} 在真实写接口落地前不得预留伪成功结果`,
);
}
assert(ROUTES.G09.allowedSources.includes("G05"), "G05 审核中公开预览必须能进入 G09 申请进度");
for (const routeKey of ["G01", "F01", "M01"]) {
assert.strictEqual(ROUTES[routeKey].kind, "root", `${routeKey} 必须是业务根语义`);
}
assert.deepStrictEqual(routeKeys.filter((routeKey) => ROUTES[routeKey].kind === "single"), ["T03"], "T03 必须是唯一单实例页面");
for (const [index, routeKey] of routeKeys.entries()) {
const route = ROUTES[routeKey];
assert(Object.isFrozen(route), `${routeKey} 路由项必须冻结`);
assert.deepStrictEqual(Object.keys(route).sort(), [...routeFields].sort(), `${routeKey} 路由字段集合不精确`);
assert(routeKinds.has(route.kind), `${routeKey} 页面类型非法`);
assert.strictEqual(route.path, registeredPaths[index], `${routeKey} 路径与 pages.json 不一致`);
for (const field of ["requiredParams", "optionalParams", "allowedSources", "resultOperations"]) {
const values = route[field];
assert(Array.isArray(values), `${routeKey}.${field} 必须是数组`);
assert(Object.isFrozen(values), `${routeKey}.${field} 必须冻结`);
assert.strictEqual(new Set(values).size, values.length, `${routeKey}.${field} 不得重复`);
assert(values.every((value) => typeof value === "string" && value.length > 0), `${routeKey}.${field} 只能包含非空字符串`);
}
assert(Object.isFrozen(route.parentParamMap), `${routeKey}.parentParamMap 必须冻结`);
assert(route.parentParamMap && Object.getPrototypeOf(route.parentParamMap) === Object.prototype, `${routeKey}.parentParamMap 必须是普通对象`);
const businessParams = [...route.requiredParams, ...route.optionalParams];
assert.strictEqual(new Set(businessParams).size, businessParams.length, `${routeKey} 必填与可选参数不得重复`);
for (const name of businessParams) {
assert(/^[A-Za-z][A-Za-z0-9]*$/.test(name), `${routeKey} 参数名非法:${name}`);
assert(!forbiddenParams.has(name), `${routeKey} 不得声明旧参数 ${name}`);
}
for (const sourceKey of route.allowedSources) {
assert(getRoute(sourceKey), `${routeKey} 引用了不存在的来源 ${sourceKey}`);
assert.notStrictEqual(sourceKey, routeKey, `${routeKey} 不得把自身声明为来源`);
}
for (const operation of route.resultOperations) {
assert(/^[a-z]+(?:-[a-z]+)*$/.test(operation), `${routeKey} 结果操作必须使用小写 kebab-case${operation}`);
}
if (route.parent === null) {
assert(["auth-root", "root"].includes(route.kind), `${routeKey} 根语义类型非法`);
assert.strictEqual(route.allowedSources.length, 0, `${routeKey} 根语义不得伪造允许来源`);
} else {
assert(typeof route.parent === "string" && getRoute(route.parent), `${routeKey} 引用了不存在的父页`);
assert(routeKinds.has(route.kind) && !["auth-root", "root"].includes(route.kind), `${routeKey} 非根页面类型非法`);
assert(routeKeys.indexOf(route.parent) < index, `${routeKey} 父页必须先于子页注册`);
assert(route.allowedSources.length > 0, `${routeKey} 非根页面必须声明至少一个允许来源`);
}
const mappedChildParams = [];
for (const [parentParam, childParam] of Object.entries(route.parentParamMap)) {
const parent = getRoute(route.parent);
assert(parent, `${routeKey} 声明父参数映射但没有父页`);
assert([...parent.requiredParams, ...parent.optionalParams].includes(parentParam), `${routeKey} 的父参数 ${parentParam} 未在父页声明`);
assert(businessParams.includes(childParam), `${routeKey} 的子参数 ${childParam} 未在本页声明`);
mappedChildParams.push(childParam);
}
assert.strictEqual(new Set(mappedChildParams).size, mappedChildParams.length, `${routeKey} 同一子参数不得映射到多个父参数`);
}
// 每条父链必须无环并最终到达已登记根页;这比维护第二张父页表更能约束结构安全。
for (const routeKey of routeKeys) {
const visited = new Set();
let cursor = routeKey;
while (cursor !== null) {
assert(!visited.has(cursor), `${routeKey} 父链存在环:${cursor}`);
visited.add(cursor);
const route = getRoute(cursor);
assert(route, `${routeKey} 父链包含未知路由:${cursor}`);
cursor = route.parent;
}
const terminal = Array.from(visited).pop();
assert(ROOT_ROUTE_KEYS.includes(terminal), `${routeKey} 父链未落到登记根页`);
}
// allowedSources 是进入权限边。所有页面都必须能从四根之一沿合法入口到达,
// 防止若干非根页面组成自洽但与真实产品入口隔离的来源环。
const sourceReachable = new Set(ROOT_ROUTE_KEYS);
let sourceReachabilityChanged = true;
while (sourceReachabilityChanged) {
sourceReachabilityChanged = false;
for (const routeKey of routeKeys) {
if (sourceReachable.has(routeKey)) continue;
if (ROUTES[routeKey].allowedSources.some((sourceKey) => sourceReachable.has(sourceKey))) {
sourceReachable.add(routeKey);
sourceReachabilityChanged = true;
}
}
}
assert.deepStrictEqual(Array.from(sourceReachable).sort(), routeKeys.slice().sort(), "存在无法从任何根语义合法进入的页面");
assert.strictEqual(getRoute(routeKeys[0]), ROUTES[routeKeys[0]], "getRoute 必须返回注册表中的同一冻结对象");
assert.strictEqual(getRoute("UNKNOWN"), null, "getRoute 对未知键必须返回 null");
for (const inheritedKey of ["constructor", "toString", "valueOf", "__proto__"]) {
assert.strictEqual(getRoute(inheritedKey), null, `getRoute 不得把原型链键 ${inheritedKey} 当成活动路由`);
}
for (const invalidKey of [null, 42, [routeKeys[0]], new String(routeKeys[0]), { toString: () => routeKeys[0] }]) {
assert.strictEqual(getRoute(invalidKey), null, "getRoute 只接受原始字符串路由键");
}
const samplePath = routePaths[Math.floor(routePaths.length / 2)];
const sampleKey = routeKeys[Math.floor(routeKeys.length / 2)];
assert.strictEqual(getRouteKeyByPath(samplePath.slice(1)), sampleKey, "路径查询必须接受无前导斜杠路径");
assert.strictEqual(getRouteKeyByPath(samplePath), sampleKey, "路径查询必须接受有前导斜杠路径");
assert.strictEqual(getRouteKeyByPath("/pages/unknown"), null, "未知路径必须返回 null");
for (const invalidPath of [null, 42, [samplePath], { toString: () => samplePath }]) {
assert.strictEqual(getRouteKeyByPath(invalidPath), null, "路径查询只接受原始字符串路径");
}
console.log(`NAVIGATION-ROUTES-CONTRACT PASS ROUTES=${routeKeys.length}`);
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
NODE-CONTRACT-END #>
# 注册表是导航语义的唯一数据所有者。本合同真实导入 ESM,并以 pages.json 交叉和
# 通用图不变量验收边界,避免用一张必须同步维护的同构影子表复制合同。
$contractPath = Join-Path $root 'tests/navigation-routes-contract.ps1'
$contractFile = Get-Content -Raw -Encoding UTF8 -LiteralPath $contractPath
$startMarker = '<# NODE-CONTRACT-BEGIN'
$endMarker = 'NODE-CONTRACT-END #>'
$startIndex = $contractFile.IndexOf($startMarker)
$endIndex = $contractFile.IndexOf($endMarker)
if ($startIndex -lt 0 -or $endIndex -le $startIndex) {
throw '无法读取内嵌的路由注册表运行合同'
}
$startIndex += $startMarker.Length
$nodeContract = $contractFile.Substring(
$startIndex,
$endIndex - $startIndex
).Trim()
$previousErrorActionPreference = $ErrorActionPreference
$ErrorActionPreference = 'Continue'
$output = $nodeContract | & node - $pagesPath $routesPath 2>&1
$nodeExitCode = $LASTEXITCODE
$ErrorActionPreference = $previousErrorActionPreference
if ($nodeExitCode -ne 0) {
throw ($output -join "`n")
}
Write-Output ($output -join "`n")