diff --git a/.env.example b/.env.example index 3a2f8b41..9ac1c74e 100644 --- a/.env.example +++ b/.env.example @@ -51,3 +51,9 @@ PUSH_TYPE=log # type=fcm일 때. Firebase 콘솔 > 프로젝트 설정 > 서비스 계정에서 받은 비공개 키 JSON을 base64로 인코딩한 값 # 예: base64 < firebase-adminsdk.json | tr -d '\n' FIREBASE_CREDENTIALS_BASE64=replace-with-base64-encoded-service-account-json + +### infrastructure:client — AI 서버(stream-ai) 챗봇 ### +STREAM_AI_BASE_URL=http://localhost:8000 +STREAM_AI_CHAT_PATH=/chat +# AI 서버가 백엔드를 거친 요청인지 확인하는 값. 코드·로그에 남기지 않는다 +STREAM_AI_INTERNAL_TOKEN=replace-with-stream-ai-internal-token diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatApi.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatApi.java new file mode 100644 index 00000000..38106037 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatApi.java @@ -0,0 +1,42 @@ +package kr.ac.kookmin.stream.api.app.welfare.chat; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import kr.ac.kookmin.stream.api.app.AppApiUser; +import kr.ac.kookmin.stream.api.app.welfare.chat.request.ChatMessageCreateRequest; +import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; +import kr.ac.kookmin.stream.common.CommonErrorCode; + +/** + * 학생 앱 챗봇 API의 문서 명세. 구현은 {@link AppChatController}가 맡는다. + *

+ * 이 API만 {@code ApiResponse} 껍데기를 쓰지 않는다. 답변이 조각으로 나눠 오므로 한 번 감싸는 껍데기를 붙일 자리가 없다. + * 그래서 에러가 두 갈래로 갈린다. + *

