diff --git a/backend/package-lock.json b/backend/package-lock.json index 787642a1..413bafde 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -33,6 +33,7 @@ "jsonrepair": "^3.13.1", "jsonwebtoken": "^9.0.2", "jspdf": "^3.0.3", + "mysql2": "^3.18.2", "openai": "^6.16.0", "p-queue": "^9.0.1", "pg-boss": "^12.5.2", @@ -1458,6 +1459,15 @@ "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": { "version": "1.12.2", "resolved": "https://registry.npmmirror.com/axios/-/axios-1.12.2.tgz", @@ -2902,6 +2912,15 @@ "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": { "version": "1.3.0", "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3302,6 +3321,12 @@ "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": { "version": "2.0.1", "resolved": "https://registry.npmmirror.com/is-stream/-/is-stream-2.0.1.tgz", @@ -3766,6 +3791,27 @@ "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": { "version": "3.7.2", "resolved": "https://registry.npmmirror.com/luxon/-/luxon-3.7.2.tgz", @@ -3938,6 +3984,44 @@ "@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": { "version": "2.7.0", "resolved": "https://registry.npmmirror.com/mz/-/mz-2.7.0.tgz", @@ -3949,6 +4033,18 @@ "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": { "version": "2.0.0", "resolved": "https://registry.npmmirror.com/napi-build-utils/-/napi-build-utils-2.0.0.tgz", @@ -5180,6 +5276,21 @@ "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": { "version": "0.11.2", "resolved": "https://registry.npmmirror.com/ssf/-/ssf-0.11.2.tgz", diff --git a/backend/package.json b/backend/package.json index 894535b8..d647b2da 100644 --- a/backend/package.json +++ b/backend/package.json @@ -50,6 +50,7 @@ "jsonrepair": "^3.13.1", "jsonwebtoken": "^9.0.2", "jspdf": "^3.0.3", + "mysql2": "^3.18.2", "openai": "^6.16.0", "p-queue": "^9.0.1", "pg-boss": "^12.5.2", diff --git a/backend/scripts/test-legacy-auth.ts b/backend/scripts/test-legacy-auth.ts new file mode 100644 index 00000000..98f068f0 --- /dev/null +++ b/backend/scripts/test-legacy-auth.ts @@ -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 + * 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 '); + 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( + '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( + '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( + '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); +}); diff --git a/backend/src/index.ts b/backend/src/index.ts index afc14835..a0e44b31 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -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) { diff --git a/backend/src/modules/legacy-bridge/legacy-auth.service.ts b/backend/src/modules/legacy-bridge/legacy-auth.service.ts new file mode 100644 index 00000000..8926147a --- /dev/null +++ b/backend/src/modules/legacy-bridge/legacy-auth.service.ts @@ -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 { + const pool = getLegacyMysqlPool(); + const [rows] = await pool.execute( + '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 { + const pool = getLegacyMysqlPool(); + const [result] = await pool.execute( + `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 { + 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 { + 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, + }; +} diff --git a/backend/src/modules/legacy-bridge/mysql-pool.ts b/backend/src/modules/legacy-bridge/mysql-pool.ts new file mode 100644 index 00000000..4a87c881 --- /dev/null +++ b/backend/src/modules/legacy-bridge/mysql-pool.ts @@ -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 { + if (pool) { + await pool.end(); + pool = null; + logger.info('Legacy MySQL pool closed'); + } +} diff --git a/backend/src/modules/legacy-bridge/routes.ts b/backend/src/modules/legacy-bridge/routes.ts new file mode 100644 index 00000000..8c854e09 --- /dev/null +++ b/backend/src/modules/legacy-bridge/routes.ts @@ -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 { + /** + * 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: '旧系统认证失败,请稍后重试', + }); + } + }); +} diff --git a/backend/src/modules/legacy-bridge/token-bridge.html b/backend/src/modules/legacy-bridge/token-bridge.html new file mode 100644 index 00000000..0e0f9934 --- /dev/null +++ b/backend/src/modules/legacy-bridge/token-bridge.html @@ -0,0 +1,120 @@ + + + + + +加载中... + + + +
+
+

正在连接...

+ +
+ + + + diff --git a/docs/00-系统总体设计/00-系统当前状态与开发指南.md b/docs/00-系统总体设计/00-系统当前状态与开发指南.md index 6660897f..2b2037fb 100644 --- a/docs/00-系统总体设计/00-系统当前状态与开发指南.md +++ b/docs/00-系统总体设计/00-系统当前状态与开发指南.md @@ -1,10 +1,11 @@ # AIclinicalresearch 系统当前状态与开发指南 -> **文档版本:** v6.4 +> **文档版本:** v6.5 > **创建日期:** 2025-11-28 > **维护者:** 开发团队 > **最后更新:** 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-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 通过 @@ -31,7 +32,8 @@ > - **2026-01-24:Protocol Agent 框架完成!** 可复用Agent框架+5阶段对话流程 > - **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-数据库/` > - ✅ **🆕 Prisma Schema 类型漂移修正** — IIT/SSA 模型 @db.* 注解对齐 + 手动迁移 + Tech Debt 注释 > - ✅ **🆕 部署文档体系整理** — 归档 2025 旧文档 + 统一操作手册 + 待部署变更清单 + README 重写 @@ -81,7 +83,7 @@ | **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** | | **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 | | **ADMIN** | 运营管理端 | Prompt管理、租户管理、用户管理、运营监控、系统知识库 | ⭐⭐⭐⭐⭐ | 🎉 **Phase 4.6完成(88%)** - Prompt知识库集成+动态注入 | **P0** | diff --git a/docs/09-架构实施/旧版系统集成/01-新旧系统集成方案.md b/docs/09-架构实施/旧版系统集成/01-新旧系统集成方案.md new file mode 100644 index 00000000..8adf8d3a --- /dev/null +++ b/docs/09-架构实施/旧版系统集成/01-新旧系统集成方案.md @@ -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) diff --git a/docs/08-项目管理/旧版本系统/服务器与数据库配置说明.md b/docs/09-架构实施/旧版系统集成/02-服务器与数据库配置说明.md similarity index 100% rename from docs/08-项目管理/旧版本系统/服务器与数据库配置说明.md rename to docs/09-架构实施/旧版系统集成/02-服务器与数据库配置说明.md diff --git a/docs/09-架构实施/旧版系统集成/03-集成实施总结.md b/docs/09-架构实施/旧版系统集成/03-集成实施总结.md new file mode 100644 index 00000000..42db2854 --- /dev/null +++ b/docs/09-架构实施/旧版系统集成/03-集成实施总结.md @@ -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 已完全覆盖) diff --git a/frontend-v2/src/App.tsx b/frontend-v2/src/App.tsx index 6c542a17..7a7f8550 100644 --- a/frontend-v2/src/App.tsx +++ b/frontend-v2/src/App.tsx @@ -83,17 +83,21 @@ function App() { } /> {/* 动态加载模块路由 - 基于模块权限系统 ⭐ 2026-01-16 */} - {MODULES.map(module => ( + {MODULES.filter(m => !m.isExternal).map(module => ( + module.isLegacyEmbed ? ( - + ) : ( + + + + ) } /> ))} diff --git a/frontend-v2/src/common/api/axios.ts b/frontend-v2/src/common/api/axios.ts index f6bbe49a..95ce39c8 100644 --- a/frontend-v2/src/common/api/axios.ts +++ b/frontend-v2/src/common/api/axios.ts @@ -1,18 +1,41 @@ /** * 带认证的 Axios 实例 - * - * 自动添加 Authorization header + * + * - 自动添加 Authorization header + * - 401 时自动刷新 Token 并重试原请求 + * - 并发请求在刷新期间排队,刷新完成后统一重试 */ -import axios from 'axios'; -import { getAccessToken } from '../../framework/auth/api'; +import axios, { AxiosError, InternalAxiosRequestConfig } from 'axios'; +import { + getAccessToken, + getRefreshToken, + refreshAccessToken, + clearTokens, +} from '../../framework/auth/api'; -// 创建 axios 实例 const apiClient = axios.create({ - timeout: 60000, // 60秒超时 + timeout: 60000, }); -// 请求拦截器 - 自动添加 Authorization header +// ---------- Token 刷新状态机 ---------- + +let isRefreshing = false; +let pendingQueue: { + resolve: (token: string) => void; + reject: (err: unknown) => void; +}[] = []; + +function processPendingQueue(error: unknown, token: string | null) { + pendingQueue.forEach(({ resolve, reject }) => { + if (token) resolve(token); + else reject(error); + }); + pendingQueue = []; +} + +// ---------- 请求拦截器 ---------- + apiClient.interceptors.request.use( (config) => { const token = getAccessToken(); @@ -21,45 +44,63 @@ apiClient.interceptors.request.use( } return config; }, - (error) => { - return Promise.reject(error); - } + (error) => Promise.reject(error), ); -// 响应拦截器 - 处理 401 错误 +// ---------- 响应拦截器 ---------- + apiClient.interceptors.response.use( (response) => response, - (error) => { - if (error.response?.status === 401) { - // Token 过期或无效,可以在这里触发登出 - console.warn('[API] 认证失败,请重新登录'); - // 可选:跳转到登录页 - // window.location.href = '/login'; + async (error: AxiosError) => { + const originalRequest = error.config as InternalAxiosRequestConfig & { + _retry?: boolean; + }; + + if (!originalRequest) return Promise.reject(error); + + const is401 = error.response?.status === 401; + const hasRefreshToken = !!getRefreshToken(); + const alreadyRetried = originalRequest._retry; + + if (!is401 || !hasRefreshToken || alreadyRetried) { + if (is401 && !hasRefreshToken) { + clearTokens(); + window.location.href = '/login'; + } + return Promise.reject(error); } - return Promise.reject(error); - } + + // First 401 caller triggers the refresh; others queue up + if (isRefreshing) { + return new Promise((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; - - - - - - - - - - - - - - - - - - - - - - diff --git a/frontend-v2/src/framework/auth/api.ts b/frontend-v2/src/framework/auth/api.ts index 34f4a61b..426d7ec5 100644 --- a/frontend-v2/src/framework/auth/api.ts +++ b/frontend-v2/src/framework/auth/api.ts @@ -74,6 +74,29 @@ export function isTokenExpired(): boolean { return Date.now() > Number(expiresAt) - 60000; // 提前1分钟判断为过期 } +/** + * 安全地解析 JSON 响应;当服务端返回 HTML(如 Nginx 错误页)时给出明确提示而非 crash + */ +async function safeJsonParse(response: Response): Promise { + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + const text = await response.text(); + if (text.trimStart().startsWith('<')) { + throw new Error( + response.status >= 500 + ? '服务器暂时不可用,请稍后重试' + : `请求失败 (${response.status})`, + ); + } + try { + return JSON.parse(text); + } catch { + throw new Error(`请求失败 (${response.status})`); + } + } + return response.json(); +} + /** * 创建带认证的fetch */ @@ -97,7 +120,7 @@ async function authFetch( headers, }); - const data = await response.json(); + const data = await safeJsonParse>(response); if (!response.ok) { throw new Error(data.message || '请求失败'); @@ -237,7 +260,7 @@ export async function refreshAccessToken(): Promise { body: JSON.stringify({ refreshToken }), }); - const data = await response.json(); + const data = await safeJsonParse>(response); if (!response.ok || !data.success) { clearTokens(); diff --git a/frontend-v2/src/framework/layout/TopNavigation.tsx b/frontend-v2/src/framework/layout/TopNavigation.tsx index 38cad9af..2382cc29 100644 --- a/frontend-v2/src/framework/layout/TopNavigation.tsx +++ b/frontend-v2/src/framework/layout/TopNavigation.tsx @@ -6,6 +6,7 @@ import { // SettingOutlined, // MVP阶段暂时隐藏设置按钮 ControlOutlined, BankOutlined, + ExportOutlined, } from '@ant-design/icons' import type { MenuProps } from 'antd' import { MODULES } from '../modules/moduleRegistry' @@ -28,9 +29,8 @@ const TopNavigation = () => { // 根据用户模块权限过滤可显示的模块 const availableModules = MODULES.filter(module => { - // 没有 moduleCode 的模块跳过(占位模块) if (!module.moduleCode) return false; - // 检查用户是否有权限访问 + if (module.isExternal || module.isLegacyEmbed) return true; return hasModule(module.moduleCode); }); @@ -117,16 +117,23 @@ const TopNavigation = () => { return (
navigate(module.path)} + onClick={() => { + if (module.isExternal && module.externalUrl) { + window.open(module.externalUrl, '_blank', 'noopener'); + } else { + navigate(module.path); + } + }} className={` - px-4 py-2 rounded-md transition-all cursor-pointer + px-4 py-2 rounded-md transition-all cursor-pointer flex items-center gap-1 ${isActive ? 'bg-blue-50 text-blue-600 font-semibold' : 'text-gray-600 hover:bg-gray-50 hover:text-blue-600' } `} > - {module.name} + {module.name} + {module.isExternal && }
) })} diff --git a/frontend-v2/src/framework/modules/moduleRegistry.ts b/frontend-v2/src/framework/modules/moduleRegistry.ts index d3e631a4..14605870 100644 --- a/frontend-v2/src/framework/modules/moduleRegistry.ts +++ b/frontend-v2/src/framework/modules/moduleRegistry.ts @@ -9,6 +9,7 @@ import { LineChartOutlined, AuditOutlined, MedicineBoxOutlined, + ProjectOutlined, } from '@ant-design/icons' /** @@ -20,6 +21,7 @@ export const MODULE_CODE_MAP: Record = { 'knowledge-base': 'PKB', 'data-cleaning': 'DC', 'statistical-analysis': 'SSA', + 'research-management': 'RM', 'statistical-tools': 'ST', 'review-system': 'RVW', 'iit-cra': 'IIT', @@ -87,17 +89,31 @@ export const MODULES: ModuleDefinition[] = [ description: '智能统计分析系统(AI+R统计引擎)', moduleCode: 'SSA', // 后端模块代码 }, + { + id: 'research-management', + name: '研究管理', + path: '/research-management', + icon: ProjectOutlined, + component: lazy(() => import('@/modules/legacy/ResearchManagement')), + placeholder: false, + requiredVersion: 'premium', + description: '研究项目管理系统(旧系统 iframe 集成)', + isLegacyEmbed: true, + externalUrl: 'https://www.xunzhengyixue.com/index.html', + moduleCode: 'RM', + }, { id: 'statistical-tools', name: '统计分析工具', path: '/statistical-tools', icon: LineChartOutlined, - component: lazy(() => import('@/modules/st')), - placeholder: true, // Java团队开发,前端集成 + component: lazy(() => import('@/modules/legacy/StatisticalTools')), + placeholder: false, requiredVersion: 'premium', - description: '统计分析工具集(Java团队开发)', - isExternal: true, // 外部模块 - moduleCode: 'ST', // 后端模块代码 + description: '统计分析工具集(旧系统 iframe 集成)', + isLegacyEmbed: true, + externalUrl: 'https://www.xunzhengyixue.com/tool.html', + moduleCode: 'ST', }, { id: 'review-system', diff --git a/frontend-v2/src/framework/modules/types.ts b/frontend-v2/src/framework/modules/types.ts index 15f4d9b9..d5db2cb0 100644 --- a/frontend-v2/src/framework/modules/types.ts +++ b/frontend-v2/src/framework/modules/types.ts @@ -55,8 +55,14 @@ export interface ModuleDefinition { /** 是否支持独立部署 */ standalone?: boolean - /** 是否为外部模块(如Java团队开发) */ + /** 是否为外部模块(新标签页打开) */ isExternal?: boolean + + /** 是否为旧系统 iframe 内嵌模块 */ + isLegacyEmbed?: boolean + + /** 外部/旧系统链接地址 */ + externalUrl?: string /** 模块描述 */ description?: string diff --git a/frontend-v2/src/modules/legacy/LegacySystemPage.tsx b/frontend-v2/src/modules/legacy/LegacySystemPage.tsx new file mode 100644 index 00000000..dacaee50 --- /dev/null +++ b/frontend-v2/src/modules/legacy/LegacySystemPage.tsx @@ -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 = ({ targetUrl }) => { + const [status, setStatus] = useState<'authenticating' | 'ready' | 'error'>('authenticating') + const [bridgeUrl, setBridgeUrl] = useState('') + const [errorMsg, setErrorMsg] = useState('') + const iframeRef = useRef(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 ( +
+ } /> + 正在连接旧系统... +
+ ) + } + + if (status === 'error') { + return ( +
+ ⚠️ + {errorMsg} + +
+ ) + } + + return ( +