- 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>
178 lines
8.2 KiB
TypeScript
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()
|
|
},
|
|
}
|
|
}
|