forked from baron/baron-sso
adminfront 개요 통계 추가
This commit is contained in:
67
backend/internal/service/rp_usage_event_emitter.go
Normal file
67
backend/internal/service/rp_usage_event_emitter.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RPUsageEventEmitter struct {
|
||||
repo repository.RPUsageOutboxRepository
|
||||
}
|
||||
|
||||
func NewRPUsageEventEmitter(repo repository.RPUsageOutboxRepository) *RPUsageEventEmitter {
|
||||
return &RPUsageEventEmitter{repo: repo}
|
||||
}
|
||||
|
||||
func (e *RPUsageEventEmitter) EmitRPUsageEvent(ctx context.Context, event domain.RPUsageEvent) error {
|
||||
if e == nil || e.repo == nil {
|
||||
return nil
|
||||
}
|
||||
event.EventType = strings.TrimSpace(event.EventType)
|
||||
event.Subject = strings.TrimSpace(event.Subject)
|
||||
event.ClientID = strings.TrimSpace(event.ClientID)
|
||||
event.Source = strings.TrimSpace(event.Source)
|
||||
event.CorrelationID = strings.TrimSpace(event.CorrelationID)
|
||||
if event.EventType == "" {
|
||||
return fmt.Errorf("rp usage event type is required")
|
||||
}
|
||||
if event.Subject == "" {
|
||||
return fmt.Errorf("rp usage subject is required")
|
||||
}
|
||||
if event.ClientID == "" {
|
||||
return fmt.Errorf("rp usage client_id is required")
|
||||
}
|
||||
if event.Source == "" {
|
||||
event.Source = "backend"
|
||||
}
|
||||
if event.OccurredAt.IsZero() {
|
||||
event.OccurredAt = time.Now()
|
||||
}
|
||||
if event.DedupeKey == "" {
|
||||
event.DedupeKey = buildRPUsageDedupeKey(event)
|
||||
}
|
||||
if event.Payload == nil {
|
||||
event.Payload = domain.JSONMap{}
|
||||
}
|
||||
return e.repo.Create(ctx, &event)
|
||||
}
|
||||
|
||||
func buildRPUsageDedupeKey(event domain.RPUsageEvent) string {
|
||||
raw := strings.Join([]string{
|
||||
event.EventType,
|
||||
event.Subject,
|
||||
event.ClientID,
|
||||
event.SessionID,
|
||||
event.Source,
|
||||
event.CorrelationID,
|
||||
event.OccurredAt.UTC().Format("2006-01-02T15:04:05.000Z"),
|
||||
}, "|")
|
||||
sum := sha256.Sum256([]byte(raw))
|
||||
return hex.EncodeToString(sum[:])
|
||||
}
|
||||
132
backend/internal/service/rp_usage_event_emitter_test.go
Normal file
132
backend/internal/service/rp_usage_event_emitter_test.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type fakeRPUsageOutboxRepo struct {
|
||||
created []domain.RPUsageEvent
|
||||
ready []domain.RPUsageEvent
|
||||
processing []string
|
||||
processed []string
|
||||
failed []string
|
||||
createErr error
|
||||
projectErr error
|
||||
}
|
||||
|
||||
func (f *fakeRPUsageOutboxRepo) Create(ctx context.Context, event *domain.RPUsageEvent) error {
|
||||
if f.createErr != nil {
|
||||
return f.createErr
|
||||
}
|
||||
f.created = append(f.created, *event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRPUsageOutboxRepo) ListReady(ctx context.Context, limit int) ([]domain.RPUsageEvent, error) {
|
||||
return f.ready, nil
|
||||
}
|
||||
|
||||
func (f *fakeRPUsageOutboxRepo) MarkProcessing(ctx context.Context, id string) error {
|
||||
f.processing = append(f.processing, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRPUsageOutboxRepo) MarkProcessed(ctx context.Context, id string) error {
|
||||
f.processed = append(f.processed, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeRPUsageOutboxRepo) MarkFailed(ctx context.Context, id string, message string, nextAttemptAt time.Time) error {
|
||||
f.failed = append(f.failed, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeRPUsageProjectionRepo struct {
|
||||
created []domain.RPUsageEvent
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeRPUsageProjectionRepo) CreateRPUsageEvent(ctx context.Context, event domain.RPUsageEvent) error {
|
||||
if f.err != nil {
|
||||
return f.err
|
||||
}
|
||||
f.created = append(f.created, event)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestRPUsageEventEmitterRequiresCanonicalFields(t *testing.T) {
|
||||
repo := &fakeRPUsageOutboxRepo{}
|
||||
emitter := NewRPUsageEventEmitter(repo)
|
||||
|
||||
err := emitter.EmitRPUsageEvent(context.Background(), domain.RPUsageEvent{
|
||||
EventType: domain.RPUsageEventTypeAuthorizationGranted,
|
||||
ClientID: "client-app",
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
require.Empty(t, repo.created)
|
||||
}
|
||||
|
||||
func TestRPUsageEventEmitterCreatesPendingOutboxEvent(t *testing.T) {
|
||||
repo := &fakeRPUsageOutboxRepo{}
|
||||
emitter := NewRPUsageEventEmitter(repo)
|
||||
|
||||
err := emitter.EmitRPUsageEvent(context.Background(), domain.RPUsageEvent{
|
||||
EventType: domain.RPUsageEventTypeAuthorizationGranted,
|
||||
Subject: "user-123",
|
||||
ClientID: "client-app",
|
||||
Source: "hydra_consent",
|
||||
CorrelationID: "challenge-1",
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Len(t, repo.created, 1)
|
||||
require.NotEmpty(t, repo.created[0].DedupeKey)
|
||||
require.Equal(t, domain.RPUsageEventTypeAuthorizationGranted, repo.created[0].EventType)
|
||||
require.Equal(t, "hydra_consent", repo.created[0].Source)
|
||||
}
|
||||
|
||||
func TestRPUsageProjectorWorkerMarksProcessedAfterProjection(t *testing.T) {
|
||||
outbox := &fakeRPUsageOutboxRepo{
|
||||
ready: []domain.RPUsageEvent{{
|
||||
ID: "event-1",
|
||||
EventType: domain.RPUsageEventTypeAuthorizationGranted,
|
||||
Subject: "user-123",
|
||||
ClientID: "client-app",
|
||||
}},
|
||||
}
|
||||
projection := &fakeRPUsageProjectionRepo{}
|
||||
worker := NewRPUsageProjectorWorker(outbox, projection)
|
||||
|
||||
worker.processOnce(context.Background())
|
||||
|
||||
require.Equal(t, []string{"event-1"}, outbox.processing)
|
||||
require.Equal(t, []string{"event-1"}, outbox.processed)
|
||||
require.Empty(t, outbox.failed)
|
||||
require.Len(t, projection.created, 1)
|
||||
}
|
||||
|
||||
func TestRPUsageProjectorWorkerMarksFailedWhenProjectionFails(t *testing.T) {
|
||||
outbox := &fakeRPUsageOutboxRepo{
|
||||
ready: []domain.RPUsageEvent{{
|
||||
ID: "event-1",
|
||||
EventType: domain.RPUsageEventTypeAuthorizationGranted,
|
||||
Subject: "user-123",
|
||||
ClientID: "client-app",
|
||||
}},
|
||||
}
|
||||
projection := &fakeRPUsageProjectionRepo{err: errors.New("clickhouse unavailable")}
|
||||
worker := NewRPUsageProjectorWorker(outbox, projection)
|
||||
|
||||
worker.processOnce(context.Background())
|
||||
|
||||
require.Equal(t, []string{"event-1"}, outbox.processing)
|
||||
require.Empty(t, outbox.processed)
|
||||
require.Equal(t, []string{"event-1"}, outbox.failed)
|
||||
}
|
||||
82
backend/internal/service/rp_usage_projector_worker.go
Normal file
82
backend/internal/service/rp_usage_projector_worker.go
Normal file
@@ -0,0 +1,82 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"baron-sso-backend/internal/domain"
|
||||
"baron-sso-backend/internal/repository"
|
||||
"context"
|
||||
"log/slog"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RPUsageProjectorWorker struct {
|
||||
outbox repository.RPUsageOutboxRepository
|
||||
projection domain.RPUsageProjectionRepository
|
||||
interval time.Duration
|
||||
batchSize int
|
||||
}
|
||||
|
||||
func NewRPUsageProjectorWorker(outbox repository.RPUsageOutboxRepository, projection domain.RPUsageProjectionRepository) *RPUsageProjectorWorker {
|
||||
return &RPUsageProjectorWorker{
|
||||
outbox: outbox,
|
||||
projection: projection,
|
||||
interval: 5 * time.Second,
|
||||
batchSize: 50,
|
||||
}
|
||||
}
|
||||
|
||||
func (w *RPUsageProjectorWorker) Start(ctx context.Context) {
|
||||
if w == nil || w.outbox == nil || w.projection == nil {
|
||||
return
|
||||
}
|
||||
ticker := time.NewTicker(w.interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
default:
|
||||
w.processOnce(ctx)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (w *RPUsageProjectorWorker) processOnce(ctx context.Context) {
|
||||
events, err := w.outbox.ListReady(ctx, w.batchSize)
|
||||
if err != nil {
|
||||
slog.Warn("failed to list rp usage outbox", "error", err)
|
||||
return
|
||||
}
|
||||
for _, event := range events {
|
||||
if err := w.outbox.MarkProcessing(ctx, event.ID); err != nil {
|
||||
slog.Warn("failed to mark rp usage event processing", "event_id", event.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
if err := w.projection.CreateRPUsageEvent(ctx, event); err != nil {
|
||||
nextAttempt := time.Now().Add(backoffDuration(event.RetryCount))
|
||||
_ = w.outbox.MarkFailed(ctx, event.ID, err.Error(), nextAttempt)
|
||||
slog.Warn("failed to project rp usage event", "event_id", event.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
if err := w.outbox.MarkProcessed(ctx, event.ID); err != nil {
|
||||
slog.Warn("failed to mark rp usage event processed", "event_id", event.ID, "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func backoffDuration(retryCount int) time.Duration {
|
||||
if retryCount < 0 {
|
||||
retryCount = 0
|
||||
}
|
||||
delay := time.Duration(retryCount+1) * time.Minute
|
||||
if delay > 30*time.Minute {
|
||||
return 30 * time.Minute
|
||||
}
|
||||
return delay
|
||||
}
|
||||
Reference in New Issue
Block a user