CaiWuXiTong/app/Utilities/SystemDatabaseConfiguration.php

595 lines
22 KiB
PHP

<?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;
private ?string $backupDirectory;
public function __construct(
?string $environmentPath = null,
?string $backupDirectory = null
)
{
$this->environmentPath = $environmentPath ?: base_path('.env');
$this->backupDirectory = $backupDirectory;
}
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 = $this->writableBackupDirectory();
$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;
}
private function writableBackupDirectory(): string
{
$preferred = $this->backupDirectory
?: storage_path('app/system-database-config-backups');
if (
(is_dir($preferred) && is_writable($preferred))
|| (
! file_exists($preferred)
&& @mkdir($preferred, 0700, true)
&& is_writable($preferred)
)
) {
return $preferred;
}
// 某些开发机曾用 root 执行诊断命令,可能留下仅 root 可写的旧目录。
// storage/app 由容器入口统一授权给网页进程,可安全作为备份回退位置。
$fallback = storage_path('app');
if (is_dir($fallback) && is_writable($fallback)) {
return $fallback;
}
throw new RuntimeException('系统数据库配置备份目录不可写,未执行切换。');
}
/**
* 根据一组完整的系统必需表自动识别目标库表前缀。
*
* @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',
'upstream_query_cache',
'upstream_query_locks',
];
$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('系统数据库地址暂时无法解析,请稍后重试或联系负责人。');
}
}
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('系统数据库暂时无法连接,请稍后重试或联系负责人。');
}
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',
],
'upstream_query_cache' => ['key', 'value', 'expiration'],
'upstream_query_locks' => ['key', 'owner', 'expiration'],
];
$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 '系统数据库加密连接失败,请稍后重试或联系负责人。';
}
if ($driverCode === 2002 || str_contains($message, 'timed out')) {
return '系统数据库暂时无法连接,请稍后重试或联系负责人。';
}
return '系统数据库连接初始化失败,请稍后重试或联系负责人。';
}
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
) . '"';
}
}