2026-06-03 12:10:25 +08:00
|
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
|
|
declare(strict_types=1);
|
|
|
|
|
|
|
|
|
|
|
|
namespace app\admin\controller\split;
|
|
|
|
|
|
|
|
|
|
|
|
use app\admin\model\Domain as DomainModel;
|
|
|
|
|
|
use app\common\controller\Backend;
|
|
|
|
|
|
use app\common\library\CountryIso;
|
|
|
|
|
|
use app\common\service\SplitAutoReplyService;
|
|
|
|
|
|
use app\common\service\SplitLinkCodeService;
|
2026-06-05 04:22:29 +08:00
|
|
|
|
use app\common\service\SplitPixelConfigService;
|
2026-06-03 12:10:25 +08:00
|
|
|
|
use app\common\service\SplitPlatformDomainService;
|
|
|
|
|
|
use think\Db;
|
|
|
|
|
|
use think\Exception;
|
|
|
|
|
|
use think\exception\DbException;
|
|
|
|
|
|
use think\exception\PDOException;
|
|
|
|
|
|
use think\exception\ValidateException;
|
|
|
|
|
|
use think\response\Json;
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 链接管理
|
|
|
|
|
|
*
|
|
|
|
|
|
* @icon fa fa-link
|
|
|
|
|
|
* @remark 分流链接管理,自动生成9位字母链接码
|
|
|
|
|
|
*/
|
|
|
|
|
|
class Link extends Backend
|
|
|
|
|
|
{
|
|
|
|
|
|
/** @var \app\admin\model\split\Link */
|
|
|
|
|
|
protected $model = null;
|
|
|
|
|
|
|
|
|
|
|
|
protected $searchFields = 'link_code,description';
|
|
|
|
|
|
|
|
|
|
|
|
protected $dataLimit = 'personal';
|
|
|
|
|
|
|
|
|
|
|
|
protected $modelValidate = true;
|
|
|
|
|
|
|
|
|
|
|
|
protected $modelSceneValidate = true;
|
|
|
|
|
|
|
|
|
|
|
|
/** @var string[] 无需鉴权的操作 */
|
|
|
|
|
|
protected $noNeedRight = ['script', 'copyinfo'];
|
|
|
|
|
|
|
|
|
|
|
|
/** @var string patches 视图目录(未完整部署时使用) */
|
|
|
|
|
|
private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/link/';
|
|
|
|
|
|
|
|
|
|
|
|
public function _initialize()
|
|
|
|
|
|
{
|
|
|
|
|
|
parent::_initialize();
|
|
|
|
|
|
|
|
|
|
|
|
$lang = $this->request->langset();
|
|
|
|
|
|
$lang = preg_match('/^([a-zA-Z\-_]{2,10})$/i', $lang) ? $lang : 'zh-cn';
|
|
|
|
|
|
$langFile = ROOT_PATH . 'patches/application/admin/lang/' . $lang . '/split/link.php';
|
|
|
|
|
|
if (is_file($langFile)) {
|
|
|
|
|
|
\think\Lang::load($langFile);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$this->model = new \app\admin\model\split\Link();
|
|
|
|
|
|
$countryList = CountryIso::getOptions();
|
|
|
|
|
|
$this->view->assign('countryList', $countryList);
|
|
|
|
|
|
$this->view->assign('ipProtectList', $this->model->getIpProtectList());
|
|
|
|
|
|
$this->view->assign('randomShuffleList', $this->model->getRandomShuffleList());
|
|
|
|
|
|
$this->view->assign('statusList', $this->model->getStatusList());
|
|
|
|
|
|
$this->assignconfig('countryList', $countryList);
|
|
|
|
|
|
$this->assignconfig('ipProtectList', $this->model->getIpProtectList());
|
|
|
|
|
|
$this->assignconfig('randomShuffleList', $this->model->getRandomShuffleList());
|
|
|
|
|
|
$this->assignconfig('statusList', $this->model->getStatusList());
|
|
|
|
|
|
|
|
|
|
|
|
$this->setupPatchFrontend();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 未部署 JS 到 public 时,或 patches 版本较新时,jsname 指向 script 接口
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function setupPatchFrontend(): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$patchJs = ROOT_PATH . 'patches/public/assets/js/backend/split/link.js';
|
|
|
|
|
|
$publicJs = ROOT_PATH . 'public/assets/js/backend/split/link.js';
|
|
|
|
|
|
$usePatchJs = is_file($patchJs) && (
|
|
|
|
|
|
!is_file($publicJs) || filemtime($patchJs) >= filemtime($publicJs)
|
|
|
|
|
|
);
|
|
|
|
|
|
if (!$usePatchJs) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$cfg = is_array($this->view->config ?? null) ? $this->view->config : [];
|
|
|
|
|
|
$version = (string) \think\Config::get('site.version');
|
|
|
|
|
|
$scriptUrl = (string) url('split.link/script', ['v' => $version], false, true);
|
|
|
|
|
|
if (strpos($scriptUrl, '?') === false) {
|
|
|
|
|
|
$scriptUrl .= '?v=' . $version;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (strpos($scriptUrl, '://') === false) {
|
|
|
|
|
|
$scriptUrl = $this->request->domain() . $scriptUrl;
|
|
|
|
|
|
}
|
|
|
|
|
|
$cfg['jsname'] = $scriptUrl;
|
|
|
|
|
|
$this->view->assign('config', $cfg);
|
|
|
|
|
|
$this->view->config = $cfg;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 渲染模板(优先 patches 视图,回退 application)
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function fetchPatch(string $template): string
|
|
|
|
|
|
{
|
|
|
|
|
|
$patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html';
|
|
|
|
|
|
$appFile = APP_PATH . 'admin/view/split/link/' . $template . '.html';
|
|
|
|
|
|
if (is_file($patchFile)) {
|
|
|
|
|
|
$file = $patchFile;
|
|
|
|
|
|
} elseif (is_file($appFile)) {
|
|
|
|
|
|
$file = $appFile;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
$this->error('模板文件不存在');
|
|
|
|
|
|
}
|
|
|
|
|
|
return (string) $this->view->fetch($file);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 列表
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return string|Json
|
|
|
|
|
|
* @throws DbException
|
|
|
|
|
|
* @throws \think\Exception
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function index()
|
|
|
|
|
|
{
|
|
|
|
|
|
$this->request->filter(['strip_tags', 'trim']);
|
|
|
|
|
|
if (false === $this->request->isAjax()) {
|
|
|
|
|
|
return $this->fetchPatch('index');
|
|
|
|
|
|
}
|
|
|
|
|
|
if ($this->request->request('keyField')) {
|
|
|
|
|
|
return $this->selectpage();
|
|
|
|
|
|
}
|
|
|
|
|
|
[$where, $sort, $order, $offset, $limit] = $this->buildparams();
|
|
|
|
|
|
$list = $this->model
|
|
|
|
|
|
->where($where)
|
|
|
|
|
|
->order($sort, $order)
|
|
|
|
|
|
->paginate($limit);
|
|
|
|
|
|
$result = ['total' => $list->total(), 'rows' => $list->items()];
|
|
|
|
|
|
return json($result);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 添加(打开表单时预生成分流链接,可编辑后保存)
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function add()
|
|
|
|
|
|
{
|
|
|
|
|
|
if (false === $this->request->isPost()) {
|
|
|
|
|
|
try {
|
|
|
|
|
|
$defaultLinkCode = (new SplitLinkCodeService())->generateUnique();
|
|
|
|
|
|
} catch (Exception $e) {
|
|
|
|
|
|
$this->error($e->getMessage());
|
|
|
|
|
|
}
|
|
|
|
|
|
$this->view->assign('defaultLinkCode', $defaultLinkCode);
|
|
|
|
|
|
return $this->fetchPatch('add');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$params = $this->request->post('row/a', []);
|
|
|
|
|
|
if ($params === []) {
|
|
|
|
|
|
$this->error(__('Parameter %s can not be empty', ''));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$params = $this->preExcludeFields($params);
|
|
|
|
|
|
|
|
|
|
|
|
if (isset($params['link_code'])) {
|
|
|
|
|
|
$params['link_code'] = SplitLinkCodeService::normalize((string) $params['link_code']);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ($this->dataLimit && $this->dataLimitFieldAutoFill) {
|
|
|
|
|
|
$params[$this->dataLimitField] = $this->auth->id;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$result = false;
|
|
|
|
|
|
Db::startTrans();
|
|
|
|
|
|
try {
|
|
|
|
|
|
if ($this->modelValidate) {
|
|
|
|
|
|
$name = str_replace('\\model\\', '\\validate\\', get_class($this->model));
|
|
|
|
|
|
$validate = $this->modelSceneValidate ? $name . '.add' : $name;
|
|
|
|
|
|
$this->model->validateFailException()->validate($validate, $params);
|
|
|
|
|
|
}
|
|
|
|
|
|
$result = $this->model->allowField(true)->save($params);
|
|
|
|
|
|
Db::commit();
|
|
|
|
|
|
} catch (ValidateException $e) {
|
|
|
|
|
|
Db::rollback();
|
|
|
|
|
|
$this->error($e->getMessage());
|
|
|
|
|
|
} catch (PDOException|Exception $e) {
|
|
|
|
|
|
Db::rollback();
|
|
|
|
|
|
$this->error($e->getMessage());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ($result === false) {
|
|
|
|
|
|
$this->error(__('No rows were inserted'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$this->success();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 复制分流链接弹窗所需数据(平台域名 + 我的域名列表)
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return Json
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function copyinfo(): Json
|
|
|
|
|
|
{
|
|
|
|
|
|
$platformDomains = $this->getPlatformDomains();
|
|
|
|
|
|
|
|
|
|
|
|
$domainQuery = (new DomainModel())->order('id', 'desc');
|
|
|
|
|
|
if ($this->dataLimit) {
|
|
|
|
|
|
$adminIds = $this->getDataLimitAdminIds();
|
|
|
|
|
|
if (is_array($adminIds)) {
|
|
|
|
|
|
$domainQuery->where($this->dataLimitField, 'in', $adminIds);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$myDomains = [];
|
|
|
|
|
|
foreach ($domainQuery->select() as $domainRow) {
|
|
|
|
|
|
$myDomains[] = [
|
|
|
|
|
|
'id' => (int) $domainRow['id'],
|
|
|
|
|
|
'domain' => (string) $domainRow['domain'],
|
|
|
|
|
|
'ns_status' => (string) $domainRow['ns_status'],
|
|
|
|
|
|
'ns_status_text' => (string) $domainRow['ns_status_text'],
|
|
|
|
|
|
'dns_status' => (string) $domainRow['dns_status'],
|
|
|
|
|
|
'dns_status_text' => (string) $domainRow['dns_status_text'],
|
|
|
|
|
|
];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$this->success('', null, [
|
|
|
|
|
|
'platform_domains' => $platformDomains,
|
|
|
|
|
|
'my_domains' => $myDomains,
|
2026-06-05 04:22:29 +08:00
|
|
|
|
'domain_index_url' => (string) url('domain', '', false, false),
|
|
|
|
|
|
'domain_add_url' => (string) url('domain/add', '', false, false),
|
2026-06-03 12:10:25 +08:00
|
|
|
|
'config_index_url' => (string) url('general/config/index'),
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 读取平台分配域名列表(优先 site 配置,回退数据库)
|
|
|
|
|
|
*
|
|
|
|
|
|
* @return array<int, string>
|
|
|
|
|
|
*/
|
|
|
|
|
|
private function getPlatformDomains(): array
|
|
|
|
|
|
{
|
|
|
|
|
|
$raw = trim((string) \think\Config::get('site.split_platform_domain'));
|
|
|
|
|
|
if ($raw === '') {
|
|
|
|
|
|
$raw = trim((string) Db::name('config')->where('name', 'split_platform_domain')->value('value'));
|
|
|
|
|
|
}
|
|
|
|
|
|
return SplitPlatformDomainService::parseList($raw);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 自动回复:读取 / 保存与当前链接关联的回复语句
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param string|null $ids 链接 ID
|
|
|
|
|
|
* @return Json
|
|
|
|
|
|
* @throws DbException
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function autoreply($ids = null): Json
|
|
|
|
|
|
{
|
|
|
|
|
|
$row = $this->model->get($ids);
|
|
|
|
|
|
if (!$row) {
|
|
|
|
|
|
$this->error(__('No Results were found'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$adminIds = $this->getDataLimitAdminIds();
|
|
|
|
|
|
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
|
|
|
|
|
|
$this->error(__('You have no permission'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ($this->request->isPost()) {
|
|
|
|
|
|
$autoReply = (string) $this->request->post('auto_reply', '');
|
|
|
|
|
|
$lines = SplitAutoReplyService::parseLines($autoReply);
|
|
|
|
|
|
try {
|
|
|
|
|
|
$row->save([
|
|
|
|
|
|
'auto_reply' => SplitAutoReplyService::formatStorage($lines),
|
|
|
|
|
|
]);
|
|
|
|
|
|
} catch (PDOException|Exception $e) {
|
|
|
|
|
|
$this->error($e->getMessage());
|
|
|
|
|
|
}
|
|
|
|
|
|
$this->success(__('Auto reply saved'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$this->success('', null, [
|
|
|
|
|
|
'id' => (int) $row['id'],
|
|
|
|
|
|
'link_code' => (string) $row['link_code'],
|
|
|
|
|
|
'auto_reply' => SplitAutoReplyService::formatDisplay((string) $row->getAttr('auto_reply')),
|
|
|
|
|
|
'line_count' => count($row->getAutoReplyLines()),
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-05 04:22:29 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 像素配置:读取 / 保存
|
|
|
|
|
|
*
|
|
|
|
|
|
* @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,
|
|
|
|
|
|
]);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 12:10:25 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 编辑(分流链接码不可修改)
|
|
|
|
|
|
*
|
|
|
|
|
|
* @param string|null $ids
|
|
|
|
|
|
* @return string
|
|
|
|
|
|
* @throws DbException
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function edit($ids = null)
|
|
|
|
|
|
{
|
|
|
|
|
|
$row = $this->model->get($ids);
|
|
|
|
|
|
if (!$row) {
|
|
|
|
|
|
$this->error(__('No Results were found'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$adminIds = $this->getDataLimitAdminIds();
|
|
|
|
|
|
if (is_array($adminIds) && !in_array($row[$this->dataLimitField], $adminIds)) {
|
|
|
|
|
|
$this->error(__('You have no permission'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (false === $this->request->isPost()) {
|
|
|
|
|
|
$this->view->assign('row', $row);
|
|
|
|
|
|
$this->view->assign('selectedCountries', $row->getSelectedCountries());
|
|
|
|
|
|
return $this->fetchPatch('edit');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$params = $this->request->post('row/a', []);
|
|
|
|
|
|
if ($params === []) {
|
|
|
|
|
|
$this->error(__('Parameter %s can not be empty', ''));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$params = $this->preExcludeFields($params);
|
|
|
|
|
|
unset($params['link_code']);
|
|
|
|
|
|
|
|
|
|
|
|
$result = false;
|
|
|
|
|
|
Db::startTrans();
|
|
|
|
|
|
try {
|
|
|
|
|
|
if ($this->modelValidate) {
|
|
|
|
|
|
$name = str_replace('\\model\\', '\\validate\\', get_class($this->model));
|
|
|
|
|
|
$validate = $this->modelSceneValidate ? $name . '.edit' : $name;
|
|
|
|
|
|
$row->validateFailException()->validate($validate, $params);
|
|
|
|
|
|
}
|
|
|
|
|
|
$result = $row->allowField(true)->save($params);
|
|
|
|
|
|
Db::commit();
|
|
|
|
|
|
} catch (ValidateException $e) {
|
|
|
|
|
|
Db::rollback();
|
|
|
|
|
|
$this->error($e->getMessage());
|
|
|
|
|
|
} catch (PDOException|Exception $e) {
|
|
|
|
|
|
Db::rollback();
|
|
|
|
|
|
$this->error($e->getMessage());
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if ($result === false) {
|
|
|
|
|
|
$this->error(__('No rows were updated'));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
$this->success();
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* 输出后台列表/表单 JS(patches 未部署到 public 时由 RequireJS 加载)
|
|
|
|
|
|
*/
|
|
|
|
|
|
public function script(): void
|
|
|
|
|
|
{
|
|
|
|
|
|
$jsFile = ROOT_PATH . 'patches/public/assets/js/backend/split/link.js';
|
|
|
|
|
|
if (!is_file($jsFile)) {
|
|
|
|
|
|
$jsFile = ROOT_PATH . 'public/assets/js/backend/split/link.js';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (!is_file($jsFile)) {
|
|
|
|
|
|
$this->error('脚本文件不存在');
|
|
|
|
|
|
}
|
|
|
|
|
|
$content = file_get_contents($jsFile);
|
|
|
|
|
|
if ($content === false) {
|
|
|
|
|
|
$this->error('读取脚本失败');
|
|
|
|
|
|
}
|
|
|
|
|
|
$response = response($content, 200, [
|
|
|
|
|
|
'Content-Type' => 'application/javascript; charset=utf-8',
|
|
|
|
|
|
'Cache-Control' => 'public, max-age=3600',
|
|
|
|
|
|
]);
|
|
|
|
|
|
throw new \think\exception\HttpResponseException($response);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|