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:
parent
e7be7d5b5f
commit
fc71526d5a
@ -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)
|
||||
|
||||
@ -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) ----------
|
||||
|
||||
@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
@ -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]')}>
|
||||
|
||||
@ -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])
|
||||
|
||||
|
||||
@ -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 }),
|
||||
}))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user