xucaiqin 2 лет назад
Родитель
Сommit
9f9d9eaded
19 измененных файлов с 959 добавлено и 5 удалено
  1. 36 0
      iot-framework/iot-common-core/pom.xml
  2. 13 0
      iot-framework/iot-common-core/src/main/java/com/middle/platform/core/filter/LoginFilter.java
  3. 1 0
      iot-framework/iot-common-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports
  4. 1 0
      iot-framework/pom.xml
  5. 39 0
      iot-gateway/pom.xml
  6. 17 0
      iot-gateway/src/main/java/com/middle/platform/gateway/GatewayApplication.java
  7. 9 0
      iot-gateway/src/main/java/com/middle/platform/gateway/constant/RequestConstant.java
  8. 86 0
      iot-gateway/src/main/java/com/middle/platform/gateway/filter/log/AccessLog.java
  9. 244 0
      iot-gateway/src/main/java/com/middle/platform/gateway/filter/log/AccessLogFilter.java
  10. 37 0
      iot-gateway/src/main/java/com/middle/platform/gateway/filter/security/AuthTokenFilter.java
  11. 23 0
      iot-gateway/src/main/java/com/middle/platform/gateway/filter/security/LoginUser.java
  12. 67 0
      iot-gateway/src/main/java/com/middle/platform/gateway/handler/GlobalExceptionHandler.java
  13. 112 0
      iot-gateway/src/main/java/com/middle/platform/gateway/utils/WebFrameworkUtils.java
  14. 31 0
      iot-gateway/src/main/resources/application.yaml
  15. 18 0
      iot-gateway/src/main/resources/bootstrap-local.yaml
  16. 22 0
      iot-gateway/src/main/resources/bootstrap.yaml
  17. 186 0
      iot-gateway/src/main/resources/logback-spring.xml
  18. 17 1
      iot-module/iot-module-system/iot-module-system-biz/src/main/java/com/middle/platfrom/system/biz/controller/IndexController.java
  19. 0 4
      iot-module/pom.xml

+ 36 - 0
iot-framework/iot-common-core/pom.xml

@@ -0,0 +1,36 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0"
+         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+    <modelVersion>4.0.0</modelVersion>
+    <parent>
+        <groupId>com.middle.platform</groupId>
+        <artifactId>iot-framework</artifactId>
+        <version>${revision}</version>
+    </parent>
+
+    <artifactId>iot-common-core</artifactId>
+    <packaging>jar</packaging>
+    <properties>
+        <maven.compiler.source>17</maven.compiler.source>
+        <maven.compiler.target>17</maven.compiler.target>
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+    </properties>
+
+    <dependencies>
+        <dependency>
+            <groupId>org.slf4j</groupId>
+            <artifactId>slf4j-api</artifactId>
+            <scope>provided</scope> <!-- 设置为 provided,只有工具类需要使用到 -->
+        </dependency>
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>io.undertow</groupId>
+            <artifactId>undertow-servlet</artifactId>
+            <scope>compile</scope>
+        </dependency>
+    </dependencies>
+</project>

+ 13 - 0
iot-framework/iot-common-core/src/main/java/com/middle/platform/core/filter/LoginFilter.java

@@ -0,0 +1,13 @@
+package com.middle.platform.core.filter;
+
+import jakarta.servlet.*;
+import lombok.extern.slf4j.Slf4j;
+
+@Slf4j
+public class LoginFilter implements Filter {
+
+    @Override
+    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) {
+        log.info("登录过滤器");
+    }
+}

+ 1 - 0
iot-framework/iot-common-core/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports

@@ -0,0 +1 @@
+com.middle.platform.core.filter.LoginFilter

+ 1 - 0
iot-framework/pom.xml

@@ -15,6 +15,7 @@
 
     <modules>
         <module>iot-common</module>
+        <module>iot-common-core</module>
         <module>iot-starter-es</module>
         <module>iot-starter-feign</module>
         <module>iot-starter-web</module>

+ 39 - 0
iot-gateway/pom.xml

@@ -16,5 +16,44 @@
         <maven.compiler.target>17</maven.compiler.target>
         <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
     </properties>
+    <dependencies>
+        <!-- Spring Cloud 基础 -->
+        <dependency>
+            <groupId>org.springframework.cloud</groupId>
+            <artifactId>spring-cloud-starter-bootstrap</artifactId>
+        </dependency>
+        <!-- Registry 注册中心相关 -->
+        <dependency>
+            <groupId>com.alibaba.cloud</groupId>
+            <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
+        </dependency>
 
+        <!-- Config 配置中心相关 -->
+        <dependency>
+            <groupId>com.alibaba.cloud</groupId>
+            <artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
+        </dependency>
+
+        <!-- Gateway 网关相关 -->
+        <dependency>
+            <groupId>org.springframework.cloud</groupId>
+            <artifactId>spring-cloud-starter-gateway</artifactId>
+        </dependency>
+
+        <!-- RPC 远程调用相关 -->
+        <dependency>
+            <groupId>org.springframework.cloud</groupId>
+            <artifactId>spring-cloud-starter-loadbalancer</artifactId>
+        </dependency>
+
+        <!-- 工具类相关 -->
+        <dependency>
+            <groupId>com.google.guava</groupId>
+            <artifactId>guava</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>com.middle.platform</groupId>
+            <artifactId>iot-common</artifactId>
+        </dependency>
+    </dependencies>
 </project>

