요기요 자동 주문 구현 정리

yogiyo-cash-backend 의 1초결제 자동 주문 시스템 — 토큰 영속 + Playwright 백그라운드 갱신 + 1초결제 / PG follow 흐름.

현재 동작

  • 사용자 앱 → 백엔드 POST /orders 호출 → 백엔드가 1초결제(YGYPAY) 자동 결제 완료까지 수행
  • 토큰 자동 영속 + JWT exp 검증 — 서버 재시작 후에도 토큰 살아있음
  • Playwright 가 daemon thread 로 매 30분 yogiyo 웹 자동 로그인 → 토큰 갱신 → session.json 저장
  • 1초결제 실패 시 (purchase_id 누락) 자동으로 PG chain follow → 결제 완료
  • "테스트 주문" 모드 — submit 직후 cancel-order 자동 호출 (실제 결제 발생 후 즉시 환불)

시스템 아키텍처

사용자 앱(yogiyo-cash-clone) │ POST /api/orders {restaurant, items, address, auto_cancel} ▼ yogiyo-cash-backend (Django) │ perform_create → run_auto_order(order) ▼ ┌─────────────────────────────────────────────────────┐ │ YogiyoZeroPayClient.from_settings() │ │ │ │ 토큰 우선순위 (JWT exp 검증): │ │ 1. memory (같은 인스턴스 내) │ │ 2. cache (locmem, 같은 프로세스 내) │ │ 3. session.json (~/.config/yogiyo-replay/) │ │ 4. settings (.env YGY_ACCESS_TOKEN) │ │ │ │ ↓ 모두 만료/없음 │ │ POST authyo/auth/refresh → 새 access + refresh │ │ → cache + session.json 저장 │ └─────────────────────────────────────────────────────┘ │ ▼ Step 1~9 자동 호출 (아래 섹션) │ ▼ yogiyo 매장에 주문 도달 또는 즉시 cancel 병렬: daemon thread (orders/apps.py:ready) 매 30분: Playwright 백그라운드 갱신 headless Chromium → www.yogiyo.co.kr → /v2/login-email-account 또는 /auth/refresh 응답 가로채기 → sub_id 검증 (게스트 토큰 무시) → session.json 영속

토큰 우선순위 + JWT 검증

YogiyoZeroPayClient.refresh_access_token() 는 다음 순서로 유효 토큰 탐색:

순위위치특징
1메모리 self._access_token같은 인스턴스 재호출 시 즉시 사용
2Django cache yogiyo:access_tokenlocmem — 같은 프로세스 lifecycle 동안 유지
3~/.config/yogiyo-replay/session.json파일 영속 — 서버 재시작 후에도 복원
4settings.YGY_ACCESS_TOKEN (.env)부트스트랩 — 첫 토큰 (수동 입력)
fallbackPOST authyo/auth/refresh위 모두 만료 시 → 새 토큰 발급

JWT exp 검증

def _is_jwt_valid(token: str, margin_sec: int = 60) -> bool:
    """JWT exp claim 검증. margin_sec 이내 만료 예정이면 무효."""
    parts = token.split('.')
    payload = json.loads(base64.urlsafe_b64decode(parts[1] + '=='))
    exp = payload.get('exp')
    return (time.time() + margin_sec) < int(exp)

모든 토큰 사용 전 호출. 만료 60초 전부터 무효 처리 (호출 중 만료 방지).

session.json 영속

위치: ~/.config/yogiyo-replay/session.json

{
  "customer_id": "708686411",
  "access_token": "<RS256, 매 갱신 시 새로>",
  "refresh_token": "<HS512, rotation 후 새로>"
}

매번 refresh 응답 받을 때 자동 저장 (_save_session). 서버 재시작 → cache 비움 → session.json 에서 로드 → 유효 토큰 확보.

Playwright 백그라운드 자동 갱신

yogiyo 의 refresh_token rotation 정책으로 백엔드 단독 갱신이 어려운 문제를 우회:

  1. headless Chromium 시작 (browser-state.json cookies 로드)
  2. www.yogiyo.co.kr/mobile/ 진입
  3. 로그인된 상태면 yogiyo JS 가 자동 refresh 호출 — 응답 가로채기
  4. 로그인 안 됨 → .envYGY_LOGIN_EMAIL/PASSWORD 자동 입력 → 로그인
  5. 토큰 응답에서 sub_id 검증 — 운영 계정 (YGY_CUSTOMER_ID) 과 일치할 때만 저장
  6. session.json + browser-state.json 영속

