Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions infrastructure/client/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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;
Expand All @@ -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;

Expand All @@ -51,6 +57,13 @@ public void stream(Long memberId, List<ChatTurn> 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) -> {
Expand All @@ -73,6 +86,27 @@ public void stream(Long memberId, List<ChatTurn> history, ChatEventListener list
guard.onError(FALLBACK_ERROR_MESSAGE);
}

/**
* 지금 처리 중인 학생 요청의 JWT를 꺼낸다. 없으면 {@code null}.
* <p>
* 인자로 받지 않고 요청에서 직접 꺼내는 이유: 인자로 받으면 {@link ChatClient}(도메인)가 토큰을 알아야 한다.
* 도메인은 인증 개념을 모르게 둔다(config-and-auth.md 4-4절). 중계는 컨트롤러 스레드에서 끝까지 동기로 돌기 때문에
* 여기서도 같은 요청이 보인다. <b>비동기로 바꾸면 요청이 보이지 않아 토큰 없이 나간다.</b>
* <p>
* 토큰 원문은 로그에 남기지 않는다.
*/
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 -> {
Expand Down
Loading