resolvePhpBinary(); $think = $this->resolveThinkScript(); $root = $this->resolveRootPath(); $lockService = new SplitTicketSyncLockService(); foreach ($ticketIds as $rawId) { $ticketId = (int) $rawId; if ($ticketId <= 0) { continue; } if ($lockService->isLocked($ticketId)) { $skipped[] = $ticketId; continue; } if ($this->spawnCli($php, $think, $root, $ticketId)) { $queued[] = $ticketId; } else { $failed[] = $ticketId; } } SplitTicketSyncLogger::log('web', 'manual sync dispatched', [ 'queued' => $queued, 'skipped' => $skipped, 'failed' => $failed, ]); return [ 'queued' => $queued, 'skipped' => $skipped, 'failed' => $failed, ]; } /** * 查询仍在同步中的工单 ID(依据 runtime 文件锁) * * @param int[] $ticketIds * @return int[] */ public function filterSyncingIds(array $ticketIds): array { $lockService = new SplitTicketSyncLockService(); $syncing = []; foreach ($ticketIds as $rawId) { $ticketId = (int) $rawId; if ($ticketId > 0 && $lockService->isLocked($ticketId)) { $syncing[] = $ticketId; } } return $syncing; } /** * 后台启动 split:sync-tickets CLI */ private function spawnCli(string $php, string $think, string $root, int $ticketId): bool { $logDir = $this->resolveLogDir(); $logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log'; $inner = sprintf( '%s %s split:sync-tickets --ticket=%d >> %s 2>&1', escapeshellarg($php), escapeshellarg($think), $ticketId, escapeshellarg($logFile) ); if ($this->isWindows()) { $command = sprintf('start /B cmd /C %s', $inner); } else { $command = sprintf('cd %s && nohup %s &', escapeshellarg($root), $inner); } if ($this->canUseExec()) { @exec($command, $output, $exitCode); SplitTicketSyncLogger::log('web', 'cli spawn exec', [ 'ticketId' => $ticketId, 'command' => $command, 'exitCode' => $exitCode, ]); return true; } if ($this->canUseShellExec()) { @shell_exec($command); SplitTicketSyncLogger::log('web', 'cli spawn shell_exec', [ 'ticketId' => $ticketId, 'command' => $command, ]); return true; } SplitTicketSyncLogger::log('web', 'cli spawn failed: exec disabled', [ 'ticketId' => $ticketId, ]); return false; } private function resolvePhpBinary(): string { if (defined('PHP_BINARY') && PHP_BINARY !== '') { return PHP_BINARY; } return 'php'; } private function resolveThinkScript(): string { $root = $this->resolveRootPath(); return rtrim($root, '/\\') . DIRECTORY_SEPARATOR . 'think'; } private function resolveRootPath(): string { if (defined('ROOT_PATH') && ROOT_PATH !== '') { return rtrim(ROOT_PATH, '/\\') . DIRECTORY_SEPARATOR; } return rtrim(dirname(__DIR__, 3), '/\\') . DIRECTORY_SEPARATOR; } private function resolveLogDir(): string { $runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : ($this->resolveRootPath() . 'runtime' . DIRECTORY_SEPARATOR); $dir = rtrim($runtime, '/\\') . DIRECTORY_SEPARATOR . 'split_ticket_sync' . DIRECTORY_SEPARATOR; if (!is_dir($dir)) { @mkdir($dir, 0755, true); } return $dir; } private function isWindows(): bool { return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN'; } /** * @return string[] */ private function disabledFunctions(): array { $raw = (string) ini_get('disable_functions'); if ($raw === '') { return []; } return array_filter(array_map('trim', explode(',', $raw))); } private function canUseExec(): bool { return function_exists('exec') && !in_array('exec', $this->disabledFunctions(), true); } private function canUseShellExec(): bool { return function_exists('shell_exec') && !in_array('shell_exec', $this->disabledFunctions(), true); } }