from datetime import datetime from decimal import Decimal, InvalidOperation from functools import wraps import hashlib import hmac import logging import secrets from typing import Any, Callable from flask import ( Flask, abort, flash, jsonify, redirect, render_template, request, session, url_for, ) from pymysql import MySQLError from werkzeug.exceptions import HTTPException from chatwoot_guest import ChatwootGuestError, create_guest_conversation from chatwoot_proxy import proxy_chatwoot from config import CONFIG from maxkb_chatwoot_bridge import handle_chatwoot_webhook from repository import MappingError, PlatformRepository, decimal_string import threading app = Flask(__name__) app.config.update( JSON_AS_ASCII=False, SECRET_KEY=CONFIG.secret_key, SESSION_COOKIE_NAME="api_interface_session", SESSION_COOKIE_HTTPONLY=True, SESSION_COOKIE_SAMESITE="Lax", SESSION_COOKIE_SECURE=CONFIG.session_cookie_secure, PERMANENT_SESSION_LIFETIME=28800, MAX_CONTENT_LENGTH=1024 * 1024, ) logging.basicConfig( level=logging.DEBUG if CONFIG.debug else logging.INFO, format="%(asctime)s %(levelname)s %(name)s %(message)s", ) repo = PlatformRepository() def payload() -> dict[str, Any]: data: dict[str, Any] = {} body = request.get_json(silent=True) if isinstance(body, dict): data.update(body) data.update(request.form.to_dict()) data.update(request.args.to_dict()) return data def api_auth(view: Callable): @wraps(view) def wrapped(*args, **kwargs): if CONFIG.api_key: provided = request.headers.get("X-API-Key", "") if not hmac.compare_digest(provided, CONFIG.api_key): return jsonify( {"error": "unauthorized", "message": "API Key 無效"} ), 401 return view(*args, **kwargs) return wrapped def login_required(view: Callable): @wraps(view) def wrapped(*args, **kwargs): if not session.get("username"): return redirect(url_for("login")) return view(*args, **kwargs) return wrapped def csrf_token() -> str: token = session.get("csrf_token") if not token: token = secrets.token_urlsafe(32) session["csrf_token"] = token return str(token) def verify_csrf() -> None: received = str(request.form.get("csrf_token", "")) saved = str(session.get("csrf_token", "")) if not received or not saved or not hmac.compare_digest(received, saved): abort(403, description="CSRF 验证失败") def required_text( data: dict[str, Any], name: str, max_length: int = 191, ) -> str: value = str(data.get(name, "")).strip() if not value: raise ValueError(f"{name} 為必填") if len(value) > max_length: raise ValueError(f"{name} 長度不可超過 {max_length}") return value def is_api_request() -> bool: return request.path == "/health" or request.path.startswith("/platform/") def update_member_session(account: dict[str, Any]) -> None: """將目前會員資料同步到 Flask Session。""" session["username"] = account.get("username_clean") or account.get("username") or "" session["username_clean"] = account.get("username_clean") or "" session["raw_username"] = account.get("raw_username") or "" session["uid"] = account.get("uid") session["account_status"] = account.get("status") or "normal" session["locked_reason"] = account.get("locked_reason") or "" session["member_type"] = account.get("member_type") or "" session["vip_level"] = account.get("vip_level") or "" session["tier"] = account.get("tier") or "" def build_chatwoot_user() -> dict[str, Any]: """建立傳給 Chatwoot 前端 SDK 的安全會員資料。 identifier 直接使用 username_clean,作為 Chatwoot 抓取會員名的關鍵標識。 顯示名稱與自訂屬性同樣使用 username_clean。 """ username = str( session.get("username_clean") or session.get("username") or "" ).strip() uid = session.get("uid") # Chatwoot 的 identifier 直接使用登入會員名。 # 例如:yongle001,而不是 member-311。 identifier = username if username else "" identifier_hash = "" if identifier and CONFIG.chatwoot_identity_secret: identifier_hash = hmac.new( CONFIG.chatwoot_identity_secret.encode("utf-8"), identifier.encode("utf-8"), hashlib.sha256, ).hexdigest() return { "is_authenticated": bool(identifier), "visitor_type": "member" if identifier else "guest", "identifier": identifier, "identifier_hash": identifier_hash, "name": username, "username": username, "uid": uid, "account_status": str( session.get("account_status", "") ), "locked_reason": str( session.get("locked_reason", "") ), "member_type": str( session.get("member_type", "") ), "vip_level": str( session.get("vip_level", "") ), "tier": str( session.get("tier", "") ), } @app.context_processor def inject_template_values(): use_guest_widget = request.path.startswith("/guest-customer-service") guest_token = CONFIG.chatwoot_guest_website_token if use_guest_widget and guest_token: # 依访客实际访问的 host(IP 或 test.pubuli.com)加载 /cw,避免写死域名 base_url = f"{request.scheme}://{request.host}/cw" else: base_url = CONFIG.chatwoot_base_url website_token = ( guest_token if use_guest_widget and guest_token else CONFIG.chatwoot_website_token ) chatwoot_config = { "enabled": bool( CONFIG.chatwoot_enabled and base_url and website_token ), "base_url": base_url, "website_token": website_token, "debug": CONFIG.chatwoot_debug, "csrf_token": csrf_token(), "guest_new_path": "/guest-chat/new", } return { "app_name": CONFIG.app_name, "csrf_token": csrf_token, "chatwoot_config": chatwoot_config, "chatwoot_user": build_chatwoot_user(), # 只在登出後首次渲染未登入頁面時為 True。 # 使用 pop() 確保該標記只消費一次。 "chatwoot_guest_reset_required": bool( session.pop( "chatwoot_guest_reset_required", False, ) ), } @app.get("/") def root(): if session.get("username"): return redirect(url_for("home")) return redirect(url_for("login")) @app.get("/guest-customer-service") def guest_customer_service(): """遊客客服頁:不登入、不查會員資料,只載入匿名 Chatwoot。""" return render_template( "guest_customer_service.html", is_guest=True, ) @app.route( "/cw/", defaults={"subpath": ""}, methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], ) @app.route( "/cw/", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], ) def chatwoot_guest_proxy(subpath: str): """把本機 Chatwoot widget 代理進來,才能注入略過「開始對話」的腳本。""" return proxy_chatwoot(subpath) @app.route( "/vite/", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], ) def chatwoot_vite_proxy(subpath: str): """widget HTML 用絕對路徑 /vite/assets,需一併代理。""" return proxy_chatwoot(f"vite/{subpath}") @app.route( "/api/v1/widget/", defaults={"subpath": ""}, methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], ) @app.route( "/api/v1/widget/", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], ) def chatwoot_widget_api_proxy(subpath: str): """widget 會打同源 /api/v1/widget。""" suffix = f"api/v1/widget/{subpath}" if subpath else "api/v1/widget" return proxy_chatwoot(suffix) @app.route( "/rails/", methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], ) def chatwoot_rails_proxy(subpath: str): """歡迎圖走 Active Storage,widget 會打同源 /rails/active_storage。""" return proxy_chatwoot(f"rails/{subpath}") @app.post("/guest-chat/new") def guest_chat_new(): """為遊客預先建立 Chatwoot 對話,前端 setUser 後可略過「開始對話」。""" received = str(request.headers.get("X-CSRF-Token", "")) saved = str(session.get("csrf_token", "")) if not received or not saved or not hmac.compare_digest(received, saved): return jsonify( {"error": "csrf_failed", "message": "CSRF 验证失败"} ), 403 try: created = create_guest_conversation() except ChatwootGuestError as exc: app.logger.exception("建立游客对话失败") return jsonify( {"error": "chatwoot_guest_failed", "message": str(exc)} ), 502 identifier = created["identifier"] identifier_hash = "" if identifier and CONFIG.chatwoot_identity_secret: identifier_hash = hmac.new( CONFIG.chatwoot_identity_secret.encode("utf-8"), identifier.encode("utf-8"), hashlib.sha256, ).hexdigest() return jsonify( { "identifier": identifier, "identifier_hash": identifier_hash, "name": created["name"], "source_id": created.get("source_id") or "", "widget_token": created.get("widget_token") or "", } ) @app.post("/webhook/bot1/chatwoot") def chatwoot_maxkb_webhook(): """本機 Chatwoot Agent Bot → PAI MaxKB 橋接(對齊正式站 :6666 路徑)。""" body = request.get_json(silent=True) if not isinstance(body, dict): return jsonify({"ok": False, "error": "invalid_json"}), 400 # Chatwoot SafeFetch 讀超時約 20s;MaxKB 常更久,先快速 200 再背景回覆。 def _run() -> None: try: result = handle_chatwoot_webhook(body) app.logger.info("Chatwoot→MaxKB 橋接完成: %s", result) except Exception: app.logger.exception("Chatwoot→MaxKB 橋接失敗") threading.Thread(target=_run, daemon=True).start() return jsonify({"ok": True, "accepted": True}) @app.route("/login", methods=["GET", "POST"]) def login(): if session.get("username"): return redirect(url_for("home")) username = "" if request.method == "POST": verify_csrf() username = str(request.form.get("username", "")).strip() if not username or len(username) > 191: flash("请输入有效的用户名", "error") else: try: account = repo.get_user_account(username) except Exception as exc: app.logger.exception("会员登录查询失败") flash(f"登录验证失败:{exc}", "error") else: if not account["is_exist"]: flash("该用户名不存在于 members 表", "error") else: session.clear() update_member_session(account) session.permanent = True csrf_token() return redirect(url_for("home")) return render_template("login.html", username=username) @app.get("/home") @login_required def home(): username = str(session["username"]) warning = "" account = { "username": username, "username_clean": username, "raw_username": str(session.get("raw_username", username)), "status": str(session.get("account_status", "normal")), "locked_reason": str(session.get("locked_reason", "")), } try: refreshed = repo.get_user_account(username) if refreshed["is_exist"]: account = refreshed update_member_session(refreshed) except Exception as exc: app.logger.exception("帳戶狀態刷新失敗") warning = str(exc) games = [ {"icon": "🎰", "name": "幸運拉霸", "category": "電子遊戲"}, {"icon": "🃏", "name": "經典百家樂", "category": "真人遊戲"}, {"icon": "⚽", "name": "體育競猜", "category": "體育遊戲"}, {"icon": "🎲", "name": "骰寶大廳", "category": "桌面遊戲"}, {"icon": "🐉", "name": "龍虎鬥", "category": "真人遊戲"}, {"icon": "🚀", "name": "飛行挑戰", "category": "休閒遊戲"}, ] return render_template( "home.html", username=username, account=account, warning=warning, games=games, ) @app.get("/customer-service") @login_required def customer_service(): username = str(session["username"]) withdrawals: list[dict[str, Any]] = [] deposits: list[dict[str, Any]] = [] account: dict[str, Any] = {} error = "" try: account = repo.get_user_account(username) if account.get("is_exist"): update_member_session(account) withdrawals = repo.get_withdraw_orders(username, unfinished_only=True) deposits = repo.get_deposit_orders(username)[:10] except Exception as exc: app.logger.exception("客服頁資料查詢失敗") error = str(exc) return render_template( "customer_service.html", username=username, account=account, withdrawals=withdrawals, deposits=deposits, error=error, ) @app.post("/logout") @login_required def logout(): verify_csrf() # 清除會員 Session 後,保留一個一次性標記。 # 登出後首次載入未登入頁面時,前端會再次 reset Chatwoot, # 避免上一位會員身份殘留在匿名訪客頁面。 session.clear() session["chatwoot_guest_reset_required"] = True return redirect(url_for("login")) @app.get("/health") @app.get("/platform/health") def health(): return jsonify( { "service": CONFIG.app_name, "status": "ok", "read_only_mode": CONFIG.read_only_mode, } ) @app.get("/platform/get-user-account") @api_auth def get_user_account(): username = required_text(payload(), "username") return jsonify(repo.get_user_account(username)) @app.get("/platform/withdraw-orders") @api_auth def withdraw_orders(): username = required_text(payload(), "username") return jsonify(repo.get_withdraw_orders(username, unfinished_only=False)) @app.get("/platform/withdraw-orders-unfinish") @api_auth def withdraw_orders_unfinish(): username = required_text(payload(), "username") return jsonify(repo.get_withdraw_orders(username, unfinished_only=True)) @app.get("/platform/deposit-orders") @app.get("/platform/deposit-not-received") @api_auth def deposit_orders(): data = payload() username = required_text(data, "username") amount: Decimal | None = None raw_amount = str(data.get("amount", "")).strip() if raw_amount: try: amount = Decimal(raw_amount) except InvalidOperation as exc: raise ValueError("amount 格式不正確") from exc created_at: datetime | None = None raw_created_at = str(data.get("created_at", "")).strip() if raw_created_at: try: created_at = datetime.fromisoformat( raw_created_at.replace("Z", "+00:00") ) if created_at.tzinfo is not None: created_at = created_at.astimezone().replace(tzinfo=None) except ValueError as exc: raise ValueError("created_at 必須為 ISO-8601 日期時間") from exc return jsonify(repo.get_deposit_orders(username, amount, created_at)) @app.route("/platform/apply-activity", methods=["POST", "PUT"]) @api_auth def apply_activity(): data = payload() username = required_text(data, "username") activity_id = required_text(data, "activity_id", max_length=64) return jsonify(repo.apply_activity(username, activity_id)) @app.route("/platform/fix-balance", methods=["POST", "PUT"]) @api_auth def fix_balance(): username = required_text(payload(), "username") account = repo.get_user_account(username) if not account["is_exist"]: return jsonify({"error": "not_found", "message": "找不到用戶"}), 404 balance = repo.get_balance(username) repo.log_balance_fix(username, balance) return jsonify( { "username": account["username_clean"], "balance": decimal_string(balance), "simulated": True, "message": "唯讀測試模式:僅回傳 members.balance,未修改資料庫", } ) @app.errorhandler(403) def handle_forbidden_error(exc: HTTPException): message = getattr(exc, "description", "拒絕存取") if is_api_request(): return jsonify({"error": "forbidden", "message": message}), 403 return render_template( "error.html", title="請求驗證失敗", message=message ), 403 @app.errorhandler(ValueError) def handle_value_error(exc: ValueError): if is_api_request(): return jsonify({"error": "bad_request", "message": str(exc)}), 400 return render_template( "error.html", title="請求錯誤", message=str(exc) ), 400 @app.errorhandler(MappingError) def handle_mapping_error(exc: MappingError): if is_api_request(): return jsonify({"error": "mapping_error", "message": str(exc)}), 500 return render_template( "error.html", title="資料映射錯誤", message=str(exc) ), 500 @app.errorhandler(MySQLError) def handle_mysql_error(exc: MySQLError): app.logger.exception("Database error") message = str(exc) if CONFIG.debug else "資料庫查詢失敗,請檢查連線與欄位設定" if is_api_request(): return jsonify({"error": "database_error", "message": message}), 500 return render_template( "error.html", title="資料庫錯誤", message=message ), 500 @app.errorhandler(HTTPException) def handle_http_exception(exc: HTTPException): status_code = int(exc.code or 500) message = str(exc.description or exc.name) if is_api_request(): return jsonify( { "error": exc.name.lower().replace(" ", "_"), "message": message, } ), status_code return render_template( "error.html", title=exc.name, message=message ), status_code @app.errorhandler(Exception) def handle_unexpected_error(exc: Exception): app.logger.exception("Unexpected error") if is_api_request(): return jsonify( {"error": "internal_error", "message": "系統內部錯誤"} ), 500 return render_template( "error.html", title="系統錯誤", message="系統內部錯誤" ), 500 if __name__ == "__main__": app.logger.info( "啟動 %s host=%s port=%s read_only=%s", CONFIG.app_name, CONFIG.host, CONFIG.port, CONFIG.read_only_mode, ) app.run(host=CONFIG.host, port=CONFIG.port, debug=CONFIG.debug)