feat: 发现列表改为服务端分页 + 全表聚合(去掉 500 上限)

- db.ListFindingsPage:WHERE(严重度/状态/类型)+ ORDER BY(严重度 CASE 或时间)+ LIMIT/OFFSET,返回当前页与匹配总数
- db.FindingStats:全表 COUNT FILTER 聚合(总数/待处理/高/中/低)+ DISTINCT 漏洞类型,供统计卡与类型下拉,不受分页影响
- GET /api/exploration/findings 带 page/limit → 分页对象{items,total,page,page_size};不带 → 保留裸数组(dashboard 汇总,与 intents 端点兼容策略一致)
- 新增 GET /api/exploration/findings/stats
- 筛选/排序全部下推到 SQL,前端不再一次拉全量再本地切片
- 资产按当前页所需批量解析;per-task 视图经 node_id 关联独立行,复用同一状态并补齐绑定资产
- 前端:发现页改为服务端驱动分页/筛选/排序,统计卡与类型下拉走 stats 接口
- db 层加 findings 分页/筛选/聚合测试(隔离标记 vulnclass,自清理)
This commit is contained in:
Autumn-27 committed 2026-08-18 10:05:18 -04:00
1 parent 05f0b69469
commit 30dc09ae08
9 files changed
+512 -140

No files matched your search