+ 17 - 0
iot-gateway/src/main/java/com/middle/platform/gateway/GatewayApplication.java

@@ -0,0 +1,17 @@
+package com.middle.platform.gateway;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+/**
+ * @author xucaiqin
+ * @date 2023-12-17 10:50:09
+ */
+@SpringBootApplication
+public class GatewayApplication {
+
+    public static void main(String[] args) {
+        // 启动 Spring Boot 应用
+        SpringApplication.run(GatewayApplication.class, args);
+    }
+}

+ 9 - 0
iot-gateway/src/main/java/com/middle/platform/gateway/constant/RequestConstant.java

@@ -0,0 +1,9 @@
+package com.middle.platform.gateway.constant;
+
+
+public class RequestConstant {
+
+    /**请求头token*/
+    public static final String TOKEN = "Access-Token";
+
+}

+ 86 - 0
iot-gateway/src/main/java/com/middle/platform/gateway/filter/log/AccessLog.java

@@ -0,0 +1,86 @@
+package com.middle.platform.gateway.filter.log;
+
+import lombok.Data;
+import org.springframework.cloud.gateway.route.Route;
+import org.springframework.http.HttpStatus;
+import org.springframework.util.MultiValueMap;
+
+import java.time.LocalDateTime;
+
+/**
+ * 网关的访问日志
+ */
+@Data
+public class AccessLog {
+
+    /**
+     * 链路追踪编号
+     */
+    private String traceId;
+    /**
+     * 用户编号
+     */
+    private Long userId;
+    /**
+     * 路由
+     */
+    private Route route;
+
+    /**
+     * 协议
+     */
+    private String schema;
+    /**
+     * 请求方法名
+     */
+    private String requestMethod;
+    /**
+     * 访问地址
+     */
+    private String requestUrl;
+    /**
+     * 查询参数
+     */
+    private MultiValueMap<String, String> queryParams;
+    /**
+     * 请求体
+     */
+    private String requestBody;
+    /**
+     * 请求头
+     */
+    private MultiValueMap<String, String> requestHeaders;
+    /**
+     * 用户 IP
+     */
+    private String userIp;
+
+    /**
+     * 响应体
+     *
+     * 类似 ApiAccessLogCreateReqDTO 的 resultCode + resultMsg
+     */
+    private String responseBody;
+    /**
+     * 响应头
+     */
+    private MultiValueMap<String, String> responseHeaders;
+    /**
+     * 响应结果
+     */
+    private HttpStatus httpStatus;
+
+    /**
+     * 开始请求时间
+     */
+    private LocalDateTime startTime;
+    /**
+     * 结束请求时间
+     */
+    private LocalDateTime endTime;
+    /**
+     * 执行时长,单位:毫秒
+     */
+    private Integer duration;
+
+}

+ 244 - 0
iot-gateway/src/main/java/com/middle/platform/gateway/filter/log/AccessLogFilter.java

