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 () {