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>
This commit is contained in:
+85
-31
@@ -2,6 +2,7 @@ import requests
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
# 환경변수에서 카페24 인증 정보 로드
|
||||
@@ -9,14 +10,21 @@ 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 = "cafe24_tokens.json"
|
||||
TOKEN_FILE = os.getenv("CAFE24_TOKEN_FILE", str(Path(__file__).resolve().parent / "cafe24_tokens.json"))
|
||||
REQUEST_TIMEOUT = 30
|
||||
|
||||
def get_auth_url():
|
||||
# state parameter could be added for security but keeping it simple for local app
|
||||
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"
|
||||
url += f"?response_type=code&client_id={CLIENT_ID}&redirect_uri={REDIRECT_URI}"
|
||||
url += f"&scope=mall.read_order,mall.write_order,mall.read_product"
|
||||
return url
|
||||
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
|
||||
|
||||
@@ -25,7 +33,8 @@ def _get_basic_auth_header():
|
||||
encoded = base64.b64encode(credentials.encode('utf-8')).decode('utf-8')
|
||||
return f"Basic {encoded}"
|
||||
|
||||
def request_new_token(auth_code: str):
|
||||
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(),
|
||||
@@ -34,15 +43,15 @@ def request_new_token(auth_code: str):
|
||||
data = {
|
||||
"grant_type": "authorization_code",
|
||||
"code": auth_code,
|
||||
"redirect_uri": REDIRECT_URI
|
||||
"redirect_uri": redirect_uri
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, data=data)
|
||||
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"Failed to get token: {response.text}")
|
||||
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"
|
||||
@@ -55,15 +64,13 @@ def refresh_access_token(refresh_token: str):
|
||||
"refresh_token": refresh_token
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, data=data)
|
||||
response = requests.post(url, headers=headers, data=data, timeout=REQUEST_TIMEOUT)
|
||||
if response.status_code == 200:
|
||||
_save_tokens(response.json())
|
||||
return True
|
||||
else:
|
||||
# Refresh token might be expired. Need to re-authenticate.
|
||||
if os.path.exists(TOKEN_FILE):
|
||||
os.remove(TOKEN_FILE)
|
||||
raise Exception("Refresh token expired or invalid. Please re-authenticate.")
|
||||
# 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"
|
||||
@@ -76,6 +83,62 @@ def _save_tokens(token_data):
|
||||
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.")
|
||||
@@ -83,18 +146,9 @@ def get_valid_access_token():
|
||||
with open(TOKEN_FILE, 'r', encoding='utf-8') as f:
|
||||
tokens = json.load(f)
|
||||
|
||||
expires_at_str = tokens.get('expires_at')
|
||||
# Parse Cafe24 typical datetime format or isoformat
|
||||
try:
|
||||
if '.' in expires_at_str: # e.g. "2023-10-01T12:00:00.000"
|
||||
expires_at = datetime.strptime(expires_at_str[:19], "%Y-%m-%dT%H:%M:%S")
|
||||
else:
|
||||
expires_at = datetime.fromisoformat(expires_at_str)
|
||||
except:
|
||||
expires_at = datetime.now() - timedelta(minutes=1) # force refresh on parse error
|
||||
|
||||
# Check if expired
|
||||
if datetime.now() >= expires_at:
|
||||
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()
|
||||
@@ -119,7 +173,7 @@ def get_cafe24_orders_count(status="N20"):
|
||||
"order_status": status
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers, params=params)
|
||||
response = requests.get(url, headers=headers, params=params, timeout=REQUEST_TIMEOUT)
|
||||
if response.status_code == 200:
|
||||
return response.json().get('count', 0)
|
||||
return 0
|
||||
@@ -155,7 +209,7 @@ def get_cafe24_orders(status="N20", progress_callback=None):
|
||||
"embed": "receivers,items" # embed items and receiver addresses
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers, params=params)
|
||||
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}")
|
||||
|
||||
@@ -205,7 +259,7 @@ def update_cafe24_tracking(dispatch_list):
|
||||
}
|
||||
}
|
||||
|
||||
response = requests.put(url, headers=headers, json=payload)
|
||||
response = requests.put(url, headers=headers, json=payload, timeout=REQUEST_TIMEOUT)
|
||||
|
||||
if response.status_code in [200, 201]:
|
||||
results["success"].append(order_id)
|
||||
@@ -236,7 +290,7 @@ def get_product_details(product_no):
|
||||
}
|
||||
url = f"https://{MALL_ID}.cafe24api.com/api/v2/admin/products/{product_no}?embed=variants"
|
||||
|
||||
response = requests.get(url, headers=headers)
|
||||
response = requests.get(url, headers=headers, timeout=REQUEST_TIMEOUT)
|
||||
if response.status_code == 200:
|
||||
return response.json().get('product', {})
|
||||
return {}
|
||||
|
||||
Reference in New Issue
Block a user