157 lines
7.0 KiB
Go
157 lines
7.0 KiB
Go
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))
|
|
}
|