Daemon thread 시작 — orders/apps.py

def ready(self):
    if not getattr(settings, 'YOGIYO_TOKEN_REFRESHER_ENABLED', True):
        return
    _start_token_refresher()  # threading.Thread, daemon=True

def loop():
    while True:
        refresh_tokens_via_browser(manual=False, timeout_sec=45)
        time.sleep(INTERVAL)  # 기본 1800초 (30분)

sub_id 검증 — 게스트 토큰 필터링

yogiyo 가 페이지 진입 시 자동 발급하는 게스트 토큰 (sub_id 다름, user_id 키 누락) 을 운영 토큰과 구별:

def on_response(response):
    data = response.json()
    for tok in (data.get('access_token'), data.get('refresh_token')):
        if tok:
            sub_id = _jwt_sub_id(tok)
            if sub_id != settings.YGY_CUSTOMER_ID:
                logger.info('게스트 토큰 무시 (sub_id=%s)', sub_id)
                return
    # 운영 계정 토큰만 captured 에 저장

Step 1 — refresh (토큰 갱신)

1 access_token 갱신
POSTauthyo.yogiyo.co.kr/api/v1/auth/refresh?customer_id={id}

입력: session.json 또는 .env 의 refresh_token. Bearer 헤더에 (만료된) access_token 또는 refresh_token 사용 — 형식만 검증.

응답: 새 access_token (RS256, 2시간) + 새 refresh_token (HS512, 60일) → cache + session.json 저장

Step 2 — customer-info

2 고객 정보 조회
GETmemberyo.yogiyo.co.kr/v3/customers/{customer_id}

응답 키: customer_id, user_id, email, phone, nickname, socials, agreement

주의: 응답의 키는 customer_id (NOT id). submit body 의 customer.id 는 이 값을 매핑.

Step 3 — addresses (location_token 발급)

3 사용자 등록 주소 — location_token + adiyo_address source
GETmemberyo.yogiyo.co.kr/v2/customers/{customer_id}/addresses

핵심 발견 (캡처 분석): 이 endpoint 응답에 location_tokenadiyo_address 의 source 가 모두 포함.

응답 키: address_id, point{lat,lng}, law{sido,sigugun,dongmyun,...}, road, admin, detail, location_token, selected_at

가장 최근 selected_at 의 주소가 활성 배송지. 이 주소의 point.lat/lng 가 cart/submit URL 의 lat/lng 와 정확히 일치해야 함 (frontend 의 라운드된 좌표 35.21 사용 시 거부).

Step 4 — vendor-info

4 매장 정보 — franchise_id 추출
GETfrontyo.yogiyo.co.kr/v1/aggregation/shops/{vendor_id}?lat&lng&serving_type=vd
⚠ 이전 추측이었던 api.yogiyo.co.kr/order/checkout/vendors/{id}500 반환 — 캡처에 없는 endpoint. 캡처 검증된 frontyo 사용.

응답: id, name, franchise (null 또는 {id, ...})

submit body 의 vendor 는 단순 {vendor_id, franchise_id} — franchise=None 이면 franchise_id: null 로 키 포함.

Step 5 — card-info (1초결제 카드 토큰)

5 등록된 1초결제 카드 조회
GETwww.yogiyo.co.kr/api/v1/ygypay/cards/
⚠ 이전 분석의 payo/v2/payments/card-info 가 아님. 실제 모바일 앱 endpoint 는 www.yogiyo.co.kr/api/v1/ygypay/cards/.
{
  "cards": [{
    "token":  "<wpayToken, base64+padding>",    // ygypay_info.token 으로 사용
    "code":   "06",                              // 카드사 코드 (payo_service=YGYPAY_06)
    "name":   "국민카드",
    "number": "4579-73**-****-7048"
  }],
  "registered": true
}

토큰은 카드 1회 등록 + 1회 결제 후 영구 사용 (NOTES.md: "3회 결제에 동일").

Step 6 — create-cart

6 카트 생성
POSTapi.yogiyo.co.kr/cart/v3/food/customers/{customer_id}/restaurants/{vendor_id}/vd/

Body

