o2o-negosium-original/frontend/src/features/chat/hooks/useChatController.ts
민헌 1f16bec9ee fix(chat): 협상 종료 시 목록·init 쿼리 캐시 무효화
- send 응답에서 종료 전이(session_status ≠ 협상중 또는 chat_end) 감지 시 sessions·init 캐시 무효화
- 마감/종료/권한 에러로 목록 복귀할 때도 동일하게 무효화
- /list 복귀 시 staleTime(60s) 과 무관하게 항상 최신 상태 표시, 종료 세션 재진입 시 인디케이터 숨김 즉시 적용

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 17:25:38 +09:00

171 lines
7.7 KiB
TypeScript

import { useEffect, useRef } from 'react'
import { useNavigate } from 'react-router'
import { useQueryClient } from '@tanstack/react-query'
import {
useChatInitQuery,
useChatMessagesQuery,
useChatSendMutation,
mapMessage,
chatKeys,
} from '@/apis/chat'
import { ErrorCode, getApiErrorMessage, isApiError } from '@/apis/types'
import { negotiationKeys, SessionStatus } from '@/apis'
import { toast } from '@/lib'
import { useChatStore } from '@/features/chat/stores/useChatStore'
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
import type { ChatMessage, UserInputType } from '@/features/chat/types'
// 더 이상 대화를 이어갈 수 없는(세션 종료/마감/권한) 코드 → 입력 잠금 후 목록으로 복귀.
const TERMINAL_CODES = new Set<number>([
ErrorCode.CHAT_NOT_IN_PROGRESS,
ErrorCode.NEGO_QUOTATION_CLOSED,
ErrorCode.NEGO_DEADLINE_PASSED,
ErrorCode.NEGO_FORBIDDEN,
ErrorCode.NEGO_NOT_FOUND,
])
// 진입 로드(init/messages) 시 '잘못된 접근'으로 볼 코드 → 권한 없음 / 없는(또는 남의) 세션.
// 토스트 안내 후 목록으로 복귀시킨다. (로그인은 유효하므로 로그아웃하지 않는다 — 인증≠인가)
const INVALID_ACCESS_CODES = new Set<number>([ErrorCode.NEGO_FORBIDDEN, ErrorCode.NEGO_NOT_FOUND])
// 화면이 보는 대화 상태가 서버와 어긋났을 수 있는 코드 → 서버 기준으로 메시지를 다시 불러와 리싱크.
const RESYNC_CODES = new Set<number>([
ErrorCode.CHAT_INPUT_MODE_MISMATCH, // 보낸 입력이 직전 봇이 요구한 모드와 불일치
ErrorCode.CHAT_AGENT_TIMEOUT, // agent 타임아웃(서버/agent 상태가 앞서 있을 수 있음)
])
let tempSeq = 0
// 낙관적 유저 말풍선 생성 (전송 즉시 표시). 서버 확정 메시지는 send 응답으로 append 한다.
function makeUserMessage(text: string, inputType: UserInputType): ChatMessage {
return {
chat_id: `temp-user-${tempSeq++}`,
sender: 'user',
bot_chat_type: null,
user_input_type: inputType,
script: text,
chat_end: false,
next_input_mode: null,
next_input_type: null,
step: '',
display_step: '',
summary: null,
indicator_value: null,
}
}
/**
* 채팅 페이지 컨트롤러: init/messages 조회를 스토어에 적재하고,
* append-only 전송 구현을 스토어에 주입한다. (mock 을 대체)
*/
export function useChatController(sessionId: string) {
const navigate = useNavigate()
const queryClient = useQueryClient()
const setInitData = useChatInitStore((s) => s.setInitData)
const initQuery = useChatInitQuery(sessionId)
const messagesQuery = useChatMessagesQuery(sessionId)
// mutate 는 참조가 안정적이다. useMutation 반환 객체 전체를 아래 effect deps 에 넣으면
// mutate 호출→pending 리렌더마다 객체 identity 가 바뀌어 effect 가 teardown(reset())→재바인딩되고,
// staleTime:Infinity 인 메시지 쿼리는 재조회되지 않아 스토어가 빈 채로 남아 채팅이 사라진다.
const { mutate: sendMutate } = useChatSendMutation(sessionId)
// 진입 로드 실패. 권한 없음/없는 세션(잘못된 접근)이면 토스트 후 목록으로 복귀시킨다.
const loadError = initQuery.error ?? messagesQuery.error
const isInvalidAccess = isApiError(loadError) && INVALID_ACCESS_CODES.has(loadError.code)
// 이미 리다이렉트한 sessionId 를 기록(boolean 이 아니라 sessionId). 리마운트 없이 sessionId 가 바뀌면
// (브라우저 뒤로/앞으로 등) 값이 달라져 가드가 자연 해제 → 두 번째 무권한 세션도 토스트+복귀가 동작한다.
const redirectedSessionRef = useRef<string | null>(null)
useEffect(() => {
if (!isInvalidAccess || redirectedSessionRef.current === sessionId) return
redirectedSessionRef.current = sessionId
toast.error('잘못된 접근입니다.')
navigate('/list', { replace: true })
}, [isInvalidAccess, sessionId, navigate])
// init 메타 → 스토어
useEffect(() => {
if (initQuery.data) setInitData(initQuery.data)
}, [initQuery.data, setInitData])
// 대화 히스토리 → 스토어 (진입 1회)
useEffect(() => {
if (messagesQuery.data) useChatStore.getState().setMessages(messagesQuery.data)
}, [messagesQuery.data])
// sessionId + 전송 구현 주입
useEffect(() => {
const store = useChatStore.getState()
store.setSessionId(sessionId)
store.bindSend((text, inputType = 'text') => {
const optimistic = makeUserMessage(text, inputType)
store.appendMessage(optimistic)
store.setIsLoading(true)
store.setPriceErrorMessage('')
sendMutate(
{ user_input: text, user_input_type: inputType === 'text' ? null : inputType },
{
onSuccess: (data) => {
const s = useChatStore.getState()
if (data.message) s.appendMessage(mapMessage(data.message))
s.setIsLoading(false)
// 협상 종료 전이(완료/거부 등): 목록·init 캐시를 무효화해
// /list 복귀 시 최신 상태를, 재진입 시 최신 session_status 를 보장한다.
if (data.session_status !== SessionStatus.IN_PROGRESS || data.message?.chat_end) {
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
queryClient.invalidateQueries({ queryKey: chatKeys.init(sessionId) })
}
},
onError: (error) => {
const s = useChatStore.getState()
// 낙관적 메시지 롤백 (서버에 저장되지 않음)
s.setMessages(s.messages.filter((m) => m.chat_id !== optimistic.chat_id))
s.setIsLoading(false)
const code = isApiError(error) ? error.code : 0
// 가격 범위 초과: 입력창 인라인 에러로 표시(페이지 유지)
if (code === ErrorCode.CHAT_PRICE_OUT_OF_RANGE) {
s.setPriceErrorMessage(getApiErrorMessage(error))
return
}
toast.error(getApiErrorMessage(error, '협상 처리 중 오류가 발생했습니다.'))
// 입력-모드 불일치/타임아웃: 서버 기준으로 대화를 다시 불러와 화면을 리싱크한다.
// (messages 쿼리가 갱신되면 위 effect 가 스토어 messages 를 덮어써 버튼/입력창이 서버 상태에 맞춰진다.)
if (RESYNC_CODES.has(code)) {
queryClient.invalidateQueries({ queryKey: chatKeys.messages(sessionId) })
}
// 마감/종료/권한 등 더 진행 불가한 상태면 잠시 후 목록으로 복귀
if (TERMINAL_CODES.has(code)) {
s.bindSend(null) // 입력 잠금(추가 전송 차단)
// 서버 기준 세션 상태가 이미 바뀐 것 — 복귀할 목록과 init 캐시를 무효화한다.
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
queryClient.invalidateQueries({ queryKey: chatKeys.init(sessionId) })
setTimeout(() => navigate('/list'), 1500)
}
},
},
)
})
return () => {
useChatStore.getState().reset()
}
}, [sessionId, sendMutate, navigate, queryClient])
return {
// 잘못된 접근이면 목록으로 리다이렉트되는 동안 스피너를 유지(ErrorPage 깜빡임 방지).
isInitLoading: initQuery.isLoading || messagesQuery.isLoading || isInvalidAccess,
// 잘못된 접근은 토스트+리다이렉트로 처리하므로 ErrorPage(재시도)로 노출하지 않는다.
initError: isInvalidAccess ? null : loadError,
// 로드 실패(서버/네트워크) 시 init·messages 를 함께 재조회한다.
refetchInit: () => {
void initQuery.refetch()
void messagesQuery.refetch()
},
}
}