environmentPath = $environmentPath ?: base_path('.env'); $this->backupDirectory = $backupDirectory; } public function current(): array { $connectionName = (string) config('database.default', 'mysql'); $connection = (array) config('database.connections.' . $connectionName, []); $options = (array) ($connection['options'] ?? []); $host = (string) ( data_get($connection, 'write.host.0') ?? data_get($connection, 'read.host.0') ?? ($connection['host'] ?? '') ); $tlsRequired = isset($options[PDO::MYSQL_ATTR_SSL_CA]) || isset($options[PDO::MYSQL_ATTR_SSL_CIPHER]); $verifyServerCertificate = defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT') ? (bool) ($options[constant('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')] ?? false) : false; return [ 'connection' => $connectionName, 'driver' => (string) ($connection['driver'] ?? $connectionName), 'host' => $host, 'port' => (int) ($connection['port'] ?? 3306), 'database' => (string) ($connection['database'] ?? ''), 'username' => (string) ($connection['username'] ?? ''), 'password' => (string) ($connection['password'] ?? ''), 'prefix' => (string) ($connection['prefix'] ?? ''), 'mode' => $this->databaseMode($host, (string) ($connection['database'] ?? '')), 'tls_required' => $tlsRequired, 'tls_verify_server_cert' => $tlsRequired && $verifyServerCertificate, 'tls_ca' => (string) ($options[PDO::MYSQL_ATTR_SSL_CA] ?? ''), ]; } /** * @return array{cipher:string,user_count:int,admin_count:int,source_count:int,prefix:string} */ public function probe(array $settings): array { if (($settings['driver'] ?? 'mysql') !== 'mysql') { throw new RuntimeException('系统数据库目前只支持 MariaDB/MySQL。'); } $this->assertHostResolves((string) $settings['host']); $this->assertTcpReachable((string) $settings['host'], (int) $settings['port']); $options = [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_TIMEOUT => 8, ]; if (! empty($settings['tls_required'])) { $options[PDO::MYSQL_ATTR_SSL_CIPHER] = 'DEFAULT'; if (! empty($settings['tls_ca'])) { $options[PDO::MYSQL_ATTR_SSL_CA] = (string) $settings['tls_ca']; } if (defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')) { $options[constant('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')] = ! empty($settings['tls_verify_server_cert']); } } 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 ' . $this->quoteIdentifier($prefix . 'users') )->fetchColumn(); $sourceCount = (int) $pdo->query( 'SELECT COUNT(*) FROM ' . $this->quoteIdentifier($prefix . 'system_database_connections') . ' WHERE company_id = 0' )->fetchColumn(); return [ 'cipher' => $cipher, 'user_count' => $userCount, 'admin_count' => 0, 'source_count' => $sourceCount, 'prefix' => $prefix, ]; } catch (PDOException $exception) { throw new RuntimeException($this->safeConnectionError($exception), 0, $exception); } catch (RuntimeException $exception) { throw $exception; } catch (Throwable $exception) { throw new RuntimeException($this->safeConnectionError($exception), 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 = $this->writableBackupDirectory(); $backupPath = $backupDirectory . DIRECTORY_SEPARATOR . 'runtime-env-' . now()->format('Ymd-His-u') . '.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); $values = [ 'DB_CONNECTION' => 'mysql', 'DB_HOST' => (string) $settings['host'], // Laravel prioritizes split read/write hosts, URL and socket over // DB_HOST. They must point to the same newly tested connection. 'DB_HOST_READ' => (string) $settings['host'], 'DB_HOST_WRITE' => (string) $settings['host'], 'DATABASE_URL' => '', 'DB_SOCKET' => '', '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_CA' => ! empty($settings['tls_required']) ? (string) ($settings['tls_ca'] ?? '') : '', 'MYSQL_ATTR_SSL_VERIFY_SERVER_CERT' => ! empty($settings['tls_required']) && ! empty($settings['tls_verify_server_cert']) ? 'true' : 'false', 'SYSTEM_DATABASE_MODE' => 'shared', // 系统数据库保存后统一读取共享云数据库配置,不再要求开发机 // 另外维护完整的 DB__* 连接参数。 'UPSTREAM_STORED_CONFIG_ENABLED' => 'true', 'UPSTREAM_LOCAL_CONFIG_ENABLED' => 'false', ]; if (isset($settings['mirror_database'])) { // 团队接入统一两个库的端点,避免旧 BUSINESS_MIRROR_DB_* 覆盖新系统连接。 $values += [ 'APP_INSTALLED' => 'true', 'BUSINESS_MIRROR_DB_HOST' => (string) $settings['host'], 'BUSINESS_MIRROR_DB_HOST_READ' => (string) $settings['host'], 'BUSINESS_MIRROR_DB_HOST_WRITE' => (string) $settings['host'], 'BUSINESS_MIRROR_DB_PORT' => (string) $settings['port'], 'BUSINESS_MIRROR_DB_DATABASE' => (string) $settings['mirror_database'], 'BUSINESS_MIRROR_DB_USERNAME' => (string) $settings['username'], 'BUSINESS_MIRROR_DB_PASSWORD' => (string) $settings['password'], 'BUSINESS_MIRROR_DB_SOCKET' => '', ]; } $updated = $this->replaceEnvironmentValues($current, $values); try { if (file_put_contents($this->environmentPath, $updated, LOCK_EX) === false) { throw new SystemDatabaseVerificationException('新配置写入失败'); } $this->clearConfigurationCache(); $this->runFreshVerification('system-database:verify'); } catch (Throwable $exception) { $restored = file_put_contents($this->environmentPath, $current, LOCK_EX) !== false; $candidateStage = $this->safeVerificationStage($exception); if (! $restored) { throw new RuntimeException( "新配置未通过应用级验证({$candidateStage}),且旧配置恢复失败。请立即使用私有备份恢复。", 0, $exception ); } try { $this->clearConfigurationCache(); $this->runFreshVerification('app:ready'); } catch (Throwable $rollbackException) { $rollbackStage = $this->safeVerificationStage($rollbackException); throw new RuntimeException( "新配置未通过应用级验证({$candidateStage}),旧配置文件已经恢复,但旧配置复核失败({$rollbackStage})。", 0, $rollbackException ); } throw new RuntimeException( "新配置未通过应用级验证({$candidateStage}),已自动恢复原系统数据库。", 0, $exception ); } return $backupPath; } private function writableBackupDirectory(): string { $preferred = $this->backupDirectory ?: storage_path('app/system-database-config-backups'); if ( (is_dir($preferred) && is_writable($preferred)) || ( ! file_exists($preferred) && @mkdir($preferred, 0700, true) && is_writable($preferred) ) ) { return $preferred; } // 某些开发机曾用 root 执行诊断命令,可能留下仅 root 可写的旧目录。 // storage/app 由容器入口统一授权给网页进程,可安全作为备份回退位置。 $fallback = storage_path('app'); if (is_dir($fallback) && is_writable($fallback)) { return $fallback; } throw new RuntimeException('系统数据库配置备份目录不可写,未执行切换。'); } /** * 根据一组完整的系统必需表自动识别目标库表前缀。 * * @param array $tableNames */ public function detectTablePrefix(array $tableNames, string $preferredPrefix = ''): string { $requiredTables = [ 'users', 'settings', 'system_database_connections', ]; $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 databaseMode(string $host, string $database): string { $configured = strtolower(trim((string) config('system_database.mode', ''))); if (in_array($configured, ['bootstrap', 'shared'], true)) { return $configured; } return app()->environment(['local', 'development']) && $host === 'mysql' && $database === 'akaunting' ? 'bootstrap' : 'shared'; } private function assertHostResolves(string $host): void { if ( filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4) || filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ) { return; } if ($host === '' || gethostbynamel($host) === false) { throw new RuntimeException('系统数据库地址暂时无法解析,请稍后重试或联系负责人。'); } } private function assertTcpReachable(string $host, int $port): void { $address = filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) ? sprintf('tcp://[%s]:%d', $host, $port) : sprintf('tcp://%s:%d', $host, $port); $socket = @stream_socket_client($address, $errorCode, $errorMessage, 4); if (! is_resource($socket)) { throw new RuntimeException('系统数据库暂时无法连接,请稍后重试或联系负责人。'); } fclose($socket); } private function safeConnectionError(Throwable $exception): string { $driverCode = $exception instanceof PDOException ? (int) ($exception->errorInfo[1] ?? 0) : 0; $message = strtolower($exception->getMessage()); if ($driverCode === 1045 || str_contains($message, 'access denied')) { return '系统数据库密码不正确,请重新输入。'; } if ($driverCode === 1049 || str_contains($message, 'unknown database')) { return '团队系统数据库配置异常,请联系负责人。'; } if ( $driverCode === 2026 || str_contains($message, 'ssl') || str_contains($message, 'certificate') || str_contains($message, 'tls') ) { return '系统数据库加密连接失败,请稍后重试或联系负责人。'; } if ($driverCode === 2002 || str_contains($message, 'timed out')) { return '系统数据库暂时无法连接,请稍后重试或联系负责人。'; } return '系统数据库连接初始化失败,请稍后重试或联系负责人。'; } protected function runFreshVerification(string $command): void { // 当前网页/Artisan 进程启动时,Laravel Dotenv 已把旧数据库值放进 // 进程环境。若直接启动子进程,Symfony Process 会继承这些旧值, // 新进程便不会读取刚写入 .env 的共享库配置,导致全新环境必然误判 // 为 bootstrap 并回滚。显式移除本次切换涉及的键,让子进程以磁盘 // 上最新(或刚恢复)的 .env 为唯一事实来源。 $php = $this->phpExecutable(); try { $process = new Process( [$php, base_path('artisan'), $command, '--no-ansi', '--quiet'], base_path(), $this->freshVerificationEnvironment() ); $process->setTimeout(60); $process->run(); } catch (SystemDatabaseVerificationException $exception) { throw $exception; } catch (Throwable $exception) { throw new SystemDatabaseVerificationException('PHP 验证进程无法启动', 0, $exception); } if (! $process->isSuccessful()) { throw new SystemDatabaseVerificationException( $this->verificationExitStage($command, (int) $process->getExitCode()) ); } } protected function phpExecutable(): string { $binary = DIRECTORY_SEPARATOR === '\\' ? 'php.exe' : 'php'; $candidates = [ (new PhpExecutableFinder())->find(false), PHP_BINARY, PHP_BINDIR.DIRECTORY_SEPARATOR.$binary, ]; foreach (array_unique($candidates) as $php) { if (is_string($php) && $php !== '' && is_file($php) && is_executable($php)) { return $php; } } throw new SystemDatabaseVerificationException('PHP 验证进程无法启动'); } protected function clearConfigurationCache(): void { try { $exitCode = Artisan::call('config:clear'); } catch (Throwable $exception) { throw new SystemDatabaseVerificationException('运行目录不可写', 0, $exception); } if ($exitCode !== 0) { throw new SystemDatabaseVerificationException('运行目录不可写'); } } private function verificationExitStage(string $command, int $exitCode): string { if ($command === 'system-database:verify') { return match ($exitCode) { VerifySystemDatabase::EXIT_CONFIG_NOT_LOADED => '新配置未被加载', VerifySystemDatabase::EXIT_DATABASE_CONNECTION_FAILED => 'Laravel 数据库连接失败', VerifySystemDatabase::EXIT_SYSTEM_TABLE_VALIDATION_FAILED => '系统表验证失败', VerifySystemDatabase::EXIT_SHARED_NOT_CONFIGURED => '新配置仍指向本机临时引导库', default => '应用级验证失败', }; } if ($command === 'app:ready') { return match ($exitCode) { ApplicationReady::EXIT_NOT_INSTALLED => 'APP_INSTALLED 未被加载', ApplicationReady::EXIT_RUNTIME_NOT_WRITABLE => '运行目录不可写', ApplicationReady::EXIT_DATABASE_CONNECTION_FAILED => 'Laravel 数据库连接失败', default => '旧配置应用复核失败', }; } return '应用级验证失败'; } private function safeVerificationStage(Throwable $exception): string { return $exception instanceof SystemDatabaseVerificationException ? $exception->getMessage() : '应用级验证失败'; } /** * @return array */ protected function freshVerificationEnvironment(): array { $keys = [ 'APP_ENV', 'APP_INSTALLED', 'DATABASE_URL', 'DB_CONNECTION', 'DB_HOST', 'DB_HOST_READ', 'DB_HOST_WRITE', 'DB_PORT', 'DB_DATABASE', 'DB_USERNAME', 'DB_PASSWORD', 'DB_PREFIX', 'DB_SOCKET', 'BUSINESS_MIRROR_DB_HOST', 'BUSINESS_MIRROR_DB_HOST_READ', 'BUSINESS_MIRROR_DB_HOST_WRITE', 'BUSINESS_MIRROR_DB_PORT', 'BUSINESS_MIRROR_DB_DATABASE', 'BUSINESS_MIRROR_DB_USERNAME', 'BUSINESS_MIRROR_DB_PASSWORD', 'BUSINESS_MIRROR_DB_SOCKET', 'MYSQL_ATTR_SSL_CIPHER', 'MYSQL_ATTR_SSL_CA', 'MYSQL_ATTR_SSL_VERIFY_SERVER_CERT', 'SYSTEM_DATABASE_MODE', 'UPSTREAM_STORED_CONFIG_ENABLED', 'UPSTREAM_LOCAL_CONFIG_ENABLED', ]; $environment = array_fill_keys($keys, false); $contents = file_get_contents($this->environmentPath); if ($contents === false) { throw new SystemDatabaseVerificationException('新配置未被加载'); } try { $saved = Dotenv::parse($contents); } catch (Throwable $exception) { throw new SystemDatabaseVerificationException('新配置未被加载', 0, $exception); } foreach ($keys as $key) { if (array_key_exists($key, $saved)) { $environment[$key] = (string) $saved[$key]; } } return $environment; } private function quoteIdentifier(string $identifier): string { return '`' . str_replace('`', '``', $identifier) . '`'; } 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('/^\s*(?:export\s+)?' . preg_quote($key, '/') . '\s*=/', $line) === 1) { $lines[$index] = $replacement; $found = true; } } if (! $found) { $lines[] = $replacement; } } return rtrim(implode("\n", $lines), "\n") . "\n"; } private function encodeEnvironmentValue(string $value): string { return '"' . str_replace( ['\\', '"', '$', "\r", "\n"], ['\\\\', '\\"', '\\$', '', '\\n'], $value ) . '"'; } }