mirror of
https://gitee.com/zhijiantianya/ruoyi-vue-pro.git
synced 2026-03-22 05:07:17 +08:00
feat(iot): 增强 MCP 配置和安全功能,包括API 密钥验证和控制工具日志
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
@@ -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<McpApiKeyAuthenticationFilter> mcpApiKeyAuthenticationFilterRegistration(
|
||||
McpSecurityProperties properties) {
|
||||
IotMcpProperties properties) {
|
||||
FilterRegistrationBean<McpApiKeyAuthenticationFilter> registration = new FilterRegistrationBean<>();
|
||||
registration.setFilter(new McpApiKeyAuthenticationFilter(properties));
|
||||
registration.addUrlPatterns("/sse", "/sse/*", "/mcp/*");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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<String, Object> 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<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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>) () -> {
|
||||
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<String, Object> toMap(IotAlertRecordDO r) {
|
||||
Map<String, Object> m = new LinkedHashMap<>();
|
||||
m.put("id", r.getId());
|
||||
|
||||
@@ -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<String, Object> deviceToMap(IotDeviceDO d, String productName) {
|
||||
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 与 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>) () -> {
|
||||
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<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);
|
||||
// TODO @AI: 当 productMap.get(productId) 为 null 时,记录告警日志(产品已删除但设备仍关联),便于数据一致性检查
|
||||
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());
|
||||
List<Map<String, Object>> 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<String, Object> 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<String>) () -> {
|
||||
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<String>) () -> 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<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 + "\""));
|
||||
}
|
||||
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<String>) () -> {
|
||||
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<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();
|
||||
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<String>) () -> {
|
||||
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()));
|
||||
|
||||
@@ -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<String, Object> productToMap(IotProductDO p) {
|
||||
Map<String, Object> 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<String>) () -> {
|
||||
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<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<>();
|
||||
@@ -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<String>) () -> {
|
||||
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) {
|
||||
|
||||
@@ -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<String>) () -> {
|
||||
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<String, Object> 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<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());
|
||||
|
||||
Reference in New Issue
Block a user