1. 统一包名修改
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
package com.zt.plat.gateway;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
|
||||
@SpringBootApplication
|
||||
public class GatewayServerApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
// 启动 Spring Boot 应用
|
||||
SpringApplication.run(GatewayServerApplication.class, args);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
package com.zt.plat.gateway.filter.cors;
|
||||
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.http.HttpMethod;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.http.server.reactive.ServerHttpResponse;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.cors.reactive.CorsUtils;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import org.springframework.web.server.WebFilter;
|
||||
import org.springframework.web.server.WebFilterChain;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
/**
|
||||
* 跨域 Filter
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@Component
|
||||
public class CorsFilter implements WebFilter {
|
||||
|
||||
private static final String ALL = "*";
|
||||
private static final String MAX_AGE = "3600L";
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
|
||||
// 非跨域请求,直接放行
|
||||
ServerHttpRequest request = exchange.getRequest();
|
||||
if (!CorsUtils.isCorsRequest(request)) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
// 设置跨域响应头
|
||||
ServerHttpResponse response = exchange.getResponse();
|
||||
HttpHeaders headers = response.getHeaders();
|
||||
headers.add("Access-Control-Allow-Origin", ALL);
|
||||
headers.add("Access-Control-Allow-Methods", ALL);
|
||||
headers.add("Access-Control-Allow-Headers", ALL);
|
||||
headers.add("Access-Control-Max-Age", MAX_AGE);
|
||||
if (request.getMethod() == HttpMethod.OPTIONS) {
|
||||
response.setStatusCode(HttpStatus.OK);
|
||||
return Mono.empty();
|
||||
}
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
package com.zt.plat.gateway.filter.cors;
|
||||
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||
import org.springframework.cloud.gateway.filter.GlobalFilter;
|
||||
import org.springframework.cloud.gateway.filter.NettyWriteResponseFilter;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 解决 Spring Cloud Gateway 2.x 跨域时,出现重复 Origin 的 BUG
|
||||
*
|
||||
* 参考文档:<a href="https://blog.csdn.net/zimou5581/article/details/90043178" />
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@Component
|
||||
public class CorsResponseHeaderFilter implements GlobalFilter, Ordered {
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
// 指定此过滤器位于 NettyWriteResponseFilter 之后
|
||||
// 即待处理完响应体后接着处理响应头
|
||||
return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER + 1;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
|
||||
return chain.filter(exchange).then(Mono.defer(() -> {
|
||||
// https://gitee.com/zhijiantianya/cloud-cloud/pulls/177/
|
||||
List<String> keysToModify = exchange.getResponse().getHeaders().entrySet().stream()
|
||||
.filter(kv -> (kv.getValue() != null && kv.getValue().size() > 1))
|
||||
.filter(kv -> (kv.getKey().equals(HttpHeaders.ACCESS_CONTROL_ALLOW_ORIGIN)
|
||||
|| kv.getKey().equals(HttpHeaders.ACCESS_CONTROL_ALLOW_CREDENTIALS)))
|
||||
.map(Map.Entry::getKey)
|
||||
.collect(Collectors.toList());
|
||||
keysToModify.forEach(key->{
|
||||
List<String> values = exchange.getResponse().getHeaders().get(key);
|
||||
if (values != null && !values.isEmpty()) {
|
||||
exchange.getResponse().getHeaders().put(key, Collections.singletonList(values.get(0)));
|
||||
}
|
||||
});
|
||||
return chain.filter(exchange);
|
||||
}));
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,111 @@
|
||||
package com.zt.plat.gateway.filter.grey;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.zt.plat.framework.common.util.collection.CollectionUtils;
|
||||
import com.zt.plat.gateway.util.EnvUtils;
|
||||
import com.alibaba.cloud.nacos.balancer.NacosBalancer;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.beans.factory.ObjectProvider;
|
||||
import org.springframework.cloud.client.ServiceInstance;
|
||||
import org.springframework.cloud.client.loadbalancer.*;
|
||||
import org.springframework.cloud.loadbalancer.core.NoopServiceInstanceListSupplier;
|
||||
import org.springframework.cloud.loadbalancer.core.ReactorServiceInstanceLoadBalancer;
|
||||
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 灰度 {@link GrayLoadBalancer} 实现类
|
||||
*
|
||||
* 根据请求的 header[version] 匹配,筛选满足 metadata[version] 相等的服务实例列表,然后随机 + 权重进行选择一个
|
||||
* 1. 假如请求的 header[version] 为空,则不进行筛选,所有服务实例都进行选择
|
||||
* 2. 如果 metadata[version] 都不相等,则不进行筛选,所有服务实例都进行选择
|
||||
*
|
||||
* 注意,考虑到实现的简易,它的权重是使用 Nacos 的 nacos.weight,所以随机 + 权重也是基于 {@link NacosBalancer} 筛选。
|
||||
* 也就是说,如果你不使用 Nacos 作为注册中心,需要微调一下筛选的实现逻辑
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@RequiredArgsConstructor
|
||||
@Slf4j
|
||||
public class GrayLoadBalancer implements ReactorServiceInstanceLoadBalancer {
|
||||
|
||||
private static final String VERSION = "version";
|
||||
|
||||
/**
|
||||
* 用于获取 serviceId 对应的服务实例的列表
|
||||
*/
|
||||
private final ObjectProvider<ServiceInstanceListSupplier> serviceInstanceListSupplierProvider;
|
||||
/**
|
||||
* 需要获取的服务实例名
|
||||
*
|
||||
* 暂时用于打印 logger 日志
|
||||
*/
|
||||
private final String serviceId;
|
||||
|
||||
@Override
|
||||
public Mono<Response<ServiceInstance>> choose(Request request) {
|
||||
// 获得 HttpHeaders 属性,实现从 header 中获取 version
|
||||
HttpHeaders headers = ((RequestDataContext) request.getContext()).getClientRequest().getHeaders();
|
||||
// 选择实例
|
||||
ServiceInstanceListSupplier supplier = serviceInstanceListSupplierProvider.getIfAvailable(NoopServiceInstanceListSupplier::new);
|
||||
return supplier.get(request).next().map(list -> getInstanceResponse(list, headers));
|
||||
}
|
||||
|
||||
private Response<ServiceInstance> getInstanceResponse(List<ServiceInstance> instances, HttpHeaders headers) {
|
||||
// 如果服务实例为空,则直接返回
|
||||
if (CollUtil.isEmpty(instances)) {
|
||||
log.warn("[getInstanceResponse][serviceId({}) 服务实例列表为空]", serviceId);
|
||||
return new EmptyResponse();
|
||||
}
|
||||
|
||||
// 筛选满足 version 条件的实例列表
|
||||
String version = headers.getFirst(VERSION);
|
||||
List<ServiceInstance> chooseInstances;
|
||||
if (StrUtil.isEmpty(version)) {
|
||||
chooseInstances = instances;
|
||||
} else {
|
||||
chooseInstances = CollectionUtils.filterList(instances, instance -> version.equals(instance.getMetadata().get("version")));
|
||||
if (CollUtil.isEmpty(chooseInstances)) {
|
||||
log.warn("[getInstanceResponse][serviceId({}) 没有满足版本({})的服务实例列表,直接使用所有服务实例列表]", serviceId, version);
|
||||
chooseInstances = instances;
|
||||
}
|
||||
}
|
||||
|
||||
// 基于 tag 过滤实例列表
|
||||
chooseInstances = filterTagServiceInstances(chooseInstances, headers);
|
||||
|
||||
// 随机 + 权重获取实例列表 TODO 芋艿:目前直接使用 Nacos 提供的方法,如果替换注册中心,需要重新失败该方法
|
||||
return new DefaultResponse(NacosBalancer.getHostByRandomWeight3(chooseInstances));
|
||||
}
|
||||
|
||||
/**
|
||||
* 基于 tag 请求头,过滤匹配 tag 的服务实例列表
|
||||
*
|
||||
* copy from EnvLoadBalancerClient
|
||||
*
|
||||
* @param instances 服务实例列表
|
||||
* @param headers 请求头
|
||||
* @return 服务实例列表
|
||||
*/
|
||||
private List<ServiceInstance> filterTagServiceInstances(List<ServiceInstance> instances, HttpHeaders headers) {
|
||||
// 情况一,没有 tag 时,直接返回
|
||||
String tag = EnvUtils.getTag(headers);
|
||||
if (StrUtil.isEmpty(tag)) {
|
||||
return instances;
|
||||
}
|
||||
|
||||
// 情况二,有 tag 时,使用 tag 匹配服务实例
|
||||
List<ServiceInstance> chooseInstances = CollectionUtils.filterList(instances, instance -> tag.equals(EnvUtils.getTag(instance)));
|
||||
if (CollUtil.isEmpty(chooseInstances)) {
|
||||
log.warn("[filterTagServiceInstances][serviceId({}) 没有满足 tag({}) 的服务实例列表,直接使用所有服务实例列表]", serviceId, tag);
|
||||
chooseInstances = instances;
|
||||
}
|
||||
return chooseInstances;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package com.zt.plat.gateway.filter.grey;
|
||||
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cloud.client.ServiceInstance;
|
||||
import org.springframework.cloud.client.loadbalancer.*;
|
||||
import org.springframework.cloud.gateway.config.GatewayLoadBalancerProperties;
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||
import org.springframework.cloud.gateway.filter.GlobalFilter;
|
||||
import org.springframework.cloud.gateway.filter.ReactiveLoadBalancerClientFilter;
|
||||
import org.springframework.cloud.gateway.support.DelegatingServiceInstance;
|
||||
import org.springframework.cloud.gateway.support.NotFoundException;
|
||||
import org.springframework.cloud.loadbalancer.core.ServiceInstanceListSupplier;
|
||||
import org.springframework.cloud.loadbalancer.support.LoadBalancerClientFactory;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.net.URI;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.springframework.cloud.gateway.support.ServerWebExchangeUtils.*;
|
||||
|
||||
/**
|
||||
* 支持灰度功能的 {@link ReactiveLoadBalancerClientFilter} 实现类
|
||||
*
|
||||
* 由于 {@link ReactiveLoadBalancerClientFilter#choose(Request, String, Set)} 是 private 方法,无法进行重写。
|
||||
* 因此,这里只好 copy 它所有的代码,手动重写 choose 方法
|
||||
*
|
||||
* 具体的使用与实现原理,可阅读如下两个文章:
|
||||
* 1. https://www.jianshu.com/p/6db15bc0be8f
|
||||
* 2. https://cloud.tencent.com/developer/article/1620795
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@Component
|
||||
@AllArgsConstructor
|
||||
@Slf4j
|
||||
@SuppressWarnings({"JavadocReference", "rawtypes", "unchecked", "ConstantConditions"})
|
||||
public class GrayReactiveLoadBalancerClientFilter implements GlobalFilter, Ordered {
|
||||
|
||||
private final LoadBalancerClientFactory clientFactory;
|
||||
|
||||
private final GatewayLoadBalancerProperties properties;
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
// https://github.com/YunaiV/cloud-cloud/pull/213
|
||||
return ReactiveLoadBalancerClientFilter.LOAD_BALANCER_CLIENT_FILTER_ORDER - 50;
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
|
||||
URI url = exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR);
|
||||
String schemePrefix = exchange.getAttribute(GATEWAY_SCHEME_PREFIX_ATTR);
|
||||
// 修改 by ZT源码:将 lb 替换成 grayLb,表示灰度负载均衡
|
||||
if (url == null || (!"grayLb".equals(url.getScheme()) && !"grayLb".equals(schemePrefix))) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
// preserve the original url
|
||||
addOriginalRequestUrl(exchange, url);
|
||||
|
||||
if (log.isTraceEnabled()) {
|
||||
log.trace(ReactiveLoadBalancerClientFilter.class.getSimpleName() + " url before: " + url);
|
||||
}
|
||||
|
||||
URI requestUri = exchange.getAttribute(GATEWAY_REQUEST_URL_ATTR);
|
||||
String serviceId = requestUri.getHost();
|
||||
Set<LoadBalancerLifecycle> supportedLifecycleProcessors = LoadBalancerLifecycleValidator
|
||||
.getSupportedLifecycleProcessors(clientFactory.getInstances(serviceId, LoadBalancerLifecycle.class),
|
||||
RequestDataContext.class, ResponseData.class, ServiceInstance.class);
|
||||
DefaultRequest<RequestDataContext> lbRequest = new DefaultRequest<>(
|
||||
new RequestDataContext(new RequestData(exchange.getRequest()), getHint(serviceId)));
|
||||
return choose(lbRequest, serviceId, supportedLifecycleProcessors).doOnNext(response -> {
|
||||
|
||||
if (!response.hasServer()) {
|
||||
supportedLifecycleProcessors.forEach(lifecycle -> lifecycle
|
||||
.onComplete(new CompletionContext<>(CompletionContext.Status.DISCARD, lbRequest, response)));
|
||||
throw NotFoundException.create(properties.isUse404(), "功能模块未装载 " + url.getHost());
|
||||
}
|
||||
|
||||
ServiceInstance retrievedInstance = response.getServer();
|
||||
|
||||
URI uri = exchange.getRequest().getURI();
|
||||
|
||||
// if the `lb:<scheme>` mechanism was used, use `<scheme>` as the default,
|
||||
// if the loadbalancer doesn't provide one.
|
||||
String overrideScheme = retrievedInstance.isSecure() ? "https" : "http";
|
||||
if (schemePrefix != null) {
|
||||
overrideScheme = url.getScheme();
|
||||
}
|
||||
|
||||
DelegatingServiceInstance serviceInstance = new DelegatingServiceInstance(retrievedInstance,
|
||||
overrideScheme);
|
||||
|
||||
URI requestUrl = reconstructURI(serviceInstance, uri);
|
||||
|
||||
if (log.isTraceEnabled()) {
|
||||
log.trace("LoadBalancerClientFilter url chosen: " + requestUrl);
|
||||
}
|
||||
exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, requestUrl);
|
||||
exchange.getAttributes().put(GATEWAY_LOADBALANCER_RESPONSE_ATTR, response);
|
||||
supportedLifecycleProcessors.forEach(lifecycle -> lifecycle.onStartRequest(lbRequest, response));
|
||||
}).then(chain.filter(exchange))
|
||||
.doOnError(throwable -> supportedLifecycleProcessors.forEach(lifecycle -> lifecycle
|
||||
.onComplete(new CompletionContext<ResponseData, ServiceInstance, RequestDataContext>(
|
||||
CompletionContext.Status.FAILED, throwable, lbRequest,
|
||||
exchange.getAttribute(GATEWAY_LOADBALANCER_RESPONSE_ATTR)))))
|
||||
.doOnSuccess(aVoid -> supportedLifecycleProcessors.forEach(lifecycle -> lifecycle
|
||||
.onComplete(new CompletionContext<ResponseData, ServiceInstance, RequestDataContext>(
|
||||
CompletionContext.Status.SUCCESS, lbRequest,
|
||||
exchange.getAttribute(GATEWAY_LOADBALANCER_RESPONSE_ATTR),
|
||||
new ResponseData(exchange.getResponse(), new RequestData(exchange.getRequest()))))));
|
||||
}
|
||||
|
||||
protected URI reconstructURI(ServiceInstance serviceInstance, URI original) {
|
||||
return LoadBalancerUriTools.reconstructURI(serviceInstance, original);
|
||||
}
|
||||
|
||||
private Mono<Response<ServiceInstance>> choose(Request<RequestDataContext> lbRequest, String serviceId,
|
||||
Set<LoadBalancerLifecycle> supportedLifecycleProcessors) {
|
||||
// 修改 by ZT源码:直接创建 GrayLoadBalancer 对象
|
||||
GrayLoadBalancer loadBalancer = new GrayLoadBalancer(
|
||||
clientFactory.getLazyProvider(serviceId, ServiceInstanceListSupplier.class), serviceId);
|
||||
supportedLifecycleProcessors.forEach(lifecycle -> lifecycle.onStart(lbRequest));
|
||||
return loadBalancer.choose(lbRequest);
|
||||
}
|
||||
|
||||
private String getHint(String serviceId) {
|
||||
LoadBalancerProperties loadBalancerProperties = clientFactory.getProperties(serviceId);
|
||||
Map<String, String> hints = loadBalancerProperties.getHint();
|
||||
String defaultHint = hints.getOrDefault("default", "default");
|
||||
String hintPropertyValue = hints.get(serviceId);
|
||||
return hintPropertyValue != null ? hintPropertyValue : defaultHint;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.zt.plat.gateway.filter.logging;
|
||||
|
||||
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 Integer userType;
|
||||
/**
|
||||
* 路由
|
||||
*
|
||||
* 类似 ApiAccessLogCreateReqDTO 的 applicationName
|
||||
*/
|
||||
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;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,263 @@
|
||||
package com.zt.plat.gateway.filter.logging;
|
||||
|
||||
import cn.hutool.core.date.LocalDateTimeUtil;
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import cn.hutool.json.JSONUtil;
|
||||
import com.zt.plat.framework.common.util.json.JsonUtils;
|
||||
import com.zt.plat.gateway.util.SecurityFrameworkUtils;
|
||||
import com.zt.plat.gateway.util.WebFrameworkUtils;
|
||||
import com.alibaba.nacos.common.utils.StringUtils;
|
||||
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 jakarta.annotation.Resource;
|
||||
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;
|
||||
|
||||
/**
|
||||
* 网关的访问日志过滤器
|
||||
*
|
||||
* 从功能上,它类似 cloud-spring-boot-starter-web 的 ApiAccessLogFilter 过滤器
|
||||
*
|
||||
* TODO 芋艿:如果网关执行异常,不会记录访问日志,后续研究下 https://github.com/Silvmike/webflux-demo/blob/master/tests/src/test/java/ru/hardcoders/demo/webflux/web_handler/filters/logging
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class AccessLogFilter implements GlobalFilter, Ordered {
|
||||
|
||||
@Resource
|
||||
private CodecConfigurer codecConfigurer;
|
||||
|
||||
/**
|
||||
* 打印日志
|
||||
*
|
||||
* @param gatewayLog 网关日志
|
||||
*/
|
||||
private void writeAccessLog(AccessLog gatewayLog) {
|
||||
// 方式一:打印 Logger 后,通过 ELK 进行收集
|
||||
// log.info("[writeAccessLog][日志内容:{}]", JsonUtils.toJsonString(gatewayLog));
|
||||
|
||||
// 方式二:调用远程服务,记录到数据库中
|
||||
// TODO 芋艿:暂未实现
|
||||
|
||||
// 方式三:打印到控制台,方便排查错误
|
||||
Map<String, Object> values = MapUtil.newHashMap(15, true); // 手工拼接,保证排序;15 保证不用扩容
|
||||
values.put("userId", gatewayLog.getUserId());
|
||||
values.put("userType", gatewayLog.getUserType());
|
||||
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("[writeAccessLog][网关日志:{}]", 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();
|
||||
// TODO traceId
|
||||
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))); // 打印日志
|
||||
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录响应日志
|
||||
* 通过 DataBufferFactory 解决响应体分段传输问题。
|
||||
*/
|
||||
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.setUserType(SecurityFrameworkUtils.getLoginUserType(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 {
|
||||
// TODO: this causes a 'HTTP/1.1 411 Length Required' // on
|
||||
// httpbin.org
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.zt.plat.gateway.filter.security;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* 登录用户信息
|
||||
*
|
||||
* copy from cloud-spring-boot-starter-security 的 LoginUser 类
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@Data
|
||||
public class LoginUser {
|
||||
|
||||
/**
|
||||
* 用户编号
|
||||
*/
|
||||
private Long id;
|
||||
/**
|
||||
* 用户类型
|
||||
*/
|
||||
private Integer userType;
|
||||
/**
|
||||
* 额外的用户信息
|
||||
*/
|
||||
private Map<String, String> info;
|
||||
/**
|
||||
* 租户编号
|
||||
*/
|
||||
private Long tenantId;
|
||||
/**
|
||||
* 授权范围
|
||||
*/
|
||||
private List<String> scopes;
|
||||
/**
|
||||
* 过期时间
|
||||
*/
|
||||
private LocalDateTime expiresTime;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
package com.zt.plat.gateway.filter.security;
|
||||
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.zt.plat.framework.common.core.KeyValue;
|
||||
import com.zt.plat.framework.common.pojo.CommonResult;
|
||||
import com.zt.plat.framework.common.util.date.LocalDateTimeUtils;
|
||||
import com.zt.plat.framework.common.util.json.JsonUtils;
|
||||
import com.zt.plat.gateway.util.SecurityFrameworkUtils;
|
||||
import com.zt.plat.gateway.util.WebFrameworkUtils;
|
||||
import com.zt.plat.framework.common.biz.system.oauth2.OAuth2TokenCommonApi;
|
||||
import com.zt.plat.framework.common.biz.system.oauth2.dto.OAuth2AccessTokenCheckRespDTO;
|
||||
import com.fasterxml.jackson.core.type.TypeReference;
|
||||
import com.google.common.cache.CacheLoader;
|
||||
import com.google.common.cache.LoadingCache;
|
||||
import org.springframework.cloud.client.loadbalancer.reactive.ReactorLoadBalancerExchangeFilterFunction;
|
||||
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
|
||||
import org.springframework.cloud.gateway.filter.GlobalFilter;
|
||||
import org.springframework.core.Ordered;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.reactive.function.client.WebClient;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
import reactor.core.publisher.Mono;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.util.Objects;
|
||||
import java.util.function.Function;
|
||||
|
||||
import static com.zt.plat.framework.common.util.cache.CacheUtils.buildAsyncReloadingCache;
|
||||
|
||||
/**
|
||||
* Token 过滤器,验证 token 的有效性
|
||||
* 1. 验证通过时,将 userId、userType、tenantId 通过 Header 转发给服务
|
||||
* 2. 验证不通过,还是会转发给服务。因为,接口是否需要登录的校验,还是交给服务自身处理
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@Component
|
||||
public class TokenAuthenticationFilter implements GlobalFilter, Ordered {
|
||||
|
||||
/**
|
||||
* CommonResult<OAuth2AccessTokenCheckRespDTO> 对应的 TypeReference 结果,用于解析 checkToken 的结果
|
||||
*/
|
||||
private static final TypeReference<CommonResult<OAuth2AccessTokenCheckRespDTO>> CHECK_RESULT_TYPE_REFERENCE
|
||||
= new TypeReference<CommonResult<OAuth2AccessTokenCheckRespDTO>>() {};
|
||||
|
||||
/**
|
||||
* 空的 LoginUser 的结果
|
||||
*
|
||||
* 用于解决如下问题:
|
||||
* 1. {@link #getLoginUser(ServerWebExchange, String)} 返回 Mono.empty() 时,会导致后续的 flatMap 无法进行处理的问题。
|
||||
* 2. {@link #buildUser(String)} 时,如果 Token 已经过期,返回 LOGIN_USER_EMPTY 对象,避免缓存无法刷新
|
||||
*/
|
||||
private static final LoginUser LOGIN_USER_EMPTY = new LoginUser();
|
||||
|
||||
private final WebClient webClient;
|
||||
|
||||
/**
|
||||
* 登录用户的本地缓存
|
||||
*
|
||||
* key1:多租户的编号
|
||||
* key2:访问令牌
|
||||
*/
|
||||
private final LoadingCache<KeyValue<Long, String>, LoginUser> loginUserCache = buildAsyncReloadingCache(Duration.ofMinutes(1),
|
||||
new CacheLoader<KeyValue<Long, String>, LoginUser>() {
|
||||
|
||||
@Override
|
||||
public LoginUser load(KeyValue<Long, String> token) {
|
||||
String body = checkAccessToken(token.getKey(), token.getValue()).block();
|
||||
return buildUser(body);
|
||||
}
|
||||
|
||||
});
|
||||
|
||||
public TokenAuthenticationFilter(ReactorLoadBalancerExchangeFilterFunction lbFunction) {
|
||||
// Q:为什么不使用 OAuth2TokenApi 进行调用?
|
||||
// A1:Spring Cloud OpenFeign 官方未内置 Reactive 的支持 https://docs.spring.io/spring-cloud-openfeign/docs/current/reference/html/#reactive-support
|
||||
// A2:校验 Token 的 API 需要使用到 header[tenant-id] 传递租户编号,暂时不想编写 RequestInterceptor 实现
|
||||
// 因此,这里采用 WebClient,通过 lbFunction 实现负载均衡
|
||||
this.webClient = WebClient.builder().filter(lbFunction).build();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
|
||||
// 移除 login-user 的请求头,避免伪造模拟
|
||||
exchange = SecurityFrameworkUtils.removeLoginUser(exchange);
|
||||
|
||||
// 情况一,如果没有 Token 令牌,则直接继续 filter
|
||||
String token = SecurityFrameworkUtils.obtainAuthorization(exchange);
|
||||
if (StrUtil.isEmpty(token)) {
|
||||
return chain.filter(exchange);
|
||||
}
|
||||
|
||||
// 情况二,如果有 Token 令牌,则解析对应 userId、userType、tenantId 等字段,并通过 通过 Header 转发给服务
|
||||
// 重要说明:defaultIfEmpty 作用,保证 Mono.empty() 情况,可以继续执行 `flatMap 的 chain.filter(exchange)` 逻辑,避免返回给前端空的 Response!!
|
||||
ServerWebExchange finalExchange = exchange;
|
||||
return getLoginUser(exchange, token).defaultIfEmpty(LOGIN_USER_EMPTY).flatMap(user -> {
|
||||
// 1. 无用户,直接 filter 继续请求
|
||||
if (user == LOGIN_USER_EMPTY || // 下面 expiresTime 的判断,为了解决 token 实际已经过期的情况
|
||||
user.getExpiresTime() == null || LocalDateTimeUtils.beforeNow(user.getExpiresTime())) {
|
||||
return chain.filter(finalExchange);
|
||||
}
|
||||
|
||||
// 2.1 有用户,则设置登录用户
|
||||
SecurityFrameworkUtils.setLoginUser(finalExchange, user);
|
||||
// 2.2 将 user 并设置到 login-user 的请求头,使用 json 存储值
|
||||
ServerWebExchange newExchange = finalExchange.mutate()
|
||||
.request(builder -> SecurityFrameworkUtils.setLoginUserHeader(builder, user)).build();
|
||||
return chain.filter(newExchange);
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<LoginUser> getLoginUser(ServerWebExchange exchange, String token) {
|
||||
// 从缓存中,获取 LoginUser
|
||||
Long tenantId = WebFrameworkUtils.getTenantId(exchange);
|
||||
KeyValue<Long, String> cacheKey = new KeyValue<Long, String>().setKey(tenantId).setValue(token);
|
||||
LoginUser localUser = loginUserCache.getIfPresent(cacheKey);
|
||||
if (localUser != null) {
|
||||
return Mono.just(localUser);
|
||||
}
|
||||
|
||||
// 缓存不存在,则请求远程服务
|
||||
return checkAccessToken(tenantId, token).flatMap((Function<String, Mono<LoginUser>>) body -> {
|
||||
LoginUser remoteUser = buildUser(body);
|
||||
if (remoteUser != null) {
|
||||
// 非空,则进行缓存
|
||||
loginUserCache.put(cacheKey, remoteUser);
|
||||
return Mono.just(remoteUser);
|
||||
}
|
||||
return Mono.empty();
|
||||
});
|
||||
}
|
||||
|
||||
private Mono<String> checkAccessToken(Long tenantId, String token) {
|
||||
return webClient.get()
|
||||
.uri(OAuth2TokenCommonApi.URL_CHECK, uriBuilder -> uriBuilder.queryParam("accessToken", token).build())
|
||||
.headers(httpHeaders -> WebFrameworkUtils.setTenantIdHeader(tenantId, httpHeaders)) // 设置租户的 Header
|
||||
.retrieve().bodyToMono(String.class);
|
||||
}
|
||||
|
||||
private LoginUser buildUser(String body) {
|
||||
// 处理结果,结果不正确
|
||||
CommonResult<OAuth2AccessTokenCheckRespDTO> result = JsonUtils.parseObject(body, CHECK_RESULT_TYPE_REFERENCE);
|
||||
if (result == null) {
|
||||
return null;
|
||||
}
|
||||
if (result.isError()) {
|
||||
// 特殊情况:令牌已经过期(code = 401),需要返回 LOGIN_USER_EMPTY,避免 Token 一直因为缓存,被误判为有效
|
||||
if (Objects.equals(result.getCode(), HttpStatus.UNAUTHORIZED.value())) {
|
||||
return LOGIN_USER_EMPTY;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// 创建登录用户
|
||||
OAuth2AccessTokenCheckRespDTO tokenInfo = result.getData();
|
||||
return new LoginUser().setId(tokenInfo.getUserId()).setUserType(tokenInfo.getUserType())
|
||||
.setInfo(tokenInfo.getUserInfo()) // 额外的用户信息
|
||||
.setTenantId(tokenInfo.getTenantId()).setScopes(tokenInfo.getScopes())
|
||||
.setExpiresTime(tokenInfo.getExpiresTime());
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getOrder() {
|
||||
return -100; // 和 Spring Security Filter 的顺序对齐
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package com.zt.plat.gateway.handler;
|
||||
|
||||
import com.zt.plat.framework.common.pojo.CommonResult;
|
||||
import com.zt.plat.gateway.util.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;
|
||||
|
||||
import static com.zt.plat.framework.common.exception.enums.GlobalErrorCodeConstants.INTERNAL_SERVER_ERROR;
|
||||
|
||||
/**
|
||||
* Gateway 的全局异常处理器,将 Exception 翻译成 CommonResult + 对应的异常编号
|
||||
*
|
||||
* 在功能上,和 cloud-spring-boot-starter-web 的 GlobalExceptionHandler 类是一致的
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@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
|
||||
CommonResult<?> 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 CommonResult<?> responseStatusExceptionHandler(ServerWebExchange exchange,
|
||||
ResponseStatusException ex) {
|
||||
// TODO 芋艿:这里要精细化翻译,默认返回用户是看不懂的
|
||||
ServerHttpRequest request = exchange.getRequest();
|
||||
log.error("[responseStatusExceptionHandler][uri({}/{}) 发生异常]", request.getURI(), request.getMethod(), ex);
|
||||
return CommonResult.error(ex.getStatusCode().value(), ex.getReason());
|
||||
}
|
||||
|
||||
/**
|
||||
* 处理系统异常,兜底处理所有的一切
|
||||
*/
|
||||
@ExceptionHandler(value = Exception.class)
|
||||
public CommonResult<?> defaultExceptionHandler(ServerWebExchange exchange,
|
||||
Throwable ex) {
|
||||
ServerHttpRequest request = exchange.getRequest();
|
||||
log.error("[defaultExceptionHandler][uri({}/{}) 发生异常]", request.getURI(), request.getMethod(), ex);
|
||||
// TODO 芋艿:是否要插入异常日志呢?
|
||||
// 返回 ERROR CommonResult
|
||||
return CommonResult.error(INTERNAL_SERVER_ERROR.getCode(), INTERNAL_SERVER_ERROR.getMsg());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package com.zt.plat.gateway.jackson;
|
||||
|
||||
import cn.hutool.core.collection.CollUtil;
|
||||
import com.zt.plat.framework.common.util.json.JsonUtils;
|
||||
import com.zt.plat.framework.common.util.json.databind.NumberSerializer;
|
||||
import com.zt.plat.framework.common.util.json.databind.TimestampLocalDateTimeDeserializer;
|
||||
import com.zt.plat.framework.common.util.json.databind.TimestampLocalDateTimeSerializer;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.fasterxml.jackson.databind.module.SimpleModule;
|
||||
import com.fasterxml.jackson.datatype.jsr310.deser.LocalDateDeserializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.deser.LocalTimeDeserializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.ser.LocalDateSerializer;
|
||||
import com.fasterxml.jackson.datatype.jsr310.ser.LocalTimeSerializer;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.LocalTime;
|
||||
import java.util.List;
|
||||
|
||||
@Configuration
|
||||
@Slf4j
|
||||
public class JacksonAutoConfiguration {
|
||||
|
||||
@Bean
|
||||
public JsonUtils jsonUtils(List<ObjectMapper> objectMappers) {
|
||||
// 1.1 创建 SimpleModule 对象
|
||||
SimpleModule simpleModule = new SimpleModule();
|
||||
simpleModule
|
||||
// 新增 Long 类型序列化规则,数值超过 2^53-1,在 JS 会出现精度丢失问题,因此 Long 自动序列化为字符串类型
|
||||
.addSerializer(Long.class, NumberSerializer.INSTANCE)
|
||||
.addSerializer(Long.TYPE, NumberSerializer.INSTANCE)
|
||||
.addSerializer(LocalDate.class, LocalDateSerializer.INSTANCE)
|
||||
.addDeserializer(LocalDate.class, LocalDateDeserializer.INSTANCE)
|
||||
.addSerializer(LocalTime.class, LocalTimeSerializer.INSTANCE)
|
||||
.addDeserializer(LocalTime.class, LocalTimeDeserializer.INSTANCE)
|
||||
// 新增 LocalDateTime 序列化、反序列化规则,使用 Long 时间戳
|
||||
.addSerializer(LocalDateTime.class, TimestampLocalDateTimeSerializer.INSTANCE)
|
||||
.addDeserializer(LocalDateTime.class, TimestampLocalDateTimeDeserializer.INSTANCE);
|
||||
// 1.2 注册到 objectMapper
|
||||
objectMappers.forEach(objectMapper -> objectMapper.registerModule(simpleModule));
|
||||
|
||||
// 2. 设置 objectMapper 到 JsonUtils
|
||||
JsonUtils.init(CollUtil.getFirst(objectMappers));
|
||||
log.info("[init][初始化 JsonUtils 成功]");
|
||||
return new JsonUtils();
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* 在 Nacos 配置发生变化时,Spring Cloud Alibaba Nacos Config 内置的监听器,会监听到配置刷新,最终触发 Gateway 的路由信息刷新。
|
||||
*
|
||||
* 参见 https://www.iocoder.cn/Spring-Cloud/Spring-Cloud-Gateway/?cloud 博客的「6. 基于配置中心 Nacos 实现动态路由」小节
|
||||
*
|
||||
* 使用方式:在 Nacos 修改 DataId 为 gateway-server.yaml 的配置,修改 spring.cloud.gateway.routes 配置项
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
package com.zt.plat.gateway.route.dynamic;
|
||||
@@ -0,0 +1,4 @@
|
||||
/**
|
||||
* 占位符
|
||||
*/
|
||||
package com.zt.plat.gateway.route;
|
||||
@@ -0,0 +1,49 @@
|
||||
package com.zt.plat.gateway.util;
|
||||
|
||||
import cn.hutool.core.thread.ThreadUtil;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.boot.ApplicationArguments;
|
||||
import org.springframework.boot.ApplicationRunner;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
/**
|
||||
* 项目启动成功后,提供文档相关的地址
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@Component
|
||||
@Slf4j
|
||||
public class BannerApplicationRunner implements ApplicationRunner {
|
||||
|
||||
@Override
|
||||
public void run(ApplicationArguments args) {
|
||||
ThreadUtil.execute(() -> {
|
||||
ThreadUtil.sleep(1, TimeUnit.SECONDS); // 延迟 1 秒,保证输出到结尾
|
||||
log.info("\n----------------------------------------------------------\n\t" +
|
||||
"项目启动成功!\n\t" +
|
||||
"接口文档: \t{} \n\t" +
|
||||
"开发文档: \t{} \n\t" +
|
||||
"----------------------------------------------------------",
|
||||
"http://172.16.46.63:30888/api-doc/",
|
||||
"http://172.16.46.63:30888");
|
||||
|
||||
// 数据报表
|
||||
System.out.println("[报表模块 cloud-module-report 教程][参考 http://172.16.46.63:30888/report/ 开启]");
|
||||
// 工作流
|
||||
System.out.println("[工作流模块 cloud-module-bpm 教程][参考 http://172.16.46.63:30888/bpm/ 开启]");
|
||||
|
||||
// 微信公众号
|
||||
System.out.println("[微信公众号 cloud-module-mp 教程][参考 http://172.16.46.63:30888/mp/build/ 开启]");
|
||||
|
||||
// AI 大模型
|
||||
System.out.println("[AI 大模型 cloud-module-ai - 教程][参考 http://172.16.46.63:30888/ai/build/ 开启]");
|
||||
// IOT 物联网
|
||||
System.out.println("[IoT 物联网 cloud-module-iot - 教程][参考 http://172.16.46.63:30888/iot/build/ 开启]");
|
||||
// IOT 项目模板
|
||||
System.out.println("[Template 项目模板 cloud-module-template - 教程][参考 http://172.16.46.63:30888/template/ 开启]");
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
package com.zt.plat.gateway.util;
|
||||
|
||||
import cn.hutool.core.net.NetUtil;
|
||||
import cn.hutool.core.util.IdUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import org.springframework.cloud.client.ServiceInstance;
|
||||
import org.springframework.http.HttpHeaders;
|
||||
|
||||
import java.util.Objects;
|
||||
|
||||
/**
|
||||
* 环境 Utils
|
||||
*
|
||||
* copy from cloud-spring-boot-starter-env 的 EnvUtils 类
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
public class EnvUtils {
|
||||
|
||||
private static final String HEADER_TAG = "tag";
|
||||
|
||||
public static final String HOST_NAME_VALUE = "${HOSTNAME}";
|
||||
|
||||
public static String getTag(HttpHeaders headers) {
|
||||
String tag = headers.getFirst(HEADER_TAG);
|
||||
// 如果请求的是 "${HOSTNAME}",则解析成对应的本地主机名
|
||||
// 目的:特殊逻辑,解决 IDEA Rest Client 不支持环境变量的读取,所以就服务器来做
|
||||
return Objects.equals(tag, HOST_NAME_VALUE) ? getHostName() : tag;
|
||||
}
|
||||
|
||||
public static String getTag(ServiceInstance instance) {
|
||||
return instance.getMetadata().get(HEADER_TAG);
|
||||
}
|
||||
|
||||
public static String getHostName() {
|
||||
return StrUtil.blankToDefault(NetUtil.getLocalHostName(), IdUtil.fastSimpleUUID());
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
package com.zt.plat.gateway.util;
|
||||
|
||||
import cn.hutool.core.map.MapUtil;
|
||||
import com.zt.plat.framework.common.util.json.JsonUtils;
|
||||
import com.zt.plat.gateway.filter.security.LoginUser;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.server.reactive.ServerHttpRequest;
|
||||
import org.springframework.util.StringUtils;
|
||||
import org.springframework.web.server.ServerWebExchange;
|
||||
|
||||
import java.net.URLEncoder;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
|
||||
/**
|
||||
* 安全服务工具类
|
||||
*
|
||||
* copy from cloud-spring-boot-starter-security 的 SecurityFrameworkUtils 类
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@Slf4j
|
||||
public class SecurityFrameworkUtils {
|
||||
|
||||
private static final String AUTHORIZATION_HEADER = "Authorization";
|
||||
|
||||
private static final String AUTHORIZATION_BEARER = "Bearer";
|
||||
|
||||
private static final String LOGIN_USER_HEADER = "login-user";
|
||||
|
||||
private static final String LOGIN_USER_ID_ATTR = "login-user-id";
|
||||
private static final String LOGIN_USER_TYPE_ATTR = "login-user-type";
|
||||
|
||||
private SecurityFrameworkUtils() {}
|
||||
|
||||
/**
|
||||
* 从请求中,获得认证 Token
|
||||
*
|
||||
* @param exchange 请求
|
||||
* @return 认证 Token
|
||||
*/
|
||||
public static String obtainAuthorization(ServerWebExchange exchange) {
|
||||
String authorization = exchange.getRequest().getHeaders().getFirst(AUTHORIZATION_HEADER);
|
||||
if (!StringUtils.hasText(authorization)) {
|
||||
return null;
|
||||
}
|
||||
int index = authorization.indexOf(AUTHORIZATION_BEARER + " ");
|
||||
if (index == -1) { // 未找到
|
||||
return null;
|
||||
}
|
||||
return authorization.substring(index + 7).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置登录用户
|
||||
*
|
||||
* @param exchange 请求
|
||||
* @param user 用户
|
||||
*/
|
||||
public static void setLoginUser(ServerWebExchange exchange, LoginUser user) {
|
||||
exchange.getAttributes().put(LOGIN_USER_ID_ATTR, user.getId());
|
||||
exchange.getAttributes().put(LOGIN_USER_TYPE_ATTR, user.getUserType());
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除请求头的用户
|
||||
*
|
||||
* @param exchange 请求
|
||||
* @return 请求
|
||||
*/
|
||||
public static ServerWebExchange removeLoginUser(ServerWebExchange exchange) {
|
||||
// 如果不包含,直接返回
|
||||
if (!exchange.getRequest().getHeaders().containsKey(LOGIN_USER_HEADER)) {
|
||||
return exchange;
|
||||
}
|
||||
// 如果包含,则移除。参考 RemoveRequestHeaderGatewayFilterFactory 实现
|
||||
ServerHttpRequest request = exchange.getRequest().mutate()
|
||||
.headers(httpHeaders -> httpHeaders.remove(LOGIN_USER_HEADER)).build();
|
||||
return exchange.mutate().request(request).build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得登录用户的编号
|
||||
*
|
||||
* @param exchange 请求
|
||||
* @return 用户编号
|
||||
*/
|
||||
public static Long getLoginUserId(ServerWebExchange exchange) {
|
||||
return MapUtil.getLong(exchange.getAttributes(), LOGIN_USER_ID_ATTR);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获得登录用户的类型
|
||||
*
|
||||
* @param exchange 请求
|
||||
* @return 用户类型
|
||||
*/
|
||||
public static Integer getLoginUserType(ServerWebExchange exchange) {
|
||||
return MapUtil.getInt(exchange.getAttributes(), LOGIN_USER_TYPE_ATTR);
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 user 并设置到 login-user 的请求头,使用 json 存储值
|
||||
*
|
||||
* @param builder 请求
|
||||
* @param user 用户
|
||||
*/
|
||||
public static void setLoginUserHeader(ServerHttpRequest.Builder builder, LoginUser user) {
|
||||
try {
|
||||
String userStr = JsonUtils.toJsonString(user);
|
||||
userStr = URLEncoder.encode(userStr, StandardCharsets.UTF_8); // 编码,避免中文乱码
|
||||
builder.header(LOGIN_USER_HEADER, userStr);
|
||||
} catch (Exception ex) {
|
||||
log.error("[setLoginUserHeader][序列化 user({}) 发生异常]", user, ex);
|
||||
throw ex;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
package com.zt.plat.gateway.util;
|
||||
|
||||
import cn.hutool.core.net.NetUtil;
|
||||
import cn.hutool.core.util.ArrayUtil;
|
||||
import cn.hutool.core.util.NumberUtil;
|
||||
import cn.hutool.extra.servlet.ServletUtil;
|
||||
import com.zt.plat.framework.common.util.json.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 工具类
|
||||
*
|
||||
* copy from cloud-spring-boot-starter-web 的 WebFrameworkUtils 类
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@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 ServletUtil} 的 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);
|
||||
}
|
||||
|
||||
}
|
||||
14
zt-gateway/src/main/resources/application-dev.yaml
Normal file
14
zt-gateway/src/main/resources/application-dev.yaml
Normal file
@@ -0,0 +1,14 @@
|
||||
--- #################### 注册中心 + 配置中心相关配置 ####################
|
||||
|
||||
spring:
|
||||
cloud:
|
||||
nacos:
|
||||
server-addr: 172.16.46.63:30848 # Nacos 服务器地址
|
||||
username: # Nacos 账号
|
||||
password: # Nacos 密码
|
||||
discovery: # 【配置中心】配置项
|
||||
namespace: ${config.namespace} # 命名空间。这里使用 maven Profile 资源过滤进行动态替换
|
||||
group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP
|
||||
config: # 【注册中心】配置项
|
||||
namespace: ${config.namespace} # 命名空间。这里使用 maven Profile 资源过滤进行动态替换
|
||||
group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP
|
||||
4
zt-gateway/src/main/resources/application-local.yaml
Normal file
4
zt-gateway/src/main/resources/application-local.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
# 日志文件配置
|
||||
logging:
|
||||
level:
|
||||
org.springframework.context.support.PostProcessorRegistrationDelegate: ERROR # TODO 芋艿:先禁用,Spring Boot 3.X 存在部分错误的 WARN 提示
|
||||
272
zt-gateway/src/main/resources/application.yaml
Normal file
272
zt-gateway/src/main/resources/application.yaml
Normal file
@@ -0,0 +1,272 @@
|
||||
spring:
|
||||
application:
|
||||
name: gateway-server
|
||||
|
||||
profiles:
|
||||
active: ${env.name}
|
||||
|
||||
codec:
|
||||
max-in-memory-size: 10MB # 调整缓冲区大小https://gitee.com/zhijiantianya/cloud-cloud/pulls/176
|
||||
|
||||
# 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
|
||||
|
||||
main:
|
||||
allow-circular-references: true # 允许循环依赖,因为项目是三层架构,无法避免这个情况。
|
||||
|
||||
config:
|
||||
import:
|
||||
- optional:classpath:application-${spring.profiles.active}.yaml # 加载【本地】配置
|
||||
- optional:nacos:${spring.application.name}-${spring.profiles.active}.yaml # 加载【Nacos】的配置
|
||||
|
||||
cloud:
|
||||
nacos:
|
||||
server-addr: ${config.server-addr} # Nacos 服务器地址
|
||||
username: ${config.username} # Nacos 账号
|
||||
password: ${config.password} # Nacos 密码
|
||||
discovery: # 【配置中心】配置项
|
||||
namespace: ${config.namespace} # 命名空间。这里使用 maven Profile 资源过滤进行动态替换
|
||||
group: ${config.group} # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP
|
||||
metadata:
|
||||
version: 1.0.0 # 服务实例的版本号,可用于灰度发布
|
||||
config: # 【注册中心】配置项
|
||||
namespace: ${config.namespace} # 命名空间。这里使用 maven Profile 资源过滤进行动态替换
|
||||
group: ${config.group} # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP
|
||||
# Spring Cloud Gateway 配置项,对应 GatewayProperties 类
|
||||
gateway:
|
||||
# 路由配置项,对应 RouteDefinition 数组
|
||||
routes:
|
||||
## system-server 服务
|
||||
- id: system-admin-api # 路由的编号
|
||||
uri: grayLb://system-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/admin-api/system/**
|
||||
filters:
|
||||
- RewritePath=/admin-api/system/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs
|
||||
- id: system-app-api # 路由的编号
|
||||
uri: grayLb://system-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/app-api/system/**
|
||||
filters:
|
||||
- RewritePath=/app-api/system/v3/api-docs, /v3/api-docs
|
||||
## infra-server 服务
|
||||
- id: infra-admin-api # 路由的编号
|
||||
uri: grayLb://infra-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/admin-api/infra/**
|
||||
filters:
|
||||
- RewritePath=/admin-api/infra/v3/api-docs, /v3/api-docs
|
||||
- id: infra-app-api # 路由的编号
|
||||
uri: grayLb://infra-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/app-api/infra/**
|
||||
filters:
|
||||
- RewritePath=/app-api/infra/v3/api-docs, /v3/api-docs
|
||||
- id: infra-spring-boot-admin # 路由的编号(Spring Boot Admin)
|
||||
uri: grayLb://infra-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/admin/**
|
||||
- id: infra-websocket # 路由的编号(WebSocket)
|
||||
uri: grayLb://infra-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/infra/ws/**
|
||||
## member-server 服务
|
||||
- id: member-admin-api # 路由的编号
|
||||
uri: grayLb://member-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/admin-api/member/**
|
||||
filters:
|
||||
- RewritePath=/admin-api/member/v3/api-docs, /v3/api-docs
|
||||
- id: member-app-api # 路由的编号
|
||||
uri: grayLb://member-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/app-api/member/**
|
||||
filters:
|
||||
- RewritePath=/app-api/member/v3/api-docs, /v3/api-docs
|
||||
## bpm-server 服务
|
||||
- id: bpm-admin-api # 路由的编号
|
||||
uri: grayLb://bpm-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/admin-api/bpm/**
|
||||
filters:
|
||||
- RewritePath=/admin-api/bpm/v3/api-docs, /v3/api-docs
|
||||
## report-server 服务
|
||||
- id: report-admin-api # 路由的编号
|
||||
uri: grayLb://report-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/admin-api/report/**
|
||||
filters:
|
||||
- RewritePath=/admin-api/report/v3/api-docs, /v3/api-docs
|
||||
- id: report-jimu # 路由的编号(积木报表)
|
||||
uri: grayLb://report-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/jmreport/**, /drag/**, /jimubi/**
|
||||
## pay-server 服务
|
||||
- id: pay-admin-api # 路由的编号
|
||||
uri: grayLb://pay-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/admin-api/pay/**
|
||||
filters:
|
||||
- RewritePath=/admin-api/pay/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs
|
||||
- id: pay-app-api # 路由的编号
|
||||
uri: grayLb://pay-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/app-api/pay/**
|
||||
filters:
|
||||
- RewritePath=/app-api/pay/v3/api-docs, /v3/api-docs
|
||||
## mp-server 服务
|
||||
- id: mp-admin-api # 路由的编号
|
||||
uri: grayLb://mp-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/admin-api/mp/**
|
||||
filters:
|
||||
- RewritePath=/admin-api/mp/v3/api-docs, /v3/api-docs
|
||||
## product-server 服务
|
||||
- id: product-admin-api # 路由的编号
|
||||
uri: grayLb://product-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/admin-api/product/**
|
||||
filters:
|
||||
- RewritePath=/admin-api/product/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs
|
||||
- id: product-app-api # 路由的编号
|
||||
uri: grayLb://product-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/app-api/product/**
|
||||
filters:
|
||||
- RewritePath=/app-api/product/v3/api-docs, /v3/api-docs
|
||||
## promotion-server 服务
|
||||
- id: promotion-admin-api # 路由的编号
|
||||
uri: grayLb://promotion-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/admin-api/promotion/**
|
||||
filters:
|
||||
- RewritePath=/admin-api/promotion/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs
|
||||
- id: promotion-app-api # 路由的编号
|
||||
uri: grayLb://promotion-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/app-api/promotion/**
|
||||
filters:
|
||||
- RewritePath=/app-api/promotion/v3/api-docs, /v3/api-docs
|
||||
## trade-server 服务
|
||||
- id: trade-admin-api # 路由的编号
|
||||
uri: grayLb://trade-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/admin-api/trade/**
|
||||
filters:
|
||||
- RewritePath=/admin-api/trade/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs
|
||||
- id: trade-app-api # 路由的编号
|
||||
uri: grayLb://trade-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/app-api/trade/**
|
||||
filters:
|
||||
- RewritePath=/app-api/trade/v3/api-docs, /v3/api-docs
|
||||
## statistics-server 服务
|
||||
- id: statistics-admin-api # 路由的编号
|
||||
uri: grayLb://statistics-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/admin-api/statistics/**
|
||||
filters:
|
||||
- RewritePath=/admin-api/statistics/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs
|
||||
## erp-server 服务
|
||||
- id: erp-admin-api # 路由的编号
|
||||
uri: grayLb://erp-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/admin-api/erp/**
|
||||
filters:
|
||||
- RewritePath=/admin-api/erp/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs
|
||||
## crm-server 服务
|
||||
- id: crm-admin-api # 路由的编号
|
||||
uri: grayLb://crm-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/admin-api/crm/**
|
||||
filters:
|
||||
- RewritePath=/admin-api/crm/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs
|
||||
## ai-server 服务
|
||||
- id: ai-admin-api # 路由的编号
|
||||
uri: grayLb://ai-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/admin-api/ai/**
|
||||
filters:
|
||||
- RewritePath=/admin-api/ai/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs
|
||||
## iot-server 服务
|
||||
- id: iot-admin-api # 路由的编号
|
||||
uri: grayLb://iot-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/admin-api/iot/**
|
||||
filters:
|
||||
- RewritePath=/admin-api/iot/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs
|
||||
## template-server 服务
|
||||
- id: template-admin-api # 路由的编号
|
||||
uri: grayLb://template-server
|
||||
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
|
||||
- Path=/admin-api/template/**
|
||||
filters:
|
||||
- RewritePath=/admin-api/template/v3/api-docs, /v3/api-docs # 配置,保证转发到 /v3/api-docs
|
||||
x-forwarded:
|
||||
prefix-enabled: false # 避免 Swagger 重复带上额外的 /admin-api/system 前缀
|
||||
|
||||
server:
|
||||
port: 48080
|
||||
|
||||
logging:
|
||||
file:
|
||||
name: ${user.home}/logs/${spring.application.name}.log # 日志文件名,全路径
|
||||
|
||||
knife4j:
|
||||
# 聚合 Swagger 文档,参考 https://doc.xiaominfo.com/docs/action/springcloud-gateway 文档
|
||||
gateway:
|
||||
enabled: true
|
||||
routes:
|
||||
- name: system-server
|
||||
service-name: system-server
|
||||
url: /admin-api/system/v3/api-docs
|
||||
- name: infra-server
|
||||
service-name: infra-server
|
||||
url: /admin-api/infra/v3/api-docs
|
||||
- name: member-server
|
||||
service-name: member-server
|
||||
url: /admin-api/member/v3/api-docs
|
||||
- name: bpm-server
|
||||
service-name: bpm-server
|
||||
url: /admin-api/bpm/v3/api-docs
|
||||
- name: pay-server
|
||||
service-name: pay-server
|
||||
url: /admin-api/pay/v3/api-docs
|
||||
- name: mp-server
|
||||
service-name: mp-server
|
||||
url: /admin-api/mp/v3/api-docs
|
||||
- name: product-server
|
||||
service-name: product-server
|
||||
url: /admin-api/product/v3/api-docs
|
||||
- name: promotion-server
|
||||
service-name: promotion-server
|
||||
url: /admin-api/promotion/v3/api-docs
|
||||
- name: trade-server
|
||||
service-name: trade-server
|
||||
url: /admin-api/trade/v3/api-docs
|
||||
- name: statistics-server
|
||||
service-name: statistics-server
|
||||
url: /admin-api/statistics/v3/api-docs
|
||||
- name: erp-server
|
||||
service-name: erp-server
|
||||
url: /admin-api/erp/v3/api-docs
|
||||
- name: crm-server
|
||||
service-name: crm-server
|
||||
url: /admin-api/crm/v3/api-docs
|
||||
- name: ai-server
|
||||
service-name: ai-server
|
||||
url: /admin-api/ai/v3/api-docs
|
||||
- name: iot-server
|
||||
service-name: iot-server
|
||||
url: /admin-api/iot/v3/api-docs
|
||||
|
||||
--- #################### ZT相关配置 ####################
|
||||
|
||||
cloud:
|
||||
info:
|
||||
version: 1.0.0
|
||||
17
zt-gateway/src/main/resources/banner.txt
Normal file
17
zt-gateway/src/main/resources/banner.txt
Normal file
@@ -0,0 +1,17 @@
|
||||
ZT源码 http://www.iocoder.cn
|
||||
Application Version: ${cloud.info.version}
|
||||
Spring Boot Version: ${spring-boot.version}
|
||||
|
||||
.__ __. ______ .______ __ __ _______
|
||||
| \ | | / __ \ | _ \ | | | | / _____|
|
||||
| \| | | | | | | |_) | | | | | | | __
|
||||
| . ` | | | | | | _ < | | | | | | |_ |
|
||||
| |\ | | `--' | | |_) | | `--' | | |__| |
|
||||
|__| \__| \______/ |______/ \______/ \______|
|
||||
|
||||
███╗ ██╗ ██████╗ ██████╗ ██╗ ██╗ ██████╗
|
||||
████╗ ██║██╔═══██╗ ██╔══██╗██║ ██║██╔════╝
|
||||
██╔██╗ ██║██║ ██║ ██████╔╝██║ ██║██║ ███╗
|
||||
██║╚██╗██║██║ ██║ ██╔══██╗██║ ██║██║ ██║
|
||||
██║ ╚████║╚██████╔╝ ██████╔╝╚██████╔╝╚██████╔╝
|
||||
╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚═════╝
|
||||
76
zt-gateway/src/main/resources/logback-spring.xml
Normal file
76
zt-gateway/src/main/resources/logback-spring.xml
Normal file
@@ -0,0 +1,76 @@
|
||||
<configuration>
|
||||
<!-- 引用 Spring Boot 的 logback 基础配置 -->
|
||||
<include resource="org/springframework/boot/logging/logback/defaults.xml" />
|
||||
<!-- 变量 cloud.info.base-package,基础业务包 -->
|
||||
<springProperty scope="context" name="cloud.info.base-package" source="cloud.info.base-package"/>
|
||||
<!-- 格式化输出:%d 表示日期,%X{tid} SkWalking 链路追踪编号,%thread 表示线程名,%-5level:级别从左显示 5 个字符宽度,%msg:日志消息,%n是换行符 -->
|
||||
<property name="PATTERN_DEFAULT" value="%d{${LOG_DATEFORMAT_PATTERN:-yyyy-MM-dd HH:mm:ss.SSS}} | %highlight(${LOG_LEVEL_PATTERN:-%5p} ${PID:- }) | %boldYellow(%thread [%tid]) %boldGreen(%-40.40logger{39}) | %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}"/>
|
||||
|
||||
<!-- 控制台 Appender -->
|
||||
<appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
|
||||
<encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
|
||||
<layout class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.TraceIdPatternLogbackLayout">
|
||||
<pattern>${PATTERN_DEFAULT}</pattern>
|
||||
</layout>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- 文件 Appender -->
|
||||
<!-- 参考 Spring Boot 的 file-appender.xml 编写 -->
|
||||
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||
<encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
|
||||
<layout class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.TraceIdPatternLogbackLayout">
|
||||
<pattern>${PATTERN_DEFAULT}</pattern>
|
||||
</layout>
|
||||
</encoder>
|
||||
<!-- 日志文件名 -->
|
||||
<file>${LOG_FILE}</file>
|
||||
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
|
||||
<!-- 滚动后的日志文件名 -->
|
||||
<fileNamePattern>${LOGBACK_ROLLINGPOLICY_FILE_NAME_PATTERN:-${LOG_FILE}.%d{yyyy-MM-dd}.%i.gz}</fileNamePattern>
|
||||
<!-- 启动服务时,是否清理历史日志,一般不建议清理 -->
|
||||
<cleanHistoryOnStart>${LOGBACK_ROLLINGPOLICY_CLEAN_HISTORY_ON_START:-false}</cleanHistoryOnStart>
|
||||
<!-- 日志文件,到达多少容量,进行滚动 -->
|
||||
<maxFileSize>${LOGBACK_ROLLINGPOLICY_MAX_FILE_SIZE:-10MB}</maxFileSize>
|
||||
<!-- 日志文件的总大小,0 表示不限制 -->
|
||||
<totalSizeCap>${LOGBACK_ROLLINGPOLICY_TOTAL_SIZE_CAP:-0}</totalSizeCap>
|
||||
<!-- 日志文件的保留天数 -->
|
||||
<maxHistory>${LOGBACK_ROLLINGPOLICY_MAX_HISTORY:-30}</maxHistory>
|
||||
</rollingPolicy>
|
||||
</appender>
|
||||
<!-- 异步写入日志,提升性能 -->
|
||||
<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
|
||||
<!-- 不丢失日志。默认的,如果队列的 80% 已满,则会丢弃 TRACT、DEBUG、INFO 级别的日志 -->
|
||||
<discardingThreshold>0</discardingThreshold>
|
||||
<!-- 更改默认的队列的深度,该值会影响性能。默认值为 256 -->
|
||||
<queueSize>256</queueSize>
|
||||
<appender-ref ref="FILE"/>
|
||||
</appender>
|
||||
|
||||
<!-- SkyWalking GRPC 日志收集,实现日志中心。注意:SkyWalking 8.4.0 版本开始支持 -->
|
||||
<appender name="GRPC" class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.log.GRPCLogClientAppender">
|
||||
<encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
|
||||
<layout class="org.apache.skywalking.apm.toolkit.log.logback.v1.x.TraceIdPatternLogbackLayout">
|
||||
<pattern>${PATTERN_DEFAULT}</pattern>
|
||||
</layout>
|
||||
</encoder>
|
||||
</appender>
|
||||
|
||||
<!-- 本地环境 -->
|
||||
<springProfile name="local">
|
||||
<root level="INFO">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
<appender-ref ref="GRPC"/> <!-- 本地环境下,如果不想接入 SkyWalking 日志服务,可以注释掉本行 -->
|
||||
<appender-ref ref="ASYNC"/> <!-- 本地环境下,如果不想打印日志,可以注释掉本行 -->
|
||||
</root>
|
||||
</springProfile>
|
||||
<!-- 其它环境 -->
|
||||
<springProfile name="dev,test,stage,prod,default">
|
||||
<root level="INFO">
|
||||
<appender-ref ref="STDOUT"/>
|
||||
<appender-ref ref="ASYNC"/>
|
||||
<appender-ref ref="GRPC"/>
|
||||
</root>
|
||||
</springProfile>
|
||||
|
||||
</configuration>
|
||||
BIN
zt-gateway/src/main/resources/static/favicon.ico
Normal file
BIN
zt-gateway/src/main/resources/static/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
146
zt-gateway/src/test/java/com/zt/plat/ProjectReactor.java
Normal file
146
zt-gateway/src/test/java/com/zt/plat/ProjectReactor.java
Normal file
@@ -0,0 +1,146 @@
|
||||
package com.zt.plat;
|
||||
|
||||
import cn.hutool.core.io.FileTypeUtil;
|
||||
import cn.hutool.core.io.FileUtil;
|
||||
import cn.hutool.core.util.StrUtil;
|
||||
import com.zt.plat.framework.common.util.collection.SetUtils;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.File;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Collection;
|
||||
import java.util.Set;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import static java.io.File.separator;
|
||||
|
||||
/**
|
||||
* 项目修改器,一键替换 Maven 的 groupId、artifactId,项目的 package 等
|
||||
* <p>
|
||||
* 通过修改 groupIdNew、artifactIdNew、projectBaseDirNew 三个变量
|
||||
*
|
||||
* @author ZT
|
||||
*/
|
||||
@Slf4j
|
||||
public class ProjectReactor {
|
||||
|
||||
private static final String GROUP_ID = "com.zt.plat";
|
||||
private static final String ARTIFACT_ID = "cloud";
|
||||
private static final String PACKAGE_NAME = "com.zt.plat";
|
||||
private static final String TITLE = "土豆管理系统";
|
||||
|
||||
/**
|
||||
* 白名单文件,不进行重写,避免出问题
|
||||
*/
|
||||
private static final Set<String> WHITE_FILE_TYPES = SetUtils.asSet("gif", "jpg", "svg", "png", // 图片
|
||||
"eot", "woff2", "ttf", "woff", // 字体
|
||||
"xdb"); // IP 库
|
||||
|
||||
public static void main(String[] args) {
|
||||
long start = System.currentTimeMillis();
|
||||
String projectBaseDir = getProjectBaseDir();
|
||||
log.info("[main][原项目路劲改地址 ({})]", projectBaseDir);
|
||||
|
||||
// ========== 配置,需要你手动修改 ==========
|
||||
String groupIdNew = "com.zt.plat";
|
||||
String artifactIdNew = "zt";
|
||||
String packageNameNew = "com.zt.plat";
|
||||
String titleNew = "土豆管理系统";
|
||||
String projectBaseDirNew = projectBaseDir + "-new"; // 一键改名后,“新”项目所在的目录
|
||||
log.info("[main][检测新项目目录 ({})是否存在]", projectBaseDirNew);
|
||||
if (FileUtil.exist(projectBaseDirNew)) {
|
||||
log.error("[main][新项目目录检测 ({})已存在,请更改新的目录!程序退出]", projectBaseDirNew);
|
||||
return;
|
||||
}
|
||||
// 如果新目录中存在 PACKAGE_NAME,ARTIFACT_ID 等关键字,路径会被替换,导致生成的文件不在预期目录
|
||||
if (StrUtil.containsAny(projectBaseDirNew, PACKAGE_NAME, ARTIFACT_ID, StrUtil.upperFirst(ARTIFACT_ID))) {
|
||||
log.error("[main][新项目目录 `projectBaseDirNew` 检测 ({}) 存在冲突名称「{}」或者「{}」,请更改新的目录!程序退出]",
|
||||
projectBaseDirNew, PACKAGE_NAME, ARTIFACT_ID);
|
||||
return;
|
||||
}
|
||||
log.info("[main][完成新项目目录检测,新项目路径地址 ({})]", projectBaseDirNew);
|
||||
// 获得需要复制的文件
|
||||
log.info("[main][开始获得需要重写的文件,预计需要 10-20 秒]");
|
||||
Collection<File> files = listFiles(projectBaseDir);
|
||||
log.info("[main][需要重写的文件数量:{},预计需要 15-30 秒]", files.size());
|
||||
// 写入文件
|
||||
files.forEach(file -> {
|
||||
// 如果是白名单的文件类型,不进行重写,直接拷贝
|
||||
String fileType = getFileType(file);
|
||||
if (WHITE_FILE_TYPES.contains(fileType)) {
|
||||
copyFile(file, projectBaseDir, projectBaseDirNew, packageNameNew, artifactIdNew);
|
||||
return;
|
||||
}
|
||||
// 如果非白名单的文件类型,重写内容,在生成文件
|
||||
String content = replaceFileContent(file, groupIdNew, artifactIdNew, packageNameNew, titleNew);
|
||||
writeFile(file, content, projectBaseDir, projectBaseDirNew, packageNameNew, artifactIdNew);
|
||||
});
|
||||
log.info("[main][重写完成]共耗时:{} 秒", (System.currentTimeMillis() - start) / 1000);
|
||||
}
|
||||
|
||||
private static String getProjectBaseDir() {
|
||||
String baseDir = System.getProperty("user.dir");
|
||||
if (StrUtil.isEmpty(baseDir)) {
|
||||
throw new NullPointerException("项目基础路径不存在");
|
||||
}
|
||||
return baseDir;
|
||||
}
|
||||
|
||||
private static Collection<File> listFiles(String projectBaseDir) {
|
||||
Collection<File> files = FileUtil.loopFiles(projectBaseDir);
|
||||
// 移除 IDEA、Git 自身的文件、Node 编译出来的文件
|
||||
files = files.stream()
|
||||
.filter(file -> !file.getPath().contains(separator + "target" + separator)
|
||||
&& !file.getPath().contains(separator + "node_modules" + separator)
|
||||
&& !file.getPath().contains(separator + ".idea" + separator)
|
||||
&& !file.getPath().contains(separator + ".git" + separator)
|
||||
&& !file.getPath().contains(separator + "dist" + separator)
|
||||
&& !file.getPath().contains(".iml")
|
||||
&& !file.getPath().contains(".html.gz"))
|
||||
.collect(Collectors.toList());
|
||||
return files;
|
||||
}
|
||||
|
||||
private static String replaceFileContent(File file, String groupIdNew,
|
||||
String artifactIdNew, String packageNameNew,
|
||||
String titleNew) {
|
||||
String content = FileUtil.readString(file, StandardCharsets.UTF_8);
|
||||
// 如果是白名单的文件类型,不进行重写
|
||||
String fileType = getFileType(file);
|
||||
if (WHITE_FILE_TYPES.contains(fileType)) {
|
||||
return content;
|
||||
}
|
||||
// 执行文件内容都重写
|
||||
return content.replaceAll(GROUP_ID, groupIdNew)
|
||||
.replaceAll(PACKAGE_NAME, packageNameNew)
|
||||
.replaceAll(ARTIFACT_ID, artifactIdNew) // 必须放在最后替换,因为 ARTIFACT_ID 太短!
|
||||
.replaceAll(StrUtil.upperFirst(ARTIFACT_ID), StrUtil.upperFirst(artifactIdNew))
|
||||
.replaceAll(TITLE, titleNew);
|
||||
}
|
||||
|
||||
private static void writeFile(File file, String fileContent, String projectBaseDir,
|
||||
String projectBaseDirNew, String packageNameNew, String artifactIdNew) {
|
||||
String newPath = buildNewFilePath(file, projectBaseDir, projectBaseDirNew, packageNameNew, artifactIdNew);
|
||||
FileUtil.writeUtf8String(fileContent, newPath);
|
||||
}
|
||||
|
||||
private static void copyFile(File file, String projectBaseDir,
|
||||
String projectBaseDirNew, String packageNameNew, String artifactIdNew) {
|
||||
String newPath = buildNewFilePath(file, projectBaseDir, projectBaseDirNew, packageNameNew, artifactIdNew);
|
||||
FileUtil.copyFile(file, new File(newPath));
|
||||
}
|
||||
|
||||
private static String buildNewFilePath(File file, String projectBaseDir,
|
||||
String projectBaseDirNew, String packageNameNew, String artifactIdNew) {
|
||||
return file.getPath().replace(projectBaseDir, projectBaseDirNew) // 新目录
|
||||
.replace(PACKAGE_NAME.replaceAll("\\.", Matcher.quoteReplacement(separator)),
|
||||
packageNameNew.replaceAll("\\.", Matcher.quoteReplacement(separator)))
|
||||
.replace(ARTIFACT_ID, artifactIdNew) //
|
||||
.replaceAll(StrUtil.upperFirst(ARTIFACT_ID), StrUtil.upperFirst(artifactIdNew));
|
||||
}
|
||||
|
||||
private static String getFileType(File file) {
|
||||
return file.length() > 0 ? FileTypeUtil.getType(file) : "";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user