修复云数据库管理不支持 PostgreSQL 连接 (#85)

This commit is contained in:
WuYongJie 2026-08-31 17:26:25 +08:00
parent 410e8bc04a
commit 11c79a2146
8 changed files with 288 additions and 31 deletions

View File

@ -15,6 +15,7 @@ use Illuminate\Routing\Controller;
use Illuminate\Validation\Rule; use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException; use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Services\UpstreamOdbcConnection; use Modules\EcommerceFinance\Services\UpstreamOdbcConnection;
use Modules\EcommerceFinance\Services\UpstreamDatabaseProbe;
use Modules\EcommerceFinance\Services\UpstreamSourceRegistry; use Modules\EcommerceFinance\Services\UpstreamSourceRegistry;
use Modules\EcommerceFinance\Services\UpstreamStoredConfiguration; use Modules\EcommerceFinance\Services\UpstreamStoredConfiguration;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException; use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
@ -258,24 +259,26 @@ class Databases extends Controller
return redirect()->route('system-settings.databases.index'); 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); abort_unless($storedConfiguration->editable() && $storedConfiguration->enabled(), 403);
$connection = $this->connection($connection); $connection = $this->connection($connection);
try { try {
UpstreamOdbcConnection::probeConfiguration($this->odbcConfiguration($connection), $connection->name); $probe->probe($connection->driver, $this->odbcConfiguration($connection), $connection->name);
} catch (DecryptException $exception) { } catch (DecryptException $exception) {
throw ValidationException::withMessages([ throw ValidationException::withMessages([
'connection' => $exception->getMessage(), 'connection' => $exception->getMessage(),
]); ]);
} catch (ServiceUnavailableHttpException) { } catch (ServiceUnavailableHttpException $exception) {
throw ValidationException::withMessages([ 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'); return redirect()->route('system-settings.databases.index');
} }
@ -283,8 +286,17 @@ class Databases extends Controller
private function validateConnection(Request $request, ?DatabaseConnection $connection = null): array private function validateConnection(Request $request, ?DatabaseConnection $connection = null): array
{ {
$sourceKeys = array_keys($this->sourceLabels()); $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([ return $request->validate([
'driver' => ['required', Rule::in(['sqlsrv', 'pgsql'])],
'name' => [ 'name' => [
'required', 'required',
'string', 'string',
@ -300,15 +312,20 @@ class Databases extends Controller
->where(fn ($query) => $query->where('company_id', UpstreamStoredConfiguration::SYSTEM_COMPANY_ID)) ->where(fn ($query) => $query->where('company_id', UpstreamStoredConfiguration::SYSTEM_COMPANY_ID))
->ignore($connection?->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'], 'port' => ['required', 'integer', 'between:1,65535'],
'database_name' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'], 'database_name' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'],
'username' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'], 'username' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'],
'password' => [$connection ? 'nullable' : 'required', 'string', 'max:1000'], 'password' => [$connection ? 'nullable' : 'required', 'string', 'max:1000'],
'odbc_driver' => ['required', 'string', 'max:128', 'not_regex:/[;{}\x00-\x1F]/'], 'odbc_driver' => [$postgres ? 'nullable' : 'required', 'string', 'max:128', 'not_regex:/[;{}\x00-\x1F]/'],
'tds_version' => ['required', 'regex:/^\d+(?:\.\d+)?$/', 'max:16'], 'tds_version' => [$postgres ? 'nullable' : 'required', 'regex:/^\d+(?:\.\d+)?$/', 'max:16'],
'connect_timeout' => ['required', 'integer', 'between:1,30'], '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'], 'allowed_scopes' => ['nullable', 'string', 'max:4000'],
'enabled' => ['nullable', 'boolean'], 'enabled' => ['nullable', 'boolean'],
]); ]);
@ -318,15 +335,15 @@ class Databases extends Controller
{ {
$attributes = [ $attributes = [
'company_id' => UpstreamStoredConfiguration::SYSTEM_COMPANY_ID, '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']), 'name' => trim($data['name']),
'driver' => 'sqlsrv', 'driver' => $data['driver'],
'host' => trim($data['host']), 'host' => trim($data['host']),
'port' => (int) $data['port'], 'port' => (int) $data['port'],
'database_name' => trim($data['database_name']), 'database_name' => trim($data['database_name']),
'username' => trim($data['username']), 'username' => trim($data['username']),
'odbc_driver' => trim($data['odbc_driver']), 'odbc_driver' => $data['driver'] === 'pgsql' ? null : trim($data['odbc_driver']),
'tds_version' => trim($data['tds_version']), 'tds_version' => $data['driver'] === 'pgsql' ? null : trim($data['tds_version']),
'connect_timeout' => (int) $data['connect_timeout'], 'connect_timeout' => (int) $data['connect_timeout'],
'query_timeout' => (int) $data['query_timeout'], 'query_timeout' => (int) $data['query_timeout'],
'allowed_scopes' => $this->parseScopes((string) ($data['allowed_scopes'] ?? '')), 'allowed_scopes' => $this->parseScopes((string) ($data['allowed_scopes'] ?? '')),
@ -374,6 +391,7 @@ class Databases extends Controller
{ {
return [ return [
'name' => '', 'name' => '',
'driver' => 'sqlsrv',
'source_key' => '', 'source_key' => '',
'enabled' => false, 'enabled' => false,
'host' => '', 'host' => '',
@ -392,6 +410,7 @@ class Databases extends Controller
{ {
return [ return [
'name' => $connection->name, 'name' => $connection->name,
'driver' => $connection->driver,
'source_key' => $connection->source_key, 'source_key' => $connection->source_key,
'enabled' => $connection->enabled, 'enabled' => $connection->enabled,
'host' => $connection->host, 'host' => $connection->host,

View File

@ -72,6 +72,12 @@ RUN --mount=type=cache,target=/tmp/composer-cache \
FROM php-base AS runtime 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 VCS_REF=unknown
ARG BUILD_DATE=unknown ARG BUILD_DATE=unknown

View File

@ -83,6 +83,11 @@ RUN --mount=type=cache,target=/root/.npm \
npm ci --no-audit --no-fund --legacy-peer-deps 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 . /var/www/html
COPY deploy/apache-vhost.conf /etc/apache2/sites-available/000-default.conf COPY deploy/apache-vhost.conf /etc/apache2/sites-available/000-default.conf
COPY deploy/php.ini /usr/local/etc/php/conf.d/jiangling.ini COPY deploy/php.ini /usr/local/etc/php/conf.d/jiangling.ini

View File

@ -0,0 +1,68 @@
<?php
namespace Modules\EcommerceFinance\Services;
use PDO;
use PDOException;
use SensitiveParameter;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
/** Connection tests only: never used to route business queries or synchronize tables. */
class UpstreamDatabaseProbe
{
public function probe(string $driver, #[SensitiveParameter] array $connection, string $label): void
{
if ($driver === 'sqlsrv') {
UpstreamOdbcConnection::probeConfiguration($connection, $label);
return;
}
if ($driver !== 'pgsql') {
throw new ServiceUnavailableHttpException(30, '不支持此数据库类型,请选择 SQL Server 或 PostgreSQL。');
}
$timeout = min(max((int) ($connection['connect_timeout'] ?? 5), 1), 30);
$queryTimeout = min(max((int) ($connection['query_timeout'] ?? 15), 1), 15) * 1000;
$dsn = 'pgsql:host=' . $this->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) . "'";
}
}

View File

@ -5,7 +5,8 @@
<div class="my-6"> <div class="my-6">
@include('system_settings.partials.navigation') @include('system_settings.partials.navigation')
<form method="POST" action="{{ $connection ? route('system-settings.databases.update', $connection->id) : route('system-settings.databases.store') }}" class="max-w-4xl rounded-xl border border-gray-200 bg-white p-6"> @php($postgres = old('driver', $values['driver']) === 'pgsql')
<form id="cloud-database-form" method="POST" action="{{ $connection ? route('system-settings.databases.update', $connection->id) : route('system-settings.databases.store') }}" class="max-w-4xl rounded-xl border border-gray-200 bg-white p-6">
@csrf @csrf
@if ($connection) @method('PATCH') @endif @if ($connection) @method('PATCH') @endif
@ -16,64 +17,97 @@
@endif @endif
<div class="grid grid-cols-1 gap-5 sm:grid-cols-2"> <div class="grid grid-cols-1 gap-5 sm:grid-cols-2">
<label class="block sm:col-span-2">
<span class="text-sm font-semibold text-gray-700">数据库类型 *</span>
<select name="driver" required class="mt-1 w-full rounded-lg border border-[#D8D2C7] px-3 py-2 text-sm focus:border-[#856632] focus:ring-[#856632]">
<option value="sqlsrv" @selected(! $postgres)>SQL Server</option>
<option value="pgsql" @selected($postgres)>PostgreSQL</option>
</select>
</label>
<p data-postgres-help class="text-sm text-gray-600 sm:col-span-2" style="{{ $postgres ? '' : 'display:none' }}" role="status">通过 SSL 加密连接(sslmode=require);仅执行只读测试,查询最长 15 秒。保存连接不会自动同步业务表。</p>
<label class="block sm:col-span-2"> <label class="block sm:col-span-2">
<span class="text-sm font-semibold text-gray-700">配置名称 *</span> <span class="text-sm font-semibold text-gray-700">配置名称 *</span>
<input name="name" value="{{ old('name', $values['name']) }}" required maxlength="191" placeholder="例如:JianSun 测试库" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm placeholder-gray-500 focus:border-purple-700 focus:ring-purple-700"> <input name="name" value="{{ old('name', $values['name']) }}" required maxlength="191" placeholder="例如:JianSun 测试库" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm placeholder-gray-500 focus:border-[#856632] focus:ring-[#856632]">
</label> </label>
<label class="block sm:col-span-2"> <label class="block sm:col-span-2">
<span class="text-sm font-semibold text-gray-700">主机地址 *</span> <span class="text-sm font-semibold text-gray-700">主机地址 *</span>
<input name="host" value="{{ old('host', $values['host']) }}" required maxlength="191" autocomplete="off" placeholder="数据库域名、IP 或本地隧道地址" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm placeholder-gray-500 focus:border-purple-700 focus:ring-purple-700"> <input name="host" value="{{ old('host', $values['host']) }}" required maxlength="191" autocomplete="off" placeholder="只填写域名或 IP,不含端口" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm placeholder-gray-500 focus:border-[#856632] focus:ring-[#856632]">
</label> </label>
<label class="block"> <label class="block">
<span class="text-sm font-semibold text-gray-700">端口 *</span> <span class="text-sm font-semibold text-gray-700">端口 *</span>
<input type="number" name="port" value="{{ old('port', $values['port']) }}" required min="1" max="65535" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-700 focus:ring-purple-700"> <input type="number" name="port" value="{{ old('port', $values['port']) }}" required min="1" max="65535" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#856632] focus:ring-[#856632]">
</label> </label>
<label class="block"> <label class="block">
<span class="text-sm font-semibold text-gray-700">数据库名称 *</span> <span class="text-sm font-semibold text-gray-700">数据库名称 *</span>
<input name="database_name" value="{{ old('database_name', $values['database_name']) }}" required maxlength="191" autocomplete="off" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-700 focus:ring-purple-700"> <input name="database_name" value="{{ old('database_name', $values['database_name']) }}" required maxlength="191" autocomplete="off" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#856632] focus:ring-[#856632]">
</label> </label>
<label class="block"> <label class="block">
<span class="text-sm font-semibold text-gray-700">只读账号 *</span> <span class="text-sm font-semibold text-gray-700">只读账号 *</span>
<input name="username" value="{{ old('username', $values['username']) }}" required maxlength="191" autocomplete="off" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-700 focus:ring-purple-700"> <input name="username" value="{{ old('username', $values['username']) }}" required maxlength="191" autocomplete="off" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#856632] focus:ring-[#856632]">
</label> </label>
<label class="block"> <label class="block">
<span class="text-sm font-semibold text-gray-700">密码 {{ $connection ? '' : '*' }}</span> <span class="text-sm font-semibold text-gray-700">密码 {{ $connection ? '' : '*' }}</span>
<input type="password" name="password" @required(! $connection) maxlength="1000" autocomplete="new-password" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-700 focus:ring-purple-700"> <input type="password" name="password" @required(! $connection) maxlength="1000" autocomplete="new-password" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#856632] focus:ring-[#856632]">
<span class="mt-1 block text-xs text-gray-500">{{ $connection ? '留空表示保留原密码。' : '保存后不会回显。' }}</span> <span class="mt-1 block text-xs text-gray-500">{{ $connection ? '留空表示保留原密码。' : '保存后不会回显。' }}</span>
</label> </label>
<label class="block sm:col-span-2"> <label data-sqlserver-only class="block sm:col-span-2" style="{{ $postgres ? 'display:none' : '' }}">
<span class="text-sm font-semibold text-gray-700">绑定业务来源</span> <span class="text-sm font-semibold text-gray-700">绑定业务来源(开发配置)</span>
<select name="source_key" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-700 focus:ring-purple-700"> <select name="source_key" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#856632] focus:ring-[#856632]">
<option value="">不绑定,仅保存和测试</option> <option value="">不绑定,仅保存和测试</option>
@foreach ($sourceLabels as $key => $label) @foreach ($sourceLabels as $key => $label)
<option value="{{ $key }}" @selected(old('source_key', $values['source_key']) === $key)>{{ $label }}</option> <option value="{{ $key }}" @selected(old('source_key', $values['source_key']) === $key)>{{ $label }}</option>
@endforeach @endforeach
</select> </select>
<span class="mt-1 block text-xs text-gray-500">用于关联现有业务查询;连接测试不需要选择。不要将新数据库绑定到其他业务库的来源。</span>
</label> </label>
</div> </div>
<details class="mt-6 rounded-lg border border-gray-200 px-4 py-3"> <details class="mt-6 rounded-lg border border-gray-200 px-4 py-3">
<summary class="cursor-pointer text-sm font-semibold text-gray-700">高级设置</summary> <summary class="cursor-pointer text-sm font-semibold text-gray-700">高级设置</summary>
<div class="mt-4 grid grid-cols-1 gap-5 sm:grid-cols-2"> <div class="mt-4 grid grid-cols-1 gap-5 sm:grid-cols-2">
<label class="block"><span class="text-sm font-semibold text-gray-700">ODBC 驱动</span><input name="odbc_driver" value="{{ old('odbc_driver', $values['odbc_driver']) }}" required maxlength="128" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-700 focus:ring-purple-700"></label> <label data-sqlserver-only class="block" style="{{ $postgres ? 'display:none' : '' }}"><span class="text-sm font-semibold text-gray-700">ODBC 驱动</span><input name="odbc_driver" value="{{ old('odbc_driver', $values['odbc_driver'] ?: 'FreeTDS') }}" required @disabled($postgres) maxlength="128" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#856632] focus:ring-[#856632]"></label>
<label class="block"><span class="text-sm font-semibold text-gray-700">TDS 版本</span><input name="tds_version" value="{{ old('tds_version', $values['tds_version']) }}" required maxlength="16" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-700 focus:ring-purple-700"></label> <label data-sqlserver-only class="block" style="{{ $postgres ? 'display:none' : '' }}"><span class="text-sm font-semibold text-gray-700">TDS 版本</span><input name="tds_version" value="{{ old('tds_version', $values['tds_version'] ?: '7.4') }}" required @disabled($postgres) maxlength="16" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#856632] focus:ring-[#856632]"></label>
<label class="block"><span class="text-sm font-semibold text-gray-700">连接超时(秒)</span><input type="number" name="connect_timeout" value="{{ old('connect_timeout', $values['connect_timeout']) }}" required min="1" max="30" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-700 focus:ring-purple-700"></label> <label class="block"><span class="text-sm font-semibold text-gray-700">连接超时(秒)</span><input type="number" name="connect_timeout" value="{{ old('connect_timeout', $values['connect_timeout']) }}" required min="1" max="30" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#856632] focus:ring-[#856632]"></label>
<label class="block"><span class="text-sm font-semibold text-gray-700">查询超时(秒)</span><input type="number" name="query_timeout" value="{{ old('query_timeout', $values['query_timeout']) }}" required min="1" max="120" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-purple-700 focus:ring-purple-700"></label> <label class="block"><span class="text-sm font-semibold text-gray-700">查询超时(秒)</span><input type="number" name="query_timeout" value="{{ old('query_timeout', $values['query_timeout']) }}" required min="1" max="120" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-[#856632] focus:ring-[#856632]"></label>
<label class="block sm:col-span-2"><span class="text-sm font-semibold text-gray-700">允许的数据范围</span><textarea name="allowed_scopes" rows="2" maxlength="4000" placeholder="绑定业务来源时填写,每行或逗号分隔" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm placeholder-gray-500 focus:border-purple-700 focus:ring-purple-700">{{ old('allowed_scopes', $values['allowed_scopes']) }}</textarea></label> <label class="block sm:col-span-2"><span class="text-sm font-semibold text-gray-700">允许的数据范围</span><textarea name="allowed_scopes" rows="2" maxlength="4000" placeholder="绑定业务来源时填写,每行或逗号分隔" class="mt-1 w-full rounded-lg border border-gray-300 px-3 py-2 text-sm placeholder-gray-500 focus:border-[#856632] focus:ring-[#856632]">{{ old('allowed_scopes', $values['allowed_scopes']) }}</textarea></label>
</div> </div>
</details> </details>
<div class="mt-6 flex items-center justify-between border-t border-gray-100 pt-5"> <div class="mt-6 flex items-center justify-between border-t border-gray-100 pt-5">
<div> <div>
<input type="hidden" name="enabled" value="0"> <input type="hidden" name="enabled" value="0">
<label class="inline-flex items-center gap-2 text-sm font-semibold text-gray-700"><input type="checkbox" name="enabled" value="1" @checked((bool) old('enabled', $values['enabled'])) class="rounded border-gray-300 text-purple-700 focus:ring-purple-700">启用</label> <label class="inline-flex items-center gap-2 text-sm font-semibold text-gray-700"><input type="checkbox" name="enabled" value="1" @checked((bool) old('enabled', $values['enabled'])) class="rounded border-gray-300 text-[#856632] focus:ring-[#856632]">启用</label>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<a href="{{ route('system-settings.databases.index') }}" class="rounded-lg px-5 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-100">取消</a> <a href="{{ route('system-settings.databases.index') }}" class="rounded-lg px-5 py-2 text-sm font-semibold text-gray-600 hover:bg-gray-100">取消</a>
<button type="submit" class="rounded-lg bg-purple-700 px-5 py-2 text-sm font-semibold text-white hover:bg-purple-800">保存</button> <button type="submit" class="rounded-lg bg-[#856632] px-5 py-2 text-sm font-semibold text-white hover:bg-[#6A5129]">保存</button>
</div> </div>
</div> </div>
</form> </form>
</div> </div>
@push('body_end')
<script>
(() => {
const form = document.getElementById('cloud-database-form');
const driver = form.elements.driver;
let previous = driver.value;
const update = (changePort) => {
const postgres = driver.value === 'pgsql';
if (changePort && form.elements.port.value === (previous === 'pgsql' ? '5432' : '1433')) {
form.elements.port.value = postgres ? '5432' : '1433';
}
form.querySelectorAll('[data-sqlserver-only]').forEach((group) => {
group.style.display = postgres ? 'none' : '';
group.querySelectorAll('input, select').forEach((field) => field.disabled = postgres);
});
form.querySelector('[data-postgres-help]').style.display = postgres ? '' : 'none';
form.elements.query_timeout.max = postgres ? '15' : '120';
previous = driver.value;
};
driver.addEventListener('change', () => update(true));
update(false);
})();
</script>
@endpush
</x-slot> </x-slot>
</x-layouts.admin> </x-layouts.admin>

View File

@ -96,7 +96,7 @@
<tbody class="divide-y divide-gray-100"> <tbody class="divide-y divide-gray-100">
@forelse ($connections as $connection) @forelse ($connections as $connection)
<tr> <tr>
<td class="px-5 py-4 font-semibold text-gray-900">{{ $connection->name }}</td> <td class="px-5 py-4 font-semibold text-gray-900">{{ $connection->name }}<span class="mt-1 block text-xs font-normal text-gray-600">{{ $connection->driver === 'pgsql' ? 'PostgreSQL · SSL' : 'SQL Server' }}</span></td>
<td class="px-5 py-4 text-gray-600">{{ $connection->host }}:{{ $connection->port }}</td> <td class="px-5 py-4 text-gray-600">{{ $connection->host }}:{{ $connection->port }}</td>
<td class="px-5 py-4 text-gray-600">{{ $connection->database_name }}</td> <td class="px-5 py-4 text-gray-600">{{ $connection->database_name }}</td>
<td class="px-5 py-4 text-gray-600">{{ $sourceLabels[$connection->source_key] ?? '未绑定' }}</td> <td class="px-5 py-4 text-gray-600">{{ $sourceLabels[$connection->source_key] ?? '未绑定' }}</td>

View File

@ -14,6 +14,7 @@ use Illuminate\Support\Facades\DB;
use Mockery; use Mockery;
use Modules\EcommerceFinance\Services\UpstreamSourceRegistry; use Modules\EcommerceFinance\Services\UpstreamSourceRegistry;
use Modules\EcommerceFinance\Services\UpstreamStoredConfiguration; use Modules\EcommerceFinance\Services\UpstreamStoredConfiguration;
use Modules\EcommerceFinance\Services\UpstreamDatabaseProbe;
use Tests\Feature\FeatureTestCase; use Tests\Feature\FeatureTestCase;
class UpstreamDatabasesTest extends FeatureTestCase class UpstreamDatabasesTest extends FeatureTestCase
@ -499,6 +500,65 @@ class UpstreamDatabasesTest extends FeatureTestCase
->assertSessionHasErrors('password'); ->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 private function payload(array $overrides = []): array
{ {
return array_merge([ return array_merge([

View File

@ -0,0 +1,65 @@
<?php
namespace Tests\Unit\EcommerceFinance;
use Mockery;
use Mockery\Adapter\Phpunit\MockeryPHPUnitIntegration;
use Modules\EcommerceFinance\Services\UpstreamDatabaseProbe;
use PDO;
use PDOException;
use PDOStatement;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
class UpstreamDatabaseProbeTest extends TestCase
{
use MockeryPHPUnitIntegration;
private function connection(): array
{
return ['host' => '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', [], '测试库');
}
}