资源代理

This commit is contained in:
iqudoo
2026-06-07 22:15:22 +08:00
parent e8c4d02af2
commit 145d0c9428
2 changed files with 128 additions and 286 deletions

View File

@@ -3,6 +3,7 @@ package com.iqudoo.platform.application.controller;
import com.alibaba.excel.util.StringUtils;
import com.iqudoo.framework.tape.Tape;
import com.iqudoo.framework.tape.modules.IModuleSetting;
import com.iqudoo.framework.tape.modules.utils.DigestUtil;
import com.iqudoo.platform.application.setting.SystemGlobalHttpProxyConfig;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.core.io.Resource;
@@ -21,7 +22,6 @@ import reactor.core.publisher.Mono;
import javax.servlet.http.HttpServletRequest;
import java.net.URI;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.function.BiConsumer;
@@ -60,7 +60,7 @@ public class HttpProxyController {
/**
* 通用代理接口(基于目录路由)
*/
@RequestMapping(value = {"/domain-proxy/", "/domain-proxy/{proxyDir:.*}/**"}, method = {
@RequestMapping(value = {"/http-proxy/", "/http-proxy/{proxyDir:.*}/**"}, method = {
RequestMethod.GET,
RequestMethod.POST,
RequestMethod.PUT,
@@ -76,34 +76,27 @@ public class HttpProxyController {
.getSetting(SystemGlobalHttpProxyConfig.class);
HttpHeaders headers = new HttpHeaders();
String requestUri = servletRequest.getRequestURI();
String proxyPath = requestUri.replaceFirst("^/domain-proxy(/[^/]+)?", "");
String proxyPath = requestUri.replaceFirst("^/http-proxy(/[^/]+)?", "");
if (proxyPath.startsWith("//")) {
proxyPath = proxyPath.substring(1);
}
// 匹配路由配置
String proxyConfig = "";
String proxyTargetUrl = null;
String proxyDomainUrl = null;
List<String> replaceList = new ArrayList<>();
Map<String, String> replaceMap = new HashMap<>();
if (StringUtils.isNotBlank(httpProxyConfig.getDomainProxyRouterMap())) {
String[] domainProxyConfig = httpProxyConfig.getDomainProxyRouterMap().split("\n");
for (String domainProxy : domainProxyConfig) {
try {
Map<String, String> folderMap = parseKeyValue(domainProxy);
if (folderMap.containsKey("key") && Objects.equals(folderMap.get("key"), proxyDir)) {
String[] routers = folderMap.get("value").trim().split("#");
if (routers.length > 1) {
replaceList.addAll(Arrays.asList(routers).subList(1, routers.length));
}
proxyDomainUrl = routers[0];
if (proxyDomainUrl.endsWith("/")) {
proxyDomainUrl = proxyDomainUrl.substring(0, proxyDomainUrl.length() - 1);
}
proxyTargetUrl = proxyDomainUrl + (proxyPath.isEmpty() ? ""
: (proxyPath.startsWith("/") ? proxyPath : "/" + proxyPath));
ProxyRouteConfig routeConfig = ProxyRouteConfig.parse(domainProxy);
// 检查是否匹配当前请求
if (routeConfig.getRoute().equals(proxyDir)) {
proxyConfig = domainProxy;
proxyDomainUrl = routeConfig.getDomainUrl();
proxyTargetUrl = routeConfig.getFullTargetUrl(proxyPath);
replaceMap.putAll(routeConfig.getReplaceMap());
break;
}
} catch (Throwable ignored) {
}
}
}
// 校验目标地址
@@ -119,6 +112,8 @@ public class HttpProxyController {
}
// 构建请求头
copyRequestHeaders(servletRequest, headers);
// 根据代理配置判断是否禁用本次缓存
validateETagWithCacheVersion(servletRequest, headers, proxyConfig);
// 处理请求方法和请求体
HttpMethod method = HttpMethod.valueOf(servletRequest.getMethod());
BodyInserter<?, ? super ClientHttpRequest> bodyInserter = buildBodyInserter(body, servletRequest);
@@ -221,7 +216,7 @@ public class HttpProxyController {
public Mono<ResponseEntity<?>> apply(List<DataBuffer> dataBuffers) {
try {
byte[] responseBytes = mergeDataBuffers(dataBuffers);
return handleResponseContent(responseBytes, clientResponse, finalProxyDomainUrl, proxyDir, finalProxyTargetUrl, replaceList);
return handleResponseContent(responseBytes, clientResponse, finalProxyDomainUrl, proxyDir, finalProxyTargetUrl, replaceMap);
} catch (Exception e) {
HttpHeaders errorHeaders = new HttpHeaders();
errorHeaders.setContentType(new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8));
@@ -269,172 +264,12 @@ public class HttpProxyController {
}
}
/**
* 通用代理接口直接指定目标URL
*/
@RequestMapping(value = "/http-proxy", method = {
RequestMethod.GET,
RequestMethod.POST,
RequestMethod.PUT,
RequestMethod.DELETE,
RequestMethod.PATCH
})
public Mono<ResponseEntity<?>> httpProxy(
@RequestParam(required = false) String targetUrl,
@RequestBody(required = false) Object body,
HttpServletRequest servletRequest) {
String proxyTargetUrl = encodeUrlQueryParams(targetUrl);
if (StringUtils.isBlank(proxyTargetUrl)) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8));
String errorMsg = "{\"code\":400,\"msg\": \"目标地址不能为空\"}";
return Mono.just(new ResponseEntity<>(errorMsg, headers, HttpStatus.BAD_REQUEST));
}
HttpHeaders headers = new HttpHeaders();
copyRequestHeaders(servletRequest, headers);
HttpMethod method = HttpMethod.valueOf(servletRequest.getMethod());
BodyInserter<?, ? super ClientHttpRequest> bodyInserter = buildBodyInserter(body, servletRequest);
return webClient.method(method)
.uri(URI.create(proxyTargetUrl))
.headers(new Consumer<HttpHeaders>() {
@Override
public void accept(HttpHeaders httpHeaders) {
httpHeaders.addAll(headers);
}
})
.body(bodyInserter)
.exchange()
.flatMap(new Function<ClientResponse, Mono<ResponseEntity<?>>>() {
@Override
public Mono<ResponseEntity<?>> apply(ClientResponse clientResponse) {
try {
// 先判断是否为二进制请求,避免不必要的收集
String targetUrlLower = proxyTargetUrl.toLowerCase();
boolean isLikelyBinary = false;
try {
isLikelyBinary = isLikelyBinaryRequest(targetUrlLower, clientResponse);
} catch (Exception e) {
// 判断失败时,默认按文本处理
}
if (isLikelyBinary) {
// 二进制请求:收集数据后返回 Resource
return clientResponse.bodyToFlux(DataBuffer.class)
.collectList()
.flatMap(new Function<List<DataBuffer>, Mono<ResponseEntity<?>>>() {
@Override
public Mono<ResponseEntity<?>> apply(List<DataBuffer> dataBuffers) {
try {
byte[] responseBytes = mergeDataBuffers(dataBuffers);
// 构建响应头
HttpHeaders responseHeaders = new HttpHeaders();
HttpHeaders clientHeaders = clientResponse.headers().asHttpHeaders();
// 先检查原始响应头的 Content-Type
MediaType originalContentType = clientHeaders.getContentType();
boolean hasBinaryContentType = isBinaryContentType(originalContentType);
// 复制响应头,但排除可能冲突的
clientHeaders.forEach(new HttpHeadersBiConsumer(responseHeaders));
// 处理 Content-Type既然进入了二进制分支说明应该是二进制内容
if (hasBinaryContentType) {
// 如果原始响应头已经是二进制类型,使用它
responseHeaders.setContentType(originalContentType);
} else {
// 如果原始响应头不是二进制类型(比如 text/html尝试从 URI 自动检测
MediaType contentType = getContentTypeFromUri(targetUrlLower);
if (contentType != null) {
// 从 URI 检测到了类型,设置它
responseHeaders.setContentType(contentType);
}
// 如果检测不到,不设置 content-type已经在上面的复制时排除了
}
// 设置 Content-Length
responseHeaders.setContentLength(responseBytes.length);
// 使用 ByteArrayResource 包装
Resource resource = new ByteArrayResource(responseBytes) {
@Override
public String getFilename() {
String filename = targetUrlLower;
int lastSlash = filename.lastIndexOf('/');
if (lastSlash >= 0 && lastSlash < filename.length() - 1) {
filename = filename.substring(lastSlash + 1);
}
int queryIndex = filename.indexOf('?');
if (queryIndex > 0) {
filename = filename.substring(0, queryIndex);
}
return filename.isEmpty() ? "binary" : filename;
}
};
return Mono.just(new ResponseEntity<>(resource, responseHeaders, clientResponse.statusCode()));
} catch (Exception e) {
HttpHeaders errorHeaders = new HttpHeaders();
errorHeaders.setContentType(new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8));
String errorMsg = "{\"code\":500,\"msg\": \"处理二进制响应失败: " + e.getMessage().replace("\"", "\\\"") + "\"}";
return Mono.just(new ResponseEntity<>(errorMsg, errorHeaders, HttpStatus.INTERNAL_SERVER_ERROR));
}
}
});
} else {
// 文本请求:收集后处理
return clientResponse.bodyToFlux(DataBuffer.class)
.collectList()
.flatMap(new Function<List<DataBuffer>, Mono<ResponseEntity<?>>>() {
@Override
public Mono<ResponseEntity<?>> apply(List<DataBuffer> dataBuffers) {
try {
byte[] responseBytes = mergeDataBuffers(dataBuffers);
return handleResponseContent(responseBytes, clientResponse, null, null, proxyTargetUrl, new ArrayList<>());
} catch (Exception e) {
HttpHeaders errorHeaders = new HttpHeaders();
errorHeaders.setContentType(new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8));
String errorMsg = "{\"code\":500,\"msg\": \"处理响应内容失败: " + e.getMessage().replace("\"", "\\\"") + "\"}";
return Mono.just(new ResponseEntity<>(errorMsg, errorHeaders, HttpStatus.INTERNAL_SERVER_ERROR));
}
}
});
}
} catch (Exception e) {
HttpHeaders errorHeaders = new HttpHeaders();
errorHeaders.setContentType(new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8));
String errorMsg = "{\"code\":500,\"msg\": \"处理响应失败: " + e.getMessage().replace("\"", "\\\"") + "\"}";
return Mono.just(new ResponseEntity<>(errorMsg, errorHeaders, HttpStatus.INTERNAL_SERVER_ERROR));
}
}
})
.onErrorResume(WebClientResponseException.class, new Function<WebClientResponseException, Mono<ResponseEntity<?>>>() {
@Override
public Mono<ResponseEntity<?>> apply(WebClientResponseException e) {
HttpHeaders errorHeaders = new HttpHeaders();
errorHeaders.setContentType(new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8));
String errorMsg = String.format("{\"code\":%d,\"msg\": \"Proxy request failure: %s\"}",
e.getRawStatusCode(), e.getMessage());
return Mono.just(new ResponseEntity<>(errorMsg, errorHeaders, HttpStatus.valueOf(e.getRawStatusCode())));
}
})
.onErrorResume(new Function<Throwable, Mono<ResponseEntity<?>>>() {
@Override
public Mono<ResponseEntity<?>> apply(Throwable e) {
HttpHeaders errorHeaders = new HttpHeaders();
errorHeaders.setContentType(new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8));
String errorMsg = "{\"code\":500,\"msg\": \"Proxy request failure: " +
e.getMessage().replace("\"", "\\\"") + "\"}";
return Mono.just(new ResponseEntity<>(errorMsg, errorHeaders, HttpStatus.INTERNAL_SERVER_ERROR));
}
});
}
/**
* 核心修复:精准域名替换 + 保护HTML标签 + 统一响应格式
*/
private Mono<ResponseEntity<?>> handleResponseContent(byte[] responseBytes, ClientResponse response,
String domainUrl, String proxyDir, String proxyTargetUrl, List<String> replaceList) {
String domainUrl, String proxyDir, String proxyTargetUrl,
Map<String, String> replaceMap) {
HttpHeaders responseHeaders = new HttpHeaders();
HttpHeaders clientHeaders = response.headers().asHttpHeaders();
HttpStatus statusCode = response.statusCode();
@@ -528,7 +363,7 @@ public class HttpProxyController {
// 仅对文本类资源进行替换
if (needReplace) {
processedContent = replaceDomainPrecisely(processedContent, domainUrl, proxyDir, replaceList);
processedContent = replaceDomainPrecisely(processedContent, domainUrl, proxyDir, replaceMap);
}
}
@@ -583,16 +418,16 @@ public class HttpProxyController {
/**
* 精准域名替换
*/
private String replaceDomainPrecisely(String content, String originalDomain, String proxyDir, List<String> replaceList) {
private String replaceDomainPrecisely(String content, String originalDomain,
String proxyDir, Map<String, String> replaceMap) {
if (StringUtils.isBlank(content) || StringUtils.isBlank(originalDomain) || StringUtils.isBlank(proxyDir)) {
return content;
}
String proxyPath = "/domain-proxy/" + proxyDir;
String escapedDomain = Pattern.quote(originalDomain);
for (String str : replaceList) {
content = content.replaceAll(str, proxyPath + str);
for (Map.Entry<String, String> stringEntry : replaceMap.entrySet()) {
content = content.replaceAll(stringEntry.getKey(), stringEntry.getValue());
}
return content.replaceAll(escapedDomain, proxyPath);
return content.replaceAll(escapedDomain, "/http-proxy/" + proxyDir);
}
/**
@@ -729,6 +564,24 @@ public class HttpProxyController {
}
}
/**
* 校验ETag是否匹配缓存版本号
*/
private void validateETagWithCacheVersion(HttpServletRequest servletRequest, HttpHeaders requestHeaders, String proxyConfig) {
String eTag = servletRequest.getHeader("If-None-Match");
if (eTag != null) {
String[] eTagArr = eTag.split(";cache-version=");
String cacheHash = DigestUtil.md5DigestAsHex(proxyConfig);
if (eTagArr.length == 2 && Objects.equals(eTagArr[1], "\"" + cacheHash + "\"")) {
requestHeaders.put("If-None-Match", Collections.singletonList(eTagArr[0]));
} else {
requestHeaders.remove("If-None-Match");
requestHeaders.remove("If-Modified-Since");
}
}
}
/**
* 构建请求体
*/
@@ -757,25 +610,6 @@ public class HttpProxyController {
}
}
/**
* 解析路由配置的键值对
*/
public static Map<String, String> parseKeyValue(String input) {
Map<String, String> result = new HashMap<>();
if (StringUtils.isBlank(input)) {
return result;
}
Pattern pattern = Pattern.compile("\\[(.*?)](.*)");
Matcher matcher = pattern.matcher(input.trim());
if (matcher.find()) {
result.put("key", matcher.group(1).trim());
result.put("value", matcher.group(2).trim());
}
return result;
}
// 魔数 -> MediaType 映射表覆盖常见文件格式的标准MIME类型
private static final Map<byte[], MediaType> MAGIC_NUMBER_MEDIA_TYPE_MAP = new HashMap<>();
@@ -880,87 +714,95 @@ public class HttpProxyController {
}
/**
* 对URL中的QueryString参数值进行URL编码UTF-8
*
* @param originalUrl 原始URL包含中文等特殊字符的QueryString
* @return 编码后的完整URL
* 代理路由配置类
* 格式:[key]http://domain.com{/api,/http-proxy/api}{value1,key1}
* 说明:{path1,path2} 中的每对都是替换映射,即 path1 会被替换成 path2
* {value1,key1} 也是替换映射,即 value1 会被替换成 key1
*/
public static String encodeUrlQueryParams(String originalUrl) {
try {
// 空值校验
if (originalUrl == null || originalUrl.isEmpty()) {
return originalUrl;
@SuppressWarnings({"JavadocLinkAsPlainText", "LombokGetterMayBeUsed", "RegExpRedundantEscape"})
public static class ProxyRouteConfig {
private String route; // 路由键,如 pet
private String proxyUrl; // 基础URL如 http://pet.iqudoo.com
private final Map<String, String> replaceMap = new HashMap<>(); // 替换映射表
/**
* 解析路由配置
*
* @param input 配置行,如 [pet]http://pet.iqudoo.com{/api,/http-proxy/api}{value1,key1}
* @return 解析后的路由配置对象
*/
public static ProxyRouteConfig parse(String input) {
ProxyRouteConfig config = new ProxyRouteConfig();
if (StringUtils.isBlank(input)) {
return config;
}
// 解析URL为URI对象方便拆分各部分
URI uri = new URI(originalUrl);
String scheme = uri.getScheme(); // http/https等
String host = uri.getHost(); // 域名
int port = uri.getPort(); // 端口
String path = uri.getPath(); // 路径
String query = uri.getQuery(); // QueryString部分
String fragment = uri.getFragment(); // 锚点部分(#后面的内容)
// 如果没有QueryString直接返回原URL
if (query == null || query.isEmpty()) {
return originalUrl;
// 正则匹配:[key]http://domain.com{/api,/http-proxy/api}{value1,key1}
Pattern pattern = Pattern.compile("\\[(.*?)]\\s*([^{]+)\\s*(?:\\{([^}]*)\\})?\\s*(?:\\{([^}]*)\\})?");
Matcher matcher = pattern.matcher(input.trim());
if (matcher.find()) {
config.route = matcher.group(1).trim();
config.proxyUrl = matcher.group(2).trim();
// 去除baseUrl末尾的斜杠
if (config.proxyUrl.endsWith("/")) {
config.proxyUrl = config.proxyUrl.substring(0, config.proxyUrl.length() - 1);
}
// 解析并编码QueryString参数
String encodedQuery = encodeQueryParams(query);
// 重新构建URI
URI encodedUri = new URI(
scheme,
null, // userInfo
host,
port,
path,
encodedQuery,
fragment
);
return encodedUri.toString();
} catch (Throwable throwable) {
return originalUrl;
// 解析第一个花括号 {} 中的内容(路径映射对)
String firstPairStr = matcher.group(3);
if (StringUtils.isNotBlank(firstPairStr)) {
parseAndPutToMap(firstPairStr, config.replaceMap);
}
// 解析第二个花括号 {} 中的内容(替换映射对)
String secondPairStr = matcher.group(4);
if (StringUtils.isNotBlank(secondPairStr)) {
parseAndPutToMap(secondPairStr, config.replaceMap);
}
}
return config;
}
/**
* 解析并编码QueryString参数保留参数名仅编码参数值
*
* @param queryString 原始QueryStringtext=你好,世界&name=test
* @return 编码后的QueryString
* 解析键值对并放入Map
* 格式key1,value1,key2,value2 或 /api,/http-proxy/api
* 注意:必须是成对出现
*/
private static String encodeQueryParams(String queryString) {
try {
Map<String, String> paramMap = new LinkedHashMap<>();
String[] params = queryString.split("&");
// 拆分参数名和参数值
for (String param : params) {
String[] keyValue = param.split("=", 2); // 只拆分第一个=,避免值中包含=的情况
String key = keyValue[0];
String value = keyValue.length > 1 ? keyValue[1] : "";
// 对参数值进行URL编码RFC 3986标准替换空格为%20而非+
String encodedValue = URLEncoder.encode(value, StandardCharsets.UTF_8)
.replace("+", "%20") // 替换URLEncoder默认的+为%20
.replace("*", "%2A")
.replace("%7E", "~");
paramMap.put(key, encodedValue);
private static void parseAndPutToMap(String pairStr, Map<String, String> map) {
String[] pairs = pairStr.split(",");
// 必须是偶数个元素才能组成键值对
for (int i = 0; i < pairs.length - 1; i += 2) {
String key = pairs[i].trim();
String value = pairs[i + 1].trim();
if (StringUtils.isNotBlank(key) && StringUtils.isNotBlank(value)) {
map.put(key, value);
}
// 重新拼接编码后的参数
StringBuilder encodedQuery = new StringBuilder();
for (Map.Entry<String, String> entry : paramMap.entrySet()) {
if (!encodedQuery.isEmpty()) {
encodedQuery.append("&");
}
encodedQuery.append(entry.getKey())
.append("=")
.append(entry.getValue());
}
return encodedQuery.toString();
} catch (Throwable throwable) {
return queryString;
}
}
public String getRoute() {
return route;
}
/**
* 获取完整的代理目标URL
*
* @param proxyPath 代理路径
* @return 完整的URL
*/
public String getFullTargetUrl(String proxyPath) {
return getDomainUrl() + (proxyPath.isEmpty() ? "" : (proxyPath.startsWith("/") ? proxyPath : "/" + proxyPath));
}
public String getDomainUrl() {
String url = proxyUrl;
if (url.endsWith("/")) {
url = url.substring(0, url.length() - 1);
}
return url;
}
public Map<String, String> getReplaceMap() {
return replaceMap;
}
}
}

View File

@@ -14,8 +14,8 @@ import lombok.Data;
sort = 999998)
public class SystemGlobalHttpProxyConfig {
@SettingField(label = "域名代理路由",
tips = "域名代理配置,多域名时换行,格式如下:[访问路径]代理域名,对应{代理域名}{资源PATH}的请求路径为{serverUrl}/domain-proxy/{访问路径}{资源PATH}",
@SettingField(label = "代理路由配置",
tips = "多条配置时换行分隔,每行的格式如下:[路由键]基础URL{替换规则1,替换内容1}{替换规则2,替换内容2}",
type = IModuleSetting.SettingFieldType.TYPE_TEXTAREA,
rows = 6)
private String domainProxyRouterMap = "";