CaiWuXiTong/app/Utilities/SystemDatabaseConfiguration.php

562 lines
20 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Utilities;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Support\Facades\Artisan;
use PDO;
use PDOException;
use RuntimeException;
use Symfony\Component\Process\Process;
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'] ?? []);
$host = (string) (
data_get($connection, 'write.host.0')
?? data_get($connection, 'read.host.0')
?? ($connection['host'] ?? '')
);
$tlsRequired = isset($options[PDO::MYSQL_ATTR_SSL_CA])
|| isset($options[PDO::MYSQL_ATTR_SSL_CIPHER]);
$verifyServerCertificate = defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')
? (bool) ($options[constant('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')] ?? false)
: false;
return [
'connection' => $connectionName,
'driver' => (string) ($connection['driver'] ?? $connectionName),
'host' => $host,
'port' => (int) ($connection['port'] ?? 3306),
'database' => (string) ($connection['database'] ?? ''),
'username' => (string) ($connection['username'] ?? ''),
'password' => (string) ($connection['password'] ?? ''),
'prefix' => (string) ($connection['prefix'] ?? ''),
'mode' => $this->databaseMode($host, (string) ($connection['database'] ?? '')),
'tls_required' => $tlsRequired,
'tls_verify_server_cert' => $tlsRequired && $verifyServerCertificate,
'tls_ca' => (string) ($options[PDO::MYSQL_ATTR_SSL_CA] ?? ''),
];
}
/**
* @return array{cipher:string,user_count:int,admin_count:int,source_count:int,prefix:string}
*/
public function probe(array $settings): array
{
if (($settings['driver'] ?? 'mysql') !== 'mysql') {
throw new RuntimeException('系统数据库目前只支持 MariaDB/MySQL。');
}
$this->assertHostResolves((string) $settings['host']);
$this->assertTcpReachable((string) $settings['host'], (int) $settings['port']);
$options = [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_TIMEOUT => 8,
];
if (! empty($settings['tls_required'])) {
$options[PDO::MYSQL_ATTR_SSL_CIPHER] = 'DEFAULT';
if (! empty($settings['tls_ca'])) {
$options[PDO::MYSQL_ATTR_SSL_CA] = (string) $settings['tls_ca'];
}
if (defined('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')) {
$options[constant('PDO::MYSQL_ATTR_SSL_VERIFY_SERVER_CERT')] =
! empty($settings['tls_verify_server_cert']);
}
}
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 加密。');
}
$tableNames = $pdo->query(
'SELECT table_name FROM information_schema.tables '
. 'WHERE table_schema = DATABASE()'
)->fetchAll(PDO::FETCH_COLUMN);
$prefix = $this->detectTablePrefix(
array_map('strval', $tableNames),
$this->current()['prefix']
);
$this->assertRequiredSchema($pdo, $prefix);
$this->assertCurrentMigrations($pdo, $prefix);
$userCount = (int) $pdo->query(
'SELECT COUNT(*) FROM ' . $this->quoteIdentifier($prefix . 'users')
)->fetchColumn();
if ($userCount < 1) {
throw new RuntimeException('目标库没有任何用户,已拒绝切换。');
}
$adminCount = $this->administratorCount($pdo, $prefix);
if ($adminCount < 1) {
throw new RuntimeException('目标库没有具备公司和后台权限的可登录管理员,已拒绝切换。');
}
$sourceCount = $this->assertSharedSources(
$pdo,
$prefix,
(string) $settings['password']
);
return [
'cipher' => $cipher,
'user_count' => $userCount,
'admin_count' => $adminCount,
'source_count' => $sourceCount,
'prefix' => $prefix,
];
} catch (PDOException $exception) {
throw new RuntimeException($this->safeConnectionError($exception), 0, $exception);
} catch (RuntimeException $exception) {
throw $exception;
} catch (Throwable $exception) {
throw new RuntimeException($this->safeConnectionError($exception), 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-u') . '.backup';
if (file_put_contents($backupPath, $current, LOCK_EX) === false) {
throw new RuntimeException('系统数据库配置备份失败,未执行切换。');
}
chmod($backupPath, 0600);
$prefix = array_key_exists('prefix', $settings)
? (string) $settings['prefix']
: $this->current()['prefix'];
$this->assertValidPrefix($prefix);
$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'],
'DB_PREFIX' => $prefix,
'MYSQL_ATTR_SSL_CIPHER' => ! empty($settings['tls_required']) ? 'DEFAULT' : '',
'MYSQL_ATTR_SSL_CA' => ! empty($settings['tls_required'])
? (string) ($settings['tls_ca'] ?? '')
: '',
'MYSQL_ATTR_SSL_VERIFY_SERVER_CERT' => ! empty($settings['tls_required'])
&& ! empty($settings['tls_verify_server_cert'])
? 'true'
: 'false',
'SYSTEM_DATABASE_MODE' => 'shared',
// 系统数据库保存后统一读取共享云数据库配置,不再要求开发机
// 另外维护完整的 DB_<SOURCE>_* 连接参数。
'UPSTREAM_STORED_CONFIG_ENABLED' => 'true',
'UPSTREAM_LOCAL_CONFIG_ENABLED' => 'false',
]);
try {
if (file_put_contents($this->environmentPath, $updated, LOCK_EX) === false) {
throw new RuntimeException('系统数据库配置写入失败。');
}
Artisan::call('config:clear');
$this->runFreshVerification('system-database:verify');
} catch (Throwable $exception) {
$restored = file_put_contents($this->environmentPath, $current, LOCK_EX) !== false;
Artisan::call('config:clear');
if (! $restored) {
throw new RuntimeException(
'新配置未通过应用级验证,且旧配置自动恢复失败。请立即使用私有备份恢复。',
0,
$exception
);
}
try {
$this->runFreshVerification('app:ready');
} catch (Throwable $rollbackException) {
throw new RuntimeException(
'新配置未通过应用级验证,旧配置文件已经恢复,但旧连接复核失败。',
0,
$rollbackException
);
}
throw new RuntimeException('新配置未通过应用级验证,已自动恢复原系统数据库。', 0, $exception);
}
return $backupPath;
}
/**
* 根据一组完整的系统必需表自动识别目标库表前缀。
*
* @param array<int,string> $tableNames
*/
public function detectTablePrefix(array $tableNames, string $preferredPrefix = ''): string
{
$requiredTables = [
'migrations',
'users',
'companies',
'roles',
'permissions',
'role_permissions',
'user_roles',
'user_companies',
'settings',
'system_database_connections',
];
$candidates = null;
foreach ($requiredTables as $requiredTable) {
$matchingPrefixes = [];
foreach ($tableNames as $tableName) {
if ($tableName !== $requiredTable && ! str_ends_with($tableName, '_' . $requiredTable)) {
continue;
}
$prefix = substr($tableName, 0, strlen($tableName) - strlen($requiredTable));
try {
$this->assertValidPrefix($prefix);
$matchingPrefixes[] = $prefix;
} catch (RuntimeException) {
continue;
}
}
$matchingPrefixes = array_values(array_unique($matchingPrefixes));
$candidates = $candidates === null
? $matchingPrefixes
: array_values(array_intersect($candidates, $matchingPrefixes));
}
if ($candidates === null || $candidates === []) {
throw new RuntimeException('目标库缺少系统必需表,已拒绝切换。');
}
if (in_array($preferredPrefix, $candidates, true)) {
return $preferredPrefix;
}
if (count($candidates) !== 1) {
throw new RuntimeException('目标库存在多套系统表,无法自动确定表前缀。');
}
return $candidates[0];
}
private function databaseMode(string $host, string $database): string
{
$configured = strtolower(trim((string) config('system_database.mode', '')));
if (in_array($configured, ['bootstrap', 'shared'], true)) {
return $configured;
}
return app()->environment(['local', 'development'])
&& $host === 'mysql'
&& $database === 'akaunting'
? 'bootstrap'
: 'shared';
}
private function assertHostResolves(string $host): void
{
if (
filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV4)
|| filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)
) {
return;
}
if ($host === '' || gethostbynamel($host) === false) {
throw new RuntimeException('连接失败阶段:DNS 解析。请检查主机名称。');
}
}
private function assertTcpReachable(string $host, int $port): void
{
$address = filter_var($host, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)
? sprintf('tcp://[%s]:%d', $host, $port)
: sprintf('tcp://%s:%d', $host, $port);
$socket = @stream_socket_client($address, $errorCode, $errorMessage, 4);
if (! is_resource($socket)) {
throw new RuntimeException('连接失败阶段:TCP 端口。请检查端口、网络和访问白名单。');
}
fclose($socket);
}
private function assertRequiredSchema(PDO $pdo, string $prefix): void
{
$requiredColumns = [
'users' => ['id', 'email', 'password', 'enabled'],
'companies' => ['id', 'enabled'],
'roles' => ['id', 'name'],
'permissions' => ['id', 'name'],
'role_permissions' => ['role_id', 'permission_id'],
'user_roles' => ['user_id', 'role_id'],
'user_companies' => ['user_id', 'company_id'],
'system_database_connections' => [
'company_id',
'source_key',
'password',
'shared_password',
'allowed_scopes',
],
];
$statement = $pdo->prepare(
'SELECT column_name FROM information_schema.columns '
. 'WHERE table_schema = DATABASE() AND table_name = ?'
);
foreach ($requiredColumns as $table => $columns) {
$statement->execute([$prefix . $table]);
$available = array_map('strval', $statement->fetchAll(PDO::FETCH_COLUMN));
$missing = array_diff($columns, $available);
if ($missing !== []) {
throw new RuntimeException(sprintf(
'目标库表结构不完整:%s 缺少 %d 个必要字段。',
$table,
count($missing)
));
}
}
}
private function assertCurrentMigrations(PDO $pdo, string $prefix): void
{
$files = glob(database_path('migrations/*.php')) ?: [];
$required = array_map(
static fn (string $path): string => pathinfo($path, PATHINFO_FILENAME),
$files
);
$applied = array_map(
'strval',
$pdo->query(
'SELECT migration FROM ' . $this->quoteIdentifier($prefix . 'migrations')
)->fetchAll(PDO::FETCH_COLUMN)
);
$missing = array_diff($required, $applied);
if ($missing !== []) {
throw new RuntimeException(sprintf(
'目标库缺少当前代码要求的 %d 个数据库迁移,已拒绝切换。',
count($missing)
));
}
}
private function administratorCount(PDO $pdo, string $prefix): int
{
$users = $this->quoteIdentifier($prefix . 'users');
$roles = $this->quoteIdentifier($prefix . 'roles');
$permissions = $this->quoteIdentifier($prefix . 'permissions');
$userRoles = $this->quoteIdentifier($prefix . 'user_roles');
$userCompanies = $this->quoteIdentifier($prefix . 'user_companies');
$rolePermissions = $this->quoteIdentifier($prefix . 'role_permissions');
return (int) $pdo->query(
"SELECT COUNT(DISTINCT u.id) FROM {$users} u "
. "INNER JOIN {$userRoles} ur ON ur.user_id = u.id "
. "INNER JOIN {$roles} r ON r.id = ur.role_id "
. "INNER JOIN {$userCompanies} uc ON uc.user_id = u.id "
. "INNER JOIN {$rolePermissions} rp ON rp.role_id = r.id "
. "INNER JOIN {$permissions} p ON p.id = rp.permission_id "
. "WHERE u.enabled = 1 "
. "AND r.name IN ('super_admin', 'admin') "
. "AND p.name = 'read-admin-panel'"
)->fetchColumn();
}
private function assertSharedSources(PDO $pdo, string $prefix, string $systemDatabasePassword): int
{
$requiredSources = array_keys((array) config('upstreams.sources', []));
if ($requiredSources === []) {
return 0;
}
$table = $this->quoteIdentifier($prefix . 'system_database_connections');
$statement = $pdo->query(
"SELECT source_key, shared_password FROM {$table} WHERE company_id = 0"
);
$records = [];
foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $record) {
$source = (string) ($record['source_key'] ?? '');
if ($source !== '') {
$records[$source] = (string) ($record['shared_password'] ?? '');
}
}
foreach ($requiredSources as $source) {
if (! array_key_exists($source, $records)) {
throw new RuntimeException('目标库缺少已登记的共享云数据库来源:' . $source . '。');
}
if ($records[$source] === '') {
throw new RuntimeException('共享云数据库密码尚未迁移:' . $source . '。');
}
try {
app(UpstreamCredentialCipher::class)->decryptWithSecret(
$records[$source],
$systemDatabasePassword
);
} catch (DecryptException|RuntimeException) {
throw new RuntimeException('系统数据库密码无法解锁共享云数据库配置:' . $source . '。');
}
}
return count($requiredSources);
}
private function safeConnectionError(Throwable $exception): string
{
$driverCode = $exception instanceof PDOException
? (int) ($exception->errorInfo[1] ?? 0)
: 0;
$message = strtolower($exception->getMessage());
if ($driverCode === 1045 || str_contains($message, 'access denied')) {
return '连接失败阶段:账号认证。请检查数据库账号和密码。';
}
if ($driverCode === 1049 || str_contains($message, 'unknown database')) {
return '连接失败阶段:选择数据库。请检查数据库名称和账号权限。';
}
if (
$driverCode === 2026
|| str_contains($message, 'ssl')
|| str_contains($message, 'certificate')
|| str_contains($message, 'tls')
) {
return '连接失败阶段:TLS 协商或证书验证。请检查 TLS 和 CA 配置。';
}
if ($driverCode === 2002 || str_contains($message, 'timed out')) {
return '连接失败阶段:数据库握手。请检查网络、端口和访问白名单。';
}
return '连接失败阶段:数据库登录或初始化。请检查库名、账号权限和 TLS 配置。';
}
protected function runFreshVerification(string $command): void
{
$process = new Process(
[PHP_BINARY, base_path('artisan'), $command, '--no-ansi', '--quiet'],
base_path()
);
$process->setTimeout(60);
$process->run();
if (! $process->isSuccessful()) {
throw new RuntimeException('新的 Laravel 进程未通过应用级数据库验证。');
}
}
private function quoteIdentifier(string $identifier): string
{
return '`' . str_replace('`', '``', $identifier) . '`';
}
private function assertValidPrefix(string $prefix): void
{
if (strlen($prefix) > 50 || preg_match('/^[A-Za-z0-9_]*$/D', $prefix) !== 1) {
throw new RuntimeException('目标库系统表前缀不安全,已拒绝切换。');
}
}
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
) . '"';
}
}