198 lines
7.1 KiB
PHP
198 lines
7.1 KiB
PHP
<?php
|
|
|
|
namespace App\Utilities;
|
|
|
|
use Illuminate\Support\Facades\Artisan;
|
|
use PDO;
|
|
use RuntimeException;
|
|
use Throwable;
|
|
|
|
class SystemDatabaseConfiguration
|
|
{
|
|
private string $environmentPath;
|
|
|
|
public function __construct(?string $environmentPath = null)
|
|
{
|
|
$this->environmentPath = $environmentPath ?: base_path('.env');
|
|
}
|
|
|
|
public function current(): array
|
|
{
|
|
$connectionName = (string) config('database.default', 'mysql');
|
|
$connection = (array) config('database.connections.' . $connectionName, []);
|
|
$options = (array) ($connection['options'] ?? []);
|
|
|
|
return [
|
|
'connection' => $connectionName,
|
|
'driver' => (string) ($connection['driver'] ?? $connectionName),
|
|
'host' => (string) (
|
|
data_get($connection, 'write.host.0')
|
|
?? data_get($connection, 'read.host.0')
|
|
?? ($connection['host'] ?? '')
|
|
),
|
|
'port' => (int) ($connection['port'] ?? 3306),
|
|
'database' => (string) ($connection['database'] ?? ''),
|
|
'username' => (string) ($connection['username'] ?? ''),
|
|
'password' => (string) ($connection['password'] ?? ''),
|
|
'prefix' => (string) ($connection['prefix'] ?? ''),
|
|
'tls_required' => isset($options[PDO::MYSQL_ATTR_SSL_CA])
|
|
|| isset($options[PDO::MYSQL_ATTR_SSL_CIPHER]),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{cipher:string,user_count:int}
|
|
*/
|
|
public function probe(array $settings): array
|
|
{
|
|
if (($settings['driver'] ?? 'mysql') !== 'mysql') {
|
|
throw new RuntimeException('系统数据库目前只支持 MariaDB/MySQL。');
|
|
}
|
|
|
|
$options = [
|
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
|
PDO::ATTR_TIMEOUT => 8,
|
|
];
|
|
|
|
if (! empty($settings['tls_required'])) {
|
|
$options[PDO::MYSQL_ATTR_SSL_CIPHER] = 'DEFAULT';
|
|
|
|
if (defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')) {
|
|
$options[constant('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')] = false;
|
|
}
|
|
}
|
|
|
|
try {
|
|
$pdo = new PDO(
|
|
sprintf(
|
|
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
|
|
$settings['host'],
|
|
$settings['port'],
|
|
$settings['database']
|
|
),
|
|
$settings['username'],
|
|
$settings['password'],
|
|
$options
|
|
);
|
|
|
|
$cipher = (string) ($pdo
|
|
->query("SHOW SESSION STATUS LIKE 'Ssl_cipher'")
|
|
->fetch(PDO::FETCH_NUM)[1] ?? '');
|
|
|
|
if (! empty($settings['tls_required']) && $cipher === '') {
|
|
throw new RuntimeException('数据库连接没有启用 TLS 加密。');
|
|
}
|
|
|
|
$prefix = $this->current()['prefix'];
|
|
$requiredTables = array_map(
|
|
fn (string $table) => $prefix . $table,
|
|
['migrations', 'users', 'roles', 'user_roles', 'user_companies', 'settings']
|
|
);
|
|
$placeholders = implode(',', array_fill(0, count($requiredTables), '?'));
|
|
$statement = $pdo->prepare(
|
|
"SELECT COUNT(*) FROM information_schema.tables "
|
|
. "WHERE table_schema = DATABASE() AND table_name IN ($placeholders)"
|
|
);
|
|
$statement->execute($requiredTables);
|
|
|
|
if ((int) $statement->fetchColumn() !== count($requiredTables)) {
|
|
throw new RuntimeException('目标库缺少系统必需表,已拒绝切换。');
|
|
}
|
|
|
|
$userCount = (int) $pdo->query(
|
|
'SELECT COUNT(*) FROM `' . str_replace('`', '``', $prefix . 'users') . '`'
|
|
)->fetchColumn();
|
|
|
|
if ($userCount < 1) {
|
|
throw new RuntimeException('目标库没有任何用户,已拒绝切换。');
|
|
}
|
|
|
|
return ['cipher' => $cipher, 'user_count' => $userCount];
|
|
} catch (RuntimeException $exception) {
|
|
throw $exception;
|
|
} catch (Throwable $exception) {
|
|
throw new RuntimeException('系统数据库连接测试失败。', 0, $exception);
|
|
}
|
|
}
|
|
|
|
public function persist(array $settings): string
|
|
{
|
|
if (! is_file($this->environmentPath) || ! is_writable($this->environmentPath)) {
|
|
throw new RuntimeException('服务器私有启动配置不可写,未执行切换。');
|
|
}
|
|
|
|
$current = file_get_contents($this->environmentPath);
|
|
|
|
if ($current === false) {
|
|
throw new RuntimeException('无法读取服务器私有启动配置。');
|
|
}
|
|
|
|
$backupDirectory = storage_path('app/system-database-config-backups');
|
|
|
|
if (! is_dir($backupDirectory) && ! mkdir($backupDirectory, 0700, true) && ! is_dir($backupDirectory)) {
|
|
throw new RuntimeException('无法创建系统数据库配置备份目录。');
|
|
}
|
|
|
|
$backupPath = $backupDirectory . DIRECTORY_SEPARATOR . 'runtime-env-' . now()->format('Ymd-His') . '.backup';
|
|
|
|
if (file_put_contents($backupPath, $current, LOCK_EX) === false) {
|
|
throw new RuntimeException('系统数据库配置备份失败,未执行切换。');
|
|
}
|
|
|
|
chmod($backupPath, 0600);
|
|
|
|
$updated = $this->replaceEnvironmentValues($current, [
|
|
'DB_CONNECTION' => 'mysql',
|
|
'DB_HOST' => (string) $settings['host'],
|
|
'DB_PORT' => (string) $settings['port'],
|
|
'DB_DATABASE' => (string) $settings['database'],
|
|
'DB_USERNAME' => (string) $settings['username'],
|
|
'DB_PASSWORD' => (string) $settings['password'],
|
|
'MYSQL_ATTR_SSL_CIPHER' => ! empty($settings['tls_required']) ? 'DEFAULT' : '',
|
|
'MYSQL_ATTR_SSL_VERIFY_SERVER_CERT' => 'false',
|
|
]);
|
|
|
|
if (file_put_contents($this->environmentPath, $updated, LOCK_EX) === false) {
|
|
file_put_contents($this->environmentPath, $current, LOCK_EX);
|
|
throw new RuntimeException('系统数据库配置保存失败,已恢复原配置。');
|
|
}
|
|
|
|
Artisan::call('config:clear');
|
|
|
|
return $backupPath;
|
|
}
|
|
|
|
private function replaceEnvironmentValues(string $environment, array $values): string
|
|
{
|
|
$lines = preg_split('/\r\n|\r|\n/', $environment) ?: [];
|
|
|
|
foreach ($values as $key => $value) {
|
|
$replacement = $key . '=' . $this->encodeEnvironmentValue((string) $value);
|
|
$found = false;
|
|
|
|
foreach ($lines as $index => $line) {
|
|
if (preg_match('/^' . preg_quote($key, '/') . '=/', $line) === 1) {
|
|
$lines[$index] = $replacement;
|
|
$found = true;
|
|
break;
|
|
}
|
|
}
|
|
|
|
if (! $found) {
|
|
$lines[] = $replacement;
|
|
}
|
|
}
|
|
|
|
return rtrim(implode("\n", $lines), "\n") . "\n";
|
|
}
|
|
|
|
private function encodeEnvironmentValue(string $value): string
|
|
{
|
|
return '"' . str_replace(
|
|
['\\', '"', '$', "\r", "\n"],
|
|
['\\\\', '\\"', '\\$', '', '\\n'],
|
|
$value
|
|
) . '"';
|
|
}
|
|
}
|