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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
wjh
2026-10-10 16:27:53 +08:00
co-authored by Claude Sonnet 5
parent 33269da935
commit 761636c2c5
45 changed files with 1557 additions and 654 deletions
@@ -0,0 +1,42 @@
package com.iot.gateway.admin;
/**
* 开放平台管理接口的默认值、前缀与响应字段名。
*/
public final class AdminDefaults {
/** 平台管理员新建时未指定租户的默认归属租户 */
public static final long DEFAULT_TENANT_ID = 1L;
/** 分页参数默认值(用于 @RequestParam 的 defaultValue,须为字符串常量) */
public static final String DEFAULT_PAGE_NUM = "1";
public static final String DEFAULT_PAGE_SIZE = "10";
/** 客户端标识与密钥的生成规则 */
public static final String CLIENT_ID_PREFIX = "client_";
public static final int CLIENT_ID_RANDOM_LENGTH = 12;
public static final String SECRET_PREFIX = "sec_";
/** 关联名称回填用的查询与列名 */
public static final String SQL_TENANT_NAMES = "SELECT id, tenant_name FROM sys_tenant";
public static final String SQL_CLIENT_NAMES = "SELECT client_id, client_name FROM open_client";
public static final String COL_ID = "id";
public static final String COL_TENANT_NAME = "tenant_name";
public static final String COL_CLIENT_ID = "client_id";
public static final String COL_CLIENT_NAME = "client_name";
public static final String TENANT_NAME_FALLBACK_PREFIX = "租户 #";
/** 密钥重置响应字段 */
public static final String FIELD_CLIENT_ID = "clientId";
public static final String FIELD_RAW_SECRET = "rawSecret";
public static final String FIELD_TIPS = "tips";
/** Webhook 测试请求与响应字段 */
public static final String FIELD_TITLE = "title";
public static final String FIELD_CONTENT = "content";
public static final String FIELD_SUCCESS = "success";
public static final String FIELD_MESSAGE = "message";
private AdminDefaults() {
}
}
@@ -3,18 +3,24 @@ package com.iot.gateway.admin;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.iot.gateway.common.BizException;
import com.iot.gateway.common.EnableStatusEnum;
import com.iot.gateway.common.Result;
import com.iot.gateway.entity.OpenClient;
import com.iot.gateway.mapper.OpenClientMapper;
import com.iot.gateway.oauth.GatewayAuthorities;
import com.iot.gateway.oauth.GatewaySecurityContext;
import com.iot.gateway.oauth.JwtClaims;
import com.iot.gateway.oauth.OpenScopeEnum;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.security.SecureRandom;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.util.StringUtils;
@@ -32,7 +38,7 @@ import org.springframework.web.bind.annotation.RestController;
@Tag(name = "开放平台管理 - 三方应用凭据管理")
@RestController
@RequestMapping("/api/open/client")
@PreAuthorize("hasAnyAuthority('ROLE_ADMIN', 'SCOPE_admin', 'SCOPE_tenant_admin')")
@PreAuthorize(GatewayAuthorities.EXPR_ADMIN_OR_TENANT_ADMIN)
public class OpenClientAdminController {
@Autowired
@@ -49,20 +55,20 @@ public class OpenClientAdminController {
@Operation(summary = "获取所有可授权的作用域 (Scopes) 枚举字典列表")
@GetMapping("/scopes")
public Result<List<Map<String, Object>>> listScopes() {
return Result.success(com.iot.gateway.oauth.OpenScopeEnum.toList());
return Result.success(OpenScopeEnum.toList());
}
@Operation(summary = "三方应用凭据分页列表 (严禁泄露密钥哈希)")
@GetMapping("/page")
public Result<Page<OpenClient>> page(
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(defaultValue = AdminDefaults.DEFAULT_PAGE_NUM) Integer pageNum,
@RequestParam(defaultValue = AdminDefaults.DEFAULT_PAGE_SIZE) Integer pageSize,
@RequestParam(required = false) Long tenantId,
@RequestParam(required = false) String keyword) {
GatewaySecurityContext ctx = GatewaySecurityContext.get();
if (ctx == null) {
throw new BizException(401, "未授权访问");
throw new BizException(HttpStatus.UNAUTHORIZED.value(), "未授权访问");
}
Page<OpenClient> page = new Page<>(pageNum, pageSize);
@@ -75,7 +81,7 @@ public class OpenClientAdminController {
} else {
// 普通租户管理员强制锁定为自身租户,防止水平越权
if (ctx.getTenantId() == null) {
throw new BizException(403, "当前账号缺少租户归属信息");
throw new BizException(HttpStatus.FORBIDDEN.value(), "当前账号缺少租户归属信息");
}
qw.eq(OpenClient::getTenantId, ctx.getTenantId());
}
@@ -104,18 +110,18 @@ public class OpenClientAdminController {
return;
}
try {
java.util.Map<Long, String> tenantNameMap = new java.util.HashMap<>();
List<Map<String, Object>> rows = jdbcTemplate.queryForList("SELECT id, tenant_name FROM sys_tenant");
Map<Long, String> tenantNameMap = new HashMap<>();
List<Map<String, Object>> rows = jdbcTemplate.queryForList(AdminDefaults.SQL_TENANT_NAMES);
for (Map<String, Object> r : rows) {
Object idObj = r.get("id");
Object nameObj = r.get("tenant_name");
Object idObj = r.get(AdminDefaults.COL_ID);
Object nameObj = r.get(AdminDefaults.COL_TENANT_NAME);
if (idObj instanceof Number && nameObj != null) {
tenantNameMap.put(((Number) idObj).longValue(), nameObj.toString());
}
}
for (OpenClient c : clients) {
if (c.getTenantId() != null) {
c.setTenantName(tenantNameMap.getOrDefault(c.getTenantId(), "租户 #" + c.getTenantId()));
c.setTenantName(tenantNameMap.getOrDefault(c.getTenantId(), AdminDefaults.TENANT_NAME_FALLBACK_PREFIX + c.getTenantId()));
}
}
} catch (Exception e) {
@@ -142,26 +148,27 @@ public class OpenClientAdminController {
@PostMapping
public Result<OpenClient> create(@RequestBody OpenClient req) {
GatewaySecurityContext ctx = GatewaySecurityContext.get();
if (ctx == null) throw new BizException(401, "未授权操作");
if (ctx == null) throw new BizException(HttpStatus.UNAUTHORIZED.value(), "未授权操作");
req.setId(null);
req.setClientId("client_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12));
req.setClientId(AdminDefaults.CLIENT_ID_PREFIX
+ UUID.randomUUID().toString().replace("-", "").substring(0, AdminDefaults.CLIENT_ID_RANDOM_LENGTH));
// 生成高强度原始 Secret
String rawSecret = "sec_" + UUID.randomUUID().toString().replace("-", "") + Long.toHexString(RANDOM.nextLong());
String rawSecret = generateRawSecret();
// 数据库落库 BCrypt 单向哈希
req.setClientSecret(passwordEncoder.encode(rawSecret));
if (!ctx.isAdmin()) {
if (ctx.getTenantId() == null) throw new BizException(403, "当前管理员无所属租户");
if (ctx.getTenantId() == null) throw new BizException(HttpStatus.FORBIDDEN.value(), "当前管理员无所属租户");
req.setTenantId(ctx.getTenantId());
} else if (req.getTenantId() == null) {
req.setTenantId(1L);
req.setTenantId(AdminDefaults.DEFAULT_TENANT_ID);
}
if (!StringUtils.hasText(req.getScopes())) req.setScopes("device.read");
if (req.getStatus() == null) req.setStatus(1);
if (req.getRateLimit() == null) req.setRateLimit(60);
if (!StringUtils.hasText(req.getScopes())) req.setScopes(OpenScopeEnum.CODE_DEVICE_READ);
if (req.getStatus() == null) req.setStatus(EnableStatusEnum.ENABLED.getCode());
if (req.getRateLimit() == null) req.setRateLimit(JwtClaims.DEFAULT_RATE_LIMIT);
clientMapper.insert(req);
log.info("新建开放应用凭据成功: id={}, clientId={}, tenantId={}", req.getId(), req.getClientId(), req.getTenantId());
@@ -213,16 +220,16 @@ public class OpenClientAdminController {
checkTenantAccess(client.getTenantId());
String rawSecret = "sec_" + UUID.randomUUID().toString().replace("-", "") + Long.toHexString(RANDOM.nextLong());
String rawSecret = generateRawSecret();
client.setClientSecret(passwordEncoder.encode(rawSecret));
clientMapper.updateById(client);
log.info("重置客户端凭证密钥完成: clientId={}", client.getClientId());
return Result.success(Map.of(
"clientId", client.getClientId(),
"rawSecret", rawSecret,
"tips", "密钥已重置,系统已将其哈希加密存储,明文仅展示一次,请妥善保存"
AdminDefaults.FIELD_CLIENT_ID, client.getClientId(),
AdminDefaults.FIELD_RAW_SECRET, rawSecret,
AdminDefaults.FIELD_TIPS, "密钥已重置,系统已将其哈希加密存储,明文仅展示一次,请妥善保存"
));
}
@@ -239,15 +246,20 @@ public class OpenClientAdminController {
return Result.success();
}
private String generateRawSecret() {
return AdminDefaults.SECRET_PREFIX + UUID.randomUUID().toString().replace("-", "")
+ Long.toHexString(RANDOM.nextLong());
}
private void checkTenantAccess(Long targetTenantId) {
GatewaySecurityContext ctx = GatewaySecurityContext.get();
if (ctx == null) {
throw new BizException(401, "未授权访问");
throw new BizException(HttpStatus.UNAUTHORIZED.value(), "未授权访问");
}
if (!ctx.isAdmin()) {
Long userTenantId = ctx.getTenantId();
if (userTenantId == null || !userTenantId.equals(targetTenantId)) {
throw new BizException(403, "权限不足: 禁止跨租户访问或操作开放凭据");
throw new BizException(HttpStatus.FORBIDDEN.value(), "权限不足: 禁止跨租户访问或操作开放凭据");
}
}
}
@@ -2,6 +2,7 @@ package com.iot.gateway.admin;
import com.iot.gateway.common.Result;
import com.iot.gateway.mcp.McpToolRegistry;
import com.iot.gateway.openapi.OpenApiFields;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
@@ -23,9 +24,9 @@ public class OpenGatewayInfoController {
@GetMapping("/health")
public Result<Map<String, Object>> health() {
return Result.success(Map.of(
"status", "ONLINE",
"service", "iot-open-gateway",
"timestamp", System.currentTimeMillis()
OpenApiFields.STATUS, OpenApiFields.GATEWAY_STATUS_ONLINE,
OpenApiFields.FIELD_SERVICE, OpenApiFields.SERVICE_NAME,
OpenApiFields.TIMESTAMP, System.currentTimeMillis()
));
}
@@ -43,12 +44,12 @@ public class OpenGatewayInfoController {
);
return Result.success(Map.of(
"status", "ONLINE",
"mcpEndpoint", "/mcp/sse",
"tokenEndpoint", "/oauth2/token",
"toolCount", tools.size(),
"tools", tools,
"openApis", apis
OpenApiFields.STATUS, OpenApiFields.GATEWAY_STATUS_ONLINE,
OpenApiFields.FIELD_MCP_ENDPOINT, OpenApiFields.MCP_ENDPOINT,
OpenApiFields.FIELD_TOKEN_ENDPOINT, OpenApiFields.TOKEN_ENDPOINT,
OpenApiFields.FIELD_TOOL_COUNT, tools.size(),
OpenApiFields.FIELD_TOOLS, tools,
OpenApiFields.FIELD_OPEN_APIS, apis
));
}
}
@@ -3,11 +3,15 @@ package com.iot.gateway.admin;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.iot.gateway.common.BizException;
import com.iot.gateway.common.EnableStatusEnum;
import com.iot.gateway.common.Result;
import com.iot.gateway.entity.OpenWebhook;
import com.iot.gateway.mapper.OpenWebhookMapper;
import com.iot.gateway.oauth.GatewayAuthorities;
import com.iot.gateway.oauth.GatewaySecurityContext;
import com.iot.gateway.webhook.WebhookChannelEnum;
import com.iot.gateway.webhook.WebhookDispatchService;
import com.iot.gateway.webhook.WebhookEventEnum;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.HashMap;
@@ -15,6 +19,7 @@ import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.util.StringUtils;
import org.springframework.web.bind.annotation.DeleteMapping;
@@ -31,7 +36,7 @@ import org.springframework.web.bind.annotation.RestController;
@Tag(name = "开放平台管理 - Webhook 事件订阅与通道管理")
@RestController
@RequestMapping("/api/open/webhook")
@PreAuthorize("hasAnyAuthority('ROLE_ADMIN', 'SCOPE_admin', 'SCOPE_tenant_admin')")
@PreAuthorize(GatewayAuthorities.EXPR_ADMIN_OR_TENANT_ADMIN)
public class OpenWebhookAdminController {
@Autowired
@@ -46,63 +51,20 @@ public class OpenWebhookAdminController {
@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"
)
));
return Result.success(WebhookEventEnum.toList());
}
@Operation(summary = "Webhook 通道分页列表")
@GetMapping("/page")
public Result<Page<OpenWebhook>> page(
@RequestParam(defaultValue = "1") Integer pageNum,
@RequestParam(defaultValue = "10") Integer pageSize,
@RequestParam(defaultValue = AdminDefaults.DEFAULT_PAGE_NUM) Integer pageNum,
@RequestParam(defaultValue = AdminDefaults.DEFAULT_PAGE_SIZE) Integer pageSize,
@RequestParam(required = false) Long tenantId,
@RequestParam(required = false) String clientId,
@RequestParam(required = false) String keyword) {
GatewaySecurityContext ctx = GatewaySecurityContext.get();
if (ctx == null) throw new BizException(401, "未授权访问");
if (ctx == null) throw new BizException(HttpStatus.UNAUTHORIZED.value(), "未授权访问");
Page<OpenWebhook> page = new Page<>(pageNum, pageSize);
LambdaQueryWrapper<OpenWebhook> qw = new LambdaQueryWrapper<>();
@@ -112,7 +74,7 @@ public class OpenWebhookAdminController {
qw.eq(OpenWebhook::getTenantId, tenantId);
}
} else {
if (ctx.getTenantId() == null) throw new BizException(403, "当前管理员无所属租户");
if (ctx.getTenantId() == null) throw new BizException(HttpStatus.FORBIDDEN.value(), "当前管理员无所属租户");
qw.eq(OpenWebhook::getTenantId, ctx.getTenantId());
}
@@ -135,20 +97,20 @@ public class OpenWebhookAdminController {
}
try {
Map<Long, String> tenantNameMap = new HashMap<>();
List<Map<String, Object>> tenantRows = jdbcTemplate.queryForList("SELECT id, tenant_name FROM sys_tenant");
List<Map<String, Object>> tenantRows = jdbcTemplate.queryForList(AdminDefaults.SQL_TENANT_NAMES);
for (Map<String, Object> r : tenantRows) {
Object idObj = r.get("id");
Object nameObj = r.get("tenant_name");
Object idObj = r.get(AdminDefaults.COL_ID);
Object nameObj = r.get(AdminDefaults.COL_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");
List<Map<String, Object>> clientRows = jdbcTemplate.queryForList(AdminDefaults.SQL_CLIENT_NAMES);
for (Map<String, Object> r : clientRows) {
Object cId = r.get("client_id");
Object cName = r.get("client_name");
Object cId = r.get(AdminDefaults.COL_CLIENT_ID);
Object cName = r.get(AdminDefaults.COL_CLIENT_NAME);
if (cId != null && cName != null) {
clientNameMap.put(cId.toString(), cName.toString());
}
@@ -156,7 +118,7 @@ public class OpenWebhookAdminController {
for (OpenWebhook w : webhooks) {
if (w.getTenantId() != null) {
w.setTenantName(tenantNameMap.getOrDefault(w.getTenantId(), "租户 #" + w.getTenantId()));
w.setTenantName(tenantNameMap.getOrDefault(w.getTenantId(), AdminDefaults.TENANT_NAME_FALLBACK_PREFIX + w.getTenantId()));
}
if (StringUtils.hasText(w.getClientId())) {
w.setClientName(clientNameMap.getOrDefault(w.getClientId(), w.getClientId()));
@@ -171,24 +133,24 @@ public class OpenWebhookAdminController {
@PostMapping
public Result<OpenWebhook> create(@RequestBody OpenWebhook req) {
GatewaySecurityContext ctx = GatewaySecurityContext.get();
if (ctx == null) throw new BizException(401, "未授权操作");
if (ctx == null) throw new BizException(HttpStatus.UNAUTHORIZED.value(), "未授权操作");
req.setId(null);
if (!ctx.isAdmin()) {
if (ctx.getTenantId() == null) throw new BizException(403, "当前管理员无所属租户");
if (ctx.getTenantId() == null) throw new BizException(HttpStatus.FORBIDDEN.value(), "当前管理员无所属租户");
req.setTenantId(ctx.getTenantId());
} else if (req.getTenantId() == null) {
req.setTenantId(1L);
req.setTenantId(AdminDefaults.DEFAULT_TENANT_ID);
}
if (!StringUtils.hasText(req.getEvents())) {
req.setEvents("alarm.triggered");
req.setEvents(WebhookEventEnum.ALARM_TRIGGERED.getCode());
}
if (req.getStatus() == null) {
req.setStatus(1);
req.setStatus(EnableStatusEnum.ENABLED.getCode());
}
if (!StringUtils.hasText(req.getChannelType())) {
req.setChannelType("http");
req.setChannelType(WebhookChannelEnum.HTTP.getCode());
}
webhookMapper.insert(req);
@@ -250,22 +212,23 @@ public class OpenWebhookAdminController {
checkTenantAccess(item.getTenantId());
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(AdminDefaults.FIELD_TITLE) ? body.get(AdminDefaults.FIELD_TITLE) : "【IoT开放平台测试】三方事件回调连通性验证";
String content = body != null && body.containsKey(AdminDefaults.FIELD_CONTENT) ? body.get(AdminDefaults.FIELD_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(AdminDefaults.FIELD_SUCCESS, ok,
AdminDefaults.FIELD_MESSAGE, ok ? "测试事件已成功投递" : "投递失败,请检查回调 URL 连通性或服务响应状态"));
}
private void checkTenantAccess(Long targetTenantId) {
GatewaySecurityContext ctx = GatewaySecurityContext.get();
if (ctx == null) {
throw new BizException(401, "未授权访问");
throw new BizException(HttpStatus.UNAUTHORIZED.value(), "未授权访问");
}
if (!ctx.isAdmin()) {
Long userTenantId = ctx.getTenantId();
if (userTenantId == null || !userTenantId.equals(targetTenantId)) {
throw new BizException(403, "权限不足: 禁止跨租户访问或操作 Webhook 通道");
throw new BizException(HttpStatus.FORBIDDEN.value(), "权限不足: 禁止跨租户访问或操作 Webhook 通道");
}
}
}
@@ -17,6 +17,8 @@ import java.util.HashMap;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
/**
@@ -28,6 +30,16 @@ import org.springframework.stereotype.Component;
@Component
public class BackendInternalClient {
/** GET 最多尝试次数(初次 + 1 次重试) */
private static final int GET_MAX_ATTEMPTS = 2;
private static final long RETRY_BACKOFF_MILLIS = 200;
private static final String QUERY_START = "?";
private static final String QUERY_KV = "=";
private static final String QUERY_AND = "&";
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(4);
private static final Duration GET_TIMEOUT = Duration.ofSeconds(5);
private static final Duration POST_TIMEOUT = Duration.ofSeconds(6);
@Value("${gateway.backend.base-url:http://localhost:8080}")
private String backendBaseUrl;
@@ -35,7 +47,7 @@ public class BackendInternalClient {
private String internalSecret;
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(4))
.connectTimeout(CONNECT_TIMEOUT)
.build();
private final ObjectMapper objectMapper = new ObjectMapper();
@@ -45,20 +57,20 @@ public class BackendInternalClient {
*/
public JsonNode get(String path, Long tenantId, Map<String, String> queryParams) {
if (tenantId == null) {
throw new BizException(401, "专线请求缺少租户上下文,严禁跨租户或无租户调用");
throw new BizException(HttpStatus.UNAUTHORIZED.value(), "专线请求缺少租户上下文,严禁跨租户或无租户调用");
}
String fullUrl = buildGetUrl(path, queryParams);
int maxAttempts = 2; // 最多尝试2次(初次 + 1次重试)
int maxAttempts = GET_MAX_ATTEMPTS;
for (int attempt = 1; attempt <= maxAttempts; attempt++) {
try {
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(fullUrl))
.header("X-Internal-Secret", internalSecret != null ? internalSecret : "")
.header("X-Tenant-Id", String.valueOf(tenantId))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(5))
.header(GatewayHeaders.INTERNAL_SECRET, internalSecret != null ? internalSecret : "")
.header(GatewayHeaders.TENANT_ID, String.valueOf(tenantId))
.header(HttpHeaders.CONTENT_TYPE, GatewayHeaders.CONTENT_TYPE_JSON)
.timeout(GET_TIMEOUT)
.GET()
.build();
@@ -68,31 +80,31 @@ public class BackendInternalClient {
} catch (HttpTimeoutException | SocketTimeoutException e) {
if (attempt < maxAttempts) {
log.warn("GET 请求超时,正在进行瞬时网络重试 (attempt={}): url={}", attempt, fullUrl);
backoff(200);
backoff(RETRY_BACKOFF_MILLIS);
continue;
}
log.error("核心后端 GET 请求超时: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage());
throw new BizException(504, "调用核心后端业务超时,服务可能处于高负荷: " + e.getMessage());
throw new BizException(HttpStatus.GATEWAY_TIMEOUT.value(), "调用核心后端业务超时,服务可能处于高负荷: " + e.getMessage());
} catch (ConnectException e) {
if (attempt < maxAttempts) {
log.warn("GET 连接异常,正在尝试重连 (attempt={}): url={}", attempt, fullUrl);
backoff(200);
backoff(RETRY_BACKOFF_MILLIS);
continue;
}
log.error("核心后端连接拒绝: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage());
throw new BizException(503, "核心后端服务不可达,请检查后端运行状态");
throw new BizException(HttpStatus.SERVICE_UNAVAILABLE.value(), "核心后端服务不可达,请检查后端运行状态");
} catch (BizException be) {
throw be;
} catch (Exception e) {
log.error("调用核心后端 GET 异常: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage());
throw new BizException(502, "核心物联后端通信异常: " + e.getMessage());
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "核心物联后端通信异常: " + e.getMessage());
}
}
throw new BizException(504, "调用核心后端业务多次超时");
throw new BizException(HttpStatus.GATEWAY_TIMEOUT.value(), "调用核心后端业务多次超时");
}
/**
@@ -100,7 +112,7 @@ public class BackendInternalClient {
*/
public JsonNode post(String path, Long tenantId, Map<String, Object> body) {
if (tenantId == null) {
throw new BizException(401, "专线请求缺少租户上下文,严禁跨租户或无租户调用");
throw new BizException(HttpStatus.UNAUTHORIZED.value(), "专线请求缺少租户上下文,严禁跨租户或无租户调用");
}
String fullUrl = backendBaseUrl + path;
@@ -108,10 +120,10 @@ public class BackendInternalClient {
String jsonStr = objectMapper.writeValueAsString(body != null ? body : new HashMap<>());
HttpRequest request = HttpRequest.newBuilder()
.uri(URI.create(fullUrl))
.header("X-Internal-Secret", internalSecret != null ? internalSecret : "")
.header("X-Tenant-Id", String.valueOf(tenantId))
.header("Content-Type", "application/json")
.timeout(Duration.ofSeconds(6))
.header(GatewayHeaders.INTERNAL_SECRET, internalSecret != null ? internalSecret : "")
.header(GatewayHeaders.TENANT_ID, String.valueOf(tenantId))
.header(HttpHeaders.CONTENT_TYPE, GatewayHeaders.CONTENT_TYPE_JSON)
.timeout(POST_TIMEOUT)
.POST(HttpRequest.BodyPublishers.ofString(jsonStr))
.build();
@@ -120,18 +132,18 @@ public class BackendInternalClient {
} catch (HttpTimeoutException | SocketTimeoutException e) {
log.error("核心后端 POST 请求超时 (未重试,防止指令重放): url={}, tenantId={}", fullUrl, tenantId);
throw new BizException(504, "核心控制通道通信超时,无法确认物理设备指令下发状态");
throw new BizException(HttpStatus.GATEWAY_TIMEOUT.value(), "核心控制通道通信超时,无法确认物理设备指令下发状态");
} catch (ConnectException e) {
log.error("核心后端连接失败: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage());
throw new BizException(503, "核心控制网关服务不可达");
throw new BizException(HttpStatus.SERVICE_UNAVAILABLE.value(), "核心控制网关服务不可达");
} catch (BizException be) {
throw be;
} catch (Exception e) {
log.error("核心后端 POST 调用异常: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage());
throw new BizException(502, "控制指令网关通信失败: " + e.getMessage());
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "控制指令网关通信失败: " + e.getMessage());
}
}
@@ -139,46 +151,46 @@ public class BackendInternalClient {
int status = resp.statusCode();
String body = resp.body();
if (status == 200) {
if (status == HttpStatus.OK.value()) {
try {
return objectMapper.readTree(body);
} catch (Exception e) {
log.error("核心后端响应非标准 JSON: body={}", body);
throw new BizException(502, "核心后端返回格式错误");
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "核心后端返回格式错误");
}
}
if (status == 401 || status == 403) {
if (status == HttpStatus.UNAUTHORIZED.value() || status == HttpStatus.FORBIDDEN.value()) {
log.error("专线内部鉴权失败: status={}, body={}, url={}", status, body, fullUrl);
throw new BizException(500, "内部通信专线密钥配置不匹配或租户无权访问");
throw new BizException(HttpStatus.INTERNAL_SERVER_ERROR.value(), "内部通信专线密钥配置不匹配或租户无权访问");
}
if (status == 404) {
throw new BizException(404, "核心后端未找到目标接口: " + fullUrl);
if (status == HttpStatus.NOT_FOUND.value()) {
throw new BizException(HttpStatus.NOT_FOUND.value(), "核心后端未找到目标接口: " + fullUrl);
}
log.warn("核心后端返回错误状态码: status={}, body={}, url={}", status, body, fullUrl);
try {
JsonNode errJson = objectMapper.readTree(body);
if (errJson.has("message")) {
throw new BizException(502, errJson.get("message").asText());
if (errJson.has(BackendResponse.FIELD_MESSAGE)) {
throw new BizException(HttpStatus.BAD_GATEWAY.value(), errJson.get(BackendResponse.FIELD_MESSAGE).asText());
}
} catch (BizException be) {
throw be;
} catch (Exception ignored) {}
throw new BizException(502, "核心后端服务返回异常代码: HTTP " + status);
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "核心后端服务返回异常代码: HTTP " + status);
}
private String buildGetUrl(String path, Map<String, String> queryParams) {
StringBuilder urlBuilder = new StringBuilder(backendBaseUrl).append(path);
if (queryParams != null && !queryParams.isEmpty()) {
urlBuilder.append("?");
urlBuilder.append(QUERY_START);
for (Map.Entry<String, String> entry : queryParams.entrySet()) {
if (entry.getValue() != null) {
String k = URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8);
String v = URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8);
urlBuilder.append(k).append("=").append(v).append("&");
urlBuilder.append(k).append(QUERY_KV).append(v).append(QUERY_AND);
}
}
urlBuilder.deleteCharAt(urlBuilder.length() - 1);
@@ -0,0 +1,46 @@
package com.iot.gateway.common;
import com.fasterxml.jackson.databind.JsonNode;
import java.util.List;
import org.springframework.http.HttpStatus;
/**
* 解析核心后端统一返回体 {@code Result<T>}(code / message / data)。
*/
public final class BackendResponse {
public static final String FIELD_CODE = "code";
public static final String FIELD_MESSAGE = "message";
public static final String FIELD_DATA = "data";
public static final String FIELD_SUCCESS = "success";
/** 分页数据:总数与当前页记录 */
public static final String FIELD_TOTAL = "total";
public static final String FIELD_RECORDS = "records";
private BackendResponse() {
}
/** 业务码为成功且带 data */
public static boolean isSuccess(JsonNode resp) {
return resp != null
&& resp.has(FIELD_CODE) && resp.get(FIELD_CODE).asInt() == HttpStatus.OK.value()
&& resp.has(FIELD_DATA);
}
public static JsonNode data(JsonNode resp) {
return resp.get(FIELD_DATA);
}
/** 取后端返回的提示语,没有时用 fallback */
public static String message(JsonNode resp, String fallback) {
return resp != null && resp.has(FIELD_MESSAGE) ? resp.get(FIELD_MESSAGE).asText() : fallback;
}
public static long total(JsonNode pageData) {
return pageData.has(FIELD_TOTAL) ? pageData.get(FIELD_TOTAL).asLong() : 0L;
}
public static Object records(JsonNode pageData) {
return pageData.has(FIELD_RECORDS) ? pageData.get(FIELD_RECORDS) : List.of();
}
}
@@ -1,6 +1,7 @@
package com.iot.gateway.common;
import lombok.Getter;
import org.springframework.http.HttpStatus;
@Getter
public class BizException extends RuntimeException {
@@ -8,7 +9,7 @@ public class BizException extends RuntimeException {
public BizException(String message) {
super(message);
this.code = 500;
this.code = HttpStatus.INTERNAL_SERVER_ERROR.value();
}
public BizException(Integer code, String message) {
@@ -0,0 +1,27 @@
package com.iot.gateway.common;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 启用状态(open_client.status、open_webhook.status 等 0/1 列)。
*/
@Getter
@AllArgsConstructor
public enum EnableStatusEnum {
DISABLED(0, "禁用"),
ENABLED(1, "启用");
private final Integer code;
private final String name;
public boolean matches(Integer value) {
return code.equals(value);
}
/** 状态值是否为启用,null 视为未启用 */
public static boolean isEnabled(Integer value) {
return ENABLED.matches(value);
}
}
@@ -0,0 +1,42 @@
package com.iot.gateway.common;
/**
* 网关收发的 HTTP 头名称与取值前缀。
*/
public final class GatewayHeaders {
/** 网关调用后端内部接口、或后端调用网关时携带的共享密钥 */
public static final String INTERNAL_SECRET = "X-Internal-Secret";
public static final String TENANT_ID = "X-Tenant-Id";
public static final String ADMIN_SECRET = "X-Gateway-Admin-Secret";
public static final String SIGNATURE = "X-Signature";
public static final String EVENT_TIMESTAMP = "X-Event-Timestamp";
public static final String HUB_SIGNATURE_256 = "X-Hub-Signature-256";
public static final String HUB_SIGNATURE_256_PREFIX = "sha256=";
public static final String RATE_LIMIT_LIMIT = "X-RateLimit-Limit";
public static final String RATE_LIMIT_REMAINING = "X-RateLimit-Remaining";
public static final String RETRY_AFTER = "Retry-After";
/** 关闭 Nginx 对 SSE 响应的缓冲 */
public static final String ACCEL_BUFFERING = "X-Accel-Buffering";
public static final String ACCEL_BUFFERING_OFF = "no";
public static final String FORWARDED_FOR = "X-Forwarded-For";
public static final String REAL_IP = "X-Real-IP";
public static final String PROXY_CLIENT_IP = "Proxy-Client-IP";
public static final String WL_PROXY_CLIENT_IP = "WL-Proxy-Client-IP";
public static final String USER_AGENT = "User-Agent";
/** OAuth2 令牌类型,同时是 Authorization 头的取值前缀(不含空格) */
public static final String BEARER = "Bearer";
public static final String BEARER_PREFIX = BEARER + " ";
public static final String CONTENT_TYPE_JSON_UTF8 = "application/json;charset=UTF-8";
public static final String CONTENT_TYPE_JSON = "application/json";
private GatewayHeaders() {
}
}
@@ -25,13 +25,13 @@ public class GlobalExceptionHandler {
if (e instanceof MethodArgumentNotValidException me && me.getBindingResult().getFieldError() != null) {
msg = me.getBindingResult().getFieldError().getDefaultMessage();
}
return Result.fail(400, msg);
return Result.fail(HttpStatus.BAD_REQUEST.value(), msg);
}
@ExceptionHandler(Exception.class)
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
public Result<Void> handleException(Exception e) {
log.error("系统未知异常: ", e);
return Result.fail(500, "网关处理异常: " + e.getMessage());
return Result.fail(HttpStatus.INTERNAL_SERVER_ERROR.value(), "网关处理异常: " + e.getMessage());
}
}
@@ -0,0 +1,16 @@
package com.iot.gateway.common;
/**
* 核心后端内部桥接接口路径({@code /api/internal/open/**}),REST OpenAPI 与 MCP 工具共用。
*/
public final class InternalPaths {
public static final String DEVICES = "/api/internal/open/devices";
public static final String DEVICE_DETAIL_PREFIX = DEVICES + "/";
public static final String TELEMETRY_LATEST = "/api/internal/open/telemetry/latest";
public static final String COMMANDS = "/api/internal/open/commands";
public static final String ALARMS = "/api/internal/open/alarms";
private InternalPaths() {
}
}
@@ -18,6 +18,17 @@ 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";
private static final String WILDCARD = "*";
private static final String ANY_CIDR = "0.0.0.0/0";
private static final String CIDR_SEPARATOR = "/";
private static final String IP_LIST_SEPARATOR = ",";
/** 代理配置的分隔符:逗号、分号、空白 */
private static final String PROXY_CONFIG_SPLIT_REGEX = "[,;\\s]+";
/** 白名单配置的分隔符:逗号、分号、中文逗号、换行 */
private static final String WHITELIST_SPLIT_REGEX = "[,;,\\n\\r]+";
private static final int BITS_PER_BYTE = 8;
private static final int BYTE_MASK = 0xFF;
private static final int CIDR_PART_COUNT = 2;
/**
* 默认信任的内网/本地反向代理 IP 网段 (如本地 Nginx / 宿主机代理)
@@ -34,7 +45,7 @@ public class IpUtils {
public static void setTrustedProxies(String proxiesConfig) {
if (StringUtils.hasText(proxiesConfig)) {
TRUSTED_PROXIES.clear();
for (String p : proxiesConfig.split("[,;\\s]+")) {
for (String p : proxiesConfig.split(PROXY_CONFIG_SPLIT_REGEX)) {
if (StringUtils.hasText(p)) {
TRUSTED_PROXIES.add(p.trim());
}
@@ -61,23 +72,23 @@ public class IpUtils {
}
// 仅在确认对端为受信任反向代理的前提下,提取代理传递的源 IP
String ip = request.getHeader("X-Forwarded-For");
String ip = request.getHeader(GatewayHeaders.FORWARDED_FOR);
if (isInvalidIp(ip)) {
ip = request.getHeader("X-Real-IP");
ip = request.getHeader(GatewayHeaders.REAL_IP);
}
if (isInvalidIp(ip)) {
ip = request.getHeader("Proxy-Client-IP");
ip = request.getHeader(GatewayHeaders.PROXY_CLIENT_IP);
}
if (isInvalidIp(ip)) {
ip = request.getHeader("WL-Proxy-Client-IP");
ip = request.getHeader(GatewayHeaders.WL_PROXY_CLIENT_IP);
}
if (isInvalidIp(ip)) {
ip = remoteAddr;
}
// 多级反向代理会有多个逗号分隔的 IP,首个为客户端真实源 IP
if (StringUtils.hasText(ip) && ip.contains(",")) {
for (String subIp : ip.split(",")) {
if (StringUtils.hasText(ip) && ip.contains(IP_LIST_SEPARATOR)) {
for (String subIp : ip.split(IP_LIST_SEPARATOR)) {
String trimmed = subIp.trim();
if (!isInvalidIp(trimmed)) {
ip = trimmed;
@@ -123,7 +134,7 @@ public class IpUtils {
String normalizedClientIp = LOCALHOST_IPV6.equals(clientIp.trim()) ? LOCALHOST_IPV4 : clientIp.trim();
String[] rules = whitelist.split("[,;,\\n\\r]+");
String[] rules = whitelist.split(WHITELIST_SPLIT_REGEX);
for (String rule : rules) {
String pattern = rule.trim();
if (!pattern.isEmpty() && matchIp(normalizedClientIp, pattern)) {
@@ -148,15 +159,15 @@ public class IpUtils {
if (LOCALHOST_IPV6.equals(ip)) ip = LOCALHOST_IPV4;
if (LOCALHOST_IPV6.equals(pattern)) pattern = LOCALHOST_IPV4;
if ("*".equals(pattern) || "0.0.0.0/0".equals(pattern)) {
if (WILDCARD.equals(pattern) || ANY_CIDR.equals(pattern)) {
return true;
}
if (pattern.contains("/")) {
if (pattern.contains(CIDR_SEPARATOR)) {
return matchCidr(ip, pattern);
}
if (pattern.contains("*")) {
if (pattern.contains(WILDCARD)) {
return matchWildcard(ip, pattern);
}
@@ -164,14 +175,14 @@ public class IpUtils {
}
private static boolean matchWildcard(String ip, String pattern) {
String regex = pattern.replace(".", "\\.").replace("*", ".*");
String regex = pattern.replace(".", "\\.").replace(WILDCARD, ".*");
return ip.matches(regex);
}
private static boolean matchCidr(String ip, String cidr) {
try {
String[] parts = cidr.split("/");
if (parts.length != 2) return false;
String[] parts = cidr.split(CIDR_SEPARATOR);
if (parts.length != CIDR_PART_COUNT) return false;
String network = parts[0];
int prefixLength = Integer.parseInt(parts[1]);
@@ -181,15 +192,15 @@ public class IpUtils {
if (ipBytes.length != netBytes.length) return false;
int byteCount = prefixLength / 8;
int bitCount = prefixLength % 8;
int byteCount = prefixLength / BITS_PER_BYTE;
int bitCount = prefixLength % BITS_PER_BYTE;
for (int i = 0; i < byteCount; i++) {
if (ipBytes[i] != netBytes[i]) return false;
}
if (bitCount > 0 && byteCount < ipBytes.length) {
int mask = (0xFF << (8 - bitCount)) & 0xFF;
int mask = (BYTE_MASK << (BITS_PER_BYTE - bitCount)) & BYTE_MASK;
return (ipBytes[byteCount] & mask) == (netBytes[byteCount] & mask);
}
@@ -0,0 +1,31 @@
package com.iot.gateway.common;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 设备在线状态:对外接口用 online/offline 过滤,对后端内部接口用 0/1。
*/
@Getter
@AllArgsConstructor
public enum OnlineStatusEnum {
OFFLINE(0, "offline"),
ONLINE(1, "online");
/** 后端内部接口的 online 查询参数名 */
public static final String PARAM_ONLINE = "online";
private final Integer code;
private final String filter;
/** 按对外过滤词(忽略大小写)解析,不是 online/offline(如 alarm、空)时返回 null */
public static OnlineStatusEnum ofFilter(String filter) {
for (OnlineStatusEnum item : values()) {
if (item.filter.equalsIgnoreCase(filter)) {
return item;
}
}
return null;
}
}
@@ -2,9 +2,12 @@ package com.iot.gateway.common;
import lombok.Data;
import java.io.Serializable;
import org.springframework.http.HttpStatus;
@Data
public class Result<T> implements Serializable {
private static final String SUCCESS_MESSAGE = "操作成功";
private Integer code;
private String message;
private T data;
@@ -18,15 +21,15 @@ public class Result<T> implements Serializable {
}
public static <T> Result<T> success() {
return new Result<>(200, "操作成功", null);
return new Result<>(HttpStatus.OK.value(), SUCCESS_MESSAGE, null);
}
public static <T> Result<T> success(T data) {
return new Result<>(200, "操作成功", data);
return new Result<>(HttpStatus.OK.value(), SUCCESS_MESSAGE, data);
}
public static <T> Result<T> fail(String message) {
return new Result<>(500, message, null);
return new Result<>(HttpStatus.INTERNAL_SERVER_ERROR.value(), message, null);
}
public static <T> Result<T> fail(Integer code, String message) {
@@ -0,0 +1,18 @@
package com.iot.gateway.mcp;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* MCP JSON-RPC 错误码(-32000 ~ -32099 为服务端自定义,-32601 为标准「方法不存在」)。
*/
@Getter
@AllArgsConstructor
public enum McpErrorEnum {
UNAUTHORIZED(-32001),
TENANT_MISMATCH(-32003),
METHOD_NOT_FOUND(-32601);
private final int code;
}
@@ -0,0 +1,24 @@
package com.iot.gateway.mcp;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 网关支持的 MCP JSON-RPC 方法。
*/
@Getter
@AllArgsConstructor
public enum McpMethodEnum {
INITIALIZE("initialize"),
INITIALIZED_NOTIFICATION("notifications/initialized"),
PING("ping"),
TOOLS_LIST("tools/list"),
TOOLS_CALL("tools/call");
private final String method;
public boolean matches(String value) {
return method.equalsIgnoreCase(value);
}
}
@@ -0,0 +1,55 @@
package com.iot.gateway.mcp;
/**
* MCP(Model Context Protocol)JSON-RPC 报文的字段名与固定取值。
*/
public final class McpProtocol {
public static final String JSONRPC_VERSION = "2.0";
public static final String PROTOCOL_VERSION = "2024-11-05";
public static final String SERVER_NAME = "iot-open-gateway";
public static final String SERVER_VERSION = "1.0.0";
/** JSON-RPC 请求/响应字段 */
public static final String FIELD_JSONRPC = "jsonrpc";
public static final String FIELD_ID = "id";
public static final String FIELD_METHOD = "method";
public static final String FIELD_PARAMS = "params";
public static final String FIELD_RESULT = "result";
public static final String FIELD_ERROR = "error";
public static final String FIELD_CODE = "code";
public static final String FIELD_MESSAGE = "message";
/** initialize 响应字段 */
public static final String FIELD_PROTOCOL_VERSION = "protocolVersion";
public static final String FIELD_SERVER_INFO = "serverInfo";
public static final String FIELD_CAPABILITIES = "capabilities";
public static final String FIELD_NAME = "name";
public static final String FIELD_VERSION = "version";
public static final String FIELD_LIST_CHANGED = "listChanged";
/** tools/list、tools/call 字段 */
public static final String FIELD_TOOLS = "tools";
public static final String FIELD_ARGUMENTS = "arguments";
public static final String FIELD_CONTENT = "content";
public static final String FIELD_IS_ERROR = "isError";
public static final String FIELD_TYPE = "type";
public static final String FIELD_TEXT = "text";
public static final String CONTENT_TYPE_TEXT = "text";
/** ping 的应答内容 */
public static final String FIELD_STATUS = "status";
public static final String STATUS_PONG = "pong";
/** SSE 事件名 */
public static final String SSE_EVENT_ENDPOINT = "endpoint";
public static final String SSE_EVENT_MESSAGE = "message";
public static final String MESSAGES_PATH = "/mcp/messages";
public static final String PARAM_SESSION_ID = "sessionId";
/** SSE 长连接保活时长:24 小时 */
public static final long SSE_TIMEOUT_MILLIS = 24L * 60 * 60 * 1000;
private McpProtocol() {
}
}
@@ -1,7 +1,9 @@
package com.iot.gateway.mcp;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iot.gateway.common.BizException;
import com.iot.gateway.common.GatewayHeaders;
import com.iot.gateway.oauth.GatewaySecurityContext;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -9,6 +11,7 @@ import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
@@ -19,6 +22,8 @@ import lombok.Data;
import lombok.NoArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.CacheControl;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
@@ -69,7 +74,7 @@ public class McpSseController {
// 严格认证:必须具备合法鉴权凭据与租户上下文,严禁匿名或跨租户访问
GatewaySecurityContext ctx = GatewaySecurityContext.get();
if (ctx == null) {
throw new BizException(401, "MCP 连接建立拒绝: 未提供有效的访问凭据,请携带 Bearer 令牌或在 URL 附加 ?access_token=");
throw new BizException(HttpStatus.UNAUTHORIZED.value(), "MCP 连接建立拒绝: 未提供有效的访问凭据,请携带 Bearer 令牌或在 URL 附加 ?access_token=");
}
Long effectiveTenantId = ctx.getTenantId();
@@ -77,15 +82,15 @@ public class McpSseController {
if (ctx.isAdmin() && tenantId != null) {
effectiveTenantId = tenantId;
} else {
throw new BizException(401, "MCP 连接建立拒绝: 缺少有效的租户授权上下文");
throw new BizException(HttpStatus.UNAUTHORIZED.value(), "MCP 连接建立拒绝: 缺少有效的租户授权上下文");
}
}
response.setHeader("Cache-Control", "no-cache");
response.setHeader("X-Accel-Buffering", "no");
response.setHeader(HttpHeaders.CACHE_CONTROL, CacheControl.noCache().getHeaderValue());
response.setHeader(GatewayHeaders.ACCEL_BUFFERING, GatewayHeaders.ACCEL_BUFFERING_OFF);
String sessionId = UUID.randomUUID().toString().replace("-", "");
SseEmitter emitter = new SseEmitter(24 * 60 * 60 * 1000L); // 24小时超长连接保活
SseEmitter emitter = new SseEmitter(McpProtocol.SSE_TIMEOUT_MILLIS); // 超长连接保活
McpSessionInfo sessionInfo = McpSessionInfo.builder()
.sessionId(sessionId)
@@ -118,8 +123,8 @@ public class McpSseController {
try {
// MCP 标准:首先发送 endpoint 事件,指定后续发送指令的 URL
emitter.send(SseEmitter.event()
.name("endpoint")
.data("/mcp/messages?sessionId=" + sessionId));
.name(McpProtocol.SSE_EVENT_ENDPOINT)
.data(McpProtocol.MESSAGES_PATH + "?" + McpProtocol.PARAM_SESSION_ID + "=" + sessionId));
log.info("MCP SSE 客户端握手建立成功: sessionId={}, clientId={}, tenantId={}",
sessionId, ctx.getClientId(), effectiveTenantId);
} catch (IOException e) {
@@ -132,18 +137,18 @@ public class McpSseController {
@Operation(summary = "处理 MCP JSON-RPC 消息请求")
@PostMapping("/messages")
public ResponseEntity<Map<String, Object>> handleMessage(
@RequestParam(required = false) String sessionId,
@RequestParam(value = McpProtocol.PARAM_SESSION_ID, required = false) String sessionId,
@RequestBody Map<String, Object> rpcRequest) {
String jsonrpc = String.valueOf(rpcRequest.getOrDefault("jsonrpc", "2.0"));
Object id = rpcRequest.get("id");
String method = String.valueOf(rpcRequest.get("method"));
String jsonrpc = String.valueOf(rpcRequest.getOrDefault(McpProtocol.FIELD_JSONRPC, McpProtocol.JSONRPC_VERSION));
Object id = rpcRequest.get(McpProtocol.FIELD_ID);
String method = String.valueOf(rpcRequest.get(McpProtocol.FIELD_METHOD));
@SuppressWarnings("unchecked")
Map<String, Object> params = (Map<String, Object>) rpcRequest.get("params");
Map<String, Object> params = (Map<String, Object>) rpcRequest.get(McpProtocol.FIELD_PARAMS);
Map<String, Object> rpcResponse = new HashMap<>();
rpcResponse.put("jsonrpc", jsonrpc);
rpcResponse.put("id", id);
rpcResponse.put(McpProtocol.FIELD_JSONRPC, jsonrpc);
rpcResponse.put(McpProtocol.FIELD_ID, id);
log.info("收到 MCP JSON-RPC 请求: method={}, id={}, sessionId={}", method, id, sessionId);
@@ -161,10 +166,8 @@ public class McpSseController {
if (currentAuth != null && currentAuth.getTenantId() != null
&& !currentAuth.isAdmin()
&& !currentAuth.getTenantId().equals(sessionInfo.getTenantId())) {
rpcResponse.put("error", Map.of(
"code", -32003,
"message", "安全拦截: 传入的认证凭据租户与当前 MCP 会话租户不一致"
));
rpcResponse.put(McpProtocol.FIELD_ERROR, rpcError(McpErrorEnum.TENANT_MISMATCH,
"安全拦截: 传入的认证凭据租户与当前 MCP 会话租户不一致"));
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(rpcResponse);
}
effectiveClientId = sessionInfo.getClientId();
@@ -179,72 +182,59 @@ public class McpSseController {
effectiveIsAdmin = currentAuth.isAdmin();
} else {
// 既无有效 sessionId 也无 Token,直接拒绝
rpcResponse.put("error", Map.of(
"code", -32001,
"message", "未授权访问: 缺少合法的 MCP 会话或 Bearer 访问凭据"
));
rpcResponse.put(McpProtocol.FIELD_ERROR, rpcError(McpErrorEnum.UNAUTHORIZED,
"未授权访问: 缺少合法的 MCP 会话或 Bearer 访问凭据"));
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(rpcResponse);
}
if ("initialize".equalsIgnoreCase(method)) {
if (McpMethodEnum.INITIALIZE.matches(method)) {
// 握手响应
Map<String, Object> serverInfo = Map.of(
"name", "iot-open-gateway",
"version", "1.0.0"
McpProtocol.FIELD_NAME, McpProtocol.SERVER_NAME,
McpProtocol.FIELD_VERSION, McpProtocol.SERVER_VERSION
);
Map<String, Object> capabilities = Map.of(
"tools", Map.of("listChanged", false)
McpProtocol.FIELD_TOOLS, Map.of(McpProtocol.FIELD_LIST_CHANGED, false)
);
Map<String, Object> result = Map.of(
"protocolVersion", "2024-11-05",
"serverInfo", serverInfo,
"capabilities", capabilities
McpProtocol.FIELD_PROTOCOL_VERSION, McpProtocol.PROTOCOL_VERSION,
McpProtocol.FIELD_SERVER_INFO, serverInfo,
McpProtocol.FIELD_CAPABILITIES, capabilities
);
rpcResponse.put("result", result);
} else if ("notifications/initialized".equalsIgnoreCase(method) || "ping".equalsIgnoreCase(method)) {
rpcResponse.put("result", Map.of("status", "pong"));
} else if ("tools/list".equalsIgnoreCase(method)) {
rpcResponse.put(McpProtocol.FIELD_RESULT, result);
} else if (McpMethodEnum.INITIALIZED_NOTIFICATION.matches(method) || McpMethodEnum.PING.matches(method)) {
rpcResponse.put(McpProtocol.FIELD_RESULT, Map.of(McpProtocol.FIELD_STATUS, McpProtocol.STATUS_PONG));
} else if (McpMethodEnum.TOOLS_LIST.matches(method)) {
// 列举所有工具
List<Map<String, Object>> tools = toolRegistry.getToolDefinitions();
rpcResponse.put("result", Map.of("tools", tools));
} else if ("tools/call".equalsIgnoreCase(method)) {
rpcResponse.put(McpProtocol.FIELD_RESULT, Map.of(McpProtocol.FIELD_TOOLS, tools));
} else if (McpMethodEnum.TOOLS_CALL.matches(method)) {
// 执行工具调用
String toolName = params != null ? String.valueOf(params.get("name")) : "";
String toolName = params != null ? String.valueOf(params.get(McpProtocol.FIELD_NAME)) : "";
@SuppressWarnings("unchecked")
Map<String, Object> arguments = params != null ? (Map<String, Object>) params.get("arguments") : Map.of();
Map<String, Object> arguments = params != null
? (Map<String, Object>) params.get(McpProtocol.FIELD_ARGUMENTS) : Map.of();
try {
Map<String, Object> toolResult = toolRegistry.executeTool(
toolName, arguments, effectiveTenantId, effectiveScopes, effectiveIsAdmin);
String contentJson = objectMapper.writeValueAsString(toolResult);
Map<String, Object> textContent = Map.of(
"type", "text",
"text", contentJson
);
rpcResponse.put("result", Map.of("content", List.of(textContent)));
rpcResponse.put(McpProtocol.FIELD_RESULT, textResult(objectMapper.writeValueAsString(toolResult), false));
} catch (BizException be) {
log.warn("执行 MCP 工具业务失败: tool={}, tenantId={}, code={}, msg={}",
toolName, effectiveTenantId, be.getCode(), be.getMessage());
Map<String, Object> errContent = Map.of(
"type", "text",
"text", "{\"error\": \"" + be.getMessage() + "\", \"code\": " + be.getCode() + "}"
);
rpcResponse.put("result", Map.of("content", List.of(errContent), "isError", true));
Map<String, Object> errBody = new LinkedHashMap<>();
errBody.put(McpProtocol.FIELD_ERROR, be.getMessage());
errBody.put(McpProtocol.FIELD_CODE, be.getCode());
rpcResponse.put(McpProtocol.FIELD_RESULT, textResult(toJson(errBody), true));
} catch (Exception e) {
log.error("执行 MCP 工具异常: tool={}, tenantId={}", toolName, effectiveTenantId, e);
Map<String, Object> errContent = Map.of(
"type", "text",
"text", "{\"error\": \"网关内部执行异常: " + e.getMessage() + "\"}"
);
rpcResponse.put("result", Map.of("content", List.of(errContent), "isError", true));
rpcResponse.put(McpProtocol.FIELD_RESULT, textResult(
toJson(Map.of(McpProtocol.FIELD_ERROR, "网关内部执行异常: " + e.getMessage())), true));
}
} else {
// 未知方法
rpcResponse.put("error", Map.of(
"code", -32601,
"message", "Method not found: " + method
));
rpcResponse.put(McpProtocol.FIELD_ERROR, rpcError(McpErrorEnum.METHOD_NOT_FOUND,
"Method not found: " + method));
}
// 如果存在活跃 SSE 会话,向 SSE 客户端推送消息事件
@@ -252,7 +242,7 @@ public class McpSseController {
try {
SseEmitter emitter = sessionMap.get(sessionId);
emitter.send(SseEmitter.event()
.name("message")
.name(McpProtocol.SSE_EVENT_MESSAGE)
.data(objectMapper.writeValueAsString(rpcResponse)));
} catch (Exception e) {
log.warn("向 SSE 会话推送消息失败: sessionId={}, err={}", sessionId, e.getMessage());
@@ -261,4 +251,30 @@ public class McpSseController {
return ResponseEntity.ok(rpcResponse);
}
private static Map<String, Object> rpcError(McpErrorEnum error, String message) {
return Map.of(McpProtocol.FIELD_CODE, error.getCode(), McpProtocol.FIELD_MESSAGE, message);
}
/** tools/call 的返回:单段文本内容,isError 仅在出错时附带 */
private static Map<String, Object> textResult(String text, boolean isError) {
Map<String, Object> textContent = Map.of(
McpProtocol.FIELD_TYPE, McpProtocol.CONTENT_TYPE_TEXT,
McpProtocol.FIELD_TEXT, text
);
Map<String, Object> result = new LinkedHashMap<>();
result.put(McpProtocol.FIELD_CONTENT, List.of(textContent));
if (isError) {
result.put(McpProtocol.FIELD_IS_ERROR, true);
}
return result;
}
private String toJson(Object value) {
try {
return objectMapper.writeValueAsString(value);
} catch (JsonProcessingException e) {
return String.valueOf(value);
}
}
}
@@ -0,0 +1,30 @@
package com.iot.gateway.mcp;
/**
* MCP 工具的入参名与返回字段名。
*/
public final class McpToolArgs {
public static final String STATUS = "status";
public static final String PAGE_NUM = "pageNum";
public static final String PAGE_SIZE = "pageSize";
public static final String DEVICE_ID = "deviceId";
public static final String COMMAND = "command";
public static final String PARAMS = "params";
public static final String SEVERITY = "severity";
public static final String RESULT_TENANT_ID = "tenantId";
public static final String RESULT_TOTAL = "total";
public static final String RESULT_DEVICES = "devices";
public static final String RESULT_DATA = "data";
public static final String RESULT_SUCCESS = "success";
public static final String RESULT_STATUS = "status";
public static final String RESULT_MESSAGE = "message";
public static final String RESULT_ALARMS = "alarms";
public static final String RESULT_IS_MOCK = "isMock";
public static final String RESULT_TIMESTAMP = "timestamp";
public static final String RESULT_TELEMETRY = "telemetry";
private McpToolArgs() {
}
}
@@ -0,0 +1,31 @@
package com.iot.gateway.mcp;
import com.iot.gateway.oauth.OpenScopeEnum;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* MCP 工具:名称与调用所需的授权范围。
*/
@Getter
@AllArgsConstructor
public enum McpToolEnum {
DEVICE_LIST("iot_device_list", OpenScopeEnum.DEVICE_READ),
TELEMETRY_LATEST("iot_telemetry_latest", OpenScopeEnum.TELEMETRY_READ),
DEVICE_CONTROL("iot_device_control", OpenScopeEnum.DEVICE_CONTROL),
ALARM_QUERY("iot_alarm_query", OpenScopeEnum.ALARM_READ);
private final String toolName;
private final OpenScopeEnum requiredScope;
/** 未知工具名返回 null */
public static McpToolEnum ofName(String toolName) {
for (McpToolEnum t : values()) {
if (t.toolName.equals(toolName)) {
return t;
}
}
return null;
}
}
@@ -2,7 +2,13 @@ package com.iot.gateway.mcp;
import com.fasterxml.jackson.databind.JsonNode;
import com.iot.gateway.common.BackendInternalClient;
import com.iot.gateway.common.BackendResponse;
import com.iot.gateway.common.BizException;
import com.iot.gateway.common.InternalPaths;
import com.iot.gateway.common.OnlineStatusEnum;
import com.iot.gateway.oauth.OpenScopeEnum;
import com.iot.gateway.oauth.ScopeMatcher;
import com.iot.gateway.openapi.CommandResultEnum;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
@@ -10,12 +16,32 @@ import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class McpToolRegistry {
/** JSON Schema 关键字 */
private static final String SCHEMA_TYPE = "type";
private static final String SCHEMA_DESCRIPTION = "description";
private static final String SCHEMA_PROPERTIES = "properties";
private static final String SCHEMA_REQUIRED = "required";
private static final String TYPE_OBJECT = "object";
private static final String TYPE_STRING = "string";
private static final String TYPE_INTEGER = "integer";
/** MCP 工具描述的字段名 */
private static final String TOOL_NAME = "name";
private static final String TOOL_DESCRIPTION = "description";
private static final String TOOL_INPUT_SCHEMA = "inputSchema";
/** 开发演示模式下的模拟数据 */
private static final String MOCK_DEVICE_CODE_1 = "DEV20261009001";
private static final String MOCK_DEVICE_CODE_2 = "DEV20261009002";
private static final int MOCK_ALARM_SEVERITY = 3;
@Autowired
private BackendInternalClient internalClient;
@@ -25,63 +51,56 @@ public class McpToolRegistry {
@Value("${gateway.mock.enabled:false}")
private boolean mockEnabled;
private static Map<String, Object> field(String type, String description) {
return Map.of(SCHEMA_TYPE, type, SCHEMA_DESCRIPTION, description);
}
private static Map<String, Object> tool(McpToolEnum toolEnum, String description,
Map<String, Object> properties, List<String> required) {
Map<String, Object> schema = new HashMap<>();
schema.put(SCHEMA_TYPE, TYPE_OBJECT);
schema.put(SCHEMA_PROPERTIES, properties);
if (required != null) {
schema.put(SCHEMA_REQUIRED, required);
}
Map<String, Object> tool = new HashMap<>();
tool.put(TOOL_NAME, toolEnum.getToolName());
tool.put(TOOL_DESCRIPTION, description + "所需权限范围: " + toolEnum.getRequiredScope().getCode());
tool.put(TOOL_INPUT_SCHEMA, schema);
return tool;
}
public List<Map<String, Object>> getToolDefinitions() {
List<Map<String, Object>> tools = new ArrayList<>();
// 1. iot_device_list
Map<String, Object> t1 = new HashMap<>();
t1.put("name", "iot_device_list");
t1.put("description", "查询当前租户名下的所有工业物联网设备台账与实时运行状态(在线/报警/停机)。所需权限范围: device.read");
Map<String, Object> s1 = new HashMap<>();
s1.put("type", "object");
Map<String, Object> p1 = new HashMap<>();
p1.put("status", Map.of("type", "string", "description", "设备状态过滤: online/offline/alarm,默认全部"));
p1.put("pageNum", Map.of("type", "integer", "description", "分页页码,默认 1"));
p1.put("pageSize", Map.of("type", "integer", "description", "每页条数,默认 10"));
s1.put("properties", p1);
t1.put("inputSchema", s1);
tools.add(t1);
Map<String, Object> deviceListProps = new HashMap<>();
deviceListProps.put(McpToolArgs.STATUS, field(TYPE_STRING, "设备状态过滤: online/offline/alarm,默认全部"));
deviceListProps.put(McpToolArgs.PAGE_NUM, field(TYPE_INTEGER, "分页页码,默认 1"));
deviceListProps.put(McpToolArgs.PAGE_SIZE, field(TYPE_INTEGER, "每页条数,默认 10"));
tools.add(tool(McpToolEnum.DEVICE_LIST,
"查询当前租户名下的所有工业物联网设备台账与实时运行状态(在线/报警/停机)。",
deviceListProps, null));
// 2. iot_telemetry_latest
Map<String, Object> t2 = new HashMap<>();
t2.put("name", "iot_telemetry_latest");
t2.put("description", "召测指定工业设备的最新实时点位遥测数据(注塑机温度、注塑压力、螺杆转速、能耗电流等)。所需权限范围: telemetry.read");
Map<String, Object> s2 = new HashMap<>();
s2.put("type", "object");
Map<String, Object> p2 = new HashMap<>();
p2.put("deviceId", Map.of("type", "string", "description", "设备唯一标识编码或编号"));
s2.put("properties", p2);
s2.put("required", List.of("deviceId"));
t2.put("inputSchema", s2);
tools.add(t2);
Map<String, Object> telemetryProps = new HashMap<>();
telemetryProps.put(McpToolArgs.DEVICE_ID, field(TYPE_STRING, "设备唯一标识编码或编号"));
tools.add(tool(McpToolEnum.TELEMETRY_LATEST,
"召测指定工业设备的最新实时点位遥测数据(注塑机温度、注塑压力、螺杆转速、能耗电流等)。",
telemetryProps, List.of(McpToolArgs.DEVICE_ID)));
// 3. iot_device_control
Map<String, Object> t3 = new HashMap<>();
t3.put("name", "iot_device_control");
t3.put("description", "向边缘物联设备安全下发反写控制指令(如设定注塑温度、设备启停控制、工艺参数微调)。所需权限范围: device.control");
Map<String, Object> s3 = new HashMap<>();
s3.put("type", "object");
Map<String, Object> p3 = new HashMap<>();
p3.put("deviceId", Map.of("type", "string", "description", "目标设备编码"));
p3.put("command", Map.of("type", "string", "description", "指令动作: start/stop/reboot/set_param"));
p3.put("params", Map.of("type", "object", "description", "指令参数键值对"));
s3.put("properties", p3);
s3.put("required", List.of("deviceId", "command"));
t3.put("inputSchema", s3);
tools.add(t3);
Map<String, Object> controlProps = new HashMap<>();
controlProps.put(McpToolArgs.DEVICE_ID, field(TYPE_STRING, "目标设备编码"));
controlProps.put(McpToolArgs.COMMAND, field(TYPE_STRING, "指令动作: start/stop/reboot/set_param"));
controlProps.put(McpToolArgs.PARAMS, field(TYPE_OBJECT, "指令参数键值对"));
tools.add(tool(McpToolEnum.DEVICE_CONTROL,
"向边缘物联设备安全下发反写控制指令(如设定注塑温度、设备启停控制、工艺参数微调)。",
controlProps, List.of(McpToolArgs.DEVICE_ID, McpToolArgs.COMMAND)));
// 4. iot_alarm_query
Map<String, Object> t4 = new HashMap<>();
t4.put("name", "iot_alarm_query");
t4.put("description", "按设备与时间范围检索未处理的设备故障与历史严重告警记录。所需权限范围: alarm.read");
Map<String, Object> s4 = new HashMap<>();
s4.put("type", "object");
Map<String, Object> p4 = new HashMap<>();
p4.put("deviceId", Map.of("type", "string", "description", "设备编码(可选)"));
p4.put("severity", Map.of("type", "integer", "description", "严重级别: 1提示, 2次要, 3严重, 4紧急"));
s4.put("properties", p4);
t4.put("inputSchema", s4);
tools.add(t4);
Map<String, Object> alarmProps = new HashMap<>();
alarmProps.put(McpToolArgs.DEVICE_ID, field(TYPE_STRING, "设备编码(可选)"));
alarmProps.put(McpToolArgs.SEVERITY, field(TYPE_INTEGER, "严重级别: 1提示, 2次要, 3严重, 4紧急"));
tools.add(tool(McpToolEnum.ALARM_QUERY,
"按设备与时间范围检索未处理的设备故障与历史严重告警记录。",
alarmProps, null));
return tools;
}
@@ -95,153 +114,154 @@ public class McpToolRegistry {
// 1. 严格租户隔离校验
if (tenantId == null) {
throw new BizException(401, "MCP 工具执行拒绝: 缺少有效的租户上下文,严禁匿名或跨租户执行");
throw new BizException(HttpStatus.UNAUTHORIZED.value(), "MCP 工具执行拒绝: 缺少有效的租户上下文,严禁匿名或跨租户执行");
}
// 2. 细粒度 Scope 校验
checkToolScope(toolName, scopes, isAdmin);
McpToolEnum tool = McpToolEnum.ofName(toolName);
checkToolScope(toolName, tool, scopes, isAdmin);
if (tool == null) {
throw new BizException(HttpStatus.BAD_REQUEST.value(), "未知的 MCP 工具名称: " + toolName);
}
Map<String, Object> result = new HashMap<>();
if ("iot_device_list".equals(toolName)) {
Map<String, String> params = new HashMap<>();
if (args != null && args.containsKey("status")) {
String st = String.valueOf(args.get("status"));
if ("online".equalsIgnoreCase(st)) params.put("online", "1");
else if ("offline".equalsIgnoreCase(st)) params.put("online", "0");
switch (tool) {
case DEVICE_LIST -> {
Map<String, String> params = new HashMap<>();
if (args != null && args.containsKey(McpToolArgs.STATUS)) {
OnlineStatusEnum onlineFilter = OnlineStatusEnum.ofFilter(String.valueOf(args.get(McpToolArgs.STATUS)));
if (onlineFilter != null) {
params.put(OnlineStatusEnum.PARAM_ONLINE, String.valueOf(onlineFilter.getCode()));
}
}
copyArg(args, params, McpToolArgs.PAGE_NUM);
copyArg(args, params, McpToolArgs.PAGE_SIZE);
// 专线调用核心业务后端多租户独立数据库
JsonNode resp = internalClient.get(InternalPaths.DEVICES, tenantId, params);
if (BackendResponse.isSuccess(resp)) {
JsonNode data = BackendResponse.data(resp);
result.put(McpToolArgs.RESULT_TENANT_ID, tenantId);
result.put(McpToolArgs.RESULT_TOTAL, BackendResponse.total(data));
result.put(McpToolArgs.RESULT_DEVICES, BackendResponse.records(data));
return result;
}
// 非 mock 模式下直接明确报错
if (!mockEnabled) {
String errMsg = BackendResponse.message(resp, "核心物联后端服务不可用或查询超时");
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "召测设备列表失败: " + errMsg);
}
// 开发演示兜底
result.put(McpToolArgs.RESULT_TENANT_ID, tenantId);
result.put(McpToolArgs.RESULT_IS_MOCK, true);
result.put(McpToolArgs.RESULT_TOTAL, 2);
result.put(McpToolArgs.RESULT_DEVICES, List.of(
Map.of("deviceCode", MOCK_DEVICE_CODE_1, "deviceName", "1号注塑机 (海天MA1200)", OnlineStatusEnum.PARAM_ONLINE, OnlineStatusEnum.ONLINE.getCode()),
Map.of("deviceCode", MOCK_DEVICE_CODE_2, "deviceName", "2号注塑机 (海天MA2000)", OnlineStatusEnum.PARAM_ONLINE, OnlineStatusEnum.OFFLINE.getCode())
));
}
if (args != null && args.containsKey("pageNum")) params.put("pageNum", String.valueOf(args.get("pageNum")));
if (args != null && args.containsKey("pageSize")) params.put("pageSize", String.valueOf(args.get("pageSize")));
case TELEMETRY_LATEST -> {
String deviceId = args != null && args.containsKey(McpToolArgs.DEVICE_ID)
? String.valueOf(args.get(McpToolArgs.DEVICE_ID)) : "";
if (deviceId.isEmpty()) {
throw new BizException(HttpStatus.BAD_REQUEST.value(), "缺少必填参数: " + McpToolArgs.DEVICE_ID);
}
// 专线调用核心业务后端多租户独立数据库
JsonNode resp = internalClient.get("/api/internal/open/devices", tenantId, params);
if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) {
JsonNode data = resp.get("data");
result.put("tenantId", tenantId);
result.put("total", data.has("total") ? data.get("total").asLong() : 0);
result.put("devices", data.has("records") ? data.get("records") : List.of());
return result;
JsonNode resp = internalClient.get(InternalPaths.TELEMETRY_LATEST, tenantId,
Map.of(McpToolArgs.DEVICE_ID, deviceId));
if (BackendResponse.isSuccess(resp)) {
result.put(McpToolArgs.RESULT_TENANT_ID, tenantId);
result.put(McpToolArgs.DEVICE_ID, deviceId);
result.put(McpToolArgs.RESULT_DATA, BackendResponse.data(resp));
return result;
}
if (!mockEnabled) {
String errMsg = BackendResponse.message(resp, "后端遥测接口不可用或设备无数据");
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "召测设备遥测失败: " + errMsg);
}
result.put(McpToolArgs.RESULT_IS_MOCK, true);
result.put(McpToolArgs.DEVICE_ID, deviceId);
result.put(McpToolArgs.RESULT_TIMESTAMP, System.currentTimeMillis());
result.put(McpToolArgs.RESULT_TELEMETRY, Map.of(
"barrel_temp_zone1", 215.4,
"barrel_temp_zone2", 220.1,
"injection_pressure_mpa", 13.8,
"screw_speed_rpm", 85.0
));
}
case DEVICE_CONTROL -> {
// 控制指令下发:绝不能在后端失败时误报“EXECUTED”
JsonNode resp = internalClient.post(InternalPaths.COMMANDS, tenantId, args);
if (BackendResponse.isSuccess(resp)) {
result.put(McpToolArgs.RESULT_TENANT_ID, tenantId);
result.put(McpToolArgs.RESULT_SUCCESS, true);
result.put(McpToolArgs.RESULT_STATUS, CommandResultEnum.EXECUTED.getCode());
result.put(McpToolArgs.RESULT_DATA, BackendResponse.data(resp));
return result;
}
// 非 mock 模式下直接明确报错
if (!mockEnabled) {
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "核心物联后端服务不可用或查询超时";
throw new BizException(502, "召测设备列表失败: " + errMsg);
String errMsg = BackendResponse.message(resp, "后端指令网关通信超时或未响应");
if (!mockEnabled) {
log.error("下发设备控制指令失败: tool={}, tenantId={}, args={}, err={}", toolName, tenantId, args, errMsg);
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "控制指令执行失败: " + errMsg);
}
result.put(McpToolArgs.RESULT_IS_MOCK, true);
result.put(McpToolArgs.RESULT_SUCCESS, false);
result.put(McpToolArgs.RESULT_STATUS, CommandResultEnum.MOCK_MODE_REFUSED.getCode());
result.put(McpToolArgs.RESULT_MESSAGE, "当前为开发演示模式,已拒绝向物理设备真实下发控制指令: " + errMsg);
}
case ALARM_QUERY -> {
Map<String, String> params = new HashMap<>();
copyArg(args, params, McpToolArgs.DEVICE_ID);
copyArg(args, params, McpToolArgs.SEVERITY);
// 开发演示兜底
result.put("tenantId", tenantId);
result.put("isMock", true);
result.put("total", 2);
result.put("devices", List.of(
Map.of("deviceCode", "DEV20261009001", "deviceName", "1号注塑机 (海天MA1200)", "online", 1),
Map.of("deviceCode", "DEV20261009002", "deviceName", "2号注塑机 (海天MA2000)", "online", 0)
));
} else if ("iot_telemetry_latest".equals(toolName)) {
String deviceId = args != null && args.containsKey("deviceId") ? String.valueOf(args.get("deviceId")) : "";
if (deviceId.isEmpty()) {
throw new BizException(400, "缺少必填参数: deviceId");
JsonNode resp = internalClient.get(InternalPaths.ALARMS, tenantId, params);
if (BackendResponse.isSuccess(resp)) {
result.put(McpToolArgs.RESULT_TENANT_ID, tenantId);
result.put(McpToolArgs.RESULT_ALARMS, BackendResponse.data(resp));
return result;
}
if (!mockEnabled) {
String errMsg = BackendResponse.message(resp, "后端告警接口不可用");
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "查询告警失败: " + errMsg);
}
result.put(McpToolArgs.RESULT_IS_MOCK, true);
result.put(McpToolArgs.RESULT_ALARMS, List.of(
Map.of("alarmId", "ALM-2026100901", McpToolArgs.DEVICE_ID, MOCK_DEVICE_CODE_1,
McpToolArgs.SEVERITY, MOCK_ALARM_SEVERITY, "title", "模具温度异常过高")
));
}
JsonNode resp = internalClient.get("/api/internal/open/telemetry/latest", tenantId, Map.of("deviceId", deviceId));
if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) {
result.put("tenantId", tenantId);
result.put("deviceId", deviceId);
result.put("data", resp.get("data"));
return result;
}
if (!mockEnabled) {
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "后端遥测接口不可用或设备无数据";
throw new BizException(502, "召测设备遥测失败: " + errMsg);
}
result.put("isMock", true);
result.put("deviceId", deviceId);
result.put("timestamp", System.currentTimeMillis());
result.put("telemetry", Map.of(
"barrel_temp_zone1", 215.4,
"barrel_temp_zone2", 220.1,
"injection_pressure_mpa", 13.8,
"screw_speed_rpm", 85.0
));
} else if ("iot_device_control".equals(toolName)) {
// 控制指令下发:绝不能在后端失败时误报“EXECUTED”
JsonNode resp = internalClient.post("/api/internal/open/commands", tenantId, args);
if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) {
result.put("tenantId", tenantId);
result.put("success", true);
result.put("status", "EXECUTED");
result.put("data", resp.get("data"));
return result;
}
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "后端指令网关通信超时或未响应";
if (!mockEnabled) {
log.error("下发设备控制指令失败: tool={}, tenantId={}, args={}, err={}", toolName, tenantId, args, errMsg);
throw new BizException(502, "控制指令执行失败: " + errMsg);
}
result.put("isMock", true);
result.put("success", false);
result.put("status", "MOCK_MODE_REFUSED");
result.put("message", "当前为开发演示模式,已拒绝向物理设备真实下发控制指令: " + errMsg);
} else if ("iot_alarm_query".equals(toolName)) {
Map<String, String> params = new HashMap<>();
if (args != null && args.containsKey("deviceId")) params.put("deviceId", String.valueOf(args.get("deviceId")));
if (args != null && args.containsKey("severity")) params.put("severity", String.valueOf(args.get("severity")));
JsonNode resp = internalClient.get("/api/internal/open/alarms", tenantId, params);
if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) {
result.put("tenantId", tenantId);
result.put("alarms", resp.get("data"));
return result;
}
if (!mockEnabled) {
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "后端告警接口不可用";
throw new BizException(502, "查询告警失败: " + errMsg);
}
result.put("isMock", true);
result.put("alarms", List.of(
Map.of("alarmId", "ALM-2026100901", "deviceId", "DEV20261009001", "severity", 3, "title", "模具温度异常过高")
));
} else {
throw new BizException(400, "未知的 MCP 工具名称: " + toolName);
}
return result;
}
private void checkToolScope(String toolName, List<String> scopes, boolean isAdmin) {
private static void copyArg(Map<String, Object> args, Map<String, String> params, String key) {
if (args != null && args.containsKey(key)) {
params.put(key, String.valueOf(args.get(key)));
}
}
/** 管理员放行;未知工具要求 all 通配;其余按工具声明的 scope 校验 */
private void checkToolScope(String toolName, McpToolEnum tool, List<String> scopes, boolean isAdmin) {
if (isAdmin) return;
String requiredScope;
switch (toolName) {
case "iot_device_list" -> requiredScope = "device.read";
case "iot_telemetry_latest" -> requiredScope = "telemetry.read";
case "iot_device_control" -> requiredScope = "device.control";
case "iot_alarm_query" -> requiredScope = "alarm.read";
default -> requiredScope = "all";
}
String requiredScope = tool != null ? tool.getRequiredScope().getCode() : ScopeMatcher.ALL;
if (scopes == null || scopes.isEmpty()) {
throw new BizException(403, "权限不足: 客户端未具备任何授权范围,无法调用 [" + toolName + "]");
throw new BizException(HttpStatus.FORBIDDEN.value(), "权限不足: 客户端未具备任何授权范围,无法调用 [" + toolName + "]");
}
String normReq = requiredScope.replace(":", ".");
boolean match = false;
for (String s : scopes) {
String norm = s.trim().replace(":", ".");
if (norm.equals(normReq) || norm.equals("all") || norm.equals("*")) {
match = true;
break;
}
}
if (!match) {
throw new BizException(403, "权限不足: 调用工具 [" + toolName + "] 需要具备 [" + requiredScope + "] 授权范围");
if (!ScopeMatcher.matches(scopes, requiredScope)) {
throw new BizException(HttpStatus.FORBIDDEN.value(), "权限不足: 调用工具 [" + toolName + "] 需要具备 [" + requiredScope + "] 授权范围");
}
}
}
@@ -0,0 +1,21 @@
package com.iot.gateway.oauth;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 核心后端签发的控制台令牌里的用户类型(JWT type 声明),取值与后端 UserTypeEnum 一致。
*/
@Getter
@AllArgsConstructor
public enum ConsoleUserTypeEnum {
PLATFORM("platform"),
TENANT("tenant");
private final String code;
public boolean matches(String value) {
return code.equalsIgnoreCase(value);
}
}
@@ -0,0 +1,38 @@
package com.iot.gateway.oauth;
/**
* 写入 Spring Security 的权限标识,以及 @PreAuthorize 用的授权表达式(须为编译期常量)。
*/
public final class GatewayAuthorities {
public static final String SCOPE_PREFIX = "SCOPE_";
public static final String ROLE_ADMIN = "ROLE_ADMIN";
public static final String ROLE_USER = "ROLE_USER";
public static final String ROLE_CLIENT = "ROLE_CLIENT";
/** 内部通道(X-Internal-Secret / X-Gateway-Admin-Secret)调用时的身份标识与名称 */
public static final String INTERNAL_ADMIN_ID = "internal_admin";
public static final String INTERNAL_ADMIN_NAME = "系统特权管理员";
public static final String SCOPE_ADMIN = SCOPE_PREFIX + ScopeMatcher.ADMIN;
public static final String SCOPE_ALL = SCOPE_PREFIX + ScopeMatcher.ALL;
public static final String SCOPE_TENANT_ADMIN = SCOPE_PREFIX + ScopeMatcher.TENANT_ADMIN;
/** 开放平台管理接口:管理员或租户管理员 */
public static final String EXPR_ADMIN_OR_TENANT_ADMIN =
"hasAnyAuthority('" + ROLE_ADMIN + "', '" + SCOPE_ADMIN + "', '" + SCOPE_TENANT_ADMIN + "')";
/** 持有指定 scope 或通配 all 即可;须是编译期常量,不能用方法拼接 */
public static final String EXPR_DEVICE_READ = "hasAuthority('" + SCOPE_PREFIX + OpenScopeEnum.CODE_DEVICE_READ
+ "') or hasAuthority('" + SCOPE_ALL + "')";
public static final String EXPR_TELEMETRY_READ = "hasAuthority('" + SCOPE_PREFIX + OpenScopeEnum.CODE_TELEMETRY_READ
+ "') or hasAuthority('" + SCOPE_ALL + "')";
public static final String EXPR_ALARM_READ = "hasAuthority('" + SCOPE_PREFIX + OpenScopeEnum.CODE_ALARM_READ
+ "') or hasAuthority('" + SCOPE_ALL + "')";
public static final String EXPR_DEVICE_CONTROL = "hasAuthority('" + SCOPE_PREFIX + OpenScopeEnum.CODE_DEVICE_CONTROL
+ "') or hasAuthority('" + SCOPE_ALL + "')";
private GatewayAuthorities() {
}
}
@@ -1,10 +1,12 @@
package com.iot.gateway.oauth;
import com.iot.gateway.common.BizException;
import java.util.List;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.springframework.http.HttpStatus;
@Data
@Builder
@@ -39,7 +41,7 @@ public class GatewaySecurityContext {
public static Long requireTenantId() {
GatewaySecurityContext ctx = get();
if (ctx == null || ctx.getTenantId() == null) {
throw new com.iot.gateway.common.BizException(401, "未识别到合法租户授权上下文,请先完成认证");
throw new BizException(HttpStatus.UNAUTHORIZED.value(), "未识别到合法租户授权上下文,请先完成认证");
}
return ctx.getTenantId();
}
@@ -50,15 +52,6 @@ public class GatewaySecurityContext {
}
public boolean hasScope(String requiredScope) {
if (admin) return true;
if (scopes == null || scopes.isEmpty()) return false;
String normalized = requiredScope != null ? requiredScope.replace(":", ".") : "";
for (String s : scopes) {
String norm = s.trim().replace(":", ".");
if (norm.equals(normalized) || norm.equals("all") || norm.equals("*")) {
return true;
}
}
return false;
return admin || ScopeMatcher.matches(scopes, requiredScope);
}
}
@@ -0,0 +1,20 @@
package com.iot.gateway.oauth;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* OAuth 2.0 授权模式。目前仅支持客户端凭据模式。
*/
@Getter
@AllArgsConstructor
public enum GrantTypeEnum {
CLIENT_CREDENTIALS("client_credentials");
private final String code;
public boolean matches(String value) {
return code.equalsIgnoreCase(value);
}
}
@@ -0,0 +1,26 @@
package com.iot.gateway.oauth;
/**
* JWT 声明名。含两类令牌:网关签发的三方客户端令牌,以及核心后端签发的控制台用户令牌。
*/
public final class JwtClaims {
/** 三方客户端令牌(网关签发) */
public static final String CLIENT_TENANT_ID = "tenant_id";
public static final String CLIENT_NAME = "client_name";
public static final String SCOPES = "scopes";
public static final String IP_WHITELIST = "ip_whitelist";
public static final String RATE_LIMIT = "rate_limit";
/** 控制台用户令牌(后端签发,名称须与后端 JwtUtil 一致) */
public static final String USER_ID = "uid";
public static final String USER_TYPE = "type";
public static final String USER_TENANT_ID = "tenantId";
public static final String USER_NICKNAME = "nickname";
/** 未配置限额的客户端默认每分钟调用次数 */
public static final int DEFAULT_RATE_LIMIT = 60;
private JwtClaims() {
}
}
@@ -10,6 +10,7 @@ import java.security.Key;
import java.util.Date;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
@@ -42,14 +43,14 @@ public class JwtTokenProvider {
public String generateToken(OpenClient client, String effectiveScopes) {
long nowMillis = System.currentTimeMillis();
Date now = new Date(nowMillis);
Date exp = new Date(nowMillis + tokenExpireSeconds * 1000);
Date exp = new Date(nowMillis + TimeUnit.SECONDS.toMillis(tokenExpireSeconds));
Map<String, Object> claims = new HashMap<>();
claims.put("tenant_id", client.getTenantId());
claims.put("client_name", client.getClientName());
claims.put("scopes", effectiveScopes != null ? effectiveScopes : client.getScopes());
claims.put("ip_whitelist", client.getIpWhitelist());
claims.put("rate_limit", client.getRateLimit() != null ? client.getRateLimit() : 60);
claims.put(JwtClaims.CLIENT_TENANT_ID, client.getTenantId());
claims.put(JwtClaims.CLIENT_NAME, client.getClientName());
claims.put(JwtClaims.SCOPES, effectiveScopes != null ? effectiveScopes : client.getScopes());
claims.put(JwtClaims.IP_WHITELIST, client.getIpWhitelist());
claims.put(JwtClaims.RATE_LIMIT, client.getRateLimit() != null ? client.getRateLimit() : JwtClaims.DEFAULT_RATE_LIMIT);
return Jwts.builder()
.setClaims(claims)
@@ -1,6 +1,9 @@
package com.iot.gateway.oauth;
import com.iot.gateway.common.BackendResponse;
import com.iot.gateway.common.GatewayHeaders;
import com.iot.gateway.common.IpUtils;
import com.fasterxml.jackson.databind.ObjectMapper;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
@@ -10,10 +13,14 @@ import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
@@ -25,6 +32,8 @@ import org.springframework.web.filter.OncePerRequestFilter;
@Component
public class OAuth2AuthenticationFilter extends OncePerRequestFilter {
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
@Autowired
private JwtTokenProvider tokenProvider;
@@ -39,13 +48,13 @@ public class OAuth2AuthenticationFilter extends OncePerRequestFilter {
throws ServletException, IOException {
// 1. 检查内部通道通信密钥 (例如核心后端调用网关,或特权管理脚本调用)
String requestInternalSecret = request.getHeader("X-Internal-Secret");
String requestAdminSecret = request.getHeader("X-Gateway-Admin-Secret");
String requestInternalSecret = request.getHeader(GatewayHeaders.INTERNAL_SECRET);
String requestAdminSecret = request.getHeader(GatewayHeaders.ADMIN_SECRET);
if ((StringUtils.hasText(internalSecret) && internalSecret.equals(requestInternalSecret)) ||
(StringUtils.hasText(adminSecret) && adminSecret.equals(requestAdminSecret))) {
String headerTenant = request.getHeader("X-Tenant-Id");
String headerTenant = request.getHeader(GatewayHeaders.TENANT_ID);
Long tenantId = null;
if (StringUtils.hasText(headerTenant)) {
try {
@@ -54,21 +63,21 @@ public class OAuth2AuthenticationFilter extends OncePerRequestFilter {
}
GatewaySecurityContext context = GatewaySecurityContext.builder()
.clientId("internal_admin")
.clientName("系统特权管理员")
.clientId(GatewayAuthorities.INTERNAL_ADMIN_ID)
.clientName(GatewayAuthorities.INTERNAL_ADMIN_NAME)
.tenantId(tenantId)
.scopes(List.of("admin", "all", "device.read", "device.control", "telemetry.read", "alarm.read"))
.scopes(ScopeMatcher.ADMIN_SCOPES)
.admin(true)
.build();
GatewaySecurityContext.set(context);
List<SimpleGrantedAuthority> authorities = List.of(
new SimpleGrantedAuthority("ROLE_ADMIN"),
new SimpleGrantedAuthority("SCOPE_admin"),
new SimpleGrantedAuthority("SCOPE_all")
new SimpleGrantedAuthority(GatewayAuthorities.ROLE_ADMIN),
new SimpleGrantedAuthority(GatewayAuthorities.SCOPE_ADMIN),
new SimpleGrantedAuthority(GatewayAuthorities.SCOPE_ALL)
);
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken("internal_admin", null, authorities);
new UsernamePasswordAuthenticationToken(GatewayAuthorities.INTERNAL_ADMIN_ID, null, authorities);
SecurityContextHolder.getContext().setAuthentication(auth);
try {
@@ -85,38 +94,38 @@ public class OAuth2AuthenticationFilter extends OncePerRequestFilter {
Claims claims = tokenProvider.parseToken(token);
if (claims != null) {
// 判断是后端用户登录凭据还是开放客户端凭据
Object uidObj = claims.get("uid");
Object userTypeObj = claims.get("type");
Object uidObj = claims.get(JwtClaims.USER_ID);
Object userTypeObj = claims.get(JwtClaims.USER_TYPE);
if (uidObj != null || userTypeObj != null) {
// 核心业务后端签发的管理员或租户用户 JWT (来自 Web 前端控制台)
String username = claims.getSubject();
String userType = userTypeObj != null ? String.valueOf(userTypeObj) : "";
Long tenantId = null;
Object tidObj = claims.get("tenantId");
Object tidObj = claims.get(JwtClaims.USER_TENANT_ID);
if (tidObj != null) {
try {
tenantId = Long.parseLong(String.valueOf(tidObj));
} catch (Exception ignored) {}
}
boolean isPlatformAdmin = "PLATFORM".equalsIgnoreCase(userType);
boolean isPlatformAdmin = ConsoleUserTypeEnum.PLATFORM.matches(userType);
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_USER"));
authorities.add(new SimpleGrantedAuthority(GatewayAuthorities.ROLE_USER));
if (isPlatformAdmin) {
authorities.add(new SimpleGrantedAuthority("ROLE_ADMIN"));
authorities.add(new SimpleGrantedAuthority("SCOPE_admin"));
authorities.add(new SimpleGrantedAuthority("SCOPE_all"));
authorities.add(new SimpleGrantedAuthority(GatewayAuthorities.ROLE_ADMIN));
authorities.add(new SimpleGrantedAuthority(GatewayAuthorities.SCOPE_ADMIN));
authorities.add(new SimpleGrantedAuthority(GatewayAuthorities.SCOPE_ALL));
} else {
authorities.add(new SimpleGrantedAuthority("SCOPE_tenant_admin"));
authorities.add(new SimpleGrantedAuthority("SCOPE_admin")); // 租户管理员亦可管理自己租户的客户端
authorities.add(new SimpleGrantedAuthority(GatewayAuthorities.SCOPE_TENANT_ADMIN));
authorities.add(new SimpleGrantedAuthority(GatewayAuthorities.SCOPE_ADMIN)); // 租户管理员亦可管理自己租户的客户端
}
GatewaySecurityContext context = GatewaySecurityContext.builder()
.clientId(username)
.clientName(claims.get("nickname", String.class))
.clientName(claims.get(JwtClaims.USER_NICKNAME, String.class))
.tenantId(tenantId)
.scopes(List.of("admin", "all"))
.scopes(ScopeMatcher.CONSOLE_SCOPES)
.admin(isPlatformAdmin)
.build();
GatewaySecurityContext.set(context);
@@ -127,10 +136,10 @@ public class OAuth2AuthenticationFilter extends OncePerRequestFilter {
} else {
// 三方应用 OAuth 2.0 客户端凭证 Token
String clientId = claims.getSubject();
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);
Long tenantId = claims.get(JwtClaims.CLIENT_TENANT_ID, Long.class);
String clientName = claims.get(JwtClaims.CLIENT_NAME, String.class);
String scopesStr = claims.get(JwtClaims.SCOPES, String.class);
String ipWhitelist = claims.get(JwtClaims.IP_WHITELIST, String.class);
// 校验客户端当前请求 IP 是否在白名单内
if (StringUtils.hasText(ipWhitelist)) {
@@ -138,15 +147,19 @@ public class OAuth2AuthenticationFilter extends OncePerRequestFilter {
if (!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}");
response.setStatus(HttpStatus.FORBIDDEN.value());
response.setContentType(GatewayHeaders.CONTENT_TYPE_JSON_UTF8);
Map<String, Object> body = new LinkedHashMap<>();
body.put(BackendResponse.FIELD_CODE, HttpStatus.FORBIDDEN.value());
body.put(BackendResponse.FIELD_MESSAGE, "客户端来源 IP [" + clientIp + "] 不在授权白名单内");
body.put(BackendResponse.FIELD_SUCCESS, false);
response.getWriter().write(OBJECT_MAPPER.writeValueAsString(body));
return;
}
}
List<String> scopes = StringUtils.hasText(scopesStr)
? Arrays.asList(scopesStr.split(","))
? Arrays.asList(scopesStr.split(ScopeMatcher.JOINER))
: Collections.emptyList();
// 严禁无租户凭据被默认落入租户 1
@@ -160,10 +173,10 @@ public class OAuth2AuthenticationFilter extends OncePerRequestFilter {
GatewaySecurityContext.set(context);
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
authorities.add(new SimpleGrantedAuthority("ROLE_CLIENT"));
authorities.add(new SimpleGrantedAuthority(GatewayAuthorities.ROLE_CLIENT));
for (String s : scopes) {
String norm = s.trim().replace(":", ".");
authorities.add(new SimpleGrantedAuthority("SCOPE_" + norm));
authorities.add(new SimpleGrantedAuthority(
GatewayAuthorities.SCOPE_PREFIX + ScopeMatcher.normalize(s)));
}
UsernamePasswordAuthenticationToken auth =
@@ -181,14 +194,14 @@ public class OAuth2AuthenticationFilter extends OncePerRequestFilter {
}
private String resolveToken(HttpServletRequest request) {
String bearer = request.getHeader("Authorization");
if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) {
return bearer.substring(7).trim();
String bearer = request.getHeader(HttpHeaders.AUTHORIZATION);
if (StringUtils.hasText(bearer) && bearer.startsWith(GatewayHeaders.BEARER_PREFIX)) {
return bearer.substring(GatewayHeaders.BEARER_PREFIX.length()).trim();
}
// 允许通过 ?token= 或 ?access_token= 查询参数传递 (供 SSE / EventSource 连接时携带)
String queryToken = request.getParameter("access_token");
String queryToken = request.getParameter(OAuthParams.ACCESS_TOKEN);
if (!StringUtils.hasText(queryToken)) {
queryToken = request.getParameter("token");
queryToken = request.getParameter(OAuthParams.QUERY_TOKEN);
}
if (StringUtils.hasText(queryToken)) {
return queryToken.trim();
@@ -3,6 +3,8 @@ package com.iot.gateway.oauth;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iot.gateway.common.EnableStatusEnum;
import com.iot.gateway.common.GatewayHeaders;
import com.iot.gateway.common.IpUtils;
import com.iot.gateway.entity.OpenClient;
import com.iot.gateway.mapper.OpenClientMapper;
@@ -17,6 +19,7 @@ import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.util.StringUtils;
@@ -45,10 +48,10 @@ public class OAuth2TokenController {
@Operation(summary = "客户端凭据模式换取访问令牌 (支持 form-urlencoded 与 application/json)")
@PostMapping(value = "/token")
public ResponseEntity<Map<String, Object>> issueToken(
@RequestParam(value = "grant_type", required = false) String formGrantType,
@RequestParam(value = "client_id", required = false) String formClientId,
@RequestParam(value = "client_secret", required = false) String formClientSecret,
@RequestParam(value = "scope", required = false) String formScope,
@RequestParam(value = OAuthParams.GRANT_TYPE, required = false) String formGrantType,
@RequestParam(value = OAuthParams.CLIENT_ID, required = false) String formClientId,
@RequestParam(value = OAuthParams.CLIENT_SECRET, required = false) String formClientSecret,
@RequestParam(value = OAuthParams.SCOPE, required = false) String formScope,
jakarta.servlet.http.HttpServletRequest request) {
String grantType = formGrantType;
@@ -59,48 +62,41 @@ public class OAuth2TokenController {
// 兼容自适应解析 application/json 请求载荷
String contentType = request.getContentType();
if ((!StringUtils.hasText(clientId) || !StringUtils.hasText(clientSecret))
&& contentType != null && contentType.toLowerCase().contains("application/json")) {
&& contentType != null && contentType.toLowerCase().contains(MediaType.APPLICATION_JSON_VALUE)) {
try {
JsonNode root = objectMapper.readTree(request.getInputStream());
if (root != null) {
if (grantType == null && root.has("grant_type")) grantType = root.get("grant_type").asText();
if (clientId == null && root.has("client_id")) clientId = root.get("client_id").asText();
if (clientSecret == null && root.has("client_secret")) clientSecret = root.get("client_secret").asText();
if (scope == null && root.has("scope")) scope = root.get("scope").asText();
if (grantType == null && root.has(OAuthParams.GRANT_TYPE)) grantType = root.get(OAuthParams.GRANT_TYPE).asText();
if (clientId == null && root.has(OAuthParams.CLIENT_ID)) clientId = root.get(OAuthParams.CLIENT_ID).asText();
if (clientSecret == null && root.has(OAuthParams.CLIENT_SECRET)) clientSecret = root.get(OAuthParams.CLIENT_SECRET).asText();
if (scope == null && root.has(OAuthParams.SCOPE)) scope = root.get(OAuthParams.SCOPE).asText();
}
} catch (Exception e) {
log.warn("解析 JSON 格式 OAuth 请求体异常: {}", e.getMessage());
}
}
Map<String, Object> error = new HashMap<>();
if (!"client_credentials".equalsIgnoreCase(grantType)) {
error.put("error", "unsupported_grant_type");
error.put("error_description", "仅支持 client_credentials 客户端凭据授权模式");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
if (!GrantTypeEnum.CLIENT_CREDENTIALS.matches(grantType)) {
return error(HttpStatus.BAD_REQUEST, OAuthErrorEnum.UNSUPPORTED_GRANT_TYPE,
"仅支持 client_credentials 客户端凭据授权模式");
}
if (!StringUtils.hasText(clientId) || !StringUtils.hasText(clientSecret)) {
error.put("error", "invalid_client");
error.put("error_description", "client_id 或 client_secret 不能为空");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
return error(HttpStatus.UNAUTHORIZED, OAuthErrorEnum.INVALID_CLIENT, "client_id 或 client_secret 不能为空");
}
OpenClient client = clientMapper.selectOne(new LambdaQueryWrapper<OpenClient>()
.eq(OpenClient::getClientId, clientId.trim()));
if (client == null) {
error.put("error", "invalid_client");
error.put("error_description", "客户端标识或密钥错误");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
return error(HttpStatus.UNAUTHORIZED, OAuthErrorEnum.INVALID_CLIENT, "客户端标识或密钥错误");
}
// 密钥比对:优先支持 BCrypt 哈希比对;兼顾历史明文并自动平滑升级为 BCrypt 哈希
boolean secretMatch = false;
String storedSecret = client.getClientSecret();
if (storedSecret != null) {
if (storedSecret.startsWith("$2a$") || storedSecret.startsWith("$2b$")) {
if (storedSecret.startsWith(OAuthParams.BCRYPT_PREFIX_2A) || storedSecret.startsWith(OAuthParams.BCRYPT_PREFIX_2B)) {
secretMatch = passwordEncoder.matches(clientSecret, storedSecret);
} else if (storedSecret.equals(clientSecret)) {
secretMatch = true;
@@ -111,21 +107,15 @@ public class OAuth2TokenController {
}
if (!secretMatch) {
error.put("error", "invalid_client");
error.put("error_description", "客户端标识或密钥错误");
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error);
return error(HttpStatus.UNAUTHORIZED, OAuthErrorEnum.INVALID_CLIENT, "客户端标识或密钥错误");
}
if (client.getStatus() == null || client.getStatus() != 1) {
error.put("error", "unauthorized_client");
error.put("error_description", "客户端已被禁用,无法申请令牌");
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
if (!EnableStatusEnum.isEnabled(client.getStatus())) {
return error(HttpStatus.FORBIDDEN, OAuthErrorEnum.UNAUTHORIZED_CLIENT, "客户端已被禁用,无法申请令牌");
}
if (client.getExpireTime() != null && client.getExpireTime().isBefore(LocalDateTime.now())) {
error.put("error", "expired_token");
error.put("error_description", "客户端凭证已过期");
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error);
return error(HttpStatus.FORBIDDEN, OAuthErrorEnum.EXPIRED_TOKEN, "客户端凭证已过期");
}
// 校验客户端来源 IP 白名单(基于可信反向代理验证)
@@ -133,32 +123,25 @@ public class OAuth2TokenController {
if (!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);
return error(HttpStatus.FORBIDDEN, OAuthErrorEnum.UNAUTHORIZED_CLIENT,
"客户端来源 IP [" + clientIp + "] 不在允许的白名单范围内");
}
// 验证 Scope 并支持合法收窄 (Scope Narrowing)
String effectiveScopes = client.getScopes();
if (StringUtils.hasText(scope)) {
List<String> clientAllowed = StringUtils.hasText(client.getScopes())
? Arrays.asList(client.getScopes().split("[,;\\s]+"))
? Arrays.asList(client.getScopes().split(ScopeMatcher.SEPARATOR_REGEX))
: Collections.emptyList();
List<String> requested = Arrays.asList(scope.split("[,;\\s]+"));
List<String> requested = Arrays.asList(scope.split(ScopeMatcher.SEPARATOR_REGEX));
for (String reqScope : requested) {
String reqNorm = reqScope.trim().replace(":", ".");
boolean allowed = clientAllowed.stream().anyMatch(ca -> {
String caNorm = ca.trim().replace(":", ".");
return caNorm.equals(reqNorm) || caNorm.equals("all") || caNorm.equals("*");
});
if (!allowed) {
error.put("error", "invalid_scope");
error.put("error_description", "请求的作用域 [" + reqScope + "] 超出该客户端的允许授权范围");
return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error);
if (!ScopeMatcher.matches(clientAllowed, reqScope)) {
return error(HttpStatus.BAD_REQUEST, OAuthErrorEnum.INVALID_SCOPE,
"请求的作用域 [" + reqScope + "] 超出该客户端的允许授权范围");
}
}
effectiveScopes = String.join(",", requested);
effectiveScopes = String.join(ScopeMatcher.JOINER, requested);
}
String accessToken = jwtTokenProvider.generateToken(client, effectiveScopes);
@@ -166,13 +149,20 @@ public class OAuth2TokenController {
clientId, clientIp, client.getTenantId(), effectiveScopes);
Map<String, Object> response = new HashMap<>();
response.put("access_token", accessToken);
response.put("token_type", "Bearer");
response.put("expires_in", jwtTokenProvider.getTokenExpireSeconds());
response.put("scope", effectiveScopes);
response.put("tenant_id", client.getTenantId());
response.put("client_name", client.getClientName());
response.put(OAuthParams.ACCESS_TOKEN, accessToken);
response.put(OAuthParams.TOKEN_TYPE, GatewayHeaders.BEARER);
response.put(OAuthParams.EXPIRES_IN, jwtTokenProvider.getTokenExpireSeconds());
response.put(OAuthParams.SCOPE, effectiveScopes);
response.put(OAuthParams.TENANT_ID, client.getTenantId());
response.put(OAuthParams.CLIENT_NAME, client.getClientName());
return ResponseEntity.ok(response);
}
private ResponseEntity<Map<String, Object>> error(HttpStatus status, OAuthErrorEnum error, String description) {
Map<String, Object> body = new HashMap<>();
body.put(OAuthErrorEnum.FIELD_ERROR, error.getCode());
body.put(OAuthErrorEnum.FIELD_DESCRIPTION, description);
return ResponseEntity.status(status).body(body);
}
}
@@ -0,0 +1,24 @@
package com.iot.gateway.oauth;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* OAuth 2.0 令牌端点的错误码(RFC 6749 §5.2),expired_token 为本网关扩展。
*/
@Getter
@AllArgsConstructor
public enum OAuthErrorEnum {
UNSUPPORTED_GRANT_TYPE("unsupported_grant_type"),
INVALID_CLIENT("invalid_client"),
UNAUTHORIZED_CLIENT("unauthorized_client"),
EXPIRED_TOKEN("expired_token"),
INVALID_SCOPE("invalid_scope");
/** 响应体字段名 */
public static final String FIELD_ERROR = "error";
public static final String FIELD_DESCRIPTION = "error_description";
private final String code;
}
@@ -0,0 +1,28 @@
package com.iot.gateway.oauth;
/**
* OAuth 2.0 令牌端点的请求参数与响应字段名。
*/
public final class OAuthParams {
public static final String GRANT_TYPE = "grant_type";
public static final String CLIENT_ID = "client_id";
public static final String CLIENT_SECRET = "client_secret";
public static final String SCOPE = "scope";
public static final String ACCESS_TOKEN = "access_token";
public static final String TOKEN_TYPE = "token_type";
public static final String EXPIRES_IN = "expires_in";
public static final String TENANT_ID = "tenant_id";
public static final String CLIENT_NAME = "client_name";
/** 供 SSE / EventSource 无法带请求头时,用查询参数传令牌的备用参数名 */
public static final String QUERY_TOKEN = "token";
/** BCrypt 哈希的两种前缀,用于区分历史明文密钥 */
public static final String BCRYPT_PREFIX_2A = "$2a$";
public static final String BCRYPT_PREFIX_2B = "$2b$";
private OAuthParams() {
}
}
@@ -1,6 +1,8 @@
package com.iot.gateway.oauth;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iot.gateway.common.BackendResponse;
import com.iot.gateway.common.GatewayHeaders;
import com.iot.gateway.common.IpUtils;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
@@ -10,10 +12,13 @@ import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import org.springframework.web.filter.OncePerRequestFilter;
@@ -26,6 +31,11 @@ import org.springframework.web.filter.OncePerRequestFilter;
@Order(10)
public class OpenClientRateLimitFilter extends OncePerRequestFilter {
private static final long MILLIS_PER_MINUTE = TimeUnit.MINUTES.toMillis(1);
private static final String COUNTER_KEY_SEPARATOR = ":";
/** 计数器缓存超过该规模时,清理过期分钟的条目 */
private static final int COUNTER_CLEANUP_THRESHOLD = 5000;
private final ObjectMapper objectMapper = new ObjectMapper();
/** 客户端 1 分钟滑动窗口计数器缓存: key = clientId + ":" + (epochMinute) */
@@ -46,23 +56,21 @@ public class OpenClientRateLimitFilter extends OncePerRequestFilter {
String clientId = ctx.getClientId();
int rateLimit = resolveRateLimit(request);
long currentMinute = System.currentTimeMillis() / 60000;
String counterKey = clientId + ":" + currentMinute;
long currentMinute = System.currentTimeMillis() / MILLIS_PER_MINUTE;
String counterKey = clientId + COUNTER_KEY_SEPARATOR + currentMinute;
AtomicInteger counter = counterMap.computeIfAbsent(counterKey, k -> new AtomicInteger(0));
int currentCount = counter.incrementAndGet();
// 定期清理过期分钟的计数器,防止内存泄漏
if (counterMap.size() > 5000) {
if (counterMap.size() > COUNTER_CLEANUP_THRESHOLD) {
counterMap.keySet().removeIf(k -> {
String[] parts = k.split(":");
if (parts.length == 2) {
try {
long min = Long.parseLong(parts[1]);
return min < currentMinute;
} catch (NumberFormatException ignored) {}
String minutePart = k.substring(k.lastIndexOf(COUNTER_KEY_SEPARATOR) + 1);
try {
return Long.parseLong(minutePart) < currentMinute;
} catch (NumberFormatException ignored) {
return false;
}
return false;
});
}
@@ -70,21 +78,21 @@ public class OpenClientRateLimitFilter extends OncePerRequestFilter {
log.warn("OAuth 2.0 客户端调用超限触发拦截 (429): clientId={}, currentCount={}, rateLimit={}/min, path={}",
clientId, currentCount, rateLimit, request.getRequestURI());
response.setStatus(429); // 429 Too Many Requests
response.setHeader("Retry-After", "60");
response.setHeader("X-RateLimit-Limit", String.valueOf(rateLimit));
response.setHeader("X-RateLimit-Remaining", "0");
response.setContentType("application/json;charset=UTF-8");
response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
response.setHeader(GatewayHeaders.RETRY_AFTER, String.valueOf(TimeUnit.MINUTES.toSeconds(1)));
response.setHeader(GatewayHeaders.RATE_LIMIT_LIMIT, String.valueOf(rateLimit));
response.setHeader(GatewayHeaders.RATE_LIMIT_REMAINING, String.valueOf(0));
response.setContentType(GatewayHeaders.CONTENT_TYPE_JSON_UTF8);
response.getWriter().write(objectMapper.writeValueAsString(Map.of(
"code", 429,
"success", false,
"message", "客户端请求频率超出配额限制 (限额: " + rateLimit + " 次/分钟),请稍后重试"
BackendResponse.FIELD_CODE, HttpStatus.TOO_MANY_REQUESTS.value(),
BackendResponse.FIELD_SUCCESS, false,
BackendResponse.FIELD_MESSAGE, "客户端请求频率超出配额限制 (限额: " + rateLimit + " 次/分钟),请稍后重试"
)));
return;
}
response.setHeader("X-RateLimit-Limit", String.valueOf(rateLimit));
response.setHeader("X-RateLimit-Remaining", String.valueOf(Math.max(0, rateLimit - currentCount)));
response.setHeader(GatewayHeaders.RATE_LIMIT_LIMIT, String.valueOf(rateLimit));
response.setHeader(GatewayHeaders.RATE_LIMIT_REMAINING, String.valueOf(Math.max(0, rateLimit - currentCount)));
}
try {
@@ -102,17 +110,17 @@ public class OpenClientRateLimitFilter extends OncePerRequestFilter {
}
private int resolveRateLimit(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7).trim();
String authHeader = request.getHeader(HttpHeaders.AUTHORIZATION);
if (StringUtils.hasText(authHeader) && authHeader.startsWith(GatewayHeaders.BEARER_PREFIX)) {
String token = authHeader.substring(GatewayHeaders.BEARER_PREFIX.length()).trim();
Claims claims = jwtTokenProvider.parseToken(token);
if (claims != null && claims.containsKey("rate_limit")) {
Object rl = claims.get("rate_limit");
if (claims != null && claims.containsKey(JwtClaims.RATE_LIMIT)) {
Object rl = claims.get(JwtClaims.RATE_LIMIT);
if (rl instanceof Number) {
return ((Number) rl).intValue();
}
}
}
return 60; // 默认每分钟 60 次
return JwtClaims.DEFAULT_RATE_LIMIT;
}
}
@@ -12,10 +12,16 @@ import java.util.Map;
@Getter
public enum OpenScopeEnum {
DEVICE_READ("device.read", "设备台账读取", "允许拉取租户名下设备台账、在线状态与点位元数据", "设备资产", "LOW"),
TELEMETRY_READ("telemetry.read", "实时遥测召测", "允许实时读取料筒温度、射胶压力、螺杆转速及能耗电流等实时点位", "工况监控", "LOW"),
ALARM_READ("alarm.read", "故障告警检索", "允许检索产线设备的未确认告警、严重级别及历史故障原因", "异常告警", "LOW"),
DEVICE_CONTROL("device.control", "设备反向控制", "高危权限!允许向边缘设备下发启停控制与工艺参数反写指令", "反向控制", "HIGH");
DEVICE_READ(OpenScopeEnum.CODE_DEVICE_READ, "设备台账读取", "允许拉取租户名下设备台账、在线状态与点位元数据", "设备资产", "LOW"),
TELEMETRY_READ(OpenScopeEnum.CODE_TELEMETRY_READ, "实时遥测召测", "允许实时读取料筒温度、射胶压力、螺杆转速及能耗电流等实时点位", "工况监控", "LOW"),
ALARM_READ(OpenScopeEnum.CODE_ALARM_READ, "故障告警检索", "允许检索产线设备的未确认告警、严重级别及历史故障原因", "异常告警", "LOW"),
DEVICE_CONTROL(OpenScopeEnum.CODE_DEVICE_CONTROL, "设备反向控制", "高危权限!允许向边缘设备下发启停控制与工艺参数反写指令", "反向控制", "HIGH");
/** 编码常量,供 @PreAuthorize 等必须是编译期常量的注解表达式引用 */
public static final String CODE_DEVICE_READ = "device.read";
public static final String CODE_TELEMETRY_READ = "telemetry.read";
public static final String CODE_ALARM_READ = "alarm.read";
public static final String CODE_DEVICE_CONTROL = "device.control";
private final String code;
private final String name;
@@ -0,0 +1,58 @@
package com.iot.gateway.oauth;
import java.util.Collection;
import java.util.List;
/**
* 授权范围(scope)的规范化与匹配。同一套规则供令牌签发、请求授权与 MCP 工具授权共用。
*/
public final class ScopeMatcher {
/** 管理类 scope,非 {@link OpenScopeEnum} 业务范围 */
public static final String ADMIN = "admin";
public static final String TENANT_ADMIN = "tenant_admin";
/** 通配 scope:持有即视为拥有全部业务范围 */
public static final String ALL = "all";
public static final String WILDCARD = "*";
/** scope 列表的分隔符(逗号、分号、空白) */
public static final String SEPARATOR_REGEX = "[,;\\s]+";
public static final String JOINER = ",";
/** 内部通道与平台超管拥有的全部 scope */
public static final List<String> ADMIN_SCOPES = List.of(
ADMIN, ALL,
OpenScopeEnum.DEVICE_READ.getCode(), OpenScopeEnum.DEVICE_CONTROL.getCode(),
OpenScopeEnum.TELEMETRY_READ.getCode(), OpenScopeEnum.ALARM_READ.getCode());
/** 控制台用户(非三方客户端)的 scope */
public static final List<String> CONSOLE_SCOPES = List.of(ADMIN, ALL);
private ScopeMatcher() {
}
/** 统一成点号分隔:device:read → device.read */
public static String normalize(String scope) {
return scope == null ? "" : scope.trim().replace(":", ".");
}
public static boolean isWildcard(String normalizedScope) {
return ALL.equals(normalizedScope) || WILDCARD.equals(normalizedScope);
}
/** granted 中是否包含 required(或通配) */
public static boolean matches(Collection<String> granted, String required) {
if (granted == null) {
return false;
}
String normRequired = normalize(required);
for (String s : granted) {
String norm = normalize(s);
if (norm.equals(normRequired) || isWildcard(norm)) {
return true;
}
}
return false;
}
}
@@ -49,7 +49,8 @@ public class SecurityConfig {
// 2. 开放平台管理后台端点 (必须具备管理员权限,严禁匿名或外部三方调用)
.requestMatchers("/api/open/**").hasAnyAuthority(
"ROLE_ADMIN", "SCOPE_admin", "SCOPE_all", "SCOPE_tenant_admin"
GatewayAuthorities.ROLE_ADMIN, GatewayAuthorities.SCOPE_ADMIN,
GatewayAuthorities.SCOPE_ALL, GatewayAuthorities.SCOPE_TENANT_ADMIN
)
// 3. MCP 智能体长连接与消息端点 (必须通过 OAuth 2.0 客户端或管理员认证)
@@ -0,0 +1,18 @@
package com.iot.gateway.openapi;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 控制指令的下发结果状态(响应体 status 字段)。
*/
@Getter
@AllArgsConstructor
public enum CommandResultEnum {
EXECUTED("EXECUTED", "已执行"),
MOCK_MODE_REFUSED("MOCK_MODE_REFUSED", "演示模式拒绝下发");
private final String code;
private final String name;
}
@@ -1,9 +1,14 @@
package com.iot.gateway.openapi;
import static com.iot.gateway.openapi.OpenApiFields.*;
import com.fasterxml.jackson.databind.JsonNode;
import com.iot.gateway.common.BackendInternalClient;
import com.iot.gateway.common.BackendResponse;
import com.iot.gateway.common.BizException;
import com.iot.gateway.common.InternalPaths;
import com.iot.gateway.common.Result;
import com.iot.gateway.oauth.GatewayAuthorities;
import com.iot.gateway.oauth.GatewaySecurityContext;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -13,6 +18,7 @@ import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
@@ -35,7 +41,7 @@ public class OpenAlarmController {
@Operation(summary = "按租户与设备检索未确认/历史告警")
@GetMapping
@PreAuthorize("hasAuthority('SCOPE_alarm.read') or hasAuthority('SCOPE_all')")
@PreAuthorize(GatewayAuthorities.EXPR_ALARM_READ)
public Result<Map<String, Object>> listAlarms(
@RequestParam(required = false) String deviceId,
@RequestParam(required = false) Integer severity) {
@@ -44,35 +50,36 @@ public class OpenAlarmController {
log.info("三方系统查询告警列表: tenantId={}, deviceId={}, severity={}", tenantId, deviceId, severity);
Map<String, String> params = new HashMap<>();
if (deviceId != null) params.put("deviceId", deviceId);
if (severity != null) params.put("severity", String.valueOf(severity));
if (deviceId != null) params.put(DEVICE_ID, deviceId);
if (severity != null) params.put(SEVERITY, String.valueOf(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")) {
return Result.success(Map.of("tenantId", tenantId, "alarms", resp.get("data")));
JsonNode resp = internalClient.get(InternalPaths.ALARMS, tenantId, params);
if (BackendResponse.isSuccess(resp)) {
return Result.success(Map.of(TENANT_ID, tenantId, ALARMS, BackendResponse.data(resp)));
}
if (!mockEnabled) {
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "核心物联后端不可用或查询告警失败";
throw new BizException(502, "查询告警失败: " + errMsg);
String errMsg = BackendResponse.message(resp, "核心物联后端不可用或查询告警失败");
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "查询告警失败: " + errMsg);
}
List<Map<String, Object>> 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(ALARM_ID, MOCK_ALARM_ID_1, DEVICE_ID, MOCK_ALARM_DEVICE, SEVERITY, MOCK_ALARM_SEVERITY_1,
ALARM_TITLE, MOCK_ALARM_TITLE_1, ALARM_CREATE_TIME, MOCK_ALARM_TIME_1),
Map.of(ALARM_ID, MOCK_ALARM_ID_2, DEVICE_ID, MOCK_ALARM_DEVICE, SEVERITY, MOCK_ALARM_SEVERITY_2,
ALARM_TITLE, MOCK_ALARM_TITLE_2, ALARM_CREATE_TIME, MOCK_ALARM_TIME_2)
);
return Result.success(Map.of("tenantId", tenantId, "isMock", true, "total", alarms.size(), "list", alarms));
return Result.success(Map.of(TENANT_ID, tenantId, IS_MOCK, true, TOTAL, alarms.size(), LIST, alarms));
}
@Operation(summary = "三方系统确认告警回执")
@PostMapping("/ack")
@PreAuthorize("hasAuthority('SCOPE_alarm.read') or hasAuthority('SCOPE_all')")
@PreAuthorize(GatewayAuthorities.EXPR_ALARM_READ)
public Result<Map<String, Object>> ackAlarm(@RequestBody Map<String, Object> req) {
Long tenantId = GatewaySecurityContext.requireTenantId();
String alarmId = String.valueOf(req.get("alarmId"));
return Result.success(Map.of("alarmId", alarmId, "tenantId", tenantId, "status", "ACKNOWLEDGED", "ackTime", System.currentTimeMillis()));
String alarmId = String.valueOf(req.get(ALARM_ID));
return Result.success(Map.of(ALARM_ID, alarmId, TENANT_ID, tenantId,
STATUS, ACK_STATUS_ACKNOWLEDGED, ACK_TIME, System.currentTimeMillis()));
}
}
@@ -0,0 +1,103 @@
package com.iot.gateway.openapi;
/**
* OpenAPI REST 接口的响应字段名、默认分页与演示(mock)数据常量。
*/
public final class OpenApiFields {
/** 分页参数默认值(用于 @RequestParam 的 defaultValue,须为字符串常量) */
public static final String DEFAULT_PAGE_NUM = "1";
public static final String DEFAULT_PAGE_SIZE = "20";
/** 请求/响应通用字段 */
public static final String TENANT_ID = "tenantId";
public static final String DEVICE_ID = "deviceId";
public static final String DEVICE_CODE = "deviceCode";
public static final String DEVICE_NAME = "deviceName";
public static final String ONLINE = "online";
public static final String TOTAL = "total";
public static final String PAGE_NUM = "pageNum";
public static final String PAGE_SIZE = "pageSize";
public static final String LIST = "list";
public static final String IS_MOCK = "isMock";
public static final String TIMESTAMP = "timestamp";
public static final String DATA = "data";
public static final String VALUES = "values";
public static final String SUCCESS = "success";
public static final String MESSAGE = "message";
public static final String STATUS = "status";
public static final String SEVERITY = "severity";
/** 遥测 */
public static final String POINT_KEY = "pointKey";
public static final String POINTS = "points";
public static final String POINT_TS = "ts";
public static final String POINT_VAL = "val";
/** 指令 */
public static final String COMMAND = "command";
public static final String COMMAND_ID = "commandId";
public static final String COMMAND_ID_PREFIX = "CMD-";
/** 告警 */
public static final String ALARM_ID = "alarmId";
public static final String ALARMS = "alarms";
public static final String ALARM_TITLE = "title";
public static final String ALARM_CREATE_TIME = "createTime";
public static final String ACK_TIME = "ackTime";
public static final String ACK_STATUS_ACKNOWLEDGED = "ACKNOWLEDGED";
/** Webhook 接收 */
public static final String RECEIVED = "received";
public static final String CHANNEL = "channel";
/** 演示数据:设备 */
public static final String MOCK_DEVICE_CODE_1 = "DEV20261009001";
public static final String MOCK_DEVICE_NAME_1 = "1号注塑机 (海天MA1200)";
public static final String MOCK_DEVICE_CODE_2 = "DEV20261009002";
public static final String MOCK_DEVICE_NAME_2 = "2号注塑机 (海天MA2000)";
/** 演示数据:遥测点位 */
public static final String MOCK_POINT_BARREL_TEMP_1 = "barrel_temp_zone1";
public static final String MOCK_POINT_BARREL_TEMP_2 = "barrel_temp_zone2";
public static final String MOCK_POINT_INJECTION_PRESSURE = "injection_pressure_mpa";
public static final String MOCK_POINT_SCREW_SPEED = "screw_speed_rpm";
public static final String MOCK_POINT_CYCLE_TIME = "cycle_time_seconds";
public static final double MOCK_BARREL_TEMP_1 = 215.4;
public static final double MOCK_BARREL_TEMP_2 = 220.1;
public static final double MOCK_INJECTION_PRESSURE = 13.8;
public static final double MOCK_SCREW_SPEED = 85.0;
public static final double MOCK_CYCLE_TIME = 24.3;
/** 演示数据:历史曲线(相对当前时刻的偏移毫秒与取值) */
public static final long MOCK_HISTORY_STEP_MILLIS = 30_000L;
public static final double MOCK_HISTORY_VAL_1 = 214.8;
public static final double MOCK_HISTORY_VAL_2 = 215.2;
public static final double MOCK_HISTORY_VAL_3 = 215.4;
/** 演示数据:告警 */
public static final String MOCK_ALARM_ID_1 = "ALM-9821";
public static final String MOCK_ALARM_ID_2 = "ALM-9822";
public static final String MOCK_ALARM_DEVICE = "DEV-ZSJ-002";
public static final int MOCK_ALARM_SEVERITY_1 = 3;
public static final int MOCK_ALARM_SEVERITY_2 = 2;
public static final String MOCK_ALARM_TITLE_1 = "注塑模具温度异常过高 (当前 265℃)";
public static final String MOCK_ALARM_TITLE_2 = "射胶压力超限预警";
public static final String MOCK_ALARM_TIME_1 = "2026-10-09 08:15:20";
public static final String MOCK_ALARM_TIME_2 = "2026-10-09 08:22:10";
/** 网关信息 */
public static final String SERVICE_NAME = "iot-open-gateway";
public static final String GATEWAY_STATUS_ONLINE = "ONLINE";
public static final String FIELD_SERVICE = "service";
public static final String FIELD_MCP_ENDPOINT = "mcpEndpoint";
public static final String FIELD_TOKEN_ENDPOINT = "tokenEndpoint";
public static final String FIELD_TOOL_COUNT = "toolCount";
public static final String FIELD_TOOLS = "tools";
public static final String FIELD_OPEN_APIS = "openApis";
public static final String MCP_ENDPOINT = "/mcp/sse";
public static final String TOKEN_ENDPOINT = "/oauth2/token";
private OpenApiFields() {
}
}
@@ -1,9 +1,14 @@
package com.iot.gateway.openapi;
import static com.iot.gateway.openapi.OpenApiFields.*;
import com.fasterxml.jackson.databind.JsonNode;
import com.iot.gateway.common.BackendInternalClient;
import com.iot.gateway.common.BackendResponse;
import com.iot.gateway.common.BizException;
import com.iot.gateway.common.InternalPaths;
import com.iot.gateway.common.Result;
import com.iot.gateway.oauth.GatewayAuthorities;
import com.iot.gateway.oauth.GatewaySecurityContext;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -11,6 +16,7 @@ import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -31,39 +37,39 @@ public class OpenCommandController {
@Operation(summary = "下发设备控制与工艺参数反写指令 (真实专线桥接,严禁伪报已执行)")
@PostMapping
@PreAuthorize("hasAuthority('SCOPE_device.control') or hasAuthority('SCOPE_all')")
@PreAuthorize(GatewayAuthorities.EXPR_DEVICE_CONTROL)
public Result<Map<String, Object>> sendCommand(@RequestBody Map<String, Object> req) {
Long tenantId = GatewaySecurityContext.requireTenantId();
String deviceId = String.valueOf(req.get("deviceId"));
String command = String.valueOf(req.get("command"));
String deviceId = String.valueOf(req.get(DEVICE_ID));
String command = String.valueOf(req.get(COMMAND));
log.info("三方系统下发设备反写指令: tenantId={}, deviceId={}, command={}", tenantId, deviceId, command);
JsonNode resp = internalClient.post("/api/internal/open/commands", tenantId, req);
if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) {
JsonNode resp = internalClient.post(InternalPaths.COMMANDS, tenantId, req);
if (BackendResponse.isSuccess(resp)) {
return Result.success(Map.of(
"success", true,
"commandId", "CMD-" + System.currentTimeMillis(),
"deviceId", deviceId,
"command", command,
"status", "EXECUTED",
"data", resp.get("data")
SUCCESS, true,
COMMAND_ID, COMMAND_ID_PREFIX + System.currentTimeMillis(),
DEVICE_ID, deviceId,
COMMAND, command,
STATUS, CommandResultEnum.EXECUTED.getCode(),
DATA, BackendResponse.data(resp)
));
}
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "核心控制网关通信超时或未响应";
String errMsg = BackendResponse.message(resp, "核心控制网关通信超时或未响应");
if (!mockEnabled) {
log.error("下发设备控制指令失败 (拒绝伪报成功): tenantId={}, deviceId={}, err={}", tenantId, deviceId, errMsg);
throw new BizException(502, "控制指令执行失败: " + errMsg);
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "控制指令执行失败: " + errMsg);
}
return Result.success(Map.of(
"success", false,
"isMock", true,
"deviceId", deviceId,
"command", command,
"status", "MOCK_MODE_REFUSED",
"message", "当前为开发演示环境,已拒绝向物理设备下发控制指令: " + errMsg
SUCCESS, false,
IS_MOCK, true,
DEVICE_ID, deviceId,
COMMAND, command,
STATUS, CommandResultEnum.MOCK_MODE_REFUSED.getCode(),
MESSAGE, "当前为开发演示环境,已拒绝向物理设备下发控制指令: " + errMsg
));
}
}
@@ -1,9 +1,15 @@
package com.iot.gateway.openapi;
import static com.iot.gateway.openapi.OpenApiFields.*;
import com.fasterxml.jackson.databind.JsonNode;
import com.iot.gateway.common.BackendInternalClient;
import com.iot.gateway.common.BackendResponse;
import com.iot.gateway.common.BizException;
import com.iot.gateway.common.InternalPaths;
import com.iot.gateway.common.OnlineStatusEnum;
import com.iot.gateway.common.Result;
import com.iot.gateway.oauth.GatewayAuthorities;
import com.iot.gateway.oauth.GatewaySecurityContext;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -13,6 +19,7 @@ import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -34,10 +41,10 @@ public class OpenDeviceController {
@Operation(summary = "查询当前租户名下设备列表")
@GetMapping
@PreAuthorize("hasAuthority('SCOPE_device.read') or hasAuthority('SCOPE_all')")
@PreAuthorize(GatewayAuthorities.EXPR_DEVICE_READ)
public Result<Map<String, Object>> listDevices(
@RequestParam(required = false, defaultValue = "1") Integer pageNum,
@RequestParam(required = false, defaultValue = "20") Integer pageSize,
@RequestParam(required = false, defaultValue = DEFAULT_PAGE_NUM) Integer pageNum,
@RequestParam(required = false, defaultValue = DEFAULT_PAGE_SIZE) Integer pageSize,
@RequestParam(required = false) String status) {
// 严格获取当前已认证租户 ID,未认证或缺少租户时抛出 401 异常,严禁默认落到租户 1
@@ -45,66 +52,67 @@ public class OpenDeviceController {
log.info("三方系统查询设备列表: tenantId={}, status={}", tenantId, status);
Map<String, String> params = new HashMap<>();
params.put("pageNum", String.valueOf(pageNum));
params.put("pageSize", String.valueOf(pageSize));
params.put(PAGE_NUM, String.valueOf(pageNum));
params.put(PAGE_SIZE, String.valueOf(pageSize));
if (status != null) {
if ("online".equalsIgnoreCase(status)) params.put("online", "1");
else if ("offline".equalsIgnoreCase(status)) params.put("online", "0");
OnlineStatusEnum onlineFilter = OnlineStatusEnum.ofFilter(status);
if (onlineFilter != null) params.put(OnlineStatusEnum.PARAM_ONLINE, String.valueOf(onlineFilter.getCode()));
}
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");
JsonNode resp = internalClient.get(InternalPaths.DEVICES, tenantId, params);
if (BackendResponse.isSuccess(resp)) {
JsonNode data = BackendResponse.data(resp);
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()
TENANT_ID, tenantId,
TOTAL, BackendResponse.total(data),
PAGE_NUM, pageNum,
PAGE_SIZE, pageSize,
LIST, BackendResponse.records(data)
));
}
if (!mockEnabled) {
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "核心物联后端不可用或请求超时";
throw new BizException(502, "召测设备列表失败: " + errMsg);
String errMsg = BackendResponse.message(resp, "核心物联后端不可用或请求超时");
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "召测设备列表失败: " + errMsg);
}
// 仅在显式配置 mockEnabled=true 时允许返回演示数据
List<Map<String, Object>> devices = List.of(
Map.of(DEVICE_CODE, MOCK_DEVICE_CODE_1, DEVICE_NAME, MOCK_DEVICE_NAME_1, ONLINE, OnlineStatusEnum.ONLINE.getCode()),
Map.of(DEVICE_CODE, MOCK_DEVICE_CODE_2, DEVICE_NAME, MOCK_DEVICE_NAME_2, ONLINE, OnlineStatusEnum.OFFLINE.getCode())
);
return Result.success(Map.of(
"tenantId", tenantId,
"isMock", true,
"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)
)
TENANT_ID, tenantId,
IS_MOCK, true,
TOTAL, devices.size(),
PAGE_NUM, pageNum,
PAGE_SIZE, pageSize,
LIST, devices
));
}
@Operation(summary = "获取指定设备详情与点位元数据")
@GetMapping("/{deviceId}")
@PreAuthorize("hasAuthority('SCOPE_device.read') or hasAuthority('SCOPE_all')")
@PreAuthorize(GatewayAuthorities.EXPR_DEVICE_READ)
public Result<Object> getDevice(@PathVariable String deviceId) {
Long tenantId = GatewaySecurityContext.requireTenantId();
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"));
JsonNode resp = internalClient.get(InternalPaths.DEVICE_DETAIL_PREFIX + deviceId, tenantId, null);
if (BackendResponse.isSuccess(resp)) {
return Result.success(BackendResponse.data(resp));
}
if (!mockEnabled) {
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "设备不存在或核心服务未响应";
throw new BizException(502, "查询设备详情失败: " + errMsg);
String errMsg = BackendResponse.message(resp, "设备不存在或核心服务未响应");
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "查询设备详情失败: " + errMsg);
}
return Result.success(Map.of(
"deviceCode", deviceId,
"tenantId", tenantId,
"isMock", true,
"deviceName", "1号注塑机 (海天MA1200)",
"online", 1
DEVICE_CODE, deviceId,
TENANT_ID, tenantId,
IS_MOCK, true,
DEVICE_NAME, MOCK_DEVICE_NAME_1,
ONLINE, OnlineStatusEnum.ONLINE.getCode()
));
}
}
@@ -1,16 +1,23 @@
package com.iot.gateway.openapi;
import static com.iot.gateway.openapi.OpenApiFields.*;
import com.fasterxml.jackson.databind.JsonNode;
import com.iot.gateway.common.BackendInternalClient;
import com.iot.gateway.common.BackendResponse;
import com.iot.gateway.common.BizException;
import com.iot.gateway.common.InternalPaths;
import com.iot.gateway.common.Result;
import com.iot.gateway.oauth.GatewayAuthorities;
import com.iot.gateway.oauth.GatewaySecurityContext;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.List;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpStatus;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
@@ -32,44 +39,44 @@ public class OpenTelemetryController {
@Operation(summary = "召测单设备最新实时遥测点位数据")
@GetMapping("/latest/{deviceId}")
@PreAuthorize("hasAuthority('SCOPE_telemetry.read') or hasAuthority('SCOPE_all')")
@PreAuthorize(GatewayAuthorities.EXPR_TELEMETRY_READ)
public Result<Map<String, Object>> getLatestTelemetry(@PathVariable String deviceId) {
Long tenantId = GatewaySecurityContext.requireTenantId();
log.info("三方系统召测最新遥测: deviceId={}, tenantId={}", deviceId, tenantId);
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")) {
JsonNode resp = internalClient.get(InternalPaths.TELEMETRY_LATEST, tenantId, Map.of(DEVICE_ID, deviceId));
if (BackendResponse.isSuccess(resp)) {
return Result.success(Map.of(
"deviceId", deviceId,
"tenantId", tenantId,
"timestamp", System.currentTimeMillis(),
"data", resp.get("data")
DEVICE_ID, deviceId,
TENANT_ID, tenantId,
TIMESTAMP, System.currentTimeMillis(),
DATA, BackendResponse.data(resp)
));
}
if (!mockEnabled) {
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "后端遥测接口不可用或设备无数据";
throw new BizException(502, "召测设备遥测失败: " + errMsg);
String errMsg = BackendResponse.message(resp, "后端遥测接口不可用或设备无数据");
throw new BizException(HttpStatus.BAD_GATEWAY.value(), "召测设备遥测失败: " + errMsg);
}
return Result.success(Map.of(
"deviceId", deviceId,
"tenantId", tenantId,
"isMock", true,
"timestamp", System.currentTimeMillis(),
"values", 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
DEVICE_ID, deviceId,
TENANT_ID, tenantId,
IS_MOCK, true,
TIMESTAMP, System.currentTimeMillis(),
VALUES, Map.of(
MOCK_POINT_BARREL_TEMP_1, MOCK_BARREL_TEMP_1,
MOCK_POINT_BARREL_TEMP_2, MOCK_BARREL_TEMP_2,
MOCK_POINT_INJECTION_PRESSURE, MOCK_INJECTION_PRESSURE,
MOCK_POINT_SCREW_SPEED, MOCK_SCREW_SPEED,
MOCK_POINT_CYCLE_TIME, MOCK_CYCLE_TIME
)
));
}
@Operation(summary = "时间范围查询历史时序遥测点位数据")
@GetMapping("/history")
@PreAuthorize("hasAuthority('SCOPE_telemetry.read') or hasAuthority('SCOPE_all')")
@PreAuthorize(GatewayAuthorities.EXPR_TELEMETRY_READ)
public Result<Map<String, Object>> getHistoryTelemetry(
@RequestParam String deviceId,
@RequestParam String pointKey,
@@ -79,13 +86,13 @@ public class OpenTelemetryController {
Long tenantId = GatewaySecurityContext.requireTenantId();
long now = System.currentTimeMillis();
return Result.success(Map.of(
"deviceId", deviceId,
"tenantId", tenantId,
"pointKey", pointKey,
"points", java.util.List.of(
Map.of("ts", now - 60000, "val", 214.8),
Map.of("ts", now - 30000, "val", 215.2),
Map.of("ts", now, "val", 215.4)
DEVICE_ID, deviceId,
TENANT_ID, tenantId,
POINT_KEY, pointKey,
POINTS, List.of(
Map.of(POINT_TS, now - 2 * MOCK_HISTORY_STEP_MILLIS, POINT_VAL, MOCK_HISTORY_VAL_1),
Map.of(POINT_TS, now - MOCK_HISTORY_STEP_MILLIS, POINT_VAL, MOCK_HISTORY_VAL_2),
Map.of(POINT_TS, now, POINT_VAL, MOCK_HISTORY_VAL_3)
)
));
}
@@ -0,0 +1,24 @@
package com.iot.gateway.webhook;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* Webhook 通道类型(open_webhook.channel_type)。
*/
@Getter
@AllArgsConstructor
public enum WebhookChannelEnum {
HTTP("http", "标准 HTTP 回调"),
WECHAT_WORK("wechat_work", "企业微信机器人"),
DINGTALK("dingtalk", "钉钉机器人"),
FEISHU("feishu", "飞书机器人");
private final String code;
private final String name;
public boolean matches(String value) {
return code.equalsIgnoreCase(value);
}
}
@@ -1,5 +1,9 @@
package com.iot.gateway.webhook;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.iot.gateway.common.EnableStatusEnum;
import com.iot.gateway.common.GatewayHeaders;
import com.iot.gateway.entity.OpenWebhook;
import com.iot.gateway.mapper.OpenWebhookMapper;
import java.net.URI;
@@ -8,12 +12,16 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
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.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.util.StringUtils;
@@ -21,11 +29,36 @@ import org.springframework.util.StringUtils;
@Service
public class WebhookDispatchService {
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
private static final Duration SEND_TIMEOUT = Duration.ofSeconds(5);
private static final String USER_AGENT_VALUE = "IoT-OpenGateway-Webhook/1.0";
private static final String HMAC_ALGORITHM = "HmacSHA256";
private static final String HEX_FORMAT = "%02x";
private static final String EVENT_ID_PREFIX = "evt_";
private static final int EVENT_ID_LENGTH = 16;
private static final String DEFAULT_TITLE = "IoT 平台业务事件通知";
private static final String TEXT_LINE_BREAK = "\n";
/** 企业微信 / 钉钉 / 飞书机器人的消息体字段 */
private static final String ROBOT_MSG_TYPE = "msgtype";
private static final String ROBOT_MSG_TYPE_FEISHU = "msg_type";
private static final String ROBOT_TEXT = "text";
private static final String ROBOT_CONTENT = "content";
/** 标准 HTTP 回调的消息体字段 */
private static final String HOOK_EVENT_ID = "event_id";
private static final String HOOK_EVENT = "event";
private static final String HOOK_TITLE = "title";
private static final String HOOK_CONTENT = "content";
private static final String HOOK_TIMESTAMP = "timestamp";
@Autowired
private OpenWebhookMapper webhookMapper;
private final ObjectMapper objectMapper = new ObjectMapper();
private final HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.connectTimeout(CONNECT_TIMEOUT)
.build();
/**
@@ -33,8 +66,9 @@ public class WebhookDispatchService {
*/
public void dispatchAlarm(Long tenantId, String title, String content) {
List<OpenWebhook> channels = webhookMapper.selectList(null).stream()
.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")))
.filter(w -> EnableStatusEnum.isEnabled(w.getStatus())
&& (tenantId == null || tenantId.equals(w.getTenantId())))
.filter(w -> WebhookEventEnum.subscribesAlarm(w.getEvents()))
.toList();
for (OpenWebhook ch : channels) {
@@ -53,23 +87,23 @@ public class WebhookDispatchService {
try {
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))
.header(HttpHeaders.CONTENT_TYPE, GatewayHeaders.CONTENT_TYPE_JSON_UTF8)
.header(GatewayHeaders.USER_AGENT, USER_AGENT_VALUE)
.header(GatewayHeaders.EVENT_TIMESTAMP, String.valueOf(timestamp))
.timeout(SEND_TIMEOUT)
.POST(HttpRequest.BodyPublishers.ofString(payload));
// 若配置了加签密钥且为 HTTP 标准回调,计算 HMAC-SHA256 签名防篡改
if (StringUtils.hasText(secret) && "http".equalsIgnoreCase(channelType)) {
if (StringUtils.hasText(secret) && WebhookChannelEnum.HTTP.matches(channelType)) {
String signature = calculateHmacSha256(payload, secret);
if (signature != null) {
reqBuilder.header("X-Hub-Signature-256", "sha256=" + signature);
reqBuilder.header(GatewayHeaders.HUB_SIGNATURE_256, GatewayHeaders.HUB_SIGNATURE_256_PREFIX + signature);
}
}
HttpRequest request = reqBuilder.build();
HttpResponse<String> resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString());
return resp.statusCode() >= 200 && resp.statusCode() < 300;
return HttpStatus.valueOf(resp.statusCode()).is2xxSuccessful();
} catch (Exception e) {
log.warn("发送 Webhook 消息失败: url={}, type={}, err={}", webhookUrl, channelType, e.getMessage());
return false;
@@ -81,31 +115,42 @@ public class WebhookDispatchService {
}
private String formatMessage(String type, String title, String content) {
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);
String safeTitle = title != null ? title : DEFAULT_TITLE;
String safeContent = content != null ? content : "";
Map<String, Object> body = new LinkedHashMap<>();
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 + "\"}}";
if (WebhookChannelEnum.WECHAT_WORK.matches(type) || WebhookChannelEnum.DINGTALK.matches(type)) {
body.put(ROBOT_MSG_TYPE, ROBOT_TEXT);
body.put(ROBOT_TEXT, Map.of(ROBOT_CONTENT, safeTitle + TEXT_LINE_BREAK + safeContent));
} else if (WebhookChannelEnum.FEISHU.matches(type)) {
body.put(ROBOT_MSG_TYPE_FEISHU, ROBOT_TEXT);
body.put(ROBOT_CONTENT, Map.of(ROBOT_TEXT, safeTitle + TEXT_LINE_BREAK + safeContent));
} else {
// 标准 HTTP Webhook 回调格式
return "{\"event_id\":\"" + eventId + "\",\"event\":\"alarm.triggered\",\"title\":\"" + safeTitle +
"\",\"content\":\"" + safeContent + "\",\"timestamp\":" + now + "}";
body.put(HOOK_EVENT_ID, EVENT_ID_PREFIX
+ UUID.randomUUID().toString().replace("-", "").substring(0, EVENT_ID_LENGTH));
body.put(HOOK_EVENT, WebhookEventEnum.ALARM_TRIGGERED.getCode());
body.put(HOOK_TITLE, safeTitle);
body.put(HOOK_CONTENT, safeContent);
body.put(HOOK_TIMESTAMP, System.currentTimeMillis());
}
try {
return objectMapper.writeValueAsString(body);
} catch (JsonProcessingException e) {
throw new IllegalStateException("Webhook 消息体序列化失败", e);
}
}
private String calculateHmacSha256(String data, String key) {
try {
SecretKeySpec secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec secretKey = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), HMAC_ALGORITHM);
Mac mac = Mac.getInstance(HMAC_ALGORITHM);
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));
sb.append(String.format(HEX_FORMAT, b));
}
return sb.toString();
} catch (Exception e) {
@@ -0,0 +1,55 @@
package com.iot.gateway.webhook;
import java.util.ArrayList;
import java.util.List;
import java.util.Map;
import lombok.AllArgsConstructor;
import lombok.Getter;
/**
* 可订阅的标准开放事件。open_webhook.events 以逗号分隔存这里的 code。
*/
@Getter
@AllArgsConstructor
public enum WebhookEventEnum {
ALARM_TRIGGERED("alarm.triggered", "设备告警触发", "ALARM",
"现场物理注塑机发生工艺超温、高压超标、停机等严重报警", "CRITICAL"),
ALARM_RECOVERED("alarm.recovered", "设备告警消除", "ALARM",
"报警条件解除,设备故障自动或手动复位消除", "INFO"),
ALARM_ACKNOWLEDGED("alarm.acknowledged", "告警已被确认", "ALARM",
"现场操作人员或值班工程师已在平台确认该告警", "INFO"),
DEVICE_ONLINE("device.online", "设备上线与重连", "DEVICE",
"边缘智能网关或现场物理注塑机通过 MQTT 成功建立连接", "INFO"),
DEVICE_OFFLINE("device.offline", "设备失联与掉线", "DEVICE",
"设备失联或心跳超时断开,可能存在断电或网络故障", "WARNING"),
DEVICE_STATUS_CHANGED("device.status_changed", "设备运行状态切换", "DEVICE",
"注塑机在运行 (RUNNING)、待机 (STANDBY)、停机 (STOPPED) 间切换", "INFO"),
/** 历史别名:早期版本用它订阅告警,已订阅的通道仍可能带这个值,分发时与 ALARM_TRIGGERED 等同 */
DEVICE_ALARM("device.alarm", "设备告警(旧名)", "ALARM", "alarm.triggered 的历史别名", "CRITICAL");
private final String code;
private final String name;
private final String category;
private final String desc;
private final String level;
/** 供订阅下拉使用的字典,不含历史别名 */
public static List<Map<String, Object>> toList() {
List<Map<String, Object>> list = new ArrayList<>();
for (WebhookEventEnum e : values()) {
if (e == DEVICE_ALARM) {
continue;
}
list.add(Map.of("code", e.code, "name", e.name, "category", e.category,
"desc", e.desc, "level", e.level));
}
return list;
}
/** 订阅串(逗号分隔)是否包含任一告警触发事件 */
public static boolean subscribesAlarm(String events) {
return events != null && (events.contains(ALARM_TRIGGERED.code) || events.contains(DEVICE_ALARM.code));
}
}
@@ -1,6 +1,8 @@
package com.iot.gateway.webhook;
import com.iot.gateway.common.GatewayHeaders;
import com.iot.gateway.common.Result;
import com.iot.gateway.openapi.OpenApiFields;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import java.util.Map;
@@ -22,14 +24,14 @@ public class WebhookReceiveController {
@PostMapping("/{channel}")
public Result<Map<String, Object>> receiveEvent(
@PathVariable String channel,
@RequestHeader(value = "X-Signature", required = false) String signature,
@RequestHeader(value = GatewayHeaders.SIGNATURE, required = false) String signature,
@RequestBody Map<String, Object> body) {
log.info("接收到外部 Webhook 回调: channel={}, signature={}, body={}", channel, signature, body);
return Result.success(Map.of(
"received", true,
"channel", channel,
"timestamp", System.currentTimeMillis()
OpenApiFields.RECEIVED, true,
OpenApiFields.CHANNEL, channel,
OpenApiFields.TIMESTAMP, System.currentTimeMillis()
));
}
}