From fc71526d5a6f9e423b376f6fa06205d3f5bbfe09 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=AF=BC=ED=97=8C?= Date: Tue, 7 Jul 2026 17:22:54 +0900 Subject: [PATCH] =?UTF-8?q?feat(chat):=20=EC=A2=85=EB=A3=8C=20=EC=84=B8?= =?UTF-8?q?=EC=85=98=20=EC=A7=84=EC=9E=85=20=EC=B0=A8=EB=8B=A8=20+=20?= =?UTF-8?q?=ED=98=91=EC=83=81=EC=A4=91=20=EC=9D=B8=EB=94=94=EC=BC=80?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20sticky=20=EC=9C=A0=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - init 에서 미참여/거부(4·5) 세션 진입을 NEGO_NOT_PARTICIPABLE 로 차단 (participate/reject 와 동일 규칙). 프론트는 목록으로 복귀 처리. - init 을 '진입 스냅샷'으로 격리: 화면 도중·포커스 재조회 제거, 언마운트 시 캐시 비움 → 정상 종료 후 튕김 방지 + 재진입 시 진입 게이트 재적용. - 인디케이터를 session_status 직접 비교 → wasInProgress sticky 플래그로 전환: 진행 화면에서 실시간 완료돼도 유지, 완료/거부 결과 열람 재진입 시엔 숨김. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/services/chat_service.py | 6 +++++ backend/tests/test_chat.py | 15 +++++++++--- frontend/src/apis/chat/chat.queries.ts | 15 +++++++++--- .../features/chat/components/ChatMessage.tsx | 8 +++---- .../features/chat/hooks/useChatController.ts | 23 ++++++++++++------- .../features/chat/stores/useChatInitStore.ts | 15 ++++++++++-- 6 files changed, 62 insertions(+), 20 deletions(-) diff --git a/backend/services/chat_service.py b/backend/services/chat_service.py index ddcf4f3..ac6ab27 100644 --- a/backend/services/chat_service.py +++ b/backend/services/chat_service.py @@ -198,6 +198,12 @@ class ChatService: ) sess.status = SessionStatus.NOT_PARTICIPATED.value + # 미참여/협상거부 상태는 진입(열람) 불가 (participate/reject 와 동일 규칙). + # 위 마감 변환으로 미참여가 된 세션도 여기서 함께 막힌다. + if sess.status in (SessionStatus.NOT_PARTICIPATED.value, SessionStatus.REJECTED.value): + res.result.SetResult(ErrorType.NEGO_NOT_PARTICIPABLE) + return res + res.session_id = str(sess.session_id) res.session_status = sess.status res.quotation_id = str(sess.quotation_id) diff --git a/backend/tests/test_chat.py b/backend/tests/test_chat.py index 853d4fe..2204447 100644 --- a/backend/tests/test_chat.py +++ b/backend/tests/test_chat.py @@ -356,9 +356,18 @@ async def test_init_marks_expired_created_as_not_participated(client, chat_seed, async with db_engine.begin() as conn: await conn.execute(text("UPDATE quotation.quotations SET end_time = now() - make_interval(hours => 1) WHERE qt_id = :qid"), {"qid": qid}) body = (await _init(client, token, sid)).json() - assert body["result"]["success"] is True - assert body["session_status"] == 4 # 미참여로 정리되어 내려옴 - assert await _session_status(db_engine, sid) == 4 # DB 도 전이됨 + # 마감된 협상생성은 DB 상 미참여로 정리되고, 미참여는 진입 불가라 init 은 에러로 막는다. + assert body["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE + assert await _session_status(db_engine, sid) == 4 # DB 는 미참여로 전이됨 + + +async def test_init_blocks_rejected_session(client, chat_seed, db_engine): + token = await _login_token(client) + sid = chat_seed["sids"]["P"] + async with db_engine.begin() as conn: + await conn.execute(text("UPDATE negotiation.sessions SET status = 5 WHERE session_id = :sid"), {"sid": sid}) # 협상거부 + body = (await _init(client, token, sid)).json() + assert body["result"]["code"] == 1301 # NEGO_NOT_PARTICIPABLE — 거부 세션 진입 차단 # ---- 순수 헬퍼 단위 테스트 (DB 불필요, ChatService @staticmethod) ---------- diff --git a/frontend/src/apis/chat/chat.queries.ts b/frontend/src/apis/chat/chat.queries.ts index 0c996dc..f66783a 100644 --- a/frontend/src/apis/chat/chat.queries.ts +++ b/frontend/src/apis/chat/chat.queries.ts @@ -5,20 +5,29 @@ import { chatApi } from './chat.api' import { chatKeys } from './chat.keys' import { mapInit, mapMessage } from './chat.type' -// 재시도해도 결과가 바뀌지 않는 클라이언트 에러(권한없음/없는 세션) → 즉시 실패시켜 목록 복귀를 지연 없이 처리. -const NON_RETRYABLE = new Set([ErrorCode.NEGO_FORBIDDEN, ErrorCode.NEGO_NOT_FOUND]) +// 재시도해도 결과가 바뀌지 않는 클라이언트 에러(권한없음/없는 세션/진입 불가 상태) → 즉시 실패시켜 목록 복귀를 지연 없이 처리. +const NON_RETRYABLE = new Set([ + ErrorCode.NEGO_FORBIDDEN, + ErrorCode.NEGO_NOT_FOUND, + ErrorCode.NEGO_NOT_PARTICIPABLE, +]) function retryQuery(failureCount: number, error: unknown): boolean { if (isApiError(error) && NON_RETRYABLE.has(error.code)) return false return failureCount < 3 } -/** 채팅 진입 메타(상품·견적). 마감 시각은 거의 불변이라 오래 캐싱한다. */ +/** + * 채팅 진입 메타(상품·견적). '진입 시점'의 스냅샷이라 화면 도중엔 재조회하지 않는다. + * (포커스 재조회가 돌면 종료된 세션에서 진입 게이트(1301)에 걸려 사용자가 튕길 수 있다. + * 재진입 시 최신 status 는 컨트롤러가 언마운트에서 캐시를 비워 보장한다.) + */ export function useChatInitQuery(sessionId: string) { return useQuery({ queryKey: chatKeys.init(sessionId), queryFn: () => chatApi.getInit(sessionId).then(mapInit), enabled: !!sessionId, staleTime: 5 * 60 * 1000, + refetchOnWindowFocus: false, retry: retryQuery, }) } diff --git a/frontend/src/features/chat/components/ChatMessage.tsx b/frontend/src/features/chat/components/ChatMessage.tsx index 473d57f..7f1ebf1 100644 --- a/frontend/src/features/chat/components/ChatMessage.tsx +++ b/frontend/src/features/chat/components/ChatMessage.tsx @@ -2,7 +2,6 @@ import { useEffect, useRef, memo } from 'react' import { cn } from '@/lib' import { useChatStore } from '@/features/chat/stores/useChatStore' import { useChatInitStore } from '@/features/chat/stores/useChatInitStore' -import { SessionStatus } from '@/apis/negotiation/negotiation.type' import type { ChatMessage as ChatMessageType } from '@/features/chat/types' import { renderEmphasis } from '@/features/chat/lib/emphasis' import { Indicator } from '@/features/chat/components/templates/Indicator' @@ -94,9 +93,10 @@ const MessageItem = memo(function MessageItem({ }) const BotMessage = memo(function BotMessage({ message, isFirst }: { message: ChatMessageType; isFirst?: boolean }) { - // 인디케이터는 진행 중인 협상에서만 표시 — 완료/거부 등 결과 열람 재진입 시에는 값이 와도 숨긴다 - const sessionStatus = useChatInitStore((s) => s.session_status) - const showIndicator = sessionStatus === SessionStatus.IN_PROGRESS + // 인디케이터는 이번 방문에서 '협상중'이었던 세션에서만 표시. + // 진행 화면에서 실시간으로 완료되면 sticky 플래그로 계속 노출하고, + // 완료/거부 세션에 결과 열람 목적으로 재진입한 경우엔 값이 와도 숨긴다. + const showIndicator = useChatInitStore((s) => s.wasInProgress) return (
diff --git a/frontend/src/features/chat/hooks/useChatController.ts b/frontend/src/features/chat/hooks/useChatController.ts index c2816b4..91c37d1 100644 --- a/frontend/src/features/chat/hooks/useChatController.ts +++ b/frontend/src/features/chat/hooks/useChatController.ts @@ -24,9 +24,13 @@ const TERMINAL_CODES = new Set([ ErrorCode.NEGO_NOT_FOUND, ]) -// 진입 로드(init/messages) 시 '잘못된 접근'으로 볼 코드 → 권한 없음 / 없는(또는 남의) 세션. -// 토스트 안내 후 목록으로 복귀시킨다. (로그인은 유효하므로 로그아웃하지 않는다 — 인증≠인가) -const INVALID_ACCESS_CODES = new Set([ErrorCode.NEGO_FORBIDDEN, ErrorCode.NEGO_NOT_FOUND]) +// 진입 로드(init/messages) 시 '잘못된 접근'으로 볼 코드 → 권한 없음 / 없는(또는 남의) 세션 / +// 진입 불가 상태(미참여·거부). 토스트 안내 후 목록으로 복귀시킨다. (인증≠인가 — 로그아웃하지 않는다) +const INVALID_ACCESS_CODES = new Set([ + ErrorCode.NEGO_FORBIDDEN, + ErrorCode.NEGO_NOT_FOUND, + ErrorCode.NEGO_NOT_PARTICIPABLE, +]) // 화면이 보는 대화 상태가 서버와 어긋났을 수 있는 코드 → 서버 기준으로 메시지를 다시 불러와 리싱크. const RESYNC_CODES = new Set([ @@ -110,11 +114,11 @@ export function useChatController(sessionId: string) { const s = useChatStore.getState() if (data.message) s.appendMessage(mapMessage(data.message)) s.setIsLoading(false) - // 협상 종료 전이(완료/거부 등): 목록·init 캐시를 무효화해 - // /list 복귀 시 최신 상태를, 재진입 시 최신 session_status 를 보장한다. + // 협상 종료 전이(완료/거부 등): 목록 캐시만 무효화해 /list 복귀 시 최신 상태를 보장한다. + // init 은 '진입 메타'라 화면에서 재조회하지 않는다 — 재조회하면 미참여/거부 진입 게이트(1301)에 + // 걸려 방금 정상 종료한 사용자가 튕겨난다. 재진입 시 최신 status 는 언마운트의 removeQueries 가 보장한다. if (data.session_status !== SessionStatus.IN_PROGRESS || data.message?.chat_end) { queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() }) - queryClient.invalidateQueries({ queryKey: chatKeys.init(sessionId) }) } }, onError: (error) => { @@ -141,9 +145,8 @@ export function useChatController(sessionId: string) { // 마감/종료/권한 등 더 진행 불가한 상태면 잠시 후 목록으로 복귀 if (TERMINAL_CODES.has(code)) { s.bindSend(null) // 입력 잠금(추가 전송 차단) - // 서버 기준 세션 상태가 이미 바뀐 것 — 복귀할 목록과 init 캐시를 무효화한다. + // 서버 기준 세션 상태가 이미 바뀐 것 — 복귀할 목록 캐시를 무효화한다. (init 은 언마운트에서 정리) queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() }) - queryClient.invalidateQueries({ queryKey: chatKeys.init(sessionId) }) setTimeout(() => navigate('/list'), 1500) } }, @@ -153,6 +156,10 @@ export function useChatController(sessionId: string) { return () => { useChatStore.getState().reset() + // 방문 간 sticky(wasInProgress) 누수 방지 — 다른 세션으로 전환/이탈 시 초기화한다. + useChatInitStore.getState().reset() + // init 은 진입 메타 캐시 — 비워 두어 재진입이 항상 최신 status 로 진입 게이트(1301)를 다시 타게 한다. + queryClient.removeQueries({ queryKey: chatKeys.init(sessionId) }) } }, [sessionId, sendMutate, navigate, queryClient]) diff --git a/frontend/src/features/chat/stores/useChatInitStore.ts b/frontend/src/features/chat/stores/useChatInitStore.ts index bb29b1d..6f23dc3 100644 --- a/frontend/src/features/chat/stores/useChatInitStore.ts +++ b/frontend/src/features/chat/stores/useChatInitStore.ts @@ -1,7 +1,12 @@ import { create } from 'zustand' import type { ChatInitData } from '@/features/chat/types' +import { SessionStatus } from '@/apis/negotiation/negotiation.type' interface ChatInitStore extends ChatInitData { + // 이번 방문(마운트) 동안 세션이 한 번이라도 '협상중'이었는지. + // 진행 중 실시간으로 완료/거부로 전이돼도 인디케이터를 계속 보여주기 위한 sticky 플래그. + // (완료/거부 세션에 '재진입'한 경우엔 처음부터 false → 숨김) + wasInProgress: boolean setInitData: (data: Partial) => void reset: () => void } @@ -28,6 +33,12 @@ const initialState: ChatInitData = { export const useChatInitStore = create((set) => ({ ...initialState, - setInitData: (data) => set(data), - reset: () => set(initialState), + wasInProgress: false, + setInitData: (data) => + set((state) => ({ + ...data, + // 한 번 '협상중'을 관측하면 유지 — 진행 화면에서 완료/거부로 전이돼도 인디케이터를 계속 보여준다. + wasInProgress: state.wasInProgress || data.session_status === SessionStatus.IN_PROGRESS, + })), + reset: () => set({ ...initialState, wasInProgress: false }), }))