forked from lunanting/cangku
421 lines
12 KiB
JavaScript
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,
|
|
};
|