+ */ +@Tag(name = "챗봇", description = "학생 앱 챗봇 질문·답변") +public interface AppChatApi { + + /** + * 질문을 보내고 답변을 SSE로 받는다. 응답은 {@code text/event-stream}이고 이벤트는 네 종류다. + *
+     * event: delta   data: { "text": "부분 응답" }     0회 이상 반복
+     * event: bubble  data: {}                          여기서부터 새 말풍선
+     * event: done    data: {}                          정상 종료
+     * event: error   data: { "message": "오류 안내" }   실패
+     * 
+ * 조각이 줄바꿈 중간에서 끊길 수 있어 프론트가 빈 줄을 직접 찾지 않도록 서버가 문단 경계마다 {@code bubble}을 보낸다. + */ + @Operation(summary = "챗봇 질문 전송 (SSE)", + description = "질문을 보내고 답변을 조각으로 받는다. 응답은 JSON 한 번이 아니라 " + + "text/event-stream이며 delta·bubble·done·error 네 이벤트가 온다. " + + "지난 대화는 서버가 DB에서 꺼내 함께 보내므로 요청에는 이번 질문만 담는다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + void sendMessage(AppApiUser apiUser, ChatMessageCreateRequest request, HttpServletResponse response) + throws IOException; +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatController.java new file mode 100644 index 00000000..6cb3984a --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/AppChatController.java @@ -0,0 +1,45 @@ +package kr.ac.kookmin.stream.api.app.welfare.chat; + +import jakarta.servlet.http.HttpServletResponse; +import jakarta.validation.Valid; +import java.io.IOException; +import java.io.UncheckedIOException; +import kr.ac.kookmin.stream.api.app.AppApiUser; +import kr.ac.kookmin.stream.api.app.welfare.chat.request.ChatMessageCreateRequest; +import kr.ac.kookmin.stream.welfare.domain.chat.service.ChatService; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +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.RestController; +import tools.jackson.databind.ObjectMapper; + +@RestController +@RequestMapping("/v1/app/chat") +@RequiredArgsConstructor +public class AppChatController implements AppChatApi { + + private static final Logger log = LoggerFactory.getLogger(AppChatController.class); + + private final ChatService chatService; + private final ObjectMapper objectMapper; + + @Override + @PostMapping(value = "/messages", produces = MediaType.TEXT_EVENT_STREAM_VALUE) + public void sendMessage( + AppApiUser apiUser, + @Valid @RequestBody ChatMessageCreateRequest request, + HttpServletResponse response + ) throws IOException { + ChatSseWriter writer = ChatSseWriter.of(response, objectMapper); + try { + chatService.answer(apiUser.userId(), request.message(), writer); + } catch (UncheckedIOException e) { + // 학생이 화면을 닫으면 쓰기가 실패한다. 오류가 아니라 정상 종료다 + log.debug("학생이 연결을 끊어 중계를 멈춥니다. memberId={}", apiUser.userId()); + } + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/ChatSseWriter.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/ChatSseWriter.java new file mode 100644 index 00000000..bc8d7343 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/ChatSseWriter.java @@ -0,0 +1,72 @@ +package kr.ac.kookmin.stream.api.app.welfare.chat; + +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.OutputStream; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import kr.ac.kookmin.stream.api.app.welfare.chat.response.ChatDeltaResponse; +import kr.ac.kookmin.stream.api.app.welfare.chat.response.ChatErrorResponse; +import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatEventListener; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import tools.jackson.databind.ObjectMapper; + +/** + * 받은 이벤트를 학생 쪽 연결에 SSE 형식({@code event: 이름}, {@code data: JSON}, 빈 줄)으로 쓰고 조각마다 바로 flush한다. + *

+ * 응답 스트림에 직접 쓴다. {@code StreamingResponseBody}로는 flush가 무시되어 톰캣 버퍼가 찰 때만 조각이 나갔다. + * 대신 답변이 끝날 때까지 요청 스레드를 쥐므로, 동시 대화가 톰캣 최대 스레드(기본 200)에 가까워지면 {@code SseEmitter}를 다시 시험한다. + */ +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +final class ChatSseWriter implements ChatEventListener { + + // bubble·done은 data가 빈 객체다. 필드 없는 record는 직렬화 설정에 따라 예외가 날 수 있어 그대로 쓴다 + private static final String EMPTY_DATA = "{}"; + + private final OutputStream output; + private final ObjectMapper objectMapper; + + /** 응답 헤더를 SSE로 정하고 출력 스트림을 연다. */ + static ChatSseWriter of(HttpServletResponse response, ObjectMapper objectMapper) throws IOException { + response.setContentType(MediaType.TEXT_EVENT_STREAM_VALUE); + response.setCharacterEncoding(StandardCharsets.UTF_8.name()); + // 중간 프록시가 응답을 모아두면 조각이 한 번에 도착해 스트리밍이 사라진다. AI 서버도 같은 헤더를 보낸다 + response.setHeader(HttpHeaders.CACHE_CONTROL, "no-cache"); + response.setHeader("X-Accel-Buffering", "no"); + return new ChatSseWriter(response.getOutputStream(), objectMapper); + } + + @Override + public void onDelta(String text) { + write("delta", objectMapper.writeValueAsString(new ChatDeltaResponse(text))); + } + + @Override + public void onBubble() { + write("bubble", EMPTY_DATA); + } + + @Override + public void onDone() { + write("done", EMPTY_DATA); + } + + @Override + public void onError(String message) { + write("error", objectMapper.writeValueAsString(new ChatErrorResponse(message))); + } + + private void write(String event, String data) { + try { + String frame = "event: " + event + "\ndata: " + data + "\n\n"; + output.write(frame.getBytes(StandardCharsets.UTF_8)); + output.flush(); + } catch (IOException e) { + // 학생이 연결을 끊었다. 받을 사람이 없으니 서비스까지 올려 보내 중계를 멈춘다 + throw new UncheckedIOException(e); + } + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/request/ChatMessageCreateRequest.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/request/ChatMessageCreateRequest.java new file mode 100644 index 00000000..8ff0462f --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/request/ChatMessageCreateRequest.java @@ -0,0 +1,18 @@ +package kr.ac.kookmin.stream.api.app.welfare.chat.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +/** + * 챗봇에 보낼 질문. 지난 대화는 서버가 DB에서 꺼내 붙이므로 이번 질문만 받는다. + *

+ * 길이를 제한하는 이유는 두 가지다. 긴 글을 그대로 넘기면 토큰 비용이 그만큼 늘고, + * 프롬프트 인젝션을 길게 심을 여지를 줄인다. + */ +public record ChatMessageCreateRequest( + @Schema(description = "학생이 입력한 질문", example = "빌릴게 어떻게 써요?") + @NotBlank(message = "질문을 입력해주세요.") + @Size(max = 500, message = "질문은 500자까지 입력할 수 있습니다.") + String message +) {} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/response/ChatDeltaResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/response/ChatDeltaResponse.java new file mode 100644 index 00000000..dd4ccbc2 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/response/ChatDeltaResponse.java @@ -0,0 +1,4 @@ +package kr.ac.kookmin.stream.api.app.welfare.chat.response; + +/** SSE {@code delta} 이벤트의 data. 답변 조각 하나. */ +public record ChatDeltaResponse(String text) {} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/response/ChatErrorResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/response/ChatErrorResponse.java new file mode 100644 index 00000000..b474b915 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/welfare/chat/response/ChatErrorResponse.java @@ -0,0 +1,4 @@ +package kr.ac.kookmin.stream.api.app.welfare.chat.response; + +/** SSE {@code error} 이벤트의 data. 학생에게 보여줄 안내문. */ +public record ChatErrorResponse(String message) {} diff --git a/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/GlobalExceptionHandler.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/GlobalExceptionHandler.java index 276fb937..7b66ad2f 100644 --- a/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/GlobalExceptionHandler.java +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/api/common/GlobalExceptionHandler.java @@ -10,12 +10,18 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.validation.FieldError; import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +/** + * 에러 응답은 항상 JSON으로 Content-Type을 미리 정한다. 미리 정해 두면 콘텐츠 협상을 건너뛴다. + * 그러지 않으면 {@code produces = text/event-stream}인 챗봇 API처럼 JSON을 만들지 않는 핸들러에서 + * {@code ApiResponse}를 쓸 컨버터를 찾지 못해 에러 본문이 나가지 않는다. + */ @RestControllerAdvice public class GlobalExceptionHandler { @@ -31,7 +37,9 @@ public ResponseEntity> handleBusiness(BusinessException e) { log.warn("[requestId={}, userId={}] ({}) {}", MDC.get(REQUEST_ID), MDC.get(USER_ID), e.getErrorCode().name(), e.getMessage()); } - return ResponseEntity.status(e.getErrorCode().status()).body(ApiResponse.error(e)); + return ResponseEntity.status(e.getErrorCode().status()) + .contentType(MediaType.APPLICATION_JSON) + .body(ApiResponse.error(e)); } @ExceptionHandler(MethodArgumentNotValidException.class) @@ -42,6 +50,7 @@ public ResponseEntity> handleValidation(MethodArgumentNotValid log.warn("[requestId={}, userId={}] (INVALID_INPUT) {}", MDC.get(REQUEST_ID), MDC.get(USER_ID), message); return ResponseEntity.status(CommonErrorCode.INVALID_INPUT.status()) + .contentType(MediaType.APPLICATION_JSON) .body(ApiResponse.error(CommonErrorCode.INVALID_INPUT.name(), message)); } @@ -50,6 +59,7 @@ public ResponseEntity> handleIllegalArgument(IllegalArgumentEx log.warn("[requestId={}, userId={}] (INVALID_INPUT) {}", MDC.get(REQUEST_ID), MDC.get(USER_ID), e.getMessage()); return ResponseEntity.status(CommonErrorCode.INVALID_INPUT.status()) + .contentType(MediaType.APPLICATION_JSON) .body(ApiResponse.error(CommonErrorCode.INVALID_INPUT.name(), e.getMessage())); } @@ -58,6 +68,7 @@ public ResponseEntity> handleInternal(Exception e) { log.error("[requestId={}, userId={}] {}", MDC.get(REQUEST_ID), MDC.get(USER_ID), e.getMessage(), e); return ResponseEntity.status(ErrorStatus.INTERNAL_SERVER_ERROR) + .contentType(MediaType.APPLICATION_JSON) .body(ApiResponse.error(CommonErrorCode.INTERNAL_SERVER_ERROR)); } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatClient.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatClient.java new file mode 100644 index 00000000..c446c33d --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatClient.java @@ -0,0 +1,19 @@ +package kr.ac.kookmin.stream.welfare.domain.chat.client; + +import java.util.List; +import kr.ac.kookmin.stream.welfare.domain.chat.domain.ChatTurn; + +/** + * 답변을 만들어 주는 AI 서버. 구현체는 {@code infrastructure:client}에 둔다. + */ +public interface ChatClient { + + /** + * 대화 이력을 보내고 답변을 조각으로 받는다. 스트림이 끝날 때까지 돌아오지 않는다. + * + * @param memberId 질문한 학생. AI 서버가 요청자를 식별하는 헤더로 쓴다 + * @param history 이번 질문까지 포함한 대화 전체. AI 서버는 대화를 저장하지 않으므로 요청마다 통째로 보낸다 + * @param listener 받은 이벤트를 넘길 곳. 계약은 {@link ChatEventListener} 참고 + */ + void stream(Long memberId, List history, ChatEventListener listener); +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatEventListener.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatEventListener.java new file mode 100644 index 00000000..906dcdca --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/client/ChatEventListener.java @@ -0,0 +1,27 @@ +package kr.ac.kookmin.stream.welfare.domain.chat.client; + +/** + * AI 서버가 보내는 답변 이벤트를 받는 쪽. 답변을 모아서 한 번에 넘기지 않고 조각마다 알려, 받는 즉시 학생에게 흘려보낼 수 있게 한다. + *

+ * 마지막에는 {@code onDone} 또는 {@code onError} 중 하나가 정확히 한 번 온다. {@link ChatClient} 구현체가 보장한다. + *

+ * 더 받을 수 없으면(학생이 연결을 끊는 등) 구현체는 unchecked 예외를 던져 중계를 멈출 수 있다. + * {@link ChatClient} 구현체는 이 예외를 AI 서버 실패로 다루지 않고 그대로 전파한다. + */ +public interface ChatEventListener { + + /** 답변 조각. 여러 번 온다. 빈 줄은 들어 있지 않다. */ + void onDelta(String text); + + /** 여기서부터 새 말풍선. 첫 말풍선 앞과 마지막 말풍선 뒤에는 오지 않는다. */ + void onBubble(); + + /** 정상 종료. */ + void onDone(); + + /** + * 실패. 스트림이 시작된 뒤에는 상태 코드를 바꿀 수 없어 예외 대신 이 통로로 알린다. + * 상세 원인은 로그에만 남기므로 여기 오는 글은 학생에게 보여줄 안내문이다. + */ + void onError(String message); +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/domain/ChatMessage.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/domain/ChatMessage.java index 12de3390..1520b6da 100644 --- a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/domain/ChatMessage.java +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/domain/ChatMessage.java @@ -18,4 +18,8 @@ public class ChatMessage { public static ChatMessage of(Long id, Long memberId, String senderType, String content) { return new ChatMessage(id, memberId, senderType, content); } + + public static ChatMessage create(Long memberId, String senderType, String content) { + return new ChatMessage(null, memberId, senderType, content); + } } diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/domain/ChatTurn.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/domain/ChatTurn.java new file mode 100644 index 00000000..fd39c706 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/domain/ChatTurn.java @@ -0,0 +1,41 @@ +package kr.ac.kookmin.stream.welfare.domain.chat.domain; + +/** + * AI 서버에 보내는 대화 한 턴. {@link ChatMessage}의 id·memberId는 AI 서버가 알 필요가 없어 역할과 글만 담는다. + *

+ * {@code role} 값은 AI 서버 계약과 {@code chat_messages.sender_type}에 같은 글자로 쓴다. 중간에 변환하면 한쪽만 고쳤을 때 조용히 어긋난다. + */ +public record ChatTurn(String role, String text) { + + /** 학생이 쓴 글. */ + public static final String USER = "user"; + + /** 챗봇이 쓴 글. Gemini 계약이 assistant가 아니라 model이다. */ + public static final String MODEL = "model"; + + public ChatTurn { + validateRole(role); + validateText(text); + } + + public static ChatTurn from(ChatMessage message) { + return new ChatTurn(message.getSenderType(), message.getContent()); + } + + public static ChatTurn user(String text) { + return new ChatTurn(USER, text); + } + + private static void validateRole(String role) { + if (role == null || role.isBlank()) { + throw new IllegalArgumentException("role이 비어 있습니다."); + } + } + + // AI 서버가 빈 글에 422를 돌려준다. 보내기 전에 막아 원인을 여기서 알 수 있게 한다 + private static void validateText(String text) { + if (text == null || text.isBlank()) { + throw new IllegalArgumentException("text가 비어 있습니다."); + } + } +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/repository/ChatMessageRepository.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/repository/ChatMessageRepository.java new file mode 100644 index 00000000..b3a0a04b --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/repository/ChatMessageRepository.java @@ -0,0 +1,15 @@ +package kr.ac.kookmin.stream.welfare.domain.chat.repository; + +import java.util.List; +import kr.ac.kookmin.stream.welfare.domain.chat.domain.ChatMessage; + +public interface ChatMessageRepository { + + /** + * 한 학생의 최근 대화를 최신순으로 가져온다. "최근 N개"를 자르려면 이 방향으로 정렬해야 하므로, + * 오래된 것부터 필요한 쪽(AI 서버에 보낼 때)은 쓰는 쪽에서 뒤집는다. + */ + List findRecentByMemberId(Long memberId, int limit); + + ChatMessage save(ChatMessage message); +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/ChatService.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/ChatService.java new file mode 100644 index 00000000..bbc33cdd --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/ChatService.java @@ -0,0 +1,14 @@ +package kr.ac.kookmin.stream.welfare.domain.chat.service; + +import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatEventListener; + +public interface ChatService { + + /** + * 질문을 AI 서버에 보내고 답변을 조각으로 흘려보낸다. 스트림이 끝날 때까지 돌아오지 않는다. + * 질문과 답변은 {@code chat_messages}에 남긴다. + * + * @param listener 받은 이벤트를 넘길 곳. 보통 컨트롤러가 학생 쪽 연결에 그대로 쓴다 + */ + void answer(Long memberId, String question, ChatEventListener listener); +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/AnswerCollector.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/AnswerCollector.java new file mode 100644 index 00000000..1a0c35da --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/AnswerCollector.java @@ -0,0 +1,56 @@ +package kr.ac.kookmin.stream.welfare.domain.chat.service.impl; + +import java.util.Optional; +import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatEventListener; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +/** + * 조각을 그대로 흘려보내면서 답변 전체를 모은다. 흘려보내기만 하면 저장할 답변이 남지 않고, 모으기만 하면 스트리밍이 사라진다. + */ +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +final class AnswerCollector implements ChatEventListener { + + private final ChatEventListener delegate; + private final StringBuilder collected = new StringBuilder(); + + /** {@code done}을 받았는지. {@code error}로 끝났으면 false로 남는다. */ + private boolean completed; + + static AnswerCollector from(ChatEventListener delegate) { + return new AnswerCollector(delegate); + } + + @Override + public void onDelta(String text) { + collected.append(text); + delegate.onDelta(text); + } + + @Override + public void onBubble() { + // 말풍선 경계는 빈 줄로 잇는다. 프론트가 보여주는 모양과 같아진다 + collected.append("\n\n"); + delegate.onBubble(); + } + + @Override + public void onDone() { + completed = true; + delegate.onDone(); + } + + @Override + public void onError(String message) { + delegate.onError(message); + } + + /** {@code done}으로 끝났고 비어 있지 않을 때만 답변을 준다. */ + Optional completedAnswer() { + String answer = collected.toString().strip(); + if (!completed || answer.isBlank()) { + return Optional.empty(); + } + return Optional.of(answer); + } +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatAnswerLock.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatAnswerLock.java new file mode 100644 index 00000000..6bf64ed4 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatAnswerLock.java @@ -0,0 +1,25 @@ +package kr.ac.kookmin.stream.welfare.domain.chat.service.impl; + +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.stereotype.Component; + +/** + * 한 회원당 답변 하나만 진행하게 막는다. 둘이 동시에 돌면 같은 이력을 읽어 가고 저장 순서가 꼬여 다음 질문의 이력이 뒤섞인다. + *

+ * 서버 메모리에 두므로 백엔드가 한 대일 때만 정확하다. 여러 대가 되면 포트로 올리고 Redis 같은 공용 저장소 구현을 둔다. + */ +@Component +class ChatAnswerLock { + + private final Set answeringMembers = ConcurrentHashMap.newKeySet(); + + /** 이미 답변 중이면 false. */ + boolean tryAcquire(Long memberId) { + return answeringMembers.add(memberId); + } + + void release(Long memberId) { + answeringMembers.remove(memberId); + } +} diff --git a/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java new file mode 100644 index 00000000..fd4e2587 --- /dev/null +++ b/core/domain/welfare/src/main/java/kr/ac/kookmin/stream/welfare/domain/chat/service/impl/ChatServiceImpl.java @@ -0,0 +1,75 @@ +package kr.ac.kookmin.stream.welfare.domain.chat.service.impl; + +import java.util.ArrayList; +import java.util.List; +import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatClient; +import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatEventListener; +import kr.ac.kookmin.stream.welfare.domain.chat.domain.ChatMessage; +import kr.ac.kookmin.stream.welfare.domain.chat.domain.ChatTurn; +import kr.ac.kookmin.stream.welfare.domain.chat.repository.ChatMessageRepository; +import kr.ac.kookmin.stream.welfare.domain.chat.service.ChatService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +class ChatServiceImpl implements ChatService { + + // 질문·답변 5쌍. 전부 보내면 학기가 갈수록 토큰 비용이 늘고, 이 정도면 후속 질문은 이어진다 + private static final int HISTORY_LIMIT = 10; + + private static final String BUSY_MESSAGE = "이전 답변이 아직 끝나지 않았습니다. 답변이 끝난 뒤에 다시 질문해 주세요."; + + private final ChatMessageRepository chatMessageRepository; + private final ChatClient chatClient; + private final ChatAnswerLock chatAnswerLock; + + /** + * {@code @Transactional}을 붙이지 않는다. 답변 생성이 10초쯤 걸려 그동안 DB 커넥션을 쥐면 챗봇과 무관한 화면까지 멈춘다. + * 저장은 각자 짧게 하고 스트리밍은 트랜잭션 밖에서 한다. 그래서 답변이 실패하면 질문만 남는다. + *

+ * 이미 답변 중인 회원의 질문은 {@code error} 이벤트로 거절하고 저장하지 않는다. 컨트롤러가 SSE 헤더를 먼저 정해 두어 + * 상태 코드로는 거절할 수 없다. + */ + @Override + public void answer(Long memberId, String question, ChatEventListener listener) { + if (!chatAnswerLock.tryAcquire(memberId)) { + listener.onError(BUSY_MESSAGE); + return; + } + // 학생이 연결을 끊으면 예외가 올라오므로 반드시 finally에서 푼다. 안 풀면 재시작 전까지 질문할 수 없다 + try { + answerInTurn(memberId, question, listener); + } finally { + chatAnswerLock.release(memberId); + } + } + + private void answerInTurn(Long memberId, String question, ChatEventListener listener) { + List turns = loadRecentTurns(memberId); + turns.add(ChatTurn.user(question)); + chatMessageRepository.save(ChatMessage.create(memberId, ChatTurn.USER, question)); + + AnswerCollector collector = AnswerCollector.from(listener); + chatClient.stream(memberId, turns, collector); + + // 중간에 끊긴 답변이나 빈 답변은 다음 질문의 이력에 섞이므로 남기지 않는다. 실패 원인은 클라이언트가 로그로 남긴다 + collector.completedAnswer() + .ifPresent(answer -> chatMessageRepository.save(ChatMessage.create(memberId, ChatTurn.MODEL, answer))); + } + + /** 최근 대화를 오래된 것부터 돌려준다. 저장소는 최신순으로 준다. */ + private List loadRecentTurns(Long memberId) { + List recent = chatMessageRepository.findRecentByMemberId(memberId, HISTORY_LIMIT); + + List turns = new ArrayList<>(recent.size() + 1); + for (int i = recent.size() - 1; i >= 0; i--) { + ChatMessage message = recent.get(i); + if (message.getContent() == null || message.getContent().isBlank()) { + continue; + } + turns.add(ChatTurn.from(message)); + } + return turns; + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6b967646..388de397 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,6 +41,8 @@ springTx = { module = "org.springframework:spring-tx" } jakartaServletApi = { module = "jakarta.servlet:jakarta.servlet-api" } jacksonDatabind = { module = "com.fasterxml.jackson.core:jackson-databind" } +# Jackson 3 — Spring Boot 4가 만드는 매퍼 빈과 같은 계열 +jackson3Databind = { module = "tools.jackson.core:jackson-databind" } slf4jApi = { module = "org.slf4j:slf4j-api" } flywayMysql = { module = "org.flywaydb:flyway-mysql" } diff --git a/infrastructure/client/build.gradle.kts b/infrastructure/client/build.gradle.kts index 831a31c7..5865b1ae 100644 --- a/infrastructure/client/build.gradle.kts +++ b/infrastructure/client/build.gradle.kts @@ -10,11 +10,13 @@ dependencies { implementation(project(":core:domain:file")) implementation(project(":core:domain:internal")) implementation(project(":core:domain:member")) + implementation(project(":core:domain:welfare")) implementation(platform(libs.springBootDependenciesBom)) implementation(libs.springBootStarter) implementation(libs.springWeb) implementation(libs.jacksonDatabind) + implementation(libs.jackson3Databind) implementation(platform(libs.awsSdkBom)) implementation(libs.awsS3) diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/SseEvent.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/SseEvent.java new file mode 100644 index 00000000..368e1566 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/SseEvent.java @@ -0,0 +1,4 @@ +package kr.ac.kookmin.stream.client.chat.streamai; + +// SSE 이벤트 하나. data는 없을 수 있다 +record SseEvent(String name, String data) {} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/SseEventReader.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/SseEventReader.java new file mode 100644 index 00000000..5f4d4779 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/SseEventReader.java @@ -0,0 +1,56 @@ +package kr.ac.kookmin.stream.client.chat.streamai; + +import java.io.BufferedReader; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.nio.charset.StandardCharsets; +import java.util.function.Consumer; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +/** + * SSE 본문을 줄 단위로 읽어 이벤트마다 넘긴다. 형식은 {@code event: 이름}, {@code data: JSON}, 빈 줄 한 번이다. + *

+ * {@code data}의 JSON은 줄바꿈이 {@code \\n}으로 escape되어 와서 한 줄을 넘지 않으므로 줄 단위로 읽어도 쪼개지지 않는다. + */ +@NoArgsConstructor(access = AccessLevel.PRIVATE) +final class SseEventReader { + + private static final String EVENT_PREFIX = "event:"; + private static final String DATA_PREFIX = "data:"; + + // 다 읽기 전에 멈추면(handler가 예외를 던지면) 여기서 닫아야 AI 서버 스트림이 바로 끊긴다. + // RestClient가 응답을 닫을 때는 남은 본문을 끝까지 읽어 버려 답변이 다 만들어질 때까지 기다리게 된다 + static void read(InputStream body, Consumer handler) throws IOException { + try (BufferedReader reader = new BufferedReader(new InputStreamReader(body, StandardCharsets.UTF_8))) { + String name = null; + String data = null; + + String line; + while ((line = reader.readLine()) != null) { + if (line.isEmpty()) { + emit(name, data, handler); + name = null; + data = null; + continue; + } + if (line.startsWith(EVENT_PREFIX)) { + name = line.substring(EVENT_PREFIX.length()).trim(); + } else if (line.startsWith(DATA_PREFIX)) { + data = line.substring(DATA_PREFIX.length()).trim(); + } + // 그 외 줄(주석 등)은 무시한다. 계약에 없지만 와도 깨지지 않아야 한다 + } + + // 마지막 이벤트 뒤에 빈 줄 없이 끊긴 경우 + emit(name, data, handler); + } + } + + private static void emit(String name, String data, Consumer handler) { + if (name != null) { + handler.accept(new SseEvent(name, data)); + } + } +} 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 new file mode 100644 index 00000000..6815770d --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatClient.java @@ -0,0 +1,108 @@ +package kr.ac.kookmin.stream.client.chat.streamai; + +import java.util.List; +import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatClient; +import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatEventListener; +import kr.ac.kookmin.stream.welfare.domain.chat.domain.ChatTurn; +import lombok.RequiredArgsConstructor; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; +import tools.jackson.core.JacksonException; +import tools.jackson.databind.JsonNode; +import tools.jackson.databind.ObjectMapper; + +/** + * AI 서버(stream-ai)에 질문을 보내고 답변을 SSE로 받아 넘긴다. + *

+ * 응답을 객체로 변환하지 않고 {@code exchange} 안에서 본문을 흘러오는 대로 읽는다. 변환을 맡기면 전체가 도착할 때까지 기다리게 되어 + * 스트리밍이 사라진다. + */ +@Component +@RequiredArgsConstructor +public class StreamAiChatClient implements ChatClient { + + private static final Logger log = LoggerFactory.getLogger(StreamAiChatClient.class); + + /** 학생에게 보여줄 실패 안내. 내부 원인은 로그에만 남긴다. */ + private static final String FALLBACK_ERROR_MESSAGE = "답변 생성 중 오류가 발생했습니다."; + + private static final String EVENT_DELTA = "delta"; + private static final String EVENT_BUBBLE = "bubble"; + private static final String EVENT_DONE = "done"; + private static final String EVENT_ERROR = "error"; + + @Qualifier("streamAiRestClient") + private final RestClient streamAiRestClient; + + private final StreamAiProperties properties; + private final ObjectMapper objectMapper; + + @Override + public void stream(Long memberId, List history, ChatEventListener listener) { + TerminalGuard guard = TerminalGuard.from(listener); + + try { + streamAiRestClient.post() + .uri(properties.chatPath()) + .header("X-Internal-Token", properties.internalToken()) + .header("X-User-Id", String.valueOf(memberId)) + .accept(MediaType.TEXT_EVENT_STREAM) + .body(StreamAiChatRequest.from(history)) + .exchange((request, response) -> { + if (response.getStatusCode().isError()) { + // 질문 원문은 남기지 않는다. 학생의 개인 사정이 섞여 들어올 수 있다 + log.error("AI 서버가 오류를 돌려줬습니다. status={}", response.getStatusCode()); + guard.onError(FALLBACK_ERROR_MESSAGE); + return null; + } + SseEventReader.read(response.getBody(), event -> dispatch(event, guard)); + return null; + }); + } catch (RestClientException e) { + // 리스너가 던진 예외(학생 연결 끊김)는 AI 서버 실패가 아니므로 잡지 않고 그대로 올려 보낸다 + log.error("AI 서버 호출에 실패했습니다.", e); + guard.onError(FALLBACK_ERROR_MESSAGE); + } + + // done도 error도 없이 스트림이 끊긴 경우. 이미 끝났으면 무시된다 + guard.onError(FALLBACK_ERROR_MESSAGE); + } + + private void dispatch(SseEvent event, TerminalGuard guard) { + switch (event.name()) { + case EVENT_DELTA -> { + String text = readField(event.data(), "text"); + if (text != null && !text.isEmpty()) { + guard.onDelta(text); + } + } + case EVENT_BUBBLE -> guard.onBubble(); + case EVENT_DONE -> guard.onDone(); + case EVENT_ERROR -> { + String message = readField(event.data(), "message"); + guard.onError(message == null || message.isBlank() ? FALLBACK_ERROR_MESSAGE : message); + } + // 계약에 없는 이벤트가 추가되어도 중계가 멈추지 않게 한다 + default -> log.debug("모르는 SSE 이벤트를 건너뜁니다. event={}", event.name()); + } + } + + /** JSON 한 줄에서 필드 하나를 꺼낸다. 깨진 줄 하나가 대화 전체를 끊지 않도록 파싱 실패는 삼킨다. */ + private String readField(String data, String field) { + if (data == null || data.isBlank()) { + return null; + } + try { + JsonNode node = objectMapper.readTree(data).get(field); + return node == null || node.isNull() ? null : node.asString(); + } catch (JacksonException e) { + log.debug("SSE data를 읽지 못해 건너뜁니다. field={}", field); + return null; + } + } +} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatRequest.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatRequest.java new file mode 100644 index 00000000..b8433d8d --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiChatRequest.java @@ -0,0 +1,19 @@ +package kr.ac.kookmin.stream.client.chat.streamai; + +import java.util.List; +import kr.ac.kookmin.stream.welfare.domain.chat.domain.ChatTurn; + +// AI 서버 요청 바디: { "messages": [ { "role": ..., "text": ... } ] } +record StreamAiChatRequest(List messages) { + + record Message(String role, String text) { + + static Message from(ChatTurn turn) { + return new Message(turn.role(), turn.text()); + } + } + + static StreamAiChatRequest from(List history) { + return new StreamAiChatRequest(history.stream().map(Message::from).toList()); + } +} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiClientConfig.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiClientConfig.java new file mode 100644 index 00000000..172bf274 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiClientConfig.java @@ -0,0 +1,37 @@ +package kr.ac.kookmin.stream.client.chat.streamai; + +import java.util.List; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.web.client.RestClient; + +/** + * {@link StreamAiChatClient}가 쓰는 RestClient 빈 설정. SSE 응답을 흘러오는 대로 읽어야 하므로 응답을 모아두지 않는 + * {@link SimpleClientHttpRequestFactory}를 쓴다. + */ +@Configuration +public class StreamAiClientConfig { + + private static final Logger log = LoggerFactory.getLogger(StreamAiClientConfig.class); + + @Bean + public RestClient streamAiRestClient(StreamAiProperties properties) { + // 로컬처럼 AI 서버 설정 없이도 기동은 되게 두고, 빠진 설정을 알려만 준다. 챗봇을 쓰면 실패한다 + List missingSettings = properties.missingSettings(); + if (!missingSettings.isEmpty()) { + log.warn("AI 서버 설정이 비어 있어 챗봇을 쓸 수 없습니다: {}", missingSettings); + } + + SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); + requestFactory.setConnectTimeout(properties.connectTimeout()); + requestFactory.setReadTimeout(properties.readTimeout()); + + return RestClient.builder() + .baseUrl(properties.baseUrl()) + .requestFactory(requestFactory) + .build(); + } +} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiProperties.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiProperties.java new file mode 100644 index 00000000..7455df22 --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/StreamAiProperties.java @@ -0,0 +1,35 @@ +package kr.ac.kookmin.stream.client.chat.streamai; + +import java.time.Duration; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * AI 서버(stream-ai) 연동 설정. internalToken은 AI 서버가 백엔드를 거친 요청인지 확인하는 값이라 환경 변수로만 받는다. + *

+ * readTimeout은 전체 답변 시간이 아니라 조각 하나를 기다리는 시간이다. 전체 길이에 맞춰 길게 잡으면 AI 서버가 멈췄을 때도 그만큼 기다린다. + */ +@ConfigurationProperties(prefix = "chat.stream-ai") +public record StreamAiProperties( + String baseUrl, + String chatPath, + String internalToken, + Duration connectTimeout, + Duration readTimeout +) { + + /** 비어 있는 필수 설정의 키 이름. 로그에 남기므로 값은 담지 않는다. */ + public List missingSettings() { + Map required = new LinkedHashMap<>(); + required.put("base-url", baseUrl); + required.put("chat-path", chatPath); + required.put("internal-token", internalToken); + + return required.entrySet().stream() + .filter(entry -> entry.getValue() == null || entry.getValue().isBlank()) + .map(Map.Entry::getKey) + .toList(); + } +} diff --git a/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/TerminalGuard.java b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/TerminalGuard.java new file mode 100644 index 00000000..c8d7665d --- /dev/null +++ b/infrastructure/client/src/main/java/kr/ac/kookmin/stream/client/chat/streamai/TerminalGuard.java @@ -0,0 +1,52 @@ +package kr.ac.kookmin.stream.client.chat.streamai; + +import kr.ac.kookmin.stream.welfare.domain.chat.client.ChatEventListener; +import lombok.AccessLevel; +import lombok.RequiredArgsConstructor; + +/** + * {@code done}·{@code error} 중 하나만, 한 번만 넘기고 끝난 뒤의 이벤트는 버린다. + * 학생 쪽 연결을 두 번 닫으려 하면 예외가 나고, 한 번도 닫지 않으면 연결이 계속 열려 있다. + */ +@RequiredArgsConstructor(access = AccessLevel.PRIVATE) +final class TerminalGuard implements ChatEventListener { + + private final ChatEventListener delegate; + private boolean finished; + + static TerminalGuard from(ChatEventListener delegate) { + return new TerminalGuard(delegate); + } + + @Override + public void onDelta(String text) { + if (!finished) { + delegate.onDelta(text); + } + } + + @Override + public void onBubble() { + if (!finished) { + delegate.onBubble(); + } + } + + @Override + public void onDone() { + if (finished) { + return; + } + finished = true; + delegate.onDone(); + } + + @Override + public void onError(String message) { + if (finished) { + return; + } + finished = true; + delegate.onError(message); + } +} diff --git a/infrastructure/client/src/main/resources/application-infrastructure-client.yml b/infrastructure/client/src/main/resources/application-infrastructure-client.yml index 7526df14..fa79542b 100644 --- a/infrastructure/client/src/main/resources/application-infrastructure-client.yml +++ b/infrastructure/client/src/main/resources/application-infrastructure-client.yml @@ -29,3 +29,13 @@ push: type: ${PUSH_TYPE:log} fcm: credentials-base64: ${FIREBASE_CREDENTIALS_BASE64:} + +# AI 서버(stream-ai). 내부 토큰은 저장소에 남기지 않는다 +# read-timeout은 전체 답변 시간이 아니라 조각 사이 간격 기준으로 잡는다 +chat: + stream-ai: + base-url: ${STREAM_AI_BASE_URL:http://localhost:8000} + chat-path: ${STREAM_AI_CHAT_PATH:/chat} + internal-token: ${STREAM_AI_INTERNAL_TOKEN:} + connect-timeout: 3s + read-timeout: 30s diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ChatMessageJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ChatMessageJpaRepository.java new file mode 100644 index 00000000..45347d5b --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ChatMessageJpaRepository.java @@ -0,0 +1,18 @@ +package kr.ac.kookmin.stream.db.welfare; + +import java.util.List; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ChatMessageJpaRepository extends JpaRepository { + + /** + * {@code message_id}로 정렬하는 이유는 {@code idx_chat_messages_member_id}를 그대로 쓰기 위해서다. + * InnoDB가 세컨더리 인덱스 끝에 PK를 붙이므로 (member_id, message_id) 정렬이 인덱스로 커버된다. + * {@code created_at}으로 정렬하면 인덱스에 없어 filesort가 생긴다. + *

+ * {@code created_at}은 DB 기본값으로만 채워지고(insertable = false) 같은 초에 여러 건이 들어올 수 있어 + * 정렬 기준으로도 PK가 더 안전하다. + */ + List findByMemberIdOrderByIdDesc(Long memberId, Pageable pageable); +} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ChatMessageRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ChatMessageRepositoryImpl.java new file mode 100644 index 00000000..77d86c7c --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/welfare/ChatMessageRepositoryImpl.java @@ -0,0 +1,29 @@ +package kr.ac.kookmin.stream.db.welfare; + +import java.util.List; +import kr.ac.kookmin.stream.welfare.domain.chat.domain.ChatMessage; +import kr.ac.kookmin.stream.welfare.domain.chat.repository.ChatMessageRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ChatMessageRepositoryImpl implements ChatMessageRepository { + + private final ChatMessageJpaRepository chatMessageJpaRepository; + + @Override + public List findRecentByMemberId(Long memberId, int limit) { + return chatMessageJpaRepository + .findByMemberIdOrderByIdDesc(memberId, PageRequest.of(0, limit)) + .stream() + .map(ChatMessageJpaEntity::toDomain) + .toList(); + } + + @Override + public ChatMessage save(ChatMessage message) { + return chatMessageJpaRepository.save(ChatMessageJpaEntity.from(message)).toDomain(); + } +}