修复随机号码前先按权重随机工单,并修复降权号码筛选与清理
This commit is contained in:
@@ -0,0 +1,21 @@
|
|||||||
|
-- 号码管理:清理无效降权池权限节点
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.number/cleanupdeferred', '清理降权池', 'fa fa-circle-o', '', '清除无落地页访问基线的无效 ratio_deferred 标记', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.number' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/cleanupdeferred' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- 可选:一次性修复历史脏数据(与后台「清理降权池」逻辑一致)
|
||||||
|
-- UPDATE `fa_split_number`
|
||||||
|
-- SET `ratio_deferred` = 0, `ratio_deferred_at` = NULL,
|
||||||
|
-- `no_inbound_click_streak` = IF(`visit_count` <= 0, 0, `no_inbound_click_streak`)
|
||||||
|
-- WHERE `ratio_deferred` = 1
|
||||||
|
-- AND (`visit_count` <= 0 OR `visit_count` <= `last_sync_visit_count`);
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.number/cleanupdeferredpreview', '清理降权池预览', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.number' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/cleanupdeferredpreview' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
@@ -18,6 +18,7 @@ use think\console\Output;
|
|||||||
* 用法:
|
* 用法:
|
||||||
* php think split:sync-tickets
|
* php think split:sync-tickets
|
||||||
* php think split:sync-tickets --ticket=12
|
* php think split:sync-tickets --ticket=12
|
||||||
|
* php think split:sync-tickets --ticket=12 --mode=auto
|
||||||
*/
|
*/
|
||||||
class SplitSyncTickets extends Command
|
class SplitSyncTickets extends Command
|
||||||
{
|
{
|
||||||
@@ -25,21 +26,24 @@ class SplitSyncTickets extends Command
|
|||||||
{
|
{
|
||||||
$this->setName('split:sync-tickets')
|
$this->setName('split:sync-tickets')
|
||||||
->addOption('ticket', 't', Option::VALUE_OPTIONAL, '指定工单 ID(强制同步,忽略周期)', '')
|
->addOption('ticket', 't', Option::VALUE_OPTIONAL, '指定工单 ID(强制同步,忽略周期)', '')
|
||||||
|
->addOption('mode', 'm', Option::VALUE_OPTIONAL, '同步方式: auto=定时自动, manual=手动触发', 'manual')
|
||||||
->setDescription('同步分流工单云控数据');
|
->setDescription('同步分流工单云控数据');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function execute(Input $input, Output $output): void
|
protected function execute(Input $input, Output $output): void
|
||||||
{
|
{
|
||||||
set_time_limit(0);
|
set_time_limit(0);
|
||||||
|
$syncMode = $this->resolveSyncMode($input);
|
||||||
SplitTicketSyncLogger::log('cli', 'command start', [
|
SplitTicketSyncLogger::log('cli', 'command start', [
|
||||||
'ticketOption' => trim((string) $input->getOption('ticket')),
|
'ticketOption' => trim((string) $input->getOption('ticket')),
|
||||||
|
'syncMode' => $syncMode,
|
||||||
'appDebug' => SplitTicketSyncLogger::isEnabled(),
|
'appDebug' => SplitTicketSyncLogger::isEnabled(),
|
||||||
]);
|
]);
|
||||||
$service = new SplitTicketSyncService();
|
$service = new SplitTicketSyncService();
|
||||||
$ticketId = trim((string) $input->getOption('ticket'));
|
$ticketId = trim((string) $input->getOption('ticket'));
|
||||||
|
|
||||||
if ($ticketId !== '' && ctype_digit($ticketId)) {
|
if ($ticketId !== '' && ctype_digit($ticketId)) {
|
||||||
$result = $service->syncOne((int) $ticketId, true);
|
$result = $service->syncOne((int) $ticketId, true, false, $syncMode);
|
||||||
if (!empty($result['skipped'])) {
|
if (!empty($result['skipped'])) {
|
||||||
$output->writeln('<comment>跳过: ' . ($result['message'] ?? '') . '</comment>');
|
$output->writeln('<comment>跳过: ' . ($result['message'] ?? '') . '</comment>');
|
||||||
return;
|
return;
|
||||||
@@ -61,8 +65,8 @@ class SplitSyncTickets extends Command
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$count = $service->syncDueTickets();
|
$count = $service->syncDueTickets();
|
||||||
$output->writeln('<info>本次处理工单数: ' . $count . '</info>');
|
$output->writeln('<info>本次投递工单数: ' . $count . '</info>');
|
||||||
$output->writeln('<comment>汇总日志已写入 runtime/log/split_sync.log</comment>');
|
$output->writeln('<comment>同步在后台 CLI 进程中执行,汇总日志见 runtime/log/split_sync.log</comment>');
|
||||||
} finally {
|
} finally {
|
||||||
$cronLock->release();
|
$cronLock->release();
|
||||||
}
|
}
|
||||||
@@ -71,4 +75,14 @@ class SplitSyncTickets extends Command
|
|||||||
$output->writeln('<comment>详细调试日志已写入 runtime/log/split_sync.log</comment>');
|
$output->writeln('<comment>详细调试日志已写入 runtime/log/split_sync.log</comment>');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 CLI 同步方式,供 sync_success_mode 落库
|
||||||
|
*/
|
||||||
|
private function resolveSyncMode(Input $input): string
|
||||||
|
{
|
||||||
|
$mode = strtolower(trim((string) $input->getOption('mode')));
|
||||||
|
|
||||||
|
return in_array($mode, ['auto', 'manual'], true) ? $mode : 'manual';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class Number extends Backend
|
|||||||
$this->assignconfig('statusList', $this->model->getStatusList());
|
$this->assignconfig('statusList', $this->model->getStatusList());
|
||||||
$this->assignconfig('manualManageList', $this->model->getManualManageList());
|
$this->assignconfig('manualManageList', $this->model->getManualManageList());
|
||||||
$this->assignconfig('platformStatusList', $this->model->getPlatformStatusList());
|
$this->assignconfig('platformStatusList', $this->model->getPlatformStatusList());
|
||||||
|
$this->assignconfig('ratioDeferredList', $this->model->getRatioDeferredList());
|
||||||
$this->assignconfig('splitLinkFilterList', $this->buildNumberSplitLinkFilterList());
|
$this->assignconfig('splitLinkFilterList', $this->buildNumberSplitLinkFilterList());
|
||||||
$this->assignconfig('splitLinkSelectList', $this->buildSplitLinkSelectConfig());
|
$this->assignconfig('splitLinkSelectList', $this->buildSplitLinkSelectConfig());
|
||||||
|
|
||||||
@@ -525,6 +526,47 @@ class Number extends Backend
|
|||||||
$this->success(sprintf(__('Batch operate success'), $count));
|
$this->success(sprintf(__('Batch operate success'), $count));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览待清理的无效降权号码数量
|
||||||
|
*/
|
||||||
|
public function cleanupdeferredpreview(): void
|
||||||
|
{
|
||||||
|
if (false === $this->request->isAjax()) {
|
||||||
|
$this->error(__('Invalid parameters'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = new \app\common\service\SplitNumberRatioDeferredService();
|
||||||
|
$adminIds = $this->dataLimit ? $this->getDataLimitAdminIds() : null;
|
||||||
|
$count = $service->countInvalidDeferred(is_array($adminIds) ? $adminIds : null);
|
||||||
|
|
||||||
|
$this->success('', null, ['count' => $count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理无效降权号码池(无落地页访问基线却标记 ratio_deferred=1 的记录)
|
||||||
|
*/
|
||||||
|
public function cleanupdeferred(): void
|
||||||
|
{
|
||||||
|
if (false === $this->request->isPost()) {
|
||||||
|
$this->error(__('Invalid parameters'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = new \app\common\service\SplitNumberRatioDeferredService();
|
||||||
|
$adminIds = $this->dataLimit ? $this->getDataLimitAdminIds() : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$count = $service->cleanupInvalidDeferred(is_array($adminIds) ? $adminIds : null);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($count <= 0) {
|
||||||
|
$this->success(__('Ratio deferred cleanup none'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->success(sprintf(__('Ratio deferred cleanup success'), $count));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 排除不可由表单提交的字段
|
* 排除不可由表单提交的字段
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -51,4 +51,16 @@ return [
|
|||||||
'Invalid manual manage' => '手动管理选项无效',
|
'Invalid manual manage' => '手动管理选项无效',
|
||||||
'Batch update success' => '批量更新成功',
|
'Batch update success' => '批量更新成功',
|
||||||
'Unit count' => '个',
|
'Unit count' => '个',
|
||||||
|
'Ratio_deferred' => '选号降权',
|
||||||
|
'Ratio deferred badge' => '降权中',
|
||||||
|
'Ratio deferred no' => '正常选号',
|
||||||
|
'Ratio deferred yes' => '降权中',
|
||||||
|
'Ratio deferred filter' => '仅显示降权中号码',
|
||||||
|
'Ratio deferred tooltip'=> '连续 %s 次访问无进线增长,已触线下号比率,选号已后置,等待同步裁决',
|
||||||
|
'Ratio deferred edit notice' => '该号码处于下号比率触线降权状态,同步后将根据进线情况恢复或关闭',
|
||||||
|
'No inbound click streak' => '无进线连续点击',
|
||||||
|
'Ratio deferred cleanup btn' => '清理降权池',
|
||||||
|
'Ratio deferred cleanup confirm' => '将清除 %d 条无效降权记录(无落地页访问或访问未超过同步基线)。确定继续?',
|
||||||
|
'Ratio deferred cleanup success' => '已清理 %d 条无效降权记录',
|
||||||
|
'Ratio deferred cleanup none' => '当前没有需要清理的无效降权记录',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class Number extends Model
|
|||||||
'status_text',
|
'status_text',
|
||||||
'manual_manage_text',
|
'manual_manage_text',
|
||||||
'platform_status_text',
|
'platform_status_text',
|
||||||
|
'ratio_deferred_text',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,6 +82,17 @@ class Number extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getRatioDeferredList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'0' => __('Ratio deferred no'),
|
||||||
|
'1' => __('Ratio deferred yes'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
@@ -158,6 +170,13 @@ class Number extends Model
|
|||||||
return $list[$key] ?? $key;
|
return $list[$key] ?? $key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getRatioDeferredTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$key = (string) ((int) ($data['ratio_deferred'] ?? 0));
|
||||||
|
$list = $this->getRatioDeferredList();
|
||||||
|
return $list[$key] ?? $key;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据链接码生成完整分流 URL
|
* 根据链接码生成完整分流 URL
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -123,6 +123,13 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
{if isset($row)}
|
{if isset($row)}
|
||||||
<label for="c-number" class="control-label">{:__('Number')}<span class="text-danger">*</span></label>
|
<label for="c-number" class="control-label">{:__('Number')}<span class="text-danger">*</span></label>
|
||||||
|
{if isset($row.ratio_deferred) && $row.ratio_deferred==1 && isset($row.status) && $row.status=='normal'}
|
||||||
|
<div class="alert alert-warning split-ratio-deferred-notice" style="margin-bottom:10px;padding:8px 12px;font-size:12px;">
|
||||||
|
<i class="fa fa-level-down"></i>
|
||||||
|
{:__('Ratio deferred edit notice')}
|
||||||
|
({:__('No inbound click streak')}:{$row.no_inbound_click_streak|default=0})
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<input id="c-number" data-rule="required" class="form-control" name="row[number]" type="text" value="{$row.number|default=''|htmlentities}" maxlength="50">
|
<input id="c-number" data-rule="required" class="form-control" name="row[number]" type="text" value="{$row.number|default=''|htmlentities}" maxlength="50">
|
||||||
{else}
|
{else}
|
||||||
<label for="c-numbers" class="control-label">{:__('Numbers')}<span class="text-danger">*</span></label>
|
<label for="c-numbers" class="control-label">{:__('Numbers')}<span class="text-danger">*</span></label>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.number/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
|
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.number/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
|
||||||
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.number/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
|
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.number/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
|
||||||
<a href="javascript:;" class="btn btn-warning btn-batch-update-status btn-disabled disabled {:$auth->check('split.number/batchupdate')?'':'hide'}" title="{:__('Batch update btn')}"><i class="fa fa-edit"></i> {:__('Batch update btn')}</a>
|
<a href="javascript:;" class="btn btn-warning btn-batch-update-status btn-disabled disabled {:$auth->check('split.number/batchupdate')?'':'hide'}" title="{:__('Batch update btn')}"><i class="fa fa-edit"></i> {:__('Batch update btn')}</a>
|
||||||
|
<a href="javascript:;" class="btn btn-default btn-filter-ratio-deferred" title="{:__('Ratio deferred filter')}"><i class="fa fa-level-down text-warning"></i> {:__('Ratio deferred badge')}</a>
|
||||||
|
<a href="javascript:;" class="btn btn-warning btn-cleanup-ratio-deferred {:$auth->check('split.number/cleanupdeferred')?'':'hide'}" title="{:__('Ratio deferred cleanup btn')}"><i class="fa fa-eraser"></i> {:__('Ratio deferred cleanup btn')}</a>
|
||||||
<a href="javascript:;" class="btn btn-info btn-batch-operate {:$auth->check('split.number/batchoperate')?'':'hide'}" title="{:__('Batch operate btn')}"><i class="fa fa-list-alt"></i> {:__('Batch operate btn')}</a>
|
<a href="javascript:;" class="btn btn-info btn-batch-operate {:$auth->check('split.number/batchoperate')?'':'hide'}" title="{:__('Batch operate btn')}"><i class="fa fa-list-alt"></i> {:__('Batch operate btn')}</a>
|
||||||
</div>
|
</div>
|
||||||
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ class SplitNodeHealthService
|
|||||||
*/
|
*/
|
||||||
public static function canAcceptWork(): bool
|
public static function canAcceptWork(): bool
|
||||||
{
|
{
|
||||||
|
return self::canAcceptWorkForTicketType('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指定工单类型是否可向 Node 提交新任务
|
||||||
|
*
|
||||||
|
* API 优先类型(如 Whatshub)常态不占 Real Browser,调度层应跳过队列门禁。
|
||||||
|
*/
|
||||||
|
public static function canAcceptWorkForTicketType(string $ticketType): bool
|
||||||
|
{
|
||||||
|
if ($ticketType !== '' && SplitScrmSpiderFactory::isApiFirstSyncType($ticketType)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
$stats = self::fetchStats();
|
$stats = self::fetchStats();
|
||||||
if ($stats === null) {
|
if ($stats === null) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use app\admin\model\split\Number;
|
||||||
|
use think\Db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下号比率触线降权(ratio_deferred)规则与降权池清理
|
||||||
|
*
|
||||||
|
* 降权前提:已有落地页访问(visit_count > last_sync_visit_count),
|
||||||
|
* 且自上次同步以来进线未增长时累计 streak,触线才标记降权。
|
||||||
|
*/
|
||||||
|
class SplitNumberRatioDeferredService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 是否具备计入 streak 的落地页访问基数
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|Number $number
|
||||||
|
*/
|
||||||
|
public function hasLandingVisitBaseline($number): bool
|
||||||
|
{
|
||||||
|
$visitCount = $this->readInt($number, 'visit_count');
|
||||||
|
if ($visitCount <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastVisit = $this->readInt($number, 'last_sync_visit_count');
|
||||||
|
|
||||||
|
return $visitCount > $lastVisit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据访问与进线快照计算新的 streak
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|Number $number
|
||||||
|
*/
|
||||||
|
public function computeStreakForVisit($number, int $currentStreak): int
|
||||||
|
{
|
||||||
|
if (!$this->hasLandingVisitBaseline($number)) {
|
||||||
|
return $currentStreak;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inboundCount = $this->readInt($number, 'inbound_count');
|
||||||
|
$lastInbound = $this->readInt($number, 'last_sync_inbound_count');
|
||||||
|
|
||||||
|
if ($inboundCount <= $lastInbound) {
|
||||||
|
return $currentStreak + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步侧:根据 visit / inbound 与上次同步检查点重算 streak
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|Number $number
|
||||||
|
*/
|
||||||
|
public function computeStreakForSync($number, int $currentStreak): int
|
||||||
|
{
|
||||||
|
$visitCount = $this->readInt($number, 'visit_count');
|
||||||
|
if ($visitCount <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastVisit = $this->readInt($number, 'last_sync_visit_count');
|
||||||
|
$inboundCount = $this->readInt($number, 'inbound_count');
|
||||||
|
$lastInbound = $this->readInt($number, 'last_sync_inbound_count');
|
||||||
|
|
||||||
|
if ($inboundCount > $lastInbound) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($visitCount > $lastVisit) {
|
||||||
|
return $currentStreak + ($visitCount - $lastVisit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $currentStreak;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否应标记 ratio_deferred(访问侧触线降权,不关 status)
|
||||||
|
*/
|
||||||
|
public function shouldMarkDeferred(int $streak, int $assignRatio, bool $hasLandingBaseline): bool
|
||||||
|
{
|
||||||
|
return $hasLandingBaseline && $assignRatio > 0 && $streak >= $assignRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否为应清理的无效降权记录
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|Number $number
|
||||||
|
*/
|
||||||
|
public function isInvalidDeferredRecord($number): bool
|
||||||
|
{
|
||||||
|
if ($this->readInt($number, 'ratio_deferred') !== 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !$this->hasLandingVisitBaseline($number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理无效降权标记(ratio_deferred=1 但无有效落地页访问基线)
|
||||||
|
*
|
||||||
|
* @param int[]|null $adminIds 数据权限管理员 ID 列表,null 表示不限制
|
||||||
|
* @return int 清理条数
|
||||||
|
*/
|
||||||
|
public function cleanupInvalidDeferred(?array $adminIds = null): int
|
||||||
|
{
|
||||||
|
$query = Number::where('ratio_deferred', 1);
|
||||||
|
if ($adminIds !== null && $adminIds !== []) {
|
||||||
|
$query->where('admin_id', 'in', $adminIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $query->field('id,visit_count,last_sync_visit_count,ratio_deferred,no_inbound_click_streak')->select();
|
||||||
|
if ($rows === null || $rows === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$count = 0;
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (!$this->isInvalidDeferredRecord($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$update = [
|
||||||
|
'ratio_deferred' => 0,
|
||||||
|
'ratio_deferred_at' => null,
|
||||||
|
'updatetime' => $now,
|
||||||
|
];
|
||||||
|
if ($this->readInt($row, 'visit_count') <= 0) {
|
||||||
|
$update['no_inbound_click_streak'] = 0;
|
||||||
|
}
|
||||||
|
Number::where('id', (int) $row['id'])->update($update);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
Db::commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计当前权限范围内无效降权条数(清理前预览)
|
||||||
|
*
|
||||||
|
* @param int[]|null $adminIds
|
||||||
|
*/
|
||||||
|
public function countInvalidDeferred(?array $adminIds = null): int
|
||||||
|
{
|
||||||
|
$query = Number::where('ratio_deferred', 1);
|
||||||
|
if ($adminIds !== null && $adminIds !== []) {
|
||||||
|
$query->where('admin_id', 'in', $adminIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = 0;
|
||||||
|
foreach ($query->field('id,visit_count,last_sync_visit_count,ratio_deferred')->select() as $row) {
|
||||||
|
if ($this->isInvalidDeferredRecord($row)) {
|
||||||
|
$total++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|Number $number
|
||||||
|
*/
|
||||||
|
private function readInt($number, string $field): int
|
||||||
|
{
|
||||||
|
if (is_array($number)) {
|
||||||
|
return (int) ($number[$field] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $number->getAttr($field);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,13 @@ use app\admin\model\split\Ticket;
|
|||||||
*/
|
*/
|
||||||
class SplitNumberVisitRuleService
|
class SplitNumberVisitRuleService
|
||||||
{
|
{
|
||||||
|
private SplitNumberRatioDeferredService $ratioDeferredService;
|
||||||
|
|
||||||
|
public function __construct(?SplitNumberRatioDeferredService $ratioDeferredService = null)
|
||||||
|
{
|
||||||
|
$this->ratioDeferredService = $ratioDeferredService ?? new SplitNumberRatioDeferredService();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 访问计数已递增后调用:累加 streak,达到 assign_ratio 时标记 ratio_deferred=1
|
* 访问计数已递增后调用:累加 streak,达到 assign_ratio 时标记 ratio_deferred=1
|
||||||
*/
|
*/
|
||||||
@@ -39,15 +46,11 @@ class SplitNumberVisitRuleService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$assignRatio = (int) ($ticket['assign_ratio'] ?? 0);
|
$assignRatio = (int) ($ticket['assign_ratio'] ?? 0);
|
||||||
$inboundCount = (int) $number['inbound_count'];
|
|
||||||
$lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0);
|
|
||||||
$streak = (int) ($number['no_inbound_click_streak'] ?? 0);
|
$streak = (int) ($number['no_inbound_click_streak'] ?? 0);
|
||||||
|
$hasBaseline = $this->ratioDeferredService->hasLandingVisitBaseline($number);
|
||||||
|
|
||||||
// 自上次同步检查点以来进线未增长,则本次访问计入 streak
|
if ($hasBaseline) {
|
||||||
if ($inboundCount <= $lastInbound) {
|
$streak = $this->ratioDeferredService->computeStreakForVisit($number, $streak);
|
||||||
$streak++;
|
|
||||||
} else {
|
|
||||||
$streak = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$update = [
|
$update = [
|
||||||
@@ -55,8 +58,9 @@ class SplitNumberVisitRuleService
|
|||||||
'updatetime' => time(),
|
'updatetime' => time(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// 非手动号码且达到下号比率:触线降权,不关 status,等待同步确认
|
if ((int) $number['manual_manage'] === 0
|
||||||
if ((int) $number['manual_manage'] === 0 && $assignRatio > 0 && $streak >= $assignRatio) {
|
&& $this->ratioDeferredService->shouldMarkDeferred($streak, $assignRatio, $hasBaseline)
|
||||||
|
) {
|
||||||
$update['ratio_deferred'] = 1;
|
$update['ratio_deferred'] = 1;
|
||||||
$update['ratio_deferred_at'] = time();
|
$update['ratio_deferred_at'] = time();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use app\admin\model\split\Link;
|
|||||||
/**
|
/**
|
||||||
* 分流链接随机打乱配置与落地页选号
|
* 分流链接随机打乱配置与落地页选号
|
||||||
*
|
*
|
||||||
* - random_shuffle=0:跨工单合并号码池,按 id 顺序严格轮转
|
* - random_shuffle=0:跨工单合并号码池,按 id 顺序全局严格轮转
|
||||||
* - random_shuffle=1:跨工单合并号码池,每次访问完全随机选号
|
* - random_shuffle=1:先按各工单开启号码数比例随机选工单,再在该工单内随机选号
|
||||||
* - 同步写入时 random_shuffle=1 还会打乱新号码 insert 顺序(影响 id 分布)
|
* - 同步写入时 random_shuffle=1 还会打乱新号码 insert 顺序(影响 id 分布)
|
||||||
*/
|
*/
|
||||||
class SplitNumberWeighService
|
class SplitNumberWeighService
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ namespace app\common\service;
|
|||||||
|
|
||||||
use app\admin\model\split\Link;
|
use app\admin\model\split\Link;
|
||||||
use app\admin\model\split\Number;
|
use app\admin\model\split\Number;
|
||||||
|
use app\admin\model\split\Ticket;
|
||||||
use think\Collection;
|
use think\Collection;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分流链接落地页:查链、跨工单合并选号、拼接跳转 URL、访问计数
|
* 分流链接落地页:查链、选号、拼接跳转 URL、访问计数
|
||||||
*
|
*
|
||||||
* 号码池为同一 split_link_id 下全部 status=normal 的号码;
|
* 号码池:同一 split_link_id 下 status=normal,且所属工单未关闭(ticket.status≠hidden)的号码;
|
||||||
* 优先选用 ratio_deferred=0 的号码,全部触线降权时回退至降权池仍可轮转。
|
* 优先选用 ratio_deferred=0,全部触线降权时回退至降权池。
|
||||||
* random_shuffle 关闭时严格轮转,开启时每次访问随机选号。
|
*
|
||||||
|
* random_shuffle=0:跨工单合并池,全局严格轮转;
|
||||||
|
* random_shuffle=1:先按各工单开启号码数量比例随机选工单,再在该工单内随机选号。
|
||||||
*/
|
*/
|
||||||
class SplitRedirectService
|
class SplitRedirectService
|
||||||
{
|
{
|
||||||
@@ -55,20 +58,17 @@ class SplitRedirectService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var Collection<int, Number>|array<int, Number> $numbers */
|
$linkId = (int) $link->getAttr('id');
|
||||||
$numbers = Number::where('split_link_id', (int) $link['id'])
|
$list = $this->loadEligibleNumbers($linkId);
|
||||||
->where('status', 'normal')
|
|
||||||
->order('id', 'asc')
|
|
||||||
->field('id,number,number_type,number_type_custom,ratio_deferred')
|
|
||||||
->select();
|
|
||||||
|
|
||||||
$list = $numbers instanceof Collection ? $numbers->all() : (array) $numbers;
|
|
||||||
if ($list === []) {
|
if ($list === []) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$linkId = (int) $link->getAttr('id');
|
if (SplitNumberWeighService::isRandomShuffleEnabled($linkId)) {
|
||||||
|
$picked = $this->pickNumberRowRandomByTicketWeight($linkId, $list);
|
||||||
|
} else {
|
||||||
$picked = $this->pickNumberRow($linkId, $list);
|
$picked = $this->pickNumberRow($linkId, $list);
|
||||||
|
}
|
||||||
if ($picked === null) {
|
if ($picked === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -103,10 +103,117 @@ class SplitRedirectService
|
|||||||
return $redirectUrl;
|
return $redirectUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可参与选号的号码:status=normal,且 ticket_name 不属于已关闭工单
|
||||||
|
*
|
||||||
|
* @return list<Number|array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function loadEligibleNumbers(int $linkId): array
|
||||||
|
{
|
||||||
|
if ($linkId <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<string> $hiddenTicketNames */
|
||||||
|
$hiddenTicketNames = Ticket::where('split_link_id', $linkId)
|
||||||
|
->where('status', 'hidden')
|
||||||
|
->column('ticket_name');
|
||||||
|
$hiddenTicketNames = array_values(array_unique(array_filter(array_map(
|
||||||
|
static function ($name): string {
|
||||||
|
return trim((string) $name);
|
||||||
|
},
|
||||||
|
$hiddenTicketNames
|
||||||
|
))));
|
||||||
|
|
||||||
|
$query = Number::where('split_link_id', $linkId)
|
||||||
|
->where('status', 'normal')
|
||||||
|
->order('id', 'asc')
|
||||||
|
->field('id,number,number_type,number_type_custom,ratio_deferred,ticket_name');
|
||||||
|
|
||||||
|
if ($hiddenTicketNames !== []) {
|
||||||
|
$query->where('ticket_name', 'not in', $hiddenTicketNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
$numbers = $query->select();
|
||||||
|
return $numbers instanceof Collection ? $numbers->all() : (array) $numbers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* random_shuffle=1:先按工单开启号码数加权随机选工单,再在该工单内选号
|
||||||
|
*
|
||||||
|
* @param list<Number|array<string, mixed>> $rows
|
||||||
|
* @return array<string, mixed>|Number|null
|
||||||
|
*/
|
||||||
|
private function pickNumberRowRandomByTicketWeight(int $linkId, array $rows)
|
||||||
|
{
|
||||||
|
$groups = $this->groupNumbersByTicket($rows);
|
||||||
|
if ($groups === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketKey = $this->pickTicketGroupKeyWeighted($groups);
|
||||||
|
if ($ticketKey === null || !isset($groups[$ticketKey])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->pickNumberRow($linkId, $groups[$ticketKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Number|array<string, mixed>> $rows
|
||||||
|
* @return array<string, list<Number|array<string, mixed>>>
|
||||||
|
*/
|
||||||
|
private function groupNumbersByTicket(array $rows): array
|
||||||
|
{
|
||||||
|
/** @var array<string, list<Number|array<string, mixed>>> $groups */
|
||||||
|
$groups = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$name = is_array($row)
|
||||||
|
? trim((string) ($row['ticket_name'] ?? ''))
|
||||||
|
: trim((string) $row->getAttr('ticket_name'));
|
||||||
|
$key = $name !== '' ? $name : '__manual__';
|
||||||
|
$groups[$key][] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按各分组号码数量加权随机选一个工单(分组 key)
|
||||||
|
*
|
||||||
|
* @param array<string, list<Number|array<string, mixed>>> $groups
|
||||||
|
*/
|
||||||
|
private function pickTicketGroupKeyWeighted(array $groups): ?string
|
||||||
|
{
|
||||||
|
$total = 0;
|
||||||
|
foreach ($groups as $rows) {
|
||||||
|
$total += count($rows);
|
||||||
|
}
|
||||||
|
if ($total <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keys = array_keys($groups);
|
||||||
|
if (count($keys) === 1) {
|
||||||
|
return $keys[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$r = random_int(1, $total);
|
||||||
|
$acc = 0;
|
||||||
|
foreach ($groups as $key => $rows) {
|
||||||
|
$acc += count($rows);
|
||||||
|
if ($r <= $acc) {
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $keys[0];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 双层选号:优先 ratio_deferred=0,否则回退至降权池
|
* 双层选号:优先 ratio_deferred=0,否则回退至降权池
|
||||||
*
|
*
|
||||||
* @param array<int, array<string, mixed>|Number> $rows
|
* @param list<Number|array<string, mixed>> $rows
|
||||||
* @return array<string, mixed>|Number|null
|
* @return array<string, mixed>|Number|null
|
||||||
*/
|
*/
|
||||||
private function pickNumberRow(int $linkId, array $rows)
|
private function pickNumberRow(int $linkId, array $rows)
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ class SplitScrmSpiderFactory
|
|||||||
// ceo_scrm 等未实现类型:新增 spider 类后在此注册
|
// ceo_scrm 等未实现类型:新增 spider 类后在此注册
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 优先同步类型:常态走 PHP cURL,不占 Node Real Browser 槽位
|
||||||
|
*
|
||||||
|
* 调度层据此跳过 Node 队列门禁并允许并行投递;蜘蛛实现本身不在此修改。
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const API_FIRST_SYNC_TYPES = [
|
||||||
|
'whatshub',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return class-string<ScrmSpiderInterface>|null
|
* @return class-string<ScrmSpiderInterface>|null
|
||||||
*/
|
*/
|
||||||
@@ -60,6 +71,22 @@ class SplitScrmSpiderFactory
|
|||||||
return array_keys(self::MAP);
|
return array_keys(self::MAP);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否 API 优先类型(调度层可并行投递且不占 Node 队列)
|
||||||
|
*/
|
||||||
|
public static function isApiFirstSyncType(string $ticketType): bool
|
||||||
|
{
|
||||||
|
return in_array(trim($ticketType), self::API_FIRST_SYNC_TYPES, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function listApiFirstSyncTypes(): array
|
||||||
|
{
|
||||||
|
return self::API_FIRST_SYNC_TYPES;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步是否依赖 Node Headless(纯 PHP cURL 等为 false)
|
* 同步是否依赖 Node Headless(纯 PHP cURL 等为 false)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class SplitSyncConfigService
|
|||||||
if ($value === '') {
|
if ($value === '') {
|
||||||
return 5;
|
return 5;
|
||||||
}
|
}
|
||||||
return max(1, min(20, (int) $value));
|
return max(1, min(50, (int) $value));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ use app\admin\model\split\Ticket;
|
|||||||
*/
|
*/
|
||||||
class SplitTicketRuleService
|
class SplitTicketRuleService
|
||||||
{
|
{
|
||||||
|
private SplitNumberRatioDeferredService $ratioDeferredService;
|
||||||
|
|
||||||
|
public function __construct(?SplitNumberRatioDeferredService $ratioDeferredService = null)
|
||||||
|
{
|
||||||
|
$this->ratioDeferredService = $ratioDeferredService ?? new SplitNumberRatioDeferredService();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步后应用工单开关规则;号码开关由 applyNumberRules 综合裁决(平台在线 + 单号上限 + 下号比率)
|
* 同步后应用工单开关规则;号码开关由 applyNumberRules 综合裁决(平台在线 + 单号上限 + 下号比率)
|
||||||
*/
|
*/
|
||||||
@@ -108,14 +115,13 @@ class SplitTicketRuleService
|
|||||||
foreach ($numbers as $number) {
|
foreach ($numbers as $number) {
|
||||||
$visitCount = (int) $number['visit_count'];
|
$visitCount = (int) $number['visit_count'];
|
||||||
$inboundCount = (int) $number['inbound_count'];
|
$inboundCount = (int) $number['inbound_count'];
|
||||||
$lastVisit = (int) ($number['last_sync_visit_count'] ?? 0);
|
|
||||||
$lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0);
|
$lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0);
|
||||||
$streak = (int) ($number['no_inbound_click_streak'] ?? 0);
|
$streak = (int) ($number['no_inbound_click_streak'] ?? 0);
|
||||||
|
|
||||||
if ($visitCount > $lastVisit && $inboundCount <= $lastInbound) {
|
if ($visitCount <= 0) {
|
||||||
$streak += ($visitCount - $lastVisit);
|
|
||||||
} elseif ($inboundCount > $lastInbound) {
|
|
||||||
$streak = 0;
|
$streak = 0;
|
||||||
|
} else {
|
||||||
|
$streak = $this->ratioDeferredService->computeStreakForSync($number, $streak);
|
||||||
}
|
}
|
||||||
|
|
||||||
$update = [
|
$update = [
|
||||||
@@ -125,20 +131,25 @@ class SplitTicketRuleService
|
|||||||
'updatetime' => time(),
|
'updatetime' => time(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// 手动管理(含用户手动关闭)的号码:仅更新统计字段,不改状态与降权标记
|
$clearDeferred = [
|
||||||
|
'ratio_deferred' => 0,
|
||||||
|
'ratio_deferred_at' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 手动管理:更新统计;无效降权(无落地页访问)一并清除
|
||||||
if ((int) $number['manual_manage'] === 1) {
|
if ((int) $number['manual_manage'] === 1) {
|
||||||
|
if ($this->ratioDeferredService->isInvalidDeferredRecord($number) || $visitCount <= 0) {
|
||||||
|
$update = array_merge($update, $clearDeferred);
|
||||||
|
if ($visitCount <= 0) {
|
||||||
|
$update['no_inbound_click_streak'] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
Number::where('id', (int) $number['id'])->update($update);
|
Number::where('id', (int) $number['id'])->update($update);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同步裁决 ratio_deferred:进线增长则解除降权;触线且无进线增长则关号并清除降权
|
// 同步裁决 ratio_deferred:同步时清除访问侧降权,由 streak 决定是否关号
|
||||||
if ($inboundCount > $lastInbound) {
|
$update = array_merge($update, $clearDeferred);
|
||||||
$update['ratio_deferred'] = 0;
|
|
||||||
$update['ratio_deferred_at'] = null;
|
|
||||||
} else {
|
|
||||||
$update['ratio_deferred'] = 0;
|
|
||||||
$update['ratio_deferred_at'] = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$update['status'] = $this->resolveAutomatedStatus(
|
$update['status'] = $this->resolveAutomatedStatus(
|
||||||
$ticket,
|
$ticket,
|
||||||
|
|||||||
@@ -4,15 +4,19 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace app\common\service;
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use app\common\library\scrm\AntiBotConfigBuilder;
|
||||||
use think\Db;
|
use think\Db;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工单手工同步 CLI 后台投递
|
* 工单同步 CLI 后台投递
|
||||||
*
|
*
|
||||||
* Web 请求仅负责鉴权与投递,实际 Spider 在独立 CLI 进程中执行,
|
* Web / Cron 仅负责鉴权与投递,实际 Spider 在独立 CLI 进程中执行,
|
||||||
* 避免长时间占用 PHP-FPM 与 Session 锁导致后台其它页面无法打开。
|
* 避免长时间占用 PHP-FPM、Cron 全局锁或 Session 锁。
|
||||||
*
|
*
|
||||||
* Node 类型(A2C / Whatshub 等)在同一 nohup 子 shell 内串行执行,避免并行 Chrome 压垮温池。
|
* 并行策略:
|
||||||
|
* - API 优先类型(Whatshub 等):独立并行投递,不占 Node Real Browser 槽位
|
||||||
|
* - 直连 PHP 类型:独立并行投递
|
||||||
|
* - Node 类型:不同 sessionKey(域名)并行;同一 sessionKey 内串行以命中 Browser 温池
|
||||||
*/
|
*/
|
||||||
class SplitTicketSyncDispatchService
|
class SplitTicketSyncDispatchService
|
||||||
{
|
{
|
||||||
@@ -24,75 +28,91 @@ class SplitTicketSyncDispatchService
|
|||||||
*/
|
*/
|
||||||
public function dispatchManual(array $ticketIds): array
|
public function dispatchManual(array $ticketIds): array
|
||||||
{
|
{
|
||||||
$queued = [];
|
$lockService = new SplitTicketSyncLockService();
|
||||||
|
$metaList = $this->loadTicketMetaList($ticketIds);
|
||||||
|
|
||||||
|
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $toDispatch */
|
||||||
|
$toDispatch = [];
|
||||||
$skipped = [];
|
$skipped = [];
|
||||||
$failed = [];
|
$failed = [];
|
||||||
|
|
||||||
$php = $this->resolvePhpBinary();
|
foreach ($metaList as $meta) {
|
||||||
$think = $this->resolveThinkScript();
|
$ticketId = (int) $meta['id'];
|
||||||
$root = $this->resolveRootPath();
|
|
||||||
$lockService = new SplitTicketSyncLockService();
|
|
||||||
$nodeTypeMap = array_flip(SplitScrmSpiderFactory::listNodeSyncTypes());
|
|
||||||
$typeMap = $this->loadTicketTypeMap($ticketIds);
|
|
||||||
|
|
||||||
/** @var int[] $nodeTicketIds */
|
|
||||||
$nodeTicketIds = [];
|
|
||||||
/** @var int[] $directTicketIds */
|
|
||||||
$directTicketIds = [];
|
|
||||||
|
|
||||||
foreach ($ticketIds as $rawId) {
|
|
||||||
$ticketId = (int) $rawId;
|
|
||||||
if ($ticketId <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($lockService->isLocked($ticketId)) {
|
if ($lockService->isLocked($ticketId)) {
|
||||||
$skipped[] = $ticketId;
|
$skipped[] = $ticketId;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
$toDispatch[] = $meta;
|
||||||
$ticketType = (string) ($typeMap[$ticketId] ?? '');
|
|
||||||
if ($ticketType !== '' && isset($nodeTypeMap[$ticketType])) {
|
|
||||||
$nodeTicketIds[] = $ticketId;
|
|
||||||
} else {
|
|
||||||
$directTicketIds[] = $ticketId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($directTicketIds as $ticketId) {
|
$dispatchResult = $this->dispatchTicketMetaList($toDispatch, 'manual');
|
||||||
if ($this->spawnCli($php, $think, $root, $ticketId)) {
|
|
||||||
$queued[] = $ticketId;
|
|
||||||
} else {
|
|
||||||
$failed[] = $ticketId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($nodeTicketIds !== []) {
|
|
||||||
$spawned = $this->spawnCliSequential($php, $think, $root, $nodeTicketIds);
|
|
||||||
if ($spawned) {
|
|
||||||
foreach ($nodeTicketIds as $ticketId) {
|
|
||||||
$queued[] = $ticketId;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
foreach ($nodeTicketIds as $ticketId) {
|
|
||||||
$failed[] = $ticketId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SplitTicketSyncLogger::log('web', 'manual sync dispatched', [
|
SplitTicketSyncLogger::log('web', 'manual sync dispatched', [
|
||||||
'queued' => $queued,
|
'queued' => $dispatchResult['queued'],
|
||||||
'skipped' => $skipped,
|
'skipped' => $skipped,
|
||||||
'failed' => $failed,
|
'failed' => $dispatchResult['failed'],
|
||||||
'nodeSerial' => $nodeTicketIds,
|
'phpCli' => $this->resolvePhpBinary(),
|
||||||
'directParallel' => $directTicketIds,
|
|
||||||
'phpCli' => $php,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'queued' => $queued,
|
'queued' => $dispatchResult['queued'],
|
||||||
'skipped' => $skipped,
|
'skipped' => $skipped,
|
||||||
'failed' => $failed,
|
'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;
|
||||||
|
}
|
||||||
|
$ticketId = (int) $meta['id'];
|
||||||
|
if ($lockService->isLocked($ticketId)) {
|
||||||
|
$skipped[] = $this->buildDispatchEntry(
|
||||||
|
$ticketId,
|
||||||
|
(string) $meta['ticket_type'],
|
||||||
|
(string) $meta['ticket_url'],
|
||||||
|
'工单正在同步中'
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$toDispatch[] = $meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'queued' => $dispatchResult['queued'],
|
||||||
|
'skipped' => array_merge($skipped, $dispatchResult['skipped']),
|
||||||
|
'failed' => $dispatchResult['failed'],
|
||||||
|
'stoppedByNodeBusy' => $dispatchResult['stoppedByNodeBusy'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,10 +137,181 @@ class SplitTicketSyncDispatchService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param int[] $ticketIds
|
* @param list<array{id:int,ticket_type:string,ticket_url:string}> $metaList
|
||||||
* @return array<int, string> ticketId => ticket_type
|
* @return array{
|
||||||
|
* queued:list<array<string,mixed>>,
|
||||||
|
* skipped:list<array<string,mixed>>,
|
||||||
|
* failed:list<array<string,mixed>>,
|
||||||
|
* stoppedByNodeBusy:bool
|
||||||
|
* }
|
||||||
*/
|
*/
|
||||||
private function loadTicketTypeMap(array $ticketIds): array
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[] $ticketIds
|
||||||
|
* @return list<array{id:int,ticket_type:string,ticket_url:string}>
|
||||||
|
*/
|
||||||
|
private function loadTicketMetaList(array $ticketIds): array
|
||||||
{
|
{
|
||||||
$ids = [];
|
$ids = [];
|
||||||
foreach ($ticketIds as $rawId) {
|
foreach ($ticketIds as $rawId) {
|
||||||
@@ -135,42 +326,72 @@ class SplitTicketSyncDispatchService
|
|||||||
|
|
||||||
$rows = Db::name('split_ticket')
|
$rows = Db::name('split_ticket')
|
||||||
->where('id', 'in', $ids)
|
->where('id', 'in', $ids)
|
||||||
->field('id,ticket_type')
|
->field('id,ticket_type,ticket_url')
|
||||||
->select();
|
->select();
|
||||||
|
|
||||||
$map = [];
|
$list = [];
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$map[(int) $row['id']] = (string) ($row['ticket_type'] ?? '');
|
$list[] = [
|
||||||
|
'id' => (int) $row['id'],
|
||||||
|
'ticket_type' => (string) ($row['ticket_type'] ?? ''),
|
||||||
|
'ticket_url' => (string) ($row['ticket_url'] ?? ''),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $map;
|
return $list;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后台启动 split:sync-tickets CLI(单工单,并行投递)
|
* @param array<string,mixed>|object $ticket
|
||||||
|
* @return array{id:int,ticket_type:string,ticket_url:string}|null
|
||||||
*/
|
*/
|
||||||
private function spawnCli(string $php, string $think, string $root, int $ticketId): bool
|
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
|
||||||
{
|
{
|
||||||
$logDir = $this->resolveLogDir();
|
$logDir = $this->resolveLogDir();
|
||||||
$logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log';
|
$logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log';
|
||||||
|
|
||||||
|
$syncMode = $this->normalizeSyncModeForCli($source);
|
||||||
$inner = sprintf(
|
$inner = sprintf(
|
||||||
'%s %s split:sync-tickets --ticket=%d >> %s 2>&1',
|
'%s %s split:sync-tickets --ticket=%d --mode=%s >> %s 2>&1',
|
||||||
escapeshellarg($php),
|
escapeshellarg($php),
|
||||||
escapeshellarg($think),
|
escapeshellarg($think),
|
||||||
$ticketId,
|
$ticketId,
|
||||||
|
$syncMode,
|
||||||
escapeshellarg($logFile)
|
escapeshellarg($logFile)
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->runBackgroundCommand($root, $inner, $ticketId, 'parallel');
|
return $this->runBackgroundCommand($root, $inner, $ticketId, $source . '-parallel');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Node 类型工单:单条 nohup 内串行执行多个 CLI,避免并行 Chrome
|
* 同一 sessionKey 内串行执行多个 CLI(单 nohup 子 shell)
|
||||||
*
|
*
|
||||||
* @param int[] $ticketIds
|
* @param int[] $ticketIds
|
||||||
*/
|
*/
|
||||||
private function spawnCliSequential(string $php, string $think, string $root, array $ticketIds): bool
|
private function spawnCliSequential(string $php, string $think, string $root, array $ticketIds, string $source): bool
|
||||||
{
|
{
|
||||||
if ($ticketIds === []) {
|
if ($ticketIds === []) {
|
||||||
return false;
|
return false;
|
||||||
@@ -184,11 +405,13 @@ class SplitTicketSyncDispatchService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log';
|
$logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log';
|
||||||
|
$syncMode = $this->normalizeSyncModeForCli($source);
|
||||||
$parts[] = sprintf(
|
$parts[] = sprintf(
|
||||||
'%s %s split:sync-tickets --ticket=%d >> %s 2>&1',
|
'%s %s split:sync-tickets --ticket=%d --mode=%s >> %s 2>&1',
|
||||||
escapeshellarg($php),
|
escapeshellarg($php),
|
||||||
escapeshellarg($think),
|
escapeshellarg($think),
|
||||||
$ticketId,
|
$ticketId,
|
||||||
|
$syncMode,
|
||||||
escapeshellarg($logFile)
|
escapeshellarg($logFile)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -198,7 +421,7 @@ class SplitTicketSyncDispatchService
|
|||||||
|
|
||||||
$inner = implode('; ', $parts);
|
$inner = implode('; ', $parts);
|
||||||
|
|
||||||
return $this->runBackgroundCommand($root, $inner, $ticketIds, 'node-serial');
|
return $this->runBackgroundCommand($root, $inner, $ticketIds, $source . '-node-serial');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -214,7 +437,7 @@ class SplitTicketSyncDispatchService
|
|||||||
|
|
||||||
if ($this->canUseExec()) {
|
if ($this->canUseExec()) {
|
||||||
@exec($command, $output, $exitCode);
|
@exec($command, $output, $exitCode);
|
||||||
SplitTicketSyncLogger::log('web', 'cli spawn exec', [
|
SplitTicketSyncLogger::log('dispatch', 'cli spawn exec', [
|
||||||
'ticketRef' => $ticketRef,
|
'ticketRef' => $ticketRef,
|
||||||
'mode' => $mode,
|
'mode' => $mode,
|
||||||
'command' => $command,
|
'command' => $command,
|
||||||
@@ -225,7 +448,7 @@ class SplitTicketSyncDispatchService
|
|||||||
|
|
||||||
if ($this->canUseShellExec()) {
|
if ($this->canUseShellExec()) {
|
||||||
@shell_exec($command);
|
@shell_exec($command);
|
||||||
SplitTicketSyncLogger::log('web', 'cli spawn shell_exec', [
|
SplitTicketSyncLogger::log('dispatch', 'cli spawn shell_exec', [
|
||||||
'ticketRef' => $ticketRef,
|
'ticketRef' => $ticketRef,
|
||||||
'mode' => $mode,
|
'mode' => $mode,
|
||||||
'command' => $command,
|
'command' => $command,
|
||||||
@@ -233,7 +456,7 @@ class SplitTicketSyncDispatchService
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
SplitTicketSyncLogger::log('web', 'cli spawn failed: exec disabled', [
|
SplitTicketSyncLogger::log('dispatch', 'cli spawn failed: exec disabled', [
|
||||||
'ticketRef' => $ticketRef,
|
'ticketRef' => $ticketRef,
|
||||||
'mode' => $mode,
|
'mode' => $mode,
|
||||||
]);
|
]);
|
||||||
@@ -333,4 +556,12 @@ class SplitTicketSyncDispatchService
|
|||||||
{
|
{
|
||||||
return function_exists('shell_exec') && !in_array('shell_exec', $this->disabledFunctions(), true);
|
return function_exists('shell_exec') && !in_array('shell_exec', $this->disabledFunctions(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 投递 CLI 时携带 --mode,供 sync_success_mode 区分自动/手动
|
||||||
|
*/
|
||||||
|
private function normalizeSyncModeForCli(string $source): string
|
||||||
|
{
|
||||||
|
return in_array($source, ['auto', 'manual'], true) ? $source : 'manual';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,14 @@ class SplitTicketSyncService
|
|||||||
|
|
||||||
private SplitTicketSyncLockService $lockService;
|
private SplitTicketSyncLockService $lockService;
|
||||||
|
|
||||||
|
private SplitTicketSyncDispatchService $dispatchService;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->numberSync = new SplitTicketNumberSyncService();
|
$this->numberSync = new SplitTicketNumberSyncService();
|
||||||
$this->ruleService = new SplitTicketRuleService();
|
$this->ruleService = new SplitTicketRuleService();
|
||||||
$this->lockService = new SplitTicketSyncLockService();
|
$this->lockService = new SplitTicketSyncLockService();
|
||||||
|
$this->dispatchService = new SplitTicketSyncDispatchService();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,7 +129,7 @@ class SplitTicketSyncService
|
|||||||
);
|
);
|
||||||
|
|
||||||
$successCount = count(array_filter($processed, static function (array $row): bool {
|
$successCount = count(array_filter($processed, static function (array $row): bool {
|
||||||
return !empty($row['success']);
|
return !empty($row['dispatched']);
|
||||||
}));
|
}));
|
||||||
$failedCount = count($processed) - $successCount;
|
$failedCount = count($processed) - $successCount;
|
||||||
|
|
||||||
@@ -151,7 +154,7 @@ class SplitTicketSyncService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 直连 PHP 通道:按周期同步,不占 Node 名额
|
* 直连 PHP 通道:挑选到期工单并投递后台 CLI,不占 Node 名额
|
||||||
*
|
*
|
||||||
* @param list<string> $directTypes
|
* @param list<string> $directTypes
|
||||||
* @return array{
|
* @return array{
|
||||||
@@ -171,16 +174,10 @@ class SplitTicketSyncService
|
|||||||
$list = $this->loadOpenTicketsForTypes($directTypes, $failThreshold, 0);
|
$list = $this->loadOpenTicketsForTypes($directTypes, $failThreshold, 0);
|
||||||
$candidateIds = $this->extractTicketIds($list);
|
$candidateIds = $this->extractTicketIds($list);
|
||||||
|
|
||||||
SplitTicketSyncLogger::logCron('direct channel start', [
|
/** @var list<array<string,mixed>> $dueList */
|
||||||
'types' => $directTypes,
|
$dueList = [];
|
||||||
'candidateCount' => count($list),
|
/** @var list<array<string,mixed>> $skipped */
|
||||||
]);
|
|
||||||
|
|
||||||
/** @var list<array<string, mixed>> $processed */
|
|
||||||
$processed = [];
|
|
||||||
/** @var list<array<string, mixed>> $skipped */
|
|
||||||
$skipped = [];
|
$skipped = [];
|
||||||
$count = 0;
|
|
||||||
|
|
||||||
foreach ($list as $ticket) {
|
foreach ($list as $ticket) {
|
||||||
$ticketId = (int) $ticket['id'];
|
$ticketId = (int) $ticket['id'];
|
||||||
@@ -193,42 +190,60 @@ class SplitTicketSyncService
|
|||||||
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skip, 'direct');
|
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skip, 'direct');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
$dueList[] = $ticket;
|
||||||
$result = $this->syncOne($ticketId, false, true, 'auto');
|
|
||||||
if (!empty($result['skipped'])) {
|
|
||||||
$skipReason = (string) ($result['message'] ?? '已跳过');
|
|
||||||
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skipReason);
|
|
||||||
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skipReason, 'direct');
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::logCron('direct channel start', [
|
||||||
|
'types' => $directTypes,
|
||||||
|
'candidateCount' => count($list),
|
||||||
|
'dueCount' => count($dueList),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dispatchResult = $this->dispatchService->dispatchAuto($dueList);
|
||||||
|
$skipped = array_merge($skipped, $this->mapDispatchSkippedToCron($dispatchResult['skipped']));
|
||||||
|
$failed = $this->mapDispatchFailedToCron($dispatchResult['failed']);
|
||||||
|
|
||||||
|
foreach ($failed as $row) {
|
||||||
|
$skipped[] = $row;
|
||||||
|
$this->logCronCandidateSkipped(
|
||||||
|
(int) $row['ticketId'],
|
||||||
|
(string) $row['ticketType'],
|
||||||
|
(string) $row['ticketUrl'],
|
||||||
|
(string) $row['reason'],
|
||||||
|
'direct'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<array<string,mixed>> $processed */
|
||||||
|
$processed = [];
|
||||||
|
foreach ($dispatchResult['queued'] as $row) {
|
||||||
$processed[] = [
|
$processed[] = [
|
||||||
'ticketId' => $ticketId,
|
'ticketId' => (int) ($row['ticketId'] ?? 0),
|
||||||
'ticketType' => $ticketType,
|
'ticketType' => (string) ($row['ticketType'] ?? ''),
|
||||||
'ticketUrl' => $ticketUrl,
|
'ticketUrl' => (string) ($row['ticketUrl'] ?? ''),
|
||||||
'channel' => 'direct',
|
'channel' => 'direct',
|
||||||
'success' => !empty($result['success']),
|
'dispatched' => true,
|
||||||
'message' => (string) ($result['message'] ?? ''),
|
'message' => '已投递后台同步',
|
||||||
];
|
];
|
||||||
$count++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'processedCount' => $count,
|
'processedCount' => count($processed),
|
||||||
'processed' => $processed,
|
'processed' => $processed,
|
||||||
'skipped' => $skipped,
|
'skipped' => $skipped,
|
||||||
'candidateIds' => $candidateIds,
|
'candidateIds' => $candidateIds,
|
||||||
'summary' => [
|
'summary' => [
|
||||||
'channel' => 'direct',
|
'channel' => 'direct',
|
||||||
'processedCount' => $count,
|
'processedCount' => count($processed),
|
||||||
'skippedCount' => count($skipped),
|
'skippedCount' => count($skipped),
|
||||||
|
'dispatchMode' => 'async-cli',
|
||||||
],
|
],
|
||||||
'stoppedByNodeBusy' => false,
|
'stoppedByNodeBusy' => false,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Node 通道:限流 + 类型公平轮转
|
* Node 通道:限流 + 类型公平轮转 + 异步 CLI 投递
|
||||||
*
|
*
|
||||||
* @param list<string> $nodeTypes
|
* @param list<string> $nodeTypes
|
||||||
* @return array{
|
* @return array{
|
||||||
@@ -269,22 +284,11 @@ class SplitTicketSyncService
|
|||||||
'dueCount' => count($duePool),
|
'dueCount' => count($duePool),
|
||||||
'pickedCount' => count($picked),
|
'pickedCount' => count($picked),
|
||||||
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
|
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||||
|
'dispatchMode' => 'async-cli',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!SplitNodeHealthService::canAcceptWork()) {
|
/** @var list<array<string,mixed>> $skipped */
|
||||||
SplitTicketSyncLogger::logCron('node channel skip', [
|
|
||||||
'reason' => 'node queue busy',
|
|
||||||
'queue' => SplitNodeHealthService::getQueueSnapshot(),
|
|
||||||
]);
|
|
||||||
$skipped = [];
|
$skipped = [];
|
||||||
foreach ($picked as $ticket) {
|
|
||||||
$skipped[] = $this->buildCronTicketEntry(
|
|
||||||
(int) $ticket['id'],
|
|
||||||
(string) $ticket['ticket_type'],
|
|
||||||
(string) $ticket['ticket_url'],
|
|
||||||
'Node 队列繁忙,本轮未执行'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
foreach ($pool as $ticket) {
|
foreach ($pool as $ticket) {
|
||||||
$ticketId = (int) $ticket['id'];
|
$ticketId = (int) $ticket['id'];
|
||||||
if (isset($pickedIdMap[$ticketId])) {
|
if (isset($pickedIdMap[$ticketId])) {
|
||||||
@@ -302,90 +306,79 @@ class SplitTicketSyncService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
$dispatchResult = $this->dispatchService->dispatchAuto($picked);
|
||||||
'processedCount' => 0,
|
$skipped = array_merge($skipped, $this->mapDispatchSkippedToCron($dispatchResult['skipped']));
|
||||||
'processed' => [],
|
$failed = $this->mapDispatchFailedToCron($dispatchResult['failed']);
|
||||||
'skipped' => $skipped,
|
|
||||||
'candidateIds' => $candidateIds,
|
|
||||||
'summary' => [
|
|
||||||
'channel' => 'node',
|
|
||||||
'processedCount' => 0,
|
|
||||||
'skippedCount' => count($skipped),
|
|
||||||
'reason' => 'node queue busy',
|
|
||||||
],
|
|
||||||
'stoppedByNodeBusy' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var list<array<string, mixed>> $processed */
|
foreach ($failed as $row) {
|
||||||
$processed = [];
|
$skipped[] = $row;
|
||||||
/** @var list<array<string, mixed>> $skipped */
|
$this->logCronCandidateSkipped(
|
||||||
$skipped = [];
|
(int) $row['ticketId'],
|
||||||
$count = 0;
|
(string) $row['ticketType'],
|
||||||
$stoppedByNodeBusy = false;
|
(string) $row['ticketUrl'],
|
||||||
|
(string) $row['reason'],
|
||||||
foreach ($pool as $ticket) {
|
'node'
|
||||||
$ticketId = (int) $ticket['id'];
|
|
||||||
$ticketType = (string) $ticket['ticket_type'];
|
|
||||||
$ticketUrl = (string) $ticket['ticket_url'];
|
|
||||||
|
|
||||||
if (!isset($pickedIdMap[$ticketId])) {
|
|
||||||
$skip = $this->shouldSkip($ticket);
|
|
||||||
if ($skip !== null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$skipped[] = $this->buildCronTicketEntry(
|
|
||||||
$ticketId,
|
|
||||||
$ticketType,
|
|
||||||
$ticketUrl,
|
|
||||||
sprintf('未进入本轮 Node 候选(前%d条到期工单公平轮转)', $candidateLimit)
|
|
||||||
);
|
);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!SplitNodeHealthService::canAcceptWork()) {
|
|
||||||
$stoppedByNodeBusy = true;
|
|
||||||
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, 'Node 队列繁忙,停止本轮后续同步');
|
|
||||||
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, 'Node 队列繁忙,停止本轮后续同步', 'node');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sessionKey = AntiBotConfigBuilder::resolveSessionKey($ticketType, $ticketUrl);
|
|
||||||
$result = $this->syncOne($ticketId, false, true, 'auto');
|
|
||||||
if (!empty($result['skipped'])) {
|
|
||||||
$skipReason = (string) ($result['message'] ?? '已跳过');
|
|
||||||
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skipReason);
|
|
||||||
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skipReason, 'node');
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @var list<array<string,mixed>> $processed */
|
||||||
|
$processed = [];
|
||||||
|
foreach ($dispatchResult['queued'] as $row) {
|
||||||
$processed[] = [
|
$processed[] = [
|
||||||
'ticketId' => $ticketId,
|
'ticketId' => (int) ($row['ticketId'] ?? 0),
|
||||||
'ticketType' => $ticketType,
|
'ticketType' => (string) ($row['ticketType'] ?? ''),
|
||||||
'ticketUrl' => $ticketUrl,
|
'ticketUrl' => (string) ($row['ticketUrl'] ?? ''),
|
||||||
'sessionKey' => $sessionKey,
|
'sessionKey' => (string) ($row['sessionKey'] ?? ''),
|
||||||
'channel' => 'node',
|
'channel' => 'node',
|
||||||
'success' => !empty($result['success']),
|
'dispatched' => true,
|
||||||
'message' => (string) ($result['message'] ?? ''),
|
'message' => '已投递后台同步',
|
||||||
];
|
];
|
||||||
$count++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'processedCount' => $count,
|
'processedCount' => count($processed),
|
||||||
'processed' => $processed,
|
'processed' => $processed,
|
||||||
'skipped' => $skipped,
|
'skipped' => $skipped,
|
||||||
'candidateIds' => $candidateIds,
|
'candidateIds' => $candidateIds,
|
||||||
'summary' => [
|
'summary' => [
|
||||||
'channel' => 'node',
|
'channel' => 'node',
|
||||||
'processedCount' => $count,
|
'processedCount' => count($processed),
|
||||||
'skippedCount' => count($skipped),
|
'skippedCount' => count($skipped),
|
||||||
'maxPerRun' => $maxPerRun,
|
'maxPerRun' => $maxPerRun,
|
||||||
|
'dispatchMode' => 'async-cli',
|
||||||
],
|
],
|
||||||
'stoppedByNodeBusy' => $stoppedByNodeBusy,
|
'stoppedByNodeBusy' => $dispatchResult['stoppedByNodeBusy'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,mixed>> $rows
|
||||||
|
* @return list<array{ticketId:int,ticketType:string,ticketUrl:string,reason:string}>
|
||||||
|
*/
|
||||||
|
private function mapDispatchSkippedToCron(array $rows): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$result[] = $this->buildCronTicketEntry(
|
||||||
|
(int) ($row['ticketId'] ?? 0),
|
||||||
|
(string) ($row['ticketType'] ?? ''),
|
||||||
|
(string) ($row['ticketUrl'] ?? ''),
|
||||||
|
(string) ($row['reason'] ?? '已跳过')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,mixed>> $rows
|
||||||
|
* @return list<array{ticketId:int,ticketType:string,ticketUrl:string,reason:string}>
|
||||||
|
*/
|
||||||
|
private function mapDispatchFailedToCron(array $rows): array
|
||||||
|
{
|
||||||
|
return $this->mapDispatchSkippedToCron($rows);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<string> $types
|
* @param list<string> $types
|
||||||
* @return list<Ticket>
|
* @return list<Ticket>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- 号码管理:清理无效降权池权限节点
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.number/cleanupdeferred', '清理降权池', 'fa fa-circle-o', '', '清除无落地页访问基线的无效 ratio_deferred 标记', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.number' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/cleanupdeferred' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
|
|
||||||
|
-- 可选:一次性修复历史脏数据(与后台「清理降权池」逻辑一致)
|
||||||
|
-- UPDATE `fa_split_number`
|
||||||
|
-- SET `ratio_deferred` = 0, `ratio_deferred_at` = NULL,
|
||||||
|
-- `no_inbound_click_streak` = IF(`visit_count` <= 0, 0, `no_inbound_click_streak`)
|
||||||
|
-- WHERE `ratio_deferred` = 1
|
||||||
|
-- AND (`visit_count` <= 0 OR `visit_count` <= `last_sync_visit_count`);
|
||||||
|
|
||||||
|
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||||
|
SELECT 'file', m.id, 'split.number/cleanupdeferredpreview', '清理降权池预览', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||||
|
FROM `fa_auth_rule` m
|
||||||
|
WHERE m.name = 'split.number' AND m.ismenu = 1
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/cleanupdeferredpreview' LIMIT 1)
|
||||||
|
LIMIT 1;
|
||||||
@@ -18,6 +18,7 @@ use think\console\Output;
|
|||||||
* 用法:
|
* 用法:
|
||||||
* php think split:sync-tickets
|
* php think split:sync-tickets
|
||||||
* php think split:sync-tickets --ticket=12
|
* php think split:sync-tickets --ticket=12
|
||||||
|
* php think split:sync-tickets --ticket=12 --mode=auto
|
||||||
*/
|
*/
|
||||||
class SplitSyncTickets extends Command
|
class SplitSyncTickets extends Command
|
||||||
{
|
{
|
||||||
@@ -25,21 +26,24 @@ class SplitSyncTickets extends Command
|
|||||||
{
|
{
|
||||||
$this->setName('split:sync-tickets')
|
$this->setName('split:sync-tickets')
|
||||||
->addOption('ticket', 't', Option::VALUE_OPTIONAL, '指定工单 ID(强制同步,忽略周期)', '')
|
->addOption('ticket', 't', Option::VALUE_OPTIONAL, '指定工单 ID(强制同步,忽略周期)', '')
|
||||||
|
->addOption('mode', 'm', Option::VALUE_OPTIONAL, '同步方式: auto=定时自动, manual=手动触发', 'manual')
|
||||||
->setDescription('同步分流工单云控数据');
|
->setDescription('同步分流工单云控数据');
|
||||||
}
|
}
|
||||||
|
|
||||||
protected function execute(Input $input, Output $output): void
|
protected function execute(Input $input, Output $output): void
|
||||||
{
|
{
|
||||||
set_time_limit(0);
|
set_time_limit(0);
|
||||||
|
$syncMode = $this->resolveSyncMode($input);
|
||||||
SplitTicketSyncLogger::log('cli', 'command start', [
|
SplitTicketSyncLogger::log('cli', 'command start', [
|
||||||
'ticketOption' => trim((string) $input->getOption('ticket')),
|
'ticketOption' => trim((string) $input->getOption('ticket')),
|
||||||
|
'syncMode' => $syncMode,
|
||||||
'appDebug' => SplitTicketSyncLogger::isEnabled(),
|
'appDebug' => SplitTicketSyncLogger::isEnabled(),
|
||||||
]);
|
]);
|
||||||
$service = new SplitTicketSyncService();
|
$service = new SplitTicketSyncService();
|
||||||
$ticketId = trim((string) $input->getOption('ticket'));
|
$ticketId = trim((string) $input->getOption('ticket'));
|
||||||
|
|
||||||
if ($ticketId !== '' && ctype_digit($ticketId)) {
|
if ($ticketId !== '' && ctype_digit($ticketId)) {
|
||||||
$result = $service->syncOne((int) $ticketId, true);
|
$result = $service->syncOne((int) $ticketId, true, false, $syncMode);
|
||||||
if (!empty($result['skipped'])) {
|
if (!empty($result['skipped'])) {
|
||||||
$output->writeln('<comment>跳过: ' . ($result['message'] ?? '') . '</comment>');
|
$output->writeln('<comment>跳过: ' . ($result['message'] ?? '') . '</comment>');
|
||||||
return;
|
return;
|
||||||
@@ -61,8 +65,8 @@ class SplitSyncTickets extends Command
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
$count = $service->syncDueTickets();
|
$count = $service->syncDueTickets();
|
||||||
$output->writeln('<info>本次处理工单数: ' . $count . '</info>');
|
$output->writeln('<info>本次投递工单数: ' . $count . '</info>');
|
||||||
$output->writeln('<comment>汇总日志已写入 runtime/log/split_sync.log</comment>');
|
$output->writeln('<comment>同步在后台 CLI 进程中执行,汇总日志见 runtime/log/split_sync.log</comment>');
|
||||||
} finally {
|
} finally {
|
||||||
$cronLock->release();
|
$cronLock->release();
|
||||||
}
|
}
|
||||||
@@ -71,4 +75,14 @@ class SplitSyncTickets extends Command
|
|||||||
$output->writeln('<comment>详细调试日志已写入 runtime/log/split_sync.log</comment>');
|
$output->writeln('<comment>详细调试日志已写入 runtime/log/split_sync.log</comment>');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 解析 CLI 同步方式,供 sync_success_mode 落库
|
||||||
|
*/
|
||||||
|
private function resolveSyncMode(Input $input): string
|
||||||
|
{
|
||||||
|
$mode = strtolower(trim((string) $input->getOption('mode')));
|
||||||
|
|
||||||
|
return in_array($mode, ['auto', 'manual'], true) ? $mode : 'manual';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ class Number extends Backend
|
|||||||
$this->assignconfig('statusList', $this->model->getStatusList());
|
$this->assignconfig('statusList', $this->model->getStatusList());
|
||||||
$this->assignconfig('manualManageList', $this->model->getManualManageList());
|
$this->assignconfig('manualManageList', $this->model->getManualManageList());
|
||||||
$this->assignconfig('platformStatusList', $this->model->getPlatformStatusList());
|
$this->assignconfig('platformStatusList', $this->model->getPlatformStatusList());
|
||||||
|
$this->assignconfig('ratioDeferredList', $this->model->getRatioDeferredList());
|
||||||
$this->assignconfig('splitLinkFilterList', $this->buildNumberSplitLinkFilterList());
|
$this->assignconfig('splitLinkFilterList', $this->buildNumberSplitLinkFilterList());
|
||||||
$this->assignconfig('splitLinkSelectList', $this->buildSplitLinkSelectConfig());
|
$this->assignconfig('splitLinkSelectList', $this->buildSplitLinkSelectConfig());
|
||||||
|
|
||||||
@@ -525,6 +526,47 @@ class Number extends Backend
|
|||||||
$this->success(sprintf(__('Batch operate success'), $count));
|
$this->success(sprintf(__('Batch operate success'), $count));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 预览待清理的无效降权号码数量
|
||||||
|
*/
|
||||||
|
public function cleanupdeferredpreview(): void
|
||||||
|
{
|
||||||
|
if (false === $this->request->isAjax()) {
|
||||||
|
$this->error(__('Invalid parameters'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = new \app\common\service\SplitNumberRatioDeferredService();
|
||||||
|
$adminIds = $this->dataLimit ? $this->getDataLimitAdminIds() : null;
|
||||||
|
$count = $service->countInvalidDeferred(is_array($adminIds) ? $adminIds : null);
|
||||||
|
|
||||||
|
$this->success('', null, ['count' => $count]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理无效降权号码池(无落地页访问基线却标记 ratio_deferred=1 的记录)
|
||||||
|
*/
|
||||||
|
public function cleanupdeferred(): void
|
||||||
|
{
|
||||||
|
if (false === $this->request->isPost()) {
|
||||||
|
$this->error(__('Invalid parameters'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$service = new \app\common\service\SplitNumberRatioDeferredService();
|
||||||
|
$adminIds = $this->dataLimit ? $this->getDataLimitAdminIds() : null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
$count = $service->cleanupInvalidDeferred(is_array($adminIds) ? $adminIds : null);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
$this->error($e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($count <= 0) {
|
||||||
|
$this->success(__('Ratio deferred cleanup none'));
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->success(sprintf(__('Ratio deferred cleanup success'), $count));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 排除不可由表单提交的字段
|
* 排除不可由表单提交的字段
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -51,4 +51,16 @@ return [
|
|||||||
'Invalid manual manage' => '手动管理选项无效',
|
'Invalid manual manage' => '手动管理选项无效',
|
||||||
'Batch update success' => '批量更新成功',
|
'Batch update success' => '批量更新成功',
|
||||||
'Unit count' => '个',
|
'Unit count' => '个',
|
||||||
|
'Ratio_deferred' => '选号降权',
|
||||||
|
'Ratio deferred badge' => '降权中',
|
||||||
|
'Ratio deferred no' => '正常选号',
|
||||||
|
'Ratio deferred yes' => '降权中',
|
||||||
|
'Ratio deferred filter' => '仅显示降权中号码',
|
||||||
|
'Ratio deferred tooltip'=> '连续 %s 次访问无进线增长,已触线下号比率,选号已后置,等待同步裁决',
|
||||||
|
'Ratio deferred edit notice' => '该号码处于下号比率触线降权状态,同步后将根据进线情况恢复或关闭',
|
||||||
|
'No inbound click streak' => '无进线连续点击',
|
||||||
|
'Ratio deferred cleanup btn' => '清理降权池',
|
||||||
|
'Ratio deferred cleanup confirm' => '将清除 %d 条无效降权记录(无落地页访问或访问未超过同步基线)。确定继续?',
|
||||||
|
'Ratio deferred cleanup success' => '已清理 %d 条无效降权记录',
|
||||||
|
'Ratio deferred cleanup none' => '当前没有需要清理的无效降权记录',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ class Number extends Model
|
|||||||
'status_text',
|
'status_text',
|
||||||
'manual_manage_text',
|
'manual_manage_text',
|
||||||
'platform_status_text',
|
'platform_status_text',
|
||||||
|
'ratio_deferred_text',
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -81,6 +82,17 @@ class Number extends Model
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<string, string>
|
||||||
|
*/
|
||||||
|
public function getRatioDeferredList(): array
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
'0' => __('Ratio deferred no'),
|
||||||
|
'1' => __('Ratio deferred yes'),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<string, string>
|
* @return array<string, string>
|
||||||
*/
|
*/
|
||||||
@@ -158,6 +170,13 @@ class Number extends Model
|
|||||||
return $list[$key] ?? $key;
|
return $list[$key] ?? $key;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getRatioDeferredTextAttr($value, $data): string
|
||||||
|
{
|
||||||
|
$key = (string) ((int) ($data['ratio_deferred'] ?? 0));
|
||||||
|
$list = $this->getRatioDeferredList();
|
||||||
|
return $list[$key] ?? $key;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据链接码生成完整分流 URL
|
* 根据链接码生成完整分流 URL
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -123,6 +123,13 @@
|
|||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
{if isset($row)}
|
{if isset($row)}
|
||||||
<label for="c-number" class="control-label">{:__('Number')}<span class="text-danger">*</span></label>
|
<label for="c-number" class="control-label">{:__('Number')}<span class="text-danger">*</span></label>
|
||||||
|
{if isset($row.ratio_deferred) && $row.ratio_deferred==1 && isset($row.status) && $row.status=='normal'}
|
||||||
|
<div class="alert alert-warning split-ratio-deferred-notice" style="margin-bottom:10px;padding:8px 12px;font-size:12px;">
|
||||||
|
<i class="fa fa-level-down"></i>
|
||||||
|
{:__('Ratio deferred edit notice')}
|
||||||
|
({:__('No inbound click streak')}:{$row.no_inbound_click_streak|default=0})
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
<input id="c-number" data-rule="required" class="form-control" name="row[number]" type="text" value="{$row.number|default=''|htmlentities}" maxlength="50">
|
<input id="c-number" data-rule="required" class="form-control" name="row[number]" type="text" value="{$row.number|default=''|htmlentities}" maxlength="50">
|
||||||
{else}
|
{else}
|
||||||
<label for="c-numbers" class="control-label">{:__('Numbers')}<span class="text-danger">*</span></label>
|
<label for="c-numbers" class="control-label">{:__('Numbers')}<span class="text-danger">*</span></label>
|
||||||
|
|||||||
@@ -18,6 +18,8 @@
|
|||||||
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.number/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
|
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.number/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
|
||||||
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.number/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
|
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.number/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
|
||||||
<a href="javascript:;" class="btn btn-warning btn-batch-update-status btn-disabled disabled {:$auth->check('split.number/batchupdate')?'':'hide'}" title="{:__('Batch update btn')}"><i class="fa fa-edit"></i> {:__('Batch update btn')}</a>
|
<a href="javascript:;" class="btn btn-warning btn-batch-update-status btn-disabled disabled {:$auth->check('split.number/batchupdate')?'':'hide'}" title="{:__('Batch update btn')}"><i class="fa fa-edit"></i> {:__('Batch update btn')}</a>
|
||||||
|
<a href="javascript:;" class="btn btn-default btn-filter-ratio-deferred" title="{:__('Ratio deferred filter')}"><i class="fa fa-level-down text-warning"></i> {:__('Ratio deferred badge')}</a>
|
||||||
|
<a href="javascript:;" class="btn btn-warning btn-cleanup-ratio-deferred {:$auth->check('split.number/cleanupdeferred')?'':'hide'}" title="{:__('Ratio deferred cleanup btn')}"><i class="fa fa-eraser"></i> {:__('Ratio deferred cleanup btn')}</a>
|
||||||
<a href="javascript:;" class="btn btn-info btn-batch-operate {:$auth->check('split.number/batchoperate')?'':'hide'}" title="{:__('Batch operate btn')}"><i class="fa fa-list-alt"></i> {:__('Batch operate btn')}</a>
|
<a href="javascript:;" class="btn btn-info btn-batch-operate {:$auth->check('split.number/batchoperate')?'':'hide'}" title="{:__('Batch operate btn')}"><i class="fa fa-list-alt"></i> {:__('Batch operate btn')}</a>
|
||||||
</div>
|
</div>
|
||||||
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
||||||
|
|||||||
@@ -42,6 +42,20 @@ class SplitNodeHealthService
|
|||||||
*/
|
*/
|
||||||
public static function canAcceptWork(): bool
|
public static function canAcceptWork(): bool
|
||||||
{
|
{
|
||||||
|
return self::canAcceptWorkForTicketType('');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 指定工单类型是否可向 Node 提交新任务
|
||||||
|
*
|
||||||
|
* API 优先类型(如 Whatshub)常态不占 Real Browser,调度层应跳过队列门禁。
|
||||||
|
*/
|
||||||
|
public static function canAcceptWorkForTicketType(string $ticketType): bool
|
||||||
|
{
|
||||||
|
if ($ticketType !== '' && SplitScrmSpiderFactory::isApiFirstSyncType($ticketType)) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
$stats = self::fetchStats();
|
$stats = self::fetchStats();
|
||||||
if ($stats === null) {
|
if ($stats === null) {
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use app\admin\model\split\Number;
|
||||||
|
use think\Db;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 下号比率触线降权(ratio_deferred)规则与降权池清理
|
||||||
|
*
|
||||||
|
* 降权前提:已有落地页访问(visit_count > last_sync_visit_count),
|
||||||
|
* 且自上次同步以来进线未增长时累计 streak,触线才标记降权。
|
||||||
|
*/
|
||||||
|
class SplitNumberRatioDeferredService
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* 是否具备计入 streak 的落地页访问基数
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|Number $number
|
||||||
|
*/
|
||||||
|
public function hasLandingVisitBaseline($number): bool
|
||||||
|
{
|
||||||
|
$visitCount = $this->readInt($number, 'visit_count');
|
||||||
|
if ($visitCount <= 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastVisit = $this->readInt($number, 'last_sync_visit_count');
|
||||||
|
|
||||||
|
return $visitCount > $lastVisit;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据访问与进线快照计算新的 streak
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|Number $number
|
||||||
|
*/
|
||||||
|
public function computeStreakForVisit($number, int $currentStreak): int
|
||||||
|
{
|
||||||
|
if (!$this->hasLandingVisitBaseline($number)) {
|
||||||
|
return $currentStreak;
|
||||||
|
}
|
||||||
|
|
||||||
|
$inboundCount = $this->readInt($number, 'inbound_count');
|
||||||
|
$lastInbound = $this->readInt($number, 'last_sync_inbound_count');
|
||||||
|
|
||||||
|
if ($inboundCount <= $lastInbound) {
|
||||||
|
return $currentStreak + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 同步侧:根据 visit / inbound 与上次同步检查点重算 streak
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|Number $number
|
||||||
|
*/
|
||||||
|
public function computeStreakForSync($number, int $currentStreak): int
|
||||||
|
{
|
||||||
|
$visitCount = $this->readInt($number, 'visit_count');
|
||||||
|
if ($visitCount <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$lastVisit = $this->readInt($number, 'last_sync_visit_count');
|
||||||
|
$inboundCount = $this->readInt($number, 'inbound_count');
|
||||||
|
$lastInbound = $this->readInt($number, 'last_sync_inbound_count');
|
||||||
|
|
||||||
|
if ($inboundCount > $lastInbound) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($visitCount > $lastVisit) {
|
||||||
|
return $currentStreak + ($visitCount - $lastVisit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $currentStreak;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否应标记 ratio_deferred(访问侧触线降权,不关 status)
|
||||||
|
*/
|
||||||
|
public function shouldMarkDeferred(int $streak, int $assignRatio, bool $hasLandingBaseline): bool
|
||||||
|
{
|
||||||
|
return $hasLandingBaseline && $assignRatio > 0 && $streak >= $assignRatio;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否为应清理的无效降权记录
|
||||||
|
*
|
||||||
|
* @param array<string, mixed>|Number $number
|
||||||
|
*/
|
||||||
|
public function isInvalidDeferredRecord($number): bool
|
||||||
|
{
|
||||||
|
if ($this->readInt($number, 'ratio_deferred') !== 1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return !$this->hasLandingVisitBaseline($number);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 清理无效降权标记(ratio_deferred=1 但无有效落地页访问基线)
|
||||||
|
*
|
||||||
|
* @param int[]|null $adminIds 数据权限管理员 ID 列表,null 表示不限制
|
||||||
|
* @return int 清理条数
|
||||||
|
*/
|
||||||
|
public function cleanupInvalidDeferred(?array $adminIds = null): int
|
||||||
|
{
|
||||||
|
$query = Number::where('ratio_deferred', 1);
|
||||||
|
if ($adminIds !== null && $adminIds !== []) {
|
||||||
|
$query->where('admin_id', 'in', $adminIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$rows = $query->field('id,visit_count,last_sync_visit_count,ratio_deferred,no_inbound_click_streak')->select();
|
||||||
|
if ($rows === null || $rows === []) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = time();
|
||||||
|
$count = 0;
|
||||||
|
Db::startTrans();
|
||||||
|
try {
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
if (!$this->isInvalidDeferredRecord($row)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$update = [
|
||||||
|
'ratio_deferred' => 0,
|
||||||
|
'ratio_deferred_at' => null,
|
||||||
|
'updatetime' => $now,
|
||||||
|
];
|
||||||
|
if ($this->readInt($row, 'visit_count') <= 0) {
|
||||||
|
$update['no_inbound_click_streak'] = 0;
|
||||||
|
}
|
||||||
|
Number::where('id', (int) $row['id'])->update($update);
|
||||||
|
$count++;
|
||||||
|
}
|
||||||
|
Db::commit();
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
Db::rollback();
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $count;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统计当前权限范围内无效降权条数(清理前预览)
|
||||||
|
*
|
||||||
|
* @param int[]|null $adminIds
|
||||||
|
*/
|
||||||
|
public function countInvalidDeferred(?array $adminIds = null): int
|
||||||
|
{
|
||||||
|
$query = Number::where('ratio_deferred', 1);
|
||||||
|
if ($adminIds !== null && $adminIds !== []) {
|
||||||
|
$query->where('admin_id', 'in', $adminIds);
|
||||||
|
}
|
||||||
|
|
||||||
|
$total = 0;
|
||||||
|
foreach ($query->field('id,visit_count,last_sync_visit_count,ratio_deferred')->select() as $row) {
|
||||||
|
if ($this->isInvalidDeferredRecord($row)) {
|
||||||
|
$total++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $total;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array<string, mixed>|Number $number
|
||||||
|
*/
|
||||||
|
private function readInt($number, string $field): int
|
||||||
|
{
|
||||||
|
if (is_array($number)) {
|
||||||
|
return (int) ($number[$field] ?? 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (int) $number->getAttr($field);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,13 @@ use app\admin\model\split\Ticket;
|
|||||||
*/
|
*/
|
||||||
class SplitNumberVisitRuleService
|
class SplitNumberVisitRuleService
|
||||||
{
|
{
|
||||||
|
private SplitNumberRatioDeferredService $ratioDeferredService;
|
||||||
|
|
||||||
|
public function __construct(?SplitNumberRatioDeferredService $ratioDeferredService = null)
|
||||||
|
{
|
||||||
|
$this->ratioDeferredService = $ratioDeferredService ?? new SplitNumberRatioDeferredService();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 访问计数已递增后调用:累加 streak,达到 assign_ratio 时标记 ratio_deferred=1
|
* 访问计数已递增后调用:累加 streak,达到 assign_ratio 时标记 ratio_deferred=1
|
||||||
*/
|
*/
|
||||||
@@ -39,15 +46,11 @@ class SplitNumberVisitRuleService
|
|||||||
}
|
}
|
||||||
|
|
||||||
$assignRatio = (int) ($ticket['assign_ratio'] ?? 0);
|
$assignRatio = (int) ($ticket['assign_ratio'] ?? 0);
|
||||||
$inboundCount = (int) $number['inbound_count'];
|
|
||||||
$lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0);
|
|
||||||
$streak = (int) ($number['no_inbound_click_streak'] ?? 0);
|
$streak = (int) ($number['no_inbound_click_streak'] ?? 0);
|
||||||
|
$hasBaseline = $this->ratioDeferredService->hasLandingVisitBaseline($number);
|
||||||
|
|
||||||
// 自上次同步检查点以来进线未增长,则本次访问计入 streak
|
if ($hasBaseline) {
|
||||||
if ($inboundCount <= $lastInbound) {
|
$streak = $this->ratioDeferredService->computeStreakForVisit($number, $streak);
|
||||||
$streak++;
|
|
||||||
} else {
|
|
||||||
$streak = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$update = [
|
$update = [
|
||||||
@@ -55,8 +58,9 @@ class SplitNumberVisitRuleService
|
|||||||
'updatetime' => time(),
|
'updatetime' => time(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// 非手动号码且达到下号比率:触线降权,不关 status,等待同步确认
|
if ((int) $number['manual_manage'] === 0
|
||||||
if ((int) $number['manual_manage'] === 0 && $assignRatio > 0 && $streak >= $assignRatio) {
|
&& $this->ratioDeferredService->shouldMarkDeferred($streak, $assignRatio, $hasBaseline)
|
||||||
|
) {
|
||||||
$update['ratio_deferred'] = 1;
|
$update['ratio_deferred'] = 1;
|
||||||
$update['ratio_deferred_at'] = time();
|
$update['ratio_deferred_at'] = time();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ use app\admin\model\split\Link;
|
|||||||
/**
|
/**
|
||||||
* 分流链接随机打乱配置与落地页选号
|
* 分流链接随机打乱配置与落地页选号
|
||||||
*
|
*
|
||||||
* - random_shuffle=0:跨工单合并号码池,按 id 顺序严格轮转
|
* - random_shuffle=0:跨工单合并号码池,按 id 顺序全局严格轮转
|
||||||
* - random_shuffle=1:跨工单合并号码池,每次访问完全随机选号
|
* - random_shuffle=1:先按各工单开启号码数比例随机选工单,再在该工单内随机选号
|
||||||
* - 同步写入时 random_shuffle=1 还会打乱新号码 insert 顺序(影响 id 分布)
|
* - 同步写入时 random_shuffle=1 还会打乱新号码 insert 顺序(影响 id 分布)
|
||||||
*/
|
*/
|
||||||
class SplitNumberWeighService
|
class SplitNumberWeighService
|
||||||
|
|||||||
@@ -6,14 +6,17 @@ namespace app\common\service;
|
|||||||
|
|
||||||
use app\admin\model\split\Link;
|
use app\admin\model\split\Link;
|
||||||
use app\admin\model\split\Number;
|
use app\admin\model\split\Number;
|
||||||
|
use app\admin\model\split\Ticket;
|
||||||
use think\Collection;
|
use think\Collection;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 分流链接落地页:查链、跨工单合并选号、拼接跳转 URL、访问计数
|
* 分流链接落地页:查链、选号、拼接跳转 URL、访问计数
|
||||||
*
|
*
|
||||||
* 号码池为同一 split_link_id 下全部 status=normal 的号码;
|
* 号码池:同一 split_link_id 下 status=normal,且所属工单未关闭(ticket.status≠hidden)的号码;
|
||||||
* 优先选用 ratio_deferred=0 的号码,全部触线降权时回退至降权池仍可轮转。
|
* 优先选用 ratio_deferred=0,全部触线降权时回退至降权池。
|
||||||
* random_shuffle 关闭时严格轮转,开启时每次访问随机选号。
|
*
|
||||||
|
* random_shuffle=0:跨工单合并池,全局严格轮转;
|
||||||
|
* random_shuffle=1:先按各工单开启号码数量比例随机选工单,再在该工单内随机选号。
|
||||||
*/
|
*/
|
||||||
class SplitRedirectService
|
class SplitRedirectService
|
||||||
{
|
{
|
||||||
@@ -55,20 +58,17 @@ class SplitRedirectService
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @var Collection<int, Number>|array<int, Number> $numbers */
|
$linkId = (int) $link->getAttr('id');
|
||||||
$numbers = Number::where('split_link_id', (int) $link['id'])
|
$list = $this->loadEligibleNumbers($linkId);
|
||||||
->where('status', 'normal')
|
|
||||||
->order('id', 'asc')
|
|
||||||
->field('id,number,number_type,number_type_custom,ratio_deferred')
|
|
||||||
->select();
|
|
||||||
|
|
||||||
$list = $numbers instanceof Collection ? $numbers->all() : (array) $numbers;
|
|
||||||
if ($list === []) {
|
if ($list === []) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
$linkId = (int) $link->getAttr('id');
|
if (SplitNumberWeighService::isRandomShuffleEnabled($linkId)) {
|
||||||
|
$picked = $this->pickNumberRowRandomByTicketWeight($linkId, $list);
|
||||||
|
} else {
|
||||||
$picked = $this->pickNumberRow($linkId, $list);
|
$picked = $this->pickNumberRow($linkId, $list);
|
||||||
|
}
|
||||||
if ($picked === null) {
|
if ($picked === null) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -103,10 +103,117 @@ class SplitRedirectService
|
|||||||
return $redirectUrl;
|
return $redirectUrl;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可参与选号的号码:status=normal,且 ticket_name 不属于已关闭工单
|
||||||
|
*
|
||||||
|
* @return list<Number|array<string, mixed>>
|
||||||
|
*/
|
||||||
|
private function loadEligibleNumbers(int $linkId): array
|
||||||
|
{
|
||||||
|
if ($linkId <= 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<string> $hiddenTicketNames */
|
||||||
|
$hiddenTicketNames = Ticket::where('split_link_id', $linkId)
|
||||||
|
->where('status', 'hidden')
|
||||||
|
->column('ticket_name');
|
||||||
|
$hiddenTicketNames = array_values(array_unique(array_filter(array_map(
|
||||||
|
static function ($name): string {
|
||||||
|
return trim((string) $name);
|
||||||
|
},
|
||||||
|
$hiddenTicketNames
|
||||||
|
))));
|
||||||
|
|
||||||
|
$query = Number::where('split_link_id', $linkId)
|
||||||
|
->where('status', 'normal')
|
||||||
|
->order('id', 'asc')
|
||||||
|
->field('id,number,number_type,number_type_custom,ratio_deferred,ticket_name');
|
||||||
|
|
||||||
|
if ($hiddenTicketNames !== []) {
|
||||||
|
$query->where('ticket_name', 'not in', $hiddenTicketNames);
|
||||||
|
}
|
||||||
|
|
||||||
|
$numbers = $query->select();
|
||||||
|
return $numbers instanceof Collection ? $numbers->all() : (array) $numbers;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* random_shuffle=1:先按工单开启号码数加权随机选工单,再在该工单内选号
|
||||||
|
*
|
||||||
|
* @param list<Number|array<string, mixed>> $rows
|
||||||
|
* @return array<string, mixed>|Number|null
|
||||||
|
*/
|
||||||
|
private function pickNumberRowRandomByTicketWeight(int $linkId, array $rows)
|
||||||
|
{
|
||||||
|
$groups = $this->groupNumbersByTicket($rows);
|
||||||
|
if ($groups === []) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$ticketKey = $this->pickTicketGroupKeyWeighted($groups);
|
||||||
|
if ($ticketKey === null || !isset($groups[$ticketKey])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->pickNumberRow($linkId, $groups[$ticketKey]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<Number|array<string, mixed>> $rows
|
||||||
|
* @return array<string, list<Number|array<string, mixed>>>
|
||||||
|
*/
|
||||||
|
private function groupNumbersByTicket(array $rows): array
|
||||||
|
{
|
||||||
|
/** @var array<string, list<Number|array<string, mixed>>> $groups */
|
||||||
|
$groups = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$name = is_array($row)
|
||||||
|
? trim((string) ($row['ticket_name'] ?? ''))
|
||||||
|
: trim((string) $row->getAttr('ticket_name'));
|
||||||
|
$key = $name !== '' ? $name : '__manual__';
|
||||||
|
$groups[$key][] = $row;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $groups;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 按各分组号码数量加权随机选一个工单(分组 key)
|
||||||
|
*
|
||||||
|
* @param array<string, list<Number|array<string, mixed>>> $groups
|
||||||
|
*/
|
||||||
|
private function pickTicketGroupKeyWeighted(array $groups): ?string
|
||||||
|
{
|
||||||
|
$total = 0;
|
||||||
|
foreach ($groups as $rows) {
|
||||||
|
$total += count($rows);
|
||||||
|
}
|
||||||
|
if ($total <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$keys = array_keys($groups);
|
||||||
|
if (count($keys) === 1) {
|
||||||
|
return $keys[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
$r = random_int(1, $total);
|
||||||
|
$acc = 0;
|
||||||
|
foreach ($groups as $key => $rows) {
|
||||||
|
$acc += count($rows);
|
||||||
|
if ($r <= $acc) {
|
||||||
|
return $key;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $keys[0];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 双层选号:优先 ratio_deferred=0,否则回退至降权池
|
* 双层选号:优先 ratio_deferred=0,否则回退至降权池
|
||||||
*
|
*
|
||||||
* @param array<int, array<string, mixed>|Number> $rows
|
* @param list<Number|array<string, mixed>> $rows
|
||||||
* @return array<string, mixed>|Number|null
|
* @return array<string, mixed>|Number|null
|
||||||
*/
|
*/
|
||||||
private function pickNumberRow(int $linkId, array $rows)
|
private function pickNumberRow(int $linkId, array $rows)
|
||||||
|
|||||||
@@ -33,6 +33,17 @@ class SplitScrmSpiderFactory
|
|||||||
// ceo_scrm 等未实现类型:新增 spider 类后在此注册
|
// ceo_scrm 等未实现类型:新增 spider 类后在此注册
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API 优先同步类型:常态走 PHP cURL,不占 Node Real Browser 槽位
|
||||||
|
*
|
||||||
|
* 调度层据此跳过 Node 队列门禁并允许并行投递;蜘蛛实现本身不在此修改。
|
||||||
|
*
|
||||||
|
* @var list<string>
|
||||||
|
*/
|
||||||
|
private const API_FIRST_SYNC_TYPES = [
|
||||||
|
'whatshub',
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return class-string<ScrmSpiderInterface>|null
|
* @return class-string<ScrmSpiderInterface>|null
|
||||||
*/
|
*/
|
||||||
@@ -60,6 +71,22 @@ class SplitScrmSpiderFactory
|
|||||||
return array_keys(self::MAP);
|
return array_keys(self::MAP);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 是否 API 优先类型(调度层可并行投递且不占 Node 队列)
|
||||||
|
*/
|
||||||
|
public static function isApiFirstSyncType(string $ticketType): bool
|
||||||
|
{
|
||||||
|
return in_array(trim($ticketType), self::API_FIRST_SYNC_TYPES, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<string>
|
||||||
|
*/
|
||||||
|
public static function listApiFirstSyncTypes(): array
|
||||||
|
{
|
||||||
|
return self::API_FIRST_SYNC_TYPES;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步是否依赖 Node Headless(纯 PHP cURL 等为 false)
|
* 同步是否依赖 Node Headless(纯 PHP cURL 等为 false)
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class SplitSyncConfigService
|
|||||||
if ($value === '') {
|
if ($value === '') {
|
||||||
return 5;
|
return 5;
|
||||||
}
|
}
|
||||||
return max(1, min(20, (int) $value));
|
return max(1, min(50, (int) $value));
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,6 +12,13 @@ use app\admin\model\split\Ticket;
|
|||||||
*/
|
*/
|
||||||
class SplitTicketRuleService
|
class SplitTicketRuleService
|
||||||
{
|
{
|
||||||
|
private SplitNumberRatioDeferredService $ratioDeferredService;
|
||||||
|
|
||||||
|
public function __construct(?SplitNumberRatioDeferredService $ratioDeferredService = null)
|
||||||
|
{
|
||||||
|
$this->ratioDeferredService = $ratioDeferredService ?? new SplitNumberRatioDeferredService();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 同步后应用工单开关规则;号码开关由 applyNumberRules 综合裁决(平台在线 + 单号上限 + 下号比率)
|
* 同步后应用工单开关规则;号码开关由 applyNumberRules 综合裁决(平台在线 + 单号上限 + 下号比率)
|
||||||
*/
|
*/
|
||||||
@@ -108,14 +115,13 @@ class SplitTicketRuleService
|
|||||||
foreach ($numbers as $number) {
|
foreach ($numbers as $number) {
|
||||||
$visitCount = (int) $number['visit_count'];
|
$visitCount = (int) $number['visit_count'];
|
||||||
$inboundCount = (int) $number['inbound_count'];
|
$inboundCount = (int) $number['inbound_count'];
|
||||||
$lastVisit = (int) ($number['last_sync_visit_count'] ?? 0);
|
|
||||||
$lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0);
|
$lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0);
|
||||||
$streak = (int) ($number['no_inbound_click_streak'] ?? 0);
|
$streak = (int) ($number['no_inbound_click_streak'] ?? 0);
|
||||||
|
|
||||||
if ($visitCount > $lastVisit && $inboundCount <= $lastInbound) {
|
if ($visitCount <= 0) {
|
||||||
$streak += ($visitCount - $lastVisit);
|
|
||||||
} elseif ($inboundCount > $lastInbound) {
|
|
||||||
$streak = 0;
|
$streak = 0;
|
||||||
|
} else {
|
||||||
|
$streak = $this->ratioDeferredService->computeStreakForSync($number, $streak);
|
||||||
}
|
}
|
||||||
|
|
||||||
$update = [
|
$update = [
|
||||||
@@ -125,20 +131,25 @@ class SplitTicketRuleService
|
|||||||
'updatetime' => time(),
|
'updatetime' => time(),
|
||||||
];
|
];
|
||||||
|
|
||||||
// 手动管理(含用户手动关闭)的号码:仅更新统计字段,不改状态与降权标记
|
$clearDeferred = [
|
||||||
|
'ratio_deferred' => 0,
|
||||||
|
'ratio_deferred_at' => null,
|
||||||
|
];
|
||||||
|
|
||||||
|
// 手动管理:更新统计;无效降权(无落地页访问)一并清除
|
||||||
if ((int) $number['manual_manage'] === 1) {
|
if ((int) $number['manual_manage'] === 1) {
|
||||||
|
if ($this->ratioDeferredService->isInvalidDeferredRecord($number) || $visitCount <= 0) {
|
||||||
|
$update = array_merge($update, $clearDeferred);
|
||||||
|
if ($visitCount <= 0) {
|
||||||
|
$update['no_inbound_click_streak'] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
Number::where('id', (int) $number['id'])->update($update);
|
Number::where('id', (int) $number['id'])->update($update);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 同步裁决 ratio_deferred:进线增长则解除降权;触线且无进线增长则关号并清除降权
|
// 同步裁决 ratio_deferred:同步时清除访问侧降权,由 streak 决定是否关号
|
||||||
if ($inboundCount > $lastInbound) {
|
$update = array_merge($update, $clearDeferred);
|
||||||
$update['ratio_deferred'] = 0;
|
|
||||||
$update['ratio_deferred_at'] = null;
|
|
||||||
} else {
|
|
||||||
$update['ratio_deferred'] = 0;
|
|
||||||
$update['ratio_deferred_at'] = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
$update['status'] = $this->resolveAutomatedStatus(
|
$update['status'] = $this->resolveAutomatedStatus(
|
||||||
$ticket,
|
$ticket,
|
||||||
|
|||||||
@@ -4,15 +4,19 @@ declare(strict_types=1);
|
|||||||
|
|
||||||
namespace app\common\service;
|
namespace app\common\service;
|
||||||
|
|
||||||
|
use app\common\library\scrm\AntiBotConfigBuilder;
|
||||||
use think\Db;
|
use think\Db;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 工单手工同步 CLI 后台投递
|
* 工单同步 CLI 后台投递
|
||||||
*
|
*
|
||||||
* Web 请求仅负责鉴权与投递,实际 Spider 在独立 CLI 进程中执行,
|
* Web / Cron 仅负责鉴权与投递,实际 Spider 在独立 CLI 进程中执行,
|
||||||
* 避免长时间占用 PHP-FPM 与 Session 锁导致后台其它页面无法打开。
|
* 避免长时间占用 PHP-FPM、Cron 全局锁或 Session 锁。
|
||||||
*
|
*
|
||||||
* Node 类型(A2C / Whatshub 等)在同一 nohup 子 shell 内串行执行,避免并行 Chrome 压垮温池。
|
* 并行策略:
|
||||||
|
* - API 优先类型(Whatshub 等):独立并行投递,不占 Node Real Browser 槽位
|
||||||
|
* - 直连 PHP 类型:独立并行投递
|
||||||
|
* - Node 类型:不同 sessionKey(域名)并行;同一 sessionKey 内串行以命中 Browser 温池
|
||||||
*/
|
*/
|
||||||
class SplitTicketSyncDispatchService
|
class SplitTicketSyncDispatchService
|
||||||
{
|
{
|
||||||
@@ -24,75 +28,91 @@ class SplitTicketSyncDispatchService
|
|||||||
*/
|
*/
|
||||||
public function dispatchManual(array $ticketIds): array
|
public function dispatchManual(array $ticketIds): array
|
||||||
{
|
{
|
||||||
$queued = [];
|
$lockService = new SplitTicketSyncLockService();
|
||||||
|
$metaList = $this->loadTicketMetaList($ticketIds);
|
||||||
|
|
||||||
|
/** @var list<array{id:int,ticket_type:string,ticket_url:string}> $toDispatch */
|
||||||
|
$toDispatch = [];
|
||||||
$skipped = [];
|
$skipped = [];
|
||||||
$failed = [];
|
$failed = [];
|
||||||
|
|
||||||
$php = $this->resolvePhpBinary();
|
foreach ($metaList as $meta) {
|
||||||
$think = $this->resolveThinkScript();
|
$ticketId = (int) $meta['id'];
|
||||||
$root = $this->resolveRootPath();
|
|
||||||
$lockService = new SplitTicketSyncLockService();
|
|
||||||
$nodeTypeMap = array_flip(SplitScrmSpiderFactory::listNodeSyncTypes());
|
|
||||||
$typeMap = $this->loadTicketTypeMap($ticketIds);
|
|
||||||
|
|
||||||
/** @var int[] $nodeTicketIds */
|
|
||||||
$nodeTicketIds = [];
|
|
||||||
/** @var int[] $directTicketIds */
|
|
||||||
$directTicketIds = [];
|
|
||||||
|
|
||||||
foreach ($ticketIds as $rawId) {
|
|
||||||
$ticketId = (int) $rawId;
|
|
||||||
if ($ticketId <= 0) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($lockService->isLocked($ticketId)) {
|
if ($lockService->isLocked($ticketId)) {
|
||||||
$skipped[] = $ticketId;
|
$skipped[] = $ticketId;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
$toDispatch[] = $meta;
|
||||||
$ticketType = (string) ($typeMap[$ticketId] ?? '');
|
|
||||||
if ($ticketType !== '' && isset($nodeTypeMap[$ticketType])) {
|
|
||||||
$nodeTicketIds[] = $ticketId;
|
|
||||||
} else {
|
|
||||||
$directTicketIds[] = $ticketId;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($directTicketIds as $ticketId) {
|
$dispatchResult = $this->dispatchTicketMetaList($toDispatch, 'manual');
|
||||||
if ($this->spawnCli($php, $think, $root, $ticketId)) {
|
|
||||||
$queued[] = $ticketId;
|
|
||||||
} else {
|
|
||||||
$failed[] = $ticketId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if ($nodeTicketIds !== []) {
|
|
||||||
$spawned = $this->spawnCliSequential($php, $think, $root, $nodeTicketIds);
|
|
||||||
if ($spawned) {
|
|
||||||
foreach ($nodeTicketIds as $ticketId) {
|
|
||||||
$queued[] = $ticketId;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
foreach ($nodeTicketIds as $ticketId) {
|
|
||||||
$failed[] = $ticketId;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SplitTicketSyncLogger::log('web', 'manual sync dispatched', [
|
SplitTicketSyncLogger::log('web', 'manual sync dispatched', [
|
||||||
'queued' => $queued,
|
'queued' => $dispatchResult['queued'],
|
||||||
'skipped' => $skipped,
|
'skipped' => $skipped,
|
||||||
'failed' => $failed,
|
'failed' => $dispatchResult['failed'],
|
||||||
'nodeSerial' => $nodeTicketIds,
|
'phpCli' => $this->resolvePhpBinary(),
|
||||||
'directParallel' => $directTicketIds,
|
|
||||||
'phpCli' => $php,
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'queued' => $queued,
|
'queued' => $dispatchResult['queued'],
|
||||||
'skipped' => $skipped,
|
'skipped' => $skipped,
|
||||||
'failed' => $failed,
|
'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;
|
||||||
|
}
|
||||||
|
$ticketId = (int) $meta['id'];
|
||||||
|
if ($lockService->isLocked($ticketId)) {
|
||||||
|
$skipped[] = $this->buildDispatchEntry(
|
||||||
|
$ticketId,
|
||||||
|
(string) $meta['ticket_type'],
|
||||||
|
(string) $meta['ticket_url'],
|
||||||
|
'工单正在同步中'
|
||||||
|
);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$toDispatch[] = $meta;
|
||||||
|
}
|
||||||
|
|
||||||
|
$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(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return [
|
||||||
|
'queued' => $dispatchResult['queued'],
|
||||||
|
'skipped' => array_merge($skipped, $dispatchResult['skipped']),
|
||||||
|
'failed' => $dispatchResult['failed'],
|
||||||
|
'stoppedByNodeBusy' => $dispatchResult['stoppedByNodeBusy'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,10 +137,181 @@ class SplitTicketSyncDispatchService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param int[] $ticketIds
|
* @param list<array{id:int,ticket_type:string,ticket_url:string}> $metaList
|
||||||
* @return array<int, string> ticketId => ticket_type
|
* @return array{
|
||||||
|
* queued:list<array<string,mixed>>,
|
||||||
|
* skipped:list<array<string,mixed>>,
|
||||||
|
* failed:list<array<string,mixed>>,
|
||||||
|
* stoppedByNodeBusy:bool
|
||||||
|
* }
|
||||||
*/
|
*/
|
||||||
private function loadTicketTypeMap(array $ticketIds): array
|
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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int[] $ticketIds
|
||||||
|
* @return list<array{id:int,ticket_type:string,ticket_url:string}>
|
||||||
|
*/
|
||||||
|
private function loadTicketMetaList(array $ticketIds): array
|
||||||
{
|
{
|
||||||
$ids = [];
|
$ids = [];
|
||||||
foreach ($ticketIds as $rawId) {
|
foreach ($ticketIds as $rawId) {
|
||||||
@@ -135,42 +326,72 @@ class SplitTicketSyncDispatchService
|
|||||||
|
|
||||||
$rows = Db::name('split_ticket')
|
$rows = Db::name('split_ticket')
|
||||||
->where('id', 'in', $ids)
|
->where('id', 'in', $ids)
|
||||||
->field('id,ticket_type')
|
->field('id,ticket_type,ticket_url')
|
||||||
->select();
|
->select();
|
||||||
|
|
||||||
$map = [];
|
$list = [];
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
$map[(int) $row['id']] = (string) ($row['ticket_type'] ?? '');
|
$list[] = [
|
||||||
|
'id' => (int) $row['id'],
|
||||||
|
'ticket_type' => (string) ($row['ticket_type'] ?? ''),
|
||||||
|
'ticket_url' => (string) ($row['ticket_url'] ?? ''),
|
||||||
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $map;
|
return $list;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 后台启动 split:sync-tickets CLI(单工单,并行投递)
|
* @param array<string,mixed>|object $ticket
|
||||||
|
* @return array{id:int,ticket_type:string,ticket_url:string}|null
|
||||||
*/
|
*/
|
||||||
private function spawnCli(string $php, string $think, string $root, int $ticketId): bool
|
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
|
||||||
{
|
{
|
||||||
$logDir = $this->resolveLogDir();
|
$logDir = $this->resolveLogDir();
|
||||||
$logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log';
|
$logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log';
|
||||||
|
|
||||||
|
$syncMode = $this->normalizeSyncModeForCli($source);
|
||||||
$inner = sprintf(
|
$inner = sprintf(
|
||||||
'%s %s split:sync-tickets --ticket=%d >> %s 2>&1',
|
'%s %s split:sync-tickets --ticket=%d --mode=%s >> %s 2>&1',
|
||||||
escapeshellarg($php),
|
escapeshellarg($php),
|
||||||
escapeshellarg($think),
|
escapeshellarg($think),
|
||||||
$ticketId,
|
$ticketId,
|
||||||
|
$syncMode,
|
||||||
escapeshellarg($logFile)
|
escapeshellarg($logFile)
|
||||||
);
|
);
|
||||||
|
|
||||||
return $this->runBackgroundCommand($root, $inner, $ticketId, 'parallel');
|
return $this->runBackgroundCommand($root, $inner, $ticketId, $source . '-parallel');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Node 类型工单:单条 nohup 内串行执行多个 CLI,避免并行 Chrome
|
* 同一 sessionKey 内串行执行多个 CLI(单 nohup 子 shell)
|
||||||
*
|
*
|
||||||
* @param int[] $ticketIds
|
* @param int[] $ticketIds
|
||||||
*/
|
*/
|
||||||
private function spawnCliSequential(string $php, string $think, string $root, array $ticketIds): bool
|
private function spawnCliSequential(string $php, string $think, string $root, array $ticketIds, string $source): bool
|
||||||
{
|
{
|
||||||
if ($ticketIds === []) {
|
if ($ticketIds === []) {
|
||||||
return false;
|
return false;
|
||||||
@@ -184,11 +405,13 @@ class SplitTicketSyncDispatchService
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log';
|
$logFile = $logDir . 'cli_' . $ticketId . '_' . date('YmdHis') . '.log';
|
||||||
|
$syncMode = $this->normalizeSyncModeForCli($source);
|
||||||
$parts[] = sprintf(
|
$parts[] = sprintf(
|
||||||
'%s %s split:sync-tickets --ticket=%d >> %s 2>&1',
|
'%s %s split:sync-tickets --ticket=%d --mode=%s >> %s 2>&1',
|
||||||
escapeshellarg($php),
|
escapeshellarg($php),
|
||||||
escapeshellarg($think),
|
escapeshellarg($think),
|
||||||
$ticketId,
|
$ticketId,
|
||||||
|
$syncMode,
|
||||||
escapeshellarg($logFile)
|
escapeshellarg($logFile)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -198,7 +421,7 @@ class SplitTicketSyncDispatchService
|
|||||||
|
|
||||||
$inner = implode('; ', $parts);
|
$inner = implode('; ', $parts);
|
||||||
|
|
||||||
return $this->runBackgroundCommand($root, $inner, $ticketIds, 'node-serial');
|
return $this->runBackgroundCommand($root, $inner, $ticketIds, $source . '-node-serial');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -214,7 +437,7 @@ class SplitTicketSyncDispatchService
|
|||||||
|
|
||||||
if ($this->canUseExec()) {
|
if ($this->canUseExec()) {
|
||||||
@exec($command, $output, $exitCode);
|
@exec($command, $output, $exitCode);
|
||||||
SplitTicketSyncLogger::log('web', 'cli spawn exec', [
|
SplitTicketSyncLogger::log('dispatch', 'cli spawn exec', [
|
||||||
'ticketRef' => $ticketRef,
|
'ticketRef' => $ticketRef,
|
||||||
'mode' => $mode,
|
'mode' => $mode,
|
||||||
'command' => $command,
|
'command' => $command,
|
||||||
@@ -225,7 +448,7 @@ class SplitTicketSyncDispatchService
|
|||||||
|
|
||||||
if ($this->canUseShellExec()) {
|
if ($this->canUseShellExec()) {
|
||||||
@shell_exec($command);
|
@shell_exec($command);
|
||||||
SplitTicketSyncLogger::log('web', 'cli spawn shell_exec', [
|
SplitTicketSyncLogger::log('dispatch', 'cli spawn shell_exec', [
|
||||||
'ticketRef' => $ticketRef,
|
'ticketRef' => $ticketRef,
|
||||||
'mode' => $mode,
|
'mode' => $mode,
|
||||||
'command' => $command,
|
'command' => $command,
|
||||||
@@ -233,7 +456,7 @@ class SplitTicketSyncDispatchService
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
SplitTicketSyncLogger::log('web', 'cli spawn failed: exec disabled', [
|
SplitTicketSyncLogger::log('dispatch', 'cli spawn failed: exec disabled', [
|
||||||
'ticketRef' => $ticketRef,
|
'ticketRef' => $ticketRef,
|
||||||
'mode' => $mode,
|
'mode' => $mode,
|
||||||
]);
|
]);
|
||||||
@@ -333,4 +556,12 @@ class SplitTicketSyncDispatchService
|
|||||||
{
|
{
|
||||||
return function_exists('shell_exec') && !in_array('shell_exec', $this->disabledFunctions(), true);
|
return function_exists('shell_exec') && !in_array('shell_exec', $this->disabledFunctions(), true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 投递 CLI 时携带 --mode,供 sync_success_mode 区分自动/手动
|
||||||
|
*/
|
||||||
|
private function normalizeSyncModeForCli(string $source): string
|
||||||
|
{
|
||||||
|
return in_array($source, ['auto', 'manual'], true) ? $source : 'manual';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,11 +21,14 @@ class SplitTicketSyncService
|
|||||||
|
|
||||||
private SplitTicketSyncLockService $lockService;
|
private SplitTicketSyncLockService $lockService;
|
||||||
|
|
||||||
|
private SplitTicketSyncDispatchService $dispatchService;
|
||||||
|
|
||||||
public function __construct()
|
public function __construct()
|
||||||
{
|
{
|
||||||
$this->numberSync = new SplitTicketNumberSyncService();
|
$this->numberSync = new SplitTicketNumberSyncService();
|
||||||
$this->ruleService = new SplitTicketRuleService();
|
$this->ruleService = new SplitTicketRuleService();
|
||||||
$this->lockService = new SplitTicketSyncLockService();
|
$this->lockService = new SplitTicketSyncLockService();
|
||||||
|
$this->dispatchService = new SplitTicketSyncDispatchService();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -126,7 +129,7 @@ class SplitTicketSyncService
|
|||||||
);
|
);
|
||||||
|
|
||||||
$successCount = count(array_filter($processed, static function (array $row): bool {
|
$successCount = count(array_filter($processed, static function (array $row): bool {
|
||||||
return !empty($row['success']);
|
return !empty($row['dispatched']);
|
||||||
}));
|
}));
|
||||||
$failedCount = count($processed) - $successCount;
|
$failedCount = count($processed) - $successCount;
|
||||||
|
|
||||||
@@ -151,7 +154,7 @@ class SplitTicketSyncService
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 直连 PHP 通道:按周期同步,不占 Node 名额
|
* 直连 PHP 通道:挑选到期工单并投递后台 CLI,不占 Node 名额
|
||||||
*
|
*
|
||||||
* @param list<string> $directTypes
|
* @param list<string> $directTypes
|
||||||
* @return array{
|
* @return array{
|
||||||
@@ -171,16 +174,10 @@ class SplitTicketSyncService
|
|||||||
$list = $this->loadOpenTicketsForTypes($directTypes, $failThreshold, 0);
|
$list = $this->loadOpenTicketsForTypes($directTypes, $failThreshold, 0);
|
||||||
$candidateIds = $this->extractTicketIds($list);
|
$candidateIds = $this->extractTicketIds($list);
|
||||||
|
|
||||||
SplitTicketSyncLogger::logCron('direct channel start', [
|
/** @var list<array<string,mixed>> $dueList */
|
||||||
'types' => $directTypes,
|
$dueList = [];
|
||||||
'candidateCount' => count($list),
|
/** @var list<array<string,mixed>> $skipped */
|
||||||
]);
|
|
||||||
|
|
||||||
/** @var list<array<string, mixed>> $processed */
|
|
||||||
$processed = [];
|
|
||||||
/** @var list<array<string, mixed>> $skipped */
|
|
||||||
$skipped = [];
|
$skipped = [];
|
||||||
$count = 0;
|
|
||||||
|
|
||||||
foreach ($list as $ticket) {
|
foreach ($list as $ticket) {
|
||||||
$ticketId = (int) $ticket['id'];
|
$ticketId = (int) $ticket['id'];
|
||||||
@@ -193,42 +190,60 @@ class SplitTicketSyncService
|
|||||||
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skip, 'direct');
|
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skip, 'direct');
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
$dueList[] = $ticket;
|
||||||
$result = $this->syncOne($ticketId, false, true, 'auto');
|
|
||||||
if (!empty($result['skipped'])) {
|
|
||||||
$skipReason = (string) ($result['message'] ?? '已跳过');
|
|
||||||
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skipReason);
|
|
||||||
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skipReason, 'direct');
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SplitTicketSyncLogger::logCron('direct channel start', [
|
||||||
|
'types' => $directTypes,
|
||||||
|
'candidateCount' => count($list),
|
||||||
|
'dueCount' => count($dueList),
|
||||||
|
]);
|
||||||
|
|
||||||
|
$dispatchResult = $this->dispatchService->dispatchAuto($dueList);
|
||||||
|
$skipped = array_merge($skipped, $this->mapDispatchSkippedToCron($dispatchResult['skipped']));
|
||||||
|
$failed = $this->mapDispatchFailedToCron($dispatchResult['failed']);
|
||||||
|
|
||||||
|
foreach ($failed as $row) {
|
||||||
|
$skipped[] = $row;
|
||||||
|
$this->logCronCandidateSkipped(
|
||||||
|
(int) $row['ticketId'],
|
||||||
|
(string) $row['ticketType'],
|
||||||
|
(string) $row['ticketUrl'],
|
||||||
|
(string) $row['reason'],
|
||||||
|
'direct'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<array<string,mixed>> $processed */
|
||||||
|
$processed = [];
|
||||||
|
foreach ($dispatchResult['queued'] as $row) {
|
||||||
$processed[] = [
|
$processed[] = [
|
||||||
'ticketId' => $ticketId,
|
'ticketId' => (int) ($row['ticketId'] ?? 0),
|
||||||
'ticketType' => $ticketType,
|
'ticketType' => (string) ($row['ticketType'] ?? ''),
|
||||||
'ticketUrl' => $ticketUrl,
|
'ticketUrl' => (string) ($row['ticketUrl'] ?? ''),
|
||||||
'channel' => 'direct',
|
'channel' => 'direct',
|
||||||
'success' => !empty($result['success']),
|
'dispatched' => true,
|
||||||
'message' => (string) ($result['message'] ?? ''),
|
'message' => '已投递后台同步',
|
||||||
];
|
];
|
||||||
$count++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'processedCount' => $count,
|
'processedCount' => count($processed),
|
||||||
'processed' => $processed,
|
'processed' => $processed,
|
||||||
'skipped' => $skipped,
|
'skipped' => $skipped,
|
||||||
'candidateIds' => $candidateIds,
|
'candidateIds' => $candidateIds,
|
||||||
'summary' => [
|
'summary' => [
|
||||||
'channel' => 'direct',
|
'channel' => 'direct',
|
||||||
'processedCount' => $count,
|
'processedCount' => count($processed),
|
||||||
'skippedCount' => count($skipped),
|
'skippedCount' => count($skipped),
|
||||||
|
'dispatchMode' => 'async-cli',
|
||||||
],
|
],
|
||||||
'stoppedByNodeBusy' => false,
|
'stoppedByNodeBusy' => false,
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Node 通道:限流 + 类型公平轮转
|
* Node 通道:限流 + 类型公平轮转 + 异步 CLI 投递
|
||||||
*
|
*
|
||||||
* @param list<string> $nodeTypes
|
* @param list<string> $nodeTypes
|
||||||
* @return array{
|
* @return array{
|
||||||
@@ -269,22 +284,11 @@ class SplitTicketSyncService
|
|||||||
'dueCount' => count($duePool),
|
'dueCount' => count($duePool),
|
||||||
'pickedCount' => count($picked),
|
'pickedCount' => count($picked),
|
||||||
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
|
'nodeQueue' => SplitNodeHealthService::getQueueSnapshot(),
|
||||||
|
'dispatchMode' => 'async-cli',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!SplitNodeHealthService::canAcceptWork()) {
|
/** @var list<array<string,mixed>> $skipped */
|
||||||
SplitTicketSyncLogger::logCron('node channel skip', [
|
|
||||||
'reason' => 'node queue busy',
|
|
||||||
'queue' => SplitNodeHealthService::getQueueSnapshot(),
|
|
||||||
]);
|
|
||||||
$skipped = [];
|
$skipped = [];
|
||||||
foreach ($picked as $ticket) {
|
|
||||||
$skipped[] = $this->buildCronTicketEntry(
|
|
||||||
(int) $ticket['id'],
|
|
||||||
(string) $ticket['ticket_type'],
|
|
||||||
(string) $ticket['ticket_url'],
|
|
||||||
'Node 队列繁忙,本轮未执行'
|
|
||||||
);
|
|
||||||
}
|
|
||||||
foreach ($pool as $ticket) {
|
foreach ($pool as $ticket) {
|
||||||
$ticketId = (int) $ticket['id'];
|
$ticketId = (int) $ticket['id'];
|
||||||
if (isset($pickedIdMap[$ticketId])) {
|
if (isset($pickedIdMap[$ticketId])) {
|
||||||
@@ -302,90 +306,79 @@ class SplitTicketSyncService
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
$dispatchResult = $this->dispatchService->dispatchAuto($picked);
|
||||||
'processedCount' => 0,
|
$skipped = array_merge($skipped, $this->mapDispatchSkippedToCron($dispatchResult['skipped']));
|
||||||
'processed' => [],
|
$failed = $this->mapDispatchFailedToCron($dispatchResult['failed']);
|
||||||
'skipped' => $skipped,
|
|
||||||
'candidateIds' => $candidateIds,
|
|
||||||
'summary' => [
|
|
||||||
'channel' => 'node',
|
|
||||||
'processedCount' => 0,
|
|
||||||
'skippedCount' => count($skipped),
|
|
||||||
'reason' => 'node queue busy',
|
|
||||||
],
|
|
||||||
'stoppedByNodeBusy' => true,
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** @var list<array<string, mixed>> $processed */
|
foreach ($failed as $row) {
|
||||||
$processed = [];
|
$skipped[] = $row;
|
||||||
/** @var list<array<string, mixed>> $skipped */
|
$this->logCronCandidateSkipped(
|
||||||
$skipped = [];
|
(int) $row['ticketId'],
|
||||||
$count = 0;
|
(string) $row['ticketType'],
|
||||||
$stoppedByNodeBusy = false;
|
(string) $row['ticketUrl'],
|
||||||
|
(string) $row['reason'],
|
||||||
foreach ($pool as $ticket) {
|
'node'
|
||||||
$ticketId = (int) $ticket['id'];
|
|
||||||
$ticketType = (string) $ticket['ticket_type'];
|
|
||||||
$ticketUrl = (string) $ticket['ticket_url'];
|
|
||||||
|
|
||||||
if (!isset($pickedIdMap[$ticketId])) {
|
|
||||||
$skip = $this->shouldSkip($ticket);
|
|
||||||
if ($skip !== null) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
$skipped[] = $this->buildCronTicketEntry(
|
|
||||||
$ticketId,
|
|
||||||
$ticketType,
|
|
||||||
$ticketUrl,
|
|
||||||
sprintf('未进入本轮 Node 候选(前%d条到期工单公平轮转)', $candidateLimit)
|
|
||||||
);
|
);
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!SplitNodeHealthService::canAcceptWork()) {
|
|
||||||
$stoppedByNodeBusy = true;
|
|
||||||
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, 'Node 队列繁忙,停止本轮后续同步');
|
|
||||||
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, 'Node 队列繁忙,停止本轮后续同步', 'node');
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$sessionKey = AntiBotConfigBuilder::resolveSessionKey($ticketType, $ticketUrl);
|
|
||||||
$result = $this->syncOne($ticketId, false, true, 'auto');
|
|
||||||
if (!empty($result['skipped'])) {
|
|
||||||
$skipReason = (string) ($result['message'] ?? '已跳过');
|
|
||||||
$skipped[] = $this->buildCronTicketEntry($ticketId, $ticketType, $ticketUrl, $skipReason);
|
|
||||||
$this->logCronCandidateSkipped($ticketId, $ticketType, $ticketUrl, $skipReason, 'node');
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @var list<array<string,mixed>> $processed */
|
||||||
|
$processed = [];
|
||||||
|
foreach ($dispatchResult['queued'] as $row) {
|
||||||
$processed[] = [
|
$processed[] = [
|
||||||
'ticketId' => $ticketId,
|
'ticketId' => (int) ($row['ticketId'] ?? 0),
|
||||||
'ticketType' => $ticketType,
|
'ticketType' => (string) ($row['ticketType'] ?? ''),
|
||||||
'ticketUrl' => $ticketUrl,
|
'ticketUrl' => (string) ($row['ticketUrl'] ?? ''),
|
||||||
'sessionKey' => $sessionKey,
|
'sessionKey' => (string) ($row['sessionKey'] ?? ''),
|
||||||
'channel' => 'node',
|
'channel' => 'node',
|
||||||
'success' => !empty($result['success']),
|
'dispatched' => true,
|
||||||
'message' => (string) ($result['message'] ?? ''),
|
'message' => '已投递后台同步',
|
||||||
];
|
];
|
||||||
$count++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'processedCount' => $count,
|
'processedCount' => count($processed),
|
||||||
'processed' => $processed,
|
'processed' => $processed,
|
||||||
'skipped' => $skipped,
|
'skipped' => $skipped,
|
||||||
'candidateIds' => $candidateIds,
|
'candidateIds' => $candidateIds,
|
||||||
'summary' => [
|
'summary' => [
|
||||||
'channel' => 'node',
|
'channel' => 'node',
|
||||||
'processedCount' => $count,
|
'processedCount' => count($processed),
|
||||||
'skippedCount' => count($skipped),
|
'skippedCount' => count($skipped),
|
||||||
'maxPerRun' => $maxPerRun,
|
'maxPerRun' => $maxPerRun,
|
||||||
|
'dispatchMode' => 'async-cli',
|
||||||
],
|
],
|
||||||
'stoppedByNodeBusy' => $stoppedByNodeBusy,
|
'stoppedByNodeBusy' => $dispatchResult['stoppedByNodeBusy'],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,mixed>> $rows
|
||||||
|
* @return list<array{ticketId:int,ticketType:string,ticketUrl:string,reason:string}>
|
||||||
|
*/
|
||||||
|
private function mapDispatchSkippedToCron(array $rows): array
|
||||||
|
{
|
||||||
|
$result = [];
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$result[] = $this->buildCronTicketEntry(
|
||||||
|
(int) ($row['ticketId'] ?? 0),
|
||||||
|
(string) ($row['ticketType'] ?? ''),
|
||||||
|
(string) ($row['ticketUrl'] ?? ''),
|
||||||
|
(string) ($row['reason'] ?? '已跳过')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param list<array<string,mixed>> $rows
|
||||||
|
* @return list<array{ticketId:int,ticketType:string,ticketUrl:string,reason:string}>
|
||||||
|
*/
|
||||||
|
private function mapDispatchFailedToCron(array $rows): array
|
||||||
|
{
|
||||||
|
return $this->mapDispatchSkippedToCron($rows);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param list<string> $types
|
* @param list<string> $types
|
||||||
* @return list<Ticket>
|
* @return list<Ticket>
|
||||||
|
|||||||
@@ -37,7 +37,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
formatter: Table.api.formatter.content
|
formatter: Table.api.formatter.content
|
||||||
},
|
},
|
||||||
{field: 'ticket_name', title: __('Ticket_name'), operate: 'LIKE'},
|
{field: 'ticket_name', title: __('Ticket_name'), operate: 'LIKE'},
|
||||||
{field: 'number', title: __('Number'), operate: 'LIKE'},
|
{
|
||||||
|
field: 'number',
|
||||||
|
title: __('Number'),
|
||||||
|
operate: 'LIKE',
|
||||||
|
formatter: Controller.api.formatter.numberWithDeferred
|
||||||
|
},
|
||||||
$.extend(
|
$.extend(
|
||||||
{field: 'number_type', title: __('Number_type'), searchList: Config.numberTypeList, formatter: Table.api.formatter.normal},
|
{field: 'number_type', title: __('Number_type'), searchList: Config.numberTypeList, formatter: Table.api.formatter.normal},
|
||||||
searchSelectMeta(false)
|
searchSelectMeta(false)
|
||||||
@@ -52,6 +57,17 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
{field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'},
|
{field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'},
|
||||||
searchSelectMeta(false)
|
searchSelectMeta(false)
|
||||||
),
|
),
|
||||||
|
$.extend(
|
||||||
|
{
|
||||||
|
field: 'ratio_deferred',
|
||||||
|
title: __('Ratio_deferred'),
|
||||||
|
visible: false,
|
||||||
|
searchable: true,
|
||||||
|
operate: '=',
|
||||||
|
searchList: Controller.api.buildRatioDeferredSearchList()
|
||||||
|
},
|
||||||
|
searchSelectMeta(false)
|
||||||
|
),
|
||||||
{
|
{
|
||||||
field: 'createtime',
|
field: 'createtime',
|
||||||
title: __('Createtime'),
|
title: __('Createtime'),
|
||||||
@@ -97,11 +113,17 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
Controller.api.openBatchOperateModal(table);
|
Controller.api.openBatchOperateModal(table);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
table.on('post-body.bs.table', function () {
|
||||||
|
table.find('[data-toggle="tooltip"]').tooltip({container: 'body'});
|
||||||
|
});
|
||||||
|
|
||||||
table.on('post-common-search.bs.table', function (e, tbl) {
|
table.on('post-common-search.bs.table', function (e, tbl) {
|
||||||
Controller.api.initCommonSearchSelectpicker(tbl);
|
Controller.api.initCommonSearchSelectpicker(tbl);
|
||||||
});
|
});
|
||||||
|
|
||||||
Table.api.bindevent(table);
|
Table.api.bindevent(table);
|
||||||
|
Controller.api.bindDeferredFilter(table);
|
||||||
|
Controller.api.bindDeferredCleanup(table);
|
||||||
},
|
},
|
||||||
add: function () {
|
add: function () {
|
||||||
Controller.api.bindevent();
|
Controller.api.bindevent();
|
||||||
@@ -138,6 +160,16 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
}
|
}
|
||||||
return ext;
|
return ext;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* PHP json_encode 会把 ['0'=>..,'1'=>..] 压成数组,FastAdmin 通用搜索会误用文案作 option value
|
||||||
|
*/
|
||||||
|
buildRatioDeferredSearchList: function () {
|
||||||
|
var list = Config.ratioDeferredList;
|
||||||
|
if ($.isArray(list)) {
|
||||||
|
return {0: list[0], 1: list[1]};
|
||||||
|
}
|
||||||
|
return list || {};
|
||||||
|
},
|
||||||
initCommonSearchSelectpicker: function (tbl) {
|
initCommonSearchSelectpicker: function (tbl) {
|
||||||
var $form = $('form.form-commonsearch', tbl.$container);
|
var $form = $('form.form-commonsearch', tbl.$container);
|
||||||
if ($form.length) {
|
if ($form.length) {
|
||||||
@@ -152,7 +184,151 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
: (Config.platformStatusList && Config.platformStatusList[value] ? Config.platformStatusList[value] : value);
|
: (Config.platformStatusList && Config.platformStatusList[value] ? Config.platformStatusList[value] : value);
|
||||||
var cls = value === 'online' ? 'success' : (value === 'offline' ? 'default' : 'warning');
|
var cls = value === 'online' ? 'success' : (value === 'offline' ? 'default' : 'warning');
|
||||||
return '<span class="text-' + cls + '">' + Fast.api.escape(text || '') + '</span>';
|
return '<span class="text-' + cls + '">' + Fast.api.escape(text || '') + '</span>';
|
||||||
|
},
|
||||||
|
isRatioDeferred: function (row) {
|
||||||
|
return parseInt(row.ratio_deferred, 10) === 1 && String(row.status || '') === 'normal';
|
||||||
|
},
|
||||||
|
ratioDeferredTooltip: function (row) {
|
||||||
|
var streak = parseInt(row.no_inbound_click_streak, 10) || 0;
|
||||||
|
var template = __('Ratio deferred tooltip');
|
||||||
|
if (!template || template === 'Ratio deferred tooltip') {
|
||||||
|
template = '连续 %s 次访问无进线增长,已触线下号比率,选号已后置,等待同步裁决';
|
||||||
}
|
}
|
||||||
|
return template.indexOf('%s') >= 0 ? template.replace('%s', String(streak)) : template;
|
||||||
|
},
|
||||||
|
ratioDeferredBadgeHtml: function (tooltip) {
|
||||||
|
var label = __('Ratio deferred badge');
|
||||||
|
if (!label || label === 'Ratio deferred badge') {
|
||||||
|
label = '降权中';
|
||||||
|
}
|
||||||
|
return '<span class="label label-warning split-ratio-deferred-badge" style="margin-left:6px;font-size:11px;font-weight:500;vertical-align:middle;"'
|
||||||
|
+ ' data-toggle="tooltip" data-placement="top" title="' + Fast.api.escape(tooltip) + '">'
|
||||||
|
+ '<i class="fa fa-level-down"></i> ' + Fast.api.escape(label)
|
||||||
|
+ '</span>';
|
||||||
|
},
|
||||||
|
numberWithDeferred: function (value, row) {
|
||||||
|
var html = Fast.api.escape(String(value || ''));
|
||||||
|
if (Controller.api.formatter.isRatioDeferred(row)) {
|
||||||
|
html += Controller.api.formatter.ratioDeferredBadgeHtml(
|
||||||
|
Controller.api.formatter.ratioDeferredTooltip(row)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
refreshSearchSelect: function ($field) {
|
||||||
|
if ($field.length && $field.hasClass('selectpicker') && $field.data('selectpicker')) {
|
||||||
|
$field.selectpicker('refresh');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
syncStatusTabs: function (table, statusValue) {
|
||||||
|
var $tabs = table.closest('.panel-intro').find('.panel-heading [data-field="status"]');
|
||||||
|
if (!$tabs.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$tabs.find('li').removeClass('active');
|
||||||
|
$tabs.find('li a[data-value="' + (statusValue === null || statusValue === undefined ? '' : statusValue) + '"]')
|
||||||
|
.parent()
|
||||||
|
.addClass('active');
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 工具栏「降权中」快捷筛选:与列表徽章一致(ratio_deferred=1 且 status=normal)
|
||||||
|
*/
|
||||||
|
bindDeferredFilter: function (table) {
|
||||||
|
var active = false;
|
||||||
|
var savedStatus = '';
|
||||||
|
var $btn = $('.btn-filter-ratio-deferred');
|
||||||
|
if (!$btn.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var applyFilter = function () {
|
||||||
|
var $form = table.closest('.bootstrap-table').find('form.form-commonsearch');
|
||||||
|
if (!$form.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var $ratioField = $form.find('[name="ratio_deferred"]');
|
||||||
|
var $statusField = $form.find('[name="status"]');
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
savedStatus = $statusField.length ? String($statusField.val() || '') : '';
|
||||||
|
if ($ratioField.length) {
|
||||||
|
$ratioField.val('1');
|
||||||
|
Controller.api.refreshSearchSelect($ratioField);
|
||||||
|
}
|
||||||
|
if ($statusField.length) {
|
||||||
|
$statusField.val('normal');
|
||||||
|
Controller.api.refreshSearchSelect($statusField);
|
||||||
|
}
|
||||||
|
Controller.api.syncStatusTabs(table, 'normal');
|
||||||
|
} else {
|
||||||
|
if ($ratioField.length) {
|
||||||
|
$ratioField.val('');
|
||||||
|
Controller.api.refreshSearchSelect($ratioField);
|
||||||
|
}
|
||||||
|
if ($statusField.length) {
|
||||||
|
$statusField.val(savedStatus);
|
||||||
|
Controller.api.refreshSearchSelect($statusField);
|
||||||
|
}
|
||||||
|
Controller.api.syncStatusTabs(table, savedStatus);
|
||||||
|
savedStatus = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
table.trigger('uncheckbox');
|
||||||
|
$form.trigger('submit');
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
$btn.on('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
active = !active;
|
||||||
|
$btn.toggleClass('btn-warning', active).toggleClass('btn-default', !active);
|
||||||
|
if (!applyFilter()) {
|
||||||
|
active = !active;
|
||||||
|
$btn.toggleClass('btn-warning', active).toggleClass('btn-default', !active);
|
||||||
|
Toastr.warning('筛选表单未就绪,请刷新页面后重试');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 清理无效降权池:无落地页访问基线却标记 ratio_deferred=1 的号码
|
||||||
|
*/
|
||||||
|
bindDeferredCleanup: function (table) {
|
||||||
|
var $btn = $('.btn-cleanup-ratio-deferred');
|
||||||
|
if (!$btn.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$btn.on('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
Fast.api.ajax({
|
||||||
|
url: 'split.number/cleanupdeferredpreview',
|
||||||
|
type: 'get'
|
||||||
|
}, function (data) {
|
||||||
|
var count = parseInt(data.count, 10) || 0;
|
||||||
|
if (count <= 0) {
|
||||||
|
Toastr.info(__('Ratio deferred cleanup none'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var template = __('Ratio deferred cleanup confirm');
|
||||||
|
if (!template || template === 'Ratio deferred cleanup confirm') {
|
||||||
|
template = '将清除 %d 条无效降权记录。确定继续?';
|
||||||
|
}
|
||||||
|
var message = template.indexOf('%d') >= 0
|
||||||
|
? template.replace('%d', String(count))
|
||||||
|
: template;
|
||||||
|
Layer.confirm(message, {icon: 3, title: __('Warning')}, function (index) {
|
||||||
|
Layer.close(index);
|
||||||
|
Fast.api.ajax({
|
||||||
|
url: 'split.number/cleanupdeferred',
|
||||||
|
type: 'post'
|
||||||
|
}, function (data, ret) {
|
||||||
|
table.bootstrapTable('refresh');
|
||||||
|
Toastr.success(ret.msg || __('Ratio deferred cleanup success'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
bindevent: function () {
|
bindevent: function () {
|
||||||
Form.api.bindevent($('form[role=form]'));
|
Form.api.bindevent($('form[role=form]'));
|
||||||
|
|||||||
@@ -37,7 +37,12 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
formatter: Table.api.formatter.content
|
formatter: Table.api.formatter.content
|
||||||
},
|
},
|
||||||
{field: 'ticket_name', title: __('Ticket_name'), operate: 'LIKE'},
|
{field: 'ticket_name', title: __('Ticket_name'), operate: 'LIKE'},
|
||||||
{field: 'number', title: __('Number'), operate: 'LIKE'},
|
{
|
||||||
|
field: 'number',
|
||||||
|
title: __('Number'),
|
||||||
|
operate: 'LIKE',
|
||||||
|
formatter: Controller.api.formatter.numberWithDeferred
|
||||||
|
},
|
||||||
$.extend(
|
$.extend(
|
||||||
{field: 'number_type', title: __('Number_type'), searchList: Config.numberTypeList, formatter: Table.api.formatter.normal},
|
{field: 'number_type', title: __('Number_type'), searchList: Config.numberTypeList, formatter: Table.api.formatter.normal},
|
||||||
searchSelectMeta(false)
|
searchSelectMeta(false)
|
||||||
@@ -52,6 +57,17 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
{field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'},
|
{field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.toggle, yes: 'normal', no: 'hidden'},
|
||||||
searchSelectMeta(false)
|
searchSelectMeta(false)
|
||||||
),
|
),
|
||||||
|
$.extend(
|
||||||
|
{
|
||||||
|
field: 'ratio_deferred',
|
||||||
|
title: __('Ratio_deferred'),
|
||||||
|
visible: false,
|
||||||
|
searchable: true,
|
||||||
|
operate: '=',
|
||||||
|
searchList: Controller.api.buildRatioDeferredSearchList()
|
||||||
|
},
|
||||||
|
searchSelectMeta(false)
|
||||||
|
),
|
||||||
{
|
{
|
||||||
field: 'createtime',
|
field: 'createtime',
|
||||||
title: __('Createtime'),
|
title: __('Createtime'),
|
||||||
@@ -97,11 +113,17 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
Controller.api.openBatchOperateModal(table);
|
Controller.api.openBatchOperateModal(table);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
table.on('post-body.bs.table', function () {
|
||||||
|
table.find('[data-toggle="tooltip"]').tooltip({container: 'body'});
|
||||||
|
});
|
||||||
|
|
||||||
table.on('post-common-search.bs.table', function (e, tbl) {
|
table.on('post-common-search.bs.table', function (e, tbl) {
|
||||||
Controller.api.initCommonSearchSelectpicker(tbl);
|
Controller.api.initCommonSearchSelectpicker(tbl);
|
||||||
});
|
});
|
||||||
|
|
||||||
Table.api.bindevent(table);
|
Table.api.bindevent(table);
|
||||||
|
Controller.api.bindDeferredFilter(table);
|
||||||
|
Controller.api.bindDeferredCleanup(table);
|
||||||
},
|
},
|
||||||
add: function () {
|
add: function () {
|
||||||
Controller.api.bindevent();
|
Controller.api.bindevent();
|
||||||
@@ -138,6 +160,16 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
}
|
}
|
||||||
return ext;
|
return ext;
|
||||||
},
|
},
|
||||||
|
/**
|
||||||
|
* PHP json_encode 会把 ['0'=>..,'1'=>..] 压成数组,FastAdmin 通用搜索会误用文案作 option value
|
||||||
|
*/
|
||||||
|
buildRatioDeferredSearchList: function () {
|
||||||
|
var list = Config.ratioDeferredList;
|
||||||
|
if ($.isArray(list)) {
|
||||||
|
return {0: list[0], 1: list[1]};
|
||||||
|
}
|
||||||
|
return list || {};
|
||||||
|
},
|
||||||
initCommonSearchSelectpicker: function (tbl) {
|
initCommonSearchSelectpicker: function (tbl) {
|
||||||
var $form = $('form.form-commonsearch', tbl.$container);
|
var $form = $('form.form-commonsearch', tbl.$container);
|
||||||
if ($form.length) {
|
if ($form.length) {
|
||||||
@@ -152,7 +184,151 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
|
|||||||
: (Config.platformStatusList && Config.platformStatusList[value] ? Config.platformStatusList[value] : value);
|
: (Config.platformStatusList && Config.platformStatusList[value] ? Config.platformStatusList[value] : value);
|
||||||
var cls = value === 'online' ? 'success' : (value === 'offline' ? 'default' : 'warning');
|
var cls = value === 'online' ? 'success' : (value === 'offline' ? 'default' : 'warning');
|
||||||
return '<span class="text-' + cls + '">' + Fast.api.escape(text || '') + '</span>';
|
return '<span class="text-' + cls + '">' + Fast.api.escape(text || '') + '</span>';
|
||||||
|
},
|
||||||
|
isRatioDeferred: function (row) {
|
||||||
|
return parseInt(row.ratio_deferred, 10) === 1 && String(row.status || '') === 'normal';
|
||||||
|
},
|
||||||
|
ratioDeferredTooltip: function (row) {
|
||||||
|
var streak = parseInt(row.no_inbound_click_streak, 10) || 0;
|
||||||
|
var template = __('Ratio deferred tooltip');
|
||||||
|
if (!template || template === 'Ratio deferred tooltip') {
|
||||||
|
template = '连续 %s 次访问无进线增长,已触线下号比率,选号已后置,等待同步裁决';
|
||||||
}
|
}
|
||||||
|
return template.indexOf('%s') >= 0 ? template.replace('%s', String(streak)) : template;
|
||||||
|
},
|
||||||
|
ratioDeferredBadgeHtml: function (tooltip) {
|
||||||
|
var label = __('Ratio deferred badge');
|
||||||
|
if (!label || label === 'Ratio deferred badge') {
|
||||||
|
label = '降权中';
|
||||||
|
}
|
||||||
|
return '<span class="label label-warning split-ratio-deferred-badge" style="margin-left:6px;font-size:11px;font-weight:500;vertical-align:middle;"'
|
||||||
|
+ ' data-toggle="tooltip" data-placement="top" title="' + Fast.api.escape(tooltip) + '">'
|
||||||
|
+ '<i class="fa fa-level-down"></i> ' + Fast.api.escape(label)
|
||||||
|
+ '</span>';
|
||||||
|
},
|
||||||
|
numberWithDeferred: function (value, row) {
|
||||||
|
var html = Fast.api.escape(String(value || ''));
|
||||||
|
if (Controller.api.formatter.isRatioDeferred(row)) {
|
||||||
|
html += Controller.api.formatter.ratioDeferredBadgeHtml(
|
||||||
|
Controller.api.formatter.ratioDeferredTooltip(row)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
refreshSearchSelect: function ($field) {
|
||||||
|
if ($field.length && $field.hasClass('selectpicker') && $field.data('selectpicker')) {
|
||||||
|
$field.selectpicker('refresh');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
syncStatusTabs: function (table, statusValue) {
|
||||||
|
var $tabs = table.closest('.panel-intro').find('.panel-heading [data-field="status"]');
|
||||||
|
if (!$tabs.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$tabs.find('li').removeClass('active');
|
||||||
|
$tabs.find('li a[data-value="' + (statusValue === null || statusValue === undefined ? '' : statusValue) + '"]')
|
||||||
|
.parent()
|
||||||
|
.addClass('active');
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 工具栏「降权中」快捷筛选:与列表徽章一致(ratio_deferred=1 且 status=normal)
|
||||||
|
*/
|
||||||
|
bindDeferredFilter: function (table) {
|
||||||
|
var active = false;
|
||||||
|
var savedStatus = '';
|
||||||
|
var $btn = $('.btn-filter-ratio-deferred');
|
||||||
|
if (!$btn.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var applyFilter = function () {
|
||||||
|
var $form = table.closest('.bootstrap-table').find('form.form-commonsearch');
|
||||||
|
if (!$form.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var $ratioField = $form.find('[name="ratio_deferred"]');
|
||||||
|
var $statusField = $form.find('[name="status"]');
|
||||||
|
|
||||||
|
if (active) {
|
||||||
|
savedStatus = $statusField.length ? String($statusField.val() || '') : '';
|
||||||
|
if ($ratioField.length) {
|
||||||
|
$ratioField.val('1');
|
||||||
|
Controller.api.refreshSearchSelect($ratioField);
|
||||||
|
}
|
||||||
|
if ($statusField.length) {
|
||||||
|
$statusField.val('normal');
|
||||||
|
Controller.api.refreshSearchSelect($statusField);
|
||||||
|
}
|
||||||
|
Controller.api.syncStatusTabs(table, 'normal');
|
||||||
|
} else {
|
||||||
|
if ($ratioField.length) {
|
||||||
|
$ratioField.val('');
|
||||||
|
Controller.api.refreshSearchSelect($ratioField);
|
||||||
|
}
|
||||||
|
if ($statusField.length) {
|
||||||
|
$statusField.val(savedStatus);
|
||||||
|
Controller.api.refreshSearchSelect($statusField);
|
||||||
|
}
|
||||||
|
Controller.api.syncStatusTabs(table, savedStatus);
|
||||||
|
savedStatus = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
table.trigger('uncheckbox');
|
||||||
|
$form.trigger('submit');
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
$btn.on('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
active = !active;
|
||||||
|
$btn.toggleClass('btn-warning', active).toggleClass('btn-default', !active);
|
||||||
|
if (!applyFilter()) {
|
||||||
|
active = !active;
|
||||||
|
$btn.toggleClass('btn-warning', active).toggleClass('btn-default', !active);
|
||||||
|
Toastr.warning('筛选表单未就绪,请刷新页面后重试');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
},
|
||||||
|
/**
|
||||||
|
* 清理无效降权池:无落地页访问基线却标记 ratio_deferred=1 的号码
|
||||||
|
*/
|
||||||
|
bindDeferredCleanup: function (table) {
|
||||||
|
var $btn = $('.btn-cleanup-ratio-deferred');
|
||||||
|
if (!$btn.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
$btn.on('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
Fast.api.ajax({
|
||||||
|
url: 'split.number/cleanupdeferredpreview',
|
||||||
|
type: 'get'
|
||||||
|
}, function (data) {
|
||||||
|
var count = parseInt(data.count, 10) || 0;
|
||||||
|
if (count <= 0) {
|
||||||
|
Toastr.info(__('Ratio deferred cleanup none'));
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
var template = __('Ratio deferred cleanup confirm');
|
||||||
|
if (!template || template === 'Ratio deferred cleanup confirm') {
|
||||||
|
template = '将清除 %d 条无效降权记录。确定继续?';
|
||||||
|
}
|
||||||
|
var message = template.indexOf('%d') >= 0
|
||||||
|
? template.replace('%d', String(count))
|
||||||
|
: template;
|
||||||
|
Layer.confirm(message, {icon: 3, title: __('Warning')}, function (index) {
|
||||||
|
Layer.close(index);
|
||||||
|
Fast.api.ajax({
|
||||||
|
url: 'split.number/cleanupdeferred',
|
||||||
|
type: 'post'
|
||||||
|
}, function (data, ret) {
|
||||||
|
table.bootstrapTable('refresh');
|
||||||
|
Toastr.success(ret.msg || __('Ratio deferred cleanup success'));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
});
|
||||||
},
|
},
|
||||||
bindevent: function () {
|
bindevent: function () {
|
||||||
Form.api.bindevent($('form[role=form]'));
|
Form.api.bindevent($('form[role=form]'));
|
||||||
|
|||||||
Reference in New Issue
Block a user