修复 operations_replica 生产驱动崩溃 (#9)
## 任务占用与边界 - 任务名称:修复 operations_replica 生产驱动崩溃 - 实际开发者:WuYongJie - 任务来源/团队分工:生产快照恢复 - 目标结果:生产 Linux 容器不再通过会崩溃的 PDO_ODBC 访问 FreeTDS;保留固定只读查询、共享系统数据库凭据来源和私有隧道。 - 预计修改路径:deploy/Dockerfile;modules/EcommerceFinance/Services/UpstreamOdbcConnection.php;tests/Unit/EcommerceFinance/UpstreamOdbcConnectionTest.php - 预计页面或路由:公司、店铺、商标信息页的共享快照读取 - 预计 Controller / Repository:UpstreamOdbcConnection;UpstreamReadOnlyQuery - 数据源、表/视图和字段:operations_replica 固定只读查询;不新增上游写入、全表同步或业务字段。 - 上游 source_key / scope:operations_replica;company-information.read、store-information.read、trademark-information.read - 权限名、菜单键或共享组件:不变 - 明确不修改的范围:上游账号、密码、权限、表结构、业务数据、Windows/本地 ODBC 行为 ## 开工碰撞检查 - [x] Codex 已执行 scripts/codex-task-preflight.ps1 - [x] 已检查最新 main 和所有远程任务分支 - [x] 已搜索主线既有页面、接口、Repository 和数据查询 - [x] 未发现重叠 ## 实际改动 - 完成内容:开发中 - Codex 实际修改的文件:待补充 - 新增或变更的公共契约:仅 Linux 容器内的 FreeTDS PDO 传输选择;不新增外部配置项 - 与原计划的差异:无 ## 验证证据 - 正常流程:同一共享配置和私有隧道下,FreeTDS 命令行 SELECT 1 已成功;PDO_ODBC 独立进程退出码 139。 - 空数据/错误输入/无权限场景:待补充自动测试。 - 受影响旧功能:Windows/本地仍使用既有 ODBC。 - 自动测试及结果:待补充。 - 实际页面、接口或只读业务查询结果:待合并和获准发布后生成三份首份快照。 ## 风险检查 - [ ] 不涉及金额、权限、审批、数据库迁移、生产配置或数据修复 - [x] 如涉及上述高风险内容,已在下方写明影响、回滚和所需授权 - [x] 未提交 .env、密码、Token、私钥、真实财务数据、生产日志或构建产物 - [x] 前端改动符合 DESIGN.md;无前端改动则写“不适用” 高风险影响、回滚和授权状态:需要构建新的应用镜像并经生产最小只读查询验证;不迁移、不改上游数据。若验证失败,发布脚本自动保留上一个可回滚镜像。 ## 合并前复查 - [ ] 已重新拉取远程引用并用实际修改范围再次运行碰撞检查 - [ ] PR 不再是 WIP:,所有测试结果已经补齐 - [ ] Codex 已重新读取原始需求和验收标准,并逐文件审核 origin/main...任务分支 的完整 diff - [ ] Codex 自审确认无越界修改、重复实现、敏感内容、未处理冲突和未验证结果 - [ ] 合并顺序不会覆盖其他未合并任务 - [x] 已授权当前任务的 Codex 执行 Squash merge、验证 main 并清理任务分支 - [ ] 如涉及生产迁移、数据修复、删除、覆盖配置或部署,已另行取得对应高风险授权 Reviewed-on: http://git.1080p.hk/WuYongJie/CaiWuXiTong/pulls/9
This commit is contained in:
parent
7608f99b9e
commit
3fd41dfb69
@ -63,6 +63,9 @@ return [
|
||||
'operations_replica' => [
|
||||
'label' => '经营分析副本库',
|
||||
'config' => 'operations_replica',
|
||||
// Linux 生产镜像中该来源经私有隧道使用 FreeTDS 的 PDO_DBLIB。
|
||||
// Windows 和其他来源继续保持现有 PDO_ODBC 行为。
|
||||
'use_pdo_dblib' => true,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@ -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
7
deploy/freetds.conf
Normal file
@ -0,0 +1,7 @@
|
||||
[global]
|
||||
# operations_replica 在生产中经受限私有隧道访问。PDO_DBLIB 由
|
||||
# FreeTDS 读取此配置,以请求 TLS;主机、端口和认证资料仍由应用
|
||||
# 从运行时私有配置和共享系统数据库解析,不能写入镜像。
|
||||
tds version = 7.4
|
||||
client charset = UTF-8
|
||||
encryption = request
|
||||
@ -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) {
|
||||
|
||||
44
tests/Unit/EcommerceFinance/UpstreamOdbcConnectionTest.php
Normal file
44
tests/Unit/EcommerceFinance/UpstreamOdbcConnectionTest.php
Normal 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'));
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user