From 11c79a21468acef6c37afbcb7eec401af644a58b Mon Sep 17 00:00:00 2001
From: WuYongJie <549727955@qq.com>
Date: Mon, 31 Aug 2026 17:26:25 +0800
Subject: [PATCH 1/2] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E4=BA=91=E6=95=B0?=
=?UTF-8?q?=E6=8D=AE=E5=BA=93=E7=AE=A1=E7=90=86=E4=B8=8D=E6=94=AF=E6=8C=81?=
=?UTF-8?q?=20PostgreSQL=20=E8=BF=9E=E6=8E=A5=20(#85)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../Controllers/SystemSettings/Databases.php | 45 ++++++++----
deploy/Dockerfile | 6 ++
deploy/Dockerfile.local | 5 ++
.../Services/UpstreamDatabaseProbe.php | 68 +++++++++++++++++++
.../system_settings/databases/form.blade.php | 68 ++++++++++++++-----
.../system_settings/databases/index.blade.php | 2 +-
.../SystemSettings/UpstreamDatabasesTest.php | 60 ++++++++++++++++
.../UpstreamDatabaseProbeTest.php | 65 ++++++++++++++++++
8 files changed, 288 insertions(+), 31 deletions(-)
create mode 100644 modules/EcommerceFinance/Services/UpstreamDatabaseProbe.php
create mode 100644 tests/Unit/EcommerceFinance/UpstreamDatabaseProbeTest.php
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')
-
+ @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', [], '测试库');
+ }
+}
From 8cbd1f21be58e6508a9f7590d039632ee9b0d289 Mon Sep 17 00:00:00 2001
From: WuYongJie <549727955@qq.com>
Date: Mon, 31 Aug 2026 17:50:42 +0800
Subject: [PATCH 2/2] =?UTF-8?q?=E4=BF=AE=E6=AD=A3=E6=96=B0=E4=BA=BA?=
=?UTF-8?q?=E4=B8=B4=E6=97=B6=E5=BC=95=E5=AF=BC=E5=BA=93=E4=B8=8E=E5=85=B1?=
=?UTF-8?q?=E4=BA=AB=E6=95=B0=E6=8D=AE=E5=BA=93=E6=8E=A5=E5=85=A5=E7=8A=B6?=
=?UTF-8?q?=E6=80=81=E7=9A=84=E8=AF=AF=E5=88=A4=20(#86)?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/Console/Commands/ApplicationReady.php | 9 ++++-
app/Console/Commands/VerifySystemDatabase.php | 16 +++++---
app/Utilities/SystemDatabaseConfiguration.php | 1 +
deploy/local-init.sh | 32 ++++++++++-----
.../Installation/ApplicationReadyTest.php | 16 ++++++++
.../Installation/VerifySystemDatabaseTest.php | 35 ++++++++++++++++-
.../LocalSharedDatabaseStatusTest.php | 39 +++++++++++++++++++
7 files changed, 131 insertions(+), 17 deletions(-)
create mode 100644 tests/Unit/Deployment/LocalSharedDatabaseStatusTest.php
diff --git a/app/Console/Commands/ApplicationReady.php b/app/Console/Commands/ApplicationReady.php
index eac78802..34ca2d43 100644
--- a/app/Console/Commands/ApplicationReady.php
+++ b/app/Console/Commands/ApplicationReady.php
@@ -2,6 +2,7 @@
namespace App\Console\Commands;
+use App\Utilities\SystemDatabaseConfiguration;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\DB;
use Throwable;
@@ -18,7 +19,7 @@ class ApplicationReady extends Command
protected $description = '检查应用安装、运行目录和数据库是否已经可用';
- public function handle(): int
+ public function handle(SystemDatabaseConfiguration $configuration): int
{
if (! config('app.installed')) {
return $this->failed('APP_INSTALLED 未被 Laravel 加载。', self::EXIT_NOT_INSTALLED);
@@ -42,7 +43,11 @@ class ApplicationReady extends Command
return $this->failed('Laravel 数据库连接失败。', self::EXIT_DATABASE_CONNECTION_FAILED);
}
- $this->components->info('应用已就绪。');
+ if ($configuration->current()['mode'] === 'bootstrap') {
+ $this->components->info('本地网页和临时引导库已就绪;团队共享数据尚未接入。');
+ } else {
+ $this->components->info('应用运行正常,当前数据库可查询。团队数据接入状态请运行 system-database:verify 检查。');
+ }
return self::SUCCESS;
}
diff --git a/app/Console/Commands/VerifySystemDatabase.php b/app/Console/Commands/VerifySystemDatabase.php
index 658965b0..52d8ddb0 100644
--- a/app/Console/Commands/VerifySystemDatabase.php
+++ b/app/Console/Commands/VerifySystemDatabase.php
@@ -15,6 +15,8 @@ class VerifySystemDatabase extends Command
public const EXIT_SYSTEM_TABLE_VALIDATION_FAILED = 12;
+ public const EXIT_SHARED_NOT_CONFIGURED = 13;
+
protected $signature = 'system-database:verify';
protected $description = '在新的 Laravel 进程中验证当前共享系统数据库';
@@ -30,12 +32,16 @@ class VerifySystemDatabase extends Command
);
}
- if (
- $settings['mode'] !== 'shared'
- || ! config('upstreams.stored_configuration.enabled', false)
- ) {
+ if ($settings['mode'] === 'bootstrap') {
+ $this->components->warn('当前仍使用本机临时引导库,尚未接入团队共享系统数据库;本次没有测试远程连接。');
+ $this->line('下一步:在“系统设置 → 数据库管理 → 系统数据库”中输入一次负责人提供的系统数据库密码。');
+
+ return self::EXIT_SHARED_NOT_CONFIGURED;
+ }
+
+ if ($settings['mode'] !== 'shared' || ! config('upstreams.stored_configuration.enabled', false)) {
return $this->failed(
- '新配置未被 Laravel 加载。',
+ '共享配置未完整启用,请通过系统数据库连接页面重新保存;不要仅手工修改运行模式。',
self::EXIT_CONFIG_NOT_LOADED
);
}
diff --git a/app/Utilities/SystemDatabaseConfiguration.php b/app/Utilities/SystemDatabaseConfiguration.php
index 312100f4..e95eb153 100644
--- a/app/Utilities/SystemDatabaseConfiguration.php
+++ b/app/Utilities/SystemDatabaseConfiguration.php
@@ -462,6 +462,7 @@ class SystemDatabaseConfiguration
VerifySystemDatabase::EXIT_CONFIG_NOT_LOADED => '新配置未被加载',
VerifySystemDatabase::EXIT_DATABASE_CONNECTION_FAILED => 'Laravel 数据库连接失败',
VerifySystemDatabase::EXIT_SYSTEM_TABLE_VALIDATION_FAILED => '系统表验证失败',
+ VerifySystemDatabase::EXIT_SHARED_NOT_CONFIGURED => '新配置仍指向本机临时引导库',
default => '应用级验证失败',
};
}
diff --git a/deploy/local-init.sh b/deploy/local-init.sh
index a01ff624..e2a32135 100644
--- a/deploy/local-init.sh
+++ b/deploy/local-init.sh
@@ -397,16 +397,30 @@ fi
echo ""
echo "====================================================="
-echo "本地测试环境已就绪:http://127.0.0.1:18088"
+echo "本地网页可访问:http://127.0.0.1:18088"
-db_host="$(read_env_value DB_HOST)"
-if [ "$db_host" = "mysql" ]; then
- echo "临时登录凭据保存在本机私有文件:$credential_file"
- echo "请自行打开该文件,不要把内容提交到 Git、聊天或日志。"
- echo ""
- echo "下一步:登录后打开“系统设置 → 数据库管理 → 系统数据库”,"
- echo "点击“连接”,只填写负责人提供的系统数据库密码。"
+# Docker healthy 只表示当前连接可用;共享接入必须由新 Laravel 进程实测,
+# 不能根据 DB_HOST 不是 mysql 就宣称团队数据已连接。
+if docker compose -f "$compose_file" exec -T -u www-data app \
+ php artisan system-database:verify --no-ansi; then
+ shared_status=0
else
- echo "已连接团队共享系统数据库,请使用其中属于自己的正式账号登录。"
+ shared_status=$?
fi
+
+case "$shared_status" in
+ 0)
+ echo "团队共享系统数据库验证通过,请使用其中属于自己的正式账号登录。"
+ ;;
+ 13)
+ echo "团队数据接入尚未完成;后台 worker 启动不代表数据已经同步。"
+ echo "临时登录凭据保存在本机私有文件:$credential_file"
+ echo "请登录后输入一次系统数据库密码,连接成功后改用团队正式账号登录。"
+ ;;
+ *)
+ echo "团队共享系统数据库未通过验证;不要将 Docker healthy 当作接入成功。" >&2
+ echo "请让 Codex 根据上方失败阶段检查本机配置,保留现有配置和未提交代码。" >&2
+ exit "$shared_status"
+ ;;
+esac
echo "====================================================="
diff --git a/tests/Feature/Installation/ApplicationReadyTest.php b/tests/Feature/Installation/ApplicationReadyTest.php
index 74f5cc4d..947cb0f0 100644
--- a/tests/Feature/Installation/ApplicationReadyTest.php
+++ b/tests/Feature/Installation/ApplicationReadyTest.php
@@ -18,4 +18,20 @@ class ApplicationReadyTest extends TestCase
$this->artisan('app:ready')
->assertExitCode(\App\Console\Commands\ApplicationReady::EXIT_NOT_INSTALLED);
}
+
+ public function test_bootstrap_stays_healthy_without_claiming_shared_data_is_ready(): void
+ {
+ config(['app.installed' => true, 'system_database.mode' => 'bootstrap']);
+ $this->artisan('app:ready')
+ ->expectsOutputToContain('团队共享数据尚未接入')
+ ->assertSuccessful();
+ }
+
+ public function test_shared_application_health_does_not_claim_upstream_sync_success(): void
+ {
+ config(['app.installed' => true, 'system_database.mode' => 'shared']);
+ $this->artisan('app:ready')
+ ->expectsOutputToContain('团队数据接入状态请运行 system-database:verify')
+ ->assertSuccessful();
+ }
}
diff --git a/tests/Feature/Installation/VerifySystemDatabaseTest.php b/tests/Feature/Installation/VerifySystemDatabaseTest.php
index 95284d83..059cba2a 100644
--- a/tests/Feature/Installation/VerifySystemDatabaseTest.php
+++ b/tests/Feature/Installation/VerifySystemDatabaseTest.php
@@ -10,7 +10,7 @@ use Tests\TestCase;
class VerifySystemDatabaseTest extends TestCase
{
- public function test_verify_command_reports_configuration_not_loaded(): void
+ public function test_bootstrap_is_not_reported_as_a_failed_remote_connection(): void
{
$configuration = Mockery::mock(SystemDatabaseConfiguration::class);
$configuration->shouldReceive('current')->once()->andReturn([
@@ -19,9 +19,42 @@ class VerifySystemDatabaseTest extends TestCase
$this->app->instance(SystemDatabaseConfiguration::class, $configuration);
$this->artisan('system-database:verify')
+ ->expectsOutputToContain('尚未接入团队共享系统数据库;本次没有测试远程连接')
+ ->assertExitCode(VerifySystemDatabase::EXIT_SHARED_NOT_CONFIGURED);
+ }
+
+ public function test_shared_mode_with_disabled_stored_configuration_is_not_ready(): void
+ {
+ config(['upstreams.stored_configuration.enabled' => false]);
+ $configuration = Mockery::mock(SystemDatabaseConfiguration::class);
+ $configuration->shouldReceive('current')->once()->andReturn(['mode' => 'shared']);
+ $this->app->instance(SystemDatabaseConfiguration::class, $configuration);
+ $this->artisan('system-database:verify')
+ ->expectsOutputToContain('共享配置未完整启用')
->assertExitCode(VerifySystemDatabase::EXIT_CONFIG_NOT_LOADED);
}
+ public function test_current_configuration_failure_remains_a_configuration_error(): void
+ {
+ $configuration = Mockery::mock(SystemDatabaseConfiguration::class);
+ $configuration->shouldReceive('current')->once()->andThrow(new RuntimeException('private details'));
+ $this->app->instance(SystemDatabaseConfiguration::class, $configuration);
+ $this->artisan('system-database:verify')
+ ->expectsOutputToContain('新配置未被 Laravel 加载')
+ ->assertExitCode(VerifySystemDatabase::EXIT_CONFIG_NOT_LOADED);
+ }
+
+ public function test_shared_success_requires_connection_and_system_table_verification(): void
+ {
+ $this->enableSharedMode();
+ $settings = ['mode' => 'shared', 'connection' => config('database.default')];
+ $configuration = Mockery::mock(SystemDatabaseConfiguration::class);
+ $configuration->shouldReceive('current')->once()->andReturn($settings);
+ $configuration->shouldReceive('probe')->once()->with($settings)->andReturn([]);
+ $this->app->instance(SystemDatabaseConfiguration::class, $configuration);
+ $this->artisan('system-database:verify')->expectsOutputToContain('共享系统数据库已通过应用级验证')->assertSuccessful();
+ }
+
public function test_verify_command_reports_database_connection_failure(): void
{
$this->enableSharedMode();
diff --git a/tests/Unit/Deployment/LocalSharedDatabaseStatusTest.php b/tests/Unit/Deployment/LocalSharedDatabaseStatusTest.php
new file mode 100644
index 00000000..13c6b4a5
--- /dev/null
+++ b/tests/Unit/Deployment/LocalSharedDatabaseStatusTest.php
@@ -0,0 +1,39 @@
+assertNotFalse($start);
+ $process = new Process(['bash', '-c',
+ 'set -eu; compose_file=fixture; credential_file=fixture; docker() { return ' . $verificationExit . '; }; '
+ . substr($script, $start),
+ ]);
+ $process->setTimeout(5);
+ $process->run();
+ $this->assertSame($expectedExit, $process->getExitCode());
+ $this->assertStringContainsString($message, $process->getOutput() . $process->getErrorOutput());
+ if ($verificationExit !== 0) {
+ $this->assertStringNotContainsString('团队共享系统数据库验证通过', $process->getOutput());
+ }
+ }
+
+ public static function verificationStates(): array
+ {
+ return [
+ 'bootstrap' => [13, 0, '团队数据接入尚未完成'],
+ 'shared success' => [0, 0, '团队共享系统数据库验证通过'],
+ 'config failure' => [10, 10, '未通过验证'],
+ 'network failure' => [11, 11, '未通过验证'],
+ 'schema failure' => [12, 12, '未通过验证'],
+ ];
+ }
+}