feat(platform): Implement legacy system integration with Wrapper Bridge architecture

Complete integration of the old clinical research platform (www.xunzhengyixue.com)
into the new AI platform via Token injection + iframe embedding:

Backend:
- Add legacy-bridge module (MySQL pool, auth service, routes)
- POST /api/v1/legacy/auth: JWT -> phone lookup -> Token injection into old MySQL
- Auto-create user in old system if not found (matched by phone number)

Frontend:
- LegacySystemPage: iframe container with Bridge URL construction
- ResearchManagement + StatisticalTools entry components
- Module registry updated from external links to iframe embed mode

ECS (token-bridge.html deployed to www.xunzhengyixue.com):
- Wrapper Bridge: sets cookies within same-origin context
- Storage Access API for cross-site dev environments
- CSS injection: hide old system nav/footer, remove padding gaps
- Inner iframe loads target page with full DOM access (same-origin)

Key technical decisions:
- Token injection (direct MySQL write) instead of calling login API
- Wrapper Bridge instead of parent-page cookie setting (cross-origin fix)
- Storage Access API + SameSite=None;Secure for third-party cookie handling
- User isolation guaranteed by phone number matching

Documentation:
- Integration plan v4.0 with full implementation record
- Implementation summary with 6 pitfalls documented
- System status guide updated (ST module now integrated)

Tested: Local E2E verified - auto login, research management, 126 statistical
tools, report generation, download, UI layout all working correctly

Made-with: Cursor
This commit is contained in:
2026-02-27 21:54:38 +08:00
parent 6124c7abc6
commit c3f7d54fdf
21 changed files with 1407 additions and 63 deletions

View File

