commit
6fc6d9cea3
@ -1,34 +1,34 @@ |
|||||||
* [首页](/?t=1565767132305) |
* [首页](/?t=1565916799573) |
||||||
* 开发文档 |
* 开发文档 |
||||||
* [快速体验](files/10010_快速体验.md?t=1565767132307) |
* [快速体验](files/10010_快速体验.md?t=1565916799577) |
||||||
* [项目接入到SOP](files/10011_项目接入到SOP.md?t=1565767132325) |
* [项目接入到SOP](files/10011_项目接入到SOP.md?t=1565916799596) |
||||||
* [新增接口](files/10020_新增接口.md?t=1565767132325) |
* [新增接口](files/10020_新增接口.md?t=1565916799596) |
||||||
* [业务参数校验](files/10030_业务参数校验.md?t=1565767132325) |
* [业务参数校验](files/10030_业务参数校验.md?t=1565916799596) |
||||||
* [错误处理](files/10040_错误处理.md?t=1565767132325) |
* [错误处理](files/10040_错误处理.md?t=1565916799596) |
||||||
* [编写文档](files/10041_编写文档.md?t=1565767132326) |
* [编写文档](files/10041_编写文档.md?t=1565916799596) |
||||||
* [接口交互详解](files/10050_接口交互详解.md?t=1565767132326) |
* [接口交互详解](files/10050_接口交互详解.md?t=1565916799597) |
||||||
* [easyopen支持](files/10070_easyopen支持.md?t=1565767132326) |
* [easyopen支持](files/10070_easyopen支持.md?t=1565916799597) |
||||||
* [使用签名校验工具](files/10080_使用签名校验工具.md?t=1565767132326) |
* [使用签名校验工具](files/10080_使用签名校验工具.md?t=1565916799597) |
||||||
* [ISV管理](files/10085_ISV管理.md?t=1565767132326) |
* [ISV管理](files/10085_ISV管理.md?t=1565916799597) |
||||||
* [自定义路由](files/10086_自定义路由.md?t=1565767132326) |
* [自定义路由](files/10086_自定义路由.md?t=1565916799597) |
||||||
* [自定义返回结果](files/10087_自定义返回结果.md?t=1565767132326) |
* [自定义返回结果](files/10087_自定义返回结果.md?t=1565916799597) |
||||||
* [自定义过滤器](files/10088_自定义过滤器.md?t=1565767132326) |
* [自定义过滤器](files/10088_自定义过滤器.md?t=1565916799597) |
||||||
* [路由授权](files/10090_路由授权.md?t=1565767132326) |
* [路由授权](files/10090_路由授权.md?t=1565916799598) |
||||||
* [接口限流](files/10092_接口限流.md?t=1565767132326) |
* [接口限流](files/10092_接口限流.md?t=1565916799598) |
||||||
* [监控日志](files/10093_监控日志.md?t=1565767132327) |
* [监控日志](files/10093_监控日志.md?t=1565916799598) |
||||||
* [SDK开发](files/10095_SDK开发.md?t=1565767132327) |
* [SDK开发](files/10095_SDK开发.md?t=1565916799598) |
||||||
* [使用SpringCloudGateway](files/10096_使用SpringCloudGateway.md?t=1565767132327) |
* [使用SpringCloudGateway](files/10096_使用SpringCloudGateway.md?t=1565916799598) |
||||||
* [应用授权](files/10097_应用授权.md?t=1565767132327) |
* [应用授权](files/10097_应用授权.md?t=1565916799598) |
||||||
* [传统web开发](files/10100_传统web开发.md?t=1565767132327) |
* [传统web开发](files/10100_传统web开发.md?t=1565916799598) |
||||||
* [文件上传](files/10104_文件上传.md?t=1565767132327) |
* [文件上传](files/10104_文件上传.md?t=1565916799598) |
||||||
* [nacos注册中心](files/10106_nacos注册中心.md?t=1565767132327) |
* [nacos注册中心](files/10106_nacos注册中心.md?t=1565916799599) |
||||||
* [扩展其它注册中心](files/10107_扩展其它注册中心.md?t=1565767132327) |
* [扩展其它注册中心](files/10107_扩展其它注册中心.md?t=1565916799599) |
||||||
* [配置Sleuth链路追踪](files/10109_配置Sleuth链路追踪.md?t=1565767132327) |
* [配置Sleuth链路追踪](files/10109_配置Sleuth链路追踪.md?t=1565916799599) |
||||||
* [预发布灰度发布](files/10110_预发布灰度发布.md?t=1565767132327) |
* [预发布灰度发布](files/10110_预发布灰度发布.md?t=1565916799599) |
||||||
* [动态修改请求参数](files/10111_动态修改请求参数.md?t=1565767132327) |
* [动态修改请求参数](files/10111_动态修改请求参数.md?t=1565916799599) |
||||||
* 原理分析 |
* 原理分析 |
||||||
* [原理分析之@ApiMapping](files/90010_原理分析之@ApiMapping.md?t=1565767132328) |
* [原理分析之@ApiMapping](files/90010_原理分析之@ApiMapping.md?t=1565916799599) |
||||||
* [原理分析之路由存储](files/90011_原理分析之路由存储.md?t=1565767132328) |
* [原理分析之路由存储](files/90011_原理分析之路由存储.md?t=1565916799599) |
||||||
* [原理分析之如何路由](files/90012_原理分析之如何路由.md?t=1565767132328) |
* [原理分析之如何路由](files/90012_原理分析之如何路由.md?t=1565916799599) |
||||||
* [原理分析之文档归纳](files/90013_原理分析之文档归纳.md?t=1565767132328) |
* [原理分析之文档归纳](files/90013_原理分析之文档归纳.md?t=1565916799600) |
||||||
* [常见问题](files/90100_常见问题.md?t=1565767132328) |
* [常见问题](files/90100_常见问题.md?t=1565916799600) |
||||||
|
@ -0,0 +1,154 @@ |
|||||||
|
/* |
||||||
|
* Copyright 2013-2017 the original author or authors. |
||||||
|
* |
||||||
|
* Licensed under the Apache License, Version 2.0 (the "License"); |
||||||
|
* you may not use this file except in compliance with the License. |
||||||
|
* You may obtain a copy of the License at |
||||||
|
* |
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
* |
||||||
|
* Unless required by applicable law or agreed to in writing, software |
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS, |
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. |
||||||
|
* See the License for the specific language governing permissions and |
||||||
|
* limitations under the License. |
||||||
|
*/ |
||||||
|
|
||||||
|
package com.gitee.sop.gatewaycommon.gateway.common; |
||||||
|
|
||||||
|
import org.springframework.core.io.InputStreamResource; |
||||||
|
import org.springframework.http.HttpEntity; |
||||||
|
import org.springframework.http.HttpHeaders; |
||||||
|
import org.springframework.http.MediaType; |
||||||
|
import org.springframework.util.LinkedMultiValueMap; |
||||||
|
import org.springframework.util.MultiValueMap; |
||||||
|
import org.springframework.web.multipart.MultipartFile; |
||||||
|
import org.springframework.web.multipart.MultipartHttpServletRequest; |
||||||
|
import org.springframework.web.util.UriComponentsBuilder; |
||||||
|
|
||||||
|
import javax.servlet.http.HttpServletRequest; |
||||||
|
import java.io.IOException; |
||||||
|
import java.nio.charset.Charset; |
||||||
|
import java.util.HashSet; |
||||||
|
import java.util.List; |
||||||
|
import java.util.Map; |
||||||
|
import java.util.Map.Entry; |
||||||
|
import java.util.Set; |
||||||
|
import java.util.stream.Collectors; |
||||||
|
|
||||||
|
import static java.util.Arrays.stream; |
||||||
|
import static java.util.Collections.emptyMap; |
||||||
|
import static org.springframework.util.StringUtils.isEmpty; |
||||||
|
import static org.springframework.util.StringUtils.tokenizeToStringArray; |
||||||
|
import static org.springframework.util.StringUtils.uriDecode; |
||||||
|
|
||||||
|
public class RequestContentDataExtractor { |
||||||
|
public static MultiValueMap<String, Object> extract(HttpServletRequest request) throws IOException { |
||||||
|
return (request instanceof MultipartHttpServletRequest) ? |
||||||
|
extractFromMultipartRequest((MultipartHttpServletRequest) request) : |
||||||
|
extractFromRequest(request); |
||||||
|
} |
||||||
|
|
||||||
|
private static MultiValueMap<String, Object> extractFromRequest(HttpServletRequest request) throws IOException { |
||||||
|
MultiValueMap<String, Object> builder = new LinkedMultiValueMap<>(); |
||||||
|
Set<String> queryParams = findQueryParams(request); |
||||||
|
|
||||||
|
for (Entry<String, String[]> entry : request.getParameterMap().entrySet()) { |
||||||
|
String key = entry.getKey(); |
||||||
|
|
||||||
|
if (!queryParams.contains(key) && entry.getValue() != null) { |
||||||
|
for (String value : entry.getValue()) { |
||||||
|
builder.add(key, value); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return builder; |
||||||
|
} |
||||||
|
|
||||||
|
private static MultiValueMap<String, Object> extractFromMultipartRequest(MultipartHttpServletRequest request) |
||||||
|
throws IOException { |
||||||
|
MultiValueMap<String, Object> builder = new LinkedMultiValueMap<>(); |
||||||
|
Map<String, List<String>> queryParamsGroupedByName = findQueryParamsGroupedByName( |
||||||
|
request); |
||||||
|
Set<String> queryParams = findQueryParams(request); |
||||||
|
|
||||||
|
for (Entry<String, String[]> entry : request.getParameterMap().entrySet()) { |
||||||
|
String key = entry.getKey(); |
||||||
|
List<String> listOfAllParams = stream(request.getParameterMap().get(key)) |
||||||
|
.collect(Collectors.toList()); |
||||||
|
List<String> listOfOnlyQueryParams = queryParamsGroupedByName.get(key); |
||||||
|
|
||||||
|
if(listOfOnlyQueryParams != null) { |
||||||
|
listOfOnlyQueryParams = listOfOnlyQueryParams.stream() |
||||||
|
.map(param -> uriDecode(param, Charset.defaultCharset())) |
||||||
|
.collect(Collectors.toList()); |
||||||
|
if (!listOfOnlyQueryParams.containsAll(listOfAllParams)) { |
||||||
|
listOfAllParams.removeAll(listOfOnlyQueryParams); |
||||||
|
for (String value : listOfAllParams) { |
||||||
|
builder.add(key, |
||||||
|
new HttpEntity<>(value, newHttpHeaders(request, key))); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if (!queryParams.contains(key)) { |
||||||
|
for (String value : entry.getValue()) { |
||||||
|
builder.add(key, |
||||||
|
new HttpEntity<>(value, newHttpHeaders(request, key))); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
for (Entry<String, List<MultipartFile>> parts : request.getMultiFileMap().entrySet()) { |
||||||
|
for (MultipartFile file : parts.getValue()) { |
||||||
|
HttpHeaders headers = new HttpHeaders(); |
||||||
|
headers.setContentDispositionFormData(file.getName(), file.getOriginalFilename()); |
||||||
|
if (file.getContentType() != null) { |
||||||
|
headers.setContentType(MediaType.valueOf(file.getContentType())); |
||||||
|
} |
||||||
|
|
||||||
|
HttpEntity entity = new HttpEntity<>(new InputStreamResource(file.getInputStream()), headers); |
||||||
|
builder.add(parts.getKey(), entity); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return builder; |
||||||
|
} |
||||||
|
|
||||||
|
private static HttpHeaders newHttpHeaders(MultipartHttpServletRequest request, |
||||||
|
String key) { |
||||||
|
HttpHeaders headers = new HttpHeaders(); |
||||||
|
String type = request.getMultipartContentType(key); |
||||||
|
|
||||||
|
if (type != null) { |
||||||
|
headers.setContentType(MediaType.valueOf(type)); |
||||||
|
} |
||||||
|
return headers; |
||||||
|
} |
||||||
|
|
||||||
|
private static Set<String> findQueryParams(HttpServletRequest request) { |
||||||
|
Set<String> result = new HashSet<>(); |
||||||
|
String query = request.getQueryString(); |
||||||
|
|
||||||
|
if (query != null) { |
||||||
|
for (String value : tokenizeToStringArray(query, "&")) { |
||||||
|
if (value.contains("=")) { |
||||||
|
value = value.substring(0, value.indexOf("=")); |
||||||
|
} |
||||||
|
result.add(value); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
static Map<String, List<String>> findQueryParamsGroupedByName( |
||||||
|
HttpServletRequest request) { |
||||||
|
String query = request.getQueryString(); |
||||||
|
if (isEmpty(query)) { |
||||||
|
return emptyMap(); |
||||||
|
} |
||||||
|
return UriComponentsBuilder.fromUriString("?" + query).build().getQueryParams(); |
||||||
|
} |
||||||
|
} |
@ -1,20 +0,0 @@ |
|||||||
package com.gitee.sop.gatewaycommon.gateway.common; |
|
||||||
|
|
||||||
import lombok.extern.slf4j.Slf4j; |
|
||||||
import org.springframework.web.server.ServerWebExchange; |
|
||||||
|
|
||||||
import javax.servlet.http.HttpServletRequest; |
|
||||||
import java.nio.charset.StandardCharsets; |
|
||||||
|
|
||||||
/** |
|
||||||
* @author tanghc |
|
||||||
*/ |
|
||||||
@Slf4j |
|
||||||
public class ServletFileUploadUtil { |
|
||||||
|
|
||||||
public static HttpServletRequest getFileUploadRequest(ServerWebExchange exchange, String requestBody) { |
|
||||||
byte[] data = requestBody.getBytes(StandardCharsets.UTF_8); |
|
||||||
return new FileUploadHttpServletRequest(exchange.getRequest(), data); |
|
||||||
} |
|
||||||
|
|
||||||
} |
|
@ -0,0 +1,102 @@ |
|||||||
|
package com.gitee.sop.gatewaycommon.gateway.common; |
||||||
|
|
||||||
|
import io.netty.buffer.ByteBufAllocator; |
||||||
|
import org.springframework.core.io.buffer.DataBuffer; |
||||||
|
import org.springframework.core.io.buffer.NettyDataBufferFactory; |
||||||
|
import org.springframework.http.HttpHeaders; |
||||||
|
import org.springframework.http.HttpMethod; |
||||||
|
import org.springframework.http.server.reactive.ServerHttpRequest; |
||||||
|
import org.springframework.http.server.reactive.ServerHttpRequestDecorator; |
||||||
|
import org.springframework.web.util.UriComponentsBuilder; |
||||||
|
import reactor.core.publisher.Flux; |
||||||
|
|
||||||
|
import java.net.URI; |
||||||
|
|
||||||
|
/** |
||||||
|
* @author tanghc |
||||||
|
*/ |
||||||
|
public class SopServerHttpRequestDecorator extends ServerHttpRequestDecorator { |
||||||
|
|
||||||
|
private Flux<DataBuffer> bodyDataBuffer; |
||||||
|
private HttpHeaders httpHeaders; |
||||||
|
private URI uri; |
||||||
|
|
||||||
|
/** |
||||||
|
* ServerHttpRequest包装,作用类似于HttpServletRequestWrapper |
||||||
|
* @param delegate 老的request |
||||||
|
* @param queryString get请求后面的参数 |
||||||
|
*/ |
||||||
|
public SopServerHttpRequestDecorator(ServerHttpRequest delegate, HttpHeaders newHeaders, String queryString) { |
||||||
|
super(delegate); |
||||||
|
if (delegate.getMethod() != HttpMethod.GET) { |
||||||
|
throw new IllegalArgumentException("this constructor must be used by GET request."); |
||||||
|
} |
||||||
|
if (queryString == null) { |
||||||
|
throw new IllegalArgumentException("queryString can not be null."); |
||||||
|
} |
||||||
|
if (newHeaders == null) { |
||||||
|
throw new IllegalArgumentException("newHeaders can not be null."); |
||||||
|
} |
||||||
|
|
||||||
|
this.httpHeaders = newHeaders; |
||||||
|
this.uri = UriComponentsBuilder.fromUri(delegate.getURI()) |
||||||
|
.replaceQuery(queryString) |
||||||
|
.build(true) |
||||||
|
.toUri(); |
||||||
|
} |
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
/** |
||||||
|
* ServerHttpRequest包装,作用类似于HttpServletRequestWrapper |
||||||
|
* @param delegate 老的request |
||||||
|
* @param newHeaders 新的headers |
||||||
|
* @param bodyData 请求体内容 |
||||||
|
*/ |
||||||
|
public SopServerHttpRequestDecorator(ServerHttpRequest delegate, HttpHeaders newHeaders, byte[] bodyData) { |
||||||
|
// 将请求体再次封装写回到request里,传到下一级,否则,由于请求体已被消费,后续的服务将取不到值
|
||||||
|
super(delegate); |
||||||
|
if (bodyData == null) { |
||||||
|
throw new IllegalArgumentException("bodyData can not be null."); |
||||||
|
} |
||||||
|
if (newHeaders == null) { |
||||||
|
throw new IllegalArgumentException("newHeaders can not be null."); |
||||||
|
} |
||||||
|
this.httpHeaders = newHeaders; |
||||||
|
// 由于请求体已改变,这里要重新设置contentLength
|
||||||
|
int contentLength = bodyData.length; |
||||||
|
httpHeaders.setContentLength(contentLength); |
||||||
|
if (contentLength <= 0) { |
||||||
|
// TODO: this causes a 'HTTP/1.1 411 Length Required' on httpbin.org
|
||||||
|
httpHeaders.set(HttpHeaders.TRANSFER_ENCODING, "chunked"); |
||||||
|
} |
||||||
|
this.bodyDataBuffer = stringBuffer(bodyData); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 字符串转DataBuffer |
||||||
|
* @param bytes 请求体 |
||||||
|
* @return 返回buffer |
||||||
|
*/ |
||||||
|
private static Flux<DataBuffer> stringBuffer(byte[] bytes) { |
||||||
|
NettyDataBufferFactory nettyDataBufferFactory = new NettyDataBufferFactory(ByteBufAllocator.DEFAULT); |
||||||
|
DataBuffer buffer = nettyDataBufferFactory.allocateBuffer(bytes.length); |
||||||
|
buffer.write(bytes); |
||||||
|
return Flux.just(buffer); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public URI getURI() { |
||||||
|
return uri == null ? super.getURI() : uri; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public HttpHeaders getHeaders() { |
||||||
|
return this.httpHeaders; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public Flux<DataBuffer> getBody() { |
||||||
|
return bodyDataBuffer == null ? super.getBody() : bodyDataBuffer; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,51 @@ |
|||||||
|
package com.gitee.sop.gatewaycommon.gateway.filter; |
||||||
|
|
||||||
|
import com.gitee.sop.gatewaycommon.gateway.ServerWebExchangeUtil; |
||||||
|
import com.gitee.sop.gatewaycommon.param.ApiParam; |
||||||
|
import com.gitee.sop.gatewaycommon.param.ParamNames; |
||||||
|
import com.gitee.sop.gatewaycommon.param.ParameterFormatter; |
||||||
|
import lombok.extern.slf4j.Slf4j; |
||||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||||
|
import org.springframework.cloud.gateway.filter.GatewayFilterChain; |
||||||
|
import org.springframework.cloud.gateway.filter.GlobalFilter; |
||||||
|
import org.springframework.core.Ordered; |
||||||
|
import org.springframework.web.server.ServerWebExchange; |
||||||
|
import reactor.core.publisher.Mono; |
||||||
|
|
||||||
|
import static com.gitee.sop.gatewaycommon.gateway.filter.Orders.PARAMETER_FORMATTER_FILTER_ORDER; |
||||||
|
|
||||||
|
/** |
||||||
|
* @author tanghc |
||||||
|
*/ |
||||||
|
@Slf4j |
||||||
|
public class ParameterFormatterFilter implements GlobalFilter, Ordered { |
||||||
|
|
||||||
|
@Autowired(required = false) |
||||||
|
private ParameterFormatter sopParameterFormatter; |
||||||
|
|
||||||
|
@Override |
||||||
|
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { |
||||||
|
ApiParam apiParam = ServerWebExchangeUtil.getApiParam(exchange); |
||||||
|
if (apiParam == null) { |
||||||
|
return chain.filter(exchange); |
||||||
|
} |
||||||
|
// 校验成功后进行参数转换
|
||||||
|
if (sopParameterFormatter != null) { |
||||||
|
ServerWebExchange formatExchange = ServerWebExchangeUtil.format( |
||||||
|
exchange |
||||||
|
, apiParam |
||||||
|
, sopParameterFormatter::format |
||||||
|
, httpHeaders -> httpHeaders.set(ParamNames.HEADER_VERSION_NAME, apiParam.fetchVersion())); |
||||||
|
if (formatExchange == null) { |
||||||
|
return chain.filter(exchange); |
||||||
|
} |
||||||
|
return chain.filter(formatExchange); |
||||||
|
} |
||||||
|
return chain.filter(exchange); |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public int getOrder() { |
||||||
|
return PARAMETER_FORMATTER_FILTER_ORDER; |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,33 @@ |
|||||||
|
package com.gitee.sop.gatewaycommon.param; |
||||||
|
|
||||||
|
import org.springframework.http.HttpHeaders; |
||||||
|
import org.springframework.http.HttpOutputMessage; |
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream; |
||||||
|
import java.io.IOException; |
||||||
|
import java.io.OutputStream; |
||||||
|
|
||||||
|
/** |
||||||
|
* @author tanghc |
||||||
|
*/ |
||||||
|
public class FormHttpOutputMessage implements HttpOutputMessage { |
||||||
|
|
||||||
|
private HttpHeaders headers = new HttpHeaders(); |
||||||
|
private ByteArrayOutputStream output = new ByteArrayOutputStream(); |
||||||
|
|
||||||
|
@Override |
||||||
|
public HttpHeaders getHeaders() { |
||||||
|
return this.headers; |
||||||
|
} |
||||||
|
|
||||||
|
@Override |
||||||
|
public OutputStream getBody() throws IOException { |
||||||
|
return this.output; |
||||||
|
} |
||||||
|
|
||||||
|
public byte[] getInput() throws IOException { |
||||||
|
this.output.flush(); |
||||||
|
return this.output.toByteArray(); |
||||||
|
} |
||||||
|
|
||||||
|
} |
@ -0,0 +1,10 @@ |
|||||||
|
package com.gitee.sop.gatewaycommon.param; |
||||||
|
|
||||||
|
import com.alibaba.fastjson.JSONObject; |
||||||
|
import com.gitee.sop.gatewaycommon.manager.Formatter; |
||||||
|
|
||||||
|
/** |
||||||
|
* @author tanghc |
||||||
|
*/ |
||||||
|
public interface ParameterFormatter extends Formatter<JSONObject> { |
||||||
|
} |
@ -1,10 +0,0 @@ |
|||||||
package com.gitee.sop.gatewaycommon.zuul.param; |
|
||||||
|
|
||||||
import com.alibaba.fastjson.JSONObject; |
|
||||||
import com.gitee.sop.gatewaycommon.manager.ParameterFormatter; |
|
||||||
|
|
||||||
/** |
|
||||||
* @author tanghc |
|
||||||
*/ |
|
||||||
public interface ZuulParameterFormatter extends ParameterFormatter<JSONObject> { |
|
||||||
} |
|
Loading…
Reference in new issue