diff --git a/README.md b/README.md index 4f585c52..aab09c63 100644 --- a/README.md +++ b/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 diff --git a/docs/refactor/backend-open-items-and-capacity-20260701.md b/docs/refactor/backend-open-items-and-capacity-20260701.md index 2eb74900..2d9c6205 100644 --- a/docs/refactor/backend-open-items-and-capacity-20260701.md +++ b/docs/refactor/backend-open-items-and-capacity-20260701.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 受限结果则作为未调参下限参考。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index af90562c..b562e2ff 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -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. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。 diff --git a/docs/refactor/performance-benchmark-summary-20260630.md b/docs/refactor/performance-benchmark-summary-20260630.md index 2043f1c5..2914a522 100644 --- a/docs/refactor/performance-benchmark-summary-20260630.md +++ b/docs/refactor/performance-benchmark-summary-20260630.md @@ -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、真实前端请求节奏和生产网络下复跑。 ## 后续复测 diff --git a/scripts/taro-h5-interaction-smoke.js b/scripts/taro-h5-interaction-smoke.js index 2820e61c..63bdbff1 100644 --- a/scripts/taro-h5-interaction-smoke.js +++ b/scripts/taro-h5-interaction-smoke.js @@ -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,