@@ -0,0 +1,244 @@
+package com.middle.platform.gateway.filter.log;
+
+import cn.hutool.core.date.LocalDateTimeUtil;
+import cn.hutool.core.map.MapUtil;
+import cn.hutool.json.JSONUtil;
+import com.alibaba.nacos.common.utils.StringUtils;
+import com.middle.platform.common.utils.JsonUtils;
+import com.middle.platform.gateway.utils.WebFrameworkUtils;
+import jakarta.annotation.Resource;
+import lombok.extern.slf4j.Slf4j;
+import org.reactivestreams.Publisher;
+import org.springframework.cloud.gateway.filter.GatewayFilterChain;
+import org.springframework.cloud.gateway.filter.GlobalFilter;
+import org.springframework.cloud.gateway.filter.factory.rewrite.CachedBodyOutputMessage;
+import org.springframework.cloud.gateway.filter.factory.rewrite.ModifyRequestBodyGatewayFilterFactory;
+import org.springframework.cloud.gateway.support.BodyInserterContext;
+import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
+import org.springframework.core.Ordered;
+import org.springframework.core.io.buffer.DataBuffer;
+import org.springframework.core.io.buffer.DataBufferFactory;
+import org.springframework.core.io.buffer.DataBufferUtils;
+import org.springframework.core.io.buffer.DefaultDataBufferFactory;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+import org.springframework.http.ReactiveHttpOutputMessage;
+import org.springframework.http.codec.CodecConfigurer;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.http.server.reactive.ServerHttpRequestDecorator;
+import org.springframework.http.server.reactive.ServerHttpResponse;
+import org.springframework.http.server.reactive.ServerHttpResponseDecorator;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.BodyInserter;
+import org.springframework.web.reactive.function.BodyInserters;
+import org.springframework.web.reactive.function.server.ServerRequest;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Flux;
+import reactor.core.publisher.Mono;
+
+import java.nio.charset.StandardCharsets;
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+
+import static cn.hutool.core.date.DatePattern.NORM_DATETIME_MS_FORMATTER;
+
+/**
+ * 网关的访问日志过滤器
+ *
+ */
+@Slf4j
+@Component
+public class AccessLogFilter implements GlobalFilter, Ordered {
+
+    @Resource
+    private CodecConfigurer codecConfigurer;
+
+    /**
+     * 打印日志
+     *
+     * @param gatewayLog 网关日志
+     */
+    private void writeAccessLog(AccessLog gatewayLog) {
+        // 打印到控制台,方便排查错误
+        Map<String, Object> values = MapUtil.newHashMap(15, true); // 手工拼接,保证排序;15 保证不用扩容
+        values.put("userId", gatewayLog.getUserId());
+        values.put("routeId", gatewayLog.getRoute() != null ? gatewayLog.getRoute().getId() : null);
+        values.put("schema", gatewayLog.getSchema());
+        values.put("requestUrl", gatewayLog.getRequestUrl());
+        values.put("queryParams", gatewayLog.getQueryParams().toSingleValueMap());
+        values.put("requestBody", JsonUtils.isJson(gatewayLog.getRequestBody()) ? // 保证 body 的展示好看
+                JSONUtil.parse(gatewayLog.getRequestBody()) : gatewayLog.getRequestBody());
+        values.put("requestHeaders", JsonUtils.toJsonString(gatewayLog.getRequestHeaders().toSingleValueMap()));
+        values.put("userIp", gatewayLog.getUserIp());
+        values.put("responseBody", JsonUtils.isJson(gatewayLog.getResponseBody()) ? // 保证 body 的展示好看
+                JSONUtil.parse(gatewayLog.getResponseBody()) : gatewayLog.getResponseBody());
+        values.put("responseHeaders", gatewayLog.getResponseHeaders() != null ?
+                JsonUtils.toJsonString(gatewayLog.getResponseHeaders().toSingleValueMap()) : null);
+        values.put("httpStatus", gatewayLog.getHttpStatus());
+        values.put("startTime", LocalDateTimeUtil.format(gatewayLog.getStartTime(), NORM_DATETIME_MS_FORMATTER));
+        values.put("endTime", LocalDateTimeUtil.format(gatewayLog.getEndTime(), NORM_DATETIME_MS_FORMATTER));
+        values.put("duration", gatewayLog.getDuration() != null ? gatewayLog.getDuration() + " ms" : null);
+        log.info("[网关日志:{}]", JsonUtils.toJsonPrettyString(values));
+    }
+
+    @Override
+    public int getOrder() {
+        return Ordered.HIGHEST_PRECEDENCE;
+    }
+
+    @Override
+    public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
+        // 将 Request 中可以直接获取到的参数,设置到网关日志
+        ServerHttpRequest request = exchange.getRequest();
+        AccessLog gatewayLog = new AccessLog();
+        gatewayLog.setRoute(WebFrameworkUtils.getGatewayRoute(exchange));
+        gatewayLog.setSchema(request.getURI().getScheme());
+        gatewayLog.setRequestMethod(request.getMethod().name());
+        gatewayLog.setRequestUrl(request.getURI().getRawPath());
+        gatewayLog.setQueryParams(request.getQueryParams());
+        gatewayLog.setRequestHeaders(request.getHeaders());
+        gatewayLog.setStartTime(LocalDateTime.now());
+        gatewayLog.setUserIp(WebFrameworkUtils.getClientIP(exchange));
+
+        // 继续 filter 过滤
+        MediaType mediaType = request.getHeaders().getContentType();
+        if (MediaType.APPLICATION_FORM_URLENCODED.isCompatibleWith(mediaType)
+                || MediaType.APPLICATION_JSON.isCompatibleWith(mediaType)) { // 适合 JSON 和 Form 提交的请求
+            return filterWithRequestBody(exchange, chain, gatewayLog);
+        }
+        return filterWithoutRequestBody(exchange, chain, gatewayLog);
+    }
+
+    private Mono<Void> filterWithoutRequestBody(ServerWebExchange exchange, GatewayFilterChain chain, AccessLog accessLog) {
+        // 包装 Response,用于记录 Response Body
+        ServerHttpResponseDecorator decoratedResponse = recordResponseLog(exchange, accessLog);
+        return chain.filter(exchange.mutate().response(decoratedResponse).build())
+                .then(Mono.fromRunnable(() -> writeAccessLog(accessLog))); // 打印日志
+    }
+
+    /**
+     * 参考 {@link ModifyRequestBodyGatewayFilterFactory} 实现
+     * 差别主要在于使用 modifiedBody 来读取 Request Body 数据
+     */
+    private Mono<Void> filterWithRequestBody(ServerWebExchange exchange, GatewayFilterChain chain, AccessLog gatewayLog) {
+        // 设置 Request Body 读取时,设置到网关日志
+        // 此处 codecConfigurer.getReaders() 的目的,是解决 spring.codec.max-in-memory-size 不生效
+        ServerRequest serverRequest = ServerRequest.create(exchange, codecConfigurer.getReaders());
+        Mono<String> modifiedBody = serverRequest.bodyToMono(String.class).flatMap(body -> {
+            gatewayLog.setRequestBody(body);
+            return Mono.just(body);
+        });
+
+        // 创建 BodyInserter 对象
+        BodyInserter<Mono<String>, ReactiveHttpOutputMessage> bodyInserter = BodyInserters.fromPublisher(modifiedBody, String.class);
+        // 创建 CachedBodyOutputMessage 对象
+        HttpHeaders headers = new HttpHeaders();
+        headers.putAll(exchange.getRequest().getHeaders());
+        // the new content type will be computed by bodyInserter
+        // and then set in the request decorator
+        headers.remove(HttpHeaders.CONTENT_LENGTH); // 移除
+        CachedBodyOutputMessage outputMessage = new CachedBodyOutputMessage(exchange, headers);
+        // 通过 BodyInserter 将 Request Body 写入到 CachedBodyOutputMessage 中
+        return bodyInserter.insert(outputMessage, new BodyInserterContext()).then(Mono.defer(() -> {
+            // 包装 Request,用于缓存 Request Body
+            ServerHttpRequest decoratedRequest = requestDecorate(exchange, headers, outputMessage);
+            // 包装 Response,用于记录 Response Body
+            ServerHttpResponseDecorator decoratedResponse = recordResponseLog(exchange, gatewayLog);
+            // 记录普通的
+            return chain.filter(exchange.mutate().request(decoratedRequest).response(decoratedResponse).build())
+                    .then(Mono.fromRunnable(() -> writeAccessLog(gatewayLog))); // 打印日志
+
+        }));
+    }
+
+    /**
+     * 记录响应日志
+     */
+    private ServerHttpResponseDecorator recordResponseLog(ServerWebExchange exchange, AccessLog gatewayLog) {
+        ServerHttpResponse response = exchange.getResponse();
+        return new ServerHttpResponseDecorator(response) {
+
+            @Override
+            public Mono<Void> writeWith(Publisher<? extends DataBuffer> body) {
+                if (body instanceof Flux) {
+                    DataBufferFactory bufferFactory = response.bufferFactory();
+                    // 计算执行时间
+                    gatewayLog.setEndTime(LocalDateTime.now());
+                    gatewayLog.setDuration((int) (LocalDateTimeUtil.between(gatewayLog.getStartTime(),
+                            gatewayLog.getEndTime()).toMillis()));
+                    // 设置其它字段
+//                    gatewayLog.setUserId(SecurityFrameworkUtils.getLoginUserId(exchange));
+                    gatewayLog.setResponseHeaders(response.getHeaders());
+                    gatewayLog.setHttpStatus((HttpStatus) response.getStatusCode());
+
+                    // 获取响应类型,如果是 json 就打印
+                    String originalResponseContentType = exchange.getAttribute(ServerWebExchangeUtils.ORIGINAL_RESPONSE_CONTENT_TYPE_ATTR);
+                    if (StringUtils.isNotBlank(originalResponseContentType)
+                            && originalResponseContentType.contains("application/json")) {
+                        Flux<? extends DataBuffer> fluxBody = Flux.from(body);
+                        return super.writeWith(fluxBody.buffer().map(dataBuffers -> {
+                            // 设置 response body 到网关日志
+                            byte[] content = readContent(dataBuffers);
+                            String responseResult = new String(content, StandardCharsets.UTF_8);
+                            gatewayLog.setResponseBody(responseResult);
+
+                            // 响应
+                            return bufferFactory.wrap(content);
+                        }));
+                    }
+                }
+                // if body is not a flux. never got there.
+                return super.writeWith(body);
+            }
+        };
+    }
+
+    // ========== 参考 ModifyRequestBodyGatewayFilterFactory 中的方法 ==========
+
+    /**
+     * 请求装饰器,支持重新计算 headers、body 缓存
+     *
+     * @param exchange      请求
+     * @param headers       请求头
+     * @param outputMessage body 缓存
+     * @return 请求装饰器
+     */
+    private ServerHttpRequestDecorator requestDecorate(ServerWebExchange exchange, HttpHeaders headers, CachedBodyOutputMessage outputMessage) {
+        return new ServerHttpRequestDecorator(exchange.getRequest()) {
+
+            @Override
+            public HttpHeaders getHeaders() {
+                long contentLength = headers.getContentLength();
+                HttpHeaders httpHeaders = new HttpHeaders();
+                httpHeaders.putAll(super.getHeaders());
+                if (contentLength > 0) {
+                    httpHeaders.setContentLength(contentLength);
+                } else {
+                    httpHeaders.set(HttpHeaders.TRANSFER_ENCODING, "chunked");
+                }
+                return httpHeaders;
+            }
+
+            @Override
+            public Flux<DataBuffer> getBody() {
+                return outputMessage.getBody();
+            }
+        };
+    }
+
+    // ========== 参考 ModifyResponseBodyGatewayFilterFactory 中的方法 ==========
+
+    private byte[] readContent(List<? extends DataBuffer> dataBuffers) {
+        // 合并多个流集合,解决返回体分段传输
+        DataBufferFactory dataBufferFactory = new DefaultDataBufferFactory();
+        DataBuffer join = dataBufferFactory.join(dataBuffers);
+        byte[] content = new byte[join.readableByteCount()];
+        join.read(content);
+        // 释放掉内存
+        DataBufferUtils.release(join);
+        return content;
+    }
+
+}

