DeepLearning101 commited on
Commit
1896ee3
·
verified ·
1 Parent(s): bbbd5c9

Update main.py

Browse files
Files changed (1) hide show
  1. main.py +368 -341
main.py CHANGED
@@ -1,49 +1,55 @@
1
- from fastapi import FastAPI, HTTPException, BackgroundTasks
2
  from fastapi.responses import RedirectResponse
3
  from fastapi.middleware.cors import CORSMiddleware
4
  from pydantic import BaseModel
5
- from typing import Optional, Dict
6
  from supabase import create_client, Client
7
  import os
8
  import requests
9
- import uuid
10
- import json
11
- import base64
12
- import hashlib
13
- import hmac
14
- import time
15
- import asyncio
16
- import urllib.parse
 
 
17
 
18
  app = FastAPI(title="Cié Cié Backend API")
19
 
20
- # 🌟 解決 CORS (跨域) 問題
21
  app.add_middleware(
22
  CORSMiddleware,
23
- allow_origins=["*"],
24
  allow_credentials=True,
25
  allow_methods=["*"],
26
  allow_headers=["*"],
27
  )
28
 
29
- # 讀取環境變數
30
  SUPABASE_URL = os.getenv("SUPABASE_URL", "")
31
- SUPABASE_KEY = os.getenv("SUPABASE_KEY", "") # 必須使用 service_role key
32
  LINE_ACCESS_TOKEN = os.getenv("LINE_ACCESS_TOKEN", "")
33
  BOSS_LINE_ID = os.getenv("BOSS_LINE_ID", "")
 
 
34
 
35
- # 🌟 LINE Pay 金鑰設定 🌟
36
- LINE_PAY_CHANNEL_ID = os.getenv("LINE_PAY_CHANNEL_ID", "")
37
- LINE_PAY_CHANNEL_SECRET = os.getenv("LINE_PAY_CHANNEL_SECRET", "")
38
- LINE_PAY_BASE_URL = "https://sandbox-api-pay.line.me"
39
- RESEND_API_KEY = os.getenv("RESEND_API_KEY", "")
40
 
41
- # 設定結帳後要跳回前端哪裡? (指向您的 GitHub Pages booking.html)
42
- RETURN_URL = "https://ciecietaipei.github.io/booking.html"
43
 
44
  supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY) if SUPABASE_URL else None
45
 
46
- # --- 資料結構定義 ---
 
 
 
 
47
  class OrderPayload(BaseModel):
48
  service_type: str
49
  name: str
@@ -52,360 +58,381 @@ class OrderPayload(BaseModel):
52
  time: str
53
  line_id: Optional[str] = ""
54
  pax: int = 2
55
- cart: Dict[str, int] = {}
56
- deposit_required: float = 0
57
- total_amount: float = 0
58
- kitchen_remarks: Optional[str] = ""
59
- # 🚀 【關鍵修正 1】允許後端海關接收前端發過來的這兩個欄位,防止 422 格式錯誤秒崩潰
60
  email: Optional[str] = ""
61
  remarks: Optional[str] = ""
62
-
63
- class ConfirmPayload(BaseModel):
64
- transaction_id: str
65
- order_id: str
66
- amount: int
 
 
 
 
 
 
 
 
 
 
67
 
68
- class RepayPayload(BaseModel):
69
- order_id: str
70
 
71
  # ==========================================
72
- # 🌟 背景自動救援掉單機器人 🌟
73
  # ==========================================
74
- async def auto_rescue_dropped_order(order_id: str, amount: int):
75
- # 讓程式在背景默默等待 3 分鐘 (180秒)
76
- await asyncio.sleep(180)
77
-
78
- if not supabase: return
79
- try:
80
- # 3 分鐘後醒來,去資料庫看這筆訂單的狀態
81
- res = supabase.table("bookings").select("*").ilike("remarks", f"%{order_id}%").execute()
82
- if not res.data: return
83
-
84
- booking = res.data[0]
85
- # 如果狀態已經是「已付」或「確認」,代表客人有乖乖跳轉回來,不需要救援
86
- if "已付" in booking.get("status", "") or "確認" in booking.get("status", ""):
87
- return
88
-
89
- # 🚨 如果還是「待付款」,立刻去敲 LINE Pay 總部的門查帳
90
- uri = "/v3/payments"
91
- query_string = urllib.parse.urlencode({"orderId": order_id})
92
- nonce = str(uuid.uuid4())
93
- message = LINE_PAY_CHANNEL_SECRET + uri + query_string + nonce
94
- signature = base64.b64encode(hmac.new(LINE_PAY_CHANNEL_SECRET.encode(), message.encode(), hashlib.sha256).digest()).decode()
95
-
96
- headers = {
97
- "Content-Type": "application/json", "X-LINE-ChannelId": LINE_PAY_CHANNEL_ID,
98
- "X-LINE-Authorization-Nonce": nonce, "X-LINE-Authorization": signature
99
- }
100
-
101
- r = requests.get(f"{LINE_PAY_BASE_URL}{uri}?{query_string}", headers=headers)
102
- res_data = r.json()
103
-
104
- if res_data.get("returnCode") == "0000" and res_data.get("info"):
105
- tx = res_data["info"][0]
106
- # 🚨 發現掉單!客人付了錢但沒跳轉回來 (卡在 AUTHORIZATION 授權中)
107
- if tx.get("transactionType") == "AUTHORIZATION":
108
- transaction_id = tx.get("transactionId")
109
-
110
- # 系統自動代客執行 Confirm 請款!
111
- confirm_uri = f"/v3/payments/{transaction_id}/confirm"
112
- confirm_nonce = str(uuid.uuid4())
113
- confirm_body = json.dumps({"amount": amount, "currency": "TWD"})
114
- confirm_msg = LINE_PAY_CHANNEL_SECRET + confirm_uri + confirm_body + confirm_nonce
115
- confirm_sig = base64.b64encode(hmac.new(LINE_PAY_CHANNEL_SECRET.encode(), confirm_msg.encode(), hashlib.sha256).digest()).decode()
116
-
117
- confirm_headers = {
118
- "Content-Type": "application/json", "X-LINE-ChannelId": LINE_PAY_CHANNEL_ID,
119
- "X-LINE-Authorization-Nonce": confirm_nonce, "X-LINE-Authorization": confirm_sig
120
- }
121
-
122
- confirm_res = requests.post(f"{LINE_PAY_BASE_URL}{confirm_uri}", headers=confirm_headers, data=confirm_body).json()
123
-
124
- if confirm_res.get("returnCode") == "0000":
125
- # 救援請款成功!強制更新資料庫狀態
126
- supabase.table("bookings").update({"status": "待處理 (已付訂金)"}).eq("id", booking['id']).execute()
127
-
128
- # 發送【特殊救援通知】給老闆
129
- if LINE_ACCESS_TOKEN and BOSS_LINE_ID:
130
- msg = f"🌟 【防掉單自動救援成功】🌟\n系統發現客人付完款但提早關閉網頁,已自動完成請款並建立訂單!\n\n👤 姓名:{booking['name']}\n📞 電話:{booking['tel']}\n⏰ 取餐:{booking['date']} {booking['time']}\n💰 成功收回:${amount}\n📝 備註:請至後台查看餐點明細。"
131
- headers_line = {"Authorization": f"Bearer {LINE_ACCESS_TOKEN}"}
132
- payload_line = {"to": BOSS_LINE_ID, "messages": [{"type": "text", "text": msg}]}
133
- requests.post("https://api.line.me/v2/bot/message/push", headers=headers_line, json=payload_line)
134
- except Exception as e:
135
- print(f"Auto rescue failed: {e}")
136
 
