生产经营分析副本库隧道端点 #8

Merged
WuYongJie merged 1 commits from fix/WuYongJie-operations-replica-tunnel-endpoint into main 2026-08-04 16:15:33 +08:00
3 changed files with 107 additions and 6 deletions

View File

@ -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),

View File

@ -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,
]);
}
}

View File

@ -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();