+ 37 - 0
iot-gateway/src/main/java/com/middle/platform/gateway/filter/security/AuthTokenFilter.java

@@ -0,0 +1,37 @@
+package com.middle.platform.gateway.filter.security;
+
+import com.middle.platform.gateway.constant.RequestConstant;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.cloud.gateway.filter.GatewayFilterChain;
+import org.springframework.cloud.gateway.filter.GlobalFilter;
+import org.springframework.core.Ordered;
+import org.springframework.stereotype.Component;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+
+/**
+ * Token 过滤器,验证 token 的有效性
+ * 1. 验证通过时,将 userId、userType、tenantId 通过 Header 转发给服务
+ * 2. 验证不通过,还是会转发给服务。因为,接口是否需要登录的校验,还是交给服务自身处理
+ *
+ * @author 芋道源码
+ */
+@Component
+@Slf4j
+public class AuthTokenFilter implements GlobalFilter, Ordered {
+
+
+    @Override
+    public Mono<Void> filter(final ServerWebExchange exchange, GatewayFilterChain chain) {
+        String token = exchange.getRequest().getHeaders().getFirst(RequestConstant.TOKEN);
+        log.info("token:{}", token);
+        return chain.filter(exchange);
+    }
+
+
+    @Override
+    public int getOrder() {
+        return -100;
+    }
+
+}

