package com.iot.gateway.common; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import java.net.ConnectException; import java.net.SocketTimeoutException; import java.net.URI; import java.net.URLEncoder; import java.net.http.HttpClient; import java.net.http.HttpConnectTimeoutException; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.net.http.HttpTimeoutException; import java.nio.charset.StandardCharsets; import java.time.Duration; import java.util.HashMap; import java.util.Map; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; /** * 网关与核心业务后端 (IOT-backend) 的专线 HTTP 桥接客户端 *

* 具备 URL 安全编码、错误细粒度分类、幂等 GET 有限重试与租户强制隔离。 */ @Slf4j @Component public class BackendInternalClient { @Value("${gateway.backend.base-url:http://localhost:8080}") private String backendBaseUrl; @Value("${gateway.backend.internal-secret:}") private String internalSecret; private final HttpClient httpClient = HttpClient.newBuilder() .connectTimeout(Duration.ofSeconds(4)) .build(); private final ObjectMapper objectMapper = new ObjectMapper(); /** * 内部专线 GET 请求 (支持参数 URL 编码、错误细粒度分类与网络抖动单次有限重试) */ public JsonNode get(String path, Long tenantId, Map queryParams) { if (tenantId == null) { throw new BizException(401, "专线请求缺少租户上下文,严禁跨租户或无租户调用"); } String fullUrl = buildGetUrl(path, queryParams); int maxAttempts = 2; // 最多尝试2次(初次 + 1次重试) for (int attempt = 1; attempt <= maxAttempts; attempt++) { try { HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(fullUrl)) .header("X-Internal-Secret", internalSecret != null ? internalSecret : "") .header("X-Tenant-Id", String.valueOf(tenantId)) .header("Content-Type", "application/json") .timeout(Duration.ofSeconds(5)) .GET() .build(); HttpResponse resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); return handleResponse(resp, fullUrl); } catch (HttpTimeoutException | SocketTimeoutException e) { if (attempt < maxAttempts) { log.warn("GET 请求超时,正在进行瞬时网络重试 (attempt={}): url={}", attempt, fullUrl); backoff(200); continue; } log.error("核心后端 GET 请求超时: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage()); throw new BizException(504, "调用核心后端业务超时,服务可能处于高负荷: " + e.getMessage()); } catch (ConnectException e) { if (attempt < maxAttempts) { log.warn("GET 连接异常,正在尝试重连 (attempt={}): url={}", attempt, fullUrl); backoff(200); continue; } log.error("核心后端连接拒绝: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage()); throw new BizException(503, "核心后端服务不可达,请检查后端运行状态"); } catch (BizException be) { throw be; } catch (Exception e) { log.error("调用核心后端 GET 异常: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage()); throw new BizException(502, "核心物联后端通信异常: " + e.getMessage()); } } throw new BizException(504, "调用核心后端业务多次超时"); } /** * 内部专线 POST 请求 (设备反写与业务提交严禁重试,避免指令重复执行) */ public JsonNode post(String path, Long tenantId, Map body) { if (tenantId == null) { throw new BizException(401, "专线请求缺少租户上下文,严禁跨租户或无租户调用"); } String fullUrl = backendBaseUrl + path; try { String jsonStr = objectMapper.writeValueAsString(body != null ? body : new HashMap<>()); HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(fullUrl)) .header("X-Internal-Secret", internalSecret != null ? internalSecret : "") .header("X-Tenant-Id", String.valueOf(tenantId)) .header("Content-Type", "application/json") .timeout(Duration.ofSeconds(6)) .POST(HttpRequest.BodyPublishers.ofString(jsonStr)) .build(); HttpResponse resp = httpClient.send(request, HttpResponse.BodyHandlers.ofString()); return handleResponse(resp, fullUrl); } catch (HttpTimeoutException | SocketTimeoutException e) { log.error("核心后端 POST 请求超时 (未重试,防止指令重放): url={}, tenantId={}", fullUrl, tenantId); throw new BizException(504, "核心控制通道通信超时,无法确认物理设备指令下发状态"); } catch (ConnectException e) { log.error("核心后端连接失败: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage()); throw new BizException(503, "核心控制网关服务不可达"); } catch (BizException be) { throw be; } catch (Exception e) { log.error("核心后端 POST 调用异常: url={}, tenantId={}, err={}", fullUrl, tenantId, e.getMessage()); throw new BizException(502, "控制指令网关通信失败: " + e.getMessage()); } } private JsonNode handleResponse(HttpResponse resp, String fullUrl) { int status = resp.statusCode(); String body = resp.body(); if (status == 200) { try { return objectMapper.readTree(body); } catch (Exception e) { log.error("核心后端响应非标准 JSON: body={}", body); throw new BizException(502, "核心后端返回格式错误"); } } if (status == 401 || status == 403) { log.error("专线内部鉴权失败: status={}, body={}, url={}", status, body, fullUrl); throw new BizException(500, "内部通信专线密钥配置不匹配或租户无权访问"); } if (status == 404) { throw new BizException(404, "核心后端未找到目标接口: " + fullUrl); } log.warn("核心后端返回错误状态码: status={}, body={}, url={}", status, body, fullUrl); try { JsonNode errJson = objectMapper.readTree(body); if (errJson.has("message")) { throw new BizException(502, errJson.get("message").asText()); } } catch (BizException be) { throw be; } catch (Exception ignored) {} throw new BizException(502, "核心后端服务返回异常代码: HTTP " + status); } private String buildGetUrl(String path, Map queryParams) { StringBuilder urlBuilder = new StringBuilder(backendBaseUrl).append(path); if (queryParams != null && !queryParams.isEmpty()) { urlBuilder.append("?"); for (Map.Entry entry : queryParams.entrySet()) { if (entry.getValue() != null) { String k = URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8); String v = URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8); urlBuilder.append(k).append("=").append(v).append("&"); } } urlBuilder.deleteCharAt(urlBuilder.length() - 1); } return urlBuilder.toString(); } private void backoff(long ms) { try { Thread.sleep(ms); } catch (InterruptedException ignored) {} } }