2026-07-02 03:39:29 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace app\common\service;
|
|
|
|
|
|
|
2026-07-04 22:09:39 +08:00
|
|
|
|
use app\common\library\scrm\AntiBotConfigBuilder;
|
2026-07-03 20:51:56 +08:00
|
|
|
|
use think\Db;
|
|
|
|
|
|
|
2026-07-02 03:39:29 +08:00
|
|
|
|
/**
|
2026-07-04 22:09:39 +08:00
|
|
|
|
* 工单同步 CLI 后台投递
|
2026-07-02 03:39:29 +08:00
|
|
|
|
*
|
2026-07-04 22:09:39 +08:00
|
|
|
|
* Web / Cron 仅负责鉴权与投递,实际 Spider 在独立 CLI 进程中执行,
|
|
|
|
|
|
* 避免长时间占用 PHP-FPM、Cron 全局锁或 Session 锁。
|
2026-07-03 20:51:56 +08:00
|
|
|
|
*
|
2026-07-04 22:09:39 +08:00
|
|
|
|
* 并行策略:
|
|
|
|
|
|
* - API 优先类型(Whatshub 等):独立并行投递,不占 Node Real Browser 槽位
|
|
|
|
|
|
* - 直连 PHP 类型:独立并行投递
|
|
|
|
|
|
* - Node 类型:不同 sessionKey(域名)并行;同一 sessionKey 内串行以命中 Browser 温池
|
2026-07-02 03:39:29 +08:00
|
|
|
|
*/
|
|
|
|
|
|
class SplitTicketSyncDispatchService
|
|
|
|
|
|
{
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 投递手工同步任务到后台 CLI
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param int[] $ticketIds 已通过权限校验的工单 ID
|
|
|
|
|
|
* @return array{queued:int[],skipped:int[],failed:int[]}
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function dispatchManual(array $ticketIds): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$lockService = new SplitTicketSyncLockService();
|
2026-07-04 22:09:39 +08:00
|
|
|
|
$metaList = $this->loadTicketMetaList($ticketIds);
|
2026-07-03 20:51:56 +08:00
|
|
|
|
|
2026-07-04 22:09:39 +08:00
|
|
|
|
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $toDispatch */
|
|
|
|
|
|
$toDispatch = [];
|
|
|
|
|
|
$skipped = [];
|
|
|
|
|
|
$failed = [];
|
2026-07-02 03:39:29 +08:00
|
|
|
|
|
2026-07-04 22:09:39 +08:00
|
|
|
|
foreach ($metaList as $meta) {
|
|
|
|
|
|
$ticketId = (int) $meta['id'];
|
2026-07-07 14:56:39 +08:00
|
|
|
|
if ((int) ($meta['manual_manage'] ?? 0) === 1) {
|
|
|
|
|
|
$skipped[] = $ticketId;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-07-02 03:39:29 +08:00
|
|
|
|
if ($lockService->isLocked($ticketId)) {
|
|
|
|
|
|
$skipped[] = $ticketId;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
2026-07-04 22:09:39 +08:00
|
|
|
|
$toDispatch[] = $meta;
|
2026-07-03 20:51:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:09:39 +08:00
|
|
|
|
$dispatchResult = $this->dispatchTicketMetaList($toDispatch, 'manual');
|
2026-07-02 03:39:29 +08:00
|
|
|
|
|
2026-07-04 22:09:39 +08:00
|
|
|
|
SplitTicketSyncLogger::log('web', 'manual sync dispatched', [
|
|
|
|
|
|
'queued' => $dispatchResult['queued'],
|
|
|
|
|
|
'skipped' => $skipped,
|
|
|
|
|
|
'failed' => $dispatchResult['failed'],
|
|
|
|
|
|
'phpCli' => $this->resolvePhpBinary(),
|
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
'queued' => $dispatchResult['queued'],
|
|
|
|
|
|
'skipped' => $skipped,
|
|
|
|
|
|
'failed' => $dispatchResult['failed'],
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Cron 自动同步:将挑选后的到期工单投递到后台 CLI(Cron 进程立即返回)
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param list<array{id:int,ticket_type:string,ticket_url:string}|Ticket|\think\Model> $tickets
|
|
|
|
|
|
* @return array{
|
|
|
|
|
|
* queued:list<array<string,mixed>>,
|
|
|
|
|
|
* skipped:list<array<string,mixed>>,
|
|
|
|
|
|
* failed:list<array<string,mixed>>,
|
|
|
|
|
|
* stoppedByNodeBusy:bool
|
|
|
|
|
|
* }
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function dispatchAuto(array $tickets): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$lockService = new SplitTicketSyncLockService();
|
|
|
|
|
|
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $toDispatch */
|
|
|
|
|
|
$toDispatch = [];
|
|
|
|
|
|
/** @var list<array<string,mixed>> $skipped */
|
|
|
|
|
|
$skipped = [];
|
|
|
|
|
|
|
|
|
|
|
|
foreach ($tickets as $ticket) {
|
|
|
|
|
|
$meta = $this->normalizeTicketMeta($ticket);
|
|
|
|
|
|
if ($meta === null) {
|
|
|
|
|
|
continue;
|
2026-07-03 20:51:56 +08:00
|
|
|
|
}
|
2026-07-04 22:09:39 +08:00
|
|
|
|
$ticketId = (int) $meta['id'];
|
|
|
|
|
|
if ($lockService->isLocked($ticketId)) {
|
|
|
|
|
|
$skipped[] = $this->buildDispatchEntry(
|
|
|
|
|
|
$ticketId,
|
|
|
|
|
|
(string) $meta['ticket_type'],
|
|
|
|
|
|
(string) $meta['ticket_url'],
|
|
|
|
|
|
'工单正在同步中'
|
|
|
|
|
|
);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$toDispatch[] = $meta;
|
2026-07-03 20:51:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:09:39 +08:00
|
|
|
|
$dispatchResult = $this->dispatchTicketMetaList($toDispatch, 'auto');
|
|
|
|
|
|
|
|
|
|
|
|
SplitTicketSyncLogger::log('cron', 'auto sync dispatched', [
|
|
|
|
|
|
'queuedCount' => count($dispatchResult['queued']),
|
|
|
|
|
|
'skippedCount' => count($skipped) + count($dispatchResult['skipped']),
|
|
|
|
|
|
'failedCount' => count($dispatchResult['failed']),
|
|
|
|
|
|
'stoppedByNodeBusy' => $dispatchResult['stoppedByNodeBusy'],
|
|
|
|
|
|
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
|
2026-07-02 03:39:29 +08:00
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
2026-07-04 22:09:39 +08:00
|
|
|
|
'queued' => $dispatchResult['queued'],
|
|
|
|
|
|
'skipped' => array_merge($skipped, $dispatchResult['skipped']),
|
|
|
|
|
|
'failed' => $dispatchResult['failed'],
|
|
|
|
|
|
'stoppedByNodeBusy' => $dispatchResult['stoppedByNodeBusy'],
|
2026-07-02 03:39:29 +08:00
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 查询仍在同步中的工单 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:09:39 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* @param list<array{id:int,ticket_type:string,ticket_url:string}> $metaList
|
|
|
|
|
|
* @return array{
|
|
|
|
|
|
* queued:list<array<string,mixed>>,
|
|
|
|
|
|
* skipped:list<array<string,mixed>>,
|
|
|
|
|
|
* failed:list<array<string,mixed>>,
|
|
|
|
|
|
* stoppedByNodeBusy:bool
|
|
|
|
|
|
* }
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function dispatchTicketMetaList(array $metaList, string $source): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$php = $this->resolvePhpBinary();
|
|
|
|
|
|
$think = $this->resolveThinkScript();
|
|
|
|
|
|
$root = $this->resolveRootPath();
|
|
|
|
|
|
|
|
|
|
|
|
/** @var list<array<string,mixed>> $queued */
|
|
|
|
|
|
$queued = [];
|
|
|
|
|
|
/** @var list<array<string,mixed>> $skipped */
|
|
|
|
|
|
$skipped = [];
|
|
|
|
|
|
/** @var list<array<string,mixed>> $failed */
|
|
|
|
|
|
$failed = [];
|
|
|
|
|
|
$stoppedByNodeBusy = false;
|
|
|
|
|
|
|
|
|
|
|
|
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $apiFirstList */
|
|
|
|
|
|
$apiFirstList = [];
|
|
|
|
|
|
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $directList */
|
|
|
|
|
|
$directList = [];
|
|
|
|
|
|
/** @var array<string, list<array{id:int,ticket_type:string,ticket_url:string}>> $nodeGroups */
|
|
|
|
|
|
$nodeGroups = [];
|
|
|
|
|
|
|
|
|
|
|
|
foreach ($metaList as $meta) {
|
|
|
|
|
|
$ticketType = (string) $meta['ticket_type'];
|
|
|
|
|
|
if (SplitScrmSpiderFactory::isApiFirstSyncType($ticketType)) {
|
|
|
|
|
|
$apiFirstList[] = $meta;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!SplitScrmSpiderFactory::requiresNode($ticketType)) {
|
|
|
|
|
|
$directList[] = $meta;
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$sessionKey = AntiBotConfigBuilder::resolveSessionKey(
|
|
|
|
|
|
$ticketType,
|
|
|
|
|
|
(string) $meta['ticket_url']
|
|
|
|
|
|
);
|
|
|
|
|
|
$nodeGroups[$sessionKey][] = $meta;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
foreach ($apiFirstList as $meta) {
|
|
|
|
|
|
$this->spawnSingleMeta($php, $think, $root, $meta, $source, $queued, $failed);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
foreach ($directList as $meta) {
|
|
|
|
|
|
$this->spawnSingleMeta($php, $think, $root, $meta, $source, $queued, $failed);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
foreach ($nodeGroups as $sessionKey => $group) {
|
|
|
|
|
|
if ($group === []) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $nodeReady */
|
|
|
|
|
|
$nodeReady = [];
|
|
|
|
|
|
foreach ($group as $meta) {
|
|
|
|
|
|
$ticketType = (string) $meta['ticket_type'];
|
|
|
|
|
|
if (!SplitNodeHealthService::canAcceptWorkForTicketType($ticketType)) {
|
|
|
|
|
|
$stoppedByNodeBusy = true;
|
|
|
|
|
|
$skipped[] = $this->buildDispatchEntry(
|
|
|
|
|
|
(int) $meta['id'],
|
|
|
|
|
|
$ticketType,
|
|
|
|
|
|
(string) $meta['ticket_url'],
|
|
|
|
|
|
'Node 队列繁忙,本轮未投递'
|
|
|
|
|
|
);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$nodeReady[] = $meta;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ($nodeReady === []) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (count($nodeReady) === 1) {
|
|
|
|
|
|
$this->spawnSingleMeta($php, $think, $root, $nodeReady[0], $source, $queued, $failed);
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$ids = array_map(static function (array $row): int {
|
|
|
|
|
|
return (int) $row['id'];
|
|
|
|
|
|
}, $nodeReady);
|
|
|
|
|
|
|
|
|
|
|
|
if ($this->spawnCliSequential($php, $think, $root, $ids, $source)) {
|
|
|
|
|
|
foreach ($nodeReady as $meta) {
|
|
|
|
|
|
$queued[] = $this->buildQueuedEntry($meta, $sessionKey, 'node-group-serial');
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
foreach ($nodeReady as $meta) {
|
|
|
|
|
|
$failed[] = $this->buildDispatchEntry(
|
|
|
|
|
|
(int) $meta['id'],
|
|
|
|
|
|
(string) $meta['ticket_type'],
|
|
|
|
|
|
(string) $meta['ticket_url'],
|
|
|
|
|
|
'CLI 投递失败'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
'queued' => $queued,
|
|
|
|
|
|
'skipped' => $skipped,
|
|
|
|
|
|
'failed' => $failed,
|
|
|
|
|
|
'stoppedByNodeBusy' => $stoppedByNodeBusy,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param list<array<string,mixed>> $queued
|
|
|
|
|
|
* @param list<array<string,mixed>> $failed
|
|
|
|
|
|
* @param array{id:int,ticket_type:string,ticket_url:string} $meta
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function spawnSingleMeta(
|
|
|
|
|
|
string $php,
|
|
|
|
|
|
string $think,
|
|
|
|
|
|
string $root,
|
|
|
|
|
|
array $meta,
|
|
|
|
|
|
string $source,
|
|
|
|
|
|
array &$queued,
|
|
|
|
|
|
array &$failed
|
|
|
|
|
|
): void {
|
|
|
|
|
|
$ticketId = (int) $meta['id'];
|
|
|
|
|
|
if ($this->spawnCli($php, $think, $root, $ticketId, $source)) {
|
|
|
|
|
|
$queued[] = $this->buildQueuedEntry($meta, '', 'parallel');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$failed[] = $this->buildDispatchEntry(
|
|
|
|
|
|
$ticketId,
|
|
|
|
|
|
(string) $meta['ticket_type'],
|
|
|
|
|
|
(string) $meta['ticket_url'],
|
|
|
|
|
|
'CLI 投递失败'
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param array{id:int,ticket_type:string,ticket_url:string} $meta
|
|
|
|
|
|
* @return array<string,mixed>
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function buildQueuedEntry(array $meta, string $sessionKey, string $mode): array
|
|
|
|
|
|
{
|
|
|
|
|
|
return [
|
|
|
|
|
|
'ticketId' => (int) $meta['id'],
|
|
|
|
|
|
'ticketType' => (string) $meta['ticket_type'],
|
|
|
|
|
|
'ticketUrl' => (string) $meta['ticket_url'],
|
|
|
|
|
|
'sessionKey' => $sessionKey,
|
|
|
|
|
|
'mode' => $mode,
|
|
|
|
|
|
'dispatched' => true,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @return array{ticketId:int,ticketType:string,ticketUrl:string,reason:string}
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function buildDispatchEntry(int $ticketId, string $ticketType, string $ticketUrl, string $reason): array
|
|
|
|
|
|
{
|
|
|
|
|
|
return [
|
|
|
|
|
|
'ticketId' => $ticketId,
|
|
|
|
|
|
'ticketType' => $ticketType,
|
|
|
|
|
|
'ticketUrl' => $ticketUrl,
|
|
|
|
|
|
'reason' => $reason,
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 03:39:29 +08:00
|
|
|
|
/**
|
2026-07-03 20:51:56 +08:00
|
|
|
|
* @param int[] $ticketIds
|
2026-07-04 22:09:39 +08:00
|
|
|
|
* @return list<array{id:int,ticket_type:string,ticket_url:string}>
|
2026-07-03 20:51:56 +08:00
|
|
|
|
*/
|
2026-07-04 22:09:39 +08:00
|
|
|
|
private function loadTicketMetaList(array $ticketIds): array
|
2026-07-03 20:51:56 +08:00
|
|
|
|
{
|
|
|
|
|
|
$ids = [];
|
|
|
|
|
|
foreach ($ticketIds as $rawId) {
|
|
|
|
|
|
$ticketId = (int) $rawId;
|
|
|
|
|
|
if ($ticketId > 0) {
|
|
|
|
|
|
$ids[] = $ticketId;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($ids === []) {
|
|
|
|
|
|
return [];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$rows = Db::name('split_ticket')
|
|
|
|
|
|
->where('id', 'in', $ids)
|
2026-07-07 14:56:39 +08:00
|
|
|
|
->field('id,ticket_type,ticket_url,manual_manage')
|
2026-07-03 20:51:56 +08:00
|
|
|
|
->select();
|
|
|
|
|
|
|
2026-07-04 22:09:39 +08:00
|
|
|
|
$list = [];
|
2026-07-03 20:51:56 +08:00
|
|
|
|
foreach ($rows as $row) {
|
2026-07-04 22:09:39 +08:00
|
|
|
|
$list[] = [
|
2026-07-07 14:56:39 +08:00
|
|
|
|
'id' => (int) $row['id'],
|
|
|
|
|
|
'ticket_type' => (string) ($row['ticket_type'] ?? ''),
|
|
|
|
|
|
'ticket_url' => (string) ($row['ticket_url'] ?? ''),
|
|
|
|
|
|
'manual_manage' => (int) ($row['manual_manage'] ?? 0),
|
2026-07-04 22:09:39 +08:00
|
|
|
|
];
|
2026-07-03 20:51:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:09:39 +08:00
|
|
|
|
return $list;
|
2026-07-03 20:51:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-04 22:09:39 +08:00
|
|
|
|
* @param array<string,mixed>|object $ticket
|
|
|
|
|
|
* @return array{id:int,ticket_type:string,ticket_url:string}|null
|
2026-07-02 03:39:29 +08:00
|
|
|
|
*/
|
2026-07-04 22:09:39 +08:00
|
|
|
|
private function normalizeTicketMeta($ticket): ?array
|
|
|
|
|
|
{
|
|
|
|
|
|
if (is_object($ticket) && method_exists($ticket, 'toArray')) {
|
|
|
|
|
|
$ticket = $ticket->toArray();
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!is_array($ticket)) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
$ticketId = (int) ($ticket['id'] ?? 0);
|
|
|
|
|
|
if ($ticketId <= 0) {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return [
|
|
|
|
|
|
'id' => $ticketId,
|
|
|
|
|
|
'ticket_type' => (string) ($ticket['ticket_type'] ?? ''),
|
|
|
|
|
|
'ticket_url' => (string) ($ticket['ticket_url'] ?? ''),
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 后台启动 split:sync-tickets CLI(单工单,独立进程)
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function spawnCli(string $php, string $think, string $root, int $ticketId, string $source): bool
|
2026-07-02 03:39:29 +08:00
|
|
|
|
{
|
|
|
|
|
|
$logDir = $this->resolveLogDir();
|
|
|
|
|
|
$logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log';
|
|
|
|
|
|
|
2026-07-08 01:07:08 +08:00
|
|
|
|
$syncMode = $this->normalizeSyncModeForCli($source);
|
2026-07-02 03:39:29 +08:00
|
|
|
|
$inner = sprintf(
|
2026-07-08 01:07:08 +08:00
|
|
|
|
'%s %s split:sync-tickets --ticket=%d --mode=%s >> %s 2>&1',
|
2026-07-02 03:39:29 +08:00
|
|
|
|
escapeshellarg($php),
|
|
|
|
|
|
escapeshellarg($think),
|
|
|
|
|
|
$ticketId,
|
2026-07-08 01:07:08 +08:00
|
|
|
|
$syncMode,
|
2026-07-02 03:39:29 +08:00
|
|
|
|
escapeshellarg($logFile)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-07-04 22:09:39 +08:00
|
|
|
|
return $this->runBackgroundCommand($root, $inner, $ticketId, $source . '-parallel');
|
2026-07-03 20:51:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-04 22:09:39 +08:00
|
|
|
|
* 同一 sessionKey 内串行执行多个 CLI(单 nohup 子 shell)
|
2026-07-03 20:51:56 +08:00
|
|
|
|
*
|
|
|
|
|
|
* @param int[] $ticketIds
|
|
|
|
|
|
*/
|
2026-07-04 22:09:39 +08:00
|
|
|
|
private function spawnCliSequential(string $php, string $think, string $root, array $ticketIds, string $source): bool
|
2026-07-03 20:51:56 +08:00
|
|
|
|
{
|
|
|
|
|
|
if ($ticketIds === []) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$logDir = $this->resolveLogDir();
|
|
|
|
|
|
$parts = [];
|
|
|
|
|
|
foreach ($ticketIds as $ticketId) {
|
|
|
|
|
|
$ticketId = (int) $ticketId;
|
|
|
|
|
|
if ($ticketId <= 0) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
$logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log';
|
2026-07-08 01:07:08 +08:00
|
|
|
|
$syncMode = $this->normalizeSyncModeForCli($source);
|
2026-07-03 20:51:56 +08:00
|
|
|
|
$parts[] = sprintf(
|
2026-07-08 01:07:08 +08:00
|
|
|
|
'%s %s split:sync-tickets --ticket=%d --mode=%s >> %s 2>&1',
|
2026-07-03 20:51:56 +08:00
|
|
|
|
escapeshellarg($php),
|
|
|
|
|
|
escapeshellarg($think),
|
|
|
|
|
|
$ticketId,
|
2026-07-08 01:07:08 +08:00
|
|
|
|
$syncMode,
|
2026-07-03 20:51:56 +08:00
|
|
|
|
escapeshellarg($logFile)
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($parts === []) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$inner = implode('; ', $parts);
|
|
|
|
|
|
|
2026-07-04 22:09:39 +08:00
|
|
|
|
return $this->runBackgroundCommand($root, $inner, $ticketIds, $source . '-node-serial');
|
2026-07-03 20:51:56 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* @param int|int[] $ticketRef 日志用 ticketId 或 ID 列表
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function runBackgroundCommand(string $root, string $inner, $ticketRef, string $mode): bool
|
|
|
|
|
|
{
|
2026-07-02 03:39:29 +08:00
|
|
|
|
if ($this->isWindows()) {
|
|
|
|
|
|
$command = sprintf('start /B cmd /C %s', $inner);
|
|
|
|
|
|
} else {
|
2026-07-03 20:51:56 +08:00
|
|
|
|
$command = sprintf('cd %s && nohup bash -c %s &', escapeshellarg($root), escapeshellarg($inner));
|
2026-07-02 03:39:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ($this->canUseExec()) {
|
|
|
|
|
|
@exec($command, $output, $exitCode);
|
2026-07-04 22:09:39 +08:00
|
|
|
|
SplitTicketSyncLogger::log('dispatch', 'cli spawn exec', [
|
2026-07-03 20:51:56 +08:00
|
|
|
|
'ticketRef' => $ticketRef,
|
|
|
|
|
|
'mode' => $mode,
|
|
|
|
|
|
'command' => $command,
|
|
|
|
|
|
'exitCode' => $exitCode,
|
2026-07-02 03:39:29 +08:00
|
|
|
|
]);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ($this->canUseShellExec()) {
|
|
|
|
|
|
@shell_exec($command);
|
2026-07-04 22:09:39 +08:00
|
|
|
|
SplitTicketSyncLogger::log('dispatch', 'cli spawn shell_exec', [
|
2026-07-03 20:51:56 +08:00
|
|
|
|
'ticketRef' => $ticketRef,
|
|
|
|
|
|
'mode' => $mode,
|
|
|
|
|
|
'command' => $command,
|
2026-07-02 03:39:29 +08:00
|
|
|
|
]);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-04 22:09:39 +08:00
|
|
|
|
SplitTicketSyncLogger::log('dispatch', 'cli spawn failed: exec disabled', [
|
2026-07-03 20:51:56 +08:00
|
|
|
|
'ticketRef' => $ticketRef,
|
|
|
|
|
|
'mode' => $mode,
|
2026-07-02 03:39:29 +08:00
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
private function resolvePhpBinary(): string
|
|
|
|
|
|
{
|
2026-07-02 19:03:06 +08:00
|
|
|
|
if (method_exists(SplitSyncConfigService::class, 'getCliPhpBinary')) {
|
|
|
|
|
|
return SplitSyncConfigService::getCliPhpBinary();
|
2026-07-02 03:39:29 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 19:03:06 +08:00
|
|
|
|
return $this->resolvePhpBinaryFallback();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 当 SplitSyncConfigService 未部署 getCliPhpBinary 时的兜底解析
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function resolvePhpBinaryFallback(): string
|
|
|
|
|
|
{
|
|
|
|
|
|
if (defined('PHP_BINDIR') && PHP_BINDIR !== '') {
|
|
|
|
|
|
$candidate = rtrim(PHP_BINDIR, '/\\') . DIRECTORY_SEPARATOR . 'php';
|
|
|
|
|
|
if ($this->isUsableCliPhp($candidate)) {
|
|
|
|
|
|
return $candidate;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
foreach (['/usr/bin/php', '/usr/local/bin/php'] as $candidate) {
|
|
|
|
|
|
if ($this->isUsableCliPhp($candidate)) {
|
|
|
|
|
|
return $candidate;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-07-02 03:39:29 +08:00
|
|
|
|
return 'php';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 19:03:06 +08:00
|
|
|
|
private function isUsableCliPhp(string $path): bool
|
|
|
|
|
|
{
|
|
|
|
|
|
if (stripos($path, 'fpm') !== false) {
|
|
|
|
|
|
return false;
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($path === 'php') {
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
return is_file($path) && is_executable($path);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-02 03:39:29 +08:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
2026-07-08 01:07:08 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 投递 CLI 时携带 --mode,供 sync_success_mode 区分自动/手动
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function normalizeSyncModeForCli(string $source): string
|
|
|
|
|
|
{
|
|
|
|
|
|
return in_array($source, ['auto', 'manual'], true) ? $source : 'manual';
|
|
|
|
|
|
}
|
2026-07-02 03:39:29 +08:00
|
|
|
|
}
|