From 4d725b94f87f6d6395680743319cffbfcddff939 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Mon, 1 Jun 2026 19:49:31 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(realtime):=20=EB=A9=80=ED=8B=B0?= =?UTF-8?q?=EC=B1=84=EB=84=90=20SSE=20+=20Stream=20=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EA=B8=B0=EB=B0=98=20=EA=B5=AC=EC=B6=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/event-stream.md | 14 +-- infra/rabbitmq/definitions.json | 14 +++ realtime/.env.example | 1 + realtime/CLAUDE.md | 23 ++-- realtime/cmd/realtime/main.go | 7 +- realtime/go.mod | 14 +-- realtime/go.sum | 32 +----- realtime/internal/auth/middleware.go | 37 +++++++ realtime/internal/auth/middleware_test.go | 37 +++++++ realtime/internal/auth/stream_token.go | 57 ++++++++++ realtime/internal/auth/stream_token_test.go | 78 ++++++++++++++ realtime/internal/bridge/dispatcher.go | 12 ++- realtime/internal/bridge/dispatcher_test.go | 84 +++++++++------ realtime/internal/bridge/envelope.go | 42 ++++++-- realtime/internal/config/config.go | 1 + realtime/internal/config/config_test.go | 10 ++ realtime/internal/session/channel.go | 16 +++ realtime/internal/session/registry.go | 34 +++--- realtime/internal/session/registry_test.go | 110 ++++---------------- realtime/internal/transport/router.go | 27 ++++- realtime/internal/transport/sse.go | 25 ++--- realtime/internal/transport/sse_test.go | 66 +++++------- 22 files changed, 474 insertions(+), 267 deletions(-) create mode 100644 realtime/internal/auth/middleware.go create mode 100644 realtime/internal/auth/middleware_test.go create mode 100644 realtime/internal/auth/stream_token.go create mode 100644 realtime/internal/auth/stream_token_test.go create mode 100644 realtime/internal/session/channel.go diff --git a/docs/event-stream.md b/docs/event-stream.md index 07444e73..c6d317fc 100644 --- a/docs/event-stream.md +++ b/docs/event-stream.md @@ -9,15 +9,15 @@ ## 1. 엔드포인트 ``` -GET /api/stream/user/{userId} -GET /api/stream/sessions/{sessionId} -GET /api/stream/documents/{documentId} +GET /realtime/stream/me # user 채널 (userId는 토큰에서 추출) +GET /realtime/stream/sessions/{sessionId} +GET /realtime/stream/documents/{documentId} ``` -- 제공 주체: Phase 1은 **Core Server**가 직접 제공. RealTime Server 분리 시점에 그쪽으로 이전. -- 인증: `Authorization: Bearer ...` 또는 쿼리 토큰 (`?access_token=...` — EventSource 한계 우회용) -- 권한: 본인 리소스만 구독 가능 -- 연결 유지: 30초마다 `:keep-alive` 코멘트 송신 +- 제공 주체: **RealTime Server (Go)**. Core는 직접 SSE를 서빙하지 않고 `stackup.realtime` exchange로 발행만 한다 (RealTime이 consume → fan-out). +- 인증: 쿼리 토큰 `?access_token=` (EventSource 헤더 한계 우회). 토큰은 Core `POST /api/auth/stream-token` 발급, RealTime `internal/auth`가 HS256(키=`SHA-256(JWT_SECRET)`)로 검증. +- 권한: 현재는 토큰 진위(userId)만 검증. 리소스 소유권(이 user가 이 session/document의 주인인가)은 후속 플랜에서 리소스 스코프 토큰 또는 Core 조회로 강화. +- 연결 유지: 약 30초마다 `: ping ` heartbeat 코멘트 송신 (`REALTIME_SSE_PING_INTERVAL`). --- diff --git a/infra/rabbitmq/definitions.json b/infra/rabbitmq/definitions.json index 778441f1..92d3b9e2 100644 --- a/infra/rabbitmq/definitions.json +++ b/infra/rabbitmq/definitions.json @@ -340,6 +340,20 @@ "destination_type": "queue", "routing_key": "realtime.session.*" }, + { + "source": "stackup.realtime", + "vhost": "/", + "destination": "q.realtime.session.notify", + "destination_type": "queue", + "routing_key": "realtime.user.*" + }, + { + "source": "stackup.realtime", + "vhost": "/", + "destination": "q.realtime.session.notify", + "destination_type": "queue", + "routing_key": "realtime.document.*" + }, { "source": "stackup.dlx", "vhost": "/", diff --git a/realtime/.env.example b/realtime/.env.example index 10fd1e58..b2279cc0 100644 --- a/realtime/.env.example +++ b/realtime/.env.example @@ -5,3 +5,4 @@ REALTIME_QUEUE_NAME=q.realtime.session.notify REALTIME_SSE_PING_INTERVAL=30s REALTIME_SSE_SLOW_CONSUMER_TIMEOUT=5s REALTIME_SSE_BUFFER_SIZE=16 +REALTIME_JWT_SECRET=change-me-in-prod diff --git a/realtime/CLAUDE.md b/realtime/CLAUDE.md index 569c3926..c34843a8 100644 --- a/realtime/CLAUDE.md +++ b/realtime/CLAUDE.md @@ -88,11 +88,14 @@ config는 cmd, internal/* 모두에서 import 가능 | ID | Method | Path | 책임 | 상태 | |----|--------|------|------|------| | - | GET | `/health` | 헬스체크 | 활성 | -| RT2 | GET | `/realtime/sessions/{id}` | SSE 작업 알림 | 활성 | -| RT1 | WS | `/realtime/sessions/{id}` | 라이브 면접 메시지 | **미구현** (US-Session-03) | +| RT2 | GET | `/realtime/stream/me` | user 채널 SSE (분석 상태). userId는 토큰에서 | 활성 | +| RT2 | GET | `/realtime/stream/documents/{id}` | document 채널 SSE | 활성 | +| RT2 | GET | `/realtime/stream/sessions/{id}` | session 채널 SSE (feedback.ready 등 비-라이브) | 활성 | +| RT1 | WS | `/realtime/sessions/{id}` | 라이브 면접 메시지 | **미구현** (후속 플랜) | | RT3 | WS | `/realtime/sessions/{id}/audio` | 음성 스트림 | **미구현** (US-Voice-01) | -> RT1과 RT2는 동일 path. Upgrade 헤더 유무로 분기. 본 PR은 SSE만이라 분기 미적용. +> 모든 `/realtime/stream/*` 는 인증 필요 — `?access_token=` 쿼리로 Core 발급 토큰 검증 (EventSource 헤더 한계 우회). `internal/auth` 미들웨어가 처리. +> RT1 WS는 동일 prefix에서 Upgrade 헤더로 분기 예정 (후속 플랜). --- @@ -100,7 +103,9 @@ config는 cmd, internal/* 모두에서 import 가능 | Queue | Bind | Consumer | DLQ | |-------|------|----------|-----| -| `q.realtime.session.notify` | `stackup.realtime` exchange, routing key `realtime.session.*` | RealTime | `dlq.q.realtime.session.notify` (via `stackup.dlx`) | +| `q.realtime.session.notify` | `stackup.realtime` exchange, routing keys `realtime.session.*` · `realtime.user.*` · `realtime.document.*` | RealTime | `dlq.q.realtime.session.notify` (via `stackup.dlx`) | + +> 단일 큐가 세 채널(session/user/document) 라우팅 키를 모두 바인딩한다. 채널 판별은 envelope `messageType`(`realtime.{kind}.notify`) → `bridge.Envelope.Channel()` 가 `context`에서 id를 꺼낸다. 발행자: Core 서버. envelope 스키마는 [`/docs/messaging.md §5`](../docs/messaging.md). @@ -145,6 +150,7 @@ Heartbeat (proxy keepalive): | `REALTIME_SSE_PING_INTERVAL` | `30s` | SSE heartbeat 주기 | | `REALTIME_SSE_SLOW_CONSUMER_TIMEOUT` | `5s` | 구독자 send timeout | | `REALTIME_SSE_BUFFER_SIZE` | `16` | 구독자별 채널 버퍼 | +| `REALTIME_JWT_SECRET` | `change-me-in-prod` | Stream 토큰 검증 키 소스 (Core `JWT_SECRET`과 동일값. 키 = `SHA-256(secret)`, HS256) | --- @@ -204,10 +210,11 @@ docker build -t stackup-realtime ./realtime ## 15. 현재 상태 (2026-05 기준) - HTTP 서버 + `/health` 활성 -- SSE `/realtime/sessions/{id}` 활성 -- AMQP `q.realtime.session.notify` consumer 활성, dispatcher → SSE fan-out 동작 -- WebSocket 미구현 -- JWT 인증 미구현 (TODO 주석) +- 멀티채널 SSE 활성 — `/realtime/stream/{me,documents/{id},sessions/{id}}` (session/user/document) +- Stream 토큰 인증 활성 — `?access_token=` 검증 (`internal/auth`, Core와 동일 HS256 규약) +- AMQP `q.realtime.session.notify` consumer 활성 — `messageType` 기반 채널 라우팅 → fan-out +- WebSocket(RT1 라이브 면접) 미구현 — 후속 플랜 +- 리소스 소유권 검증 미구현 — 현재 토큰 진위(userId)만 검증. 후속 플랜에서 리소스 스코프 토큰 또는 Core 조회로 강화 - DLQ 활성 — handler 실패 메시지는 `dlq.q.realtime.session.notify` 로 격리 - Prometheus 노출 미구현 diff --git a/realtime/cmd/realtime/main.go b/realtime/cmd/realtime/main.go index f9569f25..35740be0 100644 --- a/realtime/cmd/realtime/main.go +++ b/realtime/cmd/realtime/main.go @@ -12,6 +12,7 @@ import ( "syscall" "time" + "github.com/Team-StackUp/stackup/realtime/internal/auth" "github.com/Team-StackUp/stackup/realtime/internal/bridge" "github.com/Team-StackUp/stackup/realtime/internal/config" "github.com/Team-StackUp/stackup/realtime/internal/messaging" @@ -36,7 +37,8 @@ func main() { } sseHandler := transport.NewSSEHandler(registry, cfg.SSEBufferSize, cfg.SSEPingInterval) - router := transport.NewRouter(registry, sseHandler) + verifier := auth.NewStreamTokenVerifier(cfg.JWTSecret) + router := transport.NewRouter(sseHandler, verifier) srv := &http.Server{ Addr: cfg.ListenAddr, @@ -89,7 +91,8 @@ func (h handlerAdapter) Handle(body []byte) error { return nil // drop, do not requeue } slog.Info("dispatched", - "session_id", res.SessionID, + "channel_kind", res.Channel.Kind, + "channel_id", res.Channel.ID, "delivered", res.Delivered, "trace_id", res.Envelope.TraceID, "event_type", res.Envelope.Payload.EventType, diff --git a/realtime/go.mod b/realtime/go.mod index bde5c7a1..6376e44f 100644 --- a/realtime/go.mod +++ b/realtime/go.mod @@ -4,19 +4,7 @@ go 1.26.3 require ( github.com/caarlos0/env/v11 v11.4.1 - github.com/coder/websocket v1.8.14 github.com/go-chi/chi/v5 v5.2.5 - github.com/go-playground/validator/v10 v10.30.2 - github.com/joho/godotenv v1.5.1 + github.com/golang-jwt/jwt/v5 v5.3.1 github.com/rabbitmq/amqp091-go v1.11.0 ) - -require ( - github.com/gabriel-vasile/mimetype v1.4.13 // indirect - github.com/go-playground/locales v0.14.1 // indirect - github.com/go-playground/universal-translator v0.18.1 // indirect - github.com/leodido/go-urn v1.4.0 // indirect - golang.org/x/crypto v0.49.0 // indirect - golang.org/x/sys v0.42.0 // indirect - golang.org/x/text v0.35.0 // indirect -) diff --git a/realtime/go.sum b/realtime/go.sum index 18cf92db..cf031185 100644 --- a/realtime/go.sum +++ b/realtime/go.sum @@ -1,38 +1,10 @@ github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= -github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= -github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= -github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= -github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= -github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= -github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= -github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= -github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= -github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= -github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= -github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= -github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= -github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= -github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= -github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= -github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= -github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= github.com/rabbitmq/amqp091-go v1.11.0 h1:HxIctVm9Gid/Vtn706necmZ7Wj6pgGI2eqplRbEY8O8= github.com/rabbitmq/amqp091-go v1.11.0/go.mod h1:Hy4jKW5kQART1u+JkDTF9YYOQUHXqMuhrgxOEeS7G4o= -github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= -github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= -golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= -golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= -golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= -golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= -golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/realtime/internal/auth/middleware.go b/realtime/internal/auth/middleware.go new file mode 100644 index 00000000..0b2174ac --- /dev/null +++ b/realtime/internal/auth/middleware.go @@ -0,0 +1,37 @@ +package auth + +import ( + "context" + "net/http" +) + +type ctxKey struct{} + +// Middleware extracts the stream token from the access_token query parameter, +// verifies it, and injects the userId into the request context. +func Middleware(v *StreamTokenVerifier) func(http.Handler) http.Handler { + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + token := r.URL.Query().Get("access_token") + if token == "" { + http.Error(w, "missing access_token", http.StatusUnauthorized) + return + } + userID, err := v.Verify(token) + if err != nil { + http.Error(w, "invalid access_token", http.StatusUnauthorized) + return + } + ctx := context.WithValue(r.Context(), ctxKey{}, userID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) + } +} + +// UserIDFromContext returns the authenticated userId, or 0 if absent. +func UserIDFromContext(ctx context.Context) int64 { + if v, ok := ctx.Value(ctxKey{}).(int64); ok { + return v + } + return 0 +} diff --git a/realtime/internal/auth/middleware_test.go b/realtime/internal/auth/middleware_test.go new file mode 100644 index 00000000..99548053 --- /dev/null +++ b/realtime/internal/auth/middleware_test.go @@ -0,0 +1,37 @@ +package auth + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func TestMiddlewareRejectsMissingToken(t *testing.T) { + mw := Middleware(NewStreamTokenVerifier(testSecret)) + h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + t.Fatal("handler should not be reached") + })) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/realtime/stream/me", nil)) + if rec.Code != http.StatusUnauthorized { + t.Errorf("code = %d, want 401", rec.Code) + } +} + +func TestMiddlewarePassesValidTokenAndInjectsUserID(t *testing.T) { + mw := Middleware(NewStreamTokenVerifier(testSecret)) + var gotUserID int64 + h := mw(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotUserID = UserIDFromContext(r.Context()) + w.WriteHeader(http.StatusOK) + })) + token := mintToken(t, validClaims()) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/realtime/stream/me?access_token="+token, nil)) + if rec.Code != http.StatusOK { + t.Fatalf("code = %d, want 200", rec.Code) + } + if gotUserID != 42 { + t.Errorf("userID = %d, want 42", gotUserID) + } +} diff --git a/realtime/internal/auth/stream_token.go b/realtime/internal/auth/stream_token.go new file mode 100644 index 00000000..600f42bb --- /dev/null +++ b/realtime/internal/auth/stream_token.go @@ -0,0 +1,57 @@ +package auth + +import ( + "crypto/sha256" + "errors" + "fmt" + + "github.com/golang-jwt/jwt/v5" +) + +const ( + streamTokenType = "STREAM" + sseConnectScope = "SSE_CONNECT" +) + +var ErrInvalidStreamToken = errors.New("invalid stream token") + +// StreamTokenVerifier validates Core-issued SSE stream tokens. +// It mirrors Core StreamTokenProvider: HS256 over key = SHA-256(jwtSecret). +type StreamTokenVerifier struct { + key []byte +} + +func NewStreamTokenVerifier(jwtSecret string) *StreamTokenVerifier { + sum := sha256.Sum256([]byte(jwtSecret)) + return &StreamTokenVerifier{key: sum[:]} +} + +// Verify checks signature, expiry, tokenType, scope and returns the userId. +func (v *StreamTokenVerifier) Verify(token string) (int64, error) { + claims := jwt.MapClaims{} + parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("%w: unexpected signing method", ErrInvalidStreamToken) + } + return v.key, nil + }) + if err != nil || !parsed.Valid { + return 0, ErrInvalidStreamToken + } + if s, _ := claims["tokenType"].(string); s != streamTokenType { + return 0, ErrInvalidStreamToken + } + if s, _ := claims["scope"].(string); s != sseConnectScope { + return 0, ErrInvalidStreamToken + } + raw, ok := claims["userId"] + if !ok { + return 0, ErrInvalidStreamToken + } + // JSON numbers decode to float64 in MapClaims. + f, ok := raw.(float64) + if !ok { + return 0, ErrInvalidStreamToken + } + return int64(f), nil +} diff --git a/realtime/internal/auth/stream_token_test.go b/realtime/internal/auth/stream_token_test.go new file mode 100644 index 00000000..2cfacd04 --- /dev/null +++ b/realtime/internal/auth/stream_token_test.go @@ -0,0 +1,78 @@ +package auth + +import ( + "crypto/sha256" + "testing" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +const testSecret = "test-jwt-secret" + +// mintToken builds a Core-equivalent stream token for tests. +func mintToken(t *testing.T, claims jwt.MapClaims) string { + t.Helper() + key := sha256.Sum256([]byte(testSecret)) + tok := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + s, err := tok.SignedString(key[:]) + if err != nil { + t.Fatalf("sign: %v", err) + } + return s +} + +func validClaims() jwt.MapClaims { + return jwt.MapClaims{ + "sub": "42", + "userId": 42, + "tokenType": "STREAM", + "scope": "SSE_CONNECT", + "exp": time.Now().Add(time.Minute).Unix(), + } +} + +func TestVerifyValidToken(t *testing.T) { + v := NewStreamTokenVerifier(testSecret) + userID, err := v.Verify(mintToken(t, validClaims())) + if err != nil { + t.Fatalf("Verify err: %v", err) + } + if userID != 42 { + t.Errorf("userID = %d, want 42", userID) + } +} + +func TestVerifyRejectsExpired(t *testing.T) { + v := NewStreamTokenVerifier(testSecret) + c := validClaims() + c["exp"] = time.Now().Add(-time.Minute).Unix() + if _, err := v.Verify(mintToken(t, c)); err == nil { + t.Error("expected error for expired token") + } +} + +func TestVerifyRejectsWrongTokenType(t *testing.T) { + v := NewStreamTokenVerifier(testSecret) + c := validClaims() + c["tokenType"] = "ACCESS" + if _, err := v.Verify(mintToken(t, c)); err == nil { + t.Error("expected error for wrong tokenType") + } +} + +func TestVerifyRejectsWrongScope(t *testing.T) { + v := NewStreamTokenVerifier(testSecret) + c := validClaims() + c["scope"] = "OTHER" + if _, err := v.Verify(mintToken(t, c)); err == nil { + t.Error("expected error for wrong scope") + } +} + +func TestVerifyRejectsBadSignature(t *testing.T) { + v := NewStreamTokenVerifier("different-secret") + if _, err := v.Verify(mintToken(t, validClaims())); err == nil { + t.Error("expected error for bad signature") + } +} diff --git a/realtime/internal/bridge/dispatcher.go b/realtime/internal/bridge/dispatcher.go index 2b306a37..0e1f2087 100644 --- a/realtime/internal/bridge/dispatcher.go +++ b/realtime/internal/bridge/dispatcher.go @@ -8,7 +8,7 @@ import ( ) type DispatchResult struct { - SessionID int64 + Channel session.Channel Delivered int Envelope Envelope } @@ -28,17 +28,21 @@ func (d *Dispatcher) Dispatch(body []byte) (DispatchResult, error) { return DispatchResult{}, err } - sid := *env.Context.SessionID + channel, err := env.Channel() + if err != nil { + return DispatchResult{}, err + } + data, _ := json.Marshal(map[string]any{ "data": env.Payload.Data, "traceId": env.TraceID, }) - delivered := d.registry.Dispatch(sid, session.Event{ + delivered := d.registry.Dispatch(channel, session.Event{ ID: env.MessageID, Type: env.Payload.EventType, Data: data, }, d.slowTimeout) - return DispatchResult{SessionID: sid, Delivered: delivered, Envelope: env}, nil + return DispatchResult{Channel: channel, Delivered: delivered, Envelope: env}, nil } diff --git a/realtime/internal/bridge/dispatcher_test.go b/realtime/internal/bridge/dispatcher_test.go index f5e605d3..bfe38059 100644 --- a/realtime/internal/bridge/dispatcher_test.go +++ b/realtime/internal/bridge/dispatcher_test.go @@ -7,31 +7,26 @@ import ( "github.com/Team-StackUp/stackup/realtime/internal/session" ) -func TestDispatchValidEnvelope(t *testing.T) { +func TestDispatchSessionChannel(t *testing.T) { r := session.NewRegistry() - sub := r.Subscribe(99, 4) - defer r.Unsubscribe(99, sub) + sub := r.Subscribe(session.Channel{Kind: session.ChannelSession, ID: 99}, 4) + defer r.Unsubscribe(session.Channel{Kind: session.ChannelSession, ID: 99}, sub) d := NewDispatcher(r, 100*time.Millisecond) - - body := []byte(`{ - "messageId":"m1","messageType":"realtime.session.notify","version":"v1", - "traceId":"t1","publishedAt":"2026-05-08T00:00:00Z","publisher":"core", - "payload":{"eventType":"question.created","data":{"q":"hello"}}, - "context":{"sessionId":99} - }`) + body := []byte(`{"messageId":"m1","messageType":"realtime.session.notify","version":"v1", + "traceId":"t1","publishedAt":"2026-06-01T00:00:00Z","publisher":"core", + "payload":{"eventType":"session.message","data":{"q":"hi"}},"context":{"sessionId":99}}`) res, err := d.Dispatch(body) if err != nil { t.Fatalf("Dispatch err: %v", err) } - if res.SessionID != 99 || res.Delivered != 1 { + if res.Channel.Kind != session.ChannelSession || res.Channel.ID != 99 || res.Delivered != 1 { t.Errorf("res = %+v", res) } - select { case got := <-sub.Ch: - if got.ID != "m1" || got.Type != "question.created" { + if got.ID != "m1" || got.Type != "session.message" { t.Errorf("event = %+v", got) } case <-time.After(100 * time.Millisecond): @@ -39,46 +34,67 @@ func TestDispatchValidEnvelope(t *testing.T) { } } -func TestDispatchUnknownSessionDelivers0(t *testing.T) { +func TestDispatchUserChannel(t *testing.T) { r := session.NewRegistry() - d := NewDispatcher(r, 50*time.Millisecond) + sub := r.Subscribe(session.Channel{Kind: session.ChannelUser, ID: 42}, 4) + defer r.Unsubscribe(session.Channel{Kind: session.ChannelUser, ID: 42}, sub) - body := []byte(`{ - "messageId":"m2","messageType":"realtime.session.notify","version":"v1", - "traceId":"t2","publishedAt":"2026-05-08T00:00:00Z","publisher":"core", - "payload":{"eventType":"x","data":{}}, - "context":{"sessionId":404} - }`) + d := NewDispatcher(r, 100*time.Millisecond) + body := []byte(`{"messageId":"m2","messageType":"realtime.user.notify","version":"v1", + "traceId":"t2","publishedAt":"2026-06-01T00:00:00Z","publisher":"core", + "payload":{"eventType":"doc.state","data":{}},"context":{"userId":42}}`) res, err := d.Dispatch(body) if err != nil { t.Fatalf("Dispatch err: %v", err) } - if res.Delivered != 0 { - t.Errorf("delivered = %d", res.Delivered) + if res.Channel.Kind != session.ChannelUser || res.Channel.ID != 42 || res.Delivered != 1 { + t.Errorf("res = %+v", res) } } -func TestDispatchInvalidJSONReturnsError(t *testing.T) { +func TestDispatchDocumentChannel(t *testing.T) { r := session.NewRegistry() d := NewDispatcher(r, 50*time.Millisecond) - if _, err := d.Dispatch([]byte("{not json")); err == nil { - t.Error("expected error") + body := []byte(`{"messageId":"m3","messageType":"realtime.document.notify","version":"v1", + "traceId":"t3","publishedAt":"2026-06-01T00:00:00Z","publisher":"core", + "payload":{"eventType":"doc.state","data":{}},"context":{"documentId":101}}`) + + res, err := d.Dispatch(body) + if err != nil { + t.Fatalf("Dispatch err: %v", err) + } + if res.Channel.Kind != session.ChannelDocument || res.Channel.ID != 101 || res.Delivered != 0 { + t.Errorf("res = %+v", res) } } -func TestDispatchMissingSessionIdReturnsError(t *testing.T) { +func TestDispatchMissingChannelIDReturnsError(t *testing.T) { r := session.NewRegistry() d := NewDispatcher(r, 50*time.Millisecond) + body := []byte(`{"messageId":"m4","messageType":"realtime.session.notify","version":"v1", + "traceId":"t4","publishedAt":"2026-06-01T00:00:00Z","publisher":"core", + "payload":{"eventType":"x","data":{}},"context":{}}`) + if _, err := d.Dispatch(body); err == nil { + t.Error("expected error for missing sessionId") + } +} - body := []byte(`{ - "messageId":"m3","messageType":"realtime.session.notify","version":"v1", - "traceId":"t3","publishedAt":"2026-05-08T00:00:00Z","publisher":"core", - "payload":{"eventType":"x","data":{}}, - "context":{} - }`) - +func TestDispatchUnknownChannelReturnsError(t *testing.T) { + r := session.NewRegistry() + d := NewDispatcher(r, 50*time.Millisecond) + body := []byte(`{"messageId":"m5","messageType":"realtime.bogus.notify","version":"v1", + "traceId":"t5","publishedAt":"2026-06-01T00:00:00Z","publisher":"core", + "payload":{"eventType":"x","data":{}},"context":{"sessionId":1}}`) if _, err := d.Dispatch(body); err == nil { + t.Error("expected error for unknown channel") + } +} + +func TestDispatchInvalidJSONReturnsError(t *testing.T) { + r := session.NewRegistry() + d := NewDispatcher(r, 50*time.Millisecond) + if _, err := d.Dispatch([]byte("{not json")); err == nil { t.Error("expected error") } } diff --git a/realtime/internal/bridge/envelope.go b/realtime/internal/bridge/envelope.go index 897bfbf9..7aa46b5f 100644 --- a/realtime/internal/bridge/envelope.go +++ b/realtime/internal/bridge/envelope.go @@ -3,6 +3,9 @@ package bridge import ( "encoding/json" "errors" + "strings" + + "github.com/Team-StackUp/stackup/realtime/internal/session" ) type RealtimePayload struct { @@ -11,8 +14,9 @@ type RealtimePayload struct { } type Context struct { - UserID *int64 `json:"userId,omitempty"` - SessionID *int64 `json:"sessionId,omitempty"` + UserID *int64 `json:"userId,omitempty"` + SessionID *int64 `json:"sessionId,omitempty"` + DocumentID *int64 `json:"documentId,omitempty"` } type Envelope struct { @@ -27,7 +31,8 @@ type Envelope struct { } var ( - ErrMissingSessionID = errors.New("envelope.context.sessionId is required") + ErrMissingChannelID = errors.New("envelope.context is missing the id for its channel") + ErrUnknownChannel = errors.New("envelope.messageType is not a known realtime channel") ) func ParseEnvelope(body []byte) (Envelope, error) { @@ -35,8 +40,33 @@ func ParseEnvelope(body []byte) (Envelope, error) { if err := json.Unmarshal(body, &env); err != nil { return Envelope{}, err } - if env.Context.SessionID == nil { - return Envelope{}, ErrMissingSessionID - } return env, nil } + +// Channel resolves the fan-out target from messageType + context. +// messageType is "realtime..notify" (e.g. realtime.user.notify). +func (e Envelope) Channel() (session.Channel, error) { + parts := strings.Split(e.MessageType, ".") + if len(parts) < 2 || parts[0] != "realtime" { + return session.Channel{}, ErrUnknownChannel + } + switch session.ChannelKind(parts[1]) { + case session.ChannelSession: + if e.Context.SessionID == nil { + return session.Channel{}, ErrMissingChannelID + } + return session.Channel{Kind: session.ChannelSession, ID: *e.Context.SessionID}, nil + case session.ChannelUser: + if e.Context.UserID == nil { + return session.Channel{}, ErrMissingChannelID + } + return session.Channel{Kind: session.ChannelUser, ID: *e.Context.UserID}, nil + case session.ChannelDocument: + if e.Context.DocumentID == nil { + return session.Channel{}, ErrMissingChannelID + } + return session.Channel{Kind: session.ChannelDocument, ID: *e.Context.DocumentID}, nil + default: + return session.Channel{}, ErrUnknownChannel + } +} diff --git a/realtime/internal/config/config.go b/realtime/internal/config/config.go index b063d32c..3bac295e 100644 --- a/realtime/internal/config/config.go +++ b/realtime/internal/config/config.go @@ -11,6 +11,7 @@ type Config struct { RabbitMQURL string `env:"REALTIME_RABBITMQ_URL" envDefault:"amqp://stackup:stackup@localhost:38050/"` LogLevel string `env:"REALTIME_LOG_LEVEL" envDefault:"info"` QueueName string `env:"REALTIME_QUEUE_NAME" envDefault:"q.realtime.session.notify"` + JWTSecret string `env:"REALTIME_JWT_SECRET" envDefault:"change-me-in-prod"` SSEPingInterval time.Duration `env:"REALTIME_SSE_PING_INTERVAL" envDefault:"30s"` SSESlowConsumerTimeout time.Duration `env:"REALTIME_SSE_SLOW_CONSUMER_TIMEOUT" envDefault:"5s"` SSEBufferSize int `env:"REALTIME_SSE_BUFFER_SIZE" envDefault:"16"` diff --git a/realtime/internal/config/config_test.go b/realtime/internal/config/config_test.go index ec89ff09..806a7542 100644 --- a/realtime/internal/config/config_test.go +++ b/realtime/internal/config/config_test.go @@ -42,6 +42,16 @@ func TestLoadFromEnv(t *testing.T) { } } +func TestJWTSecretDefault(t *testing.T) { + cfg, err := Load() + if err != nil { + t.Fatalf("Load err: %v", err) + } + if cfg.JWTSecret == "" { + t.Error("JWTSecret should have a non-empty default") + } +} + func TestLoadDefaults(t *testing.T) { // Clear all overrides for _, k := range []string{ diff --git a/realtime/internal/session/channel.go b/realtime/internal/session/channel.go new file mode 100644 index 00000000..f80f7960 --- /dev/null +++ b/realtime/internal/session/channel.go @@ -0,0 +1,16 @@ +package session + +// ChannelKind enumerates the subscription channel namespaces. +type ChannelKind string + +const ( + ChannelSession ChannelKind = "session" + ChannelUser ChannelKind = "user" + ChannelDocument ChannelKind = "document" +) + +// Channel identifies a fan-out target: a kind plus the resource id. +type Channel struct { + Kind ChannelKind + ID int64 +} diff --git a/realtime/internal/session/registry.go b/realtime/internal/session/registry.go index d614bef9..618fc1e6 100644 --- a/realtime/internal/session/registry.go +++ b/realtime/internal/session/registry.go @@ -19,17 +19,17 @@ type Subscriber struct { type Registry struct { mu sync.RWMutex - subs map[int64][]*Subscriber + subs map[Channel][]*Subscriber nextID atomic.Int64 } func NewRegistry() *Registry { - return &Registry{subs: make(map[int64][]*Subscriber)} + return &Registry{subs: make(map[Channel][]*Subscriber)} } -// Subscribe registers a new subscriber for sessionID with the given channel -// buffer size and returns a *Subscriber. The caller must Unsubscribe when done. -func (r *Registry) Subscribe(sessionID int64, bufferSize int) *Subscriber { +// Subscribe registers a new subscriber for the channel with the given buffer +// size. The caller must Unsubscribe when done. +func (r *Registry) Subscribe(channel Channel, bufferSize int) *Subscriber { if bufferSize <= 0 { bufferSize = 1 } @@ -38,32 +38,32 @@ func (r *Registry) Subscribe(sessionID int64, bufferSize int) *Subscriber { Ch: make(chan Event, bufferSize), } r.mu.Lock() - r.subs[sessionID] = append(r.subs[sessionID], sub) + r.subs[channel] = append(r.subs[channel], sub) r.mu.Unlock() return sub } -func (r *Registry) Unsubscribe(sessionID int64, sub *Subscriber) { +func (r *Registry) Unsubscribe(channel Channel, sub *Subscriber) { r.mu.Lock() defer r.mu.Unlock() - list := r.subs[sessionID] + list := r.subs[channel] for i, s := range list { if s.id == sub.id { - r.subs[sessionID] = append(list[:i], list[i+1:]...) + r.subs[channel] = append(list[:i], list[i+1:]...) break } } - if len(r.subs[sessionID]) == 0 { - delete(r.subs, sessionID) + if len(r.subs[channel]) == 0 { + delete(r.subs, channel) } } -// Dispatch sends ev to all subscribers of sessionID. For each subscriber, -// the send waits up to slowTimeout before dropping that subscriber's delivery. -// Returns the number of subscribers that received the event. -func (r *Registry) Dispatch(sessionID int64, ev Event, slowTimeout time.Duration) int { +// Dispatch sends ev to all subscribers of channel. Each send waits up to +// slowTimeout before dropping that subscriber's delivery. Returns the number +// of subscribers that received the event. +func (r *Registry) Dispatch(channel Channel, ev Event, slowTimeout time.Duration) int { r.mu.RLock() - subs := append([]*Subscriber(nil), r.subs[sessionID]...) + subs := append([]*Subscriber(nil), r.subs[channel]...) r.mu.RUnlock() delivered := 0 @@ -72,7 +72,7 @@ func (r *Registry) Dispatch(sessionID int64, ev Event, slowTimeout time.Duration case s.Ch <- ev: delivered++ case <-time.After(slowTimeout): - // drop + // drop slow consumer's delivery } } return delivered diff --git a/realtime/internal/session/registry_test.go b/realtime/internal/session/registry_test.go index e15ebd7c..ed0f12b8 100644 --- a/realtime/internal/session/registry_test.go +++ b/realtime/internal/session/registry_test.go @@ -1,110 +1,46 @@ package session import ( - "sync" "testing" "time" ) -func TestSubscribeReceivesEvent(t *testing.T) { - r := NewRegistry() +func ch(kind ChannelKind, id int64) Channel { return Channel{Kind: kind, ID: id} } - sub := r.Subscribe(99, 4) - defer r.Unsubscribe(99, sub) +func TestDispatchDeliversToMatchingChannelOnly(t *testing.T) { + r := NewRegistry() + sessionSub := r.Subscribe(ch(ChannelSession, 99), 4) + userSub := r.Subscribe(ch(ChannelUser, 99), 4) + defer r.Unsubscribe(ch(ChannelSession, 99), sessionSub) + defer r.Unsubscribe(ch(ChannelUser, 99), userSub) - ev := Event{ID: "1", Type: "x", Data: []byte("hi")} - delivered := r.Dispatch(99, ev, 100*time.Millisecond) - if delivered != 1 { - t.Fatalf("delivered = %d, want 1", delivered) + n := r.Dispatch(ch(ChannelSession, 99), Event{ID: "m1", Type: "x", Data: []byte("{}")}, 100*time.Millisecond) + if n != 1 { + t.Fatalf("delivered = %d, want 1", n) } select { - case got := <-sub.Ch: - if got.ID != "1" || got.Type != "x" || string(got.Data) != "hi" { - t.Errorf("event = %+v", got) + case got := <-sessionSub.Ch: + if got.ID != "m1" { + t.Errorf("session event = %+v", got) } case <-time.After(100 * time.Millisecond): - t.Fatal("did not receive event") - } -} - -func TestDispatchToUnknownSessionDelivers0(t *testing.T) { - r := NewRegistry() - - ev := Event{ID: "1", Type: "x", Data: []byte("hi")} - if got := r.Dispatch(99, ev, 100*time.Millisecond); got != 0 { - t.Errorf("delivered = %d, want 0", got) - } -} - -func TestMultipleSubscribersReceive(t *testing.T) { - r := NewRegistry() - a := r.Subscribe(7, 4) - b := r.Subscribe(7, 4) - defer r.Unsubscribe(7, a) - defer r.Unsubscribe(7, b) - - if got := r.Dispatch(7, Event{ID: "x"}, 100*time.Millisecond); got != 2 { - t.Errorf("delivered = %d, want 2", got) - } -} - -func TestUnsubscribeStopsDelivery(t *testing.T) { - r := NewRegistry() - sub := r.Subscribe(3, 4) - r.Unsubscribe(3, sub) - - if got := r.Dispatch(3, Event{ID: "x"}, 100*time.Millisecond); got != 0 { - t.Errorf("delivered = %d, want 0", got) - } -} - -func TestSlowConsumerDoesNotBlockOthers(t *testing.T) { - r := NewRegistry() - slow := r.Subscribe(1, 1) - fast := r.Subscribe(1, 1) - defer r.Unsubscribe(1, slow) - defer r.Unsubscribe(1, fast) - - // Fill slow's buffer. - r.Dispatch(1, Event{ID: "first"}, 100*time.Millisecond) - - // Drain fast's buffer so it can receive the next message. - <-fast.Ch - - start := time.Now() - delivered := r.Dispatch(1, Event{ID: "second"}, 50*time.Millisecond) - elapsed := time.Since(start) - - // Fast consumer should have received "second", slow should be dropped. - if delivered != 1 { - t.Errorf("delivered = %d, want 1 (slow dropped)", delivered) - } - if elapsed > 200*time.Millisecond { - t.Errorf("dispatch blocked %v on slow consumer", elapsed) + t.Fatal("session sub did not receive event") } select { - case got := <-fast.Ch: - if got.ID != "second" { - t.Errorf("fast consumer received %v, want second", got.ID) - } - default: - t.Error("fast consumer did not receive any event") + case got := <-userSub.Ch: + t.Fatalf("user sub should not receive session event, got %+v", got) + case <-time.After(50 * time.Millisecond): } } -func TestConcurrentSubscribeUnsubscribe(t *testing.T) { +func TestUnsubscribeRemovesChannelEntry(t *testing.T) { r := NewRegistry() - var wg sync.WaitGroup - for i := 0; i < 50; i++ { - wg.Add(1) - go func() { - defer wg.Done() - s := r.Subscribe(42, 4) - r.Dispatch(42, Event{ID: "x"}, 50*time.Millisecond) - r.Unsubscribe(42, s) - }() + sub := r.Subscribe(ch(ChannelDocument, 7), 1) + r.Unsubscribe(ch(ChannelDocument, 7), sub) + + if n := r.Dispatch(ch(ChannelDocument, 7), Event{ID: "m", Type: "x", Data: []byte("{}")}, 50*time.Millisecond); n != 0 { + t.Errorf("delivered = %d, want 0 after unsubscribe", n) } - wg.Wait() } diff --git a/realtime/internal/transport/router.go b/realtime/internal/transport/router.go index 09622a04..e1a7a3ca 100644 --- a/realtime/internal/transport/router.go +++ b/realtime/internal/transport/router.go @@ -3,25 +3,48 @@ package transport import ( "log/slog" "net/http" + "strconv" + "github.com/Team-StackUp/stackup/realtime/internal/auth" "github.com/Team-StackUp/stackup/realtime/internal/session" "github.com/Team-StackUp/stackup/realtime/internal/trace" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" ) -func NewRouter(reg *session.Registry, sse *SSEHandler) http.Handler { +func NewRouter(sse *SSEHandler, verifier *auth.StreamTokenVerifier) http.Handler { r := chi.NewRouter() r.Use(middleware.Recoverer) r.Use(trace.Middleware) r.Use(loggingMiddleware) r.Get("/health", HealthHandler) - r.Get("/realtime/sessions/{id}", sse.ServeHTTP) + + r.Group(func(pr chi.Router) { + pr.Use(auth.Middleware(verifier)) + + pr.Get("/realtime/stream/me", func(w http.ResponseWriter, req *http.Request) { + userID := auth.UserIDFromContext(req.Context()) + sse.ServeChannel(w, req, session.Channel{Kind: session.ChannelUser, ID: userID}) + }) + pr.Get("/realtime/stream/documents/{id}", channelByPath(sse, session.ChannelDocument)) + pr.Get("/realtime/stream/sessions/{id}", channelByPath(sse, session.ChannelSession)) + }) return r } +func channelByPath(sse *SSEHandler, kind session.ChannelKind) http.HandlerFunc { + return func(w http.ResponseWriter, req *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(req, "id"), 10, 64) + if err != nil || id <= 0 { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + sse.ServeChannel(w, req, session.Channel{Kind: kind, ID: id}) + } +} + func loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { traceID := trace.FromContext(r.Context()) diff --git a/realtime/internal/transport/sse.go b/realtime/internal/transport/sse.go index 782a07e5..7a0c2bcd 100644 --- a/realtime/internal/transport/sse.go +++ b/realtime/internal/transport/sse.go @@ -1,16 +1,13 @@ package transport import ( - "context" "fmt" "log/slog" "net/http" - "strconv" "time" "github.com/Team-StackUp/stackup/realtime/internal/session" "github.com/Team-StackUp/stackup/realtime/internal/trace" - "github.com/go-chi/chi/v5" ) type SSEHandler struct { @@ -29,14 +26,9 @@ func NewSSEHandler(r *session.Registry, bufferSize int, pingInterval time.Durati } } -func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { - idStr := chi.URLParam(r, "id") - sid, err := strconv.ParseInt(idStr, 10, 64) - if err != nil || sid <= 0 { - http.Error(w, "invalid session id", http.StatusBadRequest) - return - } - +// ServeChannel streams events for the given channel to the client until the +// request context is cancelled. +func (h *SSEHandler) ServeChannel(w http.ResponseWriter, r *http.Request, channel session.Channel) { flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "streaming unsupported", http.StatusInternalServerError) @@ -51,10 +43,10 @@ func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { flusher.Flush() traceID := trace.FromContext(r.Context()) - slog.Info("sse.subscribe", "session_id", sid, "trace_id", traceID) + slog.Info("sse.subscribe", "channel_kind", channel.Kind, "channel_id", channel.ID, "trace_id", traceID) - sub := h.Registry.Subscribe(sid, h.BufferSize) - defer h.Registry.Unsubscribe(sid, sub) + sub := h.Registry.Subscribe(channel, h.BufferSize) + defer h.Registry.Unsubscribe(channel, sub) ctx := r.Context() ticker := time.NewTicker(h.PingInterval) @@ -63,7 +55,7 @@ func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { for { select { case <-ctx.Done(): - slog.Info("sse.unsubscribe", "session_id", sid, "reason", "client_close") + slog.Info("sse.unsubscribe", "channel_kind", channel.Kind, "channel_id", channel.ID, "reason", "client_close") return case <-ticker.C: if _, err := fmt.Fprintf(w, "%s%d\n\n", h.HeartbeatPrefix, time.Now().Unix()); err != nil { @@ -85,6 +77,3 @@ func (h *SSEHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { func writeSSE(w http.ResponseWriter, ev session.Event) (int, error) { return fmt.Fprintf(w, "id: %s\nevent: %s\ndata: %s\n\n", ev.ID, ev.Type, ev.Data) } - -// for tests -var _ = context.Background diff --git a/realtime/internal/transport/sse_test.go b/realtime/internal/transport/sse_test.go index c8c1387b..db3fa31b 100644 --- a/realtime/internal/transport/sse_test.go +++ b/realtime/internal/transport/sse_test.go @@ -1,57 +1,45 @@ package transport import ( - "net/http" + "context" "net/http/httptest" + "strings" "testing" "time" "github.com/Team-StackUp/stackup/realtime/internal/session" ) -func TestSSEHandlerRejectsNonNumericID(t *testing.T) { +func TestSSEDocumentChannelReceivesEvent(t *testing.T) { reg := session.NewRegistry() - sse := NewSSEHandler(reg, 4, 1*time.Second) - r := NewRouter(reg, sse) + h := NewSSEHandler(reg, 4, time.Hour) - req := httptest.NewRequest("GET", "/realtime/sessions/abc", nil) + req := httptest.NewRequest("GET", "/realtime/stream/documents/101", nil) + ctx, cancel := context.WithCancel(req.Context()) + req = req.WithContext(ctx) rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Errorf("status = %d, want 400", rec.Code) + done := make(chan struct{}) + go func() { + h.ServeChannel(rec, req, session.Channel{Kind: session.ChannelDocument, ID: 101}) + close(done) + }() + + // allow the handler to subscribe before dispatching + deadline := time.Now().Add(time.Second) + for time.Now().Before(deadline) { + if reg.Dispatch(session.Channel{Kind: session.ChannelDocument, ID: 101}, + session.Event{ID: "m1", Type: "doc.state", Data: []byte(`{"x":1}`)}, 200*time.Millisecond) == 1 { + break + } + time.Sleep(10 * time.Millisecond) } -} - -func TestSSEHandlerRejectsZeroID(t *testing.T) { - reg := session.NewRegistry() - sse := NewSSEHandler(reg, 4, 1*time.Second) - r := NewRouter(reg, sse) + time.Sleep(50 * time.Millisecond) + cancel() + <-done - req := httptest.NewRequest("GET", "/realtime/sessions/0", nil) - rec := httptest.NewRecorder() - - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusBadRequest { - t.Errorf("status = %d, want 400", rec.Code) - } -} - -func TestHealthEndpoint(t *testing.T) { - reg := session.NewRegistry() - sse := NewSSEHandler(reg, 4, 1*time.Second) - r := NewRouter(reg, sse) - - req := httptest.NewRequest("GET", "/health", nil) - rec := httptest.NewRecorder() - r.ServeHTTP(rec, req) - - if rec.Code != http.StatusOK { - t.Errorf("status = %d", rec.Code) - } - if rec.Body.String() != `{"status":"ok"}` { - t.Errorf("body = %q", rec.Body.String()) + body := rec.Body.String() + if !strings.Contains(body, "event: doc.state") || !strings.Contains(body, "id: m1") { + t.Errorf("body missing event frame:\n%s", body) } } From 249a3f8d5466c351827e856685c1c33526079bed Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Mon, 1 Jun 2026 20:14:29 +0900 Subject: [PATCH 2/5] =?UTF-8?q?refactor(core):=20SSE=20=EC=A7=81=EC=A0=91?= =?UTF-8?q?=20=EC=84=9C=EB=B9=99=20=EC=A0=9C=EA=B1=B0,=20RealTime=20?= =?UTF-8?q?=EB=B0=9C=ED=96=89=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../config/properties/RabbitMqProperties.java | 4 +- .../messaging/RealtimeNotifyPublisher.java | 39 ++++-- .../common/security/SecurityConfig.java | 4 - .../StreamTokenAuthenticationFilter.java | 67 ---------- .../stackup/common/sse/SseConfiguration.java | 9 -- .../stackup/common/sse/SseController.java | 32 ----- .../common/sse/SseEmitterRegistry.java | 114 ------------------ .../stackup/stackup/common/sse/SseEvent.java | 12 -- .../stackup/common/sse/SseEventPublisher.java | 34 ------ .../common/sse/SseKeepAliveScheduler.java | 19 --- .../application/AnalysisCallbackService.java | 10 +- .../GithubRepositoryController.java | 2 +- .../resume/presentation/ResumeController.java | 2 +- .../application/FeedbackCallbackService.java | 8 +- .../application/QuestionsCallbackService.java | 16 +-- .../application/VoiceCallbackService.java | 10 +- backend/src/main/resources/application.yml | 2 + .../messaging/RabbitMessagePublisherTest.java | 4 +- .../common/messaging/RabbitMqConfigTest.java | 4 +- .../RealtimeNotifyPublisherTest.java | 81 +++++++++++++ .../common/sse/SseEventPublisherTest.java | 47 -------- .../FeedbackCallbackServiceTest.java | 6 +- .../QuestionsCallbackServiceTest.java | 6 +- .../SessionFeedbackRequesterTest.java | 2 +- .../application/VoiceCallbackServiceTest.java | 6 +- docs/data-flow.md | 8 +- docs/messaging.md | 14 ++- 27 files changed, 169 insertions(+), 393 deletions(-) delete mode 100644 backend/src/main/java/com/stackup/stackup/common/security/StreamTokenAuthenticationFilter.java delete mode 100644 backend/src/main/java/com/stackup/stackup/common/sse/SseConfiguration.java delete mode 100644 backend/src/main/java/com/stackup/stackup/common/sse/SseController.java delete mode 100644 backend/src/main/java/com/stackup/stackup/common/sse/SseEmitterRegistry.java delete mode 100644 backend/src/main/java/com/stackup/stackup/common/sse/SseEvent.java delete mode 100644 backend/src/main/java/com/stackup/stackup/common/sse/SseEventPublisher.java delete mode 100644 backend/src/main/java/com/stackup/stackup/common/sse/SseKeepAliveScheduler.java create mode 100644 backend/src/test/java/com/stackup/stackup/common/messaging/RealtimeNotifyPublisherTest.java delete mode 100644 backend/src/test/java/com/stackup/stackup/common/sse/SseEventPublisherTest.java diff --git a/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java b/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java index 4ab130d8..a4439f93 100644 --- a/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java +++ b/backend/src/main/java/com/stackup/stackup/common/config/properties/RabbitMqProperties.java @@ -88,7 +88,9 @@ public record RoutingKeyProperties( @NotBlank String callbackQuestions, @NotBlank String callbackFeedback, @NotBlank String callbackVoice, - @NotBlank String realtimeSessionNotify + @NotBlank String realtimeSessionNotify, + @NotBlank String realtimeUserNotify, + @NotBlank String realtimeDocumentNotify ) { } diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/RealtimeNotifyPublisher.java b/backend/src/main/java/com/stackup/stackup/common/messaging/RealtimeNotifyPublisher.java index 51558f19..46123c49 100644 --- a/backend/src/main/java/com/stackup/stackup/common/messaging/RealtimeNotifyPublisher.java +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/RealtimeNotifyPublisher.java @@ -1,6 +1,7 @@ package com.stackup.stackup.common.messaging; import com.stackup.stackup.common.config.properties.RabbitMqProperties; +import com.stackup.stackup.common.sse.SseEventType; import com.stackup.stackup.common.trace.TraceContext; import java.time.Instant; import java.util.UUID; @@ -8,8 +9,11 @@ import org.springframework.amqp.rabbit.core.RabbitTemplate; import org.springframework.stereotype.Component; -// Core -> 실시간 서버 알림 -// 실시간 서버는 SSE로 구독한 컴포넌트에게 알려줌 +// Core -> RealTime 서버 알림 발행. RealTime 이 SSE/WS 구독자에게 fan-out 한다. +// 채널별 messageType(realtime.{session|user|document}.notify)으로 발행하며, +// RealTime 의 bridge.Envelope.Channel() 이 messageType + context 로 채널을 판별한다. +// 호출은 콜백 서비스의 @Transactional 안에서 DB 영속 이후 일어나므로, SSE 페이로드의 ID 는 +// 프론트가 조회 가능한 상태가 보장된다. @Component public class RealtimeNotifyPublisher { @@ -24,17 +28,27 @@ public RealtimeNotifyPublisher(RabbitMqProperties properties, RabbitTemplate rab this.rabbitTemplate = rabbitTemplate; } - public MessageEnvelope publishSessionNotify( - long sessionId, - Long userId, - String eventType, - Object data - ) { - var payload = new RealtimeNotifyPayload(eventType, data); - var context = new MessageContext(userId, sessionId, null, null); + public void publishToSession(Long sessionId, SseEventType type, Object data) { + publish(properties.routingKeys().realtimeSessionNotify(), + new MessageContext(null, sessionId, null, null), type, data); + } + + public void publishToUser(Long userId, SseEventType type, Object data) { + publish(properties.routingKeys().realtimeUserNotify(), + new MessageContext(userId, null, null, null), type, data); + } + + public void publishToDocument(Long documentId, SseEventType type, Object data) { + publish(properties.routingKeys().realtimeDocumentNotify(), + new MessageContext(null, null, documentId, null), type, data); + } + + private void publish(String routingKey, MessageContext context, SseEventType type, Object data) { + var payload = new RealtimeNotifyPayload(type.name(), data); + // messageType == routing key (realtime.{kind}.notify) — RealTime 이 이 값으로 채널을 판별한다. var envelope = new MessageEnvelope<>( UUID.randomUUID().toString(), - "realtime.session.notify", + routingKey, properties.version(), TraceContext.getTraceId(), Instant.now(), @@ -45,11 +59,10 @@ public MessageEnvelope publishSessionNotify( rabbitTemplate.convertAndSend( properties.exchanges().names().realtime(), - properties.routingKeys().realtimeSessionNotify(), + routingKey, envelope, withEnvelopeHeaders(envelope) ); - return envelope; } private MessagePostProcessor withEnvelopeHeaders(MessageEnvelope envelope) { diff --git a/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java b/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java index 5d86ac26..7bb5619a 100644 --- a/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java +++ b/backend/src/main/java/com/stackup/stackup/common/security/SecurityConfig.java @@ -30,18 +30,15 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final InternalApiKeyAuthenticationFilter internalApiKeyAuthenticationFilter; - private final StreamTokenAuthenticationFilter streamTokenAuthenticationFilter; private final CorsProperties corsProperties; public SecurityConfig( JwtAuthenticationFilter jwtAuthenticationFilter, InternalApiKeyAuthenticationFilter internalApiKeyAuthenticationFilter, - StreamTokenAuthenticationFilter streamTokenAuthenticationFilter, CorsProperties corsProperties ) { this.jwtAuthenticationFilter = jwtAuthenticationFilter; this.internalApiKeyAuthenticationFilter = internalApiKeyAuthenticationFilter; - this.streamTokenAuthenticationFilter = streamTokenAuthenticationFilter; this.corsProperties = corsProperties; } @@ -74,7 +71,6 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .anyRequest().permitAll() ) .addFilterBefore(internalApiKeyAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) - .addFilterBefore(streamTokenAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class) .build(); } diff --git a/backend/src/main/java/com/stackup/stackup/common/security/StreamTokenAuthenticationFilter.java b/backend/src/main/java/com/stackup/stackup/common/security/StreamTokenAuthenticationFilter.java deleted file mode 100644 index d5893319..00000000 --- a/backend/src/main/java/com/stackup/stackup/common/security/StreamTokenAuthenticationFilter.java +++ /dev/null @@ -1,67 +0,0 @@ -package com.stackup.stackup.common.security; - -import com.stackup.stackup.common.exception.ApiErrorCode; -import jakarta.servlet.FilterChain; -import jakarta.servlet.ServletException; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.io.IOException; -import java.util.List; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.stereotype.Component; -import org.springframework.web.filter.OncePerRequestFilter; - -// 인증 처리 전용 -@Component -public class StreamTokenAuthenticationFilter extends OncePerRequestFilter { - - private static final Logger log = LoggerFactory.getLogger(StreamTokenAuthenticationFilter.class); - private static final String STREAM_PATH_PREFIX = "/api/stream/"; - private static final String TOKEN_PARAM = "token"; - - private final StreamTokenProvider streamTokenProvider; - - public StreamTokenAuthenticationFilter(StreamTokenProvider streamTokenProvider) { - this.streamTokenProvider = streamTokenProvider; - } - - @Override - protected void doFilterInternal( - HttpServletRequest request, - HttpServletResponse response, - FilterChain chain - ) throws ServletException, IOException { - if (!request.getRequestURI().startsWith(STREAM_PATH_PREFIX)) { - chain.doFilter(request, response); - return; - } - String token = request.getParameter(TOKEN_PARAM); - if (token == null || token.isBlank()) { - // query token 없으면 일반 JWT 필터가 Authorization 헤더로 처리하도록 통과 - chain.doFilter(request, response); - return; - } - - try { - Long userId = streamTokenProvider.getUserId(token); - UserPrincipal principal = new UserPrincipal( - userId, null, List.of(new SimpleGrantedAuthority("ROLE_USER")) - ); - UsernamePasswordAuthenticationToken authentication = - new UsernamePasswordAuthenticationToken(principal, token, principal.getAuthorities()); - SecurityContextHolder.getContext().setAuthentication(authentication); - } catch (RuntimeException exception) { - request.setAttribute( - JwtAuthenticationFilter.AUTH_ERROR_CODE_ATTRIBUTE, - ApiErrorCode.AUTH_INVALID_TOKEN - ); - SecurityContextHolder.clearContext(); - log.warn("stream-token authentication failed. uri={}", request.getRequestURI()); - } - chain.doFilter(request, response); - } -} diff --git a/backend/src/main/java/com/stackup/stackup/common/sse/SseConfiguration.java b/backend/src/main/java/com/stackup/stackup/common/sse/SseConfiguration.java deleted file mode 100644 index 06b24c1b..00000000 --- a/backend/src/main/java/com/stackup/stackup/common/sse/SseConfiguration.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.stackup.stackup.common.sse; - -import org.springframework.context.annotation.Configuration; -import org.springframework.scheduling.annotation.EnableScheduling; - -@Configuration -@EnableScheduling -public class SseConfiguration { -} diff --git a/backend/src/main/java/com/stackup/stackup/common/sse/SseController.java b/backend/src/main/java/com/stackup/stackup/common/sse/SseController.java deleted file mode 100644 index 98ba9635..00000000 --- a/backend/src/main/java/com/stackup/stackup/common/sse/SseController.java +++ /dev/null @@ -1,32 +0,0 @@ -package com.stackup.stackup.common.sse; - -import com.stackup.stackup.common.security.UserPrincipal; -import java.util.Objects; -import org.springframework.http.MediaType; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -@RestController -@RequestMapping("/api/stream") -public record SseController(SseEmitterRegistry registry) { - - @GetMapping(value = "/me", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public SseEmitter streamMe(@AuthenticationPrincipal UserPrincipal principal) { - UserPrincipal authenticatedPrincipal = Objects.requireNonNull(principal, "authenticated principal is required"); - return registry.registerUser(authenticatedPrincipal.userId()); - } - - @GetMapping(value = "/sessions/{sessionId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public SseEmitter streamSession(@PathVariable Long sessionId) { - return registry.registerSession(sessionId); - } - - @GetMapping(value = "/documents/{documentId}", produces = MediaType.TEXT_EVENT_STREAM_VALUE) - public SseEmitter streamDocument(@PathVariable Long documentId) { - return registry.registerDocument(documentId); - } -} diff --git a/backend/src/main/java/com/stackup/stackup/common/sse/SseEmitterRegistry.java b/backend/src/main/java/com/stackup/stackup/common/sse/SseEmitterRegistry.java deleted file mode 100644 index 08de4c98..00000000 --- a/backend/src/main/java/com/stackup/stackup/common/sse/SseEmitterRegistry.java +++ /dev/null @@ -1,114 +0,0 @@ -package com.stackup.stackup.common.sse; - -import com.stackup.stackup.common.config.properties.SseProperties; -import java.io.IOException; -import java.util.Map; -import java.util.Objects; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.CopyOnWriteArrayList; -import org.springframework.http.MediaType; -import org.springframework.stereotype.Component; -import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; - -@Component -public class SseEmitterRegistry { - - private final SseProperties sseProperties; - private final Map> userEmitters = new ConcurrentHashMap<>(); - private final Map> sessionEmitters = new ConcurrentHashMap<>(); - private final Map> documentEmitters = new ConcurrentHashMap<>(); - - public SseEmitterRegistry(SseProperties sseProperties) { - this.sseProperties = sseProperties; - } - - public SseEmitter registerUser(Long userId) { - return register(userEmitters, userId); - } - - public SseEmitter registerSession(Long sessionId) { - return register(sessionEmitters, sessionId); - } - - public SseEmitter registerDocument(Long documentId) { - return register(documentEmitters, documentId); - } - - public void sendToUser(Long userId, SseEvent event) { - send(userEmitters.get(userId), event); - } - - public void sendToSession(Long sessionId, SseEvent event) { - send(sessionEmitters.get(sessionId), event); - } - - public void sendToDocument(Long documentId, SseEvent event) { - send(documentEmitters.get(documentId), event); - } - - public void remove(SseEmitter emitter) { - if (emitter == null) { - return; - } - removeFrom(userEmitters, emitter); - removeFrom(sessionEmitters, emitter); - removeFrom(documentEmitters, emitter); - } - - public void keepAliveAll() { - SseEvent event = new SseEvent(null, SseEventType.KEEP_ALIVE, null, java.time.Instant.now(), null); - userEmitters.values().forEach(emitters -> send(emitters, event)); - sessionEmitters.values().forEach(emitters -> send(emitters, event)); - documentEmitters.values().forEach(emitters -> send(emitters, event)); - } - - private SseEmitter register(Map> registry, Long key) { - Objects.requireNonNull(key, "key must not be null"); - - SseEmitter emitter = new SseEmitter(sseProperties.timeout().toMillis()); - registry.computeIfAbsent(key, ignored -> new CopyOnWriteArrayList<>()).add(emitter); - - emitter.onCompletion(() -> remove(emitter)); - emitter.onTimeout(() -> remove(emitter)); - emitter.onError(throwable -> remove(emitter)); - - sendInitialEvent(emitter); - return emitter; - } - - private void sendInitialEvent(SseEmitter emitter) { - try { - emitter.send(SseEmitter.event() - .name(SseEventType.KEEP_ALIVE.name()) - .data(new SseEvent(null, SseEventType.KEEP_ALIVE, "connected", java.time.Instant.now(), null), - MediaType.APPLICATION_JSON)); - } catch (IOException ex) { - remove(emitter); - } - } - - private void send(CopyOnWriteArrayList emitters, SseEvent event) { - if (emitters == null || emitters.isEmpty()) { - return; - } - for (SseEmitter emitter : emitters) { - try { - emitter.send(SseEmitter.event() - .id(event.id()) - .name(event.type().name()) - .data(event, MediaType.APPLICATION_JSON)); - } catch (IOException | IllegalStateException ex) { - remove(emitter); - } - } - } - - private void removeFrom(Map> registry, SseEmitter emitter) { - registry.forEach((key, emitters) -> { - emitters.remove(emitter); - if (emitters.isEmpty()) { - registry.remove(key, emitters); - } - }); - } -} diff --git a/backend/src/main/java/com/stackup/stackup/common/sse/SseEvent.java b/backend/src/main/java/com/stackup/stackup/common/sse/SseEvent.java deleted file mode 100644 index e22641e3..00000000 --- a/backend/src/main/java/com/stackup/stackup/common/sse/SseEvent.java +++ /dev/null @@ -1,12 +0,0 @@ -package com.stackup.stackup.common.sse; - -import java.time.Instant; - -public record SseEvent( - String id, - SseEventType type, - Object payload, - Instant timestamp, - String traceId -) { -} diff --git a/backend/src/main/java/com/stackup/stackup/common/sse/SseEventPublisher.java b/backend/src/main/java/com/stackup/stackup/common/sse/SseEventPublisher.java deleted file mode 100644 index 58bc063d..00000000 --- a/backend/src/main/java/com/stackup/stackup/common/sse/SseEventPublisher.java +++ /dev/null @@ -1,34 +0,0 @@ -package com.stackup.stackup.common.sse; - -import com.stackup.stackup.common.trace.TraceContext; -import java.time.Instant; -import java.util.UUID; -import org.springframework.stereotype.Component; - -@Component -public class SseEventPublisher { - - private final SseEmitterRegistry registry; - - public SseEventPublisher(SseEmitterRegistry registry) { - this.registry = registry; - } - - public SseEvent publishToUser(Long userId, SseEventType type, Object payload) { - SseEvent event = new SseEvent(UUID.randomUUID().toString(), type, payload, Instant.now(), TraceContext.getTraceId()); - registry.sendToUser(userId, event); - return event; - } - - public SseEvent publishToSession(Long sessionId, SseEventType type, Object payload) { - SseEvent event = new SseEvent(UUID.randomUUID().toString(), type, payload, Instant.now(), TraceContext.getTraceId()); - registry.sendToSession(sessionId, event); - return event; - } - - public SseEvent publishToDocument(Long documentId, SseEventType type, Object payload) { - SseEvent event = new SseEvent(UUID.randomUUID().toString(), type, payload, Instant.now(), TraceContext.getTraceId()); - registry.sendToDocument(documentId, event); - return event; - } -} diff --git a/backend/src/main/java/com/stackup/stackup/common/sse/SseKeepAliveScheduler.java b/backend/src/main/java/com/stackup/stackup/common/sse/SseKeepAliveScheduler.java deleted file mode 100644 index 1a5213bc..00000000 --- a/backend/src/main/java/com/stackup/stackup/common/sse/SseKeepAliveScheduler.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.stackup.stackup.common.sse; - -import org.springframework.scheduling.annotation.Scheduled; -import org.springframework.stereotype.Component; - -@Component -public class SseKeepAliveScheduler { - - private final SseEmitterRegistry registry; - - public SseKeepAliveScheduler(SseEmitterRegistry registry) { - this.registry = registry; - } - - @Scheduled(fixedDelayString = "${app.sse.keep-alive-interval}") - public void sendKeepAlive() { - registry.keepAliveAll(); - } -} diff --git a/backend/src/main/java/com/stackup/stackup/document/application/AnalysisCallbackService.java b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisCallbackService.java index bb102402..ae69b404 100644 --- a/backend/src/main/java/com/stackup/stackup/document/application/AnalysisCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisCallbackService.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.json.JsonMapper; import com.stackup.stackup.common.messaging.domain.ProcessedMessage; import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; -import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.common.messaging.RealtimeNotifyPublisher; import com.stackup.stackup.common.sse.SseEventType; import com.stackup.stackup.document.application.dto.AnalysisCallbackEnvelope; import com.stackup.stackup.document.application.dto.AnalysisCallbackPayload; @@ -32,7 +32,7 @@ public class AnalysisCallbackService { private final AnalyzedDocumentRepository documentRepository; private final ProcessedMessageRepository processedMessageRepository; - private final SseEventPublisher sseEventPublisher; + private final RealtimeNotifyPublisher realtimeNotifyPublisher; @Transactional public void apply(AnalysisCallbackEnvelope envelope) { @@ -104,11 +104,11 @@ private void applyFailed(AnalyzedDocument doc, AnalysisCallbackPayload payload) private void publishSse(AnalyzedDocument doc, AnalysisCallbackPayload payload) { SseEventType type = doc.getRepository() != null ? SseEventType.REPO_STATE : SseEventType.DOC_STATE; - sseEventPublisher.publishToDocument(doc.getId(), type, payload); - // 프론트가 documentId 를 모르고도 /api/stream/me 로 분석 진행을 받을 수 있게 user 채널에도 push. + realtimeNotifyPublisher.publishToDocument(doc.getId(), type, payload); + // 프론트가 documentId 를 모르고도 /realtime/stream/me 로 분석 진행을 받을 수 있게 user 채널에도 push. Long userId = resolveOwnerUserId(doc); if (userId != null) { - sseEventPublisher.publishToUser(userId, type, payload); + realtimeNotifyPublisher.publishToUser(userId, type, payload); } } diff --git a/backend/src/main/java/com/stackup/stackup/github/presentation/GithubRepositoryController.java b/backend/src/main/java/com/stackup/stackup/github/presentation/GithubRepositoryController.java index 212188d1..5685dadd 100644 --- a/backend/src/main/java/com/stackup/stackup/github/presentation/GithubRepositoryController.java +++ b/backend/src/main/java/com/stackup/stackup/github/presentation/GithubRepositoryController.java @@ -24,7 +24,7 @@ import org.springframework.web.bind.annotation.ResponseStatus; import org.springframework.web.bind.annotation.RestController; -@Tag(name = "Repositories", description = "GitHub 레포 등록/관리. 등록 시 분석 파이프라인이 자동 트리거되며 결과는 /api/stream/me (REPO_STATE) 로 통지됨.") +@Tag(name = "Repositories", description = "GitHub 레포 등록/관리. 등록 시 분석 파이프라인이 자동 트리거되며 결과는 /realtime/stream/me (REPO_STATE) 로 통지됨.") @RestController @RequestMapping("/api/repositories") @RequiredArgsConstructor diff --git a/backend/src/main/java/com/stackup/stackup/resume/presentation/ResumeController.java b/backend/src/main/java/com/stackup/stackup/resume/presentation/ResumeController.java index 4a08960e..2598281a 100644 --- a/backend/src/main/java/com/stackup/stackup/resume/presentation/ResumeController.java +++ b/backend/src/main/java/com/stackup/stackup/resume/presentation/ResumeController.java @@ -26,7 +26,7 @@ import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; -@Tag(name = "Resumes", description = "이력서 업로드/관리. 업로드 시 분석 파이프라인이 자동 트리거되며 결과는 /api/stream/me (DOC_STATE) 로 통지됨.") +@Tag(name = "Resumes", description = "이력서 업로드/관리. 업로드 시 분석 파이프라인이 자동 트리거되며 결과는 /realtime/stream/me (DOC_STATE) 로 통지됨.") @RestController @RequestMapping("/api/resumes") @RequiredArgsConstructor diff --git a/backend/src/main/java/com/stackup/stackup/session/application/FeedbackCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/FeedbackCallbackService.java index 2888e46f..ce1fe18c 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/FeedbackCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/FeedbackCallbackService.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.stackup.stackup.common.messaging.domain.ProcessedMessage; import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; -import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.common.messaging.RealtimeNotifyPublisher; import com.stackup.stackup.common.sse.SseEventType; import com.stackup.stackup.session.application.dto.FeedbackCallbackEnvelope; import com.stackup.stackup.session.application.dto.FeedbackCallbackPayload; @@ -33,7 +33,7 @@ public class FeedbackCallbackService { private final InterviewSessionRepository sessionRepository; private final SessionFeedbackRepository feedbackRepository; private final ProcessedMessageRepository processedMessageRepository; - private final SseEventPublisher sseEventPublisher; + private final RealtimeNotifyPublisher realtimeNotifyPublisher; @Transactional public void apply(FeedbackCallbackEnvelope envelope) { @@ -84,9 +84,9 @@ public void apply(FeedbackCallbackEnvelope envelope) { return; } - sseEventPublisher.publishToSession(sessionId, SseEventType.FEEDBACK_READY, + realtimeNotifyPublisher.publishToSession(sessionId, SseEventType.FEEDBACK_READY, new SessionFeedbackNotice(sessionId, feedback.getId())); - sseEventPublisher.publishToUser(session.getUser().getId(), SseEventType.FEEDBACK_READY, + realtimeNotifyPublisher.publishToUser(session.getUser().getId(), SseEventType.FEEDBACK_READY, new SessionFeedbackNotice(sessionId, feedback.getId())); markProcessed(envelope.messageId()); diff --git a/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java index 08615fa6..779b5ace 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java @@ -2,7 +2,7 @@ import com.stackup.stackup.common.messaging.domain.ProcessedMessage; import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; -import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.common.messaging.RealtimeNotifyPublisher; import com.stackup.stackup.common.sse.SseEventType; import com.stackup.stackup.session.application.dto.QuestionsCallbackEnvelope; import com.stackup.stackup.session.application.dto.QuestionsCallbackPayload; @@ -36,7 +36,7 @@ public class QuestionsCallbackService { private final InterviewSessionRepository sessionRepository; private final InterviewMessageRepository messageRepository; private final ProcessedMessageRepository processedMessageRepository; - private final SseEventPublisher sseEventPublisher; + private final RealtimeNotifyPublisher realtimeNotifyPublisher; private final ApplicationEventPublisher events; @Transactional @@ -85,9 +85,9 @@ private void applyPool(InterviewSession session, QuestionsCallbackPayload payloa InterviewMessage.interviewer(session, 1, first.question()) ); session.incrementQuestionCount(); - sseEventPublisher.publishToSession(session.getId(), SseEventType.SESSION_MESSAGE, message.getId()); + realtimeNotifyPublisher.publishToSession(session.getId(), SseEventType.SESSION_MESSAGE, message.getId()); // 사용자 user 채널에도 알림 — frontend 가 documentId/sessionId 사전 인지 없이도 받을 수 있게 - sseEventPublisher.publishToUser( + realtimeNotifyPublisher.publishToUser( session.getUser().getId(), SseEventType.SESSION_MESSAGE, new SessionMessageNotice(session.getId(), message.getId(), "QUESTION_POOL_READY") @@ -113,8 +113,8 @@ private void applyFollowup(InterviewSession session, QuestionsCallbackPayload pa ); session.incrementQuestionCount(); - sseEventPublisher.publishToSession(session.getId(), SseEventType.SESSION_MESSAGE, message.getId()); - sseEventPublisher.publishToUser( + realtimeNotifyPublisher.publishToSession(session.getId(), SseEventType.SESSION_MESSAGE, message.getId()); + realtimeNotifyPublisher.publishToUser( session.getUser().getId(), SseEventType.SESSION_MESSAGE, new SessionMessageNotice(session.getId(), message.getId(), "FOLLOWUP_READY") @@ -126,9 +126,9 @@ private void applyFollowup(InterviewSession session, QuestionsCallbackPayload pa && session.getTotalQuestionCount() >= max) { try { session.end(); - sseEventPublisher.publishToSession(session.getId(), SseEventType.SESSION_STATE, + realtimeNotifyPublisher.publishToSession(session.getId(), SseEventType.SESSION_STATE, new SessionStateNotice(session.getId(), session.getStatus().name(), "MAX_QUESTIONS_REACHED")); - sseEventPublisher.publishToUser(session.getUser().getId(), SseEventType.SESSION_STATE, + realtimeNotifyPublisher.publishToUser(session.getUser().getId(), SseEventType.SESSION_STATE, new SessionStateNotice(session.getId(), session.getStatus().name(), "MAX_QUESTIONS_REACHED")); events.publishEvent(new SessionEndedEvent( session.getUser().getId(), session.getId(), "MAX_QUESTIONS_REACHED")); diff --git a/backend/src/main/java/com/stackup/stackup/session/application/VoiceCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/VoiceCallbackService.java index 407909f6..dd388187 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/VoiceCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/VoiceCallbackService.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.stackup.stackup.common.messaging.domain.ProcessedMessage; import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; -import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.common.messaging.RealtimeNotifyPublisher; import com.stackup.stackup.common.sse.SseEventType; import com.stackup.stackup.session.application.dto.VoiceCallbackEnvelope; import com.stackup.stackup.session.application.dto.VoiceCallbackPayload; @@ -37,7 +37,7 @@ public class VoiceCallbackService { private final InterviewMessageRepository messageRepository; private final MessageVoiceAnalysisRepository voiceAnalysisRepository; private final ProcessedMessageRepository processedMessageRepository; - private final SseEventPublisher sseEventPublisher; + private final RealtimeNotifyPublisher realtimeNotifyPublisher; private final ApplicationEventPublisher events; @Transactional @@ -65,7 +65,7 @@ public void apply(VoiceCallbackEnvelope envelope) { if (p.errorCode() != null && !p.errorCode().isBlank()) { message.markStatus(MessageStatus.FAILED); - sseEventPublisher.publishToSession(p.sessionId(), SseEventType.SESSION_MESSAGE, + realtimeNotifyPublisher.publishToSession(p.sessionId(), SseEventType.SESSION_MESSAGE, new VoiceFailedNotice(p.sessionId(), message.getId(), p.errorCode())); markProcessed(envelope.messageId()); log.warn("callback.voice STT failed. sessionId={}, msg={}, code={}", @@ -89,9 +89,9 @@ public void apply(VoiceCallbackEnvelope envelope) { } } - sseEventPublisher.publishToSession(p.sessionId(), SseEventType.SESSION_MESSAGE, + realtimeNotifyPublisher.publishToSession(p.sessionId(), SseEventType.SESSION_MESSAGE, new VoiceTranscribedNotice(p.sessionId(), message.getId(), p.transcript())); - sseEventPublisher.publishToUser(message.getSession().getUser().getId(), + realtimeNotifyPublisher.publishToUser(message.getSession().getUser().getId(), SseEventType.SESSION_MESSAGE, new VoiceTranscribedNotice(p.sessionId(), message.getId(), p.transcript())); diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index b543758f..00c9ed56 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -112,6 +112,8 @@ app: callback-feedback: callback.feedback callback-voice: callback.voice realtime-session-notify: realtime.session.notify + realtime-user-notify: realtime.user.notify + realtime-document-notify: realtime.document.notify dead-letter: exchange: stackup.dlx routing-key-prefix: dlq. diff --git a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java index 93ae12ff..a115d060 100644 --- a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java +++ b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMessagePublisherTest.java @@ -111,7 +111,9 @@ private RabbitMqProperties rabbitMqProperties() { "callback.questions", "callback.feedback", "callback.voice", - "realtime.session.notify" + "realtime.session.notify", + "realtime.user.notify", + "realtime.document.notify" ), new RabbitMqProperties.DeadLetter("stackup.dlx", "dlq."), new RabbitMqProperties.Retry(3, Duration.ofSeconds(1), 2.0, Duration.ofSeconds(10)) diff --git a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMqConfigTest.java b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMqConfigTest.java index e7f0246d..b1b4a2cc 100644 --- a/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMqConfigTest.java +++ b/backend/src/test/java/com/stackup/stackup/common/messaging/RabbitMqConfigTest.java @@ -78,7 +78,9 @@ private RabbitMqProperties rabbitMqProperties() { "callback.questions", "callback.feedback", "callback.voice", - "realtime.session.notify" + "realtime.session.notify", + "realtime.user.notify", + "realtime.document.notify" ), new RabbitMqProperties.DeadLetter("stackup.dlx", "dlq."), new RabbitMqProperties.Retry(3, Duration.ofSeconds(1), 2.0, Duration.ofSeconds(10)) diff --git a/backend/src/test/java/com/stackup/stackup/common/messaging/RealtimeNotifyPublisherTest.java b/backend/src/test/java/com/stackup/stackup/common/messaging/RealtimeNotifyPublisherTest.java new file mode 100644 index 00000000..cef822a3 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/common/messaging/RealtimeNotifyPublisherTest.java @@ -0,0 +1,81 @@ +package com.stackup.stackup.common.messaging; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.common.config.properties.RabbitMqProperties; +import com.stackup.stackup.common.sse.SseEventType; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Answers; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.amqp.core.MessagePostProcessor; +import org.springframework.amqp.rabbit.core.RabbitTemplate; + +@ExtendWith(MockitoExtension.class) +class RealtimeNotifyPublisherTest { + + @Mock(answer = Answers.RETURNS_DEEP_STUBS) RabbitMqProperties properties; + @Mock RabbitTemplate rabbitTemplate; + @InjectMocks RealtimeNotifyPublisher publisher; + + @Test + void publishToUser_sendsUserNotifyEnvelope() { + when(properties.exchanges().names().realtime()).thenReturn("stackup.realtime"); + when(properties.routingKeys().realtimeUserNotify()).thenReturn("realtime.user.notify"); + when(properties.version()).thenReturn("v1"); + when(properties.publisher()).thenReturn("core-server"); + + publisher.publishToUser(42L, SseEventType.DOC_STATE, Map.of("state", "PROCESSING")); + + ArgumentCaptor cap = ArgumentCaptor.forClass(MessageEnvelope.class); + verify(rabbitTemplate).convertAndSend(eq("stackup.realtime"), eq("realtime.user.notify"), + cap.capture(), any(MessagePostProcessor.class)); + MessageEnvelope env = cap.getValue(); + assertThat(env.messageType()).isEqualTo("realtime.user.notify"); + assertThat(env.context().userId()).isEqualTo(42L); + assertThat(env.context().sessionId()).isNull(); + RealtimeNotifyPublisher.RealtimeNotifyPayload payload = + (RealtimeNotifyPublisher.RealtimeNotifyPayload) env.payload(); + assertThat(payload.eventType()).isEqualTo("DOC_STATE"); + } + + @Test + void publishToSession_sendsSessionNotifyEnvelopeWithSessionContext() { + when(properties.exchanges().names().realtime()).thenReturn("stackup.realtime"); + when(properties.routingKeys().realtimeSessionNotify()).thenReturn("realtime.session.notify"); + when(properties.version()).thenReturn("v1"); + when(properties.publisher()).thenReturn("core-server"); + + publisher.publishToSession(99L, SseEventType.SESSION_MESSAGE, 503L); + + ArgumentCaptor cap = ArgumentCaptor.forClass(MessageEnvelope.class); + verify(rabbitTemplate).convertAndSend(eq("stackup.realtime"), eq("realtime.session.notify"), + cap.capture(), any(MessagePostProcessor.class)); + MessageEnvelope env = cap.getValue(); + assertThat(env.context().sessionId()).isEqualTo(99L); + assertThat(env.context().userId()).isNull(); + } + + @Test + void publishToDocument_sendsDocumentNotifyEnvelopeWithDocumentContext() { + when(properties.exchanges().names().realtime()).thenReturn("stackup.realtime"); + when(properties.routingKeys().realtimeDocumentNotify()).thenReturn("realtime.document.notify"); + when(properties.version()).thenReturn("v1"); + when(properties.publisher()).thenReturn("core-server"); + + publisher.publishToDocument(101L, SseEventType.DOC_STATE, Map.of("k", "v")); + + ArgumentCaptor cap = ArgumentCaptor.forClass(MessageEnvelope.class); + verify(rabbitTemplate).convertAndSend(eq("stackup.realtime"), eq("realtime.document.notify"), + cap.capture(), any(MessagePostProcessor.class)); + assertThat(cap.getValue().context().documentId()).isEqualTo(101L); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/common/sse/SseEventPublisherTest.java b/backend/src/test/java/com/stackup/stackup/common/sse/SseEventPublisherTest.java deleted file mode 100644 index fb726e93..00000000 --- a/backend/src/test/java/com/stackup/stackup/common/sse/SseEventPublisherTest.java +++ /dev/null @@ -1,47 +0,0 @@ -package com.stackup.stackup.common.sse; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.eq; -import static org.mockito.Mockito.verify; - -import com.stackup.stackup.common.trace.TraceContext; -import java.util.Map; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; - -@ExtendWith(MockitoExtension.class) -class SseEventPublisherTest { - - @Mock - private SseEmitterRegistry registry; - - @InjectMocks - private SseEventPublisher sseEventPublisher; - - @BeforeEach - void setUp() { - TraceContext.clear(); - } - - @Test - void publishToUser_wrapsPayloadWithTraceMetadata() { - TraceContext.setTraceId("trace-777"); - - sseEventPublisher.publishToUser(7L, SseEventType.SESSION_STATE, Map.of("state", "READY")); - - ArgumentCaptor eventCaptor = ArgumentCaptor.forClass(SseEvent.class); - verify(registry).sendToUser(eq(7L), eventCaptor.capture()); - - SseEvent event = eventCaptor.getValue(); - assertThat(event.id()).isNotBlank(); - assertThat(event.type()).isEqualTo(SseEventType.SESSION_STATE); - assertThat(event.payload()).isEqualTo(Map.of("state", "READY")); - assertThat(event.timestamp()).isNotNull(); - assertThat(event.traceId()).isEqualTo("trace-777"); - } -} diff --git a/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java index e12baada..7dc7c561 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java @@ -8,7 +8,7 @@ import static org.mockito.Mockito.when; import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; -import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.common.messaging.RealtimeNotifyPublisher; import com.stackup.stackup.common.sse.SseEventType; import com.stackup.stackup.session.application.dto.FeedbackCallbackEnvelope; import com.stackup.stackup.session.application.dto.FeedbackCallbackPayload; @@ -35,7 +35,7 @@ class FeedbackCallbackServiceTest { @Mock InterviewSessionRepository sessionRepository; @Mock SessionFeedbackRepository feedbackRepository; @Mock ProcessedMessageRepository processedMessageRepository; - @Mock SseEventPublisher sseEventPublisher; + @Mock RealtimeNotifyPublisher realtimeNotifyPublisher; @InjectMocks FeedbackCallbackService service; @Test @@ -60,7 +60,7 @@ void apply_insertsFeedbackAndPushesSse() { verify(feedbackRepository).save(cap.capture()); assertThat(cap.getValue().getOverallScore()).isEqualTo(85.0); assertThat(cap.getValue().getImprovementKeywords()).contains("Spring"); - verify(sseEventPublisher).publishToSession(eq(50L), eq(SseEventType.FEEDBACK_READY), any()); + verify(realtimeNotifyPublisher).publishToSession(eq(50L), eq(SseEventType.FEEDBACK_READY), any()); } @Test diff --git a/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java index b3ddc779..66972d58 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java @@ -9,7 +9,7 @@ import com.stackup.stackup.common.messaging.domain.ProcessedMessage; import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; -import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.common.messaging.RealtimeNotifyPublisher; import com.stackup.stackup.common.sse.SseEventType; import com.stackup.stackup.session.application.dto.QuestionsCallbackEnvelope; import com.stackup.stackup.session.application.dto.QuestionsCallbackPayload; @@ -37,7 +37,7 @@ class QuestionsCallbackServiceTest { @Mock InterviewSessionRepository sessionRepository; @Mock InterviewMessageRepository messageRepository; @Mock ProcessedMessageRepository processedMessageRepository; - @Mock SseEventPublisher sseEventPublisher; + @Mock RealtimeNotifyPublisher realtimeNotifyPublisher; @Mock org.springframework.context.ApplicationEventPublisher events; @InjectMocks QuestionsCallbackService service; @@ -59,7 +59,7 @@ void apply_poolInsertsFirstQuestionAndPushesSse() { service.apply(env); verify(messageRepository).save(any(InterviewMessage.class)); - verify(sseEventPublisher).publishToSession(eq(11L), eq(SseEventType.SESSION_MESSAGE), any()); + verify(realtimeNotifyPublisher).publishToSession(eq(11L), eq(SseEventType.SESSION_MESSAGE), any()); verify(processedMessageRepository).save(any(ProcessedMessage.class)); assertThat(session.getTotalQuestionCount()).isEqualTo(1); } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java b/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java index 414e0b07..674cbfc9 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/SessionFeedbackRequesterTest.java @@ -84,6 +84,6 @@ private RabbitMqProperties.RoutingKeyProperties mockRoutingKeys() { "analyze.resume", "analyze.repository", "generate.questions", "generate.followup", "generate.feedback", "analyze.voice", "callback.analysis", "callback.questions", "callback.feedback", "callback.voice", - "realtime.session.notify"); + "realtime.session.notify", "realtime.user.notify", "realtime.document.notify"); } } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/VoiceCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/VoiceCallbackServiceTest.java index 825af1c5..fac8d0ed 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/VoiceCallbackServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/VoiceCallbackServiceTest.java @@ -8,7 +8,7 @@ import static org.mockito.Mockito.when; import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; -import com.stackup.stackup.common.sse.SseEventPublisher; +import com.stackup.stackup.common.messaging.RealtimeNotifyPublisher; import com.stackup.stackup.common.sse.SseEventType; import com.stackup.stackup.session.application.dto.VoiceCallbackEnvelope; import com.stackup.stackup.session.application.dto.VoiceCallbackPayload; @@ -38,7 +38,7 @@ class VoiceCallbackServiceTest { @Mock InterviewMessageRepository messageRepository; @Mock MessageVoiceAnalysisRepository voiceAnalysisRepository; @Mock ProcessedMessageRepository processedMessageRepository; - @Mock SseEventPublisher sseEventPublisher; + @Mock RealtimeNotifyPublisher realtimeNotifyPublisher; @Mock ApplicationEventPublisher events; @InjectMocks VoiceCallbackService service; @@ -66,7 +66,7 @@ void apply_setsTranscriptAndPublishesAnswerSubmittedEvent() { assertThat(voiceMsg.getStatus()).isEqualTo(MessageStatus.COMPLETED); verify(voiceAnalysisRepository).save(any(MessageVoiceAnalysis.class)); verify(events).publishEvent(any(AnswerSubmittedEvent.class)); - verify(sseEventPublisher).publishToSession(eq(50L), eq(SseEventType.SESSION_MESSAGE), any()); + verify(realtimeNotifyPublisher).publishToSession(eq(50L), eq(SseEventType.SESSION_MESSAGE), any()); } @Test diff --git a/docs/data-flow.md b/docs/data-flow.md index c964d0cc..be1efb68 100644 --- a/docs/data-flow.md +++ b/docs/data-flow.md @@ -65,7 +65,7 @@ ### 3.2 질문-답변 사이클 (반복) ``` -[Core/RealTime] SSE → 첫 질문 push +[Core] 영속 후 RealtimeNotifyPublisher.publishToSession → [RealTime] SSE/WS → 첫 질문 push → [Frontend] 표시 + 마이크 활성화 → [사용자] 음성 답변 (Phase 2) 또는 텍스트 → [Frontend] (음성 모드) WebRTC stream → RealTime @@ -130,8 +130,8 @@ ``` [AI 작업 시작/진행/완료] → AI Server: RabbitMQ publish (callback.analysis 등) - → [Core] consume → DB 상태 갱신 + 인메모리 채널로 push - → [Core] SSE 엔드포인트가 user별 구독자에게 broadcast + → [Core] consume → DB 상태 갱신 + RealtimeNotifyPublisher.publishToUser/publishToDocument 발행 + → [RealTime] stackup.realtime consume → user/document 채널 SSE fan-out → [Frontend] EventSource 수신 → 상태 UI 갱신 SSE 끊김 시: @@ -139,7 +139,7 @@ SSE 끊김 시: → [Frontend] 폴링 fallback: GET /api/documents/{id} (5초 간격) ``` -> 단일 Core 인스턴스에서는 인메모리 채널로 충분. 멀티 인스턴스 시점에 RabbitMQ fanout exchange 도입 (Redis 미사용 결정 — [`architecture.md §4.5`](./architecture.md)) +> SSE 서빙 주체는 **RealTime 서버**다. Core는 `stackup.realtime`으로 발행만 하고, DB 영속(AFTER_COMMIT/트랜잭션 내 INSERT 이후) 시점에 발행하므로 SSE 페이로드의 ID는 항상 조회 가능하다. 멀티 인스턴스 시점에 RealTime fanout exchange + 인스턴스별 큐 도입 (Redis 미사용 결정 — [`architecture.md §4.5`](./architecture.md)) 상세 이벤트 스펙은 [`event-stream.md`](./event-stream.md) 참조. diff --git a/docs/messaging.md b/docs/messaging.md index 2bd8cb63..09997bf7 100644 --- a/docs/messaging.md +++ b/docs/messaging.md @@ -26,7 +26,7 @@ | `ai.generate.followup` | `stackup.core-to-ai` | `generate.followup` | AI Server | | `core.callback.analysis` | `stackup.ai-to-core` | `callback.analysis` | Core Server | | `core.callback.questions` | `stackup.ai-to-core` | `callback.questions` | Core Server | -| `q.realtime.session.notify` | `stackup.realtime` | `realtime.session.*` | RealTime Server | +| `q.realtime.session.notify` | `stackup.realtime` | `realtime.session.*` · `realtime.user.*` · `realtime.document.*` | RealTime Server | ### Dead Letter Queues (durable) @@ -326,6 +326,18 @@ - `context.sessionId` 필수 — 라우팅 키 - `payload.eventType`은 SSE `event:` 필드로 매핑 +### 5.13 `realtime.user.notify` · `realtime.document.notify` + +`realtime.session.notify` 와 동일 구조. 채널(messageType)만 다르다. + +- `realtime.user.notify` — `context.userId` 필수. 분석 상태(`DOC_STATE`/`REPO_STATE`) 등 사용자 단위 알림. +- `realtime.document.notify` — `context.documentId` 필수. 문서 단위 분석 상태. +- 발행: Core `RealtimeNotifyPublisher.publishToUser(userId, ...)` / `publishToDocument(documentId, ...)`. +- `payload.eventType` 은 `SseEventType` enum 이름(`DOC_STATE` 등). RealTime이 SSE `event:` 필드로 전달. +- RealTime 측은 envelope `messageType`(`realtime.{kind}.notify`)으로 채널을 판별해 해당 채널 구독자에게 fan-out. + +> 단일 큐 `q.realtime.session.notify` 가 세 라우팅 키(`realtime.session.*`/`realtime.user.*`/`realtime.document.*`)를 모두 바인딩한다. + --- ## 6. 재시도·DLQ 정책 From 7294a9eab14f8a7f5884196704fec3fd06050acf Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Mon, 1 Jun 2026 20:52:56 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(realtime):=20WS=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=20=EB=A9=B4=EC=A0=91=20+=20=EB=A6=AC=EC=86=8C?= =?UTF-8?q?=EC=8A=A4=20=EC=8A=A4=EC=BD=94=ED=94=84=20stream=20=ED=86=A0?= =?UTF-8?q?=ED=81=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stackup/auth/application/AuthService.java | 2 +- .../common/security/StreamTokenProvider.java | 22 +++- .../session/application/SessionService.java | 8 ++ .../InternalSessionMessageController.java | 48 ++++++++ .../presentation/SessionController.java | 16 +++ .../dto/InternalAnswerSubmitRequest.java | 11 ++ .../presentation/dto/StreamTokenResponse.java | 4 + .../security/StreamTokenProviderTest.java | 46 ++++++++ .../InternalSessionMessageControllerTest.java | 50 ++++++++ docs/data-flow.md | 5 +- docs/event-stream.md | 14 ++- frontend/.env.example | 5 +- .../src/features/interview/api/streamToken.ts | 15 +++ .../interview/model/useInterviewSocket.ts | 51 ++++++++ frontend/src/shared/config/env.ts | 1 + frontend/src/shared/hooks/useEventStream.ts | 6 +- realtime/.env.example | 3 + realtime/CLAUDE.md | 14 ++- realtime/cmd/realtime/main.go | 5 +- realtime/go.mod | 2 + realtime/go.sum | 2 + realtime/internal/auth/middleware.go | 28 ++++- realtime/internal/auth/stream_token.go | 33 ++++-- realtime/internal/auth/stream_token_test.go | 18 +-- realtime/internal/config/config.go | 3 + realtime/internal/core/client.go | 62 ++++++++++ realtime/internal/core/client_test.go | 55 +++++++++ realtime/internal/transport/router.go | 39 ++++++- realtime/internal/transport/ws.go | 109 ++++++++++++++++++ realtime/internal/transport/ws_test.go | 90 +++++++++++++++ .../internal/transport/ws_testhelper_test.go | 30 +++++ 31 files changed, 747 insertions(+), 50 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/InternalSessionMessageController.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/dto/InternalAnswerSubmitRequest.java create mode 100644 backend/src/main/java/com/stackup/stackup/session/presentation/dto/StreamTokenResponse.java create mode 100644 backend/src/test/java/com/stackup/stackup/common/security/StreamTokenProviderTest.java create mode 100644 backend/src/test/java/com/stackup/stackup/session/presentation/InternalSessionMessageControllerTest.java create mode 100644 frontend/src/features/interview/api/streamToken.ts create mode 100644 frontend/src/features/interview/model/useInterviewSocket.ts create mode 100644 realtime/internal/core/client.go create mode 100644 realtime/internal/core/client_test.go create mode 100644 realtime/internal/transport/ws.go create mode 100644 realtime/internal/transport/ws_test.go create mode 100644 realtime/internal/transport/ws_testhelper_test.go diff --git a/backend/src/main/java/com/stackup/stackup/auth/application/AuthService.java b/backend/src/main/java/com/stackup/stackup/auth/application/AuthService.java index 002b5c0f..2028f905 100644 --- a/backend/src/main/java/com/stackup/stackup/auth/application/AuthService.java +++ b/backend/src/main/java/com/stackup/stackup/auth/application/AuthService.java @@ -94,7 +94,7 @@ public StreamTokenResult createStreamToken(Long userId) { if (userId == null) { throw new BadCredentialsException("Authentication is required to create stream token"); } - return new StreamTokenResult(streamTokenProvider.createStreamToken(userId)); + return new StreamTokenResult(streamTokenProvider.createStreamToken(userId, "USER", userId)); } } diff --git a/backend/src/main/java/com/stackup/stackup/common/security/StreamTokenProvider.java b/backend/src/main/java/com/stackup/stackup/common/security/StreamTokenProvider.java index c03f6aa8..d973ba6c 100644 --- a/backend/src/main/java/com/stackup/stackup/common/security/StreamTokenProvider.java +++ b/backend/src/main/java/com/stackup/stackup/common/security/StreamTokenProvider.java @@ -26,6 +26,11 @@ public class StreamTokenProvider { private static final String SSE_CONNECT_SCOPE = "SSE_CONNECT"; private static final String TOKEN_TYPE_CLAIM = "tokenType"; private static final String STREAM_TOKEN_TYPE = "STREAM"; + private static final String RESOURCE_TYPE_CLAIM = "resourceType"; + private static final String RESOURCE_ID_CLAIM = "resourceId"; + + public record StreamTokenClaims(Long userId, String resourceType, Long resourceId) { + } private final SseProperties sseProperties; private final byte[] secretKey; @@ -35,20 +40,33 @@ public StreamTokenProvider(SecurityProperties securityProperties, SseProperties this.secretKey = sha256(securityProperties.jwtSecret()); } - public String createStreamToken(Long userId) { + public String createStreamToken(Long userId, String resourceType, Long resourceId) { Instant now = Instant.now(); JWTClaimsSet claimsSet = new JWTClaimsSet.Builder() .subject(String.valueOf(userId)) .claim(USER_ID_CLAIM, userId) .claim(TOKEN_TYPE_CLAIM, STREAM_TOKEN_TYPE) .claim(SCOPE_CLAIM, SSE_CONNECT_SCOPE) + .claim(RESOURCE_TYPE_CLAIM, resourceType) + .claim(RESOURCE_ID_CLAIM, resourceId) .issueTime(Date.from(now)) .expirationTime(Date.from(now.plus(sseProperties.streamTokenTtl()))) .build(); - return sign(claimsSet); } + public StreamTokenClaims parseClaims(String token) { + JWTClaimsSet claims = parseAndValidate(token); + try { + Long userId = claims.getLongClaim(USER_ID_CLAIM); + String resourceType = claims.getStringClaim(RESOURCE_TYPE_CLAIM); + Long resourceId = claims.getLongClaim(RESOURCE_ID_CLAIM); + return new StreamTokenClaims(userId, resourceType, resourceId); + } catch (java.text.ParseException exception) { + throw invalidToken(); + } + } + public boolean validateStreamToken(String token) { parseAndValidate(token); return true; diff --git a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java index 7c9c2162..1cb3ad36 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/SessionService.java @@ -2,6 +2,7 @@ import com.stackup.stackup.common.exception.ApiErrorCode; import com.stackup.stackup.common.exception.DomainException; +import com.stackup.stackup.common.security.StreamTokenProvider; import com.stackup.stackup.document.domain.AnalysisStatus; import com.stackup.stackup.document.domain.AnalyzedDocument; import com.stackup.stackup.document.domain.AnalyzedDocumentRepository; @@ -35,6 +36,7 @@ public class SessionService { private final AnalyzedDocumentRepository documentRepository; private final UserRepository userRepository; private final ApplicationEventPublisher events; + private final StreamTokenProvider streamTokenProvider; @Transactional public SessionResult create(Long userId, SessionCreateCommand command) { @@ -126,6 +128,12 @@ public void delete(Long userId, Long sessionId) { sessionRepository.delete(session); } + public String createSessionStreamToken(Long userId, Long sessionId) { + sessionRepository.findByIdAndUser_IdAndDeletedFalse(sessionId, userId) + .orElseThrow(() -> new DomainException(ApiErrorCode.SESSION_NOT_FOUND)); + return streamTokenProvider.createStreamToken(userId, "SESSION", sessionId); + } + @Transactional public SessionResult updateMeta(Long userId, Long sessionId, String title, String memo) { InterviewSession session = loadOwned(userId, sessionId); diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/InternalSessionMessageController.java b/backend/src/main/java/com/stackup/stackup/session/presentation/InternalSessionMessageController.java new file mode 100644 index 00000000..e3b4188f --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/InternalSessionMessageController.java @@ -0,0 +1,48 @@ +package com.stackup.stackup.session.presentation; + +import com.stackup.stackup.session.application.InterviewMessageService; +import com.stackup.stackup.session.presentation.dto.InternalAnswerSubmitRequest; +import com.stackup.stackup.session.presentation.dto.MessageResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +// RealTime 서버가 WS 로 받은 답변을 위임하는 내부 엔드포인트. X-Internal-API-Key 필요. +// userId 는 RealTime 이 Stream 토큰에서 검증한 값을 body 로 전달. 기존 submitAnswer 가 +// 세션 소유권(userId+sessionId)을 다시 검증하므로 위변조 방어가 이중으로 유지된다. +@Tag(name = "Internal: Session Messages", description = "X-Internal-API-Key 필요. RealTime WS 답변 위임.") +@RestController +@RequestMapping("/api/internal/sessions/{sessionId}/messages") +@RequiredArgsConstructor +public class InternalSessionMessageController { + + private final InterviewMessageService messageService; + + @Operation(operationId = "internalSubmitAnswer", summary = "RealTime WS 답변 위임 → 답변 INSERT + 꼬리질문 발행") + @ApiResponses({ + @ApiResponse(responseCode = "201", description = "답변 저장"), + @ApiResponse(responseCode = "401", description = "X-Internal-API-Key 인증 실패"), + @ApiResponse(responseCode = "404", description = "세션 없음 / 소유자 불일치"), + @ApiResponse(responseCode = "422", description = "세션 IN_PROGRESS 아님 또는 직전이 질문 아님") + }) + @PostMapping + @ResponseStatus(HttpStatus.CREATED) + public MessageResponse submit( + @PathVariable Long sessionId, + @Valid @RequestBody InternalAnswerSubmitRequest request + ) { + return MessageResponse.from( + messageService.submitAnswer(request.userId(), sessionId, request.content(), request.idempotencyKey()) + ); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java b/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java index 859500f6..8bbf3ac3 100644 --- a/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/SessionController.java @@ -5,6 +5,7 @@ import com.stackup.stackup.session.presentation.dto.SessionCreateRequest; import com.stackup.stackup.session.presentation.dto.SessionResponse; import com.stackup.stackup.session.presentation.dto.SessionUpdateRequest; +import com.stackup.stackup.session.presentation.dto.StreamTokenResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; @@ -156,6 +157,21 @@ public SessionResponse cancel( return SessionResponse.from(sessionService.cancel(principal.userId(), sessionId)); } + @Operation(operationId = "createSessionStreamToken", summary = "세션 채널 SSE/WS 용 리소스 스코프 stream token") + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "토큰 발급"), + @ApiResponse(responseCode = "401", description = "인증 실패"), + @ApiResponse(responseCode = "404", description = "세션 없음 / 소유자 아님") + }) + @PostMapping("/{sessionId}/stream-token") + public StreamTokenResponse createSessionStreamToken( + @AuthenticationPrincipal UserPrincipal principal, + @PathVariable Long sessionId + ) { + return new StreamTokenResponse( + sessionService.createSessionStreamToken(principal.userId(), sessionId)); + } + @Operation(operationId = "deleteSession", summary = "세션 soft delete") @ApiResponses({ @ApiResponse(responseCode = "204", description = "삭제됨"), diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/InternalAnswerSubmitRequest.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/InternalAnswerSubmitRequest.java new file mode 100644 index 00000000..966b6d6f --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/InternalAnswerSubmitRequest.java @@ -0,0 +1,11 @@ +package com.stackup.stackup.session.presentation.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +public record InternalAnswerSubmitRequest( + @NotNull Long userId, + @NotBlank String content, + String idempotencyKey +) { +} diff --git a/backend/src/main/java/com/stackup/stackup/session/presentation/dto/StreamTokenResponse.java b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/StreamTokenResponse.java new file mode 100644 index 00000000..e05f3a0e --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/session/presentation/dto/StreamTokenResponse.java @@ -0,0 +1,4 @@ +package com.stackup.stackup.session.presentation.dto; + +public record StreamTokenResponse(String streamToken) { +} diff --git a/backend/src/test/java/com/stackup/stackup/common/security/StreamTokenProviderTest.java b/backend/src/test/java/com/stackup/stackup/common/security/StreamTokenProviderTest.java new file mode 100644 index 00000000..4271c289 --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/common/security/StreamTokenProviderTest.java @@ -0,0 +1,46 @@ +package com.stackup.stackup.common.security; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import com.stackup.stackup.common.config.properties.SecurityProperties; +import com.stackup.stackup.common.config.properties.SseProperties; +import java.time.Duration; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +class StreamTokenProviderTest { + + private StreamTokenProvider provider() { + SecurityProperties sec = Mockito.mock(SecurityProperties.class); + Mockito.when(sec.jwtSecret()).thenReturn("test-jwt-secret-test-jwt-secret-32b"); + SseProperties sse = Mockito.mock(SseProperties.class); + Mockito.when(sse.streamTokenTtl()).thenReturn(Duration.ofSeconds(60)); + return new StreamTokenProvider(sec, sse); + } + + @Test + void sessionScopedToken_carriesResourceClaims() { + StreamTokenProvider p = provider(); + String token = p.createStreamToken(7L, "SESSION", 99L); + StreamTokenProvider.StreamTokenClaims claims = p.parseClaims(token); + assertThat(claims.userId()).isEqualTo(7L); + assertThat(claims.resourceType()).isEqualTo("SESSION"); + assertThat(claims.resourceId()).isEqualTo(99L); + } + + @Test + void userScopedToken_carriesUserResource() { + StreamTokenProvider p = provider(); + String token = p.createStreamToken(7L, "USER", 7L); + StreamTokenProvider.StreamTokenClaims claims = p.parseClaims(token); + assertThat(claims.resourceType()).isEqualTo("USER"); + assertThat(claims.resourceId()).isEqualTo(7L); + } + + @Test + void tamperedToken_rejected() { + StreamTokenProvider p = provider(); + assertThatThrownBy(() -> p.parseClaims("not.a.token")).isInstanceOf(RuntimeException.class); + } +} diff --git a/backend/src/test/java/com/stackup/stackup/session/presentation/InternalSessionMessageControllerTest.java b/backend/src/test/java/com/stackup/stackup/session/presentation/InternalSessionMessageControllerTest.java new file mode 100644 index 00000000..8579789b --- /dev/null +++ b/backend/src/test/java/com/stackup/stackup/session/presentation/InternalSessionMessageControllerTest.java @@ -0,0 +1,50 @@ +package com.stackup.stackup.session.presentation; + +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.stackup.stackup.session.application.InterviewMessageService; +import com.stackup.stackup.session.application.dto.MessageResult; +import com.stackup.stackup.session.domain.InterviewMessage; +import com.stackup.stackup.session.domain.InterviewSession; +import com.stackup.stackup.session.domain.JobCategory; +import com.stackup.stackup.session.domain.SessionMode; +import com.stackup.stackup.session.presentation.dto.InternalAnswerSubmitRequest; +import com.stackup.stackup.user.domain.User; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class InternalSessionMessageControllerTest { + + @Mock InterviewMessageService messageService; + @InjectMocks InternalSessionMessageController controller; + + @Test + void submit_delegatesToServiceWithUserIdFromBody() { + when(messageService.submitAnswer(7L, 99L, "내 답변", "idem-1")) + .thenReturn(MessageResult.of(answerFixture())); + + controller.submit(99L, new InternalAnswerSubmitRequest(7L, "내 답변", "idem-1")); + + verify(messageService).submitAnswer(eq(7L), eq(99L), eq("내 답변"), eq("idem-1")); + } + + private InterviewMessage answerFixture() { + User user = User.createGithubUser(1L, "u", null, null, "t"); + ReflectionTestUtils.setField(user, "id", 7L); + InterviewSession s = InterviewSession.create(user, "t", null, SessionMode.TECHNICAL, + JobCategory.BACKEND, 5, 30); + ReflectionTestUtils.setField(s, "id", 99L); + InterviewMessage q = InterviewMessage.interviewer(s, 1, "Q?"); + ReflectionTestUtils.setField(q, "id", 500L); + InterviewMessage a = InterviewMessage.interviewee(s, 2, "내 답변", q, "idem-1"); + ReflectionTestUtils.setField(a, "id", 501L); + return a; + } +} diff --git a/docs/data-flow.md b/docs/data-flow.md index be1efb68..9a606293 100644 --- a/docs/data-flow.md +++ b/docs/data-flow.md @@ -71,7 +71,8 @@ → [Frontend] (음성 모드) WebRTC stream → RealTime ㄴ Phase 2 RealTime 분리 전: 청크를 REST POST로 업로드 → [AI] Whisper API → 텍스트 변환 - → [Frontend → Core] POST /api/sessions/{id}/messages (텍스트 답변) + → [Frontend] (텍스트 모드) WS send {type:"answer", content} → [RealTime] → POST /api/internal/sessions/{id}/messages + ㄴ 레거시 직접 경로: POST /api/sessions/{id}/messages (프론트 WS 전환 전까지 병행) → [Core] interview_messages INSERT (role=INTERVIEWEE, parent=직전 질문) → [Core] RabbitMQ publish: stackup.core-to-ai / generate.followup → [AI] 답변 평가 + 꼬리질문 생성 (Gemini 3.1 Flash + RAG) @@ -79,7 +80,7 @@ → [AI] RabbitMQ publish: stackup.ai-to-core / callback.questions (kind=FOLLOWUP) → [Core] interview_messages INSERT (role=INTERVIEWER, 꼬리질문) → message_voice_analyses INSERT (음성 모드일 경우) - → [Core] SSE → 다음 질문 push + → [Core] 영속 후 RealtimeNotifyPublisher.publishToSession → [RealTime] WS/SSE → 다음 질문 push ``` ### 3.3 세션 종료 diff --git a/docs/event-stream.md b/docs/event-stream.md index c6d317fc..d87ad8ab 100644 --- a/docs/event-stream.md +++ b/docs/event-stream.md @@ -9,14 +9,20 @@ ## 1. 엔드포인트 ``` -GET /realtime/stream/me # user 채널 (userId는 토큰에서 추출) -GET /realtime/stream/sessions/{sessionId} +GET /realtime/stream/me # user 채널 SSE (userId는 토큰에서 추출) +GET /realtime/stream/sessions/{sessionId} # session 채널 SSE (feedback.ready 등 비-라이브) GET /realtime/stream/documents/{documentId} +WS /realtime/sessions/{sessionId} # RT1 라이브 텍스트 면접 (양방향) ``` - 제공 주체: **RealTime Server (Go)**. Core는 직접 SSE를 서빙하지 않고 `stackup.realtime` exchange로 발행만 한다 (RealTime이 consume → fan-out). -- 인증: 쿼리 토큰 `?access_token=` (EventSource 헤더 한계 우회). 토큰은 Core `POST /api/auth/stream-token` 발급, RealTime `internal/auth`가 HS256(키=`SHA-256(JWT_SECRET)`)로 검증. -- 권한: 현재는 토큰 진위(userId)만 검증. 리소스 소유권(이 user가 이 session/document의 주인인가)은 후속 플랜에서 리소스 스코프 토큰 또는 Core 조회로 강화. + +### WS 라이브 면접 (RT1) +- 경로 `WS /realtime/sessions/{id}` (SSE `/realtime/stream/*` 와 다른 path → Upgrade 분기 없음). 인증 동일 `?access_token=`. +- 서버→클라 프레임(JSON): `{ "id": , "event": , "data": }` (session 채널 fan-out을 그대로 전달 — 질문/꼬리질문/세션상태). +- 클라→서버: `{ "type": "answer", "content": "...", "idempotencyKey"?: "..." }`. RealTime이 Core 내부 REST(`POST /api/internal/sessions/{id}/messages`)로 프록시 → 답변 INSERT + `generate.followup` 발행. +- 인증: 쿼리 토큰 `?access_token=` (EventSource/WS 헤더 한계 우회). RealTime `internal/auth`가 HS256(키=`SHA-256(JWT_SECRET)`)로 검증. +- 권한 (리소스 스코프): 토큰은 `resourceType`(`USER`/`SESSION`)·`resourceId` claim을 담는다. 소유권은 **발급 시점**에 Core가 검증하고(USER=`POST /api/auth/stream-token` 본인, SESSION=`POST /api/sessions/{id}/stream-token` 소유권 체크 후 발급), RealTime은 path 리소스와 토큰 리소스의 일치만 확인한다(불일치 → 403). 이로써 RealTime은 PG 무접근으로 소유권을 판정한다. `documents/{id}` 채널 스코프는 MVP deferred(인증 토큰만). - 연결 유지: 약 30초마다 `: ping ` heartbeat 코멘트 송신 (`REALTIME_SSE_PING_INTERVAL`). --- diff --git a/frontend/.env.example b/frontend/.env.example index 2fcba23a..0c0306ac 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -4,5 +4,8 @@ # 임시라 상우가 바꾸면 바뀔 수 있습니다. VITE_API_BASE_URL=https://www.udangtang.site -# SSE base URL (현재는 API_BASE_URL 과 동일) +# SSE base URL (레거시 — RealTime 전환 후 미사용 예정) VITE_SSE_BASE_URL=https://www.udangtang.site + +# RealTime 서버 base URL (SSE /realtime/stream/* + WS /realtime/sessions/{id}) +VITE_REALTIME_BASE_URL=http://localhost:38020 diff --git a/frontend/src/features/interview/api/streamToken.ts b/frontend/src/features/interview/api/streamToken.ts new file mode 100644 index 00000000..e5d59375 --- /dev/null +++ b/frontend/src/features/interview/api/streamToken.ts @@ -0,0 +1,15 @@ +import { apiClient } from '@/shared/api' + +// USER 채널(/realtime/stream/me): 분석 상태·피드백 ready 수신용. +export async function fetchUserStreamToken(): Promise { + const { data } = await apiClient.post<{ streamToken: string }>('/api/auth/stream-token') + return data.streamToken +} + +// SESSION 채널(SSE /realtime/stream/sessions/{id}, WS /realtime/sessions/{id}). +export async function fetchSessionStreamToken(sessionId: number): Promise { + const { data } = await apiClient.post<{ streamToken: string }>( + `/api/sessions/${sessionId}/stream-token`, + ) + return data.streamToken +} diff --git a/frontend/src/features/interview/model/useInterviewSocket.ts b/frontend/src/features/interview/model/useInterviewSocket.ts new file mode 100644 index 00000000..9127fdb2 --- /dev/null +++ b/frontend/src/features/interview/model/useInterviewSocket.ts @@ -0,0 +1,51 @@ +import { useEffect, useRef } from 'react' +import { env } from '@/shared/config/env' +import { fetchSessionStreamToken } from '@/features/interview/api/streamToken' + +type InterviewEvent = { id: string; event: string; data: unknown } +type Options = { + sessionId: number | null + onEvent: (event: InterviewEvent) => void + enabled?: boolean +} + +// 라이브 텍스트 면접 WebSocket. 서버→클라 질문/꼬리질문 수신 + 클라→서버 답변 송신. +export function useInterviewSocket({ sessionId, onEvent, enabled = true }: Options) { + const socketRef = useRef(null) + const onEventRef = useRef(onEvent) + onEventRef.current = onEvent + + useEffect(() => { + if (!enabled || sessionId == null) return + let cancelled = false + let ws: WebSocket | null = null + + const connect = async () => { + const token = await fetchSessionStreamToken(sessionId) + if (cancelled) return + const base = env.REALTIME_BASE_URL.replace(/^http/, 'ws') + ws = new WebSocket(`${base}/realtime/sessions/${sessionId}?access_token=${encodeURIComponent(token)}`) + socketRef.current = ws + ws.onmessage = (e) => { + try { + onEventRef.current(JSON.parse(e.data) as InterviewEvent) + } catch { + // ignore non-JSON + } + } + } + void connect() + + return () => { + cancelled = true + ws?.close() + socketRef.current = null + } + }, [sessionId, enabled]) + + const submitAnswer = (content: string, idempotencyKey?: string) => { + socketRef.current?.send(JSON.stringify({ type: 'answer', content, idempotencyKey })) + } + + return { submitAnswer } +} diff --git a/frontend/src/shared/config/env.ts b/frontend/src/shared/config/env.ts index 81bc8b79..0f89f5da 100644 --- a/frontend/src/shared/config/env.ts +++ b/frontend/src/shared/config/env.ts @@ -1,4 +1,5 @@ export const env = { API_BASE_URL: import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080', SSE_BASE_URL: import.meta.env.VITE_SSE_BASE_URL ?? 'http://localhost:8080', + REALTIME_BASE_URL: import.meta.env.VITE_REALTIME_BASE_URL ?? 'http://localhost:38020', } as const diff --git a/frontend/src/shared/hooks/useEventStream.ts b/frontend/src/shared/hooks/useEventStream.ts index e60c574c..db2217e0 100644 --- a/frontend/src/shared/hooks/useEventStream.ts +++ b/frontend/src/shared/hooks/useEventStream.ts @@ -17,7 +17,7 @@ type UseEventStreamOptions = { const BASE_BACKOFF_MS = 1_000 const MAX_BACKOFF_MS = 30_000 -// EventSource 는 커스텀 헤더를 못 싣는다 → 인증은 ?token= 쿼리(stream token)로 전달. +// EventSource 는 커스텀 헤더를 못 싣는다 → 인증은 ?access_token= 쿼리(stream token)로 전달. 호스트는 RealTime 서버(REALTIME_BASE_URL). export function useEventStream({ path, getToken, @@ -60,8 +60,8 @@ export function useEventStream({ } if (cancelled) return - const query = token ? `?token=${encodeURIComponent(token)}` : '' - const es = new EventSource(`${env.SSE_BASE_URL}${path}${query}`, { + const query = token ? `?access_token=${encodeURIComponent(token)}` : '' + const es = new EventSource(`${env.REALTIME_BASE_URL}${path}${query}`, { withCredentials: true, }) source = es diff --git a/realtime/.env.example b/realtime/.env.example index b2279cc0..64ca4f74 100644 --- a/realtime/.env.example +++ b/realtime/.env.example @@ -6,3 +6,6 @@ REALTIME_SSE_PING_INTERVAL=30s REALTIME_SSE_SLOW_CONSUMER_TIMEOUT=5s REALTIME_SSE_BUFFER_SIZE=16 REALTIME_JWT_SECRET=change-me-in-prod +REALTIME_CORE_BASE_URL=http://localhost:38080 +REALTIME_INTERNAL_API_KEY=change-me-internal-key +REALTIME_WS_WRITE_TIMEOUT=10s diff --git a/realtime/CLAUDE.md b/realtime/CLAUDE.md index c34843a8..416057b9 100644 --- a/realtime/CLAUDE.md +++ b/realtime/CLAUDE.md @@ -77,9 +77,10 @@ config는 cmd, internal/* 모두에서 import 가능 ## 5. 비책임 (명시적) - ❌ PostgreSQL 직접 접근 — 데이터가 필요하면 Core `/internal/*` API -- ❌ JWT 발급 — Core 책임. RealTime은 (도입 시) 서명 검증만 +- ❌ JWT 발급 — Core 책임. RealTime은 Stream 토큰 서명 검증만 - ❌ 비즈니스 로직 (질문 생성, 분석) — AI 또는 Core - ❌ RabbitMQ에 publish — Core를 통해서만 (`architecture.md §4.1`) +- ❌ PostgreSQL 접근 — WS 답변도 Core 내부 REST(`POST /api/internal/sessions/{id}/messages`)로 프록시. RealTime은 PG·MQ publish 모두 미접근 --- @@ -91,11 +92,11 @@ config는 cmd, internal/* 모두에서 import 가능 | RT2 | GET | `/realtime/stream/me` | user 채널 SSE (분석 상태). userId는 토큰에서 | 활성 | | RT2 | GET | `/realtime/stream/documents/{id}` | document 채널 SSE | 활성 | | RT2 | GET | `/realtime/stream/sessions/{id}` | session 채널 SSE (feedback.ready 등 비-라이브) | 활성 | -| RT1 | WS | `/realtime/sessions/{id}` | 라이브 면접 메시지 | **미구현** (후속 플랜) | +| RT1 | WS | `/realtime/sessions/{id}` | 라이브 텍스트 면접 (서버→클라 push + 클라→서버 답변) | 활성 | | RT3 | WS | `/realtime/sessions/{id}/audio` | 음성 스트림 | **미구현** (US-Voice-01) | -> 모든 `/realtime/stream/*` 는 인증 필요 — `?access_token=` 쿼리로 Core 발급 토큰 검증 (EventSource 헤더 한계 우회). `internal/auth` 미들웨어가 처리. -> RT1 WS는 동일 prefix에서 Upgrade 헤더로 분기 예정 (후속 플랜). +> 모든 `/realtime/stream/*` (SSE) 및 `/realtime/sessions/{id}` (WS) 는 인증 필요 — `?access_token=` 쿼리로 Core 발급 토큰 검증 (EventSource/WS 헤더 한계 우회). `internal/auth` 미들웨어가 처리. +> WS는 SSE(`/realtime/stream/*`)와 **다른 path**라 Upgrade 헤더 분기가 필요 없다. WS 핸들러(`coder/websocket`)는 session 채널 fan-out을 그대로 구독(서버→클라)하고, 수신한 답변(`{type:"answer",content,idempotencyKey?}`)을 `internal/core` 클라이언트로 Core 내부 REST(`POST /api/internal/sessions/{id}/messages`)에 프록시한다. --- @@ -151,6 +152,9 @@ Heartbeat (proxy keepalive): | `REALTIME_SSE_SLOW_CONSUMER_TIMEOUT` | `5s` | 구독자 send timeout | | `REALTIME_SSE_BUFFER_SIZE` | `16` | 구독자별 채널 버퍼 | | `REALTIME_JWT_SECRET` | `change-me-in-prod` | Stream 토큰 검증 키 소스 (Core `JWT_SECRET`과 동일값. 키 = `SHA-256(secret)`, HS256) | +| `REALTIME_CORE_BASE_URL` | `http://localhost:38080` | Core 내부 REST base URL (WS 답변 프록시) | +| `REALTIME_INTERNAL_API_KEY` | `change-me-internal-key` | Core 내부 API 호출용 `X-Internal-API-Key` | +| `REALTIME_WS_WRITE_TIMEOUT` | `10s` | WS write 타임아웃 | --- @@ -213,7 +217,7 @@ docker build -t stackup-realtime ./realtime - 멀티채널 SSE 활성 — `/realtime/stream/{me,documents/{id},sessions/{id}}` (session/user/document) - Stream 토큰 인증 활성 — `?access_token=` 검증 (`internal/auth`, Core와 동일 HS256 규약) - AMQP `q.realtime.session.notify` consumer 활성 — `messageType` 기반 채널 라우팅 → fan-out -- WebSocket(RT1 라이브 면접) 미구현 — 후속 플랜 +- WebSocket(RT1 라이브 면접) 활성 — `/realtime/sessions/{id}` 서버→클라 push + 클라→서버 답변 프록시(Core 내부 REST) - 리소스 소유권 검증 미구현 — 현재 토큰 진위(userId)만 검증. 후속 플랜에서 리소스 스코프 토큰 또는 Core 조회로 강화 - DLQ 활성 — handler 실패 메시지는 `dlq.q.realtime.session.notify` 로 격리 - Prometheus 노출 미구현 diff --git a/realtime/cmd/realtime/main.go b/realtime/cmd/realtime/main.go index 35740be0..45a252d8 100644 --- a/realtime/cmd/realtime/main.go +++ b/realtime/cmd/realtime/main.go @@ -15,6 +15,7 @@ import ( "github.com/Team-StackUp/stackup/realtime/internal/auth" "github.com/Team-StackUp/stackup/realtime/internal/bridge" "github.com/Team-StackUp/stackup/realtime/internal/config" + "github.com/Team-StackUp/stackup/realtime/internal/core" "github.com/Team-StackUp/stackup/realtime/internal/messaging" "github.com/Team-StackUp/stackup/realtime/internal/session" "github.com/Team-StackUp/stackup/realtime/internal/transport" @@ -38,7 +39,9 @@ func main() { sseHandler := transport.NewSSEHandler(registry, cfg.SSEBufferSize, cfg.SSEPingInterval) verifier := auth.NewStreamTokenVerifier(cfg.JWTSecret) - router := transport.NewRouter(sseHandler, verifier) + coreClient := core.NewClient(cfg.CoreBaseURL, cfg.InternalApiKey, cfg.WSWriteTimeout) + wsHandler := transport.NewWSHandler(registry, coreClient, cfg.WSWriteTimeout) + router := transport.NewRouter(sseHandler, wsHandler, verifier) srv := &http.Server{ Addr: cfg.ListenAddr, diff --git a/realtime/go.mod b/realtime/go.mod index 6376e44f..2e11f541 100644 --- a/realtime/go.mod +++ b/realtime/go.mod @@ -8,3 +8,5 @@ require ( github.com/golang-jwt/jwt/v5 v5.3.1 github.com/rabbitmq/amqp091-go v1.11.0 ) + +require github.com/coder/websocket v1.8.14 diff --git a/realtime/go.sum b/realtime/go.sum index cf031185..c636957c 100644 --- a/realtime/go.sum +++ b/realtime/go.sum @@ -1,5 +1,7 @@ github.com/caarlos0/env/v11 v11.4.1 h1:fYwH0sWEsBSMPG7t4e/PEfTFzrWrpjyygXyUnWiSwEw= github.com/caarlos0/env/v11 v11.4.1/go.mod h1:qupehSf/Y0TUTsxKywqRt/vJjN5nz6vauiYEUUr8P4U= +github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g= +github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg= github.com/go-chi/chi/v5 v5.2.5 h1:Eg4myHZBjyvJmAFjFvWgrqDTXFyOzjj7YIm3L3mu6Ug= github.com/go-chi/chi/v5 v5.2.5/go.mod h1:X7Gx4mteadT3eDOMTsXzmI4/rwUpOwBHLpAfupzFJP0= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= diff --git a/realtime/internal/auth/middleware.go b/realtime/internal/auth/middleware.go index 0b2174ac..ef29adae 100644 --- a/realtime/internal/auth/middleware.go +++ b/realtime/internal/auth/middleware.go @@ -5,10 +5,10 @@ import ( "net/http" ) -type ctxKey struct{} +type claimsKey struct{} // Middleware extracts the stream token from the access_token query parameter, -// verifies it, and injects the userId into the request context. +// verifies it, and injects the Claims into the request context. func Middleware(v *StreamTokenVerifier) func(http.Handler) http.Handler { return func(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -17,21 +17,37 @@ func Middleware(v *StreamTokenVerifier) func(http.Handler) http.Handler { http.Error(w, "missing access_token", http.StatusUnauthorized) return } - userID, err := v.Verify(token) + claims, err := v.Verify(token) if err != nil { http.Error(w, "invalid access_token", http.StatusUnauthorized) return } - ctx := context.WithValue(r.Context(), ctxKey{}, userID) + ctx := WithClaims(r.Context(), claims) next.ServeHTTP(w, r.WithContext(ctx)) }) } } +// WithClaims injects Claims into ctx. +func WithClaims(ctx context.Context, c Claims) context.Context { + return context.WithValue(ctx, claimsKey{}, c) +} + +// ClaimsFromContext returns the Claims stored in ctx, if any. +func ClaimsFromContext(ctx context.Context) (Claims, bool) { + c, ok := ctx.Value(claimsKey{}).(Claims) + return c, ok +} + +// WithUserID is kept for tests/wiring that only need the userId. +func WithUserID(ctx context.Context, userID int64) context.Context { + return WithClaims(ctx, Claims{UserID: userID, ResourceType: "USER", ResourceID: userID}) +} + // UserIDFromContext returns the authenticated userId, or 0 if absent. func UserIDFromContext(ctx context.Context) int64 { - if v, ok := ctx.Value(ctxKey{}).(int64); ok { - return v + if c, ok := ClaimsFromContext(ctx); ok { + return c.UserID } return 0 } diff --git a/realtime/internal/auth/stream_token.go b/realtime/internal/auth/stream_token.go index 600f42bb..57aa5860 100644 --- a/realtime/internal/auth/stream_token.go +++ b/realtime/internal/auth/stream_token.go @@ -15,6 +15,13 @@ const ( var ErrInvalidStreamToken = errors.New("invalid stream token") +// Claims holds the verified identity and resource scope from a stream token. +type Claims struct { + UserID int64 + ResourceType string + ResourceID int64 +} + // StreamTokenVerifier validates Core-issued SSE stream tokens. // It mirrors Core StreamTokenProvider: HS256 over key = SHA-256(jwtSecret). type StreamTokenVerifier struct { @@ -26,8 +33,8 @@ func NewStreamTokenVerifier(jwtSecret string) *StreamTokenVerifier { return &StreamTokenVerifier{key: sum[:]} } -// Verify checks signature, expiry, tokenType, scope and returns the userId. -func (v *StreamTokenVerifier) Verify(token string) (int64, error) { +// Verify checks signature, expiry, tokenType, scope and returns the Claims. +func (v *StreamTokenVerifier) Verify(token string) (Claims, error) { claims := jwt.MapClaims{} parsed, err := jwt.ParseWithClaims(token, claims, func(t *jwt.Token) (any, error) { if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { @@ -36,22 +43,24 @@ func (v *StreamTokenVerifier) Verify(token string) (int64, error) { return v.key, nil }) if err != nil || !parsed.Valid { - return 0, ErrInvalidStreamToken + return Claims{}, ErrInvalidStreamToken } if s, _ := claims["tokenType"].(string); s != streamTokenType { - return 0, ErrInvalidStreamToken + return Claims{}, ErrInvalidStreamToken } if s, _ := claims["scope"].(string); s != sseConnectScope { - return 0, ErrInvalidStreamToken + return Claims{}, ErrInvalidStreamToken } - raw, ok := claims["userId"] + uid, ok := claims["userId"].(float64) if !ok { - return 0, ErrInvalidStreamToken + return Claims{}, ErrInvalidStreamToken } - // JSON numbers decode to float64 in MapClaims. - f, ok := raw.(float64) - if !ok { - return 0, ErrInvalidStreamToken + out := Claims{UserID: int64(uid)} + if rt, ok := claims["resourceType"].(string); ok { + out.ResourceType = rt + } + if rid, ok := claims["resourceId"].(float64); ok { + out.ResourceID = int64(rid) } - return int64(f), nil + return out, nil } diff --git a/realtime/internal/auth/stream_token_test.go b/realtime/internal/auth/stream_token_test.go index 2cfacd04..b9acd000 100644 --- a/realtime/internal/auth/stream_token_test.go +++ b/realtime/internal/auth/stream_token_test.go @@ -24,22 +24,24 @@ func mintToken(t *testing.T, claims jwt.MapClaims) string { func validClaims() jwt.MapClaims { return jwt.MapClaims{ - "sub": "42", - "userId": 42, - "tokenType": "STREAM", - "scope": "SSE_CONNECT", - "exp": time.Now().Add(time.Minute).Unix(), + "sub": "42", + "userId": 42, + "tokenType": "STREAM", + "scope": "SSE_CONNECT", + "resourceType": "USER", + "resourceId": 42, + "exp": time.Now().Add(time.Minute).Unix(), } } func TestVerifyValidToken(t *testing.T) { v := NewStreamTokenVerifier(testSecret) - userID, err := v.Verify(mintToken(t, validClaims())) + claims, err := v.Verify(mintToken(t, validClaims())) if err != nil { t.Fatalf("Verify err: %v", err) } - if userID != 42 { - t.Errorf("userID = %d, want 42", userID) + if claims.UserID != 42 || claims.ResourceType != "USER" || claims.ResourceID != 42 { + t.Errorf("claims = %+v", claims) } } diff --git a/realtime/internal/config/config.go b/realtime/internal/config/config.go index 3bac295e..ef32a9b8 100644 --- a/realtime/internal/config/config.go +++ b/realtime/internal/config/config.go @@ -12,6 +12,9 @@ type Config struct { LogLevel string `env:"REALTIME_LOG_LEVEL" envDefault:"info"` QueueName string `env:"REALTIME_QUEUE_NAME" envDefault:"q.realtime.session.notify"` JWTSecret string `env:"REALTIME_JWT_SECRET" envDefault:"change-me-in-prod"` + CoreBaseURL string `env:"REALTIME_CORE_BASE_URL" envDefault:"http://localhost:38080"` + InternalApiKey string `env:"REALTIME_INTERNAL_API_KEY" envDefault:"change-me-internal-key"` + WSWriteTimeout time.Duration `env:"REALTIME_WS_WRITE_TIMEOUT" envDefault:"10s"` SSEPingInterval time.Duration `env:"REALTIME_SSE_PING_INTERVAL" envDefault:"30s"` SSESlowConsumerTimeout time.Duration `env:"REALTIME_SSE_SLOW_CONSUMER_TIMEOUT" envDefault:"5s"` SSEBufferSize int `env:"REALTIME_SSE_BUFFER_SIZE" envDefault:"16"` diff --git a/realtime/internal/core/client.go b/realtime/internal/core/client.go new file mode 100644 index 00000000..a8fd0dcf --- /dev/null +++ b/realtime/internal/core/client.go @@ -0,0 +1,62 @@ +package core + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "time" + + "github.com/Team-StackUp/stackup/realtime/internal/trace" +) + +// AnswerSubmitter is the subset of Core needed by the WS handler — facilitates testing. +type AnswerSubmitter interface { + SubmitAnswer(ctx context.Context, sessionID, userID int64, content, idempotencyKey string) error +} + +// Client calls Core internal REST endpoints. RealTime never touches PG or publishes to MQ; +// this is the only reverse path (WS answer -> Core). +type Client struct { + baseURL string + apiKey string + httpClient *http.Client +} + +func NewClient(baseURL, apiKey string, timeout time.Duration) *Client { + return &Client{baseURL: baseURL, apiKey: apiKey, httpClient: &http.Client{Timeout: timeout}} +} + +type answerBody struct { + UserID int64 `json:"userId"` + Content string `json:"content"` + IdempotencyKey string `json:"idempotencyKey,omitempty"` +} + +func (c *Client) SubmitAnswer(ctx context.Context, sessionID, userID int64, content, idempotencyKey string) error { + body, err := json.Marshal(answerBody{UserID: userID, Content: content, IdempotencyKey: idempotencyKey}) + if err != nil { + return err + } + url := fmt.Sprintf("%s/api/internal/sessions/%d/messages", c.baseURL, sessionID) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(body)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Internal-API-Key", c.apiKey) + if tid := trace.FromContext(ctx); tid != "" { + req.Header.Set(trace.HeaderName, tid) + } + + resp, err := c.httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("core submit answer: status %d", resp.StatusCode) + } + return nil +} diff --git a/realtime/internal/core/client_test.go b/realtime/internal/core/client_test.go new file mode 100644 index 00000000..03fdc32e --- /dev/null +++ b/realtime/internal/core/client_test.go @@ -0,0 +1,55 @@ +package core + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestSubmitAnswerPostsToCore(t *testing.T) { + var gotPath, gotKey, gotBody string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotPath = r.URL.Path + gotKey = r.Header.Get("X-Internal-API-Key") + b, _ := io.ReadAll(r.Body) + gotBody = string(b) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"id":501}`)) + })) + defer srv.Close() + + c := NewClient(srv.URL, "secret-key", 2*time.Second) + err := c.SubmitAnswer(context.Background(), 99, 7, "내 답변", "idem-1") + if err != nil { + t.Fatalf("SubmitAnswer err: %v", err) + } + if gotPath != "/api/internal/sessions/99/messages" { + t.Errorf("path = %s", gotPath) + } + if gotKey != "secret-key" { + t.Errorf("api key header = %q", gotKey) + } + var body map[string]any + if err := json.Unmarshal([]byte(gotBody), &body); err != nil { + t.Fatalf("body not json: %v", err) + } + if body["userId"] != float64(7) || body["content"] != "내 답변" || body["idempotencyKey"] != "idem-1" { + t.Errorf("body = %s", gotBody) + } +} + +func TestSubmitAnswerNon2xxReturnsError(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + })) + defer srv.Close() + + c := NewClient(srv.URL, "k", 2*time.Second) + if err := c.SubmitAnswer(context.Background(), 1, 1, "x", ""); err == nil { + t.Error("expected error on 422") + } +} diff --git a/realtime/internal/transport/router.go b/realtime/internal/transport/router.go index e1a7a3ca..7b1d005c 100644 --- a/realtime/internal/transport/router.go +++ b/realtime/internal/transport/router.go @@ -12,7 +12,7 @@ import ( "github.com/go-chi/chi/v5/middleware" ) -func NewRouter(sse *SSEHandler, verifier *auth.StreamTokenVerifier) http.Handler { +func NewRouter(sse *SSEHandler, ws *WSHandler, verifier *auth.StreamTokenVerifier) http.Handler { r := chi.NewRouter() r.Use(middleware.Recoverer) r.Use(trace.Middleware) @@ -24,11 +24,40 @@ func NewRouter(sse *SSEHandler, verifier *auth.StreamTokenVerifier) http.Handler pr.Use(auth.Middleware(verifier)) pr.Get("/realtime/stream/me", func(w http.ResponseWriter, req *http.Request) { - userID := auth.UserIDFromContext(req.Context()) - sse.ServeChannel(w, req, session.Channel{Kind: session.ChannelUser, ID: userID}) + c, _ := auth.ClaimsFromContext(req.Context()) + if c.ResourceType != "USER" { + http.Error(w, "token resource mismatch", http.StatusForbidden) + return + } + sse.ServeChannel(w, req, session.Channel{Kind: session.ChannelUser, ID: c.UserID}) + }) + pr.Get("/realtime/stream/documents/{id}", channelByPath(sse, session.ChannelDocument)) // TODO: DOCUMENT 스코프 검증 (deferred) + pr.Get("/realtime/stream/sessions/{id}", func(w http.ResponseWriter, req *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(req, "id"), 10, 64) + if err != nil || id <= 0 { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + c, _ := auth.ClaimsFromContext(req.Context()) + if c.ResourceType != "SESSION" || c.ResourceID != id { + http.Error(w, "token resource mismatch", http.StatusForbidden) + return + } + sse.ServeChannel(w, req, session.Channel{Kind: session.ChannelSession, ID: id}) + }) + pr.Get("/realtime/sessions/{id}", func(w http.ResponseWriter, req *http.Request) { + id, err := strconv.ParseInt(chi.URLParam(req, "id"), 10, 64) + if err != nil || id <= 0 { + http.Error(w, "invalid id", http.StatusBadRequest) + return + } + c, _ := auth.ClaimsFromContext(req.Context()) + if c.ResourceType != "SESSION" || c.ResourceID != id { + http.Error(w, "token resource mismatch", http.StatusForbidden) + return + } + ws.ServeWS(w, req) }) - pr.Get("/realtime/stream/documents/{id}", channelByPath(sse, session.ChannelDocument)) - pr.Get("/realtime/stream/sessions/{id}", channelByPath(sse, session.ChannelSession)) }) return r diff --git a/realtime/internal/transport/ws.go b/realtime/internal/transport/ws.go new file mode 100644 index 00000000..fec2958a --- /dev/null +++ b/realtime/internal/transport/ws.go @@ -0,0 +1,109 @@ +package transport + +import ( + "context" + "encoding/json" + "log/slog" + "net/http" + "strconv" + "time" + + "github.com/Team-StackUp/stackup/realtime/internal/auth" + "github.com/Team-StackUp/stackup/realtime/internal/core" + "github.com/Team-StackUp/stackup/realtime/internal/session" + "github.com/Team-StackUp/stackup/realtime/internal/trace" + "github.com/coder/websocket" + "github.com/go-chi/chi/v5" +) + +type WSHandler struct { + Registry *session.Registry + Core core.AnswerSubmitter + BufferSize int + WriteTimeout time.Duration +} + +func NewWSHandler(reg *session.Registry, c core.AnswerSubmitter, writeTimeout time.Duration) *WSHandler { + return &WSHandler{Registry: reg, Core: c, BufferSize: 16, WriteTimeout: writeTimeout} +} + +type inboundMessage struct { + Type string `json:"type"` + Content string `json:"content"` + IdempotencyKey string `json:"idempotencyKey"` +} + +type outboundFrame struct { + ID string `json:"id"` + Event string `json:"event"` + Data json.RawMessage `json:"data"` +} + +func (h *WSHandler) ServeWS(w http.ResponseWriter, r *http.Request) { + sid, err := strconv.ParseInt(chi.URLParam(r, "id"), 10, 64) + if err != nil || sid <= 0 { + http.Error(w, "invalid session id", http.StatusBadRequest) + return + } + userID := auth.UserIDFromContext(r.Context()) + traceID := trace.FromContext(r.Context()) + + conn, err := websocket.Accept(w, r, nil) + if err != nil { + slog.Warn("ws.accept.failed", "err", err) + return + } + defer conn.CloseNow() + + ctx := r.Context() + channel := session.Channel{Kind: session.ChannelSession, ID: sid} + sub := h.Registry.Subscribe(channel, h.BufferSize) + defer h.Registry.Unsubscribe(channel, sub) + slog.Info("ws.subscribe", "session_id", sid, "user_id", userID, "trace_id", traceID) + + go h.writeLoop(ctx, conn, sub) + + for { + _, data, err := conn.Read(ctx) + if err != nil { + slog.Info("ws.read.closed", "session_id", sid, "err", err) + return + } + var msg inboundMessage + if err := json.Unmarshal(data, &msg); err != nil || msg.Type != "answer" { + h.writeError(ctx, conn, "invalid message") + continue + } + if err := h.Core.SubmitAnswer(ctx, sid, userID, msg.Content, msg.IdempotencyKey); err != nil { + slog.Warn("ws.answer.submit.failed", "session_id", sid, "err", err) + h.writeError(ctx, conn, "answer submit failed") + } + } +} + +func (h *WSHandler) writeLoop(ctx context.Context, conn *websocket.Conn, sub *session.Subscriber) { + for { + select { + case <-ctx.Done(): + return + case ev, ok := <-sub.Ch: + if !ok { + return + } + frame, _ := json.Marshal(outboundFrame{ID: ev.ID, Event: ev.Type, Data: ev.Data}) + writeCtx, cancel := context.WithTimeout(ctx, h.WriteTimeout) + err := conn.Write(writeCtx, websocket.MessageText, frame) + cancel() + if err != nil { + return + } + } + } +} + +func (h *WSHandler) writeError(ctx context.Context, conn *websocket.Conn, message string) { + frame, _ := json.Marshal(outboundFrame{Event: "error", Data: json.RawMessage(`"` + message + `"`)}) + writeCtx, cancel := context.WithTimeout(ctx, h.WriteTimeout) + defer cancel() + _ = conn.Write(writeCtx, websocket.MessageText, frame) +} diff --git a/realtime/internal/transport/ws_test.go b/realtime/internal/transport/ws_test.go new file mode 100644 index 00000000..2a2795c3 --- /dev/null +++ b/realtime/internal/transport/ws_test.go @@ -0,0 +1,90 @@ +package transport + +import ( + "context" + "encoding/json" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/Team-StackUp/stackup/realtime/internal/session" + "github.com/coder/websocket" +) + +type fakeSubmitter struct { + mu sync.Mutex + calls []submitCall +} +type submitCall struct { + sessionID, userID int64 + content string +} + +func (f *fakeSubmitter) SubmitAnswer(_ context.Context, sessionID, userID int64, content, _ string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.calls = append(f.calls, submitCall{sessionID, userID, content}) + return nil +} + +func TestWSPushAndAnswer(t *testing.T) { + reg := session.NewRegistry() + sub := &fakeSubmitter{} + h := NewWSHandler(reg, sub, 2*time.Second) + + srv := httptest.NewServer(authInject(7, routeID("id", 99, h))) + defer srv.Close() + + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + conn, _, err := websocket.Dial(ctx, wsURL+"/realtime/sessions/99", nil) + if err != nil { + t.Fatalf("dial: %v", err) + } + defer conn.Close(websocket.StatusNormalClosure, "") + + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + if reg.Dispatch(session.Channel{Kind: session.ChannelSession, ID: 99}, + session.Event{ID: "m1", Type: "session.message", Data: []byte(`{"messageId":503}`)}, + 500*time.Millisecond) == 1 { + break + } + time.Sleep(10 * time.Millisecond) + } + _, data, err := conn.Read(ctx) + if err != nil { + t.Fatalf("read push: %v", err) + } + var frame map[string]any + if err := json.Unmarshal(data, &frame); err != nil { + t.Fatalf("frame not json: %v", err) + } + if frame["event"] != "session.message" || frame["id"] != "m1" { + t.Errorf("frame = %s", data) + } + + answer := `{"type":"answer","content":"내 답변","idempotencyKey":"k1"}` + if err := conn.Write(ctx, websocket.MessageText, []byte(answer)); err != nil { + t.Fatalf("write answer: %v", err) + } + + deadline = time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + sub.mu.Lock() + n := len(sub.calls) + sub.mu.Unlock() + if n == 1 { + break + } + time.Sleep(10 * time.Millisecond) + } + sub.mu.Lock() + defer sub.mu.Unlock() + if len(sub.calls) != 1 || sub.calls[0].sessionID != 99 || sub.calls[0].userID != 7 || sub.calls[0].content != "내 답변" { + t.Errorf("submit calls = %+v", sub.calls) + } +} diff --git a/realtime/internal/transport/ws_testhelper_test.go b/realtime/internal/transport/ws_testhelper_test.go new file mode 100644 index 00000000..8ff1de0d --- /dev/null +++ b/realtime/internal/transport/ws_testhelper_test.go @@ -0,0 +1,30 @@ +package transport + +import ( + "net/http" + "strconv" + + "github.com/Team-StackUp/stackup/realtime/internal/auth" + "github.com/go-chi/chi/v5" +) + +// authInject simulates the auth middleware injecting an authenticated userId. +func authInject(userID int64, next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + ctx := auth.WithUserID(r.Context(), userID) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// routeID wraps the handler in a chi router that binds {id} for the WS path. +func routeID(_ string, _ int64, h *WSHandler) http.Handler { + r := chi.NewRouter() + r.Get("/realtime/sessions/{id}", func(w http.ResponseWriter, req *http.Request) { + if _, err := strconv.ParseInt(chi.URLParam(req, "id"), 10, 64); err != nil { + http.Error(w, "bad id", http.StatusBadRequest) + return + } + h.ServeWS(w, req) + }) + return r +} From 6c50735ee765ef3d11a676ab0bc5fd707e189b23 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Mon, 1 Jun 2026 21:38:17 +0900 Subject: [PATCH 4/5] =?UTF-8?q?fix(realtime):=20WS=20=EB=88=84=EC=88=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/messaging/RealtimeNotifyEvent.java | 21 +++++++++++++++++ .../RealtimeNotifyEventListener.java | 23 +++++++++++++++++++ .../application/AnalysisCallbackService.java | 9 ++++---- .../application/FeedbackCallbackService.java | 13 ++++++----- .../application/QuestionsCallbackService.java | 23 +++++++++---------- .../application/VoiceCallbackService.java | 15 ++++++------ .../FeedbackCallbackServiceTest.java | 17 ++++++++++---- .../QuestionsCallbackServiceTest.java | 15 ++++++++---- .../application/VoiceCallbackServiceTest.java | 19 +++++++++++---- realtime/internal/session/registry.go | 14 ++++++++++- realtime/internal/transport/ws.go | 15 +++++++++--- 11 files changed, 137 insertions(+), 47 deletions(-) create mode 100644 backend/src/main/java/com/stackup/stackup/common/messaging/RealtimeNotifyEvent.java create mode 100644 backend/src/main/java/com/stackup/stackup/common/messaging/RealtimeNotifyEventListener.java diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/RealtimeNotifyEvent.java b/backend/src/main/java/com/stackup/stackup/common/messaging/RealtimeNotifyEvent.java new file mode 100644 index 00000000..9923fbd1 --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/RealtimeNotifyEvent.java @@ -0,0 +1,21 @@ +package com.stackup.stackup.common.messaging; + +import com.stackup.stackup.common.sse.SseEventType; + +// Core 도메인 트랜잭션 안에서 발행하는 알림 의도. 실제 RabbitMQ publish 는 +// RealtimeNotifyEventListener 가 AFTER_COMMIT 에서 수행한다 (롤백 시 미발행 보장). +public record RealtimeNotifyEvent(Channel channel, Long id, SseEventType type, Object payload) { + public enum Channel { SESSION, USER, DOCUMENT } + + public static RealtimeNotifyEvent session(Long sessionId, SseEventType type, Object payload) { + return new RealtimeNotifyEvent(Channel.SESSION, sessionId, type, payload); + } + + public static RealtimeNotifyEvent user(Long userId, SseEventType type, Object payload) { + return new RealtimeNotifyEvent(Channel.USER, userId, type, payload); + } + + public static RealtimeNotifyEvent document(Long documentId, SseEventType type, Object payload) { + return new RealtimeNotifyEvent(Channel.DOCUMENT, documentId, type, payload); + } +} diff --git a/backend/src/main/java/com/stackup/stackup/common/messaging/RealtimeNotifyEventListener.java b/backend/src/main/java/com/stackup/stackup/common/messaging/RealtimeNotifyEventListener.java new file mode 100644 index 00000000..d84db1ef --- /dev/null +++ b/backend/src/main/java/com/stackup/stackup/common/messaging/RealtimeNotifyEventListener.java @@ -0,0 +1,23 @@ +package com.stackup.stackup.common.messaging; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +// 도메인 트랜잭션 커밋 후 RealTime 알림을 발행한다. 롤백 시 미발행 → DB 와 알림의 일관성 보장. +@Component +@RequiredArgsConstructor +public class RealtimeNotifyEventListener { + + private final RealtimeNotifyPublisher publisher; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void on(RealtimeNotifyEvent event) { + switch (event.channel()) { + case SESSION -> publisher.publishToSession(event.id(), event.type(), event.payload()); + case USER -> publisher.publishToUser(event.id(), event.type(), event.payload()); + case DOCUMENT -> publisher.publishToDocument(event.id(), event.type(), event.payload()); + } + } +} diff --git a/backend/src/main/java/com/stackup/stackup/document/application/AnalysisCallbackService.java b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisCallbackService.java index ae69b404..94b1eb43 100644 --- a/backend/src/main/java/com/stackup/stackup/document/application/AnalysisCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/document/application/AnalysisCallbackService.java @@ -4,8 +4,9 @@ import com.fasterxml.jackson.databind.json.JsonMapper; import com.stackup.stackup.common.messaging.domain.ProcessedMessage; import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; -import com.stackup.stackup.common.messaging.RealtimeNotifyPublisher; +import com.stackup.stackup.common.messaging.RealtimeNotifyEvent; import com.stackup.stackup.common.sse.SseEventType; +import org.springframework.context.ApplicationEventPublisher; import com.stackup.stackup.document.application.dto.AnalysisCallbackEnvelope; import com.stackup.stackup.document.application.dto.AnalysisCallbackPayload; import com.stackup.stackup.document.domain.AnalyzedDocument; @@ -32,7 +33,7 @@ public class AnalysisCallbackService { private final AnalyzedDocumentRepository documentRepository; private final ProcessedMessageRepository processedMessageRepository; - private final RealtimeNotifyPublisher realtimeNotifyPublisher; + private final ApplicationEventPublisher events; @Transactional public void apply(AnalysisCallbackEnvelope envelope) { @@ -104,11 +105,11 @@ private void applyFailed(AnalyzedDocument doc, AnalysisCallbackPayload payload) private void publishSse(AnalyzedDocument doc, AnalysisCallbackPayload payload) { SseEventType type = doc.getRepository() != null ? SseEventType.REPO_STATE : SseEventType.DOC_STATE; - realtimeNotifyPublisher.publishToDocument(doc.getId(), type, payload); + events.publishEvent(RealtimeNotifyEvent.document(doc.getId(), type, payload)); // 프론트가 documentId 를 모르고도 /realtime/stream/me 로 분석 진행을 받을 수 있게 user 채널에도 push. Long userId = resolveOwnerUserId(doc); if (userId != null) { - realtimeNotifyPublisher.publishToUser(userId, type, payload); + events.publishEvent(RealtimeNotifyEvent.user(userId, type, payload)); } } diff --git a/backend/src/main/java/com/stackup/stackup/session/application/FeedbackCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/FeedbackCallbackService.java index ce1fe18c..40846c41 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/FeedbackCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/FeedbackCallbackService.java @@ -4,8 +4,9 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.stackup.stackup.common.messaging.domain.ProcessedMessage; import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; -import com.stackup.stackup.common.messaging.RealtimeNotifyPublisher; +import com.stackup.stackup.common.messaging.RealtimeNotifyEvent; import com.stackup.stackup.common.sse.SseEventType; +import org.springframework.context.ApplicationEventPublisher; import com.stackup.stackup.session.application.dto.FeedbackCallbackEnvelope; import com.stackup.stackup.session.application.dto.FeedbackCallbackPayload; import com.stackup.stackup.session.domain.InterviewSession; @@ -33,7 +34,7 @@ public class FeedbackCallbackService { private final InterviewSessionRepository sessionRepository; private final SessionFeedbackRepository feedbackRepository; private final ProcessedMessageRepository processedMessageRepository; - private final RealtimeNotifyPublisher realtimeNotifyPublisher; + private final ApplicationEventPublisher events; @Transactional public void apply(FeedbackCallbackEnvelope envelope) { @@ -84,10 +85,10 @@ public void apply(FeedbackCallbackEnvelope envelope) { return; } - realtimeNotifyPublisher.publishToSession(sessionId, SseEventType.FEEDBACK_READY, - new SessionFeedbackNotice(sessionId, feedback.getId())); - realtimeNotifyPublisher.publishToUser(session.getUser().getId(), SseEventType.FEEDBACK_READY, - new SessionFeedbackNotice(sessionId, feedback.getId())); + events.publishEvent(RealtimeNotifyEvent.session(sessionId, SseEventType.FEEDBACK_READY, + new SessionFeedbackNotice(sessionId, feedback.getId()))); + events.publishEvent(RealtimeNotifyEvent.user(session.getUser().getId(), SseEventType.FEEDBACK_READY, + new SessionFeedbackNotice(sessionId, feedback.getId()))); markProcessed(envelope.messageId()); log.info("callback.feedback processed. sessionId={}, feedbackId={}", sessionId, feedback.getId()); diff --git a/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java index 779b5ace..807fdba9 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/QuestionsCallbackService.java @@ -2,7 +2,7 @@ import com.stackup.stackup.common.messaging.domain.ProcessedMessage; import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; -import com.stackup.stackup.common.messaging.RealtimeNotifyPublisher; +import com.stackup.stackup.common.messaging.RealtimeNotifyEvent; import com.stackup.stackup.common.sse.SseEventType; import com.stackup.stackup.session.application.dto.QuestionsCallbackEnvelope; import com.stackup.stackup.session.application.dto.QuestionsCallbackPayload; @@ -36,7 +36,6 @@ public class QuestionsCallbackService { private final InterviewSessionRepository sessionRepository; private final InterviewMessageRepository messageRepository; private final ProcessedMessageRepository processedMessageRepository; - private final RealtimeNotifyPublisher realtimeNotifyPublisher; private final ApplicationEventPublisher events; @Transactional @@ -85,13 +84,13 @@ private void applyPool(InterviewSession session, QuestionsCallbackPayload payloa InterviewMessage.interviewer(session, 1, first.question()) ); session.incrementQuestionCount(); - realtimeNotifyPublisher.publishToSession(session.getId(), SseEventType.SESSION_MESSAGE, message.getId()); + events.publishEvent(RealtimeNotifyEvent.session(session.getId(), SseEventType.SESSION_MESSAGE, message.getId())); // 사용자 user 채널에도 알림 — frontend 가 documentId/sessionId 사전 인지 없이도 받을 수 있게 - realtimeNotifyPublisher.publishToUser( + events.publishEvent(RealtimeNotifyEvent.user( session.getUser().getId(), SseEventType.SESSION_MESSAGE, new SessionMessageNotice(session.getId(), message.getId(), "QUESTION_POOL_READY") - ); + )); log.info("callback.questions POOL processed. sessionId={}, total={}", session.getId(), questions.size()); } @@ -113,12 +112,12 @@ private void applyFollowup(InterviewSession session, QuestionsCallbackPayload pa ); session.incrementQuestionCount(); - realtimeNotifyPublisher.publishToSession(session.getId(), SseEventType.SESSION_MESSAGE, message.getId()); - realtimeNotifyPublisher.publishToUser( + events.publishEvent(RealtimeNotifyEvent.session(session.getId(), SseEventType.SESSION_MESSAGE, message.getId())); + events.publishEvent(RealtimeNotifyEvent.user( session.getUser().getId(), SseEventType.SESSION_MESSAGE, new SessionMessageNotice(session.getId(), message.getId(), "FOLLOWUP_READY") - ); + )); // maxQuestions 도달 시 자동 종료 (plan §A-4) Integer max = session.getMaxQuestions(); @@ -126,10 +125,10 @@ private void applyFollowup(InterviewSession session, QuestionsCallbackPayload pa && session.getTotalQuestionCount() >= max) { try { session.end(); - realtimeNotifyPublisher.publishToSession(session.getId(), SseEventType.SESSION_STATE, - new SessionStateNotice(session.getId(), session.getStatus().name(), "MAX_QUESTIONS_REACHED")); - realtimeNotifyPublisher.publishToUser(session.getUser().getId(), SseEventType.SESSION_STATE, - new SessionStateNotice(session.getId(), session.getStatus().name(), "MAX_QUESTIONS_REACHED")); + events.publishEvent(RealtimeNotifyEvent.session(session.getId(), SseEventType.SESSION_STATE, + new SessionStateNotice(session.getId(), session.getStatus().name(), "MAX_QUESTIONS_REACHED"))); + events.publishEvent(RealtimeNotifyEvent.user(session.getUser().getId(), SseEventType.SESSION_STATE, + new SessionStateNotice(session.getId(), session.getStatus().name(), "MAX_QUESTIONS_REACHED"))); events.publishEvent(new SessionEndedEvent( session.getUser().getId(), session.getId(), "MAX_QUESTIONS_REACHED")); log.info("session auto-completed on max questions. sessionId={}, max={}", diff --git a/backend/src/main/java/com/stackup/stackup/session/application/VoiceCallbackService.java b/backend/src/main/java/com/stackup/stackup/session/application/VoiceCallbackService.java index dd388187..df3fa973 100644 --- a/backend/src/main/java/com/stackup/stackup/session/application/VoiceCallbackService.java +++ b/backend/src/main/java/com/stackup/stackup/session/application/VoiceCallbackService.java @@ -4,7 +4,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.stackup.stackup.common.messaging.domain.ProcessedMessage; import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; -import com.stackup.stackup.common.messaging.RealtimeNotifyPublisher; +import com.stackup.stackup.common.messaging.RealtimeNotifyEvent; import com.stackup.stackup.common.sse.SseEventType; import com.stackup.stackup.session.application.dto.VoiceCallbackEnvelope; import com.stackup.stackup.session.application.dto.VoiceCallbackPayload; @@ -37,7 +37,6 @@ public class VoiceCallbackService { private final InterviewMessageRepository messageRepository; private final MessageVoiceAnalysisRepository voiceAnalysisRepository; private final ProcessedMessageRepository processedMessageRepository; - private final RealtimeNotifyPublisher realtimeNotifyPublisher; private final ApplicationEventPublisher events; @Transactional @@ -65,8 +64,8 @@ public void apply(VoiceCallbackEnvelope envelope) { if (p.errorCode() != null && !p.errorCode().isBlank()) { message.markStatus(MessageStatus.FAILED); - realtimeNotifyPublisher.publishToSession(p.sessionId(), SseEventType.SESSION_MESSAGE, - new VoiceFailedNotice(p.sessionId(), message.getId(), p.errorCode())); + events.publishEvent(RealtimeNotifyEvent.session(p.sessionId(), SseEventType.SESSION_MESSAGE, + new VoiceFailedNotice(p.sessionId(), message.getId(), p.errorCode()))); markProcessed(envelope.messageId()); log.warn("callback.voice STT failed. sessionId={}, msg={}, code={}", p.sessionId(), message.getId(), p.errorCode()); @@ -89,11 +88,11 @@ public void apply(VoiceCallbackEnvelope envelope) { } } - realtimeNotifyPublisher.publishToSession(p.sessionId(), SseEventType.SESSION_MESSAGE, - new VoiceTranscribedNotice(p.sessionId(), message.getId(), p.transcript())); - realtimeNotifyPublisher.publishToUser(message.getSession().getUser().getId(), + events.publishEvent(RealtimeNotifyEvent.session(p.sessionId(), SseEventType.SESSION_MESSAGE, + new VoiceTranscribedNotice(p.sessionId(), message.getId(), p.transcript()))); + events.publishEvent(RealtimeNotifyEvent.user(message.getSession().getUser().getId(), SseEventType.SESSION_MESSAGE, - new VoiceTranscribedNotice(p.sessionId(), message.getId(), p.transcript())); + new VoiceTranscribedNotice(p.sessionId(), message.getId(), p.transcript()))); // 텍스트 답변과 동일 흐름으로 followup 트리거. Long parentId = message.getParentMessage() == null ? null : message.getParentMessage().getId(); diff --git a/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java index 7dc7c561..1d586e21 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/FeedbackCallbackServiceTest.java @@ -2,13 +2,13 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.stackup.stackup.common.messaging.RealtimeNotifyEvent; import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; -import com.stackup.stackup.common.messaging.RealtimeNotifyPublisher; import com.stackup.stackup.common.sse.SseEventType; import com.stackup.stackup.session.application.dto.FeedbackCallbackEnvelope; import com.stackup.stackup.session.application.dto.FeedbackCallbackPayload; @@ -27,6 +27,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.util.ReflectionTestUtils; @ExtendWith(MockitoExtension.class) @@ -35,7 +36,7 @@ class FeedbackCallbackServiceTest { @Mock InterviewSessionRepository sessionRepository; @Mock SessionFeedbackRepository feedbackRepository; @Mock ProcessedMessageRepository processedMessageRepository; - @Mock RealtimeNotifyPublisher realtimeNotifyPublisher; + @Mock ApplicationEventPublisher events; @InjectMocks FeedbackCallbackService service; @Test @@ -60,7 +61,15 @@ void apply_insertsFeedbackAndPushesSse() { verify(feedbackRepository).save(cap.capture()); assertThat(cap.getValue().getOverallScore()).isEqualTo(85.0); assertThat(cap.getValue().getImprovementKeywords()).contains("Spring"); - verify(realtimeNotifyPublisher).publishToSession(eq(50L), eq(SseEventType.FEEDBACK_READY), any()); + + ArgumentCaptor evCap = ArgumentCaptor.forClass(Object.class); + verify(events, atLeastOnce()).publishEvent(evCap.capture()); + assertThat(evCap.getAllValues()).anySatisfy(e -> { + assertThat(e).isInstanceOf(RealtimeNotifyEvent.class); + RealtimeNotifyEvent rne = (RealtimeNotifyEvent) e; + assertThat(rne.channel()).isEqualTo(RealtimeNotifyEvent.Channel.SESSION); + assertThat(rne.type()).isEqualTo(SseEventType.FEEDBACK_READY); + }); } @Test diff --git a/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java index 66972d58..add67c53 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/QuestionsCallbackServiceTest.java @@ -2,14 +2,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.stackup.stackup.common.messaging.RealtimeNotifyEvent; import com.stackup.stackup.common.messaging.domain.ProcessedMessage; import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; -import com.stackup.stackup.common.messaging.RealtimeNotifyPublisher; import com.stackup.stackup.common.sse.SseEventType; import com.stackup.stackup.session.application.dto.QuestionsCallbackEnvelope; import com.stackup.stackup.session.application.dto.QuestionsCallbackPayload; @@ -26,6 +26,7 @@ import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -37,7 +38,6 @@ class QuestionsCallbackServiceTest { @Mock InterviewSessionRepository sessionRepository; @Mock InterviewMessageRepository messageRepository; @Mock ProcessedMessageRepository processedMessageRepository; - @Mock RealtimeNotifyPublisher realtimeNotifyPublisher; @Mock org.springframework.context.ApplicationEventPublisher events; @InjectMocks QuestionsCallbackService service; @@ -59,7 +59,14 @@ void apply_poolInsertsFirstQuestionAndPushesSse() { service.apply(env); verify(messageRepository).save(any(InterviewMessage.class)); - verify(realtimeNotifyPublisher).publishToSession(eq(11L), eq(SseEventType.SESSION_MESSAGE), any()); + ArgumentCaptor ev = ArgumentCaptor.forClass(Object.class); + verify(events, atLeastOnce()).publishEvent(ev.capture()); + assertThat(ev.getAllValues()).anySatisfy(e -> { + assertThat(e).isInstanceOf(RealtimeNotifyEvent.class); + RealtimeNotifyEvent rne = (RealtimeNotifyEvent) e; + assertThat(rne.channel()).isEqualTo(RealtimeNotifyEvent.Channel.SESSION); + assertThat(rne.type()).isEqualTo(SseEventType.SESSION_MESSAGE); + }); verify(processedMessageRepository).save(any(ProcessedMessage.class)); assertThat(session.getTotalQuestionCount()).isEqualTo(1); } diff --git a/backend/src/test/java/com/stackup/stackup/session/application/VoiceCallbackServiceTest.java b/backend/src/test/java/com/stackup/stackup/session/application/VoiceCallbackServiceTest.java index fac8d0ed..e9695fba 100644 --- a/backend/src/test/java/com/stackup/stackup/session/application/VoiceCallbackServiceTest.java +++ b/backend/src/test/java/com/stackup/stackup/session/application/VoiceCallbackServiceTest.java @@ -2,13 +2,13 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; -import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.atLeastOnce; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import com.stackup.stackup.common.messaging.RealtimeNotifyEvent; import com.stackup.stackup.common.messaging.domain.ProcessedMessageRepository; -import com.stackup.stackup.common.messaging.RealtimeNotifyPublisher; import com.stackup.stackup.common.sse.SseEventType; import com.stackup.stackup.session.application.dto.VoiceCallbackEnvelope; import com.stackup.stackup.session.application.dto.VoiceCallbackPayload; @@ -26,6 +26,7 @@ import java.util.Optional; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -38,7 +39,6 @@ class VoiceCallbackServiceTest { @Mock InterviewMessageRepository messageRepository; @Mock MessageVoiceAnalysisRepository voiceAnalysisRepository; @Mock ProcessedMessageRepository processedMessageRepository; - @Mock RealtimeNotifyPublisher realtimeNotifyPublisher; @Mock ApplicationEventPublisher events; @InjectMocks VoiceCallbackService service; @@ -65,8 +65,17 @@ void apply_setsTranscriptAndPublishesAnswerSubmittedEvent() { assertThat(voiceMsg.getContent()).isEqualTo("Transactions provide isolation and consistency"); assertThat(voiceMsg.getStatus()).isEqualTo(MessageStatus.COMPLETED); verify(voiceAnalysisRepository).save(any(MessageVoiceAnalysis.class)); - verify(events).publishEvent(any(AnswerSubmittedEvent.class)); - verify(realtimeNotifyPublisher).publishToSession(eq(50L), eq(SseEventType.SESSION_MESSAGE), any()); + ArgumentCaptor ev = ArgumentCaptor.forClass(Object.class); + verify(events, atLeastOnce()).publishEvent(ev.capture()); + assertThat(ev.getAllValues()).anySatisfy(e -> { + assertThat(e).isInstanceOf(AnswerSubmittedEvent.class); + }); + assertThat(ev.getAllValues()).anySatisfy(e -> { + assertThat(e).isInstanceOf(RealtimeNotifyEvent.class); + RealtimeNotifyEvent rne = (RealtimeNotifyEvent) e; + assertThat(rne.channel()).isEqualTo(RealtimeNotifyEvent.Channel.SESSION); + assertThat(rne.type()).isEqualTo(SseEventType.SESSION_MESSAGE); + }); } @Test diff --git a/realtime/internal/session/registry.go b/realtime/internal/session/registry.go index 618fc1e6..749805a1 100644 --- a/realtime/internal/session/registry.go +++ b/realtime/internal/session/registry.go @@ -66,12 +66,24 @@ func (r *Registry) Dispatch(channel Channel, ev Event, slowTimeout time.Duration subs := append([]*Subscriber(nil), r.subs[channel]...) r.mu.RUnlock() + // Reuse a single timer across subscribers. time.After would leak one timer + // goroutine per (subscriber × event) until slowTimeout elapsed. + timer := time.NewTimer(slowTimeout) + defer timer.Stop() + delivered := 0 for _, s := range subs { + if !timer.Stop() { + select { + case <-timer.C: + default: + } + } + timer.Reset(slowTimeout) select { case s.Ch <- ev: delivered++ - case <-time.After(slowTimeout): + case <-timer.C: // drop slow consumer's delivery } } diff --git a/realtime/internal/transport/ws.go b/realtime/internal/transport/ws.go index fec2958a..31895115 100644 --- a/realtime/internal/transport/ws.go +++ b/realtime/internal/transport/ws.go @@ -61,7 +61,13 @@ func (h *WSHandler) ServeWS(w http.ResponseWriter, r *http.Request) { defer h.Registry.Unsubscribe(channel, sub) slog.Info("ws.subscribe", "session_id", sid, "user_id", userID, "trace_id", traceID) - go h.writeLoop(ctx, conn, sub) + // done signals writeLoop to exit when ServeWS returns. The request context of a + // hijacked WS connection is not guaranteed to be cancelled on client close, so we + // cannot rely on ctx.Done() alone — without this the writeLoop goroutine would block + // forever on sub.Ch when the client disconnects with no event pending. + done := make(chan struct{}) + defer close(done) + go h.writeLoop(ctx, done, conn, sub) for { _, data, err := conn.Read(ctx) @@ -81,11 +87,13 @@ func (h *WSHandler) ServeWS(w http.ResponseWriter, r *http.Request) { } } -func (h *WSHandler) writeLoop(ctx context.Context, conn *websocket.Conn, sub *session.Subscriber) { +func (h *WSHandler) writeLoop(ctx context.Context, done <-chan struct{}, conn *websocket.Conn, sub *session.Subscriber) { for { select { case <-ctx.Done(): return + case <-done: + return case ev, ok := <-sub.Ch: if !ok { return @@ -102,7 +110,8 @@ func (h *WSHandler) writeLoop(ctx context.Context, conn *websocket.Conn, sub *se } func (h *WSHandler) writeError(ctx context.Context, conn *websocket.Conn, message string) { - frame, _ := json.Marshal(outboundFrame{Event: "error", Data: json.RawMessage(`"` + message + `"`)}) + msgJSON, _ := json.Marshal(message) + frame, _ := json.Marshal(outboundFrame{Event: "error", Data: msgJSON}) writeCtx, cancel := context.WithTimeout(ctx, h.WriteTimeout) defer cancel() _ = conn.Write(writeCtx, websocket.MessageText, frame) From 2d9489aba3772feceb331b9b0eeedc2346d75fb4 Mon Sep 17 00:00:00 2001 From: SeoHyeon Date: Mon, 1 Jun 2026 21:42:23 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(frontend):=20=EB=9D=BC=EC=9D=B4?= =?UTF-8?q?=EB=B8=8C=20=EB=A9=B4=EC=A0=91=20WS=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EC=9E=AC=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 끊김 시 지수 백오프(1s~30s)로 재연결, 매 시도마다 stream token 재발급. onStatusChange 콜백으로 연결 상태 노출, submitAnswer는 useCallback 메모이즈 + OPEN 상태 가드. (코드 리뷰 Important I1) Co-Authored-By: Claude Opus 4.8 (1M context) --- .../interview/model/useInterviewSocket.ts | 71 ++++++++++++++++--- 1 file changed, 62 insertions(+), 9 deletions(-) diff --git a/frontend/src/features/interview/model/useInterviewSocket.ts b/frontend/src/features/interview/model/useInterviewSocket.ts index 9127fdb2..97e5b7cb 100644 --- a/frontend/src/features/interview/model/useInterviewSocket.ts +++ b/frontend/src/features/interview/model/useInterviewSocket.ts @@ -1,31 +1,72 @@ -import { useEffect, useRef } from 'react' +import { useCallback, useEffect, useRef } from 'react' import { env } from '@/shared/config/env' import { fetchSessionStreamToken } from '@/features/interview/api/streamToken' type InterviewEvent = { id: string; event: string; data: unknown } +type ConnectionStatus = 'connecting' | 'open' | 'closed' type Options = { sessionId: number | null onEvent: (event: InterviewEvent) => void + /** 연결 상태 변화 알림 (재연결 UX 표시용). */ + onStatusChange?: (status: ConnectionStatus) => void enabled?: boolean } +const BASE_BACKOFF_MS = 1_000 +const MAX_BACKOFF_MS = 30_000 + // 라이브 텍스트 면접 WebSocket. 서버→클라 질문/꼬리질문 수신 + 클라→서버 답변 송신. -export function useInterviewSocket({ sessionId, onEvent, enabled = true }: Options) { +// 끊기면 지수 백오프로 자동 재연결한다(매 시도마다 stream token 재발급). +export function useInterviewSocket({ + sessionId, + onEvent, + onStatusChange, + enabled = true, +}: Options) { const socketRef = useRef(null) const onEventRef = useRef(onEvent) + const onStatusRef = useRef(onStatusChange) onEventRef.current = onEvent + onStatusRef.current = onStatusChange useEffect(() => { if (!enabled || sessionId == null) return + let cancelled = false - let ws: WebSocket | null = null + let reconnectTimer: ReturnType | null = null + let attempt = 0 + + const scheduleReconnect = () => { + if (cancelled) return + const delay = Math.min(BASE_BACKOFF_MS * 2 ** attempt, MAX_BACKOFF_MS) + attempt += 1 + reconnectTimer = setTimeout(() => { + if (!cancelled) void connect() + }, delay) + } const connect = async () => { - const token = await fetchSessionStreamToken(sessionId) if (cancelled) return + onStatusRef.current?.('connecting') + let token: string + try { + token = await fetchSessionStreamToken(sessionId) + } catch { + scheduleReconnect() + return + } + if (cancelled) return + const base = env.REALTIME_BASE_URL.replace(/^http/, 'ws') - ws = new WebSocket(`${base}/realtime/sessions/${sessionId}?access_token=${encodeURIComponent(token)}`) + const ws = new WebSocket( + `${base}/realtime/sessions/${sessionId}?access_token=${encodeURIComponent(token)}`, + ) socketRef.current = ws + + ws.onopen = () => { + attempt = 0 + onStatusRef.current?.('open') + } ws.onmessage = (e) => { try { onEventRef.current(JSON.parse(e.data) as InterviewEvent) @@ -33,19 +74,31 @@ export function useInterviewSocket({ sessionId, onEvent, enabled = true }: Optio // ignore non-JSON } } + ws.onclose = () => { + if (socketRef.current === ws) socketRef.current = null + onStatusRef.current?.('closed') + scheduleReconnect() + } + // onerror is followed by onclose; let onclose drive the reconnect to avoid double-scheduling. + ws.onerror = () => ws.close() } + void connect() return () => { cancelled = true - ws?.close() + if (reconnectTimer) clearTimeout(reconnectTimer) + socketRef.current?.close() socketRef.current = null } }, [sessionId, enabled]) - const submitAnswer = (content: string, idempotencyKey?: string) => { - socketRef.current?.send(JSON.stringify({ type: 'answer', content, idempotencyKey })) - } + const submitAnswer = useCallback((content: string, idempotencyKey?: string) => { + const ws = socketRef.current + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ type: 'answer', content, idempotencyKey })) + } + }, []) return { submitAnswer } }