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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 33 additions & 17 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,17 @@ daiso 인터랙티브 모드

<br>

### daiso_get_display_location

다이소 매장 내 상품의 진열 위치(구역/층)를 조회합니다.

| 파라미터 | 필수 | 설명 |
| :---------- | :--: | :--------------------------------------- |
| `productId` | O | 상품 ID (daiso_search_products로 조회) |
| `storeCode` | O | 매장 코드 (daiso_check_inventory로 조회) |

<br>

### cu_find_nearby_stores

위치 기반으로 주변 CU 매장을 조회합니다.
Expand Down Expand Up @@ -459,6 +470,7 @@ Cloudflare Edge Cache API(`caches.default`)를 사용해 다이소 REST 응답
- `GET /api/daiso/products/:id`: 1시간 TTL
- `GET /api/daiso/stores`: 24시간 TTL
- `GET /api/daiso/inventory`: 10분 TTL
- `GET /api/daiso/display-location`: 10분 TTL
- 공통: `stale-while-revalidate` 적용, 오류 응답(4xx/5xx)은 캐시하지 않음

<br>
Expand All @@ -483,23 +495,24 @@ MCP를 지원하지 않는 서비스를 위한 GET 기반 REST API입니다.

### 엔드포인트

| 엔드포인트 | 설명 |
| :------------------------------ | :---------------------------------- |
| `GET /prompt` | API 사용법 설명 페이지 (에이전트용) |
| `GET /api/daiso/products` | 제품 검색 |
| `GET /api/daiso/products/:id` | 제품 상세 정보 |
| `GET /api/daiso/stores` | 매장 검색 |
| `GET /api/daiso/inventory` | 재고 확인 |
| `GET /api/cu/stores` | CU 매장 검색 |
| `GET /api/cu/inventory` | CU 재고 확인 |
| `GET /api/oliveyoung/stores` | 올리브영 매장 검색 |
| `GET /api/oliveyoung/inventory` | 올리브영 재고 확인 |
| `GET /api/megabox/theaters` | 메가박스 주변 지점 조회 |
| `GET /api/megabox/movies` | 메가박스 영화/회차 목록 조회 |
| `GET /api/megabox/seats` | 메가박스 잔여 좌석 조회 |
| `GET /api/cgv/theaters` | CGV 극장 목록 조회 |
| `GET /api/cgv/movies` | CGV 영화 목록 조회 |
| `GET /api/cgv/timetable` | CGV 상영 시간표 조회 |
| 엔드포인트 | 설명 |
| :-------------------------------- | :---------------------------------- |
| `GET /prompt` | API 사용법 설명 페이지 (에이전트용) |
| `GET /api/daiso/products` | 제품 검색 |
| `GET /api/daiso/products/:id` | 제품 상세 정보 |
| `GET /api/daiso/stores` | 매장 검색 |
| `GET /api/daiso/inventory` | 재고 확인 |
| `GET /api/daiso/display-location` | 진열 위치 조회 |
| `GET /api/cu/stores` | CU 매장 검색 |
| `GET /api/cu/inventory` | CU 재고 확인 |
| `GET /api/oliveyoung/stores` | 올리브영 매장 검색 |
| `GET /api/oliveyoung/inventory` | 올리브영 재고 확인 |
| `GET /api/megabox/theaters` | 메가박스 주변 지점 조회 |
| `GET /api/megabox/movies` | 메가박스 영화/회차 목록 조회 |
| `GET /api/megabox/seats` | 메가박스 잔여 좌석 조회 |
| `GET /api/cgv/theaters` | CGV 극장 목록 조회 |
| `GET /api/cgv/movies` | CGV 영화 목록 조회 |
| `GET /api/cgv/timetable` | CGV 상영 시간표 조회 |

### 제품 검색

Expand Down Expand Up @@ -711,6 +724,9 @@ AI: daiso_check_inventory 도구로 특정 매장 재고 확인
사용자: 강남역 근처 다이소 매장 찾아줘
AI: daiso_find_stores 도구로 매장 검색

사용자: 이 상품 이 매장 어디에 있어?
AI: daiso_get_display_location 도구로 매장 내 진열 위치 조회

사용자: 명동 근처 올리브영 매장 찾아줘
AI: oliveyoung_find_nearby_stores 도구로 주변 매장 검색

Expand Down
27 changes: 27 additions & 0 deletions src/api/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
fetchStoreInventory,
} from '../services/daiso/tools/checkInventory.js';
import { fetchProductById } from '../services/daiso/tools/getPriceInfo.js';
import { fetchDisplayLocation } from '../services/daiso/tools/getDisplayLocation.js';
import { getImageUrl } from '../services/daiso/api.js';
import {
fetchOliveyoungProducts,
Expand Down Expand Up @@ -147,6 +148,32 @@ export async function handleCheckInventory(c: ApiContext) {
}
}

