diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..41bb2cf --- /dev/null +++ b/.env.example @@ -0,0 +1,16 @@ +# API 설정 +VITE_API_BASE_URL=http://localhost:8080 +VITE_WS_URL=ws://localhost:8080 + +# Kakao OAuth +VITE_KAKAO_JAVASCRIPT_KEY=your_kakao_key_here + +# Firebase FCM +VITE_FIREBASE_API_KEY=AIzaSyCoYr1H2VwJaLpHA0krtZCOX-dTDGlXZYM +VITE_FIREBASE_AUTH_DOMAIN=itzeep-de0ca.firebaseapp.com +VITE_FIREBASE_PROJECT_ID=itzeep-de0ca +VITE_FIREBASE_STORAGE_BUCKET=itzeep-de0ca.firebasestorage.app +VITE_FIREBASE_MESSAGING_SENDER_ID=966020195845 +VITE_FIREBASE_APP_ID=1:966020195845:web:9532cf29ef4f7108d55e6c +VITE_FIREBASE_MEASUREMENT_ID=G-4MWDC6FYCE +VITE_FIREBASE_VAPID_KEY=BBwhqrm3fd9077YciPjcCv1H7E1rrEbfIko3CwjtE4PlpkY-3PGnV0V1TBUAU_epvIP9ug_ktwaDvxQsYAQobk0 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile index ce4db1e..235c8dc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -16,11 +16,27 @@ COPY . . ARG VITE_API_BASE_URL ARG VITE_WS_URL ARG VITE_KAKAO_JAVASCRIPT_KEY +ARG VITE_FIREBASE_API_KEY +ARG VITE_FIREBASE_AUTH_DOMAIN +ARG VITE_FIREBASE_PROJECT_ID +ARG VITE_FIREBASE_STORAGE_BUCKET +ARG VITE_FIREBASE_MESSAGING_SENDER_ID +ARG VITE_FIREBASE_APP_ID +ARG VITE_FIREBASE_MEASUREMENT_ID +ARG VITE_FIREBASE_VAPID_KEY # 환경 변수 설정 ENV VITE_API_BASE_URL=$VITE_API_BASE_URL ENV VITE_WS_URL=$VITE_WS_URL ENV VITE_KAKAO_JAVASCRIPT_KEY=$VITE_KAKAO_JAVASCRIPT_KEY +ENV VITE_FIREBASE_API_KEY=$VITE_FIREBASE_API_KEY +ENV VITE_FIREBASE_AUTH_DOMAIN=$VITE_FIREBASE_AUTH_DOMAIN +ENV VITE_FIREBASE_PROJECT_ID=$VITE_FIREBASE_PROJECT_ID +ENV VITE_FIREBASE_STORAGE_BUCKET=$VITE_FIREBASE_STORAGE_BUCKET +ENV VITE_FIREBASE_MESSAGING_SENDER_ID=$VITE_FIREBASE_MESSAGING_SENDER_ID +ENV VITE_FIREBASE_APP_ID=$VITE_FIREBASE_APP_ID +ENV VITE_FIREBASE_MEASUREMENT_ID=$VITE_FIREBASE_MEASUREMENT_ID +ENV VITE_FIREBASE_VAPID_KEY=$VITE_FIREBASE_VAPID_KEY # 애플리케이션 빌드 RUN npm run build diff --git a/index.html b/index.html index 5c6cae8..10f9c46 100644 --- a/index.html +++ b/index.html @@ -15,6 +15,12 @@
+ diff --git a/public/400.png b/public/400.png new file mode 100644 index 0000000..e631fd3 Binary files /dev/null and b/public/400.png differ diff --git a/public/404.png b/public/404.png new file mode 100644 index 0000000..37fa98c Binary files /dev/null and b/public/404.png differ diff --git a/public/contract_complete.png b/public/contract_complete.png new file mode 100644 index 0000000..9b9b0c9 Binary files /dev/null and b/public/contract_complete.png differ diff --git a/public/firebase-messaging-sw.js b/public/firebase-messaging-sw.js index 67df76e..34e84d3 100644 --- a/public/firebase-messaging-sw.js +++ b/public/firebase-messaging-sw.js @@ -183,7 +183,9 @@ if (typeof firebase !== 'undefined') { const messaging = firebase.messaging() messaging.onBackgroundMessage(async function (payload) { - console.log('[SW] 백그라운드 메시지 수신:', payload) + console.log('[SW] 🔔🔔🔔 백그라운드 메시지 수신:', payload) + console.log('[SW] 🔔 메시지 타입:', payload.data?.type) + console.log('[SW] 🔔 채팅방 ID:', payload.data?.chatRoomId) try { // 백그라운드 알림을 DB에 저장하지 않음 (백엔드에서 이미 저장됨) diff --git a/public/no-mobile.png b/public/no-mobile.png new file mode 100644 index 0000000..d9b0c95 Binary files /dev/null and b/public/no-mobile.png differ diff --git a/public/panda&lion.mp4 b/public/panda&lion.mp4 new file mode 100644 index 0000000..d3f295e Binary files /dev/null and b/public/panda&lion.mp4 differ diff --git a/src/App.vue b/src/App.vue index be6bfa2..d2df75d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,12 +1,36 @@ diff --git a/src/apis/chatApi.js b/src/apis/chatApi.js index 3e7a3dd..502da17 100644 --- a/src/apis/chatApi.js +++ b/src/apis/chatApi.js @@ -210,6 +210,7 @@ export async function refreshToken() { if (res.data.success && res.data.data.accessToken) { localStorage.setItem('accessToken', res.data.data.accessToken) + localStorage.setItem('access-token', res.data.data.accessToken) // 호환성 유지 return true } diff --git a/src/apis/contractApi.js b/src/apis/contractApi.js index 88ef596..3e55e53 100644 --- a/src/apis/contractApi.js +++ b/src/apis/contractApi.js @@ -1,9 +1,21 @@ import api from './index' const CONTRACT_BASE_URL = '/api/contract' +const CONTRACT_CHAT_BASE_URL = '/api/chat/contract' // 전체 계약서 조회 (오른쪽) export const contractApi = { + // 계약 채팅방 URL로 이동 + moveContractChat: async (chatRoomId) => { + try { + const response = await api.get(`${CONTRACT_CHAT_BASE_URL}/${chatRoomId}/moveContractChat`) + return response.data + } catch (error) { + console.error('계약 채팅방 이동 실패: ', error) + throw error + } + }, + getContractBasic: async (contractChatId) => { try { const response = await api.post(`${CONTRACT_BASE_URL}/${contractChatId}/getContract`) diff --git a/src/apis/contractChatApi.js b/src/apis/contractChatApi.js index 56aa28b..0fed33c 100644 --- a/src/apis/contractChatApi.js +++ b/src/apis/contractChatApi.js @@ -461,10 +461,14 @@ export const updateSignatureStatus = async (contractChatId, signatureData) => { export const getExportStatus = async (contractChatId) => { try { const response = await api.get(`/api/contract/${contractChatId}/export/status`) - return response.data.data + if (response.data && response.data.success) { + return response.data.data + } else { + return null + } } catch (error) { console.error('내보내기 상태 조회 실패:', error) - throw error + return null } } diff --git a/src/apis/index.js b/src/apis/index.js index a7621e0..20d1a71 100644 --- a/src/apis/index.js +++ b/src/apis/index.js @@ -1,4 +1,5 @@ import axios from 'axios' +import { useLoginModal } from '@/composables/useLoginModal' // axios 인스턴스 생성 const api = axios.create({ @@ -11,8 +12,8 @@ const api = axios.create({ // 요청 인터셉터 - 모든 요청에 토큰 추가 api.interceptors.request.use( (config) => { - // localStorage에서 토큰 가져오기 - const token = localStorage.getItem('access-token') + // localStorage에서 토큰 가져오기 (두 가지 키 모두 확인) + const token = localStorage.getItem('accessToken') || localStorage.getItem('access-token') if (token) { // Authorization 헤더에 Bearer 토큰 추가 @@ -33,14 +34,33 @@ api.interceptors.response.use( }, async (error) => { const originalRequest = error.config + const router = window.$router // 라우터 인스턴스는 main.js에서 설정 + + // 에러 응답이 없는 경우 (네트워크 에러 등) + if (!error.response) { + console.error('네트워크 에러:', error.message) + return Promise.reject(error) + } + + const status = error.response.status // 401 Unauthorized 에러 처리 - if (error.response && error.response.status === 401 && !originalRequest._retry) { + if (status === 401 && !originalRequest._retry) { originalRequest._retry = true + // 토큰이 없는 경우 바로 로그인 모달 표시 + const currentToken = localStorage.getItem('accessToken') || localStorage.getItem('access-token') + if (!currentToken) { + console.error('인증 토큰이 없습니다.') + const { openLoginModal } = useLoginModal() + const currentPath = router?.currentRoute.value.fullPath || window.location.pathname + openLoginModal(currentPath) + return Promise.reject(error) + } + try { // 리프레시 토큰으로 새 액세스 토큰 요청 - const refreshToken = localStorage.getItem('refresh-token') + const refreshToken = localStorage.getItem('refreshToken') || localStorage.getItem('refresh-token') if (refreshToken) { const refreshResponse = await axios.post( `${import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080'}/api/auth/refresh`, @@ -49,7 +69,8 @@ api.interceptors.response.use( if (refreshResponse.data.success) { const newAccessToken = refreshResponse.data.data.accessToken - localStorage.setItem('access-token', newAccessToken) + localStorage.setItem('accessToken', newAccessToken) + localStorage.setItem('access-token', newAccessToken) // 호환성 유지 // 실패한 요청을 새 토큰으로 재시도 originalRequest.headers.Authorization = `Bearer ${newAccessToken}` @@ -58,11 +79,56 @@ api.interceptors.response.use( } } catch (refreshError) { console.error('토큰 갱신 실패:', refreshError) - // 로그인 페이지로 리다이렉트 - localStorage.removeItem('access-token') - localStorage.removeItem('refresh-token') - localStorage.removeItem('user') - window.location.href = '/auth/signin' + } + + // 토큰 갱신 실패 또는 리프레시 토큰이 없는 경우 + localStorage.removeItem('accessToken') + localStorage.removeItem('access-token') + localStorage.removeItem('refreshToken') + localStorage.removeItem('refresh-token') + localStorage.removeItem('userInfo') + localStorage.removeItem('user') + + // 로그인 모달 표시 + const { openLoginModal } = useLoginModal() + const currentPath = router?.currentRoute.value.fullPath || window.location.pathname + openLoginModal(currentPath) + } + + // 현재 경로 확인 (ContractCompletePage에서는 페이지 이동 대신 에러 전달) + const currentPath = router?.currentRoute.value.path || window.location.pathname + const isContractCompletePage = currentPath.includes('/contract/') && currentPath.includes('/complete') + + // 404 Not Found + if (status === 404) { + console.error('404 에러:', error.response.data) + if (!isContractCompletePage && router && router.currentRoute.value.name !== 'not-found') { + router.push({ name: 'not-found' }) + } + if (isContractCompletePage) { + return Promise.reject(error) + } + } + + // 403 Forbidden + if (status === 403) { + console.error('403 권한 없음:', error.response.data) + if (!isContractCompletePage && router) { + router.push({ name: 'unauthorized' }) + } + if (isContractCompletePage) { + return Promise.reject(error) + } + } + + // 500 Server Error + if (status >= 500) { + console.error('서버 에러:', error.response.data) + if (!isContractCompletePage && router && router.currentRoute.value.name !== 'server-error') { + router.push({ name: 'server-error' }) + } + if (isContractCompletePage) { + return Promise.reject(error) } } diff --git a/src/apis/mypage.js b/src/apis/mypage.js index 4af8b70..677279b 100644 --- a/src/apis/mypage.js +++ b/src/apis/mypage.js @@ -157,9 +157,9 @@ export const mypageAPI = { }, // 매물 삭제 - deleteProperty: async (propertyId) => { + deleteProperty: async (homeId) => { try { - const response = await api.delete(`/api/mypage/properties/${propertyId}`) + const response = await api.delete(`/api/homes/${homeId}`) return response.data } catch (error) { console.error('매물 삭제 실패:', error) diff --git a/src/apis/websocket.js b/src/apis/websocket.js index 5f07d57..1e3e3b6 100644 --- a/src/apis/websocket.js +++ b/src/apis/websocket.js @@ -37,11 +37,16 @@ class WebSocketService { this.isConnecting.value = true - const socket = new SockJS(import.meta.env.VITE_WS_URL || 'http://localhost:8080/ws') + // 개발 환경에서는 상대 경로 사용 (Vite proxy 활용) + const wsUrl = import.meta.env.DEV + ? '/ws' + : (import.meta.env.VITE_WS_URL || 'http://localhost:8080/ws') + console.log('WebSocket URL:', wsUrl) + const socket = new SockJS(wsUrl) this.stompClient = new Client({ webSocketFactory: () => socket, reconnectDelay: 2000, // 재연결 지연 시간 단축 - debug: (str) => console.log('[STOMP DEBUG]', str), + // debug: (str) => console.log('[STOMP DEBUG]', str), onConnect: (frame) => { console.log('STOMP 연결 성공:', frame) this.isConnected.value = true @@ -82,48 +87,34 @@ class WebSocketService { }) } - async sendMessage(destination, message, retryCount = 30) { - console.log('sendMessage 호출:', { destination, message }) - console.log('STOMP 클라이언트 상태:', { - hasClient: !!this.stompClient, - isConnected: this.stompClient?.connected, - internalConnected: this.isConnected.value, - stompState: this.stompClient?.state, - }) - - // 내부 연결 상태와 STOMP 연결 상태를 모두 확인 - if (!this.stompClient || (!this.stompClient.connected && !this.isConnected.value)) { - console.error('STOMP가 연결되지 않음') - return false + async sendMessage(destination, message, retryCount = 5) { + // STOMP 클라이언트가 없으면 연결 시도 + if (!this.stompClient) { + await this.connect() } - // STOMP 상태가 CONNECTED가 아니면 재시도 - if (this.stompClient.state !== 1 && retryCount > 0) { - // 1 = CONNECTED - console.warn(`STOMP 연결이 완전히 준비되지 않음. 재시도 대기... (남은 시도: ${retryCount})`) - await new Promise((resolve) => setTimeout(resolve, 200)) - return this.sendMessage(destination, message, retryCount - 1) - } else if (this.stompClient.state !== 1) { - console.error('STOMP 연결 실패 - 재시도 횟수 초과') - return false - } + // 연결 상태 확인 + const isReady = this.stompClient?.connected && this.isConnected.value - try { - const payload = { - ...message, + if (!isReady) { + if (retryCount > 0) { + await new Promise((resolve) => setTimeout(resolve, 1000)) + return this.sendMessage(destination, message, retryCount - 1) + } else { + console.error('STOMP 연결 실패 - 재시도 횟수 초과') + return false } + } - console.log('전송할 페이로드:', payload) + try { this.stompClient.publish({ destination, - body: JSON.stringify(payload), + body: JSON.stringify(message), }) - console.log('메시지 전송 성공') return true } catch (error) { console.error('메시지 전송 실패:', error) if (retryCount > 0) { - console.log(`메시지 전송 재시도... (남은 시도: ${retryCount})`) await new Promise((resolve) => setTimeout(resolve, 200)) return this.sendMessage(destination, message, retryCount - 1) } @@ -131,8 +122,8 @@ class WebSocketService { } } - sendChatMessage(chatRoomId, senderId, receiverId, content, type = 'TEXT', fileUrl = null) { - const success = this.sendMessage('/app/chat/send', { + async sendChatMessage(chatRoomId, senderId, receiverId, content, type = 'TEXT', fileUrl = null) { + const success = await this.sendMessage('/app/chat/send', { chatRoomId, senderId, receiverId, @@ -163,8 +154,8 @@ class WebSocketService { }) } - sendContractChatMessage(contractChatId, senderId, receiverId, content, type = 'TEXT') { - const success = this.sendMessage('/app/contract/chat/send', { + async sendContractChatMessage(contractChatId, senderId, receiverId, content, type = 'TEXT') { + const success = await this.sendMessage('/app/contract/chat/send', { contractChatId, senderId, receiverId, @@ -196,16 +187,16 @@ class WebSocketService { } // 계약서 내보내기 관련 메서드 - sendContractExportSignature(contractChatId, signatureData) { - return this.sendMessage(`/app/contract/${contractChatId}/export/signature`, signatureData) + async sendContractExportSignature(contractChatId, signatureData) { + return await this.sendMessage(`/app/contract/${contractChatId}/export/signature`, signatureData) } - sendContractExportPassword(contractChatId, passwordData) { - return this.sendMessage(`/app/contract/${contractChatId}/export/password`, passwordData) + async sendContractExportPassword(contractChatId, passwordData) { + return await this.sendMessage(`/app/contract/${contractChatId}/export/password`, passwordData) } - getContractExportStatus(contractChatId) { - return this.sendMessage(`/app/contract/${contractChatId}/export/status`, {}) + async getContractExportStatus(contractChatId) { + return await this.sendMessage(`/app/contract/${contractChatId}/export/status`, {}) } onMessage(topic, handler) { @@ -245,8 +236,7 @@ class WebSocketService { const subscription = this.stompClient.subscribe(topic, (message) => { try { const data = JSON.parse(message.body) - const result = handler(data) - console.log('핸들러 호출 완료! 결과:', result) + handler(data) } catch (e) { console.error('파싱 실패:', e) console.error('Raw body:', message.body) @@ -292,4 +282,4 @@ class WebSocketService { } export const websocketService = new WebSocketService() -export default websocketService +export default websocketService \ No newline at end of file diff --git a/src/components/alarm/AlarmDropdown.vue b/src/components/alarm/AlarmDropdown.vue index ccd5a50..13d7ab6 100644 --- a/src/components/alarm/AlarmDropdown.vue +++ b/src/components/alarm/AlarmDropdown.vue @@ -187,10 +187,10 @@ const handleNotificationClick = async (notification) => { if (notification.type === 'CHAT' && notification.relatedId) { // 채팅 알림 - 채팅방으로 이동 - targetUrl = `/chat?room=${notification.relatedId}` + targetUrl = `/chat?roomId=${notification.relatedId}` } else if (notification.type.includes('CONTRACT') && notification.relatedId) { - // 계약 관련 알림 - 계약 페이지로 이동 - targetUrl = `/contract/${notification.relatedId}` + // 계약 관련 알림 - 계약 채팅방으로 이동 + targetUrl = `/contract-chat/${notification.relatedId}` } else if (notification.type === 'SYSTEM') { // 시스템 알림 - 알림 목록 페이지로 이동 targetUrl = '/notifications' diff --git a/src/components/chat/chatList/ChatItem.vue b/src/components/chat/chatList/ChatItem.vue index 459e639..d5ef98e 100644 --- a/src/components/chat/chatList/ChatItem.vue +++ b/src/components/chat/chatList/ChatItem.vue @@ -1,7 +1,13 @@