372 lines
15 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\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();
return view('system_settings.databases.index', [
'connections' => $storedConfiguration->editable()
? DatabaseConnection::query()
->where('company_id', UpstreamStoredConfiguration::SYSTEM_COMPANY_ID)
->orderBy('name')
->get()
: collect(),
'sourceLabels' => $this->sourceLabels(),
'systemDatabase' => [
'name' => '财务系统配置数据库',
'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);
$settings['prefix'] = $result['prefix'];
$configuration->persist($settings);
} catch (\RuntimeException $exception) {
throw ValidationException::withMessages([
'connection' => $exception->getMessage(),
]);
}
flash(sprintf(
'系统数据库连接已验证并保存,自动识别表前缀“%s”,共检测到 %d 个用户;新配置将在下一次请求生效。',
$result['prefix'] === '' ? '无前缀' : $result['prefix'],
$result['user_count'],
))->success();
return redirect()->route('system-settings.databases.index');
}
public function create(UpstreamStoredConfiguration $storedConfiguration)
{
abort_unless($storedConfiguration->editable(), 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(), 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(), 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(), 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(), 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(), 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);
}
}