diff --git a/application/admin/command/Install/split_number.sql b/application/admin/command/Install/split_number.sql
index 9abe9c4..939b10f 100644
--- a/application/admin/command/Install/split_number.sql
+++ b/application/admin/command/Install/split_number.sql
@@ -15,6 +15,7 @@ CREATE TABLE `fa_split_number` (
`inbound_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '进线人数',
`manual_manage` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '手动管理:0=否,1=是',
`status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态:normal=正常,hidden=停用',
+ `weigh` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '排序权重(越大越靠前)',
`createtime` bigint(16) DEFAULT NULL COMMENT '创建时间',
`updatetime` bigint(16) DEFAULT NULL COMMENT '更新时间',
PRIMARY KEY (`id`),
@@ -22,6 +23,7 @@ CREATE TABLE `fa_split_number` (
KEY `admin_id` (`admin_id`),
KEY `split_link_id` (`split_link_id`),
KEY `status` (`status`),
+ KEY `weigh` (`weigh`),
KEY `number` (`number`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='分流号码表';
@@ -68,3 +70,10 @@ FROM `fa_auth_rule` m
WHERE m.name = 'split.number' AND m.ismenu = 1
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/batchupdate' LIMIT 1)
LIMIT 1;
+
+INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
+SELECT 'file', m.id, 'split.number/multi', '批量更新', 'fa fa-circle-o', '', '列表状态开关', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
+FROM `fa_auth_rule` m
+WHERE m.name = 'split.number' AND m.ismenu = 1
+ AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number/multi' LIMIT 1)
+LIMIT 1;
diff --git a/application/admin/command/SplitSyncTickets.php b/application/admin/command/SplitSyncTickets.php
index 9913c92..f6f317c 100644
--- a/application/admin/command/SplitSyncTickets.php
+++ b/application/admin/command/SplitSyncTickets.php
@@ -4,6 +4,7 @@ declare(strict_types=1);
namespace app\admin\command;
+use app\common\service\SplitTicketSyncLogger;
use app\common\service\SplitTicketSyncService;
use think\console\Command;
use think\console\Input;
@@ -29,6 +30,10 @@ class SplitSyncTickets extends Command
protected function execute(Input $input, Output $output): void
{
set_time_limit(0);
+ SplitTicketSyncLogger::log('cli', 'command start', [
+ 'ticketOption' => trim((string) $input->getOption('ticket')),
+ 'appDebug' => SplitTicketSyncLogger::isEnabled(),
+ ]);
$service = new SplitTicketSyncService();
$ticketId = trim((string) $input->getOption('ticket'));
@@ -48,5 +53,8 @@ class SplitSyncTickets extends Command
$count = $service->syncDueTickets();
$output->writeln('本次处理工单数: ' . $count . '');
+ if (SplitTicketSyncLogger::isEnabled()) {
+ $output->writeln('调试日志已写入 runtime/log/split_sync.log');
+ }
}
}
diff --git a/application/admin/controller/split/Link.php b/application/admin/controller/split/Link.php
index 9d4eb94..67641e1 100644
--- a/application/admin/controller/split/Link.php
+++ b/application/admin/controller/split/Link.php
@@ -84,13 +84,8 @@ class Link extends Backend
$cfg = is_array($this->view->config ?? null) ? $this->view->config : [];
$version = (string) \think\Config::get('site.version');
- $scriptUrl = (string) url('split.link/script', ['v' => $version], false, true);
- if (strpos($scriptUrl, '?') === false) {
- $scriptUrl .= '?v=' . $version;
- }
- if (strpos($scriptUrl, '://') === false) {
- $scriptUrl = $this->request->domain() . $scriptUrl;
- }
+ // 使用站内相对路径,避免生产环境 domain() 与反向代理/HTTPS 不一致导致 JS 跨域丢 Cookie
+ $scriptUrl = (string) url('split.link/script', ['v' => $version], '', false);
$cfg['jsname'] = $scriptUrl;
$this->view->assign('config', $cfg);
$this->view->config = $cfg;
diff --git a/application/admin/controller/split/Number.php b/application/admin/controller/split/Number.php
index 1dfc617..5d9179d 100644
--- a/application/admin/controller/split/Number.php
+++ b/application/admin/controller/split/Number.php
@@ -33,6 +33,9 @@ class Number extends Backend
protected $modelSceneValidate = true;
+ /** @var string */
+ protected $multiFields = 'status,manual_manage';
+
/** @var string[] */
protected $noNeedRight = ['script'];
@@ -76,13 +79,8 @@ class Number extends Backend
$cfg = is_array($this->view->config ?? null) ? $this->view->config : [];
$version = (string) \think\Config::get('site.version');
- $scriptUrl = (string) url('split.number/script', ['v' => $version], false, true);
- if (strpos($scriptUrl, '?') === false) {
- $scriptUrl .= '?v=' . $version;
- }
- if (strpos($scriptUrl, '://') === false) {
- $scriptUrl = $this->request->domain() . $scriptUrl;
- }
+ // 使用站内相对路径,避免生产环境 domain() 与反向代理/HTTPS 不一致导致 JS 跨域丢 Cookie
+ $scriptUrl = (string) url('split.number/script', ['v' => $version], '', false);
$cfg['jsname'] = $scriptUrl;
$this->view->assign('config', $cfg);
$this->view->config = $cfg;
@@ -127,6 +125,59 @@ class Number extends Backend
return (string) $this->view->fetch($file);
}
+ /**
+ * 列表状态开关:手动关闭时标记 manual_manage=1,防止同步自动恢复开启
+ *
+ * @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);
+ if (!isset($values['status'])) {
+ parent::multi($ids);
+ return;
+ }
+
+ if ((string) $values['status'] === 'hidden') {
+ $values['manual_manage'] = 1;
+ } elseif ((string) $values['status'] === 'normal') {
+ $values['manual_manage'] = 0;
+ }
+
+ $adminIds = $this->getDataLimitAdminIds();
+ if (is_array($adminIds)) {
+ $this->model->where($this->dataLimitField, 'in', $adminIds);
+ }
+ $count = 0;
+ Db::startTrans();
+ try {
+ $list = $this->model->where($this->model->getPk(), 'in', $ids)->select();
+ foreach ($list as $item) {
+ $count += $item->allowField(true)->isUpdate(true)->save($values);
+ }
+ Db::commit();
+ } catch (ValidateException $e) {
+ Db::rollback();
+ $this->error($e->getMessage());
+ } catch (PDOException|Exception $e) {
+ Db::rollback();
+ $this->error($e->getMessage());
+ }
+ if ($count > 0) {
+ $this->success();
+ }
+ $this->error(__('No rows were updated'));
+ }
+
/**
* @return string|Json
* @throws DbException
diff --git a/application/admin/controller/split/Ticket.php b/application/admin/controller/split/Ticket.php
index 30dd32c..31fbec6 100644
--- a/application/admin/controller/split/Ticket.php
+++ b/application/admin/controller/split/Ticket.php
@@ -8,6 +8,7 @@ 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\SplitTicketSyncLogger;
use app\common\service\SplitTicketSyncService;
use think\Db;
use think\Lang;
@@ -57,6 +58,12 @@ class Ticket extends Backend
$this->assignconfig('ticketTypeList', $this->model->getTicketTypeList());
$this->assignconfig('numberTypeList', $this->model->getNumberTypeList());
$this->assignconfig('statusList', $this->model->getStatusList());
+ $this->assignconfig([
+ 'syncConfirmMsg' => __('Sync confirm'),
+ 'syncBackgroundStartedMsg' => __('Sync background started'),
+ 'syncInProgressMsg' => __('Sync in progress'),
+ 'syncTicketStartedMsg' => __('Sync ticket started'),
+ ]);
$this->setupPatchFrontend();
}
@@ -103,13 +110,8 @@ class Ticket extends Backend
$cfg = is_array($this->view->config ?? null) ? $this->view->config : [];
$version = (string) \think\Config::get('site.version');
- $scriptUrl = (string) url('split.ticket/script', ['v' => $version], false, true);
- if (strpos($scriptUrl, '?') === false) {
- $scriptUrl .= '?v=' . $version;
- }
- if (strpos($scriptUrl, '://') === false) {
- $scriptUrl = $this->request->domain() . $scriptUrl;
- }
+ // 使用站内相对路径,避免生产环境 domain() 与反向代理/HTTPS 不一致导致 JS 跨域丢 Cookie
+ $scriptUrl = (string) url('split.ticket/script', ['v' => $version], '', false);
$cfg['jsname'] = $scriptUrl;
$this->view->assign('config', $cfg);
$this->view->config = $cfg;
@@ -228,6 +230,11 @@ class Ticket extends Backend
$this->error(__('No Results were found'));
}
+ SplitTicketSyncLogger::log('web', 'manual sync request', [
+ 'ids' => $ids,
+ 'appDebug' => SplitTicketSyncLogger::isEnabled(),
+ ]);
+
$service = new SplitTicketSyncService();
$ok = 0;
$fail = 0;
@@ -276,15 +283,42 @@ class Ticket extends Backend
if (isset($values['status'])) {
$pk = $this->model->getPk();
- $rows = $this->model->where($pk, 'in', $ids)->select();
- parent::multi($ids);
+ $adminIds = $this->getDataLimitAdminIds();
+ $query = $this->model->where($pk, 'in', $ids);
+ if (is_array($adminIds)) {
+ $query->where($this->dataLimitField, 'in', $adminIds);
+ }
+ $rows = $query->select();
+ if (!$rows || count($rows) === 0) {
+ $this->error(__('No Results were found'));
+ }
+
+ $count = 0;
+ Db::startTrans();
+ try {
+ foreach ($rows as $item) {
+ $count += $item->allowField(true)->isUpdate(true)->save($values);
+ }
+ Db::commit();
+ } catch (ValidateException $e) {
+ Db::rollback();
+ $this->error($e->getMessage());
+ } catch (PDOException|Exception $e) {
+ Db::rollback();
+ $this->error($e->getMessage());
+ }
+
foreach ($rows as $row) {
$fresh = $this->model->get($row['id']);
if ($fresh) {
$ruleService->syncNumbersWithTicketStatus($fresh);
}
}
- return;
+
+ if ($count > 0) {
+ $this->success();
+ }
+ $this->error(__('No rows were updated'));
}
parent::multi($ids);
@@ -330,7 +364,7 @@ class Ticket extends Backend
if ($result === false) {
$this->error(__('No rows were inserted'));
}
- $this->success();
+ $this->success('', null, ['id' => (int) $this->model->id]);
}
/**
@@ -363,6 +397,7 @@ class Ticket extends Backend
if (($params['number_type'] ?? '') !== 'custom') {
$params['number_type_custom'] = '';
}
+ $oldStatus = (string) ($row['status'] ?? 'hidden');
$result = false;
Db::startTrans();
try {
@@ -383,6 +418,12 @@ class Ticket extends Backend
if ($result === false) {
$this->error(__('No rows were updated'));
}
+ if (isset($params['status']) && (string) $params['status'] !== $oldStatus) {
+ $fresh = $this->model->get($row['id']);
+ if ($fresh) {
+ (new SplitTicketRuleService())->syncNumbersWithTicketStatus($fresh);
+ }
+ }
$this->success();
}
diff --git a/application/admin/lang/zh-cn/domain.php b/application/admin/lang/zh-cn/domain.php
index cbfe454..984c61b 100755
--- a/application/admin/lang/zh-cn/domain.php
+++ b/application/admin/lang/zh-cn/domain.php
@@ -25,7 +25,7 @@ return [
'Domain input tips line1' => '请输入根域名,比如:nihao.com,不要填写 www,且不支持二级子域名。',
'Domain input tips line2' => '不支持中国域名注册商注册的域名,比如阿里云、腾讯云等注册商;也不支持中国域名后缀,包括 cn、com.cn、net.cn、org.cn 等。',
'Domain guide title' => '接入指引',
- 'Domain guide content' => '提交后,请在详情中查看 Cloudflare 实际分配的 NS,并到域名注册商处修改。',
+ 'Domain guide content' => '提交后,请在详情中查看 Cloudflare 实际分配的 NS,并到域名注册商处修改。NS 生效后点击「检测」完成接入。',
'Domain submitted, please check NS in detail page and update at registrar' => '域名已提交,请在详情中查看 Cloudflare 分配的 NS,并到注册商处修改',
'Domain cannot be edited after creation' => '域名创建后不可编辑',
'Domain already exists' => '该域名已存在',
diff --git a/application/admin/lang/zh-cn/split/ticket.php b/application/admin/lang/zh-cn/split/ticket.php
index 3fcbf1e..78d7f21 100644
--- a/application/admin/lang/zh-cn/split/ticket.php
+++ b/application/admin/lang/zh-cn/split/ticket.php
@@ -26,11 +26,16 @@ return [
'Sync_status' => '同步状态',
'Sync_status_btn' => '同步状态',
'Sync running' => '正在同步,请稍候…',
+ 'Sync confirm' => '确定同步所选工单吗?任务将在后台执行,完成后自动提示结果。',
+ 'Sync background started' => '同步任务已在后台执行,请稍候…',
+ 'Sync in progress' => '同步中',
+ 'Sync ticket started' => '正在同步工单',
'Sync done' => '同步完成',
'Sync status success' => '同步成功',
'Sync status error' => '同步异常',
'Sync status pending' => '待同步',
- 'Sync display success' => '同步成功 / 在线人数 %s',
+ 'Sync display success' => '同步成功 / 在线 %s',
+ 'Sync display pending' => '待同步',
'Sync display error' => '同步异常',
'Createtime' => '创建时间',
'Section basic' => '基础信息',
diff --git a/application/admin/model/split/Number.php b/application/admin/model/split/Number.php
index c82e70a..2085f63 100644
--- a/application/admin/model/split/Number.php
+++ b/application/admin/model/split/Number.php
@@ -15,6 +15,21 @@ class Number extends Model
{
protected $name = 'split_number';
+ protected static function init(): void
+ {
+ self::beforeInsert(function (self $row): void {
+ if ((int) ($row['weigh'] ?? 0) > 0) {
+ return;
+ }
+ $linkId = (int) ($row['split_link_id'] ?? 0);
+ if ($linkId <= 0) {
+ return;
+ }
+ $maxWeigh = (int) self::where('split_link_id', $linkId)->max('weigh');
+ $row->setAttr('weigh', $maxWeigh + 1);
+ });
+ }
+
protected $autoWriteTimestamp = 'integer';
protected $createTime = 'createtime';
diff --git a/application/admin/model/split/Ticket.php b/application/admin/model/split/Ticket.php
index e19eb19..ae3a552 100644
--- a/application/admin/model/split/Ticket.php
+++ b/application/admin/model/split/Ticket.php
@@ -163,6 +163,9 @@ class Ticket extends Model
if ($status === 'success') {
return sprintf((string) __('Sync display success'), $online);
}
+ if ($status === 'pending') {
+ return (string) __('Sync display pending');
+ }
return (string) __('Sync display error');
}
diff --git a/application/admin/view/split/ticket/index.html b/application/admin/view/split/ticket/index.html
index ec5325e..71dfa41 100644
--- a/application/admin/view/split/ticket/index.html
+++ b/application/admin/view/split/ticket/index.html
@@ -1,3 +1,29 @@
+
{:build_heading(null,FALSE)}
diff --git a/application/common/library/scrm/AbstractScrmSpider.php b/application/common/library/scrm/AbstractScrmSpider.php
index b7c8a90..008ed30 100644
--- a/application/common/library/scrm/AbstractScrmSpider.php
+++ b/application/common/library/scrm/AbstractScrmSpider.php
@@ -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 $decoded
+ * @return array
+ */
+ 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;
+ }
}
diff --git a/application/common/library/scrm/UnifiedScrmData.php b/application/common/library/scrm/UnifiedScrmData.php
index bd783e6..9a4b7e8 100644
--- a/application/common/library/scrm/UnifiedScrmData.php
+++ b/application/common/library/scrm/UnifiedScrmData.php
@@ -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',
diff --git a/application/common/service/CloudflareService.php b/application/common/service/CloudflareService.php
index 51fa958..e159745 100755
--- a/application/common/service/CloudflareService.php
+++ b/application/common/service/CloudflareService.php
@@ -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;
}
}
diff --git a/application/common/service/SplitAutoReplyService.php b/application/common/service/SplitAutoReplyService.php
index b057801..05ac2ad 100644
--- a/application/common/service/SplitAutoReplyService.php
+++ b/application/common/service/SplitAutoReplyService.php
@@ -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)];
+ }
}
diff --git a/application/common/service/SplitFriendUrlBuilder.php b/application/common/service/SplitFriendUrlBuilder.php
index ceb427f..bf5c2a4 100644
--- a/application/common/service/SplitFriendUrlBuilder.php
+++ b/application/common/service/SplitFriendUrlBuilder.php
@@ -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;
}
/**
diff --git a/application/common/service/SplitNumberWeighService.php b/application/common/service/SplitNumberWeighService.php
new file mode 100644
index 0000000..d6191ea
--- /dev/null
+++ b/application/common/service/SplitNumberWeighService.php
@@ -0,0 +1,24 @@
+value('random_shuffle') === 1;
+ }
+}
diff --git a/application/common/service/SplitRedirectService.php b/application/common/service/SplitRedirectService.php
index 1086b80..a7a68f4 100644
--- a/application/common/service/SplitRedirectService.php
+++ b/application/common/service/SplitRedirectService.php
@@ -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;
}
diff --git a/application/common/service/SplitSyncConfigService.php b/application/common/service/SplitSyncConfigService.php
index 222bb65..de0cb3d 100644
--- a/application/common/service/SplitSyncConfigService.php
+++ b/application/common/service/SplitSyncConfigService.php
@@ -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 表示不自动同步
*/
diff --git a/application/common/service/SplitTicketNumberSyncService.php b/application/common/service/SplitTicketNumberSyncService.php
index f99a4b4..dccaab1 100644
--- a/application/common/service/SplitTicketNumberSyncService.php
+++ b/application/common/service/SplitTicketNumberSyncService.php
@@ -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);
+ }
+
/**
* 汇总工单进线人数(仅开启状态的号码)
*/
diff --git a/application/common/service/SplitTicketRuleService.php b/application/common/service/SplitTicketRuleService.php
index d3ff0f3..a4f65bd 100644
--- a/application/common/service/SplitTicketRuleService.php
+++ b/application/common/service/SplitTicketRuleService.php
@@ -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(),
diff --git a/application/common/service/SplitTicketSyncLogger.php b/application/common/service/SplitTicketSyncLogger.php
new file mode 100644
index 0000000..fecd42b
--- /dev/null
+++ b/application/common/service/SplitTicketSyncLogger.php
@@ -0,0 +1,114 @@
+ $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 $context
+ * @return array
+ */
+ 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 $actions
+ * @return array
+ */
+ 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;
+ }
+}
diff --git a/application/common/service/SplitTicketSyncService.php b/application/common/service/SplitTicketSyncService.php
index 610c677..71d5ed1 100644
--- a/application/common/service/SplitTicketSyncService.php
+++ b/application/common/service/SplitTicketSyncService.php
@@ -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);
+ }
+ }
}
/**
diff --git a/application/extra/site.php b/application/extra/site.php
index daf37c5..970e1a6 100755
--- a/application/extra/site.php
+++ b/application/extra/site.php
@@ -55,4 +55,5 @@ return array (
'split_sync_interval_yifafa' => '0',
'split_sync_interval_whatshub' => '0',
'split_sync_interval_sihai' => '0',
+ 'split_sync_fail_pause_threshold' => '5',
);
diff --git a/deploy-server-ip-dns.sh b/deploy-server-ip-dns.sh
index 0f7bbab..58a2cd3 100755
--- a/deploy-server-ip-dns.sh
+++ b/deploy-server-ip-dns.sh
@@ -6,6 +6,8 @@ PATCH="$BASE/patches"
cp -f "$PATCH/application/extra/cloudflare.php" "$BASE/application/extra/cloudflare.php"
cp -f "$PATCH/application/common/service/CloudflareService.php" "$BASE/application/common/service/CloudflareService.php"
-chown www:www "$BASE/application/extra/cloudflare.php" "$BASE/application/common/service/CloudflareService.php"
+cp -f "$PATCH/application/admin/lang/zh-cn/domain.php" "$BASE/application/admin/lang/zh-cn/domain.php"
+chown www:www "$BASE/application/extra/cloudflare.php" "$BASE/application/common/service/CloudflareService.php" "$BASE/application/admin/lang/zh-cn/domain.php"
php -l "$BASE/application/common/service/CloudflareService.php"
echo "部署完成。请确认 .env 中 cloudflare.server_ip 已填写。"
+echo "Cloudflare API Token 需包含权限:Zone DNS Edit、Zone Settings Edit(用于 SSL Flexible 与 Always Use HTTPS)。"
diff --git a/links.test_wzXtw.zip b/links.test_wzXtw.zip
deleted file mode 100755
index be1a117..0000000
Binary files a/links.test_wzXtw.zip and /dev/null differ
diff --git a/patches/application/admin/controller/split/Link.php b/patches/application/admin/controller/split/Link.php
index 9d4eb94..67641e1 100644
--- a/patches/application/admin/controller/split/Link.php
+++ b/patches/application/admin/controller/split/Link.php
@@ -84,13 +84,8 @@ class Link extends Backend
$cfg = is_array($this->view->config ?? null) ? $this->view->config : [];
$version = (string) \think\Config::get('site.version');
- $scriptUrl = (string) url('split.link/script', ['v' => $version], false, true);
- if (strpos($scriptUrl, '?') === false) {
- $scriptUrl .= '?v=' . $version;
- }
- if (strpos($scriptUrl, '://') === false) {
- $scriptUrl = $this->request->domain() . $scriptUrl;
- }
+ // 使用站内相对路径,避免生产环境 domain() 与反向代理/HTTPS 不一致导致 JS 跨域丢 Cookie
+ $scriptUrl = (string) url('split.link/script', ['v' => $version], '', false);
$cfg['jsname'] = $scriptUrl;
$this->view->assign('config', $cfg);
$this->view->config = $cfg;
diff --git a/patches/application/admin/controller/split/Number.php b/patches/application/admin/controller/split/Number.php
index bb63358..5d9179d 100644
--- a/patches/application/admin/controller/split/Number.php
+++ b/patches/application/admin/controller/split/Number.php
@@ -79,13 +79,8 @@ class Number extends Backend
$cfg = is_array($this->view->config ?? null) ? $this->view->config : [];
$version = (string) \think\Config::get('site.version');
- $scriptUrl = (string) url('split.number/script', ['v' => $version], false, true);
- if (strpos($scriptUrl, '?') === false) {
- $scriptUrl .= '?v=' . $version;
- }
- if (strpos($scriptUrl, '://') === false) {
- $scriptUrl = $this->request->domain() . $scriptUrl;
- }
+ // 使用站内相对路径,避免生产环境 domain() 与反向代理/HTTPS 不一致导致 JS 跨域丢 Cookie
+ $scriptUrl = (string) url('split.number/script', ['v' => $version], '', false);
$cfg['jsname'] = $scriptUrl;
$this->view->assign('config', $cfg);
$this->view->config = $cfg;
diff --git a/patches/application/admin/controller/split/Ticket.php b/patches/application/admin/controller/split/Ticket.php
index 5d64e8b..31fbec6 100644
--- a/patches/application/admin/controller/split/Ticket.php
+++ b/patches/application/admin/controller/split/Ticket.php
@@ -110,13 +110,8 @@ class Ticket extends Backend
$cfg = is_array($this->view->config ?? null) ? $this->view->config : [];
$version = (string) \think\Config::get('site.version');
- $scriptUrl = (string) url('split.ticket/script', ['v' => $version], false, true);
- if (strpos($scriptUrl, '?') === false) {
- $scriptUrl .= '?v=' . $version;
- }
- if (strpos($scriptUrl, '://') === false) {
- $scriptUrl = $this->request->domain() . $scriptUrl;
- }
+ // 使用站内相对路径,避免生产环境 domain() 与反向代理/HTTPS 不一致导致 JS 跨域丢 Cookie
+ $scriptUrl = (string) url('split.ticket/script', ['v' => $version], '', false);
$cfg['jsname'] = $scriptUrl;
$this->view->assign('config', $cfg);
$this->view->config = $cfg;
diff --git a/patches/application/admin/lang/zh-cn/domain.php b/patches/application/admin/lang/zh-cn/domain.php
new file mode 100644
index 0000000..984c61b
--- /dev/null
+++ b/patches/application/admin/lang/zh-cn/domain.php
@@ -0,0 +1,36 @@
+ '域名',
+ 'Full_url' => '完整链接',
+ 'Zone_status' => 'Zone状态',
+ 'Ns_status' => 'NameServer状态',
+ 'Dns_status' => 'DNS状态',
+ 'Nameservers' => 'NameServer',
+ 'Check_time' => '检测时间',
+ 'Check_result' => '最近检测结果',
+ 'Createtime' => '创建时间',
+ 'Zone pending' => '待激活',
+ 'Zone active' => '已激活',
+ 'Zone failed' => '异常',
+ 'NS pending' => '待验证',
+ 'NS verified' => '已验证',
+ 'NS failed' => '验证失败',
+ 'DNS pending' => '待创建',
+ 'DNS created' => '已创建',
+ 'DNS failed' => '异常',
+ 'Detect' => '检测',
+ 'Detection completed' => '检测完成',
+ 'Domain input tips title' => '填写说明',
+ 'Domain input tips line1' => '请输入根域名,比如:nihao.com,不要填写 www,且不支持二级子域名。',
+ 'Domain input tips line2' => '不支持中国域名注册商注册的域名,比如阿里云、腾讯云等注册商;也不支持中国域名后缀,包括 cn、com.cn、net.cn、org.cn 等。',
+ 'Domain guide title' => '接入指引',
+ 'Domain guide content' => '提交后,请在详情中查看 Cloudflare 实际分配的 NS,并到域名注册商处修改。NS 生效后点击「检测」完成接入。',
+ 'Domain submitted, please check NS in detail page and update at registrar' => '域名已提交,请在详情中查看 Cloudflare 分配的 NS,并到注册商处修改',
+ 'Domain cannot be edited after creation' => '域名创建后不可编辑',
+ 'Domain already exists' => '该域名已存在',
+ 'Please enter the full domain to confirm deletion' => '请输入完整域名以确认删除',
+ 'Domain confirmation does not match' => '输入的域名与记录不一致,删除已取消',
+ 'Delete domain confirm prompt' => '请输入完整域名以确认删除',
+ 'Delete domain confirm mismatch' => '域名输入不一致,请重新确认',
+];
diff --git a/patches/application/common/service/CloudflareService.php b/patches/application/common/service/CloudflareService.php
index 51fa958..e159745 100644
--- a/patches/application/common/service/CloudflareService.php
+++ b/patches/application/common/service/CloudflareService.php
@@ -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;
}
}
diff --git a/public/assets/js/backend/split/ticket.js b/public/assets/js/backend/split/ticket.js
index 10685b9..01bb5fa 100644
--- a/public/assets/js/backend/split/ticket.js
+++ b/public/assets/js/backend/split/ticket.js
@@ -57,6 +57,7 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
},
{field: 'order_limit', title: __('Order_limit'), operate: false},
{field: 'assign_ratio', title: __('Assign_ratio'), operate: false},
+ {field: 'ticket_total', title: __('Ticket_total'), operate: false, sortable: true},
{field: 'complete_count', title: __('Complete_count'), operate: false, sortable: true},
{
field: 'ticket_progress_text',
@@ -117,6 +118,17 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
});
Table.api.bindevent(table);
+ Controller.api.syncingTicketIds = [];
+ window.__splitTicketPendingPostAddSyncIds = window.__splitTicketPendingPostAddSyncIds || [];
+
+ table.on('load-success.bs.table', function () {
+ var pendingIds = window.__splitTicketPendingPostAddSyncIds;
+ if (!pendingIds || !pendingIds.length) {
+ return;
+ }
+ window.__splitTicketPendingPostAddSyncIds = [];
+ Controller.api.startBackgroundSync(table, pendingIds, false);
+ });
table.on('check.bs.table uncheck.bs.table check-all.bs.table uncheck-all.bs.table', function () {
var ids = Table.api.selectedids(table);
@@ -131,30 +143,105 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
Toastr.error(__('Please select at least one record'));
return false;
}
- Layer.confirm(__('Sync running'), {icon: 3, title: __('Sync_status_btn')}, function (index) {
+ var syncConfirmMsg = (typeof Config.syncConfirmMsg !== 'undefined' && Config.syncConfirmMsg)
+ ? Config.syncConfirmMsg
+ : '确定同步所选工单吗?任务将在后台执行,完成后自动提示结果。';
+ var syncBackgroundMsg = (typeof Config.syncBackgroundStartedMsg !== 'undefined' && Config.syncBackgroundStartedMsg)
+ ? Config.syncBackgroundStartedMsg
+ : '同步任务已在后台执行,请稍候…';
+ Layer.confirm(syncConfirmMsg, {icon: 3, title: __('Sync_status_btn')}, function (index) {
Layer.close(index);
- var loadIdx = Layer.load(1, {shade: [0.3, '#000']});
- Fast.api.ajax({
- url: 'split.ticket/sync',
- data: {ids: ids.join(',')}
- }, function (data, ret) {
- Layer.close(loadIdx);
- table.bootstrapTable('refresh');
- return false;
- }, function () {
- Layer.close(loadIdx);
- });
+ Toastr.info(syncBackgroundMsg);
+ Controller.api.startBackgroundSync(table, ids, true);
});
return false;
});
},
add: function () {
- Controller.api.bindevent();
+ Form.api.bindevent($('form[role=form]'), function (data, ret) {
+ var ticketId = ret.data && ret.data.id ? parseInt(ret.data.id, 10) : 0;
+ var syncTicketMsg = (typeof Config.syncTicketStartedMsg !== 'undefined' && Config.syncTicketStartedMsg)
+ ? Config.syncTicketStartedMsg
+ : '正在同步工单';
+ if (ticketId > 0) {
+ parent.__splitTicketPendingPostAddSyncIds = parent.__splitTicketPendingPostAddSyncIds || [];
+ parent.__splitTicketPendingPostAddSyncIds.push(ticketId);
+ }
+ if (parent && parent.Toastr) {
+ parent.Toastr.info(syncTicketMsg);
+ }
+ parent.$('.btn-refresh').trigger('click');
+ if (window.name) {
+ var layerIndex = parent.Layer.getFrameIndex(window.name);
+ parent.Layer.close(layerIndex);
+ }
+ return false;
+ });
+ Controller.api.fixSelectPlaceholder();
+ Controller.api.bindNumberTypeToggle();
+ Controller.api.bindEndTimeCheck();
},
edit: function () {
Controller.api.bindevent();
},
api: {
+ /** @type {number[]} 正在手动同步的工单 ID */
+ syncingTicketIds: [],
+
+ /**
+ * 后台同步:标记「同步中」并请求 sync 接口
+ *
+ * @param {object} table bootstrapTable 实例
+ * @param {number[]} ids 工单 ID
+ * @param {boolean} disableSyncBtn 是否禁用工具栏同步按钮
+ */
+ startBackgroundSync: function (table, ids, disableSyncBtn) {
+ ids = (ids || []).map(function (id) {
+ return parseInt(id, 10);
+ }).filter(function (id) {
+ return !isNaN(id) && id > 0;
+ });
+ if (!ids.length) {
+ return;
+ }
+ Controller.api.markTicketsSyncing(table, ids);
+ if (disableSyncBtn) {
+ $('.btn-sync').addClass('btn-disabled disabled');
+ }
+ Fast.api.ajax({
+ url: 'split.ticket/sync',
+ data: {ids: ids.join(',')},
+ loading: false
+ }, function () {
+ Controller.api.finishTicketsSync(table);
+ }, function () {
+ Controller.api.finishTicketsSync(table);
+ });
+ },
+
+ /**
+ * 将选中工单标记为「同步中」并刷新列表展示(不请求后端)
+ */
+ markTicketsSyncing: function (table, ids) {
+ Controller.api.syncingTicketIds = (ids || []).map(function (id) {
+ return parseInt(id, 10);
+ }).filter(function (id) {
+ return !isNaN(id) && id > 0;
+ });
+ var data = table.bootstrapTable('getData');
+ table.bootstrapTable('load', data);
+ },
+
+ /**
+ * 同步结束:清除标记并刷新列表数据
+ */
+ finishTicketsSync: function (table) {
+ Controller.api.syncingTicketIds = [];
+ table.bootstrapTable('refresh');
+ var ids = Table.api.selectedids(table);
+ $('.btn-sync').toggleClass('btn-disabled disabled', ids.length === 0);
+ },
+
formatter: {
/**
* 工单类型:纯文本展示,无链接/标签样式
@@ -198,9 +285,27 @@ define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefin
return String(total);
},
syncDisplay: function (value, row) {
+ var rowId = parseInt(row.id, 10);
+ if (Controller.api.syncingTicketIds.indexOf(rowId) !== -1) {
+ return Controller.api.formatter.syncingDisplayHtml();
+ }
var text = value || '';
- var color = row.sync_status === 'success' ? 'success' : 'danger';
+ var color = 'danger';
+ if (row.sync_status === 'success') {
+ color = 'success';
+ } else if (row.sync_status === 'pending') {
+ color = 'muted';
+ }
return '' + Fast.api.escape(text) + '';
+ },
+ syncingDisplayHtml: function () {
+ var label = (typeof Config.syncInProgressMsg !== 'undefined' && Config.syncInProgressMsg)
+ ? Config.syncInProgressMsg
+ : '同步中';
+ return ''
+ + ''
+ + '' + Fast.api.escape(label) + ''
+ + '';
}
},
bindevent: function () {