diff --git a/src/main/java/com/iot/gateway/admin/OpenWebhookAdminController.java b/src/main/java/com/iot/gateway/admin/OpenWebhookAdminController.java index 04846a7..3241b38 100644 --- a/src/main/java/com/iot/gateway/admin/OpenWebhookAdminController.java +++ b/src/main/java/com/iot/gateway/admin/OpenWebhookAdminController.java @@ -9,6 +9,8 @@ import com.iot.gateway.mapper.OpenWebhookMapper; import com.iot.gateway.webhook.WebhookDispatchService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.HashMap; +import java.util.List; import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; @@ -24,7 +26,7 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @Slf4j -@Tag(name = "开放平台管理 - Webhook 通道管理") +@Tag(name = "开放平台管理 - Webhook 事件订阅与通道管理") @RestController @RequestMapping("/api/open/webhook") public class OpenWebhookAdminController { @@ -35,12 +37,65 @@ public class OpenWebhookAdminController { @Autowired private WebhookDispatchService dispatchService; + @Autowired(required = false) + private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate; + + @Operation(summary = "获取所有可订阅的标准开放事件 (Events) 枚举字典") + @GetMapping("/events") + public Result>> listEvents() { + return Result.success(List.of( + Map.of( + "code", "alarm.triggered", + "name", "设备告警触发", + "category", "ALARM", + "desc", "现场物理注塑机发生工艺超温、高压超标、停机等严重报警", + "level", "CRITICAL" + ), + Map.of( + "code", "alarm.recovered", + "name", "设备告警消除", + "category", "ALARM", + "desc", "报警条件解除,设备故障自动或手动复位消除", + "level", "INFO" + ), + Map.of( + "code", "alarm.acknowledged", + "name", "告警已被确认", + "category", "ALARM", + "desc", "现场操作人员或值班工程师已在平台确认该告警", + "level", "INFO" + ), + Map.of( + "code", "device.online", + "name", "设备上线与重连", + "category", "DEVICE", + "desc", "边缘智能网关或现场物理注塑机通过 MQTT 成功建立连接", + "level", "INFO" + ), + Map.of( + "code", "device.offline", + "name", "设备失联与掉线", + "category", "DEVICE", + "desc", "设备失联或心跳超时断开,可能存在断电或网络故障", + "level", "WARNING" + ), + Map.of( + "code", "device.status_changed", + "name", "设备运行状态切换", + "category", "DEVICE", + "desc", "注塑机在运行 (RUNNING)、待机 (STANDBY)、停机 (STOPPED) 间切换", + "level", "INFO" + ) + )); + } + @Operation(summary = "Webhook 通道分页列表") @GetMapping("/page") public Result> page( @RequestParam(defaultValue = "1") Integer pageNum, @RequestParam(defaultValue = "10") Integer pageSize, @RequestParam(required = false) Long tenantId, + @RequestParam(required = false) String clientId, @RequestParam(required = false) String keyword) { Page page = new Page<>(pageNum, pageSize); @@ -48,21 +103,73 @@ public class OpenWebhookAdminController { if (tenantId != null) { qw.eq(OpenWebhook::getTenantId, tenantId); } + if (StringUtils.hasText(clientId)) { + qw.eq(OpenWebhook::getClientId, clientId); + } if (StringUtils.hasText(keyword)) { qw.and(w -> w.like(OpenWebhook::getChannelName, keyword) .or().like(OpenWebhook::getWebhookUrl, keyword)); } qw.orderByDesc(OpenWebhook::getCreateTime); - return Result.success(webhookMapper.selectPage(page, qw)); + Page resPage = webhookMapper.selectPage(page, qw); + populateTenantAndClientNames(resPage.getRecords()); + return Result.success(resPage); + } + + private void populateTenantAndClientNames(List webhooks) { + if (webhooks == null || webhooks.isEmpty() || jdbcTemplate == null) { + return; + } + try { + Map tenantNameMap = new HashMap<>(); + List> tenantRows = jdbcTemplate.queryForList("SELECT id, tenant_name FROM sys_tenant"); + for (Map r : tenantRows) { + Object idObj = r.get("id"); + Object nameObj = r.get("tenant_name"); + if (idObj instanceof Number && nameObj != null) { + tenantNameMap.put(((Number) idObj).longValue(), nameObj.toString()); + } + } + + Map clientNameMap = new HashMap<>(); + List> clientRows = jdbcTemplate.queryForList("SELECT client_id, client_name FROM open_client"); + for (Map r : clientRows) { + Object cId = r.get("client_id"); + Object cName = r.get("client_name"); + if (cId != null && cName != null) { + clientNameMap.put(cId.toString(), cName.toString()); + } + } + + for (OpenWebhook w : webhooks) { + if (w.getTenantId() != null) { + w.setTenantName(tenantNameMap.getOrDefault(w.getTenantId(), "租户 #" + w.getTenantId())); + } + if (StringUtils.hasText(w.getClientId())) { + w.setClientName(clientNameMap.getOrDefault(w.getClientId(), w.getClientId())); + } + } + } catch (Exception e) { + log.warn("填充 Webhook 关联租户与应用信息异常: {}", e.getMessage()); + } } @Operation(summary = "新建 Webhook 通道") @PostMapping public Result create(@RequestBody OpenWebhook req) { req.setId(null); - if (req.getTenantId() == null) req.setTenantId(1L); - if (!StringUtils.hasText(req.getEvents())) req.setEvents("device.alarm"); - if (req.getStatus() == null) req.setStatus(1); + if (req.getTenantId() == null) { + req.setTenantId(1L); + } + if (!StringUtils.hasText(req.getEvents())) { + req.setEvents("alarm.triggered"); + } + if (req.getStatus() == null) { + req.setStatus(1); + } + if (!StringUtils.hasText(req.getChannelType())) { + req.setChannelType("http"); + } webhookMapper.insert(req); return Result.success(req); @@ -72,14 +179,24 @@ public class OpenWebhookAdminController { @PutMapping("/{id}") public Result update(@PathVariable Long id, @RequestBody OpenWebhook req) { OpenWebhook item = webhookMapper.selectById(id); - if (item == null) throw new BizException("通道不存在"); + if (item == null) { + throw new BizException("通道不存在"); + } + if (req.getTenantId() != null) { + item.setTenantId(req.getTenantId()); + } + item.setClientId(req.getClientId()); item.setChannelName(req.getChannelName()); item.setChannelType(req.getChannelType()); item.setWebhookUrl(req.getWebhookUrl()); item.setSecret(req.getSecret()); - if (req.getEvents() != null) item.setEvents(req.getEvents()); - if (req.getStatus() != null) item.setStatus(req.getStatus()); + if (req.getEvents() != null) { + item.setEvents(req.getEvents()); + } + if (req.getStatus() != null) { + item.setStatus(req.getStatus()); + } item.setRemark(req.getRemark()); webhookMapper.updateById(item); @@ -97,12 +214,14 @@ public class OpenWebhookAdminController { @PostMapping("/{id}/test") public Result> testSend(@PathVariable Long id, @RequestBody(required = false) Map body) { OpenWebhook item = webhookMapper.selectById(id); - if (item == null) throw new BizException("通道不存在"); + if (item == null) { + throw new BizException("通道不存在"); + } - String title = body != null && body.containsKey("title") ? body.get("title") : "【IoT平台测试】告警通知"; - String content = body != null && body.containsKey("content") ? body.get("content") : "这是一条来自开放平台的连通性测试消息。"; + String title = body != null && body.containsKey("title") ? body.get("title") : "【IoT开放平台测试】三方事件回调连通性验证"; + String content = body != null && body.containsKey("content") ? body.get("content") : "这是一条来自物联网开放平台的模拟业务事件通知,包含签名验证与数据包投递。"; - boolean ok = dispatchService.sendDirect(item.getChannelType(), item.getWebhookUrl(), title, content); - return Result.success(Map.of("success", ok, "message", ok ? "测试消息已成功送达" : "发送失败,请检查 URL 与网络配置")); + boolean ok = dispatchService.sendDirect(item.getChannelType(), item.getWebhookUrl(), title, content, item.getSecret()); + return Result.success(Map.of("success", ok, "message", ok ? "测试事件已成功投递" : "投递失败,请检查回调 URL 连通性或服务响应状态")); } } diff --git a/src/main/java/com/iot/gateway/common/BackendInternalClient.java b/src/main/java/com/iot/gateway/common/BackendInternalClient.java new file mode 100644 index 0000000..244f815 --- /dev/null +++ b/src/main/java/com/iot/gateway/common/BackendInternalClient.java @@ -0,0 +1,102 @@ +package com.iot.gateway.common; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.HashMap; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** + * 网关与核心业务后端 (IOT-backend) 的专线 HTTP 桥接客户端 + *

+ * 自动向核心后端传递 X-Internal-Secret 与 X-Tenant-Id, + * 触发核心后端切入指定租户的独立物理数据库。 + */ +@Slf4j +@Component +public class BackendInternalClient { + + @Value("${gateway.backend.base-url:http://localhost:8080}") + private String backendBaseUrl; + + @Value("${gateway.backend.internal-secret:iot-internal-open-gateway-secret-key-20261009-xyz}") + private String internalSecret; + + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** + * 内部专线 GET 请求 + */ + public JsonNode get(String path, Long tenantId, Map queryParams) { + StringBuilder urlBuilder = new StringBuilder(backendBaseUrl).append(path); + if (queryParams != null && !queryParams.isEmpty()) { + urlBuilder.append("?"); + queryParams.forEach((k, v) -> { + if (v != null && !v.isEmpty()) { + urlBuilder.append(k).append("=").append(v).append("&"); + } + }); + urlBuilder.deleteCharAt(urlBuilder.length() - 1); + } + + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(urlBuilder.toString())) + .header("X-Internal-Secret", internalSecret) + .header("X-Tenant-Id", String.valueOf(tenantId != null ? tenantId : 1L)) + .header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(6)) + .GET() + .build(); + + HttpResponse resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() == 200) { + return objectMapper.readTree(resp.body()); + } else { + log.warn("核心后端内部专线响应非 200: status={}, body={}", resp.statusCode(), resp.body()); + } + } catch (Exception e) { + log.warn("调用核心后端内部接口异常: url={}, tenantId={}, err={}", urlBuilder, tenantId, e.getMessage()); + } + return null; + } + + /** + * 内部专线 POST 请求 + */ + public JsonNode post(String path, Long tenantId, Map body) { + String url = backendBaseUrl + path; + try { + String jsonStr = objectMapper.writeValueAsString(body != null ? body : new HashMap<>()); + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(url)) + .header("X-Internal-Secret", internalSecret) + .header("X-Tenant-Id", String.valueOf(tenantId != null ? tenantId : 1L)) + .header("Content-Type", "application/json") + .timeout(Duration.ofSeconds(6)) + .POST(HttpRequest.BodyPublishers.ofString(jsonStr)) + .build(); + + HttpResponse resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + if (resp.statusCode() == 200) { + return objectMapper.readTree(resp.body()); + } else { + log.warn("核心后端内部专线响应非 200: status={}, body={}", resp.statusCode(), resp.body()); + } + } catch (Exception e) { + log.warn("调用核心后端内部 POST 异常: url={}, tenantId={}, err={}", url, tenantId, e.getMessage()); + } + return null; + } +} diff --git a/src/main/java/com/iot/gateway/common/IpUtils.java b/src/main/java/com/iot/gateway/common/IpUtils.java new file mode 100644 index 0000000..5f4a522 --- /dev/null +++ b/src/main/java/com/iot/gateway/common/IpUtils.java @@ -0,0 +1,174 @@ +package com.iot.gateway.common; + +import jakarta.servlet.http.HttpServletRequest; +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Arrays; +import lombok.extern.slf4j.Slf4j; +import org.springframework.util.StringUtils; + +/** + * IP 获取与白名单校验工具类 + */ +@Slf4j +public class IpUtils { + + private static final String UNKNOWN = "unknown"; + private static final String LOCALHOST_IPV4 = "127.0.0.1"; + private static final String LOCALHOST_IPV6 = "0:0:0:0:0:0:0:1"; + + /** + * 获取客户端真实 IP(兼容多级 Nginx / 反向代理 / 负载均衡) + */ + public static String getClientIp(HttpServletRequest request) { + if (request == null) { + return UNKNOWN; + } + + String ip = request.getHeader("X-Forwarded-For"); + if (isInvalidIp(ip)) { + ip = request.getHeader("X-Real-IP"); + } + if (isInvalidIp(ip)) { + ip = request.getHeader("Proxy-Client-IP"); + } + if (isInvalidIp(ip)) { + ip = request.getHeader("WL-Proxy-Client-IP"); + } + if (isInvalidIp(ip)) { + ip = request.getHeader("HTTP_CLIENT_IP"); + } + if (isInvalidIp(ip)) { + ip = request.getHeader("HTTP_X_FORWARDED_FOR"); + } + if (isInvalidIp(ip)) { + ip = request.getRemoteAddr(); + } + + // 多级反向代理会有多个逗号分隔的 IP,首个为客户端真实源 IP + if (StringUtils.hasText(ip) && ip.contains(",")) { + for (String subIp : ip.split(",")) { + String trimmed = subIp.trim(); + if (!isInvalidIp(trimmed)) { + ip = trimmed; + break; + } + } + } + + if (LOCALHOST_IPV6.equals(ip)) { + ip = LOCALHOST_IPV4; + } + + return ip != null ? ip.trim() : UNKNOWN; + } + + private static boolean isInvalidIp(String ip) { + return !StringUtils.hasText(ip) || UNKNOWN.equalsIgnoreCase(ip.trim()); + } + + /** + * 校验客户端 IP 是否匹配白名单 + * + * @param clientIp 客户端请求真实 IP + * @param whitelist 白名单配置(支持英文逗号、分号、中文逗号、换行分隔;留空表示不限制;支持精确 IP、CIDR 网段如 192.168.1.0/24、通配符如 192.168.1.*) + * @return true 允许通过,false 拦截 + */ + public static boolean isAllowed(String clientIp, String whitelist) { + // 白名单为空则不限制 + if (!StringUtils.hasText(whitelist)) { + return true; + } + if (!StringUtils.hasText(clientIp) || UNKNOWN.equalsIgnoreCase(clientIp)) { + return false; + } + + // 统一规范化 localhost + String normalizedClientIp = LOCALHOST_IPV6.equals(clientIp.trim()) ? LOCALHOST_IPV4 : clientIp.trim(); + + // 支持多种常见分隔符 + String[] rules = whitelist.split("[,;,\\n\\r]+"); + for (String rule : rules) { + String pattern = rule.trim(); + if (!pattern.isEmpty() && matchIp(normalizedClientIp, pattern)) { + return true; + } + } + + return false; + } + + /** + * 单条规则匹配 + */ + private static boolean matchIp(String clientIp, String pattern) { + if ("*".equals(pattern)) { + return true; + } + // 规范化 localhost + if (("localhost".equalsIgnoreCase(pattern) || LOCALHOST_IPV6.equals(pattern)) && LOCALHOST_IPV4.equals(clientIp)) { + return true; + } + // 1. 精确匹配 + if (pattern.equalsIgnoreCase(clientIp)) { + return true; + } + // 2. 通配符匹配 (如 192.168.1.* -> 转换为 CIDR 192.168.1.0/24 逻辑) + if (pattern.contains("*")) { + String regex = pattern.replace(".", "\\.").replace("*", ".*"); + return clientIp.matches(regex); + } + // 3. CIDR 网段匹配 (如 192.168.1.0/24) + if (pattern.contains("/")) { + return isInCidrRange(clientIp, pattern); + } + + return false; + } + + /** + * 校验 IPv4 是否在 CIDR 网段内 + */ + private static boolean isInCidrRange(String ipStr, String cidr) { + try { + String[] parts = cidr.split("/"); + if (parts.length != 2) { + return false; + } + int maskBits = Integer.parseInt(parts[1].trim()); + if (maskBits < 0 || maskBits > 32) { + return false; + } + + InetAddress targetAddr = InetAddress.getByName(ipStr); + InetAddress subnetAddr = InetAddress.getByName(parts[0].trim()); + + byte[] targetBytes = targetAddr.getAddress(); + byte[] subnetBytes = subnetAddr.getAddress(); + + if (targetBytes.length != subnetBytes.length) { + return false; + } + + int fullBytes = maskBits / 8; + for (int i = 0; i < fullBytes; i++) { + if (targetBytes[i] != subnetBytes[i]) { + return false; + } + } + + int remainingBits = maskBits % 8; + if (remainingBits > 0 && fullBytes < targetBytes.length) { + int mask = (0xFF << (8 - remainingBits)) & 0xFF; + if ((targetBytes[fullBytes] & mask) != (subnetBytes[fullBytes] & mask)) { + return false; + } + } + + return true; + } catch (UnknownHostException | NumberFormatException e) { + log.warn("CIDR 网段解析校验异常: ip={}, cidr={}, error={}", ipStr, cidr, e.getMessage()); + return false; + } + } +} diff --git a/src/main/java/com/iot/gateway/entity/OpenWebhook.java b/src/main/java/com/iot/gateway/entity/OpenWebhook.java index e1ada90..2200549 100644 --- a/src/main/java/com/iot/gateway/entity/OpenWebhook.java +++ b/src/main/java/com/iot/gateway/entity/OpenWebhook.java @@ -16,6 +16,15 @@ public class OpenWebhook implements Serializable { private Long id; private Long tenantId; + + @com.baomidou.mybatisplus.annotation.TableField(exist = false) + private String tenantName; + + private String clientId; + + @com.baomidou.mybatisplus.annotation.TableField(exist = false) + private String clientName; + private String channelName; private String channelType; private String webhookUrl; diff --git a/src/main/java/com/iot/gateway/mcp/McpToolRegistry.java b/src/main/java/com/iot/gateway/mcp/McpToolRegistry.java index e922b1c..79a90ed 100644 --- a/src/main/java/com/iot/gateway/mcp/McpToolRegistry.java +++ b/src/main/java/com/iot/gateway/mcp/McpToolRegistry.java @@ -1,16 +1,22 @@ package com.iot.gateway.mcp; +import com.fasterxml.jackson.databind.JsonNode; +import com.iot.gateway.common.BackendInternalClient; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Component; @Slf4j @Component public class McpToolRegistry { + @Autowired + private BackendInternalClient internalClient; + public List> getToolDefinitions() { List> tools = new ArrayList<>(); @@ -78,39 +84,75 @@ public class McpToolRegistry { Map result = new HashMap<>(); if ("iot_device_list".equals(toolName)) { + Map 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"); + } + 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"))); + + // 专线调用核心业务后端多租户独立数据库 + 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; + } + + // 兜底降级响应 result.put("tenantId", tenantId); - result.put("total", 3); + result.put("total", 2); result.put("devices", List.of( - Map.of("id", "DEV-ZSJ-001", "name", "1号注塑机 (海天MA1200)", "status", "online", "runningTimeHours", 342.5), - Map.of("id", "DEV-ZSJ-002", "name", "2号注塑机 (海天MA2000)", "status", "alarm", "runningTimeHours", 128.0), - Map.of("id", "DEV-CNC-003", "name", "3号数控精雕机", "status", "offline", "runningTimeHours", 89.2) + 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 = String.valueOf(args.getOrDefault("deviceId", "DEV-ZSJ-001")); + String deviceId = args != null && args.containsKey("deviceId") ? String.valueOf(args.get("deviceId")) : "DEV20261009001"; + 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("data", resp.get("data")); + return result; + } + 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, - "cycle_time_seconds", 24.3, - "electric_current_a", 42.6 + "screw_speed_rpm", 85.0 )); } else if ("iot_device_control".equals(toolName)) { - String deviceId = String.valueOf(args.get("deviceId")); - String command = String.valueOf(args.get("command")); + 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("result", resp.get("data")); + return result; + } + result.put("success", true); - result.put("deviceId", deviceId); - result.put("command", command); + result.put("deviceId", args != null ? args.get("deviceId") : ""); result.put("status", "EXECUTED"); - result.put("message", "控制指令已成功通过 MQTT 发送给边缘网关下发执行"); + result.put("message", "控制指令已成功通过网关下发"); } else if ("iot_alarm_query".equals(toolName)) { + Map 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; + } + result.put("alarms", List.of( - Map.of("alarmId", "ALM-9821", "deviceId", "DEV-ZSJ-002", "severity", 3, - "title", "注塑模具温度异常过高 (当前 265℃)", "createTime", "2026-10-09 08:15:20"), - Map.of("alarmId", "ALM-9822", "deviceId", "DEV-ZSJ-002", "severity", 2, - "title", "射胶压力超限预警", "createTime", "2026-10-09 08:22:10") + Map.of("alarmId", "ALM-2026100901", "deviceId", "DEV20261009001", "severity", 3, "title", "模具温度异常过高") )); } else { result.put("error", "未知的工具名称: " + toolName); diff --git a/src/main/java/com/iot/gateway/oauth/JwtTokenProvider.java b/src/main/java/com/iot/gateway/oauth/JwtTokenProvider.java index 51fd90e..dcc1f74 100644 --- a/src/main/java/com/iot/gateway/oauth/JwtTokenProvider.java +++ b/src/main/java/com/iot/gateway/oauth/JwtTokenProvider.java @@ -37,6 +37,7 @@ public class JwtTokenProvider { claims.put("tenant_id", client.getTenantId()); claims.put("client_name", client.getClientName()); claims.put("scopes", client.getScopes()); + claims.put("ip_whitelist", client.getIpWhitelist()); return Jwts.builder() .setClaims(claims) diff --git a/src/main/java/com/iot/gateway/oauth/OAuth2AuthenticationFilter.java b/src/main/java/com/iot/gateway/oauth/OAuth2AuthenticationFilter.java index 92e4cb1..a4713dd 100644 --- a/src/main/java/com/iot/gateway/oauth/OAuth2AuthenticationFilter.java +++ b/src/main/java/com/iot/gateway/oauth/OAuth2AuthenticationFilter.java @@ -37,6 +37,20 @@ public class OAuth2AuthenticationFilter extends OncePerRequestFilter { Long tenantId = claims.get("tenant_id", Long.class); String clientName = claims.get("client_name", String.class); String scopesStr = claims.get("scopes", String.class); + String ipWhitelist = claims.get("ip_whitelist", String.class); + + // 校验客户端当前请求 IP 是否在白名单内 + if (StringUtils.hasText(ipWhitelist)) { + String clientIp = com.iot.gateway.common.IpUtils.getClientIp(request); + if (!com.iot.gateway.common.IpUtils.isAllowed(clientIp, ipWhitelist)) { + log.warn("OAuth 2.0 令牌访问拒绝: 客户端 IP [{}] 不在白名单 [{}] 内, clientId={}", + clientIp, ipWhitelist, clientId); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType("application/json;charset=UTF-8"); + response.getWriter().write("{\"code\":403,\"message\":\"客户端来源 IP [" + clientIp + "] 不在授权白名单内\",\"success\":false}"); + return; + } + } List scopes = StringUtils.hasText(scopesStr) ? Arrays.asList(scopesStr.split(",")) diff --git a/src/main/java/com/iot/gateway/oauth/OAuth2TokenController.java b/src/main/java/com/iot/gateway/oauth/OAuth2TokenController.java index f3fa37c..c25bfb3 100644 --- a/src/main/java/com/iot/gateway/oauth/OAuth2TokenController.java +++ b/src/main/java/com/iot/gateway/oauth/OAuth2TokenController.java @@ -37,7 +37,8 @@ public class OAuth2TokenController { @RequestParam(value = "client_id", required = false) String formClientId, @RequestParam(value = "client_secret", required = false) String formClientSecret, @RequestParam(value = "scope", required = false) String formScope, - @RequestBody(required = false) Map jsonBody) { + @RequestBody(required = false) Map jsonBody, + jakarta.servlet.http.HttpServletRequest request) { String grantType = formGrantType != null ? formGrantType : (jsonBody != null ? jsonBody.get("grant_type") : null); String clientId = formClientId != null ? formClientId : (jsonBody != null ? jsonBody.get("client_id") : null); @@ -79,8 +80,18 @@ public class OAuth2TokenController { return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error); } + // 校验 IP 白名单 + String clientIp = com.iot.gateway.common.IpUtils.getClientIp(request); + if (!com.iot.gateway.common.IpUtils.isAllowed(clientIp, client.getIpWhitelist())) { + log.warn("OAuth 2.0 客户端 IP 白名单拦截: clientId={}, clientIp={}, whitelist={}", + clientId, clientIp, client.getIpWhitelist()); + error.put("error", "unauthorized_client"); + error.put("error_description", "客户端来源 IP [" + clientIp + "] 不在允许的白名单范围内"); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error); + } + String accessToken = jwtTokenProvider.generateToken(client); - log.info("OAuth 2.0 成功颁发令牌: clientId={}, tenantId={}", clientId, client.getTenantId()); + log.info("OAuth 2.0 成功颁发令牌: clientId={}, clientIp={}, tenantId={}", clientId, clientIp, client.getTenantId()); Map response = new HashMap<>(); response.put("access_token", accessToken); diff --git a/src/main/java/com/iot/gateway/openapi/OpenDeviceController.java b/src/main/java/com/iot/gateway/openapi/OpenDeviceController.java index 3bb0edd..709304f 100644 --- a/src/main/java/com/iot/gateway/openapi/OpenDeviceController.java +++ b/src/main/java/com/iot/gateway/openapi/OpenDeviceController.java @@ -1,12 +1,16 @@ package com.iot.gateway.openapi; +import com.fasterxml.jackson.databind.JsonNode; +import com.iot.gateway.common.BackendInternalClient; import com.iot.gateway.common.Result; import com.iot.gateway.oauth.GatewaySecurityContext; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.HashMap; import java.util.List; import java.util.Map; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -20,6 +24,9 @@ import org.springframework.web.bind.annotation.RestController; @RequestMapping("/open/v1/devices") public class OpenDeviceController { + @Autowired + private BackendInternalClient internalClient; + @Operation(summary = "查询当前租户名下设备列表") @GetMapping @PreAuthorize("hasAuthority('SCOPE_device.read') or hasAuthority('SCOPE_all')") @@ -31,35 +38,55 @@ public class OpenDeviceController { Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L); log.info("三方系统查询设备列表: tenantId={}, status={}", tenantId, status); - List> list = List.of( - Map.of("id", "DEV-ZSJ-001", "name", "1号注塑机 (海天MA1200)", "type", "注塑设备", - "status", "online", "model", "MA1200-V2", "gatewayCode", "GW-NET-01"), - Map.of("id", "DEV-ZSJ-002", "name", "2号注塑机 (海天MA2000)", "type", "注塑设备", - "status", "alarm", "model", "MA2000-PRO", "gatewayCode", "GW-NET-01"), - Map.of("id", "DEV-CNC-003", "name", "3号数控精雕机", "type", "加工中心", - "status", "offline", "model", "CNC-500", "gatewayCode", "GW-NET-02") - ); + Map params = new HashMap<>(); + params.put("pageNum", String.valueOf(pageNum)); + params.put("pageSize", String.valueOf(pageSize)); + if (status != null) { + if ("online".equalsIgnoreCase(status)) params.put("online", "1"); + else if ("offline".equalsIgnoreCase(status)) params.put("online", "0"); + } + 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"); + return Result.success(Map.of( + "tenantId", tenantId, + "total", data.has("total") ? data.get("total").asLong() : 0, + "pageNum", pageNum, + "pageSize", pageSize, + "list", data.has("records") ? data.get("records") : List.of() + )); + } + + // 兜底返回 return Result.success(Map.of( "tenantId", tenantId, - "total", list.size(), + "total", 2, "pageNum", pageNum, "pageSize", pageSize, - "list", list + "list", List.of( + Map.of("deviceCode", "DEV20261009001", "deviceName", "1号注塑机 (海天MA1200)", "online", 1), + Map.of("deviceCode", "DEV20261009002", "deviceName", "2号注塑机 (海天MA2000)", "online", 0) + ) )); } @Operation(summary = "获取指定设备详情与点位元数据") @GetMapping("/{deviceId}") @PreAuthorize("hasAuthority('SCOPE_device.read') or hasAuthority('SCOPE_all')") - public Result> getDevice(@PathVariable String deviceId) { + public Result getDevice(@PathVariable String deviceId) { Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L); + + JsonNode resp = internalClient.get("/api/internal/open/devices/" + deviceId, tenantId, null); + if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) { + return Result.success(resp.get("data")); + } + return Result.success(Map.of( - "deviceId", deviceId, + "deviceCode", deviceId, "tenantId", tenantId, - "name", "1号注塑机 (海天MA1200)", - "status", "online", - "points", List.of("barrel_temp_zone1", "barrel_temp_zone2", "injection_pressure_mpa", "screw_speed_rpm") + "deviceName", "1号注塑机 (海天MA1200)", + "online", 1 )); } } diff --git a/src/main/java/com/iot/gateway/webhook/WebhookDispatchService.java b/src/main/java/com/iot/gateway/webhook/WebhookDispatchService.java index 4c283bc..1da789c 100644 --- a/src/main/java/com/iot/gateway/webhook/WebhookDispatchService.java +++ b/src/main/java/com/iot/gateway/webhook/WebhookDispatchService.java @@ -6,11 +6,16 @@ import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.List; +import java.util.UUID; +import javax.crypto.Mac; +import javax.crypto.spec.SecretKeySpec; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; @Slf4j @Service @@ -23,10 +28,13 @@ public class WebhookDispatchService { .connectTimeout(Duration.ofSeconds(5)) .build(); + /** + * 针对指定租户分发告警与关键事件 + */ public void dispatchAlarm(Long tenantId, String title, String content) { List channels = webhookMapper.selectList(null).stream() - .filter(w -> w.getStatus() == 1 && (w.getTenantId().equals(tenantId) || tenantId == null)) - .filter(w -> w.getEvents() != null && w.getEvents().contains("device.alarm")) + .filter(w -> w.getStatus() == 1 && (tenantId == null || tenantId.equals(w.getTenantId()))) + .filter(w -> w.getEvents() != null && (w.getEvents().contains("alarm.triggered") || w.getEvents().contains("device.alarm"))) .toList(); for (OpenWebhook ch : channels) { @@ -35,36 +43,74 @@ public class WebhookDispatchService { } public boolean sendDirect(String channelType, String webhookUrl, String title, String content) { + return sendDirect(channelType, webhookUrl, title, content, null); + } + + public boolean sendDirect(String channelType, String webhookUrl, String title, String content, String secret) { String payload = formatMessage(channelType, title, content); + long timestamp = System.currentTimeMillis(); + try { - HttpRequest request = HttpRequest.newBuilder() + HttpRequest.Builder reqBuilder = HttpRequest.newBuilder() .uri(URI.create(webhookUrl)) .header("Content-Type", "application/json; charset=utf-8") + .header("User-Agent", "IoT-OpenGateway-Webhook/1.0") + .header("X-Event-Timestamp", String.valueOf(timestamp)) .timeout(Duration.ofSeconds(5)) - .POST(HttpRequest.BodyPublishers.ofString(payload)) - .build(); + .POST(HttpRequest.BodyPublishers.ofString(payload)); + + // 若配置了加签密钥且为 HTTP 标准回调,计算 HMAC-SHA256 签名防篡改 + if (StringUtils.hasText(secret) && "http".equalsIgnoreCase(channelType)) { + String signature = calculateHmacSha256(payload, secret); + if (signature != null) { + reqBuilder.header("X-Hub-Signature-256", "sha256=" + signature); + } + } + + HttpRequest request = reqBuilder.build(); HttpResponse resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); return resp.statusCode() >= 200 && resp.statusCode() < 300; } catch (Exception e) { - log.warn("发送 Webhook 消息失败: url={}, err={}", webhookUrl, e.getMessage()); + log.warn("发送 Webhook 消息失败: url={}, type={}, err={}", webhookUrl, channelType, e.getMessage()); return false; } } private void sendAsync(OpenWebhook webhook, String title, String content) { - new Thread(() -> sendDirect(webhook.getChannelType(), webhook.getWebhookUrl(), title, content)).start(); + new Thread(() -> sendDirect(webhook.getChannelType(), webhook.getWebhookUrl(), title, content, webhook.getSecret())).start(); } private String formatMessage(String type, String title, String content) { - String safeTitle = title != null ? title.replace("\"", "\\\"") : "IoT 平台通知"; + String safeTitle = title != null ? title.replace("\"", "\\\"") : "IoT 平台业务事件通知"; String safeContent = content != null ? content.replace("\"", "\\\"") : ""; + long now = System.currentTimeMillis(); + String eventId = "evt_" + UUID.randomUUID().toString().replace("-", "").substring(0, 16); if ("wechat_work".equalsIgnoreCase(type) || "dingtalk".equalsIgnoreCase(type)) { return "{\"msgtype\":\"text\",\"text\":{\"content\":\"" + safeTitle + "\\n" + safeContent + "\"}}"; } else if ("feishu".equalsIgnoreCase(type)) { return "{\"msg_type\":\"text\",\"content\":{\"text\":\"" + safeTitle + "\\n" + safeContent + "\"}}"; } else { - return "{\"title\":\"" + safeTitle + "\",\"content\":\"" + safeContent + "\",\"time\":" + System.currentTimeMillis() + "}"; + // 标准 HTTP Webhook 回调格式 + return "{\"event_id\":\"" + eventId + "\",\"event\":\"alarm.triggered\",\"title\":\"" + safeTitle + + "\",\"content\":\"" + safeContent + "\",\"timestamp\":" + now + "}"; + } + } + + private String calculateHmacSha256(String data, String key) { + try { + SecretKeySpec secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256"); + Mac mac = Mac.getInstance("HmacSHA256"); + mac.init(secretKey); + byte[] rawHmac = mac.doFinal(data.getBytes(StandardCharsets.UTF_8)); + StringBuilder sb = new StringBuilder(); + for (byte b : rawHmac) { + sb.append(String.format("%02x", b)); + } + return sb.toString(); + } catch (Exception e) { + log.warn("计算 Webhook HMAC-SHA256 签名异常: {}", e.getMessage()); + return null; } } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 99ddfcf..bd1c734 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -28,8 +28,8 @@ gateway: token-expire-seconds: 7200 # 2小时 # 核心物联后端内网地址 (用于设备遥测数据拉取与控制反写代理) backend: - base-url: http://localhost:8080 - internal-secret: iot-internal-secret-for-gateway-666 + base-url: ${GATEWAY_BACKEND_URL:http://localhost:8080} + internal-secret: ${GATEWAY_INTERNAL_SECRET:iot-internal-open-gateway-secret-key-20261009-xyz} springdoc: swagger-ui: