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

@@ -214,6 +214,13 @@ import { ssaRoutes } from './modules/ssa/index.js';
await fastify.register(ssaRoutes, { prefix: '/api/v1/ssa' });
logger.info('✅ SSA智能统计分析路由已注册: /api/v1/ssa');
// ============================================
// 【集成模块】Legacy Bridge - 旧系统集成桥接
// ============================================
import { legacyBridgeRoutes } from './modules/legacy-bridge/routes.js';
await fastify.register(legacyBridgeRoutes, { prefix: '/api/v1/legacy' });
logger.info('✅ Legacy Bridge路由已注册: /api/v1/legacy');
// 启动服务器
const start = async () => {
try {
@@ -340,6 +347,11 @@ const gracefulShutdown = async (signal: string) => {
await prisma.$disconnect();
console.log('✅ 数据库连接已关闭');
// 4. 关闭 Legacy MySQL 连接池
const { closeLegacyMysqlPool } = await import('./modules/legacy-bridge/mysql-pool.js');
await closeLegacyMysqlPool();
console.log('✅ Legacy MySQL连接池已关闭');
console.log('👋 优雅关闭完成,再见!');
process.exit(0);
} catch (error) {

View File

@@ -0,0 +1,118 @@
/**
* Legacy System Auth Service
*
* Handles token injection into the old Java system's MySQL database.
* Generates MD5 tokens matching the old system's formula and inserts
* them into u_user_token, bypassing the login API entirely.
*/
import crypto from 'crypto';
import { getLegacyMysqlPool } from './mysql-pool.js';
import { logger } from '../../common/logging/index.js';
interface LegacyUser {
id: number;
phone: string;
nickname: string;
real_name: string;
user_role: string;
}
interface LegacyAuthResult {
token: string;
nickname: string;
id: number;
userRole: string;
}
const DEFAULT_PASSWORD_MD5 = 'E10ADC3949BA59ABBE56E057F20F883E'; // MD5 of "123456"
/**
* Token formula from old system: MD5("KyKz1.0:" + userId + ":" + timestamp)
*/
function generateLegacyToken(userId: number): string {
const timestamp = Date.now();
const raw = `KyKz1.0:${userId}:${timestamp}`;
return crypto.createHash('md5').update(raw).digest('hex');
}
/**
* Find user in old system by phone number.
*/
async function findUserByPhone(phone: string): Promise<LegacyUser | null> {
const pool = getLegacyMysqlPool();
const [rows] = await pool.execute<any[]>(
'SELECT id, phone, nickname, real_name, user_role FROM u_user_info WHERE phone = ?',
[phone],
);
return rows.length > 0 ? rows[0] : null;
}
/**
* Create user in old system with default password.
*/
async function createUser(phone: string, realName: string): Promise<LegacyUser> {
const pool = getLegacyMysqlPool();
const [result] = await pool.execute<any>(
`INSERT INTO u_user_info
(phone, nickname, real_name, hospital, company, academy, user_role, user_status, password, hosptial_id, email_validate_status, register_time)
VALUES (?, ?, ?, '本地', '', '', 'NORMAL', 'NORMAL', ?, 0, 0, UNIX_TIMESTAMP())`,
[phone, realName || phone, realName || phone, DEFAULT_PASSWORD_MD5],
);
const insertId = result.insertId;
logger.info('Created legacy user', { phone, insertId });
return {
id: insertId,
phone,
nickname: realName || phone,
real_name: realName || phone,
user_role: 'NORMAL',
};
}
/**
* Inject a fresh token into old system's u_user_token table.
* gen_time must be Java System.currentTimeMillis() (milliseconds since epoch),
* NOT MySQL NOW(). The old system's isTimeout() does millisecond arithmetic.
* Also delete existing tokens for this user first (matches Java generateToken behavior).
*/
async function injectToken(userId: number, token: string, userRole: string): Promise<void> {
const pool = getLegacyMysqlPool();
await pool.execute('DELETE FROM u_user_token WHERE user_id = ?', [userId]);
await pool.execute(
`INSERT INTO u_user_token (user_id, token, gen_time, user_role) VALUES (?, ?, ?, ?)`,
[userId, token, Date.now(), userRole],
);
}
/**
* Main entry: authenticate a user in the old system.
* - Finds or creates the user by phone
* - Generates and injects a token
* - Returns credentials for cookie-setting on the frontend
*/
export async function authenticateLegacyUser(
phone: string,
displayName?: string,
): Promise<LegacyAuthResult> {
let user = await findUserByPhone(phone);
if (!user) {
logger.info('Legacy user not found, creating', { phone });
user = await createUser(phone, displayName || '');
}
const token = generateLegacyToken(user.id);
await injectToken(user.id, token, user.user_role);
logger.info('Legacy token injected', { phone, userId: user.id });
return {
token,
nickname: user.nickname || user.real_name || phone,
id: user.id,
userRole: user.user_role,
};
}

View File

@@ -0,0 +1,39 @@
/**
* Legacy System MySQL Connection Pool
*
* Connects to the old Java system's MariaDB/MySQL database (xzyx_online)
* on ECS 8.154.22.149 for user sync and token injection.
*/
import mysql from 'mysql2/promise';
import { logger } from '../../common/logging/index.js';
const LEGACY_MYSQL_CONFIG = {
host: process.env.LEGACY_MYSQL_HOST || '8.154.22.149',
port: parseInt(process.env.LEGACY_MYSQL_PORT || '3306'),
user: process.env.LEGACY_MYSQL_USER || 'xzyx_rw',
password: process.env.LEGACY_MYSQL_PASSWORD || 'SKJfdwalkd',
database: process.env.LEGACY_MYSQL_DATABASE || 'xzyx_online',
waitForConnections: true,
connectionLimit: 5,
queueLimit: 0,
connectTimeout: 10000,
};
let pool: mysql.Pool | null = null;
export function getLegacyMysqlPool(): mysql.Pool {
if (!pool) {
pool = mysql.createPool(LEGACY_MYSQL_CONFIG);
logger.info('Legacy MySQL pool created', { host: LEGACY_MYSQL_CONFIG.host });
}
return pool;
}
export async function closeLegacyMysqlPool(): Promise<void> {
if (pool) {
await pool.end();
pool = null;
logger.info('Legacy MySQL pool closed');
}
}

View File

@@ -0,0 +1,61 @@
/**
* Legacy Bridge Routes
*
* POST /api/v1/legacy/auth - Authenticate current user in the old Java system
*/
import { FastifyInstance, FastifyRequest, FastifyReply } from 'fastify';
import { authenticate } from '../../common/auth/auth.middleware.js';
import { authenticateLegacyUser } from './legacy-auth.service.js';
import { logger } from '../../common/logging/index.js';
import { prisma } from '../../config/database.js';
export async function legacyBridgeRoutes(fastify: FastifyInstance): Promise<void> {
/**
* POST /auth
*
* Uses the current user's JWT to generate a valid token for the old system.
* Frontend should set the returned values as cookies on .xunzhengyixue.com
*/
fastify.post('/auth', {
preHandler: [authenticate],
}, async (request: FastifyRequest, reply: FastifyReply) => {
try {
const { userId, phone } = request.user!;
if (!phone) {
return reply.status(400).send({
error: 'Bad Request',
message: '当前用户无手机号,无法同步到旧系统',
});
}
// Look up display name from new system's user table
let displayName = phone;
try {
const newUser = await prisma.user.findUnique({
where: { id: userId },
select: { name: true },
});
if (newUser?.name) {
displayName = newUser.name;
}
} catch {
// Non-critical, fall back to phone
}
const result = await authenticateLegacyUser(phone, displayName);
return reply.send({
success: true,
data: result,
});
} catch (error) {
logger.error('Legacy auth failed', { error });
return reply.status(500).send({
error: 'Internal Server Error',
message: '旧系统认证失败,请稍后重试',
});
}
});
}

View File

@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>加载中...</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body { width: 100%; height: 100%; overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'PingFang SC', sans-serif; }
#frame { width: 100%; height: 100%; border: none; display: none; }
#status { display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 100%; gap: 16px; background: #f5f7fa; }
#status .spinner { width: 32px; height: 32px; border: 3px solid #e0e0e0;
border-top-color: #4f6ef7; border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
#status p { color: #888; font-size: 14px; }
#status button { padding: 10px 28px; font-size: 15px; border-radius: 8px; border: none;
background: #4f6ef7; color: #fff; cursor: pointer; transition: background 0.2s; }
#status button:hover { background: #3a5ce5; }
</style>
</head>
<body>
<div id="status">
<div class="spinner"></div>
<p id="msg">正在连接...</p>
<button id="btn" style="display:none;">点击授权并继续</button>
</div>
<iframe id="frame"></iframe>
<script>
(function() {
var P = new URLSearchParams(location.search);
var token = P.get('token');
var nickname = P.get('nickname');
var id = P.get('id');
var role = P.get('userRole');
var redirect = P.get('redirect') || '/index.html';
var cookieOpt = '; domain=.xunzhengyixue.com; path=/; SameSite=None; Secure';
function setCookies() {
if (!token) return;
document.cookie = 'token=' + encodeURIComponent(token) + cookieOpt;
document.cookie = 'nickname=' + encodeURIComponent(nickname) + cookieOpt;
document.cookie = 'id=' + encodeURIComponent(id) + cookieOpt;
document.cookie = 'userRole=' + encodeURIComponent(role) + cookieOpt;
}
// ---- Custom CSS injected into every page loaded in the inner iframe ----
var CUSTOM_CSS = [
'#header-navbar { display: none !important; }',
'#footer-bar { display: none !important; }',
'#menu { display: none !important; }',
'#footer { display: none !important; }',
'body { padding-top: 0 !important; margin-top: 0 !important; }',
'#page-wrapper { padding-top: 0 !important; margin-top: 0 !important; }',
'.navbar-fixed-top + * { margin-top: 0 !important; padding-top: 0 !important; }',
'section#portfolio { padding-top: 10px !important; }',
].join('\n');
function showFrame() {
var frame = document.getElementById('frame');
document.getElementById('status').style.display = 'none';
frame.style.display = 'block';
frame.onload = function() {
try {
var doc = frame.contentDocument;
if (!doc || doc.getElementById('_bridge_css')) return;
var s = doc.createElement('style');
s.id = '_bridge_css';
s.textContent = CUSTOM_CSS;
(doc.head || doc.documentElement).appendChild(s);
} catch(e) {}
};
frame.src = redirect;
}
function grantAndProceed() {
if (document.requestStorageAccess) {
document.requestStorageAccess()
.then(function() { setCookies(); showFrame(); })
.catch(function() { setCookies(); showFrame(); });
} else {
setCookies();
showFrame();
}
}
function showGrantButton() {
document.querySelector('#status .spinner').style.display = 'none';
document.getElementById('msg').textContent = '需要授权以访问旧系统';
var btn = document.getElementById('btn');
btn.style.display = 'inline-block';
btn.onclick = function() {
btn.style.display = 'none';
document.querySelector('#status .spinner').style.display = 'block';
document.getElementById('msg').textContent = '正在连接...';
grantAndProceed();
};
}
// ---- Entry point: check storage access, then set cookies + load frame ----
if (document.hasStorageAccess) {
document.hasStorageAccess().then(function(has) {
if (has) {
setCookies();
showFrame();
} else {
showGrantButton();
}
});
} else {
setCookies();
showFrame();
}
})();
</script>
</body>
</html>