package alerts import ( "context" "encoding/json" "errors" "strings" "time" "data-terminal-cloud/internal/audit" "data-terminal-cloud/internal/idempotency" "data-terminal-cloud/internal/identity" "data-terminal-cloud/internal/outbox" "github.com/google/uuid" "github.com/jackc/pgx/v5" ) const alertDeliveryTestEventType = "alert.webhook_test_requested" type DeliveryTestInput struct { Reason string `json:"reason"` } type DeliveryTest struct { ID string `json:"id"` TestRef string `json:"test_ref"` RoutingPolicyID string `json:"routing_policy_id"` Category string `json:"category"` Severity string `json:"severity"` ReceiverGroup string `json:"receiver_group"` RunbookRef string `json:"runbook_ref"` Status string `json:"status"` AttemptCount int `json:"attempt_count"` ManualRetryCount int `json:"manual_retry_count"` LastErrorCode string `json:"last_error_code,omitempty"` LastManualRetryAt *time.Time `json:"last_manual_retry_at,omitempty"` CreatedAt time.Time `json:"created_at"` CompletedAt *time.Time `json:"completed_at,omitempty"` DeadAt *time.Time `json:"dead_at,omitempty"` } type deliveryTestPayload struct { Action string `json:"action"` TestRef string `json:"test_ref"` TenantID string `json:"tenant_id"` RoutingPolicyID string `json:"routing_policy_id"` Category string `json:"category"` Severity string `json:"severity"` ReceiverGroup string `json:"receiver_group"` RunbookRef string `json:"runbook_ref"` Title string `json:"title"` Summary string `json:"summary"` OccurredAt time.Time `json:"occurred_at"` } func (s *Service) CreateDeliveryTest(ctx context.Context, principal identity.Principal, tenantPublicID, routingPolicyPublicID, key, requestID string, input DeliveryTestInput) (DeliveryTest, bool, error) { input.Reason = strings.TrimSpace(input.Reason) if !validUUID(routingPolicyPublicID) || input.Reason == "" || len([]rune(input.Reason)) > 500 { return DeliveryTest{}, false, ErrInvalid } a, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_operator", "ops_approver", "ops_admin") if err != nil { return DeliveryTest{}, false, err } requestBody, _ := json.Marshal(struct { RoutingPolicyID string `json:"routing_policy_id"` Reason string `json:"reason"` }{routingPolicyPublicID, input.Reason}) result, err := s.idem.Execute(ctx, idempotency.Request{ TenantID: &a.tenantID, ActorKey: "ops:" + a.userPublicID, Scope: "POST /api/v1/ops/alert-routing-policies/{id}/delivery-tests", Key: key, RequestHash: idempotency.Hash(requestBody), ExpiresAfter: 24 * time.Hour, }, func(op context.Context, tx pgx.Tx) (idempotency.Result, error) { var routeInternalID, category, severity, receiverGroup, runbookRef string err := tx.QueryRow(op, `SELECT id::text,category,severity,receiver_group,runbook_ref FROM alert_routing_policy WHERE tenant_id=$1::uuid AND public_id=$2::uuid AND status='ACTIVE'`, a.tenantID, routingPolicyPublicID). Scan(&routeInternalID, &category, &severity, &receiverGroup, &runbookRef) if errors.Is(err, pgx.ErrNoRows) { return idempotency.Result{}, ErrNotFound } if err != nil { return idempotency.Result{}, err } testRef := uuid.NewString() payload, _ := json.Marshal(deliveryTestPayload{ Action: "TEST", TestRef: testRef, TenantID: a.tenantPublicID, RoutingPolicyID: routingPolicyPublicID, Category: category, Severity: severity, ReceiverGroup: receiverGroup, RunbookRef: runbookRef, Title: "告警接收器联通测试", Summary: "这是一条由运营工作台发起的受控测试通知,不代表真实故障。", OccurredAt: time.Now().UTC(), }) dedupe := "alert-delivery-test:" + testRef eventID, err := outbox.NewStore(s.pool).Enqueue(op, tx, outbox.NewEvent{ TenantID: &a.tenantID, AggregateType: "alert_delivery_test", AggregateID: testRef, EventType: alertDeliveryTestEventType, DeduplicationKey: &dedupe, Payload: payload, }) if err != nil { return idempotency.Result{}, err } if err = s.audit.Record(op, tx, audit.Event{ TenantID: &a.tenantID, ActorType: "user", ActorPublicID: &a.userPublicID, Action: "alert.delivery_test.create", ResourceType: "alert_delivery_test", ResourceID: &eventID, RequestID: requestID, Purpose: input.Reason, Result: "success", Detail: map[string]any{"routing_policy_id": routingPolicyPublicID, "receiver_group": receiverGroup, "test_ref": testRef}, }); err != nil { return idempotency.Result{}, err } item, err := getDeliveryTestTx(op, tx, a.tenantID, eventID) if err != nil { return idempotency.Result{}, err } body, _ := json.Marshal(item) return idempotency.Result{StatusCode: 201, Body: body, ResourceID: &eventID}, nil }) if err != nil { return DeliveryTest{}, false, err } var item DeliveryTest if err = json.Unmarshal(result.Body, &item); err != nil { return DeliveryTest{}, false, err } return item, !result.Replayed, nil } func (s *Service) ListDeliveryTests(ctx context.Context, principal identity.Principal, tenantPublicID string) ([]DeliveryTest, error) { a, err := s.resolveActor(ctx, principal, tenantPublicID, "ops_viewer", "ops_operator", "ops_approver", "ops_admin") if err != nil { return nil, err } rows, err := s.pool.Query(ctx, deliveryTestSelect+` WHERE o.tenant_id=$1::uuid AND o.event_type=$2 ORDER BY o.created_at DESC LIMIT 50`, a.tenantID, alertDeliveryTestEventType) if err != nil { return nil, err } defer rows.Close() items := []DeliveryTest{} for rows.Next() { item, scanErr := scanDeliveryTest(rows) if scanErr != nil { return nil, scanErr } items = append(items, item) } return items, rows.Err() } const deliveryTestSelect = `SELECT o.public_id::text,o.aggregate_id::text,COALESCE(o.payload->>'routing_policy_id',''),COALESCE(o.payload->>'category',''),COALESCE(o.payload->>'severity',''),COALESCE(o.payload->>'receiver_group',''),COALESCE(o.payload->>'runbook_ref',''),CASE o.status WHEN 'pending' THEN 'PENDING' WHEN 'processing' THEN 'PROCESSING' WHEN 'completed' THEN 'DELIVERED' ELSE 'FAILED' END,o.attempt_count,o.manual_retry_count,COALESCE(o.last_error_code,''),o.last_manual_retry_at,o.created_at,o.completed_at,o.dead_at FROM outbox_event o` type deliveryTestScanner interface{ Scan(...any) error } func scanDeliveryTest(row deliveryTestScanner) (DeliveryTest, error) { var item DeliveryTest err := row.Scan(&item.ID, &item.TestRef, &item.RoutingPolicyID, &item.Category, &item.Severity, &item.ReceiverGroup, &item.RunbookRef, &item.Status, &item.AttemptCount, &item.ManualRetryCount, &item.LastErrorCode, &item.LastManualRetryAt, &item.CreatedAt, &item.CompletedAt, &item.DeadAt) return item, err } func getDeliveryTestTx(ctx context.Context, tx pgx.Tx, tenantID, eventID string) (DeliveryTest, error) { return scanDeliveryTest(tx.QueryRow(ctx, deliveryTestSelect+` WHERE o.tenant_id=$1::uuid AND o.id=$2::uuid AND o.event_type=$3`, tenantID, eventID, alertDeliveryTestEventType)) }