*/ private static array $instances = []; /** @var array */ private static array $timeoutInstances = []; public static function get(string $source, ?int $queryTimeout = null): PDO { $definition = app(UpstreamSourceRegistry::class)->definition($source); $key = $definition['key']; if ($queryTimeout === null) { return self::$instances[$key] ??= self::create($key); } $cacheKey = $key . '@' . $queryTimeout; return self::$timeoutInstances[$cacheKey] ??= self::connect($key, true, $queryTimeout); } public static function create(string $source): PDO { return self::connect($source, true); } public static function probe(string $source): void { $sources = app(UpstreamSourceRegistry::class); $definition = $sources->definition($source); self::probeConfiguration( (array) $sources->value($source, 'connection', []), $definition['label'], $definition['key'], ); } public static function probeConfiguration(array $connection, string $label, string $source = 'custom'): void { try { $payload = json_encode([ 'connection' => $connection, 'label' => $label, 'source' => $source, ], JSON_THROW_ON_ERROR); $php = (new PhpExecutableFinder())->find(false); if (! is_string($php) || $php === '') { throw new \RuntimeException('PHP CLI 不可用。'); } $script = <<<'PHP' require getcwd() . '/vendor/autoload.php'; $app = require getcwd() . '/bootstrap/app.php'; $kernel = $app->make(Illuminate\Contracts\Console\Kernel::class); $kernel->bootstrap(); $payload = json_decode(stream_get_contents(STDIN), true, 512, JSON_THROW_ON_ERROR); Modules\EcommerceFinance\Services\UpstreamOdbcConnection::probeConfigurationInProcess( (array) $payload['connection'], (string) $payload['label'], (string) $payload['source'], ); PHP; $connectTimeout = min(max((int) ($connection['connect_timeout'] ?? 5), 1), 30); $process = new Process( [$php, '-d', 'display_errors=0', '-r', $script], base_path(), null, $payload, min(max($connectTimeout + 3, 5), 15), ); $process->run(); if ($process->isSuccessful()) { return; } Log::warning('上游数据库隔离探测失败。', [ 'source' => $source, 'source_label' => $label, 'exit_code' => $process->getExitCode(), ]); } catch (\Throwable $exception) { Log::warning('上游数据库隔离探测无法完成。', [ 'source' => $source, 'source_label' => $label, 'exception' => $exception::class, ]); } throw new ServiceUnavailableHttpException(60, '上游数据库连接探测失败,请检查配置。'); } /** * 仅由隔离的 PHP CLI 子进程调用,避免 ODBC 原生崩溃拖垮 Web 请求进程。 * * @internal */ public static function probeConfigurationInProcess(array $connection, string $label, string $source = 'custom'): void { $pdo = self::open($connection, $source, $label); try { $statement = $pdo->query('SELECT 1'); $statement->fetchColumn(); } catch (PDOException $exception) { Log::warning('上游数据库只读连接探测失败。', [ 'source' => $source, 'source_label' => $label, 'sql_state' => (string) $exception->getCode(), 'exception' => $exception::class, ]); throw new ServiceUnavailableHttpException(60, '上游数据库连接探测失败,请检查配置。'); } } private static function connect(string $source, bool $requireEnabled, ?int $queryTimeout = null): PDO { $sources = app(UpstreamSourceRegistry::class); $definition = $sources->definition($source); if ($requireEnabled && ! $sources->enabled($source)) { throw new ServiceUnavailableHttpException(60, '该上游数据同步尚未启用,请联系管理员。'); } $connection = (array) $sources->value($source, 'connection', []); if ($queryTimeout !== null) { $connection['query_timeout'] = $queryTimeout; } return self::open( $connection, $definition['key'], $definition['label'], ); } private static function open(array $connection, string $source, string $label): PDO { $host = trim((string) ($connection['host'] ?? '')); $port = min(max((int) ($connection['port'] ?? 1433), 1), 65535); $db = trim((string) ($connection['database'] ?? '')); $user = trim((string) ($connection['username'] ?? '')); $pass = (string) ($connection['password'] ?? ''); $driver = self::resolveDriver((string) ($connection['odbc_driver'] ?? 'FreeTDS')); $tdsVersion = trim((string) ($connection['tds_version'] ?? '7.4')); $encryption = strtolower(trim((string) ($connection['encryption'] ?? 'off'))); $connectTimeout = min(max((int) ($connection['connect_timeout'] ?? 5), 1), 30); $queryTimeout = min(max((int) ($connection['query_timeout'] ?? 15), 1), 120); $applicationIntent = trim((string) ($connection['application_intent'] ?? '')); if ( $host === '' || $db === '' || $user === '' || $pass === '' || $driver === '' || preg_match('/[;\x00-\x1F]/', $driver) === 1 || preg_match('/^\d+(?:\.\d+)?$/', $tdsVersion) !== 1 || ! in_array($encryption, ['off', 'request', 'require', 'strict'], true) ) { throw new ServiceUnavailableHttpException(60, '上游数据库配置不完整,请联系管理员。'); } if ($applicationIntent !== '' && strcasecmp($applicationIntent, 'ReadOnly') !== 0) { throw new ServiceUnavailableHttpException(60, '上游数据库只允许只读连接意图。'); } $applicationIntent = $applicationIntent === '' ? '' : 'ReadOnly'; try { if (self::shouldUsePdoDblib($source, $driver, $encryption)) { if (preg_match('/[;\x00-\x1F]/', $host) === 1 || preg_match('/[;\x00-\x1F]/', $db) === 1) { throw new ServiceUnavailableHttpException(60, '上游数据库配置不完整,请联系管理员。'); } $pdo = new PDO( self::pdoDblibDsn($host, $port, $db, $tdsVersion), $user, $pass, self::pdoOptions($queryTimeout), ); return self::initializeSqlServerSession($pdo); } $dsn = self::odbcDsn( $host, $port, $db, $user, $pass, $driver, $tdsVersion, $encryption, $connectTimeout, $applicationIntent, ); return self::initializeSqlServerSession( new PDO($dsn, null, null, self::pdoOptions($queryTimeout)), ); } catch (PDOException $exception) { Log::warning('上游数据库连接失败。', [ 'source' => $source, 'source_label' => $label, 'sql_state' => (string) $exception->getCode(), 'exception' => $exception::class, ]); // 不传递原异常,防止异常链携带含凭据的 DSN。 throw new ServiceUnavailableHttpException(60, '上游数据库暂时无法连接,请稍后再试。'); } } public static function reset(?string $source = null): void { if ($source === null) { self::$instances = []; self::$timeoutInstances = []; return; } $key = app(UpstreamSourceRegistry::class)->definition($source)['key']; unset(self::$instances[$key]); foreach (array_keys(self::$timeoutInstances) as $cacheKey) { if (str_starts_with($cacheKey, $key . '@')) { unset(self::$timeoutInstances[$cacheKey]); } } } private static function quoteOdbcValue(string $value): string { return '{' . str_replace('}', '}}', $value) . '}'; } private static function shouldUsePdoDblib(string $source, string $driver, string $encryption): bool { return PHP_OS_FAMILY !== 'Windows' && extension_loaded('pdo_dblib') && (bool) config('upstreams.sources.' . $source . '.use_pdo_dblib', false) && strcasecmp($driver, 'FreeTDS') === 0 // pdo_dblib 通过镜像内 FreeTDS 配置请求 TLS;其他安全模式 // 保持在 ODBC 路径,避免静默降低连接安全要求。 && $encryption === 'request'; } private static function pdoDblibDsn(string $host, int $port, string $database, string $tdsVersion): string { return sprintf( 'dblib:host=%s:%d;dbname=%s;version=%s;charset=UTF-8', $host, $port, $database, $tdsVersion, ); } private static function odbcDsn( string $host, int $port, string $database, string $username, string $password, string $driver, string $tdsVersion, string $encryption, int $connectTimeout, string $applicationIntent, ): string { if (strcasecmp($driver, 'FreeTDS') === 0) { $dsn = sprintf( 'odbc:Driver={%s};Server=%s;Port=%d;Database=%s;UID=%s;PWD=%s;TDS_Version=%s;ClientCharset=UTF-8;Connect Timeout=%d', str_replace('}', '}}', $driver), self::quoteOdbcValue($host), $port, self::quoteOdbcValue($database), self::quoteOdbcValue($username), self::quoteOdbcValue($password), $tdsVersion, $connectTimeout, ); } else { $dsn = sprintf( 'odbc:Driver={%s};Server=%s;Database=%s;UID=%s;PWD=%s;Connection Timeout=%d', str_replace('}', '}}', $driver), self::quoteOdbcValue($host . ',' . $port), self::quoteOdbcValue($database), self::quoteOdbcValue($username), self::quoteOdbcValue($password), $connectTimeout, ); } $dsn .= self::encryptionAttributes($driver, $encryption); return $applicationIntent === '' ? $dsn : $dsn . ';ApplicationIntent=' . self::quoteOdbcValue($applicationIntent); } private static function pdoOptions(int $queryTimeout): array { return [ PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, PDO::ATTR_TIMEOUT => $queryTimeout, PDO::ATTR_PERSISTENT => false, ]; } /** * SQL Server 的跨库/异构只读视图要求这两个会话选项保持开启。 * * 这里在连接创建后统一初始化,避免各 Repository 拼接 SET 多语句, * 也继续让 UpstreamReadOnlyQuery 只接受单条 SELECT/WITH。 */ private static function initializeSqlServerSession(PDO $pdo): PDO { $pdo->exec('SET ANSI_NULLS ON'); $pdo->exec('SET ANSI_WARNINGS ON'); return $pdo; } private static function encryptionAttributes(string $driver, string $mode): string { if (strcasecmp($driver, 'FreeTDS') === 0) { return ';Encryption=' . $mode; } $encrypt = match ($mode) { 'off', 'request' => 'no', 'require' => 'yes', 'strict' => 'strict', }; $attributes = ';Encrypt=' . $encrypt; return $mode === 'strict' ? $attributes : $attributes . ';TrustServerCertificate=yes'; } private static function resolveDriver(string $configured): string { $override = trim((string) config('upstreams.runtime.odbc_driver', ''), '{} '); if ($override !== '') { return $override; } $configured = trim($configured, '{} '); if (PHP_OS_FAMILY === 'Windows' && strcasecmp($configured, 'FreeTDS') === 0) { return 'SQL Server'; } return $configured; } }