前端分流页功能
This commit is contained in:
@@ -15,6 +15,29 @@ class SplitAutoReplyService
|
||||
/** 单条最大字符数 */
|
||||
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') . '...';
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析为多行文本数组
|
||||
*
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* 按号码类型拼接各平台加好友链接
|
||||
*/
|
||||
class SplitFriendUrlBuilder
|
||||
{
|
||||
/**
|
||||
* 构建跳转 URL;无法构建时返回空字符串
|
||||
*/
|
||||
public static function build(string $numberType, string $number, string $numberTypeCustom = ''): string
|
||||
{
|
||||
$number = trim($number);
|
||||
if ($number === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
switch ($numberType) {
|
||||
case 'whatsapp':
|
||||
return self::buildWhatsApp($number);
|
||||
case 'telegram':
|
||||
return self::buildTelegram($number);
|
||||
case 'line':
|
||||
return self::buildLine($number);
|
||||
case 'custom':
|
||||
return self::buildCustom($number, $numberTypeCustom);
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* WhatsApp:https://api.whatsapp.com/send?phone= 仅数字
|
||||
*/
|
||||
private static function buildWhatsApp(string $number): string
|
||||
{
|
||||
$digits = preg_replace('/\D+/', '', $number) ?? '';
|
||||
if ($digits === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return 'https://api.whatsapp.com/send?phone=' . $digits;
|
||||
}
|
||||
|
||||
/**
|
||||
* Telegram:https://t.me/+ 号码(去掉前导 +)
|
||||
*/
|
||||
private static function buildTelegram(string $number): string
|
||||
{
|
||||
$id = ltrim($number, '+');
|
||||
if ($id === '') {
|
||||
return '';
|
||||
}
|
||||
|
||||
return 'https://t.me/+' . $id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Line:https://line.me/ti/p/~ 拼接号码
|
||||
*/
|
||||
private static function buildLine(string $number): string
|
||||
{
|
||||
return 'https://line.me/ti/p/~' . $number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 自定义:号码字段即为完整链接(仅允许 http/https)
|
||||
*/
|
||||
private static function buildCustom(string $number, string $numberTypeCustom): string
|
||||
{
|
||||
$url = trim($number);
|
||||
if ($url === '' && trim($numberTypeCustom) !== '') {
|
||||
$url = trim($numberTypeCustom);
|
||||
}
|
||||
if ($url === '') {
|
||||
return '';
|
||||
}
|
||||
if (!filter_var($url, FILTER_VALIDATE_URL)) {
|
||||
return '';
|
||||
}
|
||||
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
|
||||
if (!in_array($scheme, ['http', 'https'], true)) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return $url;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use GeoIp2\Database\Reader;
|
||||
use GeoIp2\Exception\AddressNotFoundException;
|
||||
|
||||
/**
|
||||
* 基于 GeoLite2-Country.mmdb 的 IP 国家查询(MaxMind GeoIP2)
|
||||
*/
|
||||
class SplitGeoIpService
|
||||
{
|
||||
/** 项目根目录下的 MaxMind 国家库文件名 */
|
||||
private const DB_FILENAME = 'GeoLite2-Country.mmdb';
|
||||
|
||||
private static ?Reader $reader = null;
|
||||
|
||||
/**
|
||||
* 解析 IP 对应 ISO 3166-1 alpha-2 国家代码;无法解析时返回 null
|
||||
*/
|
||||
public static function getCountryIso2(string $ip): ?string
|
||||
{
|
||||
$ip = trim($ip);
|
||||
if ($ip === '' || filter_var($ip, FILTER_VALIDATE_IP) === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
self::bootstrapLibrary();
|
||||
|
||||
try {
|
||||
$record = self::getReader()->country($ip);
|
||||
$code = $record->country->isoCode ?? null;
|
||||
if (!is_string($code) || $code === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return strtoupper($code);
|
||||
} catch (AddressNotFoundException $e) {
|
||||
return null;
|
||||
} catch (\Throwable $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* MMDB 绝对路径
|
||||
*/
|
||||
public static function getDatabasePath(): string
|
||||
{
|
||||
return rtrim((string) ROOT_PATH, '/\\') . DIRECTORY_SEPARATOR . self::DB_FILENAME;
|
||||
}
|
||||
|
||||
public static function isDatabaseAvailable(): bool
|
||||
{
|
||||
$path = self::getDatabasePath();
|
||||
|
||||
return is_file($path) && is_readable($path);
|
||||
}
|
||||
|
||||
private static function bootstrapLibrary(): void
|
||||
{
|
||||
static $bootstrapped = false;
|
||||
if ($bootstrapped) {
|
||||
return;
|
||||
}
|
||||
$bootstrapped = true;
|
||||
$loader = ROOT_PATH . 'patches/third_party/load_geoip2.php';
|
||||
if (is_file($loader)) {
|
||||
require_once $loader;
|
||||
}
|
||||
}
|
||||
|
||||
private static function getReader(): Reader
|
||||
{
|
||||
if (self::$reader instanceof Reader) {
|
||||
return self::$reader;
|
||||
}
|
||||
if (!self::isDatabaseAvailable()) {
|
||||
throw new \RuntimeException('GeoLite2-Country.mmdb not readable at project root');
|
||||
}
|
||||
self::$reader = new Reader(self::getDatabasePath());
|
||||
|
||||
return self::$reader;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* 分流链接 IP 防护:开启时校验访客 IP 国家是否在链接投放国家列表内
|
||||
*/
|
||||
class SplitIpProtectService
|
||||
{
|
||||
/**
|
||||
* 是否允许继续轮转跳转
|
||||
*
|
||||
* @param int|string $ipProtect 链接 ip_protect:0=关闭,1=开启
|
||||
* @param string $countriesStorage 链接 countries 字段(ISO2 逗号分隔)
|
||||
* @param string $clientIp 访客 IP
|
||||
*/
|
||||
public static function isAllowed($ipProtect, string $countriesStorage, string $clientIp): bool
|
||||
{
|
||||
if ((int) $ipProtect !== 1) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$allowed = self::parseAllowedCountries($countriesStorage);
|
||||
if ($allowed === []) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!SplitGeoIpService::isDatabaseAvailable()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$visitorCountry = SplitGeoIpService::getCountryIso2($clientIp);
|
||||
if ($visitorCountry === null || $visitorCountry === '') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return in_array($visitorCountry, $allowed, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string> 大写 ISO2 列表
|
||||
*/
|
||||
public static function parseAllowedCountries(string $storage): array
|
||||
{
|
||||
if (trim($storage) === '') {
|
||||
return [];
|
||||
}
|
||||
$result = [];
|
||||
foreach (explode(',', $storage) as $code) {
|
||||
$code = strtoupper(trim($code));
|
||||
if ($code !== '' && !in_array($code, $result, true)) {
|
||||
$result[] = $code;
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,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,87 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use app\admin\model\split\Link;
|
||||
use app\admin\model\split\Number;
|
||||
use think\Collection;
|
||||
|
||||
/**
|
||||
* 分流链接落地页:查链、轮转选号、拼接跳转 URL、访问计数
|
||||
*/
|
||||
class SplitRedirectService
|
||||
{
|
||||
private SplitRoundRobinStore $roundRobinStore;
|
||||
|
||||
public function __construct(?SplitRoundRobinStore $roundRobinStore = null)
|
||||
{
|
||||
$this->roundRobinStore = $roundRobinStore ?? new SplitRoundRobinStore();
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据链接码解析本次应跳转的加好友 URL;无效时返回 null
|
||||
*
|
||||
* @param string $linkCode 9 位链接码
|
||||
* @param string $clientIp 访客 IP(IP 防护开启时用于国家校验)
|
||||
*/
|
||||
public function resolveRedirectUrl(string $linkCode, string $clientIp = ''): ?string
|
||||
{
|
||||
$linkCode = SplitLinkCodeService::normalize($linkCode);
|
||||
if (!SplitLinkCodeService::isValidFormat($linkCode)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$link = Link::where('link_code', $linkCode)
|
||||
->where('status', 'normal')
|
||||
->find();
|
||||
if (!$link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$ipProtect = (int) $link->getAttr('ip_protect');
|
||||
$countries = (string) $link->getAttr('countries');
|
||||
if (!SplitIpProtectService::isAllowed($ipProtect, $countries, $clientIp)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @var Collection<int, Number>|array<int, Number> $numbers */
|
||||
$numbers = Number::where('split_link_id', (int) $link['id'])
|
||||
->where('status', 'normal')
|
||||
->order('id', 'asc')
|
||||
->field('id,number,number_type,number_type_custom')
|
||||
->select();
|
||||
|
||||
$count = is_countable($numbers) ? count($numbers) : 0;
|
||||
if ($count === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$linkId = (int) $link->getAttr('id');
|
||||
$index = $this->roundRobinStore->nextIndex($linkId, $count);
|
||||
$list = $numbers instanceof \think\Collection ? $numbers->all() : (array) $numbers;
|
||||
$picked = $list[$index] ?? $list[0] ?? null;
|
||||
if ($picked === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$numberType = is_array($picked) ? (string) ($picked['number_type'] ?? '') : (string) $picked->getAttr('number_type');
|
||||
$numberValue = is_array($picked) ? (string) ($picked['number'] ?? '') : (string) $picked->getAttr('number');
|
||||
$numberCustom = is_array($picked)
|
||||
? (string) ($picked['number_type_custom'] ?? '')
|
||||
: (string) $picked->getAttr('number_type_custom');
|
||||
|
||||
$redirectUrl = SplitFriendUrlBuilder::build($numberType, $numberValue, $numberCustom);
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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,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'] ?? []
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user