feat: add taro h5 runtime deployment config

This commit is contained in:
Codex
2026-06-30 01:19:14 +08:00
parent b8242429bb
commit 25d1b8e796
16 changed files with 370 additions and 78 deletions

View File

@@ -120,6 +120,20 @@ apps/taro/dist/h5-platform-admin
推荐分别部署到学生端域名、租户后台域名、平台后台域名;三者共用 `apps/taro/src/services/api.ts` 请求层,业务数据默认调用 `apps/api`,不要在页面里直写 Supabase 表。 推荐分别部署到学生端域名、租户后台域名、平台后台域名;三者共用 `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 ```text

View File

@@ -0,0 +1,7 @@
{
"portal": "platform-admin",
"apiBaseUrl": "https://api.gongxue100.com",
"supabaseUrl": "https://auth.gongxue100.com",
"supabasePublishableKey": "replace-with-supabase-publishable-key",
"tenantCode": ""
}

View File

@@ -0,0 +1,7 @@
{
"portal": "student",
"apiBaseUrl": "https://api.gongxue100.com",
"supabaseUrl": "https://auth.gongxue100.com",
"supabasePublishableKey": "replace-with-supabase-publishable-key",
"tenantCode": ""
}

View File

@@ -0,0 +1,7 @@
{
"portal": "tenant-admin",
"apiBaseUrl": "https://api.gongxue100.com",
"supabaseUrl": "https://auth.gongxue100.com",
"supabasePublishableKey": "replace-with-supabase-publishable-key",
"tenantCode": ""
}

View File

