初始版本

This commit is contained in:
root
2026-06-14 21:12:22 +08:00
commit 6a81742dc7
806 changed files with 179174 additions and 0 deletions
+15
View File
@@ -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);
+29
View File
@@ -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%';
+79
View File
@@ -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;
+89
View File
@@ -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_totalvarchar -> 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
View File
@@ -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
View File
@@ -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();
}
/**
* 输出后台列表/表单 JSpatches 未部署到 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
View File
@@ -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
View File
@@ -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();
}
/**
* 输出后台 JSpatches 未部署到 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
View File
@@ -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
View File
@@ -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 TokenToken 留空会保留原配置。',
'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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
}
+242
View File
@@ -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
View File
@@ -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 '';
}
}
/**
* WhatsApphttps://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;
}
/**
* Telegramhttps://t.me/+ 号码(去掉前导 +
*/
private static function buildTelegram(string $number): string
{
$id = ltrim($number, '+');
if ($id === '') {
return '';
}
return 'https://t.me/+' . $id;
}
/**
* Linehttps://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;
}
}
+87
View File
@@ -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_protect0=关闭,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 访客 IPIP 防护开启时用于国家校验)
*/
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
View File
@@ -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'] ?? []
),
];
}
}
+12
View File
@@ -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', ''),
];
+20
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+9
View File
@@ -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',
];
+147
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
});
+1
View File
@@ -0,0 +1 @@
9
+1
View File
@@ -0,0 +1 @@
8
View File
+323
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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();
}
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Exception;
/**
* This class represents a generic error.
*/
class AddressNotFoundException extends GeoIp2Exception
{
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Exception;
/**
* This class represents a generic error.
*/
class AuthenticationException extends GeoIp2Exception
{
}
+12
View File
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Exception;
/**
* This class represents a generic error.
*/
class GeoIp2Exception extends \Exception
{
}
+28
View File
@@ -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);
}
}
@@ -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);
}
}
@@ -0,0 +1,12 @@
<?php
declare(strict_types=1);
namespace GeoIp2\Exception;
/**
* This class represents a generic error.
*/
class OutOfQueriesException extends GeoIp2Exception
{
}
+68
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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