test: strengthen launch smoke and capacity guardrails

This commit is contained in:
Codex
2026-07-01 04:10:55 +08:00
parent 3d33c783cd
commit 6bdb2a175a
11 changed files with 494 additions and 39 deletions

2
.gitignore vendored
View File

@@ -64,6 +64,8 @@ docs/refactor/launch-artifacts/
# ✅ 参考资料(非源码)
参考/
新UI参考/
sao_live_0630/
whisper_models/
# ✅ Electron 打包产物
/release/

View File

@@ -53,7 +53,7 @@
| 对象存储/资料安全 | √ 可联调,待生产 AV/CDN | OSS/COS/Supabase Storage 签名、上传确认、短签名预览下载、水印 traceId、复检和安全扫描地基已完成 |
| PocketBase 真实数据迁移 | √ 本地跑通,待人工复核 blocker | SQLite 导出、标准化导入、校验和抽样脚本已跑通;正式切换前处理缺用户订单和缺归属手册章节 |
| Taro H5 三端前端 | √ 第一版可构建 | 学生端、租户后台、平台后台均有真实 API 页面;已补 H5 `index.html` 模板和发布产物守卫;后续继续补小程序兼容、视觉精修、状态管理、包体优化和端到端测试 |
| 生产安全/压测交付 | △ 本地真实数据压测已跑,云端待复测 | 本地 Docker/Supabase 已完成真实迁移数据 30/50/100/150 并发只读和混合读写压测;已补 PostgreSQL 调参证据脚本和上线前角色旅程烟测。最近一次只读上线门禁和 50/100/150 混合读写均 0 错误;上云后仍需执行生产 readiness、远程 Auth/RLS、4c16g 压测、PostgreSQL 调优和上线证据门禁。`@codex-security` 只有在 Codex 环境暴露扫描工具时才能标记完成 |
| 生产安全/压测交付 | △ 本地真实数据压测已跑,云端待复测 | 本地 Docker/Supabase 已完成真实迁移数据 30/50/100/150 并发只读和混合读写压测;受限 API 容器复核中抓到并修复了自动勋章并发发放唯一键冲突,并补并发回归测试。最近一次只读上线门禁和 50/100/150 混合读写均 0 错误50/100 混合读写约 418-420 req/s150 并发 P95 超 600ms 进入压力区;上云后仍需执行生产 readiness、远程 Auth/RLS、4c16g 压测、PostgreSQL 调优和上线证据门禁。`@codex-security` 只有在 Codex 环境暴露扫描工具时才能标记完成 |
更完整的进度看这些文档:
@@ -152,7 +152,7 @@ npm run smoke:taro:h5:interaction
`taro-route-contract-test` 会校验 `apps/taro/src/app.config.ts`、真实 `pages/**/index.tsx`、启动页三端跳转、H5 静态烟测入口和前端交接文档中的页面引用保持一致。新增或删除页面时必须同步路由和文档,避免 H5/小程序构建后才发现入口漂移。
`taro-api-contract-test` 会比对 `apps/taro/src` 中所有 `apiRequest('/api/...')` 调用与 `apps/api/src/features/*/index.ts` 注册路由,阻断前端调用不存在 API、method 写错或绕过统一 `/api` 命名空间的漂移;动态导入和少量 server alias 需要在脚本 allowlist 中显式声明。
`taro-persona-contract-test` 会从学生、租户管理员、平台管理员三类前端视角检查关键页面、路由和服务调用,阻断刷题、会员订单、错题收藏、学生运营、内容导入、营销财务、租户设置、平台租户账务和公共题库授权入口被误删或漂移。
`smoke:taro:h5:interaction` 会启动三套 H5 发布产物、本地 mock API 和本机 Chrome/Edge在真实浏览器里点击学生刷题/收藏/开通会员、租户题库内容/财务运营、平台租户/账务中心关键路径,用来补足静态烟测无法发现的 H5 运行时空白页、history 路由和点击事件问题。
`smoke:taro:h5:interaction` 会启动三套 H5 发布产物、本地 mock API 和本机 Chrome/Edge在真实浏览器里点击学生首页、题库、答题、收藏、错题/收藏复习、背单词、知识手册、资料短签名和水印、视频播放授权、分数线、AI 择校、消息中心、会员收银台,租户后台六个主模块,以及平台后台四个主模块,用来补足静态烟测无法发现的 H5 运行时空白页、history 路由和点击事件问题。
H5 线上推荐每个静态目录放独立 `runtime-config.json` 覆盖公开配置,避免 API/Auth 域名变化时重打包:
@@ -593,7 +593,7 @@ npm run smoke:taro:h5:interaction
node scripts\taro-h5-release-guardrails-test.js --require-dist
```
`smoke:taro:h5` 会启动临时静态服务器和 mock API验证三套 H5 的 `index.html`、静态资源、history fallback、公开 runtime config 和租户解析契约。`smoke:taro:h5:interaction` 会再拉起真实 Chrome/Edge打开三套 H5 产物并点击学生、租户后台、平台后台关键入口,确认页面 JS 执行、路由跳转、答题收藏 API后台入口点击没有空白页或运行时异常。`taro-h5-release-guardrails-test` 会确认三套 H5 目录存在 `index.html`,并扫描源码/产物是否混入旧 PocketBase、`x-user-id`、平台本地 key、数据库连接串或服务端密钥形态。正式部署时还必须在每个 H5 目录根部放置对应的 `runtime-config.json`
`smoke:taro:h5` 会启动临时静态服务器和 mock API验证三套 H5 的 `index.html`、静态资源、history fallback、公开 runtime config 和租户解析契约。`smoke:taro:h5:interaction` 会再拉起真实 Chrome/Edge打开三套 H5 产物并点击 25 项关键入口,覆盖学生首页、题库、答题、收藏、错题/收藏复习、背单词、知识手册、资料短签名和水印、视频播放授权、分数线、AI 择校、消息中心、会员收银台,租户后台六个主模块,以及平台后台四个主模块,确认页面 JS 执行、路由跳转、关键 API后台入口点击没有空白页或运行时异常。`taro-h5-release-guardrails-test` 会确认三套 H5 目录存在 `index.html`,并扫描源码/产物是否混入旧 PocketBase、`x-user-id`、平台本地 key、数据库连接串或服务端密钥形态。正式部署时还必须在每个 H5 目录根部放置对应的 `runtime-config.json`
写入生产上线证据时使用严格模式,确保三套正式发布目录已经放好真实公开 `runtime-config.json`,且 warning 为 0
@@ -630,6 +630,10 @@ node scripts\taro-h5-release-guardrails-test.js --require-dist --require-runtime
| 50 | 60s | 10% | 35,608 | 0.00% | 584.18 req/s | 174.26 ms | 224.04 ms |
| 100 | 60s | 8% | 33,843 | 0.00% | 554.42 req/s | 326.21 ms | 422.33 ms |
| 150 | 60s | 6% | 32,364 | 0.00% | 528.81 req/s | 490.38 ms | 662.42 ms |
| Docker API 2c4g / 30 | 120s | 0% | 44,038 | 0.00% | 366.27 req/s | 185.80 ms | 254.08 ms |
| Docker API 2c4g / 50 | 60s | 10% | 25,327 | 0.00% | 419.62 req/s | 213.17 ms | 270.94 ms |
| Docker API 2c4g / 100 | 60s | 8% | 25,378 | 0.00% | 417.77 req/s | 383.72 ms | 461.22 ms |
| Docker API 2c4g / 150 | 60s | 6% | 23,444 | 0.00% | 384.39 req/s | 605.89 ms | 743.19 ms |
只读上线门禁继续要求 `includeWrites=false`;混合读写报告需要显式使用 `--allow-writes` 做人工容量观察,例如:
@@ -639,7 +643,7 @@ npm run perf:summary -- --input docs/refactor/performance-reports/api-benchmark-
使用 `--allow-writes` 时会输出 `capacityObservation`,不输出 `launchGateCheck`,不能把写入场景误填成生产上线门禁的只读证据。
本地结论:当前 Docker Desktop 分配 20 CPU、约 62.7GB 内存,高于常见 4 核 16G 云服务器,不能直接作为生产 SLA。2026-07-01 03:12 最新复核中30 worker/120 秒只读上线门禁为 60,326 请求、0 错误、499.27 req/s、P95 151.29ms50 worker/60 秒/10% 写入为 35,608 请求、0 错误、584.18 req/s、P95 174.26ms100 worker/60 秒/8% 写入为 33,843 请求、0 错误、554.42 req/s、P95 326.21ms,已接近舒适区上沿150 worker/60 秒/6% 写入为 32,364 请求、0 错误、528.81 req/s、P95 490.38ms属于压力区。按单学生 0.05 到 0.2 req/s 的页面节奏粗略折算,当前本地舒适观察区间约对应 2,700 到 11,600 名活跃在线学生的请求吞吐。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要和剩余功能清单见:
本地结论:当前 Docker Desktop 分配 20 CPU、约 62.7GB 内存,高于常见 4 核 16G 云服务器,不能直接作为生产 SLA。2026-07-01 03:58 受限 API 容器复核中30 worker/120 秒只读上线门禁为 44,038 请求、0 错误、366.27 req/s、P95 185.80ms50 worker/60 秒/10% 写入为 25,327 请求、0 错误、419.62 req/s、P95 213.17ms100 worker/60 秒/8% 写入为 25,378 请求、0 错误、417.77 req/s、P95 383.72ms150 worker/60 秒/6% 写入为 23,444 请求、0 错误、384.39 req/s、P95 605.89ms属于压力区。该轮压测曾抓到自动勋章并发发放唯一键冲突,已修复并新增并发回归测试。按单学生 0.05 到 0.2 req/s 的页面节奏粗略折算,当前受限 API 容器舒适观察区间约对应 2,000 到 8,400 名活跃在线学生的请求吞吐。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要和剩余功能清单见:
```text
docs/refactor/performance-benchmark-summary-20260630.md

