From 25d1b8e7967154b04848178747b5a75c8501eb43 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 30 Jun 2026 01:19:14 +0800 Subject: [PATCH] feat: add taro h5 runtime deployment config --- README.md | 14 ++ ...platform-admin.runtime-config.example.json | 7 + .../h5-student.runtime-config.example.json | 7 + ...5-tenant-admin.runtime-config.example.json | 7 + apps/taro/src/env.ts | 140 +++++++++++++-- apps/taro/src/pages/bootstrap/index.tsx | 5 +- apps/taro/src/services/api.ts | 3 +- apps/taro/src/services/supabase.ts | 14 +- docs/refactor/README.md | 1 + docs/refactor/blueprint-coverage.md | 6 +- docs/refactor/frontend-handoff-index.md | 7 +- .../supabase-frontend-access-strategy.md | 7 +- docs/refactor/taro-frontend-integration.md | 12 +- docs/refactor/taro-h5-deployment.md | 163 +++++++++++++----- package.json | 2 +- scripts/taro-runtime-config-test.js | 53 ++++++ 16 files changed, 370 insertions(+), 78 deletions(-) create mode 100644 apps/taro/deploy/h5-platform-admin.runtime-config.example.json create mode 100644 apps/taro/deploy/h5-student.runtime-config.example.json create mode 100644 apps/taro/deploy/h5-tenant-admin.runtime-config.example.json create mode 100644 scripts/taro-runtime-config-test.js diff --git a/README.md b/README.md index ecb5fd7b..ab71e790 100644 --- a/README.md +++ b/README.md @@ -120,6 +120,20 @@ apps/taro/dist/h5-platform-admin 推荐分别部署到学生端域名、租户后台域名、平台后台域名;三者共用 `apps/taro/src/services/api.ts` 请求层,业务数据默认调用 `apps/api`,不要在页面里直写 Supabase 表。 +H5 线上推荐每个静态目录放独立 `runtime-config.json` 覆盖公开配置,避免 API/Auth 域名变化时重打包: + +```text +apps/taro/deploy/h5-student.runtime-config.example.json +apps/taro/deploy/h5-tenant-admin.runtime-config.example.json +apps/taro/deploy/h5-platform-admin.runtime-config.example.json +``` + +部署时把示例复制为对应 Web 根目录的 `runtime-config.json`,只填写 `portal`、`apiBaseUrl`、`supabaseUrl`、`supabasePublishableKey`、`tenantCode` 这类公开字段。完整 Nginx、CSP、缓存、CORS 和三域名部署说明见: + +```text +docs/refactor/taro-h5-deployment.md +``` + 学生端当前页面: ```text diff --git a/apps/taro/deploy/h5-platform-admin.runtime-config.example.json b/apps/taro/deploy/h5-platform-admin.runtime-config.example.json new file mode 100644 index 00000000..c05ffc06 --- /dev/null +++ b/apps/taro/deploy/h5-platform-admin.runtime-config.example.json @@ -0,0 +1,7 @@ +{ + "portal": "platform-admin", + "apiBaseUrl": "https://api.gongxue100.com", + "supabaseUrl": "https://auth.gongxue100.com", + "supabasePublishableKey": "replace-with-supabase-publishable-key", + "tenantCode": "" +} diff --git a/apps/taro/deploy/h5-student.runtime-config.example.json b/apps/taro/deploy/h5-student.runtime-config.example.json new file mode 100644 index 00000000..31c97056 --- /dev/null +++ b/apps/taro/deploy/h5-student.runtime-config.example.json @@ -0,0 +1,7 @@ +{ + "portal": "student", + "apiBaseUrl": "https://api.gongxue100.com", + "supabaseUrl": "https://auth.gongxue100.com", + "supabasePublishableKey": "replace-with-supabase-publishable-key", + "tenantCode": "" +} diff --git a/apps/taro/deploy/h5-tenant-admin.runtime-config.example.json b/apps/taro/deploy/h5-tenant-admin.runtime-config.example.json new file mode 100644 index 00000000..4a551c4e --- /dev/null +++ b/apps/taro/deploy/h5-tenant-admin.runtime-config.example.json @@ -0,0 +1,7 @@ +{ + "portal": "tenant-admin", + "apiBaseUrl": "https://api.gongxue100.com", + "supabaseUrl": "https://auth.gongxue100.com", + "supabasePublishableKey": "replace-with-supabase-publishable-key", + "tenantCode": "" +} diff --git a/apps/taro/src/env.ts b/apps/taro/src/env.ts index 4b7b39bc..4f38c0fa 100644 --- a/apps/taro/src/env.ts +++ b/apps/taro/src/env.ts @@ -1,10 +1,77 @@ export type Portal = 'student' | 'tenant-admin' | 'platform-admin'; +export interface AppEnv { + portal: Portal; + apiBaseUrl: string; + supabaseUrl: string; + supabasePublishableKey: string; + tenantCode: string; +} + +export interface RuntimeConfigInput { + portal?: string; + apiBaseUrl?: string; + supabaseUrl?: string; + supabasePublishableKey?: string; + tenantCode?: string; + TARO_APP_PORTAL?: string; + TARO_APP_API_BASE_URL?: string; + TARO_APP_SUPABASE_URL?: string; + TARO_APP_SUPABASE_PUBLISHABLE_KEY?: string; + TARO_APP_TENANT_CODE?: string; +} + declare const process: { env: Record; }; -export const appEnv = { +const forbiddenFrontendKeys = [ + 'SUPABASE_SERVICE_ROLE_KEY', + 'SUPABASE_SECRET_KEY', + 'DATABASE_URL', + 'ALIYUN_OSS_ACCESS_KEY_SECRET', + 'TENCENT_COS_SECRET_KEY', + 'WECHAT_PAY_PRIVATE_KEY', + 'ALIPAY_APP_PRIVATE_KEY', + 'AUTH_SESSION_SECRET', + 'PLATFORM_ADMIN_API_KEY', +] as const; + +const allowedRuntimeConfigKeys = [ + 'portal', + 'apiBaseUrl', + 'supabaseUrl', + 'supabasePublishableKey', + 'tenantCode', + 'TARO_APP_PORTAL', + 'TARO_APP_API_BASE_URL', + 'TARO_APP_SUPABASE_URL', + 'TARO_APP_SUPABASE_PUBLISHABLE_KEY', + 'TARO_APP_TENANT_CODE', +] as const; + +function normalizeString(value: unknown) { + return typeof value === 'string' ? value.trim() : ''; +} + +function normalizePortal(value: unknown): Portal | null { + if (value === 'student' || value === 'tenant-admin' || value === 'platform-admin') return value; + return null; +} + +function assertNoForbiddenKeys(input: Record, source: string) { + const leaked = forbiddenFrontendKeys.filter(key => Object.prototype.hasOwnProperty.call(input, key)); + if (leaked.length) { + throw new Error(`Forbidden secret key in ${source}: ${leaked.join(', ')}`); + } + const allowed = new Set(allowedRuntimeConfigKeys); + const unknown = Object.keys(input).filter(key => !allowed.has(key)); + if (unknown.length) { + throw new Error(`Unknown key in ${source}: ${unknown.join(', ')}`); + } +} + +export const appEnv: AppEnv = { portal: (process.env.TARO_APP_PORTAL || 'student') as Portal, apiBaseUrl: process.env.TARO_APP_API_BASE_URL || 'http://127.0.0.1:8787', supabaseUrl: process.env.TARO_APP_SUPABASE_URL || '', @@ -12,19 +79,66 @@ export const appEnv = { tenantCode: process.env.TARO_APP_TENANT_CODE || '', }; +let runtimeConfigPromise: Promise | null = null; + +export function applyRuntimeConfig(input: RuntimeConfigInput, source = 'runtime config') { + assertNoForbiddenKeys(input as Record, source); + + const portal = normalizePortal(input.portal || input.TARO_APP_PORTAL); + if (portal) appEnv.portal = portal; + + const apiBaseUrl = normalizeString(input.apiBaseUrl || input.TARO_APP_API_BASE_URL); + if (apiBaseUrl) appEnv.apiBaseUrl = apiBaseUrl.replace(/\/+$/, ''); + + const supabaseUrl = normalizeString(input.supabaseUrl || input.TARO_APP_SUPABASE_URL); + if (supabaseUrl) appEnv.supabaseUrl = supabaseUrl.replace(/\/+$/, ''); + + const supabasePublishableKey = normalizeString(input.supabasePublishableKey || input.TARO_APP_SUPABASE_PUBLISHABLE_KEY); + if (supabasePublishableKey) appEnv.supabasePublishableKey = supabasePublishableKey; + + const tenantCode = normalizeString(input.tenantCode || input.TARO_APP_TENANT_CODE); + if (tenantCode) appEnv.tenantCode = tenantCode; + + return appEnv; +} + +export async function loadRuntimeConfig() { + if (process.env.TARO_ENV !== 'h5' || typeof window === 'undefined' || typeof window.fetch !== 'function') { + return appEnv; + } + + const runtimeConfigUrl = `${window.location.origin}/runtime-config.json`; + let response: Response; + try { + response = await window.fetch(runtimeConfigUrl, { + cache: 'no-store', + credentials: 'same-origin', + }); + } catch { + return appEnv; + } + if (!response.ok) return appEnv; + + const text = (await response.text()).trim(); + if (!text || !text.startsWith('{')) return appEnv; + + let config: RuntimeConfigInput; + try { + config = JSON.parse(text) as RuntimeConfigInput; + } catch (error) { + throw new Error(`Invalid Taro runtime-config.json: ${(error as Error).message}`); + } + + return applyRuntimeConfig(config, 'runtime-config.json'); +} + +export function ensureRuntimeConfigLoaded() { + if (!runtimeConfigPromise) runtimeConfigPromise = loadRuntimeConfig(); + return runtimeConfigPromise; +} + export function assertFrontendSecretsAreAbsent() { - const forbidden = [ - 'SUPABASE_SERVICE_ROLE_KEY', - 'SUPABASE_SECRET_KEY', - 'DATABASE_URL', - 'ALIYUN_OSS_ACCESS_KEY_SECRET', - 'TENCENT_COS_SECRET_KEY', - 'WECHAT_PAY_PRIVATE_KEY', - 'ALIPAY_APP_PRIVATE_KEY', - 'AUTH_SESSION_SECRET', - 'PLATFORM_ADMIN_API_KEY', - ]; - const leaked = forbidden.filter(key => process.env[key]); + const leaked = forbiddenFrontendKeys.filter(key => process.env[key]); if (leaked.length) { throw new Error(`Forbidden secret env in Taro build: ${leaked.join(', ')}`); } diff --git a/apps/taro/src/pages/bootstrap/index.tsx b/apps/taro/src/pages/bootstrap/index.tsx index 563a6ac2..8dfe78cb 100644 --- a/apps/taro/src/pages/bootstrap/index.tsx +++ b/apps/taro/src/pages/bootstrap/index.tsx @@ -1,7 +1,7 @@ import { useEffect, useState } from 'react'; import Taro from '@tarojs/taro'; import { Button, Text, View } from '@tarojs/components'; -import { appEnv, assertFrontendSecretsAreAbsent } from '@/env'; +import { appEnv, assertFrontendSecretsAreAbsent, ensureRuntimeConfigLoaded } from '@/env'; import { resolveTenant } from '@/services/api'; import './index.css'; @@ -22,7 +22,8 @@ export default function BootstrapPage() { useEffect(() => { assertFrontendSecretsAreAbsent(); - resolveTenant({ host: hostFromRuntime() }) + ensureRuntimeConfigLoaded() + .then(() => resolveTenant({ host: hostFromRuntime() })) .then(() => { setStatus('租户解析完成'); Taro.redirectTo({ url: landingPath() }); diff --git a/apps/taro/src/services/api.ts b/apps/taro/src/services/api.ts index 0d591632..25d551ce 100644 --- a/apps/taro/src/services/api.ts +++ b/apps/taro/src/services/api.ts @@ -1,5 +1,5 @@ import Taro from '@tarojs/taro'; -import { appEnv } from '@/env'; +import { appEnv, ensureRuntimeConfigLoaded } from '@/env'; import type { ApiErrorPayload, ApiSession, TenantContext } from '@/types'; import { getStorage, removeStorage, setStorage } from './storage'; @@ -67,6 +67,7 @@ export async function apiRequest( headers?: Record; } = {}, ): Promise { + await ensureRuntimeConfigLoaded(); const tenant = getTenantContext(); const session = getSession(); const token = options.token ?? session?.token ?? null; diff --git a/apps/taro/src/services/supabase.ts b/apps/taro/src/services/supabase.ts index ca2eeee0..5d06c0f5 100644 --- a/apps/taro/src/services/supabase.ts +++ b/apps/taro/src/services/supabase.ts @@ -1,11 +1,13 @@ import { createClient, type SupabaseClient } from '@supabase/supabase-js'; -import { appEnv } from '@/env'; +import { appEnv, ensureRuntimeConfigLoaded } from '@/env'; let client: SupabaseClient | null = null; +let clientKey = ''; export function getSupabaseClient() { if (!appEnv.supabaseUrl || !appEnv.supabasePublishableKey) return null; - if (!client) { + const nextClientKey = `${appEnv.supabaseUrl}|${appEnv.supabasePublishableKey}`; + if (!client || clientKey !== nextClientKey) { client = createClient(appEnv.supabaseUrl, appEnv.supabasePublishableKey, { auth: { persistSession: true, @@ -13,12 +15,18 @@ export function getSupabaseClient() { detectSessionInUrl: true, }, }); + clientKey = nextClientKey; } return client; } +export async function ensureSupabaseClient() { + await ensureRuntimeConfigLoaded(); + return getSupabaseClient(); +} + export async function getSupabaseAccessToken() { - const supabase = getSupabaseClient(); + const supabase = await ensureSupabaseClient(); if (!supabase) return null; const { data } = await supabase.auth.getSession(); return data.session?.access_token || null; diff --git a/docs/refactor/README.md b/docs/refactor/README.md index 0d0e2b94..4a7700ce 100644 --- a/docs/refactor/README.md +++ b/docs/refactor/README.md @@ -27,6 +27,7 @@ - `docs/refactor/legacy-feature-gap-matrix.md`:对照旧题库功能的新后端差距矩阵。 - `docs/refactor/supabase-frontend-access-strategy.md`:Supabase 官方推荐能力与本项目业务 API 边界。 - `docs/refactor/taro-frontend-integration.md`:Taro/H5/小程序启动、请求封装、页面/API 映射。 +- `docs/refactor/taro-h5-deployment.md`:Taro H5 三域名部署、运行时配置、Nginx、CSP、缓存和 CORS 边界。 - `docs/refactor/multitenant-auth-security-contract.md`:多租户隔离、鉴权、权限和资源安全红线。 下一步优先级: diff --git a/docs/refactor/blueprint-coverage.md b/docs/refactor/blueprint-coverage.md index c7ae9795..44b5166f 100644 --- a/docs/refactor/blueprint-coverage.md +++ b/docs/refactor/blueprint-coverage.md @@ -1,6 +1,6 @@ # SaaS 蓝图覆盖矩阵 -更新时间:2026-06-29 +更新时间:2026-06-30 ## 目标定位 @@ -9,7 +9,7 @@ - 平台超级管理员:管理所有租户、SaaS 套餐、年费/服务费、公共/地区题库披露。 - 租户公司:拥有自己的品牌、域名、支付/登录/CRM 配置、成员角色、题库内容、销售/代理体系。 - 学生端:刷题、错题、收藏、背单词、知识手册、分数线、视频解析、会员权益。 -- 跨端前端:后续 Taro 一套代码输出 H5 和小程序,统一调用 `apps/api`。 +- 跨端前端:Taro 一套代码优先输出 H5 学生端、租户后台和平台后台,后续继续扩展微信小程序,统一调用 `apps/api`。 ## 当前覆盖情况 @@ -32,7 +32,7 @@ | 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 | | 支付 | 可联调 | 订单、支付记录、手动确认权限保护、权益发放、租户商户配置、微信支付 JSAPI、支付宝 WAP/H5、webhook 幂等、退款状态机、退款通知、补偿 worker、微信/支付宝官方账单下载 worker、资金对账导入比对、异常查询、差错工单和事件轨迹 | 异常订单运营台、真实生产账单格式验收、服务商/平台代收模式 | | AI 择校推荐 | 部分完成 | SVIP 门禁、学生输入 schema、地区/分数线上下文、`local_rules` 稳定 JSON 报告、报告台账/列表/详情、Taro 学生端基础页 | 真实 AI provider、prompt 版本管理后台、PDF 报告生成、人工复核和运营配置 | -| Taro 跨端 | 未开始 | 旧 Web 新 API 适配开始 | `apps/taro`、共享 API client、H5/小程序统一构建 | +| Taro 跨端 | 部分完成 | `apps/taro` 已有 Taro 4 React 工程、H5 学生端/租户后台/平台后台三套构建入口、统一 API client、Supabase Auth client、H5 `runtime-config.json` 运行时配置、学生端第一批学习页面、租户后台第一批运营页面、平台后台第一批管理页面 | 小程序真机兼容、支付/分享容器、公式真机验收、题图资源字段化、端到端测试和更完整 UI 打磨 | ## 接下来优先级 diff --git a/docs/refactor/frontend-handoff-index.md b/docs/refactor/frontend-handoff-index.md index a970e6b8..54e89641 100644 --- a/docs/refactor/frontend-handoff-index.md +++ b/docs/refactor/frontend-handoff-index.md @@ -18,9 +18,11 @@ - 明确 Taro 什么时候可以用 Supabase client,什么时候必须走 `apps/api`。 6. `docs/refactor/taro-frontend-integration.md` - Taro 启动、租户解析、请求封装、页面/API 映射、跨端注意事项。 -7. `docs/refactor/multitenant-auth-security-contract.md` +7. `docs/refactor/taro-h5-deployment.md` + - H5 三域名部署、`runtime-config.json`、Nginx history fallback、缓存、CSP 和 CORS 边界。 +8. `docs/refactor/multitenant-auth-security-contract.md` - 多租户、鉴权、权限、资源签名和生产安全红线。 -8. `docs/refactor/content-import-contract.md` +9. `docs/refactor/content-import-contract.md` - 后台内容导入、题目 JSON、单词、知识手册、分数线、视频的后端校验契约。 ## 当前可进入的前端工作 @@ -37,6 +39,7 @@ - 可以接入迁移期短信登录和 `tk_` session,用于本地/内网联调。 - H5 可以直接用 Supabase Auth access token 调 `apps/api`;后端已支持 JWT 验签和业务用户映射。 - H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session;微信小程序端先验证运行时兼容性,业务数据默认仍走 `apps/api`。 +- H5 生产部署优先用每个静态目录自己的 `runtime-config.json` 配置 `apiBaseUrl`、`supabaseUrl`、`supabasePublishableKey`、`tenantCode`;不要为了换域名重打包,也不要把任何 service role、数据库、支付、短信、对象存储密钥放进该文件。 - 可以接入租户品牌、已发布主题、公开素材、功能开关和域名/小程序参数解析;学生端只读 `/api/tenant/resolve` 的 `branding.theme/publicAssets`,租户后台草稿走 `/api/tenant-admin/theme`。 - 租户后台可以接入角色模板和成员 API:`/api/tenant-admin/role-templates`、`/api/tenant-admin/members`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性和成员模板绑定。 - 租户后台可以接入勋章管理和手动发放:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants`;学生端用 `GET /api/profile/badges` 展示成就。 diff --git a/docs/refactor/supabase-frontend-access-strategy.md b/docs/refactor/supabase-frontend-access-strategy.md index 3b7bd8c6..ac211096 100644 --- a/docs/refactor/supabase-frontend-access-strategy.md +++ b/docs/refactor/supabase-frontend-access-strategy.md @@ -110,16 +110,19 @@ Taro 要同时支持 H5 和微信小程序。Supabase 官方 JavaScript client - 前端保存后端返回的 access token/session。 4. 无论 H5 还是小程序,复杂业务数据默认调用 `apps/api`、Edge Function 或 RPC,不直接写 Supabase 表。 -## 推荐前端环境变量 +## 推荐前端公开配置 -只允许出现在前端构建中的变量: +只允许出现在前端构建变量或 H5 `runtime-config.json` 中的公开配置: ```text TARO_APP_API_BASE_URL=https://api.example.com TARO_APP_SUPABASE_URL=https://.supabase.co TARO_APP_SUPABASE_PUBLISHABLE_KEY=sb_publishable_xxx +TARO_APP_TENANT_CODE= ``` +H5 生产部署优先使用每个静态目录自己的 `runtime-config.json`,字段名可用 `apiBaseUrl`、`supabaseUrl`、`supabasePublishableKey`、`tenantCode`。这样学生端、租户后台、平台后台可以共用构建流程,各自按域名目录独立配置。完整规则见 `docs/refactor/taro-h5-deployment.md`。 + 禁止出现在前端: ```text diff --git a/docs/refactor/taro-frontend-integration.md b/docs/refactor/taro-frontend-integration.md index 5c428f8b..6bb3eba7 100644 --- a/docs/refactor/taro-frontend-integration.md +++ b/docs/refactor/taro-frontend-integration.md @@ -96,15 +96,17 @@ ALLOW_PLATFORM_ADMIN_KEY=false 这样旧式 `x-user-id` 和平台管理 key 会被拒绝,前端可以提前发现未按 session 接入的页面。 -前端环境变量只允许包含: +前端构建变量或 H5 `runtime-config.json` 只允许包含: ```text -TARO_APP_API_BASE_URL -TARO_APP_SUPABASE_URL -TARO_APP_SUPABASE_PUBLISHABLE_KEY +TARO_APP_PORTAL / portal +TARO_APP_API_BASE_URL / apiBaseUrl +TARO_APP_SUPABASE_URL / supabaseUrl +TARO_APP_SUPABASE_PUBLISHABLE_KEY / supabasePublishableKey +TARO_APP_TENANT_CODE / tenantCode ``` -禁止把 Supabase secret key、service role key、数据库连接串、对象存储密钥、支付私钥放进 Taro。 +H5 线上优先使用每个静态目录根部的 `runtime-config.json` 覆盖公开配置,避免 API/Auth 域名变化时重打包。完整部署、Nginx、CSP、缓存和 CORS 规则见 `docs/refactor/taro-h5-deployment.md`。禁止把 Supabase secret key、service role key、数据库连接串、对象存储密钥、支付私钥放进 Taro 构建变量或 `runtime-config.json`。 统一错误处理: diff --git a/docs/refactor/taro-h5-deployment.md b/docs/refactor/taro-h5-deployment.md index 2c7a7316..8f36c62b 100644 --- a/docs/refactor/taro-h5-deployment.md +++ b/docs/refactor/taro-h5-deployment.md @@ -1,13 +1,15 @@ # Taro H5 三入口部署说明 -更新时间:2026-06-29 +更新时间:2026-06-30 -当前 `apps/taro` 按一个 Taro 工程、三套 H5 产物组织: +当前 `apps/taro` 采用一个 Taro 4 React 工程、三套 H5 产物的方式交付: -- 学生学习端:刷题、背单词、知识手册、分数线、资料、会员和个人中心。 -- 租户后台:品牌、域名、题库、导入、学生、订单、营销、销售和数据看板。 +- 学生学习端:刷题、背单词、知识手册、分数线、资料、会员、个人中心。 +- 租户后台:品牌、主题、域名、题库、导入、学生、订单、营销、销售、CRM、财务和数据看板。 - 平台后台:租户、SaaS 套餐、订阅账单、公共题库授权和平台审计。 +后续微信小程序仍复用同一套业务 services 和页面逻辑,但 H5 是当前优先上线形态。 + ## 构建命令 ```bash @@ -26,27 +28,55 @@ apps/taro/dist/h5-platform-admin 推荐部署: -| 域名 | 目录 | 说明 | +| 域名 | 静态目录 | 说明 | | --- | --- | --- | | `www.example.com` 或租户自有学生端域名 | `h5-student` | 面向学生和 C 端用户 | | `admin.example.com` | `h5-tenant-admin` | 面向租户公司运营、教师、销售、代理、管理员 | | `console.example.com` | `h5-platform-admin` | 面向平台超级管理员 | -三个入口可以放在同一台服务器的三个静态目录,也可以放到 CDN/对象存储静态网站。API 推荐独立域名,例如 `api.example.com`。 +三个入口可以放在同一台服务器的三个静态目录,也可以分别放到不同服务器或 CDN/对象存储静态网站。API 推荐独立域名,例如 `api.example.com`。 -## 环境变量 +## 运行时配置 -构建时只允许注入: +H5 产物支持运行时覆盖公开配置。每个静态目录根部放一个 `runtime-config.json`: ```text -TARO_APP_PORTAL=student | tenant-admin | platform-admin -TARO_APP_API_BASE_URL=https://api.example.com -TARO_APP_SUPABASE_URL=https:// -TARO_APP_SUPABASE_PUBLISHABLE_KEY= -TARO_APP_TENANT_CODE=<可选,小程序或预览环境使用> +/www/tiku/h5-student/runtime-config.json +/www/tiku/h5-tenant-admin/runtime-config.json +/www/tiku/h5-platform-admin/runtime-config.json ``` -禁止进入前端构建: +示例文件: + +```text +apps/taro/deploy/h5-student.runtime-config.example.json +apps/taro/deploy/h5-tenant-admin.runtime-config.example.json +apps/taro/deploy/h5-platform-admin.runtime-config.example.json +``` + +生产示例: + +```json +{ + "portal": "student", + "apiBaseUrl": "https://api.example.com", + "supabaseUrl": "https://auth.example.com", + "supabasePublishableKey": "replace-with-supabase-publishable-key", + "tenantCode": "" +} +``` + +允许字段: + +```text +portal student | tenant-admin | platform-admin +apiBaseUrl apps/api 公开 HTTPS 地址 +supabaseUrl Supabase Auth/API 公开 HTTPS 地址 +supabasePublishableKey Supabase publishable/anon key +tenantCode 小程序、预览环境或指定租户部署可用 +``` + +这些字段是前端公开配置,不是密钥。`apps/taro/src/env.ts` 会拒绝 `runtime-config.json` 中出现服务端密钥类字段,例如: ```text SUPABASE_SERVICE_ROLE_KEY @@ -60,34 +90,32 @@ AUTH_SESSION_SECRET PLATFORM_ADMIN_API_KEY ``` -`apps/taro/src/env.ts` 会在启动时检查这些危险变量,防止误把服务端密钥打包到前端。 +构建时仍可以设置同名 `TARO_APP_*` 变量作为默认值,但生产更推荐用 `runtime-config.json`。这样 API 域名、Auth 域名、租户预览码变化时不需要重新构建 H5。 -## Nginx 建议 +## Nginx 示例 -H5 使用 history 路由时,静态服务器需要把未知路径回退到 `index.html`。 +H5 使用 browser history 路由时,静态服务器需要把未知路径回退到 `index.html`。同时对 `runtime-config.json` 禁用缓存,对 hash 后的 JS/CSS 长缓存。 ```nginx server { + listen 443 ssl http2; server_name www.example.com; root /www/tiku/h5-student; - location / { - try_files $uri $uri/ /index.html; + add_header X-Content-Type-Options nosniff always; + add_header Referrer-Policy strict-origin-when-cross-origin always; + add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https: blob:; font-src 'self' data:; connect-src 'self' https://api.example.com https://auth.example.com; media-src 'self' https: blob:; frame-ancestors 'none'; base-uri 'self'; form-action 'self'" always; + + location = /runtime-config.json { + add_header Cache-Control "no-store" always; + try_files $uri =404; } -} -server { - server_name admin.example.com; - root /www/tiku/h5-tenant-admin; - - location / { - try_files $uri $uri/ /index.html; + location ~* \.(?:js|css|woff2?|png|jpg|jpeg|gif|svg)$ { + add_header Cache-Control "public, max-age=31536000, immutable" always; + try_files $uri =404; } -} - -server { - server_name console.example.com; - root /www/tiku/h5-platform-admin; location / { try_files $uri $uri/ /index.html; @@ -95,35 +123,78 @@ server { } ``` -API CORS 必须只允许实际域名,不允许生产环境 `CORS=*`。生产前运行: +租户后台和平台后台复用同样规则,替换 `server_name`、`root` 和 CSP 中的 `connect-src` 域名即可。若学生端需要打开 OSS/COS/CDN 签名资源,`img-src/media-src` 可加入对应 HTTPS 域名;不要加入通配符 `*`。 -```bash -npm run readiness:production -npm run readiness:production:db +## CORS 和 Cookie + +API 的生产 `CORS_ORIGIN` 必须只包含实际前端域名: + +```text +CORS_ORIGIN=https://www.example.com,https://admin.example.com,https://console.example.com +``` + +禁止生产环境使用: + +```text +CORS_ORIGIN=* +``` + +当前前端以 `Authorization: Bearer ` 调用 API,`x-tenant-id` 只作为租户上下文,不作为身份来源。生产建议: + +```text +ALLOW_LEGACY_AUTH_HEADERS=false +ALLOW_PLATFORM_ADMIN_KEY=false ``` ## 前端请求边界 - 所有页面统一通过 `apps/taro/src/services/api.ts` 调用后端。 -- H5 可以用 Supabase client 管理 Auth session,但业务数据默认走 `apps/api`。 -- `x-tenant-id` 只是租户上下文,不是身份来源。 -- 登录后禁止传 `x-user-id` 或 body/query `userId` 表示当前用户。 +- H5 可以用 Supabase client 管理 Auth session/JWT,但业务数据默认走 `apps/api`。 - 订单、支付、权益、内容导入、后台配置、CRM、对象存储签名、视频播放签名必须走后端命令层。 +- 登录后禁止传 `x-user-id` 或 body/query `userId` 表示当前用户。 +- 私有 PDF、图片、视频不能由前端拼接 URL,必须使用 `content_assets` 和后端短签名。 + +## 发布步骤 + +1. 在新服务器或 CI 环境构建三套 H5: + + ```bash + npm ci + npm run build:taro:h5:student + npm run build:taro:h5:tenant + npm run build:taro:h5:platform + ``` + +2. 拷贝静态产物到对应 Web 根目录。 + +3. 根据示例文件创建每个目录的 `runtime-config.json`。 + +4. 配置 Nginx history fallback、缓存策略、安全响应头和 HTTPS。 + +5. 配置 API 的 `CORS_ORIGIN`、Supabase Auth 回调域名、微信/QQ/支付宝/微信支付回调域名。 + +6. 运行生产就绪检查: + + ```bash + npm run readiness:production + npm run readiness:production:db + npm run check:taro + ``` + +7. 打开三个域名,确认启动页能解析租户,登录后接口请求使用 `Authorization` 和正确的 `x-tenant-id`。 ## 安全审计边界 -H5 线上只发布 `apps/taro/dist/**` 静态文件,不要把 `apps/taro/node_modules` 或源码目录部署到 Web 根目录。后端/API/worker 的生产依赖审计使用: +H5 线上只发布 `apps/taro/dist/**` 静态文件和每个目录自己的 `runtime-config.json`,不要把 `apps/taro/node_modules`、源码目录、`.env`、部署脚本缓存放进 Web 根目录。 + +后端/API/worker 的生产依赖审计使用: ```bash npm run audit:runtime ``` -Taro 4.2.0 当前构建工具链仍会触发 `npm run audit:taro:toolchain` 的上游 high/critical 告警,主要来自构建期 CLI、webpack、swiper、lodash-es 等传递依赖。不要使用 `npm audit fix --force` 将 Taro 降级到 3.x;应等 Taro 官方升级后再处理,或者后续评估 Vite runner 替代方案。上线时以静态产物、前端密钥检查、CORS 域名白名单和 API runtime audit 作为阻断项。 +Taro 4.2.0 当前构建工具链仍可能触发 `npm run audit:taro:toolchain` 的上游 high/critical 告警,主要来自构建期 CLI、webpack、swiper、lodash-es 等传递依赖。不要使用 `npm audit fix --force` 将 Taro 降级到 3.x;应等 Taro 官方升级后再处理,或后续评估 Vite runner 替代方案。上线时以静态产物、前端密钥检查、CORS 域名白名单、CSP 和 API runtime audit 作为阻断项。 -## 下一步页面顺序 +## 小程序后续兼容 -1. 学生端:租户启动、登录、首页、题库入口、练习、错题、收藏。 -2. 学生端:背单词、知识手册、分数线、资料、会员、个人中心。 -3. 租户后台:数据看板、内容导航、题目录入/导入、学生管理、营销中心。 -4. 平台后台:租户、套餐、账单、公共题库授权。 -5. 小程序:验证 storage/fetch/Auth 兼容性,复用同一套 API client。 +当前 `runtime-config.json` 只用于 H5。微信小程序版本应通过编译变量、小程序启动参数或后台小程序配置传入 `tenantCode`,再调用 `GET /api/tenant/resolve?tenantCode=...`。小程序端如 `supabase-js` 兼容性不稳定,保留 `apps/api/auth/*` 登录适配层,H5 继续使用 Supabase client 管理 Auth。 diff --git a/package.json b/package.json index 943501f6..b4c8a0a5 100644 --- a/package.json +++ b/package.json @@ -42,7 +42,7 @@ "test:worker:exports": "npm run db:smoke-seed && npm run build:worker && node scripts/export-worker-integration-test.js", "test:worker:imports": "npm run db:smoke-seed && npm run build:worker && node scripts/import-worker-integration-test.js", "test:worker:public-banks": "npm run db:smoke-seed && npm run build:worker && node scripts/public-bank-worker-integration-test.js", - "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js", + "test:readiness": "node scripts/production-readiness-check-test.js && node scripts/production-config-failfast-test.js && node --import tsx scripts/taro-runtime-config-test.js", "test:pb:dry-run": "node scripts/pb-dry-run-report-test.js", "readiness:production": "node scripts/production-readiness-check.js --skip-db", "readiness:production:db": "node scripts/production-readiness-check.js --check-db", diff --git a/scripts/taro-runtime-config-test.js b/scripts/taro-runtime-config-test.js new file mode 100644 index 00000000..d044fd20 --- /dev/null +++ b/scripts/taro-runtime-config-test.js @@ -0,0 +1,53 @@ +import assert from 'node:assert/strict'; +import { pathToFileURL } from 'node:url'; + +const repoRoot = process.cwd(); +const envModule = await import(pathToFileURL(`${repoRoot}/apps/taro/src/env.ts`).href); + +envModule.applyRuntimeConfig({ + portal: 'tenant-admin', + apiBaseUrl: 'https://api.gongxue100.com///', + supabaseUrl: 'https://auth.gongxue100.com///', + supabasePublishableKey: 'sb_publishable_public_key', + tenantCode: 'tenant-a', +}); + +assert.equal(envModule.appEnv.portal, 'tenant-admin'); +assert.equal(envModule.appEnv.apiBaseUrl, 'https://api.gongxue100.com'); +assert.equal(envModule.appEnv.supabaseUrl, 'https://auth.gongxue100.com'); +assert.equal(envModule.appEnv.supabasePublishableKey, 'sb_publishable_public_key'); +assert.equal(envModule.appEnv.tenantCode, 'tenant-a'); + +envModule.applyRuntimeConfig({ + TARO_APP_PORTAL: 'platform-admin', + TARO_APP_API_BASE_URL: 'https://api2.gongxue100.com', +}); + +assert.equal(envModule.appEnv.portal, 'platform-admin'); +assert.equal(envModule.appEnv.apiBaseUrl, 'https://api2.gongxue100.com'); + +envModule.applyRuntimeConfig({ + portal: 'invalid-portal', + apiBaseUrl: '', +}); + +assert.equal(envModule.appEnv.portal, 'platform-admin'); +assert.equal(envModule.appEnv.apiBaseUrl, 'https://api2.gongxue100.com'); + +assert.throws( + () => envModule.applyRuntimeConfig({ + apiBaseUrl: 'https://api.gongxue100.com', + SUPABASE_SERVICE_ROLE_KEY: 'must-not-ship-to-browser', + }), + /Forbidden secret key in runtime config: SUPABASE_SERVICE_ROLE_KEY/, +); + +assert.throws( + () => envModule.applyRuntimeConfig({ + apiBaseUrl: 'https://api.gongxue100.com', + unexpectedFeatureFlag: 'unsafe-drift', + }), + /Unknown key in runtime config: unexpectedFeatureFlag/, +); + +console.log('[PASS] Taro runtime config guardrails');