8000 fix: 이마트24 공식 HTTP 중계로 운영 접근 오류 복구 by hmmhmmhm · Pull Request #255 · hmmhmmhm/daiso-mcp · GitHub
[go: up one dir, main page]

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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 27 additions & 15 deletions docs/convenience-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

GS25 세션 만료·인증 장애를 복구할 때는 [정상 로그인 복구 운영 절차](gs25-auth-recovery-runbook.md)부터 읽으세요. 티켓·네이버 로그인·GS 토큰 저장·재기동·가상기기 종료 후 검증과 재현용 참조 코드를 포함합니다.

CU PocketCU, 세븐일레븐 공식 앱 API와 GS25 상품·재고 API를 현재 Mac의 브라우저 없는 HTTP 중계로 호출합니다. 정상 API 응답 계약은 유지합니다. Worker에 설정이 없으면 기존 직접 호출을 사용하며, URL·토큰 또는 Access 인증의 부분 설정은 오류를 반환합니다.
CU PocketCU, 세븐일레븐 공식 앱 API와 GS25 상품·재고 API와 이마트24 공식 웹 API를 현재 Mac의 브라우저 없는 HTTP 중계로 호출합니다. 정상 API 응답 계약은 유지합니다. Worker에 설정이 없으면 기존 직접 호출을 사용하며, URL·토큰 또는 Access 인증의 부분 설정은 오류를 반환합니다.

## 실행과 설정

Expand All @@ -15,7 +15,7 @@ CONVENIENCE_RELAY_TOKEN=your_convenience_token
CONVENIENCE_RELAY_STATE_DIR=/absolute/path/to/convenience-state
```

상태 경로는 Dtryx 상태 경로와 다른 디렉터리여야 합니다. 이 디렉터리의 `quota.json`에는 재시작해도 유지되는 호출 원장이, `logs/`에는 입력·토큰·키를 제외한 제한된 관측 로그가 저장됩니다. CU·GS25·세븐일레븐이 공유하는 편의점 예산은 분당 690회, 하루 최대 10,000,000회이며 Dtryx·올리브영 예산과 공유하지 않습니다.
상태 경로는 Dtryx 상태 경로와 다른 디렉터리여야 합니다. 이 디렉터리의 `quota.json`에는 재시작해도 유지되는 호출 원장이, `logs/`에는 입력·토큰·키를 제외한 제한된 관측 로그가 저장됩니다. CU·GS25·세븐일레븐·이마트24가 공유하는 편의점 예산은 분당 690회, 하루 최대 10,000,000회이며 Dtryx·올리브영 예산과 공유하지 않습니다.

Worker에는 아래 바인딩을 설정합니다. URL은 중계 호스트의 기본 URL이며 `/v1/convenience`를 붙이지 않습니다. HTTPS 또는 로컬 루프백 HTTP만 허용합니다. REST와 MCP가 동일한 설정을 사용합니다.

Expand All @@ -38,19 +38,23 @@ Access 토큰 만료1분 전 또는 재고401/403 응답 시 정상 `tokenReissu

