初始版本
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
-- 分流链接:平台分配域名(支持多个,一行一个)
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||
SELECT 'split_platform_domain', 'split', '平台分配域名', '一行填写一个根域名,如 example.com;不含 http:// 与路径,保存后在分流链接弹窗中可选用', 'text', '', '', '', '', ' rows="8" placeholder="example.com another.com"', NULL
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_platform_domain' LIMIT 1);
|
||||
|
||||
UPDATE `fa_config`
|
||||
SET `group` = 'split',
|
||||
`type` = 'text',
|
||||
`title` = '平台分配域名',
|
||||
`tip` = '一行填写一个根域名,如 example.com;不含 http:// 与路径,保存后在分流链接弹窗中可选用',
|
||||
`extend` = ' rows="8" placeholder="example.com another.com"'
|
||||
WHERE `name` = 'split_platform_domain';
|
||||
@@ -0,0 +1,54 @@
|
||||
-- 工单云控同步:Node 服务地址与各类型同步周期(分钟,0=不自动同步)
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||
SELECT 'split_scrm_node_host', 'split', '云控 Node 服务地址', '所有云控蜘蛛共用的 Headless 服务根地址', 'string', '', 'http://127.0.0.1:3001', '', '', '', NULL
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_scrm_node_host' LIMIT 1);
|
||||
|
||||
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||
SELECT 'split_sync_fail_pause_threshold', 'split', '连续同步失败暂停阈值', '连续同步失败达到该次数后自动关闭工单并暂停定时同步;同步成功后将清零重新计数;0 表示不因失败自动暂停', 'number', '', '5', '', '', '', NULL
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_fail_pause_threshold' LIMIT 1);
|
||||
|
||||
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||
SELECT 'split_sync_interval_a2c', 'split', 'A2C云控同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_a2c' LIMIT 1);
|
||||
|
||||
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||
SELECT 'split_sync_interval_haiwang', 'split', '海王同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_haiwang' LIMIT 1);
|
||||
|
||||
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||
SELECT 'split_sync_interval_huojian', 'split', '火箭云控同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_huojian' LIMIT 1);
|
||||
|
||||
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||
SELECT 'split_sync_interval_xinghe', 'split', '星河云控同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_xinghe' LIMIT 1);
|
||||
|
||||
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||
SELECT 'split_sync_interval_ss_customer', 'split', 'SS云控(Customer)同步周期(分钟)', '0 表示不自动同步', 'number', '', '5', '', '', '', NULL
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_customer' LIMIT 1);
|
||||
|
||||
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||
SELECT 'split_sync_interval_ceo_scrm', 'split', 'CEO SCRM同步周期(分钟)', '0 表示不自动同步(蜘蛛未实现)', 'number', '', '0', '', '', '', NULL
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_ceo_scrm' LIMIT 1);
|
||||
|
||||
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||
SELECT 'split_sync_interval_taiji', 'split', '太极云控同步周期(分钟)', '0 表示不自动同步', 'number', '', '0', '', '', '', NULL
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_taiji' LIMIT 1);
|
||||
|
||||
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||
SELECT 'split_sync_interval_ss_channel', 'split', 'SS云控(Channel)同步周期(分钟)', '0 表示不自动同步', 'number', '', '0', '', '', '', NULL
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_ss_channel' LIMIT 1);
|
||||
|
||||
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||
SELECT 'split_sync_interval_yifafa', 'split', '译发发云控同步周期(分钟)', '0 表示不自动同步', 'number', '', '0', '', '', '', NULL
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_yifafa' LIMIT 1);
|
||||
|
||||
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||
SELECT 'split_sync_interval_whatshub', 'split', 'Whatshub云控同步周期(分钟)', '0 表示不自动同步', 'number', '', '0', '', '', '', NULL
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_whatshub' LIMIT 1);
|
||||
|
||||
INSERT INTO `fa_config` (`name`, `group`, `title`, `tip`, `type`, `visible`, `value`, `content`, `rule`, `extend`, `setting`)
|
||||
SELECT 'split_sync_interval_sihai', 'split', '四海云控同步周期(分钟)', '0 表示不自动同步', 'number', '', '0', '', '', '', NULL
|
||||
FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `fa_config` WHERE `name` = 'split_sync_interval_sihai' LIMIT 1);
|
||||
@@ -0,0 +1,29 @@
|
||||
-- 分流管理 - 链接管理表
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
DROP TABLE IF EXISTS `fa_split_link`;
|
||||
CREATE TABLE `fa_split_link` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
|
||||
`admin_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '管理员ID',
|
||||
`countries` varchar(500) NOT NULL DEFAULT '' COMMENT '投放国家(ISO2逗号分隔)',
|
||||
`link_code` char(9) NOT NULL DEFAULT '' COMMENT '分流链接(9位小写字母)',
|
||||
`description` varchar(255) NOT NULL DEFAULT '' COMMENT '链接描述',
|
||||
`auto_reply` text COMMENT '自动回复语句(一行一条)',
|
||||
`ip_protect` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT 'IP防护:0=关闭,1=开启',
|
||||
`random_shuffle` tinyint(1) unsigned NOT NULL DEFAULT '0' COMMENT '随机打乱:0=关闭,1=开启',
|
||||
`status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态:normal=正常,hidden=停用',
|
||||
`createtime` bigint(16) DEFAULT NULL COMMENT '创建时间',
|
||||
`updatetime` bigint(16) DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
UNIQUE KEY `link_code` (`link_code`),
|
||||
KEY `admin_id` (`admin_id`),
|
||||
KEY `status` (`status`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='分流链接表';
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||
SELECT 'file', 0, 'split', '分流管理', 'fa fa-random', '', '', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||
FROM DUAL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split' LIMIT 1);
|
||||
@@ -0,0 +1,12 @@
|
||||
-- 分流链接:自动回复语句字段(一行一条,关联 link_code)
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE `fa_split_link`
|
||||
ADD COLUMN `auto_reply` text COMMENT '自动回复语句(一行一条)' AFTER `description`;
|
||||
|
||||
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||
SELECT 'file', r.id, 'split.link/autoreply', '自动回复', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||
FROM `fa_auth_rule` r
|
||||
WHERE r.name = 'split.link' AND r.ismenu = 0
|
||||
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.link/autoreply' LIMIT 1)
|
||||
LIMIT 1;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- 分流链接:像素配置 JSON 字段 + 权限节点
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE `fa_split_link`
|
||||
ADD COLUMN `pixel_config` mediumtext COMMENT '像素配置JSON' AFTER `auto_reply`;
|
||||
|
||||
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||
SELECT 'file', m.id, 'split.link/pixel', '像素配置', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||
FROM `fa_auth_rule` m
|
||||
WHERE m.name = 'split.link' AND m.ismenu = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.link/pixel' LIMIT 1)
|
||||
LIMIT 1;
|
||||
@@ -0,0 +1,4 @@
|
||||
-- 移除分流模块下重复的「管理我的域名」菜单(统一使用顶级「域名管理」)
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
DELETE FROM `fa_auth_rule` WHERE `name` LIKE 'split.mydomain%';
|
||||
@@ -0,0 +1,79 @@
|
||||
-- 分流管理 - 号码管理表与菜单权限
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
DROP TABLE IF EXISTS `fa_split_number`;
|
||||
CREATE TABLE `fa_split_number` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT '号码ID',
|
||||
`admin_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '管理员ID',
|
||||
`split_link_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '关联分流链接ID',
|
||||
`ticket_name` varchar(100) NOT NULL DEFAULT '' COMMENT '工单名称',
|
||||
`number` varchar(50) NOT NULL DEFAULT '' COMMENT '号码',
|
||||
`number_type` varchar(32) NOT NULL DEFAULT '' COMMENT '号码类型',
|
||||
`number_type_custom` varchar(50) NOT NULL DEFAULT '' COMMENT '自定义号码类型',
|
||||
`visit_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '访问次数',
|
||||
`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`),
|
||||
UNIQUE KEY `uk_link_number` (`split_link_id`,`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='分流号码表';
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||
SELECT 'file', r.id, 'split.number', '号码管理', 'fa fa-phone', '', '', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||
FROM `fa_auth_rule` r
|
||||
WHERE r.name = 'split' AND r.ismenu = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.number' 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/index', '查看', '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/index' 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/add', '添加', '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/add' 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/edit', '编辑', '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/edit' 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/del', '删除', '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/del' 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/batchupdate', '批量更新号码', '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/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;
|
||||
@@ -0,0 +1,109 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\admin\validate\split;
|
||||
|
||||
use app\admin\model\split\Link;
|
||||
use app\admin\model\split\Number as NumberModel;
|
||||
use think\Validate;
|
||||
|
||||
/**
|
||||
* 分流号码验证器(部署源文件,由 deploy_split_number.php 复制到 application)
|
||||
*/
|
||||
class Number extends Validate
|
||||
{
|
||||
protected $rule = [
|
||||
'number_type' => 'require|checkNumberType',
|
||||
'number_type_custom' => 'max:50|checkNumberTypeCustom',
|
||||
'split_link_id' => 'require|integer|gt:0|checkSplitLink',
|
||||
'ticket_name' => 'require|max:100',
|
||||
'numbers' => 'require|checkNumbers',
|
||||
'number' => 'require|max:50',
|
||||
'status' => 'require|checkStatus',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'number_type.require' => '请选择号码类型',
|
||||
'split_link_id.require' => '请选择分流链接',
|
||||
'ticket_name.require' => '请填写工单名称',
|
||||
'ticket_name.max' => '工单名称不能超过100个字符',
|
||||
'numbers.require' => '请填写号码',
|
||||
'number.require' => '请填写号码',
|
||||
'number.max' => '号码不能超过50个字符',
|
||||
'status.require' => '请选择状态',
|
||||
];
|
||||
|
||||
protected $scene = [
|
||||
'add' => ['number_type', 'number_type_custom', 'split_link_id', 'ticket_name', 'numbers', 'status'],
|
||||
'edit' => ['number_type', 'number_type_custom', 'split_link_id', 'ticket_name', 'number', 'status'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return bool|string
|
||||
*/
|
||||
protected function checkNumberType($value)
|
||||
{
|
||||
$key = (string) $value;
|
||||
$list = (new NumberModel())->getNumberTypeList();
|
||||
return isset($list[$key]) ? true : '号码类型无效';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @param mixed $rule
|
||||
* @param array $data
|
||||
* @return bool|string
|
||||
*/
|
||||
protected function checkNumberTypeCustom($value, $rule, array $data = [])
|
||||
{
|
||||
if (($data['number_type'] ?? '') !== 'custom') {
|
||||
return true;
|
||||
}
|
||||
if (trim((string) $value) === '') {
|
||||
return '请填写自定义号码类型';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @param mixed $rule
|
||||
* @param array $data
|
||||
* @return bool|string
|
||||
*/
|
||||
protected function checkSplitLink($value, $rule, array $data = [])
|
||||
{
|
||||
$linkId = (int) $value;
|
||||
$link = Link::get($linkId);
|
||||
if (!$link) {
|
||||
return '所选分流链接不存在';
|
||||
}
|
||||
if (($link['status'] ?? '') !== 'normal') {
|
||||
return '所选分流链接已停用';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return bool|string
|
||||
*/
|
||||
protected function checkNumbers($value)
|
||||
{
|
||||
$list = NumberModel::parseNumbersText((string) $value);
|
||||
return $list !== [] ? true : '请至少填写一个有效号码(每行一个)';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return bool|string
|
||||
*/
|
||||
protected function checkStatus($value)
|
||||
{
|
||||
$key = (string) $value;
|
||||
$list = (new NumberModel())->getStatusList();
|
||||
return isset($list[$key]) ? true : '状态无效';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
-- 号码表:排序权重(可选,跳转轮转按 id 顺序;随机打乱通过新号码随机插入顺序实现)
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE `fa_split_number`
|
||||
ADD COLUMN `weigh` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '排序权重(越大越靠前)' AFTER `status`;
|
||||
|
||||
UPDATE `fa_split_number` SET `weigh` = `id` WHERE `weigh` = 0;
|
||||
@@ -0,0 +1,89 @@
|
||||
-- 分流管理 - 工单管理表与菜单权限
|
||||
SET NAMES utf8mb4;
|
||||
SET FOREIGN_KEY_CHECKS = 0;
|
||||
|
||||
DROP TABLE IF EXISTS `fa_split_ticket`;
|
||||
CREATE TABLE `fa_split_ticket` (
|
||||
`id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID',
|
||||
`admin_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '管理员ID',
|
||||
`ticket_type` varchar(32) NOT NULL DEFAULT '' COMMENT '工单类型',
|
||||
`ticket_name` varchar(100) NOT NULL DEFAULT '' COMMENT '工单名称',
|
||||
`ticket_url` varchar(1000) NOT NULL DEFAULT '' COMMENT '工单链接',
|
||||
`ticket_total` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '工单总量',
|
||||
`split_link_id` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '关联分流链接ID',
|
||||
`number_type` varchar(32) NOT NULL DEFAULT '' COMMENT '号码类型',
|
||||
`number_type_custom` varchar(50) NOT NULL DEFAULT '' COMMENT '自定义号码类型',
|
||||
`start_time` bigint(16) DEFAULT NULL COMMENT '开始时间',
|
||||
`end_time` bigint(16) DEFAULT NULL COMMENT '到期时间',
|
||||
`order_limit` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '单号上限',
|
||||
`assign_ratio` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '下号比率',
|
||||
`account` varchar(50) NOT NULL DEFAULT '' COMMENT '工单账号',
|
||||
`password` varchar(50) NOT NULL DEFAULT '' COMMENT '工单密码',
|
||||
`status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态:normal=正常,hidden=停用',
|
||||
`complete_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '完成数量(同步)',
|
||||
`inbound_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '进线人数(同步)',
|
||||
`speed_per_hour` decimal(10,2) unsigned NOT NULL DEFAULT '0.00' COMMENT '当前速度:每小时进线(同步)',
|
||||
`number_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '号码数量含离线封号(同步)',
|
||||
`number_offline_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '离线号码数(同步)',
|
||||
`number_banned_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '封号号码数(同步)',
|
||||
`online_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '在线人数(同步)',
|
||||
`sync_status` enum('success','error','pending') NOT NULL DEFAULT 'pending' COMMENT '同步状态',
|
||||
`sync_time` bigint(16) DEFAULT NULL COMMENT '最近同步时间',
|
||||
`sync_message` varchar(255) NOT NULL DEFAULT '' COMMENT '同步失败摘要',
|
||||
`createtime` bigint(16) DEFAULT NULL COMMENT '创建时间',
|
||||
`updatetime` bigint(16) DEFAULT NULL COMMENT '更新时间',
|
||||
PRIMARY KEY (`id`),
|
||||
KEY `admin_id` (`admin_id`),
|
||||
KEY `split_link_id` (`split_link_id`),
|
||||
KEY `ticket_type` (`ticket_type`),
|
||||
KEY `start_time` (`start_time`),
|
||||
KEY `end_time` (`end_time`),
|
||||
KEY `status` (`status`),
|
||||
KEY `sync_status` (`sync_status`),
|
||||
KEY `sync_time` (`sync_time`)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci COMMENT='分流工单表';
|
||||
|
||||
SET FOREIGN_KEY_CHECKS = 1;
|
||||
|
||||
-- 菜单:工单管理(父级 split)
|
||||
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||
SELECT 'file', r.id, 'split.ticket', '工单管理', 'fa fa-ticket', '', '', 1, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||
FROM `fa_auth_rule` r
|
||||
WHERE r.name = 'split' AND r.ismenu = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.ticket' 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.ticket/index', '查看', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||
FROM `fa_auth_rule` m
|
||||
WHERE m.name = 'split.ticket' AND m.ismenu = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.ticket/index' 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.ticket/add', '添加', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||
FROM `fa_auth_rule` m
|
||||
WHERE m.name = 'split.ticket' AND m.ismenu = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.ticket/add' 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.ticket/edit', '编辑', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||
FROM `fa_auth_rule` m
|
||||
WHERE m.name = 'split.ticket' AND m.ismenu = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.ticket/edit' 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.ticket/del', '删除', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||
FROM `fa_auth_rule` m
|
||||
WHERE m.name = 'split.ticket' AND m.ismenu = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.ticket/del' 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.ticket/multi', '批量更新', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||
FROM `fa_auth_rule` m
|
||||
WHERE m.name = 'split.ticket' AND m.ismenu = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.ticket/multi' LIMIT 1)
|
||||
LIMIT 1;
|
||||
@@ -0,0 +1,6 @@
|
||||
-- 工单表补充 status 字段(列表开关依赖此列)
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE `fa_split_ticket`
|
||||
ADD COLUMN `status` enum('normal','hidden') NOT NULL DEFAULT 'normal' COMMENT '状态:normal=正常,hidden=停用' AFTER `password`,
|
||||
ADD KEY `status` (`status`);
|
||||
@@ -0,0 +1,28 @@
|
||||
-- 工单表:统计字段 + ticket_total 整数化(供列表展示与后续同步接口写入)
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
-- 1. 同步统计字段(若已存在则跳过需手工处理)
|
||||
ALTER TABLE `fa_split_ticket`
|
||||
ADD COLUMN `complete_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '完成数量(同步)' AFTER `status`,
|
||||
ADD COLUMN `inbound_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '进线人数(同步)' AFTER `complete_count`,
|
||||
ADD COLUMN `speed_per_hour` decimal(10,2) unsigned NOT NULL DEFAULT '0.00' COMMENT '当前速度:每小时进线(同步)' AFTER `inbound_count`,
|
||||
ADD COLUMN `number_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '号码数量含离线封号(同步)' AFTER `speed_per_hour`,
|
||||
ADD COLUMN `number_offline_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '离线号码数(同步)' AFTER `number_count`,
|
||||
ADD COLUMN `number_banned_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '封号号码数(同步)' AFTER `number_offline_count`,
|
||||
ADD COLUMN `online_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '在线人数(同步)' AFTER `number_banned_count`,
|
||||
ADD COLUMN `sync_status` enum('success','error','pending') NOT NULL DEFAULT 'pending' COMMENT '同步状态' AFTER `online_count`,
|
||||
ADD COLUMN `sync_time` bigint(16) DEFAULT NULL COMMENT '最近同步时间' AFTER `sync_status`,
|
||||
ADD COLUMN `sync_message` varchar(255) NOT NULL DEFAULT '' COMMENT '同步失败摘要' AFTER `sync_time`;
|
||||
|
||||
ALTER TABLE `fa_split_ticket`
|
||||
ADD KEY `sync_status` (`sync_status`),
|
||||
ADD KEY `sync_time` (`sync_time`);
|
||||
|
||||
-- 2. ticket_total:varchar -> int unsigned(非数字旧值变为 0)
|
||||
UPDATE `fa_split_ticket` SET `ticket_total` = '0' WHERE `ticket_total` REGEXP '[^0-9]' OR `ticket_total` = '';
|
||||
ALTER TABLE `fa_split_ticket`
|
||||
MODIFY COLUMN `ticket_total` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '工单总量';
|
||||
|
||||
-- 3. 号码表组合索引:加速按工单汇总 visit_count
|
||||
ALTER TABLE `fa_split_number`
|
||||
ADD KEY `idx_ticket_agg` (`admin_id`, `split_link_id`, `ticket_name`(50));
|
||||
@@ -0,0 +1,20 @@
|
||||
-- 工单云控定时同步:扩展字段 + 手动同步权限节点
|
||||
SET NAMES utf8mb4;
|
||||
|
||||
ALTER TABLE `fa_split_ticket`
|
||||
ADD COLUMN `sync_fail_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '连续同步失败次数' AFTER `sync_message`,
|
||||
ADD COLUMN `speed_snapshot_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '速度计算基准完成数' AFTER `sync_fail_count`,
|
||||
ADD COLUMN `speed_snapshot_time` bigint(16) DEFAULT NULL COMMENT '速度计算基准时间' AFTER `speed_snapshot_count`;
|
||||
|
||||
ALTER TABLE `fa_split_number`
|
||||
ADD COLUMN `platform_status` enum('online','offline','unknown') NOT NULL DEFAULT 'unknown' COMMENT '云控平台在线状态' AFTER `manual_manage`,
|
||||
ADD COLUMN `last_sync_visit_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '上次同步时访问次数' AFTER `platform_status`,
|
||||
ADD COLUMN `last_sync_inbound_count` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '上次同步时进线人数' AFTER `last_sync_visit_count`,
|
||||
ADD COLUMN `no_inbound_click_streak` int(10) unsigned NOT NULL DEFAULT '0' COMMENT '进线无增长期间连续点击增量' AFTER `last_sync_inbound_count`;
|
||||
|
||||
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
|
||||
SELECT 'file', m.id, 'split.ticket/sync', '同步状态', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
|
||||
FROM `fa_auth_rule` m
|
||||
WHERE m.name = 'split.ticket' AND m.ismenu = 1
|
||||
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.ticket/sync' LIMIT 1)
|
||||
LIMIT 1;
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\admin\validate\split;
|
||||
|
||||
use app\admin\model\split\Link;
|
||||
use app\admin\model\split\Ticket as TicketModel;
|
||||
use think\Validate;
|
||||
|
||||
/**
|
||||
* 分流工单验证器(部署源文件,由 deploy_split_ticket.php 复制到 application)
|
||||
*/
|
||||
class Ticket extends Validate
|
||||
{
|
||||
protected $rule = [
|
||||
'ticket_type' => 'require|checkTicketType',
|
||||
'ticket_name' => 'require|max:100',
|
||||
'ticket_url' => 'require|max:1000',
|
||||
'ticket_total' => 'require|integer|egt:0',
|
||||
'split_link_id' => 'require|integer|gt:0|checkSplitLink',
|
||||
'number_type' => 'require|checkNumberType',
|
||||
'number_type_custom' => 'max:50|checkNumberTypeCustom',
|
||||
'start_time' => 'require|checkStartTime',
|
||||
'end_time' => 'require|checkEndTime',
|
||||
'order_limit' => 'require|integer|egt:0',
|
||||
'assign_ratio' => 'require|integer|egt:0',
|
||||
'account' => 'max:50',
|
||||
'password' => 'max:50',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'ticket_type.require' => '请选择工单类型',
|
||||
'ticket_name.require' => '请填写工单名称',
|
||||
'ticket_name.max' => '工单名称不能超过100个字符',
|
||||
'ticket_url.require' => '请填写工单链接',
|
||||
'ticket_url.max' => '工单链接不能超过1000个字符',
|
||||
'ticket_total.require' => '请填写工单总量',
|
||||
'ticket_total.integer' => '工单总量必须为整数',
|
||||
'ticket_total.egt' => '工单总量不能小于0',
|
||||
'split_link_id.require' => '请选择分流链接',
|
||||
'number_type.require' => '请选择号码类型',
|
||||
'start_time.require' => '请选择开始时间',
|
||||
'end_time.require' => '请选择到期时间',
|
||||
'order_limit.require' => '请填写单号上限',
|
||||
'order_limit.egt' => '单号上限不能小于0',
|
||||
'assign_ratio.require' => '请填写下号比率',
|
||||
'assign_ratio.egt' => '下号比率不能小于0',
|
||||
];
|
||||
|
||||
protected $scene = [
|
||||
'add' => [
|
||||
'ticket_type', 'ticket_name', 'ticket_url', 'ticket_total', 'split_link_id',
|
||||
'number_type', 'number_type_custom', 'start_time', 'end_time',
|
||||
'order_limit', 'assign_ratio', 'account', 'password',
|
||||
],
|
||||
'edit' => [
|
||||
'ticket_type', 'ticket_name', 'ticket_url', 'ticket_total', 'split_link_id',
|
||||
'number_type', 'number_type_custom', 'start_time', 'end_time',
|
||||
'order_limit', 'assign_ratio', 'account', 'password',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return bool|string
|
||||
*/
|
||||
protected function checkTicketType($value)
|
||||
{
|
||||
$key = (string) $value;
|
||||
$list = (new TicketModel())->getTicketTypeList();
|
||||
return isset($list[$key]) ? true : '工单类型无效';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return bool|string
|
||||
*/
|
||||
protected function checkNumberType($value)
|
||||
{
|
||||
$key = (string) $value;
|
||||
$list = (new TicketModel())->getNumberTypeList();
|
||||
return isset($list[$key]) ? true : '号码类型无效';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @param mixed $rule
|
||||
* @param array $data
|
||||
* @return bool|string
|
||||
*/
|
||||
protected function checkNumberTypeCustom($value, $rule, array $data = [])
|
||||
{
|
||||
if (($data['number_type'] ?? '') !== 'custom') {
|
||||
return true;
|
||||
}
|
||||
if (trim((string) $value) === '') {
|
||||
return '请填写自定义号码类型';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @param mixed $rule
|
||||
* @param array $data
|
||||
* @return bool|string
|
||||
*/
|
||||
protected function checkSplitLink($value, $rule, array $data = [])
|
||||
{
|
||||
$linkId = (int) $value;
|
||||
$link = Link::get($linkId);
|
||||
if (!$link) {
|
||||
return '所选分流链接不存在';
|
||||
}
|
||||
if (($link['status'] ?? '') !== 'normal') {
|
||||
return '所选分流链接已停用';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return bool|string
|
||||
*/
|
||||
protected function checkStartTime($value)
|
||||
{
|
||||
$ts = is_numeric($value) ? (int) $value : strtotime((string) $value);
|
||||
return ($ts !== false && $ts > 0) ? true : '开始时间格式无效';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @param mixed $rule
|
||||
* @param array $data
|
||||
* @return bool|string
|
||||
*/
|
||||
protected function checkEndTime($value, $rule, array $data = [])
|
||||
{
|
||||
$endTs = is_numeric($value) ? (int) $value : strtotime((string) $value);
|
||||
if ($endTs === false || $endTs <= 0) {
|
||||
return '到期时间格式无效';
|
||||
}
|
||||
$startRaw = $data['start_time'] ?? '';
|
||||
$startTs = is_numeric($startRaw) ? (int) $startRaw : strtotime((string) $startRaw);
|
||||
if ($startTs === false || $startTs <= 0) {
|
||||
return true;
|
||||
}
|
||||
if ($endTs <= $startTs) {
|
||||
return '到期时间必须晚于开始时间';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
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;
|
||||
use think\console\input\Option;
|
||||
use think\console\Output;
|
||||
|
||||
/**
|
||||
* 工单云控定时同步 CLI
|
||||
*
|
||||
* 用法:
|
||||
* php think split:sync-tickets
|
||||
* php think split:sync-tickets --ticket=12
|
||||
*/
|
||||
class SplitSyncTickets extends Command
|
||||
{
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setName('split:sync-tickets')
|
||||
->addOption('ticket', 't', Option::VALUE_OPTIONAL, '指定工单 ID(强制同步,忽略周期)', '')
|
||||
->setDescription('同步分流工单云控数据');
|
||||
}
|
||||
|
||||
protected function execute(Input $input, Output $output): void
|
||||
{
|
||||
set_time_limit(0);
|
||||
SplitTicketSyncLogger::log('cli', 'command start', [
|
||||
'ticketOption' => trim((string) $input->getOption('ticket')),
|
||||
'appDebug' => SplitTicketSyncLogger::isEnabled(),
|
||||
]);
|
||||
$service = new SplitTicketSyncService();
|
||||
$ticketId = trim((string) $input->getOption('ticket'));
|
||||
|
||||
if ($ticketId !== '' && ctype_digit($ticketId)) {
|
||||
$result = $service->syncOne((int) $ticketId, true);
|
||||
if (!empty($result['skipped'])) {
|
||||
$output->writeln('<comment>跳过: ' . ($result['message'] ?? '') . '</comment>');
|
||||
return;
|
||||
}
|
||||
if ($result['success']) {
|
||||
$output->writeln('<info>工单 #' . $ticketId . ' 同步成功</info>');
|
||||
} else {
|
||||
$output->writeln('<error>工单 #' . $ticketId . ' 同步失败: ' . ($result['message'] ?? '') . '</error>');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$count = $service->syncDueTickets();
|
||||
$output->writeln('<info>本次处理工单数: ' . $count . '</info>');
|
||||
if (SplitTicketSyncLogger::isEnabled()) {
|
||||
$output->writeln('<comment>调试日志已写入 runtime/log/split_sync.log</comment>');
|
||||
}
|
||||
}
|
||||
}
|
||||
+415
@@ -0,0 +1,415 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\admin\controller\split;
|
||||
|
||||
use app\admin\model\Domain as DomainModel;
|
||||
use app\common\controller\Backend;
|
||||
use app\common\library\CountryIso;
|
||||
use app\common\service\SplitAutoReplyService;
|
||||
use app\common\service\SplitLinkCodeService;
|
||||
use app\common\service\SplitPixelConfigService;
|
||||
use app\common\service\SplitPlatformDomainService;
|
||||
use think\Db;
|
||||
use think\Exception;
|
||||
use think\exception\DbException;
|
||||
use think\exception\PDOException;
|
||||
use think\exception\ValidateException;
|
||||
use think\response\Json;
|
||||
|
||||
/**
|
||||
* 链接管理
|
||||
*
|
||||
* @icon fa fa-link
|
||||
* @remark 分流链接管理,自动生成9位字母链接码
|
||||
*/
|
||||
class Link extends Backend
|
||||
{
|
||||
/** @var \app\admin\model\split\Link */
|
||||
protected $model = null;
|
||||
|
||||
protected $searchFields = 'link_code,description';
|
||||
|
||||
protected $dataLimit = 'personal';
|
||||
|
||||
protected $modelValidate = true;
|
||||
|
||||
protected $modelSceneValidate = true;
|
||||
|
||||
/** @var string[] 无需鉴权的操作 */
|
||||
protected $noNeedRight = ['script', 'copyinfo'];
|
||||
|
||||
/** @var string patches 视图目录(未完整部署时使用) */
|
||||
private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/link/';
|
||||
|
||||
public function _initialize()
|
||||
{
|
||||
parent::_initialize();
|
||||
|
||||
$lang = $this->request->langset();
|
||||
$lang = preg_match('/^([a-zA-Z\-_]{2,10})$/i', $lang) ? $lang : 'zh-cn';
|
||||
$langFile = ROOT_PATH . 'patches/application/admin/lang/' . $lang . '/split/link.php';
|
||||
if (is_file($langFile)) {
|
||||
\think\Lang::load($langFile);
|
||||
}
|
||||
|
||||
$this->model = new \app\admin\model\split\Link();
|
||||
$countryList = CountryIso::getOptions();
|
||||
$this->view->assign('countryList', $countryList);
|
||||
$this->view->assign('ipProtectList', $this->model->getIpProtectList());
|
||||
$this->view->assign('randomShuffleList', $this->model->getRandomShuffleList());
|
||||
$this->view->assign('statusList', $this->model->getStatusList());
|
||||
$this->assignconfig('countryList', $countryList);
|
||||
$this->assignconfig('ipProtectList', $this->model->getIpProtectList());
|
||||
$this->assignconfig('randomShuffleList', $this->model->getRandomShuffleList());
|
||||
$this->assignconfig('statusList', $this->model->getStatusList());
|
||||
|
||||
$this->setupPatchFrontend();
|
||||
}
|
||||
|
||||
/**
|
||||
* 未部署 JS 到 public 时,或 patches 版本较新时,jsname 指向 script 接口
|
||||
*/
|
||||
private function setupPatchFrontend(): void
|
||||
{
|
||||
$patchJs = ROOT_PATH . 'patches/public/assets/js/backend/split/link.js';
|
||||
$publicJs = ROOT_PATH . 'public/assets/js/backend/split/link.js';
|
||||
$usePatchJs = is_file($patchJs) && (
|
||||
!is_file($publicJs) || filemtime($patchJs) >= filemtime($publicJs)
|
||||
);
|
||||
if (!$usePatchJs) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cfg = is_array($this->view->config ?? null) ? $this->view->config : [];
|
||||
$version = (string) \think\Config::get('site.version');
|
||||
// 使用站内相对路径,避免生产环境 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染模板(优先 patches 视图,回退 application)
|
||||
*/
|
||||
private function fetchPatch(string $template): string
|
||||
{
|
||||
$patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html';
|
||||
$appFile = APP_PATH . 'admin/view/split/link/' . $template . '.html';
|
||||
if (is_file($patchFile)) {
|
||||
$file = $patchFile;
|
||||
} elseif (is_file($appFile)) {
|
||||
$file = $appFile;
|
||||
} else {
|
||||
$this->error('模板文件不存在');
|
||||
}
|
||||
return (string) $this->view->fetch($file);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表
|
||||
*
|
||||
* @return string|Json
|
||||
* @throws DbException
|
||||
* @throws \think\Exception
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$this->request->filter(['strip_tags', 'trim']);
|
||||
if (false === $this->request->isAjax()) {
|
||||
return $this->fetchPatch('index');
|
||||
}
|
||||
if ($this->request->request('keyField')) {
|
||||
return $this->selectpage();
|
||||
}
|
||||
[$where, $sort, $order, $offset, $limit] = $this->buildparams();
|
||||
$list = $this->model
|
||||
->where($where)
|
||||
->order($sort, $order)
|
||||
->paginate($limit);
|
||||
$result = ['total' => $list->total(), 'rows' => $list->items()];
|
||||
return json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加(打开表单时预生成分流链接,可编辑后保存)
|
||||
*/
|
||||
public function add()
|
||||
{
|
||||
if (false === $this->request->isPost()) {
|
||||
try {
|
||||
$defaultLinkCode = (new SplitLinkCodeService())->generateUnique();
|
||||
} catch (Exception $e) {
|
||||
$this->error($e->getMessage());
|
||||
}
|
||||
$this->view->assign('defaultLinkCode', $defaultLinkCode);
|
||||
return $this->fetchPatch('add');
|
||||
}
|
||||
|
||||
$params = $this->request->post('row/a', []);
|
||||
if ($params === []) {
|
||||
$this->error(__('Parameter %s can not be empty', ''));
|
||||
}
|
||||
|
||||
$params = $this->preExcludeFields($params);
|
||||
|
||||
if (isset($params['link_code'])) {
|
||||
$params['link_code'] = SplitLinkCodeService::normalize((string) $params['link_code']);
|
||||
}
|
||||
|
||||
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
|
||||
$params[$this->dataLimitField] = $this->auth->id;
|
||||
}
|
||||
|
||||
$result = false;
|
||||
Db::startTrans();
|
||||
try {
|
||||
if ($this->modelValidate) {
|
||||
$name = str_replace('\\model\\', '\\validate\\', get_class($this->model));
|
||||
$validate = $this->modelSceneValidate ? $name . '.add' : $name;
|
||||
$this->model->validateFailException()->validate($validate, $params);
|
||||
}
|
||||
$result = $this->model->allowField(true)->save($params);
|
||||
Db::commit();
|
||||
} catch (ValidateException $e) {
|
||||
Db::rollback();
|
||||
$this->error($e->getMessage());
|
||||
} catch (PDOException|Exception $e) {
|
||||
Db::rollback();
|
||||
$this->error($e->getMessage());
|
||||
}
|
||||
|
||||
if ($result === false) {
|
||||
$this->error(__('No rows were inserted'));
|
||||
}
|
||||
|
||||
$this->success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 复制分流链接弹窗所需数据(平台域名 + 我的域名列表)
|
||||
*
|
||||
* @return Json
|
||||
*/
|
||||
public function copyinfo(): Json
|
||||
{
|
||||
$platformDomains = $this->getPlatformDomains();
|
||||
|
||||
$domainQuery = (new DomainModel())->order('id', 'desc');
|
||||
if ($this->dataLimit) {
|
||||
$adminIds = $this->getDataLimitAdminIds();
|
||||
if (is_array($adminIds)) {
|
||||
$domainQuery->where($this->dataLimitField, 'in', $adminIds);
|
||||
}
|
||||
}
|
||||
|
||||
$myDomains = [];
|
||||
foreach ($domainQuery->select() as $domainRow) {
|
||||
$myDomains[] = [
|
||||
'id' => (int) $domainRow['id'],
|
||||
'domain' => (string) $domainRow['domain'],
|
||||
'ns_status' => (string) $domainRow['ns_status'],
|
||||
'ns_status_text' => (string) $domainRow['ns_status_text'],
|
||||
'dns_status' => (string) $domainRow['dns_status'],
|
||||
'dns_status_text' => (string) $domainRow['dns_status_text'],
|
||||
];
|
||||
}
|
||||
|
||||
$this->success('', null, [
|
||||
'platform_domains' => $platformDomains,
|
||||
'my_domains' => $myDomains,
|
||||
'domain_index_url' => (string) url('domain', '', false, false),
|
||||
'domain_add_url' => (string) url('domain/add', '', false, false),
|
||||
'config_index_url' => (string) url('general/config/index'),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取平台分配域名列表(优先 site 配置,回退数据库)
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function getPlatformDomains(): array
|
||||
{
|
||||
$raw = trim((string) \think\Config::get('site.split_platform_domain'));
|
||||
if ($raw === '') {
|
||||
$raw = trim((string) Db::name('config')->where('name', 'split_platform_domain')->value('value'));
|
||||
}
|
||||
return SplitPlatformDomainService::parseList($raw);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动回复:读取 / 保存与当前链接关联的回复语句
|
||||
*
|
||||
* @param string|null $ids 链接 ID
|
||||
* @return Json
|
||||
* @throws DbException
|
||||
*/
|
||||
public function autoreply($ids = null): Json
|
||||
{
|
||||
$row = $this->model->get($ids);
|
||||
if (!$row) {
|
||||
$this->error(__('No Results were found'));
|
||||
}
|
||||
|
||||
$adminIds = $this->getDataLimitAdminIds();
|
||||
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
|
||||
$this->error(__('You have no permission'));
|
||||
}
|
||||
|
||||
if ($this->request->isPost()) {
|
||||
$autoReply = (string) $this->request->post('auto_reply', '');
|
||||
$lines = SplitAutoReplyService::parseLines($autoReply);
|
||||
try {
|
||||
$row->save([
|
||||
'auto_reply' => SplitAutoReplyService::formatStorage($lines),
|
||||
]);
|
||||
} catch (PDOException|Exception $e) {
|
||||
$this->error($e->getMessage());
|
||||
}
|
||||
$this->success(__('Auto reply saved'));
|
||||
}
|
||||
|
||||
$this->success('', null, [
|
||||
'id' => (int) $row['id'],
|
||||
'link_code' => (string) $row['link_code'],
|
||||
'auto_reply' => SplitAutoReplyService::formatDisplay((string) $row->getAttr('auto_reply')),
|
||||
'line_count' => count($row->getAutoReplyLines()),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 像素配置:读取 / 保存
|
||||
*
|
||||
* @param string|null $ids 链接 ID
|
||||
* @return Json
|
||||
* @throws DbException
|
||||
*/
|
||||
public function pixel($ids = null): Json
|
||||
{
|
||||
$row = $this->model->get($ids);
|
||||
if (!$row) {
|
||||
$this->error(__('No Results were found'));
|
||||
}
|
||||
|
||||
$adminIds = $this->getDataLimitAdminIds();
|
||||
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
|
||||
$this->error(__('You have no permission'));
|
||||
}
|
||||
|
||||
if ($this->request->isPost()) {
|
||||
$payload = $this->request->post('pixel_config/a', []);
|
||||
if ($payload === []) {
|
||||
$raw = (string) $this->request->post('pixel_config', '');
|
||||
$decoded = json_decode($raw, true);
|
||||
$payload = is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
$existing = SplitPixelConfigService::parseStorage((string) $row->getAttr('pixel_config'));
|
||||
try {
|
||||
$merged = SplitPixelConfigService::mergeForSave($payload, $existing);
|
||||
$row->save([
|
||||
'pixel_config' => SplitPixelConfigService::encodeStorage($merged),
|
||||
]);
|
||||
} catch (\InvalidArgumentException $e) {
|
||||
$this->error($e->getMessage());
|
||||
} catch (PDOException|Exception $e) {
|
||||
$this->error($e->getMessage());
|
||||
}
|
||||
$this->success(__('Pixel config saved'));
|
||||
}
|
||||
|
||||
$config = SplitPixelConfigService::parseStorage((string) $row->getAttr('pixel_config'));
|
||||
$masked = SplitPixelConfigService::maskForAdmin($config);
|
||||
|
||||
$this->success('', null, [
|
||||
'id' => (int) $row['id'],
|
||||
'link_code' => (string) $row['link_code'],
|
||||
'facebook' => $masked[SplitPixelConfigService::PLATFORM_FACEBOOK],
|
||||
'tiktok' => $masked[SplitPixelConfigService::PLATFORM_TIKTOK],
|
||||
'event_options' => SplitPixelConfigService::EVENT_OPTIONS,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 编辑(分流链接码不可修改)
|
||||
*
|
||||
* @param string|null $ids
|
||||
* @return string
|
||||
* @throws DbException
|
||||
*/
|
||||
public function edit($ids = null)
|
||||
{
|
||||
$row = $this->model->get($ids);
|
||||
if (!$row) {
|
||||
$this->error(__('No Results were found'));
|
||||
}
|
||||
|
||||
$adminIds = $this->getDataLimitAdminIds();
|
||||
if (is_array($adminIds) && !in_array($row[$this->dataLimitField], $adminIds)) {
|
||||
$this->error(__('You have no permission'));
|
||||
}
|
||||
|
||||
if (false === $this->request->isPost()) {
|
||||
$this->view->assign('row', $row);
|
||||
$this->view->assign('selectedCountries', $row->getSelectedCountries());
|
||||
return $this->fetchPatch('edit');
|
||||
}
|
||||
|
||||
$params = $this->request->post('row/a', []);
|
||||
if ($params === []) {
|
||||
$this->error(__('Parameter %s can not be empty', ''));
|
||||
}
|
||||
|
||||
$params = $this->preExcludeFields($params);
|
||||
unset($params['link_code']);
|
||||
|
||||
$result = false;
|
||||
Db::startTrans();
|
||||
try {
|
||||
if ($this->modelValidate) {
|
||||
$name = str_replace('\\model\\', '\\validate\\', get_class($this->model));
|
||||
$validate = $this->modelSceneValidate ? $name . '.edit' : $name;
|
||||
$row->validateFailException()->validate($validate, $params);
|
||||
}
|
||||
$result = $row->allowField(true)->save($params);
|
||||
Db::commit();
|
||||
} catch (ValidateException $e) {
|
||||
Db::rollback();
|
||||
$this->error($e->getMessage());
|
||||
} catch (PDOException|Exception $e) {
|
||||
Db::rollback();
|
||||
$this->error($e->getMessage());
|
||||
}
|
||||
|
||||
if ($result === false) {
|
||||
$this->error(__('No rows were updated'));
|
||||
}
|
||||
|
||||
$this->success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出后台列表/表单 JS(patches 未部署到 public 时由 RequireJS 加载)
|
||||
*/
|
||||
public function script(): void
|
||||
{
|
||||
$jsFile = ROOT_PATH . 'patches/public/assets/js/backend/split/link.js';
|
||||
if (!is_file($jsFile)) {
|
||||
$jsFile = ROOT_PATH . 'public/assets/js/backend/split/link.js';
|
||||
}
|
||||
if (!is_file($jsFile)) {
|
||||
$this->error('脚本文件不存在');
|
||||
}
|
||||
$content = file_get_contents($jsFile);
|
||||
if ($content === false) {
|
||||
$this->error('读取脚本失败');
|
||||
}
|
||||
$response = response($content, 200, [
|
||||
'Content-Type' => 'application/javascript; charset=utf-8',
|
||||
'Cache-Control' => 'public, max-age=3600',
|
||||
]);
|
||||
throw new \think\exception\HttpResponseException($response);
|
||||
}
|
||||
}
|
||||
+435
@@ -0,0 +1,435 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\admin\controller\split;
|
||||
|
||||
use app\admin\model\split\Link as LinkModel;
|
||||
use app\admin\model\split\Number as NumberModel;
|
||||
use app\common\controller\Backend;
|
||||
use think\Db;
|
||||
use think\Exception;
|
||||
use think\exception\DbException;
|
||||
use think\exception\PDOException;
|
||||
use think\exception\ValidateException;
|
||||
use think\response\Json;
|
||||
|
||||
/**
|
||||
* 号码管理
|
||||
*
|
||||
* @icon fa fa-phone
|
||||
* @remark 分流号码管理,关联分流链接
|
||||
*/
|
||||
class Number extends Backend
|
||||
{
|
||||
/** @var NumberModel */
|
||||
protected $model = null;
|
||||
|
||||
protected $searchFields = 'ticket_name,number';
|
||||
|
||||
protected $dataLimit = 'personal';
|
||||
|
||||
protected $modelValidate = true;
|
||||
|
||||
protected $modelSceneValidate = true;
|
||||
|
||||
/** @var string */
|
||||
protected $multiFields = 'status,manual_manage';
|
||||
|
||||
/** @var string[] */
|
||||
protected $noNeedRight = ['script'];
|
||||
|
||||
/** @var string */
|
||||
private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/number/';
|
||||
|
||||
public function _initialize()
|
||||
{
|
||||
parent::_initialize();
|
||||
|
||||
$lang = $this->request->langset();
|
||||
$lang = preg_match('/^([a-zA-Z\-_]{2,10})$/i', $lang) ? $lang : 'zh-cn';
|
||||
$langFile = ROOT_PATH . 'patches/application/admin/lang/' . $lang . '/split/number.php';
|
||||
if (is_file($langFile)) {
|
||||
\think\Lang::load($langFile);
|
||||
}
|
||||
|
||||
$this->model = new NumberModel();
|
||||
$this->view->assign('numberTypeList', $this->model->getNumberTypeList());
|
||||
$this->view->assign('statusList', $this->model->getStatusList());
|
||||
$this->view->assign('manualManageList', $this->model->getManualManageList());
|
||||
$this->view->assign('splitLinkList', $this->buildSplitLinkList());
|
||||
$this->assignconfig('numberTypeList', $this->model->getNumberTypeList());
|
||||
$this->assignconfig('statusList', $this->model->getStatusList());
|
||||
$this->assignconfig('manualManageList', $this->model->getManualManageList());
|
||||
$this->assignconfig('platformStatusList', $this->model->getPlatformStatusList());
|
||||
|
||||
$this->setupPatchFrontend();
|
||||
}
|
||||
|
||||
private function setupPatchFrontend(): void
|
||||
{
|
||||
$patchJs = ROOT_PATH . 'patches/public/assets/js/backend/split/number.js';
|
||||
$publicJs = ROOT_PATH . 'public/assets/js/backend/split/number.js';
|
||||
$usePatchJs = is_file($patchJs) && (
|
||||
!is_file($publicJs) || filemtime($patchJs) >= filemtime($publicJs)
|
||||
);
|
||||
if (!$usePatchJs) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cfg = is_array($this->view->config ?? null) ? $this->view->config : [];
|
||||
$version = (string) \think\Config::get('site.version');
|
||||
// 使用站内相对路径,避免生产环境 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
private function buildSplitLinkList(): array
|
||||
{
|
||||
$query = (new LinkModel())->where('status', 'normal')->order('id', 'desc');
|
||||
if ($this->dataLimit) {
|
||||
$adminIds = $this->getDataLimitAdminIds();
|
||||
if (is_array($adminIds)) {
|
||||
$query->where('admin_id', 'in', $adminIds);
|
||||
}
|
||||
}
|
||||
$list = [];
|
||||
foreach ($query->select() as $row) {
|
||||
$code = (string) $row['link_code'];
|
||||
$desc = (string) $row['description'];
|
||||
$label = $desc !== '' ? $code . ' - ' . $desc : $code;
|
||||
$list[] = [
|
||||
'id' => (string) $row['id'],
|
||||
'label' => $label,
|
||||
];
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
private function fetchPatch(string $template): string
|
||||
{
|
||||
$patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html';
|
||||
$appFile = APP_PATH . 'admin/view/split/number/' . $template . '.html';
|
||||
if (is_file($patchFile)) {
|
||||
$file = $patchFile;
|
||||
} elseif (is_file($appFile)) {
|
||||
$file = $appFile;
|
||||
} else {
|
||||
$this->error('模板文件不存在');
|
||||
}
|
||||
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
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$this->request->filter(['strip_tags', 'trim']);
|
||||
if (false === $this->request->isAjax()) {
|
||||
return $this->fetchPatch('index');
|
||||
}
|
||||
if ($this->request->request('keyField')) {
|
||||
return $this->selectpage();
|
||||
}
|
||||
[$where, $sort, $order, $offset, $limit] = $this->buildparams();
|
||||
$list = $this->model
|
||||
->with(['splitLink'])
|
||||
->where($where)
|
||||
->order($sort, $order)
|
||||
->paginate($limit);
|
||||
$result = ['total' => $list->total(), 'rows' => $list->items()];
|
||||
return json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
* @throws DbException
|
||||
*/
|
||||
public function add()
|
||||
{
|
||||
if (false === $this->request->isPost()) {
|
||||
return $this->fetchPatch('add');
|
||||
}
|
||||
$params = $this->request->post('row/a', []);
|
||||
if ($params === []) {
|
||||
$this->error(__('Parameter %s can not be empty', ''));
|
||||
}
|
||||
$params = $this->preExcludeFields($params);
|
||||
if (($params['number_type'] ?? '') !== 'custom') {
|
||||
$params['number_type_custom'] = '';
|
||||
}
|
||||
|
||||
$numbersText = (string) ($params['numbers'] ?? '');
|
||||
unset($params['numbers']);
|
||||
$numberList = NumberModel::parseNumbersText($numbersText);
|
||||
if ($numberList === []) {
|
||||
$this->error(__('Please fill at least one number'));
|
||||
}
|
||||
|
||||
$validateData = array_merge($params, ['numbers' => $numbersText]);
|
||||
$adminId = $this->dataLimit && $this->dataLimitFieldAutoFill ? (int) $this->auth->id : 0;
|
||||
|
||||
$inserted = 0;
|
||||
$skipped = 0;
|
||||
Db::startTrans();
|
||||
try {
|
||||
if ($this->modelValidate) {
|
||||
$name = str_replace('\\model\\', '\\validate\\', get_class($this->model));
|
||||
$validate = $this->modelSceneValidate ? $name . '.add' : $name;
|
||||
$this->model->validateFailException()->validate($validate, $validateData);
|
||||
}
|
||||
$splitLinkId = (int) ($params['split_link_id'] ?? 0);
|
||||
$baseRow = [
|
||||
'split_link_id' => $splitLinkId,
|
||||
'ticket_name' => (string) ($params['ticket_name'] ?? ''),
|
||||
'number_type' => (string) ($params['number_type'] ?? ''),
|
||||
'number_type_custom' => (string) ($params['number_type_custom'] ?? ''),
|
||||
'status' => (string) ($params['status'] ?? 'normal'),
|
||||
'visit_count' => 0,
|
||||
'inbound_count' => 0,
|
||||
'manual_manage' => 0,
|
||||
];
|
||||
if ($adminId > 0) {
|
||||
$baseRow['admin_id'] = $adminId;
|
||||
}
|
||||
foreach ($numberList as $num) {
|
||||
$exists = $this->model
|
||||
->where('split_link_id', $splitLinkId)
|
||||
->where('number', $num)
|
||||
->find();
|
||||
if ($exists) {
|
||||
$skipped++;
|
||||
continue;
|
||||
}
|
||||
$row = $baseRow;
|
||||
$row['number'] = $num;
|
||||
$item = new NumberModel();
|
||||
$item->allowField(true)->isUpdate(false)->save($row);
|
||||
$inserted++;
|
||||
}
|
||||
if ($inserted === 0) {
|
||||
throw new Exception(__('All numbers already exist for this link'));
|
||||
}
|
||||
Db::commit();
|
||||
} catch (ValidateException $e) {
|
||||
Db::rollback();
|
||||
$this->error($e->getMessage());
|
||||
} catch (PDOException|Exception $e) {
|
||||
Db::rollback();
|
||||
$this->error($e->getMessage());
|
||||
}
|
||||
|
||||
$msg = __('Inserted %d number(s)', $inserted);
|
||||
if ($skipped > 0) {
|
||||
$msg .= ',' . __('Skipped %d duplicate(s)', $skipped);
|
||||
}
|
||||
$this->success($msg);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $ids
|
||||
* @return string
|
||||
* @throws DbException
|
||||
*/
|
||||
public function edit($ids = null)
|
||||
{
|
||||
$row = $this->model->get($ids);
|
||||
if (!$row) {
|
||||
$this->error(__('No Results were found'));
|
||||
}
|
||||
$adminIds = $this->getDataLimitAdminIds();
|
||||
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
|
||||
$this->error(__('You have no permission'));
|
||||
}
|
||||
if (false === $this->request->isPost()) {
|
||||
$this->view->assign('row', $row);
|
||||
return $this->fetchPatch('edit');
|
||||
}
|
||||
$params = $this->request->post('row/a', []);
|
||||
if ($params === []) {
|
||||
$this->error(__('Parameter %s can not be empty', ''));
|
||||
}
|
||||
$params = $this->preExcludeFields($params);
|
||||
if (($params['number_type'] ?? '') !== 'custom') {
|
||||
$params['number_type_custom'] = '';
|
||||
}
|
||||
|
||||
$newNumber = trim((string) ($params['number'] ?? ''));
|
||||
$splitLinkId = (int) ($params['split_link_id'] ?? $row['split_link_id']);
|
||||
$exists = $this->model
|
||||
->where('split_link_id', $splitLinkId)
|
||||
->where('number', $newNumber)
|
||||
->where('id', '<>', (int) $row['id'])
|
||||
->find();
|
||||
if ($exists) {
|
||||
$this->error(__('Number already exists for this link'));
|
||||
}
|
||||
|
||||
$result = false;
|
||||
Db::startTrans();
|
||||
try {
|
||||
if ($this->modelValidate) {
|
||||
$name = str_replace('\\model\\', '\\validate\\', get_class($this->model));
|
||||
$validate = $this->modelSceneValidate ? $name . '.edit' : $name;
|
||||
$row->validateFailException()->validate($validate, $params);
|
||||
}
|
||||
$result = $row->allowField(true)->save($params);
|
||||
Db::commit();
|
||||
} catch (ValidateException $e) {
|
||||
Db::rollback();
|
||||
$this->error($e->getMessage());
|
||||
} catch (PDOException|Exception $e) {
|
||||
Db::rollback();
|
||||
$this->error($e->getMessage());
|
||||
}
|
||||
if ($result === false) {
|
||||
$this->error(__('No rows were updated'));
|
||||
}
|
||||
$this->success();
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新号码状态与手动管理
|
||||
*/
|
||||
public function batchupdate(): void
|
||||
{
|
||||
if (false === $this->request->isPost()) {
|
||||
$this->error(__('Invalid parameters'));
|
||||
}
|
||||
$ids = $this->request->post('ids', '');
|
||||
if ($ids === '' || $ids === null) {
|
||||
$this->error(__('Parameter %s can not be empty', 'ids'));
|
||||
}
|
||||
if (!is_array($ids)) {
|
||||
$ids = explode(',', (string) $ids);
|
||||
}
|
||||
$ids = array_filter(array_map('intval', $ids));
|
||||
if ($ids === []) {
|
||||
$this->error(__('Parameter %s can not be empty', 'ids'));
|
||||
}
|
||||
|
||||
$status = (string) $this->request->post('status', '');
|
||||
$manualManage = $this->request->post('manual_manage', '');
|
||||
$statusList = $this->model->getStatusList();
|
||||
if (!isset($statusList[$status])) {
|
||||
$this->error(__('Invalid status'));
|
||||
}
|
||||
if (!in_array((string) $manualManage, ['0', '1'], true)) {
|
||||
$this->error(__('Invalid manual manage'));
|
||||
}
|
||||
|
||||
$query = $this->model->where('id', 'in', $ids);
|
||||
if ($this->dataLimit) {
|
||||
$adminIds = $this->getDataLimitAdminIds();
|
||||
if (is_array($adminIds)) {
|
||||
$query->where('admin_id', 'in', $adminIds);
|
||||
}
|
||||
}
|
||||
$count = $query->update([
|
||||
'status' => $status,
|
||||
'manual_manage' => (int) $manualManage,
|
||||
]);
|
||||
if ($count === false || $count === 0) {
|
||||
$this->error(__('No rows were updated'));
|
||||
}
|
||||
$this->success(__('Batch update success'));
|
||||
}
|
||||
|
||||
/**
|
||||
* 排除不可由表单提交的字段
|
||||
*
|
||||
* @param array<string, mixed> $params
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function preExcludeFields($params): array
|
||||
{
|
||||
$params = parent::preExcludeFields($params);
|
||||
unset(
|
||||
$params['visit_count'],
|
||||
$params['inbound_count'],
|
||||
$params['manual_manage'],
|
||||
$params['id']
|
||||
);
|
||||
return $params;
|
||||
}
|
||||
|
||||
public function script(): void
|
||||
{
|
||||
$jsFile = ROOT_PATH . 'patches/public/assets/js/backend/split/number.js';
|
||||
if (!is_file($jsFile)) {
|
||||
$jsFile = ROOT_PATH . 'public/assets/js/backend/split/number.js';
|
||||
}
|
||||
if (!is_file($jsFile)) {
|
||||
$this->error('脚本文件不存在');
|
||||
}
|
||||
$content = file_get_contents($jsFile);
|
||||
if ($content === false) {
|
||||
$this->error('读取脚本失败');
|
||||
}
|
||||
$response = response($content, 200, [
|
||||
'Content-Type' => 'application/javascript; charset=utf-8',
|
||||
'Cache-Control' => 'public, max-age=3600',
|
||||
]);
|
||||
throw new \think\exception\HttpResponseException($response);
|
||||
}
|
||||
}
|
||||
+452
@@ -0,0 +1,452 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\admin\controller\split;
|
||||
|
||||
use app\admin\model\split\Link as LinkModel;
|
||||
use app\admin\model\split\Ticket as TicketModel;
|
||||
use app\common\controller\Backend;
|
||||
use app\common\service\SplitTicketRuleService;
|
||||
use app\common\service\SplitTicketSyncLogger;
|
||||
use app\common\service\SplitTicketSyncService;
|
||||
use think\Db;
|
||||
use think\Lang;
|
||||
use think\Loader;
|
||||
use think\Exception;
|
||||
use think\exception\DbException;
|
||||
use think\exception\PDOException;
|
||||
use think\exception\ValidateException;
|
||||
use think\response\Json;
|
||||
|
||||
/**
|
||||
* 工单管理
|
||||
*
|
||||
* @icon fa fa-ticket
|
||||
* @remark 分流工单管理,关联分流链接
|
||||
*/
|
||||
class Ticket extends Backend
|
||||
{
|
||||
/** @var \app\admin\model\split\Ticket */
|
||||
protected $model = null;
|
||||
|
||||
protected $searchFields = 'ticket_name,ticket_url,ticket_total';
|
||||
|
||||
protected $dataLimit = 'personal';
|
||||
|
||||
protected $modelValidate = true;
|
||||
|
||||
protected $modelSceneValidate = true;
|
||||
|
||||
/** @var string[] 无需鉴权的方法 */
|
||||
protected $noNeedRight = ['script'];
|
||||
|
||||
/** @var string patches 视图目录 */
|
||||
private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/ticket/';
|
||||
|
||||
public function _initialize()
|
||||
{
|
||||
parent::_initialize();
|
||||
|
||||
$lang = $this->request->langset();
|
||||
$lang = preg_match('/^([a-zA-Z\-_]{2,10})$/i', $lang) ? $lang : 'zh-cn';
|
||||
$this->model = new \app\admin\model\split\Ticket();
|
||||
$this->view->assign('ticketTypeList', $this->model->getTicketTypeList());
|
||||
$this->view->assign('numberTypeList', $this->model->getNumberTypeList());
|
||||
$this->view->assign('statusList', $this->model->getStatusList());
|
||||
$this->view->assign('splitLinkList', $this->buildSplitLinkList());
|
||||
$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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载工单语言包(优先 patches)
|
||||
*/
|
||||
protected function loadlang($name): void
|
||||
{
|
||||
$lang = $this->request->langset();
|
||||
$lang = preg_match('/^([a-zA-Z\-_]{2,10})$/i', $lang) ? $lang : 'zh-cn';
|
||||
$name = Loader::parseName($name);
|
||||
$name = preg_match('/^([a-zA-Z0-9_\.\/]+)$/i', $name) ? $name : 'index';
|
||||
|
||||
$files = [
|
||||
APP_PATH . 'admin/lang/' . $lang . '/' . str_replace('.', '/', $name) . '.php',
|
||||
ROOT_PATH . 'patches/application/admin/lang/' . $lang . '/split/ticket.php',
|
||||
];
|
||||
$loaded = false;
|
||||
foreach ($files as $file) {
|
||||
if (is_file($file)) {
|
||||
Lang::load($file);
|
||||
$loaded = true;
|
||||
}
|
||||
}
|
||||
if (!$loaded) {
|
||||
parent::loadlang($name);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 未部署 JS 时指向 script 接口
|
||||
*/
|
||||
private function setupPatchFrontend(): void
|
||||
{
|
||||
$patchJs = ROOT_PATH . 'patches/public/assets/js/backend/split/ticket.js';
|
||||
$publicJs = ROOT_PATH . 'public/assets/js/backend/split/ticket.js';
|
||||
$usePatchJs = is_file($patchJs) && (
|
||||
!is_file($publicJs) || filemtime($patchJs) >= filemtime($publicJs)
|
||||
);
|
||||
if (!$usePatchJs) {
|
||||
return;
|
||||
}
|
||||
|
||||
$cfg = is_array($this->view->config ?? null) ? $this->view->config : [];
|
||||
$version = (string) \think\Config::get('site.version');
|
||||
// 使用站内相对路径,避免生产环境 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, array<string, string>>
|
||||
*/
|
||||
private function buildSplitLinkList(): array
|
||||
{
|
||||
$query = (new LinkModel())->where('status', 'normal')->order('id', 'desc');
|
||||
if ($this->dataLimit) {
|
||||
$adminIds = $this->getDataLimitAdminIds();
|
||||
if (is_array($adminIds)) {
|
||||
$query->where('admin_id', 'in', $adminIds);
|
||||
}
|
||||
}
|
||||
$list = [];
|
||||
foreach ($query->select() as $row) {
|
||||
$code = (string) $row['link_code'];
|
||||
$desc = (string) $row['description'];
|
||||
$label = $desc !== '' ? $code . ' - ' . $desc : $code;
|
||||
$list[] = [
|
||||
'id' => (string) $row['id'],
|
||||
'label' => $label,
|
||||
];
|
||||
}
|
||||
return $list;
|
||||
}
|
||||
|
||||
private function fetchPatch(string $template): string
|
||||
{
|
||||
$patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html';
|
||||
$appFile = APP_PATH . 'admin/view/split/ticket/' . $template . '.html';
|
||||
if (is_file($patchFile)) {
|
||||
$file = $patchFile;
|
||||
} elseif (is_file($appFile)) {
|
||||
$file = $appFile;
|
||||
} else {
|
||||
$this->error('模板文件不存在');
|
||||
}
|
||||
return (string) $this->view->fetch($file);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string|Json
|
||||
* @throws DbException
|
||||
* @throws \think\Exception
|
||||
*/
|
||||
public function index()
|
||||
{
|
||||
$this->request->filter(['strip_tags', 'trim']);
|
||||
if (false === $this->request->isAjax()) {
|
||||
return $this->fetchPatch('index');
|
||||
}
|
||||
if ($this->request->request('keyField')) {
|
||||
return $this->selectpage();
|
||||
}
|
||||
[$where, $sort, $order, $offset, $limit] = $this->buildparams();
|
||||
$ticketTable = $this->model->getTable();
|
||||
$clickSub = TicketModel::buildClickCountSubQuery();
|
||||
// 勿 with(splitLink):eagerlyType=0 为 JOIN 预载入,会重写 field 导致子查询 SQL 语法错误
|
||||
$list = $this->model
|
||||
->field($ticketTable . '.*,' . $clickSub)
|
||||
->where($where)
|
||||
->order($sort, $order)
|
||||
->paginate($limit);
|
||||
$result = ['total' => $list->total(), 'rows' => $list->items()];
|
||||
return json($result);
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步统计字段仅由接口写入,禁止表单提交篡改
|
||||
*
|
||||
* @param array<string, mixed> $params
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function preExcludeFields($params): array
|
||||
{
|
||||
$params = parent::preExcludeFields($params);
|
||||
unset(
|
||||
$params['complete_count'],
|
||||
$params['inbound_count'],
|
||||
$params['speed_per_hour'],
|
||||
$params['number_count'],
|
||||
$params['number_offline_count'],
|
||||
$params['number_banned_count'],
|
||||
$params['online_count'],
|
||||
$params['sync_status'],
|
||||
$params['sync_time'],
|
||||
$params['sync_message'],
|
||||
$params['sync_fail_count'],
|
||||
$params['speed_snapshot_count'],
|
||||
$params['speed_snapshot_time'],
|
||||
$params['click_count']
|
||||
);
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动同步选中工单
|
||||
*/
|
||||
public function sync(): void
|
||||
{
|
||||
if (!$this->request->isPost()) {
|
||||
$this->error(__('Invalid parameters'));
|
||||
}
|
||||
$ids = $this->request->post('ids', '');
|
||||
if ($ids === '') {
|
||||
$this->error(__('Parameter %s can not be empty', 'ids'));
|
||||
}
|
||||
$adminIds = $this->getDataLimitAdminIds();
|
||||
$pk = $this->model->getPk();
|
||||
$list = $this->model->where($pk, 'in', $ids)->select();
|
||||
if (!$list || count($list) === 0) {
|
||||
$this->error(__('No Results were found'));
|
||||
}
|
||||
|
||||
SplitTicketSyncLogger::log('web', 'manual sync request', [
|
||||
'ids' => $ids,
|
||||
'appDebug' => SplitTicketSyncLogger::isEnabled(),
|
||||
]);
|
||||
|
||||
$service = new SplitTicketSyncService();
|
||||
$ok = 0;
|
||||
$fail = 0;
|
||||
$messages = [];
|
||||
|
||||
foreach ($list as $row) {
|
||||
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
|
||||
$fail++;
|
||||
$messages[] = '#' . $row['id'] . ': 无权限';
|
||||
continue;
|
||||
}
|
||||
$result = $service->syncOne((int) $row['id'], true);
|
||||
if ($result['success']) {
|
||||
$ok++;
|
||||
} else {
|
||||
$fail++;
|
||||
$messages[] = '#' . $row['id'] . ': ' . ($result['message'] ?? '失败');
|
||||
}
|
||||
}
|
||||
|
||||
$summary = sprintf('成功 %d 条,失败 %d 条', $ok, $fail);
|
||||
if ($fail > 0) {
|
||||
$summary .= ';' . implode(';', array_slice($messages, 0, 5));
|
||||
}
|
||||
$this->success($summary);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新:工单状态变更时联动号码
|
||||
*
|
||||
* @param string $ids
|
||||
*/
|
||||
public function multi($ids = '')
|
||||
{
|
||||
if (!$this->request->isPost()) {
|
||||
$this->error(__('Invalid parameters'));
|
||||
}
|
||||
$ids = $ids ?: $this->request->post('ids', '');
|
||||
if ($ids === '') {
|
||||
$this->error(__('Parameter %s can not be empty', 'ids'));
|
||||
}
|
||||
|
||||
$params = $this->request->post('params', '');
|
||||
parse_str((string) $params, $values);
|
||||
$ruleService = new SplitTicketRuleService();
|
||||
|
||||
if (isset($values['status'])) {
|
||||
$pk = $this->model->getPk();
|
||||
$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);
|
||||
}
|
||||
}
|
||||
|
||||
if ($count > 0) {
|
||||
$this->success();
|
||||
}
|
||||
$this->error(__('No rows were updated'));
|
||||
}
|
||||
|
||||
parent::multi($ids);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
* @throws DbException
|
||||
*/
|
||||
public function add()
|
||||
{
|
||||
if (false === $this->request->isPost()) {
|
||||
return $this->fetchPatch('add');
|
||||
}
|
||||
$params = $this->request->post('row/a', []);
|
||||
if ($params === []) {
|
||||
$this->error(__('Parameter %s can not be empty', ''));
|
||||
}
|
||||
$params = $this->preExcludeFields($params);
|
||||
if (($params['number_type'] ?? '') !== 'custom') {
|
||||
$params['number_type_custom'] = '';
|
||||
}
|
||||
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
|
||||
$params[$this->dataLimitField] = $this->auth->id;
|
||||
}
|
||||
$result = false;
|
||||
Db::startTrans();
|
||||
try {
|
||||
if ($this->modelValidate) {
|
||||
$name = str_replace('\\model\\', '\\validate\\', get_class($this->model));
|
||||
$validate = $this->modelSceneValidate ? $name . '.add' : $name;
|
||||
$this->model->validateFailException()->validate($validate);
|
||||
}
|
||||
$result = $this->model->allowField(true)->save($params);
|
||||
Db::commit();
|
||||
} catch (ValidateException $e) {
|
||||
Db::rollback();
|
||||
$this->error($e->getMessage());
|
||||
} catch (PDOException|Exception $e) {
|
||||
Db::rollback();
|
||||
$this->error($e->getMessage());
|
||||
}
|
||||
if ($result === false) {
|
||||
$this->error(__('No rows were inserted'));
|
||||
}
|
||||
$this->success('', null, ['id' => (int) $this->model->id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $ids
|
||||
* @return string
|
||||
* @throws DbException
|
||||
*/
|
||||
public function edit($ids = null)
|
||||
{
|
||||
$row = $this->model->get($ids);
|
||||
if (!$row) {
|
||||
$this->error(__('No Results were found'));
|
||||
}
|
||||
$adminIds = $this->getDataLimitAdminIds();
|
||||
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
|
||||
$this->error(__('You have no permission'));
|
||||
}
|
||||
if (false === $this->request->isPost()) {
|
||||
$rowData = $row->toArray();
|
||||
$rowData['start_time'] = $rowData['start_time_text'] ?? '';
|
||||
$rowData['end_time'] = $rowData['end_time_text'] ?? '';
|
||||
$this->view->assign('row', $rowData);
|
||||
return $this->fetchPatch('edit');
|
||||
}
|
||||
$params = $this->request->post('row/a', []);
|
||||
if ($params === []) {
|
||||
$this->error(__('Parameter %s can not be empty', ''));
|
||||
}
|
||||
$params = $this->preExcludeFields($params);
|
||||
if (($params['number_type'] ?? '') !== 'custom') {
|
||||
$params['number_type_custom'] = '';
|
||||
}
|
||||
$oldStatus = (string) ($row['status'] ?? 'hidden');
|
||||
$result = false;
|
||||
Db::startTrans();
|
||||
try {
|
||||
if ($this->modelValidate) {
|
||||
$name = str_replace('\\model\\', '\\validate\\', get_class($this->model));
|
||||
$validate = $this->modelSceneValidate ? $name . '.edit' : $name;
|
||||
$row->validateFailException()->validate($validate);
|
||||
}
|
||||
$result = $row->allowField(true)->save($params);
|
||||
Db::commit();
|
||||
} catch (ValidateException $e) {
|
||||
Db::rollback();
|
||||
$this->error($e->getMessage());
|
||||
} catch (PDOException|Exception $e) {
|
||||
Db::rollback();
|
||||
$this->error($e->getMessage());
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
/**
|
||||
* 输出后台 JS(patches 未部署到 public 时)
|
||||
*/
|
||||
public function script(): void
|
||||
{
|
||||
$jsFile = ROOT_PATH . 'patches/public/assets/js/backend/split/ticket.js';
|
||||
if (!is_file($jsFile)) {
|
||||
$jsFile = ROOT_PATH . 'public/assets/js/backend/split/ticket.js';
|
||||
}
|
||||
if (!is_file($jsFile)) {
|
||||
$this->error('脚本文件不存在');
|
||||
}
|
||||
$content = file_get_contents($jsFile);
|
||||
if ($content === false) {
|
||||
$this->error('读取脚本失败');
|
||||
}
|
||||
$response = response($content, 200, [
|
||||
'Content-Type' => 'application/javascript; charset=utf-8',
|
||||
'Cache-Control' => 'public, max-age=3600',
|
||||
]);
|
||||
throw new \think\exception\HttpResponseException($response);
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'Domain' => '域名',
|
||||
'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' => '域名输入不一致,请重新确认',
|
||||
];
|
||||
+65
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'Countries' => '投放国家',
|
||||
'Countries help' => '可多选,存储为 ISO 两位国家代码',
|
||||
'Link_code' => '分流链接',
|
||||
'Link code auto tip' => '保存后系统自动生成 9 位小写字母链接码',
|
||||
'Description' => '链接描述',
|
||||
'Ip_protect' => 'IP防护',
|
||||
'Random_shuffle' => '随机打乱',
|
||||
'Status' => '状态',
|
||||
'Createtime' => '创建时间',
|
||||
'Ip protect off' => '关闭',
|
||||
'Ip protect on' => '开启',
|
||||
'Random shuffle off' => '关闭',
|
||||
'Random shuffle on' => '开启',
|
||||
'Status normal' => '正常',
|
||||
'Status hidden' => '停用',
|
||||
'Copy split link' => '复制分流链接',
|
||||
'Manage my domains' => '管理我的域名',
|
||||
'Domain management' => '域名管理',
|
||||
'Select main domain' => '选择主域名',
|
||||
'Platform assigned domain' => '平台分配域名',
|
||||
'My domains' => '我的域名',
|
||||
'Split platform domain empty' => '尚未配置平台分配域名,请前往',
|
||||
'Go system config' => '系统设置',
|
||||
'Split my domain empty' => '暂无域名,请先在域名管理中添加',
|
||||
'Split domain prefix tip' => '想使用自己的域名作为分流前缀,可以先去「我的域名」完成接入。',
|
||||
'Go add domain' => '去添加',
|
||||
'Generated result' => '生成结果',
|
||||
'Copy' => '复制',
|
||||
'Copy success' => '复制成功',
|
||||
'Copy failed' => '复制失败',
|
||||
'Open test' => '打开测试',
|
||||
'Close' => '关闭',
|
||||
'Split url empty' => '请先选择有效主域名',
|
||||
'System config' => '系统配置',
|
||||
'Auto reply' => '自动回复',
|
||||
'Reply statements' => '回复语句',
|
||||
'Reply statements tip'=> '一行填写一条回复语句,保存后与当前分流链接关联',
|
||||
'Auto reply saved' => '自动回复已保存',
|
||||
'Reply statements column' => '回复语',
|
||||
'NS' => 'NS',
|
||||
'DNS' => 'DNS',
|
||||
'Pixel config' => '像素配置',
|
||||
'Pixel config tip' => '浏览器 Pixel 会在分流中转页触发;服务端回传需要填写对应平台 Access Token,Token 留空会保留原配置。',
|
||||
'Facebook Pixel' => 'Facebook Pixel',
|
||||
'TikTok Pixel' => 'TikTok Pixel',
|
||||
'Add FB Pixel' => '+ 添加FB Pixel',
|
||||
'Add TK Pixel' => '+ 添加TK Pixel',
|
||||
'Pixel enabled' => '启用',
|
||||
'Server postback' => '服务端回传',
|
||||
'Pixel ID' => 'Pixel ID',
|
||||
'Access Token' => 'Access Token',
|
||||
'Test code' => '测试码',
|
||||
'Pixel event' => '事件',
|
||||
'Pixel sort' => '排序',
|
||||
'Pixel config saved' => '像素配置已保存',
|
||||
'Pixel ID required' => '请填写 Pixel ID',
|
||||
'Remove row' => '删除',
|
||||
'Optional' => '选填',
|
||||
'Pixel list empty' => '暂无配置,请点击上方按钮添加',
|
||||
'Pixel row index' => '序号',
|
||||
'Operate' => '操作',
|
||||
];
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'Id' => '号码ID',
|
||||
'Link_url' => '分流链接',
|
||||
'Ticket_name' => '工单名称',
|
||||
'Number' => '号码',
|
||||
'Numbers' => '号码',
|
||||
'Number_type' => '号码类型',
|
||||
'Number_type_custom' => '自定义号码类型',
|
||||
'Split_link_id' => '选择链接',
|
||||
'Visit_count' => '访问次数',
|
||||
'Inbound_count' => '进线人数',
|
||||
'Status' => '状态',
|
||||
'Manual_manage' => '手动管理',
|
||||
'Platform_status' => '平台状态',
|
||||
'Platform status online' => '在线',
|
||||
'Platform status offline' => '离线',
|
||||
'Platform status unknown' => '未知',
|
||||
'Createtime' => '创建时间',
|
||||
'Section basic' => '基础信息',
|
||||
'Status normal' => '正常',
|
||||
'Status hidden' => '停用',
|
||||
'Number type custom' => '自定义',
|
||||
'Manual manage yes' => '是',
|
||||
'Manual manage no' => '否',
|
||||
'Please select' => '请选择',
|
||||
'Numbers placeholder' => '每行填写一个号码',
|
||||
'Batch update title' => '批量更新号码',
|
||||
'Batch selected count' => '已选号码',
|
||||
'Batch update status' => '更新状态',
|
||||
'Batch update btn' => '更新状态',
|
||||
'Please fill at least one number' => '请至少填写一个有效号码',
|
||||
'All numbers already exist for this link' => '该链接下号码均已存在,未新增任何记录',
|
||||
'Number already exists for this link' => '该链接下已存在相同号码',
|
||||
'Inserted %d number(s)' => '成功添加 %d 条号码',
|
||||
'Skipped %d duplicate(s)' => '跳过 %d 条重复号码',
|
||||
'Invalid status' => '状态无效',
|
||||
'Invalid manual manage' => '手动管理选项无效',
|
||||
'Batch update success' => '批量更新成功',
|
||||
'Unit count' => '个',
|
||||
];
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'Ticket_type' => '工单类型',
|
||||
'Ticket_name' => '工单名称',
|
||||
'Ticket_url' => '工单链接',
|
||||
'Ticket_total' => '工单总量',
|
||||
'Split_link_id' => '分流链接',
|
||||
'Number_type' => '号码类型',
|
||||
'Number_type_custom' => '自定义号码类型',
|
||||
'Start_time' => '开始时间',
|
||||
'End_time' => '到期时间',
|
||||
'Order_limit' => '单号上限',
|
||||
'Assign_ratio' => '下号比率',
|
||||
'Account' => '工单账号',
|
||||
'Password' => '工单密码',
|
||||
'Status' => '状态',
|
||||
'Status normal' => '正常',
|
||||
'Status hidden' => '停用',
|
||||
'Complete_count' => '完成数量',
|
||||
'Ticket_progress' => '工单进度',
|
||||
'Inbound_ratio' => '进线比例',
|
||||
'Speed_per_hour' => '当前速度',
|
||||
'Number_count' => '号码数量',
|
||||
'Number_count_detail' => '离线 %s / 封号 %s',
|
||||
'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 pending' => '待同步',
|
||||
'Sync display error' => '同步异常',
|
||||
'Createtime' => '创建时间',
|
||||
'Section basic' => '基础信息',
|
||||
'Section time rule' => '时间与规则',
|
||||
'Section account' => '账号信息',
|
||||
'Number type custom' => '自定义',
|
||||
'Ticket type xinghe' => '星河云控',
|
||||
'Ticket type haiwang' => '海王',
|
||||
'Ticket type taiji' => '太极云控',
|
||||
'Ticket type huojian' => '火箭云控',
|
||||
'Ticket type ss_channel'=> 'SS云控(Channel)',
|
||||
'Ticket type ss_customer'=> 'SS云控(Customer)',
|
||||
'Ticket type yifafa' => '译发发云控',
|
||||
'Ticket type a2c' => 'A2C云控',
|
||||
'Ticket type ceo_scrm' => 'CEO SCRM',
|
||||
'Ticket type whatshub' => 'Whatshub云控',
|
||||
'Ticket type sihai' => '四海云控',
|
||||
'End time must after start' => '到期时间必须晚于开始时间',
|
||||
'Please select' => '请选择',
|
||||
];
|
||||
+138
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\admin\model\split;
|
||||
|
||||
use app\common\library\CountryIso;
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 分流链接模型
|
||||
*/
|
||||
class Link extends Model
|
||||
{
|
||||
protected $name = 'split_link';
|
||||
|
||||
protected $autoWriteTimestamp = 'integer';
|
||||
|
||||
protected $createTime = 'createtime';
|
||||
protected $updateTime = 'updatetime';
|
||||
protected $deleteTime = false;
|
||||
|
||||
protected $append = [
|
||||
'countries_text',
|
||||
'auto_reply_text',
|
||||
'ip_protect_text',
|
||||
'random_shuffle_text',
|
||||
'status_text',
|
||||
];
|
||||
|
||||
public function getIpProtectList(): array
|
||||
{
|
||||
return [
|
||||
'0' => __('Ip protect off'),
|
||||
'1' => __('Ip protect on'),
|
||||
];
|
||||
}
|
||||
|
||||
public function getRandomShuffleList(): array
|
||||
{
|
||||
return [
|
||||
'0' => __('Random shuffle off'),
|
||||
'1' => __('Random shuffle on'),
|
||||
];
|
||||
}
|
||||
|
||||
public function getStatusList(): array
|
||||
{
|
||||
return [
|
||||
'normal' => __('Status normal'),
|
||||
'hidden' => __('Status hidden'),
|
||||
];
|
||||
}
|
||||
|
||||
public function setCountriesAttr($value): string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
return CountryIso::codesToStorage($value);
|
||||
}
|
||||
return CountryIso::codesToStorage(explode(',', (string)$value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 分流链接码统一为小写
|
||||
*/
|
||||
public function setLinkCodeAttr($value): string
|
||||
{
|
||||
return strtolower(trim((string) $value));
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动回复:存储为一行一条(换行分隔)
|
||||
*
|
||||
* @param mixed $value
|
||||
*/
|
||||
public function setAutoReplyAttr($value): string
|
||||
{
|
||||
return \app\common\service\SplitAutoReplyService::formatStorage($value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 自动回复语句数组
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getAutoReplyLines(): array
|
||||
{
|
||||
return \app\common\service\SplitAutoReplyService::parseLines((string) $this->getAttr('auto_reply'));
|
||||
}
|
||||
|
||||
public function getCountriesTextAttr($value, $data): string
|
||||
{
|
||||
return CountryIso::codesToText((string)($data['countries'] ?? ''));
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表「回复语」列预览(最多 50 字 + ...,悬停用原始 auto_reply 换行展示)
|
||||
*/
|
||||
public function getAutoReplyTextAttr($value, $data): string
|
||||
{
|
||||
return \app\common\service\SplitAutoReplyService::previewForList((string) ($data['auto_reply'] ?? ''));
|
||||
}
|
||||
|
||||
public function getIpProtectTextAttr($value, $data): string
|
||||
{
|
||||
$value = $value !== '' && $value !== null ? $value : ($data['ip_protect'] ?? '');
|
||||
$list = $this->getIpProtectList();
|
||||
return $list[(string)$value] ?? '';
|
||||
}
|
||||
|
||||
public function getRandomShuffleTextAttr($value, $data): string
|
||||
{
|
||||
$value = $value !== '' && $value !== null ? $value : ($data['random_shuffle'] ?? '');
|
||||
$list = $this->getRandomShuffleList();
|
||||
return $list[(string)$value] ?? '';
|
||||
}
|
||||
|
||||
public function getStatusTextAttr($value, $data): string
|
||||
{
|
||||
$value = $value ?: ($data['status'] ?? '');
|
||||
$list = $this->getStatusList();
|
||||
return $list[$value] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 已选国家 ISO 代码数组(供编辑表单多选回显)
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function getSelectedCountries(): array
|
||||
{
|
||||
$countries = (string)$this->getAttr('countries');
|
||||
if ($countries === '') {
|
||||
return [];
|
||||
}
|
||||
return CountryIso::normalizeCodes(explode(',', $countries));
|
||||
}
|
||||
}
|
||||
+200
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\admin\model\split;
|
||||
|
||||
use app\common\service\SplitPlatformDomainService;
|
||||
use think\Db;
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 分流号码模型
|
||||
*/
|
||||
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';
|
||||
protected $updateTime = 'updatetime';
|
||||
protected $deleteTime = false;
|
||||
|
||||
protected $append = [
|
||||
'number_type_text',
|
||||
'link_url_text',
|
||||
'status_text',
|
||||
'manual_manage_text',
|
||||
'platform_status_text',
|
||||
];
|
||||
|
||||
/**
|
||||
* 号码类型(与工单模块一致)
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getNumberTypeList(): array
|
||||
{
|
||||
return [
|
||||
'whatsapp' => 'WhatsApp',
|
||||
'telegram' => 'Telegram',
|
||||
'line' => 'Line',
|
||||
'custom' => __('Number type custom'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getStatusList(): array
|
||||
{
|
||||
return [
|
||||
'normal' => __('Status normal'),
|
||||
'hidden' => __('Status hidden'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getManualManageList(): array
|
||||
{
|
||||
return [
|
||||
'0' => __('Manual manage no'),
|
||||
'1' => __('Manual manage yes'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getPlatformStatusList(): array
|
||||
{
|
||||
return [
|
||||
'online' => __('Platform status online'),
|
||||
'offline' => __('Platform status offline'),
|
||||
'unknown' => __('Platform status unknown'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联分流链接
|
||||
*/
|
||||
public function splitLink()
|
||||
{
|
||||
return $this->belongsTo(Link::class, 'split_link_id', 'id', [], 'LEFT')->setEagerlyType(0);
|
||||
}
|
||||
|
||||
public function setNumberTypeCustomAttr($value): string
|
||||
{
|
||||
return trim((string) $value);
|
||||
}
|
||||
|
||||
public function setManualManageAttr($value): int
|
||||
{
|
||||
return (int) $value === 1 ? 1 : 0;
|
||||
}
|
||||
|
||||
public function getNumberTypeTextAttr($value, $data): string
|
||||
{
|
||||
$type = (string) ($data['number_type'] ?? '');
|
||||
if ($type === 'custom') {
|
||||
$custom = trim((string) ($data['number_type_custom'] ?? ''));
|
||||
return $custom !== '' ? $custom : (string) __('Number type custom');
|
||||
}
|
||||
$list = $this->getNumberTypeList();
|
||||
return $list[$type] ?? $type;
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表展示用:仅显示分流链接码(非完整 URL)
|
||||
*/
|
||||
public function getLinkUrlTextAttr($value, $data): string
|
||||
{
|
||||
if (isset($data['split_link']) && is_array($data['split_link'])) {
|
||||
return (string) ($data['split_link']['link_code'] ?? '');
|
||||
}
|
||||
$linkId = (int) ($data['split_link_id'] ?? 0);
|
||||
if ($linkId <= 0) {
|
||||
return '';
|
||||
}
|
||||
return (string) Link::where('id', $linkId)->value('link_code');
|
||||
}
|
||||
|
||||
public function getStatusTextAttr($value, $data): string
|
||||
{
|
||||
$key = (string) ($data['status'] ?? '');
|
||||
$list = $this->getStatusList();
|
||||
return $list[$key] ?? $key;
|
||||
}
|
||||
|
||||
public function getManualManageTextAttr($value, $data): string
|
||||
{
|
||||
$key = (string) ((int) ($data['manual_manage'] ?? 0));
|
||||
$list = $this->getManualManageList();
|
||||
return $list[$key] ?? $key;
|
||||
}
|
||||
|
||||
public function getPlatformStatusTextAttr($value, $data): string
|
||||
{
|
||||
$key = (string) ($data['platform_status'] ?? 'unknown');
|
||||
$list = $this->getPlatformStatusList();
|
||||
return $list[$key] ?? $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据链接码生成完整分流 URL
|
||||
*/
|
||||
public static function buildLinkUrl(string $linkCode): string
|
||||
{
|
||||
$linkCode = trim($linkCode);
|
||||
if ($linkCode === '') {
|
||||
return '';
|
||||
}
|
||||
$raw = trim((string) \think\Config::get('site.split_platform_domain'));
|
||||
if ($raw === '') {
|
||||
$raw = trim((string) Db::name('config')->where('name', 'split_platform_domain')->value('value'));
|
||||
}
|
||||
$domains = SplitPlatformDomainService::parseList($raw);
|
||||
$domain = $domains[0] ?? '';
|
||||
if ($domain === '') {
|
||||
return '';
|
||||
}
|
||||
return 'https://' . $domain . '/s/' . $linkCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析 textarea 号码列表
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function parseNumbersText(string $text): array
|
||||
{
|
||||
$lines = preg_split('/\r\n|\r|\n/', $text) ?: [];
|
||||
$numbers = [];
|
||||
foreach ($lines as $line) {
|
||||
$num = trim((string) $line);
|
||||
if ($num === '') {
|
||||
continue;
|
||||
}
|
||||
$numbers[$num] = $num;
|
||||
}
|
||||
return array_values($numbers);
|
||||
}
|
||||
}
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\admin\model\split;
|
||||
|
||||
use think\Db;
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 分流工单模型
|
||||
*/
|
||||
class Ticket extends Model
|
||||
{
|
||||
protected $name = 'split_ticket';
|
||||
|
||||
protected $autoWriteTimestamp = 'integer';
|
||||
|
||||
protected $createTime = 'createtime';
|
||||
protected $updateTime = 'updatetime';
|
||||
protected $deleteTime = false;
|
||||
|
||||
protected $append = [
|
||||
'ticket_type_text',
|
||||
'number_type_text',
|
||||
'link_code_text',
|
||||
'start_time_text',
|
||||
'end_time_text',
|
||||
'status_text',
|
||||
'click_count',
|
||||
'ticket_progress_text',
|
||||
'inbound_ratio_text',
|
||||
'sync_display_text',
|
||||
];
|
||||
|
||||
/**
|
||||
* 工单类型
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getTicketTypeList(): array
|
||||
{
|
||||
return [
|
||||
'xinghe' => __('Ticket type xinghe'),
|
||||
'haiwang' => __('Ticket type haiwang'),
|
||||
'taiji' => __('Ticket type taiji'),
|
||||
'huojian' => __('Ticket type huojian'),
|
||||
'ss_channel' => __('Ticket type ss_channel'),
|
||||
'ss_customer' => __('Ticket type ss_customer'),
|
||||
'yifafa' => __('Ticket type yifafa'),
|
||||
'a2c' => __('Ticket type a2c'),
|
||||
'ceo_scrm' => __('Ticket type ceo_scrm'),
|
||||
'whatshub' => __('Ticket type whatshub'),
|
||||
'sihai' => __('Ticket type sihai'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getStatusList(): array
|
||||
{
|
||||
return [
|
||||
'normal' => __('Status normal'),
|
||||
'hidden' => __('Status hidden'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getSyncStatusList(): array
|
||||
{
|
||||
return [
|
||||
'success' => __('Sync status success'),
|
||||
'error' => __('Sync status error'),
|
||||
'pending' => __('Sync status pending'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 号码类型
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public function getNumberTypeList(): array
|
||||
{
|
||||
return [
|
||||
'whatsapp' => 'Whatsapp',
|
||||
'telegram' => 'Telegram',
|
||||
'line' => 'Line',
|
||||
'custom' => __('Number type custom'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 关联分流链接
|
||||
*/
|
||||
public function splitLink()
|
||||
{
|
||||
// IN 预载入:避免 eagerlyType=0(JOIN) 与列表 field 子查询冲突导致 SQL 1064
|
||||
return $this->belongsTo(Link::class, 'split_link_id', 'id', [], 'LEFT')->setEagerlyType(1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 列表子查询注入的点击数,无则按关联号码汇总
|
||||
*
|
||||
* @param mixed $value
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function getClickCountAttr($value, $data): int
|
||||
{
|
||||
if (isset($data['click_count']) && $data['click_count'] !== '' && $data['click_count'] !== null) {
|
||||
return (int) $data['click_count'];
|
||||
}
|
||||
return self::sumVisitCountForTicket($data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 工单进度:完成数量 / 工单总量
|
||||
*
|
||||
* @param mixed $value
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function getTicketProgressTextAttr($value, $data): string
|
||||
{
|
||||
$total = (int) ($data['ticket_total'] ?? 0);
|
||||
$complete = (int) ($data['complete_count'] ?? 0);
|
||||
if ($total <= 0) {
|
||||
return '0%';
|
||||
}
|
||||
$percent = round($complete / $total * 100, 2);
|
||||
return $percent . '%';
|
||||
}
|
||||
|
||||
/**
|
||||
* 进线比例:进线人数 / 点击数(点击数=关联号码 visit_count 之和)
|
||||
*
|
||||
* @param mixed $value
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function getInboundRatioTextAttr($value, $data): string
|
||||
{
|
||||
$clicks = $this->getClickCountAttr(null, $data);
|
||||
$inbound = (int) ($data['inbound_count'] ?? 0);
|
||||
if ($clicks <= 0) {
|
||||
return '—';
|
||||
}
|
||||
$percent = round($inbound / $clicks * 100, 2);
|
||||
return $percent . '%';
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步状态展示文案
|
||||
*
|
||||
* @param mixed $value
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
public function getSyncDisplayTextAttr($value, $data): string
|
||||
{
|
||||
$status = (string) ($data['sync_status'] ?? 'pending');
|
||||
$online = (int) ($data['online_count'] ?? 0);
|
||||
if ($status === 'success') {
|
||||
return sprintf((string) __('Sync display success'), $online);
|
||||
}
|
||||
if ($status === 'pending') {
|
||||
return (string) __('Sync display pending');
|
||||
}
|
||||
return (string) __('Sync display error');
|
||||
}
|
||||
|
||||
public function setStartTimeAttr($value): ?int
|
||||
{
|
||||
return self::parseTimeValue($value);
|
||||
}
|
||||
|
||||
public function setEndTimeAttr($value): ?int
|
||||
{
|
||||
return self::parseTimeValue($value);
|
||||
}
|
||||
|
||||
public function setNumberTypeCustomAttr($value): string
|
||||
{
|
||||
return trim((string) $value);
|
||||
}
|
||||
|
||||
public function setTicketTotalAttr($value): int
|
||||
{
|
||||
return max(0, (int) $value);
|
||||
}
|
||||
|
||||
public function getTicketTypeTextAttr($value, $data): string
|
||||
{
|
||||
$key = (string) ($data['ticket_type'] ?? '');
|
||||
$list = $this->getTicketTypeList();
|
||||
return $list[$key] ?? $key;
|
||||
}
|
||||
|
||||
public function getNumberTypeTextAttr($value, $data): string
|
||||
{
|
||||
$type = (string) ($data['number_type'] ?? '');
|
||||
if ($type === 'custom') {
|
||||
$custom = trim((string) ($data['number_type_custom'] ?? ''));
|
||||
return $custom !== '' ? $custom : (string) __('Number type custom');
|
||||
}
|
||||
$list = $this->getNumberTypeList();
|
||||
return $list[$type] ?? $type;
|
||||
}
|
||||
|
||||
public function getLinkCodeTextAttr($value, $data): string
|
||||
{
|
||||
if ($value !== '' && $value !== null) {
|
||||
return (string) $value;
|
||||
}
|
||||
$linkId = (int) ($data['split_link_id'] ?? 0);
|
||||
if ($linkId <= 0) {
|
||||
return '';
|
||||
}
|
||||
$code = Link::where('id', $linkId)->value('link_code');
|
||||
return (string) $code;
|
||||
}
|
||||
|
||||
public function getStartTimeTextAttr($value, $data): string
|
||||
{
|
||||
return self::formatTimeText($data['start_time'] ?? null);
|
||||
}
|
||||
|
||||
public function getEndTimeTextAttr($value, $data): string
|
||||
{
|
||||
return self::formatTimeText($data['end_time'] ?? null);
|
||||
}
|
||||
|
||||
public function getStatusTextAttr($value, $data): string
|
||||
{
|
||||
$key = (string) ($data['status'] ?? '');
|
||||
$list = $this->getStatusList();
|
||||
return $list[$key] ?? $key;
|
||||
}
|
||||
|
||||
/**
|
||||
* 构建列表用 click_count 子查询 SQL 片段
|
||||
*/
|
||||
public static function buildClickCountSubQuery(string $ticketTableAlias = ''): string
|
||||
{
|
||||
$ticketTable = (new self())->getTable();
|
||||
$numberTable = (new Number())->getTable();
|
||||
$t = $ticketTableAlias !== '' ? $ticketTableAlias : $ticketTable;
|
||||
return "(SELECT COALESCE(SUM(n.visit_count), 0) FROM `{$numberTable}` n "
|
||||
. "WHERE n.ticket_name = `{$t}`.ticket_name "
|
||||
. "AND n.split_link_id = `{$t}`.split_link_id "
|
||||
. "AND n.admin_id = `{$t}`.admin_id) AS click_count";
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $data
|
||||
*/
|
||||
private static function sumVisitCountForTicket(array $data): int
|
||||
{
|
||||
$ticketName = (string) ($data['ticket_name'] ?? '');
|
||||
$linkId = (int) ($data['split_link_id'] ?? 0);
|
||||
$adminId = (int) ($data['admin_id'] ?? 0);
|
||||
if ($ticketName === '' || $linkId <= 0) {
|
||||
return 0;
|
||||
}
|
||||
$sum = Db::name('split_number')
|
||||
->where('ticket_name', $ticketName)
|
||||
->where('split_link_id', $linkId)
|
||||
->where('admin_id', $adminId)
|
||||
->sum('visit_count');
|
||||
return (int) $sum;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*/
|
||||
private static function parseTimeValue($value): ?int
|
||||
{
|
||||
if ($value === '' || $value === null) {
|
||||
return null;
|
||||
}
|
||||
if (is_numeric($value)) {
|
||||
return (int) $value;
|
||||
}
|
||||
$ts = strtotime((string) $value);
|
||||
return $ts === false ? null : $ts;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
*/
|
||||
private static function formatTimeText($value): string
|
||||
{
|
||||
if ($value === '' || $value === null || !is_numeric($value)) {
|
||||
return '';
|
||||
}
|
||||
$ts = (int) $value;
|
||||
return $ts > 0 ? date('Y-m-d H:i:s', $ts) : '';
|
||||
}
|
||||
}
|
||||
+67
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\admin\validate\split;
|
||||
|
||||
use app\common\library\CountryIso;
|
||||
use app\common\service\SplitLinkCodeService;
|
||||
use think\Validate;
|
||||
|
||||
/**
|
||||
* 分流链接验证器
|
||||
*/
|
||||
class Link extends Validate
|
||||
{
|
||||
protected $rule = [
|
||||
'countries' => 'require|checkCountries',
|
||||
'link_code' => 'require|checkLinkCode',
|
||||
'description' => 'require|max:255',
|
||||
'ip_protect' => 'in:0,1',
|
||||
'random_shuffle' => 'in:0,1',
|
||||
'status' => 'in:normal,hidden',
|
||||
];
|
||||
|
||||
protected $message = [
|
||||
'countries.require' => '请至少选择一个投放国家',
|
||||
'link_code.require' => '请填写分流链接',
|
||||
'description.require' => '请填写链接描述',
|
||||
'description.max' => '链接描述不能超过255个字符',
|
||||
];
|
||||
|
||||
protected $scene = [
|
||||
'add' => ['countries', 'link_code', 'description', 'ip_protect', 'random_shuffle', 'status'],
|
||||
'edit' => ['countries', 'description', 'ip_protect', 'random_shuffle', 'status'],
|
||||
];
|
||||
|
||||
/**
|
||||
* @param mixed $value
|
||||
* @return bool|string
|
||||
*/
|
||||
protected function checkCountries($value)
|
||||
{
|
||||
$codes = is_array($value) ? $value : explode(',', (string)$value);
|
||||
if (CountryIso::normalizeCodes($codes) === []) {
|
||||
return '请至少选择一个有效的投放国家';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验分流链接格式与唯一性(仅新增)
|
||||
*
|
||||
* @param mixed $value
|
||||
* @return bool|string
|
||||
*/
|
||||
protected function checkLinkCode($value)
|
||||
{
|
||||
$code = SplitLinkCodeService::normalize((string) $value);
|
||||
if (!SplitLinkCodeService::isValidFormat($code)) {
|
||||
return '分流链接须为9位小写字母(a-z)';
|
||||
}
|
||||
if (!SplitLinkCodeService::isAvailable($code)) {
|
||||
return '该分流链接已被使用,请更换';
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<form id="add-form" class="form-horizontal" role="form" data-toggle="validator" method="POST" action="">
|
||||
{:token()}
|
||||
|
||||
<div class="form-group">
|
||||
<label for="c-countries" class="control-label col-xs-12 col-sm-2">{:__('Countries')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<select id="c-countries" name="row[countries][]" class="form-control selectpicker" multiple data-live-search="true" data-rule="required">
|
||||
{foreach name="countryList" item="vo" key="code"}
|
||||
<option value="{$code|htmlentities}">{$vo|htmlentities}</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="c-link_code" class="control-label col-xs-12 col-sm-2">{:__('Link_code')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<input id="c-link_code" data-rule="required" class="form-control" name="row[link_code]" type="text" value="{$defaultLinkCode|htmlentities}" maxlength="9" autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="c-description" class="control-label col-xs-12 col-sm-2">{:__('Description')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<input id="c-description" data-rule="required" class="form-control" name="row[description]" type="text" value="" maxlength="255">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="c-ip_protect" class="control-label col-xs-12 col-sm-2">{:__('Ip_protect')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<select id="c-ip_protect" class="form-control selectpicker" name="row[ip_protect]">
|
||||
{foreach name="ipProtectList" item="vo"}
|
||||
<option value="{$key|htmlentities}" {in name="key" value="0"}selected{/in}>{$vo|htmlentities}</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="c-random_shuffle" class="control-label col-xs-12 col-sm-2">{:__('Random_shuffle')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<select id="c-random_shuffle" class="form-control selectpicker" name="row[random_shuffle]">
|
||||
{foreach name="randomShuffleList" item="vo"}
|
||||
<option value="{$key|htmlentities}" {in name="key" value="0"}selected{/in}>{$vo|htmlentities}</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Status')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<div class="radio">
|
||||
{foreach name="statusList" item="vo"}
|
||||
<label for="row[status]-{$key}"><input id="row[status]-{$key}" name="row[status]" type="radio" value="{$key|htmlentities}" {in name="key" value="normal"}checked{/in} /> {$vo|htmlentities}</label>
|
||||
{/foreach}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group layer-footer">
|
||||
<label class="control-label col-xs-12 col-sm-2"></label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
|
||||
<button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
<form id="edit-form" class="form-horizontal" role="form" data-toggle="validator" method="POST" action="">
|
||||
{:token()}
|
||||
<input type="hidden" name="row[id]" value="{$row.id|htmlentities}">
|
||||
|
||||
<div class="form-group">
|
||||
<label for="c-countries" class="control-label col-xs-12 col-sm-2">{:__('Countries')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<select id="c-countries" name="row[countries][]" class="form-control selectpicker" multiple data-live-search="true" data-rule="required">
|
||||
{foreach name="countryList" item="vo" key="code"}
|
||||
<option value="{$code|htmlentities}" {in name="code" value="$selectedCountries"}selected{/in}>{$vo|htmlentities}</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="c-link_code" class="control-label col-xs-12 col-sm-2">{:__('Link_code')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<input id="c-link_code" class="form-control" type="text" value="{$row.link_code|htmlentities}" readonly>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="c-description" class="control-label col-xs-12 col-sm-2">{:__('Description')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<input id="c-description" data-rule="required" class="form-control" name="row[description]" type="text" value="{$row.description|htmlentities}" maxlength="255">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="c-ip_protect" class="control-label col-xs-12 col-sm-2">{:__('Ip_protect')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<select id="c-ip_protect" class="form-control selectpicker" name="row[ip_protect]">
|
||||
{foreach name="ipProtectList" item="vo"}
|
||||
<option value="{$key|htmlentities}" {in name="key" value="$row.ip_protect"}selected{/in}>{$vo|htmlentities}</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="c-random_shuffle" class="control-label col-xs-12 col-sm-2">{:__('Random_shuffle')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<select id="c-random_shuffle" class="form-control selectpicker" name="row[random_shuffle]">
|
||||
{foreach name="randomShuffleList" item="vo"}
|
||||
<option value="{$key|htmlentities}" {in name="key" value="$row.random_shuffle"}selected{/in}>{$vo|htmlentities}</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label col-xs-12 col-sm-2">{:__('Status')}:</label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<div class="radio">
|
||||
{foreach name="statusList" item="vo"}
|
||||
<label for="row[status]-{$key}"><input id="row[status]-{$key}" name="row[status]" type="radio" value="{$key|htmlentities}" {in name="key" value="$row.status"}checked{/in} /> {$vo|htmlentities}</label>
|
||||
{/foreach}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group layer-footer">
|
||||
<label class="control-label col-xs-12 col-sm-2"></label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
|
||||
<button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
<div class="panel panel-default panel-intro">
|
||||
|
||||
<div class="panel-heading">
|
||||
{:build_heading(null,FALSE)}
|
||||
<ul class="nav nav-tabs" data-field="status">
|
||||
<li class="{:$Think.get.status === null ? 'active' : ''}"><a href="#t-all" data-value="" data-toggle="tab">{:__('All')}</a></li>
|
||||
{foreach name="statusList" item="vo"}
|
||||
<li class="{:$Think.get.status === (string)$key ? 'active' : ''}"><a href="#t-{$key}" data-value="{$key}" data-toggle="tab">{$vo}</a></li>
|
||||
{/foreach}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
<div class="panel-body">
|
||||
<div id="myTabContent" class="tab-content">
|
||||
<div class="tab-pane fade active in" id="one">
|
||||
<div class="widget-body no-padding">
|
||||
<div id="toolbar" class="toolbar">
|
||||
<a href="javascript:;" class="btn btn-primary btn-refresh" title="{:__('Refresh')}" ><i class="fa fa-refresh"></i> </a>
|
||||
<a href="javascript:;" class="btn btn-success btn-add {:$auth->check('split.link/add')?'':'hide'}" title="{:__('Add')}" ><i class="fa fa-plus"></i> {:__('Add')}</a>
|
||||
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.link/edit')?'':'hide'}" title="{:__('Edit')}" ><i class="fa fa-pencil"></i> {:__('Edit')}</a>
|
||||
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.link/del')?'':'hide'}" title="{:__('Delete')}" ><i class="fa fa-trash"></i> {:__('Delete')}</a>
|
||||
|
||||
|
||||
<div class="dropdown btn-group {:$auth->check('split.link/multi')?'':'hide'}">
|
||||
<a class="btn btn-primary btn-more dropdown-toggle btn-disabled disabled" data-toggle="dropdown"><i class="fa fa-cog"></i> {:__('More')}</a>
|
||||
<ul class="dropdown-menu text-left" role="menu">
|
||||
{foreach name="statusList" item="vo"}
|
||||
<li><a class="btn btn-link btn-multi btn-disabled disabled" href="javascript:" data-params="status={$key}">{:__('Set status to ' . $key)}</a></li>
|
||||
{/foreach}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
||||
data-operate-edit="{:$auth->check('split.link/edit')}"
|
||||
data-operate-del="{:$auth->check('split.link/del')}"
|
||||
width="100%">
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<form id="add-form" class="form-horizontal split-number-form" role="form" data-toggle="validator" method="POST" action="">
|
||||
{:token()}
|
||||
{include file="split/number/form_body" /}
|
||||
<div class="form-group layer-footer">
|
||||
<label class="control-label col-xs-12 col-sm-2"></label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
|
||||
<button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<form id="edit-form" class="form-horizontal split-number-form" role="form" data-toggle="validator" method="POST" action="">
|
||||
{:token()}
|
||||
<input type="hidden" name="row[id]" value="{$row.id|htmlentities}">
|
||||
{include file="split/number/form_body" /}
|
||||
<div class="form-group layer-footer">
|
||||
<label class="control-label col-xs-12 col-sm-2"></label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
|
||||
<button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
+146
@@ -0,0 +1,146 @@
|
||||
<style>
|
||||
.split-number-form .panel {
|
||||
border-color: #e8ecf1;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.split-number-form .panel-heading {
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
color: #334155;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.split-number-form .panel-body {
|
||||
padding: 18px 20px 8px;
|
||||
}
|
||||
.split-number-form .form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.split-number-form .control-label {
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
padding-top: 0;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
.split-number-form .control-label .text-danger {
|
||||
margin-left: 2px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.split-number-form .form-control {
|
||||
border-radius: 4px;
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
}
|
||||
.split-number-form .st-grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
margin-bottom: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.split-number-form .st-grid-2 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
column-gap: 36px;
|
||||
}
|
||||
}
|
||||
.split-number-form .st-grid-1 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.split-number-form .st-grid-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
.split-number-form .radio-inline {
|
||||
margin-right: 18px;
|
||||
}
|
||||
.split-number-form > .layer-footer {
|
||||
margin: 12px 0 0;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
clear: both;
|
||||
}
|
||||
.split-number-form > .layer-footer > .control-label {
|
||||
display: none !important;
|
||||
}
|
||||
.split-number-form > .layer-footer > div[class*="col-"] {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
}
|
||||
.split-number-form > .layer-footer .btn {
|
||||
min-width: 100px;
|
||||
margin: 0 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">{:__('Section basic')}</div>
|
||||
<div class="panel-body">
|
||||
<div class="st-grid-2">
|
||||
<div class="st-grid-cell">
|
||||
<div class="form-group">
|
||||
<label for="c-number_type" class="control-label">{:__('Number_type')}<span class="text-danger">*</span></label>
|
||||
<select id="c-number_type" name="row[number_type]" class="form-control selectpicker" data-rule="required" data-none-selected-text="请选择" title="请选择">
|
||||
{foreach name="numberTypeList" item="vo" key="key"}
|
||||
<option value="{$key|htmlentities}" {if isset($row) && $row.number_type==$key}selected{elseif !isset($row) && $key=='whatsapp'/}selected{/if}>{$vo|htmlentities}</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-grid-cell">
|
||||
<div class="form-group">
|
||||
<label for="c-split_link_id" class="control-label">{:__('Split_link_id')}<span class="text-danger">*</span></label>
|
||||
<select id="c-split_link_id" name="row[split_link_id]" class="form-control selectpicker" data-live-search="true" data-rule="required" data-none-selected-text="请选择" title="请选择">
|
||||
<option value="">请选择</option>
|
||||
{foreach name="splitLinkList" item="link"}
|
||||
<option value="{$link.id|htmlentities}" {if isset($row) && $row.split_link_id==$link.id}selected{/if}>{$link.label|htmlentities}</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-grid-1 split-number-type-custom {if !isset($row) || $row.number_type!='custom'}hide{/if}">
|
||||
<div class="form-group">
|
||||
<label for="c-number_type_custom" class="control-label">{:__('Number_type_custom')}<span class="text-danger">*</span></label>
|
||||
<input id="c-number_type_custom" class="form-control" name="row[number_type_custom]" type="text" value="{$row.number_type_custom|default=''|htmlentities}" maxlength="50">
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-grid-1">
|
||||
<div class="form-group">
|
||||
<label for="c-ticket_name" class="control-label">{:__('Ticket_name')}<span class="text-danger">*</span></label>
|
||||
<input id="c-ticket_name" data-rule="required" class="form-control" name="row[ticket_name]" type="text" value="{$row.ticket_name|default=''|htmlentities}" maxlength="100">
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-grid-1">
|
||||
<div class="form-group">
|
||||
{if isset($row)}
|
||||
<label for="c-number" class="control-label">{:__('Number')}<span class="text-danger">*</span></label>
|
||||
<input id="c-number" data-rule="required" class="form-control" name="row[number]" type="text" value="{$row.number|default=''|htmlentities}" maxlength="50">
|
||||
{else}
|
||||
<label for="c-numbers" class="control-label">{:__('Numbers')}<span class="text-danger">*</span></label>
|
||||
<textarea id="c-numbers" data-rule="required" class="form-control" name="row[numbers]" rows="8" placeholder="{:__('Numbers placeholder')}"></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-grid-1">
|
||||
<div class="form-group">
|
||||
<label class="control-label">{:__('Status')}<span class="text-danger">*</span></label>
|
||||
<div>
|
||||
{foreach name="statusList" item="vo" key="key"}
|
||||
<label class="radio-inline">
|
||||
<input type="radio" name="row[status]" value="{$key|htmlentities}" data-rule="required" {if isset($row) && $row.status==$key}checked{elseif !isset($row) && $key=='normal'/}checked{/if}> {$vo|htmlentities}
|
||||
</label>
|
||||
{/foreach}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<div class="panel panel-default panel-intro">
|
||||
<div class="panel-heading">
|
||||
{:build_heading(null,FALSE)}
|
||||
<ul class="nav nav-tabs" data-field="status">
|
||||
<li class="{:$Think.get.status === null ? 'active' : ''}"><a href="#t-all" data-value="" data-toggle="tab">{:__('All')}</a></li>
|
||||
{foreach name="statusList" item="vo" key="key"}
|
||||
<li class="{:$Think.get.status === (string)$key ? 'active' : ''}"><a href="#t-{$key}" data-value="{$key|htmlentities}" data-toggle="tab">{$vo|htmlentities}</a></li>
|
||||
{/foreach}
|
||||
</ul>
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div id="myTabContent" class="tab-content">
|
||||
<div class="tab-pane fade active in" id="one">
|
||||
<div class="widget-body no-padding">
|
||||
<div id="toolbar" class="toolbar">
|
||||
<a href="javascript:;" class="btn btn-primary btn-refresh" title="{:__('Refresh')}"><i class="fa fa-refresh"></i></a>
|
||||
<a href="javascript:;" class="btn btn-success btn-add {:$auth->check('split.number/add')?'':'hide'}" title="{:__('Add')}"><i class="fa fa-plus"></i> {:__('Add')}</a>
|
||||
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.number/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
|
||||
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.number/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
|
||||
<a href="javascript:;" class="btn btn-warning btn-batch-update-status btn-disabled disabled {:$auth->check('split.number/batchupdate')?'':'hide'}" title="{:__('Batch update btn')}"><i class="fa fa-edit"></i> {:__('Batch update btn')}</a>
|
||||
</div>
|
||||
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
||||
data-operate-edit="{:$auth->check('split.number/edit')}"
|
||||
data-operate-del="{:$auth->check('split.number/del')}"
|
||||
width="100%">
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
<form id="add-form" class="form-horizontal split-ticket-form" role="form" data-toggle="validator" method="POST" action="">
|
||||
{:token()}
|
||||
{include file="split/ticket/form_body" /}
|
||||
<div class="form-group layer-footer">
|
||||
<label class="control-label col-xs-12 col-sm-2"></label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
|
||||
<button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<form id="edit-form" class="form-horizontal split-ticket-form" role="form" data-toggle="validator" method="POST" action="">
|
||||
{:token()}
|
||||
<input type="hidden" name="row[id]" value="{$row.id|htmlentities}">
|
||||
{include file="split/ticket/form_body" /}
|
||||
<div class="form-group layer-footer">
|
||||
<label class="control-label col-xs-12 col-sm-2"></label>
|
||||
<div class="col-xs-12 col-sm-8">
|
||||
<button type="submit" class="btn btn-primary btn-embossed disabled">{:__('OK')}</button>
|
||||
<button type="reset" class="btn btn-default btn-embossed">{:__('Reset')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
+239
@@ -0,0 +1,239 @@
|
||||
<style>
|
||||
.split-ticket-form .panel {
|
||||
border-color: #e8ecf1;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.split-ticket-form .panel-heading {
|
||||
background: linear-gradient(180deg, #f8fafc 0%, #f1f5f9 100%);
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
color: #334155;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
padding: 10px 16px;
|
||||
}
|
||||
.split-ticket-form .panel-body {
|
||||
padding: 18px 20px 8px;
|
||||
}
|
||||
.split-ticket-form .form-group {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.split-ticket-form .control-label {
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
font-size: 13px;
|
||||
margin-bottom: 6px;
|
||||
padding-top: 0;
|
||||
text-align: left;
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
.split-ticket-form .control-label .text-danger {
|
||||
margin-left: 2px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.split-ticket-form .form-control {
|
||||
border-radius: 4px;
|
||||
border-color: #cbd5e1;
|
||||
box-shadow: none;
|
||||
width: 100%;
|
||||
}
|
||||
.split-ticket-form .form-control:focus {
|
||||
border-color: #3b82f6;
|
||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.15);
|
||||
}
|
||||
/* 单列整行 */
|
||||
.split-ticket-form .st-grid-1 {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
/* 双列:PC 端 column-gap 控制列间距(不依赖 Bootstrap col padding) */
|
||||
.split-ticket-form .st-grid-2 {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
column-gap: 0;
|
||||
row-gap: 0;
|
||||
margin-bottom: 4px;
|
||||
width: 100%;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.split-ticket-form .st-grid-2 {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
column-gap: 36px;
|
||||
}
|
||||
}
|
||||
.split-ticket-form .st-grid-cell {
|
||||
min-width: 0;
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.split-ticket-form .st-grid-2 {
|
||||
row-gap: 0;
|
||||
}
|
||||
.split-ticket-form .st-grid-2 .st-grid-cell + .st-grid-cell .form-group {
|
||||
margin-top: 0;
|
||||
}
|
||||
}
|
||||
/* 底部按钮区(兼容 layer 弹窗 footer 迁移) */
|
||||
.split-ticket-form > .layer-footer {
|
||||
margin: 12px 0 0;
|
||||
padding: 16px 20px;
|
||||
border-top: 1px solid #e2e8f0;
|
||||
background: #f8fafc;
|
||||
clear: both;
|
||||
}
|
||||
.split-ticket-form > .layer-footer > .control-label {
|
||||
display: none !important;
|
||||
}
|
||||
.split-ticket-form > .layer-footer > div[class*="col-"] {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
flex: none;
|
||||
float: none;
|
||||
text-align: center;
|
||||
padding: 0;
|
||||
}
|
||||
.split-ticket-form > .layer-footer .btn {
|
||||
min-width: 100px;
|
||||
margin: 0 12px;
|
||||
padding: 8px 24px;
|
||||
font-size: 14px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.split-ticket-form > .layer-footer .btn-primary {
|
||||
background-color: #3b82f6;
|
||||
border-color: #2563eb;
|
||||
}
|
||||
.split-ticket-form > .layer-footer .btn-primary:hover,
|
||||
.split-ticket-form > .layer-footer .btn-primary:focus {
|
||||
background-color: #2563eb;
|
||||
border-color: #1d4ed8;
|
||||
}
|
||||
.split-ticket-form > .layer-footer .btn-default {
|
||||
background-color: #fff;
|
||||
border-color: #cbd5e1;
|
||||
color: #475569;
|
||||
}
|
||||
.split-ticket-form > .layer-footer .btn-default:hover {
|
||||
background-color: #f1f5f9;
|
||||
border-color: #94a3b8;
|
||||
}
|
||||
</style>
|
||||
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">{:__('Section basic')}</div>
|
||||
<div class="panel-body">
|
||||
<div class="st-grid-2">
|
||||
<div class="st-grid-cell">
|
||||
<div class="form-group">
|
||||
<label for="c-ticket_type" class="control-label">{:__('Ticket_type')}<span class="text-danger">*</span></label>
|
||||
<select id="c-ticket_type" name="row[ticket_type]" class="form-control selectpicker" data-rule="required" data-none-selected-text="请选择" title="请选择">
|
||||
<option value="">请选择</option>
|
||||
{foreach name="ticketTypeList" item="vo" key="key"}
|
||||
<option value="{$key|htmlentities}" {if isset($row) && $row.ticket_type==$key}selected{/if}>{$vo|htmlentities}</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-grid-cell">
|
||||
<div class="form-group">
|
||||
<label for="c-ticket_name" class="control-label">{:__('Ticket_name')}<span class="text-danger">*</span></label>
|
||||
<input id="c-ticket_name" data-rule="required" class="form-control" name="row[ticket_name]" type="text" value="{$row.ticket_name|default=''|htmlentities}" maxlength="100">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-grid-1">
|
||||
<div class="form-group">
|
||||
<label for="c-ticket_url" class="control-label">{:__('Ticket_url')}<span class="text-danger">*</span></label>
|
||||
<input id="c-ticket_url" data-rule="required" class="form-control" name="row[ticket_url]" type="text" value="{$row.ticket_url|default=''|htmlentities}" maxlength="1000" placeholder="https://">
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-grid-1">
|
||||
<div class="form-group">
|
||||
<label for="c-ticket_total" class="control-label">{:__('Ticket_total')}<span class="text-danger">*</span></label>
|
||||
<input id="c-ticket_total" data-rule="required" class="form-control" name="row[ticket_total]" type="number" min="0" step="1" value="{$row.ticket_total|default='0'|htmlentities}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-grid-2">
|
||||
<div class="st-grid-cell">
|
||||
<div class="form-group">
|
||||
<label for="c-split_link_id" class="control-label">{:__('Split_link_id')}<span class="text-danger">*</span></label>
|
||||
<select id="c-split_link_id" name="row[split_link_id]" class="form-control selectpicker" data-live-search="true" data-rule="required" data-none-selected-text="请选择" title="请选择">
|
||||
<option value="">请选择</option>
|
||||
{foreach name="splitLinkList" item="link"}
|
||||
<option value="{$link.id|htmlentities}" {if isset($row) && $row.split_link_id==$link.id}selected{/if}>{$link.label|htmlentities}</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-grid-cell">
|
||||
<div class="form-group">
|
||||
<label for="c-number_type" class="control-label">{:__('Number_type')}<span class="text-danger">*</span></label>
|
||||
<select id="c-number_type" name="row[number_type]" class="form-control selectpicker" data-rule="required">
|
||||
{foreach name="numberTypeList" item="vo" key="key"}
|
||||
<option value="{$key|htmlentities}" {if isset($row) && $row.number_type==$key}selected{elseif !isset($row) && $key=='whatsapp'/}selected{/if}>{$vo|htmlentities}</option>
|
||||
{/foreach}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-grid-1 split-number-type-custom {if !isset($row) || $row.number_type!='custom'}hide{/if}">
|
||||
<div class="form-group">
|
||||
<label for="c-number_type_custom" class="control-label">{:__('Number_type_custom')}<span class="text-danger split-required-star">*</span></label>
|
||||
<input id="c-number_type_custom" class="form-control" name="row[number_type_custom]" type="text" value="{$row.number_type_custom|default=''|htmlentities}" maxlength="50">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">{:__('Section time rule')}</div>
|
||||
<div class="panel-body">
|
||||
<div class="st-grid-2">
|
||||
<div class="st-grid-cell">
|
||||
<div class="form-group">
|
||||
<label for="c-start_time" class="control-label">{:__('Start_time')}<span class="text-danger">*</span></label>
|
||||
<input id="c-start_time" data-rule="required" class="form-control datetimepicker" data-date-format="YYYY-MM-DD HH:mm:ss" data-use-current="true" name="row[start_time]" type="text" value="{$row.start_time|default=''|htmlentities}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-grid-cell">
|
||||
<div class="form-group">
|
||||
<label for="c-end_time" class="control-label">{:__('End_time')}<span class="text-danger">*</span></label>
|
||||
<input id="c-end_time" data-rule="required" class="form-control datetimepicker" data-date-format="YYYY-MM-DD HH:mm:ss" data-use-current="true" name="row[end_time]" type="text" value="{$row.end_time|default=''|htmlentities}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-grid-2">
|
||||
<div class="st-grid-cell">
|
||||
<div class="form-group">
|
||||
<label for="c-order_limit" class="control-label">{:__('Order_limit')}<span class="text-danger">*</span></label>
|
||||
<input id="c-order_limit" data-rule="required;integer;range(0~)" class="form-control" name="row[order_limit]" type="number" min="0" step="1" value="{$row.order_limit|default='0'|htmlentities}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-grid-cell">
|
||||
<div class="form-group">
|
||||
<label for="c-assign_ratio" class="control-label">{:__('Assign_ratio')}<span class="text-danger">*</span></label>
|
||||
<input id="c-assign_ratio" data-rule="required;integer;range(0~)" class="form-control" name="row[assign_ratio]" type="number" min="0" step="1" value="{$row.assign_ratio|default='0'|htmlentities}">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="panel panel-default">
|
||||
<div class="panel-heading">{:__('Section account')}</div>
|
||||
<div class="panel-body">
|
||||
<div class="st-grid-2">
|
||||
<div class="st-grid-cell">
|
||||
<div class="form-group">
|
||||
<label for="c-account" class="control-label">{:__('Account')}</label>
|
||||
<input id="c-account" class="form-control" name="row[account]" type="text" value="{$row.account|default=''|htmlentities}" maxlength="50">
|
||||
</div>
|
||||
</div>
|
||||
<div class="st-grid-cell">
|
||||
<div class="form-group">
|
||||
<label for="c-password" class="control-label">{:__('Password')}</label>
|
||||
<input id="c-password" class="form-control" name="row[password]" type="text" value="{$row.password|default=''|htmlentities}" maxlength="50">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+51
@@ -0,0 +1,51 @@
|
||||
<style>
|
||||
.split-ticket-sync-pending {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-weight: 600;
|
||||
color: #31708f;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.split-ticket-sync-pending .fa-spinner {
|
||||
color: #5bc0de;
|
||||
}
|
||||
.split-ticket-sync-pending .split-ticket-sync-label::after {
|
||||
content: '';
|
||||
display: inline-block;
|
||||
width: 1.2em;
|
||||
text-align: left;
|
||||
animation: split-ticket-sync-dots 1.4s steps(4, end) infinite;
|
||||
}
|
||||
@keyframes split-ticket-sync-dots {
|
||||
0%, 20% { content: ''; }
|
||||
40% { content: '.'; }
|
||||
60% { content: '..'; }
|
||||
80%, 100% { content: '...'; }
|
||||
}
|
||||
</style>
|
||||
<div class="panel panel-default panel-intro">
|
||||
<div class="panel-heading">
|
||||
{:build_heading(null,FALSE)}
|
||||
</div>
|
||||
<div class="panel-body">
|
||||
<div id="myTabContent" class="tab-content">
|
||||
<div class="tab-pane fade active in" id="one">
|
||||
<div class="widget-body no-padding">
|
||||
<div id="toolbar" class="toolbar">
|
||||
<a href="javascript:;" class="btn btn-primary btn-refresh" title="{:__('Refresh')}"><i class="fa fa-refresh"></i></a>
|
||||
<a href="javascript:;" class="btn btn-success btn-add {:$auth->check('split.ticket/add')?'':'hide'}" title="{:__('Add')}"><i class="fa fa-plus"></i> {:__('Add')}</a>
|
||||
<a href="javascript:;" class="btn btn-success btn-edit btn-disabled disabled {:$auth->check('split.ticket/edit')?'':'hide'}" title="{:__('Edit')}"><i class="fa fa-pencil"></i> {:__('Edit')}</a>
|
||||
<a href="javascript:;" class="btn btn-danger btn-del btn-disabled disabled {:$auth->check('split.ticket/del')?'':'hide'}" title="{:__('Delete')}"><i class="fa fa-trash"></i> {:__('Delete')}</a>
|
||||
<a href="javascript:;" class="btn btn-info btn-sync btn-disabled disabled {:$auth->check('split.ticket/sync')?'':'hide'}" title="{:__('Sync_status_btn')}"><i class="fa fa-refresh"></i> {:__('Sync_status_btn')}</a>
|
||||
</div>
|
||||
<table id="table" class="table table-striped table-bordered table-hover table-nowrap"
|
||||
data-operate-edit="{:$auth->check('split.ticket/edit')}"
|
||||
data-operate-del="{:$auth->check('split.ticket/del')}"
|
||||
width="100%">
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+149
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library;
|
||||
|
||||
/**
|
||||
* ISO 3166-1 alpha-2 国家代码与中文名称
|
||||
*/
|
||||
class CountryIso
|
||||
{
|
||||
/**
|
||||
* 常用投放国家 ISO2 => 中文名
|
||||
*
|
||||
* @var array<string, string>
|
||||
*/
|
||||
private const COUNTRIES = [
|
||||
'CN' => '中国',
|
||||
'HK' => '中国香港',
|
||||
'MO' => '中国澳门',
|
||||
'TW' => '中国台湾',
|
||||
'US' => '美国',
|
||||
'CA' => '加拿大',
|
||||
'GB' => '英国',
|
||||
'DE' => '德国',
|
||||
'FR' => '法国',
|
||||
'IT' => '意大利',
|
||||
'ES' => '西班牙',
|
||||
'NL' => '荷兰',
|
||||
'BE' => '比利时',
|
||||
'CH' => '瑞士',
|
||||
'AT' => '奥地利',
|
||||
'SE' => '瑞典',
|
||||
'NO' => '挪威',
|
||||
'DK' => '丹麦',
|
||||
'FI' => '芬兰',
|
||||
'IE' => '爱尔兰',
|
||||
'PT' => '葡萄牙',
|
||||
'PL' => '波兰',
|
||||
'CZ' => '捷克',
|
||||
'HU' => '匈牙利',
|
||||
'RO' => '罗马尼亚',
|
||||
'GR' => '希腊',
|
||||
'RU' => '俄罗斯',
|
||||
'UA' => '乌克兰',
|
||||
'TR' => '土耳其',
|
||||
'IL' => '以色列',
|
||||
'SA' => '沙特阿拉伯',
|
||||
'AE' => '阿联酋',
|
||||
'QA' => '卡塔尔',
|
||||
'KW' => '科威特',
|
||||
'IN' => '印度',
|
||||
'PK' => '巴基斯坦',
|
||||
'BD' => '孟加拉国',
|
||||
'TH' => '泰国',
|
||||
'VN' => '越南',
|
||||
'MY' => '马来西亚',
|
||||
'SG' => '新加坡',
|
||||
'ID' => '印度尼西亚',
|
||||
'PH' => '菲律宾',
|
||||
'JP' => '日本',
|
||||
'KR' => '韩国',
|
||||
'AU' => '澳大利亚',
|
||||
'NZ' => '新西兰',
|
||||
'BR' => '巴西',
|
||||
'MX' => '墨西哥',
|
||||
'AR' => '阿根廷',
|
||||
'CL' => '智利',
|
||||
'CO' => '哥伦比亚',
|
||||
'PE' => '秘鲁',
|
||||
'ZA' => '南非',
|
||||
'EG' => '埃及',
|
||||
'NG' => '尼日利亚',
|
||||
'KE' => '肯尼亚',
|
||||
];
|
||||
|
||||
/**
|
||||
* 下拉选项 ISO2 => 中文名
|
||||
*
|
||||
* @return array<string, string>
|
||||
*/
|
||||
public static function getOptions(): array
|
||||
{
|
||||
return self::COUNTRIES;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验是否为合法 ISO2 代码
|
||||
*/
|
||||
public static function isValidCode(string $code): bool
|
||||
{
|
||||
$code = strtoupper(trim($code));
|
||||
return isset(self::COUNTRIES[$code]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 规范化并过滤国家代码列表
|
||||
*
|
||||
* @param array<int, string>|string $codes
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function normalizeCodes($codes): array
|
||||
{
|
||||
if (is_string($codes)) {
|
||||
$codes = explode(',', $codes);
|
||||
}
|
||||
if (!is_array($codes)) {
|
||||
return [];
|
||||
}
|
||||
$result = [];
|
||||
foreach ($codes as $code) {
|
||||
$code = strtoupper(trim((string)$code));
|
||||
if ($code !== '' && self::isValidCode($code) && !in_array($code, $result, true)) {
|
||||
$result[] = $code;
|
||||
}
|
||||
}
|
||||
sort($result);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 逗号分隔 ISO 代码转中文展示
|
||||
*/
|
||||
public static function codesToText(string $codes): string
|
||||
{
|
||||
if ($codes === '') {
|
||||
return '';
|
||||
}
|
||||
$parts = [];
|
||||
foreach (explode(',', $codes) as $code) {
|
||||
$code = strtoupper(trim($code));
|
||||
if ($code === '') {
|
||||
continue;
|
||||
}
|
||||
$parts[] = self::COUNTRIES[$code] ?? $code;
|
||||
}
|
||||
return implode(',', $parts);
|
||||
}
|
||||
|
||||
/**
|
||||
* 数组转存储字符串
|
||||
*
|
||||
* @param array<int, string> $codes
|
||||
*/
|
||||
public static function codesToStorage(array $codes): string
|
||||
{
|
||||
return implode(',', self::normalizeCodes($codes));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\scrm;
|
||||
|
||||
use app\common\service\SplitTicketSyncLogger;
|
||||
use Exception;
|
||||
|
||||
/**
|
||||
* 云控蜘蛛抽象基类(Node Headless 拦截 + 翻页 + 清洗)
|
||||
*/
|
||||
abstract class AbstractScrmSpider implements ScrmSpiderInterface
|
||||
{
|
||||
public const MODE_FETCH = 'fetch';
|
||||
|
||||
public const MODE_UI = 'ui_click';
|
||||
|
||||
protected string $nodeHost;
|
||||
|
||||
public function __construct(string $nodeHost = 'http://127.0.0.1:3001')
|
||||
{
|
||||
$this->nodeHost = rtrim($nodeHost, '/');
|
||||
}
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
abstract protected function getSpiderConfig(): array;
|
||||
|
||||
/**
|
||||
* @param array<string, mixed>|null $countData
|
||||
*/
|
||||
abstract protected function extractListTotalPages($listFirstPageData, $countData = null);
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
abstract protected function buildListPageParams(int $page): array;
|
||||
|
||||
/** @return array<string, mixed> */
|
||||
abstract protected function getUiPaginationConfig(): array;
|
||||
|
||||
/**
|
||||
* @param mixed $detailData
|
||||
* @param array<int, mixed> $allListPagesData
|
||||
*/
|
||||
abstract protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData;
|
||||
|
||||
public function run(): UnifiedScrmData
|
||||
{
|
||||
$config = $this->getSpiderConfig();
|
||||
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;
|
||||
$countApi = $config['countApi'] ?? null;
|
||||
|
||||
$apiUrlsToIntercept = [$listApi];
|
||||
if ($detailApi) {
|
||||
$apiUrlsToIntercept[] = $detailApi;
|
||||
}
|
||||
if ($countApi) {
|
||||
$apiUrlsToIntercept[] = $countApi;
|
||||
}
|
||||
|
||||
$initResult = $this->requestNode('/api/auth-and-intercept', [
|
||||
'pageUrl' => $config['pageUrl'],
|
||||
'apiUrls' => $apiUrlsToIntercept,
|
||||
'authActions' => $config['authActions'] ?? [],
|
||||
]);
|
||||
|
||||
if (empty($initResult['success'])) {
|
||||
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])) {
|
||||
throw new Exception("致命错误:未能拦截到必须的列表接口 [{$listApi}]");
|
||||
}
|
||||
|
||||
$detailData = $detailApi && isset($interceptedApis[$detailApi])
|
||||
? $interceptedApis[$detailApi]['data'] : null;
|
||||
$countData = $countApi && isset($interceptedApis[$countApi])
|
||||
? $interceptedApis[$countApi]['data'] : null;
|
||||
|
||||
$listApiNode = $interceptedApis[$listApi];
|
||||
$allListPagesData = [$listApiNode['data']];
|
||||
|
||||
$totalPages = $this->extractListTotalPages($listApiNode['data'], $countData);
|
||||
$mode = $config['paginationMode'] ?? self::MODE_FETCH;
|
||||
SplitTicketSyncLogger::log('spider', 'pagination plan', [
|
||||
'totalPages' => $totalPages,
|
||||
'mode' => $mode,
|
||||
]);
|
||||
|
||||
if ($totalPages > 1 || $totalPages === null) {
|
||||
if ($mode === self::MODE_FETCH && $totalPages !== null) {
|
||||
$paramList = [];
|
||||
for ($page = 2; $page <= $totalPages; $page++) {
|
||||
$paramList[] = $this->buildListPageParams($page);
|
||||
}
|
||||
|
||||
$fetchResult = $this->requestNode('/api/batch-fetch', [
|
||||
'tasks' => [[
|
||||
'apiPath' => $listApi,
|
||||
'fullUrl' => $listApiNode['url'],
|
||||
'headers' => $listApiNode['headers'] ?? '',
|
||||
'paramList' => $paramList,
|
||||
'method' => $config['listMethod'] ?? 'GET',
|
||||
]],
|
||||
'cookies' => $cookies,
|
||||
], 120);
|
||||
|
||||
if (!empty($fetchResult['success'])) {
|
||||
foreach ($fetchResult['results'][$listApi] as $pResult) {
|
||||
if (!empty($pResult['success'])) {
|
||||
$allListPagesData[] = $pResult['data'];
|
||||
}
|
||||
}
|
||||
}
|
||||
} elseif ($mode === self::MODE_UI) {
|
||||
$uiConfig = $this->getUiPaginationConfig();
|
||||
$firstPageData = $listApiNode['data'];
|
||||
$clicksToPerform = ($totalPages === null) ? 9999 : ($totalPages - 1);
|
||||
|
||||
$uiResult = $this->requestNode('/api/ui-pagination', [
|
||||
'apiUrl' => $listApi,
|
||||
'pageUrl' => $config['pageUrl'],
|
||||
'nextBtnSelector' => $uiConfig['nextBtnSelector'] ?? '',
|
||||
'waitMs' => $uiConfig['waitMs'] ?? 2000,
|
||||
'clicksToPerform' => $clicksToPerform,
|
||||
'cookies' => $cookies,
|
||||
'firstPageData' => $firstPageData,
|
||||
'authActions' => $config['authActions'] ?? [],
|
||||
], 1200);
|
||||
|
||||
if (!empty($uiResult['success']) && !empty($uiResult['data'])) {
|
||||
foreach ($uiResult['data'] as $pageData) {
|
||||
$allListPagesData[] = $pageData;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
protected function requestNode(string $endpoint, array $payload, int $timeout = 60): array
|
||||
{
|
||||
$url = $this->nodeHost . $endpoint;
|
||||
$started = microtime(true);
|
||||
SplitTicketSyncLogger::log('node_request', 'POST ' . $endpoint, [
|
||||
'url' => $url,
|
||||
'timeout' => $timeout,
|
||||
'payload' => $payload,
|
||||
]);
|
||||
|
||||
$ch = curl_init($url);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
|
||||
$response = curl_exec($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
$elapsedMs = (int) round((microtime(true) - $started) * 1000);
|
||||
if (curl_errno($ch)) {
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
SplitTicketSyncLogger::log('node_response', 'curl error on ' . $endpoint, [
|
||||
'httpCode' => $httpCode,
|
||||
'elapsedMs' => $elapsedMs,
|
||||
'error' => $err,
|
||||
]);
|
||||
throw new Exception($err);
|
||||
}
|
||||
curl_close($ch);
|
||||
$decoded = json_decode((string) $response, true);
|
||||
$summary = is_array($decoded) ? self::summarizeNodeResponse($decoded) : ['raw' => mb_substr((string) $response, 0, 300, 'UTF-8')];
|
||||
SplitTicketSyncLogger::log('node_response', 'POST ' . $endpoint, array_merge([
|
||||
'httpCode' => $httpCode,
|
||||
'elapsedMs' => $elapsedMs,
|
||||
'responseSize' => strlen((string) $response),
|
||||
], $summary));
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $decoded
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function summarizeNodeResponse(array $decoded): array
|
||||
{
|
||||
$summary = [
|
||||
'success' => $decoded['success'] ?? null,
|
||||
'error' => $decoded['error'] ?? null,
|
||||
];
|
||||
if (isset($decoded['interceptedApis']) && is_array($decoded['interceptedApis'])) {
|
||||
$summary['interceptedApis'] = array_keys($decoded['interceptedApis']);
|
||||
}
|
||||
if (isset($decoded['results']) && is_array($decoded['results'])) {
|
||||
$summary['resultApis'] = array_keys($decoded['results']);
|
||||
}
|
||||
if (isset($decoded['data']) && is_array($decoded['data'])) {
|
||||
$summary['dataPages'] = count($decoded['data']);
|
||||
}
|
||||
return $summary;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\scrm;
|
||||
|
||||
/**
|
||||
* 云控蜘蛛统一接口
|
||||
*/
|
||||
interface ScrmSpiderInterface
|
||||
{
|
||||
/**
|
||||
* 执行抓取并返回统一数据
|
||||
*
|
||||
* @throws \Exception
|
||||
*/
|
||||
public function run(): UnifiedScrmData;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\scrm;
|
||||
|
||||
/**
|
||||
* 云控蜘蛛统一返回数据结构
|
||||
*/
|
||||
class UnifiedScrmData
|
||||
{
|
||||
/** @var int 今日新增(完成数量) */
|
||||
public int $todayNewCount = 0;
|
||||
|
||||
/** @var int 在线号码数 */
|
||||
public int $totalOnline = 0;
|
||||
|
||||
/** @var int 离线号码数 */
|
||||
public int $totalOffline = 0;
|
||||
|
||||
/** @var array<int, array{number:string,status:string,newFollowersToday:int}> */
|
||||
public array $numbers = [];
|
||||
|
||||
/** @var int 号码总数 */
|
||||
public int $total = 0;
|
||||
|
||||
/**
|
||||
* @param string $number 号码
|
||||
* @param bool $isOnline 是否在线
|
||||
* @param int $newFollowersToday 今日进线
|
||||
*/
|
||||
public function addNumber(string $number, bool $isOnline, int $newFollowersToday = 0): void
|
||||
{
|
||||
$number = trim($number);
|
||||
if ($number === '') {
|
||||
return;
|
||||
}
|
||||
$this->numbers[] = [
|
||||
'number' => $number,
|
||||
'status' => $isOnline ? 'online' : 'offline',
|
||||
'newFollowersToday' => max(0, $newFollowersToday),
|
||||
];
|
||||
|
||||
if ($isOnline) {
|
||||
$this->totalOnline++;
|
||||
} else {
|
||||
$this->totalOffline++;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\scrm\spider;
|
||||
|
||||
use app\common\library\scrm\AbstractScrmSpider;
|
||||
use app\common\library\scrm\UnifiedScrmData;
|
||||
|
||||
/**
|
||||
* A2C 云控蜘蛛
|
||||
*/
|
||||
class A2cSpider extends AbstractScrmSpider
|
||||
{
|
||||
private const API_LIST = '/api/talk/counter/share/record/list';
|
||||
|
||||
private const API_DETAILS = '/api/talk/counter/share/detail';
|
||||
|
||||
private const DEFAULT_PER_PAGE_COUNT = 20;
|
||||
|
||||
private string $pageUrl;
|
||||
|
||||
private string $account;
|
||||
|
||||
private string $password;
|
||||
|
||||
private UnifiedScrmData $unifiedData;
|
||||
|
||||
public function __construct(
|
||||
string $pageUrl,
|
||||
string $account = '',
|
||||
string $password = '',
|
||||
string $nodeHost = 'http://127.0.0.1:3001'
|
||||
) {
|
||||
parent::__construct($nodeHost);
|
||||
$this->pageUrl = $pageUrl;
|
||||
$this->account = $account;
|
||||
$this->password = $password;
|
||||
$this->unifiedData = new UnifiedScrmData();
|
||||
}
|
||||
|
||||
protected function getSpiderConfig(): array
|
||||
{
|
||||
return [
|
||||
'pageUrl' => $this->pageUrl,
|
||||
'listApi' => self::API_LIST,
|
||||
'detailApi' => self::API_DETAILS,
|
||||
'listMethod' => 'POST',
|
||||
'paginationMode' => self::MODE_UI,
|
||||
'authActions' => [
|
||||
['type' => 'wait', 'ms' => 2000],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
||||
{
|
||||
$total = (int) ($listFirstPageData['data']['total'] ?? 0);
|
||||
$this->unifiedData->total = $total;
|
||||
if ($total <= self::DEFAULT_PER_PAGE_COUNT) {
|
||||
return 1;
|
||||
}
|
||||
return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT);
|
||||
}
|
||||
|
||||
protected function buildListPageParams(int $page): array
|
||||
{
|
||||
return ['page' => $page, 'pageSize' => self::DEFAULT_PER_PAGE_COUNT];
|
||||
}
|
||||
|
||||
protected function getUiPaginationConfig(): array
|
||||
{
|
||||
return [
|
||||
'nextBtnSelector' => '.btn-next',
|
||||
'waitMs' => 2000,
|
||||
];
|
||||
}
|
||||
|
||||
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
|
||||
{
|
||||
$unifiedData = $this->unifiedData;
|
||||
if ($detailData) {
|
||||
$unifiedData->todayNewCount = (int) ($detailData['data']['newFollowersToday'] ?? 0);
|
||||
}
|
||||
foreach ($allListPagesData as $pageRaw) {
|
||||
$records = $pageRaw['data']['rows'] ?? [];
|
||||
foreach ($records as $item) {
|
||||
if (empty($item['account'])) {
|
||||
continue;
|
||||
}
|
||||
$number = (string) $item['account'];
|
||||
$isOnline = isset($item['numberStatus']) && (int) $item['numberStatus'] === 1;
|
||||
$unifiedData->addNumber($number, $isOnline, (int) ($item['newFollowersToday'] ?? 0));
|
||||
}
|
||||
}
|
||||
return $unifiedData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\scrm\spider;
|
||||
|
||||
use app\common\library\scrm\AbstractScrmSpider;
|
||||
use app\common\library\scrm\UnifiedScrmData;
|
||||
|
||||
/**
|
||||
* 海王云控蜘蛛
|
||||
*/
|
||||
class HaiwangSpider extends AbstractScrmSpider
|
||||
{
|
||||
private const API_LIST = '/webApi/accountshow/list';
|
||||
|
||||
private const DEFAULT_PER_PAGE_COUNT = 10;
|
||||
|
||||
private string $pageUrl;
|
||||
|
||||
private string $account;
|
||||
|
||||
private string $password;
|
||||
|
||||
private UnifiedScrmData $unifiedData;
|
||||
|
||||
public function __construct(
|
||||
string $pageUrl,
|
||||
string $account = '',
|
||||
string $password = '',
|
||||
string $nodeHost = 'http://127.0.0.1:3001'
|
||||
) {
|
||||
parent::__construct($nodeHost);
|
||||
$this->pageUrl = $pageUrl;
|
||||
$this->account = $account;
|
||||
$this->password = $password;
|
||||
$this->unifiedData = new UnifiedScrmData();
|
||||
}
|
||||
|
||||
protected function getSpiderConfig(): array
|
||||
{
|
||||
return [
|
||||
'pageUrl' => $this->pageUrl,
|
||||
'listApi' => self::API_LIST,
|
||||
'listMethod' => 'POST',
|
||||
'paginationMode' => self::MODE_UI,
|
||||
'authActions' => [
|
||||
['type' => 'type', 'selector' => 'input[type="password"]', 'value' => $this->password],
|
||||
['type' => 'press', 'key' => 'Enter'],
|
||||
['type' => 'wait', 'ms' => 2000],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
||||
{
|
||||
$total = (int) ($listFirstPageData['data']['total'] ?? 0);
|
||||
$this->unifiedData->total = $total;
|
||||
if ($total <= self::DEFAULT_PER_PAGE_COUNT) {
|
||||
return 1;
|
||||
}
|
||||
return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT);
|
||||
}
|
||||
|
||||
protected function buildListPageParams(int $page): array
|
||||
{
|
||||
return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT];
|
||||
}
|
||||
|
||||
protected function getUiPaginationConfig(): array
|
||||
{
|
||||
return [
|
||||
'nextBtnSelector' => '.btn-next',
|
||||
'waitMs' => 2000,
|
||||
];
|
||||
}
|
||||
|
||||
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
|
||||
{
|
||||
$unifiedData = $this->unifiedData;
|
||||
foreach ($allListPagesData as $pageRaw) {
|
||||
$records = $pageRaw['data']['items'] ?? [];
|
||||
foreach ($records as $item) {
|
||||
if (empty($item['acclist_account'])) {
|
||||
continue;
|
||||
}
|
||||
$number = (string) $item['acclist_account'];
|
||||
$isOnline = isset($item['acclist_status']) && (int) $item['acclist_status'] === 2;
|
||||
$unifiedData->addNumber(
|
||||
$number,
|
||||
$isOnline,
|
||||
(int) ($item['account_statistics_today_effective'] ?? 0)
|
||||
);
|
||||
}
|
||||
}
|
||||
if (!empty($allListPagesData[0]['data']['shareStatistics']['sharecode_statistics_today_contact_effective'])) {
|
||||
$unifiedData->todayNewCount = (int) $allListPagesData[0]['data']['shareStatistics']['sharecode_statistics_today_contact_effective'];
|
||||
}
|
||||
return $unifiedData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\scrm\spider;
|
||||
|
||||
use app\common\library\scrm\AbstractScrmSpider;
|
||||
use app\common\library\scrm\UnifiedScrmData;
|
||||
|
||||
/**
|
||||
* 火箭云控蜘蛛
|
||||
*/
|
||||
class HuojianSpider extends AbstractScrmSpider
|
||||
{
|
||||
private const API_LIST = '/prod-api1/biz/counter/link/share/';
|
||||
|
||||
private string $pageUrl;
|
||||
|
||||
private string $account;
|
||||
|
||||
private string $password;
|
||||
|
||||
private UnifiedScrmData $unifiedData;
|
||||
|
||||
public function __construct(
|
||||
string $pageUrl,
|
||||
string $account = '',
|
||||
string $password = '',
|
||||
string $nodeHost = 'http://127.0.0.1:3001'
|
||||
) {
|
||||
parent::__construct($nodeHost);
|
||||
$this->pageUrl = $pageUrl;
|
||||
$this->account = $account;
|
||||
$this->password = $password;
|
||||
$this->unifiedData = new UnifiedScrmData();
|
||||
}
|
||||
|
||||
protected function getSpiderConfig(): array
|
||||
{
|
||||
return [
|
||||
'pageUrl' => $this->pageUrl,
|
||||
'listApi' => self::API_LIST,
|
||||
'listMethod' => 'POST',
|
||||
'paginationMode' => self::MODE_UI,
|
||||
'authActions' => [
|
||||
['type' => 'vue_fill', 'selector' => '.el-message-box__input input', 'value' => $this->password],
|
||||
['type' => 'wait', 'ms' => 500],
|
||||
['type' => 'vue_click', 'selector' => '.el-message-box__btns .el-button--primary'],
|
||||
['type' => 'wait', 'ms' => 2000],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
protected function buildListPageParams(int $page): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function getUiPaginationConfig(): array
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
|
||||
{
|
||||
$unifiedData = $this->unifiedData;
|
||||
$unifiedData->todayNewCount = (int) ($allListPagesData[0]['data']['counterWorker']['newTodayFriend'] ?? 0);
|
||||
$count = 0;
|
||||
foreach ($allListPagesData as $pageRaw) {
|
||||
$records = $pageRaw['data']['counterCsAccountVo'] ?? [];
|
||||
foreach ($records as $item) {
|
||||
if (empty($item['accountLogin'])) {
|
||||
continue;
|
||||
}
|
||||
$number = (string) $item['accountLogin'];
|
||||
$isOnline = isset($item['accountStatus']) && (int) $item['accountStatus'] === 1;
|
||||
$count++;
|
||||
$unifiedData->addNumber($number, $isOnline, (int) ($item['newTodayFriend'] ?? 0));
|
||||
}
|
||||
}
|
||||
$unifiedData->total = $count;
|
||||
return $unifiedData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\scrm\spider;
|
||||
|
||||
use app\common\library\scrm\AbstractScrmSpider;
|
||||
use app\common\library\scrm\UnifiedScrmData;
|
||||
|
||||
/**
|
||||
* SS云控(Customer) 蜘蛛
|
||||
*/
|
||||
class SsCustomerSpider extends AbstractScrmSpider
|
||||
{
|
||||
private const API_LIST = '/sys/share/report/get-customer-analysis-dimension-list';
|
||||
|
||||
private const API_DETAILS = '/sys/share/report/get-customer-analysis-statistics';
|
||||
|
||||
private const DEFAULT_PER_PAGE_COUNT = 20;
|
||||
|
||||
private string $pageUrl;
|
||||
|
||||
private string $account;
|
||||
|
||||
private string $password;
|
||||
|
||||
private UnifiedScrmData $unifiedData;
|
||||
|
||||
public function __construct(
|
||||
string $pageUrl,
|
||||
string $account = '',
|
||||
string $password = '',
|
||||
string $nodeHost = 'http://127.0.0.1:3001'
|
||||
) {
|
||||
parent::__construct($nodeHost);
|
||||
$this->pageUrl = $pageUrl;
|
||||
$this->account = $account;
|
||||
$this->password = $password;
|
||||
$this->unifiedData = new UnifiedScrmData();
|
||||
}
|
||||
|
||||
protected function getSpiderConfig(): array
|
||||
{
|
||||
return [
|
||||
'pageUrl' => $this->pageUrl,
|
||||
'listApi' => self::API_LIST,
|
||||
'detailApi' => self::API_DETAILS,
|
||||
'listMethod' => 'POST',
|
||||
'paginationMode' => self::MODE_UI,
|
||||
'authActions' => [
|
||||
['type' => 'wait', 'ms' => 2000],
|
||||
['type' => 'type', 'selector' => 'input[type="password"]', 'value' => $this->password],
|
||||
['type' => 'press', 'key' => 'Enter'],
|
||||
['type' => 'wait', 'ms' => 3000],
|
||||
[
|
||||
'type' => 'vue_click',
|
||||
'selector' => 'button[class*="reports-customers__dimension"]',
|
||||
'text' => 'social media accounts',
|
||||
],
|
||||
['type' => 'wait', 'ms' => 3000],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
protected function buildListPageParams(int $page): array
|
||||
{
|
||||
return ['page' => $page, 'pageSize' => self::DEFAULT_PER_PAGE_COUNT];
|
||||
}
|
||||
|
||||
protected function getUiPaginationConfig(): array
|
||||
{
|
||||
return [
|
||||
'nextBtnSelector' => '.arco-pagination-item-next',
|
||||
'waitMs' => 2000,
|
||||
];
|
||||
}
|
||||
|
||||
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
|
||||
{
|
||||
$unifiedData = $this->unifiedData;
|
||||
if ($detailData) {
|
||||
$unifiedData->todayNewCount = (int) ($detailData['data']['distinct_contacts_total'] ?? 0);
|
||||
}
|
||||
foreach ($allListPagesData as $pageRaw) {
|
||||
$records = $pageRaw['data']['list'] ?? [];
|
||||
foreach ($records as $item) {
|
||||
if (empty($item['channel_tag'])) {
|
||||
continue;
|
||||
}
|
||||
$number = (string) $item['channel_tag'];
|
||||
$unifiedData->addNumber($number, true, (int) ($item['distinct_contacts_total'] ?? 0));
|
||||
}
|
||||
}
|
||||
$unifiedData->total = count($unifiedData->numbers);
|
||||
return $unifiedData;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\library\scrm\spider;
|
||||
|
||||
use app\common\library\scrm\AbstractScrmSpider;
|
||||
use app\common\library\scrm\UnifiedScrmData;
|
||||
|
||||
/**
|
||||
* 星河云控蜘蛛
|
||||
*/
|
||||
class XingheSpider extends AbstractScrmSpider
|
||||
{
|
||||
private const API_LIST = '/share/share/api_yinliu_count.html';
|
||||
|
||||
private const DEFAULT_PER_PAGE_COUNT = 10;
|
||||
|
||||
private string $pageUrl;
|
||||
|
||||
private string $account;
|
||||
|
||||
private string $password;
|
||||
|
||||
private UnifiedScrmData $unifiedData;
|
||||
|
||||
public function __construct(
|
||||
string $pageUrl,
|
||||
string $account = '',
|
||||
string $password = '',
|
||||
string $nodeHost = 'http://127.0.0.1:3001'
|
||||
) {
|
||||
parent::__construct($nodeHost);
|
||||
$this->pageUrl = $pageUrl;
|
||||
$this->account = $account;
|
||||
$this->password = $password;
|
||||
$this->unifiedData = new UnifiedScrmData();
|
||||
}
|
||||
|
||||
protected function getSpiderConfig(): array
|
||||
{
|
||||
return [
|
||||
'pageUrl' => $this->pageUrl,
|
||||
'listApi' => self::API_LIST,
|
||||
'listMethod' => 'GET',
|
||||
'paginationMode' => self::MODE_FETCH,
|
||||
'authActions' => [
|
||||
['type' => 'wait', 'ms' => 2000],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
protected function extractListTotalPages($listFirstPageData, $countData = null)
|
||||
{
|
||||
$total = (int) ($listFirstPageData['count'] ?? 0);
|
||||
$this->unifiedData->total = $total;
|
||||
$this->unifiedData->todayNewCount = (int) ($listFirstPageData['totalRow']['day_sum'] ?? 0);
|
||||
if ($total <= self::DEFAULT_PER_PAGE_COUNT) {
|
||||
return 1;
|
||||
}
|
||||
return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT);
|
||||
}
|
||||
|
||||
protected function buildListPageParams(int $page): array
|
||||
{
|
||||
return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT];
|
||||
}
|
||||
|
||||
protected function getUiPaginationConfig(): array
|
||||
{
|
||||
return [
|
||||
'nextBtnSelector' => '.layui-laypage-next',
|
||||
'waitMs' => 2000,
|
||||
];
|
||||
}
|
||||
|
||||
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
|
||||
{
|
||||
$unifiedData = $this->unifiedData;
|
||||
foreach ($allListPagesData as $pageRaw) {
|
||||
$records = $pageRaw['data'] ?? [];
|
||||
foreach ($records as $item) {
|
||||
if (empty($item['user'])) {
|
||||
continue;
|
||||
}
|
||||
$number = (string) $item['user'];
|
||||
$isOnline = isset($item['online']) && (int) $item['online'] === 1;
|
||||
$unifiedData->addNumber($number, $isOnline, (int) ($item['day_sum'] ?? 0));
|
||||
}
|
||||
}
|
||||
return $unifiedData;
|
||||
}
|
||||
}
|
||||
Executable
+242
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
use think\Model;
|
||||
|
||||
/**
|
||||
* 配置模型
|
||||
*/
|
||||
class Config extends Model
|
||||
{
|
||||
|
||||
// 表名,不含前缀
|
||||
protected $name = 'config';
|
||||
// 自动写入时间戳字段
|
||||
protected $autoWriteTimestamp = false;
|
||||
// 定义时间戳字段名
|
||||
protected $createTime = false;
|
||||
protected $updateTime = false;
|
||||
// 追加属性
|
||||
protected $append = [
|
||||
'extend_html'
|
||||
];
|
||||
protected $type = [
|
||||
'setting' => 'json',
|
||||
];
|
||||
|
||||
/**
|
||||
* 读取配置类型
|
||||
* @return array
|
||||
*/
|
||||
public static function getTypeList()
|
||||
{
|
||||
$typeList = [
|
||||
'string' => __('String'),
|
||||
'password' => __('Password'),
|
||||
'text' => __('Text'),
|
||||
'editor' => __('Editor'),
|
||||
'number' => __('Number'),
|
||||
'date' => __('Date'),
|
||||
'time' => __('Time'),
|
||||
'datetime' => __('Datetime'),
|
||||
'datetimerange' => __('Datetimerange'),
|
||||
'select' => __('Select'),
|
||||
'selects' => __('Selects'),
|
||||
'image' => __('Image'),
|
||||
'images' => __('Images'),
|
||||
'file' => __('File'),
|
||||
'files' => __('Files'),
|
||||
'switch' => __('Switch'),
|
||||
'checkbox' => __('Checkbox'),
|
||||
'radio' => __('Radio'),
|
||||
'city' => __('City'),
|
||||
'selectpage' => __('Selectpage'),
|
||||
'selectpages' => __('Selectpages'),
|
||||
'array' => __('Array'),
|
||||
'custom' => __('Custom'),
|
||||
];
|
||||
return $typeList;
|
||||
}
|
||||
|
||||
public static function getRegexList()
|
||||
{
|
||||
$regexList = [
|
||||
'required' => '必选',
|
||||
'digits' => '数字',
|
||||
'letters' => '字母',
|
||||
'date' => '日期',
|
||||
'time' => '时间',
|
||||
'email' => '邮箱',
|
||||
'url' => '网址',
|
||||
'qq' => 'QQ号',
|
||||
'IDcard' => '身份证',
|
||||
'tel' => '座机电话',
|
||||
'mobile' => '手机号',
|
||||
'zipcode' => '邮编',
|
||||
'chinese' => '中文',
|
||||
'username' => '用户名',
|
||||
'password' => '密码'
|
||||
];
|
||||
return $regexList;
|
||||
}
|
||||
|
||||
public function getExtendHtmlAttr($value, $data)
|
||||
{
|
||||
$result = preg_replace_callback("/\{([a-zA-Z]+)\}/", function ($matches) use ($data) {
|
||||
if (isset($data[$matches[1]])) {
|
||||
return $data[$matches[1]];
|
||||
}
|
||||
}, $data['extend']);
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取分类分组列表(优先数据库 configgroup,避免 site.php 未同步时分组缺失)
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public static function getGroupList()
|
||||
{
|
||||
$groupList = config('site.configgroup');
|
||||
if (!is_array($groupList)) {
|
||||
$groupList = [];
|
||||
}
|
||||
try {
|
||||
$dbJson = \think\Db::name('config')->where('name', 'configgroup')->value('value');
|
||||
$dbList = json_decode((string) $dbJson, true);
|
||||
if (is_array($dbList) && $dbList !== []) {
|
||||
$groupList = $dbList;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
// 数据库不可用时回退 site.php
|
||||
}
|
||||
foreach ($groupList as $k => &$v) {
|
||||
$v = __($v);
|
||||
}
|
||||
return $groupList;
|
||||
}
|
||||
|
||||
public static function getArrayData($data)
|
||||
{
|
||||
if (!isset($data['value'])) {
|
||||
$result = [];
|
||||
foreach ($data as $index => $datum) {
|
||||
$result['field'][$index] = $datum['key'];
|
||||
$result['value'][$index] = $datum['value'];
|
||||
}
|
||||
$data = $result;
|
||||
}
|
||||
$fieldarr = $valuearr = [];
|
||||
$field = $data['field'] ?? ($data['key'] ?? []);
|
||||
$value = $data['value'] ?? [];
|
||||
foreach ($field as $m => $n) {
|
||||
if ($n != '') {
|
||||
$fieldarr[] = $field[$m];
|
||||
$valuearr[] = $value[$m];
|
||||
}
|
||||
}
|
||||
return $fieldarr ? array_combine($fieldarr, $valuearr) : [];
|
||||
}
|
||||
|
||||
/**
|
||||
* 将字符串解析成键值数组
|
||||
* @param string $text
|
||||
* @return array
|
||||
*/
|
||||
public static function decode($text, $split = "\r\n")
|
||||
{
|
||||
$content = explode($split, $text);
|
||||
$arr = [];
|
||||
foreach ($content as $k => $v) {
|
||||
if (stripos($v, "|") !== false) {
|
||||
$item = explode('|', $v);
|
||||
$arr[$item[0]] = $item[1];
|
||||
}
|
||||
}
|
||||
return $arr;
|
||||
}
|
||||
|
||||
/**
|
||||
* 将键值数组转换为字符串
|
||||
* @param array $array
|
||||
* @return string
|
||||
*/
|
||||
public static function encode($array, $split = "\r\n")
|
||||
{
|
||||
$content = '';
|
||||
if ($array && is_array($array)) {
|
||||
$arr = [];
|
||||
foreach ($array as $k => $v) {
|
||||
$arr[] = "{$k}|{$v}";
|
||||
}
|
||||
$content = implode($split, $arr);
|
||||
}
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* 本地上传配置信息
|
||||
* @return array
|
||||
*/
|
||||
public static function upload()
|
||||
{
|
||||
$uploadcfg = config('upload');
|
||||
|
||||
$uploadurl = request()->module() ? $uploadcfg['uploadurl'] : ($uploadcfg['uploadurl'] === 'ajax/upload' ? 'index/' . $uploadcfg['uploadurl'] : $uploadcfg['uploadurl']);
|
||||
|
||||
if (!preg_match("/^((?:[a-z]+:)?\/\/)(.*)/i", $uploadurl) && substr($uploadurl, 0, 1) !== '/') {
|
||||
$uploadurl = url($uploadurl, '', false);
|
||||
}
|
||||
$uploadcfg['fullmode'] = isset($uploadcfg['fullmode']) && $uploadcfg['fullmode'];
|
||||
$uploadcfg['thumbstyle'] = $uploadcfg['thumbstyle'] ?? '';
|
||||
|
||||
$upload = [
|
||||
'cdnurl' => $uploadcfg['cdnurl'],
|
||||
'uploadurl' => $uploadurl,
|
||||
'bucket' => 'local',
|
||||
'maxsize' => $uploadcfg['maxsize'],
|
||||
'mimetype' => $uploadcfg['mimetype'],
|
||||
'chunking' => $uploadcfg['chunking'],
|
||||
'chunksize' => $uploadcfg['chunksize'],
|
||||
'savekey' => $uploadcfg['savekey'],
|
||||
'multipart' => [],
|
||||
'multiple' => $uploadcfg['multiple'],
|
||||
'fullmode' => $uploadcfg['fullmode'],
|
||||
'thumbstyle' => $uploadcfg['thumbstyle'],
|
||||
'storage' => 'local'
|
||||
];
|
||||
return $upload;
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新配置文件
|
||||
*/
|
||||
public static function refreshFile()
|
||||
{
|
||||
//如果没有配置权限无法进行修改
|
||||
if (!\app\admin\library\Auth::instance()->check('general/config/edit')) {
|
||||
return false;
|
||||
}
|
||||
$config = [];
|
||||
$configList = self::all();
|
||||
foreach ($configList as $k => $v) {
|
||||
$value = $v->toArray();
|
||||
if (in_array($value['type'], ['selects', 'checkbox', 'images', 'files'])) {
|
||||
$value['value'] = explode(',', $value['value']);
|
||||
}
|
||||
if ($value['type'] == 'array') {
|
||||
$value['value'] = (array)json_decode($value['value'], true);
|
||||
}
|
||||
$config[$value['name']] = $value['value'];
|
||||
}
|
||||
file_put_contents(
|
||||
CONF_PATH . 'extra' . DS . 'site.php',
|
||||
'<?php' . "\n\nreturn " . var_export($config, true) . ";\n"
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
}
|
||||
+381
@@ -0,0 +1,381 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use fast\Http;
|
||||
use think\Exception;
|
||||
|
||||
/**
|
||||
* Cloudflare API 服务层
|
||||
*/
|
||||
class CloudflareService
|
||||
{
|
||||
private const API_BASE = 'https://api.cloudflare.com/client/v4';
|
||||
|
||||
private string $apiToken;
|
||||
private string $accountId;
|
||||
private string $serverIp;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$config = config('cloudflare');
|
||||
$this->apiToken = (string)($config['api_token'] ?? '');
|
||||
$this->accountId = (string)($config['account_id'] ?? '');
|
||||
$this->serverIp = trim((string)($config['server_ip'] ?? ''));
|
||||
if ($this->apiToken === '' || $this->accountId === '') {
|
||||
throw new Exception('Cloudflare API 凭证未配置,请在 .env 中设置 cloudflare.api_token 与 cloudflare.account_id');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建 Cloudflare Zone
|
||||
*
|
||||
* @param string $domain 根域名
|
||||
* @return array{zone_id: string, name_servers: array, status: string}
|
||||
* @throws Exception
|
||||
*/
|
||||
public function createZone(string $domain): array
|
||||
{
|
||||
$body = [
|
||||
'name' => $domain,
|
||||
'account' => ['id' => $this->accountId],
|
||||
'type' => 'full',
|
||||
];
|
||||
$response = $this->request('POST', '/zones', $body);
|
||||
$result = $response['result'] ?? [];
|
||||
return [
|
||||
'zone_id' => (string)($result['id'] ?? ''),
|
||||
'name_servers' => (array)($result['name_servers'] ?? []),
|
||||
'status' => (string)($result['status'] ?? 'pending'),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function getZone(string $zoneId): array
|
||||
{
|
||||
$response = $this->request('GET', '/zones/' . $zoneId);
|
||||
return (array)($response['result'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function deleteZone(string $zoneId): bool
|
||||
{
|
||||
$this->request('DELETE', '/zones/' . $zoneId);
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
public function listDnsRecords(string $zoneId): array
|
||||
{
|
||||
$response = $this->request('GET', '/zones/' . $zoneId . '/dns_records', [], ['per_page' => 100]);
|
||||
return (array)($response['result'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* 聚合检测 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}
|
||||
*/
|
||||
public function detectDomain(array $row): array
|
||||
{
|
||||
$domain = strtolower(trim((string)($row['domain'] ?? '')));
|
||||
$zoneId = (string)($row['zone_id'] ?? '');
|
||||
$expectedNs = $this->decodeNameservers($row['nameservers'] ?? '');
|
||||
|
||||
$zoneStatus = 'failed';
|
||||
$nsStatus = 'pending';
|
||||
$dnsStatus = 'pending';
|
||||
$messages = [];
|
||||
|
||||
try {
|
||||
$zone = $this->getZone($zoneId);
|
||||
$cfStatus = (string)($zone['status'] ?? '');
|
||||
if ($cfStatus === 'active') {
|
||||
$zoneStatus = 'active';
|
||||
$messages[] = 'Zone:已激活';
|
||||
} elseif (in_array($cfStatus, ['pending', 'initializing'], true)) {
|
||||
$zoneStatus = 'pending';
|
||||
$messages[] = 'Zone:待激活';
|
||||
} else {
|
||||
$zoneStatus = 'failed';
|
||||
$messages[] = 'Zone:异常(' . $cfStatus . ')';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$zoneStatus = 'failed';
|
||||
$messages[] = 'Zone:检测失败(' . $e->getMessage() . ')';
|
||||
}
|
||||
|
||||
$currentNs = $this->getDomainNameservers($domain);
|
||||
if ($currentNs === []) {
|
||||
$nsStatus = 'pending';
|
||||
$messages[] = 'NS:待验证(未查询到NS记录)';
|
||||
} elseif ($this->compareNameservers($currentNs, $expectedNs)) {
|
||||
$nsStatus = 'verified';
|
||||
$messages[] = 'NS:已验证';
|
||||
} else {
|
||||
$nsStatus = 'pending';
|
||||
$messages[] = 'NS:待验证(当前NS与Cloudflare不一致)';
|
||||
}
|
||||
|
||||
if ($zoneStatus !== 'active') {
|
||||
$dnsStatus = 'pending';
|
||||
$messages[] = 'DNS:待创建(Zone未激活)';
|
||||
} elseif ($nsStatus !== 'verified') {
|
||||
$dnsStatus = 'pending';
|
||||
$messages[] = 'DNS:待创建(NS未验证)';
|
||||
} elseif ($this->serverIp === '') {
|
||||
$dnsStatus = 'failed';
|
||||
$messages[] = 'DNS:未配置server_ip';
|
||||
} elseif (!filter_var($this->serverIp, FILTER_VALIDATE_IP)) {
|
||||
$dnsStatus = 'failed';
|
||||
$messages[] = 'DNS:server_ip格式无效';
|
||||
} else {
|
||||
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 . ')';
|
||||
} else {
|
||||
$dnsStatus = 'pending';
|
||||
$messages[] = 'DNS:待创建(A记录未指向' . $this->serverIp . ')';
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$dnsStatus = 'failed';
|
||||
$messages[] = 'DNS:操作失败(' . $e->getMessage() . ')';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'zone_status' => $zoneStatus,
|
||||
'ns_status' => $nsStatus,
|
||||
'dns_status' => $dnsStatus,
|
||||
'check_result' => implode('; ', $messages),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function listRootARecords(string $zoneId, string $domain): array
|
||||
{
|
||||
$response = $this->request('GET', '/zones/' . $zoneId . '/dns_records', [], [
|
||||
'type' => 'A',
|
||||
'name' => $domain,
|
||||
]);
|
||||
return (array)($response['result'] ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function hasRootCnameConflict(string $zoneId, string $domain): bool
|
||||
{
|
||||
$response = $this->request('GET', '/zones/' . $zoneId . '/dns_records', [], [
|
||||
'type' => 'CNAME',
|
||||
'name' => $domain,
|
||||
]);
|
||||
return count((array)($response['result'] ?? [])) > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
{
|
||||
$records = $this->listRootARecords($zoneId, $domain);
|
||||
if ($records === []) {
|
||||
$this->request('POST', '/zones/' . $zoneId . '/dns_records', [
|
||||
'type' => 'A',
|
||||
'name' => $domain,
|
||||
'content' => $ip,
|
||||
'ttl' => 1,
|
||||
'proxied' => true,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($records as $record) {
|
||||
$recordId = (string)($record['id'] ?? '');
|
||||
$content = (string)($record['content'] ?? '');
|
||||
$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' => 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) {
|
||||
$content = (string)($record['content'] ?? '');
|
||||
$proxied = (bool)($record['proxied'] ?? false);
|
||||
if ($content === $ip && $proxied) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws Exception
|
||||
*/
|
||||
private function request(string $method, string $path, array $body = [], array $query = []): array
|
||||
{
|
||||
$url = self::API_BASE . $path;
|
||||
if ($query !== []) {
|
||||
$url .= '?' . http_build_query($query);
|
||||
}
|
||||
|
||||
$options = [
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Authorization: Bearer ' . $this->apiToken,
|
||||
'Content-Type: application/json',
|
||||
],
|
||||
];
|
||||
|
||||
$payload = $body !== [] ? json_encode($body, JSON_UNESCAPED_UNICODE) : '';
|
||||
|
||||
if (strtoupper($method) === 'GET') {
|
||||
$result = Http::sendRequest($url, [], 'GET', $options);
|
||||
} elseif (strtoupper($method) === 'DELETE') {
|
||||
$result = Http::sendRequest($url, $payload, 'DELETE', $options);
|
||||
} else {
|
||||
$result = Http::sendRequest($url, $payload, strtoupper($method), $options);
|
||||
}
|
||||
|
||||
if (!$result['ret']) {
|
||||
throw new Exception('Cloudflare API 请求失败: ' . ($result['msg'] ?? '未知错误'));
|
||||
}
|
||||
|
||||
$decoded = json_decode((string)$result['msg'], true);
|
||||
if (!is_array($decoded)) {
|
||||
throw new Exception('Cloudflare API 响应解析失败');
|
||||
}
|
||||
|
||||
if (empty($decoded['success'])) {
|
||||
$errors = $decoded['errors'] ?? [];
|
||||
$message = 'Cloudflare API 错误';
|
||||
if (is_array($errors) && isset($errors[0]['message'])) {
|
||||
$message = (string)$errors[0]['message'];
|
||||
}
|
||||
throw new Exception($message);
|
||||
}
|
||||
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
private function decodeNameservers($nameservers): array
|
||||
{
|
||||
if (is_array($nameservers)) {
|
||||
return $nameservers;
|
||||
}
|
||||
if (is_string($nameservers) && $nameservers !== '') {
|
||||
$decoded = json_decode($nameservers, true);
|
||||
return is_array($decoded) ? $decoded : [];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
private function getDomainNameservers(string $domain): array
|
||||
{
|
||||
$records = @dns_get_record($domain, DNS_NS);
|
||||
if (!is_array($records)) {
|
||||
return [];
|
||||
}
|
||||
$list = [];
|
||||
foreach ($records as $record) {
|
||||
if (!empty($record['target'])) {
|
||||
$list[] = $this->normalizeNs((string)$record['target']);
|
||||
}
|
||||
}
|
||||
return array_values(array_unique($list));
|
||||
}
|
||||
|
||||
private function compareNameservers(array $current, array $expected): bool
|
||||
{
|
||||
if ($expected === []) {
|
||||
return false;
|
||||
}
|
||||
$currentNorm = array_map([$this, 'normalizeNs'], $current);
|
||||
$expectedNorm = array_map([$this, 'normalizeNs'], $expected);
|
||||
sort($currentNorm);
|
||||
sort($expectedNorm);
|
||||
return $currentNorm === $expectedNorm;
|
||||
}
|
||||
|
||||
private function normalizeNs(string $ns): string
|
||||
{
|
||||
return rtrim(strtolower(trim($ns)), '.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use think\Cache;
|
||||
use think\Exception;
|
||||
|
||||
/**
|
||||
* 域名检测频率限制
|
||||
* 每分钟最多 2 次,超限后冷却 120 秒
|
||||
*/
|
||||
class DomainDetectRateLimitService
|
||||
{
|
||||
/** 统计窗口(秒) */
|
||||
private const WINDOW_SECONDS = 60;
|
||||
|
||||
/** 窗口内最大检测次数 */
|
||||
private const MAX_ATTEMPTS = 2;
|
||||
|
||||
/** 超限后冷却时间(秒) */
|
||||
private const COOLDOWN_SECONDS = 120;
|
||||
|
||||
/**
|
||||
* 校验是否允许检测,不允许时抛出异常
|
||||
*
|
||||
* @param int $adminId 管理员 ID
|
||||
* @param int $domainId 域名记录 ID
|
||||
* @throws Exception
|
||||
*/
|
||||
public function assertCanDetect(int $adminId, int $domainId): void
|
||||
{
|
||||
$now = time();
|
||||
$baseKey = 'domain_detect:' . $adminId . ':' . $domainId;
|
||||
$cooldownKey = $baseKey . ':cooldown';
|
||||
$attemptsKey = $baseKey . ':attempts';
|
||||
|
||||
$cooldownUntil = (int)Cache::get($cooldownKey, 0);
|
||||
if ($cooldownUntil > $now) {
|
||||
$wait = $cooldownUntil - $now;
|
||||
throw new Exception(sprintf('检测过于频繁,请 %d 秒后再试', $wait));
|
||||
}
|
||||
|
||||
$timestamps = Cache::get($attemptsKey);
|
||||
if (!is_array($timestamps)) {
|
||||
$timestamps = [];
|
||||
}
|
||||
|
||||
$timestamps = array_values(array_filter($timestamps, static function ($ts) use ($now): bool {
|
||||
return is_int($ts) && ($now - $ts) < self::WINDOW_SECONDS;
|
||||
}));
|
||||
|
||||
if (count($timestamps) >= self::MAX_ATTEMPTS) {
|
||||
Cache::set($cooldownKey, $now + self::COOLDOWN_SECONDS, self::COOLDOWN_SECONDS);
|
||||
Cache::set($attemptsKey, $timestamps, self::WINDOW_SECONDS + self::COOLDOWN_SECONDS);
|
||||
throw new Exception(sprintf(
|
||||
'一分钟最多检测 %d 次,请 %d 秒后再试',
|
||||
self::MAX_ATTEMPTS,
|
||||
self::COOLDOWN_SECONDS
|
||||
));
|
||||
}
|
||||
|
||||
$timestamps[] = $now;
|
||||
Cache::set($attemptsKey, $timestamps, self::WINDOW_SECONDS + self::COOLDOWN_SECONDS);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* 分流链接自动回复语句(一行一条)
|
||||
*/
|
||||
class SplitAutoReplyService
|
||||
{
|
||||
/** 最多条数 */
|
||||
private const MAX_LINES = 200;
|
||||
|
||||
/** 单条最大字符数 */
|
||||
private const MAX_LINE_LENGTH = 500;
|
||||
|
||||
/** 列表预览最大字符数 */
|
||||
private const LIST_PREVIEW_MAX = 20;
|
||||
|
||||
/**
|
||||
* 列表单元格预览(单行最多 50 字,超出加 ...)
|
||||
*/
|
||||
public static function previewForList(string $raw, int $max = self::LIST_PREVIEW_MAX): string
|
||||
{
|
||||
$display = self::formatDisplay($raw);
|
||||
if ($display === '') {
|
||||
return '';
|
||||
}
|
||||
$flat = preg_replace('/\s+/u', ' ', str_replace(["\r\n", "\r", "\n"], ' ', $display));
|
||||
$flat = trim((string) $flat);
|
||||
if ($flat === '') {
|
||||
return '';
|
||||
}
|
||||
if (mb_strlen($flat, 'UTF-8') <= $max) {
|
||||
return $flat;
|
||||
}
|
||||
return mb_substr($flat, 0, $max, 'UTF-8') . '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析为多行文本数组
|
||||
*
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function parseLines(string $raw): array
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return [];
|
||||
}
|
||||
$parts = preg_split('/\r\n|\r|\n/', $raw) ?: [];
|
||||
$lines = [];
|
||||
foreach ($parts as $part) {
|
||||
$line = trim((string) $part);
|
||||
if ($line === '') {
|
||||
continue;
|
||||
}
|
||||
if (strlen($line) > self::MAX_LINE_LENGTH) {
|
||||
$line = mb_substr($line, 0, self::MAX_LINE_LENGTH, 'UTF-8');
|
||||
}
|
||||
$lines[] = $line;
|
||||
if (count($lines) >= self::MAX_LINES) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化为数据库存储(换行分隔)
|
||||
*
|
||||
* @param string|array<int, string> $value
|
||||
*/
|
||||
public static function formatStorage($value): string
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$lines = $value;
|
||||
} else {
|
||||
$lines = self::parseLines((string) $value);
|
||||
}
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
/**
|
||||
* 供表单 textarea 回显
|
||||
*/
|
||||
public static function formatDisplay(string $stored): string
|
||||
{
|
||||
$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)];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* 按号码类型拼接各平台加好友链接
|
||||
*/
|
||||
class SplitFriendUrlBuilder
|
||||
{
|
||||
/**
|
||||
* 构建跳转 URL;无法构建时返回空字符串
|
||||
*
|
||||
* @param string $whatsAppReplyText 仅 WhatsApp 类型使用,预填消息文案(urlencode 在内部处理)
|
||||
*/
|
||||
public static function build(
|
||||
string $numberType,
|
||||
string $number,
|
||||
string $numberTypeCustom = '',
|
||||
string $whatsAppReplyText = ''
|
||||
): string {
|
||||
$number = trim($number);
|
||||
if ($number === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
switch ($numberType) {
|
||||
case 'whatsapp':
|
||||
return self::buildWhatsApp($number, $whatsAppReplyText);
|
||||
case 'telegram':
|
||||
return self::buildTelegram($number);
|
||||
case 'line':
|
||||
return self::buildLine($number);
|
||||
case 'custom':
|
||||
return self::buildCustom($number, $numberTypeCustom);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WhatsApp:https://api.whatsapp.com/send?phone= 仅数字,可选 &text= 预填消息
|
||||
*/
|
||||
private static function buildWhatsApp(string $number, string $replyText = ''): string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $number) ?? '';
|
||||
if ($digits === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
$url = 'https://api.whatsapp.com/send?phone=' . $digits;
|
||||
$replyText = trim($replyText);
|
||||
if ($replyText !== '') {
|
||||
$url .= '&text=' . rawurlencode($replyText);
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram:https://t.me/+ 号码(去掉前导 +)
|
||||
*/
|
||||
private static function buildTelegram(string $number): string
|
||||
{
|
||||
$id = ltrim($number, '+');
|
||||
if ($id === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return 'https://t.me/+' . $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Line:https://line.me/ti/p/~ 拼接号码
|
||||
*/
|
||||
private static function buildLine(string $number): string
|
||||
{
|
||||
return 'https://line.me/ti/p/~' . $number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义:号码字段即为完整链接(仅允许 http/https)
|
||||
*/
|
||||
private static function buildCustom(string $number, string $numberTypeCustom): string
|
||||
{
|
||||
$url = trim($number);
|
||||
if ($url === '' && trim($numberTypeCustom) !== '') {
|
||||
$url = trim($numberTypeCustom);
|
||||
}
|
||||
if ($url === '') {
|
||||
return '';
|
||||
}
|
||||
if (!filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
return '';
|
||||
}
|
||||
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
|
||||
if (!in_array($scheme, ['http', 'https'], true)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use GeoIp2\Database\Reader;
|
||||
use GeoIp2\Exception\AddressNotFoundException;
|
||||
|
||||
/**
|
||||
* 基于 GeoLite2-Country.mmdb 的 IP 国家查询(MaxMind GeoIP2)
|
||||
*/
|
||||
class SplitGeoIpService
|
||||
{
|
||||
/** 项目根目录下的 MaxMind 国家库文件名 */
|
||||
private const DB_FILENAME = 'GeoLite2-Country.mmdb';
|
||||
|
||||
private static ?Reader $reader = null;
|
||||
|
||||
/**
|
||||
* 解析 IP 对应 ISO 3166-1 alpha-2 国家代码;无法解析时返回 null
|
||||
*/
|
||||
public static function getCountryIso2(string $ip): ?string
|
||||
{
|
||||
$ip = trim($ip);
|
||||
if ($ip === '' || filter_var($ip, FILTER_VALIDATE_IP) === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
self::bootstrapLibrary();
|
||||
|
||||
try {
|
||||
$record = self::getReader()->country($ip);
|
||||
$code = $record->country->isoCode ?? null;
|
||||
if (!is_string($code) || $code === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return strtoupper($code);
|
||||
} catch (AddressNotFoundException $e) {
|
||||
return null;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MMDB 绝对路径
|
||||
*/
|
||||
public static function getDatabasePath(): string
|
||||
{
|
||||
return rtrim((string) ROOT_PATH, '/\\') . DIRECTORY_SEPARATOR . self::DB_FILENAME;
|
||||
}
|
||||
|
||||
public static function isDatabaseAvailable(): bool
|
||||
{
|
||||
$path = self::getDatabasePath();
|
||||
|
||||
return is_file($path) && is_readable($path);
|
||||
}
|
||||
|
||||
private static function bootstrapLibrary(): void
|
||||
{
|
||||
static $bootstrapped = false;
|
||||
if ($bootstrapped) {
|
||||
return;
|
||||
}
|
||||
$bootstrapped = true;
|
||||
$loader = ROOT_PATH . 'patches/third_party/load_geoip2.php';
|
||||
if (is_file($loader)) {
|
||||
require_once $loader;
|
||||
}
|
||||
}
|
||||
|
||||
private static function getReader(): Reader
|
||||
{
|
||||
if (self::$reader instanceof Reader) {
|
||||
return self::$reader;
|
||||
}
|
||||
if (!self::isDatabaseAvailable()) {
|
||||
throw new \RuntimeException('GeoLite2-Country.mmdb not readable at project root');
|
||||
}
|
||||
self::$reader = new Reader(self::getDatabasePath());
|
||||
|
||||
return self::$reader;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* 分流链接 IP 防护:开启时校验访客 IP 国家是否在链接投放国家列表内
|
||||
*/
|
||||
class SplitIpProtectService
|
||||
{
|
||||
/**
|
||||
* 是否允许继续轮转跳转
|
||||
*
|
||||
* @param int|string $ipProtect 链接 ip_protect:0=关闭,1=开启
|
||||
* @param string $countriesStorage 链接 countries 字段(ISO2 逗号分隔)
|
||||
* @param string $clientIp 访客 IP
|
||||
*/
|
||||
public static function isAllowed($ipProtect, string $countriesStorage, string $clientIp): bool
|
||||
{
|
||||
if ((int) $ipProtect !== 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$allowed = self::parseAllowedCountries($countriesStorage);
|
||||
if ($allowed === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!SplitGeoIpService::isDatabaseAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$visitorCountry = SplitGeoIpService::getCountryIso2($clientIp);
|
||||
if ($visitorCountry === null || $visitorCountry === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($visitorCountry, $allowed, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string> 大写 ISO2 列表
|
||||
*/
|
||||
public static function parseAllowedCountries(string $storage): array
|
||||
{
|
||||
if (trim($storage) === '') {
|
||||
return [];
|
||||
}
|
||||
$result = [];
|
||||
foreach (explode(',', $storage) as $code) {
|
||||
$code = strtoupper(trim($code));
|
||||
if ($code !== '' && !in_array($code, $result, true)) {
|
||||
$result[] = $code;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\admin\model\split\Link;
|
||||
use think\Exception;
|
||||
|
||||
/**
|
||||
* 分流链接码生成服务
|
||||
*/
|
||||
class SplitLinkCodeService
|
||||
{
|
||||
private const CODE_LENGTH = 9;
|
||||
|
||||
private const CHARSET = 'abcdefghijklmnopqrstuvwxyz';
|
||||
|
||||
private const MAX_ATTEMPTS = 50;
|
||||
|
||||
/**
|
||||
* 规范化链接码(小写、去空格)
|
||||
*/
|
||||
public static function normalize(string $code): string
|
||||
{
|
||||
return strtolower(trim($code));
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否为合法 9 位小写字母链接码
|
||||
*/
|
||||
public static function isValidFormat(string $code): bool
|
||||
{
|
||||
return (bool) preg_match('/^[a-z]{9}$/', $code);
|
||||
}
|
||||
|
||||
/**
|
||||
* 链接码是否未被占用
|
||||
*/
|
||||
public static function isAvailable(string $code, int $excludeId = 0): bool
|
||||
{
|
||||
$code = self::normalize($code);
|
||||
$query = Link::where('link_code', $code);
|
||||
if ($excludeId > 0) {
|
||||
$query->where('id', '<>', $excludeId);
|
||||
}
|
||||
return !$query->find();
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一 9 位小写字母链接码
|
||||
*
|
||||
* @throws Exception
|
||||
*/
|
||||
public function generateUnique(): string
|
||||
{
|
||||
for ($i = 0; $i < self::MAX_ATTEMPTS; $i++) {
|
||||
$code = $this->generateRandom();
|
||||
if (!Link::where('link_code', $code)->find()) {
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
throw new Exception('分流链接生成失败,请稍后重试');
|
||||
}
|
||||
|
||||
private function generateRandom(): string
|
||||
{
|
||||
$chars = self::CHARSET;
|
||||
$max = strlen($chars) - 1;
|
||||
$code = '';
|
||||
for ($i = 0; $i < self::CODE_LENGTH; $i++) {
|
||||
$code .= $chars[random_int(0, $max)];
|
||||
}
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\admin\model\split\Link;
|
||||
|
||||
/**
|
||||
* 分流链接随机打乱配置读取
|
||||
*/
|
||||
class SplitNumberWeighService
|
||||
{
|
||||
/**
|
||||
* 链接是否开启随机打乱(新号码按随机插入顺序写入,跳转按 id 顺序轮转)
|
||||
*/
|
||||
public static function isRandomShuffleEnabled(int $linkId): bool
|
||||
{
|
||||
if ($linkId <= 0) {
|
||||
return false;
|
||||
}
|
||||
return (int) Link::where('id', $linkId)->value('random_shuffle') === 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,171 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* 中转页浏览器 Pixel 脚本渲染(Facebook / TikTok)
|
||||
*/
|
||||
class SplitPixelBrowserRenderer
|
||||
{
|
||||
/**
|
||||
* @param array{facebook: array<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>} $config
|
||||
* @return array{
|
||||
* head_html: string,
|
||||
* body_html: string,
|
||||
* track_lines: array<int, string>,
|
||||
* track_jobs: array<int, array<string, mixed>>
|
||||
* }
|
||||
*/
|
||||
public static function render(array $config): array
|
||||
{
|
||||
$fbRows = SplitPixelConfigService::getEnabledSorted($config, SplitPixelConfigService::PLATFORM_FACEBOOK);
|
||||
$tkRows = SplitPixelConfigService::getEnabledSorted($config, SplitPixelConfigService::PLATFORM_TIKTOK);
|
||||
|
||||
$head = [];
|
||||
$initLines = [];
|
||||
$trackLines = [];
|
||||
$jobs = [];
|
||||
|
||||
if ($fbRows !== []) {
|
||||
$head[] = self::facebookLoaderScript();
|
||||
}
|
||||
if ($tkRows !== []) {
|
||||
$head[] = self::tiktokLoaderScript();
|
||||
}
|
||||
|
||||
foreach ($fbRows as $row) {
|
||||
$pixelId = self::escapeJsString((string) ($row['pixel_id'] ?? ''));
|
||||
$testCode = trim((string) ($row['test_code'] ?? ''));
|
||||
$event = self::escapeJsString((string) ($row['event'] ?? 'PageView'));
|
||||
if ($pixelId === '') {
|
||||
continue;
|
||||
}
|
||||
if ($testCode !== '') {
|
||||
$testEsc = self::escapeJsString($testCode);
|
||||
$initLines[] = "if(typeof fbq!=='undefined'){fbq('init','{$pixelId}',{},{test_event_code:'{$testEsc}'});}";
|
||||
} else {
|
||||
$initLines[] = "if(typeof fbq!=='undefined'){fbq('init','{$pixelId}');}";
|
||||
}
|
||||
$jobs[] = [
|
||||
'platform' => 'facebook',
|
||||
'event' => (string) ($row['event'] ?? 'PageView'),
|
||||
'pixel_id' => (string) ($row['pixel_id'] ?? ''),
|
||||
];
|
||||
$trackLines[] = "if(typeof fbq!=='undefined'){fbq('track','{$event}');}";
|
||||
}
|
||||
|
||||
foreach ($tkRows as $row) {
|
||||
$pixelId = self::escapeJsString((string) ($row['pixel_id'] ?? ''));
|
||||
$event = self::mapTikTokBrowserEvent((string) ($row['event'] ?? 'PageView'));
|
||||
$eventJs = self::escapeJsString($event);
|
||||
if ($pixelId === '') {
|
||||
continue;
|
||||
}
|
||||
$initLines[] = "if(typeof ttq!=='undefined'){ttq.load('{$pixelId}');ttq.page();}";
|
||||
$jobs[] = [
|
||||
'platform' => 'tiktok',
|
||||
'event' => $event,
|
||||
'pixel_id' => (string) ($row['pixel_id'] ?? ''),
|
||||
];
|
||||
$trackLines[] = "if(typeof ttq!=='undefined'){ttq.track('{$eventJs}');}";
|
||||
}
|
||||
|
||||
return [
|
||||
'head_html' => implode("\n", $head),
|
||||
'body_html' => self::wrapScriptBlock($initLines),
|
||||
'track_lines' => $trackLines,
|
||||
'track_jobs' => $jobs,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 跳转 orchestrator:在 setTimeout 回调内触发像素事件后再 replace
|
||||
*
|
||||
* @param array<int, string> $trackLines
|
||||
*/
|
||||
public static function renderRedirectOrchestrator(string $redirectUrlJson, array $trackLines = [], int $maxWaitMs = 1500): string
|
||||
{
|
||||
$trackBlock = $trackLines !== [] ? implode("\n ", $trackLines) : '';
|
||||
$script = self::renderRedirectOrchestratorScript();
|
||||
|
||||
return str_replace(
|
||||
['{$redirectUrlJson}', '{$maxWaitMs}', '{$trackBlock}'],
|
||||
[$redirectUrlJson, (string) $maxWaitMs, $trackBlock],
|
||||
$script
|
||||
);
|
||||
}
|
||||
|
||||
private static function renderRedirectOrchestratorScript(): string
|
||||
{
|
||||
return <<<'JS'
|
||||
<script type="text/javascript">
|
||||
(function () {
|
||||
var url = {$redirectUrlJson};
|
||||
if (!url) {
|
||||
return;
|
||||
}
|
||||
setTimeout(function () {
|
||||
{$trackBlock}
|
||||
window.location.replace(url);
|
||||
}, {$maxWaitMs});
|
||||
})();
|
||||
</script>
|
||||
JS;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $lines
|
||||
*/
|
||||
private static function wrapScriptBlock(array $lines): string
|
||||
{
|
||||
if ($lines === []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<script type="text/javascript">' . "\n"
|
||||
. implode("\n", $lines) . "\n"
|
||||
. '</script>';
|
||||
}
|
||||
|
||||
private static function facebookLoaderScript(): string
|
||||
{
|
||||
return <<<'HTML'
|
||||
<script>
|
||||
!function(f,b,e,v,n,t,s){if(f.fbq)return;n=f.fbq=function(){n.callMethod?
|
||||
n.callMethod.apply(n,arguments):n.queue.push(arguments)};if(!f._fbq)f._fbq=n;
|
||||
n.push=n;n.loaded=!0;n.version='2.0';n.queue=[];t=b.createElement(e);t.async=!0;
|
||||
t.src=v;s=b.getElementsByTagName(e)[0];s.parentNode.insertBefore(t,s)}(window,document,'script','https://connect.facebook.net/en_US/fbevents.js');
|
||||
</script>
|
||||
HTML;
|
||||
}
|
||||
|
||||
private static function tiktokLoaderScript(): string
|
||||
{
|
||||
return <<<'HTML'
|
||||
<script>
|
||||
!function(w,d,t){w.TiktokAnalyticsObject=t;var ttq=w[t]=w[t]||[];ttq.methods=["page","track","identify","instances","debug","on","off","once","ready","alias","group","enableCookie","disableCookie"],ttq.setAndDefer=function(t,e){t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}};for(var i=0;i<ttq.methods.length;i++)ttq.setAndDefer(ttq,ttq.methods[i]);ttq.instance=function(t){for(var e=ttq._i[t]||[],n=0;n<ttq.methods.length;n++)ttq.setAndDefer(e,ttq.methods[n]);return e},ttq.load=function(e,n){var i="https://analytics.tiktok.com/i18n/pixel/events.js";ttq._i=ttq._i||{},ttq._i[e]=[],ttq._i[e]._u=i,ttq._t=ttq._t||{},ttq._t[e]=+new Date,ttq._o=ttq._o||{},ttq._o[e]=n||{};var o=document.createElement("script");o.type="text/javascript",o.async=!0,o.src=i+"?sdkid="+e+"&lib="+t;var a=document.getElementsByTagName("script")[0];a.parentNode.insertBefore(o,a)}}(window,document,'ttq');
|
||||
</script>
|
||||
HTML;
|
||||
}
|
||||
|
||||
private static function mapTikTokBrowserEvent(string $event): string
|
||||
{
|
||||
$map = [
|
||||
'PageView' => 'Pageview',
|
||||
'Lead' => 'SubmitForm',
|
||||
'Contact' => 'Contact',
|
||||
'AddToCart' => 'AddToCart',
|
||||
'Purchase' => 'CompletePayment',
|
||||
'Subscribe' => 'Subscribe',
|
||||
];
|
||||
|
||||
return $map[$event] ?? 'Pageview';
|
||||
}
|
||||
|
||||
private static function escapeJsString(string $value): string
|
||||
{
|
||||
return str_replace(['\\', "'"], ['\\\\', "\\'"], $value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* 分流链接像素配置:解析、校验、合并保存、脱敏
|
||||
*/
|
||||
class SplitPixelConfigService
|
||||
{
|
||||
public const PLATFORM_FACEBOOK = 'facebook';
|
||||
|
||||
public const PLATFORM_TIKTOK = 'tiktok';
|
||||
|
||||
/** @var string[] */
|
||||
public const EVENT_OPTIONS = [
|
||||
'PageView',
|
||||
'Lead',
|
||||
'Contact',
|
||||
'AddToCart',
|
||||
'Purchase',
|
||||
'Subscribe',
|
||||
];
|
||||
|
||||
private const MAX_ITEMS_PER_PLATFORM = 20;
|
||||
|
||||
/**
|
||||
* 解析存储 JSON 为规范结构
|
||||
*
|
||||
* @return array{facebook: array<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>}
|
||||
*/
|
||||
public static function parseStorage(?string $raw): array
|
||||
{
|
||||
$empty = [
|
||||
self::PLATFORM_FACEBOOK => [],
|
||||
self::PLATFORM_TIKTOK => [],
|
||||
];
|
||||
if ($raw === null || trim($raw) === '') {
|
||||
return $empty;
|
||||
}
|
||||
$decoded = json_decode($raw, true);
|
||||
if (!is_array($decoded)) {
|
||||
return $empty;
|
||||
}
|
||||
|
||||
return [
|
||||
self::PLATFORM_FACEBOOK => self::normalizePlatformList(
|
||||
$decoded[self::PLATFORM_FACEBOOK] ?? [],
|
||||
self::PLATFORM_FACEBOOK
|
||||
),
|
||||
self::PLATFORM_TIKTOK => self::normalizePlatformList(
|
||||
$decoded[self::PLATFORM_TIKTOK] ?? [],
|
||||
self::PLATFORM_TIKTOK
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 合并 POST 数据与已有配置(Token / 测试码留空保留原值)
|
||||
*
|
||||
* @param array<string, mixed> $incoming
|
||||
* @param array<string, mixed> $existing
|
||||
* @return array{facebook: array<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>}
|
||||
*/
|
||||
public static function mergeForSave(array $incoming, array $existing): array
|
||||
{
|
||||
$existingMap = self::indexById($existing);
|
||||
$result = [
|
||||
self::PLATFORM_FACEBOOK => [],
|
||||
self::PLATFORM_TIKTOK => [],
|
||||
];
|
||||
|
||||
foreach ([self::PLATFORM_FACEBOOK, self::PLATFORM_TIKTOK] as $platform) {
|
||||
$rows = is_array($incoming[$platform] ?? null) ? $incoming[$platform] : [];
|
||||
if (count($rows) > self::MAX_ITEMS_PER_PLATFORM) {
|
||||
throw new \InvalidArgumentException('像素配置数量超出上限');
|
||||
}
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$normalized = self::normalizeRow($row, $platform);
|
||||
$id = (string) ($normalized['id'] ?? '');
|
||||
if ($id !== '' && isset($existingMap[$id])) {
|
||||
$old = $existingMap[$id];
|
||||
if (trim((string) ($normalized['access_token'] ?? '')) === ''
|
||||
|| strpos((string) ($normalized['access_token'] ?? ''), '***') !== false) {
|
||||
$normalized['access_token'] = (string) ($old['access_token'] ?? '');
|
||||
}
|
||||
if (trim((string) ($normalized['test_code'] ?? '')) === '') {
|
||||
$normalized['test_code'] = (string) ($old['test_code'] ?? '');
|
||||
}
|
||||
}
|
||||
if (trim((string) ($normalized['pixel_id'] ?? '')) === '') {
|
||||
continue;
|
||||
}
|
||||
$result[$platform][] = $normalized;
|
||||
}
|
||||
usort($result[$platform], static function (array $a, array $b): int {
|
||||
return ((int) ($a['sort'] ?? 0)) <=> ((int) ($b['sort'] ?? 0));
|
||||
});
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{facebook: array<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>} $config
|
||||
*/
|
||||
public static function encodeStorage(array $config): string
|
||||
{
|
||||
$payload = [
|
||||
self::PLATFORM_FACEBOOK => $config[self::PLATFORM_FACEBOOK] ?? [],
|
||||
self::PLATFORM_TIKTOK => $config[self::PLATFORM_TIKTOK] ?? [],
|
||||
];
|
||||
|
||||
return json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES) ?: '{}';
|
||||
}
|
||||
|
||||
/**
|
||||
* 管理端 GET:脱敏 access_token
|
||||
*
|
||||
* @param array{facebook: array<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>} $config
|
||||
* @return array{facebook: array<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>}
|
||||
*/
|
||||
public static function maskForAdmin(array $config): array
|
||||
{
|
||||
$mask = static function (array $rows): array {
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$item = $row;
|
||||
$token = (string) ($item['access_token'] ?? '');
|
||||
$item['access_token'] = $token === '' ? '' : self::maskToken($token);
|
||||
$item['has_access_token'] = $token !== '';
|
||||
unset($item['access_token_raw']);
|
||||
$out[] = $item;
|
||||
}
|
||||
|
||||
return $out;
|
||||
};
|
||||
|
||||
return [
|
||||
self::PLATFORM_FACEBOOK => $mask($config[self::PLATFORM_FACEBOOK] ?? []),
|
||||
self::PLATFORM_TIKTOK => $mask($config[self::PLATFORM_TIKTOK] ?? []),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取已启用且按 sort 排序的条目(中转页 / 服务端回传)
|
||||
*
|
||||
* @param array{facebook: array<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>} $config
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
public static function getEnabledSorted(array $config, string $platform): array
|
||||
{
|
||||
$rows = $config[$platform] ?? [];
|
||||
$enabled = [];
|
||||
foreach ($rows as $row) {
|
||||
if ((int) ($row['enabled'] ?? 0) === 1) {
|
||||
$enabled[] = $row;
|
||||
}
|
||||
}
|
||||
usort($enabled, static function (array $a, array $b): int {
|
||||
return ((int) ($a['sort'] ?? 0)) <=> ((int) ($b['sort'] ?? 0));
|
||||
});
|
||||
|
||||
return $enabled;
|
||||
}
|
||||
|
||||
public static function maskToken(string $token): string
|
||||
{
|
||||
$len = strlen($token);
|
||||
if ($len <= 8) {
|
||||
return '***';
|
||||
}
|
||||
|
||||
return substr($token, 0, 3) . '***' . substr($token, -3);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string, mixed>> $rows
|
||||
* @return array<int, array<string, mixed>>
|
||||
*/
|
||||
private static function normalizePlatformList(array $rows, string $platform): array
|
||||
{
|
||||
$result = [];
|
||||
foreach ($rows as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$normalized = self::normalizeRow($row, $platform);
|
||||
if (trim((string) ($normalized['pixel_id'] ?? '')) === '') {
|
||||
continue;
|
||||
}
|
||||
$result[] = $normalized;
|
||||
}
|
||||
usort($result, static function (array $a, array $b): int {
|
||||
return ((int) ($a['sort'] ?? 0)) <=> ((int) ($b['sort'] ?? 0));
|
||||
});
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function normalizeRow(array $row, string $platform): array
|
||||
{
|
||||
$event = (string) ($row['event'] ?? 'PageView');
|
||||
if (!in_array($event, self::EVENT_OPTIONS, true)) {
|
||||
$event = 'PageView';
|
||||
}
|
||||
$id = trim((string) ($row['id'] ?? ''));
|
||||
if ($id === '') {
|
||||
$id = $platform . '_' . uniqid('', true);
|
||||
}
|
||||
|
||||
return [
|
||||
'id' => $id,
|
||||
'enabled' => (int) ($row['enabled'] ?? 0) === 1 ? 1 : 0,
|
||||
'server_postback' => (int) ($row['server_postback'] ?? 0) === 1 ? 1 : 0,
|
||||
'pixel_id' => trim((string) ($row['pixel_id'] ?? '')),
|
||||
'access_token' => trim((string) ($row['access_token'] ?? '')),
|
||||
'test_code' => trim((string) ($row['test_code'] ?? '')),
|
||||
'event' => $event,
|
||||
'sort' => max(0, (int) ($row['sort'] ?? 0)),
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array{facebook: array<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>} $config
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private static function indexById(array $config): array
|
||||
{
|
||||
$map = [];
|
||||
foreach ([self::PLATFORM_FACEBOOK, self::PLATFORM_TIKTOK] as $platform) {
|
||||
foreach ($config[$platform] ?? [] as $row) {
|
||||
$id = (string) ($row['id'] ?? '');
|
||||
if ($id !== '') {
|
||||
$map[$id] = $row;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $map;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,169 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use think\Log;
|
||||
|
||||
/**
|
||||
* 分流中转页服务端像素回传(Facebook CAPI / TikTok Events API)
|
||||
*/
|
||||
class SplitPixelPostbackService
|
||||
{
|
||||
/**
|
||||
* @param array{facebook: array<int, array<string, mixed>>, tiktok: array<int, array<string, mixed>>} $config
|
||||
*/
|
||||
public static function dispatch(array $config, string $clientIp, string $userAgent, string $eventSourceUrl = ''): void
|
||||
{
|
||||
foreach (SplitPixelConfigService::getEnabledSorted($config, SplitPixelConfigService::PLATFORM_FACEBOOK) as $row) {
|
||||
if ((int) ($row['server_postback'] ?? 0) !== 1) {
|
||||
continue;
|
||||
}
|
||||
$token = trim((string) ($row['access_token'] ?? ''));
|
||||
if ($token === '') {
|
||||
continue;
|
||||
}
|
||||
self::sendFacebook($row, $token, $clientIp, $userAgent, $eventSourceUrl);
|
||||
}
|
||||
|
||||
foreach (SplitPixelConfigService::getEnabledSorted($config, SplitPixelConfigService::PLATFORM_TIKTOK) as $row) {
|
||||
if ((int) ($row['server_postback'] ?? 0) !== 1) {
|
||||
continue;
|
||||
}
|
||||
$token = trim((string) ($row['access_token'] ?? ''));
|
||||
if ($token === '') {
|
||||
continue;
|
||||
}
|
||||
self::sendTikTok($row, $token, $clientIp, $userAgent, $eventSourceUrl);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private static function sendFacebook(array $row, string $accessToken, string $clientIp, string $userAgent, string $eventSourceUrl): void
|
||||
{
|
||||
$pixelId = trim((string) ($row['pixel_id'] ?? ''));
|
||||
if ($pixelId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$eventName = (string) ($row['event'] ?? 'PageView');
|
||||
$testCode = trim((string) ($row['test_code'] ?? ''));
|
||||
|
||||
$payload = [
|
||||
'data' => [[
|
||||
'event_name' => $eventName,
|
||||
'event_time' => time(),
|
||||
'action_source' => 'website',
|
||||
'user_data' => array_filter([
|
||||
'client_ip_address' => $clientIp !== '' ? $clientIp : null,
|
||||
'client_user_agent' => $userAgent !== '' ? $userAgent : null,
|
||||
]),
|
||||
]],
|
||||
];
|
||||
if ($eventSourceUrl !== '') {
|
||||
$payload['data'][0]['event_source_url'] = $eventSourceUrl;
|
||||
}
|
||||
if ($testCode !== '') {
|
||||
$payload['test_event_code'] = $testCode;
|
||||
}
|
||||
|
||||
$url = 'https://graph.facebook.com/v19.0/' . rawurlencode($pixelId) . '/events?access_token=' . rawurlencode($accessToken);
|
||||
self::postJson($url, $payload, [], 'facebook', $pixelId);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $row
|
||||
*/
|
||||
private static function sendTikTok(array $row, string $accessToken, string $clientIp, string $userAgent, string $eventSourceUrl): void
|
||||
{
|
||||
$pixelId = trim((string) ($row['pixel_id'] ?? ''));
|
||||
if ($pixelId === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$event = self::mapTikTokServerEvent((string) ($row['event'] ?? 'PageView'));
|
||||
$testCode = trim((string) ($row['test_code'] ?? ''));
|
||||
|
||||
$payload = [
|
||||
'pixel_code' => $pixelId,
|
||||
'event' => $event,
|
||||
'event_id' => uniqid('split_', true),
|
||||
'timestamp' => gmdate('c'),
|
||||
'context' => array_filter([
|
||||
'ip' => $clientIp !== '' ? $clientIp : null,
|
||||
'user_agent' => $userAgent !== '' ? $userAgent : null,
|
||||
'page' => $eventSourceUrl !== '' ? ['url' => $eventSourceUrl] : null,
|
||||
]),
|
||||
];
|
||||
if ($testCode !== '') {
|
||||
$payload['test_event_code'] = $testCode;
|
||||
}
|
||||
|
||||
$url = 'https://business-api.tiktok.com/open_api/v1.3/event/track/';
|
||||
self::postJson($url, $payload, [
|
||||
'Access-Token: ' . $accessToken,
|
||||
'Content-Type: application/json',
|
||||
], 'tiktok', $pixelId);
|
||||
}
|
||||
|
||||
private static function mapTikTokServerEvent(string $event): string
|
||||
{
|
||||
$map = [
|
||||
'PageView' => 'Pageview',
|
||||
'Lead' => 'SubmitForm',
|
||||
'Contact' => 'Contact',
|
||||
'AddToCart' => 'AddToCart',
|
||||
'Purchase' => 'CompletePayment',
|
||||
'Subscribe' => 'Subscribe',
|
||||
];
|
||||
|
||||
return $map[$event] ?? 'Pageview';
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
* @param array<int, string> $headers
|
||||
*/
|
||||
private static function postJson(string $url, array $payload, array $headers, string $platform, string $pixelId): void
|
||||
{
|
||||
$json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
|
||||
if ($json === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$ch = curl_init($url);
|
||||
if ($ch === false) {
|
||||
return;
|
||||
}
|
||||
|
||||
$defaultHeaders = ['Content-Type: application/json'];
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $json,
|
||||
CURLOPT_HTTPHEADER => array_merge($defaultHeaders, $headers),
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_TIMEOUT => 3,
|
||||
CURLOPT_CONNECTTIMEOUT => 2,
|
||||
]);
|
||||
|
||||
$response = curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if ($errno !== 0 || ($httpCode >= 400 && $httpCode !== 0)) {
|
||||
Log::error(sprintf(
|
||||
'Split pixel postback failed platform=%s pixel=%s http=%d curl=%d',
|
||||
$platform,
|
||||
$pixelId,
|
||||
$httpCode,
|
||||
$errno
|
||||
));
|
||||
} elseif (is_string($response) && strpos($response, '"error"') !== false) {
|
||||
Log::error(sprintf('Split pixel postback api error platform=%s pixel=%s', $platform, $pixelId));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\admin\model\Domain as DomainModel;
|
||||
|
||||
/**
|
||||
* 平台分配域名解析(支持多域名,一行一个)
|
||||
*/
|
||||
class SplitPlatformDomainService
|
||||
{
|
||||
/**
|
||||
* 解析配置文本为合法根域名列表(去重、小写)
|
||||
*
|
||||
* @param string $raw 换行或逗号分隔的域名文本
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public static function parseList(string $raw): array
|
||||
{
|
||||
$raw = trim($raw);
|
||||
if ($raw === '') {
|
||||
return [];
|
||||
}
|
||||
|
||||
$parts = preg_split('/[\r\n,]+/', $raw) ?: [];
|
||||
$domains = [];
|
||||
foreach ($parts as $part) {
|
||||
$domain = DomainModel::normalizeDomain(trim((string) $part));
|
||||
if ($domain === '' || isset($domains[$domain])) {
|
||||
continue;
|
||||
}
|
||||
if (!DomainModel::isValidRootDomain($domain)) {
|
||||
continue;
|
||||
}
|
||||
$domains[$domain] = $domain;
|
||||
}
|
||||
|
||||
return array_values($domains);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将域名列表格式化为配置存储文本(一行一个)
|
||||
*
|
||||
* @param array<int, string> $domains
|
||||
*/
|
||||
public static function formatList(array $domains): string
|
||||
{
|
||||
$lines = [];
|
||||
foreach ($domains as $domain) {
|
||||
$domain = DomainModel::normalizeDomain((string) $domain);
|
||||
if ($domain !== '' && DomainModel::isValidRootDomain($domain)) {
|
||||
$lines[$domain] = $domain;
|
||||
}
|
||||
}
|
||||
return implode("\n", array_values($lines));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\admin\model\split\Link;
|
||||
use app\admin\model\split\Number;
|
||||
use think\Collection;
|
||||
|
||||
/**
|
||||
* 分流链接落地页:查链、轮转选号、拼接跳转 URL、访问计数
|
||||
*/
|
||||
class SplitRedirectService
|
||||
{
|
||||
private SplitRoundRobinStore $roundRobinStore;
|
||||
|
||||
public function __construct(?SplitRoundRobinStore $roundRobinStore = null)
|
||||
{
|
||||
$this->roundRobinStore = $roundRobinStore ?? new SplitRoundRobinStore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据链接码解析本次应跳转的加好友 URL;无效时返回 null
|
||||
*
|
||||
* @param string $linkCode 9 位链接码
|
||||
* @param string $clientIp 访客 IP(IP 防护开启时用于国家校验)
|
||||
*/
|
||||
public function resolveRedirectUrl(string $linkCode, string $clientIp = ''): ?string
|
||||
{
|
||||
$linkCode = SplitLinkCodeService::normalize($linkCode);
|
||||
if (!SplitLinkCodeService::isValidFormat($linkCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$link = Link::where('link_code', $linkCode)
|
||||
->where('status', 'normal')
|
||||
->find();
|
||||
if (!$link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ipProtect = (int) $link->getAttr('ip_protect');
|
||||
$countries = (string) $link->getAttr('countries');
|
||||
if (!SplitIpProtectService::isAllowed($ipProtect, $countries, $clientIp)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var Collection<int, Number>|array<int, Number> $numbers */
|
||||
$numbers = Number::where('split_link_id', (int) $link['id'])
|
||||
->where('status', 'normal')
|
||||
->order('id', 'asc')
|
||||
->field('id,number,number_type,number_type_custom')
|
||||
->select();
|
||||
|
||||
$count = is_countable($numbers) ? count($numbers) : 0;
|
||||
if ($count === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$linkId = (int) $link->getAttr('id');
|
||||
$index = $this->roundRobinStore->nextIndex($linkId, $count);
|
||||
$list = $numbers instanceof \think\Collection ? $numbers->all() : (array) $numbers;
|
||||
$picked = $list[$index] ?? $list[0] ?? null;
|
||||
if ($picked === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$numberType = is_array($picked) ? (string) ($picked['number_type'] ?? '') : (string) $picked->getAttr('number_type');
|
||||
$numberValue = is_array($picked) ? (string) ($picked['number'] ?? '') : (string) $picked->getAttr('number');
|
||||
$numberCustom = is_array($picked)
|
||||
? (string) ($picked['number_type_custom'] ?? '')
|
||||
: (string) $picked->getAttr('number_type_custom');
|
||||
|
||||
$whatsAppReplyText = '';
|
||||
if ($numberType === 'whatsapp') {
|
||||
$whatsAppReplyText = SplitAutoReplyService::pickRandomLine((string) $link->getAttr('auto_reply'));
|
||||
}
|
||||
|
||||
$redirectUrl = SplitFriendUrlBuilder::build(
|
||||
$numberType,
|
||||
$numberValue,
|
||||
$numberCustom,
|
||||
$whatsAppReplyText
|
||||
);
|
||||
if ($redirectUrl === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$numberId = is_array($picked) ? (int) ($picked['id'] ?? 0) : (int) $picked->getAttr('id');
|
||||
if ($numberId > 0) {
|
||||
Number::where('id', $numberId)->setInc('visit_count');
|
||||
}
|
||||
|
||||
return $redirectUrl;
|
||||
}
|
||||
}
|
||||
+133
@@ -0,0 +1,133 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use think\Config;
|
||||
|
||||
/**
|
||||
* 分流链接号码严格轮转计数(Redis INCR,不可用时降级为 runtime 文件锁)
|
||||
*/
|
||||
class SplitRoundRobinStore
|
||||
{
|
||||
private const KEY_PREFIX = 'split:rr:';
|
||||
|
||||
/** @var \Redis|null */
|
||||
private $redis = null;
|
||||
|
||||
private bool $redisReady = false;
|
||||
|
||||
private bool $redisAttempted = false;
|
||||
|
||||
/**
|
||||
* 获取本次访问应使用的号码下标(0 .. count-1)
|
||||
*/
|
||||
public function nextIndex(int $splitLinkId, int $numberCount): int
|
||||
{
|
||||
if ($splitLinkId <= 0 || $numberCount <= 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
if ($this->ensureRedis()) {
|
||||
$key = self::KEY_PREFIX . $splitLinkId;
|
||||
$seq = (int) $this->redis->incr($key);
|
||||
if ($seq <= 0) {
|
||||
$seq = 1;
|
||||
}
|
||||
|
||||
return ($seq - 1) % $numberCount;
|
||||
}
|
||||
|
||||
return $this->nextIndexFallback($splitLinkId, $numberCount);
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试连接 Redis(配置来自 queue 扩展)
|
||||
*/
|
||||
private function ensureRedis(): bool
|
||||
{
|
||||
if ($this->redisAttempted) {
|
||||
return $this->redisReady;
|
||||
}
|
||||
$this->redisAttempted = true;
|
||||
|
||||
if (!extension_loaded('redis')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$config = Config::get('queue');
|
||||
if (!is_array($config) || ($config['connector'] ?? '') !== 'Redis') {
|
||||
$appPath = defined('APP_PATH') ? APP_PATH : (defined('ROOT_PATH') ? ROOT_PATH . 'application/' : '');
|
||||
$file = $appPath . 'extra/queue.php';
|
||||
if (is_file($file)) {
|
||||
$loaded = include $file;
|
||||
$config = is_array($loaded) ? $loaded : [];
|
||||
} else {
|
||||
$config = [];
|
||||
}
|
||||
}
|
||||
|
||||
$host = (string) ($config['host'] ?? '127.0.0.1');
|
||||
$port = (int) ($config['port'] ?? 6379);
|
||||
$password = (string) ($config['password'] ?? '');
|
||||
$select = (int) ($config['select'] ?? 0);
|
||||
$timeout = (float) ($config['timeout'] ?? 1.0);
|
||||
|
||||
try {
|
||||
$redis = new \Redis();
|
||||
$connected = $timeout > 0
|
||||
? @$redis->connect($host, $port, $timeout)
|
||||
: @$redis->connect($host, $port);
|
||||
if (!$connected) {
|
||||
return false;
|
||||
}
|
||||
if ($password !== '') {
|
||||
if (!$redis->auth($password)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if ($select > 0) {
|
||||
$redis->select($select);
|
||||
}
|
||||
$this->redis = $redis;
|
||||
$this->redisReady = true;
|
||||
} catch (\Throwable $e) {
|
||||
$this->redisReady = false;
|
||||
}
|
||||
|
||||
return $this->redisReady;
|
||||
}
|
||||
|
||||
/**
|
||||
* 无 Redis 时使用 runtime 文件锁递增(开发/单机可用;生产请启用 Redis)
|
||||
*/
|
||||
private function nextIndexFallback(int $splitLinkId, int $numberCount): int
|
||||
{
|
||||
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/');
|
||||
$dir = $runtime . 'split_rr/';
|
||||
if (!is_dir($dir) && !@mkdir($dir, 0755, true)) {
|
||||
return 0;
|
||||
}
|
||||
$file = $dir . $splitLinkId . '.cnt';
|
||||
$fp = @fopen($file, 'c+');
|
||||
if ($fp === false) {
|
||||
return 0;
|
||||
}
|
||||
if (!flock($fp, LOCK_EX)) {
|
||||
fclose($fp);
|
||||
return 0;
|
||||
}
|
||||
$raw = stream_get_contents($fp);
|
||||
$seq = (int) $raw;
|
||||
$seq++;
|
||||
ftruncate($fp, 0);
|
||||
rewind($fp);
|
||||
fwrite($fp, (string) $seq);
|
||||
fflush($fp);
|
||||
flock($fp, LOCK_UN);
|
||||
fclose($fp);
|
||||
|
||||
return ($seq - 1) % $numberCount;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\common\library\scrm\ScrmSpiderInterface;
|
||||
use app\common\library\scrm\spider\A2cSpider;
|
||||
use app\common\library\scrm\spider\HaiwangSpider;
|
||||
use app\common\library\scrm\spider\HuojianSpider;
|
||||
use app\common\library\scrm\spider\SsCustomerSpider;
|
||||
use app\common\library\scrm\spider\XingheSpider;
|
||||
|
||||
/**
|
||||
* 工单类型 -> 云控蜘蛛工厂
|
||||
*
|
||||
* 新增云控类型:在 spider/ 下新增类并在此注册 ticket_type => Class
|
||||
*/
|
||||
class SplitScrmSpiderFactory
|
||||
{
|
||||
/** @var array<string, class-string<ScrmSpiderInterface>> */
|
||||
private const MAP = [
|
||||
'a2c' => A2cSpider::class,
|
||||
'haiwang' => HaiwangSpider::class,
|
||||
'huojian' => HuojianSpider::class,
|
||||
'xinghe' => XingheSpider::class,
|
||||
'ss_customer' => SsCustomerSpider::class,
|
||||
// ceo_scrm 等未实现类型:新增 spider 类后在此注册
|
||||
];
|
||||
|
||||
/**
|
||||
* @return class-string<ScrmSpiderInterface>|null
|
||||
*/
|
||||
public static function resolveClass(string $ticketType): ?string
|
||||
{
|
||||
$ticketType = trim($ticketType);
|
||||
return self::MAP[$ticketType] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否已实现蜘蛛
|
||||
*/
|
||||
public static function isSupported(string $ticketType): bool
|
||||
{
|
||||
return self::resolveClass($ticketType) !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return ScrmSpiderInterface|null
|
||||
*/
|
||||
public static function create(
|
||||
string $ticketType,
|
||||
string $pageUrl,
|
||||
string $account = '',
|
||||
string $password = '',
|
||||
string $nodeHost = ''
|
||||
): ?ScrmSpiderInterface {
|
||||
$class = self::resolveClass($ticketType);
|
||||
if ($class === null) {
|
||||
return null;
|
||||
}
|
||||
$host = $nodeHost !== '' ? $nodeHost : SplitSyncConfigService::getNodeHost();
|
||||
return new $class($pageUrl, $account, $password, $host);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use think\Config;
|
||||
use think\Db;
|
||||
|
||||
/**
|
||||
* 工单云控同步相关系统配置读取
|
||||
*/
|
||||
class SplitSyncConfigService
|
||||
{
|
||||
private const DEFAULT_NODE_HOST = 'http://127.0.0.1:3001';
|
||||
|
||||
/**
|
||||
* Node Headless 服务根地址
|
||||
*/
|
||||
public static function getNodeHost(): string
|
||||
{
|
||||
$value = self::getConfigValue('split_scrm_node_host');
|
||||
$value = trim($value);
|
||||
return $value !== '' ? rtrim($value, '/') : self::DEFAULT_NODE_HOST;
|
||||
}
|
||||
|
||||
/**
|
||||
* 连续同步失败多少次后自动暂停工单(0 表示不因失败暂停)
|
||||
*/
|
||||
public static function getFailPauseThreshold(): int
|
||||
{
|
||||
$value = self::getConfigValue('split_sync_fail_pause_threshold');
|
||||
if ($value === '') {
|
||||
return 5;
|
||||
}
|
||||
return max(0, (int) $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 指定工单类型的自动同步周期(分钟),0 表示不自动同步
|
||||
*/
|
||||
public static function getIntervalMinutes(string $ticketType): int
|
||||
{
|
||||
$ticketType = trim($ticketType);
|
||||
if ($ticketType === '') {
|
||||
return 0;
|
||||
}
|
||||
$key = 'split_sync_interval_' . $ticketType;
|
||||
$value = self::getConfigValue($key);
|
||||
return max(0, (int) $value);
|
||||
}
|
||||
|
||||
private static function getConfigValue(string $name): string
|
||||
{
|
||||
$site = Config::get('site.' . $name);
|
||||
if ($site !== null && $site !== '') {
|
||||
return (string) $site;
|
||||
}
|
||||
$db = Db::name('config')->where('name', $name)->value('value');
|
||||
return $db !== null ? (string) $db : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\admin\model\split\Number;
|
||||
use app\admin\model\split\Ticket;
|
||||
use app\common\library\scrm\UnifiedScrmData;
|
||||
use think\Db;
|
||||
|
||||
/**
|
||||
* 工单同步结果写入号码表
|
||||
*/
|
||||
class SplitTicketNumberSyncService
|
||||
{
|
||||
/**
|
||||
* 将蜘蛛返回的号码列表同步到号码管理
|
||||
*/
|
||||
public function syncFromUnifiedData(Ticket $ticket, UnifiedScrmData $data): void
|
||||
{
|
||||
$adminId = (int) $ticket['admin_id'];
|
||||
$linkId = (int) $ticket['split_link_id'];
|
||||
$ticketName = (string) $ticket['ticket_name'];
|
||||
if ($linkId <= 0 || $ticketName === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$randomShuffle = SplitNumberWeighService::isRandomShuffleEnabled($linkId);
|
||||
|
||||
// 使用独立 number 字段存储,避免纯数字号码作为数组 key 被 PHP 自动转为 int
|
||||
$syncedNumbers = [];
|
||||
foreach ($data->numbers as $row) {
|
||||
$number = self::normalizeNumber($row['number'] ?? '');
|
||||
if ($number === '') {
|
||||
continue;
|
||||
}
|
||||
$syncedNumbers[] = [
|
||||
'number' => $number,
|
||||
'row' => $row,
|
||||
];
|
||||
}
|
||||
|
||||
$existingList = Number::where('admin_id', $adminId)
|
||||
->where('split_link_id', $linkId)
|
||||
->where('ticket_name', $ticketName)
|
||||
->select();
|
||||
|
||||
$existingMap = [];
|
||||
foreach ($existingList as $item) {
|
||||
$existingMap[(string) $item['number']] = $item;
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
if (isset($existingMap[$number])) {
|
||||
$this->updateExistingNumber($existingMap[$number], $platformStatus, $newFollowers);
|
||||
continue;
|
||||
}
|
||||
|
||||
$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($syncedNumberSet[$number])) {
|
||||
continue;
|
||||
}
|
||||
if ((int) $item['manual_manage'] === 1) {
|
||||
continue;
|
||||
}
|
||||
Number::where('id', (int) $item['id'])->update([
|
||||
'status' => 'hidden',
|
||||
'updatetime' => time(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Number $row
|
||||
*/
|
||||
private function updateExistingNumber($row, string $platformStatus, int $newFollowers): void
|
||||
{
|
||||
$update = [
|
||||
'platform_status' => $platformStatus,
|
||||
'updatetime' => time(),
|
||||
];
|
||||
|
||||
if ((int) $row['manual_manage'] === 1) {
|
||||
Number::where('id', (int) $row['id'])->update($update);
|
||||
return;
|
||||
}
|
||||
|
||||
// 进线人数由同步写入,最终开关由 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 {
|
||||
$now = time();
|
||||
$data = [
|
||||
'admin_id' => (int) $ticket['admin_id'],
|
||||
'split_link_id' => (int) $ticket['split_link_id'],
|
||||
'ticket_name' => (string) $ticket['ticket_name'],
|
||||
'number' => $number,
|
||||
'number_type' => (string) $ticket['number_type'],
|
||||
'number_type_custom' => (string) ($ticket['number_type_custom'] ?? ''),
|
||||
'visit_count' => 0,
|
||||
'inbound_count' => max(0, $newFollowers),
|
||||
'manual_manage' => 0,
|
||||
'platform_status' => $platformStatus,
|
||||
'status' => 'hidden',
|
||||
'createtime' => $now,
|
||||
'updatetime' => $now,
|
||||
];
|
||||
|
||||
try {
|
||||
Db::name('split_number')->insert($data);
|
||||
} catch (\Throwable $e) {
|
||||
$exists = Number::where('split_link_id', (int) $ticket['split_link_id'])
|
||||
->where('number', $number)
|
||||
->find();
|
||||
if ($exists) {
|
||||
$this->updateExistingNumber($exists, $platformStatus, $newFollowers);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一号码为字符串(云控 API 可能返回 int)
|
||||
*/
|
||||
private static function normalizeNumber($value): string
|
||||
{
|
||||
if ($value === null || $value === '') {
|
||||
return '';
|
||||
}
|
||||
return trim((string) $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* 汇总工单进线人数(仅开启状态的号码)
|
||||
*/
|
||||
public function sumInboundForTicket(Ticket $ticket): int
|
||||
{
|
||||
$sum = Number::where('admin_id', (int) $ticket['admin_id'])
|
||||
->where('split_link_id', (int) $ticket['split_link_id'])
|
||||
->where('ticket_name', (string) $ticket['ticket_name'])
|
||||
->where('status', 'normal')
|
||||
->sum('inbound_count');
|
||||
return (int) $sum;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\admin\model\split\Number;
|
||||
use app\admin\model\split\Ticket;
|
||||
|
||||
/**
|
||||
* 工单与号码业务规则(单号上限、下号比率、时间窗口、完成量自动开关)
|
||||
*/
|
||||
class SplitTicketRuleService
|
||||
{
|
||||
/**
|
||||
* 同步后应用全部规则并写回工单/号码
|
||||
*/
|
||||
public function applyAfterSync(Ticket $ticket, int $completeCount): void
|
||||
{
|
||||
$this->applyTicketStatusRules($ticket, $completeCount);
|
||||
$fresh = Ticket::get((int) $ticket['id']);
|
||||
if ($fresh) {
|
||||
if ((string) $fresh['status'] === 'hidden') {
|
||||
$this->cascadeTicketClosedToNumbers($fresh);
|
||||
}
|
||||
$this->applyNumberRules($fresh);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步流程:工单因时间/完成量等原因关闭时,联动关闭非手动号码
|
||||
*/
|
||||
public function cascadeTicketClosedToNumbers(Ticket $ticket): void
|
||||
{
|
||||
if ((string) ($ticket['status'] ?? 'hidden') !== 'hidden') {
|
||||
return;
|
||||
}
|
||||
Number::where('admin_id', (int) $ticket['admin_id'])
|
||||
->where('split_link_id', (int) $ticket['split_link_id'])
|
||||
->where('ticket_name', (string) $ticket['ticket_name'])
|
||||
->where('manual_manage', 0)
|
||||
->update([
|
||||
'status' => 'hidden',
|
||||
'updatetime' => time(),
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动切换工单状态时,联动非手动管理的号码
|
||||
*/
|
||||
public function syncNumbersWithTicketStatus(Ticket $ticket): void
|
||||
{
|
||||
$ticketStatus = (string) ($ticket['status'] ?? 'hidden');
|
||||
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
|
||||
{
|
||||
$orderLimit = (int) ($ticket['order_limit'] ?? 0);
|
||||
$assignRatio = (int) ($ticket['assign_ratio'] ?? 0);
|
||||
|
||||
$numbers = Number::where('admin_id', (int) $ticket['admin_id'])
|
||||
->where('split_link_id', (int) $ticket['split_link_id'])
|
||||
->where('ticket_name', (string) $ticket['ticket_name'])
|
||||
->select();
|
||||
|
||||
foreach ($numbers as $number) {
|
||||
$visitCount = (int) $number['visit_count'];
|
||||
$inboundCount = (int) $number['inbound_count'];
|
||||
$lastVisit = (int) ($number['last_sync_visit_count'] ?? 0);
|
||||
$lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0);
|
||||
$streak = (int) ($number['no_inbound_click_streak'] ?? 0);
|
||||
|
||||
if ($visitCount > $lastVisit && $inboundCount <= $lastInbound) {
|
||||
$streak += ($visitCount - $lastVisit);
|
||||
} elseif ($inboundCount > $lastInbound) {
|
||||
$streak = 0;
|
||||
}
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
$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'] ?? 'hidden')) {
|
||||
Ticket::where('id', (int) $ticket['id'])->update([
|
||||
'status' => $status,
|
||||
'updatetime' => time(),
|
||||
]);
|
||||
$ticket['status'] = $status;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 是否处于允许同步/开启的时间窗口
|
||||
*/
|
||||
public function isWithinTimeWindow(Ticket $ticket, ?int $now = null): bool
|
||||
{
|
||||
$now = $now ?? time();
|
||||
$start = $ticket['start_time'] ?? null;
|
||||
$end = $ticket['end_time'] ?? null;
|
||||
if ($start !== null && $start !== '' && (int) $start > 0 && $now < (int) $start) {
|
||||
return false;
|
||||
}
|
||||
if ($end !== null && $end !== '' && (int) $end > 0 && $now > (int) $end) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private function resolveTicketStatus(Ticket $ticket, int $completeCount): string
|
||||
{
|
||||
if (!$this->isWithinTimeWindow($ticket)) {
|
||||
return 'hidden';
|
||||
}
|
||||
|
||||
$ticketTotal = (int) ($ticket['ticket_total'] ?? 0);
|
||||
if ($ticketTotal > 0) {
|
||||
return $completeCount >= $ticketTotal ? 'hidden' : 'normal';
|
||||
}
|
||||
|
||||
return 'normal';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* 工单同步互斥锁(基于 runtime 文件锁,不依赖 Cache/Redis 扩展)
|
||||
*/
|
||||
class SplitTicketSyncLockService
|
||||
{
|
||||
private const LOCK_TTL = 1800;
|
||||
|
||||
/**
|
||||
* 尝试获取锁
|
||||
*/
|
||||
public function acquire(int $ticketId): bool
|
||||
{
|
||||
$path = $this->lockPath($ticketId);
|
||||
if ($this->isStaleLock($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
if (is_file($path)) {
|
||||
return false;
|
||||
}
|
||||
$payload = json_encode([
|
||||
'ticket_id' => $ticketId,
|
||||
'pid' => getmypid(),
|
||||
'time' => time(),
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
$written = @file_put_contents($path, $payload, LOCK_EX);
|
||||
return $written !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 释放锁
|
||||
*/
|
||||
public function release(int $ticketId): void
|
||||
{
|
||||
$path = $this->lockPath($ticketId);
|
||||
if (is_file($path)) {
|
||||
@unlink($path);
|
||||
}
|
||||
}
|
||||
|
||||
private function lockPath(int $ticketId): string
|
||||
{
|
||||
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/');
|
||||
$dir = $runtime . 'split_ticket_sync/';
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0755, true);
|
||||
}
|
||||
return $dir . $ticketId . '.lock';
|
||||
}
|
||||
|
||||
private function isStaleLock(string $path): bool
|
||||
{
|
||||
if (!is_file($path)) {
|
||||
return false;
|
||||
}
|
||||
$mtime = (int) @filemtime($path);
|
||||
return $mtime > 0 && (time() - $mtime) > self::LOCK_TTL;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use think\Config;
|
||||
|
||||
/**
|
||||
* 工单云控同步调试日志(仅 app_debug=true 时写入 runtime/log/split_sync.log)
|
||||
*/
|
||||
class SplitTicketSyncLogger
|
||||
{
|
||||
private const LOG_FILE = 'split_sync.log';
|
||||
|
||||
private static ?string $ticketTag = null;
|
||||
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
return (bool) Config::get('app_debug');
|
||||
}
|
||||
|
||||
public static function setTicketContext(?int $ticketId, ?string $ticketType = null): void
|
||||
{
|
||||
if ($ticketId === null || $ticketId <= 0) {
|
||||
self::$ticketTag = null;
|
||||
return;
|
||||
}
|
||||
$type = $ticketType !== null && $ticketType !== '' ? $ticketType : '-';
|
||||
self::$ticketTag = sprintf('#%d(%s)', $ticketId, $type);
|
||||
}
|
||||
|
||||
public static function clearTicketContext(): void
|
||||
{
|
||||
self::$ticketTag = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
*/
|
||||
public static function log(string $stage, string $message, array $context = []): void
|
||||
{
|
||||
if (!self::isEnabled()) {
|
||||
return;
|
||||
}
|
||||
|
||||
$context = self::sanitize($context);
|
||||
$ctxJson = $context !== [] ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : '';
|
||||
$line = sprintf(
|
||||
"[%s] %s [%s] %s%s\n",
|
||||
date('Y-m-d H:i:s'),
|
||||
self::$ticketTag ?? '[global]',
|
||||
$stage,
|
||||
$message,
|
||||
$ctxJson
|
||||
);
|
||||
|
||||
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (ROOT_PATH . 'runtime/');
|
||||
$dir = $runtime . 'log/';
|
||||
if (!is_dir($dir)) {
|
||||
@mkdir($dir, 0755, true);
|
||||
}
|
||||
@file_put_contents($dir . self::LOG_FILE, $line, FILE_APPEND | LOCK_EX);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private static function sanitize(array $context): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($context as $key => $value) {
|
||||
$lower = strtolower((string) $key);
|
||||
if (in_array($lower, ['password', 'passwd', 'pwd', 'token', 'secret'], true)) {
|
||||
continue;
|
||||
}
|
||||
if ($key === 'authActions' && is_array($value)) {
|
||||
$out[$key] = self::sanitizeAuthActions($value);
|
||||
continue;
|
||||
}
|
||||
if (is_array($value)) {
|
||||
$out[$key] = self::sanitize($value);
|
||||
continue;
|
||||
}
|
||||
if (is_string($value) && mb_strlen($value) > 800) {
|
||||
$out[$key] = mb_substr($value, 0, 800, 'UTF-8') . '...(truncated)';
|
||||
continue;
|
||||
}
|
||||
$out[$key] = $value;
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, mixed> $actions
|
||||
* @return array<int, mixed>
|
||||
*/
|
||||
private static function sanitizeAuthActions(array $actions): array
|
||||
{
|
||||
$sanitized = [];
|
||||
foreach ($actions as $action) {
|
||||
if (!is_array($action)) {
|
||||
$sanitized[] = $action;
|
||||
continue;
|
||||
}
|
||||
if (array_key_exists('value', $action)) {
|
||||
$action['value'] = '***';
|
||||
}
|
||||
$sanitized[] = $action;
|
||||
}
|
||||
return $sanitized;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,320 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\admin\model\split\Ticket;
|
||||
use app\common\library\scrm\UnifiedScrmData;
|
||||
use think\Db;
|
||||
use think\Exception;
|
||||
|
||||
/**
|
||||
* 分流工单云控数据同步服务
|
||||
*/
|
||||
class SplitTicketSyncService
|
||||
{
|
||||
private SplitTicketNumberSyncService $numberSync;
|
||||
|
||||
private SplitTicketRuleService $ruleService;
|
||||
|
||||
private SplitTicketSyncLockService $lockService;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->numberSync = new SplitTicketNumberSyncService();
|
||||
$this->ruleService = new SplitTicketRuleService();
|
||||
$this->lockService = new SplitTicketSyncLockService();
|
||||
}
|
||||
|
||||
/**
|
||||
* 同步单条工单
|
||||
*
|
||||
* @return array{success:bool,message:string,skipped?:bool}
|
||||
*/
|
||||
public function syncOne(int $ticketId, bool $force = false): array
|
||||
{
|
||||
$ticket = Ticket::get($ticketId);
|
||||
if (!$ticket) {
|
||||
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 {
|
||||
$result = $this->doSync($ticket);
|
||||
SplitTicketSyncLogger::log('sync', 'syncOne end', $result);
|
||||
return $result;
|
||||
} finally {
|
||||
$this->lockService->release($ticketId);
|
||||
SplitTicketSyncLogger::clearTicketContext();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描到期工单并同步
|
||||
*/
|
||||
public function syncDueTickets(): int
|
||||
{
|
||||
$count = 0;
|
||||
$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);
|
||||
if (!empty($result['skipped'])) {
|
||||
continue;
|
||||
}
|
||||
$count++;
|
||||
}
|
||||
|
||||
SplitTicketSyncLogger::log('cron', 'scan end', ['processedCount' => $count]);
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{success:bool,message:string}
|
||||
*/
|
||||
private function doSync(Ticket $ticket): array
|
||||
{
|
||||
$ticketType = (string) $ticket['ticket_type'];
|
||||
$pageUrl = trim((string) $ticket['ticket_url']);
|
||||
if ($pageUrl === '') {
|
||||
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,
|
||||
(string) ($ticket['account'] ?? ''),
|
||||
(string) ($ticket['password'] ?? '')
|
||||
);
|
||||
if ($spider === null) {
|
||||
$this->markFailure($ticket, '无法创建蜘蛛实例');
|
||||
return ['success' => false, 'message' => '无法创建蜘蛛实例'];
|
||||
}
|
||||
|
||||
Db::startTrans();
|
||||
try {
|
||||
SplitTicketSyncLogger::log('sync', 'spider run begin');
|
||||
$finalData = $spider->run();
|
||||
if (!$finalData instanceof UnifiedScrmData) {
|
||||
throw new Exception('蜘蛛返回数据无效');
|
||||
}
|
||||
|
||||
$this->numberSync->syncFromUnifiedData($ticket, $finalData);
|
||||
|
||||
$completeCount = max(0, $finalData->todayNewCount);
|
||||
$this->ruleService->applyTicketStatusRules($ticket, $completeCount);
|
||||
|
||||
$freshTicket = Ticket::get((int) $ticket['id']) ?: $ticket;
|
||||
if ((string) $freshTicket['status'] === 'hidden') {
|
||||
$this->ruleService->cascadeTicketClosedToNumbers($freshTicket);
|
||||
}
|
||||
// 号码开关最后统一由 applyNumberRules 判定(单号上限/下号比率/云控在线)
|
||||
$this->ruleService->applyNumberRules($freshTicket);
|
||||
$ticket = $freshTicket;
|
||||
|
||||
$inboundCount = $this->numberSync->sumInboundForTicket($ticket);
|
||||
$speed = $this->calcSpeedPerHour($ticket, $completeCount);
|
||||
|
||||
$payload = [
|
||||
'complete_count' => $completeCount,
|
||||
'inbound_count' => $inboundCount,
|
||||
'speed_per_hour' => $speed['speed'],
|
||||
'number_count' => max(0, $finalData->total),
|
||||
'number_offline_count' => max(0, $finalData->totalOffline),
|
||||
'number_banned_count' => 0,
|
||||
'online_count' => max(0, $finalData->totalOnline),
|
||||
'sync_fail_count' => 0,
|
||||
'speed_snapshot_count' => $speed['snapshot_count'],
|
||||
'speed_snapshot_time' => $speed['snapshot_time'],
|
||||
];
|
||||
|
||||
$this->applySyncResult($ticket, $payload, true, '');
|
||||
Db::commit();
|
||||
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];
|
||||
}
|
||||
}
|
||||
|
||||
private function shouldSkip(Ticket $ticket): ?string
|
||||
{
|
||||
if ((string) $ticket['status'] === 'hidden') {
|
||||
return '工单已关闭';
|
||||
}
|
||||
$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 '工单类型尚未实现';
|
||||
}
|
||||
$interval = SplitSyncConfigService::getIntervalMinutes((string) $ticket['ticket_type']);
|
||||
if ($interval <= 0) {
|
||||
return '该类型未配置自动同步周期';
|
||||
}
|
||||
$lastSync = (int) ($ticket['sync_time'] ?? 0);
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $payload
|
||||
*/
|
||||
public function applySyncResult(Ticket $ticket, array $payload, bool $success, string $message = ''): void
|
||||
{
|
||||
$data = [
|
||||
'complete_count' => max(0, (int) ($payload['complete_count'] ?? 0)),
|
||||
'inbound_count' => max(0, (int) ($payload['inbound_count'] ?? 0)),
|
||||
'speed_per_hour' => max(0, (float) ($payload['speed_per_hour'] ?? 0)),
|
||||
'number_count' => max(0, (int) ($payload['number_count'] ?? 0)),
|
||||
'number_offline_count' => max(0, (int) ($payload['number_offline_count'] ?? 0)),
|
||||
'number_banned_count' => max(0, (int) ($payload['number_banned_count'] ?? 0)),
|
||||
'online_count' => max(0, (int) ($payload['online_count'] ?? 0)),
|
||||
'sync_status' => $success ? 'success' : 'error',
|
||||
'sync_time' => time(),
|
||||
'sync_message' => $success ? '' : mb_substr($message, 0, 255, 'UTF-8'),
|
||||
'sync_fail_count' => $success ? 0 : ((int) ($ticket['sync_fail_count'] ?? 0) + 1),
|
||||
'speed_snapshot_count' => (int) ($payload['speed_snapshot_count'] ?? $ticket['speed_snapshot_count'] ?? 0),
|
||||
'speed_snapshot_time' => (int) ($payload['speed_snapshot_time'] ?? $ticket['speed_snapshot_time'] ?? 0),
|
||||
];
|
||||
if (!$ticket->allowField(array_keys($data))->save($data)) {
|
||||
throw new Exception('工单同步结果保存失败');
|
||||
}
|
||||
}
|
||||
|
||||
private function markFailure(Ticket $ticket, string $message): void
|
||||
{
|
||||
$failCount = (int) ($ticket['sync_fail_count'] ?? 0) + 1;
|
||||
$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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{speed:float,snapshot_count:int,snapshot_time:int}
|
||||
*/
|
||||
private function calcSpeedPerHour(Ticket $ticket, int $currentComplete): array
|
||||
{
|
||||
$now = time();
|
||||
$snapshotTime = (int) ($ticket['speed_snapshot_time'] ?? 0);
|
||||
$snapshotCount = (int) ($ticket['speed_snapshot_count'] ?? 0);
|
||||
|
||||
if ($snapshotTime <= 0) {
|
||||
return [
|
||||
'speed' => 0.0,
|
||||
'snapshot_count' => $currentComplete,
|
||||
'snapshot_time' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
$elapsed = $now - $snapshotTime;
|
||||
if ($elapsed >= 3600) {
|
||||
return [
|
||||
'speed' => 0.0,
|
||||
'snapshot_count' => $currentComplete,
|
||||
'snapshot_time' => $now,
|
||||
];
|
||||
}
|
||||
|
||||
$hours = $elapsed > 0 ? ($elapsed / 3600) : 0;
|
||||
$delta = $currentComplete - $snapshotCount;
|
||||
$speed = ($delta < 0 || $hours <= 0) ? 0.0 : round($delta / $hours, 2);
|
||||
|
||||
return [
|
||||
'speed' => $speed,
|
||||
'snapshot_count' => $snapshotCount,
|
||||
'snapshot_time' => $snapshotTime,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\admin\model\split\Link;
|
||||
|
||||
/**
|
||||
* 分流中转页上下文:跳转 URL + 像素渲染
|
||||
*/
|
||||
class SplitVisitPageService
|
||||
{
|
||||
/**
|
||||
* @return array{
|
||||
* redirect_url: string,
|
||||
* redirect_url_json: string,
|
||||
* pixel_head_html: string,
|
||||
* pixel_body_html: string,
|
||||
* orchestrator_html: string
|
||||
* }|null
|
||||
*/
|
||||
public static function build(string $linkCode, string $clientIp, string $userAgent, string $pageUrl): ?array
|
||||
{
|
||||
$redirectUrl = (new SplitRedirectService())->resolveRedirectUrl($linkCode, $clientIp);
|
||||
if ($redirectUrl === null || $redirectUrl === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$link = Link::where('link_code', SplitLinkCodeService::normalize($linkCode))
|
||||
->where('status', 'normal')
|
||||
->field('id,pixel_config')
|
||||
->find();
|
||||
|
||||
$config = SplitPixelConfigService::parseStorage(
|
||||
$link ? (string) $link->getAttr('pixel_config') : ''
|
||||
);
|
||||
|
||||
SplitPixelPostbackService::dispatch($config, $clientIp, $userAgent, $pageUrl);
|
||||
|
||||
$pixel = SplitPixelBrowserRenderer::render($config);
|
||||
$redirectJson = json_encode(
|
||||
$redirectUrl,
|
||||
JSON_HEX_TAG | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_HEX_AMP | JSON_UNESCAPED_SLASHES
|
||||
);
|
||||
|
||||
return [
|
||||
'redirect_url' => $redirectUrl,
|
||||
'redirect_url_json' => $redirectJson ?: '""',
|
||||
'pixel_head_html' => $pixel['head_html'],
|
||||
'pixel_body_html' => $pixel['body_html'],
|
||||
'orchestrator_html' => SplitPixelBrowserRenderer::renderRedirectOrchestrator(
|
||||
$redirectJson ?: '""',
|
||||
$pixel['track_lines'] ?? []
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
Executable
+12
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
use think\Env;
|
||||
|
||||
/**
|
||||
* Cloudflare 配置,优先读取 .env
|
||||
*/
|
||||
return [
|
||||
'api_token' => Env::get('cloudflare.api_token', ''),
|
||||
'account_id' => Env::get('cloudflare.account_id', ''),
|
||||
'server_ip' => Env::get('cloudflare.server_ip', ''),
|
||||
];
|
||||
Executable
+20
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\index\controller;
|
||||
|
||||
/**
|
||||
* 短链入口:/s/{link_code} 由 ThinkPHP 解析为 controller=S、action={link_code},经 _empty 转 visit
|
||||
* (部署 application/route.php 后亦可显式路由到 Split/visit)
|
||||
*/
|
||||
class S extends Split
|
||||
{
|
||||
/**
|
||||
* @param string $link_code 动作名即为 9 位链接码
|
||||
*/
|
||||
public function _empty(string $link_code = ''): string
|
||||
{
|
||||
return $this->visit($link_code);
|
||||
}
|
||||
}
|
||||
+84
@@ -0,0 +1,84 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\index\controller;
|
||||
|
||||
use app\common\controller\Frontend;
|
||||
use app\common\service\SplitLinkCodeService;
|
||||
use app\common\service\SplitVisitPageService;
|
||||
use think\exception\HttpException;
|
||||
use think\exception\HttpResponseException;
|
||||
|
||||
/**
|
||||
* 分流链接公开落地页(/s/{link_code})
|
||||
*/
|
||||
class Split extends Frontend
|
||||
{
|
||||
private const PATCH_VIEW_DIR = 'patches/application/index/view/split/';
|
||||
|
||||
/** @var string[] */
|
||||
protected $noNeedLogin = ['visit'];
|
||||
|
||||
/** @var string[] */
|
||||
protected $noNeedRight = ['visit'];
|
||||
|
||||
protected $layout = '';
|
||||
|
||||
/**
|
||||
* 访问分流短链:解析号码并输出 JS 跳转页
|
||||
*
|
||||
* @param string $link_code 路由参数:9 位小写字母链接码
|
||||
*/
|
||||
public function visit(string $link_code = ''): string
|
||||
{
|
||||
$code = SplitLinkCodeService::normalize($link_code);
|
||||
if (!SplitLinkCodeService::isValidFormat($code)) {
|
||||
$this->abortNotFound();
|
||||
}
|
||||
|
||||
$page = SplitVisitPageService::build(
|
||||
$code,
|
||||
(string) $this->request->ip(),
|
||||
(string) $this->request->server('HTTP_USER_AGENT', ''),
|
||||
(string) $this->request->url(true)
|
||||
);
|
||||
if ($page === null) {
|
||||
$this->abortNotFound();
|
||||
}
|
||||
|
||||
$this->view->assign('redirect_url', $page['redirect_url']);
|
||||
$this->view->assign('redirect_url_json', $page['redirect_url_json']);
|
||||
$this->view->assign('pixel_head_html', $page['pixel_head_html']);
|
||||
$this->view->assign('pixel_body_html', $page['pixel_body_html']);
|
||||
$this->view->assign('orchestrator_html', $page['orchestrator_html']);
|
||||
|
||||
return $this->fetchPatch('visit');
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染模板(优先 patches 视图,回退 application)
|
||||
*/
|
||||
private function fetchPatch(string $template): string
|
||||
{
|
||||
$patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html';
|
||||
$appFile = APP_PATH . 'index/view/split/' . $template . '.html';
|
||||
if (is_file($patchFile)) {
|
||||
$file = $patchFile;
|
||||
} elseif (is_file($appFile)) {
|
||||
$file = $appFile;
|
||||
} else {
|
||||
throw new HttpException(500, 'Template not found');
|
||||
}
|
||||
|
||||
return (string) $this->view->fetch($file);
|
||||
}
|
||||
|
||||
/**
|
||||
* 404:输出无中文的空白页,避免框架默认中文错误页
|
||||
*/
|
||||
private function abortNotFound(): void
|
||||
{
|
||||
throw new HttpResponseException(response($this->fetchPatch('not_found'), 404));
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>404</title>
|
||||
</head>
|
||||
<body></body>
|
||||
</html>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<title></title>
|
||||
{$pixel_head_html|raw}
|
||||
</head>
|
||||
<body>
|
||||
{$pixel_body_html|raw}
|
||||
{$orchestrator_html|raw}
|
||||
<noscript>
|
||||
<meta http-equiv="refresh" content="0;url={$redirect_url|htmlentities}">
|
||||
</noscript>
|
||||
</body>
|
||||
</html>
|
||||
Executable
+9
@@ -0,0 +1,9 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* 分流落地页路由片段:部署时合并进 application/route.php
|
||||
* 规则:'s/:link_code' => 'index/Split/visit',pattern link_code => [a-z]{9}
|
||||
*/
|
||||
return [
|
||||
's/:link_code' => 'index/Split/visit',
|
||||
];
|
||||
Executable
+147
@@ -0,0 +1,147 @@
|
||||
define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {
|
||||
|
||||
/**
|
||||
* 列表 Ajax 仅保留 addtabs 参数。
|
||||
* 切勿拼接 location.search 中的 sort/filter/op 等,否则从其它模块跳入 /domain/index 时会带错字段导致列表为空。
|
||||
*/
|
||||
var buildListQuery = function () {
|
||||
var rawSearch = location.search || '';
|
||||
if (!rawSearch) {
|
||||
return '';
|
||||
}
|
||||
var addtabsMatch = rawSearch.match(/(?:[?&])addtabs=[^&]*/);
|
||||
return addtabsMatch ? ('?' + addtabsMatch[0].replace(/^[?&]/, '')) : '';
|
||||
};
|
||||
|
||||
var Controller = {
|
||||
index: function () {
|
||||
var listQuery = buildListQuery();
|
||||
var indexUrl = 'domain/index' + listQuery;
|
||||
Table.api.init({
|
||||
extend: {
|
||||
index_url: indexUrl,
|
||||
add_url: 'domain/add',
|
||||
edit_url: '',
|
||||
del_url: '',
|
||||
multi_url: '',
|
||||
table: 'domain',
|
||||
}
|
||||
});
|
||||
|
||||
var table = $("#table");
|
||||
|
||||
table.bootstrapTable({
|
||||
url: $.fn.bootstrapTable.defaults.extend.index_url,
|
||||
pk: 'id',
|
||||
sortName: 'id',
|
||||
sortOrder: 'desc',
|
||||
fixedColumns: true,
|
||||
fixedRightNumber: 1,
|
||||
columns: [
|
||||
[
|
||||
{checkbox: true},
|
||||
{field: 'domain', title: __('Domain'), operate: 'LIKE', renderDefault: false},
|
||||
{field: 'full_url', title: __('Full_url'), operate: false, formatter: Table.api.formatter.url},
|
||||
{field: 'zone_status', title: __('Zone_status'), searchList: Config.zoneStatusList, formatter: Table.api.formatter.status},
|
||||
{field: 'ns_status', title: __('Ns_status'), searchList: Config.nsStatusList, formatter: Table.api.formatter.status},
|
||||
{field: 'dns_status', title: __('Dns_status'), searchList: Config.dnsStatusList, formatter: Table.api.formatter.status},
|
||||
{field: 'check_time', title: __('Check_time'), operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, formatter: Table.api.formatter.datetime, sortable: true},
|
||||
{field: 'check_result', title: __('Check_result'), operate: 'LIKE', class: 'autocontent', formatter: Table.api.formatter.content},
|
||||
{field: 'createtime', title: __('Createtime'), operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, formatter: Table.api.formatter.datetime, sortable: true},
|
||||
{
|
||||
field: 'operate',
|
||||
title: __('Operate'),
|
||||
table: table,
|
||||
events: Table.api.events.operate,
|
||||
buttons: [
|
||||
{
|
||||
name: 'detail',
|
||||
text: __('Detail'),
|
||||
icon: 'fa fa-list',
|
||||
classname: 'btn btn-info btn-xs btn-detail btn-dialog',
|
||||
url: 'domain/detail'
|
||||
},
|
||||
{
|
||||
name: 'detect',
|
||||
text: __('Detect'),
|
||||
icon: 'fa fa-refresh',
|
||||
classname: 'btn btn-success btn-xs btn-detect',
|
||||
url: 'domain/detect',
|
||||
refresh: true
|
||||
},
|
||||
{
|
||||
name: 'delconfirm',
|
||||
text: __('Del'),
|
||||
icon: 'fa fa-trash',
|
||||
classname: 'btn btn-danger btn-xs btn-delconfirm'
|
||||
}
|
||||
],
|
||||
formatter: Table.api.formatter.operate
|
||||
}
|
||||
]
|
||||
]
|
||||
});
|
||||
|
||||
table.on('click', '.btn-detect', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var that = this;
|
||||
var rowIndex = $(that).data('row-index');
|
||||
var row = Table.api.getrowbyindex(table, rowIndex);
|
||||
if (!row) {
|
||||
return false;
|
||||
}
|
||||
$(that).addClass('disabled');
|
||||
Fast.api.ajax({
|
||||
url: 'domain/detect/ids/' + row.id,
|
||||
type: 'post'
|
||||
}, function () {
|
||||
table.bootstrapTable('refresh');
|
||||
return false;
|
||||
}, function () {
|
||||
$(that).removeClass('disabled');
|
||||
});
|
||||
});
|
||||
|
||||
table.on('click', '.btn-delconfirm', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
var that = this;
|
||||
var rowIndex = $(that).data('row-index');
|
||||
var row = Table.api.getrowbyindex(table, rowIndex);
|
||||
if (!row) {
|
||||
return false;
|
||||
}
|
||||
Layer.prompt({
|
||||
title: __('Delete domain confirm prompt'),
|
||||
formType: 0,
|
||||
value: ''
|
||||
}, function (value, index) {
|
||||
if ($.trim(value) !== row.domain) {
|
||||
Toastr.error(__('Delete domain confirm mismatch'));
|
||||
return false;
|
||||
}
|
||||
Layer.close(index);
|
||||
Fast.api.ajax({
|
||||
url: 'domain/del',
|
||||
data: {ids: row.id, confirm_domain: $.trim(value)}
|
||||
}, function () {
|
||||
table.bootstrapTable('refresh');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Table.api.bindevent(table);
|
||||
},
|
||||
add: function () {
|
||||
Controller.api.bindevent();
|
||||
},
|
||||
api: {
|
||||
bindevent: function () {
|
||||
Form.api.bindevent($("form[role=form]"));
|
||||
}
|
||||
}
|
||||
};
|
||||
return Controller;
|
||||
});
|
||||
+884
@@ -0,0 +1,884 @@
|
||||
define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {
|
||||
|
||||
var Controller = {
|
||||
index: function () {
|
||||
Table.api.init({
|
||||
extend: {
|
||||
index_url: 'split.link/index' + location.search,
|
||||
add_url: 'split.link/add',
|
||||
edit_url: 'split.link/edit',
|
||||
del_url: 'split.link/del',
|
||||
multi_url: 'split.link/multi',
|
||||
table: 'split_link',
|
||||
}
|
||||
});
|
||||
|
||||
var table = $("#table");
|
||||
|
||||
table.bootstrapTable({
|
||||
url: $.fn.bootstrapTable.defaults.extend.index_url,
|
||||
pk: 'id',
|
||||
sortName: 'id',
|
||||
sortOrder: 'desc',
|
||||
fixedColumns: true,
|
||||
fixedRightNumber: 1,
|
||||
columns: [
|
||||
[
|
||||
{checkbox: true},
|
||||
{field: 'countries_text', title: __('Countries'), operate: false, formatter: Table.api.formatter.content},
|
||||
{
|
||||
field: 'link_code',
|
||||
title: __('Link_code'),
|
||||
operate: 'LIKE',
|
||||
formatter: Controller.api.formatter.linkCode
|
||||
},
|
||||
{field: 'description', title: __('Description'), operate: 'LIKE', class: 'autocontent', formatter: Table.api.formatter.content},
|
||||
{
|
||||
field: 'auto_reply_text',
|
||||
title: __('Reply statements column'),
|
||||
operate: false,
|
||||
formatter: Controller.api.formatter.autoReplyText
|
||||
},
|
||||
{field: 'ip_protect', title: __('Ip_protect'), searchList: Config.ipProtectList, formatter: Table.api.formatter.status},
|
||||
{field: 'random_shuffle', title: __('Random_shuffle'), searchList: Config.randomShuffleList, formatter: Table.api.formatter.status},
|
||||
{field: 'status', title: __('Status'), searchList: Config.statusList, formatter: Table.api.formatter.status},
|
||||
{field: 'createtime', title: __('Createtime'), operate: 'RANGE', addclass: 'datetimerange', autocomplete: false, formatter: Table.api.formatter.datetime, sortable: true},
|
||||
{
|
||||
field: 'operate',
|
||||
title: __('Operate'),
|
||||
table: table,
|
||||
events: Table.api.events.operate,
|
||||
buttons: [
|
||||
{
|
||||
name: 'autoreply',
|
||||
text: __('Auto reply'),
|
||||
title: __('Auto reply'),
|
||||
icon: 'fa fa-commenting-o',
|
||||
classname: 'btn btn-warning btn-xs btn-split-autoreply',
|
||||
url: 'javascript:;'
|
||||
},
|
||||
{
|
||||
name: 'pixel',
|
||||
text: __('Pixel config'),
|
||||
title: __('Pixel config'),
|
||||
icon: 'fa fa-bullseye',
|
||||
classname: 'btn btn-info btn-xs btn-split-pixel',
|
||||
url: 'javascript:;'
|
||||
}
|
||||
],
|
||||
formatter: Table.api.formatter.operate
|
||||
}
|
||||
]
|
||||
]
|
||||
});
|
||||
|
||||
table.on('click', '.btn-copy-split-link', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var linkCode = $.trim($(this).data('link-code') || '');
|
||||
if (!linkCode) {
|
||||
return false;
|
||||
}
|
||||
Controller.api.openCopyModal(linkCode);
|
||||
});
|
||||
|
||||
table.on('click', '.btn-copy-link-code-inline', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var linkCode = $.trim($(this).data('link-code') || '');
|
||||
if (linkCode) {
|
||||
Controller.api.copyText(linkCode);
|
||||
}
|
||||
});
|
||||
|
||||
table.on('click', '.btn-split-autoreply', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
var rowIndex = $(this).data('row-index');
|
||||
var row = Table.api.getrowbyindex(table, rowIndex);
|
||||
if (!row || !row.id) {
|
||||
return false;
|
||||
}
|
||||
Controller.api.openAutoReplyModal(row);
|
||||
});
|
||||
|
||||
table.on('click', '.btn-split-pixel', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
var rowIndex = $(this).data('row-index');
|
||||
var row = Table.api.getrowbyindex(table, rowIndex);
|
||||
if (!row || !row.id) {
|
||||
return false;
|
||||
}
|
||||
Controller.api.openPixelModal(row);
|
||||
});
|
||||
|
||||
Controller.api.bindAutoReplyPreviewTips(table);
|
||||
|
||||
Table.api.bindevent(table);
|
||||
},
|
||||
add: function () {
|
||||
Controller.api.bindevent();
|
||||
Controller.api.bindLinkCodeInput();
|
||||
},
|
||||
edit: function () {
|
||||
Controller.api.bindevent();
|
||||
},
|
||||
api: {
|
||||
/**
|
||||
* 规范化后台跨模块跳转 URL,避免在 split.link 页面内相对解析成 split.link/domain
|
||||
*
|
||||
* @param {string} url API 返回的地址
|
||||
* @param {string} fallback 相对 admin 模块根路径,如 domain / domain/add
|
||||
* @return {string}
|
||||
*/
|
||||
normalizeAdminRouteUrl: function (url, fallback) {
|
||||
fallback = fallback || 'domain';
|
||||
url = $.trim(url || '');
|
||||
if (url === '' || /split\.link\/domain/i.test(url)) {
|
||||
return fallback;
|
||||
}
|
||||
var modulePrefix = (Config.moduleurl || '').replace(/\/+$/, '');
|
||||
if (url.indexOf('://') !== -1) {
|
||||
try {
|
||||
var parsed = new URL(url, window.location.origin);
|
||||
var path = (parsed.pathname || '').replace(/\/+$/, '');
|
||||
if (modulePrefix && path.indexOf(modulePrefix) === 0) {
|
||||
path = path.slice(modulePrefix.length);
|
||||
}
|
||||
path = path.replace(/^\/+/, '');
|
||||
return /^split\.link\/domain/i.test(path) ? fallback : (path || fallback);
|
||||
} catch (e) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
if (url.charAt(0) === '/') {
|
||||
url = url.replace(/^\/+/, '');
|
||||
var moduleKey = modulePrefix.replace(/^\/+/, '');
|
||||
if (moduleKey && url.indexOf(moduleKey + '/') === 0) {
|
||||
url = url.slice(moduleKey.length + 1);
|
||||
}
|
||||
}
|
||||
return /^split\.link\/domain/i.test(url) ? fallback : url;
|
||||
},
|
||||
/** 弹窗样式(仅注入一次) */
|
||||
modalStyleInjected: false,
|
||||
pixelModalStyleInjected: false,
|
||||
injectModalStyles: function () {
|
||||
if (Controller.api.modalStyleInjected) {
|
||||
return;
|
||||
}
|
||||
Controller.api.modalStyleInjected = true;
|
||||
var css = [
|
||||
'.split-link-copy-modal{padding:16px 20px;box-sizing:border-box;}',
|
||||
'.split-link-copy-modal .form-group{margin-bottom:16px;}',
|
||||
'.split-link-copy-modal .control-label{display:block;font-weight:600;color:#444;margin-bottom:8px;}',
|
||||
'.split-link-copy-modal .split-link-code-row{display:flex;align-items:center;flex-wrap:wrap;gap:8px;}',
|
||||
'.split-link-copy-modal .split-link-code-value{font-size:18px;font-weight:600;color:#337ab7;text-decoration:underline;word-break:break-all;}',
|
||||
'.split-link-copy-modal .split-domain-tabs{margin-bottom:0;border-bottom:1px solid #ddd;}',
|
||||
'.split-link-copy-modal .split-domain-tab-box{border:1px solid #ddd;border-top:none;border-radius:0 0 4px 4px;background:#fafafa;overflow:hidden;}',
|
||||
'.split-link-copy-modal .split-domain-tab-box .tab-pane{max-height:280px;overflow-y:auto;overflow-x:hidden;padding:10px 12px;margin:0;}',
|
||||
'.split-link-copy-modal .split-domain-list{display:grid;grid-template-columns:repeat(3,1fr);gap:8px;align-items:stretch;}',
|
||||
'.split-link-copy-modal .split-domain-item{border:1px solid #e8e8e8;border-radius:4px;background:#fff;transition:border-color .2s,box-shadow .2s;height:100%;min-width:0;}',
|
||||
'.split-link-copy-modal .split-domain-item:hover{border-color:#b8d4f0;}',
|
||||
'.split-link-copy-modal .split-domain-item.is-checked{border-color:#337ab7;box-shadow:0 0 0 1px rgba(51,122,183,.15);}',
|
||||
'.split-link-copy-modal .split-domain-item-label{display:flex;align-items:flex-start;gap:8px;margin:0;padding:8px 10px;cursor:pointer;font-weight:normal;width:100%;height:100%;box-sizing:border-box;}',
|
||||
'.split-link-copy-modal .split-domain-item-label input[type=radio]{margin:3px 0 0;flex-shrink:0;}',
|
||||
'.split-link-copy-modal .split-domain-item-body{flex:1;min-width:0;display:flex;flex-direction:column;gap:5px;}',
|
||||
'.split-link-copy-modal .split-domain-name{font-size:13px;font-weight:600;color:#333;word-break:break-all;line-height:1.4;}',
|
||||
'.split-link-copy-modal .split-domain-status{display:flex;flex-direction:column;align-items:flex-start;gap:4px;}',
|
||||
'.split-link-copy-modal .split-domain-status .label{font-size:10px;font-weight:normal;padding:2px 6px;line-height:1.3;white-space:nowrap;margin:0;display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;}',
|
||||
'.split-link-copy-modal .split-domain-empty{margin:0;padding:20px 12px;color:#999;text-align:center;line-height:1.6;grid-column:1/-1;}',
|
||||
'.split-link-copy-modal .split-tip-box{margin:12px 0;padding:10px 12px;background:#f8f9fa;border-left:3px solid #337ab7;border-radius:0 4px 4px 0;line-height:1.6;color:#666;}',
|
||||
'.split-link-copy-modal .split-generated-url{font-size:12px;}',
|
||||
'.split-link-copy-modal .split-modal-footer{margin-top:16px;padding-top:12px;border-top:1px solid #eee;}'
|
||||
].join('');
|
||||
$('<style id="split-link-copy-modal-style" type="text/css"></style>').text(css).appendTo('head');
|
||||
},
|
||||
injectPixelModalStyles: function () {
|
||||
if (Controller.api.pixelModalStyleInjected) {
|
||||
return;
|
||||
}
|
||||
Controller.api.pixelModalStyleInjected = true;
|
||||
var css = [
|
||||
'.split-pixel-layer .layui-layer-content{padding:0;overflow:hidden;max-height:calc(86vh - 108px);}',
|
||||
'.split-pixel-layer .layui-layer-btn{border-top:1px solid #e8e8e8;background:#fafafa;}',
|
||||
'.split-pixel-modal{padding:18px 22px 14px;box-sizing:border-box;display:flex;flex-direction:column;min-height:480px;height:calc(86vh - 108px);max-height:720px;}',
|
||||
'.split-pixel-modal .split-pixel-tip{margin:0 0 14px;padding:10px 14px;background:#f0f7ff;border-left:3px solid #337ab7;border-radius:0 4px 4px 0;color:#555;font-size:13px;line-height:1.65;}',
|
||||
'.split-pixel-modal .split-pixel-tabs{margin-bottom:0;border-bottom:1px solid #ddd;}',
|
||||
'.split-pixel-modal .split-pixel-tabs>li>a{padding:9px 18px;font-weight:600;color:#666;}',
|
||||
'.split-pixel-modal .split-pixel-tabs>li.active>a{color:#337ab7;border-bottom-color:#fff;}',
|
||||
'.split-pixel-modal .split-pixel-tab-content{flex:1;display:flex;flex-direction:column;min-height:0;padding-top:14px;}',
|
||||
'.split-pixel-modal .split-pixel-tab-content>.tab-pane{display:none;flex:1;flex-direction:column;min-height:0;}',
|
||||
'.split-pixel-modal .split-pixel-tab-content>.tab-pane.active{display:flex;}',
|
||||
'.split-pixel-modal .split-pixel-list{flex:1;display:flex;flex-direction:column;min-height:0;}',
|
||||
'.split-pixel-modal .split-pixel-toolbar{margin-bottom:12px;display:flex;align-items:center;justify-content:space-between;flex-shrink:0;}',
|
||||
'.split-pixel-modal .split-pixel-toolbar .btn-add-pixel-row{font-weight:600;padding:6px 14px;}',
|
||||
'.split-pixel-modal .split-pixel-table-wrap{flex:1;min-height:320px;overflow:auto;border:1px solid #dce3eb;border-radius:6px;background:#fff;box-shadow:inset 0 1px 2px rgba(0,0,0,.03);}',
|
||||
'.split-pixel-modal .split-pixel-table{margin-bottom:0;font-size:13px;table-layout:auto;width:100%;}',
|
||||
'.split-pixel-modal .split-pixel-table thead th{background:linear-gradient(180deg,#f8fafc 0%,#eef2f6 100%);white-space:nowrap;vertical-align:middle;text-align:center;font-weight:600;color:#444;border-bottom:2px solid #dce3eb;padding:10px 8px;position:sticky;top:0;z-index:3;box-shadow:0 1px 0 #dce3eb;}',
|
||||
'.split-pixel-modal .split-pixel-table tbody td{vertical-align:middle;padding:10px 8px;border-color:#edf1f5;}',
|
||||
'.split-pixel-modal .split-pixel-table tbody tr.split-pixel-row:hover{background:#f7fbff;}',
|
||||
'.split-pixel-modal .split-pixel-table tbody tr.split-pixel-row:nth-child(even){background:#fbfcfd;}',
|
||||
'.split-pixel-modal .split-pixel-table tbody tr.split-pixel-row:nth-child(even):hover{background:#f7fbff;}',
|
||||
'.split-pixel-modal .split-pixel-table .form-control{min-width:0;height:32px;line-height:1.42857143;padding:6px 10px;border-radius:4px;}',
|
||||
'.split-pixel-modal .split-pixel-table .pixel-id{min-width:130px;}',
|
||||
'.split-pixel-modal .split-pixel-table .pixel-access-token{min-width:150px;}',
|
||||
'.split-pixel-modal .split-pixel-table .pixel-test-code{min-width:100px;}',
|
||||
'.split-pixel-modal .split-pixel-table th.pixel-event-col,.split-pixel-modal .split-pixel-table td.pixel-event-cell{min-width:148px;width:148px;}',
|
||||
'.split-pixel-modal .split-pixel-table .pixel-event{width:100%;min-width:132px;max-width:none;padding-right:28px;text-overflow:clip;overflow:visible;white-space:nowrap;cursor:pointer;}',
|
||||
'.split-pixel-modal .split-pixel-empty-row td{padding:48px 16px;color:#999;text-align:center;font-size:13px;background:#fafbfc;}',
|
||||
'.split-pixel-modal .split-pixel-sort-group{width:118px;margin:0 auto;}',
|
||||
'.split-pixel-modal .split-pixel-sort-group .form-control{text-align:center;padding-left:4px;padding-right:4px;}',
|
||||
'.split-pixel-modal .pixel-switch-wrap{text-align:center;}',
|
||||
'.split-pixel-modal .pixel-switch-wrap input[type=checkbox]{width:17px;height:17px;margin:0;cursor:pointer;vertical-align:middle;}',
|
||||
'.split-pixel-modal .pixel-row-index{display:inline-block;min-width:26px;height:26px;line-height:26px;border-radius:13px;background:#e8eef5;color:#4a6785;font-weight:600;font-size:12px;}',
|
||||
'.split-pixel-modal .btn-pixel-row-remove{padding:4px 8px;border-radius:4px;}',
|
||||
'.split-pixel-modal .col-token{min-width:160px;}'
|
||||
].join('');
|
||||
$('<style id="split-pixel-modal-style" type="text/css"></style>').text(css).appendTo('head');
|
||||
},
|
||||
formatter: {
|
||||
/**
|
||||
* 回复语:列表最多 50 字 + ...,悬停保留换行显示全文
|
||||
*/
|
||||
autoReplyText: function (value, row, index) {
|
||||
var full = (row.auto_reply != null && row.auto_reply !== '') ? String(row.auto_reply) : '';
|
||||
if (full === '') {
|
||||
return '<span class="text-muted">-</span>';
|
||||
}
|
||||
var preview = value != null && value !== '' ? String(value) : full;
|
||||
var safePreview = Fast.api.escape(preview);
|
||||
var encFull = encodeURIComponent(full);
|
||||
return '<span class="split-auto-reply-preview" data-full="' + encFull + '" style="display:inline-block;max-width:100%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;vertical-align:middle;cursor:default;">'
|
||||
+ safePreview + '</span>';
|
||||
},
|
||||
/**
|
||||
* 分流链接列:链接样式 + COPY 图标,点击链接打开弹窗
|
||||
*/
|
||||
linkCode: function (value) {
|
||||
value = value == null ? '' : value.toString();
|
||||
if (value === '') {
|
||||
return '-';
|
||||
}
|
||||
var safe = Fast.api.escape(value);
|
||||
return '<span class="split-link-code-cell">'
|
||||
+ '<a href="javascript:;" class="btn-copy-split-link split-link-code-link" data-link-code="' + safe + '" style="font-weight:600;color:#337ab7;text-decoration:underline;">' + safe + '</a>'
|
||||
+ ' <a href="javascript:;" class="btn-copy-link-code-inline text-primary" data-link-code="' + safe + '" title="' + __('Copy') + '"><i class="fa fa-copy"></i></a>'
|
||||
+ '</span>';
|
||||
}
|
||||
},
|
||||
openCopyModal: function (linkCode) {
|
||||
Controller.api.loadCopyModalData(function (data) {
|
||||
Controller.api.renderCopyModal(linkCode, data);
|
||||
});
|
||||
},
|
||||
/** 回复语列悬停提示(保留换行) */
|
||||
autoReplyTipIndex: null,
|
||||
bindAutoReplyPreviewTips: function (table) {
|
||||
table.off('mouseenter.splitAutoReply mouseleave.splitAutoReply')
|
||||
.on('mouseenter.splitAutoReply', '.split-auto-reply-preview', function () {
|
||||
var enc = $(this).attr('data-full');
|
||||
if (!enc) {
|
||||
return;
|
||||
}
|
||||
var full = '';
|
||||
try {
|
||||
full = decodeURIComponent(enc);
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
var html = '<div style="white-space:pre-wrap;word-break:break-word;padding:10px 12px;line-height:1.6;max-width:420px;font-size:13px;text-align:left;">'
|
||||
+ Controller.api.escapeHtmlWithNewlines(full) + '</div>';
|
||||
Controller.api.autoReplyTipIndex = Layer.tips(html, this, {
|
||||
tips: [1, ''],
|
||||
time: 0,
|
||||
maxWidth: 450
|
||||
});
|
||||
})
|
||||
.on('mouseleave.splitAutoReply', '.split-auto-reply-preview', function () {
|
||||
if (Controller.api.autoReplyTipIndex != null) {
|
||||
Layer.close(Controller.api.autoReplyTipIndex);
|
||||
Controller.api.autoReplyTipIndex = null;
|
||||
}
|
||||
});
|
||||
},
|
||||
escapeHtmlWithNewlines: function (text) {
|
||||
return $('<div/>').text(text).html().replace(/\n/g, '<br>');
|
||||
},
|
||||
/**
|
||||
* 打开自动回复弹窗
|
||||
*
|
||||
* @param {object} row 列表行数据
|
||||
*/
|
||||
openAutoReplyModal: function (row) {
|
||||
Fast.api.ajax({
|
||||
url: 'split.link/autoreply/ids/' + row.id,
|
||||
type: 'get'
|
||||
}, function (data, ret) {
|
||||
var info = ret.data || data || {};
|
||||
Controller.api.renderAutoReplyModal(row.id, info);
|
||||
return false;
|
||||
});
|
||||
},
|
||||
renderAutoReplyModal: function (linkId, info) {
|
||||
var linkCode = Fast.api.escape(info.link_code || '');
|
||||
var autoReply = info.auto_reply || '';
|
||||
var html = [
|
||||
'<div class="split-auto-reply-modal" style="padding:16px 20px;">',
|
||||
' <div class="form-group">',
|
||||
' <label class="control-label">' + __('Link_code') + '</label>',
|
||||
' <p class="form-control-static" style="font-size:16px;font-weight:600;color:#337ab7;margin:0;">' + linkCode + '</p>',
|
||||
' </div>',
|
||||
' <div class="form-group" style="margin-bottom:0;">',
|
||||
' <label class="control-label" for="split-auto-reply-text">' + __('Reply statements') + '</label>',
|
||||
' <p class="help-block" style="margin-top:0;">' + __('Reply statements tip') + '</p>',
|
||||
' <textarea id="split-auto-reply-text" class="form-control" rows="10" placeholder="">' + Fast.api.escape(autoReply) + '</textarea>',
|
||||
' </div>',
|
||||
'</div>'
|
||||
].join('');
|
||||
|
||||
Layer.open({
|
||||
type: 1,
|
||||
title: __('Auto reply'),
|
||||
area: ['520px', 'auto'],
|
||||
shadeClose: false,
|
||||
content: html,
|
||||
btn: [__('OK'), __('Close')],
|
||||
yes: function (index, layero) {
|
||||
var text = layero.find('#split-auto-reply-text').val();
|
||||
Fast.api.ajax({
|
||||
url: 'split.link/autoreply/ids/' + linkId,
|
||||
type: 'post',
|
||||
data: {auto_reply: text}
|
||||
}, function () {
|
||||
Layer.close(index);
|
||||
Toastr.success(__('Auto reply saved'));
|
||||
});
|
||||
return false;
|
||||
}
|
||||
});
|
||||
},
|
||||
pixelRowUid: function (prefix) {
|
||||
return prefix + '_' + Date.now().toString(36) + '_' + Math.random().toString(36).slice(2, 8);
|
||||
},
|
||||
openPixelModal: function (row) {
|
||||
Fast.api.ajax({
|
||||
url: 'split.link/pixel/ids/' + row.id,
|
||||
type: 'get'
|
||||
}, function (data, ret) {
|
||||
var info = ret.data || data || {};
|
||||
Controller.api.renderPixelModal(row.id, info);
|
||||
return false;
|
||||
});
|
||||
},
|
||||
renderPixelModal: function (linkId, info) {
|
||||
Controller.api.injectPixelModalStyles();
|
||||
var eventOptions = $.isArray(info.event_options) ? info.event_options : [
|
||||
'PageView', 'Lead', 'Contact', 'AddToCart', 'Purchase', 'Subscribe'
|
||||
];
|
||||
var fbRows = $.isArray(info.facebook) ? info.facebook : [];
|
||||
var tkRows = $.isArray(info.tiktok) ? info.tiktok : [];
|
||||
|
||||
var html = [
|
||||
'<div class="split-pixel-modal">',
|
||||
' <div class="split-pixel-tip">' + __('Pixel config tip') + '</div>',
|
||||
' <ul class="nav nav-tabs split-pixel-tabs" role="tablist">',
|
||||
' <li role="presentation" class="active"><a href="#split-pixel-fb" role="tab" data-toggle="tab"><i class="fa fa-facebook-square"></i> ' + __('Facebook Pixel') + '</a></li>',
|
||||
' <li role="presentation"><a href="#split-pixel-tk" role="tab" data-toggle="tab"><i class="fa fa-music"></i> ' + __('TikTok Pixel') + '</a></li>',
|
||||
' </ul>',
|
||||
' <div class="tab-content split-pixel-tab-content">',
|
||||
' <div role="tabpanel" class="tab-pane active" id="split-pixel-fb">',
|
||||
Controller.api.buildPixelListShell('facebook', __('Add FB Pixel')),
|
||||
' </div>',
|
||||
' <div role="tabpanel" class="tab-pane" id="split-pixel-tk">',
|
||||
Controller.api.buildPixelListShell('tiktok', __('Add TK Pixel')),
|
||||
' </div>',
|
||||
' </div>',
|
||||
'</div>'
|
||||
].join('');
|
||||
|
||||
Layer.open({
|
||||
type: 1,
|
||||
title: __('Pixel config') + ' - ' + Fast.api.escape(info.link_code || ''),
|
||||
area: ['1140px', '86vh'],
|
||||
maxmin: true,
|
||||
shadeClose: false,
|
||||
content: html,
|
||||
btn: [__('OK'), __('Close')],
|
||||
success: function (layero) {
|
||||
layero.addClass('split-pixel-layer');
|
||||
var $box = layero.find('.split-pixel-modal');
|
||||
var fillRows = function (platform, rows) {
|
||||
var $list = $box.find('.split-pixel-list[data-platform="' + platform + '"]');
|
||||
var $body = $list.find('tbody.split-pixel-list-body');
|
||||
$body.find('tr.split-pixel-row').remove();
|
||||
$.each(rows, function (_, rowData) {
|
||||
$body.append(Controller.api.buildPixelRowHtml(platform, rowData, eventOptions));
|
||||
});
|
||||
Controller.api.syncPixelListEmpty($list);
|
||||
};
|
||||
fillRows('facebook', fbRows);
|
||||
fillRows('tiktok', tkRows);
|
||||
|
||||
$box.on('click', '.btn-add-pixel-row', function () {
|
||||
var platform = $(this).data('platform');
|
||||
var $list = $box.find('.split-pixel-list[data-platform="' + platform + '"]');
|
||||
var $body = $list.find('tbody.split-pixel-list-body');
|
||||
var sortVal = $body.find('tr.split-pixel-row').length;
|
||||
$body.append(Controller.api.buildPixelRowHtml(platform, {
|
||||
id: Controller.api.pixelRowUid(platform === 'facebook' ? 'fb' : 'tk'),
|
||||
enabled: 1,
|
||||
server_postback: 0,
|
||||
pixel_id: '',
|
||||
access_token: '',
|
||||
test_code: '',
|
||||
event: 'PageView',
|
||||
sort: sortVal
|
||||
}, eventOptions));
|
||||
Controller.api.syncPixelListEmpty($list);
|
||||
var $wrap = $list.find('.split-pixel-table-wrap');
|
||||
if ($wrap.length) {
|
||||
$wrap.scrollTop($wrap[0].scrollHeight);
|
||||
}
|
||||
});
|
||||
|
||||
$box.on('click', '.btn-pixel-sort-up', function () {
|
||||
var $row = $(this).closest('tr.split-pixel-row');
|
||||
var $input = $row.find('.pixel-sort-input');
|
||||
$input.val(Math.max(0, (parseInt($input.val(), 10) || 0) + 1));
|
||||
});
|
||||
$box.on('click', '.btn-pixel-sort-down', function () {
|
||||
var $row = $(this).closest('tr.split-pixel-row');
|
||||
var $input = $row.find('.pixel-sort-input');
|
||||
$input.val(Math.max(0, (parseInt($input.val(), 10) || 0) - 1));
|
||||
});
|
||||
$box.on('click', '.btn-pixel-row-remove', function () {
|
||||
var $row = $(this).closest('tr.split-pixel-row');
|
||||
var $list = $row.closest('.split-pixel-list');
|
||||
$row.remove();
|
||||
Controller.api.syncPixelListEmpty($list);
|
||||
});
|
||||
$box.on('change', '.pixel-event', function () {
|
||||
var val = $(this).val() || '';
|
||||
$(this).attr('title', val);
|
||||
});
|
||||
},
|
||||
yes: function (index, layero) {
|
||||
var payload = Controller.api.collectPixelPayload(layero.find('.split-pixel-modal'));
|
||||
var invalid = false;
|
||||
$.each(payload.facebook.concat(payload.tiktok), function (_, item) {
|
||||
if (!$.trim(item.pixel_id)) {
|
||||
invalid = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
if (invalid) {
|
||||
Toastr.error(__('Pixel ID required'));
|
||||
return false;
|
||||
}
|
||||
Fast.api.ajax({
|
||||
url: 'split.link/pixel/ids/' + linkId,
|
||||
type: 'post',
|
||||
data: {pixel_config: payload}
|
||||
}, function () {
|
||||
Layer.close(index);
|
||||
Toastr.success(__('Pixel config saved'));
|
||||
});
|
||||
return false;
|
||||
}
|
||||
});
|
||||
},
|
||||
buildPixelListShell: function (platform, addBtnText) {
|
||||
return [
|
||||
'<div class="split-pixel-list" data-platform="' + platform + '">',
|
||||
' <div class="split-pixel-toolbar">',
|
||||
' <button type="button" class="btn btn-success btn-sm btn-add-pixel-row" data-platform="' + platform + '">',
|
||||
' <i class="fa fa-plus"></i> ' + addBtnText,
|
||||
' </button>',
|
||||
' </div>',
|
||||
' <div class="table-responsive split-pixel-table-wrap">',
|
||||
' <table class="table table-bordered table-hover split-pixel-table">',
|
||||
' <thead>',
|
||||
' <tr>',
|
||||
' <th width="52">' + __('Pixel row index') + '</th>',
|
||||
' <th width="58" title="' + __('Pixel enabled') + '">' + __('Pixel enabled') + '</th>',
|
||||
' <th width="78" title="' + __('Server postback') + '">' + __('Server postback') + '</th>',
|
||||
' <th width="140">' + __('Pixel ID') + ' <span class="text-danger">*</span></th>',
|
||||
' <th class="pixel-event-col">' + __('Pixel event') + '</th>',
|
||||
' <th class="col-token">' + __('Access Token') + '</th>',
|
||||
' <th width="108">' + __('Test code') + '</th>',
|
||||
' <th width="124">' + __('Pixel sort') + '</th>',
|
||||
' <th width="58">' + __('Operate') + '</th>',
|
||||
' </tr>',
|
||||
' </thead>',
|
||||
' <tbody class="split-pixel-list-body">',
|
||||
' <tr class="split-pixel-empty-row"><td colspan="9"><i class="fa fa-inbox" style="margin-right:6px;opacity:.5;"></i>' + __('Pixel list empty') + '</td></tr>',
|
||||
' </tbody>',
|
||||
' </table>',
|
||||
' </div>',
|
||||
'</div>'
|
||||
].join('');
|
||||
},
|
||||
syncPixelListEmpty: function ($list) {
|
||||
var $body = $list.find('tbody.split-pixel-list-body');
|
||||
var $rows = $body.find('tr.split-pixel-row');
|
||||
$body.find('tr.split-pixel-empty-row').toggle($rows.length === 0);
|
||||
$rows.each(function (idx) {
|
||||
$(this).find('.pixel-row-index').text(String(idx + 1));
|
||||
});
|
||||
},
|
||||
buildPixelRowHtml: function (platform, row, eventOptions) {
|
||||
row = row || {};
|
||||
var id = row.id || Controller.api.pixelRowUid(platform === 'facebook' ? 'fb' : 'tk');
|
||||
var enabled = parseInt(row.enabled, 10) === 1;
|
||||
var serverPostback = parseInt(row.server_postback, 10) === 1;
|
||||
var eventOpts = '';
|
||||
$.each(eventOptions, function (_, ev) {
|
||||
eventOpts += '<option value="' + ev + '"' + (row.event === ev ? ' selected' : '') + '>' + ev + '</option>';
|
||||
});
|
||||
var tokenPlaceholder = __('Optional');
|
||||
var tokenHint = row.has_access_token ? ' placeholder="' + tokenPlaceholder + ' (' + __('Optional') + ')"' : ' placeholder="' + tokenPlaceholder + '"';
|
||||
var selectedEvent = row.event || 'PageView';
|
||||
|
||||
return [
|
||||
'<tr class="split-pixel-row" data-row-id="' + Fast.api.escape(id) + '">',
|
||||
' <td class="text-center"><span class="pixel-row-index">-</span></td>',
|
||||
' <td class="pixel-switch-wrap"><input type="checkbox" class="pixel-enabled" title="' + __('Pixel enabled') + '" ' + (enabled ? 'checked' : '') + '></td>',
|
||||
' <td class="pixel-switch-wrap"><input type="checkbox" class="pixel-server-postback" title="' + __('Server postback') + '" ' + (serverPostback ? 'checked' : '') + '></td>',
|
||||
' <td><input type="text" class="form-control input-sm pixel-id" value="' + Fast.api.escape(row.pixel_id || '') + '" placeholder="' + __('Pixel ID') + '"></td>',
|
||||
' <td class="pixel-event-cell"><select class="form-control input-sm pixel-event" title="' + Fast.api.escape(selectedEvent) + '">' + eventOpts + '</select></td>',
|
||||
' <td class="col-token"><input type="text" class="form-control input-sm pixel-access-token" value=""' + tokenHint + '></td>',
|
||||
' <td><input type="text" class="form-control input-sm pixel-test-code" value="' + Fast.api.escape(row.test_code || '') + '" placeholder="' + tokenPlaceholder + '"></td>',
|
||||
' <td>',
|
||||
' <div class="input-group input-group-sm split-pixel-sort-group">',
|
||||
' <span class="input-group-btn"><button type="button" class="btn btn-default btn-pixel-sort-down" title="-1"><i class="fa fa-minus"></i></button></span>',
|
||||
' <input type="number" min="0" class="form-control pixel-sort-input" value="' + (parseInt(row.sort, 10) || 0) + '" title="' + __('Pixel sort') + '">',
|
||||
' <span class="input-group-btn"><button type="button" class="btn btn-default btn-pixel-sort-up" title="+1"><i class="fa fa-plus"></i></button></span>',
|
||||
' </div>',
|
||||
' </td>',
|
||||
' <td class="text-center">',
|
||||
' <button type="button" class="btn btn-danger btn-xs btn-pixel-row-remove" title="' + __('Remove row') + '"><i class="fa fa-trash"></i></button>',
|
||||
' </td>',
|
||||
'</tr>'
|
||||
].join('');
|
||||
},
|
||||
collectPixelPayload: function ($box) {
|
||||
var readList = function (platform) {
|
||||
var rows = [];
|
||||
$box.find('.split-pixel-list[data-platform="' + platform + '"] tbody .split-pixel-row').each(function () {
|
||||
var $row = $(this);
|
||||
rows.push({
|
||||
id: $row.attr('data-row-id') || Controller.api.pixelRowUid(platform === 'facebook' ? 'fb' : 'tk'),
|
||||
enabled: $row.find('.pixel-enabled').prop('checked') ? 1 : 0,
|
||||
server_postback: $row.find('.pixel-server-postback').prop('checked') ? 1 : 0,
|
||||
pixel_id: $.trim($row.find('.pixel-id').val()),
|
||||
access_token: $.trim($row.find('.pixel-access-token').val()),
|
||||
test_code: $.trim($row.find('.pixel-test-code').val()),
|
||||
event: $row.find('.pixel-event').val() || 'PageView',
|
||||
sort: Math.max(0, parseInt($row.find('.pixel-sort-input').val(), 10) || 0)
|
||||
});
|
||||
});
|
||||
return rows;
|
||||
};
|
||||
return {
|
||||
facebook: readList('facebook'),
|
||||
tiktok: readList('tiktok')
|
||||
};
|
||||
},
|
||||
loadCopyModalData: function (callback) {
|
||||
Fast.api.ajax({
|
||||
url: 'split.link/copyinfo',
|
||||
type: 'get'
|
||||
}, function (data, ret) {
|
||||
callback(ret.data || data || {});
|
||||
return false;
|
||||
});
|
||||
},
|
||||
renderCopyModal: function (linkCode, data) {
|
||||
Controller.api.injectModalStyles();
|
||||
|
||||
var platformDomains = $.isArray(data.platform_domains) ? data.platform_domains : [];
|
||||
if (!platformDomains.length && data.platform_domain) {
|
||||
var rawSingle = $.trim(data.platform_domain);
|
||||
platformDomains = rawSingle.split(/[\r\n,]+/).map(function (d) {
|
||||
return $.trim(d);
|
||||
}).filter(function (d) {
|
||||
return d.length > 0;
|
||||
});
|
||||
}
|
||||
var myDomains = $.isArray(data.my_domains) ? data.my_domains : [];
|
||||
var domainIndexUrl = Controller.api.normalizeAdminRouteUrl(data.domain_index_url, 'domain');
|
||||
var domainAddUrl = Controller.api.normalizeAdminRouteUrl(data.domain_add_url, 'domain/add');
|
||||
var configIndexUrl = data.config_index_url || 'general/config/index';
|
||||
var defaultType = platformDomains.length ? 'platform' : 'my';
|
||||
var defaultDomain = platformDomains.length ? platformDomains[0] : (myDomains.length ? myDomains[0].domain : '');
|
||||
var activeTab = defaultType === 'platform' ? 'platform' : 'my';
|
||||
|
||||
var platformHtml = '';
|
||||
if (platformDomains.length) {
|
||||
var platformItems = [];
|
||||
$.each(platformDomains, function (i, domain) {
|
||||
platformItems.push(Controller.api.buildDomainRadio({
|
||||
domain: domain,
|
||||
type: 'platform',
|
||||
checked: defaultType === 'platform' && domain === defaultDomain,
|
||||
showStatus: false
|
||||
}));
|
||||
});
|
||||
platformHtml = '<div class="split-domain-list">' + platformItems.join('') + '</div>';
|
||||
} else {
|
||||
platformHtml = '<p class="split-domain-empty">'
|
||||
+ __('Split platform domain empty')
|
||||
+ ' <a href="javascript:;" class="btn-goto-config">' + __('Go system config') + '</a>'
|
||||
+ '</p>';
|
||||
}
|
||||
|
||||
var myDomainsHtml = '';
|
||||
if (myDomains.length) {
|
||||
var items = [];
|
||||
$.each(myDomains, function (i, item) {
|
||||
items.push(Controller.api.buildDomainRadio({
|
||||
domain: item.domain,
|
||||
type: 'my',
|
||||
checked: defaultType === 'my' && item.domain === defaultDomain,
|
||||
nsText: item.ns_status_text || '',
|
||||
dnsText: item.dns_status_text || '',
|
||||
nsStatus: item.ns_status || '',
|
||||
dnsStatus: item.dns_status || '',
|
||||
showStatus: true
|
||||
}));
|
||||
});
|
||||
myDomainsHtml = '<div class="split-domain-list">' + items.join('') + '</div>';
|
||||
} else {
|
||||
myDomainsHtml = '<p class="split-domain-empty">' + __('Split my domain empty') + '</p>';
|
||||
}
|
||||
|
||||
var initialUrl = Controller.api.buildSplitUrl(defaultDomain, linkCode);
|
||||
var safeLinkCode = Fast.api.escape(linkCode);
|
||||
var html = [
|
||||
'<div class="split-link-copy-modal">',
|
||||
' <div class="form-group">',
|
||||
' <label class="control-label">' + __('Link_code') + '</label>',
|
||||
' <div class="split-link-code-row">',
|
||||
' <a href="javascript:;" class="split-link-code-value" data-link-code="' + safeLinkCode + '">' + safeLinkCode + '</a>',
|
||||
' <button type="button" class="btn btn-default btn-sm btn-copy-link-code" data-link-code="' + safeLinkCode + '" title="' + __('Copy') + '"><i class="fa fa-copy"></i></button>',
|
||||
' <button type="button" class="btn btn-default btn-sm btn-manage-domain">' + __('Manage my domains') + '</button>',
|
||||
' </div>',
|
||||
' </div>',
|
||||
' <div class="form-group">',
|
||||
' <label class="control-label">' + __('Select main domain') + '</label>',
|
||||
' <ul class="nav nav-tabs split-domain-tabs" role="tablist">',
|
||||
' <li role="presentation"' + (activeTab === 'platform' ? ' class="active"' : '') + '>',
|
||||
' <a href="#split-tab-platform" role="tab" data-toggle="tab">' + __('Platform assigned domain') + '</a>',
|
||||
' </li>',
|
||||
' <li role="presentation"' + (activeTab === 'my' ? ' class="active"' : '') + '>',
|
||||
' <a href="#split-tab-my" role="tab" data-toggle="tab">' + __('My domains') + '</a>',
|
||||
' </li>',
|
||||
' </ul>',
|
||||
' <div class="tab-content split-domain-tab-box">',
|
||||
' <div role="tabpanel" class="tab-pane' + (activeTab === 'platform' ? ' active' : '') + '" id="split-tab-platform">' + platformHtml + '</div>',
|
||||
' <div role="tabpanel" class="tab-pane' + (activeTab === 'my' ? ' active' : '') + '" id="split-tab-my">' + myDomainsHtml + '</div>',
|
||||
' </div>',
|
||||
' </div>',
|
||||
' <div class="split-tip-box">',
|
||||
__('Split domain prefix tip'),
|
||||
' <button type="button" class="btn btn-xs btn-primary btn-go-add-domain">' + __('Go add domain') + '</button>',
|
||||
' </div>',
|
||||
' <div class="form-group" style="margin-bottom:0;">',
|
||||
' <label class="control-label">' + __('Generated result') + '</label>',
|
||||
' <div class="input-group">',
|
||||
' <input type="text" class="form-control split-generated-url" readonly value="' + Fast.api.escape(initialUrl) + '">',
|
||||
' <span class="input-group-btn">',
|
||||
' <button type="button" class="btn btn-default btn-copy-generated-url"><i class="fa fa-copy"></i> ' + __('Copy') + '</button>',
|
||||
' </span>',
|
||||
' </div>',
|
||||
' </div>',
|
||||
' <div class="text-center split-modal-footer">',
|
||||
' <button type="button" class="btn btn-success btn-open-test"' + (initialUrl ? '' : ' disabled') + '>' + __('Open test') + '</button>',
|
||||
' <button type="button" class="btn btn-default btn-close-copy-modal">' + __('Close') + '</button>',
|
||||
' </div>',
|
||||
'</div>'
|
||||
].join('');
|
||||
|
||||
Layer.open({
|
||||
type: 1,
|
||||
title: __('Copy split link'),
|
||||
area: ['780px', 'auto'],
|
||||
maxmin: false,
|
||||
resize: false,
|
||||
shadeClose: true,
|
||||
content: html,
|
||||
success: function (layero, index) {
|
||||
var $box = layero.find('.split-link-copy-modal');
|
||||
|
||||
var refreshGeneratedUrl = function () {
|
||||
var $checked = $box.find('input[name="split_main_domain"]:checked');
|
||||
var domain = $.trim($checked.val() || '');
|
||||
var url = Controller.api.buildSplitUrl(domain, linkCode);
|
||||
$box.find('.split-generated-url').val(url);
|
||||
$box.find('.btn-open-test').prop('disabled', url === '');
|
||||
$box.find('.split-domain-item').removeClass('is-checked');
|
||||
$checked.closest('.split-domain-item').addClass('is-checked');
|
||||
};
|
||||
|
||||
var ensureTabSelection = function ($pane) {
|
||||
var $radios = $pane.find('input[name="split_main_domain"]');
|
||||
if ($radios.length && !$radios.filter(':checked').length) {
|
||||
$radios.first().prop('checked', true);
|
||||
}
|
||||
};
|
||||
|
||||
$box.on('change', 'input[name="split_main_domain"]', refreshGeneratedUrl);
|
||||
|
||||
$box.on('shown.bs.tab', 'a[data-toggle="tab"]', function (e) {
|
||||
var target = $(e.target).attr('href');
|
||||
if (target) {
|
||||
ensureTabSelection($box.find(target));
|
||||
refreshGeneratedUrl();
|
||||
}
|
||||
});
|
||||
|
||||
$box.on('click', '.btn-copy-link-code', function (e) {
|
||||
e.preventDefault();
|
||||
Controller.api.copyText(linkCode);
|
||||
});
|
||||
|
||||
$box.on('click', '.btn-manage-domain', function (e) {
|
||||
e.preventDefault();
|
||||
Layer.close(index);
|
||||
Backend.api.addtabs(Fast.api.fixurl(domainIndexUrl), __('Domain management'), 'fa fa-globe');
|
||||
});
|
||||
|
||||
$box.on('click', '.btn-go-add-domain', function (e) {
|
||||
e.preventDefault();
|
||||
Layer.close(index);
|
||||
Backend.api.addtabs(Fast.api.fixurl(domainAddUrl), __('Domain management'), 'fa fa-plus');
|
||||
});
|
||||
|
||||
$box.on('click', '.btn-goto-config', function (e) {
|
||||
e.preventDefault();
|
||||
Layer.close(index);
|
||||
Backend.api.addtabs(Fast.api.fixurl(configIndexUrl), __('System config'), 'fa fa-cogs');
|
||||
});
|
||||
|
||||
$box.on('click', '.btn-copy-generated-url', function (e) {
|
||||
e.preventDefault();
|
||||
var url = $.trim($box.find('.split-generated-url').val());
|
||||
if (!url) {
|
||||
Toastr.error(__('Split url empty'));
|
||||
return;
|
||||
}
|
||||
Controller.api.copyText(url);
|
||||
});
|
||||
|
||||
$box.on('click', '.btn-open-test', function (e) {
|
||||
e.preventDefault();
|
||||
var url = $.trim($box.find('.split-generated-url').val());
|
||||
if (url) {
|
||||
window.open(url, '_blank');
|
||||
}
|
||||
});
|
||||
|
||||
$box.on('click', '.btn-close-copy-modal', function (e) {
|
||||
e.preventDefault();
|
||||
Layer.close(index);
|
||||
});
|
||||
|
||||
ensureTabSelection($box.find('.tab-pane.active'));
|
||||
refreshGeneratedUrl();
|
||||
}
|
||||
});
|
||||
},
|
||||
buildDomainRadio: function (options) {
|
||||
var domain = Fast.api.escape(options.domain || '');
|
||||
var type = options.type || 'my';
|
||||
var checked = options.checked ? ' checked' : '';
|
||||
var checkedClass = options.checked ? ' is-checked' : '';
|
||||
var statusHtml = '';
|
||||
if (options.showStatus) {
|
||||
statusHtml = '<span class="split-domain-status">'
|
||||
+ '<span class="label ' + Controller.api.statusLabelClass(options.nsStatus) + '">' + __('NS') + ':' + Fast.api.escape(options.nsText || '-') + '</span>'
|
||||
+ '<span class="label ' + Controller.api.statusLabelClass(options.dnsStatus, true) + '">' + __('DNS') + ':' + Fast.api.escape(options.dnsText || '-') + '</span>'
|
||||
+ '</span>';
|
||||
}
|
||||
return '<div class="split-domain-item' + checkedClass + '">'
|
||||
+ '<label class="split-domain-item-label">'
|
||||
+ '<input type="radio" name="split_main_domain" value="' + domain + '" data-type="' + type + '"' + checked + '>'
|
||||
+ '<span class="split-domain-item-body">'
|
||||
+ '<span class="split-domain-name">' + domain + '</span>'
|
||||
+ statusHtml
|
||||
+ '</span>'
|
||||
+ '</label>'
|
||||
+ '</div>';
|
||||
},
|
||||
statusLabelClass: function (status, isDns) {
|
||||
if (isDns) {
|
||||
if (status === 'created') {
|
||||
return 'label-success';
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return 'label-danger';
|
||||
}
|
||||
return 'label-warning';
|
||||
}
|
||||
if (status === 'verified') {
|
||||
return 'label-success';
|
||||
}
|
||||
if (status === 'failed') {
|
||||
return 'label-danger';
|
||||
}
|
||||
return 'label-warning';
|
||||
},
|
||||
buildSplitUrl: function (domain, linkCode) {
|
||||
domain = $.trim(domain || '').replace(/^https?:\/\//i, '').replace(/\/+$/, '');
|
||||
linkCode = $.trim(linkCode || '');
|
||||
if (!domain || !linkCode) {
|
||||
return '';
|
||||
}
|
||||
return 'https://' + domain + '/s/' + linkCode;
|
||||
},
|
||||
copyText: function (text) {
|
||||
if (navigator.clipboard && window.isSecureContext) {
|
||||
navigator.clipboard.writeText(text).then(function () {
|
||||
Toastr.success(__('Copy success'));
|
||||
}).catch(function () {
|
||||
Controller.api.copyTextFallback(text);
|
||||
});
|
||||
return;
|
||||
}
|
||||
Controller.api.copyTextFallback(text);
|
||||
},
|
||||
copyTextFallback: function (text) {
|
||||
var $temp = $('<textarea>').css({position: 'fixed', left: '-9999px', top: '0'}).val(text);
|
||||
$('body').append($temp);
|
||||
$temp[0].select();
|
||||
try {
|
||||
document.execCommand('copy');
|
||||
Toastr.success(__('Copy success'));
|
||||
} catch (e) {
|
||||
Toastr.error(__('Copy failed'));
|
||||
}
|
||||
$temp.remove();
|
||||
},
|
||||
bindLinkCodeInput: function () {
|
||||
var $input = $('#c-link_code');
|
||||
if (!$input.length || $input.prop('readonly')) {
|
||||
return;
|
||||
}
|
||||
$input.on('input', function () {
|
||||
var val = $(this).val().replace(/[^a-zA-Z]/g, '').toLowerCase().slice(0, 9);
|
||||
if ($(this).val() !== val) {
|
||||
$(this).val(val);
|
||||
}
|
||||
});
|
||||
},
|
||||
bindevent: function () {
|
||||
Form.api.bindevent($("form[role=form]"));
|
||||
}
|
||||
}
|
||||
};
|
||||
return Controller;
|
||||
});
|
||||
+221
@@ -0,0 +1,221 @@
|
||||
define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {
|
||||
|
||||
var Controller = {
|
||||
index: function () {
|
||||
Table.api.init({
|
||||
extend: {
|
||||
index_url: 'split.number/index' + location.search,
|
||||
add_url: 'split.number/add',
|
||||
edit_url: 'split.number/edit',
|
||||
del_url: 'split.number/del',
|
||||
multi_url: 'split.number/multi',
|
||||
table: 'split_number',
|
||||
}
|
||||
});
|
||||
|
||||
var table = $("#table");
|
||||
|
||||
table.bootstrapTable({
|
||||
url: $.fn.bootstrapTable.defaults.extend.index_url,
|
||||
pk: 'id',
|
||||
sortName: 'id',
|
||||
sortOrder: 'desc',
|
||||
fixedColumns: true,
|
||||
fixedRightNumber: 1,
|
||||
columns: [
|
||||
[
|
||||
{checkbox: true},
|
||||
{field: 'id', title: __('Id'), sortable: true},
|
||||
{
|
||||
field: 'link_url_text',
|
||||
title: __('Link_url'),
|
||||
operate: false,
|
||||
formatter: Table.api.formatter.content
|
||||
},
|
||||
{field: 'ticket_name', title: __('Ticket_name'), operate: 'LIKE'},
|
||||
{field: 'number', title: __('Number'), operate: 'LIKE'},
|
||||
{
|
||||
field: 'number_type',
|
||||
title: __('Number_type'),
|
||||
searchList: Config.numberTypeList,
|
||||
formatter: Table.api.formatter.normal
|
||||
},
|
||||
{field: 'visit_count', title: __('Visit_count'), operate: false, sortable: true},
|
||||
{field: 'inbound_count', title: __('Inbound_count'), operate: false, sortable: true},
|
||||
{
|
||||
field: 'platform_status',
|
||||
title: __('Platform_status'),
|
||||
searchList: Config.platformStatusList,
|
||||
formatter: Controller.api.formatter.platformStatus
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: __('Status'),
|
||||
searchList: Config.statusList,
|
||||
formatter: Table.api.formatter.toggle,
|
||||
yes: 'normal',
|
||||
no: 'hidden'
|
||||
},
|
||||
{
|
||||
field: 'createtime',
|
||||
title: __('Createtime'),
|
||||
operate: 'RANGE',
|
||||
addclass: 'datetimerange',
|
||||
autocomplete: false,
|
||||
formatter: Table.api.formatter.datetime,
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
field: 'operate',
|
||||
title: __('Operate'),
|
||||
table: table,
|
||||
events: Table.api.events.operate,
|
||||
formatter: Table.api.formatter.operate
|
||||
}
|
||||
]
|
||||
]
|
||||
});
|
||||
|
||||
table.on('check.bs.table uncheck.bs.table check-all.bs.table uncheck-all.bs.table', function () {
|
||||
var ids = Table.api.selectedids(table);
|
||||
$('.btn-batch-update-status').toggleClass('btn-disabled disabled', ids.length === 0);
|
||||
});
|
||||
|
||||
$('.btn-batch-update-status').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if ($(this).hasClass('disabled')) {
|
||||
return false;
|
||||
}
|
||||
var ids = Table.api.selectedids(table);
|
||||
if (!ids.length) {
|
||||
Toastr.warning(__('Please select at least one item'));
|
||||
return false;
|
||||
}
|
||||
Controller.api.openBatchUpdateModal(ids, table);
|
||||
});
|
||||
|
||||
Table.api.bindevent(table);
|
||||
},
|
||||
add: function () {
|
||||
Controller.api.bindevent();
|
||||
},
|
||||
edit: function () {
|
||||
Controller.api.bindevent();
|
||||
},
|
||||
api: {
|
||||
formatter: {
|
||||
platformStatus: function (value, row) {
|
||||
var text = row.platform_status_text != null && row.platform_status_text !== ''
|
||||
? String(row.platform_status_text)
|
||||
: (Config.platformStatusList && Config.platformStatusList[value] ? Config.platformStatusList[value] : value);
|
||||
var cls = value === 'online' ? 'success' : (value === 'offline' ? 'default' : 'warning');
|
||||
return '<span class="text-' + cls + '">' + Fast.api.escape(text || '') + '</span>';
|
||||
}
|
||||
},
|
||||
bindevent: function () {
|
||||
Form.api.bindevent($('form[role=form]'));
|
||||
Controller.api.fixSelectPlaceholder();
|
||||
Controller.api.bindNumberTypeToggle();
|
||||
},
|
||||
fixSelectPlaceholder: function () {
|
||||
var text = __('Please select');
|
||||
if (!text || text === 'Please select' || text === 'Please Select') {
|
||||
text = '请选择';
|
||||
}
|
||||
$('#c-split_link_id').each(function () {
|
||||
var $el = $(this);
|
||||
$el.attr({'data-none-selected-text': text, 'title': text});
|
||||
$el.find('option[value=""]').first().text(text);
|
||||
if ($el.data('selectpicker')) {
|
||||
$el.selectpicker('render');
|
||||
}
|
||||
});
|
||||
},
|
||||
bindNumberTypeToggle: function () {
|
||||
var $type = $('#c-number_type');
|
||||
var $wrap = $('.split-number-type-custom');
|
||||
var $custom = $('#c-number_type_custom');
|
||||
if (!$type.length) {
|
||||
return;
|
||||
}
|
||||
var toggle = function () {
|
||||
var val = $type.val();
|
||||
if (val === 'custom') {
|
||||
$wrap.removeClass('hide');
|
||||
$custom.attr('data-rule', 'required');
|
||||
} else {
|
||||
$wrap.addClass('hide');
|
||||
$custom.removeAttr('data-rule');
|
||||
if (!$wrap.closest('form').attr('id') || $('#edit-form').length === 0) {
|
||||
$custom.val('');
|
||||
}
|
||||
}
|
||||
};
|
||||
$type.on('changed.bs.select change', toggle);
|
||||
toggle();
|
||||
},
|
||||
openBatchUpdateModal: function (ids, table) {
|
||||
var statusHtml = '';
|
||||
$.each(Config.statusList || {}, function (key, label) {
|
||||
var checked = key === 'normal' ? ' checked' : '';
|
||||
statusHtml += '<label class="radio-inline"><input type="radio" name="batch_status" value="' + key + '"' + checked + '> ' + label + '</label>';
|
||||
});
|
||||
var manualHtml = '';
|
||||
$.each(Config.manualManageList || {}, function (key, label) {
|
||||
var checked = key === '0' ? ' checked' : '';
|
||||
manualHtml += '<label class="radio-inline"><input type="radio" name="batch_manual_manage" value="' + key + '"' + checked + '> ' + label + '</label>';
|
||||
});
|
||||
var html = [
|
||||
'<div class="split-batch-update-modal" style="padding:18px 22px;">',
|
||||
' <div class="form-group">',
|
||||
' <label class="control-label">' + __('Batch selected count') + '</label>',
|
||||
' <p class="form-control-static" style="font-size:18px;font-weight:600;margin:0;">' + ids.length + ' ' + __('Unit count') + '</p>',
|
||||
' </div>',
|
||||
' <div class="form-group">',
|
||||
' <label class="control-label">' + __('Status') + '</label>',
|
||||
' <div>' + statusHtml + '</div>',
|
||||
' </div>',
|
||||
' <div class="form-group" style="margin-bottom:0;">',
|
||||
' <label class="control-label">' + __('Manual_manage') + '</label>',
|
||||
' <div>' + manualHtml + '</div>',
|
||||
' </div>',
|
||||
'</div>'
|
||||
].join('');
|
||||
|
||||
Layer.open({
|
||||
type: 1,
|
||||
title: __('Batch update title'),
|
||||
area: ['420px', 'auto'],
|
||||
shadeClose: false,
|
||||
content: html,
|
||||
btn: [__('OK'), __('Cancel')],
|
||||
yes: function (index, layero) {
|
||||
var status = layero.find('input[name="batch_status"]:checked').val();
|
||||
var manualManage = layero.find('input[name="batch_manual_manage"]:checked').val();
|
||||
if (typeof status === 'undefined' || typeof manualManage === 'undefined') {
|
||||
Toastr.error(__('Invalid parameters'));
|
||||
return false;
|
||||
}
|
||||
Fast.api.ajax({
|
||||
url: 'split.number/batchupdate',
|
||||
type: 'post',
|
||||
data: {
|
||||
ids: ids.join(','),
|
||||
status: status,
|
||||
manual_manage: manualManage
|
||||
}
|
||||
}, function () {
|
||||
Layer.close(index);
|
||||
table.bootstrapTable('refresh');
|
||||
$('.btn-batch-update-status').addClass('btn-disabled disabled');
|
||||
Toastr.success(__('Batch update success'));
|
||||
});
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
return Controller;
|
||||
});
|
||||
+393
@@ -0,0 +1,393 @@
|
||||
define(['jquery', 'bootstrap', 'backend', 'table', 'form'], function ($, undefined, Backend, Table, Form) {
|
||||
|
||||
var Controller = {
|
||||
index: function () {
|
||||
Table.api.init({
|
||||
extend: {
|
||||
index_url: 'split.ticket/index' + location.search,
|
||||
add_url: 'split.ticket/add',
|
||||
edit_url: 'split.ticket/edit',
|
||||
del_url: 'split.ticket/del',
|
||||
multi_url: 'split.ticket/multi',
|
||||
table: 'split_ticket',
|
||||
}
|
||||
});
|
||||
|
||||
var table = $("#table");
|
||||
|
||||
table.bootstrapTable({
|
||||
url: $.fn.bootstrapTable.defaults.extend.index_url,
|
||||
pk: 'id',
|
||||
sortName: 'id',
|
||||
sortOrder: 'desc',
|
||||
fixedColumns: true,
|
||||
fixedRightNumber: 1,
|
||||
columns: [
|
||||
[
|
||||
{checkbox: true},
|
||||
{
|
||||
field: 'ticket_type',
|
||||
title: __('Ticket_type'),
|
||||
searchList: Config.ticketTypeList,
|
||||
operate: false,
|
||||
formatter: Controller.api.formatter.ticketTypePlain
|
||||
},
|
||||
{field: 'ticket_name', title: __('Ticket_name'), operate: 'LIKE'},
|
||||
{
|
||||
field: 'link_code_text',
|
||||
title: __('Split_link_id'),
|
||||
operate: false,
|
||||
formatter: Controller.api.formatter.splitLinkCode
|
||||
},
|
||||
{
|
||||
field: 'start_time_text',
|
||||
title: __('Start_time'),
|
||||
operate: 'RANGE',
|
||||
addclass: 'datetimerange',
|
||||
autocomplete: false,
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
field: 'end_time_text',
|
||||
title: __('End_time'),
|
||||
operate: 'RANGE',
|
||||
addclass: 'datetimerange',
|
||||
autocomplete: false,
|
||||
sortable: true
|
||||
},
|
||||
{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',
|
||||
title: __('Ticket_progress'),
|
||||
operate: false,
|
||||
formatter: Table.api.formatter.content
|
||||
},
|
||||
{
|
||||
field: 'inbound_ratio_text',
|
||||
title: __('Inbound_ratio'),
|
||||
operate: false,
|
||||
formatter: Table.api.formatter.content
|
||||
},
|
||||
{
|
||||
field: 'speed_per_hour',
|
||||
title: __('Speed_per_hour'),
|
||||
operate: false,
|
||||
formatter: Controller.api.formatter.speedPerHour
|
||||
},
|
||||
{
|
||||
field: 'number_count',
|
||||
title: __('Number_count'),
|
||||
operate: false,
|
||||
formatter: Controller.api.formatter.numberCount
|
||||
},
|
||||
{
|
||||
field: 'sync_display_text',
|
||||
title: __('Sync_status'),
|
||||
operate: false,
|
||||
formatter: Controller.api.formatter.syncDisplay
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: __('Status'),
|
||||
searchList: Config.statusList,
|
||||
formatter: Table.api.formatter.toggle,
|
||||
yes: 'normal',
|
||||
no: 'hidden'
|
||||
},
|
||||
{
|
||||
field: 'createtime',
|
||||
title: __('Createtime'),
|
||||
operate: 'RANGE',
|
||||
addclass: 'datetimerange',
|
||||
autocomplete: false,
|
||||
formatter: Table.api.formatter.datetime,
|
||||
sortable: true
|
||||
},
|
||||
{
|
||||
field: 'operate',
|
||||
title: __('Operate'),
|
||||
table: table,
|
||||
events: Table.api.events.operate,
|
||||
formatter: Table.api.formatter.operate
|
||||
}
|
||||
]
|
||||
]
|
||||
});
|
||||
|
||||
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);
|
||||
$('.btn-sync').toggleClass('btn-disabled disabled', ids.length === 0);
|
||||
});
|
||||
|
||||
$('.btn-sync').on('click', function (e) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
var ids = Table.api.selectedids(table);
|
||||
if (!ids.length) {
|
||||
Toastr.error(__('Please select at least one record'));
|
||||
return false;
|
||||
}
|
||||
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);
|
||||
Toastr.info(syncBackgroundMsg);
|
||||
Controller.api.startBackgroundSync(table, ids, true);
|
||||
});
|
||||
return false;
|
||||
});
|
||||
},
|
||||
add: function () {
|
||||
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: {
|
||||
/**
|
||||
* 工单类型:纯文本展示,无链接/标签样式
|
||||
*/
|
||||
ticketTypePlain: function (value, row) {
|
||||
var text = row.ticket_type_text != null && row.ticket_type_text !== ''
|
||||
? String(row.ticket_type_text)
|
||||
: (value != null ? String(value) : '');
|
||||
if (text === '') {
|
||||
return '<span class="text-muted">-</span>';
|
||||
}
|
||||
return '<span class="split-ticket-type-plain">' + Fast.api.escape(text) + '</span>';
|
||||
},
|
||||
/**
|
||||
* 分流链接:纯文本 + 边框背景标记,不可点击
|
||||
*/
|
||||
splitLinkCode: function (value) {
|
||||
value = value == null ? '' : String(value);
|
||||
if ($.trim(value) === '') {
|
||||
return '<span class="text-muted">-</span>';
|
||||
}
|
||||
var safe = Fast.api.escape(value);
|
||||
return '<span class="split-ticket-link-badge" style="display:inline-block;max-width:100%;padding:2px 8px;font-size:12px;line-height:1.5;color:#555;background:#f5f5f5;border:1px solid #ddd;border-radius:3px;word-break:break-all;">'
|
||||
+ safe + '</span>';
|
||||
},
|
||||
speedPerHour: function (value) {
|
||||
var num = parseFloat(value);
|
||||
if (isNaN(num)) {
|
||||
return '0.00';
|
||||
}
|
||||
return num.toFixed(2);
|
||||
},
|
||||
numberCount: function (value, row) {
|
||||
var total = parseInt(value, 10) || 0;
|
||||
var offline = parseInt(row.number_offline_count, 10) || 0;
|
||||
var banned = parseInt(row.number_banned_count, 10) || 0;
|
||||
var tip = __('Number_count_detail').replace('%s', offline).replace('%s', banned);
|
||||
if (offline > 0 || banned > 0) {
|
||||
return '<span data-toggle="tooltip" title="' + Fast.api.escape(tip) + '">' + total + '</span>';
|
||||
}
|
||||
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 = 'danger';
|
||||
if (row.sync_status === 'success') {
|
||||
color = 'success';
|
||||
} else if (row.sync_status === 'pending') {
|
||||
color = 'muted';
|
||||
}
|
||||
return '<span class="text-' + color + '">' + Fast.api.escape(text) + '</span>';
|
||||
},
|
||||
syncingDisplayHtml: function () {
|
||||
var label = (typeof Config.syncInProgressMsg !== 'undefined' && Config.syncInProgressMsg)
|
||||
? Config.syncInProgressMsg
|
||||
: '同步中';
|
||||
return '<span class="split-ticket-sync-pending">'
|
||||
+ '<i class="fa fa-refresh fa-spin"></i>'
|
||||
+ '<span class="split-ticket-sync-label">' + Fast.api.escape(label) + '</span>'
|
||||
+ '</span>';
|
||||
}
|
||||
},
|
||||
bindevent: function () {
|
||||
Form.api.bindevent($('form[role=form]'));
|
||||
Controller.api.fixSelectPlaceholder();
|
||||
Controller.api.bindNumberTypeToggle();
|
||||
Controller.api.bindEndTimeCheck();
|
||||
},
|
||||
/**
|
||||
* selectpicker 空选项文案改为中文「请选择」
|
||||
*/
|
||||
fixSelectPlaceholder: function () {
|
||||
var text = __('Please select');
|
||||
if (!text || text === 'Please select' || text === 'Please Select') {
|
||||
text = '请选择';
|
||||
}
|
||||
$('#c-ticket_type, #c-split_link_id').each(function () {
|
||||
var $el = $(this);
|
||||
$el.attr({'data-none-selected-text': text, 'title': text});
|
||||
$el.find('option[value=""]').first().text(text);
|
||||
if ($el.data('selectpicker')) {
|
||||
$el.selectpicker('render');
|
||||
}
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 号码类型为 custom 时显示自定义输入框
|
||||
*/
|
||||
bindNumberTypeToggle: function () {
|
||||
var $type = $('#c-number_type');
|
||||
var $wrap = $('.split-number-type-custom');
|
||||
var $custom = $('#c-number_type_custom');
|
||||
if (!$type.length) {
|
||||
return;
|
||||
}
|
||||
var toggle = function () {
|
||||
var val = $type.val();
|
||||
if (val === 'custom') {
|
||||
$wrap.removeClass('hide');
|
||||
$custom.attr('data-rule', 'required');
|
||||
} else {
|
||||
$wrap.addClass('hide');
|
||||
$custom.removeAttr('data-rule');
|
||||
$custom.val('');
|
||||
}
|
||||
if ($custom.data('validator')) {
|
||||
$custom.trigger('validate');
|
||||
}
|
||||
};
|
||||
$type.on('changed.bs.select change', toggle);
|
||||
toggle();
|
||||
},
|
||||
/**
|
||||
* 前端预校验:到期时间须晚于开始时间(后端为准)
|
||||
*/
|
||||
bindEndTimeCheck: function () {
|
||||
var $form = $('form[role=form]');
|
||||
var $start = $('#c-start_time');
|
||||
var $end = $('#c-end_time');
|
||||
if (!$start.length || !$end.length) {
|
||||
return;
|
||||
}
|
||||
var parseTs = function (str) {
|
||||
str = $.trim(str || '');
|
||||
if (!str) {
|
||||
return 0;
|
||||
}
|
||||
var d = new Date(str.replace(/-/g, '/'));
|
||||
return isNaN(d.getTime()) ? 0 : Math.floor(d.getTime() / 1000);
|
||||
};
|
||||
$form.on('submit', function (e) {
|
||||
var s = parseTs($start.val());
|
||||
var en = parseTs($end.val());
|
||||
if (s > 0 && en > 0 && en <= s) {
|
||||
e.preventDefault();
|
||||
e.stopImmediatePropagation();
|
||||
Layer.msg(__('End time must after start'));
|
||||
return false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
return Controller;
|
||||
});
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
9
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
8
|
||||
Executable
+323
@@ -0,0 +1,323 @@
|
||||
CHANGELOG
|
||||
=========
|
||||
|
||||
2.13.0 (2022-08-05)
|
||||
-------------------
|
||||
|
||||
* The model class names are no longer constructed by concatenating strings.
|
||||
This change was made to improve support for tools like PHP-Scoper.
|
||||
Reported by Andrew Mead. GitHub #194.
|
||||
* Box 4.0.1 is now used to generate the `geoip2.phar` file.
|
||||
|
||||
2.12.2 (2021-11-30)
|
||||
-------------------
|
||||
|
||||
* The `geoip2.phar` now works when included from another directory.
|
||||
Reported by Eduardo Ruiz. GitHub #179.
|
||||
|
||||
2.12.1 (2021-11-23)
|
||||
-------------------
|
||||
|
||||
* The `geoip2.phar` included in 2.12.0 would only work in CLI applications.
|
||||
This was due to a change in Box 3.x. The Phar should now work in all
|
||||
applications. This release only affects users of the Phar file.
|
||||
|
||||
2.12.0 (2021-11-18)
|
||||
-------------------
|
||||
|
||||
* Support for mobile country code (MCC) and mobile network codes (MNC) was
|
||||
added for the GeoIP2 ISP and Enterprise databases as well as the GeoIP2
|
||||
City and Insights web services. `$mobileCountryCode` and
|
||||
`$mobileNetworkCode` properties were added to `GeoIp2\Model\Isp`
|
||||
for the GeoIP2 ISP database and `GeoIp2\Record\Traits` for the Enterprise
|
||||
database and the GeoIP2 City and Insights web services. We expect this data
|
||||
to be available by late January, 2022.
|
||||
* `geoip2.phar` is now generated with Box 3.x.
|
||||
|
||||
2.11.0 (2020-10-01)
|
||||
-------------------
|
||||
|
||||
* IMPORTANT: PHP 7.2 or greater is now required.
|
||||
* Added the `isResidentialProxy` property to `GeoIp2\Model\AnonymousIP` and
|
||||
`GeoIp2\Record\Traits`.
|
||||
* Additional type hints have been added.
|
||||
|
||||
2.10.0 (2019-12-12)
|
||||
-------------------
|
||||
|
||||
* PHP 5.6 or greater is now required.
|
||||
* The `network` property was added to `GeoIp2\Record\Traits`,
|
||||
`GeoIp2\Model\AnonymousIp`, `GeoIp2\Model\Asn`,
|
||||
`GeoIp2\Model\ConnectionType`, `Geoip2\Model\Domain`,
|
||||
and `GeoIp2\Model\Isp`. This is a string in CIDR format representing the
|
||||
largest network where all of the properties besides `ipAddress` have the
|
||||
same value.
|
||||
* Updated documentation of anonymizer properties - `isAnonymousVpn`
|
||||
and `isHostingProvider` - to be more descriptive.
|
||||
* The `userCount` property was added to `GeoIp2\Record\Traits`. This is an
|
||||
integer which indicates the estimated number of users sharing the
|
||||
IP/network during the past 24 hours. This output is available from GeoIP2
|
||||
Precision Insights.
|
||||
* The `staticIpScore` property was added to `GeoIp2\Record\Traits`. This is
|
||||
a float which indicates how static or dynamic an IP address is. This
|
||||
output is available from GeoIP2 Precision Insights.
|
||||
|
||||
2.9.0 (2018-04-10)
|
||||
------------------
|
||||
|
||||
* Refer to account IDs using the terminology "account" rather than "user".
|
||||
|
||||
2.8.0 (2018-01-18)
|
||||
------------------
|
||||
|
||||
* The `isInEuropeanUnion` property was added to `GeoIp2\Record\Country`
|
||||
and `GeoIp2\Record\RepresentedCountry`. This property is `true` if the
|
||||
country is a member state of the European Union.
|
||||
|
||||
2.7.0 (2017-10-27)
|
||||
------------------
|
||||
|
||||
* The following new anonymizer properties were added to `GeoIp2\Record\Traits`
|
||||
for use with GeoIP2 Precision Insights: `isAnonymous`, `isAnonymousVpn`,
|
||||
`isHostingProvider`, `isPublicProxy`, and `isTorExitNode`.
|
||||
|
||||
2.6.0 (2017-07-10)
|
||||
-----------------
|
||||
|
||||
* Code clean-up and tidying.
|
||||
* Set minimum required PHP version to 5.4 in `composer.json`. Previously,
|
||||
5.3 would work but was not tested. Now 5.4 is hard minimum version.
|
||||
|
||||
2.5.0 (2017-05-08)
|
||||
------------------
|
||||
|
||||
* Support for PHP 5.3 was dropped.
|
||||
* Added support for GeoLite2 ASN database.
|
||||
|
||||
2.4.5 (2017-01-31)
|
||||
------------------
|
||||
|
||||
* Additional error checking on the data returned from `MaxMind\Db\Reader`
|
||||
was added to help detect corrupt databases. GitHub #83.
|
||||
|
||||
2.4.4 (2016-10-11)
|
||||
------------------
|
||||
|
||||
* `isset()` on `mostSpecificSubdivision` attribute now returns the
|
||||
correct value. Reported by Juan Francisco Giordana. GitHub #81.
|
||||
|
||||
2.4.3 (2016-10-11)
|
||||
------------------
|
||||
|
||||
* `isset()` on `name` attribute now returns the correct value. Reported by
|
||||
Juan Francisco Giordana. GitHub #79.
|
||||
|
||||
2.4.2 (2016-08-17)
|
||||
------------------
|
||||
|
||||
* Updated documentation to clarify what the accuracy radius refers to.
|
||||
* Upgraded `maxmind/web-service-common` to 0.3.0. This version uses
|
||||
`composer/ca-bundle` rather than our own CA bundle. GitHub #75.
|
||||
* Improved PHP documentation generation.
|
||||
|
||||
2.4.1 (2016-06-10)
|
||||
------------------
|
||||
|
||||
* Corrected type annotations in documentation. GitHub #66.
|
||||
* Updated documentation to reflect that the accuracy radius is now included
|
||||
in City.
|
||||
* Upgraded web service client, which supports setting a proxy. GitHub #59.
|
||||
|
||||
2.4.0 (2016-04-15)
|
||||
------------------
|
||||
|
||||
* Added support for the GeoIP2 Enterprise database.
|
||||
|
||||
2.3.3 (2015-09-24)
|
||||
------------------
|
||||
|
||||
* Corrected case on `JsonSerializable` interface. Reported by Axel Etcheverry.
|
||||
GitHub #56.
|
||||
|
||||
2.3.2 (2015-09-23)
|
||||
------------------
|
||||
|
||||
* `JsonSerializable` compatibility interface was moved to `GeoIp2\Compat`
|
||||
rather than the global namespace to prevent autoloading issues. Reported by
|
||||
Tomas Buteler. GitHub #54.
|
||||
* Missing documentation for the `$postal` property was added to the
|
||||
`GeoIp2\Model\City` class. Fix by Roy Sindre Norangshol. GitHub #51.
|
||||
* In the Phar distribution, source files for this module no longer have their
|
||||
documentation stripped, allowing IDE introspection to work properly.
|
||||
Reported by Dominic Black. GitHub #52.
|
||||
|
||||
2.3.1 (2015-06-30)
|
||||
------------------
|
||||
|
||||
* Updated `maxmind/web-service-common` to version with fixes for PHP 5.3 and
|
||||
5.4.
|
||||
|
||||
2.3.0 (2015-06-29)
|
||||
------------------
|
||||
|
||||
* Support for demographics fields `averageIncome` and `populationDensity` in
|
||||
the `Location` record, returned by the Insights endpoint.
|
||||
* The `isAnonymousProxy` and `isSatelliteProvider` properties on
|
||||
`GeoIP2\Record\Traits` have been deprecated. Please use our [GeoIP2
|
||||
Anonymous IP database](https://www.maxmind.com/en/geoip2-anonymous-ip-database)
|
||||
to determine whether an IP address is used by an anonymizing service.
|
||||
|
||||
2.2.0-beta1 (2015-06-09)
|
||||
------------------------
|
||||
|
||||
* Typo fix in documentation.
|
||||
|
||||
2.2.0-alpha2 (2015-06-01)
|
||||
-------------------------
|
||||
|
||||
* `maxmind-ws/web-service-common` was renamed to `maxmind/web-service-common`.
|
||||
|
||||
2.2.0-alpha1 (2015-05-22)
|
||||
-------------------------
|
||||
|
||||
* The library no longer uses Guzzle and instead uses curl directly.
|
||||
* Support for `timeout` and `connectTimout` were added to the `$options` array
|
||||
passed to the `GeoIp2\WebService\Client` constructor. Pull request by Will
|
||||
Bradley. GitHub #36.
|
||||
|
||||
2.1.1 (2014-12-03)
|
||||
------------------
|
||||
|
||||
* The 2.1.0 Phar builds included a shebang line, causing issues when loading
|
||||
it as a library. This has been corrected. GitHub #33.
|
||||
|
||||
2.1.0 (2014-10-29)
|
||||
------------------
|
||||
|
||||
* Update ApiGen dependency to version that isn't broken on case sensitive
|
||||
file systems.
|
||||
* Added support for the GeoIP2 Anonymous IP database. The
|
||||
`GeoIP2\Database\Reader` class now has an `anonymousIp` method which returns
|
||||
a `GeoIP2\Model\AnonymousIp` object.
|
||||
* Boolean attributes like those in the `GeoIP2\Record\Traits` class now return
|
||||
`false` instead of `null` when they were not true.
|
||||
|
||||
2.0.0 (2014-09-22)
|
||||
------------------
|
||||
|
||||
* First production release.
|
||||
|
||||
0.9.0 (2014-09-15)
|
||||
------------------
|
||||
|
||||
* IMPORTANT: The deprecated `omni()` and `cityIspOrg()` methods have been
|
||||
removed from `GeoIp2\WebService\Client`.
|
||||
|
||||
0.8.1 (2014-09-12)
|
||||
------------------
|
||||
|
||||
* The check added to the `GeoIP2\Database\Reader` lookup methods in 0.8.0 did
|
||||
not work with the GeoIP2 City Database Subset by Continent with World
|
||||
Countries. This has been fixed. Fixes GitHub issue #23.
|
||||
|
||||
0.8.0 (2014-09-10)
|
||||
------------------
|
||||
|
||||
* The `GeoIp2\Database\Reader` lookup methods (e.g., `city()`, `isp()`) now
|
||||
throw a `BadMethodCallException` if they are used with a database that
|
||||
does not match the method. In particular, doing a `city()` lookup on a
|
||||
GeoIP2 Country database will result in an exception, and vice versa.
|
||||
* A `metadata()` method has been added to the `GeoIP2\Database\Reader` class.
|
||||
This returns a `MaxMind\Db\Reader\Metadata` class with information about the
|
||||
database.
|
||||
* The name attribute was missing from the RepresentedCountry class.
|
||||
|
||||
0.7.0 (2014-07-22)
|
||||
------------------
|
||||
|
||||
* The web service client API has been updated for the v2.1 release of the web
|
||||
service. In particular, the `cityIspOrg` and `omni` methods on
|
||||
`GeoIp2\WebService\Client` should be considered deprecated. The `city`
|
||||
method now provides all of the data formerly provided by `cityIspOrg`, and
|
||||
the `omni` method has been replaced by the `insights` method.
|
||||
* Support was added for GeoIP2 Connection Type, Domain and ISP databases.
|
||||
|
||||
|
||||
0.6.3 (2014-05-12)
|
||||
------------------
|
||||
|
||||
* With the previous Phar builds, some users received `phar error: invalid url
|
||||
or non-existent phar` errors. The correct alias is now used for the Phar,
|
||||
and this should no longer be an issue.
|
||||
|
||||
0.6.2 (2014-05-08)
|
||||
------------------
|
||||
|
||||
* The Phar build was broken with Guzzle 3.9.0+. This has been fixed.
|
||||
|
||||
0.6.1 (2014-05-01)
|
||||
------------------
|
||||
|
||||
* This API now officially supports HHVM.
|
||||
* The `maxmind-db/reader` dependency was updated to a version that does not
|
||||
require BC Math.
|
||||
* The Composer compatibility autoload rules are now targeted more narrowly.
|
||||
* A `box.json` file is included to build a Phar package.
|
||||
|
||||
0.6.0 (2014-02-19)
|
||||
------------------
|
||||
|
||||
* This API is now licensed under the Apache License, Version 2.0.
|
||||
* Model and record classes now implement `JsonSerializable`.
|
||||
* `isset` now works with model and record classes.
|
||||
|
||||
0.5.0 (2013-10-21)
|
||||
------------------
|
||||
|
||||
* Renamed $languages constructor parameters to $locales for both the Client
|
||||
and Reader classes.
|
||||
* Documentation and code clean-up (Ben Morel).
|
||||
* Added the interface `GeoIp2\ProviderInterface`, which is implemented by both
|
||||
`\GeoIp2\Database\Reader` and `\GeoIp2\WebService\Client`.
|
||||
|
||||
0.4.0 (2013-07-16)
|
||||
------------------
|
||||
|
||||
* This is the first release with the GeoIP2 database reader. Please see the
|
||||
`README.md` file and the `\GeoIp2\Database\Reader` class.
|
||||
* The general exception classes were replaced with specific exception classes
|
||||
representing particular types of errors, such as an authentication error.
|
||||
|
||||
0.3.0 (2013-07-12)
|
||||
------------------
|
||||
|
||||
* In namespaces and class names, "GeoIP2" was renamed to "GeoIp2" to improve
|
||||
consistency.
|
||||
|
||||
0.2.1 (2013-06-10)
|
||||
------------------
|
||||
|
||||
* First official beta release.
|
||||
* Documentation updates and corrections.
|
||||
|
||||
0.2.0 (2013-05-29)
|
||||
------------------
|
||||
|
||||
* `GenericException` was renamed to `GeoIP2Exception`.
|
||||
* We now support more languages. The new languages are de, es, fr, and pt-BR.
|
||||
* The REST API now returns a record with data about your account. There is
|
||||
a new `GeoIP\Records\MaxMind` class for this data.
|
||||
* The `continentCode` attribute on `Continent` was renamed to `code`.
|
||||
* Documentation updates.
|
||||
|
||||
0.1.1 (2013-05-14)
|
||||
------------------
|
||||
|
||||
* Updated Guzzle version requirement.
|
||||
* Fixed Composer example in README.md.
|
||||
|
||||
|
||||
0.1.0 (2013-05-13)
|
||||
------------------
|
||||
|
||||
* Initial release.
|
||||
+202
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
+442
@@ -0,0 +1,442 @@
|
||||
# GeoIP2 PHP API #
|
||||
|
||||
## Description ##
|
||||
|
||||
This package provides an API for the GeoIP2 and GeoLite2
|
||||
[web services](https://dev.maxmind.com/geoip/docs/web-services?lang=en) and
|
||||
[databases](https://dev.maxmind.com/geoip/docs/databases?lang=en).
|
||||
|
||||
## Install via Composer ##
|
||||
|
||||
We recommend installing this package with [Composer](https://getcomposer.org/).
|
||||
|
||||
### Download Composer ###
|
||||
|
||||
To download Composer, run in the root directory of your project:
|
||||
|
||||
```bash
|
||||
curl -sS https://getcomposer.org/installer | php
|
||||
```
|
||||
|
||||
You should now have the file `composer.phar` in your project directory.
|
||||
|
||||
### Install Dependencies ###
|
||||
|
||||
Run in your project root:
|
||||
|
||||
```sh
|
||||
php composer.phar require geoip2/geoip2:~2.0
|
||||
```
|
||||
|
||||
You should now have the files `composer.json` and `composer.lock` as well as
|
||||
the directory `vendor` in your project directory. If you use a version control
|
||||
system, `composer.json` should be added to it.
|
||||
|
||||
### Require Autoloader ###
|
||||
|
||||
After installing the dependencies, you need to require the Composer autoloader
|
||||
from your code:
|
||||
|
||||
```php
|
||||
require 'vendor/autoload.php';
|
||||
```
|
||||
|
||||
## Install via Phar ##
|
||||
|
||||
Although we strongly recommend using Composer, we also provide a
|
||||
[phar archive](https://php.net/manual/en/book.phar.php) containing most of the
|
||||
dependencies for GeoIP2. Our latest phar archive is available on
|
||||
[our releases page](https://github.com/maxmind/GeoIP2-php/releases).
|
||||
|
||||
### Install Dependencies ###
|
||||
|
||||
In order to use the phar archive, you must have the PHP
|
||||
[Phar extension](https://php.net/manual/en/book.phar.php) installed and
|
||||
enabled.
|
||||
|
||||
If you will be making web service requests, you must have the PHP
|
||||
[cURL extension](https://php.net/manual/en/book.curl.php)
|
||||
installed to use this archive. For Debian based distributions, this can
|
||||
typically be found in the the `php-curl` package. For other operating
|
||||
systems, please consult the relevant documentation. After installing the
|
||||
extension you may need to restart your web server.
|
||||
|
||||
If you are missing this extension, you will see errors like the following:
|
||||
|
||||
```
|
||||
PHP Fatal error: Uncaught Error: Call to undefined function MaxMind\WebService\curl_version()
|
||||
```
|
||||
|
||||
### Require Package ###
|
||||
|
||||
To use the archive, just require it from your script:
|
||||
|
||||
```php
|
||||
require 'geoip2.phar';
|
||||
```
|
||||
|
||||
## Optional C Extension ##
|
||||
|
||||
The [MaxMind DB API](https://github.com/maxmind/MaxMind-DB-Reader-php)
|
||||
includes an optional C extension that you may install to dramatically increase
|
||||
the performance of lookups in GeoIP2 or GeoLite2 databases. To install, please
|
||||
follow the instructions included with that API.
|
||||
|
||||
The extension has no effect on web-service lookups.
|
||||
|
||||
## IP Geolocation Usage ##
|
||||
|
||||
IP geolocation is inherently imprecise. Locations are often near the center of
|
||||
the population. Any location provided by a GeoIP2 database or web service
|
||||
should not be used to identify a particular address or household.
|
||||
|
||||
## Database Reader ##
|
||||
|
||||
### Usage ###
|
||||
|
||||
To use this API, you must create a new `\GeoIp2\Database\Reader` object with
|
||||
the path to the database file as the first argument to the constructor. You
|
||||
may then call the method corresponding to the database you are using.
|
||||
|
||||
If the lookup succeeds, the method call will return a model class for the
|
||||
record in the database. This model in turn contains multiple container
|
||||
classes for the different parts of the data such as the city in which the
|
||||
IP address is located.
|
||||
|
||||
If the record is not found, a `\GeoIp2\Exception\AddressNotFoundException`
|
||||
is thrown. If the database is invalid or corrupt, a
|
||||
`\MaxMind\Db\InvalidDatabaseException` will be thrown.
|
||||
|
||||
See the API documentation for more details.
|
||||
|
||||
### City Example ###
|
||||
|
||||
```php
|
||||
<?php
|
||||
require_once 'vendor/autoload.php';
|
||||
use GeoIp2\Database\Reader;
|
||||
|
||||
// This creates the Reader object, which should be reused across
|
||||
// lookups.
|
||||
$reader = new Reader('/usr/local/share/GeoIP/GeoIP2-City.mmdb');
|
||||
|
||||
// Replace "city" with the appropriate method for your database, e.g.,
|
||||
// "country".
|
||||
$record = $reader->city('128.101.101.101');
|
||||
|
||||
print($record->country->isoCode . "\n"); // 'US'
|
||||
print($record->country->name . "\n"); // 'United States'
|
||||
print($record->country->names['zh-CN'] . "\n"); // '美国'
|
||||
|
||||
print($record->mostSpecificSubdivision->name . "\n"); // 'Minnesota'
|
||||
print($record->mostSpecificSubdivision->isoCode . "\n"); // 'MN'
|
||||
|
||||
print($record->city->name . "\n"); // 'Minneapolis'
|
||||
|
||||
print($record->postal->code . "\n"); // '55455'
|
||||
|
||||
print($record->location->latitude . "\n"); // 44.9733
|
||||
print($record->location->longitude . "\n"); // -93.2323
|
||||
|
||||
print($record->traits->network . "\n"); // '128.101.101.101/32'
|
||||
|
||||
```
|
||||
|
||||
### Anonymous IP Example ###
|
||||
|
||||
```php
|
||||
<?php
|
||||
require_once 'vendor/autoload.php';
|
||||
use GeoIp2\Database\Reader;
|
||||
|
||||
// This creates the Reader object, which should be reused across
|
||||
// lookups.
|
||||
$reader = new Reader('/usr/local/share/GeoIP/GeoIP2-Anonymous-IP.mmdb');
|
||||
|
||||
$record = $reader->anonymousIp('128.101.101.101');
|
||||
|
||||
if ($record->isAnonymous) { print "anon\n"; }
|
||||
print($record->ipAddress . "\n"); // '128.101.101.101'
|
||||
print($record->network . "\n"); // '128.101.101.101/32'
|
||||
|
||||
```
|
||||
|
||||
### Connection-Type Example ###
|
||||
|
||||
```php
|
||||
<?php
|
||||
require_once 'vendor/autoload.php';
|
||||
use GeoIp2\Database\Reader;
|
||||
|
||||
// This creates the Reader object, which should be reused across
|
||||
// lookups.
|
||||
$reader = new Reader('/usr/local/share/GeoIP/GeoIP2-Connection-Type.mmdb');
|
||||
|
||||
$record = $reader->connectionType('128.101.101.101');
|
||||
|
||||
print($record->connectionType . "\n"); // 'Corporate'
|
||||
print($record->ipAddress . "\n"); // '128.101.101.101'
|
||||
print($record->network . "\n"); // '128.101.101.101/32'
|
||||
|
||||
```
|
||||
|
||||
### Domain Example ###
|
||||
|
||||
```php
|
||||
<?php
|
||||
require_once 'vendor/autoload.php';
|
||||
use GeoIp2\Database\Reader;
|
||||
|
||||
// This creates the Reader object, which should be reused across
|
||||
// lookups.
|
||||
$reader = new Reader('/usr/local/share/GeoIP/GeoIP2-Domain.mmdb');
|
||||
|
||||
$record = $reader->domain('128.101.101.101');
|
||||
|
||||
print($record->domain . "\n"); // 'umn.edu'
|
||||
print($record->ipAddress . "\n"); // '128.101.101.101'
|
||||
print($record->network . "\n"); // '128.101.101.101/32'
|
||||
|
||||
```
|
||||
|
||||
### Enterprise Example ###
|
||||
|
||||
```php
|
||||
<?php
|
||||
require_once 'vendor/autoload.php';
|
||||
use GeoIp2\Database\Reader;
|
||||
|
||||
// This creates the Reader object, which should be reused across
|
||||
// lookups.
|
||||
$reader = new Reader('/usr/local/share/GeoIP/GeoIP2-Enterprise.mmdb');
|
||||
|
||||
// Use the ->enterprise method to do a lookup in the Enterprise database
|
||||
$record = $reader->enterprise('128.101.101.101');
|
||||
|
||||
print($record->country->confidence . "\n"); // 99
|
||||
print($record->country->isoCode . "\n"); // 'US'
|
||||
print($record->country->name . "\n"); // 'United States'
|
||||
print($record->country->names['zh-CN'] . "\n"); // '美国'
|
||||
|
||||
print($record->mostSpecificSubdivision->confidence . "\n"); // 77
|
||||
print($record->mostSpecificSubdivision->name . "\n"); // 'Minnesota'
|
||||
print($record->mostSpecificSubdivision->isoCode . "\n"); // 'MN'
|
||||
|
||||
print($record->city->confidence . "\n"); // 60
|
||||
print($record->city->name . "\n"); // 'Minneapolis'
|
||||
|
||||
print($record->postal->code . "\n"); // '55455'
|
||||
|
||||
print($record->location->accuracyRadius . "\n"); // 50
|
||||
print($record->location->latitude . "\n"); // 44.9733
|
||||
print($record->location->longitude . "\n"); // -93.2323
|
||||
|
||||
print($record->traits->network . "\n"); // '128.101.101.101/32'
|
||||
|
||||
```
|
||||
|
||||
### ISP Example ###
|
||||
|
||||
```php
|
||||
<?php
|
||||
require_once 'vendor/autoload.php';
|
||||
use GeoIp2\Database\Reader;
|
||||
|
||||
// This creates the Reader object, which should be reused across
|
||||
// lookups.
|
||||
$reader = new Reader('/usr/local/share/GeoIP/GeoIP2-ISP.mmdb');
|
||||
|
||||
$record = $reader->isp('128.101.101.101');
|
||||
|
||||
print($record->autonomousSystemNumber . "\n"); // 217
|
||||
print($record->autonomousSystemOrganization . "\n"); // 'University of Minnesota'
|
||||
print($record->isp . "\n"); // 'University of Minnesota'
|
||||
print($record->organization . "\n"); // 'University of Minnesota'
|
||||
|
||||
print($record->ipAddress . "\n"); // '128.101.101.101'
|
||||
print($record->network . "\n"); // '128.101.101.101/32'
|
||||
|
||||
```
|
||||
|
||||
## Database Updates ##
|
||||
|
||||
You can keep your databases up to date with our
|
||||
[GeoIP Update program](https://github.com/maxmind/geoipupdate/releases).
|
||||
[Learn more about GeoIP Update on our developer
|
||||
portal.](https://dev.maxmind.com/geoip/updating-databases?lang=en)
|
||||
|
||||
There is also a third-party tool for updating databases using PHP and
|
||||
Composer. MaxMind does not offer support for this tool or maintain it.
|
||||
[Learn more about the Geoip2 Update tool for PHP and Composer on its
|
||||
GitHub page.](https://github.com/tronovav/geoip2-update)
|
||||
|
||||
## Web Service Client ##
|
||||
|
||||
### Usage ###
|
||||
|
||||
To use this API, you must create a new `\GeoIp2\WebService\Client`
|
||||
object with your `$accountId` and `$licenseKey`:
|
||||
|
||||
```php
|
||||
$client = new Client(42, 'abcdef123456');
|
||||
```
|
||||
|
||||
You may also call the constructor with additional arguments. The third argument
|
||||
specifies the language preferences when using the `->name` method on the model
|
||||
classes that this client creates. The fourth argument is additional options
|
||||
such as `host` and `timeout`.
|
||||
|
||||
For instance, to call the GeoLite2 web service instead of the GeoIP2 web
|
||||
service:
|
||||
|
||||
```php
|
||||
$client = new Client(42, 'abcdef123456', ['en'], ['host' => 'geolite.info']);
|
||||
```
|
||||
|
||||
After creating the client, you may now call the method corresponding to a
|
||||
specific endpoint with the IP address to look up, e.g.:
|
||||
|
||||
```php
|
||||
$record = $client->city('128.101.101.101');
|
||||
```
|
||||
|
||||
If the request succeeds, the method call will return a model class for the
|
||||
endpoint you called. This model in turn contains multiple record classes, each
|
||||
of which represents part of the data returned by the web service.
|
||||
|
||||
If there is an error, a structured exception is thrown.
|
||||
|
||||
See the API documentation for more details.
|
||||
|
||||
### Example ###
|
||||
|
||||
```php
|
||||
<?php
|
||||
require_once 'vendor/autoload.php';
|
||||
use GeoIp2\WebService\Client;
|
||||
|
||||
// This creates a Client object that can be reused across requests.
|
||||
// Replace "42" with your account ID and "license_key" with your license
|
||||
// key. Set the "host" to "geolite.info" in the fourth argument options
|
||||
// array to use the GeoLite2 web service instead of the GeoIP2 web
|
||||
// service.
|
||||
$client = new Client(42, 'abcdef123456');
|
||||
|
||||
// Replace "city" with the method corresponding to the web service that
|
||||
// you are using, e.g., "country", "insights".
|
||||
$record = $client->city('128.101.101.101');
|
||||
|
||||
print($record->country->isoCode . "\n"); // 'US'
|
||||
print($record->country->name . "\n"); // 'United States'
|
||||
print($record->country->names['zh-CN'] . "\n"); // '美国'
|
||||
|
||||
print($record->mostSpecificSubdivision->name . "\n"); // 'Minnesota'
|
||||
print($record->mostSpecificSubdivision->isoCode . "\n"); // 'MN'
|
||||
|
||||
print($record->city->name . "\n"); // 'Minneapolis'
|
||||
|
||||
print($record->postal->code . "\n"); // '55455'
|
||||
|
||||
print($record->location->latitude . "\n"); // 44.9733
|
||||
print($record->location->longitude . "\n"); // -93.2323
|
||||
|
||||
print($record->traits->network . "\n"); // '128.101.101.101/32'
|
||||
|
||||
```
|
||||
|
||||
## Values to use for Database or Array Keys ##
|
||||
|
||||
**We strongly discourage you from using a value from any `names` property as
|
||||
a key in a database or array.**
|
||||
|
||||
These names may change between releases. Instead we recommend using one of the
|
||||
following:
|
||||
|
||||
* `GeoIp2\Record\City` - `$city->geonameId`
|
||||
* `GeoIp2\Record\Continent` - `$continent->code` or `$continent->geonameId`
|
||||
* `GeoIp2\Record\Country` and `GeoIp2\Record\RepresentedCountry` -
|
||||
`$country->isoCode` or `$country->geonameId`
|
||||
* `GeoIp2\Record\Subdivision` - `$subdivision->isoCode` or `$subdivision->geonameId`
|
||||
|
||||
### What data is returned? ###
|
||||
|
||||
While many of the end points return the same basic records, the attributes
|
||||
which can be populated vary between end points. In addition, while an end
|
||||
point may offer a particular piece of data, MaxMind does not always have every
|
||||
piece of data for any given IP address.
|
||||
|
||||
Because of these factors, it is possible for any end point to return a record
|
||||
where some or all of the attributes are unpopulated.
|
||||
|
||||
See the
|
||||
[GeoIP2 web service docs](https://dev.maxmind.com/geoip/docs/web-services?lang=en)
|
||||
for details on what data each end point may return.
|
||||
|
||||
The only piece of data which is always returned is the `ipAddress`
|
||||
attribute in the `GeoIp2\Record\Traits` record.
|
||||
|
||||
## Integration with GeoNames ##
|
||||
|
||||
[GeoNames](https://www.geonames.org/) offers web services and downloadable
|
||||
databases with data on geographical features around the world, including
|
||||
populated places. They offer both free and paid premium data. Each
|
||||
feature is unique identified by a `geonameId`, which is an integer.
|
||||
|
||||
Many of the records returned by the GeoIP2 web services and databases
|
||||
include a `geonameId` property. This is the ID of a geographical feature
|
||||
(city, region, country, etc.) in the GeoNames database.
|
||||
|
||||
Some of the data that MaxMind provides is also sourced from GeoNames. We
|
||||
source things like place names, ISO codes, and other similar data from
|
||||
the GeoNames premium data set.
|
||||
|
||||
## Reporting data problems ##
|
||||
|
||||
If the problem you find is that an IP address is incorrectly mapped,
|
||||
please
|
||||
[submit your correction to MaxMind](https://www.maxmind.com/en/correction).
|
||||
|
||||
If you find some other sort of mistake, like an incorrect spelling,
|
||||
please check the [GeoNames site](https://www.geonames.org/) first. Once
|
||||
you've searched for a place and found it on the GeoNames map view, there
|
||||
are a number of links you can use to correct data ("move", "edit",
|
||||
"alternate names", etc.). Once the correction is part of the GeoNames
|
||||
data set, it will be automatically incorporated into future MaxMind
|
||||
releases.
|
||||
|
||||
If you are a paying MaxMind customer and you're not sure where to submit
|
||||
a correction, please
|
||||
[contact MaxMind support](https://www.maxmind.com/en/support) for help.
|
||||
|
||||
## Other Support ##
|
||||
|
||||
Please report all issues with this code using the
|
||||
[GitHub issue tracker](https://github.com/maxmind/GeoIP2-php/issues).
|
||||
|
||||
If you are having an issue with a MaxMind service that is not specific
|
||||
to the client API, please see
|
||||
[our support page](https://www.maxmind.com/en/support).
|
||||
|
||||
## Requirements ##
|
||||
|
||||
This library requires PHP 7.2 or greater.
|
||||
|
||||
This library also relies on the [MaxMind DB Reader](https://github.com/maxmind/MaxMind-DB-Reader-php).
|
||||
|
||||
## Contributing ##
|
||||
|
||||
Patches and pull requests are encouraged. All code should follow the PSR-2
|
||||
style guidelines. Please include unit tests whenever possible. You may obtain
|
||||
the test data for the maxmind-db folder by running `git submodule update
|
||||
--init --recursive` or adding `--recursive` to your initial clone, or from
|
||||
https://github.com/maxmind/MaxMind-DB
|
||||
|
||||
## Versioning ##
|
||||
|
||||
The GeoIP2 PHP API uses [Semantic Versioning](https://semver.org/).
|
||||
|
||||
## Copyright and License ##
|
||||
|
||||
This software is Copyright (c) 2013-2020 by MaxMind, Inc.
|
||||
|
||||
This is free software, licensed under the Apache License, Version 2.0.
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "geoip2/geoip2",
|
||||
"description": "MaxMind GeoIP2 PHP API",
|
||||
"keywords": ["geoip", "geoip2", "geolocation", "ip", "maxmind"],
|
||||
"homepage": "https://github.com/maxmind/GeoIP2-php",
|
||||
"type": "library",
|
||||
"license": "Apache-2.0",
|
||||
"authors": [
|
||||
{
|
||||
"name": "Gregory J. Oschwald",
|
||||
"email": "goschwald@maxmind.com",
|
||||
"homepage": "https://www.maxmind.com/"
|
||||
}
|
||||
],
|
||||
"require": {
|
||||
"maxmind-db/reader": "~1.8",
|
||||
"maxmind/web-service-common": "~0.8",
|
||||
"php": ">=7.2",
|
||||
"ext-json": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "3.*",
|
||||
"phpunit/phpunit": "^8.0 || ^9.0",
|
||||
"squizlabs/php_codesniffer": "3.*",
|
||||
"phpstan/phpstan": "*"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"GeoIp2\\": "src"
|
||||
}
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
require __DIR__ . '/../vendor/autoload.php';
|
||||
|
||||
use GeoIp2\Database\Reader;
|
||||
|
||||
srand(0);
|
||||
|
||||
$reader = new Reader('GeoIP2-City.mmdb');
|
||||
$count = 500000;
|
||||
$startTime = microtime(true);
|
||||
for ($i = 0; $i < $count; ++$i) {
|
||||
$ip = long2ip(rand(0, 2 ** 32 - 1));
|
||||
|
||||
try {
|
||||
$t = $reader->city($ip);
|
||||
} catch (\GeoIp2\Exception\AddressNotFoundException $e) {
|
||||
}
|
||||
if ($i % 10000 === 0) {
|
||||
echo $i . ' ' . $ip . "\n";
|
||||
}
|
||||
}
|
||||
$endTime = microtime(true);
|
||||
|
||||
$duration = $endTime - $startTime;
|
||||
echo 'Requests per second: ' . $count / $duration . "\n";
|
||||
+299
@@ -0,0 +1,299 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace GeoIp2\Database;
|
||||
|
||||
use GeoIp2\Exception\AddressNotFoundException;
|
||||
use GeoIp2\Model\AbstractModel;
|
||||
use GeoIp2\Model\AnonymousIp;
|
||||
use GeoIp2\Model\Asn;
|
||||
use GeoIp2\Model\City;
|
||||
use GeoIp2\Model\ConnectionType;
|
||||
use GeoIp2\Model\Country;
|
||||
use GeoIp2\Model\Domain;
|
||||
use GeoIp2\Model\Enterprise;
|
||||
use GeoIp2\Model\Isp;
|
||||
use GeoIp2\ProviderInterface;
|
||||
use MaxMind\Db\Reader as DbReader;
|
||||
use MaxMind\Db\Reader\InvalidDatabaseException;
|
||||
|
||||
/**
|
||||
* Instances of this class provide a reader for the GeoIP2 database format.
|
||||
* IP addresses can be looked up using the database specific methods.
|
||||
*
|
||||
* ## Usage ##
|
||||
*
|
||||
* The basic API for this class is the same for every database. First, you
|
||||
* create a reader object, specifying a file name. You then call the method
|
||||
* corresponding to the specific database, passing it the IP address you want
|
||||
* to look up.
|
||||
*
|
||||
* If the request succeeds, the method call will return a model class for
|
||||
* the method you called. This model in turn contains multiple record classes,
|
||||
* each of which represents part of the data returned by the database. If
|
||||
* the database does not contain the requested information, the attributes
|
||||
* on the record class will have a `null` value.
|
||||
*
|
||||
* If the address is not in the database, an
|
||||
* {@link \GeoIp2\Exception\AddressNotFoundException} exception will be
|
||||
* thrown. If an invalid IP address is passed to one of the methods, a
|
||||
* SPL {@link \InvalidArgumentException} will be thrown. If the database is
|
||||
* corrupt or invalid, a {@link \MaxMind\Db\Reader\InvalidDatabaseException}
|
||||
* will be thrown.
|
||||
*/
|
||||
class Reader implements ProviderInterface
|
||||
{
|
||||
/**
|
||||
* @var DbReader
|
||||
*/
|
||||
private $dbReader;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
private $dbType;
|
||||
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
private $locales;
|
||||
|
||||
/**
|
||||
* Constructor.
|
||||
*
|
||||
* @param string $filename the path to the GeoIP2 database file
|
||||
* @param array $locales list of locale codes to use in name property
|
||||
* from most preferred to least preferred
|
||||
*
|
||||
* @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
|
||||
* is corrupt or invalid
|
||||
*/
|
||||
public function __construct(
|
||||
string $filename,
|
||||
array $locales = ['en']
|
||||
) {
|
||||
$this->dbReader = new DbReader($filename);
|
||||
$this->dbType = $this->dbReader->metadata()->databaseType;
|
||||
$this->locales = $locales;
|
||||
}
|
||||
|
||||
/**
|
||||
* This method returns a GeoIP2 City model.
|
||||
*
|
||||
* @param string $ipAddress an IPv4 or IPv6 address as a string
|
||||
*
|
||||
* @throws \GeoIp2\Exception\AddressNotFoundException if the address is
|
||||
* not in the database
|
||||
* @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
|
||||
* is corrupt or invalid
|
||||
*/
|
||||
public function city(string $ipAddress): City
|
||||
{
|
||||
// @phpstan-ignore-next-line
|
||||
return $this->modelFor(City::class, 'City', $ipAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method returns a GeoIP2 Country model.
|
||||
*
|
||||
* @param string $ipAddress an IPv4 or IPv6 address as a string
|
||||
*
|
||||
* @throws \GeoIp2\Exception\AddressNotFoundException if the address is
|
||||
* not in the database
|
||||
* @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
|
||||
* is corrupt or invalid
|
||||
*/
|
||||
public function country(string $ipAddress): Country
|
||||
{
|
||||
// @phpstan-ignore-next-line
|
||||
return $this->modelFor(Country::class, 'Country', $ipAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method returns a GeoIP2 Anonymous IP model.
|
||||
*
|
||||
* @param string $ipAddress an IPv4 or IPv6 address as a string
|
||||
*
|
||||
* @throws \GeoIp2\Exception\AddressNotFoundException if the address is
|
||||
* not in the database
|
||||
* @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
|
||||
* is corrupt or invalid
|
||||
*/
|
||||
public function anonymousIp(string $ipAddress): AnonymousIp
|
||||
{
|
||||
// @phpstan-ignore-next-line
|
||||
return $this->flatModelFor(
|
||||
AnonymousIp::class,
|
||||
'GeoIP2-Anonymous-IP',
|
||||
$ipAddress
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method returns a GeoLite2 ASN model.
|
||||
*
|
||||
* @param string $ipAddress an IPv4 or IPv6 address as a string
|
||||
*
|
||||
* @throws \GeoIp2\Exception\AddressNotFoundException if the address is
|
||||
* not in the database
|
||||
* @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
|
||||
* is corrupt or invalid
|
||||
*/
|
||||
public function asn(string $ipAddress): Asn
|
||||
{
|
||||
// @phpstan-ignore-next-line
|
||||
return $this->flatModelFor(
|
||||
Asn::class,
|
||||
'GeoLite2-ASN',
|
||||
$ipAddress
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method returns a GeoIP2 Connection Type model.
|
||||
*
|
||||
* @param string $ipAddress an IPv4 or IPv6 address as a string
|
||||
*
|
||||
* @throws \GeoIp2\Exception\AddressNotFoundException if the address is
|
||||
* not in the database
|
||||
* @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
|
||||
* is corrupt or invalid
|
||||
*/
|
||||
public function connectionType(string $ipAddress): ConnectionType
|
||||
{
|
||||
// @phpstan-ignore-next-line
|
||||
return $this->flatModelFor(
|
||||
ConnectionType::class,
|
||||
'GeoIP2-Connection-Type',
|
||||
$ipAddress
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method returns a GeoIP2 Domain model.
|
||||
*
|
||||
* @param string $ipAddress an IPv4 or IPv6 address as a string
|
||||
*
|
||||
* @throws \GeoIp2\Exception\AddressNotFoundException if the address is
|
||||
* not in the database
|
||||
* @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
|
||||
* is corrupt or invalid
|
||||
*/
|
||||
public function domain(string $ipAddress): Domain
|
||||
{
|
||||
// @phpstan-ignore-next-line
|
||||
return $this->flatModelFor(
|
||||
Domain::class,
|
||||
'GeoIP2-Domain',
|
||||
$ipAddress
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method returns a GeoIP2 Enterprise model.
|
||||
*
|
||||
* @param string $ipAddress an IPv4 or IPv6 address as a string
|
||||
*
|
||||
* @throws \GeoIp2\Exception\AddressNotFoundException if the address is
|
||||
* not in the database
|
||||
* @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
|
||||
* is corrupt or invalid
|
||||
*/
|
||||
public function enterprise(string $ipAddress): Enterprise
|
||||
{
|
||||
// @phpstan-ignore-next-line
|
||||
return $this->modelFor(Enterprise::class, 'Enterprise', $ipAddress);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method returns a GeoIP2 ISP model.
|
||||
*
|
||||
* @param string $ipAddress an IPv4 or IPv6 address as a string
|
||||
*
|
||||
* @throws \GeoIp2\Exception\AddressNotFoundException if the address is
|
||||
* not in the database
|
||||
* @throws \MaxMind\Db\Reader\InvalidDatabaseException if the database
|
||||
* is corrupt or invalid
|
||||
*/
|
||||
public function isp(string $ipAddress): Isp
|
||||
{
|
||||
// @phpstan-ignore-next-line
|
||||
return $this->flatModelFor(
|
||||
Isp::class,
|
||||
'GeoIP2-ISP',
|
||||
$ipAddress
|
||||
);
|
||||
}
|
||||
|
||||
private function modelFor(string $class, string $type, string $ipAddress): AbstractModel
|
||||
{
|
||||
[$record, $prefixLen] = $this->getRecord($class, $type, $ipAddress);
|
||||
|
||||
$record['traits']['ip_address'] = $ipAddress;
|
||||
$record['traits']['prefix_len'] = $prefixLen;
|
||||
|
||||
return new $class($record, $this->locales);
|
||||
}
|
||||
|
||||
private function flatModelFor(string $class, string $type, string $ipAddress): AbstractModel
|
||||
{
|
||||
[$record, $prefixLen] = $this->getRecord($class, $type, $ipAddress);
|
||||
|
||||
$record['ip_address'] = $ipAddress;
|
||||
$record['prefix_len'] = $prefixLen;
|
||||
|
||||
return new $class($record);
|
||||
}
|
||||
|
||||
private function getRecord(string $class, string $type, string $ipAddress): array
|
||||
{
|
||||
if (strpos($this->dbType, $type) === false) {
|
||||
$method = lcfirst((new \ReflectionClass($class))->getShortName());
|
||||
|
||||
throw new \BadMethodCallException(
|
||||
"The $method method cannot be used to open a {$this->dbType} database"
|
||||
);
|
||||
}
|
||||
[$record, $prefixLen] = $this->dbReader->getWithPrefixLen($ipAddress);
|
||||
if ($record === null) {
|
||||
throw new AddressNotFoundException(
|
||||
"The address $ipAddress is not in the database."
|
||||
);
|
||||
}
|
||||
if (!\is_array($record)) {
|
||||
// This can happen on corrupt databases. Generally,
|
||||
// MaxMind\Db\Reader will throw a
|
||||
// MaxMind\Db\Reader\InvalidDatabaseException, but occasionally
|
||||
// the lookup may result in a record that looks valid but is not
|
||||
// an array. This mostly happens when the user is ignoring all
|
||||
// exceptions and the more frequent InvalidDatabaseException
|
||||
// exceptions go unnoticed.
|
||||
throw new InvalidDatabaseException(
|
||||
"Expected an array when looking up $ipAddress but received: "
|
||||
. \gettype($record)
|
||||
);
|
||||
}
|
||||
|
||||
return [$record, $prefixLen];
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \InvalidArgumentException if arguments are passed to the method
|
||||
* @throws \BadMethodCallException if the database has been closed
|
||||
*
|
||||
* @return \MaxMind\Db\Reader\Metadata object for the database
|
||||
*/
|
||||
public function metadata(): DbReader\Metadata
|
||||
{
|
||||
return $this->dbReader->metadata();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the GeoIP2 database and returns the resources to the system.
|
||||
*/
|
||||
public function close(): void
|
||||
{
|
||||
$this->dbReader->close();
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace GeoIp2\Exception;
|
||||
|
||||
/**
|
||||
* This class represents a generic error.
|
||||
*/
|
||||
class AddressNotFoundException extends GeoIp2Exception
|
||||
{
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace GeoIp2\Exception;
|
||||
|
||||
/**
|
||||
* This class represents a generic error.
|
||||
*/
|
||||
class AuthenticationException extends GeoIp2Exception
|
||||
{
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace GeoIp2\Exception;
|
||||
|
||||
/**
|
||||
* This class represents a generic error.
|
||||
*/
|
||||
class GeoIp2Exception extends \Exception
|
||||
{
|
||||
}
|
||||
+28
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace GeoIp2\Exception;
|
||||
|
||||
/**
|
||||
* This class represents an HTTP transport error.
|
||||
*/
|
||||
class HttpException extends GeoIp2Exception
|
||||
{
|
||||
/**
|
||||
* The URI queried.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $uri;
|
||||
|
||||
public function __construct(
|
||||
string $message,
|
||||
int $httpStatus,
|
||||
string $uri,
|
||||
\Exception $previous = null
|
||||
) {
|
||||
$this->uri = $uri;
|
||||
parent::__construct($message, $httpStatus, $previous);
|
||||
}
|
||||
}
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace GeoIp2\Exception;
|
||||
|
||||
/**
|
||||
* This class represents an error returned by MaxMind's GeoIP2
|
||||
* web service.
|
||||
*/
|
||||
class InvalidRequestException extends HttpException
|
||||
{
|
||||
/**
|
||||
* The code returned by the MaxMind web service.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
public $error;
|
||||
|
||||
public function __construct(
|
||||
string $message,
|
||||
string $error,
|
||||
int $httpStatus,
|
||||
string $uri,
|
||||
\Exception $previous = null
|
||||
) {
|
||||
$this->error = $error;
|
||||
parent::__construct($message, $httpStatus, $uri, $previous);
|
||||
}
|
||||
}
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace GeoIp2\Exception;
|
||||
|
||||
/**
|
||||
* This class represents a generic error.
|
||||
*/
|
||||
class OutOfQueriesException extends GeoIp2Exception
|
||||
{
|
||||
}
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace GeoIp2\Model;
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*/
|
||||
abstract class AbstractModel implements \JsonSerializable
|
||||
{
|
||||
/**
|
||||
* @var array<string, mixed>
|
||||
*/
|
||||
protected $raw;
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*/
|
||||
public function __construct(array $raw)
|
||||
{
|
||||
$this->raw = $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
protected function get(string $field)
|
||||
{
|
||||
if (isset($this->raw[$field])) {
|
||||
return $this->raw[$field];
|
||||
}
|
||||
if (preg_match('/^is_/', $field)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function __get(string $attr)
|
||||
{
|
||||
if ($attr !== 'instance' && property_exists($this, $attr)) {
|
||||
return $this->{$attr};
|
||||
}
|
||||
|
||||
throw new \RuntimeException("Unknown attribute: $attr");
|
||||
}
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*/
|
||||
public function __isset(string $attr): bool
|
||||
{
|
||||
return $attr !== 'instance' && isset($this->{$attr});
|
||||
}
|
||||
|
||||
public function jsonSerialize(): array
|
||||
{
|
||||
return $this->raw;
|
||||
}
|
||||
}
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace GeoIp2\Model;
|
||||
|
||||
use GeoIp2\Util;
|
||||
|
||||
/**
|
||||
* This class provides the GeoIP2 Anonymous IP model.
|
||||
*
|
||||
* @property-read bool $isAnonymous This is true if the IP address belongs to
|
||||
* any sort of anonymous network.
|
||||
* @property-read bool $isAnonymousVpn This is true if the IP address is
|
||||
* registered to an anonymous VPN provider. If a VPN provider does not
|
||||
* register subnets under names associated with them, we will likely only
|
||||
* flag their IP ranges using the isHostingProvider property.
|
||||
* @property-read bool $isHostingProvider This is true if the IP address belongs
|
||||
* to a hosting or VPN provider (see description of isAnonymousVpn property).
|
||||
* @property-read bool $isPublicProxy This is true if the IP address belongs to
|
||||
* a public proxy.
|
||||
* @property-read bool $isResidentialProxy This is true if the IP address is
|
||||
* on a suspected anonymizing network and belongs to a residential ISP.
|
||||
* @property-read bool $isTorExitNode This is true if the IP address is a Tor
|
||||
* exit node.
|
||||
* @property-read string $ipAddress The IP address that the data in the model is
|
||||
* for.
|
||||
* @property-read string $network The network in CIDR notation associated with
|
||||
* the record. In particular, this is the largest network where all of the
|
||||
* fields besides $ipAddress have the same value.
|
||||
*/
|
||||
class AnonymousIp extends AbstractModel
|
||||
{
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
protected $isAnonymous;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
protected $isAnonymousVpn;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
protected $isHostingProvider;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
protected $isPublicProxy;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
protected $isResidentialProxy;
|
||||
|
||||
/**
|
||||
* @var bool
|
||||
*/
|
||||
protected $isTorExitNode;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $ipAddress;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $network;
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*/
|
||||
public function __construct(array $raw)
|
||||
{
|
||||
parent::__construct($raw);
|
||||
|
||||
$this->isAnonymous = $this->get('is_anonymous');
|
||||
$this->isAnonymousVpn = $this->get('is_anonymous_vpn');
|
||||
$this->isHostingProvider = $this->get('is_hosting_provider');
|
||||
$this->isPublicProxy = $this->get('is_public_proxy');
|
||||
$this->isResidentialProxy = $this->get('is_residential_proxy');
|
||||
$this->isTorExitNode = $this->get('is_tor_exit_node');
|
||||
$ipAddress = $this->get('ip_address');
|
||||
$this->ipAddress = $ipAddress;
|
||||
$this->network = Util::cidr($ipAddress, $this->get('prefix_len'));
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace GeoIp2\Model;
|
||||
|
||||
use GeoIp2\Util;
|
||||
|
||||
/**
|
||||
* This class provides the GeoLite2 ASN model.
|
||||
*
|
||||
* @property-read int|null $autonomousSystemNumber The autonomous system number
|
||||
* associated with the IP address.
|
||||
* @property-read string|null $autonomousSystemOrganization The organization
|
||||
* associated with the registered autonomous system number for the IP
|
||||
* address.
|
||||
* @property-read string $ipAddress The IP address that the data in the model is
|
||||
* for.
|
||||
* @property-read string $network The network in CIDR notation associated with
|
||||
* the record. In particular, this is the largest network where all of the
|
||||
* fields besides $ipAddress have the same value.
|
||||
*/
|
||||
class Asn extends AbstractModel
|
||||
{
|
||||
/**
|
||||
* @var int|null
|
||||
*/
|
||||
protected $autonomousSystemNumber;
|
||||
|
||||
/**
|
||||
* @var string|null
|
||||
*/
|
||||
protected $autonomousSystemOrganization;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $ipAddress;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $network;
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*/
|
||||
public function __construct(array $raw)
|
||||
{
|
||||
parent::__construct($raw);
|
||||
$this->autonomousSystemNumber = $this->get('autonomous_system_number');
|
||||
$this->autonomousSystemOrganization =
|
||||
$this->get('autonomous_system_organization');
|
||||
$ipAddress = $this->get('ip_address');
|
||||
$this->ipAddress = $ipAddress;
|
||||
$this->network = Util::cidr($ipAddress, $this->get('prefix_len'));
|
||||
}
|
||||
}
|
||||
+123
@@ -0,0 +1,123 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace GeoIp2\Model;
|
||||
|
||||
/**
|
||||
* Model class for the data returned by City Plus web service and City
|
||||
* database.
|
||||
*
|
||||
* See https://dev.maxmind.com/geoip/docs/web-services?lang=en for more
|
||||
* details.
|
||||
*
|
||||
* @property-read \GeoIp2\Record\City $city City data for the requested IP
|
||||
* address.
|
||||
* @property-read \GeoIp2\Record\Location $location Location data for the
|
||||
* requested IP address.
|
||||
* @property-read \GeoIp2\Record\Postal $postal Postal data for the
|
||||
* requested IP address.
|
||||
* @property-read array $subdivisions An array \GeoIp2\Record\Subdivision
|
||||
* objects representing the country subdivisions for the requested IP
|
||||
* address. The number and type of subdivisions varies by country, but a
|
||||
* subdivision is typically a state, province, county, etc. Subdivisions
|
||||
* are ordered from most general (largest) to most specific (smallest).
|
||||
* If the response did not contain any subdivisions, this method returns
|
||||
* an empty array.
|
||||
* @property-read \GeoIp2\Record\Subdivision $mostSpecificSubdivision An object
|
||||
* representing the most specific subdivision returned. If the response
|
||||
* did not contain any subdivisions, this method returns an empty
|
||||
* \GeoIp2\Record\Subdivision object.
|
||||
*/
|
||||
class City extends Country
|
||||
{
|
||||
/**
|
||||
* @ignore
|
||||
*
|
||||
* @var \GeoIp2\Record\City
|
||||
*/
|
||||
protected $city;
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*
|
||||
* @var \GeoIp2\Record\Location
|
||||
*/
|
||||
protected $location;
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*
|
||||
* @var \GeoIp2\Record\Postal
|
||||
*/
|
||||
protected $postal;
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*
|
||||
* @var array<\GeoIp2\Record\Subdivision>
|
||||
*/
|
||||
protected $subdivisions = [];
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*/
|
||||
public function __construct(array $raw, array $locales = ['en'])
|
||||
{
|
||||
parent::__construct($raw, $locales);
|
||||
|
||||
$this->city = new \GeoIp2\Record\City($this->get('city'), $locales);
|
||||
$this->location = new \GeoIp2\Record\Location($this->get('location'));
|
||||
$this->postal = new \GeoIp2\Record\Postal($this->get('postal'));
|
||||
|
||||
$this->createSubdivisions($raw, $locales);
|
||||
}
|
||||
|
||||
private function createSubdivisions(array $raw, array $locales): void
|
||||
{
|
||||
if (!isset($raw['subdivisions'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ($raw['subdivisions'] as $sub) {
|
||||
$this->subdivisions[] =
|
||||
new \GeoIp2\Record\Subdivision($sub, $locales)
|
||||
;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function __get(string $attr)
|
||||
{
|
||||
if ($attr === 'mostSpecificSubdivision') {
|
||||
return $this->{$attr}();
|
||||
}
|
||||
|
||||
return parent::__get($attr);
|
||||
}
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*/
|
||||
public function __isset(string $attr): bool
|
||||
{
|
||||
if ($attr === 'mostSpecificSubdivision') {
|
||||
// We always return a mostSpecificSubdivision, even if it is the
|
||||
// empty subdivision
|
||||
return true;
|
||||
}
|
||||
|
||||
return parent::__isset($attr);
|
||||
}
|
||||
|
||||
private function mostSpecificSubdivision(): \GeoIp2\Record\Subdivision
|
||||
{
|
||||
return empty($this->subdivisions) ?
|
||||
new \GeoIp2\Record\Subdivision([], $this->locales) :
|
||||
end($this->subdivisions);
|
||||
}
|
||||
}
|
||||
+50
@@ -0,0 +1,50 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace GeoIp2\Model;
|
||||
|
||||
use GeoIp2\Util;
|
||||
|
||||
/**
|
||||
* This class provides the GeoIP2 Connection-Type model.
|
||||
*
|
||||
* @property-read string|null $connectionType The connection type may take the
|
||||
* following values: "Dialup", "Cable/DSL", "Corporate", "Cellular".
|
||||
* Additional values may be added in the future.
|
||||
* @property-read string $ipAddress The IP address that the data in the model is
|
||||
* for.
|
||||
* @property-read string $network The network in CIDR notation associated with
|
||||
* the record. In particular, this is the largest network where all of the
|
||||
* fields besides $ipAddress have the same value.
|
||||
*/
|
||||
class ConnectionType extends AbstractModel
|
||||
{
|
||||
/**
|
||||
* @var string|null
|
||||
*/
|
||||
protected $connectionType;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $ipAddress;
|
||||
|
||||
/**
|
||||
* @var string
|
||||
*/
|
||||
protected $network;
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*/
|
||||
public function __construct(array $raw)
|
||||
{
|
||||
parent::__construct($raw);
|
||||
|
||||
$this->connectionType = $this->get('connection_type');
|
||||
$ipAddress = $this->get('ip_address');
|
||||
$this->ipAddress = $ipAddress;
|
||||
$this->network = Util::cidr($ipAddress, $this->get('prefix_len'));
|
||||
}
|
||||
}
|
||||
+96
@@ -0,0 +1,96 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace GeoIp2\Model;
|
||||
|
||||
/**
|
||||
* Model class for the data returned by GeoIP2 Country web service and database.
|
||||
*
|
||||
* See https://dev.maxmind.com/geoip/docs/web-services?lang=en for more details.
|
||||
*
|
||||
* @property-read \GeoIp2\Record\Continent $continent Continent data for the
|
||||
* requested IP address.
|
||||
* @property-read \GeoIp2\Record\Country $country Country data for the requested
|
||||
* IP address. This object represents the country where MaxMind believes the
|
||||
* end user is located.
|
||||
* @property-read \GeoIp2\Record\MaxMind $maxmind Data related to your MaxMind
|
||||
* account.
|
||||
* @property-read \GeoIp2\Record\Country $registeredCountry Registered country
|
||||
* data for the requested IP address. This record represents the country
|
||||
* where the ISP has registered a given IP block and may differ from the
|
||||
* user's country.
|
||||
* @property-read \GeoIp2\Record\RepresentedCountry $representedCountry
|
||||
* Represented country data for the requested IP address. The represented
|
||||
* country is used for things like military bases. It is only present when
|
||||
* the represented country differs from the country.
|
||||
* @property-read \GeoIp2\Record\Traits $traits Data for the traits of the
|
||||
* requested IP address.
|
||||
* @property-read array $raw The raw data from the web service.
|
||||
*/
|
||||
class Country extends AbstractModel
|
||||
{
|
||||
/**
|
||||
* @var \GeoIp2\Record\Continent
|
||||
*/
|
||||
protected $continent;
|
||||
|
||||
/**
|
||||
* @var \GeoIp2\Record\Country
|
||||
*/
|
||||
protected $country;
|
||||
|
||||
/**
|
||||
* @var array<string>
|
||||
*/
|
||||
protected $locales;
|
||||
|
||||
/**
|
||||
* @var \GeoIp2\Record\MaxMind
|
||||
*/
|
||||
protected $maxmind;
|
||||
|
||||
/**
|
||||
* @var \GeoIp2\Record\Country
|
||||
*/
|
||||
protected $registeredCountry;
|
||||
|
||||
/**
|
||||
* @var \GeoIp2\Record\RepresentedCountry
|
||||
*/
|
||||
protected $representedCountry;
|
||||
|
||||
/**
|
||||
* @var \GeoIp2\Record\Traits
|
||||
*/
|
||||
protected $traits;
|
||||
|
||||
/**
|
||||
* @ignore
|
||||
*/
|
||||
public function __construct(array $raw, array $locales = ['en'])
|
||||
{
|
||||
parent::__construct($raw);
|
||||
|
||||
$this->continent = new \GeoIp2\Record\Continent(
|
||||
$this->get('continent'),
|
||||
$locales
|
||||
);
|
||||
$this->country = new \GeoIp2\Record\Country(
|
||||
$this->get('country'),
|
||||
$locales
|
||||
);
|
||||
$this->maxmind = new \GeoIp2\Record\MaxMind($this->get('maxmind'));
|
||||
$this->registeredCountry = new \GeoIp2\Record\Country(
|
||||
$this->get('registered_country'),
|
||||
$locales
|
||||
);
|
||||
$this->representedCountry = new \GeoIp2\Record\RepresentedCountry(
|
||||
$this->get('represented_country'),
|
||||
$locales
|
||||
);
|
||||
$this->traits = new \GeoIp2\Record\Traits($this->get('traits'));
|
||||
|
||||
$this->locales = $locales;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user