普通商品与聚水潭同步一致性 #64
@ -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;
|
||||
|
||||
119
app/Console/Commands/SyncProductCatalogMirror.php
Normal file
119
app/Console/Commands/SyncProductCatalogMirror.php
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@ -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' => [
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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. 页面切换必须以“总记录数、第一页、第二页、末页、筛选、汇总、空数据和更新时间”作为验收,不能只看首页有数据。
|
||||
|
||||
## 后续切换顺序
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
|
||||
55
tests/Feature/Console/BusinessMirrorScheduleTest.php
Normal file
55
tests/Feature/Console/BusinessMirrorScheduleTest.php
Normal 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();
|
||||
}
|
||||
}
|
||||
152
tests/Feature/Console/ProductCatalogMirrorSyncCommandTest.php
Normal file
152
tests/Feature/Console/ProductCatalogMirrorSyncCommandTest.php
Normal 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,
|
||||
];
|
||||
}
|
||||
}
|
||||
30
tests/Unit/Deployment/BusinessMirrorScheduleTest.php
Normal file
30
tests/Unit/Deployment/BusinessMirrorScheduleTest.php
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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');
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user