diff --git a/infrastructure/client/build.gradle.kts b/infrastructure/client/build.gradle.kts index 618deda3f..af13cab35 100644 --- a/infrastructure/client/build.gradle.kts +++ b/infrastructure/client/build.gradle.kts @@ -15,6 +15,8 @@ dependencies { implementation(platform(libs.springBootDependenciesBom)) implementation(libs.springBootStarter) implementation(libs.springWeb) + // StreamAiChatClient가 중계 중인 학생 요청의 헤더(JWT)를 읽는다 + implementation(libs.jakartaServletApi) implementation(libs.jacksonDatabind) implementation(libs.jackson3Databind) diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatClient.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatClient.java index 6815770d6..5de8570f4 100644 --- a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatClient.java +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatClient.java @@ -1,5 +1,6 @@ package kr.ac.kookmin.stream.client.chat.streamai; +import jakarta.servlet.http.HttpServletRequest; import java.util.List; import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatClient; import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatEventListener; @@ -8,10 +9,13 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.stereotype.Component; import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; +import org.springframework.web.context.request.RequestContextHolder; +import org.springframework.web.context.request.ServletRequestAttributes; import tools.jackson.core.JacksonException; import tools.jackson.databind.JsonNode; import tools.jackson.databind.ObjectMapper; @@ -36,6 +40,8 @@ public class StreamAiChatClient implements ChatClient { private static final String EVENT_DONE = "done"; private static final String EVENT_ERROR = "error"; + private static final String BEARER_PREFIX = "Bearer "; + @Qualifier("streamAiRestClient") private final RestClient streamAiRestClient; @@ -51,6 +57,13 @@ public void stream(Long memberId, List history, ChatEventListener list .uri(properties.chatPath()) .header("X-Internal-Token", properties.internalToken()) .header("X-User-Id", String.valueOf(memberId)) + .headers(headers -> { + // AI 서버가 이 학생 권한으로 기존 학생 API를 조회(GET)한다. 예: 빌릴게 재고 + String userToken = currentUserToken(); + if (userToken != null) { + headers.set("X-User-Token", userToken); + } + }) .accept(MediaType.TEXT_EVENT_STREAM) .body(StreamAiChatRequest.from(history)) .exchange((request, response) -> { @@ -73,6 +86,27 @@ public void stream(Long memberId, List history, ChatEventListener list guard.onError(FALLBACK_ERROR_MESSAGE); } + /** + * 지금 처리 중인 학생 요청의 JWT를 꺼낸다. 없으면 {@code null}. + *

+ * 인자로 받지 않고 요청에서 직접 꺼내는 이유: 인자로 받으면 {@link ChatClient}(도메인)가 토큰을 알아야 한다. + * 도메인은 인증 개념을 모르게 둔다(config-and-auth.md 4-4절). 중계는 컨트롤러 스레드에서 끝까지 동기로 돌기 때문에 + * 여기서도 같은 요청이 보인다. 비동기로 바꾸면 요청이 보이지 않아 토큰 없이 나간다. + *

+ * 토큰 원문은 로그에 남기지 않는다. + */ + private static String currentUserToken() { + if (!(RequestContextHolder.getRequestAttributes() instanceof ServletRequestAttributes attributes)) { + return null; + } + HttpServletRequest request = attributes.getRequest(); + String header = request.getHeader(HttpHeaders.AUTHORIZATION); + if (header == null || !header.startsWith(BEARER_PREFIX)) { + return null; + } + return header.substring(BEARER_PREFIX.length()); + } + private void dispatch(SseEvent event, TerminalGuard guard) { switch (event.name()) { case EVENT_DELTA -> {