| 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") |
|
|
| |
| 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", "") |
|
|
| |
| 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" |
|
|
| |
| 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): |
| |
| 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}\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}") |
|
|
|
|
| |
|
|
| @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() |
|
|
| |
| 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)}") |
|
|
|
|
| |
| @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)) |
|
|
| |
| @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)) |
|
|
| 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 |
|
|
| |
| |
| |
| @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 {} |