o2o-negosium-original/frontend/src/features/chat/hooks/useChatController.ts
민헌 fc71526d5a feat(chat): 종료 세션 진입 차단 + 협상중 인디케이터 sticky 유지
- init 에서 미참여/거부(4·5) 세션 진입을 NEGO_NOT_PARTICIPABLE 로 차단
  (participate/reject 와 동일 규칙). 프론트는 목록으로 복귀 처리.
- init 을 '진입 스냅샷'으로 격리: 화면 도중·포커스 재조회 제거, 언마운트 시
  캐시 비움 → 정상 종료 후 튕김 방지 + 재진입 시 진입 게이트 재적용.
- 인디케이터를 session_status 직접 비교 → wasInProgress sticky 플래그로 전환:
  진행 화면에서 실시간 완료돼도 유지, 완료/거부 결과 열람 재진입 시엔 숨김.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-07 17:22:54 +09:00

178 lines
8.2 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,
ErrorCode.NEGO_NOT_PARTICIPABLE,
])
// 화면이 보는 대화 상태가 서버와 어긋났을 수 있는 코드 → 서버 기준으로 메시지를 다시 불러와 리싱크.
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)
// 협상 종료 전이(완료/거부 등): 목록 캐시만 무효화해 /list 복귀 시 최신 상태를 보장한다.
// init 은 '진입 메타'라 화면에서 재조회하지 않는다 — 재조회하면 미참여/거부 진입 게이트(1301)에
// 걸려 방금 정상 종료한 사용자가 튕겨난다. 재진입 시 최신 status 는 언마운트의 removeQueries 가 보장한다.
if (data.session_status !== SessionStatus.IN_PROGRESS || data.message?.chat_end) {
queryClient.invalidateQueries({ queryKey: negotiationKeys.sessions() })
}
},
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() })
setTimeout(() => navigate('/list'), 1500)
}
},
},
)
})
return () => {
useChatStore.getState().reset()
// 방문 간 sticky(wasInProgress) 누수 방지 — 다른 세션으로 전환/이탈 시 초기화한다.
useChatInitStore.getState().reset()
// init 은 진입 메타 캐시 — 비워 두어 재진입이 항상 최신 status 로 진입 게이트(1301)를 다시 타게 한다.
queryClient.removeQueries({ queryKey: chatKeys.init(sessionId) })
}
}, [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()
},
}
}