from fastapi import FastAPI, HTTPException from fastapi.responses import RedirectResponse from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from typing import Optional from supabase import create_client, Client import os import requests # 以下 import 供 LINE Pay / 自動救援功能使用,停用中 # import uuid # import json # import base64 # import hashlib # import hmac # import time # import asyncio # import urllib.parse app = FastAPI(title="Cié Cié Backend API") 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", "") LINE_ACCESS_TOKEN = os.getenv("LINE_ACCESS_TOKEN", "") BOSS_LINE_ID = os.getenv("BOSS_LINE_ID", "") BOSS_EMAIL = os.getenv("BOSS_EMAIL", "") # 新增:老闆收信 Email GAS_MAIL_URL = os.getenv("GAS_MAIL_URL", "") # 新增:GAS 發信 Relay URL # 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" # RESEND_API_KEY = os.getenv("RESEND_API_KEY", "") 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 email: Optional[str] = "" remarks: Optional[str] = "" # 以下欄位供 LINE Pay 流程使用,停用中 # cart: Dict[str, int] = {} # deposit_required: float = 0 # total_amount: float = 0 # kitchen_remarks: Optional[str] = "" # LINE Pay 付款確認 payload(停用中) # class ConfirmPayload(BaseModel): # transaction_id: str # order_id: str # amount: int # LINE Pay 補繳 payload(停用中) # class RepayPayload(BaseModel): # order_id: str # ========================================== # 背景自動救援掉單(LINE Pay 用,停用中) # ========================================== # # async def auto_rescue_dropped_order(order_id: str, amount: int): # """當客人付完款但提早關閉網頁導致沒跳轉回來時,背景自動 3 分鐘後查帳並補確認""" # await asyncio.sleep(180) # if not supabase: return # try: # 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 # # 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] # if tx.get("transactionType") == "AUTHORIZATION": # transaction_id = tx.get("transactionId") # 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}" # requests.post("https://api.line.me/v2/bot/message/push", # headers={"Authorization": f"Bearer {LINE_ACCESS_TOKEN}"}, # json={"to": BOSS_LINE_ID, "messages": [{"type": "text", "text": msg}]}) # 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): if not supabase: raise HTTPException(status_code=500, detail="資料庫未設定") # ── LINE Pay 訂金流程(停用中)────────────────────────────── # 若未來要重新啟用,需同時取消 ConfirmPayload / RepayPayload / # auto_rescue_dropped_order / /api/linepay/confirm 等的 comment # # 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, # "email": payload.email, "status": "待付款", # "remarks": f"類型: {'外帶' if payload.service_type == 'takeout' else '內用'}\n訂單號: {order_id}\n客人備註: {payload.remarks or '無'}\n\n{payload.kitchen_remarks}" # } # supabase.table("bookings").insert(booking_data).execute() # 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)}") # ── LINE Pay 流程結束 ──────────────────────────────────────── booking_data = { "name": payload.name, "tel": payload.tel, "date": payload.date, "time": payload.time, "pax": payload.pax, "email": payload.email, "user_id": payload.line_id, "status": "待處理", "remarks": f"類型: {'外帶' if payload.service_type == 'takeout' else '內用'}\n客人備註: {payload.remarks or '無'}" } try: supabase.table("bookings").insert(booking_data).execute() notify_boss( payload.name, payload.tel, payload.date, payload.time, payload.pax, payload.email or "", "網頁新訂位" ) return {"status": "success", "message": "訂位已成功建立!"} except Exception as e: raise HTTPException(status_code=500, detail=f"寫入資料庫失敗: {str(e)}") @app.get("/api/booking/action") async def booking_action(action: str, id: str): """客人點擊確認/取消連結 → 更新 Supabase → 通知老闆 → 導回前端""" if not supabase: raise HTTPException(status_code=500, detail="資料庫未設定") if action not in ("confirm", "cancel"): raise HTTPException(status_code=400, detail="無效的動作") status_map = {"confirm": "網頁顧客已確認", "cancel": "網頁顧客已取消"} new_status = status_map[action] event = "網頁顧客已確認" if action == "confirm" else "網頁顧客已取消" try: res = supabase.table("bookings").update({"status": new_status}).eq("id", id).execute() # 原版此處無通知老闆邏輯,現已新增: if res.data: b = res.data[0] notify_boss( b["name"], b["tel"], b["date"], b["time"], b["pax"], b.get("email", ""), event ) except Exception as e: raise HTTPException(status_code=500, detail=str(e)) return RedirectResponse(url=f"{RETURN_URL}?action={action}", status_code=302) # LINE Pay 付款確認端點(停用中) # @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'], b.get('email',''), "已付訂金") # 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)) # LINE Pay 補繳連結產生端點(停用中) # @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="此訂單已完成付款或確認,無需重新結帳") # 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)) # ========================================== # 通知老闆:GAS Email 為主(必送),LINE 為輔(失敗只 log) # # 原版 notify_boss 僅發 LINE,無 Email,signature 為: # def notify_boss(name, tel, date, time, pax, amount): # if amount > 0: msg += f"\n💰 已收到線上付款:${amount}" # ...只推 LINE,無 GAS Email # ========================================== def notify_boss(name, tel, date, time, pax, email="", event="新訂位"): # 1. GAS Email(主要,必送) if BOSS_EMAIL and GAS_MAIL_URL: emoji = {"新訂位": "🔔", "網頁顧客已確認": "✅", "顧客已取消": "🚫", "已付訂金": "💰"}.get(event, "📢") subject = f"[{event}] {name} - {date} {time}" html = f"""

