chore: harden production readiness gates

This commit is contained in:
Codex
2026-07-01 06:09:23 +08:00
parent f3f6028633
commit bb938cf6e2
13 changed files with 695 additions and 63 deletions

View File

@@ -46,14 +46,14 @@
| 学生刷题主链路 | √ 可联调 | 入口、分类、集合、顺序/随机/模考、答题、错题、收藏、报告、视频、资料、个人中心、勋章、站内通知已接 API |
| 背单词/知识手册/分数线 | √ 可联调 | 列表、学习/阅读、动态筛选、JSON/CSV/Excel 导入和 Taro 第一版页面已具备 |
| 会员/订单/优惠券/激活码 | √ 可联调 | 下单、订单详情/状态轮询、优惠券规则/核销、激活码、权益、退款状态机和对账地基已完成 |
| 国内登录/支付 provider | √ 本地可跑,待真实密钥 | 阿里云/腾讯云短信、微信小程序/网页、QQ、微信支付、支付宝均有 adapter/fake 测试;生产账号和回调域名上云后联调 |
| 国内登录/支付 provider | √ 本地可跑,待真实密钥 | 阿里云/腾讯云短信、微信小程序/网页、QQ、微信支付、支付宝均有 adapter/fake 测试;生产 readiness 已阻断不支持 provider、非 HTTPS/localhost 回调、缺少支付回调和公开配置混入密钥;生产账号和回调域名上云后联调 |
| 租户后台运营 | √ 可联调 | 学生/班级、内容导入导出、营销、优惠券、积分、勋章、CRM、分佣、销售/代理转化、财务运营、主题和角色模板已具备第一版 |
| 平台 SaaS 账务 | √ 可联调 | 套餐、订阅、订阅账单、自动计费、用量采集、超额账单 API/worker、收款、逾期催缴、外部通知和审计已具备 |
| 公共题库商业化 | √ 可联调 | 平台题库授权、单地区/全国 SaaS 范围、租户采纳、手动/自动同步、冲突处理和通知已完成基础闭环 |
| 对象存储/资料安全 | √ 可联调,待生产 AV/CDN | OSS/COS/Supabase Storage 签名、上传确认、短签名预览下载、水印 traceId、复检和安全扫描地基已完成 |
| 对象存储/资料安全 | √ 可联调,待生产 AV/CDN | OSS/COS/Supabase Storage 签名、上传确认、短签名预览下载、水印 traceId、复检和安全扫描地基已完成;生产配置会拒绝 local_dev、非 HTTPS 公开 URL、非官方阿里云 OSS endpoint、阿里云 OSS 内网直签和未接外部扫描服务 |
| PocketBase 真实数据迁移 | √ 本地跑通,待人工复核 blocker | SQLite 导出、标准化导入、校验和抽样脚本已跑通;正式切换前处理缺用户订单和缺归属手册章节 |
| Taro H5 三端前端 | √ 第一版可构建 | 学生端、租户后台、平台后台均有真实 API 页面;已补 H5 `index.html` 模板和发布产物守卫;后续继续补小程序兼容、视觉精修、状态管理、包体优化和端到端测试 |
| 生产安全/压测交付 | △ 本地真实数据压测已跑,云端待复测 | 本地 Docker/Supabase 已完成真实迁移数据 30/50/100/150 并发只读和混合读写压测;受限 API 容器复核中抓到并修复了自动勋章并发发放唯一键冲突,并补并发回归测试。最近一次只读上线门禁和 50/100/150 混合读写 0 错误,50/100 混合读写约 428-444 req/s150 并发 P95 约 574ms 进入压力区;上云后仍需执行生产 readiness、远程 Auth/RLS、4c16g 压测、PostgreSQL 调优、`security:repo`、真实 `@codex-security` 扫描和上线证据门禁。 |
| 生产安全/压测交付 | △ 本地真实数据压测已跑,云端待复测 | 本地 Docker/Supabase 已完成真实迁移数据 30/50/100/150 并发只读和混合读写压测;最新 Docker API 受限资源复核为 30 并发只读 0 错误、349.7 req/s、P95 202ms50 并发混合读写 0 错误、416.5 req/s、P95 215ms100 并发混合读写 0 错误、394.2 req/s、P95 415ms150 并发混合读写 0 错误、370.5 req/s、P95 622ms。50 worker 属于本地舒适区100 可用但延迟偏高150 是压力观察;上云后仍需执行生产 readiness、远程 Auth/RLS、4c16g 压测、PostgreSQL 调优、`security:repo`、真实 `@codex-security` 扫描和上线证据门禁。 |
更完整的进度看这些文档:
@@ -429,7 +429,7 @@ npm run readiness:production
npm run readiness:production:db
```
这个检查会阻断默认弱密钥、`CORS=*`、mock 短信、legacy 身份头、local_dev 存储、对象存储未配置、CRM insecure localhost、平台审计/催缴通知 localhost 等生产风险;带 `:db` 的版本还会检查租户 provider 公开配置是否混入密钥、活跃短信/OAuth/支付 provider 是否缺少 `app_private.tenant_secrets`、平台通知 webhook 是否为生产 HTTPS、钉钉/飞书平台通知是否缺少 `app_private.platform_secrets`、域名是否未验证。
这个检查会阻断默认弱密钥、`CORS=*`不支持或 mock 短信 provider、legacy 身份头、local_dev 存储、非 HTTPS 对象存储公开 URL、非官方阿里云 OSS endpoint、阿里云 OSS 内网直签、对象存储未配置、CRM insecure localhost、平台审计/催缴通知 localhost 等生产风险;带 `:db` 的版本还会检查租户 provider 公开配置是否混入密钥、活跃短信/OAuth/支付 provider 是否缺少 `app_private.tenant_secrets`短信/OAuth/支付公开配置是否缺少生产必填字段、OAuth redirectUri 和支付 notifyUrl 是否为生产 HTTPS、平台通知 webhook 是否为生产 HTTPS、钉钉/飞书平台通知是否缺少 `app_private.platform_secrets`、域名是否未验证。
Supabase Auth/JWKS 上云后需要用真实 access token 跑远程验收:
@@ -632,35 +632,14 @@ npm --silent run manifest:taro:h5 -- --require-dist --require-runtime-config --j
最近一次本地真实迁移库已包含前期压测写入记录,当前规模约为 74,117 题、1,601 个题目合集、3,106 个练习蓝图、3,505 个单词、2,678 条知识手册、3,690 个用户、196,846 条答题记录、38,207 条错题和 466 条权益。压测 worker 是无停顿请求流,不能直接等同于真实在线学生数;前端完成后需要用真实页面埋点估算单个学生平均 RPS再折算在线容量。
README 只保留最新 Docker API 受限资源复核。更早的高资源 Docker Desktop 历史结果和每轮说明见 `docs/refactor/performance-benchmark-summary-20260630.md`
| 并发 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 |
| 50 | 60s | 10% | 51,425 | 0.00% | 845.06 req/s | 110.39 ms | 138.79 ms |
| 100 | 60s | 8% | 51,103 | 0.00% | 838.42 req/s | 210.07 ms | 272.15 ms |
| 150 | 60s | 6% | 44,889 | 0.00% | 735.22 req/s | 357.74 ms | 469.28 ms |
| 100 | 60s | 8% | 43,379 | 0.00% | 710.38 req/s | 257.59 ms | 328.75 ms |
| 150 | 60s | 6% | 43,738 | 0.00% | 716.06 req/s | 375.03 ms | 485.60 ms |
| 100 | 60s | 8% | 45,047 | 0.00% | 737.22 req/s | 244.12 ms | 320.26 ms |
| 150 | 60s | 6% | 41,707 | 0.00% | 681.44 req/s | 387.58 ms | 510.79 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 | 120s | 8% | 84,694 | 0.00% | 699.54 req/s | 265.11 ms | 337.82 ms |
| 150 | 120s | 6% | 74,209 | 0.00% | 612.19 req/s | 446.37 ms | 579.39 ms |
| 30 | 120s | 0% | 64,382 | 0.00% | 532.97 req/s | 140.64 ms | 193.69 ms |
| 50 | 60s | 10% | 40,065 | 0.00% | 658.11 req/s | 151.63 ms | 188.07 ms |
| 100 | 60s | 8% | 37,290 | 0.00% | 610.57 req/s | 303.93 ms | 376.47 ms |
| 150 | 60s | 6% | 33,952 | 0.00% | 555.08 req/s | 490.53 ms | 639.05 ms |
| 30 | 120s | 0% | 60,326 | 0.00% | 499.27 req/s | 151.29 ms | 214.61 ms |
| 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% | 42,982 | 0.00% | 357.28 req/s | 192.25 ms | 287.93 ms |
| Docker API 2c4g / 50 | 60s | 10% | 26,086 | 0.00% | 431.78 req/s | 207.10 ms | 283.57 ms |
| Docker API 2c4g / 100 | 60s | 8% | 25,242 | 0.00% | 417.03 req/s | 383.30 ms | 464.22 ms |
| Docker API 2c4g / 150 | 60s | 6% | 24,201 | 0.00% | 398.22 req/s | 567.89 ms | 706.09 ms |
| Docker API 2c4g / 30 | 120s | 0% | 42,089 | 0.00% | 349.70 req/s | 202.20 ms | 295.16 ms |
| Docker API 2c4g / 50 | 60s | 10% | 25,157 | 0.00% | 416.54 req/s | 215.00 ms | 292.56 ms |
| Docker API 2c4g / 100 | 60s | 8% | 23,873 | 0.00% | 394.18 req/s | 414.67 ms | 495.91 ms |
| Docker API 2c4g / 150 | 60s | 6% | 22,523 | 0.00% | 370.53 req/s | 621.55 ms | 750.70 ms |
只读上线门禁继续要求 `includeWrites=false`;混合读写报告需要显式使用 `--allow-writes` 做人工容量观察,例如:
@@ -670,7 +649,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 05:05 受限 API 容器复核中30 worker/120 秒只读上线门禁为 42,982 请求、0 错误、357.28 req/s、P95 192.25ms50 worker/60 秒/10% 写入为 26,086 请求、0 错误、431.78 req/s、P95 207.10ms100 worker/60 秒/8% 写入为 25,242 请求、0 错误、417.03 req/s、P95 383.30ms150 worker/60 秒/6% 写入为 24,201 请求、0 错误、398.22 req/s、P95 567.89ms属于压力区。该受限容器系列压测曾抓到自动勋章并发发放唯一键冲突,已修复并新增并发回归测试。按单学生 0.05 到 0.2 req/s 的页面节奏粗略折算,当前受限 API 容器舒适观察区间约对应 2,100 到 8,600 名活跃在线学生的请求吞吐;保守按生产首版 30% 到 50% 预留容量时,可先规划约 630 到 4,300 名活跃在线学生,等云端 4 核 16G 复测和真实前端埋点后再上调。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要和剩余功能清单见:
本地结论:当前 Docker Desktop 分配 20 CPU、约 62.7GB 内存,高于常见 4 核 16G 云服务器,不能直接作为生产 SLA。2026-07-01 05:57 受限 API 容器复核中30 worker/120 秒只读上线门禁为 42,089 请求、0 错误、349.70 req/s、P95 202.20ms50 worker/60 秒/10% 写入为 25,157 请求、0 错误、416.54 req/s、P95 215.00ms100 worker/60 秒/8% 写入为 23,873 请求、0 错误、394.18 req/s、P95 414.67ms150 worker/60 秒/6% 写入为 22,523 请求、0 错误、370.53 req/s、P95 621.55ms属于压力区。按单学生 0.05 到 0.2 req/s 的页面节奏粗略折算,当前受限 API 容器舒适观察区间约对应 2,080 到 8,330 名活跃在线学生的请求吞吐;保守按生产首版 30% 到 50% 预留容量时,可先规划约 620 到 4,160 名活跃在线学生,等云端 4 核 16G 复测和真实前端埋点后再上调。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要和剩余功能清单见:
```text
docs/refactor/performance-benchmark-summary-20260630.md
@@ -776,7 +755,7 @@ git diff --check
优先继续补:
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收。
1. 真实云端 Auth/JWKS 回归、RLS 深测和生产环境配置验收;生产 `.env` 和数据库 provider 配置必须先通过 `readiness:production``readiness:production:db`,再开放前端联调域名
2. 继续补 Taro 前端:学生端小程序公式真机验收、题图资源后台字段化、小程序支付与分享,租户后台更细导入体验/数据范围 UI/主题素材库/财务复核细节,平台后台在线收款、审计报表增强、审计告警通知升级策略、催缴通知操作台细节和小程序兼容验证。
3. 对象存储真实 AV/内容安全扫描服务联调、CDN 防盗链、转码/CDN 级水印和生命周期策略。
4. 题库导出模板精排、导出操作台、导入字段映射 UI 和复检结果操作台;继续对真实迁移数据做题目、订单、权益、错题、资料和视频抽样验收。

