8000 fix: 재고 조회 복구와 중계 점검 정확도 개선 by hmmhmmhm · Pull Request #248 · 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
8 changes: 8 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,11 @@ OY_ACCESS_CLIENT_SECRET=
# CONVENIENCE_RELAY_STATE_DIR=/absolute/path/to/convenience-state
# GS25 재고 인증키는 Worker 또는 Mac 중계 환경에만 설정하세요.
# GS25_API_KEY=your_official_app_api_key

# 선택: CGV 직접 요청이 차단되면 Dtryx 4320 프로세스의 무료 중계를 사용합니다.
# CGV_*가 모두 없으면 DTRYX_* 설정을 사용하며, CGV_* 중 하나라도 설정하면 전체 CGV 쌍을 설정해야 합니다.
# CGV는 디트릭스와 같은 토큰·호출 원장·쿼터를 공유합니다. 전용 프로세스·추가 예산을 만들지 않습니다.
# CGV_RELAY_URL=https://your-relay.example
# CGV_RELAY_TOKEN=your_dtryx_relay_token
# CGV_ACCESS_CLIENT_ID=your_access_client_id
# CGV_ACCESS_CLIENT_SECRET=your_access_client_secret
13 changes: 6 additions & 7 deletions .github/workflows/health-checks.yml
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,9 @@ jobs:
const degradedChecks = (payload.checks || []).filter((check) => check.status === 'degraded');
const lines = [...failedChecks, ...degradedChecks].map((check) => {
const sample = check.sample?.first ? ` sample=${check.sample.first}` : '';
return `${check.id}:${check.status}:${check.message}${sample}`;
const diagnostics = [check.errorCode, check.upstreamStatus === undefined ? '' : `upstreamStatus=${check.upstreamStatus}`,
check.quotaReason ? `quotaReason=${check.quotaReason}` : '', check.retryAfter ? `retryAfter=${check.retryAfter}s` : ''].filter(Boolean).join(' ');
return `${check.id}:${check.status}:${check.message}${sample}${diagnostics ? ' ' + diagnostics : ''}`;
});
const runUrl = `${process.env.GITHUB_SERVER_URL}/${process.env.GITHUB_REPOSITORY}/actions/runs/${process.env.GITHUB_RUN_ID}`;
const summary = [
Expand All @@ -100,12 +102,9 @@ jobs:
NODE
}

if ! run_health_check; then
echo "Health check failed on attempt 1; retrying once"
sleep 10
HEALTH_CHECK_ENDPOINT="${HEALTH_CHECK_ENDPOINT}&cacheBust=true"
run_health_check
fi
# 실패 직후 전체 검사를 반복하면 정상 경로까지 중계 한도를 다시 소비합니다.
# 원본 실패와 Retry-After는 첫 검사 기록으로 판단하고 다음 점검에서 재확인합니다.
run_health_check

- name: Preserve health check attempts
if: always()
Expand Down
16 changes: 16 additions & 0 deletions docs/cgv-network-analysis-result.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,19 @@ Playwright 브라우저에서 CGV 예매 페이지를 직접 재검증했습니

- 시간표 1차 조회를 `searchMovScnInfo + rtctlScopCd=08`로 전환
- 비정상 시 기존 `searchSchByMov` 경로를 fallback으로 유지

## 2026-10-08 Mac HTTP 중계 복구 검증

신선한 Worker 조회는 기존 서명과 브라우저 User-Agent를 사용해도 원본 HTTP 403을 반환한 반면, 같은 요청을 Mac에서 직접 실행하면 성공했다. 캐시된 Worker HTTP 200만으로 원본 연결의 정상 여부를 판단할 수 없다.

브라우저·유료 프록시·새 인증정보 없이 기존 `requestCgv` 서명과 공식 공개 GET API를 실행한 결과:

| 조회 | 조건 | Mac 직접 조회 | 새 인증 중계의 실제 원본 조회 |
| --- | --- | ---: | ---: |
| 극장 | 전국 | 174개 | 174개 |
| 영화 | 극장 0056, 날짜 20261008 | 5개 | 5개 |
| 시간표 | 극장 0056, 날짜 20261008 | 30개 | 30개 |

