mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-28 09:13:49 +08:00
Merge pull request #2 from RuoJi6/codex/finding-folders
feat(findings): 同一漏洞重复上报归并与文件夹展示
This commit is contained in:
29 files changed
+3051
-63
No files matched your search
@@ -0,0 +1,567 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrFindingCaseConflict = errors.New("漏洞组已变更,请重新读取证据后再操作")
|
||||
|
||||
type FindingCase struct {
|
||||
ID int64 `json:"id,string"`
|
||||
TaskID *int64 `json:"task_id,string"`
|
||||
Title string `json:"title"`
|
||||
Reason string `json:"reason"`
|
||||
Severity string `json:"severity"`
|
||||
SeverityReason string `json:"severity_reason"`
|
||||
Report string `json:"report,omitempty"`
|
||||
Version int64 `json:"version"`
|
||||
ReportVersion int64 `json:"report_version"`
|
||||
Active bool `json:"active"`
|
||||
Count int `json:"count"`
|
||||
Critical int `json:"critical"`
|
||||
High int `json:"high"`
|
||||
Medium int `json:"medium"`
|
||||
Low int `json:"low"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
type FindingCaseEvent struct {
|
||||
ID int64 `json:"id,string"`
|
||||
Action string `json:"action"`
|
||||
Actor string `json:"actor"`
|
||||
Reason string `json:"reason"`
|
||||
FindingIDs json.RawMessage `json:"finding_ids"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
type FindingCaseSuggestion struct {
|
||||
ID int64 `json:"id,string"`
|
||||
TaskID int64 `json:"task_id,string"`
|
||||
LeftID int64 `json:"left_id,string"`
|
||||
RightID int64 `json:"right_id,string"`
|
||||
Title string `json:"title"`
|
||||
Reason string `json:"reason"`
|
||||
State string `json:"state"`
|
||||
}
|
||||
type FindingCaseRow struct {
|
||||
Case *FindingCase `json:"case,omitempty"`
|
||||
FindingID int64 `json:"finding_id,string,omitempty"`
|
||||
MatchedIDs []int64 `json:"matched_ids"`
|
||||
}
|
||||
|
||||
const caseColumns = `c.id,c.task_id,c.title,c.reason,c.severity,c.severity_reason,c.report,c.version,c.report_version,c.active,c.created_at`
|
||||
|
||||
func scanCase(row interface{ Scan(...any) error }) (*FindingCase, error) {
|
||||
c := new(FindingCase)
|
||||
err := row.Scan(&c.ID, &c.TaskID, &c.Title, &c.Reason, &c.Severity, &c.SeverityReason, &c.Report, &c.Version, &c.ReportVersion, &c.Active, &c.CreatedAt)
|
||||
return c, err
|
||||
}
|
||||
func (d *DB) GetFindingCase(id int64) (*FindingCase, error) {
|
||||
c, err := scanCase(d.QueryRow(`SELECT `+caseColumns+` FROM finding_cases c WHERE id=$1`, id))
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = d.QueryRow(`SELECT count(*),count(*) FILTER(WHERE severity='critical'),count(*) FILTER(WHERE severity='high'),count(*) FILTER(WHERE severity='medium'),count(*) FILTER(WHERE severity='low') FROM findings f JOIN finding_case_members m ON m.finding_id=f.id WHERE m.case_id=$1`, id).Scan(&c.Count, &c.Critical, &c.High, &c.Medium, &c.Low)
|
||||
return c, err
|
||||
}
|
||||
func (d *DB) FindingCaseID(id int64) (int64, error) {
|
||||
var cid int64
|
||||
err := d.QueryRow(`SELECT case_id FROM finding_case_members WHERE finding_id=$1`, id).Scan(&cid)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return 0, nil
|
||||
}
|
||||
return cid, err
|
||||
}
|
||||
func (d *DB) FindingCaseMembers(id int64, page, size int) ([]*DBFinding, int, error) {
|
||||
var count int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM finding_case_members WHERE case_id=$1`, id).Scan(&count); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
rows, err := d.Query(`SELECT `+findingSelectCols+` FROM findings f LEFT JOIN tasks t ON t.id=f.task_id JOIN finding_case_members m ON m.finding_id=f.id WHERE m.case_id=$1 ORDER BY f.id LIMIT $2 OFFSET $3`, id, size, (page-1)*size)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
fs, err := scanFindings(rows)
|
||||
return fs, count, err
|
||||
}
|
||||
func (d *DB) FindingCaseEvents(id int64) ([]FindingCaseEvent, error) {
|
||||
rows, err := d.Query(`SELECT id,action,actor,reason,finding_ids,created_at FROM finding_case_events WHERE case_id=$1 ORDER BY id DESC`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []FindingCaseEvent{}
|
||||
for rows.Next() {
|
||||
var e FindingCaseEvent
|
||||
if err := rows.Scan(&e.ID, &e.Action, &e.Actor, &e.Reason, &e.FindingIDs, &e.CreatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
func (d *DB) ListFindingCases(filter FindingFilter, page, size int) ([]FindingCaseRow, int, error) {
|
||||
var err error
|
||||
filter, err = d.applyAssetScope(filter)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
where, args := filter.where()
|
||||
base := `WITH matched AS (SELECT f.id,f.severity,f.created_at,COALESCE(m.case_id,0) cid FROM findings f LEFT JOIN tasks t ON t.id=f.task_id LEFT JOIN finding_case_members m ON m.finding_id=f.id` + where + `), entities AS (SELECT CASE WHEN cid>0 THEN 'c:'||cid ELSE 'f:'||id END key,MAX(cid) cid,MIN(id) fid,jsonb_agg(id ORDER BY id) matched_ids,MAX(created_at) created FROM matched GROUP BY key)`
|
||||
var total int
|
||||
if err := d.QueryRow(base+` SELECT count(*) FROM entities`, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
order := `e.created DESC,e.fid DESC`
|
||||
if filter.Sort == "severity" {
|
||||
order = `CASE COALESCE(c.severity,f.severity) WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END DESC,` + order
|
||||
}
|
||||
args = append(args, size, (page-1)*size)
|
||||
rows, err := d.Query(base+fmt.Sprintf(` SELECT e.cid,e.fid,e.matched_ids FROM entities e LEFT JOIN finding_cases c ON c.id=e.cid LEFT JOIN findings f ON f.id=e.fid ORDER BY %s LIMIT $%d OFFSET $%d`, order, len(args)-1, len(args)), args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out := []FindingCaseRow{}
|
||||
groups := []int64{}
|
||||
for rows.Next() {
|
||||
var cid, fid int64
|
||||
var raw []byte
|
||||
if err := rows.Scan(&cid, &fid, &raw); err != nil {
|
||||
rows.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
r := FindingCaseRow{FindingID: fid}
|
||||
if err := json.Unmarshal(raw, &r.MatchedIDs); err != nil {
|
||||
rows.Close()
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, r)
|
||||
groups = append(groups, cid)
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
for i, cid := range groups {
|
||||
if cid > 0 {
|
||||
c, err := scanCase(d.QueryRow(`SELECT `+strings.Replace(caseColumns, "c.report,", "''::text,", 1)+` FROM finding_cases c WHERE id=$1`, cid))
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if err := d.QueryRow(`SELECT count(*),count(*) FILTER(WHERE severity='critical'),count(*) FILTER(WHERE severity='high'),count(*) FILTER(WHERE severity='medium'),count(*) FILTER(WHERE severity='low') FROM findings f JOIN finding_case_members m ON m.finding_id=f.id WHERE m.case_id=$1`, cid).Scan(&c.Count, &c.Critical, &c.High, &c.Medium, &c.Low); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out[i].Case = c
|
||||
out[i].FindingID = 0
|
||||
}
|
||||
}
|
||||
return out, total, nil
|
||||
}
|
||||
|
||||
func sortedFindingIDs(ids []int64) ([]int64, error) {
|
||||
set := map[int64]bool{}
|
||||
out := []int64{}
|
||||
for _, id := range ids {
|
||||
if id <= 0 {
|
||||
return nil, errors.New("无效漏洞编号")
|
||||
}
|
||||
if !set[id] {
|
||||
set[id] = true
|
||||
out = append(out, id)
|
||||
}
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
|
||||
if len(out) < 2 {
|
||||
return nil, errors.New("至少需要两条漏洞记录")
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
func lockCaseTask(tx *sql.Tx, taskID int64) error {
|
||||
if taskID <= 0 {
|
||||
return errors.New("归并需要仍存在的同一任务")
|
||||
}
|
||||
if _, err := tx.Exec(`SELECT pg_advisory_xact_lock($1)`, int64(5100000000000000)+taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
var id int64
|
||||
return tx.QueryRow(`SELECT id FROM tasks WHERE id=$1 FOR UPDATE`, taskID).Scan(&id)
|
||||
}
|
||||
func (d *DB) MergeFindingCase(ctx context.Context, taskID int64, ids []int64, title, reason, actor string) (int64, error) {
|
||||
return d.mergeFindingCase(ctx, taskID, ids, title, reason, actor, 0)
|
||||
}
|
||||
func (d *DB) mergeFindingCase(ctx context.Context, taskID int64, ids []int64, title, reason, actor string, suggestionID int64) (int64, error) {
|
||||
ids, err := sortedFindingIDs(ids)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if strings.TrimSpace(title) == "" || strings.TrimSpace(reason) == "" {
|
||||
return 0, errors.New("必须提供漏洞名称及基于证据的归并理由")
|
||||
}
|
||||
var cid int64
|
||||
err = d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
||||
if err := lockCaseTask(tx, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
if suggestionID > 0 {
|
||||
var state string
|
||||
if err := tx.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1 FOR UPDATE`, suggestionID).Scan(&state); err != nil {
|
||||
return err
|
||||
}
|
||||
if state != "pending" {
|
||||
return ErrFindingCaseConflict
|
||||
}
|
||||
}
|
||||
groupSet := map[int64]bool{}
|
||||
for _, id := range ids {
|
||||
var tid sql.NullInt64
|
||||
var group int64
|
||||
if err := tx.QueryRow(`SELECT f.task_id,COALESCE(m.case_id,0) FROM findings f LEFT JOIN finding_case_members m ON m.finding_id=f.id WHERE f.id=$1`, id).Scan(&tid, &group); err != nil {
|
||||
return err
|
||||
}
|
||||
if !tid.Valid || tid.Int64 != taskID {
|
||||
return errors.New("只能归并同一任务的漏洞")
|
||||
}
|
||||
if group > 0 {
|
||||
groupSet[group] = true
|
||||
}
|
||||
}
|
||||
groups := []int64{}
|
||||
for g := range groupSet {
|
||||
groups = append(groups, g)
|
||||
}
|
||||
sort.Slice(groups, func(i, j int) bool { return groups[i] < groups[j] })
|
||||
all := map[int64]bool{}
|
||||
for _, id := range ids {
|
||||
all[id] = true
|
||||
}
|
||||
for _, g := range groups {
|
||||
rows, err := tx.Query(`SELECT finding_id FROM finding_case_members WHERE case_id=$1`, g)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
rows.Close()
|
||||
return err
|
||||
}
|
||||
all[id] = true
|
||||
}
|
||||
err = rows.Err()
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ids = ids[:0]
|
||||
for id := range all {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
raw, _ := json.Marshal(ids)
|
||||
var blocked bool
|
||||
if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_blocks WHERE left_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb)) AND right_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb)))`, string(raw)).Scan(&blocked); err != nil {
|
||||
return err
|
||||
}
|
||||
if blocked {
|
||||
return errors.New("人工已否决或撤销这些记录的归并")
|
||||
}
|
||||
if len(groups) > 0 {
|
||||
cid = groups[0]
|
||||
} else {
|
||||
if err := tx.QueryRow(`INSERT INTO finding_cases(task_id,origin_task_id,title,reason) VALUES($1,$1,$2,$3) RETURNING id`, taskID, title, reason).Scan(&cid); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var changed int64
|
||||
for _, id := range ids {
|
||||
res, err := tx.Exec(`INSERT INTO finding_case_members(finding_id,case_id) VALUES($1,$2) ON CONFLICT(finding_id) DO UPDATE SET case_id=EXCLUDED.case_id WHERE finding_case_members.case_id<>EXCLUDED.case_id`, id, cid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
changed += n
|
||||
}
|
||||
for _, g := range groups {
|
||||
if g != cid {
|
||||
if _, err := tx.Exec(`UPDATE finding_cases SET active=false,version=version+1 WHERE id=$1`, g); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if changed > 0 {
|
||||
if _, err := tx.Exec(`UPDATE finding_cases SET version=version+1 WHERE id=$1`, cid); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO finding_case_events(case_id,action,actor,reason,finding_ids) VALUES($1,'merge',$2,$3,$4)`, cid, actor, reason, string(raw)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
_, err := tx.Exec(`UPDATE finding_case_suggestions SET state='accepted' WHERE state='pending' AND left_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb)) AND right_id IN(SELECT value::bigint FROM jsonb_array_elements_text($1::jsonb))`, string(raw))
|
||||
return err
|
||||
})
|
||||
return cid, err
|
||||
}
|
||||
func (d *DB) RemoveFindingCaseMember(ctx context.Context, cid, fid int64, reason string) error {
|
||||
c, err := d.GetFindingCase(cid)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if c == nil {
|
||||
return errors.New("归并组不存在")
|
||||
}
|
||||
return d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
||||
var origin int64
|
||||
if err := tx.QueryRow(`SELECT origin_task_id FROM finding_cases WHERE id=$1`, cid).Scan(&origin); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`SELECT pg_advisory_xact_lock($1)`, int64(5100000000000000)+origin); err != nil {
|
||||
return err
|
||||
}
|
||||
// Deleting a task retains its findings and folder history.
|
||||
var live int64
|
||||
if err := tx.QueryRow(`SELECT id FROM tasks WHERE id=$1 FOR UPDATE`, origin).Scan(&live); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||
return err
|
||||
}
|
||||
var current int64
|
||||
if err := tx.QueryRow(`SELECT case_id FROM finding_case_members WHERE finding_id=$1`, fid).Scan(¤t); err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
var removed bool
|
||||
// A repeated removal, or a last member released by dissolution, is idempotent.
|
||||
err = tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_events WHERE case_id=$1 AND finding_ids @> jsonb_build_array($2::bigint))`, cid, fid).Scan(&removed)
|
||||
if err == nil && removed {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
if current != cid {
|
||||
return ErrFindingCaseConflict
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO finding_case_blocks(left_id,right_id,reason) SELECT LEAST($1,finding_id),GREATEST($1,finding_id),$3 FROM finding_case_members WHERE case_id=$2 AND finding_id<>$1 ON CONFLICT DO NOTHING`, fid, cid, reason); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO finding_case_events(case_id,action,actor,reason,finding_ids) VALUES($1,'detach','human',$2,jsonb_build_array($3::bigint))`, cid, reason, fid); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := tx.Exec(`DELETE FROM finding_case_members WHERE case_id=$1 AND finding_id=$2`, cid, fid)
|
||||
return err
|
||||
})
|
||||
}
|
||||
func (d *DB) UpdateFindingCaseReport(ctx context.Context, cid, version int64, title, report, severity, reason string) error {
|
||||
if !ValidSeverity(severity) || strings.TrimSpace(report) == "" || strings.TrimSpace(reason) == "" || strings.TrimSpace(title) == "" {
|
||||
return errors.New("完整报告、名称、有效等级和评级依据必填")
|
||||
}
|
||||
return d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
||||
c, err := scanCase(tx.QueryRow(`SELECT `+caseColumns+` FROM finding_cases c WHERE id=$1 FOR UPDATE`, cid))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !c.Active || c.Version != version {
|
||||
return ErrFindingCaseConflict
|
||||
}
|
||||
if c.Title == title && c.Report == report && c.Severity == severity && c.SeverityReason == reason && c.ReportVersion == version {
|
||||
return nil
|
||||
}
|
||||
_, err = tx.Exec(`UPDATE finding_cases SET title=$2,report=$3,severity=$4,severity_reason=$5,report_version=$6 WHERE id=$1`, cid, title, report, severity, reason, version)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = tx.Exec(`INSERT INTO finding_case_events(case_id,action,actor,reason) VALUES($1,'report_updated','reporter',$2)`, cid, "统一报告已更新;评级依据:"+reason)
|
||||
return err
|
||||
})
|
||||
}
|
||||
func (d *DB) SuggestFindingCase(ctx context.Context, taskID, left, right int64, title, reason string) (int64, error) {
|
||||
if strings.TrimSpace(title) == "" || strings.TrimSpace(reason) == "" {
|
||||
return 0, errors.New("建议需要名称和具体证据理由")
|
||||
}
|
||||
ids, err := sortedFindingIDs([]int64{left, right})
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var id int64
|
||||
err = d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
||||
if err := lockCaseTask(tx, taskID); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, fid := range ids {
|
||||
var tid sql.NullInt64
|
||||
if err := tx.QueryRow(`SELECT task_id FROM findings WHERE id=$1`, fid).Scan(&tid); err != nil {
|
||||
return err
|
||||
}
|
||||
if !tid.Valid || tid.Int64 != taskID {
|
||||
return errors.New("只能建议同一任务的归并")
|
||||
}
|
||||
}
|
||||
var blocked bool
|
||||
if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM finding_case_blocks WHERE left_id=$1 AND right_id=$2)`, ids[0], ids[1]).Scan(&blocked); err != nil {
|
||||
return err
|
||||
}
|
||||
if blocked {
|
||||
return errors.New("人工已否决该归并")
|
||||
}
|
||||
return tx.QueryRow(`INSERT INTO finding_case_suggestions(task_id,left_id,right_id,title,reason) VALUES($1,$2,$3,$4,$5) ON CONFLICT(left_id,right_id) DO UPDATE SET reason=EXCLUDED.reason WHERE finding_case_suggestions.state='pending' RETURNING id`, taskID, ids[0], ids[1], title, reason).Scan(&id)
|
||||
})
|
||||
return id, err
|
||||
}
|
||||
func (d *DB) FindingCaseSuggestions(taskID int64) ([]FindingCaseSuggestion, error) {
|
||||
rows, err := d.Query(`SELECT id,task_id,left_id,right_id,title,reason,state FROM finding_case_suggestions WHERE state='pending' AND ($1::bigint=0 OR task_id=$1) ORDER BY id DESC`, taskID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []FindingCaseSuggestion{}
|
||||
for rows.Next() {
|
||||
var s FindingCaseSuggestion
|
||||
if err := rows.Scan(&s.ID, &s.TaskID, &s.LeftID, &s.RightID, &s.Title, &s.Reason, &s.State); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, s)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
func (d *DB) ResolveFindingCaseSuggestion(ctx context.Context, id int64, accept bool) (int64, error) {
|
||||
var s FindingCaseSuggestion
|
||||
if err := d.QueryRow(`SELECT task_id,left_id,right_id,title,reason,state FROM finding_case_suggestions WHERE id=$1`, id).Scan(&s.TaskID, &s.LeftID, &s.RightID, &s.Title, &s.Reason, &s.State); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if s.State != "pending" {
|
||||
return 0, ErrFindingCaseConflict
|
||||
}
|
||||
if accept {
|
||||
return d.mergeFindingCase(ctx, s.TaskID, []int64{s.LeftID, s.RightID}, s.Title, s.Reason, "human", id)
|
||||
}
|
||||
return 0, d.WithEvidenceTx(ctx, func(tx *sql.Tx) error {
|
||||
if err := lockCaseTask(tx, s.TaskID); err != nil {
|
||||
return err
|
||||
}
|
||||
res, err := tx.Exec(`UPDATE finding_case_suggestions SET state='rejected' WHERE id=$1 AND state='pending'`, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n, _ := res.RowsAffected(); n != 1 {
|
||||
return ErrFindingCaseConflict
|
||||
}
|
||||
if _, err := tx.Exec(`INSERT INTO finding_case_blocks(left_id,right_id,reason) VALUES($1,$2,'人工否决') ON CONFLICT DO NOTHING`, s.LeftID, s.RightID); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
type FindingDistinctStats struct {
|
||||
Total int `json:"total"`
|
||||
Reports int `json:"reports"`
|
||||
Critical int `json:"critical"`
|
||||
High int `json:"high"`
|
||||
Medium int `json:"medium"`
|
||||
Low int `json:"low"`
|
||||
Unassessed int `json:"unassessed"`
|
||||
}
|
||||
|
||||
func (d *DB) DistinctFindingStats(taskID string) (*FindingDistinctStats, error) {
|
||||
filter := FindingFilter{TaskID: taskID}
|
||||
where, args := filter.where()
|
||||
s := new(FindingDistinctStats)
|
||||
err := d.QueryRow(`WITH observations AS (SELECT f.id,f.severity,COALESCE(m.case_id,0) cid FROM findings f LEFT JOIN tasks t ON t.id=f.task_id LEFT JOIN finding_case_members m ON m.finding_id=f.id`+where+`), defects AS (SELECT severity FROM observations WHERE cid=0 UNION ALL SELECT c.severity FROM finding_cases c WHERE c.id IN(SELECT cid FROM observations WHERE cid>0)) SELECT count(*),(SELECT count(*) FROM observations),count(*) FILTER(WHERE severity='critical'),count(*) FILTER(WHERE severity='high'),count(*) FILTER(WHERE severity='medium'),count(*) FILTER(WHERE severity='low'),count(*) FILTER(WHERE severity='') FROM defects`, args...).Scan(&s.Total, &s.Reports, &s.Critical, &s.High, &s.Medium, &s.Low, &s.Unassessed)
|
||||
return s, err
|
||||
}
|
||||
|
||||
// Compact candidate pages exclude PoC/report bodies and do not treat severity as identity.
|
||||
func (d *DB) FindingCaseCandidates(fid int64, page, size int) ([]map[string]any, int, error) {
|
||||
f, err := d.FindingCaseSummary(fid)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if f == nil || f.TaskID == nil {
|
||||
return nil, 0, errors.New("漏洞任务不可用")
|
||||
}
|
||||
if page < 1 {
|
||||
page = 1
|
||||
}
|
||||
if size < 1 || size > 100 {
|
||||
size = 20
|
||||
}
|
||||
assets, _ := json.Marshal(f.AssetIDs)
|
||||
if f.AssetIDs == nil {
|
||||
assets = []byte("[]")
|
||||
}
|
||||
// Type/asset/name only retrieve candidates; the Reporter must still prove the root cause.
|
||||
where := ` WHERE f.task_id=$1 AND f.id<>$2 AND NOT EXISTS(SELECT 1 FROM finding_case_blocks b WHERE b.left_id=LEAST(f.id,$2::bigint) AND b.right_id=GREATEST(f.id,$2::bigint))
|
||||
AND ((trim($3::text)<>'' AND lower(f.vulnclass)=lower($3)) OR (trim($4::text)<>'' AND lower(f.name)=lower($4))
|
||||
OR EXISTS(SELECT 1 FROM jsonb_array_elements(COALESCE(NULLIF(f.asset_ids,'null'::jsonb),'[]'::jsonb)) a WHERE a.value IN(SELECT value FROM jsonb_array_elements($5::jsonb))))`
|
||||
args := []any{*f.TaskID, fid, f.VulnClass, f.Name, string(assets)}
|
||||
var total int
|
||||
if err := d.QueryRow(`SELECT count(*) FROM findings f`+where, args...).Scan(&total); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
args = append(args, size, (page-1)*size)
|
||||
rows, err := d.Query(`SELECT f.id,f.name,f.vulnclass,f.severity,left(f.summary,400),f.asset_ids,COALESCE(m.case_id,0) FROM findings f LEFT JOIN finding_case_members m ON m.finding_id=f.id`+where+` ORDER BY f.id DESC LIMIT $6 OFFSET $7`, args...)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := []map[string]any{}
|
||||
for rows.Next() {
|
||||
var id, cid int64
|
||||
var name, class, severity, summary string
|
||||
var assetIDs json.RawMessage
|
||||
if err := rows.Scan(&id, &name, &class, &severity, &summary, &assetIDs, &cid); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
out = append(out, map[string]any{"finding_id": strconv.FormatInt(id, 10), "name": name, "vulnclass": class, "severity": severity, "summary": summary, "asset_ids": assetIDs, "case_id": strconv.FormatInt(cid, 10)})
|
||||
}
|
||||
return out, total, rows.Err()
|
||||
}
|
||||
|
||||
// Batch task-list counters avoid an additional database round trip per task.
|
||||
func (d *DB) DistinctFindingStatsByTask() (map[string]*FindingDistinctStats, error) {
|
||||
rows, err := d.Query(`WITH observations AS (
|
||||
SELECT f.task_id,CASE WHEN m.case_id IS NULL THEN 'f:'||f.id ELSE 'c:'||m.case_id END entity,
|
||||
CASE WHEN m.case_id IS NULL THEN f.severity ELSE c.severity END severity
|
||||
FROM findings f LEFT JOIN finding_case_members m ON m.finding_id=f.id LEFT JOIN finding_cases c ON c.id=m.case_id WHERE f.task_id IS NOT NULL)
|
||||
SELECT task_id,count(DISTINCT entity),count(*),
|
||||
count(DISTINCT entity) FILTER(WHERE severity='critical'),count(DISTINCT entity) FILTER(WHERE severity='high'),
|
||||
count(DISTINCT entity) FILTER(WHERE severity='medium'),count(DISTINCT entity) FILTER(WHERE severity='low'),
|
||||
count(DISTINCT entity) FILTER(WHERE severity='') FROM observations GROUP BY task_id`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := map[string]*FindingDistinctStats{}
|
||||
for rows.Next() {
|
||||
var id int64
|
||||
s := new(FindingDistinctStats)
|
||||
if err := rows.Scan(&id, &s.Total, &s.Reports, &s.Critical, &s.High, &s.Medium, &s.Low, &s.Unassessed); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out[strconv.FormatInt(id, 10)] = s
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Folder rows and candidate searches never fetch full report/PoC bodies.
|
||||
func (d *DB) FindingCaseSummary(id int64) (*DBFinding, error) {
|
||||
cols := strings.Replace(findingSelectCols, "f.evidence,", "''::text,", 1)
|
||||
rows, err := d.Query(`SELECT `+cols+` FROM findings f LEFT JOIN tasks t ON t.id=f.task_id WHERE f.id=$1`, id)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
fs, err := scanFindings(rows)
|
||||
if err != nil || len(fs) == 0 {
|
||||
return nil, err
|
||||
}
|
||||
return fs[0], nil
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func caseTestDB(t *testing.T) (*DB, *Task, []int64) {
|
||||
t.Helper()
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { d.Close() })
|
||||
task, err := d.CreateTask("case integration", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids := []int64{}
|
||||
for _, sev := range []string{"high", "medium", "low", "high"} {
|
||||
id, err := d.AddFinding(task.ID, 0, "IDOR", "order access", sev, "GET /orders/{id}", "original PoC "+sev, "worker", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ids = append(ids, id)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM findings WHERE task_id=$1`, task.ID); _ = d.DeleteTask(task.ID) })
|
||||
return d, task, ids
|
||||
}
|
||||
func TestFindingCaseMergePreservesRecordsAndVersions(t *testing.T) {
|
||||
d, task, ids := caseTestDB(t)
|
||||
ctx := context.Background()
|
||||
id, err := d.MergeFindingCase(ctx, task.ID, ids[:3], "订单越权", "同一接口同一权限检查缺失", "reporter")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, err := d.GetFindingCase(id)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if c.Count != 3 || c.High != 1 || c.Medium != 1 || c.Low != 1 {
|
||||
t.Fatalf("counts %+v", c)
|
||||
}
|
||||
stats, err := d.DistinctFindingStats(fmt.Sprint(task.ID))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
batch, err := d.DistinctFindingStatsByTask()
|
||||
if err != nil || batch[fmt.Sprint(task.ID)] == nil || *batch[fmt.Sprint(task.ID)] != *stats {
|
||||
t.Fatalf("batch stats %+v %v", batch, err)
|
||||
}
|
||||
if stats.Total != 2 || stats.Reports != 4 || stats.Unassessed != 1 {
|
||||
t.Fatalf("stats %+v", stats)
|
||||
}
|
||||
again, err := d.MergeFindingCase(ctx, task.ID, ids[:3], "same", "same", "reporter")
|
||||
if err != nil || again != id {
|
||||
t.Fatalf("idempotency %d %v", again, err)
|
||||
}
|
||||
same, _ := d.GetFindingCase(id)
|
||||
if same.Version != c.Version {
|
||||
t.Fatal("idempotent merge invalidated report")
|
||||
}
|
||||
if err := d.UpdateFindingCaseReport(ctx, id, c.Version, "订单越权", "complete PoC", "high", "verified sensitive data"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, fid := range ids[:3] {
|
||||
f, err := d.GetFinding(fid)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.Severity != []string{"high", "medium", "low"}[i] || f.Evidence == "" || f.Status != FindingPending {
|
||||
t.Fatalf("original overwritten %+v", f)
|
||||
}
|
||||
}
|
||||
if _, err := d.Exec(`UPDATE findings SET evidence='new proof' WHERE id=$1`, ids[1]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.UpdateFindingCaseReport(ctx, id, c.Version, "x", "stale", "low", "x"); err != ErrFindingCaseConflict {
|
||||
t.Fatalf("stale accepted %v", err)
|
||||
}
|
||||
if err := d.RemoveFindingCaseMember(ctx, id, ids[1], "different cause"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := d.MergeFindingCase(ctx, task.ID, ids[:3], "x", "x", "reporter"); err == nil {
|
||||
t.Fatal("human veto ignored")
|
||||
}
|
||||
if err := d.RemoveFindingCaseMember(ctx, id, ids[2], "undo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, _ = d.GetFindingCase(id)
|
||||
if c.Active {
|
||||
t.Fatal("singleton remains group")
|
||||
}
|
||||
for _, fid := range ids[:3] {
|
||||
g, err := d.FindingCaseID(fid)
|
||||
if err != nil || g != 0 {
|
||||
t.Fatalf("dangling membership %d %v", g, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
func TestFindingCaseScopeSuggestionsAndDeletion(t *testing.T) {
|
||||
d, task, ids := caseTestDB(t)
|
||||
ctx := context.Background()
|
||||
other, err := d.CreateTask("other", "goal", nil, 0, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.DeleteTask(other.ID)
|
||||
fid, err := d.AddFinding(other.ID, 0, "IDOR", "other", "high", "same", "poc", "worker", nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer d.Exec(`DELETE FROM findings WHERE id=$1`, fid)
|
||||
if _, err := d.MergeFindingCase(ctx, task.ID, []int64{ids[0], fid}, "x", "x", "reporter"); err == nil {
|
||||
t.Fatal("cross task accepted")
|
||||
}
|
||||
sid, err := d.SuggestFindingCase(ctx, task.ID, ids[0], ids[1], "order", "uncertain")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := d.ResolveFindingCaseSuggestion(ctx, sid, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := d.MergeFindingCase(ctx, task.ID, ids[:2], "x", "x", "reporter"); err == nil {
|
||||
t.Fatal("rejection ignored")
|
||||
}
|
||||
cid, err := d.MergeFindingCase(ctx, task.ID, ids[2:], "x", "proof", "reporter")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := d.DeleteFinding(ids[3]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, _ := d.GetFindingCase(cid)
|
||||
if c.Active {
|
||||
t.Fatal("deletion did not dissolve group")
|
||||
}
|
||||
f, err := d.GetFinding(ids[2])
|
||||
if err != nil || f == nil {
|
||||
t.Fatal("surviving finding lost")
|
||||
}
|
||||
}
|
||||
func TestFindingCaseConcurrentUnion(t *testing.T) {
|
||||
d, task, ids := caseTestDB(t)
|
||||
ctx := context.Background()
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 3)
|
||||
for _, pair := range [][]int64{{ids[0], ids[1]}, {ids[1], ids[2]}, {ids[2], ids[3]}} {
|
||||
pair := pair
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
_, err := d.MergeFindingCase(ctx, task.ID, pair, "orders", "same authorization check", "reporter")
|
||||
errs <- err
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
for err := range errs {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
group := int64(0)
|
||||
for _, fid := range ids {
|
||||
cid, err := d.FindingCaseID(fid)
|
||||
if err != nil || cid == 0 {
|
||||
t.Fatalf("membership %v", err)
|
||||
}
|
||||
if group != 0 && cid != group {
|
||||
t.Fatal("overlapping parallel groups")
|
||||
}
|
||||
group = cid
|
||||
}
|
||||
c, _ := d.GetFindingCase(group)
|
||||
if c.Count != 4 {
|
||||
t.Fatalf("lost member %+v", c)
|
||||
}
|
||||
rows, total, err := d.ListFindingCases(FindingFilter{TaskID: fmt.Sprint(task.ID), Severity: "low"}, 1, 10)
|
||||
if err != nil || total != 1 || len(rows) != 1 || rows[0].Case.Count != 4 || len(rows[0].MatchedIDs) != 1 {
|
||||
t.Fatalf("filter/aggregation %d %+v %v", total, rows, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingCaseTaskDeletionAndRepeatedDetach(t *testing.T) {
|
||||
d, task, ids := caseTestDB(t)
|
||||
ctx := context.Background()
|
||||
cid, err := d.MergeFindingCase(ctx, task.ID, ids[:3], "orders", "same missing permission", "reporter")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// A source task can disappear without deleting standalone reports or breaking old links.
|
||||
if err := d.DeleteTask(task.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() {
|
||||
for _, id := range ids {
|
||||
_, _ = d.DeleteFinding(id)
|
||||
}
|
||||
}()
|
||||
c, err := d.GetFindingCase(cid)
|
||||
if err != nil || c == nil || c.TaskID != nil || c.Count != 3 {
|
||||
t.Fatalf("retention %+v %v", c, err)
|
||||
}
|
||||
if err := d.RemoveFindingCaseMember(ctx, cid, ids[0], "undo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.RemoveFindingCaseMember(ctx, cid, ids[0], "retry"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.RemoveFindingCaseMember(ctx, cid, ids[1], "undo"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.RemoveFindingCaseMember(ctx, cid, ids[2], "released by dissolution"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c, _ = d.GetFindingCase(cid)
|
||||
if c.Active || c.Count != 0 {
|
||||
t.Fatalf("folder not dissolved %+v", c)
|
||||
}
|
||||
for _, id := range ids[:3] {
|
||||
f, e := d.GetFinding(id)
|
||||
if e != nil || f == nil || f.Evidence == "" {
|
||||
t.Fatalf("original lost %d %v", id, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingCaseSuggestionResolutionIsAtomic(t *testing.T) {
|
||||
d, task, ids := caseTestDB(t)
|
||||
ctx := context.Background()
|
||||
sid, err := d.SuggestFindingCase(ctx, task.ID, ids[0], ids[1], "orders", "uncertain")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
results := make(chan error, 2)
|
||||
var wg sync.WaitGroup
|
||||
for _, accept := range []bool{true, false} {
|
||||
wg.Add(1)
|
||||
go func(a bool) { defer wg.Done(); _, e := d.ResolveFindingCaseSuggestion(ctx, sid, a); results <- e }(accept)
|
||||
}
|
||||
wg.Wait()
|
||||
close(results)
|
||||
success := 0
|
||||
for err := range results {
|
||||
if err == nil {
|
||||
success++
|
||||
}
|
||||
}
|
||||
if success != 1 {
|
||||
t.Fatalf("%d successful conflicting resolutions", success)
|
||||
}
|
||||
var state string
|
||||
if err := d.QueryRow(`SELECT state FROM finding_case_suggestions WHERE id=$1`, sid).Scan(&state); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cid, _ := d.FindingCaseID(ids[0])
|
||||
if (state == "accepted") != (cid > 0) {
|
||||
t.Fatalf("inconsistent resolution %s %d", state, cid)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindingCaseCandidatesAreCompactAndRelated(t *testing.T) {
|
||||
d, task, ids := caseTestDB(t)
|
||||
if _, err := d.Exec(`UPDATE findings SET vulnclass='SQLi',name='search injection' WHERE id=$1`, ids[3]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
items, total, err := d.FindingCaseCandidates(ids[0], 1, 1)
|
||||
if err != nil || total != 2 || len(items) != 1 {
|
||||
t.Fatalf("candidate pagination %d %+v %v", total, items, err)
|
||||
}
|
||||
if _, ok := items[0]["report"]; ok {
|
||||
t.Fatal("report included in candidate summary")
|
||||
}
|
||||
if _, ok := items[0]["evidence"]; ok {
|
||||
t.Fatal("evidence included in candidate summary")
|
||||
}
|
||||
cid, err := d.MergeFindingCase(context.Background(), task.ID, ids[:2], "orders", "proof", "reporter")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := d.RemoveFindingCaseMember(context.Background(), cid, ids[1], "different cause"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, total, err = d.FindingCaseCandidates(ids[0], 1, 20)
|
||||
if err != nil || total != 1 {
|
||||
t.Fatalf("veto excluded %d %v", total, err)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,9 @@ import (
|
||||
// DBFinding is a row in the standalone findings table. It persists across task
|
||||
// deletion unless the caller explicitly requests related finding cleanup.
|
||||
type DBFinding struct {
|
||||
ExportCaseID int64 // transient unified-export identity, never a finding handle
|
||||
ExportMemberIDs []int64
|
||||
|
||||
TrafficCount int
|
||||
EvidenceVersion int64
|
||||
ReportEvidenceVersion int64
|
||||
@@ -524,6 +527,8 @@ func (d *DB) ListFindingsForExport(f FindingFilter, ids []int64) ([]*DBFinding,
|
||||
// and vuln-class filter — computed server-side so it stays exact regardless of
|
||||
// pagination.
|
||||
type FindingStats struct {
|
||||
Distinct *FindingDistinctStats `json:"distinct,omitempty"`
|
||||
|
||||
Total int `json:"total"`
|
||||
Pending int `json:"pending"`
|
||||
Critical int `json:"critical"`
|
||||
|
||||
@@ -1384,3 +1384,90 @@ CREATE INDEX IF NOT EXISTS idx_notification_deliveries_batch
|
||||
ON notification_deliveries(batch_id) WHERE batch_id IS NOT NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_channel
|
||||
ON notification_deliveries(channel_id, id DESC);
|
||||
|
||||
-- A case collects repeated observations of one defect without rewriting findings.
|
||||
CREATE TABLE IF NOT EXISTS finding_cases (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
task_id BIGINT REFERENCES tasks(id) ON DELETE SET NULL,
|
||||
origin_task_id BIGINT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
severity TEXT NOT NULL DEFAULT '',
|
||||
severity_reason TEXT NOT NULL DEFAULT '',
|
||||
report TEXT NOT NULL DEFAULT '',
|
||||
version BIGINT NOT NULL DEFAULT 1,
|
||||
report_version BIGINT NOT NULL DEFAULT 0,
|
||||
active BOOLEAN NOT NULL DEFAULT true,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS finding_case_members (
|
||||
finding_id BIGINT PRIMARY KEY REFERENCES findings(id) ON DELETE CASCADE,
|
||||
case_id BIGINT NOT NULL REFERENCES finding_cases(id),
|
||||
joined_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_finding_case_members_case ON finding_case_members(case_id);
|
||||
CREATE TABLE IF NOT EXISTS finding_case_events (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
case_id BIGINT NOT NULL REFERENCES finding_cases(id),
|
||||
action TEXT NOT NULL,
|
||||
actor TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
finding_ids JSONB NOT NULL DEFAULT '[]',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS finding_case_suggestions (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
task_id BIGINT NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
|
||||
left_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE,
|
||||
right_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE,
|
||||
title TEXT NOT NULL,
|
||||
reason TEXT NOT NULL,
|
||||
state TEXT NOT NULL DEFAULT 'pending',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
UNIQUE(left_id,right_id),
|
||||
CHECK(left_id < right_id)
|
||||
);
|
||||
-- Rejected/detached pairs are a durable human veto, also checked on group unions.
|
||||
CREATE TABLE IF NOT EXISTS finding_case_blocks (
|
||||
left_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE,
|
||||
right_id BIGINT NOT NULL REFERENCES findings(id) ON DELETE CASCADE,
|
||||
reason TEXT NOT NULL,
|
||||
PRIMARY KEY(left_id,right_id), CHECK(left_id < right_id)
|
||||
);
|
||||
CREATE OR REPLACE FUNCTION finding_case_changed() RETURNS trigger LANGUAGE plpgsql AS $$
|
||||
DECLARE cid BIGINT; remaining INTEGER;
|
||||
BEGIN
|
||||
IF TG_TABLE_NAME = 'findings' THEN
|
||||
IF OLD IS NOT DISTINCT FROM NEW THEN RETURN NEW; END IF;
|
||||
SELECT case_id INTO cid FROM finding_case_members WHERE finding_id=NEW.id;
|
||||
ELSE
|
||||
cid := OLD.case_id;
|
||||
END IF;
|
||||
IF cid IS NOT NULL THEN
|
||||
UPDATE finding_cases SET version=version+1 WHERE id=cid;
|
||||
IF TG_OP='DELETE' THEN
|
||||
SELECT count(*) INTO remaining FROM finding_case_members WHERE case_id=cid;
|
||||
INSERT INTO finding_case_events(case_id,action,actor,reason,finding_ids)
|
||||
VALUES(cid,'member_deleted','system','成员移出或删除',jsonb_build_array(OLD.finding_id));
|
||||
IF remaining < 2 THEN
|
||||
UPDATE finding_cases SET active=false WHERE id=cid;
|
||||
DELETE FROM finding_case_members WHERE case_id=cid;
|
||||
END IF;
|
||||
END IF;
|
||||
END IF;
|
||||
RETURN NULL;
|
||||
END $$;
|
||||
DROP TRIGGER IF EXISTS finding_case_content_changed ON findings;
|
||||
CREATE TRIGGER finding_case_content_changed AFTER UPDATE ON findings
|
||||
FOR EACH ROW EXECUTE FUNCTION finding_case_changed();
|
||||
DROP TRIGGER IF EXISTS finding_case_member_deleted ON finding_case_members;
|
||||
CREATE TRIGGER finding_case_member_deleted AFTER DELETE ON finding_case_members
|
||||
FOR EACH ROW EXECUTE FUNCTION finding_case_changed();
|
||||
CREATE TABLE IF NOT EXISTS finding_case_review_runs (
|
||||
conversation_id BIGINT PRIMARY KEY REFERENCES conversations(id) ON DELETE CASCADE,
|
||||
task_id BIGINT REFERENCES tasks(id) ON DELETE SET NULL,
|
||||
state TEXT NOT NULL DEFAULT 'queued',
|
||||
error TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
|
||||
ALTER TABLE finding_case_review_runs ADD COLUMN IF NOT EXISTS finding_ids JSONB NOT NULL DEFAULT '[]';
|
||||
Reference in new issue
Block a user