mirror->ready()) { throw new RuntimeException('business_mirror_schema_missing'); } $state = $this->mirror->state(); if (! $full && ($state['initial_completed_at'] ?? null) === null) { return ['status' => 'requires_full_sync', 'mode' => 'incremental', 'batches' => 0, 'rows' => 0]; } $mode = $full ? 'full' : 'incremental'; $resume = is_array($state) && ($state['run_mode'] ?? null) === $mode && in_array($state['status'] ?? null, ['running', 'failed'], true) && ($state['window_started_at'] ?? null) !== null && ($state['window_ends_at'] ?? null) !== null; if ($resume) { $since = Carbon::parse((string) $state['window_started_at']); $until = Carbon::parse((string) $state['window_ends_at']); } elseif ($full) { $since = Carbon::create(1900, 1, 1, 0, 0, 0, config('app.timezone')); $until = now()->addSecond(); } else { $overlapHours = max((int) config('upstream_mirrors.product_prices.overlap_hours', 48), 1); $since = Carbon::parse((string) $state['last_succeeded_at'])->subHours($overlapHours); $until = now()->addSecond(); } $state = $this->mirror->startRun($mode, $since, $until, $resume); $cursor = $this->cursorFromState($state); $batchSize ??= (int) config('upstream_mirrors.product_prices.batch_size', 500); $batchSize = min(max($batchSize, 1), 2000); $maxBatches ??= (int) config('upstream_mirrors.product_prices.max_batches_per_run', 4); $maxBatches = max($maxBatches, 0); $batches = 0; $rows = (int) ($state['last_upserted_rows'] ?? 0); try { while (true) { $batch = $this->upstream->mirrorBatch( $cursor, $since->format('Y-m-d H:i:s'), $until->format('Y-m-d H:i:s'), $batchSize, ); if ($batch === []) { $this->mirror->complete($mode, $until, $rows); return ['status' => 'succeeded', 'mode' => $mode, 'batches' => $batches, 'rows' => $rows]; } $last = $batch[array_key_last($batch)]; $cursor = [ 'completed_at' => (string) ($last['cursor_completed_at'] ?? $last['completed_at']), 'business_id' => (string) $last['business_id'], 'barcode' => (string) $last['barcode'], ]; $rows += count($batch); $batches++; $this->mirror->storeBatch($batch, $cursor, $rows); if (count($batch) < $batchSize) { $this->mirror->complete($mode, $until, $rows); return ['status' => 'succeeded', 'mode' => $mode, 'batches' => $batches, 'rows' => $rows]; } if ($maxBatches > 0 && $batches >= $maxBatches) { return ['status' => 'running', 'mode' => $mode, 'batches' => $batches, 'rows' => $rows]; } } } catch (Throwable $exception) { $this->mirror->fail($exception::class); throw $exception; } } /** @return array{completed_at:string,business_id:string,barcode:string}|null */ private function cursorFromState(array $state): ?array { if (($state['cursor_completed_at'] ?? null) === null) { return null; } return [ // 直接复用 SQL Server 返回的 datetime2(7) ISO 字符串;Carbon 只有 // 6 位微秒精度,解析再格式化会丢掉最后一位并破坏稳定游标。 'completed_at' => (string) $state['cursor_completed_at'], 'business_id' => (string) ($state['cursor_business_id'] ?? ''), 'barcode' => (string) ($state['cursor_product_code'] ?? ''), ]; } }