完整版V1 加入爬虫功能

This commit is contained in:
root
2026-06-09 03:36:30 +08:00
parent 34d76cce74
commit a68b83fcbd
69 changed files with 5058 additions and 56 deletions
@@ -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,
];
}
}