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

@@ -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 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<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,
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<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() {
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(', ')}`);
}

View File

@@ -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() });

View File

@@ -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<T>(
headers?: Record<string, string>;
} = {},
): Promise<T> {
await ensureRuntimeConfigLoaded();
const tenant = getTenantContext();
const session = getSession();
const token = options.token ?? session?.token ?? null;

View File

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