View File

@@ -228,7 +228,7 @@ export async function autoGrantBadges(
tenant_id, user_id, badge_id, granted_by, legacy_id,
note, metadata, granted_at
)
values ($1, $2, $3, null, $4, $5, $6::jsonb, now())
values ($1, $2, $3, null, null, $4, $5::jsonb, now())
on conflict (tenant_id, user_id, badge_id) do nothing
returning id, legacy_id as "legacyId", user_id as "userId",
badge_id as "badgeId", note, metadata,
@@ -239,7 +239,6 @@ export async function autoGrantBadges(
input.tenantId,
input.userId,
badge.id,
`auto:${badge.id}:user:${input.userId}`,
`自动发放:${badge.name}`,
JSON.stringify(metadata),
],

View File

@@ -43,17 +43,21 @@
- 环境Windows + Docker Desktop + 本地 Supabase/PostgreSQL + 本地 API 进程。
- Docker Desktop 当前资源20 CPU、约 62.7GB 内存。这个本机结果会高于常见 4 核 16G 云服务器,不能直接作为生产 SLA。
- 数据库PocketBase 真实导入数据,并已包含前期写入压测产生的练习/答题记录。
- 当前库规模3 个租户、3,690 个用户、约 74,117 道题、1,601 个题目合集、3,106 个练习蓝图、3,505 个单词、2,678 条知识手册、196,846 条答题记录、38,207 条错题、466 条权益。
- 压测基线规模:约 7.4 万道题、3,700 个用户、1,600 个题目合集、3,100 个练习蓝图、3,500 个单词、2,600 条知识手册、19.6 条答题记录、3.8 万条错题、466 条权益。后续 `db:smoke-seed`、集成测试和压测会继续写入练习/答题/测试租户数据,因此这里按压测基线描述,不把每次测试后的瞬时行数当作 SLA 口径。
- 排行榜未纳入默认负载,因为产品默认关闭。
### 2026-07-01 最新复核
### 2026-07-01 真实迁移库 Docker API 受限资源复核
本轮使用 `npm run perf:api:docker-4c16g`API 容器限制为 2 CPU/4G、`DB_POOL_MAX=10`、关闭 legacy `x-user-id` 和平台本地 key压测鉴权使用 `PERF_AUTH_MODE=app_session` 的 Bearer session。数据库仍为本机 Docker Desktop Supabase/PostgreSQL未限制为 4 核 16G因此结果更接近“受限 API 容器 + 本机数据库”的预生产观察,不是最终云服务器 SLA。
压测先暴露出一个真实并发缺陷50 worker 混合读写中,交卷触发自动勋章发放时撞到 `user_badges_tenant_id_legacy_id_key`,产生 1 个 500。已修复为自动勋章只使用 `(tenant_id, user_id, badge_id)` 做业务幂等,不再写入自动生成 `legacy_id`;并新增 `scripts/auto-badge-concurrency-test.js`20 个并发触发同一学生同一勋章时只生成 1 条 `user_badges`
| 场景 | 并发 worker | 时长 | 刷题写入比例 | 请求数 | 错误率 | 吞吐 | P95 | P99 | 结论 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| 只读上线门禁 | 30 | 120s | 0% | 60,326 | 0.00% | 499.27 req/s | 151.29 ms | 214.61 ms | 通过 |
| 混合读写 | 50 | 60s | 10% | 35,608 | 0.00% | 584.18 req/s | 174.26 ms | 224.04 ms | 舒适 |
| 混合读写 | 100 | 60s | 8% | 33,843 | 0.00% | 554.42 req/s | 326.21 ms | 422.33 ms | 接近舒适区上沿 |
| 混合读写 | 150 | 60s | 6% | 32,364 | 0.00% | 528.81 req/s | 490.38 ms | 662.42 ms | 压力区 |
| 只读上线门禁 | 30 | 120s | 0% | 44,038 | 0.00% | 366.27 req/s | 185.80 ms | 254.08 ms | 通过 |
| 混合读写 | 50 | 60s | 10% | 25,327 | 0.00% | 419.62 req/s | 213.17 ms | 270.94 ms | 舒适 |
| 混合读写 | 100 | 60s | 8% | 25,378 | 0.00% | 417.77 req/s | 383.72 ms | 461.22 ms | 接近舒适区上沿 |
| 混合读写 | 150 | 60s | 6% | 23,444 | 0.00% | 384.39 req/s | 605.89 ms | 743.19 ms | 压力区 |
混合读写包含真实刷题闭环:创建练习 session、拉取 session detail、提交答案、交卷、读取报告。
@@ -65,10 +69,10 @@
按目前常见页面节奏先用 `0.05 到 0.2 req/s/人` 粗略折算:
- 50 worker 舒适场景 584 req/s约等于 2,920 到 11,680 名活跃在线学生的请求吞吐。
- 100 worker 上沿场景 554 req/s约等于 2,770 到 11,080 名活跃在线学生的请求吞吐。
- 150 worker 压力场景 529 req/s约等于 2,645 到 10,580 名活跃在线学生的请求吞吐。
- 50 worker 舒适场景 420 req/s约等于 2,100 到 8,400 名活跃在线学生的请求吞吐。
- 100 worker 上沿场景 418 req/s约等于 2,090 到 8,360 名活跃在线学生的请求吞吐。
- 150 worker 压力场景 384 req/s约等于 1,920 到 7,680 名活跃在线学生的请求吞吐。
当前本地结论:在 20 CPU/62.7GB Docker Desktop 环境下,后端真实刷题读写链路的舒适观察区间约为 550 到 585 req/s折算约 2,700 到 11,600 名活跃在线学生150 worker 仍 0 错误但 P95 接近 500ms视为压力区不建议作为生产承诺。
当前本地结论:在受限 API 容器下,后端真实刷题读写链路的舒适观察区间约为 418 到 420 req/s折算约 2,000 到 8,400 名活跃在线学生150 worker 仍 0 错误但 P95 已超过 600ms视为压力区不建议作为生产承诺。此前未限制 API 容器时本机可观察到约 550 到 585 req/s说明 API CPU/连接池约束会明显影响上沿。
正式对外容量必须等 4 核 16G 云服务器部署后复跑。保守规划时,生产首版建议先按本地折算值的 30% 到 50% 做容量承诺等云端压测、CDN、对象存储和真实前端埋点完成后再上调。