새 중계 검증은 프로세스나 포트를 시작하지 않고 `createCgvRelay` 핸들러를 메모리 안에서 호출했다. 원본 HTTP 요청과 기존 클라이언트 정규화는 실제 코드를 사용했다. 이 결과는 Mac에서 같은 공식 요청을 중계하는 경로가 동작함을 확인하며, 운영 중계 설치나 Worker 배포 완료를 의미하지 않는다.

복구 구현은 기존 정기 경로 검사에 CGV 작업을 추가한다. 최근 검사에서 허용된 직접 경로는 기존 3초 제한과 응답 검증을 적용하고, 실패하면 남은 요청 시간 안에 인증된 Mac 중계로 전환한다. 차단되거나 검증되지 않은 경로는 중계를 선택하여 매 요청마다 HTTP 403을 기다리지 않는다. CGV 전용 설정이 없으면 DTRYX 설정 전체를 사용하며, CGV 일부 설정과 DTRYX 자격증명을 섞지 않는다. 중계 작업은 고정 CGV 조회만 허용하고 외부 URL이나 호출자 인증 헤더를 받지 않는다. DTRYX 중계의 토큰과 기존 할당량을 함께 사용한다.
23 changes: 23 additions & 0 deletions docs/cgv-relay-recovery.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# CGV 무료 중계 복구

2026-10-08 점검에서 Worker 직접 요청은 HTTP 403으로 차단되었으나, 같은 CGV 공식 API를 Mac에서 서명·브라우저 헤더와 함께 호출하면 HTTP 200을 반환했습니다. 인증된 로컬 중계 실측은 극장 174건, 영화 5건, 시간표 30건을 확인했습니다. 이 수치는 점검 시점의 결과이며 날짜와 극장에 따라 달라집니다.

CGV는 `/v1/cgv/{theaters,movies,timetable,timetable-movie}` 경로로 기존 Dtryx 4320 프로세스를 사용합니다. 별도 예산을 만들지 않고 `DTRYX_RELAY_TOKEN`, `DTRYX_RELAY_STATE_DIR/quota.json`, Dtryx 로그를 공유합니다. 기본 상한은 `scripts/relay/quota.ts`의 일 3,000회·분 30회이며 CGV와 Dtryx의 실제 중계 원본 요청을 합산합니다. CU·세븐일레븐·GS25의 별도 편의점 원장은 공유하지 않습니다.

5분 주기 경로 점검은 CGV 직접 요청과 중계를 확인해 사용 가능한 경로를 선택합니다. 직접 경로가 차단되면 중계를 사용하고, 직접 경로가 회복되면 점검 결과에 따라 복귀합니다. 일반 요청 중 직접 실패도 경로 선택에 반영됩니다. `/health`의 `config.cgvRelay`는 유효 설정 여부만 나타내며 연결 성공을 뜻하지 않습니다.

## 설정

CGV 전용 설정이 하나도 없으면 `DTRYX_RELAY_URL`, `DTRYX_RELAY_TOKEN`, `DTRYX_ACCESS_CLIENT_ID`, `DTRYX_ACCESS_CLIENT_SECRET` 그룹 전체를 사용합니다. `CGV_*` 중 하나라도 정의하면 Dtryx와 값을 섞지 않고 CGV 그룹 전체를 사용합니다. 이때 `CGV_RELAY_URL`과 `CGV_RELAY_TOKEN`을 함께 설정하고, Access를 사용하면 `CGV_ACCESS_CLIENT_ID`와 `CGV_ACCESS_CLIENT_SECRET`도 함께 설정합니다. 빈 값도 정의된 설정으로 취급하므로 미사용 CGV 예시는 주석으로 두거나 삭제합니다. 공유 Dtryx 프로세스의 CGV 토큰은 실제 Dtryx 토큰과 같아야 합니다.

`/health`의 `config.cgvRelay`에서 `configured`, `urlValid`, `tokenConfigured`, `accessPairValid`를 확인합니다. 응답에는 URL·토큰·Access 원문을 노출하지 않습니다. 불완전한 설정은 `CGV_RELAY_CONFIG_ERROR`, 중계 실패는 `CGV_RELAY_FAILED`로 반환하며 HTTP 상태와 공개 진단을 REST·MCP에 보존합니다.

## 반영 및 확인