137
 
138
- # --- API 端點定義 ---
 
 
139
 
140
  @app.get("/")
141
  def read_root():
142
  return {"status": "online", "message": "Cié Cié FastAPI is running."}
143
 
 
144
  @app.post("/api/submit_booking")
145
- async def submit_booking(payload: OrderPayload, background_tasks: BackgroundTasks):
146
  if not supabase:
147
  raise HTTPException(status_code=500, detail="資料庫未設定")
148
 
149
- is_noshow = False
150
- try:
151
- res = supabase.table("bookings").select("id").eq("tel", payload.tel).eq("status", "No-Show").execute()
152
- is_noshow = len(res.data) > 0
153
- except: pass
154
-
155
- final_deposit = payload.deposit_required
156
- if is_noshow and final_deposit == 0:
157
- final_deposit = 1000
158
-
159
- if final_deposit > 0:
160
- order_id = f"ORDER-{uuid.uuid4().hex[:8].upper()}"
161
-
162
- request_body = {
163
- "amount": final_deposit,
164
- "currency": "TWD",
165
- "orderId": order_id,
166
- "packages": [{
167
- "id": "pkg_1", "amount": final_deposit, "name": "Cié Cié Taipei 預付金",
168
- "products": [{"name": "餐飲訂金與預付金", "quantity": 1, "price": final_deposit}]
169
- }],
170
- "redirectUrls": {
171
- "confirmUrl": f"{RETURN_URL}?action=payment_confirm&amount={final_deposit}&orderId={order_id}",
172
- "cancelUrl": f"{RETURN_URL}?action=payment_cancel"
173
- }
174
- }
175
-
176
- uri = "/v3/payments/request"
177
- nonce = str(uuid.uuid4())
178
- body_str = json.dumps(request_body)
179
- message = LINE_PAY_CHANNEL_SECRET + uri + body_str + nonce
180
- signature = base64.b64encode(hmac.new(LINE_PAY_CHANNEL_SECRET.encode(), message.encode(), hashlib.sha256).digest()).decode()
181
-
182
- headers = {
183
- "Content-Type": "application/json",
184
- "X-LINE-ChannelId": LINE_PAY_CHANNEL_ID,
185
- "X-LINE-Authorization-Nonce": nonce,
186
- "X-LINE-Authorization": signature
187
- }
188
-
189
- try:
190
- line_pay_res = requests.post(f"{LINE_PAY_BASE_URL}{uri}", headers=headers, data=body_str)
191
- res_data = line_pay_res.json()
192
-
193
- if res_data.get("returnCode") == "0000":
194
- payment_url = res_data["info"]["paymentUrl"]["web"]
195
-
196
- # 🚀 【關鍵修正 2】有付訂金時,確實寫入 email 並將客人備註融入大 remarks 中
197
- booking_data = {
198
- "name": payload.name, "tel": payload.tel, "date": payload.date,
199
- "time": payload.time, "pax": payload.pax, "user_id": payload.line_id,
200
- "email": payload.email,
201
- "status": "待付款",
202
- "remarks": f"類型: {'外帶' if payload.service_type == 'takeout' else '內用'}\n訂單號: {order_id}\n客人備註: {payload.remarks if payload.remarks else ''}\n\n{payload.kitchen_remarks}"
203
- }
204
- supabase.table("bookings").insert(booking_data).execute()
205
-
206
- # 🌟 啟動救援精靈:指派它在背景倒數 3 分鐘後執行檢查 🌟
207
- background_tasks.add_task(auto_rescue_dropped_order, order_id, final_deposit)
208
-
209
- return {
210
- "status": "require_payment", "message": "訂單需支付訂金",
211
- "is_noshow_penalty": is_noshow, "deposit_amount": final_deposit,
212
- "payment_url": payment_url, "order_id": order_id
213
- }
214
- else: raise HTTPException(status_code=500, detail=f"LINE Pay 錯誤: {res_data.get('returnMessage')}")
215
- except Exception as e: raise HTTPException(status_code=500, detail=f"金流連線失敗: {str(e)}")
216
-
217
- # 🚀 【關鍵修正 3】免付訂金時,正確塞入傳過來的 payload.email,並融入客人的需求備註
218
  booking_data = {
219
- "name": payload.name, "tel": payload.tel, "date": payload.date, "time": payload.time,
220
- "pax": payload.pax,
221
- "email": payload.email,
222
- "user_id": payload.line_id,
 
 
 
223
  "status": "待處理",
224
- "remarks": f"類型: {'外帶' if payload.service_type == 'takeout' else '內用'}\n客人備註: {payload.remarks if payload.remarks else '無'}\n\n{payload.kitchen_remarks}"
225
- }
226
-
227
- try:
228
- supabase.table("bookings").insert(booking_data).execute()
229
- notify_boss(payload.name, payload.tel, payload.date, payload.time, payload.pax, 0)
230
- return { "status": "success", "message": "訂位已成功建立!" }
231
- except Exception as e: raise HTTPException(status_code=500, detail=f"寫入資料庫失敗: {str(e)}")
232
-
233
-
234
- # 確認收錢的端點 (Confirm API)
235
- @app.post("/api/linepay/confirm")
236
- async def confirm_payment(payload: ConfirmPayload):
237
- uri = f"/v3/payments/{payload.transaction_id}/confirm"
238
- nonce = str(uuid.uuid4())
239
- body_str = json.dumps({"amount": payload.amount, "currency": "TWD"})
240
- message = LINE_PAY_CHANNEL_SECRET + uri + body_str + nonce
241
- signature = base64.b64encode(hmac.new(LINE_PAY_CHANNEL_SECRET.encode(), message.encode(), hashlib.sha256).digest()).decode()
242
-
243
- headers = {
244
- "Content-Type": "application/json", "X-LINE-ChannelId": LINE_PAY_CHANNEL_ID,
245
- "X-LINE-Authorization-Nonce": nonce, "X-LINE-Authorization": signature
246
  }