+ 23 - 0
iot-gateway/src/main/java/com/middle/platform/gateway/filter/security/LoginUser.java

@@ -0,0 +1,23 @@
+package com.middle.platform.gateway.filter.security;
+
+import lombok.Data;
+
+import java.util.List;
+
+/**
+ * 登录用户信息
+ */
+@Data
+public class LoginUser {
+
+    /**
+     * 用户编号
+     */
+    private Long id;
+
+    /**
+     * 授权范围
+     */
+    private List<String> scopes;
+
+}

+ 67 - 0
iot-gateway/src/main/java/com/middle/platform/gateway/handler/GlobalExceptionHandler.java

@@ -0,0 +1,67 @@
+package com.middle.platform.gateway.handler;
+
+import com.middle.platform.common.utils.Result;
+import com.middle.platform.gateway.utils.WebFrameworkUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler;
+import org.springframework.core.annotation.Order;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.http.server.reactive.ServerHttpResponse;
+import org.springframework.stereotype.Component;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.server.ResponseStatusException;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+
+/**
+ * Gateway 的全局异常处理器
+ */
+@Component
+@Order(-1) // 保证优先级高于默认的 Spring Cloud Gateway 的 ErrorWebExceptionHandler 实现
+@Slf4j
+public class GlobalExceptionHandler implements ErrorWebExceptionHandler {
+
+    @Override
+    public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
+        // 已经 commit,则直接返回异常
+        ServerHttpResponse response = exchange.getResponse();
+        if (response.isCommitted()) {
+            return Mono.error(ex);
+        }
+
+        // 转换成 CommonResult
+        Result<?> result;
+        if (ex instanceof ResponseStatusException) {
+            result = responseStatusExceptionHandler(exchange, (ResponseStatusException) ex);
+        } else {
+            result = defaultExceptionHandler(exchange, ex);
+        }
+
+        // 返回给前端
+        return WebFrameworkUtils.writeJSON(exchange, result);
+    }
+
+    /**
+     * 处理 Spring Cloud Gateway 默认抛出的 ResponseStatusException 异常
+     */
+    private Result<?> responseStatusExceptionHandler(ServerWebExchange exchange,
+                                                     ResponseStatusException ex) {
+        // TODO 芋艿:这里要精细化翻译,默认返回用户是看不懂的
+        ServerHttpRequest request = exchange.getRequest();
+        log.error("[responseStatusExceptionHandler][uri({}/{}) 发生异常]", request.getURI(), request.getMethod(), ex);
+        // TODO
+        return Result.failed(ex.getStatusCode().hashCode(), ex.getReason());
+    }
+
+    /**
+     * 处理系统异常,兜底处理所有的一切
+     */
+    @ExceptionHandler(value = Exception.class)
+    public Result<?> defaultExceptionHandler(ServerWebExchange exchange,
+                                             Throwable ex) {
+        ServerHttpRequest request = exchange.getRequest();
+        log.error("[defaultExceptionHandler][uri({}/{}) 发生异常]", request.getURI(), request.getMethod(), ex);
+        return Result.failed("服务异常");
+    }
+
+}

+ 112 - 0
iot-gateway/src/main/java/com/middle/platform/gateway/utils/WebFrameworkUtils.java

