feat(iot): 增强 MCP 配置和安全功能,包括API 密钥验证和控制工具日志

This commit is contained in:
haohao
2026-02-28 17:09:04 +08:00
parent 6a49fd40f8
commit 55964955af
10 changed files with 271 additions and 169 deletions

View File

@@ -12,6 +12,7 @@ import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
import org.springframework.beans.factory.support.BeanDefinitionRegistry; import org.springframework.beans.factory.support.BeanDefinitionRegistry;
import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor; import org.springframework.beans.factory.support.BeanDefinitionRegistryPostProcessor;
import org.springframework.beans.factory.support.RootBeanDefinition; import org.springframework.beans.factory.support.RootBeanDefinition;
import org.springframework.boot.ApplicationRunner;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Configuration;
@@ -33,6 +34,16 @@ public class IotMcpAutoConfiguration {
*/ */
private static final String TOOL_CALLBACK_BEAN_NAME_PREFIX = "iotToolCallback_"; private static final String TOOL_CALLBACK_BEAN_NAME_PREFIX = "iotToolCallback_";
@Bean
public ApplicationRunner iotMcpControlToolsStartupLogger(IotMcpProperties mcpProperties) {
return args -> {
if (mcpProperties.isEnableControlTools()) {
org.slf4j.LoggerFactory.getLogger(IotMcpAutoConfiguration.class)
.warn("[iotMcpControlToolsStartupLogger][控制类工具已开启,生产环境建议关闭] yudao.iot.mcp.enable-control-tools=true");
}
};
}
@Bean @Bean
public ToolCallback[] iotProductToolCallbacks(IotProductMcpTool productTool) { public ToolCallback[] iotProductToolCallbacks(IotProductMcpTool productTool) {
return ToolCallbacks.from(productTool); return ToolCallbacks.from(productTool);

View File

@@ -1,13 +1,20 @@
package cn.iocoder.yudao.module.iot.mcp.config; package cn.iocoder.yudao.module.iot.mcp.config;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import lombok.Data; import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import java.util.Collections;
import java.util.List;
/** /**
* IoT MCP 模块配置(如控制类工具开关等) * IoT MCP 模块配置控制类工具开关、API Key 认证(含租户绑定)等。
* TODO @AI: 在应用启动时打印控制类工具开关状态enableControlTools=true 时输出 WARN 提醒) * 安全配置位于 {@link #getSecurity()},对应 yaml 中 yudao.iot.mcp.security 节点。
*/ */
@ConfigurationProperties(prefix = "yudao.iot.mcp") @ConfigurationProperties(prefix = "yudao.iot.mcp")
@Validated
@Data @Data
public class IotMcpProperties { public class IotMcpProperties {
@@ -15,4 +22,43 @@ public class IotMcpProperties {
* 是否启用控制类 MCP 工具(如 iot_send_device_message默认 false生产建议关闭 * 是否启用控制类 MCP 工具(如 iot_send_device_message默认 false生产建议关闭
*/ */
private boolean enableControlTools = false; private boolean enableControlTools = false;
/**
* MCP API Key 认证配置enabled 为 true 且 apiKeys 为空时 Filter 将返回 401
*/
private Security security = new Security();
@Data
public static class Security {
/**
* 是否启用 MCP API Key 校验
*/
private boolean enabled = true;
/**
* API Key 请求头名称
*/
private String apiKeyHeader = "X-API-Key";
/**
* 配置的 API Key 列表,每个可绑定租户
*/
@Valid
private List<ApiKeyItem> apiKeys = Collections.emptyList();
}
@Data
public static class ApiKeyItem {
/**
* 客户端名称,用于日志标识(如 "openclaw"、"cursor"
*/
private String name;
/**
* API Key 值,不能为空
*/
@NotBlank(message = "MCP API Key 的 key 不能为空")
private String key;
/**
* 绑定的租户编号,认证通过后由 Filter 注入为请求头 tenant-id
*/
private Long tenantId;
}
} }

View File

@@ -2,6 +2,9 @@ package cn.iocoder.yudao.module.iot.mcp.security;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils; import cn.iocoder.yudao.framework.web.core.util.WebFrameworkUtils;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.iot.mcp.config.IotMcpProperties;
import jakarta.servlet.FilterChain; import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException; import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletRequest;
@@ -9,7 +12,6 @@ import jakarta.servlet.http.HttpServletRequestWrapper;
import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter; import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException; import java.io.IOException;
@@ -27,49 +29,51 @@ import java.util.Map;
@RequiredArgsConstructor @RequiredArgsConstructor
public class McpApiKeyAuthenticationFilter extends OncePerRequestFilter { public class McpApiKeyAuthenticationFilter extends OncePerRequestFilter {
private final McpSecurityProperties properties; private final IotMcpProperties properties;
@Override @Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException { throws ServletException, IOException {
if (!properties.isEnabled()) { IotMcpProperties.Security security = properties.getSecurity();
if (security == null || !security.isEnabled()) {
filterChain.doFilter(request, response); filterChain.doFilter(request, response);
return; return;
} }
// enabled 且 apiKeys 未配置时 fail-closed避免裸奔后续可考虑限流与重放防护 String clientIp = request.getRemoteAddr();
if (properties.getApiKeys() == null || properties.getApiKeys().isEmpty()) { String userAgent = request.getHeader("User-Agent");
log.warn("[MCP][审计] API Key 未配置,拒绝请求"); // 1. enabled 且 apiKeys 未配置时 fail-closed避免裸奔后续可考虑限流与重放防护
if (CollUtil.isEmpty(security.getApiKeys())) {
log.warn("[doFilterInternal][API Key 未配置,拒绝请求] ip={}, userAgent={}", clientIp, userAgent);
sendUnauthorized(response, "MCP API Key not configured"); sendUnauthorized(response, "MCP API Key not configured");
return; return;
} }
String requestKey = request.getHeader(properties.getApiKeyHeader()); String requestKey = request.getHeader(security.getApiKeyHeader());
if (!StringUtils.hasText(requestKey)) { if (StrUtil.isEmpty(requestKey)) {
log.warn("[MCP][审计] 缺少 API Key 请求头"); log.warn("[doFilterInternal][缺少 API Key 请求头] ip={}, userAgent={}", clientIp, userAgent);
sendUnauthorized(response, "Missing MCP API Key"); sendUnauthorized(response, "Missing MCP API Key");
return; return;
} }
McpSecurityProperties.ApiKeyItem matched = findMatchingApiKey(requestKey.trim()); IotMcpProperties.ApiKeyItem matched = findMatchingApiKey(security, requestKey.trim());
if (matched == null) { if (matched == null) {
log.warn("[MCP][审计] API Key 校验失败clientKey 前缀: {}", requestKey.length() > 4 ? requestKey.substring(0, 4) + "***" : "***"); log.warn("[doFilterInternal][API Key 校验失败] ip={}, userAgent={}, clientKeyPrefix={}", clientIp, userAgent, requestKey.length() > 4 ? requestKey.substring(0, 4) + "***" : "***");
sendUnauthorized(response, "Invalid MCP API Key"); sendUnauthorized(response, "Invalid MCP API Key");
return; return;
} }
if (log.isDebugEnabled()) { if (log.isDebugEnabled()) {
log.debug("[MCP] API Key authenticated, client={}, tenantId={}", matched.getName(), matched.getTenantId()); log.debug("[doFilterInternal][API Key 认证成功] client={}, tenantId={}", matched.getName(), matched.getTenantId());
} }
HttpServletRequest wrappedRequest = wrapRequestWithTenantId(request, matched.getTenantId()); HttpServletRequest wrappedRequest = wrapRequestWithTenantId(request, matched.getTenantId());
filterChain.doFilter(wrappedRequest, response); filterChain.doFilter(wrappedRequest, response);
} }
// TODO @AI: 增加详细审计日志IP、User-Agent、认证结果并评估使用 HashMap 缓存减少遍历时间差异 private IotMcpProperties.ApiKeyItem findMatchingApiKey(IotMcpProperties.Security security, String requestKey) {
private McpSecurityProperties.ApiKeyItem findMatchingApiKey(String requestKey) {
byte[] requestKeyBytes = requestKey.getBytes(StandardCharsets.UTF_8); byte[] requestKeyBytes = requestKey.getBytes(StandardCharsets.UTF_8);
for (McpSecurityProperties.ApiKeyItem item : properties.getApiKeys()) { for (IotMcpProperties.ApiKeyItem item : security.getApiKeys()) {
if (item == null || !StringUtils.hasText(item.getKey())) { if (item == null || StrUtil.isEmpty(item.getKey())) {
continue; continue;
} }
if (constantTimeEquals(requestKeyBytes, item.getKey().getBytes(StandardCharsets.UTF_8))) { if (constantTimeEquals(requestKeyBytes, item.getKey().getBytes(StandardCharsets.UTF_8))) {

View File

@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.iot.mcp.security; package cn.iocoder.yudao.module.iot.mcp.security;
import cn.iocoder.yudao.module.iot.mcp.config.IotMcpProperties;
import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.boot.autoconfigure.AutoConfiguration;
import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.boot.web.servlet.FilterRegistrationBean; import org.springframework.boot.web.servlet.FilterRegistrationBean;
@@ -11,12 +12,12 @@ import org.springframework.core.Ordered;
* 注册 McpApiKeyAuthenticationFilter在 Spring Security 与 TenantContextWebFilter 之前执行。 * 注册 McpApiKeyAuthenticationFilter在 Spring Security 与 TenantContextWebFilter 之前执行。
*/ */
@AutoConfiguration @AutoConfiguration
@EnableConfigurationProperties(McpSecurityProperties.class) @EnableConfigurationProperties(IotMcpProperties.class)
public class McpSecurityAutoConfiguration { public class McpSecurityAutoConfiguration {
@Bean @Bean
public FilterRegistrationBean<McpApiKeyAuthenticationFilter> mcpApiKeyAuthenticationFilterRegistration( public FilterRegistrationBean<McpApiKeyAuthenticationFilter> mcpApiKeyAuthenticationFilterRegistration(
McpSecurityProperties properties) { IotMcpProperties properties) {
FilterRegistrationBean<McpApiKeyAuthenticationFilter> registration = new FilterRegistrationBean<>(); FilterRegistrationBean<McpApiKeyAuthenticationFilter> registration = new FilterRegistrationBean<>();
registration.setFilter(new McpApiKeyAuthenticationFilter(properties)); registration.setFilter(new McpApiKeyAuthenticationFilter(properties));
registration.addUrlPatterns("/sse", "/sse/*", "/mcp/*"); registration.addUrlPatterns("/sse", "/sse/*", "/mcp/*");

View File

@@ -1,53 +0,0 @@
package cn.iocoder.yudao.module.iot.mcp.security;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import java.util.Collections;
import java.util.List;
/**
* MCP SSE 端点 API Key 认证配置(含租户绑定)。
* enabled 为 true 时若 apiKeys 为空Filter 会直接返回 401配置项使用 @Valid 校验每个 ApiKeyItem 的 key 非空。
*/
@ConfigurationProperties(prefix = "yudao.iot.mcp.security")
@Validated
@Data
public class McpSecurityProperties {
/**
* 是否启用 MCP API Key 校验
*/
private boolean enabled = true;
/**
* API Key 请求头名称
*/
private String apiKeyHeader = "X-API-Key";
/**
* 配置的 API Key 列表每个可绑定租户enabled 时若为空 Filter 将返回 401
*/
@Valid
private List<ApiKeyItem> apiKeys = Collections.emptyList();
@Data
public static class ApiKeyItem {
/**
* 客户端名称,用于日志标识(如 "openclaw"、"cursor"
*/
private String name;
/**
* API Key 值,不能为空
*/
@NotBlank(message = "MCP API Key 的 key 不能为空")
private String key;
/**
* 绑定的租户编号,认证通过后由 Filter 注入为请求头 tenant-id供 TenantContextWebFilter 设置租户上下文,后续 MCP 工具查询将限定在该租户下
*/
private Long tenantId;
}
}

View File

@@ -1,19 +1,30 @@
package cn.iocoder.yudao.module.iot.mcp.support; package cn.iocoder.yudao.module.iot.mcp.support;
import cn.hutool.core.collection.CollUtil; import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil;
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDeviceDO; 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.dal.dataobject.product.IotProductDO;
import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; import cn.iocoder.yudao.module.iot.service.device.IotDeviceService;
import cn.iocoder.yudao.module.iot.service.product.IotProductService; import cn.iocoder.yudao.module.iot.service.product.IotProductService;
import org.springframework.util.StringUtils;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
/** /**
* MCP 工具公共方法:分页参数规范化、设备解析等,供各 Tool 复用。 * MCP 工具公共方法:分页参数规范化、设备/产品解析等,供各 Tool 复用。
*/ */
public final class McpToolUtils { public final class McpToolUtils {
/**
* 产品解析结果product 与 errorJson 二选一errorJson 非空时直接返回给调用方
*/
public static class ProductResolveResult {
public IotProductDO product;
public Map<String, Object> errorJson;
}
private McpToolUtils() { private McpToolUtils() {
} }
@@ -53,7 +64,7 @@ public final class McpToolUtils {
IotDeviceDO d = deviceService.getDevice(deviceId); IotDeviceDO d = deviceService.getDevice(deviceId);
return d != null ? d.getId() : null; return d != null ? d.getId() : null;
} }
if (!StringUtils.hasText(productKey) || !StringUtils.hasText(deviceName)) { if (StrUtil.isEmpty(productKey) || StrUtil.isEmpty(deviceName)) {
return null; return null;
} }
String pk = productKey.trim(); String pk = productKey.trim();
@@ -85,7 +96,7 @@ public final class McpToolUtils {
*/ */
public static Long resolveDeviceId(IotDeviceService deviceService, IotProductService productService, public static Long resolveDeviceId(IotDeviceService deviceService, IotProductService productService,
String deviceIdStr, String productKey, String deviceName) { String deviceIdStr, String productKey, String deviceName) {
if (StringUtils.hasText(deviceIdStr)) { if (StrUtil.isNotEmpty(deviceIdStr)) {
try { try {
Long id = Long.parseLong(deviceIdStr.trim()); Long id = Long.parseLong(deviceIdStr.trim());
return resolveDeviceId(deviceService, productService, id, productKey, deviceName); return resolveDeviceId(deviceService, productService, id, productKey, deviceName);
@@ -95,4 +106,56 @@ public final class McpToolUtils {
} }
return resolveDeviceId(deviceService, productService, (Long) null, productKey, deviceName); return resolveDeviceId(deviceService, productService, (Long) null, productKey, deviceName);
} }
/**
* 解析产品productId → productKey → productName。返回 null product 表示未传任何有效参数errorJson 非空时表示未找到或多条。
*
* @param productService 产品服务
* @param productId 产品 ID
* @param productKey 产品 Key
* @param productName 产品名称
* @return 解析结果,调用方需先判断 errorJson 再使用 product
*/
public static ProductResolveResult resolveProduct(IotProductService productService,
Long productId, String productKey, String productName) {
ProductResolveResult out = new ProductResolveResult();
if (productId != null) {
IotProductDO p = productService.getProduct(productId);
if (p == null) {
out.errorJson = Map.of("error", "product_not_found", "productId", productId, "hint", "未找到该产品");
return out;
}
out.product = p;
return out;
}
if (StrUtil.isNotEmpty(productKey)) {
IotProductDO p = productService.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 (StrUtil.isNotEmpty(productName)) {
List<IotProductDO> list = productService.getProductListByName(productName.trim());
if (CollUtil.isEmpty(list)) {
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());
Map<String, Object> err = new LinkedHashMap<>();
err.put("error", "multiple_products");
err.put("productName", productName.trim());
err.put("productKeys", keys);
err.put("hint", "存在多个同名产品,请使用 productKey 或 productId 指定");
out.errorJson = err;
return out;
}
out.product = list.get(0);
return out;
}
return out;
}
} }

View File

@@ -16,7 +16,6 @@ import org.springframework.stereotype.Component;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.Map; import java.util.Map;
import java.util.concurrent.Callable;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@@ -50,7 +49,7 @@ public class IotAlertMcpTool {
@Tool(name = "iot_get_alert_records", description = "查询 IoT 告警记录列表。入参deviceId可选设备编号、或 productKey+deviceNameprocessStatus可选是否已处理pageNo、pageSize可选。返回告警列表规则名、级别、时间、处理状态") @Tool(name = "iot_get_alert_records", description = "查询 IoT 告警记录列表。入参deviceId可选设备编号、或 productKey+deviceNameprocessStatus可选是否已处理pageNo、pageSize可选。返回告警列表规则名、级别、时间、处理状态")
public String getAlertRecords(String deviceId, String productKey, String deviceName, Boolean processStatus, Integer pageNo, Integer pageSize) { public String getAlertRecords(String deviceId, String productKey, String deviceName, Boolean processStatus, Integer pageNo, Integer pageSize) {
return TenantUtils.executeIgnore((Callable<String>) () -> { return TenantUtils.executeIgnore(() -> {
IotAlertRecordPageReqVO req = new IotAlertRecordPageReqVO(); IotAlertRecordPageReqVO req = new IotAlertRecordPageReqVO();
Long resolvedDeviceId = resolveDeviceId(deviceId, productKey, deviceName); Long resolvedDeviceId = resolveDeviceId(deviceId, productKey, deviceName);
if (resolvedDeviceId != null) { if (resolvedDeviceId != null) {
@@ -67,6 +66,12 @@ public class IotAlertMcpTool {
}); });
} }
/**
* 告警记录 DO 转 Map
*
* @param r 告警记录 DO
* @return 告警记录信息 Map
*/
private Map<String, Object> toMap(IotAlertRecordDO r) { private Map<String, Object> toMap(IotAlertRecordDO r) {
Map<String, Object> m = new LinkedHashMap<>(); Map<String, Object> m = new LinkedHashMap<>();
m.put("id", r.getId()); m.put("id", r.getId());

View File

@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.iot.mcp.tool.device; package cn.iocoder.yudao.module.iot.mcp.tool.device;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.util.StrUtil; import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.spring.SpringUtil; import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.pojo.PageResult;
@@ -7,29 +8,33 @@ import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
import cn.iocoder.yudao.framework.tenant.core.util.TenantUtils; 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.controller.admin.device.vo.device.IotDevicePageReqVO;
import cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyHistoryListReqVO; import cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyHistoryListReqVO;
import cn.iocoder.yudao.module.iot.controller.admin.device.vo.property.IotDevicePropertyRespVO;
import cn.iocoder.yudao.module.iot.core.enums.IotDeviceMessageMethodEnum; 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.enums.device.IotDeviceStateEnum;
import cn.iocoder.yudao.module.iot.core.mq.message.IotDeviceMessage; 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.IotDeviceDO;
import cn.iocoder.yudao.module.iot.dal.dataobject.device.IotDevicePropertyDO; 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.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.mcp.config.IotMcpProperties; import cn.iocoder.yudao.module.iot.mcp.config.IotMcpProperties;
import cn.iocoder.yudao.module.iot.mcp.support.McpToolUtils; import cn.iocoder.yudao.module.iot.mcp.support.McpToolUtils;
import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; 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.message.IotDeviceMessageService;
import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService; import cn.iocoder.yudao.module.iot.service.device.property.IotDevicePropertyService;
import cn.iocoder.yudao.module.iot.service.product.IotProductService; import cn.iocoder.yudao.module.iot.service.product.IotProductService;
import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.time.Instant;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException; import java.time.format.DateTimeParseException;
import java.util.*; import java.util.*;
import java.util.concurrent.Callable;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@@ -49,6 +54,8 @@ public class IotDeviceMcpTool {
@Resource @Resource
private IotProductService productService; private IotProductService productService;
@Resource @Resource
private IotThingModelService thingModelService;
@Resource
private IotMcpProperties mcpProperties; private IotMcpProperties mcpProperties;
private IotDeviceService getDeviceService() { private IotDeviceService getDeviceService() {
@@ -67,6 +74,10 @@ public class IotDeviceMcpTool {
return productService != null ? productService : SpringUtil.getBean(IotProductService.class); return productService != null ? productService : SpringUtil.getBean(IotProductService.class);
} }
private IotThingModelService getThingModelService() {
return thingModelService != null ? thingModelService : SpringUtil.getBean(IotThingModelService.class);
}
private IotMcpProperties getMcpProperties() { private IotMcpProperties getMcpProperties() {
return mcpProperties != null ? mcpProperties : SpringUtil.getBean(IotMcpProperties.class); return mcpProperties != null ? mcpProperties : SpringUtil.getBean(IotMcpProperties.class);
} }
@@ -76,8 +87,12 @@ public class IotDeviceMcpTool {
} }
/** /**
* 设备元信息转 Map不含属性快照、不含 deviceSecret * 设备元信息转 Map不含属性快照、不含 deviceSecret
* TODO @AI: 评估设备信息返回是否需要脱敏规则(如设备名含手机号/地址时),可配置脱敏策略 * 当前不脱敏;若有合规需求可在 IotMcpProperties 增加脱敏开关后在此处对 deviceName/nickname 做脱敏。
*
* @param d 设备 DO
* @param productName 产品名称,可为 null
* @return 设备信息 Map不含 deviceSecret
*/ */
private Map<String, Object> deviceToMap(IotDeviceDO d, String productName) { private Map<String, Object> deviceToMap(IotDeviceDO d, String productName) {
Map<String, Object> m = new LinkedHashMap<>(); Map<String, Object> m = new LinkedHashMap<>();
@@ -99,22 +114,27 @@ public class IotDeviceMcpTool {
@Tool(name = "iot_get_device_list", description = "分页查询设备列表。入参pageNo、pageSize可选productId、deviceName、status可选不传则不过滤状态查全部。返回 total 与 listid、deviceName、nickname、productId、productKey、productName、state、onlineTime 等,不含属性快照)。") @Tool(name = "iot_get_device_list", description = "分页查询设备列表。入参pageNo、pageSize可选productId、deviceName、status可选不传则不过滤状态查全部。返回 total 与 listid、deviceName、nickname、productId、productKey、productName、state、onlineTime 等,不含属性快照)。")
public String getDeviceList(Integer pageNo, Integer pageSize, Long productId, String deviceName, Integer status) { public String getDeviceList(Integer pageNo, Integer pageSize, Long productId, String deviceName, Integer status) {
return TenantUtils.executeIgnore((Callable<String>) () -> { return TenantUtils.executeIgnore(() -> {
IotDevicePageReqVO req = new IotDevicePageReqVO(); IotDevicePageReqVO req = new IotDevicePageReqVO();
req.setPageNo(McpToolUtils.defaultPageNo(pageNo)); req.setPageNo(McpToolUtils.defaultPageNo(pageNo));
req.setPageSize(McpToolUtils.defaultPageSize(pageSize)); req.setPageSize(McpToolUtils.defaultPageSize(pageSize));
// productId 为 0 或 null 时视为未传,不按产品筛选 // 1. productId 为 0 或 null 时视为未传,不按产品筛选
if (productId != null && productId > 0) req.setProductId(productId); if (productId != null && productId > 0) req.setProductId(productId);
if (StringUtils.hasText(deviceName)) req.setDeviceName(deviceName.trim()); if (StrUtil.isNotEmpty(deviceName)) req.setDeviceName(deviceName.trim());
// status 为 null 时不设置,即不过滤状态,可查全部设备 // 2. status 为 null 时不设置,即不过滤状态,可查全部设备
if (status != null) req.setStatus(status); if (status != null) req.setStatus(status);
PageResult<IotDeviceDO> page = getDeviceService().getDevicePage(req); PageResult<IotDeviceDO> page = getDeviceService().getDevicePage(req);
Set<Long> productIds = page.getList().stream().map(IotDeviceDO::getProductId).filter(java.util.Objects::nonNull).collect(Collectors.toSet()); Set<Long> productIds = page.getList().stream().map(IotDeviceDO::getProductId).filter(Objects::nonNull).collect(Collectors.toSet());
Map<Long, IotProductDO> productMap = productIds.isEmpty() ? Map.of() : getProductService().getProductMap(productIds); Map<Long, IotProductDO> productMap = productIds.isEmpty() ? Map.of() : getProductService().getProductMap(productIds);
// TODO @AI: 当 productMap.get(productId) 为 null 时,记录告警日志(产品已删除但设备仍关联),便于数据一致性检查 List<Map<String, Object>> list = new ArrayList<>();
List<Map<String, Object>> list = page.getList().stream() for (IotDeviceDO d : page.getList()) {
.map(d -> deviceToMap(d, productMap.get(d.getProductId()) != null ? productMap.get(d.getProductId()).getName() : null)) Long pid = d.getProductId();
.collect(Collectors.toList()); IotProductDO product = pid != null ? productMap.get(pid) : null;
if (pid != null && product == null) {
log.warn("[getDeviceList][设备关联的产品已不存在,数据一致性检查] deviceId={}, productId={}", d.getId(), pid);
}
list.add(deviceToMap(d, product != null ? product.getName() : null));
}
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
result.put("total", page.getTotal()); result.put("total", page.getTotal());
result.put("list", list); result.put("list", list);
@@ -124,7 +144,7 @@ public class IotDeviceMcpTool {
@Tool(name = "iot_get_device", description = "查询单个设备基本信息。入参deviceIdLong或 productKey+deviceName。返回设备元信息id、deviceName、nickname、productId、productName、state 等,不含最新属性)。") @Tool(name = "iot_get_device", description = "查询单个设备基本信息。入参deviceIdLong或 productKey+deviceName。返回设备元信息id、deviceName、nickname、productId、productName、state 等,不含最新属性)。")
public String getDevice(Long deviceId, String productKey, String deviceName) { public String getDevice(Long deviceId, String productKey, String deviceName) {
return TenantUtils.executeIgnore((Callable<String>) () -> { return TenantUtils.executeIgnore(() -> {
Long resolvedId = resolveDeviceId(deviceId, productKey, deviceName); Long resolvedId = resolveDeviceId(deviceId, productKey, deviceName);
if (resolvedId == null) { if (resolvedId == null) {
return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "请提供 deviceId 或 productKey+deviceName并确保设备存在")); return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "请提供 deviceId 或 productKey+deviceName并确保设备存在"));
@@ -140,17 +160,17 @@ public class IotDeviceMcpTool {
@Tool(name = "iot_get_device_status", description = "查询设备当前状态和关键属性。入参:仅传 deviceName设备名称即可若存在多个同名设备则返回候选列表需再传 productKey 或产品名称指定产品。也可传 deviceId或 productKey+deviceName。返回设备名称、产品名、在线状态、最近属性快照、latestPropertyTime属性最新上报时间ISO多设备时返回 multiple_choices 与 candidates 供选择。") @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) { public String getDeviceStatus(Long deviceId, String productKey, String deviceName) {
// MCP 工具可能经 SSE 调用,无独立 HTTP 请求,租户上下文常为空;用 executeIgnore 保证可执行,租户由 api-keys[].tenant-id 在请求经 /sse、/mcp/* 时注入 // 1. MCP 工具可能经 SSE 调用,无独立 HTTP 请求,租户上下文常为空;用 executeIgnore 保证可执行,租户由 api-keys[].tenant-id 在请求经 /sse、/mcp/* 时注入
return TenantUtils.executeIgnore((Callable<String>) () -> doGetDeviceStatus(deviceId, productKey, deviceName)); return TenantUtils.executeIgnore(() -> doGetDeviceStatus(deviceId, productKey, deviceName));
} }
private String doGetDeviceStatus(Long deviceId, String productKey, String deviceName) { private String doGetDeviceStatus(Long deviceId, String productKey, String deviceName) {
Long resolvedId; Long resolvedId;
// 仅传设备名称先按名称查0 条未找到1 条直接返回,多条返回候选让用户指定产品 // 1. 仅传设备名称先按名称查0 条未找到1 条直接返回,多条返回候选让用户指定产品
if ((deviceId == null) && !StringUtils.hasText(productKey) && StringUtils.hasText(deviceName)) { if (deviceId == null && StrUtil.isEmpty(productKey) && StrUtil.isNotEmpty(deviceName)) {
String dn = deviceName.trim(); String dn = deviceName.trim();
List<IotDeviceDO> byName = getDeviceService().getDeviceListByDeviceName(dn); List<IotDeviceDO> byName = getDeviceService().getDeviceListByDeviceName(dn);
if (byName == null || byName.isEmpty()) { if (CollUtil.isEmpty(byName)) {
return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "未找到设备名称 \"" + dn + "\"")); return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "未找到设备名称 \"" + dn + "\""));
} }
if (byName.size() == 1) { if (byName.size() == 1) {
@@ -216,9 +236,9 @@ public class IotDeviceMcpTool {
return JsonUtils.toJsonString(result); return JsonUtils.toJsonString(result);
} }
@Tool(name = "iot_get_device_property_history", description = "查询设备属性历史数据。入参deviceIdLong或 productKey+deviceNameidentifier属性标识符startTime、endTimeISO8601可选。返回时间序列数据列表。") @Tool(name = "iot_get_device_property_history", description = "查询设备属性历史数据。入参deviceIdLong或 productKey+deviceNameidentifier属性标识符,可选,不传则查询所有属性startTime、endTimeISO8601可选,默认过去 24 小时)。返回时间序列数据列表。")
public String getDevicePropertyHistory(Long deviceId, String productKey, String deviceName, String identifier, String startTime, String endTime) { public String getDevicePropertyHistory(Long deviceId, String productKey, String deviceName, String identifier, String startTime, String endTime) {
return TenantUtils.executeIgnore((Callable<String>) () -> { return TenantUtils.executeIgnore(() -> {
Long resolvedId = resolveDeviceId(deviceId, productKey, deviceName); Long resolvedId = resolveDeviceId(deviceId, productKey, deviceName);
if (resolvedId == null) { if (resolvedId == null) {
return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "请提供 deviceId 或 productKey+deviceName并确保设备存在")); return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "请提供 deviceId 或 productKey+deviceName并确保设备存在"));
@@ -227,18 +247,63 @@ public class IotDeviceMcpTool {
if (device == null) { if (device == null) {
return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "设备不存在")); return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "设备不存在"));
} }
// TODO @AI: 增强时间解析逻辑,支持完整 ISO8601 格式(含时区),并限制最大查询时间范围(如 31 天 // 1. 解析时间参数:支持完整 ISO8601 Z 时区),并限制最大查询范围 31 天
// 1. 解析时间参数,失败时返回统一错误 JSON
LocalDateTime start; LocalDateTime start;
LocalDateTime end; LocalDateTime end;
ZoneId zone = ZoneId.systemDefault();
try { try {
start = StrUtil.isNotBlank(startTime) ? LocalDateTime.parse(startTime.replace("Z", "")) : LocalDateTime.now().minusHours(24); if (StrUtil.isNotEmpty(startTime)) {
end = StrUtil.isNotBlank(endTime) ? LocalDateTime.parse(endTime.replace("Z", "")) : LocalDateTime.now(); start = LocalDateTime.ofInstant(Instant.parse(startTime.trim()), zone);
} else {
start = LocalDateTime.now().minusHours(24);
}
if (StrUtil.isNotEmpty(endTime)) {
end = LocalDateTime.ofInstant(Instant.parse(endTime.trim()), zone);
} else {
end = LocalDateTime.now();
}
if (!end.isAfter(start)) {
return JsonUtils.toJsonString(Map.of("error", "invalid_time_range", "hint", "endTime 须大于 startTime"));
}
long days = java.time.temporal.ChronoUnit.DAYS.between(start, end);
if (days > 31) {
return JsonUtils.toJsonString(Map.of("error", "time_range_too_large", "hint", "查询时间范围不得超过 31 天"));
}
} catch (DateTimeParseException e) { } catch (DateTimeParseException e) {
return JsonUtils.toJsonString(Map.of( return JsonUtils.toJsonString(Map.of(
"error", "invalid_time_format", "error", "invalid_time_format",
"hint", "startTime/endTime 请使用 ISO8601 格式,如 2025-01-01T00:00:00")); "hint", "startTime/endTime 请使用 ISO8601 格式,如 2025-01-01T00:00:00 或 2025-01-01T00:00:00Z"));
} }
// 2. identifier 不传时,查询所有属性
if (StrUtil.isEmpty(identifier)) {
// 2.1 查询产品的物模型,获取所有属性标识符
List<IotThingModelDO> thingModels = getThingModelService().getThingModelListByProductId(device.getProductId());
List<String> identifiers = thingModels.stream()
.filter(m -> m.getType() != null && m.getType() == IotThingModelTypeEnum.PROPERTY.getType())
.map(IotThingModelDO::getIdentifier)
.collect(Collectors.toList());
if (CollUtil.isEmpty(identifiers)) {
return JsonUtils.toJsonString(Map.of(
"error", "no_properties",
"hint", "该产品未定义任何属性"));
}
// 2.2 循环查询每个属性的历史数据
Map<String, List<IotDevicePropertyRespVO>> result = new LinkedHashMap<>();
for (String id : identifiers) {
IotDevicePropertyHistoryListReqVO req = new IotDevicePropertyHistoryListReqVO();
req.setDeviceId(resolvedId);
req.setIdentifier(id);
req.setTimes(new LocalDateTime[]{start, end});
List<IotDevicePropertyRespVO> historyList = getDevicePropertyService().getHistoryDevicePropertyList(req);
result.put(id, historyList);
}
return JsonUtils.toJsonString(result);
}
// 3. 查询单个属性的历史数据
IotDevicePropertyHistoryListReqVO req = new IotDevicePropertyHistoryListReqVO(); IotDevicePropertyHistoryListReqVO req = new IotDevicePropertyHistoryListReqVO();
req.setDeviceId(resolvedId); req.setDeviceId(resolvedId);
req.setIdentifier(identifier); req.setIdentifier(identifier);
@@ -249,7 +314,7 @@ public class IotDeviceMcpTool {
@Tool(name = "iot_send_device_message", description = "向设备下发控制指令属性设置或服务调用。入参deviceIdLong或 productKey+deviceNametypeproperty_set 或 service_invokeidentifierparamsJSON 对象字符串)。受配置开关控制,默认关闭。") @Tool(name = "iot_send_device_message", description = "向设备下发控制指令属性设置或服务调用。入参deviceIdLong或 productKey+deviceNametypeproperty_set 或 service_invokeidentifierparamsJSON 对象字符串)。受配置开关控制,默认关闭。")
public String sendDeviceMessage(Long deviceId, String productKey, String deviceName, String type, String identifier, String params) { public String sendDeviceMessage(Long deviceId, String productKey, String deviceName, String type, String identifier, String params) {
return TenantUtils.executeIgnore((Callable<String>) () -> { return TenantUtils.executeIgnore(() -> {
if (!getMcpProperties().isEnableControlTools()) { if (!getMcpProperties().isEnableControlTools()) {
return JsonUtils.toJsonString(Map.of("accepted", false, "message", "控制类工具未开放,请检查 yudao.iot.mcp.enable-control-tools 配置")); return JsonUtils.toJsonString(Map.of("accepted", false, "message", "控制类工具未开放,请检查 yudao.iot.mcp.enable-control-tools 配置"));
} }
@@ -262,7 +327,7 @@ public class IotDeviceMcpTool {
return JsonUtils.toJsonString(Map.of("accepted", false, "error", "device_not_found", "hint", "设备不存在")); return JsonUtils.toJsonString(Map.of("accepted", false, "error", "device_not_found", "hint", "设备不存在"));
} }
Object paramsObj = null; Object paramsObj = null;
if (StrUtil.isNotBlank(params)) { if (StrUtil.isNotEmpty(params)) {
try { try {
paramsObj = JsonUtils.parseObject(params, Map.class); paramsObj = JsonUtils.parseObject(params, Map.class);
} catch (Exception e) { } catch (Exception e) {
@@ -275,7 +340,7 @@ public class IotDeviceMcpTool {
paramsObj = paramsObj != null ? Map.of("properties", paramsObj) : Map.of(); paramsObj = paramsObj != null ? Map.of("properties", paramsObj) : Map.of();
} else if ("service_invoke".equalsIgnoreCase(type)) { } else if ("service_invoke".equalsIgnoreCase(type)) {
method = IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod(); method = IotDeviceMessageMethodEnum.SERVICE_INVOKE.getMethod();
if (StrUtil.isBlank(identifier)) { if (StrUtil.isEmpty(identifier)) {
return JsonUtils.toJsonString(Map.of("accepted", false, "error", "invalid_identifier", "hint", "service_invoke 时 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()); paramsObj = paramsObj != null ? Map.of("identifier", identifier, "params", paramsObj) : Map.of("identifier", identifier, "params", Map.of());
@@ -286,8 +351,8 @@ public class IotDeviceMcpTool {
message.setDeviceId(resolvedId); message.setDeviceId(resolvedId);
try { try {
IotDeviceMessage sent = getDeviceMessageService().sendDeviceMessage(message); IotDeviceMessage sent = getDeviceMessageService().sendDeviceMessage(message);
// 仅记录 deviceId/type/identifier不记录 params 避免泄露控制参数 // 1. 仅记录 deviceId/type/identifier不记录 params 避免泄露控制参数
log.info("[sendDeviceMessage][mcp 下发指令成功] source=mcp, deviceId={}, type={}, identifier={}", resolvedId, type, identifier); log.info("[sendDeviceMessage][mcp 下发指令成功] deviceId={}, type={}, identifier={}", resolvedId, type, identifier);
return JsonUtils.toJsonString(Map.of("accepted", true, "messageId", sent.getId() != null ? sent.getId() : "")); return JsonUtils.toJsonString(Map.of("accepted", true, "messageId", sent.getId() != null ? sent.getId() : ""));
} catch (Exception e) { } catch (Exception e) {
return JsonUtils.toJsonString(Map.of("accepted", false, "message", e.getMessage())); return JsonUtils.toJsonString(Map.of("accepted", false, "message", e.getMessage()));

View File

@@ -1,5 +1,6 @@
package cn.iocoder.yudao.module.iot.mcp.tool.product; package cn.iocoder.yudao.module.iot.mcp.tool.product;
import cn.hutool.core.util.StrUtil;
import cn.hutool.extra.spring.SpringUtil; import cn.hutool.extra.spring.SpringUtil;
import cn.iocoder.yudao.framework.common.pojo.PageResult; import cn.iocoder.yudao.framework.common.pojo.PageResult;
import cn.iocoder.yudao.framework.common.util.json.JsonUtils; import cn.iocoder.yudao.framework.common.util.json.JsonUtils;
@@ -11,12 +12,10 @@ import cn.iocoder.yudao.module.iot.service.product.IotProductService;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.Callable;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@@ -35,6 +34,12 @@ public class IotProductMcpTool {
return productService != null ? productService : SpringUtil.getBean(IotProductService.class); return productService != null ? productService : SpringUtil.getBean(IotProductService.class);
} }
/**
* 产品 DO 转 Map不含密钥
*
* @param p 产品 DO
* @return 产品信息 Map
*/
private Map<String, Object> productToMap(IotProductDO p) { private Map<String, Object> productToMap(IotProductDO p) {
Map<String, Object> m = new LinkedHashMap<>(); Map<String, Object> m = new LinkedHashMap<>();
m.put("id", p.getId()); m.put("id", p.getId());
@@ -51,12 +56,12 @@ public class IotProductMcpTool {
@Tool(name = "iot_get_product_list", description = "分页查询产品列表。入参pageNo、pageSize可选name、productKey可选模糊。返回 total 与 listid、name、productKey、deviceType、status、createTime 等)。") @Tool(name = "iot_get_product_list", description = "分页查询产品列表。入参pageNo、pageSize可选name、productKey可选模糊。返回 total 与 listid、name、productKey、deviceType、status、createTime 等)。")
public String getProductList(Integer pageNo, Integer pageSize, String name, String productKey) { public String getProductList(Integer pageNo, Integer pageSize, String name, String productKey) {
return TenantUtils.executeIgnore((Callable<String>) () -> { return TenantUtils.executeIgnore(() -> {
IotProductPageReqVO req = new IotProductPageReqVO(); IotProductPageReqVO req = new IotProductPageReqVO();
req.setPageNo(McpToolUtils.defaultPageNo(pageNo)); req.setPageNo(McpToolUtils.defaultPageNo(pageNo));
req.setPageSize(McpToolUtils.defaultPageSize(pageSize)); req.setPageSize(McpToolUtils.defaultPageSize(pageSize));
if (StringUtils.hasText(name)) req.setName(name.trim()); if (StrUtil.isNotEmpty(name)) req.setName(name.trim());
if (StringUtils.hasText(productKey)) req.setProductKey(productKey.trim()); if (StrUtil.isNotEmpty(productKey)) req.setProductKey(productKey.trim());
PageResult<IotProductDO> page = getProductService().getProductPage(req); PageResult<IotProductDO> page = getProductService().getProductPage(req);
List<Map<String, Object>> list = page.getList().stream().map(this::productToMap).collect(Collectors.toList()); List<Map<String, Object>> list = page.getList().stream().map(this::productToMap).collect(Collectors.toList());
Map<String, Object> result = new LinkedHashMap<>(); Map<String, Object> result = new LinkedHashMap<>();
@@ -68,11 +73,11 @@ public class IotProductMcpTool {
@Tool(name = "iot_get_product", description = "查询单个产品详情。入参productIdLong或 productKeyString二选一。返回产品元信息id、name、productKey、deviceType、status 等,不含密钥)。") @Tool(name = "iot_get_product", description = "查询单个产品详情。入参productIdLong或 productKeyString二选一。返回产品元信息id、name、productKey、deviceType、status 等,不含密钥)。")
public String getProduct(Long productId, String productKey) { public String getProduct(Long productId, String productKey) {
return TenantUtils.executeIgnore((Callable<String>) () -> { return TenantUtils.executeIgnore(() -> {
IotProductDO p = null; IotProductDO p = null;
if (productId != null) { if (productId != null) {
p = getProductService().getProduct(productId); p = getProductService().getProduct(productId);
} else if (StringUtils.hasText(productKey)) { } else if (StrUtil.isNotEmpty(productKey)) {
p = getProductService().getProductByProductKey(productKey.trim()); p = getProductService().getProductByProductKey(productKey.trim());
} }
if (p == null) { if (p == null) {

View File

@@ -6,17 +6,16 @@ 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.product.IotProductDO;
import cn.iocoder.yudao.module.iot.dal.dataobject.thingmodel.IotThingModelDO; 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.enums.thingmodel.IotThingModelTypeEnum;
import cn.iocoder.yudao.module.iot.mcp.support.McpToolUtils;
import cn.iocoder.yudao.module.iot.service.product.IotProductService; import cn.iocoder.yudao.module.iot.service.product.IotProductService;
import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService; import cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService;
import jakarta.annotation.Resource; import jakarta.annotation.Resource;
import org.springframework.ai.tool.annotation.Tool; import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component; import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.concurrent.Callable;
import java.util.stream.Collectors; import java.util.stream.Collectors;
/** /**
@@ -40,8 +39,8 @@ public class IotThingModelMcpTool {
@Tool(name = "iot_get_thing_model", description = "查询产品的物模型定义属性、服务、事件。入参productIdLong、productKeyString、productNameString三选一方便按名称或 Key 查询。返回属性/服务/事件列表,包含标识符、名称、类型、访问模式等。") @Tool(name = "iot_get_thing_model", description = "查询产品的物模型定义属性、服务、事件。入参productIdLong、productKeyString、productNameString三选一方便按名称或 Key 查询。返回属性/服务/事件列表,包含标识符、名称、类型、访问模式等。")
public String getThingModel(Long productId, String productKey, String productName) { public String getThingModel(Long productId, String productKey, String productName) {
return TenantUtils.executeIgnore((Callable<String>) () -> { return TenantUtils.executeIgnore(() -> {
ProductResolveResult resolved = resolveProduct(productId, productKey, productName); McpToolUtils.ProductResolveResult resolved = McpToolUtils.resolveProduct(getProductService(), productId, productKey, productName);
if (resolved.errorJson != null) { if (resolved.errorJson != null) {
return JsonUtils.toJsonString(resolved.errorJson); return JsonUtils.toJsonString(resolved.errorJson);
} }
@@ -61,56 +60,12 @@ public class IotThingModelMcpTool {
}); });
} }
private static class ProductResolveResult {
IotProductDO product;
Map<String, Object> errorJson;
}
/** /**
* 解析产品productId → productKey → productName。返回 null product 表示未传任何有效参数errorJson 非空时表示未找到或多条,调用方直接返回该 JSON。 * 物模型 DO 转 Map
* TODO @AI: 若后续新增产品查询类 MCP 工具,可将 resolveProduct 抽到 McpToolUtils 复用 *
* @param m 物模型 DO
* @return 物模型信息 Map
*/ */
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) { private Map<String, Object> toMap(IotThingModelDO m) {
Map<String, Object> map = new LinkedHashMap<>(); Map<String, Object> map = new LinkedHashMap<>();
map.put("id", m.getId()); map.put("id", m.getId());