6e438344d0
주요 변경사항: - DB 접속정보를 환경변수(.env) 방식으로 분리 - orderlist_app → orderlist_db 변경 (main.py, routers/returns.py) - 네이버/카페24 API 키도 환경변수로 분리 - Dockerfile / docker-compose.yml 작성 (외부 PostgreSQL 네트워크 연결) - .gitignore: 민감파일(.env, cafe24_tokens.json, manual-ordering.json) 제외 - 불필요 파일 정리 (venv, __pycache__, 일회성 스크립트, xlsx 등) - README.md / .env.example / push_to_gitea.bat 추가 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
243 lines
8.1 KiB
Python
243 lines
8.1 KiB
Python
import requests
|
|
import json
|
|
import os
|
|
import time
|
|
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 = "cafe24_tokens.json"
|
|
|
|
def get_auth_url():
|
|
# state parameter could be added for security but keeping it simple for local app
|
|
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
|
|
|
|
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):
|
|
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)
|
|
if response.status_code == 200:
|
|
_save_tokens(response.json())
|
|
return True
|
|
else:
|
|
raise Exception(f"Failed to get token: {response.text}")
|
|
|
|
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)
|
|
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.")
|
|
|
|
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 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)
|
|
|
|
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:
|
|
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)
|
|
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)
|
|
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)
|
|
|
|
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)
|
|
if response.status_code == 200:
|
|
return response.json().get('product', {})
|
|
return {}
|