Files
dbx-corm/cafe24_api.py
T
king 065e81bf97 feat: v9.0 업그레이드 — CS 작업 탭 개편 + 밀크런/자사몰 행사 모듈
CS 통합 프로그램.ZIP(로컬 개발본 v9.0)의 기능 소스를 반영.
DB 스키마는 기존과 동일해 마이그레이션 없이 그대로 사용한다.

가져온 것
- main.py / cafe24_api.py / templates / static: v9.0 기능 코드
- routers/coupang_milkrun.py, routers/mall_event.py (신규)
- static/js/mall_event.js, static/js/milkrun_gsheet.js (신규)

운영 설정은 기존 것을 유지·재적용
- DB/카페24/네이버 접속정보를 하드코딩 대신 환경변수 기반으로 복원
- SSO(AuthGuardMiddleware, SessionMiddleware), /login, /logout, /health/db 복원
- APP_ROOT_PATH 서브경로 호스팅(root_path 템플릿 변수, app.js fetch 래퍼) 복원
- 구글시트 설정 인메모리 캐시(TTL 10분)와 /api/config/refresh 복원

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 23:56:17 +09:00

297 lines
10 KiB
Python

import requests
import json
import os
import time
from pathlib import Path
from datetime import datetime, timedelta
# 환경변수에서 카페24 인증 정보 로드
CLIENT_ID = os.getenv("CAFE24_CLIENT_ID", "")
CLIENT_SECRET = os.getenv("CAFE24_CLIENT_SECRET", "")
MALL_ID = os.getenv("CAFE24_MALL_ID", "miraskitchen")
REDIRECT_URI = os.getenv("CAFE24_REDIRECT_URI", "")
TOKEN_FILE = os.getenv("CAFE24_TOKEN_FILE", str(Path(__file__).resolve().parent / "cafe24_tokens.json"))
REQUEST_TIMEOUT = 30
def get_auth_url(redirect_uri=None, state=None):
redirect_uri = redirect_uri or REDIRECT_URI
url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/authorize"
params = {
"response_type": "code",
"client_id": CLIENT_ID,
"redirect_uri": redirect_uri,
"scope": "mall.read_order mall.write_order mall.read_product",
}
if state:
params["state"] = state
return requests.Request("GET", url, params=params).prepare().url
import base64
def _get_basic_auth_header():
credentials = f"{CLIENT_ID}:{CLIENT_SECRET}"
encoded = base64.b64encode(credentials.encode('utf-8')).decode('utf-8')
return f"Basic {encoded}"
def request_new_token(auth_code: str, redirect_uri=None):
redirect_uri = redirect_uri or REDIRECT_URI
url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/token"
headers = {
"Authorization": _get_basic_auth_header(),
"Content-Type": "application/x-www-form-urlencoded"
}
data = {
"grant_type": "authorization_code",
"code": auth_code,
"redirect_uri": redirect_uri
}
response = requests.post(url, headers=headers, data=data, timeout=REQUEST_TIMEOUT)
if response.status_code == 200:
_save_tokens(response.json())
return True
else:
raise Exception(f"카페24 인증 토큰 발급에 실패했습니다. (HTTP {response.status_code})")
def refresh_access_token(refresh_token: str):
url = f"https://{MALL_ID}.cafe24api.com/api/v2/oauth/token"
headers = {
"Authorization": _get_basic_auth_header(),
"Content-Type": "application/x-www-form-urlencoded"
}
data = {
"grant_type": "refresh_token",
"refresh_token": refresh_token
}
response = requests.post(url, headers=headers, data=data, timeout=REQUEST_TIMEOUT)
if response.status_code == 200:
_save_tokens(response.json())
return True
else:
# Preserve the token file so a failed refresh never destroys diagnostics.
raise Exception("카페24 인증이 만료되었습니다. 화면의 '재연동 필요' 버튼을 눌러 다시 연동해주세요.")
def _save_tokens(token_data):
# Cafe24 returns expires_at in string format like "2023-10-01T12:00:00.000"
if 'expires_at' not in token_data:
# Fallback if expires_at is not provided (Cafe24 provides expires_at)
expires_in = token_data.get('expires_in', 7200) # usually 2 hours
expires_at = datetime.now() + timedelta(seconds=expires_in - 60) # 1 min buffer
token_data['expires_at'] = expires_at.isoformat()
with open(TOKEN_FILE, 'w', encoding='utf-8') as f:
json.dump(token_data, f)
def _parse_cafe24_datetime(value):
if not value:
return None
text = str(value).strip()
try:
return datetime.fromisoformat(text.replace("Z", "+00:00"))
except ValueError:
pass
for date_format in ("%m/%d/%Y %H:%M:%S", "%Y/%m/%d %H:%M:%S"):
try:
return datetime.strptime(text, date_format)
except ValueError:
continue
return None
def _is_expired(value, *, leeway_seconds=0):
expires_at = _parse_cafe24_datetime(value)
if expires_at is None:
return True
now = datetime.now(expires_at.tzinfo) if expires_at.tzinfo else datetime.now()
return now >= expires_at - timedelta(seconds=leeway_seconds)
def get_token_status():
if not os.path.exists(TOKEN_FILE):
return {
"authenticated": False,
"reauth_required": True,
"message": "카페24 연동이 필요합니다.",
}
try:
with open(TOKEN_FILE, "r", encoding="utf-8") as f:
tokens = json.load(f)
except (OSError, json.JSONDecodeError):
return {
"authenticated": False,
"reauth_required": True,
"message": "카페24 인증 파일을 읽을 수 없습니다. 다시 연동해주세요.",
}
refresh_expired = _is_expired(tokens.get("refresh_token_expires_at"))
access_expired = _is_expired(tokens.get("expires_at"), leeway_seconds=60)
authenticated = bool(tokens.get("access_token")) and not refresh_expired
return {
"authenticated": authenticated,
"reauth_required": not authenticated,
"access_expired": access_expired,
"message": (
"카페24 인증이 만료되었습니다. 다시 연동해주세요."
if not authenticated
else "카페24가 연동되어 있습니다."
),
}
def get_valid_access_token():
if not os.path.exists(TOKEN_FILE):
raise Exception("No tokens found. Please authenticate first.")
with open(TOKEN_FILE, 'r', encoding='utf-8') as f:
tokens = json.load(f)
if _is_expired(tokens.get("expires_at"), leeway_seconds=60):
if not tokens.get("refresh_token") or _is_expired(tokens.get("refresh_token_expires_at")):
raise Exception("카페24 인증이 만료되었습니다. 화면의 '재연동 필요' 버튼을 눌러 다시 연동해주세요.")
print("Cafe24 Access token expired, refreshing...")
refresh_access_token(tokens.get('refresh_token'))
return get_valid_access_token()
return tokens.get('access_token')
def get_cafe24_orders_count(status="N20"):
token = get_valid_access_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"X-Cafe24-Api-Version": "2026-03-01"
}
end_date = datetime.now().strftime("%Y-%m-%d")
start_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
url = f"https://{MALL_ID}.cafe24api.com/api/v2/admin/orders/count"
params = {
"start_date": start_date,
"end_date": end_date,
"order_status": status
}
response = requests.get(url, headers=headers, params=params, timeout=REQUEST_TIMEOUT)
if response.status_code == 200:
return response.json().get('count', 0)
return 0
def get_cafe24_orders(status="N20", progress_callback=None):
"""
Fetch orders with specific status (default N20: 배송준비중)
"""
token = get_valid_access_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"X-Cafe24-Api-Version": "2026-03-01"
}
# Calculate search date range (e.g. last 7 days)
end_date = datetime.now().strftime("%Y-%m-%d")
start_date = (datetime.now() - timedelta(days=7)).strftime("%Y-%m-%d")
url = f"https://{MALL_ID}.cafe24api.com/api/v2/admin/orders"
all_orders = []
limit = 100
offset = 0
while True:
params = {
"start_date": start_date,
"end_date": end_date,
"order_status": status,
"limit": limit,
"offset": offset,
"embed": "receivers,items" # embed items and receiver addresses
}
response = requests.get(url, headers=headers, params=params, timeout=REQUEST_TIMEOUT)
if response.status_code != 200:
raise Exception(f"Failed to fetch Cafe24 orders: {response.text}")
json_data = response.json()
orders = json_data.get('orders', [])
all_orders.extend(orders)
if progress_callback:
progress_callback(len(all_orders))
if len(orders) < limit:
break
offset += limit
time.sleep(0.5) # rate limit prevention
return all_orders
def update_cafe24_tracking(dispatch_list):
"""
dispatch_list is a list of dict: {"order_id": "...", "tracking_no": "...", "shipping_company_code": "0004"}
Cafe24 API allows updating order shipments per order or item.
Assuming we update the whole order (not item by item): PUT /api/v2/admin/orders/{order_id}/shipments
"""
token = get_valid_access_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"X-Cafe24-Api-Version": "2026-03-01"
}
results = {
"success": [],
"fail": []
}
for item in dispatch_list:
order_id = item.get("order_id")
tracking_no = item.get("tracking_no")
company_code = item.get("shipping_company_code")
url = f"https://{MALL_ID}.cafe24api.com/api/v2/admin/orders/{order_id}/shipments"
payload = {
"request": {
"tracking_no": tracking_no,
"shipping_company_code": company_code
}
}
response = requests.put(url, headers=headers, json=payload, timeout=REQUEST_TIMEOUT)
if response.status_code in [200, 201]:
results["success"].append(order_id)
else:
reason = "Unknown Error"
try:
err_data = response.json()
reason = err_data.get('error', {}).get('message', response.text)
except:
reason = response.text
results["fail"].append({
"order_id": order_id,
"reason": reason
})
time.sleep(0.3) # API rate limit
return results
def get_product_details(product_no):
""" Fetch product details to get custom product codes/seller codes """
token = get_valid_access_token()
headers = {
"Authorization": f"Bearer {token}",
"Content-Type": "application/json",
"X-Cafe24-Api-Version": "2026-03-01"
}
url = f"https://{MALL_ID}.cafe24api.com/api/v2/admin/products/{product_no}?embed=variants"
response = requests.get(url, headers=headers, timeout=REQUEST_TIMEOUT)
if response.status_code == 200:
return response.json().get('product', {})
return {}