package service import ( "bufio" "context" "os" "path/filepath" "sort" "strings" "time" "view/model" "view/platform" ) const ( insightMaxFileSize = 1024 * 1024 insightMaxTotalRead = 20 * 1024 * 1024 ) // InsightService 基于既有统计快照做维护性检查。 // 它不会重新遍历整个仓库,只读取已记录的小型源码文件,用于发现 TODO、长文件等轻量风险。 type InsightService struct{} func (InsightService) Analyze(ctx context.Context, project model.Project, structure model.StructureStats, git model.GitStats) (model.ProjectInsights, error) { desc, e := platform.ResolvePath(project.Path) if e != nil { return model.ProjectInsights{}, e } out := model.ProjectInsights{ProjectID: project.ID, GeneratedAt: time.Now().Format(time.RFC3339), Issues: []model.InsightIssue{}} add := func(issue model.InsightIssue) { out.Issues = append(out.Issues, issue) switch issue.Severity { case "high": out.Summary.High++ case "medium": out.Summary.Medium++ default: out.Summary.Low++ } } checkLanguageHealth(project.Languages, add) out.Summary.LargeFiles = len(structure.LargeFiles) checkStructureHealth(structure, add) checkGitHealth(git, add) if e = scanTextMarkers(ctx, desc.WindowsPath, structure.Files, add, &out.Summary); e != nil { add(model.InsightIssue{Severity: "medium", Type: "scan_error", Title: "源码检查未完全完成", Detail: e.Error(), Suggestion: "检查项目目录权限,或重新执行项目统计。"}) } sort.SliceStable(out.Issues, func(i, j int) bool { return severityRank(out.Issues[i].Severity) > severityRank(out.Issues[j].Severity) }) out.HealthScore = healthScore(out.Summary) return out, nil } func checkLanguageHealth(langs []model.LanguageStat, add func(model.InsightIssue)) { for _, x := range langs { total := x.Code + x.Comments if x.Code < 500 || x.Files < 3 || total == 0 { continue } ratio := float64(x.Comments) / float64(total) if ratio < 0.03 { add(model.InsightIssue{Severity: "medium", Type: "low_comment_ratio", Title: "注释率偏低", Detail: x.Name + " 的注释占比低于 3%。", Suggestion: "为核心业务、复杂条件和公共接口补充解释性注释。"}) } else if ratio < 0.06 { add(model.InsightIssue{Severity: "low", Type: "low_comment_ratio", Title: "注释率略低", Detail: x.Name + " 的注释占比低于 6%。", Suggestion: "优先补充复杂模块和团队协作频繁的文件。"}) } } } func checkStructureHealth(s model.StructureStats, add func(model.InsightIssue)) { for _, f := range s.LargeFiles { severity := "medium" if f.Size >= 5*1024*1024 { severity = "high" } add(model.InsightIssue{Severity: severity, Type: "large_file", Title: "发现大文件", Detail: "文件体积可能影响仓库加载和代码审查。", Path: f.Path, Suggestion: "确认是否可以拆分、压缩或移动到对象存储。"}) } if len(s.Folders) > 0 && s.TotalSize > 0 { top := s.Folders[0] if float64(top.Size)/float64(s.TotalSize) > 0.65 && top.Files > 20 { add(model.InsightIssue{Severity: "medium", Type: "folder_concentration", Title: "目录体量过于集中", Detail: top.Name + " 占据了项目大部分体积。", Path: top.Name, Suggestion: "检查该目录是否混入构建产物、缓存或可拆分模块。"}) } } unknown := 0 for _, x := range s.Extensions { if x.Extension == "" { unknown += int(x.Files) } } if unknown > 20 { add(model.InsightIssue{Severity: "low", Type: "unknown_files", Title: "无扩展名文件较多", Detail: "项目中存在较多无扩展名文件。", Suggestion: "确认这些文件是否都是必要脚本、配置或运行产物。"}) } } func checkGitHealth(g model.GitStats, add func(model.InsightIssue)) { if g.CommitCount == 0 || len(g.Contributors) == 0 { return } top := g.Contributors[0] if float64(top.Commits)/float64(g.CommitCount) > 0.75 && g.CommitCount > 20 { add(model.InsightIssue{Severity: "medium", Type: "contributor_concentration", Title: "提交集中度较高", Detail: top.Name + " 贡献了超过 75% 的提交。", Suggestion: "关注知识分散、代码评审和关键模块交接风险。"}) } for _, c := range g.Commits[:min(30, len(g.Commits))] { if c.Added+c.Deleted > 3000 { add(model.InsightIssue{Severity: "low", Type: "large_commit", Title: "近期存在大提交", Detail: c.Message, Path: c.Hash, Suggestion: "大提交建议拆分审查,降低回滚和定位问题的成本。"}) } } } func scanTextMarkers(ctx context.Context, root string, files []model.FileEntry, add func(model.InsightIssue), summary *model.InsightSummary) error { totalRead := int64(0) for _, f := range files { if f.IsDir || f.Size <= 0 || f.Size > insightMaxFileSize || !isInsightTextFile(f.Extension) { continue } if totalRead+f.Size > insightMaxTotalRead { return nil } select { case <-ctx.Done(): return ctx.Err() default: } totalRead += f.Size path := filepath.Join(root, filepath.FromSlash(f.Path)) lines, markers := inspectTextFile(path) if lines >= 1500 { summary.LongFiles++ add(model.InsightIssue{Severity: "high", Type: "long_file", Title: "文件过长", Detail: "文件超过 1500 行,理解和评审成本较高。", Path: f.Path, Suggestion: "按职责拆分模块,提取复用函数或服务。"}) } else if lines >= 800 { summary.LongFiles++ add(model.InsightIssue{Severity: "medium", Type: "long_file", Title: "文件偏长", Detail: "文件超过 800 行。", Path: f.Path, Suggestion: "关注是否存在多个职责混在同一文件中。"}) } for _, issue := range markers { summary.TodoCount++ issue.Path = f.Path add(issue) } } return nil } func inspectTextFile(path string) (int, []model.InsightIssue) { file, e := os.Open(path) if e != nil { return 0, nil } defer file.Close() scanner := bufio.NewScanner(file) scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) line := 0 issues := []model.InsightIssue{} for scanner.Scan() { line++ text := scanner.Text() upper := strings.ToUpper(text) kind, severity := "", "low" switch { case strings.Contains(upper, "FIXME"): kind, severity = "FIXME", "medium" case strings.Contains(upper, "HACK"): kind, severity = "HACK", "medium" case strings.Contains(upper, "TODO"): kind = "TODO" } if kind != "" { issues = append(issues, model.InsightIssue{Severity: severity, Type: "todo_marker", Title: "发现 " + kind + " 标记", Detail: "源码中存在待处理标记。", Line: line, Suggestion: "确认该标记是否仍有效,并补充负责人或处理计划。", Evidence: strings.TrimSpace(text)}) } } return line, issues } func isInsightTextFile(ext string) bool { switch strings.ToLower(ext) { case ".go", ".php", ".js", ".ts", ".vue", ".jsx", ".tsx", ".css", ".scss", ".sass", ".less", ".html", ".md", ".json", ".yaml", ".yml", ".xml", ".sql", ".py", ".java", ".cs", ".rb", ".rs", ".c", ".cpp", ".h": return true default: return false } } func healthScore(s model.InsightSummary) int { score := 100 - s.High*12 - s.Medium*6 - s.Low*2 if score < 0 { return 0 } return score } func severityRank(s string) int { switch s { case "high": return 3 case "medium": return 2 default: return 1 } }