468 lines
19 KiB
PHP
468 lines
19 KiB
PHP
<?php
|
||
|
||
namespace App\Http\Controllers\SystemSettings;
|
||
|
||
use App\Models\Settings\DatabaseConnection;
|
||
use App\Models\Settings\UpstreamSchemaColumn;
|
||
use App\Utilities\SystemAccess;
|
||
use App\Utilities\SystemDatabaseConfiguration;
|
||
use App\Utilities\TeamSystemDatabaseProfile;
|
||
use Illuminate\Contracts\Encryption\DecryptException;
|
||
use Illuminate\Support\Facades\DB;
|
||
use Illuminate\Support\Facades\Schema;
|
||
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\UpstreamDatabaseProbe;
|
||
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()
|
||
{
|
||
return view('system_settings.databases.system');
|
||
}
|
||
|
||
public function dictionary(Request $request)
|
||
{
|
||
$sourceLabels = $this->sourceLabels();
|
||
$filters = $request->validate([
|
||
'source' => ['nullable', Rule::in(array_keys($sourceLabels))],
|
||
'type' => ['nullable', Rule::in(['table', 'view'])],
|
||
'search' => ['nullable', 'string', 'max:191', 'not_regex:/[\x00-\x1F]/'],
|
||
'selected_source' => ['nullable', Rule::in(array_keys($sourceLabels))],
|
||
'selected_schema' => ['nullable', 'string', 'max:128', 'not_regex:/[\x00-\x1F]/'],
|
||
'selected_object' => ['nullable', 'string', 'max:191', 'not_regex:/[\x00-\x1F]/'],
|
||
]);
|
||
$ready = Schema::hasTable('upstream_schema_columns');
|
||
$objects = null;
|
||
$columns = collect();
|
||
$sourceStatus = collect();
|
||
|
||
if ($ready) {
|
||
$query = UpstreamSchemaColumn::query();
|
||
|
||
if (! empty($filters['source'])) {
|
||
$query->where('source_key', $filters['source']);
|
||
}
|
||
|
||
if (! empty($filters['type'])) {
|
||
$query->where('object_type', $filters['type']);
|
||
}
|
||
|
||
if (! empty($filters['search'])) {
|
||
$search = '%' . str_replace(['%', '_'], ['\\%', '\\_'], trim($filters['search'])) . '%';
|
||
$query->where(function ($builder) use ($search): void {
|
||
$builder
|
||
->where('schema_name', 'like', $search)
|
||
->orWhere('object_name', 'like', $search)
|
||
->orWhere('column_name', 'like', $search);
|
||
});
|
||
}
|
||
|
||
$objects = $query
|
||
->select([
|
||
'source_key',
|
||
'schema_name',
|
||
'object_name',
|
||
'object_type',
|
||
DB::raw('COUNT(*) AS column_count'),
|
||
DB::raw('MAX(synchronized_at) AS synchronized_at'),
|
||
])
|
||
->groupBy('source_key', 'schema_name', 'object_name', 'object_type')
|
||
->orderBy('source_key')
|
||
->orderBy('schema_name')
|
||
->orderBy('object_name')
|
||
->paginate(30)
|
||
->withQueryString();
|
||
|
||
if (
|
||
! empty($filters['selected_source'])
|
||
&& ! empty($filters['selected_schema'])
|
||
&& ! empty($filters['selected_object'])
|
||
) {
|
||
$columns = UpstreamSchemaColumn::query()
|
||
->where('source_key', $filters['selected_source'])
|
||
->where('schema_name', $filters['selected_schema'])
|
||
->where('object_name', $filters['selected_object'])
|
||
->orderBy('ordinal_position')
|
||
->get();
|
||
}
|
||
|
||
$sourceStatus = UpstreamSchemaColumn::query()
|
||
->select([
|
||
'source_key',
|
||
DB::raw('COUNT(*) AS column_count'),
|
||
DB::raw('MAX(synchronized_at) AS synchronized_at'),
|
||
])
|
||
->groupBy('source_key')
|
||
->get()
|
||
->keyBy('source_key');
|
||
}
|
||
|
||
return view('system_settings.databases.dictionary', [
|
||
'ready' => $ready,
|
||
'objects' => $objects,
|
||
'columns' => $columns,
|
||
'sourceLabels' => $sourceLabels,
|
||
'sourceStatus' => $sourceStatus,
|
||
'filters' => $filters,
|
||
]);
|
||
}
|
||
|
||
public function updateSystem(
|
||
Request $request,
|
||
SystemDatabaseConfiguration $configuration,
|
||
TeamSystemDatabaseProfile $teamProfile
|
||
)
|
||
{
|
||
$data = $request->validate([
|
||
'password' => ['required', 'string', 'max:1000', 'not_regex:/[\r\n\x00]/'],
|
||
]);
|
||
|
||
try {
|
||
$settings = $teamProfile->settings($data['password']);
|
||
$result = $configuration->probe($settings);
|
||
$settings['prefix'] = $result['prefix'];
|
||
$configuration->persist($settings);
|
||
} catch (\Throwable $exception) {
|
||
if (! $exception instanceof \RuntimeException) {
|
||
try {
|
||
report($exception);
|
||
} catch (\Throwable) {
|
||
// 即使日志目录本身异常,也必须返回表单错误,不能再次升级为 500。
|
||
}
|
||
}
|
||
|
||
throw ValidationException::withMessages([
|
||
'password' => $exception instanceof \RuntimeException
|
||
? $exception->getMessage()
|
||
: '连接流程发生异常,系统已保留原数据库连接。请重试或联系负责人。',
|
||
]);
|
||
}
|
||
|
||
auth()->logout();
|
||
$request->session()->invalidate();
|
||
$request->session()->regenerateToken();
|
||
|
||
flash('系统数据库已连接。请使用共享系统数据库中的正式账号重新登录。')->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->sourceIdentifier(), $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->sourceIdentifier();
|
||
$data = $this->validateConnection($request, $connection);
|
||
$connection->update($this->attributes($data, $connection, $request));
|
||
|
||
$this->resetSource($oldSource, $storedConfiguration);
|
||
$this->resetSource($connection->sourceIdentifier(), $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->sourceIdentifier();
|
||
$connection->delete();
|
||
$this->resetSource($source, $storedConfiguration);
|
||
|
||
flash('数据库配置已删除。')->success();
|
||
|
||
return redirect()->route('system-settings.databases.index');
|
||
}
|
||
|
||
public function test(int $connection, UpstreamStoredConfiguration $storedConfiguration, UpstreamDatabaseProbe $probe)
|
||
{
|
||
abort_unless($storedConfiguration->editable() && $storedConfiguration->enabled(), 403);
|
||
$connection = $this->connection($connection);
|
||
|
||
try {
|
||
$probe->probe($connection->driver, $this->odbcConfiguration($connection), $connection->name);
|
||
} catch (DecryptException $exception) {
|
||
throw ValidationException::withMessages([
|
||
'connection' => $exception->getMessage(),
|
||
]);
|
||
} catch (ServiceUnavailableHttpException $exception) {
|
||
throw ValidationException::withMessages([
|
||
'connection' => $connection->driver !== 'sqlsrv'
|
||
? $exception->getMessage()
|
||
: 'SQL Server 连接失败。请检查地址、端口、数据库、账号、网络白名单和 ODBC 驱动。',
|
||
]);
|
||
}
|
||
|
||
$protocol = match ($connection->driver) { 'pgsql' => ' PostgreSQL SSL ', 'mysql' => ' MySQL/MariaDB TLS ', default => '' };
|
||
flash($connection->name . $protocol . '连接成功,仅执行了 SELECT 1 只读测试。')->success();
|
||
|
||
return redirect()->route('system-settings.databases.index');
|
||
}
|
||
|
||
private function validateConnection(Request $request, ?DatabaseConnection $connection = null): array
|
||
{
|
||
$sourceKeys = array_keys($this->sourceLabels());
|
||
$request->merge(['driver' => $request->input('driver', $connection?->driver ?? 'sqlsrv')]);
|
||
$postgres = in_array($request->input('driver'), ['pgsql', 'mysql'], true);
|
||
|
||
if ($postgres && $request->input('source_key', $connection?->source_key)) {
|
||
throw ValidationException::withMessages([
|
||
'source_key' => '现有业务来源使用 SQL Server,不能更换其数据库类型。请单独添加新数据库。',
|
||
]);
|
||
}
|
||
|
||
return $request->validate([
|
||
'driver' => ['required', Rule::in(['sqlsrv', 'pgsql', 'mysql'])],
|
||
'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', function ($attribute, $value, $fail) {
|
||
if (! filter_var($value, FILTER_VALIDATE_IP, FILTER_FLAG_IPV6)
|
||
&& ! preg_match('/^[a-z0-9_.-]+$/i', $value)) {
|
||
$fail('主机地址只填写域名或 IP,端口请单独填写。');
|
||
}
|
||
}],
|
||
'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' => [$postgres ? 'nullable' : 'required', 'string', 'max:128', 'not_regex:/[;{}\x00-\x1F]/'],
|
||
'tds_version' => [$postgres ? 'nullable' : 'required', 'regex:/^\d+(?:\.\d+)?$/', 'max:16'],
|
||
'connect_timeout' => ['required', 'integer', 'between:1,30'],
|
||
'query_timeout' => ['required', 'integer', $postgres ? 'between:1,15' : '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' => array_key_exists('source_key', $data) ? $data['source_key'] : $connection?->source_key,
|
||
'name' => trim($data['name']),
|
||
'driver' => $data['driver'],
|
||
'host' => trim($data['host']),
|
||
'port' => (int) $data['port'],
|
||
'database_name' => trim($data['database_name']),
|
||
'username' => trim($data['username']),
|
||
'odbc_driver' => $data['driver'] !== 'sqlsrv' ? null : trim($data['odbc_driver']),
|
||
'tds_version' => $data['driver'] !== 'sqlsrv' ? null : trim($data['tds_version']),
|
||
'connect_timeout' => (int) $data['connect_timeout'],
|
||
'query_timeout' => (int) $data['query_timeout'],
|
||
'allowed_scopes' => array_key_exists('allowed_scopes', $data)
|
||
? $this->parseScopes((string) $data['allowed_scopes']) : ($connection?->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(app(UpstreamSourceRegistry::class)->all())
|
||
->mapWithKeys(fn (array $source, string $key) => [$key => $source['label'] ?? $key])
|
||
->all();
|
||
}
|
||
|
||
private function defaultValues(): array
|
||
{
|
||
return [
|
||
'name' => '',
|
||
'driver' => 'sqlsrv',
|
||
'source_key' => '',
|
||
'enabled' => true,
|
||
'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,
|
||
'driver' => $connection->driver,
|
||
'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);
|
||
\Modules\EcommerceFinance\Services\UpstreamConnection::reset($source);
|
||
}
|
||
}
|