chore: gate production postgres tuning and taro handoff

This commit is contained in:
Codex
2026-07-01 04:27:19 +08:00
parent 6bdb2a175a
commit 69b4d3b62d
14 changed files with 702 additions and 44 deletions

View File

@@ -70,6 +70,7 @@
- `docs/refactor/legacy-feature-gap-matrix.md` - `docs/refactor/legacy-feature-gap-matrix.md`
- `docs/refactor/supabase-frontend-access-strategy.md` - `docs/refactor/supabase-frontend-access-strategy.md`
- `docs/refactor/taro-frontend-integration.md` - `docs/refactor/taro-frontend-integration.md`
- `docs/refactor/taro-production-integration-checklist.md`
- `docs/refactor/multitenant-auth-security-contract.md` - `docs/refactor/multitenant-auth-security-contract.md`
- `docs/refactor/next-development-todo.md` - `docs/refactor/next-development-todo.md`
- `docs/refactor/blueprint-coverage.md` - `docs/refactor/blueprint-coverage.md`
@@ -460,7 +461,15 @@ cp docs/refactor/production-launch-evidence.template.json docs/refactor/producti
npm run launch:gate -- --evidence docs/refactor/production-launch-evidence.json 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 构建、`smoke:taro:h5``smoke:taro:h5:interaction`、严格 `taro-h5-release-guardrails-test --require-runtime-config``audit:runtime`、真实 `@codex-security` 扫描,以及备份、回滚、真实数据抽样、生产 provider、对象存储控制、支付对账和三套 H5 `runtime-config.json` 人工确认。 `production-launch-evidence.json` 不入 Git里面只记录验收摘要、artifact 路径、审批人和时间,不保存真实 access token、支付密钥、对象存储密钥或用户隐私明细。门禁会要求以下证据全部齐备并通过
- `readiness:production``readiness:production:db`、严格 `perf:postgres:evidence -- --strict`
- 真实 `smoke:auth:remote``test:rls`
- PocketBase production dry-run、`pb:import:validate``pb:import:sample`
- 真实数据 API 读路径压测、API/worker/Taro 构建。
- `smoke:taro:h5``smoke:taro:h5:interaction`、严格 `taro-h5-release-guardrails-test --require-runtime-config`
- `audit:runtime`、真实 `@codex-security` 扫描。
- 备份、回滚、真实数据抽样、生产 provider、对象存储控制、支付对账和三套 H5 `runtime-config.json` 人工确认。
补充说明:当前 Codex 环境如果没有暴露 `@codex-security` 可调用工具,不能把插件扫描写成已完成;只能先用 `npm run audit:runtime``npm run test:readiness``npm run test:rls` 和代码审查作为临时安全证据,并在上线证据里保留插件扫描待补项。 补充说明:当前 Codex 环境如果没有暴露 `@codex-security` 可调用工具,不能把插件扫描写成已完成;只能先用 `npm run audit:runtime``npm run test:readiness``npm run test:rls` 和代码审查作为临时安全证据,并在上线证据里保留插件扫描待补项。
@@ -573,6 +582,20 @@ $env:DATABASE_URL="postgresql://postgres:postgres@127.0.0.1:54322/postgres"
npm run perf:postgres:evidence npm run perf:postgres:evidence
``` ```
生产上线前必须用严格模式生成门禁摘要:
```powershell
$env:PG_TUNING_PROFILE="shared-host"
npm run perf:postgres:evidence -- --strict --json
Remove-Item Env:\PG_TUNING_PROFILE
```
严格模式会检查 4 核 16G profile、`pending_restart=0``pg_stat_statements` 可用、`jit=off`,以及 API 请求相关超时不为 0。需要生成可人工复核的 `ALTER SYSTEM` SQL 时运行:
```powershell
npm run perf:postgres:sql -- --profile=shared-host
```
上线前角色旅程烟测: 上线前角色旅程烟测:
```powershell ```powershell

View File

@@ -20,11 +20,13 @@
- Taro 启动、租户解析、请求封装、页面/API 映射、跨端注意事项。 - Taro 启动、租户解析、请求封装、页面/API 映射、跨端注意事项。
7. `docs/refactor/taro-h5-deployment.md` 7. `docs/refactor/taro-h5-deployment.md`
- H5 三域名部署、`runtime-config.json`、Nginx history fallback、缓存、CSP 和 CORS 边界。 - H5 三域名部署、`runtime-config.json`、Nginx history fallback、缓存、CSP 和 CORS 边界。
8. `docs/refactor/multitenant-auth-security-contract.md` 8. `docs/refactor/taro-production-integration-checklist.md`
- 正式接 Supabase Auth、三套 H5、真实 provider、runtime-config 和上线门禁时逐项对照。
9. `docs/refactor/multitenant-auth-security-contract.md`
- 多租户、鉴权、权限、资源签名和生产安全红线。 - 多租户、鉴权、权限、资源签名和生产安全红线。
9. `docs/refactor/content-import-contract.md` 10. `docs/refactor/content-import-contract.md`
- 后台内容导入、题目 JSON、单词、知识手册、分数线、视频的后端校验契约。 - 后台内容导入、题目 JSON、单词、知识手册、分数线、视频的后端校验契约。
10. `docs/refactor/production-launch-evidence.template.json` 11. `docs/refactor/production-launch-evidence.template.json`
- 上线前证据文件模板;真实生产验收结果填入 `production-launch-evidence.json` 后运行 `npm run launch:gate`,该真实证据文件不入 Git。 - 上线前证据文件模板;真实生产验收结果填入 `production-launch-evidence.json` 后运行 `npm run launch:gate`,该真实证据文件不入 Git。
## 当前可进入的前端工作 ## 当前可进入的前端工作

View File