View File

@@ -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` 扫描工具时,该项只能标为待补,不能伪造完成。
- 确认数据库迁移流程、备份恢复、日志、告警。
- 准备 API 容器部署和 Supabase 云端/自托管连接方案。
- 已补 `npm run perf:api:local``npm run perf:summary``npm run perf:postgres:evidence``npm run smoke:launch-persona``npm run smoke:taro:h5``npm run smoke:taro:h5:interaction``node scripts/taro-api-contract-test.js``node scripts/taro-persona-contract-test.js``docs/refactor/performance-benchmark-runbook.md`可在本地或云端对真实迁移数据做只读门禁、混合读写容量观察、PostgreSQL 调参证据、三类后端角色旅程烟测、三类 Taro 前端角色旅程契约、H5 发布目录启动烟测、真实浏览器关键点击烟测和前端 API 契约检查。2026-07-01 03:12 最新本地真实迁移库只读 30 worker/120s 为 60,326 请求、0 错误、499.27 req/s、P95 151.29ms、P99 214.61ms混合读写 50 worker/60s/10% 写入为 35,608 请求、0 错误、584.18 req/s、P95 174.26ms、P99 224.04ms100 worker/60s/8% 写入为 33,843 请求、0 错误、554.42 req/s、P95 326.21ms、P99 422.33ms150 worker/60s/6% 写入为 32,364 请求、0 错误、528.81 req/s、P95 490.38ms、P99 662.42ms。同轮 `npm run smoke:launch-persona` 已通过,覆盖学生 SVIP 刷题、收藏、错题复习入口、租户后台关键入口、平台后台关键入口和越权拒绝。当前 Docker Desktop 给了 20 CPU/约 62.7GB 内存,舒适观察区暂按 50 到 100 个无停顿 worker 估算150 worker 已是压力区;按单学生 0.05 到 0.2 req/s 粗略折算约为 2,700 到 11,600 名活跃在线学生的本机吞吐观察区间,正式容量仍以上云 4 核 16G 复测为准。
- 已补 `npm run perf:api:local``npm run perf:summary``npm run perf:postgres:evidence``npm run smoke:launch-persona``npm run smoke:taro:h5``npm run smoke:taro:h5:interaction``node scripts/taro-api-contract-test.js``node scripts/taro-persona-contract-test.js``docs/refactor/performance-benchmark-runbook.md`可在本地或云端对真实迁移数据做只读门禁、混合读写容量观察、PostgreSQL 调参证据、三类后端角色旅程烟测、三类 Taro 前端角色旅程契约、H5 发布目录启动烟测、真实浏览器关键点击烟测和前端 API 契约检查。2026-07-01 03:58 受限 API 容器真实迁移库复核中,30 worker/120s 只读为 44,038 请求、0 错误、366.27 req/s、P95 185.80ms、P99 254.08ms50 worker/60s/10% 写入为 25,327 请求、0 错误、419.62 req/s、P95 213.17ms、P99 270.94ms100 worker/60s/8% 写入为 25,378 请求、0 错误、417.77 req/s、P95 383.72ms、P99 461.22ms150 worker/60s/6% 写入为 23,444 请求、0 错误、384.39 req/s、P95 605.89ms、P99 743.19ms。压测先抓到自动勋章并发发放唯一键冲突,已修复并新增 `scripts/auto-badge-concurrency-test.js`。当前 Docker Desktop 给了 20 CPU/约 62.7GB 内存,但 API 容器限制为 2 CPU/4G舒适观察区暂按 50 到 100 个无停顿 worker 估算150 worker 已是压力区;按单学生 0.05 到 0.2 req/s 粗略折算约为 2,000 到 8,400 名活跃在线学生的本机吞吐观察区间,正式容量仍以上云 4 核 16G 复测为准。
- 当前本地 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 阶梯并发复跑并归档到本地上线证据。
- 本轮剩余功能和容量复核已经整理到 `docs/refactor/backend-open-items-and-capacity-20260701.md`。后续不要再把学生头像上传或默认排行榜当作待办;头像只保留男女预设,排行榜仅作为租户显式开启后的活动能力。

View File

@@ -1,6 +1,6 @@
# API 压测与 4 核 16G 容量评估 Runbook
更新时间2026-06-30
更新时间2026-07-01
这份 runbook 用于在本地 Docker/Supabase 或未来 4 核 16G 云服务器上,对 SaaS 题库 API 做可重复压测。目标不是一次性给出永久容量承诺而是建立一套可以随着题库、用户量、SQL 和硬件变化持续复跑的基线。
@@ -170,6 +170,7 @@ Remove-Item Env:\DB_POOL_MAX
- 30 worker / 120s / 只读上线门禁。
- 50 worker / 60s / 10% 刷题闭环写入。
- 100 worker / 60s / 8% 刷题闭环写入。
- 150 worker / 60s / 6% 刷题闭环写入。
这个入口模拟的是“4 核 16G shared-host 中 API 容器的资源约束”,不是完整云服务器复刻。当前本地 Supabase/PostgreSQL 仍跑在 Docker Desktop 的 Supabase stack 中,除非额外手工限制 DB 容器资源,否则数据库容器仍可能使用 Docker Desktop 的全局资源。正式容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL shared-host 参数、真实对象存储/CDN 和真实网络下复跑。

View File

@@ -87,7 +87,7 @@ node scripts/taro-h5-release-guardrails-test.js --require-dist
npm run smoke:launch-persona
```
`smoke:taro:h5` 会启动临时静态服务器和 mock API验证三套 H5 的 `index.html`、JS/CSS 资源、history fallback、公开 runtime config 和 `/api/tenant/resolve` 契约。`smoke:taro:h5:interaction` 会在真实 Chrome/Edge 中点击学生、租户后台、平台后台关键路径,覆盖静态烟测发现不了的 JS 运行时、直接 history 路由刷新和 Taro 点击事件问题。`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` 契约。`smoke:taro:h5:interaction` 会在真实 Chrome/Edge 中点击学生、租户后台、平台后台关键路径,覆盖静态烟测发现不了的 JS 运行时、直接 history 路由刷新和 Taro 点击事件问题;当前脚本覆盖 25 项检查,包括学生首页、题库、答题、收藏、错题/收藏复习、背单词、知识手册、资料短签名和水印、视频播放授权、分数线、AI 择校、消息中心,租户后台六个主模块,以及平台后台四个主模块`taro-h5-release-guardrails-test` 会扫描源码、三套 H5 产物和 runtime-config 边界,防止旧 PocketBase、`x-user-id``x-platform-admin-key`、数据库连接串和服务端密钥形态进入前端发布目录。若刚构建完但未放入真实 `runtime-config.json`,脚本允许 warning正式部署目录必须补齐。
写入生产上线证据时,三套正式发布目录必须先放入真实公开 `runtime-config.json`,再运行严格模式:

