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