mirror of
https://gitee.com/zhijiantianya/ruoyi-vue-pro.git
synced 2026-03-22 05:07:17 +08:00
feat(iot): 添加 IoT MCP 工具和安全配置
This commit is contained in:
@@ -101,6 +101,19 @@
|
||||
<artifactId>spring-boot-starter-amqp</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
|
||||
<!-- Spring AI MCP Server(SSE 端点 + @Tool 注解 + ToolCallbacks) -->
|
||||
<dependency>
|
||||
<groupId>org.springframework.ai</groupId>
|
||||
<artifactId>spring-ai-starter-mcp-server-webmvc</artifactId>
|
||||
<version>1.1.2</version>
|
||||
<exclusions>
|
||||
<exclusion>
|
||||
<groupId>io.swagger.core.v3</groupId>
|
||||
<artifactId>swagger-annotations-jakarta</artifactId>
|
||||
</exclusion>
|
||||
</exclusions>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
</project>
|
||||
|
||||
@@ -42,6 +42,10 @@ public interface IotDeviceMapper extends BaseMapperX<IotDeviceDO> {
|
||||
return selectOne(IotDeviceDO::getDeviceName, deviceName);
|
||||
}
|
||||
|
||||
default List<IotDeviceDO> selectListByDeviceName(String deviceName) {
|
||||
return selectList(IotDeviceDO::getDeviceName, deviceName);
|
||||
}
|
||||
|
||||
default IotDeviceDO selectByProductKeyAndDeviceName(String productKey, String deviceName) {
|
||||
return selectOne(IotDeviceDO::getProductKey, productKey,
|
||||
IotDeviceDO::getDeviceName, deviceName);
|
||||
|
||||
@@ -38,6 +38,10 @@ public interface IotProductMapper extends BaseMapperX<IotProductDO> {
|
||||
.apply("LOWER(product_key) = {0}", productKey.toLowerCase()));
|
||||
}
|
||||
|
||||
default List<IotProductDO> selectListByName(String name) {
|
||||
return selectList(IotProductDO::getName, name);
|
||||
}
|
||||
|
||||
default List<IotProductDO> selectListByStatus(Integer status) {
|
||||
return selectList(IotProductDO::getStatus, status);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
package cn.iocoder.yudao.module.iot.mcp;
|
||||
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
|
||||
import cn.iocoder.yudao.module.iot.controller.admin.alert.vo.recrod.IotAlertRecordPageReqVO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.alert.IotAlertRecordDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
|
||||
import cn.iocoder.yudao.module.iot.service.alert.IotAlertRecordService;
|
||||
import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
|
||||
import cn.iocoder.yudao.module.iot.service.product.IotProductService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* IoT 告警记录 MCP 工具
|
||||
*/
|
||||
@Component
|
||||
public class IotAlertMcpTool {
|
||||
|
||||
@Resource
|
||||
private IotAlertRecordService alertRecordService;
|
||||
@Resource
|
||||
private IotDeviceService deviceService;
|
||||
@Resource
|
||||
private IotProductService productService;
|
||||
|
||||
// TODO @AI: 是否可以考虑使用 Spring 的 @Autowired 注解来注入?为什么不能使用@Resource?
|
||||
private IotDeviceService getDeviceService() {
|
||||
if (deviceService != null) return deviceService;
|
||||
return SpringUtil.getBean(IotDeviceService.class);
|
||||
}
|
||||
|
||||
private IotAlertRecordService getAlertRecordService() {
|
||||
if (alertRecordService != null) return alertRecordService;
|
||||
return SpringUtil.getBean(IotAlertRecordService.class);
|
||||
}
|
||||
|
||||
private IotProductService getProductService() {
|
||||
if (productService != null) return productService;
|
||||
return SpringUtil.getBean(IotProductService.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析设备:deviceId(字符串形式的 Long)优先,否则 productKey/产品名称 + deviceName。与 IotDeviceMcpTool 行为一致。
|
||||
*
|
||||
* @param deviceIdStr 设备 ID 字符串,可为 Long
|
||||
* @param productKey 产品 Key 或产品名称
|
||||
* @param deviceName 设备名称
|
||||
* @return 设备 ID,未找到或参数不足时返回 null
|
||||
*/
|
||||
private Long resolveDeviceId(String deviceIdStr, String productKey, String deviceName) {
|
||||
IotDeviceService svc = getDeviceService();
|
||||
// 1. 优先按 deviceId 解析
|
||||
if (StringUtils.hasText(deviceIdStr)) {
|
||||
try {
|
||||
Long id = Long.parseLong(deviceIdStr.trim());
|
||||
IotDeviceDO d = svc.getDevice(id);
|
||||
return d != null ? d.getId() : null;
|
||||
} catch (NumberFormatException ignored) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!StringUtils.hasText(productKey) || !StringUtils.hasText(deviceName)) {
|
||||
return null;
|
||||
}
|
||||
String pk = productKey.trim();
|
||||
String dn = deviceName.trim();
|
||||
// 2. 按 productKey 查设备
|
||||
IotDeviceDO d = svc.getDeviceFromCache(pk, dn);
|
||||
if (d != null) {
|
||||
return d.getId();
|
||||
}
|
||||
// 3. 按产品名称查:用 productKey 当名称查产品列表,再逐个用真实 productKey 查设备
|
||||
List<IotProductDO> byName = getProductService().getProductListByName(pk);
|
||||
if (byName != null) {
|
||||
for (IotProductDO product : byName) {
|
||||
if (product.getProductKey() == null) {
|
||||
continue;
|
||||
}
|
||||
d = svc.getDeviceFromCache(product.getProductKey(), dn);
|
||||
if (d != null) {
|
||||
return d.getId();
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// TODO @AI: 分页默认值与错误 JSON 格式与 IotProductMcpTool、IotDeviceMcpTool 一致,可抽公共方法或基类
|
||||
@Tool(name = "iot_get_alert_records", description = "查询 IoT 告警记录列表。入参:deviceId(可选,设备编号)、或 productKey+deviceName;processStatus(可选,是否已处理);pageNo、pageSize(可选)。返回告警列表(规则名、级别、时间、处理状态)。")
|
||||
public String getAlertRecords(String deviceId, String productKey, String deviceName, Boolean processStatus, Integer pageNo, Integer pageSize) {
|
||||
return TenantUtils.executeIgnore((Callable<String>) () -> {
|
||||
IotAlertRecordPageReqVO req = new IotAlertRecordPageReqVO();
|
||||
Long resolvedDeviceId = resolveDeviceId(deviceId, productKey, deviceName);
|
||||
if (resolvedDeviceId != null) {
|
||||
req.setDeviceId(String.valueOf(resolvedDeviceId));
|
||||
}
|
||||
req.setProcessStatus(processStatus);
|
||||
req.setPageNo(pageNo != null && pageNo > 0 ? pageNo : 1);
|
||||
req.setPageSize(pageSize != null && pageSize > 0 && pageSize <= 100 ? pageSize : 10);
|
||||
PageResult<IotAlertRecordDO> page = getAlertRecordService().getAlertRecordPage(req);
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("total", page.getTotal());
|
||||
result.put("list", page.getList().stream().map(this::toMap).collect(Collectors.toList()));
|
||||
return JsonUtils.toJsonString(result);
|
||||
});
|
||||
}
|
||||
|
||||
private Map<String, Object> toMap(IotAlertRecordDO r) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", r.getId());
|
||||
m.put("configName", r.getConfigName());
|
||||
m.put("configLevel", r.getConfigLevel());
|
||||
m.put("deviceId", r.getDeviceId());
|
||||
m.put("processStatus", r.getProcessStatus());
|
||||
m.put("processRemark", r.getProcessRemark());
|
||||
m.put("createTime", r.getCreateTime());
|
||||
return m;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package cn.iocoder.yudao.module.iot.mcp;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
|
||||
import cn.iocoder.yudao.module.iot.controller.admin.device.vo.device.IotDevicePageReqVO;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.enums.device.IotDeviceStateEnum;
|
||||
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDevicePropertyDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
|
||||
import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
|
||||
import cn.iocoder.yudao.module.iot.service.device.message.IotDeviceMessageService;
|
||||
import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService;
|
||||
import cn.iocoder.yudao.module.iot.service.product.IotProductService;
|
||||
import jakarta.annotation.Resource;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.util.*;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* IoT 设备相关 MCP 工具:设备状态查询、属性历史、下发指令
|
||||
* TODO @AI: 分页默认值、getXxxService 兜底与其它 Tool 重复,可抽公共基类或工具方法。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class IotDeviceMcpTool {
|
||||
|
||||
@Resource
|
||||
private IotDeviceService deviceService;
|
||||
@Resource
|
||||
private IotDevicePropertyService devicePropertyService;
|
||||
@Resource
|
||||
private IotDeviceMessageService deviceMessageService;
|
||||
@Resource
|
||||
private IotProductService productService;
|
||||
|
||||
// TODO @AI: 是否可以建个配置类来取这个配置?
|
||||
@Value("${yudao.iot.mcp.enable-control-tools:false}")
|
||||
private Boolean enableControlTools;
|
||||
|
||||
// TODO @AI: 是否可以考虑使用 Spring 的 @Autowired 注解来注入?为什么不能使用@Resource?
|
||||
private IotDeviceService getDeviceService() {
|
||||
if (deviceService != null) return deviceService;
|
||||
return SpringUtil.getBean(IotDeviceService.class);
|
||||
}
|
||||
|
||||
private IotDevicePropertyService getDevicePropertyService() {
|
||||
if (devicePropertyService != null) return devicePropertyService;
|
||||
return SpringUtil.getBean(IotDevicePropertyService.class);
|
||||
}
|
||||
|
||||
private IotProductService getProductService() {
|
||||
if (productService != null) return productService;
|
||||
return SpringUtil.getBean(IotProductService.class);
|
||||
}
|
||||
|
||||
private IotDeviceMessageService getDeviceMessageService() {
|
||||
if (deviceMessageService != null) return deviceMessageService;
|
||||
return SpringUtil.getBean(IotDeviceMessageService.class);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析设备:deviceId 优先,否则 productKey/产品名称 + deviceName。支持 productKey 或产品名称(如「智能电表」)查询。
|
||||
*/
|
||||
private Long resolveDeviceId(Long deviceId, String productKey, String deviceName) {
|
||||
IotDeviceService svc = getDeviceService();
|
||||
if (deviceId != null) {
|
||||
IotDeviceDO d = svc.getDevice(deviceId);
|
||||
return d != null ? d.getId() : null;
|
||||
}
|
||||
if (!StringUtils.hasText(productKey) || !StringUtils.hasText(deviceName)) {
|
||||
return null;
|
||||
}
|
||||
String pk = productKey.trim();
|
||||
String dn = deviceName.trim();
|
||||
// 1) 先按 productKey 查
|
||||
IotDeviceDO d = svc.getDeviceFromCache(pk, dn);
|
||||
if (d != null) return d.getId();
|
||||
// 2) 再按产品名称查:用 productKey 当名称查产品列表,再逐个用真实 productKey 查设备
|
||||
List<IotProductDO> byName = getProductService().getProductListByName(pk);
|
||||
if (CollUtil.isEmpty(byName)) {
|
||||
return null;
|
||||
}
|
||||
for (IotProductDO product : byName) {
|
||||
if (product.getProductKey() == null) continue;
|
||||
d = svc.getDeviceFromCache(product.getProductKey(), dn);
|
||||
if (d != null) return d.getId();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设备元信息转 Map(不含属性快照、不含 deviceSecret)
|
||||
*/
|
||||
private Map<String, Object> deviceToMap(IotDeviceDO d, String productName) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", d.getId());
|
||||
m.put("deviceName", d.getDeviceName());
|
||||
m.put("nickname", d.getNickname());
|
||||
m.put("productId", d.getProductId());
|
||||
m.put("productKey", d.getProductKey());
|
||||
m.put("productName", productName);
|
||||
m.put("state", d.getState());
|
||||
m.put("stateName", d.getState() != null ? (IotDeviceStateEnum.ONLINE.getState().equals(d.getState()) ? "在线" : IotDeviceStateEnum.OFFLINE.getState().equals(d.getState()) ? "离线" : "未激活") : null);
|
||||
m.put("onlineTime", d.getOnlineTime());
|
||||
m.put("offlineTime", d.getOfflineTime());
|
||||
m.put("deviceType", d.getDeviceType());
|
||||
m.put("gatewayId", d.getGatewayId());
|
||||
m.put("createTime", d.getCreateTime());
|
||||
return m;
|
||||
}
|
||||
|
||||
@Tool(name = "iot_get_device_list", description = "分页查询设备列表。入参:pageNo、pageSize(可选);productId、deviceName、status(可选)。返回 total 与 list(id、deviceName、nickname、productId、productKey、productName、state、onlineTime 等,不含属性快照)。")
|
||||
public String getDeviceList(Integer pageNo, Integer pageSize, Long productId, String deviceName, Integer status) {
|
||||
return TenantUtils.executeIgnore((Callable<String>) () -> {
|
||||
IotDevicePageReqVO req = new IotDevicePageReqVO();
|
||||
req.setPageNo(pageNo != null && pageNo > 0 ? pageNo : 1);
|
||||
req.setPageSize(pageSize != null && pageSize > 0 && pageSize <= 100 ? pageSize : 10);
|
||||
if (productId != null) req.setProductId(productId);
|
||||
if (StringUtils.hasText(deviceName)) req.setDeviceName(deviceName.trim());
|
||||
if (status != null) req.setStatus(status);
|
||||
PageResult<IotDeviceDO> page = getDeviceService().getDevicePage(req);
|
||||
Set<Long> productIds = page.getList().stream().map(IotDeviceDO::getProductId).filter(java.util.Objects::nonNull).collect(Collectors.toSet());
|
||||
Map<Long, IotProductDO> productMap = productIds.isEmpty() ? Map.of() : getProductService().getProductMap(productIds);
|
||||
List<Map<String, Object>> list = page.getList().stream()
|
||||
.map(d -> deviceToMap(d, productMap.get(d.getProductId()) != null ? productMap.get(d.getProductId()).getName() : null))
|
||||
.collect(Collectors.toList());
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("total", page.getTotal());
|
||||
result.put("list", list);
|
||||
return JsonUtils.toJsonString(result);
|
||||
});
|
||||
}
|
||||
|
||||
@Tool(name = "iot_get_device", description = "查询单个设备基本信息。入参:deviceId(Long)或 productKey+deviceName。返回设备元信息(id、deviceName、nickname、productId、productName、state 等,不含最新属性)。")
|
||||
public String getDevice(Long deviceId, String productKey, String deviceName) {
|
||||
return TenantUtils.executeIgnore((Callable<String>) () -> {
|
||||
Long resolvedId = resolveDeviceId(deviceId, productKey, deviceName);
|
||||
if (resolvedId == null) {
|
||||
return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "请提供 deviceId 或 productKey+deviceName,并确保设备存在"));
|
||||
}
|
||||
IotDeviceDO device = getDeviceService().getDevice(resolvedId);
|
||||
if (device == null) {
|
||||
return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "设备不存在"));
|
||||
}
|
||||
IotProductDO product = device.getProductId() != null ? getProductService().getProduct(device.getProductId()) : null;
|
||||
return JsonUtils.toJsonString(deviceToMap(device, product != null ? product.getName() : null));
|
||||
});
|
||||
}
|
||||
|
||||
@Tool(name = "iot_get_device_status", description = "查询设备当前状态和关键属性。入参:仅传 deviceName(设备名称)即可;若存在多个同名设备则返回候选列表,需再传 productKey 或产品名称指定产品。也可传 deviceId,或 productKey+deviceName。返回设备名称、产品名、在线状态、最近属性快照、latestPropertyTime(属性最新上报时间,ISO);多设备时返回 multiple_choices 与 candidates 供选择。")
|
||||
public String getDeviceStatus(Long deviceId, String productKey, String deviceName) {
|
||||
// MCP 工具可能经 SSE 调用,无独立 HTTP 请求,租户上下文常为空;用 executeIgnore 保证可执行,租户由 api-keys[].tenant-id 在请求经 /sse、/mcp/* 时注入
|
||||
return TenantUtils.executeIgnore((Callable<String>) () -> doGetDeviceStatus(deviceId, productKey, deviceName));
|
||||
}
|
||||
|
||||
private String doGetDeviceStatus(Long deviceId, String productKey, String deviceName) {
|
||||
Long resolvedId;
|
||||
// 仅传设备名称:先按名称查,0 条未找到,1 条直接返回,多条返回候选让用户指定产品
|
||||
if ((deviceId == null) && !StringUtils.hasText(productKey) && StringUtils.hasText(deviceName)) {
|
||||
String dn = deviceName.trim();
|
||||
List<IotDeviceDO> byName = getDeviceService().getDeviceListByDeviceName(dn);
|
||||
if (byName == null || byName.isEmpty()) {
|
||||
return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "未找到设备名称 \"" + dn + "\""));
|
||||
}
|
||||
if (byName.size() == 1) {
|
||||
resolvedId = byName.get(0).getId();
|
||||
} else {
|
||||
List<Map<String, Object>> candidates = new ArrayList<>();
|
||||
for (IotDeviceDO d : byName) {
|
||||
IotProductDO p = d.getProductId() != null ? getProductService().getProduct(d.getProductId()) : null;
|
||||
Map<String, Object> c = new LinkedHashMap<>();
|
||||
c.put("productKey", d.getProductKey());
|
||||
c.put("productName", p != null ? p.getName() : null);
|
||||
c.put("deviceName", d.getDeviceName());
|
||||
c.put("deviceId", d.getId());
|
||||
candidates.add(c);
|
||||
}
|
||||
return JsonUtils.toJsonString(Map.of(
|
||||
"multiple_choices", true,
|
||||
"candidates", candidates,
|
||||
"hint", "找到 " + candidates.size() + " 个同名设备,请指定 productKey 或产品名称后重试"));
|
||||
}
|
||||
} else {
|
||||
resolvedId = resolveDeviceId(deviceId, productKey, deviceName);
|
||||
if (resolvedId == null) {
|
||||
return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "请提供 deviceId,或 productKey/产品名称+deviceName,并确保设备存在"));
|
||||
}
|
||||
}
|
||||
IotDeviceDO device = getDeviceService().getDevice(resolvedId);
|
||||
if (device == null) {
|
||||
return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "设备不存在"));
|
||||
}
|
||||
IotProductDO product = device.getProductId() != null ? getProductService().getProduct(device.getProductId()) : null;
|
||||
Map<String, IotDevicePropertyDO> latest = getDevicePropertyService().getLatestDeviceProperties(resolvedId);
|
||||
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("deviceId", resolvedId);
|
||||
result.put("deviceName", device.getDeviceName());
|
||||
result.put("nickname", device.getNickname());
|
||||
result.put("productId", device.getProductId());
|
||||
result.put("productName", product != null ? product.getName() : null);
|
||||
result.put("state", device.getState());
|
||||
result.put("stateName", device.getState() != null ? (IotDeviceStateEnum.ONLINE.getState().equals(device.getState()) ? "在线" : IotDeviceStateEnum.OFFLINE.getState().equals(device.getState()) ? "离线" : "未激活") : null);
|
||||
result.put("online", IotDeviceStateEnum.ONLINE.getState().equals(device.getState()));
|
||||
result.put("onlineTime", device.getOnlineTime());
|
||||
result.put("offlineTime", device.getOfflineTime());
|
||||
|
||||
Map<String, Object> properties = new LinkedHashMap<>();
|
||||
LocalDateTime latestPropertyTime = null;
|
||||
if (latest != null) {
|
||||
for (Map.Entry<String, IotDevicePropertyDO> e : latest.entrySet()) {
|
||||
IotDevicePropertyDO prop = e.getValue();
|
||||
Map<String, Object> p = new HashMap<>();
|
||||
LocalDateTime ut = prop.getUpdateTime();
|
||||
p.put("value", prop.getValue());
|
||||
p.put("updateTime", ut != null ? ut.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) : null);
|
||||
properties.put(e.getKey(), p);
|
||||
if (ut != null && (latestPropertyTime == null || ut.isAfter(latestPropertyTime))) {
|
||||
latestPropertyTime = ut;
|
||||
}
|
||||
}
|
||||
}
|
||||
result.put("properties", properties);
|
||||
result.put("latestPropertyTime", latestPropertyTime != null ? latestPropertyTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME) : null);
|
||||
return JsonUtils.toJsonString(result);
|
||||
}
|
||||
|
||||
@Tool(name = "iot_get_device_property_history", description = "查询设备属性历史数据。入参:deviceId(Long)或 productKey+deviceName;identifier(属性标识符);startTime、endTime(ISO8601,可选)。返回时间序列数据列表。")
|
||||
public String getDevicePropertyHistory(Long deviceId, String productKey, String deviceName, String identifier, String startTime, String endTime) {
|
||||
return TenantUtils.executeIgnore((Callable<String>) () -> {
|
||||
Long resolvedId = resolveDeviceId(deviceId, productKey, deviceName);
|
||||
if (resolvedId == null) {
|
||||
return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "请提供 deviceId 或 productKey+deviceName,并确保设备存在"));
|
||||
}
|
||||
IotDeviceDO device = getDeviceService().getDevice(resolvedId);
|
||||
if (device == null) {
|
||||
return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "设备不存在"));
|
||||
}
|
||||
// 1. 解析时间参数,失败时返回统一错误 JSON
|
||||
LocalDateTime start;
|
||||
LocalDateTime end;
|
||||
try {
|
||||
start = StrUtil.isNotBlank(startTime) ? LocalDateTime.parse(startTime.replace("Z", "")) : LocalDateTime.now().minusHours(24);
|
||||
end = StrUtil.isNotBlank(endTime) ? LocalDateTime.parse(endTime.replace("Z", "")) : LocalDateTime.now();
|
||||
} catch (DateTimeParseException e) {
|
||||
return JsonUtils.toJsonString(Map.of(
|
||||
"error", "invalid_time_format",
|
||||
"hint", "startTime/endTime 请使用 ISO8601 格式,如 2025-01-01T00:00:00"));
|
||||
}
|
||||
cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyHistoryListReqVO req = new cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyHistoryListReqVO();
|
||||
req.setDeviceId(resolvedId);
|
||||
req.setIdentifier(identifier);
|
||||
req.setTimes(new LocalDateTime[]{start, end});
|
||||
return JsonUtils.toJsonString(getDevicePropertyService().getHistoryDevicePropertyList(req));
|
||||
});
|
||||
}
|
||||
|
||||
@Tool(name = "iot_send_device_message", description = "向设备下发控制指令(属性设置或服务调用)。入参:deviceId(Long)或 productKey+deviceName;type(property_set 或 service_invoke);identifier;params(JSON 对象字符串)。受配置开关控制,默认关闭。")
|
||||
public String sendDeviceMessage(Long deviceId, String productKey, String deviceName, String type, String identifier, String params) {
|
||||
return TenantUtils.executeIgnore((Callable<String>) () -> {
|
||||
if (!Boolean.TRUE.equals(enableControlTools)) {
|
||||
return JsonUtils.toJsonString(Map.of("accepted", false, "message", "控制类工具未开放,请检查 yudao.iot.mcp.enable-control-tools 配置"));
|
||||
}
|
||||
Long resolvedId = resolveDeviceId(deviceId, productKey, deviceName);
|
||||
if (resolvedId == null) {
|
||||
return JsonUtils.toJsonString(Map.of("accepted", false, "error", "device_not_found", "hint", "请提供 deviceId 或 productKey+deviceName,并确保设备存在"));
|
||||
}
|
||||
IotDeviceDO device = getDeviceService().getDevice(resolvedId);
|
||||
if (device == null) {
|
||||
return JsonUtils.toJsonString(Map.of("accepted", false, "error", "device_not_found", "hint", "设备不存在"));
|
||||
}
|
||||
// TODO @AI: 统一 MCP 工具错误码与输入校验规范(如 identifier 非空、params 结构校验)
|
||||
Object paramsObj = null;
|
||||
if (StrUtil.isNotBlank(params)) {
|
||||
try {
|
||||
paramsObj = JsonUtils.parseObject(params, Map.class);
|
||||
} catch (Exception e) {
|
||||
return JsonUtils.toJsonString(Map.of("accepted", false, "error", "invalid_params_json", "hint", "params 须为合法 JSON 对象"));
|
||||
}
|
||||
}
|
||||
String method;
|
||||
if ("property_set".equalsIgnoreCase(type)) {
|
||||
method = IotDeviceMessageMethodEnum.PROPERTY_SET.getMethod();
|
||||
paramsObj = paramsObj != null ? Map.of("properties", paramsObj) : Map.of();
|
||||
} else if ("service_invoke".equalsIgnoreCase(type)) {
|
||||
method = IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod();
|
||||
if (StrUtil.isBlank(identifier)) {
|
||||
return JsonUtils.toJsonString(Map.of("accepted", false, "error", "invalid_identifier", "hint", "service_invoke 时 identifier 不能为空"));
|
||||
}
|
||||
paramsObj = paramsObj != null ? Map.of("identifier", identifier, "params", paramsObj) : Map.of("identifier", identifier, "params", Map.of());
|
||||
} else {
|
||||
return JsonUtils.toJsonString(Map.of("accepted", false, "message", "type 仅支持 property_set 或 service_invoke"));
|
||||
}
|
||||
IotDeviceMessage message = IotDeviceMessage.requestOf(method, paramsObj);
|
||||
message.setDeviceId(resolvedId);
|
||||
try {
|
||||
IotDeviceMessage sent = getDeviceMessageService().sendDeviceMessage(message);
|
||||
log.info("[sendDeviceMessage][mcp 下发指令成功] source=mcp, deviceId={}, type={}, identifier={}", resolvedId, type, identifier);
|
||||
return JsonUtils.toJsonString(Map.of("accepted", true, "messageId", sent.getId() != null ? sent.getId() : ""));
|
||||
} catch (Exception e) {
|
||||
return JsonUtils.toJsonString(Map.of("accepted", false, "message", e.getMessage()));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
package cn.iocoder.yudao.module.iot.mcp;
|
||||
|
||||
import org.springframework.ai.support.ToolCallbacks;
|
||||
import org.springframework.ai.tool.ToolCallback;
|
||||
import org.springframework.beans.BeansException;
|
||||
import org.springframework.beans.factory.config.BeanDefinition;
|
||||
import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
|
||||
import org.springframework.beans.factory.support.BeanDefinitionRegistry;
|
||||
import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor;
|
||||
import org.springframework.beans.factory.support.RootBeanDefinition;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
// TODO @AI: 现在和 别的 Tool 在同一级目录,该怎么设计下?是不是不合适
|
||||
/**
|
||||
* IoT MCP 工具自动配置,注册 ToolCallback Bean 供 MCP Server 自动发现。
|
||||
* MCP Server 通过 getBeansOfType(ToolCallback.class) 发现工具,因此将每个 ToolCallback 注册为独立 Bean。
|
||||
* <p>
|
||||
* 当前工具类清单:IotDeviceMcpTool、IotAlertMcpTool、IotThingModelMcpTool、IotProductMcpTool。
|
||||
* 新增工具类时,须在 {@link #iotToolCallbacks} 方法入参中注入,并在 Stream.of 中追加 ToolCallbacks.from(xxxTool)。
|
||||
* </p>
|
||||
*/
|
||||
@Configuration
|
||||
public class IotMcpAutoConfiguration {
|
||||
|
||||
@Bean("iotToolCallbacks")
|
||||
public ToolCallback[] iotToolCallbacks(
|
||||
IotDeviceMcpTool deviceTool,
|
||||
IotAlertMcpTool alertTool,
|
||||
IotThingModelMcpTool thingModelTool,
|
||||
IotProductMcpTool productTool) {
|
||||
return Stream.of(
|
||||
ToolCallbacks.from(deviceTool),
|
||||
ToolCallbacks.from(alertTool),
|
||||
ToolCallbacks.from(thingModelTool),
|
||||
ToolCallbacks.from(productTool)
|
||||
).flatMap(Arrays::stream).toArray(ToolCallback[]::new);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 iotToolCallbacks 数组中的每个 ToolCallback 注册为独立 Bean,使 MCP Server 能通过 getBeansOfType(ToolCallback.class) 发现。
|
||||
*/
|
||||
@Bean
|
||||
public static BeanDefinitionRegistryPostProcessor iotToolCallbackRegistryPostProcessor() {
|
||||
return new BeanDefinitionRegistryPostProcessor() {
|
||||
@Override
|
||||
public void postProcessBeanDefinitionRegistry(BeanDefinitionRegistry registry) throws BeansException {
|
||||
// 不做处理,在 postProcessBeanFactory 时再注册(此时 iotToolCallbacks 已创建)
|
||||
}
|
||||
|
||||
@Override
|
||||
public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
|
||||
if (!(beanFactory instanceof BeanDefinitionRegistry registry)) {
|
||||
return;
|
||||
}
|
||||
String[] arrayBeanNames = beanFactory.getBeanNamesForType(ToolCallback[].class);
|
||||
for (String arrayBeanName : arrayBeanNames) {
|
||||
if (!"iotToolCallbacks".equals(arrayBeanName)) {
|
||||
continue;
|
||||
}
|
||||
ToolCallback[] callbacks = (ToolCallback[]) beanFactory.getBean(arrayBeanName);
|
||||
for (ToolCallback cb : callbacks) {
|
||||
final ToolCallback callback = cb;
|
||||
String toolName = callback.getToolDefinition().name();
|
||||
// TODO @AI: 多模块时 toolName 可能冲突,建议统一命名空间前缀(如 iot_);评估是否用 @Bean 逐个声明替代 PostProcessor 注册
|
||||
String beanName = "iotToolCallback_" + toolName;
|
||||
if (registry.containsBeanDefinition(beanName)) {
|
||||
continue;
|
||||
}
|
||||
RootBeanDefinition def = new RootBeanDefinition(ToolCallback.class, () -> callback);
|
||||
def.setScope(BeanDefinition.SCOPE_SINGLETON);
|
||||
registry.registerBeanDefinition(beanName, def);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
package cn.iocoder.yudao.module.iot.mcp;
|
||||
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.pojo.PageResult;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
|
||||
import cn.iocoder.yudao.module.iot.controller.admin.product.vo.product.IotProductPageReqVO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
|
||||
import cn.iocoder.yudao.module.iot.service.product.IotProductService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* IoT 产品信息 MCP 工具:产品列表、产品详情
|
||||
*/
|
||||
@Component
|
||||
public class IotProductMcpTool {
|
||||
|
||||
@Resource
|
||||
private IotProductService productService;
|
||||
|
||||
// TODO @AI: 是否可以考虑使用 Spring 的 @Autowired 注解来注入?为什么不能使用@Resource?
|
||||
private IotProductService getProductService() {
|
||||
if (productService != null) return productService;
|
||||
return SpringUtil.getBean(IotProductService.class);
|
||||
}
|
||||
|
||||
private Map<String, Object> productToMap(IotProductDO p) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", p.getId());
|
||||
m.put("name", p.getName());
|
||||
m.put("productKey", p.getProductKey());
|
||||
m.put("deviceType", p.getDeviceType());
|
||||
m.put("status", p.getStatus());
|
||||
m.put("description", p.getDescription());
|
||||
m.put("categoryId", p.getCategoryId());
|
||||
m.put("registerEnabled", p.getRegisterEnabled());
|
||||
m.put("createTime", p.getCreateTime());
|
||||
return m;
|
||||
}
|
||||
|
||||
// TODO @AI: 分页默认值(pageNo/pageSize)与 IotDeviceMcpTool、IotAlertMcpTool 重复,可抽公共方法或基类
|
||||
@Tool(name = "iot_get_product_list", description = "分页查询产品列表。入参:pageNo、pageSize(可选);name、productKey(可选,模糊)。返回 total 与 list(id、name、productKey、deviceType、status、createTime 等)。")
|
||||
public String getProductList(Integer pageNo, Integer pageSize, String name, String productKey) {
|
||||
return TenantUtils.executeIgnore((Callable<String>) () -> {
|
||||
IotProductPageReqVO req = new IotProductPageReqVO();
|
||||
req.setPageNo(pageNo != null && pageNo > 0 ? pageNo : 1);
|
||||
req.setPageSize(pageSize != null && pageSize > 0 && pageSize <= 100 ? pageSize : 10);
|
||||
if (StringUtils.hasText(name)) req.setName(name.trim());
|
||||
if (StringUtils.hasText(productKey)) req.setProductKey(productKey.trim());
|
||||
PageResult<IotProductDO> page = getProductService().getProductPage(req);
|
||||
List<Map<String, Object>> list = page.getList().stream().map(this::productToMap).collect(Collectors.toList());
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("total", page.getTotal());
|
||||
result.put("list", list);
|
||||
return JsonUtils.toJsonString(result);
|
||||
});
|
||||
}
|
||||
|
||||
@Tool(name = "iot_get_product", description = "查询单个产品详情。入参:productId(Long)或 productKey(String)二选一。返回产品元信息(id、name、productKey、deviceType、status 等,不含密钥)。")
|
||||
public String getProduct(Long productId, String productKey) {
|
||||
return TenantUtils.executeIgnore((Callable<String>) () -> {
|
||||
IotProductDO p = null;
|
||||
if (productId != null) {
|
||||
p = getProductService().getProduct(productId);
|
||||
} else if (StringUtils.hasText(productKey)) {
|
||||
p = getProductService().getProductByProductKey(productKey.trim());
|
||||
}
|
||||
if (p == null) {
|
||||
return JsonUtils.toJsonString(Map.of("error", "product_not_found", "hint", "请提供 productId 或 productKey,并确保产品存在"));
|
||||
}
|
||||
return JsonUtils.toJsonString(productToMap(p));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
package cn.iocoder.yudao.module.iot.mcp;
|
||||
|
||||
import cn.hutool.extra.spring.SpringUtil;
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.product.IotProductDO;
|
||||
import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO;
|
||||
import cn.iocoder.yudao.module.iot.enums.thingmodel.IotThingModelTypeEnum;
|
||||
import cn.iocoder.yudao.module.iot.service.product.IotProductService;
|
||||
import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.ai.tool.annotation.Tool;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.Callable;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* IoT 物模型 MCP 工具
|
||||
* TODO @AI: 与 IotProductMcpTool、IotDeviceMcpTool 等共用「getXxxService + SpringUtil 兜底」模式,可抽公共基类统一依赖获取与 error JSON 格式。
|
||||
*/
|
||||
@Component
|
||||
public class IotThingModelMcpTool {
|
||||
|
||||
@Resource
|
||||
private IotThingModelService thingModelService;
|
||||
@Resource
|
||||
private IotProductService productService;
|
||||
|
||||
// TODO @AI: 是否可以考虑使用 Spring 的 @Autowired 注解来注入?为什么不能使用@Resource?
|
||||
private IotProductService getProductService() {
|
||||
if (productService != null) return productService;
|
||||
return SpringUtil.getBean(IotProductService.class);
|
||||
}
|
||||
|
||||
private IotThingModelService getThingModelService() {
|
||||
if (thingModelService != null) return thingModelService;
|
||||
return SpringUtil.getBean(IotThingModelService.class);
|
||||
}
|
||||
|
||||
@Tool(name = "iot_get_thing_model", description = "查询产品的物模型定义(属性、服务、事件)。入参:productId(Long)、productKey(String)、productName(String)三选一,方便按名称或 Key 查询。返回属性/服务/事件列表,包含标识符、名称、类型、访问模式等。")
|
||||
public String getThingModel(Long productId, String productKey, String productName) {
|
||||
return TenantUtils.executeIgnore((Callable<String>) () -> {
|
||||
ProductResolveResult resolved = resolveProduct(productId, productKey, productName);
|
||||
if (resolved.errorJson != null) {
|
||||
return JsonUtils.toJsonString(resolved.errorJson);
|
||||
}
|
||||
if (resolved.product == null) {
|
||||
return JsonUtils.toJsonString(Map.of("error", "product_required", "hint", "请提供 productId、productKey 或 productName 其中之一"));
|
||||
}
|
||||
IotProductDO product = resolved.product;
|
||||
Long resolvedProductId = product.getId();
|
||||
List<IotThingModelDO> list = getThingModelService().getThingModelListByProductId(resolvedProductId);
|
||||
List<Map<String, Object>> items = list.stream().map(this::toMap).collect(Collectors.toList());
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("productId", resolvedProductId);
|
||||
result.put("productKey", product.getProductKey());
|
||||
result.put("productName", product.getName());
|
||||
result.put("items", items);
|
||||
return JsonUtils.toJsonString(result);
|
||||
});
|
||||
}
|
||||
|
||||
private static class ProductResolveResult {
|
||||
IotProductDO product;
|
||||
Map<String, Object> errorJson;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析产品:productId → productKey → productName。返回 null product 表示未传任何有效参数;errorJson 非空时表示未找到或多条,调用方直接返回该 JSON。
|
||||
*/
|
||||
private ProductResolveResult resolveProduct(Long productId, String productKey, String productName) {
|
||||
ProductResolveResult out = new ProductResolveResult();
|
||||
if (productId != null) {
|
||||
IotProductDO p = getProductService().getProduct(productId);
|
||||
if (p == null) {
|
||||
out.errorJson = Map.of("error", "product_not_found", "productId", productId, "hint", "未找到该产品");
|
||||
return out;
|
||||
}
|
||||
out.product = p;
|
||||
return out;
|
||||
}
|
||||
if (StringUtils.hasText(productKey)) {
|
||||
IotProductDO p = getProductService().getProductByProductKey(productKey.trim());
|
||||
if (p == null) {
|
||||
out.errorJson = Map.of("error", "product_not_found", "productKey", productKey.trim(), "hint", "未找到该产品");
|
||||
return out;
|
||||
}
|
||||
out.product = p;
|
||||
return out;
|
||||
}
|
||||
if (StringUtils.hasText(productName)) {
|
||||
List<IotProductDO> list = getProductService().getProductListByName(productName.trim());
|
||||
if (list == null || list.isEmpty()) {
|
||||
out.errorJson = Map.of("error", "product_not_found", "productName", productName.trim(), "hint", "未找到该名称的产品");
|
||||
return out;
|
||||
}
|
||||
if (list.size() > 1) {
|
||||
List<String> keys = list.stream().map(IotProductDO::getProductKey).collect(Collectors.toList());
|
||||
out.errorJson = new LinkedHashMap<>();
|
||||
out.errorJson.put("error", "multiple_products");
|
||||
out.errorJson.put("productName", productName.trim());
|
||||
out.errorJson.put("productKeys", keys);
|
||||
out.errorJson.put("hint", "存在多个同名产品,请使用 productKey 或 productId 指定");
|
||||
return out;
|
||||
}
|
||||
out.product = list.get(0);
|
||||
return out;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
private Map<String, Object> toMap(IotThingModelDO m) {
|
||||
Map<String, Object> map = new LinkedHashMap<>();
|
||||
map.put("id", m.getId());
|
||||
map.put("identifier", m.getIdentifier());
|
||||
map.put("name", m.getName());
|
||||
map.put("description", m.getDescription());
|
||||
map.put("type", m.getType());
|
||||
map.put("typeName", m.getType() != null ? (IotThingModelTypeEnum.PROPERTY.getType().equals(m.getType()) ? "属性" : IotThingModelTypeEnum.SERVICE.getType().equals(m.getType()) ? "服务" : IotThingModelTypeEnum.EVENT.getType().equals(m.getType()) ? "事件" : "未知") : null);
|
||||
if (m.getProperty() != null) {
|
||||
map.put("property", m.getProperty());
|
||||
}
|
||||
if (m.getService() != null) {
|
||||
map.put("service", m.getService());
|
||||
}
|
||||
if (m.getEvent() != null) {
|
||||
map.put("event", m.getEvent());
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,115 @@
|
||||
package cn.iocoder.yudao.module.iot.mcp.security;
|
||||
|
||||
import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
|
||||
import cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils;
|
||||
import jakarta.servlet.FilterChain;
|
||||
import jakarta.servlet.ServletException;
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.filter.OncePerRequestFilter;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.security.MessageDigest;
|
||||
import java.util.Collections;
|
||||
import java.util.Enumeration;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* MCP SSE 端点 API Key 认证过滤器。
|
||||
* 校验 X-API-Key 请求头,通过后注入 tenant-id 头供 {@link cn.iocoder.yudao.framework.tenant.core.web.TenantContextWebFilter} 设置租户上下文。
|
||||
*/
|
||||
@Slf4j
|
||||
@RequiredArgsConstructor
|
||||
public class McpApiKeyAuthenticationFilter extends OncePerRequestFilter {
|
||||
|
||||
private final McpSecurityProperties properties;
|
||||
|
||||
@Override
|
||||
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||
throws ServletException, IOException {
|
||||
if (!properties.isEnabled()) {
|
||||
filterChain.doFilter(request, response);
|
||||
return;
|
||||
}
|
||||
// TODO @AI: 配置启用但 apiKeys 为空时 fail-closed,避免裸奔;后续可考虑审计日志、rate limit、重放防护等
|
||||
if (properties.getApiKeys() == null || properties.getApiKeys().isEmpty()) {
|
||||
sendUnauthorized(response, "MCP API Key not configured");
|
||||
return;
|
||||
}
|
||||
|
||||
String requestKey = request.getHeader(properties.getApiKeyHeader());
|
||||
if (!StringUtils.hasText(requestKey)) {
|
||||
sendUnauthorized(response, "Missing MCP API Key");
|
||||
return;
|
||||
}
|
||||
|
||||
McpSecurityProperties.ApiKeyItem matched = findMatchingApiKey(requestKey.trim());
|
||||
if (matched == null) {
|
||||
sendUnauthorized(response, "Invalid MCP API Key");
|
||||
return;
|
||||
}
|
||||
|
||||
if (log.isDebugEnabled()) {
|
||||
log.debug("[MCP] API Key authenticated, client={}, tenantId={}", matched.getName(), matched.getTenantId());
|
||||
}
|
||||
|
||||
HttpServletRequest wrappedRequest = wrapRequestWithTenantId(request, matched.getTenantId());
|
||||
filterChain.doFilter(wrappedRequest, response);
|
||||
}
|
||||
|
||||
private McpSecurityProperties.ApiKeyItem findMatchingApiKey(String requestKey) {
|
||||
byte[] requestKeyBytes = requestKey.getBytes(StandardCharsets.UTF_8);
|
||||
for (McpSecurityProperties.ApiKeyItem item : properties.getApiKeys()) {
|
||||
if (item == null || !StringUtils.hasText(item.getKey())) {
|
||||
continue;
|
||||
}
|
||||
if (constantTimeEquals(requestKeyBytes, item.getKey().getBytes(StandardCharsets.UTF_8))) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
private static boolean constantTimeEquals(byte[] a, byte[] b) {
|
||||
try {
|
||||
return MessageDigest.isEqual(a, b);
|
||||
} catch (Exception e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private HttpServletRequest wrapRequestWithTenantId(HttpServletRequest request, Long tenantId) {
|
||||
if (tenantId == null) {
|
||||
return request;
|
||||
}
|
||||
String tenantIdStr = String.valueOf(tenantId);
|
||||
return new HttpServletRequestWrapper(request) {
|
||||
@Override
|
||||
public String getHeader(String name) {
|
||||
if (WebFrameworkUtils.HEADER_TENANT_ID.equalsIgnoreCase(name)) {
|
||||
return tenantIdStr;
|
||||
}
|
||||
return super.getHeader(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Enumeration<String> getHeaders(String name) {
|
||||
if (WebFrameworkUtils.HEADER_TENANT_ID.equalsIgnoreCase(name)) {
|
||||
return Collections.enumeration(Collections.singletonList(tenantIdStr));
|
||||
}
|
||||
return super.getHeaders(name);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private void sendUnauthorized(HttpServletResponse response, String message) throws IOException {
|
||||
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
|
||||
response.setContentType("application/json;charset=UTF-8");
|
||||
response.getWriter().write(JsonUtils.toJsonString(Map.of("code", 401, "msg", message != null ? message : "")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package cn.iocoder.yudao.module.iot.mcp.security;
|
||||
|
||||
import org.springframework.boot.autoconfigure.AutoConfiguration;
|
||||
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||
import org.springframework.boot.web.servlet.FilterRegistrationBean;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.core.Ordered;
|
||||
|
||||
/**
|
||||
* MCP SSE 端点 API Key 认证自动配置。
|
||||
* 注册 McpApiKeyAuthenticationFilter,在 Spring Security 与 TenantContextWebFilter 之前执行。
|
||||
*/
|
||||
@AutoConfiguration
|
||||
@EnableConfigurationProperties(McpSecurityProperties.class)
|
||||
public class McpSecurityAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
public FilterRegistrationBean<McpApiKeyAuthenticationFilter> mcpApiKeyAuthenticationFilterRegistration(
|
||||
McpSecurityProperties properties) {
|
||||
FilterRegistrationBean<McpApiKeyAuthenticationFilter> registration = new FilterRegistrationBean<>();
|
||||
registration.setFilter(new McpApiKeyAuthenticationFilter(properties));
|
||||
registration.addUrlPatterns("/sse", "/sse/*", "/mcp/*");
|
||||
registration.setOrder(Ordered.HIGHEST_PRECEDENCE);
|
||||
registration.setName("mcpApiKeyAuthenticationFilter");
|
||||
return registration;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
package cn.iocoder.yudao.module.iot.mcp.security;
|
||||
|
||||
import lombok.Data;
|
||||
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* MCP SSE 端点 API Key 认证配置(含租户绑定)
|
||||
*/
|
||||
@ConfigurationProperties(prefix = "yudao.iot.mcp.security")
|
||||
@Data
|
||||
public class McpSecurityProperties {
|
||||
|
||||
/**
|
||||
* 是否启用 MCP API Key 校验
|
||||
*/
|
||||
private boolean enabled = true;
|
||||
|
||||
/**
|
||||
* API Key 请求头名称
|
||||
*/
|
||||
private String apiKeyHeader = "X-API-Key";
|
||||
|
||||
/**
|
||||
* 配置的 API Key 列表,每个可绑定租户
|
||||
*/
|
||||
private List<ApiKeyItem> apiKeys = Collections.emptyList();
|
||||
|
||||
@Data
|
||||
public static class ApiKeyItem {
|
||||
/**
|
||||
* 客户端名称,用于日志标识(如 "openclaw"、"cursor")
|
||||
*/
|
||||
private String name;
|
||||
/**
|
||||
* API Key 值
|
||||
*/
|
||||
private String key;
|
||||
// TODO @AI: 现在是过滤的租户,该怎么使用配置的租户?
|
||||
/**
|
||||
* 绑定的租户编号
|
||||
*/
|
||||
private Long tenantId;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package cn.iocoder.yudao.module.iot.mcp.security.config;
|
||||
|
||||
import cn.iocoder.yudao.framework.security.config.AuthorizeRequestsCustomizer;
|
||||
import jakarta.annotation.Resource;
|
||||
import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerSseProperties;
|
||||
import org.springframework.ai.mcp.server.common.autoconfigure.properties.McpServerStreamableHttpProperties;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* MCP 端点 Spring Security 放行配置,放行 SSE / Message 路径供外部 AI 客户端连接。
|
||||
* 鉴权由同包下的 {@link cn.iocoder.yudao.module.iot.mcp.security.McpApiKeyAuthenticationFilter} 在过滤链前完成。
|
||||
* TODO @AI: permitAll 与 Filter 放行组合:若 security.enabled=true 且 apiKeys 未配置,Filter 已 401;建议文档说明或增加条件只对已配置安全时放行,避免误配裸奔。
|
||||
*/
|
||||
@Configuration(proxyBeanMethods = false, value = "iotSecurityConfiguration")
|
||||
public class SecurityConfiguration {
|
||||
|
||||
@Resource
|
||||
private Optional<McpServerSseProperties> mcpServerSseProperties;
|
||||
@Resource
|
||||
private Optional<McpServerStreamableHttpProperties> mcpServerStreamableHttpProperties;
|
||||
|
||||
@Bean("iotAuthorizeRequestsCustomizer")
|
||||
public AuthorizeRequestsCustomizer authorizeRequestsCustomizer() {
|
||||
return new AuthorizeRequestsCustomizer() {
|
||||
|
||||
@Override
|
||||
public void customize(AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry registry) {
|
||||
mcpServerSseProperties.ifPresent(properties -> {
|
||||
registry.requestMatchers(properties.getSseEndpoint()).permitAll();
|
||||
registry.requestMatchers(properties.getSseMessageEndpoint()).permitAll();
|
||||
});
|
||||
mcpServerStreamableHttpProperties.ifPresent(properties ->
|
||||
registry.requestMatchers(properties.getMcpEndpoint()).permitAll());
|
||||
}
|
||||
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
@@ -127,6 +127,14 @@ public interface IotDeviceService {
|
||||
*/
|
||||
IotDeviceDO getDeviceFromCache(String productKey, String deviceName);
|
||||
|
||||
/**
|
||||
* 按设备名称查询所有匹配设备(跨产品,用于仅传设备名时若有多个则让用户选择产品)
|
||||
*
|
||||
* @param deviceName 设备名称
|
||||
* @return 设备列表,可能为空或多条
|
||||
*/
|
||||
List<IotDeviceDO> getDeviceListByDeviceName(String deviceName);
|
||||
|
||||
/**
|
||||
* 获得设备分页
|
||||
*
|
||||
|
||||
@@ -291,6 +291,11 @@ public class IotDeviceServiceImpl implements IotDeviceService {
|
||||
return deviceMapper.selectByProductKeyAndDeviceName(productKey, deviceName);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IotDeviceDO> getDeviceListByDeviceName(String deviceName) {
|
||||
return StrUtil.isBlank(deviceName) ? Collections.emptyList() : deviceMapper.selectListByDeviceName(deviceName.trim());
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<IotDeviceDO> getDevicePage(IotDevicePageReqVO pageReqVO) {
|
||||
return deviceMapper.selectPage(pageReqVO);
|
||||
|
||||
@@ -69,6 +69,14 @@ public interface IotProductService {
|
||||
*/
|
||||
IotProductDO getProductByProductKey(String productKey);
|
||||
|
||||
/**
|
||||
* 按产品名称精确查询(名称未唯一约束,可能返回多条)
|
||||
*
|
||||
* @param name 产品名称
|
||||
* @return 产品列表,0 条或多条
|
||||
*/
|
||||
List<IotProductDO> getProductListByName(String name);
|
||||
|
||||
/**
|
||||
* 校验产品存在
|
||||
*
|
||||
|
||||
@@ -137,6 +137,11 @@ public class IotProductServiceImpl implements IotProductService {
|
||||
return productMapper.selectByProductKey(productKey);
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<IotProductDO> getProductListByName(String name) {
|
||||
return productMapper.selectListByName(name);
|
||||
}
|
||||
|
||||
@Override
|
||||
public PageResult<IotProductDO> getProductPage(IotProductPageReqVO pageReqVO) {
|
||||
return productMapper.selectPage(pageReqVO);
|
||||
|
||||
@@ -196,12 +196,13 @@ spring:
|
||||
model: deepseek-chat
|
||||
model:
|
||||
rerank: false # 是否开启“通义千问”的 Rerank 模型,填写 dashscope 开启
|
||||
# 全应用唯一 MCP Server,各模块通过注册 ToolCallback 暴露工具;模块级开关见 yudao.<模块>.mcp
|
||||
mcp:
|
||||
server:
|
||||
enabled: false
|
||||
enabled: true
|
||||
name: yudao-mcp-server
|
||||
version: 1.0.0
|
||||
instructions: 一个 MCP 示例服务
|
||||
instructions: 若依 MCP 服务,提供各业务模块注册的工具(如 IoT 设备与告警、后续可扩展 CRM 等)
|
||||
sse-endpoint: /sse
|
||||
client:
|
||||
enabled: false
|
||||
@@ -349,5 +350,14 @@ yudao:
|
||||
iot:
|
||||
message-bus:
|
||||
type: redis # 消息总线的类型
|
||||
mcp:
|
||||
enable-control-tools: true # 生产建议 false,开启后允许 AI 下发设备指令
|
||||
security:
|
||||
enabled: true
|
||||
api-key-header: X-API-Key
|
||||
api-keys:
|
||||
- name: default
|
||||
key: ${MCP_API_KEY:484fcc23-adc1-449e-80ea-6256575075bb}
|
||||
tenant-id: 1 # 绑定到租户 1,生产可通过环境变量 MCP_API_KEY 注入密钥
|
||||
|
||||
debug: false
|
||||
Reference in New Issue
Block a user