diff --git a/supply-api/cmd/supply-api/main.go b/supply-api/cmd/supply-api/main.go index 56ab8390..f5134d8d 100644 --- a/supply-api/cmd/supply-api/main.go +++ b/supply-api/cmd/supply-api/main.go @@ -12,6 +12,7 @@ import ( "lijiaoqiao/supply-api/internal/adapter" "lijiaoqiao/supply-api/internal/audit" auditrepo "lijiaoqiao/supply-api/internal/audit/repository" + auditservice "lijiaoqiao/supply-api/internal/audit/service" "lijiaoqiao/supply-api/internal/cache" "lijiaoqiao/supply-api/internal/compensation" "lijiaoqiao/supply-api/internal/config" @@ -122,6 +123,16 @@ func main() { jsonLogger.Info("警告: 审计存储使用内存实现 (生产环境不应使用)") } + var alertStore auditservice.AlertStoreInterface + if db != nil { + alertStore = auditrepo.NewPostgresAlertRepository(db.Pool) + jsonLogger.Info("告警存储: 使用PostgreSQL (DB-backed)") + } else { + alertStore = auditservice.NewInMemoryAlertStore() + jsonLogger.Info("警告: 告警存储使用内存实现 (仅开发环境允许)") + } + alertService := auditservice.NewAlertService(alertStore) + // P0-09修复: 初始化外键校验器 var fkValidator *repository.ForeignKeyValidator if db != nil { @@ -244,7 +255,7 @@ func main() { api.Register(mux) // 注册告警API路由 - alertAPI := httpapi.NewAlertAPI() + alertAPI := httpapi.NewAlertAPI(alertService) alertAPI.Register(mux) // 应用中间件链路 diff --git a/supply-api/internal/audit/alerterr/errors.go b/supply-api/internal/audit/alerterr/errors.go new file mode 100644 index 00000000..6c35a075 --- /dev/null +++ b/supply-api/internal/audit/alerterr/errors.go @@ -0,0 +1,9 @@ +package alerterr + +import "errors" + +var ( + ErrAlertNotFound = errors.New("alert not found") + ErrInvalidAlertInput = errors.New("invalid alert input") + ErrAlertConflict = errors.New("alert conflict") +) diff --git a/supply-api/internal/audit/repository/alert_repository.go b/supply-api/internal/audit/repository/alert_repository.go new file mode 100644 index 00000000..34473808 --- /dev/null +++ b/supply-api/internal/audit/repository/alert_repository.go @@ -0,0 +1,459 @@ +package repository + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgxpool" + + "lijiaoqiao/supply-api/internal/audit/alerterr" + "lijiaoqiao/supply-api/internal/audit/model" +) + +var errAlertRepositoryPoolRequired = errors.New("postgres alert repository requires a pool") + +// PostgresAlertRepository PostgreSQL实现的告警仓储。 +type PostgresAlertRepository struct { + pool *pgxpool.Pool +} + +// NewPostgresAlertRepository 创建PostgreSQL告警仓储。 +func NewPostgresAlertRepository(pool *pgxpool.Pool) *PostgresAlertRepository { + return &PostgresAlertRepository{pool: pool} +} + +// Create 创建告警。 +func (r *PostgresAlertRepository) Create(ctx context.Context, alert *model.Alert) error { + if err := r.requirePool(); err != nil { + return err + } + if alert == nil { + return alerterr.ErrInvalidAlertInput + } + + now := time.Now() + if alert.AlertID == "" { + alert.AlertID = generateAlertID() + } + if alert.Status == "" { + alert.Status = model.AlertStatusActive + } + if alert.CreatedAt.IsZero() { + alert.CreatedAt = now + } + if alert.UpdatedAt.IsZero() { + alert.UpdatedAt = alert.CreatedAt + } + if alert.FirstSeenAt.IsZero() { + alert.FirstSeenAt = alert.CreatedAt + } + if alert.LastSeenAt.IsZero() { + alert.LastSeenAt = alert.UpdatedAt + } + + eventIDsJSON, notifyChannelsJSON, metadataJSON, tagsJSON, err := marshalAlertCollections(alert) + if err != nil { + return err + } + + const query = ` + INSERT INTO audit_alerts ( + alert_id, alert_name, alert_type, alert_level, tenant_id, supplier_id, + title, message, description, + event_id, event_ids, + trigger_condition, threshold, current_value, + status, resolved_at, resolved_by, resolve_note, + notify_enabled, notify_channels, + created_at, updated_at, first_seen_at, last_seen_at, + metadata, tags + ) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, + $10, $11, + $12, $13, $14, + $15, $16, $17, $18, + $19, $20, + $21, $22, $23, $24, + $25, $26 + ) + ` + + _, err = r.pool.Exec(ctx, query, + alert.AlertID, alert.AlertName, alert.AlertType, alert.AlertLevel, alert.TenantID, nullableInt64(alert.SupplierID), + alert.Title, alert.Message, alert.Description, + nullableString(alert.EventID), eventIDsJSON, + alert.TriggerCondition, alert.Threshold, alert.CurrentValue, + alert.Status, alert.ResolvedAt, nullableString(alert.ResolvedBy), nullableString(alert.ResolveNote), + alert.NotifyEnabled, notifyChannelsJSON, + alert.CreatedAt, alert.UpdatedAt, alert.FirstSeenAt, alert.LastSeenAt, + metadataJSON, tagsJSON, + ) + if err != nil { + if isUniqueViolation(err) { + return alerterr.ErrAlertConflict + } + return fmt.Errorf("create alert: %w", err) + } + + return nil +} + +// GetByID 根据ID查询告警。 +func (r *PostgresAlertRepository) GetByID(ctx context.Context, alertID string) (*model.Alert, error) { + if err := r.requirePool(); err != nil { + return nil, err + } + + const query = ` + SELECT + alert_id, alert_name, alert_type, alert_level, tenant_id, supplier_id, + title, message, description, + event_id, event_ids, + trigger_condition, threshold, current_value, + status, resolved_at, resolved_by, resolve_note, + notify_enabled, notify_channels, + created_at, updated_at, first_seen_at, last_seen_at, + metadata, tags + FROM audit_alerts + WHERE alert_id = $1 + ` + + alert, err := scanAlert(r.pool.QueryRow(ctx, query, alertID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, alerterr.ErrAlertNotFound + } + return nil, fmt.Errorf("get alert by id: %w", err) + } + return alert, nil +} + +// Update 更新告警。 +func (r *PostgresAlertRepository) Update(ctx context.Context, alert *model.Alert) error { + if err := r.requirePool(); err != nil { + return err + } + if alert == nil || alert.AlertID == "" { + return alerterr.ErrInvalidAlertInput + } + if alert.UpdatedAt.IsZero() { + alert.UpdatedAt = time.Now() + } + + eventIDsJSON, notifyChannelsJSON, metadataJSON, tagsJSON, err := marshalAlertCollections(alert) + if err != nil { + return err + } + + const query = ` + UPDATE audit_alerts + SET + alert_name = $2, + alert_type = $3, + alert_level = $4, + tenant_id = $5, + supplier_id = $6, + title = $7, + message = $8, + description = $9, + event_id = $10, + event_ids = $11, + trigger_condition = $12, + threshold = $13, + current_value = $14, + status = $15, + resolved_at = $16, + resolved_by = $17, + resolve_note = $18, + notify_enabled = $19, + notify_channels = $20, + updated_at = $21, + first_seen_at = $22, + last_seen_at = $23, + metadata = $24, + tags = $25 + WHERE alert_id = $1 + ` + + tag, err := r.pool.Exec(ctx, query, + alert.AlertID, + alert.AlertName, alert.AlertType, alert.AlertLevel, alert.TenantID, nullableInt64(alert.SupplierID), + alert.Title, alert.Message, alert.Description, + nullableString(alert.EventID), eventIDsJSON, + alert.TriggerCondition, alert.Threshold, alert.CurrentValue, + alert.Status, alert.ResolvedAt, nullableString(alert.ResolvedBy), nullableString(alert.ResolveNote), + alert.NotifyEnabled, notifyChannelsJSON, + alert.UpdatedAt, alert.FirstSeenAt, alert.LastSeenAt, + metadataJSON, tagsJSON, + ) + if err != nil { + return fmt.Errorf("update alert: %w", err) + } + if tag.RowsAffected() == 0 { + return alerterr.ErrAlertNotFound + } + return nil +} + +// Delete 删除告警。 +func (r *PostgresAlertRepository) Delete(ctx context.Context, alertID string) error { + if err := r.requirePool(); err != nil { + return err + } + + tag, err := r.pool.Exec(ctx, `DELETE FROM audit_alerts WHERE alert_id = $1`, alertID) + if err != nil { + return fmt.Errorf("delete alert: %w", err) + } + if tag.RowsAffected() == 0 { + return alerterr.ErrAlertNotFound + } + return nil +} + +// List 查询告警列表。 +func (r *PostgresAlertRepository) List(ctx context.Context, filter *model.AlertFilter) ([]*model.Alert, int64, error) { + if err := r.requirePool(); err != nil { + return nil, 0, err + } + if filter == nil { + filter = &model.AlertFilter{} + } + + conditions := make([]string, 0, 8) + args := make([]any, 0, 8) + nextArg := func(value any) string { + args = append(args, value) + return fmt.Sprintf("$%d", len(args)) + } + + if filter.TenantID > 0 { + conditions = append(conditions, "tenant_id = "+nextArg(filter.TenantID)) + } + if filter.SupplierID > 0 { + conditions = append(conditions, "supplier_id = "+nextArg(filter.SupplierID)) + } + if filter.AlertType != "" { + conditions = append(conditions, "alert_type = "+nextArg(filter.AlertType)) + } + if filter.AlertLevel != "" { + conditions = append(conditions, "alert_level = "+nextArg(filter.AlertLevel)) + } + if filter.Status != "" { + conditions = append(conditions, "status = "+nextArg(filter.Status)) + } + if !filter.StartTime.IsZero() { + conditions = append(conditions, "created_at >= "+nextArg(filter.StartTime)) + } + if !filter.EndTime.IsZero() { + conditions = append(conditions, "created_at <= "+nextArg(filter.EndTime)) + } + if filter.Keywords != "" { + kw := "%" + strings.ToLower(filter.Keywords) + "%" + placeholder := nextArg(kw) + conditions = append(conditions, "(LOWER(title) LIKE "+placeholder+" OR LOWER(message) LIKE "+placeholder+")") + } + + whereClause := "" + if len(conditions) > 0 { + whereClause = " WHERE " + strings.Join(conditions, " AND ") + } + + countQuery := "SELECT COUNT(*) FROM audit_alerts" + whereClause + var total int64 + if err := r.pool.QueryRow(ctx, countQuery, args...).Scan(&total); err != nil { + return nil, 0, fmt.Errorf("count alerts: %w", err) + } + + limit := filter.Limit + if limit <= 0 { + limit = 100 + } + if limit > 1000 { + limit = 1000 + } + offset := filter.Offset + if offset < 0 { + offset = 0 + } + + listArgs := append(append([]any{}, args...), limit, offset) + query := ` + SELECT + alert_id, alert_name, alert_type, alert_level, tenant_id, supplier_id, + title, message, description, + event_id, event_ids, + trigger_condition, threshold, current_value, + status, resolved_at, resolved_by, resolve_note, + notify_enabled, notify_channels, + created_at, updated_at, first_seen_at, last_seen_at, + metadata, tags + FROM audit_alerts` + whereClause + ` + ORDER BY created_at DESC, alert_id DESC + LIMIT $` + fmt.Sprintf("%d", len(args)+1) + ` + OFFSET $` + fmt.Sprintf("%d", len(args)+2) + + rows, err := r.pool.Query(ctx, query, listArgs...) + if err != nil { + return nil, 0, fmt.Errorf("list alerts: %w", err) + } + defer rows.Close() + + alerts := make([]*model.Alert, 0, limit) + for rows.Next() { + alert, scanErr := scanAlert(rows) + if scanErr != nil { + return nil, 0, fmt.Errorf("scan alert: %w", scanErr) + } + alerts = append(alerts, alert) + } + if err := rows.Err(); err != nil { + return nil, 0, fmt.Errorf("iterate alerts: %w", err) + } + + return alerts, total, nil +} + +func (r *PostgresAlertRepository) requirePool() error { + if r == nil || r.pool == nil { + return errAlertRepositoryPoolRequired + } + return nil +} + +type alertScanner interface { + Scan(dest ...any) error +} + +func scanAlert(scanner alertScanner) (*model.Alert, error) { + var ( + alert model.Alert + supplierID *int64 + eventID *string + resolvedBy *string + resolveNote *string + resolvedAt *time.Time + eventIDsJSON []byte + notifyJSON []byte + metadataJSON []byte + tagsJSON []byte + ) + + err := scanner.Scan( + &alert.AlertID, &alert.AlertName, &alert.AlertType, &alert.AlertLevel, &alert.TenantID, &supplierID, + &alert.Title, &alert.Message, &alert.Description, + &eventID, &eventIDsJSON, + &alert.TriggerCondition, &alert.Threshold, &alert.CurrentValue, + &alert.Status, &resolvedAt, &resolvedBy, &resolveNote, + &alert.NotifyEnabled, ¬ifyJSON, + &alert.CreatedAt, &alert.UpdatedAt, &alert.FirstSeenAt, &alert.LastSeenAt, + &metadataJSON, &tagsJSON, + ) + if err != nil { + return nil, err + } + + if supplierID != nil { + alert.SupplierID = *supplierID + } + if eventID != nil { + alert.EventID = *eventID + } + if resolvedBy != nil { + alert.ResolvedBy = *resolvedBy + } + if resolveNote != nil { + alert.ResolveNote = *resolveNote + } + if resolvedAt != nil { + alert.ResolvedAt = resolvedAt + } + + if err := unmarshalJSONSlice(eventIDsJSON, &alert.EventIDs); err != nil { + return nil, fmt.Errorf("decode event_ids: %w", err) + } + if err := unmarshalJSONSlice(notifyJSON, &alert.NotifyChannels); err != nil { + return nil, fmt.Errorf("decode notify_channels: %w", err) + } + if err := unmarshalJSONMap(metadataJSON, &alert.Metadata); err != nil { + return nil, fmt.Errorf("decode metadata: %w", err) + } + if err := unmarshalJSONSlice(tagsJSON, &alert.Tags); err != nil { + return nil, fmt.Errorf("decode tags: %w", err) + } + + return &alert, nil +} + +func marshalAlertCollections(alert *model.Alert) ([]byte, []byte, []byte, []byte, error) { + eventIDsJSON, err := marshalJSONOrDefault(alert.EventIDs, []string{}) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("marshal event ids: %w", err) + } + notifyChannelsJSON, err := marshalJSONOrDefault(alert.NotifyChannels, []string{}) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("marshal notify channels: %w", err) + } + metadataJSON, err := marshalJSONOrDefault(alert.Metadata, map[string]any{}) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("marshal metadata: %w", err) + } + tagsJSON, err := marshalJSONOrDefault(alert.Tags, []string{}) + if err != nil { + return nil, nil, nil, nil, fmt.Errorf("marshal tags: %w", err) + } + return eventIDsJSON, notifyChannelsJSON, metadataJSON, tagsJSON, nil +} + +func marshalJSONOrDefault(value any, defaultValue any) ([]byte, error) { + if value == nil { + return json.Marshal(defaultValue) + } + return json.Marshal(value) +} + +func unmarshalJSONSlice(data []byte, target *[]string) error { + if len(data) == 0 { + *target = []string{} + return nil + } + return json.Unmarshal(data, target) +} + +func unmarshalJSONMap(data []byte, target *map[string]any) error { + if len(data) == 0 { + *target = map[string]any{} + return nil + } + return json.Unmarshal(data, target) +} + +func generateAlertID() string { + return "ALT-" + uuid.New().String()[:8] +} + +func nullableString(value string) any { + if value == "" { + return nil + } + return value +} + +func nullableInt64(value int64) any { + if value == 0 { + return nil + } + return value +} + +func isUniqueViolation(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "23505" +} diff --git a/supply-api/internal/audit/repository/alert_repository_test.go b/supply-api/internal/audit/repository/alert_repository_test.go new file mode 100644 index 00000000..02e79405 --- /dev/null +++ b/supply-api/internal/audit/repository/alert_repository_test.go @@ -0,0 +1,62 @@ +package repository + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "lijiaoqiao/supply-api/internal/audit/model" +) + +func TestNewPostgresAlertRepository_ImplementsAlertStore(t *testing.T) { + repo := NewPostgresAlertRepository(nil) + if repo == nil { + t.Fatal("expected repository") + } + + var store interface { + Create(context.Context, *model.Alert) error + GetByID(context.Context, string) (*model.Alert, error) + Update(context.Context, *model.Alert) error + Delete(context.Context, string) error + List(context.Context, *model.AlertFilter) ([]*model.Alert, int64, error) + } = repo + if store == nil { + t.Fatal("expected alert store implementation") + } +} + +func TestPostgresAlertRepository_RequiresPool(t *testing.T) { + repo := NewPostgresAlertRepository(nil) + err := repo.Create(context.Background(), &model.Alert{Title: "pool guard"}) + if err == nil { + t.Fatal("expected nil pool error") + } + if !strings.Contains(err.Error(), "pool") { + t.Fatalf("expected error to mention pool, got %v", err) + } +} + +func TestAuditAlertsDDL_DefinesAlertTable(t *testing.T) { + content, err := os.ReadFile(filepath.Join("..", "..", "..", "sql", "postgresql", "audit_alerts_v1.sql")) + if err != nil { + t.Fatalf("failed to read ddl: %v", err) + } + + sql := string(content) + checks := []string{ + "create table if not exists audit_alerts", + "alert_id text primary key", + "metadata jsonb not null default '{}'::jsonb", + "tags jsonb not null default '[]'::jsonb", + "notify_channels jsonb not null default '[]'::jsonb", + "event_ids jsonb not null default '[]'::jsonb", + } + for _, check := range checks { + if !strings.Contains(strings.ToLower(sql), check) { + t.Fatalf("expected ddl to contain %q", check) + } + } +} diff --git a/supply-api/internal/audit/service/alert_service.go b/supply-api/internal/audit/service/alert_service.go index 46b586e5..5fa47aff 100644 --- a/supply-api/internal/audit/service/alert_service.go +++ b/supply-api/internal/audit/service/alert_service.go @@ -9,14 +9,15 @@ import ( "github.com/google/uuid" + "lijiaoqiao/supply-api/internal/audit/alerterr" "lijiaoqiao/supply-api/internal/audit/model" ) // 错误定义 var ( - ErrAlertNotFound = errors.New("alert not found") - ErrInvalidAlertInput = errors.New("invalid alert input") - ErrAlertConflict = errors.New("alert conflict") + ErrAlertNotFound = alerterr.ErrAlertNotFound + ErrInvalidAlertInput = alerterr.ErrInvalidAlertInput + ErrAlertConflict = alerterr.ErrAlertConflict ) // AlertStoreInterface 告警存储接口 diff --git a/supply-api/internal/audit/service/alert_service_test.go b/supply-api/internal/audit/service/alert_service_test.go index a92f19c1..ef7cfff9 100644 --- a/supply-api/internal/audit/service/alert_service_test.go +++ b/supply-api/internal/audit/service/alert_service_test.go @@ -483,6 +483,38 @@ func TestInMemoryAlertStore_CRUD(t *testing.T) { assert.Error(t, err) } +func TestInMemoryAlertStore_PreservesMetadataAndTags(t *testing.T) { + ctx := context.Background() + store := NewInMemoryAlertStore() + + alert := &model.Alert{ + AlertID: "test-meta-001", + AlertName: "Metadata Test", + AlertType: model.AlertTypeSecurity, + AlertLevel: model.AlertLevelWarning, + TenantID: 1001, + Title: "Metadata", + Message: "metadata and tags", + Status: model.AlertStatusActive, + Metadata: map[string]any{ + "source": "unit-test", + }, + Tags: []string{"urgent", "security"}, + NotifyChannels: []string{"email"}, + EventIDs: []string{"evt-001"}, + } + + err := store.Create(ctx, alert) + assert.NoError(t, err) + + stored, err := store.GetByID(ctx, "test-meta-001") + assert.NoError(t, err) + assert.Equal(t, "unit-test", stored.Metadata["source"]) + assert.Equal(t, []string{"urgent", "security"}, stored.Tags) + assert.Equal(t, []string{"email"}, stored.NotifyChannels) + assert.Equal(t, []string{"evt-001"}, stored.EventIDs) +} + func TestInMemoryAlertStore_GetByID_NotFound(t *testing.T) { ctx := context.Background() store := NewInMemoryAlertStore() @@ -524,13 +556,13 @@ func TestInMemoryAlertStore_List_FilterByTenant(t *testing.T) { store.Create(ctx, &model.Alert{ AlertID: "1", - TenantID: 1001, + TenantID: 1001, Title: "Tenant 1001 Alert", AlertType: model.AlertTypeSecurity, }) store.Create(ctx, &model.Alert{ AlertID: "2", - TenantID: 1002, + TenantID: 1002, Title: "Tenant 1002 Alert", AlertType: model.AlertTypeSecurity, }) @@ -550,13 +582,13 @@ func TestInMemoryAlertStore_List_FilterByAlertType(t *testing.T) { store.Create(ctx, &model.Alert{ AlertID: "1", - TenantID: 1001, + TenantID: 1001, AlertType: model.AlertTypeSecurity, Title: "Security", }) store.Create(ctx, &model.Alert{ AlertID: "2", - TenantID: 1001, + TenantID: 1001, AlertType: model.AlertTypeQuota, Title: "Quota", }) @@ -607,16 +639,16 @@ func TestInMemoryAlertStore_List_FilterByStatus(t *testing.T) { store := NewInMemoryAlertStore() store.Create(ctx, &model.Alert{ - AlertID: "1", - TenantID: 1001, - Status: model.AlertStatusActive, - Title: "Active", + AlertID: "1", + TenantID: 1001, + Status: model.AlertStatusActive, + Title: "Active", }) store.Create(ctx, &model.Alert{ - AlertID: "2", - TenantID: 1001, - Status: model.AlertStatusResolved, - Title: "Resolved", + AlertID: "2", + TenantID: 1001, + Status: model.AlertStatusResolved, + Title: "Resolved", }) filter := &model.AlertFilter{ @@ -640,16 +672,16 @@ func TestInMemoryAlertStore_List_FilterByTimeRange(t *testing.T) { recentTime := now.Add(-10 * time.Minute) store.Create(ctx, &model.Alert{ - AlertID: "1", - TenantID: 1001, - CreatedAt: oldTime, - Title: "Old", + AlertID: "1", + TenantID: 1001, + CreatedAt: oldTime, + Title: "Old", }) store.Create(ctx, &model.Alert{ - AlertID: "2", - TenantID: 1001, - CreatedAt: recentTime, - Title: "Recent", + AlertID: "2", + TenantID: 1001, + CreatedAt: recentTime, + Title: "Recent", }) // Filter for recent alerts only @@ -671,20 +703,20 @@ func TestInMemoryAlertStore_List_FilterByKeywords(t *testing.T) { store := NewInMemoryAlertStore() store.Create(ctx, &model.Alert{ - AlertID: "1", - TenantID: 1001, - Title: "Database Connection Error", - Message: "Failed to connect to DB", + AlertID: "1", + TenantID: 1001, + Title: "Database Connection Error", + Message: "Failed to connect to DB", }) store.Create(ctx, &model.Alert{ - AlertID: "2", - TenantID: 1001, - Title: "API Timeout", - Message: "Request timed out", + AlertID: "2", + TenantID: 1001, + Title: "API Timeout", + Message: "Request timed out", }) filter := &model.AlertFilter{ - TenantID: 1001, + TenantID: 1001, Keywords: "Database", } results, total, err := store.List(ctx, filter) @@ -701,9 +733,9 @@ func TestInMemoryAlertStore_List_Pagination(t *testing.T) { for i := 0; i < 10; i++ { store.Create(ctx, &model.Alert{ - AlertID: string(rune('0' + i)), - TenantID: 1001, - Title: "Test", + AlertID: string(rune('0' + i)), + TenantID: 1001, + Title: "Test", }) } @@ -720,9 +752,9 @@ func TestInMemoryAlertStore_List_OffsetBeyondBounds(t *testing.T) { store := NewInMemoryAlertStore() store.Create(ctx, &model.Alert{ - AlertID: "1", - TenantID: 1001, - Title: "Test", + AlertID: "1", + TenantID: 1001, + Title: "Test", }) filter := &model.AlertFilter{TenantID: 1001, Limit: 10, Offset: 100} @@ -783,7 +815,7 @@ func TestAlert_UpdateLastSeen(t *testing.T) { alert.UpdateLastSeen() - assert.True(t, alert.LastSeenAt.After(time.Now().Add(-1 * time.Hour))) + assert.True(t, alert.LastSeenAt.After(time.Now().Add(-1*time.Hour))) } func TestAlert_AddEventID(t *testing.T) { diff --git a/supply-api/internal/httpapi/alert_api.go b/supply-api/internal/httpapi/alert_api.go index f370517e..44e44aad 100644 --- a/supply-api/internal/httpapi/alert_api.go +++ b/supply-api/internal/httpapi/alert_api.go @@ -14,12 +14,11 @@ type AlertAPI struct { } // NewAlertAPI 创建告警API处理器 -func NewAlertAPI() *AlertAPI { - // 创建内存告警存储 - alertStore := service.NewInMemoryAlertStore() - // 创建告警服务 - alertSvc := service.NewAlertService(alertStore) - // 创建告警处理器 +func NewAlertAPI(alertSvc *service.AlertService) *AlertAPI { + if alertSvc == nil { + panic("alert service is required") + } + alertHandler := handler.NewAlertHandler(alertSvc) return &AlertAPI{ diff --git a/supply-api/internal/httpapi/alert_api_test.go b/supply-api/internal/httpapi/alert_api_test.go new file mode 100644 index 00000000..b9894221 --- /dev/null +++ b/supply-api/internal/httpapi/alert_api_test.go @@ -0,0 +1,42 @@ +package httpapi + +import ( + "context" + "testing" + + "lijiaoqiao/supply-api/internal/audit/model" + "lijiaoqiao/supply-api/internal/audit/service" +) + +type recordingAlertStore struct { + createCalls int +} + +func (s *recordingAlertStore) Create(ctx context.Context, alert *model.Alert) error { + s.createCalls++ + return nil +} + +func (s *recordingAlertStore) GetByID(ctx context.Context, alertID string) (*model.Alert, error) { + return nil, service.ErrAlertNotFound +} + +func (s *recordingAlertStore) Update(ctx context.Context, alert *model.Alert) error { + return nil +} + +func (s *recordingAlertStore) Delete(ctx context.Context, alertID string) error { + return nil +} + +func (s *recordingAlertStore) List(ctx context.Context, filter *model.AlertFilter) ([]*model.Alert, int64, error) { + return nil, 0, nil +} + +func TestNewAlertAPI_UsesInjectedStore(t *testing.T) { + store := &recordingAlertStore{} + api := NewAlertAPI(service.NewAlertService(store)) + if api == nil { + t.Fatal("expected api") + } +} diff --git a/supply-api/sql/postgresql/audit_alerts_v1.sql b/supply-api/sql/postgresql/audit_alerts_v1.sql new file mode 100644 index 00000000..da283a4d --- /dev/null +++ b/supply-api/sql/postgresql/audit_alerts_v1.sql @@ -0,0 +1,41 @@ +-- 审计告警持久化表 +-- 用途:为 /api/v1/audit/alerts 提供 PostgreSQL-backed 存储,避免 HTTP 层固定回退到内存实现。 + +CREATE TABLE IF NOT EXISTS audit_alerts ( + alert_id TEXT PRIMARY KEY, + alert_name TEXT NOT NULL DEFAULT '', + alert_type TEXT NOT NULL, + alert_level TEXT NOT NULL, + tenant_id BIGINT NOT NULL, + supplier_id BIGINT, + title TEXT NOT NULL, + message TEXT NOT NULL DEFAULT '', + description TEXT NOT NULL DEFAULT '', + event_id TEXT, + event_ids JSONB NOT NULL DEFAULT '[]'::jsonb, + trigger_condition TEXT NOT NULL DEFAULT '', + threshold DOUBLE PRECISION NOT NULL DEFAULT 0, + current_value DOUBLE PRECISION NOT NULL DEFAULT 0, + status TEXT NOT NULL, + resolved_at TIMESTAMPTZ, + resolved_by TEXT, + resolve_note TEXT, + notify_enabled BOOLEAN NOT NULL DEFAULT TRUE, + notify_channels JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + first_seen_at TIMESTAMPTZ NOT NULL, + last_seen_at TIMESTAMPTZ NOT NULL, + metadata JSONB NOT NULL DEFAULT '{}'::jsonb, + tags JSONB NOT NULL DEFAULT '[]'::jsonb +); + +CREATE INDEX IF NOT EXISTS idx_audit_alerts_tenant_status_created_at + ON audit_alerts (tenant_id, status, created_at DESC); + +CREATE INDEX IF NOT EXISTS idx_audit_alerts_tenant_type_level + ON audit_alerts (tenant_id, alert_type, alert_level); + +CREATE INDEX IF NOT EXISTS idx_audit_alerts_supplier_created_at + ON audit_alerts (supplier_id, created_at DESC) + WHERE supplier_id IS NOT NULL;