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'] ?? []); $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'] ); $this->assertRequiredSchema($pdo, $prefix); $this->assertCurrentMigrations($pdo, $prefix); $userCount = (int) $pdo->query( 'SELECT COUNT(*) FROM ' . $this->quoteIdentifier($prefix . 'users') )->fetchColumn(); if ($userCount < 1) { throw new RuntimeException('目标库没有任何用户,已拒绝切换。'); } $adminCount = $this->administratorCount($pdo, $prefix); if ($adminCount < 1) { throw new RuntimeException('目标库没有具备公司和后台权限的可登录管理员,已拒绝切换。'); } $sourceCount = $this->assertSharedSources( $pdo, $prefix, (string) $settings['password'] ); return [ 'cipher' => $cipher, 'user_count' => $userCount, 'admin_count' => $adminCount, '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 = 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-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); $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_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', ]); try { if (file_put_contents($this->environmentPath, $updated, LOCK_EX) === false) { throw new RuntimeException('系统数据库配置写入失败。'); } Artisan::call('config:clear'); $this->runFreshVerification('system-database:verify'); } catch (Throwable $exception) { $restored = file_put_contents($this->environmentPath, $current, LOCK_EX) !== false; Artisan::call('config:clear'); if (! $restored) { throw new RuntimeException( '新配置未通过应用级验证,且旧配置自动恢复失败。请立即使用私有备份恢复。', 0, $exception ); } try { $this->runFreshVerification('app:ready'); } catch (Throwable $rollbackException) { throw new RuntimeException( '新配置未通过应用级验证,旧配置文件已经恢复,但旧连接复核失败。', 0, $rollbackException ); } throw new RuntimeException('新配置未通过应用级验证,已自动恢复原系统数据库。', 0, $exception); } return $backupPath; } /** * 根据一组完整的系统必需表自动识别目标库表前缀。 * * @param array $tableNames */ public function detectTablePrefix(array $tableNames, string $preferredPrefix = ''): string { $requiredTables = [ 'migrations', 'users', 'companies', 'roles', 'permissions', 'role_permissions', 'user_roles', 'user_companies', '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('连接失败阶段:DNS 解析。请检查主机名称。'); } } 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('连接失败阶段:TCP 端口。请检查端口、网络和访问白名单。'); } fclose($socket); } private function assertRequiredSchema(PDO $pdo, string $prefix): void { $requiredColumns = [ 'users' => ['id', 'email', 'password', 'enabled'], 'companies' => ['id', 'enabled'], 'roles' => ['id', 'name'], 'permissions' => ['id', 'name'], 'role_permissions' => ['role_id', 'permission_id'], 'user_roles' => ['user_id', 'role_id'], 'user_companies' => ['user_id', 'company_id'], 'system_database_connections' => [ 'company_id', 'source_key', 'password', 'shared_password', 'allowed_scopes', ], ]; $statement = $pdo->prepare( 'SELECT column_name FROM information_schema.columns ' . 'WHERE table_schema = DATABASE() AND table_name = ?' ); foreach ($requiredColumns as $table => $columns) { $statement->execute([$prefix . $table]); $available = array_map('strval', $statement->fetchAll(PDO::FETCH_COLUMN)); $missing = array_diff($columns, $available); if ($missing !== []) { throw new RuntimeException(sprintf( '目标库表结构不完整:%s 缺少 %d 个必要字段。', $table, count($missing) )); } } } private function assertCurrentMigrations(PDO $pdo, string $prefix): void { $files = glob(database_path('migrations/*.php')) ?: []; $required = array_map( static fn (string $path): string => pathinfo($path, PATHINFO_FILENAME), $files ); $applied = array_map( 'strval', $pdo->query( 'SELECT migration FROM ' . $this->quoteIdentifier($prefix . 'migrations') )->fetchAll(PDO::FETCH_COLUMN) ); $missing = array_diff($required, $applied); if ($missing !== []) { throw new RuntimeException(sprintf( '目标库缺少当前代码要求的 %d 个数据库迁移,已拒绝切换。', count($missing) )); } } private function administratorCount(PDO $pdo, string $prefix): int { $users = $this->quoteIdentifier($prefix . 'users'); $roles = $this->quoteIdentifier($prefix . 'roles'); $permissions = $this->quoteIdentifier($prefix . 'permissions'); $userRoles = $this->quoteIdentifier($prefix . 'user_roles'); $userCompanies = $this->quoteIdentifier($prefix . 'user_companies'); $rolePermissions = $this->quoteIdentifier($prefix . 'role_permissions'); return (int) $pdo->query( "SELECT COUNT(DISTINCT u.id) FROM {$users} u " . "INNER JOIN {$userRoles} ur ON ur.user_id = u.id " . "INNER JOIN {$roles} r ON r.id = ur.role_id " . "INNER JOIN {$userCompanies} uc ON uc.user_id = u.id " . "INNER JOIN {$rolePermissions} rp ON rp.role_id = r.id " . "INNER JOIN {$permissions} p ON p.id = rp.permission_id " . "WHERE u.enabled = 1 " . "AND r.name IN ('super_admin', 'admin') " . "AND p.name = 'read-admin-panel'" )->fetchColumn(); } private function assertSharedSources(PDO $pdo, string $prefix, string $systemDatabasePassword): int { $requiredSources = array_keys((array) config('upstreams.sources', [])); if ($requiredSources === []) { return 0; } $table = $this->quoteIdentifier($prefix . 'system_database_connections'); $statement = $pdo->query( "SELECT source_key, shared_password FROM {$table} WHERE company_id = 0" ); $records = []; foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $record) { $source = (string) ($record['source_key'] ?? ''); if ($source !== '') { $records[$source] = (string) ($record['shared_password'] ?? ''); } } foreach ($requiredSources as $source) { if (! array_key_exists($source, $records)) { throw new RuntimeException('目标库缺少已登记的共享云数据库来源:' . $source . '。'); } if ($records[$source] === '') { throw new RuntimeException('共享云数据库密码尚未迁移:' . $source . '。'); } try { app(UpstreamCredentialCipher::class)->decryptWithSecret( $records[$source], $systemDatabasePassword ); } catch (DecryptException|RuntimeException) { throw new RuntimeException('系统数据库密码无法解锁共享云数据库配置:' . $source . '。'); } } return count($requiredSources); } 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 '连接失败阶段:TLS 协商或证书验证。请检查 TLS 和 CA 配置。'; } if ($driverCode === 2002 || str_contains($message, 'timed out')) { return '连接失败阶段:数据库握手。请检查网络、端口和访问白名单。'; } return '连接失败阶段:数据库登录或初始化。请检查库名、账号权限和 TLS 配置。'; } protected function runFreshVerification(string $command): void { $process = new Process( [PHP_BINARY, base_path('artisan'), $command, '--no-ansi', '--quiet'], base_path() ); $process->setTimeout(60); $process->run(); if (! $process->isSuccessful()) { throw new RuntimeException('新的 Laravel 进程未通过应用级数据库验证。'); } } 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('/^' . 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 ) . '"'; } }