@@ -91,11 +91,11 @@
- 生产 `.env` 模板和 `npm run readiness:production` / `npm run readiness:production:db` 已补,后续上云必须作为验收 gate。 - 生产 `.env` 模板和 `npm run readiness:production` / `npm run readiness:production:db` 已补,后续上云必须作为验收 gate。
- Auth/JWKS 上云后必须临时设置 `AUTH_SMOKE_*` 环境变量并运行 `npm run smoke:auth:remote`,真实 access token 不得写入仓库、前端配置或日志。 - Auth/JWKS 上云后必须临时设置 `AUTH_SMOKE_*` 环境变量并运行 `npm run smoke:auth:remote`,真实 access token 不得写入仓库、前端配置或日志。
- 本地/预生产必须同时跑 `npm run test:rls`,它验证运行时 JWT claim 下的租户隔离,和 `readiness:production:db` 的静态 policy 检查互补。 - 本地/预生产必须同时跑 `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` 并通过门禁。当前 Codex 环境未暴露可调用的 `@codex-security` 扫描工具时,该项只能标为待补,不能伪造完成。 - 已补 `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、运行时审计、`@codex-security`、备份/回滚/真实抽样/生产 provider 等证据填入本地 `production-launch-evidence.json` 并通过门禁。当前 Codex 环境未暴露可调用的 `@codex-security` 扫描工具时,该项只能标为待补,不能伪造完成。
- 确认数据库迁移流程、备份恢复、日志、告警。 - 确认数据库迁移流程、备份恢复、日志、告警。
- 准备 API 容器部署和 Supabase 云端/自托管连接方案。 - 准备 API 容器部署和 Supabase 云端/自托管连接方案。
- 已补 `npm run perf:api:local``npm run perf:summary``npm run perf:postgres:evidence``npm run smoke:launch-persona``npm run smoke:taro:h5``npm run smoke:taro:h5:interaction``node scripts/taro-api-contract-test.js``node scripts/taro-persona-contract-test.js``docs/refactor/performance-benchmark-runbook.md`可在本地或云端对真实迁移数据做只读门禁、混合读写容量观察、PostgreSQL 调参证据、三类后端角色旅程烟测、三类 Taro 前端角色旅程契约、H5 发布目录启动烟测、真实浏览器关键点击烟测和前端 API 契约检查。2026-07-01 03:58 受限 API 容器真实迁移库复核中30 worker/120s 只读为 44,038 请求、0 错误、366.27 req/s、P95 185.80ms、P99 254.08ms50 worker/60s/10% 写入为 25,327 请求、0 错误、419.62 req/s、P95 213.17ms、P99 270.94ms100 worker/60s/8% 写入为 25,378 请求、0 错误、417.77 req/s、P95 383.72ms、P99 461.22ms150 worker/60s/6% 写入为 23,444 请求、0 错误、384.39 req/s、P95 605.89ms、P99 743.19ms。压测先抓到自动勋章并发发放唯一键冲突,已修复并新增 `scripts/auto-badge-concurrency-test.js`。当前 Docker Desktop 给了 20 CPU/约 62.7GB 内存,但 API 容器限制为 2 CPU/4G舒适观察区暂按 50 到 100 个无停顿 worker 估算150 worker 已是压力区;按单学生 0.05 到 0.2 req/s 粗略折算约为 2,000 到 8,400 名活跃在线学生的本机吞吐观察区间,正式容量仍以上云 4 核 16G 复测为准。 - 已补 `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``docs/refactor/performance-benchmark-runbook.md`可在本地或云端对真实迁移数据做只读门禁、混合读写容量观察、PostgreSQL 4c16g profile 调参证据、三类后端角色旅程烟测、三类 Taro 前端角色旅程契约、H5 发布目录启动烟测、真实浏览器关键点击烟测和前端 API 契约检查。2026-07-01 03:58 受限 API 容器真实迁移库复核中30 worker/120s 只读为 44,038 请求、0 错误、366.27 req/s、P95 185.80ms、P99 254.08ms50 worker/60s/10% 写入为 25,327 请求、0 错误、419.62 req/s、P95 213.17ms、P99 270.94ms100 worker/60s/8% 写入为 25,378 请求、0 错误、417.77 req/s、P95 383.72ms、P99 461.22ms150 worker/60s/6% 写入为 23,444 请求、0 错误、384.39 req/s、P95 605.89ms、P99 743.19ms。压测先抓到自动勋章并发发放唯一键冲突,已修复并新增 `scripts/auto-badge-concurrency-test.js`。当前 Docker Desktop 给了 20 CPU/约 62.7GB 内存,但 API 容器限制为 2 CPU/4G舒适观察区暂按 50 到 100 个无停顿 worker 估算150 worker 已是压力区;按单学生 0.05 到 0.2 req/s 粗略折算约为 2,000 到 8,400 名活跃在线学生的本机吞吐观察区间,正式容量仍以上云 4 核 16G 复测为准。
- 当前本地 PostgreSQL evidence 仍提示 `jit=on``statement_timeout=0``idle_in_transaction_session_timeout=0``lock_timeout=0`;上云后必须按 `docs/refactor/postgresql-4c16g-tuning.md` 调整参数并复跑 evidence。4 核 16G 正式容量报告需上云后按 6/30/50/100 阶梯并发复跑并归档到本地上线证据。 - 当前本地 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`。后续不要再把学生头像上传或默认排行榜当作待办;头像只保留男女预设,排行榜仅作为租户显式开启后的活动能力。 - 本轮剩余功能和容量复核已经整理到 `docs/refactor/backend-open-items-and-capacity-20260701.md`。后续不要再把学生头像上传或默认排行榜当作待办;头像只保留男女预设,排行榜仅作为租户显式开启后的活动能力。
### P1 商用功能完善 ### P1 商用功能完善

View File

@@ -37,6 +37,18 @@ npm run perf:postgres:evidence
该脚本会把关键 `pg_settings`、连接等待、缓存命中、大表大小和可选 `pg_stat_statements` Top SQL 输出到 `docs/refactor/launch-artifacts/`。调参前后各跑一次,配合 API 压测报告判断是否真正改善。 该脚本会把关键 `pg_settings`、连接等待、缓存命中、大表大小和可选 `pg_stat_statements` Top SQL 输出到 `docs/refactor/launch-artifacts/`。调参前后各跑一次,配合 API 压测报告判断是否真正改善。
生产上线前必须用严格模式跑一次,并把摘要填入 `production-launch-evidence.json``postgres.tuning-evidence`
```bash
PG_TUNING_PROFILE=shared-host npm run perf:postgres:evidence -- --strict --json
```
严格模式要求 4c16g profile 范围内、无 `pending_restart``pg_stat_statements` 可用、`jit=off`,且 API 请求相关超时不为 0。需要生成 `ALTER SYSTEM` SQL 时使用:
```bash
npm run perf:postgres:sql -- --profile=shared-host
```
角色旅程烟测: 角色旅程烟测:
```bash ```bash
@@ -135,7 +147,7 @@ npm run perf:api:local
## 4 核 16G 阶梯压测建议 ## 4 核 16G 阶梯压测建议
在云服务器上先按 `docs/refactor/postgresql-4c16g-tuning.md` 配置 shared-host 起步值,再跑以下矩阵。每轮之间间隔 2 到 5 分钟,观察 CPU、内存、磁盘 I/O、连接数和慢 SQL。 在云服务器上先按 `docs/refactor/postgresql-4c16g-tuning.md` 配置 shared-host 起步值,执行 `perf:postgres:evidence -- --strict` 通过,再跑以下矩阵。每轮之间间隔 2 到 5 分钟,观察 CPU、内存、磁盘 I/O、连接数和慢 SQL。
| 场景 | 并发 | 时长 | 写入 | 用途 | | 场景 | 并发 | 时长 | 写入 | 用途 |
| --- | ---: | ---: | --- | --- | | --- | ---: | ---: | --- | --- |

