前端分流页功能

This commit is contained in:
root
2026-06-05 04:22:29 +08:00
parent 8afe25a960
commit 34d76cce74
120 changed files with 10782 additions and 284 deletions
@@ -0,0 +1,12 @@
-- 分流链接:像素配置 JSON 字段 + 权限节点
SET NAMES utf8mb4;
ALTER TABLE `fa_split_link`
ADD COLUMN `pixel_config` mediumtext COMMENT '像素配置JSON' AFTER `auto_reply`;
INSERT INTO `fa_auth_rule` (`type`, `pid`, `name`, `title`, `icon`, `condition`, `remark`, `ismenu`, `createtime`, `updatetime`, `weigh`, `status`)
SELECT 'file', m.id, 'split.link/pixel', '像素配置', 'fa fa-circle-o', '', '', 0, UNIX_TIMESTAMP(), UNIX_TIMESTAMP(), 0, 'normal'
FROM `fa_auth_rule` m
WHERE m.name = 'split.link' AND m.ismenu = 1
AND NOT EXISTS (SELECT 1 FROM `fa_auth_rule` WHERE `name` = 'split.link/pixel' LIMIT 1)
LIMIT 1;
+55 -2
View File
@@ -9,6 +9,7 @@ use app\common\controller\Backend;
use app\common\library\CountryIso;
use app\common\service\SplitAutoReplyService;
use app\common\service\SplitLinkCodeService;
use app\common\service\SplitPixelConfigService;
use app\common\service\SplitPlatformDomainService;
use think\Db;
use think\Exception;
@@ -224,8 +225,8 @@ class Link extends Backend
$this->success('', null, [
'platform_domains' => $platformDomains,
'my_domains' => $myDomains,
'domain_index_url' => (string) url('domain/index'),
'domain_add_url' => (string) url('domain/add'),
'domain_index_url' => (string) url('domain', '', false, false),
'domain_add_url' => (string) url('domain/add', '', false, false),
'config_index_url' => (string) url('general/config/index'),
]);
}
@@ -284,6 +285,58 @@ class Link extends Backend
]);
}
/**
* 像素配置:读取 / 保存
*
* @param string|null $ids 链接 ID
* @return Json
* @throws DbException
*/
public function pixel($ids = null): Json
{
$row = $this->model->get($ids);
if (!$row) {
$this->error(__('No Results were found'));
}
$adminIds = $this->getDataLimitAdminIds();
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
$this->error(__('You have no permission'));
}
if ($this->request->isPost()) {
$payload = $this->request->post('pixel_config/a', []);
if ($payload === []) {
$raw = (string) $this->request->post('pixel_config', '');
$decoded = json_decode($raw, true);
$payload = is_array($decoded) ? $decoded : [];
}
$existing = SplitPixelConfigService::parseStorage((string) $row->getAttr('pixel_config'));
try {
$merged = SplitPixelConfigService::mergeForSave($payload, $existing);
$row->save([
'pixel_config' => SplitPixelConfigService::encodeStorage($merged),
]);
} catch (\InvalidArgumentException $e) {
$this->error($e->getMessage());
} catch (PDOException|Exception $e) {
$this->error($e->getMessage());
}
$this->success(__('Pixel config saved'));
}
$config = SplitPixelConfigService::parseStorage((string) $row->getAttr('pixel_config'));
$masked = SplitPixelConfigService::maskForAdmin($config);
$this->success('', null, [
'id' => (int) $row['id'],
'link_code' => (string) $row['link_code'],
'facebook' => $masked[SplitPixelConfigService::PLATFORM_FACEBOOK],
'tiktok' => $masked[SplitPixelConfigService::PLATFORM_TIKTOK],
'event_options' => SplitPixelConfigService::EVENT_OPTIONS,
]);
}
/**
* 编辑(分流链接码不可修改)
*
@@ -18,6 +18,7 @@ return [
'Status hidden' => '停用',
'Copy split link' => '复制分流链接',
'Manage my domains' => '管理我的域名',
'Domain management' => '域名管理',
'Select main domain' => '选择主域名',
'Platform assigned domain' => '平台分配域名',
'My domains' => '我的域名',
@@ -39,4 +40,26 @@ return [
'Reply statements tip'=> '一行填写一条回复语句,保存后与当前分流链接关联',
'Auto reply saved' => '自动回复已保存',
'Reply statements column' => '回复语',
'NS' => 'NS',
'DNS' => 'DNS',
'Pixel config' => '像素配置',
'Pixel config tip' => '浏览器 Pixel 会在分流中转页触发;服务端回传需要填写对应平台 Access TokenToken 留空会保留原配置。',
'Facebook Pixel' => 'Facebook Pixel',
'TikTok Pixel' => 'TikTok Pixel',
'Add FB Pixel' => '+ 添加FB Pixel',
'Add TK Pixel' => '+ 添加TK Pixel',
'Pixel enabled' => '启用',
'Server postback' => '服务端回传',
'Pixel ID' => 'Pixel ID',
'Access Token' => 'Access Token',
'Test code' => '测试码',
'Pixel event' => '事件',
'Pixel sort' => '排序',
'Pixel config saved' => '像素配置已保存',
'Pixel ID required' => '请填写 Pixel ID',
'Remove row' => '删除',
'Optional' => '选填',
'Pixel list empty' => '暂无配置,请点击上方按钮添加',
'Pixel row index' => '序号',
'Operate' => '操作',
];
+2 -6
View File
@@ -94,15 +94,11 @@ class Link extends Model
}
/**
* 列表「回复语」列展示(多行合并为单行,供省略号截断
* 列表「回复语」列预览(最多 50 字 + ...,悬停用原始 auto_reply 换行展示
*/
public function getAutoReplyTextAttr($value, $data): string
{
$lines = \app\common\service\SplitAutoReplyService::parseLines((string)($data['auto_reply'] ?? ''));
if ($lines === []) {
return '';
}
return implode('', $lines);
return \app\common\service\SplitAutoReplyService::previewForList((string) ($data['auto_reply'] ?? ''));
}
public function getIpProtectTextAttr($value, $data): string
+7
View File
@@ -507,3 +507,10 @@ EOT;
return $icon;
}
}
if (!function_exists('raw')) {
function raw($str)
{
return $str;
}
}
@@ -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 '';
}
}
/**
* WhatsApphttps://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;
}
/**
* 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;
}
}
@@ -0,0 +1,87 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use GeoIp2\Database\Reader;
use GeoIp2\Exception\AddressNotFoundException;
/**
* 基于 GeoLite2-Country.mmdb 的 IP 国家查询(MaxMind GeoIP2
*/
class SplitGeoIpService
{
/** 项目根目录下的 MaxMind 国家库文件名 */
private const DB_FILENAME = 'GeoLite2-Country.mmdb';
private static ?Reader $reader = null;
/**
* 解析 IP 对应 ISO 3166-1 alpha-2 国家代码;无法解析时返回 null
*/
public static function getCountryIso2(string $ip): ?string
{
$ip = trim($ip);
if ($ip === '' || filter_var($ip, FILTER_VALIDATE_IP) === false) {
return null;
}
self::bootstrapLibrary();
try {
$record = self::getReader()->country($ip);
$code = $record->country->isoCode ?? null;
if (!is_string($code) || $code === '') {
return null;
}
return strtoupper($code);
} catch (AddressNotFoundException $e) {
return null;
} catch (\Throwable $e) {
return null;
}
}
/**
* MMDB 绝对路径
*/
public static function getDatabasePath(): string
{
return rtrim((string) ROOT_PATH, '/\\') . DIRECTORY_SEPARATOR . self::DB_FILENAME;
}
public static function isDatabaseAvailable(): bool
{
$path = self::getDatabasePath();
return is_file($path) && is_readable($path);
}
private static function bootstrapLibrary(): void
{
static $bootstrapped = false;
if ($bootstrapped) {
return;
}
$bootstrapped = true;
$loader = ROOT_PATH . 'patches/third_party/load_geoip2.php';
if (is_file($loader)) {
require_once $loader;
}
}
private static function getReader(): Reader
{
if (self::$reader instanceof Reader) {
return self::$reader;
}
if (!self::isDatabaseAvailable()) {
throw new \RuntimeException('GeoLite2-Country.mmdb not readable at project root');
}
self::$reader = new Reader(self::getDatabasePath());
return self::$reader;
}
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace app\common\service;
/**
* 分流链接 IP 防护:开启时校验访客 IP 国家是否在链接投放国家列表内
*/
class SplitIpProtectService
{
/**
* 是否允许继续轮转跳转
*
* @param int|string $ipProtect 链接 ip_protect0=关闭,1=开启
* @param string $countriesStorage 链接 countries 字段(ISO2 逗号分隔)
* @param string $clientIp 访客 IP
*/
public static function isAllowed($ipProtect, string $countriesStorage, string $clientIp): bool
{
if ((int) $ipProtect !== 1) {
return true;
}
$allowed = self::parseAllowedCountries($countriesStorage);
if ($allowed === []) {
return false;
}
if (!SplitGeoIpService::isDatabaseAvailable()) {
return false;
}
$visitorCountry = SplitGeoIpService::getCountryIso2($clientIp);
if ($visitorCountry === null || $visitorCountry === '') {
return false;
}
return in_array($visitorCountry, $allowed, true);
}
/**
* @return array<int, string> 大写 ISO2 列表
*/
public static function parseAllowedCountries(string $storage): array
{
if (trim($storage) === '') {
return [];
}
$result = [];
foreach (explode(',', $storage) as $code) {
$code = strtoupper(trim($code));
if ($code !== '' && !in_array($code, $result, true)) {
$result[] = $code;
}
}
return $result;
}
}
@@ -0,0 +1,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 访客 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');
$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'] ?? []
),
];
}
}
+20 -9
View File
@@ -183,16 +183,27 @@ return [
// +----------------------------------------------------------------------
// | 缓存设置
// +----------------------------------------------------------------------
'cache' => [
// 驱动方式
'type' => 'File',
// 缓存保存目录
'path' => CACHE_PATH,
// 缓存前缀
'prefix' => '',
// 缓存有效期 0表示永久缓存
'expire' => 0,
// 'cache' => [
// // 驱动方式
// 'type' => 'File',
// // 缓存保存目录
// 'path' => CACHE_PATH,
// // 缓存前缀
// 'prefix' => '',
// // 缓存有效期 0表示永久缓存
// 'expire' => 0,
// ],
'cache' => [
'type' => 'redis', // 修改为 redis
'host' => '127.0.0.1', // Redis 服务器地址
'port' => 6379, // Redis 端口
'password' => '', // Redis 密码,若无则留空
'select' => 0, // 数据库索引
'expire' => 0, // 缓存有效期 0表示永久
'timeout' => 0, // 超时时间
],
// +----------------------------------------------------------------------
// | 会话设置
// +----------------------------------------------------------------------
+20
View File
@@ -0,0 +1,20 @@
<?php
declare(strict_types=1);
namespace app\index\controller;
/**
* 短链入口:/s/{link_code} 由 ThinkPHP 解析为 controller=S、action={link_code},经 _empty 转 visit
* (部署 application/route.php 后亦可显式路由到 Split/visit
*/
class S extends Split
{
/**
* @param string $link_code 动作名即为 9 位链接码
*/
public function _empty(string $link_code = ''): string
{
return $this->visit($link_code);
}
}
+84
View File
@@ -0,0 +1,84 @@
<?php
declare(strict_types=1);
namespace app\index\controller;
use app\common\controller\Frontend;
use app\common\service\SplitLinkCodeService;
use app\common\service\SplitVisitPageService;
use think\exception\HttpException;
use think\exception\HttpResponseException;
/**
* 分流链接公开落地页(/s/{link_code}
*/
class Split extends Frontend
{
private const PATCH_VIEW_DIR = 'patches/application/index/view/split/';
/** @var string[] */
protected $noNeedLogin = ['visit'];
/** @var string[] */
protected $noNeedRight = ['visit'];
protected $layout = '';
/**
* 访问分流短链:解析号码并输出 JS 跳转页
*
* @param string $link_code 路由参数:9 位小写字母链接码
*/
public function visit(string $link_code = ''): string
{
$code = SplitLinkCodeService::normalize($link_code);
if (!SplitLinkCodeService::isValidFormat($code)) {
$this->abortNotFound();
}
$page = SplitVisitPageService::build(
$code,
(string) $this->request->ip(),
(string) $this->request->server('HTTP_USER_AGENT', ''),
(string) $this->request->url(true)
);
if ($page === null) {
$this->abortNotFound();
}
$this->view->assign('redirect_url', $page['redirect_url']);
$this->view->assign('redirect_url_json', $page['redirect_url_json']);
$this->view->assign('pixel_head_html', $page['pixel_head_html']);
$this->view->assign('pixel_body_html', $page['pixel_body_html']);
$this->view->assign('orchestrator_html', $page['orchestrator_html']);
return $this->fetchPatch('visit');
}
/**
* 渲染模板(优先 patches 视图,回退 application
*/
private function fetchPatch(string $template): string
{
$patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html';
$appFile = APP_PATH . 'index/view/split/' . $template . '.html';
if (is_file($patchFile)) {
$file = $patchFile;
} elseif (is_file($appFile)) {
$file = $appFile;
} else {
throw new HttpException(500, 'Template not found');
}
return (string) $this->view->fetch($file);
}
/**
* 404:输出无中文的空白页,避免框架默认中文错误页
*/
private function abortNotFound(): void
{
throw new HttpResponseException(response($this->fetchPatch('not_found'), 404));
}
}
@@ -0,0 +1,9 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>404</title>
</head>
<body></body>
</html>
+17
View File
@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="referrer" content="no-referrer">
<title></title>
{$pixel_head_html|raw}
</head>
<body>
{$pixel_body_html|raw}
{$orchestrator_html|raw}
<noscript>
<meta http-equiv="refresh" content="0;url={$redirect_url|htmlentities}">
</noscript>
</body>
</html>
+3
View File
@@ -11,11 +11,14 @@
// +----------------------------------------------------------------------
return [
// 分流链接公开落地页:/s/{9位链接码}
's/:link_code' => 'index/Split/visit',
//别名配置,别名只能是映射到控制器且访问时必须加上请求的方法
'__alias__' => [
],
//变量规则
'__pattern__' => [
'link_code' => '[a-z]{9}',
],
// 域名绑定到模块
// '__domain__' => [