暂时提交
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
package com.example.workutils.biz.apidoc;
|
||||
|
||||
import com.example.workutils.biz.apidoc.config.ApiBaseClassManager;
|
||||
import com.example.workutils.system.config.WorkUtilsProperties;
|
||||
import com.example.workutils.system.plugin.Plugin;
|
||||
import com.example.workutils.system.plugin.PluginDescriptor;
|
||||
@@ -38,6 +39,7 @@ public class ApiDocPlugin implements Plugin {
|
||||
|
||||
private final WorkUtilsProperties properties;
|
||||
private final FileOperationHelper fileOperationHelper;
|
||||
private final ApiBaseClassManager apiBaseClassManager;
|
||||
|
||||
@Override
|
||||
public PluginDescriptor getDescriptor() { return DESCRIPTOR; }
|
||||
@@ -53,7 +55,7 @@ public class ApiDocPlugin implements Plugin {
|
||||
public Parent getEmbeddedPane() {
|
||||
if (embeddedRoot != null) return embeddedRoot;
|
||||
latch = new CountDownLatch(1);
|
||||
window = new ApiDocWindow(fileOperationHelper, properties, latch);
|
||||
window = new ApiDocWindow(fileOperationHelper, properties, latch, apiBaseClassManager);
|
||||
embeddedRoot = window.getContentPane();
|
||||
window.getStage().getScene().setRoot(new Pane());
|
||||
return embeddedRoot;
|
||||
@@ -63,7 +65,7 @@ public class ApiDocPlugin implements Plugin {
|
||||
public void open(Runnable onClosed) {
|
||||
if (window == null) {
|
||||
latch = new CountDownLatch(1);
|
||||
window = new ApiDocWindow(fileOperationHelper, properties, latch);
|
||||
window = new ApiDocWindow(fileOperationHelper, properties, latch, apiBaseClassManager);
|
||||
}
|
||||
if (embeddedRoot != null && window.getStage().getScene().getRoot() != embeddedRoot) {
|
||||
window.getStage().getScene().setRoot(embeddedRoot);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.example.workutils.biz.apidoc;
|
||||
|
||||
import com.example.workutils.biz.apidoc.config.ApiBaseClassManager;
|
||||
import com.example.workutils.biz.apidoc.generator.ApiDocGenerator;
|
||||
import com.example.workutils.biz.apidoc.model.ApiDocModel;
|
||||
import com.example.workutils.biz.apidoc.parser.JavaSourceParser;
|
||||
@@ -44,6 +45,7 @@ public class ApiDocWindow {
|
||||
private final CountDownLatch closeLatch;
|
||||
private final WorkUtilsProperties properties;
|
||||
private final FileOperationHelper fileOperationHelper;
|
||||
private final ApiBaseClassManager apiBaseClassManager;
|
||||
|
||||
// 模式选择
|
||||
private ToggleGroup modeGroup;
|
||||
@@ -82,10 +84,11 @@ public class ApiDocWindow {
|
||||
private static final String DELIMITER = "|";
|
||||
|
||||
public ApiDocWindow(FileOperationHelper fileOperationHelper, WorkUtilsProperties properties,
|
||||
CountDownLatch closeLatch) {
|
||||
CountDownLatch closeLatch, ApiBaseClassManager apiBaseClassManager) {
|
||||
this.fileOperationHelper = fileOperationHelper;
|
||||
this.properties = properties;
|
||||
this.closeLatch = closeLatch;
|
||||
this.apiBaseClassManager = apiBaseClassManager;
|
||||
this.historyPersistence = new HistoryPersistence("apidoc_history.txt");
|
||||
this.stage = new Stage();
|
||||
buildUI();
|
||||
@@ -345,7 +348,7 @@ public class ApiDocWindow {
|
||||
return;
|
||||
}
|
||||
Path tp = Paths.get(path);
|
||||
JavaSourceParser parser = new JavaSourceParser();
|
||||
JavaSourceParser parser = new JavaSourceParser(apiBaseClassManager);
|
||||
if (parser.isJavaFile(tp)) {
|
||||
exportSingle(path, tp, parser);
|
||||
} else if (parser.isDirectory(tp)) {
|
||||
@@ -389,6 +392,7 @@ public class ApiDocWindow {
|
||||
Path srcBase = resolveSrcBase(inputPath);
|
||||
if (srcBase != null) {
|
||||
log("正在解析 DTO...");
|
||||
parser.setAdditionalSrcBases(collectSiblingSrcBases(srcBase));
|
||||
parser.resolveDtosForClass(ci, srcBase);
|
||||
}
|
||||
|
||||
@@ -470,7 +474,7 @@ public class ApiDocWindow {
|
||||
|
||||
log("找到: " + foundFile + " 项目: " + foundProjName);
|
||||
|
||||
JavaSourceParser parser = new JavaSourceParser();
|
||||
JavaSourceParser parser = new JavaSourceParser(apiBaseClassManager);
|
||||
ApiDocModel.ClassInfo ci = parser.parseFile(foundFile, foundProjName, true);
|
||||
if (ci == null || ci.methods == null || ci.methods.isEmpty()) {
|
||||
log("[WARN] 未找到有效方法");
|
||||
@@ -481,6 +485,7 @@ public class ApiDocWindow {
|
||||
|
||||
if (foundSrcBase != null) {
|
||||
log("正在解析 DTO...");
|
||||
parser.setAdditionalSrcBases(collectSiblingSrcBases(foundSrcBase));
|
||||
parser.resolveDtosForClass(ci, foundSrcBase);
|
||||
}
|
||||
|
||||
@@ -536,7 +541,7 @@ public class ApiDocWindow {
|
||||
|
||||
for (ModuleInfo m : modules) {
|
||||
log("[" + m.name + "] 扫描...");
|
||||
JavaSourceParser parser = new JavaSourceParser();
|
||||
JavaSourceParser parser = new JavaSourceParser(apiBaseClassManager);
|
||||
List<Path> javaFiles = parser.findJavaFilesFrom(m.srcBase);
|
||||
if (javaFiles.isEmpty()) continue;
|
||||
|
||||
@@ -561,7 +566,9 @@ public class ApiDocWindow {
|
||||
classes.sort(Comparator.comparing(c -> c.className));
|
||||
|
||||
// DTO
|
||||
JavaSourceParser dtoParser = new JavaSourceParser();
|
||||
JavaSourceParser dtoParser = new JavaSourceParser(apiBaseClassManager);
|
||||
List<Path> allSrcBases = modules.stream().map(mod -> mod.srcBase).collect(java.util.stream.Collectors.toList());
|
||||
dtoParser.setAdditionalSrcBases(allSrcBases);
|
||||
for (ApiDocModel.ClassInfo ci : classes) dtoParser.resolveDtosForClass(ci, m.srcBase);
|
||||
|
||||
ApiDocModel.ProjectApiDoc doc = new ApiDocModel.ProjectApiDoc();
|
||||
@@ -822,6 +829,23 @@ public class ApiDocWindow {
|
||||
} catch (Exception ignored) {}
|
||||
}
|
||||
|
||||
/** 从 srcBase 路径反推项目根目录,收集所有兄弟模块的 src/main/java */
|
||||
private List<Path> collectSiblingSrcBases(Path srcBase) {
|
||||
List<Path> siblings = new ArrayList<>();
|
||||
if (srcBase == null) return siblings;
|
||||
// srcBase 格式: /project/module/src/main/java → 上溯 3 级到 module, 再上溯 1 级到 project
|
||||
Path moduleDir = srcBase.getParent(); // src/main
|
||||
if (moduleDir != null) moduleDir = moduleDir.getParent(); // src
|
||||
if (moduleDir != null) moduleDir = moduleDir.getParent(); // module
|
||||
if (moduleDir != null) {
|
||||
Path projectRoot = moduleDir.getParent(); // project
|
||||
if (projectRoot != null) {
|
||||
collectSrcBases(projectRoot, siblings);
|
||||
}
|
||||
}
|
||||
return siblings;
|
||||
}
|
||||
|
||||
private void collectModules(Path dir, List<ModuleInfo> modules, Set<String> seen) {
|
||||
if (!Files.isDirectory(dir)) return;
|
||||
Path sb = dir.resolve("src/main/java");
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package com.example.workutils.biz.apidoc.config;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* API 基类定义(用于解析 jar 依赖中无法读取源码的外部基类字段)
|
||||
* <p>
|
||||
* 存储在 data/api_base_classes.json,支持在系统设置中编辑。
|
||||
*/
|
||||
public class ApiBaseClassConfig {
|
||||
|
||||
/** 全限定类名,如 com.bestpay.drip.commons.support.BaseDTO */
|
||||
private String fqn;
|
||||
|
||||
/** 基类字段列表 */
|
||||
private List<FieldDef> fields = new ArrayList<>();
|
||||
|
||||
public String getFqn() { return fqn; }
|
||||
public void setFqn(String fqn) { this.fqn = fqn; }
|
||||
|
||||
public List<FieldDef> getFields() { return fields; }
|
||||
public void setFields(List<FieldDef> fields) { this.fields = fields; }
|
||||
|
||||
/** 单个字段定义 */
|
||||
public static class FieldDef {
|
||||
private String name;
|
||||
private String type;
|
||||
private String javadoc;
|
||||
private List<String> annotations = new ArrayList<>();
|
||||
|
||||
public String getName() { return name; }
|
||||
public void setName(String name) { this.name = name; }
|
||||
|
||||
public String getType() { return type; }
|
||||
public void setType(String type) { this.type = type; }
|
||||
|
||||
public String getJavadoc() { return javadoc; }
|
||||
public void setJavadoc(String javadoc) { this.javadoc = javadoc; }
|
||||
|
||||
public List<String> getAnnotations() { return annotations; }
|
||||
public void setAnnotations(List<String> annotations) { this.annotations = annotations; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package com.example.workutils.biz.apidoc.config;
|
||||
|
||||
import com.alibaba.fastjson.JSON;
|
||||
import com.example.workutils.biz.apidoc.model.ApiDocModel;
|
||||
import com.example.workutils.system.config.WorkUtilsProperties;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.annotation.PostConstruct;
|
||||
import java.io.IOException;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.util.*;
|
||||
|
||||
/**
|
||||
* 管理 data/api_base_classes.json,为 DTO 解析提供外部 jar 基类的字段定义。
|
||||
*/
|
||||
@Slf4j
|
||||
@Component
|
||||
public class ApiBaseClassManager {
|
||||
|
||||
private final WorkUtilsProperties properties;
|
||||
|
||||
/** FQN → FieldInfo 列表(已转换为解析器可直接使用的格式) */
|
||||
private final Map<String, List<ApiDocModel.FieldInfo>> baseClassFields = new LinkedHashMap<>();
|
||||
|
||||
/** 原始配置列表(供设置窗口编辑使用) */
|
||||
private List<ApiBaseClassConfig> rawConfigs = new ArrayList<>();
|
||||
|
||||
public ApiBaseClassManager(WorkUtilsProperties properties) {
|
||||
this.properties = properties;
|
||||
}
|
||||
|
||||
@PostConstruct
|
||||
void init() {
|
||||
reload();
|
||||
}
|
||||
|
||||
/** 重新加载配置 */
|
||||
public synchronized void reload() {
|
||||
baseClassFields.clear();
|
||||
rawConfigs.clear();
|
||||
|
||||
Path configFile = getConfigFile();
|
||||
if (!Files.exists(configFile)) {
|
||||
log.warn("API 基类配置文件不存在: {}", configFile.toAbsolutePath());
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
String json = new String(Files.readAllBytes(configFile), StandardCharsets.UTF_8);
|
||||
List<ApiBaseClassConfig> loaded = JSON.parseArray(json, ApiBaseClassConfig.class);
|
||||
if (loaded != null) {
|
||||
rawConfigs = loaded;
|
||||
for (ApiBaseClassConfig config : loaded) {
|
||||
if (config.getFqn() == null || config.getFqn().trim().isEmpty()) continue;
|
||||
List<ApiDocModel.FieldInfo> fields = new ArrayList<>();
|
||||
if (config.getFields() != null) {
|
||||
for (ApiBaseClassConfig.FieldDef fd : config.getFields()) {
|
||||
ApiDocModel.FieldInfo fi = new ApiDocModel.FieldInfo();
|
||||
fi.name = fd.getName();
|
||||
fi.type = fd.getType();
|
||||
fi.javadoc = fd.getJavadoc();
|
||||
fi.annotations = fd.getAnnotations() != null
|
||||
? new ArrayList<>(fd.getAnnotations()) : new ArrayList<>();
|
||||
fields.add(fi);
|
||||
}
|
||||
}
|
||||
baseClassFields.put(config.getFqn().trim(), fields);
|
||||
}
|
||||
}
|
||||
log.info("已加载 {} 个 API 基类定义", baseClassFields.size());
|
||||
} catch (IOException e) {
|
||||
log.error("读取 API 基类配置失败: {}", e.getMessage());
|
||||
} catch (RuntimeException e) {
|
||||
log.error("解析 API 基类配置失败: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** 根据全限定名获取基类字段 */
|
||||
public List<ApiDocModel.FieldInfo> getFields(String fqn) {
|
||||
if (fqn == null) return null;
|
||||
return baseClassFields.get(fqn);
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据短类名查找已知外部类的字段(遍历所有已配置 FQN,匹配短名)
|
||||
* 如 shortName="Response" 可匹配 "cn.com.bestpay.Response"
|
||||
*/
|
||||
public List<ApiDocModel.FieldInfo> getFieldsByShortName(String shortName) {
|
||||
if (shortName == null || shortName.isEmpty()) return null;
|
||||
for (Map.Entry<String, List<ApiDocModel.FieldInfo>> entry : baseClassFields.entrySet()) {
|
||||
String fqn = entry.getKey();
|
||||
String fqnShort = fqn.contains(".") ? fqn.substring(fqn.lastIndexOf('.') + 1) : fqn;
|
||||
if (fqnShort.equals(shortName)) {
|
||||
return entry.getValue();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 获取所有已知基类的 FQN */
|
||||
public Set<String> getKnownFqns() {
|
||||
return Collections.unmodifiableSet(baseClassFields.keySet());
|
||||
}
|
||||
|
||||
/** 获取原始配置列表(供设置窗口使用) */
|
||||
public List<ApiBaseClassConfig> getRawConfigs() {
|
||||
return rawConfigs;
|
||||
}
|
||||
|
||||
/** 保存原始配置并重新加载 */
|
||||
public synchronized void saveConfigs(List<ApiBaseClassConfig> configs) {
|
||||
Path configFile = getConfigFile();
|
||||
try {
|
||||
Files.createDirectories(configFile.getParent());
|
||||
String json = JSON.toJSONString(configs, true);
|
||||
Files.write(configFile, json.getBytes(StandardCharsets.UTF_8));
|
||||
log.info("已保存 API 基类配置: {} 条", configs != null ? configs.size() : 0);
|
||||
reload();
|
||||
} catch (IOException e) {
|
||||
log.error("保存 API 基类配置失败: {}", e.getMessage());
|
||||
throw new RuntimeException("保存 API 基类配置失败: " + e.getMessage(), e);
|
||||
}
|
||||
}
|
||||
|
||||
public Path getConfigFile() {
|
||||
return properties.getApiBaseClassesFilePath();
|
||||
}
|
||||
|
||||
public boolean exists() {
|
||||
return Files.exists(getConfigFile());
|
||||
}
|
||||
}
|
||||
@@ -10,7 +10,10 @@ import java.nio.file.Files;
|
||||
import java.nio.file.Path;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.ArrayList;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* API 文档生成器 —— 输出 HTML / Markdown 格式
|
||||
@@ -59,6 +62,10 @@ public class ApiDocGenerator {
|
||||
|
||||
private String buildHtml(ApiDocModel.ExportSelection selection, List<ApiDocModel.ProjectApiDoc> docs) {
|
||||
StringBuilder sb = new StringBuilder();
|
||||
// 计算统计数据
|
||||
int totalClasses = 0, totalMethods = 0;
|
||||
for (ApiDocModel.ProjectApiDoc d : docs) { totalClasses += d.classes.size(); totalMethods += d.totalMethods(); }
|
||||
|
||||
sb.append("<!DOCTYPE html>\n<html lang=\"zh-CN\">\n<head>\n");
|
||||
sb.append("<meta charset=\"UTF-8\">\n");
|
||||
sb.append("<meta name=\"viewport\" content=\"width=device-width, initial-scale=1.0\">\n");
|
||||
@@ -66,15 +73,27 @@ public class ApiDocGenerator {
|
||||
appendCss(sb);
|
||||
sb.append("</head>\n<body>\n");
|
||||
|
||||
// 顶部栏
|
||||
// 顶部栏(含搜索)
|
||||
sb.append("<header class=\"topbar\">\n");
|
||||
sb.append(" <div class=\"topbar-brand\">📄 API 接口文档</div>\n");
|
||||
sb.append(" <div class=\"topbar-tabs\">\n");
|
||||
sb.append(" <span class=\"tab active\">接口文档</span>\n");
|
||||
sb.append(" </div>\n");
|
||||
sb.append(" <input type=\"text\" id=\"searchInput\" class=\"search-input\" placeholder=\"搜索接口/方法名...\" oninput=\"filterDocs()\">\n");
|
||||
sb.append(" <div class=\"topbar-meta\">生成时间: ").append(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))).append("</div>\n");
|
||||
sb.append("</header>\n");
|
||||
|
||||
// 统计摘要栏
|
||||
sb.append("<div class=\"stats-bar\">\n");
|
||||
sb.append(" <span class=\"stats-item\"><span class=\"stats-num\">").append(docs.size()).append("</span> 模块</span>\n");
|
||||
sb.append(" <span class=\"stats-item\"><span class=\"stats-num\">").append(totalClasses).append("</span> 接口/类</span>\n");
|
||||
sb.append(" <span class=\"stats-item\"><span class=\"stats-num\">").append(totalMethods).append("</span> 方法</span>\n");
|
||||
sb.append(" <span class=\"stats-actions\">\n");
|
||||
sb.append(" <button class=\"ctrl-btn\" onclick=\"expandAll()\" title=\"展开全部\">▼ 展开</button>\n");
|
||||
sb.append(" <button class=\"ctrl-btn\" onclick=\"collapseAll()\" title=\"折叠全部\">▶ 折叠</button>\n");
|
||||
sb.append(" </span>\n");
|
||||
sb.append("</div>\n");
|
||||
|
||||
// 左侧导航 + 右侧内容
|
||||
sb.append("<div class=\"main-layout\">\n");
|
||||
sb.append("<aside class=\"sidebar\" id=\"sidebar\">\n");
|
||||
@@ -194,8 +213,8 @@ public class ApiDocGenerator {
|
||||
sb.append(" <div class=\"method-desc\">").append(escapeHtml(mi.javadoc)).append("</div>\n");
|
||||
}
|
||||
|
||||
// 签名
|
||||
sb.append(" <div class=\"method-signature\"><code>").append(escapeHtml(mi.fullSignature())).append("</code></div>\n");
|
||||
// 签名(带复制按钮)
|
||||
sb.append(" <div class=\"method-signature\"><code>").append(escapeHtml(mi.fullSignature())).append("</code><button class=\"copy-btn\" onclick=\"copyCode(this)\" title=\"复制签名\">复制</button></div>\n");
|
||||
|
||||
// 方法参数
|
||||
if (mi.params != null && !mi.params.isEmpty()) {
|
||||
@@ -215,9 +234,29 @@ public class ApiDocGenerator {
|
||||
renderFieldsHtmlV2(sb, mi.requestFields, "请求参数", 0);
|
||||
}
|
||||
|
||||
// 返回 DTO
|
||||
// 返回包装器(始终先展示)
|
||||
boolean wrapperHasFields = mi.responseWrapperFields != null && !mi.responseWrapperFields.isEmpty();
|
||||
if (mi.responseWrapperInheritedFrom != null) {
|
||||
if (wrapperHasFields) {
|
||||
renderFieldsHtmlV2(sb, mi.responseWrapperFields,
|
||||
"返回结果 · 包装器 " + mi.responseWrapperInheritedFrom, 0);
|
||||
} else {
|
||||
// 未配置的外部类,醒目提示
|
||||
sb.append(" <div class=\"dto-block dto-block-warn\">\n");
|
||||
sb.append(" <div class=\"dto-block-title dto-title-warn\">")
|
||||
.append("⚠️ 返回结果 · 包装器 <code>").append(escapeHtml(mi.responseWrapperInheritedFrom)).append("</code>")
|
||||
.append(" <small class=\"warn-hint\">未配置 · 请在 api_base_classes.json 中补充</small>")
|
||||
.append("</div>\n");
|
||||
sb.append(" </div>\n");
|
||||
}
|
||||
}
|
||||
|
||||
// 返回 DTO(泛型内层 T 的字段)
|
||||
if (mi.responseFields != null && !mi.responseFields.isEmpty()) {
|
||||
renderFieldsHtmlV2(sb, mi.responseFields, "返回结果", 0);
|
||||
String respTitle = mi.responseWrapperInheritedFrom != null
|
||||
? "返回结果 · " + mi.responseInnerType
|
||||
: "返回结果";
|
||||
renderFieldsHtmlV2(sb, mi.responseFields, respTitle, 0);
|
||||
}
|
||||
|
||||
// throws
|
||||
@@ -248,14 +287,24 @@ public class ApiDocGenerator {
|
||||
return sb.toString();
|
||||
}
|
||||
|
||||
/** 新版本字段渲染 —— 可折叠卡片 */
|
||||
/** 新版本字段渲染 —— 可折叠卡片 + 继承分组 */
|
||||
private void renderFieldsHtmlV2(StringBuilder sb, List<ApiDocModel.FieldInfo> fields, String title, int depth) {
|
||||
if (fields == null || fields.isEmpty()) return;
|
||||
String indent = depth > 0 ? " style=\"margin-left:" + (depth * 20) + "px;\"" : "";
|
||||
String borderColor = depth == 0 ? "#1677ff" : depth == 1 ? "#52c41a" : "#fa8c16";
|
||||
String titleIcon = depth == 0 ? "📥" : "└";
|
||||
|
||||
sb.append(" <div class=\"dto-block\"").append(indent).append(">\n");
|
||||
// 按 inheritedFrom 分组
|
||||
List<ApiDocModel.FieldInfo> directFields = new ArrayList<>();
|
||||
Map<String, List<ApiDocModel.FieldInfo>> inheritedGroups = new LinkedHashMap<>();
|
||||
for (ApiDocModel.FieldInfo f : fields) {
|
||||
if (f.inheritedFrom != null && !f.inheritedFrom.isEmpty()) {
|
||||
inheritedGroups.computeIfAbsent(f.inheritedFrom, k -> new ArrayList<>()).add(f);
|
||||
} else {
|
||||
directFields.add(f);
|
||||
}
|
||||
}
|
||||
|
||||
sb.append(" <div class=\"dto-block\">\n");
|
||||
sb.append(" <div class=\"dto-block-title\" style=\"border-left-color:").append(borderColor)
|
||||
.append("\" onclick=\"toggleDto(this)\">")
|
||||
.append("<span class=\"dto-arrow\">▼</span> ")
|
||||
@@ -263,23 +312,81 @@ public class ApiDocGenerator {
|
||||
.append(" <small>(").append(fields.size()).append(" 字段)</small>")
|
||||
.append("</div>\n");
|
||||
sb.append(" <div class=\"dto-block-body\">\n");
|
||||
sb.append(" <table class=\"data-table\"><thead><tr><th>参数名</th><th>参数类型</th><th>描述</th></tr></thead><tbody>\n");
|
||||
for (ApiDocModel.FieldInfo f : fields) {
|
||||
sb.append(" <tr>");
|
||||
sb.append("<td><code class=\"field-name\">").append(escapeHtml(f.name)).append("</code></td>");
|
||||
sb.append("<td><code>").append(escapeHtml(f.type)).append("</code></td>");
|
||||
sb.append("<td>").append(escapeHtml(f.javadoc != null ? f.javadoc : "—")).append("</td>");
|
||||
sb.append("</tr>\n");
|
||||
}
|
||||
sb.append(" </tbody></table>\n");
|
||||
sb.append(" </div>\n");
|
||||
sb.append(" </div>\n");
|
||||
|
||||
for (ApiDocModel.FieldInfo f : fields) {
|
||||
if (f.children != null && !f.children.isEmpty()) {
|
||||
renderFieldsHtmlV2(sb, f.children, f.type, depth + 1);
|
||||
// 单表格:直接字段 + 子字段全部内联
|
||||
sb.append(" <table class=\"data-table\"><thead><tr><th>参数名</th><th>参数类型</th><th>描述</th></tr></thead><tbody>\n");
|
||||
renderFieldRows(sb, directFields, 0);
|
||||
sb.append(" </tbody></table>\n");
|
||||
|
||||
// 继承分组
|
||||
if (!inheritedGroups.isEmpty()) {
|
||||
for (Map.Entry<String, List<ApiDocModel.FieldInfo>> entry : inheritedGroups.entrySet()) {
|
||||
sb.append(" <div class=\"inherit-section\">");
|
||||
sb.append("<span class=\"inherit-header\">↑ 继承自 <code>").append(escapeHtml(entry.getKey())).append("</code></span>");
|
||||
sb.append("<table class=\"data-table\"><thead><tr><th>参数名</th><th>参数类型</th><th>描述</th></tr></thead><tbody>\n");
|
||||
renderFieldRows(sb, entry.getValue(), 0);
|
||||
sb.append(" </tbody></table></div>\n");
|
||||
}
|
||||
}
|
||||
|
||||
sb.append(" </div>\n");
|
||||
sb.append(" </div>\n");
|
||||
}
|
||||
|
||||
/** 递归渲染字段行(父子内联在同一表格中) */
|
||||
private void renderFieldRows(StringBuilder sb, List<ApiDocModel.FieldInfo> fields, int nesting) {
|
||||
if (fields == null) return;
|
||||
for (ApiDocModel.FieldInfo f : fields) {
|
||||
boolean hasChildren = f.children != null && !f.children.isEmpty();
|
||||
String indentPx = nesting > 0 ? String.valueOf(nesting * 24) : "0";
|
||||
String depthClass = "d" + Math.min(nesting, 3);
|
||||
|
||||
sb.append(" <tr class=\"field-row ").append(depthClass).append("\">");
|
||||
// 参数名(含缩进)
|
||||
sb.append("<td><span class=\"field-indent\" style=\"padding-left:").append(indentPx).append("px\"></span>");
|
||||
if (hasChildren) {
|
||||
sb.append("<span class=\"field-expand-toggle\" onclick=\"toggleChildren(this)\">▶</span> ");
|
||||
}
|
||||
sb.append("<code class=\"field-name\">").append(escapeHtml(f.name)).append("</code></td>");
|
||||
// 类型(三级区分:有子字段/自定义类型/基础类型)
|
||||
sb.append("<td>");
|
||||
if (hasChildren) {
|
||||
sb.append("<code class=\"type-complex\">").append(escapeHtml(f.type)).append("</code>");
|
||||
} else if (isCustomType(f.type)) {
|
||||
sb.append("<code class=\"type-custom\">").append(escapeHtml(f.type)).append("</code>");
|
||||
} else {
|
||||
sb.append("<code>").append(escapeHtml(f.type)).append("</code>");
|
||||
}
|
||||
sb.append("</td>");
|
||||
// 描述
|
||||
String desc = escapeHtml(truncateFqnInJavadoc(f.javadoc != null ? f.javadoc : "—"));
|
||||
sb.append("<td>").append(desc).append("</td>");
|
||||
sb.append("</tr>\n");
|
||||
|
||||
// 递归渲染子字段
|
||||
if (hasChildren) {
|
||||
renderFieldRows(sb, f.children, nesting + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断类型是否为自定义类型(非 JDK 基础类型) */
|
||||
private static final java.util.Set<String> BUILTIN_TYPES = new java.util.HashSet<>(java.util.Arrays.asList(
|
||||
"String", "Integer", "Long", "Short", "Byte", "Double", "Float", "Boolean", "Character",
|
||||
"int", "long", "short", "byte", "double", "float", "boolean", "char", "void",
|
||||
"BigDecimal", "BigInteger", "Date", "LocalDate", "LocalDateTime", "LocalTime",
|
||||
"Object", "Class", "Serializable", "String", "Void",
|
||||
"List", "Set", "Map", "Collection", "ArrayList", "HashMap", "HashSet", "LinkedHashMap",
|
||||
"MultipartFile", "byte[]"
|
||||
));
|
||||
|
||||
private boolean isCustomType(String type) {
|
||||
if (type == null || type.isEmpty()) return false;
|
||||
// 剥离泛型
|
||||
String clean = type.contains("<") ? type.substring(0, type.indexOf('<')) : type;
|
||||
// 剥离数组
|
||||
clean = clean.replace("[]", "");
|
||||
return !BUILTIN_TYPES.contains(clean) && !clean.startsWith("java.");
|
||||
}
|
||||
|
||||
/** 提取 HTTP 方法注解(@GetMapping → GET) */
|
||||
@@ -313,13 +420,25 @@ public class ApiDocGenerator {
|
||||
sb.append(".topbar-tabs{display:flex;gap:0}\n");
|
||||
sb.append(".tab{padding:12px 20px;font-size:14px;color:#86909c;cursor:pointer;border-bottom:2px solid transparent;transition:.2s}\n");
|
||||
sb.append(".tab.active{color:#1677ff;border-bottom-color:#1677ff;font-weight:600}\n");
|
||||
sb.append(".topbar-meta{margin-left:auto;font-size:12px;color:#86909c}\n");
|
||||
sb.append(".topbar-meta{margin-left:auto;font-size:12px;color:#86909c;white-space:nowrap}\n");
|
||||
|
||||
// 搜索框
|
||||
sb.append(".search-input{padding:5px 12px;border:1px solid #e5e6eb;border-radius:4px;font-size:13px;outline:none;width:200px;transition:border .2s,box-shadow .2s;font-family:inherit}\n");
|
||||
sb.append(".search-input:focus{border-color:#1677ff;box-shadow:0 0 0 2px rgba(22,119,255,.1)}\n");
|
||||
|
||||
// 统计摘要栏
|
||||
sb.append(".stats-bar{display:flex;align-items:center;gap:20px;padding:6px 20px;background:#fff;border-bottom:1px solid #e5e6eb;font-size:12px;color:#86909c}\n");
|
||||
sb.append(".stats-item{display:flex;align-items:center;gap:3px}\n");
|
||||
sb.append(".stats-num{font-weight:700;font-size:14px;color:#1677ff}\n");
|
||||
sb.append(".stats-actions{margin-left:auto;display:flex;gap:6px}\n");
|
||||
sb.append(".ctrl-btn{padding:3px 10px;border:1px solid #e5e6eb;border-radius:3px;background:#fff;color:#4e5969;font-size:11px;cursor:pointer;transition:.15s;font-family:inherit}\n");
|
||||
sb.append(".ctrl-btn:hover{border-color:#1677ff;color:#1677ff;background:#e8f3ff}\n");
|
||||
|
||||
// 布局
|
||||
sb.append(".main-layout{display:flex;flex:1}\n");
|
||||
|
||||
// 侧栏
|
||||
sb.append(".sidebar{width:280px;min-width:280px;background:#fff;border-right:1px solid #e5e6eb;overflow-y:auto;padding:16px 0;position:sticky;top:48px;height:calc(100vh - 48px)}\n");
|
||||
sb.append(".sidebar{width:280px;min-width:280px;background:#fff;border-right:1px solid #e5e6eb;overflow-y:auto;padding:16px 0;position:sticky;top:78px;height:calc(100vh - 78px)}\n");
|
||||
sb.append(".sidebar-title{font-size:14px;font-weight:700;color:#1d1d1f;padding:0 16px 12px;border-bottom:1px solid #f2f3f5;margin-bottom:8px}\n");
|
||||
sb.append(".nav-group{margin-bottom:4px}\n");
|
||||
sb.append(".nav-group-title{padding:8px 16px;font-size:13px;font-weight:600;color:#4e5969;cursor:pointer;display:flex;align-items:center;gap:6px;user-select:none;transition:.15s}\n");
|
||||
@@ -391,8 +510,11 @@ public class ApiDocGenerator {
|
||||
sb.append(".method-header.collapsed+.method-body{display:none}\n");
|
||||
sb.append(".method-annotations{margin-bottom:8px}\n");
|
||||
sb.append(".method-desc{font-size:13px;color:#4e5969;padding:8px 12px;background:#fafbfc;border-radius:4px;margin-bottom:10px;border-left:3px solid #e5e6eb;white-space:pre-wrap}\n");
|
||||
sb.append(".method-signature{padding:10px 14px;background:#1e1e1e;border-radius:6px;margin-bottom:12px;overflow-x:auto}\n");
|
||||
sb.append(".method-signature code{font-family:'Consolas','Courier New',monospace;font-size:13px;color:#d4d4d4}\n");
|
||||
sb.append(".method-signature{padding:10px 14px;background:#1e1e1e;border-radius:6px;margin-bottom:12px;overflow-x:auto;position:relative;display:flex;align-items:center}\n");
|
||||
sb.append(".method-signature code{font-family:'Consolas','Courier New',monospace;font-size:13px;color:#d4d4d4;flex:1}\n");
|
||||
sb.append(".copy-btn{position:absolute;right:8px;top:50%;transform:translateY(-50%);padding:3px 10px;border:1px solid #555;border-radius:3px;background:transparent;color:#999;font-size:11px;cursor:pointer;transition:.15s;font-family:inherit}\n");
|
||||
sb.append(".copy-btn:hover{color:#fff;border-color:#888}\n");
|
||||
sb.append(".copy-btn.copied{color:#52c41a;border-color:#52c41a}\n");
|
||||
|
||||
// 参数块
|
||||
sb.append(".params-block{margin-bottom:12px}\n");
|
||||
@@ -408,19 +530,50 @@ public class ApiDocGenerator {
|
||||
sb.append(".dto-block-body{padding:0}\n");
|
||||
sb.append(".dto-block-title.collapsed+.dto-block-body{display:none}\n");
|
||||
|
||||
// 未配置包装器警告样式
|
||||
sb.append(".dto-block-warn{border-color:#ff7d00;background:#fff7e6}\n");
|
||||
sb.append(".dto-title-warn{border-left-color:#ff7d00!important;background:#fff7e6!important;color:#d46b08!important}\n");
|
||||
sb.append(".warn-hint{color:#ff7d00!important;font-weight:600;font-size:11px;background:#fff0d6;padding:2px 8px;border-radius:3px;margin-left:6px}\n");
|
||||
|
||||
// 表格
|
||||
sb.append(".data-table{width:100%;border-collapse:collapse;font-size:12px}\n");
|
||||
sb.append(".data-table th{background:#fafbfc;text-align:left;padding:7px 10px;border:1px solid #e5e6eb;font-weight:600;color:#4e5969;white-space:nowrap}\n");
|
||||
sb.append(".data-table td{padding:7px 10px;border:1px solid #e5e6eb;color:#1d1d1f}\n");
|
||||
sb.append(".data-table td{padding:7px 10px;border:1px solid #e5e6eb;color:#1d1d1f;vertical-align:top}\n");
|
||||
sb.append(".data-table td:first-child{min-width:200px}\n");
|
||||
sb.append(".data-table tr:hover td{background:#f7f8fa}\n");
|
||||
sb.append(".data-table code{font-family:'Consolas','Courier New',monospace;font-size:11px;background:#f2f3f5;padding:1px 5px;border-radius:2px;color:#1677ff}\n");
|
||||
sb.append(".data-table code.field-name{color:#d46b08}\n");
|
||||
|
||||
// 字段行层级样式
|
||||
sb.append(".field-indent{display:inline-block;min-width:0}\n");
|
||||
sb.append(".field-expand-toggle{display:inline-block;font-size:10px;color:#86909c;cursor:pointer;width:12px;text-align:center;transition:transform .15s;user-select:none}\n");
|
||||
sb.append(".field-expand-toggle.open{transform:rotate(90deg)}\n");
|
||||
sb.append(".field-row.d0 td{background:#fff}\n");
|
||||
sb.append(".field-row.d1 td{background:#f9fbff}\n");
|
||||
sb.append(".field-row.d2 td{background:#f6f9ff}\n");
|
||||
sb.append(".field-row.d3 td{background:#f3f6ff}\n");
|
||||
sb.append(".field-row.d1 td:first-child{border-left:3px solid #52c41a}\n");
|
||||
sb.append(".field-row.d2 td:first-child{border-left:3px solid #fa8c16}\n");
|
||||
sb.append(".field-row.d3 td:first-child{border-left:3px solid #722ed1}\n");
|
||||
sb.append(".type-complex{color:#722ed1!important;background:#f3e8ff!important}\n");
|
||||
sb.append(".type-custom{color:#0958d9!important;background:#e6f4ff!important;border:1px solid #91caff}\n");
|
||||
|
||||
sb.append(".fqn{font-size:11px;color:#86909c;font-family:'Consolas','Courier New',monospace}\n");
|
||||
sb.append(".inherit-tag{font-size:11px;color:#86909c;font-style:italic;margin-left:4px}\n");
|
||||
sb.append(".inherit-section{border-top:1px dashed #e5e6eb;padding-top:8px;margin-top:8px}\n");
|
||||
sb.append(".inherit-header{display:inline-block;font-size:11px;font-weight:600;color:#86909c;padding:2px 8px;background:#f7f8fa;border-radius:3px;margin-bottom:6px}\n");
|
||||
sb.append(".inherit-header code{font-size:11px;color:#1677ff;background:#e8f3ff;padding:1px 5px;border-radius:2px}\n");
|
||||
|
||||
// throws
|
||||
sb.append(".throws-block{font-size:12px;color:#f53f3f;padding:6px 0}\n");
|
||||
sb.append(".throws-block code{background:#fff2f0;padding:2px 6px;border-radius:2px}\n");
|
||||
|
||||
// 搜索高亮
|
||||
sb.append(".api-card.search-hidden,.nav-item.search-hidden,.nav-group.search-hidden{display:none!important}\n");
|
||||
sb.append(".method-card.search-hidden{display:none!important}\n");
|
||||
sb.append(".search-highlight{background:#fff3cd;padding:1px 2px;border-radius:2px}\n");
|
||||
sb.append(".search-match{box-shadow:0 0 0 2px rgba(22,119,255,.25)}\n");
|
||||
|
||||
// 响应式
|
||||
sb.append("@media(max-width:900px){.sidebar{display:none}.content{max-width:100vw}}\n");
|
||||
sb.append("</style>\n");
|
||||
@@ -429,38 +582,137 @@ public class ApiDocGenerator {
|
||||
/** JS 交互 */
|
||||
private void appendJs(StringBuilder sb) {
|
||||
sb.append("<script>\n");
|
||||
// 侧栏分组折叠
|
||||
sb.append("document.querySelectorAll('.nav-group-title').forEach(function(el){el.classList.add('open')});\n");
|
||||
sb.append("function toggleGroup(el){el.classList.toggle('open')}\n");
|
||||
// 方法折叠
|
||||
sb.append("function toggleMethod(el){el.classList.toggle('collapsed')}\n");
|
||||
// DTO 折叠
|
||||
sb.append("function toggleDto(el){el.classList.toggle('collapsed')}\n");
|
||||
// 侧栏项目激活高亮
|
||||
sb.append("function toggleChildren(el){\n");
|
||||
sb.append(" el.classList.toggle('open');\n");
|
||||
sb.append(" var tr=el.closest('tr'),depth=tr.className.match(/d(\\d)/);\n");
|
||||
sb.append(" var d=depth?parseInt(depth[1]):0;\n");
|
||||
sb.append(" var next=tr.nextElementSibling;\n");
|
||||
sb.append(" while(next){\n");
|
||||
sb.append(" var m=next.className.match(/d(\\d)/);\n");
|
||||
sb.append(" if(!m||parseInt(m[1])<=d)break;\n");
|
||||
sb.append(" next.style.display=el.classList.contains('open')?'':'none';\n");
|
||||
sb.append(" next=next.nextElementSibling;\n");
|
||||
sb.append(" }\n");
|
||||
sb.append("}\n");
|
||||
sb.append("var navItems=document.querySelectorAll('.nav-item');\n");
|
||||
sb.append("navItems.forEach(function(a){a.addEventListener('click',function(){navItems.forEach(function(x){x.classList.remove('active')});a.classList.add('active')})});\n");
|
||||
// 默认展开第一个项目组
|
||||
sb.append("var firstGroup=document.querySelector('.nav-group-title');if(firstGroup)firstGroup.classList.add('open');\n");
|
||||
// 展开全部
|
||||
sb.append("function expandAll(){\n");
|
||||
sb.append(" document.querySelectorAll('.nav-group-title').forEach(function(el){el.classList.add('open')});\n");
|
||||
sb.append(" document.querySelectorAll('.method-header.collapsed').forEach(function(el){el.classList.remove('collapsed')});\n");
|
||||
sb.append(" document.querySelectorAll('.dto-block-title.collapsed').forEach(function(el){el.classList.remove('collapsed')});\n");
|
||||
sb.append(" document.querySelectorAll('.field-expand-toggle:not(.open)').forEach(function(el){toggleChildren(el)});\n");
|
||||
sb.append("}\n");
|
||||
// 折叠全部
|
||||
sb.append("function collapseAll(){\n");
|
||||
sb.append(" document.querySelectorAll('.nav-group-title').forEach(function(el){el.classList.remove('open')});\n");
|
||||
sb.append(" document.querySelectorAll('.method-header:not(.collapsed)').forEach(function(el){el.classList.add('collapsed')});\n");
|
||||
sb.append(" document.querySelectorAll('.dto-block-title:not(.collapsed)').forEach(function(el){el.classList.add('collapsed')});\n");
|
||||
sb.append(" document.querySelectorAll('.field-expand-toggle.open').forEach(function(el){toggleChildren(el)});\n");
|
||||
sb.append("}\n");
|
||||
// 搜索过滤
|
||||
sb.append("function filterDocs(){\n");
|
||||
sb.append(" var q=document.getElementById('searchInput').value.trim().toLowerCase();\n");
|
||||
sb.append(" document.querySelectorAll('.api-card').forEach(function(card){\n");
|
||||
sb.append(" if(!q){card.classList.remove('search-hidden','search-match');\n");
|
||||
sb.append(" card.querySelectorAll('.method-card').forEach(function(m){m.classList.remove('search-hidden')});return;}\n");
|
||||
sb.append(" var cls=card.querySelector('.api-name');\n");
|
||||
sb.append(" var cm=cls?cls.textContent.toLowerCase():'';\n");
|
||||
sb.append(" var cf=card.querySelector('.info-value.mono');\n");
|
||||
sb.append(" var cq=cf?cf.textContent.toLowerCase():'';\n");
|
||||
sb.append(" var classMatch=cm.indexOf(q)>=0||cq.indexOf(q)>=0;\n");
|
||||
sb.append(" var anyMethod=false;\n");
|
||||
sb.append(" card.querySelectorAll('.method-card').forEach(function(mc){\n");
|
||||
sb.append(" var mn=mc.querySelector('.method-name');\n");
|
||||
sb.append(" var mt=mn?mn.textContent.toLowerCase():'';\n");
|
||||
sb.append(" if(mt.indexOf(q)>=0){mc.classList.remove('search-hidden');anyMethod=true;}\n");
|
||||
sb.append(" else{mc.classList.add('search-hidden')}\n");
|
||||
sb.append(" });\n");
|
||||
sb.append(" if(classMatch||anyMethod){card.classList.remove('search-hidden');card.classList.add('search-match');\n");
|
||||
sb.append(" if(anyMethod)card.querySelectorAll('.method-header.collapsed').forEach(function(h){h.classList.remove('collapsed')});}\n");
|
||||
sb.append(" else{card.classList.add('search-hidden');card.classList.remove('search-match')}\n");
|
||||
sb.append(" });\n");
|
||||
sb.append(" document.querySelectorAll('.nav-group').forEach(function(g){\n");
|
||||
sb.append(" var vis=g.querySelectorAll('.nav-item:not(.search-hidden)');\n");
|
||||
sb.append(" if(q&&vis.length===0)g.classList.add('search-hidden');else g.classList.remove('search-hidden');\n");
|
||||
sb.append(" if(q&&vis.length>0)g.querySelector('.nav-group-title').classList.add('open');\n");
|
||||
sb.append(" });\n");
|
||||
sb.append(" document.querySelectorAll('.nav-item').forEach(function(item){\n");
|
||||
sb.append(" if(!q){item.classList.remove('search-hidden');return;}\n");
|
||||
sb.append(" var t=item.textContent.toLowerCase();\n");
|
||||
sb.append(" if(t.indexOf(q)>=0)item.classList.remove('search-hidden');else item.classList.add('search-hidden');\n");
|
||||
sb.append(" });\n");
|
||||
sb.append("}\n");
|
||||
// 复制签名
|
||||
sb.append("function copyCode(btn){\n");
|
||||
sb.append(" var code=btn.parentElement.querySelector('code');\n");
|
||||
sb.append(" var text=code.textContent;\n");
|
||||
sb.append(" if(navigator.clipboard){navigator.clipboard.writeText(text);}\n");
|
||||
sb.append(" else{var ta=document.createElement('textarea');ta.value=text;document.body.appendChild(ta);ta.select();document.execCommand('copy');document.body.removeChild(ta);}\n");
|
||||
sb.append(" btn.textContent='\\u2714';btn.classList.add('copied');\n");
|
||||
sb.append(" setTimeout(function(){btn.textContent='\\u590D\\u5236';btn.classList.remove('copied');},1500);\n");
|
||||
sb.append("}\n");
|
||||
sb.append("</script>\n");
|
||||
}
|
||||
|
||||
/** 递归渲染字段表(Markdown) */
|
||||
/** 递归渲染字段表(Markdown)—— 继承分组 */
|
||||
private void renderFieldsMd(StringBuilder sb, List<ApiDocModel.FieldInfo> fields, String title, int depth) {
|
||||
if (fields == null || fields.isEmpty()) return;
|
||||
String indent = depth > 0 ? " ".repeat(depth) : "";
|
||||
String icon = depth == 0 ? "📥 " : " └ ";
|
||||
sb.append(indent).append("**").append(icon).append(title).append("**(").append(fields.size()).append(" 字段)\n\n");
|
||||
sb.append(indent).append("| 参数名 | 参数类型 | 描述 |\n");
|
||||
sb.append(indent).append("|--------|----------|------|\n");
|
||||
|
||||
// 按 inheritedFrom 分组
|
||||
List<ApiDocModel.FieldInfo> directFields = new ArrayList<>();
|
||||
Map<String, List<ApiDocModel.FieldInfo>> inheritedGroups = new LinkedHashMap<>();
|
||||
for (ApiDocModel.FieldInfo f : fields) {
|
||||
sb.append(indent).append("| `").append(f.name).append("` | `").append(f.type).append("` | ")
|
||||
.append(f.javadoc != null ? f.javadoc.replace("\n", " ").replace("|", "\\|") : "—").append(" |\n");
|
||||
if (f.inheritedFrom != null && !f.inheritedFrom.isEmpty()) {
|
||||
inheritedGroups.computeIfAbsent(f.inheritedFrom, k -> new ArrayList<>()).add(f);
|
||||
} else {
|
||||
directFields.add(f);
|
||||
}
|
||||
}
|
||||
|
||||
sb.append("**").append(icon).append(title).append("**(").append(fields.size()).append(" 字段)\n\n");
|
||||
|
||||
// 单表格:直接字段 + 子字段全部内联
|
||||
sb.append("| 参数名 | 参数类型 | 描述 |\n");
|
||||
sb.append("|--------|----------|------|\n");
|
||||
renderFieldRowsMd(sb, directFields, 0);
|
||||
sb.append("\n");
|
||||
|
||||
// 继承分组
|
||||
for (Map.Entry<String, List<ApiDocModel.FieldInfo>> entry : inheritedGroups.entrySet()) {
|
||||
sb.append("**↑ 继承自 `").append(entry.getKey()).append("`**\n\n");
|
||||
sb.append("| 参数名 | 参数类型 | 描述 |\n");
|
||||
sb.append("|--------|----------|------|\n");
|
||||
renderFieldRowsMd(sb, entry.getValue(), 0);
|
||||
sb.append("\n");
|
||||
}
|
||||
}
|
||||
|
||||
/** 递归渲染 Markdown 字段行(父子内联在同一表格中) */
|
||||
private void renderFieldRowsMd(StringBuilder sb, List<ApiDocModel.FieldInfo> fields, int nesting) {
|
||||
if (fields == null) return;
|
||||
for (ApiDocModel.FieldInfo f : fields) {
|
||||
String indent = nesting > 0 ? " ".repeat(nesting * 4) + "↳ " : "";
|
||||
String desc = f.javadoc != null
|
||||
? truncateFqnInJavadoc(f.javadoc).replace("\n", " ").replace("|", "\\|")
|
||||
: "—";
|
||||
boolean hasChildren = f.children != null && !f.children.isEmpty();
|
||||
String typeDisplay = f.type;
|
||||
if (hasChildren) {
|
||||
typeDisplay = "📦 " + f.type;
|
||||
} else if (isCustomType(f.type)) {
|
||||
typeDisplay = "📋 " + f.type;
|
||||
}
|
||||
sb.append("| ").append(indent).append("`").append(f.name).append("` | `").append(typeDisplay).append("` | ")
|
||||
.append(desc).append(" |\n");
|
||||
// 递归子字段
|
||||
if (f.children != null && !f.children.isEmpty()) {
|
||||
renderFieldsMd(sb, f.children, f.type, depth + 1);
|
||||
renderFieldRowsMd(sb, f.children, nesting + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -560,9 +812,23 @@ public class ApiDocGenerator {
|
||||
renderFieldsMd(sb, mi.requestFields, "请求参数", 0);
|
||||
}
|
||||
|
||||
// 返回 DTO
|
||||
// 返回包装器(始终先展示)
|
||||
boolean wrapperHasFieldsMd = mi.responseWrapperFields != null && !mi.responseWrapperFields.isEmpty();
|
||||
if (mi.responseWrapperInheritedFrom != null) {
|
||||
if (wrapperHasFieldsMd) {
|
||||
renderFieldsMd(sb, mi.responseWrapperFields,
|
||||
"返回结果 · 包装器 " + mi.responseWrapperInheritedFrom, 0);
|
||||
} else {
|
||||
sb.append("\n> ⚠️ **返回结果 · 包装器 `").append(mi.responseWrapperInheritedFrom).append("`** — 未配置,请在 `api_base_classes.json` 中补充\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
// 返回 DTO(泛型内层 T 的字段)
|
||||
if (mi.responseFields != null && !mi.responseFields.isEmpty()) {
|
||||
renderFieldsMd(sb, mi.responseFields, "返回结果", 0);
|
||||
String respTitleMd = mi.responseWrapperInheritedFrom != null
|
||||
? "返回结果 · " + mi.responseInnerType
|
||||
: "返回结果";
|
||||
renderFieldsMd(sb, mi.responseFields, respTitleMd, 0);
|
||||
}
|
||||
|
||||
// throws
|
||||
@@ -622,4 +888,10 @@ public class ApiDocGenerator {
|
||||
return s.replace("&", "&").replace("<", "<").replace(">", ">")
|
||||
.replace("\"", """).replace("'", "'");
|
||||
}
|
||||
|
||||
/** 截断 javadoc 中的全限定类名,只保留短名(如 com.xxx.XxxStatus → XxxStatus) */
|
||||
private String truncateFqnInJavadoc(String javadoc) {
|
||||
if (javadoc == null) return null;
|
||||
return javadoc.replaceAll("(?:[a-z][a-z0-9]*\\.){2,}([A-Z][\\w$]*)", "$1");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,8 +46,12 @@ public class ApiDocModel {
|
||||
|
||||
/** 已解析的请求 DTO 字段(深层展开) */
|
||||
public List<FieldInfo> requestFields;
|
||||
/** 已解析的响应 DTO 字段(深层展开) */
|
||||
/** 已解析的响应 DTO 字段(深层展开,泛型内层 T 的字段) */
|
||||
public List<FieldInfo> responseFields;
|
||||
/** 响应包装器字段(如 Response 外层字段:code, message 等) */
|
||||
public List<FieldInfo> responseWrapperFields;
|
||||
/** 响应包装器短名(如 "Response"),用于继承来源标记 */
|
||||
public String responseWrapperInheritedFrom;
|
||||
|
||||
/** 完整方法签名 */
|
||||
public String fullSignature() {
|
||||
@@ -89,6 +93,7 @@ public class ApiDocModel {
|
||||
public String javadoc; // 字段 JavaDoc
|
||||
public List<String> annotations; // 字段上的注解
|
||||
public List<FieldInfo> children; // 嵌套子字段(如果类型是复杂 DTO)
|
||||
public String inheritedFrom; // 继承来源(父类短名),null 表示当前类直接定义
|
||||
|
||||
public FieldInfo() {
|
||||
this.annotations = new ArrayList<>();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
package com.example.workutils.biz.apidoc.parser;
|
||||
|
||||
import com.example.workutils.biz.apidoc.config.ApiBaseClassManager;
|
||||
import com.example.workutils.biz.apidoc.model.ApiDocModel;
|
||||
import com.example.workutils.biz.apidoc.model.ApiDocModel.ClassInfo;
|
||||
import com.example.workutils.biz.apidoc.model.ApiDocModel.FieldInfo;
|
||||
@@ -58,6 +59,11 @@ public class JavaSourceParser {
|
||||
// === 泛型提取 ===
|
||||
private static final Pattern GENERIC_INNER = Pattern.compile("\\w+<(.+)>");
|
||||
|
||||
// 判断是否为泛型参数(如 T, E, K, V 等)—— 单个大写字母,不是实际 DTO 类型
|
||||
private static boolean isGenericTypeVariable(String type) {
|
||||
return type != null && type.matches("[A-Z]");
|
||||
}
|
||||
|
||||
// === JDK / 基础类型,不需要深层展开 ===
|
||||
private static final Set<String> SKIP_TYPES = new HashSet<>(Arrays.asList(
|
||||
"String", "Integer", "Long", "Short", "Byte", "Double", "Float", "Boolean", "Character",
|
||||
@@ -452,11 +458,29 @@ public class JavaSourceParser {
|
||||
|
||||
/** 已解析 DTO 缓存: FQN → 字段列表 */
|
||||
private final Map<String, List<FieldInfo>> dtoCache = new HashMap<>();
|
||||
/** 内部类缓存: 父类FQN → (内部类短名 → 字段列表) */
|
||||
private final Map<String, Map<String, List<FieldInfo>>> innerClassCache = new HashMap<>();
|
||||
/** 正在解析的标记(防止循环引用) */
|
||||
private static final List<FieldInfo> RESOLVING_MARKER = Collections.emptyList();
|
||||
/** 最大递归深度 */
|
||||
private static final int MAX_DTO_DEPTH = 4;
|
||||
|
||||
/** 外部 jar 基类字段管理器(可配置) */
|
||||
private final ApiBaseClassManager apiBaseClassManager;
|
||||
/** 额外的 src/main/java 目录(兄弟模块,用于跨模块 DTO 查找) */
|
||||
private List<Path> additionalSrcBases = Collections.emptyList();
|
||||
|
||||
public JavaSourceParser(ApiBaseClassManager apiBaseClassManager) {
|
||||
this.apiBaseClassManager = apiBaseClassManager;
|
||||
}
|
||||
|
||||
/** 设置额外的 src/main/java 目录(兄弟模块),用于跨模块 DTO 源文件查找 */
|
||||
public void setAdditionalSrcBases(List<Path> additionalSrcBases) {
|
||||
this.additionalSrcBases = additionalSrcBases != null ? additionalSrcBases : Collections.emptyList();
|
||||
}
|
||||
|
||||
/**
|
||||
* 对 ClassInfo 中每个方法的参数和返回值进行 DTO 深层解析
|
||||
* 对 ClassInfo 中每个方法的参数和返回值进行 DTO 深层解析(基于源码文件,无需 Maven 依赖)
|
||||
* @param ci 类信息
|
||||
* @param srcBaseDir src/main/java 根目录
|
||||
*/
|
||||
@@ -474,9 +498,29 @@ public class JavaSourceParser {
|
||||
if (gm.matches()) {
|
||||
mi.responseInnerType = gm.group(1).trim();
|
||||
}
|
||||
// 解析返回 DTO 字段(试所有可能的 FQN)
|
||||
// 解析返回 DTO 字段(基于源码文件)
|
||||
mi.responseFields = resolveDtoFieldsByShortName(mi.responseInnerType, ci, srcBaseDir);
|
||||
|
||||
// 如果有泛型包装器(如 Response<T>),同时解析外层包装器的字段
|
||||
if (gm.matches()) {
|
||||
String wrapperName = stripGenerics(mi.returnType);
|
||||
if (!isSkipType(wrapperName) && !isGenericTypeVariable(wrapperName)) {
|
||||
// 始终记录包装器短名,即使无法解析字段(如外部 JAR 的 Response 类)
|
||||
String wrapperShort = wrapperName.contains(".")
|
||||
? wrapperName.substring(wrapperName.lastIndexOf('.') + 1) : wrapperName;
|
||||
mi.responseWrapperInheritedFrom = wrapperShort;
|
||||
List<FieldInfo> wrapperFields = resolveDtoFieldsByShortName(wrapperName, ci, srcBaseDir);
|
||||
// 回退:通过短名查找已知外部类(如 cn.com.bestpay.Response)
|
||||
if (wrapperFields == null || wrapperFields.isEmpty()) {
|
||||
List<FieldInfo> knownFields = apiBaseClassManager.getFieldsByShortName(wrapperShort);
|
||||
if (knownFields != null && !knownFields.isEmpty()) {
|
||||
wrapperFields = deepCopyFields(knownFields);
|
||||
}
|
||||
}
|
||||
mi.responseWrapperFields = wrapperFields;
|
||||
}
|
||||
}
|
||||
|
||||
// 2) 解析请求参数 DTO
|
||||
if (mi.params != null && !mi.params.isEmpty()) {
|
||||
ParamInfo firstParam = mi.params.get(0);
|
||||
@@ -491,22 +535,24 @@ public class JavaSourceParser {
|
||||
*/
|
||||
private List<FieldInfo> resolveDtoFieldsByShortName(String shortName, ClassInfo ci, Path srcBaseDir) {
|
||||
if (shortName == null || shortName.isEmpty()) return null;
|
||||
if (isInvalidTypeName(shortName)) return null; // 含非法字符(如引号),说明被错误解析
|
||||
if (isSkipType(shortName) || shortName.contains(".")) {
|
||||
// 已是全限定名或为基础类型,直接按 FQN 解析
|
||||
return resolveDtoFields(shortName.contains(".") ? shortName : ("java.lang." + shortName), srcBaseDir);
|
||||
if (isInvalidTypeName(shortName)) return null;
|
||||
if (isGenericTypeVariable(shortName)) return null; // 泛型类型变量(T/E/K/V),非实际 DTO
|
||||
// 提前剥离泛型(如 PageResDTO<XxxDTO> → PageResDTO)
|
||||
String cleanName = stripGenerics(shortName);
|
||||
if (isSkipType(cleanName) || cleanName.contains(".")) {
|
||||
return resolveDtoFields(cleanName.contains(".") ? cleanName : ("java.lang." + cleanName), srcBaseDir);
|
||||
}
|
||||
|
||||
// 1) 精确 import
|
||||
if (ci.imports != null && ci.imports.containsKey(shortName)) {
|
||||
List<FieldInfo> r = resolveDtoFields(ci.imports.get(shortName), srcBaseDir);
|
||||
if (ci.imports != null && ci.imports.containsKey(cleanName)) {
|
||||
List<FieldInfo> r = resolveDtoFields(ci.imports.get(cleanName), srcBaseDir);
|
||||
if (r != null) return r;
|
||||
}
|
||||
|
||||
// 2) 通配 import
|
||||
if (ci.wildcardImports != null) {
|
||||
for (String pkg : ci.wildcardImports) {
|
||||
String candidate = pkg + "." + shortName;
|
||||
String candidate = pkg + "." + cleanName;
|
||||
List<FieldInfo> r = resolveDtoFields(candidate, srcBaseDir);
|
||||
if (r != null) return r;
|
||||
}
|
||||
@@ -514,14 +560,14 @@ public class JavaSourceParser {
|
||||
|
||||
// 3) 同包
|
||||
if (ci.packageName != null && !ci.packageName.isEmpty()) {
|
||||
return resolveDtoFields(ci.packageName + "." + shortName, srcBaseDir);
|
||||
return resolveDtoFields(ci.packageName + "." + cleanName, srcBaseDir);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 尝试多个 FQN 候选,返回第一个能找到文件的 FQN
|
||||
* 尝试多个 FQN 候选,返回第一个能找到源码文件的 FQN
|
||||
*/
|
||||
private String resolveBestFqn(String shortName, ClassInfo ci, Path srcBaseDir) {
|
||||
if (shortName == null || shortName.isEmpty()) return shortName;
|
||||
@@ -552,46 +598,89 @@ public class JavaSourceParser {
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 FQN 查找 DTO 源文件并解析字段
|
||||
* 根据 FQN 查找 DTO 源文件并解析字段(含父类字段递归合并 + 嵌套 DTO 展开)
|
||||
*/
|
||||
private List<FieldInfo> resolveDtoFields(String fqn, Path srcBaseDir) {
|
||||
if (fqn == null || fqn.isEmpty()) return null;
|
||||
if (isSkipType(fqn)) return null;
|
||||
// 剥离泛型(如 PageResDTO<XxxDTO> → PageResDTO),泛型部分不影响文件查找
|
||||
String cleanFqn = stripGenerics(fqn);
|
||||
if (isSkipType(cleanFqn)) return null;
|
||||
|
||||
// 检查缓存
|
||||
if (dtoCache.containsKey(fqn)) {
|
||||
List<FieldInfo> cached = dtoCache.get(fqn);
|
||||
return cached != null ? deepCopyFields(cached) : null;
|
||||
// 检查缓存(使用清理后的 key)
|
||||
if (dtoCache.containsKey(cleanFqn)) {
|
||||
List<FieldInfo> cached = dtoCache.get(cleanFqn);
|
||||
// RESOLVING_MARKER 表示正在解析中(循环引用),返回 null 中断递归
|
||||
if (cached == null || cached == RESOLVING_MARKER) return null;
|
||||
return deepCopyFields(cached);
|
||||
}
|
||||
|
||||
Path dtoFile = resolveDtonToPath(fqn, srcBaseDir);
|
||||
// 检查已知的外部 jar 基类(通过 ApiBaseClassManager 可配置,无需源码文件)
|
||||
List<FieldInfo> knownFields = apiBaseClassManager.getFields(cleanFqn);
|
||||
if (knownFields != null) {
|
||||
dtoCache.put(cleanFqn, knownFields);
|
||||
// 已知基类的嵌套字段无需展开(都是基础类型/String/Map)
|
||||
return deepCopyFields(knownFields);
|
||||
}
|
||||
|
||||
Path dtoFile = resolveDtonToPath(cleanFqn, srcBaseDir);
|
||||
if (dtoFile == null || !Files.isRegularFile(dtoFile)) {
|
||||
dtoCache.put(fqn, null);
|
||||
dtoCache.put(cleanFqn, null);
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
String content = new String(Files.readAllBytes(dtoFile), StandardCharsets.UTF_8);
|
||||
List<FieldInfo> fields = parseFields(content, dtoFile.toString());
|
||||
dtoCache.put(fqn, fields);
|
||||
|
||||
// 递归解析字段中的 DTO 类型
|
||||
// 提取并缓存内部类定义
|
||||
Map<String, List<FieldInfo>> innerClasses = extractInnerClasses(content, dtoFile.toString());
|
||||
if (!innerClasses.isEmpty()) {
|
||||
innerClassCache.put(cleanFqn, innerClasses);
|
||||
}
|
||||
|
||||
// 解析这个 DTO 文件的 imports(用于嵌套 DTO 和父类查找)
|
||||
ClassInfo tempCi = new ClassInfo();
|
||||
tempCi.packageName = extractPackageFromContent(content);
|
||||
|
||||
// 解析这个 DTO 文件的 imports
|
||||
Map<String, String> dtoImports = new LinkedHashMap<>();
|
||||
List<String> dtoWildcardImports = new ArrayList<>();
|
||||
parseImportsFromContent(content, dtoImports, dtoWildcardImports);
|
||||
tempCi.imports = dtoImports;
|
||||
tempCi.wildcardImports = dtoWildcardImports;
|
||||
|
||||
// 解析父类字段并合并(父类在前,子类字段在后)
|
||||
String superClassShort = extractSuperClassName(content);
|
||||
if (superClassShort != null && !superClassShort.isEmpty()) {
|
||||
String superFqn = resolveBestFqn(superClassShort, tempCi, srcBaseDir);
|
||||
if (superFqn != null && !superFqn.equals(fqn) && !isSkipType(superFqn)) {
|
||||
List<FieldInfo> superFields = resolveDtoFields(superFqn, srcBaseDir);
|
||||
if (superFields != null && !superFields.isEmpty()) {
|
||||
// 标记继承来源
|
||||
for (FieldInfo sf : superFields) {
|
||||
if (sf.inheritedFrom == null) {
|
||||
sf.inheritedFrom = superClassShort;
|
||||
}
|
||||
}
|
||||
List<FieldInfo> merged = new ArrayList<>(superFields);
|
||||
if (fields != null) merged.addAll(fields);
|
||||
fields = merged;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 先放入标记防止循环引用(缓存在嵌套展开后再更新)
|
||||
dtoCache.put(cleanFqn, RESOLVING_MARKER);
|
||||
|
||||
// 递归展开每个字段中的嵌套 DTO
|
||||
if (fields != null) {
|
||||
for (FieldInfo f : fields) {
|
||||
resolveNestedFields(f, tempCi, srcBaseDir, 0);
|
||||
}
|
||||
}
|
||||
return fields != null ? deepCopyFields(fields) : null;
|
||||
|
||||
// 嵌套展开完成后再缓存(包含 children 的完整版本)
|
||||
dtoCache.put(cleanFqn, fields != null ? fields : Collections.emptyList());
|
||||
return deepCopyFields(fields);
|
||||
} catch (IOException e) {
|
||||
log.debug("读取 DTO 文件失败: {} -> {}", dtoFile, e.getMessage());
|
||||
dtoCache.put(fqn, null);
|
||||
@@ -600,7 +689,55 @@ public class JavaSourceParser {
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归解析嵌套 DTO 字段
|
||||
* FQN → .java 文件路径(相对于 src/main/java 根目录)
|
||||
*/
|
||||
private Path resolveDtonToPath(String fqn, Path srcBase) {
|
||||
if (fqn == null || fqn.isEmpty()) return null;
|
||||
// 防御性去除泛型参数
|
||||
String cleanFqn = stripGenerics(fqn);
|
||||
String relativePath = cleanFqn.replace('.', '/') + ".java";
|
||||
|
||||
// 1) 主 srcBase
|
||||
if (srcBase != null) {
|
||||
Path result = srcBase.resolve(relativePath);
|
||||
if (Files.isRegularFile(result)) return result;
|
||||
}
|
||||
|
||||
// 2) 兄弟模块的 srcBase
|
||||
for (Path additional : additionalSrcBases) {
|
||||
if (additional.equals(srcBase)) continue;
|
||||
Path result = additional.resolve(relativePath);
|
||||
if (Files.isRegularFile(result)) return result;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 剥离 FQN 中的泛型参数:com.Xxx<A,B> → com.Xxx,List<A> → List
|
||||
* 连同泛型内的嵌套尖括号一起处理(如 Map<String,List<A>> → Map)
|
||||
*/
|
||||
private String stripGenerics(String fqn) {
|
||||
if (fqn == null) return null;
|
||||
if (!fqn.contains("<")) return fqn;
|
||||
// 找到第一个尖括号位置,跳过整个泛型段(含嵌套)
|
||||
int idx = fqn.indexOf('<');
|
||||
int depth = 0;
|
||||
for (int i = idx; i < fqn.length(); i++) {
|
||||
char c = fqn.charAt(i);
|
||||
if (c == '<') depth++;
|
||||
else if (c == '>') depth--;
|
||||
if (depth == 0) {
|
||||
// 去掉泛型部分,保留剩余部分(如 implements/extends 多段声明中的后段)
|
||||
return (fqn.substring(0, idx) + fqn.substring(i + 1)).trim();
|
||||
}
|
||||
}
|
||||
// 泛型未闭合,回退到简单截断
|
||||
return fqn.substring(0, idx).trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 递归解析字段中的嵌套 DTO(如 List<XxxDTO> → 展开 XxxDTO 的字段)
|
||||
*/
|
||||
private void resolveNestedFields(FieldInfo field, ClassInfo contextCi, Path srcBaseDir, int depth) {
|
||||
if (depth >= MAX_DTO_DEPTH) return;
|
||||
@@ -615,11 +752,37 @@ public class JavaSourceParser {
|
||||
|
||||
if (isSkipType(innerType)) return;
|
||||
|
||||
String cleanInner = stripGenerics(innerType);
|
||||
|
||||
// 优先检查内部类缓存(父类中定义的 static inner class)
|
||||
for (Map<String, List<FieldInfo>> innerMap : innerClassCache.values()) {
|
||||
if (innerMap.containsKey(cleanInner)) {
|
||||
field.children = deepCopyFields(innerMap.get(cleanInner));
|
||||
for (FieldInfo child : field.children) {
|
||||
resolveNestedFields(child, contextCi, srcBaseDir, depth + 1);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 使用多候选解析
|
||||
List<FieldInfo> children = resolveDtoFieldsByShortName(innerType, contextCi, srcBaseDir);
|
||||
|
||||
// 回退:通过短名查找已知外部类
|
||||
if ((children == null || children.isEmpty()) && !innerType.contains(".")) {
|
||||
List<FieldInfo> knownFields = apiBaseClassManager.getFieldsByShortName(cleanInner);
|
||||
if (knownFields != null && !knownFields.isEmpty()) {
|
||||
children = deepCopyFields(knownFields);
|
||||
}
|
||||
}
|
||||
|
||||
if (children != null && !children.isEmpty()) {
|
||||
field.fullQualifiedName = resolveBestFqn(innerType, contextCi, srcBaseDir);
|
||||
field.children = deepCopyFields(children);
|
||||
// 递归展开子字段的嵌套 DTO
|
||||
for (FieldInfo child : field.children) {
|
||||
resolveNestedFields(child, contextCi, srcBaseDir, depth + 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -636,6 +799,7 @@ public class JavaSourceParser {
|
||||
copy.fullQualifiedName = f.fullQualifiedName;
|
||||
copy.javadoc = f.javadoc;
|
||||
copy.annotations = new ArrayList<>(f.annotations);
|
||||
copy.inheritedFrom = f.inheritedFrom;
|
||||
copy.children = f.children != null ? deepCopyFields(f.children) : new ArrayList<>();
|
||||
result.add(copy);
|
||||
}
|
||||
@@ -669,6 +833,19 @@ public class JavaSourceParser {
|
||||
braceDepth += countChar(tl, '{') - countChar(tl, '}');
|
||||
if (braceDepth <= 0) break; // 类结束
|
||||
|
||||
// --- 内部类/接口/枚举体:跳过其所有字段,由 extractInnerClasses 单独处理 ---
|
||||
if (braceDepth == 1 && (tl.contains(" class ") || tl.contains(" interface ") || tl.contains(" enum "))
|
||||
&& !FIELD_PATTERN.matcher(tl).matches() && !tl.startsWith("//")) {
|
||||
// 进入内部类体,跳过直到 braceDepth 回到 1
|
||||
while (braceDepth > 1 && i + 1 < lines.length) {
|
||||
i++;
|
||||
braceDepth += countChar(lines[i].trim(), '{') - countChar(lines[i].trim(), '}');
|
||||
}
|
||||
pendingJavadoc = null;
|
||||
pendingAnnotations.clear();
|
||||
continue;
|
||||
}
|
||||
|
||||
// --- JavaDoc ---
|
||||
if (tl.startsWith("/**")) {
|
||||
pendingJavadoc = extractJavadoc(lines, i);
|
||||
@@ -718,6 +895,80 @@ public class JavaSourceParser {
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* 提取源文件中的内部类(static class / class)及其字段
|
||||
*/
|
||||
private Map<String, List<FieldInfo>> extractInnerClasses(String content, String sourceFile) {
|
||||
Map<String, List<FieldInfo>> result = new LinkedHashMap<>();
|
||||
String[] lines = content.split("\\r?\\n");
|
||||
boolean inOuterClass = false;
|
||||
int outerBraceDepth = 0;
|
||||
|
||||
for (int i = 0; i < lines.length; i++) {
|
||||
String tl = lines[i].trim();
|
||||
if (tl.isEmpty()) continue;
|
||||
|
||||
if (!inOuterClass) {
|
||||
if (tl.contains(" class ") || tl.contains(" interface ")) {
|
||||
inOuterClass = true;
|
||||
if (tl.contains("{")) outerBraceDepth = countChar(tl, '{') - countChar(tl, '}');
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
outerBraceDepth += countChar(tl, '{') - countChar(tl, '}');
|
||||
if (outerBraceDepth <= 0) break;
|
||||
|
||||
// 检测内部类定义(在外部类的第一层大括号内)
|
||||
if (outerBraceDepth == 1 && (tl.contains(" class ") || tl.contains(" interface "))
|
||||
&& !FIELD_PATTERN.matcher(tl).matches() && !tl.startsWith("//")) {
|
||||
// 提取内部类名
|
||||
String innerClassName = null;
|
||||
Matcher cm = Pattern.compile("(?:class|interface)\\s+(\\w+)").matcher(tl);
|
||||
if (cm.find()) innerClassName = cm.group(1);
|
||||
if (innerClassName == null) continue;
|
||||
|
||||
// 收集内部类的完整内容(按大括号深度跟踪)
|
||||
StringBuilder innerContent = new StringBuilder();
|
||||
innerContent.append("class ").append(innerClassName).append(" {\n");
|
||||
int innerDepth = countChar(tl, '{') - countChar(tl, '}');
|
||||
while (innerDepth > 0 && i + 1 < lines.length) {
|
||||
i++;
|
||||
String il = lines[i].trim();
|
||||
innerDepth += countChar(il, '{') - countChar(il, '}');
|
||||
if (innerDepth > 0) innerContent.append(il).append("\n");
|
||||
}
|
||||
innerContent.append("}\n");
|
||||
|
||||
// 解析内部类字段
|
||||
List<FieldInfo> innerFields = parseFields(innerContent.toString(), sourceFile + "$" + innerClassName);
|
||||
result.put(innerClassName, innerFields);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 从内容中提取 extends 的父类短名(去除泛型) */
|
||||
private String extractSuperClassName(String content) {
|
||||
for (String line : content.split("\\r?\n")) {
|
||||
String tl = line.trim();
|
||||
Matcher m = CLASS_PATTERN.matcher(tl);
|
||||
if (m.matches()) {
|
||||
String superClass = m.group(7);
|
||||
if (superClass != null && !superClass.trim().isEmpty()) {
|
||||
String raw = superClass.trim();
|
||||
int genericIdx = raw.indexOf('<');
|
||||
if (genericIdx > 0) {
|
||||
raw = raw.substring(0, genericIdx).trim();
|
||||
}
|
||||
return raw;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 从内容中提取包名 */
|
||||
private String extractPackageFromContent(String content) {
|
||||
for (String line : content.split("\\r?\\n")) {
|
||||
@@ -743,48 +994,6 @@ public class JavaSourceParser {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据全限定名找到 DTO .java 文件
|
||||
*/
|
||||
private Path resolveDtonToPath(String fqn, Path srcBaseDir) {
|
||||
if (fqn == null || srcBaseDir == null) return null;
|
||||
String relativePath;
|
||||
try {
|
||||
relativePath = fqn.replace('.', '/') + ".java";
|
||||
} catch (Exception e) {
|
||||
log.debug("非法 FQN,跳过 DTO 解析: {}", fqn);
|
||||
return null;
|
||||
}
|
||||
Path direct;
|
||||
try {
|
||||
direct = srcBaseDir.resolve(relativePath);
|
||||
} catch (Exception e) {
|
||||
log.debug("非法路径,跳过 DTO 解析: {}", relativePath);
|
||||
return null;
|
||||
}
|
||||
if (Files.isRegularFile(direct)) return direct;
|
||||
|
||||
// 如果不在当前模块,尝试扫描父目录下所有模块的 src/main/java
|
||||
Path parent = srcBaseDir.getParent(); // src/main
|
||||
if (parent != null) {
|
||||
Path grandParent = parent.getParent(); // src
|
||||
if (grandParent != null) {
|
||||
Path projectRoot = grandParent.getParent(); // 工程根
|
||||
if (projectRoot != null) {
|
||||
try (Stream<Path> dirs = Files.list(projectRoot)) {
|
||||
Optional<Path> found = dirs
|
||||
.filter(Files::isDirectory)
|
||||
.map(d -> d.resolve("src/main/java").resolve(relativePath))
|
||||
.filter(Files::isRegularFile)
|
||||
.findFirst();
|
||||
if (found.isPresent()) return found.get();
|
||||
} catch (IOException ignored) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 跳过不展开的基础类型 */
|
||||
private boolean isSkipType(String fqn) {
|
||||
if (fqn == null) return true;
|
||||
|
||||
@@ -66,6 +66,9 @@ public class WorkUtilsProperties {
|
||||
/** 插件状态配置文件(相对路径时相对于 user.dir) */
|
||||
private String pluginsConfigFile;
|
||||
|
||||
/** API 基类配置文件(相对路径时相对于 user.dir) */
|
||||
private String apiBaseClassesFile;
|
||||
|
||||
/** 工具启用/禁用配置 */
|
||||
private ToolsConfig tools;
|
||||
|
||||
@@ -297,6 +300,17 @@ public class WorkUtilsProperties {
|
||||
return Paths.get(System.getProperty("user.dir")).resolve(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* API 基类配置文件的实际路径
|
||||
*/
|
||||
public Path getApiBaseClassesFilePath() {
|
||||
Path p = Paths.get(apiBaseClassesFile != null ? apiBaseClassesFile : "data/api_base_classes.json");
|
||||
if (p.isAbsolute()) {
|
||||
return p;
|
||||
}
|
||||
return Paths.get(System.getProperty("user.dir")).resolve(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* 确保所有数据文件存在于 user.dir 同级 data/ 目录。
|
||||
* 启动时统一检查:不存在则从 classpath:templates/ 下复制模板。
|
||||
@@ -309,6 +323,7 @@ public class WorkUtilsProperties {
|
||||
ensureDataFile(getDamagedReposFilePath(), "templates/damaged_repos.cache", "损坏缓存文件");
|
||||
ensureDataFile(getMybatisGeneratorFilePath(), "templates/mybatis-generator.properties", "MyBatis生成器配置");
|
||||
ensureDataFile(getPluginsConfigFilePath(), "templates/plugins.config", "插件配置文件");
|
||||
ensureDataFile(getApiBaseClassesFilePath(), "templates/api_base_classes.json", "API基类配置");
|
||||
}
|
||||
|
||||
private void ensureDataFile(Path target, String classpathResource, String label) {
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
[
|
||||
{
|
||||
"fqn": "com.bestpay.drip.commons.support.BaseDTO",
|
||||
"fields": [
|
||||
{ "name": "reqId", "type": "String", "javadoc": "请求流水号(50位)", "annotations": ["@NotBlank(message=\"reqID不能为空\")"] },
|
||||
{ "name": "instId", "type": "String", "javadoc": "指令流水号,业务幂等性使用", "annotations": [] },
|
||||
{ "name": "ext", "type": "Map<String,String>", "javadoc": "扩展字段", "annotations": [] },
|
||||
{ "name": "optUser", "type": "String", "javadoc": "操作人", "annotations": [] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"fqn": "com.bestpay.cf.codeutils.common.base.BaseReqDTO",
|
||||
"fields": [
|
||||
{ "name": "requestSystem", "type": "String", "javadoc": "请求系统标识", "annotations": ["@NotBlank"] },
|
||||
{ "name": "traceLogId", "type": "String", "javadoc": "链路日志ID", "annotations": ["@NotBlank"] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"fqn": "cn.com.bestpay.Response",
|
||||
"fields": [
|
||||
{ "name": "success", "type": "boolean", "javadoc": "是否成功", "annotations": [] },
|
||||
{ "name": "result", "type": "T", "javadoc": "返回结果(泛型内层数据)", "annotations": [] },
|
||||
{ "name": "errorCode", "type": "String", "javadoc": "错误码", "annotations": [] },
|
||||
{ "name": "errorMsg", "type": "String", "javadoc": "错误信息", "annotations": [] },
|
||||
{ "name": "primaryErrorCode", "type": "String", "javadoc": "主错误码", "annotations": [] },
|
||||
{ "name": "primaryErrorMsg", "type": "String", "javadoc": "主错误信息", "annotations": [] },
|
||||
{ "name": "primaryErrorIP", "type": "String", "javadoc": "主错误IP", "annotations": [] }
|
||||
]
|
||||
},
|
||||
{
|
||||
"fqn": "com.bestpay.drip.commons.support.Result",
|
||||
"fields": [
|
||||
{ "name": "t", "type": "T", "javadoc": "返回结果(泛型内层数据)", "annotations": [] },
|
||||
{ "name": "errCd", "type": "String", "javadoc": "错误码 8位 = 2位中台域+2位系统域+4位错误码", "annotations": [] },
|
||||
{ "name": "errMsg", "type": "String", "javadoc": "错误描述", "annotations": [] },
|
||||
{ "name": "suggestStrategy", "type": "String", "javadoc": "错误处理建议", "annotations": [] },
|
||||
{ "name": "errLocation", "type": "String", "javadoc": "错误位置信息 class#method:line", "annotations": [] },
|
||||
{ "name": "primaryErrIP", "type": "String", "javadoc": "原始错误IP", "annotations": [] },
|
||||
{ "name": "primaryErrLocation", "type": "String", "javadoc": "原始错误位置信息 class#method:line", "annotations": [] },
|
||||
{ "name": "primaryErrCd", "type": "String", "javadoc": "原始错误码 8位 = 2位中台域+2位系统域+4位错误码", "annotations": [] },
|
||||
{ "name": "primaryErrMsg", "type": "String", "javadoc": "原始错误描述", "annotations": [] },
|
||||
{ "name": "errCdTrace", "type": "String", "javadoc": "错误码链路,逗号分割。8位为一个元素", "annotations": [] }
|
||||
]
|
||||
}
|
||||
]
|
||||
Reference in New Issue
Block a user