From 8b64a0e6b14de74e753ce56c99a746d9a4c96a51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E5=AE=B6=E8=AE=AD?= Date: Thu, 9 Jul 2026 15:39:31 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9A=82=E6=97=B6=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workutils/biz/apidoc/ApiDocPlugin.java | 6 +- .../workutils/biz/apidoc/ApiDocWindow.java | 34 +- .../biz/apidoc/config/ApiBaseClassConfig.java | 44 +++ .../apidoc/config/ApiBaseClassManager.java | 135 +++++++ .../biz/apidoc/generator/ApiDocGenerator.java | 358 +++++++++++++++--- .../biz/apidoc/model/ApiDocModel.java | 7 +- .../biz/apidoc/parser/JavaSourceParser.java | 341 +++++++++++++---- .../system/config/WorkUtilsProperties.java | 15 + .../resources/templates/api_base_classes.json | 45 +++ 9 files changed, 868 insertions(+), 117 deletions(-) create mode 100644 src/main/java/com/example/workutils/biz/apidoc/config/ApiBaseClassConfig.java create mode 100644 src/main/java/com/example/workutils/biz/apidoc/config/ApiBaseClassManager.java create mode 100644 src/main/resources/templates/api_base_classes.json diff --git a/src/main/java/com/example/workutils/biz/apidoc/ApiDocPlugin.java b/src/main/java/com/example/workutils/biz/apidoc/ApiDocPlugin.java index 16309d5..4aff6a2 100644 --- a/src/main/java/com/example/workutils/biz/apidoc/ApiDocPlugin.java +++ b/src/main/java/com/example/workutils/biz/apidoc/ApiDocPlugin.java @@ -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); diff --git a/src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java b/src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java index 18337a5..2b8e058 100644 --- a/src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java +++ b/src/main/java/com/example/workutils/biz/apidoc/ApiDocWindow.java @@ -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 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 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 collectSiblingSrcBases(Path srcBase) { + List 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 modules, Set seen) { if (!Files.isDirectory(dir)) return; Path sb = dir.resolve("src/main/java"); diff --git a/src/main/java/com/example/workutils/biz/apidoc/config/ApiBaseClassConfig.java b/src/main/java/com/example/workutils/biz/apidoc/config/ApiBaseClassConfig.java new file mode 100644 index 0000000..8ab838d --- /dev/null +++ b/src/main/java/com/example/workutils/biz/apidoc/config/ApiBaseClassConfig.java @@ -0,0 +1,44 @@ +package com.example.workutils.biz.apidoc.config; + +import java.util.ArrayList; +import java.util.List; + +/** + * API 基类定义(用于解析 jar 依赖中无法读取源码的外部基类字段) + *

+ * 存储在 data/api_base_classes.json,支持在系统设置中编辑。 + */ +public class ApiBaseClassConfig { + + /** 全限定类名,如 com.bestpay.drip.commons.support.BaseDTO */ + private String fqn; + + /** 基类字段列表 */ + private List fields = new ArrayList<>(); + + public String getFqn() { return fqn; } + public void setFqn(String fqn) { this.fqn = fqn; } + + public List getFields() { return fields; } + public void setFields(List fields) { this.fields = fields; } + + /** 单个字段定义 */ + public static class FieldDef { + private String name; + private String type; + private String javadoc; + private List 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 getAnnotations() { return annotations; } + public void setAnnotations(List annotations) { this.annotations = annotations; } + } +} diff --git a/src/main/java/com/example/workutils/biz/apidoc/config/ApiBaseClassManager.java b/src/main/java/com/example/workutils/biz/apidoc/config/ApiBaseClassManager.java new file mode 100644 index 0000000..22b0230 --- /dev/null +++ b/src/main/java/com/example/workutils/biz/apidoc/config/ApiBaseClassManager.java @@ -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> baseClassFields = new LinkedHashMap<>(); + + /** 原始配置列表(供设置窗口编辑使用) */ + private List 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 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 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 getFields(String fqn) { + if (fqn == null) return null; + return baseClassFields.get(fqn); + } + + /** + * 根据短类名查找已知外部类的字段(遍历所有已配置 FQN,匹配短名) + * 如 shortName="Response" 可匹配 "cn.com.bestpay.Response" + */ + public List getFieldsByShortName(String shortName) { + if (shortName == null || shortName.isEmpty()) return null; + for (Map.Entry> 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 getKnownFqns() { + return Collections.unmodifiableSet(baseClassFields.keySet()); + } + + /** 获取原始配置列表(供设置窗口使用) */ + public List getRawConfigs() { + return rawConfigs; + } + + /** 保存原始配置并重新加载 */ + public synchronized void saveConfigs(List 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()); + } +} diff --git a/src/main/java/com/example/workutils/biz/apidoc/generator/ApiDocGenerator.java b/src/main/java/com/example/workutils/biz/apidoc/generator/ApiDocGenerator.java index 4105b83..83cf2d1 100644 --- a/src/main/java/com/example/workutils/biz/apidoc/generator/ApiDocGenerator.java +++ b/src/main/java/com/example/workutils/biz/apidoc/generator/ApiDocGenerator.java @@ -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 docs) { StringBuilder sb = new StringBuilder(); + // 计算统计数据 + int totalClasses = 0, totalMethods = 0; + for (ApiDocModel.ProjectApiDoc d : docs) { totalClasses += d.classes.size(); totalMethods += d.totalMethods(); } + sb.append("\n\n\n"); sb.append("\n"); sb.append("\n"); @@ -66,15 +73,27 @@ public class ApiDocGenerator { appendCss(sb); sb.append("\n\n"); - // 顶部栏 + // 顶部栏(含搜索) sb.append("

\n"); sb.append("
📄 API 接口文档
\n"); sb.append("
\n"); sb.append(" 接口文档\n"); sb.append("
\n"); + sb.append(" \n"); sb.append("
生成时间: ").append(LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"))).append("
\n"); sb.append("
\n"); + // 统计摘要栏 + sb.append("
\n"); + sb.append(" ").append(docs.size()).append(" 模块\n"); + sb.append(" ").append(totalClasses).append(" 接口/类\n"); + sb.append(" ").append(totalMethods).append(" 方法\n"); + sb.append(" \n"); + sb.append(" \n"); + sb.append(" \n"); + sb.append(" \n"); + sb.append("
\n"); + // 左侧导航 + 右侧内容 sb.append("
\n"); sb.append("
\n"); + } + + /** 递归渲染字段行(父子内联在同一表格中) */ + private void renderFieldRows(StringBuilder sb, List 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(" "); + // 参数名(含缩进) + sb.append(""); + if (hasChildren) { + sb.append("▶ "); + } + sb.append("").append(escapeHtml(f.name)).append(""); + // 类型(三级区分:有子字段/自定义类型/基础类型) + sb.append(""); + if (hasChildren) { + sb.append("").append(escapeHtml(f.type)).append(""); + } else if (isCustomType(f.type)) { + sb.append("").append(escapeHtml(f.type)).append(""); + } else { + sb.append("").append(escapeHtml(f.type)).append(""); + } + sb.append(""); + // 描述 + String desc = escapeHtml(truncateFqnInJavadoc(f.javadoc != null ? f.javadoc : "—")); + sb.append("").append(desc).append(""); + sb.append("\n"); + + // 递归渲染子字段 + if (hasChildren) { + renderFieldRows(sb, f.children, nesting + 1); + } + } + } + + /** 判断类型是否为自定义类型(非 JDK 基础类型) */ + private static final java.util.Set 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("\n"); @@ -429,38 +582,137 @@ public class ApiDocGenerator { /** JS 交互 */ private void appendJs(StringBuilder sb) { sb.append("\n"); } - /** 递归渲染字段表(Markdown) */ + /** 递归渲染字段表(Markdown)—— 继承分组 */ private void renderFieldsMd(StringBuilder sb, List 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 directFields = new ArrayList<>(); + Map> 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> 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 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"); + } } diff --git a/src/main/java/com/example/workutils/biz/apidoc/model/ApiDocModel.java b/src/main/java/com/example/workutils/biz/apidoc/model/ApiDocModel.java index 46fd5b4..ef8dd37 100644 --- a/src/main/java/com/example/workutils/biz/apidoc/model/ApiDocModel.java +++ b/src/main/java/com/example/workutils/biz/apidoc/model/ApiDocModel.java @@ -46,8 +46,12 @@ public class ApiDocModel { /** 已解析的请求 DTO 字段(深层展开) */ public List requestFields; - /** 已解析的响应 DTO 字段(深层展开) */ + /** 已解析的响应 DTO 字段(深层展开,泛型内层 T 的字段) */ public List responseFields; + /** 响应包装器字段(如 Response 外层字段:code, message 等) */ + public List responseWrapperFields; + /** 响应包装器短名(如 "Response"),用于继承来源标记 */ + public String responseWrapperInheritedFrom; /** 完整方法签名 */ public String fullSignature() { @@ -89,6 +93,7 @@ public class ApiDocModel { public String javadoc; // 字段 JavaDoc public List annotations; // 字段上的注解 public List children; // 嵌套子字段(如果类型是复杂 DTO) + public String inheritedFrom; // 继承来源(父类短名),null 表示当前类直接定义 public FieldInfo() { this.annotations = new ArrayList<>(); diff --git a/src/main/java/com/example/workutils/biz/apidoc/parser/JavaSourceParser.java b/src/main/java/com/example/workutils/biz/apidoc/parser/JavaSourceParser.java index 29304ce..e3b647d 100644 --- a/src/main/java/com/example/workutils/biz/apidoc/parser/JavaSourceParser.java +++ b/src/main/java/com/example/workutils/biz/apidoc/parser/JavaSourceParser.java @@ -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 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> dtoCache = new HashMap<>(); + /** 内部类缓存: 父类FQN → (内部类短名 → 字段列表) */ + private final Map>> innerClassCache = new HashMap<>(); + /** 正在解析的标记(防止循环引用) */ + private static final List RESOLVING_MARKER = Collections.emptyList(); /** 最大递归深度 */ private static final int MAX_DTO_DEPTH = 4; + /** 外部 jar 基类字段管理器(可配置) */ + private final ApiBaseClassManager apiBaseClassManager; + /** 额外的 src/main/java 目录(兄弟模块,用于跨模块 DTO 查找) */ + private List additionalSrcBases = Collections.emptyList(); + + public JavaSourceParser(ApiBaseClassManager apiBaseClassManager) { + this.apiBaseClassManager = apiBaseClassManager; + } + + /** 设置额外的 src/main/java 目录(兄弟模块),用于跨模块 DTO 源文件查找 */ + public void setAdditionalSrcBases(List 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),同时解析外层包装器的字段 + 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 wrapperFields = resolveDtoFieldsByShortName(wrapperName, ci, srcBaseDir); + // 回退:通过短名查找已知外部类(如 cn.com.bestpay.Response) + if (wrapperFields == null || wrapperFields.isEmpty()) { + List 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 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 → 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 r = resolveDtoFields(ci.imports.get(shortName), srcBaseDir); + if (ci.imports != null && ci.imports.containsKey(cleanName)) { + List 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 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 resolveDtoFields(String fqn, Path srcBaseDir) { if (fqn == null || fqn.isEmpty()) return null; - if (isSkipType(fqn)) return null; + // 剥离泛型(如 PageResDTO → PageResDTO),泛型部分不影响文件查找 + String cleanFqn = stripGenerics(fqn); + if (isSkipType(cleanFqn)) return null; - // 检查缓存 - if (dtoCache.containsKey(fqn)) { - List cached = dtoCache.get(fqn); - return cached != null ? deepCopyFields(cached) : null; + // 检查缓存(使用清理后的 key) + if (dtoCache.containsKey(cleanFqn)) { + List 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 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 fields = parseFields(content, dtoFile.toString()); - dtoCache.put(fqn, fields); - // 递归解析字段中的 DTO 类型 + // 提取并缓存内部类定义 + Map> 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 dtoImports = new LinkedHashMap<>(); List 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 superFields = resolveDtoFields(superFqn, srcBaseDir); + if (superFields != null && !superFields.isEmpty()) { + // 标记继承来源 + for (FieldInfo sf : superFields) { + if (sf.inheritedFrom == null) { + sf.inheritedFrom = superClassShort; + } + } + List 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 → com.Xxx,List → List + * 连同泛型内的嵌套尖括号一起处理(如 Map> → 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 的字段) */ 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> 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 children = resolveDtoFieldsByShortName(innerType, contextCi, srcBaseDir); + + // 回退:通过短名查找已知外部类 + if ((children == null || children.isEmpty()) && !innerType.contains(".")) { + List 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> extractInnerClasses(String content, String sourceFile) { + Map> 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 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 dirs = Files.list(projectRoot)) { - Optional 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; diff --git a/src/main/java/com/example/workutils/system/config/WorkUtilsProperties.java b/src/main/java/com/example/workutils/system/config/WorkUtilsProperties.java index b5d34b6..5fadb56 100644 --- a/src/main/java/com/example/workutils/system/config/WorkUtilsProperties.java +++ b/src/main/java/com/example/workutils/system/config/WorkUtilsProperties.java @@ -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) { diff --git a/src/main/resources/templates/api_base_classes.json b/src/main/resources/templates/api_base_classes.json new file mode 100644 index 0000000..c3fa05c --- /dev/null +++ b/src/main/resources/templates/api_base_classes.json @@ -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", "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": [] } + ] + } +]