feat: 实现IP白名单严格拦截、三方Webhook多租户事件订阅与MCP专线业务调用
This commit is contained in:
@@ -9,6 +9,8 @@ import com.iot.gateway.mapper.OpenWebhookMapper;
|
|||||||
import com.iot.gateway.webhook.WebhookDispatchService;
|
import com.iot.gateway.webhook.WebhookDispatchService;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
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;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Tag(name = "开放平台管理 - Webhook 通道管理")
|
@Tag(name = "开放平台管理 - Webhook 事件订阅与通道管理")
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/open/webhook")
|
@RequestMapping("/api/open/webhook")
|
||||||
public class OpenWebhookAdminController {
|
public class OpenWebhookAdminController {
|
||||||
@@ -35,12 +37,65 @@ public class OpenWebhookAdminController {
|
|||||||
@Autowired
|
@Autowired
|
||||||
private WebhookDispatchService dispatchService;
|
private WebhookDispatchService dispatchService;
|
||||||
|
|
||||||
|
@Autowired(required = false)
|
||||||
|
private org.springframework.jdbc.core.JdbcTemplate jdbcTemplate;
|
||||||
|
|
||||||
|
@Operation(summary = "获取所有可订阅的标准开放事件 (Events) 枚举字典")
|
||||||
|
@GetMapping("/events")
|
||||||
|
public Result<List<Map<String, Object>>> 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 通道分页列表")
|
@Operation(summary = "Webhook 通道分页列表")
|
||||||
@GetMapping("/page")
|
@GetMapping("/page")
|
||||||
public Result<Page<OpenWebhook>> page(
|
public Result<Page<OpenWebhook>> page(
|
||||||
@RequestParam(defaultValue = "1") Integer pageNum,
|
@RequestParam(defaultValue = "1") Integer pageNum,
|
||||||
@RequestParam(defaultValue = "10") Integer pageSize,
|
@RequestParam(defaultValue = "10") Integer pageSize,
|
||||||
@RequestParam(required = false) Long tenantId,
|
@RequestParam(required = false) Long tenantId,
|
||||||
|
@RequestParam(required = false) String clientId,
|
||||||
@RequestParam(required = false) String keyword) {
|
@RequestParam(required = false) String keyword) {
|
||||||
|
|
||||||
Page<OpenWebhook> page = new Page<>(pageNum, pageSize);
|
Page<OpenWebhook> page = new Page<>(pageNum, pageSize);
|
||||||
@@ -48,21 +103,73 @@ public class OpenWebhookAdminController {
|
|||||||
if (tenantId != null) {
|
if (tenantId != null) {
|
||||||
qw.eq(OpenWebhook::getTenantId, tenantId);
|
qw.eq(OpenWebhook::getTenantId, tenantId);
|
||||||
}
|
}
|
||||||
|
if (StringUtils.hasText(clientId)) {
|
||||||
|
qw.eq(OpenWebhook::getClientId, clientId);
|
||||||
|
}
|
||||||
if (StringUtils.hasText(keyword)) {
|
if (StringUtils.hasText(keyword)) {
|
||||||
qw.and(w -> w.like(OpenWebhook::getChannelName, keyword)
|
qw.and(w -> w.like(OpenWebhook::getChannelName, keyword)
|
||||||
.or().like(OpenWebhook::getWebhookUrl, keyword));
|
.or().like(OpenWebhook::getWebhookUrl, keyword));
|
||||||
}
|
}
|
||||||
qw.orderByDesc(OpenWebhook::getCreateTime);
|
qw.orderByDesc(OpenWebhook::getCreateTime);
|
||||||
return Result.success(webhookMapper.selectPage(page, qw));
|
Page<OpenWebhook> resPage = webhookMapper.selectPage(page, qw);
|
||||||
|
populateTenantAndClientNames(resPage.getRecords());
|
||||||
|
return Result.success(resPage);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void populateTenantAndClientNames(List<OpenWebhook> webhooks) {
|
||||||
|
if (webhooks == null || webhooks.isEmpty() || jdbcTemplate == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
Map<Long, String> tenantNameMap = new HashMap<>();
|
||||||
|
List<Map<String, Object>> tenantRows = jdbcTemplate.queryForList("SELECT id, tenant_name FROM sys_tenant");
|
||||||
|
for (Map<String, Object> 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<String, String> clientNameMap = new HashMap<>();
|
||||||
|
List<Map<String, Object>> clientRows = jdbcTemplate.queryForList("SELECT client_id, client_name FROM open_client");
|
||||||
|
for (Map<String, Object> 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 通道")
|
@Operation(summary = "新建 Webhook 通道")
|
||||||
@PostMapping
|
@PostMapping
|
||||||
public Result<OpenWebhook> create(@RequestBody OpenWebhook req) {
|
public Result<OpenWebhook> create(@RequestBody OpenWebhook req) {
|
||||||
req.setId(null);
|
req.setId(null);
|
||||||
if (req.getTenantId() == null) req.setTenantId(1L);
|
if (req.getTenantId() == null) {
|
||||||
if (!StringUtils.hasText(req.getEvents())) req.setEvents("device.alarm");
|
req.setTenantId(1L);
|
||||||
if (req.getStatus() == null) req.setStatus(1);
|
}
|
||||||
|
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);
|
webhookMapper.insert(req);
|
||||||
return Result.success(req);
|
return Result.success(req);
|
||||||
@@ -72,14 +179,24 @@ public class OpenWebhookAdminController {
|
|||||||
@PutMapping("/{id}")
|
@PutMapping("/{id}")
|
||||||
public Result<Void> update(@PathVariable Long id, @RequestBody OpenWebhook req) {
|
public Result<Void> update(@PathVariable Long id, @RequestBody OpenWebhook req) {
|
||||||
OpenWebhook item = webhookMapper.selectById(id);
|
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.setChannelName(req.getChannelName());
|
||||||
item.setChannelType(req.getChannelType());
|
item.setChannelType(req.getChannelType());
|
||||||
item.setWebhookUrl(req.getWebhookUrl());
|
item.setWebhookUrl(req.getWebhookUrl());
|
||||||
item.setSecret(req.getSecret());
|
item.setSecret(req.getSecret());
|
||||||
if (req.getEvents() != null) item.setEvents(req.getEvents());
|
if (req.getEvents() != null) {
|
||||||
if (req.getStatus() != null) item.setStatus(req.getStatus());
|
item.setEvents(req.getEvents());
|
||||||
|
}
|
||||||
|
if (req.getStatus() != null) {
|
||||||
|
item.setStatus(req.getStatus());
|
||||||
|
}
|
||||||
item.setRemark(req.getRemark());
|
item.setRemark(req.getRemark());
|
||||||
|
|
||||||
webhookMapper.updateById(item);
|
webhookMapper.updateById(item);
|
||||||
@@ -97,12 +214,14 @@ public class OpenWebhookAdminController {
|
|||||||
@PostMapping("/{id}/test")
|
@PostMapping("/{id}/test")
|
||||||
public Result<Map<String, Object>> testSend(@PathVariable Long id, @RequestBody(required = false) Map<String, String> body) {
|
public Result<Map<String, Object>> testSend(@PathVariable Long id, @RequestBody(required = false) Map<String, String> body) {
|
||||||
OpenWebhook item = webhookMapper.selectById(id);
|
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 title = body != null && body.containsKey("title") ? body.get("title") : "【IoT开放平台测试】三方事件回调连通性验证";
|
||||||
String content = body != null && body.containsKey("content") ? body.get("content") : "这是一条来自开放平台的连通性测试消息。";
|
String content = body != null && body.containsKey("content") ? body.get("content") : "这是一条来自物联网开放平台的模拟业务事件通知,包含签名验证与数据包投递。";
|
||||||
|
|
||||||
boolean ok = dispatchService.sendDirect(item.getChannelType(), item.getWebhookUrl(), title, content);
|
boolean ok = dispatchService.sendDirect(item.getChannelType(), item.getWebhookUrl(), title, content, item.getSecret());
|
||||||
return Result.success(Map.of("success", ok, "message", ok ? "测试消息已成功送达" : "发送失败,请检查 URL 与网络配置"));
|
return Result.success(Map.of("success", ok, "message", ok ? "测试事件已成功投递" : "投递失败,请检查回调 URL 连通性或服务响应状态"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 桥接客户端
|
||||||
|
* <p>
|
||||||
|
* 自动向核心后端传递 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<String, String> 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<String> 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<String, Object> 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<String> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,6 +16,15 @@ public class OpenWebhook implements Serializable {
|
|||||||
private Long id;
|
private Long id;
|
||||||
|
|
||||||
private Long tenantId;
|
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 channelName;
|
||||||
private String channelType;
|
private String channelType;
|
||||||
private String webhookUrl;
|
private String webhookUrl;
|
||||||
|
|||||||
@@ -1,16 +1,22 @@
|
|||||||
package com.iot.gateway.mcp;
|
package com.iot.gateway.mcp;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.JsonNode;
|
||||||
|
import com.iot.gateway.common.BackendInternalClient;
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Component
|
@Component
|
||||||
public class McpToolRegistry {
|
public class McpToolRegistry {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private BackendInternalClient internalClient;
|
||||||
|
|
||||||
public List<Map<String, Object>> getToolDefinitions() {
|
public List<Map<String, Object>> getToolDefinitions() {
|
||||||
List<Map<String, Object>> tools = new ArrayList<>();
|
List<Map<String, Object>> tools = new ArrayList<>();
|
||||||
|
|
||||||
@@ -78,39 +84,75 @@ public class McpToolRegistry {
|
|||||||
Map<String, Object> result = new HashMap<>();
|
Map<String, Object> result = new HashMap<>();
|
||||||
|
|
||||||
if ("iot_device_list".equals(toolName)) {
|
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");
|
||||||
|
}
|
||||||
|
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("tenantId", tenantId);
|
||||||
result.put("total", 3);
|
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", 2);
|
||||||
result.put("devices", List.of(
|
result.put("devices", List.of(
|
||||||
Map.of("id", "DEV-ZSJ-001", "name", "1号注塑机 (海天MA1200)", "status", "online", "runningTimeHours", 342.5),
|
Map.of("deviceCode", "DEV20261009001", "deviceName", "1号注塑机 (海天MA1200)", "online", 1),
|
||||||
Map.of("id", "DEV-ZSJ-002", "name", "2号注塑机 (海天MA2000)", "status", "alarm", "runningTimeHours", 128.0),
|
Map.of("deviceCode", "DEV20261009002", "deviceName", "2号注塑机 (海天MA2000)", "online", 0)
|
||||||
Map.of("id", "DEV-CNC-003", "name", "3号数控精雕机", "status", "offline", "runningTimeHours", 89.2)
|
|
||||||
));
|
));
|
||||||
} else if ("iot_telemetry_latest".equals(toolName)) {
|
} 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("deviceId", deviceId);
|
||||||
result.put("timestamp", System.currentTimeMillis());
|
result.put("timestamp", System.currentTimeMillis());
|
||||||
result.put("telemetry", Map.of(
|
result.put("telemetry", Map.of(
|
||||||
"barrel_temp_zone1", 215.4,
|
"barrel_temp_zone1", 215.4,
|
||||||
"barrel_temp_zone2", 220.1,
|
"barrel_temp_zone2", 220.1,
|
||||||
"injection_pressure_mpa", 13.8,
|
"injection_pressure_mpa", 13.8,
|
||||||
"screw_speed_rpm", 85.0,
|
"screw_speed_rpm", 85.0
|
||||||
"cycle_time_seconds", 24.3,
|
|
||||||
"electric_current_a", 42.6
|
|
||||||
));
|
));
|
||||||
} else if ("iot_device_control".equals(toolName)) {
|
} else if ("iot_device_control".equals(toolName)) {
|
||||||
String deviceId = String.valueOf(args.get("deviceId"));
|
JsonNode resp = internalClient.post("/api/internal/open/commands", tenantId, args);
|
||||||
String command = String.valueOf(args.get("command"));
|
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("success", true);
|
||||||
result.put("deviceId", deviceId);
|
result.put("deviceId", args != null ? args.get("deviceId") : "");
|
||||||
result.put("command", command);
|
|
||||||
result.put("status", "EXECUTED");
|
result.put("status", "EXECUTED");
|
||||||
result.put("message", "控制指令已成功通过 MQTT 发送给边缘网关下发执行");
|
result.put("message", "控制指令已成功通过网关下发");
|
||||||
} else if ("iot_alarm_query".equals(toolName)) {
|
} 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;
|
||||||
|
}
|
||||||
|
|
||||||
result.put("alarms", List.of(
|
result.put("alarms", List.of(
|
||||||
Map.of("alarmId", "ALM-9821", "deviceId", "DEV-ZSJ-002", "severity", 3,
|
Map.of("alarmId", "ALM-2026100901", "deviceId", "DEV20261009001", "severity", 3, "title", "模具温度异常过高")
|
||||||
"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")
|
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
result.put("error", "未知的工具名称: " + toolName);
|
result.put("error", "未知的工具名称: " + toolName);
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ public class JwtTokenProvider {
|
|||||||
claims.put("tenant_id", client.getTenantId());
|
claims.put("tenant_id", client.getTenantId());
|
||||||
claims.put("client_name", client.getClientName());
|
claims.put("client_name", client.getClientName());
|
||||||
claims.put("scopes", client.getScopes());
|
claims.put("scopes", client.getScopes());
|
||||||
|
claims.put("ip_whitelist", client.getIpWhitelist());
|
||||||
|
|
||||||
return Jwts.builder()
|
return Jwts.builder()
|
||||||
.setClaims(claims)
|
.setClaims(claims)
|
||||||
|
|||||||
@@ -37,6 +37,20 @@ public class OAuth2AuthenticationFilter extends OncePerRequestFilter {
|
|||||||
Long tenantId = claims.get("tenant_id", Long.class);
|
Long tenantId = claims.get("tenant_id", Long.class);
|
||||||
String clientName = claims.get("client_name", String.class);
|
String clientName = claims.get("client_name", String.class);
|
||||||
String scopesStr = claims.get("scopes", 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<String> scopes = StringUtils.hasText(scopesStr)
|
List<String> scopes = StringUtils.hasText(scopesStr)
|
||||||
? Arrays.asList(scopesStr.split(","))
|
? Arrays.asList(scopesStr.split(","))
|
||||||
|
|||||||
@@ -37,7 +37,8 @@ public class OAuth2TokenController {
|
|||||||
@RequestParam(value = "client_id", required = false) String formClientId,
|
@RequestParam(value = "client_id", required = false) String formClientId,
|
||||||
@RequestParam(value = "client_secret", required = false) String formClientSecret,
|
@RequestParam(value = "client_secret", required = false) String formClientSecret,
|
||||||
@RequestParam(value = "scope", required = false) String formScope,
|
@RequestParam(value = "scope", required = false) String formScope,
|
||||||
@RequestBody(required = false) Map<String, String> jsonBody) {
|
@RequestBody(required = false) Map<String, String> jsonBody,
|
||||||
|
jakarta.servlet.http.HttpServletRequest request) {
|
||||||
|
|
||||||
String grantType = formGrantType != null ? formGrantType : (jsonBody != null ? jsonBody.get("grant_type") : null);
|
String grantType = formGrantType != null ? formGrantType : (jsonBody != null ? jsonBody.get("grant_type") : null);
|
||||||
String clientId = formClientId != null ? formClientId : (jsonBody != null ? jsonBody.get("client_id") : 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);
|
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);
|
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<String, Object> response = new HashMap<>();
|
Map<String, Object> response = new HashMap<>();
|
||||||
response.put("access_token", accessToken);
|
response.put("access_token", accessToken);
|
||||||
|
|||||||
@@ -1,12 +1,16 @@
|
|||||||
package com.iot.gateway.openapi;
|
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.common.Result;
|
||||||
import com.iot.gateway.oauth.GatewaySecurityContext;
|
import com.iot.gateway.oauth.GatewaySecurityContext;
|
||||||
import io.swagger.v3.oas.annotations.Operation;
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import java.util.HashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.PathVariable;
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
@@ -20,6 +24,9 @@ import org.springframework.web.bind.annotation.RestController;
|
|||||||
@RequestMapping("/open/v1/devices")
|
@RequestMapping("/open/v1/devices")
|
||||||
public class OpenDeviceController {
|
public class OpenDeviceController {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private BackendInternalClient internalClient;
|
||||||
|
|
||||||
@Operation(summary = "查询当前租户名下设备列表")
|
@Operation(summary = "查询当前租户名下设备列表")
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@PreAuthorize("hasAuthority('SCOPE_device.read') or hasAuthority('SCOPE_all')")
|
@PreAuthorize("hasAuthority('SCOPE_device.read') or hasAuthority('SCOPE_all')")
|
||||||
@@ -31,35 +38,55 @@ public class OpenDeviceController {
|
|||||||
Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L);
|
Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L);
|
||||||
log.info("三方系统查询设备列表: tenantId={}, status={}", tenantId, status);
|
log.info("三方系统查询设备列表: tenantId={}, status={}", tenantId, status);
|
||||||
|
|
||||||
List<Map<String, Object>> list = List.of(
|
Map<String, String> params = new HashMap<>();
|
||||||
Map.of("id", "DEV-ZSJ-001", "name", "1号注塑机 (海天MA1200)", "type", "注塑设备",
|
params.put("pageNum", String.valueOf(pageNum));
|
||||||
"status", "online", "model", "MA1200-V2", "gatewayCode", "GW-NET-01"),
|
params.put("pageSize", String.valueOf(pageSize));
|
||||||
Map.of("id", "DEV-ZSJ-002", "name", "2号注塑机 (海天MA2000)", "type", "注塑设备",
|
if (status != null) {
|
||||||
"status", "alarm", "model", "MA2000-PRO", "gatewayCode", "GW-NET-01"),
|
if ("online".equalsIgnoreCase(status)) params.put("online", "1");
|
||||||
Map.of("id", "DEV-CNC-003", "name", "3号数控精雕机", "type", "加工中心",
|
else if ("offline".equalsIgnoreCase(status)) params.put("online", "0");
|
||||||
"status", "offline", "model", "CNC-500", "gatewayCode", "GW-NET-02")
|
}
|
||||||
);
|
|
||||||
|
|
||||||
|
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(
|
return Result.success(Map.of(
|
||||||
"tenantId", tenantId,
|
"tenantId", tenantId,
|
||||||
"total", list.size(),
|
"total", data.has("total") ? data.get("total").asLong() : 0,
|
||||||
"pageNum", pageNum,
|
"pageNum", pageNum,
|
||||||
"pageSize", pageSize,
|
"pageSize", pageSize,
|
||||||
"list", list
|
"list", data.has("records") ? data.get("records") : List.of()
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底返回
|
||||||
|
return Result.success(Map.of(
|
||||||
|
"tenantId", tenantId,
|
||||||
|
"total", 2,
|
||||||
|
"pageNum", pageNum,
|
||||||
|
"pageSize", pageSize,
|
||||||
|
"list", List.of(
|
||||||
|
Map.of("deviceCode", "DEV20261009001", "deviceName", "1号注塑机 (海天MA1200)", "online", 1),
|
||||||
|
Map.of("deviceCode", "DEV20261009002", "deviceName", "2号注塑机 (海天MA2000)", "online", 0)
|
||||||
|
)
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@Operation(summary = "获取指定设备详情与点位元数据")
|
@Operation(summary = "获取指定设备详情与点位元数据")
|
||||||
@GetMapping("/{deviceId}")
|
@GetMapping("/{deviceId}")
|
||||||
@PreAuthorize("hasAuthority('SCOPE_device.read') or hasAuthority('SCOPE_all')")
|
@PreAuthorize("hasAuthority('SCOPE_device.read') or hasAuthority('SCOPE_all')")
|
||||||
public Result<Map<String, Object>> getDevice(@PathVariable String deviceId) {
|
public Result<Object> getDevice(@PathVariable String deviceId) {
|
||||||
Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L);
|
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(
|
return Result.success(Map.of(
|
||||||
"deviceId", deviceId,
|
"deviceCode", deviceId,
|
||||||
"tenantId", tenantId,
|
"tenantId", tenantId,
|
||||||
"name", "1号注塑机 (海天MA1200)",
|
"deviceName", "1号注塑机 (海天MA1200)",
|
||||||
"status", "online",
|
"online", 1
|
||||||
"points", List.of("barrel_temp_zone1", "barrel_temp_zone2", "injection_pressure_mpa", "screw_speed_rpm")
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,11 +6,16 @@ import java.net.URI;
|
|||||||
import java.net.http.HttpClient;
|
import java.net.http.HttpClient;
|
||||||
import java.net.http.HttpRequest;
|
import java.net.http.HttpRequest;
|
||||||
import java.net.http.HttpResponse;
|
import java.net.http.HttpResponse;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.time.Duration;
|
import java.time.Duration;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import javax.crypto.Mac;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Service
|
@Service
|
||||||
@@ -23,10 +28,13 @@ public class WebhookDispatchService {
|
|||||||
.connectTimeout(Duration.ofSeconds(5))
|
.connectTimeout(Duration.ofSeconds(5))
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 针对指定租户分发告警与关键事件
|
||||||
|
*/
|
||||||
public void dispatchAlarm(Long tenantId, String title, String content) {
|
public void dispatchAlarm(Long tenantId, String title, String content) {
|
||||||
List<OpenWebhook> channels = webhookMapper.selectList(null).stream()
|
List<OpenWebhook> channels = webhookMapper.selectList(null).stream()
|
||||||
.filter(w -> w.getStatus() == 1 && (w.getTenantId().equals(tenantId) || tenantId == null))
|
.filter(w -> w.getStatus() == 1 && (tenantId == null || tenantId.equals(w.getTenantId())))
|
||||||
.filter(w -> w.getEvents() != null && w.getEvents().contains("device.alarm"))
|
.filter(w -> w.getEvents() != null && (w.getEvents().contains("alarm.triggered") || w.getEvents().contains("device.alarm")))
|
||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
for (OpenWebhook ch : channels) {
|
for (OpenWebhook ch : channels) {
|
||||||
@@ -35,36 +43,74 @@ public class WebhookDispatchService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public boolean sendDirect(String channelType, String webhookUrl, String title, String content) {
|
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);
|
String payload = formatMessage(channelType, title, content);
|
||||||
|
long timestamp = System.currentTimeMillis();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
HttpRequest request = HttpRequest.newBuilder()
|
HttpRequest.Builder reqBuilder = HttpRequest.newBuilder()
|
||||||
.uri(URI.create(webhookUrl))
|
.uri(URI.create(webhookUrl))
|
||||||
.header("Content-Type", "application/json; charset=utf-8")
|
.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))
|
.timeout(Duration.ofSeconds(5))
|
||||||
.POST(HttpRequest.BodyPublishers.ofString(payload))
|
.POST(HttpRequest.BodyPublishers.ofString(payload));
|
||||||
.build();
|
|
||||||
|
// 若配置了加签密钥且为 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<String> resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
HttpResponse<String> resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
|
||||||
return resp.statusCode() >= 200 && resp.statusCode() < 300;
|
return resp.statusCode() >= 200 && resp.statusCode() < 300;
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("发送 Webhook 消息失败: url={}, err={}", webhookUrl, e.getMessage());
|
log.warn("发送 Webhook 消息失败: url={}, type={}, err={}", webhookUrl, channelType, e.getMessage());
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void sendAsync(OpenWebhook webhook, String title, String content) {
|
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) {
|
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("\"", "\\\"") : "";
|
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)) {
|
if ("wechat_work".equalsIgnoreCase(type) || "dingtalk".equalsIgnoreCase(type)) {
|
||||||
return "{\"msgtype\":\"text\",\"text\":{\"content\":\"" + safeTitle + "\\n" + safeContent + "\"}}";
|
return "{\"msgtype\":\"text\",\"text\":{\"content\":\"" + safeTitle + "\\n" + safeContent + "\"}}";
|
||||||
} else if ("feishu".equalsIgnoreCase(type)) {
|
} else if ("feishu".equalsIgnoreCase(type)) {
|
||||||
return "{\"msg_type\":\"text\",\"content\":{\"text\":\"" + safeTitle + "\\n" + safeContent + "\"}}";
|
return "{\"msg_type\":\"text\",\"content\":{\"text\":\"" + safeTitle + "\\n" + safeContent + "\"}}";
|
||||||
} else {
|
} 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ gateway:
|
|||||||
token-expire-seconds: 7200 # 2小时
|
token-expire-seconds: 7200 # 2小时
|
||||||
# 核心物联后端内网地址 (用于设备遥测数据拉取与控制反写代理)
|
# 核心物联后端内网地址 (用于设备遥测数据拉取与控制反写代理)
|
||||||
backend:
|
backend:
|
||||||
base-url: http://localhost:8080
|
base-url: ${GATEWAY_BACKEND_URL:http://localhost:8080}
|
||||||
internal-secret: iot-internal-secret-for-gateway-666
|
internal-secret: ${GATEWAY_INTERNAL_SECRET:iot-internal-open-gateway-secret-key-20261009-xyz}
|
||||||
|
|
||||||
springdoc:
|
springdoc:
|
||||||
swagger-ui:
|
swagger-ui:
|
||||||
|
|||||||
Reference in New Issue
Block a user