8000 fix: 편의점 중계 분당 한도와 일일 10만 회 상한 적용 by hmmhmmhm · Pull Request #230 · 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
6 changes: 4 additions & 2 deletions docs/convenience-relay.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ CONVENIENCE_RELAY_TOKEN=your_convenience_token
CONVENIENCE_RELAY_STATE_DIR=/absolute/path/to/convenience-state
```

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

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

Expand Down Expand Up @@ -58,6 +58,8 @@ CU 매장 키워드만 입력한 조회는 공식 웹에서 해당 지역 점포

본문 16 KiB, 원본 응답 2 MiB, 동시 원본 호출 4개, 전체 요청 32개를 제한합니다. 본문·대기·원장·원본 처리를 합쳐 15초 기한을 적용하며 취소된 대기 요청은 예산을 소비하지 않습니다. 인증·입력 검증 뒤 성공한 상품·메타정보·초기화·카탈로그는 5분, 매장·실재고는 30초 동안 최대 256개·16 MiB의 메모리 캐시에 저장합니다. 캐시 적중은 호출 예산을 소비하지 않습니다. 오류는 캐시하지 않습니다.

캐시 가능한 작업은 인증·입력 검증을 통과한 뒤 작업명과 모든 조회 조건이 같은 진행 요청을 공유합니다. 최초 동시 조회도 원본 호출·예산 소비는 한 번입니다. 각 응답 body와 request ID는 독립이며 공유를 기다린 요청은 `coalesced` 단계로 기록합니다. 한 요청의 취소는 다른 대기자에게 전파하지 않고, 마지막 대기자가 떠나면 원본 작업과 큐를 취소합니다. 최초 요청의 전체 15초 기한이 지난 공유 작업은 후속 대기자에게도 504를 반환하며, 실패·취소 결과는 캐시하지 않습니다. 동시 접수 32개·원본 4개·분당30회·일3,000회 상한은 유지합니다. 서로 다른 상품·지역·인증키·조회 조건은 임의로 합치지 않습니다.
캐시 가능한 작업은 인증·입력 검증을 통과한 뒤 작업명과 모든 조회 조건이 같은 진행 요청을 공유합니다. 최초 동시 조회도 원본 호출·예산 소비는 한 번입니다. 각 응답 body와 request ID는 독립이며 공유를 기다린 요청은 `coalesced` 단계로 기록합니다. 한 요청의 취소는 다른 대기자에게 전파하지 않고, 마지막 대기자가 떠나면 원본 작업과 큐를 취소합니다. 최초 요청의 전체 15초 기한이 지난 공유 작업은 후속 대기자에게도 504를 반환하며, 실패·취소 결과는 캐시하지 않습니다. 동시 접수 32개·원본 4개·분당 69회·일 100,000회 상한은 유지합니다. 서로 다른 상품·지역·인증키·조회 조건은 임의로 합치지 않습니다.

리다이렉트·비 JSON·실패 envelope·잘못된 수량은 오류로 반환합니다. 재고 오류를 수량 0으로 바꾸지 않습니다. 세븐일레븐 수량 `-1`은 미확인이며 품절로 표시하지 않습니다. 반환된 매장의 수량이 모두 미확인이면 `stockAvailable`은 false입니다. 캐시 수량은 조회 시점 이후 바뀔 수 있으므로 실제 방문 전 점포 확인이 필요합니다.

분당 상한은 UTC 기준 각 1분 구간에 적용됩니다. 69 × 1,440분 = 99,360회이므로 정상적인 하루 합계는 10만 회 이하이며, 일일 100,000회 상한도 함께 검사합니다. 일일 원장은 UTC 자정(한국 시각 오전 9시)에 새 날짜로 전환됩니다. 한도 변경이나 재시작 시 기존 원장을 초기화하지 않습니다.
2 changes: 1 addition & 1 deletion docs/gs25-auth-recovery-runbook.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ REST / MCP / CLI → Worker → Access/Tunnel → Mac 편의점 중계
| 공개 주소에서 Python 기본 UA만 403 | 실제 CLI/SDK와 같은 요청으로 대조한다. 당시 `daiso-cli/1.2.9` UA는 200이었다. 차단 기준을 추측해 우회하지 않는다. |
| 200인데 다른 상품/지역 | 상품 코드·상품명·좌표·필터·수량을 검증한다. HTTP 200만으로 복구 완료가 아니다. |

30/분·3,000/일은 편의점 중계의 **합산 자체 상한**이다. 동시 원본 4, 접수 32, 전체 15초, 재고 캐시 30초를 유지한다. CU/Seven의 운영 트래픽도 같은 편의점 원장을 소비한다. 캐시 적중·동일 진행 조회 병합은 원본 소비가 없거나 1회지만 서로 다른 조건은 각각 소비한다. 인증 갱신은 별도 인증 요청이다.
69/분·100,000/일은 편의점 중계의 **합산 자체 상한**이다. 동시 원본 4, 접수 32, 전체 15초, 재고 캐시 30초를 유지한다. CU/Seven의 운영 트래픽도 같은 편의점 원장을 소비한다. 캐시 적중·동일 진행 조회 병합은 원본 소비가 없거나 1회지만 서로 다른 조건은 각각 소비한다. 인증 갱신은 별도 인증 요청이다.

## 2. 기존 세션을 먼저 점검한다

Expand Down
26 changes: 26 additions & 0 deletions docs/plans/2026-10-05-convenience-quota-minute.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
# 편의점 중계 분당 한도 개선 계획

**목표:** 사용자 요청에 따라 CU·GS25·세븐일레븐의 합산 원본 호출을 분당 69회, UTC 일일 최대 100,000회로 운영합니다.

**설계:** 기존 고정 분·UTC 일일 창과 원자적 파일 원장을 유지합니다. `69 × 1,440 = 99,360`이므로 분당 상한을 계속 채워도 하루 10만 회 이하입니다. `createFileQuota(path, now, limits)`의 세 번째 인자로 편의점 프로필만 선택하며, 기본 프로필은 기존 일일 3,000회·분당 30회입니다. 기존 사용량은 그대로 읽어 새 한도에서 차감합니다. 동시 접수·원본 동시성·캐시·진행 조회 병합·GS 인증은 변경하지 않습니다.

**기술:** Node/TypeScript/Vitest, 기존 Mac의 Dtryx+편의점 중계.

## 구현

- [ ] `tests/relay/quota.test.ts`: 편의점 동시 74요청 중69허용·재시작 후70번째 차단·다음분 재개를 RED로 확인.
- [ ] 같은 원장의 dayCount3,000을 초기화하지 않고 새 프로필로3,001까지 진행하는 회귀, dayCount99,999에서100,000까지만 허용·UTC 다음날 재개하는 회귀를 RED로 확인.
- [ ] `scripts/relay/quota.ts`: 기본 프로필과 `CONVENIENCE_QUOTA_LIMITS = { daily: 100000, minute: 69 }`를 정의하고 사용량 판정·상태 계산에 같은 프로필 적용.
- [ ] `scripts/relay/dtryx-start.ts`: 편의점 `createFileQuota(join(convenienceDir, 'quota.json'), Date.now, CONVENIENCE_QUOTA_LIMITS)`만 변경.
- [ ] 파일 원장과 실제 `createConvenienceRelay`를 묶어 캐시·공유 진행 요청은1회만 차감, 서로 다른 원본은분당한도로 차단하는 통합 회귀 확인.
- [ ] 편의점 운영 문서·GS 복구 runbook에 새 상한과 적용 범위를 갱신.

## 검증·배포

- [ ] 전체 check·커버리지100·build·npm audit0, 독립 리뷰 확인 후 한국어 커밋/PR·CI/CodeQL 성공·병합.
- [ ] Mac 중계의 `quota.ts`, `dtryx-start.ts`만 백업·소유 프로세스 종료 확인 후 교체·재기동. 실제 원장 파일을 초기화하거나 교체하지 않음.
- [ ] 기존 일일사용량3,000 보존, 새 편의점 잔여97,000 내외·분당69 및 Dtryx의 기존 잔여/한도 유지 확인.
- [ ] 기존 GS 정상 인증을 유지한 운영 CU·GS·Seven REST/MCP 조회와 Seven 서로 다른 페이지, 비교 실패 캐시·공개 CLI1.2.13 동작 검증. 요청 전 최신 quota 확인, 공유 일일 여유를 보존.
- [ ] 보고서와 PROJECT 최신 사실 갱신·작업 checkout 정리·완료 알림.

이 작업은 CLI 배포 파일에 포함되지 않는 중계 스크립트 변경입니다. 새 CLI 버전 게시 대신 운영 중계와 저장소 반영을 검증합니다. 날짜별 한도는 계속 UTC 자정(한국 시간 오전9시)에 갱신하며, 분당 한도는 정렬된 UTC 분 창입니다.
4 changes: 2 additions & 2 deletions scripts/relay/dtryx-start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import { Readable } from 'node:stream';
import { mkdir } from 'node:fs/promises';
import { join } from 'node:path';
import { pathToFileURL } from 'node:url';
import { createFileQuota, assertSeparateQuotaDirectories } from './quota.js';
import { createFileQuota, assertSeparateQuotaDirectories, CONVENIENCE_QUOTA_LIMITS } from './quota.js';
import { createConvenienceRelay } from './convenience.js';
import { createGs25SessionTransport } from './gs25-session.js';
import { createDtryxRelay } from './dtryx.js';
Expand Down Expand Up @@ -39,7 +39,7 @@ async function main() {
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 quota = await createFileQuota(join(convenienceDir, 'quota.json'), Date.now, CONVENIENCE_QUOTA_LIMITS);
const sessionFile = process.env.GS25_AUTH_SESSION_FILE?.trim();
const convenienceHandler = createConvenienceRelay(convenienceToken, {
takeQuota: quota, onEvent: convenienceLog.append, gs25ApiKey: process.env.GS25_API_KEY,
Expand Down
14 changes: 11 additions & 3 deletions scripts/relay/quota.ts
F438
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,19 @@ export interface QuotaStatus {
blockedBy: 'daily' | 'minute' | null;
retryAfter: number;
}
export interface QuotaLimits {
readonly daily: number;
readonly minute: number;
}
export const CONVENIENCE_QUOTA_LIMITS: QuotaLimits = { daily: 100000, minute: 69 };
const DEFAULT_QUOTA_LIMITS: QuotaLimits = { daily: 3000, minute: 30 };
export type FileQuota = (() => Promise<boolean>) & { status: () => QuotaStatus };
export async function createFileQuota(
path: string,
now: () => number = Date.now,
limits: QuotaLimits = DEFAULT_QUOTA_LIMITS,
): Promise<FileQuota> {
const { daily, minute: perMinute } = limits;
let ledger: Ledger = { day: 0, minute: 0, dayCount: 0, minuteCount: 0 };
try {
const value = JSON.parse(await readFile(path, 'utf8')) as Ledger;
Expand All @@ -41,7 +49,7 @@ export async function createFileQuota(
const minute = Math.max(ledger.minute, Math.floor(time / 60000));
const dayCount = day === ledger.day ? ledger.dayCount : 0;
const minuteCount = minute === ledger.minute ? ledger.minuteCount : 0;
if (dayCount >= 3000 || minuteCount >= 30) return false;
if (dayCount >= daily || minuteCount >= perMinute) return false;
const next = { day, minute, dayCount: dayCount + 1, minuteCount: minuteCount + 1 };
// 기록 실패를 재시도해도 프로세스 내에서 이미 소비한 예산은 되돌리지 않습니다.
ledger = next;
Expand All @@ -57,8 +65,8 @@ export async function createFileQuota(
const time = now();
const day = Math.max(ledger.day, Math.floor(time / 86400000));
const minute = Math.max(ledger.minute, Math.floor(time / 60000));
const dailyRemaining = Math.max(0, 3000 - (day === ledger.day ? ledger.dayCount : 0));
const minuteRemaining = Math.max(0, 30 - (minute === ledger.minute ? ledger.minuteCount : 0));
const dailyRemaining = Math.max(0, daily - (day === ledger.day ? ledger.dayCount : 0));
const minuteRemaining = Math.max(0, perMinute - (minute === ledger.minute ? ledger.minuteCount : 0));
const resetAt = { daily: (day + 1) * 86400000, minute: (minute + 1) * 60000 };
const blockedBy = dailyRemaining === 0 ? 'daily' : minuteRemaining === 0 ? 'minute' : null;
const retryAfter = blockedBy ? Math.ceil((resetAt[blockedBy] - time) / 1000) : 0;
Expand Down
34 changes: 34 additions & 0 deletions tests/relay/convenience-quota.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/** 편의점의 파일 원장과 캐시·진행 요청 병합을 함께 검증합니다. */
import { mkdtemp, readFile, rm } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { expect, it, vi } from 'vitest';
import { createConvenienceRelay } from '../../scripts/relay/convenience.js';
import { CONVENIENCE_QUOTA_LIMITS, createFileQuota } from '../../scripts/relay/quota.js';

it('분당 69회 원본 호출 후에도 캐시는 응답하며 최초 동시 조회는 한 번만 차감한다', async () => {
const directory = await mkdtemp(join(tmpdir(), 'convenience-quota-'));
try {
let now = Date.UTC(2026, 9, 5);
const path = join(directory, 'quota.json');
const takeQuota = await createFileQuota(path, () => now, CONVENIENCE_QUOTA_LIMITS);
const fetcher = vi.fn(async () => Response.json({ SearchQueryResult: {} }));
const relay = createConvenienceRelay('test-token', { takeQuota, fetcher });
const request = (index: number) => new Request('http://localhost/v1/convenience/gs25-products', {
method: 'POST', headers: { Authorization: 'Bearer test-token' }, body: JSON.stringify({ query: `item${index}` }),
});
const responses = await Promise.all(Array.from({ length: 8 }, () => relay(request(0))));
expect(responses.every(response => response.status === 200)).toBe(true);
expect(fetcher).toHaveBeenCalledTimes(1);
for (let index = 1; index < 69; index++) expect((await relay(request(index))).status).toBe(200);
expect((await relay(request(0))).status).toBe(200);
expect((await relay(request(69))).status).toBe(429);
expect(fetcher).toHaveBeenCalledTimes(69);
expect(JSON.parse(await readFile(path, 'utf8')).dayCount).toBe(69);
now += 60000;
expect((await relay(request(69))).status).toBe(200);
expect(fetcher).toHaveBeenCalledTimes(70);
} finally {
await rm(directory, { recursive: true, force: true });
}
});
44 changes: 43 additions & 1 deletion tests/relay/quota.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { mkdtemp, readFile, rm, writeFile, symlink, mkdir } from 'node:fs/promis
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, expect, it } from 'vitest';
import { createFileQuota, assertSeparateQuotaDirectories } from '../../scripts/relay/quota.js';
import { CONVENIENCE_QUOTA_LIMITS, createFileQuota, assertSeparateQuotaDirectories } from '../../scripts/relay/quota.js';
const dirs: string[] = [];
async function file() {
const dir = await mkdtemp(join(tmpdir(), 'oy-quota-'));
Expand Down Expand Up @@ -119,3 +119,45 @@ it('원장 디렉터리의 슬래시·점·심볼릭 링크 별칭을 같은 저
await mkdir(other);
await expect(assertSeparateQuotaDirectories(directory, other)).resolves.toBeUndefined();
});

const convenienceLimits = CONVENIENCE_QUOTA_LIMITS;
it('편의점 동시 요청과 재시작에도 분당 69회만 허용한다', async () => {
const path = await file();
let now = Date.UTC(2026, 9, 5) + 1000;
const take = await createFileQuota(path, () => now, convenienceLimits);
const results = await Promise.all(Array.from({ length: 74 }, () => take()));
expect(results.filter(Boolean)).toHaveLength(69);
expect(take.status()).toMatchObject({ dailyRemaining: 99931, minuteRemaining: 0, blockedBy: 'minute', retryAfter: 59 });
const restarted = await createFileQuota(path, () => now, convenienceLimits);
expect(await restarted()).toBe(false);
now += 60000;
expect(await restarted()).toBe(true);
expect(JSON.parse(await readFile(path, 'utf8')).dayCount).toBe(70);
});

it('편의점 한도를 늘려도 기존 3000회 사용 기록을 초기화하지 않는다', async () => {
const path = await file();
const now = Date.UTC(2026, 9, 5);
await writeFile(path, JSON.stringify({ day: Math.floor(now / 86400000), minute: Math.floor(now / 60000), dayCount: 3000, minuteCount: 0 }));
const take = await createFileQuota(path, () => now, convenienceLimits);
expect(take.status()).toMatchObject({ dailyRemaining: 97000, minuteRemaining: 69, blockedBy: null });
expect(await take()).toBe(true);
expect(JSON.parse(await readFile(path, 'utf8')).dayCount).toBe(3001);
expect(await (await createFileQuota(path, () => now, convenienceLimits))()).toBe(true);
expect(JSON.parse(await readFile(path, 'utf8')).dayCount).toBe(3002);
});

it('편의점 일일 10만 회를 넘기지 않고 다음 UTC 날짜에 재개한다', async () => {
const path = await file();
let now = Date.UTC(2026, 9, 5);
await writeFile(path, JSON.stringify({ day: Math.floor(now / 86400000), minute: Math.floor(now / 60000), dayCount: 99999, minuteCount: 0 }));
const take = await createFileQuota(path, () => now, convenienceLimits);
expect(await take()).toBe(true);
expect(take.status().dailyRemaining).toBe(0);
now += 60000;
expect(await take()).toBe(false);
expect(JSON.parse(await readFile(path, 'utf8')).dayCount).toBe(100000);
now += 86400000;
expect(await take()).toBe(true);
expect(take.status().dailyRemaining).toBe(99999);
});
Loading
0