{
  "lat": 35.2085501,
  "lng": 126.83933026,
  "yotimedeal_offer_id": null,
  "products": [{
    "vendor_product_id": "1668541520",
    "vendor_category_id": "19185660",
    "quantity": 3,
    "option_sections": []
  }],
  "subscription_type": null,
  "coupon": null,
  "referral": null
}

응답: customer_cart.uuid + customer_cart.signature + order_info.full_price.

Step 7 — read-cart

7 카트 재조회 + 정확한 결제 금액 추출
GETapi.yogiyo.co.kr/cart/v3/food/{uuid}-b24b/vd/?customer_id&lat&lng

핵심: 응답의 order_info.full_price 가 submit body 의 payment.total 과 정확히 일치해야 함. frontend 계산한 order.total_price 가 아닌 cart 응답값 사용 — yogiyo 가 엄격 검증 (payment_price_not_match 400).

Step 8 — submit ⭐ (실제 결제)

8 1초결제 submit — 실제 결제 트리거
POSTapi.yogiyo.co.kr/order/cart/submit/food/{customer_id}/{lat}/{lng}

Body 전체 (캡처 검증된 정확한 형식)

{
  "customer_id": 708686411,
  "customer": {
    "id": 708686411,
    "authyo-token": "<RS256 = access_token>",         // ⚠ 키 이름에 대시 (-)
    "location_token": "<HS256, lat/lng 인코딩>",         // Step 3 addresses 응답
    "lat": 35.2085501, "lng": 126.83933026,
    "address": { street_name, road_name, alias_type, alias_text, street_number, display_address_long },
    "address_detail": { sido, gugun, admin_dong, dong, bunji, road, building, ri },
    "adiyo_address": { detail, law{...ri:''}, admin{...ri:''}, road{...ri:''}, point, location_token },
    "isLogin": true,
    "email": "...", "phone": "...",
    "phone_uuid": "<= device_id>",
    "advertisement_id": "<uuid4>",
    "subscription_type": null,
    "limit_ad_tracking": false,
    "app_tracking_transparency": null
  },
  "delivery_info": {
    email, street_number, phone,
    "use_safety_number": true,
    "extra_comments": [],                                  // 리스트!
    "comment_to_vendor": "",
    "comment_to_rider": "문 앞에",
    "is_cutlery_requested": false,
    "is_contactless_delivery": true,
    "expected_takeout_time": ""
  },
  "payment": {
    "payo_service": "YGYPAY_06",                       // YGYPAY_{card_code}
    "checkout_method_type": "YGYPAY",
    "service": "ygypay",
    "user_benefits": { subscription, point },
    "total": 14100,                                // ⚠ cart_verify.order_info.full_price 와 일치
    "cash_receipt_info": null,
    "ygypay_info": { "token": "<Step 5 card_token>", installment_plan: 0 }
  },
  "agreements": { efinance_agreement: true, tos: true, ... },
  "serving_type": "vd",
  "cart_uuid": "<Step 6 uuid>-b24b",                  // snake_case (NOT uuid)
  "cart_signature": "<Step 6 signature>",            // snake_case
  "vendor": { "vendor_id": 1525424, "franchise_id": null },
  "configuration": { feature_configuration: { ...7개 boolean: true } },
  "referral": "",
  "build_type": ""
}

응답 — zero payment 성공 (purchase_id 포함)

{
  "purchase_id":  147296710660,
  "order_number": "F2605121430JXXX4B",
  "ygy_order_id": 1459590930
}

응답 — 일반 결제 (purchase_id 누락 → Step 9 진행)

{
  "payment_no":   "20260512162715093276",
  "order_number": "F2605121627JZE4B",
  "payment_url":  "https://payo.yogiyo.co.kr/v2/payments/{payment_no}/checkout"
}
분기 처리: 응답에 purchase_id 있으면 결제 완료. 없으면 (일반 결제 흐름) Step 9 PG follow chain 자동 진행.

Step 9 — PG follow chain (옵션)

9 PG form chain — payo → wpay.inicis → orderyo callback

submit 응답에 purchase_id 가 없을 때만 동작. YogiyoZeroPayClient.follow_pg_chain() 가 자동 처리:

payment_url (GET, payo /checkout)
  ↓ HTML 응답의 form A 자동 submit
wpay.inicis.com/ygypay/u/v2/payreqauth (POST)
  ↓ HTML 응답의 form B 자동 submit
wpay.inicis.com/ygypay/u/payreqauthAction (POST)
  ↓ HTTP redirect chain
