API-CIECIETAIPEI / main.py
DeepLearning101's picture
Update main.py
0928d52 verified
from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from typing import Optional, Dict
from supabase import create_client, Client
import os
import requests
import uuid
import json
import base64
import hashlib
import hmac
import time
import asyncio
import urllib.parse
app = FastAPI(title="Cié Cié Backend API")
# 🌟 解決 CORS (跨域) 問題
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
# 讀取環境變數
SUPABASE_URL = os.getenv("SUPABASE_URL", "")
SUPABASE_KEY = os.getenv("SUPABASE_KEY", "") # 必須使用 service_role key
LINE_ACCESS_TOKEN = os.getenv("LINE_ACCESS_TOKEN", "")
BOSS_LINE_ID = os.getenv("BOSS_LINE_ID", "")
# 🌟 LINE Pay 金鑰設定 🌟
LINE_PAY_CHANNEL_ID = os.getenv("LINE_PAY_CHANNEL_ID", "")
LINE_PAY_CHANNEL_SECRET = os.getenv("LINE_PAY_CHANNEL_SECRET", "")
LINE_PAY_BASE_URL = "https://sandbox-api-pay.line.me"
# 設定結帳後要跳回前端哪裡? (指向您的 GitHub Pages booking.html)
RETURN_URL = "https://ciecietaipei.github.io/booking.html"
supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY) if SUPABASE_URL else None
# --- 資料結構定義 ---
class OrderPayload(BaseModel):
service_type: str
name: str
tel: str
date: str
time: str
line_id: Optional[str] = ""
pax: int = 2
cart: Dict[str, int] = {}
deposit_required: int = 0
total_amount: int = 0
class ConfirmPayload(BaseModel):
transaction_id: str
order_id: str
amount: int
class RepayPayload(BaseModel):
order_id: str
# ==========================================
# 🌟 背景自動救援掉單機器人 🌟
# ==========================================
async def auto_rescue_dropped_order(order_id: str, amount: int):
# 讓程式在背景默默等待 3 分鐘 (180秒)
await asyncio.sleep(180)
if not supabase: return
try:
# 3 分鐘後醒來,去資料庫看這筆訂單的狀態
res = supabase.table("bookings").select("*").ilike("remarks", f"%{order_id}%").execute()
if not res.data: return
booking = res.data[0]
# 如果狀態已經是「已付」或「確認」,代表客人有乖乖跳轉回來,不需要救援
if "已付" in booking.get("status", "") or "確認" in booking.get("status", ""):
return
# 🚨 如果還是「待付款」,立刻去敲 LINE Pay 總部的門查帳
uri = "/v3/payments"
query_string = urllib.parse.urlencode({"orderId": order_id})
nonce = str(uuid.uuid4())
message = LINE_PAY_CHANNEL_SECRET + uri + query_string + nonce
signature = base64.b64encode(hmac.new(LINE_PAY_CHANNEL_SECRET.encode(), message.encode(), hashlib.sha256).digest()).decode()
headers = {
"Content-Type": "application/json", "X-LINE-ChannelId": LINE_PAY_CHANNEL_ID,
"X-LINE-Authorization-Nonce": nonce, "X-LINE-Authorization": signature
}
r = requests.get(f"{LINE_PAY_BASE_URL}{uri}?{query_string}", headers=headers)
res_data = r.json()
if res_data.get("returnCode") == "0000" and res_data.get("info"):
tx = res_data["info"][0]
# 🚨 發現掉單!客人付了錢但沒跳轉回來 (卡在 AUTHORIZATION 授權中)
if tx.get("transactionType") == "AUTHORIZATION":
transaction_id = tx.get("transactionId")
# 系統自動代客執行 Confirm 請款!
confirm_uri = f"/v3/payments/{transaction_id}/confirm"
confirm_nonce = str(uuid.uuid4())
confirm_body = json.dumps({"amount": amount, "currency": "TWD"})
confirm_msg = LINE_PAY_CHANNEL_SECRET + confirm_uri + confirm_body + confirm_nonce
confirm_sig = base64.b64encode(hmac.new(LINE_PAY_CHANNEL_SECRET.encode(), confirm_msg.encode(), hashlib.sha256).digest()).decode()
confirm_headers = {
"Content-Type": "application/json", "X-LINE-ChannelId": LINE_PAY_CHANNEL_ID,
"X-LINE-Authorization-Nonce": confirm_nonce, "X-LINE-Authorization": confirm_sig
}
confirm_res = requests.post(f"{LINE_PAY_BASE_URL}{confirm_uri}", headers=confirm_headers, data=confirm_body).json()
if confirm_res.get("returnCode") == "0000":
# 救援請款成功!強制更新資料庫狀態
supabase.table("bookings").update({"status": "待處理 (已付訂金)"}).eq("id", booking['id']).execute()
# 發送【特殊救援通知】給老闆
if LINE_ACCESS_TOKEN and BOSS_LINE_ID:
msg = f"🌟 【防掉單自動救援成功】🌟\n系統發現客人付完款但提早關閉網頁,已自動完成請款並建立訂單!\n\n👤 姓名:{booking['name']}\n📞 電話:{booking['tel']}\n⏰ 取餐:{booking['date']} {booking['time']}\n💰 成功收回:${amount}\n📝 備註:請至後台查看餐點明細。"
headers_line = {"Authorization": f"Bearer {LINE_ACCESS_TOKEN}"}
payload_line = {"to": BOSS_LINE_ID, "messages": [{"type": "text", "text": msg}]}
requests.post("https://api.line.me/v2/bot/message/push", headers=headers_line, json=payload_line)
except Exception as e:
print(f"Auto rescue failed: {e}")
# --- API 端點定義 ---
@app.get("/")
def read_root():
return {"status": "online", "message": "Cié Cié FastAPI is running."}
@app.post("/api/submit_booking")
async def submit_booking(payload: OrderPayload, background_tasks: BackgroundTasks):
if not supabase:
raise HTTPException(status_code=500, detail="資料庫未設定")
is_noshow = False
try:
res = supabase.table("bookings").select("id").eq("tel", payload.tel).eq("status", "No-Show").execute()
is_noshow = len(res.data) > 0
except: pass
final_deposit = payload.deposit_required
if is_noshow and final_deposit == 0:
final_deposit = 1000
if final_deposit > 0:
order_id = f"ORDER-{uuid.uuid4().hex[:8].upper()}"
request_body = {
"amount": final_deposit,
"currency": "TWD",
"orderId": order_id,
"packages": [{
"id": "pkg_1", "amount": final_deposit, "name": "Cié Cié Taipei 預付金",
"products": [{"name": "餐飲訂金與預付金", "quantity": 1, "price": final_deposit}]
}],
"redirectUrls": {
"confirmUrl": f"{RETURN_URL}?action=payment_confirm&amount={final_deposit}&orderId={order_id}",
"cancelUrl": f"{RETURN_URL}?action=payment_cancel"
}
}
uri = "/v3/payments/request"
nonce = str(uuid.uuid4())
body_str = json.dumps(request_body)
message = LINE_PAY_CHANNEL_SECRET + uri + body_str + nonce
signature = base64.b64encode(hmac.new(LINE_PAY_CHANNEL_SECRET.encode(), message.encode(), hashlib.sha256).digest()).decode()
headers = {
"Content-Type": "application/json",
"X-LINE-ChannelId": LINE_PAY_CHANNEL_ID,
"X-LINE-Authorization-Nonce": nonce,
"X-LINE-Authorization": signature
}
try:
line_pay_res = requests.post(f"{LINE_PAY_BASE_URL}{uri}", headers=headers, data=body_str)
res_data = line_pay_res.json()
if res_data.get("returnCode") == "0000":
payment_url = res_data["info"]["paymentUrl"]["web"]
booking_data = {
"name": payload.name, "tel": payload.tel, "date": payload.date,
"time": payload.time, "pax": payload.pax, "user_id": payload.line_id,
"status": "待付款",
"remarks": f"類型: {'外帶' if payload.service_type == 'takeout' else '內用'}\n餐點: {payload.cart}\n訂單號: {order_id}"
}
supabase.table("bookings").insert(booking_data).execute()
# 🌟 啟動救援精靈:指派它在背景倒數 3 分鐘後執行檢查 🌟
background_tasks.add_task(auto_rescue_dropped_order, order_id, final_deposit)
return {
"status": "require_payment", "message": "訂單需支付訂金",
"is_noshow_penalty": is_noshow, "deposit_amount": final_deposit,
"payment_url": payment_url, "order_id": order_id
}
else: raise HTTPException(status_code=500, detail=f"LINE Pay 錯誤: {res_data.get('returnMessage')}")
except Exception as e: raise HTTPException(status_code=500, detail=f"金流連線失敗: {str(e)}")
booking_data = {
"name": payload.name, "tel": payload.tel, "date": payload.date, "time": payload.time,
"pax": payload.pax, "email": "", "user_id": payload.line_id, "status": "待處理",
"remarks": f"類型: {'外帶' if payload.service_type == 'takeout' else '內用'}\n餐點: {payload.cart}"
}
try:
supabase.table("bookings").insert(booking_data).execute()
notify_boss(payload.name, payload.tel, payload.date, payload.time, payload.pax, 0)
return { "status": "success", "message": "訂位已成功建立!" }
except Exception as e: raise HTTPException(status_code=500, detail=f"寫入資料庫失敗: {str(e)}")
# 確認收錢的端點 (Confirm API)
@app.post("/api/linepay/confirm")
async def confirm_payment(payload: ConfirmPayload):
uri = f"/v3/payments/{payload.transaction_id}/confirm"
nonce = str(uuid.uuid4())
body_str = json.dumps({"amount": payload.amount, "currency": "TWD"})
message = LINE_PAY_CHANNEL_SECRET + uri + body_str + nonce
signature = base64.b64encode(hmac.new(LINE_PAY_CHANNEL_SECRET.encode(), message.encode(), hashlib.sha256).digest()).decode()
headers = {
"Content-Type": "application/json", "X-LINE-ChannelId": LINE_PAY_CHANNEL_ID,
"X-LINE-Authorization-Nonce": nonce, "X-LINE-Authorization": signature
}
try:
res = requests.post(f"{LINE_PAY_BASE_URL}{uri}", headers=headers, data=body_str)
res_data = res.json()
if res_data.get("returnCode") == "0000":
update_res = supabase.table("bookings").update({"status": "待處理 (已付訂金)"}).ilike("remarks", f"%{payload.order_id}%").execute()
if update_res.data:
b = update_res.data[0]
notify_boss(b['name'], b['tel'], b['date'], b['time'], b['pax'], payload.amount)
return {"status": "success", "message": "付款確認成功"}
else:
raise HTTPException(status_code=400, detail=res_data.get('returnMessage'))
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
# 處理重新產生付款連結的 API (防呆升級版)
@app.post("/api/linepay/repay")
async def repay_payment(payload: RepayPayload):
if not supabase: raise HTTPException(status_code=500, detail="資料庫未連線")
try:
res = supabase.table("bookings").select("*").ilike("remarks", f"%{payload.order_id}%").execute()
if not res.data: raise HTTPException(status_code=404, detail="找不到該筆訂單")
booking = res.data[0]
if "已付" in booking.get("status", "") or "確認" in booking.get("status", ""):
raise HTTPException(status_code=400, detail="此訂單已完成付款或確認,無需重新結帳")
# 🌟 修改:向 LINE Pay 查詢最初應付的正確金額,避免外帶單只收 1000 元 🌟
amount = 1000 # 預設防護底線
try:
chk_uri = "/v3/payments"
chk_query = urllib.parse.urlencode({"orderId": payload.order_id})
chk_nonce = str(uuid.uuid4())
chk_msg = LINE_PAY_CHANNEL_SECRET + chk_uri + chk_query + chk_nonce
chk_sig = base64.b64encode(hmac.new(LINE_PAY_CHANNEL_SECRET.encode(), chk_msg.encode(), hashlib.sha256).digest()).decode()
chk_headers = { "Content-Type": "application/json", "X-LINE-ChannelId": LINE_PAY_CHANNEL_ID, "X-LINE-Authorization-Nonce": chk_nonce, "X-LINE-Authorization": chk_sig }
chk_res = requests.get(f"{LINE_PAY_BASE_URL}{chk_uri}?{chk_query}", headers=chk_headers).json()
if chk_res.get("returnCode") == "0000" and chk_res.get("info"):
# 成功抓出這筆訂單原本設定的金額!
amount = chk_res["info"][0].get("payInfo", [{}])[0].get("amount", 1000)
except Exception as e:
print(f"無法取得原始金額,使用預設值: {e}")
new_order_id = f"{payload.order_id}-R{int(time.time())}"
request_body = {
"amount": amount,
"currency": "TWD",
"orderId": new_order_id,
"packages": [{
"id": "pkg_repay", "amount": amount, "name": "Cié Cié Taipei 補繳結帳",
"products": [{"name": "餐飲訂金或外帶全額", "quantity": 1, "price": amount}]
}],
"redirectUrls": {
"confirmUrl": f"{RETURN_URL}?action=payment_confirm&amount={amount}&orderId={payload.order_id}",
"cancelUrl": f"{RETURN_URL}?action=payment_cancel"
}
}
uri = "/v3/payments/request"
nonce = str(uuid.uuid4())
body_str = json.dumps(request_body)
message = LINE_PAY_CHANNEL_SECRET + uri + body_str + nonce
signature = base64.b64encode(hmac.new(LINE_PAY_CHANNEL_SECRET.encode(), message.encode(), hashlib.sha256).digest()).decode()
headers = {
"Content-Type": "application/json", "X-LINE-ChannelId": LINE_PAY_CHANNEL_ID,
"X-LINE-Authorization-Nonce": nonce, "X-LINE-Authorization": signature
}
line_pay_res = requests.post(f"{LINE_PAY_BASE_URL}{uri}", headers=headers, data=body_str)
res_data = line_pay_res.json()
if res_data.get("returnCode") == "0000":
return {"payment_url": res_data["info"]["paymentUrl"]["web"]}
else:
raise HTTPException(status_code=500, detail=f"LINE Pay 錯誤: {res_data.get('returnMessage')}")
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
def notify_boss(name, tel, date, time, pax, amount):
if not LINE_ACCESS_TOKEN or not BOSS_LINE_ID: return
msg = f"🔔 【新訂單通知】\n姓名:{name}\n電話:{tel}\n時間:{date} {time}\n人數:{pax}位"
if amount > 0: msg += f"\n💰 已收到線上付款:${amount}"
headers = {"Authorization": f"Bearer {LINE_ACCESS_TOKEN}"}
payload = {"to": BOSS_LINE_ID, "messages": [{"type": "text", "text": msg}]}
try: requests.post("https://api.line.me/v2/bot/message/push", headers=headers, json=payload)
except: pass
# ==========================================
# 🌟 計算某日庫存與銷量的 API (防護升級版) 🌟
# ==========================================
@app.get("/api/inventory/{query_date}")
async def get_inventory(query_date: str):
if not supabase: return {}
try:
# 找出這天所有的訂單
res = supabase.table("bookings").select("cart, status").eq("date", query_date).execute()
sold_counts = {}
if res.data:
for b in res.data:
# 排除已經取消或 No-Show 的訂單,把扣打還給庫存
if "取消" in b.get("status", "") or "No-Show" in b.get("status", ""):
continue
# 🌟 修改:加入型別檢查防護,避免 null 或 字串 引發當機
cart = b.get("cart")
if not cart:
cart = {}
elif isinstance(cart, str):
try: cart = json.loads(cart)
except: cart = {}
# 計算這筆訂單買了什麼
for item_id, qty in cart.items():
try: qty = int(qty)
except: qty = 0
sold_counts[item_id] = sold_counts.get(item_id, 0) + qty
return sold_counts
except Exception as e:
print(f"Inventory Error: {e}")
return {}