fix: support TLS PostgreSQL cloud database connections
This commit is contained in:
parent
410e8bc04a
commit
d67ff08a2b
@ -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,
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
68
modules/EcommerceFinance/Services/UpstreamDatabaseProbe.php
Normal file
68
modules/EcommerceFinance/Services/UpstreamDatabaseProbe.php
Normal 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) . "'";
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,8 @@
|
||||
<div class="my-6">
|
||||
@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
|
||||
@if ($connection) @method('PATCH') @endif
|
||||
|
||||
@ -16,64 +17,97 @@
|
||||
@endif
|
||||
|
||||
<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">
|
||||
<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 class="block sm:col-span-2">
|
||||
<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 class="block">
|
||||
<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 class="block">
|
||||
<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 class="block">
|
||||
<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 class="block">
|
||||
<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>
|
||||
</label>
|
||||
<label class="block sm:col-span-2">
|
||||
<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">
|
||||
<label data-sqlserver-only class="block sm:col-span-2" style="{{ $postgres ? 'display:none' : '' }}">
|
||||
<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-[#856632] focus:ring-[#856632]">
|
||||
<option value="">不绑定,仅保存和测试</option>
|
||||
@foreach ($sourceLabels as $key => $label)
|
||||
<option value="{{ $key }}" @selected(old('source_key', $values['source_key']) === $key)>{{ $label }}</option>
|
||||
@endforeach
|
||||
</select>
|
||||
<span class="mt-1 block text-xs text-gray-500">用于关联现有业务查询;连接测试不需要选择。不要将新数据库绑定到其他业务库的来源。</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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 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 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="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 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 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 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-[#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-[#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-[#856632] focus:ring-[#856632]">{{ old('allowed_scopes', $values['allowed_scopes']) }}</textarea></label>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<div class="mt-6 flex items-center justify-between border-t border-gray-100 pt-5">
|
||||
<div>
|
||||
<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 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>
|
||||
<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>
|
||||
</form>
|
||||
</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-layouts.admin>
|
||||
|
||||
@ -96,7 +96,7 @@
|
||||
<tbody class="divide-y divide-gray-100">
|
||||
@forelse ($connections as $connection)
|
||||
<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->database_name }}</td>
|
||||
<td class="px-5 py-4 text-gray-600">{{ $sourceLabels[$connection->source_key] ?? '未绑定' }}</td>
|
||||
|
||||
@ -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([
|
||||
|
||||
65
tests/Unit/EcommerceFinance/UpstreamDatabaseProbeTest.php
Normal file
65
tests/Unit/EcommerceFinance/UpstreamDatabaseProbeTest.php
Normal 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', [], '测试库');
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user