1
0
forked from baron/baron-sso

adminfront 개요 통계 추가

This commit is contained in:
2026-05-06 16:14:52 +09:00
parent 6cdd0fd81e
commit 13dee9ae9b
24 changed files with 2082 additions and 297 deletions

View 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[:])
}

View 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)
}

View 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
}