forked from wangziqi/gongxue-base
test: strengthen launch security and capacity gates
This commit is contained in:
21
README.md
21
README.md
@@ -53,7 +53,7 @@
|
||||
| 对象存储/资料安全 | √ 可联调,待生产 AV/CDN | OSS/COS/Supabase Storage 签名、上传确认、短签名预览下载、水印 traceId、复检和安全扫描地基已完成 |
|
||||
| PocketBase 真实数据迁移 | √ 本地跑通,待人工复核 blocker | SQLite 导出、标准化导入、校验和抽样脚本已跑通;正式切换前处理缺用户订单和缺归属手册章节 |
|
||||
| Taro H5 三端前端 | √ 第一版可构建 | 学生端、租户后台、平台后台均有真实 API 页面;已补 H5 `index.html` 模板和发布产物守卫;后续继续补小程序兼容、视觉精修、状态管理、包体优化和端到端测试 |
|
||||
| 生产安全/压测交付 | △ 本地真实数据压测已跑,云端待复测 | 本地 Docker/Supabase 已完成真实迁移数据 30/50/100/150 并发只读和混合读写压测;受限 API 容器复核中抓到并修复了自动勋章并发发放唯一键冲突,并补并发回归测试。最近一次只读上线门禁和 50/100/150 混合读写均 0 错误,50/100 混合读写约 418-420 req/s,150 并发 P95 超 600ms 进入压力区;上云后仍需执行生产 readiness、远程 Auth/RLS、4c16g 压测、PostgreSQL 调优和上线证据门禁。`@codex-security` 只有在 Codex 环境暴露扫描工具时才能标记完成 |
|
||||
| 生产安全/压测交付 | △ 本地真实数据压测已跑,云端待复测 | 本地 Docker/Supabase 已完成真实迁移数据 30/50/100/150 并发只读和混合读写压测;受限 API 容器复核中抓到并修复了自动勋章并发发放唯一键冲突,并补并发回归测试。最近一次只读上线门禁和 50/100/150 混合读写均 0 错误,50/100 混合读写约 428-444 req/s,150 并发 P95 约 574ms 进入压力区;上云后仍需执行生产 readiness、远程 Auth/RLS、4c16g 压测、PostgreSQL 调优、`security:repo`、真实 `@codex-security` 扫描和上线证据门禁。 |
|
||||
|
||||
更完整的进度看这些文档:
|
||||
|
||||
@@ -153,7 +153,7 @@ npm run smoke:taro:h5:interaction
|
||||
`taro-route-contract-test` 会校验 `apps/taro/src/app.config.ts`、真实 `pages/**/index.tsx`、启动页三端跳转、H5 静态烟测入口和前端交接文档中的页面引用保持一致。新增或删除页面时必须同步路由和文档,避免 H5/小程序构建后才发现入口漂移。
|
||||
`taro-api-contract-test` 会比对 `apps/taro/src` 中所有 `apiRequest('/api/...')` 调用与 `apps/api/src/features/*/index.ts` 注册路由,阻断前端调用不存在 API、method 写错或绕过统一 `/api` 命名空间的漂移;动态导入和少量 server alias 需要在脚本 allowlist 中显式声明。
|
||||
`taro-persona-contract-test` 会从学生、租户管理员、平台管理员三类前端视角检查关键页面、路由和服务调用,阻断刷题、会员订单、错题收藏、学生运营、内容导入、营销财务、租户设置、平台租户账务和公共题库授权入口被误删或漂移。
|
||||
`smoke:taro:h5:interaction` 会启动三套 H5 发布产物、本地 mock API 和本机 Chrome/Edge,在真实浏览器里点击学生首页、题库、答题、收藏、错题/收藏复习、背单词、知识手册、资料短签名和水印、视频播放授权、分数线、AI 择校、消息中心、会员收银台,租户后台六个主模块,以及平台后台四个主模块,用来补足静态烟测无法发现的 H5 运行时空白页、history 路由和点击事件问题。
|
||||
`smoke:taro:h5:interaction` 会启动三套 H5 发布产物、本地 mock API 和本机 Chrome/Edge,在真实浏览器里点击学生首页、题库、答题、收藏、错题/收藏复习、背单词、知识手册、资料短签名和水印、视频播放授权、分数线、AI 择校、消息中心、会员收银台下单/支付参数/订单状态,租户后台六个主模块,以及平台后台四个主模块,用来补足静态烟测无法发现的 H5 运行时空白页、history 路由和点击事件问题。
|
||||
|
||||
H5 线上推荐每个静态目录放独立 `runtime-config.json` 覆盖公开配置,避免 API/Auth 域名变化时重打包:
|
||||
|
||||
@@ -393,6 +393,7 @@ npm run test:auth:remote-smoke
|
||||
npm run test:launch-gate
|
||||
npm run smoke:auth:remote
|
||||
npm run audit:runtime
|
||||
npm run security:repo
|
||||
npm run pb:import:dry-run
|
||||
npm run pb:import:validate
|
||||
npm run test:pb:dry-run
|
||||
@@ -468,10 +469,10 @@ npm run launch:gate -- --evidence docs/refactor/production-launch-evidence.json
|
||||
- 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` 扫描。
|
||||
- `audit:runtime`、`security:repo`、真实 `@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 security:repo`、`npm run test:readiness`、`npm run test:rls` 和代码审查作为临时安全证据,并在上线证据里保留插件扫描待补项。
|
||||
|
||||
模板文件:
|
||||
|
||||
@@ -616,7 +617,7 @@ npm run smoke:taro:h5:interaction
|
||||
node scripts\taro-h5-release-guardrails-test.js --require-dist
|
||||
```
|
||||
|
||||
`smoke:taro:h5` 会启动临时静态服务器和 mock API,验证三套 H5 的 `index.html`、静态资源、history fallback、公开 runtime config 和租户解析契约。`smoke:taro:h5:interaction` 会再拉起真实 Chrome/Edge,打开三套 H5 产物并点击 25 项关键入口,覆盖学生首页、题库、答题、收藏、错题/收藏复习、背单词、知识手册、资料短签名和水印、视频播放授权、分数线、AI 择校、消息中心、会员收银台,租户后台六个主模块,以及平台后台四个主模块,确认页面 JS 执行、路由跳转、关键 API 和后台入口点击没有空白页或运行时异常。`taro-h5-release-guardrails-test` 会确认三套 H5 目录存在 `index.html`,并扫描源码/产物是否混入旧 PocketBase、`x-user-id`、平台本地 key、数据库连接串或服务端密钥形态。正式部署时还必须在每个 H5 目录根部放置对应的 `runtime-config.json`。
|
||||
`smoke:taro:h5` 会启动临时静态服务器和 mock API,验证三套 H5 的 `index.html`、静态资源、history fallback、公开 runtime config 和租户解析契约。`smoke:taro:h5:interaction` 会再拉起真实 Chrome/Edge,打开三套 H5 产物并点击 26 项关键入口,覆盖学生首页、题库、答题、收藏、错题/收藏复习、背单词、知识手册、资料短签名和水印、视频播放授权、分数线、AI 择校、消息中心、会员收银台下单/支付参数/订单状态,租户后台六个主模块,以及平台后台四个主模块,确认页面 JS 执行、路由跳转、关键 API 和后台入口点击没有空白页或运行时异常。`taro-h5-release-guardrails-test` 会确认三套 H5 目录存在 `index.html`,并扫描源码/产物是否混入旧 PocketBase、`x-user-id`、平台本地 key、数据库连接串或服务端密钥形态。正式部署时还必须在每个 H5 目录根部放置对应的 `runtime-config.json`。
|
||||
|
||||
写入生产上线证据时使用严格模式,确保三套正式发布目录已经放好真实公开 `runtime-config.json`,且 warning 为 0:
|
||||
|
||||
@@ -653,10 +654,10 @@ node scripts\taro-h5-release-guardrails-test.js --require-dist --require-runtime
|
||||
| 50 | 60s | 10% | 35,608 | 0.00% | 584.18 req/s | 174.26 ms | 224.04 ms |
|
||||
| 100 | 60s | 8% | 33,843 | 0.00% | 554.42 req/s | 326.21 ms | 422.33 ms |
|
||||
| 150 | 60s | 6% | 32,364 | 0.00% | 528.81 req/s | 490.38 ms | 662.42 ms |
|
||||
| Docker API 2c4g / 30 | 120s | 0% | 44,038 | 0.00% | 366.27 req/s | 185.80 ms | 254.08 ms |
|
||||
| Docker API 2c4g / 50 | 60s | 10% | 25,327 | 0.00% | 419.62 req/s | 213.17 ms | 270.94 ms |
|
||||
| Docker API 2c4g / 100 | 60s | 8% | 25,378 | 0.00% | 417.77 req/s | 383.72 ms | 461.22 ms |
|
||||
| Docker API 2c4g / 150 | 60s | 6% | 23,444 | 0.00% | 384.39 req/s | 605.89 ms | 743.19 ms |
|
||||
| Docker API 2c4g / 30 | 120s | 0% | 44,692 | 0.00% | 371.62 req/s | 188.91 ms | 275.79 ms |
|
||||
| Docker API 2c4g / 50 | 60s | 10% | 26,815 | 0.00% | 444.49 req/s | 204.08 ms | 268.88 ms |
|
||||
| Docker API 2c4g / 100 | 60s | 8% | 25,950 | 0.00% | 428.30 req/s | 375.38 ms | 447.47 ms |
|
||||
| Docker API 2c4g / 150 | 60s | 6% | 23,970 | 0.00% | 394.80 req/s | 574.37 ms | 696.35 ms |
|
||||
|
||||
只读上线门禁继续要求 `includeWrites=false`;混合读写报告需要显式使用 `--allow-writes` 做人工容量观察,例如:
|
||||
|
||||
@@ -666,7 +667,7 @@ npm run perf:summary -- --input docs/refactor/performance-reports/api-benchmark-
|
||||
|
||||
使用 `--allow-writes` 时会输出 `capacityObservation`,不输出 `launchGateCheck`,不能把写入场景误填成生产上线门禁的只读证据。
|
||||
|
||||
本地结论:当前 Docker Desktop 分配 20 CPU、约 62.7GB 内存,高于常见 4 核 16G 云服务器,不能直接作为生产 SLA。2026-07-01 03:58 受限 API 容器复核中,30 worker/120 秒只读上线门禁为 44,038 请求、0 错误、366.27 req/s、P95 185.80ms;50 worker/60 秒/10% 写入为 25,327 请求、0 错误、419.62 req/s、P95 213.17ms;100 worker/60 秒/8% 写入为 25,378 请求、0 错误、417.77 req/s、P95 383.72ms;150 worker/60 秒/6% 写入为 23,444 请求、0 错误、384.39 req/s、P95 605.89ms,属于压力区。该轮压测曾抓到自动勋章并发发放唯一键冲突,已修复并新增并发回归测试。按单学生 0.05 到 0.2 req/s 的页面节奏粗略折算,当前受限 API 容器舒适观察区间约对应 2,000 到 8,400 名活跃在线学生的请求吞吐。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要和剩余功能清单见:
|
||||
本地结论:当前 Docker Desktop 分配 20 CPU、约 62.7GB 内存,高于常见 4 核 16G 云服务器,不能直接作为生产 SLA。2026-07-01 04:44 受限 API 容器复核中,30 worker/120 秒只读上线门禁为 44,692 请求、0 错误、371.62 req/s、P95 188.91ms;50 worker/60 秒/10% 写入为 26,815 请求、0 错误、444.49 req/s、P95 204.08ms;100 worker/60 秒/8% 写入为 25,950 请求、0 错误、428.30 req/s、P95 375.38ms;150 worker/60 秒/6% 写入为 23,970 请求、0 错误、394.80 req/s、P95 574.37ms,属于压力区。该受限容器系列压测曾抓到自动勋章并发发放唯一键冲突,已修复并新增并发回归测试。按单学生 0.05 到 0.2 req/s 的页面节奏粗略折算,当前受限 API 容器舒适观察区间约对应 2,100 到 8,900 名活跃在线学生的请求吞吐。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要和剩余功能清单见:
|
||||
|
||||
```text
|
||||
docs/refactor/performance-benchmark-summary-20260630.md
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
- 环境:Windows + Docker Desktop + 本地 Supabase/PostgreSQL + 本地 API 进程。
|
||||
- Docker Desktop 当前资源:20 CPU、约 62.7GB 内存。这个本机结果会高于常见 4 核 16G 云服务器,不能直接作为生产 SLA。
|
||||
- 数据库:PocketBase 真实导入数据,并已包含前期写入压测产生的练习/答题记录。
|
||||
- 压测基线规模:约 7.4 万道题、3,700 个用户、1,600 个题目合集、3,100 个练习蓝图、3,500 个单词、2,600 条知识手册、19.6 万条答题记录、3.8 万条错题、466 条权益。后续 `db:smoke-seed`、集成测试和压测会继续写入练习/答题/测试租户数据,因此这里按压测基线描述,不把每次测试后的瞬时行数当作 SLA 口径。
|
||||
- 当前本地迁移库规模:约 7.4 万道题、3,700 个用户、1,600 个题目合集、3,100 个练习蓝图、3,500 个单词、2,600 条知识手册、25.2 万条答题记录、3.8 万条错题、468 条权益。后续 `db:smoke-seed`、集成测试和压测会继续写入练习/答题/测试租户数据,因此这里按当前本地观察描述,不把每次测试后的瞬时行数当作生产 SLA 口径。
|
||||
- 排行榜未纳入默认负载,因为产品默认关闭。
|
||||
|
||||
### 2026-07-01 真实迁移库 Docker API 受限资源复核
|
||||
@@ -54,10 +54,10 @@
|
||||
|
||||
| 场景 | 并发 worker | 时长 | 刷题写入比例 | 请求数 | 错误率 | 吞吐 | P95 | P99 | 结论 |
|
||||
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
|
||||
| 只读上线门禁 | 30 | 120s | 0% | 44,038 | 0.00% | 366.27 req/s | 185.80 ms | 254.08 ms | 通过 |
|
||||
| 混合读写 | 50 | 60s | 10% | 25,327 | 0.00% | 419.62 req/s | 213.17 ms | 270.94 ms | 舒适 |
|
||||
| 混合读写 | 100 | 60s | 8% | 25,378 | 0.00% | 417.77 req/s | 383.72 ms | 461.22 ms | 接近舒适区上沿 |
|
||||
| 混合读写 | 150 | 60s | 6% | 23,444 | 0.00% | 384.39 req/s | 605.89 ms | 743.19 ms | 压力区 |
|
||||
| 只读上线门禁 | 30 | 120s | 0% | 44,692 | 0.00% | 371.62 req/s | 188.91 ms | 275.79 ms | 通过 |
|
||||
| 混合读写 | 50 | 60s | 10% | 26,815 | 0.00% | 444.49 req/s | 204.08 ms | 268.88 ms | 舒适 |
|
||||
| 混合读写 | 100 | 60s | 8% | 25,950 | 0.00% | 428.30 req/s | 375.38 ms | 447.47 ms | 接近舒适区上沿 |
|
||||
| 混合读写 | 150 | 60s | 6% | 23,970 | 0.00% | 394.80 req/s | 574.37 ms | 696.35 ms | 压力区 |
|
||||
|
||||
混合读写包含真实刷题闭环:创建练习 session、拉取 session detail、提交答案、交卷、读取报告。
|
||||
|
||||
@@ -69,10 +69,10 @@
|
||||
|
||||
按目前常见页面节奏先用 `0.05 到 0.2 req/s/人` 粗略折算:
|
||||
|
||||
- 50 worker 舒适场景 420 req/s,约等于 2,100 到 8,400 名活跃在线学生的请求吞吐。
|
||||
- 100 worker 上沿场景 418 req/s,约等于 2,090 到 8,360 名活跃在线学生的请求吞吐。
|
||||
- 150 worker 压力场景 384 req/s,约等于 1,920 到 7,680 名活跃在线学生的请求吞吐。
|
||||
- 50 worker 舒适场景 444 req/s,约等于 2,220 到 8,880 名活跃在线学生的请求吞吐。
|
||||
- 100 worker 上沿场景 428 req/s,约等于 2,140 到 8,560 名活跃在线学生的请求吞吐。
|
||||
- 150 worker 压力场景 395 req/s,约等于 1,975 到 7,900 名活跃在线学生的请求吞吐。
|
||||
|
||||
当前本地结论:在受限 API 容器下,后端真实刷题读写链路的舒适观察区间约为 418 到 420 req/s,折算约 2,000 到 8,400 名活跃在线学生;150 worker 仍 0 错误但 P95 已超过 600ms,视为压力区,不建议作为生产承诺。此前未限制 API 容器时本机可观察到约 550 到 585 req/s,说明 API CPU/连接池约束会明显影响上沿。
|
||||
当前本地结论:在受限 API 容器下,后端真实刷题读写链路的舒适观察区间约为 428 到 444 req/s,折算约 2,100 到 8,900 名活跃在线学生;150 worker 仍 0 错误但 P95 已接近 600ms,视为压力区,不建议作为生产承诺。同日本地 API 进程 30 worker/30 秒/10% 写入观察到 15,864 请求、0 错误、513.56 req/s、P95 113.97ms;受限 API 容器会明显压低上沿,因此上线承诺仍以云端受限资源复测为准。
|
||||
|
||||
正式对外容量必须等 4 核 16G 云服务器部署后复跑。保守规划时,生产首版建议先按本地折算值的 30% 到 50% 做容量承诺,等云端压测、CDN、对象存储和真实前端埋点完成后再上调。
|
||||
|
||||
@@ -46,10 +46,10 @@
|
||||
- 新增、删除或重命名 Taro 页面时必须同步 `apps/taro/src/app.config.ts`、启动页跳转、H5 静态烟测入口和本文页面清单,并运行 `node scripts/taro-route-contract-test.js`。该脚本会阻断“页面文件存在但未注册”“路由注册但文件缺失”“启动页或烟测跳到不存在页面”的漂移。
|
||||
- 新增或修改 Taro API service 时必须运行 `node scripts/taro-api-contract-test.js`。该脚本会比对前端 `apiRequest('/api/...')` 与后端 `RouteDefinition[]` 注册表,阻断调用不存在的接口、method 写错或绕过统一 API client;动态路由只能通过脚本 allowlist 明确声明。
|
||||
- 修改学生端、租户后台或平台后台关键页面时必须运行 `node scripts/taro-persona-contract-test.js`。该脚本按学生刷题/会员订单/错题收藏、租户学生运营/内容导入/营销财务/品牌权限、平台租户/账务/公共题库/员工权限三类角色旅程检查路由和服务调用,避免前端样式重做时误删核心业务入口。
|
||||
- H5 构建完成后必须运行 `npm run smoke:taro:h5:interaction` 做真实浏览器点击验证。它会覆盖学生首页到题库练习、答题、收藏、个人中心开通会员,租户后台工作台到题库内容/财务运营,以及平台后台工作台到租户管理/账务中心;如果 Chrome/Edge 缺失,可设置 `TARO_H5_SMOKE_BROWSER` 指向 Chromium 浏览器。
|
||||
- H5 构建完成后必须运行 `npm run smoke:taro:h5:interaction` 做真实浏览器点击验证。它会覆盖学生首页到题库练习、答题、收藏、会员收银台下单/支付参数/订单状态,租户后台工作台到题库内容/财务运营,以及平台后台工作台到租户管理/账务中心;如果 Chrome/Edge 缺失,可设置 `TARO_H5_SMOKE_BROWSER` 指向 Chromium 浏览器。
|
||||
- H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session;微信小程序端先验证运行时兼容性,业务数据默认仍走 `apps/api`。
|
||||
- H5 生产部署优先用每个静态目录自己的 `runtime-config.json` 配置 `apiBaseUrl`、`supabaseUrl`、`supabasePublishableKey`、`tenantCode`;不要为了换域名重打包,也不要把任何 service role、数据库、支付、短信、对象存储密钥放进该文件。
|
||||
- 上线前需要把三套 H5 构建、`npm run smoke:taro:h5` 静态启动烟测、`npm run smoke:taro:h5:interaction` 真实浏览器交互烟测、严格 `taro-h5-release-guardrails-test --require-runtime-config`、`runtime-config.json` 人工复核、真实 Auth/RLS、迁移 dry-run、对象存储、支付对账和真实 `@codex-security` 结果写入 `production-launch-evidence.json`,并通过 `npm run launch:gate`。当前环境没有暴露安全扫描工具时只能标记待补,不能把模板占位当完成。
|
||||
- 上线前需要把三套 H5 构建、`npm run smoke:taro:h5` 静态启动烟测、`npm run smoke:taro:h5:interaction` 真实浏览器交互烟测、严格 `taro-h5-release-guardrails-test --require-runtime-config`、`runtime-config.json` 人工复核、真实 Auth/RLS、迁移 dry-run、对象存储、支付对账、`security:repo` 和真实 `@codex-security` 结果写入 `production-launch-evidence.json`,并通过 `npm run launch:gate`。当前环境没有暴露安全扫描工具时只能标记待补,不能把模板占位当完成。
|
||||
- 可以接入租户品牌、已发布主题、公开素材、功能开关和域名/小程序参数解析;学生端只读 `/api/tenant/resolve` 的 `branding.theme/publicAssets`,租户后台草稿走 `/api/tenant-admin/theme`。
|
||||
- 租户后台可以接入角色模板和成员 API:`/api/tenant-admin/role-templates`、`/api/tenant-admin/members`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性和成员模板绑定。
|
||||
- 租户后台可以接入勋章管理、手动发放、积分任务、每日签到奖励、积分兑换、积分风控只读报表和反馈运营报表:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants`、`GET/PUT /api/tenant-admin/point-activity-tasks`、`GET /api/tenant-admin/point-activity-claims`、`GET/PUT /api/tenant-admin/point-exchange-items`、`GET /api/tenant-admin/point-exchange-orders`、`GET /api/tenant-admin/points-risk-report`、`GET /api/tenant-admin/feedbacks/report`;学生端用 `GET /api/profile/badges` 展示成就,通过 `POST /api/profile/check-in` 自动领取 `daily_check_in` 任务奖励,并通过 `GET /api/profile/activity-tasks`、`POST /api/profile/activity-tasks/claim`、`GET /api/profile/exchange-items`、`POST /api/profile/exchange-items/redeem` 接其它积分活动和兑换。
|
||||
|
||||
@@ -91,10 +91,10 @@
|
||||
- 生产 `.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、PostgreSQL 4c16g 严格调参证据、生产 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、运行时审计、`security:repo`、真实 `@codex-security`、备份/回滚/真实抽样/生产 provider 等证据填入本地 `production-launch-evidence.json` 并通过门禁。当前 Codex 环境未暴露可调用的 `@codex-security` 扫描工具时,该项只能标为待补,不能伪造完成。
|
||||
- 确认数据库迁移流程、备份恢复、日志、告警。
|
||||
- 准备 API 容器部署和 Supabase 云端/自托管连接方案。
|
||||
- 已补 `npm run perf:api:local`、`npm run perf:summary`、`npm run perf:postgres:evidence`、`npm run perf:postgres:sql`、`npm run smoke:launch-persona`、`npm run smoke:taro:h5`、`npm run smoke:taro:h5:interaction`、`node scripts/taro-api-contract-test.js`、`node scripts/taro-persona-contract-test.js` 和 `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.08ms;50 worker/60s/10% 写入为 25,327 请求、0 错误、419.62 req/s、P95 213.17ms、P99 270.94ms;100 worker/60s/8% 写入为 25,378 请求、0 错误、417.77 req/s、P95 383.72ms、P99 461.22ms;150 worker/60s/6% 写入为 23,444 请求、0 错误、384.39 req/s、P95 605.89ms、P99 743.19ms。压测先抓到自动勋章并发发放唯一键冲突,已修复并新增 `scripts/auto-badge-concurrency-test.js`。当前 Docker Desktop 给了 20 CPU/约 62.7GB 内存,但 API 容器限制为 2 CPU/4G,舒适观察区暂按 50 到 100 个无停顿 worker 估算,150 worker 已是压力区;按单学生 0.05 到 0.2 req/s 粗略折算约为 2,000 到 8,400 名活跃在线学生的本机吞吐观察区间,正式容量仍以上云 4 核 16G 复测为准。
|
||||
- 已补 `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 04:44 受限 API 容器真实迁移库复核中,30 worker/120s 只读为 44,692 请求、0 错误、371.62 req/s、P95 188.91ms、P99 275.79ms;50 worker/60s/10% 写入为 26,815 请求、0 错误、444.49 req/s、P95 204.08ms、P99 268.88ms;100 worker/60s/8% 写入为 25,950 请求、0 错误、428.30 req/s、P95 375.38ms、P99 447.47ms;150 worker/60s/6% 写入为 23,970 请求、0 错误、394.80 req/s、P95 574.37ms、P99 696.35ms。受限容器系列压测曾抓到自动勋章并发发放唯一键冲突,已修复并新增 `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,100 到 8,900 名活跃在线学生的本机吞吐观察区间,正式容量仍以上云 4 核 16G 复测为准。
|
||||
- 当前本地 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`。后续不要再把学生头像上传或默认排行榜当作待办;头像只保留男女预设,排行榜仅作为租户显式开启后的活动能力。
|
||||
|
||||
|
||||
@@ -117,6 +117,22 @@
|
||||
"includeWrites": false
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "performance.api-real-data-mixed",
|
||||
"status": "pass",
|
||||
"command": "PERF_START_SERVER=false PERF_API_BASE=https://api.example.com PERF_DURATION_SECONDS=120 PERF_CONCURRENCY=50 PERF_RAMP_SECONDS=15 PERF_INCLUDE_WRITES=true PERF_PRACTICE_FLOW_RATIO=0.1 PERF_AUTH_MODE=app_session npm run perf:api:local > docs/refactor/launch-artifacts/api-real-data-mixed-benchmark.log",
|
||||
"completedAt": "2026-06-30T10:49:00+08:00",
|
||||
"artifact": "launch-artifacts/api-real-data-mixed-benchmark.log",
|
||||
"summary": {
|
||||
"errors": 0,
|
||||
"errorRate": 0,
|
||||
"p95Ms": 0,
|
||||
"p99Ms": 0,
|
||||
"concurrency": 50,
|
||||
"durationSeconds": 120,
|
||||
"includeWrites": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "api.integration",
|
||||
"status": "pass",
|
||||
@@ -247,12 +263,14 @@
|
||||
"artifact": "launch-artifacts/taro-h5-interaction-smoke.json",
|
||||
"summary": {
|
||||
"fail": 0,
|
||||
"pass": 11,
|
||||
"pass": 26,
|
||||
"mockApi": {
|
||||
"keyRequests": {
|
||||
"answers": 1,
|
||||
"favorites": 2,
|
||||
"tenantResolve": 3
|
||||
"ordersCreated": 1,
|
||||
"paymentsCreated": 1,
|
||||
"orderStatus": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -279,6 +297,17 @@
|
||||
"high": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "security.repo-scan",
|
||||
"status": "pass",
|
||||
"command": "npm run security:repo -- --json > docs/refactor/launch-artifacts/repo-security-scan.json",
|
||||
"completedAt": "2026-06-30T12:22:00+08:00",
|
||||
"artifact": "launch-artifacts/repo-security-scan.json",
|
||||
"summary": {
|
||||
"critical": 0,
|
||||
"high": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "security.codex-scan",
|
||||
"status": "replace-with-pass-after-real-scan",
|
||||
|
||||
@@ -73,9 +73,10 @@ npm run readiness:production:db
|
||||
npm run smoke:auth:remote
|
||||
npm run test:rls
|
||||
npm run audit:runtime
|
||||
npm run security:repo
|
||||
```
|
||||
|
||||
正式上线前,三套 H5 严格发布证据、真实 Auth/RLS、真实 provider 抽样、对象存储控制、支付对账、PostgreSQL 严格调参证据和真实数据压测都要写入本地 `docs/refactor/production-launch-evidence.json`,再运行:
|
||||
正式上线前,三套 H5 严格发布证据、真实 Auth/RLS、真实 provider 抽样、对象存储控制、支付对账、PostgreSQL 严格调参证据、`security:repo` 和真实数据压测都要写入本地 `docs/refactor/production-launch-evidence.json`,再运行:
|
||||
|
||||
```bash
|
||||
npm run launch:gate -- --evidence docs/refactor/production-launch-evidence.json
|
||||
|
||||
@@ -78,6 +78,7 @@ npm run smoke:launch-persona
|
||||
npm run test:readiness
|
||||
npm run test:rls
|
||||
npm run audit:runtime
|
||||
npm run security:repo
|
||||
npm run check:api
|
||||
npm run check:worker
|
||||
npm run check:taro
|
||||
@@ -87,7 +88,7 @@ node scripts/taro-h5-release-guardrails-test.js --require-dist
|
||||
npm run smoke:launch-persona
|
||||
```
|
||||
|
||||
`smoke:taro:h5` 会启动临时静态服务器和 mock API,验证三套 H5 的 `index.html`、JS/CSS 资源、history fallback、公开 runtime config 和 `/api/tenant/resolve` 契约。`smoke:taro:h5:interaction` 会在真实 Chrome/Edge 中点击学生、租户后台、平台后台关键路径,覆盖静态烟测发现不了的 JS 运行时、直接 history 路由刷新和 Taro 点击事件问题;当前脚本覆盖 25 项检查,包括学生首页、题库、答题、收藏、错题/收藏复习、背单词、知识手册、资料短签名和水印、视频播放授权、分数线、AI 择校、消息中心,租户后台六个主模块,以及平台后台四个主模块。`taro-h5-release-guardrails-test` 会扫描源码、三套 H5 产物和 runtime-config 边界,防止旧 PocketBase、`x-user-id`、`x-platform-admin-key`、数据库连接串和服务端密钥形态进入前端发布目录。若刚构建完但未放入真实 `runtime-config.json`,脚本允许 warning;正式部署目录必须补齐。
|
||||
`smoke:taro:h5` 会启动临时静态服务器和 mock API,验证三套 H5 的 `index.html`、JS/CSS 资源、history fallback、公开 runtime config 和 `/api/tenant/resolve` 契约。`smoke:taro:h5:interaction` 会在真实 Chrome/Edge 中点击学生、租户后台、平台后台关键路径,覆盖静态烟测发现不了的 JS 运行时、直接 history 路由刷新和 Taro 点击事件问题;当前脚本覆盖 26 项检查,包括学生首页、题库、答题、收藏、错题/收藏复习、背单词、知识手册、资料短签名和水印、视频播放授权、分数线、AI 择校、消息中心、会员收银台下单/支付参数/订单状态,租户后台六个主模块,以及平台后台四个主模块。`taro-h5-release-guardrails-test` 会扫描源码、三套 H5 产物和 runtime-config 边界,防止旧 PocketBase、`x-user-id`、`x-platform-admin-key`、数据库连接串和服务端密钥形态进入前端发布目录。若刚构建完但未放入真实 `runtime-config.json`,脚本允许 warning;正式部署目录必须补齐。
|
||||
|
||||
写入生产上线证据时,三套正式发布目录必须先放入真实公开 `runtime-config.json`,再运行严格模式:
|
||||
|
||||
@@ -97,7 +98,7 @@ npm --silent run smoke:taro:h5:interaction -- --json > docs/refactor/launch-arti
|
||||
node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config --json > docs/refactor/launch-artifacts/taro-h5-release-guardrails.json
|
||||
```
|
||||
|
||||
如 `@codex-security` 插件在当前 Codex 环境暴露扫描工具,再补插件扫描结果。若工具不可用,不能把该项标记为已完成,只能在上线证据里标记为待补。
|
||||
`security:repo` 是仓库自带的静态安全扫描,会拦截密钥形态、前端旧鉴权头、真实 runtime-config 和生产证据误入 Git。它不能替代真实 `@codex-security`;如插件在当前 Codex 环境暴露扫描工具,再补插件扫描结果。若工具不可用,不能把该项标记为已完成,只能在上线证据里标记为待补。
|
||||
|
||||
生产 API 推荐:
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
"check:refactor": "npm run check:api && npm run check:worker && npm run check:importer && npm run pb:import:validate && npm run test:readiness && npm run test:pb:dry-run && npm run test:api",
|
||||
"audit:runtime": "npm audit --omit=dev --audit-level=high",
|
||||
"audit:taro:toolchain": "npm audit --workspace @tiku-saas/taro --audit-level=high",
|
||||
"security:repo": "node scripts/repo-security-scan.js",
|
||||
"docker:api:build": "docker compose -f docker-compose.api.yml build",
|
||||
"docker:api:up": "docker compose -f docker-compose.api.yml up api",
|
||||
"docker:api:down": "docker compose -f docker-compose.api.yml down",
|
||||
@@ -57,7 +58,7 @@
|
||||
"test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js",
|
||||
"test:worker:student-supervision": "npm run db:smoke-seed && npm run build:worker && node scripts/student-supervision-worker-integration-test.js",
|
||||
"test:rls": "npm run db:smoke-seed && node scripts/rls-tenant-isolation-test.js",
|
||||
"test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js && node --import tsx scripts/taro-api-auth-mode-test.js && node scripts/taro-student-product-guardrails-test.js && node scripts/taro-route-contract-test.js && node scripts/taro-api-contract-test.js && node scripts/taro-persona-contract-test.js && node scripts/taro-h5-release-guardrails-test.js && node --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: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/repo-security-scan-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",
|
||||
|
||||
@@ -153,6 +153,29 @@ assert.ok(
|
||||
'slow API benchmark should be reported as a blocker',
|
||||
);
|
||||
|
||||
const missingMixedBenchmark = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
evidence.checks = evidence.checks.filter(item => item.id !== 'performance.api-real-data-mixed');
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(missingMixedBenchmark.status, 0, 'missing mixed read/write benchmark should fail launch gate');
|
||||
assert.ok(
|
||||
missingMixedBenchmark.payload.checks?.some(item => item.id === 'check.performance.api-real-data-mixed' && item.status === 'blocker'),
|
||||
'missing mixed benchmark should be reported as a blocker',
|
||||
);
|
||||
|
||||
const slowMixedBenchmark = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
const item = evidence.checks.find(check => check.id === 'performance.api-real-data-mixed');
|
||||
item.summary.p95Ms = 501;
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(slowMixedBenchmark.status, 0, 'slow mixed API benchmark should fail launch gate');
|
||||
assert.ok(
|
||||
slowMixedBenchmark.payload.checks?.some(item => item.id === 'check.performance.api-real-data-mixed.summary' && item.status === 'blocker'),
|
||||
'slow mixed benchmark should be reported as a blocker',
|
||||
);
|
||||
|
||||
const missingBusinessSampling = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
evidence.checks = evidence.checks.filter(item => item.id !== 'migration.pb-import-sample');
|
||||
@@ -186,6 +209,17 @@ assert.ok(
|
||||
'missing H5 interaction smoke should be reported as a blocker',
|
||||
);
|
||||
|
||||
const missingRepoSecurityScan = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
evidence.checks = evidence.checks.filter(item => item.id !== 'security.repo-scan');
|
||||
return evidence;
|
||||
});
|
||||
assert.notEqual(missingRepoSecurityScan.status, 0, 'missing repository security scan should fail launch gate');
|
||||
assert.ok(
|
||||
missingRepoSecurityScan.payload.checks?.some(item => item.id === 'check.security.repo-scan' && item.status === 'blocker'),
|
||||
'missing repository security scan should be reported as a blocker',
|
||||
);
|
||||
|
||||
const missingAttestation = runGate(tempDir => {
|
||||
const evidence = createEvidence(tempDir);
|
||||
evidence.attestations = evidence.attestations.filter(item => item.id !== 'backup.snapshot');
|
||||
|
||||
@@ -82,6 +82,20 @@ const gateChecks = [
|
||||
includeWrites: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'performance.api-real-data-mixed',
|
||||
label: 'Real-data API mixed read/write benchmark',
|
||||
commandIncludes: 'perf:api:local',
|
||||
summary: {
|
||||
errors: 0,
|
||||
errorRate: { lte: 0.001 },
|
||||
p95Ms: { lte: 500 },
|
||||
p99Ms: { lte: 1200 },
|
||||
concurrency: { gte: 50 },
|
||||
durationSeconds: { gte: 60 },
|
||||
includeWrites: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'api.integration',
|
||||
label: 'API integration regression',
|
||||
@@ -152,10 +166,12 @@ const gateChecks = [
|
||||
commandIncludes: 'smoke:taro:h5:interaction',
|
||||
summary: {
|
||||
fail: 0,
|
||||
pass: { gte: 10 },
|
||||
pass: { gte: 26 },
|
||||
'mockApi.keyRequests.answers': { gte: 1 },
|
||||
'mockApi.keyRequests.favorites': { gte: 1 },
|
||||
'mockApi.keyRequests.tenantResolve': { gte: 3 },
|
||||
'mockApi.keyRequests.ordersCreated': { gte: 1 },
|
||||
'mockApi.keyRequests.paymentsCreated': { gte: 1 },
|
||||
'mockApi.keyRequests.orderStatus': { gte: 1 },
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -173,6 +189,12 @@ const gateChecks = [
|
||||
commandIncludes: 'audit:runtime',
|
||||
summary: { critical: 0, high: 0 },
|
||||
},
|
||||
{
|
||||
id: 'security.repo-scan',
|
||||
label: 'Repository static security scan',
|
||||
commandIncludes: 'security:repo',
|
||||
summary: { critical: 0, high: 0 },
|
||||
},
|
||||
{
|
||||
id: 'security.codex-scan',
|
||||
label: 'Codex Security scan',
|
||||
|
||||
59
scripts/repo-security-scan-test.js
Normal file
59
scripts/repo-security-scan-test.js
Normal file
@@ -0,0 +1,59 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
const scriptPath = path.join(repoRoot, 'scripts', 'repo-security-scan.js');
|
||||
|
||||
function run(cwd) {
|
||||
return spawnSync(process.execPath, [scriptPath, '--json'], {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
env: {
|
||||
PATH: process.env.PATH || '',
|
||||
Path: process.env.Path || '',
|
||||
SystemRoot: process.env.SystemRoot || '',
|
||||
ComSpec: process.env.ComSpec || '',
|
||||
TEMP: process.env.TEMP || os.tmpdir(),
|
||||
TMP: process.env.TMP || os.tmpdir(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const clean = run(repoRoot);
|
||||
assert.equal(clean.status, 0, `current repository should pass security scan: ${clean.stdout} ${clean.stderr}`);
|
||||
const cleanPayload = JSON.parse(clean.stdout);
|
||||
assert.equal(cleanPayload.summary.critical, 0);
|
||||
assert.equal(cleanPayload.summary.high, 0);
|
||||
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-security-scan-'));
|
||||
try {
|
||||
fs.mkdirSync(path.join(tempDir, 'apps', 'taro', 'src'), { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'apps', 'taro', 'src', 'unsafe.ts'),
|
||||
"export const headers = { 'x-user-id': '123' };\n",
|
||||
'utf8',
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(tempDir, 'secret.env'),
|
||||
[
|
||||
'DATABASE_URL=postgresql://postgres:real-password@db.example.com:5432/postgres',
|
||||
'SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.fake-service-role-token-that-should-not-ship',
|
||||
'',
|
||||
].join('\n'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
const unsafe = run(tempDir);
|
||||
assert.notEqual(unsafe.status, 0, 'unsafe fixture should fail security scan');
|
||||
const payload = JSON.parse(unsafe.stdout);
|
||||
assert.ok(payload.findings.some(item => item.id === 'frontend-legacy-user-header'), 'x-user-id should be detected');
|
||||
assert.ok(payload.findings.some(item => item.id === 'postgres-url'), 'database URL should be detected');
|
||||
assert.ok(payload.findings.some(item => item.id === 'supabase-service-role'), 'service role key should be detected');
|
||||
} finally {
|
||||
fs.rmSync(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
console.log('[PASS] repository security scan');
|
||||
261
scripts/repo-security-scan.js
Normal file
261
scripts/repo-security-scan.js
Normal file
@@ -0,0 +1,261 @@
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const repoRoot = process.cwd();
|
||||
|
||||
const textExtensions = new Set([
|
||||
'.js',
|
||||
'.jsx',
|
||||
'.ts',
|
||||
'.tsx',
|
||||
'.json',
|
||||
'.md',
|
||||
'.sql',
|
||||
'.toml',
|
||||
'.yml',
|
||||
'.yaml',
|
||||
'.html',
|
||||
'.css',
|
||||
'.scss',
|
||||
'.env',
|
||||
'.example',
|
||||
]);
|
||||
|
||||
const ignoredSegments = new Set([
|
||||
'.git',
|
||||
'node_modules',
|
||||
'dist',
|
||||
'.swc',
|
||||
'.temp',
|
||||
'.branches',
|
||||
'launch-artifacts',
|
||||
'performance-reports',
|
||||
'migration-reports',
|
||||
'pb_export',
|
||||
'pb_data',
|
||||
'pb_public',
|
||||
'参考',
|
||||
'新UI参考',
|
||||
'sao_live_0630',
|
||||
'whisper_models',
|
||||
]);
|
||||
|
||||
const allowlistedFiles = new Set([
|
||||
'.env.example',
|
||||
'README.md',
|
||||
'docs/refactor/ai-development-guardrails.md',
|
||||
'docs/refactor/api-structure.md',
|
||||
'docs/refactor/auth-payment-provider-plan.md',
|
||||
'docs/refactor/frontend-handoff-index.md',
|
||||
'docs/refactor/multitenant-auth-security-contract.md',
|
||||
'docs/refactor/object-storage-production-runbook.md',
|
||||
'docs/refactor/object-storage.md',
|
||||
'docs/refactor/production-launch-evidence.template.json',
|
||||
'docs/refactor/supabase-frontend-access-strategy.md',
|
||||
'docs/refactor/taro-frontend-integration.md',
|
||||
'docs/refactor/taro-h5-deployment.md',
|
||||
'docs/refactor/taro-production-integration-checklist.md',
|
||||
'docs/refactor/web-launch-acceptance-checklist.md',
|
||||
'scripts/production-readiness-check-test.js',
|
||||
'scripts/repo-security-scan.js',
|
||||
'scripts/taro-h5-release-guardrails-test.js',
|
||||
]);
|
||||
|
||||
const ruleAllowlistedFiles = {
|
||||
'postgres-url': new Set([
|
||||
'scripts/production-config-failfast-test.js',
|
||||
'scripts/repo-security-scan-test.js',
|
||||
]),
|
||||
'supabase-service-role': new Set([
|
||||
'scripts/repo-security-scan-test.js',
|
||||
'scripts/taro-runtime-config-test.js',
|
||||
]),
|
||||
'frontend-legacy-user-header': new Set([
|
||||
'scripts/repo-security-scan-test.js',
|
||||
]),
|
||||
};
|
||||
|
||||
const rules = [
|
||||
{
|
||||
id: 'postgres-url',
|
||||
severity: 'high',
|
||||
pattern: /postgres(?:ql)?:\/\/[^\s"'`<>]+/i,
|
||||
message: 'Database connection strings must not be committed outside approved examples/docs.',
|
||||
validate: (match) => {
|
||||
try {
|
||||
const url = new URL(match[0]);
|
||||
return !['127.0.0.1', 'localhost', 'host.docker.internal'].includes(url.hostname);
|
||||
} catch {
|
||||
return true;
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'private-key',
|
||||
severity: 'critical',
|
||||
pattern: /-----BEGIN [A-Z ]*PRIVATE KEY-----/i,
|
||||
message: 'Private keys must not be committed.',
|
||||
},
|
||||
{
|
||||
id: 'supabase-service-role',
|
||||
severity: 'critical',
|
||||
pattern: /\b(?:SUPABASE_SERVICE_ROLE_KEY|SUPABASE_SECRET_KEY)\s*[:=]\s*["']?([A-Za-z0-9._-]{20,})/i,
|
||||
message: 'Supabase service role or secret keys must never enter the repository.',
|
||||
validate: (match) => {
|
||||
const value = String(match[1] || '');
|
||||
return /^eyJ/.test(value) || /^sb_secret_/.test(value) || value.length >= 80;
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'provider-secret-token',
|
||||
severity: 'critical',
|
||||
pattern: /\bsk_(?:live|test)_[A-Za-z0-9]{16,}\b/,
|
||||
message: 'Provider secret keys must not be committed.',
|
||||
},
|
||||
{
|
||||
id: 'cloud-access-key',
|
||||
severity: 'critical',
|
||||
pattern: /\bAKIA[0-9A-Z]{16}\b/,
|
||||
message: 'Cloud access keys must not be committed.',
|
||||
},
|
||||
{
|
||||
id: 'wechat-pay-private-key',
|
||||
severity: 'critical',
|
||||
pattern: /\b(?:WECHAT_PAY_PRIVATE_KEY|ALIPAY_APP_PRIVATE_KEY)\s*[:=]\s*["']?(?!replace-|<|xxx|your-).{20,}/i,
|
||||
message: 'Payment private keys must stay in backend secrets/KMS.',
|
||||
},
|
||||
{
|
||||
id: 'frontend-legacy-user-header',
|
||||
severity: 'high',
|
||||
pathPattern: /^apps\/taro\/src\//,
|
||||
pattern: /\bx-user-id\b/i,
|
||||
message: 'Taro source must not use x-user-id; identity comes from Supabase JWT/session.',
|
||||
},
|
||||
{
|
||||
id: 'frontend-platform-key',
|
||||
severity: 'high',
|
||||
pathPattern: /^apps\/taro\/src\//,
|
||||
pattern: /\bx-platform-admin-key\b/i,
|
||||
message: 'Taro source must not use platform admin key headers.',
|
||||
},
|
||||
{
|
||||
id: 'frontend-pocketbase',
|
||||
severity: 'high',
|
||||
pathPattern: /^apps\/taro\/src\//,
|
||||
pattern: /\bpocketbase\b/i,
|
||||
message: 'Taro source must not depend on PocketBase.',
|
||||
},
|
||||
{
|
||||
id: 'runtime-config-committed',
|
||||
severity: 'high',
|
||||
pathPattern: /(^|\/)runtime-config\.json$/,
|
||||
pattern: /./,
|
||||
message: 'Real H5 runtime-config.json files are deployment artifacts and must not be committed.',
|
||||
},
|
||||
{
|
||||
id: 'production-launch-evidence-committed',
|
||||
severity: 'high',
|
||||
pathPattern: /^docs\/refactor\/production-launch-evidence\.json$/,
|
||||
pattern: /./,
|
||||
message: 'Real production launch evidence may contain internal evidence paths and must not be committed.',
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeSlashes(value) {
|
||||
return value.replace(/\\/g, '/');
|
||||
}
|
||||
|
||||
function relative(filePath) {
|
||||
return normalizeSlashes(path.relative(repoRoot, filePath));
|
||||
}
|
||||
|
||||
function isIgnoredPath(filePath) {
|
||||
const rel = relative(filePath);
|
||||
return rel.split('/').some(segment => ignoredSegments.has(segment));
|
||||
}
|
||||
|
||||
function shouldRead(filePath) {
|
||||
if (isIgnoredPath(filePath)) return false;
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
if (textExtensions.has(ext)) return true;
|
||||
return ['Dockerfile', '.gitignore', '.dockerignore'].includes(path.basename(filePath));
|
||||
}
|
||||
|
||||
function walk(dir) {
|
||||
const result = [];
|
||||
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
||||
const fullPath = path.join(dir, entry.name);
|
||||
if (isIgnoredPath(fullPath)) continue;
|
||||
if (entry.isDirectory()) result.push(...walk(fullPath));
|
||||
else if (shouldRead(fullPath)) result.push(fullPath);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function getTrackedFiles() {
|
||||
const result = spawnSync('git', ['ls-files'], { cwd: repoRoot, encoding: 'utf8' });
|
||||
if (result.status !== 0) return null;
|
||||
return new Set(result.stdout.split(/\r?\n/).filter(Boolean).map(normalizeSlashes));
|
||||
}
|
||||
|
||||
function scanFile(filePath, trackedFiles) {
|
||||
const rel = relative(filePath);
|
||||
const text = fs.readFileSync(filePath, 'utf8');
|
||||
const findings = [];
|
||||
const isTracked = !trackedFiles || trackedFiles.has(rel);
|
||||
|
||||
for (const rule of rules) {
|
||||
if (rule.pathPattern && !rule.pathPattern.test(rel)) continue;
|
||||
if (!rule.pathPattern && allowlistedFiles.has(rel)) continue;
|
||||
if (ruleAllowlistedFiles[rule.id]?.has(rel)) continue;
|
||||
const match = text.match(rule.pattern);
|
||||
if (!match) continue;
|
||||
if (rule.validate && !rule.validate(match, { file: rel, text })) continue;
|
||||
if ((rule.id === 'runtime-config-committed' || rule.id === 'production-launch-evidence-committed') && !isTracked) continue;
|
||||
findings.push({
|
||||
id: rule.id,
|
||||
severity: rule.severity,
|
||||
file: rel,
|
||||
message: rule.message,
|
||||
});
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
|
||||
function summarize(findings) {
|
||||
return findings.reduce(
|
||||
(summary, item) => {
|
||||
summary[item.severity] = (summary[item.severity] || 0) + 1;
|
||||
return summary;
|
||||
},
|
||||
{ critical: 0, high: 0, medium: 0, low: 0 },
|
||||
);
|
||||
}
|
||||
|
||||
function main() {
|
||||
const json = process.argv.includes('--json');
|
||||
const trackedFiles = getTrackedFiles();
|
||||
const files = walk(repoRoot);
|
||||
const findings = files.flatMap(filePath => scanFile(filePath, trackedFiles));
|
||||
const summary = {
|
||||
...summarize(findings),
|
||||
scannedFiles: files.length,
|
||||
findings: findings.length,
|
||||
};
|
||||
const payload = { summary, findings };
|
||||
|
||||
if (json) {
|
||||
console.log(JSON.stringify(payload, null, 2));
|
||||
} else {
|
||||
console.log(`Repository security scan: ${summary.findings} finding(s), ${summary.scannedFiles} file(s) scanned`);
|
||||
for (const item of findings) {
|
||||
console.log(`[${item.severity.toUpperCase()}] ${item.id} ${item.file}: ${item.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (summary.critical > 0 || summary.high > 0) process.exitCode = 1;
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -1015,6 +1015,15 @@ async function runStudentJourney(browser, portal, api) {
|
||||
await waitForText(page, '会员收银台');
|
||||
checks.push({ id: 'student.profile.to_checkout', status: 'pass', detail: await currentPath(page) });
|
||||
|
||||
await clickText(page, '提交订单');
|
||||
await waitForApiRequest(api, '/api/commerce/orders', 'POST');
|
||||
await waitForApiRequest(api, '/api/commerce/payments/create', 'POST');
|
||||
await waitForText(page, '支付参数已生成');
|
||||
await waitForText(page, 'SMOKE202607010001');
|
||||
await clickText(page, '刷新状态');
|
||||
await waitForApiRequest(api, '/api/commerce/orders/status', 'GET');
|
||||
checks.push({ id: 'student.checkout.order_payment', status: 'pass', detail: '收银台下单、支付参数生成和状态刷新 API 已触发' });
|
||||
|
||||
await navigateAndExpect(page, portal.baseUrl, '/pages/student/review/index?type=wrong', '错题本');
|
||||
await waitForText(page, '开始复习');
|
||||
await waitForText(page, '这是 H5 交互烟测题目');
|
||||
@@ -1213,6 +1222,9 @@ async function main() {
|
||||
keyRequests: {
|
||||
answers: api.requests.filter(item => item.path === '/api/learning/answers').length,
|
||||
favorites: api.requests.filter(item => item.path === '/api/learning/favorites/questions').length,
|
||||
ordersCreated: api.requests.filter(item => item.path === '/api/commerce/orders' && item.method === 'POST').length,
|
||||
paymentsCreated: api.requests.filter(item => item.path === '/api/commerce/payments/create' && item.method === 'POST').length,
|
||||
orderStatus: api.requests.filter(item => item.path === '/api/commerce/orders/status' && item.method === 'GET').length,
|
||||
tenantResolve: api.requests.filter(item => item.path === '/api/tenant/resolve').length,
|
||||
},
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user