@@ -83,17 +83,21 @@ function App() {
<Route index element={<HomePage />} />
{/* 动态加载模块路由 - 基于模块权限系统 ⭐ 2026-01-16 */}
{MODULES.map(module => (
{MODULES.filter(m => !m.isExternal).map(module => (
<Route
key={module.id}
path={`${module.path}/*`}
element={
<RouteGuard
requiredModule={module.moduleCode}
moduleName={module.name}
>
module.isLegacyEmbed ? (
<module.component />
</RouteGuard>
) : (
<RouteGuard
requiredModule={module.moduleCode}
moduleName={module.name}
>
<module.component />
</RouteGuard>
)
}
/>
))}

View File

@@ -1,18 +1,41 @@
/**
* 带认证的 Axios 实例
*
* 自动添加 Authorization header
*
* - 自动添加 Authorization header
* - 401 时自动刷新 Token 并重试原请求
* - 并发请求在刷新期间排队,刷新完成后统一重试
*/
import axios from 'axios';
import { getAccessToken } from '../../framework/auth/api';
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
import {
getAccessToken,
getRefreshToken,
refreshAccessToken,
clearTokens,
} from '../../framework/auth/api';
// 创建 axios 实例
const apiClient = axios.create({
timeout: 60000, // 60秒超时
timeout: 60000,
});
// 请求拦截器 - 自动添加 Authorization header
// ---------- Token 刷新状态机 ----------
let isRefreshing = false;
let pendingQueue: {
resolve: (token: string) => void;
reject: (err: unknown) => void;
}[] = [];
function processPendingQueue(error: unknown, token: string | null) {
pendingQueue.forEach(({ resolve, reject }) => {
if (token) resolve(token);
else reject(error);
});
pendingQueue = [];
}
// ---------- 请求拦截器 ----------
apiClient.interceptors.request.use(
(config) => {
const token = getAccessToken();
@@ -21,45 +44,63 @@ apiClient.interceptors.request.use(
}
return config;
},
(error) => {
return Promise.reject(error);
}
(error) => Promise.reject(error),
);
// 响应拦截器 - 处理 401 错误
// ---------- 响应拦截器 ----------
apiClient.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token 过期或无效,可以在这里触发登出
console.warn('[API] 认证失败,请重新登录');
// 可选:跳转到登录页
// window.location.href = '/login';
async (error: AxiosError) => {
const originalRequest = error.config as InternalAxiosRequestConfig & {
_retry?: boolean;
};
if (!originalRequest) return Promise.reject(error);
const is401 = error.response?.status === 401;
const hasRefreshToken = !!getRefreshToken();
const alreadyRetried = originalRequest._retry;
if (!is401 || !hasRefreshToken || alreadyRetried) {
if (is401 && !hasRefreshToken) {
clearTokens();
window.location.href = '/login';
}
return Promise.reject(error);
}
return Promise.reject(error);
}
// First 401 caller triggers the refresh; others queue up
if (isRefreshing) {
return new Promise<string>((resolve, reject) => {
pendingQueue.push({ resolve, reject });
}).then((newToken) => {
originalRequest.headers.Authorization = `Bearer ${newToken}`;
originalRequest._retry = true;
return apiClient(originalRequest);
});
}
isRefreshing = true;
originalRequest._retry = true;
try {
const tokens = await refreshAccessToken();
const newToken = tokens.accessToken;
originalRequest.headers.Authorization = `Bearer ${newToken}`;
processPendingQueue(null, newToken);
return apiClient(originalRequest);
} catch (refreshError) {
processPendingQueue(refreshError, null);
clearTokens();
window.location.href = '/login';
return Promise.reject(refreshError);
} finally {
isRefreshing = false;
}
},
);
export default apiClient;

View File

@@ -74,6 +74,29 @@ export function isTokenExpired(): boolean {
return Date.now() > Number(expiresAt) - 60000; // 提前1分钟判断为过期
}
/**
* 安全地解析 JSON 响应;当服务端返回 HTML如 Nginx 错误页)时给出明确提示而非 crash
*/
async function safeJsonParse<T>(response: Response): Promise<T> {
const contentType = response.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
const text = await response.text();
if (text.trimStart().startsWith('<')) {
throw new Error(
response.status >= 500
? '服务器暂时不可用,请稍后重试'
: `请求失败 (${response.status})`,
);
}
try {
return JSON.parse(text);
} catch {
throw new Error(`请求失败 (${response.status})`);
}
}
return response.json();
}
/**
* 创建带认证的fetch
*/
@@ -97,7 +120,7 @@ async function authFetch<T>(
headers,
});
const data = await response.json();
const data = await safeJsonParse<ApiResponse<T>>(response);
if (!response.ok) {
throw new Error(data.message || '请求失败');
@@ -237,7 +260,7 @@ export async function refreshAccessToken(): Promise<TokenInfo> {
body: JSON.stringify({ refreshToken }),
});
const data = await response.json();
const data = await safeJsonParse<ApiResponse<TokenInfo>>(response);
if (!response.ok || !data.success) {
clearTokens();

View File

@@ -6,6 +6,7 @@ import {
// SettingOutlined, // MVP阶段暂时隐藏设置按钮
ControlOutlined,
BankOutlined,
ExportOutlined,
} from '@ant-design/icons'
import type { MenuProps } from 'antd'
import { MODULES } from '../modules/moduleRegistry'
@@ -28,9 +29,8 @@ const TopNavigation = () => {
// 根据用户模块权限过滤可显示的模块
const availableModules = MODULES.filter(module => {
// 没有 moduleCode 的模块跳过(占位模块)
if (!module.moduleCode) return false;
// 检查用户是否有权限访问
if (module.isExternal || module.isLegacyEmbed) return true;
return hasModule(module.moduleCode);
});
@@ -117,16 +117,23 @@ const TopNavigation = () => {
return (
<div
key={module.id}
onClick={() => navigate(module.path)}
onClick={() => {
if (module.isExternal && module.externalUrl) {
window.open(module.externalUrl, '_blank', 'noopener');
} else {
navigate(module.path);
}
}}
className={`
px-4 py-2 rounded-md transition-all cursor-pointer
px-4 py-2 rounded-md transition-all cursor-pointer flex items-center gap-1
${isActive
? 'bg-blue-50 text-blue-600 font-semibold'
: 'text-gray-600 hover:bg-gray-50 hover:text-blue-600'
}
`}
>
{module.name}
<span>{module.name}</span>
{module.isExternal && <ExportOutlined style={{ fontSize: 10 }} />}
</div>
)
})}

View File

@@ -9,6 +9,7 @@ import {
LineChartOutlined,
AuditOutlined,
MedicineBoxOutlined,
ProjectOutlined,
} from '@ant-design/icons'
/**
@@ -20,6 +21,7 @@ export const MODULE_CODE_MAP: Record<string, string> = {
'knowledge-base': 'PKB',
'data-cleaning': 'DC',
'statistical-analysis': 'SSA',
'research-management': 'RM',
'statistical-tools': 'ST',
'review-system': 'RVW',
'iit-cra': 'IIT',
@@ -87,17 +89,31 @@ export const MODULES: ModuleDefinition[] = [
description: '智能统计分析系统AI+R统计引擎',
moduleCode: 'SSA', // 后端模块代码
},
{
id: 'research-management',
name: '研究管理',
path: '/research-management',
icon: ProjectOutlined,
component: lazy(() => import('@/modules/legacy/ResearchManagement')),
placeholder: false,
requiredVersion: 'premium',
description: '研究项目管理系统(旧系统 iframe 集成)',
isLegacyEmbed: true,
externalUrl: 'https://www.xunzhengyixue.com/index.html',
moduleCode: 'RM',
},
{
id: 'statistical-tools',
name: '统计分析工具',
path: '/statistical-tools',
icon: LineChartOutlined,
component: lazy(() => import('@/modules/st')),
placeholder: true, // Java团队开发前端集成
component: lazy(() => import('@/modules/legacy/StatisticalTools')),
placeholder: false,
requiredVersion: 'premium',
description: '统计分析工具集(Java团队开发',
isExternal: true, // 外部模块
moduleCode: 'ST', // 后端模块代码
description: '统计分析工具集(旧系统 iframe 集成',
isLegacyEmbed: true,
externalUrl: 'https://www.xunzhengyixue.com/tool.html',
moduleCode: 'ST',
},
{
id: 'review-system',

View File

@@ -55,8 +55,14 @@ export interface ModuleDefinition {
/** 是否支持独立部署 */
standalone?: boolean
/** 是否为外部模块(如Java团队开发 */
/** 是否为外部模块(新标签页打开 */
isExternal?: boolean
/** 是否为旧系统 iframe 内嵌模块 */
isLegacyEmbed?: boolean
/** 外部/旧系统链接地址 */
externalUrl?: string
/** 模块描述 */
description?: string

