初始版本

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
+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);
}
}
+105
View File
@@ -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)];
}
}
+104
View File
@@ -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;
}
}
+60
View File
@@ -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;
}
}
+76
View File
@@ -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;
}
}
+24
View File
@@ -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;
}
}
+171
View File
@@ -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);
}
}
+250
View File
@@ -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;
}
}
+169
View File
@@ -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));
}
}
}
+59
View File
@@ -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));
}
}
+97
View File
@@ -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;
}
}
+65
View File
@@ -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);
}
}
+62
View File
@@ -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 : '';
}
}
+182
View File
@@ -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;
}
}
+195
View File
@@ -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';
}
}
+64
View File
@@ -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;
}
}
+114
View File
@@ -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;
}
}
+320
View File
@@ -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,
];
}
}
+58
View File
@@ -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'] ?? []
),
];
}
}