orderyo.yogiyo.co.kr/callback/payment/result/
  ↓ HTML 응답에서 정규식 추출
{order_number, purchase_id, ygy_order_id}

매 step 에서 <form> 의 action / method / input 필드를 정규식으로 추출 → urlencoded POST. JS 실행 / device fingerprint 검증 없음.

Step 10 — cancel (옵션, "테스트 주문" 모드)

10 즉시 자동 취소 — order.auto_cancel=True 시
POSTapi.yogiyo.co.kr/order/api/v2/orders/{order_number}/customer_cancel/

submit 직후 yogiyo 내부 commit 지연으로 404 발생 가능. 백엔드가 자동으로 5번 재시도 (0.5초 → 2.5초 간격).

for attempt in range(5):
    try:
        client.cancel_order(submit['order_number'])
        break
    except YogiyoZeroPayError as e:
        if '404' in str(e):
            time.sleep(0.5 + attempt * 0.5)
            continue
        raise

코드 파일 매핑

파일역할
orders/services/yogiyo_pay_client.py1초결제 client. 토큰 영속 + JWT 검증 + 9개 endpoint + PG follow + JWT 검증 헬퍼.
orders/services/yogiyo_browser_auth.pyPlaywright 백그라운드 토큰 갱신. 자동 로그인 + sub_id 검증 + session.json 영속.
orders/services/auto_order.py주문 오케스트레이션 — addresses 호출, customer/delivery_info 빌드, submit, PG follow, cancel.
orders/management/commands/refresh_yogiyo_tokens.pymanage.py refresh_yogiyo_tokens [--manual] 명령.
orders/apps.pyDjango startup 시 daemon thread 시작 — 매 30분 토큰 갱신.
orders/views.pyPOST /orders + POST /orders/{id}/cancel/ action.
orders/models.pyOrder 모델 — yogiyo_order_number, yogiyo_purchase_id, auto_cancel, auto_cancelled_at 추가.
orders/serializers.pyOrderCreateSerializerauto_cancel 필드.
restaurants/services/yogiyo_search.py매장 검색 (옵션) — discovery/search/all.

.env 변수 가이드

# ─── 1초결제 자동 주문 (코어) ───
YOGIYO_AUTO_ORDER_ENABLED=True
YGY_CUSTOMER_ID=708686411
YGY_REFRESH_TOKEN=<부트스트랩용, 한 번만 유효한 토큰>
YGY_ACCESS_TOKEN=<선택, refresh 우회용>
YGY_DEVICE_ID=00000000-63f7-c1f9-0000-000000000000
YGY_PHONE_UUID=00000000-63f7-c1f9-0000-000000000000
YGY_CARD_CODE=06
YGY_CARD_TOKEN=<Step 5 응답의 cards[i].token>

# ─── Playwright 백그라운드 갱신 ───
YGY_LOGIN_EMAIL=<yogiyo 웹 로그인 이메일>
YGY_LOGIN_PASSWORD=<yogiyo 웹 로그인 패스워드>
YOGIYO_TOKEN_REFRESHER_ENABLED=True
YOGIYO_TOKEN_REFRESH_INTERVAL_SEC=1800  # 30분

진단 / 트러블슈팅

증상원인해결
refresh 실패 400 "expired or malformed token"refresh_token 이 yogiyo 측에서 rotation/reuse 무효화Playwright 자동 갱신 활성화. session.json 삭제 + 재시작.
submit 실패 500 (HTML)body 형식 문제 (키 누락/타입 mismatch)%TEMP%/yogiyo-last-submit-body.jsonsubmit-body-msc.json diff 비교.
payment_price_not_match 400payment.total 이 cart 의 실제 금액과 다름auto_order.py 에서 actual_total 사용 (cart_verify.order_info.full_price).
vendor-info 500잘못된 endpoint 사용frontyo/v1/aggregation/shops/{id} 사용 (NOT order/checkout/vendors).
cancel-order 404submit 직후 yogiyo 내부 commit 지연자동 5번 재시도 (코드에 내장).
새 토큰 sub_id mismatch (게스트)Playwright 가 로그인 안 된 상태에서 게스트 토큰 가로챔sub_id 검증 자동 (코드에 내장). .env 자격증명 확인.
submit 응답에 purchase_id 없음일반 결제 흐름 (매장이 zero payment 미지원 등)follow_pg_chain 자동 진행 (코드에 내장).

참조