防掉單終極版
Browse files為了徹底解決這個問題,我們不需要老闆「手動」去後台查帳。我們可以利用 FastAPI 的一項超強功能:「背景任務 (Background Tasks)」,為您的系統植入一個「自動救援機器人」!
🤖 系統自動救援機器人的運作邏輯:
當客人按下結帳、系統產生 LINE Pay 連結的同時,FastAPI 大腦會偷偷派出一個「背景小精靈」。
這個小精靈會在背景默默倒數 3 分鐘(給客人足夠的時間輸入密碼結帳)。
3 分鐘一到,小精靈會去資料庫看這筆訂單。如果訂單狀態已經是「已付訂金」(代表客人有乖乖跳轉回來),小精靈就會功成身退。
🚨 關鍵來了:如果訂單還是「待付款」,小精靈就會立刻衝去敲 LINE Pay 總部的門查帳!
如果發現客人其實已經付了錢(卡在授權中),小精靈會「自動代客執行請款」,把錢收進來,並且發送一則特殊的 LINE 通知給老闆,告訴老闆「我幫你救回一筆掉單了!」。
main.py
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
from fastapi import FastAPI, HTTPException
|
| 2 |
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
from pydantic import BaseModel
|
| 4 |
from typing import Optional, Dict
|
|
@@ -11,6 +11,8 @@ import base64
|
|
| 11 |
import hashlib
|
| 12 |
import hmac
|
| 13 |
import time
|
|
|
|
|
|
|
| 14 |
|
| 15 |
app = FastAPI(title="Cié Cié Backend API")
|
| 16 |
|
|
@@ -57,18 +59,85 @@ class ConfirmPayload(BaseModel):
|
|
| 57 |
order_id: str
|
| 58 |
amount: int
|
| 59 |
|
| 60 |
-
# 🌟 新增:用來接收補付款請求的資料結構
|
| 61 |
class RepayPayload(BaseModel):
|
| 62 |
order_id: str
|
| 63 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
# --- API 端點定義 ---
|
| 65 |
|
| 66 |
@app.get("/")
|
| 67 |
def read_root():
|
| 68 |
return {"status": "online", "message": "Cié Cié FastAPI is running."}
|
| 69 |
|
|
|
|
| 70 |
@app.post("/api/submit_booking")
|
| 71 |
-
async def submit_booking(payload: OrderPayload):
|
| 72 |
if not supabase:
|
| 73 |
raise HTTPException(status_code=500, detail="���料庫未設定")
|
| 74 |
|
|
@@ -94,7 +163,7 @@ async def submit_booking(payload: OrderPayload):
|
|
| 94 |
"products": [{"name": "餐飲訂金與預付金", "quantity": 1, "price": final_deposit}]
|
| 95 |
}],
|
| 96 |
"redirectUrls": {
|
| 97 |
-
"confirmUrl": f"{RETURN_URL}?action=payment_confirm&amount={final_deposit}",
|
| 98 |
"cancelUrl": f"{RETURN_URL}?action=payment_cancel"
|
| 99 |
}
|
| 100 |
}
|
|
@@ -126,6 +195,9 @@ async def submit_booking(payload: OrderPayload):
|
|
| 126 |
}
|
| 127 |
supabase.table("bookings").insert(booking_data).execute()
|
| 128 |
|
|
|
|
|
|
|
|
|
|
| 129 |
return {
|
| 130 |
"status": "require_payment", "message": "訂單需支付訂金",
|
| 131 |
"is_noshow_penalty": is_noshow, "deposit_amount": final_deposit,
|
|
@@ -146,6 +218,7 @@ async def submit_booking(payload: OrderPayload):
|
|
| 146 |
return { "status": "success", "message": "訂位已成功建立!" }
|
| 147 |
except Exception as e: raise HTTPException(status_code=500, detail=f"寫入資料庫失敗: {str(e)}")
|
| 148 |
|
|
|
|
| 149 |
# 確認收錢的端點 (Confirm API)
|
| 150 |
@app.post("/api/linepay/confirm")
|
| 151 |
async def confirm_payment(payload: ConfirmPayload):
|
|
@@ -179,27 +252,20 @@ async def confirm_payment(payload: ConfirmPayload):
|
|
| 179 |
except Exception as e:
|
| 180 |
raise HTTPException(status_code=500, detail=str(e))
|
| 181 |
|
| 182 |
-
#
|
| 183 |
@app.post("/api/linepay/repay")
|
| 184 |
async def repay_payment(payload: RepayPayload):
|
| 185 |
if not supabase: raise HTTPException(status_code=500, detail="資料庫未連線")
|
| 186 |
|
| 187 |
try:
|
| 188 |
-
# 1. 找回原本的訂單
|
| 189 |
res = supabase.table("bookings").select("*").ilike("remarks", f"%{payload.order_id}%").execute()
|
| 190 |
if not res.data: raise HTTPException(status_code=404, detail="找不到該筆訂單")
|
| 191 |
|
| 192 |
booking = res.data[0]
|
| 193 |
-
# 防呆:如果客人其實已經付過錢了,阻止他重複刷卡
|
| 194 |
if "已付" in booking.get("status", "") or "確認" in booking.get("status", ""):
|
| 195 |
raise HTTPException(status_code=400, detail="此訂單已完成付款或確認,無需重新結帳")
|
| 196 |
|
| 197 |
-
# 2. 獲取要收取的金額 (PoC 階段先以防護訂金的 1000 元作為補收預設值)
|
| 198 |
-
# 備註:在最終版的 orders 表格中,我們會直接從資料庫精準讀取 deposit_amount
|
| 199 |
amount = 1000
|
| 200 |
-
|
| 201 |
-
# 3. 重新向 LINE Pay 申請新的交易連結
|
| 202 |
-
# 技巧:在原本的 order_id 後面加上時間戳記,繞過 LINE Pay 「同訂單號不可重複請求」的限制
|
| 203 |
new_order_id = f"{payload.order_id}-R{int(time.time())}"
|
| 204 |
|
| 205 |
request_body = {
|
|
@@ -211,7 +277,6 @@ async def repay_payment(payload: RepayPayload):
|
|
| 211 |
"products": [{"name": "餐飲訂金", "quantity": 1, "price": amount}]
|
| 212 |
}],
|
| 213 |
"redirectUrls": {
|
| 214 |
-
# 付款成功後,一樣帶回前端進行確認
|
| 215 |
"confirmUrl": f"{RETURN_URL}?action=payment_confirm&amount={amount}&orderId={payload.order_id}",
|
| 216 |
"cancelUrl": f"{RETURN_URL}?action=payment_cancel"
|
| 217 |
}
|
|
|
|
| 1 |
+
from fastapi import FastAPI, HTTPException, BackgroundTasks
|
| 2 |
from fastapi.middleware.cors import CORSMiddleware
|
| 3 |
from pydantic import BaseModel
|
| 4 |
from typing import Optional, Dict
|
|
|
|
| 11 |
import hashlib
|
| 12 |
import hmac
|
| 13 |
import time
|
| 14 |
+
import asyncio
|
| 15 |
+
import urllib.parse
|
| 16 |
|
| 17 |
app = FastAPI(title="Cié Cié Backend API")
|
| 18 |
|
|
|
|
| 59 |
order_id: str
|
| 60 |
amount: int
|
| 61 |
|
|
|
|
| 62 |
class RepayPayload(BaseModel):
|
| 63 |
order_id: str
|
| 64 |
|
| 65 |
+
# ==========================================
|
| 66 |
+
# 🌟 新增:背景自動救援掉單機器人 🌟
|
| 67 |
+
# ==========================================
|
| 68 |
+
async def auto_rescue_dropped_order(order_id: str, amount: int):
|
| 69 |
+
# 讓程式在背景默默等待 3 分鐘 (180秒)
|
| 70 |
+
await asyncio.sleep(180)
|
| 71 |
+
|
| 72 |
+
if not supabase: return
|
| 73 |
+
try:
|
| 74 |
+
# 3 分鐘後醒來,去資料庫看這筆訂單的狀態
|
| 75 |
+
res = supabase.table("bookings").select("*").ilike("remarks", f"%{order_id}%").execute()
|
| 76 |
+
if not res.data: return
|
| 77 |
+
|
| 78 |
+
booking = res.data[0]
|
| 79 |
+
# 如果狀態已經是「已付」或「確認」,代表客人有乖乖跳轉回來,不需要救援
|
| 80 |
+
if "已付" in booking.get("status", "") or "確認" in booking.get("status", ""):
|
| 81 |
+
return
|
| 82 |
+
|
| 83 |
+
# 🚨 如果還是「待付款」,立刻去敲 LINE Pay 總部的門查帳
|
| 84 |
+
uri = "/v3/payments"
|
| 85 |
+
query_string = urllib.parse.urlencode({"orderId": order_id})
|
| 86 |
+
nonce = str(uuid.uuid4())
|
| 87 |
+
message = LINE_PAY_CHANNEL_SECRET + uri + query_string + nonce
|
| 88 |
+
signature = base64.b64encode(hmac.new(LINE_PAY_CHANNEL_SECRET.encode(), message.encode(), hashlib.sha256).digest()).decode()
|
| 89 |
+
|
| 90 |
+
headers = {
|
| 91 |
+
"Content-Type": "application/json", "X-LINE-ChannelId": LINE_PAY_CHANNEL_ID,
|
| 92 |
+
"X-LINE-Authorization-Nonce": nonce, "X-LINE-Authorization": signature
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
r = requests.get(f"{LINE_PAY_BASE_URL}{uri}?{query_string}", headers=headers)
|
| 96 |
+
res_data = r.json()
|
| 97 |
+
|
| 98 |
+
if res_data.get("returnCode") == "0000" and res_data.get("info"):
|
| 99 |
+
tx = res_data["info"][0]
|
| 100 |
+
# 🚨 發現掉單!客人付了錢但沒跳轉回來 (卡在 AUTHORIZATION 授權中)
|
| 101 |
+
if tx.get("transactionType") == "AUTHORIZATION":
|
| 102 |
+
transaction_id = tx.get("transactionId")
|
| 103 |
+
|
| 104 |
+
# 系統自動代客執行 Confirm 請款!
|
| 105 |
+
confirm_uri = f"/v3/payments/{transaction_id}/confirm"
|
| 106 |
+
confirm_nonce = str(uuid.uuid4())
|
| 107 |
+
confirm_body = json.dumps({"amount": amount, "currency": "TWD"})
|
| 108 |
+
confirm_msg = LINE_PAY_CHANNEL_SECRET + confirm_uri + confirm_body + confirm_nonce
|
| 109 |
+
confirm_sig = base64.b64encode(hmac.new(LINE_PAY_CHANNEL_SECRET.encode(), confirm_msg.encode(), hashlib.sha256).digest()).decode()
|
| 110 |
+
|
| 111 |
+
confirm_headers = {
|
| 112 |
+
"Content-Type": "application/json", "X-LINE-ChannelId": LINE_PAY_CHANNEL_ID,
|
| 113 |
+
"X-LINE-Authorization-Nonce": confirm_nonce, "X-LINE-Authorization": confirm_sig
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
confirm_res = requests.post(f"{LINE_PAY_BASE_URL}{confirm_uri}", headers=confirm_headers, data=confirm_body).json()
|
| 117 |
+
|
| 118 |
+
if confirm_res.get("returnCode") == "0000":
|
| 119 |
+
# 救援請款成功!強制更新資料庫狀態
|
| 120 |
+
supabase.table("bookings").update({"status": "待處理 (已付訂金)"}).eq("id", booking['id']).execute()
|
| 121 |
+
|
| 122 |
+
# 發送【特殊救援通知】給老闆
|
| 123 |
+
if LINE_ACCESS_TOKEN and BOSS_LINE_ID:
|
| 124 |
+
msg = f"🌟 【防掉單自動救援成功】🌟\n系統發現客人付完款但提早關閉網頁,已自動完成請款並建立訂單!\n\n👤 姓名:{booking['name']}\n📞 電話:{booking['tel']}\n⏰ 取餐:{booking['date']} {booking['time']}\n💰 成功收回:${amount}\n📝 備註:請至後台查看餐點明細。"
|
| 125 |
+
headers_line = {"Authorization": f"Bearer {LINE_ACCESS_TOKEN}"}
|
| 126 |
+
payload_line = {"to": BOSS_LINE_ID, "messages": [{"type": "text", "text": msg}]}
|
| 127 |
+
requests.post("https://api.line.me/v2/bot/message/push", headers=headers_line, json=payload_line)
|
| 128 |
+
except Exception as e:
|
| 129 |
+
print(f"Auto rescue failed: {e}")
|
| 130 |
+
|
| 131 |
+
|
| 132 |
# --- API 端點定義 ---
|
| 133 |
|
| 134 |
@app.get("/")
|
| 135 |
def read_root():
|
| 136 |
return {"status": "online", "message": "Cié Cié FastAPI is running."}
|
| 137 |
|
| 138 |
+
# 🌟 修改:加入 background_tasks 參數 🌟
|
| 139 |
@app.post("/api/submit_booking")
|
| 140 |
+
async def submit_booking(payload: OrderPayload, background_tasks: BackgroundTasks):
|
| 141 |
if not supabase:
|
| 142 |
raise HTTPException(status_code=500, detail="���料庫未設定")
|
| 143 |
|
|
|
|
| 163 |
"products": [{"name": "餐飲訂金與預付金", "quantity": 1, "price": final_deposit}]
|
| 164 |
}],
|
| 165 |
"redirectUrls": {
|
| 166 |
+
"confirmUrl": f"{RETURN_URL}?action=payment_confirm&amount={final_deposit}&orderId={order_id}",
|
| 167 |
"cancelUrl": f"{RETURN_URL}?action=payment_cancel"
|
| 168 |
}
|
| 169 |
}
|
|
|
|
| 195 |
}
|
| 196 |
supabase.table("bookings").insert(booking_data).execute()
|
| 197 |
|
| 198 |
+
# 🌟 啟動救援精靈:指派它在背景倒數 3 分鐘後執行檢查 🌟
|
| 199 |
+
background_tasks.add_task(auto_rescue_dropped_order, order_id, final_deposit)
|
| 200 |
+
|
| 201 |
return {
|
| 202 |
"status": "require_payment", "message": "訂單需支付訂金",
|
| 203 |
"is_noshow_penalty": is_noshow, "deposit_amount": final_deposit,
|
|
|
|
| 218 |
return { "status": "success", "message": "訂位已成功建立!" }
|
| 219 |
except Exception as e: raise HTTPException(status_code=500, detail=f"寫入資料庫失敗: {str(e)}")
|
| 220 |
|
| 221 |
+
|
| 222 |
# 確認收錢的端點 (Confirm API)
|
| 223 |
@app.post("/api/linepay/confirm")
|
| 224 |
async def confirm_payment(payload: ConfirmPayload):
|
|
|
|
| 252 |
except Exception as e:
|
| 253 |
raise HTTPException(status_code=500, detail=str(e))
|
| 254 |
|
| 255 |
+
# 處理重新產生付款連結的 API
|
| 256 |
@app.post("/api/linepay/repay")
|
| 257 |
async def repay_payment(payload: RepayPayload):
|
| 258 |
if not supabase: raise HTTPException(status_code=500, detail="資料庫未連線")
|
| 259 |
|
| 260 |
try:
|
|
|
|
| 261 |
res = supabase.table("bookings").select("*").ilike("remarks", f"%{payload.order_id}%").execute()
|
| 262 |
if not res.data: raise HTTPException(status_code=404, detail="找不到該筆訂單")
|
| 263 |
|
| 264 |
booking = res.data[0]
|
|
|
|
| 265 |
if "已付" in booking.get("status", "") or "確認" in booking.get("status", ""):
|
| 266 |
raise HTTPException(status_code=400, detail="此訂單已完成付款或確認,無需重新結帳")
|
| 267 |
|
|
|
|
|
|
|
| 268 |
amount = 1000
|
|
|
|
|
|
|
|
|
|
| 269 |
new_order_id = f"{payload.order_id}-R{int(time.time())}"
|
| 270 |
|
| 271 |
request_body = {
|
|
|
|
| 277 |
"products": [{"name": "餐飲訂金", "quantity": 1, "price": amount}]
|
| 278 |
}],
|
| 279 |
"redirectUrls": {
|
|
|
|
| 280 |
"confirmUrl": f"{RETURN_URL}?action=payment_confirm&amount={amount}&orderId={payload.order_id}",
|
| 281 |
"cancelUrl": f"{RETURN_URL}?action=payment_cancel"
|
| 282 |
}
|