466 lines
19 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\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->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, 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 === 'pgsql'
? $exception->getMessage()
: 'SQL Server 连接失败。请检查地址、端口、数据库、账号、网络白名单和 ODBC 驱动。',
]);
}
flash($connection->name . ($connection->driver === 'pgsql' ? ' PostgreSQL SSL 连接成功,仅执行了 SELECT 1 只读测试。' : '连接成功,仅执行了 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 = $request->input('driver') === 'pgsql';
if ($postgres && $request->input('source_key', $connection?->source_key)) {
throw ValidationException::withMessages([
'source_key' => '现有业务来源使用 SQL Server,不能改绑到 PostgreSQL。请为 PostgreSQL 单独保存连接。',
]);
}
return $request->validate([
'driver' => ['required', Rule::in(['sqlsrv', 'pgsql'])],
'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'] === 'pgsql' ? null : trim($data['odbc_driver']),
'tds_version' => $data['driver'] === 'pgsql' ? null : 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' => '',
'driver' => 'sqlsrv',
'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,
'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);
UpstreamOdbcConnection::reset($source);
}
}