feat: 实现IP白名单严格拦截、三方Webhook多租户事件订阅与MCP专线业务调用

This commit is contained in:
wjh
2026-10-09 11:51:23 +08:00
parent c6bd736e7f
commit 00a9dcaf2f
11 changed files with 603 additions and 58 deletions
@@ -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<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 通道分页列表")
@GetMapping("/page")
public Result<Page<OpenWebhook>> 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<OpenWebhook> 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<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 通道")
@PostMapping
public Result<OpenWebhook> 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<Void> 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<Map<String, Object>> testSend(@PathVariable Long id, @RequestBody(required = false) Map<String, String> 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 连通性或服务响应状态"));
}
}
@@ -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 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;
@@ -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<Map<String, Object>> getToolDefinitions() {
List<Map<String, Object>> tools = new ArrayList<>();
@@ -78,39 +84,75 @@ public class McpToolRegistry {
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");
}
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", 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(
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<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(
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);
@@ -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)
@@ -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<String> scopes = StringUtils.hasText(scopesStr)
? Arrays.asList(scopesStr.split(","))
@@ -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<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 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<String, Object> response = new HashMap<>();
response.put("access_token", accessToken);
@@ -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<Map<String, Object>> 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<String, String> 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", list.size(),
"total", data.has("total") ? data.get("total").asLong() : 0,
"pageNum", pageNum,
"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 = "获取指定设备详情与点位元数据")
@GetMapping("/{deviceId}")
@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);
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
));
}
}
@@ -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<OpenWebhook> 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<String> 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;
}
}
}
+2 -2
View File
@@ -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: