CaiWuXiTong/modules/EcommerceFinance/Services/BusinessMirrorSchemaManager.php
WuYongJie 920fbcb650 优化售后首屏索引、工单查询及翻页 (#452)
保留原始日期与金额,增加可重建镜像索引并通过相关回归和百万行验证。
2026-10-22 10:55:40 +08:00

277 lines
12 KiB
PHP

<?php
namespace Modules\EcommerceFinance\Services;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use RuntimeException;
class BusinessMirrorSchemaManager
{
/** @return list<array<string,mixed>> */
public function ensure(BusinessMirrorDataset $dataset): array
{
$columns = $this->columns($dataset);
$connection = (string) config('upstream_mirrors.connection', 'business_mirror');
$schema = Schema::connection($connection);
if (! $schema->hasTable($dataset->targetTable())) {
$schema->create($dataset->targetTable(), function (Blueprint $table) use ($columns, $dataset): void {
$table->char('_mirror_key', 64)->primary();
foreach ($columns as $column) {
$this->addColumn($table, $column, $dataset);
}
$table->uuid('_mirror_generation')->nullable()->index();
$table->dateTime('_mirror_synced_at', 6)->index();
$watermark = $dataset->watermarkColumn();
if ($watermark !== null && $this->isIndexable($columns, $watermark)) {
$table->index($watermark, $this->indexName($dataset->targetTable(), 'watermark'));
}
foreach ($this->validatedIndexes($dataset, $columns) as $position => $index) {
$table->index(
$this->indexColumns($index),
$this->configuredIndexName($dataset->targetTable(), $index, $position),
);
}
});
$this->ensureDerivedLookups($dataset, $connection);
return $columns;
}
$existing = array_map('strtolower', $schema->getColumnListing($dataset->targetTable()));
$missing = array_values(array_filter(
$columns,
fn (array $column): bool => ! in_array(strtolower((string) $column['column_name']), $existing, true),
));
if ($missing !== []) {
$schema->table($dataset->targetTable(), function (Blueprint $table) use ($missing, $dataset): void {
foreach ($missing as $column) {
$this->addColumn($table, $column, $dataset);
}
});
}
$configuredIndexes = $this->validatedIndexes($dataset, $columns);
$existingIndexes = collect($schema->getIndexes($dataset->targetTable()))
->map(static fn (array $index): array => array_map('strtolower', (array) ($index['columns'] ?? [])))
->all();
$missingIndexes = array_values(array_filter(
$configuredIndexes,
fn (array $index): bool => ! in_array(
array_map(fn (string $column): string => strtolower($this->indexBaseColumn($column)), $index),
$existingIndexes,
true,
),
));
if ($missingIndexes !== []) {
$schema->table($dataset->targetTable(), function (Blueprint $table) use ($dataset, $missingIndexes): void {
foreach ($missingIndexes as $position => $index) {
$table->index(
$this->indexColumns($index),
$this->configuredIndexName($dataset->targetTable(), $index, $position),
);
}
});
}
$this->ensureDerivedLookups($dataset, $connection);
return $columns;
}
private function ensureDerivedLookups(BusinessMirrorDataset $dataset, string $connection): void
{
if ($dataset->targetTable() === GoodsProfitAfsLookupSchema::TABLE) {
app(GoodsProfitAfsLookupSchema::class)->ensure(DB::connection($connection));
}
}
/** @return list<array<string,mixed>> */
public function columns(BusinessMirrorDataset $dataset): array
{
$rows = DB::table('upstream_schema_columns')
->where('source_key', $dataset->sourceKey())
->whereRaw('LOWER(schema_name) = ?', [strtolower($dataset->sourceSchema())])
->whereRaw('LOWER(object_name) = ?', [strtolower($dataset->sourceObject())])
->orderBy('ordinal_position')
->get()
->map(fn (object $row): array => (array) $row)
->all();
$selected = app(BusinessMirrorDatasetRegistry::class)->columnSelection($dataset->key());
if ($selected !== null) {
$selected = array_map('strtolower', $selected);
$rows = array_values(array_filter($rows, fn (array $row): bool => in_array(strtolower($row['column_name']), $selected, true)));
if (count($rows) !== count($selected)) throw new RuntimeException('business_mirror_selected_column_missing:' . $dataset->key());
}
if ($rows === []) {
throw new RuntimeException('business_mirror_schema_catalog_missing:' . $dataset->key());
}
$names = array_map(fn (array $row): string => strtolower((string) $row['column_name']), $rows);
if (count($names) !== count(array_unique($names))) {
throw new RuntimeException('business_mirror_duplicate_column:' . $dataset->key());
}
foreach ($rows as $row) {
$name = (string) $row['column_name'];
if (strlen($name) > 64 || str_starts_with(strtolower($name), '_mirror_')) {
throw new RuntimeException('business_mirror_unsupported_column:' . $dataset->key());
}
}
$fixedFilterColumns = array_keys($dataset->fixedFilters());
foreach ([...$dataset->keyColumns(), $dataset->watermarkColumn(), ...$fixedFilterColumns] as $required) {
if ($required !== null && ! in_array(strtolower($required), $names, true)) {
throw new RuntimeException('business_mirror_required_column_missing:' . $dataset->key());
}
}
return $rows;
}
private function addColumn(Blueprint $table, array $column, BusinessMirrorDataset $dataset): void
{
$name = (string) $column['column_name'];
$type = strtolower((string) $column['data_type']);
$length = (int) ($column['max_length'] ?? 0);
$precision = min(max((int) ($column['numeric_precision'] ?? 20), 1), 65);
$scale = min(max((int) ($column['numeric_scale'] ?? 0), 0), min($precision, 30));
// 超宽表(例如商品利润 160 多个 nvarchar 列)即使每列只有 50 字符,
// 全部按 VARCHAR 建表也会超过 InnoDB 行长度上限;这类数据集把非键列存为 TEXT。
$wideStrings = (bool) app(BusinessMirrorDatasetRegistry::class)
->definitionValue($dataset->key(), 'wide_strings', false);
$requiredInline = in_array(strtolower($name), array_map('strtolower', $dataset->keyColumns()), true)
|| strcasecmp((string) $dataset->watermarkColumn(), $name) === 0
|| $this->isConfiguredIndexColumn($dataset, $name);
$definition = match ($type) {
'bit', 'boolean' => $table->boolean($name),
'tinyint' => $table->unsignedTinyInteger($name),
'smallint' => $table->smallInteger($name),
'int', 'integer' => $table->integer($name),
'bigint' => $table->bigInteger($name),
'decimal', 'numeric', 'money', 'smallmoney' => $table->decimal($name, $precision, $scale),
'float', 'real', 'double', 'double precision' => $table->double($name),
'date' => $table->date($name),
'datetime', 'datetime2', 'smalldatetime', 'timestamp without time zone', 'timestamp with time zone' => $table->dateTime($name, 6),
'time' => $table->time($name, 6),
'uniqueidentifier', 'uuid' => $table->char($name, 36),
'binary', 'varbinary', 'image', 'timestamp', 'rowversion' => $table->binary($name),
// MariaDB counts every inline varchar column towards the 65,535-byte
// row limit. Wide legacy SQL Server tables can contain dozens of
// nvarchar columns even though each individual column looks small.
// Keep only genuinely short values inline. A declared lookup index
// may opt a bounded legacy identifier into VARCHAR; every other
// wide string remains TEXT and is stored off-page.
'char', 'nchar', 'varchar', 'nvarchar', 'character', 'character varying' => $length > 0
&& ! ($wideStrings && ! $requiredInline)
&& ($length <= 64 || ($length <= 255 && $this->isConfiguredIndexColumn($dataset, $name)))
? $table->string($name, $length)
: $table->longText($name),
default => $table->longText($name),
};
$definition->nullable();
}
private function isIndexable(array $columns, string $name, ?BusinessMirrorDataset $dataset = null): bool
{
foreach ($columns as $column) {
if (strcasecmp((string) $column['column_name'], $name) !== 0) {
continue;
}
$type = strtolower((string) $column['data_type']);
$length = (int) ($column['max_length'] ?? 0);
return ! in_array($type, ['text', 'ntext', 'xml', 'image', 'binary', 'varbinary', 'bytea', 'json', 'jsonb', 'array', 'user-defined'], true)
&& (! in_array($type, ['char', 'nchar', 'varchar', 'nvarchar', 'character', 'character varying'], true)
|| ($length > 0 && ($length <= 64 || ($length <= 255 && $dataset !== null && $this->isConfiguredIndexColumn($dataset, $name)))));
}
return false;
}
private function indexName(string $table, string $suffix): string
{
return substr($table . '_' . $suffix, 0, 64);
}
/** @return list<list<string>> */
private function validatedIndexes(BusinessMirrorDataset $dataset, array $columns): array
{
$columnNames = array_map(
static fn (array $column): string => strtolower((string) $column['column_name']),
$columns,
);
foreach ($dataset->indexDefinitions() as $index) {
foreach ($index as $column) {
$base = $this->indexBaseColumn($column);
if (! in_array(strtolower($base), $columnNames, true)) {
throw new RuntimeException('business_mirror_index_column_unsupported:' . $dataset->key());
}
if ($base !== $column) {
continue;
}
if (! $this->isIndexable($columns, $base, $dataset)) {
throw new RuntimeException('business_mirror_index_column_unsupported:' . $dataset->key());
}
}
}
return $dataset->indexDefinitions();
}
private function isConfiguredIndexColumn(BusinessMirrorDataset $dataset, string $column): bool
{
foreach ($dataset->indexDefinitions() as $index) {
foreach ($index as $indexedColumn) {
if (strcasecmp((string) $indexedColumn, $column) === 0) {
return true;
}
}
}
return false;
}
/** @param list<string> $index @return list<string|Expression> */
private function indexColumns(array $index): array
{
return array_map(function (string $column): string|Expression {
if (preg_match('/^([A-Za-z_][A-Za-z0-9_]*)\((\d+)\)$/', $column, $matched) !== 1) {
return $column;
}
return DB::raw(sprintf('`%s`(%d)', $matched[1], (int) $matched[2]));
}, $index);
}
private function indexBaseColumn(string $column): string
{
return preg_replace('/\(\d+\)$/', '', $column) ?? $column;
}
private function configuredIndexName(string $table, array $columns, int $position): string
{
$signature = implode('_', $columns) . '_' . $position;
return substr($table . '_lookup_' . hash('sha256', $signature), 0, 64);
}
}