新增
This commit is contained in:
@@ -1,226 +1,226 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||
// | 开源版本可自由商用,可去除界面版权logo
|
||||
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||
// | 访问官网:https://www.likeadmin.cn
|
||||
// | likeadmin团队 版权所有 拥有最终解释权
|
||||
// +----------------------------------------------------------------------
|
||||
// | author: likeadminTeam
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
|
||||
namespace app\common\lists;
|
||||
|
||||
|
||||
use app\common\enum\ExportEnum;
|
||||
use app\common\service\JsonService;
|
||||
use app\common\validate\ListsValidate;
|
||||
use app\Request;
|
||||
use think\facade\Config;
|
||||
|
||||
/**
|
||||
* 数据列表基类
|
||||
* Class BaseDataLists
|
||||
* @package app\common\lists
|
||||
*/
|
||||
abstract class BaseDataLists implements ListsInterface
|
||||
{
|
||||
|
||||
use ListsSearchTrait;
|
||||
use ListsSortTrait;
|
||||
use ListsExcelTrait;
|
||||
|
||||
public Request $request; //请求对象
|
||||
|
||||
public int $pageNo; //页码
|
||||
public int $pageSize; //每页数量
|
||||
public int $limitOffset; //limit查询offset值
|
||||
public int $limitLength; //limit查询数量
|
||||
public int $pageSizeMax;
|
||||
public int $pageType = 0; //默认类型:0-一般分页;1-不分页,获取最大所有数据
|
||||
|
||||
|
||||
protected string $orderBy;
|
||||
protected string $field;
|
||||
|
||||
protected $startTime;
|
||||
protected $endTime;
|
||||
|
||||
protected $start;
|
||||
protected $end;
|
||||
|
||||
protected array $params;
|
||||
protected $sortOrder = [];
|
||||
|
||||
public string $export;
|
||||
|
||||
/**
|
||||
* 管理端列表:在 request 就绪后写入 adminId/adminInfo(见 BaseAdminDataLists)
|
||||
*/
|
||||
protected function initAdminIdentity(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
//参数验证
|
||||
(new ListsValidate())->get()->goCheck();
|
||||
|
||||
//请求参数设置
|
||||
$this->request = request();
|
||||
// admin 列表子类在 initExport 中可能触发 count/lists,须先于 initPage/initExport 写入身份
|
||||
$this->initAdminIdentity();
|
||||
$this->params = $this->request->param();
|
||||
|
||||
//分页初始化
|
||||
$this->initPage();
|
||||
|
||||
//搜索初始化
|
||||
$this->initSearch();
|
||||
|
||||
//排序初始化
|
||||
$this->initSort();
|
||||
|
||||
//导出初始化
|
||||
$this->initExport();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 分页参数初始化
|
||||
* @author 令狐冲
|
||||
* @date 2021/7/30 23:55
|
||||
*/
|
||||
private function initPage()
|
||||
{
|
||||
$this->pageSizeMax = Config::get('project.lists.page_size_max');
|
||||
$this->pageSize = Config::get('project.lists.page_size');
|
||||
$this->pageType = $this->request->get('page_type', 1);
|
||||
|
||||
if ($this->pageType == 1) {
|
||||
//分页
|
||||
$this->pageNo = $this->request->get('page_no', 1) ?: 1;
|
||||
$this->pageSize = $this->request->get('page_size', $this->pageSize) ?: $this->pageSize;
|
||||
} else {
|
||||
//不分页
|
||||
$this->pageNo = 1;//强制到第一页
|
||||
$this->pageSize = $this->pageSizeMax;// 直接取最大记录数
|
||||
}
|
||||
|
||||
//limit查询参数设置
|
||||
$this->limitOffset = ($this->pageNo - 1) * $this->pageSize;
|
||||
$this->limitLength = $this->pageSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 初始化搜索
|
||||
* @return array
|
||||
* @author 令狐冲
|
||||
* @date 2021/7/31 00:00
|
||||
*/
|
||||
private function initSearch()
|
||||
{
|
||||
if (!($this instanceof ListsSearchInterface)) {
|
||||
return [];
|
||||
}
|
||||
$startTime = $this->request->get('start_time');
|
||||
if ($startTime) {
|
||||
$this->startTime = strtotime($startTime);
|
||||
}
|
||||
|
||||
$endTime = $this->request->get('end_time');
|
||||
if ($endTime) {
|
||||
$this->endTime = strtotime($endTime);
|
||||
}
|
||||
|
||||
$this->start = $this->request->get('start');
|
||||
$this->end = $this->request->get('end');
|
||||
|
||||
return $this->searchWhere = $this->createWhere($this->setSearch());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 初始化排序
|
||||
* @return array|string[]
|
||||
* @author 令狐冲
|
||||
* @date 2021/7/31 00:03
|
||||
*/
|
||||
private function initSort()
|
||||
{
|
||||
if (!($this instanceof ListsSortInterface)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->field = $this->request->get('field', '');
|
||||
$this->orderBy = $this->request->get('order_by', '');
|
||||
|
||||
return $this->sortOrder = $this->createOrder($this->setSortFields(), $this->setDefaultOrder());
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 导出初始化
|
||||
* @return false|\think\response\Json
|
||||
* @author 令狐冲
|
||||
* @date 2021/7/31 01:15
|
||||
*/
|
||||
private function initExport()
|
||||
{
|
||||
$this->export = $this->request->get('export', '');
|
||||
|
||||
//不做导出操作
|
||||
if ($this->export != ExportEnum::INFO && $this->export != ExportEnum::EXPORT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
//导出操作,但是没有实现导出接口
|
||||
if (!($this instanceof ListsExcelInterface)) {
|
||||
return JsonService::throw('该列表不支持导出');
|
||||
}
|
||||
|
||||
$this->fileName = $this->request->get('file_name', '') ?: $this->setFileName();
|
||||
|
||||
//不导出文件,不初始化一下参数
|
||||
if ($this->export != ExportEnum::EXPORT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
//导出文件名设置
|
||||
$this->fileName .= '-' . date('Y-m-d-His') . '.xlsx';
|
||||
|
||||
//导出文件准备
|
||||
//指定导出范围(例:第2页到,第5页的数据)
|
||||
if ($this->pageType == 1) {
|
||||
$this->pageStart = $this->request->get('page_start', $this->pageStart);
|
||||
$this->pageEnd = $this->request->get('page_end', $this->pageEnd);
|
||||
//改变查询数量参数(例:第2页到,第5页的数据,查询->page(2,(5-2+1)*25)
|
||||
$this->limitOffset = ($this->pageStart - 1) * $this->pageSize;
|
||||
$this->limitLength = ($this->pageEnd - $this->pageStart + 1) * $this->pageSize;
|
||||
}
|
||||
|
||||
$count = $this->count();
|
||||
|
||||
//判断导出范围是否有数据
|
||||
if ($count == 0 || ceil($count / $this->pageSize) < $this->pageStart) {
|
||||
$msg = $this->pageType ? '第' . $this->pageStart . '页到第' . $this->pageEnd . '页没有数据,无法导出' : '没有数据,无法导出';
|
||||
return JsonService::throw($msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 不需要分页,可以调用此方法,无需查询第二次
|
||||
* @return int
|
||||
* @author 令狐冲
|
||||
* @date 2021/7/6 00:34
|
||||
*/
|
||||
public function defaultCount(): int
|
||||
{
|
||||
return count($this->lists());
|
||||
}
|
||||
|
||||
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||
// | 开源版本可自由商用,可去除界面版权logo
|
||||
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||
// | 访问官网:https://www.likeadmin.cn
|
||||
// | likeadmin团队 版权所有 拥有最终解释权
|
||||
// +----------------------------------------------------------------------
|
||||
// | author: likeadminTeam
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
|
||||
namespace app\common\lists;
|
||||
|
||||
|
||||
use app\common\enum\ExportEnum;
|
||||
use app\common\service\JsonService;
|
||||
use app\common\validate\ListsValidate;
|
||||
use app\Request;
|
||||
use think\facade\Config;
|
||||
|
||||
/**
|
||||
* 数据列表基类
|
||||
* Class BaseDataLists
|
||||
* @package app\common\lists
|
||||
*/
|
||||
abstract class BaseDataLists implements ListsInterface
|
||||
{
|
||||
|
||||
use ListsSearchTrait;
|
||||
use ListsSortTrait;
|
||||
use ListsExcelTrait;
|
||||
|
||||
public Request $request; //请求对象
|
||||
|
||||
public int $pageNo; //页码
|
||||
public int $pageSize; //每页数量
|
||||
public int $limitOffset; //limit查询offset值
|
||||
public int $limitLength; //limit查询数量
|
||||
public int $pageSizeMax;
|
||||
public int $pageType = 0; //默认类型:0-一般分页;1-不分页,获取最大所有数据
|
||||
|
||||
|
||||
protected string $orderBy;
|
||||
protected string $field;
|
||||
|
||||
protected $startTime;
|
||||
protected $endTime;
|
||||
|
||||
protected $start;
|
||||
protected $end;
|
||||
|
||||
protected array $params;
|
||||
protected $sortOrder = [];
|
||||
|
||||
public string $export;
|
||||
|
||||
/**
|
||||
* 管理端列表:在 request 就绪后写入 adminId/adminInfo(见 BaseAdminDataLists)
|
||||
*/
|
||||
protected function initAdminIdentity(): void
|
||||
{
|
||||
}
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
//参数验证
|
||||
(new ListsValidate())->get()->goCheck();
|
||||
|
||||
//请求参数设置
|
||||
$this->request = request();
|
||||
// admin 列表子类在 initExport 中可能触发 count/lists,须先于 initPage/initExport 写入身份
|
||||
$this->initAdminIdentity();
|
||||
$this->params = $this->request->param();
|
||||
|
||||
//分页初始化
|
||||
$this->initPage();
|
||||
|
||||
//搜索初始化
|
||||
$this->initSearch();
|
||||
|
||||
//排序初始化
|
||||
$this->initSort();
|
||||
|
||||
//导出初始化
|
||||
$this->initExport();
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 分页参数初始化
|
||||
* @author 令狐冲
|
||||
* @date 2021/7/30 23:55
|
||||
*/
|
||||
private function initPage()
|
||||
{
|
||||
$this->pageSizeMax = Config::get('project.lists.page_size_max');
|
||||
$this->pageSize = Config::get('project.lists.page_size');
|
||||
$this->pageType = $this->request->get('page_type', 1);
|
||||
|
||||
if ($this->pageType == 1) {
|
||||
//分页
|
||||
$this->pageNo = $this->request->get('page_no', 1) ?: 1;
|
||||
$this->pageSize = $this->request->get('page_size', $this->pageSize) ?: $this->pageSize;
|
||||
} else {
|
||||
//不分页
|
||||
$this->pageNo = 1;//强制到第一页
|
||||
$this->pageSize = $this->pageSizeMax;// 直接取最大记录数
|
||||
}
|
||||
|
||||
//limit查询参数设置
|
||||
$this->limitOffset = ($this->pageNo - 1) * $this->pageSize;
|
||||
$this->limitLength = $this->pageSize;
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 初始化搜索
|
||||
* @return array
|
||||
* @author 令狐冲
|
||||
* @date 2021/7/31 00:00
|
||||
*/
|
||||
private function initSearch()
|
||||
{
|
||||
if (!($this instanceof ListsSearchInterface)) {
|
||||
return [];
|
||||
}
|
||||
$startTime = $this->request->get('start_time');
|
||||
if ($startTime) {
|
||||
$this->startTime = strtotime($startTime);
|
||||
}
|
||||
|
||||
$endTime = $this->request->get('end_time');
|
||||
if ($endTime) {
|
||||
$this->endTime = strtotime($endTime);
|
||||
}
|
||||
|
||||
$this->start = $this->request->get('start');
|
||||
$this->end = $this->request->get('end');
|
||||
|
||||
return $this->searchWhere = $this->createWhere($this->setSearch());
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 初始化排序
|
||||
* @return array|string[]
|
||||
* @author 令狐冲
|
||||
* @date 2021/7/31 00:03
|
||||
*/
|
||||
private function initSort()
|
||||
{
|
||||
if (!($this instanceof ListsSortInterface)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$this->field = $this->request->get('field', '');
|
||||
$this->orderBy = $this->request->get('order_by', '');
|
||||
|
||||
return $this->sortOrder = $this->createOrder($this->setSortFields(), $this->setDefaultOrder());
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 导出初始化
|
||||
* @return false|\think\response\Json
|
||||
* @author 令狐冲
|
||||
* @date 2021/7/31 01:15
|
||||
*/
|
||||
private function initExport()
|
||||
{
|
||||
$this->export = $this->request->get('export', '');
|
||||
|
||||
//不做导出操作
|
||||
if ($this->export != ExportEnum::INFO && $this->export != ExportEnum::EXPORT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
//导出操作,但是没有实现导出接口
|
||||
if (!($this instanceof ListsExcelInterface)) {
|
||||
return JsonService::throw('该列表不支持导出');
|
||||
}
|
||||
|
||||
$this->fileName = $this->request->get('file_name', '') ?: $this->setFileName();
|
||||
|
||||
//不导出文件,不初始化一下参数
|
||||
if ($this->export != ExportEnum::EXPORT) {
|
||||
return false;
|
||||
}
|
||||
|
||||
//导出文件名设置
|
||||
$this->fileName .= '-' . date('Y-m-d-His') . '.xlsx';
|
||||
|
||||
//导出文件准备
|
||||
//指定导出范围(例:第2页到,第5页的数据)
|
||||
if ($this->pageType == 1) {
|
||||
$this->pageStart = $this->request->get('page_start', $this->pageStart);
|
||||
$this->pageEnd = $this->request->get('page_end', $this->pageEnd);
|
||||
//改变查询数量参数(例:第2页到,第5页的数据,查询->page(2,(5-2+1)*25)
|
||||
$this->limitOffset = ($this->pageStart - 1) * $this->pageSize;
|
||||
$this->limitLength = ($this->pageEnd - $this->pageStart + 1) * $this->pageSize;
|
||||
}
|
||||
|
||||
$count = $this->count();
|
||||
|
||||
//判断导出范围是否有数据
|
||||
if ($count == 0 || ceil($count / $this->pageSize) < $this->pageStart) {
|
||||
$msg = $this->pageType ? '第' . $this->pageStart . '页到第' . $this->pageEnd . '页没有数据,无法导出' : '没有数据,无法导出';
|
||||
return JsonService::throw($msg);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @notes 不需要分页,可以调用此方法,无需查询第二次
|
||||
* @return int
|
||||
* @author 令狐冲
|
||||
* @date 2021/7/6 00:34
|
||||
*/
|
||||
public function defaultCount(): int
|
||||
{
|
||||
return count($this->lists());
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
@@ -1,122 +1,122 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\lists\Traits;
|
||||
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use think\db\Query;
|
||||
|
||||
/**
|
||||
* 列表按「数据范围」过滤。
|
||||
*
|
||||
* 使用前置条件:宿主类须通过 BaseAdminDataLists 获得 $this->adminId 与 $this->adminInfo。
|
||||
*
|
||||
* 三种用法:
|
||||
* - applyDataScopeByOwner($q, 'creator_id') 直接按某列 IN
|
||||
* - applyDataScopeByOwnerColumns($q, ['creator_id', 'doctor_id']) 多列 OR
|
||||
* - applyDataScopeByExists($q, $sqlTemplate, 'owner_expr') 通过 exists 子查询(跨表)
|
||||
*/
|
||||
trait HasDataScopeFilter
|
||||
{
|
||||
/**
|
||||
* 单列过滤。null 表示豁免(ALL)。
|
||||
*/
|
||||
protected function applyDataScopeByOwner($query, string $ownerField): bool
|
||||
{
|
||||
if (!$this->dataScopeShouldApply()) {
|
||||
return false;
|
||||
}
|
||||
$ids = $this->getDataScopeVisibleAdminIds();
|
||||
if ($ids === null) {
|
||||
return false;
|
||||
}
|
||||
if ($ids === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return true;
|
||||
}
|
||||
$query->whereIn($ownerField, $ids);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 多列 OR 过滤(任意属主列命中即可)。
|
||||
*
|
||||
* @param string[] $ownerFields
|
||||
*/
|
||||
protected function applyDataScopeByOwnerColumns($query, array $ownerFields): bool
|
||||
{
|
||||
if (!$this->dataScopeShouldApply()) {
|
||||
return false;
|
||||
}
|
||||
$ids = $this->getDataScopeVisibleAdminIds();
|
||||
if ($ids === null) {
|
||||
return false;
|
||||
}
|
||||
if ($ids === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return true;
|
||||
}
|
||||
$query->where(function ($q) use ($ownerFields, $ids) {
|
||||
$first = true;
|
||||
foreach ($ownerFields as $f) {
|
||||
if ($first) {
|
||||
$q->whereIn($f, $ids);
|
||||
$first = false;
|
||||
} else {
|
||||
$q->whereOr(function ($qq) use ($f, $ids) {
|
||||
$qq->whereIn($f, $ids);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* exists 子查询方式。
|
||||
* $subSqlTemplate 内部可使用占位符 `__OWNER_IDS__`,将被替换成逗号分隔的整数列表。
|
||||
*/
|
||||
protected function applyDataScopeByExistsSql($query, string $subSqlTemplate): bool
|
||||
{
|
||||
if (!$this->dataScopeShouldApply()) {
|
||||
return false;
|
||||
}
|
||||
$ids = $this->getDataScopeVisibleAdminIds();
|
||||
if ($ids === null) {
|
||||
return false;
|
||||
}
|
||||
if ($ids === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return true;
|
||||
}
|
||||
$inList = implode(',', $ids);
|
||||
$sql = str_replace('__OWNER_IDS__', $inList, $subSqlTemplate);
|
||||
$query->whereExists($sql);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可见 admin id;null = 全部。
|
||||
*
|
||||
* @return array<int>|null
|
||||
*/
|
||||
protected function getDataScopeVisibleAdminIds(): ?array
|
||||
{
|
||||
$adminId = property_exists($this, 'adminId') ? (int) $this->adminId : 0;
|
||||
$adminInfo = property_exists($this, 'adminInfo') && is_array($this->adminInfo) ? $this->adminInfo : [];
|
||||
|
||||
return DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
}
|
||||
|
||||
protected function dataScopeShouldApply(): bool
|
||||
{
|
||||
return DataScopeService::isEnabled();
|
||||
}
|
||||
}
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\lists\Traits;
|
||||
|
||||
use app\common\service\DataScope\DataScopeService;
|
||||
use think\db\Query;
|
||||
|
||||
/**
|
||||
* 列表按「数据范围」过滤。
|
||||
*
|
||||
* 使用前置条件:宿主类须通过 BaseAdminDataLists 获得 $this->adminId 与 $this->adminInfo。
|
||||
*
|
||||
* 三种用法:
|
||||
* - applyDataScopeByOwner($q, 'creator_id') 直接按某列 IN
|
||||
* - applyDataScopeByOwnerColumns($q, ['creator_id', 'doctor_id']) 多列 OR
|
||||
* - applyDataScopeByExists($q, $sqlTemplate, 'owner_expr') 通过 exists 子查询(跨表)
|
||||
*/
|
||||
trait HasDataScopeFilter
|
||||
{
|
||||
/**
|
||||
* 单列过滤。null 表示豁免(ALL)。
|
||||
*/
|
||||
protected function applyDataScopeByOwner($query, string $ownerField): bool
|
||||
{
|
||||
if (!$this->dataScopeShouldApply()) {
|
||||
return false;
|
||||
}
|
||||
$ids = $this->getDataScopeVisibleAdminIds();
|
||||
if ($ids === null) {
|
||||
return false;
|
||||
}
|
||||
if ($ids === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return true;
|
||||
}
|
||||
$query->whereIn($ownerField, $ids);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 多列 OR 过滤(任意属主列命中即可)。
|
||||
*
|
||||
* @param string[] $ownerFields
|
||||
*/
|
||||
protected function applyDataScopeByOwnerColumns($query, array $ownerFields): bool
|
||||
{
|
||||
if (!$this->dataScopeShouldApply()) {
|
||||
return false;
|
||||
}
|
||||
$ids = $this->getDataScopeVisibleAdminIds();
|
||||
if ($ids === null) {
|
||||
return false;
|
||||
}
|
||||
if ($ids === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return true;
|
||||
}
|
||||
$query->where(function ($q) use ($ownerFields, $ids) {
|
||||
$first = true;
|
||||
foreach ($ownerFields as $f) {
|
||||
if ($first) {
|
||||
$q->whereIn($f, $ids);
|
||||
$first = false;
|
||||
} else {
|
||||
$q->whereOr(function ($qq) use ($f, $ids) {
|
||||
$qq->whereIn($f, $ids);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* exists 子查询方式。
|
||||
* $subSqlTemplate 内部可使用占位符 `__OWNER_IDS__`,将被替换成逗号分隔的整数列表。
|
||||
*/
|
||||
protected function applyDataScopeByExistsSql($query, string $subSqlTemplate): bool
|
||||
{
|
||||
if (!$this->dataScopeShouldApply()) {
|
||||
return false;
|
||||
}
|
||||
$ids = $this->getDataScopeVisibleAdminIds();
|
||||
if ($ids === null) {
|
||||
return false;
|
||||
}
|
||||
if ($ids === []) {
|
||||
$query->whereRaw('0 = 1');
|
||||
|
||||
return true;
|
||||
}
|
||||
$inList = implode(',', $ids);
|
||||
$sql = str_replace('__OWNER_IDS__', $inList, $subSqlTemplate);
|
||||
$query->whereExists($sql);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可见 admin id;null = 全部。
|
||||
*
|
||||
* @return array<int>|null
|
||||
*/
|
||||
protected function getDataScopeVisibleAdminIds(): ?array
|
||||
{
|
||||
$adminId = property_exists($this, 'adminId') ? (int) $this->adminId : 0;
|
||||
$adminInfo = property_exists($this, 'adminInfo') && is_array($this->adminInfo) ? $this->adminInfo : [];
|
||||
|
||||
return DataScopeService::getVisibleAdminIds($adminId, $adminInfo);
|
||||
}
|
||||
|
||||
protected function dataScopeShouldApply(): bool
|
||||
{
|
||||
return DataScopeService::isEnabled();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
/**
|
||||
* 物流查询日志模型
|
||||
*/
|
||||
class ExpressQueryLog extends BaseModel
|
||||
{
|
||||
protected $name = 'express_query_log';
|
||||
|
||||
// 表只有 create_time、无 update_time;config/database.php 全局 auto_timestamp=true,需显式关闭避免 Unknown column 'update_time'
|
||||
protected $autoWriteTimestamp = false;
|
||||
}
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
/**
|
||||
* 物流查询日志模型
|
||||
*/
|
||||
class ExpressQueryLog extends BaseModel
|
||||
{
|
||||
protected $name = 'express_query_log';
|
||||
|
||||
// 表只有 create_time、无 update_time;config/database.php 全局 auto_timestamp=true,需显式关闭避免 Unknown column 'update_time'
|
||||
protected $autoWriteTimestamp = false;
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
/**
|
||||
* 物流状态变更记录模型
|
||||
*/
|
||||
class ExpressStateLog extends BaseModel
|
||||
{
|
||||
protected $name = 'express_state_log';
|
||||
|
||||
// 表只有 create_time、无 update_time;config/database.php 全局 auto_timestamp=true,需显式关闭避免 Unknown column 'update_time'
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
/**
|
||||
* 关联主表
|
||||
*/
|
||||
public function tracking()
|
||||
{
|
||||
return $this->belongsTo(ExpressTracking::class, 'tracking_id', 'id');
|
||||
}
|
||||
}
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
/**
|
||||
* 物流状态变更记录模型
|
||||
*/
|
||||
class ExpressStateLog extends BaseModel
|
||||
{
|
||||
protected $name = 'express_state_log';
|
||||
|
||||
// 表只有 create_time、无 update_time;config/database.php 全局 auto_timestamp=true,需显式关闭避免 Unknown column 'update_time'
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
/**
|
||||
* 关联主表
|
||||
*/
|
||||
public function tracking()
|
||||
{
|
||||
return $this->belongsTo(ExpressTracking::class, 'tracking_id', 'id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
/**
|
||||
* 物流轨迹明细模型
|
||||
*/
|
||||
class ExpressTrace extends BaseModel
|
||||
{
|
||||
protected $name = 'express_trace';
|
||||
|
||||
// 表只有 create_time、无 update_time;config/database.php 全局 auto_timestamp=true,需显式关闭避免 Unknown column 'update_time'
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
/**
|
||||
* 关联主表
|
||||
*/
|
||||
public function tracking()
|
||||
{
|
||||
return $this->belongsTo(ExpressTracking::class, 'tracking_id', 'id');
|
||||
}
|
||||
}
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model;
|
||||
|
||||
/**
|
||||
* 物流轨迹明细模型
|
||||
*/
|
||||
class ExpressTrace extends BaseModel
|
||||
{
|
||||
protected $name = 'express_trace';
|
||||
|
||||
// 表只有 create_time、无 update_time;config/database.php 全局 auto_timestamp=true,需显式关闭避免 Unknown column 'update_time'
|
||||
protected $autoWriteTimestamp = false;
|
||||
|
||||
/**
|
||||
* 关联主表
|
||||
*/
|
||||
public function tracking()
|
||||
{
|
||||
return $this->belongsTo(ExpressTracking::class, 'tracking_id', 'id');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,33 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace app\common\model\doctor;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 药品库模型
|
||||
*/
|
||||
class Medicine extends BaseModel
|
||||
{
|
||||
protected $name = 'doctor_medicine';
|
||||
|
||||
// 设置字段信息
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'name' => 'string',
|
||||
'name_pinyin_abbr' => 'string',
|
||||
'supplier' => 'string',
|
||||
'unit' => 'string',
|
||||
'settlement_price' => 'float',
|
||||
'retail_price' => 'float',
|
||||
'stock' => 'int',
|
||||
'image' => 'string',
|
||||
'status' => 'int',
|
||||
'type' => 'string',
|
||||
'gid' => 'string',
|
||||
'remark' => 'string',
|
||||
'create_time' => 'int',
|
||||
'update_time' => 'int',
|
||||
'delete_time' => 'int',
|
||||
];
|
||||
}
|
||||
<?php
|
||||
|
||||
namespace app\common\model\doctor;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 药品库模型
|
||||
*/
|
||||
class Medicine extends BaseModel
|
||||
{
|
||||
protected $name = 'doctor_medicine';
|
||||
|
||||
// 设置字段信息
|
||||
protected $schema = [
|
||||
'id' => 'int',
|
||||
'name' => 'string',
|
||||
'name_pinyin_abbr' => 'string',
|
||||
'supplier' => 'string',
|
||||
'unit' => 'string',
|
||||
'settlement_price' => 'float',
|
||||
'retail_price' => 'float',
|
||||
'stock' => 'int',
|
||||
'image' => 'string',
|
||||
'status' => 'int',
|
||||
'type' => 'string',
|
||||
'gid' => 'string',
|
||||
'remark' => 'string',
|
||||
'create_time' => 'int',
|
||||
'update_time' => 'int',
|
||||
'delete_time' => 'int',
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,38 +1,38 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\tcm;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 中医处方单模型
|
||||
*/
|
||||
class Prescription extends BaseModel
|
||||
{
|
||||
protected $name = 'tcm_prescription';
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $createTime = 'create_time';
|
||||
protected $updateTime = 'update_time';
|
||||
protected $deleteTime = 'delete_time';
|
||||
protected $dateFormat = false;
|
||||
|
||||
protected $json = ['herbs', 'case_record', 'aux_usage'];
|
||||
protected $jsonAssoc = true;
|
||||
|
||||
// 字段类型转换
|
||||
protected $type = [
|
||||
'dosage_amount' => 'float',
|
||||
'dosage_bag_count' => 'integer',
|
||||
'need_decoction' => 'integer',
|
||||
];
|
||||
|
||||
// 追加字段
|
||||
protected $append = ['gender_desc'];
|
||||
|
||||
public function getGenderDescAttr($value, $data)
|
||||
{
|
||||
return ($data['gender'] ?? 0) == 1 ? '男' : '女';
|
||||
}
|
||||
}
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\tcm;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 中医处方单模型
|
||||
*/
|
||||
class Prescription extends BaseModel
|
||||
{
|
||||
protected $name = 'tcm_prescription';
|
||||
protected $autoWriteTimestamp = true;
|
||||
protected $createTime = 'create_time';
|
||||
protected $updateTime = 'update_time';
|
||||
protected $deleteTime = 'delete_time';
|
||||
protected $dateFormat = false;
|
||||
|
||||
protected $json = ['herbs', 'case_record', 'aux_usage'];
|
||||
protected $jsonAssoc = true;
|
||||
|
||||
// 字段类型转换
|
||||
protected $type = [
|
||||
'dosage_amount' => 'float',
|
||||
'dosage_bag_count' => 'integer',
|
||||
'need_decoction' => 'integer',
|
||||
];
|
||||
|
||||
// 追加字段
|
||||
protected $append = ['gender_desc'];
|
||||
|
||||
public function getGenderDescAttr($value, $data)
|
||||
{
|
||||
return ($data['gender'] ?? 0) == 1 ? '男' : '女';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\model\tcm;
|
||||
|
||||
use app\common\model\BaseModel;
|
||||
|
||||
/**
|
||||
* 处方库 AI 解释报告。
|
||||
*/
|
||||
class PrescriptionLibraryAiReport extends BaseModel
|
||||
{
|
||||
protected $name = 'prescription_library_ai_report';
|
||||
|
||||
protected $autoWriteTimestamp = true;
|
||||
}
|
||||
@@ -1,222 +1,222 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\DataScope;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\common\model\auth\SystemRole;
|
||||
use think\facade\Config;
|
||||
|
||||
/**
|
||||
* 数据范围(数据隔离)工具服务。
|
||||
*
|
||||
* 设计约定:
|
||||
* - ALL (1) = 全部数据,不附加过滤
|
||||
* - DEPT_AND_CHILD (2) = 本部门及所有子部门(取 admin 全部部门的并集)
|
||||
* - DEPT (3) = 仅本部门(取 admin 全部部门的并集,不含子孙)
|
||||
* - SELF (4) = 仅本人
|
||||
*
|
||||
* 多角色时取「最严格」可见范围 = data_scope 最大值(1=全部 … 4=仅本人),
|
||||
* 与常见「数据权限取交集」一致,避免挂了一个「全部」角色就把其它角色的部门范围冲掉。
|
||||
* root 管理员固定为 ALL。未挂任何部门时,范围退化为 SELF(可由 config 关闭)。
|
||||
*
|
||||
* 关键返回:`getVisibleAdminIds` 返回 int[](可见 admin_id 集合)或 null(ALL = 不过滤)。
|
||||
*/
|
||||
class DataScopeService
|
||||
{
|
||||
public const SCOPE_ALL = 1;
|
||||
public const SCOPE_DEPT_AND_CHILD = 2;
|
||||
public const SCOPE_DEPT = 3;
|
||||
public const SCOPE_SELF = 4;
|
||||
|
||||
/**
|
||||
* 计算当前 admin 的有效数据范围。
|
||||
*/
|
||||
public static function getEffectiveScope(array $adminInfo): int
|
||||
{
|
||||
if (!self::isEnabled()) {
|
||||
return self::SCOPE_ALL;
|
||||
}
|
||||
if ((int) ($adminInfo['root'] ?? 0) === 1) {
|
||||
return self::SCOPE_ALL;
|
||||
}
|
||||
$roleIds = self::normalizeRoleIds($adminInfo['role_id'] ?? null);
|
||||
$exempt = array_map('intval', Config::get('project.data_scope.exempt_roles', []) ?: []);
|
||||
if ($roleIds !== [] && array_intersect($roleIds, $exempt) !== []) {
|
||||
return self::SCOPE_ALL;
|
||||
}
|
||||
if ($roleIds === []) {
|
||||
return self::SCOPE_SELF;
|
||||
}
|
||||
$scopes = SystemRole::whereIn('id', $roleIds)
|
||||
->whereNull('delete_time')
|
||||
->column('data_scope');
|
||||
$scopes = array_values(array_filter(array_map('intval', $scopes), static function (int $v): bool {
|
||||
return $v >= self::SCOPE_ALL && $v <= self::SCOPE_SELF;
|
||||
}));
|
||||
// 角色存在但库中无有效 data_scope(缺失/脏数据/已删角色):宁可收窄到「仅本人」,避免误放开到全站
|
||||
if ($scopes === []) {
|
||||
return self::SCOPE_SELF;
|
||||
}
|
||||
|
||||
return (int) max($scopes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一解析 token/cache 中的 role_id(数组 | 单整数 | JSON 字符串)。
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private static function normalizeRoleIds(mixed $raw): array
|
||||
{
|
||||
if ($raw === null || $raw === '') {
|
||||
return [];
|
||||
}
|
||||
if (\is_int($raw) || \is_float($raw)) {
|
||||
$v = (int) $raw;
|
||||
|
||||
return $v > 0 ? [$v] : [];
|
||||
}
|
||||
if (\is_string($raw) && is_numeric($raw)) {
|
||||
$v = (int) $raw;
|
||||
|
||||
return $v > 0 ? [$v] : [];
|
||||
}
|
||||
if (\is_string($raw)) {
|
||||
$decoded = json_decode($raw, true);
|
||||
if (\is_array($decoded)) {
|
||||
$raw = $decoded;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (!\is_array($raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
static fn ($v): int => (int) $v,
|
||||
$raw
|
||||
), static fn (int $v): bool => $v > 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 可见 admin id 集合;null = 不过滤(ALL)
|
||||
*
|
||||
* @return array<int>|null
|
||||
*/
|
||||
public static function getVisibleAdminIds(int $adminId, array $adminInfo): ?array
|
||||
{
|
||||
$scope = self::getEffectiveScope($adminInfo);
|
||||
if ($scope === self::SCOPE_ALL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($scope === self::SCOPE_SELF) {
|
||||
return $adminId > 0 ? [$adminId] : [];
|
||||
}
|
||||
|
||||
$myDeptIds = AdminDept::where('admin_id', $adminId)->column('dept_id');
|
||||
$myDeptIds = array_values(array_filter(array_map('intval', $myDeptIds), static function (int $v): bool {
|
||||
return $v > 0;
|
||||
}));
|
||||
|
||||
if ($myDeptIds === []) {
|
||||
$fallback = (bool) Config::get('project.data_scope.no_dept_fallback_self', true);
|
||||
|
||||
return $fallback ? [$adminId] : [];
|
||||
}
|
||||
|
||||
$targetDeptIds = [];
|
||||
if ($scope === self::SCOPE_DEPT) {
|
||||
$targetDeptIds = $myDeptIds;
|
||||
} else {
|
||||
foreach ($myDeptIds as $did) {
|
||||
foreach (DeptLogic::getSelfAndDescendantIds($did) as $id) {
|
||||
$id = (int) $id;
|
||||
if ($id > 0) {
|
||||
$targetDeptIds[$id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$targetDeptIds = array_keys($targetDeptIds);
|
||||
}
|
||||
|
||||
if ($targetDeptIds === []) {
|
||||
return $adminId > 0 ? [$adminId] : [];
|
||||
}
|
||||
|
||||
$ids = AdminDept::whereIn('dept_id', $targetDeptIds)->column('admin_id');
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static function (int $v): bool {
|
||||
return $v > 0;
|
||||
})));
|
||||
if ($adminId > 0 && !in_array($adminId, $ids, true)) {
|
||||
$ids[] = $adminId;
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
return (bool) Config::get('project.data_scope.enabled', true);
|
||||
}
|
||||
|
||||
public static function isAll(array $adminInfo): bool
|
||||
{
|
||||
return self::getEffectiveScope($adminInfo) === self::SCOPE_ALL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据范围下:可见成员所在部门及其下级部门 id(与业绩看板 deptOptions、部门类下拉收窄一致)。
|
||||
*
|
||||
* @return array<int, true>|null null 表示不限制;[] 表示无可选部门
|
||||
*/
|
||||
public static function getAllowedDeptIdSet(int $adminId, array $adminInfo): ?array
|
||||
{
|
||||
if ($adminId <= 0 || !self::isEnabled()) {
|
||||
return null;
|
||||
}
|
||||
$visibleIds = self::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleIds === null) {
|
||||
return null;
|
||||
}
|
||||
if ($visibleIds === []) {
|
||||
return [];
|
||||
}
|
||||
$set = [];
|
||||
foreach ($visibleIds as $aid) {
|
||||
$deptRows = AdminDept::where('admin_id', (int) $aid)->column('dept_id');
|
||||
foreach ($deptRows as $d) {
|
||||
$d = (int) $d;
|
||||
if ($d <= 0) {
|
||||
continue;
|
||||
}
|
||||
foreach (DeptLogic::getSelfAndDescendantIds($d) as $x) {
|
||||
$x = (int) $x;
|
||||
if ($x > 0) {
|
||||
$set[$x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $set;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文字描述(日志 / 接口返回可选使用)
|
||||
*/
|
||||
public static function scopeLabel(int $scope): string
|
||||
{
|
||||
return [
|
||||
self::SCOPE_ALL => '全部',
|
||||
self::SCOPE_DEPT_AND_CHILD => '本部门及下级',
|
||||
self::SCOPE_DEPT => '仅本部门',
|
||||
self::SCOPE_SELF => '仅本人',
|
||||
][$scope] ?? '全部';
|
||||
}
|
||||
}
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service\DataScope;
|
||||
|
||||
use app\adminapi\logic\dept\DeptLogic;
|
||||
use app\common\model\auth\AdminDept;
|
||||
use app\common\model\auth\AdminRole;
|
||||
use app\common\model\auth\SystemRole;
|
||||
use think\facade\Config;
|
||||
|
||||
/**
|
||||
* 数据范围(数据隔离)工具服务。
|
||||
*
|
||||
* 设计约定:
|
||||
* - ALL (1) = 全部数据,不附加过滤
|
||||
* - DEPT_AND_CHILD (2) = 本部门及所有子部门(取 admin 全部部门的并集)
|
||||
* - DEPT (3) = 仅本部门(取 admin 全部部门的并集,不含子孙)
|
||||
* - SELF (4) = 仅本人
|
||||
*
|
||||
* 多角色时取「最严格」可见范围 = data_scope 最大值(1=全部 … 4=仅本人),
|
||||
* 与常见「数据权限取交集」一致,避免挂了一个「全部」角色就把其它角色的部门范围冲掉。
|
||||
* root 管理员固定为 ALL。未挂任何部门时,范围退化为 SELF(可由 config 关闭)。
|
||||
*
|
||||
* 关键返回:`getVisibleAdminIds` 返回 int[](可见 admin_id 集合)或 null(ALL = 不过滤)。
|
||||
*/
|
||||
class DataScopeService
|
||||
{
|
||||
public const SCOPE_ALL = 1;
|
||||
public const SCOPE_DEPT_AND_CHILD = 2;
|
||||
public const SCOPE_DEPT = 3;
|
||||
public const SCOPE_SELF = 4;
|
||||
|
||||
/**
|
||||
* 计算当前 admin 的有效数据范围。
|
||||
*/
|
||||
public static function getEffectiveScope(array $adminInfo): int
|
||||
{
|
||||
if (!self::isEnabled()) {
|
||||
return self::SCOPE_ALL;
|
||||
}
|
||||
if ((int) ($adminInfo['root'] ?? 0) === 1) {
|
||||
return self::SCOPE_ALL;
|
||||
}
|
||||
$roleIds = self::normalizeRoleIds($adminInfo['role_id'] ?? null);
|
||||
$exempt = array_map('intval', Config::get('project.data_scope.exempt_roles', []) ?: []);
|
||||
if ($roleIds !== [] && array_intersect($roleIds, $exempt) !== []) {
|
||||
return self::SCOPE_ALL;
|
||||
}
|
||||
if ($roleIds === []) {
|
||||
return self::SCOPE_SELF;
|
||||
}
|
||||
$scopes = SystemRole::whereIn('id', $roleIds)
|
||||
->whereNull('delete_time')
|
||||
->column('data_scope');
|
||||
$scopes = array_values(array_filter(array_map('intval', $scopes), static function (int $v): bool {
|
||||
return $v >= self::SCOPE_ALL && $v <= self::SCOPE_SELF;
|
||||
}));
|
||||
// 角色存在但库中无有效 data_scope(缺失/脏数据/已删角色):宁可收窄到「仅本人」,避免误放开到全站
|
||||
if ($scopes === []) {
|
||||
return self::SCOPE_SELF;
|
||||
}
|
||||
|
||||
return (int) max($scopes);
|
||||
}
|
||||
|
||||
/**
|
||||
* 统一解析 token/cache 中的 role_id(数组 | 单整数 | JSON 字符串)。
|
||||
*
|
||||
* @return int[]
|
||||
*/
|
||||
private static function normalizeRoleIds(mixed $raw): array
|
||||
{
|
||||
if ($raw === null || $raw === '') {
|
||||
return [];
|
||||
}
|
||||
if (\is_int($raw) || \is_float($raw)) {
|
||||
$v = (int) $raw;
|
||||
|
||||
return $v > 0 ? [$v] : [];
|
||||
}
|
||||
if (\is_string($raw) && is_numeric($raw)) {
|
||||
$v = (int) $raw;
|
||||
|
||||
return $v > 0 ? [$v] : [];
|
||||
}
|
||||
if (\is_string($raw)) {
|
||||
$decoded = json_decode($raw, true);
|
||||
if (\is_array($decoded)) {
|
||||
$raw = $decoded;
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
if (!\is_array($raw)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map(
|
||||
static fn ($v): int => (int) $v,
|
||||
$raw
|
||||
), static fn (int $v): bool => $v > 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* 可见 admin id 集合;null = 不过滤(ALL)
|
||||
*
|
||||
* @return array<int>|null
|
||||
*/
|
||||
public static function getVisibleAdminIds(int $adminId, array $adminInfo): ?array
|
||||
{
|
||||
$scope = self::getEffectiveScope($adminInfo);
|
||||
if ($scope === self::SCOPE_ALL) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($scope === self::SCOPE_SELF) {
|
||||
return $adminId > 0 ? [$adminId] : [];
|
||||
}
|
||||
|
||||
$myDeptIds = AdminDept::where('admin_id', $adminId)->column('dept_id');
|
||||
$myDeptIds = array_values(array_filter(array_map('intval', $myDeptIds), static function (int $v): bool {
|
||||
return $v > 0;
|
||||
}));
|
||||
|
||||
if ($myDeptIds === []) {
|
||||
$fallback = (bool) Config::get('project.data_scope.no_dept_fallback_self', true);
|
||||
|
||||
return $fallback ? [$adminId] : [];
|
||||
}
|
||||
|
||||
$targetDeptIds = [];
|
||||
if ($scope === self::SCOPE_DEPT) {
|
||||
$targetDeptIds = $myDeptIds;
|
||||
} else {
|
||||
foreach ($myDeptIds as $did) {
|
||||
foreach (DeptLogic::getSelfAndDescendantIds($did) as $id) {
|
||||
$id = (int) $id;
|
||||
if ($id > 0) {
|
||||
$targetDeptIds[$id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
$targetDeptIds = array_keys($targetDeptIds);
|
||||
}
|
||||
|
||||
if ($targetDeptIds === []) {
|
||||
return $adminId > 0 ? [$adminId] : [];
|
||||
}
|
||||
|
||||
$ids = AdminDept::whereIn('dept_id', $targetDeptIds)->column('admin_id');
|
||||
$ids = array_values(array_unique(array_filter(array_map('intval', $ids), static function (int $v): bool {
|
||||
return $v > 0;
|
||||
})));
|
||||
if ($adminId > 0 && !in_array($adminId, $ids, true)) {
|
||||
$ids[] = $adminId;
|
||||
}
|
||||
|
||||
return $ids;
|
||||
}
|
||||
|
||||
public static function isEnabled(): bool
|
||||
{
|
||||
return (bool) Config::get('project.data_scope.enabled', true);
|
||||
}
|
||||
|
||||
public static function isAll(array $adminInfo): bool
|
||||
{
|
||||
return self::getEffectiveScope($adminInfo) === self::SCOPE_ALL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据范围下:可见成员所在部门及其下级部门 id(与业绩看板 deptOptions、部门类下拉收窄一致)。
|
||||
*
|
||||
* @return array<int, true>|null null 表示不限制;[] 表示无可选部门
|
||||
*/
|
||||
public static function getAllowedDeptIdSet(int $adminId, array $adminInfo): ?array
|
||||
{
|
||||
if ($adminId <= 0 || !self::isEnabled()) {
|
||||
return null;
|
||||
}
|
||||
$visibleIds = self::getVisibleAdminIds($adminId, $adminInfo);
|
||||
if ($visibleIds === null) {
|
||||
return null;
|
||||
}
|
||||
if ($visibleIds === []) {
|
||||
return [];
|
||||
}
|
||||
$set = [];
|
||||
foreach ($visibleIds as $aid) {
|
||||
$deptRows = AdminDept::where('admin_id', (int) $aid)->column('dept_id');
|
||||
foreach ($deptRows as $d) {
|
||||
$d = (int) $d;
|
||||
if ($d <= 0) {
|
||||
continue;
|
||||
}
|
||||
foreach (DeptLogic::getSelfAndDescendantIds($d) as $x) {
|
||||
$x = (int) $x;
|
||||
if ($x > 0) {
|
||||
$set[$x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $set;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文字描述(日志 / 接口返回可选使用)
|
||||
*/
|
||||
public static function scopeLabel(int $scope): string
|
||||
{
|
||||
return [
|
||||
self::SCOPE_ALL => '全部',
|
||||
self::SCOPE_DEPT_AND_CHILD => '本部门及下级',
|
||||
self::SCOPE_DEPT => '仅本部门',
|
||||
self::SCOPE_SELF => '仅本人',
|
||||
][$scope] ?? '全部';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
/**
|
||||
* Dify Chat App blocking 客户端。
|
||||
*
|
||||
* 只接受服务端配置中的模型 profile,避免把上游地址和密钥暴露给前端。
|
||||
*/
|
||||
class DifyChatService
|
||||
{
|
||||
/**
|
||||
* @param array<string,mixed> $inputs
|
||||
* @return array{ok:bool,content?:string,message_id?:string,latency_ms?:int,error_code?:string,error?:string}
|
||||
*/
|
||||
public static function chat(string $profile, array $inputs, string $query, string $user): array
|
||||
{
|
||||
$config = config('prescription_ai') ?: [];
|
||||
if (empty($config['enable'])) {
|
||||
return self::error('CONFIG_DISABLED', '处方 AI 解释未启用');
|
||||
}
|
||||
|
||||
$modelConfig = $config['models'][$profile] ?? null;
|
||||
if (!is_array($modelConfig)) {
|
||||
return self::error('INVALID_PROFILE', '不支持的 AI 模型');
|
||||
}
|
||||
|
||||
$baseUrl = trim((string) ($config['base_url'] ?? ''));
|
||||
$apiKey = trim((string) ($modelConfig['api_key'] ?? ''));
|
||||
if ($baseUrl === '' || $apiKey === '') {
|
||||
return self::error('CONFIG_MISSING', '该模型尚未配置 Dify 地址或 App Key');
|
||||
}
|
||||
if (!function_exists('curl_init')) {
|
||||
return self::error('CURL_UNAVAILABLE', '服务器尚未启用 cURL 扩展');
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'inputs' => $inputs,
|
||||
'query' => $query,
|
||||
'response_mode' => 'blocking',
|
||||
'user' => $user,
|
||||
];
|
||||
$body = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_INVALID_UTF8_SUBSTITUTE);
|
||||
if ($body === false) {
|
||||
return self::error('REQUEST_BUILD_FAILED', '处方数据编码失败');
|
||||
}
|
||||
|
||||
$timeout = max(10, min(120, (int) ($config['timeout'] ?? 90)));
|
||||
$ch = curl_init();
|
||||
if ($ch === false) {
|
||||
return self::error('CURL_INIT_FAILED', '无法初始化 AI 请求');
|
||||
}
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_URL => self::buildEndpoint($baseUrl),
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $body,
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_CONNECTTIMEOUT => min(8, max(3, (int) ceil($timeout / 4))),
|
||||
CURLOPT_TIMEOUT => $timeout,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
CURLOPT_SSL_VERIFYHOST => 2,
|
||||
CURLOPT_HTTPHEADER => [
|
||||
'Content-Type: application/json',
|
||||
'Accept: application/json',
|
||||
'Authorization: Bearer ' . $apiKey,
|
||||
],
|
||||
]);
|
||||
|
||||
$startedAt = microtime(true);
|
||||
$responseBody = curl_exec($ch);
|
||||
$errno = curl_errno($ch);
|
||||
$httpCode = (int) curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
$latencyMs = (int) round((microtime(true) - $startedAt) * 1000);
|
||||
|
||||
if ($errno !== 0) {
|
||||
if ($errno === CURLE_OPERATION_TIMEDOUT) {
|
||||
return self::error('UPSTREAM_TIMEOUT', '模型响应超时,请稍后重试', $latencyMs);
|
||||
}
|
||||
return self::error('UPSTREAM_UNAVAILABLE', '暂时无法连接 AI 服务,请稍后重试', $latencyMs);
|
||||
}
|
||||
|
||||
$decoded = json_decode((string) $responseBody, true);
|
||||
if ($httpCode === 401 || $httpCode === 403) {
|
||||
return self::error('CONFIG_INVALID', '模型 App Key 无效或无权限', $latencyMs);
|
||||
}
|
||||
if ($httpCode === 429 || $httpCode >= 500) {
|
||||
return self::error('UPSTREAM_BUSY', '模型服务繁忙,请稍后重试', $latencyMs);
|
||||
}
|
||||
if ($httpCode >= 400) {
|
||||
return self::error('UPSTREAM_REJECTED', '模型未能处理本次请求', $latencyMs);
|
||||
}
|
||||
if (!is_array($decoded)) {
|
||||
return self::error('INVALID_RESPONSE', '模型返回格式异常,请重试', $latencyMs);
|
||||
}
|
||||
|
||||
$answer = trim((string) ($decoded['answer'] ?? ''));
|
||||
if ($answer === '') {
|
||||
return self::error('EMPTY_RESPONSE', '模型未返回报告内容,请重试', $latencyMs);
|
||||
}
|
||||
|
||||
return [
|
||||
'ok' => true,
|
||||
'content' => $answer,
|
||||
'message_id' => (string) ($decoded['message_id'] ?? ''),
|
||||
'latency_ms' => $latencyMs,
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildEndpoint(string $baseUrl): string
|
||||
{
|
||||
$baseUrl = rtrim($baseUrl, '/');
|
||||
if (str_ends_with($baseUrl, '/chat-messages')) {
|
||||
return $baseUrl;
|
||||
}
|
||||
if (str_ends_with($baseUrl, '/v1')) {
|
||||
return $baseUrl . '/chat-messages';
|
||||
}
|
||||
return $baseUrl . '/v1/chat-messages';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{ok:false,error_code:string,error:string,latency_ms:int}
|
||||
*/
|
||||
private static function error(string $code, string $message, int $latencyMs = 0): array
|
||||
{
|
||||
return [
|
||||
'ok' => false,
|
||||
'error_code' => $code,
|
||||
'error' => $message,
|
||||
'latency_ms' => $latencyMs,
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -1,497 +1,497 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use think\facade\Config;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 快递轨迹:顺丰(shunfeng)、京东(jd),优先走快递100;未配置时返回官网查询链接
|
||||
*/
|
||||
class ExpressTrackService
|
||||
{
|
||||
private const KUAIDI_COM_SF = 'shunfeng';
|
||||
|
||||
private const KUAIDI_COM_JD = 'jingdong'; // 京东快递(快递100编码)
|
||||
|
||||
private const KUAIDI_COM_JT = 'jtexpress'; // 极兔速递
|
||||
|
||||
/**
|
||||
* @param string $phoneTailOverride 手工填写的收件电话(仅数字;完整 11 位或与面单一致的后四位等),优先于订单收货手机
|
||||
*
|
||||
* @return array{
|
||||
* carrier: string,
|
||||
* carrier_label: string,
|
||||
* kuaidi_com: string,
|
||||
* traces: list<array{time:string,context:string}>,
|
||||
* state: string,
|
||||
* state_text: string,
|
||||
* source: string,
|
||||
* hint: string,
|
||||
* official_url: string
|
||||
* }
|
||||
*/
|
||||
public static function query(string $expressCompany, string $trackingNumber, string $recipientPhone = '', string $phoneTailOverride = ''): array
|
||||
{
|
||||
$num = trim($trackingNumber);
|
||||
$overrideDigits = preg_replace('/\D/', '', $phoneTailOverride) ?? '';
|
||||
$recipientDigits = preg_replace('/\D/', '', $recipientPhone) ?? '';
|
||||
// 快递100 文档:phone 为收/寄件人电话;顺丰等必填。示例为完整 11 位手机号,仅传后四位易触发 408「验证码错误」
|
||||
$phoneForKuaidi = self::buildKuaidiPhoneParam($overrideDigits, $recipientDigits);
|
||||
|
||||
$resolved = self::resolveCarrier($expressCompany, $num);
|
||||
$carrier = $resolved['carrier'];
|
||||
$kuaidiCom = $resolved['kuaidi_com'];
|
||||
$label = $resolved['label'];
|
||||
$comCandidates = self::kuaidiComCandidates($kuaidiCom, $num);
|
||||
|
||||
$officialUrl = self::buildOfficialUrl($carrier, $num);
|
||||
|
||||
$out = [
|
||||
'carrier' => $carrier,
|
||||
'carrier_label' => $label,
|
||||
'kuaidi_com' => $kuaidiCom,
|
||||
'traces' => [],
|
||||
'state' => '',
|
||||
'state_text' => '',
|
||||
'source' => 'official_only',
|
||||
'hint' => '',
|
||||
'official_url' => $officialUrl,
|
||||
];
|
||||
|
||||
$cfg = Config::get('logistics.kuaidi100', []);
|
||||
$enable = !empty($cfg['enable']);
|
||||
|
||||
$result = $out;
|
||||
if (! $enable) {
|
||||
$result['hint'] = '未配置快递100查询密钥或已关闭(LOGISTICS_KUAIDI100_DISABLE),仅可打开官网查件。请在 .env 中配置 LOGISTICS_KUAIDI100_CUSTOMER、LOGISTICS_KUAIDI100_KEY';
|
||||
} elseif ($phoneForKuaidi === '' && self::kuaidiPhoneRequired($kuaidiCom)) {
|
||||
// 顺丰(及快递100 要求电话的承运商)无 phone 时不请求接口,避免无效调用
|
||||
$result['hint'] = '顺丰查询需在快递100 中同时提交单号与收/寄件人电话(可与面单一致的完整手机号或后四位)。请填写收件电话后点「刷新轨迹」。';
|
||||
} else {
|
||||
$matched = null;
|
||||
$lastFail = null;
|
||||
foreach ($comCandidates as $tryCom) {
|
||||
$tryOut = self::queryKuaidiOnce($cfg, $tryCom, $num, $phoneForKuaidi, $carrier, $label);
|
||||
if (!empty($tryOut['traces']) || ($tryOut['state'] ?? '') !== '') {
|
||||
$matched = $tryOut;
|
||||
break;
|
||||
}
|
||||
$lastFail = $tryOut;
|
||||
}
|
||||
$result = $matched ?? $lastFail ?? $out;
|
||||
}
|
||||
|
||||
// 京东自营单(JDVE…)兜底:快递100 无轨迹/陈旧时,用京东官方接口(更新或更全才采用)。
|
||||
// 未配置京东官方接口时 isConfigured()=false,本段跳过,行为与原先一致。
|
||||
if ($carrier === 'jd' && JdLogisticsService::isConfigured()) {
|
||||
try {
|
||||
$jdPhone = $overrideDigits !== '' ? $overrideDigits : $recipientDigits;
|
||||
$jd = JdLogisticsService::queryTrace($num, $jdPhone);
|
||||
if ($jd !== null && !empty($jd['traces']) && self::jdResultPreferred($jd, $result)) {
|
||||
$result['traces'] = $jd['traces'];
|
||||
$result['state'] = (string) $jd['state'];
|
||||
$result['state_text'] = (string) $jd['state_text'];
|
||||
$result['source'] = 'jd_official';
|
||||
$result['hint'] = '';
|
||||
// carrier / carrier_label / official_url / kuaidi_com 保留原值
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('ExpressTrackService jd official fallback failed', [
|
||||
'num' => $num,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 京东官方轨迹是否应优先于快递100 结果采用:
|
||||
* 快递100 无轨迹 → 直接用;否则京东更「新」(最新轨迹时间更晚)或同样新但条目更多 → 用。
|
||||
*
|
||||
* @param array{traces?:array,newest_unix?:int} $jd
|
||||
* @param array{traces?:array} $kuaidi
|
||||
*/
|
||||
private static function jdResultPreferred(array $jd, array $kuaidi): bool
|
||||
{
|
||||
$kuaidiTraces = is_array($kuaidi['traces'] ?? null) ? $kuaidi['traces'] : [];
|
||||
if ($kuaidiTraces === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$jdNewest = (int) ($jd['newest_unix'] ?? 0);
|
||||
$kuaidiNewest = self::newestUnixFromTraces($kuaidiTraces);
|
||||
if ($jdNewest > $kuaidiNewest) {
|
||||
return true;
|
||||
}
|
||||
if ($jdNewest === $kuaidiNewest && $jdNewest > 0) {
|
||||
return count($jd['traces'] ?? []) > count($kuaidiTraces);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{time?:string}> $traces
|
||||
*/
|
||||
private static function newestUnixFromTraces(array $traces): int
|
||||
{
|
||||
$best = 0;
|
||||
foreach ($traces as $t) {
|
||||
if (!is_array($t)) {
|
||||
continue;
|
||||
}
|
||||
$p = strtotime((string) ($t['time'] ?? ''));
|
||||
if ($p !== false && (int) $p > $best) {
|
||||
$best = (int) $p;
|
||||
}
|
||||
}
|
||||
|
||||
return $best;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据运单号形态纠正承运商(避免 express_tracking 误存 sf 导致京东单查不出)
|
||||
*/
|
||||
public static function normalizeExpressCompanyCode(string $trackingNumber, string $storedCompany = 'auto'): string
|
||||
{
|
||||
$byNumber = self::detectCarrierFromNumber($trackingNumber);
|
||||
if ($byNumber === null) {
|
||||
$ec = strtolower(trim($storedCompany));
|
||||
|
||||
return in_array($ec, ['sf', 'jd', 'jt', 'jtexpress', 'auto'], true) ? $ec : 'auto';
|
||||
}
|
||||
|
||||
$ec = strtolower(trim($storedCompany));
|
||||
$byEc = self::carrierFromExpressCode($ec);
|
||||
if ($byEc !== null && $byEc['carrier'] !== $byNumber['carrier']) {
|
||||
return $byNumber['carrier'];
|
||||
}
|
||||
|
||||
return $byNumber['carrier'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $cfg
|
||||
* @return array{
|
||||
* carrier: string,
|
||||
* carrier_label: string,
|
||||
* kuaidi_com: string,
|
||||
* traces: list<array{time:string,context:string}>,
|
||||
* state: string,
|
||||
* state_text: string,
|
||||
* source: string,
|
||||
* hint: string,
|
||||
* official_url: string
|
||||
* }
|
||||
*/
|
||||
private static function queryKuaidiOnce(
|
||||
array $cfg,
|
||||
string $kuaidiCom,
|
||||
string $num,
|
||||
string $phoneForKuaidi,
|
||||
string $carrier,
|
||||
string $label
|
||||
): array {
|
||||
$officialUrl = self::buildOfficialUrl($carrier, $num);
|
||||
$out = [
|
||||
'carrier' => $carrier,
|
||||
'carrier_label' => $label,
|
||||
'kuaidi_com' => $kuaidiCom,
|
||||
'traces' => [],
|
||||
'state' => '',
|
||||
'state_text' => '',
|
||||
'source' => 'kuaidi100',
|
||||
'hint' => '',
|
||||
'official_url' => $officialUrl,
|
||||
];
|
||||
|
||||
$paramArr = [
|
||||
'com' => $kuaidiCom,
|
||||
'num' => $num,
|
||||
'resultv2' => '1',
|
||||
];
|
||||
if ($phoneForKuaidi !== '') {
|
||||
$paramArr['phone'] = $phoneForKuaidi;
|
||||
}
|
||||
|
||||
$paramJson = json_encode($paramArr, JSON_UNESCAPED_UNICODE);
|
||||
$customer = (string) $cfg['customer'];
|
||||
$key = (string) $cfg['key'];
|
||||
$sign = strtoupper(md5($paramJson . $key . $customer));
|
||||
$postBody = http_build_query([
|
||||
'customer' => $customer,
|
||||
'param' => $paramJson,
|
||||
'sign' => $sign,
|
||||
]);
|
||||
|
||||
$url = (string) ($cfg['query_url'] ?? 'https://poll.kuaidi100.com/poll/query.do');
|
||||
$raw = self::httpPostForm($url, $postBody);
|
||||
if ($raw === null || $raw === '') {
|
||||
$out['hint'] = '快递100接口无响应,请稍后重试或使用官网查询';
|
||||
Log::warning('ExpressTrackService kuaidi100 empty response', ['num' => $num, 'com' => $kuaidiCom]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
$json = json_decode($raw, true);
|
||||
if (!is_array($json)) {
|
||||
$out['hint'] = '快递100返回异常,请使用官网查询';
|
||||
Log::warning('ExpressTrackService kuaidi100 invalid json', ['raw' => mb_substr($raw, 0, 500), 'com' => $kuaidiCom]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
if (isset($json['result']) && $json['result'] === false) {
|
||||
$msg = (string) ($json['message'] ?? '查询失败');
|
||||
$returnCode = (string) ($json['returnCode'] ?? '');
|
||||
if ($msg === '找不到对应公司' || $returnCode === '400') {
|
||||
$out['hint'] = '快递100暂不支持该快递公司或编码错误,请使用下方官网链接查询';
|
||||
} else {
|
||||
$out['hint'] = $msg;
|
||||
}
|
||||
Log::info('ExpressTrackService kuaidi100 business fail', [
|
||||
'message' => $msg,
|
||||
'returnCode' => $returnCode,
|
||||
'num' => $num,
|
||||
'com' => $kuaidiCom,
|
||||
]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
$data = $json['data'] ?? null;
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
if (($json['message'] ?? '') !== 'ok' && $data === []) {
|
||||
$out['hint'] = (string) ($json['message'] ?? '未查到轨迹');
|
||||
Log::info('ExpressTrackService kuaidi100 no data', ['json' => $json, 'com' => $kuaidiCom]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
$traces = [];
|
||||
foreach ($data as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$t = (string) ($row['ftime'] ?? $row['time'] ?? '');
|
||||
$c = (string) ($row['context'] ?? '');
|
||||
if ($t === '' && $c === '') {
|
||||
continue;
|
||||
}
|
||||
$traces[] = ['time' => $t, 'context' => $c];
|
||||
}
|
||||
|
||||
$out['traces'] = $traces;
|
||||
$out['state'] = (string) ($json['state'] ?? '');
|
||||
$out['state_text'] = self::stateText($out['state']);
|
||||
$out['hint'] = $traces === [] ? '暂无轨迹节点,单号可能尚未揽收' : '';
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function kuaidiComCandidates(string $primaryCom, string $num): array
|
||||
{
|
||||
$list = [$primaryCom];
|
||||
$byNumber = self::detectCarrierFromNumber($num);
|
||||
if ($byNumber !== null && !in_array($byNumber['kuaidi_com'], $list, true)) {
|
||||
$list[] = $byNumber['kuaidi_com'];
|
||||
}
|
||||
if (preg_match('/^JDVE/i', strtoupper($num)) && !in_array('jd', $list, true)) {
|
||||
$list[] = 'jd';
|
||||
}
|
||||
if (preg_match('/^(JD|JDV|JDK|JDEX)/i', strtoupper($num))) {
|
||||
foreach (['jingdong', 'jd'] as $c) {
|
||||
if (!in_array($c, $list, true)) {
|
||||
$list[] = $c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter($list, static fn ($c) => $c !== '' && $c !== 'auto')));
|
||||
}
|
||||
|
||||
/**
|
||||
* 快递100「phone」入参:有手动覆盖且不少于 4 位时用覆盖;否则用订单收货号码。
|
||||
* 对 11 位及以上数字取后 11 位作为手机号(去掉可能的前缀符号位)。
|
||||
*/
|
||||
private static function buildKuaidiPhoneParam(string $overrideDigits, string $recipientDigits): string
|
||||
{
|
||||
$d = strlen($overrideDigits) >= 4 ? $overrideDigits : $recipientDigits;
|
||||
if ($d === '') {
|
||||
return '';
|
||||
}
|
||||
if (strlen($d) >= 11) {
|
||||
return substr($d, -11);
|
||||
}
|
||||
|
||||
return $d;
|
||||
}
|
||||
|
||||
/** 实时查询文档:顺丰速运、中通快递等 phone 必填 */
|
||||
private static function kuaidiPhoneRequired(string $kuaidiCom): bool
|
||||
{
|
||||
$c = strtolower($kuaidiCom);
|
||||
|
||||
return $c === self::KUAIDI_COM_SF || $c === 'zhongtong';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{carrier: string, kuaidi_com: string, label: string}|null
|
||||
*/
|
||||
private static function carrierFromExpressCode(string $expressCompany): ?array
|
||||
{
|
||||
$ec = strtolower(trim($expressCompany));
|
||||
if ($ec === 'sf' || $ec === 'shunfeng') {
|
||||
return ['carrier' => 'sf', 'kuaidi_com' => self::KUAIDI_COM_SF, 'label' => '顺丰速运'];
|
||||
}
|
||||
if ($ec === 'jd' || $ec === 'jingdong') {
|
||||
return ['carrier' => 'jd', 'kuaidi_com' => self::KUAIDI_COM_JD, 'label' => '京东快递'];
|
||||
}
|
||||
if ($ec === 'jt' || $ec === 'jtexpress') {
|
||||
return ['carrier' => 'jt', 'kuaidi_com' => self::KUAIDI_COM_JT, 'label' => '极兔速递'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{carrier: string, kuaidi_com: string, label: string}|null
|
||||
*/
|
||||
private static function detectCarrierFromNumber(string $num): ?array
|
||||
{
|
||||
$n = trim($num);
|
||||
if ($n === '') {
|
||||
return null;
|
||||
}
|
||||
$u = strtoupper($n);
|
||||
if (preg_match('/^SF\d/i', $n)) {
|
||||
return ['carrier' => 'sf', 'kuaidi_com' => self::KUAIDI_COM_SF, 'label' => '顺丰速运(单号识别)'];
|
||||
}
|
||||
if (preg_match('/^JDVE/i', $u)) {
|
||||
return ['carrier' => 'jd', 'kuaidi_com' => self::KUAIDI_COM_JD, 'label' => '京东快递(单号识别)'];
|
||||
}
|
||||
if (preg_match('/^(JDK|JDV|JDEX)/i', $u) || preg_match('/^JD[A-Z0-9]{10,}/i', $u)) {
|
||||
return ['carrier' => 'jd', 'kuaidi_com' => self::KUAIDI_COM_JD, 'label' => '京东物流(单号识别)'];
|
||||
}
|
||||
if (preg_match('/^JT\d{13}$/i', $n)) {
|
||||
return ['carrier' => 'jt', 'kuaidi_com' => self::KUAIDI_COM_JT, 'label' => '极兔速递(单号识别)'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{carrier: string, kuaidi_com: string, label: string}
|
||||
*/
|
||||
private static function resolveCarrier(string $expressCompany, string $num): array
|
||||
{
|
||||
$byNumber = self::detectCarrierFromNumber($num);
|
||||
$byEc = self::carrierFromExpressCode($expressCompany);
|
||||
|
||||
if ($byNumber !== null && $byEc !== null && $byEc['carrier'] !== $byNumber['carrier']) {
|
||||
Log::info('ExpressTrackService carrier mismatch, prefer tracking number', [
|
||||
'express_company' => $expressCompany,
|
||||
'tracking_number' => $num,
|
||||
'stored_carrier' => $byEc['carrier'],
|
||||
'detected_carrier' => $byNumber['carrier'],
|
||||
]);
|
||||
|
||||
return $byNumber;
|
||||
}
|
||||
if ($byEc !== null) {
|
||||
return $byEc;
|
||||
}
|
||||
if ($byNumber !== null) {
|
||||
return $byNumber;
|
||||
}
|
||||
|
||||
return ['carrier' => 'auto', 'kuaidi_com' => 'auto', 'label' => '自动识别'];
|
||||
}
|
||||
|
||||
private static function stateText(string $state): string
|
||||
{
|
||||
$m = [
|
||||
'0' => '在途',
|
||||
'1' => '揽收',
|
||||
'2' => '疑难',
|
||||
'3' => '已签收',
|
||||
'4' => '退签',
|
||||
'5' => '派件中',
|
||||
'6' => '退回',
|
||||
'7' => '转投',
|
||||
'10' => '待清关',
|
||||
'11' => '清关中',
|
||||
'12' => '已清关',
|
||||
'13' => '清关异常',
|
||||
'14' => '收件人拒签',
|
||||
];
|
||||
|
||||
return $m[$state] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{sf: string, jd: string, jt: string}
|
||||
*/
|
||||
public static function officialUrls(string $trackingNumber): array
|
||||
{
|
||||
$n = trim($trackingNumber);
|
||||
$enc = rawurlencode($n);
|
||||
|
||||
return [
|
||||
// 顺丰速运官网查询(新版)
|
||||
'sf' => 'https://www.sf-express.com/cn/sc/dynamic_function/waybill/#search/bill-number/' . $enc,
|
||||
// 京东物流官网查询
|
||||
'jd' => 'https://www.jdl.com/#/trackQuery?waybillCode=' . $enc,
|
||||
// 极兔速递官网查询
|
||||
'jt' => 'https://www.jtexpress.com.cn/index/query/gzquery.html?bills=' . $enc,
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildOfficialUrl(string $carrier, string $num): string
|
||||
{
|
||||
$urls = self::officialUrls($num);
|
||||
if ($carrier === 'sf') {
|
||||
return $urls['sf'];
|
||||
}
|
||||
if ($carrier === 'jd') {
|
||||
return $urls['jd'];
|
||||
}
|
||||
if ($carrier === 'jt') {
|
||||
return $urls['jt'];
|
||||
}
|
||||
|
||||
return $urls['jt']; // 默认返回极兔
|
||||
}
|
||||
|
||||
private static function httpPostForm(string $url, string $body): ?string
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
return null;
|
||||
}
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
$resp = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return $resp === false ? null : (string) $resp;
|
||||
}
|
||||
}
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use think\facade\Config;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 快递轨迹:顺丰(shunfeng)、京东(jd),优先走快递100;未配置时返回官网查询链接
|
||||
*/
|
||||
class ExpressTrackService
|
||||
{
|
||||
private const KUAIDI_COM_SF = 'shunfeng';
|
||||
|
||||
private const KUAIDI_COM_JD = 'jingdong'; // 京东快递(快递100编码)
|
||||
|
||||
private const KUAIDI_COM_JT = 'jtexpress'; // 极兔速递
|
||||
|
||||
/**
|
||||
* @param string $phoneTailOverride 手工填写的收件电话(仅数字;完整 11 位或与面单一致的后四位等),优先于订单收货手机
|
||||
*
|
||||
* @return array{
|
||||
* carrier: string,
|
||||
* carrier_label: string,
|
||||
* kuaidi_com: string,
|
||||
* traces: list<array{time:string,context:string}>,
|
||||
* state: string,
|
||||
* state_text: string,
|
||||
* source: string,
|
||||
* hint: string,
|
||||
* official_url: string
|
||||
* }
|
||||
*/
|
||||
public static function query(string $expressCompany, string $trackingNumber, string $recipientPhone = '', string $phoneTailOverride = ''): array
|
||||
{
|
||||
$num = trim($trackingNumber);
|
||||
$overrideDigits = preg_replace('/\D/', '', $phoneTailOverride) ?? '';
|
||||
$recipientDigits = preg_replace('/\D/', '', $recipientPhone) ?? '';
|
||||
// 快递100 文档:phone 为收/寄件人电话;顺丰等必填。示例为完整 11 位手机号,仅传后四位易触发 408「验证码错误」
|
||||
$phoneForKuaidi = self::buildKuaidiPhoneParam($overrideDigits, $recipientDigits);
|
||||
|
||||
$resolved = self::resolveCarrier($expressCompany, $num);
|
||||
$carrier = $resolved['carrier'];
|
||||
$kuaidiCom = $resolved['kuaidi_com'];
|
||||
$label = $resolved['label'];
|
||||
$comCandidates = self::kuaidiComCandidates($kuaidiCom, $num);
|
||||
|
||||
$officialUrl = self::buildOfficialUrl($carrier, $num);
|
||||
|
||||
$out = [
|
||||
'carrier' => $carrier,
|
||||
'carrier_label' => $label,
|
||||
'kuaidi_com' => $kuaidiCom,
|
||||
'traces' => [],
|
||||
'state' => '',
|
||||
'state_text' => '',
|
||||
'source' => 'official_only',
|
||||
'hint' => '',
|
||||
'official_url' => $officialUrl,
|
||||
];
|
||||
|
||||
$cfg = Config::get('logistics.kuaidi100', []);
|
||||
$enable = !empty($cfg['enable']);
|
||||
|
||||
$result = $out;
|
||||
if (! $enable) {
|
||||
$result['hint'] = '未配置快递100查询密钥或已关闭(LOGISTICS_KUAIDI100_DISABLE),仅可打开官网查件。请在 .env 中配置 LOGISTICS_KUAIDI100_CUSTOMER、LOGISTICS_KUAIDI100_KEY';
|
||||
} elseif ($phoneForKuaidi === '' && self::kuaidiPhoneRequired($kuaidiCom)) {
|
||||
// 顺丰(及快递100 要求电话的承运商)无 phone 时不请求接口,避免无效调用
|
||||
$result['hint'] = '顺丰查询需在快递100 中同时提交单号与收/寄件人电话(可与面单一致的完整手机号或后四位)。请填写收件电话后点「刷新轨迹」。';
|
||||
} else {
|
||||
$matched = null;
|
||||
$lastFail = null;
|
||||
foreach ($comCandidates as $tryCom) {
|
||||
$tryOut = self::queryKuaidiOnce($cfg, $tryCom, $num, $phoneForKuaidi, $carrier, $label);
|
||||
if (!empty($tryOut['traces']) || ($tryOut['state'] ?? '') !== '') {
|
||||
$matched = $tryOut;
|
||||
break;
|
||||
}
|
||||
$lastFail = $tryOut;
|
||||
}
|
||||
$result = $matched ?? $lastFail ?? $out;
|
||||
}
|
||||
|
||||
// 京东自营单(JDVE…)兜底:快递100 无轨迹/陈旧时,用京东官方接口(更新或更全才采用)。
|
||||
// 未配置京东官方接口时 isConfigured()=false,本段跳过,行为与原先一致。
|
||||
if ($carrier === 'jd' && JdLogisticsService::isConfigured()) {
|
||||
try {
|
||||
$jdPhone = $overrideDigits !== '' ? $overrideDigits : $recipientDigits;
|
||||
$jd = JdLogisticsService::queryTrace($num, $jdPhone);
|
||||
if ($jd !== null && !empty($jd['traces']) && self::jdResultPreferred($jd, $result)) {
|
||||
$result['traces'] = $jd['traces'];
|
||||
$result['state'] = (string) $jd['state'];
|
||||
$result['state_text'] = (string) $jd['state_text'];
|
||||
$result['source'] = 'jd_official';
|
||||
$result['hint'] = '';
|
||||
// carrier / carrier_label / official_url / kuaidi_com 保留原值
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('ExpressTrackService jd official fallback failed', [
|
||||
'num' => $num,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 京东官方轨迹是否应优先于快递100 结果采用:
|
||||
* 快递100 无轨迹 → 直接用;否则京东更「新」(最新轨迹时间更晚)或同样新但条目更多 → 用。
|
||||
*
|
||||
* @param array{traces?:array,newest_unix?:int} $jd
|
||||
* @param array{traces?:array} $kuaidi
|
||||
*/
|
||||
private static function jdResultPreferred(array $jd, array $kuaidi): bool
|
||||
{
|
||||
$kuaidiTraces = is_array($kuaidi['traces'] ?? null) ? $kuaidi['traces'] : [];
|
||||
if ($kuaidiTraces === []) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$jdNewest = (int) ($jd['newest_unix'] ?? 0);
|
||||
$kuaidiNewest = self::newestUnixFromTraces($kuaidiTraces);
|
||||
if ($jdNewest > $kuaidiNewest) {
|
||||
return true;
|
||||
}
|
||||
if ($jdNewest === $kuaidiNewest && $jdNewest > 0) {
|
||||
return count($jd['traces'] ?? []) > count($kuaidiTraces);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array{time?:string}> $traces
|
||||
*/
|
||||
private static function newestUnixFromTraces(array $traces): int
|
||||
{
|
||||
$best = 0;
|
||||
foreach ($traces as $t) {
|
||||
if (!is_array($t)) {
|
||||
continue;
|
||||
}
|
||||
$p = strtotime((string) ($t['time'] ?? ''));
|
||||
if ($p !== false && (int) $p > $best) {
|
||||
$best = (int) $p;
|
||||
}
|
||||
}
|
||||
|
||||
return $best;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据运单号形态纠正承运商(避免 express_tracking 误存 sf 导致京东单查不出)
|
||||
*/
|
||||
public static function normalizeExpressCompanyCode(string $trackingNumber, string $storedCompany = 'auto'): string
|
||||
{
|
||||
$byNumber = self::detectCarrierFromNumber($trackingNumber);
|
||||
if ($byNumber === null) {
|
||||
$ec = strtolower(trim($storedCompany));
|
||||
|
||||
return in_array($ec, ['sf', 'jd', 'jt', 'jtexpress', 'auto'], true) ? $ec : 'auto';
|
||||
}
|
||||
|
||||
$ec = strtolower(trim($storedCompany));
|
||||
$byEc = self::carrierFromExpressCode($ec);
|
||||
if ($byEc !== null && $byEc['carrier'] !== $byNumber['carrier']) {
|
||||
return $byNumber['carrier'];
|
||||
}
|
||||
|
||||
return $byNumber['carrier'];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $cfg
|
||||
* @return array{
|
||||
* carrier: string,
|
||||
* carrier_label: string,
|
||||
* kuaidi_com: string,
|
||||
* traces: list<array{time:string,context:string}>,
|
||||
* state: string,
|
||||
* state_text: string,
|
||||
* source: string,
|
||||
* hint: string,
|
||||
* official_url: string
|
||||
* }
|
||||
*/
|
||||
private static function queryKuaidiOnce(
|
||||
array $cfg,
|
||||
string $kuaidiCom,
|
||||
string $num,
|
||||
string $phoneForKuaidi,
|
||||
string $carrier,
|
||||
string $label
|
||||
): array {
|
||||
$officialUrl = self::buildOfficialUrl($carrier, $num);
|
||||
$out = [
|
||||
'carrier' => $carrier,
|
||||
'carrier_label' => $label,
|
||||
'kuaidi_com' => $kuaidiCom,
|
||||
'traces' => [],
|
||||
'state' => '',
|
||||
'state_text' => '',
|
||||
'source' => 'kuaidi100',
|
||||
'hint' => '',
|
||||
'official_url' => $officialUrl,
|
||||
];
|
||||
|
||||
$paramArr = [
|
||||
'com' => $kuaidiCom,
|
||||
'num' => $num,
|
||||
'resultv2' => '1',
|
||||
];
|
||||
if ($phoneForKuaidi !== '') {
|
||||
$paramArr['phone'] = $phoneForKuaidi;
|
||||
}
|
||||
|
||||
$paramJson = json_encode($paramArr, JSON_UNESCAPED_UNICODE);
|
||||
$customer = (string) $cfg['customer'];
|
||||
$key = (string) $cfg['key'];
|
||||
$sign = strtoupper(md5($paramJson . $key . $customer));
|
||||
$postBody = http_build_query([
|
||||
'customer' => $customer,
|
||||
'param' => $paramJson,
|
||||
'sign' => $sign,
|
||||
]);
|
||||
|
||||
$url = (string) ($cfg['query_url'] ?? 'https://poll.kuaidi100.com/poll/query.do');
|
||||
$raw = self::httpPostForm($url, $postBody);
|
||||
if ($raw === null || $raw === '') {
|
||||
$out['hint'] = '快递100接口无响应,请稍后重试或使用官网查询';
|
||||
Log::warning('ExpressTrackService kuaidi100 empty response', ['num' => $num, 'com' => $kuaidiCom]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
$json = json_decode($raw, true);
|
||||
if (!is_array($json)) {
|
||||
$out['hint'] = '快递100返回异常,请使用官网查询';
|
||||
Log::warning('ExpressTrackService kuaidi100 invalid json', ['raw' => mb_substr($raw, 0, 500), 'com' => $kuaidiCom]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
if (isset($json['result']) && $json['result'] === false) {
|
||||
$msg = (string) ($json['message'] ?? '查询失败');
|
||||
$returnCode = (string) ($json['returnCode'] ?? '');
|
||||
if ($msg === '找不到对应公司' || $returnCode === '400') {
|
||||
$out['hint'] = '快递100暂不支持该快递公司或编码错误,请使用下方官网链接查询';
|
||||
} else {
|
||||
$out['hint'] = $msg;
|
||||
}
|
||||
Log::info('ExpressTrackService kuaidi100 business fail', [
|
||||
'message' => $msg,
|
||||
'returnCode' => $returnCode,
|
||||
'num' => $num,
|
||||
'com' => $kuaidiCom,
|
||||
]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
$data = $json['data'] ?? null;
|
||||
if (!is_array($data)) {
|
||||
$data = [];
|
||||
}
|
||||
if (($json['message'] ?? '') !== 'ok' && $data === []) {
|
||||
$out['hint'] = (string) ($json['message'] ?? '未查到轨迹');
|
||||
Log::info('ExpressTrackService kuaidi100 no data', ['json' => $json, 'com' => $kuaidiCom]);
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
$traces = [];
|
||||
foreach ($data as $row) {
|
||||
if (!is_array($row)) {
|
||||
continue;
|
||||
}
|
||||
$t = (string) ($row['ftime'] ?? $row['time'] ?? '');
|
||||
$c = (string) ($row['context'] ?? '');
|
||||
if ($t === '' && $c === '') {
|
||||
continue;
|
||||
}
|
||||
$traces[] = ['time' => $t, 'context' => $c];
|
||||
}
|
||||
|
||||
$out['traces'] = $traces;
|
||||
$out['state'] = (string) ($json['state'] ?? '');
|
||||
$out['state_text'] = self::stateText($out['state']);
|
||||
$out['hint'] = $traces === [] ? '暂无轨迹节点,单号可能尚未揽收' : '';
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<string>
|
||||
*/
|
||||
private static function kuaidiComCandidates(string $primaryCom, string $num): array
|
||||
{
|
||||
$list = [$primaryCom];
|
||||
$byNumber = self::detectCarrierFromNumber($num);
|
||||
if ($byNumber !== null && !in_array($byNumber['kuaidi_com'], $list, true)) {
|
||||
$list[] = $byNumber['kuaidi_com'];
|
||||
}
|
||||
if (preg_match('/^JDVE/i', strtoupper($num)) && !in_array('jd', $list, true)) {
|
||||
$list[] = 'jd';
|
||||
}
|
||||
if (preg_match('/^(JD|JDV|JDK|JDEX)/i', strtoupper($num))) {
|
||||
foreach (['jingdong', 'jd'] as $c) {
|
||||
if (!in_array($c, $list, true)) {
|
||||
$list[] = $c;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return array_values(array_unique(array_filter($list, static fn ($c) => $c !== '' && $c !== 'auto')));
|
||||
}
|
||||
|
||||
/**
|
||||
* 快递100「phone」入参:有手动覆盖且不少于 4 位时用覆盖;否则用订单收货号码。
|
||||
* 对 11 位及以上数字取后 11 位作为手机号(去掉可能的前缀符号位)。
|
||||
*/
|
||||
private static function buildKuaidiPhoneParam(string $overrideDigits, string $recipientDigits): string
|
||||
{
|
||||
$d = strlen($overrideDigits) >= 4 ? $overrideDigits : $recipientDigits;
|
||||
if ($d === '') {
|
||||
return '';
|
||||
}
|
||||
if (strlen($d) >= 11) {
|
||||
return substr($d, -11);
|
||||
}
|
||||
|
||||
return $d;
|
||||
}
|
||||
|
||||
/** 实时查询文档:顺丰速运、中通快递等 phone 必填 */
|
||||
private static function kuaidiPhoneRequired(string $kuaidiCom): bool
|
||||
{
|
||||
$c = strtolower($kuaidiCom);
|
||||
|
||||
return $c === self::KUAIDI_COM_SF || $c === 'zhongtong';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{carrier: string, kuaidi_com: string, label: string}|null
|
||||
*/
|
||||
private static function carrierFromExpressCode(string $expressCompany): ?array
|
||||
{
|
||||
$ec = strtolower(trim($expressCompany));
|
||||
if ($ec === 'sf' || $ec === 'shunfeng') {
|
||||
return ['carrier' => 'sf', 'kuaidi_com' => self::KUAIDI_COM_SF, 'label' => '顺丰速运'];
|
||||
}
|
||||
if ($ec === 'jd' || $ec === 'jingdong') {
|
||||
return ['carrier' => 'jd', 'kuaidi_com' => self::KUAIDI_COM_JD, 'label' => '京东快递'];
|
||||
}
|
||||
if ($ec === 'jt' || $ec === 'jtexpress') {
|
||||
return ['carrier' => 'jt', 'kuaidi_com' => self::KUAIDI_COM_JT, 'label' => '极兔速递'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{carrier: string, kuaidi_com: string, label: string}|null
|
||||
*/
|
||||
private static function detectCarrierFromNumber(string $num): ?array
|
||||
{
|
||||
$n = trim($num);
|
||||
if ($n === '') {
|
||||
return null;
|
||||
}
|
||||
$u = strtoupper($n);
|
||||
if (preg_match('/^SF\d/i', $n)) {
|
||||
return ['carrier' => 'sf', 'kuaidi_com' => self::KUAIDI_COM_SF, 'label' => '顺丰速运(单号识别)'];
|
||||
}
|
||||
if (preg_match('/^JDVE/i', $u)) {
|
||||
return ['carrier' => 'jd', 'kuaidi_com' => self::KUAIDI_COM_JD, 'label' => '京东快递(单号识别)'];
|
||||
}
|
||||
if (preg_match('/^(JDK|JDV|JDEX)/i', $u) || preg_match('/^JD[A-Z0-9]{10,}/i', $u)) {
|
||||
return ['carrier' => 'jd', 'kuaidi_com' => self::KUAIDI_COM_JD, 'label' => '京东物流(单号识别)'];
|
||||
}
|
||||
if (preg_match('/^JT\d{13}$/i', $n)) {
|
||||
return ['carrier' => 'jt', 'kuaidi_com' => self::KUAIDI_COM_JT, 'label' => '极兔速递(单号识别)'];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{carrier: string, kuaidi_com: string, label: string}
|
||||
*/
|
||||
private static function resolveCarrier(string $expressCompany, string $num): array
|
||||
{
|
||||
$byNumber = self::detectCarrierFromNumber($num);
|
||||
$byEc = self::carrierFromExpressCode($expressCompany);
|
||||
|
||||
if ($byNumber !== null && $byEc !== null && $byEc['carrier'] !== $byNumber['carrier']) {
|
||||
Log::info('ExpressTrackService carrier mismatch, prefer tracking number', [
|
||||
'express_company' => $expressCompany,
|
||||
'tracking_number' => $num,
|
||||
'stored_carrier' => $byEc['carrier'],
|
||||
'detected_carrier' => $byNumber['carrier'],
|
||||
]);
|
||||
|
||||
return $byNumber;
|
||||
}
|
||||
if ($byEc !== null) {
|
||||
return $byEc;
|
||||
}
|
||||
if ($byNumber !== null) {
|
||||
return $byNumber;
|
||||
}
|
||||
|
||||
return ['carrier' => 'auto', 'kuaidi_com' => 'auto', 'label' => '自动识别'];
|
||||
}
|
||||
|
||||
private static function stateText(string $state): string
|
||||
{
|
||||
$m = [
|
||||
'0' => '在途',
|
||||
'1' => '揽收',
|
||||
'2' => '疑难',
|
||||
'3' => '已签收',
|
||||
'4' => '退签',
|
||||
'5' => '派件中',
|
||||
'6' => '退回',
|
||||
'7' => '转投',
|
||||
'10' => '待清关',
|
||||
'11' => '清关中',
|
||||
'12' => '已清关',
|
||||
'13' => '清关异常',
|
||||
'14' => '收件人拒签',
|
||||
];
|
||||
|
||||
return $m[$state] ?? '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{sf: string, jd: string, jt: string}
|
||||
*/
|
||||
public static function officialUrls(string $trackingNumber): array
|
||||
{
|
||||
$n = trim($trackingNumber);
|
||||
$enc = rawurlencode($n);
|
||||
|
||||
return [
|
||||
// 顺丰速运官网查询(新版)
|
||||
'sf' => 'https://www.sf-express.com/cn/sc/dynamic_function/waybill/#search/bill-number/' . $enc,
|
||||
// 京东物流官网查询
|
||||
'jd' => 'https://www.jdl.com/#/trackQuery?waybillCode=' . $enc,
|
||||
// 极兔速递官网查询
|
||||
'jt' => 'https://www.jtexpress.com.cn/index/query/gzquery.html?bills=' . $enc,
|
||||
];
|
||||
}
|
||||
|
||||
private static function buildOfficialUrl(string $carrier, string $num): string
|
||||
{
|
||||
$urls = self::officialUrls($num);
|
||||
if ($carrier === 'sf') {
|
||||
return $urls['sf'];
|
||||
}
|
||||
if ($carrier === 'jd') {
|
||||
return $urls['jd'];
|
||||
}
|
||||
if ($carrier === 'jt') {
|
||||
return $urls['jt'];
|
||||
}
|
||||
|
||||
return $urls['jt']; // 默认返回极兔
|
||||
}
|
||||
|
||||
private static function httpPostForm(string $url, string $body): ?string
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
return null;
|
||||
}
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, [
|
||||
'Content-Type: application/x-www-form-urlencoded',
|
||||
]);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
$resp = curl_exec($ch);
|
||||
curl_close($ch);
|
||||
|
||||
return $resp === false ? null : (string) $resp;
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,461 +1,461 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use think\facade\Config;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 京东官方物流轨迹查询(京东物流开放平台 LOP,https://api.jdl.com)
|
||||
*
|
||||
* 作用:作为快递100 对「京东自营运单(JDVE…)」轨迹陈旧/缺失时的兜底数据源,
|
||||
* 同时供后台「京东接口更新」按钮直接拉取并落库。
|
||||
* 仅在 config/logistics.php 的 jd.enable=true(填好 app_key/app_secret/access_token)时生效;
|
||||
* 未配置时 isConfigured()=false,ExpressTrackService 完全沿用快递100 逻辑,互不影响。
|
||||
*
|
||||
* 接口:京东物流标准轨迹服务 /jd/tracking/query(2025-04-29 改版,对接方案编码 Tracking_JD)。
|
||||
* 调用走 LOP 统一网关,鉴权/签名规则与官方 SDK(IsvFilter) 完全一致:
|
||||
* - 公共参数(app_key/access_token/timestamp/v/sign/algorithm/LOP-DN)以 query string 拼到 URL;
|
||||
* - 业务参数 JSON 字符串作为请求体;待签串固定顺序拼接并首尾包 app_secret。
|
||||
* 加签算法由 .env JD_LOGISTICS_ALGORITHM 控制(默认 md5-salt=md5(content),另支持 HMacMD5/SHA1/SHA256/SHA512)。
|
||||
* 注意:后台「报文加解密密钥」的 RSA 公私钥仅用于报文加解密,与本网关签名无关。
|
||||
*
|
||||
* 网关/path/对接方案编码/单号类型 走 .env(JD_LOGISTICS_GATEWAY / METHOD / LOP_DN / REFERENCE_TYPE)。
|
||||
* 响应解析采用「递归找轨迹行」的宽松策略,兼容多种返回结构。
|
||||
*/
|
||||
final class JdLogisticsService
|
||||
{
|
||||
/** 轨迹行「时间」候选字段(按优先级) */
|
||||
private const TIME_FIELDS = [
|
||||
'operationTime', 'operatorTime', 'opeTime', 'operateTime', 'msgTime', 'scanTime',
|
||||
'time', 'createTime', 'waybillStateTime', 'orderTime',
|
||||
];
|
||||
|
||||
/** 轨迹行「描述」候选字段(按优先级) */
|
||||
private const CONTEXT_FIELDS = [
|
||||
'remark', 'operateRemark', 'opeRemark', 'content', 'opeTitle',
|
||||
'operationCodeName', 'operationTypeName', 'scanTypeName', 'waybillStateName', 'message', 'desc', 'msg',
|
||||
];
|
||||
|
||||
public static function isConfigured(): bool
|
||||
{
|
||||
$cfg = Config::get('logistics.jd', []);
|
||||
|
||||
// LOP 网关签名用 app_secret(md5-salt / HMAC),不需要 RSA 私钥(私钥仅用于报文加解密)
|
||||
return !empty($cfg['enable'])
|
||||
&& trim((string) ($cfg['app_key'] ?? '')) !== ''
|
||||
&& trim((string) ($cfg['app_secret'] ?? '')) !== ''
|
||||
&& trim((string) ($cfg['access_token'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询京东官方轨迹,返回与 ExpressTrackService::query 兼容的结构(失败/未配置返回 null)。
|
||||
*
|
||||
* @return array{
|
||||
* traces: list<array{time:string,context:string,status:string}>,
|
||||
* state: string,
|
||||
* state_text: string,
|
||||
* source: string,
|
||||
* hint: string,
|
||||
* newest_unix: int
|
||||
* }|null
|
||||
*/
|
||||
public static function queryTrace(string $waybillCode, string $phoneTail = ''): ?array
|
||||
{
|
||||
$num = trim($waybillCode);
|
||||
if ($num === '' || !self::isConfigured()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cfg = Config::get('logistics.jd', []);
|
||||
|
||||
// 京东物流标准轨迹服务 /jd/tracking/query:body 为 JSON 数组 [{referenceNumber, referenceType, phone}]
|
||||
$row = [
|
||||
(string) ($cfg['reference_field'] ?? 'referenceNumber') => $num,
|
||||
'referenceType' => (string) ($cfg['reference_type'] ?? '20000'),
|
||||
];
|
||||
$tail = substr(preg_replace('/\D/', '', $phoneTail) ?? '', -4);
|
||||
if ($tail !== '') {
|
||||
$row['phone'] = $tail;
|
||||
}
|
||||
$customerCode = trim((string) ($cfg['customer_code'] ?? ''));
|
||||
if ($customerCode !== '') {
|
||||
$row['customerCode'] = $customerCode;
|
||||
}
|
||||
$body = json_encode([$row], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$raw = self::request($cfg, (string) $body);
|
||||
if ($raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = json_decode($raw, true);
|
||||
if (!is_array($json)) {
|
||||
Log::warning('JdLogisticsService invalid json', ['raw' => mb_substr($raw, 0, 500), 'num' => $num]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// LOP 网关/业务错误:code 非 1000(成功)时记录原始报文,便于排查鉴权/单号/权限问题
|
||||
$code = (string) ($json['code'] ?? $json['resultCode'] ?? '');
|
||||
if ($code !== '' && !in_array($code, ['1000', '0000', '0'], true)) {
|
||||
Log::warning('JdLogisticsService lop error', [
|
||||
'num' => $num,
|
||||
'code' => $code,
|
||||
'message' => (string) ($json['msg'] ?? $json['message'] ?? $json['resultMessage'] ?? ''),
|
||||
'raw' => mb_substr($raw, 0, 500),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
// 兼容 JOS 网关层错误结构
|
||||
if (isset($json['error_response'])) {
|
||||
Log::warning('JdLogisticsService gateway error', [
|
||||
'num' => $num,
|
||||
'error' => $json['error_response'],
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$rows = self::extractTraceRows($json);
|
||||
if ($rows === []) {
|
||||
Log::info('JdLogisticsService no trace rows', ['num' => $num, 'json' => mb_substr($raw, 0, 800)]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$traces = self::normalizeRows($rows);
|
||||
if ($traces === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 时间倒序(最新在前),与快递100 输出一致
|
||||
usort($traces, static function (array $a, array $b): int {
|
||||
return ($b['_unix'] ?? 0) <=> ($a['_unix'] ?? 0);
|
||||
});
|
||||
|
||||
$newestUnix = (int) ($traces[0]['_unix'] ?? 0);
|
||||
$signed = false;
|
||||
foreach ($traces as $t) {
|
||||
if (self::looksSigned((string) $t['context'])) {
|
||||
$signed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$state = $signed ? '3' : '0';
|
||||
|
||||
// 去掉内部辅助字段
|
||||
$clean = [];
|
||||
foreach ($traces as $t) {
|
||||
$clean[] = [
|
||||
'time' => (string) $t['time'],
|
||||
'context' => (string) $t['context'],
|
||||
'status' => (string) ($t['status'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'traces' => $clean,
|
||||
'state' => $state,
|
||||
'state_text' => $signed ? '已签收' : '在途',
|
||||
'source' => 'jd_official',
|
||||
'hint' => '',
|
||||
'newest_unix' => $newestUnix,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 LOP 网关(统一鉴权/签名,与官方 SDK IsvFilter 一致)。
|
||||
*
|
||||
* 公共参数以 query string 拼到 URL;业务参数(JSON 字符串)作为请求体;
|
||||
* 网关靠 LOP-DN(对接方案编码) 路由到对应服务。
|
||||
*
|
||||
* @param array<string,mixed> $cfg
|
||||
* @param string $body 业务参数 JSON 字符串(param_json)
|
||||
*/
|
||||
private static function request(array $cfg, string $body): ?string
|
||||
{
|
||||
$appKey = (string) ($cfg['app_key'] ?? '');
|
||||
$appSecret = (string) ($cfg['app_secret'] ?? '');
|
||||
$accessToken = (string) ($cfg['access_token'] ?? '');
|
||||
$path = (string) ($cfg['method'] ?? '/jd/tracking/query');
|
||||
$version = (string) ($cfg['api_version'] ?? '2.0');
|
||||
$algorithm = trim((string) ($cfg['algorithm'] ?? 'md5-salt')) ?: 'md5-salt';
|
||||
$lopDn = (string) ($cfg['lop_dn'] ?? 'Tracking_JD');
|
||||
// 时间戳与时区必须自洽(否则网关报 471 时间戳已失效):统一用北京时间 + lop-tz=8
|
||||
$now = new \DateTime('now', new \DateTimeZone('Asia/Shanghai'));
|
||||
$timestamp = $now->format('Y-m-d H:i:s');
|
||||
|
||||
// 待签串:固定顺序拼接,首尾包 appSecret(method=接口path,param_json=业务体)
|
||||
$content = implode('', [
|
||||
$appSecret,
|
||||
'access_token', $accessToken,
|
||||
'app_key', $appKey,
|
||||
'method', $path,
|
||||
'param_json', $body,
|
||||
'timestamp', $timestamp,
|
||||
'v', $version,
|
||||
$appSecret,
|
||||
]);
|
||||
$sign = self::sign($algorithm, $content, $appSecret);
|
||||
if ($sign === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = [
|
||||
'LOP-DN' => $lopDn,
|
||||
'app_key' => $appKey,
|
||||
'access_token' => $accessToken,
|
||||
'timestamp' => $timestamp,
|
||||
'v' => $version,
|
||||
'sign' => $sign,
|
||||
'algorithm' => $algorithm,
|
||||
];
|
||||
|
||||
$base = rtrim((string) ($cfg['gateway'] ?? 'https://api.jdl.com'), '/');
|
||||
$url = $base . $path . '?' . http_build_query($query);
|
||||
|
||||
// lop-tz:与 timestamp 同源(北京时间 = 东八区 = 8)
|
||||
$offsetHours = (int) ($now->getOffset() / 3600);
|
||||
|
||||
return self::httpPostJson($url, $body, [
|
||||
'Content-Type: application/json;charset=utf-8',
|
||||
'User-Agent: lop-http/php',
|
||||
'lop-tz: ' . $offsetHours,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* LOP 网关签名(与官方 SDK Utils::sign 一致):
|
||||
* - md5-salt :md5(content) 的小写十六进制
|
||||
* - HMacMD5 / HMacSHA1 / HMacSHA256 / HMacSHA512:base64(hmac(算法, content, appSecret))
|
||||
* 不支持的算法返回 null。
|
||||
*/
|
||||
private static function sign(string $algorithm, string $content, string $secret): ?string
|
||||
{
|
||||
switch (trim($algorithm)) {
|
||||
case 'md5-salt':
|
||||
return md5($content);
|
||||
case 'HMacMD5':
|
||||
return base64_encode(hash_hmac('md5', $content, $secret, true));
|
||||
case 'HMacSHA1':
|
||||
return base64_encode(hash_hmac('sha1', $content, $secret, true));
|
||||
case 'HMacSHA256':
|
||||
return base64_encode(hash_hmac('sha256', $content, $secret, true));
|
||||
case 'HMacSHA512':
|
||||
return base64_encode(hash_hmac('sha512', $content, $secret, true));
|
||||
default:
|
||||
Log::warning('JdLogisticsService unsupported algorithm', ['algorithm' => $algorithm]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归在响应 JSON 中找出「轨迹行数组」:取出现轨迹行最多的一组。
|
||||
* 兼容字段被序列化成 JSON 字符串(如 querytrace_result 为 string)的情况。
|
||||
*
|
||||
* @param mixed $node
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private static function extractTraceRows($node): array
|
||||
{
|
||||
$best = [];
|
||||
|
||||
$walk = function ($n) use (&$walk, &$best): void {
|
||||
if (is_string($n)) {
|
||||
$trimmed = trim($n);
|
||||
if ($trimmed !== '' && ($trimmed[0] === '{' || $trimmed[0] === '[')) {
|
||||
$decoded = json_decode($trimmed, true);
|
||||
if (is_array($decoded)) {
|
||||
$walk($decoded);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
if (!is_array($n)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 是否为「轨迹行的列表」:连续数字键、且元素是带时间/描述字段的关联数组
|
||||
if (self::isList($n)) {
|
||||
$rows = [];
|
||||
foreach ($n as $item) {
|
||||
if (is_array($item) && self::rowHasTraceFields($item)) {
|
||||
$rows[] = $item;
|
||||
}
|
||||
}
|
||||
if (count($rows) > count($best)) {
|
||||
$best = $rows;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($n as $v) {
|
||||
$walk($v);
|
||||
}
|
||||
};
|
||||
|
||||
$walk($node);
|
||||
|
||||
return $best;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
*/
|
||||
private static function rowHasTraceFields(array $row): bool
|
||||
{
|
||||
$hasTime = false;
|
||||
foreach (self::TIME_FIELDS as $f) {
|
||||
if (isset($row[$f]) && trim((string) $row[$f]) !== '') {
|
||||
$hasTime = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$hasTime) {
|
||||
return false;
|
||||
}
|
||||
foreach (self::CONTEXT_FIELDS as $f) {
|
||||
if (isset($row[$f]) && trim((string) $row[$f]) !== '') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string,mixed>> $rows
|
||||
* @return list<array{time:string,context:string,status:string,_unix:int}>
|
||||
*/
|
||||
private static function normalizeRows(array $rows): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$time = '';
|
||||
foreach (self::TIME_FIELDS as $f) {
|
||||
if (isset($row[$f]) && trim((string) $row[$f]) !== '') {
|
||||
$time = trim((string) $row[$f]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
$context = '';
|
||||
foreach (self::CONTEXT_FIELDS as $f) {
|
||||
if (isset($row[$f]) && trim((string) $row[$f]) !== '') {
|
||||
$context = trim((string) $row[$f]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($time === '' && $context === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$unix = self::parseTimeToUnix($time);
|
||||
$out[] = [
|
||||
'time' => $time !== '' ? self::formatTime($time, $unix) : '',
|
||||
'context' => $context,
|
||||
'status' => '',
|
||||
'_unix' => $unix,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function parseTimeToUnix(string $time): int
|
||||
{
|
||||
$t = trim($time);
|
||||
if ($t === '') {
|
||||
return 0;
|
||||
}
|
||||
// 毫秒时间戳
|
||||
if (preg_match('/^\d{13}$/', $t)) {
|
||||
return (int) ((int) $t / 1000);
|
||||
}
|
||||
// 秒时间戳
|
||||
if (preg_match('/^\d{10}$/', $t)) {
|
||||
return (int) $t;
|
||||
}
|
||||
$p = strtotime($t);
|
||||
|
||||
return $p !== false ? (int) $p : 0;
|
||||
}
|
||||
|
||||
private static function formatTime(string $raw, int $unix): string
|
||||
{
|
||||
// 纯时间戳统一格式化成可读时间,便于落库/前端展示
|
||||
if ($unix > 0 && preg_match('/^\d{10,13}$/', trim($raw))) {
|
||||
return date('Y-m-d H:i:s', $unix);
|
||||
}
|
||||
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $arr
|
||||
*/
|
||||
private static function isList(array $arr): bool
|
||||
{
|
||||
if ($arr === []) {
|
||||
return false;
|
||||
}
|
||||
if (function_exists('array_is_list')) {
|
||||
return array_is_list($arr);
|
||||
}
|
||||
|
||||
return array_keys($arr) === range(0, count($arr) - 1);
|
||||
}
|
||||
|
||||
private static function looksSigned(string $hay): bool
|
||||
{
|
||||
if ($hay === '') {
|
||||
return false;
|
||||
}
|
||||
foreach (['准备签收', '待签收', '等待签收', '预计', '即将送达'] as $neg) {
|
||||
if (mb_stripos($hay, $neg) !== false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
foreach (['签收', '妥投', '送达', '已放在'] as $k) {
|
||||
if (mb_stripos($hay, $k) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $headers
|
||||
*/
|
||||
private static function httpPostJson(string $url, string $body, array $headers): ?string
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
return null;
|
||||
}
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
$resp = curl_exec($ch);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($resp === false) {
|
||||
Log::warning('JdLogisticsService http error', ['url' => $url, 'error' => $err]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $resp;
|
||||
}
|
||||
}
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
use think\facade\Config;
|
||||
use think\facade\Log;
|
||||
|
||||
/**
|
||||
* 京东官方物流轨迹查询(京东物流开放平台 LOP,https://api.jdl.com)
|
||||
*
|
||||
* 作用:作为快递100 对「京东自营运单(JDVE…)」轨迹陈旧/缺失时的兜底数据源,
|
||||
* 同时供后台「京东接口更新」按钮直接拉取并落库。
|
||||
* 仅在 config/logistics.php 的 jd.enable=true(填好 app_key/app_secret/access_token)时生效;
|
||||
* 未配置时 isConfigured()=false,ExpressTrackService 完全沿用快递100 逻辑,互不影响。
|
||||
*
|
||||
* 接口:京东物流标准轨迹服务 /jd/tracking/query(2025-04-29 改版,对接方案编码 Tracking_JD)。
|
||||
* 调用走 LOP 统一网关,鉴权/签名规则与官方 SDK(IsvFilter) 完全一致:
|
||||
* - 公共参数(app_key/access_token/timestamp/v/sign/algorithm/LOP-DN)以 query string 拼到 URL;
|
||||
* - 业务参数 JSON 字符串作为请求体;待签串固定顺序拼接并首尾包 app_secret。
|
||||
* 加签算法由 .env JD_LOGISTICS_ALGORITHM 控制(默认 md5-salt=md5(content),另支持 HMacMD5/SHA1/SHA256/SHA512)。
|
||||
* 注意:后台「报文加解密密钥」的 RSA 公私钥仅用于报文加解密,与本网关签名无关。
|
||||
*
|
||||
* 网关/path/对接方案编码/单号类型 走 .env(JD_LOGISTICS_GATEWAY / METHOD / LOP_DN / REFERENCE_TYPE)。
|
||||
* 响应解析采用「递归找轨迹行」的宽松策略,兼容多种返回结构。
|
||||
*/
|
||||
final class JdLogisticsService
|
||||
{
|
||||
/** 轨迹行「时间」候选字段(按优先级) */
|
||||
private const TIME_FIELDS = [
|
||||
'operationTime', 'operatorTime', 'opeTime', 'operateTime', 'msgTime', 'scanTime',
|
||||
'time', 'createTime', 'waybillStateTime', 'orderTime',
|
||||
];
|
||||
|
||||
/** 轨迹行「描述」候选字段(按优先级) */
|
||||
private const CONTEXT_FIELDS = [
|
||||
'remark', 'operateRemark', 'opeRemark', 'content', 'opeTitle',
|
||||
'operationCodeName', 'operationTypeName', 'scanTypeName', 'waybillStateName', 'message', 'desc', 'msg',
|
||||
];
|
||||
|
||||
public static function isConfigured(): bool
|
||||
{
|
||||
$cfg = Config::get('logistics.jd', []);
|
||||
|
||||
// LOP 网关签名用 app_secret(md5-salt / HMAC),不需要 RSA 私钥(私钥仅用于报文加解密)
|
||||
return !empty($cfg['enable'])
|
||||
&& trim((string) ($cfg['app_key'] ?? '')) !== ''
|
||||
&& trim((string) ($cfg['app_secret'] ?? '')) !== ''
|
||||
&& trim((string) ($cfg['access_token'] ?? '')) !== '';
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询京东官方轨迹,返回与 ExpressTrackService::query 兼容的结构(失败/未配置返回 null)。
|
||||
*
|
||||
* @return array{
|
||||
* traces: list<array{time:string,context:string,status:string}>,
|
||||
* state: string,
|
||||
* state_text: string,
|
||||
* source: string,
|
||||
* hint: string,
|
||||
* newest_unix: int
|
||||
* }|null
|
||||
*/
|
||||
public static function queryTrace(string $waybillCode, string $phoneTail = ''): ?array
|
||||
{
|
||||
$num = trim($waybillCode);
|
||||
if ($num === '' || !self::isConfigured()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cfg = Config::get('logistics.jd', []);
|
||||
|
||||
// 京东物流标准轨迹服务 /jd/tracking/query:body 为 JSON 数组 [{referenceNumber, referenceType, phone}]
|
||||
$row = [
|
||||
(string) ($cfg['reference_field'] ?? 'referenceNumber') => $num,
|
||||
'referenceType' => (string) ($cfg['reference_type'] ?? '20000'),
|
||||
];
|
||||
$tail = substr(preg_replace('/\D/', '', $phoneTail) ?? '', -4);
|
||||
if ($tail !== '') {
|
||||
$row['phone'] = $tail;
|
||||
}
|
||||
$customerCode = trim((string) ($cfg['customer_code'] ?? ''));
|
||||
if ($customerCode !== '') {
|
||||
$row['customerCode'] = $customerCode;
|
||||
}
|
||||
$body = json_encode([$row], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$raw = self::request($cfg, (string) $body);
|
||||
if ($raw === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$json = json_decode($raw, true);
|
||||
if (!is_array($json)) {
|
||||
Log::warning('JdLogisticsService invalid json', ['raw' => mb_substr($raw, 0, 500), 'num' => $num]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
// LOP 网关/业务错误:code 非 1000(成功)时记录原始报文,便于排查鉴权/单号/权限问题
|
||||
$code = (string) ($json['code'] ?? $json['resultCode'] ?? '');
|
||||
if ($code !== '' && !in_array($code, ['1000', '0000', '0'], true)) {
|
||||
Log::warning('JdLogisticsService lop error', [
|
||||
'num' => $num,
|
||||
'code' => $code,
|
||||
'message' => (string) ($json['msg'] ?? $json['message'] ?? $json['resultMessage'] ?? ''),
|
||||
'raw' => mb_substr($raw, 0, 500),
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
// 兼容 JOS 网关层错误结构
|
||||
if (isset($json['error_response'])) {
|
||||
Log::warning('JdLogisticsService gateway error', [
|
||||
'num' => $num,
|
||||
'error' => $json['error_response'],
|
||||
]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$rows = self::extractTraceRows($json);
|
||||
if ($rows === []) {
|
||||
Log::info('JdLogisticsService no trace rows', ['num' => $num, 'json' => mb_substr($raw, 0, 800)]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
$traces = self::normalizeRows($rows);
|
||||
if ($traces === []) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 时间倒序(最新在前),与快递100 输出一致
|
||||
usort($traces, static function (array $a, array $b): int {
|
||||
return ($b['_unix'] ?? 0) <=> ($a['_unix'] ?? 0);
|
||||
});
|
||||
|
||||
$newestUnix = (int) ($traces[0]['_unix'] ?? 0);
|
||||
$signed = false;
|
||||
foreach ($traces as $t) {
|
||||
if (self::looksSigned((string) $t['context'])) {
|
||||
$signed = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
$state = $signed ? '3' : '0';
|
||||
|
||||
// 去掉内部辅助字段
|
||||
$clean = [];
|
||||
foreach ($traces as $t) {
|
||||
$clean[] = [
|
||||
'time' => (string) $t['time'],
|
||||
'context' => (string) $t['context'],
|
||||
'status' => (string) ($t['status'] ?? ''),
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'traces' => $clean,
|
||||
'state' => $state,
|
||||
'state_text' => $signed ? '已签收' : '在途',
|
||||
'source' => 'jd_official',
|
||||
'hint' => '',
|
||||
'newest_unix' => $newestUnix,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* 调用 LOP 网关(统一鉴权/签名,与官方 SDK IsvFilter 一致)。
|
||||
*
|
||||
* 公共参数以 query string 拼到 URL;业务参数(JSON 字符串)作为请求体;
|
||||
* 网关靠 LOP-DN(对接方案编码) 路由到对应服务。
|
||||
*
|
||||
* @param array<string,mixed> $cfg
|
||||
* @param string $body 业务参数 JSON 字符串(param_json)
|
||||
*/
|
||||
private static function request(array $cfg, string $body): ?string
|
||||
{
|
||||
$appKey = (string) ($cfg['app_key'] ?? '');
|
||||
$appSecret = (string) ($cfg['app_secret'] ?? '');
|
||||
$accessToken = (string) ($cfg['access_token'] ?? '');
|
||||
$path = (string) ($cfg['method'] ?? '/jd/tracking/query');
|
||||
$version = (string) ($cfg['api_version'] ?? '2.0');
|
||||
$algorithm = trim((string) ($cfg['algorithm'] ?? 'md5-salt')) ?: 'md5-salt';
|
||||
$lopDn = (string) ($cfg['lop_dn'] ?? 'Tracking_JD');
|
||||
// 时间戳与时区必须自洽(否则网关报 471 时间戳已失效):统一用北京时间 + lop-tz=8
|
||||
$now = new \DateTime('now', new \DateTimeZone('Asia/Shanghai'));
|
||||
$timestamp = $now->format('Y-m-d H:i:s');
|
||||
|
||||
// 待签串:固定顺序拼接,首尾包 appSecret(method=接口path,param_json=业务体)
|
||||
$content = implode('', [
|
||||
$appSecret,
|
||||
'access_token', $accessToken,
|
||||
'app_key', $appKey,
|
||||
'method', $path,
|
||||
'param_json', $body,
|
||||
'timestamp', $timestamp,
|
||||
'v', $version,
|
||||
$appSecret,
|
||||
]);
|
||||
$sign = self::sign($algorithm, $content, $appSecret);
|
||||
if ($sign === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$query = [
|
||||
'LOP-DN' => $lopDn,
|
||||
'app_key' => $appKey,
|
||||
'access_token' => $accessToken,
|
||||
'timestamp' => $timestamp,
|
||||
'v' => $version,
|
||||
'sign' => $sign,
|
||||
'algorithm' => $algorithm,
|
||||
];
|
||||
|
||||
$base = rtrim((string) ($cfg['gateway'] ?? 'https://api.jdl.com'), '/');
|
||||
$url = $base . $path . '?' . http_build_query($query);
|
||||
|
||||
// lop-tz:与 timestamp 同源(北京时间 = 东八区 = 8)
|
||||
$offsetHours = (int) ($now->getOffset() / 3600);
|
||||
|
||||
return self::httpPostJson($url, $body, [
|
||||
'Content-Type: application/json;charset=utf-8',
|
||||
'User-Agent: lop-http/php',
|
||||
'lop-tz: ' . $offsetHours,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* LOP 网关签名(与官方 SDK Utils::sign 一致):
|
||||
* - md5-salt :md5(content) 的小写十六进制
|
||||
* - HMacMD5 / HMacSHA1 / HMacSHA256 / HMacSHA512:base64(hmac(算法, content, appSecret))
|
||||
* 不支持的算法返回 null。
|
||||
*/
|
||||
private static function sign(string $algorithm, string $content, string $secret): ?string
|
||||
{
|
||||
switch (trim($algorithm)) {
|
||||
case 'md5-salt':
|
||||
return md5($content);
|
||||
case 'HMacMD5':
|
||||
return base64_encode(hash_hmac('md5', $content, $secret, true));
|
||||
case 'HMacSHA1':
|
||||
return base64_encode(hash_hmac('sha1', $content, $secret, true));
|
||||
case 'HMacSHA256':
|
||||
return base64_encode(hash_hmac('sha256', $content, $secret, true));
|
||||
case 'HMacSHA512':
|
||||
return base64_encode(hash_hmac('sha512', $content, $secret, true));
|
||||
default:
|
||||
Log::warning('JdLogisticsService unsupported algorithm', ['algorithm' => $algorithm]);
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归在响应 JSON 中找出「轨迹行数组」:取出现轨迹行最多的一组。
|
||||
* 兼容字段被序列化成 JSON 字符串(如 querytrace_result 为 string)的情况。
|
||||
*
|
||||
* @param mixed $node
|
||||
* @return list<array<string,mixed>>
|
||||
*/
|
||||
private static function extractTraceRows($node): array
|
||||
{
|
||||
$best = [];
|
||||
|
||||
$walk = function ($n) use (&$walk, &$best): void {
|
||||
if (is_string($n)) {
|
||||
$trimmed = trim($n);
|
||||
if ($trimmed !== '' && ($trimmed[0] === '{' || $trimmed[0] === '[')) {
|
||||
$decoded = json_decode($trimmed, true);
|
||||
if (is_array($decoded)) {
|
||||
$walk($decoded);
|
||||
}
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
if (!is_array($n)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 是否为「轨迹行的列表」:连续数字键、且元素是带时间/描述字段的关联数组
|
||||
if (self::isList($n)) {
|
||||
$rows = [];
|
||||
foreach ($n as $item) {
|
||||
if (is_array($item) && self::rowHasTraceFields($item)) {
|
||||
$rows[] = $item;
|
||||
}
|
||||
}
|
||||
if (count($rows) > count($best)) {
|
||||
$best = $rows;
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($n as $v) {
|
||||
$walk($v);
|
||||
}
|
||||
};
|
||||
|
||||
$walk($node);
|
||||
|
||||
return $best;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $row
|
||||
*/
|
||||
private static function rowHasTraceFields(array $row): bool
|
||||
{
|
||||
$hasTime = false;
|
||||
foreach (self::TIME_FIELDS as $f) {
|
||||
if (isset($row[$f]) && trim((string) $row[$f]) !== '') {
|
||||
$hasTime = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$hasTime) {
|
||||
return false;
|
||||
}
|
||||
foreach (self::CONTEXT_FIELDS as $f) {
|
||||
if (isset($row[$f]) && trim((string) $row[$f]) !== '') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, array<string,mixed>> $rows
|
||||
* @return list<array{time:string,context:string,status:string,_unix:int}>
|
||||
*/
|
||||
private static function normalizeRows(array $rows): array
|
||||
{
|
||||
$out = [];
|
||||
foreach ($rows as $row) {
|
||||
$time = '';
|
||||
foreach (self::TIME_FIELDS as $f) {
|
||||
if (isset($row[$f]) && trim((string) $row[$f]) !== '') {
|
||||
$time = trim((string) $row[$f]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
$context = '';
|
||||
foreach (self::CONTEXT_FIELDS as $f) {
|
||||
if (isset($row[$f]) && trim((string) $row[$f]) !== '') {
|
||||
$context = trim((string) $row[$f]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if ($time === '' && $context === '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$unix = self::parseTimeToUnix($time);
|
||||
$out[] = [
|
||||
'time' => $time !== '' ? self::formatTime($time, $unix) : '',
|
||||
'context' => $context,
|
||||
'status' => '',
|
||||
'_unix' => $unix,
|
||||
];
|
||||
}
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
private static function parseTimeToUnix(string $time): int
|
||||
{
|
||||
$t = trim($time);
|
||||
if ($t === '') {
|
||||
return 0;
|
||||
}
|
||||
// 毫秒时间戳
|
||||
if (preg_match('/^\d{13}$/', $t)) {
|
||||
return (int) ((int) $t / 1000);
|
||||
}
|
||||
// 秒时间戳
|
||||
if (preg_match('/^\d{10}$/', $t)) {
|
||||
return (int) $t;
|
||||
}
|
||||
$p = strtotime($t);
|
||||
|
||||
return $p !== false ? (int) $p : 0;
|
||||
}
|
||||
|
||||
private static function formatTime(string $raw, int $unix): string
|
||||
{
|
||||
// 纯时间戳统一格式化成可读时间,便于落库/前端展示
|
||||
if ($unix > 0 && preg_match('/^\d{10,13}$/', trim($raw))) {
|
||||
return date('Y-m-d H:i:s', $unix);
|
||||
}
|
||||
|
||||
return $raw;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<mixed> $arr
|
||||
*/
|
||||
private static function isList(array $arr): bool
|
||||
{
|
||||
if ($arr === []) {
|
||||
return false;
|
||||
}
|
||||
if (function_exists('array_is_list')) {
|
||||
return array_is_list($arr);
|
||||
}
|
||||
|
||||
return array_keys($arr) === range(0, count($arr) - 1);
|
||||
}
|
||||
|
||||
private static function looksSigned(string $hay): bool
|
||||
{
|
||||
if ($hay === '') {
|
||||
return false;
|
||||
}
|
||||
foreach (['准备签收', '待签收', '等待签收', '预计', '即将送达'] as $neg) {
|
||||
if (mb_stripos($hay, $neg) !== false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
foreach (['签收', '妥投', '送达', '已放在'] as $k) {
|
||||
if (mb_stripos($hay, $k) !== false) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param list<string> $headers
|
||||
*/
|
||||
private static function httpPostJson(string $url, string $body, array $headers): ?string
|
||||
{
|
||||
if (!function_exists('curl_init')) {
|
||||
return null;
|
||||
}
|
||||
$ch = curl_init();
|
||||
curl_setopt($ch, CURLOPT_URL, $url);
|
||||
curl_setopt($ch, CURLOPT_POST, true);
|
||||
curl_setopt($ch, CURLOPT_POSTFIELDS, $body);
|
||||
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
|
||||
curl_setopt($ch, CURLOPT_TIMEOUT, 15);
|
||||
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false);
|
||||
curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, false);
|
||||
$resp = curl_exec($ch);
|
||||
$err = curl_error($ch);
|
||||
curl_close($ch);
|
||||
|
||||
if ($resp === false) {
|
||||
Log::warning('JdLogisticsService http error', ['url' => $url, 'error' => $err]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
return (string) $resp;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,166 +1,166 @@
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||
// | 开源版本可自由商用,可去除界面版权logo
|
||||
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||
// | 访问官网:https://www.likeadmin.cn
|
||||
// | likeadmin团队 版权所有 拥有最终解释权
|
||||
// +----------------------------------------------------------------------
|
||||
// | author: likeadminTeam
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
|
||||
use app\common\enum\ExportEnum;
|
||||
use app\common\lists\BaseDataLists;
|
||||
use app\common\lists\ListsExcelInterface;
|
||||
use app\common\lists\ListsExtendInterface;
|
||||
use think\facade\Config;
|
||||
use think\Response;
|
||||
use think\response\Json;
|
||||
use think\exception\HttpResponseException;
|
||||
|
||||
class JsonService
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 接口操作成功,返回信息
|
||||
* @param string $msg
|
||||
* @param array $data
|
||||
* @param int $code
|
||||
* @param int $show
|
||||
* @return Json
|
||||
* @author 段誉
|
||||
* @date 2021/12/24 18:28
|
||||
*/
|
||||
public static function success(string $msg = 'success', array $data = [], int $code = 1, int $show = 1): Json
|
||||
{
|
||||
return self::result($code, $show, $msg, $data);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 接口操作失败,返回信息
|
||||
* @param string $msg
|
||||
* @param array $data
|
||||
* @param int $code
|
||||
* @param int $show
|
||||
* @return Json
|
||||
* @author 段誉
|
||||
* @date 2021/12/24 18:28
|
||||
*/
|
||||
public static function fail(string $msg = 'fail', array $data = [], int $code = 0, int $show = 1): Json
|
||||
{
|
||||
return self::result($code, $show, $msg, $data);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 接口返回数据
|
||||
* @param $data
|
||||
* @return Json
|
||||
* @author 段誉
|
||||
* @date 2021/12/24 18:29
|
||||
*/
|
||||
public static function data($data): Json
|
||||
{
|
||||
return self::success('', $data, 1, 0);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 接口返回信息
|
||||
* @param int $code
|
||||
* @param int $show
|
||||
* @param string $msg
|
||||
* @param array $data
|
||||
* @param int $httpStatus
|
||||
* @return Json
|
||||
* @author 段誉
|
||||
* @date 2021/12/24 18:29
|
||||
*/
|
||||
private static function result(int $code, int $show, string $msg = 'OK', array $data = [], int $httpStatus = 200): Json
|
||||
{
|
||||
$result = compact('code', 'show', 'msg', 'data');
|
||||
return json($result, $httpStatus);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 抛出异常json
|
||||
* @param string $msg
|
||||
* @param array $data
|
||||
* @param int $code
|
||||
* @param int $show
|
||||
* @return Json
|
||||
* @author 段誉
|
||||
* @date 2021/12/24 18:29
|
||||
*/
|
||||
public static function throw(string $msg = 'fail', array $data = [], int $code = 0, int $show = 1): Json
|
||||
{
|
||||
$data = compact('code', 'show', 'msg', 'data');
|
||||
$response = Response::create($data, 'json', 200);
|
||||
throw new HttpResponseException($response);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 数据列表
|
||||
* @param \app\common\lists\BaseDataLists $lists
|
||||
* @return \think\response\Json
|
||||
* @author 令狐冲
|
||||
* @date 2021/7/28 11:15
|
||||
*/
|
||||
public static function dataLists(BaseDataLists $lists): Json
|
||||
{
|
||||
//获取导出信息
|
||||
if ($lists->export == ExportEnum::INFO && $lists instanceof ListsExcelInterface) {
|
||||
self::relaxLimitsForExcelExport();
|
||||
|
||||
return self::data($lists->excelInfo());
|
||||
}
|
||||
|
||||
//获取导出文件的下载链接
|
||||
if ($lists->export == ExportEnum::EXPORT && $lists instanceof ListsExcelInterface) {
|
||||
self::relaxLimitsForExcelExport();
|
||||
$exportDownloadUrl = $lists->createExcel($lists->setExcelFields(), $lists->lists());
|
||||
|
||||
return self::success('', ['url' => $exportDownloadUrl], 2);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'lists' => $lists->lists(),
|
||||
'count' => $lists->count(),
|
||||
'page_no' => $lists->pageNo,
|
||||
'page_size' => $lists->pageSize,
|
||||
];
|
||||
$data['extend'] = [];
|
||||
if ($lists instanceof ListsExtendInterface) {
|
||||
$data['extend'] = $lists->extend();
|
||||
}
|
||||
return self::success('', $data, 1, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Excel 导出:拉数 + PhpSpreadsheet 易超过默认 max_execution_time=30
|
||||
*/
|
||||
private static function relaxLimitsForExcelExport(): void
|
||||
{
|
||||
@set_time_limit(0);
|
||||
$max = Config::get('project.lists.export_max_execution_time', 600);
|
||||
$max = is_numeric($max) ? (int) $max : 600;
|
||||
if ($max > 0) {
|
||||
@ini_set('max_execution_time', (string) $max);
|
||||
}
|
||||
$mem = Config::get('project.lists.export_memory_limit', '512M');
|
||||
if (is_string($mem) && $mem !== '') {
|
||||
@ini_set('memory_limit', $mem);
|
||||
}
|
||||
}
|
||||
<?php
|
||||
// +----------------------------------------------------------------------
|
||||
// | likeadmin快速开发前后端分离管理后台(PHP版)
|
||||
// +----------------------------------------------------------------------
|
||||
// | 欢迎阅读学习系统程序代码,建议反馈是我们前进的动力
|
||||
// | 开源版本可自由商用,可去除界面版权logo
|
||||
// | gitee下载:https://gitee.com/likeshop_gitee/likeadmin
|
||||
// | github下载:https://github.com/likeshop-github/likeadmin
|
||||
// | 访问官网:https://www.likeadmin.cn
|
||||
// | likeadmin团队 版权所有 拥有最终解释权
|
||||
// +----------------------------------------------------------------------
|
||||
// | author: likeadminTeam
|
||||
// +----------------------------------------------------------------------
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace app\common\service;
|
||||
|
||||
|
||||
use app\common\enum\ExportEnum;
|
||||
use app\common\lists\BaseDataLists;
|
||||
use app\common\lists\ListsExcelInterface;
|
||||
use app\common\lists\ListsExtendInterface;
|
||||
use think\facade\Config;
|
||||
use think\Response;
|
||||
use think\response\Json;
|
||||
use think\exception\HttpResponseException;
|
||||
|
||||
class JsonService
|
||||
{
|
||||
|
||||
/**
|
||||
* @notes 接口操作成功,返回信息
|
||||
* @param string $msg
|
||||
* @param array $data
|
||||
* @param int $code
|
||||
* @param int $show
|
||||
* @return Json
|
||||
* @author 段誉
|
||||
* @date 2021/12/24 18:28
|
||||
*/
|
||||
public static function success(string $msg = 'success', array $data = [], int $code = 1, int $show = 1): Json
|
||||
{
|
||||
return self::result($code, $show, $msg, $data);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 接口操作失败,返回信息
|
||||
* @param string $msg
|
||||
* @param array $data
|
||||
* @param int $code
|
||||
* @param int $show
|
||||
* @return Json
|
||||
* @author 段誉
|
||||
* @date 2021/12/24 18:28
|
||||
*/
|
||||
public static function fail(string $msg = 'fail', array $data = [], int $code = 0, int $show = 1): Json
|
||||
{
|
||||
return self::result($code, $show, $msg, $data);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 接口返回数据
|
||||
* @param $data
|
||||
* @return Json
|
||||
* @author 段誉
|
||||
* @date 2021/12/24 18:29
|
||||
*/
|
||||
public static function data($data): Json
|
||||
{
|
||||
return self::success('', $data, 1, 0);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 接口返回信息
|
||||
* @param int $code
|
||||
* @param int $show
|
||||
* @param string $msg
|
||||
* @param array $data
|
||||
* @param int $httpStatus
|
||||
* @return Json
|
||||
* @author 段誉
|
||||
* @date 2021/12/24 18:29
|
||||
*/
|
||||
private static function result(int $code, int $show, string $msg = 'OK', array $data = [], int $httpStatus = 200): Json
|
||||
{
|
||||
$result = compact('code', 'show', 'msg', 'data');
|
||||
return json($result, $httpStatus);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 抛出异常json
|
||||
* @param string $msg
|
||||
* @param array $data
|
||||
* @param int $code
|
||||
* @param int $show
|
||||
* @return Json
|
||||
* @author 段誉
|
||||
* @date 2021/12/24 18:29
|
||||
*/
|
||||
public static function throw(string $msg = 'fail', array $data = [], int $code = 0, int $show = 1): Json
|
||||
{
|
||||
$data = compact('code', 'show', 'msg', 'data');
|
||||
$response = Response::create($data, 'json', 200);
|
||||
throw new HttpResponseException($response);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @notes 数据列表
|
||||
* @param \app\common\lists\BaseDataLists $lists
|
||||
* @return \think\response\Json
|
||||
* @author 令狐冲
|
||||
* @date 2021/7/28 11:15
|
||||
*/
|
||||
public static function dataLists(BaseDataLists $lists): Json
|
||||
{
|
||||
//获取导出信息
|
||||
if ($lists->export == ExportEnum::INFO && $lists instanceof ListsExcelInterface) {
|
||||
self::relaxLimitsForExcelExport();
|
||||
|
||||
return self::data($lists->excelInfo());
|
||||
}
|
||||
|
||||
//获取导出文件的下载链接
|
||||
if ($lists->export == ExportEnum::EXPORT && $lists instanceof ListsExcelInterface) {
|
||||
self::relaxLimitsForExcelExport();
|
||||
$exportDownloadUrl = $lists->createExcel($lists->setExcelFields(), $lists->lists());
|
||||
|
||||
return self::success('', ['url' => $exportDownloadUrl], 2);
|
||||
}
|
||||
|
||||
$data = [
|
||||
'lists' => $lists->lists(),
|
||||
'count' => $lists->count(),
|
||||
'page_no' => $lists->pageNo,
|
||||
'page_size' => $lists->pageSize,
|
||||
];
|
||||
$data['extend'] = [];
|
||||
if ($lists instanceof ListsExtendInterface) {
|
||||
$data['extend'] = $lists->extend();
|
||||
}
|
||||
return self::success('', $data, 1, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Excel 导出:拉数 + PhpSpreadsheet 易超过默认 max_execution_time=30
|
||||
*/
|
||||
private static function relaxLimitsForExcelExport(): void
|
||||
{
|
||||
@set_time_limit(0);
|
||||
$max = Config::get('project.lists.export_max_execution_time', 600);
|
||||
$max = is_numeric($max) ? (int) $max : 600;
|
||||
if ($max > 0) {
|
||||
@ini_set('max_execution_time', (string) $max);
|
||||
}
|
||||
$mem = Config::get('project.lists.export_memory_limit', '512M');
|
||||
if (is_string($mem) && $mem !== '') {
|
||||
@ini_set('memory_limit', $mem);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user