+22 -17
View File
@@ -250,27 +250,38 @@ func findingDTO(n *db.Node) FindingDTO {
// findingDTOsForTask converts a task's finding nodes to DTOs, stamping each with
// the owning task's id/description so the global 发现 page can group across tasks.
// triage maps node id → the standalone findings row (id + status), so the per-task
// view shows the same triage state as the global page; nodes with no row keep the
// 'pending' default and no finding_id (status is then not editable).
func findingDTOsForTask(t *Task, in []*db.Node, triage map[int64]struct {
ID int64
Status string
}) []FindingDTO {
// meta maps node id → the standalone findings row (id + status + asset ids), so the
// per-task view shows the same triage state and anchored assets as the global page;
// nodes with no row keep the 'pending' default and no finding_id (not editable).
// assets pre-resolves the anchored asset rows for label rendering.
func findingDTOsForTask(t *Task, in []*db.Node, meta map[int64]db.FindingMeta, assets map[int64]*db.Asset) []FindingDTO {
out := make([]FindingDTO, 0, len(in))
for _, n := range in {
d := findingDTO(n)
d.TaskID = t.ID
d.TaskDescription = t.Description
if r, ok := triage[n.ID]; ok {
d.FindingID = i64s(r.ID)
d.Status = r.Status
if m, ok := meta[n.ID]; ok {
d.FindingID = i64s(m.ID)
d.Status = m.Status
d.Assets = findingAssetDTOs(m.AssetIDs, assets)
}
out = append(out, d)
}
return out
}
// findingAssetDTOs maps anchored asset ids to display DTOs, skipping ids whose
// asset row is missing (e.g. deleted).
func findingAssetDTOs(ids []int64, assets map[int64]*db.Asset) []FindingAssetDTO {
var out []FindingAssetDTO
for _, aid := range ids {
if a := assets[aid]; a != nil {
out = append(out, FindingAssetDTO{ID: i64s(a.ID), Type: a.Type, Label: assetLabel(a)})
}
}
return out
}
// findingFromDB converts a standalone DBFinding row to a FindingDTO. task_id and
// task_description are empty when the originating task has been deleted (NULL).
func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO {
@@ -288,13 +299,7 @@ func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO {
Evidence: f.Evidence,
TS: rfc3339(f.CreatedAt),
}
for _, aid := range f.AssetIDs {
a := assets[aid]
if a == nil {
continue
}
d.Assets = append(d.Assets, FindingAssetDTO{ID: i64s(a.ID), Type: a.Type, Label: assetLabel(a)})
}
d.Assets = findingAssetDTOs(f.AssetIDs, assets)
if f.TaskID != nil {
d.TaskID = i64s(*f.TaskID)
d.TaskDescription = f.TaskDescription
+89 -25
View File
@@ -601,6 +601,7 @@ func (s *Server) Handler() http.Handler {
mux.HandleFunc("GET /api/exploration/frontier", s.frontier)
mux.HandleFunc("GET /api/exploration/findings", s.findings)
mux.HandleFunc("GET /api/exploration/findings/stats", s.findingStats)
mux.HandleFunc("PATCH /api/exploration/findings/{id}", s.setFindingStatus)
mux.HandleFunc("GET /api/exploration/intents", s.intents)
mux.HandleFunc("GET /api/exploration/graph", s.explorationGraph)
@@ -1349,36 +1350,40 @@ func (s *Server) frontier(w http.ResponseWriter, r *http.Request) {
func (s *Server) findings(w http.ResponseWriter, r *http.Request) {
// 无 task 参数 → 全局「发现」页:从独立 findings 表读取(任务删除后 finding 依然保留)。
// 带 task 参数 → 仅该任务(任务概览/发现 Tab 用),从 exploration_nodes 读(任务在则节点在)。
taskParam := r.URL.Query().Get("task")
q := r.URL.Query()
taskParam := q.Get("task")
if taskParam == "" {
fs, _ := s.m.pg.ListFindings(500)
// Resolve every anchored asset in one query so each finding can carry a
// display label for its assets.
seen := map[int64]bool{}
var ids []int64
for _, f := range fs {
for _, aid := range f.AssetIDs {
if aid > 0 && !seen[aid] {
seen[aid] = true
ids = append(ids, aid)
}
// 带 page/limit → 服务端分页 {items,total,...};不带 → 裸数组(dashboard 汇总用,
// 与 intents 端点的兼容策略一致)。筛选/排序统一下推到 SQL。
if q.Get("page") == "" && q.Get("limit") == "" {
fs, _ := s.m.pg.ListFindings(500)
assets := s.resolveFindingAssets(fs)
out := make([]FindingDTO, 0, len(fs))
for _, f := range fs {
out = append(out, findingFromDB(f, assets))
}
writeJSON(w, 200, out)
return
}
assets := map[int64]*db.Asset{}
if len(ids) > 0 {
list, err := s.m.pg.Assets().GetByIDs(ids)
if err != nil {
log.Printf("[findings] resolve assets: %v", err)
}
for _, a := range list {
assets[a.ID] = a
}
page := atoiDefault(q.Get("page"), 1)
limit := min(atoiDefault(q.Get("limit"), 20), 200)
filter := db.FindingFilter{
Severity: normFilter(q.Get("severity")),
Status: normFilter(q.Get("status")),
VulnClass: normFilter(q.Get("vulnclass")),
Sort: q.Get("sort"),
}
fs, total, err := s.m.pg.ListFindingsPage(filter, page, limit)
if err != nil {
writeErr(w, 500, err.Error())
return
}
assets := s.resolveFindingAssets(fs)
out := make([]FindingDTO, 0, len(fs))
for _, f := range fs {
out = append(out, findingFromDB(f, assets))
}
writeJSON(w, 200, out)
writeJSON(w, 200, map[string]any{"items": out, "total": total, "page": page, "page_size": limit})
return
}
t := s.m.ResolveTask(taskParam)
@@ -1388,11 +1393,70 @@ func (s *Server) findings(w http.ResponseWriter, r *http.Request) {
}
f, _ := t.Store.ListByKind(db.KindFinding, 200)
tid, _ := strconv.ParseInt(t.ID, 10, 64)
triage, err := s.m.pg.FindingStatusByNodeID(tid)
meta, err := s.m.pg.FindingMetaByNodeID(tid)
if err != nil {
log.Printf("[findings] task=%s triage: %v", t.ID, err)
log.Printf("[findings] task=%s meta: %v", t.ID, err)
}
writeJSON(w, 200, findingDTOsForTask(t, f, triage))
var aidSet []int64
for _, m := range meta {
aidSet = append(aidSet, m.AssetIDs...)
}
assets := s.resolveAssetIDs(aidSet)
writeJSON(w, 200, findingDTOsForTask(t, f, meta, assets))
}
// normFilter maps the frontend's "all" sentinel (and empty) to "" so the DB layer
// treats it as no filter.
func normFilter(v string) string {
if v == "all" {
return ""
}
return v
}
// resolveFindingAssets loads every asset anchored by the given findings, keyed by
// id, so each finding DTO can render its assets' labels.
func (s *Server) resolveFindingAssets(fs []*db.DBFinding) map[int64]*db.Asset {
var ids []int64
for _, f := range fs {
ids = append(ids, f.AssetIDs...)
}
return s.resolveAssetIDs(ids)
}
// resolveAssetIDs de-dupes ids and loads their asset rows into an id→asset map.
func (s *Server) resolveAssetIDs(ids []int64) map[int64]*db.Asset {
assets := map[int64]*db.Asset{}
seen := map[int64]bool{}
var uniq []int64
for _, id := range ids {
if id > 0 && !seen[id] {
seen[id] = true
uniq = append(uniq, id)
}
}
if len(uniq) == 0 {
return assets
}
list, err := s.m.pg.Assets().GetByIDs(uniq)
if err != nil {
log.Printf("[findings] resolve assets: %v", err)
}
for _, a := range list {
assets[a.ID] = a
}
return assets
}
// findingStats serves the whole-table aggregates (stat cards + vuln-class filter)
// for the paginated 发现 page.
func (s *Server) findingStats(w http.ResponseWriter, r *http.Request) {
st, err := s.m.pg.FindingStats()
if err != nil {
writeErr(w, 500, err.Error())
return
}
writeJSON(w, 200, st)
}
// setFindingStatus updates one finding's triage state (pending / false_positive /