const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const bcrypt = require('bcryptjs'); const mysql = require('mysql2/promise'); const dotenv = require('dotenv'); const DEFAULT_SITE_CONFIG = { siteName: '拼多多自动化运营平台', siteDescription: '首次安装向导', }; const ENV_KEYS = { localDbType: 'LOCAL_DB_TYPE', localDbHost: 'LOCAL_DB_HOST', localDbPort: 'LOCAL_DB_PORT', localDbName: 'LOCAL_DB_NAME', localDbUser: 'LOCAL_DB_USER', localDbPassword: 'LOCAL_DB_PASSWORD', cubeSecret: 'CUBEJS_SECRET', cubeApiSecret: 'CUBEJS_API_SECRET', }; function getEnvFilePath() { return path.resolve(__dirname, '../../.env'); } function readEnvFile() { const envFilePath = getEnvFilePath(); if (!fs.existsSync(envFilePath)) { return { envFilePath, content: '', parsed: {} }; } const content = fs.readFileSync(envFilePath, 'utf8'); return { envFilePath, content, parsed: dotenv.parse(content), }; } function serializeEnvValue(value) { const stringValue = String(value ?? ''); if (!stringValue) { return ''; } if (/[\s#"']/u.test(stringValue)) { return JSON.stringify(stringValue); } return stringValue; } function writeEnvValues(updates) { const { envFilePath, content, parsed } = readEnvFile(); const nextEnv = { ...parsed, ...updates, }; const lines = content ? content.split(/\r?\n/) : []; const handledKeys = new Set(); const nextLines = lines.map((line) => { const match = line.match(/^([A-Z0-9_]+)=(.*)$/); if (!match) { return line; } const [, key] = match; if (!(key in updates)) { return line; } handledKeys.add(key); return `${key}=${serializeEnvValue(nextEnv[key])}`; }); for (const [key, value] of Object.entries(updates)) { if (!handledKeys.has(key)) { nextLines.push(`${key}=${serializeEnvValue(value)}`); } } const output = `${nextLines.filter((line, index, array) => !(line === '' && index === array.length - 1)).join('\n')}\n`; fs.writeFileSync(envFilePath, output, 'utf8'); for (const [key, value] of Object.entries(updates)) { process.env[key] = String(value ?? ''); } } function generateSecret() { return crypto.randomBytes(32).toString('hex'); } function normalizeSystemDbConfig(source = process.env) { return { host: source[ENV_KEYS.localDbHost] || '', port: Number(source[ENV_KEYS.localDbPort] || 3306), database: source[ENV_KEYS.localDbName] || '', username: source[ENV_KEYS.localDbUser] || '', password: source[ENV_KEYS.localDbPassword] || '', }; } function hasSystemDbEnv(config = normalizeSystemDbConfig()) { return Boolean(config.host && config.database && config.username); } function assertSafeDatabaseName(database) { if (!/^[A-Za-z0-9_]+$/u.test(database || '')) { throw new Error('数据库名仅支持字母、数字和下划线'); } } async function probeSystemDatabase(config = normalizeSystemDbConfig()) { if (!hasSystemDbEnv(config)) { return { envConfigured: false, reachable: false, initialized: false, tableCount: 0, reason: '系统数据库尚未配置', }; } let connection; try { connection = await mysql.createConnection({ host: config.host, port: Number(config.port || 3306), user: config.username, password: config.password, database: config.database, charset: 'utf8mb4', connectTimeout: 5000, }); const [tables] = await connection.query( `SELECT table_name FROM information_schema.tables WHERE table_schema = ? AND table_name IN ('users', 'system_config') ORDER BY table_name`, [config.database] ); const [tableCountRows] = await connection.query( `SELECT COUNT(*) AS tableCount FROM information_schema.tables WHERE table_schema = ?`, [config.database] ); return { envConfigured: true, reachable: true, initialized: tables.length === 2, tableCount: Number(tableCountRows[0]?.tableCount || 0), reason: tables.length === 2 ? '系统数据库已就绪' : '系统数据库尚未初始化', }; } catch (error) { return { envConfigured: true, reachable: false, initialized: false, tableCount: 0, reason: error.message, }; } finally { if (connection) { await connection.end(); } } } async function getInstallStatus() { const config = normalizeSystemDbConfig(); const databaseStatus = await probeSystemDatabase(config); const required = !databaseStatus.envConfigured || (databaseStatus.reachable && !databaseStatus.initialized); return { // If a configured database is temporarily unreachable, the system is not // "uninstalled"; exposing the install wizard in that state risks data loss. required, databaseUnavailable: databaseStatus.envConfigured && !databaseStatus.reachable, systemDatabase: { ...databaseStatus, defaults: { host: config.host, port: config.port, database: config.database, username: config.username, hasPassword: Boolean(config.password), }, }, appSecrets: { hasCubeSecret: Boolean(process.env[ENV_KEYS.cubeSecret]), hasCubeApiSecret: Boolean(process.env[ENV_KEYS.cubeApiSecret]), }, }; } async function testSystemDatabaseBootstrapConnection(input) { const config = { host: (input.host || '').trim(), port: Number(input.port || 3306), database: (input.database || '').trim(), username: (input.username || '').trim(), password: input.password || '', }; if (!config.host || !config.database || !config.username) { throw new Error('请填写系统数据库主机、数据库名和用户名'); } assertSafeDatabaseName(config.database); let connection; try { connection = await mysql.createConnection({ host: config.host, port: config.port, user: config.username, password: config.password, charset: 'utf8mb4', connectTimeout: 10000, }); await connection.query('SELECT 1'); const [dbRows] = await connection.query( `SELECT COUNT(*) AS dbCount FROM information_schema.schemata WHERE schema_name = ?`, [config.database] ); const databaseExists = Number(dbRows[0]?.dbCount || 0) > 0; let tableCount = 0; if (databaseExists) { const [tableRows] = await connection.query( `SELECT COUNT(*) AS tableCount FROM information_schema.tables WHERE table_schema = ?`, [config.database] ); tableCount = Number(tableRows[0]?.tableCount || 0); } return { databaseExists, tableCount, }; } finally { if (connection) { await connection.end(); } } } function buildDatabaseScript(scriptContent, databaseName) { return scriptContent.replace(/shujuku2_system/g, databaseName); } async function executeSqlScript(connection, scriptPath, databaseName) { const content = fs.readFileSync(scriptPath, 'utf8'); const script = buildDatabaseScript(content, databaseName); await connection.query(script); } async function bootstrapInstallation(input) { const dbConfig = { host: (input?.systemDatabase?.host || '').trim(), port: Number(input?.systemDatabase?.port || 3306), database: (input?.systemDatabase?.database || '').trim(), username: (input?.systemDatabase?.username || '').trim(), password: input?.systemDatabase?.password || '', }; const admin = { username: (input?.admin?.username || '').trim(), password: input?.admin?.password || '', name: (input?.admin?.name || '').trim(), email: (input?.admin?.email || '').trim(), }; const site = { siteName: (input?.site?.siteName || DEFAULT_SITE_CONFIG.siteName).trim(), siteDescription: (input?.site?.siteDescription || '').trim(), }; if (!dbConfig.host || !dbConfig.database || !dbConfig.username) { throw new Error('请完整填写系统数据库配置'); } if (!admin.username || !admin.password || !admin.name || !admin.email) { throw new Error('请完整填写管理员账号信息'); } if (admin.password.length < 8) { throw new Error('管理员密码至少需要 8 位'); } assertSafeDatabaseName(dbConfig.database); const connectionCheck = await testSystemDatabaseBootstrapConnection(dbConfig); if (connectionCheck.databaseExists && connectionCheck.tableCount > 0) { throw new Error('目标系统数据库不是空库,请改用新库或清理后再安装'); } let serverConnection; let databaseConnection; try { serverConnection = await mysql.createConnection({ host: dbConfig.host, port: dbConfig.port, user: dbConfig.username, password: dbConfig.password, charset: 'utf8mb4', connectTimeout: 10000, multipleStatements: true, }); await executeSqlScript( serverConnection, path.resolve(__dirname, '../../database/init.sql'), dbConfig.database ); await executeSqlScript( serverConnection, path.resolve(__dirname, '../../database/summary_tables.sql'), dbConfig.database ); databaseConnection = await mysql.createConnection({ host: dbConfig.host, port: dbConfig.port, user: dbConfig.username, password: dbConfig.password, database: dbConfig.database, charset: 'utf8mb4', connectTimeout: 10000, }); const passwordHash = await bcrypt.hash(admin.password, 10); await databaseConnection.query( `UPDATE users SET username = ?, password_hash = ?, name = ?, email = ?, role = 'admin', status = 'active' WHERE username = 'admin' LIMIT 1`, [admin.username, passwordHash, admin.name, admin.email] ); await databaseConnection.query( `UPDATE system_config SET config_value = ? WHERE config_key = 'site_name'`, [site.siteName || DEFAULT_SITE_CONFIG.siteName] ); await databaseConnection.query( `UPDATE system_config SET config_value = ? WHERE config_key = 'site_description'`, [site.siteDescription || '数据分析平台'] ); await databaseConnection.query( `UPDATE system_config SET config_value = 'false' WHERE config_key = 'setup_completed'` ); await databaseConnection.query( `UPDATE system_config SET config_value = '' WHERE config_key = 'setup_completed_at'` ); const cubeSecret = process.env[ENV_KEYS.cubeSecret] || generateSecret(); const cubeApiSecret = process.env[ENV_KEYS.cubeApiSecret] || generateSecret(); writeEnvValues({ [ENV_KEYS.localDbType]: 'mysql', [ENV_KEYS.localDbHost]: dbConfig.host, [ENV_KEYS.localDbPort]: String(dbConfig.port), [ENV_KEYS.localDbName]: dbConfig.database, [ENV_KEYS.localDbUser]: dbConfig.username, [ENV_KEYS.localDbPassword]: dbConfig.password, [ENV_KEYS.cubeSecret]: cubeSecret, [ENV_KEYS.cubeApiSecret]: cubeApiSecret, }); return { siteName: site.siteName || DEFAULT_SITE_CONFIG.siteName, adminUsername: admin.username, systemDatabase: { host: dbConfig.host, port: dbConfig.port, database: dbConfig.database, username: dbConfig.username, }, }; } finally { if (databaseConnection) { await databaseConnection.end(); } if (serverConnection) { await serverConnection.end(); } } } function shouldAutoRestartAfterInstall() { return Boolean(process.env.pm_id || process.env.PM2_HOME); } module.exports = { DEFAULT_SITE_CONFIG, bootstrapInstallation, getInstallStatus, normalizeSystemDbConfig, probeSystemDatabase, shouldAutoRestartAfterInstall, testSystemDatabaseBootstrapConnection, };