From 669c8df839d15ff23967f68b9a5ab3b938288644 Mon Sep 17 00:00:00 2001 From: joyeongchan Date: Fri, 14 Aug 2026 18:41:40 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=EC=8B=9C=EC=9E=91=20=EB=B6=88=EA=B0=80?= =?UTF-8?q?=20=EC=83=81=ED=83=9C=EC=9D=98=20/match=20=EC=A7=81=EC=A0=91=20?= =?UTF-8?q?=EC=A7=84=EC=9E=85=EC=9D=84=20=EB=8C=80=EA=B8=B0=EC=8B=A4?= =?UTF-8?q?=EB=A1=9C=20=EB=8F=8C=EB=A0=A4=EB=B3=B4=EB=83=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 시작 조건을 못 채운 토너먼트의 /match 로 주소를 직접 바꿔 들어가면 검증 없이 start 를 호출해 서버 400(TOURNAMENT-007) 이 나고 전역 에러 화면으로 떨어졌다. - (예방) 이미 조회한 토너먼트 정보로 대기실 버튼과 같은 기준을 먼저 검사 - (방어) start 가 007/013/014 를 주면 throw 대신 대기실로 이동 - 두 경로 모두 ?action=tournament-not-startable 로 사유 안내 --- .../src/app/tournament/[id]/match/page.tsx | 47 ++++++++++++++++++- apps/web/src/consts/queryAction.ts | 1 + apps/web/src/consts/queryActionToast.ts | 5 ++ apps/web/src/consts/tournament.ts | 3 ++ 4 files changed, 54 insertions(+), 2 deletions(-) diff --git a/apps/web/src/app/tournament/[id]/match/page.tsx b/apps/web/src/app/tournament/[id]/match/page.tsx index 2964b7c7..c03c7aef 100644 --- a/apps/web/src/app/tournament/[id]/match/page.tsx +++ b/apps/web/src/app/tournament/[id]/match/page.tsx @@ -1,12 +1,20 @@ +import { ERROR_CODE } from '@piki/core'; import { HydrationBoundary, dehydrate } from '@tanstack/react-query'; import { isAxiosError } from 'axios'; import { redirect } from 'next/navigation'; +import { QUERY_ACTION } from '@/consts/queryAction'; import { ROUTES } from '@/consts/route'; +import { MIN_TOURNAMENT_ITEM_COUNT } from '@/consts/tournament'; +import { getApiErrorCode } from '@/utils/apiError'; +import { hasParsingItems } from '@/utils/item'; import { getQueryClient } from '@/utils/queryClient'; import { getTournament } from '../_common/_apis/getTournament'; -import type { GetTournamentInProgressResponseT } from '../_common/_types/tournamentResponse'; +import type { + GetTournamentInProgressResponseT, + TournamentPendingItemT, +} from '../_common/_types/tournamentResponse'; import { postStartTournament } from './_apis/postStartTournament'; import TournamentClient from './_components/TournamentClient'; @@ -14,6 +22,27 @@ type TournamentPageProps = { params: Promise<{ id: string }>; }; +/** 서버가 시작을 거부하는 사유들 — 에러 화면 대신 대기실로 돌려보낸다 */ +const NOT_STARTABLE_CODES: string[] = [ + ERROR_CODE.TOURNAMENT_INVALID_ITEM_COUNT, + ERROR_CODE.TOURNAMENT_ITEM_NOT_READY_TO_START, + ERROR_CODE.TOURNAMENT_ITEM_PRICE_REQUIRED, +]; + +const isNotStartableError = (error: unknown) => { + const code = getApiErrorCode(error); + return code !== null && NOT_STARTABLE_CODES.includes(code); +}; + +const notStartablePath = (tournamentId: number) => + `${ROUTES.TOURNAMENT_CREATE(tournamentId)}?${QUERY_ACTION.KEY}=${QUERY_ACTION.VALUE.TOURNAMENT_NOT_STARTABLE}`; + +/** 대기실 시작 버튼과 동일한 기준 — 후보 2개 이상, 추출 중·실패 상품 없음 */ +const isStartable = (items: TournamentPendingItemT[]) => + items.length >= MIN_TOURNAMENT_ITEM_COUNT && + !hasParsingItems(items) && + !items.some(item => item.status === 'FAILED'); + async function TournamentPage({ params }: TournamentPageProps) { const { id } = await params; const tournamentId = Number(id); @@ -34,6 +63,12 @@ async function TournamentPage({ params }: TournamentPageProps) { let playTournamentId = tournamentId; if (tournamentData.status === 'PENDING') { + // 대기실 시작 버튼과 같은 기준으로 미리 거른다 — 그냥 start 를 부르면 + // 서버가 400(TOURNAMENT-007) 을 주고 전역 에러 화면으로 떨어진다. + if (!isStartable(tournamentData.pending?.items ?? [])) { + redirect(notStartablePath(tournamentId)); + } + try { // 응답 tournamentId 활용: // - 주최자(ROOT): 요청 tournamentId 와 동일 @@ -60,8 +95,16 @@ async function TournamentPage({ params }: TournamentPageProps) { hydratedTournament = started; } catch (error) { + if (!isAxiosError(error)) throw error; + + // 사전 검사를 통과했더라도 그 사이 상태가 바뀔 수 있다. + // 시작 불가 사유는 에러 화면 대신 대기실로 돌려보낸다. + if (isNotStartableError(error)) { + redirect(notStartablePath(tournamentId)); + } + // 409: 다른 탭/요청이 먼저 start 호출한 경우 — 서버 권위 상태로 복구 - if (!isAxiosError(error) || error.response?.status !== 409) throw error; + if (error.response?.status !== 409) throw error; const latest = await getTournament(tournamentId); if (latest.status === 'COMPLETED') { diff --git a/apps/web/src/consts/queryAction.ts b/apps/web/src/consts/queryAction.ts index b18f5464..0ad14b89 100644 --- a/apps/web/src/consts/queryAction.ts +++ b/apps/web/src/consts/queryAction.ts @@ -12,6 +12,7 @@ export const QUERY_ACTION = { TOURNAMENT_ITEM_NOT_FOUND: 'tournament-item-not-found', // 삭제된 토너먼트 아이템 접근 시 create 로 폴백 후 토스트 TOURNAMENT_FORBIDDEN: 'tournament-forbidden', // 참여 권한 없는 토너먼트 접근 시 홈으로 폴백 후 토스트 TOURNAMENT_NOT_FOUND: 'tournament-not-found', // 삭제된 토너먼트에 액션을 시도한 경우 홈으로 폴백 후 토스트 + TOURNAMENT_NOT_STARTABLE: 'tournament-not-startable', // 시작 조건 미충족 상태로 /match 직접 진입 시 create 로 폴백 후 토스트 }, } as const; diff --git a/apps/web/src/consts/queryActionToast.ts b/apps/web/src/consts/queryActionToast.ts index 7f36270e..0cf04095 100644 --- a/apps/web/src/consts/queryActionToast.ts +++ b/apps/web/src/consts/queryActionToast.ts @@ -33,4 +33,9 @@ export const QUERY_ACTION_TOAST: Partial