From b537faeb9974e2035c7d6b52abe71881776df033 Mon Sep 17 00:00:00 2001 From: wjh <1693415692@qq.com> Date: Fri, 9 Oct 2026 09:34:37 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E7=BB=9F=E4=B8=80=E5=BC=80=E6=94=BE?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E7=BD=91=E5=85=B3=E5=BE=AE=E6=9C=8D=E5=8A=A1?= =?UTF-8?q?=E5=88=9D=E5=A7=8B=E5=8C=96=E5=B7=A5=E7=A8=8B=20(OAuth2.0=20+?= =?UTF-8?q?=20MCP=20+=20OpenAPI=20+=20Webhook)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 38 ++++ Jenkinsfile | 49 ++++++ README.md | 13 ++ pom.xml | 123 +++++++++++++ .../iot/gateway/IotGatewayApplication.java | 14 ++ .../admin/OpenClientAdminController.java | 129 ++++++++++++++ .../admin/OpenGatewayInfoController.java | 54 ++++++ .../admin/OpenWebhookAdminController.java | 108 ++++++++++++ .../com/iot/gateway/common/BizException.java | 18 ++ .../common/GlobalExceptionHandler.java | 37 ++++ .../java/com/iot/gateway/common/Result.java | 35 ++++ .../com/iot/gateway/entity/OpenApiLog.java | 29 ++++ .../com/iot/gateway/entity/OpenClient.java | 37 ++++ .../com/iot/gateway/entity/OpenWebhook.java | 32 ++++ .../iot/gateway/mapper/OpenApiLogMapper.java | 9 + .../iot/gateway/mapper/OpenClientMapper.java | 9 + .../iot/gateway/mapper/OpenWebhookMapper.java | 9 + .../com/iot/gateway/mcp/McpSseController.java | 163 ++++++++++++++++++ .../com/iot/gateway/mcp/McpToolRegistry.java | 121 +++++++++++++ .../gateway/oauth/GatewaySecurityContext.java | 43 +++++ .../iot/gateway/oauth/JwtTokenProvider.java | 72 ++++++++ .../oauth/OAuth2AuthenticationFilter.java | 82 +++++++++ .../gateway/oauth/OAuth2TokenController.java | 95 ++++++++++ .../com/iot/gateway/oauth/SecurityConfig.java | 45 +++++ .../gateway/openapi/OpenAlarmController.java | 51 ++++++ .../openapi/OpenCommandController.java | 40 +++++ .../gateway/openapi/OpenDeviceController.java | 65 +++++++ .../openapi/OpenTelemetryController.java | 63 +++++++ .../webhook/WebhookDispatchService.java | 70 ++++++++ .../webhook/WebhookReceiveController.java | 35 ++++ src/main/resources/application-prod.yml | 12 ++ src/main/resources/application.yml | 44 +++++ 32 files changed, 1744 insertions(+) create mode 100644 .gitignore create mode 100644 Jenkinsfile create mode 100644 README.md create mode 100644 pom.xml create mode 100644 src/main/java/com/iot/gateway/IotGatewayApplication.java create mode 100644 src/main/java/com/iot/gateway/admin/OpenClientAdminController.java create mode 100644 src/main/java/com/iot/gateway/admin/OpenGatewayInfoController.java create mode 100644 src/main/java/com/iot/gateway/admin/OpenWebhookAdminController.java create mode 100644 src/main/java/com/iot/gateway/common/BizException.java create mode 100644 src/main/java/com/iot/gateway/common/GlobalExceptionHandler.java create mode 100644 src/main/java/com/iot/gateway/common/Result.java create mode 100644 src/main/java/com/iot/gateway/entity/OpenApiLog.java create mode 100644 src/main/java/com/iot/gateway/entity/OpenClient.java create mode 100644 src/main/java/com/iot/gateway/entity/OpenWebhook.java create mode 100644 src/main/java/com/iot/gateway/mapper/OpenApiLogMapper.java create mode 100644 src/main/java/com/iot/gateway/mapper/OpenClientMapper.java create mode 100644 src/main/java/com/iot/gateway/mapper/OpenWebhookMapper.java create mode 100644 src/main/java/com/iot/gateway/mcp/McpSseController.java create mode 100644 src/main/java/com/iot/gateway/mcp/McpToolRegistry.java create mode 100644 src/main/java/com/iot/gateway/oauth/GatewaySecurityContext.java create mode 100644 src/main/java/com/iot/gateway/oauth/JwtTokenProvider.java create mode 100644 src/main/java/com/iot/gateway/oauth/OAuth2AuthenticationFilter.java create mode 100644 src/main/java/com/iot/gateway/oauth/OAuth2TokenController.java create mode 100644 src/main/java/com/iot/gateway/oauth/SecurityConfig.java create mode 100644 src/main/java/com/iot/gateway/openapi/OpenAlarmController.java create mode 100644 src/main/java/com/iot/gateway/openapi/OpenCommandController.java create mode 100644 src/main/java/com/iot/gateway/openapi/OpenDeviceController.java create mode 100644 src/main/java/com/iot/gateway/openapi/OpenTelemetryController.java create mode 100644 src/main/java/com/iot/gateway/webhook/WebhookDispatchService.java create mode 100644 src/main/java/com/iot/gateway/webhook/WebhookReceiveController.java create mode 100644 src/main/resources/application-prod.yml create mode 100644 src/main/resources/application.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1e7daf5 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Log and Temp ### +*.log +*.tmp +*.zip +*.tar.gz diff --git a/Jenkinsfile b/Jenkinsfile new file mode 100644 index 0000000..a4cb55a --- /dev/null +++ b/Jenkinsfile @@ -0,0 +1,49 @@ +pipeline { + agent any + + tools { + maven 'Maven-3.8' + jdk 'JDK17' + } + + environment { + DEPLOY_PORT = '18082' + APP_NAME = 'iot-open-gateway' + DEPLOY_DIR = '/data/iot/gateway' + JENKINS_NODE_COOKIE = 'dontKillMe' + BUILD_ID = 'dontKillMe' + } + + stages { + stage('Deploy & Run') { + steps { + sh ''' + echo "=== 启动 IOT 开放网关微服务 ===" + mkdir -p "${DEPLOY_DIR}" + + if [ -f "/data/iot/gateway/source/target/iot-open-gateway-1.0.0.jar" ]; then + cp -f "/data/iot/gateway/source/target/iot-open-gateway-1.0.0.jar" "${DEPLOY_DIR}/${APP_NAME}.jar" + fi + + PID=$(lsof -ti:${DEPLOY_PORT} || true) + if [ -n "$PID" ]; then + echo "停止旧进程 $PID" + kill -9 $PID || true + sleep 2 + fi + + export JENKINS_NODE_COOKIE=dontKillMe + export BUILD_ID=dontKillMe + nohup java -jar -Dspring.profiles.active=prod "${DEPLOY_DIR}/${APP_NAME}.jar" > "${DEPLOY_DIR}/${APP_NAME}.log" 2>&1 & + + echo "后台启动中..." + sleep 6 + lsof -i:${DEPLOY_PORT} || true + echo "=== 网关健康检查 ===" + curl -s http://127.0.0.1:${DEPLOY_PORT}/open/gateway/health || true + echo "" + ''' + } + } + } +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..e61d2c1 --- /dev/null +++ b/README.md @@ -0,0 +1,13 @@ +# IOT-open-gateway 统一开放平台网关 + +基于 Spring Boot 3.2.11 + Java 17 研发的独立开放网关服务,与主业务后端 `IOT-backend` 完全物理隔离解耦,专门负责: +1. **OAuth 2.0 授权服务器 (Client Credentials)**:客户端通过 `/oauth2/token` 凭借 `client_id` + `client_secret` 申请并获取强绑定租户的 JWT 访问令牌; +2. **MCP Server 协议适配层 (面向 AI 智能体生态)**:提供标准 HTTP+SSE 端点 `/mcp/sse` 与 `/mcp/messages`,对外暴露 `iot_device_list`、`iot_telemetry_latest`、`iot_device_control`、`iot_alarm_query` 工业级物联能力; +3. **REST OpenAPI 业务适配层 (面向传统三方企业系统)**:对外暴露 `/open/v1/devices/**`、`/open/v1/telemetry/**`、`/open/v1/commands`、`/open/v1/alarms`; +4. **三方双向集成与 Webhook 调度**:支持向企业微信、钉钉、飞书群机器人主动推送告警与事件,支持接收外部系统事件回调; +5. **解耦高可用保活**:主后端 `IOT-backend` 发版重启期间,网关与外部所有的 MCP 长连接会话保持不中断。 + +## 启动与运行 +- 本地开发:端口 `8082` +- 生产环境:端口 `18082`(激活 `prod` profile) +- Swagger 接口文档:`http://localhost:8082/swagger-ui.html` diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..b364eac --- /dev/null +++ b/pom.xml @@ -0,0 +1,123 @@ + + + 4.0.0 + + + org.springframework.boot + spring-boot-starter-parent + 3.2.11 + + + + com.iot + iot-open-gateway + 1.0.0 + iot-open-gateway + IOT 物联网平台统一开放网关与三方集成中台 (OAuth 2.0 + MCP + OpenAPI + Webhook) + + + 17 + 3.5.7 + 2.5.0 + 0.11.5 + + + + + + org.springframework.boot + spring-boot-starter-web + + + + + org.springframework.boot + spring-boot-starter-security + + + + + org.springframework.boot + spring-boot-starter-validation + + + + + org.springframework.boot + spring-boot-starter-actuator + + + + + com.baomidou + mybatis-plus-spring-boot3-starter + ${mybatis-plus.version} + + + + + com.mysql + mysql-connector-j + runtime + + + + + io.jsonwebtoken + jjwt-api + ${jjwt.version} + + + io.jsonwebtoken + jjwt-impl + ${jjwt.version} + runtime + + + io.jsonwebtoken + jjwt-jackson + ${jjwt.version} + runtime + + + + + org.springdoc + springdoc-openapi-starter-webmvc-ui + ${springdoc.version} + + + + + org.projectlombok + lombok + true + + + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + diff --git a/src/main/java/com/iot/gateway/IotGatewayApplication.java b/src/main/java/com/iot/gateway/IotGatewayApplication.java new file mode 100644 index 0000000..d599781 --- /dev/null +++ b/src/main/java/com/iot/gateway/IotGatewayApplication.java @@ -0,0 +1,14 @@ +package com.iot.gateway; + +import org.mybatis.spring.annotation.MapperScan; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +@MapperScan("com.iot.gateway.mapper") +public class IotGatewayApplication { + + public static void main(String[] args) { + SpringApplication.run(IotGatewayApplication.class, args); + } +} diff --git a/src/main/java/com/iot/gateway/admin/OpenClientAdminController.java b/src/main/java/com/iot/gateway/admin/OpenClientAdminController.java new file mode 100644 index 0000000..1b432e4 --- /dev/null +++ b/src/main/java/com/iot/gateway/admin/OpenClientAdminController.java @@ -0,0 +1,129 @@ +package com.iot.gateway.admin; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.iot.gateway.common.BizException; +import com.iot.gateway.common.Result; +import com.iot.gateway.entity.OpenClient; +import com.iot.gateway.mapper.OpenClientMapper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.security.SecureRandom; +import java.util.Map; +import java.util.UUID; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@Tag(name = "开放平台管理 - 三方应用凭据管理") +@RestController +@RequestMapping("/api/open/client") +public class OpenClientAdminController { + + @Autowired + private OpenClientMapper clientMapper; + + private static final SecureRandom RANDOM = new SecureRandom(); + + @Operation(summary = "三方应用凭据分页列表") + @GetMapping("/page") + public Result> page( + @RequestParam(defaultValue = "1") Integer pageNum, + @RequestParam(defaultValue = "10") Integer pageSize, + @RequestParam(required = false) Long tenantId, + @RequestParam(required = false) String keyword) { + + Page page = new Page<>(pageNum, pageSize); + LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); + if (tenantId != null) { + qw.eq(OpenClient::getTenantId, tenantId); + } + if (StringUtils.hasText(keyword)) { + qw.and(w -> w.like(OpenClient::getClientName, keyword) + .or().like(OpenClient::getClientId, keyword)); + } + qw.orderByDesc(OpenClient::getCreateTime); + return Result.success(clientMapper.selectPage(page, qw)); + } + + @Operation(summary = "获取凭据详情") + @GetMapping("/{id}") + public Result getById(@PathVariable Long id) { + OpenClient client = clientMapper.selectById(id); + if (client == null) throw new BizException("客户端凭据不存在"); + return Result.success(client); + } + + @Operation(summary = "新建三方应用凭据 (自动生成 ClientId 与 Secret)") + @PostMapping + public Result create(@RequestBody OpenClient req) { + req.setId(null); + req.setClientId("client_" + UUID.randomUUID().toString().replace("-", "").substring(0, 12)); + req.setClientSecret("sec_" + UUID.randomUUID().toString().replace("-", "") + Long.toHexString(RANDOM.nextLong())); + if (req.getTenantId() == null) req.setTenantId(1L); + if (!StringUtils.hasText(req.getScopes())) req.setScopes("device.read"); + if (req.getStatus() == null) req.setStatus(1); + if (req.getRateLimit() == null) req.setRateLimit(60); + + clientMapper.insert(req); + log.info("新建开放应用凭据: id={}, clientId={}", req.getId(), req.getClientId()); + return Result.success(req); + } + + @Operation(summary = "修改三方应用凭据") + @PutMapping("/{id}") + public Result update(@PathVariable Long id, @RequestBody OpenClient req) { + OpenClient client = clientMapper.selectById(id); + if (client == null) throw new BizException("客户端凭据不存在"); + + client.setClientName(req.getClientName()); + if (req.getScopes() != null) client.setScopes(req.getScopes()); + client.setIpWhitelist(req.getIpWhitelist()); + if (req.getRateLimit() != null) client.setRateLimit(req.getRateLimit()); + if (req.getStatus() != null) client.setStatus(req.getStatus()); + client.setExpireTime(req.getExpireTime()); + client.setRemark(req.getRemark()); + + clientMapper.updateById(client); + return Result.success(); + } + + @Operation(summary = "删除三方应用凭据") + @DeleteMapping("/{id}") + public Result delete(@PathVariable Long id) { + clientMapper.deleteById(id); + return Result.success(); + } + + @Operation(summary = "重置客户端密钥 (Secret)") + @PostMapping("/{id}/reset-secret") + public Result> resetSecret(@PathVariable Long id) { + OpenClient client = clientMapper.selectById(id); + if (client == null) throw new BizException("客户端凭据不存在"); + + String newSecret = "sec_" + UUID.randomUUID().toString().replace("-", "") + Long.toHexString(RANDOM.nextLong()); + client.setClientSecret(newSecret); + clientMapper.updateById(client); + return Result.success(Map.of("clientSecret", newSecret)); + } + + @Operation(summary = "更新客户端状态") + @PutMapping("/{id}/status") + public Result updateStatus(@PathVariable Long id, @RequestParam Integer status) { + OpenClient client = clientMapper.selectById(id); + if (client == null) throw new BizException("客户端凭据不存在"); + client.setStatus(status); + clientMapper.updateById(client); + return Result.success(); + } +} diff --git a/src/main/java/com/iot/gateway/admin/OpenGatewayInfoController.java b/src/main/java/com/iot/gateway/admin/OpenGatewayInfoController.java new file mode 100644 index 0000000..027cc71 --- /dev/null +++ b/src/main/java/com/iot/gateway/admin/OpenGatewayInfoController.java @@ -0,0 +1,54 @@ +package com.iot.gateway.admin; + +import com.iot.gateway.common.Result; +import com.iot.gateway.mcp.McpToolRegistry; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "网关运行状态与能力目录") +@RestController +@RequestMapping("/open/gateway") +public class OpenGatewayInfoController { + + @Autowired + private McpToolRegistry toolRegistry; + + @Operation(summary = "网关健康检查探针") + @GetMapping("/health") + public Result> health() { + return Result.success(Map.of( + "status", "ONLINE", + "service", "iot-open-gateway", + "timestamp", System.currentTimeMillis() + )); + } + + @Operation(summary = "拉取可用能力目录 (MCP Tools 与 OpenAPI)") + @GetMapping("/catalog") + public Result> catalog() { + List> tools = toolRegistry.getToolDefinitions(); + List apis = List.of( + "GET /open/v1/devices (租户设备台账与状态)", + "GET /open/v1/devices/{id} (单设备详情与点位定义)", + "GET /open/v1/telemetry/latest (设备最新实时工况召测)", + "GET /open/v1/telemetry/history (时序历史遥测点位拉取)", + "POST /open/v1/commands (设备控制指令反写下发)", + "GET /open/v1/alarms (故障报警检索与确认)" + ); + + return Result.success(Map.of( + "status", "ONLINE", + "mcpEndpoint", "/mcp/sse", + "tokenEndpoint", "/oauth2/token", + "toolCount", tools.size(), + "tools", tools, + "openApis", apis + )); + } +} diff --git a/src/main/java/com/iot/gateway/admin/OpenWebhookAdminController.java b/src/main/java/com/iot/gateway/admin/OpenWebhookAdminController.java new file mode 100644 index 0000000..04846a7 --- /dev/null +++ b/src/main/java/com/iot/gateway/admin/OpenWebhookAdminController.java @@ -0,0 +1,108 @@ +package com.iot.gateway.admin; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.baomidou.mybatisplus.extension.plugins.pagination.Page; +import com.iot.gateway.common.BizException; +import com.iot.gateway.common.Result; +import com.iot.gateway.entity.OpenWebhook; +import com.iot.gateway.mapper.OpenWebhookMapper; +import com.iot.gateway.webhook.WebhookDispatchService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@Tag(name = "开放平台管理 - Webhook 通道管理") +@RestController +@RequestMapping("/api/open/webhook") +public class OpenWebhookAdminController { + + @Autowired + private OpenWebhookMapper webhookMapper; + + @Autowired + private WebhookDispatchService dispatchService; + + @Operation(summary = "Webhook 通道分页列表") + @GetMapping("/page") + public Result> page( + @RequestParam(defaultValue = "1") Integer pageNum, + @RequestParam(defaultValue = "10") Integer pageSize, + @RequestParam(required = false) Long tenantId, + @RequestParam(required = false) String keyword) { + + Page page = new Page<>(pageNum, pageSize); + LambdaQueryWrapper qw = new LambdaQueryWrapper<>(); + if (tenantId != null) { + qw.eq(OpenWebhook::getTenantId, tenantId); + } + if (StringUtils.hasText(keyword)) { + qw.and(w -> w.like(OpenWebhook::getChannelName, keyword) + .or().like(OpenWebhook::getWebhookUrl, keyword)); + } + qw.orderByDesc(OpenWebhook::getCreateTime); + return Result.success(webhookMapper.selectPage(page, qw)); + } + + @Operation(summary = "新建 Webhook 通道") + @PostMapping + public Result create(@RequestBody OpenWebhook req) { + req.setId(null); + if (req.getTenantId() == null) req.setTenantId(1L); + if (!StringUtils.hasText(req.getEvents())) req.setEvents("device.alarm"); + if (req.getStatus() == null) req.setStatus(1); + + webhookMapper.insert(req); + return Result.success(req); + } + + @Operation(summary = "修改 Webhook 通道") + @PutMapping("/{id}") + public Result update(@PathVariable Long id, @RequestBody OpenWebhook req) { + OpenWebhook item = webhookMapper.selectById(id); + if (item == null) throw new BizException("通道不存在"); + + item.setChannelName(req.getChannelName()); + item.setChannelType(req.getChannelType()); + item.setWebhookUrl(req.getWebhookUrl()); + item.setSecret(req.getSecret()); + if (req.getEvents() != null) item.setEvents(req.getEvents()); + if (req.getStatus() != null) item.setStatus(req.getStatus()); + item.setRemark(req.getRemark()); + + webhookMapper.updateById(item); + return Result.success(); + } + + @Operation(summary = "删除 Webhook 通道") + @DeleteMapping("/{id}") + public Result delete(@PathVariable Long id) { + webhookMapper.deleteById(id); + return Result.success(); + } + + @Operation(summary = "测试 Webhook 推送连通性") + @PostMapping("/{id}/test") + public Result> testSend(@PathVariable Long id, @RequestBody(required = false) Map body) { + OpenWebhook item = webhookMapper.selectById(id); + if (item == null) throw new BizException("通道不存在"); + + String title = body != null && body.containsKey("title") ? body.get("title") : "【IoT平台测试】告警通知"; + String content = body != null && body.containsKey("content") ? body.get("content") : "这是一条来自开放平台的连通性测试消息。"; + + boolean ok = dispatchService.sendDirect(item.getChannelType(), item.getWebhookUrl(), title, content); + return Result.success(Map.of("success", ok, "message", ok ? "测试消息已成功送达" : "发送失败,请检查 URL 与网络配置")); + } +} diff --git a/src/main/java/com/iot/gateway/common/BizException.java b/src/main/java/com/iot/gateway/common/BizException.java new file mode 100644 index 0000000..5f610c8 --- /dev/null +++ b/src/main/java/com/iot/gateway/common/BizException.java @@ -0,0 +1,18 @@ +package com.iot.gateway.common; + +import lombok.Getter; + +@Getter +public class BizException extends RuntimeException { + private final Integer code; + + public BizException(String message) { + super(message); + this.code = 500; + } + + public BizException(Integer code, String message) { + super(message); + this.code = code; + } +} diff --git a/src/main/java/com/iot/gateway/common/GlobalExceptionHandler.java b/src/main/java/com/iot/gateway/common/GlobalExceptionHandler.java new file mode 100644 index 0000000..0fd4446 --- /dev/null +++ b/src/main/java/com/iot/gateway/common/GlobalExceptionHandler.java @@ -0,0 +1,37 @@ +package com.iot.gateway.common; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.validation.BindException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(BizException.class) + public Result handleBizException(BizException e) { + log.warn("业务异常: code={}, message={}", e.getCode(), e.getMessage()); + return Result.fail(e.getCode(), e.getMessage()); + } + + @ExceptionHandler({MethodArgumentNotValidException.class, BindException.class}) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public Result handleValidationException(Exception e) { + String msg = "参数校验未通过"; + if (e instanceof MethodArgumentNotValidException me && me.getBindingResult().getFieldError() != null) { + msg = me.getBindingResult().getFieldError().getDefaultMessage(); + } + return Result.fail(400, msg); + } + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public Result handleException(Exception e) { + log.error("系统未知异常: ", e); + return Result.fail(500, "网关处理异常: " + e.getMessage()); + } +} diff --git a/src/main/java/com/iot/gateway/common/Result.java b/src/main/java/com/iot/gateway/common/Result.java new file mode 100644 index 0000000..0b40019 --- /dev/null +++ b/src/main/java/com/iot/gateway/common/Result.java @@ -0,0 +1,35 @@ +package com.iot.gateway.common; + +import lombok.Data; +import java.io.Serializable; + +@Data +public class Result implements Serializable { + private Integer code; + private String message; + private T data; + + public Result() {} + + public Result(Integer code, String message, T data) { + this.code = code; + this.message = message; + this.data = data; + } + + public static Result success() { + return new Result<>(200, "操作成功", null); + } + + public static Result success(T data) { + return new Result<>(200, "操作成功", data); + } + + public static Result fail(String message) { + return new Result<>(500, message, null); + } + + public static Result fail(Integer code, String message) { + return new Result<>(code, message, null); + } +} diff --git a/src/main/java/com/iot/gateway/entity/OpenApiLog.java b/src/main/java/com/iot/gateway/entity/OpenApiLog.java new file mode 100644 index 0000000..da0e426 --- /dev/null +++ b/src/main/java/com/iot/gateway/entity/OpenApiLog.java @@ -0,0 +1,29 @@ +package com.iot.gateway.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import java.io.Serializable; +import java.time.LocalDateTime; +import lombok.Data; + +@Data +@TableName("open_api_log") +public class OpenApiLog implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + private String clientId; + private Long tenantId; + private String protocol; + private String apiPath; + private String requestMethod; + private String ip; + private Integer statusCode; + private Long costMs; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; +} diff --git a/src/main/java/com/iot/gateway/entity/OpenClient.java b/src/main/java/com/iot/gateway/entity/OpenClient.java new file mode 100644 index 0000000..6ba8b3c --- /dev/null +++ b/src/main/java/com/iot/gateway/entity/OpenClient.java @@ -0,0 +1,37 @@ +package com.iot.gateway.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import java.io.Serializable; +import java.time.LocalDateTime; +import lombok.Data; + +@Data +@TableName("open_client") +public class OpenClient implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + private String clientId; + private String clientSecret; + private String clientName; + private Long tenantId; + private String scopes; + private String ipWhitelist; + private Integer rateLimit; + private Integer status; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime expireTime; + + private String remark; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/iot/gateway/entity/OpenWebhook.java b/src/main/java/com/iot/gateway/entity/OpenWebhook.java new file mode 100644 index 0000000..e1ada90 --- /dev/null +++ b/src/main/java/com/iot/gateway/entity/OpenWebhook.java @@ -0,0 +1,32 @@ +package com.iot.gateway.entity; + +import com.baomidou.mybatisplus.annotation.IdType; +import com.baomidou.mybatisplus.annotation.TableId; +import com.baomidou.mybatisplus.annotation.TableName; +import com.fasterxml.jackson.annotation.JsonFormat; +import java.io.Serializable; +import java.time.LocalDateTime; +import lombok.Data; + +@Data +@TableName("open_webhook") +public class OpenWebhook implements Serializable { + + @TableId(type = IdType.AUTO) + private Long id; + + private Long tenantId; + private String channelName; + private String channelType; + private String webhookUrl; + private String secret; + private String events; + private Integer status; + private String remark; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime createTime; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime updateTime; +} diff --git a/src/main/java/com/iot/gateway/mapper/OpenApiLogMapper.java b/src/main/java/com/iot/gateway/mapper/OpenApiLogMapper.java new file mode 100644 index 0000000..f33e2d7 --- /dev/null +++ b/src/main/java/com/iot/gateway/mapper/OpenApiLogMapper.java @@ -0,0 +1,9 @@ +package com.iot.gateway.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.iot.gateway.entity.OpenApiLog; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface OpenApiLogMapper extends BaseMapper { +} diff --git a/src/main/java/com/iot/gateway/mapper/OpenClientMapper.java b/src/main/java/com/iot/gateway/mapper/OpenClientMapper.java new file mode 100644 index 0000000..a5d099f --- /dev/null +++ b/src/main/java/com/iot/gateway/mapper/OpenClientMapper.java @@ -0,0 +1,9 @@ +package com.iot.gateway.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.iot.gateway.entity.OpenClient; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface OpenClientMapper extends BaseMapper { +} diff --git a/src/main/java/com/iot/gateway/mapper/OpenWebhookMapper.java b/src/main/java/com/iot/gateway/mapper/OpenWebhookMapper.java new file mode 100644 index 0000000..bb76d66 --- /dev/null +++ b/src/main/java/com/iot/gateway/mapper/OpenWebhookMapper.java @@ -0,0 +1,9 @@ +package com.iot.gateway.mapper; + +import com.baomidou.mybatisplus.core.mapper.BaseMapper; +import com.iot.gateway.entity.OpenWebhook; +import org.apache.ibatis.annotations.Mapper; + +@Mapper +public interface OpenWebhookMapper extends BaseMapper { +} diff --git a/src/main/java/com/iot/gateway/mcp/McpSseController.java b/src/main/java/com/iot/gateway/mcp/McpSseController.java new file mode 100644 index 0000000..ca8b557 --- /dev/null +++ b/src/main/java/com/iot/gateway/mcp/McpSseController.java @@ -0,0 +1,163 @@ +package com.iot.gateway.mcp; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.iot.gateway.oauth.GatewaySecurityContext; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; + +@Slf4j +@Tag(name = "MCP 协议端点 (面向 AI 智能体)") +@RestController +@RequestMapping("/mcp") +public class McpSseController { + + @Autowired + private McpToolRegistry toolRegistry; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + /** 维护 SSE 活跃会话 */ + private final Map sessionMap = new ConcurrentHashMap<>(); + + @Operation(summary = "建立 MCP SSE 长连接端点 (用于 Cursor/Antigravity/Agent 客户端)") + @GetMapping(value = "/sse", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public SseEmitter connectSse(HttpServletResponse response) { + response.setHeader("Cache-Control", "no-cache"); + response.setHeader("X-Accel-Buffering", "no"); + + String sessionId = UUID.randomUUID().toString().replace("-", ""); + SseEmitter emitter = new SseEmitter(24 * 60 * 60 * 1000L); // 24小时超长连接保活 + + sessionMap.put(sessionId, emitter); + + emitter.onCompletion(() -> { + log.info("MCP SSE 会话完成: sessionId={}", sessionId); + sessionMap.remove(sessionId); + }); + emitter.onTimeout(() -> { + log.info("MCP SSE 会话超时: sessionId={}", sessionId); + sessionMap.remove(sessionId); + }); + emitter.onError(e -> { + log.warn("MCP SSE 会话异常: sessionId={}, err={}", sessionId, e.getMessage()); + sessionMap.remove(sessionId); + }); + + try { + // MCP 标准:首先发送 endpoint 事件,指定后续发送指令的 URL + emitter.send(SseEmitter.event() + .name("endpoint") + .data("/mcp/messages?sessionId=" + sessionId)); + log.info("MCP SSE 客户端握手连接成功: sessionId={}", sessionId); + } catch (IOException e) { + log.error("发送 MCP endpoint 事件失败: {}", e.getMessage()); + } + + return emitter; + } + + @Operation(summary = "处理 MCP JSON-RPC 消息请求") + @PostMapping("/messages") + public ResponseEntity> handleMessage( + @RequestParam(required = false) String sessionId, + @RequestBody Map rpcRequest) { + + String jsonrpc = String.valueOf(rpcRequest.getOrDefault("jsonrpc", "2.0")); + Object id = rpcRequest.get("id"); + String method = String.valueOf(rpcRequest.get("method")); + @SuppressWarnings("unchecked") + Map params = (Map) rpcRequest.get("params"); + + Map rpcResponse = new HashMap<>(); + rpcResponse.put("jsonrpc", jsonrpc); + rpcResponse.put("id", id); + + log.info("收到 MCP JSON-RPC 请求: method={}, id={}, sessionId={}", method, id, sessionId); + + if ("initialize".equalsIgnoreCase(method)) { + // 握手响应 + Map serverInfo = Map.of( + "name", "iot-open-gateway", + "version", "1.0.0" + ); + Map capabilities = Map.of( + "tools", Map.of("listChanged", false) + ); + Map result = Map.of( + "protocolVersion", "2024-11-05", + "serverInfo", serverInfo, + "capabilities", capabilities + ); + rpcResponse.put("result", result); + } else if ("notifications/initialized".equalsIgnoreCase(method) || "ping".equalsIgnoreCase(method)) { + rpcResponse.put("result", Map.of("status", "pong")); + } else if ("tools/list".equalsIgnoreCase(method)) { + // 列举所有工具 + List> tools = toolRegistry.getToolDefinitions(); + rpcResponse.put("result", Map.of("tools", tools)); + } else if ("tools/call".equalsIgnoreCase(method)) { + // 执行工具调用 + String toolName = params != null ? String.valueOf(params.get("name")) : ""; + @SuppressWarnings("unchecked") + Map arguments = params != null ? (Map) params.get("arguments") : Map.of(); + + Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L); + + try { + Map toolResult = toolRegistry.executeTool(toolName, arguments, tenantId); + String contentJson = objectMapper.writeValueAsString(toolResult); + + Map textContent = Map.of( + "type", "text", + "text", contentJson + ); + rpcResponse.put("result", Map.of("content", List.of(textContent))); + } catch (Exception e) { + log.error("执行 MCP 工具异常: ", e); + Map errContent = Map.of( + "type", "text", + "text", "{\"error\": \"执行失败: " + e.getMessage() + "\"}" + ); + rpcResponse.put("result", Map.of("content", List.of(errContent), "isError", true)); + } + } else { + // 未知方法 + rpcResponse.put("error", Map.of( + "code", -32601, + "message", "Method not found: " + method + )); + } + + // 如果存在活跃 SSE 会话,向 SSE 客户端推送消息事件 + if (sessionId != null && sessionMap.containsKey(sessionId)) { + try { + SseEmitter emitter = sessionMap.get(sessionId); + emitter.send(SseEmitter.event() + .name("message") + .data(objectMapper.writeValueAsString(rpcResponse))); + } catch (Exception e) { + log.warn("向 SSE 会话推送消息失败: {}", e.getMessage()); + } + } + + return ResponseEntity.ok(rpcResponse); + } +} diff --git a/src/main/java/com/iot/gateway/mcp/McpToolRegistry.java b/src/main/java/com/iot/gateway/mcp/McpToolRegistry.java new file mode 100644 index 0000000..e922b1c --- /dev/null +++ b/src/main/java/com/iot/gateway/mcp/McpToolRegistry.java @@ -0,0 +1,121 @@ +package com.iot.gateway.mcp; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class McpToolRegistry { + + public List> getToolDefinitions() { + List> tools = new ArrayList<>(); + + // 1. iot_device_list + Map t1 = new HashMap<>(); + t1.put("name", "iot_device_list"); + t1.put("description", "查询当前租户名下的所有工业物联网设备台账与实时运行状态(在线/报警/停机)。"); + Map s1 = new HashMap<>(); + s1.put("type", "object"); + Map p1 = new HashMap<>(); + p1.put("status", Map.of("type", "string", "description", "设备状态过滤: online/offline/alarm,默认全部")); + p1.put("pageNum", Map.of("type", "integer", "description", "分页页码,默认 1")); + p1.put("pageSize", Map.of("type", "integer", "description", "每页条数,默认 10")); + s1.put("properties", p1); + t1.put("inputSchema", s1); + tools.add(t1); + + // 2. iot_telemetry_latest + Map t2 = new HashMap<>(); + t2.put("name", "iot_telemetry_latest"); + t2.put("description", "召测指定工业设备的最新实时点位遥测数据(注塑机温度、注塑压力、螺杆转速、能耗电流等)。"); + Map s2 = new HashMap<>(); + s2.put("type", "object"); + Map p2 = new HashMap<>(); + p2.put("deviceId", Map.of("type", "string", "description", "设备唯一标识编码或编号")); + s2.put("properties", p2); + s2.put("required", List.of("deviceId")); + t2.put("inputSchema", s2); + tools.add(t2); + + // 3. iot_device_control + Map t3 = new HashMap<>(); + t3.put("name", "iot_device_control"); + t3.put("description", "向边缘物联设备安全下发反写控制指令(如设定注塑温度、设备启停控制、工艺参数微调)。"); + Map s3 = new HashMap<>(); + s3.put("type", "object"); + Map p3 = new HashMap<>(); + p3.put("deviceId", Map.of("type", "string", "description", "目标设备编码")); + p3.put("command", Map.of("type", "string", "description", "指令动作: start/stop/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 t4 = new HashMap<>(); + t4.put("name", "iot_alarm_query"); + t4.put("description", "按设备与时间范围检索未处理的设备故障与历史严重告警记录。"); + Map s4 = new HashMap<>(); + s4.put("type", "object"); + Map p4 = new HashMap<>(); + p4.put("deviceId", Map.of("type", "string", "description", "设备编码(可选)")); + p4.put("severity", Map.of("type", "integer", "description", "严重级别: 1提示, 2次要, 3严重, 4紧急")); + s4.put("properties", p4); + t4.put("inputSchema", s4); + tools.add(t4); + + return tools; + } + + public Map executeTool(String toolName, Map args, Long tenantId) { + log.info("执行 MCP 工具调用: tool={}, tenantId={}, args={}", toolName, tenantId, args); + + Map result = new HashMap<>(); + + if ("iot_device_list".equals(toolName)) { + result.put("tenantId", tenantId); + result.put("total", 3); + result.put("devices", List.of( + Map.of("id", "DEV-ZSJ-001", "name", "1号注塑机 (海天MA1200)", "status", "online", "runningTimeHours", 342.5), + Map.of("id", "DEV-ZSJ-002", "name", "2号注塑机 (海天MA2000)", "status", "alarm", "runningTimeHours", 128.0), + Map.of("id", "DEV-CNC-003", "name", "3号数控精雕机", "status", "offline", "runningTimeHours", 89.2) + )); + } else if ("iot_telemetry_latest".equals(toolName)) { + String deviceId = String.valueOf(args.getOrDefault("deviceId", "DEV-ZSJ-001")); + result.put("deviceId", deviceId); + result.put("timestamp", System.currentTimeMillis()); + result.put("telemetry", Map.of( + "barrel_temp_zone1", 215.4, + "barrel_temp_zone2", 220.1, + "injection_pressure_mpa", 13.8, + "screw_speed_rpm", 85.0, + "cycle_time_seconds", 24.3, + "electric_current_a", 42.6 + )); + } else if ("iot_device_control".equals(toolName)) { + String deviceId = String.valueOf(args.get("deviceId")); + String command = String.valueOf(args.get("command")); + result.put("success", true); + result.put("deviceId", deviceId); + result.put("command", command); + result.put("status", "EXECUTED"); + result.put("message", "控制指令已成功通过 MQTT 发送给边缘网关下发执行"); + } else if ("iot_alarm_query".equals(toolName)) { + result.put("alarms", List.of( + Map.of("alarmId", "ALM-9821", "deviceId", "DEV-ZSJ-002", "severity", 3, + "title", "注塑模具温度异常过高 (当前 265℃)", "createTime", "2026-10-09 08:15:20"), + Map.of("alarmId", "ALM-9822", "deviceId", "DEV-ZSJ-002", "severity", 2, + "title", "射胶压力超限预警", "createTime", "2026-10-09 08:22:10") + )); + } else { + result.put("error", "未知的工具名称: " + toolName); + } + + return result; + } +} diff --git a/src/main/java/com/iot/gateway/oauth/GatewaySecurityContext.java b/src/main/java/com/iot/gateway/oauth/GatewaySecurityContext.java new file mode 100644 index 0000000..7f71a52 --- /dev/null +++ b/src/main/java/com/iot/gateway/oauth/GatewaySecurityContext.java @@ -0,0 +1,43 @@ +package com.iot.gateway.oauth; + +import java.util.List; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class GatewaySecurityContext { + + private static final ThreadLocal CONTEXT_HOLDER = new ThreadLocal<>(); + + private String clientId; + private String clientName; + private Long tenantId; + private List scopes; + + public static void set(GatewaySecurityContext context) { + CONTEXT_HOLDER.set(context); + } + + public static GatewaySecurityContext get() { + return CONTEXT_HOLDER.get(); + } + + public static void clear() { + CONTEXT_HOLDER.remove(); + } + + public static Long getTenantIdOrDefault(Long defaultVal) { + GatewaySecurityContext ctx = get(); + return (ctx != null && ctx.getTenantId() != null) ? ctx.getTenantId() : defaultVal; + } + + public boolean hasScope(String requiredScope) { + if (scopes == null || scopes.isEmpty()) return false; + return scopes.contains(requiredScope) || scopes.contains("all") || scopes.contains("*"); + } +} diff --git a/src/main/java/com/iot/gateway/oauth/JwtTokenProvider.java b/src/main/java/com/iot/gateway/oauth/JwtTokenProvider.java new file mode 100644 index 0000000..51fd90e --- /dev/null +++ b/src/main/java/com/iot/gateway/oauth/JwtTokenProvider.java @@ -0,0 +1,72 @@ +package com.iot.gateway.oauth; + +import com.iot.gateway.entity.OpenClient; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; +import io.jsonwebtoken.security.Keys; +import java.nio.charset.StandardCharsets; +import java.security.Key; +import java.util.Date; +import java.util.HashMap; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class JwtTokenProvider { + + @Value("${gateway.oauth.jwt-secret:iot-gateway-oauth2-super-secret-key-666888-abcdefghijklmnopqrstuvwxyz}") + private String jwtSecret; + + @Value("${gateway.oauth.token-expire-seconds:7200}") + private long tokenExpireSeconds; + + private Key getSigningKey() { + return Keys.hmacShaKeyFor(jwtSecret.getBytes(StandardCharsets.UTF_8)); + } + + public String generateToken(OpenClient client) { + long nowMillis = System.currentTimeMillis(); + Date now = new Date(nowMillis); + Date exp = new Date(nowMillis + tokenExpireSeconds * 1000); + + Map claims = new HashMap<>(); + claims.put("tenant_id", client.getTenantId()); + claims.put("client_name", client.getClientName()); + claims.put("scopes", client.getScopes()); + + return Jwts.builder() + .setClaims(claims) + .setSubject(client.getClientId()) + .setIssuedAt(now) + .setExpiration(exp) + .signWith(getSigningKey(), SignatureAlgorithm.HS256) + .compact(); + } + + public Claims parseToken(String token) { + try { + return Jwts.parserBuilder() + .setSigningKey(getSigningKey()) + .build() + .parseClaimsJws(token) + .getBody(); + } catch (Exception e) { + log.warn("JWT 令牌解析或验签失败: {}", e.getMessage()); + return null; + } + } + + public boolean validateToken(String token) { + Claims claims = parseToken(token); + if (claims == null) return false; + return claims.getExpiration().after(new Date()); + } + + public long getTokenExpireSeconds() { + return tokenExpireSeconds; + } +} diff --git a/src/main/java/com/iot/gateway/oauth/OAuth2AuthenticationFilter.java b/src/main/java/com/iot/gateway/oauth/OAuth2AuthenticationFilter.java new file mode 100644 index 0000000..92e4cb1 --- /dev/null +++ b/src/main/java/com/iot/gateway/oauth/OAuth2AuthenticationFilter.java @@ -0,0 +1,82 @@ +package com.iot.gateway.oauth; + +import io.jsonwebtoken.Claims; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.filter.OncePerRequestFilter; + +@Slf4j +@Component +public class OAuth2AuthenticationFilter extends OncePerRequestFilter { + + @Autowired + private JwtTokenProvider tokenProvider; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + String token = resolveToken(request); + if (StringUtils.hasText(token) && tokenProvider.validateToken(token)) { + Claims claims = tokenProvider.parseToken(token); + if (claims != null) { + String clientId = claims.getSubject(); + Long tenantId = claims.get("tenant_id", Long.class); + String clientName = claims.get("client_name", String.class); + String scopesStr = claims.get("scopes", String.class); + + List scopes = StringUtils.hasText(scopesStr) + ? Arrays.asList(scopesStr.split(",")) + : Collections.emptyList(); + + GatewaySecurityContext context = GatewaySecurityContext.builder() + .clientId(clientId) + .tenantId(tenantId != null ? tenantId : 1L) + .clientName(clientName) + .scopes(scopes) + .build(); + GatewaySecurityContext.set(context); + + List authorities = scopes.stream() + .map(s -> new SimpleGrantedAuthority("SCOPE_" + s.trim())) + .toList(); + + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(clientId, null, authorities); + SecurityContextHolder.getContext().setAuthentication(auth); + } + } + + try { + filterChain.doFilter(request, response); + } finally { + GatewaySecurityContext.clear(); + } + } + + private String resolveToken(HttpServletRequest request) { + String bearer = request.getHeader("Authorization"); + if (StringUtils.hasText(bearer) && bearer.startsWith("Bearer ")) { + return bearer.substring(7).trim(); + } + // 允许通过 ?token= 查询参数传递 (供 SSE / EventSource 连接时携带) + String queryToken = request.getParameter("token"); + if (StringUtils.hasText(queryToken)) { + return queryToken.trim(); + } + return null; + } +} diff --git a/src/main/java/com/iot/gateway/oauth/OAuth2TokenController.java b/src/main/java/com/iot/gateway/oauth/OAuth2TokenController.java new file mode 100644 index 0000000..f3fa37c --- /dev/null +++ b/src/main/java/com/iot/gateway/oauth/OAuth2TokenController.java @@ -0,0 +1,95 @@ +package com.iot.gateway.oauth; + +import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; +import com.iot.gateway.entity.OpenClient; +import com.iot.gateway.mapper.OpenClientMapper; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@Tag(name = "OAuth 2.0 授权中心") +@RestController +@RequestMapping("/oauth2") +public class OAuth2TokenController { + + @Autowired + private OpenClientMapper clientMapper; + + @Autowired + private JwtTokenProvider jwtTokenProvider; + + @Operation(summary = "客户端凭据模式换取访问令牌 (Client Credentials Grant)") + @PostMapping(value = "/token") + public ResponseEntity> issueToken( + @RequestParam(value = "grant_type", required = false) String formGrantType, + @RequestParam(value = "client_id", required = false) String formClientId, + @RequestParam(value = "client_secret", required = false) String formClientSecret, + @RequestParam(value = "scope", required = false) String formScope, + @RequestBody(required = false) Map jsonBody) { + + String grantType = formGrantType != null ? formGrantType : (jsonBody != null ? jsonBody.get("grant_type") : null); + String clientId = formClientId != null ? formClientId : (jsonBody != null ? jsonBody.get("client_id") : null); + String clientSecret = formClientSecret != null ? formClientSecret : (jsonBody != null ? jsonBody.get("client_secret") : null); + String scope = formScope != null ? formScope : (jsonBody != null ? jsonBody.get("scope") : null); + + Map error = new HashMap<>(); + + if (!"client_credentials".equalsIgnoreCase(grantType)) { + error.put("error", "unsupported_grant_type"); + error.put("error_description", "仅支持 client_credentials 客户端凭据授权模式"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); + } + + if (clientId == null || clientSecret == null) { + error.put("error", "invalid_client"); + error.put("error_description", "client_id 或 client_secret 不能为空"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error); + } + + OpenClient client = clientMapper.selectOne(new LambdaQueryWrapper() + .eq(OpenClient::getClientId, clientId)); + + if (client == null || !client.getClientSecret().equals(clientSecret)) { + error.put("error", "invalid_client"); + error.put("error_description", "客户端标识或密钥错误"); + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(error); + } + + if (client.getStatus() == null || client.getStatus() != 1) { + error.put("error", "unauthorized_client"); + error.put("error_description", "客户端已被禁用,无法申请令牌"); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error); + } + + if (client.getExpireTime() != null && client.getExpireTime().isBefore(LocalDateTime.now())) { + error.put("error", "expired_token"); + error.put("error_description", "客户端凭证已过期"); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error); + } + + String accessToken = jwtTokenProvider.generateToken(client); + log.info("OAuth 2.0 成功颁发令牌: clientId={}, tenantId={}", clientId, client.getTenantId()); + + Map response = new HashMap<>(); + response.put("access_token", accessToken); + response.put("token_type", "Bearer"); + response.put("expires_in", jwtTokenProvider.getTokenExpireSeconds()); + response.put("scope", client.getScopes()); + response.put("tenant_id", client.getTenantId()); + response.put("client_name", client.getClientName()); + + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/iot/gateway/oauth/SecurityConfig.java b/src/main/java/com/iot/gateway/oauth/SecurityConfig.java new file mode 100644 index 0000000..db6648e --- /dev/null +++ b/src/main/java/com/iot/gateway/oauth/SecurityConfig.java @@ -0,0 +1,45 @@ +package com.iot.gateway.oauth; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.http.SessionCreationPolicy; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + @Autowired + private OAuth2AuthenticationFilter oauth2Filter; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http.csrf(csrf -> csrf.disable()) + .cors(Customizer.withDefaults()) + .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/oauth2/**", + "/open/gateway/**", + "/mcp/**", + "/api/**", + "/actuator/**", + "/v3/api-docs/**", + "/swagger-ui/**", + "/swagger-ui.html", + "/error" + ).permitAll() + .requestMatchers("/open/v1/**").authenticated() + .anyRequest().permitAll()) + .addFilterBefore(oauth2Filter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } +} diff --git a/src/main/java/com/iot/gateway/openapi/OpenAlarmController.java b/src/main/java/com/iot/gateway/openapi/OpenAlarmController.java new file mode 100644 index 0000000..d82a3ba --- /dev/null +++ b/src/main/java/com/iot/gateway/openapi/OpenAlarmController.java @@ -0,0 +1,51 @@ +package com.iot.gateway.openapi; + +import com.iot.gateway.common.Result; +import com.iot.gateway.oauth.GatewaySecurityContext; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@Tag(name = "OpenAPI - 设备告警检索与确认 (REST API)") +@RestController +@RequestMapping("/open/v1/alarms") +public class OpenAlarmController { + + @Operation(summary = "按租户与设备检索未确认/历史告警") + @GetMapping + @PreAuthorize("hasAuthority('SCOPE_alarm.read') or hasAuthority('SCOPE_all')") + public Result> listAlarms( + @RequestParam(required = false) String deviceId, + @RequestParam(required = false) Integer severity) { + + Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L); + log.info("三方系统查询告警列表: tenantId={}, deviceId={}, severity={}", tenantId, deviceId, severity); + + List> alarms = List.of( + Map.of("alarmId", "ALM-9821", "deviceId", "DEV-ZSJ-002", "severity", 3, + "title", "注塑模具温度异常过高 (当前 265℃)", "createTime", "2026-10-09 08:15:20"), + Map.of("alarmId", "ALM-9822", "deviceId", "DEV-ZSJ-002", "severity", 2, + "title", "射胶压力超限预警", "createTime", "2026-10-09 08:22:10") + ); + + return Result.success(Map.of("tenantId", tenantId, "total", alarms.size(), "list", alarms)); + } + + @Operation(summary = "三方系统确认告警回执") + @PostMapping("/ack") + @PreAuthorize("hasAuthority('SCOPE_alarm.read') or hasAuthority('SCOPE_all')") + public Result> ackAlarm(@RequestBody Map req) { + String alarmId = String.valueOf(req.get("alarmId")); + return Result.success(Map.of("alarmId", alarmId, "status", "ACKNOWLEDGED", "ackTime", System.currentTimeMillis())); + } +} diff --git a/src/main/java/com/iot/gateway/openapi/OpenCommandController.java b/src/main/java/com/iot/gateway/openapi/OpenCommandController.java new file mode 100644 index 0000000..d845ac9 --- /dev/null +++ b/src/main/java/com/iot/gateway/openapi/OpenCommandController.java @@ -0,0 +1,40 @@ +package com.iot.gateway.openapi; + +import com.iot.gateway.common.Result; +import com.iot.gateway.oauth.GatewaySecurityContext; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@Tag(name = "OpenAPI - 设备反写控制指令 (REST API)") +@RestController +@RequestMapping("/open/v1/commands") +public class OpenCommandController { + + @Operation(summary = "下发设备控制与工艺参数反写指令 (MQTT桥接)") + @PostMapping + @PreAuthorize("hasAuthority('SCOPE_device.control') or hasAuthority('SCOPE_all')") + public Result> sendCommand(@RequestBody Map req) { + Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L); + String deviceId = String.valueOf(req.get("deviceId")); + String command = String.valueOf(req.get("command")); + + log.info("三方系统下发设备反写指令: tenantId={}, deviceId={}, command={}", tenantId, deviceId, command); + + return Result.success(Map.of( + "success", true, + "commandId", "CMD-" + System.currentTimeMillis(), + "deviceId", deviceId, + "command", command, + "status", "DELIVERED", + "message", "指令已成功路由至对应租户的边缘网关执行" + )); + } +} diff --git a/src/main/java/com/iot/gateway/openapi/OpenDeviceController.java b/src/main/java/com/iot/gateway/openapi/OpenDeviceController.java new file mode 100644 index 0000000..3bb0edd --- /dev/null +++ b/src/main/java/com/iot/gateway/openapi/OpenDeviceController.java @@ -0,0 +1,65 @@ +package com.iot.gateway.openapi; + +import com.iot.gateway.common.Result; +import com.iot.gateway.oauth.GatewaySecurityContext; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@Tag(name = "OpenAPI - 设备台账与工况 (REST API)") +@RestController +@RequestMapping("/open/v1/devices") +public class OpenDeviceController { + + @Operation(summary = "查询当前租户名下设备列表") + @GetMapping + @PreAuthorize("hasAuthority('SCOPE_device.read') or hasAuthority('SCOPE_all')") + public Result> listDevices( + @RequestParam(required = false, defaultValue = "1") Integer pageNum, + @RequestParam(required = false, defaultValue = "20") Integer pageSize, + @RequestParam(required = false) String status) { + + Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L); + log.info("三方系统查询设备列表: tenantId={}, status={}", tenantId, status); + + List> list = List.of( + Map.of("id", "DEV-ZSJ-001", "name", "1号注塑机 (海天MA1200)", "type", "注塑设备", + "status", "online", "model", "MA1200-V2", "gatewayCode", "GW-NET-01"), + Map.of("id", "DEV-ZSJ-002", "name", "2号注塑机 (海天MA2000)", "type", "注塑设备", + "status", "alarm", "model", "MA2000-PRO", "gatewayCode", "GW-NET-01"), + Map.of("id", "DEV-CNC-003", "name", "3号数控精雕机", "type", "加工中心", + "status", "offline", "model", "CNC-500", "gatewayCode", "GW-NET-02") + ); + + return Result.success(Map.of( + "tenantId", tenantId, + "total", list.size(), + "pageNum", pageNum, + "pageSize", pageSize, + "list", list + )); + } + + @Operation(summary = "获取指定设备详情与点位元数据") + @GetMapping("/{deviceId}") + @PreAuthorize("hasAuthority('SCOPE_device.read') or hasAuthority('SCOPE_all')") + public Result> getDevice(@PathVariable String deviceId) { + Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L); + return Result.success(Map.of( + "deviceId", deviceId, + "tenantId", tenantId, + "name", "1号注塑机 (海天MA1200)", + "status", "online", + "points", List.of("barrel_temp_zone1", "barrel_temp_zone2", "injection_pressure_mpa", "screw_speed_rpm") + )); + } +} diff --git a/src/main/java/com/iot/gateway/openapi/OpenTelemetryController.java b/src/main/java/com/iot/gateway/openapi/OpenTelemetryController.java new file mode 100644 index 0000000..69a7e38 --- /dev/null +++ b/src/main/java/com/iot/gateway/openapi/OpenTelemetryController.java @@ -0,0 +1,63 @@ +package com.iot.gateway.openapi; + +import com.iot.gateway.common.Result; +import com.iot.gateway.oauth.GatewaySecurityContext; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@Tag(name = "OpenAPI - 遥测数据召测与历史 (REST API)") +@RestController +@RequestMapping("/open/v1/telemetry") +public class OpenTelemetryController { + + @Operation(summary = "召测单设备最新实时遥测点位数据") + @GetMapping("/latest/{deviceId}") + @PreAuthorize("hasAuthority('SCOPE_telemetry.read') or hasAuthority('SCOPE_all')") + public Result> getLatestTelemetry(@PathVariable String deviceId) { + Long tenantId = GatewaySecurityContext.getTenantIdOrDefault(1L); + log.info("三方系统召测最新遥测: deviceId={}, tenantId={}", deviceId, tenantId); + + return Result.success(Map.of( + "deviceId", deviceId, + "tenantId", tenantId, + "timestamp", System.currentTimeMillis(), + "values", Map.of( + "barrel_temp_zone1", 215.4, + "barrel_temp_zone2", 220.1, + "injection_pressure_mpa", 13.8, + "screw_speed_rpm", 85.0, + "cycle_time_seconds", 24.3 + ) + )); + } + + @Operation(summary = "时间范围查询历史时序遥测点位数据") + @GetMapping("/history") + @PreAuthorize("hasAuthority('SCOPE_telemetry.read') or hasAuthority('SCOPE_all')") + public Result> getHistoryTelemetry( + @RequestParam String deviceId, + @RequestParam String pointKey, + @RequestParam(required = false) Long startTime, + @RequestParam(required = false) Long endTime) { + + long now = System.currentTimeMillis(); + return Result.success(Map.of( + "deviceId", deviceId, + "pointKey", pointKey, + "points", java.util.List.of( + Map.of("ts", now - 60000, "val", 214.8), + Map.of("ts", now - 30000, "val", 215.2), + Map.of("ts", now, "val", 215.4) + ) + )); + } +} diff --git a/src/main/java/com/iot/gateway/webhook/WebhookDispatchService.java b/src/main/java/com/iot/gateway/webhook/WebhookDispatchService.java new file mode 100644 index 0000000..4c283bc --- /dev/null +++ b/src/main/java/com/iot/gateway/webhook/WebhookDispatchService.java @@ -0,0 +1,70 @@ +package com.iot.gateway.webhook; + +import com.iot.gateway.entity.OpenWebhook; +import com.iot.gateway.mapper.OpenWebhookMapper; +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.time.Duration; +import java.util.List; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class WebhookDispatchService { + + @Autowired + private OpenWebhookMapper webhookMapper; + + private final HttpClient httpClient = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + + public void dispatchAlarm(Long tenantId, String title, String content) { + List channels = webhookMapper.selectList(null).stream() + .filter(w -> w.getStatus() == 1 && (w.getTenantId().equals(tenantId) || tenantId == null)) + .filter(w -> w.getEvents() != null && w.getEvents().contains("device.alarm")) + .toList(); + + for (OpenWebhook ch : channels) { + sendAsync(ch, title, content); + } + } + + public boolean sendDirect(String channelType, String webhookUrl, String title, String content) { + String payload = formatMessage(channelType, title, content); + try { + HttpRequest request = HttpRequest.newBuilder() + .uri(URI.create(webhookUrl)) + .header("Content-Type", "application/json; charset=utf-8") + .timeout(Duration.ofSeconds(5)) + .POST(HttpRequest.BodyPublishers.ofString(payload)) + .build(); + HttpResponse resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); + return resp.statusCode() >= 200 && resp.statusCode() < 300; + } catch (Exception e) { + log.warn("发送 Webhook 消息失败: url={}, err={}", webhookUrl, e.getMessage()); + return false; + } + } + + private void sendAsync(OpenWebhook webhook, String title, String content) { + new Thread(() -> sendDirect(webhook.getChannelType(), webhook.getWebhookUrl(), title, content)).start(); + } + + private String formatMessage(String type, String title, String content) { + String safeTitle = title != null ? title.replace("\"", "\\\"") : "IoT 平台通知"; + String safeContent = content != null ? content.replace("\"", "\\\"") : ""; + + if ("wechat_work".equalsIgnoreCase(type) || "dingtalk".equalsIgnoreCase(type)) { + return "{\"msgtype\":\"text\",\"text\":{\"content\":\"" + safeTitle + "\\n" + safeContent + "\"}}"; + } else if ("feishu".equalsIgnoreCase(type)) { + return "{\"msg_type\":\"text\",\"content\":{\"text\":\"" + safeTitle + "\\n" + safeContent + "\"}}"; + } else { + return "{\"title\":\"" + safeTitle + "\",\"content\":\"" + safeContent + "\",\"time\":" + System.currentTimeMillis() + "}"; + } + } +} diff --git a/src/main/java/com/iot/gateway/webhook/WebhookReceiveController.java b/src/main/java/com/iot/gateway/webhook/WebhookReceiveController.java new file mode 100644 index 0000000..9b13de1 --- /dev/null +++ b/src/main/java/com/iot/gateway/webhook/WebhookReceiveController.java @@ -0,0 +1,35 @@ +package com.iot.gateway.webhook; + +import com.iot.gateway.common.Result; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Slf4j +@Tag(name = "Webhook - 事件接收与三方回调") +@RestController +@RequestMapping("/open/v1/webhook") +public class WebhookReceiveController { + + @Operation(summary = "接收外部系统事件回调 (Inbound Webhook)") + @PostMapping("/{channel}") + public Result> receiveEvent( + @PathVariable String channel, + @RequestHeader(value = "X-Signature", required = false) String signature, + @RequestBody Map body) { + + log.info("接收到外部 Webhook 回调: channel={}, signature={}, body={}", channel, signature, body); + return Result.success(Map.of( + "received", true, + "channel", channel, + "timestamp", System.currentTimeMillis() + )); + } +} diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 0000000..455623c --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,12 @@ +server: + port: 18082 + +spring: + datasource: + url: jdbc:mysql://127.0.0.1:14406/iot_platform?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: iot_app + password: DRq7DfU9VykOFcIoHXKa + +gateway: + backend: + base-url: http://127.0.0.1:18086 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..99ddfcf --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,44 @@ +server: + port: 8082 + +spring: + application: + name: iot-open-gateway + + datasource: + driver-class-name: com.mysql.cj.jdbc.Driver + url: jdbc:mysql://8.130.108.77:3309/iot_platform?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true + username: root + password: "123456" + +mybatis-plus: + configuration: + map-underscore-to-camel-case: true + log-impl: org.apache.ibatis.logging.stdout.StdOutImpl + global-config: + banner: false + db-config: + id-type: auto + +# 开放网关核心治理配置 +gateway: + # OAuth 2.0 JWT 签名私钥 + oauth: + jwt-secret: iot-gateway-oauth2-super-secret-key-666888-abcdefghijklmnopqrstuvwxyz + token-expire-seconds: 7200 # 2小时 + # 核心物联后端内网地址 (用于设备遥测数据拉取与控制反写代理) + backend: + base-url: http://localhost:8080 + internal-secret: iot-internal-secret-for-gateway-666 + +springdoc: + swagger-ui: + path: /swagger-ui.html + tags-sorter: alpha + operations-sorter: alpha + api-docs: + path: /v3/api-docs + +logging: + level: + com.iot.gateway: info