diff --git a/.gitignore b/.gitignore index 4472349f..acd974fc 100644 --- a/.gitignore +++ b/.gitignore @@ -64,6 +64,8 @@ docs/refactor/launch-artifacts/ # ✅ 参考资料(非源码) 参考/ 新UI参考/ +sao_live_0630/ +whisper_models/ # ✅ Electron 打包产物 /release/ diff --git a/README.md b/README.md index 389d797a..34e4e594 100644 --- a/README.md +++ b/README.md @@ -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/s,150 并发 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.29ms;50 worker/60 秒/10% 写入为 35,608 请求、0 错误、584.18 req/s、P95 174.26ms;100 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.80ms;50 worker/60 秒/10% 写入为 25,327 请求、0 错误、419.62 req/s、P95 213.17ms;100 worker/60 秒/8% 写入为 25,378 请求、0 错误、417.77 req/s、P95 383.72ms;150 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 diff --git a/apps/api/src/features/profile/badges.ts b/apps/api/src/features/profile/badges.ts index 2391da2a..c9060c93 100644 --- a/apps/api/src/features/profile/badges.ts +++ b/apps/api/src/features/profile/badges.ts @@ -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), ], diff --git a/docs/refactor/backend-open-items-and-capacity-20260701.md b/docs/refactor/backend-open-items-and-capacity-20260701.md index 6b16c85e..31705512 100644 --- a/docs/refactor/backend-open-items-and-capacity-20260701.md +++ b/docs/refactor/backend-open-items-and-capacity-20260701.md @@ -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、对象存储和真实前端埋点完成后再上调。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index 153f19bf..9d32c2d5 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -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.04ms;100 worker/60s/8% 写入为 33,843 请求、0 错误、554.42 req/s、P95 326.21ms、P99 422.33ms;150 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.08ms;50 worker/60s/10% 写入为 25,327 请求、0 错误、419.62 req/s、P95 213.17ms、P99 270.94ms;100 worker/60s/8% 写入为 25,378 请求、0 错误、417.77 req/s、P95 383.72ms、P99 461.22ms;150 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`。后续不要再把学生头像上传或默认排行榜当作待办;头像只保留男女预设,排行榜仅作为租户显式开启后的活动能力。 diff --git a/docs/refactor/performance-benchmark-runbook.md b/docs/refactor/performance-benchmark-runbook.md index 0b67cd4b..a6b74808 100644 --- a/docs/refactor/performance-benchmark-runbook.md +++ b/docs/refactor/performance-benchmark-runbook.md @@ -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 和真实网络下复跑。 diff --git a/docs/refactor/web-launch-acceptance-checklist.md b/docs/refactor/web-launch-acceptance-checklist.md index e27f0926..a0d02d2d 100644 --- a/docs/refactor/web-launch-acceptance-checklist.md +++ b/docs/refactor/web-launch-acceptance-checklist.md @@ -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`,再运行严格模式: diff --git a/package.json b/package.json index 121c595d..a48c20ab 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/auto-badge-concurrency-test.js b/scripts/auto-badge-concurrency-test.js new file mode 100644 index 00000000..460c5df2 --- /dev/null +++ b/scripts/auto-badge-concurrency-test.js @@ -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; +}); diff --git a/scripts/run-docker-4c16g-benchmark.js b/scripts/run-docker-4c16g-benchmark.js index 6b1365ce..bfc8cecf 100644 --- a/scripts/run-docker-4c16g-benchmark.js +++ b/scripts/run-docker-4c16g-benchmark.js @@ -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; +}); diff --git a/scripts/taro-h5-interaction-smoke.js b/scripts/taro-h5-interaction-smoke.js index 9888dd79..646e31c7 100644 --- a/scripts/taro-h5-interaction-smoke.js +++ b/scripts/taro-h5-interaction-smoke.js @@ -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();