From 55964955af9a68681d93aec573caee24ada6b212 Mon Sep 17 00:00:00 2001 From: haohao <1036606149@qq.com> Date: Sat, 28 Feb 2026 17:09:04 +0800 Subject: [PATCH] =?UTF-8?q?feat(iot):=20=E5=A2=9E=E5=BC=BA=20MCP=20?= =?UTF-8?q?=E9=85=8D=E7=BD=AE=E5=92=8C=E5=AE=89=E5=85=A8=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=EF=BC=8C=E5=8C=85=E6=8B=ACAPI=20=E5=AF=86=E9=92=A5=E9=AA=8C?= =?UTF-8?q?=E8=AF=81=E5=92=8C=E6=8E=A7=E5=88=B6=E5=B7=A5=E5=85=B7=E6=97=A5?= =?UTF-8?q?=E5=BF=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mcp/config/IotMcpAutoConfiguration.java | 11 ++ .../iot/mcp/config/IotMcpProperties.java | 50 ++++++- .../McpApiKeyAuthenticationFilter.java | 36 ++--- .../McpSecurityAutoConfiguration.java | 5 +- .../mcp/security/McpSecurityProperties.java | 53 -------- .../module/iot/mcp/support/McpToolUtils.java | 71 +++++++++- .../iot/mcp/tool/alert/IotAlertMcpTool.java | 9 +- .../iot/mcp/tool/device/IotDeviceMcpTool.java | 127 +++++++++++++----- .../mcp/tool/product/IotProductMcpTool.java | 19 ++- .../tool/thingmodel/IotThingModelMcpTool.java | 59 +------- 10 files changed, 271 insertions(+), 169 deletions(-) delete mode 100644 yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/security/McpSecurityProperties.java diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/config/IotMcpAutoConfiguration.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/config/IotMcpAutoConfiguration.java index e5a4607515..9ce4b4ac41 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/config/IotMcpAutoConfiguration.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/config/IotMcpAutoConfiguration.java @@ -12,6 +12,7 @@ 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.boot.ApplicationRunner; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -33,6 +34,16 @@ public class IotMcpAutoConfiguration { */ 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 public ToolCallback[] iotProductToolCallbacks(IotProductMcpTool productTool) { return ToolCallbacks.from(productTool); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/config/IotMcpProperties.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/config/IotMcpProperties.java index 66022fc063..5daf088521 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/config/IotMcpProperties.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/config/IotMcpProperties.java @@ -1,13 +1,20 @@ package cn.iocoder.yudao.module.iot.mcp.config; +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; /** - * IoT MCP 模块配置(如控制类工具开关等) - * TODO @AI: 在应用启动时打印控制类工具开关状态(enableControlTools=true 时输出 WARN 提醒) + * IoT MCP 模块配置:控制类工具开关、API Key 认证(含租户绑定)等。 + * 安全配置位于 {@link #getSecurity()},对应 yaml 中 yudao.iot.mcp.security 节点。 */ @ConfigurationProperties(prefix = "yudao.iot.mcp") +@Validated @Data public class IotMcpProperties { @@ -15,4 +22,43 @@ public class IotMcpProperties { * 是否启用控制类 MCP 工具(如 iot_send_device_message),默认 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 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; + } } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/security/McpApiKeyAuthenticationFilter.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/security/McpApiKeyAuthenticationFilter.java index 3d752af4a1..4efd16ec0e 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/security/McpApiKeyAuthenticationFilter.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/security/McpApiKeyAuthenticationFilter.java @@ -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.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.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -9,7 +12,6 @@ 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; @@ -27,49 +29,51 @@ import java.util.Map; @RequiredArgsConstructor public class McpApiKeyAuthenticationFilter extends OncePerRequestFilter { - private final McpSecurityProperties properties; + private final IotMcpProperties properties; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { - if (!properties.isEnabled()) { + IotMcpProperties.Security security = properties.getSecurity(); + if (security == null || !security.isEnabled()) { filterChain.doFilter(request, response); return; } - // enabled 且 apiKeys 未配置时 fail-closed,避免裸奔;后续可考虑限流与重放防护 - if (properties.getApiKeys() == null || properties.getApiKeys().isEmpty()) { - log.warn("[MCP][审计] API Key 未配置,拒绝请求"); + String clientIp = request.getRemoteAddr(); + String userAgent = request.getHeader("User-Agent"); + // 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"); return; } - String requestKey = request.getHeader(properties.getApiKeyHeader()); - if (!StringUtils.hasText(requestKey)) { - log.warn("[MCP][审计] 缺少 API Key 请求头"); + String requestKey = request.getHeader(security.getApiKeyHeader()); + if (StrUtil.isEmpty(requestKey)) { + log.warn("[doFilterInternal][缺少 API Key 请求头] ip={}, userAgent={}", clientIp, userAgent); sendUnauthorized(response, "Missing MCP API Key"); return; } - McpSecurityProperties.ApiKeyItem matched = findMatchingApiKey(requestKey.trim()); + IotMcpProperties.ApiKeyItem matched = findMatchingApiKey(security, requestKey.trim()); 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"); return; } 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()); filterChain.doFilter(wrappedRequest, response); } - // TODO @AI: 增加详细审计日志(IP、User-Agent、认证结果),并评估使用 HashMap 缓存减少遍历时间差异 - private McpSecurityProperties.ApiKeyItem findMatchingApiKey(String requestKey) { + private IotMcpProperties.ApiKeyItem findMatchingApiKey(IotMcpProperties.Security security, String requestKey) { byte[] requestKeyBytes = requestKey.getBytes(StandardCharsets.UTF_8); - for (McpSecurityProperties.ApiKeyItem item : properties.getApiKeys()) { - if (item == null || !StringUtils.hasText(item.getKey())) { + for (IotMcpProperties.ApiKeyItem item : security.getApiKeys()) { + if (item == null || StrUtil.isEmpty(item.getKey())) { continue; } if (constantTimeEquals(requestKeyBytes, item.getKey().getBytes(StandardCharsets.UTF_8))) { diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/security/McpSecurityAutoConfiguration.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/security/McpSecurityAutoConfiguration.java index 446b192e56..19f61c46a6 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/security/McpSecurityAutoConfiguration.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/security/McpSecurityAutoConfiguration.java @@ -1,5 +1,6 @@ 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.context.properties.EnableConfigurationProperties; import org.springframework.boot.web.servlet.FilterRegistrationBean; @@ -11,12 +12,12 @@ import org.springframework.core.Ordered; * 注册 McpApiKeyAuthenticationFilter,在 Spring Security 与 TenantContextWebFilter 之前执行。 */ @AutoConfiguration -@EnableConfigurationProperties(McpSecurityProperties.class) +@EnableConfigurationProperties(IotMcpProperties.class) public class McpSecurityAutoConfiguration { @Bean public FilterRegistrationBean mcpApiKeyAuthenticationFilterRegistration( - McpSecurityProperties properties) { + IotMcpProperties properties) { FilterRegistrationBean registration = new FilterRegistrationBean<>(); registration.setFilter(new McpApiKeyAuthenticationFilter(properties)); registration.addUrlPatterns("/sse", "/sse/*", "/mcp/*"); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/security/McpSecurityProperties.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/security/McpSecurityProperties.java deleted file mode 100644 index d849cb196e..0000000000 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/security/McpSecurityProperties.java +++ /dev/null @@ -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 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; - } -} diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/support/McpToolUtils.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/support/McpToolUtils.java index 02e95c28c7..121506c7bb 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/support/McpToolUtils.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/support/McpToolUtils.java @@ -1,19 +1,30 @@ package cn.iocoder.yudao.module.iot.mcp.support; 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.product.IotProductDO; import cn.iocoder.yudao.module.iot.service.device.IotDeviceService; 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.Map; +import java.util.stream.Collectors; /** - * MCP 工具公共方法:分页参数规范化、设备解析等,供各 Tool 复用。 + * MCP 工具公共方法:分页参数规范化、设备/产品解析等,供各 Tool 复用。 */ public final class McpToolUtils { + /** + * 产品解析结果:product 与 errorJson 二选一,errorJson 非空时直接返回给调用方 + */ + public static class ProductResolveResult { + public IotProductDO product; + public Map errorJson; + } + private McpToolUtils() { } @@ -53,7 +64,7 @@ public final class McpToolUtils { IotDeviceDO d = deviceService.getDevice(deviceId); return d != null ? d.getId() : null; } - if (!StringUtils.hasText(productKey) || !StringUtils.hasText(deviceName)) { + if (StrUtil.isEmpty(productKey) || StrUtil.isEmpty(deviceName)) { return null; } String pk = productKey.trim(); @@ -85,7 +96,7 @@ public final class McpToolUtils { */ public static Long resolveDeviceId(IotDeviceService deviceService, IotProductService productService, String deviceIdStr, String productKey, String deviceName) { - if (StringUtils.hasText(deviceIdStr)) { + if (StrUtil.isNotEmpty(deviceIdStr)) { try { Long id = Long.parseLong(deviceIdStr.trim()); return resolveDeviceId(deviceService, productService, id, productKey, deviceName); @@ -95,4 +106,56 @@ public final class McpToolUtils { } 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 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 keys = list.stream().map(IotProductDO::getProductKey).collect(Collectors.toList()); + Map 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; + } } diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/tool/alert/IotAlertMcpTool.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/tool/alert/IotAlertMcpTool.java index ff6f744610..a1d5bde691 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/tool/alert/IotAlertMcpTool.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/tool/alert/IotAlertMcpTool.java @@ -16,7 +16,6 @@ import org.springframework.stereotype.Component; import java.util.LinkedHashMap; import java.util.Map; -import java.util.concurrent.Callable; import java.util.stream.Collectors; /** @@ -50,7 +49,7 @@ public class IotAlertMcpTool { @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) () -> { + return TenantUtils.executeIgnore(() -> { IotAlertRecordPageReqVO req = new IotAlertRecordPageReqVO(); Long resolvedDeviceId = resolveDeviceId(deviceId, productKey, deviceName); if (resolvedDeviceId != null) { @@ -67,6 +66,12 @@ public class IotAlertMcpTool { }); } + /** + * 告警记录 DO 转 Map + * + * @param r 告警记录 DO + * @return 告警记录信息 Map + */ private Map toMap(IotAlertRecordDO r) { Map m = new LinkedHashMap<>(); m.put("id", r.getId()); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/tool/device/IotDeviceMcpTool.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/tool/device/IotDeviceMcpTool.java index 2522bd6c41..ec6513390d 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/tool/device/IotDeviceMcpTool.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/tool/device/IotDeviceMcpTool.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.iot.mcp.tool.device; +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; @@ -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.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.IotDevicePropertyRespVO; 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.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.support.McpToolUtils; 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 cn.iocoder.yudao.module.iot.service.thingmodel.IotThingModelService; import jakarta.annotation.Resource; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.tool.annotation.Tool; import org.springframework.stereotype.Component; -import org.springframework.util.StringUtils; +import java.time.Instant; import java.time.LocalDateTime; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.time.format.DateTimeParseException; import java.util.*; -import java.util.concurrent.Callable; import java.util.stream.Collectors; /** @@ -49,6 +54,8 @@ public class IotDeviceMcpTool { @Resource private IotProductService productService; @Resource + private IotThingModelService thingModelService; + @Resource private IotMcpProperties mcpProperties; private IotDeviceService getDeviceService() { @@ -67,6 +74,10 @@ public class IotDeviceMcpTool { return productService != null ? productService : SpringUtil.getBean(IotProductService.class); } + private IotThingModelService getThingModelService() { + return thingModelService != null ? thingModelService : SpringUtil.getBean(IotThingModelService.class); + } + private IotMcpProperties getMcpProperties() { return mcpProperties != null ? mcpProperties : SpringUtil.getBean(IotMcpProperties.class); } @@ -76,8 +87,12 @@ public class IotDeviceMcpTool { } /** - * 设备元信息转 Map(不含属性快照、不含 deviceSecret) - * TODO @AI: 评估设备信息返回是否需要脱敏规则(如设备名含手机号/地址时),可配置脱敏策略 + * 设备元信息转 Map(不含属性快照、不含 deviceSecret)。 + * 当前不脱敏;若有合规需求可在 IotMcpProperties 增加脱敏开关后在此处对 deviceName/nickname 做脱敏。 + * + * @param d 设备 DO + * @param productName 产品名称,可为 null + * @return 设备信息 Map,不含 deviceSecret */ private Map deviceToMap(IotDeviceDO d, String productName) { Map m = new LinkedHashMap<>(); @@ -99,22 +114,27 @@ public class IotDeviceMcpTool { @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) () -> { + return TenantUtils.executeIgnore(() -> { IotDevicePageReqVO req = new IotDevicePageReqVO(); req.setPageNo(McpToolUtils.defaultPageNo(pageNo)); req.setPageSize(McpToolUtils.defaultPageSize(pageSize)); - // productId 为 0 或 null 时视为未传,不按产品筛选 + // 1. productId 为 0 或 null 时视为未传,不按产品筛选 if (productId != null && productId > 0) req.setProductId(productId); - if (StringUtils.hasText(deviceName)) req.setDeviceName(deviceName.trim()); - // status 为 null 时不设置,即不过滤状态,可查全部设备 + if (StrUtil.isNotEmpty(deviceName)) req.setDeviceName(deviceName.trim()); + // 2. status 为 null 时不设置,即不过滤状态,可查全部设备 if (status != null) req.setStatus(status); PageResult page = getDeviceService().getDevicePage(req); - Set productIds = page.getList().stream().map(IotDeviceDO::getProductId).filter(java.util.Objects::nonNull).collect(Collectors.toSet()); + Set productIds = page.getList().stream().map(IotDeviceDO::getProductId).filter(Objects::nonNull).collect(Collectors.toSet()); Map productMap = productIds.isEmpty() ? Map.of() : getProductService().getProductMap(productIds); - // TODO @AI: 当 productMap.get(productId) 为 null 时,记录告警日志(产品已删除但设备仍关联),便于数据一致性检查 - List> list = page.getList().stream() - .map(d -> deviceToMap(d, productMap.get(d.getProductId()) != null ? productMap.get(d.getProductId()).getName() : null)) - .collect(Collectors.toList()); + List> list = new ArrayList<>(); + for (IotDeviceDO d : page.getList()) { + Long pid = d.getProductId(); + 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 result = new LinkedHashMap<>(); result.put("total", page.getTotal()); result.put("list", list); @@ -124,7 +144,7 @@ public class IotDeviceMcpTool { @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) () -> { + return TenantUtils.executeIgnore(() -> { Long resolvedId = resolveDeviceId(deviceId, productKey, deviceName); if (resolvedId == null) { 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 供选择。") public String getDeviceStatus(Long deviceId, String productKey, String deviceName) { - // MCP 工具可能经 SSE 调用,无独立 HTTP 请求,租户上下文常为空;用 executeIgnore 保证可执行,租户由 api-keys[].tenant-id 在请求经 /sse、/mcp/* 时注入 - return TenantUtils.executeIgnore((Callable) () -> doGetDeviceStatus(deviceId, productKey, deviceName)); + // 1. MCP 工具可能经 SSE 调用,无独立 HTTP 请求,租户上下文常为空;用 executeIgnore 保证可执行,租户由 api-keys[].tenant-id 在请求经 /sse、/mcp/* 时注入 + return TenantUtils.executeIgnore(() -> 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)) { + // 1. 仅传设备名称:先按名称查,0 条未找到,1 条直接返回,多条返回候选让用户指定产品 + if (deviceId == null && StrUtil.isEmpty(productKey) && StrUtil.isNotEmpty(deviceName)) { String dn = deviceName.trim(); List byName = getDeviceService().getDeviceListByDeviceName(dn); - if (byName == null || byName.isEmpty()) { + if (CollUtil.isEmpty(byName)) { return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "未找到设备名称 \"" + dn + "\"")); } if (byName.size() == 1) { @@ -216,9 +236,9 @@ public class IotDeviceMcpTool { return JsonUtils.toJsonString(result); } - @Tool(name = "iot_get_device_property_history", description = "查询设备属性历史数据。入参:deviceId(Long)或 productKey+deviceName;identifier(属性标识符);startTime、endTime(ISO8601,可选)。返回时间序列数据列表。") + @Tool(name = "iot_get_device_property_history", description = "查询设备属性历史数据。入参:deviceId(Long)或 productKey+deviceName;identifier(属性标识符,可选,不传则查询所有属性);startTime、endTime(ISO8601,可选,默认过去 24 小时)。返回时间序列数据列表。") public String getDevicePropertyHistory(Long deviceId, String productKey, String deviceName, String identifier, String startTime, String endTime) { - return TenantUtils.executeIgnore((Callable) () -> { + return TenantUtils.executeIgnore(() -> { Long resolvedId = resolveDeviceId(deviceId, productKey, deviceName); if (resolvedId == null) { return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "请提供 deviceId 或 productKey+deviceName,并确保设备存在")); @@ -227,18 +247,63 @@ public class IotDeviceMcpTool { if (device == null) { return JsonUtils.toJsonString(Map.of("error", "device_not_found", "hint", "设备不存在")); } - // TODO @AI: 增强时间解析逻辑,支持完整 ISO8601 格式(含时区),并限制最大查询时间范围(如 31 天) - // 1. 解析时间参数,失败时返回统一错误 JSON + // 1. 解析时间参数:支持完整 ISO8601(含 Z 时区),并限制最大查询范围 31 天 LocalDateTime start; LocalDateTime end; + ZoneId zone = ZoneId.systemDefault(); 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(); + if (StrUtil.isNotEmpty(startTime)) { + 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) { return JsonUtils.toJsonString(Map.of( "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 thingModels = getThingModelService().getThingModelListByProductId(device.getProductId()); + List 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> result = new LinkedHashMap<>(); + for (String id : identifiers) { + IotDevicePropertyHistoryListReqVO req = new IotDevicePropertyHistoryListReqVO(); + req.setDeviceId(resolvedId); + req.setIdentifier(id); + req.setTimes(new LocalDateTime[]{start, end}); + List historyList = getDevicePropertyService().getHistoryDevicePropertyList(req); + result.put(id, historyList); + } + return JsonUtils.toJsonString(result); + } + + // 3. 查询单个属性的历史数据 IotDevicePropertyHistoryListReqVO req = new IotDevicePropertyHistoryListReqVO(); req.setDeviceId(resolvedId); req.setIdentifier(identifier); @@ -249,7 +314,7 @@ public class IotDeviceMcpTool { @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) () -> { + return TenantUtils.executeIgnore(() -> { if (!getMcpProperties().isEnableControlTools()) { 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", "设备不存在")); } Object paramsObj = null; - if (StrUtil.isNotBlank(params)) { + if (StrUtil.isNotEmpty(params)) { try { paramsObj = JsonUtils.parseObject(params, Map.class); } catch (Exception e) { @@ -275,7 +340,7 @@ public class IotDeviceMcpTool { paramsObj = paramsObj != null ? Map.of("properties", paramsObj) : Map.of(); } else if ("service_invoke".equalsIgnoreCase(type)) { 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 不能为空")); } 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); try { IotDeviceMessage sent = getDeviceMessageService().sendDeviceMessage(message); - // 仅记录 deviceId/type/identifier,不记录 params 避免泄露控制参数 - log.info("[sendDeviceMessage][mcp 下发指令成功] source=mcp, deviceId={}, type={}, identifier={}", resolvedId, type, identifier); + // 1. 仅记录 deviceId/type/identifier,不记录 params 避免泄露控制参数 + log.info("[sendDeviceMessage][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())); diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/tool/product/IotProductMcpTool.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/tool/product/IotProductMcpTool.java index 2bdc0aeadf..117e4ce3b1 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/tool/product/IotProductMcpTool.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/tool/product/IotProductMcpTool.java @@ -1,5 +1,6 @@ package cn.iocoder.yudao.module.iot.mcp.tool.product; +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; @@ -11,12 +12,10 @@ 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; /** @@ -35,6 +34,12 @@ public class IotProductMcpTool { return productService != null ? productService : SpringUtil.getBean(IotProductService.class); } + /** + * 产品 DO 转 Map(不含密钥) + * + * @param p 产品 DO + * @return 产品信息 Map + */ private Map productToMap(IotProductDO p) { Map m = new LinkedHashMap<>(); m.put("id", p.getId()); @@ -51,12 +56,12 @@ public class IotProductMcpTool { @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) () -> { + return TenantUtils.executeIgnore(() -> { IotProductPageReqVO req = new IotProductPageReqVO(); req.setPageNo(McpToolUtils.defaultPageNo(pageNo)); req.setPageSize(McpToolUtils.defaultPageSize(pageSize)); - if (StringUtils.hasText(name)) req.setName(name.trim()); - if (StringUtils.hasText(productKey)) req.setProductKey(productKey.trim()); + if (StrUtil.isNotEmpty(name)) req.setName(name.trim()); + if (StrUtil.isNotEmpty(productKey)) req.setProductKey(productKey.trim()); PageResult page = getProductService().getProductPage(req); List> list = page.getList().stream().map(this::productToMap).collect(Collectors.toList()); Map result = new LinkedHashMap<>(); @@ -68,11 +73,11 @@ public class IotProductMcpTool { @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) () -> { + return TenantUtils.executeIgnore(() -> { IotProductDO p = null; if (productId != null) { p = getProductService().getProduct(productId); - } else if (StringUtils.hasText(productKey)) { + } else if (StrUtil.isNotEmpty(productKey)) { p = getProductService().getProductByProductKey(productKey.trim()); } if (p == null) { diff --git a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/tool/thingmodel/IotThingModelMcpTool.java b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/tool/thingmodel/IotThingModelMcpTool.java index f94dd6d5e1..7b327ef1d8 100644 --- a/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/tool/thingmodel/IotThingModelMcpTool.java +++ b/yudao-module-iot/yudao-module-iot-biz/src/main/java/cn/iocoder/yudao/module/iot/mcp/tool/thingmodel/IotThingModelMcpTool.java @@ -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.thingmodel.IotThingModelDO; 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.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; /** @@ -40,8 +39,8 @@ public class IotThingModelMcpTool { @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) () -> { - ProductResolveResult resolved = resolveProduct(productId, productKey, productName); + return TenantUtils.executeIgnore(() -> { + McpToolUtils.ProductResolveResult resolved = McpToolUtils.resolveProduct(getProductService(), productId, productKey, productName); if (resolved.errorJson != null) { return JsonUtils.toJsonString(resolved.errorJson); } @@ -61,56 +60,12 @@ public class IotThingModelMcpTool { }); } - private static class ProductResolveResult { - IotProductDO product; - Map errorJson; - } - /** - * 解析产品:productId → productKey → productName。返回 null product 表示未传任何有效参数;errorJson 非空时表示未找到或多条,调用方直接返回该 JSON。 - * TODO @AI: 若后续新增产品查询类 MCP 工具,可将 resolveProduct 抽到 McpToolUtils 复用 + * 物模型 DO 转 Map + * + * @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 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 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 toMap(IotThingModelDO m) { Map map = new LinkedHashMap<>(); map.put("id", m.getId());