refactor: 网关消除魔法值,scope/事件/请求头/MCP 协议等改为枚举与常量

- 新增 ScopeMatcher、GatewayAuthorities、JwtClaims、GatewayHeaders、InternalPaths、McpProtocol/McpToolArgs 等常量与枚举
- Webhook 消息体改用 ObjectMapper 构建,修复原先仅转义引号的问题
- 管理接口与 OpenAPI 控制器的默认值、响应字段、演示数据集中到 AdminDefaults / OpenApiFields

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wjh
2026-10-10 16:27:53 +08:00
co-authored by Claude Sonnet 5
parent 33269da935
commit 761636c2c5
45 changed files with 1557 additions and 654 deletions
@@ -2,7 +2,13 @@ package com.iot.gateway.mcp;
import com.fasterxml.jackson.databind.JsonNode;
import com.iot.gateway.common.BackendInternalClient;
import com.iot.gateway.common.BackendResponse;
import com.iot.gateway.common.BizException;
import com.iot.gateway.common.InternalPaths;
import com.iot.gateway.common.OnlineStatusEnum;
import com.iot.gateway.oauth.OpenScopeEnum;
import com.iot.gateway.oauth.ScopeMatcher;
import com.iot.gateway.openapi.CommandResultEnum;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -10,12 +16,32 @@ import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class McpToolRegistry {
/** JSON Schema 关键字 */
private static final String SCHEMA_TYPE = "type";
private static final String SCHEMA_DESCRIPTION = "description";
private static final String SCHEMA_PROPERTIES = "properties";
private static final String SCHEMA_REQUIRED = "required";
private static final String TYPE_OBJECT = "object";
private static final String TYPE_STRING = "string";
private static final String TYPE_INTEGER = "integer";
/** MCP 工具描述的字段名 */
private static final String TOOL_NAME = "name";
private static final String TOOL_DESCRIPTION = "description";
private static final String TOOL_INPUT_SCHEMA = "inputSchema";
/** 开发演示模式下的模拟数据 */
private static final String MOCK_DEVICE_CODE_1 = "DEV20261009001";
private static final String MOCK_DEVICE_CODE_2 = "DEV20261009002";
private static final int MOCK_ALARM_SEVERITY = 3;
@Autowired
private BackendInternalClient internalClient;
@@ -25,63 +51,56 @@ public class McpToolRegistry {
@Value("${gateway.mock.enabled:false}")
private boolean mockEnabled;
private static Map<String, Object> field(String type, String description) {
return Map.of(SCHEMA_TYPE, type, SCHEMA_DESCRIPTION, description);
}
private static Map<String, Object> tool(McpToolEnum toolEnum, String description,
Map<String, Object> properties, List<String> required) {
Map<String, Object> schema = new HashMap<>();
schema.put(SCHEMA_TYPE, TYPE_OBJECT);
schema.put(SCHEMA_PROPERTIES, properties);
if (required != null) {
schema.put(SCHEMA_REQUIRED, required);
}
Map<String, Object> tool = new HashMap<>();
tool.put(TOOL_NAME, toolEnum.getToolName());
tool.put(TOOL_DESCRIPTION, description + "所需权限范围: " + toolEnum.getRequiredScope().getCode());
tool.put(TOOL_INPUT_SCHEMA, schema);
return tool;
}
public List<Map<String, Object>> getToolDefinitions() {
List<Map<String, Object>> tools = new ArrayList<>();
// 1. iot_device_list
Map<String, Object> t1 = new HashMap<>();
t1.put("name", "iot_device_list");
t1.put("description", "查询当前租户名下的所有工业物联网设备台账与实时运行状态(在线/报警/停机)。所需权限范围: device.read");
Map<String, Object> s1 = new HashMap<>();
s1.put("type", "object");
Map<String, Object> p1 = new HashMap<>();
p1.put("status", Map.of("type", "string", "description", "设备状态过滤: online/offline/alarm,默认全部"));
p1.put("pageNum", Map.of("type", "integer", "description", "分页页码,默认 1"));
p1.put("pageSize", Map.of("type", "integer", "description", "每页条数,默认 10"));
s1.put("properties", p1);
t1.put("inputSchema", s1);
tools.add(t1);
Map<String, Object> deviceListProps = new HashMap<>();
deviceListProps.put(McpToolArgs.STATUS, field(TYPE_STRING, "设备状态过滤: online/offline/alarm,默认全部"));
deviceListProps.put(McpToolArgs.PAGE_NUM, field(TYPE_INTEGER, "分页页码,默认 1"));
deviceListProps.put(McpToolArgs.PAGE_SIZE, field(TYPE_INTEGER, "每页条数,默认 10"));
tools.add(tool(McpToolEnum.DEVICE_LIST,
"查询当前租户名下的所有工业物联网设备台账与实时运行状态(在线/报警/停机)。",
deviceListProps, null));
// 2. iot_telemetry_latest
Map<String, Object> t2 = new HashMap<>();
t2.put("name", "iot_telemetry_latest");
t2.put("description", "召测指定工业设备的最新实时点位遥测数据(注塑机温度、注塑压力、螺杆转速、能耗电流等)。所需权限范围: telemetry.read");
Map<String, Object> s2 = new HashMap<>();
s2.put("type", "object");
Map<String, Object> p2 = new HashMap<>();
p2.put("deviceId", Map.of("type", "string", "description", "设备唯一标识编码或编号"));
s2.put("properties", p2);
s2.put("required", List.of("deviceId"));
t2.put("inputSchema", s2);
tools.add(t2);
Map<String, Object> telemetryProps = new HashMap<>();
telemetryProps.put(McpToolArgs.DEVICE_ID, field(TYPE_STRING, "设备唯一标识编码或编号"));
tools.add(tool(McpToolEnum.TELEMETRY_LATEST,
"召测指定工业设备的最新实时点位遥测数据(注塑机温度、注塑压力、螺杆转速、能耗电流等)。",
telemetryProps, List.of(McpToolArgs.DEVICE_ID)));
// 3. iot_device_control
Map<String, Object> t3 = new HashMap<>();
t3.put("name", "iot_device_control");
t3.put("description", "向边缘物联设备安全下发反写控制指令(如设定注塑温度、设备启停控制、工艺参数微调)。所需权限范围: device.control");
Map<String, Object> s3 = new HashMap<>();
s3.put("type", "object");
Map<String, Object> p3 = new HashMap<>();
p3.put("deviceId", Map.of("type", "string", "description", "目标设备编码"));
p3.put("command", Map.of("type", "string", "description", "指令动作: start/stop/reboot/set_param"));
p3.put("params", Map.of("type", "object", "description", "指令参数键值对"));
s3.put("properties", p3);
s3.put("required", List.of("deviceId", "command"));
t3.put("inputSchema", s3);
tools.add(t3);
Map<String, Object> controlProps = new HashMap<>();
controlProps.put(McpToolArgs.DEVICE_ID, field(TYPE_STRING, "目标设备编码"));
controlProps.put(McpToolArgs.COMMAND, field(TYPE_STRING, "指令动作: start/stop/reboot/set_param"));
controlProps.put(McpToolArgs.PARAMS, field(TYPE_OBJECT, "指令参数键值对"));
tools.add(tool(McpToolEnum.DEVICE_CONTROL,
"向边缘物联设备安全下发反写控制指令(如设定注塑温度、设备启停控制、工艺参数微调)。",
controlProps, List.of(McpToolArgs.DEVICE_ID, McpToolArgs.COMMAND)));
// 4. iot_alarm_query
Map<String, Object> t4 = new HashMap<>();
t4.put("name", "iot_alarm_query");
t4.put("description", "按设备与时间范围检索未处理的设备故障与历史严重告警记录。所需权限范围: alarm.read");
Map<String, Object> s4 = new HashMap<>();
s4.put("type", "object");
Map<String, Object> p4 = new HashMap<>();
p4.put("deviceId", Map.of("type", "string", "description", "设备编码(可选)"));
p4.put("severity", Map.of("type", "integer", "description", "严重级别: 1提示, 2次要, 3严重, 4紧急"));
s4.put("properties", p4);
t4.put("inputSchema", s4);
tools.add(t4);
Map<String, Object> alarmProps = new HashMap<>();
alarmProps.put(McpToolArgs.DEVICE_ID, field(TYPE_STRING, "设备编码(可选)"));
alarmProps.put(McpToolArgs.SEVERITY, field(TYPE_INTEGER, "严重级别: 1提示, 2次要, 3严重, 4紧急"));
tools.add(tool(McpToolEnum.ALARM_QUERY,
"按设备与时间范围检索未处理的设备故障与历史严重告警记录。",
alarmProps, null));
return tools;
}
@@ -95,153 +114,154 @@ public class McpToolRegistry {
// 1. 严格租户隔离校验
if (tenantId == null) {
throw new BizException(401, "MCP 工具执行拒绝: 缺少有效的租户上下文,严禁匿名或跨租户执行");
throw new BizException(HttpStatus.UNAUTHORIZED.value(), "MCP 工具执行拒绝: 缺少有效的租户上下文,严禁匿名或跨租户执行");
}
// 2. 细粒度 Scope 校验
checkToolScope(toolName, scopes, isAdmin);
McpToolEnum tool = McpToolEnum.ofName(toolName);
checkToolScope(toolName, tool, scopes, isAdmin);
if (tool == null) {
throw new BizException(HttpStatus.BAD_REQUEST.value(), "未知的 MCP 工具名称: " + toolName);
}
Map<String, Object> result = new HashMap<>();
if ("iot_device_list".equals(toolName)) {
Map<String, String> params = new HashMap<>();
if (args != null && args.containsKey("status")) {
String st = String.valueOf(args.get("status"));
if ("online".equalsIgnoreCase(st)) params.put("online", "1");
else if ("offline".equalsIgnoreCase(st)) params.put("online", "0");
switch (tool) {
case DEVICE_LIST -> {
Map<String, String> params = new HashMap<>();
if (args != null && args.containsKey(McpToolArgs.STATUS)) {
OnlineStatusEnum onlineFilter = OnlineStatusEnum.ofFilter(String.valueOf(args.get(McpToolArgs.STATUS)));
if (onlineFilter != null) {
params.put(OnlineStatusEnum.PARAM_ONLINE, String.valueOf(onlineFilter.getCode()));
}
}
copyArg(args, params, McpToolArgs.PAGE_NUM);
copyArg(args, params, McpToolArgs.PAGE_SIZE);
// 专线调用核心业务后端多租户独立数据库
JsonNode resp = internalClient.get(InternalPaths.DEVICES, tenantId, params);
if (BackendResponse.isSuccess(resp)) {
JsonNode data = BackendResponse.data(resp);
result.put(McpToolArgs.RESULT_TENANT_ID, tenantId);
result.put(McpToolArgs.RESULT_TOTAL, BackendResponse.total(data));
result.put(McpToolArgs.RESULT_DEVICES, BackendResponse.records(data));
return result;
}
// 非 mock 模式下直接明确报错
if (!mockEnabled) {
String errMsg = BackendResponse.message(resp, "核心物联后端服务不可用或查询超时");
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "召测设备列表失败: " + errMsg);
}
// 开发演示兜底
result.put(McpToolArgs.RESULT_TENANT_ID, tenantId);
result.put(McpToolArgs.RESULT_IS_MOCK, true);
result.put(McpToolArgs.RESULT_TOTAL, 2);
result.put(McpToolArgs.RESULT_DEVICES, List.of(
Map.of("deviceCode", MOCK_DEVICE_CODE_1, "deviceName", "1号注塑机 (海天MA1200)", OnlineStatusEnum.PARAM_ONLINE, OnlineStatusEnum.ONLINE.getCode()),
Map.of("deviceCode", MOCK_DEVICE_CODE_2, "deviceName", "2号注塑机 (海天MA2000)", OnlineStatusEnum.PARAM_ONLINE, OnlineStatusEnum.OFFLINE.getCode())
));
}
if (args != null && args.containsKey("pageNum")) params.put("pageNum", String.valueOf(args.get("pageNum")));
if (args != null && args.containsKey("pageSize")) params.put("pageSize", String.valueOf(args.get("pageSize")));
case TELEMETRY_LATEST -> {
String deviceId = args != null && args.containsKey(McpToolArgs.DEVICE_ID)
? String.valueOf(args.get(McpToolArgs.DEVICE_ID)) : "";
if (deviceId.isEmpty()) {
throw new BizException(HttpStatus.BAD_REQUEST.value(), "缺少必填参数: " + McpToolArgs.DEVICE_ID);
}
// 专线调用核心业务后端多租户独立数据库
JsonNode resp = internalClient.get("/api/internal/open/devices", tenantId, params);
if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) {
JsonNode data = resp.get("data");
result.put("tenantId", tenantId);
result.put("total", data.has("total") ? data.get("total").asLong() : 0);
result.put("devices", data.has("records") ? data.get("records") : List.of());
return result;
JsonNode resp = internalClient.get(InternalPaths.TELEMETRY_LATEST, tenantId,
Map.of(McpToolArgs.DEVICE_ID, deviceId));
if (BackendResponse.isSuccess(resp)) {
result.put(McpToolArgs.RESULT_TENANT_ID, tenantId);
result.put(McpToolArgs.DEVICE_ID, deviceId);
result.put(McpToolArgs.RESULT_DATA, BackendResponse.data(resp));
return result;
}
if (!mockEnabled) {
String errMsg = BackendResponse.message(resp, "后端遥测接口不可用或设备无数据");
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "召测设备遥测失败: " + errMsg);
}
result.put(McpToolArgs.RESULT_IS_MOCK, true);
result.put(McpToolArgs.DEVICE_ID, deviceId);
result.put(McpToolArgs.RESULT_TIMESTAMP, System.currentTimeMillis());
result.put(McpToolArgs.RESULT_TELEMETRY, Map.of(
"barrel_temp_zone1", 215.4,
"barrel_temp_zone2", 220.1,
"injection_pressure_mpa", 13.8,
"screw_speed_rpm", 85.0
));
}
case DEVICE_CONTROL -> {
// 控制指令下发:绝不能在后端失败时误报“EXECUTED”
JsonNode resp = internalClient.post(InternalPaths.COMMANDS, tenantId, args);
if (BackendResponse.isSuccess(resp)) {
result.put(McpToolArgs.RESULT_TENANT_ID, tenantId);
result.put(McpToolArgs.RESULT_SUCCESS, true);
result.put(McpToolArgs.RESULT_STATUS, CommandResultEnum.EXECUTED.getCode());
result.put(McpToolArgs.RESULT_DATA, BackendResponse.data(resp));
return result;
}
// 非 mock 模式下直接明确报错
if (!mockEnabled) {
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "核心物联后端服务不可用或查询超时";
throw new BizException(502, "召测设备列表失败: " + errMsg);
String errMsg = BackendResponse.message(resp, "后端指令网关通信超时或未响应");
if (!mockEnabled) {
log.error("下发设备控制指令失败: tool={}, tenantId={}, args={}, err={}", toolName, tenantId, args, errMsg);
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "控制指令执行失败: " + errMsg);
}
result.put(McpToolArgs.RESULT_IS_MOCK, true);
result.put(McpToolArgs.RESULT_SUCCESS, false);
result.put(McpToolArgs.RESULT_STATUS, CommandResultEnum.MOCK_MODE_REFUSED.getCode());
result.put(McpToolArgs.RESULT_MESSAGE, "当前为开发演示模式,已拒绝向物理设备真实下发控制指令: " + errMsg);
}
case ALARM_QUERY -> {
Map<String, String> params = new HashMap<>();
copyArg(args, params, McpToolArgs.DEVICE_ID);
copyArg(args, params, McpToolArgs.SEVERITY);
// 开发演示兜底
result.put("tenantId", tenantId);
result.put("isMock", true);
result.put("total", 2);
result.put("devices", List.of(
Map.of("deviceCode", "DEV20261009001", "deviceName", "1号注塑机 (海天MA1200)", "online", 1),
Map.of("deviceCode", "DEV20261009002", "deviceName", "2号注塑机 (海天MA2000)", "online", 0)
));
} else if ("iot_telemetry_latest".equals(toolName)) {
String deviceId = args != null && args.containsKey("deviceId") ? String.valueOf(args.get("deviceId")) : "";
if (deviceId.isEmpty()) {
throw new BizException(400, "缺少必填参数: deviceId");
JsonNode resp = internalClient.get(InternalPaths.ALARMS, tenantId, params);
if (BackendResponse.isSuccess(resp)) {
result.put(McpToolArgs.RESULT_TENANT_ID, tenantId);
result.put(McpToolArgs.RESULT_ALARMS, BackendResponse.data(resp));
return result;
}
if (!mockEnabled) {
String errMsg = BackendResponse.message(resp, "后端告警接口不可用");
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "查询告警失败: " + errMsg);
}
result.put(McpToolArgs.RESULT_IS_MOCK, true);
result.put(McpToolArgs.RESULT_ALARMS, List.of(
Map.of("alarmId", "ALM-2026100901", McpToolArgs.DEVICE_ID, MOCK_DEVICE_CODE_1,
McpToolArgs.SEVERITY, MOCK_ALARM_SEVERITY, "title", "模具温度异常过高")
));
}
JsonNode resp = internalClient.get("/api/internal/open/telemetry/latest", tenantId, Map.of("deviceId", deviceId));
if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) {
result.put("tenantId", tenantId);
result.put("deviceId", deviceId);
result.put("data", resp.get("data"));
return result;
}
if (!mockEnabled) {
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "后端遥测接口不可用或设备无数据";
throw new BizException(502, "召测设备遥测失败: " + errMsg);
}
result.put("isMock", true);
result.put("deviceId", deviceId);
result.put("timestamp", System.currentTimeMillis());
result.put("telemetry", Map.of(
"barrel_temp_zone1", 215.4,
"barrel_temp_zone2", 220.1,
"injection_pressure_mpa", 13.8,
"screw_speed_rpm", 85.0
));
} else if ("iot_device_control".equals(toolName)) {
// 控制指令下发:绝不能在后端失败时误报“EXECUTED”
JsonNode resp = internalClient.post("/api/internal/open/commands", tenantId, args);
if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) {
result.put("tenantId", tenantId);
result.put("success", true);
result.put("status", "EXECUTED");
result.put("data", resp.get("data"));
return result;
}
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "后端指令网关通信超时或未响应";
if (!mockEnabled) {
log.error("下发设备控制指令失败: tool={}, tenantId={}, args={}, err={}", toolName, tenantId, args, errMsg);
throw new BizException(502, "控制指令执行失败: " + errMsg);
}
result.put("isMock", true);
result.put("success", false);
result.put("status", "MOCK_MODE_REFUSED");
result.put("message", "当前为开发演示模式,已拒绝向物理设备真实下发控制指令: " + errMsg);
} else if ("iot_alarm_query".equals(toolName)) {
Map<String, String> params = new HashMap<>();
if (args != null && args.containsKey("deviceId")) params.put("deviceId", String.valueOf(args.get("deviceId")));
if (args != null && args.containsKey("severity")) params.put("severity", String.valueOf(args.get("severity")));
JsonNode resp = internalClient.get("/api/internal/open/alarms", tenantId, params);
if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) {
result.put("tenantId", tenantId);
result.put("alarms", resp.get("data"));
return result;
}
if (!mockEnabled) {
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "后端告警接口不可用";
throw new BizException(502, "查询告警失败: " + errMsg);
}
result.put("isMock", true);
result.put("alarms", List.of(
Map.of("alarmId", "ALM-2026100901", "deviceId", "DEV20261009001", "severity", 3, "title", "模具温度异常过高")
));
} else {
throw new BizException(400, "未知的 MCP 工具名称: " + toolName);
}
return result;
}
private void checkToolScope(String toolName, List<String> scopes, boolean isAdmin) {
private static void copyArg(Map<String, Object> args, Map<String, String> params, String key) {
if (args != null && args.containsKey(key)) {
params.put(key, String.valueOf(args.get(key)));
}
}
/** 管理员放行;未知工具要求 all 通配;其余按工具声明的 scope 校验 */
private void checkToolScope(String toolName, McpToolEnum tool, List<String> scopes, boolean isAdmin) {
if (isAdmin) return;
String requiredScope;
switch (toolName) {
case "iot_device_list" -> requiredScope = "device.read";
case "iot_telemetry_latest" -> requiredScope = "telemetry.read";
case "iot_device_control" -> requiredScope = "device.control";
case "iot_alarm_query" -> requiredScope = "alarm.read";
default -> requiredScope = "all";
}
String requiredScope = tool != null ? tool.getRequiredScope().getCode() : ScopeMatcher.ALL;
if (scopes == null || scopes.isEmpty()) {
throw new BizException(403, "权限不足: 客户端未具备任何授权范围,无法调用 [" + toolName + "]");
throw new BizException(HttpStatus.FORBIDDEN.value(), "权限不足: 客户端未具备任何授权范围,无法调用 [" + toolName + "]");
}
String normReq = requiredScope.replace(":", ".");
boolean match = false;
for (String s : scopes) {
String norm = s.trim().replace(":", ".");
if (norm.equals(normReq) || norm.equals("all") || norm.equals("*")) {
match = true;
break;
}
}
if (!match) {
throw new BizException(403, "权限不足: 调用工具 [" + toolName + "] 需要具备 [" + requiredScope + "] 授权范围");
if (!ScopeMatcher.matches(scopes, requiredScope)) {
throw new BizException(HttpStatus.FORBIDDEN.value(), "权限不足: 调用工具 [" + toolName + "] 需要具备 [" + requiredScope + "] 授权范围");
}
}
}