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 ) . '"'; } }