mirror of
https://github.com/crmeb/CRMEB.git
synced 2026-10-28 13:31:04 +00:00
445 lines
18 KiB
PHP
445 lines
18 KiB
PHP
<?php
|
||
// +----------------------------------------------------------------------
|
||
// | CRMEB [ CRMEB赋能开发者,助力企业发展 ]
|
||
// +----------------------------------------------------------------------
|
||
// | Copyright (c) 2016~2026 https://www.crmeb.com All rights reserved.
|
||
// +----------------------------------------------------------------------
|
||
// | Licensed CRMEB并不是自由软件,未经许可不能去掉CRMEB相关版权
|
||
// +----------------------------------------------------------------------
|
||
// | Author: CRMEB Team <admin@crmeb.com>
|
||
// +----------------------------------------------------------------------
|
||
|
||
declare(strict_types=1);
|
||
|
||
namespace app\services\driver\printer;
|
||
|
||
use app\dao\driver\printer\PrinterChannelDao;
|
||
use crmeb\basic\BaseService;
|
||
use crmeb\exceptions\AdminException;
|
||
use crmeb\services\driver\DriverRegistry;
|
||
use think\Validate;
|
||
|
||
/**
|
||
* 打印机渠道管理服务
|
||
* Class PrinterChannelServices
|
||
* @package app\services\driver\printer
|
||
* @method array|object|null get($id, ?array $field = []) 获取一条数据
|
||
* @method update($id, array $data, ?string $key = null) 修改数据
|
||
* @method save(array $data) 保存数据
|
||
* @method delete($id, ?string $key = null) 删除
|
||
* @method value($where, ?string $field = '') 获取指定字段的值
|
||
*/
|
||
class PrinterChannelServices extends BaseService
|
||
{
|
||
/**
|
||
* @var PrinterChannelDao
|
||
*/
|
||
protected $dao;
|
||
|
||
/**
|
||
* 初始化
|
||
* @param PrinterChannelDao $dao
|
||
*/
|
||
public function __construct(PrinterChannelDao $dao)
|
||
{
|
||
$this->dao = $dao;
|
||
}
|
||
|
||
/**
|
||
* 获取打印机列表(含驱动状态)
|
||
* @param array $where
|
||
* @return array
|
||
*/
|
||
public function getList(array $where = []): array
|
||
{
|
||
if (array_key_exists('status', $where)) {
|
||
$where['status'] = self::validateInteger($where['status'], 0, 1, '打印机状态无效');
|
||
}
|
||
$where['is_del'] = 0;
|
||
// 设备配置列表沿用非分页数组契约,只读取展示和编辑所需字段。
|
||
$list = $this->dao->selectList($where, 'id,print_name,driver,printer_id,icon,times,print_type,print_content,sort,status,create_time,update_time', 0, 0, 'sort desc,id asc')->toArray();
|
||
$drivers = $this->getDrivers();
|
||
|
||
// 为每条记录附加驱动状态
|
||
foreach ($list as &$item) {
|
||
$item['driver_status'] = false;
|
||
$item['driver_info'] = null;
|
||
$driverName = $item['driver'] ?? '';
|
||
if ($driverName) {
|
||
foreach ($drivers as $driver) {
|
||
if ($driver['name'] === $driverName) {
|
||
$item['driver_status'] = $driver['exists'];
|
||
$item['driver_info'] = $driver;
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
$item['add_time'] = $item['add_time'] ?? $item['create_time'] ?? 0;
|
||
if (is_numeric($item['add_time'])) {
|
||
$item['add_time'] = date('Y-m-d H:i:s', $item['add_time']);
|
||
}
|
||
// 兼容 print_content 字段
|
||
if (isset($item['print_content']) && is_string($item['print_content'])) {
|
||
$item['print_content'] = json_decode($item['print_content'], true) ?: [];
|
||
}
|
||
}
|
||
|
||
return $list;
|
||
}
|
||
|
||
/**
|
||
* 获取单条详情(含当前配置值)
|
||
* @param int $id
|
||
* @return array
|
||
*/
|
||
public function getDetail(int $id): array
|
||
{
|
||
return $this->getChannel($id);
|
||
}
|
||
|
||
/**
|
||
* 新增打印机
|
||
* @param array $data
|
||
* @return bool
|
||
*/
|
||
public function saveChannel(array $data): bool
|
||
{
|
||
if (array_key_exists('print_content', $data) && !is_array($data['print_content'])) {
|
||
throw new AdminException('小票配置必须是对象');
|
||
}
|
||
$data = $this->normalizeChannelData($data);
|
||
|
||
// 从驱动注册中心读取配置字段定义(driver 字段存目录名,按 dir_name 匹配)
|
||
$driverInfo = null;
|
||
foreach (DriverRegistry::scan('printer') as $info) {
|
||
if ($info['dir_name'] === $data['driver']) {
|
||
$driverInfo = $info;
|
||
break;
|
||
}
|
||
}
|
||
if (!$driverInfo) {
|
||
throw new AdminException('打印机驱动信息不存在');
|
||
}
|
||
|
||
$saveData = [
|
||
'print_name' => $data['print_name'] ?? '',
|
||
'driver' => $data['driver'],
|
||
'printer_id' => $data['printer_id'],
|
||
'icon' => $data['icon'] ?? '',
|
||
'sort' => $data['sort'] ?? 0,
|
||
'status' => $data['status'] ?? 1,
|
||
'config_fields' => [],
|
||
'times' => $data['times'] ?? 1,
|
||
'print_type' => $data['print_type'] ?? 1,
|
||
'print_content' => json_encode($data['print_content'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR),
|
||
'is_del' => 0,
|
||
];
|
||
|
||
if ($this->dao->save($saveData) === false) {
|
||
throw new \RuntimeException('保存打印机失败');
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 更新打印机
|
||
* @param int $id
|
||
* @param array $data
|
||
* @return bool
|
||
*/
|
||
public function updateChannel(int $id, array $data): bool
|
||
{
|
||
$channel = $this->getChannel($id);
|
||
if (array_key_exists('print_content', $data) && !is_array($data['print_content'])) {
|
||
throw new AdminException('小票配置必须是对象');
|
||
}
|
||
if (array_key_exists('driver', $data) && $data['driver'] !== $channel['driver']) {
|
||
throw new AdminException('已添加的打印机不能更换驱动');
|
||
}
|
||
$normalized = $this->normalizeChannelData(array_replace($channel, $data));
|
||
$updateData = array_intersect_key($normalized, array_flip(['print_name', 'printer_id', 'icon', 'sort', 'status', 'times', 'print_type']));
|
||
if (array_key_exists('print_content', $data)) {
|
||
$updateData['print_content'] = json_encode($normalized['print_content'], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
|
||
}
|
||
if ($this->dao->update(['id' => $id, 'is_del' => 0], $updateData) === false) {
|
||
throw new \RuntimeException('更新打印机失败');
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 修改状态
|
||
* @param int $id
|
||
* @param mixed $status
|
||
* @return bool
|
||
*/
|
||
public function setStatus(int $id, $status): bool
|
||
{
|
||
$this->getChannel($id);
|
||
$status = self::validateInteger($status, 0, 1, '打印机状态无效');
|
||
if ($this->dao->update(['id' => $id, 'is_del' => 0], ['status' => $status]) === false) {
|
||
throw new \RuntimeException('修改打印机状态失败');
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 删除打印机(软删除)
|
||
* @param int $id
|
||
* @return bool
|
||
*/
|
||
public function deleteChannel(int $id): bool
|
||
{
|
||
$this->getChannel($id);
|
||
if ($this->dao->update(['id' => $id, 'is_del' => 0], ['is_del' => 1]) === false) {
|
||
throw new \RuntimeException('删除打印机失败');
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 批量排序
|
||
* @param array $sortData [{id: 1, sort: 100}, ...]
|
||
* @return bool
|
||
*/
|
||
public function setSort(array $sortData): bool
|
||
{
|
||
$validated = [];
|
||
// 先完整校验,避免后面的非法记录导致前面的排序已被修改。
|
||
foreach ($sortData as $item) {
|
||
if (!is_array($item)) {
|
||
throw new AdminException('打印机排序数据无效');
|
||
}
|
||
$id = self::validateInteger($item['id'] ?? null, 1, PHP_INT_MAX, '打印机ID无效');
|
||
$sort = self::validateInteger($item['sort'] ?? null, 0, 9999, '排序值必须是0至9999的整数');
|
||
$this->getChannel($id);
|
||
$validated[] = ['id' => $id, 'sort' => $sort];
|
||
}
|
||
foreach ($validated as $item) {
|
||
if ($this->dao->update(['id' => $item['id'], 'is_del' => 0], ['sort' => $item['sort']]) === false) {
|
||
throw new \RuntimeException('保存打印机排序失败');
|
||
}
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 获取小票打印内容配置
|
||
* @param int $id
|
||
* @return array
|
||
*/
|
||
public function printContent(int $id): array
|
||
{
|
||
$channel = $this->getChannel($id);
|
||
$content = $this->decodePrintContent($channel['print_content'] ?? null);
|
||
$content['site_url'] = (string)sys_config('site_url');
|
||
return $content;
|
||
}
|
||
|
||
/**
|
||
* 保存小票打印内容配置
|
||
* @param int $id
|
||
* @param array $data
|
||
* @return bool
|
||
*/
|
||
public function savePrintContent(int $id, array $data): bool
|
||
{
|
||
$this->getChannel($id);
|
||
$content = self::normalizePrintContent($data);
|
||
$this->validateCodeSiteUrl($content);
|
||
if ($this->dao->update(['id' => $id, 'is_del' => 0], ['print_content' => json_encode($content, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR)]) === false) {
|
||
throw new \RuntimeException('保存小票配置失败');
|
||
}
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* 默认小票模板,与管理端初始配置保持一致。
|
||
* @return array
|
||
*/
|
||
public static function defaultPrintContent(): array
|
||
{
|
||
return ['header' => 1, 'delivery' => 1, 'buyer_remarks' => 1, 'goods' => [0], 'freight' => 1,
|
||
'preferential' => 1, 'pay' => [0, 1], 'custom' => 0, 'order' => [0, 1], 'code' => 0,
|
||
'code_url' => '', 'show_notice' => 0, 'notice_content' => ''];
|
||
}
|
||
|
||
/**
|
||
* 校验并补齐小票模板,忽略接口附加信息。
|
||
* @param array $data
|
||
* @return array
|
||
*/
|
||
public static function normalizePrintContent(array $data): array
|
||
{
|
||
if ($data && array_is_list($data)) {
|
||
throw new AdminException('小票配置必须是对象');
|
||
}
|
||
$defaults = self::defaultPrintContent();
|
||
$content = array_replace($defaults, array_intersect_key($data, $defaults));
|
||
foreach (['header', 'delivery', 'buyer_remarks', 'freight', 'preferential', 'custom', 'code', 'show_notice'] as $field) {
|
||
$content[$field] = self::validateInteger($content[$field], 0, 1, '小票开关配置无效');
|
||
}
|
||
foreach (['goods' => 1, 'pay' => 1, 'order' => 3] as $field => $maximum) {
|
||
if (!is_array($content[$field]) || !array_is_list($content[$field]) || count($content[$field]) > 32) {
|
||
throw new AdminException('小票选项配置必须是数组');
|
||
}
|
||
$options = [];
|
||
foreach ($content[$field] as $value) {
|
||
$options[] = self::validateInteger($value, 0, $maximum, '小票选项配置无效');
|
||
}
|
||
$content[$field] = array_values(array_unique($options));
|
||
}
|
||
foreach (['code_url' => 2000, 'notice_content' => 1000] as $field => $length) {
|
||
if (!is_string($content[$field]) || mb_strlen($content[$field]) > $length) {
|
||
throw new AdminException('小票文本配置无效或超过长度限制');
|
||
}
|
||
$content[$field] = trim($content[$field]);
|
||
}
|
||
$url = $content['code_url'];
|
||
if ($url !== '') {
|
||
$parts = parse_url($url);
|
||
$absolute = is_array($parts) && in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true) && !empty($parts['host']);
|
||
$relative = str_starts_with($url, '/') && !str_starts_with($url, '//') && !isset($parts['scheme']);
|
||
if ((!$absolute && !$relative) || preg_match('/[\s<>]/u', $url)) {
|
||
throw new AdminException('二维码链接必须是有效的网址或站内路径');
|
||
}
|
||
} elseif ($content['code'] === 1) {
|
||
throw new AdminException('请选择二维码链接');
|
||
}
|
||
return $content;
|
||
}
|
||
|
||
/**
|
||
* 读取未删除的设备,禁止访问和恢复已删除记录。
|
||
* @param int $id
|
||
* @return array
|
||
*/
|
||
protected function getChannel(int $id): array
|
||
{
|
||
if ($id <= 0) {
|
||
throw new AdminException('打印机ID无效');
|
||
}
|
||
$channel = $this->dao->get(['id' => $id, 'is_del' => 0], ['id', 'print_name', 'driver', 'printer_id', 'icon', 'times', 'print_type', 'print_content', 'sort', 'status', 'is_del', 'create_time', 'update_time']);
|
||
if (!$channel) {
|
||
throw new AdminException('打印机不存在');
|
||
}
|
||
return $channel->toArray();
|
||
}
|
||
|
||
/**
|
||
* 解析保存的模板,空模板使用默认值,损坏模板显式报错。
|
||
* @param mixed $value
|
||
* @return array
|
||
*/
|
||
protected function decodePrintContent($value): array
|
||
{
|
||
if ($value === null || $value === '') {
|
||
return self::defaultPrintContent();
|
||
}
|
||
if (is_string($value)) {
|
||
$value = json_decode($value, true);
|
||
}
|
||
if (!is_array($value)) {
|
||
throw new AdminException('小票配置格式错误,请重新保存');
|
||
}
|
||
return self::normalizePrintContent($value);
|
||
}
|
||
|
||
/**
|
||
* 校验设备字段并转换数值,不允许非法值被强制转换为有效值。
|
||
* @param array $data
|
||
* @return array
|
||
*/
|
||
protected function normalizeChannelData(array $data): array
|
||
{
|
||
$data += ['print_name' => '', 'driver' => '', 'printer_id' => '', 'icon' => '', 'sort' => 0,
|
||
'status' => 1, 'times' => 1, 'print_type' => 1, 'print_content' => self::defaultPrintContent()];
|
||
foreach (['print_name' => 255, 'driver' => 128, 'printer_id' => 128, 'icon' => 128] as $field => $length) {
|
||
if (!is_string($data[$field]) || mb_strlen(trim($data[$field])) > $length || ($field !== 'icon' && trim($data[$field]) === '')) {
|
||
throw new AdminException('打印机名称、驱动和设备ID不能为空且不能超过长度限制');
|
||
}
|
||
$data[$field] = trim($data[$field]);
|
||
}
|
||
$data['times'] = self::validateInteger($data['times'], 1, 99, '打印联数必须是1至99的整数');
|
||
$data['print_type'] = self::validateInteger($data['print_type'], 1, 2, '打印时机无效');
|
||
$data['status'] = self::validateInteger($data['status'], 0, 1, '打印机状态无效');
|
||
$data['sort'] = self::validateInteger($data['sort'], 0, 9999, '排序值必须是0至9999的整数');
|
||
$data['print_content'] = $this->decodePrintContent($data['print_content']);
|
||
$this->validateCodeSiteUrl($data['print_content']);
|
||
return $data;
|
||
}
|
||
|
||
/**
|
||
* 站内二维码依赖配置的站点域名,不允许保存无法生成网址的模板。
|
||
* @param array $content
|
||
* @return void
|
||
*/
|
||
protected function validateCodeSiteUrl(array $content): void
|
||
{
|
||
if ($content['code'] !== 1 || !str_starts_with($content['code_url'], '/')) {
|
||
return;
|
||
}
|
||
$parts = parse_url((string)sys_config('site_url'));
|
||
if (!is_array($parts) || !in_array(strtolower($parts['scheme'] ?? ''), ['http', 'https'], true) || empty($parts['host'])) {
|
||
throw new AdminException('请先配置有效的站点域名再启用站内二维码');
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 严格校验整数和范围,允许 HTTP 表单中的整数字符串。
|
||
* @param mixed $value
|
||
* @param int $minimum
|
||
* @param int $maximum
|
||
* @param string $message
|
||
* @return int
|
||
*/
|
||
protected static function validateInteger($value, int $minimum, int $maximum, string $message): int
|
||
{
|
||
$validator = new Validate();
|
||
$validator->rule(['value' => [static function ($number) use ($minimum, $maximum, $message) {
|
||
$validType = is_int($number) || (is_string($number) && preg_match('/^\d+$/D', $number));
|
||
$integer = $validType ? filter_var($number, FILTER_VALIDATE_INT) : false;
|
||
return $integer !== false && $integer >= $minimum && $integer <= $maximum ? true : $message;
|
||
}]])->failException(true)->check(['value' => $value]);
|
||
return (int)$value;
|
||
}
|
||
|
||
/**
|
||
* 扫描打印机驱动目录,自动读取每个子目录的 plugin.json 元数据
|
||
* 扫描 driver/printer/(第三方扩展 + 内置驱动 + 插件安装的驱动)
|
||
* @return array
|
||
*/
|
||
public function getDrivers(): array
|
||
{
|
||
// 统一走驱动注册中心(读取 plugin.json 元数据),按目录名重组原契约结构
|
||
$drivers = [];
|
||
/** @var PrinterDriverServices $driverServices */
|
||
$driverServices = app()->make(PrinterDriverServices::class);
|
||
$enabled = array_column($driverServices->getAvailableDrivers(), null, 'driver_key');
|
||
foreach (DriverRegistry::scan('printer') as $info) {
|
||
$name = $info['dir_name'];
|
||
$driverKey = $info['driver_key'] ?? $name;
|
||
if (!isset($enabled[$driverKey])) {
|
||
continue;
|
||
}
|
||
// 设备选择器不需要服务商凭据,禁止透传运行时配置值。
|
||
unset($info['config_fields']);
|
||
$class = $info['namespace'] . '\\' . $info['class_name'];
|
||
$drivers[] = [
|
||
'name' => $name,
|
||
'namespace' => $class,
|
||
'class' => $class,
|
||
'file_path' => root_path() . 'driver/printer/' . $name . '/Driver.php',
|
||
'exists' => true, // 目录已在注册中心登记,视为已安装
|
||
'has_info' => true, // 登记即有 plugin.json 元数据
|
||
'description' => $info['name'],
|
||
'device_id' => $info['capabilities']['device_id'] ?? ['label' => '打印机ID', 'tip' => '请输入打印机设备ID'],
|
||
'info' => $info,
|
||
];
|
||
}
|
||
|
||
return $drivers;
|
||
}
|
||
}
|