@@ -1,10 +1,77 @@
export type Portal = 'student' | 'tenant-admin' | 'platform-admin'; 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: { declare const process: {
env: Record<string, string | undefined>; env: Record<string, string | undefined>;
}; };
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<string, unknown>, 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<string>(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, portal: (process.env.TARO_APP_PORTAL || 'student') as Portal,
apiBaseUrl: process.env.TARO_APP_API_BASE_URL || 'http://127.0.0.1:8787', apiBaseUrl: process.env.TARO_APP_API_BASE_URL || 'http://127.0.0.1:8787',
supabaseUrl: process.env.TARO_APP_SUPABASE_URL || '', supabaseUrl: process.env.TARO_APP_SUPABASE_URL || '',
@@ -12,19 +79,66 @@ export const appEnv = {
tenantCode: process.env.TARO_APP_TENANT_CODE || '', tenantCode: process.env.TARO_APP_TENANT_CODE || '',
}; };
let runtimeConfigPromise: Promise<AppEnv> | null = null;
export function applyRuntimeConfig(input: RuntimeConfigInput, source = 'runtime config') {
assertNoForbiddenKeys(input as Record<string, unknown>, 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() { export function assertFrontendSecretsAreAbsent() {
const forbidden = [ const leaked = forbiddenFrontendKeys.filter(key => process.env[key]);
'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]);
if (leaked.length) { if (leaked.length) {
throw new Error(`Forbidden secret env in Taro build: ${leaked.join(', ')}`); throw new Error(`Forbidden secret env in Taro build: ${leaked.join(', ')}`);
} }

View File

@@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import Taro from '@tarojs/taro'; import Taro from '@tarojs/taro';
import { Button, Text, View } from '@tarojs/components'; import { Button, Text, View } from '@tarojs/components';
import { appEnv, assertFrontendSecretsAreAbsent } from '@/env'; import { appEnv, assertFrontendSecretsAreAbsent, ensureRuntimeConfigLoaded } from '@/env';
import { resolveTenant } from '@/services/api'; import { resolveTenant } from '@/services/api';
import './index.css'; import './index.css';
@@ -22,7 +22,8 @@ export default function BootstrapPage() {
useEffect(() => { useEffect(() => {
assertFrontendSecretsAreAbsent(); assertFrontendSecretsAreAbsent();
resolveTenant({ host: hostFromRuntime() }) ensureRuntimeConfigLoaded()
.then(() => resolveTenant({ host: hostFromRuntime() }))
.then(() => { .then(() => {
setStatus('租户解析完成'); setStatus('租户解析完成');
Taro.redirectTo({ url: landingPath() }); Taro.redirectTo({ url: landingPath() });

View File

@@ -1,5 +1,5 @@
import Taro from '@tarojs/taro'; import Taro from '@tarojs/taro';
import { appEnv } from '@/env'; import { appEnv, ensureRuntimeConfigLoaded } from '@/env';
import type { ApiErrorPayload, ApiSession, TenantContext } from '@/types'; import type { ApiErrorPayload, ApiSession, TenantContext } from '@/types';
import { getStorage, removeStorage, setStorage } from './storage'; import { getStorage, removeStorage, setStorage } from './storage';
@@ -67,6 +67,7 @@ export async function apiRequest<T>(
headers?: Record<string, string>; headers?: Record<string, string>;
} = {}, } = {},
): Promise<T> { ): Promise<T> {
await ensureRuntimeConfigLoaded();
const tenant = getTenantContext(); const tenant = getTenantContext();
const session = getSession(); const session = getSession();
const token = options.token ?? session?.token ?? null; const token = options.token ?? session?.token ?? null;

View File

@@ -1,11 +1,13 @@
import { createClient, type SupabaseClient } from '@supabase/supabase-js'; import { createClient, type SupabaseClient } from '@supabase/supabase-js';
import { appEnv } from '@/env'; import { appEnv, ensureRuntimeConfigLoaded } from '@/env';
let client: SupabaseClient | null = null; let client: SupabaseClient | null = null;
let clientKey = '';
export function getSupabaseClient() { export function getSupabaseClient() {
if (!appEnv.supabaseUrl || !appEnv.supabasePublishableKey) return null; 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, { client = createClient(appEnv.supabaseUrl, appEnv.supabasePublishableKey, {
auth: { auth: {
persistSession: true, persistSession: true,
@@ -13,12 +15,18 @@ export function getSupabaseClient() {
detectSessionInUrl: true, detectSessionInUrl: true,
}, },
}); });
clientKey = nextClientKey;
} }
return client; return client;
} }
export async function ensureSupabaseClient() {
await ensureRuntimeConfigLoaded();
return getSupabaseClient();
}
export async function getSupabaseAccessToken() { export async function getSupabaseAccessToken() {
const supabase = getSupabaseClient(); const supabase = await ensureSupabaseClient();
if (!supabase) return null; if (!supabase) return null;
const { data } = await supabase.auth.getSession(); const { data } = await supabase.auth.getSession();
return data.session?.access_token || null; return data.session?.access_token || null;

View File

@@ -27,6 +27,7 @@
- `docs/refactor/legacy-feature-gap-matrix.md`:对照旧题库功能的新后端差距矩阵。 - `docs/refactor/legacy-feature-gap-matrix.md`:对照旧题库功能的新后端差距矩阵。
- `docs/refactor/supabase-frontend-access-strategy.md`Supabase 官方推荐能力与本项目业务 API 边界。 - `docs/refactor/supabase-frontend-access-strategy.md`Supabase 官方推荐能力与本项目业务 API 边界。
- `docs/refactor/taro-frontend-integration.md`Taro/H5/小程序启动、请求封装、页面/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`:多租户隔离、鉴权、权限和资源安全红线。 - `docs/refactor/multitenant-auth-security-contract.md`:多租户隔离、鉴权、权限和资源安全红线。
下一步优先级: 下一步优先级:

View File

@@ -1,6 +1,6 @@
# SaaS 蓝图覆盖矩阵 # SaaS 蓝图覆盖矩阵
更新时间2026-06-29 更新时间2026-06-30
## 目标定位 ## 目标定位
@@ -9,7 +9,7 @@
- 平台超级管理员管理所有租户、SaaS 套餐、年费/服务费、公共/地区题库披露。 - 平台超级管理员管理所有租户、SaaS 套餐、年费/服务费、公共/地区题库披露。
- 租户公司:拥有自己的品牌、域名、支付/登录/CRM 配置、成员角色、题库内容、销售/代理体系。 - 租户公司:拥有自己的品牌、域名、支付/登录/CRM 配置、成员角色、题库内容、销售/代理体系。
- 学生端:刷题、错题、收藏、背单词、知识手册、分数线、视频解析、会员权益。 - 学生端:刷题、错题、收藏、背单词、知识手册、分数线、视频解析、会员权益。
- 跨端前端:后续 Taro 一套代码输出 H5 小程序,统一调用 `apps/api` - 跨端前端Taro 一套代码优先输出 H5 学生端、租户后台和平台后台,后续继续扩展微信小程序,统一调用 `apps/api`
## 当前覆盖情况 ## 当前覆盖情况
@@ -32,7 +32,7 @@
| 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 | | 登录认证 | 可联调 | 短信 mock、阿里云/腾讯云短信 adapter、迁移期 session、Supabase Auth JWT、微信小程序登录、微信网页登录、QQ 登录、手机号绑定/换绑、OAuth 配置表 | 真实生产账号和回调域名联调 |
| 支付 | 可联调 | 订单、支付记录、手动确认权限保护、权益发放、租户商户配置、微信支付 JSAPI、支付宝 WAP/H5、webhook 幂等、退款状态机、退款通知、补偿 worker、微信/支付宝官方账单下载 worker、资金对账导入比对、异常查询、差错工单和事件轨迹 | 异常订单运营台、真实生产账单格式验收、服务商/平台代收模式 | | 支付 | 可联调 | 订单、支付记录、手动确认权限保护、权益发放、租户商户配置、微信支付 JSAPI、支付宝 WAP/H5、webhook 幂等、退款状态机、退款通知、补偿 worker、微信/支付宝官方账单下载 worker、资金对账导入比对、异常查询、差错工单和事件轨迹 | 异常订单运营台、真实生产账单格式验收、服务商/平台代收模式 |
| AI 择校推荐 | 部分完成 | SVIP 门禁、学生输入 schema、地区/分数线上下文、`local_rules` 稳定 JSON 报告、报告台账/列表/详情、Taro 学生端基础页 | 真实 AI provider、prompt 版本管理后台、PDF 报告生成、人工复核和运营配置 | | 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 打磨 |
## 接下来优先级 ## 接下来优先级

View File

@@ -18,9 +18,11 @@
- 明确 Taro 什么时候可以用 Supabase client什么时候必须走 `apps/api` - 明确 Taro 什么时候可以用 Supabase client什么时候必须走 `apps/api`
6. `docs/refactor/taro-frontend-integration.md` 6. `docs/refactor/taro-frontend-integration.md`
- Taro 启动、租户解析、请求封装、页面/API 映射、跨端注意事项。 - 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、单词、知识手册、分数线、视频的后端校验契约。 - 后台内容导入、题目 JSON、单词、知识手册、分数线、视频的后端校验契约。
## 当前可进入的前端工作 ## 当前可进入的前端工作
@@ -37,6 +39,7 @@
- 可以接入迁移期短信登录和 `tk_` session用于本地/内网联调。 - 可以接入迁移期短信登录和 `tk_` session用于本地/内网联调。
- H5 可以直接用 Supabase Auth access token 调 `apps/api`;后端已支持 JWT 验签和业务用户映射。 - H5 可以直接用 Supabase Auth access token 调 `apps/api`;后端已支持 JWT 验签和业务用户映射。
- H5 可以优先验证 `@supabase/supabase-js` 管理 Auth session微信小程序端先验证运行时兼容性业务数据默认仍走 `apps/api` - 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/tenant/resolve``branding.theme/publicAssets`,租户后台草稿走 `/api/tenant-admin/theme`
- 租户后台可以接入角色模板和成员 API`/api/tenant-admin/role-templates``/api/tenant-admin/members`,用于运营、教师、销售、代理等自定义菜单/模块/字段可见性和成员模板绑定。 - 租户后台可以接入角色模板和成员 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` 展示成就。 - 租户后台可以接入勋章管理和手动发放:`GET/PUT /api/tenant-admin/badges``GET/POST /api/tenant-admin/badge-grants`;学生端用 `GET /api/profile/badges` 展示成就。

View File

@@ -110,16 +110,19 @@ Taro 要同时支持 H5 和微信小程序。Supabase 官方 JavaScript client
- 前端保存后端返回的 access token/session。 - 前端保存后端返回的 access token/session。
4. 无论 H5 还是小程序,复杂业务数据默认调用 `apps/api`、Edge Function 或 RPC不直接写 Supabase 表。 4. 无论 H5 还是小程序,复杂业务数据默认调用 `apps/api`、Edge Function 或 RPC不直接写 Supabase 表。
## 推荐前端环境变量 ## 推荐前端公开配置
只允许出现在前端构建中的变量: 只允许出现在前端构建变量或 H5 `runtime-config.json` 中的公开配置
```text ```text
TARO_APP_API_BASE_URL=https://api.example.com TARO_APP_API_BASE_URL=https://api.example.com
TARO_APP_SUPABASE_URL=https://<project-ref>.supabase.co TARO_APP_SUPABASE_URL=https://<project-ref>.supabase.co
TARO_APP_SUPABASE_PUBLISHABLE_KEY=sb_publishable_xxx TARO_APP_SUPABASE_PUBLISHABLE_KEY=sb_publishable_xxx
TARO_APP_TENANT_CODE=<optional>
``` ```
H5 生产部署优先使用每个静态目录自己的 `runtime-config.json`,字段名可用 `apiBaseUrl``supabaseUrl``supabasePublishableKey``tenantCode`。这样学生端、租户后台、平台后台可以共用构建流程,各自按域名目录独立配置。完整规则见 `docs/refactor/taro-h5-deployment.md`
禁止出现在前端: 禁止出现在前端:
```text ```text

View File

@@ -96,15 +96,17 @@ ALLOW_PLATFORM_ADMIN_KEY=false
这样旧式 `x-user-id` 和平台管理 key 会被拒绝,前端可以提前发现未按 session 接入的页面。 这样旧式 `x-user-id` 和平台管理 key 会被拒绝,前端可以提前发现未按 session 接入的页面。
前端环境变量只允许包含: 前端构建变量或 H5 `runtime-config.json` 只允许包含:
```text ```text
TARO_APP_API_BASE_URL TARO_APP_PORTAL / portal
TARO_APP_SUPABASE_URL TARO_APP_API_BASE_URL / apiBaseUrl
TARO_APP_SUPABASE_PUBLISHABLE_KEY 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`
统一错误处理: 统一错误处理:

View File

@@ -1,13 +1,15 @@
# Taro H5 三入口部署说明 # Taro H5 三入口部署说明
更新时间2026-06-29 更新时间2026-06-30
当前 `apps/taro` 一个 Taro 工程、三套 H5 产物组织 当前 `apps/taro` 采用一个 Taro 4 React 工程、三套 H5 产物的方式交付
- 学生学习端:刷题、背单词、知识手册、分数线、资料、会员个人中心。 - 学生学习端:刷题、背单词、知识手册、分数线、资料、会员个人中心。
- 租户后台:品牌、域名、题库、导入、学生、订单、营销、销售和数据看板。 - 租户后台:品牌、主题、域名、题库、导入、学生、订单、营销、销售、CRM、财务和数据看板。
- 平台后台租户、SaaS 套餐、订阅账单、公共题库授权和平台审计。 - 平台后台租户、SaaS 套餐、订阅账单、公共题库授权和平台审计。
后续微信小程序仍复用同一套业务 services 和页面逻辑,但 H5 是当前优先上线形态。
## 构建命令 ## 构建命令
```bash ```bash
@@ -26,27 +28,55 @@ apps/taro/dist/h5-platform-admin
推荐部署: 推荐部署:
| 域名 | 目录 | 说明 | | 域名 | 静态目录 | 说明 |
| --- | --- | --- | | --- | --- | --- |
| `www.example.com` 或租户自有学生端域名 | `h5-student` | 面向学生和 C 端用户 | | `www.example.com` 或租户自有学生端域名 | `h5-student` | 面向学生和 C 端用户 |
| `admin.example.com` | `h5-tenant-admin` | 面向租户公司运营、教师、销售、代理、管理员 | | `admin.example.com` | `h5-tenant-admin` | 面向租户公司运营、教师、销售、代理、管理员 |
| `console.example.com` | `h5-platform-admin` | 面向平台超级管理员 | | `console.example.com` | `h5-platform-admin` | 面向平台超级管理员 |
三个入口可以放在同一台服务器的三个静态目录,也可以放到 CDN/对象存储静态网站。API 推荐独立域名,例如 `api.example.com` 三个入口可以放在同一台服务器的三个静态目录,也可以分别放到不同服务器或 CDN/对象存储静态网站。API 推荐独立域名,例如 `api.example.com`
## 环境变量 ## 运行时配置
构建时只允许注入 H5 产物支持运行时覆盖公开配置。每个静态目录根部放一个 `runtime-config.json`
```text ```text
TARO_APP_PORTAL=student | tenant-admin | platform-admin /www/tiku/h5-student/runtime-config.json
TARO_APP_API_BASE_URL=https://api.example.com /www/tiku/h5-tenant-admin/runtime-config.json
TARO_APP_SUPABASE_URL=https://<supabase-auth-host> /www/tiku/h5-platform-admin/runtime-config.json
TARO_APP_SUPABASE_PUBLISHABLE_KEY=<publishable-key>
TARO_APP_TENANT_CODE=<可选,小程序或预览环境使用>
``` ```
禁止进入前端构建 示例文件
```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 ```text
SUPABASE_SERVICE_ROLE_KEY SUPABASE_SERVICE_ROLE_KEY
@@ -60,34 +90,32 @@ AUTH_SESSION_SECRET
PLATFORM_ADMIN_API_KEY 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 ```nginx
server { server {
listen 443 ssl http2;
server_name www.example.com; server_name www.example.com;
root /www/tiku/h5-student; root /www/tiku/h5-student;
location / { add_header X-Content-Type-Options nosniff always;
try_files $uri $uri/ /index.html; 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 { location ~* \.(?:js|css|woff2?|png|jpg|jpeg|gif|svg)$ {
server_name admin.example.com; add_header Cache-Control "public, max-age=31536000, immutable" always;
root /www/tiku/h5-tenant-admin; try_files $uri =404;
location / {
try_files $uri $uri/ /index.html;
} }
}
server {
server_name console.example.com;
root /www/tiku/h5-platform-admin;
location / { location / {
try_files $uri $uri/ /index.html; 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 ## CORS 和 Cookie
npm run readiness:production
npm run readiness:production:db API 的生产 `CORS_ORIGIN` 必须只包含实际前端域名:
```text
CORS_ORIGIN=https://www.example.com,https://admin.example.com,https://console.example.com
```
禁止生产环境使用:
```text
CORS_ORIGIN=*
```
当前前端以 `Authorization: Bearer <supabase_access_token 或 tk_session>` 调用 API`x-tenant-id` 只作为租户上下文,不作为身份来源。生产建议:
```text
ALLOW_LEGACY_AUTH_HEADERS=false
ALLOW_PLATFORM_ADMIN_KEY=false
``` ```
## 前端请求边界 ## 前端请求边界
- 所有页面统一通过 `apps/taro/src/services/api.ts` 调用后端。 - 所有页面统一通过 `apps/taro/src/services/api.ts` 调用后端。
- H5 可以用 Supabase client 管理 Auth session但业务数据默认走 `apps/api` - H5 可以用 Supabase client 管理 Auth session/JWT,但业务数据默认走 `apps/api`
- `x-tenant-id` 只是租户上下文,不是身份来源。
- 登录后禁止传 `x-user-id` 或 body/query `userId` 表示当前用户。
- 订单、支付、权益、内容导入、后台配置、CRM、对象存储签名、视频播放签名必须走后端命令层。 - 订单、支付、权益、内容导入、后台配置、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 ```bash
npm run audit:runtime 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. 学生端:租户启动、登录、首页、题库入口、练习、错题、收藏 当前 `runtime-config.json` 只用于 H5。微信小程序版本应通过编译变量、小程序启动参数或后台小程序配置传入 `tenantCode`,再调用 `GET /api/tenant/resolve?tenantCode=...`。小程序端如 `supabase-js` 兼容性不稳定,保留 `apps/api/auth/*` 登录适配层H5 继续使用 Supabase client 管理 Auth。
2. 学生端:背单词、知识手册、分数线、资料、会员、个人中心。
3. 租户后台:数据看板、内容导航、题目录入/导入、学生管理、营销中心。
4. 平台后台:租户、套餐、账单、公共题库授权。
5. 小程序:验证 storage/fetch/Auth 兼容性,复用同一套 API client。

View File

@@ -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: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: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: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", "test:pb:dry-run": "node scripts/pb-dry-run-report-test.js",
"readiness:production": "node scripts/production-readiness-check.js --skip-db", "readiness:production": "node scripts/production-readiness-check.js --skip-db",
"readiness:production:db": "node scripts/production-readiness-check.js --check-db", "readiness:production:db": "node scripts/production-readiness-check.js --check-db",

View File

@@ -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');