/**
* 진열 위치 조회 API 핸들러
* GET /api/daiso/display-location?productId={상품ID}&storeCode={매장코드}
*/
export async function handleGetDisplayLocation(c: ApiContext) {
const productId = c.req.query('productId');
const storeCode = c.req.query('storeCode');

if (!productId) {
return errorResponse(c, 'MISSING_PRODUCT_ID', '상품 ID(productId)를 입력해주세요.');
}

if (!storeCode) {
return errorResponse(c, 'MISSING_STORE_CODE', '매장 코드(storeCode)를 입력해주세요.');
}

try {
const result = await fetchDisplayLocation(productId, storeCode);

return successResponse(c, result);
} catch (error) {
const message = error instanceof Error ? error.message : '알 수 없는 오류가 발생했습니다.';
return errorResponse(c, 'DISPLAY_LOCATION_FAILED', message, 500);
}
}

/**
* 올리브영 매장 검색 API 핸들러
* GET /api/oliveyoung/stores?keyword={키워드}&lat={위도}&lng={경도}
Expand Down
13 changes: 13 additions & 0 deletions src/api/routes/daisoRoutes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
handleGetProduct,
handleFindStores,
handleCheckInventory,
handleGetDisplayLocation,
} from '../handlers.js';
import type { AppBindings } from '../response.js';

Expand Down Expand Up @@ -60,4 +61,16 @@ export function registerDaisoRoutes(app: Hono<{ Bindings: AppBindings }>): void
() => handleCheckInventory(c),
),
);

app.get('/api/daiso/display-location', async (c) =>
withEdgeCache(
c.req.url,
{
ttlSeconds: 60 * 10,
staleWhileRevalidateSeconds: 60,
keyPrefix: 'daiso-display-location-v1',
},
() => handleGetDisplayLocation(c),
),
);
}
3 changes: 3 additions & 0 deletions src/services/daiso/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ export const DAISOMALL_API = {
/** 매장별 재고 조회 API */
STORE_INVENTORY: 'https://mapi.daisomall.co.kr/ms/msg/newIntSelStr',

/** 매장 내 상품 진열 위치 조회 API */
DISPLAY_LOCATION: 'https://fapi.daisomall.co.kr/pdo/selIntPdStDispInfo',

/** 이미지 CDN 베이스 URL */
IMAGE_BASE_URL: 'https://img.daisomall.co.kr',
} as const;
Expand Down
2 changes: 2 additions & 0 deletions src/services/daiso/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { createSearchProductsTool } from './tools/searchProducts.js';
import { createFindStoresTool } from './tools/findStores.js';
import { createCheckInventoryTool } from './tools/checkInventory.js';
import { createGetPriceInfoTool } from './tools/getPriceInfo.js';
import { createGetDisplayLocationTool } from './tools/getDisplayLocation.js';

/**
* 다이소 서비스 메타데이터
Expand All @@ -33,6 +34,7 @@ class DaisoService implements ServiceProvider {
createFindStoresTool(),
createCheckInventoryTool(),
createGetPriceInfoTool(),
createGetDisplayLocationTool(),
];
}
}
Expand Down
107 changes: 107 additions & 0 deletions src/services/daiso/tools/getDisplayLocation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
/**
* 진열 위치 조회 도구
*
* 다이소몰 API를 사용하여 매장 내 상품 진열 위치(구역/층)를 조회합니다.
*/

import * as z from 'zod';
import type { McpToolResponse, ToolRegistration } from '../../../core/types.js';
import type { DisplayLocation, DisplayLocationResponse } from '../types.js';
import { DAISOMALL_API } from '../api.js';
import { fetchDaisoJson } from '../client.js';

/** 도구 입력 인터페이스 */
interface GetDisplayLocationArgs {
productId: string;
storeCode: string;
}

/** 진열 위치 조회 결과 */
export interface DisplayLocationResult {
productId: string;
storeCode: string;
hasLocation: boolean;
locations: DisplayLocation[];
message?: string | null;
}

/**
* 매장 내 상품 진열 위치 조회
*/
export async function fetchDisplayLocation(
productId: string,
storeCode: string,
): Promise<DisplayLocationResult> {
const data = await fetchDaisoJson<DisplayLocationResponse>(DAISOMALL_API.DISPLAY_LOCATION, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ pdNo: productId, strCd: storeCode }),
});

if (!data.success) {
return {
productId,
storeCode,
hasLocation: false,
locations: [],
message: data.message ?? null,
};
}

const locations: DisplayLocation[] = Array.isArray(data.data)
? data.data.map((item) => ({
zoneNo: String(item.zoneNo ?? ''),
stairNo: String(item.stairNo ?? ''),
storeErp: String(item.storeErp ?? storeCode),
}))
: [];

