最初版本

This commit is contained in:
sa19840531
2026-08-15 14:51:51 +08:00
parent 4e01df4dce
commit ea47ea4449
176 changed files with 21009 additions and 0 deletions
+628
View File
@@ -0,0 +1,628 @@
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)