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>
This commit is contained in:
민헌 2026-07-07 17:22:54 +09:00
parent e7be7d5b5f
commit fc71526d5a
6 changed files with 62 additions and 20 deletions

View File

@ -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)

View File

@ -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) ----------

View File

@ -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<number>([ErrorCode.NEGO_FORBIDDEN, ErrorCode.NEGO_NOT_FOUND])
// 재시도해도 결과가 바뀌지 않는 클라이언트 에러(권한없음/없는 세션/진입 불가 상태) → 즉시 실패시켜 목록 복귀를 지연 없이 처리.
const NON_RETRYABLE = new Set<number>([
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,
})
}

View File

@ -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 (
<div className="mb-[56px]">
<div className={cn('flex flex-col', !isFirst && 'pt-[36px]')}>

View File

@ -24,9 +24,13 @@ const TERMINAL_CODES = new Set<number>([
ErrorCode.NEGO_NOT_FOUND,
])
// 진입 로드(init/messages) 시 '잘못된 접근'으로 볼 코드 → 권한 없음 / 없는(또는 남의) 세션.
// 토스트 안내 후 목록으로 복귀시킨다. (로그인은 유효하므로 로그아웃하지 않는다 — 인증≠인가)
const INVALID_ACCESS_CODES = new Set<number>([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>([
@ -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])

View File

@ -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<ChatInitData>) => void
reset: () => void
}
@ -28,6 +33,12 @@ const initialState: ChatInitData = {
export const useChatInitStore = create<ChatInitStore>((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 }),
}))