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 {}