View File

@@ -53,6 +53,8 @@ const DEFAULT_PLATFORM_ADMIN_API_KEY = 'local-platform-admin-key';
const DEFAULT_MAX_JSON_BODY_BYTES = 1024 * 1024;
const DEFAULT_MAX_IMPORT_JSON_BODY_BYTES = 10 * 1024 * 1024;
const HARD_MAX_JSON_BODY_BYTES = 50 * 1024 * 1024;
const PRODUCTION_SMS_PROVIDERS = new Set(['aliyun', 'aliyun-sms', 'aliyun_sms', 'tencent', 'tencent-sms', 'tencent_sms']);
const PRODUCTION_STORAGE_PROVIDERS = new Set(['aliyun_oss', 'tencent_cos', 'supabase_storage']);
function boundedBytes(key: string, fallback: number, hardMax = HARD_MAX_JSON_BODY_BYTES) {
const value = envNumber(key, fallback);
@@ -71,12 +73,37 @@ function isUnsafeSecret(value: string, defaultValue: string) {
);
}
function hostFromUrl(value: string) {
try {
return new URL(value).hostname.toLowerCase();
} catch {
return '';
}
}
function isLocalHost(hostname: string) {
return ['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(hostname);
}
function isHttpsProductionUrl(value: string) {
if (!value.trim()) return false;
const host = hostFromUrl(value);
return value.startsWith('https://') && Boolean(host) && !isLocalHost(host);
}
function isAllowedHost(value: string, allowedHosts: string[]) {
const host = hostFromUrl(value);
return allowedHosts.some(allowed => host === allowed || host.endsWith(`.${allowed}`));
}
function validateProductionConfig(nextConfig: ApiConfig) {
if (!nextConfig.isProduction) return;
const failures: string[] = [];
if (nextConfig.corsOrigins.includes('*')) failures.push('CORS_ORIGIN must not include * in production');
if (nextConfig.authSmsProvider === 'mock') failures.push('AUTH_SMS_PROVIDER=mock is not allowed in production');
if (!PRODUCTION_SMS_PROVIDERS.has(nextConfig.authSmsProvider.trim().toLowerCase())) {
failures.push('AUTH_SMS_PROVIDER must be aliyun/aliyun-sms or tencent/tencent-sms in production');
}
if (isUnsafeSecret(nextConfig.authCodePepper, DEFAULT_AUTH_CODE_PEPPER)) {
failures.push('AUTH_CODE_PEPPER must be a strong production secret');
}
@@ -100,6 +127,8 @@ function validateProductionConfig(nextConfig: ApiConfig) {
}
if (nextConfig.storageDefaultProvider === 'local_dev') {
failures.push('STORAGE_DEFAULT_PROVIDER=local_dev is not allowed in production');
} else if (!PRODUCTION_STORAGE_PROVIDERS.has(nextConfig.storageDefaultProvider)) {
failures.push('STORAGE_DEFAULT_PROVIDER must be aliyun_oss, tencent_cos or supabase_storage in production');
}
if (!nextConfig.storageDefaultBucket.trim()) {
failures.push('STORAGE_DEFAULT_BUCKET is required in production');
@@ -107,13 +136,22 @@ function validateProductionConfig(nextConfig: ApiConfig) {
if (!nextConfig.storageRequireTenantPrefix) {
failures.push('STORAGE_REQUIRE_TENANT_PREFIX=false is not allowed in production');
}
if (nextConfig.storagePublicBaseUrl && !isHttpsProductionUrl(nextConfig.storagePublicBaseUrl)) {
failures.push('STORAGE_PUBLIC_BASE_URL must be a production HTTPS URL when configured');
}
if (nextConfig.storageDefaultProvider === 'aliyun_oss') {
if (nextConfig.aliyunOssInternal) {
failures.push('ALIYUN_OSS_INTERNAL=true is not allowed for user-facing production signed URLs');
}
if (!nextConfig.aliyunOssAccessKeyId || !nextConfig.aliyunOssAccessKeySecret) {
failures.push('ALIYUN_OSS_ACCESS_KEY_ID and ALIYUN_OSS_ACCESS_KEY_SECRET are required for aliyun_oss');
}
if (!nextConfig.aliyunOssRegion && !nextConfig.aliyunOssEndpoint) {
failures.push('ALIYUN_OSS_REGION or ALIYUN_OSS_ENDPOINT is required for aliyun_oss');
}
if (nextConfig.aliyunOssEndpoint && (!isHttpsProductionUrl(nextConfig.aliyunOssEndpoint) || !isAllowedHost(nextConfig.aliyunOssEndpoint, ['aliyuncs.com']))) {
failures.push('ALIYUN_OSS_ENDPOINT must be a production HTTPS aliyuncs.com endpoint');
}
}
if (nextConfig.storageDefaultProvider === 'tencent_cos') {
if (!nextConfig.tencentCosRegion || !nextConfig.tencentCosAppId || !nextConfig.tencentCosSecretId || !nextConfig.tencentCosSecretKey) {
@@ -124,6 +162,9 @@ function validateProductionConfig(nextConfig: ApiConfig) {
if (!nextConfig.supabaseStorageUrl || !nextConfig.supabaseStorageServiceKey) {
failures.push('SUPABASE_STORAGE_URL and SUPABASE_STORAGE_SERVICE_KEY are required for supabase_storage');
}
if (nextConfig.supabaseStorageUrl && !isHttpsProductionUrl(nextConfig.supabaseStorageUrl)) {
failures.push('SUPABASE_STORAGE_URL must be a production HTTPS URL');
}
}
if (failures.length > 0) {

View File

@@ -102,6 +102,25 @@ function isLocalHost(hostname: string) {
return ['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(hostname);
}
function isHttpsProductionUrl(value: string) {
if (!value.trim()) return false;
try {
const url = new URL(value);
return url.protocol === 'https:' && !isLocalHost(url.hostname.toLowerCase());
} catch {
return false;
}
}
function isAllowedHost(value: string, allowedHosts: string[]) {
try {
const hostname = new URL(value).hostname.toLowerCase();
return allowedHosts.some(host => hostname === host || hostname.endsWith(`.${host}`));
} catch {
return false;
}
}
function isUnsafeSecret(value: string) {
const normalized = value.trim().toLowerCase();
return (
@@ -121,6 +140,8 @@ function validateProductionConfig(nextConfig: WorkerConfig) {
const failures: string[] = [];
if (nextConfig.storageDefaultProvider === 'local_dev') {
failures.push('STORAGE_DEFAULT_PROVIDER=local_dev is not allowed in production workers');
} else if (!['aliyun_oss', 'tencent_cos', 'supabase_storage'].includes(nextConfig.storageDefaultProvider)) {
failures.push('STORAGE_DEFAULT_PROVIDER must be aliyun_oss, tencent_cos or supabase_storage in production workers');
}
if (!nextConfig.storageDefaultBucket.trim()) {
failures.push('STORAGE_DEFAULT_BUCKET is required in production workers');
@@ -128,6 +149,9 @@ function validateProductionConfig(nextConfig: WorkerConfig) {
if (!nextConfig.storageRequireTenantPrefix) {
failures.push('STORAGE_REQUIRE_TENANT_PREFIX=false is not allowed in production workers');
}
if (nextConfig.storagePublicBaseUrl && !isHttpsProductionUrl(nextConfig.storagePublicBaseUrl)) {
failures.push('STORAGE_PUBLIC_BASE_URL must be a production HTTPS URL when configured');
}
if (nextConfig.assetSecurityScanFailOpen) {
failures.push('WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=true is not allowed in production workers');
}
@@ -158,12 +182,18 @@ function validateProductionConfig(nextConfig: WorkerConfig) {
}
}
if (nextConfig.storageDefaultProvider === 'aliyun_oss') {
if (nextConfig.aliyunOssInternal) {
failures.push('ALIYUN_OSS_INTERNAL=true is not allowed for user-facing production signed URLs');
}
if (!nextConfig.aliyunOssAccessKeyId || !nextConfig.aliyunOssAccessKeySecret) {
failures.push('ALIYUN_OSS_ACCESS_KEY_ID and ALIYUN_OSS_ACCESS_KEY_SECRET are required for aliyun_oss');
}
if (!nextConfig.aliyunOssRegion && !nextConfig.aliyunOssEndpoint) {
failures.push('ALIYUN_OSS_REGION or ALIYUN_OSS_ENDPOINT is required for aliyun_oss');
}
if (nextConfig.aliyunOssEndpoint && (!isHttpsProductionUrl(nextConfig.aliyunOssEndpoint) || !isAllowedHost(nextConfig.aliyunOssEndpoint, ['aliyuncs.com']))) {
failures.push('ALIYUN_OSS_ENDPOINT must be a production HTTPS aliyuncs.com endpoint');
}
}
if (nextConfig.storageDefaultProvider === 'tencent_cos') {
if (!nextConfig.tencentCosRegion || !nextConfig.tencentCosAppId || !nextConfig.tencentCosSecretId || !nextConfig.tencentCosSecretKey) {
@@ -174,6 +204,9 @@ function validateProductionConfig(nextConfig: WorkerConfig) {
if (!nextConfig.supabaseStorageUrl || !nextConfig.supabaseStorageServiceKey) {
failures.push('SUPABASE_STORAGE_URL and SUPABASE_STORAGE_SERVICE_KEY are required for supabase_storage');
}
if (nextConfig.supabaseStorageUrl && !isHttpsProductionUrl(nextConfig.supabaseStorageUrl)) {
failures.push('SUPABASE_STORAGE_URL must be a production HTTPS URL');
}
}
if (failures.length > 0) {

View File

@@ -45,7 +45,7 @@ Supabase Auth 可继续作为最终 JWT 用户体系目标;本地重构期先
## 短信 Provider
本地默认是 `AUTH_SMS_PROVIDER=mock`,仅开发环境返回 `debugCode`。生产环境如果仍为 mock会直接拒绝发送
本地默认是 `AUTH_SMS_PROVIDER=mock`,仅开发环境返回 `debugCode`。生产环境只允许 `aliyun/aliyun-sms``tencent/tencent-sms`;如果仍为 mock 或写成未知 providerAPI 启动和 `readiness:production` 都会直接失败
真实 provider
@@ -58,6 +58,7 @@ Supabase Auth 可继续作为最终 JWT 用户体系目标;本地重构期先
- 租户级密钥写 `app_private.tenant_secrets(secret_scope='sms')` 或生产 Vault。
- 前端只能看到 provider 是否启用、签名展示名、隐私协议链接等非敏感配置。
- provider endpoint 默认使用官方域名,生产环境只允许 HTTPS 官方域名;本地测试可使用 `localhost/127.0.0.1` fake server。
- `readiness:production:db` 会检查 active/testing 短信 provider 的 `signName/templateCode``smsSdkAppId/signName/templateId`,并阻断公开配置里的 secret-like 字段。
### 阿里云短信配置示例
@@ -196,6 +197,7 @@ body: { "code": "<qq oauth code>", "redirectUri": "https://h5.example.com/auth/q
- `endpoint``userInfoEndpoint``openIdEndpoint` 默认使用官方 HTTPS 域名;生产环境不允许本地 HTTP fake endpoint。
- `appSecret/clientSecret/AppKey` 必须存入 `app_private.tenant_secrets(secret_scope='oauth')`,不能进入 `configPublic`
- QQ 的 `redirectUri` 必须与 QQ 互联后台登记的一致;多租户自定义域名上线前需要逐个配置或设计统一授权中转域名。
- `readiness:production:db` 会阻断 OAuth endpoint/redirectUri 为 HTTP、localhost 或非官方 provider endpoint也会阻断 active/testing provider 缺少对应私密 `tenant_secrets`
## 手机号绑定/换绑
@@ -282,6 +284,7 @@ body: { "code": "<qq oauth code>", "redirectUri": "https://h5.example.com/auth/q
- 订单已支付时重复通知只返回幂等成功,不重复开通权益。
- 支付成功事务内更新 `orders``payments``payment_events``entitlements`
- 支付密钥只允许放在 `app_private.tenant_secrets` 或生产 KMS/Vault。
- `readiness:production:db` 会阻断微信支付缺 `appId/merchantId/merchantSerialNo/notifyUrl`、支付宝缺 `appId/notifyUrl`、notifyUrl/returnUrl/退款回调为 HTTP 或 localhost、provider endpoint 非官方域名、公开配置混入 `privateKey/apiV3Key/secret/token` 等密钥字段。
### 资金对账

View File

@@ -1,6 +1,6 @@
# 后端当前能力盘点
更新时间2026-06-30
更新时间2026-07-01
当前后端已经完成商用 SaaS 题库系统的主干骨架PostgreSQL 多租户 schema、Node.js 业务 API、PocketBase 数据导入工具、本地 seed、API 集成测试和对象存储签名 provider。
@@ -18,6 +18,7 @@
| RLS/租户隔离 | 可联调 | 表层普遍有 `tenant_id` 和 RLS 策略API 已支持 `tk_` 迁移 session 与 Supabase Auth JWT 双入口,并覆盖跨租户/伪造身份集成测试;生产前继续补真实云端 JWT/RLS 回归 |
| API 分层 | 可联调 | `apps/api/src/core` + `apps/api/src/features/*` |
| Docker API | 可联调 | `docker-compose.api.yml``apps/api/Dockerfile` 可用 |
| 生产配置门禁 | 可联调 | `readiness:production`/`:db` 和 API/worker 启动 fail-fast 会阻断弱密钥、mock/未知短信 provider、legacy 身份头、local_dev/未知存储、非 HTTPS 存储公开 URL、非官方阿里云 OSS endpoint、阿里云 OSS 内网直签、未接外部资源扫描、provider 公开配置混入密钥、OAuth/支付回调非 HTTPS 或缺必填字段 |
| 测试 | 可联调 | `npm run check:refactor` 覆盖 TS 检查、导入校验、生产 readiness 脚本测试、seed、API 集成测试;排行榜已覆盖默认关闭、四类指标、班级范围和跨租户拒绝 |
| 根 workspace | 可联调 | 根目录已清理为新技术栈 monorepo 编排层 |
@@ -193,6 +194,7 @@
```bash
npx supabase db reset
npm run audit:runtime
npm run security:repo
npm run check:refactor
npm run check:taro
npm run build:taro:h5:student
@@ -212,9 +214,13 @@ npm run test:worker:assets
npm run test:worker:exports
npm run test:worker:public-banks
npm run test:worker:student-supervision
npm run smoke:launch-persona
npm run perf:api:local
git diff --check
```
当前产品边界:学生头像只支持男女预设,不做上传、裁剪或第三方头像同步;排行榜后端具备可选能力,但默认关闭,学生端默认不请求,只有租户显式购买/开启活动并完成专项压测后再补日/周榜预聚合和防刷。
`check:refactor` 包含:
- API TypeScript 检查

View File

@@ -23,8 +23,8 @@
1. 真实云端 Auth/JWKS、RLS 和生产配置验收:在预生产/生产执行 `readiness:production``readiness:production:db``smoke:auth:remote``test:rls` 并留档,生产关闭 `x-user-id` 和平台本地 key 兼容入口。
2. 真实旧数据人工复核:正式切换前处理 7 个已支付缺用户订单、22 个缺所属手册章节,并对题目、权益、订单、错题、资料和视频做人工抽样。
3. 真实 provider 联调:阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、微信支付、支付宝、支付/退款回调域名、官方账单格式抽样。
4. 对象存储生产安全:配置真实 OSS/COS/Supabase Storage bucket、外部 AV/内容安全扫描、CDN 防盗链、转码/CDN 级水印、生命周期策略和恢复演练。
3. 真实 provider 联调:阿里云/腾讯云短信、微信小程序登录、微信网页登录、QQ 登录、微信支付、支付宝、支付/退款回调域名、官方账单格式抽样。当前生产 readiness 已会阻断 mock/未知短信 provider、OAuth/支付回调非 HTTPS、支付缺 `notifyUrl`、公开配置混入密钥和 active provider 缺 `tenant_secrets`
4. 对象存储生产安全:配置真实 OSS/COS/Supabase Storage bucket、外部 AV/内容安全扫描、CDN 防盗链、转码/CDN 级水印、生命周期策略和恢复演练。API/worker 已会拒绝 local_dev、未知存储 provider、非 HTTPS 公开 URL 和阿里云 OSS 内网直签。
5. 生产容量复测:目标云服务器按 4 核 16G 调参文档设置 PostgreSQL再按 30/50/100 只读和混合读写矩阵复跑,生成 `production-launch-evidence.json` 并通过 `npm run launch:gate`
6. 备份、日志、告警、回滚和安全扫描完成数据库快照、恢复演练、日志告警、Codex Security 真实扫描和上线审批证据。
@@ -50,14 +50,14 @@
本轮使用 `npm run perf:api:docker-4c16g`API 容器限制为 2 CPU/4G、`DB_POOL_MAX=10`、关闭 legacy `x-user-id` 和平台本地 key压测鉴权使用 `PERF_AUTH_MODE=app_session` 的 Bearer session。数据库仍为本机 Docker Desktop Supabase/PostgreSQL未限制为 4 核 16G因此结果更接近“受限 API 容器 + 本机数据库”的预生产观察,不是最终云服务器 SLA。
压测暴露一个真实并发缺陷50 worker 混合读写中,交卷触发自动勋章发放时撞到 `user_badges_tenant_id_legacy_id_key`,产生 1 个 500。已修复为自动勋章只使用 `(tenant_id, user_id, badge_id)` 做业务幂等,不再写入自动生成 `legacy_id`;并新增 `scripts/auto-badge-concurrency-test.js`20 个并发触发同一学生同一勋章时只生成 1 条 `user_badges`
历史受限容器压测暴露一个真实并发缺陷50 worker 混合读写中,交卷触发自动勋章发放时撞到 `user_badges_tenant_id_legacy_id_key`,产生 1 个 500。已修复为自动勋章只使用 `(tenant_id, user_id, badge_id)` 做业务幂等,不再写入自动生成 `legacy_id`;并新增 `scripts/auto-badge-concurrency-test.js`20 个并发触发同一学生同一勋章时只生成 1 条 `user_badges`
| 场景 | 并发 worker | 时长 | 刷题写入比例 | 请求数 | 错误率 | 吞吐 | P95 | P99 | 结论 |
| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| 只读上线门禁 | 30 | 120s | 0% | 42,982 | 0.00% | 357.28 req/s | 192.25 ms | 287.93 ms | 通过 |
| 混合读写 | 50 | 60s | 10% | 26,086 | 0.00% | 431.78 req/s | 207.10 ms | 283.57 ms | 舒适 |
| 混合读写 | 100 | 60s | 8% | 25,242 | 0.00% | 417.03 req/s | 383.30 ms | 464.22 ms | 接近舒适区上沿 |
| 混合读写 | 150 | 60s | 6% | 24,201 | 0.00% | 398.22 req/s | 567.89 ms | 706.09 ms | 压力区 |
| 只读上线门禁 | 30 | 120s | 0% | 42,089 | 0.00% | 349.70 req/s | 202.20 ms | 295.16 ms | 通过 |
| 混合读写 | 50 | 60s | 10% | 25,157 | 0.00% | 416.54 req/s | 215.00 ms | 292.56 ms | 舒适 |
| 混合读写 | 100 | 60s | 8% | 23,873 | 0.00% | 394.18 req/s | 414.67 ms | 495.91 ms | 可用但延迟偏高 |
| 混合读写 | 150 | 60s | 6% | 22,523 | 0.00% | 370.53 req/s | 621.55 ms | 750.70 ms | 压力区 |
混合读写包含真实刷题闭环:创建练习 session、拉取 session detail、提交答案、交卷、读取报告。
@@ -69,10 +69,10 @@
按目前常见页面节奏先用 `0.05 到 0.2 req/s/人` 粗略折算:
- 50 worker 舒适场景 432 req/s约等于 2,160 到 8,640 名活跃在线学生的请求吞吐。
- 100 worker 上沿场景 417 req/s约等于 2,085 到 8,340 名活跃在线学生的请求吞吐。
- 150 worker 压力场景 398 req/s约等于 1,990 到 7,960 名活跃在线学生的请求吞吐。
- 50 worker 舒适场景 416.54 req/s约等于 2,080 到 8,330 名活跃在线学生的请求吞吐。
- 100 worker 上沿场景 394.18 req/s约等于 1,970 到 7,880 名活跃在线学生的请求吞吐。
- 150 worker 压力场景 370.53 req/s约等于 1,850 到 7,410 名活跃在线学生的请求吞吐。
当前本地结论:在受限 API 容器下,后端真实刷题读写链路的舒适观察区间约为 417 到 432 req/s折算约 2,100 到 8,600 名活跃在线学生150 worker 仍 0 错误但 P95 已接近 600ms视为压力区不建议作为生产承诺。同日本地 API 进程 30 worker/30 秒/10% 写入观察到 15,864 请求、0 错误、513.56 req/s、P95 113.97ms受限 API 容器会明显压低上沿,因此上线承诺仍以云端受限资源复测为准。
当前本地结论:在受限 API 容器下,后端真实刷题读写链路的舒适观察区间约为 416 req/s折算约 2,080 到 8,330 名活跃在线学生;100 worker 仍可用但 P95 已超过 400ms150 worker 仍 0 错误但 P95 已超过 600ms视为压力区不建议作为生产承诺。受限 API 容器会明显压低上沿,因此上线承诺仍以云端受限资源复测为准。
正式对外容量必须等 4 核 16G 云服务器部署后复跑。保守规划时,生产首版建议先按本地折算值的 30% 到 50% 做容量承诺,约 630 到 4,300 名活跃在线学生等云端压测、CDN、对象存储和真实前端埋点完成后再上调。
正式对外容量必须等 4 核 16G 云服务器部署后复跑。保守规划时,生产首版建议先按本地折算值的 30% 到 50% 做容量承诺,约 620 到 4,160 名活跃在线学生等云端压测、CDN、对象存储和真实前端埋点完成后再上调。

View File

@@ -59,6 +59,7 @@
- 已覆盖学生、租户管理员、平台管理员、错租户、坏签名、禁用 legacy header 的 API 集成测试。
- 已补自定义角色模板、菜单/模块/字段级配置 API、班级/学生范围权限;已新增 `npm run test:rls` 本地动态 RLS 深测,覆盖主租户、合作商租户、无租户 claim、平台管理员旁路和跨租户写入拒绝已新增 `npm run smoke:auth:remote` 用真实 Supabase access token 验收云端 Auth/JWKS 映射;继续在预生产/生产执行并留档。
- 前端联调时禁止继续使用 `x-user-id``x-tenant-id` 只作为租户上下文,不能作为身份依据。
- 生产 `.env` 必须通过 `readiness:production`:短信 provider 只允许阿里云/腾讯云,禁止 mock/未知值,禁止弱密钥、`CORS=*` 和 legacy 身份头。生产数据库还必须通过 `readiness:production:db`,校验租户短信/OAuth/支付 provider 的公开配置、HTTPS 回调、私密 `tenant_secrets` 和 RLS。
2. 对象存储
- 已接阿里云 OSS、腾讯云 COS、Supabase Storage 的上传/下载签名 provider。
@@ -71,7 +72,7 @@
- 已补租户后台媒体运营报表,支持资料/视频访问汇总、Top 资源/视频和 traceId 回查;大租户后续再把实时查询替换为日/周预聚合。
- 已收紧锁定资源 CDN 边界:`members/svip/private` 外链默认拒绝,必须显式 provider-managed 才允许;视频绑定资源也复用该策略。
- 已补外部 HTTP 杀毒/内容安全 scanner 接入层、失败关闭、生产 readiness 阻断和动态水印 traceId 审计;继续联调真实扫描服务、转码/CDN 级水印、CDN 刷新和对象生命周期策略。
- 已补 API/worker 生产启动 fail-fast生产环境会拒绝 `local_dev` 存储、空 bucket、关闭租户前缀worker 会拒绝未接入外部 HTTP scanner 或 fail-open。
- 已补 API/worker 生产启动 fail-fast生产环境会拒绝 `local_dev` 存储、未知存储 provider、空 bucket、关闭租户前缀、非 HTTPS `STORAGE_PUBLIC_BASE_URL`、阿里云 OSS 内网直签worker 会拒绝未接入外部 HTTP scanner 或 fail-open。
- 已补 `docs/refactor/object-storage-production-runbook.md`,真实云厂商联调必须按 runbook 抽样验收。
- `content_assets` 继续作为资源台账,不允许前端绕过台账直接访问私有资源。
@@ -94,7 +95,7 @@
- 已补 `npm run launch:gate` 生产上线证据门禁和 `docs/refactor/production-launch-evidence.template.json` 模板;最终切换前必须把 readiness、远程 Auth、RLS、PostgreSQL 4c16g 严格调参证据、生产 dry-run、导入校验、`pb:import:sample` 业务抽样、真实数据 API 只读压测、真实数据混合读写压测、API/worker/Taro、运行时审计、`security:repo`、真实 `@codex-security`、备份/回滚/真实抽样/生产 provider 等证据填入本地 `production-launch-evidence.json` 并通过门禁。当前 Codex 环境未暴露可调用的 `@codex-security` 扫描工具时,该项只能标为待补,不能伪造完成。
- 确认数据库迁移流程、备份恢复、日志、告警。
- 准备 API 容器部署和 Supabase 云端/自托管连接方案。
- 已补 `npm run perf:api:local``npm run perf:summary``npm run perf:postgres:evidence``npm run perf:postgres:sql``npm run smoke:launch-persona``npm run smoke:taro:h5``npm run smoke:taro:h5:interaction``node scripts/taro-api-contract-test.js``node scripts/taro-persona-contract-test.js``docs/refactor/performance-benchmark-runbook.md`可在本地或云端对真实迁移数据做只读门禁、混合读写容量观察、PostgreSQL 4c16g profile 调参证据、三类后端角色旅程烟测、三类 Taro 前端角色旅程契约、H5 发布目录启动烟测、真实浏览器关键点击烟测和前端 API 契约检查。2026-07-01 05:05 受限 API 容器真实迁移库复核中30 worker/120s 只读为 42,982 请求、0 错误、357.28 req/s、P95 192.25ms、P99 287.93ms50 worker/60s/10% 写入为 26,086 请求、0 错误、431.78 req/s、P95 207.10ms、P99 283.57ms100 worker/60s/8% 写入为 25,242 请求、0 错误、417.03 req/s、P95 383.30ms、P99 464.22ms150 worker/60s/6% 写入为 24,201 请求、0 错误、398.22 req/s、P95 567.89ms、P99 706.09ms。受限容器系列压测曾抓到自动勋章并发发放唯一键冲突已修复并新增 `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,600 名活跃在线学生的本机吞吐观察区间,正式容量仍以上云 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 05:57 受限 API 容器真实迁移库复核中30 worker/120s 只读为 42,089 请求、0 错误、349.70 req/s、P95 202.20ms、P99 295.16ms50 worker/60s/10% 写入为 25,157 请求、0 错误、416.54 req/s、P95 215.00ms、P99 292.56ms100 worker/60s/8% 写入为 23,873 请求、0 错误、394.18 req/s、P95 414.67ms、P99 495.91ms150 worker/60s/6% 写入为 22,523 请求、0 错误、370.53 req/s、P95 621.55ms、P99 750.70ms。受限容器系列压测曾抓到自动勋章并发发放唯一键冲突已修复并新增 `scripts/auto-badge-concurrency-test.js`。当前 Docker Desktop 给了 20 CPU/约 62.7GB 内存,但 API 容器限制为 2 CPU/4G舒适观察区暂按 50 个无停顿 worker 估算,100 worker 可用但延迟偏高,150 worker 已是压力区;按单学生 0.05 到 0.2 req/s 粗略折算约为 2,080 到 8,330 名活跃在线学生的本机吞吐观察区间,正式容量仍以上云 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`。后续不要再把学生头像上传或默认排行榜当作待办;头像只保留男女预设,排行榜仅作为租户显式开启后的活动能力。
@@ -145,7 +146,7 @@
8. 订单和营销体验
- 已完成订单详情、订单状态轮询、激活码预检查、优惠券前台领取、下单抵扣计算、优惠券复杂规则、核销明细/报表和内部退款状态机。
- 已完成支付/退款补偿 worker、官方账单下载 worker、资金对账导入比对、差错工单、异常订单运营台和人工调整凭证复核Taro 财务运营台第一版已接入;本地继续验证回调后业务链路、幂等、审计、权益开通/撤销和密钥不泄露,真实生产账号、回调域名和账单格式抽样验收上云后补齐;继续补学生端售后入口深化、营销活动 UI 和活动效果看板。
- 已完成支付/退款补偿 worker、官方账单下载 worker、资金对账导入比对、差错工单、异常订单运营台和人工调整凭证复核Taro 财务运营台第一版已接入;`readiness:production:db` 已会阻断支付公开配置缺 `notifyUrl`、回调非 HTTPS、公开配置混入密钥和 active payment 缺私密密钥。本地继续验证回调后业务链路、幂等、审计、权益开通/撤销和密钥不泄露,真实生产账号、回调域名和账单格式抽样验收上云后补齐;继续补学生端售后入口深化、营销活动 UI 和活动效果看板。
9. 积分和反馈增强
- 已完成每日签到、积分流水、反馈提交、租户后台处理、奖励积分幂等。
@@ -199,7 +200,7 @@
4. 运维
- 后台操作审计报表。
- 定时备份、恢复演练。
- 性能压测、慢 SQL、索引审查当前已有 API 压测脚本、真实刷题读写闭环和 4 核 16G runbook,本地真实迁移数据已完成 30/50/100/150 并发阶梯压测,云服务器部署后还要按同一矩阵复跑并输出正式容量报告。
- 性能压测、慢 SQL、索引审查当前已有 API 压测脚本、真实刷题读写闭环和 4 核 16G runbook。最新 Docker API 受限资源复核为 30 并发只读 0 错误、349.7 req/s、P95 202ms50 并发混合读写 0 错误、416.5 req/s、P95 215ms100 并发混合读写 0 错误、394.2 req/s、P95 415ms150 并发混合读写 0 错误、370.5 req/s、P95 622ms。云服务器部署后还要按同一矩阵复跑并输出正式容量报告。
## Taro 前端开发 TODO

View File

@@ -17,7 +17,7 @@
- `NODE_ENV=production` 下 API 和 worker 都不能使用 `STORAGE_DEFAULT_PROVIDER=local_dev`
- `STORAGE_DEFAULT_BUCKET` 必须配置。
- `STORAGE_REQUIRE_TENANT_PREFIX=true` 必须保持开启。
- 阿里云 OSS 必须配置 `ALIYUN_OSS_REGION` `ALIYUN_OSS_ENDPOINT`,以及 AccessKey。
- 阿里云 OSS 必须配置 `ALIYUN_OSS_REGION`、官方 HTTPS `aliyuncs.com` `ALIYUN_OSS_ENDPOINT`,以及 AccessKey;生产禁止 `ALIYUN_OSS_INTERNAL=true` 用于面向用户的签名 URL
- 腾讯云 COS 必须配置 `TENCENT_COS_REGION``TENCENT_COS_APP_ID``TENCENT_COS_SECRET_ID``TENCENT_COS_SECRET_KEY`
- Supabase Storage 必须配置 `SUPABASE_STORAGE_URL``SUPABASE_STORAGE_SERVICE_KEY`
- `WORKER_ASSET_SECURITY_SCANNER` 生产必须包含 `http`,例如 `metadata_rules,http`
@@ -213,4 +213,3 @@ npm --workspace @tiku-saas/worker run exports:once
- API/worker 在 `NODE_ENV=production` 下无法用 `local_dev` 启动。
- 真实云存储上传、确认、扫描、发布、预览、下载、导出全链路通过。
- 云侧 CORS、防盗链、生命周期、备份/版本控制、访问日志有截图或变更记录。

View File

@@ -115,11 +115,30 @@
按 0.05 到 0.2 req/s/人的页面节奏粗略折算50 worker 舒适吞吐约对应 2,920 到 11,680 名活跃在线学生请求吞吐100 worker 上沿约对应 2,770 到 11,080 名150 worker 压力区约对应 2,645 到 10,580 名。该数字仍是本地 Docker 观察值,正式承诺必须在目标 4 核 16G 云服务器和真实前端埋点下复跑。
### 2026-07-01 05:57 Docker API 受限资源复核
本轮在完成生产 provider readiness 加固、`npm run smoke:launch-persona``npm run smoke:taro:h5:interaction``npm run perf:postgres:evidence` 后复跑。压测入口为 `npm run perf:api:docker-4c16g`API 容器按脚本默认限制为 2 CPU/4G使用 `PERF_AUTH_MODE=app_session`,排行榜仍未纳入默认负载。
| 并发 worker | 时长 | 刷题写入比例 | 请求数 | 错误率 | 吞吐 | P95 | P99 | 结论 |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- |
| 30 | 120s | 0% | 42,089 | 0.00% | 349.70 req/s | 202.20 ms | 295.16 ms | 通过只读上线门禁 |
| 50 | 60s | 10% | 25,157 | 0.00% | 416.54 req/s | 215.00 ms | 292.56 ms | 混合读写舒适 |
| 100 | 60s | 8% | 23,873 | 0.00% | 394.18 req/s | 414.67 ms | 495.91 ms | 可用但延迟偏高 |
| 150 | 60s | 6% | 22,523 | 0.00% | 370.53 req/s | 621.55 ms | 750.70 ms | 零错误但进入压力区 |
本轮 `npm run perf:summary` 校验结果:
- 30 worker 只读门禁通过,满足 `errors=0``errorRate<=0.001``p95<=300ms``p99<=800ms``duration>=120s`
- 50 worker 混合读写满足 `P95<=500ms/P99<=1200ms`,是当前受限 API 容器下更保守的舒适区。
- 100 worker 混合读写仍 0 错误P95 约 415ms适合作为上沿观察150 worker P95 约 622ms只作为压力区观察不作为生产承诺。
按 0.05 到 0.2 req/s/人的页面节奏粗略折算50 worker 舒适吞吐约对应 2,080 到 8,330 名活跃在线学生请求吞吐100 worker 上沿约对应 1,970 到 7,880 名150 worker 压力区约对应 1,850 到 7,410 名。生产 4 核 16G 首版容量建议先按这组本地受限资源折算值的 30% 到 50% 保守规划,即约 620 到 4,160 名活跃在线学生请求吞吐,再以上云复测和真实前端埋点修正。
## 初步结论
- 本地 Docker 环境下50 worker 混合读写 P95 约 174.26ms100 worker 混合读写 P95 约 326.21ms100 worker 已接近当前本机舒适区上沿150 worker 仍然 0 错误,但 P95 约 490.38ms已经压力区。
- 按最新 584 req/s 的 50 worker 舒适吞吐粗略折算,如果未来前端真实埋点显示每名在线学生平均 0.05 到 0.2 req/s则理论请求吞吐约对应 2,920 到 11,680 名活跃在线学生;按 100 worker 上沿 554 req/s 估算约为 2,770 到 11,080 名;按 150 worker 压力区 529 req/s 估算约为 2,645 到 10,580 名。这只是吞吐换算,不是生产 SLA。
- 当前 Docker Desktop 给了 20 CPU 和约 62.7GB 内存,生产如果使用 4 核 16G 单机,首版容量承诺建议先按本地折算值的 30% 到 50% 保守规划,再以云端复测真实前端埋点上调。
- 本地高资源 Docker Desktop 历史结果显示代码和索引在 50/100 worker 下有较高吞吐;但最新受限 API 容器复核更接近保守上线口径50 worker 混合读写 P95 约 215ms100 worker P95 约 415ms150 worker P95 约 622ms 已经进入压力区。
- 按最新 416.54 req/s 的 50 worker 舒适吞吐粗略折算,如果未来前端真实埋点显示每名在线学生平均 0.05 到 0.2 req/s则理论请求吞吐约对应 2,080 到 8,330 名活跃在线学生;按 100 worker 上沿 394.18 req/s 估算约为 1,970 到 7,880 名;按 150 worker 压力区 370.53 req/s 估算约为 1,850 到 7,410 名。这只是吞吐换算,不是生产 SLA。
- 生产如果使用 4 核 16G 单机,首版容量承诺建议先按最新本地受限资源折算值的 30% 到 50% 保守规划,即约 620 到 4,160 名活跃在线学生请求吞吐,再以云端复测真实前端埋点、CDN/对象存储和生产 PostgreSQL 调参后结果上调。
- 正式容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、对象存储/CDN、真实前端请求节奏和生产网络下复跑。
## 后续复测

View File

@@ -82,6 +82,8 @@ npm run security:repo
npm run check:api
npm run check:worker
npm run check:taro
npm run readiness:production
npm run readiness:production:db
npm run smoke:taro:h5
npm run smoke:taro:h5:interaction
node scripts/taro-h5-release-guardrails-test.js --require-dist
@@ -100,12 +102,15 @@ node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime
`security:repo` 是仓库自带的静态安全扫描,会拦截密钥形态、前端旧鉴权头、真实 runtime-config 和生产证据误入 Git。它不能替代真实 `@codex-security`;如插件在当前 Codex 环境暴露扫描工具,再补插件扫描结果。若工具不可用,不能把该项标记为已完成,只能在上线证据里标记为待补。
`readiness:production``readiness:production:db` 是生产阻断门禁:会拒绝 mock/未知短信 provider、弱密钥、`CORS=*`、旧身份头、local_dev 存储、非 HTTPS 对象存储公开 URL、阿里云 OSS 内网直签、未接外部资源扫描、localhost webhook以及租户短信/OAuth/支付公开配置缺字段、OAuth redirectUri/支付 notifyUrl 非 HTTPS、公开配置混入密钥、active provider 缺私密 `tenant_secrets` 等问题。
生产 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
AUTH_SMS_PROVIDER=aliyun 或 tencent
```
生产 worker 推荐:
@@ -115,6 +120,7 @@ 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
ALIYUN_OSS_INTERNAL=false
```
## 性能与数据库

View File

@@ -62,6 +62,50 @@ assert.notEqual(unsafeApi.status, 0, 'production API config should reject local_
assert.match(unsafeApi.output, /Invalid production API configuration/, 'API config should explain production config failure');
assert.match(unsafeApi.output, /STORAGE_DEFAULT_PROVIDER=local_dev/, 'API config should name unsafe storage provider');
const unsafeApiSmsProvider = runImport(apiConfigUrl, {
...safeApiEnv,
AUTH_SMS_PROVIDER: 'mocked-production',
});
assert.notEqual(unsafeApiSmsProvider.status, 0, 'production API config should reject unsupported SMS provider');
assert.match(
unsafeApiSmsProvider.output,
/AUTH_SMS_PROVIDER must be aliyun\/aliyun-sms or tencent\/tencent-sms/,
'API config should name supported production SMS providers',
);
const unsafeApiStoragePublicBaseUrl = runImport(apiConfigUrl, {
...safeApiEnv,
STORAGE_PUBLIC_BASE_URL: 'http://127.0.0.1:9000/assets',
});
assert.notEqual(unsafeApiStoragePublicBaseUrl.status, 0, 'production API config should reject unsafe storage public base URL');
assert.match(
unsafeApiStoragePublicBaseUrl.output,
/STORAGE_PUBLIC_BASE_URL must be a production HTTPS URL/,
'API config should require HTTPS storage public base URL',
);
const unsafeApiAliyunInternal = runImport(apiConfigUrl, {
...safeApiEnv,
ALIYUN_OSS_INTERNAL: 'true',
});
assert.notEqual(unsafeApiAliyunInternal.status, 0, 'production API config should reject internal OSS signed URL mode');
assert.match(
unsafeApiAliyunInternal.output,
/ALIYUN_OSS_INTERNAL=true is not allowed/,
'API config should name unsafe internal OSS mode',
);
const unsafeApiAliyunEndpoint = runImport(apiConfigUrl, {
...safeApiEnv,
ALIYUN_OSS_ENDPOINT: 'https://oss-cn-hangzhou.example.com',
});
assert.notEqual(unsafeApiAliyunEndpoint.status, 0, 'production API config should reject non-Aliyun OSS endpoints');
assert.match(
unsafeApiAliyunEndpoint.output,
/ALIYUN_OSS_ENDPOINT must be a production HTTPS aliyuncs\.com endpoint/,
'API config should require official Aliyun OSS endpoint host',
);
const safeApi = runImport(apiConfigUrl, safeApiEnv);
assert.equal(safeApi.status, 0, `safe production API config should load: ${safeApi.output}`);
@@ -80,6 +124,28 @@ const unsafeWorkerScanner = runImport(workerConfigUrl, {
assert.notEqual(unsafeWorkerScanner.status, 0, 'production worker config should require external scanner');
assert.match(unsafeWorkerScanner.output, /WORKER_ASSET_SECURITY_SCANNER must include http/, 'worker config should require http scanner');
const unsafeWorkerStorageBaseUrl = runImport(workerConfigUrl, {
...safeBaseEnv,
STORAGE_PUBLIC_BASE_URL: 'http://localhost:9000/assets',
});
assert.notEqual(unsafeWorkerStorageBaseUrl.status, 0, 'production worker config should reject unsafe storage public base URL');
assert.match(
unsafeWorkerStorageBaseUrl.output,
/STORAGE_PUBLIC_BASE_URL must be a production HTTPS URL/,
'worker config should require HTTPS storage public base URL',
);
const unsafeWorkerAliyunInternal = runImport(workerConfigUrl, {
...safeBaseEnv,
ALIYUN_OSS_INTERNAL: 'true',
});
assert.notEqual(unsafeWorkerAliyunInternal.status, 0, 'production worker config should reject internal OSS signed URL mode');
assert.match(
unsafeWorkerAliyunInternal.output,
/ALIYUN_OSS_INTERNAL=true is not allowed/,
'worker config should name unsafe internal OSS mode',
);
const unsafeWorkerPlatformAuditNotification = runImport(workerConfigUrl, {
...safeBaseEnv,
WORKER_PLATFORM_AUDIT_NOTIFICATION_ALLOW_INSECURE_LOCALHOST: 'true',

View File

@@ -7,12 +7,18 @@ import { spawnSync } from 'node:child_process';
const repoRoot = process.cwd();
const scriptPath = path.join(repoRoot, 'scripts', 'production-readiness-check.js');
function runReadiness(envContent) {
function runReadiness(envContent, options = {}) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'tiku-readiness-'));
const envFile = path.join(tempDir, '.env');
fs.writeFileSync(envFile, envContent, 'utf8');
const args = [scriptPath, '--env-file', envFile, '--skip-db', '--json'];
if (options.providerRows) {
const fixtureFile = path.join(tempDir, 'provider-fixture.json');
fs.writeFileSync(fixtureFile, JSON.stringify({ rows: options.providerRows }, null, 2), 'utf8');
args.push('--provider-config-fixture', fixtureFile);
}
const result = spawnSync(process.execPath, [scriptPath, '--env-file', envFile, '--skip-db', '--json'], {
const result = spawnSync(process.execPath, args, {
cwd: repoRoot,
encoding: 'utf8',
env: {
@@ -47,6 +53,37 @@ assert.ok(
'unsafe readiness should block mock SMS provider',
);
const unsupportedSmsProvider = runReadiness(`
NODE_ENV=production
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
CORS_ORIGIN=https://student.gongxue100.com
AUTH_SMS_PROVIDER=aliyun-production
AUTH_CODE_PEPPER=s3cure-prod-code-pepper-2026-06-29-abcdef
AUTH_SESSION_SECRET=s3cure-prod-session-secret-2026-06-29-ghijkl
AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json
AUTH_JWT_ISSUER=https://auth.gongxue100.com/auth/v1
ALLOW_LEGACY_AUTH_HEADERS=false
ALLOW_PLATFORM_ADMIN_KEY=false
PLATFORM_ADMIN_API_KEY=s3cure-platform-admin-key-2026-06-29-mnopqr
STORAGE_DEFAULT_PROVIDER=aliyun_oss
STORAGE_DEFAULT_BUCKET=tiku-assets
STORAGE_REQUIRE_TENANT_PREFIX=true
ALIYUN_OSS_REGION=cn-hangzhou
ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
ALIYUN_OSS_ACCESS_KEY_ID=LTAI_READINESS_TEST_ONLY
ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-secret-placeholder
WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.gongxue100.com/api/scan
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
`);
assert.notEqual(unsupportedSmsProvider.status, 0, 'unsupported production SMS provider readiness should fail');
assert.ok(
unsupportedSmsProvider.payload.checks?.some(item => item.id === 'env.auth_sms_provider' && item.status === 'blocker'),
'readiness should block unsupported AUTH_SMS_PROVIDER values',
);
const strongSecretA = 's3cure-prod-code-pepper-2026-06-29-abcdef';
const strongSecretB = 's3cure-prod-session-secret-2026-06-29-ghijkl';
const strongSecretC = 's3cure-platform-admin-key-2026-06-29-mnopqr';
@@ -95,6 +132,84 @@ assert.ok(
'env-only readiness should explicitly warn that DB checks are skipped',
);
const unsafeProviderFixture = runReadiness(
`
NODE_ENV=production
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku
CORS_ORIGIN=https://student.gongxue100.com,https://tenant-admin.gongxue100.com,https://platform-admin.gongxue100.com
AUTH_SMS_PROVIDER=aliyun
AUTH_CODE_PEPPER=${strongSecretA}
AUTH_SESSION_SECRET=${strongSecretB}
AUTH_JWT_JWKS_URL=https://auth.gongxue100.com/auth/v1/.well-known/jwks.json
AUTH_JWT_ISSUER=https://auth.gongxue100.com/auth/v1
ALLOW_LEGACY_AUTH_HEADERS=false
ALLOW_PLATFORM_ADMIN_KEY=false
PLATFORM_ADMIN_API_KEY=${strongSecretC}
STORAGE_DEFAULT_PROVIDER=aliyun_oss
STORAGE_DEFAULT_BUCKET=tiku-assets
STORAGE_REQUIRE_TENANT_PREFIX=true
ALIYUN_OSS_REGION=cn-hangzhou
ALIYUN_OSS_ENDPOINT=https://oss-cn-hangzhou.aliyuncs.com
ALIYUN_OSS_ACCESS_KEY_ID=LTAI_READINESS_TEST_ONLY
ALIYUN_OSS_ACCESS_KEY_SECRET=aliyun-readiness-secret-placeholder
WORKER_ASSET_SECURITY_SCANNER=metadata_rules,http
WORKER_ASSET_SECURITY_SCAN_HTTP_ENDPOINT=https://scanner.gongxue100.com/api/scan
WORKER_ASSET_SECURITY_SCAN_HTTP_TOKEN=s3cure-asset-scanner-token-2026-06-29-stuvwx
WORKER_ASSET_SECURITY_SCAN_FAIL_OPEN=false
`,
{
providerRows: [
{
source: 'auth',
tenantId: 'tenant-a',
provider: 'qq-oauth',
configPublic: {
appId: '101000000',
redirectUri: 'http://localhost:5173/auth/qq/callback',
},
},
{
source: 'payment',
tenantId: 'tenant-a',
provider: 'wechat_pay',
configPublic: {
appId: 'wx123',
merchantId: '1900000001',
merchantSerialNo: 'serial123',
},
},
{
source: 'payment',
tenantId: 'tenant-b',
provider: 'alipay',
configPublic: {
appId: '2021000000000000',
notifyUrl: 'http://pay.example.com/notify',
privateKey: 'should-not-be-public',
},
},
],
},
);
assert.notEqual(unsafeProviderFixture.status, 0, 'unsafe provider fixture readiness should fail');
assert.ok(
unsafeProviderFixture.payload.checks?.some(item => item.id === 'db.auth.qq-oauth.redirect_uri.unsafe' && item.status === 'blocker'),
'readiness should block unsafe QQ redirectUri',
);
assert.ok(
unsafeProviderFixture.payload.checks?.some(item => item.id === 'db.payment.wechat_pay.public_required' && item.status === 'blocker'),
'readiness should block missing WeChat Pay notifyUrl',
);
assert.ok(
unsafeProviderFixture.payload.checks?.some(item => item.id === 'db.payment.alipay.notify_url.unsafe' && item.status === 'blocker'),
'readiness should block unsafe Alipay notifyUrl',
);
assert.ok(
unsafeProviderFixture.payload.checks?.some(item => item.id === 'db.payment.alipay.public_secret' && item.status === 'blocker'),
'readiness should block secret-like public payment config keys',
);
const missingJwksIssuer = runReadiness(`
NODE_ENV=production
DATABASE_URL=postgresql://prod_user:prod_password@db.prod.internal:5432/tiku

View File

@@ -10,6 +10,21 @@ const DEFAULT_AUTH_SESSION_SECRET = 'development-session-secret-change-me';
const DEFAULT_AUTH_JWT_SECRET = 'development-jwt-secret-change-me';
const DEFAULT_PLATFORM_ADMIN_API_KEY = 'local-platform-admin-key';
const HARD_MAX_JSON_BODY_BYTES = 50 * 1024 * 1024;
const PRODUCTION_SMS_PROVIDERS = new Set(['aliyun', 'aliyun-sms', 'aliyun_sms', 'tencent', 'tencent-sms', 'tencent_sms']);
const PRODUCTION_STORAGE_PROVIDERS = new Set(['aliyun_oss', 'tencent_cos', 'supabase_storage']);
const AUTH_PROVIDER_ALIASES = {
sms: new Set(['aliyun', 'aliyun-sms', 'aliyun_sms', 'tencent', 'tencent-sms', 'tencent_sms']),
aliyun: new Set(['aliyun', 'aliyun-sms', 'aliyun_sms']),
tencent: new Set(['tencent', 'tencent-sms', 'tencent_sms']),
wechatMiniapp: new Set(['wechat-miniapp', 'wechat_miniapp', 'wechat-mini', 'wx-miniapp', 'wx_miniapp']),
wechatWeb: new Set(['wechat-web', 'wechat_web', 'wechat', 'wechat-oauth', 'wechat_oauth']),
qq: new Set(['qq', 'qq-oauth', 'qq_oauth']),
};
const PAYMENT_PROVIDER_ALIASES = {
wechatPay: new Set(['wechat_pay', 'wechat-pay', 'wechatpay', 'wxpay', 'wx_pay']),
alipay: new Set(['alipay', 'ali_pay']),
manual: new Set(['manual']),
};
const args = new Set(process.argv.slice(2));
const argValues = new Map();
@@ -25,6 +40,7 @@ const jsonOutput = args.has('--json');
const checkDb = args.has('--check-db');
const skipDb = args.has('--skip-db') || !checkDb;
const envFile = argValues.get('--env-file') || path.resolve(process.cwd(), '.env');
const providerConfigFixture = argValues.get('--provider-config-fixture') || '';
const checks = [];
function loadEnvFile(filePath) {
@@ -101,10 +117,322 @@ function hostFromUrl(value) {
}
}
function urlFromValue(value) {
try {
return new URL(String(value || ''));
} catch {
return null;
}
}
function isLocalHost(hostname) {
return ['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(hostname);
}
function isProductionHttpsUrl(value) {
const url = urlFromValue(value);
return Boolean(url && url.protocol === 'https:' && !isLocalHost(url.hostname.toLowerCase()));
}
function isAllowedHost(hostname, allowedHosts) {
const normalized = String(hostname || '').toLowerCase();
return allowedHosts.some(host => normalized === host || normalized.endsWith(`.${host}`));
}
function publicString(configPublic, keys) {
for (const key of keys) {
const value = configPublic?.[key];
if (typeof value === 'string' && value.trim()) return value.trim();
}
return '';
}
function missingPublicKeys(configPublic, keys) {
return keys.filter(key => !publicString(configPublic, [key]));
}
function normalizeProvider(value) {
return String(value || '').trim().toLowerCase();
}
function providerIn(provider, aliases) {
return aliases.has(normalizeProvider(provider));
}
function validateProviderUrl({
id,
value,
message,
allowedHosts = [],
required = false,
details = {},
}) {
const raw = String(value || '').trim();
if (!raw) {
if (required) block(`${id}.missing`, message || 'Production URL is required', details);
return;
}
const url = urlFromValue(raw);
if (!url || url.protocol !== 'https:' || isLocalHost(url.hostname.toLowerCase())) {
block(`${id}.unsafe`, message || 'Production URL must use HTTPS and must not point to localhost', details);
return;
}
if (allowedHosts.length > 0 && !isAllowedHost(url.hostname, allowedHosts)) {
block(`${id}.host`, 'Provider endpoint host is not allowed for production', {
...details,
host: url.hostname,
allowedHosts,
});
}
}
function missingPublicKeyGroups(configPublic, groups) {
return groups
.filter(group => !publicString(configPublic, group.keys))
.map(group => group.label);
}
function blockMissingPublicConfig(row, missing) {
if (missing.length === 0) return;
block(
`db.${row.source}.${safeProviderName(row.provider)}.public_required`,
'Provider public config is missing production-required fields',
{
tenantId: row.tenant_id,
provider: row.provider,
missing,
},
);
}
function validateAuthProviderPublicConfig(row) {
const provider = normalizeProvider(row.provider);
const configPublic = row.config_public || {};
const details = { tenantId: row.tenant_id, provider: row.provider };
if (provider === 'mock') {
block('db.auth.mock_provider', 'Active/testing mock auth provider is not allowed in production', details);
return;
}
if (providerIn(provider, AUTH_PROVIDER_ALIASES.aliyun)) {
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
{ label: 'signName', keys: ['signName'] },
{ label: 'templateCode', keys: ['templateCode'] },
]));
validateProviderUrl({
id: `db.auth.${safeProviderName(row.provider)}.endpoint`,
value: publicString(configPublic, ['endpoint']),
allowedHosts: ['aliyuncs.com'],
details,
});
if (!publicString(configPublic, ['regionId'])) {
warn(`db.auth.${safeProviderName(row.provider)}.region`, 'Aliyun SMS regionId is not set; default cn-hangzhou will be used', details);
}
return;
}
if (providerIn(provider, AUTH_PROVIDER_ALIASES.tencent)) {
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
{ label: 'smsSdkAppId/appId', keys: ['smsSdkAppId', 'appId'] },
{ label: 'signName', keys: ['signName'] },
{ label: 'templateId', keys: ['templateId'] },
]));
validateProviderUrl({
id: `db.auth.${safeProviderName(row.provider)}.endpoint`,
value: publicString(configPublic, ['endpoint']),
allowedHosts: ['tencentcloudapi.com'],
details,
});
if (!publicString(configPublic, ['region'])) {
warn(`db.auth.${safeProviderName(row.provider)}.region`, 'Tencent SMS region is not set; default ap-guangzhou will be used', details);
}
return;
}
if (providerIn(provider, AUTH_PROVIDER_ALIASES.wechatMiniapp)) {
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
{ label: 'appId', keys: ['appId'] },
]));
validateProviderUrl({
id: `db.auth.${safeProviderName(row.provider)}.endpoint`,
value: publicString(configPublic, ['endpoint']),
allowedHosts: ['weixin.qq.com'],
details,
});
return;
}
if (providerIn(provider, AUTH_PROVIDER_ALIASES.wechatWeb)) {
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
{ label: 'appId', keys: ['appId'] },
]));
validateProviderUrl({
id: `db.auth.${safeProviderName(row.provider)}.token_endpoint`,
value: publicString(configPublic, ['tokenEndpoint', 'accessTokenEndpoint', 'endpoint']),
allowedHosts: ['weixin.qq.com'],
details,
});
validateProviderUrl({
id: `db.auth.${safeProviderName(row.provider)}.userinfo_endpoint`,
value: publicString(configPublic, ['userInfoEndpoint', 'userinfoEndpoint']),
allowedHosts: ['weixin.qq.com'],
details,
});
validateProviderUrl({
id: `db.auth.${safeProviderName(row.provider)}.redirect_uri`,
value: publicString(configPublic, ['redirectUri', 'callbackUrl']),
details,
});
return;
}
if (providerIn(provider, AUTH_PROVIDER_ALIASES.qq)) {
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
{ label: 'appId/clientId', keys: ['appId', 'clientId'] },
{ label: 'redirectUri/callbackUrl', keys: ['redirectUri', 'callbackUrl'] },
]));
validateProviderUrl({
id: `db.auth.${safeProviderName(row.provider)}.redirect_uri`,
value: publicString(configPublic, ['redirectUri', 'callbackUrl']),
required: true,
details,
});
validateProviderUrl({
id: `db.auth.${safeProviderName(row.provider)}.token_endpoint`,
value: publicString(configPublic, ['tokenEndpoint', 'accessTokenEndpoint', 'endpoint']),
allowedHosts: ['graph.qq.com'],
details,
});
validateProviderUrl({
id: `db.auth.${safeProviderName(row.provider)}.openid_endpoint`,
value: publicString(configPublic, ['openIdEndpoint', 'openidEndpoint']),
allowedHosts: ['graph.qq.com'],
details,
});
validateProviderUrl({
id: `db.auth.${safeProviderName(row.provider)}.userinfo_endpoint`,
value: publicString(configPublic, ['userInfoEndpoint', 'userinfoEndpoint']),
allowedHosts: ['graph.qq.com'],
details,
});
return;
}
block('db.auth.unsupported_provider', 'Active/testing auth provider is not in the supported production provider list', details);
}
function validatePaymentProviderPublicConfig(row) {
const provider = normalizeProvider(row.provider);
const configPublic = row.config_public || {};
const details = { tenantId: row.tenant_id, provider: row.provider };
if (providerIn(provider, PAYMENT_PROVIDER_ALIASES.manual)) {
warn('db.payment.manual_provider', 'Manual payment account is active; use only for offline collection or migration operations', details);
return;
}
if (providerIn(provider, PAYMENT_PROVIDER_ALIASES.wechatPay)) {
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
{ label: 'appId', keys: ['appId'] },
{ label: 'merchantId/mchId', keys: ['merchantId', 'mchId'] },
{ label: 'merchantSerialNo', keys: ['merchantSerialNo'] },
{ label: 'notifyUrl', keys: ['notifyUrl'] },
]));
validateProviderUrl({
id: `db.payment.${safeProviderName(row.provider)}.notify_url`,
value: publicString(configPublic, ['notifyUrl']),
required: true,
details,
});
validateProviderUrl({
id: `db.payment.${safeProviderName(row.provider)}.refund_notify_url`,
value: publicString(configPublic, ['refundNotifyUrl']),
details,
});
for (const [key, allowedHosts] of [
['endpoint', ['api.mch.weixin.qq.com']],
['refundEndpoint', ['api.mch.weixin.qq.com']],
['refundQueryEndpoint', ['api.mch.weixin.qq.com']],
]) {
validateProviderUrl({
id: `db.payment.${safeProviderName(row.provider)}.${key}`,
value: publicString(configPublic, [key]),
allowedHosts,
details,
});
}
return;
}
if (providerIn(provider, PAYMENT_PROVIDER_ALIASES.alipay)) {
blockMissingPublicConfig(row, missingPublicKeyGroups(configPublic, [
{ label: 'appId', keys: ['appId'] },
{ label: 'notifyUrl', keys: ['notifyUrl'] },
]));
validateProviderUrl({
id: `db.payment.${safeProviderName(row.provider)}.notify_url`,
value: publicString(configPublic, ['notifyUrl']),
required: true,
details,
});
validateProviderUrl({
id: `db.payment.${safeProviderName(row.provider)}.return_url`,
value: publicString(configPublic, ['returnUrl']),
details,
});
validateProviderUrl({
id: `db.payment.${safeProviderName(row.provider)}.quit_url`,
value: publicString(configPublic, ['quitUrl']),
details,
});
for (const [key, allowedHosts] of [
['endpoint', ['openapi.alipay.com']],
['refundEndpoint', ['openapi.alipay.com']],
['refundQueryEndpoint', ['openapi.alipay.com']],
]) {
validateProviderUrl({
id: `db.payment.${safeProviderName(row.provider)}.${key}`,
value: publicString(configPublic, [key]),
allowedHosts,
details,
});
}
return;
}
block('db.payment.unsupported_provider', 'Active payment account is not in the supported production provider list', details);
}
function validateProviderConfigRows(inputRows) {
const rows = inputRows.map(row => ({
source: row.source,
tenant_id: row.tenant_id || row.tenantId || 'fixture-tenant',
provider: row.provider,
config_public: row.config_public || row.configPublic || {},
}));
if (rows.length === 0) {
warn('db.provider_fixture.empty', 'Provider config fixture did not contain any rows');
return;
}
for (const row of rows) {
const secretPaths = findSecretLikePaths(row.config_public || {});
if (secretPaths.length > 0) {
block(`db.${row.source}.${safeProviderName(row.provider)}.public_secret`, 'Provider public config contains secret-like keys', {
tenantId: row.tenant_id,
provider: row.provider,
secretPathCount: secretPaths.length,
});
}
if (row.source === 'auth') validateAuthProviderPublicConfig(row);
else if (row.source === 'payment') validatePaymentProviderPublicConfig(row);
else block('db.provider_fixture.source', 'Provider config fixture row source must be auth or payment', { source: row.source });
}
if (rows.every(row => findSecretLikePaths(row.config_public || {}).length === 0)) {
pass('db.provider_public_config', 'Active provider public configs do not contain secret-like keys');
}
}
function isSecretLikeKey(key) {
const normalized = key.toLowerCase().replace(/[-_\s]/g, '');
const allowedSecretRef = normalized === 'secretref' || normalized.endsWith('secretref');
@@ -177,9 +505,14 @@ function validateEnv() {
}
}
const authSmsProvider = env('AUTH_SMS_PROVIDER', 'mock');
if (authSmsProvider === 'mock') block('env.auth_sms_provider', 'AUTH_SMS_PROVIDER=mock is not allowed in production');
else pass('env.auth_sms_provider', 'AUTH_SMS_PROVIDER is not mock');
const authSmsProvider = env('AUTH_SMS_PROVIDER', 'mock').trim().toLowerCase();
if (!PRODUCTION_SMS_PROVIDERS.has(authSmsProvider)) {
block('env.auth_sms_provider', 'AUTH_SMS_PROVIDER must be aliyun/aliyun-sms or tencent/tencent-sms in production', {
provider: authSmsProvider || '(empty)',
});
} else {
pass('env.auth_sms_provider', 'AUTH_SMS_PROVIDER is a supported production SMS provider', { provider: authSmsProvider });
}
if (isUnsafeSecret(env('AUTH_CODE_PEPPER', DEFAULT_AUTH_CODE_PEPPER), DEFAULT_AUTH_CODE_PEPPER)) {
block('env.auth_code_pepper', 'AUTH_CODE_PEPPER must be a strong production secret');
@@ -239,9 +572,13 @@ function validateEnv() {
pass('env.body_size', 'JSON body limits are bounded');
}
const storageProvider = env('STORAGE_DEFAULT_PROVIDER', 'local_dev');
const storageProvider = env('STORAGE_DEFAULT_PROVIDER', 'local_dev').trim();
if (storageProvider === 'local_dev') {
block('env.storage_provider', 'STORAGE_DEFAULT_PROVIDER=local_dev is not allowed for production assets');
} else if (!PRODUCTION_STORAGE_PROVIDERS.has(storageProvider)) {
block('env.storage_provider.unsupported', 'STORAGE_DEFAULT_PROVIDER must be aliyun_oss, tencent_cos or supabase_storage in production', {
provider: storageProvider || '(empty)',
});
} else {
pass('env.storage_provider', 'STORAGE_DEFAULT_PROVIDER is production-capable', { provider: storageProvider });
}
@@ -255,11 +592,24 @@ function validateEnv() {
} else {
pass('env.storage_tenant_prefix', 'tenant-prefixed object keys are required');
}
if (env('STORAGE_PUBLIC_BASE_URL', '') && !isProductionHttpsUrl(env('STORAGE_PUBLIC_BASE_URL', ''))) {
block('env.storage_public_base_url', 'STORAGE_PUBLIC_BASE_URL must be a production HTTPS URL when configured');
}
if (storageProvider === 'aliyun_oss') {
for (const key of ['ALIYUN_OSS_REGION', 'ALIYUN_OSS_ENDPOINT', 'ALIYUN_OSS_ACCESS_KEY_ID', 'ALIYUN_OSS_ACCESS_KEY_SECRET']) {
if (!env(key, '')) block(`env.${key.toLowerCase()}`, `${key} is required for aliyun_oss`);
}
validateProviderUrl({
id: 'env.aliyun_oss_endpoint',
value: env('ALIYUN_OSS_ENDPOINT', ''),
message: 'ALIYUN_OSS_ENDPOINT must be a production HTTPS endpoint',
allowedHosts: ['aliyuncs.com'],
required: true,
});
if (envBool('ALIYUN_OSS_INTERNAL', false)) {
block('env.aliyun_oss_internal', 'ALIYUN_OSS_INTERNAL=true is not allowed for user-facing production signed URLs');
}
}
if (storageProvider === 'tencent_cos') {
for (const key of ['TENCENT_COS_REGION', 'TENCENT_COS_APP_ID', 'TENCENT_COS_SECRET_ID', 'TENCENT_COS_SECRET_KEY']) {
@@ -270,6 +620,12 @@ function validateEnv() {
for (const key of ['SUPABASE_STORAGE_URL', 'SUPABASE_STORAGE_SERVICE_KEY']) {
if (!env(key, '')) block(`env.${key.toLowerCase()}`, `${key} is required for supabase_storage`);
}
validateProviderUrl({
id: 'env.supabase_storage_url',
value: env('SUPABASE_STORAGE_URL', ''),
message: 'SUPABASE_STORAGE_URL must be a production HTTPS URL',
required: true,
});
}
if (envList('STORAGE_ALLOWED_MIME_TYPES').includes('application/octet-stream')) {
@@ -389,6 +745,8 @@ async function validateDatabase() {
secretPathCount: secretPaths.length,
});
}
if (row.source === 'auth') validateAuthProviderPublicConfig(row);
if (row.source === 'payment') validatePaymentProviderPublicConfig(row);
}
if (publicSecretRows.rows.every(row => findSecretLikePaths(row.config_public || {}).length === 0)) {
pass('db.provider_public_config', 'Active provider public configs do not contain secret-like keys');
@@ -721,6 +1079,12 @@ function printSummary() {
async function main() {
loadEnvFile(envFile);
validateEnv();
if (providerConfigFixture) {
const fixturePath = path.resolve(process.cwd(), providerConfigFixture);
const fixture = JSON.parse(fs.readFileSync(fixturePath, 'utf8'));
const rows = Array.isArray(fixture) ? fixture : Array.isArray(fixture.rows) ? fixture.rows : [];
validateProviderConfigRows(rows);
}
await validateDatabase();
printSummary();