View File

@@ -1,6 +1,6 @@
# PostgreSQL 4 核 16G 生产调参基线 # PostgreSQL 4 核 16G 生产调参基线
更新时间2026-06-30 更新时间2026-07-01
这份文档用于后续把 Supabase/PostgreSQL 自托管到 4 核 16G 云服务器时做生产起步配置。目标是先给题库 SaaS 一个安全、可回滚、可观测的基线,而不是追求一次性压满硬件。 这份文档用于后续把 Supabase/PostgreSQL 自托管到 4 核 16G 云服务器时做生产起步配置。目标是先给题库 SaaS 一个安全、可回滚、可观测的基线,而不是追求一次性压满硬件。
@@ -12,7 +12,7 @@
- PostgreSQL 官方 WAL/Checkpointhttps://www.postgresql.org/docs/current/runtime-config-wal.html - PostgreSQL 官方 WAL/Checkpointhttps://www.postgresql.org/docs/current/runtime-config-wal.html
- PostgreSQL 官方 Connectionshttps://www.postgresql.org/docs/current/runtime-config-connection.html - PostgreSQL 官方 Connectionshttps://www.postgresql.org/docs/current/runtime-config-connection.html
说明postgresqlco.nf 的页面可作为参数分类和调参入口参考;具体参数语义、重启要求和风险以 PostgreSQL 官方文档为准。 说明postgresqlco.nf 的页面可作为参数分类和调参入口参考;具体参数语义、重启要求和风险以 PostgreSQL 官方文档为准。当前 Codex 环境访问 `https://postgresqlco.nf/tuning-guide` 会返回 403因此落地值不直接抓取该站页面而是把它作为导航来源并以 PostgreSQL 官方文档和本项目真实压测结果共同校验。
## 适用前提 ## 适用前提
@@ -65,6 +65,26 @@ npm run perf:postgres:evidence
上云后建议顺序是:先采集一次默认值,应用本文件 shared-host 参数并重启需要重启的项,再采集一次,然后跑 API 阶梯压测。调参证据和压测报告一起进入本地 `production-launch-evidence.json`,不要提交真实证据文件。 上云后建议顺序是:先采集一次默认值,应用本文件 shared-host 参数并重启需要重启的项,再采集一次,然后跑 API 阶梯压测。调参证据和压测报告一起进入本地 `production-launch-evidence.json`,不要提交真实证据文件。
生产上线门禁使用严格模式:
```bash
PG_TUNING_PROFILE=shared-host npm run perf:postgres:evidence -- --strict --json
```
严格模式会按 `scripts/lib/postgres-tuning-profile.js` 检查 4 核 16G profile
- `jit=off`
- `statement_timeout``idle_in_transaction_session_timeout``lock_timeout` 不得为 0。
- 所有 profile 参数必须在允许范围内。
- `pending_restart` 必须为 0。
- `pg_stat_statements` 必须可用。
如果 PostgreSQL 是独立数据库主机,可改用:
```bash
PG_TUNING_PROFILE=dedicated-db npm run perf:postgres:evidence -- --strict --json
```
## 应用连接池边界 ## 应用连接池边界
4 核机器的关键不是把 `max_connections` 拉大,而是控制同时活跃 SQL 的数量。 4 核机器的关键不是把 `max_connections` 拉大,而是控制同时活跃 SQL 的数量。
@@ -107,7 +127,13 @@ where name in (
order by name; order by name;
``` ```
同机部署推荐先执行: 同机部署推荐先生成 SQL 后人工复核再执行:
```bash
npm run perf:postgres:sql -- --profile=shared-host
```
等价的 shared-host 起步 SQL 如下:
```sql ```sql
alter system set max_connections = '80'; alter system set max_connections = '80';

View File

@@ -29,6 +29,20 @@
"blocker": 0 "blocker": 0
} }
}, },
{
"id": "postgres.tuning-evidence",
"status": "pass",
"command": "PG_TUNING_PROFILE=shared-host npm run perf:postgres:evidence -- --strict --json > docs/refactor/launch-artifacts/postgres-tuning-evidence.json",
"completedAt": "2026-06-30T10:08:00+08:00",
"artifact": "launch-artifacts/postgres-tuning-evidence.json",
"summary": {
"status": "pass",
"profile": "shared-host",
"failures": 0,
"pgStatStatementsAvailable": true,
"pendingRestart": 0
}
},
{ {
"id": "auth.remote-smoke", "id": "auth.remote-smoke",
"status": "pass", "status": "pass",

View File

@@ -0,0 +1,108 @@
# Taro 生产接入检查清单
更新时间2026-07-01
这份清单给前端同事和后续 AI 使用。目标是让 `apps/taro` 的 H5 学生端、租户后台、平台后台按当前 Supabase/PostgreSQL 新后端上线,后续再扩展微信小程序。旧小程序前端文件在 `F:\project\参考\旧题库小程序前端文件`,只作为视觉、交互状态和微信平台能力参考,不继承旧 PocketBase 直连、旧 token、旧安全假设。
## 总原则
- Taro 负责 UI、路由、交互、公开 runtime config 和 Supabase Auth session。
- 复杂业务默认调用 `apps/api`,不要让页面直接写 Supabase 业务表。
- Supabase client 可用于 Auth session、可选 Realtime、公开只读 view/table 或经过 RLS/RPC 评审的低风险功能。
- 支付、短信、微信/QQ OAuth、订单、权益、激活码、优惠券、CRM、AI、导入、私有对象存储签名必须走 `apps/api`、Edge Function 或 worker。
- 页面代码不得覆盖 `Authorization``x-tenant-id`,不得发送 `x-user-id`
- 前端只做可见性优化,权限最终以后端/RLS/RPC 校验为准。
## 三套 H5 发布目录
建议三套 H5 分域部署,不共用 web root
| 门户 | 推荐域名 | 构建命令 | runtime-config |
| --- | --- | --- | --- |
| 学生端 | `https://www.example.com` 或租户自定义域名 | `npm run build:taro:h5:student` | `apps/taro/deploy/h5-student.runtime-config.example.json` |
| 租户后台 | `https://admin.example.com` | `npm run build:taro:h5:tenant` | `apps/taro/deploy/h5-tenant-admin.runtime-config.example.json` |
| 平台后台 | `https://console.example.com` | `npm run build:taro:h5:platform` | `apps/taro/deploy/h5-platform-admin.runtime-config.example.json` |
每个发布目录根部必须放置独立的 `runtime-config.json`,只允许公开字段:
```json
{
"portal": "student",
"apiBaseUrl": "https://api.example.com",
"supabaseUrl": "https://supabase.example.com",
"supabasePublishableKey": "sb_publishable_xxx",
"tenantCode": "optional-tenant-slug"
}
```
禁止出现在 `runtime-config.json`、Taro 环境变量、源码和构建产物中的内容:
- Supabase service role / secret key。
- 数据库连接串。
- 短信、OAuth、支付、对象存储、CRM、AI 的 secret/private key。
- 真实用户 token、测试 access token、平台本地管理 key。
## 生产接入步骤
1. 配好三套 H5 的 `runtime-config.json`,确认 `apiBaseUrl``supabaseUrl` 都是 HTTPS。
2. H5 登录优先使用 Supabase Auth access token迁移期 `tk_` session 只用于本地或内网联调。
3. 学生端先走完整路径:解析租户、登录、选择地区、进入题库、创建练习、答题、收藏、交卷、查看报告、错题/收藏复习、背单词、知识手册、资料预览/下载、视频授权、会员下单、订单轮询。
4. 租户后台先走完整路径:权限加载、学生运营、题库导入/任务/问题行、公共题库采纳、营销中心、财务运营、主题发布、角色模板和成员绑定。
5. 平台后台先走完整路径:平台权限加载、创建租户、租户详情、账务资料、订阅/账单/用量、公共题库授权、平台员工、审计和告警。
6. 所有真实 provider 密钥只配置在后端 `.env``app_private.tenant_secrets``app_private.platform_secrets` 或生产 KMS/Vault不进入 Taro。
7. 对象存储私有资源必须通过 `content_assets` 台账和后端短签名;前端只展示签名 URL、过期时间、`watermark.traceId` 和水印容器。
8. 支付页面只展示后端返回的支付参数、订单状态和权益结果;金额、套餐、优惠、权益最终以后端返回为准。
## 必跑检查
页面、API service、路由、runtime config 或发布目录有任何变化时,至少运行:
```bash
npm run check:taro
npm run test:readiness
npm run smoke:taro:h5
npm run smoke:taro:h5:interaction
node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config
```
后端、RLS、provider、对象存储或生产配置有变化时还要运行
```bash
npm run readiness:production
npm run readiness:production:db
npm run smoke:auth:remote
npm run test:rls
npm run audit:runtime
```
正式上线前,三套 H5 严格发布证据、真实 Auth/RLS、真实 provider 抽样、对象存储控制、支付对账、PostgreSQL 严格调参证据和真实数据压测都要写入本地 `docs/refactor/production-launch-evidence.json`,再运行:
```bash
npm run launch:gate -- --evidence docs/refactor/production-launch-evidence.json
```
## 小程序后续兼容重点
小程序不能直接照搬 H5 假设,进入真机前要单独验收:
- `@supabase/supabase-js` 的 fetch、storage、URL、token refresh 兼容性。
- 如果兼容成本高,小程序登录只调用 `apps/api/auth/*`,由后端换取可信 session。
- 微信支付容器、订阅消息、分享参数、小程序码 tenant/referral 场景。
- KaTeX/公式渲染替代方案、题图资源字段化、PDF 预览能力和下载限制。
- 网络错误、弱网续练、本地缓存恢复、切后台/回前台 token 刷新。
## 旧前端参考边界
可以参考旧项目:
- 学生端刷题流程、答题卡、题型展示、背单词卡片、知识手册阅读、个人中心视觉。
- 微信小程序分享、支付、授权和登录交互经验。
- 租户后台/运营后台字段含义和常用工作流。
不能继承旧项目:
- PocketBase SDK 直连和旧 collection 命名。
- 前端保存或拼接用户 id、租户 id 来绕过后端鉴权。
- 前端直接写订单、权益、学习记录、错题、收藏、导入任务。
- 旧头像上传/第三方头像同步;学生头像只保留男女预设。
- 默认排行榜请求;排行榜只作为租户显式开启后的活动能力。

View File

@@ -124,8 +124,8 @@ STORAGE_REQUIRE_TENANT_PREFIX=true
1. `npm run perf:postgres:evidence` 采集默认 PostgreSQL 参数。 1. `npm run perf:postgres:evidence` 采集默认 PostgreSQL 参数。
2.`docs/refactor/postgresql-4c16g-tuning.md` 应用 shared-host 起步值。 2.`docs/refactor/postgresql-4c16g-tuning.md` 应用 shared-host 起步值。
3. 重启 PostgreSQL 后再次 `npm run perf:postgres:evidence` 3. 重启 PostgreSQL 后执行 `PG_TUNING_PROFILE=shared-host npm run perf:postgres:evidence -- --strict --json`,确认 profile 达标、`pending_restart=0``pg_stat_statements` 可用
4.`npm run perf:api:local` 的 6/30/50/100 阶梯,只读和少量写入各一组。 4.`npm run perf:api:local` 的 6/30/50/100 阶梯,只读和少量写入各一组。
5. 把摘要写入本地 `production-launch-evidence.json`,执行 `npm run launch:gate` 5. `postgres.tuning-evidence` 和 API 压测摘要写入本地 `production-launch-evidence.json`,执行 `npm run launch:gate`
容量折算不要直接把压测 worker 当在线人数。真实学生有读题和思考时间,应结合 H5 埋点估算单人平均 RPS再按成功 RPS 折算在线容量。 容量折算不要直接把压测 worker 当在线人数。真实学生有读题和思考时间,应结合 H5 埋点估算单人平均 RPS再按成功 RPS 折算在线容量。

View File

@@ -41,6 +41,7 @@
"perf:api:local": "npm run build:api && node scripts/api-performance-benchmark.js", "perf:api:local": "npm run build:api && node scripts/api-performance-benchmark.js",
"perf:api:docker-4c16g": "node scripts/run-docker-4c16g-benchmark.js", "perf:api:docker-4c16g": "node scripts/run-docker-4c16g-benchmark.js",
"perf:postgres:evidence": "node scripts/postgres-tuning-evidence.js", "perf:postgres:evidence": "node scripts/postgres-tuning-evidence.js",
"perf:postgres:sql": "node scripts/postgres-tuning-evidence.js --print-sql",
"test:worker:crm": "npm run db:smoke-seed && npm run build:worker && node scripts/crm-worker-integration-test.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: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", "test:worker:platform-billing": "npm run db:smoke-seed && npm run build:worker && node scripts/platform-billing-worker-integration-test.js",
@@ -56,7 +57,7 @@
"test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js", "test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js",
"test:worker:student-supervision": "npm run db:smoke-seed && npm run build:worker && node scripts/student-supervision-worker-integration-test.js", "test:worker:student-supervision": "npm run db:smoke-seed && npm run build:worker && node scripts/student-supervision-worker-integration-test.js",
"test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js", "test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js",
"test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/remote-auth-jwt-smoke-test.js && node scripts/production-launch-gate-test.js", "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node --import tsx scripts/auto-badge-concurrency-test.js && node scripts/postgres-tuning-evidence-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:auth:remote-smoke": "node scripts/remote-auth-jwt-smoke-test.js",
"test:launch-gate": "node scripts/production-launch-gate-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", "smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js",

View File

@@ -0,0 +1,175 @@
export const postgresTuningProfiles = {
'shared-host': {
name: 'shared-host',
label: '4c16g shared-host',
description: '4 vCPU / 16GB RAM, PostgreSQL shares the host with API, worker, Nginx and monitoring agents.',
settings: {
max_connections: {
recommended: '80',
min: 40,
max: 120,
type: 'number',
rationale: '4 vCPU should use application/pooler limits instead of many direct active connections.',
},
shared_buffers: {
recommended: '3GB',
min: '2GB',
max: '4GB',
type: 'bytes',
restart: true,
rationale: 'Keep PostgreSQL buffers large enough while leaving RAM for OS cache and sibling services.',
},
effective_cache_size: {
recommended: '10GB',
min: '8GB',
max: '12GB',
type: 'bytes',
rationale: 'Planner estimate only; reflects shared buffers plus OS cache on a 16GB shared host.',
},
work_mem: {
recommended: '16MB',
min: '8MB',
max: '32MB',
type: 'bytes',
rationale: 'This is per sort/hash operation, not a global cap.',
},
maintenance_work_mem: {
recommended: '512MB',
min: '256MB',
max: '1GB',
type: 'bytes',
rationale: 'Helps index builds, vacuum and imports without overcommitting memory.',
},
autovacuum_work_mem: {
recommended: '256MB',
min: '128MB',
max: '512MB',
type: 'bytes',
rationale: 'Avoid letting multiple autovacuum workers inherit an overly high maintenance_work_mem.',
},
wal_buffers: {
recommended: '16MB',
min: '8MB',
max: '64MB',
type: 'bytes',
restart: true,
rationale: 'Enough WAL buffering for steady learning writes and import bursts.',
},
min_wal_size: {
recommended: '1GB',
min: '1GB',
max: '4GB',
type: 'bytes',
rationale: 'Reserve WAL for short write spikes and batch jobs.',
},
max_wal_size: {
recommended: '6GB',
min: '4GB',
max: '12GB',
type: 'bytes',
rationale: 'Reduce checkpoint pressure while keeping crash recovery bounded.',
},
checkpoint_timeout: {
recommended: '10min',
min: '5min',
max: '15min',
type: 'duration',
rationale: 'Avoid overly frequent checkpoints on write bursts.',
},
checkpoint_completion_target: {
recommended: '0.9',
min: 0.8,
max: 0.95,
type: 'number',
rationale: 'Spread checkpoint I/O without pushing completion too close to the next checkpoint.',
},
effective_io_concurrency: {
recommended: '100',
min: 50,
max: 300,
type: 'number',
rationale: 'SSD cloud disk starting point; validate against the actual disk class.',
},
random_page_cost: {
recommended: '1.1',
min: 1,
max: 1.5,
type: 'number',
rationale: 'Reflect SSD random I/O and encourage reasonable index scans.',
},
jit: {
recommended: 'off',
exact: 'off',
type: 'text',
rationale: 'Question-bank API queries are mostly short OLTP requests where JIT planning cost is usually not worth it.',
},
log_min_duration_statement: {
recommended: '500ms',
min: '100ms',
max: '1000ms',
type: 'duration',
rationale: 'Capture slow SQL during launch without logging every normal request.',
},
idle_in_transaction_session_timeout: {
recommended: '60s',
min: '30s',
max: '120s',
type: 'duration',
rationale: 'Kill idle transactions before they block migrations and writes.',
},
statement_timeout: {
recommended: '30s',
min: '5s',
max: '60s',
type: 'duration',
rationale: 'API requests should fail boundedly; import jobs can override per session.',
},
lock_timeout: {
recommended: '5s',
min: '1s',
max: '10s',
type: 'duration',
rationale: 'Ordinary API requests should not wait a long time behind locks.',
},
},
},
'dedicated-db': {
name: 'dedicated-db',
label: '4c16g dedicated PostgreSQL',
description: '4 vCPU / 16GB RAM where PostgreSQL is the only heavy service on the host.',
settings: {
max_connections: { recommended: '120', min: 60, max: 150, type: 'number' },
shared_buffers: { recommended: '4GB', min: '3GB', max: '5GB', type: 'bytes', restart: true },
effective_cache_size: { recommended: '12GB', min: '10GB', max: '14GB', type: 'bytes' },
work_mem: { recommended: '16MB', min: '8MB', max: '32MB', type: 'bytes' },
maintenance_work_mem: { recommended: '768MB', min: '512MB', max: '1536MB', type: 'bytes' },
autovacuum_work_mem: { recommended: '256MB', min: '128MB', max: '512MB', type: 'bytes' },
wal_buffers: { recommended: '16MB', min: '8MB', max: '64MB', type: 'bytes', restart: true },
min_wal_size: { recommended: '2GB', min: '1GB', max: '4GB', type: 'bytes' },
max_wal_size: { recommended: '8GB', min: '4GB', max: '16GB', type: 'bytes' },
checkpoint_timeout: { recommended: '15min', min: '5min', max: '20min', type: 'duration' },
checkpoint_completion_target: { recommended: '0.9', min: 0.8, max: 0.95, type: 'number' },
effective_io_concurrency: { recommended: '100', min: 50, max: 300, type: 'number' },
random_page_cost: { recommended: '1.1', min: 1, max: 1.5, type: 'number' },
jit: { recommended: 'off', exact: 'off', type: 'text' },
log_min_duration_statement: { recommended: '500ms', min: '100ms', max: '1000ms', type: 'duration' },
idle_in_transaction_session_timeout: { recommended: '60s', min: '30s', max: '120s', type: 'duration' },
statement_timeout: { recommended: '30s', min: '5s', max: '60s', type: 'duration' },
lock_timeout: { recommended: '5s', min: '1s', max: '10s', type: 'duration' },
},
},
};
export const defaultPostgresTuningProfile = 'shared-host';
export function getPostgresTuningProfile(name = defaultPostgresTuningProfile) {
const profile = postgresTuningProfiles[name];
if (!profile) {
throw new Error(`Unknown PostgreSQL tuning profile: ${name}`);
}
return profile;
}
export function getPostgresTuningSettingNames(profileName = defaultPostgresTuningProfile) {
return Object.keys(getPostgresTuningProfile(profileName).settings);
}

View File

@@ -0,0 +1,38 @@
import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import path from 'node:path';
const repoRoot = process.cwd();
const scriptPath = path.join(repoRoot, 'scripts', 'postgres-tuning-evidence.js');
function run(args) {
return spawnSync(process.execPath, [scriptPath, ...args], {
cwd: repoRoot,
encoding: 'utf8',
env: {
PATH: process.env.PATH || '',
Path: process.env.Path || '',
SystemRoot: process.env.SystemRoot || '',
ComSpec: process.env.ComSpec || '',
TEMP: process.env.TEMP || '',
TMP: process.env.TMP || '',
},
});
}
const sharedSql = run(['--print-sql', '--profile=shared-host']);
assert.equal(sharedSql.status, 0, sharedSql.stderr);
assert.match(sharedSql.stdout, /alter system set shared_buffers = '3GB';/);
assert.match(sharedSql.stdout, /alter system set statement_timeout = '30s';/);
assert.match(sharedSql.stdout, /alter system set jit = 'off';/);
const dedicatedSql = run(['--print-sql', '--profile=dedicated-db']);
assert.equal(dedicatedSql.status, 0, dedicatedSql.stderr);
assert.match(dedicatedSql.stdout, /alter system set shared_buffers = '4GB';/);
assert.match(dedicatedSql.stdout, /alter system set max_wal_size = '8GB';/);
const invalidProfile = run(['--print-sql', '--profile=unknown']);
assert.notEqual(invalidProfile.status, 0, 'unknown profile should fail');
assert.match(`${invalidProfile.stderr}${invalidProfile.stdout}`, /Unknown PostgreSQL tuning profile/);
console.log('[PASS] PostgreSQL tuning evidence helpers');

View File

@@ -1,30 +1,38 @@
import fs from 'node:fs/promises'; import fs from 'node:fs/promises';
import path from 'node:path'; import path from 'node:path';
import pg from 'pg'; import pg from 'pg';
import {
defaultPostgresTuningProfile,
getPostgresTuningProfile,
getPostgresTuningSettingNames,
} from './lib/postgres-tuning-profile.js';
const databaseUrl = process.env.DATABASE_URL || 'postgresql://postgres:postgres@127.0.0.1:54322/postgres'; 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 outputDir = process.env.PG_TUNING_OUTPUT_DIR || 'docs/refactor/launch-artifacts';
const settingNames = [ function parseArgs(argv) {
'max_connections', const options = {
'shared_buffers', profile: process.env.PG_TUNING_PROFILE || defaultPostgresTuningProfile,
'effective_cache_size', strict: process.env.PG_TUNING_STRICT === 'true',
'work_mem', printSql: false,
'maintenance_work_mem', json: false,
'autovacuum_work_mem', };
'wal_buffers', for (let index = 2; index < argv.length; index += 1) {
'min_wal_size', const arg = argv[index];
'max_wal_size', if (arg === '--strict') options.strict = true;
'checkpoint_timeout', else if (arg === '--json') options.json = true;
'checkpoint_completion_target', else if (arg === '--print-sql') options.printSql = true;
'effective_io_concurrency', else if (arg === '--profile') {
'random_page_cost', options.profile = argv[index + 1] || options.profile;
'jit', index += 1;
'log_min_duration_statement', } else if (arg.startsWith('--profile=')) {
'idle_in_transaction_session_timeout', options.profile = arg.slice('--profile='.length);
'statement_timeout', }
'lock_timeout', }
]; options.profileDefinition = getPostgresTuningProfile(options.profile);
options.settingNames = getPostgresTuningSettingNames(options.profile);
return options;
}
function shanghaiStamp(date = new Date()) { function shanghaiStamp(date = new Date()) {
const parts = Object.fromEntries( const parts = Object.fromEntries(
@@ -59,19 +67,184 @@ function redactUrl(value) {
return value.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:[redacted]@'); return value.replace(/:\/\/([^:]+):([^@]+)@/, '://$1:[redacted]@');
} }
function pgUnitMultiplier(unit) {
const text = String(unit || '').trim().toLowerCase();
if (!text) return 1;
if (text === 'ms') return 1;
if (text === 's') return 1000;
if (text === 'min') return 60_000;
const memoryMatch = text.match(/^(\d+)?\s*(b|kb|mb|gb|tb)$/);
if (!memoryMatch) return 1;
const blockCount = Number(memoryMatch[1] || 1);
const unitName = memoryMatch[2];
const factors = {
b: 1,
kb: 1024,
mb: 1024 ** 2,
gb: 1024 ** 3,
tb: 1024 ** 4,
};
return blockCount * factors[unitName];
}
function formatBytes(bytes) {
if (!Number.isFinite(bytes)) return '';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
let value = bytes;
let unitIndex = 0;
while (value >= 1024 && unitIndex < units.length - 1) {
value /= 1024;
unitIndex += 1;
}
const rounded = value >= 10 || Number.isInteger(value) ? value.toFixed(0) : value.toFixed(1);
return `${rounded}${units[unitIndex]}`;
}
function formatDuration(ms) {
if (!Number.isFinite(ms)) return '';
if (ms === 0) return '0ms';
if (ms % 60_000 === 0) return `${ms / 60_000}min`;
if (ms % 1000 === 0) return `${ms / 1000}s`;
return `${ms}ms`;
}
function normalizedSettingValue(setting) {
const number = Number(setting.setting);
if (!Number.isFinite(number)) return `${setting.setting}${setting.unit || ''}`;
const unit = String(setting.unit || '').trim();
if (!unit) return String(setting.setting);
if (/^(?:\d+)?\s*(?:b|kb|mb|gb|tb)$/i.test(unit)) {
return formatBytes(number * pgUnitMultiplier(unit));
}
if (/^(?:ms|s|min)$/i.test(unit)) {
return formatDuration(number * pgUnitMultiplier(unit));
}
return `${setting.setting}${unit}`;
}
function explainSetting(setting) { function explainSetting(setting) {
const value = `${setting.setting}${setting.unit || ''}`;
return { return {
name: setting.name, name: setting.name,
value, value: normalizedSettingValue(setting),
rawSetting: setting.setting,
unit: setting.unit || null,
source: setting.source, source: setting.source,
pendingRestart: setting.pending_restart, pendingRestart: setting.pending_restart,
}; };
} }
function evaluate(settingsRows) { function parseSettingNumber(setting) {
if (!setting) return Number.NaN;
const number = Number(setting.setting);
return Number.isFinite(number) ? number : Number.NaN;
}
function parseBytes(value) {
if (typeof value === 'number') return value;
const text = String(value || '').trim().toLowerCase();
if (!text) return Number.NaN;
const match = text.match(/^(-?\d+(?:\.\d+)?)\s*([a-z]+)?$/);
if (!match) return Number.NaN;
const amount = Number(match[1]);
const unit = match[2] || 'b';
const factors = {
b: 1,
byte: 1,
bytes: 1,
kb: 1024,
mb: 1024 ** 2,
gb: 1024 ** 3,
tb: 1024 ** 4,
};
return amount * (factors[unit] || Number.NaN);
}
function parseDurationMs(value) {
if (typeof value === 'number') return value;
const text = String(value || '').trim().toLowerCase();
if (!text) return Number.NaN;
const match = text.match(/^(-?\d+(?:\.\d+)?)\s*([a-z]+)?$/);
if (!match) return Number.NaN;
const amount = Number(match[1]);
const unit = match[2] || 'ms';
const factors = {
ms: 1,
s: 1000,
sec: 1000,
secs: 1000,
second: 1000,
seconds: 1000,
min: 60_000,
mins: 60_000,
minute: 60_000,
minutes: 60_000,
h: 3_600_000,
hour: 3_600_000,
hours: 3_600_000,
};
return amount * (factors[unit] || Number.NaN);
}
function comparableValue(setting, rule) {
if (!setting) return Number.NaN;
if (rule.type === 'bytes') return Number(setting.setting) * pgUnitMultiplier(setting.unit);
if (rule.type === 'duration') return Number(setting.setting) * pgUnitMultiplier(setting.unit);
return parseSettingNumber(setting);
}
function recommendedComparator(rule, key) {
if (rule.type === 'bytes') return parseBytes(rule[key]);
if (rule.type === 'duration') return parseDurationMs(rule[key]);
return Number(rule[key]);
}
function evaluateProfile(settingsRows, profile) {
const byName = new Map(settingsRows.map(row => [row.name, row]));
const failures = [];
const warnings = [];
for (const [name, rule] of Object.entries(profile.settings)) {
const setting = byName.get(name);
if (!setting) {
failures.push(`${name} is missing from pg_settings evidence.`);
continue;
}
if (setting.pending_restart) {
failures.push(`${name} has pending_restart=true; restart PostgreSQL before capacity testing.`);
}
if (rule.exact !== undefined) {
const actual = String(setting.setting || '').toLowerCase();
if (actual !== String(rule.exact).toLowerCase()) {
failures.push(`${name} should be ${rule.exact} for ${profile.label}, got ${setting.setting}.`);
}
continue;
}
if (rule.min !== undefined) {
const actual = comparableValue(setting, rule);
const expected = recommendedComparator(rule, 'min');
if (!Number.isFinite(actual) || actual < expected) {
failures.push(`${name} is below ${rule.min} for ${profile.label}; current value is ${normalizedSettingValue(setting)}.`);
}
}
if (rule.max !== undefined) {
const actual = comparableValue(setting, rule);
const expected = recommendedComparator(rule, 'max');
if (!Number.isFinite(actual) || actual > expected) {
failures.push(`${name} is above ${rule.max} for ${profile.label}; current value is ${normalizedSettingValue(setting)}.`);
}
}
if (rule.recommended !== undefined && String(setting.source || '').toLowerCase() === 'default') {
warnings.push(`${name} still comes from default; recommended ${profile.name} value is ${rule.recommended}.`);
}
}
return { failures, warnings };
}
function evaluate(settingsRows, profile, options, pgStatStatements) {
const byName = new Map(settingsRows.map(row => [row.name, row])); const byName = new Map(settingsRows.map(row => [row.name, row]));
const warnings = []; const warnings = [];
const failures = [];
const maxConnections = Number(byName.get('max_connections')?.setting || 0); const maxConnections = Number(byName.get('max_connections')?.setting || 0);
const jit = String(byName.get('jit')?.setting || '').toLowerCase(); const jit = String(byName.get('jit')?.setting || '').toLowerCase();
const statementTimeout = String(byName.get('statement_timeout')?.setting || ''); const statementTimeout = String(byName.get('statement_timeout')?.setting || '');
@@ -84,9 +257,19 @@ function evaluate(settingsRows) {
if (idleTimeout === '0') warnings.push('idle_in_transaction_session_timeout is disabled; long idle transactions can block migrations and writes.'); 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 (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.'); if (settingsRows.some(row => row.pending_restart)) warnings.push('Some PostgreSQL settings have pending_restart=true; restart is required before capacity testing.');
if (pgStatStatements?.unavailable) warnings.push('pg_stat_statements is unavailable; enable it before production capacity acceptance.');
const profileEvaluation = evaluateProfile(settingsRows, profile);
warnings.push(...profileEvaluation.warnings);
if (options.strict) {
failures.push(...profileEvaluation.failures);
if (pgStatStatements?.unavailable) failures.push('pg_stat_statements is unavailable in strict mode.');
}
return { return {
status: warnings.length ? 'warn' : 'pass', status: failures.length ? 'fail' : options.strict ? 'pass' : warnings.length ? 'warn' : 'pass',
profile: profile.name,
failures,
warnings, warnings,
}; };
} }
@@ -99,9 +282,17 @@ function markdown(report) {
'', '',
`数据库:${report.databaseUrl}`, `数据库:${report.databaseUrl}`,
'', '',
`调参 profile${report.profile.label} (${report.profile.name})`,
'',
`评估:${report.evaluation.status}`, `评估:${report.evaluation.status}`,
'', '',
]; ];
if (report.evaluation.failures.length) {
lines.push('## 阻断项');
lines.push('');
for (const failure of report.evaluation.failures) lines.push(`- ${failure}`);
lines.push('');
}
if (report.evaluation.warnings.length) { if (report.evaluation.warnings.length) {
lines.push('## 警告'); lines.push('## 警告');
lines.push(''); lines.push('');
@@ -158,7 +349,30 @@ function markdown(report) {
return `${lines.join('\n')}\n`; return `${lines.join('\n')}\n`;
} }
function sqlQuote(value) {
return String(value).replaceAll("'", "''");
}
function alterSystemSql(profile) {
const lines = [
`-- PostgreSQL tuning profile: ${profile.label}`,
'-- Review current values and take a snapshot before applying this file.',
'-- Run npm run perf:postgres:evidence -- --strict after reload/restart.',
'',
];
for (const [name, rule] of Object.entries(profile.settings)) {
lines.push(`alter system set ${name} = '${sqlQuote(rule.recommended)}';`);
}
lines.push('select pg_reload_conf();');
return `${lines.join('\n')}\n`;
}
async function main() { async function main() {
const options = parseArgs(process.argv);
if (options.printSql) {
console.log(alterSystemSql(options.profileDefinition));
return;
}
const pool = new pg.Pool({ connectionString: databaseUrl, max: 2 }); const pool = new pg.Pool({ connectionString: databaseUrl, max: 2 });
try { try {
const settingsRaw = await query( const settingsRaw = await query(
@@ -169,7 +383,7 @@ async function main() {
where name = any($1::text[]) where name = any($1::text[])
order by array_position($1::text[], name) order by array_position($1::text[], name)
`, `,
[settingNames], [options.settingNames],
); );
const activityByWait = await query( const activityByWait = await query(
pool, pool,
@@ -231,7 +445,12 @@ async function main() {
const report = { const report = {
generatedAt: new Date().toISOString(), generatedAt: new Date().toISOString(),
databaseUrl: redactUrl(databaseUrl), databaseUrl: redactUrl(databaseUrl),
evaluation: evaluate(settingsRaw), profile: {
name: options.profileDefinition.name,
label: options.profileDefinition.label,
description: options.profileDefinition.description,
},
evaluation: evaluate(settingsRaw, options.profileDefinition, options, pgStatStatements),
settings: settingsRaw.map(explainSetting), settings: settingsRaw.map(explainSetting),
activityByWait, activityByWait,
databaseStats, databaseStats,
@@ -239,15 +458,32 @@ async function main() {
largeRelations, largeRelations,
pgStatStatements, pgStatStatements,
}; };
const pendingRestart = settingsRaw.filter(row => row.pending_restart).length;
await fs.mkdir(outputDir, { recursive: true }); await fs.mkdir(outputDir, { recursive: true });
const jsonPath = path.join(outputDir, `postgres-tuning-evidence-${shanghaiStamp()}.json`); const jsonPath = path.join(outputDir, `postgres-tuning-evidence-${shanghaiStamp()}.json`);
const mdPath = jsonPath.replace(/\.json$/, '.md'); const mdPath = jsonPath.replace(/\.json$/, '.md');
await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8'); await fs.writeFile(jsonPath, `${JSON.stringify(report, null, 2)}\n`, 'utf8');
await fs.writeFile(mdPath, markdown(report), 'utf8'); await fs.writeFile(mdPath, markdown(report), 'utf8');
console.log(`[pg-evidence] status=${report.evaluation.status}`); const payload = {
for (const warning of report.evaluation.warnings) console.log(`[pg-evidence] warning: ${warning}`); status: report.evaluation.status,
console.log(`[pg-evidence] wrote ${jsonPath}`); profile: report.profile.name,
console.log(`[pg-evidence] wrote ${mdPath}`); warnings: report.evaluation.warnings.length,
failures: report.evaluation.failures.length,
pgStatStatementsAvailable: !pgStatStatements?.unavailable,
pendingRestart,
jsonPath,
mdPath,
};
if (options.json) {
console.log(JSON.stringify(payload, null, 2));
} else {
console.log(`[pg-evidence] status=${report.evaluation.status}`);
for (const failure of report.evaluation.failures) console.log(`[pg-evidence] failure: ${failure}`);
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}`);
}
if (options.strict && report.evaluation.failures.length > 0) process.exitCode = 1;
} finally { } finally {
await pool.end(); await pool.end();
} }

View File

@@ -130,6 +130,17 @@ assert.ok(
'migration profile mismatch should be reported as a blocker', 'migration profile mismatch should be reported as a blocker',
); );
const missingPostgresTuning = runGate(tempDir => {
const evidence = createEvidence(tempDir);
evidence.checks = evidence.checks.filter(item => item.id !== 'postgres.tuning-evidence');
return evidence;
});
assert.notEqual(missingPostgresTuning.status, 0, 'missing PostgreSQL tuning evidence should fail launch gate');
assert.ok(
missingPostgresTuning.payload.checks?.some(item => item.id === 'check.postgres.tuning-evidence' && item.status === 'blocker'),
'missing PostgreSQL tuning evidence should be reported as a blocker',
);
const slowBenchmark = runGate(tempDir => { const slowBenchmark = runGate(tempDir => {
const evidence = createEvidence(tempDir); const evidence = createEvidence(tempDir);
const item = evidence.checks.find(check => check.id === 'performance.api-real-data-read'); const item = evidence.checks.find(check => check.id === 'performance.api-real-data-read');

View File

@@ -20,6 +20,18 @@ const gateChecks = [
commandIncludes: 'readiness:production:db', commandIncludes: 'readiness:production:db',
summary: { blocker: 0 }, summary: { blocker: 0 },
}, },
{
id: 'postgres.tuning-evidence',
label: 'PostgreSQL 4c16g tuning evidence',
commandIncludes: 'perf:postgres:evidence',
summary: {
status: 'pass',
profile: { oneOf: ['shared-host', 'dedicated-db'] },
failures: 0,
pgStatStatementsAvailable: true,
pendingRestart: 0,
},
},
{ {
id: 'auth.remote-smoke', id: 'auth.remote-smoke',
label: 'Remote Supabase Auth/JWKS smoke', label: 'Remote Supabase Auth/JWKS smoke',