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

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

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