From d398a31f2a8338ac13cf7d00cad714223d41ce68 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Tue, 20 Oct 2026 09:02:19 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20CGV=20=EC=8B=9C=EA=B0=84=ED=91=9C?= =?UTF-8?q?=EB=A5=BC=20=EA=B3=B5=EC=8B=9D=20=EC=9B=B9=20HTTP=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EB=A1=9C=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/cgv-relay-recovery.md | 8 ++++++ scripts/relay/cgv.ts | 4 +-- src/services/cgv/api.ts | 9 +++++++ src/services/cgv/directRoute.ts | 4 +-- src/services/cgv/transport.ts | 12 +++++++-- tests/services/cgv/booking-http.test.ts | 34 ++++++++++++++++++++++++ tests/services/cgv/client.test.ts | 2 +- tests/services/cgv/direct-route.test.ts | 2 +- tests/utils/routeProbes-no-relay.test.ts | 6 ++++- 9 files changed, 72 insertions(+), 9 deletions(-) create mode 100644 tests/services/cgv/booking-http.test.ts diff --git a/docs/cgv-relay-recovery.md b/docs/cgv-relay-recovery.md index 3c2d52c3..eb8541a7 100644 --- a/docs/cgv-relay-recovery.md +++ b/docs/cgv-relay-recovery.md @@ -8,6 +8,14 @@ CGV는 `/v1/cgv/{theaters,movies,timetable,timetable-movie}` 경로로 기존 Dt 5분 주기 경로 점검은 CGV 직접 요청과 중계를 확인해 사용 가능한 경로를 선택합니다. 직접 경로가 차단되면 중계를 사용하고, 직접 경로가 회복되면 점검 결과에 따라 복귀합니다. 일반 요청 중 직접 실패도 경로 선택에 반영됩니다. `/health`의 `config.cgvRelay`는 유효 설정 여부만 나타내며 연결 성공을 뜻하지 않습니다. +## 2026-10-20 시간표 경로 복구 + +기존 `api.cgv.co.kr/cnm/atkt/searchMovScnInfo`는 Mac에서도 HTTP 403을 반환했습니다. 공식 웹의 비로그인 예매 화면에서 사용하는 `https://cgv.co.kr/api/v1/booking/searchMovScnInfo`는 동일한 극장·날짜 조건과 정상 Origin·Referer·Fetch Metadata 헤더로 서버에서도 HTTP 200과 시간표 데이터를 반환하는 것을 확인했습니다. 브라우저 세션이나 쿠키를 복사하지 않습니다. + +극장별 시간표 작업만 이 고정 경로를 사용합니다. Worker 직접 요청·5분 주기 경로 검사·Mac 중계가 같은 URL 생성과 헤더를 공유하며, 기존 입력·응답 검증, 기한, 예산과 캐시 정책을 유지합니다. 극장·영화 목록과 개별 영화 시간표의 기존 경로는 유지합니다. 개별 영화 조건도 우선 극장별 시간표의 실제 결과를 필터링하므로, 결과가 없을 때만 기존 개별 영화 조회가 사용됩니다. 임의의 경로를 새 호스트로 전달하지 않습니다. + +Mac 반영 시 최소 변경 파일은 `src/services/cgv/api.ts`, `transport.ts`, `directRoute.ts`, `scripts/relay/cgv.ts`입니다. 현재 설치 소스와 의존 모듈을 함께 대조하고 시작 모듈 import를 먼저 검증한 뒤, 기존 운영 절차로 원장을 보존하며 재기동합니다. + ## 설정 CGV 전용 설정이 하나도 없으면 `DTRYX_RELAY_URL`, `DTRYX_RELAY_TOKEN`, `DTRYX_ACCESS_CLIENT_ID`, `DTRYX_ACCESS_CLIENT_SECRET` 그룹 전체를 사용합니다. `CGV_*` 중 하나라도 정의하면 Dtryx와 값을 섞지 않고 CGV 그룹 전체를 사용합니다. 이때 `CGV_RELAY_URL`과 `CGV_RELAY_TOKEN`을 함께 설정하고, Access를 사용하면 `CGV_ACCESS_CLIENT_ID`와 `CGV_ACCESS_CLIENT_SECRET`도 함께 설정합니다. 빈 값도 정의된 설정으로 취급하므로 미사용 CGV 예시는 주석으로 두거나 삭제합니다. 공유 Dtryx 프로세스의 CGV 토큰은 실제 Dtryx 토큰과 같아야 합니다. diff --git a/scripts/relay/cgv.ts b/scripts/relay/cgv.ts index f9d7f3af..13440d41 100644 --- a/scripts/relay/cgv.ts +++ b/scripts/relay/cgv.ts @@ -1,5 +1,5 @@ /** 고정 CGV 공개 조회만 허용하는 무료 HTTP 중계입니다. */ -import { CGV_API } from '../../src/services/cgv/api.js'; +import { CGV_API, cgvRequestUrl } from '../../src/services/cgv/api.js'; import { createCgvHeaders } from '../../src/services/cgv/transport.js'; import { isCgvResponse } from '../../src/services/cgv/relayTransport.js'; import { @@ -61,7 +61,7 @@ export function createCgvRelay( async upstream(operation, value, signal, fetcher) { const params = value as Record; const path = paths[operation as Operation]; - const url = new URL(path, CGV_API.BASE_URL); + const url = cgvRequestUrl(path); url.searchParams.set('coCd', CGV_API.COMPANY_CODE); if (operation !== 'theaters') { url.searchParams.set('siteNo', params.theaterCode); diff --git a/src/services/cgv/api.ts b/src/services/cgv/api.ts index d1d36098..0528bc58 100644 --- a/src/services/cgv/api.ts +++ b/src/services/cgv/api.ts @@ -14,3 +14,12 @@ export const CGV_API = { TIMETABLE_SITE_SCOPE_CODE: '08', TIMETABLE_SCOPE_CODE: '01', } as const; + +/** 확인된 공식 웹 시간표 경로만 사용하고 임의 URL을 받지 않습니다. */ +export function cgvRequestUrl(path: string): URL { + return new URL( + path === CGV_API.TIMETABLE_BY_SITE_PATH + ? 'https://cgv.co.kr/api/v1/booking/searchMovScnInfo' + : CGV_API.BASE_URL + path, + ); +} diff --git a/src/services/cgv/directRoute.ts b/src/services/cgv/directRoute.ts index 02a0392f..a01015d1 100644 --- a/src/services/cgv/directRoute.ts +++ b/src/services/cgv/directRoute.ts @@ -1,6 +1,6 @@ /** CGV 직접 경로와 주기 검사의 고정 요청 규격입니다. */ import * as z from 'zod'; -import { CGV_API } from './api.js'; +import { CGV_API, cgvRequestUrl } from './api.js'; import { createCgvHeaders } from './transport.js'; import { isCgvResponse } from './relayTransport.js'; import { toYyyymmdd } from '../../utils/format.js'; @@ -49,7 +49,7 @@ export const cgvDirectRouteSpecs: Record = Object.fromE string, string >; - const url = new URL(path, CGV_API.BASE_URL); + const url = cgvRequestUrl(path); url.searchParams.set('coCd', CGV_API.COMPANY_CODE); if (operation !== 'theaters') { url.searchParams.set('siteNo', body.theaterCode); diff --git a/src/services/cgv/transport.ts b/src/services/cgv/transport.ts index e318cdaa..dcf4c5fe 100644 --- a/src/services/cgv/transport.ts +++ b/src/services/cgv/transport.ts @@ -15,7 +15,7 @@ import { validateCgvRelay, type CgvTransportOptions, } from './relayTransport.js'; -import { CGV_API } from './api.js'; +import { CGV_API, cgvRequestUrl } from './api.js'; import { CgvUpstreamUnavailableError } from './errors.js'; function toBase64(bytes: Uint8Array): string { @@ -58,6 +58,13 @@ async function createSignature( export async function createCgvHeaders(path: string): Promise> { const timestamp = Math.floor(Date.now() / 1000).toString(); return { + ...(path === CGV_API.TIMETABLE_BY_SITE_PATH ? { + Origin: 'https://cgv.co.kr', + Referer: 'https://cgv.co.kr/cnm/movieBook/cinema', + 'Sec-Fetch-Site': 'same-origin', + 'Sec-Fetch-Mode': 'cors', + 'Sec-Fetch-Dest': 'empty', + } : {}), Accept: 'application/json', 'Accept-Language': 'ko-KR', // 실제 직접 요청에서 허용된 브라우저 헤더를 유지합니다. @@ -95,7 +102,8 @@ export async function requestCgv( (ms) => requestCgvRelay(path, searchParams, options, ms), ); } - const url = `${CGV_API.BASE_URL}${path}?${searchParams.toString()}`; + const url = cgvRequestUrl(path); + url.search = searchParams.toString(); const headers = await createCgvHeaders(path); const { controller, timeoutId } = createTimeoutController(timeout); diff --git a/tests/services/cgv/booking-http.test.ts b/tests/services/cgv/booking-http.test.ts new file mode 100644 index 00000000..f20a65ae --- /dev/null +++ b/tests/services/cgv/booking-http.test.ts @@ -0,0 +1,34 @@ +/** 공식 웹 시간표 경로를 서버·직접 점검·중계에 동일하게 적용합니다. */ +import { afterEach, expect, it, vi } from 'vitest'; +import { requestCgv } from '../../../src/services/cgv/transport.js'; +import { requestDirectRoute } from '../../../src/utils/directRoutes.js'; +import { createCgvRelay } from '../../../scripts/relay/cgv.js'; +const path = '/cnm/atkt/searchMovScnInfo'; +const body = { theaterCode: '0056', playDate: '20261020' }; +const params = new URLSearchParams({ coCd: 'A420', siteNo: body.theaterCode, scnYmd: body.playDate, rtctlScopCd: '08' }); +const payload = { statusCode: 0, data: [{ siteNo: '0056', scnYmd: '20261020', atktPsblQty: 12 }] }; +afterEach(() => vi.unstubAllGlobals()); +it.each(['client', 'direct', 'relay'])('공식 웹과 같은 고정 시간표 URL·헤더로 조회한다: %s', async (mode) => { + const fetcher = vi.fn(async (input: URL | RequestInfo, init?: RequestInit) => { + const url = new URL(String(input)); + const headers = new Headers(init?.headers); + if (url.origin !== 'https://cgv.co.kr' || url.pathname !== '/api/v1/booking/searchMovScnInfo' || + headers.get('Origin') !== 'https://cgv.co.kr' || + headers.get('Referer') !== 'https://cgv.co.kr/cnm/movieBook/cinema' || + headers.get('Sec-Fetch-Site') !== 'same-origin') return new Response('blocked', { status: 403 }); + expect(Object.fromEntries(url.searchParams)).toEqual(Object.fromEntries(params)); + return Response.json(payload); + }); + vi.stubGlobal('fetch', fetcher); + if (mode === 'client') expect(await requestCgv(path, params, 1000)).toEqual(payload); + else if (mode === 'direct') expect(await requestDirectRoute('cgv-timetable', body, 1000)).toEqual(payload); + else { + const relay = createCgvRelay('test-token', { takeQuota: async () => true, fetcher }); + const response = await relay(new Request('http://localhost/v1/cgv/timetable', { + method: 'POST', headers: { Authorization: 'Bearer test-token', 'Content-Type': 'application/json' }, body: JSON.stringify(body), + })); + expect(response.status).toBe(200); + expect(await response.json()).toEqual(payload); + } + expect(fetcher).toHaveBeenCalledTimes(1); +}); diff --git a/tests/services/cgv/client.test.ts b/tests/services/cgv/client.test.ts index fc2745d5..f0247a71 100644 --- a/tests/services/cgv/client.test.ts +++ b/tests/services/cgv/client.test.ts @@ -471,7 +471,7 @@ describe('fetchCgvTimetable', () => { expect(result[0].movieCode).toBe('30000986'); expect(result[0].remainingSeats).toBe(99); expect(mockFetch).toHaveBeenCalledTimes(1); - expect(String(mockFetch.mock.calls[0][0])).toContain('/cnm/atkt/searchMovScnInfo'); + expect(String(mockFetch.mock.calls[0][0])).toContain('https://cgv.co.kr/api/v1/booking/searchMovScnInfo'); }); it('사이트 시간표에서 prodNm/frtmpSeatCnt 분기를 정규화한다', async () => { diff --git a/tests/services/cgv/direct-route.test.ts b/tests/services/cgv/direct-route.test.ts index 9cbd6665..04f336c2 100644 --- a/tests/services/cgv/direct-route.test.ts +++ b/tests/services/cgv/direct-route.test.ts @@ -28,7 +28,7 @@ it.each([ data: [], }); const [url, init] = fetcher.mock.calls[0]; - expect(url.pathname).toBe(`/cnm/atkt/${endpoint}`); + expect(url.pathname).toBe(key === 'cgv-timetable' ? `/api/v1/booking/${endpoint}` : `/cnm/atkt/${endpoint}`); expect(url.searchParams.get('coCd')).toBe('A420'); expect(url.searchParams.get('rtctlScopCd')).toBe(scope ?? null); if (key !== 'cgv-theaters') expect(url.searchParams.get('siteNo')).toBe('0056'); diff --git a/tests/utils/routeProbes-no-relay.test.ts b/tests/utils/routeProbes-no-relay.test.ts index f1980865..e6713113 100644 --- a/tests/utils/routeProbes-no-relay.test.ts +++ b/tests/utils/routeProbes-no-relay.test.ts @@ -28,7 +28,11 @@ it('실제 검사 함수는 직접 원본 요청과 중계 건강 GET만 호출 relayCalls.every(([url, init]) => url.pathname.endsWith('/health') && init.method === 'GET'), ).toBe(true); const cgvCalls = calls.filter(([url]) => url.hostname === 'api.cgv.co.kr'); - expect(cgvCalls).toHaveLength(4); + expect(cgvCalls).toHaveLength(3); + const bookingCalls = calls.filter(([url]) => url.origin === 'https://cgv.co.kr'); + expect(bookingCalls).toHaveLength(1); + expect(bookingCalls[0][0].pathname).toBe('/api/v1/booking/searchMovScnInfo'); + expect(bookingCalls[0][1].method).toBe('GET'); expect(cgvCalls.every(([, init]) => init.method === 'GET')).toBe(true); expect(storageFetch).toHaveBeenCalledTimes(1); });