402 lines
16 KiB
PHP

<?php
namespace App\Http\Controllers\SystemSettings;
use App\Models\Settings\DatabaseConnection;
use App\Utilities\SystemAccess;
use App\Utilities\SystemDatabaseConfiguration;
use Illuminate\Contracts\Encryption\DecryptException;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Illuminate\Validation\Rule;
use Illuminate\Validation\ValidationException;
use Modules\EcommerceFinance\Services\UpstreamOdbcConnection;
use Modules\EcommerceFinance\Services\UpstreamSourceRegistry;
use Modules\EcommerceFinance\Services\UpstreamStoredConfiguration;
use Symfony\Component\HttpKernel\Exception\ServiceUnavailableHttpException;
class Databases extends Controller
{
public function __construct()
{
$this->middleware(function ($request, $next) {
abort_unless(SystemAccess::canOpenSettings(), 403);
return $next($request);
});
}
public function index(
UpstreamStoredConfiguration $storedConfiguration,
SystemDatabaseConfiguration $systemDatabaseConfiguration
)
{
$systemDatabase = $systemDatabaseConfiguration->current();
$sharedMode = $systemDatabase['mode'] === 'shared';
return view('system_settings.databases.index', [
'connections' => $sharedMode && $storedConfiguration->editable()
? DatabaseConnection::query()
->where('company_id', UpstreamStoredConfiguration::SYSTEM_COMPANY_ID)
->orderBy('name')
->get()
: collect(),
'sourceLabels' => $this->sourceLabels(),
'systemDatabase' => [
'name' => $sharedMode ? '财务系统配置数据库' : '本地临时引导库',
'connection' => $systemDatabase['connection'],
'driver' => $systemDatabase['driver'],
'host' => $systemDatabase['host'] ?: '本机文件',
'port' => $systemDatabase['port'],
'database' => $systemDatabase['database'],
'mode' => $systemDatabase['mode'],
'tls_required' => $systemDatabase['tls_required'],
'tls_verify_server_cert' => $systemDatabase['tls_verify_server_cert'],
],
'editable' => $sharedMode && $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'],
'tls_verify_server_cert' => $current['tls_verify_server_cert'],
'tls_ca' => $current['tls_ca'],
'mode' => $current['mode'],
],
]);
}
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'],
'tls_verify_server_cert' => ['nullable', 'boolean'],
'tls_ca' => ['nullable', 'string', 'max:500', 'not_regex:/[\r\n\x00]/'],
]);
$sameConnection = trim($data['host']) === $current['host']
&& (int) $data['port'] === $current['port']
&& trim($data['database']) === $current['database']
&& trim($data['username']) === $current['username'];
$providedPassword = $data['password'] !== null && $data['password'] !== '';
if (! $providedPassword && ! $sameConnection) {
throw ValidationException::withMessages([
'password' => '首次连接或修改主机、端口、数据库、账号时必须填写目标数据库密码。',
]);
}
$settings = [
'driver' => 'mysql',
'host' => trim($data['host']),
'port' => (int) $data['port'],
'database' => trim($data['database']),
'username' => trim($data['username']),
'password' => $providedPassword
? $data['password']
: $current['password'],
'tls_required' => $request->boolean('tls_required'),
'tls_verify_server_cert' => $request->boolean('tls_required')
&& $request->boolean('tls_verify_server_cert'),
'tls_ca' => $request->boolean('tls_required')
? trim((string) ($data['tls_ca'] ?? ''))
: '',
];
if ($settings['password'] === '') {
throw ValidationException::withMessages([
'password' => '当前没有可保留的密码,请填写数据库密码。',
]);
}
try {
$result = $configuration->probe($settings);
$settings['prefix'] = $result['prefix'];
$configuration->persist($settings);
} catch (\RuntimeException $exception) {
throw ValidationException::withMessages([
'connection' => $exception->getMessage(),
]);
}
auth()->logout();
$request->session()->invalidate();
$request->session()->regenerateToken();
flash(sprintf(
'系统数据库已安全切换,检测到 %d 个用户、%d 个管理员和 %d 个共享来源。请使用共享系统数据库中的正式账号重新登录。',
$result['user_count'],
$result['admin_count'],
$result['source_count'],
))->success();
return redirect()->route('login');
}
public function create(UpstreamStoredConfiguration $storedConfiguration)
{
abort_unless($storedConfiguration->editable() && $storedConfiguration->enabled(), 403);
return view('system_settings.databases.form', [
'connection' => null,
'sourceLabels' => $this->sourceLabels(),
'values' => $this->defaultValues(),
]);
}
public function store(Request $request, UpstreamStoredConfiguration $storedConfiguration)
{
abort_unless($storedConfiguration->editable() && $storedConfiguration->enabled(), 403);
$data = $this->validateConnection($request);
$connection = DatabaseConnection::create($this->attributes($data, null, $request));
$this->resetSource($connection->source_key, $storedConfiguration);
flash('数据库配置已新增。')->success();
return redirect()->route('system-settings.databases.index');
}
public function edit(int $connection, UpstreamStoredConfiguration $storedConfiguration)
{
abort_unless($storedConfiguration->editable() && $storedConfiguration->enabled(), 403);
$connection = $this->connection($connection);
return view('system_settings.databases.form', [
'connection' => $connection,
'sourceLabels' => $this->sourceLabels(),
'values' => $this->values($connection),
]);
}
public function update(int $connection, Request $request, UpstreamStoredConfiguration $storedConfiguration)
{
abort_unless($storedConfiguration->editable() && $storedConfiguration->enabled(), 403);
$connection = $this->connection($connection);
$oldSource = $connection->source_key;
$data = $this->validateConnection($request, $connection);
$connection->update($this->attributes($data, $connection, $request));
$this->resetSource($oldSource, $storedConfiguration);
$this->resetSource($connection->source_key, $storedConfiguration);
flash('数据库配置已修改。')->success();
return redirect()->route('system-settings.databases.index');
}
public function destroy(int $connection, UpstreamStoredConfiguration $storedConfiguration)
{
abort_unless($storedConfiguration->editable() && $storedConfiguration->enabled(), 403);
$connection = $this->connection($connection);
$source = $connection->source_key;
$connection->delete();
$this->resetSource($source, $storedConfiguration);
flash('数据库配置已删除。')->success();
return redirect()->route('system-settings.databases.index');
}
public function test(int $connection, UpstreamStoredConfiguration $storedConfiguration)
{
abort_unless($storedConfiguration->editable() && $storedConfiguration->enabled(), 403);
$connection = $this->connection($connection);
try {
UpstreamOdbcConnection::probeConfiguration($this->odbcConfiguration($connection), $connection->name);
} catch (DecryptException $exception) {
throw ValidationException::withMessages([
'connection' => $exception->getMessage(),
]);
} catch (ServiceUnavailableHttpException) {
throw ValidationException::withMessages([
'connection' => '连接失败。请检查地址、端口、数据库、账号、网络白名单和本机 ODBC 驱动。',
]);
}
flash($connection->name . '连接成功,仅执行了 SELECT 1 只读测试。')->success();
return redirect()->route('system-settings.databases.index');
}
private function validateConnection(Request $request, ?DatabaseConnection $connection = null): array
{
$sourceKeys = array_keys($this->sourceLabels());
return $request->validate([
'name' => [
'required',
'string',
'max:191',
Rule::unique('system_database_connections', 'name')
->where(fn ($query) => $query->where('company_id', UpstreamStoredConfiguration::SYSTEM_COMPANY_ID))
->ignore($connection?->id),
],
'source_key' => [
'nullable',
Rule::in($sourceKeys),
Rule::unique('system_database_connections', 'source_key')
->where(fn ($query) => $query->where('company_id', UpstreamStoredConfiguration::SYSTEM_COMPANY_ID))
->ignore($connection?->id),
],
'host' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'],
'port' => ['required', 'integer', 'between:1,65535'],
'database_name' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'],
'username' => ['required', 'string', 'max:191', 'not_regex:/[;\x00-\x1F]/'],
'password' => [$connection ? 'nullable' : 'required', 'string', 'max:1000'],
'odbc_driver' => ['required', 'string', 'max:128', 'not_regex:/[;{}\x00-\x1F]/'],
'tds_version' => ['required', 'regex:/^\d+(?:\.\d+)?$/', 'max:16'],
'connect_timeout' => ['required', 'integer', 'between:1,30'],
'query_timeout' => ['required', 'integer', 'between:1,120'],
'allowed_scopes' => ['nullable', 'string', 'max:4000'],
'enabled' => ['nullable', 'boolean'],
]);
}
private function attributes(array $data, ?DatabaseConnection $connection, Request $request): array
{
$attributes = [
'company_id' => UpstreamStoredConfiguration::SYSTEM_COMPANY_ID,
'source_key' => $data['source_key'] ?? null,
'name' => trim($data['name']),
'driver' => 'sqlsrv',
'host' => trim($data['host']),
'port' => (int) $data['port'],
'database_name' => trim($data['database_name']),
'username' => trim($data['username']),
'odbc_driver' => trim($data['odbc_driver']),
'tds_version' => trim($data['tds_version']),
'connect_timeout' => (int) $data['connect_timeout'],
'query_timeout' => (int) $data['query_timeout'],
'allowed_scopes' => $this->parseScopes((string) ($data['allowed_scopes'] ?? '')),
'charset' => 'UTF-8',
'enabled' => $request->boolean('enabled'),
];
if (! empty($data['password'])) {
$attributes['password'] = $data['password'];
}
return $attributes;
}
private function parseScopes(string $value): array
{
$scopes = array_values(array_unique(array_filter(array_map('trim', preg_split('/[,\r\n]+/', $value) ?: []))));
foreach ($scopes as $scope) {
if ($scope === '*' || preg_match('/^[a-z0-9_.-]+(?:\.\*)?$/i', $scope) !== 1) {
throw ValidationException::withMessages([
'allowed_scopes' => '数据范围只允许精确名称或“前缀.*”,不能使用全局 *。',
]);
}
}
return $scopes;
}
private function connection(int $id): DatabaseConnection
{
return DatabaseConnection::query()
->where('company_id', UpstreamStoredConfiguration::SYSTEM_COMPANY_ID)
->findOrFail($id);
}
private function sourceLabels(): array
{
return collect((array) config('upstreams.sources', []))
->mapWithKeys(fn (array $source, string $key) => [$key => $source['label'] ?? $key])
->all();
}
private function defaultValues(): array
{
return [
'name' => '',
'source_key' => '',
'enabled' => false,
'host' => '',
'port' => 1433,
'database_name' => '',
'username' => '',
'odbc_driver' => 'FreeTDS',
'tds_version' => '7.4',
'connect_timeout' => 5,
'query_timeout' => 15,
'allowed_scopes' => '',
];
}
private function values(DatabaseConnection $connection): array
{
return [
'name' => $connection->name,
'source_key' => $connection->source_key,
'enabled' => $connection->enabled,
'host' => $connection->host,
'port' => $connection->port,
'database_name' => $connection->database_name,
'username' => $connection->username,
'odbc_driver' => $connection->odbc_driver,
'tds_version' => $connection->tds_version,
'connect_timeout' => $connection->connect_timeout,
'query_timeout' => $connection->query_timeout,
'allowed_scopes' => implode(', ', $connection->allowed_scopes ?? []),
];
}
private function odbcConfiguration(DatabaseConnection $connection): array
{
$registry = app(UpstreamSourceRegistry::class);
$source = $connection->source_key;
$value = fn (string $path, mixed $default) => $source
? $registry->value($source, 'connection.' . $path, $default)
: $default;
$password = $value('password', $source ? '' : $connection->password);
if (! is_string($password) || $password === '') {
throw new DecryptException(
'无法解锁共享云数据库密码,请确认系统数据库密码与保存共享配置时一致,并由管理员迁移旧配置。'
);
}
return [
'host' => $value('host', $connection->host),
'port' => $value('port', $connection->port),
'database' => $value('database', $connection->database_name),
'username' => $value('username', $connection->username),
'password' => $password,
'odbc_driver' => $value('odbc_driver', $connection->odbc_driver),
'tds_version' => $value('tds_version', $connection->tds_version),
'connect_timeout' => $value('connect_timeout', $connection->connect_timeout),
'query_timeout' => $value('query_timeout', $connection->query_timeout),
];
}
private function resetSource(?string $source, UpstreamStoredConfiguration $storedConfiguration): void
{
if (! $source) {
return;
}
$storedConfiguration->forget($source);
UpstreamOdbcConnection::reset($source);
}
}