From 96f16d46ce074b9f1deaf0fcbaf89982eabc9d9a Mon Sep 17 00:00:00 2001 From: hmmhmmhm Date: Mon, 27 Jul 2026 22:16:13 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20CU=20MCP=20=EB=A7=A4=EC=9E=A5=20?= =?UTF-8?q?=EC=B0=A8=EB=8B=A8=20=EC=8B=9C=20=EC=97=B0=EA=B2=B0=20=EC=9C=A0?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...07-27-convenience-store-recovery-design.md | 1 + src/services/cu/tools/checkInventory.ts | 128 ++++++++++-------- .../services/cu/tools/checkInventory.test.ts | 74 ++++++++++ 3 files changed, 145 insertions(+), 58 deletions(-) diff --git a/docs/superpowers/specs/2026-07-27-convenience-store-recovery-design.md b/docs/superpowers/specs/2026-07-27-convenience-store-recovery-design.md index 32b5f55..74a7a6c 100644 --- a/docs/superpowers/specs/2026-07-27-convenience-store-recovery-design.md +++ b/docs/superpowers/specs/2026-07-27-convenience-store-recovery-design.md @@ -68,6 +68,7 @@ CU와 세븐일레븐 클라이언트는 기존 헤더, 메서드, 요청 본문 - 선택적 워밍업은 실패해도 무시되는 호출이므로 원본 직결만 사용해 불필요한 Zyte 비용을 막는다. - 워밍업 요청 실패는 기존처럼 본 요청을 막지 않지만, 본 재고 요청의 폴백 실패는 호출자에게 전달한다. - 본 재고 요청도 확인된 `400`/`403`/Zyte `520` 차단이면 빈 항목, `available: false`, 비민감 오류 설명을 반환한다. 매장 검색이 가능하면 매장 결과는 계속 제공한다. +- MCP에서 매장 결과를 요청하지 않으면 후속 매장 호출을 생략하고, 매장 보강까지 차단돼도 재고 degraded 결과는 유지한다. - MCP 도구에는 요청 컨텍스트의 Zyte 및 Google Maps 바인딩을 생성자 옵션으로 주입한다. ### 세븐일레븐 diff --git a/src/services/cu/tools/checkInventory.ts b/src/services/cu/tools/checkInventory.ts index aa94cde..9a052c6 100644 --- a/src/services/cu/tools/checkInventory.ts +++ b/src/services/cu/tools/checkInventory.ts @@ -61,70 +61,80 @@ async function checkInventory(args: CheckInventoryArgs): Promise> | null = null; + let storeResult: Awaited> | null = + storeLimit <= 0 ? { totalCount: 0, stores: [] } : null; + let storeUnavailableReason: string | null = null; - // 좌표 미입력 + 매장 키워드 입력 시, 키워드 기반 매장 검색 결과를 우선 사용합니다. - if (!hasInputLocation && storeKeyword.trim().length > 0) { - const keywordStoreResult = await fetchCuStores( - { - searchWord: storeKeyword, - }, - { - timeout: timeoutMs, - apiKey: zyteApiKey, - }, - ); - const firstAddress = keywordStoreResult.stores.find((store) => store.address.trim().length > 0)?.address || ''; - if (firstAddress.length > 0) { - const geocoded = await geocodeCuAddress(firstAddress, { - timeout: timeoutMs, - googleMapsApiKey: googleMapsApiKey || process.env.GOOGLE_MAPS_API_KEY, - }); - if (geocoded) { - const hasStockSeed = !!firstStockItem?.itemCode; - storeResult = await fetchCuStores( - { - latitude: geocoded.latitude, - longitude: geocoded.longitude, - searchWord: storeKeyword, - itemCd: firstStockItem?.itemCode || '', - onItemNo: firstStockItem?.onItemNo || '', - jipCd: firstStockItem?.itemCode || '', - isRecommend: hasStockSeed ? 'Y' : '', - recommendId: hasStockSeed ? 'stock' : '', - pageType: hasStockSeed ? 'search_improve stock_sch_improve' : 'search_improve', - }, - { - timeout: timeoutMs, - apiKey: zyteApiKey, - }, - ); + try { + // 좌표 미입력 + 매장 키워드 입력 시, 키워드 기반 매장 검색 결과를 우선 사용합니다. + if (!storeResult && !hasInputLocation && storeKeyword.trim().length > 0) { + const keywordStoreResult = await fetchCuStores( + { + searchWord: storeKeyword, + }, + { + timeout: timeoutMs, + apiKey: zyteApiKey, + }, + ); + const firstAddress = + keywordStoreResult.stores.find((store) => store.address.trim().length > 0)?.address || ''; + if (firstAddress.length > 0) { + const geocoded = await geocodeCuAddress(firstAddress, { + timeout: timeoutMs, + googleMapsApiKey: googleMapsApiKey || process.env.GOOGLE_MAPS_API_KEY, + }); + if (geocoded) { + const hasStockSeed = !!firstStockItem?.itemCode; + storeResult = await fetchCuStores( + { + latitude: geocoded.latitude, + longitude: geocoded.longitude, + searchWord: storeKeyword, + itemCd: firstStockItem?.itemCode || '', + onItemNo: firstStockItem?.onItemNo || '', + jipCd: firstStockItem?.itemCode || '', + isRecommend: hasStockSeed ? 'Y' : '', + recommendId: hasStockSeed ? 'stock' : '', + pageType: hasStockSeed ? 'search_improve stock_sch_improve' : 'search_improve', + }, + { + timeout: timeoutMs, + apiKey: zyteApiKey, + }, + ); + } } + storeResult ||= keywordStoreResult; } - storeResult ||= keywordStoreResult; - } - if (!storeResult) { - const hasStockSeed = !!firstStockItem?.itemCode; - storeResult = await fetchCuStores( - { - latitude: resolvedLatitude, - longitude: resolvedLongitude, - searchWord: storeKeyword, - itemCd: firstStockItem?.itemCode || '', - onItemNo: firstStockItem?.onItemNo || '', - jipCd: firstStockItem?.itemCode || '', - isRecommend: hasStockSeed ? 'Y' : '', - recommendId: hasStockSeed ? 'stock' : '', - pageType: hasStockSeed ? 'search_improve stock_sch_improve' : 'search_improve', - }, - { - timeout: timeoutMs, - apiKey: zyteApiKey, - }, - ); + if (!storeResult) { + const hasStockSeed = !!firstStockItem?.itemCode; + storeResult = await fetchCuStores( + { + latitude: resolvedLatitude, + longitude: resolvedLongitude, + searchWord: storeKeyword, + itemCd: firstStockItem?.itemCode || '', + onItemNo: firstStockItem?.onItemNo || '', + jipCd: firstStockItem?.itemCode || '', + isRecommend: hasStockSeed ? 'Y' : '', + recommendId: hasStockSeed ? 'stock' : '', + pageType: hasStockSeed ? 'search_improve stock_sch_improve' : 'search_improve', + }, + { + timeout: timeoutMs, + apiKey: zyteApiKey, + }, + ); + } + } catch (error) { + storeUnavailableReason = + error instanceof Error ? error.message : 'CU 매장 조회 중 알 수 없는 오류가 발생했습니다.'; + storeResult = { totalCount: 0, stores: [] }; } + storeResult ||= { totalCount: 0, stores: [] }; const limitedStores = storeResult.stores.slice(0, storeLimit); const result = { @@ -140,6 +150,8 @@ async function checkInventory(args: CheckInventoryArgs): Promise { }); describe('createCheckInventoryTool', () => { + it('storeLimit이 0이면 차단된 재고 뒤의 매장 조회를 생략한다', async () => { + mockFetch + .mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] }))) + .mockResolvedValueOnce(new Response('blocked', { status: 403 })) + .mockResolvedValueOnce(new Response('error code: 520', { status: 520 })); + + const tool = createCheckInventoryTool({ zyteApiKey: 'worker-key' }); + const result = await tool.handler({ keyword: '커피', storeLimit: 0 }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.inventory).toEqual( + expect.objectContaining({ + available: false, + items: [], + }), + ); + expect(parsed.nearbyStores).toEqual( + expect.objectContaining({ + totalCount: 0, + count: 0, + stores: [], + }), + ); + expect(mockFetch).toHaveBeenCalledTimes(3); + }); + + it('기본 매장 조회까지 차단되어도 degraded 결과를 반환한다', async () => { + mockFetch + .mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] }))) + .mockResolvedValueOnce(new Response('stock blocked', { status: 403 })) + .mockResolvedValueOnce(new Response('error code: 520', { status: 520 })) + .mockResolvedValueOnce(new Response('store blocked', { status: 403 })) + .mockResolvedValueOnce(new Response('error code: 520', { status: 520 })); + + const tool = createCheckInventoryTool({ zyteApiKey: 'worker-key' }); + const result = await tool.handler({ keyword: '커피' }); + + const parsed = JSON.parse(result.content[0].text); + expect(parsed.inventory.available).toBe(false); + expect(parsed.nearbyStores).toEqual( + expect.objectContaining({ + available: false, + unavailableReason: expect.stringContaining('520'), + totalCount: 0, + stores: [], + }), + ); + expect(mockFetch).toHaveBeenCalledTimes(5); + }); + + it('매장 조회가 비 Error 값으로 실패해도 안전한 원인을 반환한다', async () => { + mockFetch + .mockResolvedValueOnce(new Response(JSON.stringify({ areaList: [] }))) + .mockResolvedValueOnce( + new Response( + JSON.stringify({ + spellModifyYn: 'N', + data: { stockResult: { result: { total_count: 0, rows: [] } } }, + }), + ), + ) + .mockRejectedValueOnce('blocked'); + + const result = await createCheckInventoryTool().handler({ keyword: '커피' }); + const parsed = JSON.parse(result.content[0].text); + + expect(parsed.nearbyStores).toEqual( + expect.objectContaining({ + available: false, + unavailableReason: 'CU 매장 조회 중 알 수 없는 오류가 발생했습니다.', + }), + ); + }); + it('주입된 Worker 키로 폴백하고 차단된 재고를 unavailable로 반환한다', async () => { const zyteBanBody = JSON.stringify({ type: '/download/website-ban',