普通商品与聚水潭同步一致性 #64

Merged
limeng merged 1 commits from feature/limeng-product-catalog-sync into main 2026-08-15 19:10:52 +08:00
14 changed files with 595 additions and 18 deletions

View File

@ -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;

View File

@ -0,0 +1,119 @@
<?php
namespace App\Console\Commands;
use Illuminate\Console\Command;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Log;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Modules\EcommerceFinance\Services\BusinessMirrorSynchronizer;
use Throwable;
class SyncProductCatalogMirror extends Command
{
protected $signature = 'business-mirror:sync-product-catalog
{--full : 强制启动或继续普通商品全量校准}
{--batch= : 每批最多读取行数}
{--max-batches= : 本轮最多批次数,0 表示直到完成}';
protected $description = '高频同步聚水潭普通商品,并在每周低峰期执行可断点全量校准';
public function handle(
BusinessMirrorDatasetRegistry $datasets,
BusinessMirrorSynchronizer $synchronizer,
GenericBusinessMirrorRepository $mirror,
): int {
if (! (bool) config('upstream_mirrors.refresh_enabled', false) && ! $this->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<string,mixed>|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;
}
}

View File

@ -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,
];
}

View File

@ -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' => [

View File

@ -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

View File

@ -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

View File

@ -45,10 +45,11 @@
## 同步规则
1. 首次全量只能通过 `business-mirror:sync-tables <dataset> --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. 页面切换必须以“总记录数、第一页、第二页、末页、筛选、汇总、空数据和更新时间”作为验收,不能只看首页有数据。
## 后续切换顺序

View File

@ -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<string,mixed>|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<object>,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;
}
}
}

View File

@ -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
<style>
@ -31,7 +49,8 @@
min-height: 42px;
margin-bottom: 12px;
padding: 8px 12px;
border-left: 3px solid var(--vip-content-accent-dark, #856632);
border: 1px solid var(--vip-border, #d8d2c7);
border-radius: 8px;
background: var(--vip-content-accent-soft, #f7f0de);
font-size: 0.8125rem;
}
@ -62,7 +81,15 @@
}
.product-catalog-status__dot.is-warning {
background: var(--vip-warning, #a36b00);
background: var(--vip-warning, #935f07);
}
.product-catalog-status__dot.is-danger {
background: var(--vip-danger, #b80000);
}
.product-catalog-status__dot.is-info {
background: var(--vip-info, #006395);
}
.product-catalog-filters {
@ -332,15 +359,21 @@
</style>
<div class="product-catalog-page" data-product-catalog-page>
<div class="product-catalog-status" role="status">
<div class="product-catalog-status" role="status" data-sync-state="{{ $stateCode }}">
<div class="product-catalog-status__primary">
<span class="product-catalog-status__dot {{ $stateCode && $stateCode !== 'succeeded' ? 'is-warning' : '' }}" aria-hidden="true"></span>
<span>{{ $stateLabels[$stateCode] ?? ($productCatalogError ? '数据暂不可用' : '共享业务镜像') }}</span>
<span class="product-catalog-status__dot {{ $stateDotClass }}" aria-hidden="true"></span>
<span>{{ $stateLabels[$stateCode] ?? '共享业务镜像' }}</span>
</div>
<div class="product-catalog-status__meta">
@if (!empty($productCatalogState['last_succeeded_at']))
<span>最近同步 {{ $productCatalogState['last_succeeded_at'] }}</span>
@endif
@if ($lagLabel !== null)
<span>数据延迟 {{ $lagLabel }}</span>
@endif
@if (!empty($productCatalogState['last_full_succeeded_at']))
<span>最近校准 {{ $productCatalogState['last_full_succeeded_at'] }}</span>
@endif
@if (isset($productCatalogState['mirror_row_count']))
<span>镜像总行数 {{ number_format((int) $productCatalogState['mirror_row_count']) }}</span>
@endif

View File

@ -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;

View File

@ -0,0 +1,55 @@
<?php
namespace Tests\Feature\Console;
use Mockery;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Modules\EcommerceFinance\Services\BusinessMirrorSynchronizer;
use Tests\TestCase;
class BusinessMirrorScheduleTest extends TestCase
{
public function test_generic_rotation_excludes_datasets_with_a_dedicated_scheduler(): void
{
config([
'upstream_mirrors.refresh_enabled' => 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();
}
}

View File

@ -0,0 +1,152 @@
<?php
namespace Tests\Feature\Console;
use Illuminate\Support\Carbon;
use Mockery;
use Modules\EcommerceFinance\Contracts\BusinessMirrorDataset;
use Modules\EcommerceFinance\Repositories\GenericBusinessMirrorRepository;
use Modules\EcommerceFinance\Repositories\ProductCatalogMirrorRepository;
use Modules\EcommerceFinance\Services\BusinessMirrorDatasetRegistry;
use Modules\EcommerceFinance\Services\BusinessMirrorSynchronizer;
use RuntimeException;
use Tests\TestCase;
class ProductCatalogMirrorSyncCommandTest extends TestCase
{
protected function setUp(): void
{
parent::setUp();
config([
'upstream_mirrors.refresh_enabled' => 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<string,mixed> $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,
];
}
}

View File

@ -0,0 +1,30 @@
<?php
namespace Tests\Unit\Deployment;
use PHPUnit\Framework\TestCase;
class BusinessMirrorScheduleTest extends TestCase
{
public function test_release_scripts_run_the_product_catalog_before_generic_rotation(): void
{
foreach (['activate-release.sh', 'activate-incremental-release.sh'] as $script) {
$contents = file_get_contents(dirname(__DIR__, 3) . '/deploy/' . $script);
$catalogPosition = strpos($contents, 'business-mirror:sync-product-catalog --quiet');
$snapshotPosition = strpos($contents, 'upstream:snapshots:refresh-all --quiet');
$pricePosition = strpos($contents, 'business-mirror:sync-product-prices --quiet');
$genericPosition = strpos($contents, 'business-mirror:sync-tables --quiet');
$this->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);
}
}
}

View File

@ -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');