forked from wangziqi/gongxue-base
test: add taro h5 static launch smoke
This commit is contained in:
14
README.md
14
README.md
@@ -138,6 +138,12 @@ apps/taro/dist/h5-platform-admin
|
|||||||
|
|
||||||
推荐分别部署到学生端域名、租户后台域名、平台后台域名;三者共用 `apps/taro/src/services/api.ts` 请求层,业务数据默认调用 `apps/api`,不要在页面里直写 Supabase 表。
|
推荐分别部署到学生端域名、租户后台域名、平台后台域名;三者共用 `apps/taro/src/services/api.ts` 请求层,业务数据默认调用 `apps/api`,不要在页面里直写 Supabase 表。
|
||||||
|
|
||||||
|
构建后可以先跑静态启动烟测,确认三套 H5 产物能被普通静态服务器托管、`runtime-config.json` 只含公开字段、JS/CSS 资源不 404,并用 mock 后端验证 `/api/tenant/resolve` 契约:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm run smoke:taro:h5
|
||||||
|
```
|
||||||
|
|
||||||
H5 线上推荐每个静态目录放独立 `runtime-config.json` 覆盖公开配置,避免 API/Auth 域名变化时重打包:
|
H5 线上推荐每个静态目录放独立 `runtime-config.json` 覆盖公开配置,避免 API/Auth 域名变化时重打包:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
@@ -563,10 +569,11 @@ Taro H5 发布产物守卫:
|
|||||||
npm run build:taro:h5:student
|
npm run build:taro:h5:student
|
||||||
npm run build:taro:h5:tenant
|
npm run build:taro:h5:tenant
|
||||||
npm run build:taro:h5:platform
|
npm run build:taro:h5:platform
|
||||||
|
npm run smoke:taro:h5
|
||||||
node scripts\taro-h5-release-guardrails-test.js --require-dist
|
node scripts\taro-h5-release-guardrails-test.js --require-dist
|
||||||
```
|
```
|
||||||
|
|
||||||
该守卫会确认三套 H5 目录存在 `index.html`,并扫描源码/产物是否混入旧 PocketBase、`x-user-id`、平台本地 key、数据库连接串或服务端密钥形态。正式部署时还必须在每个 H5 目录根部放置对应的 `runtime-config.json`。
|
`smoke:taro:h5` 会启动临时静态服务器和 mock API,验证三套 H5 的 `index.html`、静态资源、history fallback、公开 runtime config 和租户解析契约。`taro-h5-release-guardrails-test` 会确认三套 H5 目录存在 `index.html`,并扫描源码/产物是否混入旧 PocketBase、`x-user-id`、平台本地 key、数据库连接串或服务端密钥形态。正式部署时还必须在每个 H5 目录根部放置对应的 `runtime-config.json`。
|
||||||
|
|
||||||
最近一次本地真实迁移库数据规模约为 74,117 题、1,601 个题目合集、3,106 个练习蓝图、3,505 个单词、2,678 条知识手册、3,690 个用户、113,810 条答题记录、38,207 条错题和 458 条权益。压测 worker 是无停顿请求流,不能直接等同于真实在线学生数;前端完成后需要用真实页面埋点估算单个学生平均 RPS,再折算在线容量。
|
最近一次本地真实迁移库数据规模约为 74,117 题、1,601 个题目合集、3,106 个练习蓝图、3,505 个单词、2,678 条知识手册、3,690 个用户、113,810 条答题记录、38,207 条错题和 458 条权益。压测 worker 是无停顿请求流,不能直接等同于真实在线学生数;前端完成后需要用真实页面埋点估算单个学生平均 RPS,再折算在线容量。
|
||||||
|
|
||||||
@@ -574,6 +581,9 @@ node scripts\taro-h5-release-guardrails-test.js --require-dist
|
|||||||
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||||
| 30 | 120s | 0% | 108,336 | 0.00% | 897.04 req/s | 68.32 ms | 84.50 ms |
|
| 30 | 120s | 0% | 108,336 | 0.00% | 897.04 req/s | 68.32 ms | 84.50 ms |
|
||||||
| 50 | 60s | 10% | 52,979 | 0.00% | 870.42 req/s | 104.92 ms | 136.22 ms |
|
| 50 | 60s | 10% | 52,979 | 0.00% | 870.42 req/s | 104.92 ms | 136.22 ms |
|
||||||
|
| 50 | 60s | 10% | 51,425 | 0.00% | 845.06 req/s | 110.39 ms | 138.79 ms |
|
||||||
|
| 100 | 60s | 8% | 51,103 | 0.00% | 838.42 req/s | 210.07 ms | 272.15 ms |
|
||||||
|
| 150 | 60s | 6% | 44,889 | 0.00% | 735.22 req/s | 357.74 ms | 469.28 ms |
|
||||||
| 30 | 120s | 10% | 112,896 | 0.00% | 934.74 req/s | 64.26 ms | 81.14 ms |
|
| 30 | 120s | 10% | 112,896 | 0.00% | 934.74 req/s | 64.26 ms | 81.14 ms |
|
||||||
| 50 | 120s | 10% | 92,737 | 0.00% | 767.24 req/s | 121.51 ms | 159.12 ms |
|
| 50 | 120s | 10% | 92,737 | 0.00% | 767.24 req/s | 121.51 ms | 159.12 ms |
|
||||||
| 100 | 120s | 8% | 84,608 | 0.00% | 699.27 req/s | 254.69 ms | 331.84 ms |
|
| 100 | 120s | 8% | 84,608 | 0.00% | 699.27 req/s | 254.69 ms | 331.84 ms |
|
||||||
@@ -587,7 +597,7 @@ npm run perf:summary -- --input docs/refactor/performance-reports/api-benchmark-
|
|||||||
|
|
||||||
使用 `--allow-writes` 时会输出 `capacityObservation`,不输出 `launchGateCheck`,不能把写入场景误填成生产上线门禁的只读证据。
|
使用 `--allow-writes` 时会输出 `capacityObservation`,不输出 `launchGateCheck`,不能把写入场景误填成生产上线门禁的只读证据。
|
||||||
|
|
||||||
本地结论:100 个无停顿 worker 内 P95 仍低于 300ms;150 worker 零错误但 P95 已明显上升,可作为本机 Docker 环境的压力拐点参考。最新只读 30 worker/120s 为 897.04 req/s,粗略按单学生 0.05 到 0.2 req/s 可换算为 4,485 到 17,940 名活跃在线学生请求吞吐,但这不是生产 SLA。当前 PostgreSQL evidence 提醒本地默认库仍是 `jit=on` 且 `statement_timeout`、`idle_in_transaction_session_timeout`、`lock_timeout` 未设置,上云必须按调参文档复核。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要见:
|
本地结论:100 个无停顿 worker 内 P95 仍低于 300ms;150 worker 零错误但 P95 已明显上升,可作为本机 Docker 环境的压力拐点参考。最新只读 30 worker/120s 为 897.04 req/s,最新混合读写 100 worker/60s/8% 写入为 838.42 req/s、P95 210.07ms、0 错误。粗略按单学生 0.05 到 0.2 req/s 可换算为数千到一万多名活跃在线学生请求吞吐,但这不是生产 SLA。当前 PostgreSQL evidence 提醒本地默认库仍是 `jit=on` 且 `statement_timeout`、`idle_in_transaction_session_timeout`、`lock_timeout` 未设置,上云必须按调参文档复核。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要见:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
docs/refactor/performance-benchmark-summary-20260630.md
|
docs/refactor/performance-benchmark-summary-20260630.md
|
||||||
|
|||||||
@@ -21,13 +21,14 @@ apps/taro/dist/h5-platform-admin
|
|||||||
|
|
||||||
可以分别部署到学生端域名、租户后台域名、平台后台域名。三个入口共用 `src/services/api.ts`,不得在页面中散写 `Taro.request`。
|
可以分别部署到学生端域名、租户后台域名、平台后台域名。三个入口共用 `src/services/api.ts`,不得在页面中散写 `Taro.request`。
|
||||||
|
|
||||||
H5 入口模板在 `src/index.html`。构建后每个目录都必须有 `index.html`,否则静态 Web 不能上线。发布前运行:
|
H5 入口模板在 `src/index.html`。构建后每个目录都必须有 `index.html`,否则静态 Web 不能上线。发布前从仓库根目录运行:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node ../../scripts/taro-h5-release-guardrails-test.js --require-dist
|
npm run smoke:taro:h5
|
||||||
|
node scripts/taro-h5-release-guardrails-test.js --require-dist
|
||||||
```
|
```
|
||||||
|
|
||||||
该守卫会扫描三套 H5 产物和运行配置边界,阻断旧 PocketBase 引用、`x-user-id`、平台本地 key、数据库连接串和服务端密钥形态进入前端产物。正式部署时仍需要在每个 H5 目录根部放置对应的 `runtime-config.json`。
|
`smoke:taro:h5` 会用临时静态服务器验证三套 H5 的 `index.html`、JS/CSS 资源、history fallback、公开 `runtime-config.json` 和 `/api/tenant/resolve` 契约。发布守卫会扫描三套 H5 产物和运行配置边界,阻断旧 PocketBase 引用、`x-user-id`、平台本地 key、数据库连接串和服务端密钥形态进入前端产物。正式部署时仍需要在每个 H5 目录根部放置对应的 `runtime-config.json`。
|
||||||
|
|
||||||
## 当前学生端页面
|
## 当前学生端页面
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# 后续开发 TODO
|
# 后续开发 TODO
|
||||||
|
|
||||||
更新时间:2026-06-30
|
更新时间:2026-07-01
|
||||||
|
|
||||||
## 当前后端基线
|
## 当前后端基线
|
||||||
|
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
- 内容资源复检与安全扫描 worker 已完成:`apps/worker --job assets` 可复检 `content_assets` 中的托管对象元数据,并执行内置 `metadata_rules` 和可选外部 HTTP scanner;正常资源写回复检/扫描证据,异常资源自动置为 `failed/skipped + draft` 或 `security_scan_status=failed`,外部 scanner 不可用默认 fail-closed,并写入审计、扫描事件和安全标记。
|
- 内容资源复检与安全扫描 worker 已完成:`apps/worker --job assets` 可复检 `content_assets` 中的托管对象元数据,并执行内置 `metadata_rules` 和可选外部 HTTP scanner;正常资源写回复检/扫描证据,异常资源自动置为 `failed/skipped + draft` 或 `security_scan_status=failed`,外部 scanner 不可用默认 fail-closed,并写入审计、扫描事件和安全标记。
|
||||||
- 题库导出 worker 已完成:`apps/worker --job exports` 可抢占 `pdf/docx/daily_practice_zip` 导出任务,渲染 PDF/Word、水印或每日一练图片素材包,写入对象存储或本地开发存储,创建 `content_assets` 并回填 `assetId/hash/size`;`daily_practice` 已支持每日一练九宫格 metadata、PDF/Word 基础版式、9 张 PNG/SVG 卡片和拼图 ZIP。
|
- 题库导出 worker 已完成:`apps/worker --job exports` 可抢占 `pdf/docx/daily_practice_zip` 导出任务,渲染 PDF/Word、水印或每日一练图片素材包,写入对象存储或本地开发存储,创建 `content_assets` 并回填 `assetId/hash/size`;`daily_practice` 已支持每日一练九宫格 metadata、PDF/Word 基础版式、9 张 PNG/SVG 卡片和拼图 ZIP。
|
||||||
- 租户后台媒体运营报表已完成:`/api/tenant-content/media-analytics/summary`、`asset-events`、`video-events` 可按 7/30/90 天、资源、视频、用户和水印 traceId 查询资料访问、视频播放、拒绝访问、Top 资源/视频和日趋势;仅开放给 owner/admin/operator 或 `content:analytics:read` 权限角色,前端不会拿到签名 URL 或播放 token。
|
- 租户后台媒体运营报表已完成:`/api/tenant-content/media-analytics/summary`、`asset-events`、`video-events` 可按 7/30/90 天、资源、视频、用户和水印 traceId 查询资料访问、视频播放、拒绝访问、Top 资源/视频和日趋势;仅开放给 owner/admin/operator 或 `content:analytics:read` 权限角色,前端不会拿到签名 URL 或播放 token。
|
||||||
- 本地验证:`npm run check:refactor` 已通过。
|
- 本地验证:`npm run check:refactor` 已通过;三套 Taro H5 已补 `index.html` 构建模板、发布产物扫描和 `npm run smoke:taro:h5` 静态启动烟测,能验证发布目录、公开 runtime config、history fallback、静态资源和租户解析契约。
|
||||||
|
|
||||||
当前更适合进入前端联调前阅读的总览文档:
|
当前更适合进入前端联调前阅读的总览文档:
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
- 已补 `npm run launch:gate` 生产上线证据门禁和 `docs/refactor/production-launch-evidence.template.json` 模板;最终切换前必须把 readiness、远程 Auth、RLS、生产 dry-run、导入校验、`pb:import:sample` 业务抽样、真实数据 API 读路径压测、API/worker/Taro、运行时审计、`@codex-security`、备份/回滚/真实抽样/生产 provider 等证据填入本地 `production-launch-evidence.json` 并通过门禁。当前 Codex 环境未暴露可调用的 `@codex-security` 扫描工具时,该项只能标为待补,不能伪造完成。
|
- 已补 `npm run launch:gate` 生产上线证据门禁和 `docs/refactor/production-launch-evidence.template.json` 模板;最终切换前必须把 readiness、远程 Auth、RLS、生产 dry-run、导入校验、`pb:import:sample` 业务抽样、真实数据 API 读路径压测、API/worker/Taro、运行时审计、`@codex-security`、备份/回滚/真实抽样/生产 provider 等证据填入本地 `production-launch-evidence.json` 并通过门禁。当前 Codex 环境未暴露可调用的 `@codex-security` 扫描工具时,该项只能标为待补,不能伪造完成。
|
||||||
- 确认数据库迁移流程、备份恢复、日志、告警。
|
- 确认数据库迁移流程、备份恢复、日志、告警。
|
||||||
- 准备 API 容器部署和 Supabase 云端/自托管连接方案。
|
- 准备 API 容器部署和 Supabase 云端/自托管连接方案。
|
||||||
- 已补 `npm run perf:api:local`、`npm run perf:summary`、`npm run perf:postgres:evidence`、`npm run smoke:launch-persona` 和 `docs/refactor/performance-benchmark-runbook.md`,可在本地或云端对真实迁移数据做只读门禁、混合读写容量观察、PostgreSQL 调参证据和三类角色旅程烟测。2026-07-01 本地真实迁移库只读 30 worker/120s 为 108336 请求、0 错误、897.04 req/s、P95 68.32ms、P99 84.50ms;混合读写 50 worker/60s/10% 写入为 52979 请求、0 错误、870.42 req/s、P95 104.92ms、P99 136.22ms。
|
- 已补 `npm run perf:api:local`、`npm run perf:summary`、`npm run perf:postgres:evidence`、`npm run smoke:launch-persona`、`npm run smoke:taro:h5` 和 `docs/refactor/performance-benchmark-runbook.md`,可在本地或云端对真实迁移数据做只读门禁、混合读写容量观察、PostgreSQL 调参证据、三类角色旅程烟测和 H5 发布目录启动烟测。2026-07-01 本地真实迁移库只读 30 worker/120s 为 108336 请求、0 错误、897.04 req/s、P95 68.32ms、P99 84.50ms;混合读写复测 50 worker/60s/10% 写入为 51425 请求、0 错误、845.06 req/s、P95 110.39ms、P99 138.79ms;100 worker/60s/8% 写入为 51103 请求、0 错误、838.42 req/s、P95 210.07ms、P99 272.15ms;150 worker/60s/6% 写入为 44889 请求、0 错误、735.22 req/s、P95 357.74ms、P99 469.28ms,说明本地压力上沿仍在 100 到 150 个无停顿 worker 之间。
|
||||||
- 当前本地 PostgreSQL evidence 仍提示 `jit=on`、`statement_timeout=0`、`idle_in_transaction_session_timeout=0`、`lock_timeout=0`;上云后必须按 `docs/refactor/postgresql-4c16g-tuning.md` 调整参数并复跑 evidence。4 核 16G 正式容量报告需上云后按 6/30/50/100 阶梯并发复跑并归档到本地上线证据。
|
- 当前本地 PostgreSQL evidence 仍提示 `jit=on`、`statement_timeout=0`、`idle_in_transaction_session_timeout=0`、`lock_timeout=0`;上云后必须按 `docs/refactor/postgresql-4c16g-tuning.md` 调整参数并复跑 evidence。4 核 16G 正式容量报告需上云后按 6/30/50/100 阶梯并发复跑并归档到本地上线证据。
|
||||||
|
|
||||||
### P1 商用功能完善
|
### P1 商用功能完善
|
||||||
|
|||||||
@@ -44,13 +44,23 @@
|
|||||||
| 30 | 120s | 0% | 108,336 | 0.00% | 897.04 req/s | 68.32 ms | 84.50 ms | 通过只读上线门禁 |
|
| 30 | 120s | 0% | 108,336 | 0.00% | 897.04 req/s | 68.32 ms | 84.50 ms | 通过只读上线门禁 |
|
||||||
| 50 | 60s | 10% | 52,979 | 0.00% | 870.42 req/s | 104.92 ms | 136.22 ms | 通过混合读写观察线 |
|
| 50 | 60s | 10% | 52,979 | 0.00% | 870.42 req/s | 104.92 ms | 136.22 ms | 通过混合读写观察线 |
|
||||||
|
|
||||||
|
### 2026-07-01 H5 发布烟测后读写复核
|
||||||
|
|
||||||
|
本轮在新增 `npm run smoke:taro:h5` 后复跑,确认后端真实数据读写容量未受前端发布守卫改动影响。压测仍使用本地 Docker/Supabase 真实迁移库,排行榜未纳入默认负载。
|
||||||
|
|
||||||
|
| 并发 worker | 时长 | 刷题写入比例 | 请求数 | 错误率 | 吞吐 | P95 | P99 | 结论 |
|
||||||
|
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
|
||||||
|
| 50 | 60s | 10% | 51,425 | 0.00% | 845.06 req/s | 110.39 ms | 138.79 ms | 通过混合读写观察线 |
|
||||||
|
| 100 | 60s | 8% | 51,103 | 0.00% | 838.42 req/s | 210.07 ms | 272.15 ms | 本地舒适区内 |
|
||||||
|
| 150 | 60s | 6% | 44,889 | 0.00% | 735.22 req/s | 357.74 ms | 469.28 ms | 零错误但接近本机压力上沿 |
|
||||||
|
|
||||||
同轮 `npm run smoke:launch-persona` 已通过,覆盖普通学生 SVIP 后刷题、收藏、错题复习入口,租户管理员 dashboard/主题/学生/销售转化入口,平台管理员租户/套餐/审计入口,以及学生越权后台和跨租户访问拒绝。
|
同轮 `npm run smoke:launch-persona` 已通过,覆盖普通学生 SVIP 后刷题、收藏、错题复习入口,租户管理员 dashboard/主题/学生/销售转化入口,平台管理员租户/套餐/审计入口,以及学生越权后台和跨租户访问拒绝。
|
||||||
|
|
||||||
同轮 `npm run perf:postgres:evidence` 运行成功,但本地默认 PostgreSQL 仍有生产前必须调优的 warning:`jit=on`、`statement_timeout=0`、`idle_in_transaction_session_timeout=0`、`lock_timeout=0`。上云后要按 `docs/refactor/postgresql-4c16g-tuning.md` 调整并重启需要重启的参数,再复跑证据采集和压测。
|
同轮 `npm run perf:postgres:evidence` 运行成功,但本地默认 PostgreSQL 仍有生产前必须调优的 warning:`jit=on`、`statement_timeout=0`、`idle_in_transaction_session_timeout=0`、`lock_timeout=0`。上云后要按 `docs/refactor/postgresql-4c16g-tuning.md` 调整并重启需要重启的参数,再复跑证据采集和压测。
|
||||||
|
|
||||||
## 初步结论
|
## 初步结论
|
||||||
|
|
||||||
- 本地 Docker 环境下,100 个无停顿 worker 内 P95 仍低于 300ms,可以作为当前代码和索引状态的本地舒适区参考。
|
- 本地 Docker 环境下,100 个无停顿 worker 内 P95 仍低于 300ms,可以作为当前代码和索引状态的本地舒适区参考;2026-07-01 最新 100 worker/60s/8% 写入为 838.42 req/s、P95 210.07ms、0 错误。
|
||||||
- 150 个无停顿 worker 仍然 0 错误,但 P95 在不同轮次中接近或超过 300ms,已经能看到本地压力拐点。
|
- 150 个无停顿 worker 仍然 0 错误,但 P95 在不同轮次中接近或超过 300ms,已经能看到本地压力拐点。
|
||||||
- 按最新 897 req/s 只读吞吐粗略折算,如果未来前端真实埋点显示每名在线学生平均 0.05 到 0.2 req/s,则理论请求吞吐约对应 4,485 到 17,940 名活跃在线学生;按更保守的 700 req/s 估算约为 3,500 到 14,000 名。这只是吞吐换算,不是生产 SLA。
|
- 按最新 897 req/s 只读吞吐粗略折算,如果未来前端真实埋点显示每名在线学生平均 0.05 到 0.2 req/s,则理论请求吞吐约对应 4,485 到 17,940 名活跃在线学生;按更保守的 700 req/s 估算约为 3,500 到 14,000 名。这只是吞吐换算,不是生产 SLA。
|
||||||
- 正式容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、对象存储/CDN、真实前端请求节奏和生产网络下复跑。
|
- 正式容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、对象存储/CDN、真实前端请求节奏和生产网络下复跑。
|
||||||
|
|||||||
@@ -191,10 +191,11 @@ H5 正式回归时建议把前端登录态切到 Supabase Auth,并观察业务
|
|||||||
构建后再运行 H5 发布守卫脚本:
|
构建后再运行 H5 发布守卫脚本:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
npm run smoke:taro:h5
|
||||||
node scripts/taro-h5-release-guardrails-test.js --require-dist
|
node scripts/taro-h5-release-guardrails-test.js --require-dist
|
||||||
```
|
```
|
||||||
|
|
||||||
该脚本会检查三套 H5 产物是否存在 `index.html`,源码和产物是否混入 `x-user-id`、`x-platform-admin-key`、PocketBase 引用、数据库连接串、服务端密钥形态,并检查运行时配置示例只包含公开字段。若还没有把真实 `runtime-config.json` 放入静态目录,会显示 warning;正式发布前必须在每个 H5 目录根部补齐该文件。
|
`smoke:taro:h5` 会用临时静态服务器检查三套 H5 产物可托管、资源可加载、history fallback 可用,并用 mock API 验证租户解析契约。发布守卫会检查三套 H5 产物是否存在 `index.html`,源码和产物是否混入 `x-user-id`、`x-platform-admin-key`、PocketBase 引用、数据库连接串、服务端密钥形态,并检查运行时配置示例只包含公开字段。若还没有把真实 `runtime-config.json` 放入静态目录,会显示 warning;正式发布前必须在每个 H5 目录根部补齐该文件。
|
||||||
|
|
||||||
7. 收集生产上线证据并运行 launch gate:
|
7. 收集生产上线证据并运行 launch gate:
|
||||||
|
|
||||||
|
|||||||
@@ -81,11 +81,12 @@ npm run audit:runtime
|
|||||||
npm run check:api
|
npm run check:api
|
||||||
npm run check:worker
|
npm run check:worker
|
||||||
npm run check:taro
|
npm run check:taro
|
||||||
|
npm run smoke:taro:h5
|
||||||
node scripts/taro-h5-release-guardrails-test.js --require-dist
|
node scripts/taro-h5-release-guardrails-test.js --require-dist
|
||||||
npm run smoke:launch-persona
|
npm run smoke:launch-persona
|
||||||
```
|
```
|
||||||
|
|
||||||
`taro-h5-release-guardrails-test` 会扫描源码、三套 H5 产物和 runtime-config 边界,防止旧 PocketBase、`x-user-id`、`x-platform-admin-key`、数据库连接串和服务端密钥形态进入前端发布目录。若刚构建完但未放入真实 `runtime-config.json`,脚本允许 warning;正式部署目录必须补齐。
|
`smoke:taro:h5` 会启动临时静态服务器和 mock API,验证三套 H5 的 `index.html`、JS/CSS 资源、history fallback、公开 runtime config 和 `/api/tenant/resolve` 契约。它是发布目录级启动烟测,不替代真实浏览器/真机点击验收。`taro-h5-release-guardrails-test` 会扫描源码、三套 H5 产物和 runtime-config 边界,防止旧 PocketBase、`x-user-id`、`x-platform-admin-key`、数据库连接串和服务端密钥形态进入前端发布目录。若刚构建完但未放入真实 `runtime-config.json`,脚本允许 warning;正式部署目录必须补齐。
|
||||||
|
|
||||||
如 `@codex-security` 插件在当前 Codex 环境暴露扫描工具,再补插件扫描结果。若工具不可用,不能把该项标记为已完成,只能在上线证据里标记为待补。
|
如 `@codex-security` 插件在当前 Codex 环境暴露扫描工具,再补插件扫描结果。若工具不可用,不能把该项标记为已完成,只能在上线证据里标记为待补。
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
"test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js",
|
"test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js",
|
||||||
"test:launch-gate": "node scripts/production-launch-gate-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",
|
"smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js",
|
||||||
|
"smoke:taro:h5": "node scripts/taro-h5-static-smoke.js",
|
||||||
"test:pb:dry-run": "node scripts/pb-dry-run-report-test.js",
|
"test:pb:dry-run": "node scripts/pb-dry-run-report-test.js",
|
||||||
"test:pb:sqlite-export": "node scripts/pb-sqlite-export-test.js",
|
"test:pb:sqlite-export": "node scripts/pb-sqlite-export-test.js",
|
||||||
"test:perf:summary": "node scripts/performance-summary-test.js",
|
"test:perf:summary": "node scripts/performance-summary-test.js",
|
||||||
|
|||||||
396
scripts/taro-h5-static-smoke.js
Normal file
396
scripts/taro-h5-static-smoke.js
Normal file
@@ -0,0 +1,396 @@
|
|||||||
|
import fs from 'node:fs';
|
||||||
|
import http from 'node:http';
|
||||||
|
import path from 'node:path';
|
||||||
|
import process from 'node:process';
|
||||||
|
|
||||||
|
const repoRoot = process.cwd();
|
||||||
|
const distRoot = path.join(repoRoot, 'apps', 'taro', 'dist');
|
||||||
|
|
||||||
|
const portals = [
|
||||||
|
{
|
||||||
|
portal: 'student',
|
||||||
|
dist: 'h5-student',
|
||||||
|
landingPath: '/pages/student/home/index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
portal: 'tenant-admin',
|
||||||
|
dist: 'h5-tenant-admin',
|
||||||
|
landingPath: '/pages/tenant-admin/workbench/index',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
portal: 'platform-admin',
|
||||||
|
dist: 'h5-platform-admin',
|
||||||
|
landingPath: '/pages/platform-admin/workbench/index',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const allowedRuntimeConfigKeys = new Set([
|
||||||
|
'portal',
|
||||||
|
'apiBaseUrl',
|
||||||
|
'supabaseUrl',
|
||||||
|
'supabasePublishableKey',
|
||||||
|
'tenantCode',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const forbiddenRuntimeConfigKeys = [
|
||||||
|
'SUPABASE_SERVICE_ROLE_KEY',
|
||||||
|
'SUPABASE_SECRET_KEY',
|
||||||
|
'DATABASE_URL',
|
||||||
|
'ALIYUN_OSS_ACCESS_KEY_SECRET',
|
||||||
|
'TENCENT_COS_SECRET_KEY',
|
||||||
|
'WECHAT_PAY_PRIVATE_KEY',
|
||||||
|
'ALIPAY_APP_PRIVATE_KEY',
|
||||||
|
'AUTH_SESSION_SECRET',
|
||||||
|
'PLATFORM_ADMIN_API_KEY',
|
||||||
|
];
|
||||||
|
|
||||||
|
const forbiddenValuePatterns = [
|
||||||
|
/postgres(?:ql)?:\/\//i,
|
||||||
|
/-----BEGIN [A-Z ]*PRIVATE KEY-----/i,
|
||||||
|
/\bAKIA[0-9A-Z]{16}\b/,
|
||||||
|
/\bsk_(?:live|test)_[A-Za-z0-9]{10,}\b/,
|
||||||
|
];
|
||||||
|
|
||||||
|
function contentTypeFor(filePath) {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
if (ext === '.html') return 'text/html; charset=utf-8';
|
||||||
|
if (ext === '.js') return 'application/javascript; charset=utf-8';
|
||||||
|
if (ext === '.css') return 'text/css; charset=utf-8';
|
||||||
|
if (ext === '.json') return 'application/json; charset=utf-8';
|
||||||
|
if (ext === '.svg') return 'image/svg+xml';
|
||||||
|
if (ext === '.png') return 'image/png';
|
||||||
|
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
|
||||||
|
if (ext === '.webp') return 'image/webp';
|
||||||
|
if (ext === '.woff2') return 'font/woff2';
|
||||||
|
if (ext === '.woff') return 'font/woff';
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
function readText(filePath) {
|
||||||
|
return fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSlashes(value) {
|
||||||
|
return value.replace(/\\/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function relative(filePath) {
|
||||||
|
return normalizeSlashes(path.relative(repoRoot, filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(response, statusCode, payload) {
|
||||||
|
response.writeHead(statusCode, {
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
'cache-control': 'no-store',
|
||||||
|
});
|
||||||
|
response.end(`${JSON.stringify(payload)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function textResponse(response, statusCode, body, headers = {}) {
|
||||||
|
response.writeHead(statusCode, headers);
|
||||||
|
response.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(input, options = {}) {
|
||||||
|
const response = await fetch(input, {
|
||||||
|
method: options.method || 'GET',
|
||||||
|
headers: options.headers || {},
|
||||||
|
});
|
||||||
|
const text = await response.text();
|
||||||
|
return {
|
||||||
|
ok: response.ok,
|
||||||
|
status: response.status,
|
||||||
|
headers: response.headers,
|
||||||
|
text,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMockApiServer() {
|
||||||
|
const requests = [];
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
||||||
|
requests.push({
|
||||||
|
method: req.method,
|
||||||
|
path: url.pathname,
|
||||||
|
query: Object.fromEntries(url.searchParams.entries()),
|
||||||
|
host: req.headers.host || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (url.pathname === '/health') {
|
||||||
|
jsonResponse(res, 200, { ok: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (url.pathname === '/api/tenant/resolve') {
|
||||||
|
jsonResponse(res, 200, {
|
||||||
|
item: {
|
||||||
|
tenantId: '00000000-0000-4000-8000-000000000001',
|
||||||
|
tenantSlug: url.searchParams.get('tenantCode') || 'master',
|
||||||
|
branding: {
|
||||||
|
name: '工学题库',
|
||||||
|
theme: {
|
||||||
|
primaryColor: '#2563eb',
|
||||||
|
accentColor: '#16a34a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
enableLeaderboard: false,
|
||||||
|
},
|
||||||
|
adminFeatures: {
|
||||||
|
tenantAdmin: true,
|
||||||
|
platformAdmin: true,
|
||||||
|
},
|
||||||
|
publicConfig: {
|
||||||
|
smoke: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonResponse(res, 404, { code: 'NOT_FOUND', message: 'mock endpoint not found' });
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = await listen(server);
|
||||||
|
return {
|
||||||
|
baseUrl: `http://127.0.0.1:${port}`,
|
||||||
|
requests,
|
||||||
|
close: () => closeServer(server),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createStaticServer(portal, apiBaseUrl) {
|
||||||
|
const distDir = path.join(distRoot, portal.dist);
|
||||||
|
const runtimeConfig = {
|
||||||
|
portal: portal.portal,
|
||||||
|
apiBaseUrl,
|
||||||
|
supabaseUrl: 'https://auth.example.test',
|
||||||
|
supabasePublishableKey: 'sb_publishable_mock_key_for_static_smoke',
|
||||||
|
tenantCode: 'master',
|
||||||
|
};
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
||||||
|
const requestPath = decodeURIComponent(url.pathname);
|
||||||
|
|
||||||
|
if (requestPath === '/runtime-config.json') {
|
||||||
|
jsonResponse(res, 200, runtimeConfig);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = resolveStaticPath(distDir, requestPath);
|
||||||
|
if (!filePath) {
|
||||||
|
textResponse(res, 403, 'forbidden', { 'content-type': 'text/plain; charset=utf-8' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
||||||
|
? filePath
|
||||||
|
: path.join(distDir, 'index.html');
|
||||||
|
|
||||||
|
if (!fs.existsSync(finalPath)) {
|
||||||
|
textResponse(res, 404, 'not found', { 'content-type': 'text/plain; charset=utf-8' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const headers = {
|
||||||
|
'content-type': contentTypeFor(finalPath),
|
||||||
|
'cache-control': finalPath.endsWith('index.html') ? 'no-store' : 'public, max-age=31536000, immutable',
|
||||||
|
};
|
||||||
|
res.writeHead(200, headers);
|
||||||
|
fs.createReadStream(finalPath).pipe(res);
|
||||||
|
});
|
||||||
|
|
||||||
|
const port = await listen(server);
|
||||||
|
return {
|
||||||
|
baseUrl: `http://127.0.0.1:${port}`,
|
||||||
|
runtimeConfig,
|
||||||
|
close: () => closeServer(server),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStaticPath(distDir, requestPath) {
|
||||||
|
const cleanPath = requestPath === '/' ? '/index.html' : requestPath;
|
||||||
|
const resolved = path.resolve(distDir, `.${cleanPath}`);
|
||||||
|
const normalizedRoot = path.resolve(distDir);
|
||||||
|
if (resolved !== normalizedRoot && !resolved.startsWith(`${normalizedRoot}${path.sep}`)) return null;
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listen(server) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = server.address();
|
||||||
|
resolve(address.port);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeServer(server) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
server.close(() => resolve());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAssetUrls(indexHtml) {
|
||||||
|
const urls = new Set();
|
||||||
|
for (const match of indexHtml.matchAll(/<script\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi)) {
|
||||||
|
urls.add(match[1]);
|
||||||
|
}
|
||||||
|
for (const match of indexHtml.matchAll(/<link\b[^>]*\bhref=["']([^"']+)["'][^>]*>/gi)) {
|
||||||
|
const tag = match[0];
|
||||||
|
if (/rel=["']stylesheet["']/i.test(tag)) urls.add(match[1]);
|
||||||
|
}
|
||||||
|
return [...urls];
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRuntimeConfig(config, portal) {
|
||||||
|
const errors = [];
|
||||||
|
const keys = Object.keys(config);
|
||||||
|
const unknownKeys = keys.filter(key => !allowedRuntimeConfigKeys.has(key));
|
||||||
|
const forbiddenKeys = keys.filter(key => forbiddenRuntimeConfigKeys.includes(key));
|
||||||
|
if (unknownKeys.length) errors.push(`unknown runtime config keys: ${unknownKeys.join(', ')}`);
|
||||||
|
if (forbiddenKeys.length) errors.push(`forbidden runtime config keys: ${forbiddenKeys.join(', ')}`);
|
||||||
|
if (config.portal !== portal.portal) errors.push(`portal mismatch: expected ${portal.portal}, got ${config.portal}`);
|
||||||
|
if (!String(config.apiBaseUrl || '').startsWith('http://127.0.0.1:')) errors.push('apiBaseUrl must point to the local mock API in smoke');
|
||||||
|
if (!String(config.supabaseUrl || '').startsWith('https://')) errors.push('supabaseUrl must be HTTPS even in smoke runtime config');
|
||||||
|
if (!config.supabasePublishableKey) errors.push('supabasePublishableKey is required');
|
||||||
|
for (const [key, value] of Object.entries(config)) {
|
||||||
|
if (forbiddenValuePatterns.some(pattern => pattern.test(String(value)))) errors.push(`secret-looking value in ${key}`);
|
||||||
|
}
|
||||||
|
return errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDistExists(portal) {
|
||||||
|
const distDir = path.join(distRoot, portal.dist);
|
||||||
|
const indexPath = path.join(distDir, 'index.html');
|
||||||
|
if (!fs.existsSync(distDir)) throw new Error(`${relative(distDir)} does not exist. Build H5 before running smoke:taro:h5.`);
|
||||||
|
if (!fs.existsSync(indexPath)) throw new Error(`${relative(indexPath)} does not exist. Build output is not deployable.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function smokePortal(portal, api) {
|
||||||
|
assertDistExists(portal);
|
||||||
|
const staticServer = await createStaticServer(portal, api.baseUrl);
|
||||||
|
try {
|
||||||
|
const checks = [];
|
||||||
|
const index = await request(`${staticServer.baseUrl}/index.html`);
|
||||||
|
checks.push({
|
||||||
|
id: `${portal.portal}.index`,
|
||||||
|
ok: index.ok && /<div id="app">/.test(index.text) && /<script\b/i.test(index.text),
|
||||||
|
detail: `status=${index.status}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const assetUrls = parseAssetUrls(index.text);
|
||||||
|
checks.push({
|
||||||
|
id: `${portal.portal}.assets.discovered`,
|
||||||
|
ok: assetUrls.length >= 2,
|
||||||
|
detail: `${assetUrls.length} asset reference(s)`,
|
||||||
|
});
|
||||||
|
for (const assetUrl of assetUrls) {
|
||||||
|
const target = new URL(assetUrl, `${staticServer.baseUrl}/index.html`);
|
||||||
|
const asset = await request(target);
|
||||||
|
checks.push({
|
||||||
|
id: `${portal.portal}.asset.${assetUrl}`,
|
||||||
|
ok: asset.ok && asset.text.length > 0,
|
||||||
|
detail: `status=${asset.status} bytes=${asset.text.length}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const runtime = await request(`${staticServer.baseUrl}/runtime-config.json`);
|
||||||
|
let runtimeConfig = null;
|
||||||
|
try {
|
||||||
|
runtimeConfig = JSON.parse(runtime.text);
|
||||||
|
} catch {
|
||||||
|
runtimeConfig = null;
|
||||||
|
}
|
||||||
|
const runtimeErrors = runtimeConfig ? validateRuntimeConfig(runtimeConfig, portal) : ['runtime-config.json is not valid JSON'];
|
||||||
|
checks.push({
|
||||||
|
id: `${portal.portal}.runtime_config`,
|
||||||
|
ok: runtime.ok && runtimeErrors.length === 0,
|
||||||
|
detail: runtimeErrors.length ? runtimeErrors.join('; ') : 'public runtime config accepted',
|
||||||
|
});
|
||||||
|
|
||||||
|
const fallback = await request(`${staticServer.baseUrl}${portal.landingPath}`);
|
||||||
|
checks.push({
|
||||||
|
id: `${portal.portal}.history_fallback`,
|
||||||
|
ok: fallback.ok && /<div id="app">/.test(fallback.text),
|
||||||
|
detail: `status=${fallback.status} path=${portal.landingPath}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (runtimeConfig) {
|
||||||
|
const resolveUrl = new URL('/api/tenant/resolve', runtimeConfig.apiBaseUrl);
|
||||||
|
resolveUrl.searchParams.set('tenantCode', runtimeConfig.tenantCode);
|
||||||
|
resolveUrl.searchParams.set('host', new URL(staticServer.baseUrl).host);
|
||||||
|
const tenantResolve = await request(resolveUrl, { headers: { 'x-smoke-portal': portal.portal } });
|
||||||
|
let tenantPayload = null;
|
||||||
|
try {
|
||||||
|
tenantPayload = JSON.parse(tenantResolve.text);
|
||||||
|
} catch {
|
||||||
|
tenantPayload = null;
|
||||||
|
}
|
||||||
|
checks.push({
|
||||||
|
id: `${portal.portal}.tenant_resolve_contract`,
|
||||||
|
ok: tenantResolve.ok && Boolean(tenantPayload?.item?.tenantId) && tenantPayload.item.features?.enableLeaderboard === false,
|
||||||
|
detail: `status=${tenantResolve.status}`,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
checks.push({
|
||||||
|
id: `${portal.portal}.tenant_resolve_contract`,
|
||||||
|
ok: false,
|
||||||
|
detail: 'runtime config unavailable',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
portal: portal.portal,
|
||||||
|
staticBaseUrl: staticServer.baseUrl,
|
||||||
|
landingPath: portal.landingPath,
|
||||||
|
checks,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
await staticServer.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function summarize(results) {
|
||||||
|
const checks = results.flatMap(result => result.checks);
|
||||||
|
return {
|
||||||
|
pass: checks.filter(item => item.ok).length,
|
||||||
|
fail: checks.filter(item => !item.ok).length,
|
||||||
|
checks,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const api = await createMockApiServer();
|
||||||
|
try {
|
||||||
|
const results = [];
|
||||||
|
for (const portal of portals) {
|
||||||
|
results.push(await smokePortal(portal, api));
|
||||||
|
}
|
||||||
|
const summary = summarize(results);
|
||||||
|
console.log(`Taro H5 static smoke: ${summary.fail} fail(s), ${summary.pass} pass(es)`);
|
||||||
|
for (const result of results) {
|
||||||
|
console.log(`[PORTAL] ${result.portal} ${result.staticBaseUrl} -> ${result.landingPath}`);
|
||||||
|
for (const check of result.checks) {
|
||||||
|
console.log(`[${check.ok ? 'PASS' : 'FAIL'}] ${check.id}: ${check.detail}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const resolveRequests = api.requests.filter(item => item.path === '/api/tenant/resolve');
|
||||||
|
console.log(`[MOCK API] tenant resolve requests=${resolveRequests.length}`);
|
||||||
|
if (resolveRequests.length !== portals.length) {
|
||||||
|
console.log(`[FAIL] mock_api.tenant_resolve_count: expected ${portals.length}, got ${resolveRequests.length}`);
|
||||||
|
process.exitCode = 1;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (summary.fail > 0) process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
await api.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
process.exitCode = 1;
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user