CRMEB/backend/modules/service/mcp/tools/UploadImageTool.php
sugar1569 e83409da76 feat: 新增 MCP 图片直传与实物商品工具能力
本次提交新增 MCP 图片直传功能:包含图片直传路由、票据签发服务、直传端点,解决跨机场景图片上传问题;同时新增实物商品插件工具,支持商品详情读取、新增、编辑,完善 MCP 生态。
2026-09-27 04:14:22 +08:00

652 lines
28 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
// +----------------------------------------------------------------------
// | 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 modules\service\mcp\tools;
use app\services\driver\upload\UploadService;
use app\services\system\attachment\SystemAttachmentServices;
use crmeb\interfaces\McpToolInterface;
use crmeb\services\CacheService;
use crmeb\utils\DownloadImage;
use modules\service\mcp\McpException;
use think\facade\Config;
use think\facade\Log;
/**
* MCP 工具:图片上传转存
*
* 把 AI 客户端产出的图片写入系统当前配置的存储方式(eb_driver 启用驱动:
* 本地/OSS/COS/七牛/OBS 等),并按需写入后台素材库,返回可直接用于
* 商品资料等字段的站点资产地址与元数据。
*
* 来源按上下文与部署形态适配:直传暂存引用(crmeb_upload_ticket 签发票据
* + /api/mcp/upload 带外直传,本机图片上传的默认方式)> 生图接口 http(s) URL >
* 站点相对路径 > 本地白名单路径(仅 AI 与服务器同机部署时可用)>
* base64 data URI 内联(仅作无法发起本机 HTTP 上传时的最后应急,≤512KB)。
*/
class UploadImageTool implements McpToolInterface
{
/**
* 允许入库的图片 mime → 扩展名映射
*/
private const MIME_EXTENSIONS = [
'image/jpeg' => 'jpg',
'image/png' => 'png',
'image/gif' => 'gif',
'image/webp' => 'webp',
];
/**
* 本地文件后缀白名单(上传内容仍以真实 mime 二次校验)
*/
private const ALLOWED_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
/**
* 单张图片字节上限(外链下载 / 本地路径)
*/
private const MAX_IMAGE_SIZE = 10485760;
/**
* base64 内联单张字节上限(512KB,解码后)
*
* 阈值故意收紧:base64 以文本形式经过对话上下文与工具调用参数(体积约为
* 原图的 4/3),引导 AI 客户端优先走 crmeb_upload_ticket 票据直传,
* 不要把对话上下文当成图片传输通道。
*/
private const MAX_INLINE_IMAGE_SIZE = 524288;
/**
* 单次请求最多上传张数
*/
private const MAX_BATCH = 20;
/**
* 单密钥每分钟上传张数上限
*/
private const RATE_LIMIT_PER_MINUTE = 60;
/**
* options.dir 允许的存储子目录
*/
private const ALLOWED_STORAGE_DIRS = ['attach', 'store', 'product', 'comment'];
/**
* 工具名称
* @return string
*/
public function name(): string
{
return 'crmeb_upload_image';
}
/**
* 工具描述
* @return string
*/
public function description(): string
{
return '把图片写入站点存储(系统当前配置的存储方式:本地/OSS/COS/七牛/OBS 等)并登记后台素材库,'
. '返回可直接用于 crmeb_product_normal_create 等工具图片字段的站点资产地址(url)与元数据(att_id/宽高/大小/md5)。'
. '【本工具不接收本机文件字节:要上传 AI 本机生成或下载的图片,必须先调 crmeb_upload_ticket 拿票据、'
. '在本机执行上传命令,再把返回的 ref 作为 source 传入;不要把图片内容转成 base64 内联,'
. 'base64 只作无法发起本机 HTTP 上传时的最后应急】'
. '【来源优先级——请从上到下选用】'
. '1)直传暂存引用(如 runtime/upload/mcp_recv/up_x.png):本机图片上传的默认方式'
. '(先用 crmeb_upload_ticket 获取),图片字节不经过对话上下文;'
. '2)图片生成接口返回的 http(s) URL:服务端自动下载落站,单张上限10MB;'
. '3)站点已有图片相对路径(/uploads/...):校验存在后原样返回并补全元数据,不重复上传;'
. '4)本地绝对路径或 file:// 路径:仅当 AI 与服务器同机部署(自托管/脚本)时可用,'
. '白名单目录默认 runtime/upload,可用系统配置 mcp_upload_local_dirs 追加;'
. '5)base64 data URI(最后应急):仅当确实无法发起本机 HTTP 上传时才用,'
. '以纯文本占用大量对话上下文,解码后超过 512KB 拒绝。'
. '统一限制:仅支持 jpg/jpeg/png/gif/webp;相同内容按 md5 去重复用;'
. '单次最多20张,单密钥每分钟最多60张;批量中个别失败不影响其他图片,失败原因在 failed 数组逐条给出。'
. '默认写入后台素材库(write_attachment=true,可在 options 关闭),便于运营在「附件管理」中查看与复用。';
}
/**
* 工具中文名(管理后台展示)
* @return string
*/
public function label(): string
{
return '图片上传';
}
/**
* 工具分类(管理后台分组展示)
* @return string
*/
public function category(): string
{
return '系统设置';
}
/**
* 写操作声明:上传会产生新站点资产,执行前需客户端确认
* @return string
*/
public function interaction(): string
{
return 'execute';
}
/**
* 入参 Schema
* @return array
*/
public function inputSchema(): array
{
return [
'type' => 'object',
'properties' => [
'images' => [
'type' => 'array',
'maxItems' => 20,
'description' => '图片列表,必填;每项可为字符串(旧格式),'
. '或对象 {source 必填, filename 可选(落盘文件名不含扩展名,仅字母数字下划线中划线), real_name 可选(素材库显示名), pid 可选(素材分类ID,默认0)}。'
. '上传 AI 本机生成/下载的图片时,必须先调 crmeb_upload_ticket 直传并把返回的 ref 作为 source 传入,不要转成 base64 内联',
'items' => [
'type' => 'object',
'required' => ['source'],
'properties' => [
'source' => ['type' => 'string', 'description' => '图片来源。上传 AI 本机生成/下载的图片时,先调 crmeb_upload_ticket 拿票据、在本机执行上传命令,再把返回的 ref 传到这里(如 runtime/upload/mcp_recv/up_x.png),不要转 base64;也支持 http(s) URL、站点相对路径(/uploads/...)、file:// 或本地白名单路径(仅 AI 与服务器同机时可用);base64 data URI 仅作无法发起本机 HTTP 上传时的最后应急'],
'filename' => ['type' => 'string', 'description' => '期望落盘文件名(不含扩展名),缺省用内容 md5'],
'real_name' => ['type' => 'string', 'description' => '素材库显示名,缺省取原文件名'],
'pid' => ['type' => 'number', 'description' => '素材分类ID,默认 0(未分类)'],
],
],
],
'options' => [
'type' => 'object',
'properties' => [
'dir' => ['type' => 'string', 'description' => '存储子目录:attach(默认)/store/product/comment'],
'write_attachment' => ['type' => 'boolean', 'description' => '是否写入后台素材库,默认 true'],
'return_absolute' => ['type' => 'boolean', 'description' => '是否返回带站点域名的绝对地址 absolute,默认 true'],
],
'description' => '可选上传选项',
],
],
'required' => ['images'],
];
}
/**
* 执行工具
*
* @param array $args 参数(images, options)
* @return array count:成功数;total:总数;uploaded:成功明细;failed:失败明细(含原因)
* @throws McpException 参数级错误或触发限流
*/
public function execute(array $args): array
{
$items = $this->normalizeItems($args['images'] ?? null);
$options = $this->parseOptions($args['options'] ?? []);
$this->checkRateLimit(count($items));
$uploaded = [];
$failed = [];
foreach (array_values($items) as $index => $item) {
try {
$uploaded[] = ['index' => $index, 'ok' => true] + $this->storeOne($item, $options);
} catch (\Throwable $e) {
$failed[] = [
'index' => $index,
'ok' => false,
'source' => mb_substr((string)($item['source'] ?? ''), 0, 200),
'reason' => $e->getMessage() ?: '未知错误',
];
}
}
return [
'count' => count($uploaded),
'total' => count($items),
'uploaded' => $uploaded,
'failed' => $failed,
];
}
/**
* 归一化 images 入参:兼容旧格式字符串数组与新格式对象数组
* @param mixed $images 原始入参
* @return array 每项 ['source'=>, 'filename'=>, 'real_name'=>, 'pid'=>0]
* @throws McpException 参数缺失或格式错误
*/
private function normalizeItems($images): array
{
if (is_string($images)) {
$images = [$images];
}
if (!is_array($images) || $images === []) {
throw new McpException('参数错误:images 必须为非空图片列表');
}
if (count($images) > self::MAX_BATCH) {
throw new McpException('参数错误:单次最多上传 ' . self::MAX_BATCH . ' 张');
}
$items = [];
foreach ($images as $image) {
if (is_string($image)) {
$image = ['source' => $image];
}
$source = trim((string)($image['source'] ?? ''));
if ($source === '') {
throw new McpException('参数错误:images 中存在空的 source');
}
$items[] = [
'source' => $source,
'filename' => preg_replace('/[^A-Za-z0-9_-]/', '', (string)($image['filename'] ?? '')),
'real_name' => trim((string)($image['real_name'] ?? '')),
'pid' => max(0, (int)($image['pid'] ?? 0)),
];
}
return $items;
}
/**
* 解析上传选项
* @param array $options 原始入参
* @return array write_attachment / return_absolute / dir
*/
private function parseOptions(array $options): array
{
$dir = strtolower(trim((string)($options['dir'] ?? 'attach')));
return [
'write_attachment' => !isset($options['write_attachment']) || (bool)$options['write_attachment'],
'return_absolute' => !isset($options['return_absolute']) || (bool)$options['return_absolute'],
'dir' => in_array($dir, self::ALLOWED_STORAGE_DIRS, true) ? $dir : 'attach',
];
}
/**
* 按密钥限流:每分钟最多 RATE_LIMIT_PER_MINUTE 张
* @param int $count 本次请求数量
* @throws McpException 超限时抛出
*/
private function checkRateLimit(int $count): void
{
try {
$key = (string)request()->header('X-Mcp-Key', '');
} catch (\Throwable $e) {
$key = '';
}
$windowKey = 'mcp_upload_rl_' . md5($key ?: 'unknown');
$used = (int)CacheService::get($windowKey, 0);
if ($used + $count > self::RATE_LIMIT_PER_MINUTE) {
throw new McpException('上传频率超限:单密钥每分钟最多 ' . self::RATE_LIMIT_PER_MINUTE . ' 张,请稍后重试');
}
CacheService::set($windowKey, $used + $count, 60);
}
/**
* 转存单个图片,返回结构化元数据
* @param array $item 归一化后的图片项
* @param array $options 上传选项
* @return array url/absolute/att_id/filename/real_name/size/width/height/mime/md5/dedupe_hit
* @throws \RuntimeException 该张失败时抛出(由调用方隔离进 failed)
*/
private function storeOne(array $item, array $options): array
{
$source = $item['source'];
$content = null;
$dedupeHit = false;
$relative = '';
$md5 = '';
$width = 0;
$height = 0;
$mime = '';
$size = 0;
// 站点相对路径:校验文件真实存在,补全元数据后原样返回(不重复上传)
$localPath = $this->resolveLocalPath($source);
if ($localPath !== null) {
$content = $this->readLocalFile($localPath);
} elseif (str_starts_with($source, '/')) {
return $this->existingSiteAsset($source, $item, $options);
} elseif (str_starts_with($source, 'data:image/')) {
$content = $this->decodeBase64($source);
} elseif (preg_match('~^https?://~i', $source)) {
if ($this->isLocalAsset($source)) {
$relative = (string)parse_url($source, PHP_URL_PATH);
return $this->existingSiteAsset($relative, $item, $options);
}
$content = $this->fetchRemote($source);
} else {
throw new \RuntimeException('不支持的来源:仅支持本地白名单路径、file:// 路径、data URI、http(s) URL 或站点相对路径');
}
$size = strlen($content);
$md5 = md5($content);
if ($size > ($localPath !== null ? self::MAX_IMAGE_SIZE : self::MAX_INLINE_IMAGE_SIZE)) {
throw new \RuntimeException($localPath !== null || !str_starts_with($source, 'data:')
? '图片大小超过 10MB 限制'
: 'base64 内联图片解码后超过 512KB,会占用大量对话上下文;请改用 crmeb_upload_ticket 票据直传,或改用 http(s) URL / 站点相对路径 / 本地白名单路径');
}
$image = @getimagesizefromstring($content);
$mime = (string)($image['mime'] ?? '');
$ext = self::MIME_EXTENSIONS[$mime] ?? '';
if ($ext === '') {
throw new \RuntimeException('仅支持 jpg/png/gif/webp 格式图片');
}
$width = (int)($image[0] ?? 0);
$height = (int)($image[1] ?? 0);
// 落盘名:优先自定义文件名,默认内容 md5(同内容天然幂等)
$filename = ($item['filename'] !== '' ? $item['filename'] : $md5) . '.' . $ext;
$storageDir = $options['dir'] . '/' . date('Y/m/d');
$dedupeHit = $this->targetExists($storageDir, $filename, $md5);
if (!$dedupeHit) {
$this->streamUpload($storageDir, $filename, $content);
}
$relative = '/' . trim(Config::get('filesystem.disks.public.url', '/uploads'), '/') . '/' . $storageDir . '/' . $filename;
$attId = 0;
if ($options['write_attachment']) {
$attId = $this->writeAttachment($filename, $relative, $size, $ext, $item, $dedupeHit);
}
// 审计:记录来源与落站结果,便于追溯 AI 生成资产(来源截断,避免大 payload 入日志)
try {
Log::info('MCP图片上传', [
'att_id' => $attId,
'url' => $relative,
'md5' => $md5,
'size' => $size,
'source' => mb_substr(str_starts_with($source, 'data:') ? '[base64内联]' : $source, 0, 200),
'mcp_key' => md5((string)request()->header('X-Mcp-Key', '')),
]);
} catch (\Throwable $e) {
}
$result = [
'url' => $relative,
'att_id' => $attId,
'filename' => $filename,
'real_name' => $item['real_name'] !== '' ? $item['real_name'] : $filename,
'size' => $size,
'width' => $width,
'height' => $height,
'mime' => $mime,
'md5' => $md5,
'dedupe_hit' => $dedupeHit,
];
if ($options['return_absolute']) {
$result['absolute'] = rtrim((string)sys_config('site_url'), '/') . $relative;
}
return $result;
}
/**
* 解析本地文件路径(file:// 前缀、绝对路径或项目根相对路径),不属于本地路径来源时返回 null
* @param string $source 图片来源
* @return string|null 白名单内的真实文件路径;非本地路径来源返回 null
* @throws \RuntimeException 明确是本地路径但不在白名单/不存在时抛出
*/
private function resolveLocalPath(string $source): ?string
{
$path = $source;
if (str_starts_with($source, 'file://')) {
$path = substr($source, 7);
} elseif (preg_match('~^(?:https?|data):~i', $source)) {
// 外链与 base64 走各自来源分支
return null;
} elseif (str_starts_with($source, '/')) {
// /uploads/ 开头视为站点资产;其余以 / 开头的按本地绝对路径处理
if (str_starts_with($source, '/uploads/')) {
return null;
}
} else {
// 项目根相对路径:直传端点返回的暂存引用(runtime/upload/mcp_recv/...)等。
// 注意不能用 root_path():其实现会在路径尾部追加 DIRECTORY_SEPARATOR,
// 拼文件路径时生成 ".../up_x.png/" 导致 realpath/is_file 失败。
$path = app()->getRootPath() . ltrim($source, '/');
}
$realPath = realpath($path);
if ($realPath === false || !is_file($realPath)) {
throw new \RuntimeException('本地文件不存在:请传直传端点返回的暂存引用(ref),或确认文件在白名单目录(默认 runtime/upload)内');
}
foreach ($this->allowedLocalDirs() as $dir) {
if ($realPath === $dir || str_starts_with($realPath, $dir . DIRECTORY_SEPARATOR)) {
if (!in_array(strtolower(pathinfo($realPath, PATHINFO_EXTENSION)), self::ALLOWED_EXTENSIONS, true)) {
throw new \RuntimeException('仅支持 jpg/jpeg/png/gif/webp 格式图片');
}
return $realPath;
}
}
throw new \RuntimeException('本地路径不在白名单目录内(默认 runtime/upload,可配置 mcp_upload_local_dirs),拒绝读取');
}
/**
* 本地路径白名单:默认 runtime/upload,可用系统配置 mcp_upload_local_dirs 追加(逗号分隔)
* @return array 规范化后的绝对目录列表
*/
private function allowedLocalDirs(): array
{
$dirs = [realpath(root_path('runtime/upload')) ?: root_path('runtime/upload')];
foreach (explode(',', trim((string)sys_config('mcp_upload_local_dirs'))) as $extra) {
$extra = trim($extra);
if ($extra !== '') {
$dirs[] = realpath($extra) ?: $extra;
}
}
return array_values(array_unique(array_filter($dirs)));
}
/**
* 读取本地白名单文件字节
* @param string $path 白名单内的真实路径
* @return string 图片字节
* @throws \RuntimeException 不可读或超限时抛出
*/
private function readLocalFile(string $path): string
{
if (!is_readable($path)) {
throw new \RuntimeException('本地文件不可读');
}
$content = file_get_contents($path);
if ($content === false) {
throw new \RuntimeException('本地文件读取失败');
}
return $content;
}
/**
* 站点已有资产:校验文件真实存在后补全元数据原样返回
* @param string $relative 站点相对路径(/uploads/...)
* @param array $item 归一化图片项
* @param array $options 上传选项
* @return array 元数据
* @throws \RuntimeException 文件不存在时抛出
*/
private function existingSiteAsset(string $relative, array $item, array $options): array
{
$relative = '/' . ltrim($relative, '/');
$diskPath = public_path() . ltrim(str_replace('\\', '/', $relative), '/');
if (!is_file($diskPath)) {
throw new \RuntimeException('站点相对路径对应的文件不存在:' . $relative);
}
$content = (string)file_get_contents($diskPath);
$image = @getimagesizefromstring($content);
$filename = basename($relative);
$attId = 0;
if ($options['write_attachment']) {
$attId = $this->findAttachmentId($relative);
}
$result = [
'url' => $relative,
'att_id' => $attId,
'filename' => $filename,
'real_name' => $item['real_name'] !== '' ? $item['real_name'] : $filename,
'size' => filesize($diskPath) ?: strlen($content),
'width' => (int)($image[0] ?? 0),
'height' => (int)($image[1] ?? 0),
'mime' => (string)($image['mime'] ?? ''),
'md5' => md5($content),
'dedupe_hit' => true,
];
if ($options['return_absolute']) {
$result['absolute'] = rtrim((string)sys_config('site_url'), '/') . $relative;
}
return $result;
}
/**
* 解码并校验 base64 data URI 图片
* @param string $dataUri data:image/xxx;base64,... 形态
* @return string 图片字节
* @throws \RuntimeException 格式或解码失败时抛出
*/
private function decodeBase64(string $dataUri): string
{
if (!preg_match('~^data:image/(\w+);base64,([A-Za-z0-9+/=\r\n]+)$~', $dataUri, $matched)) {
throw new \RuntimeException('base64 图片格式不正确');
}
$content = base64_decode(str_replace(["\r", "\n"], '', $matched[2]), true);
if ($content === false || $content === '') {
throw new \RuntimeException('base64 图片解码失败');
}
return $content;
}
/**
* 下载外链图片(协议/内网/DNS 重绑定/大小防护由核心 DownloadImage 提供)
* @param string $url 图片地址
* @return string 图片字节
* @throws \RuntimeException 下载失败时抛出
*/
private function fetchRemote(string $url): string
{
try {
/** @var DownloadImage $down */
$down = app()->make(DownloadImage::class);
return $down->readImage($url, [], 20);
} catch (\Throwable $e) {
throw new \RuntimeException('远程图片下载失败:' . $e->getMessage(), 0, $e);
}
}
/**
* 判断目标文件在本地盘是否已存在(同内容 md5 命名天然幂等);非本地驱动返回 false
* @param string $storageDir 存储子目录
* @param string $filename 落盘文件名
* @param string $md5 内容指纹
* @return bool
*/
private function targetExists(string $storageDir, string $filename, string $md5): bool
{
try {
// uploadDir 经 BaseManager 魔术转发到驱动,method_exists 检测不到,直接调用并捕获
$diskPath = UploadService::init('local')->uploadDir($storageDir . '/' . $filename);
return is_string($diskPath) && is_file($diskPath) && md5((string)file_get_contents($diskPath)) === $md5;
} catch (\Throwable $e) {
return false;
}
}
/**
* 流式上传到系统当前配置的存储方式
* @param string $storageDir 存储子目录(attach/日期)
* @param string $filename 落盘文件名
* @param string $content 图片字节
* @throws \RuntimeException 上传失败时抛出
*/
private function streamUpload(string $storageDir, string $filename, string $content): void
{
$upload = UploadService::init();
if ($upload->to($storageDir)->validate()->setAuthThumb(false)->stream($content, $filename) === false) {
throw new \RuntimeException((string)$upload->getError() ?: '图片保存失败');
}
if ((string)($upload->getUploadInfo()['dir'] ?? '') === '') {
throw new \RuntimeException('图片保存失败:未返回存储地址');
}
}
/**
* 写入后台素材库并返回 att_id(已有同路径记录时复用)
* @param string $filename 落盘文件名
* @param string $relative 站点相对路径
* @param int $size 文件大小
* @param string $ext 扩展名
* @param array $item 归一化图片项(real_name/pid)
* @param bool $dedupeHit 是否为重复内容
* @return int att_id,写入失败返回 0
*/
private function writeAttachment(string $filename, string $relative, int $size, string $ext, array $item, bool $dedupeHit): int
{
try {
$attId = $this->findAttachmentId($relative);
if ($attId > 0) {
return $attId;
}
/** @var SystemAttachmentServices $services */
$services = app()->make(SystemAttachmentServices::class);
$services->attachmentAdd(
$filename,
$size,
$ext,
$relative,
$relative,
(int)$item['pid'],
UploadService::getEnabledImageType(),
0,
1,
0,
$item['real_name'] !== '' ? $item['real_name'] : ''
);
return $this->findAttachmentId($relative);
} catch (\Throwable $e) {
// 素材库登记失败不影响图片已落站,att_id 返回 0 供调用方感知
return 0;
}
}
/**
* 按站点相对路径查找素材库记录
* @param string $relative 站点相对路径
* @return int att_id,不存在返回 0
*/
private function findAttachmentId(string $relative): int
{
try {
/** @var SystemAttachmentServices $services */
$services = app()->make(SystemAttachmentServices::class);
$record = $services->getOne(['att_dir' => $relative]);
return $record ? (int)($record['att_id'] ?? 0) : 0;
} catch (\Throwable $e) {
return 0;
}
}
/**
* 判断 URL 是否已指向本站资产(站点域名或当前请求域名一致)
* @param string $url 图片地址
* @return bool
*/
private function isLocalAsset(string $url): bool
{
$host = strtolower((string)parse_url($url, PHP_URL_HOST));
if ($host === '') {
return false;
}
$siteHost = strtolower((string)parse_url((string)sys_config('site_url'), PHP_URL_HOST));
if ($siteHost !== '' && $host === $siteHost) {
return true;
}
try {
$requestHost = strtolower((string)request()->host());
} catch (\Throwable $e) {
$requestHost = '';
}
return $requestHost !== '' && $host === $requestHost;
}
}