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); } }