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:
@@ -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/** → Basic(order.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);
|
||||
}
|
||||
// 拼接环回 URL:server.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 业务接口元数据清单。
|
||||
|
||||
Reference in New Issue
Block a user