372 lines
15 KiB
PHP
372 lines
15 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();
|
||
|
||
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);
|
||
}
|
||
}
|