From 145d0c942830216d243e126ebe673c7b97d1149a Mon Sep 17 00:00:00 2001 From: iqudoo Date: Sun, 7 Jun 2026 22:15:22 +0800 Subject: [PATCH] =?UTF-8?q?=E8=B5=84=E6=BA=90=E4=BB=A3=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/HttpProxyController.java | 410 ++++++------------ .../setting/SystemGlobalHttpProxyConfig.java | 4 +- 2 files changed, 128 insertions(+), 286 deletions(-) diff --git a/src/main/java/com/iqudoo/platform/application/controller/HttpProxyController.java b/src/main/java/com/iqudoo/platform/application/controller/HttpProxyController.java index d377107..152a8d3 100644 --- a/src/main/java/com/iqudoo/platform/application/controller/HttpProxyController.java +++ b/src/main/java/com/iqudoo/platform/application/controller/HttpProxyController.java @@ -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,33 +76,26 @@ 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 replaceList = new ArrayList<>(); + Map replaceMap = new HashMap<>(); if (StringUtils.isNotBlank(httpProxyConfig.getDomainProxyRouterMap())) { String[] domainProxyConfig = httpProxyConfig.getDomainProxyRouterMap().split("\n"); for (String domainProxy : domainProxyConfig) { - try { - Map 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)); - break; - } - } catch (Throwable ignored) { + ProxyRouteConfig routeConfig = ProxyRouteConfig.parse(domainProxy); + // 检查是否匹配当前请求 + if (routeConfig.getRoute().equals(proxyDir)) { + proxyConfig = domainProxy; + proxyDomainUrl = routeConfig.getDomainUrl(); + proxyTargetUrl = routeConfig.getFullTargetUrl(proxyPath); + replaceMap.putAll(routeConfig.getReplaceMap()); + break; } } } @@ -119,6 +112,8 @@ public class HttpProxyController { } // 构建请求头 copyRequestHeaders(servletRequest, headers); + // 根据代理配置判断是否禁用本次缓存 + validateETagWithCacheVersion(servletRequest, headers, proxyConfig); // 处理请求方法和请求体 HttpMethod method = HttpMethod.valueOf(servletRequest.getMethod()); BodyInserter bodyInserter = buildBodyInserter(body, servletRequest); @@ -221,7 +216,7 @@ public class HttpProxyController { public Mono> apply(List 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> 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 bodyInserter = buildBodyInserter(body, servletRequest); - return webClient.method(method) - .uri(URI.create(proxyTargetUrl)) - .headers(new Consumer() { - @Override - public void accept(HttpHeaders httpHeaders) { - httpHeaders.addAll(headers); - } - }) - .body(bodyInserter) - .exchange() - .flatMap(new Function>>() { - @Override - public Mono> 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, Mono>>() { - @Override - public Mono> apply(List 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, Mono>>() { - @Override - public Mono> apply(List 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>>() { - @Override - public Mono> 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>>() { - @Override - public Mono> 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> handleResponseContent(byte[] responseBytes, ClientResponse response, - String domainUrl, String proxyDir, String proxyTargetUrl, List replaceList) { + String domainUrl, String proxyDir, String proxyTargetUrl, + Map 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 replaceList) { + private String replaceDomainPrecisely(String content, String originalDomain, + String proxyDir, Map 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 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 parseKeyValue(String input) { - Map 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 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 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; - } - - // 解析并编码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; - } - } - - /** - * 解析并编码QueryString参数(保留参数名,仅编码参数值) - * - * @param queryString 原始QueryString(如:text=你好,世界&name=test) - * @return 编码后的QueryString - */ - private static String encodeQueryParams(String queryString) { - try { - Map 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); - } - // 重新拼接编码后的参数 - StringBuilder encodedQuery = new StringBuilder(); - for (Map.Entry entry : paramMap.entrySet()) { - if (!encodedQuery.isEmpty()) { - encodedQuery.append("&"); + // 正则匹配:[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); + } + // 解析第一个花括号 {} 中的内容(路径映射对) + 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); } - encodedQuery.append(entry.getKey()) - .append("=") - .append(entry.getValue()); } - return encodedQuery.toString(); - } catch (Throwable throwable) { - return queryString; + return config; } + + /** + * 解析键值对并放入Map + * 格式:key1,value1,key2,value2 或 /api,/http-proxy/api + * 注意:必须是成对出现 + */ + private static void parseAndPutToMap(String pairStr, Map 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); + } + } + } + + 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 getReplaceMap() { + return replaceMap; + } + } } \ No newline at end of file diff --git a/src/main/java/com/iqudoo/platform/application/setting/SystemGlobalHttpProxyConfig.java b/src/main/java/com/iqudoo/platform/application/setting/SystemGlobalHttpProxyConfig.java index 3e0baf2..e9901d0 100644 --- a/src/main/java/com/iqudoo/platform/application/setting/SystemGlobalHttpProxyConfig.java +++ b/src/main/java/com/iqudoo/platform/application/setting/SystemGlobalHttpProxyConfig.java @@ -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 = "";