1. 같은 검증된 Git 상태의 `src`와 `scripts`를 런타임에 함께 반영합니다. 시작 파일 한 개만 복사하면 새 CGV 모듈·전송 계층·경로 선택 모듈이 누락될 수 있습니다. 상태 디렉터리와 호출 원장은 유지합니다.
2. 기존 운영 절차로 Dtryx 프로세스를 재시작합니다. Worker 설정과 코드를 함께 반영하고, 인증된 `/v1/dtryx/health`와 `/v1/cgv/health`를 확인합니다. 두 응답의 `quota`는 같은 원장을 나타내며 CGV 응답에도 `logging` 상태가 있어야 합니다. 인증값은 환경 또는 비공개 설정에서 읽고 출력하지 않습니다.
3. `/health`의 CGV 설정 진단과 운영 경로 점검 결과를 확인합니다. REST `/api/cgv/theaters`, `/api/cgv/movies?theaterCode=0056`, `/api/cgv/timetable?theaterCode=0056`를 현재 상영일로 호출해 결과를 비교합니다. 위치 검색도 확인하고 영화·시간표의 극장 코드가 선택한 극장과 일치하는지 검증합니다.
4. 실제 MCP의 `cgv_find_theaters`, `cgv_search_movies`, `cgv_get_timetable`과 게시 CLI의 동일 REST 경로를 확인합니다. 예: `daiso cgv-theaters --limit 3 --json`. CLI는 원격 API를 사용하므로 로컬 CLI에 중계 비밀값을 추가하지 않습니다. 시간표는 영화명·극장·상영일·시작 시간·잔여 좌석을 REST 결과와 비교합니다.
10BC8 5. 한도 오류에서는 REST·MCP 공개 진단의 `quotaReason`·`retryAfter`, REST의 `Retry-After`, 인증된 중계 건강 응답의 `quota.blockedBy`를 확인하고 다음 허용 시점에 재검증합니다. 상태·로그 원장을 삭제해 예산을 초기화하지 않습니다.

복구 코드와 테스트만 준비한 상태에서는 운영 배포·재시작이 완료되었다고 보고하지 않습니다. 실제 반영 뒤 인증된 건강 상태와 REST·MCP·게시 CLI 결과를 다시 검증합니다.
20 changes: 20 additions & 0 deletions docs/health-check-stock-and-quota.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# 실제 재고 점검과 중계 사용량 제한

전체 점검(`mode=full`)은 상품·매장 조회와 실제 재고 조회를 함께 확인합니다. 빠른 점검(`mode=quick`) 성공만으로 전체 재고 기능의 정상 동작을 판단하지 않습니다.

- CU: 강남역 주변 매장 1곳의 `stock` 수량을 확인합니다.
- GS25: `storeLimit=1`로 조회하고 `realStockQuantity`를 확인합니다.
- 세븐일레븐: `storeLimit=1`로 핫식스 단일 매장 재고를 확인합니다. 일반 검색어 ‘커피’의 첫 상품은 재고 조회 대상이 아닌 커피캔디일 수 있으므로 점검 상품으로 사용하지 않습니다.
- 올리브영: `stockCheckLimit=1`로 실제 매장 재고를 확인합니다. 상품 검색 결과만 있거나 `unknown` 상태이면 정상으로 판정하지 않습니다.

확인된 수량 0은 품절입니다. 미확인 값·음수·상품 목록만 있는 응답은 정상 재고 응답과 구분합니다. 재고 증거가 없는 결과는 `fail`로 보고하며, 원본 응답과 진단은 점검 기록에 남깁니다.

## 429의 출처

CU·GS25·세븐일레븐 Mac 중계는 같은 원장을 공유하며 분당 69회, UTC 하루 100,000회로 제한합니다. Cloudflare 직접 경로의 호출과 별개입니다. 분당 상한에 도달해도 일일 상한에 도달했다는 의미는 아닙니다.

응답의 `diagnostics.quotaReason`이 `minute`, `daily`, `consumer`, `consumer-busy`이면 중계가 확인한 자체 정책 제한입니다. `retryAfter`는 재시도까지 기다릴 초 단위 시간입니다. 단순 HTTP 429만으로 자체 제한과 원본 업체 제한을 구분하지 않습니다.

