修正 Proxied、 SSL/TLS 设为 Flexible、开启 Always Use HTTPS
This commit is contained in:
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\scrm;
|
||||
|
||||
use app\common\service\SplitTicketSyncLogger;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
@@ -47,6 +48,12 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
||||
public function run(): UnifiedScrmData
|
||||
{
|
||||
$config = $this->getSpiderConfig();
|
||||
SplitTicketSyncLogger::log('spider', 'run start', [
|
||||
'nodeHost' => $this->nodeHost,
|
||||
'pageUrl' => $config['pageUrl'] ?? '',
|
||||
'listApi' => $config['listApi'] ?? '',
|
||||
'paginationMode' => $config['paginationMode'] ?? self::MODE_FETCH,
|
||||
]);
|
||||
|
||||
$listApi = (string) ($config['listApi'] ?? '');
|
||||
$detailApi = $config['detailApi'] ?? null;
|
||||
@@ -67,10 +74,16 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
||||
]);
|
||||
|
||||
if (empty($initResult['success'])) {
|
||||
SplitTicketSyncLogger::log('spider', 'auth-and-intercept failed', [
|
||||
'error' => $initResult['error'] ?? '未知',
|
||||
]);
|
||||
throw new Exception('初始化失败: ' . ($initResult['error'] ?? '未知'));
|
||||
}
|
||||
|
||||
$interceptedApis = $initResult['interceptedApis'];
|
||||
SplitTicketSyncLogger::log('spider', 'auth-and-intercept ok', [
|
||||
'intercepted' => array_keys($interceptedApis),
|
||||
]);
|
||||
$cookies = $initResult['cookies'];
|
||||
|
||||
if (!isset($interceptedApis[$listApi])) {
|
||||
@@ -87,6 +100,10 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
||||
|
||||
$totalPages = $this->extractListTotalPages($listApiNode['data'], $countData);
|
||||
$mode = $config['paginationMode'] ?? self::MODE_FETCH;
|
||||
SplitTicketSyncLogger::log('spider', 'pagination plan', [
|
||||
'totalPages' => $totalPages,
|
||||
'mode' => $mode,
|
||||
]);
|
||||
|
||||
if ($totalPages > 1 || $totalPages === null) {
|
||||
if ($mode === self::MODE_FETCH && $totalPages !== null) {
|
||||
@@ -137,7 +154,15 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
||||
}
|
||||
}
|
||||
|
||||
return $this->parseToUnifiedData($detailData, $allListPagesData);
|
||||
$result = $this->parseToUnifiedData($detailData, $allListPagesData);
|
||||
SplitTicketSyncLogger::log('spider', 'run done', [
|
||||
'todayNewCount' => $result->todayNewCount,
|
||||
'totalOnline' => $result->totalOnline,
|
||||
'totalOffline' => $result->totalOffline,
|
||||
'total' => $result->total,
|
||||
'numberCount' => count($result->numbers),
|
||||
]);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -146,20 +171,63 @@ abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
||||
*/
|
||||
protected function requestNode(string $endpoint, array $payload, int $timeout = 60): array
|
||||
{
|
||||
$ch = curl_init($this->nodeHost . $endpoint);
|
||||
$url = $this->nodeHost . $endpoint;
|
||||
$started = microtime(true);
|
||||
SplitTicketSyncLogger::log('node_request', 'POST ' . $endpoint, [
|
||||
'url' => $url,
|
||||
'timeout' => $timeout,
|
||||
'payload' => $payload,
|
||||
]);
|
||||
|
||||
$ch = curl_init($url);
|
||||
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);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$elapsedMs = (int) round((microtime(true) - $started) * 1000);
|
||||
if (curl_errno($ch)) {
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
SplitTicketSyncLogger::log('node_response', 'curl error on ' . $endpoint, [
|
||||
'httpCode' => $httpCode,
|
||||
'elapsedMs' => $elapsedMs,
|
||||
'error' => $err,
|
||||
]);
|
||||
throw new Exception($err);
|
||||
}
|
||||
curl_close($ch);
|
||||
$decoded = json_decode((string) $response, true);
|
||||
$summary = is_array($decoded) ? self::summarizeNodeResponse($decoded) : ['raw' => mb_substr((string) $response, 0, 300, 'UTF-8')];
|
||||
SplitTicketSyncLogger::log('node_response', 'POST ' . $endpoint, array_merge([
|
||||
'httpCode' => $httpCode,
|
||||
'elapsedMs' => $elapsedMs,
|
||||
'responseSize' => strlen((string) $response),
|
||||
], $summary));
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $decoded
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function summarizeNodeResponse(array $decoded): array
|
||||
{
|
||||
$summary = [
|
||||
'success' => $decoded['success'] ?? null,
|
||||
'error' => $decoded['error'] ?? null,
|
||||
];
|
||||
if (isset($decoded['interceptedApis']) && is_array($decoded['interceptedApis'])) {
|
||||
$summary['interceptedApis'] = array_keys($decoded['interceptedApis']);
|
||||
}
|
||||
if (isset($decoded['results']) && is_array($decoded['results'])) {
|
||||
$summary['resultApis'] = array_keys($decoded['results']);
|
||||
}
|
||||
if (isset($decoded['data']) && is_array($decoded['data'])) {
|
||||
$summary['dataPages'] = count($decoded['data']);
|
||||
}
|
||||
return $summary;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,6 +31,10 @@ class UnifiedScrmData
|
||||
*/
|
||||
public function addNumber(string $number, bool $isOnline, int $newFollowersToday = 0): void
|
||||
{
|
||||
$number = trim($number);
|
||||
if ($number === '') {
|
||||
return;
|
||||
}
|
||||
$this->numbers[] = [
|
||||
'number' => $number,
|
||||
'status' => $isOnline ? 'online' : 'offline',
|
||||
|
||||
@@ -80,8 +80,7 @@ class CloudflareService
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合检测三项状态
|
||||
* Zone/NS 检测通过后,自动将根域名 A 记录指向 server_ip 并校验
|
||||
* 聚合检测 Zone/NS/DNS 状态;NS 已验证且 Zone 已激活时,静默校验并修正 A 记录、Proxied、SSL、HTTPS
|
||||
*
|
||||
* @param array $row 域名记录
|
||||
* @return array{zone_status: string, ns_status: string, dns_status: string, check_result: string}
|
||||
@@ -140,12 +139,12 @@ class CloudflareService
|
||||
$dnsStatus = 'failed';
|
||||
$messages[] = 'DNS:server_ip格式无效';
|
||||
} else {
|
||||
try {
|
||||
if ($this->hasRootCnameConflict($zoneId, $domain)) {
|
||||
$dnsStatus = 'failed';
|
||||
$messages[] = 'DNS:根域名存在CNAME记录,无法创建A记录';
|
||||
} else {
|
||||
$this->upsertRootARecord($zoneId, $domain, $this->serverIp);
|
||||
if ($this->hasRootCnameConflict($zoneId, $domain)) {
|
||||
$dnsStatus = 'failed';
|
||||
$messages[] = 'DNS:根域名存在CNAME记录,无法创建A记录';
|
||||
} else {
|
||||
try {
|
||||
$this->reconcileCloudflareConfig($zoneId, $domain, $this->serverIp);
|
||||
if ($this->verifyRootARecord($zoneId, $domain, $this->serverIp)) {
|
||||
$dnsStatus = 'created';
|
||||
$messages[] = 'DNS:已创建(A=' . $this->serverIp . ')';
|
||||
@@ -153,10 +152,10 @@ class CloudflareService
|
||||
$dnsStatus = 'pending';
|
||||
$messages[] = 'DNS:待创建(A记录未指向' . $this->serverIp . ')';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$dnsStatus = 'failed';
|
||||
$messages[] = 'DNS:操作失败(' . $e->getMessage() . ')';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$dnsStatus = 'failed';
|
||||
$messages[] = 'DNS:操作失败(' . $e->getMessage() . ')';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +192,49 @@ class CloudflareService
|
||||
}
|
||||
|
||||
/**
|
||||
* NS 已验证且 Zone 已激活时:读取 Proxied / SSL / HTTPS / A 记录,与期望值不一致则修正
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function reconcileCloudflareConfig(string $zoneId, string $domain, string $ip): void
|
||||
{
|
||||
$this->ensureZoneEdgeSettings($zoneId);
|
||||
$this->upsertRootARecord($zoneId, $domain, $ip);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 Zone 单项设置值
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function getZoneSettingValue(string $zoneId, string $settingId): string
|
||||
{
|
||||
$response = $this->request('GET', '/zones/' . $zoneId . '/settings/' . $settingId);
|
||||
return strtolower(trim((string)($response['result']['value'] ?? '')));
|
||||
}
|
||||
|
||||
/**
|
||||
* SSL/TLS=Flexible、Always Use HTTPS=开启;已与期望一致则跳过 PATCH
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function ensureZoneEdgeSettings(string $zoneId): void
|
||||
{
|
||||
if ($this->getZoneSettingValue($zoneId, 'ssl') !== 'flexible') {
|
||||
$this->request('PATCH', '/zones/' . $zoneId . '/settings/ssl', [
|
||||
'value' => 'flexible',
|
||||
]);
|
||||
}
|
||||
if ($this->getZoneSettingValue($zoneId, 'always_use_https') !== 'on') {
|
||||
$this->request('PATCH', '/zones/' . $zoneId . '/settings/always_use_https', [
|
||||
'value' => 'on',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建或更新根域名 A 记录(IP 与 Proxied 与期望不一致时修正)
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function upsertRootARecord(string $zoneId, string $domain, string $ip): void
|
||||
@@ -204,7 +246,7 @@ class CloudflareService
|
||||
'name' => $domain,
|
||||
'content' => $ip,
|
||||
'ttl' => 1,
|
||||
'proxied' => false,
|
||||
'proxied' => true,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
@@ -212,25 +254,33 @@ class CloudflareService
|
||||
foreach ($records as $record) {
|
||||
$recordId = (string)($record['id'] ?? '');
|
||||
$content = (string)($record['content'] ?? '');
|
||||
if ($recordId === '' || $content === $ip) {
|
||||
$proxied = (bool)($record['proxied'] ?? false);
|
||||
if ($recordId === '') {
|
||||
continue;
|
||||
}
|
||||
if ($content === $ip && $proxied) {
|
||||
continue;
|
||||
}
|
||||
$this->request('PATCH', '/zones/' . $zoneId . '/dns_records/' . $recordId, [
|
||||
'content' => $ip,
|
||||
'ttl' => 1,
|
||||
'proxied' => false,
|
||||
'proxied' => true,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验根域名 A 记录:content 指向 server_ip 且已开启 Proxy
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
private function verifyRootARecord(string $zoneId, string $domain, string $ip): bool
|
||||
{
|
||||
$records = $this->listRootARecords($zoneId, $domain);
|
||||
foreach ($records as $record) {
|
||||
if ((string)($record['content'] ?? '') === $ip) {
|
||||
$content = (string)($record['content'] ?? '');
|
||||
$proxied = (bool)($record['proxied'] ?? false);
|
||||
if ($content === $ip && $proxied) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,4 +90,16 @@ class SplitAutoReplyService
|
||||
$lines = self::parseLines($stored);
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 从多行回复语中随机抽取一条(无配置时返回空字符串)
|
||||
*/
|
||||
public static function pickRandomLine(string $raw): string
|
||||
{
|
||||
$lines = self::parseLines($raw);
|
||||
if ($lines === []) {
|
||||
return '';
|
||||
}
|
||||
return $lines[array_rand($lines)];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,9 +11,15 @@ class SplitFriendUrlBuilder
|
||||
{
|
||||
/**
|
||||
* 构建跳转 URL;无法构建时返回空字符串
|
||||
*
|
||||
* @param string $whatsAppReplyText 仅 WhatsApp 类型使用,预填消息文案(urlencode 在内部处理)
|
||||
*/
|
||||
public static function build(string $numberType, string $number, string $numberTypeCustom = ''): string
|
||||
{
|
||||
public static function build(
|
||||
string $numberType,
|
||||
string $number,
|
||||
string $numberTypeCustom = '',
|
||||
string $whatsAppReplyText = ''
|
||||
): string {
|
||||
$number = trim($number);
|
||||
if ($number === '') {
|
||||
return '';
|
||||
@@ -21,7 +27,7 @@ class SplitFriendUrlBuilder
|
||||
|
||||
switch ($numberType) {
|
||||
case 'whatsapp':
|
||||
return self::buildWhatsApp($number);
|
||||
return self::buildWhatsApp($number, $whatsAppReplyText);
|
||||
case 'telegram':
|
||||
return self::buildTelegram($number);
|
||||
case 'line':
|
||||
@@ -34,16 +40,22 @@ class SplitFriendUrlBuilder
|
||||
}
|
||||
|
||||
/**
|
||||
* WhatsApp:https://api.whatsapp.com/send?phone= 仅数字
|
||||
* WhatsApp:https://api.whatsapp.com/send?phone= 仅数字,可选 &text= 预填消息
|
||||
*/
|
||||
private static function buildWhatsApp(string $number): string
|
||||
private static function buildWhatsApp(string $number, string $replyText = ''): string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $number) ?? '';
|
||||
if ($digits === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return 'https://api.whatsapp.com/send?phone=' . $digits;
|
||||
$url = 'https://api.whatsapp.com/send?phone=' . $digits;
|
||||
$replyText = trim($replyText);
|
||||
if ($replyText !== '') {
|
||||
$url .= '&text=' . rawurlencode($replyText);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\admin\model\split\Link;
|
||||
|
||||
/**
|
||||
* 分流链接随机打乱配置读取
|
||||
*/
|
||||
class SplitNumberWeighService
|
||||
{
|
||||
/**
|
||||
* 链接是否开启随机打乱(新号码按随机插入顺序写入,跳转按 id 顺序轮转)
|
||||
*/
|
||||
public static function isRandomShuffleEnabled(int $linkId): bool
|
||||
{
|
||||
if ($linkId <= 0) {
|
||||
return false;
|
||||
}
|
||||
return (int) Link::where('id', $linkId)->value('random_shuffle') === 1;
|
||||
}
|
||||
}
|
||||
@@ -72,7 +72,17 @@ class SplitRedirectService
|
||||
? (string) ($picked['number_type_custom'] ?? '')
|
||||
: (string) $picked->getAttr('number_type_custom');
|
||||
|
||||
$redirectUrl = SplitFriendUrlBuilder::build($numberType, $numberValue, $numberCustom);
|
||||
$whatsAppReplyText = '';
|
||||
if ($numberType === 'whatsapp') {
|
||||
$whatsAppReplyText = SplitAutoReplyService::pickRandomLine((string) $link->getAttr('auto_reply'));
|
||||
}
|
||||
|
||||
$redirectUrl = SplitFriendUrlBuilder::build(
|
||||
$numberType,
|
||||
$numberValue,
|
||||
$numberCustom,
|
||||
$whatsAppReplyText
|
||||
);
|
||||
if ($redirectUrl === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -24,6 +24,18 @@ class SplitSyncConfigService
|
||||
return $value !== '' ? rtrim($value, '/') : self::DEFAULT_NODE_HOST;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连续同步失败多少次后自动暂停工单(0 表示不因失败暂停)
|
||||
*/
|
||||
public static function getFailPauseThreshold(): int
|
||||
{
|
||||
$value = self::getConfigValue('split_sync_fail_pause_threshold');
|
||||
if ($value === '') {
|
||||
return 5;
|
||||
}
|
||||
return max(0, (int) $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定工单类型的自动同步周期(分钟),0 表示不自动同步
|
||||
*/
|
||||
|
||||
@@ -26,13 +26,19 @@ class SplitTicketNumberSyncService
|
||||
return;
|
||||
}
|
||||
|
||||
$randomShuffle = SplitNumberWeighService::isRandomShuffleEnabled($linkId);
|
||||
|
||||
// 使用独立 number 字段存储,避免纯数字号码作为数组 key 被 PHP 自动转为 int
|
||||
$syncedNumbers = [];
|
||||
foreach ($data->numbers as $row) {
|
||||
$number = trim((string) ($row['number'] ?? ''));
|
||||
$number = self::normalizeNumber($row['number'] ?? '');
|
||||
if ($number === '') {
|
||||
continue;
|
||||
}
|
||||
$syncedNumbers[$number] = $row;
|
||||
$syncedNumbers[] = [
|
||||
'number' => $number,
|
||||
'row' => $row,
|
||||
];
|
||||
}
|
||||
|
||||
$existingList = Number::where('admin_id', $adminId)
|
||||
@@ -45,7 +51,12 @@ class SplitTicketNumberSyncService
|
||||
$existingMap[(string) $item['number']] = $item;
|
||||
}
|
||||
|
||||
foreach ($syncedNumbers as $number => $row) {
|
||||
$syncedNumberSet = [];
|
||||
$pendingInserts = [];
|
||||
foreach ($syncedNumbers as $entry) {
|
||||
$number = $entry['number'];
|
||||
$row = $entry['row'];
|
||||
$syncedNumberSet[$number] = true;
|
||||
$platformStatus = ($row['status'] ?? '') === 'online' ? 'online' : 'offline';
|
||||
$newFollowers = (int) ($row['newFollowersToday'] ?? 0);
|
||||
|
||||
@@ -54,11 +65,30 @@ class SplitTicketNumberSyncService
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->insertNumber($ticket, $number, $platformStatus, $newFollowers);
|
||||
$pendingInserts[] = [
|
||||
'number' => $number,
|
||||
'platform_status' => $platformStatus,
|
||||
'new_followers' => $newFollowers,
|
||||
];
|
||||
}
|
||||
|
||||
if ($pendingInserts !== []) {
|
||||
// 随机打乱:打乱待插入批次顺序,按随机顺序逐条 insert 以获得乱序自增 id
|
||||
if ($randomShuffle && count($pendingInserts) > 1) {
|
||||
shuffle($pendingInserts);
|
||||
}
|
||||
foreach ($pendingInserts as $item) {
|
||||
$this->insertNumber(
|
||||
$ticket,
|
||||
$item['number'],
|
||||
$item['platform_status'],
|
||||
$item['new_followers']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($existingMap as $number => $item) {
|
||||
if (isset($syncedNumbers[$number])) {
|
||||
if (isset($syncedNumberSet[$number])) {
|
||||
continue;
|
||||
}
|
||||
if ((int) $item['manual_manage'] === 1) {
|
||||
@@ -86,17 +116,17 @@ class SplitTicketNumberSyncService
|
||||
return;
|
||||
}
|
||||
|
||||
$update['status'] = $platformStatus === 'online' ? 'normal' : 'hidden';
|
||||
if ($update['status'] === 'normal') {
|
||||
$update['inbound_count'] = max(0, $newFollowers);
|
||||
}
|
||||
|
||||
// 进线人数由同步写入,最终开关由 applyNumberRules 统一判定(单号上限/下号比率等)
|
||||
$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';
|
||||
private function insertNumber(
|
||||
Ticket $ticket,
|
||||
string $number,
|
||||
string $platformStatus,
|
||||
int $newFollowers
|
||||
): void {
|
||||
$now = time();
|
||||
$data = [
|
||||
'admin_id' => (int) $ticket['admin_id'],
|
||||
@@ -106,10 +136,10 @@ class SplitTicketNumberSyncService
|
||||
'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,
|
||||
'inbound_count' => max(0, $newFollowers),
|
||||
'manual_manage' => 0,
|
||||
'platform_status' => $platformStatus,
|
||||
'status' => $status,
|
||||
'status' => 'hidden',
|
||||
'createtime' => $now,
|
||||
'updatetime' => $now,
|
||||
];
|
||||
@@ -126,6 +156,17 @@ class SplitTicketNumberSyncService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一号码为字符串(云控 API 可能返回 int)
|
||||
*/
|
||||
private static function normalizeNumber($value): string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return '';
|
||||
}
|
||||
return trim((string) $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总工单进线人数(仅开启状态的号码)
|
||||
*/
|
||||
|
||||
@@ -17,9 +17,14 @@ class SplitTicketRuleService
|
||||
*/
|
||||
public function applyAfterSync(Ticket $ticket, int $completeCount): void
|
||||
{
|
||||
$this->applyNumberRules($ticket);
|
||||
$this->applyTicketStatusRules($ticket, $completeCount);
|
||||
$this->cascadeTicketClosedToNumbers($ticket);
|
||||
$fresh = Ticket::get((int) $ticket['id']);
|
||||
if ($fresh) {
|
||||
if ((string) $fresh['status'] === 'hidden') {
|
||||
$this->cascadeTicketClosedToNumbers($fresh);
|
||||
}
|
||||
$this->applyNumberRules($fresh);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -41,23 +46,30 @@ class SplitTicketRuleService
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动切换工单状态时,联动非手动管理的号码(开→全开,关→全关)
|
||||
* 手动切换工单状态时,联动非手动管理的号码
|
||||
*/
|
||||
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(),
|
||||
]);
|
||||
if ($ticketStatus === 'hidden') {
|
||||
$this->cascadeTicketClosedToNumbers($ticket);
|
||||
return;
|
||||
}
|
||||
$this->applyNumberRules($ticket);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单号上限、下号比率
|
||||
* 工单处于开启状态时,将云控在线的非手动号码设为开启
|
||||
*
|
||||
* @deprecated 请使用 applyNumberRules(会校验单号上限/下号比率)
|
||||
*/
|
||||
public function syncOnlineNumbersWhenTicketOpen(Ticket $ticket): void
|
||||
{
|
||||
$this->applyNumberRules($ticket);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单号上限、下号比率、云控在线状态、工单开关综合决定号码状态
|
||||
*/
|
||||
public function applyNumberRules(Ticket $ticket): void
|
||||
{
|
||||
@@ -67,7 +79,6 @@ class SplitTicketRuleService
|
||||
$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) {
|
||||
@@ -83,31 +94,66 @@ class SplitTicketRuleService
|
||||
$streak = 0;
|
||||
}
|
||||
|
||||
$status = (string) $number['status'];
|
||||
if ($orderLimit > 0 && $inboundCount > $orderLimit) {
|
||||
$status = 'hidden';
|
||||
}
|
||||
if ($assignRatio > 0 && $streak >= $assignRatio) {
|
||||
$status = 'hidden';
|
||||
$update = [
|
||||
'no_inbound_click_streak' => $streak,
|
||||
'last_sync_visit_count' => $visitCount,
|
||||
'last_sync_inbound_count' => $inboundCount,
|
||||
'updatetime' => time(),
|
||||
];
|
||||
|
||||
// 手动管理(含用户手动关闭)的号码:仅更新统计字段,不改状态
|
||||
if ((int) $number['manual_manage'] === 1) {
|
||||
Number::where('id', (int) $number['id'])->update($update);
|
||||
continue;
|
||||
}
|
||||
|
||||
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(),
|
||||
]);
|
||||
$update['status'] = $this->resolveAutomatedStatus(
|
||||
$ticket,
|
||||
(string) ($number['platform_status'] ?? 'unknown'),
|
||||
$inboundCount,
|
||||
$streak,
|
||||
$orderLimit,
|
||||
$assignRatio
|
||||
);
|
||||
|
||||
Number::where('id', (int) $number['id'])->update($update);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成量、时间窗口决定工单开关
|
||||
* 非手动管理号码的自动开关判定
|
||||
*/
|
||||
private function resolveAutomatedStatus(
|
||||
Ticket $ticket,
|
||||
string $platformStatus,
|
||||
int $inboundCount,
|
||||
int $streak,
|
||||
int $orderLimit,
|
||||
int $assignRatio
|
||||
): string {
|
||||
if ((string) ($ticket['status'] ?? 'hidden') !== 'normal') {
|
||||
return 'hidden';
|
||||
}
|
||||
if ($platformStatus !== 'online') {
|
||||
return 'hidden';
|
||||
}
|
||||
if ($orderLimit > 0 && $inboundCount >= $orderLimit) {
|
||||
return 'hidden';
|
||||
}
|
||||
if ($assignRatio > 0 && $streak >= $assignRatio) {
|
||||
return 'hidden';
|
||||
}
|
||||
|
||||
return 'normal';
|
||||
}
|
||||
|
||||
/**
|
||||
* 完成量、时间窗口决定工单开关(定时与手动同步均适用)
|
||||
*/
|
||||
public function applyTicketStatusRules(Ticket $ticket, int $completeCount): void
|
||||
{
|
||||
$status = $this->resolveTicketStatus($ticket, $completeCount);
|
||||
if ($status !== (string) $ticket['status']) {
|
||||
if ($status !== (string) ($ticket['status'] ?? 'hidden')) {
|
||||
Ticket::where('id', (int) $ticket['id'])->update([
|
||||
'status' => $status,
|
||||
'updatetime' => time(),
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use think\Config;
|
||||
|
||||
/**
|
||||
* 工单云控同步调试日志(仅 app_debug=true 时写入 runtime/log/split_sync.log)
|
||||
*/
|
||||
class SplitTicketSyncLogger
|
||||
{
|
||||
private const LOG_FILE = 'split_sync.log';
|
||||
|
||||
private static ?string $ticketTag = null;
|
||||
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
return (bool) Config::get('app_debug');
|
||||
}
|
||||
|
||||
public static function setTicketContext(?int $ticketId, ?string $ticketType = null): void
|
||||
{
|
||||
if ($ticketId === null || $ticketId <= 0) {
|
||||
self::$ticketTag = null;
|
||||
return;
|
||||
}
|
||||
$type = $ticketType !== null && $ticketType !== '' ? $ticketType : '-';
|
||||
self::$ticketTag = sprintf('#%d(%s)', $ticketId, $type);
|
||||
}
|
||||
|
||||
public static function clearTicketContext(): void
|
||||
{
|
||||
self::$ticketTag = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public static function log(string $stage, string $message, array $context = []): void
|
||||
{
|
||||
if (!self::isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$context = self::sanitize($context);
|
||||
$ctxJson = $context !== [] ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : '';
|
||||
$line = sprintf(
|
||||
"[%s] %s [%s] %s%s\n",
|
||||
date('Y-m-d H:i:s'),
|
||||
self::$ticketTag ?? '[global]',
|
||||
$stage,
|
||||
$message,
|
||||
$ctxJson
|
||||
);
|
||||
|
||||
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (ROOT_PATH . 'runtime/');
|
||||
$dir = $runtime . 'log/';
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0755, true);
|
||||
}
|
||||
@file_put_contents($dir . self::LOG_FILE, $line, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function sanitize(array $context): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($context as $key => $value) {
|
||||
$lower = strtolower((string) $key);
|
||||
if (in_array($lower, ['password', 'passwd', 'pwd', 'token', 'secret'], true)) {
|
||||
continue;
|
||||
}
|
||||
if ($key === 'authActions' && is_array($value)) {
|
||||
$out[$key] = self::sanitizeAuthActions($value);
|
||||
continue;
|
||||
}
|
||||
if (is_array($value)) {
|
||||
$out[$key] = self::sanitize($value);
|
||||
continue;
|
||||
}
|
||||
if (is_string($value) && mb_strlen($value) > 800) {
|
||||
$out[$key] = mb_substr($value, 0, 800, 'UTF-8') . '...(truncated)';
|
||||
continue;
|
||||
}
|
||||
$out[$key] = $value;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $actions
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private static function sanitizeAuthActions(array $actions): array
|
||||
{
|
||||
$sanitized = [];
|
||||
foreach ($actions as $action) {
|
||||
if (!is_array($action)) {
|
||||
$sanitized[] = $action;
|
||||
continue;
|
||||
}
|
||||
if (array_key_exists('value', $action)) {
|
||||
$action['value'] = '***';
|
||||
}
|
||||
$sanitized[] = $action;
|
||||
}
|
||||
return $sanitized;
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,6 @@ use think\Exception;
|
||||
*/
|
||||
class SplitTicketSyncService
|
||||
{
|
||||
private const FAIL_PAUSE_THRESHOLD = 5;
|
||||
|
||||
private SplitTicketNumberSyncService $numberSync;
|
||||
|
||||
private SplitTicketRuleService $ruleService;
|
||||
@@ -38,24 +36,42 @@ class SplitTicketSyncService
|
||||
{
|
||||
$ticket = Ticket::get($ticketId);
|
||||
if (!$ticket) {
|
||||
SplitTicketSyncLogger::log('sync', 'ticket not found', ['ticketId' => $ticketId]);
|
||||
return ['success' => false, 'message' => '工单不存在'];
|
||||
}
|
||||
|
||||
SplitTicketSyncLogger::setTicketContext($ticketId, (string) $ticket['ticket_type']);
|
||||
SplitTicketSyncLogger::log('sync', 'syncOne start', [
|
||||
'force' => $force,
|
||||
'status' => (string) $ticket['status'],
|
||||
'syncFailCount' => (int) ($ticket['sync_fail_count'] ?? 0),
|
||||
'syncTime' => (int) ($ticket['sync_time'] ?? 0),
|
||||
'pageUrl' => (string) $ticket['ticket_url'],
|
||||
'nodeHost' => SplitSyncConfigService::getNodeHost(),
|
||||
]);
|
||||
|
||||
if (!$force) {
|
||||
$skip = $this->shouldSkip($ticket);
|
||||
if ($skip !== null) {
|
||||
SplitTicketSyncLogger::log('sync', 'skipped', ['reason' => $skip]);
|
||||
SplitTicketSyncLogger::clearTicketContext();
|
||||
return ['success' => false, 'message' => $skip, 'skipped' => true];
|
||||
}
|
||||
}
|
||||
|
||||
if (!$this->lockService->acquire($ticketId)) {
|
||||
SplitTicketSyncLogger::log('sync', 'lock busy', ['ticketId' => $ticketId]);
|
||||
SplitTicketSyncLogger::clearTicketContext();
|
||||
return ['success' => false, 'message' => '工单正在同步中', 'skipped' => true];
|
||||
}
|
||||
|
||||
try {
|
||||
return $this->doSync($ticket);
|
||||
$result = $this->doSync($ticket);
|
||||
SplitTicketSyncLogger::log('sync', 'syncOne end', $result);
|
||||
return $result;
|
||||
} finally {
|
||||
$this->lockService->release($ticketId);
|
||||
SplitTicketSyncLogger::clearTicketContext();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -65,13 +81,25 @@ class SplitTicketSyncService
|
||||
public function syncDueTickets(): int
|
||||
{
|
||||
$count = 0;
|
||||
$list = Ticket::where('status', 'normal')
|
||||
->where('sync_fail_count', '<', self::FAIL_PAUSE_THRESHOLD)
|
||||
->select();
|
||||
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
|
||||
$query = Ticket::where('status', 'normal');
|
||||
if ($failThreshold > 0) {
|
||||
$query->where('sync_fail_count', '<', $failThreshold);
|
||||
}
|
||||
$list = $query->select();
|
||||
|
||||
SplitTicketSyncLogger::log('cron', 'scan start', [
|
||||
'candidateCount' => count($list),
|
||||
]);
|
||||
|
||||
foreach ($list as $ticket) {
|
||||
$skip = $this->shouldSkip($ticket);
|
||||
if ($skip !== null) {
|
||||
SplitTicketSyncLogger::log('cron', 'candidate skipped', [
|
||||
'ticketId' => (int) $ticket['id'],
|
||||
'ticketType' => (string) $ticket['ticket_type'],
|
||||
'reason' => $skip,
|
||||
]);
|
||||
continue;
|
||||
}
|
||||
$result = $this->syncOne((int) $ticket['id'], false);
|
||||
@@ -81,6 +109,7 @@ class SplitTicketSyncService
|
||||
$count++;
|
||||
}
|
||||
|
||||
SplitTicketSyncLogger::log('cron', 'scan end', ['processedCount' => $count]);
|
||||
return $count;
|
||||
}
|
||||
|
||||
@@ -92,15 +121,21 @@ class SplitTicketSyncService
|
||||
$ticketType = (string) $ticket['ticket_type'];
|
||||
$pageUrl = trim((string) $ticket['ticket_url']);
|
||||
if ($pageUrl === '') {
|
||||
SplitTicketSyncLogger::log('sync', 'empty pageUrl');
|
||||
$this->markFailure($ticket, '工单链接为空');
|
||||
return ['success' => false, 'message' => '工单链接为空'];
|
||||
}
|
||||
|
||||
if (!SplitScrmSpiderFactory::isSupported($ticketType)) {
|
||||
SplitTicketSyncLogger::log('sync', 'spider not supported', ['ticketType' => $ticketType]);
|
||||
$this->markFailure($ticket, '工单类型尚未实现蜘蛛');
|
||||
return ['success' => false, 'message' => '工单类型尚未实现蜘蛛'];
|
||||
}
|
||||
|
||||
SplitTicketSyncLogger::log('sync', 'create spider', [
|
||||
'ticketType' => $ticketType,
|
||||
'hasAccount' => trim((string) ($ticket['account'] ?? '')) !== '',
|
||||
]);
|
||||
$spider = SplitScrmSpiderFactory::create(
|
||||
$ticketType,
|
||||
$pageUrl,
|
||||
@@ -114,22 +149,24 @@ class SplitTicketSyncService
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
SplitTicketSyncLogger::log('sync', 'spider run begin');
|
||||
$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) {
|
||||
$freshTicket = Ticket::get((int) $ticket['id']) ?: $ticket;
|
||||
if ((string) $freshTicket['status'] === 'hidden') {
|
||||
$this->ruleService->cascadeTicketClosedToNumbers($freshTicket);
|
||||
$ticket = $freshTicket;
|
||||
}
|
||||
// 号码开关最后统一由 applyNumberRules 判定(单号上限/下号比率/云控在线)
|
||||
$this->ruleService->applyNumberRules($freshTicket);
|
||||
$ticket = $freshTicket;
|
||||
|
||||
$inboundCount = $this->numberSync->sumInboundForTicket($ticket);
|
||||
$speed = $this->calcSpeedPerHour($ticket, $completeCount);
|
||||
@@ -149,10 +186,17 @@ class SplitTicketSyncService
|
||||
|
||||
$this->applySyncResult($ticket, $payload, true, '');
|
||||
Db::commit();
|
||||
SplitTicketSyncLogger::log('sync', 'db commit ok', $payload);
|
||||
return ['success' => true, 'message' => '同步成功'];
|
||||
} catch (\Throwable $e) {
|
||||
Db::rollback();
|
||||
$msg = mb_substr($e->getMessage(), 0, 255, 'UTF-8');
|
||||
SplitTicketSyncLogger::log('sync', 'exception', [
|
||||
'type' => get_class($e),
|
||||
'message' => $msg,
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
]);
|
||||
$this->markFailure($ticket, $msg);
|
||||
return ['success' => false, 'message' => $msg];
|
||||
}
|
||||
@@ -163,8 +207,9 @@ class SplitTicketSyncService
|
||||
if ((string) $ticket['status'] === 'hidden') {
|
||||
return '工单已关闭';
|
||||
}
|
||||
if ((int) ($ticket['sync_fail_count'] ?? 0) >= self::FAIL_PAUSE_THRESHOLD) {
|
||||
return '连续同步失败超过5次已暂停';
|
||||
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
|
||||
if ($failThreshold > 0 && (int) ($ticket['sync_fail_count'] ?? 0) >= $failThreshold) {
|
||||
return sprintf('连续同步失败超过%d次已暂停', $failThreshold);
|
||||
}
|
||||
if (!SplitScrmSpiderFactory::isSupported((string) $ticket['ticket_type'])) {
|
||||
return '工单类型尚未实现';
|
||||
@@ -174,7 +219,13 @@ class SplitTicketSyncService
|
||||
return '该类型未配置自动同步周期';
|
||||
}
|
||||
$lastSync = (int) ($ticket['sync_time'] ?? 0);
|
||||
if ($lastSync > 0 && (time() - $lastSync) < ($interval * 60)) {
|
||||
$elapsed = $lastSync > 0 ? (time() - $lastSync) : null;
|
||||
if ($lastSync > 0 && $elapsed !== null && $elapsed < ($interval * 60)) {
|
||||
SplitTicketSyncLogger::log('sync', 'interval not reached', [
|
||||
'intervalMinutes' => $interval,
|
||||
'elapsedSeconds' => $elapsed,
|
||||
'needSeconds' => $interval * 60,
|
||||
]);
|
||||
return '未到同步周期';
|
||||
}
|
||||
return null;
|
||||
@@ -208,12 +259,26 @@ class SplitTicketSyncService
|
||||
private function markFailure(Ticket $ticket, string $message): void
|
||||
{
|
||||
$failCount = (int) ($ticket['sync_fail_count'] ?? 0) + 1;
|
||||
$ticket->save([
|
||||
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
|
||||
$previousSyncStatus = (string) ($ticket['sync_status'] ?? 'pending');
|
||||
$neverSyncedSuccessfully = $previousSyncStatus === 'pending' && (int) ($ticket['sync_time'] ?? 0) <= 0;
|
||||
$update = [
|
||||
'sync_status' => 'error',
|
||||
'sync_time' => time(),
|
||||
'sync_message' => mb_substr($message, 0, 255, 'UTF-8'),
|
||||
'sync_fail_count' => $failCount,
|
||||
]);
|
||||
];
|
||||
// 新建工单首次同步失败:立即关闭;已同步过的工单仍按连续失败阈值关闭
|
||||
if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) {
|
||||
$update['status'] = 'hidden';
|
||||
}
|
||||
$ticket->save($update);
|
||||
if (isset($update['status']) && $update['status'] === 'hidden') {
|
||||
$fresh = Ticket::get((int) $ticket['id']);
|
||||
if ($fresh) {
|
||||
$this->ruleService->cascadeTicketClosedToNumbers($fresh);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user