diff --git a/README.md b/README.md index b6ba92ef..18f04626 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,7 @@ | 对象存储/资料安全 | √ 可联调,待生产 AV/CDN | OSS/COS/Supabase Storage 签名、上传确认、短签名预览下载、水印 traceId、复检和安全扫描地基已完成 | | PocketBase 真实数据迁移 | √ 本地跑通,待人工复核 blocker | SQLite 导出、标准化导入、校验和抽样脚本已跑通;正式切换前处理缺用户订单和缺归属手册章节 | | Taro H5 三端前端 | √ 第一版可构建 | 学生端、租户后台、平台后台均有真实 API 页面;后续继续补小程序兼容、视觉精修、状态管理和端到端测试 | -| 生产安全/压测交付 | △ 本地真实数据压测已跑,云端待复测 | 本地 Docker/Supabase 已完成真实迁移数据 30/50/100/150 并发混合读写压测;上云后仍需执行 `@codex-security`、生产 readiness、远程 Auth/RLS、4c16g 压测、PostgreSQL 调优和上线证据门禁 | +| 生产安全/压测交付 | △ 本地真实数据压测已跑,云端待复测 | 本地 Docker/Supabase 已完成真实迁移数据 30/50/100/150 并发混合读写压测;已补 PostgreSQL 调参证据脚本和上线前角色旅程烟测;上云后仍需执行生产 readiness、远程 Auth/RLS、4c16g 压测、PostgreSQL 调优和上线证据门禁。`@codex-security` 只有在 Codex 环境暴露扫描工具时才能标记完成 | 更完整的进度看这些文档: @@ -74,6 +74,7 @@ - `docs/refactor/next-development-todo.md` - `docs/refactor/blueprint-coverage.md` - `docs/refactor/api-structure.md` +- `docs/refactor/web-launch-acceptance-checklist.md` ## 目录结构 @@ -445,6 +446,8 @@ npm run launch:gate -- --evidence docs/refactor/production-launch-evidence.json `production-launch-evidence.json` 不入 Git,里面只记录验收摘要、artifact 路径、审批人和时间,不保存真实 access token、支付密钥、对象存储密钥或用户隐私明细。门禁会要求以下证据全部齐备并通过:`readiness:production`、`readiness:production:db`、真实 `smoke:auth:remote`、`test:rls`、PocketBase production dry-run、`pb:import:validate`、`pb:import:sample`、真实数据 API 读路径压测、API/worker/Taro 构建、`audit:runtime`、`@codex-security` 扫描,以及备份、回滚、真实数据抽样、生产 provider、对象存储控制、支付对账和三套 H5 `runtime-config.json` 人工确认。 +补充说明:当前 Codex 环境如果没有暴露 `@codex-security` 可调用工具,不能把插件扫描写成已完成;只能先用 `npm run audit:runtime`、`npm run test:readiness`、`npm run test:rls` 和代码审查作为临时安全证据,并在上线证据里保留插件扫描待补项。 + 模板文件: ```text @@ -538,16 +541,42 @@ docs/refactor/postgresql-4c16g-tuning.md docs/refactor/performance-benchmark-runbook.md ``` -最近一次本地真实迁移库已开启刷题写入闭环压测,数据规模约为 74,102 题、1,597 个题目合集、3,102 个练习蓝图、3,500 个单词、2,676 条知识手册和 3,670 个用户。压测 worker 是无停顿请求流,不能直接等同于真实在线学生数;前端完成后需要用真实页面埋点估算单个学生平均 RPS,再折算在线容量。 +PostgreSQL 调参与运行证据采集: + +```powershell +$env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" +npm run perf:postgres:evidence +``` + +上线前角色旅程烟测: + +```powershell +$env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres" +npm run smoke:launch-persona +``` + +`smoke:launch-persona` 会从普通学生、租户管理员、平台管理员三个视角调用真实 API,覆盖 SVIP 后刷题、收藏、错题复习入口、租户数据看板/主题/学生/销售转化、平台租户/套餐/审计入口和越权拒绝。它会写入少量 `launch_persona_smoke` 测试记录,生产只建议在灰度或演练租户运行。 + +最近一次本地真实迁移库数据规模约为 74,117 题、1,601 个题目合集、3,106 个练习蓝图、3,505 个单词、2,678 条知识手册、3,690 个用户、113,810 条答题记录、38,207 条错题和 458 条权益。压测 worker 是无停顿请求流,不能直接等同于真实在线学生数;前端完成后需要用真实页面埋点估算单个学生平均 RPS,再折算在线容量。 | 并发 worker | 时长 | 刷题写入比例 | 请求数 | 错误率 | 吞吐 | P95 | P99 | | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| 30 | 120s | 0% | 108,336 | 0.00% | 897.04 req/s | 68.32 ms | 84.50 ms | +| 50 | 60s | 10% | 52,979 | 0.00% | 870.42 req/s | 104.92 ms | 136.22 ms | | 30 | 120s | 10% | 112,896 | 0.00% | 934.74 req/s | 64.26 ms | 81.14 ms | | 50 | 120s | 10% | 92,737 | 0.00% | 767.24 req/s | 121.51 ms | 159.12 ms | | 100 | 120s | 8% | 84,608 | 0.00% | 699.27 req/s | 254.69 ms | 331.84 ms | | 150 | 120s | 6% | 82,693 | 0.00% | 682.40 req/s | 369.90 ms | 493.69 ms | -本地结论:100 个无停顿 worker 内 P95 仍低于 300ms;150 worker 零错误但 P95 已明显上升,可作为本机 Docker 环境的压力拐点参考。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要见: +只读上线门禁继续要求 `includeWrites=false`;混合读写报告需要显式使用 `--allow-writes` 做人工容量观察,例如: + +```powershell +npm run perf:summary -- --input docs/refactor/performance-reports/api-benchmark-20260701-002524.json --json --allow-writes --min-duration-seconds=60 --min-concurrency=50 --max-p95-ms=500 --max-p99-ms=1200 +``` + +使用 `--allow-writes` 时会输出 `capacityObservation`,不输出 `launchGateCheck`,不能把写入场景误填成生产上线门禁的只读证据。 + +本地结论:100 个无停顿 worker 内 P95 仍低于 300ms;150 worker 零错误但 P95 已明显上升,可作为本机 Docker 环境的压力拐点参考。最新只读 30 worker/120s 为 897.04 req/s,粗略按单学生 0.05 到 0.2 req/s 可换算为 4,485 到 17,940 名活跃在线学生请求吞吐,但这不是生产 SLA。当前 PostgreSQL evidence 提醒本地默认库仍是 `jit=on` 且 `statement_timeout`、`idle_in_transaction_session_timeout`、`lock_timeout` 未设置,上云必须按调参文档复核。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要见: ```text docs/refactor/performance-benchmark-summary-20260630.md diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index c77f53fb..98c0b07f 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -91,10 +91,11 @@ - 生产 `.env` 模板和 `npm run readiness:production` / `npm run readiness:production:db` 已补,后续上云必须作为验收 gate。 - Auth/JWKS 上云后必须临时设置 `AUTH_SMOKE_*` 环境变量并运行 `npm run smoke:auth:remote`,真实 access token 不得写入仓库、前端配置或日志。 - 本地/预生产必须同时跑 `npm run test:rls`,它验证运行时 JWT claim 下的租户隔离,和 `readiness:production:db` 的静态 policy 检查互补。 - - 已补 `npm run launch:gate` 生产上线证据门禁和 `docs/refactor/production-launch-evidence.template.json` 模板;最终切换前必须把 readiness、远程 Auth、RLS、生产 dry-run、导入校验、`pb:import:sample` 业务抽样、真实数据 API 读路径压测、API/worker/Taro、运行时审计、`@codex-security`、备份/回滚/真实抽样/生产 provider 等证据填入本地 `production-launch-evidence.json` 并通过门禁。 + - 已补 `npm run launch:gate` 生产上线证据门禁和 `docs/refactor/production-launch-evidence.template.json` 模板;最终切换前必须把 readiness、远程 Auth、RLS、生产 dry-run、导入校验、`pb:import:sample` 业务抽样、真实数据 API 读路径压测、API/worker/Taro、运行时审计、`@codex-security`、备份/回滚/真实抽样/生产 provider 等证据填入本地 `production-launch-evidence.json` 并通过门禁。当前 Codex 环境未暴露可调用的 `@codex-security` 扫描工具时,该项只能标为待补,不能伪造完成。 - 确认数据库迁移流程、备份恢复、日志、告警。 - 准备 API 容器部署和 Supabase 云端/自托管连接方案。 - - 已补 `npm run perf:api:local` 和 `docs/refactor/performance-benchmark-runbook.md`,可在本地或云端对真实迁移数据做只读混合压测;4 核 16G 正式容量报告需上云后按 6/30/50/100 阶梯并发复跑并归档到本地上线证据。 + - 已补 `npm run perf:api:local`、`npm run perf:summary`、`npm run perf:postgres:evidence`、`npm run smoke:launch-persona` 和 `docs/refactor/performance-benchmark-runbook.md`,可在本地或云端对真实迁移数据做只读门禁、混合读写容量观察、PostgreSQL 调参证据和三类角色旅程烟测。2026-07-01 本地真实迁移库只读 30 worker/120s 为 108336 请求、0 错误、897.04 req/s、P95 68.32ms、P99 84.50ms;混合读写 50 worker/60s/10% 写入为 52979 请求、0 错误、870.42 req/s、P95 104.92ms、P99 136.22ms。 + - 当前本地 PostgreSQL evidence 仍提示 `jit=on`、`statement_timeout=0`、`idle_in_transaction_session_timeout=0`、`lock_timeout=0`;上云后必须按 `docs/refactor/postgresql-4c16g-tuning.md` 调整参数并复跑 evidence。4 核 16G 正式容量报告需上云后按 6/30/50/100 阶梯并发复跑并归档到本地上线证据。 ### P1 商用功能完善 diff --git a/docs/refactor/performance-benchmark-runbook.md b/docs/refactor/performance-benchmark-runbook.md index e6df9716..9ceb9e86 100644 --- a/docs/refactor/performance-benchmark-runbook.md +++ b/docs/refactor/performance-benchmark-runbook.md @@ -19,6 +19,22 @@ npm run perf:api:local - 默认不创建练习 session,不写业务数据。 - 输出 JSON 和 Markdown 报告到 `docs/refactor/performance-reports/`。该目录已被 `.gitignore` 忽略,不应提交。 +PostgreSQL 调参与运行证据: + +```bash +npm run perf:postgres:evidence +``` + +该脚本会把关键 `pg_settings`、连接等待、缓存命中、大表大小和可选 `pg_stat_statements` Top SQL 输出到 `docs/refactor/launch-artifacts/`。调参前后各跑一次,配合 API 压测报告判断是否真正改善。 + +角色旅程烟测: + +```bash +npm run smoke:launch-persona +``` + +该脚本从普通学生、租户管理员、平台管理员三个视角调用真实 API,覆盖 SVIP 后刷题、收藏、错题复习入口、租户 dashboard/主题/学生/销售转化、平台租户/套餐/审计入口和越权拒绝。它会写入少量 `launch_persona_smoke` 测试练习与收藏记录,只建议在本地、预生产或灰度租户运行。 + ## 前置条件 本地真实迁移库压测: @@ -120,6 +136,8 @@ npm run perf:api:local | spike-100 | 100 | 2min | 否 | 短峰值和缓存命中观察 | | spike-100-write | 100 | 2min | 是 | 短峰值刷题闭环,找 P95/P99 拐点 | +本地 Docker Desktop 可以先用同一矩阵做跑分,但只能证明代码、索引和本机 Docker 环境的趋势。正式容量承诺必须以目标云服务器、生产 PostgreSQL 参数、生产 API/worker 连接池、对象存储/CDN 和真实网络重新跑。 + PowerShell 示例: ```powershell @@ -203,6 +221,14 @@ npm run perf:summary -- --input docs/refactor/performance-reports/api-benchmark- 工具会从 `summary.errors`、`summary.errorRate`、`summary.latencyOk.p95Ms`、`summary.latencyOk.p99Ms`、`config.concurrency`、`config.durationSeconds`、`config.includeWrites` 生成 `launchGateCheck.summary`,并按上线门禁阈值返回退出码。通过后,把 `launchGateCheck.summary` 转写到 `production-launch-evidence.json`;`artifact` 保留对应日志或报告路径。 +写入混合场景默认不会通过只读门禁。如果只是做容量观察,可以显式允许写入并使用写入场景阈值: + +```powershell +npm run perf:summary -- --input docs/refactor/performance-reports/api-benchmark-20260630-xxxxxx.json --json --allow-writes --min-duration-seconds=60 --min-concurrency=50 --max-p95-ms=500 --max-p99-ms=1200 +``` + +使用 `--allow-writes` 时,摘要工具输出 `capacityObservation`,不输出 `launchGateCheck`;不要把写入场景误填进生产上线门禁的 `performance.api-real-data-read`。 + 更高的 50/100 并发、写入混合场景和容量结论仍应作为人工容量报告归档;门禁只负责挡住明显不达标的基础读路径。 证据中只记录报告路径、并发矩阵、P95/P99、错误率和结论,不保存真实 token、支付密钥、用户隐私或完整响应。 diff --git a/docs/refactor/performance-benchmark-summary-20260630.md b/docs/refactor/performance-benchmark-summary-20260630.md index 1545025b..88dc79d0 100644 --- a/docs/refactor/performance-benchmark-summary-20260630.md +++ b/docs/refactor/performance-benchmark-summary-20260630.md @@ -1,6 +1,6 @@ # 真实迁移数据 API 压测摘要 -更新时间:2026-06-30 +更新时间:2026-07-01 这份文件只记录脱敏后的聚合指标,便于 README、上线门禁和后续 AI 开发继续引用。原始 JSON/Markdown 报告位于已忽略的 `docs/refactor/performance-reports/`,不要提交到 Git。 @@ -8,8 +8,9 @@ - 环境:本地 Docker Desktop + 本地 Supabase/PostgreSQL + 本地 API 进程。 - 数据:PocketBase 真实导出数据导入新 PostgreSQL 后压测。 -- 数据规模:约 74,102 道题、1,597 个题目合集、3,102 个练习蓝图、3,500 个单词、2,676 条知识手册、3,670 个用户。 -- 写入流量:开启真实刷题闭环,包含创建 session、拉取 session detail、提交若干答案、交卷和读取报告。 +- 数据规模:当前真实迁移库约 74,117 道题、1,601 个题目合集、3,106 个练习蓝图、3,505 个单词、2,678 条知识手册、3,690 个用户、113,810 条答题记录、38,207 条错题和 458 条权益。 +- 写入流量:混合场景开启真实刷题闭环,包含创建 session、拉取 session detail、提交若干答案、交卷和读取报告。 +- 商城说明:本地库的订单数据会受 seed、烟测和导入演练影响,当前容量结论聚焦题库读写链路,不用于推断 GMV、支付或订单峰值。 - 排行榜:未纳入默认压测,当前产品默认关闭排行榜。 - 说明:压测 worker 是无停顿请求流,不等同于真实在线学生数。真实在线容量需要前端埋点后按单个学生平均 RPS 折算。 @@ -34,11 +35,24 @@ | 100 | 60s | 8% | 59,123 | 0.00% | 969.92 req/s | 179.70 ms | 235.19 ms | | 150 | 60s | 6% | 53,167 | 0.00% | 870.06 req/s | 298.23 ms | 402.89 ms | +### 2026-07-01 上线门禁与读写复核 + +这轮在同一套本地 Docker/Supabase 真实迁移库上执行,补充了只读上线门禁和 50 worker 混合读写复核。 + +| 并发 worker | 时长 | 刷题写入比例 | 请求数 | 错误率 | 吞吐 | P95 | P99 | 结论 | +| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | +| 30 | 120s | 0% | 108,336 | 0.00% | 897.04 req/s | 68.32 ms | 84.50 ms | 通过只读上线门禁 | +| 50 | 60s | 10% | 52,979 | 0.00% | 870.42 req/s | 104.92 ms | 136.22 ms | 通过混合读写观察线 | + +同轮 `npm run smoke:launch-persona` 已通过,覆盖普通学生 SVIP 后刷题、收藏、错题复习入口,租户管理员 dashboard/主题/学生/销售转化入口,平台管理员租户/套餐/审计入口,以及学生越权后台和跨租户访问拒绝。 + +同轮 `npm run perf:postgres:evidence` 运行成功,但本地默认 PostgreSQL 仍有生产前必须调优的 warning:`jit=on`、`statement_timeout=0`、`idle_in_transaction_session_timeout=0`、`lock_timeout=0`。上云后要按 `docs/refactor/postgresql-4c16g-tuning.md` 调整并重启需要重启的参数,再复跑证据采集和压测。 + ## 初步结论 - 本地 Docker 环境下,100 个无停顿 worker 内 P95 仍低于 300ms,可以作为当前代码和索引状态的本地舒适区参考。 - 150 个无停顿 worker 仍然 0 错误,但 P95 在不同轮次中接近或超过 300ms,已经能看到本地压力拐点。 -- 如果未来前端真实埋点显示每名在线学生平均 0.05 到 0.2 req/s,则 700 req/s 理论吞吐约对应 3,500 到 14,000 名活跃在线学生请求量;这只是吞吐换算,不是生产 SLA。 +- 按最新 897 req/s 只读吞吐粗略折算,如果未来前端真实埋点显示每名在线学生平均 0.05 到 0.2 req/s,则理论请求吞吐约对应 4,485 到 17,940 名活跃在线学生;按更保守的 700 req/s 估算约为 3,500 到 14,000 名。这只是吞吐换算,不是生产 SLA。 - 正式容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、对象存储/CDN、真实前端请求节奏和生产网络下复跑。 ## 后续复测 diff --git a/docs/refactor/postgresql-4c16g-tuning.md b/docs/refactor/postgresql-4c16g-tuning.md index e4c25efe..79d6f18e 100644 --- a/docs/refactor/postgresql-4c16g-tuning.md +++ b/docs/refactor/postgresql-4c16g-tuning.md @@ -46,6 +46,25 @@ | `statement_timeout` | `30s` | `30s` | API 请求不应长期占用数据库;导入脚本用会话级覆盖 | | `lock_timeout` | `5s` | `5s` | 防止普通请求长时间等锁 | +## 证据采集脚本 + +仓库已补上线前证据采集脚本: + +```bash +npm run perf:postgres:evidence +``` + +脚本会输出 JSON/Markdown 到已忽略的 `docs/refactor/launch-artifacts/`,包含: + +- `pg_settings` 中关键调参项、来源和 `pending_restart`。 +- `pg_stat_activity` 连接状态、锁等待和 I/O 等待聚合。 +- `pg_stat_database` 事务、回滚和缓存命中率。 +- `pg_stat_bgwriter` checkpoint 计数和耗时。 +- public schema 大表估算行数、总大小和索引大小。 +- 如已启用 `pg_stat_statements`,输出按总执行耗时排序的 Top SQL。 + +上云后建议顺序是:先采集一次默认值,应用本文件 shared-host 参数并重启需要重启的项,再采集一次,然后跑 API 阶梯压测。调参证据和压测报告一起进入本地 `production-launch-evidence.json`,不要提交真实证据文件。 + ## 应用连接池边界 4 核机器的关键不是把 `max_connections` 拉大,而是控制同时活跃 SQL 的数量。 @@ -173,6 +192,23 @@ limit 20; - 没有 OOM、没有频繁连接耗尽、没有长时间 idle in transaction。 - 慢 SQL 日志能对应到具体接口、worker 或迁移脚本。 +## pg_stat_statements + +上线压测和灰度期间建议启用 `pg_stat_statements`。如果自托管 Supabase/PostgreSQL 允许修改 `shared_preload_libraries`,推荐: + +```sql +alter system set shared_preload_libraries = 'pg_stat_statements'; +``` + +该参数需要重启 PostgreSQL。重启后执行: + +```sql +create extension if not exists pg_stat_statements; +select pg_stat_statements_reset(); +``` + +每轮压测后看总耗时、平均耗时和调用量最高的 SQL,再决定是否新增索引、改 SQL、加预聚合或调整 API 缓存。不要把慢 SQL 明细直接提交到 Git,因为其中可能包含业务表名、常量和内部路径。 + ## 压测闭环 调参后必须跑 API 压测,不要只凭参数表判断容量。当前仓库提供本地压测脚本: diff --git a/docs/refactor/web-launch-acceptance-checklist.md b/docs/refactor/web-launch-acceptance-checklist.md new file mode 100644 index 00000000..a3fb5546 --- /dev/null +++ b/docs/refactor/web-launch-acceptance-checklist.md @@ -0,0 +1,117 @@ +# Web 版上线前验收清单 + +更新时间:2026-06-30 + +这份清单用于先上线 H5 Web 题库。Taro 仍然是前端工程,后端以 Supabase Auth/JWT、PostgreSQL/RLS、`apps/api`、worker 为主。前端视觉和交互可以参考 `F:\project\参考\旧题库小程序前端文件` 和旧 Web 版,但不能继承旧 PocketBase 直连、旧鉴权或旧字段模型。 + +## 前端接入原则 + +- H5 登录态优先使用 Supabase Auth access token。 +- 复杂业务统一调用 `apps/api`,不要让页面直接写 Supabase 表。 +- `runtime-config.json` 只能放 `portal`、`apiBaseUrl`、`supabaseUrl`、`supabasePublishableKey`、`tenantCode`。 +- 前端禁止出现 service role key、secret key、数据库连接串、OSS/COS 密钥、支付私钥、短信密钥。 +- 页面层禁止手写 `x-user-id`、`Authorization`、`x-tenant-id`,统一使用 `apps/taro/src/services/api.ts`。 +- 私有图片、PDF、视频只消费后端短签名和 `content_assets` 权限结果,不能拼对象存储 URL。 +- 学生头像只做男女预设,不做上传、裁剪或第三方头像同步。 +- 排行榜默认不请求、不展示;只有租户购买/开启活动且完成专项压测后再接独立页面。 + +## 学生端验收旅程 + +用普通学生账号在 H5 走一遍: + +1. 进入域名后能解析租户品牌、主题、公开配置。 +2. 登录后 `GET /api/profile/me` 返回当前用户,不能靠页面传 userId。 +3. 查看题库入口、地区、分类树、合集和练习蓝图。 +4. 开通或确认 SVIP 权益后创建顺序/随机/模考 session。 +5. 拉取 session detail,刷新页面后仍能按后端 session 续练。 +6. 提交答案、交卷、查看报告、逐题复盘。 +7. 收藏题目,在个人中心或复习页进入收藏练习。 +8. 产生错题后查看错题本、复习计划和错题复习 session。 +9. 背单词:今日计划、单元学习、收藏练习、发音、进度上报。 +10. 知识手册:章节、搜索、公式/图片/RichContent 安全渲染。 +11. 分数线:地区、院校、专业、年份和动态字段筛选。 +12. 资料下载:预览/下载前必须看到短签名、水印 traceId 或权限提示。 +13. 视频解析:未授权提示、授权后短签名播放、水印上下文、次数扣减。 +14. 订单:套餐、优惠券、下单、支付参数、订单详情、状态轮询、售后入口。 +15. 个人中心:权益、学习统计、勋章、积分任务、兑换、站内通知。 + +自动烟测命令: + +```bash +npm run smoke:launch-persona +``` + +该命令会写入少量测试练习和收藏记录,生产只在灰度/演练租户执行。 + +## 租户后台验收旅程 + +用租户管理员账号在 H5 走一遍: + +1. 工作台模块按权限显示,学生账号访问后台必须 403。 +2. 数据看板能按 7/30/90 天、地区读取收益、注册、学习、内容、活跃、反馈、激活码。 +3. 学生运营:列表、状态、批量导入、分班、备注、跟进任务、督导规则、CRM 推送。 +4. 题库内容:入口、分类、题目集合、蓝图、题目/单词/手册/分数线/视频导入预览、异步 job、复检。 +5. 营销中心:激活码、优惠券规则、核销报表、积分任务、兑换、勋章、用户通知。 +6. CRM/分佣:CRM 配置、队列、失败池、日志脱敏、重试/忽略、结算、凭证复核、销售转化报表。 +7. 财务运营:退款、官方账单下载任务、对账异常、差错工单、人工调整凭证。 +8. 设置:品牌、主题模板/草稿/发布、域名、支付账户、登录 provider、角色模板、成员绑定。 +9. 跨租户访问必须拒绝,字段权限如手机号脱敏要按角色生效。 + +## 平台后台验收旅程 + +用平台管理员账号在 H5 走一遍: + +1. 工作台、租户列表、租户详情、状态变更、账务资料维护。 +2. SaaS 套餐、订阅、订阅账单候选、批量开票、收款、逾期催缴。 +3. 用量记录、用量超额候选、超额账单生成。 +4. 公共题库授权、租户可见范围、采纳/同步/冲突运营摘要。 +5. 平台员工、权限点、禁用/恢复、受限员工越权拒绝。 +6. 审计日志、CSV/JSON 导出、审计告警、外部通知渠道。 +7. 学生或租户管理员访问平台后台必须 403。 + +## 安全与配置门禁 + +上线前至少执行: + +```bash +npm run test:readiness +npm run test:rls +npm run audit:runtime +npm run check:api +npm run check:worker +npm run check:taro +npm run smoke:launch-persona +``` + +如 `@codex-security` 插件在当前 Codex 环境暴露扫描工具,再补插件扫描结果。若工具不可用,不能把该项标记为已完成,只能在上线证据里标记为待补。 + +生产 API 推荐: + +```text +ALLOW_LEGACY_AUTH_HEADERS=false +ALLOW_PLATFORM_ADMIN_KEY=false +CORS_ORIGIN=https://student.example.com,https://tenant-admin.example.com,https://platform-admin.example.com +``` + +生产 worker 推荐: + +```text +WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http +WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false +STORAGE_DEFAULT_PROVIDER=aliyun_oss 或 tencent_cos 或 supabase_storage +STORAGE_REQUIRE_TENANT_PREFIX=true +``` + +## 性能与数据库 + +本地真实迁移库已能跑题库读写压测,但正式容量必须在目标 4 核 16G 云服务器复测。 + +推荐顺序: + +1. `npm run perf:postgres:evidence` 采集默认 PostgreSQL 参数。 +2. 按 `docs/refactor/postgresql-4c16g-tuning.md` 应用 shared-host 起步值。 +3. 重启 PostgreSQL 后再次 `npm run perf:postgres:evidence`。 +4. 跑 `npm run perf:api:local` 的 6/30/50/100 阶梯,只读和少量写入各一组。 +5. 把摘要写入本地 `production-launch-evidence.json`,执行 `npm run launch:gate`。 + +容量折算不要直接把压测 worker 当在线人数。真实学生有读题和思考时间,应结合 H5 埋点估算单人平均 RPS,再按成功 RPS 折算在线容量。 diff --git a/package.json b/package.json index f9195529..f0fd532f 100644 --- a/package.json +++ b/package.json @@ -39,6 +39,7 @@ "smoke:auth:remote": "node scripts/remote-auth-jwt-smoke.js", "test:api": "npm run db:smoke-seed && npm run build:api && node scripts/api-integration-test.js --start-server", "perf:api:local": "npm run build:api && node scripts/api-performance-benchmark.js", + "perf:postgres:evidence": "node scripts/postgres-tuning-evidence.js", "test:worker:crm": "npm run db:smoke-seed && npm run build:worker && node scripts/crm-worker-integration-test.js", "test:worker:commerce": "npm run db:smoke-seed && npm run build:worker && node scripts/commerce-worker-integration-test.js", "test:worker:platform-billing": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-billing-worker-integration-test.js", @@ -57,6 +58,7 @@ "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js", "test:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js", "test:launch-gate": "node scripts/production-launch-gate-test.js", + "smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js", "test:pb:dry-run": "node scripts/pb-dry-run-report-test.js", "test:pb:sqlite-export": "node scripts/pb-sqlite-export-test.js", "test:perf:summary": "node scripts/performance-summary-test.js", diff --git a/scripts/launch-persona-smoke.js b/scripts/launch-persona-smoke.js new file mode 100644 index 00000000..b4cee760 --- /dev/null +++ b/scripts/launch-persona-smoke.js @@ -0,0 +1,482 @@ +import assert from 'node:assert/strict'; +import { spawn } from 'node:child_process'; +import fs from 'node:fs/promises'; +import net from 'node:net'; +import path from 'node:path'; +import pg from 'pg'; + +const DEFAULT_DATABASE_URL = 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; +const databaseUrl = process.env.DATABASE_URL || DEFAULT_DATABASE_URL; +const outputDir = process.env.LAUNCH_SMOKE_OUTPUT_DIR || 'docs/refactor/launch-artifacts'; +const startServer = boolEnv('LAUNCH_SMOKE_START_SERVER', !process.env.LAUNCH_SMOKE_API_BASE); +const fixedPort = Number(process.env.LAUNCH_SMOKE_API_PORT || 0) || 0; + +const ids = { + tenant: '00000000-0000-0000-0000-000000000001', + partnerTenant: '00000000-0000-0000-0000-000000000901', + student: '00000000-0000-0000-0000-000000000101', + tenantAdmin: '00000000-0000-0000-0000-000000000102', + platformAdmin: '00000000-0000-0000-0000-000000000999', + region: '00000000-0000-0000-0000-000000000301', + plan: '00000000-0000-0000-0000-000000000201', +}; + +let apiBase = process.env.LAUNCH_SMOKE_API_BASE || process.env.API_BASE || ''; +let serverProcess = null; +let serverLogs = ''; + +function boolEnv(key, fallback) { + const value = process.env[key]; + if (value === undefined || value === '') return fallback; + return ['1', 'true', 'yes', 'on'].includes(value.toLowerCase()); +} + +function nowIso() { + return new Date().toISOString(); +} + +function shanghaiStamp(date = new Date()) { + const parts = Object.fromEntries( + new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).formatToParts(date).map(part => [part.type, part.value]), + ); + return `${parts.year}${parts.month}${parts.day}-${parts.hour}${parts.minute}${parts.second}`; +} + +function getFreePort() { + return new Promise((resolve, reject) => { + const server = net.createServer(); + server.on('error', reject); + server.listen(fixedPort, '127.0.0.1', () => { + const address = server.address(); + server.close(() => resolve(address.port)); + }); + }); +} + +function sleep(ms) { + return new Promise(resolve => setTimeout(resolve, ms)); +} + +async function waitForHealth(timeoutMs = 20_000) { + const started = Date.now(); + let lastError = null; + while (Date.now() - started < timeoutMs) { + try { + const response = await fetch(new URL('/health', apiBase)); + const payload = await response.json().catch(() => ({})); + if (response.ok && payload.ok) return; + } catch (error) { + lastError = error; + } + await sleep(250); + } + throw new Error(`API server did not become healthy. ${lastError?.message || ''}\n${serverLogs}`); +} + +async function startServerIfNeeded() { + if (!startServer) return; + const port = await getFreePort(); + apiBase = `http://127.0.0.1:${port}`; + serverProcess = spawn(process.execPath, ['apps/api/dist/apps/api/src/server.js'], { + cwd: process.cwd(), + env: { + ...process.env, + PORT: String(port), + DATABASE_URL: databaseUrl, + MAX_JSON_BODY_BYTES: process.env.MAX_JSON_BODY_BYTES || '1048576', + MAX_IMPORT_JSON_BODY_BYTES: process.env.MAX_IMPORT_JSON_BODY_BYTES || '10485760', + }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + serverProcess.stdout.on('data', chunk => { + serverLogs += chunk.toString(); + }); + serverProcess.stderr.on('data', chunk => { + serverLogs += chunk.toString(); + }); + await waitForHealth(); +} + +function stopServer() { + if (serverProcess && !serverProcess.killed) serverProcess.kill(); +} + +function buildUrl(pathname, query = {}) { + const target = new URL(pathname, apiBase); + for (const [key, value] of Object.entries(query)) { + if (value !== undefined && value !== null && value !== '') target.searchParams.set(key, String(value)); + } + return target; +} + +async function request(pathname, options = {}) { + const response = await fetch(buildUrl(pathname, options.query), { + method: options.method || 'GET', + headers: { + 'content-type': 'application/json', + ...(options.tenantId === false ? {} : { 'x-tenant-id': options.tenantId || ids.tenant }), + ...(options.userId === false ? {} : { 'x-user-id': options.userId || ids.student }), + ...(options.headers || {}), + }, + body: options.body ? JSON.stringify(options.body) : undefined, + }); + const payload = await response.json().catch(() => ({})); + const expectedStatus = options.expectStatus || 200; + assert.equal(response.status, expectedStatus, `${options.method || 'GET'} ${pathname} expected ${expectedStatus}, got ${response.status}: ${JSON.stringify(payload)}`); + return payload; +} + +async function one(pool, sql, params = []) { + const result = await pool.query(sql, params); + return result.rows[0] || null; +} + +function pickAnswer(question) { + const correctIndices = Array.isArray(question.correctOptionIndices) ? question.correctOptionIndices : []; + if (correctIndices.length) return { selectedOptions: correctIndices.map(item => String(item)) }; + if (question.correctOptionIndex !== undefined && question.correctOptionIndex !== null) return { selectedOptions: [String(question.correctOptionIndex)] }; + if (Array.isArray(question.options) && question.options.length) return { selectedOptions: ['0'] }; + return { answerText: 'launch smoke answer', selfJudgedCorrect: true }; +} + +async function ensureSvipEntitlement(pool) { + await pool.query( + ` + insert into public.entitlements ( + tenant_id, user_id, entitlement_type, scope_type, scope_id, + source_type, legacy_source_id, starts_at, expires_at, status, metadata + ) + values ( + $1, $2, 'svip', 'tenant', null, + 'launch_persona_smoke', 'launch-persona-smoke', now() - interval '1 minute', + now() + interval '1 day', 'active', + '{"source":"launch_persona_smoke","temporary":true}'::jsonb + ) + on conflict do nothing + `, + [ids.tenant, ids.student], + ); +} + +async function discoverStudentPath(pool) { + const entry = await one( + pool, + ` + select id, name + from public.content_entries + where tenant_id = $1 and is_active = true and entry_type = 'question_practice' + order by sort_order asc, created_at asc + limit 1 + `, + [ids.tenant], + ); + assert.ok(entry, 'launch smoke needs an active question_practice content entry'); + + const collection = await one( + pool, + ` + select qc.id, qc.name, qc.node_id as content_node_id + from public.question_collections qc + where qc.tenant_id = $1 + and qc.status = 'active' + and exists ( + select 1 + from public.question_collection_items qci + join public.questions q on q.tenant_id = qci.tenant_id and q.id = qci.question_id + where qci.tenant_id = qc.tenant_id + and qci.collection_id = qc.id + and q.status = 'published' + ) + order by qc.created_at asc + limit 1 + `, + [ids.tenant], + ); + assert.ok(collection, 'launch smoke needs an active question collection with questions'); + + const blueprint = await one( + pool, + ` + select id, name, mode + from public.practice_blueprints + where tenant_id = $1 and collection_id = $2 and status = 'active' + order by case mode when 'sequential' then 0 when 'random' then 1 else 2 end, created_at asc + limit 1 + `, + [ids.tenant, collection.id], + ); + assert.ok(blueprint, 'launch smoke needs an active practice blueprint'); + + return { entry, collection, blueprint }; +} + +async function studentJourney(pool) { + await ensureSvipEntitlement(pool); + const pathInfo = await discoverStudentPath(pool); + + const profile = await request('/api/profile/me'); + assert.equal(profile.item?.userId, ids.student, 'student profile should load current user'); + + const entitlements = await request('/api/commerce/entitlements'); + assert.equal(entitlements.summary?.isSvip, true, 'student should be SVIP before practice journey'); + + const entries = await request('/api/catalog/content-entries', { query: { entryType: 'question_practice' } }); + assert.ok(entries.items?.some(item => item.id === pathInfo.entry.id), 'student should see question practice entry'); + + const collections = await request('/api/catalog/question-collections', { query: { nodeId: pathInfo.collection.content_node_id } }); + assert.ok(collections.items?.some(item => item.id === pathInfo.collection.id), 'student should see question collection'); + + const session = await request('/api/learning/practice-sessions', { + method: 'POST', + body: { + blueprintId: pathInfo.blueprint.id, + collectionId: pathInfo.collection.id, + questionLimit: 3, + metadata: { source: 'launch_persona_smoke' }, + }, + }); + assert.ok(session.item?.id, 'student should create practice session'); + assert.notEqual(session.item?.accessMode, 'free', 'SVIP student practice should not be constrained by free quota'); + + const detail = await request('/api/learning/practice-sessions/detail', { + query: { practiceSessionId: session.item.id }, + }); + const questions = detail.item?.questions || []; + assert.ok(questions.length > 0, 'practice detail should include questions'); + + const firstQuestion = questions[0]; + await request('/api/learning/favorites/questions', { + method: 'POST', + body: { questionId: firstQuestion.id, favorite: true }, + }); + + await request('/api/learning/answers', { + method: 'POST', + body: { + practiceSessionId: session.item.id, + questionId: firstQuestion.id, + ...pickAnswer(firstQuestion), + }, + }); + + const report = await request('/api/learning/practice-sessions/submit', { + method: 'POST', + body: { practiceSessionId: session.item.id }, + }); + assert.equal(report.item?.practiceSessionId, session.item.id, 'student should submit practice and receive report'); + + const favorites = await request('/api/learning/favorites/questions', { query: { limit: 20 } }); + assert.ok(favorites.items?.some(item => item.id === firstQuestion.id || item.questionId === firstQuestion.id), 'favorite list should include toggled question'); + + const wrong = await request('/api/learning/wrong-questions', { query: { limit: 20 } }); + const wrongPlan = await request('/api/learning/wrong-questions/review-plan'); + const favoriteReview = await request('/api/learning/practice-sessions', { + method: 'POST', + body: { mode: 'favorite_review', questionLimit: 3, metadata: { source: 'launch_persona_smoke' } }, + }); + assert.equal(favoriteReview.item?.mode, 'favorite_review', 'student should create favorite review session'); + + const wrongReview = + wrong.items?.length > 0 + ? await request('/api/learning/practice-sessions', { + method: 'POST', + body: { mode: 'wrong_review', questionLimit: 3, metadata: { source: 'launch_persona_smoke' } }, + }) + : null; + + return { + profile: { profileId: profile.item?.id, userId: profile.item?.userId, avatarPreset: profile.item?.avatarPreset || null }, + entitlement: { isSvip: entitlements.summary?.isSvip, count: entitlements.items?.length || 0 }, + practice: { + sessionId: session.item.id, + accessMode: session.item.accessMode, + questionCount: questions.length, + reportId: report.item?.id, + correctCount: report.item?.correctCount, + wrongCount: report.item?.wrongCount, + }, + favorite: { questionId: firstQuestion.id, favoriteCount: favorites.items?.length || 0, reviewSessionId: favoriteReview.item?.id }, + wrongReview: { + wrongCount: wrong.items?.length || 0, + reviewPlanCount: wrongPlan.items?.length || 0, + reviewSessionId: wrongReview?.item?.id || null, + skippedReason: wrong.items?.length > 0 ? null : 'No wrong questions were present for this student after the sampled answer.', + }, + }; +} + +async function tenantAdminJourney() { + const overview = await request('/api/tenant-admin/overview', { userId: ids.tenantAdmin }); + const dashboard = await request('/api/tenant-admin/dashboard', { + userId: ids.tenantAdmin, + query: { timeRange: '7d', regionId: ids.region }, + }); + const theme = await request('/api/tenant-admin/theme', { userId: ids.tenantAdmin }); + const students = await request('/api/tenant-admin/students', { + userId: ids.tenantAdmin, + query: { limit: 10 }, + }); + const conversion = await request('/api/referral/conversion-report', { + userId: ids.tenantAdmin, + query: { limit: 10 }, + }); + const studentDenied = await request('/api/tenant-admin/dashboard', { expectStatus: 403 }); + const crossTenantDenied = await request('/api/tenant-admin/dashboard', { + tenantId: ids.partnerTenant, + userId: ids.tenantAdmin, + expectStatus: 403, + }); + + return { + overview: { tenantId: overview.item?.id, name: overview.item?.name }, + dashboard: { + tenantId: dashboard.item?.scope?.tenantId, + trendBuckets: dashboard.item?.trends?.length || 0, + activeHourBuckets: dashboard.item?.activeHours?.length || 0, + questionCount: dashboard.item?.cards?.content?.questions || 0, + }, + theme: { status: theme.item?.status || null, activeTemplateCode: theme.item?.activeTemplateCode || null }, + students: { count: students.items?.length || 0 }, + referralConversion: { + mode: conversion.item?.aggregation?.mode || 'realtime_or_legacy', + leadCount: conversion.item?.summary?.leadCount || 0, + paidSourceCount: conversion.item?.summary?.paidSourceCount || 0, + }, + guards: { + studentDashboardDenied: studentDenied.code, + crossTenantDenied: crossTenantDenied.code, + }, + }; +} + +async function platformAdminJourney() { + const headers = { 'x-platform-admin-key': process.env.PLATFORM_ADMIN_API_KEY || 'local-platform-admin-key' }; + const overview = await request('/api/platform-admin/overview', { + tenantId: false, + userId: false, + headers, + }); + const tenants = await request('/api/platform-admin/tenants', { + tenantId: false, + userId: false, + headers, + query: { limit: 10 }, + }); + const plans = await request('/api/platform-admin/plans', { + tenantId: false, + userId: false, + headers, + }); + const auditAlerts = await request('/api/platform-admin/audit-alerts', { + tenantId: false, + userId: false, + headers, + query: { status: 'open', limit: 10 }, + }); + const studentDenied = await request('/api/platform-admin/overview', { + tenantId: false, + expectStatus: 403, + }); + + return { + overview: { + tenantTotal: overview.item?.tenants?.total || 0, + activeTenantCount: overview.item?.tenants?.active || 0, + }, + tenants: { count: tenants.items?.length || 0 }, + plans: { count: plans.items?.length || 0 }, + auditAlerts: { openCount: auditAlerts.items?.length || 0 }, + guards: { studentPlatformDenied: studentDenied.code }, + }; +} + +function stepCell(step, formatPassed) { + if (step.status === 'pass') return formatPassed(step.result); + return `失败:${step.error?.message || 'unknown error'}`; +} + +async function writeReport(report) { + await fs.mkdir(outputDir, { recursive: true }); + const jsonPath = path.join(outputDir, `launch-persona-smoke-${shanghaiStamp()}.json`); + const mdPath = jsonPath.replace(/\.json$/, '.md'); + await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + const lines = [ + '# 上线前角色旅程烟测报告', + '', + `生成时间:${new Date(report.finishedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`, + '', + '| 角色 | 结果 | 关键验证 |', + '| --- | --- | --- |', + `| 普通学生 | ${report.student.status} | ${stepCell(report.student, result => `SVIP=${result.entitlement.isSvip}; session=${result.practice.sessionId}; favoriteReview=${result.favorite.reviewSessionId}; wrongReview=${result.wrongReview.reviewSessionId || result.wrongReview.skippedReason}`)} |`, + `| 租户管理员 | ${report.tenantAdmin.status} | ${stepCell(report.tenantAdmin, result => `dashboardBuckets=${result.dashboard.trendBuckets}; studentDenied=${result.guards.studentDashboardDenied}; crossTenant=${result.guards.crossTenantDenied}`)} |`, + `| 平台管理员 | ${report.platformAdmin.status} | ${stepCell(report.platformAdmin, result => `tenants=${result.overview.tenantTotal}; studentDenied=${result.guards.studentPlatformDenied}`)} |`, + '', + '说明:该脚本会写入少量 `launch_persona_smoke` 测试权益、练习 session、答题和收藏记录。生产环境仅建议在灰度/演练租户运行。', + '', + ]; + await fs.writeFile(mdPath, `${lines.join('\n')}\n`, 'utf8'); + return { jsonPath, mdPath }; +} + +async function runStep(name, fn) { + const startedAt = nowIso(); + try { + const result = await fn(); + return { name, status: 'pass', startedAt, finishedAt: nowIso(), result }; + } catch (error) { + return { + name, + status: 'fail', + startedAt, + finishedAt: nowIso(), + error: { + message: error?.message || String(error), + stack: error?.stack || '', + }, + }; + } +} + +async function main() { + const pool = new pg.Pool({ connectionString: databaseUrl, max: 4 }); + try { + await startServerIfNeeded(); + if (!apiBase) throw new Error('LAUNCH_SMOKE_API_BASE is required when LAUNCH_SMOKE_START_SERVER=false.'); + const report = { + startedAt: nowIso(), + finishedAt: '', + apiBase, + databaseUrl: databaseUrl.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:[redacted]@'), + student: await runStep('student', () => studentJourney(pool)), + tenantAdmin: await runStep('tenant-admin', tenantAdminJourney), + platformAdmin: await runStep('platform-admin', platformAdminJourney), + }; + report.finishedAt = nowIso(); + report.status = [report.student, report.tenantAdmin, report.platformAdmin].every(item => item.status === 'pass') ? 'pass' : 'fail'; + const files = await writeReport(report); + console.log(`[launch-smoke] status=${report.status}`); + console.log(`[launch-smoke] wrote ${files.jsonPath}`); + console.log(`[launch-smoke] wrote ${files.mdPath}`); + if (report.status !== 'pass') process.exitCode = 1; + } finally { + await pool.end(); + stopServer(); + } +} + +main().catch(error => { + console.error(error); + if (serverLogs) console.error(serverLogs); + stopServer(); + process.exit(1); +}); diff --git a/scripts/performance-summary-test.js b/scripts/performance-summary-test.js index e6f6754f..d26307c9 100644 --- a/scripts/performance-summary-test.js +++ b/scripts/performance-summary-test.js @@ -42,11 +42,11 @@ function sampleReport(overrides = {}) { }; } -function runScript(report) { +function runScript(report, args = []) { const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-perf-summary-')); const inputPath = path.join(tempDir, 'report.json'); fs.writeFileSync(inputPath, JSON.stringify(report, null, 2), 'utf8'); - const result = spawnSync(process.execPath, [scriptPath, '--input', inputPath, '--json'], { + const result = spawnSync(process.execPath, [scriptPath, '--input', inputPath, '--json', ...args], { cwd: repoRoot, encoding: 'utf8', }); @@ -108,4 +108,18 @@ assert.ok( 'write benchmark should explain includeWrites failure', ); +const allowedWriteRun = runScript(sampleReport({ config: { includeWrites: true } }), [ + '--allow-writes', + '--min-duration-seconds=60', + '--min-concurrency=30', + '--max-p95-ms=500', + '--max-p99-ms=1200', +]); +assert.equal(allowedWriteRun.status, 0, `allowed write report should exit 0: ${allowedWriteRun.stdout} ${allowedWriteRun.stderr}`); +assert.equal(allowedWriteRun.payload.evaluation?.status, 'pass'); +assert.equal(allowedWriteRun.payload.evaluation?.options?.allowWrites, true); +assert.equal(allowedWriteRun.payload.launchGateCheck, undefined); +assert.equal(allowedWriteRun.payload.capacityObservation?.id, 'performance.api-real-data-mixed'); +assert.equal(allowedWriteRun.payload.capacityObservation?.summary?.includeWrites, true); + console.log('[PASS] performance summary'); diff --git a/scripts/performance-summary.js b/scripts/performance-summary.js index aa9f4e7d..fb3b349b 100644 --- a/scripts/performance-summary.js +++ b/scripts/performance-summary.js @@ -18,12 +18,14 @@ function parseArgs(argv) { output: '', json: false, quiet: false, + allowWrites: false, thresholds: { ...defaultThresholds }, }; for (let index = 2; index < argv.length; index += 1) { const arg = argv[index]; if (arg === '--json') options.json = true; else if (arg === '--quiet') options.quiet = true; + else if (arg === '--allow-writes') options.allowWrites = true; else if (arg === '--input') { options.input = argv[index + 1] || ''; index += 1; @@ -105,7 +107,7 @@ function summarizeBenchmark(report) { }; } -function evaluateSummary(summary, thresholds = defaultThresholds) { +function evaluateSummary(summary, thresholds = defaultThresholds, options = {}) { const failures = []; if (summary.errors !== thresholds.errors) failures.push(`errors expected ${thresholds.errors} but got ${summary.errors}`); if (summary.errorRate > thresholds.errorRate) failures.push(`errorRate expected <= ${thresholds.errorRate} but got ${summary.errorRate}`); @@ -113,12 +115,15 @@ function evaluateSummary(summary, thresholds = defaultThresholds) { if (summary.p99Ms > thresholds.p99Ms) failures.push(`p99Ms expected <= ${thresholds.p99Ms} but got ${summary.p99Ms}`); if (summary.concurrency < thresholds.concurrency) failures.push(`concurrency expected >= ${thresholds.concurrency} but got ${summary.concurrency}`); if (summary.durationSeconds < thresholds.durationSeconds) failures.push(`durationSeconds expected >= ${thresholds.durationSeconds} but got ${summary.durationSeconds}`); - if (summary.includeWrites !== false) failures.push('includeWrites expected false'); + if (!options.allowWrites && summary.includeWrites !== false) failures.push('includeWrites expected false'); return { status: failures.length ? 'fail' : 'pass', failures, summary, thresholds, + options: { + allowWrites: Boolean(options.allowWrites), + }, }; } @@ -141,6 +146,27 @@ function launchGateCheck(evaluation) { }; } +function capacityObservation(evaluation) { + return { + id: evaluation.summary.includeWrites ? 'performance.api-real-data-mixed' : 'performance.api-real-data-read-observation', + status: evaluation.status, + command: 'npm run perf:api:local', + completedAt: new Date().toISOString(), + artifact: '', + summary: { + errors: evaluation.summary.errors, + errorRate: evaluation.summary.errorRate, + p95Ms: evaluation.summary.p95Ms, + p99Ms: evaluation.summary.p99Ms, + concurrency: evaluation.summary.concurrency, + durationSeconds: evaluation.summary.durationSeconds, + includeWrites: evaluation.summary.includeWrites, + requests: evaluation.summary.requests, + throughputRps: evaluation.summary.throughputRps, + }, + }; +} + async function main() { const options = parseArgs(process.argv); if (!options.input) { @@ -152,12 +178,14 @@ async function main() { const inputPath = path.resolve(process.cwd(), options.input); const report = readJson(inputPath); const summary = summarizeBenchmark(report); - const evaluation = evaluateSummary(summary, options.thresholds); + const evaluation = evaluateSummary(summary, options.thresholds, { allowWrites: options.allowWrites }); + const evidenceKey = options.allowWrites ? 'capacityObservation' : 'launchGateCheck'; + const evidenceValue = options.allowWrites ? capacityObservation(evaluation) : launchGateCheck(evaluation); const payload = { input: inputPath, evaluation, - launchGateCheck: { - ...launchGateCheck(evaluation), + [evidenceKey]: { + ...evidenceValue, artifact: options.input, }, }; @@ -182,4 +210,4 @@ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.me main(); } -export { defaultThresholds, evaluateSummary, launchGateCheck, summarizeBenchmark }; +export { capacityObservation, defaultThresholds, evaluateSummary, launchGateCheck, summarizeBenchmark }; diff --git a/scripts/postgres-tuning-evidence.js b/scripts/postgres-tuning-evidence.js new file mode 100644 index 00000000..7de5716c --- /dev/null +++ b/scripts/postgres-tuning-evidence.js @@ -0,0 +1,259 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import pg from 'pg'; + +const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; +const outputDir = process.env.PG_TUNING_OUTPUT_DIR || 'docs/refactor/launch-artifacts'; + +const settingNames = [ + 'max_connections', + 'shared_buffers', + 'effective_cache_size', + 'work_mem', + 'maintenance_work_mem', + 'autovacuum_work_mem', + 'wal_buffers', + 'min_wal_size', + 'max_wal_size', + 'checkpoint_timeout', + 'checkpoint_completion_target', + 'effective_io_concurrency', + 'random_page_cost', + 'jit', + 'log_min_duration_statement', + 'idle_in_transaction_session_timeout', + 'statement_timeout', + 'lock_timeout', +]; + +function shanghaiStamp(date = new Date()) { + const parts = Object.fromEntries( + new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + hour12: false, + }).formatToParts(date).map(part => [part.type, part.value]), + ); + return `${parts.year}${parts.month}${parts.day}-${parts.hour}${parts.minute}${parts.second}`; +} + +async function query(pool, sql, params = []) { + const result = await pool.query(sql, params); + return result.rows; +} + +async function maybeQuery(pool, sql, params = []) { + try { + return await query(pool, sql, params); + } catch (error) { + return { unavailable: true, message: error?.message || String(error) }; + } +} + +function redactUrl(value) { + return value.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:[redacted]@'); +} + +function explainSetting(setting) { + const value = `${setting.setting}${setting.unit || ''}`; + return { + name: setting.name, + value, + source: setting.source, + pendingRestart: setting.pending_restart, + }; +} + +function evaluate(settingsRows) { + const byName = new Map(settingsRows.map(row => [row.name, row])); + const warnings = []; + const maxConnections = Number(byName.get('max_connections')?.setting || 0); + const jit = String(byName.get('jit')?.setting || '').toLowerCase(); + const statementTimeout = String(byName.get('statement_timeout')?.setting || ''); + const idleTimeout = String(byName.get('idle_in_transaction_session_timeout')?.setting || ''); + const lockTimeout = String(byName.get('lock_timeout')?.setting || ''); + + if (maxConnections > 150) warnings.push('max_connections is high for a 4 vCPU database; prefer API/pooler limits over direct connections.'); + if (jit === 'on') warnings.push('jit is on; short OLTP-style question-bank API queries usually start safer with jit=off.'); + if (statementTimeout === '0') warnings.push('statement_timeout is disabled; production API should have a bounded global timeout and import jobs should override per session.'); + if (idleTimeout === '0') warnings.push('idle_in_transaction_session_timeout is disabled; long idle transactions can block migrations and writes.'); + if (lockTimeout === '0') warnings.push('lock_timeout is disabled; ordinary API requests may wait too long behind locks.'); + if (settingsRows.some(row => row.pending_restart)) warnings.push('Some PostgreSQL settings have pending_restart=true; restart is required before capacity testing.'); + + return { + status: warnings.length ? 'warn' : 'pass', + warnings, + }; +} + +function markdown(report) { + const lines = [ + '# PostgreSQL 调参与运行证据', + '', + `生成时间:${new Date(report.generatedAt).toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' })}`, + '', + `数据库:${report.databaseUrl}`, + '', + `评估:${report.evaluation.status}`, + '', + ]; + if (report.evaluation.warnings.length) { + lines.push('## 警告'); + lines.push(''); + for (const warning of report.evaluation.warnings) lines.push(`- ${warning}`); + lines.push(''); + } + lines.push('## 关键参数'); + lines.push(''); + lines.push('| 参数 | 当前值 | 来源 | 待重启 |'); + lines.push('| --- | ---: | --- | --- |'); + for (const item of report.settings) { + lines.push(`| ${item.name} | ${item.value} | ${item.source} | ${item.pendingRestart ? '是' : '否'} |`); + } + lines.push(''); + lines.push('## 连接与等待'); + lines.push(''); + lines.push('| state | wait_event_type | wait_event | count |'); + lines.push('| --- | --- | --- | ---: |'); + for (const item of report.activityByWait) { + lines.push(`| ${item.state || '-'} | ${item.wait_event_type || '-'} | ${item.wait_event || '-'} | ${item.count} |`); + } + lines.push(''); + lines.push('## 缓存与事务'); + lines.push(''); + lines.push('| datname | commits | rollbacks | cache_hit_ratio |'); + lines.push('| --- | ---: | ---: | ---: |'); + for (const item of report.databaseStats) { + lines.push(`| ${item.datname} | ${item.xact_commit} | ${item.xact_rollback} | ${item.cache_hit_ratio ?? '-'} |`); + } + lines.push(''); + lines.push('## 大表规模'); + lines.push(''); + lines.push('| 表 | 估算行数 | 总大小 | 索引大小 |'); + lines.push('| --- | ---: | ---: | ---: |'); + for (const item of report.largeRelations) { + lines.push(`| ${item.relation} | ${item.estimated_rows} | ${item.total_size} | ${item.index_size} |`); + } + lines.push(''); + if (Array.isArray(report.pgStatStatements)) { + lines.push('## pg_stat_statements Top SQL'); + lines.push(''); + lines.push('| calls | total_exec_ms | mean_exec_ms | rows | query |'); + lines.push('| ---: | ---: | ---: | ---: | --- |'); + for (const item of report.pgStatStatements) { + lines.push(`| ${item.calls} | ${item.total_exec_ms} | ${item.mean_exec_ms} | ${item.rows} | ${String(item.query || '').replaceAll('|', '\\|')} |`); + } + lines.push(''); + } else { + lines.push('## pg_stat_statements'); + lines.push(''); + lines.push(`未采集:${report.pgStatStatements?.message || 'extension/view unavailable'}`); + lines.push(''); + } + return `${lines.join('\n')}\n`; +} + +async function main() { + const pool = new pg.Pool({ connectionString: databaseUrl, max: 2 }); + try { + const settingsRaw = await query( + pool, + ` + select name, setting, unit, source, pending_restart + from pg_settings + where name = any($1::text[]) + order by array_position($1::text[], name) + `, + [settingNames], + ); + const activityByWait = await query( + pool, + ` + select state, wait_event_type, wait_event, count(*)::int as count + from pg_stat_activity + where datname = current_database() + group by state, wait_event_type, wait_event + order by count desc, state nulls last + `, + ); + const databaseStats = await query( + pool, + ` + select datname, + xact_commit::text, + xact_rollback::text, + blks_read::text, + blks_hit::text, + round(blks_hit * 100.0 / nullif(blks_hit + blks_read, 0), 2)::text as cache_hit_ratio + from pg_stat_database + where datname = current_database() + `, + ); + const bgwriter = await maybeQuery( + pool, + ` + select checkpoints_timed::text, checkpoints_req::text, + checkpoint_write_time::text, checkpoint_sync_time::text + from pg_stat_bgwriter + `, + ); + const largeRelations = await query( + pool, + ` + select relid::regclass::text as relation, + n_live_tup::bigint::text as estimated_rows, + pg_size_pretty(pg_total_relation_size(relid)) as total_size, + pg_size_pretty(pg_indexes_size(relid)) as index_size + from pg_stat_user_tables + where schemaname = 'public' + order by pg_total_relation_size(relid) desc + limit 20 + `, + ); + const pgStatStatements = await maybeQuery( + pool, + ` + select calls::bigint::text, + round(total_exec_time::numeric, 2)::text as total_exec_ms, + round(mean_exec_time::numeric, 2)::text as mean_exec_ms, + rows::bigint::text, + left(regexp_replace(query, '\\s+', ' ', 'g'), 180) as query + from pg_stat_statements + order by total_exec_time desc + limit 10 + `, + ); + const report = { + generatedAt: new Date().toISOString(), + databaseUrl: redactUrl(databaseUrl), + evaluation: evaluate(settingsRaw), + settings: settingsRaw.map(explainSetting), + activityByWait, + databaseStats, + bgwriter, + largeRelations, + pgStatStatements, + }; + await fs.mkdir(outputDir, { recursive: true }); + const jsonPath = path.join(outputDir, `postgres-tuning-evidence-${shanghaiStamp()}.json`); + const mdPath = jsonPath.replace(/\.json$/, '.md'); + await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); + await fs.writeFile(mdPath, markdown(report), 'utf8'); + console.log(`[pg-evidence] status=${report.evaluation.status}`); + for (const warning of report.evaluation.warnings) console.log(`[pg-evidence] warning: ${warning}`); + console.log(`[pg-evidence] wrote ${jsonPath}`); + console.log(`[pg-evidence] wrote ${mdPath}`); + } finally { + await pool.end(); + } +} + +main().catch(error => { + console.error(error); + process.exit(1); +});