test: add taro route contract guardrail

This commit is contained in:
Codex
2026-07-01 01:22:34 +08:00
parent 7c0e55e419
commit 7559a33459
6 changed files with 111 additions and 2 deletions

View File

@@ -141,9 +141,12 @@ apps/taro/dist/h5-platform-admin
构建后可以先跑静态启动烟测,确认三套 H5 产物能被普通静态服务器托管、`runtime-config.json` 只含公开字段、JS/CSS 资源不 404并用 mock 后端验证 `/api/tenant/resolve` 契约:
```bash
node scripts/taro-route-contract-test.js
npm run smoke:taro:h5
```
`taro-route-contract-test` 会校验 `apps/taro/src/app.config.ts`、真实 `pages/**/index.tsx`、启动页三端跳转、H5 静态烟测入口和前端交接文档中的页面引用保持一致。新增或删除页面时必须同步路由和文档,避免 H5/小程序构建后才发现入口漂移。
H5 线上推荐每个静态目录放独立 `runtime-config.json` 覆盖公开配置,避免 API/Auth 域名变化时重打包:
```text

View File

@@ -53,6 +53,7 @@
- 严禁把旧 PocketBase 字段结构作为新系统长期事实来源。
- 严禁为学生头像新增上传链路、对象存储签名、第三方头像落库或后台批量导入字段;学生头像只允许 `avatarPreset=male/female` 默认资源,租户后台学生接口也不能写头像 URL 或平台主角色字段。
- 严禁在学生端默认请求或展示排行榜;排行榜接口只作为租户显式开启后的活动能力,默认学习激励以后台配置的勋章自动发放为主。
- 严禁新增 Taro 页面后不注册路由、不更新启动页/静态烟测入口或不更新前端交接清单;页面变更后必须运行 `node scripts/taro-route-contract-test.js`,确保 `app.config.ts``pages/**/index.tsx`、启动页跳转和交接文档一致。
## Supabase 直连表的准入条件

View File

@@ -41,6 +41,7 @@
- 可以接入迁移期短信登录和 `tk_` session用于本地/内网联调。
- H5 可以直接用 Supabase Auth access token 调 `apps/api`;后端已支持 JWT 验签和业务用户映射。
- `apps/taro/src/services/api.ts` 现在默认 Supabase JWT 优先、迁移期 `tk_` 兜底;公共接口必须显式 `authMode='none'`。页面不要手写 `Authorization``x-tenant-id``x-user-id`
- 新增、删除或重命名 Taro 页面时必须同步 `apps/taro/src/app.config.ts`、启动页跳转、H5 静态烟测入口和本文页面清单,并运行 `node scripts/taro-route-contract-test.js`。该脚本会阻断“页面文件存在但未注册”“路由注册但文件缺失”“启动页或烟测跳到不存在页面”的漂移。
- H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session微信小程序端先验证运行时兼容性业务数据默认仍走 `apps/api`
- H5 生产部署优先用每个静态目录自己的 `runtime-config.json` 配置 `apiBaseUrl``supabaseUrl``supabasePublishableKey``tenantCode`;不要为了换域名重打包,也不要把任何 service role、数据库、支付、短信、对象存储密钥放进该文件。
- 上线前需要把三套 H5 构建、`npm run smoke:taro:h5` 静态启动烟测、严格 `taro-h5-release-guardrails-test --require-runtime-config``runtime-config.json` 人工复核、真实 Auth/RLS、迁移 dry-run、对象存储、支付对账和真实 `@codex-security` 结果写入 `production-launch-evidence.json`,并通过 `npm run launch:gate`。当前环境没有暴露安全扫描工具时只能标记待补,不能把模板占位当完成。

View File

@@ -209,7 +209,7 @@
- 旧原生小程序前端位于 `F:\project\参考\旧题库小程序前端文件`,后续 Taro 小程序/H5 交互补齐时只作为交互、页面状态和微信平台能力参考,不继承旧直连 PocketBase 或旧鉴权逻辑。
- 租户通过域名、小程序配置或启动参数解析。
- 页面主题、品牌、功能开关都从后端租户配置读取;学生端和后台只消费 `/api/tenant/resolve` 的已发布 `branding.theme/publicAssets`,租户后台草稿只通过 `/api/tenant-admin/theme` 查看。
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client已补 `apps/taro/src/index.html` 模板,三套 H5 构建都会生成 `index.html`;已补 `scripts/taro-h5-release-guardrails-test.js` 扫描源码、发布产物和 runtime-config 边界,阻断旧 PocketBase、`x-user-id`、平台本地 key、数据库连接串服务端密钥形态进入前端产物。学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、独立消息中心、积分任务/兑换/积分明细第一版、男女预设头像选择 UI、题干/选项/解析/知识手册 RichContent 安全渲染、知识手册章节内搜索/安全摘要高亮/目录定位、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、租户详情、状态变更、账务资料维护、平台员工列表/创建/编辑/禁用恢复、平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、催缴外部通知渠道/事件摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、学习督导自动化/每日规则保存、主题模板预览/发布、角色模板、成员绑定、CRM/分佣操作台、积分任务/兑换操作台和用户通知查看第一版;下一步补小程序公式真机验收、题图资源字段化、状态管理、首屏包体优化、更细数据范围 UI、督导触达联动和小程序兼容验证。
- 当前已完成 H5 学生端、租户后台、平台后台三套构建入口和统一 API client已补 `apps/taro/src/index.html` 模板,三套 H5 构建都会生成 `index.html`;已补 `scripts/taro-route-contract-test.js``scripts/taro-h5-release-guardrails-test.js``scripts/taro-h5-static-smoke.js`,分别校验页面路由契约、源码/产物/runtime-config 安全边界和 H5 静态启动契约,阻断旧 PocketBase、`x-user-id`、平台本地 key、数据库连接串服务端密钥形态和页面路由漂移进入前端产物。学生端、租户后台、平台后台都有第一批真实 API 页面;学生端已补地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、错题/收藏复习、题目反馈、视频解析、练习/模考报告、个人中心学习报告可视化、会员收银台、订单详情、售后入口、独立消息中心、积分任务/兑换/积分明细第一版、男女预设头像选择 UI、题干/选项/解析/知识手册 RichContent 安全渲染、知识手册章节内搜索/安全摘要高亮/目录定位、H5 KaTeX 公式渲染、私有资源 ID 题图短签名渲染、逐题复盘、背单词卡片学习/发音/收藏练习第一版;平台后台已接入创建租户、租户详情、状态变更、账务资料维护、平台员工列表/创建/编辑/禁用恢复、平台审计查询/CSV 导出、开放审计告警展示/确认/解决、审计告警外部通知渠道/事件状态摘要、催缴外部通知渠道/事件摘要、订阅、订阅账单候选/dry-run/批量生成、自动计费生成结果查看、收款、用量和公共题库授权第一版写操作;租户后台已接权限驱动工作台、学生运营操作台、学习督导自动化/每日规则保存、主题模板预览/发布、角色模板、成员绑定、CRM/分佣操作台、积分任务/兑换操作台和用户通知查看第一版;下一步补小程序公式真机验收、题图资源字段化、状态管理、首屏包体优化、更细数据范围 UI、督导触达联动和小程序兼容验证。
### 第一批页面

View File

@@ -55,7 +55,7 @@
"test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js",
"test:worker:student-supervision": "npm run db:smoke-seed && npm run build:worker && node scripts/student-supervision-worker-integration-test.js",
"test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js",
"test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js",
"test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js",
"test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js",
"test:launch-gate": "node scripts/production-launch-gate-test.js",
"smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js",

View File

@@ -0,0 +1,104 @@
import assert from 'node:assert/strict';
import fs from 'node:fs';
import path from 'node:path';
const repoRoot = process.cwd();
const taroSrc = path.join(repoRoot, 'apps', 'taro', 'src');
const pagesRoot = path.join(taroSrc, 'pages');
const appConfigPath = path.join(taroSrc, 'app.config.ts');
const bootstrapPath = path.join(pagesRoot, 'bootstrap', 'index.tsx');
const h5StaticSmokePath = path.join(repoRoot, 'scripts', 'taro-h5-static-smoke.js');
const frontendHandoffPath = path.join(repoRoot, 'docs', 'refactor', 'frontend-handoff-index.md');
function readText(filePath) {
return fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
}
function normalizeSlashes(value) {
return value.replace(/\\/g, '/');
}
function walkIndexPages(dir) {
const entries = fs.readdirSync(dir, { withFileTypes: true });
const files = [];
for (const entry of entries) {
const entryPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
files.push(...walkIndexPages(entryPath));
continue;
}
if (entry.name === 'index.tsx') files.push(entryPath);
}
return files;
}
function parseAppRoutes() {
const text = readText(appConfigPath);
const match = text.match(/pages\s*:\s*\[([\s\S]*?)\]/m);
assert.ok(match, 'apps/taro/src/app.config.ts must define pages: [...]');
return [...match[1].matchAll(/['"`]([^'"`]+)['"`]/g)]
.map(item => item[1].trim())
.filter(route => route.startsWith('pages/'));
}
function routeFromIndexFile(filePath) {
return normalizeSlashes(path.relative(taroSrc, filePath)).replace(/\/index\.tsx$/, '/index');
}
function parseLiteralPagePaths(text) {
return [...text.matchAll(/['"`](\/pages\/[^'"`?#]+)['"`]/g)]
.map(item => item[1].replace(/^\/+/, ''))
.filter(route => route.endsWith('/index'));
}
function uniqueSorted(items) {
return [...new Set(items)].sort();
}
const appRoutes = parseAppRoutes();
const appRouteSet = new Set(appRoutes);
const actualRoutes = walkIndexPages(pagesRoot).map(routeFromIndexFile);
const actualRouteSet = new Set(actualRoutes);
assert.equal(appRoutes.length, appRouteSet.size, 'app.config.ts must not contain duplicate page routes');
assert.equal(appRoutes[0], 'pages/bootstrap/index', 'The first Taro page must be the bootstrap page for tenant/runtime config resolution');
const missingFiles = appRoutes.filter(route => !actualRouteSet.has(route));
const unregisteredPages = actualRoutes.filter(route => !appRouteSet.has(route));
assert.deepEqual(missingFiles, [], 'Every app.config.ts route must have a matching pages/**/index.tsx file');
assert.deepEqual(unregisteredPages, [], 'Every pages/**/index.tsx file must be registered in app.config.ts');
const expectedPortalLandingRoutes = [
'pages/platform-admin/workbench/index',
'pages/student/home/index',
'pages/tenant-admin/workbench/index',
];
const bootstrapPageRoutes = parseLiteralPagePaths(readText(bootstrapPath));
const bootstrapLandingRoutes = bootstrapPageRoutes.filter(route => route !== 'pages/bootstrap/index');
assert.deepEqual(
uniqueSorted(bootstrapLandingRoutes),
expectedPortalLandingRoutes,
'Bootstrap landing routes must stay explicit for the three H5 portals',
);
for (const route of bootstrapPageRoutes) {
assert.ok(appRouteSet.has(route), `Bootstrap route is not registered in app.config.ts: ${route}`);
}
const staticSmokeLandingRoutes = parseLiteralPagePaths(readText(h5StaticSmokePath));
assert.deepEqual(
uniqueSorted(staticSmokeLandingRoutes),
uniqueSorted(bootstrapLandingRoutes),
'H5 static smoke landing routes must match bootstrap landing routes',
);
for (const route of staticSmokeLandingRoutes) {
assert.ok(appRouteSet.has(route), `H5 static smoke landing route is not registered in app.config.ts: ${route}`);
}
const handoffRoutes = [...readText(frontendHandoffPath).matchAll(/apps\/taro\/src\/(pages\/[^`|\s]+\/index\.tsx)/g)]
.map(item => item[1].replace(/\/index\.tsx$/, '/index'));
for (const route of uniqueSorted(handoffRoutes)) {
assert.ok(appRouteSet.has(route), `Frontend handoff doc references a page that is not registered in app.config.ts: ${route}`);
assert.ok(actualRouteSet.has(route), `Frontend handoff doc references a page file that is missing: ${route}`);
}
console.log(`[PASS] Taro route contract (${appRoutes.length} registered pages)`);