完整版V1 加入爬虫功能
This commit is contained in:
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\admin\command;
|
||||
|
||||
use app\common\service\SplitTicketSyncService;
|
||||
use think\console\Command;
|
||||
use think\console\Input;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
/**
|
||||
* 工单云控定时同步 CLI
|
||||
*
|
||||
* 用法:
|
||||
* php think split:sync-tickets
|
||||
* php think split:sync-tickets --ticket=12
|
||||
*/
|
||||
class SplitSyncTickets extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName('split:sync-tickets')
|
||||
->addOption('ticket', 't', Option::VALUE_OPTIONAL, '指定工单 ID(强制同步,忽略周期)', '')
|
||||
->setDescription('同步分流工单云控数据');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output): void
|
||||
{
|
||||
set_time_limit(0);
|
||||
$service = new SplitTicketSyncService();
|
||||
$ticketId = trim((string) $input->getOption('ticket'));
|
||||
|
||||
if ($ticketId !== '' && ctype_digit($ticketId)) {
|
||||
$result = $service->syncOne((int) $ticketId, true);
|
||||
if (!empty($result['skipped'])) {
|
||||
$output->writeln('<comment>跳过: ' . ($result['message'] ?? '') . '</comment>');
|
||||
return;
|
||||
}
|
||||
if ($result['success']) {
|
||||
$output->writeln('<info>工单 #' . $ticketId . ' 同步成功</info>');
|
||||
} else {
|
||||
$output->writeln('<error>工单 #' . $ticketId . ' 同步失败: ' . ($result['message'] ?? '') . '</error>');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$count = $service->syncDueTickets();
|
||||
$output->writeln('<info>本次处理工单数: ' . $count . '</info>');
|
||||
}
|
||||
}
|
||||
@@ -58,6 +58,7 @@ class Number extends Backend
|
||||
$this->assignconfig('numberTypeList', $this->model->getNumberTypeList());
|
||||
$this->assignconfig('statusList', $this->model->getStatusList());
|
||||
$this->assignconfig('manualManageList', $this->model->getManualManageList());
|
||||
$this->assignconfig('platformStatusList', $this->model->getPlatformStatusList());
|
||||
|
||||
$this->setupPatchFrontend();
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@ namespace app\admin\controller\split;
|
||||
use app\admin\model\split\Link as LinkModel;
|
||||
use app\admin\model\split\Ticket as TicketModel;
|
||||
use app\common\controller\Backend;
|
||||
use app\common\service\SplitTicketRuleService;
|
||||
use app\common\service\SplitTicketSyncService;
|
||||
use think\Db;
|
||||
use think\Lang;
|
||||
use think\Loader;
|
||||
use think\Exception;
|
||||
use think\exception\DbException;
|
||||
use think\exception\PDOException;
|
||||
@@ -45,11 +49,6 @@ class Ticket extends Backend
|
||||
|
||||
$lang = $this->request->langset();
|
||||
$lang = preg_match('/^([a-zA-Z\-_]{2,10})$/i', $lang) ? $lang : 'zh-cn';
|
||||
$langFile = ROOT_PATH . 'patches/application/admin/lang/' . $lang . '/split/ticket.php';
|
||||
if (is_file($langFile)) {
|
||||
\think\Lang::load($langFile);
|
||||
}
|
||||
|
||||
$this->model = new \app\admin\model\split\Ticket();
|
||||
$this->view->assign('ticketTypeList', $this->model->getTicketTypeList());
|
||||
$this->view->assign('numberTypeList', $this->model->getNumberTypeList());
|
||||
@@ -62,6 +61,32 @@ class Ticket extends Backend
|
||||
$this->setupPatchFrontend();
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载工单语言包(优先 patches)
|
||||
*/
|
||||
protected function loadlang($name): void
|
||||
{
|
||||
$lang = $this->request->langset();
|
||||
$lang = preg_match('/^([a-zA-Z\-_]{2,10})$/i', $lang) ? $lang : 'zh-cn';
|
||||
$name = Loader::parseName($name);
|
||||
$name = preg_match('/^([a-zA-Z0-9_\.\/]+)$/i', $name) ? $name : 'index';
|
||||
|
||||
$files = [
|
||||
APP_PATH . 'admin/lang/' . $lang . '/' . str_replace('.', '/', $name) . '.php',
|
||||
ROOT_PATH . 'patches/application/admin/lang/' . $lang . '/split/ticket.php',
|
||||
];
|
||||
$loaded = false;
|
||||
foreach ($files as $file) {
|
||||
if (is_file($file)) {
|
||||
Lang::load($file);
|
||||
$loaded = true;
|
||||
}
|
||||
}
|
||||
if (!$loaded) {
|
||||
parent::loadlang($name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 未部署 JS 时指向 script 接口
|
||||
*/
|
||||
@@ -146,10 +171,9 @@ class Ticket extends Backend
|
||||
[$where, $sort, $order, $offset, $limit] = $this->buildparams();
|
||||
$ticketTable = $this->model->getTable();
|
||||
$clickSub = TicketModel::buildClickCountSubQuery();
|
||||
// 勿 with(splitLink):eagerlyType=0 为 JOIN 预载入,会重写 field 导致子查询 SQL 语法错误
|
||||
$list = $this->model
|
||||
->field($ticketTable . '.*')
|
||||
->fieldRaw($clickSub)
|
||||
->with(['splitLink'])
|
||||
->field($ticketTable . '.*,' . $clickSub)
|
||||
->where($where)
|
||||
->order($sort, $order)
|
||||
->paginate($limit);
|
||||
@@ -177,11 +201,95 @@ class Ticket extends Backend
|
||||
$params['sync_status'],
|
||||
$params['sync_time'],
|
||||
$params['sync_message'],
|
||||
$params['sync_fail_count'],
|
||||
$params['speed_snapshot_count'],
|
||||
$params['speed_snapshot_time'],
|
||||
$params['click_count']
|
||||
);
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动同步选中工单
|
||||
*/
|
||||
public function sync(): void
|
||||
{
|
||||
if (!$this->request->isPost()) {
|
||||
$this->error(__('Invalid parameters'));
|
||||
}
|
||||
$ids = $this->request->post('ids', '');
|
||||
if ($ids === '') {
|
||||
$this->error(__('Parameter %s can not be empty', 'ids'));
|
||||
}
|
||||
$adminIds = $this->getDataLimitAdminIds();
|
||||
$pk = $this->model->getPk();
|
||||
$list = $this->model->where($pk, 'in', $ids)->select();
|
||||
if (!$list || count($list) === 0) {
|
||||
$this->error(__('No Results were found'));
|
||||
}
|
||||
|
||||
$service = new SplitTicketSyncService();
|
||||
$ok = 0;
|
||||
$fail = 0;
|
||||
$messages = [];
|
||||
|
||||
foreach ($list as $row) {
|
||||
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
|
||||
$fail++;
|
||||
$messages[] = '#' . $row['id'] . ': 无权限';
|
||||
continue;
|
||||
}
|
||||
$result = $service->syncOne((int) $row['id'], true);
|
||||
if ($result['success']) {
|
||||
$ok++;
|
||||
} else {
|
||||
$fail++;
|
||||
$messages[] = '#' . $row['id'] . ': ' . ($result['message'] ?? '失败');
|
||||
}
|
||||
}
|
||||
|
||||
$summary = sprintf('成功 %d 条,失败 %d 条', $ok, $fail);
|
||||
if ($fail > 0) {
|
||||
$summary .= ';' . implode(';', array_slice($messages, 0, 5));
|
||||
}
|
||||
$this->success($summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新:工单状态变更时联动号码
|
||||
*
|
||||
* @param string $ids
|
||||
*/
|
||||
public function multi($ids = '')
|
||||
{
|
||||
if (!$this->request->isPost()) {
|
||||
$this->error(__('Invalid parameters'));
|
||||
}
|
||||
$ids = $ids ?: $this->request->post('ids', '');
|
||||
if ($ids === '') {
|
||||
$this->error(__('Parameter %s can not be empty', 'ids'));
|
||||
}
|
||||
|
||||
$params = $this->request->post('params', '');
|
||||
parse_str((string) $params, $values);
|
||||
$ruleService = new SplitTicketRuleService();
|
||||
|
||||
if (isset($values['status'])) {
|
||||
$pk = $this->model->getPk();
|
||||
$rows = $this->model->where($pk, 'in', $ids)->select();
|
||||
parent::multi($ids);
|
||||
foreach ($rows as $row) {
|
||||
$fresh = $this->model->get($row['id']);
|
||||
if ($fresh) {
|
||||
$ruleService->syncNumbersWithTicketStatus($fresh);
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
parent::multi($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
* @throws DbException
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
return [
|
||||
'Id' => '号码ID',
|
||||
'Link_url' => '链接URL',
|
||||
'Link_url' => '分流链接',
|
||||
'Ticket_name' => '工单名称',
|
||||
'Number' => '号码',
|
||||
'Numbers' => '号码',
|
||||
@@ -13,6 +13,10 @@ return [
|
||||
'Inbound_count' => '进线人数',
|
||||
'Status' => '状态',
|
||||
'Manual_manage' => '手动管理',
|
||||
'Platform_status' => '平台状态',
|
||||
'Platform status online' => '在线',
|
||||
'Platform status offline' => '离线',
|
||||
'Platform status unknown' => '未知',
|
||||
'Createtime' => '创建时间',
|
||||
'Section basic' => '基础信息',
|
||||
'Status normal' => '正常',
|
||||
|
||||
@@ -24,6 +24,9 @@ return [
|
||||
'Number_count' => '号码数量',
|
||||
'Number_count_detail' => '离线 %s / 封号 %s',
|
||||
'Sync_status' => '同步状态',
|
||||
'Sync_status_btn' => '同步状态',
|
||||
'Sync running' => '正在同步,请稍候…',
|
||||
'Sync done' => '同步完成',
|
||||
'Sync status success' => '同步成功',
|
||||
'Sync status error' => '同步异常',
|
||||
'Sync status pending' => '待同步',
|
||||
|
||||
@@ -26,6 +26,7 @@ class Number extends Model
|
||||
'link_url_text',
|
||||
'status_text',
|
||||
'manual_manage_text',
|
||||
'platform_status_text',
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -65,6 +66,18 @@ class Number extends Model
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getPlatformStatusList(): array
|
||||
{
|
||||
return [
|
||||
'online' => __('Platform status online'),
|
||||
'offline' => __('Platform status offline'),
|
||||
'unknown' => __('Platform status unknown'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联分流链接
|
||||
*/
|
||||
@@ -94,17 +107,19 @@ class Number extends Model
|
||||
return $list[$type] ?? $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表展示用:仅显示分流链接码(非完整 URL)
|
||||
*/
|
||||
public function getLinkUrlTextAttr($value, $data): string
|
||||
{
|
||||
if ($value !== '' && $value !== null) {
|
||||
return (string) $value;
|
||||
if (isset($data['split_link']) && is_array($data['split_link'])) {
|
||||
return (string) ($data['split_link']['link_code'] ?? '');
|
||||
}
|
||||
$linkId = (int) ($data['split_link_id'] ?? 0);
|
||||
if ($linkId <= 0) {
|
||||
return '';
|
||||
}
|
||||
$code = Link::where('id', $linkId)->value('link_code');
|
||||
return self::buildLinkUrl((string) $code);
|
||||
return (string) Link::where('id', $linkId)->value('link_code');
|
||||
}
|
||||
|
||||
public function getStatusTextAttr($value, $data): string
|
||||
@@ -121,6 +136,13 @@ class Number extends Model
|
||||
return $list[$key] ?? $key;
|
||||
}
|
||||
|
||||
public function getPlatformStatusTextAttr($value, $data): string
|
||||
{
|
||||
$key = (string) ($data['platform_status'] ?? 'unknown');
|
||||
$list = $this->getPlatformStatusList();
|
||||
return $list[$key] ?? $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据链接码生成完整分流 URL
|
||||
*/
|
||||
|
||||
@@ -98,7 +98,8 @@ class Ticket extends Model
|
||||
*/
|
||||
public function splitLink()
|
||||
{
|
||||
return $this->belongsTo(Link::class, 'split_link_id', 'id', [], 'LEFT')->setEagerlyType(0);
|
||||
// IN 预载入:避免 eagerlyType=0(JOIN) 与列表 field 子查询冲突导致 SQL 1064
|
||||
return $this->belongsTo(Link::class, 'split_link_id', 'id', [], 'LEFT')->setEagerlyType(1);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -126,7 +127,7 @@ class Ticket extends Model
|
||||
$total = (int) ($data['ticket_total'] ?? 0);
|
||||
$complete = (int) ($data['complete_count'] ?? 0);
|
||||
if ($total <= 0) {
|
||||
return '—';
|
||||
return '0%';
|
||||
}
|
||||
$percent = round($complete / $total * 100, 2);
|
||||
return $percent . '%';
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
<a href="javascript:;" class="btn btn-success btn-add {:$auth->check('split.ticket/add')?'':'hide'}" title="{:__('Add')}"><i class="fa fa-plus"></i> {:__('Add')}</a>
|
||||
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.ticket/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.ticket/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
|
||||
<a href="javascript:;" class="btn btn-info btn-sync btn-disabled disabled {:$auth->check('split.ticket/sync')?'':'hide'}" title="{:__('Sync_status_btn')}"><i class="fa fa-refresh"></i> {:__('Sync_status_btn')}</a>
|
||||
</div>
|
||||
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
||||
data-operate-edit="{:$auth->check('split.ticket/edit')}"
|
||||
|
||||
@@ -17,4 +17,5 @@ return [
|
||||
'app\admin\command\Min',
|
||||
'app\admin\command\Addon',
|
||||
'app\admin\command\Api',
|
||||
'app\admin\command\SplitSyncTickets',
|
||||
];
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\scrm;
|
||||
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* 云控蜘蛛抽象基类(Node Headless 拦截 + 翻页 + 清洗)
|
||||
*/
|
||||
abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
||||
{
|
||||
public const MODE_FETCH = 'fetch';
|
||||
|
||||
public const MODE_UI = 'ui_click';
|
||||
|
||||
protected string $nodeHost;
|
||||
|
||||
public function __construct(string $nodeHost = 'http://127.0.0.1:3001')
|
||||
{
|
||||
$this->nodeHost = rtrim($nodeHost, '/');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
abstract protected function getSpiderConfig(): array;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $countData
|
||||
*/
|
||||
abstract protected function extractListTotalPages($listFirstPageData, $countData = null);
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
abstract protected function buildListPageParams(int $page): array;
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
abstract protected function getUiPaginationConfig(): array;
|
||||
|
||||
/**
|
||||
* @param mixed $detailData
|
||||
* @param array<int, mixed> $allListPagesData
|
||||
*/
|
||||
abstract protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData;
|
||||
|
||||
public function run(): UnifiedScrmData
|
||||
{
|
||||
$config = $this->getSpiderConfig();
|
||||
|
||||
$listApi = (string) ($config['listApi'] ?? '');
|
||||
$detailApi = $config['detailApi'] ?? null;
|
||||
$countApi = $config['countApi'] ?? null;
|
||||
|
||||
$apiUrlsToIntercept = [$listApi];
|
||||
if ($detailApi) {
|
||||
$apiUrlsToIntercept[] = $detailApi;
|
||||
}
|
||||
if ($countApi) {
|
||||
$apiUrlsToIntercept[] = $countApi;
|
||||
}
|
||||
|
||||
$initResult = $this->requestNode('/api/auth-and-intercept', [
|
||||
'pageUrl' => $config['pageUrl'],
|
||||
'apiUrls' => $apiUrlsToIntercept,
|
||||
'authActions' => $config['authActions'] ?? [],
|
||||
]);
|
||||
|
||||
if (empty($initResult['success'])) {
|
||||
throw new Exception('初始化失败: ' . ($initResult['error'] ?? '未知'));
|
||||
}
|
||||
|
||||
$interceptedApis = $initResult['interceptedApis'];
|
||||
$cookies = $initResult['cookies'];
|
||||
|
||||
if (!isset($interceptedApis[$listApi])) {
|
||||
throw new Exception("致命错误:未能拦截到必须的列表接口 [{$listApi}]");
|
||||
}
|
||||
|
||||
$detailData = $detailApi && isset($interceptedApis[$detailApi])
|
||||
? $interceptedApis[$detailApi]['data'] : null;
|
||||
$countData = $countApi && isset($interceptedApis[$countApi])
|
||||
? $interceptedApis[$countApi]['data'] : null;
|
||||
|
||||
$listApiNode = $interceptedApis[$listApi];
|
||||
$allListPagesData = [$listApiNode['data']];
|
||||
|
||||
$totalPages = $this->extractListTotalPages($listApiNode['data'], $countData);
|
||||
$mode = $config['paginationMode'] ?? self::MODE_FETCH;
|
||||
|
||||
if ($totalPages > 1 || $totalPages === null) {
|
||||
if ($mode === self::MODE_FETCH && $totalPages !== null) {
|
||||
$paramList = [];
|
||||
for ($page = 2; $page <= $totalPages; $page++) {
|
||||
$paramList[] = $this->buildListPageParams($page);
|
||||
}
|
||||
|
||||
$fetchResult = $this->requestNode('/api/batch-fetch', [
|
||||
'tasks' => [[
|
||||
'apiPath' => $listApi,
|
||||
'fullUrl' => $listApiNode['url'],
|
||||
'headers' => $listApiNode['headers'] ?? '',
|
||||
'paramList' => $paramList,
|
||||
'method' => $config['listMethod'] ?? 'GET',
|
||||
]],
|
||||
'cookies' => $cookies,
|
||||
], 120);
|
||||
|
||||
if (!empty($fetchResult['success'])) {
|
||||
foreach ($fetchResult['results'][$listApi] as $pResult) {
|
||||
if (!empty($pResult['success'])) {
|
||||
$allListPagesData[] = $pResult['data'];
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($mode === self::MODE_UI) {
|
||||
$uiConfig = $this->getUiPaginationConfig();
|
||||
$firstPageData = $listApiNode['data'];
|
||||
$clicksToPerform = ($totalPages === null) ? 9999 : ($totalPages - 1);
|
||||
|
||||
$uiResult = $this->requestNode('/api/ui-pagination', [
|
||||
'apiUrl' => $listApi,
|
||||
'pageUrl' => $config['pageUrl'],
|
||||
'nextBtnSelector' => $uiConfig['nextBtnSelector'] ?? '',
|
||||
'waitMs' => $uiConfig['waitMs'] ?? 2000,
|
||||
'clicksToPerform' => $clicksToPerform,
|
||||
'cookies' => $cookies,
|
||||
'firstPageData' => $firstPageData,
|
||||
'authActions' => $config['authActions'] ?? [],
|
||||
], 1200);
|
||||
|
||||
if (!empty($uiResult['success']) && !empty($uiResult['data'])) {
|
||||
foreach ($uiResult['data'] as $pageData) {
|
||||
$allListPagesData[] = $pageData;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->parseToUnifiedData($detailData, $allListPagesData);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function requestNode(string $endpoint, array $payload, int $timeout = 60): array
|
||||
{
|
||||
$ch = curl_init($this->nodeHost . $endpoint);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
|
||||
$response = curl_exec($ch);
|
||||
if (curl_errno($ch)) {
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
throw new Exception($err);
|
||||
}
|
||||
curl_close($ch);
|
||||
$decoded = json_decode((string) $response, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\scrm;
|
||||
|
||||
/**
|
||||
* 云控蜘蛛统一接口
|
||||
*/
|
||||
interface ScrmSpiderInterface
|
||||
{
|
||||
/**
|
||||
* 执行抓取并返回统一数据
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function run(): UnifiedScrmData;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\scrm;
|
||||
|
||||
/**
|
||||
* 云控蜘蛛统一返回数据结构
|
||||
*/
|
||||
class UnifiedScrmData
|
||||
{
|
||||
/** @var int 今日新增(完成数量) */
|
||||
public int $todayNewCount = 0;
|
||||
|
||||
/** @var int 在线号码数 */
|
||||
public int $totalOnline = 0;
|
||||
|
||||
/** @var int 离线号码数 */
|
||||
public int $totalOffline = 0;
|
||||
|
||||
/** @var array<int, array{number:string,status:string,newFollowersToday:int}> */
|
||||
public array $numbers = [];
|
||||
|
||||
/** @var int 号码总数 */
|
||||
public int $total = 0;
|
||||
|
||||
/**
|
||||
* @param string $number 号码
|
||||
* @param bool $isOnline 是否在线
|
||||
* @param int $newFollowersToday 今日进线
|
||||
*/
|
||||
public function addNumber(string $number, bool $isOnline, int $newFollowersToday = 0): void
|
||||
{
|
||||
$this->numbers[] = [
|
||||
'number' => $number,
|
||||
'status' => $isOnline ? 'online' : 'offline',
|
||||
'newFollowersToday' => max(0, $newFollowersToday),
|
||||
];
|
||||
|
||||
if ($isOnline) {
|
||||
$this->totalOnline++;
|
||||
} else {
|
||||
$this->totalOffline++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\scrm\spider;
|
||||
|
||||
use app\common\library\scrm\AbstractScrmSpider;
|
||||
use app\common\library\scrm\UnifiedScrmData;
|
||||
|
||||
/**
|
||||
* A2C 云控蜘蛛
|
||||
*/
|
||||
class A2cSpider extends AbstractScrmSpider
|
||||
{
|
||||
private const API_LIST = '/api/talk/counter/share/record/list';
|
||||
|
||||
private const API_DETAILS = '/api/talk/counter/share/detail';
|
||||
|
||||
private const DEFAULT_PER_PAGE_COUNT = 20;
|
||||
|
||||
private string $pageUrl;
|
||||
|
||||
private string $account;
|
||||
|
||||
private string $password;
|
||||
|
||||
private UnifiedScrmData $unifiedData;
|
||||
|
||||
public function __construct(
|
||||
string $pageUrl,
|
||||
string $account = '',
|
||||
string $password = '',
|
||||
string $nodeHost = 'http://127.0.0.1:3001'
|
||||
) {
|
||||
parent::__construct($nodeHost);
|
||||
$this->pageUrl = $pageUrl;
|
||||
$this->account = $account;
|
||||
$this->password = $password;
|
||||
$this->unifiedData = new UnifiedScrmData();
|
||||
}
|
||||
|
||||
protected function getSpiderConfig(): array
|
||||
{
|
||||
return [
|
||||
'pageUrl' => $this->pageUrl,
|
||||
'listApi' => self::API_LIST,
|
||||
'detailApi' => self::API_DETAILS,
|
||||
'listMethod' => 'POST',
|
||||
'paginationMode' => self::MODE_UI,
|
||||
'authActions' => [
|
||||
['type' => 'wait', 'ms' => 2000],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
||||
{
|
||||
$total = (int) ($listFirstPageData['data']['total'] ?? 0);
|
||||
$this->unifiedData->total = $total;
|
||||
if ($total <= self::DEFAULT_PER_PAGE_COUNT) {
|
||||
return 1;
|
||||
}
|
||||
return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT);
|
||||
}
|
||||
|
||||
protected function buildListPageParams(int $page): array
|
||||
{
|
||||
return ['page' => $page, 'pageSize' => self::DEFAULT_PER_PAGE_COUNT];
|
||||
}
|
||||
|
||||
protected function getUiPaginationConfig(): array
|
||||
{
|
||||
return [
|
||||
'nextBtnSelector' => '.btn-next',
|
||||
'waitMs' => 2000,
|
||||
];
|
||||
}
|
||||
|
||||
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
|
||||
{
|
||||
$unifiedData = $this->unifiedData;
|
||||
if ($detailData) {
|
||||
$unifiedData->todayNewCount = (int) ($detailData['data']['newFollowersToday'] ?? 0);
|
||||
}
|
||||
foreach ($allListPagesData as $pageRaw) {
|
||||
$records = $pageRaw['data']['rows'] ?? [];
|
||||
foreach ($records as $item) {
|
||||
if (empty($item['account'])) {
|
||||
continue;
|
||||
}
|
||||
$number = (string) $item['account'];
|
||||
$isOnline = isset($item['numberStatus']) && (int) $item['numberStatus'] === 1;
|
||||
$unifiedData->addNumber($number, $isOnline, (int) ($item['newFollowersToday'] ?? 0));
|
||||
}
|
||||
}
|
||||
return $unifiedData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\scrm\spider;
|
||||
|
||||
use app\common\library\scrm\AbstractScrmSpider;
|
||||
use app\common\library\scrm\UnifiedScrmData;
|
||||
|
||||
/**
|
||||
* 海王云控蜘蛛
|
||||
*/
|
||||
class HaiwangSpider extends AbstractScrmSpider
|
||||
{
|
||||
private const API_LIST = '/webApi/accountshow/list';
|
||||
|
||||
private const DEFAULT_PER_PAGE_COUNT = 10;
|
||||
|
||||
private string $pageUrl;
|
||||
|
||||
private string $account;
|
||||
|
||||
private string $password;
|
||||
|
||||
private UnifiedScrmData $unifiedData;
|
||||
|
||||
public function __construct(
|
||||
string $pageUrl,
|
||||
string $account = '',
|
||||
string $password = '',
|
||||
string $nodeHost = 'http://127.0.0.1:3001'
|
||||
) {
|
||||
parent::__construct($nodeHost);
|
||||
$this->pageUrl = $pageUrl;
|
||||
$this->account = $account;
|
||||
$this->password = $password;
|
||||
$this->unifiedData = new UnifiedScrmData();
|
||||
}
|
||||
|
||||
protected function getSpiderConfig(): array
|
||||
{
|
||||
return [
|
||||
'pageUrl' => $this->pageUrl,
|
||||
'listApi' => self::API_LIST,
|
||||
'listMethod' => 'POST',
|
||||
'paginationMode' => self::MODE_UI,
|
||||
'authActions' => [
|
||||
['type' => 'type', 'selector' => 'input[type="password"]', 'value' => $this->password],
|
||||
['type' => 'press', 'key' => 'Enter'],
|
||||
['type' => 'wait', 'ms' => 2000],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
||||
{
|
||||
$total = (int) ($listFirstPageData['data']['total'] ?? 0);
|
||||
$this->unifiedData->total = $total;
|
||||
if ($total <= self::DEFAULT_PER_PAGE_COUNT) {
|
||||
return 1;
|
||||
}
|
||||
return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT);
|
||||
}
|
||||
|
||||
protected function buildListPageParams(int $page): array
|
||||
{
|
||||
return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT];
|
||||
}
|
||||
|
||||
protected function getUiPaginationConfig(): array
|
||||
{
|
||||
return [
|
||||
'nextBtnSelector' => '.btn-next',
|
||||
'waitMs' => 2000,
|
||||
];
|
||||
}
|
||||
|
||||
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
|
||||
{
|
||||
$unifiedData = $this->unifiedData;
|
||||
foreach ($allListPagesData as $pageRaw) {
|
||||
$records = $pageRaw['data']['items'] ?? [];
|
||||
foreach ($records as $item) {
|
||||
if (empty($item['acclist_account'])) {
|
||||
continue;
|
||||
}
|
||||
$number = (string) $item['acclist_account'];
|
||||
$isOnline = isset($item['acclist_status']) && (int) $item['acclist_status'] === 2;
|
||||
$unifiedData->addNumber(
|
||||
$number,
|
||||
$isOnline,
|
||||
(int) ($item['account_statistics_today_effective'] ?? 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!empty($allListPagesData[0]['data']['shareStatistics']['sharecode_statistics_today_contact_effective'])) {
|
||||
$unifiedData->todayNewCount = (int) $allListPagesData[0]['data']['shareStatistics']['sharecode_statistics_today_contact_effective'];
|
||||
}
|
||||
return $unifiedData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\scrm\spider;
|
||||
|
||||
use app\common\library\scrm\AbstractScrmSpider;
|
||||
use app\common\library\scrm\UnifiedScrmData;
|
||||
|
||||
/**
|
||||
* 火箭云控蜘蛛
|
||||
*/
|
||||
class HuojianSpider extends AbstractScrmSpider
|
||||
{
|
||||
private const API_LIST = '/prod-api1/biz/counter/link/share/';
|
||||
|
||||
private string $pageUrl;
|
||||
|
||||
private string $account;
|
||||
|
||||
private string $password;
|
||||
|
||||
private UnifiedScrmData $unifiedData;
|
||||
|
||||
public function __construct(
|
||||
string $pageUrl,
|
||||
string $account = '',
|
||||
string $password = '',
|
||||
string $nodeHost = 'http://127.0.0.1:3001'
|
||||
) {
|
||||
parent::__construct($nodeHost);
|
||||
$this->pageUrl = $pageUrl;
|
||||
$this->account = $account;
|
||||
$this->password = $password;
|
||||
$this->unifiedData = new UnifiedScrmData();
|
||||
}
|
||||
|
||||
protected function getSpiderConfig(): array
|
||||
{
|
||||
return [
|
||||
'pageUrl' => $this->pageUrl,
|
||||
'listApi' => self::API_LIST,
|
||||
'listMethod' => 'POST',
|
||||
'paginationMode' => self::MODE_UI,
|
||||
'authActions' => [
|
||||
['type' => 'vue_fill', 'selector' => '.el-message-box__input input', 'value' => $this->password],
|
||||
['type' => 'wait', 'ms' => 500],
|
||||
['type' => 'vue_click', 'selector' => '.el-message-box__btns .el-button--primary'],
|
||||
['type' => 'wait', 'ms' => 2000],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
protected function buildListPageParams(int $page): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function getUiPaginationConfig(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
|
||||
{
|
||||
$unifiedData = $this->unifiedData;
|
||||
$unifiedData->todayNewCount = (int) ($allListPagesData[0]['data']['counterWorker']['newTodayFriend'] ?? 0);
|
||||
$count = 0;
|
||||
foreach ($allListPagesData as $pageRaw) {
|
||||
$records = $pageRaw['data']['counterCsAccountVo'] ?? [];
|
||||
foreach ($records as $item) {
|
||||
if (empty($item['accountLogin'])) {
|
||||
continue;
|
||||
}
|
||||
$number = (string) $item['accountLogin'];
|
||||
$isOnline = isset($item['accountStatus']) && (int) $item['accountStatus'] === 1;
|
||||
$count++;
|
||||
$unifiedData->addNumber($number, $isOnline, (int) ($item['newTodayFriend'] ?? 0));
|
||||
}
|
||||
}
|
||||
$unifiedData->total = $count;
|
||||
return $unifiedData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\scrm\spider;
|
||||
|
||||
use app\common\library\scrm\AbstractScrmSpider;
|
||||
use app\common\library\scrm\UnifiedScrmData;
|
||||
|
||||
/**
|
||||
* SS云控(Customer) 蜘蛛
|
||||
*/
|
||||
class SsCustomerSpider extends AbstractScrmSpider
|
||||
{
|
||||
private const API_LIST = '/sys/share/report/get-customer-analysis-dimension-list';
|
||||
|
||||
private const API_DETAILS = '/sys/share/report/get-customer-analysis-statistics';
|
||||
|
||||
private const DEFAULT_PER_PAGE_COUNT = 20;
|
||||
|
||||
private string $pageUrl;
|
||||
|
||||
private string $account;
|
||||
|
||||
private string $password;
|
||||
|
||||
private UnifiedScrmData $unifiedData;
|
||||
|
||||
public function __construct(
|
||||
string $pageUrl,
|
||||
string $account = '',
|
||||
string $password = '',
|
||||
string $nodeHost = 'http://127.0.0.1:3001'
|
||||
) {
|
||||
parent::__construct($nodeHost);
|
||||
$this->pageUrl = $pageUrl;
|
||||
$this->account = $account;
|
||||
$this->password = $password;
|
||||
$this->unifiedData = new UnifiedScrmData();
|
||||
}
|
||||
|
||||
protected function getSpiderConfig(): array
|
||||
{
|
||||
return [
|
||||
'pageUrl' => $this->pageUrl,
|
||||
'listApi' => self::API_LIST,
|
||||
'detailApi' => self::API_DETAILS,
|
||||
'listMethod' => 'POST',
|
||||
'paginationMode' => self::MODE_UI,
|
||||
'authActions' => [
|
||||
['type' => 'wait', 'ms' => 2000],
|
||||
['type' => 'type', 'selector' => 'input[type="password"]', 'value' => $this->password],
|
||||
['type' => 'press', 'key' => 'Enter'],
|
||||
['type' => 'wait', 'ms' => 3000],
|
||||
[
|
||||
'type' => 'vue_click',
|
||||
'selector' => 'button[class*="reports-customers__dimension"]',
|
||||
'text' => 'social media accounts',
|
||||
],
|
||||
['type' => 'wait', 'ms' => 3000],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function buildListPageParams(int $page): array
|
||||
{
|
||||
return ['page' => $page, 'pageSize' => self::DEFAULT_PER_PAGE_COUNT];
|
||||
}
|
||||
|
||||
protected function getUiPaginationConfig(): array
|
||||
{
|
||||
return [
|
||||
'nextBtnSelector' => '.arco-pagination-item-next',
|
||||
'waitMs' => 2000,
|
||||
];
|
||||
}
|
||||
|
||||
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
|
||||
{
|
||||
$unifiedData = $this->unifiedData;
|
||||
if ($detailData) {
|
||||
$unifiedData->todayNewCount = (int) ($detailData['data']['distinct_contacts_total'] ?? 0);
|
||||
}
|
||||
foreach ($allListPagesData as $pageRaw) {
|
||||
$records = $pageRaw['data']['list'] ?? [];
|
||||
foreach ($records as $item) {
|
||||
if (empty($item['channel_tag'])) {
|
||||
continue;
|
||||
}
|
||||
$number = (string) $item['channel_tag'];
|
||||
$unifiedData->addNumber($number, true, (int) ($item['distinct_contacts_total'] ?? 0));
|
||||
}
|
||||
}
|
||||
$unifiedData->total = count($unifiedData->numbers);
|
||||
return $unifiedData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\scrm\spider;
|
||||
|
||||
use app\common\library\scrm\AbstractScrmSpider;
|
||||
use app\common\library\scrm\UnifiedScrmData;
|
||||
|
||||
/**
|
||||
* 星河云控蜘蛛
|
||||
*/
|
||||
class XingheSpider extends AbstractScrmSpider
|
||||
{
|
||||
private const API_LIST = '/share/share/api_yinliu_count.html';
|
||||
|
||||
private const DEFAULT_PER_PAGE_COUNT = 10;
|
||||
|
||||
private string $pageUrl;
|
||||
|
||||
private string $account;
|
||||
|
||||
private string $password;
|
||||
|
||||
private UnifiedScrmData $unifiedData;
|
||||
|
||||
public function __construct(
|
||||
string $pageUrl,
|
||||
string $account = '',
|
||||
string $password = '',
|
||||
string $nodeHost = 'http://127.0.0.1:3001'
|
||||
) {
|
||||
parent::__construct($nodeHost);
|
||||
$this->pageUrl = $pageUrl;
|
||||
$this->account = $account;
|
||||
$this->password = $password;
|
||||
$this->unifiedData = new UnifiedScrmData();
|
||||
}
|
||||
|
||||
protected function getSpiderConfig(): array
|
||||
{
|
||||
return [
|
||||
'pageUrl' => $this->pageUrl,
|
||||
'listApi' => self::API_LIST,
|
||||
'listMethod' => 'GET',
|
||||
'paginationMode' => self::MODE_FETCH,
|
||||
'authActions' => [
|
||||
['type' => 'wait', 'ms' => 2000],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
||||
{
|
||||
$total = (int) ($listFirstPageData['count'] ?? 0);
|
||||
$this->unifiedData->total = $total;
|
||||
$this->unifiedData->todayNewCount = (int) ($listFirstPageData['totalRow']['day_sum'] ?? 0);
|
||||
if ($total <= self::DEFAULT_PER_PAGE_COUNT) {
|
||||
return 1;
|
||||
}
|
||||
return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT);
|
||||
}
|
||||
|
||||
protected function buildListPageParams(int $page): array
|
||||
{
|
||||
return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT];
|
||||
}
|
||||
|
||||
protected function getUiPaginationConfig(): array
|
||||
{
|
||||
return [
|
||||
'nextBtnSelector' => '.layui-laypage-next',
|
||||
'waitMs' => 2000,
|
||||
];
|
||||
}
|
||||
|
||||
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
|
||||
{
|
||||
$unifiedData = $this->unifiedData;
|
||||
foreach ($allListPagesData as $pageRaw) {
|
||||
$records = $pageRaw['data'] ?? [];
|
||||
foreach ($records as $item) {
|
||||
if (empty($item['user'])) {
|
||||
continue;
|
||||
}
|
||||
$number = (string) $item['user'];
|
||||
$isOnline = isset($item['online']) && (int) $item['online'] === 1;
|
||||
$unifiedData->addNumber($number, $isOnline, (int) ($item['day_sum'] ?? 0));
|
||||
}
|
||||
}
|
||||
return $unifiedData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\library\scrm\ScrmSpiderInterface;
|
||||
use app\common\library\scrm\spider\A2cSpider;
|
||||
use app\common\library\scrm\spider\HaiwangSpider;
|
||||
use app\common\library\scrm\spider\HuojianSpider;
|
||||
use app\common\library\scrm\spider\SsCustomerSpider;
|
||||
use app\common\library\scrm\spider\XingheSpider;
|
||||
|
||||
/**
|
||||
* 工单类型 -> 云控蜘蛛工厂
|
||||
*
|
||||
* 新增云控类型:在 spider/ 下新增类并在此注册 ticket_type => Class
|
||||
*/
|
||||
class SplitScrmSpiderFactory
|
||||
{
|
||||
/** @var array<string, class-string<ScrmSpiderInterface>> */
|
||||
private const MAP = [
|
||||
'a2c' => A2cSpider::class,
|
||||
'haiwang' => HaiwangSpider::class,
|
||||
'huojian' => HuojianSpider::class,
|
||||
'xinghe' => XingheSpider::class,
|
||||
'ss_customer' => SsCustomerSpider::class,
|
||||
// ceo_scrm 等未实现类型:新增 spider 类后在此注册
|
||||
];
|
||||
|
||||
/**
|
||||
* @return class-string<ScrmSpiderInterface>|null
|
||||
*/
|
||||
public static function resolveClass(string $ticketType): ?string
|
||||
{
|
||||
$ticketType = trim($ticketType);
|
||||
return self::MAP[$ticketType] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已实现蜘蛛
|
||||
*/
|
||||
public static function isSupported(string $ticketType): bool
|
||||
{
|
||||
return self::resolveClass($ticketType) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ScrmSpiderInterface|null
|
||||
*/
|
||||
public static function create(
|
||||
string $ticketType,
|
||||
string $pageUrl,
|
||||
string $account = '',
|
||||
string $password = '',
|
||||
string $nodeHost = ''
|
||||
): ?ScrmSpiderInterface {
|
||||
$class = self::resolveClass($ticketType);
|
||||
if ($class === null) {
|
||||
return null;
|
||||
}
|
||||
$host = $nodeHost !== '' ? $nodeHost : SplitSyncConfigService::getNodeHost();
|
||||
return new $class($pageUrl, $account, $password, $host);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use think\Config;
|
||||
use think\Db;
|
||||
|
||||
/**
|
||||
* 工单云控同步相关系统配置读取
|
||||
*/
|
||||
class SplitSyncConfigService
|
||||
{
|
||||
private const DEFAULT_NODE_HOST = 'http://127.0.0.1:3001';
|
||||
|
||||
/**
|
||||
* Node Headless 服务根地址
|
||||
*/
|
||||
public static function getNodeHost(): string
|
||||
{
|
||||
$value = self::getConfigValue('split_scrm_node_host');
|
||||
$value = trim($value);
|
||||
return $value !== '' ? rtrim($value, '/') : self::DEFAULT_NODE_HOST;
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定工单类型的自动同步周期(分钟),0 表示不自动同步
|
||||
*/
|
||||
public static function getIntervalMinutes(string $ticketType): int
|
||||
{
|
||||
$ticketType = trim($ticketType);
|
||||
if ($ticketType === '') {
|
||||
return 0;
|
||||
}
|
||||
$key = 'split_sync_interval_' . $ticketType;
|
||||
$value = self::getConfigValue($key);
|
||||
return max(0, (int) $value);
|
||||
}
|
||||
|
||||
private static function getConfigValue(string $name): string
|
||||
{
|
||||
$site = Config::get('site.' . $name);
|
||||
if ($site !== null && $site !== '') {
|
||||
return (string) $site;
|
||||
}
|
||||
$db = Db::name('config')->where('name', $name)->value('value');
|
||||
return $db !== null ? (string) $db : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\admin\model\split\Number;
|
||||
use app\admin\model\split\Ticket;
|
||||
use app\common\library\scrm\UnifiedScrmData;
|
||||
use think\Db;
|
||||
|
||||
/**
|
||||
* 工单同步结果写入号码表
|
||||
*/
|
||||
class SplitTicketNumberSyncService
|
||||
{
|
||||
/**
|
||||
* 将蜘蛛返回的号码列表同步到号码管理
|
||||
*/
|
||||
public function syncFromUnifiedData(Ticket $ticket, UnifiedScrmData $data): void
|
||||
{
|
||||
$adminId = (int) $ticket['admin_id'];
|
||||
$linkId = (int) $ticket['split_link_id'];
|
||||
$ticketName = (string) $ticket['ticket_name'];
|
||||
if ($linkId <= 0 || $ticketName === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$syncedNumbers = [];
|
||||
foreach ($data->numbers as $row) {
|
||||
$number = trim((string) ($row['number'] ?? ''));
|
||||
if ($number === '') {
|
||||
continue;
|
||||
}
|
||||
$syncedNumbers[$number] = $row;
|
||||
}
|
||||
|
||||
$existingList = Number::where('admin_id', $adminId)
|
||||
->where('split_link_id', $linkId)
|
||||
->where('ticket_name', $ticketName)
|
||||
->select();
|
||||
|
||||
$existingMap = [];
|
||||
foreach ($existingList as $item) {
|
||||
$existingMap[(string) $item['number']] = $item;
|
||||
}
|
||||
|
||||
foreach ($syncedNumbers as $number => $row) {
|
||||
$platformStatus = ($row['status'] ?? '') === 'online' ? 'online' : 'offline';
|
||||
$newFollowers = (int) ($row['newFollowersToday'] ?? 0);
|
||||
|
||||
if (isset($existingMap[$number])) {
|
||||
$this->updateExistingNumber($existingMap[$number], $platformStatus, $newFollowers);
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->insertNumber($ticket, $number, $platformStatus, $newFollowers);
|
||||
}
|
||||
|
||||
foreach ($existingMap as $number => $item) {
|
||||
if (isset($syncedNumbers[$number])) {
|
||||
continue;
|
||||
}
|
||||
if ((int) $item['manual_manage'] === 1) {
|
||||
continue;
|
||||
}
|
||||
Number::where('id', (int) $item['id'])->update([
|
||||
'status' => 'hidden',
|
||||
'updatetime' => time(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Number $row
|
||||
*/
|
||||
private function updateExistingNumber($row, string $platformStatus, int $newFollowers): void
|
||||
{
|
||||
$update = [
|
||||
'platform_status' => $platformStatus,
|
||||
'updatetime' => time(),
|
||||
];
|
||||
|
||||
if ((int) $row['manual_manage'] === 1) {
|
||||
Number::where('id', (int) $row['id'])->update($update);
|
||||
return;
|
||||
}
|
||||
|
||||
$update['status'] = $platformStatus === 'online' ? 'normal' : 'hidden';
|
||||
if ($update['status'] === 'normal') {
|
||||
$update['inbound_count'] = max(0, $newFollowers);
|
||||
}
|
||||
|
||||
Number::where('id', (int) $row['id'])->update($update);
|
||||
}
|
||||
|
||||
private function insertNumber(Ticket $ticket, string $number, string $platformStatus, int $newFollowers): void
|
||||
{
|
||||
$status = $platformStatus === 'online' ? 'normal' : 'hidden';
|
||||
$now = time();
|
||||
$data = [
|
||||
'admin_id' => (int) $ticket['admin_id'],
|
||||
'split_link_id' => (int) $ticket['split_link_id'],
|
||||
'ticket_name' => (string) $ticket['ticket_name'],
|
||||
'number' => $number,
|
||||
'number_type' => (string) $ticket['number_type'],
|
||||
'number_type_custom' => (string) ($ticket['number_type_custom'] ?? ''),
|
||||
'visit_count' => 0,
|
||||
'inbound_count' => $status === 'normal' ? max(0, $newFollowers) : 0,
|
||||
'manual_manage' => 0,
|
||||
'platform_status' => $platformStatus,
|
||||
'status' => $status,
|
||||
'createtime' => $now,
|
||||
'updatetime' => $now,
|
||||
];
|
||||
|
||||
try {
|
||||
Db::name('split_number')->insert($data);
|
||||
} catch (\Throwable $e) {
|
||||
$exists = Number::where('split_link_id', (int) $ticket['split_link_id'])
|
||||
->where('number', $number)
|
||||
->find();
|
||||
if ($exists) {
|
||||
$this->updateExistingNumber($exists, $platformStatus, $newFollowers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总工单进线人数(仅开启状态的号码)
|
||||
*/
|
||||
public function sumInboundForTicket(Ticket $ticket): int
|
||||
{
|
||||
$sum = Number::where('admin_id', (int) $ticket['admin_id'])
|
||||
->where('split_link_id', (int) $ticket['split_link_id'])
|
||||
->where('ticket_name', (string) $ticket['ticket_name'])
|
||||
->where('status', 'normal')
|
||||
->sum('inbound_count');
|
||||
return (int) $sum;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\admin\model\split\Number;
|
||||
use app\admin\model\split\Ticket;
|
||||
|
||||
/**
|
||||
* 工单与号码业务规则(单号上限、下号比率、时间窗口、完成量自动开关)
|
||||
*/
|
||||
class SplitTicketRuleService
|
||||
{
|
||||
/**
|
||||
* 同步后应用全部规则并写回工单/号码
|
||||
*/
|
||||
public function applyAfterSync(Ticket $ticket, int $completeCount): void
|
||||
{
|
||||
$this->applyNumberRules($ticket);
|
||||
$this->applyTicketStatusRules($ticket, $completeCount);
|
||||
$this->cascadeTicketClosedToNumbers($ticket);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步流程:工单因时间/完成量等原因关闭时,联动关闭非手动号码
|
||||
*/
|
||||
public function cascadeTicketClosedToNumbers(Ticket $ticket): void
|
||||
{
|
||||
if ((string) ($ticket['status'] ?? 'hidden') !== 'hidden') {
|
||||
return;
|
||||
}
|
||||
Number::where('admin_id', (int) $ticket['admin_id'])
|
||||
->where('split_link_id', (int) $ticket['split_link_id'])
|
||||
->where('ticket_name', (string) $ticket['ticket_name'])
|
||||
->where('manual_manage', 0)
|
||||
->update([
|
||||
'status' => 'hidden',
|
||||
'updatetime' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动切换工单状态时,联动非手动管理的号码(开→全开,关→全关)
|
||||
*/
|
||||
public function syncNumbersWithTicketStatus(Ticket $ticket): void
|
||||
{
|
||||
$ticketStatus = (string) ($ticket['status'] ?? 'hidden');
|
||||
Number::where('admin_id', (int) $ticket['admin_id'])
|
||||
->where('split_link_id', (int) $ticket['split_link_id'])
|
||||
->where('ticket_name', (string) $ticket['ticket_name'])
|
||||
->where('manual_manage', 0)
|
||||
->update([
|
||||
'status' => $ticketStatus,
|
||||
'updatetime' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单号上限、下号比率
|
||||
*/
|
||||
public function applyNumberRules(Ticket $ticket): void
|
||||
{
|
||||
$orderLimit = (int) ($ticket['order_limit'] ?? 0);
|
||||
$assignRatio = (int) ($ticket['assign_ratio'] ?? 0);
|
||||
|
||||
$numbers = Number::where('admin_id', (int) $ticket['admin_id'])
|
||||
->where('split_link_id', (int) $ticket['split_link_id'])
|
||||
->where('ticket_name', (string) $ticket['ticket_name'])
|
||||
->where('manual_manage', 0)
|
||||
->select();
|
||||
|
||||
foreach ($numbers as $number) {
|
||||
$visitCount = (int) $number['visit_count'];
|
||||
$inboundCount = (int) $number['inbound_count'];
|
||||
$lastVisit = (int) ($number['last_sync_visit_count'] ?? 0);
|
||||
$lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0);
|
||||
$streak = (int) ($number['no_inbound_click_streak'] ?? 0);
|
||||
|
||||
if ($visitCount > $lastVisit && $inboundCount <= $lastInbound) {
|
||||
$streak += ($visitCount - $lastVisit);
|
||||
} elseif ($inboundCount > $lastInbound) {
|
||||
$streak = 0;
|
||||
}
|
||||
|
||||
$status = (string) $number['status'];
|
||||
if ($orderLimit > 0 && $inboundCount > $orderLimit) {
|
||||
$status = 'hidden';
|
||||
}
|
||||
if ($assignRatio > 0 && $streak >= $assignRatio) {
|
||||
$status = 'hidden';
|
||||
}
|
||||
|
||||
Number::where('id', (int) $number['id'])->update([
|
||||
'no_inbound_click_streak' => $streak,
|
||||
'last_sync_visit_count' => $visitCount,
|
||||
'last_sync_inbound_count' => $inboundCount,
|
||||
'status' => $status,
|
||||
'updatetime' => time(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成量、时间窗口决定工单开关
|
||||
*/
|
||||
public function applyTicketStatusRules(Ticket $ticket, int $completeCount): void
|
||||
{
|
||||
$status = $this->resolveTicketStatus($ticket, $completeCount);
|
||||
if ($status !== (string) $ticket['status']) {
|
||||
Ticket::where('id', (int) $ticket['id'])->update([
|
||||
'status' => $status,
|
||||
'updatetime' => time(),
|
||||
]);
|
||||
$ticket['status'] = $status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否处于允许同步/开启的时间窗口
|
||||
*/
|
||||
public function isWithinTimeWindow(Ticket $ticket, ?int $now = null): bool
|
||||
{
|
||||
$now = $now ?? time();
|
||||
$start = $ticket['start_time'] ?? null;
|
||||
$end = $ticket['end_time'] ?? null;
|
||||
if ($start !== null && $start !== '' && (int) $start > 0 && $now < (int) $start) {
|
||||
return false;
|
||||
}
|
||||
if ($end !== null && $end !== '' && (int) $end > 0 && $now > (int) $end) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function resolveTicketStatus(Ticket $ticket, int $completeCount): string
|
||||
{
|
||||
if (!$this->isWithinTimeWindow($ticket)) {
|
||||
return 'hidden';
|
||||
}
|
||||
|
||||
$ticketTotal = (int) ($ticket['ticket_total'] ?? 0);
|
||||
if ($ticketTotal > 0) {
|
||||
return $completeCount >= $ticketTotal ? 'hidden' : 'normal';
|
||||
}
|
||||
|
||||
return 'normal';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* 工单同步互斥锁(基于 runtime 文件锁,不依赖 Cache/Redis 扩展)
|
||||
*/
|
||||
class SplitTicketSyncLockService
|
||||
{
|
||||
private const LOCK_TTL = 1800;
|
||||
|
||||
/**
|
||||
* 尝试获取锁
|
||||
*/
|
||||
public function acquire(int $ticketId): bool
|
||||
{
|
||||
$path = $this->lockPath($ticketId);
|
||||
if ($this->isStaleLock($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
if (is_file($path)) {
|
||||
return false;
|
||||
}
|
||||
$payload = json_encode([
|
||||
'ticket_id' => $ticketId,
|
||||
'pid' => getmypid(),
|
||||
'time' => time(),
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
$written = @file_put_contents($path, $payload, LOCK_EX);
|
||||
return $written !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放锁
|
||||
*/
|
||||
public function release(int $ticketId): void
|
||||
{
|
||||
$path = $this->lockPath($ticketId);
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function lockPath(int $ticketId): string
|
||||
{
|
||||
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/');
|
||||
$dir = $runtime . 'split_ticket_sync/';
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0755, true);
|
||||
}
|
||||
return $dir . $ticketId . '.lock';
|
||||
}
|
||||
|
||||
private function isStaleLock(string $path): bool
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
return false;
|
||||
}
|
||||
$mtime = (int) @filemtime($path);
|
||||
return $mtime > 0 && (time() - $mtime) > self::LOCK_TTL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,255 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\admin\model\split\Ticket;
|
||||
use app\common\library\scrm\UnifiedScrmData;
|
||||
use think\Db;
|
||||
use think\Exception;
|
||||
|
||||
/**
|
||||
* 分流工单云控数据同步服务
|
||||
*/
|
||||
class SplitTicketSyncService
|
||||
{
|
||||
private const FAIL_PAUSE_THRESHOLD = 5;
|
||||
|
||||
private SplitTicketNumberSyncService $numberSync;
|
||||
|
||||
private SplitTicketRuleService $ruleService;
|
||||
|
||||
private SplitTicketSyncLockService $lockService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->numberSync = new SplitTicketNumberSyncService();
|
||||
$this->ruleService = new SplitTicketRuleService();
|
||||
$this->lockService = new SplitTicketSyncLockService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步单条工单
|
||||
*
|
||||
* @return array{success:bool,message:string,skipped?:bool}
|
||||
*/
|
||||
public function syncOne(int $ticketId, bool $force = false): array
|
||||
{
|
||||
$ticket = Ticket::get($ticketId);
|
||||
if (!$ticket) {
|
||||
return ['success' => false, 'message' => '工单不存在'];
|
||||
}
|
||||
|
||||
if (!$force) {
|
||||
$skip = $this->shouldSkip($ticket);
|
||||
if ($skip !== null) {
|
||||
return ['success' => false, 'message' => $skip, 'skipped' => true];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->lockService->acquire($ticketId)) {
|
||||
return ['success' => false, 'message' => '工单正在同步中', 'skipped' => true];
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->doSync($ticket);
|
||||
} finally {
|
||||
$this->lockService->release($ticketId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描到期工单并同步
|
||||
*/
|
||||
public function syncDueTickets(): int
|
||||
{
|
||||
$count = 0;
|
||||
$list = Ticket::where('status', 'normal')
|
||||
->where('sync_fail_count', '<', self::FAIL_PAUSE_THRESHOLD)
|
||||
->select();
|
||||
|
||||
foreach ($list as $ticket) {
|
||||
$skip = $this->shouldSkip($ticket);
|
||||
if ($skip !== null) {
|
||||
continue;
|
||||
}
|
||||
$result = $this->syncOne((int) $ticket['id'], false);
|
||||
if (!empty($result['skipped'])) {
|
||||
continue;
|
||||
}
|
||||
$count++;
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{success:bool,message:string}
|
||||
*/
|
||||
private function doSync(Ticket $ticket): array
|
||||
{
|
||||
$ticketType = (string) $ticket['ticket_type'];
|
||||
$pageUrl = trim((string) $ticket['ticket_url']);
|
||||
if ($pageUrl === '') {
|
||||
$this->markFailure($ticket, '工单链接为空');
|
||||
return ['success' => false, 'message' => '工单链接为空'];
|
||||
}
|
||||
|
||||
if (!SplitScrmSpiderFactory::isSupported($ticketType)) {
|
||||
$this->markFailure($ticket, '工单类型尚未实现蜘蛛');
|
||||
return ['success' => false, 'message' => '工单类型尚未实现蜘蛛'];
|
||||
}
|
||||
|
||||
$spider = SplitScrmSpiderFactory::create(
|
||||
$ticketType,
|
||||
$pageUrl,
|
||||
(string) ($ticket['account'] ?? ''),
|
||||
(string) ($ticket['password'] ?? '')
|
||||
);
|
||||
if ($spider === null) {
|
||||
$this->markFailure($ticket, '无法创建蜘蛛实例');
|
||||
return ['success' => false, 'message' => '无法创建蜘蛛实例'];
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
$finalData = $spider->run();
|
||||
if (!$finalData instanceof UnifiedScrmData) {
|
||||
throw new Exception('蜘蛛返回数据无效');
|
||||
}
|
||||
|
||||
$this->numberSync->syncFromUnifiedData($ticket, $finalData);
|
||||
$this->ruleService->applyNumberRules($ticket);
|
||||
|
||||
$completeCount = max(0, $finalData->todayNewCount);
|
||||
$this->ruleService->applyTicketStatusRules($ticket, $completeCount);
|
||||
|
||||
$freshTicket = Ticket::get((int) $ticket['id']);
|
||||
if ($freshTicket) {
|
||||
$this->ruleService->cascadeTicketClosedToNumbers($freshTicket);
|
||||
$ticket = $freshTicket;
|
||||
}
|
||||
|
||||
$inboundCount = $this->numberSync->sumInboundForTicket($ticket);
|
||||
$speed = $this->calcSpeedPerHour($ticket, $completeCount);
|
||||
|
||||
$payload = [
|
||||
'complete_count' => $completeCount,
|
||||
'inbound_count' => $inboundCount,
|
||||
'speed_per_hour' => $speed['speed'],
|
||||
'number_count' => max(0, $finalData->total),
|
||||
'number_offline_count' => max(0, $finalData->totalOffline),
|
||||
'number_banned_count' => 0,
|
||||
'online_count' => max(0, $finalData->totalOnline),
|
||||
'sync_fail_count' => 0,
|
||||
'speed_snapshot_count' => $speed['snapshot_count'],
|
||||
'speed_snapshot_time' => $speed['snapshot_time'],
|
||||
];
|
||||
|
||||
$this->applySyncResult($ticket, $payload, true, '');
|
||||
Db::commit();
|
||||
return ['success' => true, 'message' => '同步成功'];
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
$msg = mb_substr($e->getMessage(), 0, 255, 'UTF-8');
|
||||
$this->markFailure($ticket, $msg);
|
||||
return ['success' => false, 'message' => $msg];
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldSkip(Ticket $ticket): ?string
|
||||
{
|
||||
if ((string) $ticket['status'] === 'hidden') {
|
||||
return '工单已关闭';
|
||||
}
|
||||
if ((int) ($ticket['sync_fail_count'] ?? 0) >= self::FAIL_PAUSE_THRESHOLD) {
|
||||
return '连续同步失败超过5次已暂停';
|
||||
}
|
||||
if (!SplitScrmSpiderFactory::isSupported((string) $ticket['ticket_type'])) {
|
||||
return '工单类型尚未实现';
|
||||
}
|
||||
$interval = SplitSyncConfigService::getIntervalMinutes((string) $ticket['ticket_type']);
|
||||
if ($interval <= 0) {
|
||||
return '该类型未配置自动同步周期';
|
||||
}
|
||||
$lastSync = (int) ($ticket['sync_time'] ?? 0);
|
||||
if ($lastSync > 0 && (time() - $lastSync) < ($interval * 60)) {
|
||||
return '未到同步周期';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function applySyncResult(Ticket $ticket, array $payload, bool $success, string $message = ''): void
|
||||
{
|
||||
$data = [
|
||||
'complete_count' => max(0, (int) ($payload['complete_count'] ?? 0)),
|
||||
'inbound_count' => max(0, (int) ($payload['inbound_count'] ?? 0)),
|
||||
'speed_per_hour' => max(0, (float) ($payload['speed_per_hour'] ?? 0)),
|
||||
'number_count' => max(0, (int) ($payload['number_count'] ?? 0)),
|
||||
'number_offline_count' => max(0, (int) ($payload['number_offline_count'] ?? 0)),
|
||||
'number_banned_count' => max(0, (int) ($payload['number_banned_count'] ?? 0)),
|
||||
'online_count' => max(0, (int) ($payload['online_count'] ?? 0)),
|
||||
'sync_status' => $success ? 'success' : 'error',
|
||||
'sync_time' => time(),
|
||||
'sync_message' => $success ? '' : mb_substr($message, 0, 255, 'UTF-8'),
|
||||
'sync_fail_count' => $success ? 0 : ((int) ($ticket['sync_fail_count'] ?? 0) + 1),
|
||||
'speed_snapshot_count' => (int) ($payload['speed_snapshot_count'] ?? $ticket['speed_snapshot_count'] ?? 0),
|
||||
'speed_snapshot_time' => (int) ($payload['speed_snapshot_time'] ?? $ticket['speed_snapshot_time'] ?? 0),
|
||||
];
|
||||
if (!$ticket->allowField(array_keys($data))->save($data)) {
|
||||
throw new Exception('工单同步结果保存失败');
|
||||
}
|
||||
}
|
||||
|
||||
private function markFailure(Ticket $ticket, string $message): void
|
||||
{
|
||||
$failCount = (int) ($ticket['sync_fail_count'] ?? 0) + 1;
|
||||
$ticket->save([
|
||||
'sync_status' => 'error',
|
||||
'sync_time' => time(),
|
||||
'sync_message' => mb_substr($message, 0, 255, 'UTF-8'),
|
||||
'sync_fail_count' => $failCount,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{speed:float,snapshot_count:int,snapshot_time:int}
|
||||
*/
|
||||
private function calcSpeedPerHour(Ticket $ticket, int $currentComplete): array
|
||||
{
|
||||
$now = time();
|
||||
$snapshotTime = (int) ($ticket['speed_snapshot_time'] ?? 0);
|
||||
$snapshotCount = (int) ($ticket['speed_snapshot_count'] ?? 0);
|
||||
|
||||
if ($snapshotTime <= 0) {
|
||||
return [
|
||||
'speed' => 0.0,
|
||||
'snapshot_count' => $currentComplete,
|
||||
'snapshot_time' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
$elapsed = $now - $snapshotTime;
|
||||
if ($elapsed >= 3600) {
|
||||
return [
|
||||
'speed' => 0.0,
|
||||
'snapshot_count' => $currentComplete,
|
||||
'snapshot_time' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
$hours = $elapsed > 0 ? ($elapsed / 3600) : 0;
|
||||
$delta = $currentComplete - $snapshotCount;
|
||||
$speed = ($delta < 0 || $hours <= 0) ? 0.0 : round($delta / $hours, 2);
|
||||
|
||||
return [
|
||||
'speed' => $speed,
|
||||
'snapshot_count' => $snapshotCount,
|
||||
'snapshot_time' => $snapshotTime,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -42,8 +42,17 @@ return array (
|
||||
'category2' => 'Category2',
|
||||
'custom' => 'Custom',
|
||||
),
|
||||
'split_platform_domain' => 'link1.com
|
||||
link2.com
|
||||
link3.com
|
||||
link4.com',
|
||||
'split_platform_domain' => 'links.test',
|
||||
'split_scrm_node_host' => 'http://127.0.0.1:3001',
|
||||
'split_sync_interval_a2c' => '3',
|
||||
'split_sync_interval_haiwang' => '3',
|
||||
'split_sync_interval_huojian' => '3',
|
||||
'split_sync_interval_xinghe' => '3',
|
||||
'split_sync_interval_ss_custome' => '3',
|
||||
'split_sync_interval_ceo_scrm' => '0',
|
||||
'split_sync_interval_taiji' => '0',
|
||||
'split_sync_interval_ss_channel' => '0',
|
||||
'split_sync_interval_yifafa' => '0',
|
||||
'split_sync_interval_whatshub' => '0',
|
||||
'split_sync_interval_sihai' => '0',
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user