diff --git a/README.md b/README.md index 316fce37..20aee2d4 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/refactor/ai-development-guardrails.md b/docs/refactor/ai-development-guardrails.md index 1222a819..6ca721eb 100644 --- a/docs/refactor/ai-development-guardrails.md +++ b/docs/refactor/ai-development-guardrails.md @@ -53,6 +53,7 @@ - 严禁把旧 PocketBase 字段结构作为新系统长期事实来源。 - 严禁为学生头像新增上传链路、对象存储签名、第三方头像落库或后台批量导入字段;学生头像只允许 `avatarPreset=male/female` 默认资源,租户后台学生接口也不能写头像 URL 或平台主角色字段。 - 严禁在学生端默认请求或展示排行榜;排行榜接口只作为租户显式开启后的活动能力,默认学习激励以后台配置的勋章自动发放为主。 +- 严禁新增 Taro 页面后不注册路由、不更新启动页/静态烟测入口或不更新前端交接清单;页面变更后必须运行 `node scripts/taro-route-contract-test.js`,确保 `app.config.ts`、`pages/**/index.tsx`、启动页跳转和交接文档一致。 ## Supabase 直连表的准入条件 diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index 85350df2..a671bd01 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -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`。当前环境没有暴露安全扫描工具时只能标记待补,不能把模板占位当完成。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index a1c5e2cc..6478bcab 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -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、督导触达联动和小程序兼容验证。 ### 第一批页面 diff --git a/package.json b/package.json index f5a5b792..9b0ef8a0 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/taro-route-contract-test.js b/scripts/taro-route-contract-test.js new file mode 100644 index 00000000..202b1605 --- /dev/null +++ b/scripts/taro-route-contract-test.js @@ -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)`);