feat: collect project source for code analysis

This commit is contained in:
王鹏
2026-07-07 16:18:14 +08:00
parent 8b0be5c103
commit a90edbafd3
5 changed files with 512 additions and 0 deletions

View File

@@ -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<Section> sections = new ArrayList<Section>();
private CodeAnalysisSourceSummary sourceSnapshot;
private List<String> warnings = new ArrayList<String>();
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<Section> getSections() { return sections; }
public void setSections(List<Section> sections) { this.sections = sections; }
public CodeAnalysisSourceSummary getSourceSnapshot() { return sourceSnapshot; }
public void setSourceSnapshot(CodeAnalysisSourceSummary sourceSnapshot) { this.sourceSnapshot = sourceSnapshot; }
public List<String> getWarnings() { return warnings; }
public void setWarnings(List<String> 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; }
}
}

View File

@@ -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<SourceFile> includedFiles = new ArrayList<SourceFile>();
private List<SkippedFile> skippedFiles = new ArrayList<SkippedFile>();
private List<String> warnings = new ArrayList<String>();
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<SourceFile> getIncludedFiles() { return includedFiles; }
public void setIncludedFiles(List<SourceFile> includedFiles) { this.includedFiles = includedFiles; }
public List<SkippedFile> getSkippedFiles() { return skippedFiles; }
public void setSkippedFiles(List<SkippedFile> skippedFiles) { this.skippedFiles = skippedFiles; }
public List<String> getWarnings() { return warnings; }
public void setWarnings(List<String> 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; }
}
}

View File

@@ -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; }
}

View File

@@ -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<String> ALLOWED_SUFFIXES = Arrays.asList(
".java", ".xml", ".yml", ".yaml", ".properties", ".vue", ".js", ".ts",
".json", ".scss", ".css", ".sql", ".md");
private static final List<String> EXACT_ALLOWED_NAMES = Arrays.asList("pom.xml", "package.json", "readme.md");
private static final List<String> 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<Map<String, Object>> 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<Map<String, Object>> nodes)
{
if (nodes == null)
{
return;
}
for (Map<String, Object> node : nodes)
{
if (node == null)
{
continue;
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> children = (List<Map<String, Object>>) 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<String, Object> 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<String, Object> 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<String, String> 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<String, Object> node, List<Map<String, Object>> 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);
}
}

View File

@@ -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.<Map<String, Object>>emptyList());
when(frontProjectPreviewService.getStructure(eq(1L), eq(10L), eq("sql")))
.thenReturn(Collections.<Map<String, Object>>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.<Map<String, Object>>emptyList());
when(frontProjectPreviewService.getStructure(eq(1L), eq(10L), eq("admin_frontend")))
.thenReturn(Collections.<Map<String, Object>>emptyList());
when(frontProjectPreviewService.getStructure(eq(1L), eq(10L), eq("sql")))
.thenReturn(Collections.<Map<String, Object>>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<Map<String, Object>> nodes(Map<String, Object>... nodes)
{
List<Map<String, Object>> result = new ArrayList<Map<String, Object>>();
Collections.addAll(result, nodes);
return result;
}
private Map<String, Object> node(String id, String label, String path, String type)
{
Map<String, Object> node = new HashMap<String, Object>();
node.put("id", id);
node.put("label", label);
node.put("path", path);
node.put("type", type);
return node;
}
private Map<String, String> content(String value)
{
Map<String, String> result = new HashMap<String, String>();
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();
}
}