forked from wangziqi/gongxue-base
test: deepen taro h5 admin smoke and record capacity
This commit is contained in:
18
README.md
18
README.md
@@ -53,7 +53,7 @@
|
||||
| 对象存储/资料安全 | √ 可联调,待生产 AV/CDN | OSS/COS/Supabase Storage 签名、上传确认、短签名预览下载、水印 traceId、复检和安全扫描地基已完成;生产配置会拒绝 local_dev、非 HTTPS 公开 URL、非官方阿里云 OSS endpoint、阿里云 OSS 内网直签和未接外部扫描服务 |
|
||||
| PocketBase 真实数据迁移 | √ 本地跑通,待人工复核 blocker | SQLite 导出、标准化导入、校验和抽样脚本已跑通;正式切换前处理缺用户订单和缺归属手册章节 |
|
||||
| Taro H5 三端前端 | √ 第一版可构建 | 学生端、租户后台、平台后台均有真实 API 页面;已补 H5 `index.html` 模板和发布产物守卫;后续继续补小程序兼容、视觉精修、状态管理、包体优化和端到端测试 |
|
||||
| 生产安全/压测交付 | △ 本地真实数据压测已跑,云端待复测 | 本地 Docker/Supabase 已完成真实迁移数据 30/50/100/150 并发只读和混合读写压测;最新 Docker API 受限资源复核为 30 并发只读 0 错误、321.92 req/s、P95 225.17ms,50 并发混合读写 0 错误、394.19 req/s、P95 235.96ms。进一步显式限制 DB 容器为 2 CPU/8G 后,30 只读和 50 混合读写均 0 错误但 P95 分别升至 1183.29ms/996.01ms,未通过上线延迟门禁。上云后必须执行生产 readiness、PostgreSQL shared-host 调优、远程 Auth/RLS、4C16G 复测、`security:repo`、真实 `@codex-security` 扫描和上线证据门禁。 |
|
||||
| 生产安全/压测交付 | △ 本地真实数据压测已跑,云端待复测 | 本地 Docker/Supabase 已完成真实迁移数据 30/50/100/150 并发只读和混合读写压测;最新 Docker API 受限资源复核为 30 并发只读 0 错误、305.59 req/s、P95 241.46ms,50 并发混合读写 0 错误、398.79 req/s、P95 231.28ms,100 并发混合读写 0 错误、370.86 req/s、P95 445.26ms,150 并发混合读写 0 错误、340.74 req/s、P95 689.67ms。进一步显式限制 DB 容器为 2 CPU/8G 后,30 只读和 50 混合读写均 0 错误但 P95 分别升至 1183.29ms/996.01ms,未通过上线延迟门禁。上云后必须执行生产 readiness、PostgreSQL shared-host 调优、远程 Auth/RLS、4C16G 复测、`security:repo`、真实 `@codex-security` 扫描和上线证据门禁。 |
|
||||
|
||||
更完整的进度看这些文档:
|
||||
|
||||
@@ -620,7 +620,7 @@ npm run manifest:taro:h5
|
||||
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 产物并点击 26 项关键入口,覆盖学生首页、题库、答题、收藏、错题/收藏复习、背单词、知识手册、资料短签名和水印、视频播放授权、分数线、AI 择校、消息中心、会员收银台下单/支付参数/订单状态,租户后台六个主模块,以及平台后台四个主模块,确认页面 JS 执行、路由跳转、关键 API 和后台入口点击没有空白页或运行时异常。`manifest:taro:h5` 会生成三套 H5 的部署清单,记录构建命令、发布目录、入口路由、`index.html` hash、资源数量、runtime-config 状态和租户解析模式,方便前端/运维核对实际上传目录。`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 产物并点击 32 项关键入口,覆盖学生首页、题库、答题、收藏、错题/收藏复习、背单词、知识手册、资料短签名和水印、视频播放授权、分数线、AI 择校、消息中心、会员收银台下单/支付参数/订单状态,租户后台内容导入、公共题库采纳/同步/冲突处理、学生运营、营销/CRM/分佣、主题/角色/成员写操作,以及平台后台租户、账务、公共题库授权和员工写操作,确认页面 JS 执行、路由跳转、关键 API 和后台入口点击没有空白页或运行时异常。`manifest:taro:h5` 会生成三套 H5 的部署清单,记录构建命令、发布目录、入口路由、`index.html` hash、资源数量、runtime-config 状态和租户解析模式,方便前端/运维核对实际上传目录。`taro-h5-release-guardrails-test` 会确认三套 H5 目录存在 `index.html`,并扫描源码/产物是否混入旧 PocketBase、`x-user-id`、平台本地 key、数据库连接串或服务端密钥形态。正式部署时还必须在每个 H5 目录根部放置对应的 `runtime-config.json`。
|
||||
|
||||
写入生产上线证据时使用严格模式,确保三套正式发布目录已经放好真实公开 `runtime-config.json`,且 warning 为 0:
|
||||
|
||||
@@ -631,16 +631,16 @@ node scripts\taro-h5-release-guardrails-test.js --require-dist --require-runtime
|
||||
npm --silent run manifest:taro:h5 -- --require-dist --require-runtime-config --json --write docs/refactor/launch-artifacts/taro-h5-release-manifest.json > docs/refactor/launch-artifacts/taro-h5-release-manifest.stdout.json
|
||||
```
|
||||
|
||||
最近一次本地真实迁移库已包含前期压测写入记录,当前规模约为 19 个租户、74,131 题、1,631 个题目合集、3,120 个练习蓝图、3,519 个单词、2,692 条知识手册、3,722 个用户、72,710 个练习 session、302,585 条答题记录、38,207 条错题、1,501 个内容资源和 472 条权益。压测 worker 是无停顿请求流,不能直接等同于真实在线学生数;前端完成后需要用真实页面埋点估算单个学生平均 RPS,再折算在线容量。
|
||||
最近一次本地真实迁移库已包含前期压测写入记录,当前规模约为 19 个租户、7.4 万题、1,630 个题目合集、3,120 个练习蓝图、3,519 个单词、2,692 条知识手册、3,722 个用户、7.3 万个练习 session、30.3 万条答题记录、3.8 万条错题、1,501 个内容资源和 472 条权益。压测 worker 是无停顿请求流,不能直接等同于真实在线学生数;前端完成后需要用真实页面埋点估算单个学生平均 RPS,再折算在线容量。
|
||||
|
||||
README 只保留最新 Docker API 受限资源复核。更早的高资源 Docker Desktop 历史结果和每轮说明见 `docs/refactor/performance-benchmark-summary-20260630.md`。
|
||||
|
||||
| 并发 worker | 时长 | 刷题写入比例 | 请求数 | 错误率 | 吞吐 | P95 | P99 |
|
||||
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| Docker API 2c4g / 30 | 120s | 0% | 38,724 | 0.00% | 321.92 req/s | 225.17 ms | 338.35 ms |
|
||||
| Docker API 2c4g / 50 | 60s | 10% | 23,810 | 0.00% | 394.19 req/s | 235.96 ms | 320.19 ms |
|
||||
| Docker API 2c4g / 100 | 60s | 8% | 23,384 | 0.00% | 386.14 req/s | 417.12 ms | 498.89 ms |
|
||||
| Docker API 2c4g / 150 | 60s | 6% | 22,314 | 0.00% | 366.79 req/s | 613.15 ms | 744.50 ms |
|
||||
| Docker API 2c4g / 30 | 120s | 0% | 36,800 | 0.00% | 305.59 req/s | 241.46 ms | 351.17 ms |
|
||||
| Docker API 2c4g / 50 | 60s | 10% | 24,120 | 0.00% | 398.79 req/s | 231.28 ms | 325.94 ms |
|
||||
| Docker API 2c4g / 100 | 60s | 8% | 22,462 | 0.00% | 370.86 req/s | 445.26 ms | 532.32 ms |
|
||||
| Docker API 2c4g / 150 | 60s | 6% | 20,708 | 0.00% | 340.74 req/s | 689.67 ms | 833.08 ms |
|
||||
| Docker API 2c4g + DB 2c8g / 30 | 120s | 0% | 8,397 | 0.00% | 69.48 req/s | 1183.29 ms | 1697.59 ms |
|
||||
| Docker API 2c4g + DB 2c8g / 50 | 60s | 10% | 6,640 | 0.00% | 109.61 req/s | 996.01 ms | 1303.45 ms |
|
||||
| Docker API 2c4g + DB 2c8g / 100 | 60s | 8% | 6,381 | 0.00% | 104.13 req/s | 1705.51 ms | 2098.41 ms |
|
||||
@@ -649,12 +649,12 @@ README 只保留最新 Docker API 受限资源复核。更早的高资源 Docker
|
||||
只读上线门禁继续要求 `includeWrites=false`;混合读写报告需要显式使用 `--allow-writes` 做人工容量观察,例如:
|
||||
|
||||
```powershell
|
||||
npm run perf:summary -- --input docs/refactor/performance-reports/api-benchmark-20260701-064518.json --json --allow-writes --min-duration-seconds=60 --min-concurrency=50 --max-p95-ms=500 --max-p99-ms=1200
|
||||
npm run perf:summary -- --input docs/refactor/performance-reports/api-benchmark-20260701-075709.json --json --allow-writes --min-duration-seconds=60 --min-concurrency=50 --max-p95-ms=500 --max-p99-ms=1200
|
||||
```
|
||||
|
||||
使用 `--allow-writes` 时会输出 `capacityObservation`,不输出 `launchGateCheck`,不能把写入场景误填成生产上线门禁的只读证据。
|
||||
|
||||
本地结论:当前 Docker Desktop 分配 20 CPU、约 63GB 内存,高于常见 4 核 16G 云服务器,不能直接作为生产 SLA。2026-07-01 06:44 只限制 API 容器时,50 worker 混合读写为 23,810 请求、0 错误、394.19 req/s、P95 235.96ms,这是“DB 未受限”的乐观观察;按 0.05 到 0.2 req/s/人的页面节奏粗略折算,约对应 1,970 到 7,880 名活跃在线学生请求吞吐。2026-07-01 06:53 同时限制 API 2C/4G 和 DB 2C/8G 时,30 worker 只读和 50 worker 混合读写仍 0 错误,但 P95 分别升至 1183.29ms 和 996.01ms,未通过上线延迟门禁,这是“DB 受限但未调 PostgreSQL 参数”的悲观观察;50 worker 109.61 req/s 只可粗略折算约 548 到 2,192 名活跃在线学生的下限吞吐,不能作为可售 SLA。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL shared-host 参数、`pg_stat_statements`、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要和剩余功能清单见:
|
||||
本地结论:当前 Docker Desktop 分配 20 CPU、约 63GB 内存,高于常见 4 核 16G 云服务器,不能直接作为生产 SLA。2026-07-01 07:59 只限制 API 容器时,50 worker 混合读写为 24,120 请求、0 错误、398.79 req/s、P95 231.28ms,这是“DB 未受限”的乐观观察;按 0.05 到 0.2 req/s/人的页面节奏粗略折算,约对应 1,994 到 7,976 名活跃在线学生请求吞吐。100 worker 上沿为 370.86 req/s、P95 445.26ms,约对应 1,854 到 7,417 名;150 worker 进入压力区,为 340.74 req/s、P95 689.67ms,约对应 1,704 到 6,815 名。2026-07-01 06:53 同时限制 API 2C/4G 和 DB 2C/8G 时,30 worker 只读和 50 worker 混合读写仍 0 错误,但 P95 分别升至 1183.29ms 和 996.01ms,未通过上线延迟门禁,这是“DB 受限但未调 PostgreSQL 参数”的悲观观察;50 worker 109.61 req/s 只可粗略折算约 548 到 2,192 名活跃在线学生的下限吞吐,不能作为可售 SLA。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL shared-host 参数、`pg_stat_statements`、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要和剩余功能清单见:
|
||||
|
||||
```text
|
||||
docs/refactor/performance-benchmark-summary-20260630.md
|
||||
|
||||
@@ -46,7 +46,7 @@
|
||||
- 当前本地迁移库规模:19 个租户、约 7.4 万道题、3,722 个用户、1,631 个题目合集、3,120 个练习蓝图、3,519 个单词、2,692 条知识手册条目、7.3 万练习 session、30.3 万条答题记录、3.8 万条错题、1,501 个内容资源、472 条权益。后续 `db:smoke-seed`、集成测试和压测会继续写入练习/答题/测试租户数据,因此这里按当前本地观察描述,不把每次测试后的瞬时行数当作生产 SLA 口径。
|
||||
- 排行榜未纳入默认负载,因为产品默认关闭。
|
||||
|
||||
### 2026-07-01 真实迁移库 Docker API 受限资源复核
|
||||
### 2026-07-01 07:59 真实迁移库 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。
|
||||
|
||||
@@ -54,14 +54,14 @@
|
||||
|
||||
| 场景 | 并发 worker | 时长 | 刷题写入比例 | 请求数 | 错误率 | 吞吐 | P95 | P99 | 结论 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
|
||||
| 只读上线门禁 | 30 | 120s | 0% | 38,724 | 0.00% | 321.92 req/s | 225.17 ms | 338.35 ms | 通过 |
|
||||
| 混合读写 | 50 | 60s | 10% | 23,810 | 0.00% | 394.19 req/s | 235.96 ms | 320.19 ms | 舒适 |
|
||||
| 混合读写 | 100 | 60s | 8% | 23,384 | 0.00% | 386.14 req/s | 417.12 ms | 498.89 ms | 可用但延迟偏高 |
|
||||
| 混合读写 | 150 | 60s | 6% | 22,314 | 0.00% | 366.79 req/s | 613.15 ms | 744.50 ms | 压力区 |
|
||||
| 只读上线门禁 | 30 | 120s | 0% | 36,800 | 0.00% | 305.59 req/s | 241.46 ms | 351.17 ms | 通过 |
|
||||
| 混合读写 | 50 | 60s | 10% | 24,120 | 0.00% | 398.79 req/s | 231.28 ms | 325.94 ms | 舒适 |
|
||||
| 混合读写 | 100 | 60s | 8% | 22,462 | 0.00% | 370.86 req/s | 445.26 ms | 532.32 ms | 可用但延迟偏高 |
|
||||
| 混合读写 | 150 | 60s | 6% | 20,708 | 0.00% | 340.74 req/s | 689.67 ms | 833.08 ms | 压力区 |
|
||||
|
||||
混合读写包含真实刷题闭环:创建练习 session、拉取 session detail、提交答案、交卷、读取报告。
|
||||
|
||||
同轮 `npm run smoke:launch-persona` 已通过,覆盖普通学生 SVIP 后刷题、收藏、错题复习入口,租户管理员 dashboard/主题/学生/销售转化入口,平台管理员租户/套餐/审计入口,以及学生越权后台和跨租户访问拒绝。
|
||||
同轮 `npm run smoke:taro:h5:interaction` 已通过 32/32,覆盖学生端核心旅程、租户后台真实写操作和平台后台真实写操作;`npm run smoke:launch-persona` 已通过,覆盖普通学生 SVIP 后刷题、收藏、错题复习入口,租户管理员 dashboard/主题/学生/销售转化入口,平台管理员租户/套餐/审计入口,以及学生越权后台和跨租户访问拒绝。
|
||||
|
||||
### 在线人数折算
|
||||
|
||||
@@ -69,11 +69,11 @@
|
||||
|
||||
按目前常见页面节奏先用 `0.05 到 0.2 req/s/人` 粗略折算:
|
||||
|
||||
- 50 worker 舒适场景 394.19 req/s,约等于 1,970 到 7,880 名活跃在线学生的请求吞吐。
|
||||
- 100 worker 上沿场景 386.14 req/s,约等于 1,930 到 7,720 名活跃在线学生的请求吞吐。
|
||||
- 150 worker 压力场景 366.79 req/s,约等于 1,830 到 7,330 名活跃在线学生的请求吞吐。
|
||||
- 50 worker 舒适场景 398.79 req/s,约等于 1,994 到 7,976 名活跃在线学生的请求吞吐。
|
||||
- 100 worker 上沿场景 370.86 req/s,约等于 1,854 到 7,417 名活跃在线学生的请求吞吐。
|
||||
- 150 worker 压力场景 340.74 req/s,约等于 1,704 到 6,815 名活跃在线学生的请求吞吐。
|
||||
|
||||
当前本地结论:在受限 API 容器下,后端真实刷题读写链路的舒适观察区间约为 394 req/s,折算约 1,970 到 7,880 名活跃在线学生;100 worker 仍可用但 P95 已超过 400ms,150 worker 仍 0 错误但 P95 已超过 600ms,视为压力区,不建议作为生产承诺。受限 API 容器会明显压低上沿,因此上线承诺仍以云端受限资源复测为准。
|
||||
当前本地结论:在受限 API 容器下,后端真实刷题读写链路的舒适观察区间约为 399 req/s,折算约 1,994 到 7,976 名活跃在线学生;100 worker 仍可用但 P95 已超过 400ms,150 worker 仍 0 错误但 P95 已接近 700ms,视为压力区,不建议作为生产承诺。受限 API 容器会明显压低上沿,因此上线承诺仍以云端受限资源复测为准。
|
||||
|
||||
### 2026-07-01 Docker API + DB 显式受限复核
|
||||
|
||||
@@ -86,4 +86,4 @@
|
||||
| 混合读写 | 100 | 60s | 8% | 6,381 | 0.00% | 104.13 req/s | 1705.51 ms | 2098.41 ms | 压力区 |
|
||||
| 混合读写 | 150 | 60s | 6% | 5,211 | 0.00% | 83.23 req/s | 3006.71 ms | 3699.64 ms | 压力区 |
|
||||
|
||||
这组结果说明:代码在更严格资源下仍保持 0 错误,但默认 PostgreSQL 参数无法支撑上线延迟门禁。正式对外容量必须等 4 核 16G 云服务器部署、PostgreSQL shared-host 调参、`pg_stat_statements` 和慢 SQL 观察完成后复跑。没有云端复测前,不再把本地 06:44 API-only 观察值当生产容量承诺,只保留为乐观参考;06:53 DB 受限结果则作为未调参下限参考。
|
||||
这组结果说明:代码在更严格资源下仍保持 0 错误,但默认 PostgreSQL 参数无法支撑上线延迟门禁。正式对外容量必须等 4 核 16G 云服务器部署、PostgreSQL shared-host 调参、`pg_stat_statements` 和慢 SQL 观察完成后复跑。没有云端复测前,不再把本地 07:59 API-only 观察值当生产容量承诺,只保留为乐观参考;06:53 DB 受限结果则作为未调参下限参考。
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
- 已补 `npm run launch:gate` 生产上线证据门禁和 `docs/refactor/production-launch-evidence.template.json` 模板;最终切换前必须把 readiness、远程 Auth、RLS、PostgreSQL 4c16g 严格调参证据、生产 dry-run、导入校验、`pb:import:sample` 业务抽样、真实数据 API 只读压测、真实数据混合读写压测、API/worker/Taro、运行时审计、`security:repo`、真实 `@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 perf:postgres:sql`、`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`、`node scripts/product-scope-guardrails-test.js` 和 `docs/refactor/performance-benchmark-runbook.md`,可在本地或云端对真实迁移数据做只读门禁、混合读写容量观察、PostgreSQL 4c16g profile 调参证据、三类后端角色旅程烟测、三类 Taro 前端角色旅程契约、H5 发布目录启动烟测、真实浏览器关键点击烟测、前端 API 契约检查和产品口径防回归检查。2026-07-01 06:44 API-only 受限容器复核中,50 worker/60s/10% 写入为 23,810 请求、0 错误、394.19 req/s、P95 235.96ms、P99 320.19ms;2026-07-01 06:53 进一步显式限制 DB 容器为 2 CPU/8G 后,30 worker 只读为 8,397 请求、0 错误、69.48 req/s、P95 1183.29ms,50 worker/60s/10% 写入为 6,640 请求、0 错误、109.61 req/s、P95 996.01ms,均未通过上线延迟门禁。受限容器系列压测曾抓到自动勋章并发发放唯一键冲突,已修复并新增 `scripts/auto-badge-concurrency-test.js`。当前结论是:本地 API-only 结果只能作为乐观观察,DB 受限未调参结果作为悲观下限;正式容量仍以上云 4 核 16G、PostgreSQL shared-host 调参、真实前端埋点和生产对象存储/CDN 复测为准。
|
||||
- 已补 `npm run perf:api:local`、`npm run perf:summary`、`npm run perf:postgres:evidence`、`npm run perf:postgres:sql`、`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`、`node scripts/product-scope-guardrails-test.js` 和 `docs/refactor/performance-benchmark-runbook.md`,可在本地或云端对真实迁移数据做只读门禁、混合读写容量观察、PostgreSQL 4c16g profile 调参证据、三类后端角色旅程烟测、三类 Taro 前端角色旅程契约、H5 发布目录启动烟测、真实浏览器关键点击烟测、前端 API 契约检查和产品口径防回归检查。2026-07-01 07:59 API-only 受限容器复核中,30 worker/120s 只读为 36,800 请求、0 错误、305.59 req/s、P95 241.46ms;50 worker/60s/10% 写入为 24,120 请求、0 错误、398.79 req/s、P95 231.28ms;100 worker/60s/8% 写入为 22,462 请求、0 错误、370.86 req/s、P95 445.26ms;150 worker/60s/6% 写入为 20,708 请求、0 错误、340.74 req/s、P95 689.67ms。2026-07-01 06:53 进一步显式限制 DB 容器为 2 CPU/8G 后,30 worker 只读为 8,397 请求、0 错误、69.48 req/s、P95 1183.29ms,50 worker/60s/10% 写入为 6,640 请求、0 错误、109.61 req/s、P95 996.01ms,均未通过上线延迟门禁。受限容器系列压测曾抓到自动勋章并发发放唯一键冲突,已修复并新增 `scripts/auto-badge-concurrency-test.js`。当前结论是:本地 API-only 结果只能作为乐观观察,DB 受限未调参结果作为悲观下限;正式容量仍以上云 4 核 16G、PostgreSQL shared-host 调参、真实前端埋点和生产对象存储/CDN 复测为准。
|
||||
- 当前本地 PostgreSQL evidence 仍提示 `jit=on`、`statement_timeout=0`、`idle_in_transaction_session_timeout=0`、`lock_timeout=0`;上云后必须按 `docs/refactor/postgresql-4c16g-tuning.md` 调整参数,执行 `PG_TUNING_PROFILE=shared-host npm run perf:postgres:evidence -- --strict --json` 并通过后,再按 6/30/50/100 阶梯并发复跑容量报告并归档到本地上线证据。
|
||||
- 本轮剩余功能和容量复核已经整理到 `docs/refactor/backend-open-items-and-capacity-20260701.md`。后续不要再把学生头像上传、默认排行榜或已完成的学生消息中心当作待办;头像只保留男女预设,排行榜仅作为租户显式开启后的活动能力。`scripts/product-scope-guardrails-test.js` 已纳入 `npm run test:readiness`,用于阻断这些旧口径回流。
|
||||
|
||||
@@ -263,11 +263,11 @@
|
||||
|
||||
## 推荐下一步顺序
|
||||
|
||||
1. 补租户后台写操作台:公共题库采纳/同步、冲突查看、单条/批量冲突采纳平台或保留本地、导入问题、模板预览/下载、上传/粘贴 preview/import、字段映射编辑、异步导入轮询、导入后复检详情、权限驱动工作台、学生创建/更新/批量导入/批量分班/备注/跟进、角色模板配置、成员绑定模板、CRM 配置/队列/跟进分配策略、分佣规则/成员比例/结算生成审核打款/导出/凭证复核已接第一版;继续补成员批量运营、更细数据范围 UI、真实打款 provider 和发票。
|
||||
1. 租户后台写操作台已接第一版并纳入 H5 真实交互烟测:公共题库采纳/同步、冲突查看、批量保留本地、导入 preview/import/post-check、权限驱动工作台、学生备注/跟进/督导/CRM 推送、积分/兑换/优惠券、CRM 配置、分佣规则/成员比例/结算生成/导出、主题预览/发布、角色模板和成员绑定。下一步继续补成员批量运营、更细数据范围 UI、真实打款 provider、发票、导入字段映射更细 UI 和分佣凭证复核体验。
|
||||
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、男女预设头像选择、收银台、订单详情、售后入口、独立消息中心、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、知识手册章节内搜索/安全摘要高亮/目录定位、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词学习概览/卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、小程序支付容器、分享场景和状态管理。前端不得实现头像上传、头像裁剪或第三方头像同步。
|
||||
3. 补平台后台增强:租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。
|
||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||
5. 三套 H5 上云前必须在目标目录补真实公开 `runtime-config.json`,先执行 `npm run smoke:taro:h5`、`npm run smoke:taro:h5:interaction` 和 `node scripts/taro-h5-release-guardrails-test.js --require-dist` 做本地发布目录烟测、真实浏览器交互烟测和发布守卫;写入生产上线证据时必须执行 `npm --silent run smoke:taro:h5 -- --json`、`npm --silent run smoke:taro:h5:interaction -- --json` 和 `node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config --json`,确保 warning 为 0。随后人工打开学生端/租户后台/平台后台域名,确认入口、租户解析、登录态和 `Authorization + x-tenant-id` 请求正常。当前构建仍有 webpack 体积 warning,后续做首屏拆包、按入口拆页面和 Supabase client 引入优化。
|
||||
5. 三套 H5 上云前必须在目标目录补真实公开 `runtime-config.json`,先执行 `npm run smoke:taro:h5`、`npm run smoke:taro:h5:interaction` 和 `node scripts/taro-h5-release-guardrails-test.js --require-dist` 做本地发布目录烟测、真实浏览器交互烟测和发布守卫;写入生产上线证据时必须执行 `npm --silent run smoke:taro:h5 -- --json`、`npm --silent run smoke:taro:h5:interaction -- --json` 和 `node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config --json`,确保 warning 为 0。当前 H5 交互烟测为 32/32 通过,已经覆盖租户后台和平台后台真实写操作;随后仍要人工打开学生端/租户后台/平台后台域名,确认入口、租户解析、登录态和 `Authorization + x-tenant-id` 请求正常。当前构建仍有 webpack 体积 warning,后续做首屏拆包、按入口拆页面和 Supabase client 引入优化。
|
||||
6. 使用 `F:\project\参考\旧题库数据库文件` 中的真实 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 继续做人工复核和抽样验收;当前 dry-run/导入/校验链路已跑通,下一步重点是 7 个已支付缺用户订单、22 个待复核手册章节、2533 道旧分类缺失题目和 5298 条引用已删除题目的答题记录的运营处理结论。导入过程中发现的字段污染、跨集合引用断裂、敏感字段和旧权限问题都要沉淀到 importer mapper 或修复脚本,不手工临时修库。
|
||||
7. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
|
||||
8. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
||||
|
||||
@@ -181,11 +181,30 @@
|
||||
|
||||
结论:资源更严格后系统仍保持 0 错误,但延迟不满足上线门禁。按 50 worker 混合读写 109.61 req/s 粗略折算,理论请求吞吐约对应 548 到 2,192 名活跃在线学生;由于该轮未调 PostgreSQL 参数且只在本地 Docker Desktop 上运行,只能作为“未调参受限资源下限观察”,不能作为首版生产规划值。
|
||||
|
||||
### 2026-07-01 07:59 Docker API 受限资源复核
|
||||
|
||||
本轮在增强 `scripts/taro-h5-interaction-smoke.js` 后复跑。H5 真实交互烟测已覆盖学生端核心旅程、租户后台内容导入/公共题库/学生运营/营销 CRM 分佣/主题角色成员写操作,以及平台后台租户/账务/题库授权/员工写操作,结果为 32/32 通过。随后使用 `npm run perf:api:docker-4c16g` 在真实迁移库上复跑 30/50/100/150 阶梯;API 容器限制为 2 CPU/4G,数据库未额外限制,压测鉴权为 `PERF_AUTH_MODE=app_session`。排行榜仍未纳入默认负载,头像相关链路不参与压测,因为学生头像产品策略已固定为男女预设头像。
|
||||
|
||||
| 场景 | 并发 worker | 时长 | 刷题写入比例 | 请求数 | 错误率 | 吞吐 | P95 | P99 | 门禁结论 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
|
||||
| 只读上线门禁 | 30 | 120s | 0% | 36,800 | 0.00% | 305.59 req/s | 241.46 ms | 351.17 ms | 通过 |
|
||||
| 混合读写 | 50 | 60s | 10% | 24,120 | 0.00% | 398.79 req/s | 231.28 ms | 325.94 ms | 通过 |
|
||||
| 混合读写 | 100 | 60s | 8% | 22,462 | 0.00% | 370.86 req/s | 445.26 ms | 532.32 ms | 上沿观察 |
|
||||
| 混合读写 | 150 | 60s | 6% | 20,708 | 0.00% | 340.74 req/s | 689.67 ms | 833.08 ms | 压力区 |
|
||||
|
||||
本轮结论:
|
||||
|
||||
- 30 worker 只读门禁通过,满足 `errors=0`、`errorRate<=0.001`、`p95<=300ms`、`p99<=800ms`、`duration>=120s`。
|
||||
- 50 worker 混合读写满足 `P95<=500ms/P99<=1200ms`,仍是当前受限 API 容器下的舒适观察区。
|
||||
- 100 worker 混合读写 0 错误但 P95 已到 445.26ms,适合作为上沿观察;150 worker 0 错误但 P95 已到 689.67ms,只作为压力区观察。
|
||||
|
||||
按 0.05 到 0.2 req/s/人的页面节奏粗略折算,50 worker 舒适吞吐约对应 1,994 到 7,976 名活跃在线学生请求吞吐;100 worker 上沿约对应 1,854 到 7,417 名;150 worker 压力区约对应 1,704 到 6,815 名。该数字仍是本地 API 受限、DB 未受限观察值,正式容量必须在目标云服务器和真实前端埋点下复跑。
|
||||
|
||||
## 初步结论
|
||||
|
||||
- 本地高资源 Docker Desktop 历史结果显示代码和索引在 50/100 worker 下有较高吞吐;只限制 API 容器时,最新 50 worker 混合读写 P95 约 236ms,100 worker P95 约 417ms,150 worker P95 约 613ms;进一步把 DB 容器也限制为 2 CPU/8G 后,30 worker 只读和 50 worker 混合读写均未通过延迟门禁。
|
||||
- 本地高资源 Docker Desktop 历史结果显示代码和索引在 50/100 worker 下有较高吞吐;只限制 API 容器时,最新 50 worker 混合读写 P95 约 231ms,100 worker P95 约 445ms,150 worker P95 约 690ms;进一步把 DB 容器也限制为 2 CPU/8G 后,30 worker 只读和 50 worker 混合读写均未通过延迟门禁。
|
||||
- 最新 DB 显式受限结果说明:不能仅凭本地 Docker API 受限跑分给生产承诺,生产 4C16G 必须先应用 PostgreSQL shared-host 调参并启用 `pg_stat_statements`,再跑正式只读/混合读写矩阵。
|
||||
- 生产如果使用 4 核 16G 单机,首版容量承诺不要直接沿用本地 API-only 的人数估算;应先以云端调参后复测结果为准。没有云端复测前,可以把 06:44 结果看作“API 受限但 DB 未受限的乐观观察”,把 06:53 结果看作“DB 也受限但未调参的悲观观察”。
|
||||
- 生产如果使用 4 核 16G 单机,首版容量承诺不要直接沿用本地 API-only 的人数估算;应先以云端调参后复测结果为准。没有云端复测前,可以把 07:59 结果看作“API 受限但 DB 未受限的乐观观察”,把 06:53 结果看作“DB 也受限但未调参的悲观观察”。
|
||||
- 正式容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、对象存储/CDN、真实前端请求节奏和生产网络下复跑。
|
||||
|
||||
## 后续复测
|
||||
|
||||
@@ -35,6 +35,22 @@ const ids = {
|
||||
scorelineRecord: '00000000-0000-4000-8000-000000001304',
|
||||
notification: '00000000-0000-4000-8000-000000001401',
|
||||
aiReport: '00000000-0000-4000-8000-000000001501',
|
||||
class: '00000000-0000-4000-8000-000000001601',
|
||||
teacher: '00000000-0000-4000-8000-000000001602',
|
||||
tenantMember: '00000000-0000-4000-8000-000000001603',
|
||||
roleTemplate: '00000000-0000-4000-8000-000000001604',
|
||||
importJob: '00000000-0000-4000-8000-000000001701',
|
||||
publicBank: '00000000-0000-4000-8000-000000001801',
|
||||
publicBankGrant: '00000000-0000-4000-8000-000000001802',
|
||||
publicBankAdoption: '00000000-0000-4000-8000-000000001803',
|
||||
crmQueue: '00000000-0000-4000-8000-000000001901',
|
||||
coupon: '00000000-0000-4000-8000-000000002001',
|
||||
pointTask: '00000000-0000-4000-8000-000000002101',
|
||||
pointExchange: '00000000-0000-4000-8000-000000002102',
|
||||
settlement: '00000000-0000-4000-8000-000000002201',
|
||||
proof: '00000000-0000-4000-8000-000000002202',
|
||||
platformStaff: '00000000-0000-4000-8000-000000002301',
|
||||
invoice: '00000000-0000-4000-8000-000000002401',
|
||||
};
|
||||
|
||||
const portals = [
|
||||
@@ -182,6 +198,170 @@ const tenantDashboard = {
|
||||
recentActivities: [{ activityType: 'student_registered', title: '新增学生注册', occurredAt: new Date().toISOString() }],
|
||||
};
|
||||
|
||||
const tenantStudent = {
|
||||
membershipId: 'member-student-smoke',
|
||||
userId: ids.user,
|
||||
username: 'student_smoke',
|
||||
name: '测试学生',
|
||||
phone: '13800000000',
|
||||
email: 'student@example.test',
|
||||
status: 'active',
|
||||
regionId: ids.region,
|
||||
regionName: '天津',
|
||||
selectedSchoolId: ids.school,
|
||||
selectedSchoolName: '天津职业大学',
|
||||
selectedMajorId: ids.major,
|
||||
selectedMajorName: '软件工程',
|
||||
questionsAnsweredToday: 6,
|
||||
masteredWordsCount: 12,
|
||||
classes: [{ classId: ids.class, className: '天津冲刺班' }],
|
||||
};
|
||||
|
||||
const tenantMembers = [
|
||||
{
|
||||
id: ids.tenantMember,
|
||||
membershipId: ids.tenantMember,
|
||||
userId: ids.teacher,
|
||||
username: 'teacher_smoke',
|
||||
name: '测试教师',
|
||||
phone: '13900000000',
|
||||
email: 'teacher@example.test',
|
||||
role: 'teacher',
|
||||
primaryRole: 'teacher',
|
||||
status: 'active',
|
||||
roleTemplateId: ids.roleTemplate,
|
||||
roleTemplateName: '教师运营模板',
|
||||
permissions: { 'students:read': true, 'students:write': true },
|
||||
},
|
||||
{
|
||||
id: 'member-sales-smoke',
|
||||
membershipId: 'member-sales-smoke',
|
||||
userId: 'sales-user-smoke',
|
||||
username: 'sales_smoke',
|
||||
name: '测试销售',
|
||||
phone: '13700000000',
|
||||
role: 'sales',
|
||||
primaryRole: 'sales',
|
||||
status: 'active',
|
||||
commissionRate: 0.2,
|
||||
},
|
||||
];
|
||||
|
||||
const tenantThemeTemplates = [
|
||||
{
|
||||
code: 'classic',
|
||||
name: '经典主题',
|
||||
description: '适合默认题库品牌',
|
||||
theme: { primaryColor: '#2563eb', accentColor: '#0f766e' },
|
||||
},
|
||||
{
|
||||
code: 'focus',
|
||||
name: '专注主题',
|
||||
description: '适合学习型机构',
|
||||
theme: { primaryColor: '#0f766e', accentColor: '#f59e0b' },
|
||||
},
|
||||
{
|
||||
code: 'contrast',
|
||||
name: '高对比主题',
|
||||
description: '适合活动页面',
|
||||
theme: { primaryColor: '#111827', accentColor: '#dc2626' },
|
||||
},
|
||||
];
|
||||
|
||||
const tenantTheme = {
|
||||
activeTemplateCode: 'classic',
|
||||
activeTemplateName: '经典主题',
|
||||
draftTemplateCode: 'focus',
|
||||
draftTemplateName: '专注主题',
|
||||
status: 'draft',
|
||||
theme: { primaryColor: '#2563eb', accentColor: '#0f766e' },
|
||||
publicAssets: { logoUrl: '/assets/logo.svg', shareImageUrl: '/assets/share.png', iconSet: 'classic', shareCardStyle: 'clean' },
|
||||
};
|
||||
|
||||
const tenantPermissionCatalog = {
|
||||
permissions: [
|
||||
{ key: 'students:read', group: 'students', label: '学生查看' },
|
||||
{ key: 'students:write', group: 'students', label: '学生编辑' },
|
||||
{ key: 'content:write', group: 'content', label: '内容编辑' },
|
||||
{ key: 'marketing:write', group: 'marketing', label: '营销编辑' },
|
||||
],
|
||||
menuGroups: [
|
||||
{ key: 'dashboard', group: 'menu', label: '数据看板' },
|
||||
{ key: 'students', group: 'menu', label: '学生运营' },
|
||||
{ key: 'content', group: 'menu', label: '题库内容' },
|
||||
{ key: 'marketing', group: 'menu', label: '营销中心' },
|
||||
{ key: 'settings', group: 'menu', label: '租户设置' },
|
||||
],
|
||||
fieldGroups: [
|
||||
{ key: 'student.phone', group: 'student', label: '学生手机号' },
|
||||
{ key: 'student.email', group: 'student', label: '学生邮箱' },
|
||||
],
|
||||
};
|
||||
|
||||
const importJob = {
|
||||
id: ids.importJob,
|
||||
importType: 'questions',
|
||||
sourceFormat: 'json',
|
||||
status: 'previewed',
|
||||
sourceName: 'smoke-questions.json',
|
||||
totalCount: 1,
|
||||
validCount: 1,
|
||||
successCount: 0,
|
||||
errorCount: 0,
|
||||
warningCount: 0,
|
||||
insertedCount: 0,
|
||||
updatedCount: 0,
|
||||
skippedCount: 0,
|
||||
executionMode: 'sync',
|
||||
attemptCount: 0,
|
||||
maxAttempts: 3,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
summary: { source: 'h5-smoke' },
|
||||
};
|
||||
|
||||
const publicQuestionBank = {
|
||||
grantId: ids.publicBankGrant,
|
||||
sourceQuestionBankId: ids.publicBank,
|
||||
sourceQuestionBankName: '平台公共题库',
|
||||
sourceRegionName: '天津',
|
||||
name: '平台公共题库',
|
||||
regionName: '天津',
|
||||
grantScope: 'plan_code',
|
||||
questionCount: 128,
|
||||
adoptedId: ids.publicBankAdoption,
|
||||
adoptionStatus: 'active',
|
||||
syncStatus: 'conflict',
|
||||
copiedQuestionCount: 120,
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const publicBankConflicts = {
|
||||
adoptionId: ids.publicBankAdoption,
|
||||
syncStatus: 'conflict',
|
||||
status: 'conflict',
|
||||
lastSyncedAt: new Date().toISOString(),
|
||||
conflictCount: 1,
|
||||
counts: { conflict: 1 },
|
||||
conflicts: [{
|
||||
sourceQuestionId: ids.question,
|
||||
targetQuestionId: 'target-question-smoke',
|
||||
action: 'update_conflict',
|
||||
sourceHash: 'platformhash',
|
||||
previousSourceHash: 'oldhash',
|
||||
targetHash: 'tenanthash',
|
||||
}],
|
||||
};
|
||||
|
||||
const platformPermissionCatalog = [
|
||||
{ key: '*', group: 'staff', label: '超级权限' },
|
||||
{ key: 'platform:staff:write', group: 'staff', label: '员工编辑' },
|
||||
{ key: 'platform:staff:status', group: 'staff', label: '员工状态' },
|
||||
{ key: 'platform:tenant:write', group: 'tenant', label: '租户编辑' },
|
||||
{ key: 'platform:billing:write', group: 'billing', label: '账务编辑' },
|
||||
{ key: 'platform:question_bank:write', group: 'question_bank', label: '题库授权' },
|
||||
];
|
||||
|
||||
function mockApiPayload(pathname, method, query, body) {
|
||||
if (pathname === '/health') return { ok: true };
|
||||
if (pathname === '/api/tenant/resolve') return baseTenantPayload(query);
|
||||
@@ -522,9 +702,16 @@ function mockApiPayload(pathname, method, query, body) {
|
||||
}
|
||||
|
||||
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/overview') {
|
||||
return { item: { id: ids.tenant, slug: 'master', status: 'active', brandName: '工学题库商户后台', shortName: '工学', billingStatus: 'normal' } };
|
||||
}
|
||||
if (pathname === '/api/tenant-admin/theme-templates') return { items: tenantThemeTemplates };
|
||||
if (pathname === '/api/tenant-admin/theme') return { item: tenantTheme };
|
||||
if (pathname === '/api/tenant-admin/theme/preview') return { item: { ...tenantTheme, draftTemplateCode: body.templateCode || 'focus', templateName: '专注主题', status: 'draft' } };
|
||||
if (pathname === '/api/tenant-admin/theme/publish') return { item: { ...tenantTheme, activeTemplateCode: body.templateCode || tenantTheme.draftTemplateCode, templateName: '专注主题', status: 'published' } };
|
||||
if (pathname === '/api/tenant-admin/permissions') {
|
||||
return {
|
||||
...tenantPermissionCatalog,
|
||||
current: {
|
||||
role: 'owner',
|
||||
menuPermissions: { dashboard: true, students: true, content: true, marketing: true, commerce: true, settings: true },
|
||||
@@ -533,24 +720,132 @@ function mockApiPayload(pathname, method, query, body) {
|
||||
roleDefaults: { owner: ['*'] },
|
||||
};
|
||||
}
|
||||
if (pathname === '/api/tenant-content/content-entries') return { items: [{ id: ids.entry, name: '天津专升本题库', entryType: 'question_bank', status: 'active' }] };
|
||||
if (pathname === '/api/tenant-content/public-question-banks') return { items: [{ grantId: 'grant-smoke', questionBankId: 'bank-smoke', name: '平台公共题库', regionName: '天津', grantScope: '套餐授权', questionCount: 128 }] };
|
||||
if (pathname === '/api/tenant-content/imports/templates') return { item: { importType: query.get('importType') || 'questions', format: query.get('format') || 'json', content: '{}' } };
|
||||
if (pathname === '/api/tenant-content/imports/field-mapping') return { item: { importType: query.get('importType') || 'questions', aliases: {}, allowedTargets: [] } };
|
||||
if (pathname === '/api/tenant-admin/classes') {
|
||||
return { scoped: false, items: [{ id: ids.class, name: '天津冲刺班', code: 'TJ-01', status: 'active', regionName: '天津', studentCount: 32, teacherCount: 2 }] };
|
||||
}
|
||||
if (pathname === '/api/tenant-admin/students' && method === 'GET') return { scoped: false, items: [tenantStudent] };
|
||||
if (pathname === '/api/tenant-admin/students' && method === 'PUT') return { item: { ...tenantStudent, ...body, userId: body.userId || ids.user } };
|
||||
if (pathname === '/api/tenant-admin/students/bulk-upsert') return { total: body.students?.length || 1, successCount: body.students?.length || 1, errorCount: 0, items: body.students || [] };
|
||||
if (pathname === '/api/tenant-admin/students/status') return { item: { userId: body.userId || ids.user, status: body.status || 'active' } };
|
||||
if (pathname === '/api/tenant-admin/students/crm-push') return { requestId: 'crm-push-smoke', total: body.studentUserIds?.length || 1, successCount: body.studentUserIds?.length || 1, errorCount: 0 };
|
||||
if (pathname === '/api/tenant-admin/classes/members/bulk-assign') return { total: body.assignments?.length || 1, successCount: body.assignments?.length || 1, errorCount: 0 };
|
||||
if (pathname === '/api/tenant-admin/teachers') return { items: [{ userId: ids.teacher, username: 'teacher_smoke', name: '测试教师', phone: '13900000000', status: 'active', classCount: 1 }] };
|
||||
if (pathname === '/api/tenant-admin/students/notes' && method === 'GET') return { items: [{ id: 'note-smoke', studentUserId: ids.user, noteType: 'learning', content: '烟测备注', visibility: 'tenant_staff', isPinned: false, createdByName: '测试教师', createdAt: new Date().toISOString() }] };
|
||||
if (pathname === '/api/tenant-admin/students/notes' && method === 'PUT') return { item: { id: 'note-smoke-created', ...body, createdAt: new Date().toISOString() } };
|
||||
if (pathname === '/api/tenant-admin/students/followups/report') {
|
||||
return { item: { summary: { total: 6, openBacklog: 2, overdueBacklog: 1, done: 3, crmPushTasks: 2, completionRate: 0.5, avgCompleteHours: 8 }, byAssignee: [{ assignedToUserId: ids.teacher, assignedToName: '测试教师', total: 4, open: 1, done: 3, overdue: 1 }], overdueItems: [] } };
|
||||
}
|
||||
if (pathname === '/api/tenant-admin/students/followups' && method === 'GET') {
|
||||
return { items: [{ id: 'followup-smoke', studentUserId: ids.user, studentName: '测试学生', title: '学习跟进', followupType: 'learning', priority: 'normal', status: 'open', assignedToUserId: ids.teacher, assignedToName: '测试教师' }] };
|
||||
}
|
||||
if (pathname === '/api/tenant-admin/students/followups' && method === 'PUT') return { item: { id: body.id || 'followup-created-smoke', ...body } };
|
||||
if (pathname === '/api/tenant-admin/students/supervision/preview') {
|
||||
return { item: { rules: {}, filters: {}, totalCandidates: 1, candidates: [{ studentUserId: ids.user, studentName: '测试学生', title: '学习风险提醒', description: '近期答题偏少', followupType: 'learning', priority: 'high', riskScore: 86, reasons: [{ code: 'low_activity' }] }] } };
|
||||
}
|
||||
if (pathname === '/api/tenant-admin/students/supervision/rules' && method === 'GET') return { scoped: false, items: [{ id: 'supervision-rule-smoke', name: '全租户 学习督导', status: 'active', className: null, lastResult: { status: 'ok' } }] };
|
||||
if (pathname === '/api/tenant-admin/students/supervision/rules' && method === 'PUT') return { item: { id: 'supervision-rule-created-smoke', ...body, status: body.status || 'active' } };
|
||||
if (pathname === '/api/tenant-admin/students/supervision/generate') return { total: body.studentUserIds?.length || 1, successCount: body.studentUserIds?.length || 1, errorCount: 0 };
|
||||
if (pathname === '/api/tenant-admin/role-templates' && method === 'GET') {
|
||||
return { items: [{ id: ids.roleTemplate, code: 'teacher-ops', name: '教师运营模板', baseRole: 'teacher', status: 'active', sortOrder: 10, permissions: { 'students:read': true }, menuPermissions: { students: true }, modulePermissions: {}, fieldPermissions: {}, dataScope: { mode: 'class' }, isSystem: false }] };
|
||||
}
|
||||
if (pathname === '/api/tenant-admin/role-templates' && method === 'PUT') return { item: { id: body.id || 'role-template-created-smoke', ...body } };
|
||||
if (pathname === '/api/tenant-admin/role-templates/disable') return { item: { id: body.roleTemplateId, status: 'disabled' } };
|
||||
if (pathname === '/api/tenant-admin/members' && method === 'GET') return { items: tenantMembers };
|
||||
if (pathname === '/api/tenant-admin/members' && method === 'PUT') return { item: { id: body.membershipId || 'member-created-smoke', membershipId: body.membershipId || 'member-created-smoke', userId: body.userId || ids.teacher, ...body } };
|
||||
if (pathname === '/api/tenant-admin/members/disable') return { item: { id: body.membershipId, status: 'disabled' } };
|
||||
if (pathname === '/api/tenant-admin/domains') return { items: [{ id: 'domain-smoke', host: 'tiku.example.test', domainType: 'custom', status: 'verified', isPrimary: true }] };
|
||||
if (pathname === '/api/tenant-admin/payment-accounts') return { items: [{ id: 'payment-smoke', provider: 'wechat_pay', status: 'active', mode: 'tenant_collect' }] };
|
||||
if (pathname === '/api/tenant-admin/auth-providers') return { items: [{ id: 'auth-wechat-smoke', provider: 'wechat_miniapp', status: 'active' }] };
|
||||
|
||||
if (pathname === '/api/tenant-content/content-entries') return { items: [{ id: ids.entry, name: '天津专升本题库', entryType: 'question_bank', visibility: 'tenant', isActive: true, regionName: '天津', status: 'active' }] };
|
||||
if (pathname === '/api/tenant-content/imports') return { items: [importJob] };
|
||||
if (pathname === '/api/tenant-content/imports/detail') return { item: importJob, issueSummary: {}, itemStatusSummary: { valid: 1 }, recentIssues: [], importPostCheck: { status: 'passed', issues: [] }, worker: { executionMode: 'sync', attemptCount: 0, maxAttempts: 3 } };
|
||||
if (pathname === '/api/tenant-content/imports/issues') return { items: [] };
|
||||
if (pathname === '/api/tenant-content/imports/templates') {
|
||||
const importType = query.get('importType') || 'questions';
|
||||
const format = query.get('format') || 'json';
|
||||
return { item: { importType, format, fileName: `${importType}-template.${format}`, mimeType: 'application/json', contentBase64: 'W3sidGl0bGUiOiJzbW9rZSJ9XQ==', contentPreview: '[{"title":"smoke","type":"single_choice"}]', fields: [{ field: 'title', label: '题干', required: true, aliases: ['题干', 'title'] }] } };
|
||||
}
|
||||
if (pathname === '/api/tenant-content/imports/field-mapping') return { item: { importType: query.get('importType') || 'questions', title: '导入字段', fields: [{ field: 'title', label: '题干', required: true, aliases: ['题干', 'title'] }], requiredFields: ['title'] } };
|
||||
if (pathname.startsWith('/api/tenant-content/imports/preview/')) return { job: { ...importJob, id: ids.importJob }, items: [{ rowNo: 1, status: 'valid', externalId: 'smoke-question', normalized: { title: 'smoke' }, issues: [] }], issues: [] };
|
||||
if (/^\/api\/tenant-content\/imports\/(questions|vocabulary|handbook|scoreline|videos)$/.test(pathname)) return { item: { ...importJob, id: ids.importJob, jobId: ids.importJob, status: 'completed', successCount: 1 }, preview: { job: importJob } };
|
||||
if (pathname === '/api/tenant-content/imports/post-check') return { item: { status: 'passed', issues: [], checkedAt: new Date().toISOString() } };
|
||||
if (pathname === '/api/tenant-content/public-question-banks') return { items: [publicQuestionBank] };
|
||||
if (pathname === '/api/tenant-content/public-question-banks/adopt') return { item: { id: ids.publicBankAdoption, grantId: body.grantId || ids.publicBankGrant, status: 'active' } };
|
||||
if (pathname === '/api/tenant-content/public-question-banks/sync') return { item: { id: body.adoptionId || ids.publicBankAdoption, syncStatus: 'conflict' }, sync: publicBankConflicts };
|
||||
if (pathname === '/api/tenant-content/public-question-banks/conflicts') return { item: publicBankConflicts };
|
||||
if (pathname === '/api/tenant-content/public-question-banks/conflicts/resolve') return { item: { adoptionId: body.adoptionId, sourceQuestionId: body.sourceQuestionId, resolution: body.resolution, status: 'resolved' } };
|
||||
if (pathname === '/api/tenant-content/public-question-banks/conflicts/resolve-batch') return { item: { adoptionId: body.adoptionId, resolvedCount: body.sourceQuestionIds?.length || 1, resolution: body.resolution } };
|
||||
if (pathname === '/api/tenant-content/notifications') return { items: [{ id: 'content-notification-smoke', notificationType: 'public_bank_conflict', status: 'unread', severity: 'warning', adoptionId: ids.publicBankAdoption, title: '公共题库存在同步冲突', message: '平台公共题库有 1 道题需要处理。', createdAt: new Date().toISOString() }], summary: { unread: 1 } };
|
||||
if (pathname === '/api/tenant-content/notifications/status') return { item: { updatedCount: body.notificationIds?.length || 1, status: body.status } };
|
||||
|
||||
if (pathname === '/api/tenant-admin/code-batches') return { items: [{ id: 'batch-smoke', name: 'SVIP 激活码批次', status: 'active', totalCount: 100, usedCount: 18 }] };
|
||||
if (pathname === '/api/tenant-admin/activation-codes') return { items: [{ id: 'code-smoke', code: 'SMOKE2026', status: 'unused' }] };
|
||||
if (pathname === '/api/tenant-admin/coupons' && method === 'GET') return { items: [{ id: ids.coupon, code: 'SMOKE80', status: 'active', campaignName: '暑期活动', discountType: 'fixed', discountValue: 8000, usedCount: 3, maxUses: 100, minOrderAmountCents: 10000, perUserLimit: 1 }] };
|
||||
if (pathname === '/api/tenant-admin/coupons' && method === 'PUT') return { item: { id: body.id || ids.coupon, ...body, usedCount: 0 } };
|
||||
if (pathname === '/api/tenant-admin/coupons/redemptions') return { items: [{ id: 'redemption-smoke', couponId: ids.coupon, couponCode: 'SMOKE80', userId: ids.user, userName: '测试学生', status: 'used', discountAppliedCents: 8000, planName: 'SVIP 年卡', orderNo: 'SMOKE202607010001' }] };
|
||||
if (pathname === '/api/tenant-admin/coupons/report') return { item: { claimCount: 12, usedCount: 3, discountCents: 24000, paidAmountCents: 59700, conversionRate: 0.25, byCampaign: [{ campaignName: '暑期活动', claimCount: 12, usedCount: 3, discountCents: 24000 }] } };
|
||||
if (pathname === '/api/tenant-admin/point-activity-tasks' && method === 'GET') return { items: [{ id: ids.pointTask, code: 'daily-smoke', title: '每日练习', taskType: 'practice_complete', rewardPoints: 10, claimLimitPerUser: 1, periodType: 'daily', status: 'active', claimCount: 4, claimUserCount: 3 }] };
|
||||
if (pathname === '/api/tenant-admin/point-activity-tasks' && method === 'PUT') return { item: { id: body.id || ids.pointTask, ...body, claimCount: 0, claimUserCount: 0 } };
|
||||
if (pathname === '/api/tenant-admin/point-activity-claims') return { items: [{ id: 'claim-smoke', taskTitle: '每日练习', rewardPoints: 10, userName: '测试学生', periodKey: '2026-07-01' }] };
|
||||
if (pathname === '/api/tenant-admin/point-exchange-items' && method === 'GET') return { items: [{ id: ids.pointExchange, code: 'manual-gift', title: '人工兑换礼品', costPoints: 100, itemType: 'manual', stockRemaining: 20, status: 'active', orderCount: 1 }] };
|
||||
if (pathname === '/api/tenant-admin/point-exchange-items' && method === 'PUT') return { item: { id: body.id || ids.pointExchange, ...body, stockRemaining: body.stockTotal || null, orderCount: 0 } };
|
||||
if (pathname === '/api/tenant-admin/point-exchange-orders') return { items: [{ id: 'exchange-order-smoke', itemTitle: '人工兑换礼品', status: 'pending_fulfillment', userName: '测试学生', costPoints: 100, createdAt: new Date().toISOString() }] };
|
||||
if (pathname === '/api/tenant-admin/points-risk-report') return { item: { summary: { riskScore: 8, suspiciousUserCount: 0, suspiciousEventCount: 0, netPoints: 120 }, suspiciousUsers: [], suspiciousEvents: [], suspiciousTasks: [] } };
|
||||
if (pathname === '/api/tenant-admin/feedbacks/report') return { item: { summary: { total: 5, pendingBacklog: 1, handledRate: 0.8, rewardPoints: 20, highPriority: 1, resolved: 2, closed: 1, avgHandleHours: 4, resolutionRate: 0.6 }, recentUnhandled: [], topQuestions: [] } };
|
||||
if (pathname === '/api/tenant-admin/user-notifications') return { items: [{ id: 'user-notification-smoke', userId: ids.user, userName: '测试学生', notificationType: 'badge_granted', status: 'unread', title: '勋章发放', message: '坚持练习勋章已发放', createdAt: new Date().toISOString() }], summary: { unread: 1, archived: 0 } };
|
||||
if (pathname === '/api/crm/config' && method === 'GET') return { item: { enabled: true, url: 'https://crm.example.test/webhook', secretRef: 'app_private.tenant_secrets:crm:webhook', formName: '刷题题库', examType: '专升本', timeoutSec: 10, delaySec: 60, assignmentMode: 'referrer', assignmentPool: ['sales-user-smoke'], assignmentCursor: 0 } };
|
||||
if (pathname === '/api/crm/config' && method === 'PUT') return { item: { id: 'crm-config-smoke', ...body } };
|
||||
if (pathname === '/api/crm/queue') return { items: [{ id: ids.crmQueue, status: 'failed', source: 'student.crm_push', attempts: 3, lastHttpCode: 500, lastError: 'smoke failed webhook', targetUrl: 'https://crm.example.test/webhook', provider: 'webhook', createdAt: new Date().toISOString() }] };
|
||||
if (pathname === '/api/crm/dead-letters') return { summary: { total: 1, failed: 1, ignored: 0 }, items: [{ id: ids.crmQueue, status: 'failed', source: 'student.crm_push', attempts: 3, lastError: 'smoke failed webhook', deadLetteredAt: new Date().toISOString() }] };
|
||||
if (pathname === '/api/crm/queue/logs') return { item: { id: query.get('queueId') || ids.crmQueue }, items: [{ id: 'crm-log-smoke', outcome: 'failed', attempt: 3, httpCode: 500, errorMessage: 'smoke failed webhook', createdAt: new Date().toISOString() }] };
|
||||
if (pathname === '/api/crm/queue/action') return { item: { id: body.queueId || ids.crmQueue, status: body.action === 'ignore' ? 'discarded' : 'pending', lastOperatorAction: body.action } };
|
||||
if (pathname === '/api/commission/settings' && method === 'GET') return { item: { defaultRate: 0.2, minSettlementCents: 0, settlementCycle: 'monthly' } };
|
||||
if (pathname === '/api/commission/settings' && method === 'PUT') return { item: { ...body } };
|
||||
if (pathname === '/api/commission/member-rate') return { item: { userId: body.userId, commissionRate: body.commissionRate } };
|
||||
if (pathname === '/api/commission/summary') return { item: { sourceCount: 3, grossAmountCents: 59700, commissionAmountCents: 11940, paidUserCount: 3 } };
|
||||
if (pathname === '/api/referral/conversion-report') return { item: { summary: { leadCount: 10, convertedLeadCount: 3, conversionRate: 0.3, grossAmountCents: 59700, commissionAmountCents: 11940, crmFailedCount: 1, openFollowupCount: 2, avgFirstPayHours: 24 }, funnel: [{ key: 'lead', label: '客资', count: 10 }], byReferrer: [{ referrerUserId: 'sales-user-smoke', name: '测试销售', role: 'sales', leadCount: 10, convertedLeadCount: 3, conversionRate: 0.3, grossAmountCents: 59700, commissionAmountCents: 11940, crmFailedCount: 1, openFollowupCount: 2, overdueFollowupCount: 1, avgFirstPayHours: 24 }], dailyTrend: [] } };
|
||||
if (pathname === '/api/commission/orders') return { items: [{ referrerUserId: 'sales-user-smoke', referrerName: '测试销售', grossAmountCents: 19900, commissionRate: 0.2, commissionAmountCents: 3980, sourceType: 'referral', sourceId: 'source-smoke' }] };
|
||||
if (pathname === '/api/commission/settlements' && method === 'GET') return { items: [{ id: ids.settlement, settlementNo: 'SETTLE-SMOKE', referrerUserId: 'sales-user-smoke', referrerName: '测试销售', status: 'pending_review', commissionAmountCents: 3980, periodStart: '2026-07-01', periodEnd: '2026-07-31', sourceCount: 1 }] };
|
||||
if (pathname === '/api/commission/settlements/generate') return { item: { id: ids.settlement, ...body, settlementNo: 'SETTLE-SMOKE', commissionAmountCents: 3980 } };
|
||||
if (pathname === '/api/commission/settlements/status') return { item: { id: body.settlementId || ids.settlement, status: body.status } };
|
||||
if (pathname === '/api/commission/settlements/export') return { item: { filename: 'settlement-smoke.csv', format: 'csv', mimeType: 'text/csv', contentBase64: 'aWQsc3RhdHVzCnNtb2tlLHBhaWQK', rowCount: 1 } };
|
||||
if (pathname === '/api/commission/settlements/proofs' && method === 'GET') return { items: [{ id: ids.proof, settlementId: query.get('settlementId') || ids.settlement, title: '烟测凭证', status: 'submitted', externalUrl: 'https://assets.example.test/proof.pdf', amountCents: 3980 }] };
|
||||
if (pathname === '/api/commission/settlements/proofs' && method === 'POST') return { item: { id: ids.proof, status: 'submitted', ...body } };
|
||||
if (pathname === '/api/commission/settlements/proofs/status') return { item: { id: body.proofId || ids.proof, status: body.status } };
|
||||
if (pathname.startsWith('/api/tenant-content/') || pathname.startsWith('/api/tenant-admin/') || pathname.startsWith('/api/crm/') || pathname.startsWith('/api/commission/') || pathname.startsWith('/api/referral/')) {
|
||||
return method === 'GET' ? { items: [], item: null, summary: {} } : { ok: true, item: { id: 'tenant-smoke' } };
|
||||
}
|
||||
|
||||
if (pathname === '/api/platform-admin/overview') {
|
||||
return { item: { tenants: { total: 6, active: 5 }, billing: { unpaidAmountCents: 360000 }, subscriptions: { expiringSoon: 1 }, usage: { questions: 74117 } } };
|
||||
return { item: { tenants: { total: 6, active: 5 }, billing: { unpaidAmountCents: 360000, paidAmountCents: 1200000 }, subscriptions: { expiringSoon: 1 }, usage: { questions: 74117, students: 3722 } } };
|
||||
}
|
||||
if (pathname === '/api/platform-admin/permissions') return { item: { primaryRole: 'platform_admin', effective: { '*': true }, catalog: [] } };
|
||||
if (pathname === '/api/platform-admin/plans') return { items: [{ id: ids.plan, code: 'starter_yearly', name: '基础年费', baseAmountCents: 199900, status: 'active' }] };
|
||||
if (pathname === '/api/platform-admin/tenants') return { items: [{ id: ids.tenant, slug: 'master', name: '工学题库', brandName: '工学题库', status: 'active', billingStatus: 'normal', planCode: 'starter_yearly', openBalanceCents: 0 }] };
|
||||
if (pathname === '/api/platform-admin/invoices') return { items: [{ id: 'invoice-smoke', tenantId: ids.tenant, invoiceNo: 'INV-SMOKE', status: 'unpaid', totalCents: 199900, balanceCents: 199900 }] };
|
||||
if (pathname === '/api/platform-admin/question-banks') return { items: [{ id: 'bank-smoke', name: '平台公共题库', status: 'active', questionCount: 128 }] };
|
||||
if (pathname === '/api/platform-admin/question-bank-grants') return { items: [{ id: 'grant-smoke', questionBankId: 'bank-smoke', grantScope: 'all_active_tenants', status: 'active' }] };
|
||||
if (pathname === '/api/platform-admin/staff') return { items: [{ id: ids.user, name: '平台管理员', primaryRole: 'platform_admin', status: 'active', authUserId: 'auth-smoke' }] };
|
||||
if (pathname === '/api/platform-admin/permissions') return { item: { primaryRole: 'platform_admin', effective: { '*': true, 'platform:staff:write': true, 'platform:staff:status': true }, catalog: platformPermissionCatalog } };
|
||||
if (pathname === '/api/platform-admin/plans') return { items: [{ id: ids.plan, code: 'starter_yearly', name: '基础年费', billingCycle: 'yearly', baseAmountCents: 199900, currency: 'CNY', status: 'active' }] };
|
||||
if (pathname === '/api/platform-admin/tenants' && method === 'GET') return { items: [{ id: ids.tenant, slug: 'master', name: '工学题库', legalName: '工学教育科技有限公司', brandName: '工学题库', status: 'active', billingStatus: 'normal', planCode: 'starter_yearly', subscriptionStatus: 'active', subscriptionExpiresAt: '2027-07-01T00:00:00.000Z', openBalanceCents: 199900 }] };
|
||||
if (pathname === '/api/platform-admin/tenants' && method === 'POST') return { item: { id: ids.tenant, slug: body.slug || 'smoke-tenant', name: body.name || '烟测租户', brandName: body.brandName || body.name || '烟测租户', status: body.status || 'active', billingStatus: body.billingStatus || 'trial', planCode: body.planCode || 'starter_yearly' } };
|
||||
if (pathname === '/api/platform-admin/tenants/detail') return { item: { tenant: { id: ids.tenant, slug: 'master', name: '工学题库', legalName: '工学教育科技有限公司', brandName: '工学题库', status: 'active', billingStatus: 'normal', billingName: '工学教育科技有限公司', contactName: '陈老师', contactPhone: '13800000000', invoiceType: 'normal_vat' }, domains: [{ id: 'domain-smoke', host: 'tiku.example.test', status: 'verified', isPrimary: true }], subscriptions: [{ id: 'subscription-smoke', planCode: 'starter_yearly', status: 'active', amountCents: 199900, startsAt: '2026-07-01', expiresAt: '2027-07-01' }], invoices: [{ id: ids.invoice, tenantId: ids.tenant, invoiceNo: 'INV-SMOKE', status: 'unpaid', totalCents: 199900, paidCents: 0, balanceCents: 199900, dueDate: '2026-07-10' }], usage: [{ id: 'usage-smoke', tenantId: ids.tenant, metricKey: 'students', metricValue: 3722, periodStart: '2026-07-01', periodEnd: '2026-07-31' }] } };
|
||||
if (pathname === '/api/platform-admin/tenants/status') return { item: { id: body.tenantId || ids.tenant, status: body.status || 'active', billingStatus: body.billingStatus || 'normal' } };
|
||||
if (pathname === '/api/platform-admin/tenants/billing-profile') return { item: { ...body } };
|
||||
if (pathname === '/api/platform-admin/audit-logs') return { items: [{ id: 'audit-smoke', tenantId: ids.tenant, action: 'tenant.updated', targetType: 'tenant', targetId: ids.tenant, actorName: '平台管理员', createdAt: new Date().toISOString(), details: { source: 'smoke' } }] };
|
||||
if (pathname === '/api/platform-admin/invoices' && method === 'GET') return { items: [{ id: ids.invoice, tenantId: ids.tenant, tenantSlug: 'master', tenantName: '工学题库', invoiceNo: 'INV-SMOKE', invoiceType: 'subscription', status: 'unpaid', currency: 'CNY', totalCents: 199900, paidCents: 0, balanceCents: 199900, dueDate: '2026-07-10' }] };
|
||||
if (pathname === '/api/platform-admin/invoices/reminders') return { items: [{ id: 'reminder-smoke', tenantId: ids.tenant, tenantName: '工学题库', invoiceId: ids.invoice, invoiceNo: 'INV-SMOKE', reminderType: 'overdue', channel: 'internal', status: 'pending', reminderLevel: 1, balanceCentsSnapshot: 199900, message: '烟测催缴' }] };
|
||||
if (pathname === '/api/platform-admin/usage' && method === 'GET') return { items: [{ id: 'usage-smoke', tenantId: ids.tenant, tenantName: '工学题库', metricKey: 'students', metricValue: 3722, periodStart: '2026-07-01', periodEnd: '2026-07-31' }] };
|
||||
if (pathname === '/api/platform-admin/usage' && method === 'POST') return { item: { id: 'usage-created-smoke', ...body } };
|
||||
if (pathname === '/api/platform-admin/subscriptions') return { item: { id: 'subscription-created-smoke', ...body } };
|
||||
if (pathname === '/api/platform-admin/invoices/from-subscription') return { item: { id: ids.invoice, invoiceNo: 'INV-SMOKE', ...body } };
|
||||
if (pathname === '/api/platform-admin/invoices/subscription-candidates') return { items: [{ tenantId: ids.tenant, tenantSlug: 'master', tenantName: '工学题库', subscriptionId: 'subscription-smoke', planCode: 'starter_yearly', planName: '基础年费', status: 'active', amountCents: 199900, expiresAt: '2026-07-20', hasExistingInvoice: false, wouldCreate: true }] };
|
||||
if (pathname === '/api/platform-admin/invoices/from-subscriptions-batch') return { item: { dryRun: body.dryRun === true, createdCount: body.dryRun ? 0 : 1, skippedCount: 0, items: [{ tenantId: ids.tenant, subscriptionId: 'subscription-smoke', wouldCreate: true }] } };
|
||||
if (pathname === '/api/platform-admin/invoices/usage-overage-candidates') return { items: [{ tenantId: ids.tenant, tenantSlug: 'master', tenantName: '工学题库', planCode: 'starter_yearly', planName: '基础年费', periodStart: query.get('periodStart') || '2026-07-01', periodEnd: query.get('periodEnd') || '2026-07-31', hasExistingInvoice: false, wouldCreate: true, totalCents: 8800, items: [{ description: '超额学生数', quantity: 88, unitAmountCents: 100 }] }] };
|
||||
if (pathname === '/api/platform-admin/invoices/from-usage-overage') return { item: { dryRun: body.dryRun === true, createdCount: body.dryRun ? 0 : 1, skippedCount: 0, totalCents: 8800, items: [{ tenantId: ids.tenant, periodStart: body.periodStart, periodEnd: body.periodEnd, totalCents: 8800, wouldCreate: true }] } };
|
||||
if (pathname === '/api/platform-admin/invoices/payments/manual-confirm') return { item: { id: 'payment-smoke', invoiceId: body.invoiceId || ids.invoice, amountCents: body.amountCents, status: 'confirmed' } };
|
||||
if (pathname === '/api/platform-admin/invoices/process-overdue') return { item: { dryRun: body.dryRun === true, processed: 1, markedOverdue: body.dryRun ? 0 : 1, reminderCreated: body.dryRun ? 0 : 1, skippedReminder: 0, items: [{ id: ids.invoice, wouldMarkOverdue: true, wouldCreateReminder: true }] } };
|
||||
if (pathname === '/api/platform-admin/question-banks') return { items: [{ id: ids.publicBank, tenantId: null, name: '平台公共题库', regionId: ids.region, regionName: '天津', sourceScope: 'platform_public', status: 'active', questionCount: 128 }] };
|
||||
if (pathname === '/api/platform-admin/question-bank-grants' && method === 'GET') return { items: [{ id: ids.publicBankGrant, sourceQuestionBankId: ids.publicBank, sourceQuestionBankName: '平台公共题库', sourceRegionName: '天津', grantScope: 'all_active_tenants', allowedPlanCodes: ['starter_yearly'], status: 'active' }] };
|
||||
if (pathname === '/api/platform-admin/question-bank-grants' && method === 'PUT') return { item: { id: body.id || ids.publicBankGrant, ...body } };
|
||||
if (pathname === '/api/platform-admin/staff' && method === 'GET') return { items: [{ id: ids.platformStaff, name: '平台管理员', username: 'platform_admin', email: 'platform@example.test', phone: '13600000000', primaryRole: 'platform_admin', status: 'active', authUserId: 'auth-smoke', platformPermissions: { '*': true }, createdAt: new Date().toISOString() }] };
|
||||
if (pathname === '/api/platform-admin/staff' && method === 'PUT') return { item: { id: body.id || ids.platformStaff, ...body, primaryRole: 'platform_admin', platformPermissions: body.platformPermissions || { '*': true } } };
|
||||
if (pathname === '/api/platform-admin/staff/status') return { item: { id: body.staffId || ids.platformStaff, status: body.status || 'active' } };
|
||||
if (pathname.startsWith('/api/platform-admin/')) return method === 'GET' ? { items: [], item: null } : { ok: true, item: { id: 'platform-smoke' } };
|
||||
|
||||
return method === 'GET' ? { items: [], item: null } : { ok: true, item: { id: 'smoke' } };
|
||||
@@ -762,6 +1057,7 @@ class CdpPage {
|
||||
await this.send('Page.enable');
|
||||
await this.send('Log.enable');
|
||||
await this.send('Network.enable');
|
||||
await this.send('Page.setInterceptFileChooserDialog', { enabled: false }).catch(() => {});
|
||||
return this;
|
||||
}
|
||||
|
||||
@@ -795,6 +1091,12 @@ class CdpPage {
|
||||
await this.send('Page.navigate', { url });
|
||||
}
|
||||
|
||||
async mouseClick(x, y) {
|
||||
await this.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x, y, button: 'none' });
|
||||
await this.send('Input.dispatchMouseEvent', { type: 'mousePressed', x, y, button: 'left', clickCount: 1 });
|
||||
await this.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x, y, button: 'left', clickCount: 1 });
|
||||
}
|
||||
|
||||
close() {
|
||||
this.ws.close();
|
||||
}
|
||||
@@ -851,6 +1153,14 @@ class CdpPage {
|
||||
.filter(Boolean)
|
||||
.slice(-20);
|
||||
}
|
||||
|
||||
async acceptDialogs() {
|
||||
const events = this.events.filter(event => event.method === 'Page.javascriptDialogOpening');
|
||||
this.events = this.events.filter(event => event.method !== 'Page.javascriptDialogOpening');
|
||||
for (const event of events) {
|
||||
await this.send('Page.handleJavaScriptDialog', { accept: true }).catch(() => {});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function newPage(browser, url) {
|
||||
@@ -907,6 +1217,7 @@ async function waitForPath(page, pathPart, timeoutMs = 10_000) {
|
||||
}
|
||||
|
||||
async function clickText(page, text) {
|
||||
await page.acceptDialogs();
|
||||
const result = await page.evaluate(`
|
||||
(() => {
|
||||
const expected = ${JSON.stringify(text)};
|
||||
@@ -923,6 +1234,7 @@ async function clickText(page, text) {
|
||||
'.admin-button',
|
||||
'.platform-row',
|
||||
'.platform-button',
|
||||
'.platform-chip',
|
||||
'[class*="button"]'
|
||||
].join(',');
|
||||
const visible = el => {
|
||||
@@ -959,20 +1271,327 @@ async function clickText(page, text) {
|
||||
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }));
|
||||
}
|
||||
target.click();
|
||||
return { ok: true, tag: target.tagName, className: target.className, text: textOf(target).slice(0, 120) };
|
||||
return { ok: true, tag: target.tagName, className: target.className, text: textOf(target).slice(0, 120), x, y };
|
||||
})()
|
||||
`);
|
||||
await page.acceptDialogs();
|
||||
if (!result?.ok) throw new Error(`Clickable text not found: ${text}\n${result?.body || ''}`);
|
||||
if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y);
|
||||
await delay(350);
|
||||
await page.acceptDialogs();
|
||||
return result;
|
||||
}
|
||||
|
||||
async function clickTextInSection(page, sectionTitle, text) {
|
||||
await page.acceptDialogs();
|
||||
const result = await page.evaluate(`
|
||||
(() => {
|
||||
const sectionTitle = ${JSON.stringify(sectionTitle)};
|
||||
const expected = ${JSON.stringify(text)};
|
||||
const clickableSelector = [
|
||||
'button',
|
||||
'taro-button-core',
|
||||
'a',
|
||||
'[role="button"]',
|
||||
'[onclick]',
|
||||
'.admin-row',
|
||||
'.admin-button',
|
||||
'.platform-row',
|
||||
'.platform-button',
|
||||
'.platform-chip',
|
||||
'[class*="button"]'
|
||||
].join(',');
|
||||
const visible = el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const normalized = value => String(value || '').replace(/\\s+/g, ' ').trim();
|
||||
const textOf = el => normalized(el.innerText || el.textContent || '');
|
||||
const sections = Array.from(document.querySelectorAll('.admin-section,.platform-section,section'))
|
||||
.filter(el => visible(el) && textOf(el).includes(sectionTitle));
|
||||
const root = sections.sort((a, b) => textOf(a).length - textOf(b).length)[0];
|
||||
if (!root) return { ok: false, body: (document.body?.innerText || '').slice(0, 1200) };
|
||||
const byElement = new Map();
|
||||
for (const el of Array.from(root.querySelectorAll('*'))) {
|
||||
if (!visible(el)) continue;
|
||||
const nodeText = textOf(el);
|
||||
if (!nodeText.includes(expected)) continue;
|
||||
const target = el.matches(clickableSelector) ? el : el.closest(clickableSelector);
|
||||
if (!target || !visible(target) || !root.contains(target)) continue;
|
||||
const targetText = textOf(target);
|
||||
if (!targetText.includes(expected)) continue;
|
||||
const exact = targetText === expected ? 0 : 1;
|
||||
const tagScore = ['BUTTON', 'TARO-BUTTON-CORE', 'A'].includes(target.tagName) ? 0 : 1;
|
||||
const lengthScore = targetText.length;
|
||||
const previous = byElement.get(target);
|
||||
const next = { el: target, text: targetText, score: exact * 10000 + tagScore * 1000 + lengthScore };
|
||||
if (!previous || next.score < previous.score) byElement.set(target, next);
|
||||
}
|
||||
const candidates = Array.from(byElement.values()).sort((a, b) => a.score - b.score);
|
||||
const target = candidates[0]?.el;
|
||||
if (!target) return { ok: false, body: textOf(root).slice(0, 1200) };
|
||||
target.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
const rect = target.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
|
||||
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }));
|
||||
}
|
||||
target.click();
|
||||
return { ok: true, tag: target.tagName, className: target.className, text: textOf(target).slice(0, 120), x, y };
|
||||
})()
|
||||
`);
|
||||
await page.acceptDialogs();
|
||||
if (!result?.ok) throw new Error(`Clickable text not found in section "${sectionTitle}": ${text}\n${result?.body || ''}`);
|
||||
if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y);
|
||||
await delay(350);
|
||||
await page.acceptDialogs();
|
||||
return result;
|
||||
}
|
||||
|
||||
async function clickTextIfPresent(page, text) {
|
||||
const exists = await page.evaluate(`
|
||||
(() => {
|
||||
const expected = ${JSON.stringify(text)};
|
||||
const visible = el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
return Array.from(document.querySelectorAll('button,taro-button-core,a,[role="button"],[onclick],[class*="button"]'))
|
||||
.some(el => visible(el) && String(el.innerText || el.textContent || '').replace(/\\s+/g, ' ').trim().includes(expected));
|
||||
})()
|
||||
`);
|
||||
if (!exists) return false;
|
||||
await clickText(page, text);
|
||||
return true;
|
||||
}
|
||||
|
||||
async function clickVisibleTextCandidate(page, texts) {
|
||||
const result = await page.evaluate(`
|
||||
(() => {
|
||||
const expectedTexts = ${JSON.stringify(texts)};
|
||||
const clickableSelector = [
|
||||
'button',
|
||||
'taro-button-core',
|
||||
'a',
|
||||
'[role="button"]',
|
||||
'[onclick]',
|
||||
'.platform-chip',
|
||||
'[class*="button"]',
|
||||
'[class*="btn"]',
|
||||
'[class*="model__btn"]',
|
||||
'[class*="model__confirm"]'
|
||||
].join(',');
|
||||
const visible = el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const normalized = value => String(value || '').replace(/\\s+/g, ' ').trim();
|
||||
const textOf = el => normalized(el.innerText || el.textContent || '');
|
||||
const modalSelector = [
|
||||
'.weui-dialog',
|
||||
'.weui-picker',
|
||||
'.taro-modal',
|
||||
'[role="dialog"]',
|
||||
'[aria-modal="true"]',
|
||||
'[class*="dialog"]',
|
||||
'[class*="modal"]'
|
||||
].join(',');
|
||||
const activeRoots = Array.from(document.querySelectorAll(modalSelector)).filter(visible);
|
||||
if (!activeRoots.length) activeRoots.push(document.body);
|
||||
const candidates = [];
|
||||
activeRoots.forEach((root, rootIndex) => {
|
||||
for (const el of Array.from(root.querySelectorAll('*'))) {
|
||||
if (!visible(el)) continue;
|
||||
const text = textOf(el);
|
||||
if (!expectedTexts.some(expected => text === expected || text.includes(expected))) continue;
|
||||
const target = el.matches(clickableSelector) ? el : el.closest(clickableSelector);
|
||||
if (!target || !visible(target) || !root.contains(target)) continue;
|
||||
const targetText = textOf(target);
|
||||
const order = expectedTexts.findIndex(expected => targetText === expected || targetText.includes(expected));
|
||||
const exact = expectedTexts.some(expected => targetText === expected) ? 0 : 1;
|
||||
candidates.push({ el: target, text: targetText, score: rootIndex * 1000000 + order * 100000 + exact * 10000 + targetText.length });
|
||||
}
|
||||
});
|
||||
candidates.sort((a, b) => a.score - b.score);
|
||||
const target = candidates[0]?.el;
|
||||
if (!target) return { ok: false, body: (document.body?.innerText || '').slice(0, 1200) };
|
||||
target.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
const rect = target.getBoundingClientRect();
|
||||
const x = rect.left + rect.width / 2;
|
||||
const y = rect.top + rect.height / 2;
|
||||
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
|
||||
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }));
|
||||
}
|
||||
target.click();
|
||||
return { ok: true, text: textOf(target).slice(0, 120), x, y };
|
||||
})()
|
||||
`);
|
||||
if (result?.ok) {
|
||||
if (Number.isFinite(result.x) && Number.isFinite(result.y)) await page.mouseClick(result.x, result.y);
|
||||
await delay(250);
|
||||
await page.acceptDialogs();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function clickTextAndConfirm(page, text) {
|
||||
await clickText(page, text);
|
||||
await delay(250);
|
||||
await page.acceptDialogs();
|
||||
await clickVisibleTextCandidate(page, ['确认', '发布', '停用', '禁用', '恢复', '重试', '忽略', '已打款']);
|
||||
await page.acceptDialogs();
|
||||
}
|
||||
|
||||
async function waitForApiRequest(api, path, method) {
|
||||
await waitUntil(`${method || ''} ${path} request`, async () => {
|
||||
return api.requests.some(item => item.path === path && (!method || item.method === method));
|
||||
}, 10_000);
|
||||
}
|
||||
|
||||
async function waitForApiRequestCount(api, path, method, minCount, timeoutMs = 10_000) {
|
||||
await waitUntil(`${method || ''} ${path} request count >= ${minCount}`, async () => {
|
||||
return api.requests.filter(item => item.path === path && (!method || item.method === method)).length >= minCount;
|
||||
}, timeoutMs);
|
||||
}
|
||||
|
||||
function requestCount(api, path, method) {
|
||||
return api.requests.filter(item => item.path === path && (!method || item.method === method)).length;
|
||||
}
|
||||
|
||||
async function clickTextAndConfirmForApi(page, api, text, path, method) {
|
||||
const before = requestCount(api, path, method);
|
||||
await clickText(page, text);
|
||||
const confirmTexts = ['确认', '发布', '停用', '禁用', '恢复', '重试', '忽略', '已打款'];
|
||||
for (let attempt = 0; attempt < 8; attempt += 1) {
|
||||
await page.acceptDialogs();
|
||||
const hasModal = await page.evaluate(`
|
||||
(() => {
|
||||
const visible = el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const modalSelector = '.weui-dialog,.weui-half-screen-dialog,.taro-modal__content,[role="dialog"],[aria-modal="true"],[class*="dialog"],[class*="modal__content"]';
|
||||
return Array.from(document.querySelectorAll(modalSelector)).some(visible);
|
||||
})()
|
||||
`);
|
||||
if (hasModal) await clickVisibleTextCandidate(page, confirmTexts);
|
||||
await page.acceptDialogs();
|
||||
try {
|
||||
await waitForApiRequestCount(api, path, method, before + 1, 1_200);
|
||||
return;
|
||||
} catch {
|
||||
await delay(250);
|
||||
}
|
||||
}
|
||||
try {
|
||||
await waitForApiRequestCount(api, path, method, before + 1, 2_000);
|
||||
} catch (error) {
|
||||
const diagnostics = await page.evaluate(`
|
||||
(() => {
|
||||
const visible = el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const normalized = value => String(value || '').replace(/\\s+/g, ' ').trim();
|
||||
const modalSelector = '.weui-dialog,.weui-mask,.weui-half-screen-dialog,.taro-modal,[role="dialog"],[aria-modal="true"],[class*="dialog"],[class*="modal"]';
|
||||
return {
|
||||
body: normalized(document.body?.innerText || '').slice(0, 1500),
|
||||
modals: Array.from(document.querySelectorAll(modalSelector))
|
||||
.filter(visible)
|
||||
.map(el => ({ tag: el.tagName, className: String(el.className || ''), text: normalized(el.innerText || el.textContent || '').slice(0, 600) })),
|
||||
buttons: Array.from(document.querySelectorAll('button,taro-button-core,a,[role="button"],[onclick],[class*="button"],[class*="btn"]'))
|
||||
.filter(visible)
|
||||
.map(el => ({ tag: el.tagName, className: String(el.className || ''), text: normalized(el.innerText || el.textContent || '').slice(0, 120) }))
|
||||
.filter(item => item.text.includes('发布') || item.text.includes('确认') || item.text.includes('重试') || item.text.includes('停用') || item.text.includes('禁用') || item.text.includes('已打款'))
|
||||
.slice(0, 30)
|
||||
};
|
||||
})()
|
||||
`).catch(error => ({ error: error.message }));
|
||||
const recentRequests = api.requests.slice(-20).map(item => `${item.method} ${item.path}`);
|
||||
throw new Error(`${error.message}\nConfirm diagnostics: ${JSON.stringify(diagnostics, null, 2)}\nRecent API requests: ${JSON.stringify(recentRequests, null, 2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function fillByPlaceholder(page, placeholder, value, occurrence = 0) {
|
||||
const result = await page.evaluate(`
|
||||
(() => {
|
||||
const expected = ${JSON.stringify(placeholder)};
|
||||
const nextValue = ${JSON.stringify(value)};
|
||||
const occurrence = ${Number(occurrence)};
|
||||
const elements = Array.from(document.querySelectorAll('input, textarea'))
|
||||
.filter(el => String(el.getAttribute('placeholder') || '').includes(expected));
|
||||
const target = elements[occurrence];
|
||||
if (!target) return { ok: false, body: (document.body?.innerText || '').slice(0, 1200) };
|
||||
target.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set;
|
||||
if (setter) setter.call(target, nextValue);
|
||||
else target.value = nextValue;
|
||||
for (const type of ['input', 'change']) {
|
||||
target.dispatchEvent(new Event(type, { bubbles: true, cancelable: true }));
|
||||
}
|
||||
return { ok: true, placeholder: target.getAttribute('placeholder'), value: target.value };
|
||||
})()
|
||||
`);
|
||||
if (!result?.ok) throw new Error(`Input placeholder not found: ${placeholder}\n${result?.body || ''}`);
|
||||
await delay(150);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function fillByPlaceholderInSection(page, sectionTitle, placeholder, value, occurrence = 0) {
|
||||
const result = await page.evaluate(`
|
||||
(() => {
|
||||
const sectionTitle = ${JSON.stringify(sectionTitle)};
|
||||
const expected = ${JSON.stringify(placeholder)};
|
||||
const nextValue = ${JSON.stringify(value)};
|
||||
const occurrence = ${Number(occurrence)};
|
||||
const visible = el => {
|
||||
const style = window.getComputedStyle(el);
|
||||
const rect = el.getBoundingClientRect();
|
||||
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
||||
};
|
||||
const normalized = value => String(value || '').replace(/\\s+/g, ' ').trim();
|
||||
const sections = Array.from(document.querySelectorAll('.admin-section,.platform-section,section'))
|
||||
.filter(el => visible(el) && normalized(el.innerText || el.textContent || '').includes(sectionTitle));
|
||||
const root = sections.sort((a, b) => normalized(a.innerText || a.textContent || '').length - normalized(b.innerText || b.textContent || '').length)[0];
|
||||
if (!root) return { ok: false, body: (document.body?.innerText || '').slice(0, 1200) };
|
||||
const elements = Array.from(root.querySelectorAll('input, textarea'))
|
||||
.filter(el => String(el.getAttribute('placeholder') || '').includes(expected));
|
||||
const target = elements[occurrence];
|
||||
if (!target) return { ok: false, body: normalized(root.innerText || root.textContent || '').slice(0, 1200) };
|
||||
target.scrollIntoView({ block: 'center', inline: 'center' });
|
||||
const setter = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(target), 'value')?.set;
|
||||
if (setter) setter.call(target, nextValue);
|
||||
else target.value = nextValue;
|
||||
for (const type of ['input', 'change']) {
|
||||
target.dispatchEvent(new Event(type, { bubbles: true, cancelable: true }));
|
||||
}
|
||||
return { ok: true, placeholder: target.getAttribute('placeholder'), value: target.value };
|
||||
})()
|
||||
`);
|
||||
if (!result?.ok) throw new Error(`Input placeholder not found in section "${sectionTitle}": ${placeholder}\n${result?.body || ''}`);
|
||||
await delay(150);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function ensureInputValue(page, placeholder, value, occurrence = 0) {
|
||||
const current = await page.evaluate(`
|
||||
(() => {
|
||||
const expected = ${JSON.stringify(placeholder)};
|
||||
const occurrence = ${Number(occurrence)};
|
||||
const elements = Array.from(document.querySelectorAll('input, textarea'))
|
||||
.filter(el => String(el.getAttribute('placeholder') || '').includes(expected));
|
||||
return elements[occurrence]?.value || '';
|
||||
})()
|
||||
`);
|
||||
if (!current) await fillByPlaceholder(page, placeholder, value, occurrence);
|
||||
}
|
||||
|
||||
async function navigateAndExpect(page, baseUrl, path, text, timeoutMs = 10_000) {
|
||||
await page.navigate(`${baseUrl}${path}`);
|
||||
await waitForPath(page, path.split('?')[0], timeoutMs);
|
||||
@@ -1084,7 +1703,7 @@ async function runStudentJourney(browser, portal, api) {
|
||||
}
|
||||
}
|
||||
|
||||
async function runTenantJourney(browser, portal) {
|
||||
async function runTenantJourney(browser, portal, api) {
|
||||
const checks = [];
|
||||
const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`);
|
||||
try {
|
||||
@@ -1097,6 +1716,27 @@ async function runTenantJourney(browser, portal) {
|
||||
await waitForText(page, '题库内容');
|
||||
checks.push({ id: 'tenant.workbench.to_content', status: 'pass', detail: await currentPath(page) });
|
||||
|
||||
await fillByPlaceholder(page, '粘贴题目', '[{"title":"smoke","type":"single_choice"}]');
|
||||
await clickText(page, '后端预览');
|
||||
await waitForApiRequest(api, '/api/tenant-content/imports/preview/questions', 'POST');
|
||||
await waitForText(page, '预览第');
|
||||
await clickTextAndConfirm(page, '执行导入');
|
||||
await waitForApiRequest(api, '/api/tenant-content/imports/questions', 'POST');
|
||||
await clickTextAndConfirm(page, '复检任务');
|
||||
await waitForApiRequest(api, '/api/tenant-content/imports/post-check', 'POST');
|
||||
checks.push({ id: 'tenant.content.import_preview_execute_postcheck', status: 'pass', detail: '内容导入预览、执行和复检 API 已触发' });
|
||||
|
||||
await clickTextAndConfirm(page, '采纳');
|
||||
await waitForApiRequest(api, '/api/tenant-content/public-question-banks/adopt', 'POST');
|
||||
await clickTextAndConfirm(page, '同步');
|
||||
await waitForApiRequest(api, '/api/tenant-content/public-question-banks/sync', 'POST');
|
||||
await waitForText(page, '同步冲突');
|
||||
await clickTextAndConfirm(page, '全部保留本地');
|
||||
await waitForApiRequest(api, '/api/tenant-content/public-question-banks/conflicts/resolve-batch', 'POST');
|
||||
await clickText(page, '标记已读');
|
||||
await waitForApiRequest(api, '/api/tenant-content/notifications/status', 'POST');
|
||||
checks.push({ id: 'tenant.content.public_bank_adopt_sync_conflict', status: 'pass', detail: '公共题库采纳、同步、冲突处理和通知状态 API 已触发' });
|
||||
|
||||
await page.navigate(`${portal.baseUrl}${portal.landingPath}`);
|
||||
await waitForText(page, '财务运营');
|
||||
await clickText(page, '财务运营');
|
||||
@@ -1104,27 +1744,84 @@ async function runTenantJourney(browser, portal) {
|
||||
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) });
|
||||
}
|
||||
await navigateAndExpect(page, portal.baseUrl, '/pages/tenant-admin/dashboard/index', '数据看板');
|
||||
checks.push({ id: 'tenant.workbench.to_dashboard', status: 'pass', detail: await currentPath(page) });
|
||||
|
||||
await navigateAndExpect(page, portal.baseUrl, '/pages/tenant-admin/students/index', '学生运营');
|
||||
await waitForText(page, '测试学生');
|
||||
await clickText(page, '详情');
|
||||
await fillByPlaceholder(page, '学生备注', 'H5 烟测备注');
|
||||
await clickText(page, '保存备注');
|
||||
await waitForApiRequest(api, '/api/tenant-admin/students/notes', 'PUT');
|
||||
await fillByPlaceholder(page, '标题', 'H5 烟测跟进');
|
||||
await clickText(page, '保存跟进');
|
||||
await waitForApiRequest(api, '/api/tenant-admin/students/followups', 'PUT');
|
||||
await clickText(page, '预览候选');
|
||||
await waitForApiRequest(api, '/api/tenant-admin/students/supervision/preview', 'GET');
|
||||
await waitForText(page, '近期答题偏少');
|
||||
await clickText(page, '生成跟进任务');
|
||||
await waitForApiRequest(api, '/api/tenant-admin/students/supervision/generate', 'POST');
|
||||
await clickText(page, '保存每日规则');
|
||||
await waitForApiRequest(api, '/api/tenant-admin/students/supervision/rules', 'PUT');
|
||||
await fillByPlaceholder(page, '跟进标题', 'H5 烟测 CRM 推送');
|
||||
await fillByPlaceholder(page, '每行一个学生 userId', ids.user);
|
||||
await clickText(page, '入队推送');
|
||||
await waitForApiRequest(api, '/api/tenant-admin/students/crm-push', 'POST');
|
||||
checks.push({ id: 'tenant.students.operations', status: 'pass', detail: '备注、跟进、督导预览/生成/规则和 CRM 推送 API 已触发' });
|
||||
|
||||
await navigateAndExpect(page, portal.baseUrl, '/pages/tenant-admin/marketing/index', '营销中心');
|
||||
await fillByPlaceholder(page, '任务编码', 'h5-smoke-task');
|
||||
await fillByPlaceholder(page, '任务名称', 'H5 烟测积分任务');
|
||||
await clickText(page, '保存积分任务');
|
||||
await waitForApiRequest(api, '/api/tenant-admin/point-activity-tasks', 'PUT');
|
||||
await fillByPlaceholder(page, '兑换编码', 'h5-smoke-gift');
|
||||
await fillByPlaceholder(page, '兑换名称', 'H5 烟测兑换商品');
|
||||
await clickText(page, '保存兑换商品');
|
||||
await waitForApiRequest(api, '/api/tenant-admin/point-exchange-items', 'PUT');
|
||||
await fillByPlaceholder(page, '优惠券码', 'SMOKE90');
|
||||
await fillByPlaceholder(page, '优惠金额', '9');
|
||||
await clickText(page, '保存优惠券');
|
||||
await waitForApiRequest(api, '/api/tenant-admin/coupons', 'PUT');
|
||||
await clickText(page, '保存 CRM');
|
||||
await waitForApiRequest(api, '/api/crm/config', 'PUT');
|
||||
await clickText(page, '日志');
|
||||
await waitForApiRequest(api, '/api/crm/queue/logs', 'GET');
|
||||
await clickTextAndConfirmForApi(page, api, '重试', '/api/crm/queue/action', 'POST');
|
||||
await clickText(page, '保存规则');
|
||||
await waitForApiRequest(api, '/api/commission/settings', 'PUT');
|
||||
await clickTextInSection(page, '成员分佣比例', '测试销售');
|
||||
await clickText(page, '保存成员比例');
|
||||
await waitForApiRequest(api, '/api/commission/member-rate', 'PUT');
|
||||
await clickTextInSection(page, '分佣统计', '测试销售');
|
||||
await clickText(page, '生成结算单');
|
||||
await waitForApiRequest(api, '/api/commission/settlements/generate', 'POST');
|
||||
await clickText(page, '导出 CSV');
|
||||
await waitForApiRequest(api, '/api/commission/settlements/export', 'GET');
|
||||
checks.push({ id: 'tenant.marketing.operations', status: 'pass', detail: '积分、兑换、优惠券、CRM、分佣规则、成员比例和结算 API 已触发' });
|
||||
|
||||
await navigateAndExpect(page, portal.baseUrl, '/pages/tenant-admin/settings/index', '租户设置');
|
||||
await clickText(page, '保存草稿');
|
||||
await waitForApiRequest(api, '/api/tenant-admin/theme/preview', 'POST');
|
||||
await waitForText(page, '草稿:专注主题');
|
||||
await clickTextAndConfirmForApi(page, api, '发布主题', '/api/tenant-admin/theme/publish', 'POST');
|
||||
await clickText(page, '新建模板');
|
||||
await fillByPlaceholder(page, '模板名称', 'H5 烟测角色');
|
||||
await fillByPlaceholder(page, '模板编码', 'h5-smoke-role');
|
||||
await clickText(page, '保存模板');
|
||||
await waitForApiRequest(api, '/api/tenant-admin/role-templates', 'PUT');
|
||||
await clickText(page, '新建成员');
|
||||
await fillByPlaceholder(page, '姓名', 'H5 烟测成员', 1);
|
||||
await fillByPlaceholder(page, '手机号', '13911112222', 1);
|
||||
await clickText(page, '保存成员');
|
||||
await waitForApiRequest(api, '/api/tenant-admin/members', 'PUT');
|
||||
checks.push({ id: 'tenant.settings.brand_role_member', status: 'pass', detail: '主题草稿/发布、角色模板和成员绑定 API 已触发' });
|
||||
return checks;
|
||||
} finally {
|
||||
page.close();
|
||||
}
|
||||
}
|
||||
|
||||
async function runPlatformJourney(browser, portal) {
|
||||
async function runPlatformJourney(browser, portal, api) {
|
||||
const checks = [];
|
||||
const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`);
|
||||
try {
|
||||
@@ -1137,6 +1834,18 @@ async function runPlatformJourney(browser, portal) {
|
||||
await waitForText(page, '租户管理');
|
||||
checks.push({ id: 'platform.workbench.to_tenants', status: 'pass', detail: await currentPath(page) });
|
||||
|
||||
await fillByPlaceholder(page, 'tenant-slug', 'smoke-tenant');
|
||||
await fillByPlaceholder(page, '合作商名称', '烟测合作商');
|
||||
await clickTextAndConfirmForApi(page, api, '创建租户', '/api/platform-admin/tenants', 'POST');
|
||||
await waitForApiRequest(api, '/api/platform-admin/tenants/detail', 'GET');
|
||||
await waitForText(page, '租户详情');
|
||||
await clickTextInSection(page, '租户列表', '详情');
|
||||
await waitForText(page, ids.tenant);
|
||||
await clickTextAndConfirmForApi(page, api, '提交状态变更', '/api/platform-admin/tenants/status', 'PATCH');
|
||||
await fillByPlaceholder(page, '公司或个人抬头', '烟测合作商有限公司');
|
||||
await clickTextAndConfirmForApi(page, api, '保存账务资料', '/api/platform-admin/tenants/billing-profile', 'PUT');
|
||||
checks.push({ id: 'platform.tenants.create_status_billing', status: 'pass', detail: '平台租户创建、状态变更和账务资料 API 已触发' });
|
||||
|
||||
await page.navigate(`${portal.baseUrl}${portal.landingPath}`);
|
||||
await waitForText(page, '账务中心');
|
||||
await clickText(page, '账务中心');
|
||||
@@ -1144,18 +1853,50 @@ async function runPlatformJourney(browser, portal) {
|
||||
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) });
|
||||
}
|
||||
await ensureInputValue(page, 'tenantId', ids.tenant, 0);
|
||||
await ensureInputValue(page, 'starter_yearly', 'starter_yearly');
|
||||
await clickTextAndConfirm(page, '开通订阅');
|
||||
await waitForApiRequest(api, '/api/platform-admin/subscriptions', 'POST');
|
||||
await ensureInputValue(page, 'tenantId', ids.tenant, 1);
|
||||
await clickTextAndConfirm(page, '生成订阅账单');
|
||||
await waitForApiRequest(api, '/api/platform-admin/invoices/from-subscription', 'POST');
|
||||
await fillByPlaceholderInSection(page, '收款与用量', 'tenantId', ids.tenant, 0);
|
||||
await fillByPlaceholderInSection(page, '收款与用量', 'invoiceId', ids.invoice);
|
||||
await fillByPlaceholderInSection(page, '收款与用量', '元', '1999');
|
||||
await clickTextAndConfirmForApi(page, api, '确认收款', '/api/platform-admin/invoices/payments/manual-confirm', 'POST');
|
||||
await fillByPlaceholderInSection(page, '收款与用量', 'tenantId', ids.tenant, 1);
|
||||
await fillByPlaceholderInSection(page, '收款与用量', '0', '3722');
|
||||
await clickTextAndConfirmForApi(page, api, '记录用量', '/api/platform-admin/usage', 'POST');
|
||||
await clickText(page, '预览批量');
|
||||
await waitForApiRequest(api, '/api/platform-admin/invoices/from-subscriptions-batch', 'POST');
|
||||
await clickTextAndConfirmForApi(page, api, '批量生成账单', '/api/platform-admin/invoices/from-subscriptions-batch', 'POST');
|
||||
await clickText(page, '预览超额');
|
||||
await waitForApiRequest(api, '/api/platform-admin/invoices/from-usage-overage', 'POST');
|
||||
await clickTextAndConfirmForApi(page, api, '生成超额账单', '/api/platform-admin/invoices/from-usage-overage', 'POST');
|
||||
await clickText(page, '预览逾期');
|
||||
await waitForApiRequest(api, '/api/platform-admin/invoices/process-overdue', 'POST');
|
||||
await clickTextAndConfirmForApi(page, api, '生成催缴', '/api/platform-admin/invoices/process-overdue', 'POST');
|
||||
checks.push({ id: 'platform.billing.operations', status: 'pass', detail: '订阅、账单、收款、用量、批量、超额和催缴 API 已触发' });
|
||||
|
||||
await navigateAndExpect(page, portal.baseUrl, '/pages/platform-admin/question-banks/index', '公共题库授权');
|
||||
checks.push({ id: 'platform.workbench.to_question-banks', status: 'pass', detail: await currentPath(page) });
|
||||
await clickText(page, '选择题库');
|
||||
await fillByPlaceholder(page, 'starter_yearly,regional_yearly', 'starter_yearly');
|
||||
await clickTextAndConfirmForApi(page, api, '保存授权', '/api/platform-admin/question-bank-grants', 'PUT');
|
||||
checks.push({ id: 'platform.question_banks.grant', status: 'pass', detail: '公共题库授权保存 API 已触发' });
|
||||
|
||||
await navigateAndExpect(page, portal.baseUrl, '/pages/platform-admin/staff/index', '平台员工');
|
||||
checks.push({ id: 'platform.workbench.to_staff', status: 'pass', detail: await currentPath(page) });
|
||||
await clickText(page, '清空表单');
|
||||
await fillByPlaceholder(page, 'auth.users.id', 'auth-h5-smoke');
|
||||
await fillByPlaceholder(page, 'platform_operator', 'h5_smoke_operator');
|
||||
await fillByPlaceholder(page, '员工姓名', 'H5 烟测平台员工');
|
||||
await waitForText(page, '员工编辑');
|
||||
await clickText(page, '员工编辑');
|
||||
await waitForText(page, '当前权限点\n1');
|
||||
await clickTextAndConfirmForApi(page, api, '保存员工', '/api/platform-admin/staff', 'PUT');
|
||||
await clickTextAndConfirmForApi(page, api, '禁用', '/api/platform-admin/staff/status', 'PATCH');
|
||||
checks.push({ id: 'platform.staff.operations', status: 'pass', detail: '平台员工保存和禁用 API 已触发' });
|
||||
return checks;
|
||||
} finally {
|
||||
page.close();
|
||||
@@ -1203,8 +1944,8 @@ async function main() {
|
||||
browser = await startBrowser(options);
|
||||
const checks = [];
|
||||
checks.push(...await runStudentJourney(browser, staticServers.find(item => item.portal === 'student'), api));
|
||||
checks.push(...await runTenantJourney(browser, staticServers.find(item => item.portal === 'tenant-admin')));
|
||||
checks.push(...await runPlatformJourney(browser, staticServers.find(item => item.portal === 'platform-admin')));
|
||||
checks.push(...await runTenantJourney(browser, staticServers.find(item => item.portal === 'tenant-admin'), api));
|
||||
checks.push(...await runPlatformJourney(browser, staticServers.find(item => item.portal === 'platform-admin'), api));
|
||||
|
||||
const payload = {
|
||||
generatedAt: new Date().toISOString(),
|
||||
@@ -1226,6 +1967,11 @@ async function main() {
|
||||
paymentsCreated: api.requests.filter(item => item.path === '/api/commerce/payments/create' && item.method === 'POST').length,
|
||||
orderStatus: api.requests.filter(item => item.path === '/api/commerce/orders/status' && item.method === 'GET').length,
|
||||
tenantResolve: api.requests.filter(item => item.path === '/api/tenant/resolve').length,
|
||||
tenantContentWrites: api.requests.filter(item => item.path.startsWith('/api/tenant-content/') && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(item.method)).length,
|
||||
tenantAdminWrites: api.requests.filter(item => item.path.startsWith('/api/tenant-admin/') && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(item.method)).length,
|
||||
crmWrites: api.requests.filter(item => item.path.startsWith('/api/crm/') && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(item.method)).length,
|
||||
commissionWrites: api.requests.filter(item => item.path.startsWith('/api/commission/') && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(item.method)).length,
|
||||
platformAdminWrites: api.requests.filter(item => item.path.startsWith('/api/platform-admin/') && ['POST', 'PUT', 'PATCH', 'DELETE'].includes(item.method)).length,
|
||||
},
|
||||
},
|
||||
checks,
|
||||
|
||||
Reference in New Issue
Block a user