diff --git a/.gitignore b/.gitignore
index 02ac4c4..554584d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,5 @@ target/
.idea/
*.iml
logs/
+gateway-run.log
+tmp_*.sql
diff --git a/docs/sql/04_app.sql b/docs/sql/04_app.sql
new file mode 100644
index 0000000..2a05c32
--- /dev/null
+++ b/docs/sql/04_app.sql
@@ -0,0 +1,29 @@
+-- =====================================================================
+-- 开放接口中心 应用治理表 open_api_app(应用级免签 C 方向,G1)
+-- 语义:按调用方应用(appKey)维度登记治理属性——只管"免签与否",不管密钥;
+-- 密钥链(appauth starter Redis sign:app:{appKey})职责不变,SIGNED 应用照旧从那里取 secret,
+-- ANON 应用不要求密钥存在(免签应用可无密钥)。
+-- 判定:表空 / 无行 / 禁用 / 过期 = 一律维持验签(fail-closed 缺省,存量 appKey 零行为变化);
+-- auth_mode=ANON 的行仅内部入口免签(免 X-Timestamp/X-Signature 两头,仍须带 X-App-Key),
+-- 到期判定是惰性的(AppAuthRegistry.admit 实时比对 expire_at,零定时任务),
+-- 过期行即刻不再命中(等价恢复验签),行数据不清除,想再用走治理页续期(重新起算 ≤31 天)。
+-- 哨兵:app_key='*' = 匿名窗口行(uk 保证全局至多一行)——内部入口无 X-App-Key 的请求也放行,
+-- 强制生效周期 ≤31 天(O1 决议);该行只折算成"窗口到期时刻",不进 appKey 映射。
+-- 操作注意:ANON 应用改回 SIGNED 前,须先确认密钥链有该 appKey 的键,否则即刻 401。
+-- 写入方:治理面 API(/api/internal/admin/apps,保存后就地 reload + 广播)或手工 SQL;
+-- 网关运行时只读,改行后热重载即生效(不重启)。
+-- 可重复执行:CREATE TABLE IF NOT EXISTS(不 DROP——治理数据不可丢)
+-- =====================================================================
+
+CREATE TABLE IF NOT EXISTS open_api_app (
+ id BIGINT AUTO_INCREMENT PRIMARY KEY,
+ app_key VARCHAR(64) NOT NULL COMMENT '调用方 appKey(与密钥链 sign:app:{appKey} 同键;ANON 应用无须密钥;*=匿名窗口行(内部入口无票据放行,至多一行)',
+ app_name VARCHAR(64) COMMENT '展示名(可选)',
+ auth_mode VARCHAR(16) NOT NULL DEFAULT 'SIGNED' COMMENT 'SIGNED=验签(缺省)/ANON=内部入口免签',
+ expire_at DATETIME COMMENT '免签生效到期(ANON 行必填,保存时校验 ≤31 天;过期=惰性恢复验签;SIGNED 行为 NULL)',
+ enabled TINYINT NOT NULL DEFAULT 1,
+ remark VARCHAR(256),
+ gmt_create DATETIME NOT NULL,
+ gmt_modified DATETIME NOT NULL,
+ UNIQUE KEY uk_app (app_key)
+);
diff --git a/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java b/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java
index 154eb91..6bfdaab 100644
--- a/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java
+++ b/src/main/java/com/yunhai/platform/gateway/admin/InternalAdminController.java
@@ -1,9 +1,12 @@
package com.yunhai.platform.gateway.admin;
import com.yunhai.platform.apistd.ApiResponse;
+import com.yunhai.platform.gateway.auth.AppAuthRegistry;
+import com.yunhai.platform.gateway.entity.OpenApiApp;
import com.yunhai.platform.gateway.entity.OpenApiLog;
import com.yunhai.platform.gateway.entity.OpenApiRoute;
import com.yunhai.platform.gateway.entity.OpenApiTarget;
+import com.yunhai.platform.gateway.repository.OpenApiAppRepository;
import com.yunhai.platform.gateway.repository.OpenApiLogRepository;
import com.yunhai.platform.gateway.repository.OpenApiRouteRepository;
import com.yunhai.platform.gateway.repository.OpenApiTargetRepository;
@@ -14,12 +17,14 @@ import com.yunhai.platform.utils.model.BasePageResult;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
@@ -27,6 +32,8 @@ import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
+import java.time.format.DateTimeFormatter;
+import java.time.format.DateTimeParseException;
import java.util.Arrays;
import java.util.HashSet;
import java.util.LinkedHashMap;
@@ -34,9 +41,10 @@ import java.util.Map;
import java.util.Set;
/**
- * 网关治理接口(/api/internal/admin/**,D5 治理面后端):路由/目标地址的查询与行内编辑、
- * 台账分页查询;写操作落库后立即热重载注册表(routes→reload / targets→reloadTargets)
- * 并向 {@link RouteChangeBroadcaster} 发一条变更广播(其余实例收到即各自重载),无须重启网关。
+ * 网关治理接口(/api/internal/admin/**,D5 治理面后端):路由/目标地址/应用的查询与行内编辑、
+ * 台账分页查询;写操作落库后立即热重载注册表(routes→reload / targets→reloadTargets /
+ * apps→appAuthRegistry.reload)并向 {@link RouteChangeBroadcaster} 发一条变更广播
+ * (其余实例收到即各自重载),无须重启网关。
* 与 {@code RouteRefreshController} 同前缀,被 RoutingFilter 旁路(不进 oapi 链)。
*
* 前端:治理面 UI 独立在 {@code open-gateway/front}(Vue3),本类不再下发 HTML
@@ -61,6 +69,13 @@ public class InternalAdminController {
private static final Set AUTH_MODE = new HashSet<>(Arrays.asList("SIGNED", "ANON"));
private static final Set CACHE_SCOPE = new HashSet<>(Arrays.asList("CALLER", "GLOBAL"));
+ /** ANON 行(含 '*' 匿名窗口行)免签生效周期硬上限(O1 决议:覆盖所有月份的"1 个月") */
+ static final int MAX_ANON_TTL_DAYS = 31;
+ /** 应用免签模式值(与 AUTH_MODE 白名单同源;本类仅应用接口消费 ANON 分支) */
+ private static final String APP_MODE_ANON = "ANON";
+ /** expireAt 入参兼容两种写法:页面用 "yyyy-MM-dd HH:mm:ss",手工调用可用 ISO */
+ private static final DateTimeFormatter TS_FORMATTER = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
+
@Autowired
private OpenApiRouteRepository routeRepo;
@@ -70,20 +85,29 @@ public class InternalAdminController {
@Autowired
private OpenApiLogRepository logRepo;
+ @Autowired
+ private OpenApiAppRepository appRepo;
+
@Autowired
private RouteRegistry routeRegistry;
+ @Autowired
+ private AppAuthRegistry appAuthRegistry;
+
/** 变更广播(多实例同步);广播开关关闭时该 bean 不存在,故 required=false */
@Autowired(required = false)
private RouteChangeBroadcaster broadcaster;
/** 测试/显式装配入口 */
void setRepos(OpenApiRouteRepository routeRepo, OpenApiTargetRepository targetRepo,
- OpenApiLogRepository logRepo, RouteRegistry routeRegistry) {
+ OpenApiLogRepository logRepo, RouteRegistry routeRegistry,
+ OpenApiAppRepository appRepo, AppAuthRegistry appAuthRegistry) {
this.routeRepo = routeRepo;
this.targetRepo = targetRepo;
this.logRepo = logRepo;
this.routeRegistry = routeRegistry;
+ this.appRepo = appRepo;
+ this.appAuthRegistry = appAuthRegistry;
}
/** 路由表分页(治理对象全量含禁用行;targetCode+pathPattern 稳定排序) */
@@ -143,6 +167,20 @@ public class InternalAdminController {
}
route.setRateLimitQps(v);
}
+ if (body.containsKey("timeoutMs")) {
+ int v = intVal(body.get("timeoutMs"), "timeoutMs");
+ if (v < 1000 || v > 120000) {
+ throw new IllegalArgumentException("timeoutMs 须在 [1000,120000] 毫秒:" + v);
+ }
+ route.setTimeoutMs(v);
+ }
+ if (body.containsKey("requestBodyLimitMb")) {
+ int v = intVal(body.get("requestBodyLimitMb"), "requestBodyLimitMb");
+ if (v < 1 || v > 100) {
+ throw new IllegalArgumentException("requestBodyLimitMb 须在 [1,100] MB:" + v);
+ }
+ route.setRequestBodyLimitMb(v);
+ }
if (body.containsKey("enabled")) {
route.setEnabled(Boolean.TRUE.equals(body.get("enabled")));
}
@@ -193,6 +231,160 @@ public class InternalAdminController {
return out;
}
+ /** 应用治理表分页(治理对象全量含禁用/过期行;appKey 稳定排序,'*' 窗口行按字典序自然置顶) */
+ @Operation(operationId = "GatewayAdmin.apps", summary = "应用治理表分页")
+ @GetMapping("/apps")
+ public BasePageResult apps(
+ @RequestParam(value = "pageIndex", required = false, defaultValue = "1") Integer pageIndex,
+ @RequestParam(value = "pageSize", required = false, defaultValue = "10") Integer pageSize) {
+ return toResult(appRepo.findAll(pageRequest(pageIndex, pageSize, Sort.by("appKey"))));
+ }
+
+ /**
+ * 新增应用行(O3 决议:治理页闭环,不留"只有 UI 没有后端"断层)。
+ * 校验:appKey 非空 ≤64 且唯一(预查友好报错,uk 兜底收敛为 code=2);authMode 值域;
+ * authMode=ANON 时 expireAt 必填、> now、≤ now+31 天(O1 时间锁,SIGNED 行不设到期)。
+ * 成功后就地 reload + 广播。
+ */
+ @Operation(operationId = "GatewayAdmin.createApp", summary = "新增应用")
+ @PostMapping("/apps")
+ public Map createApp(@RequestBody Map body) {
+ String appKey = text(body.get("appKey"));
+ if (appKey == null || appKey.isEmpty()) {
+ throw new IllegalArgumentException("appKey 不可为空");
+ }
+ if (appKey.length() > 64) {
+ throw new IllegalArgumentException("appKey 长度不可超过 64:" + appKey.length());
+ }
+ if (appRepo.existsByAppKey(appKey)) {
+ throw new IllegalArgumentException(appKey.equals(AppAuthRegistry.WINDOW_APP_KEY)
+ ? "匿名窗口行已存在(appKey='*' 全局至多一行),如需调整请编辑现有行"
+ : "appKey 已存在:" + appKey + "(如需重建请先禁用旧行)");
+ }
+ String authMode = requireAuthMode(body.get("authMode"));
+ OpenApiApp app = new OpenApiApp();
+ app.setAppKey(appKey);
+ app.setAppName(text(body.get("appName")));
+ app.setAuthMode(authMode);
+ app.setRemark(text(body.get("remark")));
+ if (APP_MODE_ANON.equals(authMode)) {
+ app.setExpireAt(requireAnonExpire(body.get("expireAt"))); // ANON 必配到期(含 '*' 窗口行)
+ }
+ LocalDateTime now = LocalDateTime.now();
+ app.setGmtCreate(now);
+ app.setGmtModified(now);
+ try {
+ appRepo.save(app);
+ } catch (DataIntegrityViolationException e) {
+ // 并发窗口下预查失效由 uk 兜底,同样收敛为业务错误而非 500
+ throw new IllegalArgumentException("appKey 已存在:" + appKey + "(如需重建请先禁用旧行)");
+ }
+ return savedAppResult();
+ }
+
+ /**
+ * 行内编辑应用。只接受 {@code appName/authMode/enabled/remark/expireAt};
+ * appKey 不可改(行身份,同路由"不可改 target/pattern"惯例,要换=禁用旧行+新增新行);
+ * 切 ANON(或对 ANON 行续期)时 expireAt 必填并同 POST 校验——每次保存重新起算 ≤31 天;
+ * 切回 SIGNED 时 expireAt 置 NULL(注意:须先确认密钥链有该 appKey,否则即刻 401)。
+ */
+ @Operation(operationId = "GatewayAdmin.updateApp", summary = "编辑应用/启停/续期")
+ @PutMapping("/apps/{id}")
+ public Map updateApp(@PathVariable("id") Long id,
+ @RequestBody Map body) {
+ if (body.containsKey("appKey")) {
+ throw new IllegalArgumentException("appKey 不可改(行身份):如需更换请禁用本行后新增");
+ }
+ OpenApiApp app = appRepo.findById(id).orElse(null);
+ if (app == null) {
+ throw new IllegalArgumentException("应用不存在:" + id);
+ }
+ boolean modeTouched = body.containsKey("authMode");
+ if (modeTouched) {
+ app.setAuthMode(requireAuthMode(body.get("authMode")));
+ }
+ if (body.containsKey("appName")) {
+ app.setAppName(text(body.get("appName")));
+ }
+ if (body.containsKey("remark")) {
+ app.setRemark(text(body.get("remark")));
+ }
+ if (body.containsKey("enabled")) {
+ app.setEnabled(Boolean.TRUE.equals(body.get("enabled")));
+ }
+ if (APP_MODE_ANON.equals(app.getAuthMode())) {
+ if (body.containsKey("expireAt")) {
+ app.setExpireAt(requireAnonExpire(body.get("expireAt"))); // 续期:每次保存重新起算
+ } else if (modeTouched) {
+ // 切(或重申)ANON 必须同时给 expireAt——未传按越界拒绝,防止"开关一拨白送 31 天"
+ throw new IllegalArgumentException("authMode=ANON 时 expireAt 必填(免签最长 " + MAX_ANON_TTL_DAYS
+ + " 天,到期惰性恢复验签)");
+ } // else:既有 ANON 行普通编辑(备注/启停),保留原 expireAt
+ } else {
+ app.setExpireAt(null); // SIGNED 行不设到期
+ }
+ app.setGmtModified(LocalDateTime.now());
+ try {
+ appRepo.save(app);
+ } catch (DataIntegrityViolationException e) {
+ throw new IllegalArgumentException("保存冲突:应用行已被并发修改,请刷新后重试");
+ }
+ return savedAppResult();
+ }
+
+ private String requireAuthMode(Object v) {
+ String mode = text(v);
+ if (!AUTH_MODE.contains(mode)) {
+ throw new IllegalArgumentException("authMode 非法:" + mode + "(合法 " + AUTH_MODE + ")");
+ }
+ return mode;
+ }
+
+ /**
+ * ANON 行的 expireAt(O1 时间锁):必填、可解析、> now、≤ now+31 天——
+ * 惰性到期靠它实时比对,续期 = 每次保存重新起算。
+ */
+ private static LocalDateTime requireAnonExpire(Object v) {
+ LocalDateTime expireAt = parseDateTime(v);
+ if (expireAt == null) {
+ throw new IllegalArgumentException("authMode=ANON 时 expireAt 必填(免签最长 " + MAX_ANON_TTL_DAYS
+ + " 天,到期惰性恢复验签)");
+ }
+ LocalDateTime now = LocalDateTime.now();
+ if (!expireAt.isAfter(now)) {
+ throw new IllegalArgumentException("expireAt 必须晚于当前时刻:" + TS_FORMATTER.format(expireAt));
+ }
+ if (expireAt.isAfter(now.plusDays(MAX_ANON_TTL_DAYS))) {
+ throw new IllegalArgumentException("expireAt 不可晚于 " + MAX_ANON_TTL_DAYS + " 天(免签长期化=后门,"
+ + "续期请到期前再来保存,每次保存重新起算)");
+ }
+ return expireAt;
+ }
+
+ /** 入参兼容 "yyyy-MM-dd HH:mm:ss" 与 ISO;null/空白返回 null(由调用方判必填) */
+ private static LocalDateTime parseDateTime(Object v) {
+ if (v == null) {
+ return null;
+ }
+ String s = String.valueOf(v).trim();
+ if (s.isEmpty()) {
+ return null;
+ }
+ try {
+ return s.contains("T") ? LocalDateTime.parse(s) : LocalDateTime.parse(s, TS_FORMATTER);
+ } catch (DateTimeParseException e) {
+ throw new IllegalArgumentException("expireAt 格式非法:" + s + "(应为 yyyy-MM-dd HH:mm:ss)");
+ }
+ }
+
+ /** 应用写操作收尾:就地 reload + 广播(其余实例靠广播同步),返回快照装载行数 */
+ private Map savedAppResult() {
+ Map out = new LinkedHashMap<>();
+ out.put("apps", appAuthRegistry.reload());
+ broadcast();
+ return out;
+ }
+
/** 变更广播:本实例已就地重载,其余实例靠广播同步(发布失败只记 WARN,不影响本次写操作) */
private void broadcast() {
if (broadcaster != null) {
diff --git a/src/main/java/com/yunhai/platform/gateway/admin/ProxyTestController.java b/src/main/java/com/yunhai/platform/gateway/admin/ProxyTestController.java
new file mode 100644
index 0000000..792affd
--- /dev/null
+++ b/src/main/java/com/yunhai/platform/gateway/admin/ProxyTestController.java
@@ -0,0 +1,370 @@
+package com.yunhai.platform.gateway.admin;
+
+import com.yunhai.platform.apistd.ApiResponse;
+import com.yunhai.platform.gateway.auth.GatewayKeyService;
+import com.yunhai.platform.gateway.filter.SignVerifyFilter;
+import com.yunhai.platform.utils.crypto.SignUtil;
+import com.yunhai.platform.utils.exception.ErrorCode;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.apache.http.HttpEntity;
+import org.apache.http.client.config.RequestConfig;
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.RequestBuilder;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.impl.client.CloseableHttpClient;
+import org.apache.http.impl.client.HttpClients;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+import javax.servlet.http.HttpServletRequest;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.net.SocketTimeoutException;
+import java.net.URI;
+import java.nio.charset.StandardCharsets;
+import java.util.Arrays;
+import java.util.HashSet;
+import java.util.LinkedHashMap;
+import java.util.Map;
+import java.util.Set;
+
+/**
+ * 治理面「代理测试」端点(N 系治理小页的调障配套):输入 appKey/appSecret + 完整 oapi 地址,
+ * 网关在服务端代为签名并回环打自身的真实 oapi 链(Sanitize→路由判定→验签→转发),
+ * 把最终 status/耗时/响应体原样带回页面——测的是生产同一条链,不是旁路模拟。
+ *
+ * 为什么回环自身而不是直连提供方:排障要复现的正是调用方将经历的完整判定
+ * (路由是否命中、验签是否通过、observe 是否放行、转发是否 502)。回环地址取
+ * {@code request.getLocalAddr():getLocalPort()}(本机直打,不经 Caddy),天然是
+ * 内部入口——与 starter/SDK 的容器网内调用同一口径(一律验签)。
+ *
+ * 安全边界:入参 URL 的 scheme/host 一律忽略,只取 path+query 且形状必须为
+ * {@code /{target}/oapi/**}——不存在 SSRF 面(只会打到网关自身)。appSecret 支持两种来源:
+ * 页面明传(INPUT)或留空由 {@link GatewayKeyService} 从密钥链解析(REDIS,fail-closed)。
+ * 签名串 canonical 复用 {@link SignVerifyFilter#buildCanonicalTarget}——与验签侧同一实现,
+ * 杜绝双写漂移;时间戳毫秒(F1 契约)。
+ *
+ * 响应契约:与 InternalAdminController 同——成功 {@code {code:0,data}}(advice 统一包),
+ * 入参非法 HTTP 200 + {@code {code:2,msg}}。observe=true 时验签失败也会 200(FAIL_OBSERVED
+ * 放行透传),判定结论以访问台账为准——页面有文案提示。
+ */
+@Tag(name = "GatewayAdmin", description = "网关治理:路由旋钮 / 目标地址 / 访问台账")
+@RestController
+@RequestMapping("/api/internal/admin")
+public class ProxyTestController {
+
+ private static final Set ALLOWED_METHODS = new HashSet<>(
+ Arrays.asList("GET", "POST", "PUT", "DELETE", "PATCH", "HEAD", "OPTIONS"));
+
+ /** 入参 URL 的 path 长度上限(防长串绕形) */
+ private static final int MAX_PATH_LEN = 512;
+ /** 入参 URL 的 query 长度上限 */
+ private static final int MAX_QUERY_LEN = 2048;
+ /** 请求体入参上限 64KB(治理面手工测试足够) */
+ private static final int MAX_REQ_BODY_BYTES = 64 * 1024;
+ /** 响应体带回上限 256KB:超出截断并置 bodyTruncated=true */
+ private static final int MAX_RES_BODY_BYTES = 256 * 1024;
+ /** 连接网关自身 5s(本机回环,防御挂死) */
+ private static final int CONNECT_TIMEOUT_MS = 5_000;
+ /** 读超时 60s:须覆盖 route.timeout_ms 上限(当前最大配 60s) */
+ private static final int READ_TIMEOUT_MS = 60_000;
+
+ @Autowired
+ private GatewayKeyService keyService;
+
+ /** 测试/显式装配入口(同 InternalAdminController.setRepos 惯例) */
+ void setKeyService(GatewayKeyService keyService) {
+ this.keyService = keyService;
+ }
+
+ /** 回环基址覆盖(单测注入本地 HttpServer 用);null = 按请求 localAddr:localPort 推导 */
+ private String loopbackBaseOverride;
+
+ void setLoopbackBase(String base) {
+ this.loopbackBaseOverride = base;
+ }
+
+ /**
+ * 代理测试:服务端签名后回环打真实 oapi 链,返回 status/耗时/响应体与签名调试信息。
+ *
+ * @param body {url, method?, appKey, appSecret?, contentType?, body?}
+ */
+ @Operation(operationId = "GatewayAdmin.proxyTest", summary = "代理测试:票据签名后回环打真实 oapi 链")
+ @PostMapping("/proxy-test")
+ public Map proxyTest(@RequestBody Map body, HttpServletRequest req)
+ throws IOException {
+ String method = upperMethod(body.get("method"));
+ String url = text(body.get("url"));
+ String appKey = text(body.get("appKey"));
+ String appSecret = text(body.get("appSecret"));
+ String contentType = text(body.get("contentType"));
+ String reqBody = text(body.get("body"));
+ if (url == null) {
+ throw new IllegalArgumentException("url 不可为空(形如 http://网关域名/{target}/oapi/**)");
+ }
+ if (appKey == null) {
+ throw new IllegalArgumentException("appKey 不可为空");
+ }
+ if (reqBody != null && reqBody.getBytes(StandardCharsets.UTF_8).length > MAX_REQ_BODY_BYTES) {
+ throw new IllegalArgumentException("body 超过 " + MAX_REQ_BODY_BYTES / 1024 + "KB 上限");
+ }
+
+ Parsed p = parse(url);
+ String target = p.target;
+ String pathAndQuery = lenientUriEncode(p.rawPath, p.rawQuery); // 传输形(含容错编码)
+ int q = pathAndQuery.indexOf('?');
+ String finalPath = q < 0 ? pathAndQuery : pathAndQuery.substring(0, q);
+ String finalQuery = q < 0 ? null : pathAndQuery.substring(q + 1);
+ // canonical 坐标系 = 剥 target 首段后的路径;query 取传输形(网关侧同样以原始 query 解码排序)
+ String strippedPath = finalPath.substring(finalPath.indexOf('/', 1));
+
+ String secret;
+ String secretSource;
+ if (appSecret != null) {
+ secret = appSecret;
+ secretSource = "INPUT";
+ } else {
+ secret = keyService.getSecret(appKey); // fail-closed:未知/禁用/Redis 异常一律 null
+ secretSource = "REDIS";
+ if (secret == null) {
+ throw new IllegalArgumentException(
+ "appSecret 未填且网关解析不到该 appKey 的密钥(未注册/已禁用/密钥链不可用)");
+ }
+ }
+
+ String timestamp = String.valueOf(System.currentTimeMillis());
+ String canonical = SignVerifyFilter.buildCanonicalTarget(strippedPath, finalQuery);
+ String signature;
+ try {
+ signature = SignUtil.signRequest(method, canonical, timestamp, secret);
+ } catch (RuntimeException e) {
+ // SignUtil.checkSecret:密钥短于 16 位等——验签侧同因判 FAIL,这里给明确报错
+ throw new IllegalArgumentException("签名失败:" + e.getMessage());
+ }
+
+ String loopbackBase = loopbackBase(req);
+ String requestUrl = loopbackBase + pathAndQuery;
+
+ long startNanos = System.nanoTime();
+ int status = -1;
+ String resContentType = null;
+ String resBody = "";
+ boolean truncated = false;
+ IOException failure = null;
+ // 治理面低频手工操作:每次请求用独立 client,用完即关,不占全局连接池
+ try (CloseableHttpClient client = HttpClients.createDefault()) {
+ RequestBuilder rb = RequestBuilder.create(method)
+ .setUri(URI.create(requestUrl))
+ .setConfig(RequestConfig.custom()
+ .setConnectTimeout(CONNECT_TIMEOUT_MS)
+ .setSocketTimeout(READ_TIMEOUT_MS)
+ .setConnectionRequestTimeout(CONNECT_TIMEOUT_MS)
+ .setRedirectsEnabled(false)
+ .build())
+ .addHeader("X-App-Key", appKey)
+ .addHeader("X-Timestamp", timestamp)
+ .addHeader("X-Signature", signature);
+ if (reqBody != null && !reqBody.isEmpty() && hasRequestBody(method)) {
+ // 注意 StringEntity(String,String) 的第二参是 charset 不是 content-type——必须走 ContentType 形态
+ org.apache.http.entity.ContentType ct = contentType == null
+ ? org.apache.http.entity.ContentType.APPLICATION_JSON
+ : org.apache.http.entity.ContentType.parse(contentType);
+ rb.setEntity(new StringEntity(reqBody, ct));
+ }
+ try (CloseableHttpResponse up = client.execute(rb.build())) {
+ status = up.getStatusLine().getStatusCode();
+ org.apache.http.Header ct = up.getFirstHeader("Content-Type");
+ if (ct != null) {
+ resContentType = ct.getValue();
+ }
+ HttpEntity entity = up.getEntity();
+ if (entity != null) {
+ ByteArrayOutputStream out = new ByteArrayOutputStream();
+ try (InputStream in = entity.getContent()) {
+ byte[] buf = new byte[8192];
+ int n;
+ while (out.size() <= MAX_RES_BODY_BYTES && (n = in.read(buf)) >= 0) {
+ out.write(buf, 0, n);
+ }
+ }
+ byte[] bytes = out.toByteArray();
+ truncated = bytes.length > MAX_RES_BODY_BYTES;
+ resBody = new String(bytes, 0, Math.min(bytes.length, MAX_RES_BODY_BYTES),
+ StandardCharsets.UTF_8);
+ }
+ } catch (SocketTimeoutException e) {
+ // 网关自身 60s 未回包(后端 route.timeout 超时应已由网关回裸 504,此处为兜底口径)
+ failure = e;
+ status = 504;
+ }
+ } catch (IOException e) {
+ failure = e; // 连不上网关自身(端口收网/绑定异常等罕见场景;status 保持 -1)
+ }
+ int latencyMs = (int) ((System.nanoTime() - startNanos) / 1_000_000L);
+
+ Map sign = new LinkedHashMap<>();
+ sign.put("appKey", appKey);
+ sign.put("timestamp", timestamp);
+ sign.put("canonical", canonical);
+ sign.put("signature", signature);
+ sign.put("secretSource", secretSource);
+
+ Map out = new LinkedHashMap<>();
+ out.put("target", target);
+ out.put("method", method);
+ out.put("requestUrl", requestUrl);
+ out.put("status", status);
+ out.put("latencyMs", latencyMs);
+ out.put("contentType", resContentType);
+ out.put("bodyTruncated", truncated);
+ out.put("body", resBody);
+ out.put("signature", sign);
+ out.put("error", failure == null ? null : "回环调用网关自身失败:" + failure.getMessage());
+ return out;
+ }
+
+ /** 值域/参数错误 → HTTP 200 + {code:2, msg}(与 InternalAdminController.badRequest 同口径) */
+ @ExceptionHandler(IllegalArgumentException.class)
+ public ApiResponse