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,prefix:string} */ 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 加密。'); } $tableNames = $pdo->query( 'SELECT table_name FROM information_schema.tables ' . 'WHERE table_schema = DATABASE()' )->fetchAll(PDO::FETCH_COLUMN); $prefix = $this->detectTablePrefix( array_map('strval', $tableNames), $this->current()['prefix'] ); $userCount = (int) $pdo->query( 'SELECT COUNT(*) FROM `' . str_replace('`', '``', $prefix . 'users') . '`' )->fetchColumn(); if ($userCount < 1) { throw new RuntimeException('目标库没有任何用户,已拒绝切换。'); } return ['cipher' => $cipher, 'user_count' => $userCount, 'prefix' => $prefix]; } 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); $prefix = array_key_exists('prefix', $settings) ? (string) $settings['prefix'] : $this->current()['prefix']; $this->assertValidPrefix($prefix); $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'], 'DB_PREFIX' => $prefix, 'MYSQL_ATTR_SSL_CIPHER' => ! empty($settings['tls_required']) ? 'DEFAULT' : '', 'MYSQL_ATTR_SSL_VERIFY_SERVER_CERT' => 'false', // 系统数据库保存后统一读取共享云数据库配置,不再要求开发机 // 另外维护完整的 DB__* 连接参数。 'UPSTREAM_STORED_CONFIG_ENABLED' => 'true', 'UPSTREAM_LOCAL_CONFIG_ENABLED' => '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; } /** * 根据一组完整的系统必需表自动识别目标库表前缀。 * * @param array $tableNames */ public function detectTablePrefix(array $tableNames, string $preferredPrefix = ''): string { $requiredTables = ['migrations', 'users', 'roles', 'user_roles', 'user_companies', 'settings']; $candidates = null; foreach ($requiredTables as $requiredTable) { $matchingPrefixes = []; foreach ($tableNames as $tableName) { if ($tableName !== $requiredTable && ! str_ends_with($tableName, '_' . $requiredTable)) { continue; } $prefix = substr($tableName, 0, strlen($tableName) - strlen($requiredTable)); try { $this->assertValidPrefix($prefix); $matchingPrefixes[] = $prefix; } catch (RuntimeException) { continue; } } $matchingPrefixes = array_values(array_unique($matchingPrefixes)); $candidates = $candidates === null ? $matchingPrefixes : array_values(array_intersect($candidates, $matchingPrefixes)); } if ($candidates === null || $candidates === []) { throw new RuntimeException('目标库缺少系统必需表,已拒绝切换。'); } if (in_array($preferredPrefix, $candidates, true)) { return $preferredPrefix; } if (count($candidates) !== 1) { throw new RuntimeException('目标库存在多套系统表,无法自动确定表前缀。'); } return $candidates[0]; } private function assertValidPrefix(string $prefix): void { if (strlen($prefix) > 50 || preg_match('/^[A-Za-z0-9_]*$/D', $prefix) !== 1) { throw new RuntimeException('目标库系统表前缀不安全,已拒绝切换。'); } } 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 ) . '"'; } }