View File

@@ -56,7 +56,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-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-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: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-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-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,112 @@
import assert from 'node:assert/strict';
import pg from 'pg';
import { autoGrantBadges } from '../apps/api/src/features/profile/badges.ts';
const DATABASE_URL = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres';
const TENANT_ID = process.env.TENANT_ID || '00000000-0000-0000-0000-000000000001';
const USER_ID = process.env.USER_ID || '00000000-0000-0000-0000-000000000101';
const BADGE_ID = '00000000-0000-0000-0000-00000000b881';
const pool = new pg.Pool({ connectionString: DATABASE_URL, max: 8 });
async function resetFixture() {
await pool.query(
`
delete from public.user_notifications
where tenant_id = $1
and dedupe_key = $2
`,
[TENANT_ID, `badge:${BADGE_ID}:user:${USER_ID}`],
);
await pool.query(
`
delete from public.user_badges
where tenant_id = $1
and (badge_id = $2::uuid or legacy_id = $3)
`,
[TENANT_ID, BADGE_ID, `auto:${BADGE_ID}:user:${USER_ID}`],
);
await pool.query(
`
delete from public.badges
where tenant_id = $1 and id = $2::uuid
`,
[TENANT_ID, BADGE_ID],
);
await pool.query(
`
insert into public.badges (
id, tenant_id, legacy_id, name, description, category, level,
unlock_type, condition_field, condition_operator, condition_value,
condition_extra, metadata, sort_order, is_active
)
values (
$1::uuid, $2, 'integration-badge-auto-concurrency',
'并发自动勋章烟测', '并发触发时只能发放一次', 'practice', 1,
'practice_count', 'concurrencyGrantCount', 'gte', 1,
'{}'::jsonb, '{"source":"auto-badge-concurrency-test"}'::jsonb, 99, true
)
`,
[BADGE_ID, TENANT_ID],
);
}
async function grantOnce(index) {
const client = await pool.connect();
try {
await client.query('begin');
const grants = await autoGrantBadges(client, {
tenantId: TENANT_ID,
userId: USER_ID,
trigger: 'practice_count',
evidence: {
concurrencyGrantCount: 1,
workerIndex: index,
},
});
await client.query('commit');
return grants;
} catch (error) {
await client.query('rollback').catch(() => undefined);
throw error;
} finally {
client.release();
}
}
async function main() {
try {
await resetFixture();
const results = await Promise.all(Array.from({ length: 20 }, (_, index) => grantOnce(index)));
const grantedRows = results.flat().filter(item => item.badgeId === BADGE_ID);
const count = await pool.query(
`
select count(*)::int as count
from public.user_badges
where tenant_id = $1 and user_id = $2 and badge_id = $3::uuid
`,
[TENANT_ID, USER_ID, BADGE_ID],
);
const legacyCount = await pool.query(
`
select count(*)::int as count
from public.user_badges
where tenant_id = $1 and legacy_id = $2
`,
[TENANT_ID, `auto:${BADGE_ID}:user:${USER_ID}`],
);
assert.equal(Number(count.rows[0]?.count || 0), 1, 'concurrent auto badge grants should create one user_badges row');
assert.equal(Number(legacyCount.rows[0]?.count || 0), 0, 'auto badge grants should not write legacy_id idempotency keys');
assert.equal(grantedRows.length, 1, 'only one concurrent caller should receive a new grant payload');
console.log('Auto badge concurrency test complete.');
} finally {
await pool.end();
}
}
main().catch(error => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -9,6 +9,7 @@ const matrix = [
{ name: 'read-30', duration: 120, concurrency: 30, ramp: 15, writes: false, ratio: 0 },
{ name: 'mixed-50', duration: 60, concurrency: 50, ramp: 10, writes: true, ratio: 0.1 },
{ name: 'mixed-100', duration: 60, concurrency: 100, ramp: 15, writes: true, ratio: 0.08 },
{ name: 'mixed-150', duration: 60, concurrency: 150, ramp: 20, writes: true, ratio: 0.06 },
];
function run(command, args, options = {}) {
@@ -44,25 +45,32 @@ function latestBenchmarkJson(before) {
return path.relative(repoRoot, files[0]).replaceAll('\\', '/');
}
function waitForHealth(timeoutMs = 60_000) {
const started = Date.now();
while (Date.now() - started < timeoutMs) {
const result = spawnSync(process.execPath, ['-e', `fetch('${apiBase}/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))`], {
cwd: repoRoot,
stdio: 'ignore',
shell: process.platform === 'win32',
});
if (result.status === 0) return;
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1000);
}
throw new Error(`API did not become healthy at ${apiBase}`);
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function main() {
async function waitForHealth(timeoutMs = 120_000) {
const started = Date.now();
let lastError = null;
while (Date.now() - started < timeoutMs) {
try {
const response = await fetch(`${apiBase}/health`);
const payload = await response.json().catch(() => ({}));
if (response.ok && payload.ok) return;
lastError = new Error(`HTTP ${response.status}: ${JSON.stringify(payload)}`);
} catch (error) {
lastError = error;
}
await sleep(1000);
}
throw new Error(`API did not become healthy at ${apiBase}. ${lastError?.message || ''}`);
}
async function main() {
const generated = [];
try {
run('docker', ['compose', '-f', 'docker-compose.api.yml', '-f', 'docker-compose.api.benchmark.yml', 'up', '-d', '--build', 'api']);
waitForHealth();
await waitForHealth();
for (const item of matrix) {
const before = Date.now();
run('node', ['scripts/api-performance-benchmark.js'], {
@@ -92,4 +100,7 @@ function main() {
}
}
main();
main().catch(error => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -22,6 +22,19 @@ const ids = {
session: '00000000-0000-4000-8000-000000000601',
plan: '00000000-0000-4000-8000-000000000701',
order: '00000000-0000-4000-8000-000000000801',
vocabularyUnit: '00000000-0000-4000-8000-000000000901',
vocabularyWord: '00000000-0000-4000-8000-000000000902',
handbookSubject: '00000000-0000-4000-8000-000000001001',
handbookChapter: '00000000-0000-4000-8000-000000001002',
handbookEntry: '00000000-0000-4000-8000-000000001003',
asset: '00000000-0000-4000-8000-000000001101',
video: '00000000-0000-4000-8000-000000001201',
school: '00000000-0000-4000-8000-000000001301',
major: '00000000-0000-4000-8000-000000001302',
scorelineField: '00000000-0000-4000-8000-000000001303',
scorelineRecord: '00000000-0000-4000-8000-000000001304',
notification: '00000000-0000-4000-8000-000000001401',
aiReport: '00000000-0000-4000-8000-000000001501',
};
const portals = [
@@ -210,7 +223,8 @@ function mockApiPayload(pathname, method, query, body) {
phone: '13800000000',
avatarPreset: 'male',
score: 120,
target: { regionId: ids.region, regionName: '天津' },
target: { regionId: ids.region, regionName: '天津', schoolId: ids.school, schoolName: '天津职业大学', majorId: ids.major, majorName: '软件工程' },
membership: { isSvip: true },
stats: { answers: { totalAnswered: 12 } },
},
};
@@ -218,7 +232,23 @@ function mockApiPayload(pathname, method, query, body) {
if (pathname === '/api/profile/score-events') return { items: [] };
if (pathname === '/api/profile/activity-tasks') return { items: [] };
if (pathname === '/api/profile/exchange-items') return { items: [] };
if (pathname === '/api/profile/notifications') return { items: [], summary: { unread: 0, read: 0, archived: 0 } };
if (pathname === '/api/profile/notifications') {
return {
items: [{
id: ids.notification,
notificationType: 'badge_granted',
status: query.get('status') || 'unread',
severity: 'success',
title: '入门勋章已发放',
message: '完成首次练习后,系统已自动发放坚持练习勋章。',
actionLabel: '查看个人中心',
actionPath: '/pages/student/profile/index',
createdAt: new Date().toISOString(),
}],
summary: { unread: 1, read: 0, archived: 0, dismissed: 0 },
};
}
if (pathname === '/api/profile/notifications/status') return { item: { updatedCount: body.notificationIds?.length || 1, status: body.status || 'read' } };
if (pathname === '/api/profile/badges') return { items: [{ id: 'badge-smoke', name: '坚持练习' }] };
if (pathname === '/api/profile/exam-countdowns') return { items: [] };
if (pathname === '/api/commerce/entitlements') return { items: [{ type: 'svip', status: 'active' }], summary: { svip: true } };
@@ -270,8 +300,21 @@ function mockApiPayload(pathname, method, query, body) {
}
if (pathname === '/api/learning/favorites/questions' && method === 'POST') return { ok: true, favorite: body.favorite !== false };
if (pathname === '/api/learning/favorites/questions') return { items: [question] };
if (pathname === '/api/learning/wrong-questions/review-plan') return { items: [], nextAction: {} };
if (pathname === '/api/learning/wrong-questions') return { items: [] };
if (pathname === '/api/learning/wrong-questions/review-plan') {
return {
items: [{
questionId: ids.question,
content: question.content,
type: question.type,
typeLabel: question.typeLabel,
wrongCount: 2,
lastWrongAt: new Date().toISOString(),
}],
nextAction: { mode: 'wrong_review', questionLimit: 30 },
};
}
if (pathname === '/api/learning/wrong-questions/resolve') return { ok: true };
if (pathname === '/api/learning/wrong-questions') return { items: [question] };
if (pathname === '/api/learning/stats') {
return {
item: {
@@ -288,6 +331,196 @@ function mockApiPayload(pathname, method, query, body) {
if (pathname === '/api/learning/trend') return { items: [{ date: '2026-07-01', answeredCount: 3, correctCount: 2, wrongCount: 1, accuracy: 0.66, sessionCount: 1, reportCount: 1, score: 66, totalScore: 100 }] };
if (pathname === '/api/learning/practice-sessions/history') return { items: [] };
if (pathname === '/api/catalog/vocabulary-units') {
return { items: [{ id: ids.vocabularyUnit, name: '英语核心词汇', description: '专升本高频词', wordCount: 1, regionId: ids.region }] };
}
if (pathname === '/api/catalog/vocabulary-words') {
return {
items: [{
id: ids.vocabularyWord,
wordId: ids.vocabularyWord,
unitId: ids.vocabularyUnit,
word: 'analysis',
phonetic: "ə'næləsɪs",
meaning: '分析',
example: 'Careful analysis helps students improve.',
exampleTranslation: '细致分析能帮助学生提升。',
status: 'new',
}],
};
}
if (pathname === '/api/learning/vocabulary/review-plan') {
return {
item: {
words: [{
id: ids.vocabularyWord,
wordId: ids.vocabularyWord,
unitId: ids.vocabularyUnit,
word: 'analysis',
phonetic: "ə'næləsɪs",
meaning: '分析',
example: 'Careful analysis helps students improve.',
exampleTranslation: '细致分析能帮助学生提升。',
dueLevel: 'new',
}],
dueCount: 1,
newCount: 1,
totalPlanned: 1,
},
};
}
if (pathname === '/api/learning/vocabulary/stats') {
return { item: { totalWords: 1, progressedWords: 0, masteredWords: 0, learningWords: 0, todayReviewed: 0, favoriteWords: 0 } };
}
if (pathname === '/api/learning/vocabulary/favorites' && method === 'POST') return { ok: true, favorite: body.favorite !== false };
if (pathname === '/api/learning/vocabulary/favorites') return { items: [] };
if (pathname === '/api/learning/vocabulary/review') return { item: { wordId: body.wordId || ids.vocabularyWord, result: body.result || 'known' } };
if (pathname === '/api/catalog/handbook-subjects') {
return { items: [{ id: ids.handbookSubject, name: '高等数学手册', type: 'handbook', description: '章节化知识点' }] };
}
if (pathname === '/api/catalog/handbook-chapters') {
return { items: [{ id: ids.handbookChapter, subjectId: ids.handbookSubject, name: '函数与极限', description: '基础章节' }] };
}
if (pathname === '/api/catalog/handbook-entries') {
return {
items: [{
id: ids.handbookEntry,
chapterId: ids.handbookChapter,
title: '导数定义',
summary: '导数描述函数在某一点附近的变化率。',
content: '设函数 y=f(x),当自变量增量趋近于 0 时,差商的极限称为导数。$f(x)=x^2$ 的导数是 $2x$。',
}],
};
}
if (pathname === '/api/catalog/assets') {
return {
items: [{
id: ids.asset,
title: '天津专升本英语真题 PDF',
assetType: 'pdf',
categoryLabel: '真题资料',
description: '用于验证私有资料短签名、水印和预览入口。',
fileName: 'tj-english-paper.pdf',
mimeType: 'application/pdf',
visibility: 'members',
previewStatus: 'ready',
}],
};
}
if (pathname === '/api/catalog/assets/preview') {
return {
item: { id: ids.asset, title: '天津专升本英语真题 PDF', assetType: 'pdf', fileName: 'tj-english-paper.pdf', mimeType: 'application/pdf', visibility: 'members' },
preview: { url: 'https://assets.example.test/tj-english-paper-preview.pdf?token=smoke', expiresInSec: 300, expiresAt: new Date(Date.now() + 300_000).toISOString(), signatureMode: 'signed' },
watermark: { mode: 'visible_overlay', required: true, text: '工学题库 SMOKE', traceId: 'wm-preview-smoke', position: 'diagonal', opacity: 0.16, repeat: true, renderHint: 'overlay' },
};
}
if (pathname === '/api/catalog/assets/download') {
return {
item: { id: ids.asset, title: '天津专升本英语真题 PDF', assetType: 'pdf', fileName: 'tj-english-paper.pdf', mimeType: 'application/pdf', visibility: 'members' },
download: { url: 'https://assets.example.test/tj-english-paper.pdf?token=smoke', expiresInSec: 300, expiresAt: new Date(Date.now() + 300_000).toISOString(), signatureMode: 'signed' },
watermark: { mode: 'visible_overlay', required: true, text: '工学题库 SMOKE', traceId: 'wm-download-smoke', position: 'diagonal', opacity: 0.16, repeat: true, renderHint: 'overlay' },
};
}
if (pathname === '/api/questions/videos') {
return {
videos: [{
id: ids.video,
questionId: query.get('questionId') || ids.question,
title: '本题视频解析',
description: '后端签名播放烟测视频',
duration: 180,
accessMode: 'svip',
playable: true,
}],
total: 1,
};
}
if (pathname === '/api/videos/play') {
return {
item: { id: body.videoId || ids.video, questionId: body.questionId || ids.question, title: '本题视频解析', duration: 180, accessMode: 'svip' },
playToken: 'video-play-token-smoke',
playback: { url: 'https://assets.example.test/video-smoke.mp4?token=smoke', expiresInSec: 300, expiresAt: new Date(Date.now() + 300_000).toISOString(), signatureMode: 'signed' },
watermark: { mode: 'visible_overlay', required: true, text: '工学题库 SMOKE', traceId: 'wm-video-smoke', position: 'diagonal', opacity: 0.16, repeat: true, renderHint: 'overlay' },
access: { accessMode: 'svip', consumedQuota: 0 },
};
}
if (pathname === '/api/scoreline/fields') {
return { items: [{ id: ids.scorelineField, regionId: ids.region, fieldKey: 'min_score', fieldName: '最低分', fieldType: 'number', unit: '分', isFilter: true, isVisible: true, isTrend: true, sortOrder: 1 }] };
}
if (pathname === '/api/scoreline/schools') {
return { items: [{ id: ids.school, regionId: ids.region, name: '天津职业大学', shortName: '天职大', isHot: true, order: 1 }] };
}
if (pathname === '/api/scoreline/majors') {
return { items: [{ id: ids.major, regionId: ids.region, schoolId: ids.school, name: '软件工程', order: 1, hasRestriction: false }] };
}
if (pathname === '/api/scoreline/years') return { items: [2026, 2025, 2024] };
if (pathname === '/api/scoreline/trend' || pathname === '/api/scoreline/records') {
return {
items: [{
id: ids.scorelineRecord,
regionId: ids.region,
schoolId: ids.school,
majorId: ids.major,
year: 2025,
schoolName: '天津职业大学',
majorName: '软件工程',
fieldValues: { min_score: 218 },
}],
total: 1,
};
}
if (pathname === '/api/ai/school-recommendations') {
return {
items: [{
id: ids.aiReport,
tenantId: ids.tenant,
userId: ids.user,
regionId: ids.region,
status: 'generated',
provider: 'local_rules',
promptVersion: 'smoke-v1',
inputPayload: { estimatedScore: 210, riskPreference: 'balanced' },
contextPayload: {},
resultPayload: {
schemaVersion: 'school-recommendation-report-v1',
summary: '建议优先关注天津职业大学软件工程方向。',
riskLevel: 'balanced',
recommendedSchools: [{
schoolId: ids.school,
schoolName: '天津职业大学',
majorId: ids.major,
majorName: '软件工程',
latestYear: 2025,
latestScore: 218,
averageScore: 214,
scoreGap: -8,
riskLevel: 'balanced',
confidence: 0.76,
reason: '与预估分接近,适合作为稳中带冲选择。',
tags: ['天津', '工科'],
}],
actionPlan: ['复盘公共课错题', '补齐英语词汇'],
disclaimers: ['烟测报告仅用于页面联调。'],
dataCoverage: { scorelines: 1 },
},
generatedAt: new Date().toISOString(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
}],
};
}
if (pathname === '/api/ai/school-recommendations/generate') {
return mockApiPayload('/api/ai/school-recommendations', 'GET', query, body);
}
if (pathname === '/api/ai/school-recommendations/export') {
return { item: { reportId: ids.aiReport, format: query.get('format') || 'markdown', fileName: 'school-report-smoke.md', mimeType: 'text/markdown', contentBase64: 'IyBTbW9rZQ==', contentText: '# Smoke', sha256: 'smoke', sizeBytes: 7 } };
}
if (pathname === '/api/tenant-admin/dashboard') return { item: tenantDashboard };
if (pathname === '/api/tenant-admin/overview') return { item: { slug: 'master', brandName: '工学题库商户后台' } };
if (pathname === '/api/tenant-admin/permissions') {
@@ -740,6 +973,12 @@ async function waitForApiRequest(api, path, method) {
}, 10_000);
}
async function navigateAndExpect(page, baseUrl, path, text, timeoutMs = 10_000) {
await page.navigate(`${baseUrl}${path}`);
await waitForPath(page, path.split('?')[0], timeoutMs);
await waitForText(page, text, timeoutMs);
}
async function runStudentJourney(browser, portal, api) {
const checks = [];
const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`);
@@ -775,6 +1014,61 @@ async function runStudentJourney(browser, portal, api) {
await waitForPath(page, '/pages/student/checkout/index');
await waitForText(page, '会员收银台');
checks.push({ id: 'student.profile.to_checkout', status: 'pass', detail: await currentPath(page) });
await navigateAndExpect(page, portal.baseUrl, '/pages/student/review/index?type=wrong', '错题本');
await waitForText(page, '开始复习');
await waitForText(page, '这是 H5 交互烟测题目');
await clickText(page, '收藏');
await waitForText(page, '收藏夹');
await clickText(page, '开始复习');
await waitForPath(page, '/pages/student/practice/index');
checks.push({ id: 'student.review.wrong_favorite', status: 'pass', detail: '错题本、收藏夹和后端组卷入口可打开' });
await navigateAndExpect(page, portal.baseUrl, '/pages/student/vocabulary/index', '背单词');
await waitForText(page, '学习概览');
await waitForText(page, 'analysis');
await clickText(page, '认识');
await waitForApiRequest(api, '/api/learning/vocabulary/review', 'POST');
checks.push({ id: 'student.vocabulary.review', status: 'pass', detail: '背单词计划和复习提交 API 已触发' });
await navigateAndExpect(page, portal.baseUrl, '/pages/student/handbook/index', '知识手册');
await waitForText(page, '高等数学手册');
await waitForText(page, '导数定义');
await waitForText(page, '变化率');
checks.push({ id: 'student.handbook.rendered', status: 'pass', detail: '手册科目、章节和知识点渲染正常' });
await navigateAndExpect(page, portal.baseUrl, '/pages/student/assets/index', '资料下载');
await waitForText(page, '天津专升本英语真题 PDF');
await clickText(page, '预览');
await waitForText(page, '追踪码');
await waitForApiRequest(api, '/api/catalog/assets/preview', 'GET');
await clickText(page, '下载');
await waitForText(page, '确认下载');
await waitForApiRequest(api, '/api/catalog/assets/download', 'GET');
checks.push({ id: 'student.assets.signed_watermark', status: 'pass', detail: '资料预览/下载短签名和水印面板可用' });
await navigateAndExpect(page, portal.baseUrl, `/pages/student/video/index?questionId=${ids.question}`, '视频解析');
await waitForText(page, '本题视频解析');
await clickText(page, '本题视频解析');
await waitForApiRequest(api, '/api/videos/play', 'POST');
checks.push({ id: 'student.video.play_auth', status: 'pass', detail: '视频列表和播放授权 API 已触发' });
await navigateAndExpect(page, portal.baseUrl, '/pages/student/scoreline/index', '历年分数线');
await waitForText(page, '天津职业大学');
await waitForText(page, '最低分');
checks.push({ id: 'student.scoreline.rendered', status: 'pass', detail: '分数线筛选、趋势和结果渲染正常' });
await navigateAndExpect(page, portal.baseUrl, '/pages/student/ai-school/index', 'AI择校推荐');
await waitForText(page, '推荐结果');
await waitForText(page, '天津职业大学');
checks.push({ id: 'student.ai_school.rendered', status: 'pass', detail: 'AI 择校报告列表和推荐结果可渲染' });
await navigateAndExpect(page, portal.baseUrl, '/pages/student/notifications/index', '消息中心');
await waitForText(page, '入门勋章已发放');
await clickText(page, '全部已读');
await waitForApiRequest(api, '/api/profile/notifications/status', 'POST');
checks.push({ id: 'student.notifications.status', status: 'pass', detail: '消息筛选和批量已读 API 已触发' });
return checks;
} finally {
page.close();
@@ -800,6 +1094,21 @@ async function runTenantJourney(browser, portal) {
await waitForPath(page, '/pages/tenant-admin/finance/index');
await waitForText(page, '财务运营');
checks.push({ id: 'tenant.workbench.to_finance', status: 'pass', detail: await currentPath(page) });
const moduleChecks = [
{ label: '数据看板', path: '/pages/tenant-admin/dashboard/index' },
{ label: '学生运营', path: '/pages/tenant-admin/students/index' },
{ label: '营销中心', path: '/pages/tenant-admin/marketing/index' },
{ label: '租户设置', path: '/pages/tenant-admin/settings/index' },
];
for (const item of moduleChecks) {
await page.navigate(`${portal.baseUrl}${portal.landingPath}`);
await waitForText(page, item.label);
await clickText(page, item.label);
await waitForPath(page, item.path);
await waitForText(page, item.label);
checks.push({ id: `tenant.workbench.to_${item.path.split('/').at(-2)}`, status: 'pass', detail: await currentPath(page) });
}
return checks;
} finally {
page.close();
@@ -825,6 +1134,19 @@ async function runPlatformJourney(browser, portal) {
await waitForPath(page, '/pages/platform-admin/billing/index');
await waitForText(page, '账务中心');
checks.push({ id: 'platform.workbench.to_billing', status: 'pass', detail: await currentPath(page) });
const moduleChecks = [
{ label: '公共题库', path: '/pages/platform-admin/question-banks/index', expected: '公共题库授权' },
{ label: '平台员工', path: '/pages/platform-admin/staff/index', expected: '平台员工' },
];
for (const item of moduleChecks) {
await page.navigate(`${portal.baseUrl}${portal.landingPath}`);
await waitForText(page, item.label);
await clickText(page, item.label);
await waitForPath(page, item.path);
await waitForText(page, item.expected);
checks.push({ id: `platform.workbench.to_${item.path.split('/').at(-2)}`, status: 'pass', detail: await currentPath(page) });
}
return checks;
} finally {
page.close();