mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-08-15 07:30:53 +02:00
Delete internal directory
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -1,449 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestAssetURLNormalizationAndValidation(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "asset-validation.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
asset := &Asset{Host: "https://例子.测试/path", Tags: []string{" prod ", "prod"}}
|
||||
result, err := db.UpsertAssets([]*Asset{asset}, "")
|
||||
if err != nil || result.Created != 1 {
|
||||
t.Fatalf("URL asset was not created: result=%#v err=%v", result, err)
|
||||
}
|
||||
if asset.Domain != "xn--fsqu00a.xn--0zwm56d" || asset.Protocol != "https" || asset.Port != 443 {
|
||||
t.Fatalf("URL fields were not normalized: %#v", asset)
|
||||
}
|
||||
if len(asset.Tags) != 1 || asset.Tags[0] != "prod" {
|
||||
t.Fatalf("tags were not normalized: %#v", asset.Tags)
|
||||
}
|
||||
|
||||
invalid := []*Asset{
|
||||
{IP: "999.1.1.1", Status: "active"},
|
||||
{Domain: "bad_domain.example", Status: "active"},
|
||||
{Domain: "example.com", Port: 70000, Status: "active"},
|
||||
{Domain: "example.com", Protocol: "HTTP 1.1", Status: "active"},
|
||||
{Domain: "example.com", Status: "deleted"},
|
||||
}
|
||||
for _, candidate := range invalid {
|
||||
if _, err := db.UpsertAssets([]*Asset{candidate}, ""); err == nil {
|
||||
t.Fatalf("invalid asset unexpectedly accepted: %#v", candidate)
|
||||
}
|
||||
}
|
||||
|
||||
for _, host := range []string{"123", "not a formal target", "https://", "https://user:password@example.com"} {
|
||||
result, err := db.UpsertAssets([]*Asset{{Host: host}}, "")
|
||||
if err != nil || result.Created != 1 {
|
||||
t.Fatalf("opaque asset address %q was not accepted: result=%#v err=%v", host, result, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetValidationRejectsOversizedTags(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "asset-tag-validation.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
_, err = db.UpsertAssets([]*Asset{{Domain: "example.com", Tags: []string{strings.Repeat("x", 65)}}}, "")
|
||||
if err == nil || !strings.Contains(err.Error(), "标签") {
|
||||
t.Fatalf("expected tag validation error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFofaAssetIgnoresInvalidOptionalStructuredFields(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "fofa-asset-validation.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
asset := &Asset{
|
||||
Host: "https://203.0.113.59:8443",
|
||||
IP: "203.0.113.59",
|
||||
Domain: "provider_specific_invalid_domain_59",
|
||||
Port: 8443,
|
||||
Protocol: "https",
|
||||
Source: "fofa",
|
||||
}
|
||||
result, err := db.UpsertAssets([]*Asset{asset}, "")
|
||||
if err != nil || result.Created != 1 {
|
||||
t.Fatalf("FOFA asset with dirty optional domain was not created: result=%#v err=%v", result, err)
|
||||
}
|
||||
if asset.Domain != "" || asset.IP != "203.0.113.59" {
|
||||
t.Fatalf("FOFA structured fields were not sanitized: %#v", asset)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetUpsertDeduplicatesAndUpdates(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "assets.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
first := &Asset{Host: "https://example.com", Domain: "Example.COM", Port: 443, Protocol: "HTTPS", Title: "Old", Source: "fofa"}
|
||||
result, err := db.UpsertAssets([]*Asset{first}, "user-a")
|
||||
if err != nil || result.Created != 1 || result.Updated != 0 {
|
||||
t.Fatalf("first upsert = %#v, %v", result, err)
|
||||
}
|
||||
second := &Asset{Domain: "example.com", Port: 443, Protocol: "https", Title: "New", Server: "nginx", Source: "fofa"}
|
||||
result, err = db.UpsertAssets([]*Asset{second}, "user-a")
|
||||
if err != nil || result.Created != 0 || result.Updated != 1 {
|
||||
t.Fatalf("second upsert = %#v, %v", result, err)
|
||||
}
|
||||
assets, total, err := db.ListAssets(20, 0, AssetListFilter{}, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || total != 1 || len(assets) != 1 {
|
||||
t.Fatalf("list assets total=%d len=%d err=%v", total, len(assets), err)
|
||||
}
|
||||
if assets[0].Title != "New" || assets[0].Server != "nginx" || assets[0].Protocol != "https" {
|
||||
t.Fatalf("asset not refreshed: %#v", assets[0])
|
||||
}
|
||||
stats, err := db.GetAssetStats(RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || stats["total"] != 1 {
|
||||
t.Fatalf("stats=%#v err=%v", stats, err)
|
||||
}
|
||||
coverage, ok := stats["coverage"].(map[string]interface{})
|
||||
if !ok || coverage["never_scanned"] != 1 || coverage["rate"] != 0 {
|
||||
t.Fatalf("coverage=%#v", stats["coverage"])
|
||||
}
|
||||
assetTrend, ok := stats["asset_trend"].([]map[string]interface{})
|
||||
if !ok || len(assetTrend) != 30 {
|
||||
t.Fatalf("asset trend=%#v", stats["asset_trend"])
|
||||
}
|
||||
riskTrend, ok := stats["risk_trend"].([]map[string]interface{})
|
||||
if !ok || len(riskTrend) != 30 {
|
||||
t.Fatalf("risk trend=%#v", stats["risk_trend"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetAccessFiltersOwners(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "assets-access.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
now := time.Now()
|
||||
if _, err := db.Exec(`INSERT INTO rbac_users (id,username,display_name,password_hash,enabled,is_builtin,created_at,updated_at) VALUES ('user-a','user-a','User A','hash',1,0,?,?)`, now, now); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.UpsertAssets([]*Asset{{IP: "10.0.0.1", Port: 80, Protocol: "http"}}, "user-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, total, err := db.ListAssets(20, 0, AssetListFilter{}, RBACListAccess{UserID: "user-b", Scope: RBACScopeAssigned})
|
||||
if err != nil || total != 0 {
|
||||
t.Fatalf("unexpected cross-user assets: total=%d err=%v", total, err)
|
||||
}
|
||||
_, total, err = db.ListAssets(20, 0, AssetListFilter{}, RBACListAccess{UserID: "user-a", Scope: RBACScopeOwn})
|
||||
if err != nil || total != 1 {
|
||||
t.Fatalf("owner cannot list asset: total=%d err=%v", total, err)
|
||||
}
|
||||
assets, _, err := db.ListAssets(1, 0, AssetListFilter{}, RBACListAccess{UserID: "user-a", Scope: RBACScopeAssigned})
|
||||
if err != nil || len(assets) != 1 || !db.UserCanAccessResource("user-a", RBACScopeAssigned, "asset", assets[0].ID) {
|
||||
t.Fatalf("creator assignment missing: assets=%d err=%v", len(assets), err)
|
||||
}
|
||||
options, err := db.ListAssignableRBACResources("asset", "10.0.0.1", 10)
|
||||
if err != nil || len(options) != 1 {
|
||||
t.Fatalf("asset resource picker: options=%#v err=%v", options, err)
|
||||
}
|
||||
project, err := db.CreateProject(&Project{Name: "Alpha", Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.SetResourceOwner("project", project.ID, "user-b"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
asset := assets[0]
|
||||
asset.ProjectID = project.ID
|
||||
if err := db.UpdateAsset(asset.ID, asset, RBACListAccess{Scope: RBACScopeAll}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projectAssets, total, err := db.ListAssets(20, 0, AssetListFilter{ProjectID: project.ID}, RBACListAccess{UserID: "user-b", Scope: RBACScopeOwn})
|
||||
if err != nil || total != 1 || len(projectAssets) != 1 || projectAssets[0].ProjectName != "Alpha" {
|
||||
t.Fatalf("project-bound asset access failed: total=%d assets=%#v err=%v", total, projectAssets, err)
|
||||
}
|
||||
if !db.UserCanAccessResource("user-b", RBACScopeOwn, "asset", asset.ID) {
|
||||
t.Fatal("project owner cannot access bound asset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateAssetsProjectIsAtomicAndScoped(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "asset-batch-project.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
project, err := db.CreateProject(&Project{Name: "Batch Project", Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.UpsertAssets([]*Asset{
|
||||
{IP: "192.0.2.1", Port: 80, Protocol: "http"},
|
||||
{IP: "192.0.2.2", Port: 443, Protocol: "https"},
|
||||
}, "owner-a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assets, _, err := db.ListAssets(10, 0, AssetListFilter{}, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || len(assets) != 2 {
|
||||
t.Fatalf("list assets: len=%d err=%v", len(assets), err)
|
||||
}
|
||||
ids := []string{assets[0].ID, assets[1].ID}
|
||||
updated, err := db.UpdateAssetsProject(ids, project.ID, RBACListAccess{UserID: "owner-a", Scope: RBACScopeOwn})
|
||||
if err != nil || updated != 2 {
|
||||
t.Fatalf("batch bind: updated=%d err=%v", updated, err)
|
||||
}
|
||||
for _, id := range ids {
|
||||
asset, err := db.GetAsset(id, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || asset.ProjectID != project.ID {
|
||||
t.Fatalf("asset %s was not bound: asset=%#v err=%v", id, asset, err)
|
||||
}
|
||||
}
|
||||
|
||||
if _, err := db.UpdateAssetsProject([]string{ids[0], "missing"}, "", RBACListAccess{Scope: RBACScopeAll}); err == nil {
|
||||
t.Fatal("partial batch update unexpectedly succeeded")
|
||||
}
|
||||
asset, err := db.GetAsset(ids[0], RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || asset.ProjectID != project.ID {
|
||||
t.Fatalf("failed batch changed an asset: asset=%#v err=%v", asset, err)
|
||||
}
|
||||
|
||||
updated, err = db.UpdateAssetsProject(ids, "", RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || updated != 2 {
|
||||
t.Fatalf("batch unbind: updated=%d err=%v", updated, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetAdvancedFiltersAndBulkMetadata(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "asset-advanced.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
project, err := db.CreateProject(&Project{Name: "Production", Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
input := []*Asset{
|
||||
{ProjectID: project.ID, Domain: "critical.example.com", Port: 443, Protocol: "https", Country: "CN", ResponsiblePerson: "Alice", Department: "Security", BusinessSystem: "Portal", Environment: "production", Criticality: "critical", Tags: []string{"internet"}},
|
||||
{ProjectID: project.ID, Domain: "dev.example.com", Port: 8080, Protocol: "http", Country: "US", Environment: "development", Criticality: "low"},
|
||||
}
|
||||
if result, err := db.UpsertAssets(input, "", true); err != nil || result.Created != 2 {
|
||||
t.Fatalf("create assets: result=%#v err=%v", result, err)
|
||||
}
|
||||
conversation, err := db.CreateConversation("critical scan", ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.MarkAssetScanned(input[0].ID, conversation.ID, "", "", RBACListAccess{Scope: RBACScopeAll}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.CreateVulnerability(&Vulnerability{ConversationID: conversation.ID, Title: "critical finding", Severity: "critical", Target: input[0].Domain}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
minVulns := 1
|
||||
items, total, err := db.ListAssets(20, 0, AssetListFilter{
|
||||
Status: "active", RiskLevel: "critical", MinVulnerabilities: &minVulns,
|
||||
Country: "cn", Environment: "production", Criticality: "critical",
|
||||
SortBy: "vulnerability_count", SortOrder: "desc",
|
||||
}, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || total != 1 || len(items) != 1 {
|
||||
t.Fatalf("advanced query: total=%d items=%#v err=%v", total, items, err)
|
||||
}
|
||||
if items[0].ResponsiblePerson != "Alice" || items[0].BusinessSystem != "Portal" || items[0].VulnerabilityCount != 1 {
|
||||
t.Fatalf("metadata did not round-trip: %#v", items[0])
|
||||
}
|
||||
|
||||
status := "inactive"
|
||||
owner := "Bob"
|
||||
environment := "staging"
|
||||
updated, err := db.UpdateAssetsBulk([]string{input[0].ID, input[1].ID}, AssetBulkPatch{
|
||||
Status: &status, ResponsiblePerson: &owner, Environment: &environment,
|
||||
AddTags: []string{"review"}, RemoveTags: []string{"internet"},
|
||||
}, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || updated != 2 {
|
||||
t.Fatalf("bulk update: updated=%d err=%v", updated, err)
|
||||
}
|
||||
for _, id := range []string{input[0].ID, input[1].ID} {
|
||||
item, err := db.GetAsset(id, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if item.Status != "inactive" || item.ResponsiblePerson != "Bob" || item.Environment != "staging" || len(item.Tags) != 1 || item.Tags[0] != "review" {
|
||||
t.Fatalf("unexpected bulk metadata: %#v", item)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAssetsForOperationAndBatchDelete(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "asset-selection.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
for i := 1; i <= 3; i++ {
|
||||
if _, err := db.UpsertAssets([]*Asset{{IP: "198.51.100." + strconv.Itoa(i), Port: 443, Protocol: "https", Tags: []string{"selected"}}}, "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
items, total, err := db.ListAssetsForOperation(10, AssetListFilter{Tag: "selected"}, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || total != 3 || len(items) != 3 {
|
||||
t.Fatalf("selection: total=%d len=%d err=%v", total, len(items), err)
|
||||
}
|
||||
ids := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
ids = append(ids, item.ID)
|
||||
}
|
||||
deleted, err := db.DeleteAssets(ids, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || deleted != 3 {
|
||||
t.Fatalf("batch delete: deleted=%d err=%v", deleted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeAssetsIsAtomic(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "asset-merge.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
input := []*Asset{
|
||||
{Domain: "merge.example.com", Port: 80, Protocol: "http", Title: "Primary", Tags: []string{"one"}},
|
||||
{Domain: "merge.example.com", Port: 443, Protocol: "https", ResponsiblePerson: "Alice", Tags: []string{"two"}},
|
||||
}
|
||||
if _, err := db.UpsertAssets(input, "", true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
primary, err := db.GetAsset(input[0].ID, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
primary.ResponsiblePerson = "Alice"
|
||||
primary.Tags = []string{"one", "two"}
|
||||
merged, err := db.MergeAssets(primary, []string{input[1].ID}, RBACListAccess{Scope: RBACScopeAll}, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || merged != 1 {
|
||||
t.Fatalf("merge: merged=%d err=%v", merged, err)
|
||||
}
|
||||
items, total, err := db.ListAssets(10, 0, AssetListFilter{}, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || total != 1 || len(items) != 1 || items[0].ResponsiblePerson != "Alice" || len(items[0].Tags) != 2 {
|
||||
t.Fatalf("unexpected merged asset: total=%d items=%#v err=%v", total, items, err)
|
||||
}
|
||||
|
||||
before := items[0].Title
|
||||
items[0].Title = "Must roll back"
|
||||
if _, err := db.MergeAssets(items[0], []string{"missing"}, RBACListAccess{Scope: RBACScopeAll}, RBACListAccess{Scope: RBACScopeAll}); err == nil {
|
||||
t.Fatal("merge with missing duplicate unexpectedly succeeded")
|
||||
}
|
||||
after, err := db.GetAsset(items[0].ID, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || after.Title != before {
|
||||
t.Fatalf("failed merge was not atomic: asset=%#v err=%v", after, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetScanLinkReturnsTimeAndRelatedVulnerabilities(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "asset-scan.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
if _, err := db.UpsertAssets([]*Asset{{IP: "192.0.2.10", Port: 443, Protocol: "https"}}, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
assets, _, err := db.ListAssets(10, 0, AssetListFilter{}, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil || len(assets) != 1 {
|
||||
t.Fatalf("list assets: len=%d err=%v", len(assets), err)
|
||||
}
|
||||
conv, err := db.CreateConversation("asset scan", ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.MarkAssetScanned(assets[0].ID, conv.ID, "", "", RBACListAccess{Scope: RBACScopeAll}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.CreateVulnerability(&Vulnerability{ConversationID: conv.ID, Title: "finding", Severity: "high", Target: "192.0.2.10"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
linked, err := db.GetAsset(assets[0].ID, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if linked.LastScanAt == nil || linked.LastScanConversationID != conv.ID || linked.VulnerabilityCount != 1 || linked.RiskLevel != "high" {
|
||||
t.Fatalf("unexpected scan metadata: %#v", linked)
|
||||
}
|
||||
vulns, err := db.ListVulnerabilities(10, 0, VulnerabilityListFilter{ConversationID: conv.ID})
|
||||
if err != nil || len(vulns) != 1 {
|
||||
t.Fatalf("list linked vulnerabilities: len=%d err=%v", len(vulns), err)
|
||||
}
|
||||
vulns[0].Status = "fixed"
|
||||
if err := db.UpdateVulnerability(vulns[0].ID, vulns[0]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolved, err := db.GetAsset(assets[0].ID, RBACListAccess{Scope: RBACScopeAll})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resolved.VulnerabilityCount != 1 || resolved.RiskLevel != "normal" {
|
||||
t.Fatalf("resolved finding should remain in history without raising current risk: %#v", resolved)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAssetListFlexibleFiltersAndOldestScanPagination(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "asset-query.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
assets := []*Asset{
|
||||
{IP: "192.0.2.1", Port: 443, Protocol: "https", Source: "fofa", Tags: []string{"prod"}},
|
||||
{IP: "192.0.2.2", Port: 80, Protocol: "http", Source: "manual", Tags: []string{"prod", "legacy"}},
|
||||
{Domain: "never.example.com", Port: 443, Protocol: "https", Source: "manual", Tags: []string{"prod"}},
|
||||
}
|
||||
if _, err := db.UpsertAssets(assets, ""); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := time.Now().Add(-90 * 24 * time.Hour).UTC()
|
||||
recent := time.Now().Add(-24 * time.Hour).UTC()
|
||||
if _, err := db.Exec(`UPDATE assets SET last_scan_at=? WHERE id=?`, old, assets[0].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE assets SET last_scan_at=? WHERE id=?`, recent, assets[1].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
access := RBACListAccess{Scope: RBACScopeAll}
|
||||
firstPage, total, err := db.ListAssets(2, 0, AssetListFilter{Tag: "prod", SortBy: "last_scan_at", SortOrder: "asc"}, access)
|
||||
if err != nil || total != 3 || len(firstPage) != 2 {
|
||||
t.Fatalf("oldest scan page: total=%d len=%d err=%v", total, len(firstPage), err)
|
||||
}
|
||||
if firstPage[0].ID != assets[2].ID || firstPage[0].LastScanAt != nil || firstPage[1].ID != assets[0].ID {
|
||||
t.Fatalf("expected never-scanned then oldest scanned asset, got %#v", firstPage)
|
||||
}
|
||||
secondPage, _, err := db.ListAssets(2, 2, AssetListFilter{Tag: "prod", SortBy: "last_scan_at", SortOrder: "asc"}, access)
|
||||
if err != nil || len(secondPage) != 1 || secondPage[0].ID != assets[1].ID {
|
||||
t.Fatalf("unexpected second page: %#v err=%v", secondPage, err)
|
||||
}
|
||||
|
||||
never, total, err := db.ListAssets(20, 0, AssetListFilter{ScanState: "never"}, access)
|
||||
if err != nil || total != 1 || len(never) != 1 || never[0].ID != assets[2].ID {
|
||||
t.Fatalf("never-scanned filter: total=%d assets=%#v err=%v", total, never, err)
|
||||
}
|
||||
port := 443
|
||||
filtered, total, err := db.ListAssets(20, 0, AssetListFilter{Source: "fofa", Port: &port, LastScanBefore: &recent}, access)
|
||||
if err != nil || total != 1 || len(filtered) != 1 || filtered[0].ID != assets[0].ID {
|
||||
t.Fatalf("structured filters: total=%d assets=%#v err=%v", total, filtered, err)
|
||||
}
|
||||
}
|
||||
@@ -1,167 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// AttackChainNode 攻击链节点
|
||||
type AttackChainNode struct {
|
||||
ID string `json:"id"`
|
||||
Type string `json:"type"` // tool, vulnerability, target, exploit
|
||||
Label string `json:"label"`
|
||||
ToolExecutionID string `json:"tool_execution_id,omitempty"`
|
||||
Metadata map[string]interface{} `json:"metadata"`
|
||||
RiskScore int `json:"risk_score"`
|
||||
}
|
||||
|
||||
// AttackChainEdge 攻击链边
|
||||
type AttackChainEdge struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Type string `json:"type"` // leads_to, exploits, enables, depends_on
|
||||
Weight int `json:"weight"`
|
||||
}
|
||||
|
||||
// SaveAttackChainNode 保存攻击链节点
|
||||
func (db *DB) SaveAttackChainNode(conversationID, nodeID, nodeType, nodeName, toolExecutionID, metadata string, riskScore int) error {
|
||||
var toolExecID sql.NullString
|
||||
if toolExecutionID != "" {
|
||||
toolExecID = sql.NullString{String: toolExecutionID, Valid: true}
|
||||
}
|
||||
|
||||
var metadataJSON sql.NullString
|
||||
if metadata != "" {
|
||||
metadataJSON = sql.NullString{String: metadata, Valid: true}
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT OR REPLACE INTO attack_chain_nodes
|
||||
(id, conversation_id, node_type, node_name, tool_execution_id, metadata, risk_score, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`
|
||||
|
||||
_, err := db.Exec(query, nodeID, conversationID, nodeType, nodeName, toolExecID, metadataJSON, riskScore)
|
||||
if err != nil {
|
||||
db.logger.Error("保存攻击链节点失败", zap.Error(err), zap.String("nodeId", nodeID))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveAttackChainEdge 保存攻击链边
|
||||
func (db *DB) SaveAttackChainEdge(conversationID, edgeID, sourceNodeID, targetNodeID, edgeType string, weight int) error {
|
||||
query := `
|
||||
INSERT OR REPLACE INTO attack_chain_edges
|
||||
(id, conversation_id, source_node_id, target_node_id, edge_type, weight, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
`
|
||||
|
||||
_, err := db.Exec(query, edgeID, conversationID, sourceNodeID, targetNodeID, edgeType, weight)
|
||||
if err != nil {
|
||||
db.logger.Error("保存攻击链边失败", zap.Error(err), zap.String("edgeId", edgeID))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAttackChainNodes 加载攻击链节点
|
||||
func (db *DB) LoadAttackChainNodes(conversationID string) ([]AttackChainNode, error) {
|
||||
query := `
|
||||
SELECT id, node_type, node_name, tool_execution_id, metadata, risk_score
|
||||
FROM attack_chain_nodes
|
||||
WHERE conversation_id = ?
|
||||
ORDER BY created_at ASC, rowid ASC
|
||||
`
|
||||
|
||||
rows, err := db.Query(query, conversationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询攻击链节点失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var nodes []AttackChainNode
|
||||
for rows.Next() {
|
||||
var node AttackChainNode
|
||||
var toolExecID sql.NullString
|
||||
var metadataJSON sql.NullString
|
||||
|
||||
err := rows.Scan(&node.ID, &node.Type, &node.Label, &toolExecID, &metadataJSON, &node.RiskScore)
|
||||
if err != nil {
|
||||
db.logger.Warn("扫描攻击链节点失败", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
if toolExecID.Valid {
|
||||
node.ToolExecutionID = toolExecID.String
|
||||
}
|
||||
|
||||
if metadataJSON.Valid && metadataJSON.String != "" {
|
||||
if err := json.Unmarshal([]byte(metadataJSON.String), &node.Metadata); err != nil {
|
||||
db.logger.Warn("解析节点元数据失败", zap.Error(err))
|
||||
node.Metadata = make(map[string]interface{})
|
||||
}
|
||||
} else {
|
||||
node.Metadata = make(map[string]interface{})
|
||||
}
|
||||
|
||||
nodes = append(nodes, node)
|
||||
}
|
||||
|
||||
return nodes, nil
|
||||
}
|
||||
|
||||
// LoadAttackChainEdges 加载攻击链边
|
||||
func (db *DB) LoadAttackChainEdges(conversationID string) ([]AttackChainEdge, error) {
|
||||
query := `
|
||||
SELECT id, source_node_id, target_node_id, edge_type, weight
|
||||
FROM attack_chain_edges
|
||||
WHERE conversation_id = ?
|
||||
ORDER BY created_at ASC, rowid ASC
|
||||
`
|
||||
|
||||
rows, err := db.Query(query, conversationID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询攻击链边失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var edges []AttackChainEdge
|
||||
for rows.Next() {
|
||||
var edge AttackChainEdge
|
||||
|
||||
err := rows.Scan(&edge.ID, &edge.Source, &edge.Target, &edge.Type, &edge.Weight)
|
||||
if err != nil {
|
||||
db.logger.Warn("扫描攻击链边失败", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
edges = append(edges, edge)
|
||||
}
|
||||
|
||||
return edges, nil
|
||||
}
|
||||
|
||||
// DeleteAttackChain 删除对话的攻击链数据
|
||||
func (db *DB) DeleteAttackChain(conversationID string) error {
|
||||
// 先删除边(因为有外键约束)
|
||||
_, err := db.Exec("DELETE FROM attack_chain_edges WHERE conversation_id = ?", conversationID)
|
||||
if err != nil {
|
||||
db.logger.Warn("删除攻击链边失败", zap.Error(err))
|
||||
}
|
||||
|
||||
// 再删除节点
|
||||
_, err = db.Exec("DELETE FROM attack_chain_nodes WHERE conversation_id = ?", conversationID)
|
||||
if err != nil {
|
||||
db.logger.Error("删除攻击链节点失败", zap.Error(err), zap.String("conversationId", conversationID))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// AuditLog platform operation audit record.
|
||||
type AuditLog struct {
|
||||
ID string `json:"id"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
Level string `json:"level"`
|
||||
Category string `json:"category"`
|
||||
Action string `json:"action"`
|
||||
Result string `json:"result"`
|
||||
Actor string `json:"actor"`
|
||||
SessionHint string `json:"sessionHint,omitempty"`
|
||||
ClientIP string `json:"clientIp,omitempty"`
|
||||
UserAgent string `json:"userAgent,omitempty"`
|
||||
ResourceType string `json:"resourceType,omitempty"`
|
||||
ResourceID string `json:"resourceId,omitempty"`
|
||||
ResourceAvailable *bool `json:"resourceAvailable,omitempty"` // API-only: whether linked resource still exists
|
||||
Message string `json:"message"`
|
||||
Detail map[string]interface{} `json:"detail,omitempty"`
|
||||
}
|
||||
|
||||
// ListAuditLogsFilter query parameters.
|
||||
type ListAuditLogsFilter struct {
|
||||
Actor string
|
||||
Level string
|
||||
Category string
|
||||
Action string
|
||||
Result string
|
||||
Query string
|
||||
ResourceType string
|
||||
ResourceID string
|
||||
RelatedUserID string
|
||||
Since *time.Time
|
||||
Until *time.Time
|
||||
Limit int
|
||||
Offset int
|
||||
}
|
||||
|
||||
func buildAuditLogsWhere(filter ListAuditLogsFilter) (string, []interface{}) {
|
||||
conditions := []string{"1=1"}
|
||||
args := []interface{}{}
|
||||
if filter.Actor != "" {
|
||||
conditions = append(conditions, "actor = ?")
|
||||
args = append(args, filter.Actor)
|
||||
}
|
||||
if filter.Level != "" {
|
||||
conditions = append(conditions, "level = ?")
|
||||
args = append(args, filter.Level)
|
||||
}
|
||||
if filter.Category != "" {
|
||||
conditions = append(conditions, "category = ?")
|
||||
args = append(args, filter.Category)
|
||||
}
|
||||
if filter.Action != "" {
|
||||
conditions = append(conditions, "action = ?")
|
||||
args = append(args, filter.Action)
|
||||
}
|
||||
if filter.Result != "" {
|
||||
conditions = append(conditions, "result = ?")
|
||||
args = append(args, filter.Result)
|
||||
}
|
||||
if filter.ResourceType != "" {
|
||||
conditions = append(conditions, "resource_type = ?")
|
||||
args = append(args, filter.ResourceType)
|
||||
}
|
||||
if filter.ResourceID != "" {
|
||||
conditions = append(conditions, "resource_id = ?")
|
||||
args = append(args, filter.ResourceID)
|
||||
}
|
||||
if relatedUserID := strings.TrimSpace(filter.RelatedUserID); relatedUserID != "" {
|
||||
conditions = append(conditions, `(resource_id = ? OR detail_json LIKE ? OR detail_json LIKE ?)`)
|
||||
args = append(args, relatedUserID, `%"user_id":"`+relatedUserID+`"%`, `%"userId":"`+relatedUserID+`"%`)
|
||||
}
|
||||
if filter.Since != nil {
|
||||
conditions = append(conditions, sqliteEpochGE("created_at", ">="))
|
||||
args = append(args, formatSQLiteUTC(*filter.Since))
|
||||
}
|
||||
if filter.Until != nil {
|
||||
conditions = append(conditions, sqliteEpochGE("created_at", "<="))
|
||||
args = append(args, formatSQLiteUTC(*filter.Until))
|
||||
}
|
||||
if q := strings.TrimSpace(filter.Query); q != "" {
|
||||
like := "%" + q + "%"
|
||||
conditions = append(conditions, "(message LIKE ? OR resource_id LIKE ? OR action LIKE ? OR category LIKE ? OR detail_json LIKE ?)")
|
||||
args = append(args, like, like, like, like, like)
|
||||
}
|
||||
return strings.Join(conditions, " AND "), args
|
||||
}
|
||||
|
||||
// AppendAuditLog inserts one audit row.
|
||||
func (db *DB) AppendAuditLog(row *AuditLog) error {
|
||||
if row == nil {
|
||||
return errors.New("audit log is nil")
|
||||
}
|
||||
if strings.TrimSpace(row.ID) == "" {
|
||||
return errors.New("audit id is required")
|
||||
}
|
||||
if row.CreatedAt.IsZero() {
|
||||
row.CreatedAt = time.Now().UTC()
|
||||
} else {
|
||||
row.CreatedAt = row.CreatedAt.UTC()
|
||||
}
|
||||
if strings.TrimSpace(row.Level) == "" {
|
||||
row.Level = "info"
|
||||
}
|
||||
detailJSON := ""
|
||||
if len(row.Detail) > 0 {
|
||||
if b, err := json.Marshal(row.Detail); err == nil {
|
||||
detailJSON = string(b)
|
||||
}
|
||||
}
|
||||
query := `
|
||||
INSERT INTO audit_logs (
|
||||
id, created_at, level, category, action, result, actor, session_hint,
|
||||
client_ip, user_agent, resource_type, resource_id, message, detail_json
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
_, err := db.Exec(query,
|
||||
row.ID, formatSQLiteUTC(row.CreatedAt), row.Level, row.Category, row.Action, row.Result,
|
||||
row.Actor, row.SessionHint, row.ClientIP, row.UserAgent,
|
||||
row.ResourceType, row.ResourceID, row.Message, detailJSON,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// GetAuditLogByID returns one row.
|
||||
func (db *DB) GetAuditLogByID(id string) (*AuditLog, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return nil, errors.New("id is required")
|
||||
}
|
||||
query := `
|
||||
SELECT id, created_at, level, category, action, result, actor,
|
||||
COALESCE(session_hint, ''), COALESCE(client_ip, ''), COALESCE(user_agent, ''),
|
||||
COALESCE(resource_type, ''), COALESCE(resource_id, ''), message, COALESCE(detail_json, '')
|
||||
FROM audit_logs WHERE id = ?
|
||||
`
|
||||
var row AuditLog
|
||||
var detailJSON string
|
||||
err := db.QueryRow(query, id).Scan(
|
||||
&row.ID, &row.CreatedAt, &row.Level, &row.Category, &row.Action, &row.Result, &row.Actor,
|
||||
&row.SessionHint, &row.ClientIP, &row.UserAgent,
|
||||
&row.ResourceType, &row.ResourceID, &row.Message, &detailJSON,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if detailJSON != "" {
|
||||
_ = json.Unmarshal([]byte(detailJSON), &row.Detail)
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// CountAuditLogs counts rows matching filter.
|
||||
func (db *DB) CountAuditLogs(filter ListAuditLogsFilter) (int64, error) {
|
||||
where, args := buildAuditLogsWhere(filter)
|
||||
query := `SELECT COUNT(*) FROM audit_logs WHERE ` + where
|
||||
var n int64
|
||||
err := db.QueryRow(query, args...).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
|
||||
// ListAuditLogs lists audit rows newest first.
|
||||
func (db *DB) ListAuditLogs(filter ListAuditLogsFilter) ([]*AuditLog, error) {
|
||||
where, args := buildAuditLogsWhere(filter)
|
||||
limit := filter.Limit
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 50
|
||||
}
|
||||
offset := filter.Offset
|
||||
if offset < 0 {
|
||||
offset = 0
|
||||
}
|
||||
query := `
|
||||
SELECT id, created_at, level, category, action, result, actor,
|
||||
COALESCE(session_hint, ''), COALESCE(client_ip, ''), COALESCE(user_agent, ''),
|
||||
COALESCE(resource_type, ''), COALESCE(resource_id, ''), message, COALESCE(detail_json, '')
|
||||
FROM audit_logs
|
||||
WHERE ` + where + `
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ? OFFSET ?
|
||||
`
|
||||
args = append(args, limit, offset)
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var list []*AuditLog
|
||||
for rows.Next() {
|
||||
var row AuditLog
|
||||
var detailJSON string
|
||||
if err := rows.Scan(
|
||||
&row.ID, &row.CreatedAt, &row.Level, &row.Category, &row.Action, &row.Result, &row.Actor,
|
||||
&row.SessionHint, &row.ClientIP, &row.UserAgent,
|
||||
&row.ResourceType, &row.ResourceID, &row.Message, &detailJSON,
|
||||
); err != nil {
|
||||
continue
|
||||
}
|
||||
if detailJSON != "" {
|
||||
_ = json.Unmarshal([]byte(detailJSON), &row.Detail)
|
||||
}
|
||||
list = append(list, &row)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
// DeleteAuditLogsBefore removes rows older than cutoff.
|
||||
func (db *DB) DeleteAuditLogsBefore(cutoff time.Time) (int64, error) {
|
||||
res, err := db.Exec(`DELETE FROM audit_logs WHERE `+sqliteEpochGE("created_at", "<"), formatSQLiteUTC(cutoff))
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestBuildAuditLogsWhere_timeFilterSQL(t *testing.T) {
|
||||
since := time.Date(2026, 6, 16, 17, 2, 0, 0, time.UTC)
|
||||
until := time.Date(2026, 6, 17, 3, 3, 0, 0, time.UTC)
|
||||
where, args := buildAuditLogsWhere(ListAuditLogsFilter{Since: &since, Until: &until})
|
||||
if !strings.Contains(where, "strftime('%s', created_at) >=") {
|
||||
t.Fatalf("expected epoch comparison for since, got %q", where)
|
||||
}
|
||||
if !strings.Contains(where, "strftime('%s', created_at) <=") {
|
||||
t.Fatalf("expected epoch comparison for until, got %q", where)
|
||||
}
|
||||
if len(args) != 2 {
|
||||
t.Fatalf("expected 2 time args, got %d", len(args))
|
||||
}
|
||||
for i, arg := range args {
|
||||
s, ok := arg.(string)
|
||||
if !ok || s == "" {
|
||||
t.Fatalf("arg %d: want non-empty UTC RFC3339 string, got %v", i, arg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildAuditLogsWhere_relatedUserID(t *testing.T) {
|
||||
where, args := buildAuditLogsWhere(ListAuditLogsFilter{Category: "rbac", RelatedUserID: "user-123"})
|
||||
if !strings.Contains(where, "resource_id = ?") || !strings.Contains(where, "detail_json LIKE ?") {
|
||||
t.Fatalf("expected related-user predicates, got %q", where)
|
||||
}
|
||||
if len(args) != 4 {
|
||||
t.Fatalf("expected category plus 3 related-user args, got %#v", args)
|
||||
}
|
||||
if args[1] != "user-123" || args[2] != `%"user_id":"user-123"%` || args[3] != `%"userId":"user-123"%` {
|
||||
t.Fatalf("unexpected related-user args: %#v", args)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAuditLogs_timeFilterMixedStorageFormats(t *testing.T) {
|
||||
root, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
dbPath := filepath.Join(root, "..", "..", "data", "conversations.db")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
t.Skip("conversations.db not found")
|
||||
}
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
since, _ := ParseRFC3339Time("2026-06-16T17:02:00Z")
|
||||
until, _ := ParseRFC3339Time("2026-06-17T03:03:00Z")
|
||||
filter := ListAuditLogsFilter{Since: &since, Until: &until, Limit: 50}
|
||||
logs, err := db.ListAuditLogs(filter)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, row := range logs {
|
||||
at := row.CreatedAt.UTC()
|
||||
if at.Before(since) || at.After(until) {
|
||||
t.Fatalf("log %s at %s outside [%s, %s]", row.ID, at, since, until)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,631 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// BatchTaskQueueRow 批量任务队列数据库行
|
||||
type BatchTaskQueueRow struct {
|
||||
ID string
|
||||
Title sql.NullString
|
||||
Role sql.NullString
|
||||
AgentMode sql.NullString
|
||||
ScheduleMode sql.NullString
|
||||
CronExpr sql.NullString
|
||||
NextRunAt sql.NullTime
|
||||
ScheduleEnabled sql.NullInt64
|
||||
LastScheduleTriggerAt sql.NullTime
|
||||
LastScheduleError sql.NullString
|
||||
LastRunError sql.NullString
|
||||
ProjectID sql.NullString
|
||||
Concurrency sql.NullInt64
|
||||
Status string
|
||||
CreatedAt time.Time
|
||||
StartedAt sql.NullTime
|
||||
CompletedAt sql.NullTime
|
||||
CurrentIndex int
|
||||
}
|
||||
|
||||
// BatchTaskRow 批量任务数据库行
|
||||
type BatchTaskRow struct {
|
||||
ID string
|
||||
QueueID string
|
||||
Message string
|
||||
ConversationID sql.NullString
|
||||
Status string
|
||||
StartedAt sql.NullTime
|
||||
CompletedAt sql.NullTime
|
||||
Error sql.NullString
|
||||
Result sql.NullString
|
||||
}
|
||||
|
||||
// CreateBatchQueue 创建批量任务队列
|
||||
func (db *DB) CreateBatchQueue(
|
||||
queueID string,
|
||||
title string,
|
||||
role string,
|
||||
agentMode string,
|
||||
scheduleMode string,
|
||||
cronExpr string,
|
||||
nextRunAt *time.Time,
|
||||
projectID string,
|
||||
concurrency int,
|
||||
tasks []map[string]interface{},
|
||||
) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
now := time.Now()
|
||||
var nextRunAtValue interface{}
|
||||
if nextRunAt != nil {
|
||||
nextRunAtValue = *nextRunAt
|
||||
}
|
||||
|
||||
var projectIDVal interface{}
|
||||
if strings.TrimSpace(projectID) != "" {
|
||||
projectIDVal = strings.TrimSpace(projectID)
|
||||
}
|
||||
_, err = tx.Exec(
|
||||
"INSERT INTO batch_task_queues (id, title, role, agent_mode, schedule_mode, cron_expr, next_run_at, schedule_enabled, project_id, concurrency, status, created_at, current_index) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
|
||||
queueID, title, role, agentMode, scheduleMode, cronExpr, nextRunAtValue, 1, projectIDVal, concurrency, "pending", now, 0,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建批量任务队列失败: %w", err)
|
||||
}
|
||||
|
||||
// 插入任务
|
||||
for _, task := range tasks {
|
||||
taskID, ok := task["id"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
message, ok := task["message"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
_, err = tx.Exec(
|
||||
"INSERT INTO batch_tasks (id, queue_id, message, status) VALUES (?, ?, ?, ?)",
|
||||
taskID, queueID, message, "pending",
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建批量任务失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
const batchQueueSelectColumns = `id, title, role, agent_mode, schedule_mode, cron_expr, next_run_at, schedule_enabled, last_schedule_trigger_at, last_schedule_error, last_run_error, project_id, concurrency, status, created_at, started_at, completed_at, current_index`
|
||||
|
||||
// GetBatchQueue 获取批量任务队列
|
||||
func (db *DB) GetBatchQueue(queueID string) (*BatchTaskQueueRow, error) {
|
||||
var row BatchTaskQueueRow
|
||||
var createdAt string
|
||||
err := db.QueryRow(
|
||||
"SELECT "+batchQueueSelectColumns+" FROM batch_task_queues WHERE id = ?",
|
||||
queueID,
|
||||
).Scan(&row.ID, &row.Title, &row.Role, &row.AgentMode, &row.ScheduleMode, &row.CronExpr, &row.NextRunAt, &row.ScheduleEnabled, &row.LastScheduleTriggerAt, &row.LastScheduleError, &row.LastRunError, &row.ProjectID, &row.Concurrency, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询批量任务队列失败: %w", err)
|
||||
}
|
||||
|
||||
parsedTime, parseErr := time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
if parseErr != nil {
|
||||
// 尝试其他时间格式
|
||||
parsedTime, parseErr = time.Parse(time.RFC3339, createdAt)
|
||||
if parseErr != nil {
|
||||
db.logger.Warn("解析创建时间失败", zap.String("createdAt", createdAt), zap.Error(parseErr))
|
||||
parsedTime = time.Now()
|
||||
}
|
||||
}
|
||||
row.CreatedAt = parsedTime
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
// GetAllBatchQueues 获取所有批量任务队列
|
||||
func (db *DB) GetAllBatchQueues() ([]*BatchTaskQueueRow, error) {
|
||||
rows, err := db.Query(
|
||||
"SELECT " + batchQueueSelectColumns + " FROM batch_task_queues ORDER BY created_at DESC",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询批量任务队列列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var queues []*BatchTaskQueueRow
|
||||
for rows.Next() {
|
||||
var row BatchTaskQueueRow
|
||||
var createdAt string
|
||||
if err := rows.Scan(&row.ID, &row.Title, &row.Role, &row.AgentMode, &row.ScheduleMode, &row.CronExpr, &row.NextRunAt, &row.ScheduleEnabled, &row.LastScheduleTriggerAt, &row.LastScheduleError, &row.LastRunError, &row.ProjectID, &row.Concurrency, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex); err != nil {
|
||||
return nil, fmt.Errorf("扫描批量任务队列失败: %w", err)
|
||||
}
|
||||
parsedTime, parseErr := time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
if parseErr != nil {
|
||||
parsedTime, parseErr = time.Parse(time.RFC3339, createdAt)
|
||||
if parseErr != nil {
|
||||
db.logger.Warn("解析创建时间失败", zap.String("createdAt", createdAt), zap.Error(parseErr))
|
||||
parsedTime = time.Now()
|
||||
}
|
||||
}
|
||||
row.CreatedAt = parsedTime
|
||||
queues = append(queues, &row)
|
||||
}
|
||||
|
||||
return queues, nil
|
||||
}
|
||||
|
||||
// ListBatchQueues 列出批量任务队列(支持筛选和分页)
|
||||
func (db *DB) ListBatchQueues(limit, offset int, status, keyword string) ([]*BatchTaskQueueRow, error) {
|
||||
return db.ListBatchQueuesForAccess(limit, offset, status, keyword, "", "")
|
||||
}
|
||||
|
||||
func (db *DB) ListBatchQueuesForAccess(limit, offset int, status, keyword, userID, scope string) ([]*BatchTaskQueueRow, error) {
|
||||
query := "SELECT " + batchQueueSelectColumns + " FROM batch_task_queues WHERE 1=1"
|
||||
args := []interface{}{}
|
||||
|
||||
// 状态筛选
|
||||
if status != "" && status != "all" {
|
||||
query += " AND status = ?"
|
||||
args = append(args, status)
|
||||
}
|
||||
|
||||
// 关键字搜索(搜索队列ID和标题)
|
||||
if keyword != "" {
|
||||
query += " AND (id LIKE ? OR title LIKE ?)"
|
||||
args = append(args, "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID != "" && scope != RBACScopeAll {
|
||||
query += ` AND (
|
||||
owner_user_id = ?
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM rbac_resource_assignments ra
|
||||
WHERE ra.user_id = ? AND ra.resource_type = 'batch_task' AND ra.resource_id = batch_task_queues.id
|
||||
)
|
||||
OR (
|
||||
project_id IS NOT NULL AND project_id <> '' AND (
|
||||
EXISTS (SELECT 1 FROM projects p WHERE p.id = batch_task_queues.project_id AND p.owner_user_id = ?)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM rbac_resource_assignments pra
|
||||
WHERE pra.user_id = ? AND pra.resource_type = 'project' AND pra.resource_id = batch_task_queues.project_id
|
||||
)
|
||||
)
|
||||
)
|
||||
)`
|
||||
args = append(args, userID, userID, userID, userID)
|
||||
}
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询批量任务队列列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var queues []*BatchTaskQueueRow
|
||||
for rows.Next() {
|
||||
var row BatchTaskQueueRow
|
||||
var createdAt string
|
||||
if err := rows.Scan(&row.ID, &row.Title, &row.Role, &row.AgentMode, &row.ScheduleMode, &row.CronExpr, &row.NextRunAt, &row.ScheduleEnabled, &row.LastScheduleTriggerAt, &row.LastScheduleError, &row.LastRunError, &row.ProjectID, &row.Concurrency, &row.Status, &createdAt, &row.StartedAt, &row.CompletedAt, &row.CurrentIndex); err != nil {
|
||||
return nil, fmt.Errorf("扫描批量任务队列失败: %w", err)
|
||||
}
|
||||
parsedTime, parseErr := time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
if parseErr != nil {
|
||||
parsedTime, parseErr = time.Parse(time.RFC3339, createdAt)
|
||||
if parseErr != nil {
|
||||
db.logger.Warn("解析创建时间失败", zap.String("createdAt", createdAt), zap.Error(parseErr))
|
||||
parsedTime = time.Now()
|
||||
}
|
||||
}
|
||||
row.CreatedAt = parsedTime
|
||||
queues = append(queues, &row)
|
||||
}
|
||||
|
||||
return queues, nil
|
||||
}
|
||||
|
||||
// CountBatchQueues 统计批量任务队列总数(支持筛选条件)
|
||||
func (db *DB) CountBatchQueues(status, keyword string) (int, error) {
|
||||
return db.CountBatchQueuesForAccess(status, keyword, "", "")
|
||||
}
|
||||
|
||||
func (db *DB) CountBatchQueuesForAccess(status, keyword, userID, scope string) (int, error) {
|
||||
query := "SELECT COUNT(*) FROM batch_task_queues WHERE 1=1"
|
||||
args := []interface{}{}
|
||||
|
||||
// 状态筛选
|
||||
if status != "" && status != "all" {
|
||||
query += " AND status = ?"
|
||||
args = append(args, status)
|
||||
}
|
||||
|
||||
// 关键字搜索(搜索队列ID和标题)
|
||||
if keyword != "" {
|
||||
query += " AND (id LIKE ? OR title LIKE ?)"
|
||||
args = append(args, "%"+keyword+"%", "%"+keyword+"%")
|
||||
}
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID != "" && scope != RBACScopeAll {
|
||||
query += ` AND (
|
||||
owner_user_id = ?
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM rbac_resource_assignments ra
|
||||
WHERE ra.user_id = ? AND ra.resource_type = 'batch_task' AND ra.resource_id = batch_task_queues.id
|
||||
)
|
||||
OR (
|
||||
project_id IS NOT NULL AND project_id <> '' AND (
|
||||
EXISTS (SELECT 1 FROM projects p WHERE p.id = batch_task_queues.project_id AND p.owner_user_id = ?)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM rbac_resource_assignments pra
|
||||
WHERE pra.user_id = ? AND pra.resource_type = 'project' AND pra.resource_id = batch_task_queues.project_id
|
||||
)
|
||||
)
|
||||
)
|
||||
)`
|
||||
args = append(args, userID, userID, userID, userID)
|
||||
}
|
||||
|
||||
var count int
|
||||
err := db.QueryRow(query, args...).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("统计批量任务队列总数失败: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// GetBatchTasks 获取批量任务队列的所有任务
|
||||
func (db *DB) GetBatchTasks(queueID string) ([]*BatchTaskRow, error) {
|
||||
rows, err := db.Query(
|
||||
"SELECT id, queue_id, message, conversation_id, status, started_at, completed_at, error, result FROM batch_tasks WHERE queue_id = ? ORDER BY rowid ASC",
|
||||
queueID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询批量任务失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var tasks []*BatchTaskRow
|
||||
for rows.Next() {
|
||||
var task BatchTaskRow
|
||||
if err := rows.Scan(
|
||||
&task.ID, &task.QueueID, &task.Message, &task.ConversationID,
|
||||
&task.Status, &task.StartedAt, &task.CompletedAt, &task.Error, &task.Result,
|
||||
); err != nil {
|
||||
return nil, fmt.Errorf("扫描批量任务失败: %w", err)
|
||||
}
|
||||
tasks = append(tasks, &task)
|
||||
}
|
||||
|
||||
return tasks, nil
|
||||
}
|
||||
|
||||
// UpdateBatchQueueStatus 更新批量任务队列状态
|
||||
func (db *DB) UpdateBatchQueueStatus(queueID, status string) error {
|
||||
var err error
|
||||
now := time.Now()
|
||||
|
||||
if status == "running" {
|
||||
_, err = db.Exec(
|
||||
"UPDATE batch_task_queues SET status = ?, started_at = COALESCE(started_at, ?) WHERE id = ?",
|
||||
status, now, queueID,
|
||||
)
|
||||
} else if status == "completed" || status == "cancelled" {
|
||||
_, err = db.Exec(
|
||||
"UPDATE batch_task_queues SET status = ?, completed_at = COALESCE(completed_at, ?) WHERE id = ?",
|
||||
status, now, queueID,
|
||||
)
|
||||
} else {
|
||||
_, err = db.Exec(
|
||||
"UPDATE batch_task_queues SET status = ? WHERE id = ?",
|
||||
status, queueID,
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新批量任务队列状态失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateBatchTaskStatus 更新批量任务状态
|
||||
func (db *DB) UpdateBatchTaskStatus(queueID, taskID, status string, conversationID, result, errorMsg string) error {
|
||||
var err error
|
||||
now := time.Now()
|
||||
|
||||
// 构建更新语句
|
||||
var updates []string
|
||||
var args []interface{}
|
||||
|
||||
updates = append(updates, "status = ?")
|
||||
args = append(args, status)
|
||||
|
||||
if conversationID != "" {
|
||||
updates = append(updates, "conversation_id = ?")
|
||||
args = append(args, conversationID)
|
||||
}
|
||||
|
||||
if result != "" {
|
||||
updates = append(updates, "result = ?")
|
||||
args = append(args, result)
|
||||
}
|
||||
|
||||
if errorMsg != "" {
|
||||
updates = append(updates, "error = ?")
|
||||
args = append(args, errorMsg)
|
||||
}
|
||||
|
||||
if status == "running" {
|
||||
updates = append(updates, "started_at = COALESCE(started_at, ?)")
|
||||
args = append(args, now)
|
||||
}
|
||||
|
||||
if status == "completed" || status == "failed" || status == "cancelled" {
|
||||
updates = append(updates, "completed_at = COALESCE(completed_at, ?)")
|
||||
args = append(args, now)
|
||||
}
|
||||
|
||||
args = append(args, queueID, taskID)
|
||||
|
||||
// 构建SQL语句
|
||||
sql := "UPDATE batch_tasks SET "
|
||||
for i, update := range updates {
|
||||
if i > 0 {
|
||||
sql += ", "
|
||||
}
|
||||
sql += update
|
||||
}
|
||||
sql += " WHERE queue_id = ? AND id = ?"
|
||||
|
||||
_, err = db.Exec(sql, args...)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新批量任务状态失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateBatchQueueCurrentIndex 更新批量任务队列的当前索引
|
||||
func (db *DB) UpdateBatchQueueCurrentIndex(queueID string, currentIndex int) error {
|
||||
_, err := db.Exec(
|
||||
"UPDATE batch_task_queues SET current_index = ? WHERE id = ?",
|
||||
currentIndex, queueID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新批量任务队列当前索引失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateBatchQueueMetadata 更新批量任务队列标题、角色、代理模式和并发数
|
||||
func (db *DB) UpdateBatchQueueMetadata(queueID, title, role, agentMode string, concurrency int) error {
|
||||
_, err := db.Exec(
|
||||
"UPDATE batch_task_queues SET title = ?, role = ?, agent_mode = ?, concurrency = ? WHERE id = ?",
|
||||
title, role, agentMode, concurrency, queueID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新批量任务队列元数据失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateBatchQueueSchedule 更新批量任务队列调度相关信息
|
||||
func (db *DB) UpdateBatchQueueSchedule(queueID, scheduleMode, cronExpr string, nextRunAt *time.Time) error {
|
||||
var nextRunAtValue interface{}
|
||||
if nextRunAt != nil {
|
||||
nextRunAtValue = *nextRunAt
|
||||
}
|
||||
_, err := db.Exec(
|
||||
"UPDATE batch_task_queues SET schedule_mode = ?, cron_expr = ?, next_run_at = ? WHERE id = ?",
|
||||
scheduleMode, cronExpr, nextRunAtValue, queueID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新批量任务调度配置失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateBatchQueueScheduleEnabled 是否允许 Cron 自动触发(手工「开始执行」不受影响)
|
||||
func (db *DB) UpdateBatchQueueScheduleEnabled(queueID string, enabled bool) error {
|
||||
v := 0
|
||||
if enabled {
|
||||
v = 1
|
||||
}
|
||||
_, err := db.Exec(
|
||||
"UPDATE batch_task_queues SET schedule_enabled = ? WHERE id = ?",
|
||||
v, queueID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新批量任务调度开关失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordBatchQueueScheduledTriggerStart 记录一次由调度触发的开始时间并清空调度层错误
|
||||
func (db *DB) RecordBatchQueueScheduledTriggerStart(queueID string, at time.Time) error {
|
||||
_, err := db.Exec(
|
||||
"UPDATE batch_task_queues SET last_schedule_trigger_at = ?, last_schedule_error = NULL WHERE id = ?",
|
||||
at, queueID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("记录调度触发时间失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetBatchQueueLastScheduleError 调度启动失败等原因(如状态不允许、重置失败)
|
||||
func (db *DB) SetBatchQueueLastScheduleError(queueID, msg string) error {
|
||||
_, err := db.Exec(
|
||||
"UPDATE batch_task_queues SET last_schedule_error = ? WHERE id = ?",
|
||||
msg, queueID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入调度错误信息失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetBatchQueueLastRunError 最近一轮执行中出现的子任务失败摘要(空串表示清空)
|
||||
func (db *DB) SetBatchQueueLastRunError(queueID, msg string) error {
|
||||
var v interface{}
|
||||
if strings.TrimSpace(msg) == "" {
|
||||
v = nil
|
||||
} else {
|
||||
v = msg
|
||||
}
|
||||
_, err := db.Exec(
|
||||
"UPDATE batch_task_queues SET last_run_error = ? WHERE id = ?",
|
||||
v, queueID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入最近运行错误失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ResetBatchQueueForRerun 重置队列和任务状态用于下一轮调度执行
|
||||
func (db *DB) ResetBatchQueueForRerun(queueID string) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
_, err = tx.Exec(
|
||||
"UPDATE batch_task_queues SET status = ?, current_index = 0, started_at = NULL, completed_at = NULL, last_run_error = NULL, last_schedule_error = NULL WHERE id = ?",
|
||||
"pending", queueID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("重置批量任务队列状态失败: %w", err)
|
||||
}
|
||||
|
||||
_, err = tx.Exec(
|
||||
"UPDATE batch_tasks SET status = ?, conversation_id = NULL, started_at = NULL, completed_at = NULL, error = NULL, result = NULL WHERE queue_id = ?",
|
||||
"pending", queueID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("重置批量任务状态失败: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// UpdateBatchTaskMessage 更新批量任务消息
|
||||
func (db *DB) UpdateBatchTaskMessage(queueID, taskID, message string) error {
|
||||
_, err := db.Exec(
|
||||
"UPDATE batch_tasks SET message = ? WHERE queue_id = ? AND id = ?",
|
||||
message, queueID, taskID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新批量任务消息失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddBatchTask 添加任务到批量任务队列
|
||||
func (db *DB) AddBatchTask(queueID, taskID, message string) error {
|
||||
_, err := db.Exec(
|
||||
"INSERT INTO batch_tasks (id, queue_id, message, status) VALUES (?, ?, ?, ?)",
|
||||
taskID, queueID, message, "pending",
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("添加批量任务失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CancelPendingBatchTasks 批量取消队列中所有 pending 状态的任务(单条 SQL)
|
||||
func (db *DB) CancelPendingBatchTasks(queueID string, completedAt time.Time) error {
|
||||
_, err := db.Exec(
|
||||
"UPDATE batch_tasks SET status = ?, completed_at = ? WHERE queue_id = ? AND status = ?",
|
||||
"cancelled", completedAt, queueID, "pending",
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("批量取消 pending 任务失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// PrepareBatchSingleTaskRun 准备单条执行:可选重置子任务,并更新队列索引与状态
|
||||
func (db *DB) PrepareBatchSingleTaskRun(queueID, taskID string, taskIndex int, resetTask, resumeQueue bool) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
if resetTask {
|
||||
_, err = tx.Exec(
|
||||
"UPDATE batch_tasks SET status = ?, conversation_id = NULL, started_at = NULL, completed_at = NULL, error = NULL, result = NULL WHERE queue_id = ? AND id = ?",
|
||||
"pending", queueID, taskID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("重置批量任务状态失败: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if resumeQueue {
|
||||
_, err = tx.Exec(
|
||||
"UPDATE batch_task_queues SET status = ?, current_index = ?, completed_at = NULL, last_run_error = NULL WHERE id = ?",
|
||||
"paused", taskIndex, queueID,
|
||||
)
|
||||
} else {
|
||||
_, err = tx.Exec(
|
||||
"UPDATE batch_task_queues SET current_index = ?, last_run_error = NULL WHERE id = ?",
|
||||
taskIndex, queueID,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新批量任务队列状态失败: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// DeleteBatchTask 删除批量任务
|
||||
func (db *DB) DeleteBatchTask(queueID, taskID string) error {
|
||||
_, err := db.Exec(
|
||||
"DELETE FROM batch_tasks WHERE queue_id = ? AND id = ?",
|
||||
queueID, taskID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除批量任务失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteBatchQueue 删除批量任务队列
|
||||
func (db *DB) DeleteBatchQueue(queueID string) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开始事务失败: %w", err)
|
||||
}
|
||||
defer tx.Rollback()
|
||||
|
||||
// 删除任务(外键会自动级联删除)
|
||||
_, err = tx.Exec("DELETE FROM batch_tasks WHERE queue_id = ?", queueID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除批量任务失败: %w", err)
|
||||
}
|
||||
|
||||
// 删除队列
|
||||
_, err = tx.Exec("DELETE FROM batch_task_queues WHERE id = ?", queueID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除批量任务队列失败: %w", err)
|
||||
}
|
||||
|
||||
return tx.Commit()
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (db *DB) RecordC2PayloadArtifact(filename, payloadID, listenerID, ownerUserID string) error {
|
||||
filename = strings.TrimSpace(filename)
|
||||
if filename == "" || strings.TrimSpace(listenerID) == "" || strings.TrimSpace(ownerUserID) == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO c2_payload_artifacts(filename, payload_id, listener_id, owner_user_id, created_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(filename) DO UPDATE SET payload_id=excluded.payload_id, listener_id=excluded.listener_id, owner_user_id=excluded.owner_user_id, created_at=excluded.created_at
|
||||
`, filename, payloadID, listenerID, ownerUserID, time.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) UserCanAccessC2Payload(userID, scope, filename string) bool {
|
||||
if scope == RBACScopeAll {
|
||||
return true
|
||||
}
|
||||
var listenerID, ownerUserID string
|
||||
if err := db.QueryRow(`SELECT listener_id, owner_user_id FROM c2_payload_artifacts WHERE filename = ?`, strings.TrimSpace(filename)).Scan(&listenerID, &ownerUserID); err != nil {
|
||||
return false
|
||||
}
|
||||
return ownerUserID == strings.TrimSpace(userID) || db.UserCanAccessResource(userID, scope, "c2_listener", listenerID)
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (db *DB) UpsertChatUploadArtifact(relativePath, conversationID, ownerUserID string) error {
|
||||
relativePath = strings.TrimSpace(relativePath)
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
ownerUserID = strings.TrimSpace(ownerUserID)
|
||||
if relativePath == "" || conversationID == "" || ownerUserID == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO chat_upload_artifacts(relative_path, conversation_id, owner_user_id, created_at)
|
||||
VALUES (?, ?, ?, ?)
|
||||
ON CONFLICT(relative_path) DO UPDATE SET conversation_id=excluded.conversation_id, owner_user_id=excluded.owner_user_id
|
||||
`, relativePath, conversationID, ownerUserID, time.Now())
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) GetChatUploadArtifact(relativePath string) (conversationID, ownerUserID string, ok bool) {
|
||||
err := db.QueryRow(`SELECT conversation_id, owner_user_id FROM chat_upload_artifacts WHERE relative_path = ?`, strings.TrimSpace(relativePath)).Scan(&conversationID, &ownerUserID)
|
||||
return conversationID, ownerUserID, err == nil
|
||||
}
|
||||
|
||||
func (db *DB) DeleteChatUploadArtifactPath(relativePath string) error {
|
||||
path := strings.Trim(strings.TrimSpace(relativePath), "/")
|
||||
if path == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := db.Exec(`DELETE FROM chat_upload_artifacts WHERE relative_path = ? OR relative_path LIKE ? ESCAPE '\'`, path, escapeLikePrefix(path)+"/%")
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) RenameChatUploadArtifactPath(oldPath, newPath string) error {
|
||||
oldPath = strings.Trim(strings.TrimSpace(oldPath), "/")
|
||||
newPath = strings.Trim(strings.TrimSpace(newPath), "/")
|
||||
if oldPath == "" || newPath == "" {
|
||||
return nil
|
||||
}
|
||||
_, err := db.Exec(`
|
||||
UPDATE chat_upload_artifacts
|
||||
SET relative_path = CASE
|
||||
WHEN relative_path = ? THEN ?
|
||||
ELSE ? || substr(relative_path, length(?) + 1)
|
||||
END
|
||||
WHERE relative_path = ? OR relative_path LIKE ? ESCAPE '\'
|
||||
`, oldPath, newPath, newPath, oldPath, oldPath, escapeLikePrefix(oldPath)+"/%")
|
||||
return err
|
||||
}
|
||||
|
||||
func escapeLikePrefix(value string) string {
|
||||
return strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(value)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,108 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestDeleteConversationRemovesEinoScopedDirs(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
dbPath := filepath.Join(tmp, "conversations.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
plantaskBase := filepath.Join(tmp, "skills", ".eino", "plantask")
|
||||
checkpointBase := filepath.Join(tmp, "eino-checkpoints")
|
||||
reductionBase := filepath.Join(tmp, "reduction")
|
||||
workspaceBase := filepath.Join(tmp, "workspace")
|
||||
db.SetEinoConversationDirs(plantaskBase, checkpointBase, reductionBase, workspaceBase)
|
||||
|
||||
conv, err := db.CreateConversation("cleanup test", ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation: %v", err)
|
||||
}
|
||||
convID := conv.ID
|
||||
seg := sanitizeConversationPathSegment(convID)
|
||||
for _, base := range []struct {
|
||||
root string
|
||||
file string
|
||||
}{
|
||||
{db.conversationArtifactsDir, "transcript.txt"},
|
||||
{plantaskBase, "task-1.json"},
|
||||
{checkpointBase, "runner-deep.ckpt"},
|
||||
{filepath.Join(reductionBase, "conversations"), "tool-output.txt"},
|
||||
{filepath.Join(workspaceBase, "conversations"), "page.html"},
|
||||
} {
|
||||
dir := filepath.Join(base.root, seg)
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", dir, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, base.file), []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write %s: %v", base.file, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := db.DeleteConversation(convID); err != nil {
|
||||
t.Fatalf("DeleteConversation: %v", err)
|
||||
}
|
||||
|
||||
for _, base := range []string{db.conversationArtifactsDir, plantaskBase, checkpointBase, filepath.Join(reductionBase, "conversations"), filepath.Join(workspaceBase, "conversations")} {
|
||||
dir := filepath.Join(base, seg)
|
||||
if _, statErr := os.Stat(dir); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("expected removed dir %s, stat err=%v", dir, statErr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteProjectRemovesReductionDir(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
dbPath := filepath.Join(tmp, "conversations.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
reductionBase := filepath.Join(tmp, "reduction")
|
||||
workspaceBase := filepath.Join(tmp, "workspace")
|
||||
db.SetEinoConversationDirs("", "", reductionBase, workspaceBase)
|
||||
|
||||
project, err := db.CreateProject(&Project{Name: "cleanup test"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProject: %v", err)
|
||||
}
|
||||
seg := sanitizeConversationPathSegment(project.ID)
|
||||
reductionDir := filepath.Join(reductionBase, "projects", seg, "clear")
|
||||
if err := os.MkdirAll(reductionDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", reductionDir, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(reductionDir, "call-1.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write: %v", err)
|
||||
}
|
||||
workspaceDir := filepath.Join(workspaceBase, "projects", seg, "downloads")
|
||||
if err := os.MkdirAll(workspaceDir, 0o755); err != nil {
|
||||
t.Fatalf("mkdir %s: %v", workspaceDir, err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(workspaceDir, "app.js"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatalf("write workspace: %v", err)
|
||||
}
|
||||
|
||||
if err := db.DeleteProject(project.ID); err != nil {
|
||||
t.Fatalf("DeleteProject: %v", err)
|
||||
}
|
||||
|
||||
projectReductionDir := filepath.Join(reductionBase, "projects", seg)
|
||||
if _, statErr := os.Stat(projectReductionDir); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("expected removed dir %s, stat err=%v", projectReductionDir, statErr)
|
||||
}
|
||||
projectWorkspaceDir := filepath.Join(workspaceBase, "projects", seg)
|
||||
if _, statErr := os.Stat(projectWorkspaceDir); !os.IsNotExist(statErr) {
|
||||
t.Fatalf("expected removed dir %s, stat err=%v", projectWorkspaceDir, statErr)
|
||||
}
|
||||
}
|
||||
@@ -1,32 +0,0 @@
|
||||
package database
|
||||
|
||||
// ConversationCreateMeta describes how a conversation was created (for audit hooks).
|
||||
type ConversationCreateMeta struct {
|
||||
Source string
|
||||
WebShellConnectionID string
|
||||
ProjectID string
|
||||
RoleName string
|
||||
AgentMode string
|
||||
ClientIP string
|
||||
SessionHint string
|
||||
}
|
||||
|
||||
// ConversationCreateHook is invoked after a conversation row is inserted.
|
||||
type ConversationCreateHook func(conv *Conversation, meta ConversationCreateMeta)
|
||||
|
||||
var conversationCreateHook ConversationCreateHook
|
||||
|
||||
// SetConversationCreateHook registers a global hook (e.g. platform audit).
|
||||
func SetConversationCreateHook(h ConversationCreateHook) {
|
||||
conversationCreateHook = h
|
||||
}
|
||||
|
||||
func notifyConversationCreated(conv *Conversation, meta ConversationCreateMeta) {
|
||||
if conversationCreateHook == nil || conv == nil {
|
||||
return
|
||||
}
|
||||
if meta.Source == "" {
|
||||
meta.Source = "unknown"
|
||||
}
|
||||
conversationCreateHook(conv, meta)
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestConversationProjectFilter(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
dbPath := filepath.Join(tmp, "conversations.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
p, err := db.CreateProject(&Project{Name: "target-a", Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateProject: %v", err)
|
||||
}
|
||||
|
||||
convNone, err := db.CreateConversation("unbound", ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation unbound: %v", err)
|
||||
}
|
||||
convBound, err := db.CreateConversation("bound", ConversationCreateMeta{ProjectID: p.ID})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation bound: %v", err)
|
||||
}
|
||||
|
||||
totalAll, err := db.CountConversations("", "")
|
||||
if err != nil || totalAll < 2 {
|
||||
t.Fatalf("CountConversations all: total=%d err=%v", totalAll, err)
|
||||
}
|
||||
|
||||
totalBound, err := db.CountConversations("", p.ID)
|
||||
if err != nil || totalBound != 1 {
|
||||
t.Fatalf("CountConversations project: total=%d err=%v", totalBound, err)
|
||||
}
|
||||
|
||||
totalUnbound, err := db.CountConversations("", ProjectFilterUnbound)
|
||||
if err != nil || totalUnbound != 1 {
|
||||
t.Fatalf("CountConversations unbound: total=%d err=%v", totalUnbound, err)
|
||||
}
|
||||
|
||||
listBound, err := db.ListConversations(10, 0, "", "", p.ID)
|
||||
if err != nil || len(listBound) != 1 || listBound[0].ID != convBound.ID {
|
||||
t.Fatalf("ListConversations project: %+v err=%v", listBound, err)
|
||||
}
|
||||
|
||||
listUnbound, err := db.ListConversations(10, 0, "", "", ProjectFilterUnbound)
|
||||
if err != nil || len(listUnbound) != 1 || listUnbound[0].ID != convNone.ID {
|
||||
t.Fatalf("ListConversations unbound: %+v err=%v", listUnbound, err)
|
||||
}
|
||||
|
||||
_ = convNone
|
||||
_ = convBound
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestTurnSliceRange(t *testing.T) {
|
||||
mk := func(id, role string) Message {
|
||||
return Message{ID: id, Role: role}
|
||||
}
|
||||
msgs := []Message{
|
||||
mk("u1", "user"),
|
||||
mk("a1", "assistant"),
|
||||
mk("u2", "user"),
|
||||
mk("a2", "assistant"),
|
||||
}
|
||||
cases := []struct {
|
||||
anchor string
|
||||
start int
|
||||
end int
|
||||
}{
|
||||
{"u1", 0, 2},
|
||||
{"a1", 0, 2},
|
||||
{"u2", 2, 4},
|
||||
{"a2", 2, 4},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
s, e, err := turnSliceRange(msgs, tc.anchor)
|
||||
if err != nil {
|
||||
t.Fatalf("anchor %s: %v", tc.anchor, err)
|
||||
}
|
||||
if s != tc.start || e != tc.end {
|
||||
t.Fatalf("anchor %s: got [%d,%d) want [%d,%d)", tc.anchor, s, e, tc.start, tc.end)
|
||||
}
|
||||
}
|
||||
if _, _, err := turnSliceRange(msgs, "nope"); err == nil {
|
||||
t.Fatal("expected error for missing id")
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestDeleteConversationPreservesVulnerabilities(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
dbPath := filepath.Join(tmp, "vuln-preserve.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
conv, err := db.CreateConversation("vuln source chat", ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation: %v", err)
|
||||
}
|
||||
|
||||
vuln, err := db.CreateVulnerability(&Vulnerability{
|
||||
ConversationID: conv.ID,
|
||||
Title: "SQL Injection",
|
||||
Severity: "high",
|
||||
Status: "open",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateVulnerability: %v", err)
|
||||
}
|
||||
|
||||
if err := db.DeleteConversation(conv.ID); err != nil {
|
||||
t.Fatalf("DeleteConversation: %v", err)
|
||||
}
|
||||
|
||||
got, err := db.GetVulnerability(vuln.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetVulnerability after delete: %v", err)
|
||||
}
|
||||
if got.Title != "SQL Injection" {
|
||||
t.Fatalf("title = %q, want SQL Injection", got.Title)
|
||||
}
|
||||
if got.ConversationID != "" {
|
||||
t.Fatalf("conversation_id = %q, want empty after conversation delete", got.ConversationID)
|
||||
}
|
||||
if got.ConversationTag != "vuln source chat" {
|
||||
t.Fatalf("conversation_tag = %q, want vuln source chat", got.ConversationTag)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateVulnerabilitiesConversationFK(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
dbPath := filepath.Join(tmp, "vuln-fk-migrate.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
ok, err := vulnerabilitiesConversationFKOnDeleteSetNull(db.DB)
|
||||
if err != nil {
|
||||
t.Fatalf("vulnerabilitiesConversationFKOnDeleteSetNull: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("expected vulnerabilities.conversation_id FK to use ON DELETE SET NULL")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,486 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ConversationGroup 对话分组
|
||||
type ConversationGroup struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Icon string `json:"icon"`
|
||||
Pinned bool `json:"pinned"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
OwnerUserID string `json:"-"`
|
||||
}
|
||||
|
||||
// GroupExistsByName 检查分组名称是否已存在
|
||||
func (db *DB) GroupExistsByName(name string, excludeID string) (bool, error) {
|
||||
return db.groupExistsByNameForOwner(name, excludeID, "")
|
||||
}
|
||||
|
||||
func (db *DB) groupExistsByNameForOwner(name, excludeID, ownerUserID string) (bool, error) {
|
||||
var count int
|
||||
var err error
|
||||
if ownerUserID != "" && excludeID != "" {
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM conversation_groups WHERE name = ? AND owner_user_id = ? AND id != ?", name, ownerUserID, excludeID).Scan(&count)
|
||||
} else if ownerUserID != "" {
|
||||
err = db.QueryRow("SELECT COUNT(*) FROM conversation_groups WHERE name = ? AND owner_user_id = ?", name, ownerUserID).Scan(&count)
|
||||
} else if excludeID != "" {
|
||||
err = db.QueryRow(
|
||||
"SELECT COUNT(*) FROM conversation_groups WHERE name = ? AND id != ?",
|
||||
name, excludeID,
|
||||
).Scan(&count)
|
||||
} else {
|
||||
err = db.QueryRow(
|
||||
"SELECT COUNT(*) FROM conversation_groups WHERE name = ?",
|
||||
name,
|
||||
).Scan(&count)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("检查分组名称失败: %w", err)
|
||||
}
|
||||
|
||||
return count > 0, nil
|
||||
}
|
||||
|
||||
// CreateGroup 创建分组
|
||||
func (db *DB) CreateGroup(name, icon string, owners ...string) (*ConversationGroup, error) {
|
||||
ownerUserID := ""
|
||||
if len(owners) > 0 {
|
||||
ownerUserID = owners[0]
|
||||
}
|
||||
// 检查名称是否已存在
|
||||
exists, err := db.groupExistsByNameForOwner(name, "", ownerUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if exists {
|
||||
return nil, fmt.Errorf("分组名称已存在")
|
||||
}
|
||||
|
||||
id := uuid.New().String()
|
||||
now := time.Now()
|
||||
|
||||
if icon == "" {
|
||||
icon = "📁"
|
||||
}
|
||||
|
||||
_, err = db.Exec(
|
||||
"INSERT INTO conversation_groups (id, name, icon, pinned, owner_user_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)",
|
||||
id, name, icon, 0, ownerUserID, now, now,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建分组失败: %w", err)
|
||||
}
|
||||
|
||||
return &ConversationGroup{
|
||||
ID: id,
|
||||
Name: name,
|
||||
Icon: icon,
|
||||
Pinned: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
OwnerUserID: ownerUserID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ListGroups 列出所有分组
|
||||
func (db *DB) ListGroups() ([]*ConversationGroup, error) {
|
||||
return db.ListGroupsForAccess("", RBACScopeAll)
|
||||
}
|
||||
|
||||
func (db *DB) ListGroupsForAccess(userID, scope string) ([]*ConversationGroup, error) {
|
||||
query := "SELECT id, name, icon, COALESCE(pinned, 0), COALESCE(owner_user_id, ''), created_at, updated_at FROM conversation_groups"
|
||||
args := []interface{}{}
|
||||
if scope != RBACScopeAll {
|
||||
query += " WHERE owner_user_id = ?"
|
||||
args = append(args, userID)
|
||||
}
|
||||
query += " ORDER BY COALESCE(pinned, 0) DESC, created_at ASC"
|
||||
rows, err := db.Query(
|
||||
query, args...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询分组列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var groups []*ConversationGroup
|
||||
for rows.Next() {
|
||||
var group ConversationGroup
|
||||
var createdAt, updatedAt string
|
||||
var pinned int
|
||||
|
||||
if err := rows.Scan(&group.ID, &group.Name, &group.Icon, &pinned, &group.OwnerUserID, &createdAt, &updatedAt); err != nil {
|
||||
return nil, fmt.Errorf("扫描分组失败: %w", err)
|
||||
}
|
||||
|
||||
group.Pinned = pinned != 0
|
||||
|
||||
// 尝试多种时间格式解析
|
||||
var err1, err2 error
|
||||
group.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
|
||||
if err1 != nil {
|
||||
group.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
}
|
||||
if err1 != nil {
|
||||
group.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
}
|
||||
|
||||
group.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt)
|
||||
if err2 != nil {
|
||||
group.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
}
|
||||
if err2 != nil {
|
||||
group.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||
}
|
||||
|
||||
groups = append(groups, &group)
|
||||
}
|
||||
|
||||
return groups, nil
|
||||
}
|
||||
|
||||
// GetGroup 获取分组
|
||||
func (db *DB) GetGroup(id string) (*ConversationGroup, error) {
|
||||
var group ConversationGroup
|
||||
var createdAt, updatedAt string
|
||||
var pinned int
|
||||
|
||||
err := db.QueryRow(
|
||||
"SELECT id, name, icon, COALESCE(pinned, 0), COALESCE(owner_user_id, ''), created_at, updated_at FROM conversation_groups WHERE id = ?",
|
||||
id,
|
||||
).Scan(&group.ID, &group.Name, &group.Icon, &pinned, &group.OwnerUserID, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("分组不存在")
|
||||
}
|
||||
return nil, fmt.Errorf("查询分组失败: %w", err)
|
||||
}
|
||||
|
||||
// 尝试多种时间格式解析
|
||||
var err1, err2 error
|
||||
group.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
|
||||
if err1 != nil {
|
||||
group.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
}
|
||||
if err1 != nil {
|
||||
group.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
}
|
||||
|
||||
group.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt)
|
||||
if err2 != nil {
|
||||
group.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
}
|
||||
if err2 != nil {
|
||||
group.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||
}
|
||||
|
||||
group.Pinned = pinned != 0
|
||||
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func (db *DB) UserCanAccessGroup(userID, scope, groupID string) bool {
|
||||
if scope == RBACScopeAll {
|
||||
return true
|
||||
}
|
||||
var count int
|
||||
err := db.QueryRow(`SELECT COUNT(*) FROM conversation_groups WHERE id = ? AND owner_user_id = ?`, groupID, userID).Scan(&count)
|
||||
return err == nil && count > 0
|
||||
}
|
||||
|
||||
// UpdateGroup 更新分组
|
||||
func (db *DB) UpdateGroup(id, name, icon string) error {
|
||||
existing, err := db.GetGroup(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 检查名称是否已存在(排除当前分组)
|
||||
exists, err := db.groupExistsByNameForOwner(name, id, existing.OwnerUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exists {
|
||||
return fmt.Errorf("分组名称已存在")
|
||||
}
|
||||
|
||||
_, err = db.Exec(
|
||||
"UPDATE conversation_groups SET name = ?, icon = ?, updated_at = ? WHERE id = ?",
|
||||
name, icon, time.Now(), id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新分组失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteGroup 删除分组
|
||||
func (db *DB) DeleteGroup(id string) error {
|
||||
_, err := db.Exec("DELETE FROM conversation_groups WHERE id = ?", id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除分组失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddConversationToGroup 将对话添加到分组
|
||||
// 注意:一个对话只能属于一个分组,所以在添加新分组之前,会先删除该对话的所有旧分组关联
|
||||
func (db *DB) AddConversationToGroup(conversationID, groupID string) error {
|
||||
// 先删除该对话的所有旧分组关联,确保一个对话只属于一个分组
|
||||
_, err := db.Exec(
|
||||
"DELETE FROM conversation_group_mappings WHERE conversation_id = ?",
|
||||
conversationID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除对话旧分组关联失败: %w", err)
|
||||
}
|
||||
|
||||
// 然后插入新的分组关联
|
||||
id := uuid.New().String()
|
||||
_, err = db.Exec(
|
||||
"INSERT INTO conversation_group_mappings (id, conversation_id, group_id, created_at) VALUES (?, ?, ?, ?)",
|
||||
id, conversationID, groupID, time.Now(),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("添加对话到分组失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveConversationFromGroup 从分组中移除对话
|
||||
func (db *DB) RemoveConversationFromGroup(conversationID, groupID string) error {
|
||||
_, err := db.Exec(
|
||||
"DELETE FROM conversation_group_mappings WHERE conversation_id = ? AND group_id = ?",
|
||||
conversationID, groupID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("从分组中移除对话失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetConversationsByGroup 获取分组中的所有对话
|
||||
func (db *DB) GetConversationsByGroup(groupID string) ([]*Conversation, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, COALESCE(cgm.pinned, 0) as group_pinned
|
||||
FROM conversations c
|
||||
INNER JOIN conversation_group_mappings cgm ON c.id = cgm.conversation_id
|
||||
WHERE cgm.group_id = ?
|
||||
ORDER BY COALESCE(cgm.pinned, 0) DESC, c.updated_at DESC`,
|
||||
groupID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询分组对话失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var conversations []*Conversation
|
||||
for rows.Next() {
|
||||
var conv Conversation
|
||||
var createdAt, updatedAt string
|
||||
var pinned int
|
||||
var groupPinned int
|
||||
|
||||
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &groupPinned); err != nil {
|
||||
return nil, fmt.Errorf("扫描对话失败: %w", err)
|
||||
}
|
||||
|
||||
// 尝试多种时间格式解析
|
||||
var err1, err2 error
|
||||
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
|
||||
if err1 != nil {
|
||||
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
}
|
||||
if err1 != nil {
|
||||
conv.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
}
|
||||
|
||||
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt)
|
||||
if err2 != nil {
|
||||
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
}
|
||||
if err2 != nil {
|
||||
conv.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||
}
|
||||
|
||||
conv.Pinned = pinned != 0
|
||||
|
||||
conversations = append(conversations, &conv)
|
||||
}
|
||||
|
||||
return conversations, nil
|
||||
}
|
||||
|
||||
// SearchConversationsByGroup 搜索分组中的对话(按标题和消息内容模糊匹配)
|
||||
func (db *DB) SearchConversationsByGroup(groupID string, searchQuery string) ([]*Conversation, error) {
|
||||
// 构建SQL查询,支持按标题和消息内容搜索
|
||||
// 使用 DISTINCT 避免因为一个对话有多条匹配消息而重复
|
||||
query := `SELECT DISTINCT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, COALESCE(cgm.pinned, 0) as group_pinned
|
||||
FROM conversations c
|
||||
INNER JOIN conversation_group_mappings cgm ON c.id = cgm.conversation_id
|
||||
WHERE cgm.group_id = ?`
|
||||
|
||||
args := []interface{}{groupID}
|
||||
|
||||
// 如果有搜索关键词,添加标题和消息内容搜索条件
|
||||
if searchQuery != "" {
|
||||
searchPattern := "%" + searchQuery + "%"
|
||||
// 搜索标题或消息内容
|
||||
// 使用 LEFT JOIN 连接消息表,这样即使没有消息的对话也能被搜索到(通过标题)
|
||||
query += ` AND (
|
||||
LOWER(c.title) LIKE LOWER(?)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM messages m
|
||||
WHERE m.conversation_id = c.id
|
||||
AND LOWER(m.content) LIKE LOWER(?)
|
||||
)
|
||||
)`
|
||||
args = append(args, searchPattern, searchPattern)
|
||||
}
|
||||
|
||||
query += " ORDER BY COALESCE(cgm.pinned, 0) DESC, c.updated_at DESC"
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("搜索分组对话失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var conversations []*Conversation
|
||||
for rows.Next() {
|
||||
var conv Conversation
|
||||
var createdAt, updatedAt string
|
||||
var pinned int
|
||||
var groupPinned int
|
||||
|
||||
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &groupPinned); err != nil {
|
||||
return nil, fmt.Errorf("扫描对话失败: %w", err)
|
||||
}
|
||||
|
||||
// 尝试多种时间格式解析
|
||||
var err1, err2 error
|
||||
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
|
||||
if err1 != nil {
|
||||
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||
}
|
||||
if err1 != nil {
|
||||
conv.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||
}
|
||||
|
||||
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt)
|
||||
if err2 != nil {
|
||||
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||
}
|
||||
if err2 != nil {
|
||||
conv.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||
}
|
||||
|
||||
conv.Pinned = pinned != 0
|
||||
|
||||
conversations = append(conversations, &conv)
|
||||
}
|
||||
|
||||
return conversations, nil
|
||||
}
|
||||
|
||||
// GetGroupByConversation 获取对话所属的分组
|
||||
func (db *DB) GetGroupByConversation(conversationID string) (string, error) {
|
||||
var groupID string
|
||||
err := db.QueryRow(
|
||||
"SELECT group_id FROM conversation_group_mappings WHERE conversation_id = ? LIMIT 1",
|
||||
conversationID,
|
||||
).Scan(&groupID)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return "", nil // 没有分组
|
||||
}
|
||||
return "", fmt.Errorf("查询对话分组失败: %w", err)
|
||||
}
|
||||
return groupID, nil
|
||||
}
|
||||
|
||||
// UpdateConversationPinned 更新对话置顶状态
|
||||
func (db *DB) UpdateConversationPinned(id string, pinned bool) error {
|
||||
pinnedValue := 0
|
||||
if pinned {
|
||||
pinnedValue = 1
|
||||
}
|
||||
// 注意:不更新 updated_at,因为置顶操作不应该改变对话的更新时间
|
||||
_, err := db.Exec(
|
||||
"UPDATE conversations SET pinned = ? WHERE id = ?",
|
||||
pinnedValue, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新对话置顶状态失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateGroupPinned 更新分组置顶状态
|
||||
func (db *DB) UpdateGroupPinned(id string, pinned bool) error {
|
||||
pinnedValue := 0
|
||||
if pinned {
|
||||
pinnedValue = 1
|
||||
}
|
||||
_, err := db.Exec(
|
||||
"UPDATE conversation_groups SET pinned = ?, updated_at = ? WHERE id = ?",
|
||||
pinnedValue, time.Now(), id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新分组置顶状态失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GroupMapping 分组映射关系
|
||||
type GroupMapping struct {
|
||||
ConversationID string `json:"conversationId"`
|
||||
GroupID string `json:"groupId"`
|
||||
}
|
||||
|
||||
// GetAllGroupMappings 批量获取所有分组映射(消除 N+1 查询)
|
||||
func (db *DB) GetAllGroupMappings() ([]GroupMapping, error) {
|
||||
rows, err := db.Query("SELECT conversation_id, group_id FROM conversation_group_mappings")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询分组映射失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var mappings []GroupMapping
|
||||
for rows.Next() {
|
||||
var m GroupMapping
|
||||
if err := rows.Scan(&m.ConversationID, &m.GroupID); err != nil {
|
||||
return nil, fmt.Errorf("扫描分组映射失败: %w", err)
|
||||
}
|
||||
mappings = append(mappings, m)
|
||||
}
|
||||
|
||||
if mappings == nil {
|
||||
mappings = []GroupMapping{}
|
||||
}
|
||||
return mappings, nil
|
||||
}
|
||||
|
||||
// UpdateConversationPinnedInGroup 更新对话在分组中的置顶状态
|
||||
func (db *DB) UpdateConversationPinnedInGroup(conversationID, groupID string, pinned bool) error {
|
||||
pinnedValue := 0
|
||||
if pinned {
|
||||
pinnedValue = 1
|
||||
}
|
||||
_, err := db.Exec(
|
||||
"UPDATE conversation_group_mappings SET pinned = ? WHERE conversation_id = ? AND group_id = ?",
|
||||
pinnedValue, conversationID, groupID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新分组对话置顶状态失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// DeleteHitlInterruptLogsByIDs deletes decided HITL audit logs by id (pending rows are skipped).
|
||||
func (db *DB) DeleteHitlInterruptLogsByIDs(ids []string) (int64, error) {
|
||||
if db == nil {
|
||||
return 0, fmt.Errorf("database is nil")
|
||||
}
|
||||
clean := make([]string, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
id = strings.TrimSpace(id)
|
||||
if id != "" {
|
||||
clean = append(clean, id)
|
||||
}
|
||||
}
|
||||
if len(clean) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
placeholders := strings.TrimRight(strings.Repeat("?,", len(clean)), ",")
|
||||
q := fmt.Sprintf(`DELETE FROM hitl_interrupts WHERE status != 'pending' AND id IN (%s)`, placeholders)
|
||||
args := make([]interface{}, len(clean))
|
||||
for i, id := range clean {
|
||||
args[i] = id
|
||||
}
|
||||
res, err := db.Exec(q, args...)
|
||||
if err != nil {
|
||||
db.logger.Error("批量删除人机协同审计日志失败", zap.Error(err), zap.Int("count", len(clean)))
|
||||
return 0, fmt.Errorf("批量删除人机协同审计日志失败: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// DeleteHitlInterruptLogsMatching deletes decided logs matching whereSQL (e.g. "WHERE 1=1 AND status != 'pending' ...").
|
||||
func (db *DB) DeleteHitlInterruptLogsMatching(whereSQL string, args []interface{}) (int64, error) {
|
||||
if db == nil {
|
||||
return 0, fmt.Errorf("database is nil")
|
||||
}
|
||||
whereSQL = strings.TrimSpace(whereSQL)
|
||||
if whereSQL == "" {
|
||||
return 0, fmt.Errorf("where clause is required")
|
||||
}
|
||||
q := `DELETE FROM hitl_interrupts ` + whereSQL
|
||||
res, err := db.Exec(q, args...)
|
||||
if err != nil {
|
||||
db.logger.Error("清空人机协同审计日志失败", zap.Error(err))
|
||||
return 0, fmt.Errorf("清空人机协同审计日志失败: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
|
||||
// PurgeHitlInterruptLogsBefore deletes decided logs with decided/created time before cutoff.
|
||||
func (db *DB) PurgeHitlInterruptLogsBefore(cutoff time.Time) (int64, error) {
|
||||
if db == nil {
|
||||
return 0, fmt.Errorf("database is nil")
|
||||
}
|
||||
res, err := db.Exec(
|
||||
`DELETE FROM hitl_interrupts WHERE status != 'pending' AND datetime(COALESCE(decided_at, created_at)) < datetime(?)`,
|
||||
cutoff.UTC().Format(time.RFC3339),
|
||||
)
|
||||
if err != nil {
|
||||
db.logger.Error("清理过期人机协同审计日志失败", zap.Error(err))
|
||||
return 0, fmt.Errorf("清理过期人机协同审计日志失败: %w", err)
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
return n, nil
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func ensureHitlInterruptsTable(t *testing.T, db *DB) {
|
||||
t.Helper()
|
||||
if _, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS hitl_interrupts (
|
||||
id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
message_id TEXT,
|
||||
mode TEXT NOT NULL,
|
||||
tool_name TEXT NOT NULL,
|
||||
tool_call_id TEXT,
|
||||
payload TEXT,
|
||||
status TEXT NOT NULL,
|
||||
decision TEXT,
|
||||
decision_comment TEXT,
|
||||
created_at DATETIME NOT NULL,
|
||||
decided_at DATETIME
|
||||
);`); err != nil {
|
||||
t.Fatalf("create hitl_interrupts: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteHitlInterruptLogsByIDs_skipsPending(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "hitl.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
ensureHitlInterruptsTable(t, db)
|
||||
|
||||
now := time.Now().UTC().Format(time.RFC3339)
|
||||
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
||||
(id, conversation_id, mode, tool_name, status, created_at)
|
||||
VALUES ('pending-1', 'c1', 'approval', 'exec', 'pending', ?)`, now); err != nil {
|
||||
t.Fatalf("insert pending: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
||||
(id, conversation_id, mode, tool_name, status, decision, created_at, decided_at)
|
||||
VALUES ('done-1', 'c1', 'approval', 'exec', 'decided', 'approve', ?, ?)`, now, now); err != nil {
|
||||
t.Fatalf("insert decided: %v", err)
|
||||
}
|
||||
|
||||
deleted, err := db.DeleteHitlInterruptLogsByIDs([]string{"pending-1", "done-1"})
|
||||
if err != nil {
|
||||
t.Fatalf("DeleteHitlInterruptLogsByIDs: %v", err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
t.Fatalf("deleted = %d, want 1", deleted)
|
||||
}
|
||||
|
||||
var status string
|
||||
if err := db.QueryRow(`SELECT status FROM hitl_interrupts WHERE id = 'pending-1'`).Scan(&status); err != nil {
|
||||
t.Fatalf("pending row missing: %v", err)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT id FROM hitl_interrupts WHERE id = 'done-1'`).Scan(new(string)); err == nil {
|
||||
t.Fatal("decided row should be deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeHitlInterruptLogsBefore(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "hitl.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
ensureHitlInterruptsTable(t, db)
|
||||
|
||||
old := time.Now().AddDate(0, 0, -100).UTC().Format(time.RFC3339)
|
||||
recent := time.Now().AddDate(0, 0, -1).UTC().Format(time.RFC3339)
|
||||
for _, row := range []struct{ id, decided string }{
|
||||
{"old-1", old},
|
||||
{"new-1", recent},
|
||||
} {
|
||||
if _, err := db.Exec(`INSERT INTO hitl_interrupts
|
||||
(id, conversation_id, mode, tool_name, status, decision, created_at, decided_at)
|
||||
VALUES (?, 'c1', 'approval', 'exec', 'decided', 'approve', ?, ?)`, row.id, row.decided, row.decided); err != nil {
|
||||
t.Fatalf("insert %s: %v", row.id, err)
|
||||
}
|
||||
}
|
||||
|
||||
cutoff := time.Now().AddDate(0, 0, -90)
|
||||
deleted, err := db.PurgeHitlInterruptLogsBefore(cutoff)
|
||||
if err != nil {
|
||||
t.Fatalf("PurgeHitlInterruptLogsBefore: %v", err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
t.Fatalf("deleted = %d, want 1", deleted)
|
||||
}
|
||||
if err := db.QueryRow(`SELECT id FROM hitl_interrupts WHERE id = 'old-1'`).Scan(new(string)); err == nil {
|
||||
t.Fatal("old row should be purged")
|
||||
}
|
||||
if err := db.QueryRow(`SELECT id FROM hitl_interrupts WHERE id = 'new-1'`).Scan(new(string)); err != nil {
|
||||
t.Fatalf("new row should remain: %v", err)
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,102 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestCancelOrphanedRunningToolExecutions(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "monitor.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
start := time.Now().Add(-2 * time.Hour)
|
||||
exec := &mcp.ToolExecution{
|
||||
ID: "orphan-hydra",
|
||||
ToolName: "hydra",
|
||||
Arguments: map[string]interface{}{"target": "127.0.0.1"},
|
||||
Status: "running",
|
||||
StartTime: start,
|
||||
}
|
||||
if err := db.SaveToolExecution(exec); err != nil {
|
||||
t.Fatalf("SaveToolExecution: %v", err)
|
||||
}
|
||||
|
||||
end := time.Now()
|
||||
n, err := db.CancelOrphanedRunningToolExecutions(end, "执行已中断(服务重启)")
|
||||
if err != nil {
|
||||
t.Fatalf("CancelOrphanedRunningToolExecutions: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("expected 1 row updated, got %d", n)
|
||||
}
|
||||
|
||||
got, err := db.GetToolExecution("orphan-hydra")
|
||||
if err != nil {
|
||||
t.Fatalf("GetToolExecution: %v", err)
|
||||
}
|
||||
if got.Status != "orphaned" {
|
||||
t.Fatalf("expected orphaned, got %s", got.Status)
|
||||
}
|
||||
if got.EndTime == nil {
|
||||
t.Fatal("expected end_time to be set")
|
||||
}
|
||||
if got.Duration <= 0 {
|
||||
t.Fatalf("expected positive duration, got %v", got.Duration)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalizeStaleRunningToolExecutions_skipsActive(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "monitor.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now()
|
||||
oldStart := now.Add(-5 * time.Minute)
|
||||
if err := db.SaveToolExecution(&mcp.ToolExecution{
|
||||
ID: "stale", ToolName: "hydra", Status: "running", StartTime: oldStart,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveToolExecution stale: %v", err)
|
||||
}
|
||||
if err := db.SaveToolExecution(&mcp.ToolExecution{
|
||||
ID: "active", ToolName: "hydra", Status: "running", StartTime: oldStart,
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveToolExecution active: %v", err)
|
||||
}
|
||||
|
||||
active := map[string]struct{}{"active": {}}
|
||||
n, err := db.FinalizeStaleRunningToolExecutions(now, time.Minute, active, "执行已中断(会话已结束)")
|
||||
if err != nil {
|
||||
t.Fatalf("FinalizeStaleRunningToolExecutions: %v", err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("expected 1 stale row updated, got %d", n)
|
||||
}
|
||||
|
||||
stale, err := db.GetToolExecution("stale")
|
||||
if err != nil {
|
||||
t.Fatalf("GetToolExecution stale: %v", err)
|
||||
}
|
||||
if stale.Status != "orphaned" {
|
||||
t.Fatalf("stale expected orphaned, got %s", stale.Status)
|
||||
}
|
||||
|
||||
activeExec, err := db.GetToolExecution("active")
|
||||
if err != nil {
|
||||
t.Fatalf("GetToolExecution active: %v", err)
|
||||
}
|
||||
if activeExec.Status != "running" {
|
||||
t.Fatalf("active expected running, got %s", activeExec.Status)
|
||||
}
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestPurgeToolExecutionsBefore(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "monitor.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
oldStart := time.Now().AddDate(0, 0, -100)
|
||||
newStart := time.Now().AddDate(0, 0, -1)
|
||||
|
||||
oldExec := &mcp.ToolExecution{
|
||||
ID: "old-completed",
|
||||
ToolName: "nmap::scan",
|
||||
Arguments: map[string]interface{}{"target": "127.0.0.1"},
|
||||
Status: "completed",
|
||||
StartTime: oldStart,
|
||||
}
|
||||
oldFailed := &mcp.ToolExecution{
|
||||
ID: "old-failed",
|
||||
ToolName: "nmap::scan",
|
||||
Arguments: map[string]interface{}{"target": "127.0.0.1"},
|
||||
Status: "failed",
|
||||
Error: "timeout",
|
||||
StartTime: oldStart,
|
||||
}
|
||||
newExec := &mcp.ToolExecution{
|
||||
ID: "new-completed",
|
||||
ToolName: "nmap::scan",
|
||||
Arguments: map[string]interface{}{"target": "127.0.0.1"},
|
||||
Status: "completed",
|
||||
StartTime: newStart,
|
||||
}
|
||||
for _, exec := range []*mcp.ToolExecution{oldExec, oldFailed, newExec} {
|
||||
if err := db.SaveToolExecution(exec); err != nil {
|
||||
t.Fatalf("SaveToolExecution(%s): %v", exec.ID, err)
|
||||
}
|
||||
}
|
||||
if err := db.UpdateToolStats("nmap::scan", 3, 2, 1, &newStart); err != nil {
|
||||
t.Fatalf("UpdateToolStats: %v", err)
|
||||
}
|
||||
|
||||
cutoff := time.Now().AddDate(0, 0, -90)
|
||||
deleted, err := db.PurgeToolExecutionsBefore(cutoff)
|
||||
if err != nil {
|
||||
t.Fatalf("PurgeToolExecutionsBefore: %v", err)
|
||||
}
|
||||
if deleted != 2 {
|
||||
t.Fatalf("deleted = %d, want 2", deleted)
|
||||
}
|
||||
|
||||
if _, err := db.GetToolExecution("old-completed"); err == nil {
|
||||
t.Fatal("old-completed should be deleted")
|
||||
}
|
||||
if _, err := db.GetToolExecution("old-failed"); err == nil {
|
||||
t.Fatal("old-failed should be deleted")
|
||||
}
|
||||
if _, err := db.GetToolExecution("new-completed"); err != nil {
|
||||
t.Fatalf("new-completed should remain: %v", err)
|
||||
}
|
||||
|
||||
stats, err := db.LoadToolStats()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadToolStats: %v", err)
|
||||
}
|
||||
stat := stats["nmap::scan"]
|
||||
if stat == nil {
|
||||
t.Fatal("expected stats for nmap::scan")
|
||||
}
|
||||
if stat.TotalCalls != 1 || stat.SuccessCalls != 1 || stat.FailedCalls != 0 {
|
||||
t.Fatalf("stats after purge = %+v, want total=1 success=1 failed=0", stat)
|
||||
}
|
||||
|
||||
total, err := db.CountToolExecutions("", "")
|
||||
if err != nil {
|
||||
t.Fatalf("CountToolExecutions: %v", err)
|
||||
}
|
||||
if total != 1 {
|
||||
t.Fatalf("remaining executions = %d, want 1", total)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeToolExecutionsBefore_zeroRetentionSkipsViaService(t *testing.T) {
|
||||
// RetentionDaysEffective: 0 means no purge at service layer; DB method still works when called directly.
|
||||
dbPath := filepath.Join(t.TempDir(), "monitor.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
exec := &mcp.ToolExecution{
|
||||
ID: "ancient",
|
||||
ToolName: "curl::get",
|
||||
Arguments: map[string]interface{}{},
|
||||
Status: "completed",
|
||||
StartTime: time.Now().AddDate(-1, 0, 0),
|
||||
}
|
||||
if err := db.SaveToolExecution(exec); err != nil {
|
||||
t.Fatalf("SaveToolExecution: %v", err)
|
||||
}
|
||||
|
||||
deleted, err := db.PurgeToolExecutionsBefore(time.Now())
|
||||
if err != nil {
|
||||
t.Fatalf("PurgeToolExecutionsBefore: %v", err)
|
||||
}
|
||||
if deleted != 1 {
|
||||
t.Fatalf("deleted = %d, want 1", deleted)
|
||||
}
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestLoadToolStatsSummaryAndListPage(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "monitor-summary.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now()
|
||||
tools := []struct {
|
||||
name string
|
||||
calls int
|
||||
ok int
|
||||
fail int
|
||||
result string
|
||||
}{
|
||||
{"alpha::run", 10, 9, 1, `{"content":[{"type":"text","text":"` + string(make([]byte, 64*1024)) + `"}]}`},
|
||||
{"beta::scan", 5, 5, 0, `{"content":[{"type":"text","text":"ok"}]}`},
|
||||
{"gamma::ping", 1, 1, 0, `{"content":[{"type":"text","text":"pong"}]}`},
|
||||
}
|
||||
|
||||
for _, tool := range tools {
|
||||
if err := db.UpdateToolStats(tool.name, tool.calls, tool.ok, tool.fail, &now); err != nil {
|
||||
t.Fatalf("UpdateToolStats(%s): %v", tool.name, err)
|
||||
}
|
||||
for j := 0; j < tool.calls; j++ {
|
||||
exec := &mcp.ToolExecution{
|
||||
ID: fmt.Sprintf("%s-exec-%d", tool.name, j),
|
||||
ToolName: tool.name,
|
||||
Arguments: map[string]interface{}{"n": j},
|
||||
Status: "completed",
|
||||
StartTime: now.Add(-time.Duration(j) * time.Minute),
|
||||
Result: &mcp.ToolResult{Content: []mcp.Content{{Type: "text", Text: tool.result}}},
|
||||
}
|
||||
end := exec.StartTime.Add(time.Second)
|
||||
exec.EndTime = &end
|
||||
exec.Duration = time.Second
|
||||
if err := db.SaveToolExecution(exec); err != nil {
|
||||
t.Fatalf("SaveToolExecution: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
summary, err := db.LoadToolStatsSummary(2)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadToolStatsSummary: %v", err)
|
||||
}
|
||||
if summary.Summary.ToolCount != 3 {
|
||||
t.Fatalf("toolCount = %d, want 3", summary.Summary.ToolCount)
|
||||
}
|
||||
if summary.Summary.TotalCalls != 16 {
|
||||
t.Fatalf("totalCalls = %d, want 16", summary.Summary.TotalCalls)
|
||||
}
|
||||
if len(summary.TopTools) != 2 {
|
||||
t.Fatalf("top tools = %d, want 2", len(summary.TopTools))
|
||||
}
|
||||
if summary.TopTools[0].ToolName != "alpha::run" {
|
||||
t.Fatalf("top tool = %q, want alpha::run", summary.TopTools[0].ToolName)
|
||||
}
|
||||
|
||||
list, err := db.LoadToolExecutionListPage(0, 5, "", "")
|
||||
if err != nil {
|
||||
t.Fatalf("LoadToolExecutionListPage: %v", err)
|
||||
}
|
||||
if len(list) != 5 {
|
||||
t.Fatalf("list len = %d, want 5", len(list))
|
||||
}
|
||||
for _, exec := range list {
|
||||
if exec.Arguments != nil || exec.Result != nil || exec.Error != "" {
|
||||
t.Fatalf("expected lite execution row, got args/result/error on %s", exec.ID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadToolStatsSummaryDoesNotCountCancelledAsFailed(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "monitor-cancelled-summary.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
now := time.Now()
|
||||
for i, status := range []string{"completed", "cancelled", "failed"} {
|
||||
exec := &mcp.ToolExecution{
|
||||
ID: fmt.Sprintf("exec-%d", i),
|
||||
ToolName: "exec",
|
||||
Arguments: map[string]interface{}{},
|
||||
Status: status,
|
||||
StartTime: now.Add(time.Duration(i) * time.Second),
|
||||
}
|
||||
end := exec.StartTime.Add(time.Second)
|
||||
exec.EndTime = &end
|
||||
exec.Duration = time.Second
|
||||
if err := db.SaveToolExecution(exec); err != nil {
|
||||
t.Fatalf("SaveToolExecution(%s): %v", status, err)
|
||||
}
|
||||
}
|
||||
|
||||
summary, err := db.LoadToolStatsSummary(1)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadToolStatsSummary: %v", err)
|
||||
}
|
||||
if summary.Summary.TotalCalls != 3 {
|
||||
t.Fatalf("totalCalls = %d, want 3", summary.Summary.TotalCalls)
|
||||
}
|
||||
if summary.Summary.SuccessCalls != 1 {
|
||||
t.Fatalf("successCalls = %d, want 1", summary.Summary.SuccessCalls)
|
||||
}
|
||||
if summary.Summary.FailedCalls != 1 {
|
||||
t.Fatalf("failedCalls = %d, want 1", summary.Summary.FailedCalls)
|
||||
}
|
||||
if len(summary.TopTools) != 1 {
|
||||
t.Fatalf("top tools = %d, want 1", len(summary.TopTools))
|
||||
}
|
||||
if summary.TopTools[0].FailedCalls != 1 {
|
||||
t.Fatalf("top tool failedCalls = %d, want 1", summary.TopTools[0].FailedCalls)
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ConversationPlanTask mirrors the public fields persisted by Eino plantask.
|
||||
// Keeping the transport model here avoids coupling the HTTP layer to Eino's
|
||||
// private task type.
|
||||
type ConversationPlanTask struct {
|
||||
ID string `json:"id"`
|
||||
Subject string `json:"subject"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Blocks []string `json:"blocks,omitempty"`
|
||||
BlockedBy []string `json:"blockedBy,omitempty"`
|
||||
ActiveForm string `json:"activeForm,omitempty"`
|
||||
Owner string `json:"owner,omitempty"`
|
||||
}
|
||||
|
||||
// ListConversationPlanTasks returns the live Eino task board for one
|
||||
// conversation. A missing task directory is the normal state for short or
|
||||
// legacy conversations and therefore returns an empty list.
|
||||
func (db *DB) ListConversationPlanTasks(conversationID string) ([]ConversationPlanTask, error) {
|
||||
return db.ListConversationPlanTasksSince(conversationID, time.Time{})
|
||||
}
|
||||
|
||||
// ListConversationPlanTasksSince limits the board to files written during the
|
||||
// current agent run. The Eino backend intentionally keeps older task files for
|
||||
// model continuity, but the conversation UI must not surface those files before
|
||||
// the new run has called TaskCreate.
|
||||
func (db *DB) ListConversationPlanTasksSince(conversationID string, since time.Time) ([]ConversationPlanTask, error) {
|
||||
if db == nil {
|
||||
return []ConversationPlanTask{}, nil
|
||||
}
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
if conversationID == "" {
|
||||
return nil, fmt.Errorf("conversation id is required")
|
||||
}
|
||||
base := strings.TrimSpace(db.einoPlantaskBaseDir)
|
||||
if base == "" {
|
||||
return []ConversationPlanTask{}, nil
|
||||
}
|
||||
|
||||
dir := filepath.Join(base, sanitizeConversationPathSegment(conversationID))
|
||||
entries, err := os.ReadDir(dir)
|
||||
if os.IsNotExist(err) {
|
||||
return []ConversationPlanTask{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read conversation plan tasks: %w", err)
|
||||
}
|
||||
|
||||
type numberedTask struct {
|
||||
number int
|
||||
task ConversationPlanTask
|
||||
}
|
||||
numbered := make([]numberedTask, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
idText := strings.TrimSuffix(entry.Name(), ".json")
|
||||
number, parseErr := strconv.Atoi(idText)
|
||||
if parseErr != nil || number < 1 {
|
||||
continue
|
||||
}
|
||||
if !since.IsZero() {
|
||||
info, infoErr := entry.Info()
|
||||
if infoErr != nil {
|
||||
continue
|
||||
}
|
||||
if info.ModTime().Before(since) {
|
||||
continue
|
||||
}
|
||||
}
|
||||
content, readErr := os.ReadFile(filepath.Join(dir, entry.Name()))
|
||||
if readErr != nil {
|
||||
if db.logger != nil {
|
||||
db.logger.Debug("读取 Eino 任务文件失败",
|
||||
zap.String("conversationId", conversationID),
|
||||
zap.String("file", entry.Name()),
|
||||
zap.Error(readErr))
|
||||
}
|
||||
continue
|
||||
}
|
||||
var task ConversationPlanTask
|
||||
if decodeErr := json.Unmarshal(content, &task); decodeErr != nil {
|
||||
// TaskUpdate writes files concurrently with this read. A partial read
|
||||
// is transient, so skip it and let the next poll recover.
|
||||
if db.logger != nil {
|
||||
db.logger.Debug("解析 Eino 任务文件失败",
|
||||
zap.String("conversationId", conversationID),
|
||||
zap.String("file", entry.Name()),
|
||||
zap.Error(decodeErr))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if strings.TrimSpace(task.ID) == "" {
|
||||
task.ID = idText
|
||||
}
|
||||
if strings.EqualFold(strings.TrimSpace(task.Status), "deleted") {
|
||||
continue
|
||||
}
|
||||
numbered = append(numbered, numberedTask{number: number, task: task})
|
||||
}
|
||||
|
||||
sort.SliceStable(numbered, func(i, j int) bool {
|
||||
return numbered[i].number < numbered[j].number
|
||||
})
|
||||
tasks := make([]ConversationPlanTask, 0, len(numbered))
|
||||
for _, item := range numbered {
|
||||
tasks = append(tasks, item.task)
|
||||
}
|
||||
return tasks, nil
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestListConversationPlanTasksSortedAndToleratesMissingDirectory(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
db, err := NewDB(filepath.Join(tmp, "plantask.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
base := filepath.Join(tmp, "skills", ".eino", "plantask")
|
||||
db.SetEinoConversationDirs(base, "", "", "")
|
||||
missing, err := db.ListConversationPlanTasks("missing")
|
||||
if err != nil || len(missing) != 0 {
|
||||
t.Fatalf("missing task board = %#v, err=%v", missing, err)
|
||||
}
|
||||
|
||||
dir := filepath.Join(base, "conversation-1")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
files := map[string]string{
|
||||
"10.json": `{"id":"10","subject":"最后检查","status":"pending"}`,
|
||||
"2.json": `{"id":"2","subject":"实现接口","status":"in_progress","activeForm":"正在实现接口"}`,
|
||||
"1.json": `{"id":"1","subject":"梳理需求","status":"completed"}`,
|
||||
"bad.json": `{`,
|
||||
}
|
||||
for name, content := range files {
|
||||
if err := os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(%s): %v", name, err)
|
||||
}
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, ".highwatermark"), []byte("10"), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(highwatermark): %v", err)
|
||||
}
|
||||
|
||||
tasks, err := db.ListConversationPlanTasks("conversation-1")
|
||||
if err != nil {
|
||||
t.Fatalf("ListConversationPlanTasks: %v", err)
|
||||
}
|
||||
if len(tasks) != 3 {
|
||||
t.Fatalf("tasks = %#v, want 3", tasks)
|
||||
}
|
||||
if tasks[0].ID != "1" || tasks[1].ID != "2" || tasks[2].ID != "10" {
|
||||
t.Fatalf("task order = %q, %q, %q", tasks[0].ID, tasks[1].ID, tasks[2].ID)
|
||||
}
|
||||
if tasks[1].ActiveForm != "正在实现接口" {
|
||||
t.Fatalf("activeForm = %q", tasks[1].ActiveForm)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListConversationPlanTasksSinceHidesPreviousRunUntilTaskCreate(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
db, err := NewDB(filepath.Join(tmp, "plantask-current-run.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
base := filepath.Join(tmp, "plantask")
|
||||
db.SetEinoConversationDirs(base, "", "", "")
|
||||
dir := filepath.Join(base, "conversation-current-run")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatalf("MkdirAll: %v", err)
|
||||
}
|
||||
oldPath := filepath.Join(dir, "1.json")
|
||||
if err := os.WriteFile(oldPath, []byte(`{"id":"1","subject":"上一轮任务","status":"in_progress"}`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(old): %v", err)
|
||||
}
|
||||
runStartedAt := time.Now().Add(-time.Second)
|
||||
oldTime := runStartedAt.Add(-time.Minute)
|
||||
if err := os.Chtimes(oldPath, oldTime, oldTime); err != nil {
|
||||
t.Fatalf("Chtimes(old): %v", err)
|
||||
}
|
||||
|
||||
tasks, err := db.ListConversationPlanTasksSince("conversation-current-run", runStartedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("ListConversationPlanTasksSince(before TaskCreate): %v", err)
|
||||
}
|
||||
if len(tasks) != 0 {
|
||||
t.Fatalf("stale tasks shown before current TaskCreate: %#v", tasks)
|
||||
}
|
||||
|
||||
newPath := filepath.Join(dir, "2.json")
|
||||
if err := os.WriteFile(newPath, []byte(`{"id":"2","subject":"本轮任务","status":"pending"}`), 0o644); err != nil {
|
||||
t.Fatalf("WriteFile(new): %v", err)
|
||||
}
|
||||
tasks, err = db.ListConversationPlanTasksSince("conversation-current-run", runStartedAt)
|
||||
if err != nil {
|
||||
t.Fatalf("ListConversationPlanTasksSince(after TaskCreate): %v", err)
|
||||
}
|
||||
if len(tasks) != 1 || tasks[0].ID != "2" {
|
||||
t.Fatalf("current tasks = %#v, want task 2 only", tasks)
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// DedupeConsecutiveProcessDetails 去掉相邻且语义相同的过程详情(使用 DB 中 data 列原始 JSON 作指纹,避免 map 序列化键序不稳定)。
|
||||
func DedupeConsecutiveProcessDetails(rows []ProcessDetail) []ProcessDetail {
|
||||
if len(rows) < 2 {
|
||||
return rows
|
||||
}
|
||||
out := make([]ProcessDetail, 0, len(rows))
|
||||
var lastKey string
|
||||
for _, d := range rows {
|
||||
key := processDetailRowKey(d)
|
||||
if len(out) > 0 && key != "" && key == lastKey {
|
||||
continue
|
||||
}
|
||||
out = append(out, d)
|
||||
lastKey = key
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func processDetailRowKey(d ProcessDetail) string {
|
||||
return fmt.Sprintf("%s\x00%s\x00%s", d.EventType, strings.TrimSpace(d.Message), d.Data)
|
||||
}
|
||||
@@ -1,182 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestProcessDetailsSummaryDoesNotGuessIDLessResultsByOrder(t *testing.T) {
|
||||
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
|
||||
for _, id := range []string{"call-1", "call-2", "call-3", "call-4"} {
|
||||
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
|
||||
"toolName": "http-framework-test", "toolCallId": id,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddProcessDetail(tool_call): %v", err)
|
||||
}
|
||||
}
|
||||
results := []map[string]interface{}{
|
||||
{"toolName": "http-framework-test", "toolCallId": "call-1", "success": true},
|
||||
{"toolName": "http-framework-test", "toolCallId": "call-2", "success": true},
|
||||
{"toolName": "http-framework-test", "success": true},
|
||||
{"toolName": "http-framework-test", "success": true},
|
||||
}
|
||||
var resultIDs []string
|
||||
for _, result := range results {
|
||||
resultID, err := db.AddProcessDetailWithID(messageID, conversationID, "tool_result", "result", result)
|
||||
if err != nil {
|
||||
t.Fatalf("AddProcessDetail(tool_result): %v", err)
|
||||
}
|
||||
resultIDs = append(resultIDs, resultID)
|
||||
}
|
||||
|
||||
summary, err := db.GetProcessDetailsSummary(messageID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||
}
|
||||
if len(summary.ToolExecutions) != 6 {
|
||||
t.Fatalf("tool executions = %d, want 6", len(summary.ToolExecutions))
|
||||
}
|
||||
for i, execution := range summary.ToolExecutions[:2] {
|
||||
if execution.Status != "completed" {
|
||||
t.Fatalf("execution %d status = %q, want completed", i, execution.Status)
|
||||
}
|
||||
if execution.ResultDetailID != resultIDs[i] {
|
||||
t.Fatalf("execution %d result detail id = %q, want %q", i, execution.ResultDetailID, resultIDs[i])
|
||||
}
|
||||
}
|
||||
for i, execution := range summary.ToolExecutions[2:4] {
|
||||
if execution.Status != "result_missing" {
|
||||
t.Fatalf("unmatched call %d status = %q, want result_missing", i, execution.Status)
|
||||
}
|
||||
}
|
||||
for i, execution := range summary.ToolExecutions[4:] {
|
||||
if execution.Status != "completed" || execution.ToolCallID != "" {
|
||||
t.Fatalf("idless result %d = %#v, want separate completed result without toolCallId", i, execution)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailsSummaryPairsRepeatedToolCallIDsFIFO(t *testing.T) {
|
||||
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
|
||||
"toolName": "execute", "toolCallId": "legacy-reused-id",
|
||||
}); err != nil {
|
||||
t.Fatalf("AddProcessDetail(tool_call): %v", err)
|
||||
}
|
||||
}
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := db.AddProcessDetail(messageID, conversationID, "tool_result", "result", map[string]interface{}{
|
||||
"toolName": "execute", "toolCallId": "legacy-reused-id", "success": true,
|
||||
}); err != nil {
|
||||
t.Fatalf("AddProcessDetail(tool_result): %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
summary, err := db.GetProcessDetailsSummary(messageID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||
}
|
||||
if len(summary.ToolExecutions) != 2 {
|
||||
t.Fatalf("tool executions = %d, want 2", len(summary.ToolExecutions))
|
||||
}
|
||||
for i, execution := range summary.ToolExecutions {
|
||||
if execution.Status != "completed" {
|
||||
t.Fatalf("execution %d status = %q, want completed", i, execution.Status)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailsSummaryDoesNotReportPersistedOrphanAsRunning(t *testing.T) {
|
||||
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
|
||||
if err := db.AddProcessDetail(messageID, conversationID, "tool_call", "call", map[string]interface{}{
|
||||
"toolName": "execute", "toolCallId": "orphan",
|
||||
}); err != nil {
|
||||
t.Fatalf("AddProcessDetail(tool_call): %v", err)
|
||||
}
|
||||
summary, err := db.GetProcessDetailsSummary(messageID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||
}
|
||||
if len(summary.ToolExecutions) != 1 || summary.ToolExecutions[0].Status != "result_missing" {
|
||||
t.Fatalf("tool executions = %#v, want result_missing", summary.ToolExecutions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailsSummaryIncludesPersistedTurnTiming(t *testing.T) {
|
||||
db, _, messageID := setupProcessDetailsSummaryTest(t)
|
||||
startedAt := "2026-08-10T08:00:00Z"
|
||||
completedAt := "2026-08-10T08:12:59Z"
|
||||
if _, err := db.Exec(
|
||||
"UPDATE messages SET content = ?, created_at = ?, updated_at = ? WHERE id = ?",
|
||||
"done", startedAt, completedAt, messageID,
|
||||
); err != nil {
|
||||
t.Fatalf("update message timing: %v", err)
|
||||
}
|
||||
|
||||
summary, err := db.GetProcessDetailsSummary(messageID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||
}
|
||||
if summary.Status != "completed" {
|
||||
t.Fatalf("status = %q, want completed", summary.Status)
|
||||
}
|
||||
if summary.StartedAt == nil || summary.CompletedAt == nil {
|
||||
t.Fatalf("timing missing: %#v", summary)
|
||||
}
|
||||
if want := int64((12*time.Minute + 59*time.Second) / time.Millisecond); summary.DurationMs != want {
|
||||
t.Fatalf("durationMs = %d, want %d", summary.DurationMs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessDetailsSummaryTreatsCancelledPlaceholderAsTerminal(t *testing.T) {
|
||||
db, conversationID, messageID := setupProcessDetailsSummaryTest(t)
|
||||
startedAt := "2026-08-10T08:00:00Z"
|
||||
if _, err := db.Exec(
|
||||
"UPDATE messages SET content = ?, created_at = ?, updated_at = ? WHERE id = ?",
|
||||
"处理中...", startedAt, startedAt, messageID,
|
||||
); err != nil {
|
||||
t.Fatalf("update running placeholder: %v", err)
|
||||
}
|
||||
if _, err := db.Exec(`
|
||||
INSERT INTO process_details (id, message_id, conversation_id, event_type, message, data, created_at)
|
||||
VALUES ('cancelled-detail', ?, ?, 'cancelled', 'interrupted', '{}', '2026-08-10T08:02:05Z')`,
|
||||
messageID, conversationID); err != nil {
|
||||
t.Fatalf("insert cancelled detail: %v", err)
|
||||
}
|
||||
|
||||
summary, err := db.GetProcessDetailsSummary(messageID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetProcessDetailsSummary: %v", err)
|
||||
}
|
||||
if summary.Status != "cancelled" {
|
||||
t.Fatalf("status = %q, want cancelled", summary.Status)
|
||||
}
|
||||
if summary.CompletedAt == nil {
|
||||
t.Fatal("cancelled summary should expose a fixed completion time")
|
||||
}
|
||||
if want := int64((2*time.Minute + 5*time.Second) / time.Millisecond); summary.DurationMs != want {
|
||||
t.Fatalf("durationMs = %d, want %d", summary.DurationMs, want)
|
||||
}
|
||||
}
|
||||
|
||||
func setupProcessDetailsSummaryTest(t *testing.T) (*DB, string, string) {
|
||||
t.Helper()
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "process-details.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
conversation, err := db.CreateConversation("process details", ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateConversation: %v", err)
|
||||
}
|
||||
message, err := db.AddMessage(conversation.ID, "assistant", "done", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("AddMessage: %v", err)
|
||||
}
|
||||
return db, conversation.ID, message.ID
|
||||
}
|
||||
@@ -1,635 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
var factKeyPattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9._/-]*$`)
|
||||
|
||||
// ValidateFactKey 校验事实 key(项目内唯一标识)。
|
||||
func ValidateFactKey(key string) error {
|
||||
key = strings.TrimSpace(key)
|
||||
if key == "" {
|
||||
return fmt.Errorf("fact_key 不能为空")
|
||||
}
|
||||
if len(key) > 128 {
|
||||
return fmt.Errorf("fact_key 过长(最多 128 字符)")
|
||||
}
|
||||
if !factKeyPattern.MatchString(key) {
|
||||
return fmt.Errorf("fact_key 格式无效,仅允许字母、数字及 . _ / -,且须以字母或数字开头(支持驼峰命名)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Project 渗透测试项目(跨对话共享黑板)。
|
||||
type Project struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
ScopeJSON string `json:"scope_json,omitempty"`
|
||||
Status string `json:"status"` // active | archived
|
||||
Pinned bool `json:"pinned"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ProjectFact 项目事实(黑板条目)。
|
||||
type ProjectFact struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
FactKey string `json:"fact_key"`
|
||||
Category string `json:"category"`
|
||||
Summary string `json:"summary"`
|
||||
Body string `json:"body"`
|
||||
Confidence string `json:"confidence"` // confirmed | tentative | deprecated
|
||||
SourceConversationID string `json:"source_conversation_id,omitempty"`
|
||||
SourceMessageID string `json:"source_message_id,omitempty"`
|
||||
Pinned bool `json:"pinned"`
|
||||
RelatedVulnerabilityID string `json:"related_vulnerability_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ProjectFactListFilter 事实列表筛选。
|
||||
type ProjectFactListFilter struct {
|
||||
Category string
|
||||
Confidence string
|
||||
Search string
|
||||
RelatedVulnerabilityID string
|
||||
ExcludeDeprecated bool // 为 true 时排除 confidence=deprecated
|
||||
}
|
||||
|
||||
// CreateProject 创建项目。
|
||||
func (db *DB) CreateProject(p *Project) (*Project, error) {
|
||||
if p.ID == "" {
|
||||
p.ID = uuid.New().String()
|
||||
}
|
||||
if strings.TrimSpace(p.Status) == "" {
|
||||
p.Status = "active"
|
||||
}
|
||||
now := time.Now()
|
||||
if p.CreatedAt.IsZero() {
|
||||
p.CreatedAt = now
|
||||
}
|
||||
p.UpdatedAt = now
|
||||
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO projects (id, name, description, scope_json, status, pinned, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
p.ID, p.Name, p.Description, p.ScopeJSON, p.Status, boolToInt(p.Pinned), p.CreatedAt, p.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建项目失败: %w", err)
|
||||
}
|
||||
return p, nil
|
||||
}
|
||||
|
||||
// GetProject 获取项目。
|
||||
func (db *DB) GetProject(id string) (*Project, error) {
|
||||
var p Project
|
||||
var pinned int
|
||||
var createdAt, updatedAt string
|
||||
err := db.QueryRow(
|
||||
`SELECT id, name, COALESCE(description,''), COALESCE(scope_json,''), status, pinned, created_at, updated_at
|
||||
FROM projects WHERE id = ?`, id,
|
||||
).Scan(&p.ID, &p.Name, &p.Description, &p.ScopeJSON, &p.Status, &pinned, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("项目不存在")
|
||||
}
|
||||
return nil, fmt.Errorf("获取项目失败: %w", err)
|
||||
}
|
||||
p.Pinned = pinned != 0
|
||||
p.CreatedAt = parseDBTime(createdAt)
|
||||
p.UpdatedAt = parseDBTime(updatedAt)
|
||||
return &p, nil
|
||||
}
|
||||
|
||||
// GetProjectName returns a project display name without loading the full record.
|
||||
func (db *DB) GetProjectName(id string) (string, error) {
|
||||
var name string
|
||||
err := db.QueryRow(`SELECT name FROM projects WHERE id = ?`, id).Scan(&name)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return "", fmt.Errorf("项目不存在")
|
||||
}
|
||||
return "", fmt.Errorf("获取项目名称失败: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(name), nil
|
||||
}
|
||||
|
||||
func projectListSearchPattern(q string) string {
|
||||
q = strings.TrimSpace(q)
|
||||
if q == "" {
|
||||
return ""
|
||||
}
|
||||
var b strings.Builder
|
||||
b.WriteByte('%')
|
||||
for _, r := range q {
|
||||
switch r {
|
||||
case '%', '_', '\\':
|
||||
b.WriteByte('\\')
|
||||
b.WriteRune(r)
|
||||
default:
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
b.WriteByte('%')
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func appendProjectListFilters(query string, args []interface{}, status, search string) (string, []interface{}) {
|
||||
if s := strings.TrimSpace(status); s != "" {
|
||||
query += " AND status = ?"
|
||||
args = append(args, s)
|
||||
}
|
||||
if pattern := projectListSearchPattern(search); pattern != "" {
|
||||
query += ` AND (LOWER(name) LIKE LOWER(?) ESCAPE '\' OR LOWER(COALESCE(description,'')) LIKE LOWER(?) ESCAPE '\' OR LOWER(id) LIKE LOWER(?) ESCAPE '\')`
|
||||
args = append(args, pattern, pattern, pattern)
|
||||
}
|
||||
return query, args
|
||||
}
|
||||
|
||||
func appendProjectAccessFilter(query string, args []interface{}, userID, scope string) (string, []interface{}) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" || scope == RBACScopeAll {
|
||||
return query, args
|
||||
}
|
||||
query += ` AND (owner_user_id = ? OR EXISTS (
|
||||
SELECT 1 FROM rbac_resource_assignments ra
|
||||
WHERE ra.user_id = ? AND ra.resource_type = 'project' AND ra.resource_id = projects.id
|
||||
))`
|
||||
args = append(args, userID, userID)
|
||||
return query, args
|
||||
}
|
||||
|
||||
// CountProjects 统计项目数量。
|
||||
func (db *DB) CountProjects(status, search string) (int, error) {
|
||||
query := `SELECT COUNT(*) FROM projects WHERE 1=1`
|
||||
args := []interface{}{}
|
||||
query, args = appendProjectListFilters(query, args, status, search)
|
||||
var count int
|
||||
if err := db.QueryRow(query, args...).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("统计项目失败: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (db *DB) CountProjectsForAccess(status, search, userID, scope string) (int, error) {
|
||||
query := `SELECT COUNT(*) FROM projects WHERE 1=1`
|
||||
args := []interface{}{}
|
||||
query, args = appendProjectListFilters(query, args, status, search)
|
||||
query, args = appendProjectAccessFilter(query, args, userID, scope)
|
||||
var count int
|
||||
if err := db.QueryRow(query, args...).Scan(&count); err != nil {
|
||||
return 0, fmt.Errorf("统计项目失败: %w", err)
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// ListProjects 列出项目。
|
||||
func (db *DB) ListProjects(status, search string, limit, offset int) ([]*Project, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
query := `SELECT id, name, COALESCE(description,''), COALESCE(scope_json,''), status, pinned, created_at, updated_at
|
||||
FROM projects WHERE 1=1`
|
||||
args := []interface{}{}
|
||||
query, args = appendProjectListFilters(query, args, status, search)
|
||||
query += " ORDER BY pinned DESC, updated_at DESC LIMIT ? OFFSET ?"
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("列出项目失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*Project
|
||||
for rows.Next() {
|
||||
var p Project
|
||||
var pinned int
|
||||
var createdAt, updatedAt string
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.ScopeJSON, &p.Status, &pinned, &createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Pinned = pinned != 0
|
||||
p.CreatedAt = parseDBTime(createdAt)
|
||||
p.UpdatedAt = parseDBTime(updatedAt)
|
||||
out = append(out, &p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) ListProjectsForAccess(status, search string, limit, offset int, userID, scope string) ([]*Project, error) {
|
||||
if scope == RBACScopeAll || strings.TrimSpace(userID) == "" {
|
||||
return db.ListProjects(status, search, limit, offset)
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
query := `SELECT id, name, COALESCE(description,''), COALESCE(scope_json,''), status, pinned, created_at, updated_at
|
||||
FROM projects WHERE 1=1`
|
||||
args := []interface{}{}
|
||||
query, args = appendProjectListFilters(query, args, status, search)
|
||||
query, args = appendProjectAccessFilter(query, args, userID, scope)
|
||||
query += " ORDER BY pinned DESC, updated_at DESC LIMIT ? OFFSET ?"
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("列出项目失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*Project
|
||||
for rows.Next() {
|
||||
var p Project
|
||||
var pinned int
|
||||
var createdAt, updatedAt string
|
||||
if err := rows.Scan(&p.ID, &p.Name, &p.Description, &p.ScopeJSON, &p.Status, &pinned, &createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Pinned = pinned != 0
|
||||
p.CreatedAt = parseDBTime(createdAt)
|
||||
p.UpdatedAt = parseDBTime(updatedAt)
|
||||
out = append(out, &p)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// UpdateProject 更新项目。
|
||||
func (db *DB) UpdateProject(p *Project) error {
|
||||
p.UpdatedAt = time.Now()
|
||||
_, err := db.Exec(
|
||||
`UPDATE projects SET name = ?, description = ?, scope_json = ?, status = ?, pinned = ?, updated_at = ? WHERE id = ?`,
|
||||
p.Name, p.Description, p.ScopeJSON, p.Status, boolToInt(p.Pinned), p.UpdatedAt, p.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新项目失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteProject 删除项目(级联删除事实;对话 project_id 置空由 FK 处理;其他资源 project_id 置空)。
|
||||
func (db *DB) DeleteProject(id string) error {
|
||||
if _, err := db.Exec(`UPDATE vulnerabilities SET project_id = NULL WHERE project_id = ?`, id); err != nil {
|
||||
return fmt.Errorf("解除漏洞项目关联失败: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE assets SET project_id = NULL WHERE project_id = ?`, id); err != nil {
|
||||
return fmt.Errorf("解除资产项目关联失败: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE webshell_connections SET project_id = NULL WHERE project_id = ?`, id); err != nil {
|
||||
return fmt.Errorf("解除 WebShell 项目关联失败: %w", err)
|
||||
}
|
||||
if _, err := db.Exec(`UPDATE c2_listeners SET project_id = NULL WHERE project_id = ?`, id); err != nil {
|
||||
return fmt.Errorf("解除 C2 监听器项目关联失败: %w", err)
|
||||
}
|
||||
_, err := db.Exec(`DELETE FROM projects WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
return fmt.Errorf("删除项目失败: %w", err)
|
||||
}
|
||||
db.removeProjectScopedDirs(id)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetConversationProjectID 返回对话绑定的项目 ID。
|
||||
func (db *DB) GetConversationProjectID(conversationID string) (string, error) {
|
||||
var pid sql.NullString
|
||||
err := db.QueryRow(`SELECT project_id FROM conversations WHERE id = ?`, conversationID).Scan(&pid)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return "", fmt.Errorf("对话不存在")
|
||||
}
|
||||
return "", err
|
||||
}
|
||||
if pid.Valid {
|
||||
return strings.TrimSpace(pid.String), nil
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// SetConversationProjectID 设置对话所属项目(空字符串表示解除绑定)。
|
||||
func (db *DB) SetConversationProjectID(conversationID, projectID string) error {
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID != "" {
|
||||
if _, err := db.GetProject(projectID); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
var val interface{}
|
||||
if projectID == "" {
|
||||
val = nil
|
||||
} else {
|
||||
val = projectID
|
||||
}
|
||||
_, err := db.Exec(`UPDATE conversations SET project_id = ?, updated_at = ? WHERE id = ?`, val, time.Now(), conversationID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("设置对话项目失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListProjectFactsForIndex 列出用于黑板索引注入的事实(不含 deprecated,除非 includeDeprecated)。
|
||||
func (db *DB) ListProjectFactsForIndex(projectID string, includeDeprecated bool) ([]*ProjectFact, error) {
|
||||
query := `SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
||||
COALESCE(related_vulnerability_id,''), created_at, updated_at
|
||||
FROM project_facts WHERE project_id = ?`
|
||||
args := []interface{}{projectID}
|
||||
if !includeDeprecated {
|
||||
query += " AND confidence != 'deprecated'"
|
||||
}
|
||||
query += " ORDER BY pinned DESC, updated_at DESC"
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProjectFacts(rows)
|
||||
}
|
||||
|
||||
// ListProjectFacts 分页列出项目事实。
|
||||
func (db *DB) ListProjectFacts(projectID string, filter ProjectFactListFilter, limit, offset int) ([]*ProjectFact, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
query := `SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
||||
COALESCE(related_vulnerability_id,''), created_at, updated_at
|
||||
FROM project_facts WHERE project_id = ?`
|
||||
args := []interface{}{projectID}
|
||||
if c := strings.TrimSpace(filter.Category); c != "" {
|
||||
query += " AND category = ?"
|
||||
args = append(args, c)
|
||||
}
|
||||
if c := strings.TrimSpace(filter.Confidence); c != "" {
|
||||
query += " AND confidence = ?"
|
||||
args = append(args, c)
|
||||
}
|
||||
if filter.ExcludeDeprecated {
|
||||
query += " AND confidence != 'deprecated'"
|
||||
}
|
||||
if rid := strings.TrimSpace(filter.RelatedVulnerabilityID); rid != "" {
|
||||
query += " AND related_vulnerability_id = ?"
|
||||
args = append(args, rid)
|
||||
}
|
||||
if s := strings.TrimSpace(filter.Search); s != "" {
|
||||
pat := "%" + s + "%"
|
||||
query += " AND (fact_key LIKE ? OR summary LIKE ? OR body LIKE ?)"
|
||||
args = append(args, pat, pat, pat)
|
||||
}
|
||||
query += " ORDER BY pinned DESC, updated_at DESC LIMIT ? OFFSET ?"
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProjectFacts(rows)
|
||||
}
|
||||
|
||||
// GetProjectFactByKey 按 key 获取事实。
|
||||
func (db *DB) GetProjectFactByKey(projectID, factKey string) (*ProjectFact, error) {
|
||||
row := db.QueryRow(
|
||||
`SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
||||
COALESCE(related_vulnerability_id,''), created_at, updated_at
|
||||
FROM project_facts WHERE project_id = ? AND fact_key = ?`,
|
||||
projectID, factKey,
|
||||
)
|
||||
return scanProjectFactRow(row)
|
||||
}
|
||||
|
||||
// GetProjectFact 按 ID 获取事实。
|
||||
func (db *DB) GetProjectFact(id string) (*ProjectFact, error) {
|
||||
row := db.QueryRow(
|
||||
`SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
||||
COALESCE(related_vulnerability_id,''), created_at, updated_at
|
||||
FROM project_facts WHERE id = ?`, id,
|
||||
)
|
||||
return scanProjectFactRow(row)
|
||||
}
|
||||
|
||||
// mergeFactBodyOnUpdate 更新时若 incoming body 为空则保留已有内容,避免仅改 summary 时丢失攻击链。
|
||||
func mergeFactBodyOnUpdate(incoming, existing string) string {
|
||||
if strings.TrimSpace(incoming) == "" {
|
||||
return existing
|
||||
}
|
||||
return incoming
|
||||
}
|
||||
|
||||
// UpsertProjectFact 创建或更新事实(按 project_id + fact_key)。
|
||||
func (db *DB) UpsertProjectFact(f *ProjectFact) (*ProjectFact, error) {
|
||||
if err := ValidateFactKey(f.FactKey); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(f.Category) == "" {
|
||||
f.Category = "note"
|
||||
}
|
||||
if strings.TrimSpace(f.Confidence) == "" {
|
||||
f.Confidence = "tentative"
|
||||
}
|
||||
now := time.Now()
|
||||
|
||||
existing, err := db.GetProjectFactByKey(f.ProjectID, f.FactKey)
|
||||
if err == nil && existing != nil {
|
||||
f.ID = existing.ID
|
||||
f.CreatedAt = existing.CreatedAt
|
||||
f.UpdatedAt = now
|
||||
f.Body = mergeFactBodyOnUpdate(f.Body, existing.Body)
|
||||
if strings.TrimSpace(f.Category) == "" {
|
||||
f.Category = existing.Category
|
||||
}
|
||||
if strings.TrimSpace(f.Confidence) == "" {
|
||||
f.Confidence = existing.Confidence
|
||||
}
|
||||
_, err = db.Exec(
|
||||
`UPDATE project_facts SET category = ?, summary = ?, body = ?, confidence = ?,
|
||||
source_conversation_id = COALESCE(?, source_conversation_id),
|
||||
source_message_id = COALESCE(?, source_message_id),
|
||||
pinned = ?, related_vulnerability_id = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
f.Category, f.Summary, f.Body, f.Confidence,
|
||||
nullIfEmpty(f.SourceConversationID), nullIfEmpty(f.SourceMessageID), boolToInt(f.Pinned),
|
||||
nullIfEmpty(f.RelatedVulnerabilityID), f.UpdatedAt, f.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("更新事实失败: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
if f.ID == "" {
|
||||
f.ID = uuid.New().String()
|
||||
}
|
||||
f.CreatedAt = now
|
||||
f.UpdatedAt = now
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO project_facts (
|
||||
id, project_id, fact_key, category, summary, body, confidence,
|
||||
source_conversation_id, source_message_id, pinned, related_vulnerability_id,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
f.ID, f.ProjectID, f.FactKey, f.Category, f.Summary, f.Body, f.Confidence,
|
||||
nullIfEmpty(f.SourceConversationID), nullIfEmpty(f.SourceMessageID), boolToInt(f.Pinned),
|
||||
nullIfEmpty(f.RelatedVulnerabilityID),
|
||||
f.CreatedAt, f.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建事实失败: %w", err)
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// DeprecateProjectFact 将事实标记为 deprecated(关联边同步 deprecated)。
|
||||
func (db *DB) DeprecateProjectFact(projectID, factKey string) error {
|
||||
res, err := db.Exec(
|
||||
`UPDATE project_facts SET confidence = 'deprecated', updated_at = ? WHERE project_id = ? AND fact_key = ?`,
|
||||
time.Now(), projectID, factKey,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("事实不存在")
|
||||
}
|
||||
return db.DeprecateProjectFactEdgesForKey(projectID, factKey)
|
||||
}
|
||||
|
||||
// RestoreProjectFact 将已废弃事实恢复为 tentative 或 confirmed(重新参与黑板索引)。
|
||||
func (db *DB) RestoreProjectFact(projectID, factKey, confidence string) error {
|
||||
confidence = strings.TrimSpace(strings.ToLower(confidence))
|
||||
if confidence == "" {
|
||||
confidence = "tentative"
|
||||
}
|
||||
if confidence != "confirmed" && confidence != "tentative" {
|
||||
return fmt.Errorf("confidence 须为 confirmed 或 tentative")
|
||||
}
|
||||
|
||||
existing, err := db.GetProjectFactByKey(projectID, factKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("事实不存在")
|
||||
}
|
||||
if strings.ToLower(strings.TrimSpace(existing.Confidence)) != "deprecated" {
|
||||
return fmt.Errorf("事实未处于废弃状态")
|
||||
}
|
||||
|
||||
_, err = db.Exec(
|
||||
`UPDATE project_facts SET confidence = ?, updated_at = ? WHERE project_id = ? AND fact_key = ?`,
|
||||
confidence, time.Now(), projectID, factKey,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteProjectFact 删除事实(级联删除相关边)。
|
||||
func (db *DB) DeleteProjectFact(id string) error {
|
||||
f, err := db.GetProjectFact(id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := db.DeleteProjectFactEdgesForKey(f.ProjectID, f.FactKey); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.Exec(`DELETE FROM project_facts WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
func scanProjectFacts(rows *sql.Rows) ([]*ProjectFact, error) {
|
||||
var out []*ProjectFact
|
||||
for rows.Next() {
|
||||
f, err := scanProjectFactFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanProjectFactRow(row *sql.Row) (*ProjectFact, error) {
|
||||
var f ProjectFact
|
||||
var pinned int
|
||||
var createdAt, updatedAt string
|
||||
err := row.Scan(
|
||||
&f.ID, &f.ProjectID, &f.FactKey, &f.Category, &f.Summary, &f.Body, &f.Confidence,
|
||||
&f.SourceConversationID, &f.SourceMessageID, &pinned,
|
||||
&f.RelatedVulnerabilityID, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("事实不存在")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
f.Pinned = pinned != 0
|
||||
f.CreatedAt = parseDBTime(createdAt)
|
||||
f.UpdatedAt = parseDBTime(updatedAt)
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func scanProjectFactFromRows(rows *sql.Rows) (*ProjectFact, error) {
|
||||
var f ProjectFact
|
||||
var pinned int
|
||||
var createdAt, updatedAt string
|
||||
err := rows.Scan(
|
||||
&f.ID, &f.ProjectID, &f.FactKey, &f.Category, &f.Summary, &f.Body, &f.Confidence,
|
||||
&f.SourceConversationID, &f.SourceMessageID, &pinned,
|
||||
&f.RelatedVulnerabilityID, &createdAt, &updatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
f.Pinned = pinned != 0
|
||||
f.CreatedAt = parseDBTime(createdAt)
|
||||
f.UpdatedAt = parseDBTime(updatedAt)
|
||||
return &f, nil
|
||||
}
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func nullIfEmpty(s string) interface{} {
|
||||
if strings.TrimSpace(s) == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseDBTime(s string) time.Time {
|
||||
s = strings.TrimSpace(s)
|
||||
if s == "" {
|
||||
return time.Time{}
|
||||
}
|
||||
// go-sqlite3 读 DATETIME 常返回 RFC3339(含 T),写入时可能是空格分隔格式,需兼容多种形态
|
||||
layouts := []string{
|
||||
time.RFC3339Nano,
|
||||
time.RFC3339,
|
||||
"2006-01-02 15:04:05.999999999-07:00",
|
||||
"2006-01-02 15:04:05-07:00",
|
||||
"2006-01-02T15:04:05.999999999-07:00",
|
||||
"2006-01-02T15:04:05-07:00",
|
||||
"2006-01-02 15:04:05.999999999",
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02T15:04:05.999999999",
|
||||
"2006-01-02T15:04:05",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
if t, e := time.Parse(layout, s); e == nil {
|
||||
return t
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
@@ -1,112 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ProjectDashboardFact 仪表盘跨项目近期事实条目。
|
||||
type ProjectDashboardFact struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
ProjectName string `json:"project_name"`
|
||||
FactKey string `json:"fact_key"`
|
||||
Category string `json:"category"`
|
||||
Summary string `json:"summary"`
|
||||
Confidence string `json:"confidence"`
|
||||
Pinned bool `json:"pinned"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ProjectDashboardTotals 仪表盘项目事实汇总计数。
|
||||
type ProjectDashboardTotals struct {
|
||||
ActiveProjects int `json:"active_projects"`
|
||||
TotalFacts int `json:"total_facts"`
|
||||
}
|
||||
|
||||
// ProjectDashboardSummary 仪表盘项目情报摘要。
|
||||
type ProjectDashboardSummary struct {
|
||||
RecentFacts []ProjectDashboardFact `json:"recent_facts"`
|
||||
Totals ProjectDashboardTotals `json:"totals"`
|
||||
}
|
||||
|
||||
// GetProjectDashboardSummary 聚合跨项目近期事实(仅活跃项目、排除 deprecated)。
|
||||
func (db *DB) GetProjectDashboardSummary(factLimit int) (*ProjectDashboardSummary, error) {
|
||||
return db.GetProjectDashboardSummaryForAccess(factLimit, "", "")
|
||||
}
|
||||
|
||||
func (db *DB) GetProjectDashboardSummaryForAccess(factLimit int, userID, scope string) (*ProjectDashboardSummary, error) {
|
||||
if factLimit <= 0 {
|
||||
factLimit = 5
|
||||
}
|
||||
if factLimit > 50 {
|
||||
factLimit = 50
|
||||
}
|
||||
|
||||
out := &ProjectDashboardSummary{
|
||||
RecentFacts: []ProjectDashboardFact{},
|
||||
}
|
||||
|
||||
projectAccess := ""
|
||||
args := []interface{}{}
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID != "" && scope != RBACScopeAll {
|
||||
projectAccess = ` AND (
|
||||
p.owner_user_id = ?
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM rbac_resource_assignments ra
|
||||
WHERE ra.user_id = ? AND ra.resource_type = 'project' AND ra.resource_id = p.id
|
||||
)
|
||||
)`
|
||||
args = append(args, userID, userID)
|
||||
}
|
||||
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM projects p WHERE p.status = 'active'`+projectAccess, args...).Scan(&out.Totals.ActiveProjects); err != nil {
|
||||
return nil, fmt.Errorf("统计活跃项目失败: %w", err)
|
||||
}
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM project_facts f
|
||||
INNER JOIN projects p ON p.id = f.project_id
|
||||
WHERE f.confidence != 'deprecated' AND p.status = 'active'`+projectAccess,
|
||||
args...,
|
||||
).Scan(&out.Totals.TotalFacts); err != nil {
|
||||
return nil, fmt.Errorf("统计事实失败: %w", err)
|
||||
}
|
||||
|
||||
queryArgs := append([]interface{}{}, args...)
|
||||
queryArgs = append(queryArgs, factLimit)
|
||||
rows, err := db.Query(
|
||||
`SELECT f.id, f.project_id, p.name, f.fact_key, f.category, f.summary, f.confidence, f.pinned, f.updated_at
|
||||
FROM project_facts f
|
||||
INNER JOIN projects p ON p.id = f.project_id
|
||||
WHERE f.confidence != 'deprecated' AND p.status = 'active'`+projectAccess+`
|
||||
ORDER BY f.pinned DESC, f.updated_at DESC
|
||||
LIMIT ?`,
|
||||
queryArgs...,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询近期事实失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
for rows.Next() {
|
||||
var item ProjectDashboardFact
|
||||
var pinned int
|
||||
var updatedAt string
|
||||
if err := rows.Scan(
|
||||
&item.ID, &item.ProjectID, &item.ProjectName, &item.FactKey,
|
||||
&item.Category, &item.Summary, &item.Confidence, &pinned, &updatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
item.Pinned = pinned != 0
|
||||
item.ProjectName = strings.TrimSpace(item.ProjectName)
|
||||
item.UpdatedAt = parseDBTime(updatedAt)
|
||||
out.RecentFacts = append(out.RecentFacts, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
@@ -1,410 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// ValidProjectFactEdgeTypes 项目事实图允许的边类型。
|
||||
var ValidProjectFactEdgeTypes = map[string]struct{}{
|
||||
"depends_on": {},
|
||||
"leads_to": {},
|
||||
"enables": {},
|
||||
"exploits": {},
|
||||
"discovered_on": {},
|
||||
"contains": {},
|
||||
"part_of": {},
|
||||
"supports": {},
|
||||
}
|
||||
|
||||
// ProjectFactEdge 项目事实关系边(source → target)。
|
||||
type ProjectFactEdge struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id"`
|
||||
SourceFactKey string `json:"source_fact_key"`
|
||||
TargetFactKey string `json:"target_fact_key"`
|
||||
EdgeType string `json:"edge_type"`
|
||||
Confidence string `json:"confidence"` // confirmed | tentative | deprecated
|
||||
SourceConversationID string `json:"source_conversation_id,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// ProjectFactEdgeInput 写入边时的输入(出边:source → To)。
|
||||
type ProjectFactEdgeInput struct {
|
||||
To string `json:"to"`
|
||||
Type string `json:"type"`
|
||||
Confidence string `json:"confidence,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectFactEdgeFromInput 写入入边时的输入(From → 当前事实)。
|
||||
type ProjectFactEdgeFromInput struct {
|
||||
From string `json:"from"`
|
||||
Type string `json:"type"`
|
||||
Confidence string `json:"confidence,omitempty"`
|
||||
}
|
||||
|
||||
// ProjectFactGraphNode 图 API 节点。
|
||||
type ProjectFactGraphNode struct {
|
||||
ID string `json:"id"`
|
||||
FactKey string `json:"fact_key"`
|
||||
Category string `json:"category"`
|
||||
Label string `json:"label"` // 图节点短标签(截断)
|
||||
Summary string `json:"summary"` // 完整摘要(侧栏等详情用)
|
||||
Confidence string `json:"confidence"`
|
||||
Type string `json:"type"`
|
||||
Pinned bool `json:"pinned"`
|
||||
}
|
||||
|
||||
// ProjectFactGraphEdge 图 API 边。
|
||||
type ProjectFactGraphEdge struct {
|
||||
ID string `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Target string `json:"target"`
|
||||
Type string `json:"type"`
|
||||
Confidence string `json:"confidence"`
|
||||
}
|
||||
|
||||
// ProjectFactGraph 项目事实图。
|
||||
type ProjectFactGraph struct {
|
||||
Nodes []ProjectFactGraphNode `json:"nodes"`
|
||||
Edges []ProjectFactGraphEdge `json:"edges"`
|
||||
}
|
||||
|
||||
// ValidateProjectFactEdgeType 校验边类型。
|
||||
func ValidateProjectFactEdgeType(edgeType string) error {
|
||||
edgeType = strings.TrimSpace(strings.ToLower(edgeType))
|
||||
if edgeType == "" {
|
||||
return fmt.Errorf("edge type 不能为空")
|
||||
}
|
||||
if _, ok := ValidProjectFactEdgeTypes[edgeType]; !ok {
|
||||
return fmt.Errorf("无效的 edge type: %s", edgeType)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeEdgeConfidence(confidence string) string {
|
||||
confidence = strings.TrimSpace(strings.ToLower(confidence))
|
||||
switch confidence {
|
||||
case "confirmed", "deprecated":
|
||||
return confidence
|
||||
default:
|
||||
return "tentative"
|
||||
}
|
||||
}
|
||||
|
||||
// ListProjectFactEdgesByProject 列出项目全部边。
|
||||
func (db *DB) ListProjectFactEdgesByProject(projectID string) ([]*ProjectFactEdge, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT id, project_id, source_fact_key, target_fact_key, edge_type, confidence,
|
||||
COALESCE(source_conversation_id,''), created_at, updated_at
|
||||
FROM project_fact_edges
|
||||
WHERE project_id = ?
|
||||
ORDER BY created_at ASC, rowid ASC`,
|
||||
projectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProjectFactEdges(rows)
|
||||
}
|
||||
|
||||
// ListOutgoingProjectFactEdges 列出某事实的全部出边。
|
||||
func (db *DB) ListOutgoingProjectFactEdges(projectID, sourceFactKey string) ([]*ProjectFactEdge, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT id, project_id, source_fact_key, target_fact_key, edge_type, confidence,
|
||||
COALESCE(source_conversation_id,''), created_at, updated_at
|
||||
FROM project_fact_edges
|
||||
WHERE project_id = ? AND source_fact_key = ?
|
||||
ORDER BY created_at ASC, rowid ASC`,
|
||||
projectID, sourceFactKey,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProjectFactEdges(rows)
|
||||
}
|
||||
|
||||
// ListIncomingProjectFactEdges 列出某事实的全部入边。
|
||||
func (db *DB) ListIncomingProjectFactEdges(projectID, targetFactKey string) ([]*ProjectFactEdge, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT id, project_id, source_fact_key, target_fact_key, edge_type, confidence,
|
||||
COALESCE(source_conversation_id,''), created_at, updated_at
|
||||
FROM project_fact_edges
|
||||
WHERE project_id = ? AND target_fact_key = ?
|
||||
ORDER BY created_at ASC, rowid ASC`,
|
||||
projectID, targetFactKey,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanProjectFactEdges(rows)
|
||||
}
|
||||
|
||||
// ReplaceOutgoingProjectFactEdges 替换某事实的全部出边(links 省略时不调用)。
|
||||
func (db *DB) ReplaceOutgoingProjectFactEdges(projectID, sourceFactKey, sourceConversationID string, inputs []ProjectFactEdgeInput) error {
|
||||
sourceFactKey = strings.TrimSpace(sourceFactKey)
|
||||
if sourceFactKey == "" {
|
||||
return fmt.Errorf("source_fact_key 不能为空")
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
`DELETE FROM project_fact_edges WHERE project_id = ? AND source_fact_key = ?`,
|
||||
projectID, sourceFactKey,
|
||||
); err != nil {
|
||||
return fmt.Errorf("清除旧边失败: %w", err)
|
||||
}
|
||||
for _, in := range inputs {
|
||||
target := strings.TrimSpace(in.To)
|
||||
if target == "" {
|
||||
continue
|
||||
}
|
||||
if err := ValidateFactKey(target); err != nil {
|
||||
return fmt.Errorf("target fact_key 无效 (%s): %w", target, err)
|
||||
}
|
||||
if target == sourceFactKey {
|
||||
return fmt.Errorf("边不能指向自身: %s", sourceFactKey)
|
||||
}
|
||||
if err := ValidateProjectFactEdgeType(in.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
edge := &ProjectFactEdge{
|
||||
ID: uuid.New().String(),
|
||||
ProjectID: projectID,
|
||||
SourceFactKey: sourceFactKey,
|
||||
TargetFactKey: target,
|
||||
EdgeType: strings.ToLower(strings.TrimSpace(in.Type)),
|
||||
Confidence: normalizeEdgeConfidence(in.Confidence),
|
||||
SourceConversationID: sourceConversationID,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := db.insertProjectFactEdge(edge); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ReplaceIncomingProjectFactEdges 替换某事实的全部入边(From 为来源 fact_key)。
|
||||
func (db *DB) ReplaceIncomingProjectFactEdges(projectID, targetFactKey string, inputs []ProjectFactEdgeFromInput) error {
|
||||
targetFactKey = strings.TrimSpace(targetFactKey)
|
||||
if targetFactKey == "" {
|
||||
return fmt.Errorf("target_fact_key 不能为空")
|
||||
}
|
||||
if _, err := db.Exec(
|
||||
`DELETE FROM project_fact_edges WHERE project_id = ? AND target_fact_key = ?`,
|
||||
projectID, targetFactKey,
|
||||
); err != nil {
|
||||
return fmt.Errorf("清除旧入边失败: %w", err)
|
||||
}
|
||||
for _, in := range inputs {
|
||||
source := strings.TrimSpace(in.From)
|
||||
if source == "" {
|
||||
continue
|
||||
}
|
||||
if err := ValidateFactKey(source); err != nil {
|
||||
return fmt.Errorf("source fact_key 无效 (%s): %w", source, err)
|
||||
}
|
||||
if source == targetFactKey {
|
||||
return fmt.Errorf("边不能指向自身: %s", targetFactKey)
|
||||
}
|
||||
if err := ValidateProjectFactEdgeType(in.Type); err != nil {
|
||||
return err
|
||||
}
|
||||
sourceConversationID := ""
|
||||
if srcFact, err := db.GetProjectFactByKey(projectID, source); err == nil && srcFact != nil {
|
||||
sourceConversationID = srcFact.SourceConversationID
|
||||
}
|
||||
edge := &ProjectFactEdge{
|
||||
ID: uuid.New().String(),
|
||||
ProjectID: projectID,
|
||||
SourceFactKey: source,
|
||||
TargetFactKey: targetFactKey,
|
||||
EdgeType: strings.ToLower(strings.TrimSpace(in.Type)),
|
||||
Confidence: normalizeEdgeConfidence(in.Confidence),
|
||||
SourceConversationID: sourceConversationID,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
}
|
||||
if err := db.insertProjectFactEdge(edge); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetProjectFactEdge 按 ID 获取边。
|
||||
func (db *DB) GetProjectFactEdge(edgeID string) (*ProjectFactEdge, error) {
|
||||
var e ProjectFactEdge
|
||||
var createdAt, updatedAt string
|
||||
err := db.QueryRow(
|
||||
`SELECT id, project_id, source_fact_key, target_fact_key, edge_type, confidence,
|
||||
COALESCE(source_conversation_id,''), created_at, updated_at
|
||||
FROM project_fact_edges WHERE id = ?`, edgeID,
|
||||
).Scan(&e.ID, &e.ProjectID, &e.SourceFactKey, &e.TargetFactKey, &e.EdgeType, &e.Confidence,
|
||||
&e.SourceConversationID, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("边不存在")
|
||||
}
|
||||
e.CreatedAt = parseDBTime(createdAt)
|
||||
e.UpdatedAt = parseDBTime(updatedAt)
|
||||
return &e, nil
|
||||
}
|
||||
|
||||
// AddProjectFactEdge 新增单条边(已存在则更新 confidence)。
|
||||
func (db *DB) AddProjectFactEdge(projectID string, in ProjectFactEdgeInput, sourceFactKey, sourceConversationID string) (*ProjectFactEdge, error) {
|
||||
sourceFactKey = strings.TrimSpace(sourceFactKey)
|
||||
target := strings.TrimSpace(in.To)
|
||||
if sourceFactKey == "" || target == "" {
|
||||
return nil, fmt.Errorf("source 与 target 必填")
|
||||
}
|
||||
if sourceFactKey == target {
|
||||
return nil, fmt.Errorf("边不能指向自身")
|
||||
}
|
||||
if err := ValidateProjectFactEdgeType(in.Type); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ValidateFactKey(target); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
e := &ProjectFactEdge{
|
||||
ID: uuid.New().String(),
|
||||
ProjectID: projectID,
|
||||
SourceFactKey: sourceFactKey,
|
||||
TargetFactKey: target,
|
||||
EdgeType: strings.ToLower(strings.TrimSpace(in.Type)),
|
||||
Confidence: normalizeEdgeConfidence(in.Confidence),
|
||||
SourceConversationID: sourceConversationID,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
}
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO project_fact_edges (
|
||||
id, project_id, source_fact_key, target_fact_key, edge_type, confidence,
|
||||
source_conversation_id, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(project_id, source_fact_key, target_fact_key, edge_type)
|
||||
DO UPDATE SET confidence = excluded.confidence, updated_at = excluded.updated_at`,
|
||||
e.ID, e.ProjectID, e.SourceFactKey, e.TargetFactKey, e.EdgeType, e.Confidence,
|
||||
nullIfEmpty(e.SourceConversationID), e.CreatedAt, e.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("添加边失败: %w", err)
|
||||
}
|
||||
// 返回最新
|
||||
rows, err := db.Query(
|
||||
`SELECT id, project_id, source_fact_key, target_fact_key, edge_type, confidence,
|
||||
COALESCE(source_conversation_id,''), created_at, updated_at
|
||||
FROM project_fact_edges
|
||||
WHERE project_id = ? AND source_fact_key = ? AND target_fact_key = ? AND edge_type = ?`,
|
||||
projectID, sourceFactKey, target, e.EdgeType,
|
||||
)
|
||||
if err != nil {
|
||||
return e, nil
|
||||
}
|
||||
defer rows.Close()
|
||||
list, err := scanProjectFactEdges(rows)
|
||||
if err != nil || len(list) == 0 {
|
||||
return e, nil
|
||||
}
|
||||
return list[0], nil
|
||||
}
|
||||
|
||||
// DeleteProjectFactEdge 删除单条边。
|
||||
func (db *DB) DeleteProjectFactEdge(edgeID string) error {
|
||||
res, err := db.Exec(`DELETE FROM project_fact_edges WHERE id = ?`, edgeID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
n, _ := res.RowsAffected()
|
||||
if n == 0 {
|
||||
return fmt.Errorf("边不存在")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) insertProjectFactEdge(e *ProjectFactEdge) error {
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO project_fact_edges (
|
||||
id, project_id, source_fact_key, target_fact_key, edge_type, confidence,
|
||||
source_conversation_id, created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
e.ID, e.ProjectID, e.SourceFactKey, e.TargetFactKey, e.EdgeType, e.Confidence,
|
||||
nullIfEmpty(e.SourceConversationID), e.CreatedAt, e.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入边失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RenameProjectFactKeyEdges 事实 key 变更时同步边上的引用。
|
||||
func (db *DB) RenameProjectFactKeyEdges(projectID, oldKey, newKey string) error {
|
||||
oldKey = strings.TrimSpace(oldKey)
|
||||
newKey = strings.TrimSpace(newKey)
|
||||
if oldKey == "" || newKey == "" || oldKey == newKey {
|
||||
return nil
|
||||
}
|
||||
now := time.Now()
|
||||
if _, err := db.Exec(
|
||||
`UPDATE project_fact_edges SET source_fact_key = ?, updated_at = ?
|
||||
WHERE project_id = ? AND source_fact_key = ?`,
|
||||
newKey, now, projectID, oldKey,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := db.Exec(
|
||||
`UPDATE project_fact_edges SET target_fact_key = ?, updated_at = ?
|
||||
WHERE project_id = ? AND target_fact_key = ?`,
|
||||
newKey, now, projectID, oldKey,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteProjectFactEdgesForKey 删除与某 fact_key 相关的全部边。
|
||||
func (db *DB) DeleteProjectFactEdgesForKey(projectID, factKey string) error {
|
||||
_, err := db.Exec(
|
||||
`DELETE FROM project_fact_edges
|
||||
WHERE project_id = ? AND (source_fact_key = ? OR target_fact_key = ?)`,
|
||||
projectID, factKey, factKey,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeprecateProjectFactEdgesForKey 将关联边标记为 deprecated。
|
||||
func (db *DB) DeprecateProjectFactEdgesForKey(projectID, factKey string) error {
|
||||
now := time.Now()
|
||||
_, err := db.Exec(
|
||||
`UPDATE project_fact_edges SET confidence = 'deprecated', updated_at = ?
|
||||
WHERE project_id = ? AND (source_fact_key = ? OR target_fact_key = ?)
|
||||
AND confidence != 'deprecated'`,
|
||||
now, projectID, factKey, factKey,
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
func scanProjectFactEdges(rows *sql.Rows) ([]*ProjectFactEdge, error) {
|
||||
var out []*ProjectFactEdge
|
||||
for rows.Next() {
|
||||
var e ProjectFactEdge
|
||||
var createdAt, updatedAt string
|
||||
if err := rows.Scan(
|
||||
&e.ID, &e.ProjectID, &e.SourceFactKey, &e.TargetFactKey, &e.EdgeType, &e.Confidence,
|
||||
&e.SourceConversationID, &createdAt, &updatedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.CreatedAt = parseDBTime(createdAt)
|
||||
e.UpdatedAt = parseDBTime(updatedAt)
|
||||
out = append(out, &e)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestUpsertProjectFact_preservesBodyOnEmptyUpdate(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "facts.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
proj, err := db.CreateProject(&Project{Name: "test-facts"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const body = "## 攻击链\n1. step\n```http\nGET / HTTP/1.1\n```\n"
|
||||
_, err = db.UpsertProjectFact(&ProjectFact{
|
||||
ProjectID: proj.ID,
|
||||
FactKey: "finding/sqli-login",
|
||||
Category: "finding",
|
||||
Summary: "SQLi on /login",
|
||||
Body: body,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
updated, err := db.UpsertProjectFact(&ProjectFact{
|
||||
ProjectID: proj.ID,
|
||||
FactKey: "finding/sqli-login",
|
||||
Summary: "SQLi on /login (confirmed)",
|
||||
Body: "",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Summary != "SQLi on /login (confirmed)" {
|
||||
t.Fatalf("summary=%q", updated.Summary)
|
||||
}
|
||||
if updated.Body != body {
|
||||
t.Fatalf("returned body=%q want preserved attack chain", updated.Body)
|
||||
}
|
||||
|
||||
fromDB, err := db.GetProjectFactByKey(proj.ID, "finding/sqli-login")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fromDB.Body != body {
|
||||
t.Fatalf("stored body=%q want preserved", fromDB.Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertProjectFact_replacesBodyWhenProvided(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "facts.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
proj, err := db.CreateProject(&Project{Name: "test-facts"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, err = db.UpsertProjectFact(&ProjectFact{
|
||||
ProjectID: proj.ID,
|
||||
FactKey: "target/primary",
|
||||
Summary: "v1",
|
||||
Body: "old body",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
const newBody = "new body with evidence"
|
||||
updated, err := db.UpsertProjectFact(&ProjectFact{
|
||||
ProjectID: proj.ID,
|
||||
FactKey: "target/primary",
|
||||
Summary: "v2",
|
||||
Body: newBody,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Body != newBody {
|
||||
t.Fatalf("body=%q want %q", updated.Body, newBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRestoreProjectFact(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "facts.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
proj, err := db.CreateProject(&Project{Name: "restore-test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := "target/restore-me"
|
||||
_, err = db.UpsertProjectFact(&ProjectFact{
|
||||
ProjectID: proj.ID,
|
||||
FactKey: key,
|
||||
Summary: "s",
|
||||
Confidence: "confirmed",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.DeprecateProjectFact(proj.ID, key); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.RestoreProjectFact(proj.ID, key, "confirmed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
f, err := db.GetProjectFactByKey(proj.ID, key)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if f.Confidence != "confirmed" {
|
||||
t.Fatalf("confidence=%q want confirmed", f.Confidence)
|
||||
}
|
||||
if err := db.RestoreProjectFact(proj.ID, key, ""); err == nil {
|
||||
t.Fatal("expected error when not deprecated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMergeFactBodyOnUpdate(t *testing.T) {
|
||||
if got := mergeFactBodyOnUpdate("", "keep"); got != "keep" {
|
||||
t.Fatalf("empty incoming: got %q", got)
|
||||
}
|
||||
if got := mergeFactBodyOnUpdate(" ", "keep"); got != "keep" {
|
||||
t.Fatalf("whitespace incoming: got %q", got)
|
||||
}
|
||||
if got := mergeFactBodyOnUpdate("new", "old"); got != "new" {
|
||||
t.Fatalf("non-empty incoming: got %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestListProjectsSearchCaseInsensitive(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "projects-search.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
p1, err := db.CreateProject(&Project{Name: "Alpha Security Review", Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p2, err := db.CreateProject(&Project{Name: "beta-scan", Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.CreateProject(&Project{Name: "Other", Status: "archived"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
search string
|
||||
status string
|
||||
want []string
|
||||
}{
|
||||
{name: "case insensitive name", search: "alpha", status: "active", want: []string{p1.ID}},
|
||||
{name: "upper query", search: "BETA", status: "active", want: []string{p2.ID}},
|
||||
{name: "search by id substring", search: p1.ID[:8], status: "", want: []string{p1.ID}},
|
||||
{name: "status filter", search: "alpha", status: "archived", want: nil},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
list, err := db.ListProjects(tc.status, tc.search, 50, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := make([]string, 0, len(list))
|
||||
for _, p := range list {
|
||||
got = append(got, p.ID)
|
||||
}
|
||||
if len(got) != len(tc.want) {
|
||||
t.Fatalf("got %v want %v", got, tc.want)
|
||||
}
|
||||
for i := range got {
|
||||
if got[i] != tc.want[i] {
|
||||
t.Fatalf("got %v want %v", got, tc.want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestProjectListSearchPatternEscapesWildcards(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "projects-like.db")
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer db.Close()
|
||||
|
||||
p, err := db.CreateProject(&Project{Name: "100% coverage", Status: "active"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
list, err := db.ListProjects("active", "100%", 50, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) != 1 || list[0].ID != p.ID {
|
||||
t.Fatalf("expected exact match for literal %% query, got %#v", list)
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ProjectStats 项目聚合统计。
|
||||
type ProjectStats struct {
|
||||
FactCount int `json:"fact_count"`
|
||||
VulnCount int `json:"vuln_count"`
|
||||
ConversationCount int `json:"conversation_count"`
|
||||
SparseFactCount int `json:"sparse_fact_count"`
|
||||
}
|
||||
|
||||
// GetProjectStatsCounts 统计项目下事实、漏洞、对话数量(不含 sparse,由 project 包补全)。
|
||||
func (db *DB) GetProjectStatsCounts(projectID string) (*ProjectStats, error) {
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID == "" {
|
||||
return nil, fmt.Errorf("project_id 不能为空")
|
||||
}
|
||||
if _, err := db.GetProject(projectID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stats := &ProjectStats{}
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM project_facts WHERE project_id = ? AND confidence != 'deprecated'`,
|
||||
projectID,
|
||||
).Scan(&stats.FactCount); err != nil {
|
||||
return nil, fmt.Errorf("统计事实失败: %w", err)
|
||||
}
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM vulnerabilities WHERE project_id = ?`,
|
||||
projectID,
|
||||
).Scan(&stats.VulnCount); err != nil {
|
||||
return nil, fmt.Errorf("统计漏洞失败: %w", err)
|
||||
}
|
||||
if err := db.QueryRow(
|
||||
`SELECT COUNT(*) FROM conversations WHERE project_id = ?`,
|
||||
projectID,
|
||||
).Scan(&stats.ConversationCount); err != nil {
|
||||
return nil, fmt.Errorf("统计对话失败: %w", err)
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// ListProjectFactsForSparseCheck 返回用于待补全检测的事实字段(非 deprecated)。
|
||||
func (db *DB) ListProjectFactsForSparseCheck(projectID string) ([]struct {
|
||||
Category string
|
||||
FactKey string
|
||||
Body string
|
||||
}, error) {
|
||||
rows, err := db.Query(
|
||||
`SELECT category, fact_key, COALESCE(body,'') FROM project_facts WHERE project_id = ? AND confidence != 'deprecated'`,
|
||||
projectID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []struct {
|
||||
Category string
|
||||
FactKey string
|
||||
Body string
|
||||
}
|
||||
for rows.Next() {
|
||||
var row struct {
|
||||
Category string
|
||||
FactKey string
|
||||
Body string
|
||||
}
|
||||
if err := rows.Scan(&row.Category, &row.FactKey, &row.Body); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// ListConversationsByProjectID 列出绑定到项目的对话。
|
||||
func (db *DB) ListConversationsByProjectID(projectID string, limit, offset int) ([]*Conversation, error) {
|
||||
if limit <= 0 {
|
||||
limit = 100
|
||||
}
|
||||
rows, err := db.Query(
|
||||
`SELECT id, title, COALESCE(pinned, 0), created_at, updated_at, project_id, role_name
|
||||
FROM conversations WHERE project_id = ? ORDER BY updated_at DESC LIMIT ? OFFSET ?`,
|
||||
projectID, limit, offset,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询项目对话失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var conversations []*Conversation
|
||||
for rows.Next() {
|
||||
var conv Conversation
|
||||
var createdAt, updatedAt string
|
||||
var pinned int
|
||||
var pid sql.NullString
|
||||
var roleName sql.NullString
|
||||
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &pid, &roleName); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if pid.Valid {
|
||||
conv.ProjectID = strings.TrimSpace(pid.String)
|
||||
}
|
||||
if roleName.Valid {
|
||||
conv.RoleName = normalizeConversationRoleName(roleName.String)
|
||||
}
|
||||
conv.CreatedAt = parseDBTime(createdAt)
|
||||
conv.UpdatedAt = parseDBTime(updatedAt)
|
||||
conv.Pinned = pinned != 0
|
||||
conversations = append(conversations, &conv)
|
||||
}
|
||||
return conversations, rows.Err()
|
||||
}
|
||||
|
||||
// CountConversationsByProjectID 统计项目绑定对话数。
|
||||
func (db *DB) CountConversationsByProjectID(projectID string) (int, error) {
|
||||
var n int
|
||||
err := db.QueryRow(`SELECT COUNT(*) FROM conversations WHERE project_id = ?`, projectID).Scan(&n)
|
||||
return n, err
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestParseDBTime_projectFactFormats(t *testing.T) {
|
||||
cases := []string{
|
||||
"2026-05-26 11:13:07.442143+08:00",
|
||||
"2026-05-26 11:13:07",
|
||||
"2026-05-26T11:13:07.442143+08:00",
|
||||
}
|
||||
for _, s := range cases {
|
||||
got := parseDBTime(s)
|
||||
if got.IsZero() {
|
||||
t.Fatalf("parseDBTime(%q) returned zero", s)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestListProjectFacts_updatedAtJSON(t *testing.T) {
|
||||
root, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Skip(err)
|
||||
}
|
||||
dbPath := filepath.Join(root, "..", "..", "data", "conversations.db")
|
||||
if _, err := os.Stat(dbPath); err != nil {
|
||||
t.Skip("conversations.db not found")
|
||||
}
|
||||
db, err := NewDB(dbPath, zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
projects, err := db.ListProjects("", "", 1, 0)
|
||||
if err != nil || len(projects) == 0 {
|
||||
t.Skip("no projects")
|
||||
}
|
||||
pid := projects[0].ID
|
||||
|
||||
list, err := db.ListProjectFacts(pid, ProjectFactListFilter{}, 5, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(list) == 0 {
|
||||
t.Skip("no facts")
|
||||
}
|
||||
for _, f := range list {
|
||||
if f.UpdatedAt.IsZero() {
|
||||
t.Fatalf("fact %s UpdatedAt is zero after ListProjectFacts", f.FactKey)
|
||||
}
|
||||
b, err := json.Marshal(f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m map[string]interface{}
|
||||
if err := json.Unmarshal(b, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, ok := m["updated_at"].(string)
|
||||
if !ok || raw == "" || raw[:4] == "0001" {
|
||||
t.Fatalf("bad updated_at in JSON: %v", m["updated_at"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDBTime_zeroOnGarbage(t *testing.T) {
|
||||
if !parseDBTime("").IsZero() {
|
||||
t.Fatal("expected zero for empty")
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure RFC3339 round-trip used by API is after year 2000.
|
||||
func TestParseDBTime_marshalRoundTrip(t *testing.T) {
|
||||
s := "2026-05-26 11:13:07.442143+08:00"
|
||||
tm := parseDBTime(s)
|
||||
b, err := json.Marshal(tm)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var back time.Time
|
||||
if err := json.Unmarshal(b, &back); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if back.IsZero() {
|
||||
t.Fatalf("unmarshal zero from %s", string(b))
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,727 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/mcp"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func newRBACTestDB(t *testing.T) *DB {
|
||||
t.Helper()
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "rbac.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatalf("NewDB: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
return db
|
||||
}
|
||||
|
||||
func TestRBACToolExecutionOwnershipAccess(t *testing.T) {
|
||||
db := newRBACTestDB(t)
|
||||
for _, exec := range []*mcp.ToolExecution{
|
||||
{ID: "exec-u1", ToolName: "one", Status: "completed", StartTime: time.Now(), OwnerUserID: "u1"},
|
||||
{ID: "exec-u2", ToolName: "two", Status: "completed", StartTime: time.Now(), OwnerUserID: "u2"},
|
||||
{ID: "exec-legacy", ToolName: "legacy", Status: "completed", StartTime: time.Now()},
|
||||
} {
|
||||
if err := db.SaveToolExecution(exec); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
access := RBACListAccess{UserID: "u1", Scope: RBACScopeAssigned}
|
||||
rows, err := db.LoadToolExecutionListPageForAccess(0, 20, "", "", access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].ID != "exec-u1" {
|
||||
t.Fatalf("rows = %#v, want only exec-u1", rows)
|
||||
}
|
||||
summary, err := db.LoadToolStatsSummaryForAccess(10, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if summary.Summary.TotalCalls != 1 || summary.Summary.ToolCount != 1 || len(summary.TopTools) != 1 || summary.TopTools[0].ToolName != "one" {
|
||||
t.Fatalf("scoped summary = %#v", summary)
|
||||
}
|
||||
if !db.UserCanAccessToolExecution("u1", RBACScopeAssigned, "exec-u1") {
|
||||
t.Fatal("owner could not access execution")
|
||||
}
|
||||
if db.UserCanAccessToolExecution("u1", RBACScopeAssigned, "exec-u2") {
|
||||
t.Fatal("foreign execution was accessible")
|
||||
}
|
||||
if db.UserCanAccessToolExecution("u1", RBACScopeAssigned, "exec-legacy") {
|
||||
t.Fatal("ownerless legacy execution did not fail closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACGroupAndUploadOwnership(t *testing.T) {
|
||||
db := newRBACTestDB(t)
|
||||
group1, err := db.CreateGroup("u1 group", "", "u1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
group2, err := db.CreateGroup("u2 group", "", "u2")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
groups, err := db.ListGroupsForAccess("u1", RBACScopeAssigned)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(groups) != 1 || groups[0].ID != group1.ID {
|
||||
t.Fatalf("groups = %#v, want only %s (not %s)", groups, group1.ID, group2.ID)
|
||||
}
|
||||
if db.UserCanAccessGroup("u1", RBACScopeAssigned, group2.ID) {
|
||||
t.Fatal("foreign group was accessible")
|
||||
}
|
||||
|
||||
conversation, err := db.CreateConversation("upload", ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.UpsertChatUploadArtifact("2026-07-10/"+conversation.ID+"/a.txt", conversation.ID, "u1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if conv, owner, ok := db.GetChatUploadArtifact("2026-07-10/" + conversation.ID + "/a.txt"); !ok || conv != conversation.ID || owner != "u1" {
|
||||
t.Fatalf("artifact = conv=%q owner=%q ok=%v", conv, owner, ok)
|
||||
}
|
||||
if err := db.RenameChatUploadArtifactPath("2026-07-10/"+conversation.ID+"/a.txt", "2026-07-10/"+conversation.ID+"/b.txt"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, _, ok := db.GetChatUploadArtifact("2026-07-10/" + conversation.ID + "/b.txt"); !ok {
|
||||
t.Fatal("renamed artifact metadata missing")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemRoleBootstrapDoesNotLeakManagementReadPermissions(t *testing.T) {
|
||||
db := newRBACTestDB(t)
|
||||
catalog := map[string]string{
|
||||
"auth:self": "self", "project:read": "projects", "project:write": "project writes",
|
||||
"agent:local-execute": "local tools",
|
||||
"rbac:read": "rbac", "config:read": "config", "audit:read": "audit", "terminal:execute": "terminal",
|
||||
"mcp:execute": "invoke", "mcp:write": "manage", "mcp:external:execute": "external invoke",
|
||||
"workflow:execute": "run", "workflow:write": "manage definitions", "knowledge:write": "manage knowledge",
|
||||
}
|
||||
if err := db.BootstrapRBAC("hash", catalog); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
viewer, err := db.CreateRBACUser("viewer-policy", "Viewer", "hash", true, []string{RBACSystemRoleViewer})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
viewerAccess, err := db.ResolveRBACAccess(viewer.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !viewerAccess.Permissions["project:read"] || viewerAccess.Permissions["rbac:read"] || viewerAccess.Permissions["config:read"] || viewerAccess.Permissions["audit:read"] {
|
||||
t.Fatalf("unexpected viewer permissions: %#v", viewerAccess.Permissions)
|
||||
}
|
||||
auditor, err := db.CreateRBACUser("auditor-policy", "Auditor", "hash", true, []string{RBACSystemRoleAuditor})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
auditorAccess, err := db.ResolveRBACAccess(auditor.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !auditorAccess.Permissions["audit:read"] || auditorAccess.Permissions["config:read"] || auditorAccess.Permissions["rbac:read"] {
|
||||
t.Fatalf("unexpected auditor permissions: %#v", auditorAccess.Permissions)
|
||||
}
|
||||
operator, err := db.CreateRBACUser("operator-policy", "Operator", "hash", true, []string{RBACSystemRoleOperator})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
operatorAccess, err := db.ResolveRBACAccess(operator.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !operatorAccess.Permissions["mcp:execute"] || operatorAccess.Permissions["mcp:write"] || operatorAccess.Permissions["mcp:external:execute"] {
|
||||
t.Fatalf("unexpected operator MCP permissions: %#v", operatorAccess.Permissions)
|
||||
}
|
||||
if !operatorAccess.Permissions["workflow:execute"] || operatorAccess.Permissions["workflow:write"] || operatorAccess.Permissions["knowledge:write"] {
|
||||
t.Fatalf("operator received global definition mutation permissions: %#v", operatorAccess.Permissions)
|
||||
}
|
||||
if !operatorAccess.Permissions["agent:local-execute"] {
|
||||
t.Fatalf("operator is missing explicit local tool permission: %#v", operatorAccess.Permissions)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPermissionScopeDoesNotWidenAcrossUnrelatedRoles(t *testing.T) {
|
||||
db := newRBACTestDB(t)
|
||||
catalog := map[string]string{"auth:self": "self", "project:read": "read", "project:write": "write", "audit:read": "audit"}
|
||||
if err := db.BootstrapRBAC("hash", catalog); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ownWrite, err := db.UpsertRBACRole("", "own-writer", "", RBACScopeOwn, []string{"project:write"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, err := db.CreateRBACUser("mixed-scope", "Mixed", "hash", true, []string{RBACSystemRoleAuditor, ownWrite.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
access, err := db.ResolveRBACAccess(user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if access.Scope != RBACScopeAll {
|
||||
t.Fatalf("compatibility scope = %q, want all", access.Scope)
|
||||
}
|
||||
if got := access.PermissionScopes["project:read"]; got != RBACScopeAll {
|
||||
t.Fatalf("project:read scope = %q, want all", got)
|
||||
}
|
||||
if got := access.PermissionScopes["project:write"]; got != RBACScopeOwn {
|
||||
t.Fatalf("project:write scope widened to %q, want own", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleRejectsUnknownPermission(t *testing.T) {
|
||||
db := newRBACTestDB(t)
|
||||
if err := db.BootstrapRBAC("hash", map[string]string{"auth:self": "self"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.UpsertRBACRole("", "future-role", "", RBACScopeAssigned, []string{"future:permission"}); err == nil {
|
||||
t.Fatal("unknown permission was persisted")
|
||||
}
|
||||
if _, err := db.Exec(`INSERT INTO rbac_permissions (key, description, created_at) VALUES ('stale:permission', '', ?)`, time.Now()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.BootstrapRBAC("hash", map[string]string{"auth:self": "self"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var count int
|
||||
if err := db.QueryRow(`SELECT COUNT(*) FROM rbac_permissions WHERE key = 'stale:permission'`).Scan(&count); err != nil || count != 0 {
|
||||
t.Fatalf("stale permission survived bootstrap: count=%d err=%v", count, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACProjectAndConversationListAccess(t *testing.T) {
|
||||
db := newRBACTestDB(t)
|
||||
p1, _ := db.CreateProject(&Project{Name: "visible"})
|
||||
p2, _ := db.CreateProject(&Project{Name: "hidden"})
|
||||
if err := db.SetResourceOwner("project", p1.ID, "u1"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
c1, _ := db.CreateConversation("visible conv", ConversationCreateMeta{ProjectID: p1.ID})
|
||||
c2, _ := db.CreateConversation("hidden conv", ConversationCreateMeta{ProjectID: p2.ID})
|
||||
_ = db.SetResourceOwner("conversation", c1.ID, "u1")
|
||||
_ = db.SetResourceOwner("conversation", c2.ID, "u2")
|
||||
|
||||
projects, err := db.ListProjectsForAccess("", "", 50, 0, "u1", RBACScopeOwn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(projects) != 1 || projects[0].ID != p1.ID {
|
||||
t.Fatalf("projects = %#v, want only %s", projects, p1.ID)
|
||||
}
|
||||
|
||||
convs, err := db.ListConversationsForAccess(50, 0, "", "", "", "u1", RBACScopeOwn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(convs) != 1 || convs[0].ID != c1.ID {
|
||||
t.Fatalf("conversations = %#v, want only %s", convs, c1.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACVulnerabilityAccessInheritsProject(t *testing.T) {
|
||||
db := newRBACTestDB(t)
|
||||
user, err := db.CreateRBACUser("u1", "User 1", "hash", true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p1, _ := db.CreateProject(&Project{Name: "visible"})
|
||||
p2, _ := db.CreateProject(&Project{Name: "hidden"})
|
||||
if err := db.AssignResourceToUser(user.ID, "project", p1.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
v1, _ := db.CreateVulnerability(&Vulnerability{ProjectID: p1.ID, Title: "v1", Severity: "high"})
|
||||
v2, _ := db.CreateVulnerability(&Vulnerability{ProjectID: p2.ID, Title: "v2", Severity: "high"})
|
||||
|
||||
items, err := db.ListVulnerabilitiesForAccess(50, 0, VulnerabilityListFilter{}, RBACListAccess{UserID: user.ID, Scope: RBACScopeAssigned})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(items) != 1 || items[0].ID != v1.ID {
|
||||
t.Fatalf("vulnerabilities = %#v, want only %s; hidden %s", items, v1.ID, v2.ID)
|
||||
}
|
||||
if !db.UserCanAccessResource(user.ID, RBACScopeAssigned, "vulnerability", v1.ID) {
|
||||
t.Fatalf("expected project assignment to allow vulnerability detail")
|
||||
}
|
||||
if db.UserCanAccessResource(user.ID, RBACScopeAssigned, "vulnerability", v2.ID) {
|
||||
t.Fatalf("unexpected access to hidden vulnerability")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACConversationAccessInheritsProject(t *testing.T) {
|
||||
db := newRBACTestDB(t)
|
||||
user, err := db.CreateRBACUser("project-member", "Project Member", "hash", true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, err := db.CreateProject(&Project{Name: "assigned project"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversation, err := db.CreateConversation("project conversation", ConversationCreateMeta{ProjectID: project.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AssignResourceToUser(user.ID, "project", project.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
rows, err := db.ListConversationsForAccess(50, 0, "", "", "", user.ID, RBACScopeAssigned)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 || rows[0].ID != conversation.ID {
|
||||
t.Fatalf("conversations = %#v, want project conversation %s", rows, conversation.ID)
|
||||
}
|
||||
if !db.UserCanAccessResource(user.ID, RBACScopeAssigned, "conversation", conversation.ID) {
|
||||
t.Fatal("expected project assignment to allow conversation detail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACBatchResourceAssignmentValidationAndAtomicity(t *testing.T) {
|
||||
db := newRBACTestDB(t)
|
||||
user, err := db.CreateRBACUser("batch-member", "Batch Member", "hash", true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p1, err := db.CreateProject(&Project{Name: "p1"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p2, err := db.CreateProject(&Project{Name: "p2"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
p3, err := db.CreateProject(&Project{Name: "p3"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
options, err := db.ListAssignableRBACResources("project", "p1", 50)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(options) != 1 || options[0].ID != p1.ID || options[0].Label != "p1" {
|
||||
t.Fatalf("resource options = %#v, want p1", options)
|
||||
}
|
||||
firstPage, err := db.ListAssignableRBACResourcesPage("project", "", 2, 0)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
secondPage, err := db.ListAssignableRBACResourcesPage("project", "", 2, 2)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(firstPage) != 2 || len(secondPage) != 1 {
|
||||
t.Fatalf("paged resource options = %d + %d, want 2 + 1", len(firstPage), len(secondPage))
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, option := range append(firstPage, secondPage...) {
|
||||
seen[option.ID] = true
|
||||
}
|
||||
if !seen[p1.ID] || !seen[p2.ID] || !seen[p3.ID] {
|
||||
t.Fatalf("paged resource options missed resources: %#v", seen)
|
||||
}
|
||||
if _, err := db.ListAssignableRBACResources("secret_table", "", 50); err == nil {
|
||||
t.Fatal("expected unsupported picker resource type to fail")
|
||||
}
|
||||
|
||||
if _, err := db.AssignResourcesToUser(user.ID, "unknown_type", []string{p1.ID}); err == nil {
|
||||
t.Fatal("expected unsupported resource type to fail")
|
||||
}
|
||||
if _, err := db.AssignResourcesToUser(user.ID, "project", []string{p1.ID, "missing-project"}); err == nil {
|
||||
t.Fatal("expected missing resource to fail the entire batch")
|
||||
}
|
||||
rows, err := db.ListRBACResourceAssignments(user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 0 {
|
||||
t.Fatalf("partial grants persisted after failed batch: %#v", rows)
|
||||
}
|
||||
|
||||
created, err := db.AssignResourcesToUser(user.ID, "project", []string{p1.ID, p1.ID, p2.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created != 2 {
|
||||
t.Fatalf("created = %d, want 2 unique grants", created)
|
||||
}
|
||||
created, err = db.AssignResourcesToUser(user.ID, "project", []string{p1.ID, p2.ID})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if created != 0 {
|
||||
t.Fatalf("idempotent retry created = %d, want 0", created)
|
||||
}
|
||||
rows, err = db.ListRBACResourceAssignments(user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 2 {
|
||||
t.Fatalf("assignment count = %d, want 2", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACWebshellAndBatchListAccess(t *testing.T) {
|
||||
db := newRBACTestDB(t)
|
||||
ws1 := WebShellConnection{ID: "ws_visible", ProjectID: "p1", URL: "http://a", Type: "php", Method: "post", CreatedAt: time.Now()}
|
||||
ws2 := WebShellConnection{ID: "ws_hidden", ProjectID: "p2", URL: "http://b", Type: "php", Method: "post", CreatedAt: time.Now()}
|
||||
ws3 := WebShellConnection{ID: "ws_other_project", ProjectID: "p2", URL: "http://c", Type: "php", Method: "post", CreatedAt: time.Now()}
|
||||
ws4 := WebShellConnection{ID: "ws_unbound", URL: "http://d", Type: "php", Method: "post", CreatedAt: time.Now()}
|
||||
if err := db.CreateWebshellConnection(&ws1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateWebshellConnection(&ws2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateWebshellConnection(&ws3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateWebshellConnection(&ws4); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = db.SetResourceOwner("webshell", ws1.ID, "u1")
|
||||
_ = db.SetResourceOwner("webshell", ws2.ID, "u2")
|
||||
_ = db.SetResourceOwner("webshell", ws3.ID, "u1")
|
||||
_ = db.SetResourceOwner("webshell", ws4.ID, "u1")
|
||||
webshells, err := db.ListWebshellConnectionsForAccess("u1", RBACScopeOwn, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(webshells) != 3 {
|
||||
t.Fatalf("webshells = %#v, want 3 owned webshells including unbound", webshells)
|
||||
}
|
||||
webshells, err = db.ListWebshellConnectionsForAccess("u1", RBACScopeOwn, "p1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(webshells) != 1 || webshells[0].ID != ws1.ID {
|
||||
t.Fatalf("webshells scoped to p1 = %#v, want only %s", webshells, ws1.ID)
|
||||
}
|
||||
webshells, err = db.ListWebshellConnectionsForAccess("u1", RBACScopeOwn, ProjectFilterUnbound)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(webshells) != 1 || webshells[0].ID != ws4.ID {
|
||||
t.Fatalf("unbound webshells = %#v, want only %s", webshells, ws4.ID)
|
||||
}
|
||||
|
||||
if err := db.CreateBatchQueue("q_visible", "visible", "", "eino_single", "manual", "", nil, "", 1, []map[string]interface{}{{"id": "t1", "message": "a"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateBatchQueue("q_hidden", "hidden", "", "eino_single", "manual", "", nil, "", 1, []map[string]interface{}{{"id": "t2", "message": "b"}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_ = db.SetResourceOwner("batch_task", "q_visible", "u1")
|
||||
_ = db.SetResourceOwner("batch_task", "q_hidden", "u2")
|
||||
queues, err := db.ListBatchQueuesForAccess(50, 0, "all", "", "u1", RBACScopeOwn)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(queues) != 1 || queues[0].ID != "q_visible" {
|
||||
t.Fatalf("queues = %#v, want only q_visible", queues)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACC2AccessInheritsListener(t *testing.T) {
|
||||
db := newRBACTestDB(t)
|
||||
now := time.Now()
|
||||
l1 := &C2Listener{ID: "l_visible", ProjectID: "p1", Name: "visible", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9001, OwnerUserID: "u1", CreatedAt: now}
|
||||
l2 := &C2Listener{ID: "l_hidden", ProjectID: "p2", Name: "hidden", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9002, OwnerUserID: "u2", CreatedAt: now}
|
||||
l3 := &C2Listener{ID: "l_other_project", ProjectID: "p2", Name: "other project", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9003, OwnerUserID: "u1", CreatedAt: now}
|
||||
l4 := &C2Listener{ID: "l_unbound", Name: "unbound", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9004, OwnerUserID: "u1", CreatedAt: now}
|
||||
if err := db.CreateC2Listener(l1); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Listener(l2); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Listener(l3); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Listener(l4); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.UpsertC2Session(&C2Session{ID: "s_visible", ListenerID: l1.ID, ImplantUUID: "implant-visible", Status: "active", FirstSeenAt: now, LastCheckIn: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.UpsertC2Session(&C2Session{ID: "s_hidden", ListenerID: l2.ID, ImplantUUID: "implant-hidden", Status: "active", FirstSeenAt: now, LastCheckIn: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.UpsertC2Session(&C2Session{ID: "s_other_project", ListenerID: l3.ID, ImplantUUID: "implant-other-project", Status: "active", FirstSeenAt: now, LastCheckIn: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.UpsertC2Session(&C2Session{ID: "s_unbound", ListenerID: l4.ID, ImplantUUID: "implant-unbound", Status: "active", FirstSeenAt: now, LastCheckIn: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Task(&C2Task{ID: "t_visible", SessionID: "s_visible", TaskType: "shell", Status: "queued", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Task(&C2Task{ID: "t_hidden", SessionID: "s_hidden", TaskType: "shell", Status: "queued", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Task(&C2Task{ID: "t_other_project", SessionID: "s_other_project", TaskType: "shell", Status: "queued", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Task(&C2Task{ID: "t_unbound", SessionID: "s_unbound", TaskType: "shell", Status: "queued", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AppendC2Event(&C2Event{ID: "e_visible", Level: "info", Category: "task", SessionID: "s_visible", TaskID: "t_visible", Message: "visible", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AppendC2Event(&C2Event{ID: "e_hidden", Level: "info", Category: "task", SessionID: "s_hidden", TaskID: "t_hidden", Message: "hidden", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AppendC2Event(&C2Event{ID: "e_other_project", Level: "info", Category: "task", SessionID: "s_other_project", TaskID: "t_other_project", Message: "other project", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AppendC2Event(&C2Event{ID: "e_unbound", Level: "info", Category: "task", SessionID: "s_unbound", TaskID: "t_unbound", Message: "unbound", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
access := RBACListAccess{UserID: "u1", Scope: RBACScopeOwn}
|
||||
listeners, err := db.ListC2ListenersForAccess(access, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listeners) != 3 {
|
||||
t.Fatalf("listeners = %#v, want 3 owned listeners including unbound", listeners)
|
||||
}
|
||||
listeners, err = db.ListC2ListenersForAccess(access, "p1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listeners) != 1 || listeners[0].ID != l1.ID {
|
||||
t.Fatalf("listeners scoped to p1 = %#v, want only %s", listeners, l1.ID)
|
||||
}
|
||||
listeners, err = db.ListC2ListenersForAccess(access, ProjectFilterUnbound)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(listeners) != 1 || listeners[0].ID != l4.ID {
|
||||
t.Fatalf("unbound listeners = %#v, want only %s", listeners, l4.ID)
|
||||
}
|
||||
sessions, err := db.ListC2SessionsForAccess(ListC2SessionsFilter{}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sessions) != 3 {
|
||||
t.Fatalf("sessions = %#v, want 3 owned sessions including unbound", sessions)
|
||||
}
|
||||
sessions, err = db.ListC2SessionsForAccess(ListC2SessionsFilter{ProjectID: "p1"}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sessions) != 1 || sessions[0].ID != "s_visible" {
|
||||
t.Fatalf("sessions scoped to p1 = %#v, want only s_visible", sessions)
|
||||
}
|
||||
sessions, err = db.ListC2SessionsForAccess(ListC2SessionsFilter{ProjectID: ProjectFilterUnbound}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(sessions) != 1 || sessions[0].ID != "s_unbound" {
|
||||
t.Fatalf("unbound sessions = %#v, want only s_unbound", sessions)
|
||||
}
|
||||
tasks, err := db.ListC2TasksForAccess(ListC2TasksFilter{}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tasks) != 3 {
|
||||
t.Fatalf("tasks = %#v, want 3 owned tasks including unbound", tasks)
|
||||
}
|
||||
tasks, err = db.ListC2TasksForAccess(ListC2TasksFilter{ProjectID: "p1"}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tasks) != 1 || tasks[0].ID != "t_visible" {
|
||||
t.Fatalf("tasks scoped to p1 = %#v, want only t_visible", tasks)
|
||||
}
|
||||
tasks, err = db.ListC2TasksForAccess(ListC2TasksFilter{ProjectID: ProjectFilterUnbound}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(tasks) != 1 || tasks[0].ID != "t_unbound" {
|
||||
t.Fatalf("unbound tasks = %#v, want only t_unbound", tasks)
|
||||
}
|
||||
events, err := db.ListC2EventsForAccess(ListC2EventsFilter{}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 3 {
|
||||
t.Fatalf("events = %#v, want 3 owned events including unbound", events)
|
||||
}
|
||||
events, err = db.ListC2EventsForAccess(ListC2EventsFilter{ProjectID: "p1"}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 1 || events[0].ID != "e_visible" {
|
||||
t.Fatalf("events scoped to p1 = %#v, want only e_visible", events)
|
||||
}
|
||||
events, err = db.ListC2EventsForAccess(ListC2EventsFilter{ProjectID: ProjectFilterUnbound}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(events) != 1 || events[0].ID != "e_unbound" {
|
||||
t.Fatalf("unbound events = %#v, want only e_unbound", events)
|
||||
}
|
||||
if !db.UserCanAccessResource("u1", RBACScopeOwn, "c2_task", "t_visible") {
|
||||
t.Fatalf("expected listener ownership to allow task detail")
|
||||
}
|
||||
if db.UserCanAccessResource("u1", RBACScopeOwn, "c2_task", "t_hidden") {
|
||||
t.Fatalf("unexpected access to hidden task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACC2AssignedDeleteIsScoped(t *testing.T) {
|
||||
db := newRBACTestDB(t)
|
||||
user, err := db.CreateRBACUser("u1", "User 1", "hash", true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
now := time.Now()
|
||||
if err := db.CreateC2Listener(&C2Listener{ID: "l_assigned", Name: "assigned", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9001, CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Listener(&C2Listener{ID: "l_hidden", Name: "hidden", Type: "http_beacon", BindHost: "127.0.0.1", BindPort: 9002, CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AssignResourceToUser(user.ID, "c2_listener", "l_assigned"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, row := range []struct {
|
||||
sessionID string
|
||||
listener string
|
||||
taskID string
|
||||
eventID string
|
||||
}{
|
||||
{"s_assigned", "l_assigned", "t_assigned", "e_assigned"},
|
||||
{"s_hidden", "l_hidden", "t_hidden", "e_hidden"},
|
||||
} {
|
||||
if err := db.UpsertC2Session(&C2Session{ID: row.sessionID, ListenerID: row.listener, ImplantUUID: row.sessionID + "_uuid", Status: "active", FirstSeenAt: now, LastCheckIn: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateC2Task(&C2Task{ID: row.taskID, SessionID: row.sessionID, TaskType: "shell", Status: "queued", CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.AppendC2Event(&C2Event{ID: row.eventID, Level: "info", Category: "task", SessionID: row.sessionID, TaskID: row.taskID, Message: row.eventID, CreatedAt: now}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
access := RBACListAccess{UserID: user.ID, Scope: RBACScopeAssigned}
|
||||
n, err := db.DeleteC2TasksByIDsForAccess([]string{"t_assigned", "t_hidden"}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("deleted tasks = %d, want 1", n)
|
||||
}
|
||||
if task, _ := db.GetC2Task("t_hidden"); task == nil {
|
||||
t.Fatalf("hidden task was deleted")
|
||||
}
|
||||
n, err = db.DeleteC2EventsByIDsForAccess([]string{"e_assigned", "e_hidden"}, access)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Fatalf("deleted events = %d, want 1", n)
|
||||
}
|
||||
hiddenEvents, err := db.ListC2Events(ListC2EventsFilter{TaskID: "t_hidden"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hiddenEvents) != 1 {
|
||||
t.Fatalf("hidden event count = %d, want 1", len(hiddenEvents))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRBACAssignmentLabelsAndWeakTitles(t *testing.T) {
|
||||
db := newRBACTestDB(t)
|
||||
user, err := db.CreateRBACUser("label-member", "Label Member", "hash", true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, err := db.CreateProject(&Project{Name: "Alpha Project"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
conversation, err := db.CreateConversation("1", ConversationCreateMeta{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.AssignResourcesToUser(user.ID, "project", []string{project.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
options, err := db.ListAssignableRBACResources("conversation", "", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(options) == 0 {
|
||||
t.Fatal("expected conversation options")
|
||||
}
|
||||
for _, option := range options {
|
||||
if option.ID == conversation.ID && !strings.Contains(option.Label, "1 ·") {
|
||||
t.Fatalf("weak conversation label = %q, want suffix with short id", option.Label)
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := db.ListRBACResourceAssignments(user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("assignments = %#v, want 1", rows)
|
||||
}
|
||||
if rows[0].ResourceLabel != "Alpha Project" {
|
||||
t.Fatalf("assignment label = %q, want Alpha Project", rows[0].ResourceLabel)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteRBACResourceAssignmentWithDetails(t *testing.T) {
|
||||
db := newRBACTestDB(t)
|
||||
user, err := db.CreateRBACUser("revoke-member", "Revoke Member", "hash", true, nil)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
project, err := db.CreateProject(&Project{Name: "Revoked Project"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.AssignResourcesToUser(user.ID, "project", []string{project.ID}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rows, err := db.ListRBACResourceAssignments(user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("assignments = %#v, want 1", rows)
|
||||
}
|
||||
|
||||
deleted, err := db.DeleteRBACResourceAssignmentWithDetails(rows[0].ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if deleted.ID != rows[0].ID || deleted.UserID != user.ID || deleted.ResourceType != "project" || deleted.ResourceID != project.ID {
|
||||
t.Fatalf("deleted assignment = %#v", deleted)
|
||||
}
|
||||
remaining, err := db.ListRBACResourceAssignments(user.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(remaining) != 0 {
|
||||
t.Fatalf("remaining assignments = %#v, want none", remaining)
|
||||
}
|
||||
if _, err := db.DeleteRBACResourceAssignmentWithDetails(rows[0].ID); err == nil {
|
||||
t.Fatal("second delete unexpectedly succeeded")
|
||||
}
|
||||
}
|
||||
@@ -1,174 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// RobotUserBinding maps one tenant-scoped platform identity to one RBAC user.
|
||||
// external_user_id must be derived from the verified platform event, never
|
||||
// from user-controlled message content.
|
||||
type RobotUserBinding struct {
|
||||
ID string `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
ExternalUserID string `json:"externalUserId"`
|
||||
RBACUserID string `json:"rbacUserId"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
func normalizeRobotIdentity(platform, externalUserID string) (string, string, error) {
|
||||
platform = strings.ToLower(strings.TrimSpace(platform))
|
||||
externalUserID = strings.TrimSpace(externalUserID)
|
||||
if platform == "" || externalUserID == "" {
|
||||
return "", "", fmt.Errorf("robot platform and external user identity are required")
|
||||
}
|
||||
return platform, externalUserID, nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateRobotBindingCode(userID, codeHash string, expiresAt time.Time) error {
|
||||
userID = strings.TrimSpace(userID)
|
||||
codeHash = strings.TrimSpace(codeHash)
|
||||
if userID == "" || codeHash == "" || !expiresAt.After(time.Now()) {
|
||||
return fmt.Errorf("invalid robot binding code")
|
||||
}
|
||||
now := time.Now()
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
// Keep only the newest active code per user and remove expired/used secrets.
|
||||
if _, err = tx.Exec(`DELETE FROM robot_binding_codes WHERE rbac_user_id = ? OR expires_at <= ? OR used_at IS NOT NULL`, userID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err = tx.Exec(`INSERT INTO robot_binding_codes (code_hash, rbac_user_id, expires_at, created_at) VALUES (?, ?, ?, ?)`, codeHash, userID, expiresAt, now); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
// ConsumeRobotBindingCode atomically consumes a single-use code and binds the
|
||||
// verified platform identity. Existing bindings are deliberately replaced so
|
||||
// users can recover from stale or incorrect associations with a fresh code.
|
||||
func (db *DB) ConsumeRobotBindingCode(platform, externalUserID, codeHash string) (*RBACUser, error) {
|
||||
platform, externalUserID, err := normalizeRobotIdentity(platform, externalUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
codeHash = strings.TrimSpace(codeHash)
|
||||
if codeHash == "" {
|
||||
return nil, fmt.Errorf("binding code is required")
|
||||
}
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
var userID string
|
||||
now := time.Now()
|
||||
if err = tx.QueryRow(`
|
||||
SELECT c.rbac_user_id
|
||||
FROM robot_binding_codes c
|
||||
JOIN rbac_users u ON u.id = c.rbac_user_id
|
||||
WHERE c.code_hash = ? AND c.used_at IS NULL AND c.expires_at > ? AND u.enabled = 1
|
||||
`, codeHash, now).Scan(&userID); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("binding code is invalid or expired")
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
result, err := tx.Exec(`UPDATE robot_binding_codes SET used_at = ? WHERE code_hash = ? AND used_at IS NULL`, now, codeHash)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return nil, fmt.Errorf("binding code has already been used")
|
||||
}
|
||||
if _, err = tx.Exec(`
|
||||
INSERT INTO robot_user_bindings (id, platform, external_user_id, rbac_user_id, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 1, ?, ?)
|
||||
ON CONFLICT(platform, external_user_id) DO UPDATE SET
|
||||
rbac_user_id = excluded.rbac_user_id,
|
||||
enabled = 1,
|
||||
updated_at = excluded.updated_at
|
||||
`, uuid.New().String(), platform, externalUserID, userID, now, now); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db.GetRBACUserByID(userID)
|
||||
}
|
||||
|
||||
func (db *DB) ResolveRobotRBACAccess(platform, externalUserID string) (*RBACAccess, error) {
|
||||
platform, externalUserID, err := normalizeRobotIdentity(platform, externalUserID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var userID string
|
||||
err = db.QueryRow(`
|
||||
SELECT b.rbac_user_id
|
||||
FROM robot_user_bindings b
|
||||
JOIN rbac_users u ON u.id = b.rbac_user_id
|
||||
WHERE b.platform = ? AND b.external_user_id = ? AND b.enabled = 1 AND u.enabled = 1
|
||||
`, platform, externalUserID).Scan(&userID)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("robot identity is not bound")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db.ResolveRBACAccess(userID)
|
||||
}
|
||||
|
||||
func (db *DB) ListRobotUserBindings(userID string) ([]RobotUserBinding, error) {
|
||||
rows, err := db.Query(`
|
||||
SELECT id, platform, external_user_id, rbac_user_id, enabled, created_at, updated_at
|
||||
FROM robot_user_bindings WHERE rbac_user_id = ? ORDER BY updated_at DESC
|
||||
`, strings.TrimSpace(userID))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []RobotUserBinding
|
||||
for rows.Next() {
|
||||
var b RobotUserBinding
|
||||
var enabled int
|
||||
var createdAt, updatedAt string
|
||||
if err := rows.Scan(&b.ID, &b.Platform, &b.ExternalUserID, &b.RBACUserID, &enabled, &createdAt, &updatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
b.Enabled = enabled != 0
|
||||
b.CreatedAt = parseDBTime(createdAt)
|
||||
b.UpdatedAt = parseDBTime(updatedAt)
|
||||
out = append(out, b)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) DeleteRobotUserBindingForUser(bindingID, userID string) error {
|
||||
result, err := db.Exec(`DELETE FROM robot_user_bindings WHERE id = ? AND rbac_user_id = ?`, strings.TrimSpace(bindingID), strings.TrimSpace(userID))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if affected, _ := result.RowsAffected(); affected != 1 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) DeleteRobotIdentityBinding(platform, externalUserID string) error {
|
||||
platform, externalUserID, err := normalizeRobotIdentity(platform, externalUserID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = db.Exec(`DELETE FROM robot_user_bindings WHERE platform = ? AND external_user_id = ?`, platform, externalUserID)
|
||||
return err
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/security"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestRobotBindingCodeIsSingleUseAndPermissionsAreResolvedLive(t *testing.T) {
|
||||
db, err := database.NewDB(t.TempDir()+"/robot-identity.db", zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := db.BootstrapRBAC("hash", security.PermissionCatalog); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
user, err := db.CreateRBACUser("bound-user", "Bound User", "hash", true, []string{database.RBACSystemRoleOperator})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.CreateRobotBindingCode(user.ID, "code-hash", time.Now().Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bound, err := db.ConsumeRobotBindingCode("LARK", "t:tenant|u:user", "code-hash")
|
||||
if err != nil || bound.ID != user.ID {
|
||||
t.Fatalf("consume binding code: user=%v err=%v", bound, err)
|
||||
}
|
||||
if _, err := db.ConsumeRobotBindingCode("lark", "t:tenant|u:other", "code-hash"); err == nil {
|
||||
t.Fatal("single-use binding code was accepted twice")
|
||||
}
|
||||
access, err := db.ResolveRobotRBACAccess("lark", "t:tenant|u:user")
|
||||
if err != nil || !access.Permissions["agent:execute"] {
|
||||
t.Fatalf("resolved access does not include live role permissions: %#v err=%v", access, err)
|
||||
}
|
||||
disabled := false
|
||||
if err := db.UpdateRBACUser(user.ID, user.DisplayName, &disabled, nil); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ResolveRobotRBACAccess("lark", "t:tenant|u:user"); err == nil {
|
||||
t.Fatal("disabled RBAC user retained robot access")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRobotBindingCodeExpiryAndOwnerScopedRevocation(t *testing.T) {
|
||||
db, err := database.NewDB(t.TempDir()+"/robot-revoke.db", zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := db.BootstrapRBAC("hash", security.PermissionCatalog); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
u1, _ := db.CreateRBACUser("binding-owner", "Owner", "hash", true, nil)
|
||||
u2, _ := db.CreateRBACUser("binding-other", "Other", "hash", true, nil)
|
||||
now := time.Now()
|
||||
if _, err := db.Exec(`INSERT INTO robot_binding_codes (code_hash, rbac_user_id, expires_at, created_at) VALUES (?, ?, ?, ?)`, "expired-hash", u1.ID, now.Add(-time.Minute), now.Add(-2*time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ConsumeRobotBindingCode("wecom", "t:corp|u:expired", "expired-hash"); err == nil {
|
||||
t.Fatal("expired binding code was accepted")
|
||||
}
|
||||
if err := db.CreateRobotBindingCode(u1.ID, "valid-hash", time.Now().Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ConsumeRobotBindingCode("wecom", "t:corp|u:one", "valid-hash"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
bindings, err := db.ListRobotUserBindings(u1.ID)
|
||||
if err != nil || len(bindings) != 1 {
|
||||
t.Fatalf("bindings=%v err=%v", bindings, err)
|
||||
}
|
||||
if err := db.DeleteRobotUserBindingForUser(bindings[0].ID, u2.ID); err == nil {
|
||||
t.Fatal("another user revoked a binding they do not own")
|
||||
}
|
||||
if _, err := db.ResolveRobotRBACAccess("wecom", "t:corp|u:one"); err != nil {
|
||||
t.Fatalf("unauthorized revocation changed binding: %v", err)
|
||||
}
|
||||
if err := db.DeleteRobotUserBindingForUser(bindings[0].ID, u1.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ResolveRobotRBACAccess("wecom", "t:corp|u:one"); err == nil {
|
||||
t.Fatal("revoked binding still resolves")
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// RobotSessionBinding 机器人会话绑定信息。
|
||||
type RobotSessionBinding struct {
|
||||
SessionKey string
|
||||
ConversationID string
|
||||
RoleName string
|
||||
AgentMode string
|
||||
UpdatedAt time.Time
|
||||
}
|
||||
|
||||
// GetRobotSessionBinding 按 session_key 获取机器人会话绑定。
|
||||
func (db *DB) GetRobotSessionBinding(sessionKey string) (*RobotSessionBinding, error) {
|
||||
sessionKey = strings.TrimSpace(sessionKey)
|
||||
if sessionKey == "" {
|
||||
return nil, nil
|
||||
}
|
||||
var b RobotSessionBinding
|
||||
var updatedAt string
|
||||
err := db.QueryRow(
|
||||
"SELECT session_key, conversation_id, role_name, agent_mode, updated_at FROM robot_user_sessions WHERE session_key = ?",
|
||||
sessionKey,
|
||||
).Scan(&b.SessionKey, &b.ConversationID, &b.RoleName, &b.AgentMode, &updatedAt)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, fmt.Errorf("查询机器人会话绑定失败: %w", err)
|
||||
}
|
||||
if t, e := time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt); e == nil {
|
||||
b.UpdatedAt = t
|
||||
} else if t, e := time.Parse("2006-01-02 15:04:05", updatedAt); e == nil {
|
||||
b.UpdatedAt = t
|
||||
} else {
|
||||
b.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||
}
|
||||
if strings.TrimSpace(b.RoleName) == "" {
|
||||
b.RoleName = "默认"
|
||||
}
|
||||
if strings.TrimSpace(b.AgentMode) == "" {
|
||||
b.AgentMode = "eino_single"
|
||||
}
|
||||
return &b, nil
|
||||
}
|
||||
|
||||
// UpsertRobotSessionBinding 写入或更新机器人会话绑定(包含角色)。
|
||||
func (db *DB) UpsertRobotSessionBinding(sessionKey, conversationID, roleName, agentMode string) error {
|
||||
sessionKey = strings.TrimSpace(sessionKey)
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
roleName = strings.TrimSpace(roleName)
|
||||
agentMode = strings.TrimSpace(agentMode)
|
||||
if sessionKey == "" || conversationID == "" {
|
||||
return nil
|
||||
}
|
||||
if roleName == "" {
|
||||
roleName = "默认"
|
||||
}
|
||||
if agentMode == "" {
|
||||
agentMode = "eino_single"
|
||||
}
|
||||
_, err := db.Exec(`
|
||||
INSERT INTO robot_user_sessions (session_key, conversation_id, role_name, agent_mode, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(session_key) DO UPDATE SET
|
||||
conversation_id = excluded.conversation_id,
|
||||
role_name = excluded.role_name,
|
||||
agent_mode = excluded.agent_mode,
|
||||
updated_at = excluded.updated_at
|
||||
`, sessionKey, conversationID, roleName, agentMode, time.Now())
|
||||
if err != nil {
|
||||
return fmt.Errorf("写入机器人会话绑定失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteRobotSessionBinding 删除机器人会话绑定。
|
||||
func (db *DB) DeleteRobotSessionBinding(sessionKey string) error {
|
||||
sessionKey = strings.TrimSpace(sessionKey)
|
||||
if sessionKey == "" {
|
||||
return nil
|
||||
}
|
||||
if _, err := db.Exec("DELETE FROM robot_user_sessions WHERE session_key = ?", sessionKey); err != nil {
|
||||
return fmt.Errorf("删除机器人会话绑定失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,142 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// SkillStats Skills统计信息
|
||||
type SkillStats struct {
|
||||
SkillName string
|
||||
TotalCalls int
|
||||
SuccessCalls int
|
||||
FailedCalls int
|
||||
LastCallTime *time.Time
|
||||
}
|
||||
|
||||
// SaveSkillStats 保存Skills统计信息
|
||||
func (db *DB) SaveSkillStats(skillName string, stats *SkillStats) error {
|
||||
var lastCallTime sql.NullTime
|
||||
if stats.LastCallTime != nil {
|
||||
lastCallTime = sql.NullTime{Time: *stats.LastCallTime, Valid: true}
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT OR REPLACE INTO skill_stats
|
||||
(skill_name, total_calls, success_calls, failed_calls, last_call_time, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err := db.Exec(query,
|
||||
skillName,
|
||||
stats.TotalCalls,
|
||||
stats.SuccessCalls,
|
||||
stats.FailedCalls,
|
||||
lastCallTime,
|
||||
time.Now(),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
db.logger.Error("保存Skills统计信息失败", zap.Error(err), zap.String("skillName", skillName))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadSkillStats 加载所有Skills统计信息
|
||||
func (db *DB) LoadSkillStats() (map[string]*SkillStats, error) {
|
||||
query := `
|
||||
SELECT skill_name, total_calls, success_calls, failed_calls, last_call_time
|
||||
FROM skill_stats
|
||||
`
|
||||
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
stats := make(map[string]*SkillStats)
|
||||
for rows.Next() {
|
||||
var stat SkillStats
|
||||
var lastCallTime sql.NullTime
|
||||
|
||||
err := rows.Scan(
|
||||
&stat.SkillName,
|
||||
&stat.TotalCalls,
|
||||
&stat.SuccessCalls,
|
||||
&stat.FailedCalls,
|
||||
&lastCallTime,
|
||||
)
|
||||
if err != nil {
|
||||
db.logger.Warn("加载Skills统计信息失败", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
|
||||
if lastCallTime.Valid {
|
||||
stat.LastCallTime = &lastCallTime.Time
|
||||
}
|
||||
|
||||
stats[stat.SkillName] = &stat
|
||||
}
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// UpdateSkillStats 更新Skills统计信息(累加模式)
|
||||
func (db *DB) UpdateSkillStats(skillName string, totalCalls, successCalls, failedCalls int, lastCallTime *time.Time) error {
|
||||
var lastCallTimeSQL sql.NullTime
|
||||
if lastCallTime != nil {
|
||||
lastCallTimeSQL = sql.NullTime{Time: *lastCallTime, Valid: true}
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO skill_stats (skill_name, total_calls, success_calls, failed_calls, last_call_time, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(skill_name) DO UPDATE SET
|
||||
total_calls = total_calls + ?,
|
||||
success_calls = success_calls + ?,
|
||||
failed_calls = failed_calls + ?,
|
||||
last_call_time = COALESCE(?, last_call_time),
|
||||
updated_at = ?
|
||||
`
|
||||
|
||||
_, err := db.Exec(query,
|
||||
skillName, totalCalls, successCalls, failedCalls, lastCallTimeSQL, time.Now(),
|
||||
totalCalls, successCalls, failedCalls, lastCallTimeSQL, time.Now(),
|
||||
)
|
||||
|
||||
if err != nil {
|
||||
db.logger.Error("更新Skills统计信息失败", zap.Error(err), zap.String("skillName", skillName))
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearSkillStats 清空所有Skills统计信息
|
||||
func (db *DB) ClearSkillStats() error {
|
||||
query := `DELETE FROM skill_stats`
|
||||
_, err := db.Exec(query)
|
||||
if err != nil {
|
||||
db.logger.Error("清空Skills统计信息失败", zap.Error(err))
|
||||
return err
|
||||
}
|
||||
db.logger.Info("已清空所有Skills统计信息")
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearSkillStatsByName 清空指定skill的统计信息
|
||||
func (db *DB) ClearSkillStatsByName(skillName string) error {
|
||||
query := `DELETE FROM skill_stats WHERE skill_name = ?`
|
||||
_, err := db.Exec(query, skillName)
|
||||
if err != nil {
|
||||
db.logger.Error("清空指定skill统计信息失败", zap.Error(err), zap.String("skillName", skillName))
|
||||
return err
|
||||
}
|
||||
db.logger.Info("已清空指定skill统计信息", zap.String("skillName", skillName))
|
||||
return nil
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// formatSQLiteUTC stores instants as UTC RFC3339 for consistent SQLite reads/writes.
|
||||
func formatSQLiteUTC(t time.Time) string {
|
||||
return t.UTC().Format(time.RFC3339Nano)
|
||||
}
|
||||
|
||||
// sqliteEpochGE returns SQL comparing column to param as Unix seconds (timezone-safe).
|
||||
func sqliteEpochGE(column, op string) string {
|
||||
return "strftime('%s', " + column + ") " + op + " strftime('%s', ?)"
|
||||
}
|
||||
|
||||
// ParseRFC3339Time parses API/query timestamps (RFC3339 or RFC3339Nano).
|
||||
func ParseRFC3339Time(value string) (time.Time, error) {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return time.Time{}, errors.New("empty time value")
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339Nano, value); err == nil {
|
||||
return t.UTC(), nil
|
||||
}
|
||||
t, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
return t.UTC(), nil
|
||||
}
|
||||
@@ -1,53 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// FindNearestToolExecutionArguments returns the arguments for the execution record
|
||||
// closest to a persisted tool_call detail. Eino can persist a tool_call with empty
|
||||
// model arguments while the monitor execution row still has the real command/URL.
|
||||
func (db *DB) FindNearestToolExecutionArguments(conversationID, toolName string, at time.Time, window time.Duration) (string, map[string]interface{}, error) {
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
toolName = strings.TrimSpace(toolName)
|
||||
if db == nil || conversationID == "" || toolName == "" || at.IsZero() {
|
||||
return "", nil, sql.ErrNoRows
|
||||
}
|
||||
if window <= 0 {
|
||||
window = 5 * time.Second
|
||||
}
|
||||
start := at.Add(-window)
|
||||
end := at.Add(window)
|
||||
rows, err := db.Query(`
|
||||
SELECT id, arguments
|
||||
FROM tool_executions
|
||||
WHERE conversation_id = ?
|
||||
AND tool_name = ?
|
||||
AND julianday(start_time) BETWEEN julianday(?) AND julianday(?)
|
||||
ORDER BY ABS(julianday(start_time) - julianday(?)) ASC, start_time ASC
|
||||
LIMIT 1`, conversationID, toolName, start, end, at)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
if !rows.Next() {
|
||||
if err := rows.Err(); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
return "", nil, sql.ErrNoRows
|
||||
}
|
||||
var id string
|
||||
var raw string
|
||||
if err := rows.Scan(&id, &raw); err != nil {
|
||||
return "", nil, err
|
||||
}
|
||||
var args map[string]interface{}
|
||||
if err := json.Unmarshal([]byte(raw), &args); err != nil {
|
||||
return "", nil, fmt.Errorf("parse tool execution arguments: %w", err)
|
||||
}
|
||||
return strings.TrimSpace(id), args, nil
|
||||
}
|
||||
@@ -1,547 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// VulnerabilityListFilter 列表/统计/导出共用的筛选条件
|
||||
type VulnerabilityListFilter struct {
|
||||
ID string
|
||||
Search string // 关键词模糊匹配(标题、描述、类型、目标等)
|
||||
ConversationID string
|
||||
ProjectID string
|
||||
Severity string
|
||||
Status string
|
||||
TaskID string
|
||||
ConversationTag string
|
||||
TaskTag string
|
||||
}
|
||||
|
||||
type RBACListAccess struct {
|
||||
UserID string
|
||||
Scope string
|
||||
}
|
||||
|
||||
func escapeVulnerabilityLikePattern(s string) string {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, `%`, `\%`)
|
||||
s = strings.ReplaceAll(s, `_`, `\_`)
|
||||
return "%" + s + "%"
|
||||
}
|
||||
|
||||
func (f VulnerabilityListFilter) appendWhere(query string, args []interface{}) (string, []interface{}) {
|
||||
if f.ID != "" {
|
||||
query += " AND id = ?"
|
||||
args = append(args, f.ID)
|
||||
}
|
||||
if f.ConversationID != "" {
|
||||
query += " AND conversation_id = ?"
|
||||
args = append(args, f.ConversationID)
|
||||
}
|
||||
if f.ProjectID != "" {
|
||||
query += " AND project_id = ?"
|
||||
args = append(args, f.ProjectID)
|
||||
}
|
||||
if f.TaskID != "" {
|
||||
query += " AND EXISTS (SELECT 1 FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id AND (bt.id = ? OR bt.queue_id = ?))"
|
||||
args = append(args, f.TaskID, f.TaskID)
|
||||
}
|
||||
if f.ConversationTag != "" {
|
||||
query += " AND conversation_tag = ?"
|
||||
args = append(args, f.ConversationTag)
|
||||
}
|
||||
if f.TaskTag != "" {
|
||||
query += " AND task_tag = ?"
|
||||
args = append(args, f.TaskTag)
|
||||
}
|
||||
if f.Severity != "" {
|
||||
query += " AND severity = ?"
|
||||
args = append(args, f.Severity)
|
||||
}
|
||||
if f.Status != "" {
|
||||
query += " AND status = ?"
|
||||
args = append(args, f.Status)
|
||||
}
|
||||
search := strings.TrimSpace(f.Search)
|
||||
if search != "" {
|
||||
pattern := escapeVulnerabilityLikePattern(search)
|
||||
query += ` AND (
|
||||
LOWER(id) LIKE LOWER(?) OR
|
||||
LOWER(title) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(description, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(vulnerability_type, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(target, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(preconditions, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(reproduction_steps, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(evidence, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(impact, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(recommendation, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(retest_notes, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(conversation_id, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(conversation_tag, '')) LIKE LOWER(?) OR
|
||||
LOWER(COALESCE(task_tag, '')) LIKE LOWER(?)
|
||||
)`
|
||||
for i := 0; i < 14; i++ {
|
||||
args = append(args, pattern)
|
||||
}
|
||||
}
|
||||
return query, args
|
||||
}
|
||||
|
||||
func appendVulnerabilityAccessFilter(query string, args []interface{}, access RBACListAccess) (string, []interface{}) {
|
||||
userID := strings.TrimSpace(access.UserID)
|
||||
if userID == "" || access.Scope == RBACScopeAll {
|
||||
return query, args
|
||||
}
|
||||
query += ` AND (
|
||||
owner_user_id = ?
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM rbac_resource_assignments ra
|
||||
WHERE ra.user_id = ? AND ra.resource_type = 'vulnerability' AND ra.resource_id = vulnerabilities.id
|
||||
)
|
||||
OR (
|
||||
project_id IS NOT NULL AND project_id <> '' AND (
|
||||
EXISTS (SELECT 1 FROM projects p WHERE p.id = vulnerabilities.project_id AND p.owner_user_id = ?)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM rbac_resource_assignments pra
|
||||
WHERE pra.user_id = ? AND pra.resource_type = 'project' AND pra.resource_id = vulnerabilities.project_id
|
||||
)
|
||||
)
|
||||
)
|
||||
OR (
|
||||
conversation_id IS NOT NULL AND conversation_id <> '' AND (
|
||||
EXISTS (SELECT 1 FROM conversations c WHERE c.id = vulnerabilities.conversation_id AND c.owner_user_id = ?)
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM rbac_resource_assignments cra
|
||||
WHERE cra.user_id = ? AND cra.resource_type = 'conversation' AND cra.resource_id = vulnerabilities.conversation_id
|
||||
)
|
||||
)
|
||||
)
|
||||
)`
|
||||
args = append(args, userID, userID, userID, userID, userID, userID)
|
||||
return query, args
|
||||
}
|
||||
|
||||
// Vulnerability 漏洞
|
||||
type Vulnerability struct {
|
||||
ID string `json:"id"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
ConversationTag string `json:"conversation_tag,omitempty"`
|
||||
TaskTag string `json:"task_tag,omitempty"`
|
||||
TaskID string `json:"task_id,omitempty"`
|
||||
TaskQueueID string `json:"task_queue_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Description string `json:"description"`
|
||||
Severity string `json:"severity"` // critical, high, medium, low, info
|
||||
Status string `json:"status"` // open, confirmed, fixed, false_positive, ignored
|
||||
Type string `json:"type"`
|
||||
Target string `json:"target"`
|
||||
Preconditions string `json:"preconditions"`
|
||||
ReproSteps string `json:"reproduction_steps"`
|
||||
Evidence string `json:"evidence"`
|
||||
Impact string `json:"impact"`
|
||||
Recommendation string `json:"recommendation"`
|
||||
RetestNotes string `json:"retest_notes"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// CreateVulnerability 创建漏洞
|
||||
func (db *DB) CreateVulnerability(vuln *Vulnerability) (*Vulnerability, error) {
|
||||
if vuln.ID == "" {
|
||||
vuln.ID = uuid.New().String()
|
||||
}
|
||||
if vuln.Status == "" {
|
||||
vuln.Status = "open"
|
||||
}
|
||||
now := time.Now()
|
||||
if vuln.CreatedAt.IsZero() {
|
||||
vuln.CreatedAt = now
|
||||
}
|
||||
vuln.UpdatedAt = now
|
||||
|
||||
if strings.TrimSpace(vuln.ProjectID) == "" && vuln.ConversationID != "" {
|
||||
if pid, err := db.GetConversationProjectID(vuln.ConversationID); err == nil {
|
||||
vuln.ProjectID = pid
|
||||
}
|
||||
}
|
||||
|
||||
query := `
|
||||
INSERT INTO vulnerabilities (
|
||||
id, conversation_id, project_id, conversation_tag, task_tag, title, description, severity, status,
|
||||
vulnerability_type, target, preconditions, reproduction_steps, evidence, impact, recommendation, retest_notes,
|
||||
created_at, updated_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
|
||||
_, err := db.Exec(
|
||||
query,
|
||||
vuln.ID, nullIfEmpty(vuln.ConversationID), nullIfEmpty(vuln.ProjectID), vuln.ConversationTag, vuln.TaskTag, vuln.Title, vuln.Description,
|
||||
vuln.Severity, vuln.Status, vuln.Type, vuln.Target,
|
||||
vuln.Preconditions, vuln.ReproSteps, vuln.Evidence, vuln.Impact, vuln.Recommendation, vuln.RetestNotes,
|
||||
vuln.CreatedAt, vuln.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("创建漏洞失败: %w", err)
|
||||
}
|
||||
db.refreshAssetRiskCacheForConversationsBestEffort(vuln.ConversationID)
|
||||
return vuln, nil
|
||||
}
|
||||
|
||||
// GetVulnerability 获取漏洞
|
||||
func (db *DB) GetVulnerability(id string) (*Vulnerability, error) {
|
||||
var vuln Vulnerability
|
||||
query := `
|
||||
SELECT id, COALESCE(conversation_id,''), COALESCE(project_id,''), title, description, severity, status,
|
||||
conversation_tag, task_tag, vulnerability_type, target,
|
||||
COALESCE(preconditions,''), COALESCE(reproduction_steps,''), COALESCE(evidence,''),
|
||||
impact, recommendation, COALESCE(retest_notes,''),
|
||||
COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id,
|
||||
COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id,
|
||||
created_at, updated_at
|
||||
FROM vulnerabilities
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
err := db.QueryRow(query, id).Scan(
|
||||
&vuln.ID, &vuln.ConversationID, &vuln.ProjectID, &vuln.Title, &vuln.Description,
|
||||
&vuln.Severity, &vuln.Status, &vuln.ConversationTag, &vuln.TaskTag, &vuln.Type, &vuln.Target,
|
||||
&vuln.Preconditions, &vuln.ReproSteps, &vuln.Evidence, &vuln.Impact, &vuln.Recommendation, &vuln.RetestNotes,
|
||||
&vuln.TaskID, &vuln.TaskQueueID,
|
||||
&vuln.CreatedAt, &vuln.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, fmt.Errorf("漏洞不存在")
|
||||
}
|
||||
return nil, fmt.Errorf("获取漏洞失败: %w", err)
|
||||
}
|
||||
|
||||
return &vuln, nil
|
||||
}
|
||||
|
||||
// ListVulnerabilities 列出漏洞
|
||||
func (db *DB) ListVulnerabilities(limit, offset int, filter VulnerabilityListFilter) ([]*Vulnerability, error) {
|
||||
return db.ListVulnerabilitiesForAccess(limit, offset, filter, RBACListAccess{})
|
||||
}
|
||||
|
||||
func (db *DB) ListVulnerabilitiesForAccess(limit, offset int, filter VulnerabilityListFilter, access RBACListAccess) ([]*Vulnerability, error) {
|
||||
query := `
|
||||
SELECT id, COALESCE(conversation_id,''), COALESCE(project_id,''), title, description, severity, status, conversation_tag, task_tag,
|
||||
vulnerability_type, target,
|
||||
COALESCE(preconditions,''), COALESCE(reproduction_steps,''), COALESCE(evidence,''),
|
||||
impact, recommendation, COALESCE(retest_notes,''),
|
||||
COALESCE((SELECT bt.id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_id,
|
||||
COALESCE((SELECT bt.queue_id FROM batch_tasks bt WHERE bt.conversation_id = vulnerabilities.conversation_id LIMIT 1), '') AS task_queue_id,
|
||||
created_at, updated_at
|
||||
FROM vulnerabilities
|
||||
WHERE 1=1
|
||||
`
|
||||
args := []interface{}{}
|
||||
query, args = filter.appendWhere(query, args)
|
||||
query, args = appendVulnerabilityAccessFilter(query, args, access)
|
||||
|
||||
query += " ORDER BY created_at DESC LIMIT ? OFFSET ?"
|
||||
args = append(args, limit, offset)
|
||||
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询漏洞列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var vulnerabilities []*Vulnerability
|
||||
for rows.Next() {
|
||||
var vuln Vulnerability
|
||||
err := rows.Scan(
|
||||
&vuln.ID, &vuln.ConversationID, &vuln.ProjectID, &vuln.Title, &vuln.Description,
|
||||
&vuln.Severity, &vuln.Status, &vuln.ConversationTag, &vuln.TaskTag, &vuln.Type, &vuln.Target,
|
||||
&vuln.Preconditions, &vuln.ReproSteps, &vuln.Evidence, &vuln.Impact, &vuln.Recommendation, &vuln.RetestNotes,
|
||||
&vuln.TaskID, &vuln.TaskQueueID,
|
||||
&vuln.CreatedAt, &vuln.UpdatedAt,
|
||||
)
|
||||
if err != nil {
|
||||
db.logger.Warn("扫描漏洞记录失败", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
vulnerabilities = append(vulnerabilities, &vuln)
|
||||
}
|
||||
|
||||
return vulnerabilities, nil
|
||||
}
|
||||
|
||||
// CountVulnerabilities 统计漏洞总数(支持筛选条件)
|
||||
func (db *DB) CountVulnerabilities(filter VulnerabilityListFilter) (int, error) {
|
||||
return db.CountVulnerabilitiesForAccess(filter, RBACListAccess{})
|
||||
}
|
||||
|
||||
func (db *DB) CountVulnerabilitiesForAccess(filter VulnerabilityListFilter, access RBACListAccess) (int, error) {
|
||||
query := "SELECT COUNT(*) FROM vulnerabilities WHERE 1=1"
|
||||
args := []interface{}{}
|
||||
query, args = filter.appendWhere(query, args)
|
||||
query, args = appendVulnerabilityAccessFilter(query, args, access)
|
||||
|
||||
var count int
|
||||
err := db.QueryRow(query, args...).Scan(&count)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("统计漏洞总数失败: %w", err)
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
|
||||
// UpdateVulnerability 更新漏洞
|
||||
func (db *DB) UpdateVulnerability(id string, vuln *Vulnerability) error {
|
||||
vuln.UpdatedAt = time.Now()
|
||||
var oldConversationID string
|
||||
_ = db.QueryRow(`SELECT COALESCE(conversation_id,'') FROM vulnerabilities WHERE id = ?`, id).Scan(&oldConversationID)
|
||||
|
||||
query := `
|
||||
UPDATE vulnerabilities
|
||||
SET project_id = ?, conversation_tag = ?, task_tag = ?, title = ?, description = ?, severity = ?, status = ?,
|
||||
vulnerability_type = ?, target = ?, preconditions = ?, reproduction_steps = ?, evidence = ?, impact = ?,
|
||||
recommendation = ?, retest_notes = ?, updated_at = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
|
||||
_, err := db.Exec(
|
||||
query,
|
||||
nullIfEmpty(vuln.ProjectID), vuln.ConversationTag, vuln.TaskTag, vuln.Title, vuln.Description, vuln.Severity, vuln.Status,
|
||||
vuln.Type, vuln.Target, vuln.Preconditions, vuln.ReproSteps, vuln.Evidence, vuln.Impact,
|
||||
vuln.Recommendation, vuln.RetestNotes, vuln.UpdatedAt, id,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新漏洞失败: %w", err)
|
||||
}
|
||||
|
||||
db.refreshAssetRiskCacheForConversationsBestEffort(oldConversationID, vuln.ConversationID)
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteVulnerabilitiesByFilter 按筛选条件批量删除漏洞,返回实际删除条数
|
||||
func (db *DB) DeleteVulnerabilitiesByFilter(filter VulnerabilityListFilter) (int64, error) {
|
||||
return db.DeleteVulnerabilitiesByFilterForAccess(filter, RBACListAccess{})
|
||||
}
|
||||
|
||||
func (db *DB) DeleteVulnerabilitiesByFilterForAccess(filter VulnerabilityListFilter, access RBACListAccess) (int64, error) {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("开启事务失败: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
where := "WHERE 1=1"
|
||||
args := []interface{}{}
|
||||
where, args = filter.appendWhere(where, args)
|
||||
where, args = appendVulnerabilityAccessFilter(where, args, access)
|
||||
affectedConversations, err := collectVulnerabilityConversationIDs(tx, where, args)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
clearQuery := `UPDATE project_facts SET related_vulnerability_id = NULL
|
||||
WHERE related_vulnerability_id IN (SELECT id FROM vulnerabilities ` + where + `)`
|
||||
if _, err := tx.Exec(clearQuery, args...); err != nil {
|
||||
return 0, fmt.Errorf("清理事实漏洞关联失败: %w", err)
|
||||
}
|
||||
|
||||
deleteQuery := `DELETE FROM vulnerabilities ` + where
|
||||
result, err := tx.Exec(deleteQuery, args...)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("批量删除漏洞失败: %w", err)
|
||||
}
|
||||
deleted, err := result.RowsAffected()
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("获取删除条数失败: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return 0, fmt.Errorf("提交事务失败: %w", err)
|
||||
}
|
||||
db.refreshAssetRiskCacheForConversationsBestEffort(affectedConversations...)
|
||||
return deleted, nil
|
||||
}
|
||||
|
||||
// DeleteVulnerability 删除漏洞
|
||||
func (db *DB) DeleteVulnerability(id string) error {
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return fmt.Errorf("开启事务失败: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
var conversationID string
|
||||
_ = tx.QueryRow(`SELECT COALESCE(conversation_id,'') FROM vulnerabilities WHERE id = ?`, id).Scan(&conversationID)
|
||||
|
||||
// 删除漏洞前先解除项目事实中的关联,避免前端继续显示已删除漏洞的短 ID。
|
||||
if _, err := tx.Exec("UPDATE project_facts SET related_vulnerability_id = NULL WHERE related_vulnerability_id = ?", id); err != nil {
|
||||
return fmt.Errorf("清理事实漏洞关联失败: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec("DELETE FROM vulnerabilities WHERE id = ?", id); err != nil {
|
||||
return fmt.Errorf("删除漏洞失败: %w", err)
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
return fmt.Errorf("提交事务失败: %w", err)
|
||||
}
|
||||
db.refreshAssetRiskCacheForConversationsBestEffort(conversationID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func collectVulnerabilityConversationIDs(tx *sql.Tx, where string, args []interface{}) ([]string, error) {
|
||||
rows, err := tx.Query(`SELECT DISTINCT COALESCE(conversation_id,'') FROM vulnerabilities `+where, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询受影响漏洞会话失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
ids := []string{}
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if strings.TrimSpace(id) != "" {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
}
|
||||
return ids, rows.Err()
|
||||
}
|
||||
|
||||
// GetVulnerabilityStats 获取漏洞统计(筛选条件与 ListVulnerabilities / CountVulnerabilities 一致)
|
||||
func (db *DB) GetVulnerabilityStats(filter VulnerabilityListFilter) (map[string]interface{}, error) {
|
||||
return db.GetVulnerabilityStatsForAccess(filter, RBACListAccess{})
|
||||
}
|
||||
|
||||
func (db *DB) GetVulnerabilityStatsForAccess(filter VulnerabilityListFilter, access RBACListAccess) (map[string]interface{}, error) {
|
||||
stats := make(map[string]interface{})
|
||||
|
||||
where := "WHERE 1=1"
|
||||
args := []interface{}{}
|
||||
where, args = filter.appendWhere(where, args)
|
||||
where, args = appendVulnerabilityAccessFilter(where, args, access)
|
||||
|
||||
// 总漏洞数
|
||||
var totalCount int
|
||||
query := "SELECT COUNT(*) FROM vulnerabilities " + where
|
||||
err := db.QueryRow(query, args...).Scan(&totalCount)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取总漏洞数失败: %w", err)
|
||||
}
|
||||
stats["total"] = totalCount
|
||||
|
||||
// 按严重程度统计
|
||||
severityQuery := "SELECT severity, COUNT(*) FROM vulnerabilities " + where + " GROUP BY severity"
|
||||
|
||||
rows, err := db.Query(severityQuery, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取严重程度统计失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
severityStats := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var severity string
|
||||
var count int
|
||||
if err := rows.Scan(&severity, &count); err != nil {
|
||||
continue
|
||||
}
|
||||
severityStats[severity] = count
|
||||
}
|
||||
stats["by_severity"] = severityStats
|
||||
|
||||
// 按状态统计
|
||||
statusQuery := "SELECT status, COUNT(*) FROM vulnerabilities " + where + " GROUP BY status"
|
||||
|
||||
rows, err = db.Query(statusQuery, args...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("获取状态统计失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
statusStats := make(map[string]int)
|
||||
for rows.Next() {
|
||||
var status string
|
||||
var count int
|
||||
if err := rows.Scan(&status, &count); err != nil {
|
||||
continue
|
||||
}
|
||||
statusStats[status] = count
|
||||
}
|
||||
stats["by_status"] = statusStats
|
||||
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// GetVulnerabilityFilterOptions 获取漏洞筛选建议项
|
||||
func (db *DB) GetVulnerabilityFilterOptions() (map[string][]string, error) {
|
||||
return db.GetVulnerabilityFilterOptionsForAccess(RBACListAccess{})
|
||||
}
|
||||
|
||||
func (db *DB) GetVulnerabilityFilterOptionsForAccess(access RBACListAccess) (map[string][]string, error) {
|
||||
collect := func(query string, args ...interface{}) ([]string, error) {
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]string, 0)
|
||||
for rows.Next() {
|
||||
var val string
|
||||
if err := rows.Scan(&val); err != nil {
|
||||
continue
|
||||
}
|
||||
if val == "" {
|
||||
continue
|
||||
}
|
||||
items = append(items, val)
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
where := "WHERE 1=1"
|
||||
accessArgs := []interface{}{}
|
||||
where, accessArgs = appendVulnerabilityAccessFilter(where, accessArgs, access)
|
||||
|
||||
vulnIDs, err := collect(`SELECT DISTINCT id FROM vulnerabilities `+where+` ORDER BY created_at DESC LIMIT 500`, accessArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询漏洞ID建议失败: %w", err)
|
||||
}
|
||||
conversationIDs, err := collect(`SELECT DISTINCT conversation_id FROM vulnerabilities `+where+` AND conversation_id IS NOT NULL AND conversation_id <> '' ORDER BY created_at DESC LIMIT 500`, accessArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询会话ID建议失败: %w", err)
|
||||
}
|
||||
taskIDs, err := collect(`SELECT DISTINCT bt.id FROM batch_tasks bt JOIN vulnerabilities ON bt.conversation_id = vulnerabilities.conversation_id `+where+` AND bt.id <> '' ORDER BY bt.rowid DESC LIMIT 500`, accessArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询任务ID建议失败: %w", err)
|
||||
}
|
||||
queueIDs, err := collect(`SELECT DISTINCT bt.queue_id FROM batch_tasks bt JOIN vulnerabilities ON bt.conversation_id = vulnerabilities.conversation_id `+where+` AND bt.queue_id <> '' ORDER BY bt.rowid DESC LIMIT 500`, accessArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询队列ID建议失败: %w", err)
|
||||
}
|
||||
conversationTags, err := collect(`SELECT DISTINCT conversation_tag FROM vulnerabilities `+where+` AND conversation_tag IS NOT NULL AND conversation_tag <> '' ORDER BY conversation_tag LIMIT 500`, accessArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询对话标签建议失败: %w", err)
|
||||
}
|
||||
taskTags, err := collect(`SELECT DISTINCT task_tag FROM vulnerabilities `+where+` AND task_tag IS NOT NULL AND task_tag <> '' ORDER BY task_tag LIMIT 500`, accessArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询任务标签建议失败: %w", err)
|
||||
}
|
||||
projectIDs, err := collect(`SELECT DISTINCT project_id FROM vulnerabilities `+where+` AND project_id IS NOT NULL AND project_id <> '' ORDER BY created_at DESC LIMIT 200`, accessArgs...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询项目ID建议失败: %w", err)
|
||||
}
|
||||
|
||||
return map[string][]string{
|
||||
"vulnerability_ids": vulnIDs,
|
||||
"conversation_ids": conversationIDs,
|
||||
"project_ids": projectIDs,
|
||||
"task_ids": taskIDs,
|
||||
"queue_ids": queueIDs,
|
||||
"conversation_tags": conversationTags,
|
||||
"task_tags": taskTags,
|
||||
}, nil
|
||||
}
|
||||
@@ -1,215 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// VulnerabilityAlertSubscription is the single source of truth shared by Web
|
||||
// settings and robot commands. Alerts are opt-in and user scoped.
|
||||
type VulnerabilityAlertSubscription struct {
|
||||
UserID string `json:"user_id"`
|
||||
Enabled bool `json:"enabled"`
|
||||
MinSeverity string `json:"min_severity"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type VulnerabilityAlertRecipient struct {
|
||||
UserID string
|
||||
Platform string
|
||||
ExternalUserID string
|
||||
}
|
||||
|
||||
type VulnerabilityAlertDelivery struct {
|
||||
ID int64
|
||||
Vulnerability *Vulnerability
|
||||
UserID string
|
||||
Platform string
|
||||
ExternalUserID string
|
||||
Attempts int
|
||||
}
|
||||
|
||||
var vulnerabilitySeverityRank = map[string]int{
|
||||
"info": 0, "low": 1, "medium": 2, "high": 3, "critical": 4,
|
||||
}
|
||||
|
||||
func NormalizeVulnerabilityAlertSeverity(value string) (string, error) {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value == "" {
|
||||
value = "high"
|
||||
}
|
||||
if _, ok := vulnerabilitySeverityRank[value]; !ok {
|
||||
return "", fmt.Errorf("invalid minimum severity %q", value)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (db *DB) GetVulnerabilityAlertSubscription(userID string) (*VulnerabilityAlertSubscription, error) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
var sub VulnerabilityAlertSubscription
|
||||
var enabled int
|
||||
var createdAt, updatedAt string
|
||||
err := db.QueryRow(`SELECT user_id, enabled, min_severity, created_at, updated_at
|
||||
FROM vulnerability_alert_subscriptions WHERE user_id = ?`, userID).
|
||||
Scan(&sub.UserID, &enabled, &sub.MinSeverity, &createdAt, &updatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
now := time.Now()
|
||||
return &VulnerabilityAlertSubscription{UserID: userID, MinSeverity: "high", CreatedAt: now, UpdatedAt: now}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sub.Enabled = enabled != 0
|
||||
sub.CreatedAt = parseDBTime(createdAt)
|
||||
sub.UpdatedAt = parseDBTime(updatedAt)
|
||||
return &sub, nil
|
||||
}
|
||||
|
||||
func (db *DB) UpsertVulnerabilityAlertSubscription(userID string, enabled bool, minSeverity string) (*VulnerabilityAlertSubscription, error) {
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID == "" {
|
||||
return nil, fmt.Errorf("user id is required")
|
||||
}
|
||||
severity, err := NormalizeVulnerabilityAlertSeverity(minSeverity)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
now := time.Now()
|
||||
_, err = db.Exec(`INSERT INTO vulnerability_alert_subscriptions
|
||||
(user_id, enabled, min_severity, created_at, updated_at) VALUES (?, ?, ?, ?, ?)
|
||||
ON CONFLICT(user_id) DO UPDATE SET enabled = excluded.enabled,
|
||||
min_severity = excluded.min_severity, updated_at = excluded.updated_at`,
|
||||
userID, boolToInt(enabled), severity, now, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return db.GetVulnerabilityAlertSubscription(userID)
|
||||
}
|
||||
|
||||
// ListVulnerabilityAlertRecipients applies the same RBAC ownership/assignment
|
||||
// boundaries as the vulnerability list, then expands only enabled robot bindings.
|
||||
func (db *DB) ListVulnerabilityAlertRecipients(vuln *Vulnerability) ([]VulnerabilityAlertRecipient, error) {
|
||||
if vuln == nil {
|
||||
return nil, nil
|
||||
}
|
||||
rank, ok := vulnerabilitySeverityRank[strings.ToLower(strings.TrimSpace(vuln.Severity))]
|
||||
if !ok {
|
||||
return nil, nil
|
||||
}
|
||||
rows, err := db.Query(`
|
||||
SELECT DISTINCT s.user_id, b.platform, b.external_user_id, s.min_severity
|
||||
FROM vulnerability_alert_subscriptions s
|
||||
JOIN rbac_users u ON u.id = s.user_id AND u.enabled = 1
|
||||
JOIN robot_user_bindings b ON b.rbac_user_id = s.user_id AND b.enabled = 1
|
||||
WHERE s.enabled = 1 AND (
|
||||
EXISTS (SELECT 1 FROM vulnerabilities v WHERE v.id = ? AND v.owner_user_id = s.user_id)
|
||||
OR EXISTS (SELECT 1 FROM rbac_resource_assignments ra WHERE ra.user_id = s.user_id AND ra.resource_type = 'vulnerability' AND ra.resource_id = ?)
|
||||
OR (? <> '' AND (EXISTS (SELECT 1 FROM projects p WHERE p.id = ? AND p.owner_user_id = s.user_id)
|
||||
OR EXISTS (SELECT 1 FROM rbac_resource_assignments pra WHERE pra.user_id = s.user_id AND pra.resource_type = 'project' AND pra.resource_id = ?)))
|
||||
OR (? <> '' AND (EXISTS (SELECT 1 FROM conversations c WHERE c.id = ? AND c.owner_user_id = s.user_id)
|
||||
OR EXISTS (SELECT 1 FROM rbac_resource_assignments cra WHERE cra.user_id = s.user_id AND cra.resource_type = 'conversation' AND cra.resource_id = ?)))
|
||||
)`, vuln.ID, vuln.ID, vuln.ProjectID, vuln.ProjectID, vuln.ProjectID,
|
||||
vuln.ConversationID, vuln.ConversationID, vuln.ConversationID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make([]VulnerabilityAlertRecipient, 0)
|
||||
for rows.Next() {
|
||||
var recipient VulnerabilityAlertRecipient
|
||||
var minimum string
|
||||
if err := rows.Scan(&recipient.UserID, &recipient.Platform, &recipient.ExternalUserID, &minimum); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if rank >= vulnerabilitySeverityRank[minimum] {
|
||||
out = append(out, recipient)
|
||||
}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) SetVulnerabilityCreatedHook(hook func(*Vulnerability)) {
|
||||
db.vulnerabilityCreatedHook = hook
|
||||
}
|
||||
|
||||
// NotifyVulnerabilityCreated must be called after resource ownership has been
|
||||
// committed. Delivery runs asynchronously and never delays the write path.
|
||||
func (db *DB) NotifyVulnerabilityCreated(vulnerability *Vulnerability) {
|
||||
if db == nil || vulnerability == nil || db.vulnerabilityCreatedHook == nil {
|
||||
return
|
||||
}
|
||||
created := *vulnerability
|
||||
go db.vulnerabilityCreatedHook(&created)
|
||||
}
|
||||
|
||||
func (db *DB) EnqueueVulnerabilityAlertDeliveries(vulnerabilityID string, recipients []VulnerabilityAlertRecipient) error {
|
||||
now := time.Now()
|
||||
tx, err := db.Begin()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
for _, r := range recipients {
|
||||
if _, err := tx.Exec(`INSERT INTO vulnerability_alert_deliveries
|
||||
(vulnerability_id, user_id, platform, external_user_id, status, attempts, next_attempt_at, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, 'pending', 0, ?, ?, ?)
|
||||
ON CONFLICT(vulnerability_id, platform, external_user_id) DO NOTHING`,
|
||||
vulnerabilityID, r.UserID, r.Platform, r.ExternalUserID, now, now, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return tx.Commit()
|
||||
}
|
||||
|
||||
func (db *DB) ListDueVulnerabilityAlertDeliveries(limit int) ([]VulnerabilityAlertDelivery, error) {
|
||||
if limit <= 0 || limit > 100 {
|
||||
limit = 50
|
||||
}
|
||||
rows, err := db.Query(`SELECT d.id, d.user_id, d.platform, d.external_user_id, d.attempts,
|
||||
v.id, COALESCE(v.conversation_id,''), COALESCE(v.project_id,''), v.title, COALESCE(v.description,''),
|
||||
v.severity, v.status, COALESCE(v.vulnerability_type,''), COALESCE(v.target,''),
|
||||
COALESCE(v.impact,''), COALESCE(v.recommendation,''), v.created_at, v.updated_at
|
||||
FROM vulnerability_alert_deliveries d JOIN vulnerabilities v ON v.id = d.vulnerability_id
|
||||
WHERE d.status IN ('pending','retry') AND d.next_attempt_at <= ?
|
||||
ORDER BY d.next_attempt_at, d.id LIMIT ?`, time.Now(), limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []VulnerabilityAlertDelivery
|
||||
for rows.Next() {
|
||||
var d VulnerabilityAlertDelivery
|
||||
v := &Vulnerability{}
|
||||
if err := rows.Scan(&d.ID, &d.UserID, &d.Platform, &d.ExternalUserID, &d.Attempts,
|
||||
&v.ID, &v.ConversationID, &v.ProjectID, &v.Title, &v.Description, &v.Severity, &v.Status,
|
||||
&v.Type, &v.Target, &v.Impact, &v.Recommendation, &v.CreatedAt, &v.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
d.Vulnerability = v
|
||||
out = append(out, d)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) MarkVulnerabilityAlertDeliverySent(id int64) error {
|
||||
_, err := db.Exec(`UPDATE vulnerability_alert_deliveries SET status='sent', attempts=attempts+1, last_error='', updated_at=? WHERE id=?`, time.Now(), id)
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) MarkVulnerabilityAlertDeliveryFailed(id int64, attempts int, sendErr error) error {
|
||||
status := "retry"
|
||||
if attempts >= 5 {
|
||||
status = "failed"
|
||||
}
|
||||
delay := time.Minute * time.Duration(1<<min(attempts, 6))
|
||||
message := ""
|
||||
if sendErr != nil {
|
||||
message = sendErr.Error()
|
||||
}
|
||||
_, err := db.Exec(`UPDATE vulnerability_alert_deliveries SET status=?, attempts=?, next_attempt_at=?, last_error=?, updated_at=? WHERE id=?`,
|
||||
status, attempts, time.Now().Add(delay), message, time.Now(), id)
|
||||
return err
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
package database_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"cyberstrike-ai/internal/security"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestVulnerabilityAlertSubscriptionIsOptInAndRBACScoped(t *testing.T) {
|
||||
db, err := database.NewDB(t.TempDir()+"/alerts.db", zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := db.BootstrapRBAC("hash", security.PermissionCatalog); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owner, _ := db.CreateRBACUser("alert-owner", "Owner", "hash", true, []string{database.RBACSystemRoleOperator})
|
||||
other, _ := db.CreateRBACUser("alert-other", "Other", "hash", true, []string{database.RBACSystemRoleOperator})
|
||||
|
||||
for i, pair := range []struct {
|
||||
user *database.RBACUser
|
||||
external string
|
||||
}{{owner, "owner"}, {other, "other"}} {
|
||||
code := "code-" + string(rune('a'+i))
|
||||
if err := db.CreateRobotBindingCode(pair.user.ID, code, time.Now().Add(time.Minute)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.ConsumeRobotBindingCode("wecom", pair.external, code); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
defaultSub, err := db.GetVulnerabilityAlertSubscription(owner.ID)
|
||||
if err != nil || defaultSub.Enabled || defaultSub.MinSeverity != "high" {
|
||||
t.Fatalf("unsafe default: %#v %v", defaultSub, err)
|
||||
}
|
||||
if _, err := db.UpsertVulnerabilityAlertSubscription(owner.ID, true, "high"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := db.UpsertVulnerabilityAlertSubscription(other.ID, true, "low"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
vuln, err := db.CreateVulnerability(&database.Vulnerability{Title: "owned", Severity: "high"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.SetResourceOwner("vulnerability", vuln.ID, owner.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recipients, err := db.ListVulnerabilityAlertRecipients(vuln)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recipients) != 1 || recipients[0].UserID != owner.ID || recipients[0].ExternalUserID != "owner" {
|
||||
t.Fatalf("alert escaped RBAC boundary: %#v", recipients)
|
||||
}
|
||||
if err := db.EnqueueVulnerabilityAlertDeliveries(vuln.ID, recipients); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.EnqueueVulnerabilityAlertDeliveries(vuln.ID, recipients); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deliveries, err := db.ListDueVulnerabilityAlertDeliveries(10)
|
||||
if err != nil || len(deliveries) != 1 {
|
||||
t.Fatalf("outbox is not durable/deduplicated: %#v %v", deliveries, err)
|
||||
}
|
||||
if err := db.MarkVulnerabilityAlertDeliverySent(deliveries[0].ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
deliveries, _ = db.ListDueVulnerabilityAlertDeliveries(10)
|
||||
if len(deliveries) != 0 {
|
||||
t.Fatalf("sent delivery remained due: %#v", deliveries)
|
||||
}
|
||||
|
||||
vuln.Severity = "medium"
|
||||
recipients, err = db.ListVulnerabilityAlertRecipients(vuln)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(recipients) != 0 {
|
||||
t.Fatalf("minimum severity was ignored: %#v", recipients)
|
||||
}
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// WebShellConnection WebShell 连接配置
|
||||
type WebShellConnection struct {
|
||||
ID string `json:"id"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
URL string `json:"url"`
|
||||
Password string `json:"password"`
|
||||
Type string `json:"type"`
|
||||
Method string `json:"method"`
|
||||
CmdParam string `json:"cmdParam"`
|
||||
Remark string `json:"remark"`
|
||||
Encoding string `json:"encoding"` // 目标响应编码:auto / utf-8 / gbk / gb18030,空值视为 auto
|
||||
OS string `json:"os"` // 目标操作系统:auto / linux / windows,空值/未知视为 auto
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
// GetWebshellConnectionState 获取连接关联的持久化状态 JSON,不存在时返回 "{}"
|
||||
func (db *DB) GetWebshellConnectionState(connectionID string) (string, error) {
|
||||
var stateJSON string
|
||||
err := db.QueryRow(`SELECT state_json FROM webshell_connection_states WHERE connection_id = ?`, connectionID).Scan(&stateJSON)
|
||||
if err == sql.ErrNoRows {
|
||||
return "{}", nil
|
||||
}
|
||||
if err != nil {
|
||||
db.logger.Error("查询 WebShell 连接状态失败", zap.Error(err), zap.String("connectionID", connectionID))
|
||||
return "", err
|
||||
}
|
||||
if stateJSON == "" {
|
||||
stateJSON = "{}"
|
||||
}
|
||||
return stateJSON, nil
|
||||
}
|
||||
|
||||
// UpsertWebshellConnectionState 保存连接关联的持久化状态 JSON
|
||||
func (db *DB) UpsertWebshellConnectionState(connectionID, stateJSON string) error {
|
||||
if stateJSON == "" {
|
||||
stateJSON = "{}"
|
||||
}
|
||||
query := `
|
||||
INSERT INTO webshell_connection_states (connection_id, state_json, updated_at)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(connection_id) DO UPDATE SET
|
||||
state_json = excluded.state_json,
|
||||
updated_at = excluded.updated_at
|
||||
`
|
||||
if _, err := db.Exec(query, connectionID, stateJSON, time.Now()); err != nil {
|
||||
db.logger.Error("保存 WebShell 连接状态失败", zap.Error(err), zap.String("connectionID", connectionID))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ListWebshellConnections 列出所有 WebShell 连接,按创建时间倒序
|
||||
func (db *DB) ListWebshellConnections() ([]WebShellConnection, error) {
|
||||
return db.ListWebshellConnectionsForAccess("", "", "")
|
||||
}
|
||||
|
||||
func (db *DB) ListWebshellConnectionsForAccess(userID, scope, projectID string) ([]WebShellConnection, error) {
|
||||
query := `
|
||||
SELECT id, COALESCE(project_id, '') AS project_id, url, password, type, method, cmd_param, remark,
|
||||
COALESCE(encoding, '') AS encoding, COALESCE(os, '') AS os, created_at
|
||||
FROM webshell_connections
|
||||
WHERE 1=1
|
||||
`
|
||||
args := []interface{}{}
|
||||
projectID = strings.TrimSpace(projectID)
|
||||
if projectID == ProjectFilterUnbound {
|
||||
query += ` AND COALESCE(project_id, '') = ''`
|
||||
} else if projectID != "" {
|
||||
query += ` AND COALESCE(project_id, '') = ?`
|
||||
args = append(args, projectID)
|
||||
}
|
||||
userID = strings.TrimSpace(userID)
|
||||
if userID != "" && scope != RBACScopeAll {
|
||||
query += ` AND (
|
||||
owner_user_id = ?
|
||||
OR EXISTS (
|
||||
SELECT 1 FROM rbac_resource_assignments ra
|
||||
WHERE ra.user_id = ? AND ra.resource_type = 'webshell' AND ra.resource_id = webshell_connections.id
|
||||
)
|
||||
)`
|
||||
args = append(args, userID, userID)
|
||||
}
|
||||
query += ` ORDER BY created_at DESC`
|
||||
rows, err := db.Query(query, args...)
|
||||
if err != nil {
|
||||
db.logger.Error("查询 WebShell 连接列表失败", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var list []WebShellConnection
|
||||
for rows.Next() {
|
||||
var c WebShellConnection
|
||||
err := rows.Scan(&c.ID, &c.ProjectID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.Encoding, &c.OS, &c.CreatedAt)
|
||||
if err != nil {
|
||||
db.logger.Warn("扫描 WebShell 连接行失败", zap.Error(err))
|
||||
continue
|
||||
}
|
||||
list = append(list, c)
|
||||
}
|
||||
return list, rows.Err()
|
||||
}
|
||||
|
||||
// GetWebshellConnection 根据 ID 获取一条连接
|
||||
func (db *DB) GetWebshellConnection(id string) (*WebShellConnection, error) {
|
||||
query := `
|
||||
SELECT id, COALESCE(project_id, '') AS project_id, url, password, type, method, cmd_param, remark,
|
||||
COALESCE(encoding, '') AS encoding, COALESCE(os, '') AS os, created_at
|
||||
FROM webshell_connections WHERE id = ?
|
||||
`
|
||||
var c WebShellConnection
|
||||
err := db.QueryRow(query, id).Scan(&c.ID, &c.ProjectID, &c.URL, &c.Password, &c.Type, &c.Method, &c.CmdParam, &c.Remark, &c.Encoding, &c.OS, &c.CreatedAt)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
db.logger.Error("查询 WebShell 连接失败", zap.Error(err), zap.String("id", id))
|
||||
return nil, err
|
||||
}
|
||||
return &c, nil
|
||||
}
|
||||
|
||||
// CreateWebshellConnection 创建 WebShell 连接
|
||||
func (db *DB) CreateWebshellConnection(c *WebShellConnection) error {
|
||||
query := `
|
||||
INSERT INTO webshell_connections (id, project_id, url, password, type, method, cmd_param, remark, encoding, os, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
`
|
||||
_, err := db.Exec(query, c.ID, strings.TrimSpace(c.ProjectID), c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.Encoding, c.OS, c.CreatedAt)
|
||||
if err != nil {
|
||||
db.logger.Error("创建 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateWebshellConnection 更新 WebShell 连接
|
||||
func (db *DB) UpdateWebshellConnection(c *WebShellConnection) error {
|
||||
query := `
|
||||
UPDATE webshell_connections
|
||||
SET project_id = ?, url = ?, password = ?, type = ?, method = ?, cmd_param = ?, remark = ?, encoding = ?, os = ?
|
||||
WHERE id = ?
|
||||
`
|
||||
result, err := db.Exec(query, strings.TrimSpace(c.ProjectID), c.URL, c.Password, c.Type, c.Method, c.CmdParam, c.Remark, c.Encoding, c.OS, c.ID)
|
||||
if err != nil {
|
||||
db.logger.Error("更新 WebShell 连接失败", zap.Error(err), zap.String("id", c.ID))
|
||||
return err
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteWebshellConnection 删除 WebShell 连接
|
||||
func (db *DB) DeleteWebshellConnection(id string) error {
|
||||
result, err := db.Exec(`DELETE FROM webshell_connections WHERE id = ?`, id)
|
||||
if err != nil {
|
||||
db.logger.Error("删除 WebShell 连接失败", zap.Error(err), zap.String("id", id))
|
||||
return err
|
||||
}
|
||||
affected, _ := result.RowsAffected()
|
||||
if affected == 0 {
|
||||
return sql.ErrNoRows
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,468 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// WorkflowDefinition is a persisted user-defined graph/workflow template.
|
||||
// graph_json intentionally remains opaque so users can define their own fields.
|
||||
type WorkflowDefinition struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version int `json:"version"`
|
||||
GraphJSON string `json:"graph_json"`
|
||||
Enabled bool `json:"enabled"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
type WorkflowRun struct {
|
||||
ID string `json:"id"`
|
||||
WorkflowID string `json:"workflow_id"`
|
||||
WorkflowVersion int `json:"workflow_version"`
|
||||
ConversationID string `json:"conversation_id,omitempty"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
RoleID string `json:"role_id,omitempty"`
|
||||
Status string `json:"status"`
|
||||
InputJSON string `json:"input_json,omitempty"`
|
||||
OutputJSON string `json:"output_json,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
PendingHITLNodeID string `json:"pending_hitl_node_id,omitempty"`
|
||||
PendingHITLJSON string `json:"pending_hitl_json,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
}
|
||||
|
||||
type WorkflowNodeRun struct {
|
||||
ID string `json:"id"`
|
||||
RunID string `json:"run_id"`
|
||||
NodeID string `json:"node_id"`
|
||||
Status string `json:"status"`
|
||||
InputJSON string `json:"input_json,omitempty"`
|
||||
OutputJSON string `json:"output_json,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
StartedAt time.Time `json:"started_at"`
|
||||
FinishedAt *time.Time `json:"finished_at,omitempty"`
|
||||
}
|
||||
|
||||
func scanWorkflowNodeRun(scanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}) (*WorkflowNodeRun, error) {
|
||||
var row WorkflowNodeRun
|
||||
var inputJSON, outputJSON, errText sql.NullString
|
||||
var finishedAt sql.NullTime
|
||||
if err := scanner.Scan(&row.ID, &row.RunID, &row.NodeID, &row.Status, &inputJSON, &outputJSON, &errText, &row.StartedAt, &finishedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.InputJSON = inputJSON.String
|
||||
row.OutputJSON = outputJSON.String
|
||||
row.Error = errText.String
|
||||
if finishedAt.Valid {
|
||||
t := finishedAt.Time
|
||||
row.FinishedAt = &t
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
func scanWorkflowDefinition(scanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}) (*WorkflowDefinition, error) {
|
||||
var row WorkflowDefinition
|
||||
var desc sql.NullString
|
||||
var enabled int
|
||||
if err := scanner.Scan(&row.ID, &row.Name, &desc, &row.Version, &row.GraphJSON, &enabled, &row.CreatedAt, &row.UpdatedAt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.Description = desc.String
|
||||
row.Enabled = enabled != 0
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
const workflowDefinitionColumns = `id, name, description, version, graph_json, enabled, created_at, updated_at`
|
||||
|
||||
func (db *DB) ListWorkflowDefinitions(includeDisabled bool) ([]*WorkflowDefinition, error) {
|
||||
query := "SELECT " + workflowDefinitionColumns + " FROM workflow_definitions"
|
||||
if !includeDisabled {
|
||||
query += " WHERE enabled = 1"
|
||||
}
|
||||
query += " ORDER BY updated_at DESC"
|
||||
rows, err := db.Query(query)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询工作流列表失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var out []*WorkflowDefinition
|
||||
for rows.Next() {
|
||||
wf, err := scanWorkflowDefinition(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("扫描工作流失败: %w", err)
|
||||
}
|
||||
out = append(out, wf)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) GetWorkflowDefinition(id string) (*WorkflowDefinition, error) {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return nil, nil
|
||||
}
|
||||
wf, err := scanWorkflowDefinition(db.QueryRow("SELECT "+workflowDefinitionColumns+" FROM workflow_definitions WHERE id = ?", id))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询工作流失败: %w", err)
|
||||
}
|
||||
return wf, nil
|
||||
}
|
||||
|
||||
func (db *DB) UpsertWorkflowDefinition(wf *WorkflowDefinition) error {
|
||||
if wf == nil {
|
||||
return fmt.Errorf("工作流为空")
|
||||
}
|
||||
wf.ID = strings.TrimSpace(wf.ID)
|
||||
wf.Name = strings.TrimSpace(wf.Name)
|
||||
if wf.ID == "" || wf.Name == "" {
|
||||
return fmt.Errorf("工作流 id 和 name 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(wf.GraphJSON) == "" {
|
||||
wf.GraphJSON = `{"nodes":[],"edges":[],"config":{}}`
|
||||
}
|
||||
if wf.Version <= 0 {
|
||||
wf.Version = 1
|
||||
}
|
||||
now := time.Now()
|
||||
existing, err := db.GetWorkflowDefinition(wf.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if existing == nil {
|
||||
_, err = db.Exec(
|
||||
`INSERT INTO workflow_definitions (id, name, description, version, graph_json, enabled, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
wf.ID, wf.Name, wf.Description, wf.Version, wf.GraphJSON, boolToInt(wf.Enabled), now, now,
|
||||
)
|
||||
} else {
|
||||
nextVersion := existing.Version + 1
|
||||
if wf.Version > existing.Version {
|
||||
nextVersion = wf.Version
|
||||
}
|
||||
_, err = db.Exec(
|
||||
`UPDATE workflow_definitions
|
||||
SET name = ?, description = ?, version = ?, graph_json = ?, enabled = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
wf.Name, wf.Description, nextVersion, wf.GraphJSON, boolToInt(wf.Enabled), now, wf.ID,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("保存工作流失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) DeleteWorkflowDefinition(id string) error {
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return fmt.Errorf("工作流 id 不能为空")
|
||||
}
|
||||
if _, err := db.Exec("DELETE FROM workflow_definitions WHERE id = ?", id); err != nil {
|
||||
return fmt.Errorf("删除工作流失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateWorkflowRun(run *WorkflowRun) error {
|
||||
if run == nil {
|
||||
return fmt.Errorf("工作流运行为空")
|
||||
}
|
||||
if strings.TrimSpace(run.ID) == "" || strings.TrimSpace(run.WorkflowID) == "" {
|
||||
return fmt.Errorf("工作流运行 id 和 workflow_id 不能为空")
|
||||
}
|
||||
if run.WorkflowVersion <= 0 {
|
||||
run.WorkflowVersion = 1
|
||||
}
|
||||
if strings.TrimSpace(run.Status) == "" {
|
||||
run.Status = "running"
|
||||
}
|
||||
if run.StartedAt.IsZero() {
|
||||
run.StartedAt = time.Now()
|
||||
}
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO workflow_runs (id, workflow_id, workflow_version, conversation_id, project_id, role_id, status, input_json, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
run.ID, run.WorkflowID, run.WorkflowVersion, nullString(run.ConversationID), nullString(run.ProjectID), nullString(run.RoleID), run.Status, run.InputJSON, run.StartedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建工作流运行失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) FinishWorkflowRun(runID, status, outputJSON, errText string) error {
|
||||
runID = strings.TrimSpace(runID)
|
||||
if runID == "" {
|
||||
return fmt.Errorf("工作流运行 id 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(status) == "" {
|
||||
status = "completed"
|
||||
}
|
||||
now := time.Now()
|
||||
_, err := db.Exec(
|
||||
`UPDATE workflow_runs SET status = ?, output_json = ?, error = ?, finished_at = ? WHERE id = ?`,
|
||||
status, outputJSON, errText, now, runID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新工作流运行失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateWorkflowNodeRun(n *WorkflowNodeRun) error {
|
||||
if n == nil {
|
||||
return fmt.Errorf("工作流节点运行为空")
|
||||
}
|
||||
if strings.TrimSpace(n.ID) == "" || strings.TrimSpace(n.RunID) == "" || strings.TrimSpace(n.NodeID) == "" {
|
||||
return fmt.Errorf("节点运行 id、run_id 和 node_id 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(n.Status) == "" {
|
||||
n.Status = "running"
|
||||
}
|
||||
if n.StartedAt.IsZero() {
|
||||
n.StartedAt = time.Now()
|
||||
}
|
||||
_, err := db.Exec(
|
||||
`INSERT INTO workflow_node_runs (id, run_id, node_id, status, input_json, started_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
n.ID, n.RunID, n.NodeID, n.Status, n.InputJSON, n.StartedAt,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("创建工作流节点运行失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) FinishWorkflowNodeRun(nodeRunID, status, outputJSON, errText string) error {
|
||||
nodeRunID = strings.TrimSpace(nodeRunID)
|
||||
if nodeRunID == "" {
|
||||
return fmt.Errorf("节点运行 id 不能为空")
|
||||
}
|
||||
if strings.TrimSpace(status) == "" {
|
||||
status = "completed"
|
||||
}
|
||||
now := time.Now()
|
||||
_, err := db.Exec(
|
||||
`UPDATE workflow_node_runs SET status = ?, output_json = ?, error = ?, finished_at = ? WHERE id = ?`,
|
||||
status, outputJSON, errText, now, nodeRunID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新工作流节点运行失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) ListWorkflowNodeRuns(runID string) ([]*WorkflowNodeRun, error) {
|
||||
runID = strings.TrimSpace(runID)
|
||||
if runID == "" {
|
||||
return nil, fmt.Errorf("工作流运行 id 不能为空")
|
||||
}
|
||||
rows, err := db.Query(
|
||||
`SELECT id, run_id, node_id, status, input_json, output_json, error, started_at, finished_at
|
||||
FROM workflow_node_runs WHERE run_id = ? ORDER BY started_at ASC`,
|
||||
runID,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询工作流节点运行失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*WorkflowNodeRun
|
||||
for rows.Next() {
|
||||
row, err := scanWorkflowNodeRun(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func scanWorkflowRun(scanner interface {
|
||||
Scan(dest ...interface{}) error
|
||||
}) (*WorkflowRun, error) {
|
||||
var row WorkflowRun
|
||||
var convID, projectID, roleID, inputJSON, outputJSON, errText, pendingNode, pendingJSON sql.NullString
|
||||
var finishedAt sql.NullTime
|
||||
if err := scanner.Scan(
|
||||
&row.ID, &row.WorkflowID, &row.WorkflowVersion,
|
||||
&convID, &projectID, &roleID, &row.Status,
|
||||
&inputJSON, &outputJSON, &errText,
|
||||
&pendingNode, &pendingJSON,
|
||||
&row.StartedAt, &finishedAt,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
row.ConversationID = convID.String
|
||||
row.ProjectID = projectID.String
|
||||
row.RoleID = roleID.String
|
||||
row.InputJSON = inputJSON.String
|
||||
row.OutputJSON = outputJSON.String
|
||||
row.Error = errText.String
|
||||
row.PendingHITLNodeID = pendingNode.String
|
||||
row.PendingHITLJSON = pendingJSON.String
|
||||
if finishedAt.Valid {
|
||||
t := finishedAt.Time
|
||||
row.FinishedAt = &t
|
||||
}
|
||||
return &row, nil
|
||||
}
|
||||
|
||||
const workflowRunColumns = `id, workflow_id, workflow_version, conversation_id, project_id, role_id, status, input_json, output_json, error, pending_hitl_node_id, pending_hitl_json, started_at, finished_at`
|
||||
|
||||
func (db *DB) GetWorkflowRun(runID string) (*WorkflowRun, error) {
|
||||
runID = strings.TrimSpace(runID)
|
||||
if runID == "" {
|
||||
return nil, nil
|
||||
}
|
||||
row, err := scanWorkflowRun(db.QueryRow("SELECT "+workflowRunColumns+" FROM workflow_runs WHERE id = ?", runID))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询工作流运行失败: %w", err)
|
||||
}
|
||||
return row, nil
|
||||
}
|
||||
|
||||
func (db *DB) SetWorkflowRunStatus(runID, status string) error {
|
||||
runID = strings.TrimSpace(runID)
|
||||
if runID == "" {
|
||||
return fmt.Errorf("工作流运行 id 不能为空")
|
||||
}
|
||||
_, err := db.Exec(`UPDATE workflow_runs SET status = ? WHERE id = ?`, strings.TrimSpace(status), runID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新工作流运行状态失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) SetWorkflowRunAwaitingHITL(runID, nodeID, pendingJSON string) error {
|
||||
runID = strings.TrimSpace(runID)
|
||||
if runID == "" {
|
||||
return fmt.Errorf("工作流运行 id 不能为空")
|
||||
}
|
||||
_, err := db.Exec(
|
||||
`UPDATE workflow_runs SET status = 'awaiting_hitl', pending_hitl_node_id = ?, pending_hitl_json = ?, finished_at = NULL WHERE id = ?`,
|
||||
strings.TrimSpace(nodeID), pendingJSON, runID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("更新工作流 HITL 等待状态失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecordWorkflowRunHITLDecision stores a human decision on a paused workflow run.
|
||||
func (db *DB) RecordWorkflowRunHITLDecision(runID string, approved bool, comment string) error {
|
||||
runID = strings.TrimSpace(runID)
|
||||
if runID == "" {
|
||||
return fmt.Errorf("工作流运行 id 不能为空")
|
||||
}
|
||||
run, err := db.GetWorkflowRun(runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if run == nil {
|
||||
return fmt.Errorf("工作流运行不存在")
|
||||
}
|
||||
pending := map[string]interface{}{}
|
||||
if strings.TrimSpace(run.PendingHITLJSON) != "" {
|
||||
_ = json.Unmarshal([]byte(run.PendingHITLJSON), &pending)
|
||||
}
|
||||
if approved {
|
||||
pending["decision"] = "approved"
|
||||
} else {
|
||||
pending["decision"] = "rejected"
|
||||
}
|
||||
pending["comment"] = strings.TrimSpace(comment)
|
||||
raw, _ := json.Marshal(pending)
|
||||
_, err = db.Exec(
|
||||
`UPDATE workflow_runs SET pending_hitl_json = ? WHERE id = ? AND status = 'awaiting_hitl'`,
|
||||
string(raw), runID,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("记录工作流审批决定失败: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (db *DB) ListWorkflowRunsAwaitingHITL(limit int) ([]*WorkflowRun, error) {
|
||||
return db.ListWorkflowRunsAwaitingHITLFiltered("", limit)
|
||||
}
|
||||
|
||||
// ListWorkflowRunsAwaitingHITLFiltered returns awaiting_hitl runs, optionally scoped to a conversation.
|
||||
func (db *DB) ListWorkflowRunsAwaitingHITLFiltered(conversationID string, limit int) ([]*WorkflowRun, error) {
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
conversationID = strings.TrimSpace(conversationID)
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
if conversationID != "" {
|
||||
rows, err = db.Query(
|
||||
`SELECT `+workflowRunColumns+` FROM workflow_runs WHERE status = 'awaiting_hitl' AND conversation_id = ? ORDER BY started_at DESC LIMIT ?`,
|
||||
conversationID, limit,
|
||||
)
|
||||
} else {
|
||||
rows, err = db.Query(
|
||||
`SELECT `+workflowRunColumns+` FROM workflow_runs WHERE status = 'awaiting_hitl' ORDER BY started_at DESC LIMIT ?`,
|
||||
limit,
|
||||
)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("查询等待审批的工作流运行失败: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []*WorkflowRun
|
||||
for rows.Next() {
|
||||
row, err := scanWorkflowRun(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out = append(out, row)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (db *DB) migrateWorkflowRunsTable() error {
|
||||
cols := []struct{ name, ddl string }{
|
||||
{"pending_hitl_node_id", "ALTER TABLE workflow_runs ADD COLUMN pending_hitl_node_id TEXT"},
|
||||
{"pending_hitl_json", "ALTER TABLE workflow_runs ADD COLUMN pending_hitl_json TEXT"},
|
||||
}
|
||||
for _, col := range cols {
|
||||
var count int
|
||||
err := db.QueryRow("SELECT COUNT(*) FROM pragma_table_info('workflow_runs') WHERE name=?", col.name).Scan(&count)
|
||||
if err != nil || count > 0 {
|
||||
continue
|
||||
}
|
||||
if _, err := db.Exec(col.ddl); err != nil {
|
||||
errMsg := strings.ToLower(err.Error())
|
||||
if !strings.Contains(errMsg, "duplicate column") && !strings.Contains(errMsg, "already exists") {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func nullString(v string) interface{} {
|
||||
v = strings.TrimSpace(v)
|
||||
if v == "" {
|
||||
return nil
|
||||
}
|
||||
return v
|
||||
}
|
||||
@@ -1,286 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"database/sql"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type WorkflowPackageInspection struct {
|
||||
ID, PackageHash, ManifestJSON, WorkflowPayloadJSON, InspectionJSON string
|
||||
SourceWorkflowID, SourceContentHash, SourceGraphHash string
|
||||
SourceRevision int
|
||||
LocalConflictState, LocalWorkflowID, LocalContentHash, LocalGraphHash string
|
||||
CreatedBy, Status string
|
||||
CreatedAt, ExpiresAt time.Time
|
||||
ConsumedAt *time.Time
|
||||
}
|
||||
|
||||
type WorkflowPackageImport struct {
|
||||
ID, InspectionID, RequestHash, IdempotencyKey, ActorUserID string
|
||||
Action, SourceWorkflowID, TargetWorkflowID, ResultingWorkflowID string
|
||||
Result, ErrorCode, ErrorMessage string
|
||||
CreatedAt time.Time
|
||||
AppliedAt *time.Time
|
||||
}
|
||||
|
||||
type WorkflowPackageApplyRequest struct {
|
||||
InspectionID, RequestHash, IdempotencyKey, ActorUserID, Action, NewWorkflowID string
|
||||
ConfirmOverwrite bool
|
||||
}
|
||||
|
||||
type WorkflowPackageStoreError struct{ Code, Message string }
|
||||
|
||||
func (e *WorkflowPackageStoreError) Error() string { return e.Code + ": " + e.Message }
|
||||
func workflowPackageStoreError(code, message string) error {
|
||||
return &WorkflowPackageStoreError{code, message}
|
||||
}
|
||||
|
||||
func (db *DB) CreateWorkflowPackageInspection(v *WorkflowPackageInspection) error {
|
||||
if v == nil || strings.TrimSpace(v.ID) == "" || strings.TrimSpace(v.CreatedBy) == "" {
|
||||
return fmt.Errorf("workflow package inspection is incomplete")
|
||||
}
|
||||
if v.CreatedAt.IsZero() {
|
||||
v.CreatedAt = time.Now().UTC()
|
||||
}
|
||||
if v.ExpiresAt.IsZero() {
|
||||
v.ExpiresAt = v.CreatedAt.Add(30 * time.Minute)
|
||||
}
|
||||
_, err := db.Exec(`INSERT INTO workflow_package_inspections (id,package_hash,manifest_json,workflow_payload_json,inspection_json,source_workflow_id,source_revision,source_content_hash,source_graph_hash,local_conflict_state,local_workflow_id,local_content_hash,local_graph_hash,created_by,status,created_at,expires_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`, v.ID, v.PackageHash, v.ManifestJSON, v.WorkflowPayloadJSON, v.InspectionJSON, v.SourceWorkflowID, v.SourceRevision, v.SourceContentHash, v.SourceGraphHash, v.LocalConflictState, nullString(v.LocalWorkflowID), nullString(v.LocalContentHash), nullString(v.LocalGraphHash), v.CreatedBy, "ready", v.CreatedAt.UTC(), v.ExpiresAt.UTC())
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) GetWorkflowPackageInspection(id, actor string) (*WorkflowPackageInspection, error) {
|
||||
now := time.Now().UTC()
|
||||
_, _ = db.Exec(`UPDATE workflow_package_inspections SET status='expired' WHERE status='ready' AND expires_at <= ?`, now)
|
||||
row, err := scanWorkflowPackageInspection(db.QueryRow(`SELECT id,package_hash,manifest_json,workflow_payload_json,inspection_json,source_workflow_id,source_revision,source_content_hash,source_graph_hash,local_conflict_state,COALESCE(local_workflow_id,''),COALESCE(local_content_hash,''),COALESCE(local_graph_hash,''),created_by,status,created_at,expires_at,consumed_at FROM workflow_package_inspections WHERE id=? AND created_by=?`, strings.TrimSpace(id), strings.TrimSpace(actor)))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return row, err
|
||||
}
|
||||
|
||||
func scanWorkflowPackageInspection(s interface{ Scan(...any) error }) (*WorkflowPackageInspection, error) {
|
||||
var v WorkflowPackageInspection
|
||||
var consumed sql.NullTime
|
||||
err := s.Scan(&v.ID, &v.PackageHash, &v.ManifestJSON, &v.WorkflowPayloadJSON, &v.InspectionJSON, &v.SourceWorkflowID, &v.SourceRevision, &v.SourceContentHash, &v.SourceGraphHash, &v.LocalConflictState, &v.LocalWorkflowID, &v.LocalContentHash, &v.LocalGraphHash, &v.CreatedBy, &v.Status, &v.CreatedAt, &v.ExpiresAt, &consumed)
|
||||
if consumed.Valid {
|
||||
t := consumed.Time
|
||||
v.ConsumedAt = &t
|
||||
}
|
||||
return &v, err
|
||||
}
|
||||
|
||||
func (db *DB) GetWorkflowPackageImport(id, actor string) (*WorkflowPackageImport, error) {
|
||||
v, err := scanWorkflowPackageImport(db.QueryRow(`SELECT id,inspection_id,request_hash,idempotency_key,actor_user_id,action,source_workflow_id,target_workflow_id,COALESCE(resulting_workflow_id,''),result,COALESCE(error_code,''),COALESCE(error_message,''),created_at,applied_at FROM workflow_package_imports WHERE id=? AND actor_user_id=?`, strings.TrimSpace(id), strings.TrimSpace(actor)))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return v, err
|
||||
}
|
||||
func scanWorkflowPackageImport(s interface{ Scan(...any) error }) (*WorkflowPackageImport, error) {
|
||||
var v WorkflowPackageImport
|
||||
var applied sql.NullTime
|
||||
err := s.Scan(&v.ID, &v.InspectionID, &v.RequestHash, &v.IdempotencyKey, &v.ActorUserID, &v.Action, &v.SourceWorkflowID, &v.TargetWorkflowID, &v.ResultingWorkflowID, &v.Result, &v.ErrorCode, &v.ErrorMessage, &v.CreatedAt, &applied)
|
||||
if applied.Valid {
|
||||
t := applied.Time
|
||||
v.AppliedAt = &t
|
||||
}
|
||||
return &v, err
|
||||
}
|
||||
|
||||
func (db *DB) ApplyWorkflowPackageImport(ctx context.Context, req WorkflowPackageApplyRequest) (*WorkflowPackageImport, bool, error) {
|
||||
tx, err := db.BeginTx(ctx, nil)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var existingHash string
|
||||
previous, prevErr := scanWorkflowPackageImport(tx.QueryRowContext(ctx, `SELECT id,inspection_id,request_hash,idempotency_key,actor_user_id,action,source_workflow_id,target_workflow_id,COALESCE(resulting_workflow_id,''),result,COALESCE(error_code,''),COALESCE(error_message,''),created_at,applied_at FROM workflow_package_imports WHERE actor_user_id=? AND idempotency_key=?`, req.ActorUserID, req.IdempotencyKey))
|
||||
if prevErr == nil {
|
||||
existingHash = previous.RequestHash
|
||||
if existingHash != req.RequestHash {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_IDEMPOTENCY_KEY_REUSED", "幂等键已用于其他请求")
|
||||
}
|
||||
return previous, true, nil
|
||||
}
|
||||
if prevErr != sql.ErrNoRows {
|
||||
return nil, false, prevErr
|
||||
}
|
||||
inspection, err := scanWorkflowPackageInspection(tx.QueryRowContext(ctx, `SELECT id,package_hash,manifest_json,workflow_payload_json,inspection_json,source_workflow_id,source_revision,source_content_hash,source_graph_hash,local_conflict_state,COALESCE(local_workflow_id,''),COALESCE(local_content_hash,''),COALESCE(local_graph_hash,''),created_by,status,created_at,expires_at,consumed_at FROM workflow_package_inspections WHERE id=? AND created_by=?`, req.InspectionID, req.ActorUserID))
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_INSPECTION_NOT_FOUND", "预检不存在")
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if !inspection.ExpiresAt.After(now) || inspection.Status == "expired" {
|
||||
_, _ = tx.ExecContext(ctx, `UPDATE workflow_package_inspections SET status='expired' WHERE id=?`, inspection.ID)
|
||||
return nil, false, workflowPackageStoreError("WFPKG_INSPECTION_EXPIRED", "预检已过期")
|
||||
}
|
||||
if inspection.Status != "ready" {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_INSPECTION_CONSUMED", "预检已被使用")
|
||||
}
|
||||
var payload struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
GraphJSON string `json:"graph_json"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(inspection.WorkflowPayloadJSON), &payload); err != nil {
|
||||
return nil, false, fmt.Errorf("decode inspection payload: %w", err)
|
||||
}
|
||||
targetID := inspection.SourceWorkflowID
|
||||
if req.Action == "rename" {
|
||||
targetID = strings.TrimSpace(req.NewWorkflowID)
|
||||
if !validWorkflowPackageID(targetID) {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_INVALID_RENAME_ID", "新工作流 ID 无效")
|
||||
}
|
||||
}
|
||||
sourceCurrent, err := scanWorkflowDefinition(tx.QueryRowContext(ctx, "SELECT "+workflowDefinitionColumns+" FROM workflow_definitions WHERE id=?", inspection.SourceWorkflowID))
|
||||
if err == sql.ErrNoRows {
|
||||
sourceCurrent = nil
|
||||
} else if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err := checkWorkflowPackageSnapshot(inspection, sourceCurrent, inspection.SourceWorkflowID); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
current := sourceCurrent
|
||||
if targetID != inspection.SourceWorkflowID {
|
||||
current, err = scanWorkflowDefinition(tx.QueryRowContext(ctx, "SELECT "+workflowDefinitionColumns+" FROM workflow_definitions WHERE id=?", targetID))
|
||||
if err == sql.ErrNoRows {
|
||||
current = nil
|
||||
} else if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
}
|
||||
result := ""
|
||||
resultingID := ""
|
||||
switch req.Action {
|
||||
case "create":
|
||||
if inspection.LocalConflictState != "none" || current != nil {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_ID_CONFLICT", "目标工作流已存在")
|
||||
}
|
||||
result = "created"
|
||||
resultingID = targetID
|
||||
case "keep_existing":
|
||||
if inspection.LocalConflictState == "none" {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_ID_CONFLICT", "当前预检不允许保留本地")
|
||||
}
|
||||
if inspection.LocalConflictState == "identical" {
|
||||
result = "skipped_identical"
|
||||
} else {
|
||||
result = "kept_existing"
|
||||
}
|
||||
resultingID = targetID
|
||||
case "overwrite":
|
||||
if inspection.LocalConflictState != "id_conflict" {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_ID_CONFLICT", "当前预检不允许覆盖")
|
||||
}
|
||||
if !req.ConfirmOverwrite {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_OVERWRITE_CONFIRMATION_REQUIRED", "覆盖需要确认")
|
||||
}
|
||||
result = "overwritten"
|
||||
resultingID = targetID
|
||||
case "rename":
|
||||
if inspection.LocalConflictState != "id_conflict" || current != nil {
|
||||
return nil, false, workflowPackageStoreError("WFPKG_ID_CONFLICT", "当前预检不允许另存")
|
||||
}
|
||||
result = "renamed"
|
||||
resultingID = targetID
|
||||
default:
|
||||
return nil, false, workflowPackageStoreError("WFPKG_INVALID_ACTION", "导入动作无效")
|
||||
}
|
||||
if result == "created" || result == "renamed" {
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO workflow_definitions (id,name,description,version,graph_json,enabled,created_at,updated_at) VALUES (?,?,?,?,?,?,?,?)`, resultingID, payload.Name, payload.Description, 1, payload.GraphJSON, boolToInt(payload.Enabled), now, now)
|
||||
} else if result == "overwritten" {
|
||||
_, err = tx.ExecContext(ctx, `UPDATE workflow_definitions SET name=?,description=?,version=version+1,graph_json=?,enabled=?,updated_at=? WHERE id=?`, payload.Name, payload.Description, payload.GraphJSON, boolToInt(payload.Enabled), now, resultingID)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
imp := &WorkflowPackageImport{ID: "wpii_" + strings.ReplaceAll(uuid.NewString(), "-", ""), InspectionID: inspection.ID, RequestHash: req.RequestHash, IdempotencyKey: req.IdempotencyKey, ActorUserID: req.ActorUserID, Action: req.Action, SourceWorkflowID: inspection.SourceWorkflowID, TargetWorkflowID: targetID, ResultingWorkflowID: resultingID, Result: result, CreatedAt: now, AppliedAt: &now}
|
||||
_, err = tx.ExecContext(ctx, `INSERT INTO workflow_package_imports (id,inspection_id,request_hash,idempotency_key,actor_user_id,action,source_workflow_id,target_workflow_id,resulting_workflow_id,result,created_at,applied_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?)`, imp.ID, imp.InspectionID, imp.RequestHash, imp.IdempotencyKey, imp.ActorUserID, imp.Action, imp.SourceWorkflowID, imp.TargetWorkflowID, nullString(imp.ResultingWorkflowID), imp.Result, now, now)
|
||||
if err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if _, err = tx.ExecContext(ctx, `UPDATE workflow_package_inspections SET status='consumed',consumed_at=? WHERE id=? AND status='ready'`, now, inspection.ID); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
if err = tx.Commit(); err != nil {
|
||||
return nil, false, err
|
||||
}
|
||||
return imp, false, nil
|
||||
}
|
||||
|
||||
func checkWorkflowPackageSnapshot(i *WorkflowPackageInspection, current *WorkflowDefinition, targetID string) error {
|
||||
if i.LocalConflictState == "none" {
|
||||
if current != nil {
|
||||
return workflowPackageStoreError("WFPKG_CONFLICT_CHANGED", "本地工作流已变化")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if current == nil || current.ID != i.LocalWorkflowID || current.ID != targetID {
|
||||
return workflowPackageStoreError("WFPKG_CONFLICT_CHANGED", "本地工作流已变化")
|
||||
}
|
||||
content, graph := workflowDefinitionPackageHashes(current)
|
||||
if content != i.LocalContentHash || graph != i.LocalGraphHash {
|
||||
return workflowPackageStoreError("WFPKG_CONFLICT_CHANGED", "本地工作流已变化")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func workflowDefinitionPackageHashes(w *WorkflowDefinition) (string, string) {
|
||||
var g any
|
||||
dec := json.NewDecoder(strings.NewReader(w.GraphJSON))
|
||||
dec.UseNumber()
|
||||
_ = dec.Decode(&g)
|
||||
graph, _ := json.Marshal(g)
|
||||
payload := struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description,omitempty"`
|
||||
Version int `json:"version"`
|
||||
GraphJSON string `json:"graph_json"`
|
||||
Enabled bool `json:"enabled"`
|
||||
}{w.ID, w.Name, w.Description, w.Version, string(graph), w.Enabled}
|
||||
b, _ := json.Marshal(payload)
|
||||
return workflowPackageHash(b), workflowPackageHash(graph)
|
||||
}
|
||||
func workflowPackageHash(b []byte) string {
|
||||
s := sha256.Sum256(b)
|
||||
return "sha256:" + hex.EncodeToString(s[:])
|
||||
}
|
||||
func validWorkflowPackageID(id string) bool {
|
||||
if len(id) < 1 || len(id) > 128 {
|
||||
return false
|
||||
}
|
||||
for _, r := range id {
|
||||
if unicode.IsControl(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (db *DB) PurgeWorkflowPackageLifecycle(now time.Time) error {
|
||||
now = now.UTC()
|
||||
if _, err := db.Exec(`UPDATE workflow_package_inspections SET status='expired' WHERE status='ready' AND expires_at<=?`, now); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := db.Exec(`DELETE FROM workflow_package_inspections WHERE status='expired' AND expires_at<? AND NOT EXISTS (SELECT 1 FROM workflow_package_imports i WHERE i.inspection_id=workflow_package_inspections.id)`, now.Add(-24*time.Hour)); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := db.Exec(`DELETE FROM workflow_package_imports WHERE created_at<?`, now.AddDate(0, 0, -90))
|
||||
return err
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
package database
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestWorkflowPackageApplyOverwriteIsTransactionalAndIdempotent(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "workflow-package.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
current := &WorkflowDefinition{ID: "wf-1", Name: "Local", Version: 12, GraphJSON: `{"nodes":[]}`, Enabled: true}
|
||||
if err := db.UpsertWorkflowDefinition(current); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
current, _ = db.GetWorkflowDefinition("wf-1")
|
||||
content, graph := workflowDefinitionPackageHashes(current)
|
||||
payload, _ := json.Marshal(map[string]any{"id": "wf-1", "name": "Imported", "description": "new", "version": 18, "graph_json": `{"nodes":[]}`, "enabled": false})
|
||||
now := time.Now().UTC()
|
||||
inspection := &WorkflowPackageInspection{ID: "wpi_test", PackageHash: "sha256:pkg", ManifestJSON: "{}", WorkflowPayloadJSON: string(payload), InspectionJSON: "{}", SourceWorkflowID: "wf-1", SourceRevision: 18, SourceContentHash: "sha256:src", SourceGraphHash: "sha256:graph", LocalConflictState: "id_conflict", LocalWorkflowID: "wf-1", LocalContentHash: content, LocalGraphHash: graph, CreatedBy: "user-1", CreatedAt: now, ExpiresAt: now.Add(time.Minute)}
|
||||
if err := db.CreateWorkflowPackageInspection(inspection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req := WorkflowPackageApplyRequest{InspectionID: inspection.ID, RequestHash: "sha256:req", IdempotencyKey: "key-1", ActorUserID: "user-1", Action: "overwrite", ConfirmOverwrite: true}
|
||||
imp, replayed, err := db.ApplyWorkflowPackageImport(context.Background(), req)
|
||||
if err != nil || replayed || imp.Result != "overwritten" {
|
||||
t.Fatalf("apply = %#v replay=%v err=%v", imp, replayed, err)
|
||||
}
|
||||
updated, _ := db.GetWorkflowDefinition("wf-1")
|
||||
if updated.Version != 13 || updated.Name != "Imported" || updated.Enabled {
|
||||
t.Fatalf("updated workflow = %#v", updated)
|
||||
}
|
||||
replay, replayed, err := db.ApplyWorkflowPackageImport(context.Background(), req)
|
||||
if err != nil || !replayed || replay.ID != imp.ID {
|
||||
t.Fatalf("replay = %#v replay=%v err=%v", replay, replayed, err)
|
||||
}
|
||||
gotInspection, err := db.GetWorkflowPackageInspection(inspection.ID, "user-1")
|
||||
if err != nil || gotInspection.Status != "consumed" {
|
||||
t.Fatalf("inspection=%#v err=%v", gotInspection, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorkflowPackageApplyRejectsChangedConflictSnapshot(t *testing.T) {
|
||||
db, err := NewDB(filepath.Join(t.TempDir(), "workflow-package-conflict.db"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
if err := db.UpsertWorkflowDefinition(&WorkflowDefinition{ID: "wf-2", Name: "Local", GraphJSON: `{"nodes":[]}`, Enabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
local, _ := db.GetWorkflowDefinition("wf-2")
|
||||
content, graph := workflowDefinitionPackageHashes(local)
|
||||
payload, _ := json.Marshal(map[string]any{"id": "wf-2", "name": "Imported", "version": 2, "graph_json": `{"nodes":[]}`, "enabled": true})
|
||||
now := time.Now().UTC()
|
||||
inspection := &WorkflowPackageInspection{ID: "wpi_changed", PackageHash: "sha256:pkg", ManifestJSON: "{}", WorkflowPayloadJSON: string(payload), InspectionJSON: "{}", SourceWorkflowID: "wf-2", SourceRevision: 2, SourceContentHash: "sha256:src", SourceGraphHash: "sha256:graph", LocalConflictState: "id_conflict", LocalWorkflowID: "wf-2", LocalContentHash: content, LocalGraphHash: graph, CreatedBy: "user-1", CreatedAt: now, ExpiresAt: now.Add(time.Minute)}
|
||||
if err := db.CreateWorkflowPackageInspection(inspection); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.UpsertWorkflowDefinition(&WorkflowDefinition{ID: "wf-2", Name: "Changed", GraphJSON: `{"nodes":[]}`, Enabled: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err = db.ApplyWorkflowPackageImport(context.Background(), WorkflowPackageApplyRequest{InspectionID: inspection.ID, RequestHash: "sha256:req", IdempotencyKey: "key-2", ActorUserID: "user-1", Action: "overwrite", ConfirmOverwrite: true})
|
||||
if e, ok := err.(*WorkflowPackageStoreError); !ok || e.Code != "WFPKG_CONFLICT_CHANGED" {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user