헬스체크 결과에도 `errorCode`, `upstreamStatus`, `quotaReason`, `retryAfter`를 보존합니다. Actions는 실패 직후 전체 점검을 다시 실행하지 않습니다. 정상 경로까지 반복 호출하며 중계 한도를 소비하던 동작을 제거했습니다. 첫 실패 기록을 보존하고 다음 점검에서 재확인합니다.

CGV·디트릭스 중계는 기존 디트릭스 원장을 공유합니다. 편의점 원장과 분리되어 있으며 이번 복구로 한도를 늘리거나 원장을 초기화하지 않습니다.
37 changes: 37 additions & 0 deletions docs/seveneleven-stock-recovery-20261008.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# 세븐일레븐 재고 요청 복구 (2026-10-08)

감사에서 `/api/v1/open/real-stock/multi/01/stocks`의 `storeList`가 한 점포일 때
HTTP 200을 반환하고, 두 점포 이상일 때 HTTP 400, `code: 501`,
`message: 정상적인 점포 조회 요청이 아닙니다.`를 반환하는 것을 확인했습니다.
경로 이름의 `multi`를 근거로 여러 점포를 한 요청에 넣으면 안 됩니다.

재고 클라이언트는 지역 키워드로 매장을 필터링하고 기존 거리 기준으로 정렬한 뒤
`storeLimit`에 포함된 점포만 선택합니다. 선택한 점포를 순서대로 한 곳씩 호출하며,
직접 API와 중계의 `seven-stock` 모두 `storeList: [점포코드]`를 사용합니다.
`totalStoreCount`는 제한 이전의 일치 매장 수를 유지합니다. 제한이 0이면 재고 상품
메타데이터와 재고 API를 호출하지 않습니다.

성공 수량 0은 품절이고, 누락·음수·해석할 수 없는 수량은 -1(미확인)입니다.
개별 직접 API 실패는 최초 오류 원문과 다른 점포의 성공 수량을 함께 보존합니다.
직접 API HTTP 429는 그 오류를 보존한 채 남은 점포 조회를 중단합니다.
중계의 `ServiceError`는 그대로 전파하여 쿼터·구성·상위 서비스 오류를 숨기지 않고
후속 점포 호출을 중단합니다. 클라이언트의 공개 읽기 캐시는 재고를 저장하지 않습니다.
MCP는 오류 코드 501·503만으로 암호화 실패를 추정하지 않고 RSA·복호화·암호화
문구가 실제 오류에 있는 경우에만 암호화 안내를 제공합니다. REST도 오류 메시지를 보존합니다.

Worker의 재고 캐시는 `seveneleven-inventory-v3` 키를 사용하여 기존 10분 실패
캐시를 참조하지 않습니다. `stockAvailable: true`이며 `stockError: null`인 응답만
30초 저장하고 stale 재사용은 0초로 설정합니다. 확인된 0개 재고는 저장할 수 있으나
오류·미확인 결과는 `Cache-Control: no-store`를 적용합니다.

CU·GS25·세븐일레븐 공통 중계 예산은 분당 69회, 일당 100,000회입니다.
재고 복구를 위해 이 한도를 올리거나 원장을 초기화하지 않습니다.
429 이후에는 중계가 제공하는 재시도 시간과 운영 예산을 따릅니다.
기본 재고 요청은 최대 20개 점포를 조회합니다. 상품 검색 상한 10회, 매장 검색 1회,
상품 재고 메타데이터 1회를 합치면 단일 조회의 최대 호출 수는 32회입니다.
다른 서비스와 동시 요청이 같은 예산을 소비하므로 남은 예산을 가정하지 않고
실제 중계 쿼터 응답에서 중단합니다.

