Files
ai-customer-service/guest-cs-stack/flask/app.py
T
2026-08-15 14:51:51 +08:00

629 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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_base = CONFIG.chatwoot_guest_base_url
guest_token = CONFIG.chatwoot_guest_website_token
base_url = (
guest_base
if use_guest_widget and guest_base and guest_token
else CONFIG.chatwoot_base_url
)
website_token = (
guest_token
if use_guest_widget and guest_base 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/<path:subpath>",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
def chatwoot_guest_proxy(subpath: str):
"""把本機 Chatwoot widget 代理進來,才能注入略過「開始對話」的腳本。"""
return proxy_chatwoot(subpath)
@app.route(
"/vite/<path:subpath>",
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/<path:subpath>",
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/<path:subpath>",
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)