初始版本

This commit is contained in:
root
2026-06-14 21:12:22 +08:00
commit 6a81742dc7
806 changed files with 179174 additions and 0 deletions
+415
View File
@@ -0,0 +1,415 @@
<?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;
use app\common\service\SplitPixelConfigService;
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');
// 使用站内相对路径,避免生产环境 domain() 与反向代理/HTTPS 不一致导致 JS 跨域丢 Cookie
$scriptUrl = (string) url('split.link/script', ['v' => $version], '', false);
$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,
'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'),
]);
}
/**
* 读取平台分配域名列表(优先 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()),
]);
}
/**
* 像素配置:读取 / 保存
*
* @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,
]);
}
/**
* 编辑(分流链接码不可修改)
*
* @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();
}
/**
* 输出后台列表/表单 JSpatches 未部署到 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);
}
}
+435
View File
@@ -0,0 +1,435 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\split;
use app\admin\model\split\Link as LinkModel;
use app\admin\model\split\Number as NumberModel;
use app\common\controller\Backend;
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-phone
* @remark 分流号码管理,关联分流链接
*/
class Number extends Backend
{
/** @var NumberModel */
protected $model = null;
protected $searchFields = 'ticket_name,number';
protected $dataLimit = 'personal';
protected $modelValidate = true;
protected $modelSceneValidate = true;
/** @var string */
protected $multiFields = 'status,manual_manage';
/** @var string[] */
protected $noNeedRight = ['script'];
/** @var string */
private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/number/';
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/number.php';
if (is_file($langFile)) {
\think\Lang::load($langFile);
}
$this->model = new NumberModel();
$this->view->assign('numberTypeList', $this->model->getNumberTypeList());
$this->view->assign('statusList', $this->model->getStatusList());
$this->view->assign('manualManageList', $this->model->getManualManageList());
$this->view->assign('splitLinkList', $this->buildSplitLinkList());
$this->assignconfig('numberTypeList', $this->model->getNumberTypeList());
$this->assignconfig('statusList', $this->model->getStatusList());
$this->assignconfig('manualManageList', $this->model->getManualManageList());
$this->assignconfig('platformStatusList', $this->model->getPlatformStatusList());
$this->setupPatchFrontend();
}
private function setupPatchFrontend(): void
{
$patchJs = ROOT_PATH . 'patches/public/assets/js/backend/split/number.js';
$publicJs = ROOT_PATH . 'public/assets/js/backend/split/number.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');
// 使用站内相对路径,避免生产环境 domain() 与反向代理/HTTPS 不一致导致 JS 跨域丢 Cookie
$scriptUrl = (string) url('split.number/script', ['v' => $version], '', false);
$cfg['jsname'] = $scriptUrl;
$this->view->assign('config', $cfg);
$this->view->config = $cfg;
}
/**
* @return array<int, array<string, string>>
*/
private function buildSplitLinkList(): array
{
$query = (new LinkModel())->where('status', 'normal')->order('id', 'desc');
if ($this->dataLimit) {
$adminIds = $this->getDataLimitAdminIds();
if (is_array($adminIds)) {
$query->where('admin_id', 'in', $adminIds);
}
}
$list = [];
foreach ($query->select() as $row) {
$code = (string) $row['link_code'];
$desc = (string) $row['description'];
$label = $desc !== '' ? $code . ' - ' . $desc : $code;
$list[] = [
'id' => (string) $row['id'],
'label' => $label,
];
}
return $list;
}
private function fetchPatch(string $template): string
{
$patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html';
$appFile = APP_PATH . 'admin/view/split/number/' . $template . '.html';
if (is_file($patchFile)) {
$file = $patchFile;
} elseif (is_file($appFile)) {
$file = $appFile;
} else {
$this->error('模板文件不存在');
}
return (string) $this->view->fetch($file);
}
/**
* 列表状态开关:手动关闭时标记 manual_manage=1,防止同步自动恢复开启
*
* @param string $ids
*/
public function multi($ids = '')
{
if (!$this->request->isPost()) {
$this->error(__('Invalid parameters'));
}
$ids = $ids ?: $this->request->post('ids', '');
if ($ids === '') {
$this->error(__('Parameter %s can not be empty', 'ids'));
}
$params = $this->request->post('params', '');
parse_str((string) $params, $values);
if (!isset($values['status'])) {
parent::multi($ids);
return;
}
if ((string) $values['status'] === 'hidden') {
$values['manual_manage'] = 1;
} elseif ((string) $values['status'] === 'normal') {
$values['manual_manage'] = 0;
}
$adminIds = $this->getDataLimitAdminIds();
if (is_array($adminIds)) {
$this->model->where($this->dataLimitField, 'in', $adminIds);
}
$count = 0;
Db::startTrans();
try {
$list = $this->model->where($this->model->getPk(), 'in', $ids)->select();
foreach ($list as $item) {
$count += $item->allowField(true)->isUpdate(true)->save($values);
}
Db::commit();
} catch (ValidateException $e) {
Db::rollback();
$this->error($e->getMessage());
} catch (PDOException|Exception $e) {
Db::rollback();
$this->error($e->getMessage());
}
if ($count > 0) {
$this->success();
}
$this->error(__('No rows were updated'));
}
/**
* @return string|Json
* @throws DbException
*/
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
->with(['splitLink'])
->where($where)
->order($sort, $order)
->paginate($limit);
$result = ['total' => $list->total(), 'rows' => $list->items()];
return json($result);
}
/**
* @return string
* @throws DbException
*/
public function add()
{
if (false === $this->request->isPost()) {
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 (($params['number_type'] ?? '') !== 'custom') {
$params['number_type_custom'] = '';
}
$numbersText = (string) ($params['numbers'] ?? '');
unset($params['numbers']);
$numberList = NumberModel::parseNumbersText($numbersText);
if ($numberList === []) {
$this->error(__('Please fill at least one number'));
}
$validateData = array_merge($params, ['numbers' => $numbersText]);
$adminId = $this->dataLimit && $this->dataLimitFieldAutoFill ? (int) $this->auth->id : 0;
$inserted = 0;
$skipped = 0;
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, $validateData);
}
$splitLinkId = (int) ($params['split_link_id'] ?? 0);
$baseRow = [
'split_link_id' => $splitLinkId,
'ticket_name' => (string) ($params['ticket_name'] ?? ''),
'number_type' => (string) ($params['number_type'] ?? ''),
'number_type_custom' => (string) ($params['number_type_custom'] ?? ''),
'status' => (string) ($params['status'] ?? 'normal'),
'visit_count' => 0,
'inbound_count' => 0,
'manual_manage' => 0,
];
if ($adminId > 0) {
$baseRow['admin_id'] = $adminId;
}
foreach ($numberList as $num) {
$exists = $this->model
->where('split_link_id', $splitLinkId)
->where('number', $num)
->find();
if ($exists) {
$skipped++;
continue;
}
$row = $baseRow;
$row['number'] = $num;
$item = new NumberModel();
$item->allowField(true)->isUpdate(false)->save($row);
$inserted++;
}
if ($inserted === 0) {
throw new Exception(__('All numbers already exist for this link'));
}
Db::commit();
} catch (ValidateException $e) {
Db::rollback();
$this->error($e->getMessage());
} catch (PDOException|Exception $e) {
Db::rollback();
$this->error($e->getMessage());
}
$msg = __('Inserted %d number(s)', $inserted);
if ($skipped > 0) {
$msg .= '' . __('Skipped %d duplicate(s)', $skipped);
}
$this->success($msg);
}
/**
* @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((int) $row[$this->dataLimitField], $adminIds, true)) {
$this->error(__('You have no permission'));
}
if (false === $this->request->isPost()) {
$this->view->assign('row', $row);
return $this->fetchPatch('edit');
}
$params = $this->request->post('row/a', []);
if ($params === []) {
$this->error(__('Parameter %s can not be empty', ''));
}
$params = $this->preExcludeFields($params);
if (($params['number_type'] ?? '') !== 'custom') {
$params['number_type_custom'] = '';
}
$newNumber = trim((string) ($params['number'] ?? ''));
$splitLinkId = (int) ($params['split_link_id'] ?? $row['split_link_id']);
$exists = $this->model
->where('split_link_id', $splitLinkId)
->where('number', $newNumber)
->where('id', '<>', (int) $row['id'])
->find();
if ($exists) {
$this->error(__('Number already exists for this link'));
}
$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();
}
/**
* 批量更新号码状态与手动管理
*/
public function batchupdate(): void
{
if (false === $this->request->isPost()) {
$this->error(__('Invalid parameters'));
}
$ids = $this->request->post('ids', '');
if ($ids === '' || $ids === null) {
$this->error(__('Parameter %s can not be empty', 'ids'));
}
if (!is_array($ids)) {
$ids = explode(',', (string) $ids);
}
$ids = array_filter(array_map('intval', $ids));
if ($ids === []) {
$this->error(__('Parameter %s can not be empty', 'ids'));
}
$status = (string) $this->request->post('status', '');
$manualManage = $this->request->post('manual_manage', '');
$statusList = $this->model->getStatusList();
if (!isset($statusList[$status])) {
$this->error(__('Invalid status'));
}
if (!in_array((string) $manualManage, ['0', '1'], true)) {
$this->error(__('Invalid manual manage'));
}
$query = $this->model->where('id', 'in', $ids);
if ($this->dataLimit) {
$adminIds = $this->getDataLimitAdminIds();
if (is_array($adminIds)) {
$query->where('admin_id', 'in', $adminIds);
}
}
$count = $query->update([
'status' => $status,
'manual_manage' => (int) $manualManage,
]);
if ($count === false || $count === 0) {
$this->error(__('No rows were updated'));
}
$this->success(__('Batch update success'));
}
/**
* 排除不可由表单提交的字段
*
* @param array<string, mixed> $params
* @return array<string, mixed>
*/
protected function preExcludeFields($params): array
{
$params = parent::preExcludeFields($params);
unset(
$params['visit_count'],
$params['inbound_count'],
$params['manual_manage'],
$params['id']
);
return $params;
}
public function script(): void
{
$jsFile = ROOT_PATH . 'patches/public/assets/js/backend/split/number.js';
if (!is_file($jsFile)) {
$jsFile = ROOT_PATH . 'public/assets/js/backend/split/number.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);
}
}
+452
View File
@@ -0,0 +1,452 @@
<?php
declare(strict_types=1);
namespace app\admin\controller\split;
use app\admin\model\split\Link as LinkModel;
use app\admin\model\split\Ticket as TicketModel;
use app\common\controller\Backend;
use app\common\service\SplitTicketRuleService;
use app\common\service\SplitTicketSyncLogger;
use app\common\service\SplitTicketSyncService;
use think\Db;
use think\Lang;
use think\Loader;
use think\Exception;
use think\exception\DbException;
use think\exception\PDOException;
use think\exception\ValidateException;
use think\response\Json;
/**
* 工单管理
*
* @icon fa fa-ticket
* @remark 分流工单管理,关联分流链接
*/
class Ticket extends Backend
{
/** @var \app\admin\model\split\Ticket */
protected $model = null;
protected $searchFields = 'ticket_name,ticket_url,ticket_total';
protected $dataLimit = 'personal';
protected $modelValidate = true;
protected $modelSceneValidate = true;
/** @var string[] 无需鉴权的方法 */
protected $noNeedRight = ['script'];
/** @var string patches 视图目录 */
private const PATCH_VIEW_DIR = 'patches/application/admin/view/split/ticket/';
public function _initialize()
{
parent::_initialize();
$lang = $this->request->langset();
$lang = preg_match('/^([a-zA-Z\-_]{2,10})$/i', $lang) ? $lang : 'zh-cn';
$this->model = new \app\admin\model\split\Ticket();
$this->view->assign('ticketTypeList', $this->model->getTicketTypeList());
$this->view->assign('numberTypeList', $this->model->getNumberTypeList());
$this->view->assign('statusList', $this->model->getStatusList());
$this->view->assign('splitLinkList', $this->buildSplitLinkList());
$this->assignconfig('ticketTypeList', $this->model->getTicketTypeList());
$this->assignconfig('numberTypeList', $this->model->getNumberTypeList());
$this->assignconfig('statusList', $this->model->getStatusList());
$this->assignconfig([
'syncConfirmMsg' => __('Sync confirm'),
'syncBackgroundStartedMsg' => __('Sync background started'),
'syncInProgressMsg' => __('Sync in progress'),
'syncTicketStartedMsg' => __('Sync ticket started'),
]);
$this->setupPatchFrontend();
}
/**
* 加载工单语言包(优先 patches)
*/
protected function loadlang($name): void
{
$lang = $this->request->langset();
$lang = preg_match('/^([a-zA-Z\-_]{2,10})$/i', $lang) ? $lang : 'zh-cn';
$name = Loader::parseName($name);
$name = preg_match('/^([a-zA-Z0-9_\.\/]+)$/i', $name) ? $name : 'index';
$files = [
APP_PATH . 'admin/lang/' . $lang . '/' . str_replace('.', '/', $name) . '.php',
ROOT_PATH . 'patches/application/admin/lang/' . $lang . '/split/ticket.php',
];
$loaded = false;
foreach ($files as $file) {
if (is_file($file)) {
Lang::load($file);
$loaded = true;
}
}
if (!$loaded) {
parent::loadlang($name);
}
}
/**
* 未部署 JS 时指向 script 接口
*/
private function setupPatchFrontend(): void
{
$patchJs = ROOT_PATH . 'patches/public/assets/js/backend/split/ticket.js';
$publicJs = ROOT_PATH . 'public/assets/js/backend/split/ticket.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');
// 使用站内相对路径,避免生产环境 domain() 与反向代理/HTTPS 不一致导致 JS 跨域丢 Cookie
$scriptUrl = (string) url('split.ticket/script', ['v' => $version], '', false);
$cfg['jsname'] = $scriptUrl;
$this->view->assign('config', $cfg);
$this->view->config = $cfg;
}
/**
* @return array<int, array<string, string>>
*/
private function buildSplitLinkList(): array
{
$query = (new LinkModel())->where('status', 'normal')->order('id', 'desc');
if ($this->dataLimit) {
$adminIds = $this->getDataLimitAdminIds();
if (is_array($adminIds)) {
$query->where('admin_id', 'in', $adminIds);
}
}
$list = [];
foreach ($query->select() as $row) {
$code = (string) $row['link_code'];
$desc = (string) $row['description'];
$label = $desc !== '' ? $code . ' - ' . $desc : $code;
$list[] = [
'id' => (string) $row['id'],
'label' => $label,
];
}
return $list;
}
private function fetchPatch(string $template): string
{
$patchFile = ROOT_PATH . self::PATCH_VIEW_DIR . $template . '.html';
$appFile = APP_PATH . 'admin/view/split/ticket/' . $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();
$ticketTable = $this->model->getTable();
$clickSub = TicketModel::buildClickCountSubQuery();
// 勿 with(splitLink)eagerlyType=0 为 JOIN 预载入,会重写 field 导致子查询 SQL 语法错误
$list = $this->model
->field($ticketTable . '.*,' . $clickSub)
->where($where)
->order($sort, $order)
->paginate($limit);
$result = ['total' => $list->total(), 'rows' => $list->items()];
return json($result);
}
/**
* 同步统计字段仅由接口写入,禁止表单提交篡改
*
* @param array<string, mixed> $params
* @return array<string, mixed>
*/
protected function preExcludeFields($params): array
{
$params = parent::preExcludeFields($params);
unset(
$params['complete_count'],
$params['inbound_count'],
$params['speed_per_hour'],
$params['number_count'],
$params['number_offline_count'],
$params['number_banned_count'],
$params['online_count'],
$params['sync_status'],
$params['sync_time'],
$params['sync_message'],
$params['sync_fail_count'],
$params['speed_snapshot_count'],
$params['speed_snapshot_time'],
$params['click_count']
);
return $params;
}
/**
* 手动同步选中工单
*/
public function sync(): void
{
if (!$this->request->isPost()) {
$this->error(__('Invalid parameters'));
}
$ids = $this->request->post('ids', '');
if ($ids === '') {
$this->error(__('Parameter %s can not be empty', 'ids'));
}
$adminIds = $this->getDataLimitAdminIds();
$pk = $this->model->getPk();
$list = $this->model->where($pk, 'in', $ids)->select();
if (!$list || count($list) === 0) {
$this->error(__('No Results were found'));
}
SplitTicketSyncLogger::log('web', 'manual sync request', [
'ids' => $ids,
'appDebug' => SplitTicketSyncLogger::isEnabled(),
]);
$service = new SplitTicketSyncService();
$ok = 0;
$fail = 0;
$messages = [];
foreach ($list as $row) {
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
$fail++;
$messages[] = '#' . $row['id'] . ': 无权限';
continue;
}
$result = $service->syncOne((int) $row['id'], true);
if ($result['success']) {
$ok++;
} else {
$fail++;
$messages[] = '#' . $row['id'] . ': ' . ($result['message'] ?? '失败');
}
}
$summary = sprintf('成功 %d 条,失败 %d 条', $ok, $fail);
if ($fail > 0) {
$summary .= '' . implode('', array_slice($messages, 0, 5));
}
$this->success($summary);
}
/**
* 批量更新:工单状态变更时联动号码
*
* @param string $ids
*/
public function multi($ids = '')
{
if (!$this->request->isPost()) {
$this->error(__('Invalid parameters'));
}
$ids = $ids ?: $this->request->post('ids', '');
if ($ids === '') {
$this->error(__('Parameter %s can not be empty', 'ids'));
}
$params = $this->request->post('params', '');
parse_str((string) $params, $values);
$ruleService = new SplitTicketRuleService();
if (isset($values['status'])) {
$pk = $this->model->getPk();
$adminIds = $this->getDataLimitAdminIds();
$query = $this->model->where($pk, 'in', $ids);
if (is_array($adminIds)) {
$query->where($this->dataLimitField, 'in', $adminIds);
}
$rows = $query->select();
if (!$rows || count($rows) === 0) {
$this->error(__('No Results were found'));
}
$count = 0;
Db::startTrans();
try {
foreach ($rows as $item) {
$count += $item->allowField(true)->isUpdate(true)->save($values);
}
Db::commit();
} catch (ValidateException $e) {
Db::rollback();
$this->error($e->getMessage());
} catch (PDOException|Exception $e) {
Db::rollback();
$this->error($e->getMessage());
}
foreach ($rows as $row) {
$fresh = $this->model->get($row['id']);
if ($fresh) {
$ruleService->syncNumbersWithTicketStatus($fresh);
}
}
if ($count > 0) {
$this->success();
}
$this->error(__('No rows were updated'));
}
parent::multi($ids);
}
/**
* @return string
* @throws DbException
*/
public function add()
{
if (false === $this->request->isPost()) {
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 (($params['number_type'] ?? '') !== 'custom') {
$params['number_type_custom'] = '';
}
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);
}
$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('', null, ['id' => (int) $this->model->id]);
}
/**
* @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((int) $row[$this->dataLimitField], $adminIds, true)) {
$this->error(__('You have no permission'));
}
if (false === $this->request->isPost()) {
$rowData = $row->toArray();
$rowData['start_time'] = $rowData['start_time_text'] ?? '';
$rowData['end_time'] = $rowData['end_time_text'] ?? '';
$this->view->assign('row', $rowData);
return $this->fetchPatch('edit');
}
$params = $this->request->post('row/a', []);
if ($params === []) {
$this->error(__('Parameter %s can not be empty', ''));
}
$params = $this->preExcludeFields($params);
if (($params['number_type'] ?? '') !== 'custom') {
$params['number_type_custom'] = '';
}
$oldStatus = (string) ($row['status'] ?? 'hidden');
$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);
}
$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'));
}
if (isset($params['status']) && (string) $params['status'] !== $oldStatus) {
$fresh = $this->model->get($row['id']);
if ($fresh) {
(new SplitTicketRuleService())->syncNumbersWithTicketStatus($fresh);
}
}
$this->success();
}
/**
* 输出后台 JSpatches 未部署到 public 时)
*/
public function script(): void
{
$jsFile = ROOT_PATH . 'patches/public/assets/js/backend/split/ticket.js';
if (!is_file($jsFile)) {
$jsFile = ROOT_PATH . 'public/assets/js/backend/split/ticket.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);
}
}