From a90edbafd30f1d501673c1a59a9d2b567e6ed819 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E9=B9=8F?= Date: Tue, 7 Jul 2026 16:18:14 +0800 Subject: [PATCH] feat: collect project source for code analysis --- .../front/dto/CodeAnalysisResponse.java | 38 +++ .../front/dto/CodeAnalysisSourceSummary.java | 79 ++++++ .../dto/GenerateCodeAnalysisRequest.java | 18 ++ .../front/ProjectCodeAnalysisService.java | 247 ++++++++++++++++++ .../front/ProjectCodeAnalysisServiceTest.java | 130 +++++++++ 5 files changed, 512 insertions(+) create mode 100644 RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/CodeAnalysisResponse.java create mode 100644 RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/CodeAnalysisSourceSummary.java create mode 100644 RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/GenerateCodeAnalysisRequest.java create mode 100644 RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/ProjectCodeAnalysisService.java create mode 100644 RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/ProjectCodeAnalysisServiceTest.java diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/CodeAnalysisResponse.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/CodeAnalysisResponse.java new file mode 100644 index 0000000..538f963 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/CodeAnalysisResponse.java @@ -0,0 +1,38 @@ +package com.ruoyi.generator.domain.front.dto; + +import java.util.ArrayList; +import java.util.List; + +public class CodeAnalysisResponse +{ + private String title; + private String markdown; + private List
sections = new ArrayList
(); + private CodeAnalysisSourceSummary sourceSnapshot; + private List warnings = new ArrayList(); + + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public String getMarkdown() { return markdown; } + public void setMarkdown(String markdown) { this.markdown = markdown; } + public List
getSections() { return sections; } + public void setSections(List
sections) { this.sections = sections; } + public CodeAnalysisSourceSummary getSourceSnapshot() { return sourceSnapshot; } + public void setSourceSnapshot(CodeAnalysisSourceSummary sourceSnapshot) { this.sourceSnapshot = sourceSnapshot; } + public List getWarnings() { return warnings; } + public void setWarnings(List warnings) { this.warnings = warnings; } + + public static class Section + { + private String key; + private String title; + private Boolean complete = Boolean.FALSE; + + public String getKey() { return key; } + public void setKey(String key) { this.key = key; } + public String getTitle() { return title; } + public void setTitle(String title) { this.title = title; } + public Boolean getComplete() { return complete; } + public void setComplete(Boolean complete) { this.complete = complete; } + } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/CodeAnalysisSourceSummary.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/CodeAnalysisSourceSummary.java new file mode 100644 index 0000000..31b0ea5 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/CodeAnalysisSourceSummary.java @@ -0,0 +1,79 @@ +package com.ruoyi.generator.domain.front.dto; + +import java.util.ArrayList; +import java.util.List; + +public class CodeAnalysisSourceSummary +{ + private Long projectId; + private Integer fileCount = 0; + private Integer analyzedFileCount = 0; + private Integer skippedFileCount = 0; + private Integer charCount = 0; + private Integer estimatedTokens = 0; + private List includedFiles = new ArrayList(); + private List skippedFiles = new ArrayList(); + private List warnings = new ArrayList(); + private String promptContext = ""; + + public Long getProjectId() { return projectId; } + public void setProjectId(Long projectId) { this.projectId = projectId; } + public Integer getFileCount() { return fileCount; } + public void setFileCount(Integer fileCount) { this.fileCount = fileCount; } + public Integer getAnalyzedFileCount() { return analyzedFileCount; } + public void setAnalyzedFileCount(Integer analyzedFileCount) { this.analyzedFileCount = analyzedFileCount; } + public Integer getSkippedFileCount() { return skippedFileCount; } + public void setSkippedFileCount(Integer skippedFileCount) { this.skippedFileCount = skippedFileCount; } + public Integer getCharCount() { return charCount; } + public void setCharCount(Integer charCount) { this.charCount = charCount; } + public Integer getEstimatedTokens() { return estimatedTokens; } + public void setEstimatedTokens(Integer estimatedTokens) { this.estimatedTokens = estimatedTokens; } + public List getIncludedFiles() { return includedFiles; } + public void setIncludedFiles(List includedFiles) { this.includedFiles = includedFiles; } + public List getSkippedFiles() { return skippedFiles; } + public void setSkippedFiles(List skippedFiles) { this.skippedFiles = skippedFiles; } + public List getWarnings() { return warnings; } + public void setWarnings(List warnings) { this.warnings = warnings; } + public String getPromptContext() { return promptContext; } + public void setPromptContext(String promptContext) { this.promptContext = promptContext; } + + public static class SourceFile + { + private String templateType; + private String nodeId; + private String path; + private String name; + private String content; + private Integer charCount = 0; + private Boolean truncated = Boolean.FALSE; + + public String getTemplateType() { return templateType; } + public void setTemplateType(String templateType) { this.templateType = templateType; } + public String getNodeId() { return nodeId; } + public void setNodeId(String nodeId) { this.nodeId = nodeId; } + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + public String getName() { return name; } + public void setName(String name) { this.name = name; } + public String getContent() { return content; } + public void setContent(String content) { this.content = content; } + public Integer getCharCount() { return charCount; } + public void setCharCount(Integer charCount) { this.charCount = charCount; } + public Boolean getTruncated() { return truncated; } + public void setTruncated(Boolean truncated) { this.truncated = truncated; } + } + + public static class SkippedFile + { + private String templateType; + private String path; + private String reason; + + public String getTemplateType() { return templateType; } + public void setTemplateType(String templateType) { this.templateType = templateType; } + public String getPath() { return path; } + public void setPath(String path) { this.path = path; } + public String getReason() { return reason; } + public void setReason(String reason) { this.reason = reason; } + } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/GenerateCodeAnalysisRequest.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/GenerateCodeAnalysisRequest.java new file mode 100644 index 0000000..67de765 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/domain/front/dto/GenerateCodeAnalysisRequest.java @@ -0,0 +1,18 @@ +package com.ruoyi.generator.domain.front.dto; + +public class GenerateCodeAnalysisRequest +{ + private String mode; + private Long previousGenerationId; + private String previousMarkdown; + private String extraRequirements; + + public String getMode() { return mode; } + public void setMode(String mode) { this.mode = mode; } + public Long getPreviousGenerationId() { return previousGenerationId; } + public void setPreviousGenerationId(Long previousGenerationId) { this.previousGenerationId = previousGenerationId; } + public String getPreviousMarkdown() { return previousMarkdown; } + public void setPreviousMarkdown(String previousMarkdown) { this.previousMarkdown = previousMarkdown; } + public String getExtraRequirements() { return extraRequirements; } + public void setExtraRequirements(String extraRequirements) { this.extraRequirements = extraRequirements; } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/ProjectCodeAnalysisService.java b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/ProjectCodeAnalysisService.java new file mode 100644 index 0000000..8e4e0e7 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/main/java/com/ruoyi/generator/service/front/ProjectCodeAnalysisService.java @@ -0,0 +1,247 @@ +package com.ruoyi.generator.service.front; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import com.ruoyi.common.utils.StringUtils; +import com.ruoyi.generator.domain.front.dto.CodeAnalysisSourceSummary; +import com.ruoyi.generator.domain.front.dto.PreviewFileRequest; + +@Service +public class ProjectCodeAnalysisService +{ + private static final int LARGE_FILE_LIMIT = 100 * 1024; + private static final String[] TEMPLATE_TYPES = { "backend", "frontend", "admin_frontend", "sql" }; + private static final List ALLOWED_SUFFIXES = Arrays.asList( + ".java", ".xml", ".yml", ".yaml", ".properties", ".vue", ".js", ".ts", + ".json", ".scss", ".css", ".sql", ".md"); + private static final List EXACT_ALLOWED_NAMES = Arrays.asList("pom.xml", "package.json", "readme.md"); + private static final List SKIPPED_SEGMENTS = Arrays.asList( + "node_modules", "target", "dist", ".git", ".idea"); + + @Autowired + private IFrontProjectPreviewService frontProjectPreviewService; + + public CodeAnalysisSourceSummary buildSourceSummary(Long userId, Long projectId) + { + CodeAnalysisSourceSummary summary = new CodeAnalysisSourceSummary(); + summary.setProjectId(projectId); + for (String templateType : TEMPLATE_TYPES) + { + collectTemplate(summary, userId, projectId, templateType); + } + finishSummary(summary); + return summary; + } + + private void collectTemplate(CodeAnalysisSourceSummary summary, Long userId, Long projectId, String templateType) + { + List> nodes; + try + { + nodes = frontProjectPreviewService.getStructure(userId, projectId, templateType); + } + catch (RuntimeException e) + { + summary.getWarnings().add(templateType + " 源码结构读取失败:" + StringUtils.defaultString(e.getMessage())); + return; + } + collectNodes(summary, userId, projectId, templateType, nodes); + } + + private void collectNodes(CodeAnalysisSourceSummary summary, Long userId, Long projectId, String templateType, + List> nodes) + { + if (nodes == null) + { + return; + } + for (Map node : nodes) + { + if (node == null) + { + continue; + } + @SuppressWarnings("unchecked") + List> children = (List>) node.get("children"); + if (!isFile(node, children)) + { + collectNodes(summary, userId, projectId, templateType, children); + continue; + } + summary.setFileCount(summary.getFileCount() + 1); + collectFile(summary, userId, projectId, templateType, node); + } + } + + private void collectFile(CodeAnalysisSourceSummary summary, Long userId, Long projectId, String templateType, + Map node) + { + String path = normalizePath(firstNonBlank(stringValue(node.get("path")), stringValue(node.get("label")), + stringValue(node.get("id")))); + String skipReason = skipReason(path); + if (StringUtils.isNotEmpty(skipReason)) + { + addSkipped(summary, templateType, path, skipReason); + return; + } + if (!isAllowedCodeFile(path)) + { + addSkipped(summary, templateType, path, "非代码或非文档文件"); + return; + } + + String nodeId = stringValue(node.get("id")); + String content = readContent(userId, projectId, templateType, node, path); + int originalLength = content.length(); + boolean truncated = false; + if (content.length() > LARGE_FILE_LIMIT) + { + content = content.substring(0, LARGE_FILE_LIMIT) + + "\n\n[文件超过 100KB,代码解读仅保留前 100KB 内容]"; + truncated = true; + summary.getWarnings().add(path + " 超过 100KB,已裁剪后参与分析"); + } + + CodeAnalysisSourceSummary.SourceFile file = new CodeAnalysisSourceSummary.SourceFile(); + file.setTemplateType(templateType); + file.setNodeId(nodeId); + file.setPath(path); + file.setName(fileName(path)); + file.setContent(content); + file.setCharCount(originalLength); + file.setTruncated(truncated); + summary.getIncludedFiles().add(file); + } + + private String readContent(Long userId, Long projectId, String templateType, Map node, String path) + { + PreviewFileRequest request = new PreviewFileRequest(); + request.setTemplateType(templateType); + request.setNodeId(stringValue(node.get("id"))); + request.setCategory(path); + Object tableId = node.get("tableId"); + if (tableId instanceof Number) + { + request.setTableId(((Number) tableId).longValue()); + } + Map result = frontProjectPreviewService.getFileContent(userId, projectId, request); + if (result == null || result.isEmpty()) + { + return ""; + } + if (result.containsKey("content")) + { + return StringUtils.defaultString(result.get("content")); + } + return StringUtils.defaultString(result.values().iterator().next()); + } + + private void finishSummary(CodeAnalysisSourceSummary summary) + { + int charCount = 0; + StringBuilder prompt = new StringBuilder(); + for (CodeAnalysisSourceSummary.SourceFile file : summary.getIncludedFiles()) + { + charCount += file.getContent() == null ? 0 : file.getContent().length(); + prompt.append("\n\n## ").append(file.getTemplateType()).append(" / ").append(file.getPath()).append("\n"); + prompt.append(file.getContent() == null ? "" : file.getContent()); + } + summary.setAnalyzedFileCount(summary.getIncludedFiles().size()); + summary.setSkippedFileCount(summary.getSkippedFiles().size()); + summary.setCharCount(charCount); + summary.setEstimatedTokens(Math.max(0, (charCount + 3) / 4)); + summary.setPromptContext(prompt.toString().trim()); + } + + private boolean isFile(Map node, List> children) + { + String type = stringValue(node.get("type")); + if ("folder".equalsIgnoreCase(type) || "directory".equalsIgnoreCase(type)) + { + return false; + } + if ("file".equalsIgnoreCase(type) || Boolean.TRUE.equals(node.get("isFile"))) + { + return true; + } + return children == null || children.isEmpty(); + } + + private boolean isAllowedCodeFile(String path) + { + String lower = path.toLowerCase(Locale.ROOT); + String name = fileName(lower); + if (EXACT_ALLOWED_NAMES.contains(name)) + { + return true; + } + for (String suffix : ALLOWED_SUFFIXES) + { + if (lower.endsWith(suffix)) + { + return true; + } + } + return false; + } + + private String skipReason(String path) + { + String normalized = "/" + normalizePath(path).toLowerCase(Locale.ROOT) + "/"; + for (String segment : SKIPPED_SEGMENTS) + { + if (normalized.contains("/" + segment + "/")) + { + if ("node_modules".equals(segment)) + { + return "依赖目录"; + } + return "构建产物或本地工程目录"; + } + } + return ""; + } + + private void addSkipped(CodeAnalysisSourceSummary summary, String templateType, String path, String reason) + { + CodeAnalysisSourceSummary.SkippedFile file = new CodeAnalysisSourceSummary.SkippedFile(); + file.setTemplateType(templateType); + file.setPath(path); + file.setReason(reason); + summary.getSkippedFiles().add(file); + } + + private String firstNonBlank(String... values) + { + for (String value : values) + { + if (StringUtils.isNotBlank(value)) + { + return value; + } + } + return ""; + } + + private String normalizePath(String path) + { + return StringUtils.defaultString(path).replace("\\", "/"); + } + + private String fileName(String path) + { + String normalized = normalizePath(path); + int index = normalized.lastIndexOf("/"); + return index >= 0 ? normalized.substring(index + 1) : normalized; + } + + private String stringValue(Object value) + { + return value == null ? "" : String.valueOf(value); + } +} diff --git a/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/ProjectCodeAnalysisServiceTest.java b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/ProjectCodeAnalysisServiceTest.java new file mode 100644 index 0000000..aa7b480 --- /dev/null +++ b/RuoYi-Vue/ruoyi-generator/src/test/java/com/ruoyi/generator/service/front/ProjectCodeAnalysisServiceTest.java @@ -0,0 +1,130 @@ +package com.ruoyi.generator.service.front; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.when; + +import java.lang.reflect.Field; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.Before; +import org.junit.Test; +import org.mockito.Mock; +import org.mockito.MockitoAnnotations; +import com.ruoyi.generator.domain.front.dto.CodeAnalysisSourceSummary; +import com.ruoyi.generator.domain.front.dto.PreviewFileRequest; + +public class ProjectCodeAnalysisServiceTest +{ + private ProjectCodeAnalysisService service; + + @Mock + private IFrontProjectPreviewService frontProjectPreviewService; + + @Before + public void setUp() throws Exception + { + MockitoAnnotations.initMocks(this); + service = new ProjectCodeAnalysisService(); + setField("frontProjectPreviewService", frontProjectPreviewService); + } + + @Test + public void shouldCollectAllowedPreviewFilesAndSkipBuildArtifacts() + { + when(frontProjectPreviewService.getStructure(eq(1L), eq(10L), eq("backend"))) + .thenReturn(nodes( + node("backend:src/main/java/com/example/UserController.java", + "UserController.java", "src/main/java/com/example/UserController.java", "file"), + node("backend:target/classes/App.class", + "App.class", "target/classes/App.class", "file"))); + when(frontProjectPreviewService.getStructure(eq(1L), eq(10L), eq("frontend"))) + .thenReturn(nodes(node("frontend:node_modules/vue/index.js", + "index.js", "node_modules/vue/index.js", "file"))); + when(frontProjectPreviewService.getStructure(eq(1L), eq(10L), eq("admin_frontend"))) + .thenReturn(Collections.>emptyList()); + when(frontProjectPreviewService.getStructure(eq(1L), eq(10L), eq("sql"))) + .thenReturn(Collections.>emptyList()); + when(frontProjectPreviewService.getFileContent(eq(1L), eq(10L), any(PreviewFileRequest.class))) + .thenReturn(content("package com.example;\npublic class UserController {}\n")); + + CodeAnalysisSourceSummary summary = service.buildSourceSummary(1L, 10L); + + assertEquals(Integer.valueOf(1), summary.getAnalyzedFileCount()); + assertEquals(Integer.valueOf(3), summary.getFileCount()); + assertTrue(summary.getIncludedFiles().get(0).getPath().contains("UserController.java")); + assertTrue(summary.getPromptContext().contains("UserController")); + assertTrue(summary.getSkippedFiles().stream().anyMatch(file -> file.getReason().contains("构建产物"))); + assertTrue(summary.getSkippedFiles().stream().anyMatch(file -> file.getPath().contains("node_modules"))); + } + + @Test + public void shouldTrimLargeFilesAndKeepSummaryUsable() + { + String large = repeat("public void method() {}\n", 7000); + when(frontProjectPreviewService.getStructure(eq(1L), eq(10L), eq("backend"))) + .thenReturn(nodes(node("backend:src/main/java/App.java", + "App.java", "src/main/java/App.java", "file"))); + when(frontProjectPreviewService.getStructure(eq(1L), eq(10L), eq("frontend"))) + .thenReturn(Collections.>emptyList()); + when(frontProjectPreviewService.getStructure(eq(1L), eq(10L), eq("admin_frontend"))) + .thenReturn(Collections.>emptyList()); + when(frontProjectPreviewService.getStructure(eq(1L), eq(10L), eq("sql"))) + .thenReturn(Collections.>emptyList()); + when(frontProjectPreviewService.getFileContent(eq(1L), eq(10L), any(PreviewFileRequest.class))) + .thenReturn(content(large)); + + CodeAnalysisSourceSummary summary = service.buildSourceSummary(1L, 10L); + + assertEquals(Integer.valueOf(1), summary.getAnalyzedFileCount()); + assertTrue(summary.getIncludedFiles().get(0).getContent().length() < large.length()); + assertTrue(summary.getWarnings().stream().anyMatch(value -> value.contains("超过 100KB"))); + assertTrue(summary.getEstimatedTokens().intValue() > 0); + } + + private void setField(String name, Object value) throws Exception + { + Field field = ProjectCodeAnalysisService.class.getDeclaredField(name); + field.setAccessible(true); + field.set(service, value); + } + + private List> nodes(Map... nodes) + { + List> result = new ArrayList>(); + Collections.addAll(result, nodes); + return result; + } + + private Map node(String id, String label, String path, String type) + { + Map node = new HashMap(); + node.put("id", id); + node.put("label", label); + node.put("path", path); + node.put("type", type); + return node; + } + + private Map content(String value) + { + Map result = new HashMap(); + result.put("content", value); + return result; + } + + private String repeat(String value, int times) + { + StringBuilder builder = new StringBuilder(); + for (int i = 0; i < times; i++) + { + builder.append(value); + } + return builder.toString(); + } +}