修复 operations_replica 生产驱动崩溃 #9

Merged
WuYongJie merged 2 commits from fix/WuYongJie-operations-replica-pdo-dblib into main 2026-08-04 17:09:14 +08:00
5 changed files with 163 additions and 37 deletions

View File

@ -63,6 +63,9 @@ return [
'operations_replica' => [
'label' => '经营分析副本库',
'config' => 'operations_replica',
// Linux 生产镜像中该来源经私有隧道使用 FreeTDS 的 PDO_DBLIB。
// Windows 和其他来源继续保持现有 PDO_ODBC 行为。
'use_pdo_dblib' => true,
],
],
];

View File

@ -14,6 +14,7 @@ RUN apt-get update \
libxml2-dev \
libzip-dev \
freetds-bin \
freetds-dev \
tdsodbc \
unixodbc-dev \
unzip \
@ -27,6 +28,7 @@ RUN apt-get update \
gd \
intl \
mbstring \
pdo_dblib \
pdo_odbc \
pdo_mysql \
simplexml \
@ -68,6 +70,7 @@ COPY --from=frontend /js /var/www/html/public/js
COPY --from=frontend /css/app.css /var/www/html/public/css/app.css
COPY --from=frontend /mix-manifest.json /var/www/html/public/mix-manifest.json
COPY deploy/apache-vhost.conf /etc/apache2/sites-available/000-default.conf
COPY deploy/freetds.conf /etc/freetds/freetds.conf
COPY deploy/php.ini /usr/local/etc/php/conf.d/caiwu.ini
COPY deploy/entrypoint.sh /usr/local/bin/caiwu-entrypoint
COPY . /var/www/html

7
deploy/freetds.conf Normal file
View File

@ -0,0 +1,7 @@
[global]
# operations_replica 在生产中经受限私有隧道访问。PDO_DBLIB 由
# FreeTDS 读取此配置,以请求 TLS;主机、端口和认证资料仍由应用
# 从运行时私有配置和共享系统数据库解析,不能写入镜像。
tds version = 7.4
client charset = UTF-8
encryption = request

View File

@ -10,7 +10,10 @@ use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
/**
* 按来源隔离的 SQL Server PDO_ODBC 连接管理。
* 按来源隔离的 SQL Server PDO 连接管理。
*
* 默认使用 PDO_ODBC;仅生产的 operations_replica 通过已验证的
* FreeTDS PDO_DBLIB 传输,以避免 unixODBC 在该私有隧道上的原生崩溃。
*/
class UpstreamOdbcConnection
{
@ -167,43 +170,34 @@ PHP;
$applicationIntent = $applicationIntent === '' ? '' : 'ReadOnly';
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($db),
self::quoteOdbcValue($user),
self::quoteOdbcValue($pass),
$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($db),
self::quoteOdbcValue($user),
self::quoteOdbcValue($pass),
$connectTimeout
);
}
$dsn .= self::encryptionAttributes($driver, $encryption);
if ($applicationIntent !== '') {
$dsn .= ';ApplicationIntent=' . self::quoteOdbcValue($applicationIntent);
}
try {
return new PDO($dsn, null, null, [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_TIMEOUT => $queryTimeout,
PDO::ATTR_PERSISTENT => false,
]);
if (self::shouldUsePdoDblib($source, $driver, $encryption)) {
if (preg_match('/[;\x00-\x1F]/', $host) === 1 || preg_match('/[;\x00-\x1F]/', $db) === 1) {
throw new ServiceUnavailableHttpException(60, '上游数据库配置不完整,请联系管理员。');
}
return new PDO(
self::pdoDblibDsn($host, $port, $db, $tdsVersion),
$user,
$pass,
self::pdoOptions($queryTimeout),
);
}
$dsn = self::odbcDsn(
$host,
$port,
$db,
$user,
$pass,
$driver,
$tdsVersion,
$encryption,
$connectTimeout,
$applicationIntent,
);
return new PDO($dsn, null, null, self::pdoOptions($queryTimeout));
} catch (PDOException $exception) {
Log::warning('上游数据库连接失败。', [
'source' => $source,
@ -234,6 +228,81 @@ PHP;
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,
];
}
private static function encryptionAttributes(string $driver, string $mode): string
{
if (strcasecmp($driver, 'FreeTDS') === 0) {

View File

@ -0,0 +1,44 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Modules\EcommerceFinance\Services\UpstreamOdbcConnection;
use ReflectionMethod;
use Tests\TestCase;
class UpstreamOdbcConnectionTest extends TestCase
{
public function test_operations_replica_dblib_dsn_uses_only_endpoint_and_database_metadata(): void
{
$method = new ReflectionMethod(UpstreamOdbcConnection::class, 'pdoDblibDsn');
$dsn = $method->invoke(null, '127.0.0.1', 15433, 'reporting', '7.4');
$this->assertSame(
'dblib:host=127.0.0.1:15433;dbname=reporting;version=7.4;charset=UTF-8',
$dsn,
);
$this->assertStringNotContainsString('PWD=', $dsn);
$this->assertStringNotContainsString('UID=', $dsn);
}
public function test_production_dblib_transport_is_only_opted_in_for_the_marked_source(): void
{
$method = new ReflectionMethod(UpstreamOdbcConnection::class, 'shouldUsePdoDblib');
config()->set('upstreams.sources.operations_replica.use_pdo_dblib', true);
config()->set('upstreams.sources.jiansun.use_pdo_dblib', false);
$operations = $method->invoke(null, 'operations_replica', 'FreeTDS', 'request');
$jiansun = $method->invoke(null, 'jiansun', 'FreeTDS', 'request');
if (PHP_OS_FAMILY !== 'Windows' && extension_loaded('pdo_dblib')) {
$this->assertTrue($operations);
} else {
$this->assertFalse($operations);
}
$this->assertFalse($jiansun);
$this->assertFalse($method->invoke(null, 'operations_replica', 'FreeTDS', 'strict'));
}
}