247
 
248
  try:
249
- res = requests.post(f"{LINE_PAY_BASE_URL}{uri}", headers=headers, data=body_str)
250
- res_data = res.json()
251
-
252
- if res_data.get("returnCode") == "0000":
253
- update_res = supabase.table("bookings").update({"status": "待處理 (已付訂金)"}).ilike("remarks", f"%{payload.order_id}%").execute()
254
- if update_res.data:
255
- b = update_res.data[0]
256
- notify_boss(b['name'], b['tel'], b['date'], b['time'], b['pax'], payload.amount)
257
-
258
- return {"status": "success", "message": "付款確認成功"}
259
- else:
260
- raise HTTPException(status_code=400, detail=res_data.get('returnMessage'))
261
- except Exception as e:
262
- raise HTTPException(status_code=500, detail=str(e))
263
-
264
- # 處理重新產生付款連結的 API (防呆升級版)
265
- @app.post("/api/linepay/repay")
266
- async def repay_payment(payload: RepayPayload):
267
- if not supabase: raise HTTPException(status_code=500, detail="資料庫未連線")
268
-
269
- try:
270
- res = supabase.table("bookings").select("*").ilike("remarks", f"%{payload.order_id}%").execute()
271
- if not res.data: raise HTTPException(status_code=404, detail="找不到該筆訂單")
272
-
273
- booking = res.data[0]
274
- if "已付" in booking.get("status", "") or "確認" in booking.get("status", ""):
275
- raise HTTPException(status_code=400, detail="此訂單已完成付款或確認,無需重新結帳")
276
-
277
- amount = 1000
278
- try:
279
- chk_uri = "/v3/payments"
280
- chk_query = urllib.parse.urlencode({"orderId": payload.order_id})
281
- chk_nonce = str(uuid.uuid4())
282
- chk_msg = LINE_PAY_CHANNEL_SECRET + chk_uri + chk_query + chk_nonce
283
- chk_sig = base64.b64encode(hmac.new(LINE_PAY_CHANNEL_SECRET.encode(), chk_msg.encode(), hashlib.sha256).digest()).decode()
284
- chk_headers = { "Content-Type": "application/json", "X-LINE-ChannelId": LINE_PAY_CHANNEL_ID, "X-LINE-Authorization-Nonce": chk_nonce, "X-LINE-Authorization": chk_sig }
285
- chk_res = requests.get(f"{LINE_PAY_BASE_URL}{chk_uri}?{chk_query}", headers=chk_headers).json()
286
- if chk_res.get("returnCode") == "0000" and chk_res.get("info"):
287
- amount = chk_res["info"][0].get("payInfo", [{}])[0].get("amount", 1000)
288
- except Exception as e:
289
- print(f"無法取得原始金額,使用預設值: {e}")
290
-
291
- new_order_id = f"{payload.order_id}-R{int(time.time())}"
292
-
293
- request_body = {
294
- "amount": amount,
295
- "currency": "TWD",
296
- "orderId": new_order_id,
297
- "packages": [{
298
- "id": "pkg_repay", "amount": amount, "name": "Cié Cié Taipei 補繳結帳",
299
- "products": [{"name": "餐飲訂金或外帶全額", "quantity": 1, "price": amount}]
300
- }],
301
- "redirectUrls": {
302
- "confirmUrl": f"{RETURN_URL}?action=payment_confirm&amount={amount}&orderId={payload.order_id}",
303
- "cancelUrl": f"{RETURN_URL}?action=payment_cancel"
304
- }
305
- }
306
-
307
- uri = "/v3/payments/request"
308
- nonce = str(uuid.uuid4())
309
- body_str = json.dumps(request_body)
310
- message = LINE_PAY_CHANNEL_SECRET + uri + body_str + nonce
311
- signature = base64.b64encode(hmac.new(LINE_PAY_CHANNEL_SECRET.encode(), message.encode(), hashlib.sha256).digest()).decode()
312
-
313
- headers = {
314
- "Content-Type": "application/json", "X-LINE-ChannelId": LINE_PAY_CHANNEL_ID,
315
- "X-LINE-Authorization-Nonce": nonce, "X-LINE-Authorization": signature
316
- }
317
-
318
- line_pay_res = requests.post(f"{LINE_PAY_BASE_URL}{uri}", headers=headers, data=body_str)
319
- res_data = line_pay_res.json()
320
-
321
- if res_data.get("returnCode") == "0000":
322
- return {"payment_url": res_data["info"]["paymentUrl"]["web"]}
323
- else:
324
- raise HTTPException(status_code=500, detail=f"LINE Pay 錯誤: {res_data.get('returnMessage')}")
325
-
326
- except Exception as e:
327
- raise HTTPException(status_code=500, detail=str(e))
328
-
329
- def notify_boss(name, tel, date, time, pax, amount):
330
- if not LINE_ACCESS_TOKEN or not BOSS_LINE_ID:
331
- print(f"⚠️ notify_boss 跳過:LINE_ACCESS_TOKEN={'有' if LINE_ACCESS_TOKEN else '無'}, BOSS_LINE_ID={'有' if BOSS_LINE_ID else '無'}")
332
- return
333
- msg = f"🔔 【新訂單通知】\n姓名:{name}\n電話:{tel}\n時間:{date} {time}\n人數:{pax}位"
334
- if amount > 0: msg += f"\n💰 已收到線上付款:${amount}"
335
-
336
- headers = {"Authorization": f"Bearer {LINE_ACCESS_TOKEN}"}
337
- payload = {"to": BOSS_LINE_ID, "messages": [{"type": "text", "text": msg}]}
338
- try:
339
- r = requests.post("https://api.line.me/v2/bot/message/push", headers=headers, json=payload)
340
- print(f"✅ notify_boss LINE 回應:{r.status_code} {r.text}")
341
- except Exception as e:
342
- print(f"❌ notify_boss LINE 發送失敗:{e}")
343
-
344
- # 🌟 新增:郵件發送 API (供 ADMIN 呼叫)
345
- class EmailPayload(BaseModel):
346
- to: str
347
- subject: str
348
- htmlBody: str
349
- name: Optional[str] = "Ci�� Cié Taipei"
350
-
351
- @app.post("/api/send_email")
352
- async def send_email(payload: EmailPayload):
353
- if not RESEND_API_KEY:
354
- raise HTTPException(status_code=500, detail="郵件服務未設定 (缺少 RESEND_API_KEY)")
355
- try:
356
- response = requests.post(
357
- "https://api.resend.com/emails",
358
- headers={"Authorization": f"Bearer {RESEND_API_KEY}", "Content-Type": "application/json"},
359
- json={"from": f"{payload.name} <onboarding@resend.dev>", "to": [payload.to], "subject": payload.subject, "html": payload.htmlBody},
360
- timeout=15
361
  )
