From b40bb931876a1bc1b2e2ee3a5fa7e630c9c43786 Mon Sep 17 00:00:00 2001 From: limeng <1131177064@qq.com> Date: Sat, 15 Aug 2026 19:06:41 +0800 Subject: [PATCH] =?UTF-8?q?=E6=99=AE=E9=80=9A=E5=95=86=E5=93=81=E4=B8=93?= =?UTF-8?q?=E7=94=A8=E5=90=8C=E6=AD=A5=E4=B8=8E=E6=A0=A1=E5=87=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/Console/Commands/SyncBusinessMirrors.php | 8 + .../Commands/SyncProductCatalogMirror.php | 119 ++++++++++++++ app/Http/Controllers/Business/Pages.php | 7 + config/upstream_mirrors.php | 15 ++ deploy/activate-incremental-release.sh | 3 +- deploy/activate-release.sh | 3 +- docs/上游业务镜像清单.md | 7 +- .../ProductCatalogMirrorRepository.php | 51 ++++++ .../normal-products.blade.php | 51 ++++-- .../Business/ProductCatalogPageTest.php | 78 ++++++++- .../Console/BusinessMirrorScheduleTest.php | 55 +++++++ .../ProductCatalogMirrorSyncCommandTest.php | 152 ++++++++++++++++++ .../Deployment/BusinessMirrorScheduleTest.php | 30 ++++ .../ProductCatalogMirrorRepositoryTest.php | 34 ++++ 14 files changed, 595 insertions(+), 18 deletions(-) create mode 100644 app/Console/Commands/SyncProductCatalogMirror.php create mode 100644 tests/Feature/Console/BusinessMirrorScheduleTest.php create mode 100644 tests/Feature/Console/ProductCatalogMirrorSyncCommandTest.php create mode 100644 tests/Unit/Deployment/BusinessMirrorScheduleTest.php diff --git a/app/Console/Commands/SyncBusinessMirrors.php b/app/Console/Commands/SyncBusinessMirrors.php index 5abb3fa5..64098240 100644 --- a/app/Console/Commands/SyncBusinessMirrors.php +++ b/app/Console/Commands/SyncBusinessMirrors.php @@ -85,8 +85,16 @@ class SyncBusinessMirrors extends Command GenericBusinessMirrorRepository $mirror, ): array { $scheduled = []; + $dedicated = array_fill_keys(array_map( + 'strval', + (array) config('upstream_mirrors.table_sync.dedicated_datasets', []), + ), true); foreach ($datasets->all() as $key => $dataset) { + if (isset($dedicated[$key])) { + continue; + } + $state = $mirror->state($dataset); if (! is_array($state) || ($state['initial_completed_at'] ?? null) === null) { continue; diff --git a/app/Console/Commands/SyncProductCatalogMirror.php b/app/Console/Commands/SyncProductCatalogMirror.php new file mode 100644 index 00000000..9e245d94 --- /dev/null +++ b/app/Console/Commands/SyncProductCatalogMirror.php @@ -0,0 +1,119 @@ +option('full')) { + return self::SUCCESS; + } + + $datasetKey = (string) config( + 'upstream_mirrors.product_catalog.dataset_key', + 'operations_replica.rpt.jst_sku', + ); + + try { + $dataset = $datasets->get($datasetKey); + $state = $mirror->state($dataset); + $full = $this->shouldRunFull($state); + $result = $synchronizer->sync( + $datasetKey, + $full, + $this->option('batch') === null + ? (int) config('upstream_mirrors.product_catalog.batch_size', 1000) + : (int) $this->option('batch'), + $this->option('max-batches') === null + ? (int) config('upstream_mirrors.product_catalog.max_batches_per_run', 5) + : (int) $this->option('max-batches'), + ); + } catch (Throwable $exception) { + Log::error('普通商品共享镜像同步失败。', [ + 'dataset' => $datasetKey, + 'exception' => $exception::class, + ]); + $this->error('普通商品同步失败,已保留上一版共享数据。'); + + return self::FAILURE; + } + + if ($result['status'] === 'requires_full_sync') { + $this->warn('普通商品尚未完成首次全量同步,需经授权后显式使用 --full。'); + + return self::SUCCESS; + } + + $this->info(sprintf( + '普通商品%s:%s,本轮 %d 批,累计处理 %d 行。', + $result['mode'] === 'full' ? '全量校准' : '增量同步', + $result['status'] === 'succeeded' ? '完成' : '进行中', + $result['batches'], + $result['rows'], + )); + + return self::SUCCESS; + } + + /** @param array|null $state */ + private function shouldRunFull(?array $state): bool + { + if ((bool) $this->option('full')) { + return true; + } + + if (is_array($state) + && ($state['run_mode'] ?? null) === 'full' + && in_array($state['status'] ?? null, ['running', 'failed'], true)) { + return true; + } + + if (! is_array($state) || ($state['initial_completed_at'] ?? null) === null) { + return false; + } + + $now = now(); + if (! $this->isReconcileWindow($now)) { + return false; + } + + $lastFull = $state['last_full_succeeded_at'] ?? null; + if ($lastFull === null || $lastFull === '') { + return true; + } + + $hours = max((int) config('upstream_mirrors.product_catalog.full_reconcile_hours', 168), 1); + + return Carbon::parse((string) $lastFull)->addHours($hours)->lte($now); + } + + private function isReconcileWindow(Carbon $now): bool + { + $weekday = min(max((int) config('upstream_mirrors.product_catalog.reconcile_weekday', 0), 0), 6); + $startHour = min(max((int) config('upstream_mirrors.product_catalog.reconcile_start_hour', 2), 0), 23); + $windowHours = min(max((int) config('upstream_mirrors.product_catalog.reconcile_window_hours', 6), 1), 24 - $startHour); + + return $now->dayOfWeek === $weekday + && $now->hour >= $startHour + && $now->hour < $startHour + $windowHours; + } +} diff --git a/app/Http/Controllers/Business/Pages.php b/app/Http/Controllers/Business/Pages.php index 8d292e4c..c36cc18f 100644 --- a/app/Http/Controllers/Business/Pages.php +++ b/app/Http/Controllers/Business/Pages.php @@ -547,10 +547,12 @@ class Pages extends Controller $page, '共享业务镜像尚未建立,普通商品资料暂时无法读取。', null, + ['code' => 'unavailable', 'lag_minutes' => null], ); } $state = $this->productCatalog->state(); + $syncStatus = $this->productCatalog->syncStatus($state); if (! $this->productCatalog->ready()) { return $this->emptyProductCatalogContent( $filters, @@ -558,6 +560,7 @@ class Pages extends Controller $page, '普通商品资料正在执行首次全量同步,完成前不会把未同步数据误判为无数据。', $state, + $syncStatus, ); } @@ -578,6 +581,7 @@ class Pages extends Controller 'productCatalogFilters' => $filters, 'productCatalogError' => null, 'productCatalogState' => $state, + 'productCatalogSyncStatus' => $syncStatus, ]; } catch (Throwable $exception) { report($exception); @@ -588,6 +592,7 @@ class Pages extends Controller $page, '共享业务数据暂时无法读取,请稍后重试。', null, + ['code' => 'unavailable', 'lag_minutes' => null], ); } } @@ -598,6 +603,7 @@ class Pages extends Controller int $page, string $error, ?array $state, + array $syncStatus, ): array { return [ 'productCatalogRows' => new LengthAwarePaginator( @@ -613,6 +619,7 @@ class Pages extends Controller 'productCatalogFilters' => $filters, 'productCatalogError' => $error, 'productCatalogState' => $state, + 'productCatalogSyncStatus' => $syncStatus, ]; } diff --git a/config/upstream_mirrors.php b/config/upstream_mirrors.php index d44842f2..32e54a7f 100644 --- a/config/upstream_mirrors.php +++ b/config/upstream_mirrors.php @@ -19,6 +19,18 @@ return [ 'overlap_hours' => (int) env('PRODUCT_PRICE_MIRROR_OVERLAP_HOURS', 48), ], + 'product_catalog' => [ + 'dataset_key' => 'operations_replica.rpt.jst_sku', + 'batch_size' => (int) env('PRODUCT_CATALOG_MIRROR_BATCH_SIZE', 1000), + 'max_batches_per_run' => (int) env('PRODUCT_CATALOG_MIRROR_MAX_BATCHES', 5), + 'stale_after_minutes' => (int) env('PRODUCT_CATALOG_MIRROR_STALE_MINUTES', 10), + 'full_reconcile_hours' => (int) env('PRODUCT_CATALOG_MIRROR_FULL_RECONCILE_HOURS', 168), + // 每周日凌晨 02:00-08:00 可启动到期校准;启动后按断点持续到完成。 + 'reconcile_weekday' => (int) env('PRODUCT_CATALOG_MIRROR_RECONCILE_WEEKDAY', 0), + 'reconcile_start_hour' => (int) env('PRODUCT_CATALOG_MIRROR_RECONCILE_START_HOUR', 2), + 'reconcile_window_hours' => (int) env('PRODUCT_CATALOG_MIRROR_RECONCILE_WINDOW_HOURS', 6), + ], + /* |-------------------------------------------------------------------------- | 上游原表镜像目录 @@ -37,6 +49,9 @@ return [ 'max_datasets_per_run' => (int) env('BUSINESS_MIRROR_MAX_DATASETS', 1), 'overlap_hours' => (int) env('BUSINESS_MIRROR_OVERLAP_HOURS', 48), 'full_reconcile_hours' => (int) env('BUSINESS_MIRROR_FULL_RECONCILE_HOURS', 168), + 'dedicated_datasets' => [ + 'operations_replica.rpt.jst_sku', + ], ], 'datasets' => [ diff --git a/deploy/activate-incremental-release.sh b/deploy/activate-incremental-release.sh index 2e7f6097..fdb7cd81 100644 --- a/deploy/activate-incremental-release.sh +++ b/deploy/activate-incremental-release.sh @@ -127,8 +127,9 @@ mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha" # 只运行已登记的上游后台读取,不启用整个 Laravel 调度器,避免意外启动 # 账单提醒等既有任务。同一把锁保证快照与业务镜像不会并发施压上游。 +# 普通商品优先高频增量,并在配置的每周低峰窗口自动继续全量校准。 cat > /etc/cron.d/caiwu-jiansun-snapshots <<'EOF' -* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1 +* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1 EOF chmod 644 /etc/cron.d/caiwu-jiansun-snapshots diff --git a/deploy/activate-release.sh b/deploy/activate-release.sh index 45a4b552..6c51a60f 100644 --- a/deploy/activate-release.sh +++ b/deploy/activate-release.sh @@ -131,8 +131,9 @@ mv "$REMOTE_DIR/.deployed-sha.tmp" "$REMOTE_DIR/.deployed-sha" # 只运行已登记的上游后台读取,不启用整个 Laravel 调度器,避免意外启动 # 账单提醒等既有任务。同一把锁保证快照与业务镜像不会并发施压上游。 +# 普通商品优先高频增量,并在配置的每周低峰窗口自动继续全量校准。 cat > /etc/cron.d/caiwu-jiansun-snapshots <<'EOF' -* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1 +* * * * * root /usr/bin/flock -n /run/caiwu-upstream-sync.lock /bin/sh -c '/usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-catalog --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan upstream:snapshots:refresh-all --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-product-prices --quiet; /usr/bin/docker exec -u www-data caiwu_app php artisan business-mirror:sync-tables --quiet' >/dev/null 2>&1 EOF chmod 644 /etc/cron.d/caiwu-jiansun-snapshots diff --git a/docs/上游业务镜像清单.md b/docs/上游业务镜像清单.md index c0f5f38f..9bfdc4f4 100644 --- a/docs/上游业务镜像清单.md +++ b/docs/上游业务镜像清单.md @@ -45,10 +45,11 @@ ## 同步规则 1. 首次全量只能通过 `business-mirror:sync-tables --full` 显式启动;失败或中断后使用同一命令继续。 -2. 日常任务只处理已经完成首次全量的数据集。存在可靠更新时间的表使用 48 小时重叠窗口;没有可靠更新时间的表按稳定主键追加。 +2. 日常任务只处理已经完成首次全量的数据集。存在可靠更新时间的表使用 48 小时重叠窗口;没有可靠更新时间的表按稳定主键追加。`rpt.jst_sku` 由普通商品专用任务每分钟优先处理,每轮默认最多 5 批、每批 1000 行,不再参加通用数据集轮转。 3. 全量校准使用新的同步代次,只有全部完成后才删除镜像中本轮未出现的旧行,因此中途失败不会清空当前可用数据。 -4. 每轮默认只处理 1 个数据集、每个数据集 2 批,每批 500 行,避免同时压迫两个旧系统。 -5. 页面切换必须以“总记录数、第一页、第二页、末页、筛选、汇总、空数据和更新时间”作为验收,不能只看首页有数据。 +4. 普通商品在最近完整校准超过 168 小时后,于周日 02:00-08:00 低峰窗口启动全量校准;一旦启动,后续每分钟从稳定游标继续,直至完整结束。首次全量仍必须显式授权,不能由定时任务自行启动。 +5. 除专用数据集外,通用任务每轮默认只处理 1 个数据集、每个数据集 2 批,每批 500 行,避免同时压迫两个旧系统。 +6. 页面切换必须以“总记录数、第一页、第二页、末页、筛选、汇总、空数据和更新时间”作为验收,不能只看首页有数据。 ## 后续切换顺序 diff --git a/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php b/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php index b78582e6..192e3733 100644 --- a/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php +++ b/modules/EcommerceFinance/Repositories/ProductCatalogMirrorRepository.php @@ -2,11 +2,14 @@ namespace Modules\EcommerceFinance\Repositories; +use Carbon\CarbonInterface; use Illuminate\Database\ConnectionInterface; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Schema; use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset; use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry; use RuntimeException; +use Throwable; class ProductCatalogMirrorRepository { @@ -40,6 +43,41 @@ class ProductCatalogMirrorRepository return $this->mirror->state($this->dataset()); } + /** + * @param array|null $state + * @return array{code:string,lag_minutes:?int} + */ + public function syncStatus(?array $state = null, ?CarbonInterface $at = null): array + { + $state ??= $this->state(); + if (! is_array($state)) { + return ['code' => 'unavailable', 'lag_minutes' => null]; + } + + $lastSucceeded = $this->timestamp($state['last_succeeded_at'] ?? null); + $lagMinutes = $lastSucceeded === null + ? null + : (int) ceil(max(0, ($at ?? now())->getTimestamp() - $lastSucceeded) / 60); + $status = (string) ($state['status'] ?? 'pending'); + + if (($state['initial_completed_at'] ?? null) === null) { + $code = $status === 'failed' + ? 'initial_failed' + : ($status === 'running' ? 'initializing' : 'pending'); + } elseif ($status === 'failed') { + $code = 'failed'; + } elseif ($status === 'running') { + $code = ($state['run_mode'] ?? null) === 'full' ? 'reconciling' : 'syncing'; + } elseif ($lagMinutes === null + || $lagMinutes > max((int) config('upstream_mirrors.product_catalog.stale_after_minutes', 10), 1)) { + $code = 'delayed'; + } else { + $code = 'healthy'; + } + + return ['code' => $code, 'lag_minutes' => $lagMinutes]; + } + /** @return array{data:list,total:int} */ public function paginate(array $filters, int $page, int $perPage): array { @@ -125,4 +163,17 @@ class ProductCatalogMirrorRepository { return '%' . str_replace(['!', '%', '_'], ['!!', '!%', '!_'], trim($value)) . '%'; } + + private function timestamp(mixed $value): ?int + { + if (! is_string($value) || $value === '') { + return null; + } + + try { + return Carbon::parse($value)->getTimestamp(); + } catch (Throwable) { + return null; + } + } } diff --git a/resources/views/business/pages/product-inventory/normal-products.blade.php b/resources/views/business/pages/product-inventory/normal-products.blade.php index c76d4bb3..6cf30bc8 100644 --- a/resources/views/business/pages/product-inventory/normal-products.blade.php +++ b/resources/views/business/pages/product-inventory/normal-products.blade.php @@ -3,16 +3,34 @@ $productCatalogFilters = $content['productCatalogFilters'] ?? []; $productCatalogError = $content['productCatalogError'] ?? null; $productCatalogState = $content['productCatalogState'] ?? null; + $productCatalogSyncStatus = $content['productCatalogSyncStatus'] ?? ['code' => 'unavailable', 'lag_minutes' => null]; $hasFilters = collect($productCatalogFilters)->contains( static fn ($value) => $value !== null && $value !== '' ); $stateLabels = [ - 'succeeded' => '同步完成', - 'running' => '同步中', - 'failed' => '最近同步失败', + 'healthy' => '同步正常', + 'syncing' => '增量同步中', + 'reconciling' => '全量校准中', + 'delayed' => '同步延迟', + 'failed' => '同步失败,显示最近数据', + 'initial_failed' => '首次同步失败', + 'initializing' => '首次同步中', 'pending' => '等待同步', + 'unavailable' => '数据暂不可用', ]; - $stateCode = is_array($productCatalogState) ? ($productCatalogState['status'] ?? '') : ''; + $stateCode = (string) ($productCatalogSyncStatus['code'] ?? 'unavailable'); + $stateDotClass = match ($stateCode) { + 'failed', 'initial_failed', 'unavailable' => 'is-danger', + 'delayed', 'pending', 'initializing' => 'is-warning', + 'syncing', 'reconciling' => 'is-info', + default => '', + }; + $lagMinutes = $productCatalogSyncStatus['lag_minutes'] ?? null; + $lagLabel = is_int($lagMinutes) + ? ($lagMinutes === 0 + ? '小于 1 分钟' + : ($lagMinutes < 60 ? $lagMinutes . ' 分钟' : floor($lagMinutes / 60) . ' 小时 ' . ($lagMinutes % 60) . ' 分钟')) + : null; @endphp
-
+
- - {{ $stateLabels[$stateCode] ?? ($productCatalogError ? '数据暂不可用' : '共享业务镜像') }} + + {{ $stateLabels[$stateCode] ?? '共享业务镜像' }}
@if (!empty($productCatalogState['last_succeeded_at'])) 最近同步 {{ $productCatalogState['last_succeeded_at'] }} @endif + @if ($lagLabel !== null) + 数据延迟 {{ $lagLabel }} + @endif + @if (!empty($productCatalogState['last_full_succeeded_at'])) + 最近校准 {{ $productCatalogState['last_full_succeeded_at'] }} + @endif @if (isset($productCatalogState['mirror_row_count'])) 镜像总行数 {{ number_format((int) $productCatalogState['mirror_row_count']) }} @endif diff --git a/tests/Feature/Business/ProductCatalogPageTest.php b/tests/Feature/Business/ProductCatalogPageTest.php index da21e987..ca747512 100644 --- a/tests/Feature/Business/ProductCatalogPageTest.php +++ b/tests/Feature/Business/ProductCatalogPageTest.php @@ -19,12 +19,18 @@ class ProductCatalogPageTest extends FeatureTestCase { $this->grantAdminPermission(); - $repository = \Mockery::mock(ProductCatalogMirrorRepository::class); - $repository->shouldReceive('available')->once()->andReturnTrue(); - $repository->shouldReceive('state')->once()->andReturn([ + $state = [ 'status' => 'succeeded', 'last_succeeded_at' => '2026-08-15 17:51:03', + 'last_full_succeeded_at' => '2026-08-10 03:15:00', 'mirror_row_count' => 547217, + ]; + $repository = \Mockery::mock(ProductCatalogMirrorRepository::class); + $repository->shouldReceive('available')->once()->andReturnTrue(); + $repository->shouldReceive('state')->once()->andReturn($state); + $repository->shouldReceive('syncStatus')->once()->with($state)->andReturn([ + 'code' => 'healthy', + 'lag_minutes' => 4, ]); $repository->shouldReceive('ready')->once()->andReturnTrue(); $repository->shouldReceive('paginate') @@ -80,7 +86,11 @@ class ProductCatalogPageTest extends FeatureTestCase ->assertOk() ->assertSee('data-product-catalog-page', false) ->assertSeeText('普通商品资料') + ->assertSee('data-sync-state="healthy"', false) + ->assertSeeText('同步正常') ->assertSeeText('最近同步 2026-08-15 17:51:03') + ->assertSeeText('数据延迟 4 分钟') + ->assertSeeText('最近校准 2026-08-10 03:15:00') ->assertSeeText('STYLE-001') ->assertSeeText('SKU-001') ->assertSeeText('岩板餐桌') @@ -93,9 +103,14 @@ class ProductCatalogPageTest extends FeatureTestCase { $this->grantAdminPermission(); + $state = ['status' => 'running']; $repository = \Mockery::mock(ProductCatalogMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnTrue(); - $repository->shouldReceive('state')->once()->andReturn(['status' => 'running']); + $repository->shouldReceive('state')->once()->andReturn($state); + $repository->shouldReceive('syncStatus')->once()->with($state)->andReturn([ + 'code' => 'initializing', + 'lag_minutes' => null, + ]); $repository->shouldReceive('ready')->once()->andReturnFalse(); $repository->shouldNotReceive('paginate'); $this->app->instance(ProductCatalogMirrorRepository::class, $repository); @@ -106,6 +121,7 @@ class ProductCatalogPageTest extends FeatureTestCase ])) ->assertOk() ->assertSeeText('普通商品资料正在执行首次全量同步') + ->assertSeeText('首次同步中') ->assertDontSeeText('当前镜像中没有普通商品资料。'); } @@ -116,6 +132,7 @@ class ProductCatalogPageTest extends FeatureTestCase $repository = \Mockery::mock(ProductCatalogMirrorRepository::class); $repository->shouldReceive('available')->once()->andReturnFalse(); $repository->shouldNotReceive('state'); + $repository->shouldNotReceive('syncStatus'); $repository->shouldNotReceive('ready'); $repository->shouldNotReceive('paginate'); $this->app->instance(ProductCatalogMirrorRepository::class, $repository); @@ -129,6 +146,59 @@ class ProductCatalogPageTest extends FeatureTestCase ->assertDontSeeText('当前镜像中没有普通商品资料。'); } + public function test_normal_products_page_keeps_old_rows_visible_after_a_sync_failure(): void + { + $this->grantAdminPermission(); + + $state = [ + 'status' => 'failed', + 'run_mode' => 'incremental', + 'initial_completed_at' => '2026-08-14 21:15:36', + 'last_succeeded_at' => '2026-08-15 17:51:03', + 'mirror_row_count' => 547217, + ]; + $repository = \Mockery::mock(ProductCatalogMirrorRepository::class); + $repository->shouldReceive('available')->once()->andReturnTrue(); + $repository->shouldReceive('state')->once()->andReturn($state); + $repository->shouldReceive('syncStatus')->once()->with($state)->andReturn([ + 'code' => 'failed', + 'lag_minutes' => 18, + ]); + $repository->shouldReceive('ready')->once()->andReturnTrue(); + $repository->shouldReceive('paginate')->once()->andReturn([ + 'data' => [(object) [ + 'autoid' => 1001, + 'i_id' => 'STYLE-OLD', + 'sku_id' => 'SKU-OLD', + 'name' => '最近成功同步的商品', + 'short_name' => null, + 'properties_value' => null, + 'color' => null, + 'sale_price' => 100, + 'cost_price' => null, + 'purchase_price' => null, + 'market_price' => null, + 'brand' => null, + 'category' => null, + 'supplier_name' => null, + 'enabled' => 1, + 'stock_disabled' => 0, + 'modified' => '2026-08-15 17:45:00', + ]], + 'total' => 1, + ]); + $this->app->instance(ProductCatalogMirrorRepository::class, $repository); + + $this->loginAs()->get(route('business.pages.show', [ + 'section' => 'product-inventory', + 'page' => 'normal-products', + ])) + ->assertOk() + ->assertSee('data-sync-state="failed"', false) + ->assertSeeText('同步失败,显示最近数据') + ->assertSeeText('最近成功同步的商品'); + } + public function test_normal_products_page_rejects_users_without_sidebar_permission(): void { $subject = 'user_' . $this->user->id; diff --git a/tests/Feature/Console/BusinessMirrorScheduleTest.php b/tests/Feature/Console/BusinessMirrorScheduleTest.php new file mode 100644 index 00000000..bccd7d41 --- /dev/null +++ b/tests/Feature/Console/BusinessMirrorScheduleTest.php @@ -0,0 +1,55 @@ + true, + 'upstream_mirrors.table_sync.dedicated_datasets' => [ + ProductCatalogMirrorRepository::DATASET_KEY, + ], + ]); + + $catalog = Mockery::mock(BusinessMirrorDataset::class); + $other = Mockery::mock(BusinessMirrorDataset::class); + $datasets = Mockery::mock(BusinessMirrorDatasetRegistry::class); + $datasets->shouldReceive('all')->once()->andReturn([ + ProductCatalogMirrorRepository::DATASET_KEY => $catalog, + 'test.dbo.other' => $other, + ]); + $mirror = Mockery::mock(GenericBusinessMirrorRepository::class); + $mirror->shouldReceive('state')->once()->with($other)->andReturn([ + 'initial_completed_at' => '2026-08-01 02:00:00', + 'last_attempted_at' => '2026-08-15 01:00:00', + ]); + $mirror->shouldNotReceive('state')->with($catalog); + $synchronizer = Mockery::mock(BusinessMirrorSynchronizer::class); + $synchronizer->shouldReceive('sync') + ->once() + ->with('test.dbo.other', false, null, null) + ->andReturn([ + 'status' => 'succeeded', + 'mode' => 'incremental', + 'batches' => 1, + 'rows' => 10, + 'dataset' => 'test.dbo.other', + ]); + + $this->app->instance(BusinessMirrorDatasetRegistry::class, $datasets); + $this->app->instance(GenericBusinessMirrorRepository::class, $mirror); + $this->app->instance(BusinessMirrorSynchronizer::class, $synchronizer); + + $this->artisan('business-mirror:sync-tables')->assertSuccessful(); + } +} diff --git a/tests/Feature/Console/ProductCatalogMirrorSyncCommandTest.php b/tests/Feature/Console/ProductCatalogMirrorSyncCommandTest.php new file mode 100644 index 00000000..f1178aa9 --- /dev/null +++ b/tests/Feature/Console/ProductCatalogMirrorSyncCommandTest.php @@ -0,0 +1,152 @@ + true, + 'upstream_mirrors.product_catalog' => [ + 'dataset_key' => ProductCatalogMirrorRepository::DATASET_KEY, + 'batch_size' => 1000, + 'max_batches_per_run' => 5, + 'full_reconcile_hours' => 168, + 'reconcile_weekday' => 0, + 'reconcile_start_hour' => 2, + 'reconcile_window_hours' => 6, + ], + ]); + } + + protected function tearDown(): void + { + Carbon::setTestNow(); + + parent::tearDown(); + } + + public function test_it_runs_incremental_sync_with_dedicated_limits(): void + { + Carbon::setTestNow('2026-08-17 10:00:00'); + $this->bindDependencies([ + 'status' => 'succeeded', + 'run_mode' => 'incremental', + 'initial_completed_at' => '2026-08-01 02:00:00', + 'last_full_succeeded_at' => '2026-08-16 04:00:00', + ], false, $this->syncResult('incremental')); + + $this->artisan('business-mirror:sync-product-catalog') + ->expectsOutputToContain('普通商品增量同步:完成') + ->assertSuccessful(); + } + + public function test_it_starts_a_due_full_reconcile_inside_the_weekly_window(): void + { + Carbon::setTestNow('2026-08-23 02:30:00'); + $this->bindDependencies([ + 'status' => 'succeeded', + 'run_mode' => 'incremental', + 'initial_completed_at' => '2026-08-01 02:00:00', + 'last_full_succeeded_at' => '2026-08-15 02:00:00', + ], true, $this->syncResult('full', 'running')); + + $this->artisan('business-mirror:sync-product-catalog') + ->expectsOutputToContain('普通商品全量校准:进行中') + ->assertSuccessful(); + } + + public function test_it_continues_an_in_progress_full_reconcile_outside_the_start_window(): void + { + Carbon::setTestNow('2026-08-24 10:00:00'); + $this->bindDependencies([ + 'status' => 'running', + 'run_mode' => 'full', + 'initial_completed_at' => '2026-08-01 02:00:00', + 'last_full_succeeded_at' => '2026-08-15 02:00:00', + 'cursor' => ['autoid' => 5000], + ], true, $this->syncResult('full', 'running')); + + $this->artisan('business-mirror:sync-product-catalog')->assertSuccessful(); + } + + public function test_it_does_not_automatically_start_the_first_full_sync(): void + { + Carbon::setTestNow('2026-08-23 02:30:00'); + $this->bindDependencies([ + 'status' => 'pending', + 'initial_completed_at' => null, + ], false, $this->syncResult('incremental', 'requires_full_sync')); + + $this->artisan('business-mirror:sync-product-catalog') + ->expectsOutputToContain('需经授权后显式使用 --full') + ->assertSuccessful(); + } + + public function test_it_reports_failure_without_claiming_the_old_mirror_was_removed(): void + { + Carbon::setTestNow('2026-08-17 10:00:00'); + $dataset = Mockery::mock(BusinessMirrorDataset::class); + $datasets = Mockery::mock(BusinessMirrorDatasetRegistry::class); + $datasets->shouldReceive('get')->once()->with(ProductCatalogMirrorRepository::DATASET_KEY)->andReturn($dataset); + $mirror = Mockery::mock(GenericBusinessMirrorRepository::class); + $mirror->shouldReceive('state')->once()->with($dataset)->andReturn([ + 'status' => 'succeeded', + 'initial_completed_at' => '2026-08-01 02:00:00', + 'last_full_succeeded_at' => '2026-08-16 04:00:00', + ]); + $synchronizer = Mockery::mock(BusinessMirrorSynchronizer::class); + $synchronizer->shouldReceive('sync')->once()->andThrow(new RuntimeException('upstream_unavailable')); + $this->app->instance(BusinessMirrorDatasetRegistry::class, $datasets); + $this->app->instance(GenericBusinessMirrorRepository::class, $mirror); + $this->app->instance(BusinessMirrorSynchronizer::class, $synchronizer); + + $this->artisan('business-mirror:sync-product-catalog') + ->expectsOutputToContain('已保留上一版共享数据') + ->assertFailed(); + } + + /** @param array $state */ + private function bindDependencies(array $state, bool $full, array $result): void + { + $dataset = Mockery::mock(BusinessMirrorDataset::class); + $datasets = Mockery::mock(BusinessMirrorDatasetRegistry::class); + $datasets->shouldReceive('get')->once()->with(ProductCatalogMirrorRepository::DATASET_KEY)->andReturn($dataset); + $mirror = Mockery::mock(GenericBusinessMirrorRepository::class); + $mirror->shouldReceive('state')->once()->with($dataset)->andReturn($state); + $synchronizer = Mockery::mock(BusinessMirrorSynchronizer::class); + $synchronizer->shouldReceive('sync') + ->once() + ->with(ProductCatalogMirrorRepository::DATASET_KEY, $full, 1000, 5) + ->andReturn($result); + + $this->app->instance(BusinessMirrorDatasetRegistry::class, $datasets); + $this->app->instance(GenericBusinessMirrorRepository::class, $mirror); + $this->app->instance(BusinessMirrorSynchronizer::class, $synchronizer); + } + + /** @return array{status:string,mode:string,batches:int,rows:int,dataset:string} */ + private function syncResult(string $mode, string $status = 'succeeded'): array + { + return [ + 'status' => $status, + 'mode' => $mode, + 'batches' => 2, + 'rows' => 2000, + 'dataset' => ProductCatalogMirrorRepository::DATASET_KEY, + ]; + } +} diff --git a/tests/Unit/Deployment/BusinessMirrorScheduleTest.php b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php new file mode 100644 index 00000000..f2867862 --- /dev/null +++ b/tests/Unit/Deployment/BusinessMirrorScheduleTest.php @@ -0,0 +1,30 @@ +assertNotFalse($catalogPosition, $script); + $this->assertNotFalse($snapshotPosition, $script); + $this->assertNotFalse($pricePosition, $script); + $this->assertNotFalse($genericPosition, $script); + $this->assertLessThan($snapshotPosition, $catalogPosition, $script); + $this->assertLessThan($pricePosition, $catalogPosition, $script); + $this->assertLessThan($genericPosition, $catalogPosition, $script); + $this->assertSame(1, substr_count($contents, 'business-mirror:sync-product-catalog --quiet'), $script); + $this->assertStringContainsString('docker exec -u www-data caiwu_app', $contents, $script); + $this->assertStringContainsString('/run/caiwu-upstream-sync.lock', $contents, $script); + } + } +} diff --git a/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php b/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php index 762aa7de..b5b92801 100644 --- a/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php +++ b/tests/Unit/EcommerceFinance/ProductCatalogMirrorRepositoryTest.php @@ -3,6 +3,7 @@ namespace Tests\Unit\EcommerceFinance; use Illuminate\Database\Schema\Blueprint; +use Illuminate\Support\Carbon; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Schema; use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository; @@ -27,6 +28,7 @@ class ProductCatalogMirrorRepositoryTest extends TestCase 'prefix' => '', ], 'upstream_mirrors.connection' => 'business_mirror', + 'upstream_mirrors.product_catalog.stale_after_minutes' => 10, 'upstream_mirrors.datasets' => [ ProductCatalogMirrorRepository::DATASET_KEY => $this->datasetDefinition(), ], @@ -131,6 +133,38 @@ class ProductCatalogMirrorRepositoryTest extends TestCase $this->repository->paginate($this->filters(), 1, 25); } + public function test_it_reports_healthy_delayed_reconciling_and_failed_sync_states(): void + { + $at = Carbon::parse('2026-08-15 18:00:00'); + $base = [ + 'status' => 'succeeded', + 'run_mode' => 'incremental', + 'initial_completed_at' => '2026-08-14 21:15:36', + 'last_succeeded_at' => '2026-08-15 17:55:00', + ]; + + $this->assertSame( + ['code' => 'healthy', 'lag_minutes' => 5], + $this->repository->syncStatus($base, $at), + ); + $this->assertSame( + ['code' => 'delayed', 'lag_minutes' => 11], + $this->repository->syncStatus([...$base, 'last_succeeded_at' => '2026-08-15 17:49:00'], $at), + ); + $this->assertSame( + 'reconciling', + $this->repository->syncStatus([...$base, 'status' => 'running', 'run_mode' => 'full'], $at)['code'], + ); + $this->assertSame( + 'failed', + $this->repository->syncStatus([...$base, 'status' => 'failed'], $at)['code'], + ); + $this->assertSame( + 'initial_failed', + $this->repository->syncStatus(['status' => 'failed', 'initial_completed_at' => null], $at)['code'], + ); + } + public function test_schema_manager_adds_configured_lookup_indexes_idempotently(): void { Schema::connection('business_mirror')->drop('ops_jst_sku'); -- 2.47.2