diff --git a/src/main/java/com/iot/gateway/admin/AdminDefaults.java b/src/main/java/com/iot/gateway/admin/AdminDefaults.java new file mode 100644 index 0000000..716d815 --- /dev/null +++ b/src/main/java/com/iot/gateway/admin/AdminDefaults.java @@ -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() { + } +} diff --git a/src/main/java/com/iot/gateway/admin/OpenClientAdminController.java b/src/main/java/com/iot/gateway/admin/OpenClientAdminController.java index ffecb75..25fd91d 100644 --- a/src/main/java/com/iot/gateway/admin/OpenClientAdminController.java +++ b/src/main/java/com/iot/gateway/admin/OpenClientAdminController.java @@ -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>> listScopes() { - return Result.success(com.iot.gateway.oauth.OpenScopeEnum.toList()); + return Result.success(OpenScopeEnum.toList()); } @Operation(summary = "三方应用凭据分页列表 (严禁泄露密钥哈希)") @GetMapping("/page") public Result> 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 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 tenantNameMap = new java.util.HashMap<>(); - List> rows = jdbcTemplate.queryForList("SELECT id, tenant_name FROM sys_tenant"); + Map tenantNameMap = new HashMap<>(); + List> rows = jdbcTemplate.queryForList(AdminDefaults.SQL_TENANT_NAMES); for (Map 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 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(), "权限不足: 禁止跨租户访问或操作开放凭据"); } } } diff --git a/src/main/java/com/iot/gateway/admin/OpenGatewayInfoController.java b/src/main/java/com/iot/gateway/admin/OpenGatewayInfoController.java index 027cc71..ca6efbf 100644 --- a/src/main/java/com/iot/gateway/admin/OpenGatewayInfoController.java +++ b/src/main/java/com/iot/gateway/admin/OpenGatewayInfoController.java @@ -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> 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 )); } } diff --git a/src/main/java/com/iot/gateway/admin/OpenWebhookAdminController.java b/src/main/java/com/iot/gateway/admin/OpenWebhookAdminController.java index 895f37b..1845248 100644 --- a/src/main/java/com/iot/gateway/admin/OpenWebhookAdminController.java +++ b/src/main/java/com/iot/gateway/admin/OpenWebhookAdminController.java @@ -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>> 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( - @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 page = new Page<>(pageNum, pageSize); LambdaQueryWrapper 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 tenantNameMap = new HashMap<>(); - List> tenantRows = jdbcTemplate.queryForList("SELECT id, tenant_name FROM sys_tenant"); + List> tenantRows = jdbcTemplate.queryForList(AdminDefaults.SQL_TENANT_NAMES); for (Map 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 clientNameMap = new HashMap<>(); - List> clientRows = jdbcTemplate.queryForList("SELECT client_id, client_name FROM open_client"); + List> clientRows = jdbcTemplate.queryForList(AdminDefaults.SQL_CLIENT_NAMES); for (Map 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 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 通道"); } } } diff --git a/src/main/java/com/iot/gateway/common/BackendInternalClient.java b/src/main/java/com/iot/gateway/common/BackendInternalClient.java index 2d44453..1061490 100644 --- a/src/main/java/com/iot/gateway/common/BackendInternalClient.java +++ b/src/main/java/com/iot/gateway/common/BackendInternalClient.java @@ -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 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 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 queryParams) { StringBuilder urlBuilder = new StringBuilder(backendBaseUrl).append(path); if (queryParams != null && !queryParams.isEmpty()) { - urlBuilder.append("?"); + urlBuilder.append(QUERY_START); for (Map.Entry 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); diff --git a/src/main/java/com/iot/gateway/common/BackendResponse.java b/src/main/java/com/iot/gateway/common/BackendResponse.java new file mode 100644 index 0000000..bc3bd71 --- /dev/null +++ b/src/main/java/com/iot/gateway/common/BackendResponse.java @@ -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}(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(); + } +} diff --git a/src/main/java/com/iot/gateway/common/BizException.java b/src/main/java/com/iot/gateway/common/BizException.java index 5f610c8..c00127f 100644 --- a/src/main/java/com/iot/gateway/common/BizException.java +++ b/src/main/java/com/iot/gateway/common/BizException.java @@ -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) { diff --git a/src/main/java/com/iot/gateway/common/EnableStatusEnum.java b/src/main/java/com/iot/gateway/common/EnableStatusEnum.java new file mode 100644 index 0000000..089f1f5 --- /dev/null +++ b/src/main/java/com/iot/gateway/common/EnableStatusEnum.java @@ -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); + } +} diff --git a/src/main/java/com/iot/gateway/common/GatewayHeaders.java b/src/main/java/com/iot/gateway/common/GatewayHeaders.java new file mode 100644 index 0000000..c78b904 --- /dev/null +++ b/src/main/java/com/iot/gateway/common/GatewayHeaders.java @@ -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() { + } +} diff --git a/src/main/java/com/iot/gateway/common/GlobalExceptionHandler.java b/src/main/java/com/iot/gateway/common/GlobalExceptionHandler.java index 0fd4446..187f277 100644 --- a/src/main/java/com/iot/gateway/common/GlobalExceptionHandler.java +++ b/src/main/java/com/iot/gateway/common/GlobalExceptionHandler.java @@ -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 handleException(Exception e) { log.error("系统未知异常: ", e); - return Result.fail(500, "网关处理异常: " + e.getMessage()); + return Result.fail(HttpStatus.INTERNAL_SERVER_ERROR.value(), "网关处理异常: " + e.getMessage()); } } diff --git a/src/main/java/com/iot/gateway/common/InternalPaths.java b/src/main/java/com/iot/gateway/common/InternalPaths.java new file mode 100644 index 0000000..bf5d796 --- /dev/null +++ b/src/main/java/com/iot/gateway/common/InternalPaths.java @@ -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() { + } +} diff --git a/src/main/java/com/iot/gateway/common/IpUtils.java b/src/main/java/com/iot/gateway/common/IpUtils.java index aa4f7cf..19d6c84 100644 --- a/src/main/java/com/iot/gateway/common/IpUtils.java +++ b/src/main/java/com/iot/gateway/common/IpUtils.java @@ -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); } diff --git a/src/main/java/com/iot/gateway/common/OnlineStatusEnum.java b/src/main/java/com/iot/gateway/common/OnlineStatusEnum.java new file mode 100644 index 0000000..c84ec17 --- /dev/null +++ b/src/main/java/com/iot/gateway/common/OnlineStatusEnum.java @@ -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; + } +} diff --git a/src/main/java/com/iot/gateway/common/Result.java b/src/main/java/com/iot/gateway/common/Result.java index 0b40019..dfc772d 100644 --- a/src/main/java/com/iot/gateway/common/Result.java +++ b/src/main/java/com/iot/gateway/common/Result.java @@ -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 implements Serializable { + private static final String SUCCESS_MESSAGE = "操作成功"; + private Integer code; private String message; private T data; @@ -18,15 +21,15 @@ public class Result implements Serializable { } public static Result success() { - return new Result<>(200, "操作成功", null); + return new Result<>(HttpStatus.OK.value(), SUCCESS_MESSAGE, null); } public static Result success(T data) { - return new Result<>(200, "操作成功", data); + return new Result<>(HttpStatus.OK.value(), SUCCESS_MESSAGE, data); } public static Result fail(String message) { - return new Result<>(500, message, null); + return new Result<>(HttpStatus.INTERNAL_SERVER_ERROR.value(), message, null); } public static Result fail(Integer code, String message) { diff --git a/src/main/java/com/iot/gateway/mcp/McpErrorEnum.java b/src/main/java/com/iot/gateway/mcp/McpErrorEnum.java new file mode 100644 index 0000000..c222023 --- /dev/null +++ b/src/main/java/com/iot/gateway/mcp/McpErrorEnum.java @@ -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; +} diff --git a/src/main/java/com/iot/gateway/mcp/McpMethodEnum.java b/src/main/java/com/iot/gateway/mcp/McpMethodEnum.java new file mode 100644 index 0000000..bd6bfb6 --- /dev/null +++ b/src/main/java/com/iot/gateway/mcp/McpMethodEnum.java @@ -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); + } +} diff --git a/src/main/java/com/iot/gateway/mcp/McpProtocol.java b/src/main/java/com/iot/gateway/mcp/McpProtocol.java new file mode 100644 index 0000000..8f770c0 --- /dev/null +++ b/src/main/java/com/iot/gateway/mcp/McpProtocol.java @@ -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() { + } +} diff --git a/src/main/java/com/iot/gateway/mcp/McpSseController.java b/src/main/java/com/iot/gateway/mcp/McpSseController.java index fd4f511..c88aa67 100644 --- a/src/main/java/com/iot/gateway/mcp/McpSseController.java +++ b/src/main/java/com/iot/gateway/mcp/McpSseController.java @@ -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> handleMessage( - @RequestParam(required = false) String sessionId, + @RequestParam(value = McpProtocol.PARAM_SESSION_ID, required = false) String sessionId, @RequestBody Map 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 params = (Map) rpcRequest.get("params"); + Map params = (Map) rpcRequest.get(McpProtocol.FIELD_PARAMS); Map 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 serverInfo = Map.of( - "name", "iot-open-gateway", - "version", "1.0.0" + McpProtocol.FIELD_NAME, McpProtocol.SERVER_NAME, + McpProtocol.FIELD_VERSION, McpProtocol.SERVER_VERSION ); Map capabilities = Map.of( - "tools", Map.of("listChanged", false) + McpProtocol.FIELD_TOOLS, Map.of(McpProtocol.FIELD_LIST_CHANGED, false) ); Map 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> 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 arguments = params != null ? (Map) params.get("arguments") : Map.of(); + Map arguments = params != null + ? (Map) params.get(McpProtocol.FIELD_ARGUMENTS) : Map.of(); try { Map toolResult = toolRegistry.executeTool( toolName, arguments, effectiveTenantId, effectiveScopes, effectiveIsAdmin); - String contentJson = objectMapper.writeValueAsString(toolResult); - - Map 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 errContent = Map.of( - "type", "text", - "text", "{\"error\": \"" + be.getMessage() + "\", \"code\": " + be.getCode() + "}" - ); - rpcResponse.put("result", Map.of("content", List.of(errContent), "isError", true)); + Map 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 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 rpcError(McpErrorEnum error, String message) { + return Map.of(McpProtocol.FIELD_CODE, error.getCode(), McpProtocol.FIELD_MESSAGE, message); + } + + /** tools/call 的返回:单段文本内容,isError 仅在出错时附带 */ + private static Map textResult(String text, boolean isError) { + Map textContent = Map.of( + McpProtocol.FIELD_TYPE, McpProtocol.CONTENT_TYPE_TEXT, + McpProtocol.FIELD_TEXT, text + ); + Map 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); + } + } } diff --git a/src/main/java/com/iot/gateway/mcp/McpToolArgs.java b/src/main/java/com/iot/gateway/mcp/McpToolArgs.java new file mode 100644 index 0000000..b919e69 --- /dev/null +++ b/src/main/java/com/iot/gateway/mcp/McpToolArgs.java @@ -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() { + } +} diff --git a/src/main/java/com/iot/gateway/mcp/McpToolEnum.java b/src/main/java/com/iot/gateway/mcp/McpToolEnum.java new file mode 100644 index 0000000..c0190fe --- /dev/null +++ b/src/main/java/com/iot/gateway/mcp/McpToolEnum.java @@ -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; + } +} diff --git a/src/main/java/com/iot/gateway/mcp/McpToolRegistry.java b/src/main/java/com/iot/gateway/mcp/McpToolRegistry.java index 6f14d60..46f43ac 100644 --- a/src/main/java/com/iot/gateway/mcp/McpToolRegistry.java +++ b/src/main/java/com/iot/gateway/mcp/McpToolRegistry.java @@ -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 field(String type, String description) { + return Map.of(SCHEMA_TYPE, type, SCHEMA_DESCRIPTION, description); + } + + private static Map tool(McpToolEnum toolEnum, String description, + Map properties, List required) { + Map schema = new HashMap<>(); + schema.put(SCHEMA_TYPE, TYPE_OBJECT); + schema.put(SCHEMA_PROPERTIES, properties); + if (required != null) { + schema.put(SCHEMA_REQUIRED, required); + } + Map 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> getToolDefinitions() { List> tools = new ArrayList<>(); - // 1. iot_device_list - Map t1 = new HashMap<>(); - t1.put("name", "iot_device_list"); - t1.put("description", "查询当前租户名下的所有工业物联网设备台账与实时运行状态(在线/报警/停机)。所需权限范围: device.read"); - Map s1 = new HashMap<>(); - s1.put("type", "object"); - Map 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 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 t2 = new HashMap<>(); - t2.put("name", "iot_telemetry_latest"); - t2.put("description", "召测指定工业设备的最新实时点位遥测数据(注塑机温度、注塑压力、螺杆转速、能耗电流等)。所需权限范围: telemetry.read"); - Map s2 = new HashMap<>(); - s2.put("type", "object"); - Map 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 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 t3 = new HashMap<>(); - t3.put("name", "iot_device_control"); - t3.put("description", "向边缘物联设备安全下发反写控制指令(如设定注塑温度、设备启停控制、工艺参数微调)。所需权限范围: device.control"); - Map s3 = new HashMap<>(); - s3.put("type", "object"); - Map 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 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 t4 = new HashMap<>(); - t4.put("name", "iot_alarm_query"); - t4.put("description", "按设备与时间范围检索未处理的设备故障与历史严重告警记录。所需权限范围: alarm.read"); - Map s4 = new HashMap<>(); - s4.put("type", "object"); - Map 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 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 result = new HashMap<>(); - if ("iot_device_list".equals(toolName)) { - Map params = new HashMap<>(); - if (args != null && args.containsKey("status")) { - String st = String.valueOf(args.get("status")); - if ("online".equalsIgnoreCase(st)) params.put("online", "1"); - else if ("offline".equalsIgnoreCase(st)) params.put("online", "0"); + switch (tool) { + case DEVICE_LIST -> { + Map 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 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 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 scopes, boolean isAdmin) { + private static void copyArg(Map args, Map 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 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 + "] 授权范围"); } } } diff --git a/src/main/java/com/iot/gateway/oauth/ConsoleUserTypeEnum.java b/src/main/java/com/iot/gateway/oauth/ConsoleUserTypeEnum.java new file mode 100644 index 0000000..bd925cc --- /dev/null +++ b/src/main/java/com/iot/gateway/oauth/ConsoleUserTypeEnum.java @@ -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); + } +} diff --git a/src/main/java/com/iot/gateway/oauth/GatewayAuthorities.java b/src/main/java/com/iot/gateway/oauth/GatewayAuthorities.java new file mode 100644 index 0000000..d7bd336 --- /dev/null +++ b/src/main/java/com/iot/gateway/oauth/GatewayAuthorities.java @@ -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() { + } +} diff --git a/src/main/java/com/iot/gateway/oauth/GatewaySecurityContext.java b/src/main/java/com/iot/gateway/oauth/GatewaySecurityContext.java index 13d30ee..53c49d7 100644 --- a/src/main/java/com/iot/gateway/oauth/GatewaySecurityContext.java +++ b/src/main/java/com/iot/gateway/oauth/GatewaySecurityContext.java @@ -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); } } diff --git a/src/main/java/com/iot/gateway/oauth/GrantTypeEnum.java b/src/main/java/com/iot/gateway/oauth/GrantTypeEnum.java new file mode 100644 index 0000000..f9541f0 --- /dev/null +++ b/src/main/java/com/iot/gateway/oauth/GrantTypeEnum.java @@ -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); + } +} diff --git a/src/main/java/com/iot/gateway/oauth/JwtClaims.java b/src/main/java/com/iot/gateway/oauth/JwtClaims.java new file mode 100644 index 0000000..30ffab6 --- /dev/null +++ b/src/main/java/com/iot/gateway/oauth/JwtClaims.java @@ -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() { + } +} diff --git a/src/main/java/com/iot/gateway/oauth/JwtTokenProvider.java b/src/main/java/com/iot/gateway/oauth/JwtTokenProvider.java index b98bde7..d81c0e4 100644 --- a/src/main/java/com/iot/gateway/oauth/JwtTokenProvider.java +++ b/src/main/java/com/iot/gateway/oauth/JwtTokenProvider.java @@ -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 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) diff --git a/src/main/java/com/iot/gateway/oauth/OAuth2AuthenticationFilter.java b/src/main/java/com/iot/gateway/oauth/OAuth2AuthenticationFilter.java index 0aff223..74be02e 100644 --- a/src/main/java/com/iot/gateway/oauth/OAuth2AuthenticationFilter.java +++ b/src/main/java/com/iot/gateway/oauth/OAuth2AuthenticationFilter.java @@ -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 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 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 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 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 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(); diff --git a/src/main/java/com/iot/gateway/oauth/OAuth2TokenController.java b/src/main/java/com/iot/gateway/oauth/OAuth2TokenController.java index db24a1d..f8b8c14 100644 --- a/src/main/java/com/iot/gateway/oauth/OAuth2TokenController.java +++ b/src/main/java/com/iot/gateway/oauth/OAuth2TokenController.java @@ -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> 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 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() .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 clientAllowed = StringUtils.hasText(client.getScopes()) - ? Arrays.asList(client.getScopes().split("[,;\\s]+")) + ? Arrays.asList(client.getScopes().split(ScopeMatcher.SEPARATOR_REGEX)) : Collections.emptyList(); - List requested = Arrays.asList(scope.split("[,;\\s]+")); + List 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 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> error(HttpStatus status, OAuthErrorEnum error, String description) { + Map body = new HashMap<>(); + body.put(OAuthErrorEnum.FIELD_ERROR, error.getCode()); + body.put(OAuthErrorEnum.FIELD_DESCRIPTION, description); + return ResponseEntity.status(status).body(body); + } } diff --git a/src/main/java/com/iot/gateway/oauth/OAuthErrorEnum.java b/src/main/java/com/iot/gateway/oauth/OAuthErrorEnum.java new file mode 100644 index 0000000..bcbd93b --- /dev/null +++ b/src/main/java/com/iot/gateway/oauth/OAuthErrorEnum.java @@ -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; +} diff --git a/src/main/java/com/iot/gateway/oauth/OAuthParams.java b/src/main/java/com/iot/gateway/oauth/OAuthParams.java new file mode 100644 index 0000000..54cc62d --- /dev/null +++ b/src/main/java/com/iot/gateway/oauth/OAuthParams.java @@ -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() { + } +} diff --git a/src/main/java/com/iot/gateway/oauth/OpenClientRateLimitFilter.java b/src/main/java/com/iot/gateway/oauth/OpenClientRateLimitFilter.java index de9c8b2..6a95446 100644 --- a/src/main/java/com/iot/gateway/oauth/OpenClientRateLimitFilter.java +++ b/src/main/java/com/iot/gateway/oauth/OpenClientRateLimitFilter.java @@ -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; } } diff --git a/src/main/java/com/iot/gateway/oauth/OpenScopeEnum.java b/src/main/java/com/iot/gateway/oauth/OpenScopeEnum.java index bc3e62a..45abcba 100644 --- a/src/main/java/com/iot/gateway/oauth/OpenScopeEnum.java +++ b/src/main/java/com/iot/gateway/oauth/OpenScopeEnum.java @@ -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; diff --git a/src/main/java/com/iot/gateway/oauth/ScopeMatcher.java b/src/main/java/com/iot/gateway/oauth/ScopeMatcher.java new file mode 100644 index 0000000..42b19ea --- /dev/null +++ b/src/main/java/com/iot/gateway/oauth/ScopeMatcher.java @@ -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 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 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 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; + } +} diff --git a/src/main/java/com/iot/gateway/oauth/SecurityConfig.java b/src/main/java/com/iot/gateway/oauth/SecurityConfig.java index 64ffbfd..01fd4e1 100644 --- a/src/main/java/com/iot/gateway/oauth/SecurityConfig.java +++ b/src/main/java/com/iot/gateway/oauth/SecurityConfig.java @@ -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 客户端或管理员认证) diff --git a/src/main/java/com/iot/gateway/openapi/CommandResultEnum.java b/src/main/java/com/iot/gateway/openapi/CommandResultEnum.java new file mode 100644 index 0000000..bdcb919 --- /dev/null +++ b/src/main/java/com/iot/gateway/openapi/CommandResultEnum.java @@ -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; +} diff --git a/src/main/java/com/iot/gateway/openapi/OpenAlarmController.java b/src/main/java/com/iot/gateway/openapi/OpenAlarmController.java index c61488a..168d59e 100644 --- a/src/main/java/com/iot/gateway/openapi/OpenAlarmController.java +++ b/src/main/java/com/iot/gateway/openapi/OpenAlarmController.java @@ -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> 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 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> 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> ackAlarm(@RequestBody Map 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())); } } diff --git a/src/main/java/com/iot/gateway/openapi/OpenApiFields.java b/src/main/java/com/iot/gateway/openapi/OpenApiFields.java new file mode 100644 index 0000000..69ab0fc --- /dev/null +++ b/src/main/java/com/iot/gateway/openapi/OpenApiFields.java @@ -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() { + } +} diff --git a/src/main/java/com/iot/gateway/openapi/OpenCommandController.java b/src/main/java/com/iot/gateway/openapi/OpenCommandController.java index 9e619d8..66515d0 100644 --- a/src/main/java/com/iot/gateway/openapi/OpenCommandController.java +++ b/src/main/java/com/iot/gateway/openapi/OpenCommandController.java @@ -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> sendCommand(@RequestBody Map 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 )); } } diff --git a/src/main/java/com/iot/gateway/openapi/OpenDeviceController.java b/src/main/java/com/iot/gateway/openapi/OpenDeviceController.java index 566c2e7..0483222 100644 --- a/src/main/java/com/iot/gateway/openapi/OpenDeviceController.java +++ b/src/main/java/com/iot/gateway/openapi/OpenDeviceController.java @@ -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> 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 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> 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 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() )); } } diff --git a/src/main/java/com/iot/gateway/openapi/OpenTelemetryController.java b/src/main/java/com/iot/gateway/openapi/OpenTelemetryController.java index 244496c..2cf677a 100644 --- a/src/main/java/com/iot/gateway/openapi/OpenTelemetryController.java +++ b/src/main/java/com/iot/gateway/openapi/OpenTelemetryController.java @@ -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> 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> 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) ) )); } diff --git a/src/main/java/com/iot/gateway/webhook/WebhookChannelEnum.java b/src/main/java/com/iot/gateway/webhook/WebhookChannelEnum.java new file mode 100644 index 0000000..f0bae4b --- /dev/null +++ b/src/main/java/com/iot/gateway/webhook/WebhookChannelEnum.java @@ -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); + } +} diff --git a/src/main/java/com/iot/gateway/webhook/WebhookDispatchService.java b/src/main/java/com/iot/gateway/webhook/WebhookDispatchService.java index 1da789c..370adac 100644 --- a/src/main/java/com/iot/gateway/webhook/WebhookDispatchService.java +++ b/src/main/java/com/iot/gateway/webhook/WebhookDispatchService.java @@ -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 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 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 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) { diff --git a/src/main/java/com/iot/gateway/webhook/WebhookEventEnum.java b/src/main/java/com/iot/gateway/webhook/WebhookEventEnum.java new file mode 100644 index 0000000..6ed0388 --- /dev/null +++ b/src/main/java/com/iot/gateway/webhook/WebhookEventEnum.java @@ -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> toList() { + List> 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)); + } +} diff --git a/src/main/java/com/iot/gateway/webhook/WebhookReceiveController.java b/src/main/java/com/iot/gateway/webhook/WebhookReceiveController.java index 9b13de1..ce76ba6 100644 --- a/src/main/java/com/iot/gateway/webhook/WebhookReceiveController.java +++ b/src/main/java/com/iot/gateway/webhook/WebhookReceiveController.java @@ -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> receiveEvent( @PathVariable String channel, - @RequestHeader(value = "X-Signature", required = false) String signature, + @RequestHeader(value = GatewayHeaders.SIGNATURE, required = false) String signature, @RequestBody Map 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() )); } }