모든 조회는 `Authorization: Bearer <CONVENIENCE_RELAY_TOKEN>`를 요구합니다. 아래 작업에 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` |
| 작업 | 원본 조회 | 입력 |
| -------------------------------------------------- | ------------------ | ---------------------------------------------------------------------- |
| `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` |
| `emart24-products` | 상품 검색 | `currentPage`, `pageCnt`, `sortType`, `saleProductYn`, `searchWord` |
| `emart24-stores` | 매장 검색 | `page`, 선택 `search`, `AREA1`, `AREA2`, `SVR_24: "1"` |
| `emart24-stock` | 매장별 수량 | `searchPluCode`, 쉼표로 연결한 `bizNoArr` |
| `emart24-store-detail` | 매장 상세 | `bizNo` |

인증된 GET `/v1/convenience/health`는 실행·대기 수와 원장·로그 상태를 반환합니다. 공개 Worker `/health`의 `config.convenienceRelay`는 설정 여부만 반환합니다.

Expand All @@ -75,3 +79,11 @@ CU 매장 키워드만 입력한 조회는 공식 웹에서 해당 지역 점포
이 응답은 공개 API에서 HTTP 502를 유지하고, 오류 문구를 `정상적인 점포 조회 요청이 아닙니다. 일시적인 외부 서비스 오류입니다. 잠시 후 재시도해주세요.`로 표시합니다. API와 MCP의 공통 `diagnostics`에는 원본 `upstreamStatus: 400`, `upstreamCode: 501`, `upstreamMessage`가 남습니다. 확인된 문구만 전달하며 응답 본문 전체나 알 수 없는 오류 문구는 공개하지 않습니다. 이 요청 거부는 `retryable: false`로 표시해 자동 재시도를 하지 않습니다. 안내 문구는 정책이 일시적이라는 확정 진단이 아닙니다.

중계 내부에서는 이 확인된 거부를 HTTP 200의 `success: false` 실패 envelope로 전달해 중간 연결 구간의 5xx 오류 페이지 처리와 분리합니다. Worker는 이를 성공으로 반환하지 않고 HTTP 502로 변환합니다. 실패는 캐시하지 않으며 원본 호출 로그에도 오류로 남습니다. 구버전 중계의 HTTP 502 진단도 계속 읽을 수 있습니다.

## 이마트24 조회 경로

이마트24는 기존 공식 도메인과 고정 경로를 그대로 사용합니다. 상품 검색은 `application/x-www-form-urlencoded` 폼, 나머지는 GET이며 기존 `Accept`·`X-Requested-With` 헤더를 유지합니다. 직접 조회와 Mac HTTP 중계는 같은 입력·응답 검증기를 사용하고 임의 URL·헤더·경로를 받지 않습니다. Worker의 사전 점검이 직접 경로를 정상으로 확인했으면 최대 3초의 직접 조회를 먼저 시도합니다. 실패하면 남은 요청 기한 안에서 기존 인증된 편의점 중계를 사용하고 해당 작업의 직접 실패는 기존 5분 재검사 정책을 따릅니다. 설정이 없는 로컬 CLI는 종전 직접 조회를 유지합니다.

Mac 중계의 이마트24 재고 응답은 30초, 상품·매장 검색·매장 상세는 5분 동안 캐시합니다. 공개 REST의 기존 엣지 캐시는 상품 10분·매장 30분·재고 5분을 유지합니다. 정상 운영 인증과 `x-health-check-force-fresh: true`를 함께 보낸 새 조회는 엣지 캐시와 모든 재고 단계의 중계 캐시를 우회합니다. 비교 조회의 이마트24 검색에도 같은 새 조회 설정을 전달합니다. 인증되지 않은 요청은 새 조회 헤더만으로 캐시를 우회할 수 없습니다.

공식 재고의 `storeGoodsQty: null`은 빈 목록으로 처리합니다 10BC8 . 수량 0은 확인된 0이며 누락·null·빈 문자열·-1은 미확인으로 유지합니다. 실패 JSON·403 HTML·잘못된 수량을 성공이나 품절로 바꾸지 않습니다. 매장 검색의 정상 `error: 0`과 결과 없음 `error: 1`은 기존 키워드 대체 조회 동작을 유지합니다.
4 changes: 2 additions & 2 deletions docs/relay-observability.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ rg --fixed-strings '확인할-request-id' "$OY_RELAY_STATE_DIR/logs" "$DTRYX_REL

인증과 payload 검증 뒤 공통 릴레이 캐시를 확인한다. REST와 MCP가 같은 조건을 요청하면 공유된다. 상품 검색/매장은 5분, 상품 ID는 10분, 재고는 1분 TTL이다. 최대 256항목 및 키·값 직렬화 16MiB, 오래된 항목부터 퇴출한다. 성공만 저장하며 동일 진행 요청은 합친다. 탭이나 브라우저를 추가하지 않는다.

캐시 적중은 quota를 소비하지 않는다. `x-relay-cache: hit/miss/coalesced` 및 health.cache로 확인한다. 서로 다른 검색이 대부분이면 절감 효과는 작다. 현재 OY quota는 분당 300회·UTC 일일 300,000회다. 소비자별 신규 원본 작업은 분당 240회이며, 사용한 원장은 유지한다. REST 캐시도 별도로 존재하며 products5분/stores24시간/inventory10분이므로 외부 REST 응답의 최신성은 두 계층의 영향을 받는다. `_healthCheck`는 REST 캐시를 새로 조회하지만 공통 릴레이 캐시는 계속 적용된다.
캐시 적중은 quota를 소비하지 않는다. `x-relay-cache: hit/miss/coalesced` 및 health.cache로 확인한다. 서로 다른 검색이 대부분이면 절감 효과는 작다. 현재 OY quota는 분당 300회·UTC 일일 300,000회다. 소비자별 신규 원본 작업은 분당 240회이며, 사용한 원장은 유지한다. REST 캐시도 별도로 존재하며 products5분/stores24시간/inventory10분이므로 외부 REST 응답의 최신성은 두 계층의 영향을 받는다. 공개 REST 요청의 `_healthCheck`는 REST 캐시 키만 바꾸며 공통 릴레이 캐시는 계속 적용된다. 운영 인증이 유효한 `x-health-check-force-fresh: true` 요청은 REST 캐시와 OY 내부 캐시를 우회하고, 릴레이에도 `x-relay-cache-bypass: true`를 전달하여 저장 응답과 진행 중인 동일 요청을 재사용하지 않는다. 인증 없는 헤더나 `_healthCheck`만으로 이 우회를 활성화할 수 없다.


## 과거 OY 요청 집중 보호 (1.2.7)
Expand All @@ -64,7 +64,7 @@ health.cache의 `hits`, `misses`, `expired`, `evicted`는 프로세스 시작

## GitHub Health Checks 판정과 보존

quick/full 모두 새 검사 요약을 요청한다. 실패 후 한 번 재시도할 때는 REST 캐시도 우회한다. 공통 OY 릴레이 캐시는 유지한다. HTTP 실패 후 이전 성공 파일을 재사용하지 않으며, 비정상 JSON·빈 검사 목록·모순된 요약은 실패로 처리한다. 각 요청은 최대 240초, 전체 job은 최대 10분이다. 시도별 원본 JSON과 오류 요약은 Actions artifact로 7일 보관한다.
quick/full 모두 운영 인증과 `fresh=true`, `x-health-check-force-fresh: true`를 함께 보내 새 검사 요약을 요청한다. 허용된 원점의 검사 대상에는 운영 인증과 fresh 헤더가 전달되며 REST·OY 내부·공통 릴레이 캐시를 우회한다. 실패 직후 전체 검사를 반복하지 않고, 검증된 분당 중계 한도 429만 만료 후 해당 검사 ID를 한 번 fresh로 재확인한다. HTTP 실패 후 이전 성공 파일을 재사용하지 않으며, 비정상 JSON·빈 검사 목록·모순된 요약은 실패로 처리한다. 각 요청은 최대 240초, 전체 job은 최대 10분이다. 시도별 원본 JSON과 오류 요약은 Actions artifact로 7일 보관한다.

CGV는 `CGV_UPSTREAM_UNAVAILABLE`, API HTTP 503, 원본 HTTP 403이 모두 확인될 때만 `degraded`로 분류한다. 이는 해당 기능이 제한된 상태이며 복구를 뜻하지 않는다. 인증 오류, 타임아웃, JSON 파싱 실패 등은 계속 `fail`이다. 선택 데이터가 없는 `skipped` 검사는 정상 요약에 포함될 수 있다. Actions 성공 여부와 별도로 요약의 저하 서비스 목록을 확인해야 한다. 유료 Zyte 재시도는 활성화하지 않는다.

Expand Down
19 changes: 16 additions & 3 deletions scripts/relay/convenience.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { emart24DirectRouteSpecs, isEmart24Operation } from '../../src/services/emart24/relaySpecs.js';
import { parseSevenStockFailure } from '../../src/utils/sevenStockFailure.js';
import { CU_DEFAULT_HEADERS } from '../../src/services/cu/client.js';
import { GS25_DEFAULT_HEADERS } from '../../src/services/gs25/client.js';
Expand Down Expand Up @@ -266,15 +267,27 @@ export function createConvenienceRelay(
return createHttpRelay(token, {
...options,
cacheTtl: (operation) =>
['cu-store', 'seven-stock', 'gs25-stock'].includes(operation) ? 30000 : 300000,
['cu-store', 'seven-stock', 'gs25-stock', 'emart24-stock'].includes(operation) ? 30000 : 300000,
prefix: '/v1/convenience/',
operations: Object.keys(paths),
operations: [...Object.keys(paths), ...Object.keys(emart24DirectRouteSpecs)],
validate(value, operation) {
const parsed = schemas[operation as Operation].safeParse(value);
const parsed = (isEmart24Operation(operation) ? emart24DirectRouteSpecs[operation].input : schemas[operation as Operation]).safeParse(value);
if (!parsed.success) throw new RelayError(400);
return parsed.data;
},
async upstream(operation, params, signal, fetcher) {
if (isEmart24Operation(operation)) {
const spec = emart24DirectRouteSpecs[operation];
const { url, init } = spec.build(params);
const response = await abortable(fetcher(url, { ...init, redirect: 'manual', signal }), signal);
if (response.status !== 200) {
void response.body?.cancel().catch(() => undefined);
throw new RelayError(502);
}
const data = await readJson(response.body, 2 * 1024 * 1024, signal, 502);
if (!spec.response.safeParse(data).success) throw new RelayError(502);
return data;
}
const [base, path, method, schema] = paths[operation as Operation];
const url = new URL(path, base);
const body = { ...(params as Record<string, unknown>) };
Expand Down
17 changes: 12 additions & 5 deletions src/api/compareHandlers.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isFreshOperationalRequest } from './operationalFreshness.js';
import { compareProducts, parseCompareServices } from '../services/compare/client.js';
import { type ApiContext, errorResponse, successResponse } from './response.js';
import { convenienceTransportFromBindings } from '../utils/convenienceTransport.js';
Expand All @@ -12,11 +13,17 @@ export async function handleCompareProducts(c: ApiContext) {
}

try {
const result = await compareProducts({
keyword,
limit,
services,
}, convenienceTransportFromBindings(c.env));
const result = await compareProducts(
{
keyword,
limit,
services,
},
{
...convenienceTransportFromBindings(c.env),
bypassCache: await isFreshOperationalRequest(c),
},
);

return successResponse(c, result, {
total: result.resultCount,
Expand Down
Loading
Loading
0