## 任务占用与边界 - 任务名称:全项目上游业务镜像目录与通用同步框架 - 实际开发者:WuYongJie - 目标结果:登记当前代码实际使用的上游原表,提供低压、可断点的首次全量、增量重叠和全量校准框架。 - 实际修改:13 个文件;新增通用数据集目录、动态镜像建表、同步状态、同步命令、生产低压轮询、测试和团队清单。 - 将写入的公共契约:`business_mirror_table_states`、`business-mirror:sync-tables`、`BusinessMirrorDatasetRegistry`、`BusinessMirrorSchemaManager`。 - 仅复用、明确不修改:`UpstreamReadOnlyQueryFactory`、`upstream_schema_columns`、既有 `product_price_mirror_rows`。 - 明确不包含:本 PR 不切换其余业务页面 Repository,不执行生产迁移或首次全量,不提交真实业务数据或凭据。 ## 合并前自审 - [x] 已重新拉取最新 `origin/main`,分支仅领先 1 个提交 - [x] `git merge-tree --write-tree origin/main HEAD` 无冲突 - [x] 完整 diff 共 13 个文件,未发现越界文件或敏感内容 - [x] 生产共享数据字典核对 38 个数据集,缺失对象/字段为 0 - [x] 临时 MariaDB 空库迁移实测成功,验证库已删除 - [x] 上游异常空结果不会清空已有镜像 - [x] 定时任务只轮转已完成首次全量的数据集,不会自动扫描未初始化大表 ## 测试 - `BusinessMirrorSynchronizerTest` 等相关测试:14 tests / 65 assertions 全通过 - 新同步器专项:5 tests / 25 assertions 全通过 - 新增 PHP 文件语法检查全部通过 - 两份生产激活脚本 `bash -n` 通过 - `git diff --check` 通过 ## 风险与回滚 - 包含新增迁移,但发布脚本遇到迁移仍会要求独立高风险授权;合并本身不会执行生产迁移。 - 首次全量必须逐数据集显式执行并做数量/翻页/筛选验收,特别是约 280 万行的大表不得随发布自动全扫。 - 回滚代码可回退本合并提交;生产迁移执行前必须备份 `caiwu_system_config` 与 `caiwu_business_mirror`。 Reviewed-on: http://git.1080p.hk/WuYongJie/CaiWuXiTong/pulls/53
257 lines
8.0 KiB
PHP
257 lines
8.0 KiB
PHP
<?php
|
|
|
|
namespace Modules\EcommerceFinance\Repositories;
|
|
|
|
use Illuminate\Database\ConnectionInterface;
|
|
use Illuminate\Database\DatabaseManager;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
|
|
use RuntimeException;
|
|
|
|
class GenericBusinessMirrorRepository
|
|
{
|
|
private readonly ConnectionInterface $connection;
|
|
|
|
public function __construct(DatabaseManager $database)
|
|
{
|
|
$this->connection = $database->connection(
|
|
(string) config('upstream_mirrors.connection', 'business_mirror')
|
|
);
|
|
}
|
|
|
|
public function connection(): ConnectionInterface
|
|
{
|
|
return $this->connection;
|
|
}
|
|
|
|
public function ready(BusinessMirrorDataset $dataset): bool
|
|
{
|
|
$schema = Schema::connection($this->connection->getName());
|
|
|
|
if (! $schema->hasTable('business_mirror_table_states')
|
|
|| ! $schema->hasTable($dataset->targetTable())) {
|
|
return false;
|
|
}
|
|
|
|
$state = $this->state($dataset);
|
|
|
|
return is_array($state) && ($state['initial_completed_at'] ?? null) !== null;
|
|
}
|
|
|
|
/** @return array<string,mixed>|null */
|
|
public function state(BusinessMirrorDataset $dataset): ?array
|
|
{
|
|
if (! Schema::connection($this->connection->getName())->hasTable('business_mirror_table_states')) {
|
|
return null;
|
|
}
|
|
|
|
$row = $this->connection->table('business_mirror_table_states')
|
|
->where('dataset_key', $dataset->key())
|
|
->first();
|
|
|
|
if ($row === null) {
|
|
return null;
|
|
}
|
|
|
|
$state = (array) $row;
|
|
$state['cursor'] = $this->decode($state['cursor'] ?? null);
|
|
$state['high_watermark'] = $this->decode($state['high_watermark'] ?? null);
|
|
|
|
return $state;
|
|
}
|
|
|
|
public function startRun(
|
|
BusinessMirrorDataset $dataset,
|
|
string $mode,
|
|
string $generation,
|
|
?string $windowStart,
|
|
?string $windowEnd,
|
|
bool $resume,
|
|
): array {
|
|
$now = now();
|
|
$values = [
|
|
'source_key' => $dataset->sourceKey(),
|
|
'source_object' => $dataset->sourceSchema() . '.' . $dataset->sourceObject(),
|
|
'target_table' => $dataset->targetTable(),
|
|
'status' => 'running',
|
|
'run_mode' => $mode,
|
|
'generation' => $generation,
|
|
'last_attempted_at' => $now,
|
|
'last_error_code' => null,
|
|
'updated_at' => $now,
|
|
];
|
|
|
|
if (! $resume) {
|
|
$values += [
|
|
'cursor' => null,
|
|
'window_started_at' => $windowStart,
|
|
'window_ends_at' => $windowEnd,
|
|
'last_upserted_rows' => 0,
|
|
];
|
|
}
|
|
|
|
$query = $this->connection->table('business_mirror_table_states')
|
|
->where('dataset_key', $dataset->key());
|
|
|
|
if ($query->exists()) {
|
|
$query->update($values);
|
|
} else {
|
|
$this->connection->table('business_mirror_table_states')->insert(
|
|
['dataset_key' => $dataset->key(), 'created_at' => $now] + $values,
|
|
);
|
|
}
|
|
|
|
return $this->state($dataset) ?? [];
|
|
}
|
|
|
|
public function storeBatch(
|
|
BusinessMirrorDataset $dataset,
|
|
array $columns,
|
|
array $rows,
|
|
array $cursor,
|
|
string $generation,
|
|
int $cumulativeRows,
|
|
): void {
|
|
$now = now();
|
|
$columnNames = array_map(fn (array $column): string => (string) $column['column_name'], $columns);
|
|
$mapped = [];
|
|
|
|
foreach ($rows as $row) {
|
|
$values = [];
|
|
foreach ($columnNames as $columnName) {
|
|
$values[$columnName] = $this->value($row, $columnName);
|
|
}
|
|
|
|
$identity = array_map(
|
|
fn (string $column): string => $this->identityValue($this->value($row, $column)),
|
|
$dataset->keyColumns(),
|
|
);
|
|
|
|
$mapped[] = $values + [
|
|
'_mirror_key' => hash('sha256', implode("\0", $identity)),
|
|
'_mirror_generation' => $generation,
|
|
'_mirror_synced_at' => $now,
|
|
];
|
|
}
|
|
|
|
$this->connection->transaction(function () use (
|
|
$dataset, $columnNames, $mapped, $cursor, $cumulativeRows, $now
|
|
): void {
|
|
$updates = [...$columnNames, '_mirror_generation', '_mirror_synced_at'];
|
|
$chunkSize = max(1, intdiv(50000, max(count($updates) + 1, 1)));
|
|
|
|
foreach (array_chunk($mapped, $chunkSize) as $chunk) {
|
|
$this->connection->table($dataset->targetTable())->upsert($chunk, ['_mirror_key'], $updates);
|
|
}
|
|
|
|
$this->connection->table('business_mirror_table_states')
|
|
->where('dataset_key', $dataset->key())
|
|
->update([
|
|
'cursor' => json_encode($cursor, JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION),
|
|
'last_upserted_rows' => $cumulativeRows,
|
|
'updated_at' => $now,
|
|
]);
|
|
});
|
|
}
|
|
|
|
public function complete(
|
|
BusinessMirrorDataset $dataset,
|
|
string $mode,
|
|
string $generation,
|
|
array $highWatermark,
|
|
int $rows,
|
|
): void {
|
|
$now = now();
|
|
|
|
$this->connection->transaction(function () use (
|
|
$dataset, $mode, $generation, $highWatermark, $rows, $now
|
|
): void {
|
|
if ($mode === 'full') {
|
|
$existingRows = $this->connection->table($dataset->targetTable())->count();
|
|
|
|
if ($rows === 0 && $existingRows > 0) {
|
|
throw new RuntimeException('business_mirror_unexpected_empty_source');
|
|
}
|
|
|
|
$this->connection->table($dataset->targetTable())
|
|
->where(function ($query) use ($generation): void {
|
|
$query->whereNull('_mirror_generation')
|
|
->orWhere('_mirror_generation', '!=', $generation);
|
|
})
|
|
->delete();
|
|
}
|
|
|
|
$rowCount = $this->connection->table($dataset->targetTable())->count();
|
|
$values = [
|
|
'status' => 'succeeded',
|
|
'cursor' => null,
|
|
'high_watermark' => json_encode($highWatermark, JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION),
|
|
'last_succeeded_at' => $now,
|
|
'last_upserted_rows' => $rows,
|
|
'mirror_row_count' => $rowCount,
|
|
'last_error_code' => null,
|
|
'updated_at' => $now,
|
|
];
|
|
|
|
if ($mode === 'full') {
|
|
$values['initial_completed_at'] = $now;
|
|
$values['last_full_succeeded_at'] = $now;
|
|
}
|
|
|
|
$this->connection->table('business_mirror_table_states')
|
|
->where('dataset_key', $dataset->key())
|
|
->update($values);
|
|
});
|
|
}
|
|
|
|
public function fail(BusinessMirrorDataset $dataset, string $errorCode): void
|
|
{
|
|
$this->connection->table('business_mirror_table_states')
|
|
->where('dataset_key', $dataset->key())
|
|
->update([
|
|
'status' => 'failed',
|
|
'last_error_code' => mb_substr($errorCode, 0, 120),
|
|
'updated_at' => now(),
|
|
]);
|
|
}
|
|
|
|
private function decode(mixed $value): ?array
|
|
{
|
|
if (is_array($value)) {
|
|
return $value;
|
|
}
|
|
|
|
if (! is_string($value) || $value === '') {
|
|
return null;
|
|
}
|
|
|
|
$decoded = json_decode($value, true);
|
|
|
|
return is_array($decoded) ? $decoded : null;
|
|
}
|
|
|
|
private function value(array $row, string $name): mixed
|
|
{
|
|
foreach ($row as $key => $value) {
|
|
if (strcasecmp((string) $key, $name) === 0) {
|
|
return $value;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
private function identityValue(mixed $value): string
|
|
{
|
|
if ($value === null) {
|
|
return '<NULL>';
|
|
}
|
|
|
|
if (is_bool($value)) {
|
|
return $value ? '1' : '0';
|
|
}
|
|
|
|
return (string) $value;
|
|
}
|
|
}
|