628 lines
19 KiB
Python
628 lines
19 KiB
Python
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/<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)
|