362
- if response.status_code in (200, 201):
363
- return {"status": "success", "message": f"郵件已成功發送至 {payload.to}"}
364
- else:
365
- raise HTTPException(status_code=500, detail=f"Resend 錯誤: {response.text}")
366
  except Exception as e:
367
- raise HTTPException(status_code=500, detail=f"郵件發送失敗: {str(e)}")
 
368
 
369
  @app.get("/api/booking/action")
370
  async def booking_action(action: str, id: str):
371
- """用戶點擊確認/取消連結後,更新 Supabase 回前端"""
372
  if not supabase:
373
  raise HTTPException(status_code=500, detail="資料庫未設定")
374
  if action not in ("confirm", "cancel"):
375
  raise HTTPException(status_code=400, detail="無效的動作")
376
 
377
  status_map = {"confirm": "顧客已確認", "cancel": "顧客已取消"}
 
 
 
378
  try:
379
- supabase.table("bookings").update({"status": status_map[action]}).eq("id", id).execute()
 
 
 
 
 
 
 
380
  except Exception as e:
381
  raise HTTPException(status_code=500, detail=str(e))
382
 
383
  return RedirectResponse(url=f"{RETURN_URL}?action={action}", status_code=302)
384
-
385
- @app.get("/api/inventory/{query_date}")
386
- async def get_inventory(query_date: str):
387
- if not supabase: return {}
388
- try:
389
- res = supabase.table("bookings").select("cart, status").eq("date", query_date).execute()
390
- sold_counts = {}
391
- if res.data:
392
- for b in res.data:
393
- if "取消" in b.get("status", "") or "No-Show" in b.get("status", ""):
394
- continue
395
-
396
- cart = b.get("cart")
397
- if not cart:
398
- cart = {}
399
- elif isinstance(cart, str):
400
- try: cart = json.loads(cart)
401
- except: cart = {}
402
-
403
- for item_id, qty in cart.items():
404
- try: qty = int(qty)
405
- except: qty = 0
406
- sold_counts[item_id] = sold_counts.get(item_id, 0) + qty
407
-
408
- return sold_counts
409
- except Exception as e:
410
- print(f"Inventory Error: {e}")
411
- return {}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, HTTPException
2
  from fastapi.responses import RedirectResponse
3
  from fastapi.middleware.cors import CORSMiddleware
4
  from pydantic import BaseModel
5
+ from typing import Optional
6
  from supabase import create_client, Client
7
  import os
8
  import requests
9
+
10
+ # 以下 import 供 LINE Pay / 自動救援功能使用,停用中
11
+ # import uuid
12
+ # import json
13
+ # import base64
14
+ # import hashlib
15
+ # import hmac
16
+ # import time
17
+ # import asyncio
18
+ # import urllib.parse
19
 
20
  app = FastAPI(title="Cié Cié Backend API")
21
 
 
22
  app.add_middleware(
23
  CORSMiddleware,
24
+ allow_origins=["*"],
25
  allow_credentials=True,
26
  allow_methods=["*"],
27
  allow_headers=["*"],
28
  )
29
 
30
+ # 環境變數
31
  SUPABASE_URL = os.getenv("SUPABASE_URL", "")
32
+ SUPABASE_KEY = os.getenv("SUPABASE_KEY", "")
33
  LINE_ACCESS_TOKEN = os.getenv("LINE_ACCESS_TOKEN", "")
34
  BOSS_LINE_ID = os.getenv("BOSS_LINE_ID", "")
35
+ BOSS_EMAIL = os.getenv("BOSS_EMAIL", "") # 新增:老闆收信 Email
36
+ GAS_MAIL_URL = os.getenv("GAS_MAIL_URL", "") # 新增:GAS 發信 Relay URL
37
 
