8000 fix: CGV 시간표 공식 웹 경로 복구 by hmmhmmhm · Pull Request #284 · hmmhmmhm/daiso-mcp · GitHub
[go: up one dir, main page]

Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions docs/cgv-relay-recovery.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 토큰과 같아야 합니다.
Expand Down
4 changes: 2 additions & 2 deletions scripts/relay/cgv.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -61,7 +61,7 @@ export function createCgvRelay(
async upstream(operation, value, signal, fetcher) {
const params = value as Record<string, string>;
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);
Expand Down
9 changes: 9 additions & 0 deletions src/services/cgv/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
4 changes: 2 additions & 2 deletions src/services/cgv/directRoute.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -49,7 +49,7 @@ export const cgvDirectRouteSpecs: Record<string, DirectRouteSpec> = 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);
Expand Down
12 changes: 10 additions & 2 deletions src/services/cgv/transport.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -58,6 +58,13 @@ async function createSignature(
export async function createCgvHeaders(path: string): Promise<Record<string, string>> {
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',
// 실제 직접 요청에서 허용된 브라우저 헤더를 유지합니다.
Expand Down Expand Up @@ -95,7 +102,8 @@ export async function requestCgv<TResponse>(
(ms) => requestCgvRelay<TResponse>(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);

Expand Down
34 changes: 34 additions & 0 deletions tests/services/cgv/booking-http.test.ts
Original file line number Diff line number Diff line change
@@ -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));
1798 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). F438 toBe(200);
expect(await response.json()).toEqual(payload);
}
expect(fetcher).toHaveBeenCalledTimes(1);
});
2 changes: 1 addition & 1 deletion tests/services/cgv/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
2 changes: 1 addition & 1 deletion tests/services/cgv/direct-route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
6 changes: 5 additions & 1 deletion tests/utils/routeProbes-no-relay.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Loading
0