562 lines
20 KiB
PHP
562 lines
20 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;
|
||
|
||
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
|
||
) . '"';
|
||
}
|
||
}
|