@@ -0,0 +1,112 @@
+package com.middle.platform.gateway.utils;
+
+import cn.hutool.core.net.NetUtil;
+import cn.hutool.core.util.ArrayUtil;
+import cn.hutool.core.util.NumberUtil;
+import cn.hutool.extra.servlet.JakartaServletUtil;
+import com.middle.platform.common.utils.JsonUtils;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.cloud.gateway.route.Route;
+import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
+import org.springframework.core.io.buffer.DataBufferFactory;
+import org.springframework.http.HttpHeaders;
+import org.springframework.http.MediaType;
+import org.springframework.http.server.reactive.ServerHttpRequest;
+import org.springframework.http.server.reactive.ServerHttpResponse;
+import org.springframework.web.server.ServerWebExchange;
+import reactor.core.publisher.Mono;
+
+/**
+ * Web 工具类
+ */
+@Slf4j
+public class WebFrameworkUtils {
+
+    private static final String HEADER_TENANT_ID = "tenant-id";
+
+    private WebFrameworkUtils() {}
+
+    /**
+     * 将 Gateway 请求中的 header,设置到 HttpHeaders 中
+     *
+     * @param tenantId 租户编号
+     * @param httpHeaders WebClient 的请求
+     */
+    public static void setTenantIdHeader(Long tenantId, HttpHeaders httpHeaders) {
+        if (tenantId == null) {
+            return;
+        }
+        httpHeaders.set(HEADER_TENANT_ID, String.valueOf(tenantId));
+    }
+
+    public static Long getTenantId(ServerWebExchange exchange) {
+        String tenantId = exchange.getRequest().getHeaders().getFirst(HEADER_TENANT_ID);
+        return NumberUtil.isNumber(tenantId) ? Long.valueOf(tenantId) : null;
+    }
+
+    /**
+     * 返回 JSON 字符串
+     *
+     * @param exchange 响应
+     * @param object 对象,会序列化成 JSON 字符串
+     */
+    @SuppressWarnings("deprecation") // 必须使用 APPLICATION_JSON_UTF8_VALUE,否则会乱码
+    public static Mono<Void> writeJSON(ServerWebExchange exchange, Object object) {
+        // 设置 header
+        ServerHttpResponse response = exchange.getResponse();
+        response.getHeaders().setContentType(MediaType.APPLICATION_JSON_UTF8);
+        // 设置 body
+        return response.writeWith(Mono.fromSupplier(() -> {
+            DataBufferFactory bufferFactory = response.bufferFactory();
+            try {
+                return bufferFactory.wrap(JsonUtils.toJsonByte(object));
+            } catch (Exception ex) {
+                ServerHttpRequest request = exchange.getRequest();
+                log.error("[writeJSON][uri({}/{}) 发生异常]", request.getURI(), request.getMethod(), ex);
+                return bufferFactory.wrap(new byte[0]);
+            }
+        }));
+    }
+
+    /**
+     * 获得客户端 IP
+     *
+     * 参考 {@link JakartaServletUtil} 的 getClientIP 方法
+     *
+     * @param exchange 请求
+     * @param otherHeaderNames 其它 header 名字的数组
+     * @return 客户端 IP
+     */
+    public static String getClientIP(ServerWebExchange exchange, String... otherHeaderNames) {
+        String[] headers = { "X-Forwarded-For", "X-Real-IP", "Proxy-Client-IP", "WL-Proxy-Client-IP", "HTTP_CLIENT_IP", "HTTP_X_FORWARDED_FOR" };
+        if (ArrayUtil.isNotEmpty(otherHeaderNames)) {
+            headers = ArrayUtil.addAll(headers, otherHeaderNames);
+        }
+        // 方式一,通过 header 获取
+        String ip;
+        for (String header : headers) {
+            ip = exchange.getRequest().getHeaders().getFirst(header);
+            if (!NetUtil.isUnknown(ip)) {
+                return NetUtil.getMultistageReverseProxyIp(ip);
+            }
+        }
+
+        // 方式二,通过 remoteAddress 获取
+        if (exchange.getRequest().getRemoteAddress() == null) {
+            return null;
+        }
+        ip = exchange.getRequest().getRemoteAddress().getHostString();
+        return NetUtil.getMultistageReverseProxyIp(ip);
+    }
+
+    /**
+     * 获得请求匹配的 Route 路由
+     *
+     * @param exchange 请求
+     * @return 路由
+     */
+    public static Route getGatewayRoute(ServerWebExchange exchange) {
+        return exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_ROUTE_ATTR);
+    }
+
+}

+ 31 - 0
iot-gateway/src/main/resources/application.yaml

