CaiWuXiTong/app/Utilities/SystemDatabaseConfiguration.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
) . '"';
}
}