From bb938cf6e229e7210578fba46c7d8a6519644307 Mon Sep 17 00:00:00 2001 From: Codex Date: Wed, 1 Jul 2026 06:09:23 +0800 Subject: [PATCH] chore: harden production readiness gates --- README.md | 45 +-- apps/api/src/core/config.ts | 43 +- apps/worker/src/config.ts | 33 ++ docs/refactor/auth-payment-provider-plan.md | 5 +- docs/refactor/backend-capability-status.md | 8 +- ...ackend-open-items-and-capacity-20260701.md | 24 +- docs/refactor/next-development-todo.md | 9 +- .../object-storage-production-runbook.md | 3 +- .../performance-benchmark-summary-20260630.md | 25 +- .../web-launch-acceptance-checklist.md | 6 + scripts/production-config-failfast-test.js | 66 ++++ scripts/production-readiness-check-test.js | 119 +++++- scripts/production-readiness-check.js | 372 +++++++++++++++++- 13 files changed, 695 insertions(+), 63 deletions(-) diff --git a/README.md b/README.md index aee3d51e..e11f73cf 100644 --- a/README.md +++ b/README.md @@ -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/s,150 并发 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 202ms,50 并发混合读写 0 错误、416.5 req/s、P95 215ms,100 并发混合读写 0 错误、394.2 req/s、P95 415ms,150 并发混合读写 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.25ms;50 worker/60 秒/10% 写入为 26,086 请求、0 错误、431.78 req/s、P95 207.10ms;100 worker/60 秒/8% 写入为 25,242 请求、0 错误、417.03 req/s、P95 383.30ms;150 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.20ms;50 worker/60 秒/10% 写入为 25,157 请求、0 错误、416.54 req/s、P95 215.00ms;100 worker/60 秒/8% 写入为 23,873 请求、0 错误、394.18 req/s、P95 414.67ms;150 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 和复检结果操作台;继续对真实迁移数据做题目、订单、权益、错题、资料和视频抽样验收。 diff --git a/apps/api/src/core/config.ts b/apps/api/src/core/config.ts index d777115c..d6b785b7 100644 --- a/apps/api/src/core/config.ts +++ b/apps/api/src/core/config.ts @@ -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) { diff --git a/apps/worker/src/config.ts b/apps/worker/src/config.ts index 90247e2f..57dba627 100644 --- a/apps/worker/src/config.ts +++ b/apps/worker/src/config.ts @@ -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) { diff --git a/docs/refactor/auth-payment-provider-plan.md b/docs/refactor/auth-payment-provider-plan.md index 986d9203..2e966c82 100644 --- a/docs/refactor/auth-payment-provider-plan.md +++ b/docs/refactor/auth-payment-provider-plan.md @@ -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 或写成未知 provider,API 启动和 `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": "", "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": "", "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` 等密钥字段。 ### 资金对账 diff --git a/docs/refactor/backend-capability-status.md b/docs/refactor/backend-capability-status.md index 392b41f8..36975175 100644 --- a/docs/refactor/backend-capability-status.md +++ b/docs/refactor/backend-capability-status.md @@ -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 检查 diff --git a/docs/refactor/backend-open-items-and-capacity-20260701.md b/docs/refactor/backend-open-items-and-capacity-20260701.md index 7d1225e2..27398d18 100644 --- a/docs/refactor/backend-open-items-and-capacity-20260701.md +++ b/docs/refactor/backend-open-items-and-capacity-20260701.md @@ -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 已超过 400ms,150 worker 仍 0 错误但 P95 已超过 600ms,视为压力区,不建议作为生产承诺。受限 API 容器会明显压低上沿,因此上线承诺仍以云端受限资源复测为准。 -正式对外容量必须等 4 核 16G 云服务器部署后复跑。保守规划时,生产首版建议先按本地折算值的 30% 到 50% 做容量承诺,约 630 到 4,300 名活跃在线学生,等云端压测、CDN、对象存储和真实前端埋点完成后再上调。 +正式对外容量必须等 4 核 16G 云服务器部署后复跑。保守规划时,生产首版建议先按本地折算值的 30% 到 50% 做容量承诺,约 620 到 4,160 名活跃在线学生,等云端压测、CDN、对象存储和真实前端埋点完成后再上调。 diff --git a/docs/refactor/next-development-todo.md b/docs/refactor/next-development-todo.md index b3cd37d4..716f20ba 100644 --- a/docs/refactor/next-development-todo.md +++ b/docs/refactor/next-development-todo.md @@ -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.93ms;50 worker/60s/10% 写入为 26,086 请求、0 错误、431.78 req/s、P95 207.10ms、P99 283.57ms;100 worker/60s/8% 写入为 25,242 请求、0 错误、417.03 req/s、P95 383.30ms、P99 464.22ms;150 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.16ms;50 worker/60s/10% 写入为 25,157 请求、0 错误、416.54 req/s、P95 215.00ms、P99 292.56ms;100 worker/60s/8% 写入为 23,873 请求、0 错误、394.18 req/s、P95 414.67ms、P99 495.91ms;150 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 202ms,50 并发混合读写 0 错误、416.5 req/s、P95 215ms,100 并发混合读写 0 错误、394.2 req/s、P95 415ms,150 并发混合读写 0 错误、370.5 req/s、P95 622ms。云服务器部署后还要按同一矩阵复跑并输出正式容量报告。 ## Taro 前端开发 TODO diff --git a/docs/refactor/object-storage-production-runbook.md b/docs/refactor/object-storage-production-runbook.md index 46706b41..a22a761f 100644 --- a/docs/refactor/object-storage-production-runbook.md +++ b/docs/refactor/object-storage-production-runbook.md @@ -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、防盗链、生命周期、备份/版本控制、访问日志有截图或变更记录。 - diff --git a/docs/refactor/performance-benchmark-summary-20260630.md b/docs/refactor/performance-benchmark-summary-20260630.md index ffe0cf2e..6f060a28 100644 --- a/docs/refactor/performance-benchmark-summary-20260630.md +++ b/docs/refactor/performance-benchmark-summary-20260630.md @@ -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.26ms,100 worker 混合读写 P95 约 326.21ms,100 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 约 215ms,100 worker P95 约 415ms,150 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、真实前端请求节奏和生产网络下复跑。 ## 后续复测 diff --git a/docs/refactor/web-launch-acceptance-checklist.md b/docs/refactor/web-launch-acceptance-checklist.md index dfffc77b..d918b977 100644 --- a/docs/refactor/web-launch-acceptance-checklist.md +++ b/docs/refactor/web-launch-acceptance-checklist.md @@ -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 ``` ## 性能与数据库 diff --git a/scripts/production-config-failfast-test.js b/scripts/production-config-failfast-test.js index 3057aa0e..a7273a61 100644 --- a/scripts/production-config-failfast-test.js +++ b/scripts/production-config-failfast-test.js @@ -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', diff --git a/scripts/production-readiness-check-test.js b/scripts/production-readiness-check-test.js index f1a17732..ca778c12 100644 --- a/scripts/production-readiness-check-test.js +++ b/scripts/production-readiness-check-test.js @@ -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 diff --git a/scripts/production-readiness-check.js b/scripts/production-readiness-check.js index ece43e3c..90306fb3 100644 --- a/scripts/production-readiness-check.js +++ b/scripts/production-readiness-check.js @@ -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();