| 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 端點 | |
| # ========================================== | |
| def read_root(): | |
| return {"status": "online", "message": "Cié Cié FastAPI is running."} | |
| 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)}") | |
| 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"""<div style="font-family:sans-serif; background:#1a1a1a; color:#eee; padding:24px; border-radius:8px;"> | |
| <h2 style="color:#d4af37; border-bottom:1px solid #444; padding-bottom:12px;">{emoji} Cié Cié Taipei — {event}</h2> | |
| <table style="width:100%; border-collapse:collapse; margin-top:12px;"> | |
| <tr><td style="color:#aaa; padding:6px 0; width:80px;">姓名</td><td style="color:#fff; font-weight:bold;">{name}</td></tr> | |
| <tr><td style="color:#aaa; padding:6px 0;">電話</td><td style="color:#fff;">{tel}</td></tr> | |
| <tr><td style="color:#aaa; padding:6px 0;">日期</td><td style="color:#d4af37; font-weight:bold;">{date}</td></tr> | |
| <tr><td style="color:#aaa; padding:6px 0;">時間</td><td style="color:#d4af37; font-weight:bold;">{time}</td></tr> | |
| <tr><td style="color:#aaa; padding:6px 0;">人數</td><td style="color:#fff;">{pax} 位</td></tr> | |
| <tr><td style="color:#aaa; padding:6px 0;">Email</td><td style="color:#fff;">{email or '-'}</td></tr> | |
| </table> | |
| </div>""" | |
| 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} <onboarding@resend.dev>", "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 {} | |