api生成器初版确定提交

This commit is contained in:
张家训
2026-07-28 19:52:30 +08:00
parent 476141ebdc
commit acd74107f9
7 changed files with 1194 additions and 11 deletions
@@ -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("&", "&amp;").replace("\"", "&quot;").replace("<", "&lt;").replace(">", "&gt;");
}
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 头,防止嵌入到 &lt;script&gt; 标签中导致 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();
}
}