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:
111
backend/package-lock.json
generated
111
backend/package-lock.json
generated
@@ -33,6 +33,7 @@
|
|||||||
"jsonrepair": "^3.13.1",
|
"jsonrepair": "^3.13.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jspdf": "^3.0.3",
|
"jspdf": "^3.0.3",
|
||||||
|
"mysql2": "^3.18.2",
|
||||||
"openai": "^6.16.0",
|
"openai": "^6.16.0",
|
||||||
"p-queue": "^9.0.1",
|
"p-queue": "^9.0.1",
|
||||||
"pg-boss": "^12.5.2",
|
"pg-boss": "^12.5.2",
|
||||||
@@ -1458,6 +1459,15 @@
|
|||||||
"fastq": "^1.17.1"
|
"fastq": "^1.17.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/aws-ssl-profiles": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/aws-ssl-profiles/-/aws-ssl-profiles-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 6.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/axios": {
|
"node_modules/axios": {
|
||||||
"version": "1.12.2",
|
"version": "1.12.2",
|
||||||
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.12.2.tgz",
|
"resolved": "https://registry.npmmirror.com/axios/-/axios-1.12.2.tgz",
|
||||||
@@ -2902,6 +2912,15 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/generate-function": {
|
||||||
|
"version": "2.3.1",
|
||||||
|
"resolved": "https://registry.npmmirror.com/generate-function/-/generate-function-2.3.1.tgz",
|
||||||
|
"integrity": "sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-property": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/get-intrinsic": {
|
"node_modules/get-intrinsic": {
|
||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
@@ -3302,6 +3321,12 @@
|
|||||||
"node": ">=0.12.0"
|
"node": ">=0.12.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-property": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/is-property/-/is-property-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-stream": {
|
"node_modules/is-stream": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmmirror.com/is-stream/-/is-stream-2.0.1.tgz",
|
"resolved": "https://registry.npmmirror.com/is-stream/-/is-stream-2.0.1.tgz",
|
||||||
@@ -3766,6 +3791,27 @@
|
|||||||
"node": ">= 12.0.0"
|
"node": ">= 12.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/long": {
|
||||||
|
"version": "5.3.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/long/-/long-5.3.2.tgz",
|
||||||
|
"integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/lru.min": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmmirror.com/lru.min/-/lru.min-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"bun": ">=1.0.0",
|
||||||
|
"deno": ">=1.30.0",
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wellwelwel"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/luxon": {
|
"node_modules/luxon": {
|
||||||
"version": "3.7.2",
|
"version": "3.7.2",
|
||||||
"resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.7.2.tgz",
|
"resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.7.2.tgz",
|
||||||
@@ -3938,6 +3984,44 @@
|
|||||||
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
"@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mysql2": {
|
||||||
|
"version": "3.18.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/mysql2/-/mysql2-3.18.2.tgz",
|
||||||
|
"integrity": "sha512-UfEShBFAZZEAKjySnTUuE7BgqkYT4mx+RjoJ5aqtmwSSvNcJ/QxQPXz/y3jSxNiVRedPfgccmuBtiPCSiEEytw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"aws-ssl-profiles": "^1.1.2",
|
||||||
|
"denque": "^2.1.0",
|
||||||
|
"generate-function": "^2.3.1",
|
||||||
|
"iconv-lite": "^0.7.2",
|
||||||
|
"long": "^5.3.2",
|
||||||
|
"lru.min": "^1.1.4",
|
||||||
|
"named-placeholders": "^1.1.6",
|
||||||
|
"sql-escaper": "^1.3.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 8.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/node": ">= 8"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/mysql2/node_modules/iconv-lite": {
|
||||||
|
"version": "0.7.2",
|
||||||
|
"resolved": "https://registry.npmmirror.com/iconv-lite/-/iconv-lite-0.7.2.tgz",
|
||||||
|
"integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/express"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mz": {
|
"node_modules/mz": {
|
||||||
"version": "2.7.0",
|
"version": "2.7.0",
|
||||||
"resolved": "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz",
|
"resolved": "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz",
|
||||||
@@ -3949,6 +4033,18 @@
|
|||||||
"thenify-all": "^1.0.0"
|
"thenify-all": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/named-placeholders": {
|
||||||
|
"version": "1.1.6",
|
||||||
|
"resolved": "https://registry.npmmirror.com/named-placeholders/-/named-placeholders-1.1.6.tgz",
|
||||||
|
"integrity": "sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lru.min": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/napi-build-utils": {
|
"node_modules/napi-build-utils": {
|
||||||
"version": "2.0.0",
|
"version": "2.0.0",
|
||||||
"resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
"resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
|
||||||
@@ -5180,6 +5276,21 @@
|
|||||||
"node": ">= 10.x"
|
"node": ">= 10.x"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/sql-escaper": {
|
||||||
|
"version": "1.3.3",
|
||||||
|
"resolved": "https://registry.npmmirror.com/sql-escaper/-/sql-escaper-1.3.3.tgz",
|
||||||
|
"integrity": "sha512-BsTCV265VpTp8tm1wyIm1xqQCS+Q9NHx2Sr+WcnUrgLrQ6yiDIvHYJV5gHxsj1lMBy2zm5twLaZao8Jd+S8JJw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"bun": ">=1.0.0",
|
||||||
|
"deno": ">=2.0.0",
|
||||||
|
"node": ">=12.0.0"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/mysqljs/sql-escaper?sponsor=1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ssf": {
|
"node_modules/ssf": {
|
||||||
"version": "0.11.2",
|
"version": "0.11.2",
|
||||||
"resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
|
"resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz",
|
||||||
|
|||||||
@@ -50,6 +50,7 @@
|
|||||||
"jsonrepair": "^3.13.1",
|
"jsonrepair": "^3.13.1",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"jspdf": "^3.0.3",
|
"jspdf": "^3.0.3",
|
||||||
|
"mysql2": "^3.18.2",
|
||||||
"openai": "^6.16.0",
|
"openai": "^6.16.0",
|
||||||
"p-queue": "^9.0.1",
|
"p-queue": "^9.0.1",
|
||||||
"pg-boss": "^12.5.2",
|
"pg-boss": "^12.5.2",
|
||||||
|
|||||||
120
backend/scripts/test-legacy-auth.ts
Normal file
120
backend/scripts/test-legacy-auth.ts
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
/**
|
||||||
|
* Legacy Auth Token Injection Test
|
||||||
|
*
|
||||||
|
* Tests the full flow:
|
||||||
|
* 1. Connect to old system MySQL (xzyx_online)
|
||||||
|
* 2. Find user by phone
|
||||||
|
* 3. Generate MD5 token (same formula as old Java system)
|
||||||
|
* 4. Insert token into u_user_token
|
||||||
|
* 5. Print cookie-setting instructions for manual browser verification
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* npx tsx scripts/test-legacy-auth.ts <phone>
|
||||||
|
* npx tsx scripts/test-legacy-auth.ts 18611348738
|
||||||
|
*/
|
||||||
|
|
||||||
|
import mysql from 'mysql2/promise';
|
||||||
|
import crypto from 'crypto';
|
||||||
|
|
||||||
|
const 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',
|
||||||
|
};
|
||||||
|
|
||||||
|
function generateToken(userId: number): string {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
const raw = `KyKz1.0:${userId}:${timestamp}`;
|
||||||
|
return crypto.createHash('md5').update(raw).digest('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const phone = process.argv[2];
|
||||||
|
if (!phone) {
|
||||||
|
console.error('Usage: npx tsx scripts/test-legacy-auth.ts <phone>');
|
||||||
|
console.error('Example: npx tsx scripts/test-legacy-auth.ts 18611348738');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log('Legacy Auth Token Injection Test');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
|
||||||
|
// Step 1: Connect
|
||||||
|
console.log('\n[1/5] Connecting to MySQL...');
|
||||||
|
const conn = await mysql.createConnection(MYSQL_CONFIG);
|
||||||
|
console.log(` OK - connected to ${MYSQL_CONFIG.host}:${MYSQL_CONFIG.port}/${MYSQL_CONFIG.database}`);
|
||||||
|
|
||||||
|
// Step 2: Find user
|
||||||
|
console.log(`\n[2/5] Looking up user by phone: ${phone}`);
|
||||||
|
const [rows] = await conn.execute<any[]>(
|
||||||
|
'SELECT id, phone, nickname, real_name, user_role FROM u_user_info WHERE phone = ?',
|
||||||
|
[phone],
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rows.length === 0) {
|
||||||
|
console.log(' User NOT found. To test, pick an existing user:');
|
||||||
|
const [sample] = await conn.execute<any[]>(
|
||||||
|
'SELECT id, phone, nickname, real_name FROM u_user_info LIMIT 5',
|
||||||
|
);
|
||||||
|
console.table(sample);
|
||||||
|
await conn.end();
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = rows[0];
|
||||||
|
console.log(` Found: id=${user.id}, nickname="${user.nickname}", role=${user.user_role}`);
|
||||||
|
|
||||||
|
// Step 3: Generate token
|
||||||
|
console.log('\n[3/5] Generating MD5 token...');
|
||||||
|
const token = generateToken(user.id);
|
||||||
|
console.log(` Token: ${token}`);
|
||||||
|
|
||||||
|
// Step 4: Delete old tokens + insert new one
|
||||||
|
// gen_time must be milliseconds since epoch (Java System.currentTimeMillis()),
|
||||||
|
// NOT MySQL NOW(). The old system's isTimeout() does millisecond arithmetic.
|
||||||
|
console.log('\n[4/5] Deleting old tokens and inserting new one...');
|
||||||
|
await conn.execute('DELETE FROM u_user_token WHERE user_id = ?', [user.id]);
|
||||||
|
const genTime = Date.now();
|
||||||
|
await conn.execute(
|
||||||
|
'INSERT INTO u_user_token (user_id, token, gen_time, user_role) VALUES (?, ?, ?, ?)',
|
||||||
|
[user.id, token, genTime, user.user_role],
|
||||||
|
);
|
||||||
|
console.log(` OK - token inserted (gen_time=${genTime}, which is ${new Date(genTime).toISOString()})`);
|
||||||
|
|
||||||
|
// Step 5: Verify
|
||||||
|
console.log('\n[5/5] Verifying token in database...');
|
||||||
|
const [verify] = await conn.execute<any[]>(
|
||||||
|
'SELECT id, user_id, token, gen_time, user_role FROM u_user_token WHERE token = ?',
|
||||||
|
[token],
|
||||||
|
);
|
||||||
|
console.log(' Verified:');
|
||||||
|
console.table(verify);
|
||||||
|
|
||||||
|
await conn.end();
|
||||||
|
|
||||||
|
// Print manual test instructions
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
console.log('TOKEN INJECTION SUCCESSFUL!');
|
||||||
|
console.log('='.repeat(60));
|
||||||
|
console.log('\nManual browser test:');
|
||||||
|
console.log('1. Open browser, go to https://www.xunzhengyixue.com');
|
||||||
|
console.log('2. Open DevTools (F12) -> Console');
|
||||||
|
console.log('3. Paste the following 3 lines:\n');
|
||||||
|
|
||||||
|
const nickname = user.nickname || user.real_name || phone;
|
||||||
|
console.log(`document.cookie = "token=${token}; domain=.xunzhengyixue.com; path=/";`);
|
||||||
|
console.log(`document.cookie = "nickname=${encodeURIComponent(nickname)}; domain=.xunzhengyixue.com; path=/";`);
|
||||||
|
console.log(`document.cookie = "id=${user.id}; domain=.xunzhengyixue.com; path=/";`);
|
||||||
|
|
||||||
|
console.log('\n4. Refresh the page (F5)');
|
||||||
|
console.log('5. You should see the user\'s projects without logging in manually');
|
||||||
|
console.log('\n' + '='.repeat(60));
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch((err) => {
|
||||||
|
console.error('Test failed:', err);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -214,6 +214,13 @@ import { ssaRoutes } from './modules/ssa/index.js';
|
|||||||
await fastify.register(ssaRoutes, { prefix: '/api/v1/ssa' });
|
await fastify.register(ssaRoutes, { prefix: '/api/v1/ssa' });
|
||||||
logger.info('✅ SSA智能统计分析路由已注册: /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 () => {
|
const start = async () => {
|
||||||
try {
|
try {
|
||||||
@@ -340,6 +347,11 @@ const gracefulShutdown = async (signal: string) => {
|
|||||||
await prisma.$disconnect();
|
await prisma.$disconnect();
|
||||||
console.log('✅ 数据库连接已关闭');
|
console.log('✅ 数据库连接已关闭');
|
||||||
|
|
||||||
|
// 4. 关闭 Legacy MySQL 连接池
|
||||||
|
const { closeLegacyMysqlPool } = await import('./modules/legacy-bridge/mysql-pool.js');
|
||||||
|
await closeLegacyMysqlPool();
|
||||||
|
console.log('✅ Legacy MySQL连接池已关闭');
|
||||||
|
|
||||||
console.log('👋 优雅关闭完成,再见!');
|
console.log('👋 优雅关闭完成,再见!');
|
||||||
process.exit(0);
|
process.exit(0);
|
||||||
} catch (error) {
|
} 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>
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
# AIclinicalresearch 系统当前状态与开发指南
|
# AIclinicalresearch 系统当前状态与开发指南
|
||||||
|
|
||||||
> **文档版本:** v6.4
|
> **文档版本:** v6.5
|
||||||
> **创建日期:** 2025-11-28
|
> **创建日期:** 2025-11-28
|
||||||
> **维护者:** 开发团队
|
> **维护者:** 开发团队
|
||||||
> **最后更新:** 2026-02-27
|
> **最后更新:** 2026-02-27
|
||||||
> **🎉 重大里程碑:**
|
> **🎉 重大里程碑:**
|
||||||
|
> - **🆕 2026-02-27:旧版系统集成完成!** Token 注入自动登录 + Wrapper Bridge 架构 + Storage Access API + iframe 嵌入(研究管理 + 统计分析工具 126 个) + CSS 注入样式定制 + 本地 E2E 验证通过
|
||||||
> - **🆕 2026-02-27:数据库文档体系 + 部署文档体系 + Prisma Schema 对齐完成!** 6 篇数据库核心文档 + 部署文档归档整理 + 统一操作手册 + 数据库开发规范 v3.0 + Cursor Rule 自动提醒 + Schema 类型漂移修正
|
> - **🆕 2026-02-27:数据库文档体系 + 部署文档体系 + Prisma Schema 对齐完成!** 6 篇数据库核心文档 + 部署文档归档整理 + 统一操作手册 + 数据库开发规范 v3.0 + Cursor Rule 自动提醒 + Schema 类型漂移修正
|
||||||
> - **🆕 2026-02-26:ASL 工具 4 SR 图表生成器 + 工具 5 Meta 分析引擎开发完成!** PRISMA 流程图(中英切换)+ 基线特征表 + Meta 分析(HR/二分类/连续型)+ 森林图/漏斗图 + R Docker meta 包 + E2E 36/36 通过
|
> - **🆕 2026-02-26:ASL 工具 4 SR 图表生成器 + 工具 5 Meta 分析引擎开发完成!** PRISMA 流程图(中英切换)+ 基线特征表 + Meta 分析(HR/二分类/连续型)+ 森林图/漏斗图 + R Docker meta 包 + E2E 36/36 通过
|
||||||
> - **🆕 2026-02-26:CRA Agent V3.0 P0+P1 全部完成!** 自驱动质控流水线 + ChatOrchestrator + LLM Function Calling + E2E 54/54 通过
|
> - **🆕 2026-02-26:CRA Agent V3.0 P0+P1 全部完成!** 自驱动质控流水线 + ChatOrchestrator + LLM Function Calling + E2E 54/54 通过
|
||||||
@@ -31,7 +32,8 @@
|
|||||||
> - **2026-01-24:Protocol Agent 框架完成!** 可复用Agent框架+5阶段对话流程
|
> - **2026-01-24:Protocol Agent 框架完成!** 可复用Agent框架+5阶段对话流程
|
||||||
> - **2026-01-22:OSS 存储集成完成!** 阿里云 OSS 正式接入平台基础层
|
> - **2026-01-22:OSS 存储集成完成!** 阿里云 OSS 正式接入平台基础层
|
||||||
>
|
>
|
||||||
> **🆕 最新进展(数据库文档体系 + 部署文档整理 2026-02-27):**
|
> **🆕 最新进展(旧版系统集成 + 数据库文档体系 2026-02-27):**
|
||||||
|
> - ✅ **🆕 旧版系统集成** — Token 注入自动登录 + Wrapper Bridge(Cookie 设置 + CSS 注入)+ Storage Access API + 本地 E2E 全部通过
|
||||||
> - ✅ **🆕 数据库文档体系建立** — 6 篇核心文档(架构总览/迁移历史/环境对照/技术债务/种子数据/PG扩展),位于 `docs/01-平台基础层/07-数据库/`
|
> - ✅ **🆕 数据库文档体系建立** — 6 篇核心文档(架构总览/迁移历史/环境对照/技术债务/种子数据/PG扩展),位于 `docs/01-平台基础层/07-数据库/`
|
||||||
> - ✅ **🆕 Prisma Schema 类型漂移修正** — IIT/SSA 模型 @db.* 注解对齐 + 手动迁移 + Tech Debt 注释
|
> - ✅ **🆕 Prisma Schema 类型漂移修正** — IIT/SSA 模型 @db.* 注解对齐 + 手动迁移 + Tech Debt 注释
|
||||||
> - ✅ **🆕 部署文档体系整理** — 归档 2025 旧文档 + 统一操作手册 + 待部署变更清单 + README 重写
|
> - ✅ **🆕 部署文档体系整理** — 归档 2025 旧文档 + 统一操作手册 + 待部署变更清单 + README 重写
|
||||||
@@ -81,7 +83,7 @@
|
|||||||
| **DC** | 数据清洗整理 | ETL + 医学NER(百万行级数据) | ⭐⭐⭐⭐⭐ | ✅ **Tool B完成 + Tool C 99%(异步架构+性能优化-99%+多指标转换+7大功能)** | **P0** |
|
| **DC** | 数据清洗整理 | ETL + 医学NER(百万行级数据) | ⭐⭐⭐⭐⭐ | ✅ **Tool B完成 + Tool C 99%(异步架构+性能优化-99%+多指标转换+7大功能)** | **P0** |
|
||||||
| **IIT** | IIT Manager Agent | CRA Agent - LLM Tool Use + 自驱动质控 + 统一驾驶舱 | ⭐⭐⭐⭐⭐ | 🎉 **V3.0 P0+P1完成!** ChatOrchestrator + 4工具 + E2E 54/54 | **P1-2** |
|
| **IIT** | IIT Manager Agent | CRA Agent - LLM Tool Use + 自驱动质控 + 统一驾驶舱 | ⭐⭐⭐⭐⭐ | 🎉 **V3.0 P0+P1完成!** ChatOrchestrator + 4工具 + E2E 54/54 | **P1-2** |
|
||||||
| **SSA** | 智能统计分析 | **QPER架构** + 四层七工具 + 对话层LLM + 意图路由器 | ⭐⭐⭐⭐⭐ | 🎉 **Phase I-IV 开发完成** — QPER闭环 + Session黑板 + 意图路由 + 对话LLM + 方法咨询 + 对话驱动分析,E2E 107/107 | **P1** |
|
| **SSA** | 智能统计分析 | **QPER架构** + 四层七工具 + 对话层LLM + 意图路由器 | ⭐⭐⭐⭐⭐ | 🎉 **Phase I-IV 开发完成** — QPER闭环 + Session黑板 + 意图路由 + 对话LLM + 方法咨询 + 对话驱动分析,E2E 107/107 | **P1** |
|
||||||
| **ST** | 统计分析工具 | 100+轻量化统计工具 | ⭐⭐⭐⭐ | 📋 规划中 | P2 |
|
| **ST** | 统计分析工具 | 126 个轻量化统计工具(旧系统 iframe 嵌入) | ⭐⭐⭐⭐ | ✅ **旧系统集成完成** — Token 注入 + Wrapper Bridge + E2E 验证通过 | P2 |
|
||||||
| **RVW** | 稿件审查系统 | 方法学评估 + 🆕数据侦探(L1/L2/L2.5验证)+ Skills架构 + Word导出 | ⭐⭐⭐⭐ | 🚀 **V2.0 Week3完成(85%)** - 统计验证扩展+负号归一化+文件格式提示+用户体验优化 | P1 |
|
| **RVW** | 稿件审查系统 | 方法学评估 + 🆕数据侦探(L1/L2/L2.5验证)+ Skills架构 + Word导出 | ⭐⭐⭐⭐ | 🚀 **V2.0 Week3完成(85%)** - 统计验证扩展+负号归一化+文件格式提示+用户体验优化 | P1 |
|
||||||
| **ADMIN** | 运营管理端 | Prompt管理、租户管理、用户管理、运营监控、系统知识库 | ⭐⭐⭐⭐⭐ | 🎉 **Phase 4.6完成(88%)** - Prompt知识库集成+动态注入 | **P0** |
|
| **ADMIN** | 运营管理端 | Prompt管理、租户管理、用户管理、运营监控、系统知识库 | ⭐⭐⭐⭐⭐ | 🎉 **Phase 4.6完成(88%)** - Prompt知识库集成+动态注入 | **P0** |
|
||||||
|
|
||||||
|
|||||||
418
docs/09-架构实施/旧版系统集成/01-新旧系统集成方案.md
Normal file
418
docs/09-架构实施/旧版系统集成/01-新旧系统集成方案.md
Normal file
@@ -0,0 +1,418 @@
|
|||||||
|
# 新旧系统集成方案
|
||||||
|
|
||||||
|
> **版本:** v4.0
|
||||||
|
> **创建日期:** 2026-02-27
|
||||||
|
> **最后更新:** 2026-02-27(Wrapper Bridge 架构 + Storage Access API + 本地 E2E 验证通过)
|
||||||
|
> **维护者:** 开发团队
|
||||||
|
> **状态:** ✅ Phase 0-2 全部完成,本地开发环境 E2E 验证通过,待部署生产环境
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
### 1.1 两套系统概览
|
||||||
|
|
||||||
|
| 维度 | 新系统(AI临床研究平台) | 老系统(循证医学平台) |
|
||||||
|
|------|------------------------|----------------------|
|
||||||
|
| 域名 | `iit.xunzhengyixue.com` | `www.xunzhengyixue.com` |
|
||||||
|
| API 域名 | `iit.xunzhengyixue.com/api` (SAE) | `api.xunzhengyixue.com` (ECS Nginx 反代) |
|
||||||
|
| 部署 | 阿里云 SAE(Serverless) | 阿里云 ECS(`8.154.22.149`) |
|
||||||
|
| 技术栈 | Node.js + React 19 + PostgreSQL | Spring Boot 1.4.1 + Java 8 + MySQL |
|
||||||
|
| 前端部署 | SAE 静态资源 | Nginx 直接 serve 静态文件 |
|
||||||
|
| 认证方式 | JWT(Access 2h + Refresh 7d) | 自定义 MD5 Token(MySQL 存储,3.5 天有效期) |
|
||||||
|
| 数据库 | PostgreSQL 15(14 个 Schema) | MariaDB/MySQL(`xzyx_online`) |
|
||||||
|
|
||||||
|
### 1.2 老系统模块
|
||||||
|
|
||||||
|
| 模块 | 入口地址 | 集成状态 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| 研究管理 | `https://www.xunzhengyixue.com/index.html` | Phase 0 外链已完成 → Phase 2 将改为 iframe 内嵌 |
|
||||||
|
| 统计分析工具 | `https://www.xunzhengyixue.com/tool.html` | Phase 0 外链已完成 → Phase 2 将改为 iframe 内嵌 |
|
||||||
|
| AI 问答 | 老系统内 | 已在新系统完全重写(AIA 模块),无需集成 |
|
||||||
|
|
||||||
|
### 1.3 约束条件
|
||||||
|
|
||||||
|
- Java 团队已离职,未正式交接源码
|
||||||
|
- 后找到一份源码,经 ECS 侦察确认 **与生产环境完全一致**
|
||||||
|
- 线上系统可通过 ECS SSH 访问,MySQL 数据库可完全读写
|
||||||
|
- 两系统同属 `xunzhengyixue.com` 根域(Cookie 可共享)
|
||||||
|
|
||||||
|
### 1.4 用户关系
|
||||||
|
|
||||||
|
- 两套系统的用户是 **同一批人**,由运营人员统一开账号
|
||||||
|
- 老系统按用户隔离数据(每个用户有自己的研究项目)
|
||||||
|
- 自动登录必须以用户本人身份进行,不能使用公共账号
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. ECS 侦察结果(Phase 1 - 已完成)
|
||||||
|
|
||||||
|
> 2026-02-27 通过 SSH 登录 `8.154.22.149` 实地验证
|
||||||
|
|
||||||
|
### 2.1 部署架构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────┐
|
||||||
|
│ ECS 8.154.22.149 │
|
||||||
|
│ │
|
||||||
|
www.xunzhengyixue.com ──► Nginx:443 │
|
||||||
|
│ │ │
|
||||||
|
│ ├─ / → 静态文件 │
|
||||||
|
│ │ /home/work/www/xunzhengyixue/ │
|
||||||
|
│ │ │
|
||||||
|
│ └─ /api-proxy/ → rag.xunzhengyixue│
|
||||||
|
│ │
|
||||||
|
api.xunzhengyixue.com ──► Nginx:443 │
|
||||||
|
│ └─ / → proxy_pass 127.0.0.1:8899 │
|
||||||
|
│ │ │
|
||||||
|
│ Java JAR (Spring Boot) │
|
||||||
|
│ XZYXServer-0.0.1-SNAPSHOT.jar │
|
||||||
|
│ -Xms800m -Xmx4000m │
|
||||||
|
│ 运行自 2025 年至今 │
|
||||||
|
│ │ │
|
||||||
|
│ MariaDB/MySQL :3306 │
|
||||||
|
│ xzyx_online │
|
||||||
|
└─────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 关键确认事项
|
||||||
|
|
||||||
|
| 项目 | 侦察结果 |
|
||||||
|
|------|---------|
|
||||||
|
| 源码一致性 | **生产 `common.js` 与本地源码 100% 一致** |
|
||||||
|
| 前端静态文件路径 | `/home/work/www/xunzhengyixue/` |
|
||||||
|
| 要修改的文件 | `/home/work/www/xunzhengyixue/static/js/common.js` |
|
||||||
|
| Java 部署方式 | 直接 JAR 运行(非 Docker),PID 66548 |
|
||||||
|
| API 地址 | `https://api.xunzhengyixue.com`(`config.js` 确认) |
|
||||||
|
| Cookie 域名 | **`xunzhengyixue.com`**(`config.js` 退出登录逻辑确认) |
|
||||||
|
| Cookie 名称 | `token`, `nickname`, `id`(3 个) |
|
||||||
|
| 登录检查逻辑 | 仅检查 `$.cookie("nickname")` 是否存在 |
|
||||||
|
| API 认证逻辑 | `$.cookie("token")` → `Authorization` 请求头 |
|
||||||
|
| X-Frame-Options | **未设置** → iframe 嵌入可行 |
|
||||||
|
| Nginx 改 common.js | **无需重启 Java**,静态文件改完即生效 |
|
||||||
|
|
||||||
|
### 2.3 认证机制详解
|
||||||
|
|
||||||
|
```
|
||||||
|
Token 生成公式:MD5("KyKz1.0:" + userId + ":" + timestamp)
|
||||||
|
Token 存储位置:MySQL u_user_token 表
|
||||||
|
Token 有效期:可配置(生产 5000 分钟 ≈ 3.5 天)
|
||||||
|
Token 传递方式:Cookie → JS 读取 → Authorization 请求头
|
||||||
|
|
||||||
|
老系统 AuthInterceptor 验证流程:
|
||||||
|
1. 从 Authorization header 取 Token
|
||||||
|
2. 查 u_user_token 表是否存在该 Token
|
||||||
|
3. 检查是否过期(gen_time + timeout)
|
||||||
|
4. 不涉及密码验证 ← 这是直接写 Token 方案可行的根本原因
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.4 对集成友好的 6 个关键发现
|
||||||
|
|
||||||
|
| 发现 | 对集成的意义 |
|
||||||
|
|------|-------------|
|
||||||
|
| **无 CSRF 保护** | 可以从新系统直接 POST 调用 API |
|
||||||
|
| **CORS 全开**(`Access-Control-Allow-Origin: *`) | 新系统前端可直接 fetch 调用老系统 API |
|
||||||
|
| **无 X-Frame-Options** | iframe 嵌入完全可行 |
|
||||||
|
| **Token 仅查表验证,不校验密码** | 直接写 Token 到 DB 即可,绕过密码 |
|
||||||
|
| **同根域名 `.xunzhengyixue.com`** | 新系统 `iit.*` 可设置 Cookie 供 `www.*` 读取 |
|
||||||
|
| **前端纯静态 + 后端分离** | 改 `common.js` 无需碰 Java 代码或重启服务 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 最终方案:Token 注入 + iframe 嵌入
|
||||||
|
|
||||||
|
经过 Phase 0(外链验证)和 Phase 1(ECS 侦察),最终确认采用 **方案 B+E 组合**:
|
||||||
|
|
||||||
|
- **方案 E(Token 注入)**:新系统后端直接写 Token 到老系统 MySQL,实现自动登录
|
||||||
|
- **方案 B(iframe 嵌入)**:在新系统内用 iframe 加载老系统页面,隐藏老系统导航
|
||||||
|
|
||||||
|
### 3.1 为什么选 Token 注入而非调用登录 API
|
||||||
|
|
||||||
|
| 对比项 | 调用登录 API(方案 C) | 直接写 Token(方案 E) |
|
||||||
|
|--------|----------------------|----------------------|
|
||||||
|
| 需要知道用户密码 | 是 | **否** |
|
||||||
|
| 用户改了老系统密码 | 失败 | **不受影响** |
|
||||||
|
| 依赖老系统 API 可用 | 是 | **否**(只依赖 MySQL) |
|
||||||
|
| 可同时创建账号 | 否 | **是** |
|
||||||
|
| 安全性 | 密码在网络传输 | Token 仅写入 DB |
|
||||||
|
|
||||||
|
### 3.2 整体流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户在新系统点击"研究管理"或"统计分析工具"
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
新系统前端 → POST /api/legacy/auth(携带 JWT)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
新系统后端:
|
||||||
|
1. 从 JWT 解出当前用户手机号
|
||||||
|
2. 连接老系统 MySQL(xzyx_online)
|
||||||
|
3. SELECT id FROM u_user_info WHERE phone = 手机号
|
||||||
|
├─ 不存在 → INSERT 创建账号(默认密码 MD5)
|
||||||
|
└─ 存在 → 获取 userId
|
||||||
|
4. 生成 Token = MD5("KyKz1.0:" + userId + ":" + Date.now())
|
||||||
|
5. INSERT INTO u_user_token (user_id, token, gen_time, user_role)
|
||||||
|
6. 返回 { token, nickname, id, userRole }
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
新系统前端:
|
||||||
|
7. 构建 Bridge URL(token/nickname/id/userRole/redirect 作为参数)
|
||||||
|
8. iframe src = https://www.xunzhengyixue.com/token-bridge.html?...
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
Bridge 页面(www.xunzhengyixue.com 域内执行):
|
||||||
|
9. Storage Access API 检查(同站自动授权,跨站需一次性用户点击)
|
||||||
|
10. document.cookie = "token=xxx; domain=.xunzhengyixue.com"(同域设置)
|
||||||
|
11. 创建内部 iframe 加载目标页面(/index.html 或 /tool.html)
|
||||||
|
12. 每次内部页面加载后,注入自定义 CSS(隐藏导航栏、页脚等)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
老系统页面(Bridge 内部 iframe,同源):
|
||||||
|
13. 读取 Cookie nickname → 存在 → 正常渲染
|
||||||
|
14. AJAX 请求带 Cookie 中的 token → AuthInterceptor 验证通过
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
用户看到自己的研究项目(iframe 内),新系统导航栏保持在顶部
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.3 账号同步策略
|
||||||
|
|
||||||
|
| 场景 | 处理方式 |
|
||||||
|
|------|---------|
|
||||||
|
| 用户在老系统已有账号 | 直接注入 Token,使用原有账号 |
|
||||||
|
| 用户在老系统无账号 | 自动创建(INSERT u_user_info),默认密码 `123456` 的 MD5 |
|
||||||
|
| 用户改了老系统密码 | **不影响**,Token 注入不依赖密码 |
|
||||||
|
| 用户在老系统被删除 | 自动重新创建 |
|
||||||
|
|
||||||
|
匹配规则:以 **手机号(phone)** 作为两套系统的用户关联键。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 实施计划
|
||||||
|
|
||||||
|
### Phase 0 — 外链跳转(已完成)
|
||||||
|
|
||||||
|
顶部导航加外链按钮,点击后 `window.open()` 打开老系统新标签页。
|
||||||
|
|
||||||
|
**已改动文件:**
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend-v2/src/framework/modules/types.ts` | 新增 `externalUrl?: string` 字段 |
|
||||||
|
| `frontend-v2/src/framework/modules/moduleRegistry.ts` | 新增「研究管理」模块 + 「统计分析工具」加 `externalUrl` |
|
||||||
|
| `frontend-v2/src/framework/layout/TopNavigation.tsx` | 外部模块 `window.open` + 外链图标 + 跳过权限检查 |
|
||||||
|
| `frontend-v2/src/App.tsx` | 外部模块跳过内部路由注册 |
|
||||||
|
|
||||||
|
### Phase 1 — ECS 侦察(已完成)
|
||||||
|
|
||||||
|
SSH 登录 ECS,验证部署架构、Nginx 配置、Cookie 机制、源码一致性。
|
||||||
|
|
||||||
|
**结论:** 全部确认,方案可行。详见第 2 节。
|
||||||
|
|
||||||
|
### Phase 2 — 新系统代码改动清单
|
||||||
|
|
||||||
|
**后端新增文件:**
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/src/modules/legacy-bridge/legacy-auth.service.ts` | 连接老系统 MySQL,查询用户、写入 token |
|
||||||
|
| `backend/src/modules/legacy-bridge/routes.ts` | `POST /api/v1/legacy/auth` 接口 |
|
||||||
|
|
||||||
|
**后端改动文件:**
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `backend/.env` | 新增 `LEGACY_MYSQL_*` 连接配置 |
|
||||||
|
| `backend/src/index.ts` | 注册 `/api/v1/legacy` 路由 |
|
||||||
|
|
||||||
|
**前端新增文件:**
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend-v2/src/modules/legacy/LegacySystemPage.tsx` | iframe 容器页面(调后端注入 token → 加载 iframe) |
|
||||||
|
| `frontend-v2/src/modules/legacy/ResearchManagement.tsx` | 研究管理入口(指向 index.html) |
|
||||||
|
| `frontend-v2/src/modules/legacy/StatisticalTools.tsx` | 统计分析工具入口(指向 tool.html) |
|
||||||
|
|
||||||
|
**前端改动文件:**
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend-v2/src/framework/modules/types.ts` | 新增 `legacyUrl` 字段 |
|
||||||
|
| `frontend-v2/src/framework/modules/moduleRegistry.ts` | 研究管理/统计分析模块改为 iframe 内嵌模式 |
|
||||||
|
| `frontend-v2/src/App.tsx` | legacy 模块路由注册 |
|
||||||
|
|
||||||
|
**ECS 改动文件:**
|
||||||
|
|
||||||
|
| 文件 | 改动 |
|
||||||
|
|------|------|
|
||||||
|
| `/home/work/www/xunzhengyixue/token-bridge.html` | **新增**:Wrapper Bridge 页面(设 Cookie + 内嵌 iframe + 注入 CSS) |
|
||||||
|
| `/home/work/www/xunzhengyixue/static/js/common.js` | iframe 检测:隐藏 header + footer(bridge 的 CSS 注入已覆盖,可选回退) |
|
||||||
|
| `/home/work/www/xunzhengyixue/tool/js/appajax.js` | iframe 检测:隐藏 menu + footer(bridge 的 CSS 注入已覆盖,可选回退) |
|
||||||
|
|
||||||
|
### Phase 2 — Token 注入 + iframe 嵌入(已完成)
|
||||||
|
|
||||||
|
| 步骤 | 内容 | 位置 | 状态 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| 2.1 | 新系统后端添加 MySQL 连接(老系统数据库) | `backend/` | ✅ 已完成 |
|
||||||
|
| 2.2 | 新增 `POST /api/v1/legacy/auth` 接口 | `backend/` | ✅ 已完成 |
|
||||||
|
| 2.3 | 前端创建 iframe 容器页面组件 | `frontend-v2/` | ✅ 已完成 |
|
||||||
|
| 2.4 | 修改模块注册:从外链改为内嵌 iframe 页面 | `frontend-v2/` | ✅ 已完成 |
|
||||||
|
| 2.5 | ECS 部署 Token Bridge 页面 | ECS 服务器 | ✅ 已部署 |
|
||||||
|
| 2.6 | Token 注入测试 + iframe 显示测试 | 浏览器 | ✅ 已验证 |
|
||||||
|
|
||||||
|
### Phase 2.5 — Wrapper Bridge 架构升级
|
||||||
|
|
||||||
|
**动机:** 原方案由父页面(新系统)设置 Cookie,但在 localhost 开发环境无法跨域设置
|
||||||
|
`.xunzhengyixue.com` 的 Cookie。改为 Wrapper Bridge 方案后,Cookie 在老系统域名内
|
||||||
|
由 bridge 页面自己设置,同时利用同源 DOM 访问注入自定义 CSS。
|
||||||
|
|
||||||
|
**架构:**
|
||||||
|
|
||||||
|
```
|
||||||
|
新系统 iframe
|
||||||
|
└─ token-bridge.html(www.xunzhengyixue.com,设 Cookie + 注入 CSS)
|
||||||
|
└─ inner iframe(www.xunzhengyixue.com/index.html 或 /tool.html)
|
||||||
|
↑ 同源,bridge 可直接操作其 DOM
|
||||||
|
```
|
||||||
|
|
||||||
|
**优势:**
|
||||||
|
- 本地开发环境(localhost)可正常测试
|
||||||
|
- 所有样式定制集中在 bridge 页面一处
|
||||||
|
- 不再需要修改老系统的 `common.js` / `appajax.js`(之前的改动可选回退)
|
||||||
|
- 未来可随时调整老系统的外观,无需登录 ECS 改文件
|
||||||
|
|
||||||
|
**`token-bridge.html` 工作流程:**
|
||||||
|
1. 检查 Storage Access API(`document.hasStorageAccess()`)
|
||||||
|
- 同站(生产环境):自动授权 → 直接进入步骤 2
|
||||||
|
- 跨站(本地开发)首次:显示"点击授权并继续"按钮 → 用户点击 → `requestStorageAccess()` → 浏览器授权(缓存 30 天)
|
||||||
|
- 跨站(本地开发)后续:已缓存授权 → 直接进入步骤 2
|
||||||
|
2. 读取 URL 参数,在 `.xunzhengyixue.com` 域下设置 Cookie(`SameSite=None; Secure`)
|
||||||
|
3. 创建内部 iframe 加载目标页面
|
||||||
|
4. 每次内部 iframe 导航后,注入自定义 CSS:
|
||||||
|
- 隐藏 `#header-navbar`、`#footer-bar`(主页面)
|
||||||
|
- 隐藏 `#menu`、`#footer`(工具详情页)
|
||||||
|
- 清除 `body` 和 `#page-wrapper` 的顶部间距
|
||||||
|
|
||||||
|
**源码位置:** `backend/src/modules/legacy-bridge/token-bridge.html`
|
||||||
|
|
||||||
|
**浏览器兼容性:**
|
||||||
|
|
||||||
|
| 环境 | 结果 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 生产环境(同站) | ✅ 自动工作 | `iit.*` 和 `www.*` 是 same-site |
|
||||||
|
| 本地开发普通模式 | ✅ 自动工作 | Storage Access API 自动授权 |
|
||||||
|
| 本地开发无痕模式 | ❌ 不可用 | Chrome 无痕模式禁止所有第三方 Cookie |
|
||||||
|
|
||||||
|
### Phase 2 — ECS 历史操作记录
|
||||||
|
|
||||||
|
> 以下 `common.js` 和 `appajax.js` 的改动在 Bridge 升级后**已冗余**(bridge 的 CSS 注入覆盖了相同功能)。
|
||||||
|
> 保留这些改动作为双重保障;如需回退,用 `.bak` 文件恢复即可。
|
||||||
|
|
||||||
|
**文件 1:`/home/work/www/xunzhengyixue/static/js/common.js`**
|
||||||
|
|
||||||
|
在 `navigation:function () {` 开头加入的 iframe 检测代码。
|
||||||
|
|
||||||
|
**文件 2:`/home/work/www/xunzhengyixue/tool/js/appajax.js`**
|
||||||
|
|
||||||
|
在文件开头加入的 iframe 检测代码。
|
||||||
|
|
||||||
|
### Phase 2 — 踩坑记录
|
||||||
|
|
||||||
|
| 问题 | 原因 | 修复 |
|
||||||
|
|------|------|------|
|
||||||
|
| Token 注入后 tool.html 正常,但 index.html 跳转登录页 | `gen_time` 字段用了 MySQL `NOW()`(datetime 格式),老系统期望 Java `System.currentTimeMillis()`(毫秒时间戳) | 改为 `Date.now()`,同时先 DELETE 旧 token 再 INSERT |
|
||||||
|
| 研究管理没有左侧导航 | iframe 检测代码隐藏了 `#nav-col` 并 `return` 导致侧边栏内容未生成 | 只隐藏 header + footer,不 return |
|
||||||
|
| 工具详情页仍显示顶部导航 | 工具详情页是独立前端,不加载 `common.js`,使用 `tool/js/appajax.js` | 在 `appajax.js` 开头也加 iframe 检测 |
|
||||||
|
| localhost 无法测试 iframe 嵌入 | 父页面(localhost)无法为 `.xunzhengyixue.com` 设 Cookie | 升级为 Wrapper Bridge:由 bridge 页面在同域内设 Cookie |
|
||||||
|
| Bridge 设了 Cookie 但老系统仍跳登录页 | 浏览器第三方 Cookie 隔离:cross-site iframe 的 `document.cookie` 被静默拒绝 | 添加 `SameSite=None; Secure` + Storage Access API 双重保障 |
|
||||||
|
| 隐藏导航栏后页面顶部多出空白条 | `#header-navbar` 隐藏后 body 的 `padding-top` 仍在 | Bridge CSS 注入额外清除 `body`、`#page-wrapper` 的 padding-top/margin-top |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 验证结果(2026-02-27 本地 E2E 测试)
|
||||||
|
|
||||||
|
| 测试项 | 结果 |
|
||||||
|
|------|------|
|
||||||
|
| Token 注入 + 自动登录 | ✅ 通过 |
|
||||||
|
| 研究管理(index.html)— 左侧导航正常 | ✅ 通过 |
|
||||||
|
| 统计分析工具(tool.html)— 126 个工具 | ✅ 通过 |
|
||||||
|
| 工具详情页(tool/isttest1.html 等) | ✅ 通过 |
|
||||||
|
| 统计分析 + 出报告 + 下载报告 | ✅ 通过 |
|
||||||
|
| 导航栏/页脚隐藏 + 顶部间距消除 | ✅ 通过 |
|
||||||
|
| 用户隔离(不同用户看不到彼此项目) | ✅ 设计保证(按手机号绑定) |
|
||||||
|
| 普通模式 Storage Access API | ✅ 自动授权 |
|
||||||
|
| 无痕模式 | ❌ 预期不可用(浏览器限制) |
|
||||||
|
|
||||||
|
## 6. 后续步骤
|
||||||
|
|
||||||
|
| 事项 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 部署新系统后端到 SAE | 确保 `.env` 中 `LEGACY_MYSQL_*` 配置指向 ECS MySQL |
|
||||||
|
| 部署新系统前端到 SAE | 包含 `LegacySystemPage` 等新组件 |
|
||||||
|
| 生产端到端验证 | 同站环境(`iit.*` → `www.*`),应自动工作无需 Storage Access API |
|
||||||
|
| 可选:回退 ECS JS 改动 | `common.js` 和 `appajax.js` 的 iframe 检测代码已被 bridge CSS 覆盖,可用 `.bak` 恢复 |
|
||||||
|
| 监控 | 关注 `/api/v1/legacy/auth` 接口调用量和失败率 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 曾考虑但未选用的方案
|
||||||
|
|
||||||
|
### 方案 A:直接外链跳转
|
||||||
|
|
||||||
|
作为 Phase 0 已实施。用户需手动登录老系统,两个标签页切换,体验不佳。
|
||||||
|
|
||||||
|
### 方案 C:前端直调登录 API
|
||||||
|
|
||||||
|
需要知道用户在老系统的密码。如果用户改了密码就会失败。已被方案 E(Token 注入)取代。
|
||||||
|
|
||||||
|
### 方案 D:Nginx 反向代理统一入口
|
||||||
|
|
||||||
|
在新系统 Nginx 配置 `/legacy/*` 反代到老系统。路径改写复杂,流量多一次中转,SAE 环境配置不便。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录 A:Nginx 配置摘要(生产环境)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# api.xunzhengyixue.com → Java 后端
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name api.xunzhengyixue.com;
|
||||||
|
location / {
|
||||||
|
proxy_pass http://127.0.0.1:8899;
|
||||||
|
proxy_cookie_path / /;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
# www.xunzhengyixue.com → 静态文件
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name xunzhengyixue.com www.xunzhengyixue.com;
|
||||||
|
root /home/work/www/xunzhengyixue;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location /api-proxy/ {
|
||||||
|
proxy_pass https://rag.xunzhengyixue.com/api/v1/;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 附录 B:老系统源码关键文件索引
|
||||||
|
|
||||||
|
| 文件 | 位置(相对于 Java_old_system/XZYXServer) | 说明 |
|
||||||
|
|------|----------------------------------------|------|
|
||||||
|
| 登录 Controller | `src/main/java/com/xzyx/controller/UserController.java` | 3 个登录端点 |
|
||||||
|
| Token 生成 | `src/main/java/com/xzyx/biz/service/impl/MysqlTokenValidator.java` | MD5 Token 公式 |
|
||||||
|
| 认证拦截器 | `src/main/java/com/xzyx/framework/AuthInterceptor.java` | 仅查表验证 Token |
|
||||||
|
| CORS 配置 | `src/main/java/com/xzyx/framework/CorsFilter.java` | 全开(`*`) |
|
||||||
|
| MVC 配置 | `src/main/java/com/xzyx/framework/MvcConfig.java` | 拦截路径注册 |
|
||||||
|
| 前端导航+登录检查 | ECS `/home/work/www/xunzhengyixue/static/js/common.js` | **已修改**:iframe 检测 |
|
||||||
|
| 工具详情页 JS | ECS `/home/work/www/xunzhengyixue/tool/js/appajax.js` | **已修改**:iframe 检测 |
|
||||||
|
| 前端 API 配置 | ECS `/home/work/www/xunzhengyixue/static/js/config.js` | Cookie 域名逻辑 |
|
||||||
|
| 前端 HTTP 工具 | ECS `/home/work/www/xunzhengyixue/static/js/request.js` | Token → Authorization |
|
||||||
|
| 前端入口页 | ECS `/home/work/www/xunzhengyixue/index.html`, `tool.html` | iframe 加载目标 |
|
||||||
|
|
||||||
|
## 附录 C:老系统数据库表结构
|
||||||
|
|
||||||
|
详见 [02-服务器与数据库配置说明.md](./02-服务器与数据库配置说明.md)
|
||||||
115
docs/09-架构实施/旧版系统集成/03-集成实施总结.md
Normal file
115
docs/09-架构实施/旧版系统集成/03-集成实施总结.md
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
# 旧版系统集成实施总结
|
||||||
|
|
||||||
|
> **日期:** 2026-02-27
|
||||||
|
> **耗时:** 1 天(含 ECS 侦察 + 代码开发 + Bridge 架构升级 + E2E 验证)
|
||||||
|
> **状态:** ✅ 本地 E2E 验证通过,待部署生产环境
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
将老系统(循证医学平台 `www.xunzhengyixue.com`)的两个核心模块——**研究管理**和**统计分析工具**——无缝嵌入新系统(AI 临床研究平台),实现:
|
||||||
|
|
||||||
|
- 用户在新系统内一键访问老系统功能,无需重复登录
|
||||||
|
- 老系统导航栏/页脚隐藏,视觉融入新系统
|
||||||
|
- 用户数据隔离(AAA 用户看不到 BBB 的项目)
|
||||||
|
|
||||||
|
## 2. 最终架构
|
||||||
|
|
||||||
|
```
|
||||||
|
新系统页面(iit.xunzhengyixue.com)
|
||||||
|
│
|
||||||
|
├─ 顶部导航栏:研究管理 | 统计分析工具
|
||||||
|
│
|
||||||
|
└─ 点击后 ─► POST /api/v1/legacy/auth
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
新系统后端:
|
||||||
|
- JWT 解出用户手机号
|
||||||
|
- 连老系统 MySQL,查找/创建用户
|
||||||
|
- 生成 MD5 Token 写入 u_user_token
|
||||||
|
- 返回 { token, nickname, id, userRole }
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
前端构建 Bridge URL ─► iframe 加载
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
token-bridge.html(www.xunzhengyixue.com)
|
||||||
|
- Storage Access API 检查(同站自动授权)
|
||||||
|
- 设置 Cookie(SameSite=None; Secure)
|
||||||
|
- 内嵌 iframe 加载目标页面
|
||||||
|
- 注入自定义 CSS(隐藏导航栏/页脚/清除间距)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
老系统页面正常渲染,用户看到自己的数据
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 代码改动清单
|
||||||
|
|
||||||
|
### 新系统后端(3 个文件)
|
||||||
|
|
||||||
|
| 文件 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `backend/src/modules/legacy-bridge/legacy-auth.service.ts` | 新增 | 连接老系统 MySQL,Token 生成与注入 |
|
||||||
|
| `backend/src/modules/legacy-bridge/routes.ts` | 新增 | `POST /api/v1/legacy/auth` |
|
||||||
|
| `backend/src/modules/legacy-bridge/mysql-pool.ts` | 新增 | MySQL 连接池(懒加载) |
|
||||||
|
| `backend/src/index.ts` | 改动 | 注册 legacy 路由 |
|
||||||
|
| `backend/.env` | 改动 | `LEGACY_MYSQL_*` 配置 |
|
||||||
|
|
||||||
|
### 新系统前端(5 个文件)
|
||||||
|
|
||||||
|
| 文件 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `frontend-v2/src/modules/legacy/LegacySystemPage.tsx` | 新增 | Bridge iframe 容器 |
|
||||||
|
| `frontend-v2/src/modules/legacy/ResearchManagement.tsx` | 新增 | 研究管理入口 |
|
||||||
|
| `frontend-v2/src/modules/legacy/StatisticalTools.tsx` | 新增 | 统计分析工具入口 |
|
||||||
|
| `frontend-v2/src/framework/modules/types.ts` | 改动 | 新增 `legacyUrl` 字段 |
|
||||||
|
| `frontend-v2/src/framework/modules/moduleRegistry.ts` | 改动 | 模块注册改为 iframe 模式 |
|
||||||
|
| `frontend-v2/src/App.tsx` | 改动 | legacy 路由注册 |
|
||||||
|
|
||||||
|
### ECS 服务器(3 个文件)
|
||||||
|
|
||||||
|
| 文件 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `/home/work/www/xunzhengyixue/token-bridge.html` | **新增** | Wrapper Bridge(核心) |
|
||||||
|
| `/home/work/www/xunzhengyixue/static/js/common.js` | 改动 | iframe 检测(已被 bridge CSS 覆盖,保留为双重保障) |
|
||||||
|
| `/home/work/www/xunzhengyixue/tool/js/appajax.js` | 改动 | iframe 检测(已被 bridge CSS 覆盖,保留为双重保障) |
|
||||||
|
|
||||||
|
## 4. 关键技术决策
|
||||||
|
|
||||||
|
| 决策 | 方案 | 原因 |
|
||||||
|
|------|------|------|
|
||||||
|
| 认证方式 | Token 注入(直写 MySQL) | 不依赖老系统 API、不需要用户密码 |
|
||||||
|
| 嵌入方式 | Wrapper Bridge + 内嵌 iframe | 同源 DOM 访问,可注入 CSS |
|
||||||
|
| Cookie 跨域 | Storage Access API + SameSite=None | 解决 localhost 开发环境第三方 Cookie 限制 |
|
||||||
|
| 用户关联 | 手机号匹配 | 两套系统唯一可靠的关联键 |
|
||||||
|
| 样式定制 | Bridge 页面 CSS 注入 | 集中管理,无需改老系统文件 |
|
||||||
|
|
||||||
|
## 5. 踩坑与修复
|
||||||
|
|
||||||
|
| # | 问题 | 修复 |
|
||||||
|
|---|------|------|
|
||||||
|
| 1 | `gen_time` 用 MySQL `NOW()` 导致登录失败 | 改为 `Date.now()`(毫秒时间戳) |
|
||||||
|
| 2 | 隐藏 `#nav-col` 导致研究管理无左侧导航 | 只隐藏 header + footer,不隐藏侧边栏 |
|
||||||
|
| 3 | 工具详情页有独立 JS 不受 `common.js` 控制 | 在 `appajax.js` 也加 iframe 检测 |
|
||||||
|
| 4 | localhost 无法为 `.xunzhengyixue.com` 设 Cookie | 升级为 Wrapper Bridge(同域内设 Cookie) |
|
||||||
|
| 5 | `SameSite=None` 在 Chrome 中仍被拒绝 | 添加 Storage Access API 双重保障 |
|
||||||
|
| 6 | 隐藏导航栏后顶部留白 | CSS 清除 body/page-wrapper 的 padding-top |
|
||||||
|
|
||||||
|
## 6. 验证结果
|
||||||
|
|
||||||
|
| 测试项 | 结果 |
|
||||||
|
|------|------|
|
||||||
|
| Token 注入 + 自动登录 | ✅ |
|
||||||
|
| 研究管理 — 左侧导航 + 内容 | ✅ |
|
||||||
|
| 统计分析工具 — 126 个工具列表 | ✅ |
|
||||||
|
| 工具详情页 — 统计分析 + 出报告 + 下载 | ✅ |
|
||||||
|
| 导航栏/页脚隐藏 + 无顶部间距 | ✅ |
|
||||||
|
| 普通模式自动授权 | ✅ |
|
||||||
|
| 无痕模式 | ❌ 浏览器限制,预期行为 |
|
||||||
|
|
||||||
|
## 7. 后续
|
||||||
|
|
||||||
|
1. 部署新系统到 SAE 生产环境
|
||||||
|
2. 生产端到端验证(同站环境,预期无需 Storage Access API)
|
||||||
|
3. 可选:回退 ECS `common.js` / `appajax.js` 的 iframe 检测改动(bridge CSS 已完全覆盖)
|
||||||
@@ -83,17 +83,21 @@ function App() {
|
|||||||
<Route index element={<HomePage />} />
|
<Route index element={<HomePage />} />
|
||||||
|
|
||||||
{/* 动态加载模块路由 - 基于模块权限系统 ⭐ 2026-01-16 */}
|
{/* 动态加载模块路由 - 基于模块权限系统 ⭐ 2026-01-16 */}
|
||||||
{MODULES.map(module => (
|
{MODULES.filter(m => !m.isExternal).map(module => (
|
||||||
<Route
|
<Route
|
||||||
key={module.id}
|
key={module.id}
|
||||||
path={`${module.path}/*`}
|
path={`${module.path}/*`}
|
||||||
element={
|
element={
|
||||||
|
module.isLegacyEmbed ? (
|
||||||
|
<module.component />
|
||||||
|
) : (
|
||||||
<RouteGuard
|
<RouteGuard
|
||||||
requiredModule={module.moduleCode}
|
requiredModule={module.moduleCode}
|
||||||
moduleName={module.name}
|
moduleName={module.name}
|
||||||
>
|
>
|
||||||
<module.component />
|
<module.component />
|
||||||
</RouteGuard>
|
</RouteGuard>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,18 +1,41 @@
|
|||||||
/**
|
/**
|
||||||
* 带认证的 Axios 实例
|
* 带认证的 Axios 实例
|
||||||
*
|
*
|
||||||
* 自动添加 Authorization header
|
* - 自动添加 Authorization header
|
||||||
|
* - 401 时自动刷新 Token 并重试原请求
|
||||||
|
* - 并发请求在刷新期间排队,刷新完成后统一重试
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import axios from 'axios';
|
import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios';
|
||||||
import { getAccessToken } from '../../framework/auth/api';
|
import {
|
||||||
|
getAccessToken,
|
||||||
|
getRefreshToken,
|
||||||
|
refreshAccessToken,
|
||||||
|
clearTokens,
|
||||||
|
} from '../../framework/auth/api';
|
||||||
|
|
||||||
// 创建 axios 实例
|
|
||||||
const apiClient = axios.create({
|
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(
|
apiClient.interceptors.request.use(
|
||||||
(config) => {
|
(config) => {
|
||||||
const token = getAccessToken();
|
const token = getAccessToken();
|
||||||
@@ -21,45 +44,63 @@ apiClient.interceptors.request.use(
|
|||||||
}
|
}
|
||||||
return config;
|
return config;
|
||||||
},
|
},
|
||||||
(error) => {
|
(error) => Promise.reject(error),
|
||||||
return Promise.reject(error);
|
|
||||||
}
|
|
||||||
);
|
);
|
||||||
|
|
||||||
// 响应拦截器 - 处理 401 错误
|
// ---------- 响应拦截器 ----------
|
||||||
|
|
||||||
apiClient.interceptors.response.use(
|
apiClient.interceptors.response.use(
|
||||||
(response) => response,
|
(response) => response,
|
||||||
(error) => {
|
async (error: AxiosError) => {
|
||||||
if (error.response?.status === 401) {
|
const originalRequest = error.config as InternalAxiosRequestConfig & {
|
||||||
// Token 过期或无效,可以在这里触发登出
|
_retry?: boolean;
|
||||||
console.warn('[API] 认证失败,请重新登录');
|
};
|
||||||
// 可选:跳转到登录页
|
|
||||||
// window.location.href = '/login';
|
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;
|
export default apiClient;
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -74,6 +74,29 @@ export function isTokenExpired(): boolean {
|
|||||||
return Date.now() > Number(expiresAt) - 60000; // 提前1分钟判断为过期
|
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
|
* 创建带认证的fetch
|
||||||
*/
|
*/
|
||||||
@@ -97,7 +120,7 @@ async function authFetch<T>(
|
|||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await safeJsonParse<ApiResponse<T>>(response);
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(data.message || '请求失败');
|
throw new Error(data.message || '请求失败');
|
||||||
@@ -237,7 +260,7 @@ export async function refreshAccessToken(): Promise<TokenInfo> {
|
|||||||
body: JSON.stringify({ refreshToken }),
|
body: JSON.stringify({ refreshToken }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await safeJsonParse<ApiResponse<TokenInfo>>(response);
|
||||||
|
|
||||||
if (!response.ok || !data.success) {
|
if (!response.ok || !data.success) {
|
||||||
clearTokens();
|
clearTokens();
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
// SettingOutlined, // MVP阶段暂时隐藏设置按钮
|
// SettingOutlined, // MVP阶段暂时隐藏设置按钮
|
||||||
ControlOutlined,
|
ControlOutlined,
|
||||||
BankOutlined,
|
BankOutlined,
|
||||||
|
ExportOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import type { MenuProps } from 'antd'
|
import type { MenuProps } from 'antd'
|
||||||
import { MODULES } from '../modules/moduleRegistry'
|
import { MODULES } from '../modules/moduleRegistry'
|
||||||
@@ -28,9 +29,8 @@ const TopNavigation = () => {
|
|||||||
|
|
||||||
// 根据用户模块权限过滤可显示的模块
|
// 根据用户模块权限过滤可显示的模块
|
||||||
const availableModules = MODULES.filter(module => {
|
const availableModules = MODULES.filter(module => {
|
||||||
// 没有 moduleCode 的模块跳过(占位模块)
|
|
||||||
if (!module.moduleCode) return false;
|
if (!module.moduleCode) return false;
|
||||||
// 检查用户是否有权限访问
|
if (module.isExternal || module.isLegacyEmbed) return true;
|
||||||
return hasModule(module.moduleCode);
|
return hasModule(module.moduleCode);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -117,16 +117,23 @@ const TopNavigation = () => {
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={module.id}
|
key={module.id}
|
||||||
onClick={() => navigate(module.path)}
|
onClick={() => {
|
||||||
|
if (module.isExternal && module.externalUrl) {
|
||||||
|
window.open(module.externalUrl, '_blank', 'noopener');
|
||||||
|
} else {
|
||||||
|
navigate(module.path);
|
||||||
|
}
|
||||||
|
}}
|
||||||
className={`
|
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
|
${isActive
|
||||||
? 'bg-blue-50 text-blue-600 font-semibold'
|
? 'bg-blue-50 text-blue-600 font-semibold'
|
||||||
: 'text-gray-600 hover:bg-gray-50 hover:text-blue-600'
|
: 'text-gray-600 hover:bg-gray-50 hover:text-blue-600'
|
||||||
}
|
}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
{module.name}
|
<span>{module.name}</span>
|
||||||
|
{module.isExternal && <ExportOutlined style={{ fontSize: 10 }} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
LineChartOutlined,
|
LineChartOutlined,
|
||||||
AuditOutlined,
|
AuditOutlined,
|
||||||
MedicineBoxOutlined,
|
MedicineBoxOutlined,
|
||||||
|
ProjectOutlined,
|
||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -20,6 +21,7 @@ export const MODULE_CODE_MAP: Record<string, string> = {
|
|||||||
'knowledge-base': 'PKB',
|
'knowledge-base': 'PKB',
|
||||||
'data-cleaning': 'DC',
|
'data-cleaning': 'DC',
|
||||||
'statistical-analysis': 'SSA',
|
'statistical-analysis': 'SSA',
|
||||||
|
'research-management': 'RM',
|
||||||
'statistical-tools': 'ST',
|
'statistical-tools': 'ST',
|
||||||
'review-system': 'RVW',
|
'review-system': 'RVW',
|
||||||
'iit-cra': 'IIT',
|
'iit-cra': 'IIT',
|
||||||
@@ -87,17 +89,31 @@ export const MODULES: ModuleDefinition[] = [
|
|||||||
description: '智能统计分析系统(AI+R统计引擎)',
|
description: '智能统计分析系统(AI+R统计引擎)',
|
||||||
moduleCode: 'SSA', // 后端模块代码
|
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',
|
id: 'statistical-tools',
|
||||||
name: '统计分析工具',
|
name: '统计分析工具',
|
||||||
path: '/statistical-tools',
|
path: '/statistical-tools',
|
||||||
icon: LineChartOutlined,
|
icon: LineChartOutlined,
|
||||||
component: lazy(() => import('@/modules/st')),
|
component: lazy(() => import('@/modules/legacy/StatisticalTools')),
|
||||||
placeholder: true, // Java团队开发,前端集成
|
placeholder: false,
|
||||||
requiredVersion: 'premium',
|
requiredVersion: 'premium',
|
||||||
description: '统计分析工具集(Java团队开发)',
|
description: '统计分析工具集(旧系统 iframe 集成)',
|
||||||
isExternal: true, // 外部模块
|
isLegacyEmbed: true,
|
||||||
moduleCode: 'ST', // 后端模块代码
|
externalUrl: 'https://www.xunzhengyixue.com/tool.html',
|
||||||
|
moduleCode: 'ST',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'review-system',
|
id: 'review-system',
|
||||||
|
|||||||
@@ -55,9 +55,15 @@ export interface ModuleDefinition {
|
|||||||
/** 是否支持独立部署 */
|
/** 是否支持独立部署 */
|
||||||
standalone?: boolean
|
standalone?: boolean
|
||||||
|
|
||||||
/** 是否为外部模块(如Java团队开发) */
|
/** 是否为外部模块(新标签页打开) */
|
||||||
isExternal?: boolean
|
isExternal?: boolean
|
||||||
|
|
||||||
|
/** 是否为旧系统 iframe 内嵌模块 */
|
||||||
|
isLegacyEmbed?: boolean
|
||||||
|
|
||||||
|
/** 外部/旧系统链接地址 */
|
||||||
|
externalUrl?: string
|
||||||
|
|
||||||
/** 模块描述 */
|
/** 模块描述 */
|
||||||
description?: string
|
description?: string
|
||||||
|
|
||||||
|
|||||||
104
frontend-v2/src/modules/legacy/LegacySystemPage.tsx
Normal file
104
frontend-v2/src/modules/legacy/LegacySystemPage.tsx
Normal 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
|
||||||
13
frontend-v2/src/modules/legacy/ResearchManagement.tsx
Normal file
13
frontend-v2/src/modules/legacy/ResearchManagement.tsx
Normal 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
|
||||||
13
frontend-v2/src/modules/legacy/StatisticalTools.tsx
Normal file
13
frontend-v2/src/modules/legacy/StatisticalTools.tsx
Normal 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
|
||||||
Reference in New Issue
Block a user