diff --git a/AGENTS.md b/AGENTS.md index 074f6a93..ce761ea7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -148,6 +148,7 @@ - 生产服务器不能直接访问某个上游时,只允许通过专用低权限 SSH 账户建立受限反向隧道;每个来源使用独立 `SourceKey`、私有配置、计划任务和 Docker 私网端口,禁止监听公网地址,隧道账户禁止 Shell、TTY、本地转发和任意端口转发。 - 隧道由 `scripts/keep-upstream-tunnel.ps1` 自动重连,并通过 `scripts/install-upstream-tunnel-task.ps1 -SourceKey <来源标识>` 安装。数据库凭据不得作为隧道参数;每台候选中继开发机使用独立 SSH 私钥,不得多人共享私钥。 - 每个来源最多安排 1 台主中继和 1 台备用中继;同一来源同一时刻只允许一个远端监听成功,备用机低频重试。隧道中断时页面继续读取该来源旧快照,禁止绕过缓存直接重试上游。 -- 上游数据库账号和密码只允许存在于服务器私有环境配置;实际端点除服务器配置外,只允许存在于中继开发机当前用户专属、已收紧 ACL 的隧道配置。代码、提交、PR、共享文档和日志中一律不得出现真实值。 +- 系统数据库的启动连接只允许存在于服务器私有 `runtime/.env`;超级管理员可在“数据库管理”中修改,但保存前必须验证 TLS、系统必需表和用户数据,并在持久化 `storage` 中生成私有备份。 +- 云数据库连接与系统数据库配置必须分开。显式开启 `UPSTREAM_STORED_CONFIG_ENABLED` 后,云数据库密码使用当前 `APP_KEY` 加密保存到系统数据库的 `system_database_connections`;真实值仍不得进入代码、提交、PR、共享文档或日志。中继开发机只保留已收紧 ACL 的隧道端点配置,不保存数据库密码。 详细 SOP 见 `docs/开发与部署SOP.md`。 diff --git a/app/Http/Controllers/SystemSettings/Databases.php b/app/Http/Controllers/SystemSettings/Databases.php index b4bffb3f..386dfa5e 100644 --- a/app/Http/Controllers/SystemSettings/Databases.php +++ b/app/Http/Controllers/SystemSettings/Databases.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers\SystemSettings; use App\Models\Settings\DatabaseConnection; use App\Utilities\SystemAccess; +use App\Utilities\SystemDatabaseConfiguration; use Illuminate\Http\Request; use Illuminate\Routing\Controller; use Illuminate\Validation\Rule; @@ -24,10 +25,12 @@ class Databases extends Controller }); } - public function index(UpstreamStoredConfiguration $storedConfiguration) + public function index( + UpstreamStoredConfiguration $storedConfiguration, + SystemDatabaseConfiguration $systemDatabaseConfiguration + ) { - $defaultConnection = (string) config('database.default'); - $systemDatabase = (array) config('database.connections.' . $defaultConnection, []); + $systemDatabase = $systemDatabaseConfiguration->current(); return view('system_settings.databases.index', [ 'connections' => $storedConfiguration->editable() @@ -38,16 +41,79 @@ class Databases extends Controller : collect(), 'sourceLabels' => $this->sourceLabels(), 'systemDatabase' => [ - 'connection' => $defaultConnection, - 'driver' => $systemDatabase['driver'] ?? $defaultConnection, - 'host' => $systemDatabase['host'] ?? '本机文件', - 'port' => $systemDatabase['port'] ?? null, - 'database' => $systemDatabase['database'] ?? '', + 'connection' => $systemDatabase['connection'], + 'driver' => $systemDatabase['driver'], + 'host' => $systemDatabase['host'] ?: '本机文件', + 'port' => $systemDatabase['port'], + 'database' => $systemDatabase['database'], + 'tls_required' => $systemDatabase['tls_required'], ], 'editable' => $storedConfiguration->editable(), ]); } + public function editSystem(SystemDatabaseConfiguration $configuration) + { + $current = $configuration->current(); + + return view('system_settings.databases.system', [ + 'values' => [ + 'host' => $current['host'], + 'port' => $current['port'], + 'database' => $current['database'], + 'username' => $current['username'], + 'tls_required' => $current['tls_required'], + ], + ]); + } + + public function updateSystem(Request $request, SystemDatabaseConfiguration $configuration) + { + $current = $configuration->current(); + $data = $request->validate([ + 'host' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'], + 'port' => ['required', 'integer', 'between:1,65535'], + 'database' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'], + 'username' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'], + 'password' => ['nullable', 'string', 'max:1000', 'not_regex:/[\r\n\x00]/'], + 'tls_required' => ['nullable', 'boolean'], + ]); + + $settings = [ + 'driver' => 'mysql', + 'host' => trim($data['host']), + 'port' => (int) $data['port'], + 'database' => trim($data['database']), + 'username' => trim($data['username']), + 'password' => $data['password'] !== null && $data['password'] !== '' + ? $data['password'] + : $current['password'], + 'tls_required' => $request->boolean('tls_required'), + ]; + + if ($settings['password'] === '') { + throw ValidationException::withMessages([ + 'password' => '当前没有可保留的密码,请填写数据库密码。', + ]); + } + + try { + $result = $configuration->probe($settings); + $configuration->persist($settings); + } catch (\RuntimeException $exception) { + throw ValidationException::withMessages([ + 'connection' => $exception->getMessage(), + ]); + } + + flash(sprintf( + '系统数据库连接已验证并保存,共检测到 %d 个用户;新配置将在下一次请求生效。', + $result['user_count'] + ))->success(); + + return redirect()->route('system-settings.databases.index'); + } + public function create(UpstreamStoredConfiguration $storedConfiguration) { abort_unless($storedConfiguration->editable(), 403); diff --git a/app/Utilities/SystemDatabaseConfiguration.php b/app/Utilities/SystemDatabaseConfiguration.php new file mode 100644 index 00000000..d0cc46a1 --- /dev/null +++ b/app/Utilities/SystemDatabaseConfiguration.php @@ -0,0 +1,197 @@ +environmentPath = $environmentPath ?: base_path('.env'); + } + + public function current(): array + { + $connectionName = (string) config('database.default', 'mysql'); + $connection = (array) config('database.connections.' . $connectionName, []); + $options = (array) ($connection['options'] ?? []); + + return [ + 'connection' => $connectionName, + 'driver' => (string) ($connection['driver'] ?? $connectionName), + 'host' => (string) ( + data_get($connection, 'write.host.0') + ?? data_get($connection, 'read.host.0') + ?? ($connection['host'] ?? '') + ), + 'port' => (int) ($connection['port'] ?? 3306), + 'database' => (string) ($connection['database'] ?? ''), + 'username' => (string) ($connection['username'] ?? ''), + 'password' => (string) ($connection['password'] ?? ''), + 'prefix' => (string) ($connection['prefix'] ?? ''), + 'tls_required' => isset($options[PDO::MYSQL_ATTR_SSL_CA]) + || isset($options[PDO::MYSQL_ATTR_SSL_CIPHER]), + ]; + } + + /** + * @return array{cipher:string,user_count:int} + */ + public function probe(array $settings): array + { + if (($settings['driver'] ?? 'mysql') !== 'mysql') { + throw new RuntimeException('系统数据库目前只支持 MariaDB/MySQL。'); + } + + $options = [ + PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, + PDO::ATTR_TIMEOUT => 8, + ]; + + if (! empty($settings['tls_required'])) { + $options[PDO::MYSQL_ATTR_SSL_CIPHER] = 'DEFAULT'; + + if (defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')) { + $options[constant('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')] = false; + } + } + + try { + $pdo = new PDO( + sprintf( + 'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', + $settings['host'], + $settings['port'], + $settings['database'] + ), + $settings['username'], + $settings['password'], + $options + ); + + $cipher = (string) ($pdo + ->query("SHOW SESSION STATUS LIKE 'Ssl_cipher'") + ->fetch(PDO::FETCH_NUM)[1] ?? ''); + + if (! empty($settings['tls_required']) && $cipher === '') { + throw new RuntimeException('数据库连接没有启用 TLS 加密。'); + } + + $prefix = $this->current()['prefix']; + $requiredTables = array_map( + fn (string $table) => $prefix . $table, + ['migrations', 'users', 'roles', 'user_roles', 'user_companies', 'settings'] + ); + $placeholders = implode(',', array_fill(0, count($requiredTables), '?')); + $statement = $pdo->prepare( + "SELECT COUNT(*) FROM information_schema.tables " + . "WHERE table_schema = DATABASE() AND table_name IN ($placeholders)" + ); + $statement->execute($requiredTables); + + if ((int) $statement->fetchColumn() !== count($requiredTables)) { + throw new RuntimeException('目标库缺少系统必需表,已拒绝切换。'); + } + + $userCount = (int) $pdo->query( + 'SELECT COUNT(*) FROM `' . str_replace('`', '``', $prefix . 'users') . '`' + )->fetchColumn(); + + if ($userCount < 1) { + throw new RuntimeException('目标库没有任何用户,已拒绝切换。'); + } + + return ['cipher' => $cipher, 'user_count' => $userCount]; + } catch (RuntimeException $exception) { + throw $exception; + } catch (Throwable $exception) { + throw new RuntimeException('系统数据库连接测试失败。', 0, $exception); + } + } + + public function persist(array $settings): string + { + if (! is_file($this->environmentPath) || ! is_writable($this->environmentPath)) { + throw new RuntimeException('服务器私有启动配置不可写,未执行切换。'); + } + + $current = file_get_contents($this->environmentPath); + + if ($current === false) { + throw new RuntimeException('无法读取服务器私有启动配置。'); + } + + $backupDirectory = storage_path('app/system-database-config-backups'); + + if (! is_dir($backupDirectory) && ! mkdir($backupDirectory, 0700, true) && ! is_dir($backupDirectory)) { + throw new RuntimeException('无法创建系统数据库配置备份目录。'); + } + + $backupPath = $backupDirectory . DIRECTORY_SEPARATOR . 'runtime-env-' . now()->format('Ymd-His') . '.backup'; + + if (file_put_contents($backupPath, $current, LOCK_EX) === false) { + throw new RuntimeException('系统数据库配置备份失败,未执行切换。'); + } + + chmod($backupPath, 0600); + + $updated = $this->replaceEnvironmentValues($current, [ + 'DB_CONNECTION' => 'mysql', + 'DB_HOST' => (string) $settings['host'], + 'DB_PORT' => (string) $settings['port'], + 'DB_DATABASE' => (string) $settings['database'], + 'DB_USERNAME' => (string) $settings['username'], + 'DB_PASSWORD' => (string) $settings['password'], + 'MYSQL_ATTR_SSL_CIPHER' => ! empty($settings['tls_required']) ? 'DEFAULT' : '', + 'MYSQL_ATTR_SSL_VERIFY_SERVER_CERT' => 'false', + ]); + + if (file_put_contents($this->environmentPath, $updated, LOCK_EX) === false) { + file_put_contents($this->environmentPath, $current, LOCK_EX); + throw new RuntimeException('系统数据库配置保存失败,已恢复原配置。'); + } + + Artisan::call('config:clear'); + + return $backupPath; + } + + private function replaceEnvironmentValues(string $environment, array $values): string + { + $lines = preg_split('/\r\n|\r|\n/', $environment) ?: []; + + foreach ($values as $key => $value) { + $replacement = $key . '=' . $this->encodeEnvironmentValue((string) $value); + $found = false; + + foreach ($lines as $index => $line) { + if (preg_match('/^' . preg_quote($key, '/') . '=/', $line) === 1) { + $lines[$index] = $replacement; + $found = true; + break; + } + } + + if (! $found) { + $lines[] = $replacement; + } + } + + return rtrim(implode("\n", $lines), "\n") . "\n"; + } + + private function encodeEnvironmentValue(string $value): string + { + return '"' . str_replace( + ['\\', '"', '$', "\r", "\n"], + ['\\\\', '\\"', '\\$', '', '\\n'], + $value + ) . '"'; + } +} diff --git a/compose.jiangling.yml b/compose.jiangling.yml index 33576579..faa451a6 100644 --- a/compose.jiangling.yml +++ b/compose.jiangling.yml @@ -34,7 +34,9 @@ services: - "0.0.0.0:13306:3306" volumes: - caiwu_config_database:/var/lib/mysql + - ./deploy/config-db-init.sh:/docker-entrypoint-initdb.d/10-config-db-security.sh:ro networks: + - default - config_database mem_limit: 256m cpus: "0.50" @@ -53,6 +55,8 @@ services: depends_on: db: condition: service_healthy + config_db: + condition: service_healthy environment: RUN_MIGRATIONS: ${RUN_MIGRATIONS:-0} ports: diff --git a/config/database.php b/config/database.php index 6914f878..c46cb383 100644 --- a/config/database.php +++ b/config/database.php @@ -2,6 +2,34 @@ use Illuminate\Support\Str; +$mysqlSslOptions = static function (): array { + if (! extension_loaded('pdo_mysql')) { + return []; + } + + $options = []; + $ca = env('MYSQL_ATTR_SSL_CA'); + $cipher = env('MYSQL_ATTR_SSL_CIPHER'); + $verifyServerCertificate = env('MYSQL_ATTR_SSL_VERIFY_SERVER_CERT'); + + if (is_string($ca) && trim($ca) !== '') { + $options[PDO::MYSQL_ATTR_SSL_CA] = $ca; + } + + if (is_string($cipher) && trim($cipher) !== '') { + $options[PDO::MYSQL_ATTR_SSL_CIPHER] = $cipher; + } + + if ($verifyServerCertificate !== null && defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')) { + $options[constant('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')] = filter_var( + $verifyServerCertificate, + FILTER_VALIDATE_BOOL + ); + } + + return $options; +}; + return [ /* @@ -76,9 +104,7 @@ return [ 'ERROR_FOR_DIVISION_BY_ZERO', 'NO_ENGINE_SUBSTITUTION', ], - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), - ]) : [], + 'options' => $mysqlSslOptions(), ], 'mariadb' => [ @@ -114,9 +140,7 @@ return [ 'ERROR_FOR_DIVISION_BY_ZERO', 'NO_ENGINE_SUBSTITUTION', ], - 'options' => extension_loaded('pdo_mysql') ? array_filter([ - PDO::MYSQL_ATTR_SSL_CA => env('MYSQL_ATTR_SSL_CA'), - ]) : [], + 'options' => $mysqlSslOptions(), ], 'pgsql' => [ diff --git a/config/upstreams.php b/config/upstreams.php index dc7006bb..916f13c3 100644 --- a/config/upstreams.php +++ b/config/upstreams.php @@ -7,7 +7,7 @@ return [ |-------------------------------------------------------------------------- | | 每个来源使用独立配置、缓存命名空间、查询预算和熔断状态。新增来源时 - | 只登记非敏感标识和配置文件名,真实端点及凭据只放服务器私有 .env。 + | 只在代码中登记非敏感标识;生产凭据可在明确开启后加密保存到系统库。 | */ 'runtime' => [ @@ -16,6 +16,11 @@ return [ 'slot_wait_ms' => (int) env('UPSTREAM_SLOT_WAIT_MS', 1500), ], + 'stored_configuration' => [ + // 生产环境默认关闭;只允许在系统数据库和备份均已就绪后显式开启。 + 'enabled' => filter_var(env('UPSTREAM_STORED_CONFIG_ENABLED', false), FILTER_VALIDATE_BOOL), + ], + 'sources' => [ 'jiansun' => [ 'label' => 'JianSun 旧业务库', diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index 422c3d73..68521e8e 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -22,6 +22,11 @@ if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then exit 1 fi +if [ ! -f "$REMOTE_DIR/runtime/config-db.env" ]; then + echo "Missing $REMOTE_DIR/runtime/config-db.env; run bootstrap-server.sh first" >&2 + exit 1 +fi + exec 9>"$LOCK_FILE" if ! flock -n 9; then echo "Another CaiWuXiTong deployment is already running" >&2 diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index 1e2441bd..b45733e7 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -26,6 +26,11 @@ if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then exit 1 fi +if [ ! -f "$REMOTE_DIR/runtime/config-db.env" ]; then + echo "Missing $REMOTE_DIR/runtime/config-db.env; run bootstrap-server.sh first" >&2 + exit 1 +fi + exec 9>"$LOCK_FILE" if ! flock -n 9; then echo "Another CaiWuXiTong deployment is already running" >&2 diff --git a/deploy/bootstrap-server.sh b/deploy/bootstrap-server.sh index 4c5580ae..b7e7c53d 100644 --- a/deploy/bootstrap-server.sh +++ b/deploy/bootstrap-server.sh @@ -30,11 +30,33 @@ fi install -d -m 700 "$REMOTE_DIR/runtime" +if [ ! -f "$REMOTE_DIR/runtime/config-db.env" ]; then + CONFIG_DB_PASSWORD="$(openssl rand -hex 32)" + CONFIG_DB_ROOT_PASSWORD="$(openssl rand -hex 32)" + + umask 077 + { + printf '%s\n' \ + 'MARIADB_DATABASE=caiwu_system_config' \ + 'MARIADB_USER=caiwu_config_writer' \ + "MARIADB_PASSWORD=$CONFIG_DB_PASSWORD" \ + "MARIADB_ROOT_PASSWORD=$CONFIG_DB_ROOT_PASSWORD" \ + 'MARIADB_AUTO_UPGRADE=1' + } > "$REMOTE_DIR/runtime/config-db.env" + chmod 600 "$REMOTE_DIR/runtime/config-db.env" +fi + if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then - DB_PASSWORD="$(openssl rand -hex 24)" - DB_ROOT_PASSWORD="$(openssl rand -hex 24)" + LEGACY_DB_PASSWORD="$(openssl rand -hex 24)" + LEGACY_DB_ROOT_PASSWORD="$(openssl rand -hex 24)" + CONFIG_DB_PASSWORD="$(sed -n 's/^MARIADB_PASSWORD=//p' "$REMOTE_DIR/runtime/config-db.env" | head -n 1)" APP_KEY="$(openssl rand -base64 32 | tr -d '\n')" + if [ -z "$CONFIG_DB_PASSWORD" ]; then + echo "Missing MARIADB_PASSWORD in $REMOTE_DIR/runtime/config-db.env" >&2 + exit 1 + fi + umask 077 { printf '%s\n' \ @@ -49,21 +71,24 @@ if [ ! -f "$REMOTE_DIR/runtime/.env" ]; then 'APP_TIMEZONE=Asia/Shanghai' \ 'LOG_CHANNEL=stack' \ 'DB_CONNECTION=mysql' \ - 'DB_HOST=db' \ + 'DB_HOST=config_db' \ 'DB_PORT=3306' \ - 'DB_DATABASE=caiwu' \ - 'DB_USERNAME=caiwu' \ - "DB_PASSWORD=$DB_PASSWORD" \ + 'DB_DATABASE=caiwu_system_config' \ + 'DB_USERNAME=caiwu_config_writer' \ + "DB_PASSWORD=$CONFIG_DB_PASSWORD" \ 'DB_PREFIX=ak_' \ + 'MYSQL_ATTR_SSL_CIPHER=DEFAULT' \ + 'MYSQL_ATTR_SSL_VERIFY_SERVER_CERT=false' \ 'CACHE_DRIVER=file' \ 'SESSION_DRIVER=file' \ 'QUEUE_CONNECTION=sync' \ + 'UPSTREAM_STORED_CONFIG_ENABLED=true' \ 'JIANSUN_ENABLED=false' \ 'JIANSUN_ALLOWED_SCOPES=' \ 'MARIADB_DATABASE=caiwu' \ 'MARIADB_USER=caiwu' \ - "MARIADB_PASSWORD=$DB_PASSWORD" \ - "MARIADB_ROOT_PASSWORD=$DB_ROOT_PASSWORD" \ + "MARIADB_PASSWORD=$LEGACY_DB_PASSWORD" \ + "MARIADB_ROOT_PASSWORD=$LEGACY_DB_ROOT_PASSWORD" \ 'MARIADB_AUTO_UPGRADE=1' } > "$REMOTE_DIR/runtime/.env" chmod 600 "$REMOTE_DIR/runtime/.env" diff --git a/deploy/config-db-init.sh b/deploy/config-db-init.sh new file mode 100755 index 00000000..bce1e4b5 --- /dev/null +++ b/deploy/config-db-init.sh @@ -0,0 +1,13 @@ +#!/bin/sh +set -eu + +case "${MARIADB_USER:-}" in + ''|*[!A-Za-z0-9_]* ) + echo 'MARIADB_USER contains unsupported characters' >&2 + exit 1 + ;; +esac + +mariadb --protocol=socket -uroot -p"$MARIADB_ROOT_PASSWORD" < 相关 API 使用 `X-Caiwu-Data-Status`(`fresh` / `stale`)和 `X-Caiwu-Data-Synced-At` 响应头报告数据状态,验收时不能只看 HTTP 200。 -服务器私有 `runtime/.env` 只使用下列变量名,真实值不得写入 Git 或本文档: +旧的环境变量连接方式只用于迁移或紧急回滚;日常由超级管理员在“数据库管理”的“云数据库”区域维护,并加密保存到系统数据库。生产服务器私有 `runtime/.env` 至少保留下列开关和非敏感保护参数,真实值不得写入 Git 或本文档: ```dotenv +UPSTREAM_STORED_CONFIG_ENABLED=true JIANSUN_ENABLED=false JIANSUN_ALLOWED_SCOPES= DB_JIANSUN_HOST= @@ -224,7 +225,7 @@ JIANSUN_SLOW_QUERY_COOLDOWN=300 ### 8.3 生产服务器不能直连上游时 -使用一台指定开发机作为主中继,另一台作为备用中继。它们只转发 TCP 流量,不保存数据库凭据,也不在开发机执行同步查询。数据库凭据仍只存在于服务器私有 `runtime/.env`,应用仍通过同一套快照、限流和熔断逻辑读取数据。 +使用一台指定开发机作为主中继,另一台作为备用中继。它们只转发 TCP 流量,不保存数据库凭据,也不在开发机执行同步查询。数据库凭据加密保存在系统数据库,中继机只保存已收紧 ACL 的端点和隧道配置;应用仍通过同一套快照、限流和熔断逻辑读取数据。 安全边界: diff --git a/modules/EcommerceFinance/Services/UpstreamStoredConfiguration.php b/modules/EcommerceFinance/Services/UpstreamStoredConfiguration.php index 95416f0d..462cef55 100644 --- a/modules/EcommerceFinance/Services/UpstreamStoredConfiguration.php +++ b/modules/EcommerceFinance/Services/UpstreamStoredConfiguration.php @@ -6,9 +6,9 @@ use App\Models\Settings\DatabaseConnection; use Illuminate\Support\Facades\Schema; /** - * 读取仅供本地开发使用的上游连接配置。 + * 从系统数据库读取经 Laravel APP_KEY 加密保存的上游连接配置。 * - * 生产环境始终使用私有环境变量,避免网页配置覆盖服务器运行配置。 + * 本地、开发和测试环境保持可用;生产环境必须通过私有环境变量显式开启。 */ class UpstreamStoredConfiguration { @@ -19,7 +19,8 @@ class UpstreamStoredConfiguration public function editable(): bool { - return app()->environment(['local', 'development', 'testing']); + return app()->environment(['local', 'development', 'testing']) + || (bool) config('upstreams.stored_configuration.enabled', false); } public function find(string $source): ?DatabaseConnection diff --git a/resources/views/system_settings/databases/index.blade.php b/resources/views/system_settings/databases/index.blade.php index 27c184c3..4e930c58 100644 --- a/resources/views/system_settings/databases/index.blade.php +++ b/resources/views/system_settings/databases/index.blade.php @@ -20,11 +20,15 @@ @endif
-

当前系统数据库

+
+

当前系统数据库

+ 修改系统数据库 +

{{ strtoupper($systemDatabase['driver']) }} · {{ $systemDatabase['host'] }}{{ $systemDatabase['port'] ? ':' . $systemDatabase['port'] : '' }} · {{ $systemDatabase['database'] ?: $systemDatabase['connection'] }}

-

用户、系统设置和下方所有数据库配置都统一保存在这个数据库中。系统库的启动连接由私有 runtime/.env 提供,页面不会显示密码。

+

用户、侧边栏设置和下方所有云数据库配置都保存在这个数据库中。系统库启动连接与云数据库配置相互独立,密码不会回显。

+

传输加密:{{ $systemDatabase['tls_required'] ? 'TLS 已启用' : '未启用' }}

@if ($editable)

下方可继续添加多个云数据库;连接测试只执行 SELECT 1。

@else @@ -32,6 +36,13 @@ @endif
+
+
+

云数据库

+

每条密码均使用系统 APP_KEY 加密后存入上方系统数据库。

+
+
+
diff --git a/resources/views/system_settings/databases/system.blade.php b/resources/views/system_settings/databases/system.blade.php new file mode 100644 index 00000000..3abcc1c4 --- /dev/null +++ b/resources/views/system_settings/databases/system.blade.php @@ -0,0 +1,61 @@ + + 修改系统数据库 + + +
+ @include('system_settings.partials.navigation') + +
+ @csrf + @method('PATCH') + + @if ($errors->any()) + + @endif + +
+ 保存前会先验证 TLS、系统必需表和用户数据;任何一项不完整都会拒绝切换。密码留空表示保留当前密码。 +
+ +
+ + + + + +
+ +
+ + +
+ +
+ 取消 + +
+ +
+
+
diff --git a/routes/admin.php b/routes/admin.php index 177856c1..b8c1d752 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -24,6 +24,8 @@ Route::group(['as' => 'system-settings.', 'prefix' => 'system-settings'], functi Route::patch('sidebar', 'SystemSettings\Sidebar@update')->name('sidebar.update'); Route::get('databases', 'SystemSettings\Databases@index')->name('databases.index'); + Route::get('databases/system/edit', 'SystemSettings\Databases@editSystem')->name('databases.system.edit'); + Route::patch('databases/system', 'SystemSettings\Databases@updateSystem')->name('databases.system.update'); Route::get('databases/create', 'SystemSettings\Databases@create')->name('databases.create'); Route::post('databases', 'SystemSettings\Databases@store')->name('databases.store'); Route::get('databases/{connection}/edit', 'SystemSettings\Databases@edit')->name('databases.edit'); diff --git a/tests/Feature/SystemSettings/UpstreamDatabasesTest.php b/tests/Feature/SystemSettings/UpstreamDatabasesTest.php index a51f78d8..fb6da952 100644 --- a/tests/Feature/SystemSettings/UpstreamDatabasesTest.php +++ b/tests/Feature/SystemSettings/UpstreamDatabasesTest.php @@ -7,6 +7,7 @@ use App\Models\Auth\Role; use App\Models\Settings\DatabaseConnection; use Illuminate\Support\Facades\DB; use Modules\EcommerceFinance\Services\UpstreamSourceRegistry; +use Modules\EcommerceFinance\Services\UpstreamStoredConfiguration; use Tests\Feature\FeatureTestCase; class UpstreamDatabasesTest extends FeatureTestCase @@ -103,6 +104,48 @@ class UpstreamDatabasesTest extends FeatureTestCase ->assertForbidden(); } + public function test_database_page_separates_system_and_cloud_database_configuration(): void + { + $connectionName = (string) config('database.default'); + config(["database.connections.$connectionName.password" => 'system-database-test-secret']); + + $this->loginAs() + ->get(route('system-settings.databases.index')) + ->assertOk() + ->assertSeeText('当前系统数据库') + ->assertSeeText('修改系统数据库') + ->assertSeeText('云数据库'); + + $this->get(route('system-settings.databases.system.edit')) + ->assertOk() + ->assertSeeText('测试并保存') + ->assertDontSee('system-database-test-secret'); + } + + public function test_production_stored_configuration_requires_explicit_private_flag(): void + { + $connection = $this->connection('production-secret'); + $connection->update(['source_key' => 'jiansun']); + $originalEnvironment = $this->app->environment(); + + try { + $this->app['env'] = 'production'; + config(['upstreams.stored_configuration.enabled' => false]); + + $disabled = app(UpstreamStoredConfiguration::class); + $this->assertFalse($disabled->editable()); + $this->assertNull($disabled->find('jiansun')); + + config(['upstreams.stored_configuration.enabled' => true]); + $enabled = new UpstreamStoredConfiguration(); + + $this->assertTrue($enabled->editable()); + $this->assertSame($connection->id, $enabled->find('jiansun')?->id); + } finally { + $this->app['env'] = $originalEnvironment; + } + } + private function payload(array $overrides = []): array { return array_merge([