cangku/backend/src/services/installService.js

421 lines
12 KiB
JavaScript

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,
};