38
+ # LINE Pay 金鑰(停用中)
39
+ # LINE_PAY_CHANNEL_ID = os.getenv("LINE_PAY_CHANNEL_ID", "")
40
+ # LINE_PAY_CHANNEL_SECRET = os.getenv("LINE_PAY_CHANNEL_SECRET", "")
41
+ # LINE_PAY_BASE_URL = "https://sandbox-api-pay.line.me"
42
+ # RESEND_API_KEY = os.getenv("RESEND_API_KEY", "")
43
 
44
+ RETURN_URL = "https://ciecietaipei.github.io/booking.html"
 
45
 
46
  supabase: Client = create_client(SUPABASE_URL, SUPABASE_KEY) if SUPABASE_URL else None
47
 
48
+
49
+ # ==========================================
50
+ # 資料結構
51
+ # ==========================================
52
+
53
  class OrderPayload(BaseModel):
54
  service_type: str
55
  name: str
 
58
  time: str
59
  line_id: Optional[str] = ""
60
  pax: int = 2
 
 
 
 
 
61
  email: Optional[str] = ""
62
  remarks: Optional[str] = ""
63
+ # 以下欄位供 LINE Pay 流程使用,停用中
64
+ # cart: Dict[str, int] = {}
65
+ # deposit_required: float = 0
66
+ # total_amount: float = 0
67
+ # kitchen_remarks: Optional[str] = ""
68
+
69
+ # LINE Pay 付款確認 payload(停用中)
70
+ # class ConfirmPayload(BaseModel):
71
+ # transaction_id: str
72
+ # order_id: str
73
+ # amount: int
74
+
75
+ # LINE Pay 補繳 payload(停用中)
76
+ # class RepayPayload(BaseModel):
77
+ # order_id: str
78
 
 
 
79
 
80
  # ==========================================
81
+ # 背景自動救援掉單(LINE Pay 用,停用中)
82
  # ==========================================
83
+ #
84
+ # async def auto_rescue_dropped_order(order_id: str, amount: int):
85
+ # """當客人付完款但提早關閉網頁導致沒跳轉回來時,背景自動 3 分鐘後查帳並補確認"""
86
+ # await asyncio.sleep(180)
87
+ # if not supabase: return
88
+ # try:
89
+ # res = supabase.table("bookings").select("*").ilike("remarks", f"%{order_id}%").execute()
90
+ # if not res.data: return
91
+ # booking = res.data[0]
92
+ # if "已付" in booking.get("status", "") or "確認" in booking.get("status", ""):
93
+ # return
94
+ #
95
+ # uri = "/v3/payments"
96
+ # query_string = urllib.parse.urlencode({"orderId": order_id})
97
+ # nonce = str(uuid.uuid4())
98
+ # message = LINE_PAY_CHANNEL_SECRET + uri + query_string + nonce
99
+ # signature = base64.b64encode(hmac.new(LINE_PAY_CHANNEL_SECRET.encode(), message.encode(), hashlib.sha256).digest()).decode()
100
+ # headers = {
101
+ # "Content-Type": "application/json", "X-LINE-ChannelId": LINE_PAY_CHANNEL_ID,
102
+ # "X-LINE-Authorization-Nonce": nonce, "X-LINE-Authorization": signature
103
+ # }
104
+ # r = requests.get(f"{LINE_PAY_BASE_URL}{uri}?{query_string}", headers=headers)
105
+ # res_data = r.json()
106
+ #
107
+ # if res_data.get("returnCode") == "0000" and res_data.get("info"):
108
+ # tx = res_data["info"][0]
109
+ # if tx.get("transactionType") == "AUTHORIZATION":
110
+ # transaction_id = tx.get("transactionId")
111
+ # confirm_uri = f"/v3/payments/{transaction_id}/confirm"
112
+ # confirm_nonce = str(uuid.uuid4())
113
+ # confirm_body = json.dumps({"amount": amount, "currency": "TWD"})
114
+ # confirm_msg = LINE_PAY_CHANNEL_SECRET + confirm_uri + confirm_body + confirm_nonce
115
+ # confirm_sig = base64.b64encode(hmac.new(LINE_PAY_CHANNEL_SECRET.encode(), confirm_msg.encode(), hashlib.sha256).digest()).decode()
116
+ # confirm_headers = {
117
+ # "Content-Type": "application/json", "X-LINE-ChannelId": LINE_PAY_CHANNEL_ID,
118
+ # "X-LINE-Authorization-Nonce": confirm_nonce, "X-LINE-Authorization": confirm_sig
119
+ # }
120
+ # confirm_res = requests.post(f"{LINE_PAY_BASE_URL}{confirm_uri}", headers=confirm_headers, data=confirm_body).json()
121
+ # if confirm_res.get("returnCode") == "0000":
122
+ # supabase.table("bookings").update({"status": "待處理 (已付訂金)"}).eq("id", booking['id']).execute()
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}"
125
+ # requests.post("https://api.line.me/v2/bot/message/push",
126
+ # headers={"Authorization": f"Bearer {LINE_ACCESS_TOKEN}"},
127
+ # json={"to": BOSS_LINE_ID, "messages": [{"type": "text", "text": msg}]})
128
+ # except Exception as e:
129
+ # print(f"Auto rescue failed: {e}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
130
 
131
 
132
+ # ==========================================
133
+ # API 端點
134
+ # ==========================================
135
 
136
  @app.get("/")
137
  def read_root():
138
  return {"status": "online", "message": "Cié Cié FastAPI is running."}
139
 
140
+
141
  @app.post("/api/submit_booking")
142
+ async def submit_booking(payload: OrderPayload):
143
  if not supabase:
144
  raise HTTPException(status_code=500, detail="資料庫未設定")
145
 
