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:
@@ -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) {
|
||||||
|
case DEVICE_LIST -> {
|
||||||
Map<String, String> params = new HashMap<>();
|
Map<String, String> params = new HashMap<>();
|
||||||
if (args != null && args.containsKey("status")) {
|
if (args != null && args.containsKey(McpToolArgs.STATUS)) {
|
||||||
String st = String.valueOf(args.get("status"));
|
OnlineStatusEnum onlineFilter = OnlineStatusEnum.ofFilter(String.valueOf(args.get(McpToolArgs.STATUS)));
|
||||||
if ("online".equalsIgnoreCase(st)) params.put("online", "1");
|
if (onlineFilter != null) {
|
||||||
else if ("offline".equalsIgnoreCase(st)) params.put("online", "0");
|
params.put(OnlineStatusEnum.PARAM_ONLINE, String.valueOf(onlineFilter.getCode()));
|
||||||
}
|
}
|
||||||
if (args != null && args.containsKey("pageNum")) params.put("pageNum", String.valueOf(args.get("pageNum")));
|
}
|
||||||
if (args != null && args.containsKey("pageSize")) params.put("pageSize", String.valueOf(args.get("pageSize")));
|
copyArg(args, params, McpToolArgs.PAGE_NUM);
|
||||||
|
copyArg(args, params, McpToolArgs.PAGE_SIZE);
|
||||||
|
|
||||||
// 专线调用核心业务后端多租户独立数据库
|
// 专线调用核心业务后端多租户独立数据库
|
||||||
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);
|
||||||
result.put("tenantId", tenantId);
|
result.put(McpToolArgs.RESULT_TENANT_ID, tenantId);
|
||||||
result.put("total", data.has("total") ? data.get("total").asLong() : 0);
|
result.put(McpToolArgs.RESULT_TOTAL, BackendResponse.total(data));
|
||||||
result.put("devices", data.has("records") ? data.get("records") : List.of());
|
result.put(McpToolArgs.RESULT_DEVICES, BackendResponse.records(data));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 非 mock 模式下直接明确报错
|
// 非 mock 模式下直接明确报错
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 开发演示兜底
|
// 开发演示兜底
|
||||||
result.put("tenantId", tenantId);
|
result.put(McpToolArgs.RESULT_TENANT_ID, tenantId);
|
||||||
result.put("isMock", true);
|
result.put(McpToolArgs.RESULT_IS_MOCK, true);
|
||||||
result.put("total", 2);
|
result.put(McpToolArgs.RESULT_TOTAL, 2);
|
||||||
result.put("devices", List.of(
|
result.put(McpToolArgs.RESULT_DEVICES, List.of(
|
||||||
Map.of("deviceCode", "DEV20261009001", "deviceName", "1号注塑机 (海天MA1200)", "online", 1),
|
Map.of("deviceCode", MOCK_DEVICE_CODE_1, "deviceName", "1号注塑机 (海天MA1200)", OnlineStatusEnum.PARAM_ONLINE, OnlineStatusEnum.ONLINE.getCode()),
|
||||||
Map.of("deviceCode", "DEV20261009002", "deviceName", "2号注塑机 (海天MA2000)", "online", 0)
|
Map.of("deviceCode", MOCK_DEVICE_CODE_2, "deviceName", "2号注塑机 (海天MA2000)", OnlineStatusEnum.PARAM_ONLINE, OnlineStatusEnum.OFFLINE.getCode())
|
||||||
));
|
));
|
||||||
} else if ("iot_telemetry_latest".equals(toolName)) {
|
}
|
||||||
String deviceId = args != null && args.containsKey("deviceId") ? String.valueOf(args.get("deviceId")) : "";
|
case TELEMETRY_LATEST -> {
|
||||||
|
String deviceId = args != null && args.containsKey(McpToolArgs.DEVICE_ID)
|
||||||
|
? String.valueOf(args.get(McpToolArgs.DEVICE_ID)) : "";
|
||||||
if (deviceId.isEmpty()) {
|
if (deviceId.isEmpty()) {
|
||||||
throw new BizException(400, "缺少必填参数: deviceId");
|
throw new BizException(HttpStatus.BAD_REQUEST.value(), "缺少必填参数: " + McpToolArgs.DEVICE_ID);
|
||||||
}
|
}
|
||||||
|
|
||||||
JsonNode resp = internalClient.get("/api/internal/open/telemetry/latest", tenantId, Map.of("deviceId", deviceId));
|
JsonNode resp = internalClient.get(InternalPaths.TELEMETRY_LATEST, tenantId,
|
||||||
if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) {
|
Map.of(McpToolArgs.DEVICE_ID, deviceId));
|
||||||
result.put("tenantId", tenantId);
|
if (BackendResponse.isSuccess(resp)) {
|
||||||
result.put("deviceId", deviceId);
|
result.put(McpToolArgs.RESULT_TENANT_ID, tenantId);
|
||||||
result.put("data", resp.get("data"));
|
result.put(McpToolArgs.DEVICE_ID, deviceId);
|
||||||
|
result.put(McpToolArgs.RESULT_DATA, BackendResponse.data(resp));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
result.put("isMock", true);
|
result.put(McpToolArgs.RESULT_IS_MOCK, true);
|
||||||
result.put("deviceId", deviceId);
|
result.put(McpToolArgs.DEVICE_ID, deviceId);
|
||||||
result.put("timestamp", System.currentTimeMillis());
|
result.put(McpToolArgs.RESULT_TIMESTAMP, System.currentTimeMillis());
|
||||||
result.put("telemetry", Map.of(
|
result.put(McpToolArgs.RESULT_TELEMETRY, Map.of(
|
||||||
"barrel_temp_zone1", 215.4,
|
"barrel_temp_zone1", 215.4,
|
||||||
"barrel_temp_zone2", 220.1,
|
"barrel_temp_zone2", 220.1,
|
||||||
"injection_pressure_mpa", 13.8,
|
"injection_pressure_mpa", 13.8,
|
||||||
"screw_speed_rpm", 85.0
|
"screw_speed_rpm", 85.0
|
||||||
));
|
));
|
||||||
} else if ("iot_device_control".equals(toolName)) {
|
}
|
||||||
|
case DEVICE_CONTROL -> {
|
||||||
// 控制指令下发:绝不能在后端失败时误报“EXECUTED”
|
// 控制指令下发:绝不能在后端失败时误报“EXECUTED”
|
||||||
JsonNode resp = internalClient.post("/api/internal/open/commands", tenantId, args);
|
JsonNode resp = internalClient.post(InternalPaths.COMMANDS, tenantId, args);
|
||||||
if (resp != null && resp.has("code") && resp.get("code").asInt() == 200 && resp.has("data")) {
|
if (BackendResponse.isSuccess(resp)) {
|
||||||
result.put("tenantId", tenantId);
|
result.put(McpToolArgs.RESULT_TENANT_ID, tenantId);
|
||||||
result.put("success", true);
|
result.put(McpToolArgs.RESULT_SUCCESS, true);
|
||||||
result.put("status", "EXECUTED");
|
result.put(McpToolArgs.RESULT_STATUS, CommandResultEnum.EXECUTED.getCode());
|
||||||
result.put("data", resp.get("data"));
|
result.put(McpToolArgs.RESULT_DATA, BackendResponse.data(resp));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
String errMsg = resp != null && resp.has("message") ? resp.get("message").asText() : "后端指令网关通信超时或未响应";
|
String errMsg = BackendResponse.message(resp, "后端指令网关通信超时或未响应");
|
||||||
if (!mockEnabled) {
|
if (!mockEnabled) {
|
||||||
log.error("下发设备控制指令失败: tool={}, tenantId={}, args={}, err={}", toolName, tenantId, args, errMsg);
|
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("isMock", true);
|
result.put(McpToolArgs.RESULT_IS_MOCK, true);
|
||||||
result.put("success", false);
|
result.put(McpToolArgs.RESULT_SUCCESS, false);
|
||||||
result.put("status", "MOCK_MODE_REFUSED");
|
result.put(McpToolArgs.RESULT_STATUS, CommandResultEnum.MOCK_MODE_REFUSED.getCode());
|
||||||
result.put("message", "当前为开发演示模式,已拒绝向物理设备真实下发控制指令: " + errMsg);
|
result.put(McpToolArgs.RESULT_MESSAGE, "当前为开发演示模式,已拒绝向物理设备真实下发控制指令: " + errMsg);
|
||||||
} else if ("iot_alarm_query".equals(toolName)) {
|
}
|
||||||
|
case ALARM_QUERY -> {
|
||||||
Map<String, String> params = new HashMap<>();
|
Map<String, String> params = new HashMap<>();
|
||||||
if (args != null && args.containsKey("deviceId")) params.put("deviceId", String.valueOf(args.get("deviceId")));
|
copyArg(args, params, McpToolArgs.DEVICE_ID);
|
||||||
if (args != null && args.containsKey("severity")) params.put("severity", String.valueOf(args.get("severity")));
|
copyArg(args, params, McpToolArgs.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)) {
|
||||||
result.put("tenantId", tenantId);
|
result.put(McpToolArgs.RESULT_TENANT_ID, tenantId);
|
||||||
result.put("alarms", resp.get("data"));
|
result.put(McpToolArgs.RESULT_ALARMS, BackendResponse.data(resp));
|
||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
}
|
}
|
||||||
|
|
||||||
result.put("isMock", true);
|
result.put(McpToolArgs.RESULT_IS_MOCK, true);
|
||||||
result.put("alarms", List.of(
|
result.put(McpToolArgs.RESULT_ALARMS, List.of(
|
||||||
Map.of("alarmId", "ALM-2026100901", "deviceId", "DEV20261009001", "severity", 3, "title", "模具温度异常过高")
|
Map.of("alarmId", "ALM-2026100901", McpToolArgs.DEVICE_ID, MOCK_DEVICE_CODE_1,
|
||||||
|
McpToolArgs.SEVERITY, MOCK_ALARM_SEVERITY, "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 {
|
||||||
long min = Long.parseLong(parts[1]);
|
return Long.parseLong(minutePart) < currentMinute;
|
||||||
return min < currentMinute;
|
} catch (NumberFormatException ignored) {
|
||||||
} catch (NumberFormatException ignored) {}
|
|
||||||
}
|
|
||||||
return false;
|
return false;
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -70,21 +78,21 @@ public class OpenClientRateLimitFilter extends OncePerRequestFilter {
|
|||||||
log.warn("OAuth 2.0 客户端调用超限触发拦截 (429): clientId={}, currentCount={}, rateLimit={}/min, path={}",
|
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()
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user