前端分流页功能
This commit is contained in:
@@ -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;
|
||||
@@ -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 Token,Token 留空会保留原配置。',
|
||||
'Facebook Pixel' => 'Facebook Pixel',
|
||||
'TikTok Pixel' => 'TikTok Pixel',
|
||||
'Add FB Pixel' => '+ 添加FB Pixel',
|
||||
'Add TK Pixel' => '+ 添加TK Pixel',
|
||||
'Pixel enabled' => '启用',
|
||||
'Server postback' => '服务端回传',
|
||||
'Pixel ID' => 'Pixel ID',
|
||||
'Access Token' => 'Access Token',
|
||||
'Test code' => '测试码',
|
||||
'Pixel event' => '事件',
|
||||
'Pixel sort' => '排序',
|
||||
'Pixel config saved' => '像素配置已保存',
|
||||
'Pixel ID required' => '请填写 Pixel ID',
|
||||
'Remove row' => '删除',
|
||||
'Optional' => '选填',
|
||||
'Pixel list empty' => '暂无配置,请点击上方按钮添加',
|
||||
'Pixel row index' => '序号',
|
||||
'Operate' => '操作',
|
||||
];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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'] ?? []
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
+20
-9
@@ -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, // 超时时间
|
||||
],
|
||||
|
||||
// +----------------------------------------------------------------------
|
||||
// | 会话设置
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -11,11 +11,14 @@
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
return [
|
||||
// 分流链接公开落地页:/s/{9位链接码}
|
||||
's/:link_code' => 'index/Split/visit',
|
||||
//别名配置,别名只能是映射到控制器且访问时必须加上请求的方法
|
||||
'__alias__' => [
|
||||
],
|
||||
//变量规则
|
||||
'__pattern__' => [
|
||||
'link_code' => '[a-z]{9}',
|
||||
],
|
||||
// 域名绑定到模块
|
||||
// '__domain__' => [
|
||||
|
||||
Reference in New Issue
Block a user