mirror of
https://github.com/crmeb/CRMEB.git
synced 2026-10-28 13:31:04 +00:00
feat: 新增 MCP 图片直传与实物商品工具能力
本次提交新增 MCP 图片直传功能:包含图片直传路由、票据签发服务、直传端点,解决跨机场景图片上传问题;同时新增实物商品插件工具,支持商品详情读取、新增、编辑,完善 MCP 生态。
This commit is contained in:
parent
04908b0a54
commit
e83409da76
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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,
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
@ -0,0 +1,41 @@
|
||||
<?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\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);
|
||||
}
|
||||
}
|
||||
246
backend/modules/product/normal/mcp/NormalProductCreateTool.php
Normal file
246
backend/modules/product/normal/mcp/NormalProductCreateTool.php
Normal file
@ -0,0 +1,246 @@
|
||||
<?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\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 内 <img> 均须使用站点图片地址;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 '';
|
||||
}
|
||||
}
|
||||
169
backend/modules/product/normal/mcp/NormalProductReadTool.php
Normal file
169
backend/modules/product/normal/mcp/NormalProductReadTool.php
Normal file
@ -0,0 +1,169 @@
|
||||
<?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\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;
|
||||
}
|
||||
}
|
||||
319
backend/modules/product/normal/mcp/NormalProductUpdateTool.php
Normal file
319
backend/modules/product/normal/mcp/NormalProductUpdateTool.php
Normal file
@ -0,0 +1,319 @@
|
||||
<?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\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 内 <img> 均须使用站点图片地址;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;
|
||||
}
|
||||
}
|
||||
245
backend/modules/service/mcp/controller/McpUploadController.php
Normal file
245
backend/modules/service/mcp/controller/McpUploadController.php
Normal file
@ -0,0 +1,245 @@
|
||||
<?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\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) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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图片直传接口']);
|
||||
|
||||
@ -0,0 +1,76 @@
|
||||
<?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\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;
|
||||
}
|
||||
}
|
||||
651
backend/modules/service/mcp/tools/UploadImageTool.php
Normal file
651
backend/modules/service/mcp/tools/UploadImageTool.php
Normal file
@ -0,0 +1,651 @@
|
||||
<?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;
|
||||
}
|
||||
}
|
||||
170
backend/modules/service/mcp/tools/UploadTicketTool.php
Normal file
170
backend/modules/service/mcp/tools/UploadTicketTool.php
Normal file
@ -0,0 +1,170 @@
|
||||
<?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 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);
|
||||
}
|
||||
}
|
||||
@ -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` 内 `<img>` 只接受换取到的站点图片地址。
|
||||
|
||||
插件停用或卸载后,其事件监听器不再加载,对应工具会自动从 `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` |
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user