2026-03-23 14:57:47 +08:00

902 lines
32 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

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

<?php
// +----------------------------------------------------------------------
// | CRMEB [ CRMEB赋能开发者助力企业发展 ]
// +----------------------------------------------------------------------
// | Copyright (c) 2016~2023 https://www.crmeb.com All rights reserved.
// +----------------------------------------------------------------------
// | Licensed CRMEB并不是自由软件未经许可不能去掉CRMEB相关版权
// +----------------------------------------------------------------------
// | Author: CRMEB Team <admin@crmeb.com>
// +----------------------------------------------------------------------
namespace app\outapi\controller;
use think\facade\Db;
use think\Request;
/**
* MCP (Model Context Protocol) 控制器
* 提供 AI 助手调用 CRMEB API 的标准接口
*
* 认证方式account + password
* 通过请求头传递账号和密码进行认证
*/
class Mcp extends AuthController
{
/**
* 初始化
* MCP 接口不走 Token 中间件,直接使用 appid + appsecret 认证
*/
protected function initialize()
{
// 不调用父类 initialize因为 MCP 不走 Token 中间件
$this->authByAppSecret();
}
/**
* 通过 appid + appsecret 认证
* 参考 AuthTokenMiddleware 的验证流程
*/
private function authByAppSecret()
{
$account = $this->request->header('account', '');
$password = $this->request->header('password', '');
if (empty($account) || empty($password)) {
$this->authFail('认证失败:缺少 account 或 password');
return;
}
try {
// 查询账号信息
$accountInfo = Db::name('out_account')
->where('appid', $account)
->where('is_del', 0)
->find();
// 账号不存在
if (!$accountInfo) {
$this->authFail('账号不存在');
return;
}
// 验证密码
if (!password_verify($password, $accountInfo['appsecret'])) {
$this->authFail('密码验证失败');
return;
}
// 检查账号状态status=0 或 status=2 表示禁用)
if ($accountInfo['status'] == 0 || $accountInfo['status'] == 2) {
$this->authFail('账号已被禁用');
return;
}
// 认证成功,设置账号信息
$this->outId = (int)$accountInfo['id'];
$this->outInfo = $accountInfo;
// 验证接口权限(参考 AuthTokenMiddleware
// $this->verifyAuth();
} catch (\crmeb\exceptions\AuthException $e) {
// AuthException 转换为友好错误
$this->authFail('您暂时没有访问权限');
} catch (\Exception $e) {
// 不暴露具体错误信息
$this->authFail('认证失败');
}
}
/**
* 验证接口权限
* 参考 AuthTokenMiddleware 的 verifyAuth 逻辑
* MCP 接口需要进行路由权限检查
*/
private function verifyAuth()
{
try {
// 注入 outId 和 outInfo 到 request模拟中间件的行为
$outInfo = $this->outInfo;
$this->request->macro('outId', function () use (&$outInfo) {
return (int)$outInfo['id'];
});
$this->request->macro('outInfo', function () use (&$outInfo) {
return $outInfo;
});
// 调用接口权限验证服务
$outInterfaceServices = app()->make(\app\services\out\OutInterfaceServices::class);
$outInterfaceServices->verifyAuth($this->request);
} catch (\crmeb\exceptions\AuthException $e) {
// 权限验证失败,抛出友好的错误信息
throw new \crmeb\exceptions\AuthException(110000); // 无权限访问
} catch (\Exception $e) {
// 其他异常,统一返回无权限
throw new \crmeb\exceptions\AuthException(110000);
}
}
/**
* 认证失败处理
* 设置错误标识,在 index 方法中返回错误响应
*/
private function authFail(string $message)
{
$this->outId = 0;
$this->outInfo = ['error' => $message];
}
/**
* 获取MCP工具定义列表
* 定义所有可供AI助手调用的工具及其参数结构
*
* @return array 工具定义数组
*/
private function getTools(): array
{
return [
// 分类管理
[
'name' => 'crmeb_category_list',
'description' => '获取商品分类列表,支持树形结构展示',
'inputSchema' => [
'type' => 'object',
'properties' => [
'page' => ['type' => 'number', 'description' => '页码(非树形模式时有效)'],
'limit' => ['type' => 'number', 'description' => '每页数量(非树形模式时有效)'],
'tree' => ['type' => 'boolean', 'description' => '是否返回树形结构默认为true'],
'pid' => ['type' => 'number', 'description' => '父级ID指定则只返回该父级下的分类'],
],
],
],
[
'name' => 'crmeb_category_detail',
'description' => '获取分类详情',
'inputSchema' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'number', 'description' => '分类ID'],
],
'required' => ['id'],
],
],
// 商品管理
[
'name' => 'crmeb_product_list',
'description' => '获取商品列表',
'inputSchema' => [
'type' => 'object',
'properties' => [
'page' => ['type' => 'number', 'description' => '页码'],
'limit' => ['type' => 'number', 'description' => '每页数量'],
'cate_id' => ['type' => 'number', 'description' => '分类ID'],
'keyword' => ['type' => 'string', 'description' => '搜索关键词'],
'stock_min' => ['type' => 'number', 'description' => '最小库存'],
'stock_max' => ['type' => 'number', 'description' => '最大库存'],
],
],
],
[
'name' => 'crmeb_product_detail',
'description' => '获取商品详情',
'inputSchema' => [
'type' => 'object',
'properties' => [
'id' => ['type' => 'number', 'description' => '商品ID'],
],
'required' => ['id'],
],
],
// 订单管理
[
'name' => 'crmeb_order_list',
'description' => '获取订单列表',
'inputSchema' => [
'type' => 'object',
'properties' => [
'page' => ['type' => 'number', 'description' => '页码'],
'limit' => ['type' => 'number', 'description' => '每页数量'],
'status' => ['type' => 'number', 'description' => '订单状态'],
'keyword' => ['type' => 'string', 'description' => '搜索关键词'],
],
],
],
[
'name' => 'crmeb_order_detail',
'description' => '获取订单详情',
'inputSchema' => [
'type' => 'object',
'properties' => [
'order_id' => ['type' => 'string', 'description' => '订单号'],
],
'required' => ['order_id'],
],
],
[
'name' => 'crmeb_order_express_list',
'description' => '获取物流公司列表',
'inputSchema' => [
'type' => 'object',
'properties' => new \stdClass(),
],
],
// 售后管理
[
'name' => 'crmeb_refund_list',
'description' => '获取售后订单列表',
'inputSchema' => [
'type' => 'object',
'properties' => [
'page' => ['type' => 'number', 'description' => '页码'],
'limit' => ['type' => 'number', 'description' => '每页数量'],
],
],
],
[
'name' => 'crmeb_refund_detail',
'description' => '获取售后订单详情',
'inputSchema' => [
'type' => 'object',
'properties' => [
'order_id' => ['type' => 'string', 'description' => '售后订单号'],
],
'required' => ['order_id'],
],
],
// 优惠券管理
[
'name' => 'crmeb_coupon_list',
'description' => '获取优惠券列表',
'inputSchema' => [
'type' => 'object',
'properties' => [
'page' => ['type' => 'number', 'description' => '页码'],
'limit' => ['type' => 'number', 'description' => '每页数量'],
],
],
],
// 用户管理
[
'name' => 'crmeb_user_list',
'description' => '获取用户列表',
'inputSchema' => [
'type' => 'object',
'properties' => [
'page' => ['type' => 'number', 'description' => '页码'],
'limit' => ['type' => 'number', 'description' => '每页数量'],
'keyword' => ['type' => 'string', 'description' => '搜索关键词'],
],
],
],
[
'name' => 'crmeb_user_detail',
'description' => '获取用户详情',
'inputSchema' => [
'type' => 'object',
'properties' => [
'uid' => ['type' => 'number', 'description' => '用户ID'],
],
'required' => ['uid'],
],
],
];
}
/**
* 处理工具调用
* 根据工具名称分发到对应的处理方法
*
* @param string $name 工具名称
* @param array $args 工具参数
* @return array 处理结果
* @throws \Exception 未知工具或参数错误时抛出异常
*/
private function handleToolCall(string $name, array $args = [])
{
switch ($name) {
// 分类管理
case 'crmeb_category_list':
return $this->categoryList($args);
case 'crmeb_category_detail':
if (!isset($args['id']) || !is_numeric($args['id'])) {
throw new \Exception('参数错误:缺少 id 或格式不正确');
}
return $this->categoryDetail((int)$args['id']);
// 商品管理
case 'crmeb_product_list':
return $this->productList($args);
case 'crmeb_product_detail':
if (!isset($args['id']) || !is_numeric($args['id'])) {
throw new \Exception('参数错误:缺少 id 或格式不正确');
}
return $this->productDetail((int)$args['id']);
// 订单管理
case 'crmeb_order_list':
return $this->orderList($args);
case 'crmeb_order_detail':
if (empty($args['order_id'])) {
throw new \Exception('参数错误:缺少 order_id');
}
return $this->orderDetail($args['order_id']);
case 'crmeb_order_express_list':
return $this->orderExpressList();
// 售后管理
case 'crmeb_refund_list':
return $this->refundList($args);
case 'crmeb_refund_detail':
if (empty($args['order_id'])) {
throw new \Exception('参数错误:缺少 order_id');
}
return $this->refundDetail($args['order_id']);
// 优惠券管理
case 'crmeb_coupon_list':
return $this->couponList($args);
// 用户管理
case 'crmeb_user_list':
return $this->userList($args);
case 'crmeb_user_detail':
if (!isset($args['uid']) || !is_numeric($args['uid'])) {
throw new \Exception('参数错误:缺少 uid 或格式不正确');
}
return $this->userDetail((int)$args['uid']);
default:
throw new \Exception("未知工具: {$name}");
}
}
// ==================== 分类管理 ====================
/**
* 获取商品分类列表
*
* @param array $args 查询参数
* - page: 页码默认1非树形模式时有效
* - limit: 每页数量默认10最大100非树形模式时有效
* - tree: 是否返回树形结构默认false
* - pid: 父级ID指定则只返回该父级下的分类
* @return array 分类列表和总数
*/
private function categoryList(array $args): array
{
$page = max(1, (int)($args['page'] ?? 1));
$limit = min(100, max(1, (int)($args['limit'] ?? 10))); // 限制最大100
$isTree = $args['tree'] ?? true;
$pid = $args['pid'] ?? null;
// 构建基础查询
$query = Db::name('store_category')->where('is_show', 1);
// 如果指定了父级ID
if ($pid !== null) {
$query = $query->where('pid', $pid);
}
// 树形模式:获取所有分类并构建树
if ($isTree) {
$allList = Db::name('store_category')
->where('is_show', 1)
->order('sort desc, id desc')
->select()
->toArray();
// 如果有指定pid从该节点开始构建树
if ($pid !== null) {
$tree = $this->buildCategoryTree($allList, $pid);
return ['list' => $tree, 'count' => count($tree)];
}
// 否则构建完整树从根节点pid=0开始
$tree = $this->buildCategoryTree($allList, 0);
return ['list' => $tree, 'count' => count($tree)];
}
// 普通列表模式
$list = $query
->order('sort desc, id desc')
->page($page, $limit)
->select()
->toArray();
$count = $query->count();
return ['list' => $list, 'count' => $count];
}
/**
* 构建分类树形结构
*
* @param array $list 所有分类数据
* @param int $pid 父级ID
* @return array 树形结构
*/
private function buildCategoryTree(array $list, int $pid): array
{
$tree = [];
foreach ($list as $item) {
if ($item['pid'] == $pid) {
$children = $this->buildCategoryTree($list, $item['id']);
if (!empty($children)) {
$item['children'] = $children;
}
$tree[] = $item;
}
}
return $tree;
}
/**
* 获取分类详情
*
* @param int $id 分类ID
* @return array 分类详细信息
* @throws \Exception 分类不存在时抛出异常
*/
private function categoryDetail(int $id): array
{
$info = Db::name('store_category')->where('id', $id)->find();
if (!$info) {
throw new \Exception('分类不存在');
}
return $info;
}
// ==================== 商品管理 ====================
/**
* 获取商品列表
* 支持按分类、关键词、库存范围筛选
*
* @param array $args 查询参数
* - page: 页码默认1
* - limit: 每页数量默认10最大100
* - cate_id: 分类ID可选
* - keyword: 搜索关键词(可选)
* - stock_min: 最小库存(可选)
* - stock_max: 最大库存(可选)
* @return array 商品列表和总数
*/
private function productList(array $args): array
{
$page = max(1, (int)($args['page'] ?? 1));
$limit = min(100, max(1, (int)($args['limit'] ?? 10))); // 限制最大100
$where = [['is_show', '=', 1]];
// 分类筛选:通过关联表查询
if (!empty($args['cate_id'])) {
$cateId = (int)$args['cate_id'];
// 验证分类是否存在
$categoryExists = Db::name('store_category')->where('id', $cateId)->where('is_show', 1)->count();
if (!$categoryExists) {
throw new \Exception('分类不存在');
}
// 通过关联表查询商品ID
$productIds = Db::name('store_product_cate')
->where('cate_id', $cateId)
->column('product_id');
if (empty($productIds)) {
return ['list' => [], 'count' => 0];
}
$where[] = ['id', 'in', $productIds];
}
// 关键词搜索:转义通配符防止注入
if (!empty($args['keyword'])) {
$keyword = addcslashes($args['keyword'], '%_');
$where[] = ['store_name', 'like', '%' . $keyword . '%'];
}
if (isset($args['stock_min'])) {
$where[] = ['stock', '>=', (int)$args['stock_min']];
}
if (isset($args['stock_max'])) {
$where[] = ['stock', '<=', (int)$args['stock_max']];
}
$list = Db::name('store_product')
->where($where)
->field('id,store_name,cate_id,price,stock,image,sales,is_show')
->order('id desc')
->page($page, $limit)
->select()
->toArray();
$count = Db::name('store_product')->where($where)->count();
return ['list' => $list, 'count' => $count];
}
/**
* 获取商品详情
*
* @param int $id 商品ID
* @return array 商品详细信息(已过滤敏感字段)
* @throws \Exception 商品不存在时抛出异常
*/
private function productDetail(int $id): array
{
$info = Db::name('store_product')->where('id', $id)->find();
if (!$info) {
throw new \Exception('商品不存在');
}
// 过滤敏感字段,只返回必要信息
return [
'id' => $info['id'],
'store_name' => $info['store_name'] ?? '',
'cate_id' => $info['cate_id'] ?? 0,
'price' => $info['price'] ?? 0,
'stock' => $info['stock'] ?? 0,
'image' => $info['image'] ?? '',
'slider_image' => $info['slider_image'] ?? '',
'sales' => $info['sales'] ?? 0,
'unit_name' => $info['unit_name'] ?? '',
'content' => $info['content'] ?? '',
'is_show' => $info['is_show'] ?? 1,
];
}
// ==================== 订单管理 ====================
/**
* 获取订单列表
* 支持按状态和关键词筛选
*
* @param array $args 查询参数
* - page: 页码默认1
* - limit: 每页数量默认10最大100
* - status: 订单状态(可选)
* - keyword: 搜索关键词,匹配订单号/姓名/手机号(可选)
* @return array 订单列表和总数
*/
private function orderList(array $args): array
{
$page = max(1, (int)($args['page'] ?? 1));
$limit = min(100, max(1, (int)($args['limit'] ?? 10))); // 限制最大100
$where = [['is_del', '=', 0]];
if (isset($args['status'])) {
$where[] = ['status', '=', (int)$args['status']];
}
// 关键词搜索:转义通配符防止注入
if (!empty($args['keyword'])) {
$keyword = addcslashes($args['keyword'], '%_');
$where[] = ['order_id|real_name|user_phone', 'like', '%' . $keyword . '%'];
}
$list = Db::name('store_order')
->where($where)
->field('id,order_id,uid,total_price,pay_price,paid,status,delivery_type,add_time')
->order('id desc')
->page($page, $limit)
->select()
->toArray();
$count = Db::name('store_order')->where($where)->count();
return ['list' => $list, 'count' => $count];
}
/**
* 获取订单详情
*
* @param string $orderId 订单号
* @return array 订单详细信息(已过滤敏感字段)
* @throws \Exception 订单不存在时抛出异常
*/
private function orderDetail(string $orderId): array
{
$info = Db::name('store_order')->where('order_id', $orderId)->find();
if (!$info) {
throw new \Exception('订单不存在');
}
// 过滤敏感字段,只返回必要信息
return [
'id' => $info['id'],
'order_id' => $info['order_id'],
'uid' => $info['uid'],
'real_name' => $info['real_name'] ?? '',
'user_phone' => $info['user_phone'] ?? '',
'user_address' => $info['user_address'] ?? '',
'total_price' => $info['total_price'] ?? 0,
'pay_price' => $info['pay_price'] ?? 0,
'pay_type' => $info['pay_type'] ?? '',
'paid' => $info['paid'] ?? 0,
'status' => $info['status'] ?? 0,
'delivery_type' => $info['delivery_type'] ?? '',
'delivery_name' => $info['delivery_name'] ?? '',
'delivery_id' => $info['delivery_id'] ?? '',
'refund_status' => $info['refund_status'] ?? 0,
'add_time' => $info['add_time'] ?? 0,
];
}
/**
* 获取物流公司列表
* 返回所有启用的快递公司信息
*
* @return array 物流公司列表
*/
private function orderExpressList(): array
{
$list = Db::name('express')->where('is_show', 1)->field('id,name,code')->select()->toArray();
return ['list' => $list];
}
// ==================== 售后管理 ====================
/**
* 获取售后订单列表
* 返回所有有退款状态的订单
*
* @param array $args 查询参数
* - page: 页码默认1
* - limit: 每页数量默认10最大100
* @return array 售后订单列表和总数
*/
private function refundList(array $args): array
{
$page = max(1, (int)($args['page'] ?? 1));
$limit = min(100, max(1, (int)($args['limit'] ?? 10))); // 限制最大100
$list = Db::name('store_order')
->where('refund_status', '>', 0)
->field('id,order_id,uid,total_price,pay_price,refund_status,refund_reason')
->order('id desc')
->page($page, $limit)
->select()
->toArray();
$count = Db::name('store_order')->where('refund_status', '>', 0)->count();
return ['list' => $list, 'count' => $count];
}
/**
* 获取售后订单详情
*
* @param string $orderId 售后订单号
* @return array 售后订单详细信息(已过滤敏感字段)
* @throws \Exception 售后订单不存在时抛出异常
*/
private function refundDetail(string $orderId): array
{
$info = Db::name('store_order')
->where('order_id', $orderId)
->where('refund_status', '>', 0)
->find();
if (!$info) {
throw new \Exception('售后订单不存在');
}
// 过滤敏感字段,只返回必要信息
return [
'id' => $info['id'],
'order_id' => $info['order_id'],
'uid' => $info['uid'],
'total_price' => $info['total_price'] ?? 0,
'pay_price' => $info['pay_price'] ?? 0,
'refund_status' => $info['refund_status'] ?? 0,
'refund_reason' => $info['refund_reason'] ?? '',
'refund_price' => $info['refund_price'] ?? 0,
'refund_explain' => $info['refund_explain'] ?? '',
'refund_img' => $info['refund_img'] ?? '',
'add_time' => $info['add_time'] ?? 0,
];
}
// ==================== 优惠券管理 ====================
/**
* 获取优惠券列表
*
* @param array $args 查询参数
* - page: 页码默认1
* - limit: 每页数量默认10最大100
* @return array 优惠券列表和总数
*/
private function couponList(array $args): array
{
$page = max(1, (int)($args['page'] ?? 1));
$limit = min(100, max(1, (int)($args['limit'] ?? 10))); // 限制最大100
$list = Db::name('store_coupon_issue')
->where('is_del', 0)
->field('id,coupon_title,coupon_price,use_min_price,start_time,end_time')
->order('id desc')
->page($page, $limit)
->select()
->toArray();
$count = Db::name('store_coupon_issue')->where('is_del', 0)->count();
return ['list' => $list, 'count' => $count];
}
// ==================== 用户管理 ====================
/**
* 获取用户列表
* 支持按昵称或手机号搜索
*
* @param array $args 查询参数
* - page: 页码默认1
* - limit: 每页数量默认10最大100
* - keyword: 搜索关键词,匹配昵称/手机号(可选)
* @return array 用户列表和总数
*/
private function userList(array $args): array
{
$page = max(1, (int)($args['page'] ?? 1));
$limit = min(100, max(1, (int)($args['limit'] ?? 10))); // 限制最大100
$where = [];
// 关键词搜索:转义通配符防止注入
if (!empty($args['keyword'])) {
$keyword = addcslashes($args['keyword'], '%_');
$where[] = ['nickname|phone', 'like', '%' . $keyword . '%'];
}
$list = Db::name('user')
->where($where)
->field('uid,nickname,avatar,phone,balance,integral,add_time')
->order('uid desc')
->page($page, $limit)
->select()
->toArray();
$count = Db::name('user')->where($where)->count();
return ['list' => $list, 'count' => $count];
}
/**
* 获取用户详情
*
* @param int $uid 用户ID
* @return array 用户详细信息(已过滤敏感字段)
* @throws \Exception 用户不存在时抛出异常
*/
private function userDetail(int $uid): array
{
$info = Db::name('user')->where('uid', $uid)->find();
if (!$info) {
throw new \Exception('用户不存在');
}
// 过滤敏感字段,只返回必要信息
return [
'uid' => $info['uid'],
'nickname' => $info['nickname'] ?? '',
'avatar' => $info['avatar'] ?? '',
'phone' => $info['phone'] ?? '',
'now_money' => $info['now_money'] ?? 0,
'integral' => $info['integral'] ?? 0,
'level' => $info['level'] ?? 0,
'add_time' => $info['add_time'] ?? 0,
'last_time' => $info['last_time'] ?? 0,
];
}
// ==================== MCP 接口 ====================
/**
* MCP 服务入口方法
* 处理所有 MCP 协议请求,包括:
* - initialize: 初始化连接,返回服务信息和能力
* - tools/list: 获取可用工具列表
* - tools/call: 调用指定工具执行操作
*
* @param Request $request HTTP请求对象
* @return \think\response\Json JSON-RPC 2.0 格式响应
*/
public function index(Request $request)
{
$input = file_get_contents('php://input');
$data = json_decode($input, true);
$id = $data['id'] ?? null;
if (!$data) {
return json(['jsonrpc' => '2.0', 'error' => ['code' => -32700, 'message' => 'Parse error'], 'id' => null]);
}
// 认证检查
if (empty($this->outId)) {
$errorMsg = $this->outInfo['error'] ?? '认证失败';
return json([
'jsonrpc' => '2.0',
'id' => $id,
'error' => ['code' => -32600, 'message' => $errorMsg]
]);
}
$method = $data['method'] ?? '';
$params = $data['params'] ?? [];
try {
switch ($method) {
case 'initialize':
return json([
'jsonrpc' => '2.0',
'id' => $id,
'result' => [
'protocolVersion' => '2024-11-05',
'capabilities' => ['tools' => new \stdClass()],
'serverInfo' => [
'name' => 'crmeb-mcp-server',
'version' => '1.0.0',
],
],
]);
case 'tools/list':
return json([
'jsonrpc' => '2.0',
'id' => $id,
'result' => ['tools' => $this->getTools()],
]);
case 'tools/call':
$toolName = $params['name'] ?? '';
$toolArgs = $params['arguments'] ?? [];
$result = $this->handleToolCall($toolName, $toolArgs);
return json([
'jsonrpc' => '2.0',
'id' => $id,
'result' => [
'content' => [
[
'type' => 'text',
'text' => json_encode($result, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT),
],
],
],
]);
default:
return json([
'jsonrpc' => '2.0',
'id' => $id,
'error' => ['code' => -32601, 'message' => "Method not found: {$method}"],
]);
}
} catch (\Exception $e) {
// 生产环境返回通用错误信息,避免泄露内部细节
$errorMessage = $e->getMessage();
// 对于业务异常(如"商品不存在"),返回具体错误
// 对于系统异常如SQL错误返回通用错误
$safeErrors = ['商品不存在', '订单不存在', '售后订单不存在', '用户不存在', '分类不存在', '父分类不存在',
'同级分类下已存在同名分类', '分类名称不能超过50个字符',
'参数错误', '未知工具'];
$isSafeError = false;
foreach ($safeErrors as $safeError) {
if (strpos($errorMessage, $safeError) !== false) {
$isSafeError = true;
break;
}
}
return json([
'jsonrpc' => '2.0',
'id' => $id,
'error' => ['code' => -32603, 'message' => $isSafeError ? $errorMessage : '服务器内部错误'],
]);
}
}
}