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:
@@ -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) {
|
||||
|
||||
118
backend/src/modules/legacy-bridge/legacy-auth.service.ts
Normal file
118
backend/src/modules/legacy-bridge/legacy-auth.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
39
backend/src/modules/legacy-bridge/mysql-pool.ts
Normal file
39
backend/src/modules/legacy-bridge/mysql-pool.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
61
backend/src/modules/legacy-bridge/routes.ts
Normal file
61
backend/src/modules/legacy-bridge/routes.ts
Normal 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: '旧系统认证失败,请稍后重试',
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
120
backend/src/modules/legacy-bridge/token-bridge.html
Normal file
120
backend/src/modules/legacy-bridge/token-bridge.html
Normal 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>
|
||||
Reference in New Issue
Block a user