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