numberSync = new SplitTicketNumberSyncService(); $this->ruleService = new SplitTicketRuleService(); $this->lockService = new SplitTicketSyncLockService(); } /** * 同步单条工单 * * @return array{success:bool,message:string,skipped?:bool} */ public function syncOne(int $ticketId, bool $force = false): array { $ticket = Ticket::get($ticketId); if (!$ticket) { return ['success' => false, 'message' => '工单不存在']; } if (!$force) { $skip = $this->shouldSkip($ticket); if ($skip !== null) { return ['success' => false, 'message' => $skip, 'skipped' => true]; } } if (!$this->lockService->acquire($ticketId)) { return ['success' => false, 'message' => '工单正在同步中', 'skipped' => true]; } try { return $this->doSync($ticket); } finally { $this->lockService->release($ticketId); } } /** * 扫描到期工单并同步 */ public function syncDueTickets(): int { $count = 0; $list = Ticket::where('status', 'normal') ->where('sync_fail_count', '<', self::FAIL_PAUSE_THRESHOLD) ->select(); foreach ($list as $ticket) { $skip = $this->shouldSkip($ticket); if ($skip !== null) { continue; } $result = $this->syncOne((int) $ticket['id'], false); if (!empty($result['skipped'])) { continue; } $count++; } return $count; } /** * @return array{success:bool,message:string} */ private function doSync(Ticket $ticket): array { $ticketType = (string) $ticket['ticket_type']; $pageUrl = trim((string) $ticket['ticket_url']); if ($pageUrl === '') { $this->markFailure($ticket, '工单链接为空'); return ['success' => false, 'message' => '工单链接为空']; } if (!SplitScrmSpiderFactory::isSupported($ticketType)) { $this->markFailure($ticket, '工单类型尚未实现蜘蛛'); return ['success' => false, 'message' => '工单类型尚未实现蜘蛛']; } $spider = SplitScrmSpiderFactory::create( $ticketType, $pageUrl, (string) ($ticket['account'] ?? ''), (string) ($ticket['password'] ?? '') ); if ($spider === null) { $this->markFailure($ticket, '无法创建蜘蛛实例'); return ['success' => false, 'message' => '无法创建蜘蛛实例']; } Db::startTrans(); try { $finalData = $spider->run(); if (!$finalData instanceof UnifiedScrmData) { throw new Exception('蜘蛛返回数据无效'); } $this->numberSync->syncFromUnifiedData($ticket, $finalData); $this->ruleService->applyNumberRules($ticket); $completeCount = max(0, $finalData->todayNewCount); $this->ruleService->applyTicketStatusRules($ticket, $completeCount); $freshTicket = Ticket::get((int) $ticket['id']); if ($freshTicket) { $this->ruleService->cascadeTicketClosedToNumbers($freshTicket); $ticket = $freshTicket; } $inboundCount = $this->numberSync->sumInboundForTicket($ticket); $speed = $this->calcSpeedPerHour($ticket, $completeCount); $payload = [ 'complete_count' => $completeCount, 'inbound_count' => $inboundCount, 'speed_per_hour' => $speed['speed'], 'number_count' => max(0, $finalData->total), 'number_offline_count' => max(0, $finalData->totalOffline), 'number_banned_count' => 0, 'online_count' => max(0, $finalData->totalOnline), 'sync_fail_count' => 0, 'speed_snapshot_count' => $speed['snapshot_count'], 'speed_snapshot_time' => $speed['snapshot_time'], ]; $this->applySyncResult($ticket, $payload, true, ''); Db::commit(); return ['success' => true, 'message' => '同步成功']; } catch (\Throwable $e) { Db::rollback(); $msg = mb_substr($e->getMessage(), 0, 255, 'UTF-8'); $this->markFailure($ticket, $msg); return ['success' => false, 'message' => $msg]; } } private function shouldSkip(Ticket $ticket): ?string { if ((string) $ticket['status'] === 'hidden') { return '工单已关闭'; } if ((int) ($ticket['sync_fail_count'] ?? 0) >= self::FAIL_PAUSE_THRESHOLD) { return '连续同步失败超过5次已暂停'; } if (!SplitScrmSpiderFactory::isSupported((string) $ticket['ticket_type'])) { return '工单类型尚未实现'; } $interval = SplitSyncConfigService::getIntervalMinutes((string) $ticket['ticket_type']); if ($interval <= 0) { return '该类型未配置自动同步周期'; } $lastSync = (int) ($ticket['sync_time'] ?? 0); if ($lastSync > 0 && (time() - $lastSync) < ($interval * 60)) { return '未到同步周期'; } return null; } /** * @param array $payload */ public function applySyncResult(Ticket $ticket, array $payload, bool $success, string $message = ''): void { $data = [ 'complete_count' => max(0, (int) ($payload['complete_count'] ?? 0)), 'inbound_count' => max(0, (int) ($payload['inbound_count'] ?? 0)), 'speed_per_hour' => max(0, (float) ($payload['speed_per_hour'] ?? 0)), 'number_count' => max(0, (int) ($payload['number_count'] ?? 0)), 'number_offline_count' => max(0, (int) ($payload['number_offline_count'] ?? 0)), 'number_banned_count' => max(0, (int) ($payload['number_banned_count'] ?? 0)), 'online_count' => max(0, (int) ($payload['online_count'] ?? 0)), 'sync_status' => $success ? 'success' : 'error', 'sync_time' => time(), 'sync_message' => $success ? '' : mb_substr($message, 0, 255, 'UTF-8'), 'sync_fail_count' => $success ? 0 : ((int) ($ticket['sync_fail_count'] ?? 0) + 1), 'speed_snapshot_count' => (int) ($payload['speed_snapshot_count'] ?? $ticket['speed_snapshot_count'] ?? 0), 'speed_snapshot_time' => (int) ($payload['speed_snapshot_time'] ?? $ticket['speed_snapshot_time'] ?? 0), ]; if (!$ticket->allowField(array_keys($data))->save($data)) { throw new Exception('工单同步结果保存失败'); } } private function markFailure(Ticket $ticket, string $message): void { $failCount = (int) ($ticket['sync_fail_count'] ?? 0) + 1; $ticket->save([ 'sync_status' => 'error', 'sync_time' => time(), 'sync_message' => mb_substr($message, 0, 255, 'UTF-8'), 'sync_fail_count' => $failCount, ]); } /** * @return array{speed:float,snapshot_count:int,snapshot_time:int} */ private function calcSpeedPerHour(Ticket $ticket, int $currentComplete): array { $now = time(); $snapshotTime = (int) ($ticket['speed_snapshot_time'] ?? 0); $snapshotCount = (int) ($ticket['speed_snapshot_count'] ?? 0); if ($snapshotTime <= 0) { return [ 'speed' => 0.0, 'snapshot_count' => $currentComplete, 'snapshot_time' => $now, ]; } $elapsed = $now - $snapshotTime; if ($elapsed >= 3600) { return [ 'speed' => 0.0, 'snapshot_count' => $currentComplete, 'snapshot_time' => $now, ]; } $hours = $elapsed > 0 ? ($elapsed / 3600) : 0; $delta = $currentComplete - $snapshotCount; $speed = ($delta < 0 || $hours <= 0) ? 0.0 : round($delta / $hours, 2); return [ 'speed' => $speed, 'snapshot_count' => $snapshotCount, 'snapshot_time' => $snapshotTime, ]; } }