feat: 新增 /api/admin/httpProxy 环回代理端点,供 B 端接口测试直连调用(自动附加 Basic/X-Admin-Token,路径白名单限 /order 与 /api/admin,读超时 120s)

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-11 17:50:34 +08:00
co-authored by Claude Code
parent 8fff1db294
commit 97be68db61
@@ -2,6 +2,8 @@ package com.hospital.front.api.admin;
import java.io.File;
import java.lang.management.ManagementFactory;
import java.net.URI;
import java.util.Base64;
import java.nio.ByteBuffer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
@@ -280,7 +282,123 @@ public class AdminApi {
return Result.success();
}
// ================= 5. 接口清单(供 B 端接口测试工具加载 =================
// ================= 5. 接口代理(供 B 端接口测试工具直连调用 =================
/** httpProxy 请求体(Solon 自动绑定 JSON 请求体) */
public static class HttpProxyReq {
/** HTTP 方法:GET/POST/PUT/DELETE */
public String method = "GET";
/** 接口路径,如 /order/getItemList */
public String path;
/** query 参数(拼到 URL 上) */
public Map<String, String> query;
/** 请求体字符串(POST/PUT 时使用) */
public String body;
/** 额外请求头 */
public Map<String, String> headers;
}
/** 代理专用 HttpClient:环回调用本机接口,读超时 120s 适配大接口 */
private final java.net.http.HttpClient proxyHttp = java.net.http.HttpClient.newBuilder()
.connectTimeout(java.time.Duration.ofSeconds(5))
.build();
/**
* HTTP 代理:在 main 进程内环回调用本机接口,原样透传响应。
*
* B 端接口测试工具经此代理访问业务接口,免去 Agent WS 通道倒手
* (WS 通道有 2MB 消息上限与 60s 等待超时,大接口会超时/掉线)。
* 自动按 path 附加鉴权头:/order/** → Basicorder.basic 配置),
* /api/admin/** → X-Admin-Token。
*/
@Mapping(value = "/httpProxy", method = {MethodType.POST})
public Result<Map<String, Object>> httpProxy(Context ctx, HttpProxyReq req) {
checkToken(ctx);
// 参数校验与路径白名单:只允许代理本机 /order/** 与 /api/admin/**
if (req == null || req.path == null || req.path.isEmpty()) {
throw BusinessException.paramMissing("path 不能为空");
}
String path = req.path.trim();
if (!path.startsWith("/order/") && !path.startsWith("/api/admin/")) {
throw BusinessException.businessFailed("仅允许代理 /order/** 与 /api/admin/** 路径:" + path);
}
// 拼接环回 URLserver.port + path + query
String port = Solon.cfg().get("server.port", "8080");
StringBuilder url = new StringBuilder("http://127.0.0.1:").append(port).append(path);
if (req.query != null && !req.query.isEmpty()) {
StringBuilder qs = new StringBuilder();
for (Map.Entry<String, String> e : req.query.entrySet()) {
if (e.getKey() == null || e.getValue() == null) {
continue;
}
if (qs.length() > 0) {
qs.append('&');
}
qs.append(java.net.URLEncoder.encode(e.getKey(), StandardCharsets.UTF_8))
.append('=')
.append(java.net.URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8));
}
if (qs.length() > 0) {
url.append('?').append(qs);
}
}
// 构建请求:按 path 附加鉴权头
java.net.http.HttpRequest.Builder rb = java.net.http.HttpRequest.newBuilder(URI_COMPAT.apply(url.toString()));
String m = req.method == null ? "GET" : req.method.toUpperCase();
java.net.http.HttpRequest.BodyPublisher pub = req.body == null || req.body.isEmpty()
? java.net.http.HttpRequest.BodyPublishers.noBody()
: java.net.http.HttpRequest.BodyPublishers.ofString(req.body, StandardCharsets.UTF_8);
switch (m) {
case "POST" -> rb.POST(pub);
case "PUT" -> rb.PUT(pub);
case "DELETE" -> rb.DELETE();
default -> rb.GET();
}
if (path.startsWith("/order/")) {
String basic = Solon.cfg().get("order.basic", "");
boolean authFlag = !"false".equals(Solon.cfg().get("order.authFlag", "true"));
if (authFlag && basic != null && !basic.isEmpty()) {
// 对齐 OrderApi checkBasic 的校验格式:Basic + Base64(user:password)
rb.header("Authorization", "Basic "
+ Base64.getEncoder().encodeToString(basic.getBytes(StandardCharsets.UTF_8)));
}
} else {
rb.header("X-Admin-Token", Solon.cfg().get("admin.token", ""));
}
if (req.headers != null) {
for (Map.Entry<String, String> e : req.headers.entrySet()) {
if (e.getKey() != null && e.getValue() != null) {
rb.header(e.getKey(), e.getValue());
}
}
}
rb.timeout(java.time.Duration.ofSeconds(120));
// 执行并透传
try {
java.net.http.HttpResponse<String> resp = proxyHttp.send(rb.build(),
java.net.http.HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
Map<String, Object> out = new LinkedHashMap<>();
out.put("status", resp.statusCode());
out.put("body", resp.body());
return Result.success(out);
} catch (java.io.IOException e) {
throw BusinessException.businessFailed("环回调用失败(本机 " + path + "):" + e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw BusinessException.businessFailed("环回调用被中断:" + path);
}
}
/** URI 构建容错(URL 已校验,异常时给出可读错误) */
private static final java.util.function.Function<String, URI> URI_COMPAT = s -> {
try {
return URI.create(s);
} catch (Exception e) {
throw BusinessException.paramMissing("非法 URL" + s);
}
};
// ================= 6. 接口清单(供 B 端接口测试工具加载) =================
/**
* 返回 /order 业务接口元数据清单。