return {
productId,
storeCode,
hasLocation: locations.length > 0,
locations,
};
}

/**
* 진열 위치 조회 핸들러
*/
async function getDisplayLocation(args: GetDisplayLocationArgs): Promise<McpToolResponse> {
const { productId, storeCode } = args;

if (!productId || productId.trim().length === 0) {
throw new Error('상품 ID(productId)를 입력해주세요.');
}

if (!storeCode || storeCode.trim().length === 0) {
throw new Error('매장 코드(storeCode)를 입력해주세요.');
}

const result = await fetchDisplayLocation(productId, storeCode);

return {
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
};
}

/**
* 도구 등록 정보 생성
*/
export function createGetDisplayLocationTool(): ToolRegistration {
return {
name: 'daiso_get_display_location',
metadata: {
title: '진열 위치 조회',
description:
'다이소 매장 내 상품의 진열 위치(구역/층)를 조회합니다. 상품 ID와 매장 코드가 필요합니다.',
inputSchema: {
productId: z.string().describe('상품 ID (daiso_search_products로 조회한 상품의 id)'),
storeCode: z
.string()
.describe('매장 코드 (daiso_check_inventory로 조회한 매장의 storeCode)'),
},
},
handler: getDisplayLocation as (args: unknown) => Promise<McpToolResponse>,
};
}
21 changes: 21 additions & 0 deletions src/services/daiso/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,27 @@ export interface StoreInventoryResponse {
success: boolean;
}

// 진열 위치 정보
export interface DisplayLocation {
zoneNo: string;
stairNo: string;
storeErp: string;
}

// 진열 위치 조회 응답 (fapi.daisomall.co.kr API)
export interface DisplayLocationResponse {
message: string | null;
data: Array<{
zoneNo: string;
stairNo: string;
storeErp: string;
}>;
extraData: Record<string, unknown>;
extraString: string | null;
returnCode: string | null;
success: boolean;
}

// 상품 문서 타입 (가격 조회용)
export interface ProductDoc {
PD_NO: string;
Expand Down
93 changes: 93 additions & 0 deletions tests/api/handlers-get-display-location.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
/**
* API 핸들러 테스트 - 진열 위치 조회
*/

import { describe, it, expect, vi } from 'vitest';
import { handleGetDisplayLocation } from '../../src/api/handlers.js';
import { createMockContext, setupFetchMock } from './testHelpers.js';

const mockFetch = vi.fn();
setupFetchMock(mockFetch);

describe('handleGetDisplayLocation', () => {
it('진열 위치 정보를 반환한다', async () => {
mockFetch.mockResolvedValue(
new Response(
JSON.stringify({
success: true,
data: [{ zoneNo: '60', stairNo: '2', storeErp: '04515' }],
}),
),
);

const ctx = createMockContext({ productId: '12345', storeCode: '04515' });
await handleGetDisplayLocation(ctx);

expect(ctx.json).toHaveBeenCalledWith(
expect.objectContaining({
success: true,
data: expect.objectContaining({
productId: '12345',
storeCode: '04515',
hasLocation: true,
}),
}),
);
});

it('productId가 없으면 에러를 반환한다', async () => {
const ctx = createMockContext({ storeCode: '04515' });
await handleGetDisplayLocation(ctx);

expect(ctx.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: { code: 'MISSING_PRODUCT_ID', message: '상품 ID(productId)를 입력해주세요.' },
}),
400,
);
});

it('storeCode가 없으면 에러를 반환한다', async () => {
const ctx = createMockContext({ productId: '12345' });
await handleGetDisplayLocation(ctx);

expect(ctx.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: { code: 'MISSING_STORE_CODE', message: '매장 코드(storeCode)를 입력해주세요.' },
}),
400,
);
});

it('API 에러 시 500 에러를 반환한다', async () => {
mockFetch.mockRejectedValue(new Error('Network error'));

const ctx = createMockContext({ productId: '12345', storeCode: '04515' });
await handleGetDisplayLocation(ctx);

expect(ctx.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: { code: 'DISPLAY_LOCATION_FAILED', message: 'Network error' },
}),
500,
);
});

it('알 수 없는 에러도 처리한다', async () => {
mockFetch.mockRejectedValue('Unknown error');

const ctx = createMockContext({ productId: '12345', storeCode: '04515' });
await handleGetDisplayLocation(ctx);

expect(ctx.json).toHaveBeenCalledWith(
expect.objectContaining({
success: false,
error: { code: 'DISPLAY_LOCATION_FAILED', message: '알 수 없는 오류가 발생했습니다.' },
}),
500,
);
});
});
Loading