359 lines
12 KiB
PHP
359 lines
12 KiB
PHP
<?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;
|
||
}
|
||
}
|