146
+ # ── LINE Pay 訂金流程(停用中)──────────────────────────────
147
+ # 若未來要重新啟用,需同時取消 ConfirmPayload / RepayPayload /
148
+ # auto_rescue_dropped_order / /api/linepay/confirm 等的 comment
149
+ #
150
+ # is_noshow = False
151
+ # try:
152
+ # res = supabase.table("bookings").select("id").eq("tel", payload.tel).eq("status", "No-Show").execute()
153
+ # is_noshow = len(res.data) > 0
154
+ # except: pass
155
+ #
156
+ # final_deposit = payload.deposit_required
157
+ # if is_noshow and final_deposit == 0:
158
+ # final_deposit = 1000
159
+ #
160
+ # if final_deposit > 0:
161
+ # order_id = f"ORDER-{uuid.uuid4().hex[:8].upper()}"
162
+ # request_body = {
163
+ # "amount": final_deposit, "currency": "TWD", "orderId": order_id,
164
+ # "packages": [{"id": "pkg_1", "amount": final_deposit, "name": "Cié Cié Taipei 預付金",
165
+ # "products": [{"name": "餐飲訂金與預付金", "quantity": 1, "price": final_deposit}]}],
166
+ # "redirectUrls": {
167
+ # "confirmUrl": f"{RETURN_URL}?action=payment_confirm&amount={final_deposit}&orderId={order_id}",
168
+ # "cancelUrl": f"{RETURN_URL}?action=payment_cancel"
169
+ # }
170
+ # }
171
+ # uri = "/v3/payments/request"
172
+ # nonce = str(uuid.uuid4())
173
+ # body_str = json.dumps(request_body)
174
+ # message = LINE_PAY_CHANNEL_SECRET + uri + body_str + nonce
175
+ # signature = base64.b64encode(hmac.new(LINE_PAY_CHANNEL_SECRET.encode(), message.encode(), hashlib.sha256).digest()).decode()
176
+ # headers = {
177
+ # "Content-Type": "application/json", "X-LINE-ChannelId": LINE_PAY_CHANNEL_ID,
178
+ # "X-LINE-Authorization-Nonce": nonce, "X-LINE-Authorization": signature
179
+ # }
180
+ # try:
181
+ # line_pay_res = requests.post(f"{LINE_PAY_BASE_URL}{uri}", headers=headers, data=body_str)
182
+ # res_data = line_pay_res.json()
183
+ # if res_data.get("returnCode") == "0000":
184
+ # payment_url = res_data["info"]["paymentUrl"]["web"]
185
+ # booking_data = {
186
+ # "name": payload.name, "tel": payload.tel, "date": payload.date,
187
+ # "time": payload.time, "pax": payload.pax, "user_id": payload.line_id,
188
+ # "email": payload.email, "status": "待付款",
189
+ # "remarks": f"類型: {'外帶' if payload.service_type == 'takeout' else '內用'}\n訂單號: {order_id}\n客人備註: {payload.remarks or '無'}\n\n{payload.kitchen_remarks}"
190
+ # }
191
+ # supabase.table("bookings").insert(booking_data).execute()
192
+ # background_tasks.add_task(auto_rescue_dropped_order, order_id, final_deposit)
193
+ # return {
194
+ # "status": "require_payment", "message": "訂單需支付訂金",
195
+ # "is_noshow_penalty": is_noshow, "deposit_amount": final_deposit,
196
+ # "payment_url": payment_url, "order_id": order_id
197
+ # }
198
+ # else:
199
+ # raise HTTPException(status_code=500, detail=f"LINE Pay 錯誤: {res_data.get('returnMessage')}")
200
+ # except Exception as e:
201
+ # raise HTTPException(status_code=500, detail=f"金流連線失敗: {str(e)}")
202
+ # ── LINE Pay 流程結束 ────────────────────────────────────────
203
+
 
 
 
 
 
 
 
 
 
 
 