`tests/services/seveneleven/inventory-bounded.test.ts`는 직접·중계 단일 점포 호출,
거리순 제한, 0·미확인 구분, 개별 실패 보존, 직접·중계 429 중단을 검증합니다.
이 문서는 로컬 코드 복구를 기록하며 운영 배포나 운영 호출의 추가 성공을 주장하지 않습니다.
92 changes: 92 additions & 0 deletions scripts/relay/cgv.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
/** 고정 CGV 공개 조회만 허용하는 무료 HTTP 중계입니다. */
import { CGV_API } from '../../src/services/cgv/api.js';
import { createCgvHeaders } from '../../src/services/cgv/transport.js';
import { isCgvResponse } from '../../src/services/cgv/relayTransport.js';
import {
createHttpRelay,
RelayError,
abortable,
readJson,
type HttpRelayOptions,
} from './http-relay.js';
const paths = {
theaters: CGV_API.THEATER_LIST_PATH,
movies: CGV_API.MOVIE_LIST_PATH,
timetable: CGV_API.TIMETABLE_BY_SITE_PATH,
'timetable-movie': CGV_API.TIMETABLE_PATH,
};
type Operation = keyof typeof paths;
function validate(value: unknown, operation: string): Record<string, string> {
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new RelayError(400);
const p = value as Record<string, unknown>;
const keys =
operation === 'theaters'
? []
: operation === 'timetable-movie'
? ['theaterCode', 'playDate', 'movieCode']
: ['theaterCode', 'playDate'];
if (Object.keys(p).length !== keys.length || !Object.keys(p).every((key) => keys.includes(key)))
throw new RelayError(400);
if (operation !== 'theaters') {
if (
typeof p.theaterCode !== 'string' ||
!/^\d{4}$/.test(p.theaterCode) ||
typeof p.playDate !== 'string' ||
!/^\d{8}$/.test(p.playDate)
)
throw new RelayError(400);
const date = `${p.playDate.slice(0, 4)}-${p.playDate.slice(4, 6)}-${p.playDate.slice(6, 8)}`;
const time = Date.parse(`${date}T00:00:00Z`);
if (!Number.isFinite(time) || new Date(time).toISOString().slice(0, 10) !== date)
throw new RelayError(400);
}
if (
operation === 'timetable-movie' &&
(typeof p.movieCode !== 'string' || !/^\d{1,16}$/.test(p.movieCode))
)
throw new RelayError(400);
return p as Record<string, string>;
}
export function createCgvRelay(
token: string,
options: Pick<HttpRelayOptions, 'takeQuota' | 'fetcher' | 'onEvent'>,
) {
return createHttpRelay(token, {
...options,
prefix: '/v1/cgv/',
operations: Object.keys(paths),
validate,
async upstream(operation, value, signal, fetcher) {
const params = value as Record<string, string>;
const path = paths[operation as Operation];
const url = new URL(path, CGV_API.BASE_URL);
url.searchParams.set('coCd', CGV_API.COMPANY_CODE);
if (operation !== 'theaters') {
url.searchParams.set('siteNo', params.theaterCode);
url.searchParams.set('scnYmd', params.playDate);
}
if (operation === 'timetable')
url.searchParams.set('rtctlScopCd', CGV_API.TIMETABLE_SITE_SCOPE_CODE);
if (operation === 'timetable-movie') {
url.searchParams.set('movNo', params.movieCode);
url.searchParams.set('rtctlScopCd', CGV_API.TIMETABLE_SCOPE_CODE);
}
const response = await abortable(
fetcher(url, {
method: 'GET',
redirect: 'manual',
headers: await createCgvHeaders(path),
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 (!isCgvResponse(data)) throw new RelayError(502);
return data;
},
});
}
11 changes: 10 additions & 1 deletion scripts/relay/dtryx-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { pathToFileURL } from 'node:url';
import { createFileQuota, assertSeparateQuotaDirectories, CONVENIENCE_QUOTA_LIMITS } from './quota.js';
import { createConvenienceRelay } from './convenience.js';
import { createGs25SessionTransport } from './gs25-session.js';
import { createCgvRelay } from './cgv.js';
import { createDtryxRelay } from './dtryx.js';

let logger: ReturnType<typeof createRelayLogger> | undefined;
Expand Down Expand Up @@ -63,9 +64,17 @@ async function main() {
}
return response;
}, log.append);
const cgvRelay = createCgvRelay(token, { takeQuota, onEvent: log.append });
const cgvHandler = observeRelay(async (request) => {
const response = await cgvRelay(request);
if (response.ok && new URL(request.url).pathname === '/v1/cgv/health') {
return Response.json({ ...(await response.json()), quota: takeQuota.status(), logging: log.status() });
}
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);
: new URL(request.url).pathname.startsWith('/v1/cgv/') ? cgvHandler(request) : dtryxHandler(request);
const server = createServer(async (req, res) => {
const controller = new AbortController();
res.once('close', () => controller.abort());
Expand Down
Loading
Loading
0