架构:分离系统数据库与云数据库配置

This commit is contained in:
WuYongJie 2026-07-22 15:50:01 +08:00
parent d9af0a0b7f
commit e09b8a0113
16 changed files with 496 additions and 32 deletions

View File

@ -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`。

View File

@ -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);

View File

@ -0,0 +1,197 @@
<?php
namespace App\Utilities;
use Illuminate\Support\Facades\Artisan;
use PDO;
use RuntimeException;
use Throwable;
class SystemDatabaseConfiguration
{
private string $environmentPath;
public function __construct(?string $environmentPath = null)
{
$this->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
) . '"';
}
}

View File

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

View File

@ -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' => [

View File

@ -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 旧业务库',

View File

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

View File

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

View File

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

13
deploy/config-db-init.sh Executable file
View File

@ -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" <<SQL
ALTER USER \`$MARIADB_USER\`@'%' REQUIRE SSL;
SQL

View File

@ -139,7 +139,7 @@ git pull --ff-only
### 8.1 统一接入边界
本系统按 3 个以上上游数据库设计,但不会把多个数据库直接挂到页面上。`config/upstreams.php` 只登记不敏感的来源标识和配置文件名;每个来源再分别提供连接配置、固定查询适配器、允许的数据范围、快照、限流、熔断和刷新器。真实端点、数据库名、账号和密码仍只存在于服务器私有 `runtime/.env`。
本系统按 3 个以上上游数据库设计,但不会把多个数据库直接挂到页面上。`config/upstreams.php` 只登记不敏感的来源标识和配置文件名;每个来源再分别提供连接配置、固定查询适配器、允许的数据范围、快照、限流、熔断和刷新器。系统数据库的启动连接只存在于服务器私有 `runtime/.env`;显式开启 `UPSTREAM_STORED_CONFIG_ENABLED` 后,云数据库端点及账号保存在系统库中,密码使用当前 `APP_KEY` 加密。
固定调用链如下:
@ -192,9 +192,10 @@ php artisan upstream:snapshots:refresh <source_key>
相关 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 的端点和隧道配置;应用仍通过同一套快照、限流和熔断逻辑读取数据。
安全边界:

View File

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

View File

@ -20,11 +20,15 @@
@endif
<div class="mb-5 rounded-xl bg-purple-100 px-5 py-4 text-sm leading-6 text-gray-700">
<p class="font-semibold text-gray-900">当前系统数据库</p>
<div class="flex flex-wrap items-start justify-between gap-3">
<p class="font-semibold text-gray-900">当前系统数据库</p>
<a href="{{ route('system-settings.databases.system.edit') }}" class="font-semibold text-purple-700 hover:text-purple-800 hover:underline">修改系统数据库</a>
</div>
<p class="mt-1">
{{ strtoupper($systemDatabase['driver']) }} · {{ $systemDatabase['host'] }}{{ $systemDatabase['port'] ? ':' . $systemDatabase['port'] : '' }} · {{ $systemDatabase['database'] ?: $systemDatabase['connection'] }}
</p>
<p class="mt-1">用户、系统设置和下方所有数据库配置都统一保存在这个数据库中。系统库的启动连接由私有 <code>runtime/.env</code> 提供,页面不会显示密码。</p>
<p class="mt-1">用户、侧边栏设置和下方所有云数据库配置都保存在这个数据库中。系统库启动连接与云数据库配置相互独立,密码不会回显。</p>
<p class="mt-1">传输加密:{{ $systemDatabase['tls_required'] ? 'TLS 已启用' : '未启用' }}</p>
@if ($editable)
<p class="mt-1">下方可继续添加多个云数据库;连接测试只执行 <code>SELECT 1</code>。</p>
@else
@ -32,6 +36,13 @@
@endif
</div>
<div class="mb-3 flex items-center justify-between gap-3">
<div>
<p class="font-semibold text-gray-900">云数据库</p>
<p class="text-xs text-gray-500">每条密码均使用系统 APP_KEY 加密后存入上方系统数据库。</p>
</div>
</div>
<div class="overflow-x-auto rounded-xl border border-gray-200 bg-white">
<table class="min-w-full divide-y divide-gray-200 text-sm">
<thead class="bg-purple-50 text-left text-gray-700">

View File

@ -0,0 +1,61 @@
<x-layouts.admin>
<x-slot name="title">修改系统数据库</x-slot>
<x-slot name="content">
<div class="my-6">
@include('system_settings.partials.navigation')
<form method="POST" action="{{ route('system-settings.databases.system.update') }}" class="max-w-4xl rounded-xl border border-gray-200 bg-white p-6">
@csrf
@method('PATCH')
@if ($errors->any())
<div class="mb-5 rounded-lg bg-red-50 px-4 py-3 text-sm text-red-700" role="alert">
@foreach ($errors->all() as $error)<p>{{ $error }}</p>@endforeach
</div>
@endif
<div class="mb-6 rounded-lg bg-yellow-50 px-4 py-3 text-sm leading-6 text-yellow-800">
保存前会先验证 TLS、系统必需表和用户数据;任何一项不完整都会拒绝切换。密码留空表示保留当前密码。
</div>
<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>
<input name="host" value="{{ old('host', $values['host']) }}" 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">
</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">
</label>
<label class="block">
<span class="text-sm font-semibold text-gray-700">数据库名称 *</span>
<input name="database" value="{{ old('database', $values['database']) }}" 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">
</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">
</label>
<label class="block">
<span class="text-sm font-semibold text-gray-700">数据库密码</span>
<input type="password" name="password" 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">
<span class="mt-1 block text-xs text-gray-500">留空保留当前密码,页面不会回显。</span>
</label>
</div>
<div class="mt-6 border-t border-gray-100 pt-5">
<input type="hidden" name="tls_required" value="0">
<label class="inline-flex items-center gap-2 text-sm font-semibold text-gray-700">
<input type="checkbox" name="tls_required" value="1" @checked((bool) old('tls_required', $values['tls_required'])) class="rounded border-gray-300 text-purple-700 focus:ring-purple-700">
要求 TLS 加密连接
</label>
</div>
<div class="mt-6 flex justify-end gap-3 border-t border-gray-100 pt-5">
<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>
</div>
</form>
</div>
</x-slot>
</x-layouts.admin>

View File

@ -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');

View File

@ -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([