@@ -0,0 +1,31 @@
+#本地配置文件,仅供参考
+spring:
+  cloud:
+    # Spring Cloud Gateway 配置项,对应 GatewayProperties 类
+    gateway:
+      # 路由配置项,对应 RouteDefinition 数组
+      routes:
+        - id: iot-system
+          uri: lb://iot-system
+          predicates:
+            - Path=/iot-system/**
+          filters:
+            - StripPrefix=1
+        - id: iot-data
+          uri: lb://iot-data
+          predicates:
+            - Path=/iot-data/**
+          filters:
+            - StripPrefix=1
+        - id: iot-manage
+          uri: lb://iot-manage
+          predicates:
+            - Path=/iot-manage/**
+          filters:
+            - StripPrefix=1
+        - id: iot-auth
+          uri: lb://iot-auth
+          predicates:
+            - Path=/iot-auth/**
+          filters:
+            - StripPrefix=1

+ 18 - 0
iot-gateway/src/main/resources/bootstrap-local.yaml

@@ -0,0 +1,18 @@
+spring:
+  cloud:
+    nacos:
+      server-addr: 127.0.0.1:8848
+      discovery:
+        namespace: 78524032-ad7a-4724-8f8b-eef5ac08c5b5
+      config:
+        server-addr: 127.0.0.1:8848 # Nacos 服务器地址
+        namespace: 78524032-ad7a-4724-8f8b-eef5ac08c5b5
+        group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP
+        name: ${spring.application.name} # 使用的 Nacos 配置集的 dataId,默认为 spring.application.name
+        file-extension: yaml # 使用的 Nacos 配置集的 dataId 的文件拓展名,同时也是 Nacos 配置集的配置格式,默认为 properties
+        username: nacos
+        password: nacos
+        shared-configs:
+          - data-id: common.yaml
+            group: common
+            refresh: true

+ 22 - 0
iot-gateway/src/main/resources/bootstrap.yaml

@@ -0,0 +1,22 @@
+server:
+  port: 9000
+spring:
+  application:
+    name: iot-gateway
+  profiles:
+    active: local
+  # Servlet 配置
+  servlet:
+    # 文件上传相关配置项
+    multipart:
+      max-file-size: 16MB # 单个文件大小
+      max-request-size: 32MB # 设置总上传的文件大小
+  # Jackson 配置项
+  jackson:
+    serialization:
+      write-dates-as-timestamps: true # 设置 LocalDateTime 的格式,使用时间戳
+      write-date-timestamps-as-nanoseconds: false # 设置不使用 nanoseconds 的格式。例如说 1611460870.401,而是直接 1611460870401
+      write-durations-as-timestamps: true # 设置 Duration 的格式,使用时间戳
+      fail-on-empty-beans: false # 允许序列化无属性的 Bean
+
+

+ 186 - 0
iot-gateway/src/main/resources/logback-spring.xml

@@ -0,0 +1,186 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!-- 日志级别从低到高分为TRACE < DEBUG < INFO < WARN < ERROR < FATAL,如果设置为WARN,则低于WARN的信息都不会输出 -->
+<!-- scan:当此属性设置为true时,配置文档如果发生改变,将会被重新加载,默认值为true -->
+<!-- scanPeriod:设置监测配置文档是否有修改的时间间隔,如果没有给出时间单位,默认单位是毫秒。
+                         当scan为true时,此属性生效。默认的时间间隔为1分钟。 -->
+<configuration scan="true" scanPeriod="10 seconds">
+    <!--关闭logback自身的debug日志打印-->
+    <statusListener class="ch.qos.logback.core.status.NopStatusListener"/>
+    <contextName>logback</contextName>
+
+    <!-- name的值是变量的名称,value的值时变量定义的值。通过定义的值会被插入到logger上下文中。定义后,可以使“${}”来使用变量。 -->
+    <property name="log.path" value="logs"/>
+    <springProperty scope="context" name="LOG_HOME" source="spring.application.name"/>
+    <!--0. 日志格式和颜色渲染 -->
+    <!-- 彩色日志依赖的渲染类 -->
+    <conversionRule conversionWord="clr" converterClass="org.springframework.boot.logging.logback.ColorConverter"/>
+    <conversionRule conversionWord="wex"
+                    converterClass="org.springframework.boot.logging.logback.WhitespaceThrowableProxyConverter"/>
+    <conversionRule conversionWord="wEx"
+                    converterClass="org.springframework.boot.logging.logback.ExtendedWhitespaceThrowableProxyConverter"/>
+    <!-- 彩色日志格式 -->
+    <property name="CONSOLE_LOG_PATTERN"
+              value="${CONSOLE_LOG_PATTERN:-%clr(%d{yyyy-MM-dd HH:mm:ss.SSS}){faint} %clr(${LOG_LEVEL_PATTERN:-%5p}) %clr(${PID:- }){magenta} %clr(---){faint} %clr([%15.15t]){faint} %clr(%-40.40logger{39}){cyan} %clr(:){faint} %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}}"/>
+
+    <!--1. 输出到控制台-->
+    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
+        <!--此日志appender是为开发使用,只配置最底级别,控制台输出的日志级别是大于或等于此级别的日志信息-->
+        <filter class="ch.qos.logback.classic.filter.ThresholdFilter">
+            <level>debug</level>
+        </filter>
+        <encoder>
+            <Pattern>${CONSOLE_LOG_PATTERN}</Pattern>
+            <!-- 设置字符集 -->
+            <charset>UTF-8</charset>
+        </encoder>
+    </appender>
+
+    <!--2. 输出到文档-->
+    <!-- 2.1 level为 DEBUG 日志,时间滚动输出  -->
+    <appender name="DEBUG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <!-- 正在记录的日志文档的路径及文档名 -->
+        <file>${log.path}/${LOG_HOME}/debug.log</file>
+        <!--日志文档输出格式-->
+        <encoder>
+            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
+            <charset>UTF-8</charset> <!-- 设置字符集 -->
+        </encoder>
+        <!-- 日志记录器的滚动策略,按日期,按大小记录 -->
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- 日志归档 -->
+            <fileNamePattern>${log.path}/${LOG_HOME}/%d{yyyy-MM-dd}/debug-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
+            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
+                <maxFileSize>100MB</maxFileSize>
+            </timeBasedFileNamingAndTriggeringPolicy>
+            <!--日志文档保留天数-->
+            <maxHistory>15</maxHistory>
+        </rollingPolicy>
+        <!-- 此日志文档只记录debug级别的 -->
+        <filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <level>debug</level>
+            <onMatch>ACCEPT</onMatch>
+            <onMismatch>DENY</onMismatch>
+        </filter>
+    </appender>
+
+    <!-- 2.2 level为 INFO 日志,时间滚动输出  -->
+    <appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <!-- 正在记录的日志文档的路径及文档名 -->
+        <file>${log.path}/${LOG_HOME}/info.log</file>
+        <!--日志文档输出格式-->
+        <encoder>
+            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
+            <charset>UTF-8</charset>
+        </encoder>
+        <!-- 日志记录器的滚动策略,按日期,按大小记录 -->
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- 每天日志归档路径以及格式 -->
+            <fileNamePattern>${log.path}/${LOG_HOME}/%d{yyyy-MM-dd}/info-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
+            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
+                <maxFileSize>100MB</maxFileSize>
+            </timeBasedFileNamingAndTriggeringPolicy>
+            <!--日志文档保留天数-->
+            <maxHistory>15</maxHistory>
+        </rollingPolicy>
+        <!-- 此日志文档只记录info级别的 -->
+        <filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <level>info</level>
+            <onMatch>ACCEPT</onMatch>
+            <onMismatch>DENY</onMismatch>
+        </filter>
+    </appender>
+
+    <!-- 2.3 level为 WARN 日志,时间滚动输出  -->
+    <appender name="WARN_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <!-- 正在记录的日志文档的路径及文档名 -->
+        <file>${log.path}/${LOG_HOME}/warn.log</file>
+        <!--日志文档输出格式-->
+        <encoder>
+            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
+            <charset>UTF-8</charset> <!-- 此处设置字符集 -->
+        </encoder>
+        <!-- 日志记录器的滚动策略,按日期,按大小记录 -->
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <fileNamePattern>${log.path}/${LOG_HOME}/%d{yyyy-MM-dd}/warn-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
+            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
+                <maxFileSize>100MB</maxFileSize>
+            </timeBasedFileNamingAndTriggeringPolicy>
+            <!--日志文档保留天数-->
+            <maxHistory>15</maxHistory>
+        </rollingPolicy>
+        <!-- 此日志文档只记录warn级别的 -->
+        <filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <level>warn</level>
+            <onMatch>ACCEPT</onMatch>
+            <onMismatch>DENY</onMismatch>
+        </filter>
+    </appender>
+
+    <!-- 2.4 level为 ERROR 日志,时间滚动输出  -->
+    <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <!-- 正在记录的日志文档的路径及文档名 -->
+        <file>${log.path}/${LOG_HOME}/error.log</file>
+        <!--日志文档输出格式-->
+        <encoder>
+            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{50} - %msg%n</pattern>
+            <charset>UTF-8</charset> <!-- 此处设置字符集 -->
+        </encoder>
+        <!-- 日志记录器的滚动策略,按日期,按大小记录 -->
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <fileNamePattern>${log.path}/${LOG_HOME}/%d{yyyy-MM-dd}/error-%d{yyyy-MM-dd}.%i.log</fileNamePattern>
+            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
+                <maxFileSize>100MB</maxFileSize>
+            </timeBasedFileNamingAndTriggeringPolicy>
+            <!--日志文档保留天数-->
+            <maxHistory>15</maxHistory>
+        </rollingPolicy>
+        <!-- 此日志文档只记录ERROR级别的 -->
+        <filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <level>ERROR</level>
+            <onMatch>ACCEPT</onMatch>
+            <onMismatch>DENY</onMismatch>
+        </filter>
+    </appender>
+
+
+    <!-- 4. 最终的策略 -->
+    <root level="info">
+        <appender-ref ref="CONSOLE"/>
+        <appender-ref ref="DEBUG_FILE"/>
+        <appender-ref ref="INFO_FILE"/>
+        <appender-ref ref="WARN_FILE"/>
+        <appender-ref ref="ERROR_FILE"/>
+    </root>
+
+    <!-- 4.1 开发环境:打印控制台-->
+    <springProfile name="dev">
+        <root level="debug">
+            <appender-ref ref="CONSOLE"/>
+            <appender-ref ref="DEBUG_FILE"/>
+            <appender-ref ref="INFO_FILE"/>
+            <appender-ref ref="WARN_FILE"/>
+            <appender-ref ref="ERROR_FILE"/>
+        </root>
+    </springProfile>
+    <!--  4.2 测试环境:输出到文档-->
+    <springProfile name="test">
+        <root level="debug">
+            <appender-ref ref="CONSOLE"/>
+            <appender-ref ref="DEBUG_FILE"/>
+            <appender-ref ref="INFO_FILE"/>
+            <appender-ref ref="WARN_FILE"/>
+            <appender-ref ref="ERROR_FILE"/>
+        </root>
+    </springProfile>
+    <!--  4.3 生产环境:输出到文档-->
+    <springProfile name="pro">
+        <root level="info">
+            <appender-ref ref="CONSOLE"/>
+            <appender-ref ref="DEBUG_FILE"/>
+            <appender-ref ref="INFO_FILE"/>
+            <appender-ref ref="ERROR_FILE"/>
+            <appender-ref ref="WARN_FILE"/>
+        </root>
+    </springProfile>
+
+</configuration>

+ 17 - 1
iot-module/iot-module-system/iot-module-system-biz/src/main/java/com/middle/platfrom/system/biz/controller/IndexController.java

@@ -1,8 +1,24 @@
 package com.middle.platfrom.system.biz.controller;
 
+import com.middle.platform.common.utils.DateTimeUtil;
+import com.middle.platform.common.utils.Result;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
 /**
  * @author xucaiqin
- * @date 2023-12-17 13:52:13
+ * @date 2023-12-15 08:46:49
  */
+@RestController
+@RequestMapping("/index")
 public class IndexController {
+    @Value("${spring.application.name}")
+    private String name;
+
+    @GetMapping("")
+    public Result<Object> server() {
+        return Result.ok(name + ":" + DateTimeUtil.nowStr());
+    }
 }

+ 0 - 4
iot-module/pom.xml

@@ -16,10 +16,6 @@
         <module>iot-module-system</module>
         <module>iot-module-data</module>
         <module>iot-module-manage</module>
-        <module>iot-module-data/iot-module-data-biz</module>
-        <module>iot-module-data/iot-module-data-api</module>
-        <module>iot-module-manage/iot-module-manage-api</module>
-        <module>iot-module-manage/iot-module-manage-biz</module>
     </modules>
     <properties>
         <maven.compiler.source>17</maven.compiler.source>