Files
links/application/admin/controller/split/Ticket.php
T
2026-07-08 01:07:08 +08:00

653 lines
22 KiB
PHP
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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\SplitTicketSyncDispatchService;
use app\common\service\SplitTicketSyncLogger;
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', 'syncpolling'];
/** @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('splitLinkFilterList', $this->buildTicketSplitLinkFilterList());
$this->assignconfig([
'syncConfirmMsg' => __('Sync confirm'),
'syncBackgroundStartedMsg' => __('Sync background started'),
'syncInProgressMsg' => __('Sync in progress'),
'syncTicketStartedMsg' => __('Sync ticket started'),
'syncDoneMsg' => __('Sync done'),
]);
$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;
}
/**
* 已有工单关联的分流链接(供列表筛选下拉)
*
* @return array<string, string> id => label
*/
private function buildTicketSplitLinkFilterList(): array
{
$ticketTable = $this->model->getTable();
$linkTable = (new LinkModel())->getTable();
$query = Db::table($ticketTable)
->alias('t')
->join($linkTable . ' l', 't.split_link_id = l.id')
->where('t.split_link_id', '>', 0)
->group('t.split_link_id')
->field('l.id,l.link_code,l.description')
->order('l.id', 'desc');
if ($this->dataLimit) {
$adminIds = $this->getDataLimitAdminIds();
if (is_array($adminIds)) {
$query->where('t.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[(string) $row['id']] = $label;
}
return $list;
}
/**
* 按当前筛选条件汇总列表统计列
*
* @param callable|array<mixed> $where buildparams() 返回的闭包或条件数组
* @return array<string, int|float|string>
*/
private function buildListSummary($where): array
{
$stats = $this->model
->where($where)
->fieldRaw(
'COALESCE(SUM(ticket_total), 0) AS sum_ticket_total,'
. ' COALESCE(SUM(complete_count), 0) AS sum_complete_count,'
. ' COALESCE(SUM(speed_per_hour), 0) AS sum_speed_per_hour,'
. ' COALESCE(AVG(CASE WHEN ticket_total > 0 THEN complete_count * 100.0 / ticket_total ELSE 0 END), 0) AS avg_ticket_progress'
)
->find();
if (!$stats) {
return [
'ticket_total' => 0,
'complete_count' => 0,
'speed_per_hour' => '0.00',
'ticket_progress_text' => '0.00%',
];
}
return [
'ticket_total' => (int) ($stats['sum_ticket_total'] ?? 0),
'complete_count' => (int) ($stats['sum_complete_count'] ?? 0),
'speed_per_hour' => number_format((float) ($stats['sum_speed_per_hour'] ?? 0), 2, '.', ''),
'ticket_progress_text' => number_format((float) ($stats['avg_ticket_progress'] ?? 0), 2, '.', '') . '%',
];
}
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(),
'summary' => $this->buildListSummary($where),
];
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['active_number_count'],
$params['number_offline_count'],
$params['number_banned_count'],
$params['online_count'],
$params['sync_status'],
$params['sync_time'],
$params['sync_message'],
$params['sync_success_time'],
$params['sync_success_mode'],
$params['sync_fail_count'],
$params['speed_snapshot_count'],
$params['speed_snapshot_time'],
$params['click_count'],
$params['manual_manage']
);
return $params;
}
/**
* 用户切换工单状态时写入 manual_manage,防止同步自动恢复开启
*
* @param array<string, mixed> $values
*/
private function applyManualManageForStatusChange(array &$values): void
{
(new SplitTicketRuleService())->applyManualManageForStatusChange($values);
}
/**
* 手动同步选中工单(投递 CLI 后台执行,Web 请求立即返回)
*/
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(),
]);
/** @var int[] $allowedIds */
$allowedIds = [];
$denied = 0;
foreach ($list as $row) {
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
$denied++;
continue;
}
$allowedIds[] = (int) $row['id'];
}
if ($allowedIds === []) {
$this->error(__('You have no permission'));
}
// 尽早释放 Session 锁,避免投递等待期间阻塞同账号其它后台请求(如编辑/新增弹窗)
if (function_exists('session_write_close')) {
session_write_close();
}
$dispatch = new SplitTicketSyncDispatchService();
$result = $dispatch->dispatchManual($allowedIds);
$queuedCount = count($result['queued']);
$skippedCount = count($result['skipped']);
$failedCount = count($result['failed']);
if ($queuedCount === 0) {
if ($skippedCount > 0) {
$this->error(__('Sync all skipped busy'));
}
$this->error(__('Sync dispatch failed'));
}
$summary = sprintf(__('Sync dispatch queued'), $queuedCount);
if ($skippedCount > 0) {
$summary .= sprintf(__('Sync dispatch skipped suffix'), $skippedCount);
}
if ($failedCount > 0) {
$summary .= sprintf(__('Sync dispatch failed suffix'), $failedCount);
}
if ($denied > 0) {
$summary .= sprintf(__('Sync dispatch denied suffix'), $denied);
}
$this->success($summary, null, [
'queued' => $result['queued'],
'syncing' => $result['queued'],
]);
}
/**
* 轮询手工同步进度(依据 runtime 文件锁)
*/
public function syncpolling(): void
{
$idsRaw = trim((string) $this->request->request('ids', ''));
if ($idsRaw === '') {
$this->success('', null, ['syncing' => []]);
}
/** @var int[] $ticketIds */
$ticketIds = [];
foreach (explode(',', $idsRaw) as $part) {
$part = trim($part);
if ($part !== '' && ctype_digit($part)) {
$ticketIds[] = (int) $part;
}
}
if ($ticketIds === []) {
$this->success('', null, ['syncing' => []]);
}
$dispatch = new SplitTicketSyncDispatchService();
$syncing = $dispatch->filterSyncingIds($ticketIds);
$this->success('', null, ['syncing' => $syncing]);
}
/**
* 批量更新:工单状态变更时联动号码
*
* @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'])) {
$this->applyManualManageForStatusChange($values);
$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()) {
$copyRow = $this->buildCopyRowData();
if ($copyRow !== null) {
$this->view->assign('row', $copyRow);
}
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]);
}
/**
* 从 copy_id 构建添加工单表单的预填数据(仅复制业务字段,不含同步统计)
*
* @return array<string, mixed>|null
*/
private function buildCopyRowData(): ?array
{
$copyId = (int) $this->request->get('copy_id/d', 0);
if ($copyId <= 0) {
return null;
}
$row = $this->model->get($copyId);
if (!$row) {
return null;
}
$adminIds = $this->getDataLimitAdminIds();
if (is_array($adminIds) && !in_array((int) $row[$this->dataLimitField], $adminIds, true)) {
return null;
}
$data = $row->toArray();
$copyFields = [
'ticket_type',
'ticket_name',
'ticket_url',
'ticket_total',
'split_link_id',
'number_type',
'number_type_custom',
'order_limit',
'assign_ratio',
'account',
'password',
];
$copyRow = [];
foreach ($copyFields as $field) {
if (array_key_exists($field, $data)) {
$copyRow[$field] = $data[$field];
}
}
$copyRow['start_time'] = $data['start_time_text'] ?? '';
$copyRow['end_time'] = $data['end_time_text'] ?? '';
$name = trim((string) ($copyRow['ticket_name'] ?? ''));
if ($name !== '' && mb_strpos($name, ' (副本)') === false) {
$copyRow['ticket_name'] = $name . ' (副本)';
}
return $copyRow;
}
/**
* @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');
if (isset($params['status']) && (string) $params['status'] !== $oldStatus) {
$this->applyManualManageForStatusChange($params);
}
$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);
}
}