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
+162
-36
@@ -2,6 +2,8 @@ package db
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
@@ -61,23 +63,18 @@ func (d *DB) AddFinding(taskID, nodeID int64, vulnclass, severity, summary, evid
|
||||
return id, err
|
||||
}
|
||||
|
||||
// ListFindings returns all findings (newest first), joined with task description.
|
||||
func (d *DB) ListFindings(limit int) ([]*DBFinding, error) {
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
}
|
||||
rows, err := d.Query(`
|
||||
SELECT f.id, f.task_id, f.node_id, f.vulnclass, f.severity, f.summary,
|
||||
f.evidence, f.worker, f.asset_ids, COALESCE(f.status, 'pending'), f.created_at,
|
||||
COALESCE(t.description, '') AS task_description
|
||||
FROM findings f
|
||||
LEFT JOIN tasks t ON f.task_id = t.id
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
// findingSelectCols is the column list (with task_description join) every finding
|
||||
// list query selects, so scanFinding stays in sync across callers.
|
||||
const findingSelectCols = `f.id, f.task_id, f.node_id, f.vulnclass, f.severity, f.summary,
|
||||
f.evidence, f.worker, f.asset_ids, COALESCE(f.status, 'pending'), f.created_at,
|
||||
COALESCE(t.description, '') AS task_description`
|
||||
|
||||
// scanFindings materializes rows selected via findingSelectCols.
|
||||
func scanFindings(rows interface {
|
||||
Next() bool
|
||||
Scan(...any) error
|
||||
Err() error
|
||||
}) ([]*DBFinding, error) {
|
||||
var out []*DBFinding
|
||||
for rows.Next() {
|
||||
f := &DBFinding{}
|
||||
@@ -92,6 +89,134 @@ func (d *DB) ListFindings(limit int) ([]*DBFinding, error) {
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListFindings returns all findings (newest first), joined with task description.
|
||||
// Kept for the dashboard's summary; the paginated 发现 page uses ListFindingsPage.
|
||||
func (d *DB) ListFindings(limit int) ([]*DBFinding, error) {
|
||||
if limit <= 0 {
|
||||
limit = 500
|
||||
}
|
||||
rows, err := d.Query(`
|
||||
SELECT `+findingSelectCols+`
|
||||
FROM findings f
|
||||
LEFT JOIN tasks t ON f.task_id = t.id
|
||||
ORDER BY f.created_at DESC
|
||||
LIMIT $1`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanFindings(rows)
|
||||
}
|
||||
|
||||
// FindingFilter narrows a paginated findings query. Empty-string fields mean "no
|
||||
// filter on that column". Sort is "severity" (severity desc, then newest) or
|
||||
// anything else (newest first).
|
||||
type FindingFilter struct {
|
||||
Severity string // high | medium | low
|
||||
Status string // pending | false_positive | ignored | resolved
|
||||
VulnClass string
|
||||
Sort string // "severity" | "time"
|
||||
}
|
||||
|
||||
// where builds the WHERE clause (shared by the page and count queries) plus its
|
||||
// positional args. Only equality filters, all parameterized.
|
||||
func (f FindingFilter) where() (string, []any) {
|
||||
var conds []string
|
||||
var args []any
|
||||
add := func(col, val string) {
|
||||
if val == "" {
|
||||
return
|
||||
}
|
||||
args = append(args, val)
|
||||
conds = append(conds, fmt.Sprintf("f.%s = $%d", col, len(args)))
|
||||
}
|
||||
add("severity", f.Severity)
|
||||
add("status", f.Status)
|
||||
add("vulnclass", f.VulnClass)
|
||||
if len(conds) == 0 {
|
||||
return "", args
|
||||
}
|
||||
return " WHERE " + strings.Join(conds, " AND "), args
|
||||
}
|
||||
|
||||
// ListFindingsPage returns one page of findings matching the filter, plus the
|
||||
// total count of matching rows (for the frontend pager). page is 1-based.
|
||||
func (d *DB) ListFindingsPage(f FindingFilter, page, pageSize int) ([]*DBFinding, int, error) {
|
||||
if page <= 0 {
|
||||
page = 1
|
||||
}
|
||||
if pageSize <= 0 {
|
||||
pageSize = 20
|
||||
}
|
||||
where, args := f.where()
|
||||
|
||||
var total int
|
||||
if err := d.QueryRow(`SELECT COUNT(*) FROM findings f`+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
|
||||
order := "f.created_at DESC"
|
||||
if f.Sort == "severity" {
|
||||
// high > medium > low > 其它, then newest first.
|
||||
order = `CASE f.severity WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC, f.created_at DESC`
|
||||
}
|
||||
pageArgs := append(append([]any{}, args...), pageSize, (page-1)*pageSize)
|
||||
q := fmt.Sprintf(`
|
||||
SELECT %s
|
||||
FROM findings f
|
||||
LEFT JOIN tasks t ON f.task_id = t.id%s
|
||||
ORDER BY %s
|
||||
LIMIT $%d OFFSET $%d`, findingSelectCols, where, order, len(args)+1, len(args)+2)
|
||||
rows, err := d.Query(q, pageArgs...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out, err := scanFindings(rows)
|
||||
return out, total, err
|
||||
}
|
||||
|
||||
// FindingStats is the whole-table aggregate powering the 发现 page's stat cards
|
||||
// and vuln-class filter — computed server-side so it stays exact regardless of
|
||||
// pagination.
|
||||
type FindingStats struct {
|
||||
Total int `json:"total"`
|
||||
Pending int `json:"pending"`
|
||||
High int `json:"high"`
|
||||
Medium int `json:"medium"`
|
||||
Low int `json:"low"`
|
||||
VulnClasses []string `json:"vulnclasses"`
|
||||
}
|
||||
|
||||
// FindingStats returns whole-table counts (by severity + pending) and the sorted
|
||||
// set of distinct vuln classes.
|
||||
func (d *DB) FindingStats() (*FindingStats, error) {
|
||||
st := &FindingStats{VulnClasses: []string{}}
|
||||
err := d.QueryRow(`SELECT
|
||||
COUNT(*),
|
||||
COUNT(*) FILTER (WHERE status = 'pending'),
|
||||
COUNT(*) FILTER (WHERE severity = 'high'),
|
||||
COUNT(*) FILTER (WHERE severity = 'medium'),
|
||||
COUNT(*) FILTER (WHERE severity = 'low')
|
||||
FROM findings`).Scan(&st.Total, &st.Pending, &st.High, &st.Medium, &st.Low)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rows, err := d.Query(`SELECT DISTINCT vulnclass FROM findings WHERE vulnclass <> '' ORDER BY vulnclass`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var vc string
|
||||
if err := rows.Scan(&vc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
st.VulnClasses = append(st.VulnClasses, vc)
|
||||
}
|
||||
return st, rows.Err()
|
||||
}
|
||||
|
||||
// SetFindingStatus updates one finding's triage state. Returns rows affected.
|
||||
func (d *DB) SetFindingStatus(id int64, status string) (int64, error) {
|
||||
res, err := d.Exec(`UPDATE findings SET status=$1 WHERE id=$2`, status, id)
|
||||
@@ -101,36 +226,37 @@ func (d *DB) SetFindingStatus(id int64, status string) (int64, error) {
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// FindingStatusByNodeID maps a task's finding node ids to their standalone-row
|
||||
// id and triage state, so the per-task view (which reads exploration nodes) can
|
||||
// show and edit the same status as the global 发现 page.
|
||||
func (d *DB) FindingStatusByNodeID(taskID int64) (map[int64]struct {
|
||||
ID int64
|
||||
Status string
|
||||
}, error) {
|
||||
out := map[int64]struct {
|
||||
ID int64
|
||||
Status string
|
||||
}{}
|
||||
// FindingMeta is the standalone-row data (id, triage state, anchored assets) the
|
||||
// per-task view grafts onto its exploration-node findings.
|
||||
type FindingMeta struct {
|
||||
ID int64
|
||||
Status string
|
||||
AssetIDs []int64
|
||||
}
|
||||
|
||||
// FindingMetaByNodeID maps a task's finding node ids to their standalone-row
|
||||
// metadata, so the per-task view (which reads exploration nodes) can show and
|
||||
// edit the same status — and the same anchored assets — as the global 发现 page.
|
||||
func (d *DB) FindingMetaByNodeID(taskID int64) (map[int64]FindingMeta, error) {
|
||||
out := map[int64]FindingMeta{}
|
||||
if taskID <= 0 {
|
||||
return out, nil
|
||||
}
|
||||
rows, err := d.Query(`SELECT node_id, id, COALESCE(status,'pending') FROM findings
|
||||
rows, err := d.Query(`SELECT node_id, id, COALESCE(status,'pending'), asset_ids FROM findings
|
||||
WHERE task_id=$1 AND node_id IS NOT NULL`, taskID)
|
||||
if err != nil {
|
||||
return out, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var nid, id int64
|
||||
var st string
|
||||
if err := rows.Scan(&nid, &id, &st); err != nil {
|
||||
var nid int64
|
||||
var m FindingMeta
|
||||
var aidsJSON string
|
||||
if err := rows.Scan(&nid, &m.ID, &m.Status, &aidsJSON); err != nil {
|
||||
return out, err
|
||||
}
|
||||
out[nid] = struct {
|
||||
ID int64
|
||||
Status string
|
||||
}{ID: id, Status: st}
|
||||
_ = json.Unmarshal([]byte(aidsJSON), &m.AssetIDs)
|
||||
out[nid] = m
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestFindingsPageAndStats exercises ListFindingsPage (filter/sort/paging) and
|
||||
// FindingStats against the live dev PG. It tags its rows with a unique vulnclass
|
||||
// so assertions are isolated from any pre-existing data, and cleans up after.
|
||||
func TestFindingsPageAndStats(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
|
||||
const vc = "__test_vc_pagination__"
|
||||
// clean any leftovers from a prior aborted run, and clean up on exit
|
||||
cleanup := func() { _, _ = d.Exec(`DELETE FROM findings WHERE vulnclass=$1`, vc) }
|
||||
cleanup()
|
||||
defer cleanup()
|
||||
|
||||
// Seed 5 findings under the marker vulnclass: 3 high, 2 low; 2 pending, 3 resolved.
|
||||
seed := []struct {
|
||||
sev, status string
|
||||
}{
|
||||
{"high", "pending"},
|
||||
{"high", "resolved"},
|
||||
{"high", "resolved"},
|
||||
{"low", "pending"},
|
||||
{"low", "resolved"},
|
||||
}
|
||||
var ids []int64
|
||||
for i, s := range seed {
|
||||
id, err := d.AddFinding(0, 0, vc, s.sev, "summary", "poc", "tester", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("AddFinding[%d]: %v", i, err)
|
||||
}
|
||||
if _, err := d.SetFindingStatus(id, s.status); err != nil {
|
||||
t.Fatalf("SetFindingStatus[%d]: %v", i, err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
|
||||
// Filter by our vulnclass → exactly the 5 seeded rows, paged 2 per page.
|
||||
p1, total, err := d.ListFindingsPage(FindingFilter{VulnClass: vc, Sort: "severity"}, 1, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 5 {
|
||||
t.Fatalf("total: want 5, got %d", total)
|
||||
}
|
||||
if len(p1) != 2 {
|
||||
t.Fatalf("page1 size: want 2, got %d", len(p1))
|
||||
}
|
||||
// severity sort → highs first
|
||||
if p1[0].Severity != "high" || p1[1].Severity != "high" {
|
||||
t.Fatalf("severity sort broken: %q, %q", p1[0].Severity, p1[1].Severity)
|
||||
}
|
||||
p3, _, err := d.ListFindingsPage(FindingFilter{VulnClass: vc, Sort: "severity"}, 3, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(p3) != 1 {
|
||||
t.Fatalf("page3 size: want 1 (5 rows / 2), got %d", len(p3))
|
||||
}
|
||||
|
||||
// Combined filter: vulnclass + status=pending → 2 rows.
|
||||
pend, total, err := d.ListFindingsPage(FindingFilter{VulnClass: vc, Status: FindingPending}, 1, 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 2 || len(pend) != 2 {
|
||||
t.Fatalf("pending filter: want 2/2, got %d/%d", total, len(pend))
|
||||
}
|
||||
|
||||
// Combined filter: vulnclass + severity=high → 3 rows.
|
||||
_, total, err = d.ListFindingsPage(FindingFilter{VulnClass: vc, Severity: "high"}, 1, 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if total != 3 {
|
||||
t.Fatalf("high filter: want 3, got %d", total)
|
||||
}
|
||||
|
||||
// Stats: whole-table, so assert our contribution is reflected (>=) and the
|
||||
// marker vulnclass is present.
|
||||
st, err := d.FindingStats()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Total < 5 || st.High < 3 || st.Low < 2 || st.Pending < 2 {
|
||||
t.Fatalf("stats undercount: %+v", st)
|
||||
}
|
||||
if !slices.Contains(st.VulnClasses, vc) {
|
||||
t.Fatalf("stats vulnclasses missing %q", vc)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user