api生成器初版确定提交
This commit is contained in:
@@ -61,9 +61,14 @@ public class ApiDocGenerator {
|
||||
|
||||
String template = resourceLoader.loadTemplate();
|
||||
|
||||
// 懒初始化本地代理(仅第一次触发)
|
||||
String proxyUrl = DubboDebugProxy.getInstance().getInvokeUrl();
|
||||
|
||||
return template
|
||||
.replace("{{CSS}}", resourceLoader.loadCss())
|
||||
.replace("{{JS}}", resourceLoader.loadJs())
|
||||
.replace("{{HTML2CANVAS}}", resourceLoader.loadHtml2Canvas())
|
||||
.replace("{{PROXY_URL}}", proxyUrl)
|
||||
.replace("{{GENERATE_TIME}}", LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")))
|
||||
.replace("{{STATS_BAR}}", buildStatsBar(docs.size(), totalClasses, totalMethods))
|
||||
.replace("{{SIDEBAR}}", buildSidebar(docs))
|
||||
@@ -167,7 +172,7 @@ public class ApiDocGenerator {
|
||||
sb.append(" <div class=\"methods-section\">\n");
|
||||
sb.append(" <div class=\"section-label\">📋 接口方法</div>\n");
|
||||
for (ApiDocModel.MethodInfo mi : ci.methods) {
|
||||
sb.append(buildMethodCard(anchor, mi));
|
||||
sb.append(buildMethodCard(anchor, mi, ci.fullClassName));
|
||||
}
|
||||
sb.append(" </div>\n");
|
||||
}
|
||||
@@ -206,15 +211,24 @@ public class ApiDocGenerator {
|
||||
}
|
||||
|
||||
/** 构建单个方法卡片 */
|
||||
private String buildMethodCard(String classAnchor, ApiDocModel.MethodInfo mi) {
|
||||
private String buildMethodCard(String classAnchor, ApiDocModel.MethodInfo mi, String fullClassName) {
|
||||
String methodId = classAnchor + "_" + safeId(mi.name);
|
||||
StringBuilder sb = new StringBuilder(2000);
|
||||
sb.append(" <div class=\"method-card\" id=\"").append(methodId).append("\">\n");
|
||||
String httpMethod = extractHttpMethod(mi.annotations);
|
||||
StringBuilder sb = new StringBuilder(3000);
|
||||
|
||||
// 所有方法默认嵌入调试元数据
|
||||
String paramTypesJson = buildParamTypesJson(mi);
|
||||
String sampleArgsJson = buildSampleArgsJson(mi.requestFields);
|
||||
sb.append(" <div class=\"method-card\" id=\"").append(methodId).append("\"");
|
||||
sb.append("\n data-dubbo-interface=\"").append(escapeAttr(fullClassName)).append("\"");
|
||||
sb.append("\n data-dubbo-method=\"").append(escapeAttr(mi.name)).append("\"");
|
||||
sb.append("\n data-dubbo-param-types='").append(paramTypesJson).append("'");
|
||||
sb.append("\n data-dubbo-sample-args='").append(sampleArgsJson).append("'");
|
||||
sb.append(">\n");
|
||||
|
||||
// 方法头
|
||||
sb.append(" <div class=\"method-header\" onclick=\"toggleMethod(this)\">\n");
|
||||
sb.append(" <span class=\"method-arrow\">▼</span>\n");
|
||||
String httpMethod = extractHttpMethod(mi.annotations);
|
||||
if (!httpMethod.isEmpty()) {
|
||||
sb.append(" <span class=\"http-badge ").append(httpMethod.toLowerCase()).append("\">").append(httpMethod).append("</span>\n");
|
||||
}
|
||||
@@ -222,6 +236,7 @@ public class ApiDocGenerator {
|
||||
if (mi.returnType != null) {
|
||||
sb.append(" <span class=\"method-return\">→ <code>").append(escapeHtml(mi.returnType)).append("</code></span>\n");
|
||||
}
|
||||
sb.append(" <button class=\"method-copy-btn dubbo\" onclick=\"event.stopPropagation();openDubboDebug(this.closest('.method-card'))\" title=\"接口调试\">🔧 调试</button>\n");
|
||||
sb.append(" <button class=\"method-copy-btn img\" onclick=\"event.stopPropagation();copyImage(this.closest('.method-card'),this)\" title=\"复制为图片(PNG)\">🖼 图片</button>\n");
|
||||
sb.append(" <button class=\"method-copy-btn txt\" onclick=\"event.stopPropagation();copyRtfFromEl(this.closest('.method-card'),this)\" title=\"复制为 Word 富文本(RTF)\">📋 富文本</button>\n");
|
||||
sb.append(" </div>\n");
|
||||
@@ -417,6 +432,108 @@ public class ApiDocGenerator {
|
||||
return "";
|
||||
}
|
||||
|
||||
// ==================== Dubbo 调试元数据 ====================
|
||||
|
||||
/** 提取参数类型列表 JSON 数组,如 ["com.xxx.ReqDTO"] */
|
||||
private String buildParamTypesJson(ApiDocModel.MethodInfo mi) {
|
||||
StringBuilder sb = new StringBuilder("[");
|
||||
if (mi.params != null) {
|
||||
boolean first = true;
|
||||
for (ApiDocModel.ParamInfo pi : mi.params) {
|
||||
if (!first) sb.append(",");
|
||||
String fqn = pi.fullQualifiedName != null && !pi.fullQualifiedName.isEmpty()
|
||||
? pi.fullQualifiedName : pi.type;
|
||||
sb.append("\"").append(escapeJson(fqn)).append("\"");
|
||||
first = false;
|
||||
}
|
||||
}
|
||||
sb.append("]");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** 根据请求字段生成示例 args JSON,如 [{"loanNo":"","seasonList":[...]}] */
|
||||
private String buildSampleArgsJson(List<ApiDocModel.FieldInfo> requestFields) {
|
||||
StringBuilder sb = new StringBuilder("[");
|
||||
if (requestFields != null && !requestFields.isEmpty()) {
|
||||
sb.append(fieldToSampleJson(requestFields));
|
||||
}
|
||||
sb.append("]");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** 递归将字段列表转为示例 JSON 字符串 */
|
||||
private String fieldToSampleJson(List<ApiDocModel.FieldInfo> fields) {
|
||||
StringBuilder sb = new StringBuilder("{");
|
||||
for (int i = 0; i < fields.size(); i++) {
|
||||
if (i > 0) sb.append(",");
|
||||
ApiDocModel.FieldInfo f = fields.get(i);
|
||||
sb.append("\"").append(escapeJson(f.name)).append("\":");
|
||||
sb.append(sampleValueForField(f));
|
||||
}
|
||||
sb.append("}");
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** 根据字段类型返回示例值 */
|
||||
private String sampleValueForField(ApiDocModel.FieldInfo f) {
|
||||
if (f.children != null && !f.children.isEmpty()) {
|
||||
// 如果类型是集合(如 List<DTO> / DTO[] / Set<DTO>),包裹一层数组
|
||||
if (isCollectionType(f.type)) {
|
||||
return "[" + fieldToSampleJson(f.children) + "]";
|
||||
}
|
||||
return fieldToSampleJson(f.children);
|
||||
}
|
||||
// 无 children 的纯集合字段 → 空数组
|
||||
if (isCollectionType(f.type)) {
|
||||
return "[]";
|
||||
}
|
||||
String type = f.type == null ? "" : f.type.trim();
|
||||
// 数字类型
|
||||
if ("int".equals(type) || "long".equals(type) || "short".equals(type) || "byte".equals(type)
|
||||
|| "double".equals(type) || "float".equals(type)
|
||||
|| "Integer".equals(type) || "Long".equals(type) || "Short".equals(type) || "Byte".equals(type)
|
||||
|| "Double".equals(type) || "Float".equals(type)
|
||||
|| "BigDecimal".equals(type) || "BigInteger".equals(type)) {
|
||||
return "0";
|
||||
}
|
||||
// 布尔
|
||||
if ("boolean".equals(type) || "Boolean".equals(type)) {
|
||||
return "false";
|
||||
}
|
||||
// 其他默认字符串
|
||||
return "\"\"";
|
||||
}
|
||||
|
||||
/** 判断 type 是否为集合/数组类型 */
|
||||
private boolean isCollectionType(String type) {
|
||||
if (type == null) return false;
|
||||
// 数组格式 DTO[]
|
||||
if (type.endsWith("[]")) return true;
|
||||
// 泛型集合 List<...>, Set<...>, Collection<...> 等
|
||||
if (type.startsWith("List<") || type.startsWith("Set<") || type.startsWith("Collection<")
|
||||
|| type.startsWith("ArrayList<") || type.startsWith("HashSet<")) return true;
|
||||
// 纯集合名(无泛型参数)
|
||||
String simple = type.trim();
|
||||
return "List".equals(simple) || "Set".equals(simple) || "Collection".equals(simple)
|
||||
|| "ArrayList".equals(simple) || "HashSet".equals(simple);
|
||||
}
|
||||
|
||||
/** JSON 字符串转义(双引号、反斜杠、换行等) */
|
||||
private String escapeJson(String s) {
|
||||
if (s == null) return "";
|
||||
return s.replace("\\", "\\\\")
|
||||
.replace("\"", "\\\"")
|
||||
.replace("\n", "\\n")
|
||||
.replace("\r", "\\r")
|
||||
.replace("\t", "\\t");
|
||||
}
|
||||
|
||||
/** HTML 属性值转义 */
|
||||
private String escapeAttr(String s) {
|
||||
if (s == null) return "";
|
||||
return s.replace("&", "&").replace("\"", """).replace("<", "<").replace(">", ">");
|
||||
}
|
||||
|
||||
private String safeId(String s) {
|
||||
if (s == null) return "x";
|
||||
return s.replaceAll("[^a-zA-Z0-9\\u4e00-\\u9fa5_-]", "_");
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
package com.example.workutils.biz.apidoc.generator;
|
||||
|
||||
import com.sun.net.httpserver.HttpExchange;
|
||||
import com.sun.net.httpserver.HttpServer;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.URI;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Arrays;
|
||||
import java.util.HashSet;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.Executors;
|
||||
|
||||
/**
|
||||
* 本地 HTTP 代理,解决浏览器直连内网网关不通的问题。
|
||||
* 监听 localhost 随机端口,JS 请求本代理,由 Java 侧转发到 gw.test.bestpay.net。
|
||||
*
|
||||
* 单例,应用启动时创建,进程退出时自动回收。
|
||||
*/
|
||||
@Slf4j
|
||||
public class DubboDebugProxy {
|
||||
|
||||
private static volatile DubboDebugProxy INSTANCE;
|
||||
|
||||
private final int port;
|
||||
private final HttpServer server;
|
||||
|
||||
private DubboDebugProxy() {
|
||||
try {
|
||||
server = HttpServer.create(new InetSocketAddress("127.0.0.1", 0), 0);
|
||||
this.port = server.getAddress().getPort();
|
||||
server.createContext("/dubbo/invoke", this::handleInvoke);
|
||||
server.setExecutor(Executors.newFixedThreadPool(4));
|
||||
server.start();
|
||||
log.info("DubboDebugProxy 已启动: http://127.0.0.1:{}/dubbo/invoke", port);
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("无法启动本地代理", e);
|
||||
}
|
||||
}
|
||||
|
||||
public static DubboDebugProxy getInstance() {
|
||||
if (INSTANCE == null) {
|
||||
synchronized (DubboDebugProxy.class) {
|
||||
if (INSTANCE == null) {
|
||||
INSTANCE = new DubboDebugProxy();
|
||||
}
|
||||
}
|
||||
}
|
||||
return INSTANCE;
|
||||
}
|
||||
|
||||
public int getPort() {
|
||||
return port;
|
||||
}
|
||||
|
||||
public String getInvokeUrl() {
|
||||
return "http://127.0.0.1:" + port + "/dubbo/invoke";
|
||||
}
|
||||
|
||||
private void handleInvoke(HttpExchange exchange) throws IOException {
|
||||
// CORS 头
|
||||
exchange.getResponseHeaders().add("Access-Control-Allow-Origin", "*");
|
||||
exchange.getResponseHeaders().add("Access-Control-Allow-Methods", "POST, OPTIONS");
|
||||
exchange.getResponseHeaders().add("Access-Control-Allow-Headers", "*");
|
||||
|
||||
if ("OPTIONS".equalsIgnoreCase(exchange.getRequestMethod())) {
|
||||
exchange.sendResponseHeaders(204, -1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!"POST".equalsIgnoreCase(exchange.getRequestMethod())) {
|
||||
exchange.sendResponseHeaders(405, -1);
|
||||
return;
|
||||
}
|
||||
|
||||
// 读取请求体
|
||||
byte[] body = readAllBytes(exchange.getRequestBody());
|
||||
|
||||
// 从 header 中取网关地址(JS 传自定义 header),默认走测试环境
|
||||
String gwUrl = exchange.getRequestHeaders().getFirst("X-Gateway-Url");
|
||||
if (gwUrl == null || gwUrl.isEmpty()) {
|
||||
gwUrl = "https://gw.test.bestpay.net/open/1.0/GenericFacade/genericInvoke";
|
||||
}
|
||||
|
||||
// 透传所有请求头到上游(排除 proxy 专用 header)
|
||||
Set<String> skipHeaders = new HashSet<>(Arrays.asList(
|
||||
"X-gateway-url", "Host", "Connection", "Transfer-encoding", "Content-length"));
|
||||
Map<String, List<String>> reqHeaders = exchange.getRequestHeaders();
|
||||
|
||||
try {
|
||||
// 转发到网关
|
||||
URI uri = URI.create(gwUrl);
|
||||
HttpURLConnection conn = (HttpURLConnection) uri.toURL().openConnection();
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setDoOutput(true);
|
||||
conn.setConnectTimeout(15_000);
|
||||
conn.setReadTimeout(30_000);
|
||||
|
||||
// 透传所有 header
|
||||
for (Map.Entry<String, List<String>> entry : reqHeaders.entrySet()) {
|
||||
String key = entry.getKey();
|
||||
if (key != null && !skipHeaders.contains(key.toLowerCase())) {
|
||||
for (String val : entry.getValue()) {
|
||||
conn.addRequestProperty(key, val);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try (OutputStream os = conn.getOutputStream()) {
|
||||
os.write(body);
|
||||
os.flush();
|
||||
}
|
||||
|
||||
int status = conn.getResponseCode();
|
||||
InputStream is = status >= 400 ? conn.getErrorStream() : conn.getInputStream();
|
||||
byte[] respBody = is != null ? readAllBytes(is) : new byte[0];
|
||||
|
||||
// 原样返回
|
||||
exchange.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
|
||||
exchange.sendResponseHeaders(status, respBody.length);
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(respBody);
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.error("代理转发失败: {} {}", gwUrl, e.getMessage());
|
||||
String err = "{\"error\":\"" + escapeJson(e.getMessage()) + "\"}";
|
||||
byte[] errBytes = err.getBytes(StandardCharsets.UTF_8);
|
||||
exchange.sendResponseHeaders(502, errBytes.length);
|
||||
try (OutputStream os = exchange.getResponseBody()) {
|
||||
os.write(errBytes);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] readAllBytes(InputStream is) throws IOException {
|
||||
byte[] buf = new byte[8192];
|
||||
java.io.ByteArrayOutputStream bos = new java.io.ByteArrayOutputStream();
|
||||
int n;
|
||||
while ((n = is.read(buf)) != -1) {
|
||||
bos.write(buf, 0, n);
|
||||
}
|
||||
return bos.toByteArray();
|
||||
}
|
||||
|
||||
private static String escapeJson(String s) {
|
||||
if (s == null) return "";
|
||||
return s.replace("\\", "\\\\").replace("\"", "\\\"");
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
package com.example.workutils.biz.apidoc.generator;
|
||||
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.Scanner;
|
||||
|
||||
/**
|
||||
* API 文档资源加载器。
|
||||
@@ -45,9 +45,19 @@ public class HtmlResourceLoader {
|
||||
return loadResource(RESOURCE_BASE + "api-doc.js");
|
||||
}
|
||||
|
||||
/**
|
||||
* 加载 html2canvas 库(本地打包,无外部 CDN 依赖)。
|
||||
* 对应资源文件:resources/templates/apidoc/html2canvas.min.js
|
||||
*/
|
||||
public String loadHtml2Canvas() {
|
||||
return loadResource(RESOURCE_BASE + "html2canvas.min.js");
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 classpath 读取资源文件内容。
|
||||
* 所有资源必须以 UTF-8 编码。
|
||||
* 使用 ByteArrayOutputStream 替代 Scanner,避免 \\A 分隔符在不同平台上的兼容性问题。
|
||||
* 同时自动剥离 UTF-8 BOM 头,防止嵌入到 <script> 标签中导致 JS 解析失败。
|
||||
*/
|
||||
private String loadResource(String path) {
|
||||
ClassLoader cl = getClass().getClassLoader();
|
||||
@@ -58,12 +68,25 @@ public class HtmlResourceLoader {
|
||||
if (is == null) {
|
||||
throw new IOException("资源文件未找到: " + path);
|
||||
}
|
||||
try (Scanner scanner = new Scanner(is, StandardCharsets.UTF_8.name())) {
|
||||
scanner.useDelimiter("\\A");
|
||||
return scanner.hasNext() ? scanner.next() : "";
|
||||
byte[] bytes = readAllBytes(is);
|
||||
String content = new String(bytes, StandardCharsets.UTF_8);
|
||||
// 剥离 UTF-8 BOM(\uFEFF),避免在 <script> 标签中导致 JS 解析失败
|
||||
if (!content.isEmpty() && content.charAt(0) == '\uFEFF') {
|
||||
content = content.substring(1);
|
||||
}
|
||||
return content;
|
||||
} catch (IOException e) {
|
||||
throw new RuntimeException("加载资源失败: " + path, e);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] readAllBytes(InputStream is) throws IOException {
|
||||
ByteArrayOutputStream bos = new ByteArrayOutputStream();
|
||||
byte[] buf = new byte[4096];
|
||||
int n;
|
||||
while ((n = is.read(buf)) != -1) {
|
||||
bos.write(buf, 0, n);
|
||||
}
|
||||
return bos.toByteArray();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user