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..f504eca5 100644 --- a/scripts/relay/dtryx-start.ts +++ b/scripts/relay/dtryx-start.ts @@ -6,7 +6,8 @@ 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'; let logger: ReturnType | undefined; @@ -28,8 +29,25 @@ 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) 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 }); + 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 +58,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 +99,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/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/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/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(); +}); 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: [