204
  booking_data = {
205
+ "name": payload.name,
206
+ "tel": payload.tel,
207
+ "date": payload.date,
208
+ "time": payload.time,
209
+ "pax": payload.pax,
210
+ "email": payload.email,
211
+ "user_id": payload.line_id,
212
  "status": "待處理",
213
+ "remarks": f"類型: {'外帶' if payload.service_type == 'takeout' else '內用'}\n客人備註: {payload.remarks or '無'}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  }
215
 
216
  try:
217
+ supabase.table("bookings").insert(booking_data).execute()
218
+ notify_boss(
219
+ payload.name, payload.tel, payload.date, payload.time,
220
+ payload.pax, payload.email or "", "新訂位"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
221
  )
222
+ return {"status": "success", "message": "訂位已成功建立!"}
 
 
 
223
  except Exception as e:
224
+ raise HTTPException(status_code=500, detail=f"寫入資料庫失敗: {str(e)}")
225
+
226
 
227
  @app.get("/api/booking/action")
228
  async def booking_action(action: str, id: str):
229
+ """客人點擊確認/取消連結更新 Supabase → 通知老闆 → 導回前端"""
230
  if not supabase:
231
  raise HTTPException(status_code=500, detail="資料庫未設定")
232
  if action not in ("confirm", "cancel"):
233
  raise HTTPException(status_code=400, detail="無效的動作")
234
 
235
  status_map = {"confirm": "顧客已確認", "cancel": "顧客已取消"}
236
+ new_status = status_map[action]
237
+ event = "顧客已確認" if action == "confirm" else "顧客已取消"
238
+
239
  try:
240
+ res = supabase.table("bookings").update({"status": new_status}).eq("id", id).execute()
241
+ # 原版此處無通知老闆邏輯,現已新增:
242
+ if res.data:
243
+ b = res.data[0]
244
+ notify_boss(
245
+ b["name"], b["tel"], b["date"], b["time"],
246
+ b["pax"], b.get("email", ""), event
247
+ )
248
  except Exception as e:
249
  raise HTTPException(status_code=500, detail=str(e))
250
 
251
  return RedirectResponse(url=f"{RETURN_URL}?action={action}", status_code=302)
252
+
253
+
254
+ # LINE Pay 付款確認端點(停用中)
255
+ # @app.post("/api/linepay/confirm")
256
+ # async def confirm_payment(payload: ConfirmPayload):
257
+ # uri = f"/v3/payments/{payload.transaction_id}/confirm"
258
+ # nonce = str(uuid.uuid4())
259
+ # body_str = json.dumps({"amount": payload.amount, "currency": "TWD"})
260
+ # message = LINE_PAY_CHANNEL_SECRET + uri + body_str + nonce
261
+ # signature = base64.b64encode(hmac.new(LINE_PAY_CHANNEL_SECRET.encode(), message.encode(), hashlib.sha256).digest()).decode()
262
+ # headers = {
263
+ # "Content-Type": "application/json", "X-LINE-ChannelId": LINE_PAY_CHANNEL_ID,
264
+ # "X-LINE-Authorization-Nonce": nonce, "X-LINE-Authorization": signature
265
+ # }
266
+ # try:
267
+ # res = requests.post(f"{LINE_PAY_BASE_URL}{uri}", headers=headers, data=body_str)
268
+ # res_data = res.json()
269
+ # if res_data.get("returnCode") == "0000":
270
+ # update_res = supabase.table("bookings").update({"status": "待處理 (已付訂金)"}).ilike("remarks", f"%{payload.order_id}%").execute()
271
+ # if update_res.data:
272
+ # b = update_res.data[0]
273
+ # notify_boss(b['name'], b['tel'], b['date'], b['time'], b['pax'], b.get('email',''), "已付訂金")
274
+ # return {"status": "success", "message": "付款確認成功"}
275
+ # else:
276
+ # raise HTTPException(status_code=400, detail=res_data.get('returnMessage'))
277
+ # except Exception as e:
278
+ # raise HTTPException(status_code=500, detail=str(e))
279
+
280
+
281
+ # LINE Pay 補繳連結產生端點(停用中)
282
+ # @app.post("/api/linepay/repay")
283
+ # async def repay_payment(payload: RepayPayload):
284
+ # if not supabase: raise HTTPException(status_code=500, detail="資料庫未連線")
285
+ # try:
286
+ # res = supabase.table("bookings").select("*").ilike("remarks", f"%{payload.order_id}%").execute()
287
+ # if not res.data: raise HTTPException(status_code=404, detail="找不到該筆訂單")
288
+ # booking = res.data[0]
289
+ # if "已付" in booking.get("status", "") or "確認" in booking.get("status", ""):
290
+ # raise HTTPException(status_code=400, detail="此訂單已完成付款或確認,無需重新結帳")
291
+ # amount = 1000
292
+ # try:
293
+ # chk_uri = "/v3/payments"
294
+ # chk_query = urllib.parse.urlencode({"orderId": payload.order_id})
295
+ # chk_nonce = str(uuid.uuid4())
296
+ # chk_msg = LINE_PAY_CHANNEL_SECRET + chk_uri + chk_query + chk_nonce
297
+ # chk_sig = base64.b64encode(hmac.new(LINE_PAY_CHANNEL_SECRET.encode(), chk_msg.encode(), hashlib.sha256).digest()).decode()
298
+ # chk_headers = {"Content-Type": "application/json", "X-LINE-ChannelId": LINE_PAY_CHANNEL_ID, "X-LINE-Authorization-Nonce": chk_nonce, "X-LINE-Authorization": chk_sig}
299
+ # chk_res = requests.get(f"{LINE_PAY_BASE_URL}{chk_uri}?{chk_query}", headers=chk_headers).json()
300
+ # if chk_res.get("returnCode") == "0000" and chk_res.get("info"):
301
+ # amount = chk_res["info"][0].get("payInfo", [{}])[0].get("amount", 1000)
302
+ # except Exception as e:
303
+ # print(f"無法取得原始金額,使用預設值: {e}")
304
+ # new_order_id = f"{payload.order_id}-R{int(time.time())}"
305
+ # request_body = {
306
+ # "amount": amount, "currency": "TWD", "orderId": new_order_id,
307
+ # "packages": [{"id": "pkg_repay", "amount": amount, "name": "Cié Cié Taipei 補繳結帳",
308
+ # "products": [{"name": "餐飲訂金或外帶全額", "quantity": 1, "price": amount}]}],
309
+ # "redirectUrls": {
310
+ # "confirmUrl": f"{RETURN_URL}?action=payment_confirm&amount={amount}&orderId={payload.order_id}",
311
+ # "cancelUrl": f"{RETURN_URL}?action=payment_cancel"
312
+ # }
313
+ # }
314
+ # uri = "/v3/payments/request"
315
+ # nonce = str(uuid.uuid4())
316
+ # body_str = json.dumps(request_body)
317
+ # message = LINE_PAY_CHANNEL_SECRET + uri + body_str + nonce
318
+ # signature = base64.b64encode(hmac.new(LINE_PAY_CHANNEL_SECRET.encode(), message.encode(), hashlib.sha256).digest()).decode()
319
+ # headers = {
320
+ # "Content-Type": "application/json", "X-LINE-ChannelId": LINE_PAY_CHANNEL_ID,
321
+ # "X-LINE-Authorization-Nonce": nonce, "X-LINE-Authorization": signature
322
+ # }
323
+ # line_pay_res = requests.post(f"{LINE_PAY_BASE_URL}{uri}", headers=headers, data=body_str)
324
+ # res_data = line_pay_res.json()
325
+ # if res_data.get("returnCode") == "0000":
326
+ # return {"payment_url": res_data["info"]["paymentUrl"]["web"]}
327
+ # else:
328
+ # raise HTTPException(status_code=500, detail=f"LINE Pay 錯誤: {res_data.get('returnMessage')}")
329
+ # except Exception as e:
330
+ # raise HTTPException(status_code=500, detail=str(e))
331
+
332
+
333
+ # ==========================================
334
+ # 通知老闆:GAS Email 為主(必送),LINE 為輔(失敗只 log)
335
+ #
336
+ # 原版 notify_boss 僅發 LINE,無 Email,signature 為:
337
+ # def notify_boss(name, tel, date, time, pax, amount):
338
+ # if amount > 0: msg += f"\n💰 已收到線上付款:${amount}"
339
+ # ...只推 LINE,無 GAS Email
340
+ # ==========================================
341
+ def notify_boss(name, tel, date, time, pax, email="", event="新訂位"):
342
+ # 1. GAS Email(主要,必送)
343
+ if BOSS_EMAIL and GAS_MAIL_URL:
344
+ emoji = {"新訂位": "🔔", "顧客已確認": "✅", "顧客已取消": "🚫", "已付訂金": "💰"}.get(event, "📢")
345
+ subject = f"[{event}] {name} - {date} {time}"
346
+ html = f"""<div style="font-family:sans-serif; background:#1a1a1a; color:#eee; padding:24px; border-radius:8px;">
347
+ <h2 style="color:#d4af37; border-bottom:1px solid #444; padding-bottom:12px;">{emoji} Cié Cié Taipei — {event}</h2>
348
+ <table style="width:100%; border-collapse:collapse; margin-top:12px;">
349
+ <tr><td style="color:#aaa; padding:6px 0; width:80px;">姓名</td><td style="color:#fff; font-weight:bold;">{name}</td></tr>
350
+ <tr><td style="color:#aaa; padding:6px 0;">電話</td><td style="color:#fff;">{tel}</td></tr>
351
+ <tr><td style="color:#aaa; padding:6px 0;">日期</td><td style="color:#d4af37; font-weight:bold;">{date}</td></tr>
352
+ <tr><td style="color:#aaa; padding:6px 0;">時間</td><td style="color:#d4af37; font-weight:bold;">{time}</td></tr>
353
+ <tr><td style="color:#aaa; padding:6px 0;">人數</td><td style="color:#fff;">{pax} 位</td></tr>
354
+ <tr><td style="color:#aaa; padding:6px 0;">Email</td><td style="color:#fff;">{email or '-'}</td></tr>
355
+ </table>
356
+ </div>"""
357
+ try:
358
+ r = requests.post(
359
+ GAS_MAIL_URL,
360
+ json={"to": BOSS_EMAIL, "subject": subject, "htmlBody": html, "name": "Cié Cié Taipei"},
361
+ timeout=15
362
+ )
363
+ print(f"✅ notify_boss GAS Email 回應:{r.status_code}")
364
+ except Exception as e:
365
+ print(f"❌ notify_boss GAS Email 失敗:{e}")
366
+ else:
367
+ print(f"⚠️ notify_boss GAS Email 跳過:BOSS_EMAIL={'有' if BOSS_EMAIL else '無'}, GAS_MAIL_URL={'有' if GAS_MAIL_URL else '無'}")
368
+
369
+ # 2. LINE(輔助,失敗只 log)
370
+ if LINE_ACCESS_TOKEN and BOSS_LINE_ID:
371
+ emoji = {"新訂位": "🔔", "顧客已確認": "✅", "顧客已取消": "🚫", "已付訂金": "💰"}.get(event, "📢")
372
+ msg = f"{emoji} 【{event}】\n姓名:{name}\n電話:{tel}\n時間:{date} {time}\n人數:{pax} 位"
373
+ if email:
374
+ msg += f"\nEmail:{email}"
375
+ try:
376
+ r = requests.post(
377
+ "https://api.line.me/v2/bot/message/push",
378
+ headers={"Authorization": f"Bearer {LINE_ACCESS_TOKEN}"},
379
+ json={"to": BOSS_LINE_ID, "messages": [{"type": "text", "text": msg}]},
380
+ timeout=10
381
+ )
382
+ print(f"✅ notify_boss LINE 回應:{r.status_code}")
383
+ except Exception as e:
384
+ print(f"❌ notify_boss LINE 失敗:{e}")
385
+ else:
386
+ print(f"⚠️ notify_boss LINE 跳過:TOKEN={'有' if LINE_ACCESS_TOKEN else '無'}, BOSS_ID={'有' if BOSS_LINE_ID else '無'}")
387
+
388
+
389
+ # Resend Email 端點(停用中 — Resend 無法使用,已改由 app.py 直接呼叫 GAS)
390
+ # class EmailPayload(BaseModel):
391
+ # to: str
392
+ # subject: str
393
+ # htmlBody: str
394
+ # name: Optional[str] = "Cié Cié Taipei"
395
+ #
396
+ # @app.post("/api/send_email")
397
+ # async def send_email(payload: EmailPayload):
398
+ # if not RESEND_API_KEY:
399
+ # raise HTTPException(status_code=500, detail="郵件服務未設定 (缺少 RESEND_API_KEY)")
400
+ # try:
401
+ # response = requests.post(
402
+ # "https://api.resend.com/emails",
403
+ # headers={"Authorization": f"Bearer {RESEND_API_KEY}", "Content-Type": "application/json"},
404
+ # json={"from": f"{payload.name} <onboarding@resend.dev>", "to": [payload.to], "subject": payload.subject, "html": payload.htmlBody},
405
+ # timeout=15
406
+ # )
407
+ # if response.status_code in (200, 201):
408
+ # return {"status": "success", "message": f"郵件已成功發送至 {payload.to}"}
409
+ # else:
410
+ # raise HTTPException(status_code=500, detail=f"Resend 錯誤: {response.text}")
411
+ # except Exception as e:
412
+ # raise HTTPException(status_code=500, detail=f"郵件發送失敗: {str(e)}")
413
+
414
+
415
+ # 庫存查詢端點(停用中 — 預點餐功能關閉,view3 未啟用)
416
+ # @app.get("/api/inventory/{query_date}")
417
+ # async def get_inventory(query_date: str):
418
+ # if not supabase: return {}
419
+ # try:
420
+ # res = supabase.table("bookings").select("cart, status").eq("date", query_date).execute()
421
+ # sold_counts = {}
422
+ # if res.data:
423
+ # for b in res.data:
424
+ # if "取消" in b.get("status", "") or "No-Show" in b.get("status", ""):
425
+ # continue
426
+ # cart = b.get("cart")
427
+ # if not cart: cart = {}
428
+ # elif isinstance(cart, str):
429
+ # try: cart = json.loads(cart)
430
+ # except: cart = {}
431
+ # for item_id, qty in cart.items():
432
+ # try: qty = int(qty)
433
+ # except: qty = 0
434
+ # sold_counts[item_id] = sold_counts.get(item_id, 0) + qty
435
+ # return sold_counts
436
+ # except Exception as e:
437
+ # print(f"Inventory Error: {e}")
438
+ # return {}