From e83409da76f807496b627c9d989ed4e08bcdcf52 Mon Sep 17 00:00:00 2001 From: sugar1569 Date: Sun, 27 Sep 2026 04:10:27 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20MCP=20=E5=9B=BE?= =?UTF-8?q?=E7=89=87=E7=9B=B4=E4=BC=A0=E4=B8=8E=E5=AE=9E=E7=89=A9=E5=95=86?= =?UTF-8?q?=E5=93=81=E5=B7=A5=E5=85=B7=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本次提交新增 MCP 图片直传功能:包含图片直传路由、票据签发服务、直传端点,解决跨机场景图片上传问题;同时新增实物商品插件工具,支持商品详情读取、新增、编辑,完善 MCP 生态。 --- .crmeb/rules/事件规范.md | 6 +- .../crmeb-plugin-dev/references/mcp-tools.md | 14 +- backend/crmeb/utils/SafeEvent.php | 4 +- backend/modules/product/normal/event.php | 9 + .../listener/McpToolsRegisterListener.php | 41 ++ .../normal/mcp/NormalProductCreateTool.php | 246 +++++++ .../normal/mcp/NormalProductReadTool.php | 169 +++++ .../normal/mcp/NormalProductUpdateTool.php | 319 +++++++++ .../mcp/controller/McpUploadController.php | 245 +++++++ backend/modules/service/mcp/route/user.php | 4 + .../mcp/service/McpUploadTicketServices.php | 76 ++ .../service/mcp/tools/UploadImageTool.php | 651 ++++++++++++++++++ .../service/mcp/tools/UploadTicketTool.php | 170 +++++ help/docs/api/MCP接口.md | 49 +- 14 files changed, 1990 insertions(+), 13 deletions(-) create mode 100644 backend/modules/product/normal/listener/McpToolsRegisterListener.php create mode 100644 backend/modules/product/normal/mcp/NormalProductCreateTool.php create mode 100644 backend/modules/product/normal/mcp/NormalProductReadTool.php create mode 100644 backend/modules/product/normal/mcp/NormalProductUpdateTool.php create mode 100644 backend/modules/service/mcp/controller/McpUploadController.php create mode 100644 backend/modules/service/mcp/service/McpUploadTicketServices.php create mode 100644 backend/modules/service/mcp/tools/UploadImageTool.php create mode 100644 backend/modules/service/mcp/tools/UploadTicketTool.php diff --git a/.crmeb/rules/事件规范.md b/.crmeb/rules/事件规范.md index d5e5f4425..1aee00d9e 100644 --- a/.crmeb/rules/事件规范.md +++ b/.crmeb/rules/事件规范.md @@ -71,7 +71,7 @@ event(OrderRefundRefuseListener::class, [$orderRefundInfo]); | 级别 | 范围 | 监听器异常行为 | |-----|------|-------------| | **关键(默认)** | 订单/资金/库存等数据一致性事件、注册中心事件、服务代理与查询类事件 | 异常继续上抛,中断主流程(保证数据一致性) | -| **安全事件** | 展示扩展、通知推送、营销旁路、统计记录、渠道同步、定时任务收尾钩子(清单见 `SafeEvent::$safeEvents`) | 异常仅 `Log::error` 记录,继续执行后续监听器 | +| **安全事件** | 展示扩展、通知推送、营销旁路、统计记录、渠道同步、定时任务收尾钩子、插件工具注册(McpToolsRegister)(清单见 `SafeEvent::$safeEvents`) | 异常仅 `Log::error` 记录,继续执行后续监听器 | | **安全监听器(任何事件下)** | 类名以 `NoticeListener` 或 `ExtendListener` 结尾的监听器 | 同安全事件,一律隔离 | **强制约定**: @@ -248,7 +248,7 @@ v8.1.xu 采用三层架构,事件监听器分布在核心层和模块层: | 事件名 | 触发场景 | 注册位置 | |--------|---------|---------| -| `McpToolsRegister` | MCP 服务模块(`modules/service/mcp`)构建工具注册中心时,装载内置工具后触发;事件参数为 `McpToolRegistry` 实例,插件监听器调用 `$registry->register(工具类名)` 追加/覆盖同名工具,工具类须实现核心契约 `crmeb\interfaces\McpToolInterface` | 触发方:service_mcp;监听器注册在各插件自身 event.php | +| `McpToolsRegister` | MCP 服务模块(`modules/service/mcp`)构建工具注册中心时,装载内置工具后触发;事件参数按规范打包为 `[McpToolRegistry 实例]`,插件监听器解构 `[$registry] = $event;` 后调用 `$registry->register(工具类名)` 追加/覆盖同名工具,工具类须实现核心契约 `crmeb\interfaces\McpToolInterface`。已纳入安全事件清单,监听器异常隔离不中断服务 | 触发方:service_mcp;监听器注册在各插件自身 event.php | ### 4.12 系统与定时任务事件 @@ -360,6 +360,6 @@ event('UserExpChange', [$uid, $data['number'], $data['type'], $data['remark']]); **版本**: v8.1.xu **创建日期**: 2026-06-01 -**最后更新**: 2026-09-16(登记 McpToolsRegister 工具注册扩展事件;McpToolInterface 契约下沉至 crmeb\interfaces) +**最后更新**: 2026-09-27(登记 McpToolsRegister 工具注册扩展事件并纳入安全事件清单;McpToolInterface 契约下沉至 crmeb\interfaces) **适用范围**: CRMEB v8 **文档位置**: [.crmeb/rules/事件规范.md](事件规范.md) diff --git a/.crmeb/skills/crmeb-plugin-dev/references/mcp-tools.md b/.crmeb/skills/crmeb-plugin-dev/references/mcp-tools.md index 39bb915c9..99065ca3c 100644 --- a/.crmeb/skills/crmeb-plugin-dev/references/mcp-tools.md +++ b/.crmeb/skills/crmeb-plugin-dev/references/mcp-tools.md @@ -108,18 +108,20 @@ class McpToolsRegisterListener implements ListenerInterface /** * 向注册中心注册插件工具(传类名,容器延迟实例化并注入依赖) * - * @param mixed $event McpToolRegistry 实例 + * @param array $event 事件参数按项目规范打包为 [McpToolRegistry 实例],解构后使用 */ public function handle($event): void { - $event->register(CardKeyOrderListTool::class); + [$registry] = $event; + $registry->register(CardKeyOrderListTool::class); } } ``` 要点: -- 监听器内只做工具注册,**不写业务逻辑**:`McpToolsRegister` 属注册中心类事件(关键级),监听器抛异常会导致整个 MCP 接口 500。 +- 监听器内只做工具注册,**不写业务逻辑**:`McpToolsRegister` 已纳入安全事件清单(`SafeEvent::$safeEvents`),监听器抛异常会被框架隔离并记日志,仅本插件工具不生效,不影响 MCP 服务与其他插件工具。 +- 事件参数与所有 CRMEB 事件一致按数组打包,**必须先解构** `[$registry] = $event;` 再调用 `register()`,直接把 `$event` 当对象调用会报 `Call to a member function register() on array`。 - 参数类型不写 `McpToolRegistry` 类型标注(该类在 modules 层,避免产生 use 依赖);需要文档说明时用 docblock 全限定名。 - `register()` 同名覆盖既有工具——用于定制内置工具行为,也是命名冲突风险来源。 @@ -174,6 +176,12 @@ return [ 上述类的下沉是既定方向;下沉完成后本节应同步删除并更新示例。 +## 工具需要接收 AI 生成的图片 + +AI 客户端生成的图片通常是 base64 data URI 或外部临时 URL,直接入库会失效。不要在插件工具里各自实现上传转存:引导 AI 先调用内置工具 `crmeb_upload_image`(上传到系统当前配置的存储方式并登记素材库,返回站点资产地址与 att_id),插件的图片字段只接受站点地址;对直接传入的 `data:` 前缀值应拒绝并提示先调用 `crmeb_upload_image`(参照 `modules/product/normal/mcp/NormalProductCreateTool.php`)。 + +跨机场景(AI 客户端与服务器不同机,生产常态)推荐「票据直传」链路,避免 base64 撑爆对话上下文:AI 先调 `crmeb_upload_ticket` 获取票据与可直接执行的本机 curl 命令 → 客户端本机直传 `POST /api/mcp/upload`(multipart,字段 ticket + files[])拿到暂存引用 `ref` → 把 `ref` 作为 `source` 调 `crmeb_upload_image` 完成正式入库(时序与边界见 [MCP 接口](../../../../help/docs/api/MCP接口.md) 第 7 节)。插件工具描述里也应保持同样的引导口径;`crmeb_upload_image` 其余来源按上下文成本由低到高为 http(s) URL(上限 10MB)、站点相对路径、本地白名单路径(默认 `runtime/upload`,可配置 `mcp_upload_local_dirs`,仅同机部署可用)、base64 内联(仅作最后应急,解码后超过 512KB 拒绝)。 + ## 自测 ```bash diff --git a/backend/crmeb/utils/SafeEvent.php b/backend/crmeb/utils/SafeEvent.php index 8c8488349..1b9529042 100644 --- a/backend/crmeb/utils/SafeEvent.php +++ b/backend/crmeb/utils/SafeEvent.php @@ -38,7 +38,7 @@ class SafeEvent extends Event { /** * 安全事件清单(旁路型事件:其下所有监听器异常仅记日志,不中断主流程) - * 关键事件(订单/资金/库存/注册中心/服务代理与查询类)不在此列,保持异常上抛 + * 关键事件(订单/资金/库存等数据一致性事件、服务代理与查询类)不在此列,保持异常上抛 * @var array|string[] */ protected array $safeEvents = [ @@ -70,6 +70,8 @@ class SafeEvent extends Event 'HttpEnd', // 渠道同步类 'WechatFanSync', + // 插件扩展点:MCP 工具注册,单个插件监听器异常只影响其工具可用性,降级不中断服务 + 'McpToolsRegister', // 定时任务收尾钩子(CrontabBeforeRun 为执行前置校验,保持关键级) 'CrontabAfterRun', 'CrontabSuccess', diff --git a/backend/modules/product/normal/event.php b/backend/modules/product/normal/event.php index 72e728ee7..1f73acc13 100644 --- a/backend/modules/product/normal/event.php +++ b/backend/modules/product/normal/event.php @@ -126,5 +126,14 @@ return [ 'ProductTypeDataProxy' => [ \modules\product\normal\listener\ProductTypeDataProxyListener::class, ], + + // ================================================================ + // MCP 工具注册事件(触发方:service_mcp 注册中心,每次 tools/list 与 tools/call 重建) + // ================================================================ + + /** 向 MCP 服务贡献实物商品读取/新增/编辑工具 */ + 'McpToolsRegister' => [ + \modules\product\normal\listener\McpToolsRegisterListener::class, + ], ], ]; diff --git a/backend/modules/product/normal/listener/McpToolsRegisterListener.php b/backend/modules/product/normal/listener/McpToolsRegisterListener.php new file mode 100644 index 000000000..fb3d4eb13 --- /dev/null +++ b/backend/modules/product/normal/listener/McpToolsRegisterListener.php @@ -0,0 +1,41 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\product\normal\listener; + +use crmeb\interfaces\ListenerInterface; +use modules\product\normal\mcp\NormalProductCreateTool; +use modules\product\normal\mcp\NormalProductReadTool; +use modules\product\normal\mcp\NormalProductUpdateTool; + +/** + * 向 MCP 服务注册本插件工具 + * + * 仅做工具注册,不写业务逻辑:McpToolsRegister 属关键级事件, + * 监听器抛异常会导致整个 MCP 接口 500。 + */ +class McpToolsRegisterListener implements ListenerInterface +{ + /** + * 向注册中心注册插件工具(传类名,容器延迟实例化并注入依赖) + * + * @param array $event 事件参数按项目规范打包为 [McpToolRegistry 实例], + * 解构后使用(modules\service\mcp 层,不做类型标注避免跨模块 use 依赖) + */ + public function handle($event): void + { + [$registry] = $event; + $registry->register(NormalProductReadTool::class); + $registry->register(NormalProductCreateTool::class); + $registry->register(NormalProductUpdateTool::class); + } +} diff --git a/backend/modules/product/normal/mcp/NormalProductCreateTool.php b/backend/modules/product/normal/mcp/NormalProductCreateTool.php new file mode 100644 index 000000000..e3dc435f0 --- /dev/null +++ b/backend/modules/product/normal/mcp/NormalProductCreateTool.php @@ -0,0 +1,246 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\product\normal\mcp; + +use app\services\product\product\StoreCategoryServices; +use crmeb\interfaces\McpToolInterface; +use modules\product\normal\service\ProductSaveServices; + +/** + * MCP 工具:实物商品新增(插件贡献) + * + * 创建单规格实物商品。典型用法:AI 客户端自行识图生成商品资料后, + * 将本工具的 slider_image 传用户提供的商品图,完成"一张照片建品"。 + */ +class NormalProductCreateTool implements McpToolInterface +{ + /** + * 实物商品保存服务 + * @var ProductSaveServices + */ + private ProductSaveServices $saveServices; + + /** + * 商品分类服务(校验分类有效性) + * @var StoreCategoryServices + */ + private StoreCategoryServices $categoryServices; + + /** + * 构造函数 + * @param ProductSaveServices $saveServices 实物商品保存服务 + * @param StoreCategoryServices $categoryServices 商品分类服务 + */ + public function __construct(ProductSaveServices $saveServices, StoreCategoryServices $categoryServices) + { + $this->saveServices = $saveServices; + $this->categoryServices = $categoryServices; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_product_normal_create'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '新增一个单规格实物商品并返回商品ID。必填:store_name(商品名称,建议不超过30个汉字)、' + . 'cate_id(分类ID,可传数字或数字数组;请先调用 crmeb_category_list 查询真实分类ID,不得编造)、' + . 'slider_image(轮播图站点地址数组,必填,可多张,第一张将作为主图)。可选:intro(简介,最长256字)、description(详情富文本HTML)、' + . 'keyword(关键词,逗号分隔)、unit_name(单位,默认"件")、price(售价)、original_price(原价,划线价)、' + . 'stock(库存)、cost(成本价)、sort(排序)、is_show(1立即上架 0默认下架,缺省下架)。' + . '图片说明:slider_image 与 description 内 均须使用站点图片地址;AI 生成的图片(base64/外部URL)' + . '请先调用 crmeb_upload_image 上传换取站点地址后再传入,直接传 base64 会被拒绝。' + . '本工具固定创建单规格商品(价格库存取 price/stock);多规格商品与运费模板请在管理后台配置。' + . '与 crmeb_product_normal_update 的区别:本工具用于创建新商品,更新已有商品请用后者。'; + } + + /** + * 工具中文名(管理后台展示) + * @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' => [ + 'store_name' => ['type' => 'string', 'description' => '商品名称,必填,建议不超过30个汉字'], + 'cate_id' => ['type' => 'number', 'description' => '分类ID,必填,数字或数字数组,须为 crmeb_category_list 返回的真实分类'], + 'slider_image' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'description' => '轮播图站点地址数组,必填,可传多张,第一张作为主图;每项须为 crmeb_upload_image 返回的站点地址', + ], + 'intro' => ['type' => 'string', 'description' => '商品简介,可选,最长256字'], + 'description' => ['type' => 'string', 'description' => '商品详情富文本HTML,可选;img 标签须使用站点图片地址,AI生成图先调 crmeb_upload_image 换取'], + 'keyword' => ['type' => 'string', 'description' => '关键词,可选,逗号分隔'], + 'unit_name' => ['type' => 'string', 'description' => '计件单位,可选,默认"件"'], + 'price' => ['type' => 'number', 'description' => '售价,可选,默认0'], + 'original_price' => ['type' => 'number', 'description' => '划线原价,可选,默认同售价'], + 'stock' => ['type' => 'number', 'description' => '库存,可选,默认0'], + 'cost' => ['type' => 'number', 'description' => '成本价,可选,默认0'], + 'sort' => ['type' => 'number', 'description' => '排序,可选,默认0'], + 'is_show' => ['type' => 'number', 'description' => '上下架:1立即上架,0默认下架(缺省下架)'], + ], + 'required' => ['store_name', 'cate_id', 'slider_image'], + ]; + } + + /** + * 执行工具 + * + * @param array $args 商品资料 + * @return array ['id' => 商品ID];校验失败时返回 error 结构 + */ + public function execute(array $args): array + { + try { + $data = $this->buildData($args); + $error = $this->validateCategory($data['cate_id']); + if ($error !== '') { + return ['error' => $error]; + } + $id = $this->saveServices->save(0, $data); + } catch (\Throwable $e) { + return ['error' => '新增商品失败:' . $e->getMessage()]; + } + + return [ + 'id' => $id, + 'notice' => (int)($data['is_show'] ?? 0) === 1 + ? '商品已创建并上架' + : '商品已创建,当前为下架状态,请在管理后台上架或更新时传 is_show=1', + ]; + } + + /** + * 清洗入参为保存服务所需数据 + * @param array $args 原始入参 + * @return array + */ + private function buildData(array $args): array + { + $storeName = trim((string)($args['store_name'] ?? '')); + if ($storeName === '') { + throw new \RuntimeException('store_name 不能为空'); + } + $sliderImage = $this->normalizeImageList($args['slider_image'] ?? ''); + if ($sliderImage === []) { + throw new \RuntimeException('slider_image 不能为空,请至少提供一张商品图URL'); + } + $cateId = $this->normalizeCateId($args['cate_id'] ?? 0); + + $price = max(0.0, (float)($args['price'] ?? 0)); + $data = [ + 'store_name' => mb_substr($storeName, 0, 128), + 'cate_id' => $cateId, + 'slider_image' => $sliderImage, + 'intro' => mb_substr(trim((string)($args['intro'] ?? '')), 0, 256), + 'description' => (string)($args['description'] ?? ''), + 'keyword' => mb_substr(trim((string)($args['keyword'] ?? '')), 0, 255), + 'unit_name' => mb_substr(trim((string)($args['unit_name'] ?? '')), 0, 16) ?: '件', + 'price' => $price, + 'original_price' => max(0.0, (float)($args['original_price'] ?? $price)), + 'stock' => max(0, (int)($args['stock'] ?? 0)), + 'cost' => max(0.0, (float)($args['cost'] ?? 0)), + 'sort' => (int)($args['sort'] ?? 0), + 'is_show' => (int)($args['is_show'] ?? 0) === 1 ? 1 : 0, + ]; + return $data; + } + + /** + * 归一化分类ID(接受数字或数字数组) + * @param mixed $cateId 原始入参 + * @return array + */ + private function normalizeCateId($cateId): array + { + $list = is_array($cateId) ? $cateId : [$cateId]; + $list = array_values(array_filter(array_map('intval', $list), fn($id) => $id > 0)); + if ($list === []) { + throw new \RuntimeException('cate_id 格式不正确,请传分类ID数字或数组'); + } + return $list; + } + + /** + * 归一化轮播图列表(接受字符串或数组) + * @param mixed $sliderImage 原始入参 + * @return array + */ + private function normalizeImageList($sliderImage): array + { + $list = is_array($sliderImage) ? $sliderImage : [$sliderImage]; + $list = array_values(array_filter(array_map(fn($item) => trim((string)$item), $list))); + foreach ($list as $item) { + if (str_starts_with($item, 'data:')) { + throw new \RuntimeException('slider_image 不接受 base64 数据,请先调用 crmeb_upload_image 上传换取站点地址'); + } + } + return $list; + } + + /** + * 校验首个(落库生效的)分类真实存在 + * @param array $cateId 分类ID数组 + * @return string 空串表示通过 + */ + private function validateCategory(array $cateId): string + { + try { + $category = $this->categoryServices->get((int)reset($cateId)); + } catch (\Throwable $e) { + $category = null; + } + if (!$category) { + return '分类ID ' . (int)reset($cateId) . ' 不存在,请先调用 crmeb_category_list 查询真实分类ID'; + } + return ''; + } +} diff --git a/backend/modules/product/normal/mcp/NormalProductReadTool.php b/backend/modules/product/normal/mcp/NormalProductReadTool.php new file mode 100644 index 000000000..988f9f834 --- /dev/null +++ b/backend/modules/product/normal/mcp/NormalProductReadTool.php @@ -0,0 +1,169 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\product\normal\mcp; + +use crmeb\interfaces\McpToolInterface; +use modules\product\normal\service\ProductServices; + +/** + * MCP 工具:实物商品详情读取(插件贡献) + * + * 返回实物商品完整资料(中台主表 + 实物扩展 + SKU),供 AI 客户端 + * 在改写商品资料(如按照片重新完善详情)前了解商品现状。 + */ +class NormalProductReadTool implements McpToolInterface +{ + /** + * 实物商品服务 + * @var ProductServices + */ + private ProductServices $productServices; + + /** + * 构造函数 + * @param ProductServices $productServices 实物商品服务 + */ + public function __construct(ProductServices $productServices) + { + $this->productServices = $productServices; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_product_normal_read'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '按商品ID读取实物商品完整资料,返回名称(store_name)、简介(intro)、详情富文本(description)、关键词(keyword)、' + . '单位(unit_name)、分类ID数组(cate_id)、价格(price/original_price/cost)、库存(stock)、销量、主图与轮播图(image/slider_image)、' + . '上下架状态(is_show: 1上架 0下架)、规格模式(spec_type: 0单规格 1多规格)、规格头(items)与SKU明细(attrs,含每条规格的' + . 'detail/price/original_price/stock/cost)、配送方式(delivery_methods)与运费配置(freight_config)。' + . '与 crmeb_product_detail(仅中台主表基础字段)不同:本工具额外返回实物SKU明细与运费配置,是编辑实物商品前的推荐读取入口;' + . '仅支持 product_type=normal 的实物商品,已删除商品不可读。description 最长返回5000字符。'; + } + + /** + * 工具中文名(管理后台展示) + * @return string + */ + public function label(): string + { + return '实物商品详情'; + } + + /** + * 工具分类(管理后台分组展示) + * @return string + */ + public function category(): string + { + return '商品'; + } + + /** + * 入参 Schema + * @return array + */ + public function inputSchema(): array + { + return [ + 'type' => 'object', + 'properties' => [ + 'id' => ['type' => 'number', 'description' => '商品ID(中台商品 eb_product_center 主键)'], + ], + 'required' => ['id'], + ]; + } + + /** + * 执行工具 + * + * @param array $args 参数(id) + * @return array 商品完整资料;参数或商品异常时返回 error 结构 + */ + public function execute(array $args): array + { + if (!isset($args['id']) || !is_numeric($args['id']) || (int)$args['id'] <= 0) { + return ['error' => '参数错误:缺少 id 或格式不正确']; + } + try { + $info = $this->productServices->getExtendInfo((int)$args['id']); + } catch (\Throwable $e) { + return ['error' => '读取商品失败:' . $e->getMessage()]; + } + if (!$info) { + return ['error' => '商品不存在或已删除']; + } + if (($info['product_type'] ?? 'normal') !== 'normal') { + return ['error' => '该商品不是实物商品(product_type=' . $info['product_type'] . '),本工具仅支持实物商品']; + } + + return [ + 'id' => (int)($info['id'] ?? 0), + 'store_name' => (string)($info['store_name'] ?? ''), + 'product_type' => (string)($info['product_type'] ?? 'normal'), + 'intro' => (string)($info['intro'] ?? ''), + 'description' => mb_substr((string)($info['description'] ?? ''), 0, 5000), + 'keyword' => (string)($info['keyword'] ?? ''), + 'unit_name' => (string)($info['unit_name'] ?? ''), + 'cate_id' => array_map('intval', (array)($info['cate_id'] ?? [])), + 'price' => (float)($info['price'] ?? 0), + 'original_price' => (float)($info['original_price'] ?? 0), + 'cost' => (float)($info['cost'] ?? 0), + 'stock' => (int)($info['stock'] ?? 0), + 'sales' => (int)($info['sales'] ?? 0), + 'image' => (string)($info['image'] ?? ''), + 'slider_image' => array_values((array)($info['slider_image'] ?? [])), + 'is_show' => (int)($info['is_show'] ?? 0), + 'spec_type' => (int)($info['spec_type'] ?? 0), + 'items' => array_values((array)($info['items'] ?? [])), + 'attrs' => $this->formatAttrs((array)($info['attrs'] ?? [])), + 'delivery_methods' => array_values((array)($info['delivery_methods'] ?? [])), + 'freight_config' => (array)($info['freight_config'] ?? []), + ]; + } + + /** + * 精简 SKU 明细(只保留 AI 改写资料所需的字段) + * @param array $attrs SKU 列表 + * @return array + */ + private function formatAttrs(array $attrs): array + { + $result = []; + foreach ($attrs as $attr) { + if (!is_array($attr)) { + continue; + } + $result[] = [ + 'detail' => (array)($attr['detail'] ?? []), + 'price' => (float)($attr['price'] ?? 0), + 'original_price' => (float)($attr['original_price'] ?? 0), + 'cost' => (float)($attr['cost'] ?? 0), + 'stock' => (int)($attr['stock'] ?? 0), + 'pic' => (string)($attr['pic'] ?? ''), + 'is_default_select' => (int)($attr['is_default_select'] ?? 0), + ]; + } + return $result; + } +} diff --git a/backend/modules/product/normal/mcp/NormalProductUpdateTool.php b/backend/modules/product/normal/mcp/NormalProductUpdateTool.php new file mode 100644 index 000000000..f37ea2b4b --- /dev/null +++ b/backend/modules/product/normal/mcp/NormalProductUpdateTool.php @@ -0,0 +1,319 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\product\normal\mcp; + +use crmeb\interfaces\McpToolInterface; +use modules\product\normal\service\ProductSaveServices; +use modules\product\normal\service\ProductServices; + +/** + * MCP 工具:实物商品编辑(插件贡献) + * + * 只更新传入的字段,其余字段保留原值(内部先读取现状合并后再走标准保存服务)。 + * 典型用法:AI 客户端先读取商品资料,改写名称/简介/详情/关键词后调用本工具写回。 + */ +class NormalProductUpdateTool implements McpToolInterface +{ + /** + * 实物商品服务(读取现状) + * @var ProductServices + */ + private ProductServices $productServices; + + /** + * 实物商品保存服务 + * @var ProductSaveServices + */ + private ProductSaveServices $saveServices; + + /** + * 构造函数 + * @param ProductServices $productServices 实物商品服务 + * @param ProductSaveServices $saveServices 实物商品保存服务 + */ + public function __construct(ProductServices $productServices, ProductSaveServices $saveServices) + { + $this->productServices = $productServices; + $this->saveServices = $saveServices; + } + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_product_normal_update'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '更新已有实物商品的资料,只覆盖传入字段,未传字段保持原值。可更新:store_name(名称)、intro(简介,最长256字)、' + . 'description(详情富文本HTML)、keyword(关键词,逗号分隔)、unit_name(单位)、cate_id(分类ID,数字或数组,建议先调 ' + . 'crmeb_category_list 查询)、slider_image(轮播图站点地址数组,整组替换,第一张为主图)、is_show(1上架 0下架)、sort(排序);' + . '单规格商品还可更新 price/original_price/stock/cost。' + . '图片说明:slider_image 与 description 内 均须使用站点图片地址;AI 生成的图片(base64/外部URL)' + . '请先调用 crmeb_upload_image 上传换取站点地址后再传入,直接传 base64 会被拒绝。' + . '限制:多规格商品(spec_type=1)不支持本工具修改价格库存,请在管理后台调整SKU;仅支持 product_type=normal 的实物商品。' + . '建议先用 crmeb_product_normal_read 读取商品现状再按需传入要修改的字段。'; + } + + /** + * 工具中文名(管理后台展示) + * @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' => [ + 'id' => ['type' => 'number', 'description' => '商品ID,必填'], + 'store_name' => ['type' => 'string', 'description' => '商品名称,可选'], + 'intro' => ['type' => 'string', 'description' => '商品简介,可选,最长256字'], + 'description' => ['type' => 'string', 'description' => '商品详情富文本HTML,可选'], + 'keyword' => ['type' => 'string', 'description' => '关键词,可选,逗号分隔'], + 'unit_name' => ['type' => 'string', 'description' => '计件单位,可选'], + 'cate_id' => ['type' => 'number', 'description' => '分类ID,可选,数字或数字数组'], + 'slider_image' => [ + 'type' => 'array', + 'items' => ['type' => 'string'], + 'description' => '轮播图站点地址数组,可选,整组替换(未传则保持原轮播图),第一张为主图;每项须为 crmeb_upload_image 返回的站点地址', + ], + 'price' => ['type' => 'number', 'description' => '售价,可选,仅单规格商品生效'], + 'original_price' => ['type' => 'number', 'description' => '划线原价,可选,仅单规格商品生效'], + 'stock' => ['type' => 'number', 'description' => '库存,可选,仅单规格商品生效'], + 'cost' => ['type' => 'number', 'description' => '成本价,可选,仅单规格商品生效'], + 'is_show' => ['type' => 'number', 'description' => '上下架:1上架,0下架,可选'], + 'sort' => ['type' => 'number', 'description' => '排序,可选'], + ], + 'required' => ['id'], + ]; + } + + /** + * 执行工具 + * + * @param array $args 参数(id + 可选更新字段) + * @return array 更新结果与字段对照;校验失败时返回 error 结构 + */ + public function execute(array $args): array + { + if (!isset($args['id']) || !is_numeric($args['id']) || (int)$args['id'] <= 0) { + return ['error' => '参数错误:缺少 id 或格式不正确']; + } + $id = (int)$args['id']; + try { + $current = $this->productServices->getExtendInfo($id); + if (!$current) { + return ['error' => '商品不存在或已删除']; + } + if (($current['product_type'] ?? 'normal') !== 'normal') { + return ['error' => '该商品不是实物商品(product_type=' . $current['product_type'] . '),本工具仅支持实物商品']; + } + + $data = $this->mergeData($current, $args); + if ($data === null) { + return ['error' => '没有传入任何可更新的字段']; + } + $this->saveServices->save($id, $data); + } catch (\Throwable $e) { + return ['error' => '更新商品失败:' . $e->getMessage()]; + } + + return [ + 'id' => $id, + 'updated' => array_keys($data['__updated']), + 'notice' => '商品资料已更新', + ]; + } + + /** + * 以商品现状为基底合并更新字段,补齐保存服务所需但不允许经由本工具清空的键 + * + * 保存服务为全量保存:未设置的键会被默认值覆盖,因此必须从现状回填 + * 赠券、运费、限购、预售等本工具不开放修改的配置,避免误清。 + * + * @param array $current 商品现状(getExtendInfo 返回结构) + * @param array $args 更新入参 + * @return array|null 合并后的保存数据;无任何可更新字段时返回 null + */ + private function mergeData(array $current, array $args): ?array + { + $updated = []; + $data = []; + + // ---- 文本类字段 ---- + foreach (['store_name', 'intro', 'keyword', 'unit_name', 'description'] as $field) { + if (array_key_exists($field, $args)) { + $value = trim((string)$args[$field]); + $updated[$field] = $value; + $data[$field] = match ($field) { + 'store_name' => mb_substr($value, 0, 128), + 'intro' => mb_substr($value, 0, 256), + 'keyword' => mb_substr($value, 0, 255), + 'unit_name' => mb_substr($value, 0, 16), + default => $value, + }; + } + } + if (isset($data['store_name']) && $data['store_name'] === '') { + throw new \RuntimeException('store_name 不能为空'); + } + + // ---- 分类 ---- + if (array_key_exists('cate_id', $args)) { + $cateId = is_array($args['cate_id']) ? $args['cate_id'] : [$args['cate_id']]; + $cateId = array_values(array_filter(array_map('intval', $cateId), fn($cid) => $cid > 0)); + if ($cateId === []) { + throw new \RuntimeException('cate_id 格式不正确'); + } + $data['cate_id'] = $cateId; + $updated['cate_id'] = $cateId; + } + + // ---- 轮播图 ---- + if (array_key_exists('slider_image', $args)) { + $slider = is_array($args['slider_image']) ? $args['slider_image'] : [$args['slider_image']]; + $slider = array_values(array_filter(array_map(fn($item) => trim((string)$item), $slider))); + if ($slider === []) { + throw new \RuntimeException('slider_image 不能为空'); + } + foreach ($slider as $item) { + if (str_starts_with($item, 'data:')) { + throw new \RuntimeException('slider_image 不接受 base64 数据,请先调用 crmeb_upload_image 上传换取站点地址'); + } + } + $data['slider_image'] = $slider; + $updated['slider_image'] = $slider; + } + + // ---- 状态与排序 ---- + if (array_key_exists('is_show', $args)) { + $data['is_show'] = (int)$args['is_show'] === 1 ? 1 : 0; + $updated['is_show'] = $data['is_show']; + } + if (array_key_exists('sort', $args)) { + $data['sort'] = (int)$args['sort']; + $updated['sort'] = $data['sort']; + } + + // ---- 价格库存(仅单规格)---- + $specType = (int)($current['spec_type'] ?? 0); + $priceFields = ['price', 'original_price', 'stock', 'cost']; + $priceInput = []; + foreach ($priceFields as $field) { + if (array_key_exists($field, $args)) { + $priceInput[$field] = $args[$field]; + } + } + if ($priceInput !== [] && $specType === 1) { + throw new \RuntimeException('多规格商品不支持通过本工具修改价格库存,请在管理后台调整SKU'); + } + foreach ($priceInput as $field => $value) { + $data[$field] = $field === 'stock' ? max(0, (int)$value) : max(0.0, (float)$value); + $updated[$field] = $data[$field]; + } + + if ($updated === []) { + return null; + } + + // ---- 回填保存服务的必填与不可见配置(未更新时保持原值)---- + $data += [ + 'store_name' => (string)($current['store_name'] ?? ''), + 'cate_id' => array_map('intval', (array)($current['cate_id'] ?? [])), + 'slider_image' => array_values((array)($current['slider_image'] ?? [])), + 'intro' => (string)($current['intro'] ?? ''), + 'description' => (string)($current['description'] ?? ''), + 'keyword' => (string)($current['keyword'] ?? ''), + 'unit_name' => (string)($current['unit_name'] ?? '件'), + 'spec_type' => $specType, + 'attrs' => (array)($current['attrs'] ?? []), + 'items' => (array)($current['items'] ?? []), + 'cost' => (float)($current['cost'] ?? 0), + 'ficti' => (int)($current['ficti'] ?? 0), + 'sort' => (int)($current['sort'] ?? 0), + 'is_show' => (int)($current['is_show'] ?? 0), + 'is_sub' => (int)($current['is_sub'] ?? 0), + 'is_vip' => (int)($current['is_vip'] ?? 0), + 'vip_product' => (int)($current['vip_product'] ?? 0), + 'presale' => (int)($current['presale'] ?? 0), + 'presale_time' => (array)($current['presale_time'] ?? []), + 'is_limit' => (int)($current['is_limit'] ?? 0), + 'min_qty' => (int)($current['min_qty'] ?? 1), + 'limit_type' => (int)($current['limit_type'] ?? 0), + 'limit_num' => (int)($current['limit_num'] ?? 0), + 'is_gift' => (int)($current['is_gift'] ?? 0), + 'gift_price' => (float)($current['gift_price'] ?? 0), + 'give_integral' => (float)($current['give_integral'] ?? 0), + 'coupon_ids' => array_values(array_filter(array_map('intval', explode(',', (string)($current['give_coupon_ids'] ?? ''))))), + 'label_list' => array_map('intval', (array)($current['label_list'] ?? [])), + 'custom_form' => (array)($current['custom_form'] ?? []), + 'params_list' => (array)($current['params_list'] ?? []), + 'protection_list' => array_map('intval', (array)($current['protection_list'] ?? [])), + 'recommend_list' => (array)($current['recommend_list'] ?? []), + 'video_link' => (string)($current['video_link'] ?? ''), + 'weight' => (float)($current['weight'] ?? 0), + 'volume' => (float)($current['volume'] ?? 0), + 'delivery_methods' => array_values((array)($current['delivery_methods'] ?? ['express'])), + 'freight_config' => (array)($current['freight_config'] ?? []), + ]; + + // ---- 单规格改价时同步 SKU,避免主表价格被 SKU 覆盖回旧值 ---- + if ($specType === 0 && $priceInput !== []) { + $attrs = $data['attrs']; + if (isset($attrs[0]) && is_array($attrs[0])) { + foreach (['price', 'original_price', 'stock', 'cost'] as $field) { + if (isset($data[$field])) { + $attrs[0][$field] = $data[$field]; + } + } + $data['attrs'] = $attrs; + } + } + + $data['__updated'] = $updated; + return $data; + } +} diff --git a/backend/modules/service/mcp/controller/McpUploadController.php b/backend/modules/service/mcp/controller/McpUploadController.php new file mode 100644 index 000000000..94583f7f6 --- /dev/null +++ b/backend/modules/service/mcp/controller/McpUploadController.php @@ -0,0 +1,245 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\controller; + +use crmeb\basic\BaseController; +use crmeb\services\CacheService; +use modules\service\mcp\service\McpUploadTicketServices; +use think\facade\Log; +use think\file\UploadedFile; +use think\Request; +use think\response\Json; + +/** + * MCP 图片直传端点 + * + * 跨机场景(AI 客户端与商城服务器不同机)下,把客户端本机图片字节带外 + * 写入服务器暂存区的 HTTP 入口:POST /api/mcp/upload,multipart/form-data, + * 表单字段 ticket(crmeb_upload_ticket 签发)+ files[](可多张)。 + * + * 职责边界:本端点只做「收字节 + 暂存 + 过期清理」,不做正式入库;正式 + * 入库统一由 MCP 工具 crmeb_upload_image 完成(去重/素材库/限流/审计口径 + * 全部收敛在该工具内),端点返回的 ref 直接作为该工具的 source 即可。 + * 这样入库失败时只需重试工具调用,无需客户端重传文件。 + * + * 鉴权:仅凭一次性票据(10 分钟,服务端只存摘要),不要求 X-Mcp-Key—— + * 避免 AI 客户端把长期密钥写进命令、进入对话上下文与命令历史;同时受 + * MCP 服务总开关 mcp_enabled 约束。 + */ +class McpUploadController extends BaseController +{ + /** + * 单次最多接收张数(与 crmeb_upload_image 单次上限一致) + */ + private const MAX_BATCH = 20; + + /** + * 单张字节上限(与 crmeb_upload_image 外链/本地口径一致) + */ + private const MAX_FILE_SIZE = 10485760; + + /** + * 单账号每分钟接收张数上限 + */ + private const RATE_LIMIT_PER_MINUTE = 60; + + /** + * 暂存区保留时长(秒),超时文件由本端点机会式清理 + */ + private const STAGING_TTL = 3600; + + /** + * 暂存区(项目根相对路径,位于本地路径白名单目录 runtime/upload 内) + */ + private const STAGING_DIR = 'runtime/upload/mcp_recv'; + + /** + * 单次清理扫描的文件数上限(避免大目录拖慢请求) + */ + private const GC_SCAN_LIMIT = 200; + + /** + * 允许接收的图片 mime → 扩展名(落盘扩展名以内容为准) + */ + private const MIME_EXTENSIONS = [ + 'image/jpeg' => 'jpg', + 'image/png' => 'png', + 'image/gif' => 'gif', + 'image/webp' => 'webp', + ]; + + /** + * 初始化(直传端点不使用后台/用户 Token 体系,鉴权在 index 内按票据处理) + */ + protected function initialize() + { + } + + /** + * 接收直传图片字节:写入暂存区并返回暂存引用 + * + * @param Request $request HTTP 请求对象 + * @return Json {code, msg, data:{total, count, received[], failed[]}} + */ + public function index(Request $request): Json + { + if (!intval(sys_config('mcp_enabled', 1))) { + return $this->fail(403, 'MCP 服务未开启'); + } + + $ticket = trim((string)$request->post('ticket', '')); + if ($ticket === '') { + return $this->fail(401, '缺少上传票据:请先调用 MCP 工具 crmeb_upload_ticket 获取' + . '(若表单较大,请确认服务端 post_max_size / upload_max_filesize 足够)'); + } + /** @var McpUploadTicketServices $ticketServices */ + $ticketServices = app()->make(McpUploadTicketServices::class); + $payload = $ticketServices->verify($ticket); + if ($payload === null) { + return $this->fail(401, '上传票据无效或已过期,请重新调用 MCP 工具 crmeb_upload_ticket 获取'); + } + + // PHP 在 multipart 解析阶段即按 php.ini max_file_uploads(默认 20)截断超量文件, + // 控制器只能看到被截断后的集合;识别解析期告警,避免「调用方以为全部成功、实际 + // 少收」的静默丢件(下方 count 校验只在 ini 上限高于本端点上限时才可能触发) + $parseError = error_get_last(); + if (is_array($parseError) && str_contains((string)($parseError['message'] ?? ''), 'Maximum number of allowable file uploads')) { + return $this->fail(400, '上传张数超过服务端限制(php.ini max_file_uploads=' . (int)ini_get('max_file_uploads') + . '),请拆分后重试,单次最多 ' . self::MAX_BATCH . ' 张'); + } + + $files = $request->file('files') ?? $request->file('file'); + if ($files instanceof UploadedFile) { + $files = [$files]; + } + if (!is_array($files) || $files === []) { + return $this->fail(400, '未收到上传文件:请使用 multipart/form-data,文件字段名 files[](单张可用 file)'); + } + if (count($files) > self::MAX_BATCH) { + return $this->fail(400, '单次最多上传 ' . self::MAX_BATCH . ' 张'); + } + + // 频率限制:按票据所属账号,防止单个账号高频灌入暂存区 + $windowKey = 'mcp_direct_upload_rl_' . (string)$payload['key_md5']; + $used = (int)CacheService::get($windowKey, 0); + if ($used + count($files) > self::RATE_LIMIT_PER_MINUTE) { + return $this->fail(429, '直传频率超限:单账号每分钟最多 ' . self::RATE_LIMIT_PER_MINUTE . ' 张,请稍后重试'); + } + CacheService::set($windowKey, $used + count($files), 60); + + $stagingDir = root_path(self::STAGING_DIR); + if (!is_dir($stagingDir) && !@mkdir($stagingDir, 0755, true) && !is_dir($stagingDir)) { + return $this->fail(500, '暂存目录创建失败,请检查 runtime 目录权限'); + } + $this->gcStaging($stagingDir); + + $received = []; + $failed = []; + foreach (array_values($files) as $index => $file) { + try { + if (!$file instanceof UploadedFile) { + throw new \RuntimeException('文件上传失败(可能超过 php.ini 上传限制或表单缺少 enctype)'); + } + $originalName = (string)$file->getOriginalName(); + if ((int)$file->getSize() > self::MAX_FILE_SIZE) { + throw new \RuntimeException('图片大小超过 10MB 限制'); + } + $content = (string)file_get_contents($file->getRealPath()); + $image = @getimagesizefromstring($content); + $mime = (string)($image['mime'] ?? ''); + $ext = self::MIME_EXTENSIONS[$mime] ?? ''; + if ($ext === '') { + throw new \RuntimeException('仅支持 jpg/png/gif/webp 格式图片'); + } + $filename = 'up_' . bin2hex(random_bytes(6)) . '.' . $ext; + if (@file_put_contents($stagingDir . '/' . $filename, $content) === false) { + throw new \RuntimeException('暂存写入失败'); + } + $received[] = [ + 'index' => $index, + 'ok' => true, + 'ref' => self::STAGING_DIR . '/' . $filename, + 'name' => $originalName, + 'size' => strlen($content), + 'mime' => $mime, + ]; + } catch (\Throwable $e) { + $failed[] = [ + 'index' => $index, + 'ok' => false, + 'name' => $file instanceof UploadedFile ? (string)$file->getOriginalName() : '', + 'reason' => $e->getMessage() ?: '未知错误', + ]; + } + } + + // 审计:只记录票据归属摘要、结果计数与暂存引用,不记录文件内容 + try { + Log::info('MCP图片直传', [ + 'key_md5' => (string)$payload['key_md5'], + 'total' => count($files), + 'received' => count($received), + 'failed' => count($failed), + 'refs' => array_column($received, 'ref'), + ]); + } catch (\Throwable $e) { + } + + return json([ + 'code' => 200, + 'msg' => 'success', + 'data' => [ + 'total' => count($files), + 'count' => count($received), + 'received' => $received, + 'failed' => $failed, + 'next_step' => '把 received[].ref 作为 source 调用 MCP 工具 crmeb_upload_image 完成正式入库', + ], + ]); + } + + /** + * 失败响应(HTTP 状态码与 code 一致,便于 curl 侧判断) + * + * @param int $code HTTP 状态码 + * @param string $message 失败原因 + * @return Json + */ + private function fail(int $code, string $message): Json + { + return json(['code' => $code, 'msg' => $message, 'data' => null])->code($code); + } + + /** + * 机会式清理暂存区过期文件(1 小时),扫描量有上限 + * + * @param string $stagingDir 暂存目录绝对路径 + * @return void + */ + private function gcStaging(string $stagingDir): void + { + try { + $deadline = time() - self::STAGING_TTL; + $scanned = 0; + foreach ((array)glob($stagingDir . '/up_*') as $path) { + if (++$scanned > self::GC_SCAN_LIMIT) { + break; + } + if (is_file($path) && filemtime($path) < $deadline) { + @unlink($path); + } + } + } catch (\Throwable $e) { + } + } +} \ No newline at end of file diff --git a/backend/modules/service/mcp/route/user.php b/backend/modules/service/mcp/route/user.php index a660237a4..1c9c4afbe 100644 --- a/backend/modules/service/mcp/route/user.php +++ b/backend/modules/service/mcp/route/user.php @@ -18,3 +18,7 @@ use think\facade\Route; */ Route::post('mcp', [\modules\service\mcp\controller\McpController::class, 'index']) ->option(['real_name' => 'MCP服务接口']); + +// 图片直传端点:跨机场景把客户端本机图片字节带外写入暂存区(票据鉴权,见 crmeb_upload_ticket 工具) +Route::post('mcp/upload', [\modules\service\mcp\controller\McpUploadController::class, 'index']) + ->option(['real_name' => 'MCP图片直传接口']); diff --git a/backend/modules/service/mcp/service/McpUploadTicketServices.php b/backend/modules/service/mcp/service/McpUploadTicketServices.php new file mode 100644 index 000000000..f863c4037 --- /dev/null +++ b/backend/modules/service/mcp/service/McpUploadTicketServices.php @@ -0,0 +1,76 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\service; + +use crmeb\services\CacheService; +use modules\service\mcp\McpException; + +/** + * MCP 图片直传票据服务 + * + * 跨机场景(AI 客户端与商城服务器不同机)下,图片字节既无法通过工具参数 + * 传递(base64 会占用大量对话上下文),服务器也读不到客户端磁盘。票据是 + * 带外直传的授权凭据:MCP 工具 crmeb_upload_ticket 签发,AI 客户端在自己 + * 所在机器用 HTTP 把文件直传到 /api/mcp/upload,命令中无需出现 MCP 密钥。 + * + * 安全口径:32 位随机 hex,服务端只存 sha256 摘要(明文不落缓存键与日志); + * 10 分钟有效,仅授予「写入暂存区」能力,正式入库仍由 crmeb_upload_image + * 按原口径完成(去重/素材库/限流/审计)。票据不做单次消费,重试友好: + * 重复提交的内容按 md5 去重收敛,暂存文件由接收端点按 1 小时 TTL 清理。 + */ +class McpUploadTicketServices +{ + /** + * 票据有效期(秒) + */ + public const TTL = 600; + + /** + * 票据缓存键前缀(键内为票据摘要) + */ + private const CACHE_PREFIX = 'mcp:upload_ticket:'; + + /** + * 签发票据(返回明文,服务端仅存摘要) + * + * @param string $key 调用账号的 MCP 密钥 + * @return string 32 位 hex 票据 + * @throws McpException 缓存写入失败时抛出 + */ + public function issue(string $key): string + { + $ticket = bin2hex(random_bytes(16)); + $cacheKey = self::CACHE_PREFIX . hash('sha256', $ticket); + $payload = ['key_md5' => md5($key), 'issued_at' => time()]; + // 写入后读回确认,避免发放无法校验的票据 + if (CacheService::set($cacheKey, $payload, self::TTL) !== true || !is_array(CacheService::get($cacheKey))) { + throw new McpException('上传票据签发失败,请稍后重试'); + } + return $ticket; + } + + /** + * 校验票据并返回载荷 + * + * @param string $ticket 明文票据 + * @return array|null {key_md5, issued_at};无效或已过期返回 null + */ + public function verify(string $ticket): ?array + { + if (!preg_match('/^[a-f0-9]{32}$/', $ticket)) { + return null; + } + $payload = CacheService::get(self::CACHE_PREFIX . hash('sha256', $ticket)); + return is_array($payload) && isset($payload['key_md5']) ? $payload : null; + } +} \ No newline at end of file diff --git a/backend/modules/service/mcp/tools/UploadImageTool.php b/backend/modules/service/mcp/tools/UploadImageTool.php new file mode 100644 index 000000000..819909b29 --- /dev/null +++ b/backend/modules/service/mcp/tools/UploadImageTool.php @@ -0,0 +1,651 @@ + +// +---------------------------------------------------------------------- +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; + } +} diff --git a/backend/modules/service/mcp/tools/UploadTicketTool.php b/backend/modules/service/mcp/tools/UploadTicketTool.php new file mode 100644 index 000000000..89ec9dd01 --- /dev/null +++ b/backend/modules/service/mcp/tools/UploadTicketTool.php @@ -0,0 +1,170 @@ + +// +---------------------------------------------------------------------- +declare(strict_types=1); + +namespace modules\service\mcp\tools; + +use crmeb\interfaces\McpToolInterface; +use crmeb\services\CacheService; +use modules\service\mcp\McpException; +use modules\service\mcp\service\McpUploadTicketServices; + +/** + * MCP 工具:图片直传票据 + * + * 跨机场景(AI 客户端与商城服务器不同机)下,AI 本机生成的图片文件无法 + * 通过工具参数传递(base64 会占用大量对话上下文),服务器也读不到客户端 + * 磁盘。本工具签发一次性 HTTP 直传票据:AI 客户端在自己所在机器用 curl + * 等把文件直传到 /api/mcp/upload,字节不经过对话上下文、命令中也无需 + * 出现 MCP 密钥;随后用端点返回的 ref 调 crmeb_upload_image 完成入库。 + */ +class UploadTicketTool implements McpToolInterface +{ + /** + * 单账号每分钟票据签发上限 + */ + private const RATE_LIMIT_PER_MINUTE = 10; + + /** + * 与接收端点一致的接收口径(回给调用方,便于一次成型) + */ + private const MAX_BATCH = 20; + private const MAX_FILE_SIZE = 10485760; + + /** + * 工具名称 + * @return string + */ + public function name(): string + { + return 'crmeb_upload_ticket'; + } + + /** + * 工具描述 + * @return string + */ + public function description(): string + { + return '获取图片直传票据:只要需要上传「AI 运行所在机器」上的图片文件(本机生成或下载的商品图一律属于此类),' + . '必须先调用本工具拿票据与 upload_url(这是本机图片上传的默认方式,不要改用 base64 内联替代),' + . '然后在你所在机器执行返回的 curl 命令(或等价 HTTP 表单请求)把文件直传服务器——' + . '图片字节不经过对话上下文,命令中也不需要 MCP 密钥,是跨机场景(AI 客户端与服务器不同机)的推荐上传入口。' + . '【完整链路】1)本工具签发票据;2)本机执行上传命令(multipart/form-data,字段 ticket 与 files[],' + . '单次最多 20 张、单张 10MB、仅 jpg/png/gif/webp);3)把响应 data.received[].ref(如 runtime/upload/mcp_recv/up_x.png)' + . '作为 source 调用 crmeb_upload_image 换取正式站点地址(自动去重/登记素材库/返回元数据),再写入商品等字段。' + . '【口径】票据 10 分钟内有效、仅签发账号可用、可重试;暂存文件保留 1 小时后自动清理,入库失败可只重试第 3 步不必重传。' + . '若所在环境无法发起 HTTP 上传(无 shell/curl),改用 crmeb_upload_image 的 http(s) URL 来源,' + . '或 base64(仅 ≤512KB 的极小图应急)。'; + } + + /** + * 工具中文名(管理后台展示) + * @return string + */ + public function label(): string + { + return '图片直传票据'; + } + + /** + * 工具分类(管理后台分组展示) + * @return string + */ + public function category(): string + { + return '系统设置'; + } + + /** + * 入参 Schema(无参数:票据只与调用账号绑定) + * @return array + */ + public function inputSchema(): array + { + return ['type' => 'object']; + } + + /** + * 执行工具:签发直传票据并返回可直接执行的命令 + * + * @param array $args 参数(本工具无参数) + * @return array ticket/upload_url/expires_in/command 等 + * @throws McpException 无法识别账号或触发限流时抛出 + */ + public function execute(array $args): array + { + $key = $this->clientKey(); + if ($key === '') { + throw new McpException('无法识别调用账号:缺少 X-Mcp-Key 或 Authorization 请求头'); + } + $this->checkRateLimit($key); + + /** @var McpUploadTicketServices $services */ + $services = app()->make(McpUploadTicketServices::class); + $ticket = $services->issue($key); + + // 客户端只用站点公网地址,与服务端部署形态(容器/物理机/路径)无关 + $base = rtrim((string)sys_config('site_url'), '/'); + if ($base === '') { + // site_url 未配置时退回客户端实际访问的域名,保证返回地址对客户端可达 + $base = rtrim((string)request()->domain(), '/'); + } + $uploadUrl = $base . '/api/mcp/upload'; + + return [ + 'ticket' => $ticket, + 'upload_url' => $uploadUrl, + 'expires_in' => McpUploadTicketServices::TTL, + 'max_files' => self::MAX_BATCH, + 'max_size' => self::MAX_FILE_SIZE, + 'accept' => 'jpg/jpeg/png/gif/webp', + 'command' => 'curl -s -F "ticket=' . $ticket . '" -F "files[]=@/本机图片路径.png" ' . $uploadUrl, + 'next_step' => '把上传响应 data.received[].ref 作为 source 调用 crmeb_upload_image 完成正式入库', + ]; + } + + /** + * 取调用账号密钥(与 McpController 认证口径一致:X-Mcp-Key 或 Bearer) + * @return string + */ + private function clientKey(): string + { + try { + $key = (string)request()->header('x-mcp-key', ''); + if ($key === '') { + $authorization = (string)request()->header('authorization', ''); + if (stripos($authorization, 'bearer ') === 0) { + $key = trim(substr($authorization, 7)); + } + } + return $key; + } catch (\Throwable $e) { + return ''; + } + } + + /** + * 按密钥限流签发:每分钟最多 RATE_LIMIT_PER_MINUTE 张票据 + * + * @param string $key 调用账号密钥 + * @throws McpException 超限时抛出 + */ + private function checkRateLimit(string $key): void + { + $windowKey = 'mcp_upload_ticket_rl_' . md5($key); + $used = (int)CacheService::get($windowKey, 0); + if ($used + 1 > self::RATE_LIMIT_PER_MINUTE) { + throw new McpException('票据签发频率超限:每分钟最多 ' . self::RATE_LIMIT_PER_MINUTE . ' 次,请稍后重试'); + } + CacheService::set($windowKey, $used + 1, 60); + } +} \ No newline at end of file diff --git a/help/docs/api/MCP接口.md b/help/docs/api/MCP接口.md index 9d5fc184e..ee08c668c 100644 --- a/help/docs/api/MCP接口.md +++ b/help/docs/api/MCP接口.md @@ -11,6 +11,7 @@ MCP 服务通过 JSON-RPC 2.0 over HTTP 向 AI 客户端开放 CRMEB 查询、 | 插件 key | `service_mcp` | | 插件目录 | `backend/modules/service/mcp/` | | 请求地址 | `POST /api/mcp` | +| 图片直传端点 | `POST /api/mcp/upload`(multipart 表单,票据鉴权;跨机客户端回传图片字节用,见第 7 节) | | 传输方式 | HTTP 单端点、JSON 请求/响应 | | 访问认证 | `X-Mcp-Key` 或 `Authorization: Bearer`(账号体系是唯一认证权威) | | 服务开关 | `mcp_enabled`(安装默认开启,在「系统配置 → MCP服务」中切换) | @@ -142,7 +143,7 @@ curl 'https://example.com/api/mcp' \ ## 5. 内置工具 -当前自动发现 55 个内置工具,其中 52 个业务工具、3 个开发知识工具。标记“写”的工具还会返回 `interaction: "execute"`。业务工具均声明 `category` 分类(用户、商品、订单、售后退款、营销、系统设置、消息通知、开发支持),后台「MCP 工具」列表与账号权限勾选按分类分组展示。 +当前自动发现 57 个内置工具,其中 54 个业务工具、3 个开发知识工具。标记“写”的工具还会返回 `interaction: "execute"`。业务工具均声明 `category` 分类(用户、商品、订单、售后退款、营销、系统设置、消息通知、开发支持),后台「MCP 工具」列表与账号权限勾选按分类分组展示。 | 业务域 | 工具 | |---|---| @@ -151,7 +152,7 @@ curl 'https://example.com/api/mcp' \ | 售后与退款 | `crmeb_after_sale_list`、`crmeb_after_sale_statistics`、`crmeb_refund_list`、`crmeb_refund_detail` | | 优惠券 | `crmeb_coupon_list`、`crmeb_coupon_create`(写)、`crmeb_coupon_claim_list`、`crmeb_coupon_claim_statistics`、`crmeb_coupon_holding_statistics`、`crmeb_coupon_usage_statistics` | | 用户 | `crmeb_user_list`、`crmeb_user_detail`、`crmeb_user_statistics` | -| 系统配置 | `crmeb_config_list`、`crmeb_config_tab_list`、`crmeb_config_update`(写) | +| 系统配置 | `crmeb_config_list`、`crmeb_config_tab_list`、`crmeb_config_update`(写)、`crmeb_upload_image`(写)、`crmeb_upload_ticket` | | 菜单与模块 | `crmeb_menu_list`、`crmeb_module_list` | | 第三方驱动 | `crmeb_driver_list`、`crmeb_driver_config_update`(写) | | 消息发送 | `crmeb_email_send`(写)、`crmeb_qywechat_send`(写) | @@ -163,9 +164,40 @@ curl 'https://example.com/api/mcp' \ `service_mcp` 先自动发现自身 `tools/*Tool.php`,再触发字符串事件 `McpToolsRegister`。其他已启用插件可以监听该事件,注册实现 `crmeb\interfaces\McpToolInterface` 的工具;同名工具以后注册者为准。 +官方插件当前贡献的工具: + +| 插件 | 工具 | +|---|---| +| 实物商品(product_normal) | `crmeb_product_normal_read`(读)、`crmeb_product_normal_create`(写,新增单规格实物商品)、`crmeb_product_normal_update`(写,按字段合并更新,仅单规格可改价) | + +`crmeb_product_normal_read/create/update` 组合支持“一张照片建品/改品”流程:调用方 AI 客户端自行完成图片识别与文案生成,读取商品现状后按字段写回,服务端不内置任何 AI 调用。AI 生成的图片需先入库为站点资产(写入系统当前配置的存储方式并登记后台素材库):跨机部署(AI 客户端与商城服务器不同机)走「票据直传」链路——先调 `crmeb_upload_ticket` 获取票据与本机可执行的 curl 命令,客户端直传 `POST /api/mcp/upload` 拿到暂存引用 `ref`,再把 `ref` 作为 `source` 调 `crmeb_upload_image` 完成正式入库(详见第 7 节)。`crmeb_upload_image` 的来源按上下文成本从低到高:直传暂存引用 → 生图接口 http(s) URL(服务端下载落站,上限 10MB)→ 站点已有图片相对路径(校验存在后原样返回并补全元数据)→ 本地白名单路径(本机 Agent 先把生图文件放入 `runtime/upload`,可用系统配置 `mcp_upload_local_dirs` 追加白名单,仅 AI 与服务器同机部署时可用)→ base64 data URI 内联(仅作无法发起本机 HTTP 上传时的最后应急,以纯文本经过对话上下文,解码后超过 512KB 拒绝并提示改用直传)。仅支持 jpg/jpeg/png/gif/webp;同内容按 md5 去重;单次最多 20 张、单密钥每分钟最多 60 张;批量中个别失败不影响其他图片,结果按 uploaded/failed 逐条返回,成功项含 url/att_id/宽高/大小/md5/dedupe_hit。商品工具的 `slider_image`(数组,整组写入)与 `description` 内 `` 只接受换取到的站点图片地址。 + 插件停用或卸载后,其事件监听器不再加载,对应工具会自动从 `tools/list` 消失。开发流程和约束见 [插件扩展 MCP 工具](../../../.crmeb/skills/crmeb-plugin-dev/references/mcp-tools.md)。 -## 7. 错误响应 +## 7. 图片直传端点 + +`POST /api/mcp/upload` 是跨机场景的图片字节带外入口:AI 客户端(WorkBuddy 等)在自己机器上生成图片后,由客户端本机直接发出 multipart 请求把字节交给服务器暂存——不占用对话上下文(不塞 base64),也不要求服务器能读到客户端磁盘(不走本地路径),是「生成图片 → 写入商城」零人工闭环的推荐链路。 + +三步时序(全部由 AI 客户端自主完成): + +1. 调 MCP 工具 `crmeb_upload_ticket`(无参数)获取票据:返回 `ticket`(10 分钟有效)、`upload_url`,以及 `command` 字段——可直接在本机执行的一条 curl 命令; +2. 客户端本机执行上传命令(或同构的 multipart 请求),把生成好的图片传上去: + + ```bash + curl -s -F "ticket=<票据>" -F "files[]=@/本机图片路径.png" https://example.com/api/mcp/upload + ``` + + 响应 `data.received[].ref` 为暂存引用(形如 `runtime/upload/mcp_recv/up_xxx.png`);单次最多 20 张、单张不超过 10MB,仅支持 jpg/jpeg/png/gif/webp; +3. 把 `ref` 作为 `source` 调 `crmeb_upload_image` 完成正式入库(去重、素材库登记、限流、审计均收敛在该工具内),再用返回的站点地址调用商品工具写库。入库失败只需重试工具调用,不必重传文件。 + +鉴权与边界: + +- 直传端点只认票据、不要求 `X-Mcp-Key`:避免长期密钥进入 AI 客户端的命令、对话上下文与命令历史;票据服务端只存 sha256 摘要,10 分钟过期后重新调用工具获取即可(不做单次消费,重试友好)。票据只能由有权调用 `crmeb_upload_ticket` 的账号获取,端点同样受服务总开关 `mcp_enabled` 约束。 +- 端点只做「收字节 + 暂存」:暂存文件 1 小时未被入库会被机会式清理,正式入库必须经 `crmeb_upload_image`。 +- 失败口径:票据缺失或无效返回 401;非图片、超 10MB 等逐条进 `data.failed[]`,不影响同批其他图片;单账号每分钟最多 60 张,超限返回 429;单批超过 20 张返回 400(超过服务端 `max_file_uploads` 上限时同样明确报错,不会静默丢弃)。 +- 生产环境请经 HTTPS 调用并限制可信来源(见第 9 节)。 + +## 8. 错误响应 除 notification 返回 HTTP 204 外,当前接口通常以 HTTP 200 返回 JSON-RPC 成功或错误对象,调用方必须检查 `error`,不能只判断 HTTP 状态码。 @@ -178,7 +210,7 @@ curl 'https://example.com/api/mcp' \ 系统异常只在服务端日志中保留文件、行号和堆栈,响应不会暴露内部细节。 -## 8. 安全与上线检查 +## 9. 安全与上线检查 - 只通过 HTTPS 对外提供服务,限制可信来源、请求大小、频率和并发。 - 按客户端最小授权:为不同客户端创建独立账号并只勾选需要的工具。 @@ -189,7 +221,7 @@ curl 'https://example.com/api/mcp' \ - 不要把商城用户 token、管理员 token 或开放平台凭据当作 MCP 密钥使用。 - 生产环境仅开放确实需要的网络入口;无需外部访问时保持内网可达。 -## 9. 常见问题 +## 10. 常见问题 | 现象 | 排查方向 | |---|---| @@ -203,13 +235,18 @@ curl 'https://example.com/api/mcp' \ | 工具不存在 | 重新执行 `tools/list`;确认提供该工具的插件已启用 | | 客户端只能使用本地 command 类型 | 选择支持远程 HTTP MCP 的客户端或版本,本服务不提供 stdio 启动命令 | | AI Agent 看不到新工具 | 系统账号白名单已勾选的前提下,在 AI Agent 的 MCP 服务管理中重新执行「同步工具」 | +| 直传端点返回 `401` | 票据缺失或已过期(有效期 10 分钟):重新调用 `crmeb_upload_ticket` 获取新票据与新命令,不要复用旧命令 | +| 直传端点返回 `400` 提示超过服务端限制 | 单批文件数超过 PHP `max_file_uploads`(默认 20,与工具单次上限一致),拆分为每批不超过 20 张后重试 | +| 直传端点返回 `429` | 单账号每分钟最多 60 张,稍后重试或分批上传 | -## 10. 实现位置 +## 11. 实现位置 | 内容 | 路径 | |---|---| | 路由 | `backend/modules/service/mcp/route/user.php` | | 协议与认证 | `backend/modules/service/mcp/controller/McpController.php` | +| 图片直传端点(multipart 收字节 + 暂存 GC) | `backend/modules/service/mcp/controller/McpUploadController.php` | +| 直传票据签发/校验 | `backend/modules/service/mcp/service/McpUploadTicketServices.php` | | 工具注册中心 | `backend/modules/service/mcp/McpToolRegistry.php` | | 内置工具 | `backend/modules/service/mcp/tools/` | | 账号列表页(MCP配置) | `backend/modules/service/mcp/schema/AccountListPage.php` |