Skip to content
Merged
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
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,11 @@ dependencies {
testImplementation "org.springframework.cloud:spring-cloud-starter-openfeign"
testImplementation "com.squareup.okhttp3:mockwebserver:4.12.0"

// WebSocket
implementation 'org.springframework.boot:spring-boot-starter-websocket'

// WebFlux
implementation 'org.springframework.boot:spring-boot-starter-webflux'
}

dependencyManagement {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,25 @@
import com.sofa.linkiving.global.common.BaseResponse;

import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;

@Tag(name = "Chat", description = "채팅 관리 API")
@Tag(name = "Chat", description = """
AI 채팅 통합 명세 (HTTP + WebSocket)

### 📡 1. WebSocket 연결 정보 (필수)
답변을 실시간으로 수신하기 위해 **반드시 소켓 연결 및 구독**이 선행되어야 합니다.

* **Socket Endpoint:** `ws://{domain}/ws/chat`
* **Subscribe Path:** `/topic/chat/{chatId}`
* **Auth Header:** `Authorization: Bearer {accessToken}` (CONNECT 프레임 헤더)
### 🚀 2. 동작 흐름
1. **소켓 연결:** 프론트엔드에서 WebSocket 연결 및 `/topic/chat/{chatId}` 구독
2. **질문 전송:** `/app/send/{chatId}` (STOMP)로 질문 전송
3. **답변 수신:** 소켓 구독 채널로 토큰 단위 답변 스트리밍 (`String` 데이터)
4. **완료:** `END_OF_STREAM` 메시지 수신 시 스트리밍 종료

""")
public interface ChatApi {
@Operation(summary = "채팅방 목록 조회", description = "사용자의 채팅방 목록 정보(채팅방 Id, 제목)을 조회합니다.")
BaseResponse<ChatsRes> getChats(Member member);
Expand All @@ -19,4 +35,9 @@ BaseResponse<CreateChatRes> createChat(
CreateChatReq req,
Member member
);

void sendMessage(@Parameter(description = "채팅방 Id", required = true) Long chatId,
@Parameter(description = "사용자 질문 내용", required = true) String message, Member member);

void cancelMessage(@Parameter(description = "채팅방 Id", required = true) Long chatId, Member member);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
package com.sofa.linkiving.domain.chat.controller;

import org.springframework.messaging.handler.annotation.DestinationVariable;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
Expand Down Expand Up @@ -36,4 +39,16 @@ public BaseResponse<CreateChatRes> createChat(@RequestBody @Valid CreateChatReq
CreateChatRes res = chatFacade.createChat(req.firstChat(), member);
return BaseResponse.success(res, "채팅방 생성 완료");
}

@MessageMapping("/send/{chatId}")
@Override
public void sendMessage(@DestinationVariable Long chatId, @Payload String message, @AuthMember Member member) {
chatFacade.generateAnswer(chatId, member, message);
}

@MessageMapping("/cancel/{chatId}")
@Override
public void cancelMessage(@DestinationVariable Long chatId, @AuthMember Member member) {
chatFacade.cancelAnswer(chatId, member);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.sofa.linkiving.domain.chat.controller;

import java.time.Duration;
import java.util.Map;

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 reactor.core.publisher.Flux;

@RestController
@RequestMapping("/mock/ai")
public class MockAiController {

@PostMapping(value = "/generate", produces = MediaType.APPLICATION_NDJSON_VALUE) // 또는 TEXT_EVENT_STREAM_VALUE
public Flux<String> generateAnswer(@RequestBody Map<String, String> request) {
String userPrompt = request.get("prompt");

String fakeResponse = """
안녕하세요! 저는 임시 AI 봇입니다. 🤖
현재 AI 서버가 구축되지 않아서 테스트용 답변을 드리고 있어요.
질문하신 내용인 "%s"에 대해 답변을 생성하는 척 하고 있습니다.
취소 기능을 테스트하시려면 지금 바로 취소 버튼을 눌러보세요!
타이핑 효과를 위해 천천히 답변을 보내고 있습니다...
""".formatted(userPrompt);

return Flux.fromArray(fakeResponse.split(""))
.delayElements(Duration.ofMillis(100));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package com.sofa.linkiving.domain.chat.error;

import org.springframework.http.HttpStatus;

import com.sofa.linkiving.global.error.code.ErrorCode;

import lombok.Getter;
import lombok.RequiredArgsConstructor;

@Getter
@RequiredArgsConstructor
public enum ChatErrorCode implements ErrorCode {

CHAT_NOT_FOUND(HttpStatus.NOT_FOUND, "C-001", "채팅을 찾을 수 없습니다."),
ALREADY_GENERATING(HttpStatus.BAD_REQUEST, "C-002", "현재 답변이 생성 중입니다. 잠시만 기다려주세요.");

private final HttpStatus status;
private final String code;
private final String message;
}
Original file line number Diff line number Diff line change
Expand Up @@ -37,4 +37,15 @@ public ChatsRes getChats(Member member) {
List<Chat> chats = chatService.getChats(member);
return ChatsRes.from(chats);
}

@Transactional
public void generateAnswer(Long chatId, Member member, String message) {
Chat chat = chatService.getChat(chatId, member);
messageService.generateAnswer(chat, message);
}

public void cancelAnswer(Long chatId, Member member) {
Chat chat = chatService.getChat(chatId, member);
messageService.cancelAnswer(chat);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.sofa.linkiving.domain.chat.manager;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.springframework.stereotype.Component;

import reactor.core.Disposable;

@Component
public class SubscriptionManager {

private final Map<String, Disposable> activeSubscriptions = new ConcurrentHashMap<>();

/**
* 구독 추가 (기존 작업이 있다면 취소 후 등록)
*/
public void add(String key, Disposable subscription) {
cancel(key); // 안전하게 기존 작업 정리
activeSubscriptions.put(key, subscription);
}

/**
* 구독 취소 및 자원 해제
*/
public void cancel(String key) {
Disposable subscription = activeSubscriptions.remove(key);
if (subscription != null && !subscription.isDisposed()) {
subscription.dispose();
}
}

/**
* 완료된 구독 제거 (자원 해제 없이 Map에서만 삭제)
*/
public void remove(String key) {
activeSubscriptions.remove(key);
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.sofa.linkiving.domain.chat.repository;

import java.util.List;
import java.util.Optional;

import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
Expand All @@ -21,4 +22,6 @@ public interface ChatRepository extends JpaRepository<Chat, Long> {
ORDER BY MAX(m.createdAt) DESC
""")
List<Chat> findAllByMemberOrderByLastMessageDesc(@Param("member") Member member);

Optional<Chat> findByIdAndMember(Long id, Member member);
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
import org.springframework.stereotype.Service;

import com.sofa.linkiving.domain.chat.entity.Chat;
import com.sofa.linkiving.domain.chat.error.ChatErrorCode;
import com.sofa.linkiving.domain.chat.repository.ChatRepository;
import com.sofa.linkiving.domain.member.entity.Member;
import com.sofa.linkiving.global.error.exception.BusinessException;

import lombok.RequiredArgsConstructor;

Expand All @@ -15,6 +17,12 @@
public class ChatQueryService {
private final ChatRepository chatRepository;

public Chat findChat(Long chatId, Member member) {
return chatRepository.findByIdAndMember(chatId, member).orElseThrow(
() -> new BusinessException(ChatErrorCode.CHAT_NOT_FOUND)
);
}

public List<Chat> findAllOrderByLastMessageDesc(Member member) {
return chatRepository.findAllByMemberOrderByLastMessageDesc(member);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,19 @@
import com.sofa.linkiving.domain.member.entity.Member;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;

@Slf4j
@Service
@RequiredArgsConstructor
public class ChatService {
private final ChatCommandService chatCommandService;
private final ChatQueryService chatQueryService;

public Chat getChat(Long chatId, Member member) {
return chatQueryService.findChat(chatId, member);
}

public List<Chat> getChats(Member member) {
return chatQueryService.findAllOrderByLastMessageDesc(member);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import org.springframework.stereotype.Service;

import com.sofa.linkiving.domain.chat.entity.Message;
import com.sofa.linkiving.domain.chat.repository.MessageRepository;

import lombok.RequiredArgsConstructor;
Expand All @@ -10,4 +11,8 @@
@RequiredArgsConstructor
public class MessageCommandService {
private final MessageRepository messageRepository;

public Message saveMessage(Message message) {
return messageRepository.save(message);
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,88 @@
package com.sofa.linkiving.domain.chat.service;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;

import com.sofa.linkiving.domain.chat.entity.Chat;
import com.sofa.linkiving.domain.chat.entity.Message;
import com.sofa.linkiving.domain.chat.enums.Type;
import com.sofa.linkiving.domain.chat.manager.SubscriptionManager;

import lombok.RequiredArgsConstructor;
import reactor.core.Disposable;

@Service
@RequiredArgsConstructor
public class MessageService {
private final MessageCommandService messageCommandService;
private final MessageQueryService messageQueryService;

private final SimpMessagingTemplate messagingTemplate;
private final SubscriptionManager subscriptionManager;

private final WebClient webClient = WebClient.create("http://localhost:8080/mock/ai");
private final Map<String, StringBuilder> messageBuffers = new ConcurrentHashMap<>();

public void generateAnswer(Chat chat, String userMessage) {

String roomId = chat.getId().toString();

if (messageBuffers.containsKey(roomId)) {
return;
}

messageBuffers.put(roomId, new StringBuilder());

Disposable subscription = webClient.post()
.uri("/generate")
.bodyValue(Map.of("prompt", userMessage))
.retrieve()
.bodyToFlux(String.class)
.doOnComplete(() -> {
String fullAnswer = messageBuffers.remove(roomId).toString();

saveMessage(chat, Type.USER, userMessage);
saveMessage(chat, Type.AI, fullAnswer);

subscriptionManager.remove(roomId);
messagingTemplate.convertAndSend("/topic/chat/" + roomId, "END_OF_STREAM");
})
.doOnError(e -> {
subscriptionManager.remove(roomId);
messagingTemplate.convertAndSend("/topic/chat/" + roomId, "ERROR: " + e.getMessage());
})
.subscribe(token -> {
StringBuilder buffer = messageBuffers.get(roomId);
if (buffer != null) {
buffer.append(token);
}

messagingTemplate.convertAndSend("/topic/chat/" + roomId, token);
});

subscriptionManager.add(roomId, subscription);
}

public void cancelAnswer(Chat chat) {
String roomId = chat.getId().toString();

subscriptionManager.cancel(roomId);
messageBuffers.remove(roomId);

messagingTemplate.convertAndSend("/topic/chat/" + roomId, "GENERATION_CANCELLED");
}

private void saveMessage(Chat chat, Type type, String content) {
Message message = Message.builder()
.chat(chat)
.type(type)
.content(content)
.build();

messageCommandService.saveMessage(message);
}
}
18 changes: 18 additions & 0 deletions src/main/java/com/sofa/linkiving/global/config/WebMvcConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@
import java.util.List;

import org.springframework.context.annotation.Configuration;
import org.springframework.core.task.AsyncTaskExecutor;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

import com.sofa.linkiving.security.resolver.AuthMemberArgumentResolver;
Expand All @@ -20,4 +23,19 @@ public class WebMvcConfig implements WebMvcConfigurer {
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(authMemberArgumentResolver);
}

@Override
public void configureAsyncSupport(AsyncSupportConfigurer configurer) {
configurer.setTaskExecutor(mvcTaskExecutor());
}

public AsyncTaskExecutor mvcTaskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(10);
executor.setMaxPoolSize(100);
executor.setQueueCapacity(50);
executor.setThreadNamePrefix("mvc-async-");
executor.initialize();
return executor;
}
}
Loading