diff --git a/app/Http/Controllers/SystemSettings/Databases.php b/app/Http/Controllers/SystemSettings/Databases.php index 0eb9b009..581ba4b8 100644 --- a/app/Http/Controllers/SystemSettings/Databases.php +++ b/app/Http/Controllers/SystemSettings/Databases.php @@ -15,6 +15,7 @@ use Illuminate\Routing\Controller; use Illuminate\Validation\Rule; use Illuminate\Validation\ValidationException; use Modules\EcommerceFinance\Services\UpstreamOdbcConnection; +use Modules\EcommerceFinance\Services\UpstreamDatabaseProbe; use Modules\EcommerceFinance\Services\UpstreamSourceRegistry; use Modules\EcommerceFinance\Services\UpstreamStoredConfiguration; use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; @@ -258,24 +259,26 @@ class Databases extends Controller return redirect()->route('system-settings.databases.index'); } - public function test(int $connection, UpstreamStoredConfiguration $storedConfiguration) + public function test(int $connection, UpstreamStoredConfiguration $storedConfiguration, UpstreamDatabaseProbe $probe) { abort_unless($storedConfiguration->editable() && $storedConfiguration->enabled(), 403); $connection = $this->connection($connection); try { - UpstreamOdbcConnection::probeConfiguration($this->odbcConfiguration($connection), $connection->name); + $probe->probe($connection->driver, $this->odbcConfiguration($connection), $connection->name); } catch (DecryptException $exception) { throw ValidationException::withMessages([ 'connection' => $exception->getMessage(), ]); - } catch (ServiceUnavailableHttpException) { + } catch (ServiceUnavailableHttpException $exception) { throw ValidationException::withMessages([ - 'connection' => '连接失败。请检查地址、端口、数据库、账号、网络白名单和本机 ODBC 驱动。', + 'connection' => $connection->driver === 'pgsql' + ? $exception->getMessage() + : 'SQL Server 连接失败。请检查地址、端口、数据库、账号、网络白名单和 ODBC 驱动。', ]); } - flash($connection->name . '连接成功,仅执行了 SELECT 1 只读测试。')->success(); + flash($connection->name . ($connection->driver === 'pgsql' ? ' PostgreSQL SSL 连接成功,仅执行了 SELECT 1 只读测试。' : '连接成功,仅执行了 SELECT 1 只读测试。'))->success(); return redirect()->route('system-settings.databases.index'); } @@ -283,8 +286,17 @@ class Databases extends Controller private function validateConnection(Request $request, ?DatabaseConnection $connection = null): array { $sourceKeys = array_keys($this->sourceLabels()); + $request->merge(['driver' => $request->input('driver', $connection?->driver ?? 'sqlsrv')]); + $postgres = $request->input('driver') === 'pgsql'; + + if ($postgres && $request->input('source_key', $connection?->source_key)) { + throw ValidationException::withMessages([ + 'source_key' => '现有业务来源使用 SQL Server,不能改绑到 PostgreSQL。请为 PostgreSQL 单独保存连接。', + ]); + } return $request->validate([ + 'driver' => ['required', Rule::in(['sqlsrv', 'pgsql'])], 'name' => [ 'required', 'string', @@ -300,15 +312,20 @@ class Databases extends Controller ->where(fn ($query) => $query->where('company_id', UpstreamStoredConfiguration::SYSTEM_COMPANY_ID)) ->ignore($connection?->id), ], - 'host' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'], + 'host' => ['required', 'string', 'max:191', function ($attribute, $value, $fail) { + if (! filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6) + && ! preg_match('/^[a-z0-9_.-]+$/i', $value)) { + $fail('主机地址只填写域名或 IP,端口请单独填写。'); + } + }], 'port' => ['required', 'integer', 'between:1,65535'], 'database_name' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'], 'username' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'], 'password' => [$connection ? 'nullable' : 'required', 'string', 'max:1000'], - 'odbc_driver' => ['required', 'string', 'max:128', 'not_regex:/[;{}\x00-\x1F]/'], - 'tds_version' => ['required', 'regex:/^\d+(?:\.\d+)?$/', 'max:16'], + 'odbc_driver' => [$postgres ? 'nullable' : 'required', 'string', 'max:128', 'not_regex:/[;{}\x00-\x1F]/'], + 'tds_version' => [$postgres ? 'nullable' : 'required', 'regex:/^\d+(?:\.\d+)?$/', 'max:16'], 'connect_timeout' => ['required', 'integer', 'between:1,30'], - 'query_timeout' => ['required', 'integer', 'between:1,120'], + 'query_timeout' => ['required', 'integer', $postgres ? 'between:1,15' : 'between:1,120'], 'allowed_scopes' => ['nullable', 'string', 'max:4000'], 'enabled' => ['nullable', 'boolean'], ]); @@ -318,15 +335,15 @@ class Databases extends Controller { $attributes = [ 'company_id' => UpstreamStoredConfiguration::SYSTEM_COMPANY_ID, - 'source_key' => $data['source_key'] ?? null, + 'source_key' => array_key_exists('source_key', $data) ? $data['source_key'] : $connection?->source_key, 'name' => trim($data['name']), - 'driver' => 'sqlsrv', + 'driver' => $data['driver'], 'host' => trim($data['host']), 'port' => (int) $data['port'], 'database_name' => trim($data['database_name']), 'username' => trim($data['username']), - 'odbc_driver' => trim($data['odbc_driver']), - 'tds_version' => trim($data['tds_version']), + 'odbc_driver' => $data['driver'] === 'pgsql' ? null : trim($data['odbc_driver']), + 'tds_version' => $data['driver'] === 'pgsql' ? null : trim($data['tds_version']), 'connect_timeout' => (int) $data['connect_timeout'], 'query_timeout' => (int) $data['query_timeout'], 'allowed_scopes' => $this->parseScopes((string) ($data['allowed_scopes'] ?? '')), @@ -374,6 +391,7 @@ class Databases extends Controller { return [ 'name' => '', + 'driver' => 'sqlsrv', 'source_key' => '', 'enabled' => false, 'host' => '', @@ -392,6 +410,7 @@ class Databases extends Controller { return [ 'name' => $connection->name, + 'driver' => $connection->driver, 'source_key' => $connection->source_key, 'enabled' => $connection->enabled, 'host' => $connection->host, diff --git a/deploy/Dockerfile b/deploy/Dockerfile index 2024a960..aba499ae 100644 --- a/deploy/Dockerfile +++ b/deploy/Dockerfile @@ -72,6 +72,12 @@ RUN --mount=type=cache,target=/tmp/composer-cache \ FROM php-base AS runtime +# Keep the existing PHP/Composer cache layers when adding PostgreSQL support. +RUN apt-get -o Acquire::Retries=3 update \ + && apt-get -o Acquire::Retries=3 install -y --no-install-recommends libpq-dev \ + && docker-php-ext-install -j2 pdo_pgsql \ + && rm -rf /var/lib/apt/lists/* + ARG VCS_REF=unknown ARG BUILD_DATE=unknown diff --git a/deploy/Dockerfile.local b/deploy/Dockerfile.local index 2f1383b5..eb2307bc 100644 --- a/deploy/Dockerfile.local +++ b/deploy/Dockerfile.local @@ -83,6 +83,11 @@ RUN --mount=type=cache,target=/root/.npm \ npm ci --no-audit --no-fund --legacy-peer-deps # ---------------- 复制源码 ---------------- +RUN apt-get -o Acquire::Retries=3 update \ + && apt-get -o Acquire::Retries=3 install -y --no-install-recommends libpq-dev \ + && docker-php-ext-install -j2 pdo_pgsql \ + && rm -rf /var/lib/apt/lists/* + COPY . /var/www/html COPY deploy/apache-vhost.conf /etc/apache2/sites-available/000-default.conf COPY deploy/php.ini /usr/local/etc/php/conf.d/jiangling.ini diff --git a/modules/EcommerceFinance/Services/UpstreamDatabaseProbe.php b/modules/EcommerceFinance/Services/UpstreamDatabaseProbe.php new file mode 100644 index 00000000..a41b9ff1 --- /dev/null +++ b/modules/EcommerceFinance/Services/UpstreamDatabaseProbe.php @@ -0,0 +1,68 @@ +quote((string) $connection['host']) + . ';port=' . min(max((int) $connection['port'], 1), 65535) + . ';dbname=' . $this->quote((string) $connection['database']) + . ';sslmode=require;connect_timeout=' . $timeout + . ";options='-c default_transaction_read_only=on -c statement_timeout=" . $queryTimeout . "'"; + + try { + // One short-lived connection, TLS and read-only enforced at session startup. + $pdo = $this->connect($dsn, (string) $connection['username'], (string) $connection['password']); + $statement = $pdo->query('SELECT 1'); + if ($statement === false || (int) $statement->fetchColumn() !== 1) { + throw new ServiceUnavailableHttpException(30, 'PostgreSQL 只读测试未返回预期结果。'); + } + $statement->closeCursor(); + } catch (PDOException) { + // Do not expose raw driver errors, DSNs or credentials in logs/session flashes. + throw new ServiceUnavailableHttpException(30, 'PostgreSQL 连接失败。请检查账号、密码、数据库访问权限,以及服务器网络和 SSL 配置。'); + } + } + + protected function connect(string $dsn, string $username, #[SensitiveParameter] string $password): PDO + { + if (! in_array('pgsql', PDO::getAvailableDrivers(), true)) { + throw new ServiceUnavailableHttpException(30, '应用缺少 PostgreSQL 驱动,请部署包含 pdo_pgsql 的新版应用镜像。'); + } + + return new PDO($dsn, $username, $password, [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_PERSISTENT => false, + ]); + } + + private function quote(string $value): string + { + // PDO replaces all DSN semicolons with spaces; reject rather than alter database names. + if (preg_match('/[;\x00-\x1F]/', $value)) { + throw new ServiceUnavailableHttpException(30, '主机或数据库名称包含不支持的字符。'); + } + + return "'" . str_replace(['\\', "'"], ['\\\\', "\\'"], $value) . "'"; + } +} diff --git a/resources/views/system_settings/databases/form.blade.php b/resources/views/system_settings/databases/form.blade.php index 9cd2946e..7e3e5cdf 100644 --- a/resources/views/system_settings/databases/form.blade.php +++ b/resources/views/system_settings/databases/form.blade.php @@ -5,7 +5,8 @@
@include('system_settings.partials.navigation') -
+ @php($postgres = old('driver', $values['driver']) === 'pgsql') + @csrf @if ($connection) @method('PATCH') @endif @@ -16,64 +17,97 @@ @endif
+ +

通过 SSL 加密连接(sslmode=require);仅执行只读测试,查询最长 15 秒。保存连接不会自动同步业务表。

-
高级设置
- - - - - + + + + +
- +
取消 - +
+ @push('body_end') + + @endpush diff --git a/resources/views/system_settings/databases/index.blade.php b/resources/views/system_settings/databases/index.blade.php index 3df03c6b..d38520ea 100644 --- a/resources/views/system_settings/databases/index.blade.php +++ b/resources/views/system_settings/databases/index.blade.php @@ -96,7 +96,7 @@ @forelse ($connections as $connection) - {{ $connection->name }} + {{ $connection->name }}{{ $connection->driver === 'pgsql' ? 'PostgreSQL · SSL' : 'SQL Server' }} {{ $connection->host }}:{{ $connection->port }} {{ $connection->database_name }} {{ $sourceLabels[$connection->source_key] ?? '未绑定' }} diff --git a/tests/Feature/SystemSettings/UpstreamDatabasesTest.php b/tests/Feature/SystemSettings/UpstreamDatabasesTest.php index 61e29e86..1812ff0f 100644 --- a/tests/Feature/SystemSettings/UpstreamDatabasesTest.php +++ b/tests/Feature/SystemSettings/UpstreamDatabasesTest.php @@ -14,6 +14,7 @@ use Illuminate\Support\Facades\DB; use Mockery; use Modules\EcommerceFinance\Services\UpstreamSourceRegistry; use Modules\EcommerceFinance\Services\UpstreamStoredConfiguration; +use Modules\EcommerceFinance\Services\UpstreamDatabaseProbe; use Tests\Feature\FeatureTestCase; class UpstreamDatabasesTest extends FeatureTestCase @@ -499,6 +500,65 @@ class UpstreamDatabasesTest extends FeatureTestCase ->assertSessionHasErrors('password'); } + public function test_postgres_can_be_saved_without_sql_server_settings(): void + { + $payload = $this->payload(['driver' => 'pgsql', 'port' => 25432, 'password' => 'pg-secret']); + unset($payload['odbc_driver'], $payload['tds_version']); + $this->loginAs()->post(route('system-settings.databases.store'), $payload) + ->assertSessionHasNoErrors()->assertRedirect(route('system-settings.databases.index')); + $connection = DatabaseConnection::where('driver', 'pgsql')->firstOrFail(); + $this->assertNull($connection->odbc_driver); + $this->assertNull($connection->source_key); + $this->assertSame('pg-secret', $connection->password); + $this->get(route('system-settings.databases.edit', $connection->id)) + ->assertOk()->assertSee('sslmode=require')->assertSee('PostgreSQL')->assertDontSee('pg-secret'); + } + + public function test_changing_wrong_driver_preserves_password(): void + { + $connection = $this->connection('keep-secret'); + $this->loginAs()->patch(route('system-settings.databases.update', $connection->id), $this->payload([ + 'driver' => 'pgsql', 'port' => 25432, 'password' => '', + ]))->assertSessionHasNoErrors(); + $connection->refresh(); + $this->assertSame('pgsql', $connection->driver); + $this->assertSame(25432, $connection->port); + $this->assertSame('keep-secret', $connection->password); + } + + public function test_postgres_cannot_replace_existing_sql_server_business_source(): void + { + $this->withExceptionHandling(); + $this->loginAs()->post(route('system-settings.databases.store'), $this->payload([ + 'driver' => 'pgsql', 'source_key' => 'operations_replica', + ]))->assertSessionHasErrors('source_key'); + $this->assertSame(0, DatabaseConnection::where('company_id', 0)->count()); + } + + public function test_host_must_not_include_port(): void + { + $this->withExceptionHandling(); + $this->loginAs()->post(route('system-settings.databases.store'), $this->payload([ + 'host' => '127.0.0.1:25432', + ]))->assertSessionHasErrors('host'); + } + + public function test_test_button_uses_saved_postgres_driver_and_password(): void + { + $connection = $this->connection('saved-pg-secret'); + $connection->update(['driver' => 'pgsql', 'port' => 25432]); + $probe = Mockery::mock(UpstreamDatabaseProbe::class); + $probe->shouldReceive('probe')->once()->withArgs(fn ($driver, $config, $label) => + $driver === 'pgsql' && $config['password'] === 'saved-pg-secret' && $config['port'] === 25432 + ); + $this->app->instance(UpstreamDatabaseProbe::class, $probe); + $this->loginAs()->post(route('system-settings.databases.test', $connection->id)) + ->assertSessionHasNoErrors()->assertRedirect(route('system-settings.databases.index')); + $this->assertFlashLevel('success'); + $this->assertStringContainsString('PostgreSQL SSL 连接成功', session('flash_notification')->first()->message); + $this->get(route('system-settings.databases.index'))->assertSeeText('PostgreSQL')->assertDontSee('saved-pg-secret'); + } + private function payload(array $overrides = []): array { return array_merge([ diff --git a/tests/Unit/EcommerceFinance/UpstreamDatabaseProbeTest.php b/tests/Unit/EcommerceFinance/UpstreamDatabaseProbeTest.php new file mode 100644 index 00000000..0f66222e --- /dev/null +++ b/tests/Unit/EcommerceFinance/UpstreamDatabaseProbeTest.php @@ -0,0 +1,65 @@ + 'db.example.test', 'port' => 25432, 'database' => "test' database", + 'username' => 'reader', 'password' => 'fixture-secret', 'connect_timeout' => 5, 'query_timeout' => 120]; + } + + public function test_postgres_uses_tls_read_only_timeout_and_one_select(): void + { + $statement = Mockery::mock(PDOStatement::class); + $statement->shouldReceive('fetchColumn')->once()->andReturn(1); + $statement->shouldReceive('closeCursor')->once(); + $pdo = Mockery::mock(PDO::class); + $pdo->shouldReceive('query')->once()->with('SELECT 1')->andReturn($statement); + $probe = Mockery::mock(UpstreamDatabaseProbe::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $probe->shouldReceive('connect')->once()->withArgs(function ($dsn, $user, $password) { + $this->assertStringContainsString('sslmode=require', $dsn); + $this->assertStringContainsString('default_transaction_read_only=on', $dsn); + $this->assertStringContainsString('statement_timeout=15000', $dsn); + $this->assertStringContainsString("dbname='test\\' database'", $dsn); + $this->assertStringContainsString('connect_timeout=5', $dsn); + $this->assertStringNotContainsString('fixture-secret', $dsn); + + return $user === 'reader' && $password === 'fixture-secret'; + })->andReturn($pdo); + $probe->probe('pgsql', $this->connection(), '测试库'); + } + + public function test_driver_error_never_exposes_credentials(): void + { + $probe = Mockery::mock(UpstreamDatabaseProbe::class)->makePartial()->shouldAllowMockingProtectedMethods(); + $probe->shouldReceive('connect')->once()->andThrow(new PDOException('fixture-secret private driver detail')); + try { + $probe->probe('pgsql', $this->connection(), '测试库'); + $this->fail('Expected sanitized connection error'); + } catch (ServiceUnavailableHttpException $exception) { + $this->assertStringContainsString('PostgreSQL 连接失败', $exception->getMessage()); + $this->assertStringNotContainsString('fixture-secret', $exception->getMessage()); + $this->assertNull($exception->getPrevious()); + } + } + + public function test_unsupported_driver_is_not_probed(): void + { + $this->expectException(ServiceUnavailableHttpException::class); + $this->expectExceptionMessage('不支持此数据库类型'); + (new UpstreamDatabaseProbe())->probe('unknown', [], '测试库'); + } +}