From 82e2551abc4ceb9220340395890cb0519f75f049 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=95=9C=EC=9E=88=EB=8A=94-=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=EB=84=A4=EC=9E=84?= Date: Thu, 4 Sep 2025 18:18:22 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=EC=98=A4=ED=94=88=EC=B1=84?= =?UTF-8?q?=ED=8C=85=20api=20=EC=97=B0=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 2 + src/api/openChat.ts | 87 ++++++++++++ src/components/Profile.tsx | 3 +- src/pages/Chat.tsx | 263 ++++++++++++++++++++++++++++++++----- src/pages/SelectInfo.tsx | 81 +++++++++--- src/services/websocket.ts | 202 ++++++++++++++++++++++++++++ src/types/openChat.ts | 71 ++++++++++ 7 files changed, 660 insertions(+), 49 deletions(-) create mode 100644 src/api/openChat.ts create mode 100644 src/services/websocket.ts create mode 100644 src/types/openChat.ts diff --git a/src/App.tsx b/src/App.tsx index a0555da..4616a24 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import MbtiTestIntro from "@/pages/MbtiTestIntro"; import MbtiTestQuestions from "@/pages/MbtiTestQuestions"; import MbtiTestResult from "@/pages/MbtiTestResult"; import Error from "@/pages/Error"; + import CenteredLayout from "@/components/CenteredLayout"; import ToastMessage from "@/components/ToastMessage"; import useAuthStore from "@/store/useAuthStore"; @@ -145,6 +146,7 @@ const App = () => { } /> } /> } /> + } /> diff --git a/src/api/openChat.ts b/src/api/openChat.ts new file mode 100644 index 0000000..f650b35 --- /dev/null +++ b/src/api/openChat.ts @@ -0,0 +1,87 @@ +import { authInstance } from "./axios"; +import { + OpenChatRoom, + OpenChatMessage, + CreateOpenChatRequest, + OpenChatRoomsResponse, + OpenChatMessagesResponse, + CreateOpenChatResponse +} from "@/types/openChat"; + +/** + * 오픈 채팅방 목록 조회 + */ +export const getOpenChatRooms = async (): Promise => { + try { + const response = + await authInstance.get("/api/open-chat"); + return response.data.data; + } catch (error) { + console.error("Failed to fetch open chat rooms:", error); + throw new Error("오픈 채팅방 목록을 불러올 수 없습니다."); + } +}; + +/** + * 오픈 채팅방 메시지 조회 + * @param openChatId - 오픈 채팅방 ID + * @param openChatMessageId - 마지막 메시지 번호 (선택사항) + */ +export const getOpenChatMessages = async ( + openChatId: number, + openChatMessageId?: number +): Promise<{ messages: OpenChatMessage[]; hasMore: boolean }> => { + try { + const params = openChatMessageId + ? `?openChatMessageId=${openChatMessageId}` + : ""; + const response = await authInstance.get( + `/api/open-chat/${openChatId}${params}` + ); + return response.data.data; + } catch (error) { + console.error("Failed to fetch open chat messages:", error); + throw new Error("채팅 메시지를 불러올 수 없습니다."); + } +}; + +/** + * 오픈 채팅방 생성 + * @param chatData - 채팅방 생성 데이터 + */ +export const createOpenChatRoom = async ( + chatData: CreateOpenChatRequest +): Promise<{ openChatId: number }> => { + try { + const response = await authInstance.post( + "/api/open-chat", + chatData + ); + return response.data.data; + } catch (error) { + console.error("Failed to create open chat room:", error); + throw new Error("오픈 채팅방을 생성할 수 없습니다."); + } +}; + +/** + * 오픈 채팅방 상세 정보 조회 + * @param openChatId - 오픈 채팅방 ID + */ +export const getOpenChatRoomDetail = async ( + openChatId: number +): Promise => { + try { + const rooms = await getOpenChatRooms(); + const room = rooms.find((room) => room.id === openChatId); + + if (!room) { + throw new Error("채팅방을 찾을 수 없습니다."); + } + + return room; + } catch (error) { + console.error("Failed to fetch open chat room detail:", error); + throw new Error("채팅방 정보를 불러올 수 없습니다."); + } +}; diff --git a/src/components/Profile.tsx b/src/components/Profile.tsx index 579a0eb..7972469 100644 --- a/src/components/Profile.tsx +++ b/src/components/Profile.tsx @@ -55,7 +55,8 @@ const Profile = ({ state: { type: "topicChat", chatTitle: topicData.chatTitle, - description: topicData.description + description: topicData.description, + openChatId: topicData.chatTitle === "N의 대화" ? 1 : 2 // 임시 ID } }); } diff --git a/src/pages/Chat.tsx b/src/pages/Chat.tsx index f50b39a..c4c5eb7 100644 --- a/src/pages/Chat.tsx +++ b/src/pages/Chat.tsx @@ -1,5 +1,5 @@ import { useEffect, useRef, useState, ChangeEvent, KeyboardEvent } from "react"; -import { useLocation } from "react-router-dom"; +import { useLocation, useNavigate } from "react-router-dom"; import { Helmet } from "react-helmet"; import { authInstance } from "@/api/axios"; import { trackEvent } from "@/libs/analytics"; @@ -9,10 +9,17 @@ import ChatMessage from "@/components/ChatMessage"; import ChatActionBar from "@/components/ChatActionBar"; import TipsMenuContainer from "@/components/tips/TipsMenuContainer"; import pickMbtiImage from "@/utils/pickMbtiImage"; +// import websocketService from "@/services/websocket"; +import { getOpenChatMessages } from "@/api/openChat"; +import { WebSocketMessage } from "@/types/openChat"; +import { Mbti } from "@/types/mbti"; interface Message { role: "user" | "assistant"; content: string; + nickname?: string; + mbti?: string; + messageType?: "text" | "image" | "system"; } interface ChatResponse { @@ -29,18 +36,23 @@ interface ChatHistoryResponse { } const Chat = () => { + const navigate = useNavigate(); const { state } = useLocation(); const { mbti, mode, id = Date.now().toString(), name, - chatTitle: openChatTitle + chatTitle: openChatTitle, + openChatId, + nickname, + description } = state; const [messages, setMessages] = useState([]); const [input, setInput] = useState(""); const [isOpen, setIsOpen] = useState(false); + const [isConnected, setIsConnected] = useState(false); const bottomRef = useRef(null); const chatTitle = @@ -48,6 +60,57 @@ const Chat = () => { const assistantImgUrl = pickMbtiImage(mbti); const storageKey = `chatMessages_${id}`; + const isTopicChat = mode === "topicChat"; + + useEffect(() => { + if (!isTopicChat) { + return; + } + + // topicChat 유효성 검증 + if (!openChatId || !nickname || !mbti) { + navigate("/"); + return; + } + + const initializeOpenChat = async () => { + try { + // 기존 메시지 로드 + const { messages: openChatMessages } = + await getOpenChatMessages(openChatId); + const convertedMessages: Message[] = openChatMessages.map((msg) => ({ + role: msg.nickname === nickname ? "user" : "assistant", + content: msg.content, + nickname: msg.nickname, + mbti: msg.mbti, + messageType: msg.messageType + })); + setMessages(convertedMessages.reverse()); + + // WebSocket 연결 (임시 비활성화) + // await websocketService.connect({ + // nickname, + // mbti: mbti as Mbti, + // openChatId + // }); + + // websocketService.onMessage(handleWebSocketMessage); + // websocketService.onConnectionChange(setIsConnected); + setIsConnected(true); // 임시로 연결된 것으로 처리 + } catch (error) { + console.error("오픈채팅 초기화 실패:", error); + } + }; + + initializeOpenChat(); + + return () => { + if (isTopicChat) { + // websocketService.disconnect(); + } + }; + }, [isTopicChat, openChatId, nickname, mbti, navigate]); + useEffect(() => { const fetchMessages = async () => { if (mode === "virtualFriend") { @@ -67,7 +130,7 @@ const Chat = () => { } catch (error) { console.error("채팅 불러오기 실패", error); } - } else { + } else if (!isTopicChat) { const storedMessage = sessionStorage.getItem(storageKey); if (storedMessage) { setMessages(JSON.parse(storedMessage)); @@ -76,13 +139,13 @@ const Chat = () => { }; fetchMessages(); - }, [mode, id, storageKey]); + }, [mode, id, storageKey, isTopicChat]); useEffect(() => { - if (mode !== "virtualFriend") { + if (mode !== "virtualFriend" && !isTopicChat) { sessionStorage.setItem(storageKey, JSON.stringify(messages)); } - }, [messages, mode, storageKey]); + }, [messages, mode, storageKey, isTopicChat]); useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); @@ -97,15 +160,89 @@ const Chat = () => { setIsOpen(nextState); }; + const handleWebSocketMessage = (wsMessage: WebSocketMessage) => { + switch (wsMessage.type) { + case "message": + if (wsMessage.data.message) { + const newMessage: Message = { + role: + wsMessage.data.message.nickname === nickname + ? "user" + : "assistant", + content: wsMessage.data.message.content, + nickname: wsMessage.data.message.nickname, + mbti: wsMessage.data.message.mbti, + messageType: wsMessage.data.message.messageType + }; + setMessages((prev) => [...prev, newMessage]); + } + break; + case "join": + if (wsMessage.data.participant) { + const systemMessage: Message = { + role: "assistant", + content: `${wsMessage.data.participant.nickname}님이 입장했습니다.`, + messageType: "system" + }; + setMessages((prev) => [...prev, systemMessage]); + } + break; + case "leave": + if (wsMessage.data.participant) { + const systemMessage: Message = { + role: "assistant", + content: `${wsMessage.data.participant.nickname}님이 퇴장했습니다.`, + messageType: "system" + }; + setMessages((prev) => [...prev, systemMessage]); + } + break; + } + }; + const handleSend = async (messageToSend: string) => { if (!messageToSend.trim()) return; + setInput(""); + + if (isTopicChat) { + // 오픈채팅 WebSocket 전송 (임시 비활성화) + try { + // if (websocketService.isConnected()) { + // websocketService.sendMessage(messageToSend.trim()); + // } + + // 임시로 메시지를 바로 추가 (실제 WebSocket 없이) + const newMessage: Message = { + role: "user", + content: messageToSend, + nickname, + mbti: mbti as string + }; + setMessages((prev) => [...prev, newMessage]); + + // 임시 응답 메시지 추가 + setTimeout(() => { + const responseMessage: Message = { + role: "assistant", + content: "실시간 채팅 기능이 곧 활성화될 예정입니다!", + nickname: "시스템", + mbti: "ENFP" + }; + setMessages((prev) => [...prev, responseMessage]); + }, 1000); + } catch (error) { + console.error("메시지 전송 실패:", error); + } + return; + } + + // 기존 AI 채팅 로직 const updatedMessages: Message[] = [ ...messages, { role: "user", content: messageToSend } ]; setMessages(updatedMessages); - setInput(""); try { const url = @@ -145,38 +282,102 @@ const Chat = () => { return ( <> - - - + + +
+ + {/* topicChat 연결 상태 표시 */} + {isTopicChat && ( +
+ {isConnected ? "실시간 연결됨" : "연결 중..."} +
+ )} +
{/* 메시지 리스트 */} - {messages.map((msg, idx) => ( -
- {/* 캐릭터 아이콘 */} - {msg.role === "assistant" && ( - MBTI ICON - )} - {/* 채팅 메시지 */} -
- + {messages.map((msg, idx) => { + // 시스템 메시지 처리 + if (msg.messageType === "system") { + return ( +
+ + {msg.content} + +
+ ); + } + + return ( +
+ {/* 캐릭터 아이콘 또는 사용자 정보 */} + {msg.role === "assistant" && ( +
+ {isTopicChat && msg.nickname ? ( +
+
+ {msg.nickname.charAt(0)} +
+ {msg.mbti && ( + + {msg.mbti} + + )} +
+ ) : ( + MBTI ICON + )} +
+ )} + {/* 채팅 메시지 */} +
+ {isTopicChat && msg.role === "assistant" && msg.nickname && ( +
+ {msg.nickname} +
+ )} + +
-
- ))} + ); + })}
diff --git a/src/pages/SelectInfo.tsx b/src/pages/SelectInfo.tsx index 3d866b7..219ccaa 100644 --- a/src/pages/SelectInfo.tsx +++ b/src/pages/SelectInfo.tsx @@ -7,6 +7,7 @@ import { getMBTIgroup, mapAgeToNumber } from "@/utils/helpers"; import { authInstance } from "@/api/axios"; import ToastMessage from "@/components/ToastMessage"; import trackClickEvent from "@/utils/trackClickEvent"; +import { Mbti } from "@/types/mbti"; type FastFriendResponse = { header: { @@ -43,11 +44,17 @@ function isVirtualFriendResponse( const SelectInfo = () => { const navigate = useNavigate(); const location = useLocation(); - const { type, mbti: testResultMBTI, chatTitle, description } = location.state; // type: fastFriend, virtualFriend, topicChat + const { + type, + mbti: testResultMBTI, + chatTitle, + description, + openChatId + } = location.state; // type: fastFriend, virtualFriend, topicChat const isFastFriend = type === "fastFriend"; const isVirtualFriend = type === "virtualFriend"; const isTopicChat = type === "topicChat"; - const isNameRequired = isVirtualFriend; + const isNameRequired = isVirtualFriend || isTopicChat; const headerTitle = isTopicChat ? "내 정보입력" @@ -82,6 +89,7 @@ const SelectInfo = () => { const [job, setJob] = useState(null); const [freeSetting, setFreeSetting] = useState(""); const [toastMessage, setToastMessage] = useState(null); + const [isCheckingNickname, setIsCheckingNickname] = useState(false); useEffect(() => { if (mbtiTestResult && mbtiTestResult.length === 4) { @@ -142,27 +150,61 @@ const SelectInfo = () => { setTimeout(() => setToastMessage(null), 3000); }; + const checkNicknameAvailability = async ( + nickname: string + ): Promise => { + if (!openChatId) return true; + + try { + // 임시로 랜덤하게 중복 검사 (실제 WebSocket 없이) + // 실제로는 websocketService.checkNickname을 사용 + const isAvailable = Math.random() > 0.3; // 70% 확률로 사용 가능 + return isAvailable; + } catch (error) { + console.error("Nickname check failed:", error); + return false; + } + }; + const handleConfirmButton = async () => { const isMBTIComplete = Object.values(selectedMBTI).every( (val) => val !== null ); - // topicChat일 때는 이름만 필수 + // topicChat일 때 처리 if (isTopicChat) { - if (!name) { - return showToast("이름을 입력해주세요"); + if (!name.trim()) { + return showToast("닉네임을 입력해주세요"); + } + + if (!isMBTIComplete) { + return showToast("MBTI를 선택해주세요"); } - // topicChat은 바로 채팅으로 이동 + + // 닉네임 중복 검사 + setIsCheckingNickname(true); + const isNicknameAvailable = await checkNicknameAvailability(name.trim()); + setIsCheckingNickname(false); + + if (!isNicknameAvailable) { + return showToast( + "이미 사용 중인 닉네임입니다. 다른 닉네임을 입력해주세요." + ); + } + + // 오픈 채팅방으로 이동 + const mbti = + `${selectedMBTI.E}${selectedMBTI.N}${selectedMBTI.F}${selectedMBTI.P}` as Mbti; trackClickEvent("오픈채팅 - 내 정보 입력", "대화 시작하기"); navigate("/chat", { - // FIXME: 추후 수정 필요 (오픈 채팅 기능) state: { - mbti: "ENFP", // 기본 MBTI 또는 선택된 MBTI mode: "topicChat", - id: Date.now().toString(), - name, + mbti, + id: openChatId.toString(), chatTitle, - description + description, + openChatId, + nickname: name.trim() } }); return; @@ -394,17 +436,18 @@ const SelectInfo = () => { )} - {/* topicChat일 때만 이름 입력 필드 표시 */} + {/* topicChat일 때만 닉네임 입력 필드 표시 */} {isTopicChat && (
- {/* 이름 입력 */} + {/* 닉네임 입력 */}
{ value={name} onChange={handleNameChange} className="h-[56px] w-full rounded-lg border border-gray-200 px-4 focus:border-primary-light focus:ring-primary-light focus:outline-none" - placeholder="이름" + placeholder="채팅방에서 사용할 닉네임" maxLength={6} /> +

+ 채팅방에서 다른 사람들에게 표시될 이름입니다. +

@@ -430,10 +476,11 @@ const SelectInfo = () => { {/* 대화 시작 버튼 */}
diff --git a/src/services/websocket.ts b/src/services/websocket.ts new file mode 100644 index 0000000..4fa57c8 --- /dev/null +++ b/src/services/websocket.ts @@ -0,0 +1,202 @@ +import { + WebSocketMessage, + OpenChatMessage, + ChatParticipant +} from "@/types/openChat"; +import { Mbti } from "@/types/mbti"; + +export interface WebSocketConfig { + nickname: string; + mbti: Mbti; + openChatId: number; +} + +export class OpenChatWebSocket { + private ws: WebSocket | null = null; + private config: WebSocketConfig | null = null; + private reconnectAttempts = 0; + private maxReconnectAttempts = 5; + private reconnectDelay = 1000; + private messageHandlers = new Set<(message: WebSocketMessage) => void>(); + private connectionHandlers = new Set<(connected: boolean) => void>(); + + constructor(private serverUrl: string) {} + + connect(config: WebSocketConfig): Promise { + return new Promise((resolve, reject) => { + this.config = config; + const wsUrl = `${this.serverUrl}/ws/chats?nickname=${encodeURIComponent(config.nickname)}&mbti=${config.mbti}&open_chat_id=${config.openChatId}`; + + try { + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + console.log("WebSocket connected"); + this.reconnectAttempts = 0; + this.notifyConnectionHandlers(true); + resolve(true); + }; + + this.ws.onmessage = (event) => { + try { + const message: WebSocketMessage = JSON.parse(event.data); + this.notifyMessageHandlers(message); + } catch (error) { + console.error("Failed to parse WebSocket message:", error); + } + }; + + this.ws.onclose = (event) => { + console.log("WebSocket closed:", event.code, event.reason); + this.notifyConnectionHandlers(false); + + if (!event.wasClean && this.shouldReconnect()) { + this.scheduleReconnect(); + } + }; + + this.ws.onerror = (error) => { + console.error("WebSocket error:", error); + reject(new Error("Failed to connect to chat server")); + }; + } catch (error) { + reject(error); + } + }); + } + + disconnect() { + if (this.ws) { + this.ws.close(1000, "User disconnected"); + this.ws = null; + } + this.config = null; + this.reconnectAttempts = 0; + } + + sendMessage(content: string) { + if (!this.ws || this.ws.readyState !== WebSocket.OPEN) { + throw new Error("WebSocket is not connected"); + } + + const message = { + type: "message", + data: { + content, + timestamp: new Date().toISOString() + } + }; + + this.ws.send(JSON.stringify(message)); + } + + checkNickname(nickname: string, openChatId: number): Promise { + return new Promise((resolve, reject) => { + if (!this.config) { + reject(new Error("WebSocket not configured")); + return; + } + + const tempWs = new WebSocket( + `${this.serverUrl}/ws/chats?nickname=${encodeURIComponent(nickname)}&mbti=${this.config.mbti}&open_chat_id=${openChatId}&check_only=true` + ); + + const timeout = setTimeout(() => { + tempWs.close(); + reject(new Error("Nickname check timeout")); + }, 5000); + + tempWs.onmessage = (event) => { + try { + const message: WebSocketMessage = JSON.parse(event.data); + clearTimeout(timeout); + + if (message.type === "nickname_check") { + resolve(message.data.nicknameAvailable ?? false); + } else if (message.type === "error") { + resolve(false); + } + + tempWs.close(); + } catch (error) { + clearTimeout(timeout); + reject(error); + tempWs.close(); + } + }; + + tempWs.onerror = () => { + clearTimeout(timeout); + reject(new Error("Failed to check nickname")); + }; + }); + } + + onMessage(handler: (message: WebSocketMessage) => void) { + this.messageHandlers.add(handler); + + return () => { + this.messageHandlers.delete(handler); + }; + } + + onConnectionChange(handler: (connected: boolean) => void) { + this.connectionHandlers.add(handler); + + return () => { + this.connectionHandlers.delete(handler); + }; + } + + isConnected(): boolean { + return this.ws?.readyState === WebSocket.OPEN; + } + + private shouldReconnect(): boolean { + return ( + this.reconnectAttempts < this.maxReconnectAttempts && this.config !== null + ); + } + + private scheduleReconnect() { + setTimeout( + () => { + if (this.config && this.shouldReconnect()) { + this.reconnectAttempts++; + console.log( + `Attempting to reconnect (${this.reconnectAttempts}/${this.maxReconnectAttempts})` + ); + this.connect(this.config); + } + }, + this.reconnectDelay * Math.pow(2, this.reconnectAttempts) + ); + } + + private notifyMessageHandlers(message: WebSocketMessage) { + this.messageHandlers.forEach((handler) => { + try { + handler(message); + } catch (error) { + console.error("Error in message handler:", error); + } + }); + } + + private notifyConnectionHandlers(connected: boolean) { + this.connectionHandlers.forEach((handler) => { + try { + handler(connected); + } catch (error) { + console.error("Error in connection handler:", error); + } + }); + } +} + +// Singleton instance +const websocketService = new OpenChatWebSocket( + import.meta.env.VITE_WEBSOCKET_URL || "ws://localhost:8080" +); + +export default websocketService; diff --git a/src/types/openChat.ts b/src/types/openChat.ts new file mode 100644 index 0000000..16a63f3 --- /dev/null +++ b/src/types/openChat.ts @@ -0,0 +1,71 @@ +export interface OpenChatRoom { + id: number; + title: string; + description: string; + imageUrl?: string; + participantCount: number; + maxParticipants?: number; + createdAt: string; + updatedAt: string; +} + +export interface OpenChatMessage { + id: number; + openChatId: number; + nickname: string; + mbti: string; + content: string; + timestamp: string; + messageType: "text" | "image" | "system"; +} + +export interface ChatParticipant { + nickname: string; + mbti: string; + joinedAt: string; +} + +export interface WebSocketMessage { + type: "message" | "join" | "leave" | "nickname_check" | "error"; + data: { + message?: OpenChatMessage; + participant?: ChatParticipant; + error?: string; + nicknameAvailable?: boolean; + }; +} + +export interface CreateOpenChatRequest { + title: string; + description: string; + imageUrl?: string; +} + +export interface OpenChatRoomsResponse { + header: { + code: number; + message: string; + }; + data: OpenChatRoom[]; +} + +export interface OpenChatMessagesResponse { + header: { + code: number; + message: string; + }; + data: { + messages: OpenChatMessage[]; + hasMore: boolean; + }; +} + +export interface CreateOpenChatResponse { + header: { + code: number; + message: string; + }; + data: { + openChatId: number; + }; +} From 7503239f4214e81e3065ec549ae265dc38a62a91 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=95=9C=EC=9E=88=EB=8A=94-=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=EB=84=A4=EC=9E=84?= Date: Thu, 11 Sep 2025 16:44:42 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20=EB=B3=80=EA=B2=BD=EB=90=9C=20api?= =?UTF-8?q?=20=EC=A0=81=EC=9A=A9=20=EC=9E=91=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Profile.tsx | 3 +- src/components/TopicProfileContainer.tsx | 81 ++++++++++++++++++++---- src/pages/Chat.tsx | 20 +++--- src/pages/SelectInfo.tsx | 23 +++---- src/services/websocket.ts | 8 +-- tsconfig.app.json | 3 +- vite.config.ts | 4 ++ 7 files changed, 101 insertions(+), 41 deletions(-) diff --git a/src/components/Profile.tsx b/src/components/Profile.tsx index 7972469..d580e7f 100644 --- a/src/components/Profile.tsx +++ b/src/components/Profile.tsx @@ -13,6 +13,7 @@ interface ProfileProps { chatTitle: string; description: string; image: string; + openChatId?: number; }; } const Profile = ({ @@ -56,7 +57,7 @@ const Profile = ({ type: "topicChat", chatTitle: topicData.chatTitle, description: topicData.description, - openChatId: topicData.chatTitle === "N의 대화" ? 1 : 2 // 임시 ID + openChatId: topicData.openChatId || 1 } }); } diff --git a/src/components/TopicProfileContainer.tsx b/src/components/TopicProfileContainer.tsx index c76d331..3266864 100644 --- a/src/components/TopicProfileContainer.tsx +++ b/src/components/TopicProfileContainer.tsx @@ -1,25 +1,82 @@ +import { useEffect, useState } from "react"; import Profile from "@/components/Profile"; +import { getOpenChatRooms } from "@/api/openChat"; +import { OpenChatRoom } from "@/types/openChat"; type TopicData = { chatTitle: string; description: string; image: string; + openChatId: number; }; -const topicData: TopicData[] = [ - { - chatTitle: "N의 대화", - description: "망상력 N% 대화방", - image: "/image/N의_대화.svg" - }, - { - chatTitle: "F의 대화", - description: "F 감성 대화방", - image: "/image/F의_대화.svg" +const TopicProfileContainer = () => { + const [topicData, setTopicData] = useState([]); + const [isLoading, setIsLoading] = useState(true); + + useEffect(() => { + const loadOpenChatRooms = async () => { + try { + const rooms = await getOpenChatRooms(); + const convertedData: TopicData[] = rooms.map((room: OpenChatRoom) => ({ + chatTitle: room.title, + description: room.description, + image: room.imageUrl || "/image/N의_대화.svg", + openChatId: room.id + })); + + if (convertedData.length !== 0) { + setTopicData(convertedData); + } else { + setTopicData([ + { + chatTitle: "N의 대화", + description: "망상력 N% 대화방", + image: "/image/N의_대화.svg", + openChatId: 1 + }, + { + chatTitle: "F의 대화", + description: "F 감성 대화방", + image: "/image/F의_대화.svg", + openChatId: 2 + } + ]); + } + } catch (error) { + console.error("Failed to load open chat rooms:", error); + // 오류 시 기본값 사용 + setTopicData([ + { + chatTitle: "N의 대화", + description: "망상력 N% 대화방", + image: "/image/N의_대화.svg", + openChatId: 1 + }, + { + chatTitle: "F의 대화", + description: "F 감성 대화방", + image: "/image/F의_대화.svg", + openChatId: 2 + } + ]); + } finally { + setIsLoading(false); + } + }; + + loadOpenChatRooms(); + }, []); + + if (isLoading) { + return ( +
+
+
+
+ ); } -]; -const TopicProfileContainer = () => { return (
{topicData.map((topic, index) => ( diff --git a/src/pages/Chat.tsx b/src/pages/Chat.tsx index c4c5eb7..664ec0d 100644 --- a/src/pages/Chat.tsx +++ b/src/pages/Chat.tsx @@ -87,7 +87,7 @@ const Chat = () => { })); setMessages(convertedMessages.reverse()); - // WebSocket 연결 (임시 비활성화) + // WebSocket 연결 (서버 준비 시 활성화) // await websocketService.connect({ // nickname, // mbti: mbti as Mbti, @@ -96,7 +96,7 @@ const Chat = () => { // websocketService.onMessage(handleWebSocketMessage); // websocketService.onConnectionChange(setIsConnected); - setIsConnected(true); // 임시로 연결된 것으로 처리 + setIsConnected(true); // 임시로 연결됨으로 표시 } catch (error) { console.error("오픈채팅 초기화 실패:", error); } @@ -206,30 +206,30 @@ const Chat = () => { setInput(""); if (isTopicChat) { - // 오픈채팅 WebSocket 전송 (임시 비활성화) + // 오픈채팅 WebSocket 전송 (서버 준비 시 활성화) try { // if (websocketService.isConnected()) { // websocketService.sendMessage(messageToSend.trim()); // } - // 임시로 메시지를 바로 추가 (실제 WebSocket 없이) - const newMessage: Message = { + // 임시: 실제 서버 없이도 정상 작동하도록 mock 구현 + const userMessage: Message = { role: "user", content: messageToSend, nickname, mbti: mbti as string }; - setMessages((prev) => [...prev, newMessage]); + setMessages((prev) => [...prev, userMessage]); - // 임시 응답 메시지 추가 + // Mock 응답 setTimeout(() => { - const responseMessage: Message = { + const mockResponse: Message = { role: "assistant", - content: "실시간 채팅 기능이 곧 활성화될 예정입니다!", + content: `${nickname}님의 메시지를 받았습니다! 서버 연결 후 실시간 채팅이 활성화됩니다.`, nickname: "시스템", mbti: "ENFP" }; - setMessages((prev) => [...prev, responseMessage]); + setMessages((prev) => [...prev, mockResponse]); }, 1000); } catch (error) { console.error("메시지 전송 실패:", error); diff --git a/src/pages/SelectInfo.tsx b/src/pages/SelectInfo.tsx index 219ccaa..d944ad4 100644 --- a/src/pages/SelectInfo.tsx +++ b/src/pages/SelectInfo.tsx @@ -8,6 +8,7 @@ import { authInstance } from "@/api/axios"; import ToastMessage from "@/components/ToastMessage"; import trackClickEvent from "@/utils/trackClickEvent"; import { Mbti } from "@/types/mbti"; +// import websocketService from "@/services/websocket"; type FastFriendResponse = { header: { @@ -151,15 +152,20 @@ const SelectInfo = () => { }; const checkNicknameAvailability = async ( - nickname: string + nicknameToCheck: string ): Promise => { if (!openChatId) return true; try { - // 임시로 랜덤하게 중복 검사 (실제 WebSocket 없이) - // 실제로는 websocketService.checkNickname을 사용 - const isAvailable = Math.random() > 0.3; // 70% 확률로 사용 가능 - return isAvailable; + // WebSocket 닉네임 중복 검사 (서버 준비 시 활성화) + // return await websocketService.checkNickname(nicknameToCheck, openChatId); + + // 임시: 실제 서버 없이도 정상 작동하도록 mock 구현 + await new Promise((resolve) => setTimeout(resolve, 1000)); + console.log( + `Checking nickname: ${nicknameToCheck} for chatId: ${openChatId}` + ); + return Math.random() > 0.3; // 70% 확률로 사용 가능 } catch (error) { console.error("Nickname check failed:", error); return false; @@ -187,9 +193,7 @@ const SelectInfo = () => { setIsCheckingNickname(false); if (!isNicknameAvailable) { - return showToast( - "이미 사용 중인 닉네임입니다. 다른 닉네임을 입력해주세요." - ); + return showToast("이미 사용 중인 닉네임입니다"); } // 오픈 채팅방으로 이동 @@ -458,9 +462,6 @@ const SelectInfo = () => { placeholder="채팅방에서 사용할 닉네임" maxLength={6} /> -

- 채팅방에서 다른 사람들에게 표시될 이름입니다. -

diff --git a/src/services/websocket.ts b/src/services/websocket.ts index 4fa57c8..2d1576d 100644 --- a/src/services/websocket.ts +++ b/src/services/websocket.ts @@ -1,8 +1,4 @@ -import { - WebSocketMessage, - OpenChatMessage, - ChatParticipant -} from "@/types/openChat"; +import { WebSocketMessage } from "@/types/openChat"; import { Mbti } from "@/types/mbti"; export interface WebSocketConfig { @@ -25,6 +21,7 @@ export class OpenChatWebSocket { connect(config: WebSocketConfig): Promise { return new Promise((resolve, reject) => { this.config = config; + console.log("this.serverUrl", this.serverUrl); const wsUrl = `${this.serverUrl}/ws/chats?nickname=${encodeURIComponent(config.nickname)}&mbti=${config.mbti}&open_chat_id=${config.openChatId}`; try { @@ -194,7 +191,6 @@ export class OpenChatWebSocket { } } -// Singleton instance const websocketService = new OpenChatWebSocket( import.meta.env.VITE_WEBSOCKET_URL || "ws://localhost:8080" ); diff --git a/tsconfig.app.json b/tsconfig.app.json index 225e483..74b17be 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -10,7 +10,8 @@ "@/types/*": ["types/*"], "@/utils/*": ["utils/*"], "@/constants/*": ["constants/*"], - "@/libs/*": ["libs/*"] + "@/libs/*": ["libs/*"], + "@/services/*": ["services/*"] }, "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", "target": "ES2020", diff --git a/vite.config.ts b/vite.config.ts index 72847d0..2886227 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -58,6 +58,10 @@ export default defineConfig(({ mode }: { mode: string }) => { { find: "@/mock", replacement: path.resolve(__dirname, "src/mock") + }, + { + find: "@/services", + replacement: path.resolve(__dirname, "src/services") } ] } From 511e2802da8700a64d2f95d7cea93a527702f076 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=95=9C=EC=9E=88=EB=8A=94-=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=EB=84=A4=EC=9E=84?= Date: Thu, 11 Sep 2025 17:24:39 +0900 Subject: [PATCH 3/7] =?UTF-8?q?fix:=20=EC=9B=B9=EC=86=8C=EC=BC=93=20?= =?UTF-8?q?=ED=99=9C=EC=84=B1=ED=99=94=20=EB=B0=8F=20=EC=A3=BC=EC=84=9D=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/api/openChat.ts | 12 ++- src/pages/Chat.tsx | 153 +++++++++++++++++++++++++++----------- src/pages/SelectInfo.tsx | 24 ++++-- src/services/websocket.ts | 14 ++-- 4 files changed, 144 insertions(+), 59 deletions(-) diff --git a/src/api/openChat.ts b/src/api/openChat.ts index f650b35..5d2f3db 100644 --- a/src/api/openChat.ts +++ b/src/api/openChat.ts @@ -38,10 +38,18 @@ export const getOpenChatMessages = async ( const response = await authInstance.get( `/api/open-chat/${openChatId}${params}` ); - return response.data.data; + + // API 응답 데이터 검증 + if (response.data && response.data.data) { + return response.data.data; + } else { + console.warn("API 응답 데이터가 예상 형식과 다름:", response.data); + return { messages: [], hasMore: false }; + } } catch (error) { console.error("Failed to fetch open chat messages:", error); - throw new Error("채팅 메시지를 불러올 수 없습니다."); + // 오류 발생 시 빈 데이터 반환 (앱이 중단되지 않도록) + return { messages: [], hasMore: false }; } }; diff --git a/src/pages/Chat.tsx b/src/pages/Chat.tsx index 664ec0d..eb38344 100644 --- a/src/pages/Chat.tsx +++ b/src/pages/Chat.tsx @@ -9,7 +9,7 @@ import ChatMessage from "@/components/ChatMessage"; import ChatActionBar from "@/components/ChatActionBar"; import TipsMenuContainer from "@/components/tips/TipsMenuContainer"; import pickMbtiImage from "@/utils/pickMbtiImage"; -// import websocketService from "@/services/websocket"; +import websocketService from "@/services/websocket"; import { getOpenChatMessages } from "@/api/openChat"; import { WebSocketMessage } from "@/types/openChat"; import { Mbti } from "@/types/mbti"; @@ -38,6 +38,8 @@ interface ChatHistoryResponse { const Chat = () => { const navigate = useNavigate(); const { state } = useLocation(); + console.log("💬 Chat 컴포넌트 시작, state:", state); + const { mbti, mode, @@ -63,40 +65,89 @@ const Chat = () => { const isTopicChat = mode === "topicChat"; useEffect(() => { + console.log("🔍 useEffect 실행", { + isTopicChat, + openChatId, + nickname, + mbti + }); + if (!isTopicChat) { + console.log("❌ topicChat 모드가 아님"); return; } // topicChat 유효성 검증 if (!openChatId || !nickname || !mbti) { + console.log("❌ 필수 데이터 누락:", { openChatId, nickname, mbti }); navigate("/"); return; } const initializeOpenChat = async () => { + console.log("🚀 initializeOpenChat 시작", { openChatId, nickname, mbti }); try { // 기존 메시지 로드 - const { messages: openChatMessages } = - await getOpenChatMessages(openChatId); - const convertedMessages: Message[] = openChatMessages.map((msg) => ({ - role: msg.nickname === nickname ? "user" : "assistant", - content: msg.content, - nickname: msg.nickname, - mbti: msg.mbti, - messageType: msg.messageType - })); - setMessages(convertedMessages.reverse()); - - // WebSocket 연결 (서버 준비 시 활성화) - // await websocketService.connect({ - // nickname, - // mbti: mbti as Mbti, - // openChatId - // }); - - // websocketService.onMessage(handleWebSocketMessage); - // websocketService.onConnectionChange(setIsConnected); - setIsConnected(true); // 임시로 연결됨으로 표시 + console.log("📥 메시지 로드 시작..."); + try { + const response = await getOpenChatMessages(openChatId); + console.log("📥 API 응답:", response); + + if (response && response.messages) { + console.log("📥 메시지 로드 완료:", response.messages.length, "개"); + const convertedMessages: Message[] = response.messages.map( + (msg) => ({ + role: msg.nickname === nickname ? "user" : "assistant", + content: msg.content, + nickname: msg.nickname, + mbti: msg.mbti, + messageType: msg.messageType + }) + ); + setMessages(convertedMessages.reverse()); + } else { + console.log("📥 메시지가 없거나 API 응답 형식이 잘못됨"); + setMessages([]); + } + } catch (apiError) { + console.warn("📥 메시지 로드 실패, 빈 배열로 시작:", apiError); + setMessages([]); + } + + // WebSocket 연결 시도 + const wsUrl = + import.meta.env.VITE_WEBSOCKET_URL || "ws://localhost:8080"; + console.log( + "🔗 WebSocket 연결 시도:", + wsUrl, + nickname, + mbti, + openChatId + ); + + try { + console.log("hei"); + const connected = await websocketService.connect({ + nickname, + mbti: mbti as Mbti, + openChatId + }); + + if (connected) { + console.log("✅ WebSocket 연결 성공"); + websocketService.onMessage(handleWebSocketMessage); + websocketService.onConnectionChange(setIsConnected); + setIsConnected(true); + } else { + console.log("❌ WebSocket 연결 실패, Mock 모드로 동작"); + setIsConnected(false); + } + } catch (wsError) { + console.warn("WebSocket 연결 실패:", wsError); + console.log("📍 연결 시도한 URL:", wsUrl); + console.log("🔧 Mock 모드로 전환합니다"); + setIsConnected(false); + } } catch (error) { console.error("오픈채팅 초기화 실패:", error); } @@ -105,8 +156,9 @@ const Chat = () => { initializeOpenChat(); return () => { - if (isTopicChat) { - // websocketService.disconnect(); + if (isTopicChat && websocketService.isConnected()) { + console.log("🔌 WebSocket 연결 해제"); + websocketService.disconnect(); } }; }, [isTopicChat, openChatId, nickname, mbti, navigate]); @@ -206,33 +258,46 @@ const Chat = () => { setInput(""); if (isTopicChat) { - // 오픈채팅 WebSocket 전송 (서버 준비 시 활성화) + // 사용자 메시지를 즉시 화면에 표시 + const userMessage: Message = { + role: "user", + content: messageToSend, + nickname, + mbti: mbti as string + }; + setMessages((prev) => [...prev, userMessage]); + + // 오픈채팅 WebSocket 전송 try { - // if (websocketService.isConnected()) { - // websocketService.sendMessage(messageToSend.trim()); - // } - - // 임시: 실제 서버 없이도 정상 작동하도록 mock 구현 - const userMessage: Message = { - role: "user", - content: messageToSend, - nickname, - mbti: mbti as string - }; - setMessages((prev) => [...prev, userMessage]); - - // Mock 응답 + if (websocketService.isConnected()) { + // 실제 WebSocket으로 메시지 전송 + websocketService.sendMessage(messageToSend.trim()); + console.log("✅ WebSocket으로 메시지 전송:", messageToSend); + } else { + console.log("❌ WebSocket 연결되지 않음, Mock 응답 사용"); + // WebSocket이 연결되지 않은 경우 Mock 응답 + setTimeout(() => { + const mockResponse: Message = { + role: "assistant", + content: `[Mock] ${nickname}님의 메시지를 받았습니다! "${messageToSend}"에 대한 응답입니다.`, + nickname: "시스템", + mbti: "ENFP" + }; + setMessages((prev) => [...prev, mockResponse]); + }, 1000); + } + } catch (error) { + console.error("메시지 전송 실패:", error); + // 오류 발생 시 Mock 응답 setTimeout(() => { - const mockResponse: Message = { + const errorResponse: Message = { role: "assistant", - content: `${nickname}님의 메시지를 받았습니다! 서버 연결 후 실시간 채팅이 활성화됩니다.`, + content: `메시지 전송에 실패했습니다. Mock 응답으로 대체합니다.`, nickname: "시스템", mbti: "ENFP" }; - setMessages((prev) => [...prev, mockResponse]); + setMessages((prev) => [...prev, errorResponse]); }, 1000); - } catch (error) { - console.error("메시지 전송 실패:", error); } return; } diff --git a/src/pages/SelectInfo.tsx b/src/pages/SelectInfo.tsx index d944ad4..16b97fb 100644 --- a/src/pages/SelectInfo.tsx +++ b/src/pages/SelectInfo.tsx @@ -8,7 +8,7 @@ import { authInstance } from "@/api/axios"; import ToastMessage from "@/components/ToastMessage"; import trackClickEvent from "@/utils/trackClickEvent"; import { Mbti } from "@/types/mbti"; -// import websocketService from "@/services/websocket"; +import websocketService from "@/services/websocket"; type FastFriendResponse = { header: { @@ -157,18 +157,28 @@ const SelectInfo = () => { if (!openChatId) return true; try { + // 현재 선택된 MBTI 조합 생성 + const mbti = + `${selectedMBTI.E}${selectedMBTI.N}${selectedMBTI.F}${selectedMBTI.P}` as Mbti; + // WebSocket 닉네임 중복 검사 (서버 준비 시 활성화) - // return await websocketService.checkNickname(nicknameToCheck, openChatId); + return await websocketService.checkNickname( + nicknameToCheck, + openChatId, + mbti + ); + } catch (error) { + console.warn( + "WebSocket nickname check failed, using mock:", + (error as Error).message + ); - // 임시: 실제 서버 없이도 정상 작동하도록 mock 구현 + // WebSocket 서버가 준비되지 않았거나 연결 실패 시 Mock 구현으로 fallback await new Promise((resolve) => setTimeout(resolve, 1000)); console.log( - `Checking nickname: ${nicknameToCheck} for chatId: ${openChatId}` + `[MOCK] Checking nickname: ${nicknameToCheck} for chatId: ${openChatId}` ); return Math.random() > 0.3; // 70% 확률로 사용 가능 - } catch (error) { - console.error("Nickname check failed:", error); - return false; } }; diff --git a/src/services/websocket.ts b/src/services/websocket.ts index 2d1576d..79bd360 100644 --- a/src/services/websocket.ts +++ b/src/services/websocket.ts @@ -87,15 +87,17 @@ export class OpenChatWebSocket { this.ws.send(JSON.stringify(message)); } - checkNickname(nickname: string, openChatId: number): Promise { + checkNickname( + nickname: string, + openChatId: number, + mbti: Mbti = "ENFP" + ): Promise { return new Promise((resolve, reject) => { - if (!this.config) { - reject(new Error("WebSocket not configured")); - return; - } + // config가 없어도 닉네임 체크는 가능하도록 기본값 사용 + const useMbti = this.config?.mbti || mbti; const tempWs = new WebSocket( - `${this.serverUrl}/ws/chats?nickname=${encodeURIComponent(nickname)}&mbti=${this.config.mbti}&open_chat_id=${openChatId}&check_only=true` + `${this.serverUrl}/ws/chats?nickname=${encodeURIComponent(nickname)}&mbti=${useMbti}&open_chat_id=${openChatId}&check_only=true` ); const timeout = setTimeout(() => { From b82e0b7d5e6b349e0aed01624dc683f826b07924 Mon Sep 17 00:00:00 2001 From: soohyuniii Date: Thu, 11 Sep 2025 23:05:34 +0900 Subject: [PATCH 4/7] =?UTF-8?q?fix:=20=EB=AC=B8=EA=B5=AC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/pages/SelectInfo.tsx | 12 ++++++------ src/services/websocket.ts | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/pages/SelectInfo.tsx b/src/pages/SelectInfo.tsx index 16b97fb..23fa62e 100644 --- a/src/pages/SelectInfo.tsx +++ b/src/pages/SelectInfo.tsx @@ -174,7 +174,7 @@ const SelectInfo = () => { ); // WebSocket 서버가 준비되지 않았거나 연결 실패 시 Mock 구현으로 fallback - await new Promise((resolve) => setTimeout(resolve, 1000)); + await new Promise((resolve) => setTimeout(resolve, 10000)); console.log( `[MOCK] Checking nickname: ${nicknameToCheck} for chatId: ${openChatId}` ); @@ -203,7 +203,7 @@ const SelectInfo = () => { setIsCheckingNickname(false); if (!isNicknameAvailable) { - return showToast("이미 사용 중인 닉네임입니다"); + return showToast("같은 닉네임을 가진 유저가 있어요!"); } // 오픈 채팅방으로 이동 @@ -450,17 +450,17 @@ const SelectInfo = () => { )} - {/* topicChat일 때만 닉네임 입력 필드 표시 */} + {/* topicChat일 때만 이름 입력 필드 표시 */} {isTopicChat && (
- {/* 닉네임 입력 */} + {/* 이름 입력 */}
{ value={name} onChange={handleNameChange} className="h-[56px] w-full rounded-lg border border-gray-200 px-4 focus:border-primary-light focus:ring-primary-light focus:outline-none" - placeholder="채팅방에서 사용할 닉네임" + placeholder="이름" maxLength={6} />
diff --git a/src/services/websocket.ts b/src/services/websocket.ts index 79bd360..9802868 100644 --- a/src/services/websocket.ts +++ b/src/services/websocket.ts @@ -97,7 +97,7 @@ export class OpenChatWebSocket { const useMbti = this.config?.mbti || mbti; const tempWs = new WebSocket( - `${this.serverUrl}/ws/chats?nickname=${encodeURIComponent(nickname)}&mbti=${useMbti}&open_chat_id=${openChatId}&check_only=true` + `${this.serverUrl}/ws/chats?nickname=${encodeURIComponent(nickname)}&mbti=${useMbti}&open_chat_id=${openChatId}` ); const timeout = setTimeout(() => { From 9728a3aba5bcdb35eb679d590c115ff39995a1b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=95=9C=EC=9E=88=EB=8A=94-=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=EB=84=A4=EC=9E=84?= Date: Thu, 18 Sep 2025 14:26:07 +0900 Subject: [PATCH 5/7] fix: add log --- src/pages/SelectInfo.tsx | 21 ++++++++++++++++- src/services/websocket.ts | 47 ++++++++++++++++++++++++++++++++++----- 2 files changed, 62 insertions(+), 6 deletions(-) diff --git a/src/pages/SelectInfo.tsx b/src/pages/SelectInfo.tsx index 23fa62e..df3a944 100644 --- a/src/pages/SelectInfo.tsx +++ b/src/pages/SelectInfo.tsx @@ -156,11 +156,30 @@ const SelectInfo = () => { ): Promise => { if (!openChatId) return true; + // 환경 변수로 WebSocket 사용 여부 체크 + const useWebSocketServer = + import.meta.env.VITE_USE_WEBSOCKET_SERVER !== "false"; + + if (!useWebSocketServer) { + console.log("🔧 WebSocket 서버 사용 안함 (환경 변수), Mock 모드 사용"); + await new Promise((resolve) => setTimeout(resolve, 800)); + console.log( + `[MOCK] Checking nickname: ${nicknameToCheck} for chatId: ${openChatId}` + ); + return Math.random() > 0.3; // 70% 확률로 사용 가능 + } + try { // 현재 선택된 MBTI 조합 생성 const mbti = `${selectedMBTI.E}${selectedMBTI.N}${selectedMBTI.F}${selectedMBTI.P}` as Mbti; + console.log("🔍 WebSocket 닉네임 검사 시작:", { + nicknameToCheck, + openChatId, + mbti + }); + // WebSocket 닉네임 중복 검사 (서버 준비 시 활성화) return await websocketService.checkNickname( nicknameToCheck, @@ -174,7 +193,7 @@ const SelectInfo = () => { ); // WebSocket 서버가 준비되지 않았거나 연결 실패 시 Mock 구현으로 fallback - await new Promise((resolve) => setTimeout(resolve, 10000)); + await new Promise((resolve) => setTimeout(resolve, 800)); console.log( `[MOCK] Checking nickname: ${nicknameToCheck} for chatId: ${openChatId}` ); diff --git a/src/services/websocket.ts b/src/services/websocket.ts index 9802868..732ff9f 100644 --- a/src/services/websocket.ts +++ b/src/services/websocket.ts @@ -95,39 +95,76 @@ export class OpenChatWebSocket { return new Promise((resolve, reject) => { // config가 없어도 닉네임 체크는 가능하도록 기본값 사용 const useMbti = this.config?.mbti || mbti; + const wsUrl = `${this.serverUrl}/ws/chats?nickname=${encodeURIComponent(nickname)}&mbti=${useMbti}&open_chat_id=${openChatId}`; - const tempWs = new WebSocket( - `${this.serverUrl}/ws/chats?nickname=${encodeURIComponent(nickname)}&mbti=${useMbti}&open_chat_id=${openChatId}` - ); + console.log("🔍 닉네임 체크 WebSocket 연결 시도:", wsUrl); + const tempWs = new WebSocket(wsUrl); + + // 연결은 성공하지만 응답이 늦을 수 있으므로 5초로 설정 const timeout = setTimeout(() => { + console.log("⏰ 닉네임 체크 타임아웃 (5초)"); tempWs.close(); reject(new Error("Nickname check timeout")); }, 5000); + tempWs.onopen = () => { + console.log("✅ 닉네임 체크 WebSocket 연결 성공"); + + // 서버에 닉네임 체크 요청 메시지 전송 + const checkMessage = { + type: "nickname_check", + data: { + nickname: nickname, + mbti: useMbti, + openChatId: openChatId + } + }; + + console.log("📤 닉네임 체크 요청 전송:", checkMessage); + tempWs.send(JSON.stringify(checkMessage)); + }; + tempWs.onmessage = (event) => { try { + console.log("📨 닉네임 체크 응답 받음:", event.data); const message: WebSocketMessage = JSON.parse(event.data); clearTimeout(timeout); if (message.type === "nickname_check") { - resolve(message.data.nicknameAvailable ?? false); + const available = message.data.nicknameAvailable ?? false; + console.log("🎯 닉네임 사용 가능:", available); + resolve(available); } else if (message.type === "error") { + console.log("❌ 서버에서 에러 응답"); + resolve(false); + } else { + console.log("❓ 예상치 못한 메시지 타입:", message.type); resolve(false); } tempWs.close(); } catch (error) { + console.error("📨 메시지 파싱 오류:", error); clearTimeout(timeout); reject(error); tempWs.close(); } }; - tempWs.onerror = () => { + tempWs.onerror = (error) => { + console.error("❌ 닉네임 체크 WebSocket 오류:", error); clearTimeout(timeout); reject(new Error("Failed to check nickname")); }; + + tempWs.onclose = (event) => { + console.log( + "🔌 닉네임 체크 WebSocket 연결 종료:", + event.code, + event.reason + ); + }; }); } From c1113925177abed92130ba0353d72405051c2af8 Mon Sep 17 00:00:00 2001 From: soohyuniii Date: Tue, 30 Sep 2025 01:36:34 +0900 Subject: [PATCH 6/7] =?UTF-8?q?fix:=20websocket=20=EC=97=B0=EA=B2=B0?= =?UTF-8?q?=EA=B0=92=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/services/websocket.ts | 38 +++++++++++++++++++++----------------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/src/services/websocket.ts b/src/services/websocket.ts index 732ff9f..851fdcc 100644 --- a/src/services/websocket.ts +++ b/src/services/websocket.ts @@ -102,34 +102,38 @@ export class OpenChatWebSocket { const tempWs = new WebSocket(wsUrl); // 연결은 성공하지만 응답이 늦을 수 있으므로 5초로 설정 - const timeout = setTimeout(() => { - console.log("⏰ 닉네임 체크 타임아웃 (5초)"); - tempWs.close(); - reject(new Error("Nickname check timeout")); - }, 5000); + // const timeout = setTimeout(() => { + // console.log("⏰ 닉네임 체크 타임아웃 (5초)"); + // tempWs.close(); + // reject(new Error("Nickname check timeout")); + // }, 50000); tempWs.onopen = () => { console.log("✅ 닉네임 체크 WebSocket 연결 성공"); // 서버에 닉네임 체크 요청 메시지 전송 - const checkMessage = { - type: "nickname_check", - data: { - nickname: nickname, - mbti: useMbti, - openChatId: openChatId - } + const payload = { + // type: "nickname_check", + // payload: { + type: 1, + nickname: nickname, + message: useMbti, + openChatId: 1 + // } }; - console.log("📤 닉네임 체크 요청 전송:", checkMessage); - tempWs.send(JSON.stringify(checkMessage)); + console.log("📤 닉네임 체크 요청 전송:", payload); + tempWs.send(JSON.stringify(payload)); }; tempWs.onmessage = (event) => { try { + console.log("eee", event); console.log("📨 닉네임 체크 응답 받음:", event.data); + const message: WebSocketMessage = JSON.parse(event.data); - clearTimeout(timeout); + console.log("mm", message); + // clearTimeout(timeout); if (message.type === "nickname_check") { const available = message.data.nicknameAvailable ?? false; @@ -146,7 +150,7 @@ export class OpenChatWebSocket { tempWs.close(); } catch (error) { console.error("📨 메시지 파싱 오류:", error); - clearTimeout(timeout); + // clearTimeout(timeout); reject(error); tempWs.close(); } @@ -154,7 +158,7 @@ export class OpenChatWebSocket { tempWs.onerror = (error) => { console.error("❌ 닉네임 체크 WebSocket 오류:", error); - clearTimeout(timeout); + // clearTimeout(timeout); reject(new Error("Failed to check nickname")); }; From 945b17938893e2dc4e82acfd693a61ba2f597501 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B6=8C=ED=95=9C=EC=9E=88=EB=8A=94-=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=EB=84=A4=EC=9E=84?= Date: Wed, 1 Oct 2025 17:43:22 +0900 Subject: [PATCH 7/7] feat: open chat history --- src/api/openChat.ts | 16 ++-- src/pages/Chat.tsx | 174 +++++++++++++++++++++----------------- src/services/websocket.ts | 93 +++++++++----------- src/types/openChat.ts | 32 ++++--- 4 files changed, 169 insertions(+), 146 deletions(-) diff --git a/src/api/openChat.ts b/src/api/openChat.ts index 5d2f3db..593467f 100644 --- a/src/api/openChat.ts +++ b/src/api/openChat.ts @@ -4,7 +4,6 @@ import { OpenChatMessage, CreateOpenChatRequest, OpenChatRoomsResponse, - OpenChatMessagesResponse, CreateOpenChatResponse } from "@/types/openChat"; @@ -35,13 +34,20 @@ export const getOpenChatMessages = async ( const params = openChatMessageId ? `?openChatMessageId=${openChatMessageId}` : ""; - const response = await authInstance.get( + const response = await authInstance.get<{ data: OpenChatMessage[] }>( `/api/open-chat/${openChatId}${params}` ); - // API 응답 데이터 검증 - if (response.data && response.data.data) { - return response.data.data; + // API 응답 데이터 검증 - 실제 API 구조에 맞게 수정 + if ( + response.data && + response.data.data && + Array.isArray(response.data.data) + ) { + return { + messages: response.data.data, + hasMore: false // 일단 false로 설정, 필요시 서버에서 제공 + }; } else { console.warn("API 응답 데이터가 예상 형식과 다름:", response.data); return { messages: [], hasMore: false }; diff --git a/src/pages/Chat.tsx b/src/pages/Chat.tsx index eb38344..3cf27e7 100644 --- a/src/pages/Chat.tsx +++ b/src/pages/Chat.tsx @@ -38,7 +38,6 @@ interface ChatHistoryResponse { const Chat = () => { const navigate = useNavigate(); const { state } = useLocation(); - console.log("💬 Chat 컴포넌트 시작, state:", state); const { mbti, @@ -56,6 +55,10 @@ const Chat = () => { const [isOpen, setIsOpen] = useState(false); const [isConnected, setIsConnected] = useState(false); const bottomRef = useRef(null); + const wsCleanupRef = useRef<{ + messageCleanup?: () => void; + connectionCleanup?: () => void; + }>({}); const chatTitle = openChatTitle || (name ? `${name}과 대화` : `${mbti}와 대화`); @@ -65,68 +68,53 @@ const Chat = () => { const isTopicChat = mode === "topicChat"; useEffect(() => { - console.log("🔍 useEffect 실행", { - isTopicChat, - openChatId, - nickname, - mbti - }); - if (!isTopicChat) { - console.log("❌ topicChat 모드가 아님"); return; } // topicChat 유효성 검증 if (!openChatId || !nickname || !mbti) { - console.log("❌ 필수 데이터 누락:", { openChatId, nickname, mbti }); navigate("/"); return; } const initializeOpenChat = async () => { - console.log("🚀 initializeOpenChat 시작", { openChatId, nickname, mbti }); try { // 기존 메시지 로드 - console.log("📥 메시지 로드 시작..."); try { const response = await getOpenChatMessages(openChatId); - console.log("📥 API 응답:", response); - if (response && response.messages) { - console.log("📥 메시지 로드 완료:", response.messages.length, "개"); + if ( + response && + response.messages && + Array.isArray(response.messages) + ) { const convertedMessages: Message[] = response.messages.map( - (msg) => ({ - role: msg.nickname === nickname ? "user" : "assistant", - content: msg.content, - nickname: msg.nickname, - mbti: msg.mbti, - messageType: msg.messageType - }) + (msg) => { + return { + role: msg.nickname === nickname ? "user" : "assistant", + content: msg.message, + nickname: msg.nickname, + mbti: msg.mbti || undefined, + messageType: msg.messageType || "text" + }; + } ); + + // 메시지 순서: API에서 최신순으로 오므로 reverse()로 시간순 정렬 setMessages(convertedMessages.reverse()); } else { - console.log("📥 메시지가 없거나 API 응답 형식이 잘못됨"); setMessages([]); } } catch (apiError) { - console.warn("📥 메시지 로드 실패, 빈 배열로 시작:", apiError); setMessages([]); } // WebSocket 연결 시도 const wsUrl = import.meta.env.VITE_WEBSOCKET_URL || "ws://localhost:8080"; - console.log( - "🔗 WebSocket 연결 시도:", - wsUrl, - nickname, - mbti, - openChatId - ); try { - console.log("hei"); const connected = await websocketService.connect({ nickname, mbti: mbti as Mbti, @@ -134,18 +122,25 @@ const Chat = () => { }); if (connected) { - console.log("✅ WebSocket 연결 성공"); - websocketService.onMessage(handleWebSocketMessage); - websocketService.onConnectionChange(setIsConnected); + // 핸들러 등록 시 cleanup 함수들을 저장 + const messageCleanup = websocketService.onMessage( + handleWebSocketMessage + ); + const connectionCleanup = + websocketService.onConnectionChange(setIsConnected); + + // cleanup 함수들을 ref에 저장 + wsCleanupRef.current = { + messageCleanup, + connectionCleanup + }; + setIsConnected(true); } else { - console.log("❌ WebSocket 연결 실패, Mock 모드로 동작"); setIsConnected(false); } } catch (wsError) { console.warn("WebSocket 연결 실패:", wsError); - console.log("📍 연결 시도한 URL:", wsUrl); - console.log("🔧 Mock 모드로 전환합니다"); setIsConnected(false); } } catch (error) { @@ -156,8 +151,18 @@ const Chat = () => { initializeOpenChat(); return () => { + // 웹소켓 핸들러 정리 + if ( + wsCleanupRef.current.messageCleanup || + wsCleanupRef.current.connectionCleanup + ) { + wsCleanupRef.current.messageCleanup?.(); + wsCleanupRef.current.connectionCleanup?.(); + wsCleanupRef.current = {}; + } + + // 웹소켓 연결 해제 if (isTopicChat && websocketService.isConnected()) { - console.log("🔌 WebSocket 연결 해제"); websocketService.disconnect(); } }; @@ -213,42 +218,58 @@ const Chat = () => { }; const handleWebSocketMessage = (wsMessage: WebSocketMessage) => { - switch (wsMessage.type) { - case "message": - if (wsMessage.data.message) { - const newMessage: Message = { - role: - wsMessage.data.message.nickname === nickname - ? "user" - : "assistant", - content: wsMessage.data.message.content, - nickname: wsMessage.data.message.nickname, - mbti: wsMessage.data.message.mbti, - messageType: wsMessage.data.message.messageType - }; - setMessages((prev) => [...prev, newMessage]); - } - break; - case "join": - if (wsMessage.data.participant) { - const systemMessage: Message = { - role: "assistant", - content: `${wsMessage.data.participant.nickname}님이 입장했습니다.`, - messageType: "system" - }; - setMessages((prev) => [...prev, systemMessage]); + if (wsMessage.type === "ERROR") { + // 에러 메시지 처리 + const errorMessage: Message = { + role: "assistant", + content: wsMessage.message, + messageType: "system" + }; + + // 중복 시스템 메시지 방지 + setMessages((prev) => { + const lastMessage = prev[prev.length - 1]; + if ( + lastMessage?.messageType === "system" && + lastMessage.content === wsMessage.message + ) { + return prev; } - break; - case "leave": - if (wsMessage.data.participant) { - const systemMessage: Message = { - role: "assistant", - content: `${wsMessage.data.participant.nickname}님이 퇴장했습니다.`, - messageType: "system" - }; - setMessages((prev) => [...prev, systemMessage]); + return [...prev, errorMessage]; + }); + } else if (wsMessage.type === "NOTICE") { + // 시스템 알림 메시지 처리 (입장/퇴장) + const systemMessage: Message = { + role: "assistant", + content: wsMessage.message, + messageType: "system" + }; + + // 중복 시스템 메시지 방지 + setMessages((prev) => { + const lastMessage = prev[prev.length - 1]; + if ( + lastMessage?.messageType === "system" && + lastMessage.content === wsMessage.message + ) { + return prev; } - break; + return [...prev, systemMessage]; + }); + } else if ( + wsMessage.type === null && + wsMessage.nickname && + wsMessage.message + ) { + // 일반 채팅 메시지 처리 + const newMessage: Message = { + role: wsMessage.nickname === nickname ? "user" : "assistant", + content: wsMessage.message, + nickname: wsMessage.nickname, + mbti: wsMessage.mbti || undefined, + messageType: "text" + }; + setMessages((prev) => [...prev, newMessage]); } }; @@ -272,9 +293,7 @@ const Chat = () => { if (websocketService.isConnected()) { // 실제 WebSocket으로 메시지 전송 websocketService.sendMessage(messageToSend.trim()); - console.log("✅ WebSocket으로 메시지 전송:", messageToSend); } else { - console.log("❌ WebSocket 연결되지 않음, Mock 응답 사용"); // WebSocket이 연결되지 않은 경우 Mock 응답 setTimeout(() => { const mockResponse: Message = { @@ -388,6 +407,7 @@ const Chat = () => {
{/* 메시지 리스트 */} + {messages.map((msg, idx) => { // 시스템 메시지 처리 if (msg.messageType === "system") { @@ -429,9 +449,9 @@ const Chat = () => {
)} {/* 채팅 메시지 */} -
+
{isTopicChat && msg.role === "assistant" && msg.nickname && ( -
+
{msg.nickname}
)} diff --git a/src/services/websocket.ts b/src/services/websocket.ts index 851fdcc..d1ccd63 100644 --- a/src/services/websocket.ts +++ b/src/services/websocket.ts @@ -1,4 +1,4 @@ -import { WebSocketMessage } from "@/types/openChat"; +import { WebSocketMessage, WebSocketRequestMessage } from "@/types/openChat"; import { Mbti } from "@/types/mbti"; export interface WebSocketConfig { @@ -20,15 +20,19 @@ export class OpenChatWebSocket { connect(config: WebSocketConfig): Promise { return new Promise((resolve, reject) => { + // 기존 연결이 있으면 먼저 정리 + if (this.ws && this.ws.readyState !== WebSocket.CLOSED) { + this.disconnect(); + } + this.config = config; - console.log("this.serverUrl", this.serverUrl); + const wsUrl = `${this.serverUrl}/ws/chats?nickname=${encodeURIComponent(config.nickname)}&mbti=${config.mbti}&open_chat_id=${config.openChatId}`; try { this.ws = new WebSocket(wsUrl); this.ws.onopen = () => { - console.log("WebSocket connected"); this.reconnectAttempts = 0; this.notifyConnectionHandlers(true); resolve(true); @@ -44,7 +48,6 @@ export class OpenChatWebSocket { }; this.ws.onclose = (event) => { - console.log("WebSocket closed:", event.code, event.reason); this.notifyConnectionHandlers(false); if (!event.wasClean && this.shouldReconnect()) { @@ -67,6 +70,11 @@ export class OpenChatWebSocket { this.ws.close(1000, "User disconnected"); this.ws = null; } + + // 모든 핸들러 정리 + this.messageHandlers.clear(); + this.connectionHandlers.clear(); + this.config = null; this.reconnectAttempts = 0; } @@ -76,12 +84,16 @@ export class OpenChatWebSocket { throw new Error("WebSocket is not connected"); } - const message = { - type: "message", - data: { - content, - timestamp: new Date().toISOString() - } + if (!this.config) { + throw new Error("WebSocket config is not set"); + } + + const message: WebSocketRequestMessage = { + type: "MESSAGE", + mbti: this.config.mbti, + nickname: this.config.nickname, + message: content, + openChatId: this.config.openChatId }; this.ws.send(JSON.stringify(message)); @@ -97,77 +109,54 @@ export class OpenChatWebSocket { const useMbti = this.config?.mbti || mbti; const wsUrl = `${this.serverUrl}/ws/chats?nickname=${encodeURIComponent(nickname)}&mbti=${useMbti}&open_chat_id=${openChatId}`; - console.log("🔍 닉네임 체크 WebSocket 연결 시도:", wsUrl); - const tempWs = new WebSocket(wsUrl); - // 연결은 성공하지만 응답이 늦을 수 있으므로 5초로 설정 - // const timeout = setTimeout(() => { - // console.log("⏰ 닉네임 체크 타임아웃 (5초)"); - // tempWs.close(); - // reject(new Error("Nickname check timeout")); - // }, 50000); - tempWs.onopen = () => { - console.log("✅ 닉네임 체크 WebSocket 연결 성공"); - // 서버에 닉네임 체크 요청 메시지 전송 - const payload = { - // type: "nickname_check", - // payload: { - type: 1, + const payload: WebSocketRequestMessage = { + type: "NICKNAME_CHECK", + mbti: useMbti, nickname: nickname, - message: useMbti, - openChatId: 1 - // } + message: "", + openChatId: openChatId }; - console.log("📤 닉네임 체크 요청 전송:", payload); tempWs.send(JSON.stringify(payload)); }; tempWs.onmessage = (event) => { try { - console.log("eee", event); - console.log("📨 닉네임 체크 응답 받음:", event.data); - const message: WebSocketMessage = JSON.parse(event.data); - console.log("mm", message); - // clearTimeout(timeout); - - if (message.type === "nickname_check") { - const available = message.data.nicknameAvailable ?? false; - console.log("🎯 닉네임 사용 가능:", available); - resolve(available); - } else if (message.type === "error") { - console.log("❌ 서버에서 에러 응답"); + + // 닉네임 중복시 ERROR 타입으로 응답 + if ( + message.type === "ERROR" && + message.message.includes("닉네임이 중복됩니다") + ) { resolve(false); - } else { - console.log("❓ 예상치 못한 메시지 타입:", message.type); + } else if (message.type === "ERROR") { resolve(false); + } else { + resolve(true); } tempWs.close(); } catch (error) { - console.error("📨 메시지 파싱 오류:", error); - // clearTimeout(timeout); + console.error("Failed to parse WebSocket message:", error); + reject(error); tempWs.close(); } }; tempWs.onerror = (error) => { - console.error("❌ 닉네임 체크 WebSocket 오류:", error); - // clearTimeout(timeout); + console.error("Failed to check nickname:", error); + reject(new Error("Failed to check nickname")); }; tempWs.onclose = (event) => { - console.log( - "🔌 닉네임 체크 WebSocket 연결 종료:", - event.code, - event.reason - ); + console.log("WebSocket closed:", event.code, event.reason); }; }); } diff --git a/src/types/openChat.ts b/src/types/openChat.ts index 16a63f3..2542d99 100644 --- a/src/types/openChat.ts +++ b/src/types/openChat.ts @@ -10,13 +10,13 @@ export interface OpenChatRoom { } export interface OpenChatMessage { - id: number; + openChatMessageId: number; openChatId: number; nickname: string; - mbti: string; - content: string; - timestamp: string; - messageType: "text" | "image" | "system"; + mbti: string | null; + message: string; + timestamp?: string; + messageType?: "text" | "image" | "system"; } export interface ChatParticipant { @@ -25,14 +25,22 @@ export interface ChatParticipant { joinedAt: string; } +// 웹소켓 요청 메시지 형태 +export interface WebSocketRequestMessage { + type: string; + mbti: string; + nickname: string; + message: string; + openChatId: number; +} + +// 웹소켓 응답 메시지 형태 export interface WebSocketMessage { - type: "message" | "join" | "leave" | "nickname_check" | "error"; - data: { - message?: OpenChatMessage; - participant?: ChatParticipant; - error?: string; - nicknameAvailable?: boolean; - }; + type: "ERROR" | "NOTICE" | null; + mbti: string | null; + nickname: string | null; + message: string; + openChatId: number; } export interface CreateOpenChatRequest {