View File

@@ -0,0 +1,104 @@
import React, { useState, useEffect, useRef, useCallback } from 'react'
import { Spin, message } from 'antd'
import { LoadingOutlined } from '@ant-design/icons'
import apiClient from '@/common/api/axios'
const BRIDGE_URL = 'https://www.xunzhengyixue.com/token-bridge.html'
interface LegacyAuthData {
token: string
nickname: string
id: number
userRole: string
}
interface LegacySystemPageProps {
targetUrl: string
}
/**
* Embeds the old system in an iframe via a same-origin bridge page.
*
* Flow: call backend to inject token into old MySQL → build bridge URL with
* auth params → iframe loads bridge → bridge sets cookies + loads target page
* in a nested iframe → bridge injects custom CSS (same-origin DOM access).
*/
const LegacySystemPage: React.FC<LegacySystemPageProps> = ({ targetUrl }) => {
const [status, setStatus] = useState<'authenticating' | 'ready' | 'error'>('authenticating')
const [bridgeUrl, setBridgeUrl] = useState('')
const [errorMsg, setErrorMsg] = useState('')
const iframeRef = useRef<HTMLIFrameElement>(null)
const authDoneRef = useRef(false)
const authenticate = useCallback(async () => {
if (authDoneRef.current) return
try {
setStatus('authenticating')
const resp = await apiClient.post<{ success: boolean; data: LegacyAuthData }>('/api/v1/legacy/auth')
const { token, nickname, id, userRole } = resp.data.data
const redirectPath = new URL(targetUrl).pathname
const params = new URLSearchParams({
token,
nickname,
id: String(id),
userRole,
redirect: redirectPath,
})
setBridgeUrl(`${BRIDGE_URL}?${params.toString()}`)
authDoneRef.current = true
setStatus('ready')
} catch (err: any) {
const msg = err?.response?.data?.message || err?.message || '旧系统认证失败'
setErrorMsg(msg)
setStatus('error')
message.error(msg)
}
}, [targetUrl])
useEffect(() => {
authenticate()
}, [authenticate])
if (status === 'authenticating') {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 16 }}>
<Spin indicator={<LoadingOutlined style={{ fontSize: 36 }} spin />} />
<span style={{ color: '#666' }}>...</span>
</div>
)
}
if (status === 'error') {
return (
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', height: '100%', gap: 16 }}>
<span style={{ fontSize: 48 }}></span>
<span style={{ color: '#999' }}>{errorMsg}</span>
<button
onClick={() => { authDoneRef.current = false; authenticate() }}
style={{ padding: '8px 24px', cursor: 'pointer', borderRadius: 6, border: '1px solid #d9d9d9', background: '#fff' }}
>
</button>
</div>
)
}
return (
<iframe
ref={iframeRef}
src={bridgeUrl}
style={{
width: '100%',
height: '100%',
border: 'none',
display: 'block',
}}
title="旧系统"
/>
)
}
export default LegacySystemPage

View File

@@ -0,0 +1,13 @@
import LegacySystemPage from './LegacySystemPage'
const RESEARCH_MANAGEMENT_URL = 'https://www.xunzhengyixue.com/index.html'
const ResearchManagement: React.FC = () => {
return (
<div style={{ flex: 1, height: '100%', overflow: 'hidden' }}>
<LegacySystemPage targetUrl={RESEARCH_MANAGEMENT_URL} />
</div>
)
}
export default ResearchManagement

View File

@@ -0,0 +1,13 @@
import LegacySystemPage from './LegacySystemPage'
const STATISTICAL_TOOLS_URL = 'https://www.xunzhengyixue.com/tool.html'
const StatisticalTools: React.FC = () => {
return (
<div style={{ flex: 1, height: '100%', overflow: 'hidden' }}>
<LegacySystemPage targetUrl={STATISTICAL_TOOLS_URL} />
</div>
)
}
export default StatisticalTools