From 5040cb36d1974e6c6a19d6610568a1865dcadcf3 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 5 Oct 2026 09:27:12 +0900 Subject: [PATCH 1/3] =?UTF-8?q?fix:=20=EC=98=AC=EB=A6=AC=EB=B8=8C=EC=98=81?= =?UTF-8?q?=20=EB=B8=8C=EB=9D=BC=EC=9A=B0=EC=A0=80=20=EC=A4=80=EB=B9=84=20?= =?UTF-8?q?=EC=A4=91=20=EC=A1=B0=ED=9A=8C=20=EB=A7=8C=EB=A3=8C=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/oliveyoung-free-relay.md | 4 +- .../plans/2026-10-05-oliveyoung-cold-start.md | 27 ++++ ...2026-10-05-oliveyoung-cold-start-design.md | 15 +++ scripts/relay/oliveyoung.ts | 7 +- tests/relay/cold-start.test.ts | 119 ++++++++++++++++++ tests/relay/oliveyoung.test.ts | 15 +-- 6 files changed, 176 insertions(+), 11 deletions(-) create mode 100644 docs/superpowers/plans/2026-10-05-oliveyoung-cold-start.md create mode 100644 docs/superpowers/specs/2026-10-05-oliveyoung-cold-start-design.md create mode 100644 tests/relay/cold-start.test.ts diff --git a/docs/oliveyoung-free-relay.md b/docs/oliveyoung-free-relay.md index a01cff66..274ae89d 100644 --- a/docs/oliveyoung-free-relay.md +++ b/docs/oliveyoung-free-relay.md @@ -70,7 +70,7 @@ GitHub Actions의 `Sync Worker Secrets`는 위 네 OY secrets를 동기화하며 | `stock-goods-info-v3` | `goodsNo` | | `stock-stores` | `productId`, `lat`, `lon`, `pageIdx`, `searchWords`, `mapLat`, `mapLon` | -숫자·문자열·불리언 타입과 필드 목록을 검증합니다. 요청 본문은 최대 16 KiB입니다. 인증 실패는 브라우저 작업 전에 401, 잘못된 경로는 404, 잘못된 본문은 400/413입니다. 한 브라우저 작업만 동시에 실행하며, 실행·대기를 합쳐 8개를 넘으면 503입니다. 취소되었거나 15초 이상 대기한 요청은 실행하지 않습니다. 브라우저 fetch는 본문을 읽을 때까지 15초 제한을 적용합니다. 정상 HTTP 200과 `SUCCESS` JSON만 반환하며, HTML·상태 오류·네트워크 실패는 원문이나 비밀을 노출하지 않는 502가 됩니다. +숫자·문자열·불리언 타입과 필드 목록을 검증합니다. 요청 본문은 최대 16 KiB입니다. 인증 실패는 브라우저 작업 전에 401, 잘못된 경로는 404, 잘못된 본문은 400/413입니다. 한 브라우저 작업만 동시에 실행하며, 실행·대기를 합쳐 8개를 넘으면 503입니다. 취소되었거나 40초 이상 대기한 요청은 실행하지 않습니다. 같은 조회에 합류한 요청도 각자의 도착 시각으로 만료를 판단하므로 오래된 대기자는 503, 유효한 대기자는 조회 결과를 받습니다. 대기 상한 40초는 관찰된 브라우저 준비 35초를 수용하기 위한 값입니다. 준비된 세션에서는 Worker 기본 예산 60초에서 브라우저 작업 watchdog 18초와 여유 2초를 뺀 대기 목표에 해당합니다. 수명 관리의 추가 준비는 작업 watchdog 전에, 회수는 작업 뒤에 진행될 수 있으므로 모든 요청이 60초 안에 끝난다는 보장은 아닙니다. 브라우저 fetch는 본문을 읽을 때까지 15초 제한을 적용합니다. 정상 HTTP 200과 `SUCCESS` JSON만 반환하며, HTML·상태 오류·네트워크 실패는 원문이나 비밀을 노출하지 않는 502가 됩니다. 준비 실패도 성공으로 반환하거나 성공 캐시에 저장하지 않습니다. 기존 상품·매장·재고의 실패 시 30분 캐시 재사용 동작은 이 전송 변경에서 확대하지 않았습니다. 캐시 데이터가 실시간 재고를 보장하지는 않습니다. @@ -90,7 +90,7 @@ GitHub Actions의 `Sync Worker Secrets`는 위 네 OY secrets를 동기화하며 브라우저는 별도 guard 프로세스가 소유합니다. 중계 부모가 SIGKILL로 종료되어도 IPC 단절을 감지해 회수합니다. PID·생성 시각·프로세스 그룹을 확인한 소유 프로세스만 종료합니다. macOS에서 별도 그룹으로 분리되는 crashpad 보조 프로세스도 실행별 고유 경로로 식별해 메모리 집계와 회수에 포함합니다. 브라우저에는 GUI 실행에 필요한 환경만 전달하고 운영 토큰은 제외합니다. 정상 종료가 지연되면 확인된 그룹을 강제 종료하고 실제 소멸을 확인합니다. 개인 Chrome을 프로세스 이름으로 일괄 종료하지 않습니다. guard까지 강제 종료되거나 소유권 확인이 실패한 경우 소유 마커를 유지하여 재시작을 차단합니다. 이 경우 운영자가 잔존 프로세스를 확인해야 합니다. 소유 확인 없이 마커 파일을 지우면 안 됩니다. -소유 브라우저 프로세스 그룹의 RSS를 30초마다 점검하며 1GiB를 넘으면 회수합니다. 이는 순간적인 메모리 사용까지 막는 OS 하드 제한은 아닙니다. 설치 설정은 Node heap을 256MiB로 제한합니다. 브라우저 응답은 최대 2MiB이며 본문을 스트리밍으로 읽으면서 제한합니다. 인증된 `GET /health`에서 탭 수·호출 수·메모리 관측값과 상태를 확인합니다. +소유 브라우저 프로세스 그룹의 RSS를 30초마다 점검하며 1GiB를 넘으면 회수합니다. 이는 순간적인 메모리 사용까지 막는 OS 하드 제한은 아닙니다. 설치 설정은 Node heap을 256MiB로 제한합니다. 브라우저 응답은 최대 2MiB이며 본문을 스트리밍으로 읽으면서 제한합니다. 인증된 `GET /health`에서 탭 수·호출 수·메모리 관측값과 상태를 확인합니다. `state`는 현재 수명 상태이며 `reason`은 마지막 회수 사유입니다. 준비가 성공해 `state: ready`로 돌아와도 이전 `request-failed` 등이 남을 수 있으므로 `reason`만으로 현재 준비 실패를 판단하지 않습니다. ### 전체 호출량과 인증 diff --git a/docs/superpowers/plans/2026-10-05-oliveyoung-cold-start.md b/docs/superpowers/plans/2026-10-05-oliveyoung-cold-start.md new file mode 100644 index 00000000..e851333f --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-oliveyoung-cold-start.md @@ -0,0 +1,27 @@ +# 올리브영 준비 대기 보강 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement task-by-task. + +**Goal:** 브라우저 준비35초가 뒤따른 올리브영 조회를 만료시키는 회귀를 수정합니다. + +**Architecture:** 준비된 세션에서 Worker60초/브라우저API watchdog18초를 목표 예산으로40초 bounded queue를 사용합니다. 준비·회수를 더한 전체60초 보장은 아닙니다. 인증·슬롯·상한·취소·캐시 계약을 보존합니다. + +**Tech Stack:** TypeScript, Vitest fake timers, Playwright lifecycle. + +## Task 1: cold-start queue + +Files: Modify scripts/relay/oliveyoung.ts; tests/relay/oliveyoung.test.ts 또는 별도 tests/relay/cold-start.test.ts. Modify docs/oliveyoung* 적절한 기존 운영문서 또는 위 spec. + +- [x] 현재코드 전체 테스트로 baseline 확인 +- [x] 첫 operation이35초 걸리고 두번째 operation도200이 되는 실패 테스트를 먼저 실행 +- [x] 두 만료 검사 위치에 명명된40초 상수 사용.60초-18초 예산 이유 한국어주석 +- [x] 정확히40초 대기 요청은 실행하지 않는 경계 및 합류자 freshness/취소 테스트. 기존30초 만료 테스트를40초 경계로 수정 +- [x] 준비 실패는200/빈재고로 반환하거나 성공캐시하지 않는 기존/새 테스트 확인 +- [x] focused 테스트, check·coverage100%·build, 독립 명세 및 품질 리뷰 +- [ ] 한국어중립커밋 후 draft PR 생성. 검증 스크립트 하나만 기존Mac중계에 원자적반영·정상재기동·실제재고검증 + +## 독립 조사 + +- [x] Daiso 현재판매3종×매장키워드2 운영 재고와 공식원본 대조 +- [x] CU/SevenEleven Mac 성공과 Worker차단의 범위 설명 +- [x] GS25 공식 공개배포의 인증지원 여부와 완전복구 조건 조사. 키/로그 노출 및 임의유료호출 없음 diff --git a/docs/superpowers/specs/2026-10-05-oliveyoung-cold-start-design.md b/docs/superpowers/specs/2026-10-05-oliveyoung-cold-start-design.md new file mode 100644 index 00000000..54020d3b --- /dev/null +++ b/docs/superpowers/specs/2026-10-05-oliveyoung-cold-start-design.md @@ -0,0 +1,15 @@ +# 올리브영 브라우저 준비 중 요청 만료 방지 + +사용자가 재고 점검 후 올리브영 오동작 방지를 요청해 앞서 제안한 준비·대기 처리 보강을 진행합니다. + +## 재현 근거 + +운영 inventory는 find-store와 product-search를 동시에 중계합니다. 2026-10-04 23:59 UTC 원장에서는 첫 find-store가 브라우저 준비와 함께 35.1초 후 성공했지만 뒤따른 product-search는 30초 대기 상한을 넘겨 실행하지 않고 503을 반환했습니다. 같은 요청은 준비 후 2.4초에 성공했습니다. 분당/일일 쿼터 소진이 아닙니다. + +## 선택한 변경 + +현재 Worker의 중계 조회 기본 예산은 60초, 브라우저 API 작업 watchdog은 18초입니다. 대기 상한은 40초로 조정해 관찰된 준비35초를 수용합니다. 준비된 세션의 후속 작업을 기준으로 대기40초와 API watchdog18초를 기본 예산60초 안에 배치합니다. 추가 세션 준비와 회수까지 포함한 전체 응답시간 보장은 아닙니다. 대기 만료를 검사하는 두 위치가 같은 상수를 사용합니다. 취소 요청은 그대로 실행하지 않고, 슬롯8·소비자·분당/일일 상한·캐시·직렬 브라우저 실행을 유지합니다. 대기시간을 무한히 늘리거나 일반 오류를 성공/재고0으로 바꾸지 않습니다. + +35초 준비 후 다른 operation이 성공하는 테스트, 정확히40초 대기 만료 및 미실행, 만료된 합류자와 유효한 합류자가 결과를 나누어 받는 테스트, 취소 요청 및 준비 실패가 성공 캐시에 들어가지 않는 기존 테스트를 확인합니다. 수명 상태의 reason은 마지막 실패 기록이라는 점을 문서화하며 ready 판정 변경은 별도 근거 없이는 하지 않습니다. + +전체 check·coverage100%·build와 명세/품질 리뷰 후 draft PR로 제시합니다. 검증한 스크립트 하나를 기존 Mac 중계에 원자적으로 반영합니다. 이전 내용의 해시 일치와 유휴 상태를 확인하고, 백업을 보존한 뒤 정상 종료·재기동과 실제 재고 조회를 검증합니다. Worker 배포나 전체 runtime 교체는 필요하지 않습니다. 다이소는 현재 판매상품과 원본 대조로 진단하고 GS25 인증키와 Cloudflare 출발지 차단은 조사 결과를 별도로 기록합니다. diff --git a/scripts/relay/oliveyoung.ts b/scripts/relay/oliveyoung.ts index 58d7b6cb..08adca2a 100644 --- a/scripts/relay/oliveyoung.ts +++ b/scripts/relay/oliveyoung.ts @@ -9,6 +9,9 @@ export type BrowserRunner = ( path: string, body: Record, ) => Promise; +// 준비된 세션의 작업 watchdog 18초와 여유 2초를 Worker 기본 예산 60초에서 뺀 대기 목표입니다. +// 수명 관리의 추가 준비·회수 시간까지 포함한 전체 응답 시간을 보장하지는 않습니다. +const MAX_QUEUE_WAIT_MS = 40000; const paths: Record = { 'find-store': OLIVEYOUNG_API.STORE_FINDER_PATH, 'product-search-v3': OLIVEYOUNG_API.PRODUCT_SEARCH_PATH, @@ -189,7 +192,7 @@ export function createOliveyoungRelay( entry!.startedAt = Date.now(); if ( [...signals].every( - ([signal, arrived]) => signal.aborted || entry!.startedAt - arrived >= 30000, + ([signal, arrived]) => signal.aborted || entry!.startedAt - arrived >= MAX_QUEUE_WAIT_MS, ) ) return null; @@ -224,7 +227,7 @@ export function createOliveyoungRelay( } try { const result = await entry.task; - if (request.signal.aborted || result === null || entry.startedAt - queuedAt >= 30000) { + if (request.signal.aborted || result === null || entry.startedAt - queuedAt >= MAX_QUEUE_WAIT_MS) { emit('queue', request.signal.aborted ? 'canceled' : 'expired'); return error(503, 'Request expired'); } diff --git a/tests/relay/cold-start.test.ts b/tests/relay/cold-start.test.ts new file mode 100644 index 00000000..d6680ac4 --- /dev/null +++ b/tests/relay/cold-start.test.ts @@ -0,0 +1,119 @@ +import { EventEmitter } from 'node:events'; +import { afterEach, expect, it, vi } from 'vitest'; +import { createBrowserLifecycle } from '../../scripts/relay/lifecycle.js'; +import { createOliveyoungRelay } from '../../scripts/relay/oliveyoung.js'; + +const storeBody = { + lat: 1, + lon: 1, + pageIdx: 1, + searchWords: '', + pogKeys: '', + serviceKeys: '', + mapLat: 1, + mapLon: 1, +}; +const productBody = { includeSoldOut: true, keyword: 'x', page: 1, sort: 'x', size: 1 }; +const request = (operation: string, body: Record, consumer: string) => + new Request(`http://localhost/v1/oliveyoung/${operation}`, { + method: 'POST', + headers: { + authorization: 'Bearer test', + 'x-relay-consumer': consumer.padStart(64, '0'), + }, + body: JSON.stringify(body), + }); + +afterEach(() => vi.useRealTimers()); + +it('페이지 접속 10초와 URL 준비 25초 뒤 두 operation이 한 페이지로 성공한다', async () => { + vi.useFakeTimers(); + const page = Object.assign(new EventEmitter(), { + goto: vi.fn(() => new Promise(resolve => setTimeout(resolve, 10000))), + waitForURL: vi.fn(() => new Promise(resolve => setTimeout(resolve, 25000))), + evaluate: vi.fn().mockResolvedValue({ status: 200, body: { status: 'SUCCESS' } }), + }); + const context = Object.assign(new EventEmitter(), { + newPage: vi.fn().mockResolvedValue(page), + pages: () => [page], + }); + const owner = { + browser: { newContext: vi.fn().mockResolvedValue(context) }, + close: vi.fn().mockResolvedValue(undefined), + rss: vi.fn().mockResolvedValue(100), + pid: 123, + }; + const launch = vi.fn().mockResolvedValue(owner); + const life = createBrowserLifecycle(launch); + const takeQuota = vi.fn().mockResolvedValue(true); + const relay = createOliveyoungRelay('test', life.run, { takeQuota }); + try { + const store = relay(request('find-store', storeBody, '1')); + await vi.advanceTimersByTimeAsync(0); + const product = relay(request('product-search-v3', productBody, '2')); + await vi.advanceTimersByTimeAsync(0); + expect(page.evaluate).not.toHaveBeenCalled(); + expect(life.status().state).toBe('starting'); + + await vi.advanceTimersByTimeAsync(10000); + expect(page.waitForURL).toHaveBeenCalledTimes(1); + expect(page.evaluate).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(25000); + + expect((await store).status).toBe(200); + expect((await product).status).toBe(200); + expect(page.evaluate).toHaveBeenCalledTimes(2); + expect(context.newPage).toHaveBeenCalledTimes(1); + expect(launch).toHaveBeenCalledTimes(1); + expect(takeQuota).toHaveBeenCalledTimes(2); + expect(life.status()).toMatchObject({ state: 'ready', pages: 1, totalCalls: 2 }); + } finally { + await life.close(); + expect(owner.close).toHaveBeenCalledTimes(1); + expect(vi.getTimerCount()).toBe(0); + } +}); + +it.each([35000, 39999])('%i밀리초 브라우저 준비 후 대기한 상품 검색도 성공한다', async (delay) => { + vi.useFakeTimers(); + const result = { status: 'SUCCESS', data: {} }; + const runner = vi.fn() + .mockImplementationOnce(() => new Promise(resolve => setTimeout(() => resolve(result), delay))) + .mockResolvedValue(result); + const takeQuota = vi.fn().mockResolvedValue(true); + const relay = createOliveyoungRelay('test', runner, { takeQuota }); + const store = relay(request('find-store', storeBody, '1')); + await vi.advanceTimersByTimeAsync(0); + expect(runner).toHaveBeenCalledTimes(1); + const product = relay(request('product-search-v3', productBody, '2')); + await vi.advanceTimersByTimeAsync(0); + expect(runner).toHaveBeenCalledTimes(1); + + await vi.advanceTimersByTimeAsync(delay); + + expect((await store).status).toBe(200); + const response = await product; + expect(response.status).toBe(200); + expect(await response.json()).toEqual(result); + expect(runner).toHaveBeenNthCalledWith(2, '/oystore/api/stock/product-search-v3', productBody); + expect(takeQuota).toHaveBeenCalledTimes(2); +}); + +it('브라우저 준비 실패를 성공으로 반환하거나 캐시하지 않고 다음 요청에서 재시도한다', async () => { + const runner = vi.fn() + .mockRejectedValueOnce(new Error('startup failed')) + .mockResolvedValue({ status: 'SUCCESS', data: {} }); + const relay = createOliveyoungRelay('test', runner); + const failed = await relay(request('find-store', storeBody, '1')); + expect(failed.status).toBe(502); + expect(await failed.json()).toEqual({ error: 'Upstream unavailable' }); + + const retry = await relay(request('find-store', storeBody, '2')); + expect(retry.status).toBe(200); + expect(retry.headers.get('x-relay-cache')).toBe('miss'); + expect(runner).toHaveBeenCalledTimes(2); + const cached = await relay(request('find-store', storeBody, '3')); + expect(cached.status).toBe(200); + expect(cached.headers.get('x-relay-cache')).toBe('hit'); + expect(runner).toHaveBeenCalledTimes(2); +}); diff --git a/tests/relay/oliveyoung.test.ts b/tests/relay/oliveyoung.test.ts index ffe78864..b20d8fa0 100644 --- a/tests/relay/oliveyoung.test.ts +++ b/tests/relay/oliveyoung.test.ts @@ -258,12 +258,12 @@ it('만료된 대기는 실행하지 않고 실패한 응답을 캐시하지 않 await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(1)); const queued = relay(request(undefined, JSON.stringify({ goodsNo: 'other' }))); await new Promise((resolve) => setTimeout(resolve, 0)); - clock.mockReturnValue(31000); + clock.mockReturnValue(41000); release({ status: 'SUCCESS' }); await first; expect((await queued).status).toBe(503); expect(runner).toHaveBeenCalledTimes(1); - clock.mockReturnValue(631000); + clock.mockReturnValue(641000); expect((await relay(request())).status).toBe(502); expect((await relay(request())).status).toBe(502); expect(runner).toHaveBeenCalledTimes(3); @@ -297,7 +297,7 @@ it('비동기 관측 실패도 요청 처리에 영향을 주지 않는다', asy }); expect((await relay(request())).status).toBe(200); }); -it('취소된 오래된 요청에 합류한 새 요청은 자기 대기 시간으로 실행한다', async () => { +it('40초 만료된 요청에 합류한 새 요청은 자기 대기 시간으로 실행한다', async () => { const clock = vi.spyOn(Date, 'now').mockReturnValue(1000); let release!: (value: { status: string }) => void; const runner = vi @@ -315,13 +315,14 @@ it('취소된 오래된 요청에 합류한 새 요청은 자기 대기 시간 await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(1)); const old = relay(request()); await new Promise((resolve) => setTimeout(resolve, 0)); - clock.mockReturnValue(31000); + clock.mockReturnValue(41000); const fresh = relay(request()); await new Promise((resolve) => setTimeout(resolve, 0)); release({ status: 'SUCCESS' }); expect((await first).status).toBe(200); expect((await old).status).toBe(503); expect((await fresh).status).toBe(200); + expect(runner).toHaveBeenCalledTimes(2); expect(takeQuota).toHaveBeenCalledTimes(2); clock.mockRestore(); }); @@ -407,7 +408,7 @@ it('공유 요청의 부모 ID와 실제 예산 소비를 기록한다', async ( expect((await child).status).toBe(502); }); -it('브라우저 교체 중 23초 기다린 작업은 실행하고 30초 대기는 만료로 기록한다', async () => { +it('브라우저 교체 중 39999밀리초 기다린 작업은 실행하고 40초 대기는 만료로 기록한다', async () => { const clock = vi.spyOn(Date, 'now').mockReturnValue(0); const events: unknown[] = []; let release!: (value: { status: string }) => void; @@ -418,10 +419,10 @@ it('브라우저 교체 중 23초 기다린 작업은 실행하고 30초 대기 await vi.waitFor(() => expect(runner).toHaveBeenCalledTimes(1)); const expired = relay(request(undefined, JSON.stringify({ goodsNo: 'expired' }))); await new Promise(resolve => setTimeout(resolve, 0)); - clock.mockReturnValue(7000); + clock.mockReturnValue(1); const valid = relay(request(undefined, JSON.stringify({ goodsNo: 'valid' }))); await new Promise(resolve => setTimeout(resolve, 0)); - clock.mockReturnValue(30000); + clock.mockReturnValue(40000); release({ status: 'SUCCESS' }); expect((await first).status).toBe(200); expect((await expired).status).toBe(503); From 6229ca5fa55b576b6d5fac5191b547b47764b16b Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 5 Oct 2026 11:16:07 +0900 Subject: [PATCH 2/3] =?UTF-8?q?fix:=20=ED=8E=B8=EC=9D=98=EC=A0=90=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EB=A5=BC=20Mac=20=EC=A4=91=EA=B3=84=EB=A1=9C?= =?UTF-8?q?=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 11 + docs/convenience-relay.md | 53 +++ .../plans/2026-10-05-convenience-relay.md | 10 + scripts/relay/convenience.ts | 327 ++++++++++++++++++ scripts/relay/dtryx-start.ts | 23 +- scripts/relay/dtryx.ts | 194 +---------- scripts/relay/http-relay.ts | 207 +++++++++++ scripts/relay/logging.ts | 2 +- src/api/configStatus.ts | 13 + src/api/gs25Handlers.ts | 21 +- src/api/handlers.ts | 12 +- src/api/response.ts | 4 + src/api/sevenelevenHandlers.ts | 16 +- src/index.ts | 7 +- src/services/cu/client.ts | 15 +- src/services/cu/index.ts | 5 +- src/services/cu/tools/checkInventory.ts | 11 +- src/services/cu/tools/findNearbyStores.ts | 8 +- src/services/gs25/client.ts | 10 +- src/services/gs25/index.ts | 5 +- src/services/gs25/productSearch.ts | 6 +- src/services/gs25/storeStockTransport.ts | 11 +- src/services/gs25/tools/checkInventory.ts | 12 +- src/services/gs25/tools/findNearbyStores.ts | 9 +- src/services/gs25/tools/searchProducts.ts | 9 +- src/services/seveneleven/client.ts | 54 +-- src/services/seveneleven/index.ts | 13 +- src/services/seveneleven/inventory.ts | 23 +- src/services/seveneleven/productKeyword.ts | 1 + .../seveneleven/tools/checkInventory.ts | 10 +- .../seveneleven/tools/getCatalogSnapshot.ts | 7 +- .../seveneleven/tools/getSearchPopwords.ts | 7 +- .../seveneleven/tools/searchProducts.ts | 8 +- .../seveneleven/tools/searchStores.ts | 8 +- src/services/seveneleven/transport.ts | 66 ++++ src/utils/convenienceTransport.ts | 118 +++++++ .../api/convenience-relay-integration.test.ts | 64 ++++ tests/app/app-info-pages.test.ts | 1 + tests/relay/convenience.test.ts | 287 +++++++++++++++ .../seveneleven/inventory-unknown.test.ts | 31 ++ tests/utils/convenienceTransport.test.ts | 200 +++++++++++ vitest.config.ts | 2 +- 42 files changed, 1590 insertions(+), 311 deletions(-) create mode 100644 docs/convenience-relay.md create mode 100644 docs/superpowers/plans/2026-10-05-convenience-relay.md create mode 100644 scripts/relay/convenience.ts create mode 100644 scripts/relay/http-relay.ts create mode 100644 src/services/seveneleven/transport.ts create mode 100644 src/utils/convenienceTransport.ts create mode 100644 tests/api/convenience-relay-integration.test.ts create mode 100644 tests/relay/convenience.test.ts create mode 100644 tests/services/seveneleven/inventory-unknown.test.ts create mode 100644 tests/utils/convenienceTransport.test.ts diff --git a/.env.example b/.env.example index 525f4015..ff7b5452 100644 --- a/.env.example +++ b/.env.example @@ -24,3 +24,14 @@ OY_ACCESS_CLIENT_ID= OY_ACCESS_CLIENT_SECRET= # 로컬 중계 전용 상태 디렉터리. 호출 원장과 브라우저 소유 마커를 보관합니다. # OY_RELAY_STATE_DIR=/Users/daisorelay/Library/Application Support/DaisoRelay/state + +# 선택: CU·세븐일레븐·GS25 전용 Mac 중계 (미설정 시 직접 API 호출) +# 활성화하려면 URL과 토큰을 함께 설정하고, 빈 예시는 삭제하세요. +# CONVENIENCE_RELAY_URL=https://your-relay.example +# CONVENIENCE_RELAY_TOKEN=your_convenience_relay_token +# CONVENIENCE_ACCESS_CLIENT_ID=your_access_client_id +# CONVENIENCE_ACCESS_CLIENT_SECRET=your_access_client_secret +# 로컬 Dtryx 4320 프로세스에서 별도 원장·로그 경로를 사용합니다. +# CONVENIENCE_RELAY_STATE_DIR=/absolute/path/to/convenience-state +# GS25 재고 인증키는 Worker 또는 Mac 중계 환경에만 설정하세요. +# GS25_API_KEY=your_official_app_api_key diff --git a/docs/convenience-relay.md b/docs/convenience-relay.md new file mode 100644 index 00000000..d94f3180 --- /dev/null +++ b/docs/convenience-relay.md @@ -0,0 +1,53 @@ +# 편의점 Mac 중계 + +CU PocketCU, 세븐일레븐 공식 앱 API와 GS25 상품·재고 API를 현재 Mac의 브라우저 없는 HTTP 중계로 호출합니다. 정상 API 응답 계약은 유지합니다. Worker에 설정이 없으면 기존 직접 호출을 사용하며, URL·토큰 또는 Access 인증의 부분 설정은 오류를 반환합니다. + +## 실행과 설정 + +기존 `scripts/relay/dtryx-start.ts`의 127.0.0.1:4320 서버에 `/v1/convenience/` 경로를 추가합니다. Dtryx의 토큰·원장·로그는 별도로 유지합니다. 기존 Dtryx 설정만 있는 실행도 지원합니다. 편의점 설정을 추가할 때는 아래 두 변수를 함께 설정하세요. + +```dotenv +CONVENIENCE_RELAY_TOKEN=your_convenience_token +CONVENIENCE_RELAY_STATE_DIR=/absolute/path/to/convenience-state +``` + +상태 경로는 Dtryx 상태 경로와 다른 디렉터리여야 합니다. 이 디렉터리의 `quota.json`에는 재시작해도 유지되는 호출 원장이, `logs/`에는 입력·토큰·키를 제외한 제한된 관측 로그가 저장됩니다. 편의점 예산은 하루 3,000회, 분당 30회이며 Dtryx·올리브영 예산과 공유하지 않습니다. + +Worker에는 아래 바인딩을 설정합니다. URL은 중계 호스트의 기본 URL이며 `/v1/convenience`를 붙이지 않습니다. HTTPS 또는 로컬 루프백 HTTP만 허용합니다. REST와 MCP가 동일한 설정을 사용합니다. + +```dotenv +CONVENIENCE_RELAY_URL=https://your-relay.example +CONVENIENCE_RELAY_TOKEN=your_convenience_token +CONVENIENCE_ACCESS_CLIENT_ID=your_access_client_id +CONVENIENCE_ACCESS_CLIENT_SECRET=your_access_client_secret +``` + +Cloudflare Tunnel에서 `/v1/convenience/.*` 경로를 4320 서버로 전달하고 기존 Access 정책을 적용합니다. Access를 사용하지 않는 로컬 실행에서는 두 Access 변수를 모두 생략합니다. 실제 토큰·키는 `.env`, 비공개 런타임 환경, Worker secrets에만 보관합니다. + +GS25 재고는 별도 공식 앱 인증키가 필요합니다. `GS25_API_KEY`를 Worker 또는 Mac 중계 환경에 설정합니다. Worker 키는 인증된 HTTPS 중계의 본문으로 전달되고 공식 API의 `Api-Key` 헤더에만 사용됩니다. Mac 환경 키가 있으면 우선합니다. 키 없음과 원본 401/403은 인증 오류이며, 중계 혼잡·할당량 장애와 구분합니다. 키가 없으면 GS25 상품 검색은 사용할 수 있지만 재고 수량은 복구되지 않습니다. + +## 고정 작업 + +모든 조회는 `Authorization: Bearer `를 요구합니다. 아래 작업에 JSON POST만 허용하며 임의 URL·헤더·외부 경로 전달은 거절합니다. + +| 작업 | 원본 조회 | 입력 | +| --- | --- | --- | +| `cu-prime` | 재고 검색 초기화 | `{}` | +| `cu-stock` | 상품 재고 검색 | 기존 PocketCU 검색 본문 | +| `cu-store` | 매장별 수량 | 기존 PocketCU 매장 본문 | +| `seven-goods` | 상품 검색 | `query`, `pageNo`, `pageSize` | +| `seven-store` | 매장 검색 | `collection: "store"`, `query`, `sort: "Date/desc"`, `listCount` | +| `seven-popwords` | 인기 검색어 | `label` | +| `seven-stock-meta` | 재고 상품 메타정보 | `itemCd` | +| `seven-stock` | 매장별 실재고 | `smCd`, `stokMngCd`, `stokMngQty`, `stockApplicationRate`, `storeList` | +| `seven-pages`, `seven-issues`, `seven-exhibitions` | 카탈로그 | `{}` | +| `gs25-products` | 상품 검색 | `query` | +| `gs25-stock` | 매장·재고 | 기존 BFF 조회 파라미터를 문자열 JSON으로 전달, 선택 `apiKey` | + +인증된 GET `/v1/convenience/health`는 실행·대기 수와 원장·로그 상태를 반환합니다. 공개 Worker `/health`의 `config.convenienceRelay`는 설정 여부만 반환합니다. + +## 제한과 결과 해석 + +본문 16 KiB, 원본 응답 2 MiB, 동시 원본 호출 4개, 전체 요청 32개를 제한합니다. 본문·대기·원장·원본 처리를 합쳐 15초 기한을 적용하며 취소된 대기 요청은 예산을 소비하지 않습니다. 인증·입력 검증 뒤 성공한 상품·메타정보·초기화·카탈로그는 5분, 매장·실재고는 30초 동안 최대 256개·16 MiB의 메모리 캐시에 저장합니다. 캐시 적중은 호출 예산을 소비하지 않습니다. 오류는 캐시하지 않습니다. + +리다이렉트·비 JSON·실패 envelope·잘못된 수량은 오류로 반환합니다. 재고 오류를 수량 0으로 바꾸지 않습니다. 세븐일레븐 수량 `-1`은 미확인이며 품절로 표시하지 않습니다. 반환된 매장의 수량이 모두 미확인이면 `stockAvailable`은 false입니다. 캐시 수량은 조회 시점 이후 바뀔 수 있으므로 실제 방문 전 점포 확인이 필요합니다. diff --git a/docs/superpowers/plans/2026-10-05-convenience-relay.md b/docs/superpowers/plans/2026-10-05-convenience-relay.md new file mode 100644 index 00000000..8eedb8bf --- /dev/null +++ b/docs/superpowers/plans/2026-10-05-convenience-relay.md @@ -0,0 +1,10 @@ +# 편의점 Mac 중계 복구 계획 + +사용자는 전체 복구와 현재 Mac 중계 사용을 승인했습니다. CU와 세븐일레븐은 Mac에서 상품·실재고가 성공하지만 Worker에서는 HTML/403 응답입니다. GS25는 상품 검색의 경로 장애와 재고 인증 부족이 별개입니다. + +1. 기존 Dtryx 4320 서버에서 `/v1/convenience/` 경로를 별도 인증 토큰·호출 원장·동시성 제한을 가진 브라우저 없는 핸들러로 분배합니다. 기존 Tunnel/Access 호스트를 활용해 외부 URL 계약을 유지합니다. Dtryx/OY 예산은 공유하지 않습니다. +2. CU PocketCU 및 세븐일레븐 공식 API와 GS25 상품 검색/재고 조회의 고정 작업만 허용합니다. 임의 URL·헤더 프록시는 금지합니다. 요청·응답 크기, 전체 기한, 취소, 리다이렉트 차단, 인증 실패, upstream 비 JSON/실패 응답을 검증합니다. 정상 JSON은 기존 클라이언트가 해석하도록 전달합니다. +3. 공통 Worker 전송과 CONVENIENCE_RELAY_URL/TOKEN/ACCESS_CLIENT_ID/SECRET 바인딩을 REST 및 MCP 각 서비스에 연결합니다. 설정 없는 기존 동작은 유지합니다. 부분 설정은 명확한 오류로 처리하며 실패를 재고 0으로 변환하지 않습니다. GS25 인증키는 정상적인 공식 앱 사용 경로에서 확인할 수 있을 때만 사용하고 소스에 넣지 않습니다. +4. RED 재현 테스트 후 구현합니다. 기존 응답 계약을 유지하고 세븐일레븐 미확인 수량 -1을 품절로 표시하지 않습니다. 해당 표시의 정확성도 검증합니다. +5. 전체 check, coverage 4항목100%, build 및 독립 명세/품질 리뷰 후 PR을 생성합니다. 승인된 운영 복구 범위에서 Mac 런타임·토큰·Worker 설정을 반영하고 실제 상품명과 매장별 수량을 REST/MCP/게시 CLI로 검증합니다. 올리브영 PR208 냉간 준비 수정을 포함합니다. +6. GS25 재고 인증이 끝내 제공되지 않으면 상품 검색과 나머지 복구를 완료하되 GS25 재고는 미복구로 명시하고 필요한 인증만 요청합니다. 완료 판정은 성공 상태뿐 아니라 실제 수량 응답을 요구합니다. diff --git a/scripts/relay/convenience.ts b/scripts/relay/convenience.ts new file mode 100644 index 00000000..13d242e3 --- /dev/null +++ b/scripts/relay/convenience.ts @@ -0,0 +1,327 @@ +import { CU_DEFAULT_HEADERS } from '../../src/services/cu/client.js'; +import { GS25_DEFAULT_HEADERS } from '../../src/services/gs25/client.js'; +import { GS25_TOTAL_SEARCH_HEADERS } from '../../src/services/gs25/productSearch.js'; +/** 편의점 공식 API의 고정 작업만 중계합니다. */ +import * as z from 'zod'; +import { CU_API } from '../../src/services/cu/api.js'; +import { SEVENELEVEN_API } from '../../src/services/seveneleven/api.js'; +import { GS25_API } from '../../src/services/gs25/api.js'; +import { + createHttpRelay, + RelayError, + abortable, + readJson, + type HttpRelayOptions, +} from './http-relay.js'; +const text = z + .string() + .max(200) + .regex(/^[^\p{Cc}]*$/u); +const code = z + .string() + .max(64) + .regex(/^[A-Za-z0-9_-]*$/); +const count = z.number().int().min(0).max(9999); +const quantity = z.union([z.number().int().min(-1), z.string().regex(/^(?:-1|\d{1,9})$/)]); +const successSchema = z + .object({ success: z.literal(true).optional(), error: z.never().optional() }) + .passthrough(); +const rows = z.array(z.object({}).passthrough()); +const envelope = z + .object({ data: z.unknown(), success: z.boolean().optional(), code: z.number().optional() }) + .passthrough() + .refine( + (v) => + Object.hasOwn(v, 'data') && + v.success !== false && + (v.code === undefined || (v.code >= 200 && v.code < 300)), + ); +const schemas = { + 'cu-prime': z.object({}).strict(), + 'cu-stock': z + .object({ + searchWord: text.min(1), + prevSearchWord: z.literal(''), + spellModifyUseYn: z.literal('Y'), + offset: count, + limit: count.min(1), + searchSort: code.min(1), + }) + .strict(), + 'cu-store': z + .object({ + latVal: z.string().regex(/^-?\d+(\.\d+)?$/), + longVal: z.string().regex(/^-?\d+(\.\d+)?$/), + baseLatVal: z.string().regex(/^-?\d+(\.\d+)?$/), + baseLongVal: z.string().regex(/^-?\d+(\.\d+)?$/), + tabId: z.literal('2'), + filterSvcList: z.array(z.never()).length(0), + filterAdtList: z.array(z.never()).length(0), + stockCdcYn: z.literal('N'), + searchStock: z.literal(false), + pickupType: z.literal('change'), + getRoute: z.literal('IOS'), + areaTplNo: z.literal('0'), + childMealPickUpYn: z.literal('N'), + isCurrentSearch: z.literal('N'), + pageType: text, + searchWord: text, + isRecommend: z.enum(['', 'Y', 'N']), + recommendId: code, + jipCd: code, + itemCd: code, + item_cd: code, + onItemNo: code, + }) + .strict() + .refine( + (v) => + [v.latVal, v.baseLatVal].every((n) => Math.abs(Number(n)) <= 90) && + [v.longVal, v.baseLongVal].every((n) => Math.abs(Number(n)) <= 180), + ), + 'seven-goods': z.object({ query: text.min(1), pageNo: count, pageSize: count.min(1) }).strict(), + 'seven-store': z + .object({ + collection: z.literal('store'), + query: text.min(1), + sort: z.literal('Date/desc'), + listCount: count.min(1), + }) + .strict(), + 'seven-popwords': z.object({ label: code.min(1) }).strict(), + 'seven-stock-meta': z.object({ itemCd: code.min(1) }).strict(), + 'seven-stock': z + .object({ + smCd: code.min(1), + stokMngCd: code.min(1), + stokMngQty: count, + stockApplicationRate: z + .string() + .max(32) + .regex(/^\d*(\.\d+)?$/), + storeList: z.array(code.min(1)).min(1).max(200), + }) + .strict(), + 'seven-pages': z.object({}).strict(), + 'seven-issues': z.object({}).strict(), + 'seven-exhibitions': z.object({}).strict(), + 'gs25-products': z.object({ query: text.min(1) }).strict(), + 'gs25-stock': z + .object({ + serviceCode: z.enum(['01', '02']), + realTimeStockYn: z.enum(['Y', 'N']), + itemCode: code.min(1).optional(), + keyword: text.min(1).optional(), + storeCode: code.min(1).optional(), + pageNumber: z + .string() + .regex(/^\d{1,4}$/) + .optional(), + pageCount: z + .string() + .regex(/^\d{1,4}$/) + .optional(), + myPositionYCoordination: z + .string() + .regex(/^-?\d+(\.\d+)?$/) + .optional(), + centerPositionYCoordination: z + .string() + .regex(/^-?\d+(\.\d+)?$/) + .optional(), + myPositionXCoordination: z + .string() + .regex(/^-?\d+(\.\d+)?$/) + .optional(), + centerPositionXCoordination: z + .string() + .regex(/^-?\d+(\.\d+)?$/) + .optional(), + radiusCondition: z.literal('1000').optional(), + pickupStoreYn: z.literal('N').optional(), + isSuperDlvyStoreSelected: z.literal('N').optional(), + isGs25DlvyStoreSelected: z.literal('N').optional(), + apiKey: z + .string() + .min(1) + .max(512) + .regex(/^[^\p{Cc}]*$/u) + .optional(), + }) + .strict() + .refine( + (v) => + [v.myPositionYCoordination, v.centerPositionYCoordination].every( + (n) => n === undefined || Math.abs(Number(n)) <= 90, + ) && + [v.myPositionXCoordination, v.centerPositionXCoordination].every( + (n) => n === undefined || Math.abs(Number(n)) <= 180, + ), + ), +}; +type Operation = keyof typeof schemas; +const paths: Record = { + 'cu-prime': [ + CU_API.BASE_URL, + CU_API.STOCK_DISPLAY_PATH, + 'POST', + z + .object({}) + .passthrough() + .refine((v) => v.resp_cd === undefined || v.resp_cd === '0000'), + ], + 'cu-stock': [ + CU_API.BASE_URL, + CU_API.STOCK_MAIN_PATH, + 'POST', + z + .object({ + data: z + .object({ + stockResult: z.object({ result: z.object({ rows }).passthrough() }).passthrough(), + }) + .passthrough(), + }) + .passthrough() + .refine((v) => v.resp_cd === undefined || v.resp_cd === '0000'), + ], + 'cu-store': [ + CU_API.BASE_URL, + CU_API.STORE_PATH, + 'POST', + z + .object({ storeList: rows }) + .passthrough() + .refine((v) => v.resp_cd === undefined || v.resp_cd === '0000'), + ], + 'seven-goods': [SEVENELEVEN_API.BASE_URL, SEVENELEVEN_API.SEARCH_GOODS_PATH, 'POST', envelope], + 'seven-store': [SEVENELEVEN_API.BASE_URL, SEVENELEVEN_API.SEARCH_STORE_PATH, 'POST', envelope], + 'seven-popwords': [ + SEVENELEVEN_API.BASE_URL, + SEVENELEVEN_API.SEARCH_POPWORD_PATH, + 'POST', + envelope, + ], + 'seven-stock-meta': [ + SEVENELEVEN_API.BASE_URL, + SEVENELEVEN_API.PRODUCT_SEARCH_STOCK_PATH, + 'GET', + z.object({ itemCd: code.min(1), smCd: code.min(1) }).passthrough(), + ], + 'seven-stock': [ + SEVENELEVEN_API.BASE_URL, + `${SEVENELEVEN_API.REAL_STOCK_MULTI_PATH}/01/stocks`, + 'POST', + envelope.and( + z.object({ + data: z + .object({ + storeList: z.array(z.object({ storeCd: code.min(1), stock: quantity }).passthrough()), + }) + .passthrough(), + }), + ), + ], + 'seven-pages': [SEVENELEVEN_API.BASE_URL, SEVENELEVEN_API.PRODUCT_PAGES_PATH, 'GET', envelope], + 'seven-issues': [SEVENELEVEN_API.BASE_URL, SEVENELEVEN_API.PRODUCT_ISSUES_PATH, 'GET', envelope], + 'seven-exhibitions': [ + SEVENELEVEN_API.BASE_URL, + SEVENELEVEN_API.EXHIBITION_MAIN_PATH, + 'GET', + envelope, + ], + 'gs25-products': [ + GS25_API.APIGW_BASE_URL, + GS25_API.TOTAL_SEARCH_PATH, + 'POST', + z.object({ SearchQueryResult: z.object({}).passthrough() }).passthrough(), + ], + 'gs25-stock': [ + GS25_API.BFF_BASE_URL, + GS25_API.STORE_STOCK_PATH, + 'GET', + z.object({ stores: rows }).passthrough(), + ], +}; +export function createConvenienceRelay( + token: string, + options: Pick & { gs25ApiKey?: string }, +) { + return createHttpRelay(token, { + ...options, + cacheTtl: (operation) => + ['cu-store', 'seven-stock', 'gs25-stock'].includes(operation) ? 30000 : 300000, + prefix: '/v1/convenience/', + operations: Object.keys(paths), + validate(value, operation) { + const parsed = schemas[operation as Operation].safeParse(value); + if (!parsed.success) throw new RelayError(400); + return parsed.data; + }, + async upstream(operation, params, signal, fetcher) { + const [base, path, method, schema] = paths[operation as Operation]; + const url = new URL(path, base); + const body = { ...(params as Record) }; + const headers: Record = { + Accept: 'application/json', + 'Content-Type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Linux; Android 15)', + }; + if (operation.startsWith('cu-')) Object.assign(headers, CU_DEFAULT_HEADERS); + if (operation.startsWith('gs25-')) { + Object.assign( + headers, + operation === 'gs25-products' ? GS25_TOTAL_SEARCH_HEADERS : GS25_DEFAULT_HEADERS, + ); + } + if (operation === 'gs25-stock') { + const key = options.gs25ApiKey?.trim() || body.apiKey; + if (!key) throw new RelayError(403); + headers['Api-Key'] = String(key); + delete body.apiKey; + } + if (method === 'GET' || operation === 'seven-popwords') + url.search = new URLSearchParams(body as Record).toString(); + const response = await abortable( + fetcher(url, { + method, + headers, + redirect: 'manual', + signal, + body: + method === 'POST' + ? JSON.stringify(operation === 'seven-popwords' ? {} : body) + : undefined, + }), + signal, + ); + if (response.status !== 200) { + void response.body?.cancel().catch(() => undefined); + throw new RelayError( + operation === 'gs25-stock' && [401, 403].includes(response.status) + ? response.status + : 502, + ); + } + const data = await readJson(response.body, 2 * 1024 * 1024, signal, 502); + if ( + operation === 'cu-store' && + body.itemCd && + !z + .object({ storeList: z.array(z.object({ stock: quantity }).passthrough()) }) + .safeParse(data).success + ) + throw new RelayError(502); + if ( + operation === 'gs25-stock' && + (body.itemCode || body.keyword) && + !z + .object({ stores: z.array(z.object({ realStockQuantity: quantity }).passthrough()) }) + .safeParse(data).success + ) + throw new RelayError(502); + if (!successSchema.and(schema).safeParse(data).success) throw new RelayError(502); + return data; + }, + }); +} diff --git a/scripts/relay/dtryx-start.ts b/scripts/relay/dtryx-start.ts index b5c4d787..6d26fcdf 100644 --- a/scripts/relay/dtryx-start.ts +++ b/scripts/relay/dtryx-start.ts @@ -7,6 +7,7 @@ import { mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; import { createFileQuota } from './quota.js'; +import { createConvenienceRelay } from './convenience.js'; import { createDtryxRelay } from './dtryx.js'; let logger: ReturnType | undefined; @@ -28,8 +29,24 @@ async function main() { 60000, ); heartbeat.unref(); + let convenience: ((request: Request) => Promise) | undefined; + let convenienceLog: ReturnType | undefined; + if (process.env.CONVENIENCE_RELAY_TOKEN !== undefined || process.env.CONVENIENCE_RELAY_STATE_DIR !== undefined) { + const convenienceToken = process.env.CONVENIENCE_RELAY_TOKEN; + const convenienceDir = process.env.CONVENIENCE_RELAY_STATE_DIR; + if (!convenienceToken?.trim() || !convenienceDir || convenienceDir === stateDir) throw Error('Invalid convenience configuration'); + await mkdir(convenienceDir, { recursive: true, mode: 0o700 }); + convenienceLog = createRelayLogger(join(convenienceDir, 'logs'), { service: 'convenience' }); + const quota = await createFileQuota(join(convenienceDir, 'quota.json')); + const convenienceHandler = createConvenienceRelay(convenienceToken, { takeQuota: quota, onEvent: convenienceLog.append, gs25ApiKey: process.env.GS25_API_KEY }); + convenience = observeRelay(async (request) => { + const response = await convenienceHandler(request); + if (response.ok && new URL(request.url).pathname === '/v1/convenience/health') return Response.json({ ...(await response.json()), quota: quota.status(), logging: convenienceLog!.status() }); + return response; + }, convenienceLog.append); + } const relay = createDtryxRelay(token, { takeQuota, onEvent: log.append }); - const handler = observeRelay(async (request) => { + const dtryxHandler = observeRelay(async (request) => { const response = await relay(request); if (response.ok && new URL(request.url).pathname === '/v1/dtryx/health') { return Response.json({ @@ -40,6 +57,9 @@ async function main() { } return response; }, log.append); + const handler = (request: Request) => new URL(request.url).pathname.startsWith('/v1/convenience/') + ? convenience ? convenience(request) : Promise.resolve(Response.json({ error: 'Not found' }, { status: 404 })) + : dtryxHandler(request); const server = createServer(async (req, res) => { const controller = new AbortController(); res.once('close', () => controller.abort()); @@ -78,6 +98,7 @@ async function main() { clearInterval(heartbeat); log.append({ stage: 'process', outcome: 'stopping' }); void log.flush(); + void convenienceLog?.flush(); server.close(); server.closeAllConnections(); }; diff --git a/scripts/relay/dtryx.ts b/scripts/relay/dtryx.ts index 319a84ff..3b6e245c 100644 --- a/scripts/relay/dtryx.ts +++ b/scripts/relay/dtryx.ts @@ -1,5 +1,5 @@ /** 고정 공개 API만 허용하는 디트릭스 전용 중계입니다. */ -import { randomUUID, timingSafeEqual } from 'node:crypto'; +import { createHttpRelay, RelayError, abortable, readJson, type HttpRelayOptions } from './http-relay.js'; import { DTRYX_API } from '../../src/services/dtryx/api.js'; type Route = 'timetable' | 'play-dates' | 'movies'; @@ -8,69 +8,6 @@ interface Params { cinemaCode: string; playDate?: string; } -interface Options { - takeQuota: () => Promise; - fetcher?: typeof fetch; - onEvent?: (event: { - stage: string; - operation: string; - requestId: string; - outcome: string; - status: number; - durationMs: number; - }) => void | Promise; -} -class RelayError extends Error { - constructor(readonly status: number) { - super('Relay failure'); - } -} -const reply = (status: number, body: unknown) => - Response.json(body, { - status, - headers: { 'Cache-Control': 'no-store' }, - }); - -/** 취소 신호를 무시하는 외부 처리도 기한 안에 반환합니다. */ -function abortable(task: Promise, signal: AbortSignal): Promise { - return new Promise((resolve, reject) => { - const abort = () => reject(new RelayError(499)); - task.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)); - if (signal.aborted) { - reject(new RelayError(499)); - return; - } - signal.addEventListener('abort', abort, { once: true }); - }); -} - -async function readJson( - body: ReadableStream | null, - limit: number, - signal: AbortSignal, - status: number, -): Promise { - if (!body) throw new RelayError(status); - const reader = body.getReader(); - const chunks: Uint8Array[] = []; - let bytes = 0; - try { - while (true) { - const { value, done } = await abortable(reader.read(), signal); - if (done) break; - bytes += value.byteLength; - if (bytes > limit) throw new RelayError(status === 400 ? 413 : status); - chunks.push(value); - } - return JSON.parse(Buffer.concat(chunks).toString('utf8')); - } catch (error) { - if (error instanceof RelayError) throw error; - throw new RelayError(status); - } finally { - void reader.cancel().catch(() => undefined); - } -} - function validate(value: unknown, route: Route): Params { if (!value || typeof value !== 'object' || Array.isArray(value)) throw new RelayError(400); const p = value as Record; @@ -148,125 +85,12 @@ async function upstream(route: Route, p: Params, signal: AbortSignal, fetcher: t return data; } -export function createDtryxRelay(token: string, options: Options) { - if (!token.trim()) throw new Error('DTRYX_RELAY_TOKEN is required'); - const secret = Buffer.from(`Bearer ${token}`); - const fetcher = options.fetcher ?? fetch; - let outstanding = 0; - let active = 0; - const queue: Array<() => void> = []; - function acquire(signal: AbortSignal): Promise { - if (signal.aborted) return Promise.reject(new RelayError(499)); - if (active < 4) { - active++; - return Promise.resolve(); - } - return new Promise((resolve, reject) => { - const start = () => { - signal.removeEventListener('abort', abort); - active++; - resolve(); - }; - const abort = () => { - queue.splice(queue.indexOf(start), 1); - reject(new RelayError(499)); - }; - signal.addEventListener('abort', abort, { once: true }); - queue.push(start); - }); - } - return async (request: Request): Promise => { - const authorization = Buffer.from(request.headers.get('authorization') ?? ''); - if (authorization.length !== secret.length || !timingSafeEqual(authorization, secret)) - return reply(401, { error: 'Unauthorized' }); - const url = new URL(request.url); - if (url.pathname === '/v1/dtryx/health' && request.method === 'GET' && !url.search) - return reply(200, { status: 'ok', active, outstanding, queued: queue.length }); - const name = url.pathname.slice('/v1/dtryx/'.length); - if ( - !url.pathname.startsWith('/v1/dtryx/') || - !['timetable', 'play-dates', 'movies'].includes(name) || - url.search - ) - return reply(404, { error: 'Not found' }); - if (request.method !== 'POST') return reply(405, { error: 'Method not allowed' }); - if (outstanding >= 32) return reply(503, { error: 'Relay busy' }); - outstanding++; - const deadline = performance.now() + 15000; - const controller = new AbortController(); - const abort = () => controller.abort(); - request.signal.addEventListener('abort', abort, { once: true }); - if (request.signal.aborted) abort(); - let expired = false; - const timer = setTimeout(() => { - expired = true; - abort(); - }, 15000); - let acquired = false; - let stage = 'body'; - let resultStatus = 200; - const suppliedId = request.headers.get('x-request-id'); - const requestId = - suppliedId && /^[A-Za-z0-9_-]{1,64}$/.test(suppliedId) ? suppliedId : randomUUID(); - try { - const p = validate( - await readJson(request.body, 16384, controller.signal, 400), - name as Route, - ); - stage = 'queue'; - await acquire(controller.signal); - acquired = true; - if (performance.now() >= deadline) { - expired = true; - abort(); - } - controller.signal.throwIfAborted(); - stage = 'quota'; - if ( - !(await abortable( - options.takeQuota().catch(() => { - throw new RelayError(503); - }), - controller.signal, - )) - ) - throw new RelayError(429); - controller.signal.throwIfAborted(); - stage = 'upstream'; - return reply(200, await upstream(name as Route, p, controller.signal, fetcher)); - } catch (error) { - const status = expired - ? 504 - : controller.signal.aborted - ? 499 - : error instanceof RelayError - ? error.status - : 502; - resultStatus = status; - return reply(status, { error: 'Relay request failed' }); - } finally { - try { - void Promise.resolve( - options.onEvent?.({ - stage, - operation: name, - requestId, - outcome: resultStatus === 200 ? 'ok' : 'error', - status: resultStatus, - durationMs: performance.now() - deadline + 15000, - }), - ).catch(() => undefined); - } catch { - /* 관측 실패는 요청에 영향을 주지 않습니다. */ - } - clearTimeout(timer); - request.signal.removeEventListener('abort', abort); - controller.abort(); - outstanding--; - if (acquired) { - active--; - queue.shift()?.(); - } - } - }; +export function createDtryxRelay(token: string, options: Pick) { + return createHttpRelay(token, { + ...options, + prefix: '/v1/dtryx/', + operations: ['timetable', 'play-dates', 'movies'], + validate: (value, operation) => validate(value, operation as Route), + upstream: (operation, params, signal, fetcher) => upstream(operation as Route, params as Params, signal, fetcher), + }); } diff --git a/scripts/relay/http-relay.ts b/scripts/relay/http-relay.ts new file mode 100644 index 00000000..8482e7ca --- /dev/null +++ b/scripts/relay/http-relay.ts @@ -0,0 +1,207 @@ +import { canonicalKey, createResponseCache } from './cache.js'; +/** 공개 API 중계의 인증·용량·기한·동시성 제한입니다. */ +import { randomUUID, timingSafeEqual } from 'node:crypto'; +export interface HttpRelayOptions { + cacheTtl?: (operation: string) => number; + prefix: string; + operations: string[]; + validate: (value: unknown, operation: string) => unknown; + upstream: ( + operation: string, + params: unknown, + signal: AbortSignal, + fetcher: typeof fetch, + ) => Promise; + takeQuota: () => Promise; + fetcher?: typeof fetch; + onEvent?: (event: { + stage: string; + operation: string; + requestId: string; + outcome: string; + status: number; + durationMs: number; + }) => void | Promise; +} +export class RelayError extends Error { + constructor(readonly status: number) { + super('Relay failure'); + } +} +const reply = (status: number, body: unknown) => + Response.json(body, { + status, + headers: { 'Cache-Control': 'no-store' }, + }); + +/** 취소 신호를 무시하는 외부 처리도 기한 안에 반환합니다. */ +export function abortable(task: Promise, signal: AbortSignal): Promise { + return new Promise((resolve, reject) => { + const abort = () => reject(new RelayError(499)); + task.then(resolve, reject).finally(() => signal.removeEventListener('abort', abort)); + if (signal.aborted) { + reject(new RelayError(499)); + return; + } + signal.addEventListener('abort', abort, { once: true }); + }); +} + +export async function readJson( + body: ReadableStream | null, + limit: number, + signal: AbortSignal, + status: number, +): Promise { + if (!body) throw new RelayError(status); + const reader = body.getReader(); + const chunks: Uint8Array[] = []; + let bytes = 0; + try { + while (true) { + const { value, done } = await abortable(reader.read(), signal); + if (done) break; + bytes += value.byteLength; + if (bytes > limit) throw new RelayError(status === 400 ? 413 : status); + chunks.push(value); + } + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch (error) { + if (error instanceof RelayError) throw error; + throw new RelayError(status); + } finally { + void reader.cancel().catch(() => undefined); + } +} + +export function createHttpRelay(token: string, options: HttpRelayOptions) { + if (!token.trim()) throw new Error('Relay token is required'); + const secret = Buffer.from(`Bearer ${token}`); + const fetcher = options.fetcher ?? fetch; + const cache = options.cacheTtl ? createResponseCache() : undefined; + let outstanding = 0; + let active = 0; + const queue: Array<() => void> = []; + function acquire(signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(new RelayError(499)); + if (active < 4) { + active++; + return Promise.resolve(); + } + return new Promise((resolve, reject) => { + const start = () => { + signal.removeEventListener('abort', abort); + active++; + resolve(); + }; + const abort = () => { + queue.splice(queue.indexOf(start), 1); + reject(new RelayError(499)); + }; + signal.addEventListener('abort', abort, { once: true }); + queue.push(start); + }); + } + return async (request: Request): Promise => { + const authorization = Buffer.from(request.headers.get('authorization') ?? ''); + if (authorization.length !== secret.length || !timingSafeEqual(authorization, secret)) + return reply(401, { error: 'Unauthorized' }); + const url = new URL(request.url); + if (url.pathname === `${options.prefix}health` && request.method === 'GET' && !url.search) + return reply(200, { status: 'ok', active, outstanding, queued: queue.length }); + const name = url.pathname.slice(options.prefix.length); + if ( + !url.pathname.startsWith(options.prefix) || + !options.operations.includes(name) || + url.search + ) + return reply(404, { error: 'Not found' }); + if (request.method !== 'POST') return reply(405, { error: 'Method not allowed' }); + if (outstanding >= 32) return reply(503, { error: 'Relay busy' }); + outstanding++; + const deadline = performance.now() + 15000; + const controller = new AbortController(); + const abort = () => controller.abort(); + request.signal.addEventListener('abort', abort, { once: true }); + if (request.signal.aborted) abort(); + let expired = false; + const timer = setTimeout(() => { + expired = true; + abort(); + }, 15000); + let acquired = false; + let stage = 'body'; + let resultStatus = 200; + const suppliedId = request.headers.get('x-request-id'); + const requestId = + suppliedId && /^[A-Za-z0-9_-]{1,64}$/.test(suppliedId) ? suppliedId : randomUUID(); + try { + const p = options.validate(await readJson(request.body, 16384, controller.signal, 400), name); + const key = canonicalKey(name, p as Record); + const cached = cache?.get(key); + if (cached !== undefined) { + controller.signal.throwIfAborted(); + stage = 'cache'; + return new Response(cached, { + headers: { 'Content-Type': 'application/json', 'Cache-Control': 'no-store' }, + }); + } + stage = 'queue'; + await acquire(controller.signal); + acquired = true; + if (performance.now() >= deadline) { + expired = true; + abort(); + } + controller.signal.throwIfAborted(); + stage = 'quota'; + if ( + !(await abortable( + options.takeQuota().catch(() => { + throw new RelayError(503); + }), + controller.signal, + )) + ) + throw new RelayError(429); + controller.signal.throwIfAborted(); + stage = 'upstream'; + const data = await options.upstream(name, p, controller.signal, fetcher); + if (cache) cache.set(key, JSON.stringify(data), options.cacheTtl!(name)); + return reply(200, data); + } catch (error) { + const status = expired + ? 504 + : controller.signal.aborted + ? 499 + : error instanceof RelayError + ? error.status + : 502; + resultStatus = status; + return reply(status, { error: 'Relay request failed' }); + } finally { + try { + void Promise.resolve( + options.onEvent?.({ + stage, + operation: name, + requestId, + outcome: resultStatus === 200 ? 'ok' : 'error', + status: resultStatus, + durationMs: performance.now() - deadline + 15000, + }), + ).catch(() => undefined); + } catch { + /* 관측 실패는 요청에 영향을 주지 않습니다. */ + } + clearTimeout(timer); + request.signal.removeEventListener('abort', abort); + controller.abort(); + outstanding--; + if (acquired) { + active--; + queue.shift()?.(); + } + } + }; +} diff --git a/scripts/relay/logging.ts b/scripts/relay/logging.ts index 256c47b9..a8c47e6a 100644 --- a/scripts/relay/logging.ts +++ b/scripts/relay/logging.ts @@ -4,7 +4,7 @@ import { randomUUID } from 'node:crypto'; import { join } from 'node:path'; interface LoggerOptions { - service: 'oliveyoung' | 'dtryx'; + service: 'oliveyoung' | 'dtryx' | 'convenience'; now?: () => number; statfs?: (path: string) => Promise<{ bavail: number; bsize: number }>; appendFile?: typeof appendFile; diff --git a/src/api/configStatus.ts b/src/api/configStatus.ts index 4eaa2d00..6499fb24 100644 --- a/src/api/configStatus.ts +++ b/src/api/configStatus.ts @@ -18,6 +18,7 @@ export interface ConfigStatus { accessPairValid: boolean; }; dtryxRelay: ConfigStatus['oliveyoungRelay']; + convenienceRelay: ConfigStatus['oliveyoungRelay']; googleMapsApiKey: ConfigStatusItem & { enabled: false }; kakaoRestApiKey: ConfigStatusItem; zyteApiKey: ConfigStatusItem & { enabled: false }; @@ -56,7 +57,19 @@ export function buildConfigStatus(bindings?: AppBindings): ConfigStatus { (dtryxId && dtryxSecret), }; + const convenienceId = isConfigured(bindings?.CONVENIENCE_ACCESS_CLIENT_ID); + const convenienceSecret = isConfigured(bindings?.CONVENIENCE_ACCESS_CLIENT_SECRET); + const convenienceUrl = isValidDtryxRelayUrl(bindings?.CONVENIENCE_RELAY_URL); + const convenienceToken = isConfigured(bindings?.CONVENIENCE_RELAY_TOKEN); + const conveniencePair = (bindings?.CONVENIENCE_ACCESS_CLIENT_ID === undefined && bindings?.CONVENIENCE_ACCESS_CLIENT_SECRET === undefined) || (convenienceId && convenienceSecret); return { + convenienceRelay: { + configured: convenienceUrl && convenienceToken && conveniencePair, + urlConfigured: isConfigured(bindings?.CONVENIENCE_RELAY_URL), urlValid: convenienceUrl, + tokenConfigured: convenienceToken, accessClientIdConfigured: convenienceId, accessClientSecretConfigured: convenienceSecret, + accessConfigured: convenienceId && convenienceSecret, accessPairValid: conveniencePair, + usedBy: ['cu', 'seveneleven', 'gs25'], + }, oliveyoungRelay: { configured: urlValid && tokenConfigured && accessPairValid, urlConfigured: isConfigured(bindings?.OY_RELAY_URL), diff --git a/src/api/gs25Handlers.ts b/src/api/gs25Handlers.ts index 6ba0eb10..9a59fb28 100644 --- a/src/api/gs25Handlers.ts +++ b/src/api/gs25Handlers.ts @@ -1,3 +1,4 @@ +import { convenienceTransportFromBindings } from '../utils/convenienceTransport.js'; import { geocodeBindings } from '../utils/geocode.js'; /** * GS25 GET API 핸들러 @@ -64,6 +65,7 @@ export async function handleGs25FindStores(c: ApiContext) { }, { timeout: 20000, + ...convenienceTransportFromBindings(c.env), zyteApiKey: c.env?.ZYTE_API_KEY, apiKey: c.env?.GS25_API_KEY, }, @@ -91,7 +93,8 @@ export async function handleGs25FindStores(c: ApiContext) { }, { timeout: 20000, - zyteApiKey: c.env?.ZYTE_API_KEY, + ...convenienceTransportFromBindings(c.env), + zyteApiKey: c.env?.ZYTE_API_KEY, apiKey: c.env?.GS25_API_KEY, }, ); @@ -158,7 +161,8 @@ export async function handleGs25SearchProducts(c: ApiContext) { try { const products = await fetchGs25SearchProducts(keyword, { timeout: 20000, - zyteApiKey: c.env?.ZYTE_API_KEY, + ...convenienceTransportFromBindings(c.env), + zyteApiKey: c.env?.ZYTE_API_KEY, }); const limitedProducts = products.slice(0, limit); @@ -237,7 +241,8 @@ export async function handleGs25CheckInventory(c: ApiContext) { }, { timeout: 20000, - zyteApiKey: c.env?.ZYTE_API_KEY, + ...convenienceTransportFromBindings(c.env), + zyteApiKey: c.env?.ZYTE_API_KEY, apiKey: c.env?.GS25_API_KEY, }, ); @@ -280,13 +285,15 @@ export async function handleGs25CheckInventory(c: ApiContext) { }, { timeout: 20000, - zyteApiKey: c.env?.ZYTE_API_KEY, + ...convenienceTransportFromBindings(c.env), + zyteApiKey: c.env?.ZYTE_API_KEY, apiKey: c.env?.GS25_API_KEY, }, ); } else { const searchProducts = await fetchGs25SearchProducts(keyword, { timeout: 20000, + ...convenienceTransportFromBindings(c.env), zyteApiKey: c.env?.ZYTE_API_KEY, }); firstProduct = searchProducts.find((p) => p.itemCode.length > 0); @@ -306,7 +313,8 @@ export async function handleGs25CheckInventory(c: ApiContext) { }, { timeout: 20000, - zyteApiKey: c.env?.ZYTE_API_KEY, + ...convenienceTransportFromBindings(c.env), + zyteApiKey: c.env?.ZYTE_API_KEY, apiKey: c.env?.GS25_API_KEY, }, ); @@ -322,7 +330,8 @@ export async function handleGs25CheckInventory(c: ApiContext) { }, { timeout: 20000, - zyteApiKey: c.env?.ZYTE_API_KEY, + ...convenienceTransportFromBindings(c.env), + zyteApiKey: c.env?.ZYTE_API_KEY, apiKey: c.env?.GS25_API_KEY, }, ); diff --git a/src/api/handlers.ts b/src/api/handlers.ts index 55017ec9..b5af8505 100644 --- a/src/api/handlers.ts +++ b/src/api/handlers.ts @@ -1,3 +1,4 @@ +import { convenienceTransportFromBindings } from '../utils/convenienceTransport.js'; import { geocodeBindings } from '../utils/geocode.js'; /** * GET API 핸들러 @@ -263,6 +264,7 @@ export async function handleCuFindStores(c: ApiContext) { }, { timeout: 15000, + ...convenienceTransportFromBindings(c.env), apiKey: c.env.ZYTE_API_KEY, }, ); @@ -316,6 +318,7 @@ export async function handleCuCheckInventory(c: ApiContext) { }, { timeout: 15000, + ...convenienceTransportFromBindings(c.env), apiKey: c.env?.ZYTE_API_KEY, }, ); @@ -336,7 +339,8 @@ export async function handleCuCheckInventory(c: ApiContext) { }, { timeout: 15000, - apiKey: c.env?.ZYTE_API_KEY, + ...convenienceTransportFromBindings(c.env), + apiKey: c.env?.ZYTE_API_KEY, }, ); const firstAddress = keywordStoreResult.stores.find((store) => store.address.trim().length > 0)?.address || ''; @@ -361,7 +365,8 @@ export async function handleCuCheckInventory(c: ApiContext) { }, { timeout: 15000, - apiKey: c.env?.ZYTE_API_KEY, + ...convenienceTransportFromBindings(c.env), + apiKey: c.env?.ZYTE_API_KEY, }, ); } @@ -385,7 +390,8 @@ export async function handleCuCheckInventory(c: ApiContext) { }, { timeout: 15000, - apiKey: c.env?.ZYTE_API_KEY, + ...convenienceTransportFromBindings(c.env), + apiKey: c.env?.ZYTE_API_KEY, }, ); } diff --git a/src/api/response.ts b/src/api/response.ts index 02c1b21e..f6ca5b3b 100644 --- a/src/api/response.ts +++ b/src/api/response.ts @@ -16,6 +16,10 @@ export interface AppBindings { OY_RELAY_TOKEN?: string; OY_ACCESS_CLIENT_ID?: string; OY_ACCESS_CLIENT_SECRET?: string; + CONVENIENCE_RELAY_URL?: string; + CONVENIENCE_RELAY_TOKEN?: string; + CONVENIENCE_ACCESS_CLIENT_ID?: string; + CONVENIENCE_ACCESS_CLIENT_SECRET?: string; GS25_API_KEY?: string; GOOGLE_MAPS_API_KEY?: string; KAKAO_REST_API_KEY?: string; diff --git a/src/api/sevenelevenHandlers.ts b/src/api/sevenelevenHandlers.ts index 252269c6..73158df0 100644 --- a/src/api/sevenelevenHandlers.ts +++ b/src/api/sevenelevenHandlers.ts @@ -1,3 +1,4 @@ +import { convenienceTransportFromBindings } from '../utils/convenienceTransport.js'; /** * 세븐일레븐 GET API 핸들러 */ @@ -50,7 +51,8 @@ export async function handleSevenElevenSearchProducts(c: ApiContext) { size, sort, }, - { zyteApiKey: c.env?.ZYTE_API_KEY }, + { ...convenienceTransportFromBindings(c.env), + zyteApiKey: c.env?.ZYTE_API_KEY }, ); return successResponse( @@ -93,7 +95,8 @@ export async function handleSevenElevenSearchStores(c: ApiContext) { keyword, limit: safeLimit, }, - { zyteApiKey: c.env?.ZYTE_API_KEY }, + { ...convenienceTransportFromBindings(c.env), + zyteApiKey: c.env?.ZYTE_API_KEY }, ); return successResponse( @@ -141,6 +144,7 @@ export async function handleSevenElevenCheckInventory(c: ApiContext) { }, { timeout: safeTimeoutMs, + ...convenienceTransportFromBindings(c.env), zyteApiKey: c.env?.ZYTE_API_KEY, }, ); @@ -149,7 +153,7 @@ export async function handleSevenElevenCheckInventory(c: ApiContext) { ? '실시간 재고 데이터가 포함되어 있습니다.' : result.stockError ? `실시간 재고 API 호출에 실패했습니다: ${result.stockError.message}` - : '실시간 재고 API가 현재 제한되어 있어 매장 목록만 제공됩니다.'; + : '선택한 매장의 재고 수량을 확인하지 못했습니다.'; return successResponse( c, @@ -187,7 +191,8 @@ export async function handleSevenElevenGetSearchPopwords(c: ApiContext) { try { const keywords = await fetchSevenElevenSearchPopwords(label, { - zyteApiKey: c.env?.ZYTE_API_KEY, + ...convenienceTransportFromBindings(c.env), + zyteApiKey: c.env?.ZYTE_API_KEY, }); return successResponse(c, { @@ -219,7 +224,8 @@ export async function handleSevenElevenGetCatalogSnapshot(c: ApiContext) { const result = await fetchSevenElevenCatalogSnapshot({ includeIssues, includeExhibition, - zyteApiKey: c.env?.ZYTE_API_KEY, + ...convenienceTransportFromBindings(c.env), + zyteApiKey: c.env?.ZYTE_API_KEY, }); return successResponse(c, { diff --git a/src/index.ts b/src/index.ts index 27d115d9..a333c4e4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,3 +1,4 @@ +import { convenienceTransportFromBindings } from './utils/convenienceTransport.js'; import { handleLotteMartFindStores } from './api/lottemartHandlers.js'; import { geocodeBindings } from './utils/geocode.js'; /** @@ -70,12 +71,12 @@ const createRegistry = (bindings?: AppBindings) => { registry.registerAll([ createDaisoService, () => - createGs25Service({ + createGs25Service({ ...convenienceTransportFromBindings(bindings), ...geocodeBindings(bindings), zyteApiKey: bindings?.ZYTE_API_KEY, apiKey: bindings?.GS25_API_KEY, }), - () => createSevenElevenService({ zyteApiKey: bindings?.ZYTE_API_KEY }), + () => createSevenElevenService({ ...convenienceTransportFromBindings(bindings), zyteApiKey: bindings?.ZYTE_API_KEY }), createCompareService, () => createFeedbackService({ @@ -93,7 +94,7 @@ const createRegistry = (bindings?: AppBindings) => { ...geocodeBindings(bindings), }), () => - createCuService({ + createCuService({ ...convenienceTransportFromBindings(bindings), zyteApiKey: bindings?.ZYTE_API_KEY, ...geocodeBindings(bindings), }), diff --git a/src/services/cu/client.ts b/src/services/cu/client.ts index e82cc34e..7c75ab57 100644 --- a/src/services/cu/client.ts +++ b/src/services/cu/client.ts @@ -1,3 +1,4 @@ +import { hasConvenienceRelay, requestConvenienceRelay, type ConvenienceTransportOptions } from '../../utils/convenienceTransport.js'; import { geocodeLocation } from '../../utils/geocode.js'; /** * CU API 클라이언트 @@ -9,7 +10,7 @@ import { CU_API } from './api.js'; import { cuStockUnavailableReason } from './upstreamError.js'; import type { CuStockItem, CuStockMainResponse, CuStore, CuStoreResponse } from './types.js'; -interface RequestOptions { +interface RequestOptions extends ConvenienceTransportOptions { timeout?: number; apiKey?: string; googleMapsApiKey?: string; @@ -41,7 +42,7 @@ interface FetchCuStockParams { -const CU_DEFAULT_HEADERS = { +export const CU_DEFAULT_HEADERS = { 'Content-Type': 'application/json', Accept: 'application/json, text/javascript, */*; q=0.01', 'X-Requested-With': 'XMLHttpRequest', @@ -106,6 +107,10 @@ async function requestCuJson( body: Record, options: RequestOptions = {}, ): Promise { + if (hasConvenienceRelay(options)) { + const operation = path === CU_API.STORE_PATH ? 'cu-store' : 'cu-stock'; + return requestConvenienceRelay(operation, body, options, options.timeout); + } return fetchJsonWithZyteFallback(`${CU_API.BASE_URL}${path}`, { method: 'POST', timeout: options.timeout, @@ -220,7 +225,7 @@ export async function fetchCuStores( const hasLongitude = typeof params.longitude === 'number' && Number.isFinite(params.longitude); // 좌표가 없고 검색어가 있으면 웹 매장 검색으로 폴백합니다. - if (searchWord.length > 0 && (!hasLatitude || !hasLongitude)) { + if (!hasConvenienceRelay(options) && searchWord.length > 0 && (!hasLatitude || !hasLongitude)) { const html = await requestCuWebHtml( CU_API.WEB_STORE_LIST_PATH, { @@ -296,6 +301,10 @@ export async function fetchCuStores( * 정책 변경 시 사전 호출이 필요한 경우를 대비한 워밍업 요청입니다. */ export async function primeCuStockDisplay(options: RequestOptions = {}): Promise { + if (hasConvenienceRelay(options)) { + await requestConvenienceRelay('cu-prime', {}, options, options.timeout); + return; + } await fetchJson(`${CU_API.BASE_URL}${CU_API.STOCK_DISPLAY_PATH}`, { method: 'POST', timeout: options.timeout, diff --git a/src/services/cu/index.ts b/src/services/cu/index.ts index f4a37367..231a052f 100644 --- a/src/services/cu/index.ts +++ b/src/services/cu/index.ts @@ -1,3 +1,4 @@ +import type { ConvenienceTransportOptions } from '../../utils/convenienceTransport.js'; /** * CU 서비스 프로바이더 */ @@ -14,7 +15,7 @@ const CU_METADATA: ServiceMetadata = { description: 'CU 매장 탐색 및 상품 재고 조회 서비스', }; -export interface CuServiceOptions { +export interface CuServiceOptions extends ConvenienceTransportOptions { zyteApiKey?: string; googleMapsApiKey?: string; kakaoRestApiKey?: string; @@ -29,7 +30,7 @@ class CuService implements ServiceProvider { getTools(): ToolRegistration[] { return [ - createFindNearbyStoresTool(this.options.zyteApiKey), + createFindNearbyStoresTool(this.options.zyteApiKey, this.options), createCheckInventoryTool({ zyteApiKey: this.options.zyteApiKey, ...this.options, diff --git a/src/services/cu/tools/checkInventory.ts b/src/services/cu/tools/checkInventory.ts index 7a4063bc..482b2535 100644 --- a/src/services/cu/tools/checkInventory.ts +++ b/src/services/cu/tools/checkInventory.ts @@ -1,3 +1,4 @@ +import type { ConvenienceTransportOptions } from '../../../utils/convenienceTransport.js'; /** * CU 재고 확인 도구 */ @@ -6,7 +7,7 @@ import * as z from 'zod'; import type { McpToolResponse, ToolRegistration } from '../../../core/types.js'; import { fetchCuStock, fetchCuStores, geocodeCuAddress } from '../client.js'; -interface CheckInventoryArgs { +interface CheckInventoryArgs extends ConvenienceTransportOptions { keyword: string; latitude?: number; longitude?: number; @@ -23,7 +24,7 @@ interface CheckInventoryArgs { naverClientSecret?: string; } -interface CuInventoryToolOptions { +interface CuInventoryToolOptions extends ConvenienceTransportOptions { zyteApiKey?: string; googleMapsApiKey?: string; kakaoRestApiKey?: string; @@ -58,6 +59,7 @@ async function checkInventory(args: CheckInventoryArgs): Promise store.address.trim().length > 0)?.address || ''; if (firstAddress.length > 0) { const geocoded = await geocodeCuAddress(firstAddress, { - timeout: timeoutMs, ...args, + timeout: timeoutMs, googleMapsApiKey, }); if (geocoded) { @@ -106,6 +109,7 @@ async function checkInventory(args: CheckInventoryArgs): Promise - findNearbyStores({ ...args, zyteApiKey: zyteApiKey ?? args.zyteApiKey })) as ( + findNearbyStores({ ...args, ...transport, zyteApiKey: zyteApiKey ?? args.zyteApiKey })) as ( args: unknown, ) => Promise, }; diff --git a/src/services/gs25/client.ts b/src/services/gs25/client.ts index 885e0d83..4617569d 100644 --- a/src/services/gs25/client.ts +++ b/src/services/gs25/client.ts @@ -1,3 +1,4 @@ +import { hasConvenienceRelay, type ConvenienceTransportOptions } from '../../utils/convenienceTransport.js'; import { geocodeLocation } from '../../utils/geocode.js'; /** * GS25 API 클라이언트 @@ -24,7 +25,7 @@ export { sortGs25Stores, } from './storeUtils.js'; -interface RequestOptions { +interface RequestOptions extends ConvenienceTransportOptions { timeout?: number; googleMapsApiKey?: string; kakaoRestApiKey?: string; @@ -62,7 +63,7 @@ interface CacheEntry { stores: Gs25Store[]; } -const GS25_DEFAULT_HEADERS = { +export const GS25_DEFAULT_HEADERS = { Accept: 'application/json, text/plain, */*', 'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7', 'User-Agent': @@ -175,7 +176,7 @@ export async function fetchGs25Stores( longitude, }); - if (useCache) { + if (useCache && !hasConvenienceRelay(options)) { const cached = gs25StoresCache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { return { @@ -227,6 +228,7 @@ export async function fetchGs25Stores( const body = await fetchGs25StoreStockResponse( endpoint.toString(), { + ...options, timeout, zyteApiKey: options.zyteApiKey, apiKey: options.apiKey, @@ -238,7 +240,7 @@ export async function fetchGs25Stores( .map(normalizeStore) .filter((store) => store.storeCode.length > 0); - if (useCache) { + if (useCache && !hasConvenienceRelay(options)) { gs25StoresCache.set(cacheKey, { stores, expiresAt: Date.now() + GS25_STORES_CACHE_TTL_MS, diff --git a/src/services/gs25/index.ts b/src/services/gs25/index.ts index b9abd502..e77c8198 100644 --- a/src/services/gs25/index.ts +++ b/src/services/gs25/index.ts @@ -1,3 +1,4 @@ +import type { ConvenienceTransportOptions } from '../../utils/convenienceTransport.js'; /** * GS25 서비스 프로바이더 */ @@ -15,7 +16,7 @@ const GS25_METADATA: ServiceMetadata = { description: 'GS25 매장 탐색, 상품 키워드 검색, 재고 조회 서비스', }; -interface Gs25ServiceOptions { +interface Gs25ServiceOptions extends ConvenienceTransportOptions { googleMapsApiKey?: string; kakaoRestApiKey?: string; naverClientId?: string; @@ -37,7 +38,7 @@ class Gs25Service implements ServiceProvider { this.options.apiKey, this.options, ), - createSearchProductsTool(this.options.zyteApiKey), + createSearchProductsTool(this.options.zyteApiKey, this.options), createCheckInventoryTool( this.options.googleMapsApiKey, this.options.zyteApiKey, diff --git a/src/services/gs25/productSearch.ts b/src/services/gs25/productSearch.ts index 19c334f7..b52808ca 100644 --- a/src/services/gs25/productSearch.ts +++ b/src/services/gs25/productSearch.ts @@ -1,3 +1,4 @@ +import { hasConvenienceRelay, requestConvenienceRelay, type ConvenienceTransportOptions } from '../../utils/convenienceTransport.js'; /** * GS25 상품 검색 API */ @@ -7,7 +8,7 @@ import { fetchJson } from '../../utils/http.js'; import { GS25_API } from './api.js'; import { toNumber } from './storeUtils.js'; -interface RequestOptions { +interface RequestOptions extends ConvenienceTransportOptions { timeout?: number; zyteApiKey?: string; } @@ -48,7 +49,7 @@ export interface Gs25SearchProduct { stockCheckEnabled: boolean; } -const GS25_TOTAL_SEARCH_HEADERS = { +export const GS25_TOTAL_SEARCH_HEADERS = { Accept: 'application/json, text/plain, */*', 'Accept-Language': 'ko-KR,ko;q=0.9,en-US;q=0.8,en;q=0.7', 'User-Agent': @@ -71,6 +72,7 @@ async function fetchGs25TotalSearchResponse( const endpoint = new URL(GS25_API.TOTAL_SEARCH_PATH, GS25_API.APIGW_BASE_URL); const bodyText = JSON.stringify({ query }); + if (hasConvenienceRelay(options)) return requestConvenienceRelay('gs25-products', { query }, options, timeout); return fetchJson(endpoint.toString(), { ...GS25_DEFAULT_FETCH_OPTIONS, method: 'POST', diff --git a/src/services/gs25/storeStockTransport.ts b/src/services/gs25/storeStockTransport.ts index 7505d2d2..88e1378b 100644 --- a/src/services/gs25/storeStockTransport.ts +++ b/src/services/gs25/storeStockTransport.ts @@ -1,12 +1,14 @@ +import { hasConvenienceRelay, requestConvenienceRelay, type ConvenienceTransportOptions } from '../../utils/convenienceTransport.js'; /** * GS25 재고 원본 요청과 Zyte 대체 경로 */ +import { ServiceError } from '../../core/errors.js'; import { fetchJson, HttpError } from '../../utils/http.js'; import { Gs25UpstreamUnavailableError } from './errors.js'; import type { Gs25StoreStockResponse } from './types.js'; -interface StoreStockTransportOptions { +interface StoreStockTransportOptions extends ConvenienceTransportOptions { timeout?: number; zyteApiKey?: string; apiKey?: string; @@ -26,6 +28,13 @@ export async function fetchGs25StoreStockResponse( options: StoreStockTransportOptions, headers: Record, ): Promise { + if (hasConvenienceRelay(options)) { + const params = Object.fromEntries(new URL(url).searchParams); + return requestConvenienceRelay('gs25-stock', { ...params, ...(options.apiKey?.trim() ? { apiKey: options.apiKey.trim() } : {}) }, options, options.timeout).catch((error: unknown) => { + if (error instanceof ServiceError && isAuthenticationStatus(error.upstreamStatus)) throw new Gs25UpstreamUnavailableError(); + throw error; + }); + } const requestHeaders = withApiKey(headers, options.apiKey); try { diff --git a/src/services/gs25/tools/checkInventory.ts b/src/services/gs25/tools/checkInventory.ts index ecac2970..8b8772d3 100644 --- a/src/services/gs25/tools/checkInventory.ts +++ b/src/services/gs25/tools/checkInventory.ts @@ -1,3 +1,4 @@ +import type { ConvenienceTransportOptions } from '../../../utils/convenienceTransport.js'; import type { GeocodeOptions } from '../../../utils/geocode.js'; /** * GS25 재고 확인 도구 @@ -20,7 +21,7 @@ function getProcessEnvValue(name: string): string | undefined { return typeof process !== 'undefined' ? process.env[name] : undefined; } -interface CheckInventoryArgs { +interface CheckInventoryArgs extends ConvenienceTransportOptions { keyword: string; itemCode?: string; latitude?: number; @@ -72,6 +73,7 @@ async function checkInventory(args: CheckInventoryArgs): Promise 0) { const geocoded = await geocodeGs25Address(firstAddress, { - timeout: timeoutMs, ...args, + timeout: timeoutMs, googleMapsApiKey, }); if (geocoded) { @@ -124,6 +126,7 @@ async function checkInventory(args: CheckInventoryArgs): Promise 0 ) { const geocoded = await geocodeGs25Address(keyword, { - timeout: timeoutMs, ...args, + timeout: timeoutMs, googleMapsApiKey, }); if (geocoded) { @@ -78,6 +79,7 @@ async function findNearbyStores(args: FindNearbyStoresArgs): Promise - searchProducts({ ...args, zyteApiKey: zyteApiKey ?? args.zyteApiKey })) as ( + searchProducts({ ...args, ...transport, zyteApiKey: zyteApiKey ?? args.zyteApiKey })) as ( args: unknown, ) => Promise, }; diff --git a/src/services/seveneleven/client.ts b/src/services/seveneleven/client.ts index b4e0f5bd..27dc1f72 100644 --- a/src/services/seveneleven/client.ts +++ b/src/services/seveneleven/client.ts @@ -1,9 +1,11 @@ +import { hasConvenienceRelay, requestConvenienceRelay, type ConvenienceTransportOptions } from '../../utils/convenienceTransport.js'; /** * 세븐일레븐 API 클라이언트 */ /* c8 ignore start */ -import { withSevenElevenReadCache } from './readCache.js'; +import { requestSevenElevenJson, SEVENELEVEN_DEFAULT_HEADERS, SEVENELEVEN_DEFAULT_FETCH_OPTIONS, type SevenElevenRequestOptions } from './transport.js'; +export type { SevenElevenRequestOptions } from './transport.js'; import { fetchJsonWithZyteFallback } from '../../utils/zyteJsonFallback.js'; import { SEVENELEVEN_API } from './api.js'; import type { @@ -17,11 +19,6 @@ import type { SevenElevenStoreSearchResult, } from './types.js'; -export interface SevenElevenRequestOptions { - timeout?: number; - zyteApiKey?: string; -} - interface SearchProductsParams { query: string; page?: number; @@ -60,16 +57,6 @@ interface StockProductData { stockApplicationRate?: string | number; } -const SEVENELEVEN_DEFAULT_HEADERS = { - Accept: 'application/json, text/plain, */*', - 'Content-Type': 'application/json', - 'User-Agent': 'Mozilla/5.0 (Linux; Android 15)', -} as const; -const SEVENELEVEN_DEFAULT_FETCH_OPTIONS = { - retries: 1, - retryDelayMs: 250, -} as const; - function toNumber(value: unknown): number { if (typeof value === 'number') { return Number.isFinite(value) ? value : 0; @@ -180,27 +167,6 @@ function normalizeStores(items: unknown[]): SevenElevenStore[] { .filter((store) => store.storeCode.length > 0 || store.storeName.length > 0); } -async function requestSevenElevenJson( - path: string, - method: 'GET' | 'POST', - body: unknown, - options: SevenElevenRequestOptions = {}, -): Promise> { - const { timeout = 15000, zyteApiKey } = options; - const url = `${SEVENELEVEN_API.BASE_URL}${path}`; - - return withSevenElevenReadCache(path, body, timeout, () => fetchJsonWithZyteFallback>(url, { - ...SEVENELEVEN_DEFAULT_FETCH_OPTIONS, - method, - retryUnsafeMethods: method === 'POST', - timeout, - headers: SEVENELEVEN_DEFAULT_HEADERS, - body: method === 'POST' ? JSON.stringify(body || {}) : undefined, - zyteApiKey, - zyteTags: { service: 'seveneleven' }, - })); -} - export async function searchSevenElevenProducts( params: SearchProductsParams, options: SevenElevenRequestOptions = {}, @@ -347,7 +313,9 @@ export async function fetchSevenElevenStockProductMeta( const encodedItemCode = encodeURIComponent(itemCode.trim()); const url = `${SEVENELEVEN_API.BASE_URL}${SEVENELEVEN_API.PRODUCT_SEARCH_STOCK_PATH}?itemCd=${encodedItemCode}`; - const response = await fetchJsonWithZyteFallback(url, { + const response = hasConvenienceRelay(options) + ? await requestConvenienceRelay('seven-stock-meta', { itemCd: itemCode.trim() }, options, timeout) + : await fetchJsonWithZyteFallback(url, { ...SEVENELEVEN_DEFAULT_FETCH_OPTIONS, method: 'GET', timeout, @@ -406,7 +374,7 @@ function extractContentArray(data: unknown): unknown[] { } export async function fetchSevenElevenCatalogSnapshot( - options: { + options: ConvenienceTransportOptions & { includeIssues?: boolean; includeExhibition?: boolean; timeout?: number; @@ -416,21 +384,27 @@ export async function fetchSevenElevenCatalogSnapshot( const { includeIssues = true, includeExhibition = true, timeout = 15000, zyteApiKey } = options; const tasks: Array>> = [ - requestSevenElevenJson(SEVENELEVEN_API.PRODUCT_PAGES_PATH, 'GET', null, { timeout, zyteApiKey }), + requestSevenElevenJson(SEVENELEVEN_API.PRODUCT_PAGES_PATH, 'GET', null, { ...options, timeout, zyteApiKey }), includeIssues ? requestSevenElevenJson(SEVENELEVEN_API.PRODUCT_ISSUES_PATH, 'GET', null, { + ...options, timeout, zyteApiKey, }) : Promise.resolve({ data: { content: [] } }), includeExhibition ? requestSevenElevenJson(SEVENELEVEN_API.EXHIBITION_MAIN_PATH, 'GET', null, { + ...options, timeout, zyteApiKey, }) : Promise.resolve({ data: [] }), ]; + if (hasConvenienceRelay(options)) { + const [pages, issues, exhibitions] = await Promise.all(tasks); + return { pages: normalizeProducts(extractContentArray(pages.data)), issues: normalizeProducts(extractContentArray(issues.data)), exhibitions: normalizeExhibitions(extractContentArray(exhibitions.data)) }; + } const [pagesResult, issuesResult, exhibitionsResult] = await Promise.allSettled(tasks); const pagesData = pagesResult.status === 'fulfilled' ? pagesResult.value.data : []; diff --git a/src/services/seveneleven/index.ts b/src/services/seveneleven/index.ts index 76d56bd5..806be511 100644 --- a/src/services/seveneleven/index.ts +++ b/src/services/seveneleven/index.ts @@ -1,3 +1,4 @@ +import type { ConvenienceTransportOptions } from '../../utils/convenienceTransport.js'; /** * 세븐일레븐 서비스 프로바이더 */ @@ -17,7 +18,7 @@ const SEVENELEVEN_METADATA: ServiceMetadata = { description: '세븐일레븐 공개 상품/검색/카탈로그 조회 서비스', }; -export interface SevenElevenServiceOptions { +export interface SevenElevenServiceOptions extends ConvenienceTransportOptions { zyteApiKey?: string; } @@ -28,11 +29,11 @@ class SevenElevenService implements ServiceProvider { getTools(): ToolRegistration[] { return [ - createSearchProductsTool(this.options.zyteApiKey), - createSearchStoresTool(this.options.zyteApiKey), - createCheckInventoryTool(this.options.zyteApiKey), - createGetSearchPopwordsTool(this.options.zyteApiKey), - createGetCatalogSnapshotTool(this.options.zyteApiKey), + createSearchProductsTool(this.options.zyteApiKey, this.options), + createSearchStoresTool(this.options.zyteApiKey, this.options), + createCheckInventoryTool(this.options.zyteApiKey, this.options), + createGetSearchPopwordsTool(this.options.zyteApiKey, this.options), + createGetCatalogSnapshotTool(this.options.zyteApiKey, this.options), ]; } } diff --git a/src/services/seveneleven/inventory.ts b/src/services/seveneleven/inventory.ts index f2b7fa5c..8d5e687e 100644 --- a/src/services/seveneleven/inventory.ts +++ b/src/services/seveneleven/inventory.ts @@ -1,3 +1,4 @@ +import { hasConvenienceRelay, requestConvenienceRelay } from '../../utils/convenienceTransport.js'; /** * 세븐일레븐 재고 조회 클라이언트 * @@ -61,19 +62,6 @@ const SEVENELEVEN_DEFAULT_HEADERS = { 'User-Agent': 'Mozilla/5.0 (Linux; Android 15)', } as const; -function toNumber(value: unknown): number { - if (typeof value === 'number') { - return Number.isFinite(value) ? value : 0; - } - - if (typeof value === 'string') { - const parsed = Number.parseFloat(value); - return Number.isFinite(parsed) ? parsed : 0; - } - - return 0; -} - function toStringValue(value: unknown): string { return typeof value === 'string' ? value : ''; } @@ -85,8 +73,8 @@ function normalizeStockStore(raw: StockApiStoreRaw): SevenElevenStockStore { address: '', latitude: 0, longitude: 0, - stockQuantity: toNumber(raw.stock), - isSoldOut: toNumber(raw.stock) <= 0, + stockQuantity: toNullableNumber(raw.stock) ?? -1, + isSoldOut: toNullableNumber(raw.stock) === 0, distanceM: null, }; } @@ -272,7 +260,9 @@ async function tryStockApi( } try { - const response = await fetchJsonWithZyteFallback>(url, { + const response = hasConvenienceRelay(options) + ? await requestConvenienceRelay>('seven-stock', payload, options, timeout) + : await fetchJsonWithZyteFallback>(url, { method: 'POST', timeout, headers: SEVENELEVEN_DEFAULT_HEADERS, @@ -384,6 +374,7 @@ export async function checkSevenElevenInventory( } const limited = resultStores.slice(0, storeLimit); + stockAvailable = limited.some((store) => store.stockQuantity >= 0); const inStockCount = stockAvailable ? limited.filter((s) => s.stockQuantity > 0).length : 0; return { diff --git a/src/services/seveneleven/productKeyword.ts b/src/services/seveneleven/productKeyword.ts index 1e7e8ca5..df661631 100644 --- a/src/services/seveneleven/productKeyword.ts +++ b/src/services/seveneleven/productKeyword.ts @@ -152,6 +152,7 @@ export async function searchSevenElevenProductsWithVariants( sort, }, { + ...options, timeout, zyteApiKey, }, diff --git a/src/services/seveneleven/tools/checkInventory.ts b/src/services/seveneleven/tools/checkInventory.ts index e02de0ab..20b9da30 100644 --- a/src/services/seveneleven/tools/checkInventory.ts +++ b/src/services/seveneleven/tools/checkInventory.ts @@ -1,3 +1,4 @@ +import type { ConvenienceTransportOptions } from '../../../utils/convenienceTransport.js'; /** * 세븐일레븐 재고 확인 도구 * @@ -34,7 +35,7 @@ function isEncryptedStockFailure( return codeMatched || messageMatched; } -async function checkInventory(args: CheckInventoryArgs, zyteApiKey?: string): Promise { +async function checkInventory(args: CheckInventoryArgs, zyteApiKey?: string, transport: ConvenienceTransportOptions = {}): Promise { const { keyword, storeKeyword = '', @@ -54,6 +55,7 @@ async function checkInventory(args: CheckInventoryArgs, zyteApiKey?: string): Pr }, { timeout: timeoutMs, + ...transport, zyteApiKey, }, ); @@ -66,7 +68,7 @@ async function checkInventory(args: CheckInventoryArgs, zyteApiKey?: string): Pr ? '실시간 재고 API가 암호화 검증에서 거절되었습니다. 현재 구현의 평문 요청만으로는 재고 수량을 조회할 수 없습니다.' : result.stockError ? `실시간 재고 API 호출에 실패했습니다: ${result.stockError.message}` - : '실시간 재고 API가 현재 제한되어 있어 매장 목록만 제공됩니다. stockQuantity가 -1인 경우 재고 수량 미확인 상태입니다.'; + : '선택한 매장의 재고 수량을 확인하지 못했습니다. stockQuantity가 -1인 경우 재고 수량 미확인 상태입니다.'; return { content: [ @@ -95,7 +97,7 @@ async function checkInventory(args: CheckInventoryArgs, zyteApiKey?: string): Pr }; } -export function createCheckInventoryTool(zyteApiKey?: string): ToolRegistration { +export function createCheckInventoryTool(zyteApiKey?: string, transport: ConvenienceTransportOptions = {}): ToolRegistration { return { name: 'seveneleven_check_inventory', metadata: { @@ -113,7 +115,7 @@ export function createCheckInventoryTool(zyteApiKey?: string): ToolRegistration timeoutMs: z.number().optional().default(20000).describe('요청 제한 시간(ms, 기본값: 20000)'), }, }, - handler: (args: unknown) => checkInventory(args as CheckInventoryArgs, zyteApiKey), + handler: (args: unknown) => checkInventory(args as CheckInventoryArgs, zyteApiKey, transport), }; } /* c8 ignore stop */ diff --git a/src/services/seveneleven/tools/getCatalogSnapshot.ts b/src/services/seveneleven/tools/getCatalogSnapshot.ts index db6c0552..fae9a6b8 100644 --- a/src/services/seveneleven/tools/getCatalogSnapshot.ts +++ b/src/services/seveneleven/tools/getCatalogSnapshot.ts @@ -1,3 +1,4 @@ +import type { ConvenienceTransportOptions } from '../../../utils/convenienceTransport.js'; /** * 세븐일레븐 카탈로그 스냅샷 도구 */ @@ -16,6 +17,7 @@ interface GetCatalogSnapshotArgs { async function getCatalogSnapshot( args: GetCatalogSnapshotArgs, zyteApiKey?: string, + transport: ConvenienceTransportOptions = {}, ): Promise { const { includeIssues = true, includeExhibition = true, limit = 20, timeoutMs = 15000 } = args; @@ -23,6 +25,7 @@ async function getCatalogSnapshot( includeIssues, includeExhibition, timeout: timeoutMs, + ...transport, zyteApiKey, }); @@ -58,7 +61,7 @@ async function getCatalogSnapshot( }; } -export function createGetCatalogSnapshotTool(zyteApiKey?: string): ToolRegistration { +export function createGetCatalogSnapshotTool(zyteApiKey?: string, transport: ConvenienceTransportOptions = {}): ToolRegistration { return { name: 'seveneleven_get_catalog_snapshot', metadata: { @@ -71,6 +74,6 @@ export function createGetCatalogSnapshotTool(zyteApiKey?: string): ToolRegistrat timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), }, }, - handler: (args: unknown) => getCatalogSnapshot(args as GetCatalogSnapshotArgs, zyteApiKey), + handler: (args: unknown) => getCatalogSnapshot(args as GetCatalogSnapshotArgs, zyteApiKey, transport), }; } diff --git a/src/services/seveneleven/tools/getSearchPopwords.ts b/src/services/seveneleven/tools/getSearchPopwords.ts index 1eada050..af823424 100644 --- a/src/services/seveneleven/tools/getSearchPopwords.ts +++ b/src/services/seveneleven/tools/getSearchPopwords.ts @@ -1,3 +1,4 @@ +import type { ConvenienceTransportOptions } from '../../../utils/convenienceTransport.js'; /** * 세븐일레븐 인기 검색어 조회 도구 */ @@ -14,11 +15,13 @@ interface GetSearchPopwordsArgs { async function getSearchPopwords( args: GetSearchPopwordsArgs, zyteApiKey?: string, + transport: ConvenienceTransportOptions = {}, ): Promise { const { label = 'home', timeoutMs = 15000 } = args; const keywords = await fetchSevenElevenSearchPopwords(label, { timeout: timeoutMs, + ...transport, zyteApiKey, }); @@ -45,7 +48,7 @@ async function getSearchPopwords( }; } -export function createGetSearchPopwordsTool(zyteApiKey?: string): ToolRegistration { +export function createGetSearchPopwordsTool(zyteApiKey?: string, transport: ConvenienceTransportOptions = {}): ToolRegistration { return { name: 'seveneleven_get_search_popwords', metadata: { @@ -56,6 +59,6 @@ export function createGetSearchPopwordsTool(zyteApiKey?: string): ToolRegistrati timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), }, }, - handler: (args: unknown) => getSearchPopwords(args as GetSearchPopwordsArgs, zyteApiKey), + handler: (args: unknown) => getSearchPopwords(args as GetSearchPopwordsArgs, zyteApiKey, transport), }; } diff --git a/src/services/seveneleven/tools/searchProducts.ts b/src/services/seveneleven/tools/searchProducts.ts index 422b9c74..806aaa57 100644 --- a/src/services/seveneleven/tools/searchProducts.ts +++ b/src/services/seveneleven/tools/searchProducts.ts @@ -1,3 +1,4 @@ +import type { ConvenienceTransportOptions } from '../../../utils/convenienceTransport.js'; /** * 세븐일레븐 상품 검색 도구 */ @@ -21,7 +22,7 @@ function buildTextResponse(payload: Record): McpToolResponse { }; } -async function searchProducts(args: SearchProductsArgs, zyteApiKey?: string): Promise { +async function searchProducts(args: SearchProductsArgs, zyteApiKey?: string, transport: ConvenienceTransportOptions = {}): Promise { const { query, page = 1, size = 20, sort = 'recommend', timeoutMs = 15000 } = args; if (!query || query.trim().length === 0) { @@ -34,6 +35,7 @@ async function searchProducts(args: SearchProductsArgs, zyteApiKey?: string): Pr size, sort, timeout: timeoutMs, + ...transport, zyteApiKey, }); @@ -79,7 +81,7 @@ const searchProductsOutputSchema = { message: z.string().optional().describe('degraded 상태의 원인 메시지'), }; -export function createSearchProductsTool(zyteApiKey?: string): ToolRegistration { +export function createSearchProductsTool(zyteApiKey?: string, transport: ConvenienceTransportOptions = {}): ToolRegistration { return { name: 'seveneleven_search_products', metadata: { @@ -98,6 +100,6 @@ export function createSearchProductsTool(zyteApiKey?: string): ToolRegistration }, outputSchema: searchProductsOutputSchema, }, - handler: (args: unknown) => searchProducts(args as SearchProductsArgs, zyteApiKey), + handler: (args: unknown) => searchProducts(args as SearchProductsArgs, zyteApiKey, transport), }; } diff --git a/src/services/seveneleven/tools/searchStores.ts b/src/services/seveneleven/tools/searchStores.ts index 05895221..9f5b8dd3 100644 --- a/src/services/seveneleven/tools/searchStores.ts +++ b/src/services/seveneleven/tools/searchStores.ts @@ -1,3 +1,4 @@ +import type { ConvenienceTransportOptions } from '../../../utils/convenienceTransport.js'; /** * 세븐일레븐 매장 검색 도구 */ @@ -12,7 +13,7 @@ interface SearchStoresArgs { timeoutMs?: number; } -async function searchStores(args: SearchStoresArgs, zyteApiKey?: string): Promise { +async function searchStores(args: SearchStoresArgs, zyteApiKey?: string, transport: ConvenienceTransportOptions = {}): Promise { const { keyword, limit = 20, timeoutMs = 15000 } = args; if (!keyword || keyword.trim().length === 0) { @@ -26,6 +27,7 @@ async function searchStores(args: SearchStoresArgs, zyteApiKey?: string): Promis }, { timeout: timeoutMs, + ...transport, zyteApiKey, }, ); @@ -49,7 +51,7 @@ async function searchStores(args: SearchStoresArgs, zyteApiKey?: string): Promis }; } -export function createSearchStoresTool(zyteApiKey?: string): ToolRegistration { +export function createSearchStoresTool(zyteApiKey?: string, transport: ConvenienceTransportOptions = {}): ToolRegistration { return { name: 'seveneleven_search_stores', metadata: { @@ -61,6 +63,6 @@ export function createSearchStoresTool(zyteApiKey?: string): ToolRegistration { timeoutMs: z.number().optional().default(15000).describe('요청 제한 시간(ms, 기본값: 15000)'), }, }, - handler: (args: unknown) => searchStores(args as SearchStoresArgs, zyteApiKey), + handler: (args: unknown) => searchStores(args as SearchStoresArgs, zyteApiKey, transport), }; } diff --git a/src/services/seveneleven/transport.ts b/src/services/seveneleven/transport.ts new file mode 100644 index 00000000..ed86885f --- /dev/null +++ b/src/services/seveneleven/transport.ts @@ -0,0 +1,66 @@ +/** 세븐일레븐 공개 JSON API의 직접·중계 전송입니다. */ +import { withSevenElevenReadCache } from './readCache.js'; +import { fetchJsonWithZyteFallback } from '../../utils/zyteJsonFallback.js'; +import { SEVENELEVEN_API } from './api.js'; +import type { SevenElevenApiEnvelope } from './types.js'; +import { + hasConvenienceRelay, + requestConvenienceRelay, + type ConvenienceTransportOptions, +} from '../../utils/convenienceTransport.js'; +export interface SevenElevenRequestOptions extends ConvenienceTransportOptions { + timeout?: number; + zyteApiKey?: string; +} + +export const SEVENELEVEN_DEFAULT_HEADERS = { + Accept: 'application/json, text/plain, */*', + 'Content-Type': 'application/json', + 'User-Agent': 'Mozilla/5.0 (Linux; Android 15)', +} as const; +export const SEVENELEVEN_DEFAULT_FETCH_OPTIONS = { + retries: 1, + retryDelayMs: 250, +} as const; + +export async function requestSevenElevenJson( + path: string, + method: 'GET' | 'POST', + body: unknown, + options: SevenElevenRequestOptions = {}, +): Promise> { + const { timeout = 15000, zyteApiKey } = options; + if (hasConvenienceRelay(options)) { + const operations: Record = { + [SEVENELEVEN_API.SEARCH_GOODS_PATH]: 'seven-goods', + [SEVENELEVEN_API.SEARCH_STORE_PATH]: 'seven-store', + [SEVENELEVEN_API.SEARCH_POPWORD_PATH]: 'seven-popwords', + [SEVENELEVEN_API.PRODUCT_PAGES_PATH]: 'seven-pages', + [SEVENELEVEN_API.PRODUCT_ISSUES_PATH]: 'seven-issues', + [SEVENELEVEN_API.EXHIBITION_MAIN_PATH]: 'seven-exhibitions', + }; + const parsed = new URL(path, SEVENELEVEN_API.BASE_URL); + return requestConvenienceRelay( + operations[parsed.pathname], + parsed.pathname === SEVENELEVEN_API.SEARCH_POPWORD_PATH + ? { label: parsed.searchParams.get('label') } + : body || {}, + options, + timeout, + ); + } + const url = `${SEVENELEVEN_API.BASE_URL}${path}`; + + return withSevenElevenReadCache(path, body, timeout, () => + fetchJsonWithZyteFallback>(url, { + ...SEVENELEVEN_DEFAULT_FETCH_OPTIONS, + method, + retryUnsafeMethods: method === 'POST', + timeout, + headers: SEVENELEVEN_DEFAULT_HEADERS, + body: method === 'POST' ? JSON.stringify(body || {}) : undefined, + zyteApiKey, + zyteTags: { service: 'seveneleven' }, + }), + ); +} diff --git a/src/utils/convenienceTransport.ts b/src/utils/convenienceTransport.ts new file mode 100644 index 00000000..192d8240 --- /dev/null +++ b/src/utils/convenienceTransport.ts @@ -0,0 +1,118 @@ +/** 편의점 공통 릴레이 설정과 인증 전송입니다. */ +import { ServiceError } from '../core/errors.js'; +import { diagnosticHeaders } from './diagnostics.js'; +import { isValidDtryxRelayUrl } from '../services/dtryx/transport.js'; +import { createTimeoutController } from './http.js'; +export interface ConvenienceTransportOptions { + convenienceRelayUrl?: string; + convenienceRelayToken?: string; + convenienceAccessClientId?: string; + convenienceAccessClientSecret?: string; +} +export function convenienceTransportFromBindings(bindings?: { + CONVENIENCE_RELAY_URL?: string; + CONVENIENCE_RELAY_TOKEN?: string; + CONVENIENCE_ACCESS_CLIENT_ID?: string; + CONVENIENCE_ACCESS_CLIENT_SECRET?: string; +}): ConvenienceTransportOptions { + return { + convenienceRelayUrl: bindings?.CONVENIENCE_RELAY_URL, + convenienceRelayToken: bindings?.CONVENIENCE_RELAY_TOKEN, + convenienceAccessClientId: bindings?.CONVENIENCE_ACCESS_CLIENT_ID, + convenienceAccessClientSecret: bindings?.CONVENIENCE_ACCESS_CLIENT_SECRET, + }; +} +export function hasConvenienceRelay(options: ConvenienceTransportOptions): boolean { + return [ + options.convenienceRelayUrl, + options.convenienceRelayToken, + options.convenienceAccessClientId, + options.convenienceAccessClientSecret, + ].some((v) => v !== undefined); +} +export async function requestConvenienceRelay( + operation: string, + body: unknown, + options: ConvenienceTransportOptions, + timeout = 15000, +): Promise { + const { + convenienceRelayUrl: url, + convenienceRelayToken: token, + convenienceAccessClientId: id, + convenienceAccessClientSecret: secret, + } = options; + const access = id !== undefined || secret !== undefined; + if ( + !isValidDtryxRelayUrl(url) || + !token?.trim() || + (access && (!id?.trim() || !secret?.trim())) + ) { + throw new ServiceError( + 'CONVENIENCE_RELAY_CONFIG_ERROR', + '편의점 릴레이 URL, 토큰 및 Access 설정을 확인하세요.', + 503, + false, + ); + } + const headers: Record = { + ...diagnosticHeaders(), + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `Bearer ${token}`, + }; + if (access) { + headers['CF-Access-Client-Id'] = id!; + headers['CF-Access-Client-Secret'] = secret!; + } + const { controller, timeoutId } = createTimeoutController(timeout); + try { + const response = await fetch(`${url!.replace(/\/$/, '')}/v1/convenience/${operation}`, { + method: 'POST', + headers, + body: JSON.stringify(body), + redirect: 'manual', + signal: controller.signal, + }); + if (response.status !== 200) { + void response.body?.cancel().catch(() => undefined); + const status = response.status; + throw new ServiceError( + 'CONVENIENCE_RELAY_FAILED', + '편의점 릴레이 요청에 실패했습니다.', + status === 429 || status === 503 || status === 504 ? status : 502, + status === 429 || status >= 500, + status, + ); + } + const data: unknown = await response.json(); + if ( + !data || + typeof data !== 'object' || + Array.isArray(data) || + ('success' in data && data.success === false) || + 'error' in data || + ('resp_cd' in data && data.resp_cd !== '0000') + ) + throw Error('Invalid response'); + return data as T; + } catch (error) { + if (error instanceof ServiceError) throw error; + if (controller.signal.aborted) + throw new ServiceError( + 'CONVENIENCE_RELAY_TIMEOUT', + '편의점 릴레이 요청 시간이 초과되었습니다.', + 504, + true, + ); + throw new ServiceError( + 'CONVENIENCE_RELAY_FAILED', + '편의점 릴레이 요청에 실패했습니다.', + 502, + true, + ); + } finally { + clearTimeout(timeoutId); + controller.abort(); + } +} diff --git a/tests/api/convenience-relay-integration.test.ts b/tests/api/convenience-relay-integration.test.ts new file mode 100644 index 00000000..83acb8af --- /dev/null +++ b/tests/api/convenience-relay-integration.test.ts @@ -0,0 +1,64 @@ +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; +import { afterEach, expect, it, vi } from 'vitest'; +import app from '../../src/index.js'; +import { clearSevenElevenReadCache } from '../../src/services/seveneleven/readCache.js'; +const env = { CONVENIENCE_RELAY_URL: 'https://relay.example', CONVENIENCE_RELAY_TOKEN: 'token' }; +afterEach(() => vi.unstubAllGlobals()); +const fixture = (url: string) => { + const op = url.split('/').at(-1); + if (op === 'cu-prime') return {}; + if (op === 'cu-stock') + return { + data: { + stockResult: { result: { rows: [{ fields: { item_cd: '123', item_nm: '과자' } }] } }, + }, + }; + if (op === 'cu-store') return { storeList: [{ storeCd: 's', storeNm: '점포', stock: '3' }] }; + if (op === 'gs25-products') + return { + SearchQueryResult: { + Collection: [ + { Documentset: { Document: [{ field: { itemCode: '123', itemName: '과자' } }] } }, + ], + }, + }; + if (op === 'seven-goods') return { data: { content: [{ itemCd: '123', itemOnm: '과자' }] } }; + throw Error(`unexpected direct request ${url}`); +}; +it('REST와 MCP의 편의점 검색이 같은 릴레이 바인딩을 사용한다', async () => { + clearSevenElevenReadCache(); + const fetcher = vi.fn(async (url: string) => Response.json(fixture(url))); + vi.stubGlobal('fetch', fetcher); + for (const path of [ + '/api/cu/inventory?keyword=과자&lat=37&lng=127', + '/api/gs25/products?keyword=과자', + '/api/seveneleven/products?query=과자', + ]) { + const response = await app.request(path, undefined, env); + expect(response.status).toBe(200); + expect(await response.text()).toContain('과자'); + } + const client = new Client({ name: 'test', version: '1' }); + await client.connect( + new StreamableHTTPClientTransport(new URL('https://local.test/mcp'), { + fetch: async (url, init) => app.request(new Request(url, init), undefined, env), + }), + ); + try { + for (const [name, args] of [ + ['cu_check_inventory', { keyword: '과자', latitude: 37, longitude: 127 }], + ['gs25_search_products', { keyword: '과자' }], + ['seveneleven_search_products', { query: '과자' }], + ] as const) { + const result = await client.callTool({ name, arguments: args }); + expect(result.isError).not.toBe(true); + expect(JSON.stringify(result)).toContain('과자'); + } + expect( + fetcher.mock.calls.every(([url]) => url.startsWith('https://relay.example/v1/convenience/')), + ).toEqual(true); + } finally { + await client.close(); + } +}); diff --git a/tests/app/app-info-pages.test.ts b/tests/app/app-info-pages.test.ts index 996ad82b..f9fc0480 100644 --- a/tests/app/app-info-pages.test.ts +++ b/tests/app/app-info-pages.test.ts @@ -293,6 +293,7 @@ describe('기본 페이지', () => { const data = await res.json(); expect(data.status).toBe('ok'); expect(data.config).toEqual({ + convenienceRelay: { configured: false, urlConfigured: false, urlValid: false, tokenConfigured: false, accessClientIdConfigured: false, accessClientSecretConfigured: false, accessConfigured: false, accessPairValid: true, usedBy: ['cu', 'seveneleven', 'gs25'] }, oliveyoungRelay: { configured: false, urlConfigured: false, diff --git a/tests/relay/convenience.test.ts b/tests/relay/convenience.test.ts new file mode 100644 index 00000000..37508dfd --- /dev/null +++ b/tests/relay/convenience.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createConvenienceRelay } from '../../scripts/relay/convenience.js'; +const req = (operation: string, body: unknown) => + new Request(`http://localhost/v1/convenience/${operation}`, { + method: 'POST', + headers: { Authorization: 'Bearer convenience' }, + body: JSON.stringify(body), + }); +describe('편의점 고정 작업 중계', () => { + it('CU 검색 원본을 보존하고 별도 예산을 소비한다', async () => { + const data = { resp_cd: '0000', data: { stockResult: { result: { rows: [] } } } }; + const fetcher = vi.fn(async () => Response.json(data)); + const takeQuota = vi.fn(async () => true); + const relay = createConvenienceRelay('convenience', { fetcher, takeQuota }); + const response = await relay( + req('cu-stock', { + searchWord: '과자', + prevSearchWord: '', + spellModifyUseYn: 'Y', + offset: 0, + limit: 20, + searchSort: 'recom', + }), + ); + expect(response.status).toBe(200); + expect(await response.json()).toEqual(data); + const [url, init] = fetcher.mock.calls[0] as unknown as [URL, RequestInit]; + expect(url.href).toBe('https://www.pocketcu.co.kr/api/search/rest/stock/main'); + expect(init.redirect).toBe('manual'); + expect(takeQuota).toHaveBeenCalledTimes(1); + }); + it('임의 프록시 필드와 실패 JSON은 거절한다', async () => { + const fetcher = vi.fn(async () => Response.json({ success: false, code: 500 })); + const relay = createConvenienceRelay('convenience', { fetcher, takeQuota: async () => true }); + expect( + ( + await relay( + req('seven-goods', { query: '커피', pageNo: 0, pageSize: 20, url: 'https://evil' }), + ) + ).status, + ).toBe(400); + expect(fetcher).not.toHaveBeenCalled(); + expect( + (await relay(req('seven-goods', { query: '커피', pageNo: 0, pageSize: 20 }))).status, + ).toBe(502); + }); +}); +it('성공 검색만 캐시하고 실패 응답은 예산과 재시도를 유지한다', async () => { + const fetcher = vi.fn(async () => Response.json({ SearchQueryResult: {} })); + const takeQuota = vi.fn(async () => true); + const relay = createConvenienceRelay('convenience', { fetcher, takeQuota }); + expect((await relay(req('gs25-products', { query: '과자' }))).status).toBe(200); + expect((await relay(req('gs25-products', { query: '과자' }))).status).toBe(200); + expect(fetcher).toHaveBeenCalledTimes(1); + expect(takeQuota).toHaveBeenCalledTimes(1); +}); +it.each([ + [ + 'seven-stock', + { smCd: '1', stokMngCd: '1', stokMngQty: 1, stockApplicationRate: '1', storeList: ['s'] }, + { data: { storeList: [{ storeCd: 's', stock: 'unknown' }] } }, + ], + [ + 'gs25-stock', + { serviceCode: '01', realTimeStockYn: 'Y', itemCode: '1', apiKey: 'key' }, + { stores: [{ storeCode: 's' }] }, + ], +])('잘못된 재고를 0으로 바꾸지 않는다 %s', async (operation, body, data) => { + const relay = createConvenienceRelay('convenience', { + fetcher: async () => Response.json(data), + takeQuota: async () => true, + }); + expect((await relay(req(operation as string, body))).status).toBe(502); +}); +const storeBody = { + latVal: '37', + longVal: '127', + baseLatVal: '37', + baseLongVal: '127', + tabId: '2', + filterSvcList: [], + filterAdtList: [], + stockCdcYn: 'N', + searchStock: false, + pickupType: 'change', + getRoute: 'IOS', + areaTplNo: '0', + childMealPickUpYn: 'N', + isCurrentSearch: 'N', + pageType: 'search_improve', + searchWord: '', + isRecommend: '', + recommendId: '', + jipCd: '', + itemCd: '', + item_cd: '', + onItemNo: '', +}; +const realStockBody = { + smCd: '1', + stokMngCd: '1', + stokMngQty: 1, + stockApplicationRate: '1', + storeList: ['s'], +}; +it.each([ + ['cu-prime', {}, {}, 'https://www.pocketcu.co.kr/api/search/display/stock', 'POST'], + ['cu-store', storeBody, { storeList: [] }, 'https://www.pocketcu.co.kr/api/store', 'POST'], + [ + 'seven-store', + { collection: 'store', query: '강남', sort: 'Date/desc', listCount: 100 }, + { success: true, code: 200, data: {} }, + 'https://new.7-elevenapp.co.kr/api/v1/open/search/store', + 'POST', + ], + [ + 'seven-popwords', + { label: 'home' }, + { data: [] }, + 'https://new.7-elevenapp.co.kr/api/v1/open/search/popword?label=home', + 'POST', + ], + [ + 'seven-stock-meta', + { itemCd: '123' }, + { itemCd: '123', smCd: '1' }, + 'https://new.7-elevenapp.co.kr/api/v1/open/product/search/stock?itemCd=123', + 'GET', + ], + [ + 'seven-stock', + realStockBody, + { data: { storeList: [{ storeCd: 's', stock: -1 }] } }, + 'https://new.7-elevenapp.co.kr/api/v1/open/real-stock/multi/01/stocks', + 'POST', + ], + ['seven-pages', {}, { data: [] }, 'https://new.7-elevenapp.co.kr/api/v1/product/pages', 'GET'], + ['seven-issues', {}, { data: [] }, 'https://new.7-elevenapp.co.kr/api/v1/product/issues', 'GET'], + [ + 'seven-exhibitions', + {}, + { data: [] }, + 'https://new.7-elevenapp.co.kr/api/v1/exhibition/main/list', + 'GET', + ], + [ + 'gs25-stock', + { serviceCode: '01', realTimeStockYn: 'Y', apiKey: 'worker-key' }, + { stores: [] }, + 'https://b2c-bff.woodongs.com/api/bff/v2/store/stock?serviceCode=01&realTimeStockYn=Y', + 'GET', + ], +])('공식 작업 %s만 지정된 URL로 전송한다', async (operation, body, data, expectedUrl, method) => { + const fetcher = vi.fn(async () => Response.json(data)); + const relay = createConvenienceRelay('convenience', { fetcher, takeQuota: async () => true }); + expect(await (await relay(req(operation as string, body))).json()).toEqual(data); + const [url, init] = fetcher.mock.calls[0] as unknown as [URL, RequestInit]; + expect(url.href).toBe(expectedUrl); + expect(init.method).toBe(method); + expect(init.headers).not.toHaveProperty('Authorization'); + expect(url.search).not.toContain('worker-key'); +}); +it('인증키 없이 GS25 재고 호출을 하지 않고 로컬 키는 안전하게 헤더로 전달한다', async () => { + const fetcher = vi.fn(async () => Response.json({ stores: [{ realStockQuantity: '0' }] })); + const body = { + serviceCode: '01', + realTimeStockYn: 'Y', + itemCode: '123', + myPositionYCoordination: '37', + centerPositionYCoordination: '37', + myPositionXCoordination: '127', + centerPositionXCoordination: '127', + }; + expect( + ( + await createConvenienceRelay('convenience', { fetcher, takeQuota: async () => true })( + req('gs25-stock', body), + ) + ).status, + ).toBe(403); + expect(fetcher).not.toHaveBeenCalled(); + const relay = createConvenienceRelay('convenience', { + fetcher, + takeQuota: async () => true, + gs25ApiKey: 'local-key', + }); + expect((await relay(req('gs25-stock', body))).status).toBe(200); + const [, init] = fetcher.mock.calls[0] as unknown as [URL, RequestInit]; + expect(init.headers).toMatchObject({ 'Api-Key': 'local-key' }); + for (const field of [ + 'myPositionYCoordination', + 'centerPositionYCoordination', + 'myPositionXCoordination', + 'centerPositionXCoordination', + ]) + expect((await relay(req('gs25-stock', { ...body, [field]: '181' }))).status).toBe(400); +}); +it('CU 좌표와 상품 재고의 형식을 검사한다', async () => { + const fetcher = vi.fn(async () => Response.json({ storeList: [{ stock: '3' }] })); + const relay = createConvenienceRelay('convenience', { fetcher, takeQuota: async () => true }); + for (const field of ['latVal', 'baseLatVal', 'longVal', 'baseLongVal']) + expect((await relay(req('cu-store', { ...storeBody, [field]: '181' }))).status).toBe(400); + expect((await relay(req('cu-store', { ...storeBody, itemCd: '123' }))).status).toBe(200); + fetcher.mockImplementation(async () => Response.json({ storeList: [{}] })); + expect((await relay(req('cu-store', { ...storeBody, itemCd: '124' }))).status).toBe(502); +}); +it.each([ + ['cu-prime', {}, { resp_cd: '0000' }, 200], + ['cu-prime', {}, { resp_cd: '3000' }, 502], + [ + 'cu-stock', + { + searchWord: '과자', + prevSearchWord: '', + spellModifyUseYn: 'Y', + offset: 0, + limit: 20, + searchSort: 'recom', + }, + { data: { stockResult: { result: { rows: [] } } } }, + 200, + ], + [ + 'cu-stock', + { + searchWord: '과자', + prevSearchWord: '', + spellModifyUseYn: 'Y', + offset: 0, + limit: 20, + searchSort: 'recom', + }, + { resp_cd: '3000', data: { stockResult: { result: { rows: [] } } } }, + 502, + ], + ['cu-store', storeBody, { resp_cd: '0000', storeList: [] }, 200], + ['cu-store', storeBody, { resp_cd: '3000', storeList: [] }, 502], + ['seven-goods', { query: '과자', pageNo: 0, pageSize: 20 }, { data: [], code: 199 }, 502], + ['seven-goods', { query: '과자', pageNo: 0, pageSize: 20 }, { data: [], code: 300 }, 502], + ['seven-goods', { query: '과자', pageNo: 0, pageSize: 20 }, { code: 200 }, 502], +])('성공 및 실패 코드 검사 %s %j', async (op, body, data, status) => { + const relay = createConvenienceRelay('convenience', { + fetcher: async () => Response.json(data), + takeQuota: async () => true, + }); + expect((await relay(req(op as string, body))).status).toBe(status); +}); +it('HTML, 리다이렉트와 취소 실패가 재고 0이 되지 않는다', async () => { + for (const response of [ + new Response(''), + new Response(null, { status: 302 }), + new Response(new ReadableStream({ cancel: () => Promise.reject(Error('secret')) }), { + status: 403, + }), + ]) { + const relay = createConvenienceRelay('convenience', { + fetcher: async () => response, + takeQuota: async () => true, + }); + expect((await relay(req('cu-prime', {}))).status).toBe(502); + } +}); +it.each([401, 403, 500])('GS25 원본 인증 실패 %i만 인증 상태로 보존한다', async (status) => { + const relay = createConvenienceRelay('convenience', { + fetcher: async () => new Response(null, { status }), + takeQuota: async () => true, + gs25ApiKey: 'key', + }); + expect((await relay(req('gs25-stock', { serviceCode: '01', realTimeStockYn: 'Y' }))).status).toBe( + status === 500 ? 502 : status, + ); +}); +it('실패 envelope는 초기화도 캐시하지 않는다', async () => { + const fetcher = vi.fn(async () => Response.json({ success: false, error: 'upstream failed' })); + const relay = createConvenienceRelay('convenience', { fetcher, takeQuota: async () => true }); + expect((await relay(req('cu-prime', {}))).status).toBe(502); + expect((await relay(req('cu-prime', {}))).status).toBe(502); + expect(fetcher).toHaveBeenCalledTimes(2); +}); +it('정수 범위를 벗어나는 재고 문자열을 거절한다', async () => { + const relay = createConvenienceRelay('convenience', { + fetcher: async () => + Response.json({ data: { storeList: [{ storeCd: 's', stock: '999999999999999999999' }] } }), + takeQuota: async () => true, + }); + expect((await relay(req('seven-stock', realStockBody))).status).toBe(502); +}); diff --git a/tests/services/seveneleven/inventory-unknown.test.ts b/tests/services/seveneleven/inventory-unknown.test.ts new file mode 100644 index 00000000..cb2cd543 --- /dev/null +++ b/tests/services/seveneleven/inventory-unknown.test.ts @@ -0,0 +1,31 @@ +import { afterEach, expect, it, vi } from 'vitest'; +import { checkSevenElevenInventory } from '../../../src/services/seveneleven/inventory.js'; +import { handleSevenElevenCheckInventory } from '../../../src/api/sevenelevenHandlers.js'; +import { createCheckInventoryTool } from '../../../src/services/seveneleven/tools/checkInventory.js'; +import { Hono } from 'hono'; +import type { AppBindings } from '../../../src/api/response.js'; +const options = { convenienceRelayUrl: 'https://relay.example', convenienceRelayToken: 'token' }; +afterEach(() => vi.unstubAllGlobals()); +const prepare = () => vi.stubGlobal('fetch', async (url: string) => { + if (url.endsWith('seven-goods')) return Response.json({ data: { content: [{ itemCd: '123', itemOnm: '과자' }] } }); + if (url.endsWith('seven-store')) return Response.json({ data: { SearchQueryResult: { Collection: [{ Documentset: { Document: [{ storeCd: 's', storeNm: '점포' }] } }] } } }); + if (url.endsWith('seven-stock-meta')) return Response.json({ itemCd: '123', smCd: '1', stokMngCd: '1', stokMngQty: 1, stockApplicationRate: '1' }); + return Response.json({ data: { storeList: [{ storeCd: 's', stock: -1 }, { storeCd: 'other', stock: 2 }] } }); +}); +it('선택한 매장의 -1은 미확인으로 남고 품절이나 재고 확인 성공으로 표시하지 않는다', async () => { + prepare(); + const result = await checkSevenElevenInventory({ productKeyword: '과자', storeKeyword: '점포', storeLimit: 1 }, options); + expect(result.stockAvailable).toBe(false); + expect(result.inStockStoreCount).toBe(0); + expect(result.stores).toEqual([expect.objectContaining({ stockQuantity: -1, isSoldOut: false })]); +}); +it('API가 성공해도 선택한 매장 수량이 미확인이면 원인을 추정하지 않는다', async () => { + prepare(); + const tool = createCheckInventoryTool(undefined, options); + const result = await tool.handler({ keyword: '과자', storeKeyword: '점포' }); + expect(JSON.stringify(result)).toContain('선택한 매장의 재고 수량을 확인하지 못했습니다'); + const app = new Hono<{ Bindings: AppBindings }>(); + app.get('/inventory', handleSevenElevenCheckInventory); + const response = await app.request('/inventory?keyword=과자&storeKeyword=점포', undefined, { CONVENIENCE_RELAY_URL: options.convenienceRelayUrl, CONVENIENCE_RELAY_TOKEN: 'token' }); + expect(await response.text()).toContain('선택한 매장의 재고 수량을 확인하지 못했습니다'); +}); diff --git a/tests/utils/convenienceTransport.test.ts b/tests/utils/convenienceTransport.test.ts new file mode 100644 index 00000000..dc557fd4 --- /dev/null +++ b/tests/utils/convenienceTransport.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, it, vi } from 'vitest'; +import { + hasConvenienceRelay, + requestConvenienceRelay, + convenienceTransportFromBindings, +} from '../../src/utils/convenienceTransport.js'; +const options = { convenienceRelayUrl: 'https://relay.example', convenienceRelayToken: 'token' }; +describe('편의점 Worker 전송', () => { + it('설정 없는 기존 경로와 부분 설정을 구분한다', async () => { + expect(hasConvenienceRelay({})).toBe(false); + expect(hasConvenienceRelay({ convenienceRelayToken: '' })).toBe(true); + await expect( + requestConvenienceRelay('cu-prime', {}, { convenienceRelayToken: 'token' }), + ).rejects.toThrow('설정'); + }); + it('Access와 Bearer 인증을 고정 경로에만 전송한다', async () => { + const fetcher = vi.fn(async () => Response.json({ data: [] })); + vi.stubGlobal('fetch', fetcher); + try { + const binding = convenienceTransportFromBindings({ + CONVENIENCE_RELAY_URL: options.convenienceRelayUrl, + CONVENIENCE_RELAY_TOKEN: 'token', + CONVENIENCE_ACCESS_CLIENT_ID: 'id', + CONVENIENCE_ACCESS_CLIENT_SECRET: 'secret', + }); + expect(await requestConvenienceRelay('seven-pages', {}, binding)).toEqual({ data: [] }); + expect(fetcher.mock.calls[0]).toEqual([ + 'https://relay.example/v1/convenience/seven-pages', + expect.objectContaining({ + redirect: 'manual', + headers: expect.objectContaining({ + Authorization: 'Bearer token', + 'CF-Access-Client-Id': 'id', + }), + }), + ]); + } finally { + vi.unstubAllGlobals(); + } + }); +}); +it.each([ + undefined, + { + convenienceRelayUrl: 'https://relay.example', + convenienceRelayToken: 'token', + convenienceAccessClientId: '', + }, + { + convenienceRelayUrl: 'https://relay.example', + convenienceRelayToken: ' ', + convenienceAccessClientId: 'id', + convenienceAccessClientSecret: 'secret', + }, + { + convenienceRelayUrl: 'https://relay.example', + convenienceRelayToken: 'token', + convenienceAccessClientSecret: 'secret', + }, + { + convenienceRelayUrl: 'https://relay.example', + convenienceRelayToken: 'token', + convenienceAccessClientId: 'id', + convenienceAccessClientSecret: '', + }, +])('부분 설정과 빈 설정을 거절한다 %j', async (input) => { + const binding = convenienceTransportFromBindings(); + expect(hasConvenienceRelay(binding)).toBe(false); + await expect(requestConvenienceRelay('cu-prime', {}, input || {})).rejects.toThrow('설정'); +}); +it.each([429, 503, 504, 302, 401, 500])( + 'HTTP %i를 안전한 서비스 오류로 전달한다', + async (status) => { + vi.stubGlobal('fetch', async () => new Response('secret', { status })); + try { + await expect(requestConvenienceRelay('cu-prime', {}, options)).rejects.toMatchObject({ + status: [429, 503, 504].includes(status) ? status : 502, + upstreamStatus: status, + }); + } finally { + vi.unstubAllGlobals(); + } + }, +); +it.each([null, 1, [], { success: false }, { error: 'secret' }, { resp_cd: '3000' }, ''])( + '실패 JSON과 HTML을 거절한다 %j', + async (data) => { + vi.stubGlobal('fetch', async () => + typeof data === 'string' ? new Response(data) : Response.json(data), + ); + try { + await expect(requestConvenienceRelay('cu-prime', {}, options)).rejects.toThrow('실패'); + } finally { + vi.unstubAllGlobals(); + } + }, +); +it('연결 실패와 취소를 안전하게 처리한다', async () => { + vi.stubGlobal('fetch', async () => { + throw Error('secret'); + }); + await expect(requestConvenienceRelay('cu-prime', {}, options)).rejects.toThrow('실패'); + vi.stubGlobal('fetch', async (_url: string, init: RequestInit) => { + await new Promise((resolve) => init.signal!.addEventListener('abort', () => resolve())); + throw Error('secret'); + }); + await expect(requestConvenienceRelay('cu-prime', {}, options, 1)).rejects.toThrow('초과'); + vi.unstubAllGlobals(); +}); +it('정상 코드와 빈 Access 없이 성공을 전달하고 취소 실패를 무시한다', async () => { + vi.stubGlobal('fetch', async () => Response.json({ resp_cd: '0000' })); + expect(await requestConvenienceRelay('cu-prime', {}, options)).toEqual({ resp_cd: '0000' }); + vi.stubGlobal( + 'fetch', + async () => + new Response(new ReadableStream({ cancel: () => Promise.reject(Error('cancel')) }), { + status: 302, + }), + ); + await expect(requestConvenienceRelay('cu-prime', {}, options)).rejects.toThrow('실패'); + vi.unstubAllGlobals(); +}); +import { buildConfigStatus } from '../../src/api/configStatus.js'; +import { fetchGs25StoreStockResponse } from '../../src/services/gs25/storeStockTransport.js'; +it('편의점 상태는 비밀 없이 부분 Access 설정을 구분한다', () => { + expect(buildConfigStatus().convenienceRelay.configured).toBe(false); + const complete = { + CONVENIENCE_RELAY_URL: 'https://relay.example', + CONVENIENCE_RELAY_TOKEN: 'token', + CONVENIENCE_ACCESS_CLIENT_ID: 'id', + CONVENIENCE_ACCESS_CLIENT_SECRET: 'secret', + }; + expect(buildConfigStatus(complete).convenienceRelay.configured).toBe(true); + expect( + buildConfigStatus({ ...complete, CONVENIENCE_ACCESS_CLIENT_SECRET: '' }).convenienceRelay + .configured, + ).toBe(false); + expect( + buildConfigStatus({ + CONVENIENCE_RELAY_URL: 'https://relay.example', + CONVENIENCE_RELAY_TOKEN: 'token', + }).convenienceRelay.configured, + ).toBe(true); +}); +it.each([401, 403, 503])('GS25 재고 인증과 중계 혼잡 %i를 구분한다', async (status) => { + vi.stubGlobal('fetch', async () => new Response('secret', { status })); + try { + const pending = fetchGs25StoreStockResponse( + 'https://b2c-bff.woodongs.com/api/bff/v2/store/stock?serviceCode=01&realTimeStockYn=Y', + options, + {}, + ); + if (status === 503) + await expect(pending).rejects.toMatchObject({ code: 'CONVENIENCE_RELAY_FAILED' }); + else await expect(pending).rejects.toThrow('GS25_API_KEY'); + } finally { + vi.unstubAllGlobals(); + } +}); +it('GS25 Worker 키는 고정 작업 본문에만 전달한다', async () => { + const fetcher = vi.fn(async () => Response.json({ stores: [] })); + vi.stubGlobal('fetch', fetcher); + try { + expect( + await fetchGs25StoreStockResponse( + 'https://b2c-bff.woodongs.com/api/bff/v2/store/stock?serviceCode=01', + { ...options, apiKey: ' key ' }, + {}, + ), + ).toEqual({ stores: [] }); + expect( + JSON.parse((fetcher.mock.calls[0] as unknown as [string, RequestInit])[1].body as string), + ).toEqual({ serviceCode: '01', apiKey: 'key' }); + } finally { + vi.unstubAllGlobals(); + } +}); +import { requestSevenElevenJson } from '../../src/services/seveneleven/transport.js'; +import { SEVENELEVEN_API } from '../../src/services/seveneleven/api.js'; +it('세븐일레븐 GET·인기어·빈 POST도 고정 작업에 대응한다', async () => { + vi.stubGlobal('fetch', async () => Response.json({ data: [] })); + try { + expect( + await requestSevenElevenJson( + `${SEVENELEVEN_API.SEARCH_POPWORD_PATH}?label=home`, + 'POST', + {}, + options, + ), + ).toEqual({ data: [] }); + expect( + await requestSevenElevenJson(SEVENELEVEN_API.PRODUCT_PAGES_PATH, 'GET', null, options), + ).toEqual({ data: [] }); + expect(await requestSevenElevenJson(SEVENELEVEN_API.SEARCH_GOODS_PATH, 'POST', null)).toEqual({ + data: [], + }); + } finally { + vi.unstubAllGlobals(); + } +}); diff --git a/vitest.config.ts b/vitest.config.ts index 32d1e668..37acf223 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -30,7 +30,7 @@ export default defineConfig({ reporter: ['text', 'text-summary', 'html', 'json'], // 커버리지 대상 파일 - include: ['src/**/*.ts', 'scripts/relay/cache.ts', 'scripts/relay/logging.ts', 'scripts/relay/observed.ts', 'scripts/relay/oliveyoung.ts', 'scripts/relay/dtryx.ts', 'scripts/relay/browser.ts', 'scripts/relay/lifecycle.ts', 'scripts/relay/ownership.ts', 'scripts/relay/guard-controller.ts', 'scripts/relay/guard-runtime.ts', 'scripts/relay/supervisor.ts', 'scripts/relay/quota.ts', 'scripts/relay/consumer.ts', 'scripts/relay/browser-environment.ts'], + include: ['src/**/*.ts', 'scripts/relay/cache.ts', 'scripts/relay/logging.ts', 'scripts/relay/observed.ts', 'scripts/relay/oliveyoung.ts', 'scripts/relay/dtryx.ts', 'scripts/relay/http-relay.ts', 'scripts/relay/convenience.ts', 'scripts/relay/browser.ts', 'scripts/relay/lifecycle.ts', 'scripts/relay/ownership.ts', 'scripts/relay/guard-controller.ts', 'scripts/relay/guard-runtime.ts', 'scripts/relay/supervisor.ts', 'scripts/relay/quota.ts', 'scripts/relay/consumer.ts', 'scripts/relay/browser-environment.ts'], // 커버리지 제외 파일 exclude: [ From 8b47b9250b72ea8eb74365cb1d2bb5a47156fe88 Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 5 Oct 2026 11:20:00 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20=ED=8E=B8=EC=9D=98=EC=A0=90=20?= =?UTF-8?q?=ED=98=B8=EC=B6=9C=20=EC=9B=90=EC=9E=A5=20=EA=B2=BD=EB=A1=9C=20?= =?UTF-8?q?=EB=B3=84=EC=B9=AD=20=EA=B3=B5=EC=9C=A0=20=EB=B0=A9=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/relay/dtryx-start.ts | 5 +++-- scripts/relay/quota.ts | 8 +++++++- tests/relay/quota.test.ts | 17 +++++++++++++++-- 3 files changed, 25 insertions(+), 5 deletions(-) diff --git a/scripts/relay/dtryx-start.ts b/scripts/relay/dtryx-start.ts index 6d26fcdf..f504eca5 100644 --- a/scripts/relay/dtryx-start.ts +++ b/scripts/relay/dtryx-start.ts @@ -6,7 +6,7 @@ import { Readable } from 'node:stream'; import { mkdir } from 'node:fs/promises'; import { join } from 'node:path'; import { pathToFileURL } from 'node:url'; -import { createFileQuota } from './quota.js'; +import { createFileQuota, assertSeparateQuotaDirectories } from './quota.js'; import { createConvenienceRelay } from './convenience.js'; import { createDtryxRelay } from './dtryx.js'; @@ -34,8 +34,9 @@ async function main() { if (process.env.CONVENIENCE_RELAY_TOKEN !== undefined || process.env.CONVENIENCE_RELAY_STATE_DIR !== undefined) { const convenienceToken = process.env.CONVENIENCE_RELAY_TOKEN; const convenienceDir = process.env.CONVENIENCE_RELAY_STATE_DIR; - if (!convenienceToken?.trim() || !convenienceDir || convenienceDir === stateDir) throw Error('Invalid convenience configuration'); + if (!convenienceToken?.trim() || !convenienceDir) throw Error('Invalid convenience configuration'); await mkdir(convenienceDir, { recursive: true, mode: 0o700 }); + await assertSeparateQuotaDirectories(stateDir, convenienceDir); convenienceLog = createRelayLogger(join(convenienceDir, 'logs'), { service: 'convenience' }); const quota = await createFileQuota(join(convenienceDir, 'quota.json')); const convenienceHandler = createConvenienceRelay(convenienceToken, { takeQuota: quota, onEvent: convenienceLog.append, gs25ApiKey: process.env.GS25_API_KEY }); diff --git a/scripts/relay/quota.ts b/scripts/relay/quota.ts index c598caa0..c0f2dae5 100644 --- a/scripts/relay/quota.ts +++ b/scripts/relay/quota.ts @@ -1,5 +1,5 @@ /** 단일 중계 프로세스에서 사용하는 재시작 보존 호출 상한. */ -import { readFile, rename, writeFile } from 'node:fs/promises'; +import { readFile, rename, writeFile, realpath } from 'node:fs/promises'; interface Ledger { day: number; minute: number; @@ -66,3 +66,9 @@ export async function createFileQuota( }, }); } + +/** 서비스별 원장이 같은 실제 디렉터리를 공유하지 않도록 검사합니다. */ +export async function assertSeparateQuotaDirectories(first: string, second: string): Promise { + const [firstPath, secondPath] = await Promise.all([realpath(first), realpath(second)]); + if (firstPath === secondPath) throw new Error('Quota directories must be separate'); +} diff --git a/tests/relay/quota.test.ts b/tests/relay/quota.test.ts index 77a0d115..dfb5b87e 100644 --- a/tests/relay/quota.test.ts +++ b/tests/relay/quota.test.ts @@ -1,8 +1,8 @@ -import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'; +import { mkdtemp, readFile, rm, writeFile, symlink, mkdir } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { afterEach, expect, it } from 'vitest'; -import { createFileQuota } from '../../scripts/relay/quota.js'; +import { createFileQuota, assertSeparateQuotaDirectories } from '../../scripts/relay/quota.js'; const dirs: string[] = []; async function file() { const dir = await mkdtemp(join(tmpdir(), 'oy-quota-')); @@ -106,3 +106,16 @@ it('일일 상한과 시계 역행에도 정확한 상태를 제공한다', asyn retryAfter: 86401, }); }); + +it('원장 디렉터리의 슬래시·점·심볼릭 링크 별칭을 같은 저장소로 거절한다', async () => { + const quotaPath = await file(); + const directory = join(quotaPath, '..'); + const link = join(directory, 'alias'); + await symlink(directory, link); + for (const alias of [directory, directory + '/', directory + '/.', link]) { + await expect(assertSeparateQuotaDirectories(directory, alias)).rejects.toThrow('separate'); + } + const other = join(directory, 'other'); + await mkdir(other); + await expect(assertSeparateQuotaDirectories(directory, other)).resolves.toBeUndefined(); +});