CaiWuXiTong/modules/EcommerceFinance/Services/UpstreamOdbcConnection.php

359 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Support\Facades\Log;
use PDO;
use PDOException;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
/**
* 按来源隔离的 SQL Server PDO 连接管理。
*
* 默认使用 PDO_ODBC;仅生产的 operations_replica 通过已验证的
* FreeTDS PDO_DBLIB 传输,以避免 unixODBC 在该私有隧道上的原生崩溃。
*/
class UpstreamOdbcConnection
{
/** @var array<string,PDO> */
private static array $instances = [];
public static function get(string $source): PDO
{
$definition = app(UpstreamSourceRegistry::class)->definition($source);
$key = $definition['key'];
return self::$instances[$key] ??= self::create($key);
}
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): PDO
{
$sources = app(UpstreamSourceRegistry::class);
$definition = $sources->definition($source);
if ($requireEnabled && ! $sources->enabled($source)) {
throw new ServiceUnavailableHttpException(60, '该上游数据同步尚未启用,请联系管理员。');
}
return self::open(
(array) $sources->value($source, '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 = [];
return;
}
$key = app(UpstreamSourceRegistry::class)->definition($source)['key'];
unset(self::$instances[$key]);
}
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;
}
}