diff --git a/config/upstreams.php b/config/upstreams.php index 37e0a51b..2cc1ab94 100644 --- a/config/upstreams.php +++ b/config/upstreams.php @@ -37,6 +37,19 @@ return [ 'enabled' => filter_var(env('UPSTREAM_STORED_CONFIG_ENABLED', false), FILTER_VALIDATE_BOOL), ], + 'tunnel_endpoints' => [ + // 仅生产环境使用的私有隧道端点。它只替换连接主机和端口, + // 数据库名、账号和密码仍从共享系统数据库读取,不能写入此处。 + 'jiansun' => [ + 'host' => env('UPSTREAM_TUNNEL_JIANSUN_HOST'), + 'port' => env('UPSTREAM_TUNNEL_JIANSUN_PORT'), + ], + 'operations_replica' => [ + 'host' => env('UPSTREAM_TUNNEL_OPERATIONS_REPLICA_HOST'), + 'port' => env('UPSTREAM_TUNNEL_OPERATIONS_REPLICA_PORT'), + ], + ], + 'local_configuration' => [ // 开发机显式开启后完全使用本机私有来源配置,不读取共享密码。 'enabled' => filter_var(env('UPSTREAM_LOCAL_CONFIG_ENABLED', false), FILTER_VALIDATE_BOOL), diff --git a/modules/EcommerceFinance/Services/UpstreamSourceRegistry.php b/modules/EcommerceFinance/Services/UpstreamSourceRegistry.php index 1f386d74..d5287524 100644 --- a/modules/EcommerceFinance/Services/UpstreamSourceRegistry.php +++ b/modules/EcommerceFinance/Services/UpstreamSourceRegistry.php @@ -59,10 +59,14 @@ class UpstreamSourceRegistry return $this->connection($definition['key'], (array) $configured); } - // Hosts and credentials come from the shared record, while the ODBC - // driver must match the operating system that runs this application. - if ($suffix === 'connection.odbc_driver' && trim((string) $configured) !== '') { - return $configured; + if (str_starts_with($suffix, 'connection.')) { + $field = substr($suffix, strlen('connection.')); + $connection = $this->connection( + $definition['key'], + (array) config($definition['config'] . '.connection', []), + ); + + return $connection[$field] ?? $default; } return $this->storedConfiguration->value($definition['key'], $suffix, $configured); @@ -94,13 +98,63 @@ class UpstreamSourceRegistry $resolved = $configured; foreach ($fields as $field) { - $resolved[$field] = $this->value( + // ODBC 驱动由运行应用的操作系统决定,不能因共享记录中的 + // Windows 驱动名而覆盖 Linux 容器中的可用驱动。 + if ($field === 'odbc_driver' && trim((string) ($configured[$field] ?? '')) !== '') { + $resolved[$field] = $configured[$field]; + + continue; + } + + $resolved[$field] = $this->storedConfiguration->value( $source, 'connection.' . $field, $configured[$field] ?? null, ); } - return $resolved; + return $this->withProductionTunnelEndpoint($source, $resolved); + } + + /** + * 生产服务器无法直连上游时,只覆盖到受限 SSH 隧道的私有端点。 + * 共享系统数据库仍是账号、密码和业务库信息的唯一来源,开发机则 + * 保持原始上游地址直连,避免把生产 Docker 网络地址传播给开发环境。 + */ + private function withProductionTunnelEndpoint(string $source, array $connection): array + { + if ( + ! app()->environment('production') + || $this->usesLocalConfiguration() + || ! $this->storedConfiguration->enabled() + ) { + return $connection; + } + + $endpoint = config('upstreams.tunnel_endpoints.' . $source, []); + + if (! is_array($endpoint)) { + return $connection; + } + + $host = trim((string) ($endpoint['host'] ?? '')); + $port = $endpoint['port'] ?? null; + + if ($host === '' && ($port === null || $port === '')) { + return $connection; + } + + if ( + $host === '' + || preg_match('/^[A-Za-z0-9._:-]+$/', $host) !== 1 + || filter_var($port, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1, 'max_range' => 65535]]) === false + ) { + throw new \RuntimeException('生产上游隧道端点配置不完整。'); + } + + return array_replace($connection, [ + 'host' => $host, + 'port' => (int) $port, + ]); } } diff --git a/tests/Feature/SystemSettings/UpstreamDatabasesTest.php b/tests/Feature/SystemSettings/UpstreamDatabasesTest.php index 823160bb..61e29e86 100644 --- a/tests/Feature/SystemSettings/UpstreamDatabasesTest.php +++ b/tests/Feature/SystemSettings/UpstreamDatabasesTest.php @@ -227,6 +227,40 @@ class UpstreamDatabasesTest extends FeatureTestCase $this->assertSame('ReadOnly', $resolved['application_intent']); } + public function test_production_tunnel_replaces_only_the_shared_connection_endpoint(): void + { + $connection = $this->connection('shared-secret'); + $connection->update([ + 'source_key' => 'operations_replica', + 'host' => 'shared-db.test', + 'port' => 14330, + 'database_name' => 'shared_database', + 'username' => 'shared_reader', + ]); + $this->app->detectEnvironment(fn () => 'production'); + + try { + config([ + 'upstreams.local_configuration.enabled' => false, + 'upstreams.stored_configuration.enabled' => true, + 'upstreams.tunnel_endpoints.operations_replica' => [ + 'host' => 'private-tunnel.test', + 'port' => 15433, + ], + ]); + $sources = new UpstreamSourceRegistry(new UpstreamStoredConfiguration()); + $resolved = $sources->value('operations_replica', 'connection', []); + + $this->assertSame('private-tunnel.test', $resolved['host']); + $this->assertSame(15433, $resolved['port']); + $this->assertSame('shared_database', $resolved['database']); + $this->assertSame('shared_reader', $resolved['username']); + $this->assertSame('shared-secret', $resolved['password']); + } finally { + $this->app->detectEnvironment(fn () => 'testing'); + } + } + public function test_global_wildcard_scope_is_rejected(): void { $this->withExceptionHandling();