forked from wangziqi/gongxue-base
test: add taro h5 interaction smoke
This commit is contained in:
12
README.md
12
README.md
@@ -145,11 +145,13 @@ node scripts/taro-route-contract-test.js
|
|||||||
node scripts/taro-api-contract-test.js
|
node scripts/taro-api-contract-test.js
|
||||||
node scripts/taro-persona-contract-test.js
|
node scripts/taro-persona-contract-test.js
|
||||||
npm run smoke:taro:h5
|
npm run smoke:taro:h5
|
||||||
|
npm run smoke:taro:h5:interaction
|
||||||
```
|
```
|
||||||
|
|
||||||
`taro-route-contract-test` 会校验 `apps/taro/src/app.config.ts`、真实 `pages/**/index.tsx`、启动页三端跳转、H5 静态烟测入口和前端交接文档中的页面引用保持一致。新增或删除页面时必须同步路由和文档,避免 H5/小程序构建后才发现入口漂移。
|
`taro-route-contract-test` 会校验 `apps/taro/src/app.config.ts`、真实 `pages/**/index.tsx`、启动页三端跳转、H5 静态烟测入口和前端交接文档中的页面引用保持一致。新增或删除页面时必须同步路由和文档,避免 H5/小程序构建后才发现入口漂移。
|
||||||
`taro-api-contract-test` 会比对 `apps/taro/src` 中所有 `apiRequest('/api/...')` 调用与 `apps/api/src/features/*/index.ts` 注册路由,阻断前端调用不存在 API、method 写错或绕过统一 `/api` 命名空间的漂移;动态导入和少量 server alias 需要在脚本 allowlist 中显式声明。
|
`taro-api-contract-test` 会比对 `apps/taro/src` 中所有 `apiRequest('/api/...')` 调用与 `apps/api/src/features/*/index.ts` 注册路由,阻断前端调用不存在 API、method 写错或绕过统一 `/api` 命名空间的漂移;动态导入和少量 server alias 需要在脚本 allowlist 中显式声明。
|
||||||
`taro-persona-contract-test` 会从学生、租户管理员、平台管理员三类前端视角检查关键页面、路由和服务调用,阻断刷题、会员订单、错题收藏、学生运营、内容导入、营销财务、租户设置、平台租户账务和公共题库授权入口被误删或漂移。
|
`taro-persona-contract-test` 会从学生、租户管理员、平台管理员三类前端视角检查关键页面、路由和服务调用,阻断刷题、会员订单、错题收藏、学生运营、内容导入、营销财务、租户设置、平台租户账务和公共题库授权入口被误删或漂移。
|
||||||
|
`smoke:taro:h5:interaction` 会启动三套 H5 发布产物、本地 mock API 和本机 Chrome/Edge,在真实浏览器里点击学生刷题/收藏/开通会员、租户题库内容/财务运营、平台租户/账务中心关键路径,用来补足静态烟测无法发现的 H5 运行时空白页、history 路由和点击事件问题。
|
||||||
|
|
||||||
H5 线上推荐每个静态目录放独立 `runtime-config.json` 覆盖公开配置,避免 API/Auth 域名变化时重打包:
|
H5 线上推荐每个静态目录放独立 `runtime-config.json` 覆盖公开配置,避免 API/Auth 域名变化时重打包:
|
||||||
|
|
||||||
@@ -457,7 +459,7 @@ cp docs/refactor/production-launch-evidence.template.json docs/refactor/producti
|
|||||||
npm run launch:gate -- --evidence docs/refactor/production-launch-evidence.json
|
npm run launch:gate -- --evidence docs/refactor/production-launch-evidence.json
|
||||||
```
|
```
|
||||||
|
|
||||||
`production-launch-evidence.json` 不入 Git,里面只记录验收摘要、artifact 路径、审批人和时间,不保存真实 access token、支付密钥、对象存储密钥或用户隐私明细。门禁会要求以下证据全部齐备并通过:`readiness:production`、`readiness:production:db`、真实 `smoke:auth:remote`、`test:rls`、PocketBase production dry-run、`pb:import:validate`、`pb:import:sample`、真实数据 API 读路径压测、API/worker/Taro 构建、`smoke:taro:h5`、严格 `taro-h5-release-guardrails-test --require-runtime-config`、`audit:runtime`、真实 `@codex-security` 扫描,以及备份、回滚、真实数据抽样、生产 provider、对象存储控制、支付对账和三套 H5 `runtime-config.json` 人工确认。
|
`production-launch-evidence.json` 不入 Git,里面只记录验收摘要、artifact 路径、审批人和时间,不保存真实 access token、支付密钥、对象存储密钥或用户隐私明细。门禁会要求以下证据全部齐备并通过:`readiness:production`、`readiness:production:db`、真实 `smoke:auth:remote`、`test:rls`、PocketBase production dry-run、`pb:import:validate`、`pb:import:sample`、真实数据 API 读路径压测、API/worker/Taro 构建、`smoke:taro:h5`、`smoke:taro:h5:interaction`、严格 `taro-h5-release-guardrails-test --require-runtime-config`、`audit:runtime`、真实 `@codex-security` 扫描,以及备份、回滚、真实数据抽样、生产 provider、对象存储控制、支付对账和三套 H5 `runtime-config.json` 人工确认。
|
||||||
|
|
||||||
补充说明:当前 Codex 环境如果没有暴露 `@codex-security` 可调用工具,不能把插件扫描写成已完成;只能先用 `npm run audit:runtime`、`npm run test:readiness`、`npm run test:rls` 和代码审查作为临时安全证据,并在上线证据里保留插件扫描待补项。
|
补充说明:当前 Codex 环境如果没有暴露 `@codex-security` 可调用工具,不能把插件扫描写成已完成;只能先用 `npm run audit:runtime`、`npm run test:readiness`、`npm run test:rls` 和代码审查作为临时安全证据,并在上线证据里保留插件扫描待补项。
|
||||||
|
|
||||||
@@ -577,15 +579,17 @@ npm run build:taro:h5:student
|
|||||||
npm run build:taro:h5:tenant
|
npm run build:taro:h5:tenant
|
||||||
npm run build:taro:h5:platform
|
npm run build:taro:h5:platform
|
||||||
npm run smoke:taro:h5
|
npm run smoke:taro:h5
|
||||||
|
npm run smoke:taro:h5:interaction
|
||||||
node scripts\taro-h5-release-guardrails-test.js --require-dist
|
node scripts\taro-h5-release-guardrails-test.js --require-dist
|
||||||
```
|
```
|
||||||
|
|
||||||
`smoke:taro:h5` 会启动临时静态服务器和 mock API,验证三套 H5 的 `index.html`、静态资源、history fallback、公开 runtime config 和租户解析契约。`taro-h5-release-guardrails-test` 会确认三套 H5 目录存在 `index.html`,并扫描源码/产物是否混入旧 PocketBase、`x-user-id`、平台本地 key、数据库连接串或服务端密钥形态。正式部署时还必须在每个 H5 目录根部放置对应的 `runtime-config.json`。
|
`smoke:taro:h5` 会启动临时静态服务器和 mock API,验证三套 H5 的 `index.html`、静态资源、history fallback、公开 runtime config 和租户解析契约。`smoke:taro:h5:interaction` 会再拉起真实 Chrome/Edge,打开三套 H5 产物并点击学生、租户后台、平台后台关键入口,确认页面 JS 执行、路由跳转、答题收藏 API、后台入口点击没有空白页或运行时异常。`taro-h5-release-guardrails-test` 会确认三套 H5 目录存在 `index.html`,并扫描源码/产物是否混入旧 PocketBase、`x-user-id`、平台本地 key、数据库连接串或服务端密钥形态。正式部署时还必须在每个 H5 目录根部放置对应的 `runtime-config.json`。
|
||||||
|
|
||||||
写入生产上线证据时使用严格模式,确保三套正式发布目录已经放好真实公开 `runtime-config.json`,且 warning 为 0:
|
写入生产上线证据时使用严格模式,确保三套正式发布目录已经放好真实公开 `runtime-config.json`,且 warning 为 0:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
npm --silent run smoke:taro:h5 -- --json > docs/refactor/launch-artifacts/taro-h5-static-smoke.json
|
npm --silent run smoke:taro:h5 -- --json > docs/refactor/launch-artifacts/taro-h5-static-smoke.json
|
||||||
|
npm --silent run smoke:taro:h5:interaction -- --json > docs/refactor/launch-artifacts/taro-h5-interaction-smoke.json
|
||||||
node scripts\taro-h5-release-guardrails-test.js --require-dist --require-runtime-config --json > docs/refactor/launch-artifacts/taro-h5-release-guardrails.json
|
node scripts\taro-h5-release-guardrails-test.js --require-dist --require-runtime-config --json > docs/refactor/launch-artifacts/taro-h5-release-guardrails.json
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -606,6 +610,8 @@ node scripts\taro-h5-release-guardrails-test.js --require-dist --require-runtime
|
|||||||
| 50 | 120s | 10% | 92,737 | 0.00% | 767.24 req/s | 121.51 ms | 159.12 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 |
|
| 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 |
|
| 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 |
|
||||||
|
|
||||||
只读上线门禁继续要求 `includeWrites=false`;混合读写报告需要显式使用 `--allow-writes` 做人工容量观察,例如:
|
只读上线门禁继续要求 `includeWrites=false`;混合读写报告需要显式使用 `--allow-writes` 做人工容量观察,例如:
|
||||||
|
|
||||||
@@ -615,7 +621,7 @@ npm run perf:summary -- --input docs/refactor/performance-reports/api-benchmark-
|
|||||||
|
|
||||||
使用 `--allow-writes` 时会输出 `capacityObservation`,不输出 `launchGateCheck`,不能把写入场景误填成生产上线门禁的只读证据。
|
使用 `--allow-writes` 时会输出 `capacityObservation`,不输出 `launchGateCheck`,不能把写入场景误填成生产上线门禁的只读证据。
|
||||||
|
|
||||||
本地结论:100 个无停顿 worker 内 P95 仍低于 300ms;150 worker 零错误但 P95 已明显上升,可作为本机 Docker 环境的压力拐点参考。最新只读 30 worker/120s 为 897.04 req/s,最新混合读写 100 worker/60s/8% 写入为 737.22 req/s、P95 244.12ms、P99 320.26ms、0 错误;150 worker/60s/6% 写入仍 0 错误,但 P95 已到 387.58ms、P99 510.79ms。工程上建议把本机 Docker 舒适区暂记为 100 个无停顿压测 worker;按单学生 0.05 到 0.2 req/s 的页面节奏粗略折算,约可支撑 3,700 到 14,700 名活跃在线学生的请求吞吐观察区间;按 150 worker 压力区吞吐折算约为 3,400 到 13,600 名,但这不是生产 SLA。当前 PostgreSQL evidence 提醒本地默认库仍是 `jit=on` 且 `statement_timeout`、`idle_in_transaction_session_timeout`、`lock_timeout` 未设置,上云必须按调参文档复核。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要见:
|
本地结论:100 个无停顿 worker、120 秒、8% 刷题写入闭环复测为 84,694 请求、0 错误、699.54 req/s、P95 265.11ms、P99 337.82ms,仍可作为当前本机 Docker 舒适区;150 个无停顿 worker、120 秒、6% 写入为 74,209 请求、0 错误、612.19 req/s、P95 446.37ms、P99 579.39ms,已进入压力区并出现吞吐回落。按单学生 0.05 到 0.2 req/s 的页面节奏粗略折算,100 worker 舒适区约对应 3,500 到 14,000 名活跃在线学生的请求吞吐观察区间;150 worker 压力区约对应 3,000 到 12,200 名。这个折算不是生产 SLA,压测 worker 也不等于在线人数。当前 PostgreSQL evidence 提醒本地默认库仍是 `jit=on` 且 `statement_timeout`、`idle_in_transaction_session_timeout`、`lock_timeout` 未设置,上云必须按调参文档复核。正式对外容量承诺必须在目标 4 核 16G 云服务器、生产 PostgreSQL 参数、生产对象存储/CDN 和真实前端请求节奏下复跑。脱敏摘要见:
|
||||||
|
|
||||||
```text
|
```text
|
||||||
docs/refactor/performance-benchmark-summary-20260630.md
|
docs/refactor/performance-benchmark-summary-20260630.md
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ export default {
|
|||||||
options: {},
|
options: {},
|
||||||
},
|
},
|
||||||
h5: {
|
h5: {
|
||||||
publicPath: './',
|
publicPath: '/',
|
||||||
staticDirectory: 'static',
|
staticDirectory: 'static',
|
||||||
output: {
|
output: {
|
||||||
filename: 'js/[name].[contenthash:8].js',
|
filename: 'js/[name].[contenthash:8].js',
|
||||||
|
|||||||
@@ -25,6 +25,15 @@ declare const process: {
|
|||||||
env: Record<string, string | undefined>;
|
env: Record<string, string | undefined>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type ProcessLike = {
|
||||||
|
env?: Record<string, string | undefined>;
|
||||||
|
};
|
||||||
|
|
||||||
|
function envValue(key: string) {
|
||||||
|
const runtimeProcess = typeof process === 'undefined' ? undefined : (process as ProcessLike);
|
||||||
|
return runtimeProcess?.env?.[key];
|
||||||
|
}
|
||||||
|
|
||||||
const forbiddenFrontendKeys = [
|
const forbiddenFrontendKeys = [
|
||||||
'SUPABASE_SERVICE_ROLE_KEY',
|
'SUPABASE_SERVICE_ROLE_KEY',
|
||||||
'SUPABASE_SECRET_KEY',
|
'SUPABASE_SECRET_KEY',
|
||||||
@@ -72,11 +81,11 @@ function assertNoForbiddenKeys(input: Record<string, unknown>, source: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const appEnv: AppEnv = {
|
export const appEnv: AppEnv = {
|
||||||
portal: (process.env.TARO_APP_PORTAL || 'student') as Portal,
|
portal: (envValue('TARO_APP_PORTAL') || 'student') as Portal,
|
||||||
apiBaseUrl: process.env.TARO_APP_API_BASE_URL || 'http://127.0.0.1:8787',
|
apiBaseUrl: envValue('TARO_APP_API_BASE_URL') || 'http://127.0.0.1:8787',
|
||||||
supabaseUrl: process.env.TARO_APP_SUPABASE_URL || '',
|
supabaseUrl: envValue('TARO_APP_SUPABASE_URL') || '',
|
||||||
supabasePublishableKey: process.env.TARO_APP_SUPABASE_PUBLISHABLE_KEY || '',
|
supabasePublishableKey: envValue('TARO_APP_SUPABASE_PUBLISHABLE_KEY') || '',
|
||||||
tenantCode: process.env.TARO_APP_TENANT_CODE || '',
|
tenantCode: envValue('TARO_APP_TENANT_CODE') || '',
|
||||||
};
|
};
|
||||||
|
|
||||||
let runtimeConfigPromise: Promise<AppEnv> | null = null;
|
let runtimeConfigPromise: Promise<AppEnv> | null = null;
|
||||||
@@ -103,7 +112,7 @@ export function applyRuntimeConfig(input: RuntimeConfigInput, source = 'runtime
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function loadRuntimeConfig() {
|
export async function loadRuntimeConfig() {
|
||||||
if (process.env.TARO_ENV !== 'h5' || typeof window === 'undefined' || typeof window.fetch !== 'function') {
|
if (!isH5Runtime() || typeof window === 'undefined' || typeof window.fetch !== 'function') {
|
||||||
return appEnv;
|
return appEnv;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,8 +147,20 @@ export function ensureRuntimeConfigLoaded() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function assertFrontendSecretsAreAbsent() {
|
export function assertFrontendSecretsAreAbsent() {
|
||||||
const leaked = forbiddenFrontendKeys.filter(key => process.env[key]);
|
const leaked = forbiddenFrontendKeys.filter(key => envValue(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(', ')}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function taroRuntimeEnv() {
|
||||||
|
return envValue('TARO_ENV') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isH5Runtime() {
|
||||||
|
return taroRuntimeEnv() === 'h5' || (typeof window !== 'undefined' && typeof document !== 'undefined');
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isWeappRuntime() {
|
||||||
|
return taroRuntimeEnv() === 'weapp';
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
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, ensureRuntimeConfigLoaded } from '@/env';
|
import { appEnv, assertFrontendSecretsAreAbsent, ensureRuntimeConfigLoaded, isH5Runtime } from '@/env';
|
||||||
import { resolveTenant } from '@/services/api';
|
import { resolveTenant } from '@/services/api';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
function hostFromRuntime() {
|
function hostFromRuntime() {
|
||||||
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') return window.location.host;
|
if (isH5Runtime() && typeof window !== 'undefined') return window.location.host;
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
loadSchoolRecommendationReports,
|
loadSchoolRecommendationReports,
|
||||||
type SchoolRecommendationReport,
|
type SchoolRecommendationReport,
|
||||||
} from '@/services/ai';
|
} from '@/services/ai';
|
||||||
|
import { isH5Runtime } from '@/env';
|
||||||
import { loadProfile, type StudentProfile } from '@/services/profile';
|
import { loadProfile, type StudentProfile } from '@/services/profile';
|
||||||
import '../student.css';
|
import '../student.css';
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ function recommendationRows(report: SchoolRecommendationReport | null) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function saveExportFile(fileName: string, content: string, mimeType: string) {
|
function saveExportFile(fileName: string, content: string, mimeType: string) {
|
||||||
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined' && typeof document !== 'undefined') {
|
if (isH5Runtime() && typeof window !== 'undefined' && typeof document !== 'undefined') {
|
||||||
const blob = new Blob([content], { type: mimeType });
|
const blob = new Blob([content], { type: mimeType });
|
||||||
const url = window.URL.createObjectURL(blob);
|
const url = window.URL.createObjectURL(blob);
|
||||||
const link = document.createElement('a');
|
const link = document.createElement('a');
|
||||||
@@ -94,7 +95,7 @@ export default function StudentAiSchoolPage() {
|
|||||||
const payload = await exportSchoolRecommendationReport(current.id, format);
|
const payload = await exportSchoolRecommendationReport(current.id, format);
|
||||||
if (!payload.item?.contentText) throw new Error('后端未返回报告内容');
|
if (!payload.item?.contentText) throw new Error('后端未返回报告内容');
|
||||||
saveExportFile(payload.item.fileName, payload.item.contentText, payload.item.mimeType);
|
saveExportFile(payload.item.fileName, payload.item.contentText, payload.item.mimeType);
|
||||||
if (process.env.TARO_ENV === 'h5') Taro.showToast({ title: '报告已导出', icon: 'success' });
|
if (isH5Runtime()) Taro.showToast({ title: '报告已导出', icon: 'success' });
|
||||||
} catch (nextError) {
|
} catch (nextError) {
|
||||||
setError(nextError instanceof Error ? nextError.message : '导出失败');
|
setError(nextError instanceof Error ? nextError.message : '导出失败');
|
||||||
} finally {
|
} finally {
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type ContentAsset,
|
type ContentAsset,
|
||||||
type SignedAssetLink,
|
type SignedAssetLink,
|
||||||
} from '@/services/catalog';
|
} from '@/services/catalog';
|
||||||
|
import { isH5Runtime } from '@/env';
|
||||||
import '../student.css';
|
import '../student.css';
|
||||||
|
|
||||||
type PreviewState = {
|
type PreviewState = {
|
||||||
@@ -20,7 +21,7 @@ type PreviewState = {
|
|||||||
|
|
||||||
function openUrl(url?: string, copiedText = '签名链接已复制,请在浏览器中打开') {
|
function openUrl(url?: string, copiedText = '签名链接已复制,请在浏览器中打开') {
|
||||||
if (!url) return;
|
if (!url) return;
|
||||||
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
|
if (isH5Runtime() && typeof window !== 'undefined') {
|
||||||
window.open(url, '_blank', 'noopener,noreferrer');
|
window.open(url, '_blank', 'noopener,noreferrer');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -129,10 +130,10 @@ export default function StudentAssetsPage() {
|
|||||||
<Text className='row-meta'>下载前请确认页面水印和追踪码。签名 URL 不会长期保存,过期后需要重新申请。</Text>
|
<Text className='row-meta'>下载前请确认页面水印和追踪码。签名 URL 不会长期保存,过期后需要重新申请。</Text>
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
) : null}
|
||||||
{previewState.kind === 'preview' && process.env.TARO_ENV === 'h5' ? (
|
{previewState.kind === 'preview' && isH5Runtime() ? (
|
||||||
<WebView className='asset-preview-iframe' src={previewState.link.url || ''} />
|
<WebView className='asset-preview-iframe' src={previewState.link.url || ''} />
|
||||||
) : null}
|
) : null}
|
||||||
{previewState.kind === 'preview' && process.env.TARO_ENV !== 'h5' ? (
|
{previewState.kind === 'preview' && !isH5Runtime() ? (
|
||||||
<View className='asset-download-confirm'>
|
<View className='asset-download-confirm'>
|
||||||
<Text className='row-main'>已获取预览授权</Text>
|
<Text className='row-main'>已获取预览授权</Text>
|
||||||
<Text className='row-meta'>当前小程序容器不直接嵌入私有文件。请先确认水印追踪码,再使用外部打开或复制签名链接。</Text>
|
<Text className='row-meta'>当前小程序容器不直接嵌入私有文件。请先确认水印追踪码,再使用外部打开或复制签名链接。</Text>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
type PaymentCreateResult,
|
type PaymentCreateResult,
|
||||||
type SvipPlan,
|
type SvipPlan,
|
||||||
} from '@/services/commerce';
|
} from '@/services/commerce';
|
||||||
|
import { isH5Runtime, isWeappRuntime } from '@/env';
|
||||||
import { loadProfile, type StudentProfile } from '@/services/profile';
|
import { loadProfile, type StudentProfile } from '@/services/profile';
|
||||||
import '../student.css';
|
import '../student.css';
|
||||||
|
|
||||||
@@ -56,7 +57,7 @@ function buildPaymentUrl(result?: PaymentCreateResult) {
|
|||||||
|
|
||||||
function tryOpenPaymentUrl(url: string) {
|
function tryOpenPaymentUrl(url: string) {
|
||||||
if (!url) return false;
|
if (!url) return false;
|
||||||
if (process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
|
if (isH5Runtime() && typeof window !== 'undefined') {
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -167,8 +168,8 @@ export default function StudentCheckoutPage() {
|
|||||||
const paymentPayload = await createPayment({
|
const paymentPayload = await createPayment({
|
||||||
orderNo: nextOrder.orderNo,
|
orderNo: nextOrder.orderNo,
|
||||||
provider,
|
provider,
|
||||||
returnUrl: process.env.TARO_ENV === 'h5' && typeof window !== 'undefined' ? window.location.href : undefined,
|
returnUrl: isH5Runtime() && typeof window !== 'undefined' ? window.location.href : undefined,
|
||||||
quitUrl: process.env.TARO_ENV === 'h5' && typeof window !== 'undefined' ? window.location.href : undefined,
|
quitUrl: isH5Runtime() && typeof window !== 'undefined' ? window.location.href : undefined,
|
||||||
});
|
});
|
||||||
setPayment(paymentPayload.item || null);
|
setPayment(paymentPayload.item || null);
|
||||||
setMessage(provider === 'manual' ? '已生成线下支付记录,请联系教务或客服确认。' : '支付参数已生成,请继续完成支付。');
|
setMessage(provider === 'manual' ? '已生成线下支付记录,请联系教务或客服确认。' : '支付参数已生成,请继续完成支付。');
|
||||||
@@ -181,7 +182,7 @@ export default function StudentCheckoutPage() {
|
|||||||
|
|
||||||
async function handleOpenPayment() {
|
async function handleOpenPayment() {
|
||||||
if (!payment) return;
|
if (!payment) return;
|
||||||
if (payment.provider === 'wechat_pay' && process.env.TARO_ENV === 'weapp') {
|
if (payment.provider === 'wechat_pay' && isWeappRuntime()) {
|
||||||
const paramsForWeapp = payment.paymentParams || {};
|
const paramsForWeapp = payment.paymentParams || {};
|
||||||
Taro.requestPayment(paramsForWeapp as unknown as Taro.requestPayment.Option)
|
Taro.requestPayment(paramsForWeapp as unknown as Taro.requestPayment.Option)
|
||||||
.then(() => order?.orderNo ? pollStatus(order.orderNo) : undefined)
|
.then(() => order?.orderNo ? pollStatus(order.orderNo) : undefined)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
type PaymentCreateResult,
|
type PaymentCreateResult,
|
||||||
} from '@/services/commerce';
|
} from '@/services/commerce';
|
||||||
import { getTenantContext } from '@/services/api';
|
import { getTenantContext } from '@/services/api';
|
||||||
|
import { isH5Runtime } from '@/env';
|
||||||
import '../student.css';
|
import '../student.css';
|
||||||
|
|
||||||
function centsToYuan(value?: number | null) {
|
function centsToYuan(value?: number | null) {
|
||||||
@@ -94,13 +95,13 @@ export default function StudentOrderDetailPage() {
|
|||||||
const payload = await createPayment({
|
const payload = await createPayment({
|
||||||
orderNo,
|
orderNo,
|
||||||
provider: provider || detail?.payProvider || status?.payProvider || 'alipay',
|
provider: provider || detail?.payProvider || status?.payProvider || 'alipay',
|
||||||
returnUrl: process.env.TARO_ENV === 'h5' && typeof window !== 'undefined' ? window.location.href : undefined,
|
returnUrl: isH5Runtime() && typeof window !== 'undefined' ? window.location.href : undefined,
|
||||||
quitUrl: process.env.TARO_ENV === 'h5' && typeof window !== 'undefined' ? window.location.href : undefined,
|
quitUrl: isH5Runtime() && typeof window !== 'undefined' ? window.location.href : undefined,
|
||||||
});
|
});
|
||||||
const nextPayment = payload.item || null;
|
const nextPayment = payload.item || null;
|
||||||
setPayment(nextPayment);
|
setPayment(nextPayment);
|
||||||
const url = paymentUrl(nextPayment);
|
const url = paymentUrl(nextPayment);
|
||||||
if (url && process.env.TARO_ENV === 'h5' && typeof window !== 'undefined') {
|
if (url && isH5Runtime() && typeof window !== 'undefined') {
|
||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
} else if (url) {
|
} else if (url) {
|
||||||
await Taro.setClipboardData({ data: url });
|
await Taro.setClipboardData({ data: url });
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
type PublicQuestionBankItem,
|
type PublicQuestionBankItem,
|
||||||
type TenantContentNotificationItem,
|
type TenantContentNotificationItem,
|
||||||
} from '@/services/tenantAdmin';
|
} from '@/services/tenantAdmin';
|
||||||
|
import { isH5Runtime } from '@/env';
|
||||||
import '../admin.css';
|
import '../admin.css';
|
||||||
|
|
||||||
function displayBankName(item: PublicQuestionBankItem) {
|
function displayBankName(item: PublicQuestionBankItem) {
|
||||||
@@ -74,7 +75,7 @@ function readLocalFileAsText(file: File) {
|
|||||||
|
|
||||||
function openH5FilePicker(format: ImportSourceFormat) {
|
function openH5FilePicker(format: ImportSourceFormat) {
|
||||||
return new Promise<{ fileName: string; text?: string; fileBase64?: string }>((resolve, reject) => {
|
return new Promise<{ fileName: string; text?: string; fileBase64?: string }>((resolve, reject) => {
|
||||||
if (process.env.TARO_ENV !== 'h5' || typeof document === 'undefined') {
|
if (!isH5Runtime() || typeof document === 'undefined') {
|
||||||
reject(new Error('当前端暂未接入文件选择,请粘贴 JSON/CSV 内容后预览导入。'));
|
reject(new Error('当前端暂未接入文件选择,请粘贴 JSON/CSV 内容后预览导入。'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -111,7 +112,7 @@ function base64ToUint8Array(base64: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function downloadTemplateFile(template: ImportTemplateItem) {
|
function downloadTemplateFile(template: ImportTemplateItem) {
|
||||||
if (process.env.TARO_ENV !== 'h5' || typeof document === 'undefined') {
|
if (!isH5Runtime() || typeof document === 'undefined') {
|
||||||
throw new Error('当前端暂不支持直接下载模板,请先使用模板预览。');
|
throw new Error('当前端暂不支持直接下载模板,请先使用模板预览。');
|
||||||
}
|
}
|
||||||
if (!template.contentBase64) throw new Error('模板内容为空,请重新加载模板。');
|
if (!template.contentBase64) throw new Error('模板内容为空,请重新加载模板。');
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
|
import { isH5Runtime } from '../env';
|
||||||
|
|
||||||
export type ApiAuthMode = 'auto' | 'none' | 'supabase' | 'legacy';
|
export type ApiAuthMode = 'auto' | 'none' | 'supabase' | 'legacy';
|
||||||
|
|
||||||
export type SupabaseAccessTokenProvider = () => Promise<string | null>;
|
export type SupabaseAccessTokenProvider = () => Promise<string | null>;
|
||||||
|
|
||||||
async function defaultH5SupabaseAccessTokenProvider() {
|
async function defaultH5SupabaseAccessTokenProvider() {
|
||||||
if (process.env.TARO_ENV !== 'h5') return null;
|
if (!isH5Runtime()) return null;
|
||||||
const { getSupabaseAccessToken } = await import('./supabase');
|
const { getSupabaseAccessToken } = await import('./supabase');
|
||||||
return getSupabaseAccessToken();
|
return getSupabaseAccessToken();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Taro from '@tarojs/taro';
|
import Taro from '@tarojs/taro';
|
||||||
|
import { isH5Runtime } from '@/env';
|
||||||
|
|
||||||
export type AccentType = 'us' | 'uk';
|
export type AccentType = 'us' | 'uk';
|
||||||
|
|
||||||
@@ -62,7 +63,7 @@ export async function playWordPronunciation(word: string, accent: AccentType = '
|
|||||||
|
|
||||||
const url = pronunciationUrl(text, accent);
|
const url = pronunciationUrl(text, accent);
|
||||||
|
|
||||||
if (process.env.TARO_ENV === 'h5' && typeof Audio !== 'undefined') {
|
if (isH5Runtime() && typeof Audio !== 'undefined') {
|
||||||
try {
|
try {
|
||||||
const audio = new Audio(url);
|
const audio = new Audio(url);
|
||||||
audio.preload = 'auto';
|
audio.preload = 'auto';
|
||||||
|
|||||||
@@ -44,9 +44,10 @@
|
|||||||
- 新增、删除或重命名 Taro 页面时必须同步 `apps/taro/src/app.config.ts`、启动页跳转、H5 静态烟测入口和本文页面清单,并运行 `node scripts/taro-route-contract-test.js`。该脚本会阻断“页面文件存在但未注册”“路由注册但文件缺失”“启动页或烟测跳到不存在页面”的漂移。
|
- 新增、删除或重命名 Taro 页面时必须同步 `apps/taro/src/app.config.ts`、启动页跳转、H5 静态烟测入口和本文页面清单,并运行 `node scripts/taro-route-contract-test.js`。该脚本会阻断“页面文件存在但未注册”“路由注册但文件缺失”“启动页或烟测跳到不存在页面”的漂移。
|
||||||
- 新增或修改 Taro API service 时必须运行 `node scripts/taro-api-contract-test.js`。该脚本会比对前端 `apiRequest('/api/...')` 与后端 `RouteDefinition[]` 注册表,阻断调用不存在的接口、method 写错或绕过统一 API client;动态路由只能通过脚本 allowlist 明确声明。
|
- 新增或修改 Taro API service 时必须运行 `node scripts/taro-api-contract-test.js`。该脚本会比对前端 `apiRequest('/api/...')` 与后端 `RouteDefinition[]` 注册表,阻断调用不存在的接口、method 写错或绕过统一 API client;动态路由只能通过脚本 allowlist 明确声明。
|
||||||
- 修改学生端、租户后台或平台后台关键页面时必须运行 `node scripts/taro-persona-contract-test.js`。该脚本按学生刷题/会员订单/错题收藏、租户学生运营/内容导入/营销财务/品牌权限、平台租户/账务/公共题库/员工权限三类角色旅程检查路由和服务调用,避免前端样式重做时误删核心业务入口。
|
- 修改学生端、租户后台或平台后台关键页面时必须运行 `node scripts/taro-persona-contract-test.js`。该脚本按学生刷题/会员订单/错题收藏、租户学生运营/内容导入/营销财务/品牌权限、平台租户/账务/公共题库/员工权限三类角色旅程检查路由和服务调用,避免前端样式重做时误删核心业务入口。
|
||||||
|
- H5 构建完成后必须运行 `npm run smoke:taro:h5:interaction` 做真实浏览器点击验证。它会覆盖学生首页到题库练习、答题、收藏、个人中心开通会员,租户后台工作台到题库内容/财务运营,以及平台后台工作台到租户管理/账务中心;如果 Chrome/Edge 缺失,可设置 `TARO_H5_SMOKE_BROWSER` 指向 Chromium 浏览器。
|
||||||
- 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、数据库、支付、短信、对象存储密钥放进该文件。
|
- H5 生产部署优先用每个静态目录自己的 `runtime-config.json` 配置 `apiBaseUrl`、`supabaseUrl`、`supabasePublishableKey`、`tenantCode`;不要为了换域名重打包,也不要把任何 service role、数据库、支付、短信、对象存储密钥放进该文件。
|
||||||
- 上线前需要把三套 H5 构建、`npm run smoke:taro:h5` 静态启动烟测、严格 `taro-h5-release-guardrails-test --require-runtime-config`、`runtime-config.json` 人工复核、真实 Auth/RLS、迁移 dry-run、对象存储、支付对账和真实 `@codex-security` 结果写入 `production-launch-evidence.json`,并通过 `npm run launch:gate`。当前环境没有暴露安全扫描工具时只能标记待补,不能把模板占位当完成。
|
- 上线前需要把三套 H5 构建、`npm run smoke:taro:h5` 静态启动烟测、`npm run smoke:taro:h5:interaction` 真实浏览器交互烟测、严格 `taro-h5-release-guardrails-test --require-runtime-config`、`runtime-config.json` 人工复核、真实 Auth/RLS、迁移 dry-run、对象存储、支付对账和真实 `@codex-security` 结果写入 `production-launch-evidence.json`,并通过 `npm run launch:gate`。当前环境没有暴露安全扫描工具时只能标记待补,不能把模板占位当完成。
|
||||||
- 可以接入租户品牌、已发布主题、公开素材、功能开关和域名/小程序参数解析;学生端只读 `/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/PUT /api/tenant-admin/point-activity-tasks`、`GET /api/tenant-admin/point-activity-claims`、`GET/PUT /api/tenant-admin/point-exchange-items`、`GET /api/tenant-admin/point-exchange-orders`、`GET /api/tenant-admin/points-risk-report`、`GET /api/tenant-admin/feedbacks/report`;学生端用 `GET /api/profile/badges` 展示成就,通过 `POST /api/profile/check-in` 自动领取 `daily_check_in` 任务奖励,并通过 `GET /api/profile/activity-tasks`、`POST /api/profile/activity-tasks/claim`、`GET /api/profile/exchange-items`、`POST /api/profile/exchange-items/redeem` 接其它积分活动和兑换。
|
- 租户后台可以接入勋章管理、手动发放、积分任务、每日签到奖励、积分兑换、积分风控只读报表和反馈运营报表:`GET/PUT /api/tenant-admin/badges`、`GET/POST /api/tenant-admin/badge-grants`、`GET/PUT /api/tenant-admin/point-activity-tasks`、`GET /api/tenant-admin/point-activity-claims`、`GET/PUT /api/tenant-admin/point-exchange-items`、`GET /api/tenant-admin/point-exchange-orders`、`GET /api/tenant-admin/points-risk-report`、`GET /api/tenant-admin/feedbacks/report`;学生端用 `GET /api/profile/badges` 展示成就,通过 `POST /api/profile/check-in` 自动领取 `daily_check_in` 任务奖励,并通过 `GET /api/profile/activity-tasks`、`POST /api/profile/activity-tasks/claim`、`GET /api/profile/exchange-items`、`POST /api/profile/exchange-items/redeem` 接其它积分活动和兑换。
|
||||||
|
|||||||
@@ -44,7 +44,7 @@
|
|||||||
- 内容资源复检与安全扫描 worker 已完成:`apps/worker --job assets` 可复检 `content_assets` 中的托管对象元数据,并执行内置 `metadata_rules` 和可选外部 HTTP scanner;正常资源写回复检/扫描证据,异常资源自动置为 `failed/skipped + draft` 或 `security_scan_status=failed`,外部 scanner 不可用默认 fail-closed,并写入审计、扫描事件和安全标记。
|
- 内容资源复检与安全扫描 worker 已完成:`apps/worker --job assets` 可复检 `content_assets` 中的托管对象元数据,并执行内置 `metadata_rules` 和可选外部 HTTP scanner;正常资源写回复检/扫描证据,异常资源自动置为 `failed/skipped + draft` 或 `security_scan_status=failed`,外部 scanner 不可用默认 fail-closed,并写入审计、扫描事件和安全标记。
|
||||||
- 题库导出 worker 已完成:`apps/worker --job exports` 可抢占 `pdf/docx/daily_practice_zip` 导出任务,渲染 PDF/Word、水印或每日一练图片素材包,写入对象存储或本地开发存储,创建 `content_assets` 并回填 `assetId/hash/size`;`daily_practice` 已支持每日一练九宫格 metadata、PDF/Word 基础版式、9 张 PNG/SVG 卡片和拼图 ZIP。
|
- 题库导出 worker 已完成:`apps/worker --job exports` 可抢占 `pdf/docx/daily_practice_zip` 导出任务,渲染 PDF/Word、水印或每日一练图片素材包,写入对象存储或本地开发存储,创建 `content_assets` 并回填 `assetId/hash/size`;`daily_practice` 已支持每日一练九宫格 metadata、PDF/Word 基础版式、9 张 PNG/SVG 卡片和拼图 ZIP。
|
||||||
- 租户后台媒体运营报表已完成:`/api/tenant-content/media-analytics/summary`、`asset-events`、`video-events` 可按 7/30/90 天、资源、视频、用户和水印 traceId 查询资料访问、视频播放、拒绝访问、Top 资源/视频和日趋势;仅开放给 owner/admin/operator 或 `content:analytics:read` 权限角色,前端不会拿到签名 URL 或播放 token。
|
- 租户后台媒体运营报表已完成:`/api/tenant-content/media-analytics/summary`、`asset-events`、`video-events` 可按 7/30/90 天、资源、视频、用户和水印 traceId 查询资料访问、视频播放、拒绝访问、Top 资源/视频和日趋势;仅开放给 owner/admin/operator 或 `content:analytics:read` 权限角色,前端不会拿到签名 URL 或播放 token。
|
||||||
- 本地验证:`npm run check:refactor` 已通过;三套 Taro H5 已补 `index.html` 构建模板、发布产物扫描和 `npm run smoke:taro:h5` 静态启动烟测,能验证发布目录、公开 runtime config、history fallback、静态资源和租户解析契约。
|
- 本地验证:`npm run check:refactor` 已通过;三套 Taro H5 已补 `index.html` 构建模板、发布产物扫描、`npm run smoke:taro:h5` 静态启动烟测和 `npm run smoke:taro:h5:interaction` 真实浏览器交互烟测,能验证发布目录、公开 runtime config、history fallback、静态资源、租户解析契约、关键页面 JS 执行和三类角色入口点击。
|
||||||
|
|
||||||
当前更适合进入前端联调前阅读的总览文档:
|
当前更适合进入前端联调前阅读的总览文档:
|
||||||
|
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
- 已补 `npm run launch:gate` 生产上线证据门禁和 `docs/refactor/production-launch-evidence.template.json` 模板;最终切换前必须把 readiness、远程 Auth、RLS、生产 dry-run、导入校验、`pb:import:sample` 业务抽样、真实数据 API 读路径压测、API/worker/Taro、运行时审计、`@codex-security`、备份/回滚/真实抽样/生产 provider 等证据填入本地 `production-launch-evidence.json` 并通过门禁。当前 Codex 环境未暴露可调用的 `@codex-security` 扫描工具时,该项只能标为待补,不能伪造完成。
|
- 已补 `npm run launch:gate` 生产上线证据门禁和 `docs/refactor/production-launch-evidence.template.json` 模板;最终切换前必须把 readiness、远程 Auth、RLS、生产 dry-run、导入校验、`pb:import:sample` 业务抽样、真实数据 API 读路径压测、API/worker/Taro、运行时审计、`@codex-security`、备份/回滚/真实抽样/生产 provider 等证据填入本地 `production-launch-evidence.json` 并通过门禁。当前 Codex 环境未暴露可调用的 `@codex-security` 扫描工具时,该项只能标为待补,不能伪造完成。
|
||||||
- 确认数据库迁移流程、备份恢复、日志、告警。
|
- 确认数据库迁移流程、备份恢复、日志、告警。
|
||||||
- 准备 API 容器部署和 Supabase 云端/自托管连接方案。
|
- 准备 API 容器部署和 Supabase 云端/自托管连接方案。
|
||||||
- 已补 `npm run perf:api:local`、`npm run perf:summary`、`npm run perf:postgres:evidence`、`npm run smoke:launch-persona`、`npm run smoke:taro:h5`、`node scripts/taro-api-contract-test.js`、`node scripts/taro-persona-contract-test.js` 和 `docs/refactor/performance-benchmark-runbook.md`,可在本地或云端对真实迁移数据做只读门禁、混合读写容量观察、PostgreSQL 调参证据、三类后端角色旅程烟测、三类 Taro 前端角色旅程契约、H5 发布目录启动烟测和前端 API 契约检查。2026-07-01 本地真实迁移库只读 30 worker/120s 为 108336 请求、0 错误、897.04 req/s、P95 68.32ms、P99 84.50ms;最新混合读写复测 100 worker/60s/8% 写入为 43379 请求、0 错误、710.38 req/s、P95 257.59ms、P99 328.75ms;150 worker/60s/6% 写入为 43738 请求、0 错误、716.06 req/s、P95 375.03ms、P99 485.60ms,说明本地舒适区暂按 100 个无停顿 worker 估算,压力上沿仍在 100 到 150 worker 之间。
|
- 已补 `npm run perf:api:local`、`npm run perf:summary`、`npm run perf:postgres:evidence`、`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 调参证据、三类后端角色旅程烟测、三类 Taro 前端角色旅程契约、H5 发布目录启动烟测、真实浏览器关键点击烟测和前端 API 契约检查。2026-07-01 本地真实迁移库只读 30 worker/120s 为 108336 请求、0 错误、897.04 req/s、P95 68.32ms、P99 84.50ms;最新混合读写复测 100 worker/120s/8% 写入为 84694 请求、0 错误、699.54 req/s、P95 265.11ms、P99 337.82ms;150 worker/120s/6% 写入为 74209 请求、0 错误、612.19 req/s、P95 446.37ms、P99 579.39ms。当前本机 Docker 舒适区暂按 100 个无停顿 worker 估算,150 worker 已是压力区;按单学生 0.05 到 0.2 req/s 粗略折算约为 3500 到 14000 名活跃在线学生的吞吐观察区间,正式容量仍以上云 4 核 16G 复测为准。
|
||||||
- 当前本地 PostgreSQL evidence 仍提示 `jit=on`、`statement_timeout=0`、`idle_in_transaction_session_timeout=0`、`lock_timeout=0`;上云后必须按 `docs/refactor/postgresql-4c16g-tuning.md` 调整参数并复跑 evidence。4 核 16G 正式容量报告需上云后按 6/30/50/100 阶梯并发复跑并归档到本地上线证据。
|
- 当前本地 PostgreSQL evidence 仍提示 `jit=on`、`statement_timeout=0`、`idle_in_transaction_session_timeout=0`、`lock_timeout=0`;上云后必须按 `docs/refactor/postgresql-4c16g-tuning.md` 调整参数并复跑 evidence。4 核 16G 正式容量报告需上云后按 6/30/50/100 阶梯并发复跑并归档到本地上线证据。
|
||||||
|
|
||||||
### P1 商用功能完善
|
### P1 商用功能完善
|
||||||
@@ -265,7 +265,7 @@
|
|||||||
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、男女预设头像选择、收银台、订单详情、售后入口、独立消息中心、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、知识手册章节内搜索/安全摘要高亮/目录定位、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词学习概览/卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、小程序支付容器、分享场景和状态管理。前端不得实现头像上传、头像裁剪或第三方头像同步。
|
2. 继续补 Taro 学生端旧体验:地区选择、刷题答题卡、后端权威断点续练、本地进度恢复、模拟倒计时、主观题后端自评、阅读理解/案例分析多小题、视频播放、反馈、模考报告、逐题复盘、错题/收藏专题、个人中心学习报告、男女预设头像选择、收银台、订单详情、售后入口、独立消息中心、积分任务/兑换/积分明细、题干/解析/知识手册 RichContent 安全渲染、知识手册章节内搜索/安全摘要高亮/目录定位、H5 KaTeX 公式渲染、私有资源 ID 题图短签名、背单词学习概览/卡片学习/发音/收藏练习、资料短签名水印预览/下载确认已接第一版;继续补小程序公式真机验收、题图资源字段化、小程序支付容器、分享场景和状态管理。前端不得实现头像上传、头像裁剪或第三方头像同步。
|
||||||
3. 补平台后台增强:租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。
|
3. 补平台后台增强:租户基础资料编辑增强、平台审计告警升级策略、平台催缴通知配置操作台细节和平台在线收款。
|
||||||
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
4. 云服务器部署 Supabase/PostgreSQL 和 API,配置对象存储生产环境变量,跑 `check:refactor` 的远程等价测试。
|
||||||
5. 三套 H5 上云前必须在目标目录补真实公开 `runtime-config.json`,先执行 `npm run smoke:taro:h5` 和 `node scripts/taro-h5-release-guardrails-test.js --require-dist` 做本地发布目录烟测;写入生产上线证据时必须执行 `npm --silent run smoke:taro:h5 -- --json` 和 `node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config --json`,确保 warning 为 0。随后人工打开学生端/租户后台/平台后台域名,确认入口、租户解析、登录态和 `Authorization + x-tenant-id` 请求正常。当前构建仍有 webpack 体积 warning,后续做首屏拆包、按入口拆页面和 Supabase client 引入优化。
|
5. 三套 H5 上云前必须在目标目录补真实公开 `runtime-config.json`,先执行 `npm run smoke:taro:h5`、`npm run smoke:taro:h5:interaction` 和 `node scripts/taro-h5-release-guardrails-test.js --require-dist` 做本地发布目录烟测、真实浏览器交互烟测和发布守卫;写入生产上线证据时必须执行 `npm --silent run smoke:taro:h5 -- --json`、`npm --silent run smoke:taro:h5:interaction -- --json` 和 `node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config --json`,确保 warning 为 0。随后人工打开学生端/租户后台/平台后台域名,确认入口、租户解析、登录态和 `Authorization + x-tenant-id` 请求正常。当前构建仍有 webpack 体积 warning,后续做首屏拆包、按入口拆页面和 Supabase client 引入优化。
|
||||||
6. 使用 `F:\project\参考\旧题库数据库文件` 中的真实 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 继续做人工复核和抽样验收;当前 dry-run/导入/校验链路已跑通,下一步重点是 7 个已支付缺用户订单、22 个待复核手册章节、2533 道旧分类缺失题目和 5298 条引用已删除题目的答题记录的运营处理结论。导入过程中发现的字段污染、跨集合引用断裂、敏感字段和旧权限问题都要沉淀到 importer mapper 或修复脚本,不手工临时修库。
|
6. 使用 `F:\project\参考\旧题库数据库文件` 中的真实 PocketBase 数据,按 `docs/refactor/pocketbase-real-data-migration-runbook.md` 继续做人工复核和抽样验收;当前 dry-run/导入/校验链路已跑通,下一步重点是 7 个已支付缺用户订单、22 个待复核手册章节、2533 道旧分类缺失题目和 5298 条引用已删除题目的答题记录的运营处理结论。导入过程中发现的字段污染、跨集合引用断裂、敏感字段和旧权限问题都要沉淀到 importer mapper 或修复脚本,不手工临时修库。
|
||||||
7. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
|
7. 并行补真实登录、真实生产账单格式验收、异常订单运营台、对象存储真实 AV/内容安全服务联调、转码/CDN 级水印/生命周期、题库导出模板精排/操作台、公共题库生产定时调度和失败告警。
|
||||||
8. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
8. 前后端联调通过后,再做支付、权限、数据导入、资料下载、视频播放的商用验收。
|
||||||
|
|||||||
@@ -225,6 +225,24 @@
|
|||||||
"tenantResolveRequests": 3
|
"tenantResolveRequests": 3
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"id": "taro.h5-interaction-smoke",
|
||||||
|
"status": "pass",
|
||||||
|
"command": "npm --silent run smoke:taro:h5:interaction -- --json > docs/refactor/launch-artifacts/taro-h5-interaction-smoke.json",
|
||||||
|
"completedAt": "2026-06-30T12:13:00+08:00",
|
||||||
|
"artifact": "launch-artifacts/taro-h5-interaction-smoke.json",
|
||||||
|
"summary": {
|
||||||
|
"fail": 0,
|
||||||
|
"pass": 11,
|
||||||
|
"mockApi": {
|
||||||
|
"keyRequests": {
|
||||||
|
"answers": 1,
|
||||||
|
"favorites": 2,
|
||||||
|
"tenantResolve": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"id": "taro.h5-release-guardrails",
|
"id": "taro.h5-release-guardrails",
|
||||||
"status": "pass",
|
"status": "pass",
|
||||||
|
|||||||
@@ -192,15 +192,17 @@ H5 正式回归时建议把前端登录态切到 Supabase Auth,并观察业务
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run smoke:taro:h5
|
npm run smoke:taro:h5
|
||||||
|
npm run smoke:taro:h5:interaction
|
||||||
node scripts/taro-h5-release-guardrails-test.js --require-dist
|
node scripts/taro-h5-release-guardrails-test.js --require-dist
|
||||||
```
|
```
|
||||||
|
|
||||||
`smoke:taro:h5` 会用临时静态服务器检查三套 H5 产物可托管、资源可加载、history fallback 可用,并用 mock API 验证租户解析契约。发布守卫会检查三套 H5 产物是否存在 `index.html`,源码和产物是否混入 `x-user-id`、`x-platform-admin-key`、PocketBase 引用、数据库连接串、服务端密钥形态,并检查运行时配置示例只包含公开字段。若还没有把真实 `runtime-config.json` 放入静态目录,会显示 warning;正式发布前必须在每个 H5 目录根部补齐该文件。
|
`smoke:taro:h5` 会用临时静态服务器检查三套 H5 产物可托管、资源可加载、history fallback 可用,并用 mock API 验证租户解析契约。`smoke:taro:h5:interaction` 会用真实 Chrome/Edge 打开三套发布产物并点击学生刷题/收藏/会员、租户题库内容/财务、平台租户/账务中心关键路径;若服务器没有默认浏览器,可设置 `TARO_H5_SMOKE_BROWSER=/path/to/chrome`。发布守卫会检查三套 H5 产物是否存在 `index.html`,源码和产物是否混入 `x-user-id`、`x-platform-admin-key`、PocketBase 引用、数据库连接串、服务端密钥形态,并检查运行时配置示例只包含公开字段。若还没有把真实 `runtime-config.json` 放入静态目录,会显示 warning;正式发布前必须在每个 H5 目录根部补齐该文件。
|
||||||
|
|
||||||
写入 `production-launch-evidence.json` 的正式证据必须使用严格模式,确保三套发布目录都已放置真实公开 `runtime-config.json` 且没有 warning:
|
写入 `production-launch-evidence.json` 的正式证据必须使用严格模式,确保三套发布目录都已放置真实公开 `runtime-config.json` 且没有 warning:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm --silent run smoke:taro:h5 -- --json > docs/refactor/launch-artifacts/taro-h5-static-smoke.json
|
npm --silent run smoke:taro:h5 -- --json > docs/refactor/launch-artifacts/taro-h5-static-smoke.json
|
||||||
|
npm --silent run smoke:taro:h5:interaction -- --json > docs/refactor/launch-artifacts/taro-h5-interaction-smoke.json
|
||||||
node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config --json > docs/refactor/launch-artifacts/taro-h5-release-guardrails.json
|
node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config --json > docs/refactor/launch-artifacts/taro-h5-release-guardrails.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -82,16 +82,18 @@ npm run check:api
|
|||||||
npm run check:worker
|
npm run check:worker
|
||||||
npm run check:taro
|
npm run check:taro
|
||||||
npm run smoke:taro:h5
|
npm run smoke:taro:h5
|
||||||
|
npm run smoke:taro:h5:interaction
|
||||||
node scripts/taro-h5-release-guardrails-test.js --require-dist
|
node scripts/taro-h5-release-guardrails-test.js --require-dist
|
||||||
npm run smoke:launch-persona
|
npm run smoke:launch-persona
|
||||||
```
|
```
|
||||||
|
|
||||||
`smoke:taro:h5` 会启动临时静态服务器和 mock API,验证三套 H5 的 `index.html`、JS/CSS 资源、history fallback、公开 runtime config 和 `/api/tenant/resolve` 契约。它是发布目录级启动烟测,不替代真实浏览器/真机点击验收。`taro-h5-release-guardrails-test` 会扫描源码、三套 H5 产物和 runtime-config 边界,防止旧 PocketBase、`x-user-id`、`x-platform-admin-key`、数据库连接串和服务端密钥形态进入前端发布目录。若刚构建完但未放入真实 `runtime-config.json`,脚本允许 warning;正式部署目录必须补齐。
|
`smoke:taro:h5` 会启动临时静态服务器和 mock API,验证三套 H5 的 `index.html`、JS/CSS 资源、history fallback、公开 runtime config 和 `/api/tenant/resolve` 契约。`smoke:taro:h5:interaction` 会在真实 Chrome/Edge 中点击学生、租户后台、平台后台关键路径,覆盖静态烟测发现不了的 JS 运行时、直接 history 路由刷新和 Taro 点击事件问题。`taro-h5-release-guardrails-test` 会扫描源码、三套 H5 产物和 runtime-config 边界,防止旧 PocketBase、`x-user-id`、`x-platform-admin-key`、数据库连接串和服务端密钥形态进入前端发布目录。若刚构建完但未放入真实 `runtime-config.json`,脚本允许 warning;正式部署目录必须补齐。
|
||||||
|
|
||||||
写入生产上线证据时,三套正式发布目录必须先放入真实公开 `runtime-config.json`,再运行严格模式:
|
写入生产上线证据时,三套正式发布目录必须先放入真实公开 `runtime-config.json`,再运行严格模式:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm --silent run smoke:taro:h5 -- --json > docs/refactor/launch-artifacts/taro-h5-static-smoke.json
|
npm --silent run smoke:taro:h5 -- --json > docs/refactor/launch-artifacts/taro-h5-static-smoke.json
|
||||||
|
npm --silent run smoke:taro:h5:interaction -- --json > docs/refactor/launch-artifacts/taro-h5-interaction-smoke.json
|
||||||
node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config --json > docs/refactor/launch-artifacts/taro-h5-release-guardrails.json
|
node scripts/taro-h5-release-guardrails-test.js --require-dist --require-runtime-config --json > docs/refactor/launch-artifacts/taro-h5-release-guardrails.json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,7 @@
|
|||||||
"test:launch-gate": "node scripts/production-launch-gate-test.js",
|
"test:launch-gate": "node scripts/production-launch-gate-test.js",
|
||||||
"smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js",
|
"smoke:launch-persona": "npm run build:api && node scripts/launch-persona-smoke.js",
|
||||||
"smoke:taro:h5": "node scripts/taro-h5-static-smoke.js",
|
"smoke:taro:h5": "node scripts/taro-h5-static-smoke.js",
|
||||||
|
"smoke:taro:h5:interaction": "node scripts/taro-h5-interaction-smoke.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",
|
||||||
"test:pb:sqlite-export": "node scripts/pb-sqlite-export-test.js",
|
"test:pb:sqlite-export": "node scripts/pb-sqlite-export-test.js",
|
||||||
"test:perf:summary": "node scripts/performance-summary-test.js",
|
"test:perf:summary": "node scripts/performance-summary-test.js",
|
||||||
|
|||||||
@@ -24,7 +24,17 @@ function sampleSummaryValue(expectedValue) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function sampleSummary(summarySpec) {
|
function sampleSummary(summarySpec) {
|
||||||
return Object.fromEntries(Object.entries(summarySpec || {}).map(([key, value]) => [key, sampleSummaryValue(value)]));
|
const result = {};
|
||||||
|
for (const [key, value] of Object.entries(summarySpec || {})) {
|
||||||
|
const parts = key.split('.');
|
||||||
|
let cursor = result;
|
||||||
|
for (const part of parts.slice(0, -1)) {
|
||||||
|
if (!cursor[part] || typeof cursor[part] !== 'object') cursor[part] = {};
|
||||||
|
cursor = cursor[part];
|
||||||
|
}
|
||||||
|
cursor[parts.at(-1)] = sampleSummaryValue(value);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createEvidence(tempDir, overrides = {}) {
|
function createEvidence(tempDir, overrides = {}) {
|
||||||
@@ -154,6 +164,17 @@ assert.ok(
|
|||||||
'missing H5 static smoke should be reported as a blocker',
|
'missing H5 static smoke should be reported as a blocker',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const missingTaroInteractionSmoke = runGate(tempDir => {
|
||||||
|
const evidence = createEvidence(tempDir);
|
||||||
|
evidence.checks = evidence.checks.filter(item => item.id !== 'taro.h5-interaction-smoke');
|
||||||
|
return evidence;
|
||||||
|
});
|
||||||
|
assert.notEqual(missingTaroInteractionSmoke.status, 0, 'missing H5 interaction smoke should fail launch gate');
|
||||||
|
assert.ok(
|
||||||
|
missingTaroInteractionSmoke.payload.checks?.some(item => item.id === 'check.taro.h5-interaction-smoke' && item.status === 'blocker'),
|
||||||
|
'missing H5 interaction smoke should be reported as a blocker',
|
||||||
|
);
|
||||||
|
|
||||||
const missingAttestation = runGate(tempDir => {
|
const missingAttestation = runGate(tempDir => {
|
||||||
const evidence = createEvidence(tempDir);
|
const evidence = createEvidence(tempDir);
|
||||||
evidence.attestations = evidence.attestations.filter(item => item.id !== 'backup.snapshot');
|
evidence.attestations = evidence.attestations.filter(item => item.id !== 'backup.snapshot');
|
||||||
|
|||||||
@@ -134,6 +134,18 @@ const gateChecks = [
|
|||||||
tenantResolveRequests: 3,
|
tenantResolveRequests: 3,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'taro.h5-interaction-smoke',
|
||||||
|
label: 'Taro H5 real browser interaction smoke',
|
||||||
|
commandIncludes: 'smoke:taro:h5:interaction',
|
||||||
|
summary: {
|
||||||
|
fail: 0,
|
||||||
|
pass: { gte: 10 },
|
||||||
|
'mockApi.keyRequests.answers': { gte: 1 },
|
||||||
|
'mockApi.keyRequests.favorites': { gte: 1 },
|
||||||
|
'mockApi.keyRequests.tenantResolve': { gte: 3 },
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'taro.h5-release-guardrails',
|
id: 'taro.h5-release-guardrails',
|
||||||
label: 'Taro H5 release artifact guardrails',
|
label: 'Taro H5 release artifact guardrails',
|
||||||
|
|||||||
924
scripts/taro-h5-interaction-smoke.js
Normal file
924
scripts/taro-h5-interaction-smoke.js
Normal file
@@ -0,0 +1,924 @@
|
|||||||
|
import { spawn } from 'node:child_process';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import http from 'node:http';
|
||||||
|
import net from 'node:net';
|
||||||
|
import os from 'node:os';
|
||||||
|
import path from 'node:path';
|
||||||
|
import { setTimeout as delay } from 'node:timers/promises';
|
||||||
|
|
||||||
|
const repoRoot = process.cwd();
|
||||||
|
const distRoot = path.join(repoRoot, 'apps', 'taro', 'dist');
|
||||||
|
const outputDir = process.env.TARO_H5_INTERACTION_OUTPUT_DIR || 'docs/refactor/launch-artifacts';
|
||||||
|
|
||||||
|
const ids = {
|
||||||
|
tenant: '00000000-0000-4000-8000-000000000001',
|
||||||
|
user: '00000000-0000-4000-8000-000000000101',
|
||||||
|
region: '00000000-0000-4000-8000-000000000301',
|
||||||
|
entry: '00000000-0000-4000-8000-000000000401',
|
||||||
|
node: '00000000-0000-4000-8000-000000000402',
|
||||||
|
collection: '00000000-0000-4000-8000-000000000403',
|
||||||
|
blueprint: '00000000-0000-4000-8000-000000000404',
|
||||||
|
question: '00000000-0000-4000-8000-000000000501',
|
||||||
|
session: '00000000-0000-4000-8000-000000000601',
|
||||||
|
plan: '00000000-0000-4000-8000-000000000701',
|
||||||
|
order: '00000000-0000-4000-8000-000000000801',
|
||||||
|
};
|
||||||
|
|
||||||
|
const portals = [
|
||||||
|
{ portal: 'student', dist: 'h5-student', landingPath: '/pages/student/home/index' },
|
||||||
|
{ portal: 'tenant-admin', dist: 'h5-tenant-admin', landingPath: '/pages/tenant-admin/workbench/index' },
|
||||||
|
{ portal: 'platform-admin', dist: 'h5-platform-admin', landingPath: '/pages/platform-admin/workbench/index' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function parseArgs(argv) {
|
||||||
|
return {
|
||||||
|
json: argv.includes('--json'),
|
||||||
|
keepBrowser: argv.includes('--keep-browser'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function shanghaiTimestampForFile(date = new Date()) {
|
||||||
|
const parts = Object.fromEntries(
|
||||||
|
new Intl.DateTimeFormat('en-CA', {
|
||||||
|
timeZone: 'Asia/Shanghai',
|
||||||
|
year: 'numeric',
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit',
|
||||||
|
hour12: false,
|
||||||
|
}).formatToParts(date).map(part => [part.type, part.value]),
|
||||||
|
);
|
||||||
|
return `${parts.year}${parts.month}${parts.day}-${parts.hour}${parts.minute}${parts.second}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSlashes(value) {
|
||||||
|
return value.replace(/\\/g, '/');
|
||||||
|
}
|
||||||
|
|
||||||
|
function relative(filePath) {
|
||||||
|
return normalizeSlashes(path.relative(repoRoot, filePath));
|
||||||
|
}
|
||||||
|
|
||||||
|
function contentTypeFor(filePath) {
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
if (ext === '.html') return 'text/html; charset=utf-8';
|
||||||
|
if (ext === '.js') return 'application/javascript; charset=utf-8';
|
||||||
|
if (ext === '.css') return 'text/css; charset=utf-8';
|
||||||
|
if (ext === '.json') return 'application/json; charset=utf-8';
|
||||||
|
if (ext === '.svg') return 'image/svg+xml';
|
||||||
|
if (ext === '.png') return 'image/png';
|
||||||
|
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg';
|
||||||
|
if (ext === '.webp') return 'image/webp';
|
||||||
|
if (ext === '.woff2') return 'font/woff2';
|
||||||
|
if (ext === '.woff') return 'font/woff';
|
||||||
|
return 'application/octet-stream';
|
||||||
|
}
|
||||||
|
|
||||||
|
function jsonResponse(response, statusCode, payload, extraHeaders = {}) {
|
||||||
|
response.writeHead(statusCode, {
|
||||||
|
'content-type': 'application/json; charset=utf-8',
|
||||||
|
'cache-control': 'no-store',
|
||||||
|
'access-control-allow-origin': '*',
|
||||||
|
'access-control-allow-methods': 'GET,POST,PUT,PATCH,DELETE,OPTIONS',
|
||||||
|
'access-control-allow-headers': 'authorization,content-type,x-tenant-id,x-smoke-portal',
|
||||||
|
...extraHeaders,
|
||||||
|
});
|
||||||
|
response.end(`${JSON.stringify(payload)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function textResponse(response, statusCode, body, headers = {}) {
|
||||||
|
response.writeHead(statusCode, headers);
|
||||||
|
response.end(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestBody(req) {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
let raw = '';
|
||||||
|
req.on('data', chunk => {
|
||||||
|
raw += chunk.toString();
|
||||||
|
});
|
||||||
|
req.on('end', () => {
|
||||||
|
if (!raw.trim()) {
|
||||||
|
resolve({});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
resolve(JSON.parse(raw));
|
||||||
|
} catch {
|
||||||
|
resolve({ raw });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function baseTenantPayload(query) {
|
||||||
|
return {
|
||||||
|
item: {
|
||||||
|
tenantId: ids.tenant,
|
||||||
|
tenantSlug: query.get('tenantCode') || 'master',
|
||||||
|
branding: {
|
||||||
|
brandName: '工学题库',
|
||||||
|
shortName: '工学',
|
||||||
|
theme: {
|
||||||
|
primaryColor: '#2563eb',
|
||||||
|
accentColor: '#16a34a',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
features: {
|
||||||
|
enableLeaderboard: false,
|
||||||
|
},
|
||||||
|
adminFeatures: {
|
||||||
|
tenantAdmin: true,
|
||||||
|
platformAdmin: true,
|
||||||
|
},
|
||||||
|
publicConfig: {
|
||||||
|
h5InteractionSmoke: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const question = {
|
||||||
|
id: ids.question,
|
||||||
|
content: '这是 H5 交互烟测题目:1 + 1 = ?',
|
||||||
|
type: 'single_choice',
|
||||||
|
typeLabel: '单选题',
|
||||||
|
options: ['2', '3', '4', '5'],
|
||||||
|
correctOptionIndex: 0,
|
||||||
|
correctOptionIndices: [0],
|
||||||
|
explanation: '1 + 1 = 2。此题用于验证普通学生刷题、答题和收藏入口。',
|
||||||
|
answerText: '2',
|
||||||
|
hasVideoExplanation: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const tenantDashboard = {
|
||||||
|
cards: {
|
||||||
|
students: { total: 128, newInRange: 12 },
|
||||||
|
learning: { answers: 3200, activeStudents: 86, accuracy: 0.78 },
|
||||||
|
content: { questions: 74117 },
|
||||||
|
activationCodes: { total: 500, used: 180 },
|
||||||
|
feedback: { pending: 3 },
|
||||||
|
},
|
||||||
|
paymentStats: {
|
||||||
|
revenueCentsInRange: 529900,
|
||||||
|
paidOrdersInRange: 26,
|
||||||
|
},
|
||||||
|
planSales: [{ planId: ids.plan, name: 'SVIP 年卡', paidOrders: 18, revenueCents: 358200 }],
|
||||||
|
recentActivities: [{ activityType: 'student_registered', title: '新增学生注册', occurredAt: new Date().toISOString() }],
|
||||||
|
};
|
||||||
|
|
||||||
|
function mockApiPayload(pathname, method, query, body) {
|
||||||
|
if (pathname === '/health') return { ok: true };
|
||||||
|
if (pathname === '/api/tenant/resolve') return baseTenantPayload(query);
|
||||||
|
if (pathname === '/api/auth/me') return { user: { id: ids.user, name: '测试学生' }, item: { id: ids.user, name: '测试学生' } };
|
||||||
|
|
||||||
|
if (pathname === '/api/catalog/content-entries') {
|
||||||
|
return {
|
||||||
|
items: [{
|
||||||
|
id: ids.entry,
|
||||||
|
name: '天津专升本题库',
|
||||||
|
entryType: 'question_bank',
|
||||||
|
description: '公共课、专业课和院校分类入口',
|
||||||
|
regionId: ids.region,
|
||||||
|
}],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (pathname === '/api/catalog/banners' || pathname === '/api/catalog/announcements') return { items: [] };
|
||||||
|
if (pathname === '/api/catalog/regions') return { items: [{ id: ids.region, name: '天津', code: 'tj', isHot: true }] };
|
||||||
|
if (pathname === '/api/catalog/content-nodes') {
|
||||||
|
return { items: [{ id: ids.node, entryId: ids.entry, name: '文化课', nodeType: 'subject', markerType: '公共课', isLeaf: true }] };
|
||||||
|
}
|
||||||
|
if (pathname === '/api/catalog/question-collections') {
|
||||||
|
return { items: [{ id: ids.collection, entryId: ids.entry, nodeId: ids.node, name: '英语基础章节', collectionType: 'chapter', questionCount: 1 }] };
|
||||||
|
}
|
||||||
|
if (pathname === '/api/catalog/practice-blueprints') {
|
||||||
|
return { items: [{ id: ids.blueprint, entryId: ids.entry, nodeId: ids.node, collectionId: ids.collection, name: '顺序练习', mode: 'sequential', questionLimit: 1 }] };
|
||||||
|
}
|
||||||
|
if (pathname === '/api/catalog/question-collections/questions' || pathname === '/api/catalog/questions') return { items: [question] };
|
||||||
|
if (pathname === '/api/catalog/svip-plans') {
|
||||||
|
return { items: [{ id: ids.plan, name: 'SVIP 年卡', priceCents: 19900, days: 365, badge: '推荐', regionId: ids.region, desc: '题库、解析和资料权益' }] };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/profile/me') {
|
||||||
|
return {
|
||||||
|
item: {
|
||||||
|
id: ids.user,
|
||||||
|
userId: ids.user,
|
||||||
|
name: '测试学生',
|
||||||
|
phone: '13800000000',
|
||||||
|
avatarPreset: 'male',
|
||||||
|
score: 120,
|
||||||
|
target: { regionId: ids.region, regionName: '天津' },
|
||||||
|
stats: { answers: { totalAnswered: 12 } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (pathname === '/api/profile/score-events') return { items: [] };
|
||||||
|
if (pathname === '/api/profile/activity-tasks') return { items: [] };
|
||||||
|
if (pathname === '/api/profile/exchange-items') return { items: [] };
|
||||||
|
if (pathname === '/api/profile/notifications') return { items: [], summary: { unread: 0, read: 0, archived: 0 } };
|
||||||
|
if (pathname === '/api/profile/badges') return { items: [{ id: 'badge-smoke', name: '坚持练习' }] };
|
||||||
|
if (pathname === '/api/profile/exam-countdowns') return { items: [] };
|
||||||
|
if (pathname === '/api/commerce/entitlements') return { items: [{ type: 'svip', status: 'active' }], summary: { svip: true } };
|
||||||
|
if (pathname === '/api/commerce/orders' && method === 'GET') return { items: [] };
|
||||||
|
if (pathname === '/api/commerce/orders' && method === 'POST') {
|
||||||
|
return { item: { id: ids.order, orderNo: 'SMOKE202607010001', status: 'pending_payment', amountCents: 19900, planId: body.planId || ids.plan } };
|
||||||
|
}
|
||||||
|
if (pathname === '/api/commerce/payments/create') {
|
||||||
|
return { item: { provider: body.provider || 'alipay', paymentParams: { url: 'https://pay.example.test/smoke' } } };
|
||||||
|
}
|
||||||
|
if (pathname === '/api/commerce/orders/status') return { item: { orderNo: query.get('orderNo') || 'SMOKE202607010001', status: 'pending_payment' } };
|
||||||
|
|
||||||
|
if (pathname === '/api/learning/practice-sessions' && method === 'POST') {
|
||||||
|
return {
|
||||||
|
item: {
|
||||||
|
id: ids.session,
|
||||||
|
mode: body.mode || 'sequential',
|
||||||
|
questionIds: [ids.question],
|
||||||
|
questionCount: 1,
|
||||||
|
durationMinutes: 30,
|
||||||
|
accessMode: 'svip',
|
||||||
|
consumedFreeQuota: 0,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (pathname === '/api/learning/practice-sessions/detail') {
|
||||||
|
return {
|
||||||
|
item: {
|
||||||
|
id: ids.session,
|
||||||
|
mode: 'sequential',
|
||||||
|
questionIds: [ids.question],
|
||||||
|
questionCount: 1,
|
||||||
|
status: 'active',
|
||||||
|
questions: [question],
|
||||||
|
answersByQuestion: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (pathname === '/api/learning/answers') {
|
||||||
|
return {
|
||||||
|
item: {
|
||||||
|
id: 'answer-smoke',
|
||||||
|
questionId: body.questionId || ids.question,
|
||||||
|
isCorrect: true,
|
||||||
|
selectedOptions: body.selectedOptions || ['0'],
|
||||||
|
answeredAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (pathname === '/api/learning/favorites/questions' && method === 'POST') return { ok: true, favorite: body.favorite !== false };
|
||||||
|
if (pathname === '/api/learning/favorites/questions') return { items: [question] };
|
||||||
|
if (pathname === '/api/learning/wrong-questions/review-plan') return { items: [], nextAction: {} };
|
||||||
|
if (pathname === '/api/learning/wrong-questions') return { items: [] };
|
||||||
|
if (pathname === '/api/learning/stats') {
|
||||||
|
return {
|
||||||
|
item: {
|
||||||
|
windowDays: 30,
|
||||||
|
answers: { totalAnswered: 12, correctCount: 10, wrongCount: 2, todayAnswered: 3, accuracy: 0.83 },
|
||||||
|
sessions: { totalSessions: 2, activeSessions: 0, finishedSessions: 2 },
|
||||||
|
reports: { reportCount: 1, avgAccuracy: 0.83, bestScore: 83 },
|
||||||
|
wrongBook: { unresolvedWrong: 2, resolvedWrong: 1, totalWrongBook: 3 },
|
||||||
|
favorites: { favoriteQuestions: 1 },
|
||||||
|
questionTypes: [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (pathname === '/api/learning/trend') return { items: [{ date: '2026-07-01', answeredCount: 3, correctCount: 2, wrongCount: 1, accuracy: 0.66, sessionCount: 1, reportCount: 1, score: 66, totalScore: 100 }] };
|
||||||
|
if (pathname === '/api/learning/practice-sessions/history') return { items: [] };
|
||||||
|
|
||||||
|
if (pathname === '/api/tenant-admin/dashboard') return { item: tenantDashboard };
|
||||||
|
if (pathname === '/api/tenant-admin/overview') return { item: { slug: 'master', brandName: '工学题库商户后台' } };
|
||||||
|
if (pathname === '/api/tenant-admin/permissions') {
|
||||||
|
return {
|
||||||
|
current: {
|
||||||
|
role: 'owner',
|
||||||
|
menuPermissions: { dashboard: true, students: true, content: true, marketing: true, commerce: true, settings: true },
|
||||||
|
effectivePermissions: { '*': true },
|
||||||
|
},
|
||||||
|
roleDefaults: { owner: ['*'] },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (pathname === '/api/tenant-content/content-entries') return { items: [{ id: ids.entry, name: '天津专升本题库', entryType: 'question_bank', status: 'active' }] };
|
||||||
|
if (pathname === '/api/tenant-content/public-question-banks') return { items: [{ grantId: 'grant-smoke', questionBankId: 'bank-smoke', name: '平台公共题库', regionName: '天津', grantScope: '套餐授权', questionCount: 128 }] };
|
||||||
|
if (pathname === '/api/tenant-content/imports/templates') return { item: { importType: query.get('importType') || 'questions', format: query.get('format') || 'json', content: '{}' } };
|
||||||
|
if (pathname === '/api/tenant-content/imports/field-mapping') return { item: { importType: query.get('importType') || 'questions', aliases: {}, allowedTargets: [] } };
|
||||||
|
if (pathname.startsWith('/api/tenant-content/') || pathname.startsWith('/api/tenant-admin/') || pathname.startsWith('/api/crm/') || pathname.startsWith('/api/commission/') || pathname.startsWith('/api/referral/')) {
|
||||||
|
return method === 'GET' ? { items: [], item: null, summary: {} } : { ok: true, item: { id: 'tenant-smoke' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (pathname === '/api/platform-admin/overview') {
|
||||||
|
return { item: { tenants: { total: 6, active: 5 }, billing: { unpaidAmountCents: 360000 }, subscriptions: { expiringSoon: 1 }, usage: { questions: 74117 } } };
|
||||||
|
}
|
||||||
|
if (pathname === '/api/platform-admin/permissions') return { item: { primaryRole: 'platform_admin', effective: { '*': true }, catalog: [] } };
|
||||||
|
if (pathname === '/api/platform-admin/plans') return { items: [{ id: ids.plan, code: 'starter_yearly', name: '基础年费', baseAmountCents: 199900, status: 'active' }] };
|
||||||
|
if (pathname === '/api/platform-admin/tenants') return { items: [{ id: ids.tenant, slug: 'master', name: '工学题库', brandName: '工学题库', status: 'active', billingStatus: 'normal', planCode: 'starter_yearly', openBalanceCents: 0 }] };
|
||||||
|
if (pathname === '/api/platform-admin/invoices') return { items: [{ id: 'invoice-smoke', tenantId: ids.tenant, invoiceNo: 'INV-SMOKE', status: 'unpaid', totalCents: 199900, balanceCents: 199900 }] };
|
||||||
|
if (pathname === '/api/platform-admin/question-banks') return { items: [{ id: 'bank-smoke', name: '平台公共题库', status: 'active', questionCount: 128 }] };
|
||||||
|
if (pathname === '/api/platform-admin/question-bank-grants') return { items: [{ id: 'grant-smoke', questionBankId: 'bank-smoke', grantScope: 'all_active_tenants', status: 'active' }] };
|
||||||
|
if (pathname === '/api/platform-admin/staff') return { items: [{ id: ids.user, name: '平台管理员', primaryRole: 'platform_admin', status: 'active', authUserId: 'auth-smoke' }] };
|
||||||
|
if (pathname.startsWith('/api/platform-admin/')) return method === 'GET' ? { items: [], item: null } : { ok: true, item: { id: 'platform-smoke' } };
|
||||||
|
|
||||||
|
return method === 'GET' ? { items: [], item: null } : { ok: true, item: { id: 'smoke' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createMockApiServer() {
|
||||||
|
const requests = [];
|
||||||
|
const server = http.createServer(async (req, res) => {
|
||||||
|
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
||||||
|
if (req.method === 'OPTIONS') {
|
||||||
|
jsonResponse(res, 204, {});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = await requestBody(req);
|
||||||
|
requests.push({
|
||||||
|
method: req.method || 'GET',
|
||||||
|
path: url.pathname,
|
||||||
|
query: Object.fromEntries(url.searchParams.entries()),
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
try {
|
||||||
|
jsonResponse(res, 200, mockApiPayload(url.pathname, req.method || 'GET', url.searchParams, body));
|
||||||
|
} catch (error) {
|
||||||
|
jsonResponse(res, 500, { code: 'MOCK_API_ERROR', message: error instanceof Error ? error.message : String(error) });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const port = await listen(server);
|
||||||
|
return {
|
||||||
|
baseUrl: `http://127.0.0.1:${port}`,
|
||||||
|
requests,
|
||||||
|
close: () => closeServer(server),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function createStaticServer(portal, apiBaseUrl) {
|
||||||
|
const distDir = path.join(distRoot, portal.dist);
|
||||||
|
const runtimeConfig = {
|
||||||
|
portal: portal.portal,
|
||||||
|
apiBaseUrl,
|
||||||
|
supabaseUrl: 'https://auth.example.test',
|
||||||
|
supabasePublishableKey: 'sb_publishable_mock_key_for_h5_interaction_smoke',
|
||||||
|
tenantCode: 'master',
|
||||||
|
};
|
||||||
|
|
||||||
|
assertDistExists(portal);
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
||||||
|
const requestPath = decodeURIComponent(url.pathname);
|
||||||
|
if (requestPath === '/runtime-config.json') {
|
||||||
|
jsonResponse(res, 200, runtimeConfig);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = resolveStaticPath(distDir, requestPath);
|
||||||
|
if (!filePath) {
|
||||||
|
textResponse(res, 403, 'forbidden', { 'content-type': 'text/plain; charset=utf-8' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const finalPath = fs.existsSync(filePath) && fs.statSync(filePath).isFile()
|
||||||
|
? filePath
|
||||||
|
: path.join(distDir, 'index.html');
|
||||||
|
if (!fs.existsSync(finalPath)) {
|
||||||
|
textResponse(res, 404, 'not found', { 'content-type': 'text/plain; charset=utf-8' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
res.writeHead(200, {
|
||||||
|
'content-type': contentTypeFor(finalPath),
|
||||||
|
'cache-control': finalPath.endsWith('index.html') ? 'no-store' : 'public, max-age=31536000, immutable',
|
||||||
|
});
|
||||||
|
fs.createReadStream(finalPath).pipe(res);
|
||||||
|
});
|
||||||
|
const port = await listen(server);
|
||||||
|
return {
|
||||||
|
portal: portal.portal,
|
||||||
|
baseUrl: `http://127.0.0.1:${port}`,
|
||||||
|
landingPath: portal.landingPath,
|
||||||
|
close: () => closeServer(server),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveStaticPath(distDir, requestPath) {
|
||||||
|
const cleanPath = requestPath === '/' ? '/index.html' : requestPath;
|
||||||
|
const resolved = path.resolve(distDir, `.${cleanPath}`);
|
||||||
|
const normalizedRoot = path.resolve(distDir);
|
||||||
|
if (resolved !== normalizedRoot && !resolved.startsWith(`${normalizedRoot}${path.sep}`)) return null;
|
||||||
|
return resolved;
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertDistExists(portal) {
|
||||||
|
const distDir = path.join(distRoot, portal.dist);
|
||||||
|
const indexPath = path.join(distDir, 'index.html');
|
||||||
|
if (!fs.existsSync(distDir)) throw new Error(`${relative(distDir)} does not exist. Run npm run build:taro:h5 before interaction smoke.`);
|
||||||
|
if (!fs.existsSync(indexPath)) throw new Error(`${relative(indexPath)} does not exist. Run npm run build:taro:h5 before interaction smoke.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listen(server) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
server.once('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', () => resolve(server.address().port));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeServer(server) {
|
||||||
|
return new Promise(resolve => server.close(() => resolve()));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFreePort() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const server = net.createServer();
|
||||||
|
server.on('error', reject);
|
||||||
|
server.listen(0, '127.0.0.1', () => {
|
||||||
|
const address = server.address();
|
||||||
|
server.close(() => resolve(address.port));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function findBrowserExecutable() {
|
||||||
|
const candidates = [
|
||||||
|
process.env.TARO_H5_SMOKE_BROWSER,
|
||||||
|
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
|
||||||
|
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
|
||||||
|
'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||||
|
'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
|
||||||
|
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
|
||||||
|
'/Applications/Microsoft Edge.app/Contents/MacOS/Microsoft Edge',
|
||||||
|
'/usr/bin/google-chrome',
|
||||||
|
'/usr/bin/chromium',
|
||||||
|
'/usr/bin/chromium-browser',
|
||||||
|
'/usr/bin/microsoft-edge',
|
||||||
|
].filter(Boolean);
|
||||||
|
return candidates.find(item => fs.existsSync(item));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForDebugEndpoint(port, timeoutMs = 15_000) {
|
||||||
|
const started = Date.now();
|
||||||
|
while (Date.now() - started < timeoutMs) {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${port}/json/version`);
|
||||||
|
if (response.ok) return await response.json();
|
||||||
|
} catch {
|
||||||
|
// Chrome is still starting.
|
||||||
|
}
|
||||||
|
await delay(200);
|
||||||
|
}
|
||||||
|
throw new Error('Browser DevTools endpoint did not become available.');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startBrowser(options) {
|
||||||
|
const executable = findBrowserExecutable();
|
||||||
|
if (!executable) {
|
||||||
|
throw new Error('Chrome/Edge executable not found. Set TARO_H5_SMOKE_BROWSER to a Chromium-based browser path.');
|
||||||
|
}
|
||||||
|
const debugPort = await getFreePort();
|
||||||
|
const userDataDir = fs.mkdtempSync(path.join(os.tmpdir(), 'taro-h5-interaction-'));
|
||||||
|
const args = [
|
||||||
|
'--headless=new',
|
||||||
|
'--disable-gpu',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--no-first-run',
|
||||||
|
'--no-default-browser-check',
|
||||||
|
'--disable-background-networking',
|
||||||
|
'--disable-extensions',
|
||||||
|
`--remote-debugging-port=${debugPort}`,
|
||||||
|
`--user-data-dir=${userDataDir}`,
|
||||||
|
'about:blank',
|
||||||
|
];
|
||||||
|
const child = spawn(executable, args, { stdio: 'ignore', windowsHide: true });
|
||||||
|
await waitForDebugEndpoint(debugPort);
|
||||||
|
return {
|
||||||
|
executable,
|
||||||
|
debugPort,
|
||||||
|
close: async () => {
|
||||||
|
if (!options.keepBrowser && !child.killed) child.kill();
|
||||||
|
if (!options.keepBrowser) {
|
||||||
|
await delay(300);
|
||||||
|
fs.rmSync(userDataDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
class CdpPage {
|
||||||
|
constructor(wsUrl) {
|
||||||
|
this.wsUrl = wsUrl;
|
||||||
|
this.id = 1;
|
||||||
|
this.pending = new Map();
|
||||||
|
this.events = [];
|
||||||
|
this.ws = new WebSocket(wsUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
async connect() {
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
this.ws.addEventListener('open', resolve, { once: true });
|
||||||
|
this.ws.addEventListener('error', reject, { once: true });
|
||||||
|
this.ws.addEventListener('message', event => {
|
||||||
|
const message = JSON.parse(event.data);
|
||||||
|
if (message.id && this.pending.has(message.id)) {
|
||||||
|
const { resolve: finish, reject: fail } = this.pending.get(message.id);
|
||||||
|
this.pending.delete(message.id);
|
||||||
|
if (message.error) fail(new Error(`${message.error.message}: ${message.error.data || ''}`));
|
||||||
|
else finish(message.result || {});
|
||||||
|
} else if (message.method) {
|
||||||
|
this.events.push(message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await this.send('Runtime.enable');
|
||||||
|
await this.send('Page.enable');
|
||||||
|
await this.send('Log.enable');
|
||||||
|
await this.send('Network.enable');
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
send(method, params = {}) {
|
||||||
|
const id = this.id++;
|
||||||
|
this.ws.send(JSON.stringify({ id, method, params }));
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
this.pending.set(id, { resolve, reject });
|
||||||
|
setTimeout(() => {
|
||||||
|
if (this.pending.has(id)) {
|
||||||
|
this.pending.delete(id);
|
||||||
|
reject(new Error(`CDP command timed out: ${method}`));
|
||||||
|
}
|
||||||
|
}, 10_000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async evaluate(expression) {
|
||||||
|
const result = await this.send('Runtime.evaluate', {
|
||||||
|
expression,
|
||||||
|
awaitPromise: true,
|
||||||
|
returnByValue: true,
|
||||||
|
});
|
||||||
|
if (result.exceptionDetails) {
|
||||||
|
throw new Error(result.exceptionDetails.text || 'Runtime evaluation failed');
|
||||||
|
}
|
||||||
|
return result.result?.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
async navigate(url) {
|
||||||
|
await this.send('Page.navigate', { url });
|
||||||
|
}
|
||||||
|
|
||||||
|
close() {
|
||||||
|
this.ws.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnosticEvents() {
|
||||||
|
return this.events
|
||||||
|
.filter(event => [
|
||||||
|
'Runtime.exceptionThrown',
|
||||||
|
'Runtime.consoleAPICalled',
|
||||||
|
'Log.entryAdded',
|
||||||
|
'Network.loadingFailed',
|
||||||
|
'Network.responseReceived',
|
||||||
|
].includes(event.method))
|
||||||
|
.map(event => {
|
||||||
|
if (event.method === 'Runtime.exceptionThrown') {
|
||||||
|
return {
|
||||||
|
method: event.method,
|
||||||
|
text: event.params?.exceptionDetails?.text,
|
||||||
|
description: event.params?.exceptionDetails?.exception?.description,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (event.method === 'Runtime.consoleAPICalled') {
|
||||||
|
return {
|
||||||
|
method: event.method,
|
||||||
|
type: event.params?.type,
|
||||||
|
args: (event.params?.args || []).map(arg => arg.value || arg.description).filter(Boolean).slice(0, 5),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (event.method === 'Log.entryAdded') {
|
||||||
|
return {
|
||||||
|
method: event.method,
|
||||||
|
level: event.params?.entry?.level,
|
||||||
|
text: event.params?.entry?.text,
|
||||||
|
url: event.params?.entry?.url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (event.method === 'Network.loadingFailed') {
|
||||||
|
return {
|
||||||
|
method: event.method,
|
||||||
|
errorText: event.params?.errorText,
|
||||||
|
type: event.params?.type,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const response = event.params?.response || {};
|
||||||
|
if (response.status >= 400) {
|
||||||
|
return {
|
||||||
|
method: event.method,
|
||||||
|
status: response.status,
|
||||||
|
url: response.url,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.slice(-20);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function newPage(browser, url) {
|
||||||
|
const response = await fetch(`http://127.0.0.1:${browser.debugPort}/json/new?${encodeURIComponent(url)}`, { method: 'PUT' });
|
||||||
|
if (!response.ok) throw new Error(`Failed to create browser tab: ${response.status}`);
|
||||||
|
const target = await response.json();
|
||||||
|
return new CdpPage(target.webSocketDebuggerUrl).connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitUntil(label, fn, timeoutMs = 10_000) {
|
||||||
|
const started = Date.now();
|
||||||
|
let lastValue;
|
||||||
|
while (Date.now() - started < timeoutMs) {
|
||||||
|
lastValue = await fn().catch(error => ({ error: error.message }));
|
||||||
|
if (lastValue) return lastValue;
|
||||||
|
await delay(200);
|
||||||
|
}
|
||||||
|
throw new Error(`Timed out waiting for ${label}. Last value: ${JSON.stringify(lastValue)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function bodyText(page) {
|
||||||
|
return page.evaluate('document.body ? document.body.innerText : ""');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function currentPath(page) {
|
||||||
|
return page.evaluate('location.pathname + location.search + location.hash');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForText(page, text, timeoutMs = 10_000) {
|
||||||
|
try {
|
||||||
|
await waitUntil(`text "${text}"`, async () => {
|
||||||
|
const textContent = await bodyText(page);
|
||||||
|
return textContent.includes(text);
|
||||||
|
}, timeoutMs);
|
||||||
|
} catch (error) {
|
||||||
|
const [pathValue, textContent] = await Promise.all([
|
||||||
|
currentPath(page).catch(() => ''),
|
||||||
|
bodyText(page).catch(() => ''),
|
||||||
|
]);
|
||||||
|
throw new Error(`${error.message}\nCurrent path: ${pathValue}\nBody excerpt: ${textContent.slice(0, 1200)}\nBrowser events: ${JSON.stringify(page.diagnosticEvents(), null, 2)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function assertNoText(page, text) {
|
||||||
|
const textContent = await bodyText(page);
|
||||||
|
if (textContent.includes(text)) throw new Error(`Unexpected text found in page: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForPath(page, pathPart, timeoutMs = 10_000) {
|
||||||
|
await waitUntil(`path "${pathPart}"`, async () => {
|
||||||
|
const pathValue = await currentPath(page);
|
||||||
|
return pathValue.includes(pathPart);
|
||||||
|
}, timeoutMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clickText(page, text) {
|
||||||
|
const result = await page.evaluate(`
|
||||||
|
(() => {
|
||||||
|
const expected = ${JSON.stringify(text)};
|
||||||
|
const clickableSelector = [
|
||||||
|
'button',
|
||||||
|
'taro-button-core',
|
||||||
|
'a',
|
||||||
|
'[role="button"]',
|
||||||
|
'[onclick]',
|
||||||
|
'.entry-tile',
|
||||||
|
'.list-row',
|
||||||
|
'.metric',
|
||||||
|
'.admin-row',
|
||||||
|
'.admin-button',
|
||||||
|
'.platform-row',
|
||||||
|
'.platform-button',
|
||||||
|
'[class*="button"]'
|
||||||
|
].join(',');
|
||||||
|
const visible = el => {
|
||||||
|
const style = window.getComputedStyle(el);
|
||||||
|
const rect = el.getBoundingClientRect();
|
||||||
|
return style.visibility !== 'hidden' && style.display !== 'none' && rect.width > 0 && rect.height > 0;
|
||||||
|
};
|
||||||
|
const normalized = value => String(value || '').replace(/\\s+/g, ' ').trim();
|
||||||
|
const textOf = el => normalized(el.innerText || el.textContent || '');
|
||||||
|
const byElement = new Map();
|
||||||
|
for (const el of Array.from(document.querySelectorAll('*'))) {
|
||||||
|
if (!visible(el)) continue;
|
||||||
|
const text = textOf(el);
|
||||||
|
if (!text.includes(expected)) continue;
|
||||||
|
const target = el.matches(clickableSelector) ? el : el.closest(clickableSelector);
|
||||||
|
if (!target || !visible(target)) continue;
|
||||||
|
const targetText = textOf(target);
|
||||||
|
if (!targetText.includes(expected)) continue;
|
||||||
|
const exact = targetText === expected ? 0 : 1;
|
||||||
|
const tagScore = ['BUTTON', 'TARO-BUTTON-CORE', 'A'].includes(target.tagName) ? 0 : 1;
|
||||||
|
const lengthScore = targetText.length;
|
||||||
|
const previous = byElement.get(target);
|
||||||
|
const next = { el: target, text: targetText, score: exact * 10000 + tagScore * 1000 + lengthScore };
|
||||||
|
if (!previous || next.score < previous.score) byElement.set(target, next);
|
||||||
|
}
|
||||||
|
const candidates = Array.from(byElement.values()).sort((a, b) => a.score - b.score);
|
||||||
|
const target = candidates[0]?.el;
|
||||||
|
if (!target) return { ok: false, body: (document.body?.innerText || '').slice(0, 1200) };
|
||||||
|
target.scrollIntoView({ block: 'center', inline: 'center' });
|
||||||
|
const rect = target.getBoundingClientRect();
|
||||||
|
const x = rect.left + rect.width / 2;
|
||||||
|
const y = rect.top + rect.height / 2;
|
||||||
|
for (const type of ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click']) {
|
||||||
|
target.dispatchEvent(new MouseEvent(type, { bubbles: true, cancelable: true, view: window, clientX: x, clientY: y }));
|
||||||
|
}
|
||||||
|
target.click();
|
||||||
|
return { ok: true, tag: target.tagName, className: target.className, text: textOf(target).slice(0, 120) };
|
||||||
|
})()
|
||||||
|
`);
|
||||||
|
if (!result?.ok) throw new Error(`Clickable text not found: ${text}\n${result?.body || ''}`);
|
||||||
|
await delay(350);
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function waitForApiRequest(api, path, method) {
|
||||||
|
await waitUntil(`${method || ''} ${path} request`, async () => {
|
||||||
|
return api.requests.some(item => item.path === path && (!method || item.method === method));
|
||||||
|
}, 10_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runStudentJourney(browser, portal, api) {
|
||||||
|
const checks = [];
|
||||||
|
const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`);
|
||||||
|
try {
|
||||||
|
await waitForText(page, '今日学习');
|
||||||
|
await waitForText(page, '功能导航');
|
||||||
|
await assertNoText(page, '排行榜');
|
||||||
|
checks.push({ id: 'student.home.rendered', status: 'pass', detail: '首页渲染并未默认展示排行榜' });
|
||||||
|
|
||||||
|
await clickText(page, '刷题');
|
||||||
|
await waitForPath(page, '/pages/student/catalog/index');
|
||||||
|
await waitForText(page, '题库练习');
|
||||||
|
checks.push({ id: 'student.home.to_catalog', status: 'pass', detail: await currentPath(page) });
|
||||||
|
|
||||||
|
await clickText(page, '顺序练习');
|
||||||
|
await waitForPath(page, '/pages/student/practice/index');
|
||||||
|
await waitForText(page, '这是 H5 交互烟测题目');
|
||||||
|
checks.push({ id: 'student.catalog.to_practice', status: 'pass', detail: await currentPath(page) });
|
||||||
|
|
||||||
|
await clickText(page, 'A.');
|
||||||
|
await waitForApiRequest(api, '/api/learning/answers', 'POST');
|
||||||
|
await waitForText(page, '正确');
|
||||||
|
await clickText(page, '收藏');
|
||||||
|
await waitForApiRequest(api, '/api/learning/favorites/questions', 'POST');
|
||||||
|
checks.push({ id: 'student.practice.answer_favorite', status: 'pass', detail: '答题和收藏 API 已触发' });
|
||||||
|
|
||||||
|
await page.navigate(`${portal.baseUrl}/pages/student/home/index`);
|
||||||
|
await waitForText(page, '个人中心');
|
||||||
|
await clickText(page, '个人中心');
|
||||||
|
await waitForPath(page, '/pages/student/profile/index');
|
||||||
|
await waitForText(page, '会员套餐');
|
||||||
|
await clickText(page, '开通会员');
|
||||||
|
await waitForPath(page, '/pages/student/checkout/index');
|
||||||
|
await waitForText(page, '会员收银台');
|
||||||
|
checks.push({ id: 'student.profile.to_checkout', status: 'pass', detail: await currentPath(page) });
|
||||||
|
return checks;
|
||||||
|
} finally {
|
||||||
|
page.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runTenantJourney(browser, portal) {
|
||||||
|
const checks = [];
|
||||||
|
const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`);
|
||||||
|
try {
|
||||||
|
await waitForText(page, '工学题库商户后台');
|
||||||
|
await waitForText(page, '后台模块');
|
||||||
|
checks.push({ id: 'tenant.workbench.rendered', status: 'pass', detail: '工作台渲染并展示权限驱动模块' });
|
||||||
|
|
||||||
|
await clickText(page, '题库内容');
|
||||||
|
await waitForPath(page, '/pages/tenant-admin/content/index');
|
||||||
|
await waitForText(page, '题库内容');
|
||||||
|
checks.push({ id: 'tenant.workbench.to_content', status: 'pass', detail: await currentPath(page) });
|
||||||
|
|
||||||
|
await page.navigate(`${portal.baseUrl}${portal.landingPath}`);
|
||||||
|
await waitForText(page, '财务运营');
|
||||||
|
await clickText(page, '财务运营');
|
||||||
|
await waitForPath(page, '/pages/tenant-admin/finance/index');
|
||||||
|
await waitForText(page, '财务运营');
|
||||||
|
checks.push({ id: 'tenant.workbench.to_finance', status: 'pass', detail: await currentPath(page) });
|
||||||
|
return checks;
|
||||||
|
} finally {
|
||||||
|
page.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runPlatformJourney(browser, portal) {
|
||||||
|
const checks = [];
|
||||||
|
const page = await newPage(browser, `${portal.baseUrl}${portal.landingPath}`);
|
||||||
|
try {
|
||||||
|
await waitForText(page, 'SaaS 平台后台');
|
||||||
|
await waitForText(page, '后台模块');
|
||||||
|
checks.push({ id: 'platform.workbench.rendered', status: 'pass', detail: '平台工作台渲染' });
|
||||||
|
|
||||||
|
await clickText(page, '租户管理');
|
||||||
|
await waitForPath(page, '/pages/platform-admin/tenants/index');
|
||||||
|
await waitForText(page, '租户管理');
|
||||||
|
checks.push({ id: 'platform.workbench.to_tenants', status: 'pass', detail: await currentPath(page) });
|
||||||
|
|
||||||
|
await page.navigate(`${portal.baseUrl}${portal.landingPath}`);
|
||||||
|
await waitForText(page, '账务中心');
|
||||||
|
await clickText(page, '账务中心');
|
||||||
|
await waitForPath(page, '/pages/platform-admin/billing/index');
|
||||||
|
await waitForText(page, '账务中心');
|
||||||
|
checks.push({ id: 'platform.workbench.to_billing', status: 'pass', detail: await currentPath(page) });
|
||||||
|
return checks;
|
||||||
|
} finally {
|
||||||
|
page.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markdownReport(payload) {
|
||||||
|
const lines = [
|
||||||
|
'# Taro H5 交互烟测报告',
|
||||||
|
'',
|
||||||
|
`生成时间:${payload.generatedAt}`,
|
||||||
|
'',
|
||||||
|
`浏览器:${payload.browser.executable}`,
|
||||||
|
'',
|
||||||
|
`结果:${payload.summary.fail} fail(s),${payload.summary.pass} pass(es)`,
|
||||||
|
'',
|
||||||
|
'| 检查项 | 状态 | 说明 |',
|
||||||
|
'| --- | --- | --- |',
|
||||||
|
];
|
||||||
|
for (const check of payload.checks) {
|
||||||
|
lines.push(`| ${check.id} | ${check.status} | ${String(check.detail || '').replace(/\|/g, '\\|')} |`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
lines.push('说明:该烟测使用本地 mock API 和三套 H5 发布产物,覆盖真实浏览器中的学生、租户后台、平台后台关键入口点击。它不替代真实 Supabase Auth、支付、短信、对象存储和生产 provider 联调。');
|
||||||
|
return `${lines.join('\n')}\n`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeReport(payload) {
|
||||||
|
fs.mkdirSync(outputDir, { recursive: true });
|
||||||
|
const stamp = shanghaiTimestampForFile();
|
||||||
|
const jsonPath = path.join(outputDir, `taro-h5-interaction-smoke-${stamp}.json`);
|
||||||
|
const mdPath = path.join(outputDir, `taro-h5-interaction-smoke-${stamp}.md`);
|
||||||
|
fs.writeFileSync(jsonPath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
||||||
|
fs.writeFileSync(mdPath, markdownReport(payload), 'utf8');
|
||||||
|
return { jsonPath, mdPath };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const options = parseArgs(process.argv.slice(2));
|
||||||
|
const api = await createMockApiServer();
|
||||||
|
const staticServers = [];
|
||||||
|
let browser = null;
|
||||||
|
try {
|
||||||
|
for (const portal of portals) staticServers.push(await createStaticServer(portal, api.baseUrl));
|
||||||
|
browser = await startBrowser(options);
|
||||||
|
const checks = [];
|
||||||
|
checks.push(...await runStudentJourney(browser, staticServers.find(item => item.portal === 'student'), api));
|
||||||
|
checks.push(...await runTenantJourney(browser, staticServers.find(item => item.portal === 'tenant-admin')));
|
||||||
|
checks.push(...await runPlatformJourney(browser, staticServers.find(item => item.portal === 'platform-admin')));
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
generatedAt: new Date().toISOString(),
|
||||||
|
summary: {
|
||||||
|
pass: checks.filter(item => item.status === 'pass').length,
|
||||||
|
fail: checks.filter(item => item.status !== 'pass').length,
|
||||||
|
},
|
||||||
|
browser: {
|
||||||
|
executable: browser.executable,
|
||||||
|
},
|
||||||
|
staticServers: staticServers.map(item => ({ portal: item.portal, baseUrl: item.baseUrl, landingPath: item.landingPath })),
|
||||||
|
mockApi: {
|
||||||
|
baseUrl: api.baseUrl,
|
||||||
|
requestCount: api.requests.length,
|
||||||
|
keyRequests: {
|
||||||
|
answers: api.requests.filter(item => item.path === '/api/learning/answers').length,
|
||||||
|
favorites: api.requests.filter(item => item.path === '/api/learning/favorites/questions').length,
|
||||||
|
tenantResolve: api.requests.filter(item => item.path === '/api/tenant/resolve').length,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
checks,
|
||||||
|
};
|
||||||
|
const files = writeReport(payload);
|
||||||
|
payload.artifacts = {
|
||||||
|
json: relative(files.jsonPath),
|
||||||
|
markdown: relative(files.mdPath),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (options.json) {
|
||||||
|
console.log(JSON.stringify(payload, null, 2));
|
||||||
|
} else {
|
||||||
|
console.log(`Taro H5 interaction smoke: ${payload.summary.fail} fail(s), ${payload.summary.pass} pass(es)`);
|
||||||
|
for (const check of checks) console.log(`[${check.status.toUpperCase()}] ${check.id}: ${check.detail}`);
|
||||||
|
console.log(`[smoke] wrote ${relative(files.jsonPath)}`);
|
||||||
|
console.log(`[smoke] wrote ${relative(files.mdPath)}`);
|
||||||
|
}
|
||||||
|
if (payload.summary.fail > 0) process.exitCode = 1;
|
||||||
|
} catch (error) {
|
||||||
|
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
||||||
|
process.exitCode = 1;
|
||||||
|
} finally {
|
||||||
|
if (browser) await browser.close();
|
||||||
|
for (const server of staticServers) await server.close();
|
||||||
|
await api.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
Reference in New Issue
Block a user