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:
1 parent
05f0b69469
commit
30dc09ae08
9 files changed
+512
-140
No files matched your search
+22
-17
@@ -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
@@ -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 /
|
||||
|
||||
Reference in new issue
Block a user