初始版本

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
+87
View File
@@ -0,0 +1,87 @@
<?php
namespace app\common\behavior;
use think\Config;
use think\Lang;
use think\Loader;
class Common
{
public function appInit()
{
$allowLangList = Config::get('allow_lang_list') ?? ['zh-cn', 'en'];
Lang::setAllowLangList($allowLangList);
}
public function appDispatch(&$dispatch)
{
$pathinfoArr = explode('/', request()->pathinfo());
if (!Config::get('url_domain_deploy') && $pathinfoArr && in_array($pathinfoArr[0], ['index', 'api'])) {
//如果是以index或api开始的URL则关闭路由检测
\think\App::route(false);
}
}
public function moduleInit(&$request)
{
// 设置mbstring字符编码
mb_internal_encoding("UTF-8");
// 如果修改了index.php入口地址,则需要手动修改cdnurl的值
$url = preg_replace("/\/(\w+)\.php$/i", '', $request->root());
// 如果未设置__CDN__则自动匹配得出
if (!Config::get('view_replace_str.__CDN__')) {
Config::set('view_replace_str.__CDN__', $url);
}
// 如果未设置__PUBLIC__则自动匹配得出
if (!Config::get('view_replace_str.__PUBLIC__')) {
Config::set('view_replace_str.__PUBLIC__', $url . '/');
}
// 如果未设置__ROOT__则自动匹配得出
if (!Config::get('view_replace_str.__ROOT__')) {
Config::set('view_replace_str.__ROOT__', preg_replace("/\/public\/$/", '', $url . '/'));
}
// 如果未设置cdnurl则自动匹配得出
if (!Config::get('site.cdnurl')) {
Config::set('site.cdnurl', $url);
}
// 如果未设置cdnurl则自动匹配得出
if (!Config::get('upload.cdnurl')) {
Config::set('upload.cdnurl', $url);
}
if (Config::get('app_debug')) {
// 如果是调试模式将version置为当前的时间戳可避免缓存
Config::set('site.version', time());
// 如果是开发模式那么将异常模板修改成官方的
Config::set('exception_tmpl', THINK_PATH . 'tpl' . DS . 'think_exception.tpl');
}
// 如果是trace模式且Ajax的情况下关闭trace
if (Config::get('app_trace') && $request->isAjax()) {
Config::set('app_trace', false);
}
// 切换多语言
if (Config::get('lang_switch_on')) {
$lang = $request->get('lang', '');
if (preg_match("/^([a-zA-Z\-_]{2,10})\$/i", $lang)) {
\think\Cookie::set('think_var', $lang);
}
}
// Form别名
if (!class_exists('Form')) {
class_alias('fast\\Form', 'Form');
}
}
public function addonBegin(&$request)
{
// 加载插件语言包
$lang = request()->langset();
$lang = preg_match("/^([a-zA-Z\-_]{2,10})\$/i", $lang) ? $lang : 'zh-cn';
Lang::load([
APP_PATH . 'common' . DS . 'lang' . DS . $lang . DS . 'addon' . EXT,
]);
$this->moduleInit($request);
}
}
+330
View File
@@ -0,0 +1,330 @@
<?php
namespace app\common\controller;
use app\common\library\Auth;
use think\Config;
use think\exception\HttpResponseException;
use think\exception\ValidateException;
use think\Hook;
use think\Lang;
use think\Loader;
use think\Request;
use think\Response;
use think\Route;
use think\Validate;
/**
* API控制器基类
*/
class Api
{
/**
* @var Request Request 实例
*/
protected $request;
/**
* @var bool 验证失败是否抛出异常
*/
protected $failException = false;
/**
* @var bool 是否批量验证
*/
protected $batchValidate = false;
/**
* @var array 前置操作方法列表
*/
protected $beforeActionList = [];
/**
* 无需登录的方法,同时也就不需要鉴权了
* @var array
*/
protected $noNeedLogin = [];
/**
* 无需鉴权的方法,但需要登录
* @var array
*/
protected $noNeedRight = [];
/**
* 权限Auth
* @var Auth
*/
protected $auth = null;
/**
* 默认响应输出类型,支持json/xml
* @var string
*/
protected $responseType = 'json';
/**
* 构造方法
* @access public
* @param Request $request Request 对象
*/
public function __construct(Request $request = null)
{
$this->request = is_null($request) ? Request::instance() : $request;
// 控制器初始化
$this->_initialize();
// 前置操作方法
if ($this->beforeActionList) {
foreach ($this->beforeActionList as $method => $options) {
is_numeric($method) ?
$this->beforeAction($options) :
$this->beforeAction($method, $options);
}
}
}
/**
* 初始化操作
* @access protected
*/
protected function _initialize()
{
//跨域请求检测
check_cors_request();
// 检测IP是否允许
check_ip_allowed();
//移除HTML标签
$this->request->filter('trim,strip_tags,htmlspecialchars');
$this->auth = Auth::instance();
$modulename = $this->request->module();
$controllername = Loader::parseName($this->request->controller());
$actionname = strtolower($this->request->action());
// token
$token = $this->request->server('HTTP_TOKEN', $this->request->request('token', \think\Cookie::get('token')));
$path = str_replace('.', '/', $controllername) . '/' . $actionname;
// 设置当前请求的URI
$this->auth->setRequestUri($path);
// 检测是否需要验证登录
if (!$this->auth->match($this->noNeedLogin)) {
//初始化
$this->auth->init($token);
//检测是否登录
if (!$this->auth->isLogin()) {
$this->error(__('Please login first'), null, 401);
}
// 判断是否需要验证权限
if (!$this->auth->match($this->noNeedRight)) {
// 判断控制器和方法判断是否有对应权限
if (!$this->auth->check($path)) {
$this->error(__('You have no permission'), null, 403);
}
}
} else {
// 如果有传递token才验证是否登录状态
if ($token) {
$this->auth->init($token);
}
}
$upload = \app\common\model\Config::upload();
// 上传信息配置后
Hook::listen("upload_config_init", $upload);
Config::set('upload', array_merge(Config::get('upload'), $upload));
// 加载当前控制器语言包
$this->loadlang($controllername);
}
/**
* 加载语言文件
* @param string $name
*/
protected function loadlang($name)
{
$name = Loader::parseName($name);
$name = preg_match("/^([a-zA-Z0-9_\.\/]+)\$/i", $name) ? $name : 'index';
$lang = $this->request->langset();
$lang = preg_match("/^([a-zA-Z\-_]{2,10})\$/i", $lang) ? $lang : 'zh-cn';
Lang::load(APP_PATH . $this->request->module() . '/lang/' . $lang . '/' . str_replace('.', '/', $name) . '.php');
}
/**
* 操作成功返回的数据
* @param string $msg 提示信息
* @param mixed $data 要返回的数据
* @param int $code 错误码,默认为1
* @param string $type 输出类型
* @param array $header 发送的 Header 信息
*/
protected function success($msg = '', $data = null, $code = 1, $type = null, array $header = [])
{
$this->result($msg, $data, $code, $type, $header);
}
/**
* 操作失败返回的数据
* @param string $msg 提示信息
* @param mixed $data 要返回的数据
* @param int $code 错误码,默认为0
* @param string $type 输出类型
* @param array $header 发送的 Header 信息
*/
protected function error($msg = '', $data = null, $code = 0, $type = null, array $header = [])
{
$this->result($msg, $data, $code, $type, $header);
}
/**
* 返回封装后的 API 数据到客户端
* @access protected
* @param mixed $msg 提示信息
* @param mixed $data 要返回的数据
* @param int $code 错误码,默认为0
* @param string $type 输出类型,支持json/xml/jsonp
* @param array $header 发送的 Header 信息
* @return void
* @throws HttpResponseException
*/
protected function result($msg, $data = null, $code = 0, $type = null, array $header = [])
{
$result = [
'code' => $code,
'msg' => $msg,
'time' => Request::instance()->server('REQUEST_TIME'),
'data' => $data,
];
// 如果未设置类型则使用默认类型判断
$type = $type ? : $this->responseType;
if (isset($header['statuscode'])) {
$code = $header['statuscode'];
unset($header['statuscode']);
} else {
//未设置状态码,根据code值判断
$code = $code >= 1000 || $code < 200 ? 200 : $code;
}
$response = Response::create($result, $type, $code)->header($header);
throw new HttpResponseException($response);
}
/**
* 前置操作
* @access protected
* @param string $method 前置操作方法名
* @param array $options 调用参数 ['only'=>[...]] 或者 ['except'=>[...]]
* @return void
*/
protected function beforeAction($method, $options = [])
{
if (isset($options['only'])) {
if (is_string($options['only'])) {
$options['only'] = explode(',', $options['only']);
}
if (!in_array($this->request->action(), $options['only'])) {
return;
}
} elseif (isset($options['except'])) {
if (is_string($options['except'])) {
$options['except'] = explode(',', $options['except']);
}
if (in_array($this->request->action(), $options['except'])) {
return;
}
}
call_user_func([$this, $method]);
}
/**
* 设置验证失败后是否抛出异常
* @access protected
* @param bool $fail 是否抛出异常
* @return $this
*/
protected function validateFailException($fail = true)
{
$this->failException = $fail;
return $this;
}
/**
* 验证数据
* @access protected
* @param array $data 数据
* @param string|array $validate 验证器名或者验证规则数组
* @param array $message 提示信息
* @param bool $batch 是否批量验证
* @param mixed $callback 回调方法(闭包)
* @return array|string|true
* @throws ValidateException
*/
protected function validate($data, $validate, $message = [], $batch = false, $callback = null)
{
if (is_array($validate)) {
$v = Loader::validate();
$v->rule($validate);
} else {
// 支持场景
if (strpos($validate, '.')) {
list($validate, $scene) = explode('.', $validate);
}
$v = Loader::validate($validate);
!empty($scene) && $v->scene($scene);
}
// 批量验证
if ($batch || $this->batchValidate) {
$v->batch(true);
}
// 设置错误信息
if (is_array($message)) {
$v->message($message);
}
// 使用回调验证
if ($callback && is_callable($callback)) {
call_user_func_array($callback, [$v, &$data]);
}
if (!$v->check($data)) {
if ($this->failException) {
throw new ValidateException($v->getError());
}
return $v->getError();
}
return true;
}
/**
* 刷新Token
*/
protected function token()
{
$token = $this->request->param('__token__');
//验证Token
if (!Validate::make()->check(['__token__' => $token], ['__token__' => 'require|token'])) {
$this->error(__('Token verification error'), ['__token__' => $this->request->token()]);
}
//刷新Token
$this->request->token();
}
}
+500
View File
@@ -0,0 +1,500 @@
<?php
namespace app\common\controller;
use app\admin\library\Auth;
use app\common\library\SelectPage;
use think\Config;
use think\Controller;
use think\Hook;
use think\Lang;
use think\Loader;
use think\Model;
use think\Session;
use think\Validate;
/**
* 后台控制器基类
*/
class Backend extends Controller
{
/**
* 无需登录的方法,同时也就不需要鉴权了
* @var array
*/
protected $noNeedLogin = [];
/**
* 无需鉴权的方法,但需要登录
* @var array
*/
protected $noNeedRight = [];
/**
* 布局模板
* @var string
*/
protected $layout = 'default';
/**
* 权限控制类
* @var Auth
*/
protected $auth = null;
/**
* 模型对象
* @var \think\Model
*/
protected $model = null;
/**
* 快速搜索时执行查找的字段
*/
protected $searchFields = 'id';
/**
* 是否是关联查询
*/
protected $relationSearch = false;
/**
* 是否开启数据限制
* 支持auth/personal
* 表示按权限判断/仅限个人
* 默认为禁用,若启用请务必保证表中存在admin_id字段
*/
protected $dataLimit = false;
/**
* 数据限制字段
*/
protected $dataLimitField = 'admin_id';
/**
* 数据限制开启时自动填充限制字段值
*/
protected $dataLimitFieldAutoFill = true;
/**
* 是否开启Validate验证
*/
protected $modelValidate = false;
/**
* 是否开启模型场景验证
*/
protected $modelSceneValidate = false;
/**
* Multi方法可批量修改的字段
*/
protected $multiFields = 'status';
/**
* Selectpage可显示的字段
*/
protected $selectpageFields = '*';
/**
* 前台提交过来,需要排除的字段数据
*/
protected $excludeFields = "";
/**
* 导入文件首行类型
* 支持comment/name
* 表示注释或字段名
*/
protected $importHeadType = 'comment';
/**
* 引入后台控制器的traits
*/
use \app\admin\library\traits\Backend;
public function _initialize()
{
$modulename = $this->request->module();
$controllername = Loader::parseName($this->request->controller());
$actionname = strtolower($this->request->action());
$path = str_replace('.', '/', $controllername) . '/' . $actionname;
// 定义是否Addtabs请求
!defined('IS_ADDTABS') && define('IS_ADDTABS', (bool)input("addtabs"));
// 定义是否Dialog请求
!defined('IS_DIALOG') && define('IS_DIALOG', (bool)input("dialog"));
// 定义是否AJAX请求
!defined('IS_AJAX') && define('IS_AJAX', $this->request->isAjax());
// 检测IP是否允许
check_ip_allowed();
$this->auth = Auth::instance();
// 设置当前请求的URI
$this->auth->setRequestUri($path);
// 检测是否需要验证登录
if (!$this->auth->match($this->noNeedLogin)) {
//检测是否登录
if (!$this->auth->isLogin()) {
Hook::listen('admin_nologin', $this);
$url = Session::get('referer');
$url = $url ? $url : $this->request->url();
if (in_array($this->request->pathinfo(), ['/', 'index/index'])) {
$this->redirect('index/login', [], 302, ['referer' => $url]);
exit;
}
$this->error(__('Please login first'), url('index/login', ['url' => $url]));
}
// 判断是否需要验证权限
if (!$this->auth->match($this->noNeedRight)) {
// 判断控制器和方法是否有对应权限
if (!$this->auth->check($path)) {
Hook::listen('admin_nopermission', $this);
$this->error(__('You have no permission'), '');
}
}
}
// 非选项卡时重定向
if (!$this->request->isPost() && !IS_AJAX && !IS_ADDTABS && !IS_DIALOG && input("ref") == 'addtabs') {
$url = preg_replace_callback("/([\?|&]+)ref=addtabs(&?)/i", function ($matches) {
return $matches[2] == '&' ? $matches[1] : '';
}, $this->request->url());
if (Config::get('url_domain_deploy')) {
if (stripos($url, $this->request->server('SCRIPT_NAME')) === 0) {
$url = substr($url, strlen($this->request->server('SCRIPT_NAME')));
}
$url = url($url, '', false);
}
$this->redirect('index/index', [], 302, ['referer' => $url]);
exit;
}
// 设置面包屑导航数据
$breadcrumb = [];
if (!IS_DIALOG && !config('fastadmin.multiplenav') && config('fastadmin.breadcrumb')) {
$breadcrumb = $this->auth->getBreadCrumb($path);
array_pop($breadcrumb);
}
$this->view->breadcrumb = $breadcrumb;
// 如果有使用模板布局
if ($this->layout) {
$this->view->engine->layout('layout/' . $this->layout);
}
// 语言检测
$lang = $this->request->langset();
$lang = preg_match("/^([a-zA-Z\-_]{2,10})\$/i", $lang) ? $lang : 'zh-cn';
$site = Config::get("site");
$upload = \app\common\model\Config::upload();
// 上传信息配置后
Hook::listen("upload_config_init", $upload);
// 配置信息
$config = [
'site' => array_intersect_key($site, array_flip(['name', 'indexurl', 'cdnurl', 'version', 'timezone', 'languages'])),
'upload' => $upload,
'modulename' => $modulename,
'controllername' => $controllername,
'actionname' => $actionname,
'jsname' => 'backend/' . str_replace('.', '/', $controllername),
'moduleurl' => rtrim(url("/{$modulename}", '', false), '/'),
'language' => $lang,
'referer' => Session::get("referer")
];
$config = array_merge($config, Config::get("view_replace_str"));
Config::set('upload', array_merge(Config::get('upload'), $upload));
// 配置信息后
Hook::listen("config_init", $config);
//加载当前控制器语言包
$this->loadlang($controllername);
//渲染站点配置
$this->assign('site', $site);
//渲染配置信息
$this->assign('config', $config);
//渲染权限对象
$this->assign('auth', $this->auth);
//渲染管理员对象
$this->assign('admin', Session::get('admin'));
}
/**
* 加载语言文件
* @param string $name
*/
protected function loadlang($name)
{
$name = Loader::parseName($name);
$name = preg_match("/^([a-zA-Z0-9_\.\/]+)\$/i", $name) ? $name : 'index';
$lang = $this->request->langset();
$lang = preg_match("/^([a-zA-Z\-_]{2,10})\$/i", $lang) ? $lang : 'zh-cn';
Lang::load(APP_PATH . $this->request->module() . '/lang/' . $lang . '/' . str_replace('.', '/', $name) . '.php');
}
/**
* 渲染配置信息
* @param mixed $name 键名或数组
* @param mixed $value 值
*/
protected function assignconfig($name, $value = '')
{
$this->view->config = array_merge($this->view->config ? $this->view->config : [], is_array($name) ? $name : [$name => $value]);
}
/**
* 生成查询所需要的条件,排序方式
* @param mixed $searchfields 快速查询的字段
* @param boolean $relationSearch 是否关联查询
* @return array
*/
protected function buildparams($searchfields = null, $relationSearch = null)
{
$searchfields = is_null($searchfields) ? $this->searchFields : $searchfields;
$relationSearch = is_null($relationSearch) ? $this->relationSearch : $relationSearch;
$search = $this->request->get("search", '');
$filter = $this->request->get("filter", '');
$op = $this->request->get("op", '', 'trim');
$sort = $this->request->get("sort", !empty($this->model) && $this->model->getPk() ? $this->model->getPk() : 'id');
$order = $this->request->get("order", "DESC");
$offset = max(0, $this->request->get("offset/d", 0));
$limit = max(0, $this->request->get("limit/d", 0));
$limit = $limit ?: 999999;
//新增自动计算页码
$page = $limit ? intval($offset / $limit) + 1 : 1;
if ($this->request->has("page")) {
$page = max(0, $this->request->get("page/d", 1));
}
$this->request->get([config('paginate.var_page') => $page]);
$filter = (array)json_decode($filter, true);
$op = (array)json_decode($op, true);
$filter = $filter ? $filter : [];
$where = [];
$alias = [];
$bind = [];
$name = '';
$aliasName = '';
if (!empty($this->model) && $relationSearch) {
$name = $this->model->getTable();
$alias[$name] = Loader::parseName(basename(str_replace('\\', '/', get_class($this->model))));
$aliasName = $alias[$name] . '.';
}
$sortArr = explode(',', $sort);
foreach ($sortArr as $index => & $item) {
$item = stripos($item, ".") === false ? $aliasName . trim($item) : $item;
}
unset($item);
$sort = implode(',', $sortArr);
$adminIds = $this->getDataLimitAdminIds();
if (is_array($adminIds)) {
$where[] = [$aliasName . $this->dataLimitField, 'in', $adminIds];
}
if ($search) {
$searcharr = is_array($searchfields) ? $searchfields : explode(',', $searchfields);
foreach ($searcharr as $k => &$v) {
$v = stripos($v, ".") === false ? $aliasName . $v : $v;
}
unset($v);
$where[] = [implode("|", $searcharr), "LIKE", "%{$search}%"];
}
$index = 0;
foreach ($filter as $k => $v) {
if (!preg_match('/^[a-zA-Z0-9_\-\.]+$/', $k)) {
continue;
}
$sym = $op[$k] ?? '=';
if (stripos($k, ".") === false) {
$k = $aliasName . $k;
}
$v = !is_array($v) ? trim($v) : $v;
$sym = strtoupper($op[$k] ?? $sym);
//null和空字符串特殊处理
if (!is_array($v)) {
if (in_array(strtoupper($v), ['NULL', 'NOT NULL'])) {
$sym = strtoupper($v);
}
if (in_array($v, ['""', "''"])) {
$v = '';
$sym = '=';
}
}
switch ($sym) {
case '=':
case '<>':
$where[] = [$k, $sym, (string)$v];
break;
case 'LIKE':
case 'NOT LIKE':
case 'LIKE %...%':
case 'NOT LIKE %...%':
$where[] = [$k, trim(str_replace('%...%', '', $sym)), "%{$v}%"];
break;
case '>':
case '>=':
case '<':
case '<=':
$where[] = [$k, $sym, intval($v)];
break;
case 'FINDIN':
case 'FINDINSET':
case 'FIND_IN_SET':
$v = is_array($v) ? $v : explode(',', str_replace(' ', ',', $v));
$findArr = array_values($v);
foreach ($findArr as $idx => $item) {
$bindName = "item_" . $index . "_" . $idx;
$bind[$bindName] = $item;
$where[] = "FIND_IN_SET(:{$bindName}, `" . str_replace('.', '`.`', $k) . "`)";
}
break;
case 'IN':
case 'IN(...)':
case 'NOT IN':
case 'NOT IN(...)':
$where[] = [$k, str_replace('(...)', '', $sym), is_array($v) ? $v : explode(',', $v)];
break;
case 'BETWEEN':
case 'NOT BETWEEN':
$arr = array_slice(explode(',', $v), 0, 2);
if (stripos($v, ',') === false || !array_filter($arr, function ($v) {
return $v != '' && $v !== false && $v !== null;
})) {
continue 2;
}
//当出现一边为空时改变操作符
if ($arr[0] === '') {
$sym = $sym == 'BETWEEN' ? '<=' : '>';
$arr = $arr[1];
} elseif ($arr[1] === '') {
$sym = $sym == 'BETWEEN' ? '>=' : '<';
$arr = $arr[0];
}
$where[] = [$k, $sym, $arr];
break;
case 'RANGE':
case 'NOT RANGE':
$v = str_replace(' - ', ',', $v);
$arr = array_slice(explode(',', $v), 0, 2);
if (stripos($v, ',') === false || !array_filter($arr)) {
continue 2;
}
//当出现一边为空时改变操作符
if ($arr[0] === '') {
$sym = $sym == 'RANGE' ? '<=' : '>';
$arr = $arr[1];
} elseif ($arr[1] === '') {
$sym = $sym == 'RANGE' ? '>=' : '<';
$arr = $arr[0];
}
$tableArr = explode('.', $k);
if (count($tableArr) > 1 && $tableArr[0] != $name && !in_array($tableArr[0], $alias)
&& !empty($this->model) && $this->relationSearch) {
//修复关联模型下时间无法搜索的BUG
$relation = Loader::parseName($tableArr[0], 1, false);
$alias[$this->model->$relation()->getTable()] = $tableArr[0];
}
$where[] = [$k, str_replace('RANGE', 'BETWEEN', $sym) . ' TIME', $arr];
break;
case 'NULL':
case 'IS NULL':
case 'NOT NULL':
case 'IS NOT NULL':
$where[] = [$k, strtolower(str_replace('IS ', '', $sym))];
break;
default:
break;
}
$index++;
}
if (!empty($this->model)) {
$this->model->alias($alias);
}
$model = $this->model;
$where = function ($query) use ($where, $alias, $bind, &$model) {
if (!empty($model)) {
$model->alias($alias);
$model->bind($bind);
}
foreach ($where as $k => $v) {
if (is_array($v)) {
call_user_func_array([$query, 'where'], $v);
} else {
$query->where($v);
}
}
};
return [$where, $sort, $order, $offset, $limit, $page, $alias, $bind];
}
/**
* 获取数据限制的管理员ID
* 禁用数据限制时返回的是null
* @return mixed
*/
protected function getDataLimitAdminIds()
{
if (!$this->dataLimit) {
return null;
}
if ($this->auth->isSuperAdmin()) {
return null;
}
$adminIds = [];
if (in_array($this->dataLimit, ['auth', 'personal'])) {
$adminIds = $this->dataLimit == 'auth' ? $this->auth->getChildrenAdminIds(true) : [$this->auth->id];
}
return $adminIds;
}
/**
* Selectpage的实现方法
*/
protected function selectpage()
{
//设置过滤方法
$this->request->filter(['trim', 'strip_tags', 'htmlspecialchars']);
$selectPage = new SelectPage($this->model, $this->selectpageFields);
// 数据限制
$dataLimitIds = $this->getDataLimitAdminIds();
if (is_array($dataLimitIds)) {
$selectPage->setDataLimit($this->dataLimit, $this->dataLimitField, $dataLimitIds);
}
try {
$result = $selectPage->execute($this->request->request());
} catch (\think\Exception $e) {
$this->error(__($e->getMessage()));
}
return json($result);
}
/**
* 刷新Token
*/
protected function token()
{
$token = $this->request->param('__token__');
//验证Token
if (!Validate::make()->check(['__token__' => $token], ['__token__' => 'require|token'])) {
$this->error(__('Token verification error'), '', ['__token__' => $this->request->token()]);
}
//刷新Token
$this->request->token();
}
}
+162
View File
@@ -0,0 +1,162 @@
<?php
namespace app\common\controller;
use app\common\library\Auth;
use think\Config;
use think\Controller;
use think\Hook;
use think\Lang;
use think\Loader;
use think\Validate;
/**
* 前台控制器基类
*/
class Frontend extends Controller
{
/**
* 布局模板
* @var string
*/
protected $layout = '';
/**
* 无需登录的方法,同时也就不需要鉴权了
* @var array
*/
protected $noNeedLogin = [];
/**
* 无需鉴权的方法,但需要登录
* @var array
*/
protected $noNeedRight = [];
/**
* 权限Auth
* @var Auth
*/
protected $auth = null;
public function _initialize()
{
//移除HTML标签
$this->request->filter('trim,strip_tags,htmlspecialchars');
$modulename = $this->request->module();
$controllername = Loader::parseName($this->request->controller());
$actionname = strtolower($this->request->action());
// 检测IP是否允许
check_ip_allowed();
// 如果有使用模板布局
if ($this->layout) {
$this->view->engine->layout('layout/' . $this->layout);
}
$this->auth = Auth::instance();
// token
$token = $this->request->server('HTTP_TOKEN', $this->request->request('token', \think\Cookie::get('token')));
$path = str_replace('.', '/', $controllername) . '/' . $actionname;
// 设置当前请求的URI
$this->auth->setRequestUri($path);
// 检测是否需要验证登录
if (!$this->auth->match($this->noNeedLogin)) {
//初始化
$this->auth->init($token);
//检测是否登录
if (!$this->auth->isLogin()) {
$this->error(__('Please login first'), 'index/user/login');
}
// 判断是否需要验证权限
if (!$this->auth->match($this->noNeedRight)) {
// 判断控制器和方法判断是否有对应权限
if (!$this->auth->check($path)) {
$this->error(__('You have no permission'));
}
}
} else {
// 如果有传递token才验证是否登录状态
if ($token) {
$this->auth->init($token);
}
}
$this->view->assign('user', $this->auth->getUser());
// 语言检测
$lang = $this->request->langset();
$lang = preg_match("/^([a-zA-Z\-_]{2,10})\$/i", $lang) ? $lang : 'zh-cn';
$site = Config::get("site");
$upload = \app\common\model\Config::upload();
// 上传信息配置后
Hook::listen("upload_config_init", $upload);
// 配置信息
$config = [
'site' => array_intersect_key($site, array_flip(['name', 'cdnurl', 'version', 'timezone', 'languages'])),
'upload' => $upload,
'modulename' => $modulename,
'controllername' => $controllername,
'actionname' => $actionname,
'jsname' => 'frontend/' . str_replace('.', '/', $controllername),
'moduleurl' => rtrim(url("/{$modulename}", '', false), '/'),
'language' => $lang
];
$config = array_merge($config, Config::get("view_replace_str"));
Config::set('upload', array_merge(Config::get('upload'), $upload));
// 配置信息后
Hook::listen("config_init", $config);
// 加载当前控制器语言包
$this->loadlang($controllername);
$this->assign('site', $site);
$this->assign('config', $config);
}
/**
* 加载语言文件
* @param string $name
*/
protected function loadlang($name)
{
$name = Loader::parseName($name);
$name = preg_match("/^([a-zA-Z0-9_\.\/]+)\$/i", $name) ? $name : 'index';
$lang = $this->request->langset();
$lang = preg_match("/^([a-zA-Z\-_]{2,10})\$/i", $lang) ? $lang : 'zh-cn';
Lang::load(APP_PATH . $this->request->module() . '/lang/' . $lang . '/' . str_replace('.', '/', $name) . '.php');
}
/**
* 渲染配置信息
* @param mixed $name 键名或数组
* @param mixed $value 值
*/
protected function assignconfig($name, $value = '')
{
$this->view->config = array_merge($this->view->config ? $this->view->config : [], is_array($name) ? $name : [$name => $value]);
}
/**
* 刷新Token
*/
protected function token()
{
$token = $this->request->param('__token__');
//验证Token
if (!Validate::make()->check(['__token__' => $token], ['__token__' => 'require|token'])) {
$this->error(__('Token verification error'), '', ['__token__' => $this->request->token()]);
}
//刷新Token
$this->request->token();
}
}
+17
View File
@@ -0,0 +1,17 @@
<?php
namespace app\common\exception;
use think\Exception;
use Throwable;
class UploadException extends Exception
{
public function __construct($message = "", $code = 0, $data = [])
{
$this->message = $message;
$this->code = $code;
$this->data = $data;
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
return [
'addon %s not found' => '插件未找到',
'addon %s is disabled' => '插件已禁用',
'addon controller %s not found' => '插件控制器未找到',
'addon action %s not found' => '插件控制器方法未找到',
'addon can not be empty' => '插件不能为空',
'Keep login' => '保持会话',
'Forgot password' => '忘记密码?',
'Username' => '用户名',
'User id' => '会员ID',
'Nickname' => '昵称',
'Password' => '密码',
'Sign up' => '注 册',
'Sign in' => '登 录',
'Sign out' => '退 出',
'Guest' => '游客',
'Welcome' => '%s,你好!',
'Add' => '添加',
'Edit' => '编辑',
'Delete' => '删除',
'Move' => '移动',
'Name' => '名称',
'Status' => '状态',
'Weigh' => '权重',
'Operate' => '操作',
'Warning' => '温馨提示',
'Default' => '默认',
'Article' => '文章',
'Page' => '单页',
'OK' => '确定',
'Cancel' => '取消',
'Loading' => '加载中',
'More' => '更多',
'Normal' => '正常',
'Hidden' => '隐藏',
'Submit' => '提交',
'Reset' => '重置',
'Execute' => '执行',
'Close' => '关闭',
'Search' => '搜索',
'Refresh' => '刷新',
'First' => '首页',
'Previous' => '上一页',
'Next' => '下一页',
'Last' => '末页',
'None' => '无',
'Online' => '在线',
'Logout' => '退出',
'Profile' => '个人资料',
'Index' => '首页',
'Hot' => '热门',
'Recommend' => '推荐',
'Dashboard' => '控制台',
'Code' => '编号',
'Message' => '内容',
'Line' => '行号',
'File' => '文件',
'Menu' => '菜单',
'Type' => '类型',
'Title' => '标题',
'Content' => '内容',
'Append' => '追加',
'Memo' => '备注',
'Parent' => '父级',
'Params' => '参数',
'Permission' => '权限',
'Begin time' => '开始时间',
'End time' => '结束时间',
'Create time' => '创建时间',
'Flag' => '标志',
'Home' => '首页',
'Store' => '插件市场',
'Services' => '服务',
'Download' => '下载',
'Demo' => '演示',
'Donation' => '捐赠',
'Forum' => '社区',
'Docs' => '文档',
'Go back' => '返回首页',
'Jump now' => '立即跳转',
'Please login first' => '请登录后再操作',
'Send verification code' => '发送验证码',
'Redirect now' => '立即跳转',
'Operation completed' => '操作成功!',
'Operation failed' => '操作失败!',
'Unknown data format' => '未知的数据格式!',
'Network error' => '网络错误!',
'Advanced search' => '高级搜索',
'Invalid parameters' => '未知参数',
'No results were found' => '记录未找到',
'Parameter %s can not be empty' => '参数%s不能为空',
'You have no permission' => '你没有权限访问',
'An unexpected error occurred' => '发生了一个意外错误,程序猿正在紧急处理中',
'This page will be re-directed in %s seconds' => '页面将在 %s 秒后自动跳转',
];
+583
View File
@@ -0,0 +1,583 @@
<?php
namespace app\common\library;
use app\common\model\User;
use app\common\model\UserRule;
use fast\Random;
use think\Config;
use think\Db;
use think\Exception;
use think\Hook;
use think\Request;
use think\Validate;
class Auth
{
protected static $instance = null;
protected $_error = '';
protected $_logined = false;
protected $_user = null;
protected $_token = '';
//Token默认有效时长
protected $keeptime = 2592000;
protected $requestUri = '';
protected $rules = [];
//默认配置
protected $config = [];
protected $options = [];
protected $allowFields = ['id', 'username', 'nickname', 'mobile', 'avatar', 'score'];
public function __construct($options = [])
{
if ($config = Config::get('user')) {
$this->config = array_merge($this->config, $config);
}
$this->options = array_merge($this->config, $options);
}
/**
*
* @param array $options 参数
* @return Auth
*/
public static function instance($options = [])
{
if (is_null(self::$instance)) {
self::$instance = new static($options);
}
return self::$instance;
}
/**
* 获取User模型
* @return User
*/
public function getUser()
{
return $this->_user;
}
/**
* 兼容调用user模型的属性
*
* @param string $name
* @return mixed
*/
public function __get($name)
{
return $this->_user ? $this->_user->$name : null;
}
/**
* 兼容调用user模型的属性
*/
public function __isset($name)
{
return isset($this->_user) ? isset($this->_user->$name) : false;
}
/**
* 根据Token初始化
*
* @param string $token Token
* @return boolean
*/
public function init($token)
{
if ($this->_logined) {
return true;
}
if ($this->_error) {
return false;
}
$data = Token::get($token);
if (!$data) {
return false;
}
$user_id = intval($data['user_id']);
if ($user_id > 0) {
$user = User::get($user_id);
if (!$user) {
$this->setError('Account not exist');
return false;
}
if ($user['status'] != 'normal') {
$this->setError('Account is locked');
return false;
}
$this->_user = $user;
$this->_logined = true;
$this->_token = $token;
//初始化成功的事件
Hook::listen("user_init_successed", $this->_user);
return true;
} else {
$this->setError('You are not logged in');
return false;
}
}
/**
* 注册用户
*
* @param string $username 用户名
* @param string $password 密码
* @param string $email 邮箱
* @param string $mobile 手机号
* @param array $extend 扩展参数
* @return boolean
*/
public function register($username, $password, $email = '', $mobile = '', $extend = [])
{
// 检测用户名、昵称、邮箱、手机号是否存在
if (User::getByUsername($username)) {
$this->setError('Username already exist');
return false;
}
if (User::getByNickname($username)) {
$this->setError('Nickname already exist');
return false;
}
if ($email && User::getByEmail($email)) {
$this->setError('Email already exist');
return false;
}
if ($mobile && User::getByMobile($mobile)) {
$this->setError('Mobile already exist');
return false;
}
$ip = request()->ip();
$time = time();
$data = [
'username' => $username,
'password' => $password,
'email' => $email,
'mobile' => $mobile,
'level' => 1,
'score' => 0,
'avatar' => '',
];
$params = array_merge($data, [
'nickname' => preg_match("/^1[3-9]{1}\d{9}$/", $username) ? substr_replace($username, '****', 3, 4) : $username,
'salt' => Random::alnum(),
'jointime' => $time,
'joinip' => $ip,
'logintime' => $time,
'loginip' => $ip,
'prevtime' => $time,
'status' => 'normal'
]);
$params['password'] = $this->getEncryptPassword($password, $params['salt']);
$params = array_merge($params, $extend);
//账号注册时需要开启事务,避免出现垃圾数据
Db::startTrans();
try {
$user = User::create($params, true);
$this->_user = User::get($user->id);
//设置Token
$this->_token = Random::uuid();
Token::set($this->_token, $user->id, $this->keeptime);
//设置登录状态
$this->_logined = true;
//注册成功的事件
Hook::listen("user_register_successed", $this->_user, $data);
Db::commit();
} catch (Exception $e) {
$this->setError($e->getMessage());
Db::rollback();
return false;
}
return true;
}
/**
* 用户登录
*
* @param string $account 账号,用户名、邮箱、手机号
* @param string $password 密码
* @return boolean
*/
public function login($account, $password)
{
$field = Validate::is($account, 'email') ? 'email' : (Validate::regex($account, '/^1\d{10}$/') ? 'mobile' : 'username');
$user = User::get([$field => $account]);
if (!$user) {
$this->setError('Account is incorrect');
return false;
}
if ($user->status != 'normal') {
$this->setError('Account is locked');
return false;
}
if ($user->loginfailure >= 10 && time() - $user->loginfailuretime < 86400) {
$this->setError('Please try again after 1 day');
return false;
}
if ($user->password != $this->getEncryptPassword($password, $user->salt)) {
$user->save(['loginfailure' => $user->loginfailure + 1, 'loginfailuretime' => time()]);
$this->setError('Password is incorrect');
return false;
}
//直接登录会员
return $this->direct($user->id);
}
/**
* 退出
*
* @return boolean
*/
public function logout()
{
if (!$this->_logined) {
$this->setError('You are not logged in');
return false;
}
//设置登录标识
$this->_logined = false;
//删除Token
Token::delete($this->_token);
//退出成功的事件
Hook::listen("user_logout_successed", $this->_user);
return true;
}
/**
* 修改密码
* @param string $newpassword 新密码
* @param string $oldpassword 旧密码
* @param bool $ignoreoldpassword 忽略旧密码
* @return boolean
*/
public function changepwd($newpassword, $oldpassword = '', $ignoreoldpassword = false)
{
if (!$this->_logined) {
$this->setError('You are not logged in');
return false;
}
//判断旧密码是否正确
if ($this->_user->password == $this->getEncryptPassword($oldpassword, $this->_user->salt) || $ignoreoldpassword) {
Db::startTrans();
try {
$salt = Random::alnum();
$newpassword = $this->getEncryptPassword($newpassword, $salt);
$this->_user->save(['loginfailure' => 0, 'password' => $newpassword, 'salt' => $salt]);
Token::delete($this->_token);
//修改密码成功的事件
Hook::listen("user_changepwd_successed", $this->_user);
Db::commit();
} catch (Exception $e) {
Db::rollback();
$this->setError($e->getMessage());
return false;
}
return true;
} else {
$this->setError('Password is incorrect');
return false;
}
}
/**
* 直接登录账号
* @param int $user_id
* @return boolean
*/
public function direct($user_id)
{
$user = User::get($user_id);
if ($user) {
Db::startTrans();
try {
$ip = request()->ip();
$time = time();
//判断连续登录和最大连续登录
if ($user->logintime < \fast\Date::unixtime('day')) {
$user->successions = $user->logintime < \fast\Date::unixtime('day', -1) ? 1 : $user->successions + 1;
$user->maxsuccessions = max($user->successions, $user->maxsuccessions);
}
$user->prevtime = $user->logintime;
//记录本次登录的IP和时间
$user->loginip = $ip;
$user->logintime = $time;
//重置登录失败次数
$user->loginfailure = 0;
$user->save();
$this->_user = $user;
$this->_token = Random::uuid();
Token::set($this->_token, $user->id, $this->keeptime);
$this->_logined = true;
//登录成功的事件
Hook::listen("user_login_successed", $this->_user);
Db::commit();
} catch (Exception $e) {
Db::rollback();
$this->setError($e->getMessage());
return false;
}
return true;
} else {
return false;
}
}
/**
* 检测是否是否有对应权限
* @param string $path 控制器/方法
* @param string $module 模块 默认为当前模块
* @return boolean
*/
public function check($path = null, $module = null)
{
if (!$this->_logined) {
return false;
}
$ruleList = $this->getRuleList();
$rules = [];
foreach ($ruleList as $k => $v) {
$rules[] = $v['name'];
}
$url = ($module ? $module : request()->module()) . '/' . (is_null($path) ? $this->getRequestUri() : $path);
$url = strtolower(str_replace('.', '/', $url));
return in_array($url, $rules);
}
/**
* 判断是否登录
* @return boolean
*/
public function isLogin()
{
if ($this->_logined) {
return true;
}
return false;
}
/**
* 获取当前Token
* @return string
*/
public function getToken()
{
return $this->_token;
}
/**
* 获取会员基本信息
*/
public function getUserinfo()
{
$data = $this->_user->toArray();
$allowFields = $this->getAllowFields();
$userinfo = array_intersect_key($data, array_flip($allowFields));
$userinfo = array_merge($userinfo, Token::get($this->_token));
return $userinfo;
}
/**
* 获取会员组别规则列表
* @return array|bool|\PDOStatement|string|\think\Collection
*/
public function getRuleList()
{
if ($this->rules) {
return $this->rules;
}
$group = $this->_user->group;
if (!$group) {
return [];
}
$rules = explode(',', $group->rules);
$this->rules = UserRule::where('status', 'normal')->where('id', 'in', $rules)->field('id,pid,name,title,ismenu')->select();
return $this->rules;
}
/**
* 获取当前请求的URI
* @return string
*/
public function getRequestUri()
{
return $this->requestUri;
}
/**
* 设置当前请求的URI
* @param string $uri
*/
public function setRequestUri($uri)
{
$this->requestUri = $uri;
}
/**
* 获取允许输出的字段
* @return array
*/
public function getAllowFields()
{
return $this->allowFields;
}
/**
* 设置允许输出的字段
* @param array $fields
*/
public function setAllowFields($fields)
{
$this->allowFields = $fields;
}
/**
* 删除一个指定会员
* @param int $user_id 会员ID
* @return boolean
*/
public function delete($user_id)
{
$user = User::get($user_id);
if (!$user) {
return false;
}
Db::startTrans();
try {
// 删除会员
User::destroy($user_id);
// 删除会员指定的所有Token
Token::clear($user_id);
Hook::listen("user_delete_successed", $user);
Db::commit();
} catch (Exception $e) {
Db::rollback();
$this->setError($e->getMessage());
return false;
}
return true;
}
/**
* 获取密码加密后的字符串
* @param string $password 密码
* @param string $salt 密码盐
* @return string
*/
public function getEncryptPassword($password, $salt = '')
{
return md5(md5($password) . $salt);
}
/**
* 检测当前控制器和方法是否匹配传递的数组
*
* @param array $arr 需要验证权限的数组
* @return boolean
*/
public function match($arr = [])
{
$request = Request::instance();
$arr = is_array($arr) ? $arr : explode(',', $arr);
if (!$arr) {
return false;
}
$arr = array_map('strtolower', $arr);
// 是否存在
if (in_array(strtolower($request->action()), $arr) || in_array('*', $arr)) {
return true;
}
// 没找到匹配
return false;
}
/**
* 设置会话有效时间
* @param int $keeptime 默认为永久
*/
public function keeptime($keeptime = 0)
{
$this->keeptime = $keeptime;
}
/**
* 渲染用户数据
* @param array $datalist 二维数组
* @param mixed $fields 加载的字段列表
* @param string $fieldkey 渲染的字段
* @param string $renderkey 结果字段
* @return array
*/
public function render(&$datalist, $fields = [], $fieldkey = 'user_id', $renderkey = 'userinfo')
{
$fields = !$fields ? ['id', 'nickname', 'level', 'avatar'] : (is_array($fields) ? $fields : explode(',', $fields));
$ids = [];
foreach ($datalist as $k => $v) {
if (!isset($v[$fieldkey])) {
continue;
}
$ids[] = $v[$fieldkey];
}
$list = [];
if ($ids) {
if (!in_array('id', $fields)) {
$fields[] = 'id';
}
$ids = array_unique($ids);
$selectlist = User::where('id', 'in', $ids)->column($fields);
foreach ($selectlist as $k => $v) {
$list[$v['id']] = $v;
}
}
foreach ($datalist as $k => &$v) {
$v[$renderkey] = $list[$v[$fieldkey]] ?? null;
}
unset($v);
return $datalist;
}
/**
* 设置错误信息
*
* @param string $error 错误信息
* @return Auth
*/
public function setError($error)
{
$this->_error = $error;
return $this;
}
/**
* 获取错误信息
* @return string
*/
public function getError()
{
return $this->_error ? __($this->_error) : '';
}
}
+149
View File
@@ -0,0 +1,149 @@
<?php
declare(strict_types=1);
namespace app\common\library;
/**
* ISO 3166-1 alpha-2 国家代码与中文名称
*/
class CountryIso
{
/**
* 常用投放国家 ISO2 => 中文名
*
* @var array<string, string>
*/
private const COUNTRIES = [
'CN' => '中国',
'HK' => '中国香港',
'MO' => '中国澳门',
'TW' => '中国台湾',
'US' => '美国',
'CA' => '加拿大',
'GB' => '英国',
'DE' => '德国',
'FR' => '法国',
'IT' => '意大利',
'ES' => '西班牙',
'NL' => '荷兰',
'BE' => '比利时',
'CH' => '瑞士',
'AT' => '奥地利',
'SE' => '瑞典',
'NO' => '挪威',
'DK' => '丹麦',
'FI' => '芬兰',
'IE' => '爱尔兰',
'PT' => '葡萄牙',
'PL' => '波兰',
'CZ' => '捷克',
'HU' => '匈牙利',
'RO' => '罗马尼亚',
'GR' => '希腊',
'RU' => '俄罗斯',
'UA' => '乌克兰',
'TR' => '土耳其',
'IL' => '以色列',
'SA' => '沙特阿拉伯',
'AE' => '阿联酋',
'QA' => '卡塔尔',
'KW' => '科威特',
'IN' => '印度',
'PK' => '巴基斯坦',
'BD' => '孟加拉国',
'TH' => '泰国',
'VN' => '越南',
'MY' => '马来西亚',
'SG' => '新加坡',
'ID' => '印度尼西亚',
'PH' => '菲律宾',
'JP' => '日本',
'KR' => '韩国',
'AU' => '澳大利亚',
'NZ' => '新西兰',
'BR' => '巴西',
'MX' => '墨西哥',
'AR' => '阿根廷',
'CL' => '智利',
'CO' => '哥伦比亚',
'PE' => '秘鲁',
'ZA' => '南非',
'EG' => '埃及',
'NG' => '尼日利亚',
'KE' => '肯尼亚',
];
/**
* 下拉选项 ISO2 => 中文名
*
* @return array<string, string>
*/
public static function getOptions(): array
{
return self::COUNTRIES;
}
/**
* 校验是否为合法 ISO2 代码
*/
public static function isValidCode(string $code): bool
{
$code = strtoupper(trim($code));
return isset(self::COUNTRIES[$code]);
}
/**
* 规范化并过滤国家代码列表
*
* @param array<int, string>|string $codes
* @return array<int, string>
*/
public static function normalizeCodes($codes): array
{
if (is_string($codes)) {
$codes = explode(',', $codes);
}
if (!is_array($codes)) {
return [];
}
$result = [];
foreach ($codes as $code) {
$code = strtoupper(trim((string)$code));
if ($code !== '' && self::isValidCode($code) && !in_array($code, $result, true)) {
$result[] = $code;
}
}
sort($result);
return $result;
}
/**
* 逗号分隔 ISO 代码转中文展示
*/
public static function codesToText(string $codes): string
{
if ($codes === '') {
return '';
}
$parts = [];
foreach (explode(',', $codes) as $code) {
$code = strtoupper(trim($code));
if ($code === '') {
continue;
}
$parts[] = self::COUNTRIES[$code] ?? $code;
}
return implode(',', $parts);
}
/**
* 数组转存储字符串
*
* @param array<int, string> $codes
*/
public static function codesToStorage(array $codes): string
{
return implode(',', self::normalizeCodes($codes));
}
}
+236
View File
@@ -0,0 +1,236 @@
<?php
namespace app\common\library;
use think\Config;
use Tx\Mailer;
use Tx\Mailer\Exceptions\CodeException;
use Tx\Mailer\Exceptions\SendException;
class Email
{
/**
* 单例对象
*/
protected static $instance;
/**
* phpmailer对象
*/
protected $mail = null;
/**
* 错误内容
*/
protected $error = '';
/**
* 默认配置
*/
public $options = [
'charset' => 'utf-8', //编码格式
'debug' => false, //调式模式
'mail_type' => 0, //状态
];
/**
* 初始化
* @access public
* @param array $options 参数
* @return Email
*/
public static function instance($options = [])
{
if (is_null(self::$instance)) {
self::$instance = new static($options);
}
return self::$instance;
}
/**
* 构造函数
* @param array $options
*/
public function __construct($options = [])
{
if ($config = Config::get('site')) {
$this->options = array_merge($this->options, $config);
}
$this->options = array_merge($this->options, $options);
$secureArr = [0 => '', 1 => 'tls', 2 => 'ssl'];
$secure = $secureArr[$this->options['mail_verify_type']] ?? '';
$logger = isset($this->options['debug']) && $this->options['debug'] ? new Log : null;
$this->mail = new Mailer($logger);
$this->mail->setServer($this->options['mail_smtp_host'], $this->options['mail_smtp_port'], $secure);
$this->mail->setAuth($this->options['mail_from'], $this->options['mail_smtp_pass']);
//设置发件人
$this->from($this->options['mail_from'], $this->options['mail_smtp_user']);
}
/**
* 设置邮件主题
* @param string $subject 邮件主题
* @return $this
*/
public function subject($subject)
{
$this->mail->setSubject($subject);
return $this;
}
/**
* 设置发件人
* @param string $email 发件人邮箱
* @param string $name 发件人名称
* @return $this
*/
public function from($email, $name = '')
{
$this->mail->setFrom($name, $email);
return $this;
}
/**
* 设置收件人
* @param mixed $email 收件人,多个收件人以,进行分隔
* @return $this
*/
public function to($email)
{
$emailArr = $this->buildAddress($email);
foreach ($emailArr as $address => $name) {
$this->mail->addTo($name, $address);
}
return $this;
}
/**
* 设置抄送
* @param mixed $email 收件人,多个收件人以,进行分隔
* @param string $name 收件人名称
* @return Email
*/
public function cc($email, $name = '')
{
$emailArr = $this->buildAddress($email);
if (count($emailArr) == 1 && $name) {
$emailArr[key($emailArr)] = $name;
}
foreach ($emailArr as $address => $name) {
$this->mail->addCC($name, $address);
}
return $this;
}
/**
* 设置密送
* @param mixed $email 收件人,多个收件人以,进行分隔
* @param string $name 收件人名称
* @return Email
*/
public function bcc($email, $name = '')
{
$emailArr = $this->buildAddress($email);
if (count($emailArr) == 1 && $name) {
$emailArr[key($emailArr)] = $name;
}
foreach ($emailArr as $address => $name) {
$this->mail->addBCC($name, $address);
}
return $this;
}
/**
* 设置邮件正文
* @param string $body 邮件下方
* @param boolean $ishtml 是否HTML格式
* @return $this
*/
public function message($body, $ishtml = true)
{
$this->mail->setBody($body);
return $this;
}
/**
* 添加附件
* @param string $path 附件路径
* @param string $name 附件名称
* @return Email
*/
public function attachment($path, $name = '')
{
$this->mail->addAttachment($name, $path);
return $this;
}
/**
* 构建Email地址
* @param mixed $emails Email数据
* @return array
*/
protected function buildAddress($emails)
{
if (!is_array($emails)) {
$emails = array_flip(explode(',', str_replace(";", ",", $emails)));
foreach ($emails as $key => $value) {
$emails[$key] = strstr($key, '@', true);
}
}
return $emails;
}
/**
* 获取最后产生的错误
* @return string
*/
public function getError()
{
return $this->error;
}
/**
* 设置错误
* @param string $error 信息信息
*/
protected function setError($error)
{
$this->error = $error;
}
/**
* 发送邮件
* @return boolean
*/
public function send()
{
$result = false;
if (in_array($this->options['mail_type'], [1, 2])) {
try {
$result = $this->mail->send();
} catch (SendException $e) {
$this->setError($e->getCode() . $e->getMessage());
} catch (CodeException $e) {
preg_match_all("/Expected: (\d+)\, Got: (\d+)( \| (.*))?\$/i", $e->getMessage(), $matches);
$code = $matches[2][0] ?? 0;
$message = isset($matches[2][0]) && isset($matches[4][0]) ? $matches[4][0] : $e->getMessage();
$message = mb_convert_encoding($message, 'UTF-8', 'GBK,GB2312,BIG5');
$this->setError($message);
} catch (\Exception $e) {
$this->setError($e->getMessage());
}
$this->setError($result ? '' : $this->getError());
} else {
//邮件功能已关闭
$this->setError(__('Mail already closed'));
}
return $result;
}
}
+157
View File
@@ -0,0 +1,157 @@
<?php
namespace app\common\library;
use fast\Random;
use think\Hook;
/**
* 邮箱验证码类
*/
class Ems
{
/**
* 验证码有效时长
* @var int
*/
protected static $expire = 120;
/**
* 最大允许检测的次数
* @var int
*/
protected static $maxCheckNums = 10;
/**
* 获取最后一次邮箱发送的数据
*
* @param int $email 邮箱
* @param string $event 事件
* @return Ems|null
*/
public static function get($email, $event = 'default')
{
$ems = \app\common\model\Ems::where(['email' => $email, 'event' => $event])
->order('id', 'DESC')
->find();
Hook::listen('ems_get', $ems, null, true);
return $ems ?: null;
}
/**
* 发送验证码
*
* @param int $email 邮箱
* @param int $code 验证码,为空时将自动生成4位数字
* @param string $event 事件
* @return boolean
*/
public static function send($email, $code = null, $event = 'default')
{
$code = is_null($code) ? Random::numeric(config('captcha.length')) : $code;
$time = time();
$ip = request()->ip();
$ems = \app\common\model\Ems::create(['event' => $event, 'email' => $email, 'code' => $code, 'ip' => $ip, 'createtime' => $time]);
if (!Hook::get('ems_send')) {
//采用框架默认的邮件推送
Hook::add('ems_send', function ($params) {
$obj = new Email();
$result = $obj
->to($params->email)
->subject('请查收你的验证码!')
->message("你的验证码是:" . $params->code . "" . ceil(self::$expire / 60) . "分钟内有效。")
->send();
return $result;
});
}
$result = Hook::listen('ems_send', $ems, null, true);
if (!$result) {
$ems->delete();
return false;
}
return true;
}
/**
* 发送通知
*
* @param mixed $email 邮箱,多个以,分隔
* @param string $msg 消息内容
* @param string $template 消息模板
* @return boolean
*/
public static function notice($email, $msg = '', $template = null)
{
$params = [
'email' => $email,
'msg' => $msg,
'template' => $template
];
if (!Hook::get('ems_notice')) {
//采用框架默认的邮件推送
Hook::add('ems_notice', function ($params) {
$subject = '你收到一封新的邮件!';
$content = $params['msg'];
$email = new Email();
$result = $email->to($params['email'])
->subject($subject)
->message($content)
->send();
return $result;
});
}
$result = Hook::listen('ems_notice', $params, null, true);
return (bool)$result;
}
/**
* 校验验证码
*
* @param int $email 邮箱
* @param int $code 验证码
* @param string $event 事件
* @return boolean
*/
public static function check($email, $code, $event = 'default')
{
$time = time() - self::$expire;
$ems = \app\common\model\Ems::where(['email' => $email, 'event' => $event])
->order('id', 'DESC')
->find();
if ($ems) {
if ($ems['createtime'] > $time && $ems['times'] <= self::$maxCheckNums) {
$correct = $code == $ems['code'];
if (!$correct) {
$ems->times = $ems->times + 1;
$ems->save();
return false;
} else {
$result = Hook::listen('ems_check', $ems, null, true);
return true;
}
} else {
// 过期则清空该邮箱验证码
self::flush($email, $event);
return false;
}
} else {
return false;
}
}
/**
* 清空指定邮箱验证码
*
* @param int $email 邮箱
* @param string $event 事件
* @return boolean
*/
public static function flush($email, $event = 'default')
{
\app\common\model\Ems::where(['email' => $email, 'event' => $event])
->delete();
Hook::listen('ems_flush');
return true;
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace app\common\library;
use Psr\Log\AbstractLogger;
use think\Hook;
/**
* 日志记录类
*/
class Log extends AbstractLogger
{
/**
* Logs with an arbitrary level.
*
* @param mixed $level
* @param string $message
* @param array $context
*
* @return void
*/
public function log($level, $message, array $context = [])
{
\think\Log::write($message);
}
}
+225
View File
@@ -0,0 +1,225 @@
<?php
namespace app\common\library;
use app\admin\model\AuthRule;
use fast\Tree;
use think\addons\Service;
use think\Db;
use think\Exception;
use think\exception\PDOException;
class Menu
{
/**
* 创建菜单
* @param array $menu
* @param mixed $parent 父类的name或pid
*/
public static function create($menu = [], $parent = 0)
{
$old = [];
self::menuUpdate($menu, $old, $parent);
//菜单刷新处理
$info = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 2)[1];
preg_match('/addons\\\\([a-z0-9]+)\\\\/i', $info['class'], $matches);
if ($matches && isset($matches[1])) {
Menu::refresh($matches[1], $menu);
}
}
/**
* 删除菜单
* @param string $name 规则name
* @return boolean
*/
public static function delete($name)
{
$ids = self::getAuthRuleIdsByName($name);
if (!$ids) {
return false;
}
AuthRule::destroy($ids);
return true;
}
/**
* 启用菜单
* @param string $name
* @return boolean
*/
public static function enable($name)
{
$ids = self::getAuthRuleIdsByName($name);
if (!$ids) {
return false;
}
AuthRule::where('id', 'in', $ids)->update(['status' => 'normal']);
return true;
}
/**
* 禁用菜单
* @param string $name
* @return boolean
*/
public static function disable($name)
{
$ids = self::getAuthRuleIdsByName($name);
if (!$ids) {
return false;
}
AuthRule::where('id', 'in', $ids)->update(['status' => 'hidden']);
return true;
}
/**
* 升级菜单
* @param string $name 插件名称
* @param array $menu 新菜单
* @return bool
*/
public static function upgrade($name, $menu)
{
$ids = self::getAuthRuleIdsByName($name);
$old = AuthRule::where('id', 'in', $ids)->select();
$old = collection($old)->toArray();
$old = array_column($old, null, 'name');
Db::startTrans();
try {
self::menuUpdate($menu, $old);
$ids = [];
foreach ($old as $index => $item) {
if (!isset($item['keep'])) {
$ids[] = $item['id'];
}
}
if ($ids) {
//旧版本的菜单需要做删除处理
$config = Service::config($name);
$menus = $config['menus'] ?? [];
$where = ['id' => ['in', $ids]];
if ($menus) {
//必须是旧版本中的菜单,可排除用户自主创建的菜单
$where['name'] = ['in', $menus];
}
AuthRule::where($where)->delete();
}
Db::commit();
} catch (PDOException $e) {
Db::rollback();
return false;
}
Menu::refresh($name, $menu);
return true;
}
/**
* 刷新插件菜单配置缓存
* @param string $name
* @param array $menu
*/
public static function refresh($name, $menu = [])
{
if (!$menu) {
// $menu为空时表示首次安装,首次安装需刷新插件菜单标识缓存
$menuIds = Menu::getAuthRuleIdsByName($name);
$menus = Db::name("auth_rule")->where('id', 'in', $menuIds)->column('name');
} else {
// 刷新新的菜单缓存
$getMenus = function ($menu) use (&$getMenus) {
$result = [];
foreach ($menu as $index => $item) {
$result[] = $item['name'];
$result = array_merge($result, isset($item['sublist']) && is_array($item['sublist']) ? $getMenus($item['sublist']) : []);
}
return $result;
};
$menus = $getMenus($menu);
}
//刷新新的插件核心菜单缓存
Service::config($name, ['menus' => $menus]);
}
/**
* 导出指定名称的菜单规则
* @param string $name
* @return array
*/
public static function export($name)
{
$ids = self::getAuthRuleIdsByName($name);
if (!$ids) {
return [];
}
$menuList = [];
$menu = AuthRule::getByName($name);
if ($menu) {
$ruleList = collection(AuthRule::where('id', 'in', $ids)->select())->toArray();
$menuList = Tree::instance()->init($ruleList)->getTreeArray($menu['id']);
}
return $menuList;
}
/**
* 菜单升级
* @param array $newMenu
* @param array $oldMenu
* @param int $parent
* @throws Exception
*/
private static function menuUpdate($newMenu, &$oldMenu, $parent = 0)
{
if (!is_numeric($parent)) {
$parentRule = AuthRule::getByName($parent);
$pid = $parentRule ? $parentRule['id'] : 0;
} else {
$pid = $parent;
}
$allow = array_flip(['file', 'name', 'title', 'url', 'icon', 'condition', 'remark', 'ismenu', 'menutype', 'extend', 'weigh', 'status']);
foreach ($newMenu as $k => $v) {
$hasChild = isset($v['sublist']) && $v['sublist'];
$data = array_intersect_key($v, $allow);
$data['ismenu'] = $data['ismenu'] ?? ($hasChild ? 1 : 0);
$data['icon'] = $data['icon'] ?? ($hasChild ? 'fa fa-list' : 'fa fa-circle-o');
$data['pid'] = $pid;
$data['status'] = $data['status'] ?? 'normal';
if (!isset($oldMenu[$data['name']])) {
$menu = AuthRule::create($data);
} else {
$menu = $oldMenu[$data['name']];
//更新旧菜单
AuthRule::update($data, ['id' => $menu['id']]);
$oldMenu[$data['name']]['keep'] = true;
}
if ($hasChild) {
self::menuUpdate($v['sublist'], $oldMenu, $menu['id']);
}
}
}
/**
* 根据名称获取规则IDS
* @param string $name
* @return array
*/
public static function getAuthRuleIdsByName($name)
{
$ids = [];
$menu = AuthRule::getByName($name);
if ($menu) {
// 必须将结果集转换为数组
$ruleList = collection(AuthRule::order('weigh', 'desc')->field('id,pid,name')->select())->toArray();
// 构造菜单数据
$ids = Tree::instance()->init($ruleList)->getChildrenIds($menu['id'], true);
}
return $ids;
}
}
+872
View File
@@ -0,0 +1,872 @@
<?php
namespace app\common\library;
use Exception;
/**
* 安全过滤类
*
* @category Security
* @copyright Copyright (c) 2008 - 2014, EllisLab, Inc. (https://ellislab.com/)
* @copyright Copyright (c) 2014 - 2019, British Columbia Institute of Technology (https://bcit.ca/)
* @license https://opensource.org/licenses/MIT MIT License
* @link https://codeigniter.com
* @author EllisLab Dev Team
*/
class Security
{
protected static $instance = null;
/**
* List of sanitize filename strings
*
* @var array
*/
public $filename_bad_chars = array(
'../',
'<!--',
'-->',
'<',
'>',
"'",
'"',
'&',
'$',
'#',
'{',
'}',
'[',
']',
'=',
';',
'?',
'%20',
'%22',
'%3c', // <
'%253c', // <
'%3e', // >
'%0e', // >
'%28', // (
'%29', // )
'%2528', // (
'%26', // &
'%24', // $
'%3f', // ?
'%3b', // ;
'%3d' // =
);
/**
* Character set
*
* Will be overridden by the constructor.
*
* @var string
*/
public $charset = 'UTF-8';
/**
* XSS Hash
*
* Random Hash for protecting URLs.
*
* @var string
*/
protected $_xss_hash;
/**
* List of never allowed strings
*
* @var array
*/
protected $_never_allowed_str = array(
'document.cookie' => '[removed]',
'(document).cookie' => '[removed]',
'document.write' => '[removed]',
'(document).write' => '[removed]',
'.parentNode' => '[removed]',
'.innerHTML' => '[removed]',
'-moz-binding' => '[removed]',
'<!--' => '&lt;!--',
'-->' => '--&gt;',
'<![CDATA[' => '&lt;![CDATA[',
'<comment>' => '&lt;comment&gt;',
'<%' => '&lt;&#37;'
);
/**
* List of never allowed regex replacements
*
* @var array
*/
protected $_never_allowed_regex = array(
'javascript\s*:',
'(\(?document\)?|\(?window\)?(\.document)?)\.(location|on\w*)',
'expression\s*(\(|&\#40;)', // CSS and IE
'vbscript\s*:', // IE, surprise!
'wscript\s*:', // IE
'jscript\s*:', // IE
'vbs\s*:', // IE
'Redirect\s+30\d',
"([\"'])?data\s*:[^\\1]*?base64[^\\1]*?,[^\\1]*?\\1?"
);
protected $options = [
'placeholder' => '[removed]'
];
/**
* Class constructor
*
* @return void
*/
public function __construct($options = [])
{
$this->options = array_merge($this->options, $options);
foreach ($this->_never_allowed_str as $index => &$item) {
$item = str_replace('[removed]', $this->options['placeholder'], $item);
}
}
/**
*
* @param array $options 参数
* @return Security
*/
public static function instance($options = [])
{
if (is_null(self::$instance)) {
self::$instance = new static($options);
}
return self::$instance;
}
/**
* XSS Clean
*
* Sanitizes data so that Cross Site Scripting Hacks can be
* prevented. This method does a fair amount of work but
* it is extremely thorough, designed to prevent even the
* most obscure XSS attempts. Nothing is ever 100% foolproof,
* of course, but I haven't been able to get anything passed
* the filter.
*
* Note: Should only be used to deal with data upon submission.
* It's not something that should be used for general
* runtime processing.
*
* @link http://channel.bitflux.ch/wiki/XSS_Prevention
* Based in part on some code and ideas from Bitflux.
*
* @link http://ha.ckers.org/xss.html
* To help develop this script I used this great list of
* vulnerabilities along with a few other hacks I've
* harvested from examining vulnerabilities in other programs.
*
* @param string|string[] $str Input data
* @param bool $is_image Whether the input is an image
* @return string
*/
public function xss_clean($str, $is_image = false)
{
// Is the string an array?
if (is_array($str)) {
foreach ($str as $key => &$value) {
$str[$key] = $this->xss_clean($value);
}
return $str;
}
// Remove Invisible Characters
$str = $this->remove_invisible_characters($str);
/*
* URL Decode
*
* Just in case stuff like this is submitted:
*
* <a href="http://%77%77%77%2E%67%6F%6F%67%6C%65%2E%63%6F%6D">Google</a>
*
* Note: Use rawurldecode() so it does not remove plus signs
*/
if (stripos($str, '%') !== false) {
do {
$oldstr = $str;
$str = rawurldecode($str);
$str = preg_replace_callback('#%(?:\s*[0-9a-f]){2,}#i', array($this, '_urldecodespaces'), $str);
} while ($oldstr !== $str);
unset($oldstr);
}
/*
* Convert character entities to ASCII
*
* This permits our tests below to work reliably.
* We only convert entities that are within tags since
* these are the ones that will pose security problems.
*/
$str = preg_replace_callback("/[^a-z0-9>]+[a-z0-9]+=([\'\"]).*?\\1/si", array($this, '_convert_attribute'), $str);
$str = preg_replace_callback('/<\w+.*/si', array($this, '_decode_entity'), $str);
// Remove Invisible Characters Again!
$str = $this->remove_invisible_characters($str);
/*
* Convert all tabs to spaces
*
* This prevents strings like this: ja vascript
* NOTE: we deal with spaces between characters later.
* NOTE: preg_replace was found to be amazingly slow here on
* large blocks of data, so we use str_replace.
*/
$str = str_replace("\t", ' ', $str);
// Capture converted string for later comparison
$converted_string = $str;
// Remove Strings that are never allowed
$str = $this->_do_never_allowed($str);
/*
* Makes PHP tags safe
*
* Note: XML tags are inadvertently replaced too:
*
* <?xml
*
* But it doesn't seem to pose a problem.
*/
if ($is_image === true) {
// Images have a tendency to have the PHP short opening and
// closing tags every so often so we skip those and only
// do the long opening tags.
$str = preg_replace('/<\?(php)/i', '&lt;?\\1', $str);
} else {
$str = str_replace(array('<?', '?' . '>'), array('&lt;?', '?&gt;'), $str);
}
/*
* Compact any exploded words
*
* This corrects words like: j a v a s c r i p t
* These words are compacted back to their correct state.
*/
$words = array(
'javascript',
'expression',
'vbscript',
'jscript',
'wscript',
'vbs',
'script',
'base64',
'applet',
'alert',
'document',
'write',
'cookie',
'window',
'confirm',
'prompt',
'eval'
);
foreach ($words as $word) {
$word = implode('\s*', str_split($word)) . '\s*';
// We only want to do this when it is followed by a non-word character
// That way valid stuff like "dealer to" does not become "dealerto"
$str = preg_replace_callback('#(' . substr($word, 0, -3) . ')(\W)#is', array($this, '_compact_exploded_words'), $str);
}
/*
* Remove disallowed Javascript in links or img tags
* We used to do some version comparisons and use of stripos(),
* but it is dog slow compared to these simplified non-capturing
* preg_match(), especially if the pattern exists in the string
*
* Note: It was reported that not only space characters, but all in
* the following pattern can be parsed as separators between a tag name
* and its attributes: [\d\s"\'`;,\/\=\(\x00\x0B\x09\x0C]
* ... however, $this->remove_invisible_characters() above already strips the
* hex-encoded ones, so we'll skip them below.
*/
do {
$original = $str;
if (preg_match('/<a/i', $str)) {
$str = preg_replace_callback('#<a(?:rea)?[^a-z0-9>]+([^>]*?)(?:>|$)#si', array($this, '_js_link_removal'), $str);
}
if (preg_match('/<img/i', $str)) {
$str = preg_replace_callback('#<img[^a-z0-9]+([^>]*?)(?:\s?/?>|$)#si', array($this, '_js_img_removal'), $str);
}
if (preg_match('/script|xss/i', $str)) {
$str = preg_replace('#</*(?:script|xss).*?>#si', $this->options['placeholder'], $str);
}
} while ($original !== $str);
unset($original);
/*
* Sanitize naughty HTML elements
*
* If a tag containing any of the words in the list
* below is found, the tag gets converted to entities.
*
* So this: <blink>
* Becomes: &lt;blink&gt;
*/
$pattern = '#'
. '<((?<slash>/*\s*)((?<tagName>[a-z0-9]+)(?=[^a-z0-9]|$)|.+)' // tag start and name, followed by a non-tag character
. '[^\s\042\047a-z0-9>/=]*' // a valid attribute character immediately after the tag would count as a separator
// optional attributes
. '(?<attributes>(?:[\s\042\047/=]*' // non-attribute characters, excluding > (tag close) for obvious reasons
. '[^\s\042\047>/=]+' // attribute characters
// optional attribute-value
. '(?:\s*=' // attribute-value separator
. '(?:[^\s\042\047=><`]+|\s*\042[^\042]*\042|\s*\047[^\047]*\047|\s*(?U:[^\s\042\047=><`]*))' // single, double or non-quoted value
. ')?' // end optional attribute-value group
. ')*)' // end optional attributes group
. '[^>]*)(?<closeTag>\>)?#isS';
// Note: It would be nice to optimize this for speed, BUT
// only matching the naughty elements here results in
// false positives and in turn - vulnerabilities!
do {
$old_str = $str;
$str = preg_replace_callback($pattern, array($this, '_sanitize_naughty_html'), $str);
} while ($old_str !== $str);
unset($old_str);
/*
* Sanitize naughty scripting elements
*
* Similar to above, only instead of looking for
* tags it looks for PHP and JavaScript commands
* that are disallowed. Rather than removing the
* code, it simply converts the parenthesis to entities
* rendering the code un-executable.
*
* For example: eval('some code')
* Becomes: eval&#40;'some code'&#41;
*/
$str = preg_replace(
'#(alert|prompt|confirm|cmd|passthru|eval|exec|expression|system|fopen|fsockopen|file|file_get_contents|readfile|unlink)(\s*)\((.*?)\)#si',
'\\1\\2&#40;\\3&#41;',
$str
);
// Same thing, but for "tag functions" (e.g. eval`some code`)
$str = preg_replace(
'#(alert|prompt|confirm|cmd|passthru|eval|exec|expression|system|fopen|fsockopen|file|file_get_contents|readfile|unlink)(\s*)`(.*?)`#si',
'\\1\\2&#96;\\3&#96;',
$str
);
// Final clean up
// This adds a bit of extra precaution in case
// something got through the above filters
$str = $this->_do_never_allowed($str);
/*
* Images are Handled in a Special Way
* - Essentially, we want to know that after all of the character
* conversion is done whether any unwanted, likely XSS, code was found.
* If not, we return TRUE, as the image is clean.
* However, if the string post-conversion does not matched the
* string post-removal of XSS, then it fails, as there was unwanted XSS
* code found and removed/changed during processing.
*/
if ($is_image === true) {
return ($str === $converted_string);
}
return $str;
}
// --------------------------------------------------------------------
/**
* XSS Hash
*
* Generates the XSS hash if needed and returns it.
*
* @return string XSS hash
*/
public function xss_hash()
{
if ($this->_xss_hash === null) {
$rand = $this->get_random_bytes(16);
$this->_xss_hash = ($rand === false)
? md5(uniqid(mt_rand(), true))
: bin2hex($rand);
}
return $this->_xss_hash;
}
// --------------------------------------------------------------------
/**
* Get random bytes
*
* @param int $length Output length
* @return string
*/
public function get_random_bytes($length)
{
if (empty($length) or !ctype_digit((string)$length)) {
return false;
}
if (function_exists('random_bytes')) {
try {
// The cast is required to avoid TypeError
return random_bytes((int)$length);
} catch (Exception $e) {
// If random_bytes() can't do the job, we can't either ...
// There's no point in using fallbacks.
return false;
}
}
// Unfortunately, none of the following PRNGs is guaranteed to exist ...
if (defined('MCRYPT_DEV_URANDOM') && ($output = mcrypt_create_iv($length, MCRYPT_DEV_URANDOM)) !== false) {
return $output;
}
if (is_readable('/dev/urandom') && ($fp = fopen('/dev/urandom', 'rb')) !== false) {
// Try not to waste entropy ...
stream_set_chunk_size($fp, $length);
$output = fread($fp, $length);
fclose($fp);
if ($output !== false) {
return $output;
}
}
if (function_exists('openssl_random_pseudo_bytes')) {
return openssl_random_pseudo_bytes($length);
}
return false;
}
// --------------------------------------------------------------------
/**
* HTML Entities Decode
*
* A replacement for html_entity_decode()
*
* The reason we are not using html_entity_decode() by itself is because
* while it is not technically correct to leave out the semicolon
* at the end of an entity most browsers will still interpret the entity
* correctly. html_entity_decode() does not convert entities without
* semicolons, so we are left with our own little solution here. Bummer.
*
* @link https://secure.php.net/html-entity-decode
*
* @param string $str Input
* @param string $charset Character set
* @return string
*/
public function entity_decode($str, $charset = null)
{
if (strpos($str, '&') === false) {
return $str;
}
static $_entities;
isset($charset) or $charset = $this->charset;
isset($_entities) or $_entities = array_map('strtolower', get_html_translation_table(HTML_ENTITIES, ENT_COMPAT | ENT_HTML5, $charset));
do {
$str_compare = $str;
// Decode standard entities, avoiding false positives
if (preg_match_all('/&[a-z]{2,}(?![a-z;])/i', $str, $matches)) {
$replace = array();
$matches = array_unique(array_map('strtolower', $matches[0]));
foreach ($matches as &$match) {
if (($char = array_search($match . ';', $_entities, true)) !== false) {
$replace[$match] = $char;
}
}
$str = str_replace(array_keys($replace), array_values($replace), $str);
}
// Decode numeric & UTF16 two byte entities
$str = html_entity_decode(
preg_replace('/(&#(?:x0*[0-9a-f]{2,5}(?![0-9a-f;])|(?:0*\d{2,4}(?![0-9;]))))/iS', '$1;', $str),
ENT_COMPAT | ENT_HTML5,
$charset
);
} while ($str_compare !== $str);
return $str;
}
// --------------------------------------------------------------------
/**
* Sanitize Filename
*
* @param string $str Input file name
* @param bool $relative_path Whether to preserve paths
* @return string
*/
public function sanitize_filename($str, $relative_path = false)
{
$bad = $this->filename_bad_chars;
if (!$relative_path) {
$bad[] = './';
$bad[] = '/';
}
$str = $this->remove_invisible_characters($str, false);
do {
$old = $str;
$str = str_replace($bad, '', $str);
} while ($old !== $str);
return stripslashes($str);
}
// ----------------------------------------------------------------
/**
* Strip Image Tags
*
* @param string $str
* @return string
*/
public function strip_image_tags($str)
{
return preg_replace(
array(
'#<img[\s/]+.*?src\s*=\s*(["\'])([^\\1]+?)\\1.*?\>#i',
'#<img[\s/]+.*?src\s*=\s*?(([^\s"\'=<>`]+)).*?\>#i'
),
'\\2',
$str
);
}
// ----------------------------------------------------------------
/**
* URL-decode taking spaces into account
*
* @param array $matches
* @return string
*/
protected function _urldecodespaces($matches)
{
$input = $matches[0];
$nospaces = preg_replace('#\s+#', '', $input);
return ($nospaces === $input)
? $input
: rawurldecode($nospaces);
}
// ----------------------------------------------------------------
/**
* Compact Exploded Words
*
* Callback method for xss_clean() to remove whitespace from
* things like 'j a v a s c r i p t'.
*
* @param array $matches
* @return string
*/
protected function _compact_exploded_words($matches)
{
return preg_replace('/\s+/s', '', $matches[1]) . $matches[2];
}
// --------------------------------------------------------------------
/**
* Sanitize Naughty HTML
*
* Callback method for xss_clean() to remove naughty HTML elements.
*
* @param array $matches
* @return string
*/
protected function _sanitize_naughty_html($matches)
{
static $naughty_tags = array(
'alert',
'area',
'prompt',
'confirm',
'applet',
'audio',
'basefont',
'base',
'behavior',
'bgsound',
'blink',
'body',
'embed',
'expression',
'form',
'frameset',
'frame',
'head',
'html',
'ilayer',
'iframe',
'input',
'button',
'select',
'isindex',
'layer',
'link',
'meta',
'keygen',
'object',
'plaintext',
'style',
'script',
'textarea',
'title',
'math',
'video',
'svg',
'xml',
'xss'
);
static $evil_attributes = array(
'on\w+',
'style',
'xmlns',
'formaction',
'form',
'xlink:href',
'FSCommand',
'seekSegmentTime'
);
// First, escape unclosed tags
if (empty($matches['closeTag'])) {
return '&lt;' . $matches[1];
} // Is the element that we caught naughty? If so, escape it
elseif (in_array(strtolower($matches['tagName']), $naughty_tags, true)) {
return '&lt;' . $matches[1] . '&gt;';
} // For other tags, see if their attributes are "evil" and strip those
elseif (isset($matches['attributes'])) {
// We'll store the already filtered attributes here
$attributes = array();
// Attribute-catching pattern
$attributes_pattern = '#'
. '(?<name>[^\s\042\047>/=]+)' // attribute characters
// optional attribute-value
. '(?:\s*=(?<value>[^\s\042\047=><`]+|\s*\042[^\042]*\042|\s*\047[^\047]*\047|\s*(?U:[^\s\042\047=><`]*)))' // attribute-value separator
. '#i';
// Blacklist pattern for evil attribute names
$is_evil_pattern = '#^(' . implode('|', $evil_attributes) . ')$#i';
// Each iteration filters a single attribute
do {
// Strip any non-alpha characters that may precede an attribute.
// Browsers often parse these incorrectly and that has been a
// of numerous XSS issues we've had.
$matches['attributes'] = preg_replace('#^[^a-z]+#i', '', $matches['attributes']);
if (!preg_match($attributes_pattern, $matches['attributes'], $attribute, PREG_OFFSET_CAPTURE)) {
// No (valid) attribute found? Discard everything else inside the tag
break;
}
if (
// Is it indeed an "evil" attribute?
preg_match($is_evil_pattern, $attribute['name'][0])
// Or does it have an equals sign, but no value and not quoted? Strip that too!
or (trim($attribute['value'][0]) === '')
) {
$attributes[] = 'xss=removed';
} else {
$attributes[] = $attribute[0][0];
}
$matches['attributes'] = substr($matches['attributes'], $attribute[0][1] + strlen($attribute[0][0]));
} while ($matches['attributes'] !== '');
$attributes = empty($attributes)
? ''
: ' ' . implode(' ', $attributes);
return '<' . $matches['slash'] . $matches['tagName'] . $attributes . '>';
}
return $matches[0];
}
// --------------------------------------------------------------------
/**
* JS Link Removal
*
* Callback method for xss_clean() to sanitize links.
*
* This limits the PCRE backtracks, making it more performance friendly
* and prevents PREG_BACKTRACK_LIMIT_ERROR from being triggered in
* PHP 5.2+ on link-heavy strings.
*
* @param array $match
* @return string
*/
protected function _js_link_removal($match)
{
return str_replace(
$match[1],
preg_replace(
'#href=.*?(?:(?:alert|prompt|confirm)(?:\(|&\#40;|`|&\#96;)|javascript:|livescript:|mocha:|charset=|window\.|\(?document\)?\.|\.cookie|<script|<xss|d\s*a\s*t\s*a\s*:)#si',
'',
$this->_filter_attributes($match[1])
),
$match[0]
);
}
// --------------------------------------------------------------------
/**
* JS Image Removal
*
* Callback method for xss_clean() to sanitize image tags.
*
* This limits the PCRE backtracks, making it more performance friendly
* and prevents PREG_BACKTRACK_LIMIT_ERROR from being triggered in
* PHP 5.2+ on image tag heavy strings.
*
* @param array $match
* @return string
*/
protected function _js_img_removal($match)
{
return str_replace(
$match[1],
preg_replace(
'#src=.*?(?:(?:alert|prompt|confirm|eval)(?:\(|&\#40;|`|&\#96;)|javascript:|livescript:|mocha:|charset=|window\.|\(?document\)?\.|\.cookie|<script|<xss|base64\s*,)#si',
'',
$this->_filter_attributes($match[1])
),
$match[0]
);
}
// --------------------------------------------------------------------
/**
* Attribute Conversion
*
* @param array $match
* @return string
*/
protected function _convert_attribute($match)
{
return str_replace(array('>', '<', '\\'), array('&gt;', '&lt;', '\\\\'), $match[0]);
}
// --------------------------------------------------------------------
/**
* Filter Attributes
*
* Filters tag attributes for consistency and safety.
*
* @param string $str
* @return string
*/
protected function _filter_attributes($str)
{
$out = '';
if (preg_match_all('#\s*[a-z\-]+\s*=\s*(\042|\047)([^\\1]*?)\\1#is', $str, $matches)) {
foreach ($matches[0] as $match) {
$out .= preg_replace('#/\*.*?\*/#s', '', $match);
}
}
return $out;
}
// --------------------------------------------------------------------
/**
* HTML Entity Decode Callback
*
* @param array $match
* @return string
*/
protected function _decode_entity($match)
{
// Protect GET variables in URLs
// 901119URL5918AMP18930PROTECT8198
$match = preg_replace('|\&([a-z\_0-9\-]+)\=([a-z\_0-9\-/]+)|i', $this->xss_hash() . '\\1=\\2', $match[0]);
// Decode, then un-protect URL GET vars
return str_replace(
$this->xss_hash(),
'&',
$this->entity_decode($match, $this->charset)
);
}
// --------------------------------------------------------------------
/**
* Do Never Allowed
*
* @param string
* @return string
*/
protected function _do_never_allowed($str)
{
$str = str_replace(array_keys($this->_never_allowed_str), $this->_never_allowed_str, $str);
foreach ($this->_never_allowed_regex as $regex) {
$str = preg_replace('#' . $regex . '#is', $this->options['placeholder'], $str);
}
return $str;
}
/**
* Remove Invisible Characters
*/
public function remove_invisible_characters($str, $url_encoded = true)
{
$non_displayables = array();
// every control character except newline (dec 10),
// carriage return (dec 13) and horizontal tab (dec 09)
if ($url_encoded) {
$non_displayables[] = '/%0[0-8bcef]/i'; // url encoded 00-08, 11, 12, 14, 15
$non_displayables[] = '/%1[0-9a-f]/i'; // url encoded 16-31
$non_displayables[] = '/%7f/i'; // url encoded 127
}
$non_displayables[] = '/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F]+/S'; // 00-08, 11, 12, 14-31, 127
do {
$str = preg_replace($non_displayables, '', $str, -1, $count);
} while ($count);
return $str;
}
}
+447
View File
@@ -0,0 +1,447 @@
<?php
namespace app\common\library;
use fast\Tree;
use think\Db;
use think\Exception;
use think\Model;
/**
* SelectPage 查询构建器
*/
class SelectPage
{
/**
* 模型实例
* @var Model
*/
protected $model;
/**
* 允许显示的字段
* @var array|string
*/
protected $selectpageFields = '*';
/**
* 数据限制模式
* @var bool|string
*/
protected $dataLimit = false;
/**
* 数据限制字段
* @var string
*/
protected $dataLimitField = 'admin_id';
/**
* 允许的表字段列表
* @var array
*/
protected $allowedFields = [];
/**
* 允许的操作符(ThinkPHP Builder::$exp 的键和值(不包含exp),去重后保留小写)
* @var array
*/
protected static $allowedOperators = [
'eq', 'neq', 'gt', 'egt', 'lt', 'elt',
'=', '<>', '>', '>=', '<', '<=',
'like', 'not like', 'notlike',
'in', 'not in', 'notin',
'between', 'not between', 'notbetween',
'null', 'not null', 'notnull',
'exists', 'not exists', 'notexists',
'> time', '< time', '>= time', '<= time',
'between time', 'not between time', 'notbetween time',
];
/**
* 允许排序的字段
* @var array
*/
protected $orderFields = [];
/**
* @param Model $model 模型实例
* @param string $fields SelectPage可显示的字段
*/
public function __construct(Model $model, $fields = '*')
{
$this->model = $model;
$this->selectpageFields = $fields;
$this->allowedFields = array_map('strtolower', $model->getTableFields());
$this->orderFields = $this->allowedFields;
}
/**
* 数据限制的ID集合
* @var array
*/
protected $dataLimitIds = [];
/**
* 设置数据限制
* @param bool|string $dataLimit auth/personal/false
* @param string $dataLimitField 限制字段
* @param array $dataLimitIds 允许的ID列表
* @return $this
*/
public function setDataLimit($dataLimit, $dataLimitField = 'admin_id', array $dataLimitIds = [])
{
$this->dataLimit = $dataLimit;
$this->dataLimitField = $dataLimitField;
$this->dataLimitIds = $dataLimitIds;
return $this;
}
/**
* 应用数据限制条件(每次构建新查询链前调用)
* ThinkPHP 的 count()/select() 执行后会清空 model options
* 所以需要在每次查询前重新注入 dataLimit 条件。
* @return $this
*/
protected function applyDataLimit()
{
if ($this->dataLimit) {
$this->model->where($this->dataLimitField, 'in', $this->dataLimitIds);
}
return $this;
}
/**
* 执行查询
* @param array $params 请求参数
* @return array ['list' => [...], 'total' => int]
*/
public function execute(array $params)
{
$keywordWords = $this->getArrayParam($params, 'q_word');
$page = $params['pageNumber'] ?? 1;
$pageSize = $params['pageSize'] ?? 10;
$andor = strtoupper($params['andOr'] ?? 'AND');
$orderBy = $this->getArrayParam($params, 'orderBy');
$showField = $params['showField'] ?? 'name';
$keyField = $params['keyField'] ?? '';
$keyValue = $params['keyValue'] ?? null;
$searchField = $this->getArrayParam($params, 'searchField');
$custom = $this->getArrayParam($params, 'custom');
$isTree = (bool)($params['isTree'] ?? 0);
$isHtml = (bool)($params['isHtml'] ?? 0);
// 树形模式强制参数
if ($isTree) {
$keywordWords = [];
$pageSize = 999999;
}
// 验证字段
$this->validateField($showField);
$this->validateField($keyField);
// 验证搜索字段
foreach ($searchField as $f) {
$this->validateField($f);
}
// 验证自定义条件的字段和操作符
$this->validateCustomConditions($custom);
// 构建排序
$order = $this->buildOrder($orderBy);
// 构建查询条件
$where = $this->buildWhere(
$keywordWords,
$andor,
$showField,
$searchField,
$custom,
$keyField,
$keyValue
);
// 执行总数统计
$total = $this->applyDataLimit()
->model->where($where)
->count();
if ($total <= 0) {
return ['list' => [], 'total' => 0];
}
// 排序处理
if ($keyValue !== null && $keyField) {
$this->applyPrimaryKeyOrder($keyField, $keyValue);
} else {
$this->model->order($order);
}
// 执行查询(count()会清空options,需重新应用dataLimit
$dataList = $this->applyDataLimit()
->model->where($where)
->page($page, $pageSize)
->select();
// 构建结果集
$list = $this->buildResultList($dataList, $showField, $keyField);
// 树形结构处理
if ($isTree && !$keyValue) {
$list = $this->buildTreeList($list, $showField, $isHtml);
}
return ['list' => $list, 'total' => $total];
}
/**
* 标准化字段为数组(支持逗号分隔字符串)
*/
protected function normalizeField($field): array
{
if (is_array($field)) {
return $field;
}
if (is_string($field) && strpos($field, ',') !== false) {
return array_map('trim', explode(',', $field));
}
return $field !== '' ? [$field] : [];
}
/**
* 获取数组参数
*/
protected function getArrayParam(array $params, string $key): array
{
$value = $params[$key] ?? [];
if (is_array($value)) {
return $value;
}
if (is_string($value) && strpos($value, ',') !== false) {
return array_map('trim', explode(',', $value));
}
if ($value === '' || $value === null) {
return [];
}
return [$value];
}
/**
* 验证字段名是否在允许列表中
*/
protected function validateField(string $field)
{
$field = strtolower($field);
if (!in_array($field, $this->allowedFields, true)) {
throw new Exception('Invalid parameters');
}
}
/**
* 验证自定义搜索条件
*/
protected function validateCustomConditions(array $custom)
{
foreach ($custom as $k => $v) {
$field = strtolower($k);
if (!in_array($field, $this->allowedFields, true)) {
throw new Exception('Invalid parameters');
}
// 如果操作符是数组形式传入,校验操作符合法性
if (is_array($v) && count($v) >= 2) {
$operator = strtolower(trim($v[0]));
if (!in_array($operator, self::$allowedOperators, true)) {
throw new Exception('Invalid parameters');
}
}
}
}
/**
* 构建排序
*/
protected function buildOrder(array $orderBy): array
{
$order = [];
foreach ($orderBy as $v) {
if (!isset($v[0], $v[1])) {
continue;
}
$field = strtolower($v[0]);
$direction = strtoupper($v[1]) === 'ASC' ? 'ASC' : 'DESC';
if (in_array($field, $this->orderFields, true)) {
$order[$field] = $direction;
}
}
return $order;
}
/**
* 构建查询条件
*/
protected function buildWhere(
array $keywordWords,
string $andor,
string $showField,
array $searchField,
array $custom,
string $keyField,
$keyValue
)
{
// 如果有 keyValue,按主键值精确查询
if ($keyValue !== null && $keyField) {
return [$keyField => ['in', is_array($keyValue) ? $keyValue : explode(',', (string)$keyValue)]];
}
return function ($query) use ($keywordWords, $andor, $showField, $searchField, $custom) {
// 关键词搜索
$searchFields = $this->resolveSearchFields($searchField, $showField, $andor);
$words = array_filter(array_unique($keywordWords));
if (!empty($words)) {
if (count($words) === 1) {
$query->where($searchFields, 'like', '%' . reset($words) . '%');
} else {
$query->where(function ($query) use ($words, $searchFields) {
foreach ($words as $word) {
$query->whereOr($searchFields, 'like', '%' . $word . '%');
}
});
}
}
// 自定义条件
foreach ($custom as $k => $v) {
if (is_array($v) && count($v) >= 2) {
$operator = strtolower(trim($v[0]));
$value = $v[1];
$query->where(strtolower($k), $operator, $value);
} else {
$query->where(strtolower($k), '=', $v);
}
}
};
}
/**
* 解析搜索字段
*/
protected function resolveSearchFields(array $searchField, string $showField, string $andor): string
{
// 过滤掉不在允许列表中的字段
$validFields = [];
$inputFields = array_filter(array_map('trim', $searchField));
foreach ($inputFields as $field) {
$lowerField = strtolower($field);
if (in_array($lowerField, $this->allowedFields, true)) {
$validFields[] = $lowerField;
}
}
if (empty($validFields)) {
$lowerShow = strtolower($showField);
if (in_array($lowerShow, $this->allowedFields, true)) {
return $lowerShow;
}
return 'id';
}
$logic = $andor === 'AND' ? '&' : '|';
return implode($logic, $validFields);
}
/**
* 应用主键排序
*/
protected function applyPrimaryKeyOrder(string $keyField, $keyValue)
{
$values = is_array($keyValue) ? $keyValue : explode(',', (string)$keyValue);
$values = array_unique(array_filter(array_map(function ($v) {
return trim((string)$v);
}, $values)));
if (empty($values)) {
return;
}
$quotedValues = implode(',', array_map(function ($v) {
return Db::quote($v);
}, $values));
$this->model->orderRaw("FIELD(`{$keyField}`, {$quotedValues})");
}
/**
* 构建结果列表
*/
protected function buildResultList($dataList, string $showField, string $keyField): array
{
$list = [];
$fields = $this->resolveSelectpageFields();
foreach ($dataList as $item) {
$row = $item instanceof Model ? $item->toArray() : (array)$item;
// 移除敏感字段
unset($row['password'], $row['salt']);
if ($this->selectpageFields === '*') {
$result = [
$keyField => $row[$keyField] ?? '',
$showField => $row[$showField] ?? '',
];
} else {
$result = array_intersect_key($row, array_flip($fields));
}
// 添加父级ID
$result['pid'] = $row['pid'] ?? ($row['parent_id'] ?? 0);
// HTML 转义
$result = array_map(function ($value) {
return $value === null ? '' : htmlentities((string)$value, ENT_QUOTES, 'UTF-8');
}, $result);
$list[] = $result;
}
return $list;
}
/**
* 构建树形列表
*/
protected function buildTreeList(array $list, string $showField, bool $isHtml): array
{
$tree = Tree::instance();
$tree->init($list, 'pid');
$result = $tree->getTreeList($tree->getTreeArray(0), $showField);
if (!$isHtml) {
foreach ($result as &$item) {
$item = str_replace('&nbsp;', ' ', $item);
}
unset($item);
}
return $result;
}
/**
* 解析 SelectPage 显示字段
*/
protected function resolveSelectpageFields(): array
{
if (is_array($this->selectpageFields)) {
return $this->selectpageFields;
}
if ($this->selectpageFields && $this->selectpageFields !== '*') {
return explode(',', $this->selectpageFields);
}
return [];
}
}
+132
View File
@@ -0,0 +1,132 @@
<?php
namespace app\common\library;
use fast\Random;
use think\Hook;
/**
* 短信验证码类
*/
class Sms
{
/**
* 验证码有效时长
* @var int
*/
protected static $expire = 120;
/**
* 最大允许检测的次数
* @var int
*/
protected static $maxCheckNums = 10;
/**
* 获取最后一次手机发送的数据
*
* @param int $mobile 手机号
* @param string $event 事件
* @return Sms
*/
public static function get($mobile, $event = 'default')
{
$sms = \app\common\model\Sms::where(['mobile' => $mobile, 'event' => $event])
->order('id', 'DESC')
->find();
Hook::listen('sms_get', $sms, null, true);
return $sms ?: null;
}
/**
* 发送验证码
*
* @param int $mobile 手机号
* @param int $code 验证码,为空时将自动生成4位数字
* @param string $event 事件
* @return boolean
*/
public static function send($mobile, $code = null, $event = 'default')
{
$code = is_null($code) ? Random::numeric(config('captcha.length')) : $code;
$time = time();
$ip = request()->ip();
$sms = \app\common\model\Sms::create(['event' => $event, 'mobile' => $mobile, 'code' => $code, 'ip' => $ip, 'createtime' => $time]);
$result = Hook::listen('sms_send', $sms, null, true);
if (!$result) {
$sms->delete();
return false;
}
return true;
}
/**
* 发送通知
*
* @param mixed $mobile 手机号,多个以,分隔
* @param string $msg 消息内容
* @param string $template 消息模板
* @return boolean
*/
public static function notice($mobile, $msg = '', $template = null)
{
$params = [
'mobile' => $mobile,
'msg' => $msg,
'template' => $template
];
$result = Hook::listen('sms_notice', $params, null, true);
return (bool)$result;
}
/**
* 校验验证码
*
* @param int $mobile 手机号
* @param int $code 验证码
* @param string $event 事件
* @return boolean
*/
public static function check($mobile, $code, $event = 'default')
{
$time = time() - self::$expire;
$sms = \app\common\model\Sms::where(['mobile' => $mobile, 'event' => $event])
->order('id', 'DESC')
->find();
if ($sms) {
if ($sms['createtime'] > $time && $sms['times'] <= self::$maxCheckNums) {
$correct = $code == $sms['code'];
if (!$correct) {
$sms->times = $sms->times + 1;
$sms->save();
return false;
} else {
$result = Hook::listen('sms_check', $sms, null, true);
return $result;
}
} else {
// 过期则清空该手机验证码
self::flush($mobile, $event);
return false;
}
} else {
return false;
}
}
/**
* 清空指定手机号验证码
*
* @param int $mobile 手机号
* @param string $event 事件
* @return boolean
*/
public static function flush($mobile, $event = 'default')
{
\app\common\model\Sms::where(['mobile' => $mobile, 'event' => $event])
->delete();
Hook::listen('sms_flush');
return true;
}
}
+161
View File
@@ -0,0 +1,161 @@
<?php
namespace app\common\library;
use app\common\library\token\Driver;
use think\App;
use think\Config;
use think\Log;
/**
* Token操作类
*/
class Token
{
/**
* @var array Token的实例
*/
public static $instance = [];
/**
* @var object 操作句柄
*/
public static $handler;
/**
* 连接Token驱动
* @access public
* @param array $options 配置数组
* @param bool|string $name Token连接标识 true 强制重新连接
* @return Driver
*/
public static function connect(array $options = [], $name = false)
{
$type = !empty($options['type']) ? $options['type'] : 'File';
if (false === $name) {
$name = md5(serialize($options));
}
if (true === $name || !isset(self::$instance[$name])) {
$class = false === strpos($type, '\\') ?
'\\app\\common\\library\\token\\driver\\' . ucwords($type) :
$type;
// 记录初始化信息
App::$debug && Log::record('[ TOKEN ] INIT ' . $type, 'info');
if (true === $name) {
return new $class($options);
}
self::$instance[$name] = new $class($options);
}
return self::$instance[$name];
}
/**
* 自动初始化Token
* @access public
* @param array $options 配置数组
* @return Driver
*/
public static function init(array $options = [])
{
if (is_null(self::$handler)) {
if (empty($options) && 'complex' == Config::get('token.type')) {
$default = Config::get('token.default');
// 获取默认Token配置,并连接
$options = Config::get('token.' . $default['type']) ?: $default;
} elseif (empty($options)) {
$options = Config::get('token');
}
self::$handler = self::connect($options);
}
return self::$handler;
}
/**
* 判断Token是否可用(check别名)
* @access public
* @param string $token Token标识
* @param int $user_id 会员ID
* @return bool
*/
public static function has($token, $user_id)
{
return self::check($token, $user_id);
}
/**
* 判断Token是否可用
* @param string $token Token标识
* @param int $user_id 会员ID
* @return bool
*/
public static function check($token, $user_id)
{
return self::init()->check($token, $user_id);
}
/**
* 读取Token
* @access public
* @param string $token Token标识
* @param mixed $default 默认值
* @return mixed
*/
public static function get($token, $default = false)
{
return self::init()->get($token) ?: $default;
}
/**
* 写入Token
* @access public
* @param string $token Token标识
* @param mixed $user_id 会员ID
* @param int|null $expire 有效时间 0为永久
* @return boolean
*/
public static function set($token, $user_id, $expire = null)
{
return self::init()->set($token, $user_id, $expire);
}
/**
* 删除Token(delete别名)
* @access public
* @param string $token Token标识
* @return boolean
*/
public static function rm($token)
{
return self::delete($token);
}
/**
* 删除Token
* @param string $token 标签名
* @return bool
*/
public static function delete($token)
{
return self::init()->delete($token);
}
/**
* 清除Token
* @access public
* @param int $user_id 会员ID
* @return boolean
*/
public static function clear($user_id = null)
{
return self::init()->clear($user_id);
}
}
+447
View File
@@ -0,0 +1,447 @@
<?php
namespace app\common\library;
use app\common\exception\UploadException;
use app\common\model\Attachment;
use fast\Random;
use FilesystemIterator;
use think\Config;
use think\File;
use think\Hook;
/**
* 文件上传类
*/
class Upload
{
protected $merging = false;
protected $chunkDir = null;
protected $config = [];
protected $error = '';
/**
* @var File
*/
protected $file = null;
protected $fileInfo = null;
public function __construct($file = null)
{
$this->config = Config::get('upload');
$this->chunkDir = RUNTIME_PATH . 'chunks';
if ($file) {
$this->setFile($file);
}
}
/**
* 设置分片目录
* @param $dir
*/
public function setChunkDir($dir)
{
$this->chunkDir = $dir;
}
/**
* 获取文件
* @return File
*/
public function getFile()
{
return $this->file;
}
/**
* 设置文件
* @param $file
* @throws UploadException
*/
public function setFile($file)
{
if (empty($file)) {
throw new UploadException(__('No file upload or server upload limit exceeded'));
}
$fileInfo = $file->getInfo();
$suffix = strtolower(pathinfo($fileInfo['name'], PATHINFO_EXTENSION));
$suffix = $suffix && preg_match("/^[a-zA-Z0-9]+$/", $suffix) ? $suffix : 'file';
$fileInfo['suffix'] = $suffix;
$fileInfo['imagewidth'] = 0;
$fileInfo['imageheight'] = 0;
$this->file = $file;
$this->fileInfo = $fileInfo;
$this->checkExecutable();
}
/**
* 检测是否为可执行脚本
* @return bool
* @throws UploadException
*/
protected function checkExecutable()
{
//禁止上传以.开头的文件
if (substr($this->fileInfo['name'], 0, 1) === '.') {
throw new UploadException(__('Uploaded file format is limited'));
}
//禁止上传PHP和HTML文件
if (in_array($this->fileInfo['type'], ['text/x-php', 'text/html']) || in_array($this->fileInfo['suffix'], ['php', 'html', 'htm', 'phar', 'phtml']) || preg_match("/^php(.*)/i", $this->fileInfo['suffix'])) {
throw new UploadException(__('Uploaded file format is limited'));
}
return true;
}
/**
* 检测文件类型
* @return bool
* @throws UploadException
*/
protected function checkMimetype()
{
$mimetypeArr = explode(',', strtolower($this->config['mimetype']));
$typeArr = explode('/', $this->fileInfo['type']);
//Mimetype值不正确
if (stripos($this->fileInfo['type'], '/') === false) {
throw new UploadException(__('Uploaded file format is limited'));
}
//验证文件后缀
if (in_array($this->fileInfo['suffix'], $mimetypeArr) || in_array('.' . $this->fileInfo['suffix'], $mimetypeArr)
|| in_array($typeArr[0] . "/*", $mimetypeArr) || (in_array($this->fileInfo['type'], $mimetypeArr) && stripos($this->fileInfo['type'], '/') !== false)) {
return true;
}
throw new UploadException(__('Uploaded file format is limited'));
}
/**
* 检测是否图片
* @param bool $force
* @return bool
* @throws UploadException
*/
protected function checkImage($force = false)
{
//验证是否为图片文件
if (in_array($this->fileInfo['type'], ['image/gif', 'image/jpg', 'image/jpeg', 'image/bmp', 'image/png', 'image/webp']) || in_array($this->fileInfo['suffix'], ['gif', 'jpg', 'jpeg', 'bmp', 'png', 'webp'])) {
$imgInfo = getimagesize($this->fileInfo['tmp_name']);
if (!$imgInfo || !isset($imgInfo[0]) || !isset($imgInfo[1])) {
throw new UploadException(__('Uploaded file is not a valid image'));
}
$this->fileInfo['imagewidth'] = $imgInfo[0] ?? 0;
$this->fileInfo['imageheight'] = $imgInfo[1] ?? 0;
return true;
} else {
return !$force;
}
}
/**
* 检测文件大小
* @throws UploadException
*/
protected function checkSize()
{
preg_match('/([0-9\.]+)(\w+)/', $this->config['maxsize'], $matches);
$size = $matches ? $matches[1] : $this->config['maxsize'];
$type = $matches ? strtolower($matches[2]) : 'b';
$typeDict = ['b' => 0, 'k' => 1, 'kb' => 1, 'm' => 2, 'mb' => 2, 'gb' => 3, 'g' => 3];
$size = (int)($size * pow(1024, $typeDict[$type] ?? 0));
if ($this->fileInfo['size'] > $size) {
throw new UploadException(__(
'File is too big (%sMiB), Max filesize: %sMiB.',
round($this->fileInfo['size'] / pow(1024, 2), 2),
round($size / pow(1024, 2), 2)
));
}
}
/**
* 获取后缀
* @return string
*/
public function getSuffix()
{
return $this->fileInfo['suffix'] ?: 'file';
}
/**
* 获取存储的文件名
* @param string $savekey 保存路径
* @param string $filename 文件名
* @param string $md5 文件MD5
* @param string $category 分类
* @return mixed|null
*/
public function getSavekey($savekey = null, $filename = null, $md5 = null, $category = null)
{
if ($filename) {
$suffix = strtolower(pathinfo($filename, PATHINFO_EXTENSION));
} else {
$suffix = $this->fileInfo['suffix'] ?? '';
}
$suffix = $suffix && preg_match("/^[a-zA-Z0-9]+$/", $suffix) ? $suffix : 'file';
$filename = $filename ? $filename : ($this->fileInfo['name'] ?? 'unknown');
$filename = xss_clean(strip_tags(htmlspecialchars($filename)));
$fileprefix = substr($filename, 0, strripos($filename, '.'));
$md5 = $md5 ? $md5 : (isset($this->fileInfo['tmp_name']) ? md5_file($this->fileInfo['tmp_name']) : '');
$category = $category ? $category : request()->post('category');
$category = $category ? xss_clean($category) : 'all';
$replaceArr = [
'{year}' => date("Y"),
'{mon}' => date("m"),
'{day}' => date("d"),
'{hour}' => date("H"),
'{min}' => date("i"),
'{sec}' => date("s"),
'{random}' => Random::alnum(16),
'{random32}' => Random::alnum(32),
'{category}' => $category ? $category : '',
'{filename}' => substr($filename, 0, 100),
'{fileprefix}' => substr($fileprefix, 0, 100),
'{suffix}' => $suffix,
'{.suffix}' => $suffix ? '.' . $suffix : '',
'{filemd5}' => $md5,
];
$savekey = $savekey ? $savekey : $this->config['savekey'];
$savekey = str_replace(array_keys($replaceArr), array_values($replaceArr), $savekey);
return $savekey;
}
/**
* 清理分片文件
* @param $chunkid
*/
public function clean($chunkid)
{
if (!preg_match('/^[a-z0-9\-]{36}$/', $chunkid)) {
throw new UploadException(__('Invalid parameters'));
}
$iterator = new \GlobIterator($this->chunkDir . DS . $chunkid . '-*', FilesystemIterator::KEY_AS_FILENAME);
$array = iterator_to_array($iterator);
foreach ($array as $index => &$item) {
$sourceFile = $item->getRealPath() ?: $item->getPathname();
$item = null;
@unlink($sourceFile);
}
}
/**
* 合并分片文件
* @param string $chunkid
* @param int $chunkcount
* @param string $filename
* @return attachment|\think\Model
* @throws UploadException
*/
public function merge($chunkid, $chunkcount, $filename)
{
if (!preg_match('/^[a-z0-9\-]{36}$/', $chunkid)) {
throw new UploadException(__('Invalid parameters'));
}
$filePath = $this->chunkDir . DS . $chunkid;
$completed = true;
//检查所有分片是否都存在
for ($i = 0; $i < $chunkcount; $i++) {
if (!file_exists("{$filePath}-{$i}.part")) {
$completed = false;
break;
}
}
if (!$completed) {
$this->clean($chunkid);
throw new UploadException(__('Chunk file info error'));
}
//如果所有文件分片都上传完毕,开始合并
$uploadPath = $filePath;
if (!$destFile = @fopen($uploadPath, "wb")) {
$this->clean($chunkid);
throw new UploadException(__('Chunk file merge error'));
}
if (flock($destFile, LOCK_EX)) { // 进行排他型锁定
for ($i = 0; $i < $chunkcount; $i++) {
$partFile = "{$filePath}-{$i}.part";
if (!$handle = @fopen($partFile, "rb")) {
break;
}
while ($buff = fread($handle, filesize($partFile))) {
fwrite($destFile, $buff);
}
@fclose($handle);
@unlink($partFile); //删除分片
}
flock($destFile, LOCK_UN);
}
@fclose($destFile);
$attachment = null;
try {
$file = new File($uploadPath);
$info = [
'name' => $filename,
'type' => $file->getMime(),
'tmp_name' => $uploadPath,
'error' => 0,
'size' => $file->getSize()
];
$file->setSaveName($filename)->setUploadInfo($info);
$file->isTest(true);
//重新设置文件
$this->setFile($file);
unset($file);
$this->merging = true;
//允许大文件
$this->config['maxsize'] = "1024G";
$attachment = $this->upload();
} catch (\Exception $e) {
@unlink($uploadPath);
throw new UploadException($e->getMessage());
}
return $attachment;
}
/**
* 分片上传
* @throws UploadException
*/
public function chunk($chunkid, $chunkindex, $chunkcount, $chunkfilesize = null, $chunkfilename = null, $direct = false)
{
if ($this->fileInfo['type'] != 'application/octet-stream') {
throw new UploadException(__('Uploaded file format is limited'));
}
if (!preg_match('/^[a-z0-9\-]{36}$/', $chunkid)) {
throw new UploadException(__('Invalid parameters'));
}
$destDir = RUNTIME_PATH . 'chunks';
$fileName = $chunkid . "-" . $chunkindex . '.part';
$destFile = $destDir . DS . $fileName;
if (!is_dir($destDir)) {
@mkdir($destDir, 0755, true);
}
if (!move_uploaded_file($this->file->getPathname(), $destFile)) {
throw new UploadException(__('Chunk file write error'));
}
$file = new File($destFile);
$info = [
'name' => $fileName,
'type' => $file->getMime(),
'tmp_name' => $destFile,
'error' => 0,
'size' => $file->getSize()
];
$file->setSaveName($fileName)->setUploadInfo($info);
$this->setFile($file);
return $file;
}
/**
* 普通上传
* @return \app\common\model\attachment|\think\Model
* @throws UploadException
*/
public function upload($savekey = null)
{
if (empty($this->file)) {
throw new UploadException(__('No file upload or server upload limit exceeded'));
}
$this->checkSize();
$this->checkExecutable();
$this->checkMimetype();
$this->checkImage();
$savekey = $savekey ? $savekey : $this->getSavekey();
$savekey = '/' . ltrim($savekey, '/');
$uploadDir = substr($savekey, 0, strripos($savekey, '/') + 1);
$fileName = substr($savekey, strripos($savekey, '/') + 1);
$destDir = ROOT_PATH . 'public' . str_replace('/', DS, $uploadDir);
$sha1 = $this->file->hash();
//如果是合并文件
if ($this->merging) {
if (!$this->file->check()) {
throw new UploadException($this->file->getError());
}
$destFile = $destDir . $fileName;
$sourceFile = $this->file->getRealPath() ?: $this->file->getPathname();
$info = $this->file->getInfo();
$this->file = null;
if (!is_dir($destDir)) {
@mkdir($destDir, 0755, true);
}
rename($sourceFile, $destFile);
$file = new File($destFile);
$file->setSaveName($fileName)->setUploadInfo($info);
} else {
$file = $this->file->move($destDir, $fileName);
if (!$file) {
// 上传失败获取错误信息
throw new UploadException($this->file->getError());
}
}
$this->file = $file;
$category = request()->post('category');
$category = array_key_exists($category, config('site.attachmentcategory') ?? []) ? $category : '';
$auth = Auth::instance();
$params = array(
'admin_id' => (int)session('admin.id'),
'user_id' => (int)$auth->id,
'filename' => mb_substr(htmlspecialchars(strip_tags($this->fileInfo['name'])), 0, 100),
'category' => $category,
'filesize' => $this->fileInfo['size'],
'imagewidth' => $this->fileInfo['imagewidth'],
'imageheight' => $this->fileInfo['imageheight'],
'imagetype' => $this->fileInfo['suffix'],
'imageframes' => 0,
'mimetype' => $this->fileInfo['type'],
'url' => $uploadDir . $file->getSaveName(),
'uploadtime' => time(),
'storage' => 'local',
'sha1' => $sha1,
'extparam' => '',
);
$attachment = new Attachment();
$attachment->data(array_filter($params));
$attachment->save();
\think\Hook::listen("upload_after", $attachment);
return $attachment;
}
/**
* 设置错误信息
* @param $msg
*/
public function setError($msg)
{
$this->error = $msg;
}
/**
* 获取错误信息
* @return string
*/
public function getError()
{
return $this->error;
}
}
+233
View File
@@ -0,0 +1,233 @@
<?php
declare(strict_types=1);
namespace app\common\library\scrm;
use app\common\service\SplitTicketSyncLogger;
use Exception;
/**
* 云控蜘蛛抽象基类(Node Headless 拦截 + 翻页 + 清洗)
*/
abstract class AbstractScrmSpider implements ScrmSpiderInterface
{
public const MODE_FETCH = 'fetch';
public const MODE_UI = 'ui_click';
protected string $nodeHost;
public function __construct(string $nodeHost = 'http://127.0.0.1:3001')
{
$this->nodeHost = rtrim($nodeHost, '/');
}
/** @return array<string, mixed> */
abstract protected function getSpiderConfig(): array;
/**
* @param array<string, mixed>|null $countData
*/
abstract protected function extractListTotalPages($listFirstPageData, $countData = null);
/**
* @return array<string, mixed>
*/
abstract protected function buildListPageParams(int $page): array;
/** @return array<string, mixed> */
abstract protected function getUiPaginationConfig(): array;
/**
* @param mixed $detailData
* @param array<int, mixed> $allListPagesData
*/
abstract protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData;
public function run(): UnifiedScrmData
{
$config = $this->getSpiderConfig();
SplitTicketSyncLogger::log('spider', 'run start', [
'nodeHost' => $this->nodeHost,
'pageUrl' => $config['pageUrl'] ?? '',
'listApi' => $config['listApi'] ?? '',
'paginationMode' => $config['paginationMode'] ?? self::MODE_FETCH,
]);
$listApi = (string) ($config['listApi'] ?? '');
$detailApi = $config['detailApi'] ?? null;
$countApi = $config['countApi'] ?? null;
$apiUrlsToIntercept = [$listApi];
if ($detailApi) {
$apiUrlsToIntercept[] = $detailApi;
}
if ($countApi) {
$apiUrlsToIntercept[] = $countApi;
}
$initResult = $this->requestNode('/api/auth-and-intercept', [
'pageUrl' => $config['pageUrl'],
'apiUrls' => $apiUrlsToIntercept,
'authActions' => $config['authActions'] ?? [],
]);
if (empty($initResult['success'])) {
SplitTicketSyncLogger::log('spider', 'auth-and-intercept failed', [
'error' => $initResult['error'] ?? '未知',
]);
throw new Exception('初始化失败: ' . ($initResult['error'] ?? '未知'));
}
$interceptedApis = $initResult['interceptedApis'];
SplitTicketSyncLogger::log('spider', 'auth-and-intercept ok', [
'intercepted' => array_keys($interceptedApis),
]);
$cookies = $initResult['cookies'];
if (!isset($interceptedApis[$listApi])) {
throw new Exception("致命错误:未能拦截到必须的列表接口 [{$listApi}]");
}
$detailData = $detailApi && isset($interceptedApis[$detailApi])
? $interceptedApis[$detailApi]['data'] : null;
$countData = $countApi && isset($interceptedApis[$countApi])
? $interceptedApis[$countApi]['data'] : null;
$listApiNode = $interceptedApis[$listApi];
$allListPagesData = [$listApiNode['data']];
$totalPages = $this->extractListTotalPages($listApiNode['data'], $countData);
$mode = $config['paginationMode'] ?? self::MODE_FETCH;
SplitTicketSyncLogger::log('spider', 'pagination plan', [
'totalPages' => $totalPages,
'mode' => $mode,
]);
if ($totalPages > 1 || $totalPages === null) {
if ($mode === self::MODE_FETCH && $totalPages !== null) {
$paramList = [];
for ($page = 2; $page <= $totalPages; $page++) {
$paramList[] = $this->buildListPageParams($page);
}
$fetchResult = $this->requestNode('/api/batch-fetch', [
'tasks' => [[
'apiPath' => $listApi,
'fullUrl' => $listApiNode['url'],
'headers' => $listApiNode['headers'] ?? '',
'paramList' => $paramList,
'method' => $config['listMethod'] ?? 'GET',
]],
'cookies' => $cookies,
], 120);
if (!empty($fetchResult['success'])) {
foreach ($fetchResult['results'][$listApi] as $pResult) {
if (!empty($pResult['success'])) {
$allListPagesData[] = $pResult['data'];
}
}
}
} elseif ($mode === self::MODE_UI) {
$uiConfig = $this->getUiPaginationConfig();
$firstPageData = $listApiNode['data'];
$clicksToPerform = ($totalPages === null) ? 9999 : ($totalPages - 1);
$uiResult = $this->requestNode('/api/ui-pagination', [
'apiUrl' => $listApi,
'pageUrl' => $config['pageUrl'],
'nextBtnSelector' => $uiConfig['nextBtnSelector'] ?? '',
'waitMs' => $uiConfig['waitMs'] ?? 2000,
'clicksToPerform' => $clicksToPerform,
'cookies' => $cookies,
'firstPageData' => $firstPageData,
'authActions' => $config['authActions'] ?? [],
], 1200);
if (!empty($uiResult['success']) && !empty($uiResult['data'])) {
foreach ($uiResult['data'] as $pageData) {
$allListPagesData[] = $pageData;
}
}
}
}
$result = $this->parseToUnifiedData($detailData, $allListPagesData);
SplitTicketSyncLogger::log('spider', 'run done', [
'todayNewCount' => $result->todayNewCount,
'totalOnline' => $result->totalOnline,
'totalOffline' => $result->totalOffline,
'total' => $result->total,
'numberCount' => count($result->numbers),
]);
return $result;
}
/**
* @param array<string, mixed> $payload
* @return array<string, mixed>
*/
protected function requestNode(string $endpoint, array $payload, int $timeout = 60): array
{
$url = $this->nodeHost . $endpoint;
$started = microtime(true);
SplitTicketSyncLogger::log('node_request', 'POST ' . $endpoint, [
'url' => $url,
'timeout' => $timeout,
'payload' => $payload,
]);
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
curl_setopt($ch, CURLOPT_HTTPHEADER, ['Content-Type: application/json']);
curl_setopt($ch, CURLOPT_TIMEOUT, $timeout);
$response = curl_exec($ch);
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
$elapsedMs = (int) round((microtime(true) - $started) * 1000);
if (curl_errno($ch)) {
$err = curl_error($ch);
curl_close($ch);
SplitTicketSyncLogger::log('node_response', 'curl error on ' . $endpoint, [
'httpCode' => $httpCode,
'elapsedMs' => $elapsedMs,
'error' => $err,
]);
throw new Exception($err);
}
curl_close($ch);
$decoded = json_decode((string) $response, true);
$summary = is_array($decoded) ? self::summarizeNodeResponse($decoded) : ['raw' => mb_substr((string) $response, 0, 300, 'UTF-8')];
SplitTicketSyncLogger::log('node_response', 'POST ' . $endpoint, array_merge([
'httpCode' => $httpCode,
'elapsedMs' => $elapsedMs,
'responseSize' => strlen((string) $response),
], $summary));
return is_array($decoded) ? $decoded : [];
}
/**
* @param array<string, mixed> $decoded
* @return array<string, mixed>
*/
private static function summarizeNodeResponse(array $decoded): array
{
$summary = [
'success' => $decoded['success'] ?? null,
'error' => $decoded['error'] ?? null,
];
if (isset($decoded['interceptedApis']) && is_array($decoded['interceptedApis'])) {
$summary['interceptedApis'] = array_keys($decoded['interceptedApis']);
}
if (isset($decoded['results']) && is_array($decoded['results'])) {
$summary['resultApis'] = array_keys($decoded['results']);
}
if (isset($decoded['data']) && is_array($decoded['data'])) {
$summary['dataPages'] = count($decoded['data']);
}
return $summary;
}
}
+18
View File
@@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace app\common\library\scrm;
/**
* 云控蜘蛛统一接口
*/
interface ScrmSpiderInterface
{
/**
* 执行抓取并返回统一数据
*
* @throws \Exception
*/
public function run(): UnifiedScrmData;
}
+50
View File
@@ -0,0 +1,50 @@
<?php
declare(strict_types=1);
namespace app\common\library\scrm;
/**
* 云控蜘蛛统一返回数据结构
*/
class UnifiedScrmData
{
/** @var int 今日新增(完成数量) */
public int $todayNewCount = 0;
/** @var int 在线号码数 */
public int $totalOnline = 0;
/** @var int 离线号码数 */
public int $totalOffline = 0;
/** @var array<int, array{number:string,status:string,newFollowersToday:int}> */
public array $numbers = [];
/** @var int 号码总数 */
public int $total = 0;
/**
* @param string $number 号码
* @param bool $isOnline 是否在线
* @param int $newFollowersToday 今日进线
*/
public function addNumber(string $number, bool $isOnline, int $newFollowersToday = 0): void
{
$number = trim($number);
if ($number === '') {
return;
}
$this->numbers[] = [
'number' => $number,
'status' => $isOnline ? 'online' : 'offline',
'newFollowersToday' => max(0, $newFollowersToday),
];
if ($isOnline) {
$this->totalOnline++;
} else {
$this->totalOffline++;
}
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
declare(strict_types=1);
namespace app\common\library\scrm\spider;
use app\common\library\scrm\AbstractScrmSpider;
use app\common\library\scrm\UnifiedScrmData;
/**
* A2C 云控蜘蛛
*/
class A2cSpider extends AbstractScrmSpider
{
private const API_LIST = '/api/talk/counter/share/record/list';
private const API_DETAILS = '/api/talk/counter/share/detail';
private const DEFAULT_PER_PAGE_COUNT = 20;
private string $pageUrl;
private string $account;
private string $password;
private UnifiedScrmData $unifiedData;
public function __construct(
string $pageUrl,
string $account = '',
string $password = '',
string $nodeHost = 'http://127.0.0.1:3001'
) {
parent::__construct($nodeHost);
$this->pageUrl = $pageUrl;
$this->account = $account;
$this->password = $password;
$this->unifiedData = new UnifiedScrmData();
}
protected function getSpiderConfig(): array
{
return [
'pageUrl' => $this->pageUrl,
'listApi' => self::API_LIST,
'detailApi' => self::API_DETAILS,
'listMethod' => 'POST',
'paginationMode' => self::MODE_UI,
'authActions' => [
['type' => 'wait', 'ms' => 2000],
],
];
}
protected function extractListTotalPages($listFirstPageData, $countData = null)
{
$total = (int) ($listFirstPageData['data']['total'] ?? 0);
$this->unifiedData->total = $total;
if ($total <= self::DEFAULT_PER_PAGE_COUNT) {
return 1;
}
return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT);
}
protected function buildListPageParams(int $page): array
{
return ['page' => $page, 'pageSize' => self::DEFAULT_PER_PAGE_COUNT];
}
protected function getUiPaginationConfig(): array
{
return [
'nextBtnSelector' => '.btn-next',
'waitMs' => 2000,
];
}
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
{
$unifiedData = $this->unifiedData;
if ($detailData) {
$unifiedData->todayNewCount = (int) ($detailData['data']['newFollowersToday'] ?? 0);
}
foreach ($allListPagesData as $pageRaw) {
$records = $pageRaw['data']['rows'] ?? [];
foreach ($records as $item) {
if (empty($item['account'])) {
continue;
}
$number = (string) $item['account'];
$isOnline = isset($item['numberStatus']) && (int) $item['numberStatus'] === 1;
$unifiedData->addNumber($number, $isOnline, (int) ($item['newFollowersToday'] ?? 0));
}
}
return $unifiedData;
}
}
+101
View File
@@ -0,0 +1,101 @@
<?php
declare(strict_types=1);
namespace app\common\library\scrm\spider;
use app\common\library\scrm\AbstractScrmSpider;
use app\common\library\scrm\UnifiedScrmData;
/**
* 海王云控蜘蛛
*/
class HaiwangSpider extends AbstractScrmSpider
{
private const API_LIST = '/webApi/accountshow/list';
private const DEFAULT_PER_PAGE_COUNT = 10;
private string $pageUrl;
private string $account;
private string $password;
private UnifiedScrmData $unifiedData;
public function __construct(
string $pageUrl,
string $account = '',
string $password = '',
string $nodeHost = 'http://127.0.0.1:3001'
) {
parent::__construct($nodeHost);
$this->pageUrl = $pageUrl;
$this->account = $account;
$this->password = $password;
$this->unifiedData = new UnifiedScrmData();
}
protected function getSpiderConfig(): array
{
return [
'pageUrl' => $this->pageUrl,
'listApi' => self::API_LIST,
'listMethod' => 'POST',
'paginationMode' => self::MODE_UI,
'authActions' => [
['type' => 'type', 'selector' => 'input[type="password"]', 'value' => $this->password],
['type' => 'press', 'key' => 'Enter'],
['type' => 'wait', 'ms' => 2000],
],
];
}
protected function extractListTotalPages($listFirstPageData, $countData = null)
{
$total = (int) ($listFirstPageData['data']['total'] ?? 0);
$this->unifiedData->total = $total;
if ($total <= self::DEFAULT_PER_PAGE_COUNT) {
return 1;
}
return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT);
}
protected function buildListPageParams(int $page): array
{
return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT];
}
protected function getUiPaginationConfig(): array
{
return [
'nextBtnSelector' => '.btn-next',
'waitMs' => 2000,
];
}
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
{
$unifiedData = $this->unifiedData;
foreach ($allListPagesData as $pageRaw) {
$records = $pageRaw['data']['items'] ?? [];
foreach ($records as $item) {
if (empty($item['acclist_account'])) {
continue;
}
$number = (string) $item['acclist_account'];
$isOnline = isset($item['acclist_status']) && (int) $item['acclist_status'] === 2;
$unifiedData->addNumber(
$number,
$isOnline,
(int) ($item['account_statistics_today_effective'] ?? 0)
);
}
}
if (!empty($allListPagesData[0]['data']['shareStatistics']['sharecode_statistics_today_contact_effective'])) {
$unifiedData->todayNewCount = (int) $allListPagesData[0]['data']['shareStatistics']['sharecode_statistics_today_contact_effective'];
}
return $unifiedData;
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
declare(strict_types=1);
namespace app\common\library\scrm\spider;
use app\common\library\scrm\AbstractScrmSpider;
use app\common\library\scrm\UnifiedScrmData;
/**
* 火箭云控蜘蛛
*/
class HuojianSpider extends AbstractScrmSpider
{
private const API_LIST = '/prod-api1/biz/counter/link/share/';
private string $pageUrl;
private string $account;
private string $password;
private UnifiedScrmData $unifiedData;
public function __construct(
string $pageUrl,
string $account = '',
string $password = '',
string $nodeHost = 'http://127.0.0.1:3001'
) {
parent::__construct($nodeHost);
$this->pageUrl = $pageUrl;
$this->account = $account;
$this->password = $password;
$this->unifiedData = new UnifiedScrmData();
}
protected function getSpiderConfig(): array
{
return [
'pageUrl' => $this->pageUrl,
'listApi' => self::API_LIST,
'listMethod' => 'POST',
'paginationMode' => self::MODE_UI,
'authActions' => [
['type' => 'vue_fill', 'selector' => '.el-message-box__input input', 'value' => $this->password],
['type' => 'wait', 'ms' => 500],
['type' => 'vue_click', 'selector' => '.el-message-box__btns .el-button--primary'],
['type' => 'wait', 'ms' => 2000],
],
];
}
protected function extractListTotalPages($listFirstPageData, $countData = null)
{
return 1;
}
protected function buildListPageParams(int $page): array
{
return [];
}
protected function getUiPaginationConfig(): array
{
return [];
}
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
{
$unifiedData = $this->unifiedData;
$unifiedData->todayNewCount = (int) ($allListPagesData[0]['data']['counterWorker']['newTodayFriend'] ?? 0);
$count = 0;
foreach ($allListPagesData as $pageRaw) {
$records = $pageRaw['data']['counterCsAccountVo'] ?? [];
foreach ($records as $item) {
if (empty($item['accountLogin'])) {
continue;
}
$number = (string) $item['accountLogin'];
$isOnline = isset($item['accountStatus']) && (int) $item['accountStatus'] === 1;
$count++;
$unifiedData->addNumber($number, $isOnline, (int) ($item['newTodayFriend'] ?? 0));
}
}
$unifiedData->total = $count;
return $unifiedData;
}
}
+102
View File
@@ -0,0 +1,102 @@
<?php
declare(strict_types=1);
namespace app\common\library\scrm\spider;
use app\common\library\scrm\AbstractScrmSpider;
use app\common\library\scrm\UnifiedScrmData;
/**
* SS云控(Customer) 蜘蛛
*/
class SsCustomerSpider extends AbstractScrmSpider
{
private const API_LIST = '/sys/share/report/get-customer-analysis-dimension-list';
private const API_DETAILS = '/sys/share/report/get-customer-analysis-statistics';
private const DEFAULT_PER_PAGE_COUNT = 20;
private string $pageUrl;
private string $account;
private string $password;
private UnifiedScrmData $unifiedData;
public function __construct(
string $pageUrl,
string $account = '',
string $password = '',
string $nodeHost = 'http://127.0.0.1:3001'
) {
parent::__construct($nodeHost);
$this->pageUrl = $pageUrl;
$this->account = $account;
$this->password = $password;
$this->unifiedData = new UnifiedScrmData();
}
protected function getSpiderConfig(): array
{
return [
'pageUrl' => $this->pageUrl,
'listApi' => self::API_LIST,
'detailApi' => self::API_DETAILS,
'listMethod' => 'POST',
'paginationMode' => self::MODE_UI,
'authActions' => [
['type' => 'wait', 'ms' => 2000],
['type' => 'type', 'selector' => 'input[type="password"]', 'value' => $this->password],
['type' => 'press', 'key' => 'Enter'],
['type' => 'wait', 'ms' => 3000],
[
'type' => 'vue_click',
'selector' => 'button[class*="reports-customers__dimension"]',
'text' => 'social media accounts',
],
['type' => 'wait', 'ms' => 3000],
],
];
}
protected function extractListTotalPages($listFirstPageData, $countData = null)
{
return null;
}
protected function buildListPageParams(int $page): array
{
return ['page' => $page, 'pageSize' => self::DEFAULT_PER_PAGE_COUNT];
}
protected function getUiPaginationConfig(): array
{
return [
'nextBtnSelector' => '.arco-pagination-item-next',
'waitMs' => 2000,
];
}
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
{
$unifiedData = $this->unifiedData;
if ($detailData) {
$unifiedData->todayNewCount = (int) ($detailData['data']['distinct_contacts_total'] ?? 0);
}
foreach ($allListPagesData as $pageRaw) {
$records = $pageRaw['data']['list'] ?? [];
foreach ($records as $item) {
if (empty($item['channel_tag'])) {
continue;
}
$number = (string) $item['channel_tag'];
$unifiedData->addNumber($number, true, (int) ($item['distinct_contacts_total'] ?? 0));
}
}
$unifiedData->total = count($unifiedData->numbers);
return $unifiedData;
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
declare(strict_types=1);
namespace app\common\library\scrm\spider;
use app\common\library\scrm\AbstractScrmSpider;
use app\common\library\scrm\UnifiedScrmData;
/**
* 星河云控蜘蛛
*/
class XingheSpider extends AbstractScrmSpider
{
private const API_LIST = '/share/share/api_yinliu_count.html';
private const DEFAULT_PER_PAGE_COUNT = 10;
private string $pageUrl;
private string $account;
private string $password;
private UnifiedScrmData $unifiedData;
public function __construct(
string $pageUrl,
string $account = '',
string $password = '',
string $nodeHost = 'http://127.0.0.1:3001'
) {
parent::__construct($nodeHost);
$this->pageUrl = $pageUrl;
$this->account = $account;
$this->password = $password;
$this->unifiedData = new UnifiedScrmData();
}
protected function getSpiderConfig(): array
{
return [
'pageUrl' => $this->pageUrl,
'listApi' => self::API_LIST,
'listMethod' => 'GET',
'paginationMode' => self::MODE_FETCH,
'authActions' => [
['type' => 'wait', 'ms' => 2000],
],
];
}
protected function extractListTotalPages($listFirstPageData, $countData = null)
{
$total = (int) ($listFirstPageData['count'] ?? 0);
$this->unifiedData->total = $total;
$this->unifiedData->todayNewCount = (int) ($listFirstPageData['totalRow']['day_sum'] ?? 0);
if ($total <= self::DEFAULT_PER_PAGE_COUNT) {
return 1;
}
return (int) ceil($total / self::DEFAULT_PER_PAGE_COUNT);
}
protected function buildListPageParams(int $page): array
{
return ['page' => $page, 'limit' => self::DEFAULT_PER_PAGE_COUNT];
}
protected function getUiPaginationConfig(): array
{
return [
'nextBtnSelector' => '.layui-laypage-next',
'waitMs' => 2000,
];
}
protected function parseToUnifiedData($detailData, array $allListPagesData): UnifiedScrmData
{
$unifiedData = $this->unifiedData;
foreach ($allListPagesData as $pageRaw) {
$records = $pageRaw['data'] ?? [];
foreach ($records as $item) {
if (empty($item['user'])) {
continue;
}
$number = (string) $item['user'];
$isOnline = isset($item['online']) && (int) $item['online'] === 1;
$unifiedData->addNumber($number, $isOnline, (int) ($item['day_sum'] ?? 0));
}
}
return $unifiedData;
}
}
+92
View File
@@ -0,0 +1,92 @@
<?php
// +----------------------------------------------------------------------
// | ThinkPHP [ WE CAN DO IT JUST THINK ]
// +----------------------------------------------------------------------
// | Copyright (c) 2006~2018 http://thinkphp.cn All rights reserved.
// +----------------------------------------------------------------------
// | Licensed ( http://www.apache.org/licenses/LICENSE-2.0 )
// +----------------------------------------------------------------------
// | Author: liu21st <liu21st@gmail.com>
// +----------------------------------------------------------------------
namespace app\common\library\token;
/**
* Token基础类
*/
abstract class Driver
{
protected $handler = null;
protected $options = [];
/**
* 存储Token
* @param string $token Token
* @param int $user_id 会员ID
* @param int $expire 过期时长,0表示无限,单位秒
* @return bool
*/
abstract function set($token, $user_id, $expire = 0);
/**
* 获取Token内的信息
* @param string $token
* @return array
*/
abstract function get($token);
/**
* 判断Token是否可用
* @param string $token Token
* @param int $user_id 会员ID
* @return boolean
*/
abstract function check($token, $user_id);
/**
* 删除Token
* @param string $token
* @return boolean
*/
abstract function delete($token);
/**
* 删除指定用户的所有Token
* @param int $user_id
* @return boolean
*/
abstract function clear($user_id);
/**
* 返回句柄对象,可执行其它高级方法
*
* @access public
* @return object
*/
public function handler()
{
return $this->handler;
}
/**
* 获取加密后的Token
* @param string $token Token标识
* @return string
*/
protected function getEncryptedToken($token)
{
$config = \think\Config::get('token');
$token = $token ?? ''; // 为兼容 php8
return hash_hmac($config['hashalgo'], $token, $config['key']);
}
/**
* 获取过期剩余时长
* @param $expiretime
* @return float|int|mixed
*/
protected function getExpiredIn($expiretime)
{
return $expiretime ? max(0, $expiretime - time()) : 365 * 86400;
}
}
+118
View File
@@ -0,0 +1,118 @@
<?php
namespace app\common\library\token\driver;
use app\common\library\token\Driver;
/**
* Token操作类
*/
class Mysql extends Driver
{
/**
* 默认配置
* @var array
*/
protected $options = [
'table' => 'user_token',
'expire' => 2592000,
'connection' => [],
];
/**
* 构造函数
* @param array $options 参数
* @access public
*/
public function __construct($options = [])
{
if (!empty($options)) {
$this->options = array_merge($this->options, $options);
}
if ($this->options['connection']) {
$this->handler = \think\Db::connect($this->options['connection'])->name($this->options['table']);
} else {
$this->handler = \think\Db::name($this->options['table']);
}
$time = time();
$tokentime = cache('tokentime');
if (!$tokentime || $tokentime < $time - 86400) {
cache('tokentime', $time);
$this->handler->where('expiretime', '<', $time)->where('expiretime', '>', 0)->delete();
}
}
/**
* 存储Token
* @param string $token Token
* @param int $user_id 会员ID
* @param int $expire 过期时长,0表示无限,单位秒
* @return bool
*/
public function set($token, $user_id, $expire = null)
{
$expiretime = !is_null($expire) && $expire !== 0 ? time() + $expire : 0;
$token = $this->getEncryptedToken($token);
$this->handler->insert(['token' => $token, 'user_id' => $user_id, 'createtime' => time(), 'expiretime' => $expiretime]);
return true;
}
/**
* 获取Token内的信息
* @param string $token
* @return array
*/
public function get($token)
{
$data = $this->handler->where('token', $this->getEncryptedToken($token))->find();
if ($data) {
if (!$data['expiretime'] || $data['expiretime'] > time()) {
//返回未加密的token给客户端使用
$data['token'] = $token;
//返回剩余有效时间
$data['expires_in'] = $this->getExpiredIn($data['expiretime']);
return $data;
} else {
self::delete($token);
}
}
return [];
}
/**
* 判断Token是否可用
* @param string $token Token
* @param int $user_id 会员ID
* @return boolean
*/
public function check($token, $user_id)
{
$data = $this->get($token);
return $data && $data['user_id'] == $user_id ? true : false;
}
/**
* 删除Token
* @param string $token
* @return boolean
*/
public function delete($token)
{
$this->handler->where('token', $this->getEncryptedToken($token))->delete();
return true;
}
/**
* 删除指定用户的所有Token
* @param int $user_id
* @return boolean
*/
public function clear($user_id)
{
$this->handler->where('user_id', $user_id)->delete();
return true;
}
}
+168
View File
@@ -0,0 +1,168 @@
<?php
namespace app\common\library\token\driver;
use app\common\library\token\Driver;
/**
* Token操作类
*/
class Redis extends Driver
{
protected $options = [
'host' => '127.0.0.1',
'port' => 6379,
'password' => '',
'select' => 0,
'timeout' => 0,
'expire' => 0,
'persistent' => false,
'userprefix' => 'up:',
'tokenprefix' => 'tp:',
];
/**
* 构造函数
* @param array $options 缓存参数
* @throws \BadFunctionCallException
* @access public
*/
public function __construct($options = [])
{
if (!extension_loaded('redis')) {
throw new \BadFunctionCallException('not support: redis');
}
if (!empty($options)) {
$this->options = array_merge($this->options, $options);
}
$this->handler = new \Redis;
if ($this->options['persistent']) {
$this->handler->pconnect($this->options['host'], $this->options['port'], $this->options['timeout'], 'persistent_id_' . $this->options['select']);
} else {
$this->handler->connect($this->options['host'], $this->options['port'], $this->options['timeout']);
}
if ('' != $this->options['password']) {
$this->handler->auth($this->options['password']);
}
if (0 != $this->options['select']) {
$this->handler->select($this->options['select']);
}
}
/**
* 获取加密后的Token
* @param string $token Token标识
* @return string
*/
protected function getEncryptedToken($token)
{
$config = \think\Config::get('token');
$token = $token ?? ''; // 为兼容 php8
return $this->options['tokenprefix'] . hash_hmac($config['hashalgo'], $token, $config['key']);
}
/**
* 获取会员的key
* @param $user_id
* @return string
*/
protected function getUserKey($user_id)
{
return $this->options['userprefix'] . $user_id;
}
/**
* 存储Token
* @param string $token Token
* @param int $user_id 会员ID
* @param int $expire 过期时长,0表示无限,单位秒
* @return bool
*/
public function set($token, $user_id, $expire = 0)
{
if (is_null($expire)) {
$expire = $this->options['expire'];
}
if ($expire instanceof \DateTime) {
$expire = $expire->getTimestamp() - time();
}
$key = $this->getEncryptedToken($token);
if ($expire) {
$result = $this->handler->setex($key, $expire, $user_id);
} else {
$result = $this->handler->set($key, $user_id);
}
//写入会员关联的token
$this->handler->sAdd($this->getUserKey($user_id), $key);
return $result;
}
/**
* 获取Token内的信息
* @param string $token
* @return array
*/
public function get($token)
{
$key = $this->getEncryptedToken($token);
$value = $this->handler->get($key);
if (is_null($value) || false === $value) {
return [];
}
//获取有效期
$expire = $this->handler->ttl($key);
$expire = $expire < 0 ? 365 * 86400 : $expire;
$expiretime = time() + $expire;
//解决使用redis方式储存token时api接口Token刷新与检测因expires_in拼写错误报错的BUG
$result = ['token' => $token, 'user_id' => $value, 'expiretime' => $expiretime, 'expires_in' => $expire];
return $result;
}
/**
* 判断Token是否可用
* @param string $token Token
* @param int $user_id 会员ID
* @return boolean
*/
public function check($token, $user_id)
{
$data = self::get($token);
return $data && $data['user_id'] == $user_id ? true : false;
}
/**
* 删除Token
* @param string $token
* @return boolean
*/
public function delete($token)
{
$data = $this->get($token);
if ($data) {
$key = $this->getEncryptedToken($token);
$user_id = $data['user_id'];
$this->handler->del($key);
$this->handler->sRem($this->getUserKey($user_id), $key);
}
return true;
}
/**
* 删除指定用户的所有Token
* @param int $user_id
* @return boolean
*/
public function clear($user_id)
{
$keys = $this->handler->sMembers($this->getUserKey($user_id));
$this->handler->del($this->getUserKey($user_id));
$this->handler->del($keys);
return true;
}
}
+88
View File
@@ -0,0 +1,88 @@
<?php
namespace app\common\model;
use think\Cache;
use think\Model;
/**
* 地区数据模型
*/
class Area extends Model
{
/**
* 根据经纬度获取当前地区信息
*
* @param string $lng 经度
* @param string $lat 纬度
* @return Area 城市信息
*/
public static function getAreaFromLngLat($lng, $lat, $level = 3)
{
$namearr = [1 => 'geo:province', 2 => 'geo:city', 3 => 'geo:district'];
$rangearr = [1 => 15000, 2 => 1000, 3 => 200];
$geoname = $namearr[$level] ?? $namearr[3];
$georange = $rangearr[$level] ?? $rangearr[3];
// 读取范围内的ID
$redis = Cache::store('redis')->handler();
$georadiuslist = [];
if (method_exists($redis, 'georadius')) {
$georadiuslist = $redis->georadius($geoname, $lng, $lat, $georange, 'km', ['WITHDIST', 'COUNT' => 5, 'ASC']);
}
if ($georadiuslist) {
list($id, $distance) = $georadiuslist[0];
}
$id = isset($id) && $id ? $id : 3;
return self::get($id);
}
/**
* 根据经纬度获取省份
*
* @param string $lng 经度
* @param string $lat 纬度
* @return Area
*/
public static function getProvinceFromLngLat($lng, $lat)
{
$provincedata = null;
$citydata = self::getCityFromLngLat($lng, $lat);
if ($citydata) {
$provincedata = self::get($citydata['pid']);
}
return $provincedata;
}
/**
* 根据经纬度获取城市
*
* @param string $lng 经度
* @param string $lat 纬度
* @return Area
*/
public static function getCityFromLngLat($lng, $lat)
{
$citydata = null;
$districtdata = self::getDistrictFromLngLat($lng, $lat);
if ($districtdata) {
$citydata = self::get($districtdata['pid']);
}
return $citydata;
}
/**
* 根据经纬度获取地区
*
* @param string $lng 经度
* @param string $lat 纬度
* @return Area
*/
public static function getDistrictFromLngLat($lng, $lat)
{
$districtdata = self::getAreaFromLngLat($lng, $lat, 3);
return $districtdata;
}
}
+98
View File
@@ -0,0 +1,98 @@
<?php
namespace app\common\model;
use think\Model;
class Attachment extends Model
{
// 开启自动写入时间戳字段
protected $autoWriteTimestamp = 'int';
// 定义时间戳字段名
protected $createTime = 'createtime';
protected $updateTime = 'updatetime';
// 定义字段类型
protected $type = [
];
protected $append = [
'thumb_style'
];
protected static function init()
{
// 如果已经上传该资源,则不再记录
self::beforeInsert(function ($model) {
if (self::where('url', '=', $model['url'])->where('storage', $model['storage'])->find()) {
return false;
}
});
self::beforeWrite(function ($row) {
if (isset($row['category']) && $row['category'] == 'unclassed') {
$row['category'] = '';
}
});
}
public function setUploadtimeAttr($value)
{
return is_numeric($value) ? $value : strtotime($value);
}
public function getCategoryAttr($value)
{
return $value == '' ? 'unclassed' : $value;
}
public function setCategoryAttr($value)
{
return $value == 'unclassed' ? '' : $value;
}
/**
* 获取云储存的缩略图样式字符
*/
public function getThumbStyleAttr($value, $data)
{
if (!isset($data['storage']) || $data['storage'] == 'local') {
return '';
} else {
$config = get_addon_config($data['storage']);
if ($config && isset($config['thumbstyle'])) {
return $config['thumbstyle'];
}
}
return '';
}
/**
* 获取Mimetype列表
* @return array
*/
public static function getMimetypeList()
{
$data = [
"image/*" => __("Image"),
"audio/*" => __("Audio"),
"video/*" => __("Video"),
"text/*" => __("Text"),
"application/*" => __("Application"),
"zip,rar,7z,tar" => __("Zip"),
];
return $data;
}
/**
* 获取定义的附件类别列表
* @return array
*/
public static function getCategoryList()
{
$data = config('site.attachmentcategory') ?? [];
foreach ($data as $index => &$datum) {
$datum = __($datum);
}
$data['unclassed'] = __('Unclassed');
return $data;
}
}
+89
View File
@@ -0,0 +1,89 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 分类模型
*/
class Category extends Model
{
// 开启自动写入时间戳字段
protected $autoWriteTimestamp = 'int';
// 定义时间戳字段名
protected $createTime = 'createtime';
protected $updateTime = 'updatetime';
// 追加属性
protected $append = [
'type_text',
'flag_text',
];
protected static function init()
{
self::afterInsert(function ($row) {
if (!$row['weigh']) {
$row->save(['weigh' => $row['id']]);
}
});
}
public function setFlagAttr($value, $data)
{
return is_array($value) ? implode(',', $value) : $value;
}
/**
* 读取分类类型
* @return array
*/
public static function getTypeList()
{
$typeList = config('site.categorytype');
foreach ($typeList as $k => &$v) {
$v = __($v);
}
return $typeList;
}
public function getTypeTextAttr($value, $data)
{
$value = $value ? $value : $data['type'];
$list = $this->getTypeList();
return $list[$value] ?? '';
}
public function getFlagList()
{
return ['hot' => __('Hot'), 'index' => __('Index'), 'recommend' => __('Recommend')];
}
public function getFlagTextAttr($value, $data)
{
$value = $value ? $value : $data['flag'];
$valueArr = explode(',', $value);
$list = $this->getFlagList();
return implode(',', array_intersect_key($list, array_flip($valueArr)));
}
/**
* 读取分类列表
* @param string $type 指定类型
* @param string $status 指定状态
* @return array
*/
public static function getCategoryArray($type = null, $status = null)
{
$list = collection(self::where(function ($query) use ($type, $status) {
if (!is_null($type)) {
$query->where('type', '=', $type);
}
if (!is_null($status)) {
$query->where('status', '=', $status);
}
})->order('weigh', 'desc')->select())->toArray();
return $list;
}
}
+242
View File
@@ -0,0 +1,242 @@
<?php
declare(strict_types=1);
namespace app\common\model;
use think\Model;
/**
* 配置模型
*/
class Config extends Model
{
// 表名,不含前缀
protected $name = 'config';
// 自动写入时间戳字段
protected $autoWriteTimestamp = false;
// 定义时间戳字段名
protected $createTime = false;
protected $updateTime = false;
// 追加属性
protected $append = [
'extend_html'
];
protected $type = [
'setting' => 'json',
];
/**
* 读取配置类型
* @return array
*/
public static function getTypeList()
{
$typeList = [
'string' => __('String'),
'password' => __('Password'),
'text' => __('Text'),
'editor' => __('Editor'),
'number' => __('Number'),
'date' => __('Date'),
'time' => __('Time'),
'datetime' => __('Datetime'),
'datetimerange' => __('Datetimerange'),
'select' => __('Select'),
'selects' => __('Selects'),
'image' => __('Image'),
'images' => __('Images'),
'file' => __('File'),
'files' => __('Files'),
'switch' => __('Switch'),
'checkbox' => __('Checkbox'),
'radio' => __('Radio'),
'city' => __('City'),
'selectpage' => __('Selectpage'),
'selectpages' => __('Selectpages'),
'array' => __('Array'),
'custom' => __('Custom'),
];
return $typeList;
}
public static function getRegexList()
{
$regexList = [
'required' => '必选',
'digits' => '数字',
'letters' => '字母',
'date' => '日期',
'time' => '时间',
'email' => '邮箱',
'url' => '网址',
'qq' => 'QQ号',
'IDcard' => '身份证',
'tel' => '座机电话',
'mobile' => '手机号',
'zipcode' => '邮编',
'chinese' => '中文',
'username' => '用户名',
'password' => '密码'
];
return $regexList;
}
public function getExtendHtmlAttr($value, $data)
{
$result = preg_replace_callback("/\{([a-zA-Z]+)\}/", function ($matches) use ($data) {
if (isset($data[$matches[1]])) {
return $data[$matches[1]];
}
}, $data['extend']);
return $result;
}
/**
* 读取分类分组列表(优先数据库 configgroup,避免 site.php 未同步时分组缺失)
*
* @return array
*/
public static function getGroupList()
{
$groupList = config('site.configgroup');
if (!is_array($groupList)) {
$groupList = [];
}
try {
$dbJson = \think\Db::name('config')->where('name', 'configgroup')->value('value');
$dbList = json_decode((string) $dbJson, true);
if (is_array($dbList) && $dbList !== []) {
$groupList = $dbList;
}
} catch (\Throwable $e) {
// 数据库不可用时回退 site.php
}
foreach ($groupList as $k => &$v) {
$v = __($v);
}
return $groupList;
}
public static function getArrayData($data)
{
if (!isset($data['value'])) {
$result = [];
foreach ($data as $index => $datum) {
$result['field'][$index] = $datum['key'];
$result['value'][$index] = $datum['value'];
}
$data = $result;
}
$fieldarr = $valuearr = [];
$field = $data['field'] ?? ($data['key'] ?? []);
$value = $data['value'] ?? [];
foreach ($field as $m => $n) {
if ($n != '') {
$fieldarr[] = $field[$m];
$valuearr[] = $value[$m];
}
}
return $fieldarr ? array_combine($fieldarr, $valuearr) : [];
}
/**
* 将字符串解析成键值数组
* @param string $text
* @return array
*/
public static function decode($text, $split = "\r\n")
{
$content = explode($split, $text);
$arr = [];
foreach ($content as $k => $v) {
if (stripos($v, "|") !== false) {
$item = explode('|', $v);
$arr[$item[0]] = $item[1];
}
}
return $arr;
}
/**
* 将键值数组转换为字符串
* @param array $array
* @return string
*/
public static function encode($array, $split = "\r\n")
{
$content = '';
if ($array && is_array($array)) {
$arr = [];
foreach ($array as $k => $v) {
$arr[] = "{$k}|{$v}";
}
$content = implode($split, $arr);
}
return $content;
}
/**
* 本地上传配置信息
* @return array
*/
public static function upload()
{
$uploadcfg = config('upload');
$uploadurl = request()->module() ? $uploadcfg['uploadurl'] : ($uploadcfg['uploadurl'] === 'ajax/upload' ? 'index/' . $uploadcfg['uploadurl'] : $uploadcfg['uploadurl']);
if (!preg_match("/^((?:[a-z]+:)?\/\/)(.*)/i", $uploadurl) && substr($uploadurl, 0, 1) !== '/') {
$uploadurl = url($uploadurl, '', false);
}
$uploadcfg['fullmode'] = isset($uploadcfg['fullmode']) && $uploadcfg['fullmode'];
$uploadcfg['thumbstyle'] = $uploadcfg['thumbstyle'] ?? '';
$upload = [
'cdnurl' => $uploadcfg['cdnurl'],
'uploadurl' => $uploadurl,
'bucket' => 'local',
'maxsize' => $uploadcfg['maxsize'],
'mimetype' => $uploadcfg['mimetype'],
'chunking' => $uploadcfg['chunking'],
'chunksize' => $uploadcfg['chunksize'],
'savekey' => $uploadcfg['savekey'],
'multipart' => [],
'multiple' => $uploadcfg['multiple'],
'fullmode' => $uploadcfg['fullmode'],
'thumbstyle' => $uploadcfg['thumbstyle'],
'storage' => 'local'
];
return $upload;
}
/**
* 刷新配置文件
*/
public static function refreshFile()
{
//如果没有配置权限无法进行修改
if (!\app\admin\library\Auth::instance()->check('general/config/edit')) {
return false;
}
$config = [];
$configList = self::all();
foreach ($configList as $k => $v) {
$value = $v->toArray();
if (in_array($value['type'], ['selects', 'checkbox', 'images', 'files'])) {
$value['value'] = explode(',', $value['value']);
}
if ($value['type'] == 'array') {
$value['value'] = (array)json_decode($value['value'], true);
}
$config[$value['name']] = $value['value'];
}
file_put_contents(
CONF_PATH . 'extra' . DS . 'site.php',
'<?php' . "\n\nreturn " . var_export($config, true) . ";\n"
);
return true;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 邮箱验证码
*/
class Ems extends Model
{
// 开启自动写入时间戳字段
protected $autoWriteTimestamp = 'int';
// 定义时间戳字段名
protected $createTime = 'createtime';
protected $updateTime = false;
// 追加属性
protected $append = [
];
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 会员余额日志模型
*/
class MoneyLog extends Model
{
// 表名
protected $name = 'user_money_log';
// 开启自动写入时间戳字段
protected $autoWriteTimestamp = 'int';
// 定义时间戳字段名
protected $createTime = 'createtime';
protected $updateTime = '';
// 追加属性
protected $append = [
];
}
+23
View File
@@ -0,0 +1,23 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 会员积分日志模型
*/
class ScoreLog extends Model
{
// 表名
protected $name = 'user_score_log';
// 开启自动写入时间戳字段
protected $autoWriteTimestamp = 'int';
// 定义时间戳字段名
protected $createTime = 'createtime';
protected $updateTime = '';
// 追加属性
protected $append = [
];
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace app\common\model;
use think\Model;
/**
* 短信验证码
*/
class Sms extends Model
{
// 开启自动写入时间戳字段
protected $autoWriteTimestamp = 'int';
// 定义时间戳字段名
protected $createTime = 'createtime';
protected $updateTime = false;
// 追加属性
protected $append = [
];
}
+155
View File
@@ -0,0 +1,155 @@
<?php
namespace app\common\model;
use think\Db;
use think\Model;
/**
* 会员模型
* @method static mixed getByUsername($str) 通过用户名查询用户
* @method static mixed getByNickname($str) 通过昵称查询用户
* @method static mixed getByMobile($str) 通过手机查询用户
* @method static mixed getByEmail($str) 通过邮箱查询用户
*/
class User extends Model
{
// 开启自动写入时间戳字段
protected $autoWriteTimestamp = 'int';
// 定义时间戳字段名
protected $createTime = 'createtime';
protected $updateTime = 'updatetime';
// 追加属性
protected $append = [
'url',
];
/**
* 获取个人URL
* @param string $value
* @param array $data
* @return string
*/
public function getUrlAttr($value, $data)
{
return "/u/" . $data['id'];
}
/**
* 获取头像
* @param string $value
* @param array $data
* @return string
*/
public function getAvatarAttr($value, $data)
{
if (!$value) {
//如果不需要启用首字母头像,请使用
//$value = '/assets/img/avatar.png';
$value = letter_avatar($data['nickname']);
}
return $value;
}
/**
* 获取会员的组别
*/
public function getGroupAttr($value, $data)
{
return UserGroup::get($data['group_id']);
}
/**
* 获取验证字段数组值
* @param string $value
* @param array $data
* @return object
*/
public function getVerificationAttr($value, $data)
{
$value = array_filter((array)json_decode($value, true));
$value = array_merge(['email' => 0, 'mobile' => 0], $value);
return (object)$value;
}
/**
* 设置验证字段
* @param mixed $value
* @return string
*/
public function setVerificationAttr($value)
{
$value = is_object($value) || is_array($value) ? json_encode($value) : $value;
return $value;
}
/**
* 变更会员余额
* @param int $money 余额
* @param int $user_id 会员ID
* @param string $memo 备注
*/
public static function money($money, $user_id, $memo)
{
Db::startTrans();
try {
$user = self::lock(true)->find($user_id);
if ($user && $money != 0) {
$before = $user->money;
//$after = $user->money + $money;
$after = function_exists('bcadd') ? bcadd($user->money, $money, 2) : $user->money + $money;
//更新会员信息
$user->save(['money' => $after]);
//写入日志
MoneyLog::create(['user_id' => $user_id, 'money' => $money, 'before' => $before, 'after' => $after, 'memo' => $memo]);
}
Db::commit();
} catch (\Exception $e) {
Db::rollback();
}
}
/**
* 变更会员积分
* @param int $score 积分
* @param int $user_id 会员ID
* @param string $memo 备注
*/
public static function score($score, $user_id, $memo)
{
Db::startTrans();
try {
$user = self::lock(true)->find($user_id);
if ($user && $score != 0) {
$before = $user->score;
$after = $user->score + $score;
$level = self::nextlevel($after);
//更新会员信息
$user->save(['score' => $after, 'level' => $level]);
//写入日志
ScoreLog::create(['user_id' => $user_id, 'score' => $score, 'before' => $before, 'after' => $after, 'memo' => $memo]);
}
Db::commit();
} catch (\Exception $e) {
Db::rollback();
}
}
/**
* 根据积分获取等级
* @param int $score 积分
* @return int
*/
public static function nextlevel($score = 0)
{
$lv = array(1 => 0, 2 => 30, 3 => 100, 4 => 500, 5 => 1000, 6 => 2000, 7 => 3000, 8 => 5000, 9 => 8000, 10 => 10000);
$level = 1;
foreach ($lv as $key => $value) {
if ($score >= $value) {
$level = $key;
}
}
return $level;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace app\common\model;
use think\Model;
class UserGroup extends Model
{
// 表名
protected $name = 'user_group';
// 自动写入时间戳字段
protected $autoWriteTimestamp = 'int';
// 定义时间戳字段名
protected $createTime = 'createtime';
protected $updateTime = 'updatetime';
// 追加属性
protected $append = [
];
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace app\common\model;
use think\Model;
class UserRule extends Model
{
// 表名
protected $name = 'user_rule';
// 自动写入时间戳字段
protected $autoWriteTimestamp = 'int';
// 定义时间戳字段名
protected $createTime = 'createtime';
protected $updateTime = 'updatetime';
// 追加属性
protected $append = [
];
}
+50
View File
@@ -0,0 +1,50 @@
<?php
namespace app\common\model;
use think\Model;
class Version extends Model
{
// 开启自动写入时间戳字段
protected $autoWriteTimestamp = 'int';
// 定义时间戳字段名
protected $createTime = 'createtime';
protected $updateTime = 'updatetime';
// 定义字段类型
protected $type = [
];
/**
* 检测版本号
*
* @param string $version 客户端版本号
* @return array
*/
public static function check($version)
{
$versionlist = self::where('status', 'normal')->cache('__version__')->order('weigh desc,id desc')->select();
foreach ($versionlist as $k => $v) {
// 版本正常且新版本号不等于验证的版本号且找到匹配的旧版本
if ($v['status'] == 'normal' && $v['newversion'] !== $version && \fast\Version::check($version, $v['oldversion'])) {
$updateversion = $v;
break;
}
}
if (isset($updateversion)) {
$search = ['{version}', '{newversion}', '{downloadurl}', '{url}', '{packagesize}'];
$replace = [$version, $updateversion['newversion'], $updateversion['downloadurl'], $updateversion['downloadurl'], $updateversion['packagesize']];
$upgradetext = str_replace($search, $replace, $updateversion['content']);
return [
"enforce" => $updateversion['enforce'],
"version" => $version,
"newversion" => $updateversion['newversion'],
"downloadurl" => $updateversion['downloadurl'],
"packagesize" => $updateversion['packagesize'],
"upgradetext" => $upgradetext
];
}
return null;
}
}
+381
View File
@@ -0,0 +1,381 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use fast\Http;
use think\Exception;
/**
* Cloudflare API 服务层
*/
class CloudflareService
{
private const API_BASE = 'https://api.cloudflare.com/client/v4';
private string $apiToken;
private string $accountId;
private string $serverIp;
public function __construct()
{
$config = config('cloudflare');
$this->apiToken = (string)($config['api_token'] ?? '');
$this->accountId = (string)($config['account_id'] ?? '');
$this->serverIp = trim((string)($config['server_ip'] ?? ''));
if ($this->apiToken === '' || $this->accountId === '') {
throw new Exception('Cloudflare API 凭证未配置,请在 .env 中设置 cloudflare.api_token 与 cloudflare.account_id');
}
}
/**
* 创建 Cloudflare Zone
*
* @param string $domain 根域名
* @return array{zone_id: string, name_servers: array, status: string}
* @throws Exception
*/
public function createZone(string $domain): array
{
$body = [
'name' => $domain,
'account' => ['id' => $this->accountId],
'type' => 'full',
];
$response = $this->request('POST', '/zones', $body);
$result = $response['result'] ?? [];
return [
'zone_id' => (string)($result['id'] ?? ''),
'name_servers' => (array)($result['name_servers'] ?? []),
'status' => (string)($result['status'] ?? 'pending'),
];
}
/**
* @throws Exception
*/
public function getZone(string $zoneId): array
{
$response = $this->request('GET', '/zones/' . $zoneId);
return (array)($response['result'] ?? []);
}
/**
* @throws Exception
*/
public function deleteZone(string $zoneId): bool
{
$this->request('DELETE', '/zones/' . $zoneId);
return true;
}
/**
* @throws Exception
*/
public function listDnsRecords(string $zoneId): array
{
$response = $this->request('GET', '/zones/' . $zoneId . '/dns_records', [], ['per_page' => 100]);
return (array)($response['result'] ?? []);
}
/**
* 聚合检测 Zone/NS/DNS 状态;NS 已验证且 Zone 已激活时,静默校验并修正 A 记录、Proxied、SSL、HTTPS
*
* @param array $row 域名记录
* @return array{zone_status: string, ns_status: string, dns_status: string, check_result: string}
*/
public function detectDomain(array $row): array
{
$domain = strtolower(trim((string)($row['domain'] ?? '')));
$zoneId = (string)($row['zone_id'] ?? '');
$expectedNs = $this->decodeNameservers($row['nameservers'] ?? '');
$zoneStatus = 'failed';
$nsStatus = 'pending';
$dnsStatus = 'pending';
$messages = [];
try {
$zone = $this->getZone($zoneId);
$cfStatus = (string)($zone['status'] ?? '');
if ($cfStatus === 'active') {
$zoneStatus = 'active';
$messages[] = 'Zone:已激活';
} elseif (in_array($cfStatus, ['pending', 'initializing'], true)) {
$zoneStatus = 'pending';
$messages[] = 'Zone:待激活';
} else {
$zoneStatus = 'failed';
$messages[] = 'Zone:异常(' . $cfStatus . ')';
}
} catch (\Throwable $e) {
$zoneStatus = 'failed';
$messages[] = 'Zone:检测失败(' . $e->getMessage() . ')';
}
$currentNs = $this->getDomainNameservers($domain);
if ($currentNs === []) {
$nsStatus = 'pending';
$messages[] = 'NS:待验证(未查询到NS记录)';
} elseif ($this->compareNameservers($currentNs, $expectedNs)) {
$nsStatus = 'verified';
$messages[] = 'NS:已验证';
} else {
$nsStatus = 'pending';
$messages[] = 'NS:待验证(当前NS与Cloudflare不一致)';
}
if ($zoneStatus !== 'active') {
$dnsStatus = 'pending';
$messages[] = 'DNS:待创建(Zone未激活)';
} elseif ($nsStatus !== 'verified') {
$dnsStatus = 'pending';
$messages[] = 'DNS:待创建(NS未验证)';
} elseif ($this->serverIp === '') {
$dnsStatus = 'failed';
$messages[] = 'DNS:未配置server_ip';
} elseif (!filter_var($this->serverIp, FILTER_VALIDATE_IP)) {
$dnsStatus = 'failed';
$messages[] = 'DNS:server_ip格式无效';
} else {
if ($this->hasRootCnameConflict($zoneId, $domain)) {
$dnsStatus = 'failed';
$messages[] = 'DNS:根域名存在CNAME记录,无法创建A记录';
} else {
try {
$this->reconcileCloudflareConfig($zoneId, $domain, $this->serverIp);
if ($this->verifyRootARecord($zoneId, $domain, $this->serverIp)) {
$dnsStatus = 'created';
$messages[] = 'DNS:已创建(A=' . $this->serverIp . ')';
} else {
$dnsStatus = 'pending';
$messages[] = 'DNS:待创建(A记录未指向' . $this->serverIp . ')';
}
} catch (\Throwable $e) {
$dnsStatus = 'failed';
$messages[] = 'DNS:操作失败(' . $e->getMessage() . ')';
}
}
}
return [
'zone_status' => $zoneStatus,
'ns_status' => $nsStatus,
'dns_status' => $dnsStatus,
'check_result' => implode('; ', $messages),
];
}
/**
* @throws Exception
*/
private function listRootARecords(string $zoneId, string $domain): array
{
$response = $this->request('GET', '/zones/' . $zoneId . '/dns_records', [], [
'type' => 'A',
'name' => $domain,
]);
return (array)($response['result'] ?? []);
}
/**
* @throws Exception
*/
private function hasRootCnameConflict(string $zoneId, string $domain): bool
{
$response = $this->request('GET', '/zones/' . $zoneId . '/dns_records', [], [
'type' => 'CNAME',
'name' => $domain,
]);
return count((array)($response['result'] ?? [])) > 0;
}
/**
* NS 已验证且 Zone 已激活时:读取 Proxied / SSL / HTTPS / A 记录,与期望值不一致则修正
*
* @throws Exception
*/
private function reconcileCloudflareConfig(string $zoneId, string $domain, string $ip): void
{
$this->ensureZoneEdgeSettings($zoneId);
$this->upsertRootARecord($zoneId, $domain, $ip);
}
/**
* 读取 Zone 单项设置值
*
* @throws Exception
*/
private function getZoneSettingValue(string $zoneId, string $settingId): string
{
$response = $this->request('GET', '/zones/' . $zoneId . '/settings/' . $settingId);
return strtolower(trim((string)($response['result']['value'] ?? '')));
}
/**
* SSL/TLS=Flexible、Always Use HTTPS=开启;已与期望一致则跳过 PATCH
*
* @throws Exception
*/
private function ensureZoneEdgeSettings(string $zoneId): void
{
if ($this->getZoneSettingValue($zoneId, 'ssl') !== 'flexible') {
$this->request('PATCH', '/zones/' . $zoneId . '/settings/ssl', [
'value' => 'flexible',
]);
}
if ($this->getZoneSettingValue($zoneId, 'always_use_https') !== 'on') {
$this->request('PATCH', '/zones/' . $zoneId . '/settings/always_use_https', [
'value' => 'on',
]);
}
}
/**
* 创建或更新根域名 A 记录(IP 与 Proxied 与期望不一致时修正)
*
* @throws Exception
*/
private function upsertRootARecord(string $zoneId, string $domain, string $ip): void
{
$records = $this->listRootARecords($zoneId, $domain);
if ($records === []) {
$this->request('POST', '/zones/' . $zoneId . '/dns_records', [
'type' => 'A',
'name' => $domain,
'content' => $ip,
'ttl' => 1,
'proxied' => true,
]);
return;
}
foreach ($records as $record) {
$recordId = (string)($record['id'] ?? '');
$content = (string)($record['content'] ?? '');
$proxied = (bool)($record['proxied'] ?? false);
if ($recordId === '') {
continue;
}
if ($content === $ip && $proxied) {
continue;
}
$this->request('PATCH', '/zones/' . $zoneId . '/dns_records/' . $recordId, [
'content' => $ip,
'ttl' => 1,
'proxied' => true,
]);
}
}
/**
* 校验根域名 A 记录:content 指向 server_ip 且已开启 Proxy
*
* @throws Exception
*/
private function verifyRootARecord(string $zoneId, string $domain, string $ip): bool
{
$records = $this->listRootARecords($zoneId, $domain);
foreach ($records as $record) {
$content = (string)($record['content'] ?? '');
$proxied = (bool)($record['proxied'] ?? false);
if ($content === $ip && $proxied) {
return true;
}
}
return false;
}
/**
* @throws Exception
*/
private function request(string $method, string $path, array $body = [], array $query = []): array
{
$url = self::API_BASE . $path;
if ($query !== []) {
$url .= '?' . http_build_query($query);
}
$options = [
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $this->apiToken,
'Content-Type: application/json',
],
];
$payload = $body !== [] ? json_encode($body, JSON_UNESCAPED_UNICODE) : '';
if (strtoupper($method) === 'GET') {
$result = Http::sendRequest($url, [], 'GET', $options);
} elseif (strtoupper($method) === 'DELETE') {
$result = Http::sendRequest($url, $payload, 'DELETE', $options);
} else {
$result = Http::sendRequest($url, $payload, strtoupper($method), $options);
}
if (!$result['ret']) {
throw new Exception('Cloudflare API 请求失败: ' . ($result['msg'] ?? '未知错误'));
}
$decoded = json_decode((string)$result['msg'], true);
if (!is_array($decoded)) {
throw new Exception('Cloudflare API 响应解析失败');
}
if (empty($decoded['success'])) {
$errors = $decoded['errors'] ?? [];
$message = 'Cloudflare API 错误';
if (is_array($errors) && isset($errors[0]['message'])) {
$message = (string)$errors[0]['message'];
}
throw new Exception($message);
}
return $decoded;
}
private function decodeNameservers($nameservers): array
{
if (is_array($nameservers)) {
return $nameservers;
}
if (is_string($nameservers) && $nameservers !== '') {
$decoded = json_decode($nameservers, true);
return is_array($decoded) ? $decoded : [];
}
return [];
}
private function getDomainNameservers(string $domain): array
{
$records = @dns_get_record($domain, DNS_NS);
if (!is_array($records)) {
return [];
}
$list = [];
foreach ($records as $record) {
if (!empty($record['target'])) {
$list[] = $this->normalizeNs((string)$record['target']);
}
}
return array_values(array_unique($list));
}
private function compareNameservers(array $current, array $expected): bool
{
if ($expected === []) {
return false;
}
$currentNorm = array_map([$this, 'normalizeNs'], $current);
$expectedNorm = array_map([$this, 'normalizeNs'], $expected);
sort($currentNorm);
sort($expectedNorm);
return $currentNorm === $expectedNorm;
}
private function normalizeNs(string $ns): string
{
return rtrim(strtolower(trim($ns)), '.');
}
}
@@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use think\Cache;
use think\Exception;
/**
* 域名检测频率限制
* 每分钟最多 2 次,超限后冷却 120 秒
*/
class DomainDetectRateLimitService
{
/** 统计窗口(秒) */
private const WINDOW_SECONDS = 60;
/** 窗口内最大检测次数 */
private const MAX_ATTEMPTS = 2;
/** 超限后冷却时间(秒) */
private const COOLDOWN_SECONDS = 120;
/**
* 校验是否允许检测,不允许时抛出异常
*
* @param int $adminId 管理员 ID
* @param int $domainId 域名记录 ID
* @throws Exception
*/
public function assertCanDetect(int $adminId, int $domainId): void
{
$now = time();
$baseKey = 'domain_detect:' . $adminId . ':' . $domainId;
$cooldownKey = $baseKey . ':cooldown';
$attemptsKey = $baseKey . ':attempts';
$cooldownUntil = (int)Cache::get($cooldownKey, 0);
if ($cooldownUntil > $now) {
$wait = $cooldownUntil - $now;
throw new Exception(sprintf('检测过于频繁,请 %d 秒后再试', $wait));
}
$timestamps = Cache::get($attemptsKey);
if (!is_array($timestamps)) {
$timestamps = [];
}
$timestamps = array_values(array_filter($timestamps, static function ($ts) use ($now): bool {
return is_int($ts) && ($now - $ts) < self::WINDOW_SECONDS;
}));
if (count($timestamps) >= self::MAX_ATTEMPTS) {
Cache::set($cooldownKey, $now + self::COOLDOWN_SECONDS, self::COOLDOWN_SECONDS);
Cache::set($attemptsKey, $timestamps, self::WINDOW_SECONDS + self::COOLDOWN_SECONDS);
throw new Exception(sprintf(
'一分钟最多检测 %d 次,请 %d 秒后再试',
self::MAX_ATTEMPTS,
self::COOLDOWN_SECONDS
));
}
$timestamps[] = $now;
Cache::set($attemptsKey, $timestamps, self::WINDOW_SECONDS + self::COOLDOWN_SECONDS);
}
}
+105
View File
@@ -0,0 +1,105 @@
<?php
declare(strict_types=1);
namespace app\common\service;
/**
* 分流链接自动回复语句(一行一条)
*/
class SplitAutoReplyService
{
/** 最多条数 */
private const MAX_LINES = 200;
/** 单条最大字符数 */
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') . '...';
}
/**
* 解析为多行文本数组
*
* @return array<int, string>
*/
public static function parseLines(string $raw): array
{
$raw = trim($raw);
if ($raw === '') {
return [];
}
$parts = preg_split('/\r\n|\r|\n/', $raw) ?: [];
$lines = [];
foreach ($parts as $part) {
$line = trim((string) $part);
if ($line === '') {
continue;
}
if (strlen($line) > self::MAX_LINE_LENGTH) {
$line = mb_substr($line, 0, self::MAX_LINE_LENGTH, 'UTF-8');
}
$lines[] = $line;
if (count($lines) >= self::MAX_LINES) {
break;
}
}
return $lines;
}
/**
* 格式化为数据库存储(换行分隔)
*
* @param string|array<int, string> $value
*/
public static function formatStorage($value): string
{
if (is_array($value)) {
$lines = $value;
} else {
$lines = self::parseLines((string) $value);
}
return implode("\n", $lines);
}
/**
* 供表单 textarea 回显
*/
public static function formatDisplay(string $stored): string
{
$lines = self::parseLines($stored);
return implode("\n", $lines);
}
/**
* 从多行回复语中随机抽取一条(无配置时返回空字符串)
*/
public static function pickRandomLine(string $raw): string
{
$lines = self::parseLines($raw);
if ($lines === []) {
return '';
}
return $lines[array_rand($lines)];
}
}
+104
View File
@@ -0,0 +1,104 @@
<?php
declare(strict_types=1);
namespace app\common\service;
/**
* 按号码类型拼接各平台加好友链接
*/
class SplitFriendUrlBuilder
{
/**
* 构建跳转 URL;无法构建时返回空字符串
*
* @param string $whatsAppReplyText 仅 WhatsApp 类型使用,预填消息文案(urlencode 在内部处理)
*/
public static function build(
string $numberType,
string $number,
string $numberTypeCustom = '',
string $whatsAppReplyText = ''
): string {
$number = trim($number);
if ($number === '') {
return '';
}
switch ($numberType) {
case 'whatsapp':
return self::buildWhatsApp($number, $whatsAppReplyText);
case 'telegram':
return self::buildTelegram($number);
case 'line':
return self::buildLine($number);
case 'custom':
return self::buildCustom($number, $numberTypeCustom);
default:
return '';
}
}
/**
* WhatsApphttps://api.whatsapp.com/send?phone= 仅数字,可选 &text= 预填消息
*/
private static function buildWhatsApp(string $number, string $replyText = ''): string
{
$digits = preg_replace('/\D+/', '', $number) ?? '';
if ($digits === '') {
return '';
}
$url = 'https://api.whatsapp.com/send?phone=' . $digits;
$replyText = trim($replyText);
if ($replyText !== '') {
$url .= '&text=' . rawurlencode($replyText);
}
return $url;
}
/**
* Telegramhttps://t.me/+ 号码(去掉前导 +
*/
private static function buildTelegram(string $number): string
{
$id = ltrim($number, '+');
if ($id === '') {
return '';
}
return 'https://t.me/+' . $id;
}
/**
* Linehttps://line.me/ti/p/~ 拼接号码
*/
private static function buildLine(string $number): string
{
return 'https://line.me/ti/p/~' . $number;
}
/**
* 自定义:号码字段即为完整链接(仅允许 http/https
*/
private static function buildCustom(string $number, string $numberTypeCustom): string
{
$url = trim($number);
if ($url === '' && trim($numberTypeCustom) !== '') {
$url = trim($numberTypeCustom);
}
if ($url === '') {
return '';
}
if (!filter_var($url, FILTER_VALIDATE_URL)) {
return '';
}
$scheme = strtolower((string) parse_url($url, PHP_URL_SCHEME));
if (!in_array($scheme, ['http', 'https'], true)) {
return '';
}
return $url;
}
}
+87
View File
@@ -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;
}
}
+60
View File
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace app\common\service;
/**
* 分流链接 IP 防护:开启时校验访客 IP 国家是否在链接投放国家列表内
*/
class SplitIpProtectService
{
/**
* 是否允许继续轮转跳转
*
* @param int|string $ipProtect 链接 ip_protect0=关闭,1=开启
* @param string $countriesStorage 链接 countries 字段(ISO2 逗号分隔)
* @param string $clientIp 访客 IP
*/
public static function isAllowed($ipProtect, string $countriesStorage, string $clientIp): bool
{
if ((int) $ipProtect !== 1) {
return true;
}
$allowed = self::parseAllowedCountries($countriesStorage);
if ($allowed === []) {
return false;
}
if (!SplitGeoIpService::isDatabaseAvailable()) {
return false;
}
$visitorCountry = SplitGeoIpService::getCountryIso2($clientIp);
if ($visitorCountry === null || $visitorCountry === '') {
return false;
}
return in_array($visitorCountry, $allowed, true);
}
/**
* @return array<int, string> 大写 ISO2 列表
*/
public static function parseAllowedCountries(string $storage): array
{
if (trim($storage) === '') {
return [];
}
$result = [];
foreach (explode(',', $storage) as $code) {
$code = strtoupper(trim($code));
if ($code !== '' && !in_array($code, $result, true)) {
$result[] = $code;
}
}
return $result;
}
}
+76
View File
@@ -0,0 +1,76 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Link;
use think\Exception;
/**
* 分流链接码生成服务
*/
class SplitLinkCodeService
{
private const CODE_LENGTH = 9;
private const CHARSET = 'abcdefghijklmnopqrstuvwxyz';
private const MAX_ATTEMPTS = 50;
/**
* 规范化链接码(小写、去空格)
*/
public static function normalize(string $code): string
{
return strtolower(trim($code));
}
/**
* 是否为合法 9 位小写字母链接码
*/
public static function isValidFormat(string $code): bool
{
return (bool) preg_match('/^[a-z]{9}$/', $code);
}
/**
* 链接码是否未被占用
*/
public static function isAvailable(string $code, int $excludeId = 0): bool
{
$code = self::normalize($code);
$query = Link::where('link_code', $code);
if ($excludeId > 0) {
$query->where('id', '<>', $excludeId);
}
return !$query->find();
}
/**
* 生成唯一 9 位小写字母链接码
*
* @throws Exception
*/
public function generateUnique(): string
{
for ($i = 0; $i < self::MAX_ATTEMPTS; $i++) {
$code = $this->generateRandom();
if (!Link::where('link_code', $code)->find()) {
return $code;
}
}
throw new Exception('分流链接生成失败,请稍后重试');
}
private function generateRandom(): string
{
$chars = self::CHARSET;
$max = strlen($chars) - 1;
$code = '';
for ($i = 0; $i < self::CODE_LENGTH; $i++) {
$code .= $chars[random_int(0, $max)];
}
return $code;
}
}
+24
View File
@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Link;
/**
* 分流链接随机打乱配置读取
*/
class SplitNumberWeighService
{
/**
* 链接是否开启随机打乱(新号码按随机插入顺序写入,跳转按 id 顺序轮转)
*/
public static function isRandomShuffleEnabled(int $linkId): bool
{
if ($linkId <= 0) {
return false;
}
return (int) Link::where('id', $linkId)->value('random_shuffle') === 1;
}
}
+171
View File
@@ -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);
}
}
+250
View File
@@ -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;
}
}
+169
View File
@@ -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));
}
}
}
+59
View File
@@ -0,0 +1,59 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\Domain as DomainModel;
/**
* 平台分配域名解析(支持多域名,一行一个)
*/
class SplitPlatformDomainService
{
/**
* 解析配置文本为合法根域名列表(去重、小写)
*
* @param string $raw 换行或逗号分隔的域名文本
* @return array<int, string>
*/
public static function parseList(string $raw): array
{
$raw = trim($raw);
if ($raw === '') {
return [];
}
$parts = preg_split('/[\r\n,]+/', $raw) ?: [];
$domains = [];
foreach ($parts as $part) {
$domain = DomainModel::normalizeDomain(trim((string) $part));
if ($domain === '' || isset($domains[$domain])) {
continue;
}
if (!DomainModel::isValidRootDomain($domain)) {
continue;
}
$domains[$domain] = $domain;
}
return array_values($domains);
}
/**
* 将域名列表格式化为配置存储文本(一行一个)
*
* @param array<int, string> $domains
*/
public static function formatList(array $domains): string
{
$lines = [];
foreach ($domains as $domain) {
$domain = DomainModel::normalizeDomain((string) $domain);
if ($domain !== '' && DomainModel::isValidRootDomain($domain)) {
$lines[$domain] = $domain;
}
}
return implode("\n", array_values($lines));
}
}
+97
View File
@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Link;
use app\admin\model\split\Number;
use think\Collection;
/**
* 分流链接落地页:查链、轮转选号、拼接跳转 URL、访问计数
*/
class SplitRedirectService
{
private SplitRoundRobinStore $roundRobinStore;
public function __construct(?SplitRoundRobinStore $roundRobinStore = null)
{
$this->roundRobinStore = $roundRobinStore ?? new SplitRoundRobinStore();
}
/**
* 根据链接码解析本次应跳转的加好友 URL;无效时返回 null
*
* @param string $linkCode 9 位链接码
* @param string $clientIp 访客 IPIP 防护开启时用于国家校验)
*/
public function resolveRedirectUrl(string $linkCode, string $clientIp = ''): ?string
{
$linkCode = SplitLinkCodeService::normalize($linkCode);
if (!SplitLinkCodeService::isValidFormat($linkCode)) {
return null;
}
$link = Link::where('link_code', $linkCode)
->where('status', 'normal')
->find();
if (!$link) {
return null;
}
$ipProtect = (int) $link->getAttr('ip_protect');
$countries = (string) $link->getAttr('countries');
if (!SplitIpProtectService::isAllowed($ipProtect, $countries, $clientIp)) {
return null;
}
/** @var Collection<int, Number>|array<int, Number> $numbers */
$numbers = Number::where('split_link_id', (int) $link['id'])
->where('status', 'normal')
->order('id', 'asc')
->field('id,number,number_type,number_type_custom')
->select();
$count = is_countable($numbers) ? count($numbers) : 0;
if ($count === 0) {
return null;
}
$linkId = (int) $link->getAttr('id');
$index = $this->roundRobinStore->nextIndex($linkId, $count);
$list = $numbers instanceof \think\Collection ? $numbers->all() : (array) $numbers;
$picked = $list[$index] ?? $list[0] ?? null;
if ($picked === null) {
return null;
}
$numberType = is_array($picked) ? (string) ($picked['number_type'] ?? '') : (string) $picked->getAttr('number_type');
$numberValue = is_array($picked) ? (string) ($picked['number'] ?? '') : (string) $picked->getAttr('number');
$numberCustom = is_array($picked)
? (string) ($picked['number_type_custom'] ?? '')
: (string) $picked->getAttr('number_type_custom');
$whatsAppReplyText = '';
if ($numberType === 'whatsapp') {
$whatsAppReplyText = SplitAutoReplyService::pickRandomLine((string) $link->getAttr('auto_reply'));
}
$redirectUrl = SplitFriendUrlBuilder::build(
$numberType,
$numberValue,
$numberCustom,
$whatsAppReplyText
);
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;
}
}
+133
View File
@@ -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;
}
}
+65
View File
@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\common\library\scrm\ScrmSpiderInterface;
use app\common\library\scrm\spider\A2cSpider;
use app\common\library\scrm\spider\HaiwangSpider;
use app\common\library\scrm\spider\HuojianSpider;
use app\common\library\scrm\spider\SsCustomerSpider;
use app\common\library\scrm\spider\XingheSpider;
/**
* 工单类型 -> 云控蜘蛛工厂
*
* 新增云控类型:在 spider/ 下新增类并在此注册 ticket_type => Class
*/
class SplitScrmSpiderFactory
{
/** @var array<string, class-string<ScrmSpiderInterface>> */
private const MAP = [
'a2c' => A2cSpider::class,
'haiwang' => HaiwangSpider::class,
'huojian' => HuojianSpider::class,
'xinghe' => XingheSpider::class,
'ss_customer' => SsCustomerSpider::class,
// ceo_scrm 等未实现类型:新增 spider 类后在此注册
];
/**
* @return class-string<ScrmSpiderInterface>|null
*/
public static function resolveClass(string $ticketType): ?string
{
$ticketType = trim($ticketType);
return self::MAP[$ticketType] ?? null;
}
/**
* 是否已实现蜘蛛
*/
public static function isSupported(string $ticketType): bool
{
return self::resolveClass($ticketType) !== null;
}
/**
* @return ScrmSpiderInterface|null
*/
public static function create(
string $ticketType,
string $pageUrl,
string $account = '',
string $password = '',
string $nodeHost = ''
): ?ScrmSpiderInterface {
$class = self::resolveClass($ticketType);
if ($class === null) {
return null;
}
$host = $nodeHost !== '' ? $nodeHost : SplitSyncConfigService::getNodeHost();
return new $class($pageUrl, $account, $password, $host);
}
}
+62
View File
@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use think\Config;
use think\Db;
/**
* 工单云控同步相关系统配置读取
*/
class SplitSyncConfigService
{
private const DEFAULT_NODE_HOST = 'http://127.0.0.1:3001';
/**
* Node Headless 服务根地址
*/
public static function getNodeHost(): string
{
$value = self::getConfigValue('split_scrm_node_host');
$value = trim($value);
return $value !== '' ? rtrim($value, '/') : self::DEFAULT_NODE_HOST;
}
/**
* 连续同步失败多少次后自动暂停工单(0 表示不因失败暂停)
*/
public static function getFailPauseThreshold(): int
{
$value = self::getConfigValue('split_sync_fail_pause_threshold');
if ($value === '') {
return 5;
}
return max(0, (int) $value);
}
/**
* 指定工单类型的自动同步周期(分钟),0 表示不自动同步
*/
public static function getIntervalMinutes(string $ticketType): int
{
$ticketType = trim($ticketType);
if ($ticketType === '') {
return 0;
}
$key = 'split_sync_interval_' . $ticketType;
$value = self::getConfigValue($key);
return max(0, (int) $value);
}
private static function getConfigValue(string $name): string
{
$site = Config::get('site.' . $name);
if ($site !== null && $site !== '') {
return (string) $site;
}
$db = Db::name('config')->where('name', $name)->value('value');
return $db !== null ? (string) $db : '';
}
}
+182
View File
@@ -0,0 +1,182 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Number;
use app\admin\model\split\Ticket;
use app\common\library\scrm\UnifiedScrmData;
use think\Db;
/**
* 工单同步结果写入号码表
*/
class SplitTicketNumberSyncService
{
/**
* 将蜘蛛返回的号码列表同步到号码管理
*/
public function syncFromUnifiedData(Ticket $ticket, UnifiedScrmData $data): void
{
$adminId = (int) $ticket['admin_id'];
$linkId = (int) $ticket['split_link_id'];
$ticketName = (string) $ticket['ticket_name'];
if ($linkId <= 0 || $ticketName === '') {
return;
}
$randomShuffle = SplitNumberWeighService::isRandomShuffleEnabled($linkId);
// 使用独立 number 字段存储,避免纯数字号码作为数组 key 被 PHP 自动转为 int
$syncedNumbers = [];
foreach ($data->numbers as $row) {
$number = self::normalizeNumber($row['number'] ?? '');
if ($number === '') {
continue;
}
$syncedNumbers[] = [
'number' => $number,
'row' => $row,
];
}
$existingList = Number::where('admin_id', $adminId)
->where('split_link_id', $linkId)
->where('ticket_name', $ticketName)
->select();
$existingMap = [];
foreach ($existingList as $item) {
$existingMap[(string) $item['number']] = $item;
}
$syncedNumberSet = [];
$pendingInserts = [];
foreach ($syncedNumbers as $entry) {
$number = $entry['number'];
$row = $entry['row'];
$syncedNumberSet[$number] = true;
$platformStatus = ($row['status'] ?? '') === 'online' ? 'online' : 'offline';
$newFollowers = (int) ($row['newFollowersToday'] ?? 0);
if (isset($existingMap[$number])) {
$this->updateExistingNumber($existingMap[$number], $platformStatus, $newFollowers);
continue;
}
$pendingInserts[] = [
'number' => $number,
'platform_status' => $platformStatus,
'new_followers' => $newFollowers,
];
}
if ($pendingInserts !== []) {
// 随机打乱:打乱待插入批次顺序,按随机顺序逐条 insert 以获得乱序自增 id
if ($randomShuffle && count($pendingInserts) > 1) {
shuffle($pendingInserts);
}
foreach ($pendingInserts as $item) {
$this->insertNumber(
$ticket,
$item['number'],
$item['platform_status'],
$item['new_followers']
);
}
}
foreach ($existingMap as $number => $item) {
if (isset($syncedNumberSet[$number])) {
continue;
}
if ((int) $item['manual_manage'] === 1) {
continue;
}
Number::where('id', (int) $item['id'])->update([
'status' => 'hidden',
'updatetime' => time(),
]);
}
}
/**
* @param Number $row
*/
private function updateExistingNumber($row, string $platformStatus, int $newFollowers): void
{
$update = [
'platform_status' => $platformStatus,
'updatetime' => time(),
];
if ((int) $row['manual_manage'] === 1) {
Number::where('id', (int) $row['id'])->update($update);
return;
}
// 进线人数由同步写入,最终开关由 applyNumberRules 统一判定(单号上限/下号比率等)
$update['inbound_count'] = max(0, $newFollowers);
Number::where('id', (int) $row['id'])->update($update);
}
private function insertNumber(
Ticket $ticket,
string $number,
string $platformStatus,
int $newFollowers
): void {
$now = time();
$data = [
'admin_id' => (int) $ticket['admin_id'],
'split_link_id' => (int) $ticket['split_link_id'],
'ticket_name' => (string) $ticket['ticket_name'],
'number' => $number,
'number_type' => (string) $ticket['number_type'],
'number_type_custom' => (string) ($ticket['number_type_custom'] ?? ''),
'visit_count' => 0,
'inbound_count' => max(0, $newFollowers),
'manual_manage' => 0,
'platform_status' => $platformStatus,
'status' => 'hidden',
'createtime' => $now,
'updatetime' => $now,
];
try {
Db::name('split_number')->insert($data);
} catch (\Throwable $e) {
$exists = Number::where('split_link_id', (int) $ticket['split_link_id'])
->where('number', $number)
->find();
if ($exists) {
$this->updateExistingNumber($exists, $platformStatus, $newFollowers);
}
}
}
/**
* 统一号码为字符串(云控 API 可能返回 int)
*/
private static function normalizeNumber($value): string
{
if ($value === null || $value === '') {
return '';
}
return trim((string) $value);
}
/**
* 汇总工单进线人数(仅开启状态的号码)
*/
public function sumInboundForTicket(Ticket $ticket): int
{
$sum = Number::where('admin_id', (int) $ticket['admin_id'])
->where('split_link_id', (int) $ticket['split_link_id'])
->where('ticket_name', (string) $ticket['ticket_name'])
->where('status', 'normal')
->sum('inbound_count');
return (int) $sum;
}
}
+195
View File
@@ -0,0 +1,195 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Number;
use app\admin\model\split\Ticket;
/**
* 工单与号码业务规则(单号上限、下号比率、时间窗口、完成量自动开关)
*/
class SplitTicketRuleService
{
/**
* 同步后应用全部规则并写回工单/号码
*/
public function applyAfterSync(Ticket $ticket, int $completeCount): void
{
$this->applyTicketStatusRules($ticket, $completeCount);
$fresh = Ticket::get((int) $ticket['id']);
if ($fresh) {
if ((string) $fresh['status'] === 'hidden') {
$this->cascadeTicketClosedToNumbers($fresh);
}
$this->applyNumberRules($fresh);
}
}
/**
* 同步流程:工单因时间/完成量等原因关闭时,联动关闭非手动号码
*/
public function cascadeTicketClosedToNumbers(Ticket $ticket): void
{
if ((string) ($ticket['status'] ?? 'hidden') !== 'hidden') {
return;
}
Number::where('admin_id', (int) $ticket['admin_id'])
->where('split_link_id', (int) $ticket['split_link_id'])
->where('ticket_name', (string) $ticket['ticket_name'])
->where('manual_manage', 0)
->update([
'status' => 'hidden',
'updatetime' => time(),
]);
}
/**
* 手动切换工单状态时,联动非手动管理的号码
*/
public function syncNumbersWithTicketStatus(Ticket $ticket): void
{
$ticketStatus = (string) ($ticket['status'] ?? 'hidden');
if ($ticketStatus === 'hidden') {
$this->cascadeTicketClosedToNumbers($ticket);
return;
}
$this->applyNumberRules($ticket);
}
/**
* 工单处于开启状态时,将云控在线的非手动号码设为开启
*
* @deprecated 请使用 applyNumberRules(会校验单号上限/下号比率)
*/
public function syncOnlineNumbersWhenTicketOpen(Ticket $ticket): void
{
$this->applyNumberRules($ticket);
}
/**
* 单号上限、下号比率、云控在线状态、工单开关综合决定号码状态
*/
public function applyNumberRules(Ticket $ticket): void
{
$orderLimit = (int) ($ticket['order_limit'] ?? 0);
$assignRatio = (int) ($ticket['assign_ratio'] ?? 0);
$numbers = Number::where('admin_id', (int) $ticket['admin_id'])
->where('split_link_id', (int) $ticket['split_link_id'])
->where('ticket_name', (string) $ticket['ticket_name'])
->select();
foreach ($numbers as $number) {
$visitCount = (int) $number['visit_count'];
$inboundCount = (int) $number['inbound_count'];
$lastVisit = (int) ($number['last_sync_visit_count'] ?? 0);
$lastInbound = (int) ($number['last_sync_inbound_count'] ?? 0);
$streak = (int) ($number['no_inbound_click_streak'] ?? 0);
if ($visitCount > $lastVisit && $inboundCount <= $lastInbound) {
$streak += ($visitCount - $lastVisit);
} elseif ($inboundCount > $lastInbound) {
$streak = 0;
}
$update = [
'no_inbound_click_streak' => $streak,
'last_sync_visit_count' => $visitCount,
'last_sync_inbound_count' => $inboundCount,
'updatetime' => time(),
];
// 手动管理(含用户手动关闭)的号码:仅更新统计字段,不改状态
if ((int) $number['manual_manage'] === 1) {
Number::where('id', (int) $number['id'])->update($update);
continue;
}
$update['status'] = $this->resolveAutomatedStatus(
$ticket,
(string) ($number['platform_status'] ?? 'unknown'),
$inboundCount,
$streak,
$orderLimit,
$assignRatio
);
Number::where('id', (int) $number['id'])->update($update);
}
}
/**
* 非手动管理号码的自动开关判定
*/
private function resolveAutomatedStatus(
Ticket $ticket,
string $platformStatus,
int $inboundCount,
int $streak,
int $orderLimit,
int $assignRatio
): string {
if ((string) ($ticket['status'] ?? 'hidden') !== 'normal') {
return 'hidden';
}
if ($platformStatus !== 'online') {
return 'hidden';
}
if ($orderLimit > 0 && $inboundCount >= $orderLimit) {
return 'hidden';
}
if ($assignRatio > 0 && $streak >= $assignRatio) {
return 'hidden';
}
return 'normal';
}
/**
* 完成量、时间窗口决定工单开关(定时与手动同步均适用)
*/
public function applyTicketStatusRules(Ticket $ticket, int $completeCount): void
{
$status = $this->resolveTicketStatus($ticket, $completeCount);
if ($status !== (string) ($ticket['status'] ?? 'hidden')) {
Ticket::where('id', (int) $ticket['id'])->update([
'status' => $status,
'updatetime' => time(),
]);
$ticket['status'] = $status;
}
}
/**
* 是否处于允许同步/开启的时间窗口
*/
public function isWithinTimeWindow(Ticket $ticket, ?int $now = null): bool
{
$now = $now ?? time();
$start = $ticket['start_time'] ?? null;
$end = $ticket['end_time'] ?? null;
if ($start !== null && $start !== '' && (int) $start > 0 && $now < (int) $start) {
return false;
}
if ($end !== null && $end !== '' && (int) $end > 0 && $now > (int) $end) {
return false;
}
return true;
}
private function resolveTicketStatus(Ticket $ticket, int $completeCount): string
{
if (!$this->isWithinTimeWindow($ticket)) {
return 'hidden';
}
$ticketTotal = (int) ($ticket['ticket_total'] ?? 0);
if ($ticketTotal > 0) {
return $completeCount >= $ticketTotal ? 'hidden' : 'normal';
}
return 'normal';
}
}
+64
View File
@@ -0,0 +1,64 @@
<?php
declare(strict_types=1);
namespace app\common\service;
/**
* 工单同步互斥锁(基于 runtime 文件锁,不依赖 Cache/Redis 扩展)
*/
class SplitTicketSyncLockService
{
private const LOCK_TTL = 1800;
/**
* 尝试获取锁
*/
public function acquire(int $ticketId): bool
{
$path = $this->lockPath($ticketId);
if ($this->isStaleLock($path)) {
@unlink($path);
}
if (is_file($path)) {
return false;
}
$payload = json_encode([
'ticket_id' => $ticketId,
'pid' => getmypid(),
'time' => time(),
], JSON_UNESCAPED_UNICODE);
$written = @file_put_contents($path, $payload, LOCK_EX);
return $written !== false;
}
/**
* 释放锁
*/
public function release(int $ticketId): void
{
$path = $this->lockPath($ticketId);
if (is_file($path)) {
@unlink($path);
}
}
private function lockPath(int $ticketId): string
{
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (dirname(__DIR__, 3) . '/runtime/');
$dir = $runtime . 'split_ticket_sync/';
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
return $dir . $ticketId . '.lock';
}
private function isStaleLock(string $path): bool
{
if (!is_file($path)) {
return false;
}
$mtime = (int) @filemtime($path);
return $mtime > 0 && (time() - $mtime) > self::LOCK_TTL;
}
}
+114
View File
@@ -0,0 +1,114 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use think\Config;
/**
* 工单云控同步调试日志(仅 app_debug=true 时写入 runtime/log/split_sync.log
*/
class SplitTicketSyncLogger
{
private const LOG_FILE = 'split_sync.log';
private static ?string $ticketTag = null;
public static function isEnabled(): bool
{
return (bool) Config::get('app_debug');
}
public static function setTicketContext(?int $ticketId, ?string $ticketType = null): void
{
if ($ticketId === null || $ticketId <= 0) {
self::$ticketTag = null;
return;
}
$type = $ticketType !== null && $ticketType !== '' ? $ticketType : '-';
self::$ticketTag = sprintf('#%d(%s)', $ticketId, $type);
}
public static function clearTicketContext(): void
{
self::$ticketTag = null;
}
/**
* @param array<string, mixed> $context
*/
public static function log(string $stage, string $message, array $context = []): void
{
if (!self::isEnabled()) {
return;
}
$context = self::sanitize($context);
$ctxJson = $context !== [] ? ' ' . json_encode($context, JSON_UNESCAPED_UNICODE) : '';
$line = sprintf(
"[%s] %s [%s] %s%s\n",
date('Y-m-d H:i:s'),
self::$ticketTag ?? '[global]',
$stage,
$message,
$ctxJson
);
$runtime = defined('RUNTIME_PATH') ? RUNTIME_PATH : (ROOT_PATH . 'runtime/');
$dir = $runtime . 'log/';
if (!is_dir($dir)) {
@mkdir($dir, 0755, true);
}
@file_put_contents($dir . self::LOG_FILE, $line, FILE_APPEND | LOCK_EX);
}
/**
* @param array<string, mixed> $context
* @return array<string, mixed>
*/
private static function sanitize(array $context): array
{
$out = [];
foreach ($context as $key => $value) {
$lower = strtolower((string) $key);
if (in_array($lower, ['password', 'passwd', 'pwd', 'token', 'secret'], true)) {
continue;
}
if ($key === 'authActions' && is_array($value)) {
$out[$key] = self::sanitizeAuthActions($value);
continue;
}
if (is_array($value)) {
$out[$key] = self::sanitize($value);
continue;
}
if (is_string($value) && mb_strlen($value) > 800) {
$out[$key] = mb_substr($value, 0, 800, 'UTF-8') . '...(truncated)';
continue;
}
$out[$key] = $value;
}
return $out;
}
/**
* @param array<int, mixed> $actions
* @return array<int, mixed>
*/
private static function sanitizeAuthActions(array $actions): array
{
$sanitized = [];
foreach ($actions as $action) {
if (!is_array($action)) {
$sanitized[] = $action;
continue;
}
if (array_key_exists('value', $action)) {
$action['value'] = '***';
}
$sanitized[] = $action;
}
return $sanitized;
}
}
+320
View File
@@ -0,0 +1,320 @@
<?php
declare(strict_types=1);
namespace app\common\service;
use app\admin\model\split\Ticket;
use app\common\library\scrm\UnifiedScrmData;
use think\Db;
use think\Exception;
/**
* 分流工单云控数据同步服务
*/
class SplitTicketSyncService
{
private SplitTicketNumberSyncService $numberSync;
private SplitTicketRuleService $ruleService;
private SplitTicketSyncLockService $lockService;
public function __construct()
{
$this->numberSync = new SplitTicketNumberSyncService();
$this->ruleService = new SplitTicketRuleService();
$this->lockService = new SplitTicketSyncLockService();
}
/**
* 同步单条工单
*
* @return array{success:bool,message:string,skipped?:bool}
*/
public function syncOne(int $ticketId, bool $force = false): array
{
$ticket = Ticket::get($ticketId);
if (!$ticket) {
SplitTicketSyncLogger::log('sync', 'ticket not found', ['ticketId' => $ticketId]);
return ['success' => false, 'message' => '工单不存在'];
}
SplitTicketSyncLogger::setTicketContext($ticketId, (string) $ticket['ticket_type']);
SplitTicketSyncLogger::log('sync', 'syncOne start', [
'force' => $force,
'status' => (string) $ticket['status'],
'syncFailCount' => (int) ($ticket['sync_fail_count'] ?? 0),
'syncTime' => (int) ($ticket['sync_time'] ?? 0),
'pageUrl' => (string) $ticket['ticket_url'],
'nodeHost' => SplitSyncConfigService::getNodeHost(),
]);
if (!$force) {
$skip = $this->shouldSkip($ticket);
if ($skip !== null) {
SplitTicketSyncLogger::log('sync', 'skipped', ['reason' => $skip]);
SplitTicketSyncLogger::clearTicketContext();
return ['success' => false, 'message' => $skip, 'skipped' => true];
}
}
if (!$this->lockService->acquire($ticketId)) {
SplitTicketSyncLogger::log('sync', 'lock busy', ['ticketId' => $ticketId]);
SplitTicketSyncLogger::clearTicketContext();
return ['success' => false, 'message' => '工单正在同步中', 'skipped' => true];
}
try {
$result = $this->doSync($ticket);
SplitTicketSyncLogger::log('sync', 'syncOne end', $result);
return $result;
} finally {
$this->lockService->release($ticketId);
SplitTicketSyncLogger::clearTicketContext();
}
}
/**
* 扫描到期工单并同步
*/
public function syncDueTickets(): int
{
$count = 0;
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
$query = Ticket::where('status', 'normal');
if ($failThreshold > 0) {
$query->where('sync_fail_count', '<', $failThreshold);
}
$list = $query->select();
SplitTicketSyncLogger::log('cron', 'scan start', [
'candidateCount' => count($list),
]);
foreach ($list as $ticket) {
$skip = $this->shouldSkip($ticket);
if ($skip !== null) {
SplitTicketSyncLogger::log('cron', 'candidate skipped', [
'ticketId' => (int) $ticket['id'],
'ticketType' => (string) $ticket['ticket_type'],
'reason' => $skip,
]);
continue;
}
$result = $this->syncOne((int) $ticket['id'], false);
if (!empty($result['skipped'])) {
continue;
}
$count++;
}
SplitTicketSyncLogger::log('cron', 'scan end', ['processedCount' => $count]);
return $count;
}
/**
* @return array{success:bool,message:string}
*/
private function doSync(Ticket $ticket): array
{
$ticketType = (string) $ticket['ticket_type'];
$pageUrl = trim((string) $ticket['ticket_url']);
if ($pageUrl === '') {
SplitTicketSyncLogger::log('sync', 'empty pageUrl');
$this->markFailure($ticket, '工单链接为空');
return ['success' => false, 'message' => '工单链接为空'];
}
if (!SplitScrmSpiderFactory::isSupported($ticketType)) {
SplitTicketSyncLogger::log('sync', 'spider not supported', ['ticketType' => $ticketType]);
$this->markFailure($ticket, '工单类型尚未实现蜘蛛');
return ['success' => false, 'message' => '工单类型尚未实现蜘蛛'];
}
SplitTicketSyncLogger::log('sync', 'create spider', [
'ticketType' => $ticketType,
'hasAccount' => trim((string) ($ticket['account'] ?? '')) !== '',
]);
$spider = SplitScrmSpiderFactory::create(
$ticketType,
$pageUrl,
(string) ($ticket['account'] ?? ''),
(string) ($ticket['password'] ?? '')
);
if ($spider === null) {
$this->markFailure($ticket, '无法创建蜘蛛实例');
return ['success' => false, 'message' => '无法创建蜘蛛实例'];
}
Db::startTrans();
try {
SplitTicketSyncLogger::log('sync', 'spider run begin');
$finalData = $spider->run();
if (!$finalData instanceof UnifiedScrmData) {
throw new Exception('蜘蛛返回数据无效');
}
$this->numberSync->syncFromUnifiedData($ticket, $finalData);
$completeCount = max(0, $finalData->todayNewCount);
$this->ruleService->applyTicketStatusRules($ticket, $completeCount);
$freshTicket = Ticket::get((int) $ticket['id']) ?: $ticket;
if ((string) $freshTicket['status'] === 'hidden') {
$this->ruleService->cascadeTicketClosedToNumbers($freshTicket);
}
// 号码开关最后统一由 applyNumberRules 判定(单号上限/下号比率/云控在线)
$this->ruleService->applyNumberRules($freshTicket);
$ticket = $freshTicket;
$inboundCount = $this->numberSync->sumInboundForTicket($ticket);
$speed = $this->calcSpeedPerHour($ticket, $completeCount);
$payload = [
'complete_count' => $completeCount,
'inbound_count' => $inboundCount,
'speed_per_hour' => $speed['speed'],
'number_count' => max(0, $finalData->total),
'number_offline_count' => max(0, $finalData->totalOffline),
'number_banned_count' => 0,
'online_count' => max(0, $finalData->totalOnline),
'sync_fail_count' => 0,
'speed_snapshot_count' => $speed['snapshot_count'],
'speed_snapshot_time' => $speed['snapshot_time'],
];
$this->applySyncResult($ticket, $payload, true, '');
Db::commit();
SplitTicketSyncLogger::log('sync', 'db commit ok', $payload);
return ['success' => true, 'message' => '同步成功'];
} catch (\Throwable $e) {
Db::rollback();
$msg = mb_substr($e->getMessage(), 0, 255, 'UTF-8');
SplitTicketSyncLogger::log('sync', 'exception', [
'type' => get_class($e),
'message' => $msg,
'file' => $e->getFile(),
'line' => $e->getLine(),
]);
$this->markFailure($ticket, $msg);
return ['success' => false, 'message' => $msg];
}
}
private function shouldSkip(Ticket $ticket): ?string
{
if ((string) $ticket['status'] === 'hidden') {
return '工单已关闭';
}
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
if ($failThreshold > 0 && (int) ($ticket['sync_fail_count'] ?? 0) >= $failThreshold) {
return sprintf('连续同步失败超过%d次已暂停', $failThreshold);
}
if (!SplitScrmSpiderFactory::isSupported((string) $ticket['ticket_type'])) {
return '工单类型尚未实现';
}
$interval = SplitSyncConfigService::getIntervalMinutes((string) $ticket['ticket_type']);
if ($interval <= 0) {
return '该类型未配置自动同步周期';
}
$lastSync = (int) ($ticket['sync_time'] ?? 0);
$elapsed = $lastSync > 0 ? (time() - $lastSync) : null;
if ($lastSync > 0 && $elapsed !== null && $elapsed < ($interval * 60)) {
SplitTicketSyncLogger::log('sync', 'interval not reached', [
'intervalMinutes' => $interval,
'elapsedSeconds' => $elapsed,
'needSeconds' => $interval * 60,
]);
return '未到同步周期';
}
return null;
}
/**
* @param array<string, mixed> $payload
*/
public function applySyncResult(Ticket $ticket, array $payload, bool $success, string $message = ''): void
{
$data = [
'complete_count' => max(0, (int) ($payload['complete_count'] ?? 0)),
'inbound_count' => max(0, (int) ($payload['inbound_count'] ?? 0)),
'speed_per_hour' => max(0, (float) ($payload['speed_per_hour'] ?? 0)),
'number_count' => max(0, (int) ($payload['number_count'] ?? 0)),
'number_offline_count' => max(0, (int) ($payload['number_offline_count'] ?? 0)),
'number_banned_count' => max(0, (int) ($payload['number_banned_count'] ?? 0)),
'online_count' => max(0, (int) ($payload['online_count'] ?? 0)),
'sync_status' => $success ? 'success' : 'error',
'sync_time' => time(),
'sync_message' => $success ? '' : mb_substr($message, 0, 255, 'UTF-8'),
'sync_fail_count' => $success ? 0 : ((int) ($ticket['sync_fail_count'] ?? 0) + 1),
'speed_snapshot_count' => (int) ($payload['speed_snapshot_count'] ?? $ticket['speed_snapshot_count'] ?? 0),
'speed_snapshot_time' => (int) ($payload['speed_snapshot_time'] ?? $ticket['speed_snapshot_time'] ?? 0),
];
if (!$ticket->allowField(array_keys($data))->save($data)) {
throw new Exception('工单同步结果保存失败');
}
}
private function markFailure(Ticket $ticket, string $message): void
{
$failCount = (int) ($ticket['sync_fail_count'] ?? 0) + 1;
$failThreshold = SplitSyncConfigService::getFailPauseThreshold();
$previousSyncStatus = (string) ($ticket['sync_status'] ?? 'pending');
$neverSyncedSuccessfully = $previousSyncStatus === 'pending' && (int) ($ticket['sync_time'] ?? 0) <= 0;
$update = [
'sync_status' => 'error',
'sync_time' => time(),
'sync_message' => mb_substr($message, 0, 255, 'UTF-8'),
'sync_fail_count' => $failCount,
];
// 新建工单首次同步失败:立即关闭;已同步过的工单仍按连续失败阈值关闭
if ($neverSyncedSuccessfully || ($failThreshold > 0 && $failCount >= $failThreshold)) {
$update['status'] = 'hidden';
}
$ticket->save($update);
if (isset($update['status']) && $update['status'] === 'hidden') {
$fresh = Ticket::get((int) $ticket['id']);
if ($fresh) {
$this->ruleService->cascadeTicketClosedToNumbers($fresh);
}
}
}
/**
* @return array{speed:float,snapshot_count:int,snapshot_time:int}
*/
private function calcSpeedPerHour(Ticket $ticket, int $currentComplete): array
{
$now = time();
$snapshotTime = (int) ($ticket['speed_snapshot_time'] ?? 0);
$snapshotCount = (int) ($ticket['speed_snapshot_count'] ?? 0);
if ($snapshotTime <= 0) {
return [
'speed' => 0.0,
'snapshot_count' => $currentComplete,
'snapshot_time' => $now,
];
}
$elapsed = $now - $snapshotTime;
if ($elapsed >= 3600) {
return [
'speed' => 0.0,
'snapshot_count' => $currentComplete,
'snapshot_time' => $now,
];
}
$hours = $elapsed > 0 ? ($elapsed / 3600) : 0;
$delta = $currentComplete - $snapshotCount;
$speed = ($delta < 0 || $hours <= 0) ? 0.0 : round($delta / $hours, 2);
return [
'speed' => $speed,
'snapshot_count' => $snapshotCount,
'snapshot_time' => $snapshotTime,
];
}
}
+58
View File
@@ -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'] ?? []
),
];
}
}
+1
View File
@@ -0,0 +1 @@
页面未找到
+64
View File
@@ -0,0 +1,64 @@
{__NOLAYOUT__}<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<title>{:__('Warning')}</title>
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="shortcut icon" href="__CDN__/assets/img/favicon.ico" />
<style type="text/css">
*{box-sizing:border-box;margin:0;padding:0;font-family:Lantinghei SC,Open Sans,Arial,Hiragino Sans GB,Microsoft YaHei,"微软雅黑",STHeiti,WenQuanYi Micro Hei,SimSun,sans-serif;-webkit-font-smoothing:antialiased}
body{padding:70px 50px;background:#f4f6f8;font-weight:400;font-size:1pc;-webkit-text-size-adjust:none;color:#333}
a{outline:0;color:#3498db;text-decoration:none;cursor:pointer}
.system-message{margin:20px auto;padding:50px 0px;background:#fff;box-shadow:0 0 30px hsla(0,0%,39%,.06);text-align:center;width:100%;border-radius:2px;}
.system-message h1{margin:0;margin-bottom:9pt;color:#444;font-weight:400;font-size:30px}
.system-message .jump,.system-message .image{margin:20px 0;padding:0;padding:10px 0;font-weight:400}
.system-message .jump{font-size:14px}
.system-message .jump a{color:#333}
.system-message p{font-size:9pt;line-height:20px}
.system-message .btn{display:inline-block;margin-right:10px;width:138px;height:2pc;border:1px solid #44a0e8;border-radius:30px;color:#44a0e8;text-align:center;font-size:1pc;line-height:2pc;margin-bottom:5px;}
.success .btn{border-color:#69bf4e;color:#69bf4e}
.error .btn{border-color:#ff8992;color:#ff8992}
.info .btn{border-color:#3498db;color:#3498db}
.copyright p{width:100%;color:#919191;text-align:center;font-size:10px}
.system-message .btn-grey{border-color:#bbb;color:#bbb}
.clearfix:after{clear:both;display:block;visibility:hidden;height:0;content:"."}
@media (max-width:768px){body {padding:20px;}}
@media (max-width:480px){.system-message h1{font-size:30px;}}
</style>
</head>
<body>
{php}$codeText=$code == 1 ? 'success' : ($code == 0 ? 'error' : 'info');{/php}
<div class="system-message {$codeText}">
<div class="image">
<img src="__CDN__/assets/img/{$codeText}.svg" alt="" width="120" />
</div>
<h1>{$msg}</h1>
{if $url}
<p class="jump">
{:__('This page will be re-directed in %s seconds', '<span id="wait">' . $wait . '</span>')}
</p>
{/if}
<p class="clearfix">
<a href="__PUBLIC__" class="btn btn-grey">{:__('Go back')}</a>
{if $url}
<a id="href" href="{$url|htmlentities}" class="btn btn-primary">{:__('Jump now')}</a>
{/if}
</p>
</div>
{if $url}
<script type="text/javascript">
(function () {
var wait = document.getElementById('wait'),
href = document.getElementById('href').href;
var interval = setInterval(function () {
var time = --wait.innerHTML;
if (time <= 0) {
location.href = href;
clearInterval(interval);
}
}, 1000);
})();
</script>
{/if}
</body>
</html>
+101
View File
@@ -0,0 +1,101 @@
<?php
$cdnurl = function_exists('config') ? config('view_replace_str.__CDN__') : '';
$publicurl = function_exists('config') ? (config('view_replace_str.__PUBLIC__')?:'/') : '/';
$debug = function_exists('config') ? config('app_debug') : false;
$lang = [
'An error occurred' => '发生错误',
'Home' => '返回主页',
'Previous Page' => '返回上一页',
'The page you are looking for is temporarily unavailable' => '你所浏览的页面暂时无法访问',
'You can return to the previous page and try again' => '你可以返回上一页重试'
];
$langSet = '';
if (isset($_GET['lang'])) {
$langSet = strtolower($_GET['lang']);
} elseif (isset($_COOKIE['think_var'])) {
$langSet = strtolower($_COOKIE['think_var']);
} elseif (isset($_SERVER['HTTP_ACCEPT_LANGUAGE'])) {
preg_match('/^([a-z\d\-]+)/i', $_SERVER['HTTP_ACCEPT_LANGUAGE'], $matches);
$langSet = strtolower($matches[1] ?? '');
}
$langSet = $langSet && in_array($langSet, ['zh-cn', 'en']) ? $langSet : 'zh-cn';
$langSet == 'en' && $lang = array_combine(array_keys($lang), array_keys($lang));
?>
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html;charset=UTF-8">
<title><?=$lang['An error occurred']?></title>
<meta name="robots" content="noindex,nofollow" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<link rel="shortcut icon" href="<?php echo $cdnurl;?>/assets/img/favicon.ico" />
<style>
* {-moz-box-sizing:border-box;-webkit-box-sizing:border-box;box-sizing:border-box;}
html,body,div,span,object,iframe,h1,h2,h3,h4,h5,h6,p,blockquote,pre,abbr,address,cite,code,del,dfn,em,img,ins,kbd,q,samp,small,strong,sub,sup,var,b,i,dl,dt,dd,ol,ul,li,fieldset,form,label,legend,caption,article,aside,canvas,details,figcaption,figure,footer,header,hgroup,menu,nav,section,summary,time,mark,audio,video {margin:0;padding:0;border:0;outline:0;vertical-align:baseline;background:transparent;}
article,aside,details,figcaption,figure,footer,header,hgroup,nav,section {display:block;}
html {font-size:16px;line-height:24px;width:100%;height:100%;-webkit-text-size-adjust:100%;-ms-text-size-adjust:100%;overflow-y:scroll;overflow-x:hidden;}
img {vertical-align:middle;max-width:100%;height:auto;border:0;-ms-interpolation-mode:bicubic;}
body {min-height:100%;background:#f4f6f8;text-rendering:optimizeLegibility;-webkit-font-smoothing:antialiased;-moz-osx-font-smoothing:grayscale;font-family:"Helvetica Neue",Helvetica,"PingFang SC","Hiragino Sans GB","Microsoft YaHei",,Arial,sans-serif;}
.clearfix {clear:both;zoom:1;}
.clearfix:before,.clearfix:after {content:"\0020";display:block;height:0;visibility:hidden;}
.clearfix:after {clear:both;}
body.error-page-wrapper,.error-page-wrapper.preview {background-position:center center;background-repeat:no-repeat;background-size:cover;position:relative;}
.error-page-wrapper .content-container {border-radius:2px;text-align:center;box-shadow:0 0 30px rgba(99,99,99,0.06);padding:50px;background-color:#fff;width:100%;max-width:560px;position:absolute;left:50%;top:50%;margin-top:-220px;margin-left:-280px;}
.error-page-wrapper .content-container.in {left:0px;opacity:1;}
.error-page-wrapper .head-line {transition:color .2s linear;font-size:40px;line-height:60px;letter-spacing:-1px;margin-bottom:20px;color:#777;}
.error-page-wrapper .subheader {transition:color .2s linear;font-size:32px;line-height:46px;color:#494949;}
.error-page-wrapper .hr {height:1px;background-color:#eee;width:80%;max-width:350px;margin:25px auto;}
.error-page-wrapper .context {transition:color .2s linear;font-size:16px;line-height:27px;color:#aaa;}
.error-page-wrapper .context p {margin:0;}
.error-page-wrapper .context p:nth-child(n+2) {margin-top:16px;}
.error-page-wrapper .buttons-container {margin-top:35px;overflow:hidden;}
.error-page-wrapper .buttons-container a {transition:text-indent .2s ease-out,color .2s linear,background-color .2s linear;text-indent:0px;font-size:14px;text-transform:uppercase;text-decoration:none;color:#fff;background-color:#2ecc71;border-radius:99px;padding:8px 0 8px;text-align:center;display:inline-block;overflow:hidden;position:relative;width:45%;}
.error-page-wrapper .buttons-container a:hover {text-indent:15px;}
.error-page-wrapper .buttons-container a:nth-child(1) {float:left;}
.error-page-wrapper .buttons-container a:nth-child(2) {float:right;}
@media screen and (max-width:580px) {
.error-page-wrapper {padding:30px 5%;}
.error-page-wrapper .content-container {padding:37px;position:static;left:0;margin-top:0;margin-left:0;}
.error-page-wrapper .head-line {font-size:36px;}
.error-page-wrapper .subheader {font-size:27px;line-height:37px;}
.error-page-wrapper .hr {margin:30px auto;width:215px;}
}
@media screen and (max-width:450px) {
.error-page-wrapper {padding:30px;}
.error-page-wrapper .head-line {font-size:32px;}
.error-page-wrapper .hr {margin:25px auto;width:180px;}
.error-page-wrapper .context {font-size:15px;line-height:22px;}
.error-page-wrapper .context p:nth-child(n+2) {margin-top:10px;}
.error-page-wrapper .buttons-container {margin-top:29px;}
.error-page-wrapper .buttons-container a {float:none !important;width:65%;margin:0 auto;font-size:13px;padding:9px 0;}
.error-page-wrapper .buttons-container a:nth-child(2) {margin-top:12px;}
}
</style>
</head>
<body class="error-page-wrapper">
<div class="content-container">
<div class="head-line">
<img src="<?=$cdnurl?>/assets/img/error.svg" alt="" width="120"/>
</div>
<div class="subheader">
<?=$debug?$message:$lang['The page you are looking for is temporarily unavailable']?>
</div>
<div class="hr"></div>
<div class="context">
<p>
<?=$lang['You can return to the previous page and try again']?>
</p>
</div>
<div class="buttons-container">
<a href="<?=$publicurl?>"><?=$lang['Home']?></a>
<a href="javascript:" onclick="history.go(-1)"><?=$lang['Previous Page']?></a>
</div>
</div>
</body>
</html>