{emoji} Cié Cié Taipei — {event}

姓名{name}
電話{tel}
日期{date}
時間{time}
人數{pax} 位
Email{email or '-'}
""" try: r = requests.post( GAS_MAIL_URL, json={"to": BOSS_EMAIL, "subject": subject, "htmlBody": html, "name": "Cié Cié Taipei"}, timeout=15 ) if r.status_code == 200: body = r.json() if r.text.strip().startswith("{") else {} if body.get("status") == "error": print(f"❌ notify_boss GAS Email 失敗(GAS 錯誤):{body.get('message', r.text[:200])}") else: print(f"✅ notify_boss GAS Email 成功") else: print(f"❌ notify_boss GAS Email 失敗:HTTP {r.status_code} — {r.text[:200]}") except Exception as e: print(f"❌ notify_boss GAS Email 失敗:{e}") else: print(f"⚠️ notify_boss GAS Email 跳過:BOSS_EMAIL={'有' if BOSS_EMAIL else '無'}, GAS_MAIL_URL={'有' if GAS_MAIL_URL else '無'}") # 2. LINE(輔助,失敗只 log) if LINE_ACCESS_TOKEN and BOSS_LINE_ID: emoji = {"新訂位": "🔔", "顧客已確認": "✅", "顧客已取消": "🚫", "已付訂金": "💰"}.get(event, "📢") msg = f"{emoji} 【{event}】\n姓名:{name}\n電話:{tel}\n時間:{date} {time}\n人數:{pax} 位" if email: msg += f"\nEmail:{email}" try: r = requests.post( "https://api.line.me/v2/bot/message/push", headers={"Authorization": f"Bearer {LINE_ACCESS_TOKEN}"}, json={"to": BOSS_LINE_ID, "messages": [{"type": "text", "text": msg}]}, timeout=10 ) print(f"✅ notify_boss LINE 回應:{r.status_code}") except Exception as e: print(f"❌ notify_boss LINE 失敗:{e}") else: print(f"⚠️ notify_boss LINE 跳過:TOKEN={'有' if LINE_ACCESS_TOKEN else '無'}, BOSS_ID={'有' if BOSS_LINE_ID else '無'}") # Resend Email 端點(停用中 — Resend 無法使用,已改由 app.py 直接呼叫 GAS) # class EmailPayload(BaseModel): # to: str # subject: str # htmlBody: str # name: Optional[str] = "Cié Cié Taipei" # # @app.post("/api/send_email") # async def send_email(payload: EmailPayload): # if not RESEND_API_KEY: # raise HTTPException(status_code=500, detail="郵件服務未設定 (缺少 RESEND_API_KEY)") # try: # response = requests.post( # "https://api.resend.com/emails", # headers={"Authorization": f"Bearer {RESEND_API_KEY}", "Content-Type": "application/json"}, # json={"from": f"{payload.name} ", "to": [payload.to], "subject": payload.subject, "html": payload.htmlBody}, # timeout=15 # ) # if response.status_code in (200, 201): # return {"status": "success", "message": f"郵件已成功發送至 {payload.to}"} # else: # raise HTTPException(status_code=500, detail=f"Resend 錯誤: {response.text}") # except Exception as e: # raise HTTPException(status_code=500, detail=f"郵件發送失敗: {str(e)}") # 庫存查詢端點(停用中 — 預點餐功能關閉,view3 未啟用) # @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: # if "取消" in b.get("status", "") or "No-Show" in b.get("status", ""): # continue # 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 {}