最初版本

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
@@ -0,0 +1,627 @@
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)
@@ -0,0 +1,256 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
import uuid
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from config import CONFIG
_AUTH_CACHE: dict[str, Any] = {"headers": None, "expires_at": 0.0}
DEFAULT_GREETING = (
"您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?"
)
class ChatwootGuestError(RuntimeError):
pass
def _request(
method: str,
path: str,
headers: dict[str, str],
payload: dict[str, Any] | None = None,
) -> tuple[int, dict[str, str], Any]:
body = None
req_headers = dict(headers)
if payload is not None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req_headers["Content-Type"] = "application/json"
req_headers["Accept"] = "application/json"
api_base = (
CONFIG.chatwoot_guest_api_base or CONFIG.chatwoot_base_url
).rstrip("/")
request = Request(
f"{api_base}{path}",
data=body,
headers=req_headers,
method=method,
)
try:
with urlopen(request, timeout=20) as response:
raw = response.read().decode("utf-8", "replace")
data = json.loads(raw) if raw else {}
return response.status, dict(response.headers.items()), data
except HTTPError as exc:
raw = exc.read().decode("utf-8", "replace")
try:
data = json.loads(raw) if raw else {}
except json.JSONDecodeError:
data = {"message": raw[:300]}
raise ChatwootGuestError(
f"Chatwoot {method} {path} 失敗:{exc.code} {data}"
) from exc
except URLError as exc:
raise ChatwootGuestError(f"無法連線 Chatwoot:{exc.reason}") from exc
def _login_headers(*, force: bool = False) -> dict[str, str]:
email = CONFIG.chatwoot_guest_api_email or CONFIG.chatwoot_api_email
password = CONFIG.chatwoot_guest_api_password or CONFIG.chatwoot_api_password
if not email or not password:
raise ChatwootGuestError("未設定 Chatwoot API 帳號密碼")
now = time.time()
cached = _AUTH_CACHE.get("headers")
if not force and cached and now < float(_AUTH_CACHE.get("expires_at") or 0):
return cached
status, headers, _data = _request(
"POST",
"/auth/sign_in",
{},
{
"email": email,
"password": password,
},
)
if status >= 400:
raise ChatwootGuestError("Chatwoot 登入失敗")
token = headers.get("access-token") or headers.get("Access-Token") or ""
client = headers.get("client") or headers.get("Client") or ""
uid = headers.get("uid") or headers.get("Uid") or ""
if not token or not client or not uid:
raise ChatwootGuestError("Chatwoot 登入未回傳 token")
auth_headers = {
"access-token": token,
"client": client,
"uid": uid,
"token-type": "Bearer",
}
_AUTH_CACHE["headers"] = auth_headers
_AUTH_CACHE["expires_at"] = now + 300
return auth_headers
def _dig(data: Any, *keys: str) -> Any:
current = data
for key in keys:
if not isinstance(current, dict):
return None
current = current.get(key)
return current
def _source_id(*payloads: Any) -> str:
for payload in payloads:
if not isinstance(payload, dict):
continue
body = payload.get("payload") or payload
candidates = [
_dig(body, "contact_inbox", "source_id"),
_dig(payload, "contact_inbox", "source_id"),
_dig(
payload,
"last_non_activity_message",
"conversation",
"contact_inbox",
"source_id",
),
]
messages = body.get("messages") if isinstance(body, dict) else None
if isinstance(messages, list) and messages:
candidates.append(
_dig(
messages[0],
"conversation",
"contact_inbox",
"source_id",
)
)
for value in candidates:
if value:
return str(value)
return ""
def _contact_id(payload: Any) -> int | None:
if not isinstance(payload, dict):
return None
body = payload.get("payload") or payload
if not isinstance(body, dict):
return None
contact = body.get("contact") or body
if not isinstance(contact, dict):
return None
contact_id = contact.get("id")
return int(contact_id) if contact_id is not None else None
def create_guest_conversation() -> dict[str, Any]:
"""建立新的遊客聯絡人與對話,讓 widget 直接進入聊天室。"""
identifier = f"guest-{uuid.uuid4().hex[:16]}"
headers = _login_headers()
account_id = CONFIG.chatwoot_account_id
try:
return _create_with_headers(identifier, headers, account_id)
except ChatwootGuestError as exc:
if "401" not in str(exc) and "403" not in str(exc):
raise
headers = _login_headers(force=True)
return _create_with_headers(identifier, headers, account_id)
def _create_with_headers(
identifier: str,
headers: dict[str, str],
account_id: int,
) -> dict[str, Any]:
_, _, contact_body = _request(
"POST",
f"/api/v1/accounts/{account_id}/contacts",
headers,
{
"inbox_id": CONFIG.chatwoot_guest_inbox_id or CONFIG.chatwoot_inbox_id,
"name": "遊客",
"identifier": identifier,
"custom_attributes": {"visitor_type": "guest"},
},
)
contact_id = _contact_id(contact_body)
if not contact_id:
raise ChatwootGuestError("Chatwoot 未回傳 contact id")
source_id = _source_id(contact_body)
if not source_id:
raise ChatwootGuestError("Chatwoot 未回傳 source_id")
# 必須帶同一個 source_id。只傳 contact_id + inbox_id 時,
# ContactInboxBuilder 會再產生一組 WebWidget UUID,
# 歡迎語會掛在新 session,widget JWT 對不到就變成空白對話。
# 歡迎圖不走 Chatwoot 附件,否則 widget 會顯示檔名與「下載」。
_, _, conversation = _request(
"POST",
f"/api/v1/accounts/{account_id}/conversations",
headers,
{
"inbox_id": CONFIG.chatwoot_guest_inbox_id or CONFIG.chatwoot_inbox_id,
"contact_id": contact_id,
"source_id": source_id,
"status": "open",
"message": {
"content": CONFIG.chatwoot_greeting or DEFAULT_GREETING,
"message_type": "outgoing",
},
},
)
conversation_id = conversation.get("id") if isinstance(conversation, dict) else None
return {
"identifier": identifier,
"contact_id": contact_id,
"conversation_id": conversation_id,
"source_id": source_id,
"widget_token": make_widget_token(source_id),
"name": "遊客",
}
def make_widget_token(source_id: str) -> str:
"""產生 Chatwoot widget 的 cw_conversation JWT。"""
secret = CONFIG.chatwoot_widget_jwt_secret
if not secret:
raise ChatwootGuestError("未設定 CHATWOOT_WIDGET_JWT_SECRET")
now = int(time.time())
header = _b64url(json.dumps({"alg": "HS256"}, separators=(",", ":")).encode())
payload = _b64url(
json.dumps(
{
"source_id": source_id,
"inbox_id": CONFIG.chatwoot_guest_inbox_id or CONFIG.chatwoot_inbox_id,
"iat": now,
"exp": now + 60 * 60 * 24 * 180,
},
separators=(",", ":"),
).encode()
)
signing_input = f"{header}.{payload}".encode()
signature = hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).digest()
return f"{header}.{payload}.{_b64url(signature)}"
def _b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env", override=True)
def env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
@dataclass(frozen=True)
class Config:
app_name: str = os.getenv("APP_NAME", "API接口系统")
host: str = os.getenv("APP_HOST", os.getenv("API_HOST", "0.0.0.0"))
port: int = int(os.getenv("APP_PORT", os.getenv("API_PORT", "7788")))
debug: bool = env_bool("APP_DEBUG", env_bool("API_DEBUG", False))
secret_key: str = os.getenv("SECRET_KEY", "replace-this-secret-key")
api_key: str = os.getenv("API_KEY", "change-me")
session_cookie_secure: bool = env_bool("SESSION_COOKIE_SECURE", False)
db_host: str = os.getenv("DB_HOST", "127.0.0.1")
db_port: int = int(os.getenv("DB_PORT", "3306"))
db_user: str = os.getenv("DB_USER", "api_reader")
db_password: str = os.getenv("DB_PASSWORD", "")
db_name: str = os.getenv("DB_NAME", "member_system")
db_charset: str = os.getenv("DB_CHARSET", "utf8mb4")
db_connect_timeout: int = int(os.getenv("DB_CONNECT_TIMEOUT", "5"))
members_table: str = os.getenv("MEMBERS_TABLE", "members")
withdraw_table: str = os.getenv("WITHDRAW_TABLE", "withdraw_records")
withdraw_request_table: str = os.getenv(
"WITHDRAW_REQUEST_TABLE", "withdraw_request_records"
)
deposit_table: str = os.getenv("DEPOSIT_TABLE", "deposit_records")
read_only_mode: bool = env_bool("READ_ONLY_MODE", True)
auto_init_sim_tables: bool = env_bool("AUTO_INIT_SIM_TABLES", False)
default_activity_id: str = os.getenv("DEFAULT_ACTIVITY_ID", "ad-x1234")
deposit_search_hours: int = int(os.getenv("DEPOSIT_SEARCH_HOURS", "24"))
max_orders: int = int(os.getenv("MAX_ORDERS", "50"))
# Chatwoot 網站客服設定
chatwoot_enabled: bool = env_bool("CHATWOOT_ENABLED", True)
chatwoot_base_url: str = os.getenv(
"CHATWOOT_BASE_URL",
"http://pai.pubuli.com",
).rstrip("/")
chatwoot_website_token: str = os.getenv(
"CHATWOOT_WEBSITE_TOKEN",
"Vivum4g1WWxH1AmxoTivAcX5",
)
# 必須使用 Chatwoot Website Inbox 的 Identity Validation Token。
# 此值只保留在後端,絕不可輸出到 HTML 或 JavaScript。
chatwoot_identity_secret: str = os.getenv(
"CHATWOOT_IDENTITY_SECRET",
"",
)
chatwoot_debug: bool = env_bool("CHATWOOT_DEBUG", False)
chatwoot_account_id: int = int(os.getenv("CHATWOOT_ACCOUNT_ID", "1"))
chatwoot_inbox_id: int = int(os.getenv("CHATWOOT_INBOX_ID", "1"))
chatwoot_api_email: str = os.getenv("CHATWOOT_API_EMAIL", "")
chatwoot_api_password: str = os.getenv("CHATWOOT_API_PASSWORD", "")
chatwoot_greeting: str = os.getenv(
"CHATWOOT_GREETING",
"您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?",
)
chatwoot_greeting_image: str = os.getenv(
"CHATWOOT_GREETING_IMAGE",
"static/guest_promo.png",
)
chatwoot_guest_base_url: str = os.getenv(
"CHATWOOT_GUEST_BASE_URL",
"",
).rstrip("/")
chatwoot_guest_website_token: str = os.getenv(
"CHATWOOT_GUEST_WEBSITE_TOKEN",
"",
)
chatwoot_guest_proxy_target: str = os.getenv(
"CHATWOOT_GUEST_PROXY_TARGET",
"http://127.0.0.1:3000",
).rstrip("/")
chatwoot_guest_api_base: str = os.getenv(
"CHATWOOT_GUEST_API_BASE",
"",
).rstrip("/")
chatwoot_guest_api_email: str = os.getenv("CHATWOOT_GUEST_API_EMAIL", "")
chatwoot_guest_api_password: str = os.getenv("CHATWOOT_GUEST_API_PASSWORD", "")
chatwoot_guest_inbox_id: int = int(os.getenv("CHATWOOT_GUEST_INBOX_ID", "1"))
chatwoot_widget_jwt_secret: str = os.getenv("CHATWOOT_WIDGET_JWT_SECRET", "")
chatwoot_agent_bot_id: int = int(os.getenv("CHATWOOT_AGENT_BOT_ID", "0") or "0")
chatwoot_agent_bot_token: str = os.getenv("CHATWOOT_AGENT_BOT_TOKEN", "")
# 本機 Chatwoot → PAI MaxKB 橋接
maxkb_bridge_enabled: bool = env_bool("MAXKB_BRIDGE_ENABLED", True)
maxkb_base_url: str = os.getenv(
"MAXKB_BASE_URL",
"http://pai.pubuli.com:8080",
).rstrip("/")
maxkb_access_token: str = os.getenv("MAXKB_ACCESS_TOKEN", "")
maxkb_timeout_sec: float = float(os.getenv("MAXKB_TIMEOUT_SEC", "120"))
CONFIG = Config()
@@ -0,0 +1,470 @@
"""Chatwoot Agent Bot webhook → MaxKB(PAI)→ 回寫對話。"""
from __future__ import annotations
import json
import logging
import re
import threading
import time
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from config import CONFIG
logger = logging.getLogger(__name__)
_SESSION_LOCK = threading.Lock()
_SESSIONS: dict[str, dict[str, Any]] = {}
_SESSION_FILE = Path(__file__).resolve().parent / ".maxkb_cw_sessions.json"
_QUICK_QUESTION_RE = re.compile(
r"<quick_question>\s*(.*?)\s*</quick_question>",
re.IGNORECASE | re.DOTALL,
)
def _load_sessions() -> None:
if not _SESSION_FILE.exists():
return
try:
data = json.loads(_SESSION_FILE.read_text(encoding="utf-8"))
if isinstance(data, dict):
_SESSIONS.update(data)
except Exception:
logger.exception("讀取 MaxKB session 快取失敗")
def _save_sessions() -> None:
try:
_SESSION_FILE.write_text(
json.dumps(_SESSIONS, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except Exception:
logger.exception("寫入 MaxKB session 快取失敗")
_load_sessions()
def _http_json(
method: str,
url: str,
*,
headers: dict[str, str] | None = None,
body: Any = None,
timeout: float = 120,
) -> tuple[int, Any]:
data = None
if body is not None:
data = body if isinstance(body, (bytes, bytearray)) else json.dumps(
body, ensure_ascii=False
).encode("utf-8")
req_headers = {"Content-Type": "application/json", "Accept": "application/json"}
if headers:
req_headers.update(headers)
req = Request(url, data=data, headers=req_headers, method=method)
try:
with urlopen(req, timeout=timeout) as resp:
raw = resp.read()
try:
return resp.status, json.loads(raw.decode("utf-8") or "null")
except Exception:
return resp.status, raw.decode("utf-8", errors="replace")
except HTTPError as exc:
raw = exc.read()
try:
return exc.code, json.loads(raw.decode("utf-8") or "null")
except Exception:
return exc.code, raw.decode("utf-8", errors="replace")
except URLError as exc:
raise RuntimeError(f"HTTP 連線失敗: {url}: {exc}") from exc
def _maxkb_auth() -> str:
status, body = _http_json(
"POST",
f"{CONFIG.maxkb_base_url}/chat/api/auth/anonymous",
body={"access_token": CONFIG.maxkb_access_token},
timeout=30,
)
if status != 200 or not isinstance(body, dict) or body.get("code") != 200:
raise RuntimeError(f"MaxKB anonymous auth 失敗: {status} {body}")
token = body.get("data")
if not token:
raise RuntimeError("MaxKB anonymous auth 未回傳 token")
return str(token)
def _maxkb_open(jwt: str) -> str:
status, body = _http_json(
"GET",
f"{CONFIG.maxkb_base_url}/chat/api/open",
headers={"Authorization": f"Bearer {jwt}"},
timeout=30,
)
if status != 200 or not isinstance(body, dict) or body.get("code") != 200:
raise RuntimeError(f"MaxKB open 失敗: {status} {body}")
chat_id = body.get("data")
if not chat_id:
raise RuntimeError("MaxKB open 未回傳 chat_id")
return str(chat_id)
def _maxkb_ask(
jwt: str,
chat_id: str,
message: str,
form_data: dict[str, Any],
) -> str:
status, body = _http_json(
"POST",
f"{CONFIG.maxkb_base_url}/chat/api/chat_message/{chat_id}",
headers={"Authorization": f"Bearer {jwt}"},
body={
"message": message,
"stream": False,
"re_chat": False,
"form_data": form_data,
},
timeout=CONFIG.maxkb_timeout_sec,
)
if status != 200 or not isinstance(body, dict) or body.get("code") != 200:
raise RuntimeError(f"MaxKB chat 失敗: {status} {body}")
data = body.get("data") or {}
content = ""
if isinstance(data, dict):
content = str(data.get("content") or "").strip()
if not content:
answers = data.get("answer_list") or []
if answers and isinstance(answers[0], dict):
content = str(answers[0].get("content") or "").strip()
if not content:
raise RuntimeError(f"MaxKB 回覆為空: {body}")
return content
def _get_or_create_session(conversation_key: str) -> dict[str, Any]:
with _SESSION_LOCK:
sess = _SESSIONS.get(conversation_key)
if sess and sess.get("jwt") and sess.get("chat_id"):
return dict(sess)
jwt = _maxkb_auth()
chat_id = _maxkb_open(jwt)
sess = {
"jwt": jwt,
"chat_id": chat_id,
"updated_at": int(time.time()),
}
with _SESSION_LOCK:
_SESSIONS[conversation_key] = sess
_save_sessions()
return dict(sess)
def _refresh_session(conversation_key: str) -> dict[str, Any]:
with _SESSION_LOCK:
_SESSIONS.pop(conversation_key, None)
_save_sessions()
return _get_or_create_session(conversation_key)
def _split_quick_questions(reply: str) -> tuple[str, list[str]]:
"""把 MaxKB 的 <quick_question> 標籤拆成純文字 + 按鈕選項。"""
raw = str(reply or "")
items: list[str] = []
for match in _QUICK_QUESTION_RE.findall(raw):
label = " ".join(str(match).split()).strip()
if label and label not in items:
items.append(label)
clean = _QUICK_QUESTION_RE.sub("", raw)
clean = re.sub(r"[ \t]+\n", "\n", clean)
clean = re.sub(r"\n{3,}", "\n\n", clean).strip()
return clean, items
def _chatwoot_reply(
account_id: int,
conversation_id: int,
content: str,
*,
content_type: str = "text",
content_attributes: dict[str, Any] | None = None,
) -> None:
base = (
CONFIG.chatwoot_guest_api_base
or CONFIG.chatwoot_guest_proxy_target
or "http://127.0.0.1:3000"
).rstrip("/")
token = CONFIG.chatwoot_agent_bot_token
if not token:
raise RuntimeError("未設定 CHATWOOT_AGENT_BOT_TOKEN")
url = (
f"{base}/api/v1/accounts/{account_id}/conversations/"
f"{conversation_id}/messages"
)
payload: dict[str, Any] = {
"content": content,
"message_type": "outgoing",
"private": False,
"content_type": content_type,
}
if content_attributes:
payload["content_attributes"] = content_attributes
status, body = _http_json(
"POST",
url,
headers={"api_access_token": token},
body=payload,
timeout=30,
)
if status >= 300:
raise RuntimeError(f"Chatwoot 回覆失敗: {status} {body}")
def _chatwoot_reply_from_maxkb(
account_id: int,
conversation_id: int,
reply: str,
) -> None:
"""回寫 Chatwoot:去掉 quick_question 原文,改成可點選按鈕。"""
clean, items = _split_quick_questions(reply)
if clean:
_chatwoot_reply(account_id, conversation_id, clean)
elif not items:
# 空回覆不送
return
if items:
_chatwoot_reply(
account_id,
conversation_id,
"",
content_type="input_select",
content_attributes={
"items": [{"title": label, "value": label} for label in items]
},
)
def _has_attachments(message: dict[str, Any]) -> bool:
atts = message.get("attachments") or message.get("files") or []
return isinstance(atts, list) and len(atts) > 0
def _is_incoming_message(message: dict[str, Any]) -> bool:
message_type = message.get("message_type")
incoming_types = {0, "0", "incoming", "Incoming"}
if message_type not in incoming_types:
return False
if message.get("private"):
return False
if message.get("content_type") in {"input_email", "form", "input_select"}:
return False
has_text = bool(str(message.get("content") or "").strip())
return has_text or _has_attachments(message)
def _pick_latest_incoming(messages: Any) -> dict[str, Any] | None:
if not isinstance(messages, list):
return None
for msg in reversed(messages):
if isinstance(msg, dict) and _is_incoming_message(msg):
return msg
return None
def _extract_incoming(payload: dict[str, Any]) -> dict[str, Any] | None:
event = str(payload.get("event") or "")
message: dict[str, Any] | None = None
if event == "message_created":
candidate = payload.get("message") or payload
message = candidate if isinstance(candidate, dict) else None
elif event == "conversation_updated":
# Agent Bot webhook 常送 conversation_updated,訊息在 messages 陣列
conversation = payload.get("conversation") if isinstance(payload.get("conversation"), dict) else {}
messages = payload.get("messages") or conversation.get("messages") or []
message = _pick_latest_incoming(messages)
elif not event:
candidate = payload.get("message") or payload
message = candidate if isinstance(candidate, dict) else None
else:
return None
if not message or not _is_incoming_message(message):
return None
content = str(message.get("content") or "").strip()
conversation = message.get("conversation") or payload.get("conversation") or {}
if not isinstance(conversation, dict):
conversation = {}
conversation_id = conversation.get("id") or message.get("conversation_id") or payload.get("id")
account = payload.get("account") if isinstance(payload.get("account"), dict) else {}
account_id = (
message.get("account_id")
or conversation.get("account_id")
or account.get("id")
or CONFIG.chatwoot_account_id
)
if not conversation_id:
return None
sender = message.get("sender") or {}
username = ""
if isinstance(sender, dict):
username = str(
sender.get("name")
or sender.get("identifier")
or (sender.get("additional_attributes") or {}).get("username")
or ""
)
return {
"account_id": int(account_id),
"conversation_id": int(conversation_id),
"content": content,
"username": username,
"message_id": message.get("id"),
}
def _hard_reply_for_complaint(message: str) -> str | None:
"""客诉/提款强制话术,避免意图误判。"""
text = "".join(str(message or "").split())
if "一次不给赢" in text:
return "亲,每次游戏都会有输有赢,关键是保持好心态,接受挑战,最终会有丰厚的回报!"
# YD-CUSTOMER-018
if "回归" in text and "杀" in text:
return (
"亲,游戏输输赢赢很正常的,拼的是智力、耐心、心态和运气,"
"如果您觉得手气不好,可以稍作休息,转移一下注意力,稍后再进行投注,"
"或许下一个大奖就是您的,祝您鸿运当头,盈利多多。"
)
# YD-CUSTOMER-017
low = text.lower()
if (
"pg电子" in low
or "PG电子" in text
or "电子真是假" in text
or "假的我永远" in text
or ("电子" in text and "假" in text)
):
return (
"亲,您好!电子游戏的大奖奖池是根据机率来的哦,每一轮的结果都是随机的,"
"所以不能保证每次都能中奖。希望您能理解,这也是游戏的一部分乐趣所在!"
"我们可以一起再试试,说不定下一次就能碰上好运呢"
)
# YD-WITHDRAW-016(标准为转专人,禁止追问订单号)
if "取消提款" in text or "取消提现" in text or "申请取消提款" in text:
return (
"您好,取消提款需由专员协助核实处理,请稍候为您转接真人客服。"
"<quick_question>转专人服务</quick_question>"
)
return None
def handle_chatwoot_webhook(payload: dict[str, Any]) -> dict[str, Any]:
if not CONFIG.maxkb_bridge_enabled:
return {"ok": True, "skipped": "bridge_disabled"}
incoming = _extract_incoming(payload)
if not incoming:
return {"ok": True, "skipped": "not_incoming"}
account_id = incoming["account_id"]
conversation_id = incoming["conversation_id"]
key = f"{account_id}:{conversation_id}"
message_id = incoming.get("message_id")
content = str(incoming.get("content") or "").strip()
now = int(time.time())
event = str(payload.get("event") or "")
with _SESSION_LOCK:
cur = dict(_SESSIONS.get(key) or {})
if message_id is not None and cur.get("last_message_id") == message_id:
return {"ok": True, "skipped": "duplicate", "event": event}
if (
content
and cur.get("last_content") == content
and now - int(cur.get("last_content_at") or 0) < 45
):
return {"ok": True, "skipped": "duplicate_content", "event": event}
if message_id is not None:
cur["last_message_id"] = message_id
if content:
cur["last_content"] = content
cur["last_content_at"] = now
cur["updated_at"] = now
_SESSIONS[key] = cur
_save_sessions()
# 純圖片/附件沒有文字時,MaxKB 收不到內容,先前會直接略過,畫面像卡住。
if not content:
ack = (
"已收到您的图片。目前客服无法辨识图片内容,"
"请用文字说明您要咨询的问题,例如:充值未到账、无法登录、提款问题。"
)
_chatwoot_reply_from_maxkb(account_id, conversation_id, ack)
return {
"ok": True,
"conversation_id": conversation_id,
"image_ack": True,
"event": event,
}
form_data = {
"account_id": str(account_id),
"conversation_id": str(conversation_id),
"bot_id": str(CONFIG.chatwoot_agent_bot_id or ""),
"username": incoming["username"] or "游客",
}
hard = _hard_reply_for_complaint(incoming["content"])
if hard:
_chatwoot_reply_from_maxkb(account_id, conversation_id, hard)
with _SESSION_LOCK:
cur = dict(_SESSIONS.get(key) or {})
cur["updated_at"] = int(time.time())
cur["hard_reply"] = True
_SESSIONS[key] = cur
_save_sessions()
return {
"ok": True,
"conversation_id": conversation_id,
"hard_reply": True,
"reply_len": len(hard),
"event": event,
}
sess = _get_or_create_session(key)
try:
reply = _maxkb_ask(
sess["jwt"], sess["chat_id"], incoming["content"], form_data
)
except Exception:
logger.exception("MaxKB 對話失敗,嘗試重建 session")
sess = _refresh_session(key)
reply = _maxkb_ask(
sess["jwt"], sess["chat_id"], incoming["content"], form_data
)
_chatwoot_reply_from_maxkb(account_id, conversation_id, reply)
with _SESSION_LOCK:
cur = dict(_SESSIONS.get(key) or {})
cur["updated_at"] = int(time.time())
if message_id is not None:
cur["last_message_id"] = message_id
_SESSIONS[key] = cur
_save_sessions()
return {
"ok": True,
"conversation_id": conversation_id,
"chat_id": sess["chat_id"],
"reply_len": len(reply),
"event": event,
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,453 @@
{% if chatwoot_config.enabled %}
<script>
(function () {
"use strict";
/*
* 會員身份由 Flask Session 注入,identifier 直接使用 username_clean。
* 不再接受 ?identifier=... 等網址參數,避免使用者冒充其他會員。
*/
const CHATWOOT_CONFIG = {{ chatwoot_config | tojson }};
const CHATWOOT_USER = {{ chatwoot_user | tojson }};
window.chatwootSettings = {
hideMessageBubble: false,
showUnreadMessagesDialog: false,
position: "right",
locale: "zh_TW",
useBrowserLanguage: false,
type: "standard",
darkMode: "auto",
launcherTitle: ""
};
function debugLog() {
if (!CHATWOOT_CONFIG.debug) {
return;
}
console.log.apply(console, arguments);
}
function configureChatwootIdentity() {
if (!window.$chatwoot) {
return;
}
if (!CHATWOOT_USER.is_authenticated) {
if (typeof window.$chatwoot.setCustomAttributes === "function") {
window.$chatwoot.setCustomAttributes({
visitor_type: "guest"
});
}
debugLog("Chatwoot anonymous guest ready");
return;
}
if (
typeof window.$chatwoot.setUser !== "function" ||
!CHATWOOT_USER.identifier
) {
return;
}
const userPayload = {
name: CHATWOOT_USER.name || CHATWOOT_USER.username
};
if (CHATWOOT_USER.identifier_hash) {
userPayload.identifier_hash = CHATWOOT_USER.identifier_hash;
}
window.$chatwoot.setUser(
CHATWOOT_USER.identifier,
userPayload
);
if (typeof window.$chatwoot.setCustomAttributes === "function") {
window.$chatwoot.setCustomAttributes({
visitor_type: "member",
username: CHATWOOT_USER.username || "",
member_uid: CHATWOOT_USER.uid == null
? ""
: String(CHATWOOT_USER.uid),
account_status: CHATWOOT_USER.account_status || "",
locked_reason: CHATWOOT_USER.locked_reason || "",
member_type: CHATWOOT_USER.member_type || "",
vip_level: CHATWOOT_USER.vip_level || "",
tier: CHATWOOT_USER.tier || ""
});
}
}
/*
* Flask 登出前清除 Chatwoot Session,避免下一個登入會員
* 沿用上一位會員的 Chatwoot Cookie 與對話。
*/
window.resetChatwootBeforeLogout = function (form) {
if (!form) {
return true;
}
/*
* 防止使用者重複點擊登出,造成多次提交。
*/
if (form.dataset.submitting === "true") {
return false;
}
form.dataset.submitting = "true";
const submitLogout = function () {
form.submit();
};
if (
window.$chatwoot &&
typeof window.$chatwoot.reset === "function"
) {
try {
window.$chatwoot.reset();
} catch (error) {
console.error(
"Chatwoot reset before logout failed:",
error
);
}
/*
* Chatwoot reset 沒有 Promise 回傳,因此提供短暫緩衝時間。
* 即使前端清理未完成,後端的一次性 guest reset
* 仍會在登出後首次載入匿名頁面時再次執行。
*/
window.setTimeout(
submitLogout,
300
);
return false;
}
submitLogout();
return false;
};
window.openGuestChatNewConversation = function () {
createGuestConversationAndShow({ openAfter: true });
};
window.exitGuestChatAndStartNew = function () {
// 先關掉視窗,再換成新對話並重新打開
closeGuestChat();
createGuestConversationAndShow({ openAfter: true, delayMs: 350 });
};
function createGuestConversationAndShow(options) {
var opts = options || {};
var openAfter = opts.openAfter !== false;
var delayMs = typeof opts.delayMs === "number" ? opts.delayMs : 700;
if (!CHATWOOT_CONFIG.guest_new_path) {
return;
}
fetch(CHATWOOT_CONFIG.guest_new_path, {
method: "POST",
headers: {
Accept: "application/json",
"X-CSRF-Token": CHATWOOT_CONFIG.csrf_token || ""
}
})
.then(function (response) {
return response.json().then(function (data) {
if (!response.ok) {
throw new Error((data && data.message) || "建立游客对话失败");
}
return data;
});
})
.then(function (data) {
if (!data || !data.widget_token) {
throw new Error("Chatwoot 未回傳 widget_token");
}
document.querySelectorAll("iframe[src*='widget']").forEach(
function (iframe) {
const url = new URL(iframe.src, window.location.origin);
url.searchParams.set("cw_conversation", data.widget_token);
url.hash = "/messages";
iframe.src = url.toString();
}
);
if (!openAfter) {
window.__guestWidgetOpen = false;
return;
}
window.__guestWidgetOpen = true;
window.setTimeout(function () {
if (
window.$chatwoot &&
typeof window.$chatwoot.toggle === "function"
) {
window.$chatwoot.toggle("open");
}
}, delayMs);
})
.catch(function (error) {
console.error("Chatwoot guest open failed:", error);
window.alert("客服暂时无法开启,请稍后再试。");
});
};
function isGuestChatOpen() {
var holder = document.querySelector(".woot-widget-holder");
if (holder && !holder.classList.contains("woot--hide")) {
var style = window.getComputedStyle(holder);
var rect = holder.getBoundingClientRect();
if (
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity || "1") > 0 &&
rect.width > 0 &&
rect.height > 0
) {
return true;
}
}
return !!window.__guestWidgetOpen;
}
function openGuestChatResume() {
window.__guestWidgetOpen = true;
if (window.$chatwoot && typeof window.$chatwoot.toggle === "function") {
try {
window.$chatwoot.toggle("open");
return;
} catch (error) {}
}
var holder = document.querySelector(".woot-widget-holder");
if (holder) {
holder.classList.remove("woot--hide");
}
}
function closeGuestChat() {
window.__guestWidgetOpen = false;
if (window.$chatwoot && typeof window.$chatwoot.toggle === "function") {
try {
window.$chatwoot.toggle("close");
return;
} catch (error) {}
}
var holder = document.querySelector(".woot-widget-holder");
if (holder) {
holder.classList.add("woot--hide");
}
}
function bindGuestBubbleToggle() {
if (!CHATWOOT_CONFIG.guest_new_path || window.__guestBubbleBound) {
return;
}
window.__guestBubbleBound = true;
window.addEventListener("chatwoot:opened", function () {
window.__guestWidgetOpen = true;
});
window.addEventListener("chatwoot:closed", function () {
window.__guestWidgetOpen = false;
});
document.addEventListener(
"click",
function (event) {
var target = event.target;
if (!target || typeof target.closest !== "function") {
return;
}
var bubble = target.closest(
".woot-widget-bubble, .woot--bubble-holder, button.woot-widget-bubble"
);
if (!bubble) {
return;
}
// 右下角氣泡:只收起/重開同一個對話,绝不開新單。
event.preventDefault();
event.stopPropagation();
if (isGuestChatOpen()) {
closeGuestChat();
} else {
openGuestChatResume();
}
},
true
);
}
window.addEventListener("message", function (event) {
var raw = event && event.data;
if (typeof raw !== "string" || raw.indexOf("chatwoot-widget:") !== 0) {
return;
}
try {
var payload = JSON.parse(raw.replace("chatwoot-widget:", ""));
if (payload && payload.event === "guestExitNewChat") {
if (typeof window.exitGuestChatAndStartNew === "function") {
window.exitGuestChatAndStartNew();
} else if (typeof window.openGuestChatNewConversation === "function") {
window.openGuestChatNewConversation();
}
}
} catch (error) {}
});
window.addEventListener(
"chatwoot:ready",
function () {
debugLog("chatwoot:ready", window.$chatwoot);
const guestResetRequired =
{{ chatwoot_guest_reset_required | tojson }};
/*
* 只有「剛完成會員登出」且目前未登入時,才清除舊會員身份。
* 一般遊客頁面不會每次都 reset,因此不會破壞既有匿名對話。
*/
if (
!CHATWOOT_USER.is_authenticated &&
guestResetRequired &&
window.$chatwoot &&
typeof window.$chatwoot.reset === "function"
) {
try {
window.$chatwoot.reset();
} catch (error) {
console.error(
"Chatwoot guest reset failed:",
error
);
}
/*
* reset 後稍候再設定 visitor_type=guest。
* 不執行 setUser(),因此不會向 Chatwoot 傳遞用戶名。
*/
window.setTimeout(
configureChatwootIdentity,
300
);
bindGuestBubbleToggle();
return;
}
configureChatwootIdentity();
bindGuestBubbleToggle();
}
);
window.addEventListener(
"chatwoot:error",
function (event) {
console.error(
"chatwoot:error",
event && event.detail ? event.detail : event
);
}
);
if (CHATWOOT_CONFIG.debug) {
window.addEventListener(
"chatwoot:on-message",
function (event) {
debugLog("chatwoot:on-message", event.detail);
}
);
window.addEventListener(
"chatwoot:postback",
function (event) {
debugLog("chatwoot:postback", event.detail);
}
);
window.addEventListener(
"chatwoot:opened",
function () {
debugLog("chatwoot:opened");
}
);
window.addEventListener(
"chatwoot:closed",
function () {
debugLog("chatwoot:closed");
}
);
window.addEventListener(
"chatwoot:on-start-conversation",
function (event) {
debugLog(
"chatwoot:on-start-conversation",
event.detail
);
}
);
}
const script = document.createElement("script");
const firstScript = document.getElementsByTagName("script")[0];
script.src = (
CHATWOOT_CONFIG.base_url +
"/packs/js/sdk.js"
);
script.async = true;
script.onload = function () {
if (!window.chatwootSDK) {
console.error("Chatwoot SDK object not found");
return;
}
window.chatwootSDK.run({
websiteToken: CHATWOOT_CONFIG.website_token,
baseUrl: CHATWOOT_CONFIG.base_url
});
// 父頁強制觸發 iframe 內快捷入口/宣傳圖掛載(同源 /cw)
function boostGuestUi() {
try {
var iframes = document.querySelectorAll("iframe");
for (var i = 0; i < iframes.length; i += 1) {
var iframe = iframes[i];
var src = iframe.getAttribute("src") || "";
if (src.indexOf("widget") === -1 && src.indexOf("/cw") === -1) {
continue;
}
var win = iframe.contentWindow;
if (win && typeof win.__ydEnsureGuestUI === "function") {
win.__ydEnsureGuestUI();
}
}
} catch (err) {}
}
window.setInterval(boostGuestUi, 1000);
window.setTimeout(boostGuestUi, 1500);
window.setTimeout(boostGuestUi, 3500);
};
script.onerror = function () {
console.error(
"Chatwoot SDK load failed:",
script.src
);
};
firstScript.parentNode.insertBefore(
script,
firstScript
);
})();
</script>
{% endif %}
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body class="{% block body_class %}{% endblock %}">
{% block body %}{% endblock %}
{% block integrations %}{% endblock %}
</body>
</html>
@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}游客客服|{{ app_name }}{% endblock %}
{% block body_class %}guest-page{% endblock %}
{% block body %}
<header class="topbar">
<div>
<div class="site-title">游客客服中心</div>
<div class="site-subtitle">Guest Customer Service</div>
</div>
<a class="back-link" href="{{ url_for('login') }}">返回登录页</a>
</header>
<main class="page narrow">
<section class="support-panel guest-support-panel">
<div class="support-avatar">🎧</div>
<span class="guest-badge">游客模式</span>
<h1>欢迎使用在线客服</h1>
<p>您目前以游客身份进入,可咨询注册、登录、活动与平台操作问题。</p>
<div class="alert alert-warning">
游客模式不会识别会员身份,也不能查询充值、提款、余额或其他会员资料。
</div>
<button
type="button"
class="primary-button guest-open-chat"
onclick="if (window.openGuestChatNewConversation) { window.openGuestChatNewConversation(); }"
>
开启在线客服
</button>
</section>
</main>
{% endblock %}
{% block integrations %}
{% include "_chatwoot.html" %}
{% endblock %}
@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}遊戲首頁|{{ app_name }}{% endblock %}
{% block body %}
<header class="topbar">
<div>
<div class="site-title">YD GAME DEMO</div>
<div class="site-subtitle">Python 唯讀 API 測試站點</div>
</div>
<div class="member-area">
<span>用戶:<strong>{{ account.raw_username or username }}</strong></span>
<span class="status-badge {{ account.status }}">
{{ "已凍結" if account.status == "locked" else "正常" }}
</span>
<form
method="post"
action="{{ url_for('logout') }}"
onsubmit="return window.resetChatwootBeforeLogout ? window.resetChatwootBeforeLogout(this) : true;"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="logout-button" type="submit">登出</button>
</form>
</div>
</header>
<main class="page">
{% if warning %}
<div class="alert alert-error">帳戶資料讀取失敗:{{ warning }}</div>
{% endif %}
{% if account.status == "locked" %}
<div class="alert alert-warning">
<strong>帳戶已凍結</strong>
{% if account.locked_reason %}|凍結原因:{{ account.locked_reason }}{% endif %}
</div>
{% endif %}
<section class="hero">
<div>
<span class="eyebrow">PYTHON TEST ENVIRONMENT</span>
<h1>歡迎進入遊戲站點模擬首頁</h1>
<p>登入會員:{{ account.username_clean or username }}。本頁只用於驗證會員資料、充值、提款與客服 API。</p>
</div>
<a class="service-entry" href="{{ url_for('customer_service') }}">
<span class="service-icon">🎧</span>
<span><strong>在線客服</strong><small>查詢充值與提款資料</small></span>
</a>
</section>
<section class="account-grid">
<div class="metric-card"><span>UID</span><strong>{{ account.uid or "NA" }}</strong></div>
<div class="metric-card"><span>VIP 等級</span><strong>{{ account.vip_level or "NA" }}</strong></div>
<div class="metric-card"><span>會員餘額</span><strong>{{ account.balance or "0.0000" }}</strong></div>
<div class="metric-card"><span>凍結餘額</span><strong>{{ account.frozen_balance or "0.0000" }}</strong></div>
</section>
<section>
<div class="section-heading"><h2>熱門遊戲</h2><span>畫面模擬,不執行真實投注</span></div>
<div class="game-grid">
{% for game in games %}
<article class="game-card">
<div class="game-icon">{{ game.icon }}</div>
<div><h3>{{ game.name }}</h3><p>{{ game.category }}</p></div>
<button type="button" disabled>模擬遊戲</button>
</article>
{% endfor %}
</div>
</section>
</main>
<a class="floating-service" href="{{ url_for('customer_service') }}">🎧 客服</a>
{% endblock %}
{% block integrations %}
{% include "_chatwoot.html" %}
{% endblock %}
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}登录|{{ app_name }}{% endblock %}
{% block body_class %}login-body{% endblock %}
{% block body %}
<main class="login-card">
<div class="brand-mark">YD</div>
<h1>游戏站点模拟</h1>
<p class="muted">仅使用 members 表内存在的用户名登录,不需要密码。</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
<form method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="username">用户名</label>
<input id="username" name="username" type="text" maxlength="191"
value="{{ username }}" placeholder="输入 username 或 username_clean"
required autofocus>
<button type="submit" class="primary-button">登录测试站点</button>
</form>
<div class="login-divider"><span>或</span></div>
<a
class="guest-service-button"
href="{{ url_for('guest_customer_service') }}"
>
🎧 游客进入客服
</a>
<p class="guest-service-note">
游客只能进行一般咨询,不能查询会员充值、提款或账户资料。
</p>
<p class="security-note">【测试限定】此登录方式不具备正式身份验证能力。</p>
</main>
{% endblock %}
@@ -0,0 +1,4 @@
from app import app
application = app
@@ -0,0 +1,30 @@
CHATWOOT_INBOX_ID=1
MAXKB_TIMEOUT_SEC=120
CHATWOOT_ENABLED=true
CHATWOOT_GUEST_BASE_URL=http://20.191.152.26:7777/cw
MAXKB_ACCESS_TOKEN=***
APP_NAME=API接口系统
CHATWOOT_API_PASSWORD=***
CHATWOOT_WEBSITE_TOKEN=***
MAXKB_BASE_URL=http://pai.pubuli.com:8080
APP_DEBUG=false
CHATWOOT_BASE_URL=http://pai.pubuli.com:3000
CHATWOOT_AGENT_BOT_TOKEN=***
MAXKB_BRIDGE_ENABLED=true
CHATWOOT_GUEST_API_PASSWORD=***
CHATWOOT_GREETING=您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?
CHATWOOT_GUEST_INBOX_ID=1
CHATWOOT_ACCOUNT_ID=1
CHATWOOT_GUEST_API_EMAIL=admin@localhost.local
CHATWOOT_GUEST_API_BASE=http://127.0.0.1:3000
APP_HOST=0.0.0.0
CHATWOOT_GUEST_WEBSITE_TOKEN=***
CHATWOOT_AGENT_BOT_ID=1
FRONTEND_URL=http://20.191.152.26:7777
CHATWOOT_API_EMAIL=yyds@nobody.com
CHATWOOT_GUEST_PROXY_TARGET=http://127.0.0.1:3000
CHATWOOT_IDENTITY_SECRET=***
APP_PORT=7788
CHATWOOT_WIDGET_JWT_SECRET=***
CHATWOOT_DEBUG=false
BUNDLE_APP_CONFIG=/usr/local/bundle
@@ -0,0 +1,35 @@
#!/bin/bash
set -euo pipefail
# test.pubuli.com:7777 空库初始化
sudo docker exec guest-chat bundle exec rails runner "
email = 'admin@localhost.local'
password = 'LocalCw#168'
greeting = '您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?'
site_url = 'http://test.pubuli.com:7777'
account = Account.first || Account.create!(name: '测试客服', locale: 'zh_TW')
user = User.find_by(email: email)
unless user
user = User.new(name: '测试管理员', email: email, password: password, password_confirmation: password)
user.save!
AccountUser.create!(account: account, user: user, role: :administrator)
end
inbox = account.inboxes.first
unless inbox
channel = Channel::WebWidget.create!(
account: account,
website_url: site_url,
widget_color: '#E53935',
welcome_title: '',
welcome_tagline: greeting,
pre_chat_form_enabled: false
)
inbox = Inbox.create!(account: account, name: '49彩票集团客服', channel: channel, greeting_enabled: true, greeting_message: greeting)
end
bot = AgentBot.find_by(name: 'AI 客服') || AgentBot.create!(name: 'AI 客服', outgoing_url: 'http://127.0.0.1:7788/webhook/bot1/chatwoot', account: account)
AgentBotInbox.find_or_create_by!(inbox: inbox, agent_bot: bot, account: account)
puts \"OK account=#{account.id} inbox=#{inbox.id} token=#{inbox.channel.website_token} bot_url=#{bot.outgoing_url}\"
"
@@ -0,0 +1,33 @@
#!/bin/bash
set -euo pipefail
sudo docker exec guest-chat bundle exec rails runner "
email = 'admin@localhost.local'
password = 'LocalCw#168'
greeting = '您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?'
account = Account.first || Account.create!(name: '本地客服', locale: 'zh_TW')
user = User.find_by(email: email)
unless user
user = User.new(name: '本地管理员', email: email, password: password, password_confirmation: password)
user.save!
AccountUser.create!(account: account, user: user, role: :administrator)
end
inbox = account.inboxes.first
unless inbox
channel = Channel::WebWidget.create!(
account: account,
website_url: 'http://www.pubuli.com:7777',
widget_color: '#E53935',
welcome_title: '',
welcome_tagline: greeting,
pre_chat_form_enabled: false
)
inbox = Inbox.create!(account: account, name: '49彩票集团客服', channel: channel, greeting_enabled: true, greeting_message: greeting)
end
bot = AgentBot.find_by(name: 'AI 客服') || AgentBot.create!(name: 'AI 客服', outgoing_url: 'http://127.0.0.1:7788/webhook/bot1/chatwoot', account: account)
AgentBotInbox.find_or_create_by!(inbox: inbox, agent_bot: bot, account: account)
puts \"OK account=#{account.id} inbox=#{inbox.id} token=#{inbox.channel.website_token} bot_url=#{bot.outgoing_url}\"
"
@@ -0,0 +1,28 @@
SECRET_KEY_BASE=61db0140c2e890c28f6737913fb22734365590c7b9501590cd43b108532e13dfe368b1792e6a6bee1ad90dda9dbb42fca39c9d4ef76749c24488a63246ee01ca
FRONTEND_URL=http://127.0.0.1:3000
FORCE_SSL=false
DEFAULT_LOCALE=zh_TW
ENABLE_ACCOUNT_SIGNUP=true
RAILS_ENV=production
NODE_ENV=production
RAILS_LOG_TO_STDOUT=true
RAILS_SERVE_STATIC_FILES=true
INSTALLATION_ENV=docker
BUNDLE_WITHOUT=development:test
EXECJS_RUNTIME=Disabled
REDIS_URL=redis://redis:6379
REDIS_PASSWORD=ziqIkS_An0QDXH9FQ70V7KTz
POSTGRES_HOST=postgres
POSTGRES_USERNAME=postgres
POSTGRES_PASSWORD=xi_E9IhtgSYjC9PoQXZmUnMX
POSTGRES_DATABASE=chatwoot_production
ACTIVE_STORAGE_SERVICE=local
MAILER_SENDER_EMAIL=Chatwoot <noreply@localhost>
SMTP_ADDRESS=
ENABLE_PUSH_RELAY_SERVER=false
# 允许 Agent Bot webhook 打 Docker 内网 guest-cs
SAFE_FETCH_ALLOW_PRIVATE_NETWORK=true
@@ -0,0 +1,12 @@
# 远端部署(对外 7777 → 容器 7788)
# www.pubuli.com / 20.191.152.26
services:
guest-chat:
ports: !override
- "7777:7788"
env_file:
- ./guest-cs.remote.env
environment:
CHATWOOT_GUEST_BASE_URL: http://www.pubuli.com:7777/cw
FRONTEND_URL: http://www.pubuli.com:7777
@@ -0,0 +1,12 @@
# test.pubuli.com 部署(对外 7777 → 容器 7788)
# docker compose -f docker-compose.chat.yaml -f docker-compose.chat.test7777.yaml up -d --force-recreate
services:
guest-chat:
ports: !override
- "7777:7788"
env_file:
- ./guest-cs.test.env
environment:
CHATWOOT_GUEST_BASE_URL: http://20.191.152.26:7777/cw
FRONTEND_URL: http://20.191.152.26:7777
@@ -0,0 +1,78 @@
# 只部署「在线客服聊天框」(production 预编译前端)
# 浏览器入口:http://127.0.0.1:7788/guest-customer-service
#
# docker pull ladioussuapp/guest-chat:latest
# docker compose -f docker-compose.chat.yaml -f docker-compose.reuse-chatwoot-data.yaml up -d
name: guest-chat
x-cw-env: &cw-env
NODE_ENV: production
RAILS_ENV: production
INSTALLATION_ENV: docker
RAILS_SERVE_STATIC_FILES: "true"
services:
guest-chat:
image: ladioussuapp/guest-chat:latest
build:
context: .
dockerfile: Dockerfile.chatwidget
args:
RAILS_ENV: production
NODE_ENV: production
container_name: guest-chat
restart: always
env_file:
- ./chatwoot.env
- ./guest-cs.env
environment:
<<: *cw-env
CHATWOOT_GUEST_PROXY_TARGET: http://127.0.0.1:3000
CHATWOOT_GUEST_API_BASE: http://127.0.0.1:3000
CHATWOOT_GUEST_BASE_URL: http://127.0.0.1:7788/cw
APP_DEBUG: "false"
ports:
# 只对外开聊天框;Chatwoot 后台 3000 不映射
- "127.0.0.1:7788:7788"
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- storage_data:/app/storage
depends_on:
- postgres
- redis
networks:
- guest-chat-net
postgres:
image: pgvector/pgvector:pg16
restart: always
volumes:
- postgres_data:/var/lib/postgresql/data
environment:
POSTGRES_DB: chatwoot_production
POSTGRES_USER: postgres
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-xi_E9IhtgSYjC9PoQXZmUnMX}
networks:
- guest-chat-net
redis:
image: redis:alpine
restart: always
command: ["sh", "-c", "redis-server --requirepass \"$$REDIS_PASSWORD\""]
env_file:
- ./chatwoot.env
volumes:
- redis_data:/data
networks:
- guest-chat-net
volumes:
storage_data:
postgres_data:
redis_data:
networks:
guest-chat-net:
driver: bridge
@@ -0,0 +1,56 @@
APP_NAME=API接口系统
APP_HOST=0.0.0.0
APP_PORT=7788
APP_DEBUG=false
CHATWOOT_ENABLED=true
CHATWOOT_BASE_URL=http://pai.pubuli.com:3000
CHATWOOT_WEBSITE_TOKEN=FZBhe2W4VXSXNT9Ek4etmP3L
CHATWOOT_IDENTITY_SECRET=9Fq7fmzHmButzJdq2mkbaHx5
CHATWOOT_DEBUG=false
CHATWOOT_ACCOUNT_ID=1
CHATWOOT_INBOX_ID=1
CHATWOOT_API_EMAIL=yyds@nobody.com
CHATWOOT_API_PASSWORD=YYDSyyds#168
CHATWOOT_GREETING=您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?
# 浏览器访问仍用 127.0.0.1:7788/cw;容器内代理/API 走 docker 网
CHATWOOT_GUEST_BASE_URL=http://127.0.0.1:7788/cw
CHATWOOT_GUEST_WEBSITE_TOKEN=utiK8hZAiaqmqdp2baN6Pq14
CHATWOOT_GUEST_PROXY_TARGET=http://127.0.0.1:3000
CHATWOOT_GUEST_API_BASE=http://127.0.0.1:3000
CHATWOOT_GUEST_API_EMAIL=admin@localhost.local
CHATWOOT_GUEST_API_PASSWORD=LocalCw#168
CHATWOOT_GUEST_INBOX_ID=1
CHATWOOT_WIDGET_JWT_SECRET=61db0140c2e890c28f6737913fb22734365590c7b9501590cd43b108532e13dfe368b1792e6a6bee1ad90dda9dbb42fca39c9d4ef76749c24488a63246ee01ca
CHATWOOT_AGENT_BOT_ID=1
CHATWOOT_AGENT_BOT_TOKEN=E4rBf9TbMi1C5ShpDtLztGZh
MAXKB_BRIDGE_ENABLED=true
MAXKB_BASE_URL=http://pai.pubuli.com:8080
MAXKB_ACCESS_TOKEN=4a44cfae71e8aee7
MAXKB_TIMEOUT_SEC=120
SECRET_KEY=replace-with-a-long-random-secret
API_KEY=
SESSION_COOKIE_SECURE=false
# 会员库:本机隧道时指向宿主机;无隧道则登录会员功能不可用,游客页仍可用
DB_HOST=host.docker.internal
DB_PORT=3306
DB_USER=root
DB_PASSWORD=z88KmX2VjuiYHep
DB_NAME=member_system
DB_CHARSET=utf8mb4
DB_CONNECT_TIMEOUT=5
MEMBERS_TABLE=members
WITHDRAW_TABLE=withdraw_records
WITHDRAW_REQUEST_TABLE=withdraw_request_records
DEPOSIT_TABLE=deposit_records
READ_ONLY_MODE=true
AUTO_INIT_SIM_TABLES=false
DEFAULT_ACTIVITY_ID=ad-x1234
DEPOSIT_SEARCH_HOURS=24
MAX_ORDERS=50
@@ -0,0 +1,3 @@
# 远端 20.191.152.26 / www.pubuli.com:7777 专用(bootstrap 后 token)
CHATWOOT_GUEST_WEBSITE_TOKEN=j1UAjqBgZMWQ8WfczgJSymQx
CHATWOOT_GUEST_BASE_URL=http://www.pubuli.com:7777/cw
@@ -0,0 +1,4 @@
# test.pubuli.com:7777 专用(bootstrap 后 token)
CHATWOOT_GUEST_WEBSITE_TOKEN=j1UAjqBgZMWQ8WfczgJSymQx
CHATWOOT_GUEST_BASE_URL=http://20.191.152.26:7777/cw
CHATWOOT_AGENT_BOT_TOKEN=SrQpnTgYzXq4i17nVQhemxjT
+69
View File
@@ -0,0 +1,69 @@
# test.pubuli.com:7777 部署说明
目标:把本地 Docker 版 `http://127.0.0.1:7788/guest-customer-service` 部署到
**http://test.pubuli.com:7777/guest-customer-service**
## 现状
| 项目 | 说明 |
|------|------|
| DNS | `test.pubuli.com` → **100.31.73.254**(与 `www.pubuli.com` / 20.191.152.26 不同) |
| 当前 7777 | 旧 **Werkzeug** Python 直跑,不是 Docker |
| SSH | 100.31.73.254 **仅允许公钥**,密码登录被拒 |
## 在 test 服务器部署
### 1. SSH 登录(需有密钥权限)
```bash
ssh root@100.31.73.254
# 或你们实际账号
```
### 2. 上传 guest-cs-stack 目录
需包含:
- `docker-compose.chat.yaml`
- `docker-compose.chat.test7777.yaml`
- `chatwoot.env`
- `guest-cs.env`
- `guest-cs.test.env`
- `deploy-test7777.sh`
- `bootstrap-chatwoot-test.sh`
### 3. 一键部署
```bash
cd guest-cs-stack
chmod +x deploy-test7777.sh bootstrap-chatwoot-test.sh
./deploy-test7777.sh
```
空库首次需 bootstrap,把输出的 `token=` 写入 `guest-cs.test.env` 的 `CHATWOOT_GUEST_WEBSITE_TOKEN`,再:
```bash
docker compose -f docker-compose.chat.yaml -f docker-compose.chat.test7777.yaml up -d --force-recreate guest-chat
```
### 4. 验证
```bash
curl -I http://127.0.0.1:7777/guest-customer-service
# 应看到 Server: gunicorn(不是 Werkzeug)
```
浏览器:http://test.pubuli.com:7777/guest-customer-service(Ctrl+F5)
## 镜像
```bash
docker pull ladioussuapp/guest-chat:latest
```
Hub:https://hub.docker.com/r/ladioussuapp/guest-chat
## 注意
- 只 `docker pull` 不会更新正在跑的旧 Python,必须 `./deploy-test7777.sh` 停旧进程并 `--force-recreate`
- MaxKB 仍走 `pai.pubuli.com:8080`(guest-cs.env 已配置)
- 若无法 SSH 到 100.31.73.254,需提供 **SSH 私钥** 或请有权限的人在服务器执行上述命令
+39
View File
@@ -0,0 +1,39 @@
# 基于官方 Chatwoot production 镜像(构建时已 assets:precompile)
# 再叠 Flask;运行时 RAILS_ENV=production,不跑 development / vite
FROM chatwoot/chatwoot:v4.16.2
USER root
RUN apk add --no-cache python3 py3-pip py3-virtualenv
WORKDIR /opt/guest-cs
RUN python3 -m venv /opt/guest-cs/.venv
ENV PATH="/opt/guest-cs/.venv/bin:$PATH"
COPY flask/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY flask/ .
COPY start-all.sh /usr/local/bin/start-all.sh
RUN chmod +x /usr/local/bin/start-all.sh \
&& mkdir -p /app/storage \
&& sed -i 's/\r$//' /usr/local/bin/start-all.sh \
&& test -d /app/public/assets
# 与 docker-compose.production.yaml / 官方 Dockerfile 生产参数对齐
ENV RAILS_ENV=production \
NODE_ENV=production \
INSTALLATION_ENV=docker \
RAILS_SERVE_STATIC_FILES=true \
BUNDLE_WITHOUT=development:test \
EXECJS_RUNTIME=Disabled \
APP_HOST=0.0.0.0 \
APP_PORT=7788 \
APP_DEBUG=false \
CHATWOOT_GUEST_PROXY_TARGET=http://127.0.0.1:3000 \
CHATWOOT_GUEST_API_BASE=http://127.0.0.1:3000
EXPOSE 3000 7788
WORKDIR /app
ENTRYPOINT ["/usr/local/bin/start-all.sh"]
+51
View File
@@ -0,0 +1,51 @@
# 只打包「游客在线客服聊天框」
# 前端:官方 Chatwoot production 镜像(构建期已 assets:precompile / Vite 编译)
# 运行:RAILS_ENV=production,不跑 development / vite / pnpm / bundle install
#
# 对照:https://github.com/chatwoot/chatwoot/blob/v4.16.2/docker-compose.production.yaml
# https://github.com/chatwoot/chatwoot/blob/v4.16.2/docker/Dockerfile
FROM chatwoot/chatwoot:v4.16.2
USER root
# 确认生产静态资源已存在(官方镜像 precompile 产物)
RUN test -d /app/public/assets \
&& test -d /app/public/vite/assets \
&& test "$(ls -A /app/public/vite/assets 2>/dev/null | wc -l)" -gt 0 \
&& echo "[ok] Chatwoot production frontend assets present"
RUN apk add --no-cache python3 py3-pip py3-virtualenv
WORKDIR /opt/guest-cs
RUN python3 -m venv /opt/guest-cs/.venv
ENV PATH="/opt/guest-cs/.venv/bin:$PATH"
COPY flask/requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY flask/ .
COPY start-chat.sh /usr/local/bin/start-chat.sh
RUN chmod +x /usr/local/bin/start-chat.sh \
&& mkdir -p /app/storage \
&& sed -i 's/\r$//' /usr/local/bin/start-chat.sh
# 与 docker-compose.production.yaml 对齐;聊天框经 7788 代理 widget
ENV RAILS_ENV=production \
NODE_ENV=production \
INSTALLATION_ENV=docker \
RAILS_SERVE_STATIC_FILES=true \
BUNDLE_WITHOUT=development:test \
EXECJS_RUNTIME=Disabled \
APP_HOST=0.0.0.0 \
APP_PORT=7788 \
APP_DEBUG=false \
CHATWOOT_GUEST_PROXY_TARGET=http://127.0.0.1:3000 \
CHATWOOT_GUEST_API_BASE=http://127.0.0.1:3000 \
CHATWOOT_GUEST_BASE_URL=http://127.0.0.1:7788/cw
# 对外产品口是 7788(聊天框);3000 仅容器内给 widget API
EXPOSE 7788
WORKDIR /app
ENTRYPOINT ["/usr/local/bin/start-chat.sh"]
+4
View File
@@ -0,0 +1,4 @@
# 基于官方 Chatwoot 镜像打本地标签,便于 docker save 打包
FROM chatwoot/chatwoot:v4.16.2
LABEL org.opencontainers.image.title="guest-cs-chatwoot"
LABEL org.opencontainers.image.description="Chatwoot for guest customer service stack"
+37
View File
@@ -0,0 +1,37 @@
# 在线客服聊天框(production)
只打包图上那个聊天框,前端用官方 Chatwoot **production 预编译**资源(`assets:precompile` / Vite),不跑 development。
## Pull
```bash
docker pull ladioussuapp/guest-chat:latest
```
Hub:https://hub.docker.com/r/ladioussuapp/guest-chat
## 启动(复用本机旧 Chatwoot 数据)
```powershell
cd F:\AI\guest-cs-stack
docker compose -f docker-compose.chat.yaml -f docker-compose.chat-data.yaml up -d
```
## 远端 test.pubuli.com(对外 7788)
详见 [DEPLOY-TEST.md](./DEPLOY-TEST.md)
```bash
docker pull ladioussuapp/guest-chat:latest
docker compose -f docker-compose.chat.yaml -f docker-compose.chat.test.yaml up -d --force-recreate
```
入口:http://test.pubuli.com:7788/guest-customer-service
**注意:** test 服务器目前 7788 仍是旧 Werkzeug,必须先 SSH 上去停旧进程再部署 Docker。
## 入口
- http://127.0.0.1:7788/guest-customer-service
对外只开 `7788`;Chatwoot 后台 `3000` 仅容器内给 widget API,不映射到宿主机。
@@ -0,0 +1,8 @@
venv/
__pycache__/
*.pyc
*.pyo
.env
.maxkb_cw_sessions.json
.pytest_cache/
*.md
@@ -0,0 +1,38 @@
APP_NAME=API接口系統
APP_HOST=0.0.0.0
APP_PORT=7788
APP_DEBUG=false
CHATWOOT_ENABLED=true
CHATWOOT_BASE_URL=http://pai.pubuli.com
CHATWOOT_WEBSITE_TOKEN=FZBhe2W4VXSXNT9Ek4etmP3L
CHATWOOT_IDENTITY_SECRET=replace-with-chatwoot-hmac-token
CHATWOOT_DEBUG=false
CHATWOOT_ACCOUNT_ID=1
CHATWOOT_INBOX_ID=1
CHATWOOT_API_EMAIL=
CHATWOOT_API_PASSWORD=
CHATWOOT_GREETING=您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?
SECRET_KEY=replace-with-a-long-random-secret
API_KEY=
SESSION_COOKIE_SECURE=false
DB_HOST=database-yd-ai-reader.ckfyc24ggq6w.us-east-1.rds.amazonaws.com
DB_PORT=3306
DB_USER=root
DB_PASSWORD=replace-with-db-password
DB_NAME=member_system
DB_CHARSET=utf8mb4
DB_CONNECT_TIMEOUT=5
MEMBERS_TABLE=members
WITHDRAW_TABLE=withdraw_records
WITHDRAW_REQUEST_TABLE=withdraw_request_records
DEPOSIT_TABLE=deposit_records
READ_ONLY_MODE=true
AUTO_INIT_SIM_TABLES=false
DEFAULT_ACTIVITY_ID=ad-x1234
DEPOSIT_SEARCH_HOURS=24
MAX_ORDERS=50
@@ -0,0 +1,17 @@
FROM python:3.13-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
APP_HOST=0.0.0.0 \
APP_PORT=7788
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 7788
CMD ["gunicorn", "--bind", "0.0.0.0:7788", "--workers", "2", "--threads", "4", "--timeout", "120", "wsgi:application"]
+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)
@@ -0,0 +1,256 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
import uuid
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from config import CONFIG
_AUTH_CACHE: dict[str, Any] = {"headers": None, "expires_at": 0.0}
DEFAULT_GREETING = (
"您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?"
)
class ChatwootGuestError(RuntimeError):
pass
def _request(
method: str,
path: str,
headers: dict[str, str],
payload: dict[str, Any] | None = None,
) -> tuple[int, dict[str, str], Any]:
body = None
req_headers = dict(headers)
if payload is not None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req_headers["Content-Type"] = "application/json"
req_headers["Accept"] = "application/json"
api_base = (
CONFIG.chatwoot_guest_api_base or CONFIG.chatwoot_base_url
).rstrip("/")
request = Request(
f"{api_base}{path}",
data=body,
headers=req_headers,
method=method,
)
try:
with urlopen(request, timeout=20) as response:
raw = response.read().decode("utf-8", "replace")
data = json.loads(raw) if raw else {}
return response.status, dict(response.headers.items()), data
except HTTPError as exc:
raw = exc.read().decode("utf-8", "replace")
try:
data = json.loads(raw) if raw else {}
except json.JSONDecodeError:
data = {"message": raw[:300]}
raise ChatwootGuestError(
f"Chatwoot {method} {path} 失敗:{exc.code} {data}"
) from exc
except URLError as exc:
raise ChatwootGuestError(f"無法連線 Chatwoot:{exc.reason}") from exc
def _login_headers(*, force: bool = False) -> dict[str, str]:
email = CONFIG.chatwoot_guest_api_email or CONFIG.chatwoot_api_email
password = CONFIG.chatwoot_guest_api_password or CONFIG.chatwoot_api_password
if not email or not password:
raise ChatwootGuestError("未設定 Chatwoot API 帳號密碼")
now = time.time()
cached = _AUTH_CACHE.get("headers")
if not force and cached and now < float(_AUTH_CACHE.get("expires_at") or 0):
return cached
status, headers, _data = _request(
"POST",
"/auth/sign_in",
{},
{
"email": email,
"password": password,
},
)
if status >= 400:
raise ChatwootGuestError("Chatwoot 登入失敗")
token = headers.get("access-token") or headers.get("Access-Token") or ""
client = headers.get("client") or headers.get("Client") or ""
uid = headers.get("uid") or headers.get("Uid") or ""
if not token or not client or not uid:
raise ChatwootGuestError("Chatwoot 登入未回傳 token")
auth_headers = {
"access-token": token,
"client": client,
"uid": uid,
"token-type": "Bearer",
}
_AUTH_CACHE["headers"] = auth_headers
_AUTH_CACHE["expires_at"] = now + 300
return auth_headers
def _dig(data: Any, *keys: str) -> Any:
current = data
for key in keys:
if not isinstance(current, dict):
return None
current = current.get(key)
return current
def _source_id(*payloads: Any) -> str:
for payload in payloads:
if not isinstance(payload, dict):
continue
body = payload.get("payload") or payload
candidates = [
_dig(body, "contact_inbox", "source_id"),
_dig(payload, "contact_inbox", "source_id"),
_dig(
payload,
"last_non_activity_message",
"conversation",
"contact_inbox",
"source_id",
),
]
messages = body.get("messages") if isinstance(body, dict) else None
if isinstance(messages, list) and messages:
candidates.append(
_dig(
messages[0],
"conversation",
"contact_inbox",
"source_id",
)
)
for value in candidates:
if value:
return str(value)
return ""
def _contact_id(payload: Any) -> int | None:
if not isinstance(payload, dict):
return None
body = payload.get("payload") or payload
if not isinstance(body, dict):
return None
contact = body.get("contact") or body
if not isinstance(contact, dict):
return None
contact_id = contact.get("id")
return int(contact_id) if contact_id is not None else None
def create_guest_conversation() -> dict[str, Any]:
"""建立新的遊客聯絡人與對話,讓 widget 直接進入聊天室。"""
identifier = f"guest-{uuid.uuid4().hex[:16]}"
headers = _login_headers()
account_id = CONFIG.chatwoot_account_id
try:
return _create_with_headers(identifier, headers, account_id)
except ChatwootGuestError as exc:
if "401" not in str(exc) and "403" not in str(exc):
raise
headers = _login_headers(force=True)
return _create_with_headers(identifier, headers, account_id)
def _create_with_headers(
identifier: str,
headers: dict[str, str],
account_id: int,
) -> dict[str, Any]:
_, _, contact_body = _request(
"POST",
f"/api/v1/accounts/{account_id}/contacts",
headers,
{
"inbox_id": CONFIG.chatwoot_guest_inbox_id or CONFIG.chatwoot_inbox_id,
"name": "遊客",
"identifier": identifier,
"custom_attributes": {"visitor_type": "guest"},
},
)
contact_id = _contact_id(contact_body)
if not contact_id:
raise ChatwootGuestError("Chatwoot 未回傳 contact id")
source_id = _source_id(contact_body)
if not source_id:
raise ChatwootGuestError("Chatwoot 未回傳 source_id")
# 必須帶同一個 source_id。只傳 contact_id + inbox_id 時,
# ContactInboxBuilder 會再產生一組 WebWidget UUID,
# 歡迎語會掛在新 session,widget JWT 對不到就變成空白對話。
# 歡迎圖不走 Chatwoot 附件,否則 widget 會顯示檔名與「下載」。
_, _, conversation = _request(
"POST",
f"/api/v1/accounts/{account_id}/conversations",
headers,
{
"inbox_id": CONFIG.chatwoot_guest_inbox_id or CONFIG.chatwoot_inbox_id,
"contact_id": contact_id,
"source_id": source_id,
"status": "open",
"message": {
"content": CONFIG.chatwoot_greeting or DEFAULT_GREETING,
"message_type": "outgoing",
},
},
)
conversation_id = conversation.get("id") if isinstance(conversation, dict) else None
return {
"identifier": identifier,
"contact_id": contact_id,
"conversation_id": conversation_id,
"source_id": source_id,
"widget_token": make_widget_token(source_id),
"name": "遊客",
}
def make_widget_token(source_id: str) -> str:
"""產生 Chatwoot widget 的 cw_conversation JWT。"""
secret = CONFIG.chatwoot_widget_jwt_secret
if not secret:
raise ChatwootGuestError("未設定 CHATWOOT_WIDGET_JWT_SECRET")
now = int(time.time())
header = _b64url(json.dumps({"alg": "HS256"}, separators=(",", ":")).encode())
payload = _b64url(
json.dumps(
{
"source_id": source_id,
"inbox_id": CONFIG.chatwoot_guest_inbox_id or CONFIG.chatwoot_inbox_id,
"iat": now,
"exp": now + 60 * 60 * 24 * 180,
},
separators=(",", ":"),
).encode()
)
signing_input = f"{header}.{payload}".encode()
signature = hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).digest()
return f"{header}.{payload}.{_b64url(signature)}"
def _b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
@@ -0,0 +1,724 @@
from __future__ import annotations
from urllib.error import HTTPError, URLError
from urllib.parse import urlencode
from urllib.request import Request, urlopen
from flask import Response, request
from config import CONFIG
HOP_BY_HOP = {
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailers",
"transfer-encoding",
"upgrade",
"host",
"content-length",
}
INJECT_SCRIPT = """
<script>
(function () {
var CABLE_ORIGIN = "__CABLE_WS_ORIGIN__";
var NativeWS = window.WebSocket;
if (!NativeWS || window.__ydCablePatched) {
return;
}
window.__ydCablePatched = true;
function WrappedWS(url, protocols) {
var next = String(url || "");
if (next.indexOf("/cable") !== -1 && next.indexOf(CABLE_ORIGIN) !== 0) {
try {
var parsed = new URL(next, window.location.href);
next = CABLE_ORIGIN + "/cable" + (parsed.search || "") + (parsed.hash || "");
} catch (err) {
next = CABLE_ORIGIN + "/cable";
}
}
if (protocols === undefined) {
return new NativeWS(next);
}
return new NativeWS(next, protocols);
}
WrappedWS.prototype = NativeWS.prototype;
WrappedWS.CONNECTING = NativeWS.CONNECTING;
WrappedWS.OPEN = NativeWS.OPEN;
WrappedWS.CLOSING = NativeWS.CLOSING;
WrappedWS.CLOSED = NativeWS.CLOSED;
window.WebSocket = WrappedWS;
})();
</script>
<style>
a[href*="chatwoot.com"],
a[href*="Chatwoot"],
.branding--container,
.branding {
display: none !important;
}
html, body, #app, .conversation-wrap, .messages-wrap {
background: #f5f6f8 !important;
}
header {
background: #e31c23 !important;
color: #fff !important;
border: 0 !important;
box-shadow: none !important;
min-height: 64px !important;
display: flex !important;
align-items: center !important;
justify-content: space-between !important;
gap: 8px !important;
padding: 10px 12px !important;
}
header [data-yd-right] {
display: inline-flex !important;
align-items: center !important;
gap: 8px !important;
margin-left: auto !important;
}
header, header * {
color: #fff !important;
}
header button.close-button,
header button[data-yd-close],
header button[data-yd-exit] {
display: inline-flex !important;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
padding: 0;
font-size: 26px;
line-height: 1;
color: #fff !important;
background: transparent !important;
border: 0;
cursor: pointer;
}
header button[data-yd-exit] svg {
width: 20px;
height: 20px;
display: block;
}
.agent-message .chat-bubble,
.agent-message-wrap .chat-bubble,
.prose-bubble {
background: #fff !important;
color: #222 !important;
border-radius: 14px !important;
border: 0 !important;
box-shadow: 0 1px 2px rgba(0,0,0,.06) !important;
}
.user-message .chat-bubble,
.user-message-wrap .chat-bubble {
background: #ffe5e5 !important;
color: #222 !important;
border-radius: 14px !important;
border: 0 !important;
}
.chat-message--input,
.chat-footer,
footer {
background: #fff !important;
border-top: 1px solid #eee !important;
}
textarea, input[type="text"] {
background: #f2f3f5 !important;
color: #222 !important;
border-radius: 999px !important;
border: 0 !important;
}
[data-yd-panel] {
margin: 10px 0 4px;
padding: 0;
font-size: 13px;
color: #333;
width: 100%;
max-width: 320px;
}
[data-yd-panel] .yd-hint {
margin: 0 0 10px;
color: #666;
}
[data-yd-panel] .yd-grid {
display: grid;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px;
}
[data-yd-panel] .yd-grid button {
display: flex;
align-items: center;
gap: 6px;
min-height: 42px;
padding: 8px 10px;
border: 1px solid #e6e6e6;
border-radius: 10px;
background: #fff;
color: #333;
text-align: left;
cursor: pointer;
font-size: 12px;
}
[data-yd-panel] .yd-grid button span {
display: inline-grid;
place-items: center;
width: 22px;
height: 22px;
border-radius: 6px;
color: #fff;
font-size: 11px;
font-weight: 700;
}
[data-yd-panel] .yd-faq-title {
margin: 14px 0 6px;
font-size: 14px;
font-weight: 800;
}
[data-yd-panel] .yd-faq button {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
padding: 12px 2px;
border: 0;
border-bottom: 1px solid #eee;
background: transparent;
color: #333;
text-align: left;
cursor: pointer;
}
[data-yd-send] {
display: inline-grid !important;
place-items: center;
width: 36px;
height: 36px;
margin-left: 8px;
border: 0;
border-radius: 999px;
color: #fff;
background: #e31c23;
font-size: 16px;
cursor: pointer;
}
[data-yd-powered] {
display: block;
padding: 4px 0 8px;
color: #b0b0b0;
font-size: 11px;
text-align: center;
}
</style>
<script>
(function () {
var PROMO_SRC = "/static/guest_promo.png?v=49";
var GREET_MARK = "欢迎光临【49彩票集团】";
var QUICK = [
{ label: "充值问题", text: "我想咨询充值问题", color: "#3b82f6", icon: "充" },
{ label: "提款问题", text: "我想咨询提款问题", color: "#22c55e", icon: "提" },
{ label: "优惠活动", text: "我想咨询优惠活动", color: "#f59e0b", icon: "惠" },
{ label: "账户问题", text: "我想咨询账户问题", color: "#8b5cf6", icon: "户" },
{ label: "游戏问题", text: "我想咨询游戏问题", color: "#a855f7", icon: "游" },
{ label: "代理合作", text: "我想咨询代理合作", color: "#f97316", icon: "代" },
{ label: "常见问题", text: "常见问题", color: "#ef4444", icon: "?" },
{ label: "转人工客服", text: "转人工客服", color: "#e31c23", icon: "人" }
];
var FAQ = [
"提款未到账怎么办?",
"如何修改登录密码?",
"如何绑定银行卡?"
];
function norm(text) {
return String(text || "").replace(/\\s+/g, "");
}
function hideChatwootBrand() {
var nodes = document.querySelectorAll("a, p, span, div, small");
for (var i = 0; i < nodes.length; i += 1) {
var el = nodes[i];
var text = norm(el.textContent);
if (
text.indexOf("Chatwoot") === -1 &&
text.indexOf("chatwoot") === -1 &&
text.indexOf("由Chatwoot支援") === -1
) {
continue;
}
if (text.length > 40) {
continue;
}
el.style.setProperty("display", "none", "important");
}
}
function hideStartButtons() {
var nodes = document.querySelectorAll("button, a, .button, [role='button']");
for (var i = 0; i < nodes.length; i += 1) {
var el = nodes[i];
if (
norm(el.textContent).indexOf("開始對話") === -1 &&
norm(el.textContent).indexOf("开始对话") === -1
) {
continue;
}
el.style.setProperty("display", "none", "important");
el.style.setProperty("visibility", "hidden", "important");
}
}
function hideFileChrome() {
var nodes = document.querySelectorAll("a, button, div, span, p");
for (var i = 0; i < nodes.length; i += 1) {
var el = nodes[i];
var text = el.textContent || "";
if (
text.indexOf("guest_promo.png") === -1 &&
!(text.indexOf("下載") !== -1 && text.indexOf(".png") !== -1)
) {
continue;
}
var box = el.closest(".file") || el.parentElement;
if (box) {
box.style.setProperty("display", "none", "important");
}
}
}
function findGreetingNode() {
var nodes = document.querySelectorAll("p, div, span");
for (var i = 0; i < nodes.length; i += 1) {
var el = nodes[i];
if ((el.textContent || "").indexOf(GREET_MARK) === -1) {
continue;
}
if (el.querySelector && el.querySelector("p, div")) {
continue;
}
return el;
}
return null;
}
function closeWidget() {
try {
window.parent.postMessage(
"chatwoot-widget:" + JSON.stringify({ event: "closeWindow" }),
"*"
);
} catch (err) {}
}
function exitAndNewChat() {
try {
if (
window.parent &&
typeof window.parent.exitGuestChatAndStartNew === "function"
) {
window.parent.exitGuestChatAndStartNew();
return;
}
if (
window.parent &&
typeof window.parent.openGuestChatNewConversation === "function"
) {
window.parent.openGuestChatNewConversation();
return;
}
} catch (err) {}
try {
window.parent.postMessage(
"chatwoot-widget:" +
JSON.stringify({ event: "guestExitNewChat" }),
"*"
);
} catch (err2) {}
}
var EXIT_ICON =
'<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
'<path d="M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>' +
'<path d="M14 12H21" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/>' +
'<path d="M18 8l4 4-4 4" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>' +
"</svg>";
function fillAndSend(text) {
var ta = document.querySelector("textarea");
if (!ta) {
return;
}
var setter = Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
"value"
);
if (setter && setter.set) {
setter.set.call(ta, text);
} else {
ta.value = text;
}
ta.dispatchEvent(new Event("input", { bubbles: true }));
ta.dispatchEvent(
new KeyboardEvent("keydown", { key: "Enter", code: "Enter", bubbles: true })
);
}
function tuneHeader() {
var header = document.querySelector("header");
if (!header) {
return;
}
header.style.setProperty("background", "#e31c23", "important");
header.style.setProperty("color", "#fff", "important");
header.style.setProperty("display", "flex", "important");
header.style.setProperty("align-items", "center", "important");
header.style.setProperty("justify-content", "space-between", "important");
var oldTitles = header.querySelectorAll("h1, h2, p, span, strong, div");
for (var ot = 0; ot < oldTitles.length; ot += 1) {
var node = oldTitles[ot];
if (
node.closest("[data-yd-title]") ||
node.closest("[data-yd-online]") ||
node.closest("[data-yd-right]") ||
node.getAttribute("data-yd-title") ||
node.getAttribute("data-yd-online") ||
node.getAttribute("data-yd-right") ||
node.tagName === "BUTTON" ||
(node.querySelector &&
node.querySelector(
"button, img, svg, [data-yd-title], [data-yd-online], [data-yd-right]"
))
) {
continue;
}
var t = norm(node.textContent);
if (!t || t === "×" || t === "在线" || t.indexOf("7x24") !== -1 || t === "在线客服") {
continue;
}
if (t.length <= 30) {
node.style.setProperty("display", "none", "important");
}
}
if (!header.querySelector("[data-yd-title]")) {
var box = document.createElement("div");
box.setAttribute("data-yd-title", "1");
box.style.cssText =
"display:flex;flex-direction:column;gap:2px;line-height:1.2;color:#fff;flex:1;";
box.innerHTML =
'<strong style="font-size:16px;color:#fff;">在线客服</strong>' +
'<span style="font-size:11px;opacity:.92;color:#fff;">7x24小时为您服务</span>';
header.insertBefore(box, header.firstChild);
}
var right = header.querySelector("[data-yd-right]");
if (!right) {
right = document.createElement("div");
right.setAttribute("data-yd-right", "1");
right.style.cssText =
"display:inline-flex;align-items:center;gap:8px;margin-left:auto;";
header.appendChild(right);
}
var online = header.querySelector("[data-yd-online]");
if (!online) {
online = document.createElement("span");
online.setAttribute("data-yd-online", "1");
online.style.cssText =
"display:inline-flex;align-items:center;gap:5px;font-size:12px;color:#fff;white-space:nowrap;";
online.innerHTML =
'<i style="width:8px;height:8px;border-radius:999px;background:#22c55e;display:inline-block;"></i>在线';
}
if (online.parentNode !== right) {
right.appendChild(online);
}
// 隱藏原生關閉鈕,改用「退出並開新聊天」圖示
var buttons = header.querySelectorAll("button");
for (var i = 0; i < buttons.length; i += 1) {
var btn = buttons[i];
if (btn.getAttribute("data-yd-exit")) {
continue;
}
btn.style.setProperty("display", "none", "important");
}
var exitBtn = header.querySelector("button[data-yd-exit]");
if (!exitBtn) {
exitBtn = document.createElement("button");
exitBtn.type = "button";
exitBtn.setAttribute("data-yd-exit", "1");
exitBtn.setAttribute("aria-label", "退出并开启新对话");
exitBtn.title = "退出并开启新对话";
exitBtn.innerHTML = EXIT_ICON;
exitBtn.addEventListener("click", function (event) {
event.preventDefault();
event.stopPropagation();
exitAndNewChat();
});
right.appendChild(exitBtn);
}
exitBtn.style.setProperty("display", "inline-flex", "important");
if (exitBtn.parentNode !== right) {
right.appendChild(exitBtn);
} else if (online.nextSibling !== exitBtn) {
right.appendChild(exitBtn);
}
}
function ensureQuickPanel() {
var greet = findGreetingNode();
if (!greet || !greet.parentNode) {
return;
}
var panel = document.querySelector("[data-yd-panel]");
if (!panel) {
panel = document.createElement("div");
panel.setAttribute("data-yd-panel", "1");
var hint = document.createElement("p");
hint.className = "yd-hint";
hint.textContent = "您可以点击下方快捷入口获取帮助";
panel.appendChild(hint);
var grid = document.createElement("div");
grid.className = "yd-grid";
QUICK.forEach(function (item) {
var b = document.createElement("button");
b.type = "button";
b.innerHTML =
'<span style="background:' +
item.color +
'">' +
item.icon +
"</span>" +
item.label;
b.addEventListener("click", function () {
fillAndSend(item.text);
});
grid.appendChild(b);
});
panel.appendChild(grid);
var faqTitle = document.createElement("div");
faqTitle.className = "yd-faq-title";
faqTitle.textContent = "猜你想问";
panel.appendChild(faqTitle);
var faq = document.createElement("div");
faq.className = "yd-faq";
FAQ.forEach(function (q) {
var b = document.createElement("button");
b.type = "button";
b.innerHTML = "<span>" + q + "</span><span>›</span>";
b.addEventListener("click", function () {
fillAndSend(q);
});
faq.appendChild(b);
});
panel.appendChild(faq);
}
// 固定插在歡迎語正下方(同氣泡內、緊接文字後)
if (greet.nextSibling !== panel) {
greet.parentNode.insertBefore(panel, greet.nextSibling);
}
}
function ensureComposer() {
var ta = document.querySelector("textarea");
if (!ta || !ta.parentNode) {
return;
}
if (!ta.placeholder || ta.placeholder.indexOf("訊息") !== -1 || ta.placeholder.indexOf("请输入信息") !== -1) {
ta.placeholder = "请输入您的问题...";
}
if (!document.querySelector("[data-yd-send]")) {
var send = document.createElement("button");
send.type = "button";
send.setAttribute("data-yd-send", "1");
send.setAttribute("aria-label", "发送");
send.textContent = "➤";
send.addEventListener("click", function () {
fillAndSend(ta.value || "");
});
ta.parentNode.appendChild(send);
}
var footer = document.querySelector(".chat-message--input, .chat-footer, footer") || ta.parentNode.parentNode;
var powered = document.querySelector("[data-yd-powered]");
if (footer && !powered) {
powered = document.createElement("div");
powered.setAttribute("data-yd-powered", "1");
footer.appendChild(powered);
}
if (powered) {
powered.textContent = "49彩票AI智能客服";
}
}
function paintTheme() {
var userBubbles = document.querySelectorAll(
".user-message .chat-bubble, .user-message-wrap .chat-bubble"
);
for (var i = 0; i < userBubbles.length; i += 1) {
userBubbles[i].style.setProperty("background", "#ffe5e5", "important");
userBubbles[i].style.setProperty("color", "#222", "important");
}
var agentBubbles = document.querySelectorAll(
".agent-message .chat-bubble, .agent-message-wrap .chat-bubble"
);
for (var j = 0; j < agentBubbles.length; j += 1) {
agentBubbles[j].style.setProperty("background", "#fff", "important");
agentBubbles[j].style.setProperty("color", "#222", "important");
}
ensureComposer();
ensureQuickPanel();
}
function paintPromo() {
hideStartButtons();
hideFileChrome();
hideChatwootBrand();
tuneHeader();
paintTheme();
if (document.querySelector("img[data-yd-promo]")) {
return;
}
var greet = findGreetingNode();
if (!greet || !greet.parentNode) {
return;
}
var img = document.createElement("img");
img.setAttribute("data-yd-promo", "1");
img.src = PROMO_SRC;
img.alt = "";
img.style.cssText =
"display:block;width:100%;max-width:280px;border-radius:12px;margin:0 0 8px;";
greet.parentNode.insertBefore(img, greet);
}
setInterval(paintPromo, 250);
document.addEventListener("DOMContentLoaded", paintPromo);
// ActionCable 若仍失敗,輪詢訊息補上坐席回覆
var lastMsgKey = "";
function pollAgentMessages() {
try {
var params = new URLSearchParams(window.location.search);
var token = params.get("website_token");
var cw = params.get("cw_conversation");
if (!token || !cw) {
return;
}
fetch(
"/api/v1/widget/messages?website_token=" +
encodeURIComponent(token) +
"&cw_conversation=" +
encodeURIComponent(cw),
{ credentials: "same-origin" }
)
.then(function (response) {
return response.json();
})
.then(function (data) {
var list = [];
if (data && Array.isArray(data.payload)) {
list = data.payload;
} else if (data && data.payload && Array.isArray(data.payload.messages)) {
list = data.payload.messages;
} else if (Array.isArray(data)) {
list = data;
} else if (data && Array.isArray(data.messages)) {
list = data.messages;
}
if (!list.length) {
return;
}
var maxId = 0;
for (var i = 0; i < list.length; i += 1) {
var id = Number((list[i] && list[i].id) || 0);
if (id > maxId) {
maxId = id;
}
}
var key = String(maxId) + ":" + String(list.length);
if (lastMsgKey && key !== lastMsgKey) {
window.location.reload();
return;
}
lastMsgKey = key;
})
.catch(function () {});
} catch (err) {}
}
setInterval(pollAgentMessages, 2500);
setTimeout(pollAgentMessages, 1200);
})();
</script>
"""
def _cable_ws_origin() -> str:
from urllib.parse import urlparse
parsed = urlparse(CONFIG.chatwoot_guest_proxy_target or "http://127.0.0.1:3000")
scheme = "wss" if parsed.scheme == "https" else "ws"
host = parsed.netloc or "127.0.0.1:3000"
return f"{scheme}://{host}"
def _inject_script() -> str:
return INJECT_SCRIPT.replace("__CABLE_WS_ORIGIN__", _cable_ws_origin())
def _forward_headers() -> dict[str, str]:
headers: dict[str, str] = {}
for key, value in request.headers.items():
if key.lower() in HOP_BY_HOP:
continue
headers[key] = value
return headers
def proxy_chatwoot(subpath: str) -> Response:
target = CONFIG.chatwoot_guest_proxy_target.rstrip("/")
query = urlencode(request.args, doseq=True)
url = f"{target}/{subpath.lstrip('/')}"
if query:
url = f"{url}?{query}"
body = request.get_data() if request.method not in {"GET", "HEAD"} else None
forwarded = Request(
url,
data=body,
headers=_forward_headers(),
method=request.method,
)
try:
with urlopen(forwarded, timeout=30) as upstream:
payload = upstream.read()
status = upstream.status
content_type = upstream.headers.get("Content-Type", "")
response_headers = [
(key, value)
for key, value in upstream.headers.items()
if key.lower() not in HOP_BY_HOP
]
except HTTPError as exc:
payload = exc.read()
status = exc.code
content_type = exc.headers.get("Content-Type", "") if exc.headers else ""
response_headers = []
except URLError as exc:
return Response(
f"无法连线本机 Chatwoot:{exc.reason}",
status=502,
mimetype="text/plain; charset=utf-8",
)
if "text/html" in content_type.lower() and payload:
html = payload.decode("utf-8", "replace")
inject = _inject_script()
if "</head>" in html:
html = html.replace("</head>", inject + "</head>", 1)
elif "</body>" in html:
html = html.replace("</body>", inject + "</body>", 1)
else:
html += inject
payload = html.encode("utf-8")
return Response(payload, status=status, headers=response_headers)
+113
View File
@@ -0,0 +1,113 @@
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env", override=True)
def env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
@dataclass(frozen=True)
class Config:
app_name: str = os.getenv("APP_NAME", "API接口系统")
host: str = os.getenv("APP_HOST", os.getenv("API_HOST", "0.0.0.0"))
port: int = int(os.getenv("APP_PORT", os.getenv("API_PORT", "7788")))
debug: bool = env_bool("APP_DEBUG", env_bool("API_DEBUG", False))
secret_key: str = os.getenv("SECRET_KEY", "replace-this-secret-key")
api_key: str = os.getenv("API_KEY", "change-me")
session_cookie_secure: bool = env_bool("SESSION_COOKIE_SECURE", False)
db_host: str = os.getenv("DB_HOST", "127.0.0.1")
db_port: int = int(os.getenv("DB_PORT", "3306"))
db_user: str = os.getenv("DB_USER", "api_reader")
db_password: str = os.getenv("DB_PASSWORD", "")
db_name: str = os.getenv("DB_NAME", "member_system")
db_charset: str = os.getenv("DB_CHARSET", "utf8mb4")
db_connect_timeout: int = int(os.getenv("DB_CONNECT_TIMEOUT", "5"))
members_table: str = os.getenv("MEMBERS_TABLE", "members")
withdraw_table: str = os.getenv("WITHDRAW_TABLE", "withdraw_records")
withdraw_request_table: str = os.getenv(
"WITHDRAW_REQUEST_TABLE", "withdraw_request_records"
)
deposit_table: str = os.getenv("DEPOSIT_TABLE", "deposit_records")
read_only_mode: bool = env_bool("READ_ONLY_MODE", True)
auto_init_sim_tables: bool = env_bool("AUTO_INIT_SIM_TABLES", False)
default_activity_id: str = os.getenv("DEFAULT_ACTIVITY_ID", "ad-x1234")
deposit_search_hours: int = int(os.getenv("DEPOSIT_SEARCH_HOURS", "24"))
max_orders: int = int(os.getenv("MAX_ORDERS", "50"))
# Chatwoot 網站客服設定
chatwoot_enabled: bool = env_bool("CHATWOOT_ENABLED", True)
chatwoot_base_url: str = os.getenv(
"CHATWOOT_BASE_URL",
"http://pai.pubuli.com",
).rstrip("/")
chatwoot_website_token: str = os.getenv(
"CHATWOOT_WEBSITE_TOKEN",
"Vivum4g1WWxH1AmxoTivAcX5",
)
# 必須使用 Chatwoot Website Inbox 的 Identity Validation Token。
# 此值只保留在後端,絕不可輸出到 HTML 或 JavaScript。
chatwoot_identity_secret: str = os.getenv(
"CHATWOOT_IDENTITY_SECRET",
"",
)
chatwoot_debug: bool = env_bool("CHATWOOT_DEBUG", False)
chatwoot_account_id: int = int(os.getenv("CHATWOOT_ACCOUNT_ID", "1"))
chatwoot_inbox_id: int = int(os.getenv("CHATWOOT_INBOX_ID", "1"))
chatwoot_api_email: str = os.getenv("CHATWOOT_API_EMAIL", "")
chatwoot_api_password: str = os.getenv("CHATWOOT_API_PASSWORD", "")
chatwoot_greeting: str = os.getenv(
"CHATWOOT_GREETING",
"您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?",
)
chatwoot_greeting_image: str = os.getenv(
"CHATWOOT_GREETING_IMAGE",
"static/guest_promo.png",
)
chatwoot_guest_base_url: str = os.getenv(
"CHATWOOT_GUEST_BASE_URL",
"",
).rstrip("/")
chatwoot_guest_website_token: str = os.getenv(
"CHATWOOT_GUEST_WEBSITE_TOKEN",
"",
)
chatwoot_guest_proxy_target: str = os.getenv(
"CHATWOOT_GUEST_PROXY_TARGET",
"http://127.0.0.1:3000",
).rstrip("/")
chatwoot_guest_api_base: str = os.getenv(
"CHATWOOT_GUEST_API_BASE",
"",
).rstrip("/")
chatwoot_guest_api_email: str = os.getenv("CHATWOOT_GUEST_API_EMAIL", "")
chatwoot_guest_api_password: str = os.getenv("CHATWOOT_GUEST_API_PASSWORD", "")
chatwoot_guest_inbox_id: int = int(os.getenv("CHATWOOT_GUEST_INBOX_ID", "1"))
chatwoot_widget_jwt_secret: str = os.getenv("CHATWOOT_WIDGET_JWT_SECRET", "")
chatwoot_agent_bot_id: int = int(os.getenv("CHATWOOT_AGENT_BOT_ID", "0") or "0")
chatwoot_agent_bot_token: str = os.getenv("CHATWOOT_AGENT_BOT_TOKEN", "")
# 本機 Chatwoot → PAI MaxKB 橋接
maxkb_bridge_enabled: bool = env_bool("MAXKB_BRIDGE_ENABLED", True)
maxkb_base_url: str = os.getenv(
"MAXKB_BASE_URL",
"http://pai.pubuli.com:8080",
).rstrip("/")
maxkb_access_token: str = os.getenv("MAXKB_ACCESS_TOKEN", "")
maxkb_timeout_sec: float = float(os.getenv("MAXKB_TIMEOUT_SEC", "120"))
CONFIG = Config()
+28
View File
@@ -0,0 +1,28 @@
from contextlib import contextmanager
from typing import Iterator
import pymysql
from pymysql.connections import Connection
from config import CONFIG
@contextmanager
def db_connection() -> Iterator[Connection]:
connection = pymysql.connect(
host=CONFIG.db_host,
port=CONFIG.db_port,
user=CONFIG.db_user,
password=CONFIG.db_password,
database=CONFIG.db_name,
charset=CONFIG.db_charset,
cursorclass=pymysql.cursors.DictCursor,
autocommit=True,
connect_timeout=CONFIG.db_connect_timeout,
read_timeout=20,
write_timeout=20,
)
try:
yield connection
finally:
connection.close()
@@ -0,0 +1,352 @@
"""Chatwoot Agent Bot webhook → MaxKB(PAI)→ 回寫對話。"""
from __future__ import annotations
import json
import logging
import threading
import time
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from config import CONFIG
logger = logging.getLogger(__name__)
_SESSION_LOCK = threading.Lock()
_SESSIONS: dict[str, dict[str, Any]] = {}
_SESSION_FILE = Path(__file__).resolve().parent / ".maxkb_cw_sessions.json"
def _load_sessions() -> None:
if not _SESSION_FILE.exists():
return
try:
data = json.loads(_SESSION_FILE.read_text(encoding="utf-8"))
if isinstance(data, dict):
_SESSIONS.update(data)
except Exception:
logger.exception("讀取 MaxKB session 快取失敗")
def _save_sessions() -> None:
try:
_SESSION_FILE.write_text(
json.dumps(_SESSIONS, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except Exception:
logger.exception("寫入 MaxKB session 快取失敗")
_load_sessions()
def _http_json(
method: str,
url: str,
*,
headers: dict[str, str] | None = None,
body: Any = None,
timeout: float = 120,
) -> tuple[int, Any]:
data = None
if body is not None:
data = body if isinstance(body, (bytes, bytearray)) else json.dumps(
body, ensure_ascii=False
).encode("utf-8")
req_headers = {"Content-Type": "application/json", "Accept": "application/json"}
if headers:
req_headers.update(headers)
req = Request(url, data=data, headers=req_headers, method=method)
try:
with urlopen(req, timeout=timeout) as resp:
raw = resp.read()
try:
return resp.status, json.loads(raw.decode("utf-8") or "null")
except Exception:
return resp.status, raw.decode("utf-8", errors="replace")
except HTTPError as exc:
raw = exc.read()
try:
return exc.code, json.loads(raw.decode("utf-8") or "null")
except Exception:
return exc.code, raw.decode("utf-8", errors="replace")
except URLError as exc:
raise RuntimeError(f"HTTP 連線失敗: {url}: {exc}") from exc
def _maxkb_auth() -> str:
status, body = _http_json(
"POST",
f"{CONFIG.maxkb_base_url}/chat/api/auth/anonymous",
body={"access_token": CONFIG.maxkb_access_token},
timeout=30,
)
if status != 200 or not isinstance(body, dict) or body.get("code") != 200:
raise RuntimeError(f"MaxKB anonymous auth 失敗: {status} {body}")
token = body.get("data")
if not token:
raise RuntimeError("MaxKB anonymous auth 未回傳 token")
return str(token)
def _maxkb_open(jwt: str) -> str:
status, body = _http_json(
"GET",
f"{CONFIG.maxkb_base_url}/chat/api/open",
headers={"Authorization": f"Bearer {jwt}"},
timeout=30,
)
if status != 200 or not isinstance(body, dict) or body.get("code") != 200:
raise RuntimeError(f"MaxKB open 失敗: {status} {body}")
chat_id = body.get("data")
if not chat_id:
raise RuntimeError("MaxKB open 未回傳 chat_id")
return str(chat_id)
def _maxkb_ask(
jwt: str,
chat_id: str,
message: str,
form_data: dict[str, Any],
) -> str:
status, body = _http_json(
"POST",
f"{CONFIG.maxkb_base_url}/chat/api/chat_message/{chat_id}",
headers={"Authorization": f"Bearer {jwt}"},
body={
"message": message,
"stream": False,
"re_chat": False,
"form_data": form_data,
},
timeout=CONFIG.maxkb_timeout_sec,
)
if status != 200 or not isinstance(body, dict) or body.get("code") != 200:
raise RuntimeError(f"MaxKB chat 失敗: {status} {body}")
data = body.get("data") or {}
content = ""
if isinstance(data, dict):
content = str(data.get("content") or "").strip()
if not content:
answers = data.get("answer_list") or []
if answers and isinstance(answers[0], dict):
content = str(answers[0].get("content") or "").strip()
if not content:
raise RuntimeError(f"MaxKB 回覆為空: {body}")
return content
def _get_or_create_session(conversation_key: str) -> dict[str, Any]:
with _SESSION_LOCK:
sess = _SESSIONS.get(conversation_key)
if sess and sess.get("jwt") and sess.get("chat_id"):
return dict(sess)
jwt = _maxkb_auth()
chat_id = _maxkb_open(jwt)
sess = {
"jwt": jwt,
"chat_id": chat_id,
"updated_at": int(time.time()),
}
with _SESSION_LOCK:
_SESSIONS[conversation_key] = sess
_save_sessions()
return dict(sess)
def _refresh_session(conversation_key: str) -> dict[str, Any]:
with _SESSION_LOCK:
_SESSIONS.pop(conversation_key, None)
_save_sessions()
return _get_or_create_session(conversation_key)
def _chatwoot_reply(
account_id: int,
conversation_id: int,
content: str,
) -> None:
base = (
CONFIG.chatwoot_guest_api_base
or CONFIG.chatwoot_guest_proxy_target
or "http://127.0.0.1:3000"
).rstrip("/")
token = CONFIG.chatwoot_agent_bot_token
if not token:
raise RuntimeError("未設定 CHATWOOT_AGENT_BOT_TOKEN")
url = (
f"{base}/api/v1/accounts/{account_id}/conversations/"
f"{conversation_id}/messages"
)
status, body = _http_json(
"POST",
url,
headers={"api_access_token": token},
body={
"content": content,
"message_type": "outgoing",
"private": False,
},
timeout=30,
)
if status >= 300:
raise RuntimeError(f"Chatwoot 回覆失敗: {status} {body}")
def _extract_incoming(payload: dict[str, Any]) -> dict[str, Any] | None:
event = str(payload.get("event") or "")
# Agent Bot 會送 message_created / message_updated;只處理新建且來自聯絡人的訊息
if event and event not in {"message_created"}:
return None
message = payload.get("message") or payload
if not isinstance(message, dict):
return None
# 0 / incoming = 聯絡人;1 / outgoing = 客服或 bot(避免自回)
message_type = message.get("message_type")
incoming_types = {0, "0", "incoming", "Incoming"}
if message_type not in incoming_types:
return None
if message.get("private"):
return None
content = str(message.get("content") or "").strip()
if not content:
return None
conversation = message.get("conversation") or payload.get("conversation") or {}
if not isinstance(conversation, dict):
conversation = {}
conversation_id = conversation.get("id") or message.get("conversation_id")
account = payload.get("account") if isinstance(payload.get("account"), dict) else {}
account_id = (
message.get("account_id")
or conversation.get("account_id")
or account.get("id")
or CONFIG.chatwoot_account_id
)
if not conversation_id:
return None
sender = message.get("sender") or {}
username = ""
if isinstance(sender, dict):
username = str(
sender.get("name")
or sender.get("identifier")
or (sender.get("additional_attributes") or {}).get("username")
or ""
)
return {
"account_id": int(account_id),
"conversation_id": int(conversation_id),
"content": content,
"username": username,
"message_id": message.get("id"),
}
def _hard_reply_for_complaint(message: str) -> str | None:
"""客诉/提款强制话术,避免意图误判。"""
text = "".join(str(message or "").split())
if "一次不给赢" in text:
return "亲,每次游戏都会有输有赢,关键是保持好心态,接受挑战,最终会有丰厚的回报!"
# YD-CUSTOMER-018
if "回归" in text and "杀" in text:
return (
"亲,游戏输输赢赢很正常的,拼的是智力、耐心、心态和运气,"
"如果您觉得手气不好,可以稍作休息,转移一下注意力,稍后再进行投注,"
"或许下一个大奖就是您的,祝您鸿运当头,盈利多多。"
)
# YD-CUSTOMER-017
low = text.lower()
if (
"pg电子" in low
or "PG电子" in text
or "电子真是假" in text
or "假的我永远" in text
or ("电子" in text and "假" in text)
):
return (
"亲,您好!电子游戏的大奖奖池是根据机率来的哦,每一轮的结果都是随机的,"
"所以不能保证每次都能中奖。希望您能理解,这也是游戏的一部分乐趣所在!"
"我们可以一起再试试,说不定下一次就能碰上好运呢"
)
# YD-WITHDRAW-016(标准为转专人,禁止追问订单号)
if "取消提款" in text or "取消提现" in text or "申请取消提款" in text:
return (
"您好,取消提款需由专员协助核实处理,请稍候为您转接真人客服。"
"<quick_question>转专人服务</quick_question>"
)
return None
def handle_chatwoot_webhook(payload: dict[str, Any]) -> dict[str, Any]:
if not CONFIG.maxkb_bridge_enabled:
return {"ok": True, "skipped": "bridge_disabled"}
incoming = _extract_incoming(payload)
if not incoming:
return {"ok": True, "skipped": "not_incoming"}
account_id = incoming["account_id"]
conversation_id = incoming["conversation_id"]
key = f"{account_id}:{conversation_id}"
form_data = {
"account_id": str(account_id),
"conversation_id": str(conversation_id),
"bot_id": str(CONFIG.chatwoot_agent_bot_id or ""),
"username": incoming["username"] or "游客",
}
hard = _hard_reply_for_complaint(incoming["content"])
if hard:
_chatwoot_reply(account_id, conversation_id, hard)
with _SESSION_LOCK:
cur = _SESSIONS.get(key) or {}
cur["updated_at"] = int(time.time())
cur["last_message_id"] = incoming.get("message_id")
cur["hard_reply"] = True
_SESSIONS[key] = cur
_save_sessions()
return {
"ok": True,
"conversation_id": conversation_id,
"hard_reply": True,
"reply_len": len(hard),
}
sess = _get_or_create_session(key)
try:
reply = _maxkb_ask(
sess["jwt"], sess["chat_id"], incoming["content"], form_data
)
except Exception:
logger.exception("MaxKB 對話失敗,嘗試重建 session")
sess = _refresh_session(key)
reply = _maxkb_ask(
sess["jwt"], sess["chat_id"], incoming["content"], form_data
)
_chatwoot_reply(account_id, conversation_id, reply)
with _SESSION_LOCK:
cur = _SESSIONS.get(key) or {}
cur["updated_at"] = int(time.time())
cur["last_message_id"] = incoming.get("message_id")
_SESSIONS[key] = cur
_save_sessions()
return {
"ok": True,
"conversation_id": conversation_id,
"chat_id": sess["chat_id"],
"reply_len": len(reply),
}
@@ -0,0 +1,178 @@
from __future__ import annotations
from typing import Any
from config import CONFIG
from db import db_connection
REQUIRED: dict[str, set[str]] = {
CONFIG.members_table: {
"id",
"uid",
"username",
"username_clean",
"balance",
"remark",
},
CONFIG.withdraw_table: {
"id",
"username",
"username_clean",
"withdraw_amount",
"withdraw_time",
"status",
"remark",
},
CONFIG.withdraw_request_table: {
"id",
"withdraw_request_id",
"username_raw",
"username_clean",
"withdraw_amount",
"request_time",
"operation",
"audit_status",
"remark",
},
CONFIG.deposit_table: {
"id",
"deposit_id",
"username",
"username_clean",
"deposit_amount",
"actual_arrival_amount",
"status",
"deposit_time",
},
}
def extract_column_name(row: Any) -> str:
"""兼容不同 MySQL/PyMySQL 對欄位名稱大小寫的返回方式。"""
if isinstance(row, dict):
for key in (
"column_name_value",
"column_name",
"COLUMN_NAME",
):
value = row.get(key)
if value is not None:
return str(value)
if row:
return str(next(iter(row.values())))
if isinstance(row, (list, tuple)) and row:
return str(row[0])
raise KeyError(f"無法取得欄位名稱:{row!r}")
def fetch_table_columns(cursor: Any, table: str) -> set[str]:
cursor.execute(
"""
SELECT COLUMN_NAME AS column_name_value
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=%s
AND TABLE_NAME=%s
ORDER BY ORDINAL_POSITION
""",
(
CONFIG.db_name,
table,
),
)
return {
extract_column_name(row)
for row in cursor.fetchall()
}
def main() -> int:
print("API接口系統資料庫預檢")
print("=" * 48)
print(f"資料庫:{CONFIG.db_name}")
print(f"唯讀模式:{CONFIG.read_only_mode}")
print(f"會員表:{CONFIG.members_table}")
print(f"提款記錄表:{CONFIG.withdraw_table}")
print(f"提款申請表:{CONFIG.withdraw_request_table}")
print(f"充值表:{CONFIG.deposit_table}")
print("-" * 48)
failed = False
try:
with db_connection() as conn, conn.cursor() as cursor:
cursor.execute(
"""
SELECT
DATABASE() AS database_name,
@@global.read_only AS global_read_only
"""
)
server_info = cursor.fetchone() or {}
if isinstance(server_info, dict):
database_name = (
server_info.get("database_name")
or server_info.get("DATABASE_NAME")
or CONFIG.db_name
)
if "global_read_only" in server_info:
mysql_read_only = server_info["global_read_only"]
else:
mysql_read_only = server_info.get("GLOBAL_READ_ONLY")
else:
database_name = CONFIG.db_name
mysql_read_only = "unknown"
print(f"目前連線資料庫:{database_name}")
print(f"MySQL global read_only:{mysql_read_only}")
print("-" * 48)
for table, required_columns in REQUIRED.items():
columns = fetch_table_columns(cursor, table)
missing = sorted(required_columns - columns)
if not columns:
failed = True
print(
f"[失敗] {table}: "
"資料表不存在,或目前帳號無 information_schema 讀取權限"
)
continue
if missing:
failed = True
print(
f"[失敗] {table}: "
f"共 {len(columns)} 個欄位,缺少 {missing}"
)
continue
print(
f"[正常] {table}: "
f"{len(columns)} 個欄位,必要欄位完整"
)
except Exception as exc:
print(
f"[失敗] 資料庫預檢執行錯誤:"
f"{type(exc).__name__}: {exc}"
)
return 1
print("-" * 48)
if failed:
print("預檢結果:存在資料表或欄位問題")
return 1
print("預檢結果:全部正常")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,531 @@
import re
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Any, Optional
from config import CONFIG
from db import db_connection
_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9_]+$")
_FROZEN_MARK_RE = re.compile(r"[\((]?\s*已冻结\s*[\))]?")
class MappingError(RuntimeError):
pass
def safe_identifier(value: str) -> str:
if not value or not _IDENTIFIER_RE.fullmatch(value):
raise MappingError(f"不安全或無效的資料庫識別字:{value!r}")
return f"`{value}`"
def table_name(value: str) -> str:
return safe_identifier(value)
def decimal_string(value: Any, default: str = "0.0000") -> str:
if value is None or value == "":
return default
try:
return format(Decimal(str(value)), "f")
except Exception:
return str(value)
def format_datetime(value: Any) -> str:
if value is None:
return ""
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
return str(value)
def clean_frozen_username(value: str) -> str:
cleaned = _FROZEN_MARK_RE.sub("", str(value or ""))
return cleaned.strip()
def is_finished_status(value: Any) -> bool:
status = str(value or "").strip().lower()
if not status:
return False
final_keywords = (
"success", "successful", "completed", "complete", "approved",
"paid", "done", "rejected", "cancelled", "canceled", "failed",
"成功", "完成", "已支付", "已出款", "出款成功", "审核通过",
"審核通過", "通过", "通過", "拒绝", "拒絕", "驳回", "駁回",
"取消", "失败", "失敗",
)
return any(keyword in status for keyword in final_keywords)
class PlatformRepository:
"""依照現有 member_system 真實表結構建立的唯讀資料層。"""
def ensure_simulation_tables(self) -> None:
"""唯讀測試版不建立資料表,也不對採集庫執行寫入。"""
return
def get_user_account(self, username: str) -> dict[str, Any]:
"""查詢 members,並由 username 是否包含「已冻结」判斷鎖定。"""
login_username = str(username or "").strip()
if not login_username:
return self._not_found_account("")
members = table_name(CONFIG.members_table)
sql = f"""
SELECT
id,
uid,
username,
username_clean,
member_type,
vip_level,
points,
tier,
balance,
frozen_balance,
other_balance,
deposit_count,
deposit_amount,
withdraw_count,
withdraw_amount,
register_time,
last_login_time,
remark,
updated_at
FROM {members}
WHERE username = %s OR username_clean = %s
ORDER BY
CASE
WHEN username = %s THEN 0
WHEN username_clean = %s THEN 1
ELSE 2
END,
updated_at DESC,
id DESC
LIMIT 1
"""
with db_connection() as conn, conn.cursor() as cursor:
cursor.execute(
sql,
(login_username, login_username, login_username, login_username),
)
row = cursor.fetchone()
# 少數舊資料可能未回填 username_clean;再用移除凍結標記後比對。
if not row:
fallback_sql = f"""
SELECT
id,
uid,
username,
username_clean,
member_type,
vip_level,
points,
tier,
balance,
frozen_balance,
other_balance,
deposit_count,
deposit_amount,
withdraw_count,
withdraw_amount,
register_time,
last_login_time,
remark,
updated_at
FROM {members}
WHERE TRIM(
REPLACE(
REPLACE(
REPLACE(username, '(已冻结)', ''),
'(已冻结)', ''
),
'已冻结', ''
)
) = %s
ORDER BY updated_at DESC, id DESC
LIMIT 1
"""
cursor.execute(fallback_sql, (login_username,))
row = cursor.fetchone()
if not row:
return self._not_found_account(login_username)
raw_username = str(row.get("username") or login_username).strip()
clean_username = str(row.get("username_clean") or "").strip()
if not clean_username:
clean_username = clean_frozen_username(raw_username)
if not clean_username:
clean_username = login_username
locked = "已冻结" in raw_username
remark = str(row.get("remark") or "").strip()
return {
"username": clean_username,
"raw_username": raw_username,
"username_clean": clean_username,
"uid": row.get("uid"),
"is_exist": True,
"status": "locked" if locked else "normal",
"locked_reason": remark if locked else "",
"remark": remark,
"member_type": str(row.get("member_type") or ""),
"vip_level": str(row.get("vip_level") or ""),
"points": decimal_string(row.get("points")),
"tier": str(row.get("tier") or ""),
"balance": decimal_string(row.get("balance")),
"frozen_balance": decimal_string(row.get("frozen_balance")),
"other_balance": decimal_string(row.get("other_balance")),
"deposit_count": int(row.get("deposit_count") or 0),
"deposit_amount": decimal_string(row.get("deposit_amount")),
"withdraw_count": int(row.get("withdraw_count") or 0),
"withdraw_amount": decimal_string(row.get("withdraw_amount")),
"register_time": format_datetime(row.get("register_time")),
"last_login_time": format_datetime(row.get("last_login_time")),
}
@staticmethod
def _not_found_account(username: str) -> dict[str, Any]:
return {
"username": username,
"raw_username": "",
"username_clean": username,
"uid": None,
"is_exist": False,
"status": "not_found",
"locked_reason": "",
}
def get_balance(self, username: str) -> Decimal:
account = self.get_user_account(username)
if not account["is_exist"]:
raise MappingError(f"members 表找不到會員:{username}")
return Decimal(str(account.get("balance") or "0"))
def get_withdraw_orders(
self,
username: str,
unfinished_only: bool = False,
) -> list[dict[str, Any]]:
account = self.get_user_account(username)
clean_username = (
account["username_clean"] if account["is_exist"] else str(username).strip()
)
raw_username = (
account["raw_username"] if account["is_exist"] else str(username).strip()
)
records = self._query_withdraw_records(clean_username, raw_username)
requests = self._query_withdraw_requests(clean_username, raw_username)
combined = records + requests
if unfinished_only:
combined = [
item for item in combined
if not is_finished_status(item.get("status"))
]
combined.sort(
key=lambda item: item.get("sort_time") or datetime.min,
reverse=True,
)
for item in combined:
item.pop("sort_time", None)
return combined[: CONFIG.max_orders]
def _query_withdraw_records(
self,
clean_username: str,
raw_username: str,
) -> list[dict[str, Any]]:
table = table_name(CONFIG.withdraw_table)
sql = f"""
SELECT
withdraw_id,
withdraw_id_2,
username,
username_clean,
withdraw_amount,
fee,
arrival_amount,
withdraw_channel,
withdraw_time,
status,
remark,
reviewer,
review_time,
processor,
process_time
FROM {table}
WHERE
username_clean = %s
OR username = %s
OR username = %s
ORDER BY withdraw_time DESC, id DESC
LIMIT %s
"""
with db_connection() as conn, conn.cursor() as cursor:
cursor.execute(
sql,
(clean_username, clean_username, raw_username, CONFIG.max_orders),
)
rows = cursor.fetchall()
return [
{
"source": "withdraw_records",
"order_id": str(
row.get("withdraw_id") or row.get("withdraw_id_2") or ""
),
"username": clean_username,
"status": str(row.get("status") or ""),
"amount": float(row.get("withdraw_amount") or 0),
"fee": float(row.get("fee") or 0),
"arrival_amount": float(row.get("arrival_amount") or 0),
"withdraw_channel": str(row.get("withdraw_channel") or ""),
"create_time": format_datetime(row.get("withdraw_time")),
"fail_reason": str(row.get("remark") or ""),
"reviewer": str(row.get("reviewer") or ""),
"review_time": format_datetime(row.get("review_time")),
"processor": str(row.get("processor") or ""),
"process_time": format_datetime(row.get("process_time")),
"sort_time": row.get("withdraw_time") or datetime.min,
}
for row in rows
]
def _query_withdraw_requests(
self,
clean_username: str,
raw_username: str,
) -> list[dict[str, Any]]:
"""查詢提款申請。
狀態判斷口徑:
1. operation 為主要狀態欄位;
2. operation 為空時才使用 audit_status;
3. 同一 withdraw_request_id 只保留最新一筆;
4. withdraw_request_id 為空時保留該筆資料,不做錯誤合併。
"""
table = table_name(CONFIG.withdraw_request_table)
# 多抓一些歷史快照,再於 Python 端依 withdraw_request_id 去重。
# SQL 已按 request_time、id 倒序,所以第一筆就是最新狀態。
fetch_limit = max(CONFIG.max_orders * 5, CONFIG.max_orders)
sql = f"""
SELECT
id,
withdraw_request_id,
username_raw,
username_clean,
current_balance,
withdraw_channel,
withdraw_amount,
request_time,
turnover,
operation,
auto_withdraw,
audit_status,
remark
FROM {table}
WHERE
username_clean = %s
OR username_raw = %s
OR username_raw = %s
OR username_raw LIKE %s
OR username_raw LIKE %s
ORDER BY request_time DESC, id DESC
LIMIT %s
"""
with db_connection() as conn, conn.cursor() as cursor:
cursor.execute(
sql,
(
clean_username,
clean_username,
raw_username,
f"{clean_username}(%",
f"{clean_username}(%",
fetch_limit,
),
)
rows = cursor.fetchall()
latest_rows: list[dict[str, Any]] = []
seen_request_ids: set[str] = set()
for row in rows:
request_id = row.get("withdraw_request_id")
# 有 request_id:只保留倒序結果中的第一筆,也就是最新狀態。
if request_id not in (None, ""):
request_key = str(request_id)
if request_key in seen_request_ids:
continue
seen_request_ids.add(request_key)
# 沒有 request_id:無法可靠判定是否同一筆申請,因此保留。
latest_rows.append(row)
if len(latest_rows) >= CONFIG.max_orders:
break
result: list[dict[str, Any]] = []
for row in latest_rows:
operation = str(row.get("operation") or "").strip()
audit_status = str(row.get("audit_status") or "").strip()
# 提款申請狀態以 operation 為主,audit_status 僅作備援。
status = operation or audit_status or "UNKNOWN"
result.append(
{
"source": "withdraw_request_records",
"order_id": str(row.get("withdraw_request_id") or ""),
"record_id": int(row.get("id") or 0),
"username": clean_username,
"status": status,
"operation": operation,
"audit_status": audit_status,
"amount": float(row.get("withdraw_amount") or 0),
"fee": 0.0,
"arrival_amount": 0.0,
"current_balance": float(row.get("current_balance") or 0),
"turnover": float(row.get("turnover") or 0),
"withdraw_channel": str(row.get("withdraw_channel") or ""),
"create_time": format_datetime(row.get("request_time")),
"fail_reason": str(row.get("remark") or ""),
"auto_withdraw": str(row.get("auto_withdraw") or ""),
"sort_time": row.get("request_time") or datetime.min,
}
)
return result
def get_deposit_orders(
self,
username: str,
amount: Optional[Decimal] = None,
created_at: Optional[datetime] = None,
) -> list[dict[str, Any]]:
account = self.get_user_account(username)
clean_username = (
account["username_clean"] if account["is_exist"] else str(username).strip()
)
raw_username = (
account["raw_username"] if account["is_exist"] else str(username).strip()
)
table = table_name(CONFIG.deposit_table)
conditions = [
"(username_clean = %s OR username = %s OR username = %s)"
]
params: list[Any] = [clean_username, clean_username, raw_username]
if amount is not None:
conditions.append("ABS(deposit_amount - %s) < 0.01")
params.append(amount)
if created_at is not None:
hours = CONFIG.deposit_search_hours
conditions.append("deposit_time BETWEEN %s AND %s")
params.extend(
[
created_at - timedelta(hours=hours),
created_at + timedelta(hours=hours),
]
)
sql = f"""
SELECT
deposit_id,
deposit_no,
username,
username_clean,
deposit_amount,
actual_arrival_amount,
crypto_amount,
fee,
deposit_bank,
deposit_type,
status,
description,
deposit_time,
arrival_time,
operation_time,
backend_remark,
frontend_remark
FROM {table}
WHERE {' AND '.join(conditions)}
ORDER BY deposit_time DESC, id DESC
LIMIT %s
"""
params.append(CONFIG.max_orders)
with db_connection() as conn, conn.cursor() as cursor:
cursor.execute(sql, params)
rows = cursor.fetchall()
return [
{
"username": clean_username,
"thirdparty_order_id": str(
row.get("deposit_no") or row.get("deposit_id") or ""
),
"deposit_id": str(row.get("deposit_id") or ""),
"deposit_no": str(row.get("deposit_no") or ""),
"amount": float(row.get("deposit_amount") or 0),
"actual_arrival_amount": float(
row.get("actual_arrival_amount") or 0
),
"crypto_amount": float(row.get("crypto_amount") or 0),
"fee": float(row.get("fee") or 0),
"status": str(row.get("status") or ""),
"deposit_bank": str(row.get("deposit_bank") or ""),
"deposit_type": str(row.get("deposit_type") or ""),
"description": str(row.get("description") or ""),
"created_at": format_datetime(row.get("deposit_time")),
"arrival_time": format_datetime(row.get("arrival_time")),
"operation_time": format_datetime(row.get("operation_time")),
"backend_remark": str(row.get("backend_remark") or ""),
"frontend_remark": str(row.get("frontend_remark") or ""),
}
for row in rows
]
def apply_activity(self, username: str, activity_id: str) -> dict[str, Any]:
"""唯讀測試:只模擬資格回傳,不寫入資料庫。"""
account = self.get_user_account(username)
if not account["is_exist"]:
return {
"activity_found": False,
"qualified": False,
"status": "user_not_found",
"simulated": True,
}
activity_found = activity_id == CONFIG.default_activity_id
return {
"activity_found": activity_found,
"qualified": activity_found,
"status": (
"simulated_qualified" if activity_found else "activity_not_found"
),
"simulated": True,
}
def log_balance_fix(self, username: str, balance: Decimal) -> None:
"""唯讀模式不寫入任何修復日誌。"""
return
@@ -0,0 +1,4 @@
Flask>=3.1,<4
PyMySQL>=1.1,<2
python-dotenv>=1.0,<2
gunicorn>=23,<24
File diff suppressed because one or more lines are too long
@@ -0,0 +1,432 @@
{% if chatwoot_config.enabled %}
<script>
(function () {
"use strict";
/*
* 會員身份由 Flask Session 注入,identifier 直接使用 username_clean。
* 不再接受 ?identifier=... 等網址參數,避免使用者冒充其他會員。
*/
const CHATWOOT_CONFIG = {{ chatwoot_config | tojson }};
const CHATWOOT_USER = {{ chatwoot_user | tojson }};
window.chatwootSettings = {
hideMessageBubble: false,
showUnreadMessagesDialog: false,
position: "right",
locale: "zh_TW",
useBrowserLanguage: false,
type: "standard",
darkMode: "auto",
launcherTitle: ""
};
function debugLog() {
if (!CHATWOOT_CONFIG.debug) {
return;
}
console.log.apply(console, arguments);
}
function configureChatwootIdentity() {
if (!window.$chatwoot) {
return;
}
if (!CHATWOOT_USER.is_authenticated) {
if (typeof window.$chatwoot.setCustomAttributes === "function") {
window.$chatwoot.setCustomAttributes({
visitor_type: "guest"
});
}
debugLog("Chatwoot anonymous guest ready");
return;
}
if (
typeof window.$chatwoot.setUser !== "function" ||
!CHATWOOT_USER.identifier
) {
return;
}
const userPayload = {
name: CHATWOOT_USER.name || CHATWOOT_USER.username
};
if (CHATWOOT_USER.identifier_hash) {
userPayload.identifier_hash = CHATWOOT_USER.identifier_hash;
}
window.$chatwoot.setUser(
CHATWOOT_USER.identifier,
userPayload
);
if (typeof window.$chatwoot.setCustomAttributes === "function") {
window.$chatwoot.setCustomAttributes({
visitor_type: "member",
username: CHATWOOT_USER.username || "",
member_uid: CHATWOOT_USER.uid == null
? ""
: String(CHATWOOT_USER.uid),
account_status: CHATWOOT_USER.account_status || "",
locked_reason: CHATWOOT_USER.locked_reason || "",
member_type: CHATWOOT_USER.member_type || "",
vip_level: CHATWOOT_USER.vip_level || "",
tier: CHATWOOT_USER.tier || ""
});
}
}
/*
* Flask 登出前清除 Chatwoot Session,避免下一個登入會員
* 沿用上一位會員的 Chatwoot Cookie 與對話。
*/
window.resetChatwootBeforeLogout = function (form) {
if (!form) {
return true;
}
/*
* 防止使用者重複點擊登出,造成多次提交。
*/
if (form.dataset.submitting === "true") {
return false;
}
form.dataset.submitting = "true";
const submitLogout = function () {
form.submit();
};
if (
window.$chatwoot &&
typeof window.$chatwoot.reset === "function"
) {
try {
window.$chatwoot.reset();
} catch (error) {
console.error(
"Chatwoot reset before logout failed:",
error
);
}
/*
* Chatwoot reset 沒有 Promise 回傳,因此提供短暫緩衝時間。
* 即使前端清理未完成,後端的一次性 guest reset
* 仍會在登出後首次載入匿名頁面時再次執行。
*/
window.setTimeout(
submitLogout,
300
);
return false;
}
submitLogout();
return false;
};
window.openGuestChatNewConversation = function () {
createGuestConversationAndShow({ openAfter: true });
};
window.exitGuestChatAndStartNew = function () {
// 先關掉視窗,再換成新對話並重新打開
closeGuestChat();
createGuestConversationAndShow({ openAfter: true, delayMs: 350 });
};
function createGuestConversationAndShow(options) {
var opts = options || {};
var openAfter = opts.openAfter !== false;
var delayMs = typeof opts.delayMs === "number" ? opts.delayMs : 700;
if (!CHATWOOT_CONFIG.guest_new_path) {
return;
}
fetch(CHATWOOT_CONFIG.guest_new_path, {
method: "POST",
headers: {
Accept: "application/json",
"X-CSRF-Token": CHATWOOT_CONFIG.csrf_token || ""
}
})
.then(function (response) {
return response.json().then(function (data) {
if (!response.ok) {
throw new Error((data && data.message) || "建立游客对话失败");
}
return data;
});
})
.then(function (data) {
if (!data || !data.widget_token) {
throw new Error("Chatwoot 未回傳 widget_token");
}
document.querySelectorAll("iframe[src*='widget']").forEach(
function (iframe) {
const url = new URL(iframe.src, window.location.origin);
url.searchParams.set("cw_conversation", data.widget_token);
url.hash = "/messages";
iframe.src = url.toString();
}
);
if (!openAfter) {
window.__guestWidgetOpen = false;
return;
}
window.__guestWidgetOpen = true;
window.setTimeout(function () {
if (
window.$chatwoot &&
typeof window.$chatwoot.toggle === "function"
) {
window.$chatwoot.toggle("open");
}
}, delayMs);
})
.catch(function (error) {
console.error("Chatwoot guest open failed:", error);
window.alert("客服暂时无法开启,请稍后再试。");
});
};
function isGuestChatOpen() {
var holder = document.querySelector(".woot-widget-holder");
if (holder && !holder.classList.contains("woot--hide")) {
var style = window.getComputedStyle(holder);
var rect = holder.getBoundingClientRect();
if (
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity || "1") > 0 &&
rect.width > 0 &&
rect.height > 0
) {
return true;
}
}
return !!window.__guestWidgetOpen;
}
function openGuestChatResume() {
window.__guestWidgetOpen = true;
if (window.$chatwoot && typeof window.$chatwoot.toggle === "function") {
try {
window.$chatwoot.toggle("open");
return;
} catch (error) {}
}
var holder = document.querySelector(".woot-widget-holder");
if (holder) {
holder.classList.remove("woot--hide");
}
}
function closeGuestChat() {
window.__guestWidgetOpen = false;
if (window.$chatwoot && typeof window.$chatwoot.toggle === "function") {
try {
window.$chatwoot.toggle("close");
return;
} catch (error) {}
}
var holder = document.querySelector(".woot-widget-holder");
if (holder) {
holder.classList.add("woot--hide");
}
}
function bindGuestBubbleToggle() {
if (!CHATWOOT_CONFIG.guest_new_path || window.__guestBubbleBound) {
return;
}
window.__guestBubbleBound = true;
window.addEventListener("chatwoot:opened", function () {
window.__guestWidgetOpen = true;
});
window.addEventListener("chatwoot:closed", function () {
window.__guestWidgetOpen = false;
});
document.addEventListener(
"click",
function (event) {
var target = event.target;
if (!target || typeof target.closest !== "function") {
return;
}
var bubble = target.closest(
".woot-widget-bubble, .woot--bubble-holder, button.woot-widget-bubble"
);
if (!bubble) {
return;
}
// 右下角氣泡:只收起/重開同一個對話,绝不開新單。
event.preventDefault();
event.stopPropagation();
if (isGuestChatOpen()) {
closeGuestChat();
} else {
openGuestChatResume();
}
},
true
);
}
window.addEventListener("message", function (event) {
var raw = event && event.data;
if (typeof raw !== "string" || raw.indexOf("chatwoot-widget:") !== 0) {
return;
}
try {
var payload = JSON.parse(raw.replace("chatwoot-widget:", ""));
if (payload && payload.event === "guestExitNewChat") {
if (typeof window.exitGuestChatAndStartNew === "function") {
window.exitGuestChatAndStartNew();
} else if (typeof window.openGuestChatNewConversation === "function") {
window.openGuestChatNewConversation();
}
}
} catch (error) {}
});
window.addEventListener(
"chatwoot:ready",
function () {
debugLog("chatwoot:ready", window.$chatwoot);
const guestResetRequired =
{{ chatwoot_guest_reset_required | tojson }};
/*
* 只有「剛完成會員登出」且目前未登入時,才清除舊會員身份。
* 一般遊客頁面不會每次都 reset,因此不會破壞既有匿名對話。
*/
if (
!CHATWOOT_USER.is_authenticated &&
guestResetRequired &&
window.$chatwoot &&
typeof window.$chatwoot.reset === "function"
) {
try {
window.$chatwoot.reset();
} catch (error) {
console.error(
"Chatwoot guest reset failed:",
error
);
}
/*
* reset 後稍候再設定 visitor_type=guest。
* 不執行 setUser(),因此不會向 Chatwoot 傳遞用戶名。
*/
window.setTimeout(
configureChatwootIdentity,
300
);
bindGuestBubbleToggle();
return;
}
configureChatwootIdentity();
bindGuestBubbleToggle();
}
);
window.addEventListener(
"chatwoot:error",
function (event) {
console.error(
"chatwoot:error",
event && event.detail ? event.detail : event
);
}
);
if (CHATWOOT_CONFIG.debug) {
window.addEventListener(
"chatwoot:on-message",
function (event) {
debugLog("chatwoot:on-message", event.detail);
}
);
window.addEventListener(
"chatwoot:postback",
function (event) {
debugLog("chatwoot:postback", event.detail);
}
);
window.addEventListener(
"chatwoot:opened",
function () {
debugLog("chatwoot:opened");
}
);
window.addEventListener(
"chatwoot:closed",
function () {
debugLog("chatwoot:closed");
}
);
window.addEventListener(
"chatwoot:on-start-conversation",
function (event) {
debugLog(
"chatwoot:on-start-conversation",
event.detail
);
}
);
}
const script = document.createElement("script");
const firstScript = document.getElementsByTagName("script")[0];
script.src = (
CHATWOOT_CONFIG.base_url +
"/packs/js/sdk.js"
);
script.async = true;
script.onload = function () {
if (!window.chatwootSDK) {
console.error("Chatwoot SDK object not found");
return;
}
window.chatwootSDK.run({
websiteToken: CHATWOOT_CONFIG.website_token,
baseUrl: CHATWOOT_CONFIG.base_url
});
};
script.onerror = function () {
console.error(
"Chatwoot SDK load failed:",
script.src
);
};
firstScript.parentNode.insertBefore(
script,
firstScript
);
})();
</script>
{% endif %}
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body class="{% block body_class %}{% endblock %}">
{% block body %}{% endblock %}
{% block integrations %}{% endblock %}
</body>
</html>
@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}客服中心|{{ app_name }}{% endblock %}
{% block body %}
<header class="topbar">
<div>
<div class="site-title">客服測試入口</div>
<div class="site-subtitle">目前用戶:{{ username }}</div>
</div>
<a class="back-link" href="{{ url_for('home') }}">返回首頁</a>
</header>
<main class="page narrow">
<section class="support-panel">
<div class="support-avatar">🎧</div>
<h1>會員資料查詢</h1>
{% if error %}
<div class="alert alert-error">{{ error }}</div>
{% else %}
{% if account.status == "locked" %}
<div class="alert alert-warning">
帳戶已凍結{% if account.locked_reason %}|{{ account.locked_reason }}{% endif %}
</div>
{% endif %}
<h2>未完成提款</h2>
{% if not withdrawals %}
<div class="empty-state">目前沒有查到未完成提款資料。</div>
{% else %}
<div class="order-list">
{% for order in withdrawals %}
<article class="order-card">
<strong>{{ order.status or "狀態未知" }}</strong>
<span>來源:{{ order.source }}</span>
<span>訂單:{{ order.order_id or "NA" }}</span>
<span>金額:{{ order.amount }}</span>
<span>時間:{{ order.create_time or "NA" }}</span>
{% if order.fail_reason %}<span>備註:{{ order.fail_reason }}</span>{% endif %}
</article>
{% endfor %}
</div>
{% endif %}
<h2 class="section-gap">最近充值</h2>
{% if not deposits %}
<div class="empty-state">目前沒有查到充值資料。</div>
{% else %}
<div class="order-list">
{% for order in deposits %}
<article class="order-card">
<strong>{{ order.status or "狀態未知" }}</strong>
<span>訂單:{{ order.thirdparty_order_id or "NA" }}</span>
<span>充值金額:{{ order.amount }}</span>
<span>實際到帳:{{ order.actual_arrival_amount }}</span>
<span>充值時間:{{ order.created_at or "NA" }}</span>
<span>到帳時間:{{ order.arrival_time or "NA" }}</span>
</article>
{% endfor %}
</div>
{% endif %}
{% endif %}
</section>
</main>
{% endblock %}
{% block integrations %}
{% include "_chatwoot.html" %}
{% endblock %}
@@ -0,0 +1,11 @@
{% extends "base.html" %}
{% block title %}{{ title }}|{{ app_name }}{% endblock %}
{% block body_class %}login-body{% endblock %}
{% block body %}
<main class="login-card">
<div class="brand-mark">!</div>
<h1>{{ title }}</h1>
<div class="alert alert-error">{{ message }}</div>
<a class="primary-link" href="{{ url_for('root') }}">返回系統</a>
</main>
{% endblock %}
@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}游客客服|{{ app_name }}{% endblock %}
{% block body_class %}guest-page{% endblock %}
{% block body %}
<header class="topbar">
<div>
<div class="site-title">游客客服中心</div>
<div class="site-subtitle">Guest Customer Service</div>
</div>
<a class="back-link" href="{{ url_for('login') }}">返回登录页</a>
</header>
<main class="page narrow">
<section class="support-panel guest-support-panel">
<div class="support-avatar">🎧</div>
<span class="guest-badge">游客模式</span>
<h1>欢迎使用在线客服</h1>
<p>您目前以游客身份进入,可咨询注册、登录、活动与平台操作问题。</p>
<div class="alert alert-warning">
游客模式不会识别会员身份,也不能查询充值、提款、余额或其他会员资料。
</div>
<button
type="button"
class="primary-button guest-open-chat"
onclick="if (window.openGuestChatNewConversation) { window.openGuestChatNewConversation(); }"
>
开启在线客服
</button>
</section>
</main>
{% endblock %}
{% block integrations %}
{% include "_chatwoot.html" %}
{% endblock %}
@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}遊戲首頁|{{ app_name }}{% endblock %}
{% block body %}
<header class="topbar">
<div>
<div class="site-title">YD GAME DEMO</div>
<div class="site-subtitle">Python 唯讀 API 測試站點</div>
</div>
<div class="member-area">
<span>用戶:<strong>{{ account.raw_username or username }}</strong></span>
<span class="status-badge {{ account.status }}">
{{ "已凍結" if account.status == "locked" else "正常" }}
</span>
<form
method="post"
action="{{ url_for('logout') }}"
onsubmit="return window.resetChatwootBeforeLogout ? window.resetChatwootBeforeLogout(this) : true;"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="logout-button" type="submit">登出</button>
</form>
</div>
</header>
<main class="page">
{% if warning %}
<div class="alert alert-error">帳戶資料讀取失敗:{{ warning }}</div>
{% endif %}
{% if account.status == "locked" %}
<div class="alert alert-warning">
<strong>帳戶已凍結</strong>
{% if account.locked_reason %}|凍結原因:{{ account.locked_reason }}{% endif %}
</div>
{% endif %}
<section class="hero">
<div>
<span class="eyebrow">PYTHON TEST ENVIRONMENT</span>
<h1>歡迎進入遊戲站點模擬首頁</h1>
<p>登入會員:{{ account.username_clean or username }}。本頁只用於驗證會員資料、充值、提款與客服 API。</p>
</div>
<a class="service-entry" href="{{ url_for('customer_service') }}">
<span class="service-icon">🎧</span>
<span><strong>在線客服</strong><small>查詢充值與提款資料</small></span>
</a>
</section>
<section class="account-grid">
<div class="metric-card"><span>UID</span><strong>{{ account.uid or "NA" }}</strong></div>
<div class="metric-card"><span>VIP 等級</span><strong>{{ account.vip_level or "NA" }}</strong></div>
<div class="metric-card"><span>會員餘額</span><strong>{{ account.balance or "0.0000" }}</strong></div>
<div class="metric-card"><span>凍結餘額</span><strong>{{ account.frozen_balance or "0.0000" }}</strong></div>
</section>
<section>
<div class="section-heading"><h2>熱門遊戲</h2><span>畫面模擬,不執行真實投注</span></div>
<div class="game-grid">
{% for game in games %}
<article class="game-card">
<div class="game-icon">{{ game.icon }}</div>
<div><h3>{{ game.name }}</h3><p>{{ game.category }}</p></div>
<button type="button" disabled>模擬遊戲</button>
</article>
{% endfor %}
</div>
</section>
</main>
<a class="floating-service" href="{{ url_for('customer_service') }}">🎧 客服</a>
{% endblock %}
{% block integrations %}
{% include "_chatwoot.html" %}
{% endblock %}
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}登录|{{ app_name }}{% endblock %}
{% block body_class %}login-body{% endblock %}
{% block body %}
<main class="login-card">
<div class="brand-mark">YD</div>
<h1>游戏站点模拟</h1>
<p class="muted">仅使用 members 表内存在的用户名登录,不需要密码。</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
<form method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="username">用户名</label>
<input id="username" name="username" type="text" maxlength="191"
value="{{ username }}" placeholder="输入 username 或 username_clean"
required autofocus>
<button type="submit" class="primary-button">登录测试站点</button>
</form>
<div class="login-divider"><span>或</span></div>
<a
class="guest-service-button"
href="{{ url_for('guest_customer_service') }}"
>
🎧 游客进入客服
</a>
<p class="guest-service-note">
游客只能进行一般咨询,不能查询会员充值、提款或账户资料。
</p>
<p class="security-note">【测试限定】此登录方式不具备正式身份验证能力。</p>
</main>
{% endblock %}
@@ -0,0 +1,4 @@
from app import app
application = app
+22
View File
@@ -0,0 +1,22 @@
import os
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
meta_cmds = [
"curl -s -H Metadata:true 'http://169.254.169.254/metadata/instance/compute/name?api-version=2021-02-01&format=text'",
"curl -s -H Metadata:true 'http://169.254.169.254/metadata/instance/compute/resourceGroupName?api-version=2021-02-01&format=text'",
"curl -s -H Metadata:true 'http://169.254.169.254/metadata/instance/compute/location?api-version=2021-02-01&format=text'",
"curl -s -H Metadata:true 'http://169.254.169.254/metadata/instance/network/interface/0/ipv4/ipAddress/0/publicIpAddress?api-version=2021-02-01&format=text'",
"curl -s -H Metadata:true 'http://169.254.169.254/metadata/instance?api-version=2021-02-01' | python3 -c \"import sys,json; d=json.load(sys.stdin); print('vm',d['compute']['name']); print('rg',d['compute']['resourceGroupName']); print('loc',d['compute']['location']); print('pubip',d['network']['interface'][0]['ipv4']['ipAddress'][0].get('publicIpAddress','?'))\" 2>/dev/null || true",
]
for cmd in meta_cmds:
print(">>>", cmd[-60:])
_, o, _ = c.exec_command(cmd, timeout=20)
print(o.read().decode().strip())
c.close()
+13
View File
@@ -0,0 +1,13 @@
import os
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
_, o, _ = c.exec_command(
"curl -s http://127.0.0.1:7777/guest-customer-service | grep CHATWOOT_CONFIG | head -c 300",
timeout=30,
)
print(o.read().decode())
c.close()
+19
View File
@@ -0,0 +1,19 @@
import os
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
cmds = [
"curl -s http://127.0.0.1:7777/guest-customer-service -H 'Host: 20.191.152.26:7777' | grep -E 'CHATWOOT_CONFIG|base_url|127.0.0.1' | head -5",
"sudo docker exec guest-chat printenv FRONTEND_URL CHATWOOT_GUEST_BASE_URL",
"grep -n 'request.host' /home/wakka/app.py 2>/dev/null || sudo docker exec guest-chat grep -n 'request.host' /opt/guest-cs/app.py | head -3",
]
for cmd in cmds:
print("===", cmd[:80])
_, o, _ = c.exec_command(cmd, timeout=30)
print(o.read().decode(errors="replace")[:800])
c.close()
+14
View File
@@ -0,0 +1,14 @@
import os
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
cmd = """curl -s -o /dev/null -w '%{http_code}' 'http://127.0.0.1:7777/api/v1/widget/messages?website_token=j1UAjqBgZMWQ8WfczgJSymQx&cw_conversation=test'
echo
curl -s 'http://127.0.0.1:7777/cw/widget?website_token=j1UAjqBgZMWQ8WfczgJSymQx' | grep -o 'CABLE_ORIGIN = \"[^\"]*\"' | head -1
"""
_, o, _ = c.exec_command(cmd, timeout=30)
print(o.read().decode())
c.close()
+28
View File
@@ -0,0 +1,28 @@
import os
import sys
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
script = r"""
sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -c "
SELECT m.id, m.conversation_id, m.message_type, m.sender_type, left(m.content,50) AS content, m.created_at
FROM messages m
WHERE m.content LIKE '%特平尾%' OR m.conversation_id >= 4
ORDER BY m.id DESC LIMIT 20;
"
sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -c "
SELECT c.id, c.display_id, c.status, count(m.id) AS msgs
FROM conversations c
LEFT JOIN messages m ON m.conversation_id = c.id
GROUP BY c.id ORDER BY c.id DESC LIMIT 6;
"
"""
_, o, e = c.exec_command(script, timeout=60)
sys.stdout.buffer.write(o.read())
c.close()
+22
View File
@@ -0,0 +1,22 @@
import os
import sys
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
cmds = [
"sudo docker ps -a --filter name=guest-chat --format '{{.Status}}'",
"sudo docker logs --tail 40 guest-chat 2>&1",
"ss -tlnp | grep 7777 || true",
"curl -s -o /dev/null -w 'guest:%{http_code}\\n' http://127.0.0.1:7777/guest-customer-service || echo curl_fail",
]
for cmd in cmds:
print("====", cmd)
_, o, e = c.exec_command(cmd, timeout=60)
print(o.read().decode("utf-8", "replace"))
err = e.read().decode("utf-8", "replace")
if err:
print("ERR", err[:500])
c.close()
+54
View File
@@ -0,0 +1,54 @@
import ast
import pathlib
import re
import subprocess
import tempfile
text = pathlib.Path(r"F:/AI/guest-cs-stack/flask/chatwoot_proxy.py").read_text(encoding="utf-8")
mod = ast.parse(text)
found = None
for node in ast.walk(mod):
if isinstance(node, ast.Constant) and isinstance(node.value, str) and "polishQuickQuestions" in node.value:
found = node.value
break
# strip script tags, keep JS
js = found
js = re.sub(r"</?script[^>]*>", "\n", js, flags=re.I)
# remove style blocks if any
js = re.sub(r"<style[\s\S]*?</style>", "\n", js, flags=re.I)
# remove remaining HTML-ish lines starting with <
lines = []
for line in js.splitlines():
s = line.strip()
if s.startswith("<") and not s.startswith("<!"):
continue
lines.append(line)
js = "\n".join(lines)
with tempfile.NamedTemporaryFile("w", suffix=".js", delete=False, encoding="utf-8") as f:
f.write(js)
path = f.name
r = subprocess.run(["node", "--check", path], capture_output=True, text=True)
print("return", r.returncode)
print(r.stderr[:2000] if r.stderr else "OK")
print("wrote", path, "len", len(js))
# Also locate any /... patterns that look broken: newline inside
for m in re.finditer(r"/[^/\n]*\n[^/]*/[gimsuy]*", js):
print("MULTILINE REGEX?", repr(m.group(0)[:100]))
# Find SyntaxError line from node if possible by binary search
if r.returncode != 0:
err = r.stderr
m = re.search(r":(\d+)\n", err)
if m:
ln = int(m.group(1))
all_lines = js.splitlines()
start = max(0, ln - 5)
end = min(len(all_lines), ln + 5)
print("--- around error ---")
for i in range(start, end):
mark = ">>" if i + 1 == ln else " "
print(f"{mark}{i+1}: {all_lines[i]}")
+51
View File
@@ -0,0 +1,51 @@
import os
import sys
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
script = r"""
echo '=== env ==='
sudo docker exec guest-chat printenv MAXKB_BASE_URL MAXKB_ACCESS_TOKEN MAXKB_BRIDGE_ENABLED CHATWOOT_AGENT_BOT_TOKEN
echo '=== agent bot webhook ==='
sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -t -A -c "SELECT name, outgoing_url FROM agent_bots WHERE id=1;"
echo '=== maxkb auth+open+chat test ==='
sudo docker exec guest-chat python3 - <<'PY'
import os, json, urllib.request, sys
sys.path.insert(0, '/opt/guest-cs')
base = os.environ.get('MAXKB_BASE_URL', '')
tok = os.environ.get('MAXKB_ACCESS_TOKEN', '')
print('MAXKB', base)
try:
req = urllib.request.Request(base + '/chat/api/auth/anonymous', data=json.dumps({'access_token': tok}).encode(), headers={'Content-Type': 'application/json'}, method='POST')
with urllib.request.urlopen(req, timeout=25) as r:
body = json.loads(r.read().decode())
print('auth', body.get('code'), str(body.get('data', ''))[:40] + '...')
jwt = body['data']
req2 = urllib.request.Request(base + '/chat/api/open', headers={'Authorization': 'Bearer ' + jwt})
with urllib.request.urlopen(req2, timeout=25) as r:
open_body = json.loads(r.read().decode())
chat_id = open_body['data']
print('open chat_id', chat_id)
req3 = urllib.request.Request(base + '/chat/api/chat_message/' + chat_id, data=json.dumps({'message': '你好', 'stream': False, 're_chat': False, 'form_data': {'username': 'test'}}).encode(), headers={'Authorization': 'Bearer ' + jwt, 'Content-Type': 'application/json'}, method='POST')
with urllib.request.urlopen(req3, timeout=120) as r:
ans = json.loads(r.read().decode())
content = (ans.get('data') or {}).get('content', '')[:120]
print('chat reply', ans.get('code'), content)
except Exception as e:
print('FAIL', e)
PY
echo '=== recent bridge logs ==='
sudo docker logs guest-chat 2>&1 | grep -iE 'MaxKB|bridge|webhook/bot1' | tail -8
"""
_, o, e = c.exec_command(script, timeout=180)
sys.stdout.buffer.write(o.read())
sys.stdout.buffer.write(e.read())
c.close()
+20
View File
@@ -0,0 +1,20 @@
import os
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
cmds = [
"curl -s -H Metadata:true 'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/' -d 'grant_type=client_credentials' 2>&1 | head -c 300",
"curl -s -H Metadata:true 'http://169.254.169.254/metadata/instance/compute/subscriptionId?api-version=2021-02-01&format=text'",
]
for cmd in cmds:
print("===", cmd[:80])
_, o, e = c.exec_command(cmd, timeout=20)
print(o.read().decode()[:500])
print(e.read().decode()[:200])
c.close()
+43
View File
@@ -0,0 +1,43 @@
import os
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
script = r"""
python3 <<'PY'
import json, urllib.request
def meta(path):
req = urllib.request.Request(
f"http://169.254.169.254{path}",
headers={"Metadata": "true"},
)
with urllib.request.urlopen(req, timeout=5) as r:
return r.read().decode()
try:
tok = json.loads(meta(
"/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fmanagement.azure.com%2F"
))
print("MI_TOKEN", "yes" if tok.get("access_token") else "no")
except Exception as e:
print("MI_TOKEN", "no", e)
inst = json.loads(meta("/metadata/instance?api-version=2021-02-01"))
comp = inst["compute"]
net = inst["network"]["interface"][0]
print("VM", comp["name"])
print("RG", comp["resourceGroupName"])
print("LOC", comp["location"])
print("PUBIP", net["ipv4"]["ipAddress"][0].get("publicIpAddress", ""))
PY
curl -sI http://127.0.0.1:7777/guest-customer-service | grep -i server
"""
_, o, e = c.exec_command(script, timeout=30)
print(o.read().decode())
print(e.read().decode())
c.close()
+25
View File
@@ -0,0 +1,25 @@
import os
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
cmds = [
"hostname -I",
"curl -s http://127.0.0.1:7777/guest-customer-service | grep CHATWOOT_CONFIG",
"sudo docker exec guest-chat printenv CHATWOOT_GUEST_BASE_URL FRONTEND_URL",
"curl -s -H Metadata:true 'http://169.254.169.254/metadata/instance/compute?api-version=2021-02-01&format=text' 2>/dev/null | head -20",
"command -v az || echo no-az",
]
for cmd in cmds:
print("===", cmd)
_, o, e = c.exec_command(cmd, timeout=30)
print(o.read().decode()[:800])
err = e.read().decode()
if err.strip():
print("ERR:", err[:200])
c.close()
+19
View File
@@ -0,0 +1,19 @@
import os, paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
cmds = [
"sudo docker ps -a",
"ss -tlnp | grep 7777 || true",
"curl -sI --max-time 20 http://127.0.0.1:7777/guest-customer-service | head -12",
"sudo docker logs guest-chat --tail 50 2>&1",
]
for cmd in cmds:
print("===", cmd)
_, o, e = c.exec_command(cmd, timeout=120)
print(o.read().decode())
err = e.read().decode()
if err:
print("ERR:", err[:800])
c.close()
+19
View File
@@ -0,0 +1,19 @@
import os, paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
cmds = [
"sudo ufw status 2>/dev/null || echo ufw_n/a",
"sudo iptables -L INPUT -n 2>/dev/null | head -15 || true",
"curl -s http://127.0.0.1:7777/guest-customer-service | grep -o 'guest_new_path[^,]*' | head -1",
"sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -c 'SELECT count(*) AS accounts FROM accounts;'",
]
for cmd in cmds:
print("===", cmd)
_, o, e = c.exec_command(cmd, timeout=60)
print(o.read().decode())
err = e.read().decode()
if err:
print("ERR:", err[:400])
c.close()
+15
View File
@@ -0,0 +1,15 @@
import os
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
cmd = r"""sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -c "
SELECT id, pre_chat_form_enabled, feature_flags FROM channel_web_widgets;
SELECT m.id, m.conversation_id, m.message_type, left(m.content,60), m.created_at FROM messages ORDER BY id DESC LIMIT 5;
"
"""
_, o, _ = c.exec_command(cmd, timeout=30)
print(o.read().decode(errors='replace'))
c.close()
@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}游客客服|{{ app_name }}{% endblock %}
{% block body_class %}guest-page{% endblock %}
{% block body %}
<header class="topbar">
<div>
<div class="site-title">游客客服中心</div>
<div class="site-subtitle">Guest Customer Service</div>
</div>
<a class="back-link" href="{{ url_for('login') }}">返回登录页</a>
</header>
<main class="page narrow">
<section class="support-panel guest-support-panel">
<div class="support-avatar">🎧</div>
<span class="guest-badge">游客模式</span>
<h1>欢迎使用在线客服</h1>
<p>您目前以游客身份进入,可咨询注册、登录、活动与平台操作问题。</p>
<div class="alert alert-warning">
游客模式不会识别会员身份,也不能查询充值、提款、余额或其他会员资料。
</div>
<button
type="button"
class="primary-button guest-open-chat"
onclick="if (window.openGuestChatNewConversation) { window.openGuestChatNewConversation(); }"
>
开启在线客服
</button>
</section>
</main>
{% endblock %}
{% block integrations %}
{% include "_chatwoot.html" %}
{% endblock %}
@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}游客客服|{{ app_name }}{% endblock %}
{% block body_class %}guest-page{% endblock %}
{% block body %}
<header class="topbar">
<div>
<div class="site-title">游客客服中心</div>
<div class="site-subtitle">Guest Customer Service</div>
</div>
<a class="back-link" href="{{ url_for('login') }}">返回登录页</a>
</header>
<main class="page narrow">
<section class="support-panel guest-support-panel">
<div class="support-avatar">🎧</div>
<span class="guest-badge">游客模式</span>
<h1>欢迎使用在线客服</h1>
<p>您目前以游客身份进入,可咨询注册、登录、活动与平台操作问题。</p>
<div class="alert alert-warning">
游客模式不会识别会员身份,也不能查询充值、提款、余额或其他会员资料。
</div>
<button
type="button"
class="primary-button guest-open-chat"
onclick="if (window.openGuestChatNewConversation) { window.openGuestChatNewConversation(); }"
>
开启在线客服
</button>
</section>
</main>
{% endblock %}
{% block integrations %}
{% include "_chatwoot.html" %}
{% endblock %}
@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}遊戲首頁|{{ app_name }}{% endblock %}
{% block body %}
<header class="topbar">
<div>
<div class="site-title">YD GAME DEMO</div>
<div class="site-subtitle">Python 唯讀 API 測試站點</div>
</div>
<div class="member-area">
<span>用戶:<strong>{{ account.raw_username or username }}</strong></span>
<span class="status-badge {{ account.status }}">
{{ "已凍結" if account.status == "locked" else "正常" }}
</span>
<form
method="post"
action="{{ url_for('logout') }}"
onsubmit="return window.resetChatwootBeforeLogout ? window.resetChatwootBeforeLogout(this) : true;"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="logout-button" type="submit">登出</button>
</form>
</div>
</header>
<main class="page">
{% if warning %}
<div class="alert alert-error">帳戶資料讀取失敗:{{ warning }}</div>
{% endif %}
{% if account.status == "locked" %}
<div class="alert alert-warning">
<strong>帳戶已凍結</strong>
{% if account.locked_reason %}|凍結原因:{{ account.locked_reason }}{% endif %}
</div>
{% endif %}
<section class="hero">
<div>
<span class="eyebrow">PYTHON TEST ENVIRONMENT</span>
<h1>歡迎進入遊戲站點模擬首頁</h1>
<p>登入會員:{{ account.username_clean or username }}。本頁只用於驗證會員資料、充值、提款與客服 API。</p>
</div>
<a class="service-entry" href="{{ url_for('customer_service') }}">
<span class="service-icon">🎧</span>
<span><strong>在線客服</strong><small>查詢充值與提款資料</small></span>
</a>
</section>
<section class="account-grid">
<div class="metric-card"><span>UID</span><strong>{{ account.uid or "NA" }}</strong></div>
<div class="metric-card"><span>VIP 等級</span><strong>{{ account.vip_level or "NA" }}</strong></div>
<div class="metric-card"><span>會員餘額</span><strong>{{ account.balance or "0.0000" }}</strong></div>
<div class="metric-card"><span>凍結餘額</span><strong>{{ account.frozen_balance or "0.0000" }}</strong></div>
</section>
<section>
<div class="section-heading"><h2>熱門遊戲</h2><span>畫面模擬,不執行真實投注</span></div>
<div class="game-grid">
{% for game in games %}
<article class="game-card">
<div class="game-icon">{{ game.icon }}</div>
<div><h3>{{ game.name }}</h3><p>{{ game.category }}</p></div>
<button type="button" disabled>模擬遊戲</button>
</article>
{% endfor %}
</div>
</section>
</main>
<a class="floating-service" href="{{ url_for('customer_service') }}">🎧 客服</a>
{% endblock %}
{% block integrations %}
{% include "_chatwoot.html" %}
{% endblock %}
@@ -0,0 +1,74 @@
{% extends "base.html" %}
{% block title %}遊戲首頁|{{ app_name }}{% endblock %}
{% block body %}
<header class="topbar">
<div>
<div class="site-title">YD GAME DEMO</div>
<div class="site-subtitle">Python 唯讀 API 測試站點</div>
</div>
<div class="member-area">
<span>用戶:<strong>{{ account.raw_username or username }}</strong></span>
<span class="status-badge {{ account.status }}">
{{ "已凍結" if account.status == "locked" else "正常" }}
</span>
<form
method="post"
action="{{ url_for('logout') }}"
onsubmit="return window.resetChatwootBeforeLogout ? window.resetChatwootBeforeLogout(this) : true;"
>
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button class="logout-button" type="submit">登出</button>
</form>
</div>
</header>
<main class="page">
{% if warning %}
<div class="alert alert-error">帳戶資料讀取失敗:{{ warning }}</div>
{% endif %}
{% if account.status == "locked" %}
<div class="alert alert-warning">
<strong>帳戶已凍結</strong>
{% if account.locked_reason %}|凍結原因:{{ account.locked_reason }}{% endif %}
</div>
{% endif %}
<section class="hero">
<div>
<span class="eyebrow">PYTHON TEST ENVIRONMENT</span>
<h1>歡迎進入遊戲站點模擬首頁</h1>
<p>登入會員:{{ account.username_clean or username }}。本頁只用於驗證會員資料、充值、提款與客服 API。</p>
</div>
<a class="service-entry" href="{{ url_for('customer_service') }}">
<span class="service-icon">🎧</span>
<span><strong>在線客服</strong><small>查詢充值與提款資料</small></span>
</a>
</section>
<section class="account-grid">
<div class="metric-card"><span>UID</span><strong>{{ account.uid or "NA" }}</strong></div>
<div class="metric-card"><span>VIP 等級</span><strong>{{ account.vip_level or "NA" }}</strong></div>
<div class="metric-card"><span>會員餘額</span><strong>{{ account.balance or "0.0000" }}</strong></div>
<div class="metric-card"><span>凍結餘額</span><strong>{{ account.frozen_balance or "0.0000" }}</strong></div>
</section>
<section>
<div class="section-heading"><h2>熱門遊戲</h2><span>畫面模擬,不執行真實投注</span></div>
<div class="game-grid">
{% for game in games %}
<article class="game-card">
<div class="game-icon">{{ game.icon }}</div>
<div><h3>{{ game.name }}</h3><p>{{ game.category }}</p></div>
<button type="button" disabled>模擬遊戲</button>
</article>
{% endfor %}
</div>
</section>
</main>
<a class="floating-service" href="{{ url_for('customer_service') }}">🎧 客服</a>
{% endblock %}
{% block integrations %}
{% include "_chatwoot.html" %}
{% endblock %}
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+26
View File
@@ -0,0 +1,26 @@
import os
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
cmds = [
"curl -s http://127.0.0.1:7777/guest-customer-service | grep -E 'guest_new_path|base_url|csrf' | head -3",
"curl -s -X POST http://127.0.0.1:7777/guest-chat/new -H 'Content-Type: application/json' -d '{}' -w '\\nHTTP:%{http_code}\\n'",
"sudo docker logs guest-chat --tail 80 2>&1",
"sudo docker exec guest-chat curl -sI http://127.0.0.1:3000 2>&1 | head -5",
"sudo docker exec guest-chat curl -s http://127.0.0.1:3000/api/v1/widget/conversations -X POST -H 'Content-Type: application/json' -d '{}' 2>&1 | head -c 300",
]
for cmd in cmds:
print("===", cmd[:100])
_, o, e = c.exec_command(cmd, timeout=60)
out = o.read().decode(errors="replace")
err = e.read().decode(errors="replace")
print(out[:2000])
if err.strip():
print("ERR:", err[:300])
c.close()
+51
View File
@@ -0,0 +1,51 @@
import os
import sys
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
script = r"""
echo '=== bot webhook ==='
sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -t -A -c "SELECT outgoing_url FROM agent_bots LIMIT 1;"
echo '=== maxkb env ==='
sudo docker exec guest-chat printenv MAXKB_BASE_URL MAXKB_ACCESS_TOKEN MAXKB_BRIDGE_ENABLED
echo '=== maxkb reachability ==='
sudo docker exec guest-chat python3 - <<'PY'
import json, os, urllib.request
base=os.environ.get('MAXKB_BASE_URL','')
tok=os.environ.get('MAXKB_ACCESS_TOKEN','')
print('base', base)
try:
req=urllib.request.Request(base+'/chat/api/auth/anonymous', data=json.dumps({'access_token':tok}).encode(), headers={'Content-Type':'application/json'}, method='POST')
with urllib.request.urlopen(req, timeout=20) as r:
print('auth', r.status, r.read()[:200])
except Exception as e:
print('auth_fail', e)
PY
echo '=== recent guest-cs logs ==='
sudo docker logs guest-chat 2>&1 | grep -iE 'webhook|MaxKB|chatwoot|bridge|error|exception|502' | tail -30
echo '=== test webhook locally ==='
sudo docker exec guest-chat python3 - <<'PY'
import json, urllib.request
payload={"event":"message_created","message_type":"incoming","content":"123","conversation":{"id":1,"inbox_id":1,"contact_inbox":{"source_id":"test"}},"sender":{"id":1}}
req=urllib.request.Request('http://127.0.0.1:7788/webhook/bot1/chatwoot', data=json.dumps(payload).encode(), headers={'Content-Type':'application/json'}, method='POST')
try:
with urllib.request.urlopen(req, timeout=15) as r:
print('webhook', r.status, r.read()[:200])
except Exception as e:
if hasattr(e,'read'): print('webhook_fail', e.read()[:500])
else: print('webhook_fail', e)
PY
"""
_, o, e = c.exec_command(script, timeout=120)
sys.stdout.buffer.write(o.read())
sys.stdout.buffer.write(e.read())
c.close()
+45
View File
@@ -0,0 +1,45 @@
import os
import sys
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
script = r"""
sudo docker exec guest-chat python3 -c "
import os, json, urllib.request, traceback
base=os.environ.get('MAXKB_BASE_URL','')
tok=os.environ.get('MAXKB_ACCESS_TOKEN','')
bot=os.environ.get('CHATWOOT_AGENT_BOT_TOKEN','')
print('MAXKB_BASE_URL', base)
print('BOT_TOKEN', bot[:8]+'...' if bot else 'EMPTY')
try:
req=urllib.request.Request(base+'/chat/api/auth/anonymous', data=json.dumps({'access_token':tok}).encode(), headers={'Content-Type':'application/json'}, method='POST')
with urllib.request.urlopen(req, timeout=25) as r:
body=r.read().decode()
print('maxkb_auth_ok', r.status, body[:250])
except Exception as e:
print('maxkb_auth_err', repr(e))
traceback.print_exc()
payload={'event':'message_created','message_type':0,'content':'123','conversation':{'id':999,'account_id':1,'inbox_id':1},'account':{'id':1},'sender':{'id':1,'name':'test'}}
try:
req=urllib.request.Request('http://127.0.0.1:7788/webhook/bot1/chatwoot', data=json.dumps(payload).encode(), headers={'Content-Type':'application/json'}, method='POST')
with urllib.request.urlopen(req, timeout=20) as r:
print('webhook_ok', r.status, r.read()[:200])
except Exception as e:
if hasattr(e,'read'): print('webhook_err_body', e.read()[:500])
print('webhook_err', repr(e))
"
sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -t -A -c "SELECT access_token FROM agent_bots WHERE id=1;"
sudo docker logs guest-chat 2>&1 | grep -iE 'MaxKB|bridge|webhook|Chatwoot.*回覆|agent_bot' | tail -20
"""
_, o, e = c.exec_command(script, timeout=120)
sys.stdout.buffer.write(o.read())
sys.stdout.buffer.write(e.read())
c.close()
+59
View File
@@ -0,0 +1,59 @@
import os
import sys
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
script = r"""
echo '=== latest messages ==='
sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -c "SELECT id, message_type, left(content,40) AS content, created_at FROM messages ORDER BY id DESC LIMIT 8;"
echo '=== bridge logs (last 25) ==='
sudo docker logs guest-chat 2>&1 | grep -iE 'MaxKB|bridge|401|failed|reply_len|exception|特平|webhook/bot1' | tail -25
echo '=== test maxkb ask ==='
sudo docker exec guest-chat python3 - <<'PY'
import os, json, urllib.request, sys, time
sys.path.insert(0,'/opt/guest-cs')
base=os.environ['MAXKB_BASE_URL']
tok=os.environ['MAXKB_ACCESS_TOKEN']
t0=time.time()
req=urllib.request.Request(base+'/chat/api/auth/anonymous', data=json.dumps({'access_token':tok}).encode(), headers={'Content-Type':'application/json'}, method='POST')
with urllib.request.urlopen(req, timeout=30) as r:
jwt=json.loads(r.read())['data']
req2=urllib.request.Request(base+'/chat/api/open', headers={'Authorization':'Bearer '+jwt})
with urllib.request.urlopen(req2, timeout=30) as r:
cid=json.loads(r.read())['data']
body={'message':'特平尾怎么赔?','stream':False,'re_chat':False,'form_data':{'username':'游客'}}
req3=urllib.request.Request(base+f'/chat/api/chat_message/{cid}', data=json.dumps(body).encode(), headers={'Authorization':'Bearer '+jwt,'Content-Type':'application/json'}, method='POST')
with urllib.request.urlopen(req3, timeout=120) as r:
ans=json.loads(r.read())
print('elapsed', round(time.time()-t0,1), 's')
print('code', ans.get('code'))
data=ans.get('data') or {}
print('reply', (data.get('content') or '')[:200])
PY
echo '=== simulate webhook for user msg ==='
sudo docker exec guest-chat python3 - <<'PY'
import json, urllib.request, sys
sys.path.insert(0,'/opt/guest-cs')
import maxkb_chatwoot_bridge as m
# get latest incoming msg id from db would be ideal; use high id
p={'event':'conversation_updated','id':5,'account':{'id':1},'messages':[{'id':9999991,'content':'特平尾怎么赔?','message_type':0,'private':False,'conversation_id':5,'account_id':1,'sender':{'name':'游客'}}]}
print('extract', m._extract_incoming(p))
req=urllib.request.Request('http://127.0.0.1:7788/webhook/bot1/chatwoot', data=json.dumps(p).encode(), headers={'Content-Type':'application/json'}, method='POST')
with urllib.request.urlopen(req, timeout=180) as r:
print('webhook', r.status, r.read()[:300])
PY
sleep 3
sudo docker logs guest-chat 2>&1 | grep -iE 'bridge|MaxKB|401|reply_len|failed' | tail -8
"""
_, o, e = c.exec_command(script, timeout=240)
sys.stdout.buffer.write(o.read())
sys.stdout.buffer.write(e.read())
c.close()
+25
View File
@@ -0,0 +1,25 @@
import os
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
cmds = [
"""sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -c "SELECT id,name,outgoing_url FROM agent_bots;" """,
"""sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -c "SELECT id,status,content FROM messages ORDER BY id DESC LIMIT 5;" """,
"sudo docker logs guest-chat --tail 120 2>&1",
"sudo docker exec guest-chat printenv MAXKB_BASE_URL MAXKB_BRIDGE_ENABLED CHATWOOT_AGENT_BOT_TOKEN",
]
for cmd in cmds:
print("===", cmd[:90])
_, o, e = c.exec_command(cmd, timeout=90)
out = o.read().decode(errors="replace")
err = e.read().decode(errors="replace")
print(out[:4000])
if err.strip():
print("ERR:", err[:300])
c.close()
+43
View File
@@ -0,0 +1,43 @@
"""Deploy fixed chatwoot_proxy.py (safe polishQuickQuestions) to guest-chat."""
import os
import sys
from pathlib import Path
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
local = Path(r"F:/AI/guest-cs-stack/flask/chatwoot_proxy.py")
remote_sh = "/home/wakka/_deploy_proxy_fix.sh"
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
sftp = c.open_sftp()
with sftp.file("/home/wakka/chatwoot_proxy.py.patch", "wb") as rf:
rf.write(local.read_bytes().replace(b"\r\n", b"\n"))
script = b"""#!/bin/bash
set -e
sudo docker cp /home/wakka/chatwoot_proxy.py.patch guest-chat:/opt/guest-cs/chatwoot_proxy.py
sudo docker restart guest-chat
sleep 14
curl -s -o /dev/null -w "guest:%{http_code}\\n" http://127.0.0.1:7777/guest-customer-service
html=$(curl -s "http://127.0.0.1:7777/cw/widget?website_token=j1UAjqBgZMWQ8WfczgJSymQx" -H "Host: 20.191.152.26:7777")
echo "has_pullTags=$(printf '%s' "$html" | grep -c 'function pullTags' || true)"
echo "has_bad_escape=$(printf '%s' "$html" | grep -c 'tagOpen.replace' || true)"
echo "has_qq=$(printf '%s' "$html" | grep -c 'polishQuickQuestions' || true)"
printf '%s' "$html" | grep -n 'pullTags\\|data-yd-qq\\|PROMO_SRC' | head -20
"""
with sftp.file(remote_sh, "wb") as rf:
rf.write(script)
sftp.close()
_, o, e = c.exec_command(f"bash {remote_sh}", timeout=120)
sys.stdout.write(o.read().decode("utf-8", "replace"))
err = e.read().decode("utf-8", "replace")
if err:
sys.stderr.write(err)
code = o.channel.recv_exit_status()
c.close()
sys.exit(code)
+50
View File
@@ -0,0 +1,50 @@
"""Diagnose remote 7777 stuck: guest-new + messages API + chatwoot health."""
import os
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
script = r"""
set -e
echo '=== docker ps ==='
sudo docker ps --filter name=guest-chat --format 'table {{.Names}}\t{{.Status}}'
echo '=== chatwoot rails :3000 ==='
curl -s -o /dev/null -w 'rails root: %{http_code}\n' http://127.0.0.1:3000/ 2>/dev/null || echo rails curl failed
sudo docker exec guest-chat bash -c 'curl -s -o /dev/null -w "internal rails: %{http_code}\n" http://127.0.0.1:3000/' 2>/dev/null || true
echo '=== guest-chat/new ==='
rm -f /tmp/gc.jar /tmp/g.html
curl -s -c /tmp/gc.jar http://127.0.0.1:7777/guest-customer-service -o /tmp/g.html
csrf=$(python3 -c "import re; print(re.search(r'\"csrf_token\":\s*\"([^\"]+)\"', open('/tmp/g.html').read()).group(1))")
NEW=$(curl -s -b /tmp/gc.jar -X POST http://127.0.0.1:7777/guest-chat/new -H 'Content-Type: application/json' -H "X-CSRF-Token: $csrf" -d '{}')
echo "$NEW" | head -c 400
echo
wt=$(python3 -c "import json,sys; d=json.loads(open('/dev/stdin').read()); print(d.get('widget_token',''))" <<< "$NEW")
echo widget_token_len=${#wt}
echo '=== messages via flask proxy ==='
curl -s -o /dev/null -w 'proxy messages: %{http_code}\n' "http://127.0.0.1:7777/api/v1/widget/messages?website_token=j1UAjqBgZMWQ8WfczgJSymQx&cw_conversation=$wt"
curl -s "http://127.0.0.1:7777/api/v1/widget/messages?website_token=j1UAjqBgZMWQ8WfczgJSymQx&cw_conversation=$wt" | head -c 500
echo
echo '=== messages direct chatwoot ==='
curl -s -o /dev/null -w 'direct messages: %{http_code}\n' "http://127.0.0.1:3000/api/v1/widget/messages?website_token=j1UAjqBgZMWQ8WfczgJSymQx&cw_conversation=$wt" 2>/dev/null || echo direct failed
echo '=== FRONTEND_URL ==='
sudo docker exec guest-chat printenv FRONTEND_URL 2>/dev/null || true
sudo docker exec guest-chat printenv CHATWOOT_GUEST_BASE_URL 2>/dev/null || true
echo '=== recent logs ==='
sudo docker logs guest-chat --tail 25 2>&1
"""
_, o, e = c.exec_command(script, timeout=120)
print(o.read().decode())
err = e.read().decode()
if err:
print("ERR:", err[:800])
c.close()
+25
View File
@@ -0,0 +1,25 @@
import os
import sys
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
cmds = [
"ls -la /home/wakka/guest-cs-stack/",
"cd /home/wakka/guest-cs-stack && sudo docker compose -f docker-compose.chat.yaml -f docker-compose.chat.test7777.yaml config 2>&1 | tail -30",
"sudo docker inspect guest-chat --format '{{.Created}} {{range .Config.Env}}{{println .}}{{end}}' | head -20",
]
for cmd in cmds:
print("===", cmd[:80])
_, o, e = c.exec_command(cmd, timeout=60)
print(o.read().decode())
err = e.read().decode()
if err:
print("ERR:", err[:300])
c.close()
+25
View File
@@ -0,0 +1,25 @@
import os
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
cmds = [
"ls -la ~/.ssh/ 2>/dev/null; cat ~/.ssh/id_ed25519.pub 2>/dev/null; cat ~/.ssh/id_rsa.pub 2>/dev/null",
"ssh -o BatchMode=yes -o ConnectTimeout=8 wakka@100.31.73.254 hostname 2>&1",
"ssh -o BatchMode=yes -o ConnectTimeout=8 root@100.31.73.254 hostname 2>&1",
"ssh -o BatchMode=yes -o ConnectTimeout=8 ubuntu@100.31.73.254 hostname 2>&1",
"for k in ~/.ssh/id_ed25519 ~/.ssh/id_rsa; do [ -f \"$k\" ] && ssh -i \"$k\" -o BatchMode=yes -o ConnectTimeout=8 root@100.31.73.254 hostname 2>&1; done",
]
for cmd in cmds:
print("===", cmd[:90])
_, o, e = c.exec_command(cmd, timeout=30)
print(o.read().decode())
err = e.read().decode()
if err.strip():
print("ERR:", err[:200])
c.close()
+54
View File
@@ -0,0 +1,54 @@
"""Apply auto-reply fix on wakka-test-server."""
import os
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
base = os.path.dirname(os.path.abspath(__file__))
remote_stack = "/home/wakka/guest-cs-stack"
compose = "docker-compose.chat.yaml -f docker-compose.chat.test7777.yaml"
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
uploads = [
(os.path.join(base, "flask", "maxkb_chatwoot_bridge.py"), "/home/wakka/maxkb_chatwoot_bridge.py"),
(os.path.join(base, "flask", "app.py"), "/home/wakka/app.py"),
(os.path.join(base, "guest-cs.test.env"), f"{remote_stack}/guest-cs.test.env"),
]
sftp = c.open_sftp()
for local, remote in uploads:
with open(local, "rb") as fh:
data = fh.read().replace(b"\r\n", b"\n")
with sftp.file(remote, "w") as rf:
rf.write(data)
print("uploaded", os.path.basename(local))
sftp.close()
cmd = f"""
set -e
sudo docker cp /home/wakka/maxkb_chatwoot_bridge.py guest-chat:/opt/guest-cs/maxkb_chatwoot_bridge.py
sudo docker cp /home/wakka/app.py guest-chat:/opt/guest-cs/app.py
cd {remote_stack}
sudo docker compose -f {compose} up -d --force-recreate guest-chat
sleep 40
sudo docker exec guest-chat printenv CHATWOOT_AGENT_BOT_TOKEN
sudo docker exec guest-chat python3 -c "
import json, urllib.request
p={{'event':'conversation_updated','id':3,'account':{{'id':1}},'messages':[{{'id':888888,'content':'你好','message_type':0,'private':False,'conversation_id':3,'account_id':1,'sender':{{'name':'游客'}}}}]}}
req=urllib.request.Request('http://127.0.0.1:7788/webhook/bot1/chatwoot', data=json.dumps(p).encode(), headers={{'Content-Type':'application/json'}}, method='POST')
with urllib.request.urlopen(req, timeout=120) as r:
print('webhook', r.status, r.read()[:250])
"
sudo docker logs guest-chat 2>&1 | grep -iE 'bridge|MaxKB|401|reply_len' | tail -6
"""
print(">>> apply fix")
_, o, e = c.exec_command(cmd, timeout=240)
print(o.read().decode(errors="replace"))
err = e.read().decode(errors="replace")
if err.strip():
print(err)
c.close()
+56
View File
@@ -0,0 +1,56 @@
import os
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
base = os.path.dirname(os.path.abspath(__file__))
remote_stack = "/home/wakka/guest-cs-stack"
compose = "docker-compose.chat.yaml -f docker-compose.chat.test7777.yaml"
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
sftp = c.open_sftp()
for name in ["maxkb_chatwoot_bridge.py", "app.py"]:
with open(os.path.join(base, "flask", name), "rb") as fh:
data = fh.read().replace(b"\r\n", b"\n")
with sftp.file(f"/home/wakka/{name}", "w") as rf:
rf.write(data)
with open(os.path.join(base, "guest-cs.test.env"), "rb") as fh:
data = fh.read().replace(b"\r\n", b"\n")
with sftp.file(f"{remote_stack}/guest-cs.test.env", "w") as rf:
rf.write(data)
sftp.close()
cmd = f"""
set -e
cd {remote_stack}
sudo docker compose -f {compose} up -d --force-recreate guest-chat
sleep 40
sudo docker cp /home/wakka/maxkb_chatwoot_bridge.py guest-chat:/opt/guest-cs/maxkb_chatwoot_bridge.py
sudo docker cp /home/wakka/app.py guest-chat:/opt/guest-cs/app.py
# reload gunicorn workers
sudo docker exec guest-chat pkill -HUP gunicorn || sudo docker restart guest-chat
sleep 15
sudo docker exec guest-chat printenv CHATWOOT_AGENT_BOT_TOKEN
sudo docker exec guest-chat python3 -c "
import json, urllib.request, importlib, maxkb_chatwoot_bridge as m
importlib.reload(m)
p={{'event':'conversation_updated','id':3,'account':{{'id':1}},'messages':[{{'id':777777,'content':'你好','message_type':0,'private':False,'conversation_id':3,'account_id':1,'sender':{{'name':'游客'}}}}]}}
print('extract', m._extract_incoming(p))
"
sudo docker exec guest-chat python3 -c "
import json, urllib.request
p={{'event':'conversation_updated','id':3,'account':{{'id':1}},'messages':[{{'id':777776,'content':'你好','message_type':0,'private':False,'conversation_id':3,'account_id':1,'sender':{{'name':'游客'}}}}]}}
req=urllib.request.Request('http://127.0.0.1:7788/webhook/bot1/chatwoot', data=json.dumps(p).encode(), headers={{'Content-Type':'application/json'}}, method='POST')
with urllib.request.urlopen(req, timeout=120) as r:
print('webhook', r.status, r.read()[:300])
"
sleep 5
sudo docker logs guest-chat 2>&1 | grep -iE 'bridge|MaxKB|401|reply_len|橋接' | tail -8
"""
_, o, e = c.exec_command(cmd, timeout=300)
print(o.read().decode(errors="replace"))
print(e.read().decode(errors="replace"))
c.close()
+63
View File
@@ -0,0 +1,63 @@
import os
import paramiko
pw = os.environ["DEPLOY_PASSWORD"]
c = paramiko.SSHClient()
c.set_missing_host_key_policy(paramiko.AutoAddPolicy())
c.connect("20.191.152.26", username="wakka", password=pw, timeout=30)
fix = r"""
sudo docker exec guest-chat bundle exec rails runner "
email = 'admin@localhost.local'
user = User.find_by(email: email)
if user
user.confirm if user.respond_to?(:confirm)
user.update!(confirmed_at: Time.current) if user.respond_to?(:confirmed_at) && user.confirmed_at.nil?
puts \"confirmed user id=#{user.id} confirmed_at=#{user.confirmed_at}\"
else
puts 'user not found'
end
"
"""
print(">>> confirm admin user")
_, o, e = c.exec_command(fix, timeout=120)
print(o.read().decode())
print(e.read().decode())
# test guest-chat/new with csrf
test = r"""
python3 <<'PY'
import re, json, urllib.request
req = urllib.request.Request('http://127.0.0.1:7777/guest-customer-service')
with urllib.request.urlopen(req, timeout=15) as r:
html = r.read().decode()
m = re.search(r'"csrf_token":\s*"([^"]+)"', html)
csrf = m.group(1) if m else ''
print('csrf', csrf[:20] + '...')
post = urllib.request.Request(
'http://127.0.0.1:7777/guest-chat/new',
data=b'{}',
headers={'Content-Type': 'application/json', 'X-CSRF-Token': csrf},
method='POST',
)
try:
with urllib.request.urlopen(post, timeout=30) as r:
body = r.read().decode()
print('status', r.status)
print('body', body[:300])
except Exception as e:
if hasattr(e, 'read'):
print('error', e.read().decode()[:400])
else:
print('error', e)
PY
"""
print(">>> test guest-chat/new")
_, o, e = c.exec_command(test, timeout=60)
print(o.read().decode())
print(e.read().decode())
c.close()
@@ -0,0 +1,8 @@
venv/
__pycache__/
*.pyc
*.pyo
.env
.maxkb_cw_sessions.json
.pytest_cache/
*.md
@@ -0,0 +1,38 @@
APP_NAME=API接口系統
APP_HOST=0.0.0.0
APP_PORT=7788
APP_DEBUG=false
CHATWOOT_ENABLED=true
CHATWOOT_BASE_URL=http://pai.pubuli.com
CHATWOOT_WEBSITE_TOKEN=FZBhe2W4VXSXNT9Ek4etmP3L
CHATWOOT_IDENTITY_SECRET=replace-with-chatwoot-hmac-token
CHATWOOT_DEBUG=false
CHATWOOT_ACCOUNT_ID=1
CHATWOOT_INBOX_ID=1
CHATWOOT_API_EMAIL=
CHATWOOT_API_PASSWORD=
CHATWOOT_GREETING=您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?
SECRET_KEY=replace-with-a-long-random-secret
API_KEY=
SESSION_COOKIE_SECURE=false
DB_HOST=database-yd-ai-reader.ckfyc24ggq6w.us-east-1.rds.amazonaws.com
DB_PORT=3306
DB_USER=root
DB_PASSWORD=replace-with-db-password
DB_NAME=member_system
DB_CHARSET=utf8mb4
DB_CONNECT_TIMEOUT=5
MEMBERS_TABLE=members
WITHDRAW_TABLE=withdraw_records
WITHDRAW_REQUEST_TABLE=withdraw_request_records
DEPOSIT_TABLE=deposit_records
READ_ONLY_MODE=true
AUTO_INIT_SIM_TABLES=false
DEFAULT_ACTIVITY_ID=ad-x1234
DEPOSIT_SEARCH_HOURS=24
MAX_ORDERS=50
@@ -0,0 +1,17 @@
FROM python:3.13-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
APP_HOST=0.0.0.0 \
APP_PORT=7788
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 7788
CMD ["gunicorn", "--bind", "0.0.0.0:7788", "--workers", "2", "--threads", "4", "--timeout", "120", "wsgi:application"]
@@ -0,0 +1,453 @@
{% if chatwoot_config.enabled %}
<script>
(function () {
"use strict";
/*
* 會員身份由 Flask Session 注入,identifier 直接使用 username_clean。
* 不再接受 ?identifier=... 等網址參數,避免使用者冒充其他會員。
*/
const CHATWOOT_CONFIG = {{ chatwoot_config | tojson }};
const CHATWOOT_USER = {{ chatwoot_user | tojson }};
window.chatwootSettings = {
hideMessageBubble: false,
showUnreadMessagesDialog: false,
position: "right",
locale: "zh_TW",
useBrowserLanguage: false,
type: "standard",
darkMode: "auto",
launcherTitle: ""
};
function debugLog() {
if (!CHATWOOT_CONFIG.debug) {
return;
}
console.log.apply(console, arguments);
}
function configureChatwootIdentity() {
if (!window.$chatwoot) {
return;
}
if (!CHATWOOT_USER.is_authenticated) {
if (typeof window.$chatwoot.setCustomAttributes === "function") {
window.$chatwoot.setCustomAttributes({
visitor_type: "guest"
});
}
debugLog("Chatwoot anonymous guest ready");
return;
}
if (
typeof window.$chatwoot.setUser !== "function" ||
!CHATWOOT_USER.identifier
) {
return;
}
const userPayload = {
name: CHATWOOT_USER.name || CHATWOOT_USER.username
};
if (CHATWOOT_USER.identifier_hash) {
userPayload.identifier_hash = CHATWOOT_USER.identifier_hash;
}
window.$chatwoot.setUser(
CHATWOOT_USER.identifier,
userPayload
);
if (typeof window.$chatwoot.setCustomAttributes === "function") {
window.$chatwoot.setCustomAttributes({
visitor_type: "member",
username: CHATWOOT_USER.username || "",
member_uid: CHATWOOT_USER.uid == null
? ""
: String(CHATWOOT_USER.uid),
account_status: CHATWOOT_USER.account_status || "",
locked_reason: CHATWOOT_USER.locked_reason || "",
member_type: CHATWOOT_USER.member_type || "",
vip_level: CHATWOOT_USER.vip_level || "",
tier: CHATWOOT_USER.tier || ""
});
}
}
/*
* Flask 登出前清除 Chatwoot Session,避免下一個登入會員
* 沿用上一位會員的 Chatwoot Cookie 與對話。
*/
window.resetChatwootBeforeLogout = function (form) {
if (!form) {
return true;
}
/*
* 防止使用者重複點擊登出,造成多次提交。
*/
if (form.dataset.submitting === "true") {
return false;
}
form.dataset.submitting = "true";
const submitLogout = function () {
form.submit();
};
if (
window.$chatwoot &&
typeof window.$chatwoot.reset === "function"
) {
try {
window.$chatwoot.reset();
} catch (error) {
console.error(
"Chatwoot reset before logout failed:",
error
);
}
/*
* Chatwoot reset 沒有 Promise 回傳,因此提供短暫緩衝時間。
* 即使前端清理未完成,後端的一次性 guest reset
* 仍會在登出後首次載入匿名頁面時再次執行。
*/
window.setTimeout(
submitLogout,
300
);
return false;
}
submitLogout();
return false;
};
window.openGuestChatNewConversation = function () {
createGuestConversationAndShow({ openAfter: true });
};
window.exitGuestChatAndStartNew = function () {
// 先關掉視窗,再換成新對話並重新打開
closeGuestChat();
createGuestConversationAndShow({ openAfter: true, delayMs: 350 });
};
function createGuestConversationAndShow(options) {
var opts = options || {};
var openAfter = opts.openAfter !== false;
var delayMs = typeof opts.delayMs === "number" ? opts.delayMs : 700;
if (!CHATWOOT_CONFIG.guest_new_path) {
return;
}
fetch(CHATWOOT_CONFIG.guest_new_path, {
method: "POST",
headers: {
Accept: "application/json",
"X-CSRF-Token": CHATWOOT_CONFIG.csrf_token || ""
}
})
.then(function (response) {
return response.json().then(function (data) {
if (!response.ok) {
throw new Error((data && data.message) || "建立游客对话失败");
}
return data;
});
})
.then(function (data) {
if (!data || !data.widget_token) {
throw new Error("Chatwoot 未回傳 widget_token");
}
document.querySelectorAll("iframe[src*='widget']").forEach(
function (iframe) {
const url = new URL(iframe.src, window.location.origin);
url.searchParams.set("cw_conversation", data.widget_token);
url.hash = "/messages";
iframe.src = url.toString();
}
);
if (!openAfter) {
window.__guestWidgetOpen = false;
return;
}
window.__guestWidgetOpen = true;
window.setTimeout(function () {
if (
window.$chatwoot &&
typeof window.$chatwoot.toggle === "function"
) {
window.$chatwoot.toggle("open");
}
}, delayMs);
})
.catch(function (error) {
console.error("Chatwoot guest open failed:", error);
window.alert("客服暂时无法开启,请稍后再试。");
});
};
function isGuestChatOpen() {
var holder = document.querySelector(".woot-widget-holder");
if (holder && !holder.classList.contains("woot--hide")) {
var style = window.getComputedStyle(holder);
var rect = holder.getBoundingClientRect();
if (
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity || "1") > 0 &&
rect.width > 0 &&
rect.height > 0
) {
return true;
}
}
return !!window.__guestWidgetOpen;
}
function openGuestChatResume() {
window.__guestWidgetOpen = true;
if (window.$chatwoot && typeof window.$chatwoot.toggle === "function") {
try {
window.$chatwoot.toggle("open");
return;
} catch (error) {}
}
var holder = document.querySelector(".woot-widget-holder");
if (holder) {
holder.classList.remove("woot--hide");
}
}
function closeGuestChat() {
window.__guestWidgetOpen = false;
if (window.$chatwoot && typeof window.$chatwoot.toggle === "function") {
try {
window.$chatwoot.toggle("close");
return;
} catch (error) {}
}
var holder = document.querySelector(".woot-widget-holder");
if (holder) {
holder.classList.add("woot--hide");
}
}
function bindGuestBubbleToggle() {
if (!CHATWOOT_CONFIG.guest_new_path || window.__guestBubbleBound) {
return;
}
window.__guestBubbleBound = true;
window.addEventListener("chatwoot:opened", function () {
window.__guestWidgetOpen = true;
});
window.addEventListener("chatwoot:closed", function () {
window.__guestWidgetOpen = false;
});
document.addEventListener(
"click",
function (event) {
var target = event.target;
if (!target || typeof target.closest !== "function") {
return;
}
var bubble = target.closest(
".woot-widget-bubble, .woot--bubble-holder, button.woot-widget-bubble"
);
if (!bubble) {
return;
}
// 右下角氣泡:只收起/重開同一個對話,绝不開新單。
event.preventDefault();
event.stopPropagation();
if (isGuestChatOpen()) {
closeGuestChat();
} else {
openGuestChatResume();
}
},
true
);
}
window.addEventListener("message", function (event) {
var raw = event && event.data;
if (typeof raw !== "string" || raw.indexOf("chatwoot-widget:") !== 0) {
return;
}
try {
var payload = JSON.parse(raw.replace("chatwoot-widget:", ""));
if (payload && payload.event === "guestExitNewChat") {
if (typeof window.exitGuestChatAndStartNew === "function") {
window.exitGuestChatAndStartNew();
} else if (typeof window.openGuestChatNewConversation === "function") {
window.openGuestChatNewConversation();
}
}
} catch (error) {}
});
window.addEventListener(
"chatwoot:ready",
function () {
debugLog("chatwoot:ready", window.$chatwoot);
const guestResetRequired =
{{ chatwoot_guest_reset_required | tojson }};
/*
* 只有「剛完成會員登出」且目前未登入時,才清除舊會員身份。
* 一般遊客頁面不會每次都 reset,因此不會破壞既有匿名對話。
*/
if (
!CHATWOOT_USER.is_authenticated &&
guestResetRequired &&
window.$chatwoot &&
typeof window.$chatwoot.reset === "function"
) {
try {
window.$chatwoot.reset();
} catch (error) {
console.error(
"Chatwoot guest reset failed:",
error
);
}
/*
* reset 後稍候再設定 visitor_type=guest。
* 不執行 setUser(),因此不會向 Chatwoot 傳遞用戶名。
*/
window.setTimeout(
configureChatwootIdentity,
300
);
bindGuestBubbleToggle();
return;
}
configureChatwootIdentity();
bindGuestBubbleToggle();
}
);
window.addEventListener(
"chatwoot:error",
function (event) {
console.error(
"chatwoot:error",
event && event.detail ? event.detail : event
);
}
);
if (CHATWOOT_CONFIG.debug) {
window.addEventListener(
"chatwoot:on-message",
function (event) {
debugLog("chatwoot:on-message", event.detail);
}
);
window.addEventListener(
"chatwoot:postback",
function (event) {
debugLog("chatwoot:postback", event.detail);
}
);
window.addEventListener(
"chatwoot:opened",
function () {
debugLog("chatwoot:opened");
}
);
window.addEventListener(
"chatwoot:closed",
function () {
debugLog("chatwoot:closed");
}
);
window.addEventListener(
"chatwoot:on-start-conversation",
function (event) {
debugLog(
"chatwoot:on-start-conversation",
event.detail
);
}
);
}
const script = document.createElement("script");
const firstScript = document.getElementsByTagName("script")[0];
script.src = (
CHATWOOT_CONFIG.base_url +
"/packs/js/sdk.js"
);
script.async = true;
script.onload = function () {
if (!window.chatwootSDK) {
console.error("Chatwoot SDK object not found");
return;
}
window.chatwootSDK.run({
websiteToken: CHATWOOT_CONFIG.website_token,
baseUrl: CHATWOOT_CONFIG.base_url
});
// 父頁強制觸發 iframe 內快捷入口/宣傳圖掛載(同源 /cw)
function boostGuestUi() {
try {
var iframes = document.querySelectorAll("iframe");
for (var i = 0; i < iframes.length; i += 1) {
var iframe = iframes[i];
var src = iframe.getAttribute("src") || "";
if (src.indexOf("widget") === -1 && src.indexOf("/cw") === -1) {
continue;
}
var win = iframe.contentWindow;
if (win && typeof win.__ydEnsureGuestUI === "function") {
win.__ydEnsureGuestUI();
}
}
} catch (err) {}
}
window.setInterval(boostGuestUi, 1000);
window.setTimeout(boostGuestUi, 1500);
window.setTimeout(boostGuestUi, 3500);
};
script.onerror = function () {
console.error(
"Chatwoot SDK load failed:",
script.src
);
};
firstScript.parentNode.insertBefore(
script,
firstScript
);
})();
</script>
{% endif %}
@@ -0,0 +1,627 @@
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)
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body class="{% block body_class %}{% endblock %}">
{% block body %}{% endblock %}
{% block integrations %}{% endblock %}
</body>
</html>
@@ -0,0 +1,256 @@
from __future__ import annotations
import base64
import hashlib
import hmac
import json
import time
import uuid
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from config import CONFIG
_AUTH_CACHE: dict[str, Any] = {"headers": None, "expires_at": 0.0}
DEFAULT_GREETING = (
"您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?"
)
class ChatwootGuestError(RuntimeError):
pass
def _request(
method: str,
path: str,
headers: dict[str, str],
payload: dict[str, Any] | None = None,
) -> tuple[int, dict[str, str], Any]:
body = None
req_headers = dict(headers)
if payload is not None:
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
req_headers["Content-Type"] = "application/json"
req_headers["Accept"] = "application/json"
api_base = (
CONFIG.chatwoot_guest_api_base or CONFIG.chatwoot_base_url
).rstrip("/")
request = Request(
f"{api_base}{path}",
data=body,
headers=req_headers,
method=method,
)
try:
with urlopen(request, timeout=20) as response:
raw = response.read().decode("utf-8", "replace")
data = json.loads(raw) if raw else {}
return response.status, dict(response.headers.items()), data
except HTTPError as exc:
raw = exc.read().decode("utf-8", "replace")
try:
data = json.loads(raw) if raw else {}
except json.JSONDecodeError:
data = {"message": raw[:300]}
raise ChatwootGuestError(
f"Chatwoot {method} {path} 失敗:{exc.code} {data}"
) from exc
except URLError as exc:
raise ChatwootGuestError(f"無法連線 Chatwoot:{exc.reason}") from exc
def _login_headers(*, force: bool = False) -> dict[str, str]:
email = CONFIG.chatwoot_guest_api_email or CONFIG.chatwoot_api_email
password = CONFIG.chatwoot_guest_api_password or CONFIG.chatwoot_api_password
if not email or not password:
raise ChatwootGuestError("未設定 Chatwoot API 帳號密碼")
now = time.time()
cached = _AUTH_CACHE.get("headers")
if not force and cached and now < float(_AUTH_CACHE.get("expires_at") or 0):
return cached
status, headers, _data = _request(
"POST",
"/auth/sign_in",
{},
{
"email": email,
"password": password,
},
)
if status >= 400:
raise ChatwootGuestError("Chatwoot 登入失敗")
token = headers.get("access-token") or headers.get("Access-Token") or ""
client = headers.get("client") or headers.get("Client") or ""
uid = headers.get("uid") or headers.get("Uid") or ""
if not token or not client or not uid:
raise ChatwootGuestError("Chatwoot 登入未回傳 token")
auth_headers = {
"access-token": token,
"client": client,
"uid": uid,
"token-type": "Bearer",
}
_AUTH_CACHE["headers"] = auth_headers
_AUTH_CACHE["expires_at"] = now + 300
return auth_headers
def _dig(data: Any, *keys: str) -> Any:
current = data
for key in keys:
if not isinstance(current, dict):
return None
current = current.get(key)
return current
def _source_id(*payloads: Any) -> str:
for payload in payloads:
if not isinstance(payload, dict):
continue
body = payload.get("payload") or payload
candidates = [
_dig(body, "contact_inbox", "source_id"),
_dig(payload, "contact_inbox", "source_id"),
_dig(
payload,
"last_non_activity_message",
"conversation",
"contact_inbox",
"source_id",
),
]
messages = body.get("messages") if isinstance(body, dict) else None
if isinstance(messages, list) and messages:
candidates.append(
_dig(
messages[0],
"conversation",
"contact_inbox",
"source_id",
)
)
for value in candidates:
if value:
return str(value)
return ""
def _contact_id(payload: Any) -> int | None:
if not isinstance(payload, dict):
return None
body = payload.get("payload") or payload
if not isinstance(body, dict):
return None
contact = body.get("contact") or body
if not isinstance(contact, dict):
return None
contact_id = contact.get("id")
return int(contact_id) if contact_id is not None else None
def create_guest_conversation() -> dict[str, Any]:
"""建立新的遊客聯絡人與對話,讓 widget 直接進入聊天室。"""
identifier = f"guest-{uuid.uuid4().hex[:16]}"
headers = _login_headers()
account_id = CONFIG.chatwoot_account_id
try:
return _create_with_headers(identifier, headers, account_id)
except ChatwootGuestError as exc:
if "401" not in str(exc) and "403" not in str(exc):
raise
headers = _login_headers(force=True)
return _create_with_headers(identifier, headers, account_id)
def _create_with_headers(
identifier: str,
headers: dict[str, str],
account_id: int,
) -> dict[str, Any]:
_, _, contact_body = _request(
"POST",
f"/api/v1/accounts/{account_id}/contacts",
headers,
{
"inbox_id": CONFIG.chatwoot_guest_inbox_id or CONFIG.chatwoot_inbox_id,
"name": "遊客",
"identifier": identifier,
"custom_attributes": {"visitor_type": "guest"},
},
)
contact_id = _contact_id(contact_body)
if not contact_id:
raise ChatwootGuestError("Chatwoot 未回傳 contact id")
source_id = _source_id(contact_body)
if not source_id:
raise ChatwootGuestError("Chatwoot 未回傳 source_id")
# 必須帶同一個 source_id。只傳 contact_id + inbox_id 時,
# ContactInboxBuilder 會再產生一組 WebWidget UUID,
# 歡迎語會掛在新 session,widget JWT 對不到就變成空白對話。
# 歡迎圖不走 Chatwoot 附件,否則 widget 會顯示檔名與「下載」。
_, _, conversation = _request(
"POST",
f"/api/v1/accounts/{account_id}/conversations",
headers,
{
"inbox_id": CONFIG.chatwoot_guest_inbox_id or CONFIG.chatwoot_inbox_id,
"contact_id": contact_id,
"source_id": source_id,
"status": "open",
"message": {
"content": CONFIG.chatwoot_greeting or DEFAULT_GREETING,
"message_type": "outgoing",
},
},
)
conversation_id = conversation.get("id") if isinstance(conversation, dict) else None
return {
"identifier": identifier,
"contact_id": contact_id,
"conversation_id": conversation_id,
"source_id": source_id,
"widget_token": make_widget_token(source_id),
"name": "遊客",
}
def make_widget_token(source_id: str) -> str:
"""產生 Chatwoot widget 的 cw_conversation JWT。"""
secret = CONFIG.chatwoot_widget_jwt_secret
if not secret:
raise ChatwootGuestError("未設定 CHATWOOT_WIDGET_JWT_SECRET")
now = int(time.time())
header = _b64url(json.dumps({"alg": "HS256"}, separators=(",", ":")).encode())
payload = _b64url(
json.dumps(
{
"source_id": source_id,
"inbox_id": CONFIG.chatwoot_guest_inbox_id or CONFIG.chatwoot_inbox_id,
"iat": now,
"exp": now + 60 * 60 * 24 * 180,
},
separators=(",", ":"),
).encode()
)
signing_input = f"{header}.{payload}".encode()
signature = hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).digest()
return f"{header}.{payload}.{_b64url(signature)}"
def _b64url(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,113 @@
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env", override=True)
def env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
@dataclass(frozen=True)
class Config:
app_name: str = os.getenv("APP_NAME", "API接口系统")
host: str = os.getenv("APP_HOST", os.getenv("API_HOST", "0.0.0.0"))
port: int = int(os.getenv("APP_PORT", os.getenv("API_PORT", "7788")))
debug: bool = env_bool("APP_DEBUG", env_bool("API_DEBUG", False))
secret_key: str = os.getenv("SECRET_KEY", "replace-this-secret-key")
api_key: str = os.getenv("API_KEY", "change-me")
session_cookie_secure: bool = env_bool("SESSION_COOKIE_SECURE", False)
db_host: str = os.getenv("DB_HOST", "127.0.0.1")
db_port: int = int(os.getenv("DB_PORT", "3306"))
db_user: str = os.getenv("DB_USER", "api_reader")
db_password: str = os.getenv("DB_PASSWORD", "")
db_name: str = os.getenv("DB_NAME", "member_system")
db_charset: str = os.getenv("DB_CHARSET", "utf8mb4")
db_connect_timeout: int = int(os.getenv("DB_CONNECT_TIMEOUT", "5"))
members_table: str = os.getenv("MEMBERS_TABLE", "members")
withdraw_table: str = os.getenv("WITHDRAW_TABLE", "withdraw_records")
withdraw_request_table: str = os.getenv(
"WITHDRAW_REQUEST_TABLE", "withdraw_request_records"
)
deposit_table: str = os.getenv("DEPOSIT_TABLE", "deposit_records")
read_only_mode: bool = env_bool("READ_ONLY_MODE", True)
auto_init_sim_tables: bool = env_bool("AUTO_INIT_SIM_TABLES", False)
default_activity_id: str = os.getenv("DEFAULT_ACTIVITY_ID", "ad-x1234")
deposit_search_hours: int = int(os.getenv("DEPOSIT_SEARCH_HOURS", "24"))
max_orders: int = int(os.getenv("MAX_ORDERS", "50"))
# Chatwoot 網站客服設定
chatwoot_enabled: bool = env_bool("CHATWOOT_ENABLED", True)
chatwoot_base_url: str = os.getenv(
"CHATWOOT_BASE_URL",
"http://pai.pubuli.com",
).rstrip("/")
chatwoot_website_token: str = os.getenv(
"CHATWOOT_WEBSITE_TOKEN",
"Vivum4g1WWxH1AmxoTivAcX5",
)
# 必須使用 Chatwoot Website Inbox 的 Identity Validation Token。
# 此值只保留在後端,絕不可輸出到 HTML 或 JavaScript。
chatwoot_identity_secret: str = os.getenv(
"CHATWOOT_IDENTITY_SECRET",
"",
)
chatwoot_debug: bool = env_bool("CHATWOOT_DEBUG", False)
chatwoot_account_id: int = int(os.getenv("CHATWOOT_ACCOUNT_ID", "1"))
chatwoot_inbox_id: int = int(os.getenv("CHATWOOT_INBOX_ID", "1"))
chatwoot_api_email: str = os.getenv("CHATWOOT_API_EMAIL", "")
chatwoot_api_password: str = os.getenv("CHATWOOT_API_PASSWORD", "")
chatwoot_greeting: str = os.getenv(
"CHATWOOT_GREETING",
"您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?",
)
chatwoot_greeting_image: str = os.getenv(
"CHATWOOT_GREETING_IMAGE",
"static/guest_promo.png",
)
chatwoot_guest_base_url: str = os.getenv(
"CHATWOOT_GUEST_BASE_URL",
"",
).rstrip("/")
chatwoot_guest_website_token: str = os.getenv(
"CHATWOOT_GUEST_WEBSITE_TOKEN",
"",
)
chatwoot_guest_proxy_target: str = os.getenv(
"CHATWOOT_GUEST_PROXY_TARGET",
"http://127.0.0.1:3000",
).rstrip("/")
chatwoot_guest_api_base: str = os.getenv(
"CHATWOOT_GUEST_API_BASE",
"",
).rstrip("/")
chatwoot_guest_api_email: str = os.getenv("CHATWOOT_GUEST_API_EMAIL", "")
chatwoot_guest_api_password: str = os.getenv("CHATWOOT_GUEST_API_PASSWORD", "")
chatwoot_guest_inbox_id: int = int(os.getenv("CHATWOOT_GUEST_INBOX_ID", "1"))
chatwoot_widget_jwt_secret: str = os.getenv("CHATWOOT_WIDGET_JWT_SECRET", "")
chatwoot_agent_bot_id: int = int(os.getenv("CHATWOOT_AGENT_BOT_ID", "0") or "0")
chatwoot_agent_bot_token: str = os.getenv("CHATWOOT_AGENT_BOT_TOKEN", "")
# 本機 Chatwoot → PAI MaxKB 橋接
maxkb_bridge_enabled: bool = env_bool("MAXKB_BRIDGE_ENABLED", True)
maxkb_base_url: str = os.getenv(
"MAXKB_BASE_URL",
"http://pai.pubuli.com:8080",
).rstrip("/")
maxkb_access_token: str = os.getenv("MAXKB_ACCESS_TOKEN", "")
maxkb_timeout_sec: float = float(os.getenv("MAXKB_TIMEOUT_SEC", "120"))
CONFIG = Config()
@@ -0,0 +1,28 @@
from contextlib import contextmanager
from typing import Iterator
import pymysql
from pymysql.connections import Connection
from config import CONFIG
@contextmanager
def db_connection() -> Iterator[Connection]:
connection = pymysql.connect(
host=CONFIG.db_host,
port=CONFIG.db_port,
user=CONFIG.db_user,
password=CONFIG.db_password,
database=CONFIG.db_name,
charset=CONFIG.db_charset,
cursorclass=pymysql.cursors.DictCursor,
autocommit=True,
connect_timeout=CONFIG.db_connect_timeout,
read_timeout=20,
write_timeout=20,
)
try:
yield connection
finally:
connection.close()
@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}游客客服|{{ app_name }}{% endblock %}
{% block body_class %}guest-page{% endblock %}
{% block body %}
<header class="topbar">
<div>
<div class="site-title">游客客服中心</div>
<div class="site-subtitle">Guest Customer Service</div>
</div>
<a class="back-link" href="{{ url_for('login') }}">返回登录页</a>
</header>
<main class="page narrow">
<section class="support-panel guest-support-panel">
<div class="support-avatar">🎧</div>
<span class="guest-badge">游客模式</span>
<h1>欢迎使用在线客服</h1>
<p>您目前以游客身份进入,可咨询注册、登录、活动与平台操作问题。</p>
<div class="alert alert-warning">
游客模式不会识别会员身份,也不能查询充值、提款、余额或其他会员资料。
</div>
<button
type="button"
class="primary-button guest-open-chat"
onclick="if (window.openGuestChatNewConversation) { window.openGuestChatNewConversation(); }"
>
开启在线客服
</button>
</section>
</main>
{% endblock %}
{% block integrations %}
{% include "_chatwoot.html" %}
{% endblock %}
@@ -0,0 +1,40 @@
{% extends "base.html" %}
{% block title %}登录|{{ app_name }}{% endblock %}
{% block body_class %}login-body{% endblock %}
{% block body %}
<main class="login-card">
<div class="brand-mark">YD</div>
<h1>游戏站点模拟</h1>
<p class="muted">仅使用 members 表内存在的用户名登录,不需要密码。</p>
{% with messages = get_flashed_messages(with_categories=true) %}
{% for category, message in messages %}
<div class="alert alert-{{ category }}">{{ message }}</div>
{% endfor %}
{% endwith %}
<form method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<label for="username">用户名</label>
<input id="username" name="username" type="text" maxlength="191"
value="{{ username }}" placeholder="输入 username 或 username_clean"
required autofocus>
<button type="submit" class="primary-button">登录测试站点</button>
</form>
<div class="login-divider"><span>或</span></div>
<a
class="guest-service-button"
href="{{ url_for('guest_customer_service') }}"
>
🎧 游客进入客服
</a>
<p class="guest-service-note">
游客只能进行一般咨询,不能查询会员充值、提款或账户资料。
</p>
<p class="security-note">【测试限定】此登录方式不具备正式身份验证能力。</p>
</main>
{% endblock %}
@@ -0,0 +1,470 @@
"""Chatwoot Agent Bot webhook → MaxKB(PAI)→ 回寫對話。"""
from __future__ import annotations
import json
import logging
import re
import threading
import time
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
from config import CONFIG
logger = logging.getLogger(__name__)
_SESSION_LOCK = threading.Lock()
_SESSIONS: dict[str, dict[str, Any]] = {}
_SESSION_FILE = Path(__file__).resolve().parent / ".maxkb_cw_sessions.json"
_QUICK_QUESTION_RE = re.compile(
r"<quick_question>\s*(.*?)\s*</quick_question>",
re.IGNORECASE | re.DOTALL,
)
def _load_sessions() -> None:
if not _SESSION_FILE.exists():
return
try:
data = json.loads(_SESSION_FILE.read_text(encoding="utf-8"))
if isinstance(data, dict):
_SESSIONS.update(data)
except Exception:
logger.exception("讀取 MaxKB session 快取失敗")
def _save_sessions() -> None:
try:
_SESSION_FILE.write_text(
json.dumps(_SESSIONS, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except Exception:
logger.exception("寫入 MaxKB session 快取失敗")
_load_sessions()
def _http_json(
method: str,
url: str,
*,
headers: dict[str, str] | None = None,
body: Any = None,
timeout: float = 120,
) -> tuple[int, Any]:
data = None
if body is not None:
data = body if isinstance(body, (bytes, bytearray)) else json.dumps(
body, ensure_ascii=False
).encode("utf-8")
req_headers = {"Content-Type": "application/json", "Accept": "application/json"}
if headers:
req_headers.update(headers)
req = Request(url, data=data, headers=req_headers, method=method)
try:
with urlopen(req, timeout=timeout) as resp:
raw = resp.read()
try:
return resp.status, json.loads(raw.decode("utf-8") or "null")
except Exception:
return resp.status, raw.decode("utf-8", errors="replace")
except HTTPError as exc:
raw = exc.read()
try:
return exc.code, json.loads(raw.decode("utf-8") or "null")
except Exception:
return exc.code, raw.decode("utf-8", errors="replace")
except URLError as exc:
raise RuntimeError(f"HTTP 連線失敗: {url}: {exc}") from exc
def _maxkb_auth() -> str:
status, body = _http_json(
"POST",
f"{CONFIG.maxkb_base_url}/chat/api/auth/anonymous",
body={"access_token": CONFIG.maxkb_access_token},
timeout=30,
)
if status != 200 or not isinstance(body, dict) or body.get("code") != 200:
raise RuntimeError(f"MaxKB anonymous auth 失敗: {status} {body}")
token = body.get("data")
if not token:
raise RuntimeError("MaxKB anonymous auth 未回傳 token")
return str(token)
def _maxkb_open(jwt: str) -> str:
status, body = _http_json(
"GET",
f"{CONFIG.maxkb_base_url}/chat/api/open",
headers={"Authorization": f"Bearer {jwt}"},
timeout=30,
)
if status != 200 or not isinstance(body, dict) or body.get("code") != 200:
raise RuntimeError(f"MaxKB open 失敗: {status} {body}")
chat_id = body.get("data")
if not chat_id:
raise RuntimeError("MaxKB open 未回傳 chat_id")
return str(chat_id)
def _maxkb_ask(
jwt: str,
chat_id: str,
message: str,
form_data: dict[str, Any],
) -> str:
status, body = _http_json(
"POST",
f"{CONFIG.maxkb_base_url}/chat/api/chat_message/{chat_id}",
headers={"Authorization": f"Bearer {jwt}"},
body={
"message": message,
"stream": False,
"re_chat": False,
"form_data": form_data,
},
timeout=CONFIG.maxkb_timeout_sec,
)
if status != 200 or not isinstance(body, dict) or body.get("code") != 200:
raise RuntimeError(f"MaxKB chat 失敗: {status} {body}")
data = body.get("data") or {}
content = ""
if isinstance(data, dict):
content = str(data.get("content") or "").strip()
if not content:
answers = data.get("answer_list") or []
if answers and isinstance(answers[0], dict):
content = str(answers[0].get("content") or "").strip()
if not content:
raise RuntimeError(f"MaxKB 回覆為空: {body}")
return content
def _get_or_create_session(conversation_key: str) -> dict[str, Any]:
with _SESSION_LOCK:
sess = _SESSIONS.get(conversation_key)
if sess and sess.get("jwt") and sess.get("chat_id"):
return dict(sess)
jwt = _maxkb_auth()
chat_id = _maxkb_open(jwt)
sess = {
"jwt": jwt,
"chat_id": chat_id,
"updated_at": int(time.time()),
}
with _SESSION_LOCK:
_SESSIONS[conversation_key] = sess
_save_sessions()
return dict(sess)
def _refresh_session(conversation_key: str) -> dict[str, Any]:
with _SESSION_LOCK:
_SESSIONS.pop(conversation_key, None)
_save_sessions()
return _get_or_create_session(conversation_key)
def _split_quick_questions(reply: str) -> tuple[str, list[str]]:
"""把 MaxKB 的 <quick_question> 標籤拆成純文字 + 按鈕選項。"""
raw = str(reply or "")
items: list[str] = []
for match in _QUICK_QUESTION_RE.findall(raw):
label = " ".join(str(match).split()).strip()
if label and label not in items:
items.append(label)
clean = _QUICK_QUESTION_RE.sub("", raw)
clean = re.sub(r"[ \t]+\n", "\n", clean)
clean = re.sub(r"\n{3,}", "\n\n", clean).strip()
return clean, items
def _chatwoot_reply(
account_id: int,
conversation_id: int,
content: str,
*,
content_type: str = "text",
content_attributes: dict[str, Any] | None = None,
) -> None:
base = (
CONFIG.chatwoot_guest_api_base
or CONFIG.chatwoot_guest_proxy_target
or "http://127.0.0.1:3000"
).rstrip("/")
token = CONFIG.chatwoot_agent_bot_token
if not token:
raise RuntimeError("未設定 CHATWOOT_AGENT_BOT_TOKEN")
url = (
f"{base}/api/v1/accounts/{account_id}/conversations/"
f"{conversation_id}/messages"
)
payload: dict[str, Any] = {
"content": content,
"message_type": "outgoing",
"private": False,
"content_type": content_type,
}
if content_attributes:
payload["content_attributes"] = content_attributes
status, body = _http_json(
"POST",
url,
headers={"api_access_token": token},
body=payload,
timeout=30,
)
if status >= 300:
raise RuntimeError(f"Chatwoot 回覆失敗: {status} {body}")
def _chatwoot_reply_from_maxkb(
account_id: int,
conversation_id: int,
reply: str,
) -> None:
"""回寫 Chatwoot:去掉 quick_question 原文,改成可點選按鈕。"""
clean, items = _split_quick_questions(reply)
if clean:
_chatwoot_reply(account_id, conversation_id, clean)
elif not items:
# 空回覆不送
return
if items:
_chatwoot_reply(
account_id,
conversation_id,
"",
content_type="input_select",
content_attributes={
"items": [{"title": label, "value": label} for label in items]
},
)
def _has_attachments(message: dict[str, Any]) -> bool:
atts = message.get("attachments") or message.get("files") or []
return isinstance(atts, list) and len(atts) > 0
def _is_incoming_message(message: dict[str, Any]) -> bool:
message_type = message.get("message_type")
incoming_types = {0, "0", "incoming", "Incoming"}
if message_type not in incoming_types:
return False
if message.get("private"):
return False
if message.get("content_type") in {"input_email", "form", "input_select"}:
return False
has_text = bool(str(message.get("content") or "").strip())
return has_text or _has_attachments(message)
def _pick_latest_incoming(messages: Any) -> dict[str, Any] | None:
if not isinstance(messages, list):
return None
for msg in reversed(messages):
if isinstance(msg, dict) and _is_incoming_message(msg):
return msg
return None
def _extract_incoming(payload: dict[str, Any]) -> dict[str, Any] | None:
event = str(payload.get("event") or "")
message: dict[str, Any] | None = None
if event == "message_created":
candidate = payload.get("message") or payload
message = candidate if isinstance(candidate, dict) else None
elif event == "conversation_updated":
# Agent Bot webhook 常送 conversation_updated,訊息在 messages 陣列
conversation = payload.get("conversation") if isinstance(payload.get("conversation"), dict) else {}
messages = payload.get("messages") or conversation.get("messages") or []
message = _pick_latest_incoming(messages)
elif not event:
candidate = payload.get("message") or payload
message = candidate if isinstance(candidate, dict) else None
else:
return None
if not message or not _is_incoming_message(message):
return None
content = str(message.get("content") or "").strip()
conversation = message.get("conversation") or payload.get("conversation") or {}
if not isinstance(conversation, dict):
conversation = {}
conversation_id = conversation.get("id") or message.get("conversation_id") or payload.get("id")
account = payload.get("account") if isinstance(payload.get("account"), dict) else {}
account_id = (
message.get("account_id")
or conversation.get("account_id")
or account.get("id")
or CONFIG.chatwoot_account_id
)
if not conversation_id:
return None
sender = message.get("sender") or {}
username = ""
if isinstance(sender, dict):
username = str(
sender.get("name")
or sender.get("identifier")
or (sender.get("additional_attributes") or {}).get("username")
or ""
)
return {
"account_id": int(account_id),
"conversation_id": int(conversation_id),
"content": content,
"username": username,
"message_id": message.get("id"),
}
def _hard_reply_for_complaint(message: str) -> str | None:
"""客诉/提款强制话术,避免意图误判。"""
text = "".join(str(message or "").split())
if "一次不给赢" in text:
return "亲,每次游戏都会有输有赢,关键是保持好心态,接受挑战,最终会有丰厚的回报!"
# YD-CUSTOMER-018
if "回归" in text and "杀" in text:
return (
"亲,游戏输输赢赢很正常的,拼的是智力、耐心、心态和运气,"
"如果您觉得手气不好,可以稍作休息,转移一下注意力,稍后再进行投注,"
"或许下一个大奖就是您的,祝您鸿运当头,盈利多多。"
)
# YD-CUSTOMER-017
low = text.lower()
if (
"pg电子" in low
or "PG电子" in text
or "电子真是假" in text
or "假的我永远" in text
or ("电子" in text and "假" in text)
):
return (
"亲,您好!电子游戏的大奖奖池是根据机率来的哦,每一轮的结果都是随机的,"
"所以不能保证每次都能中奖。希望您能理解,这也是游戏的一部分乐趣所在!"
"我们可以一起再试试,说不定下一次就能碰上好运呢"
)
# YD-WITHDRAW-016(标准为转专人,禁止追问订单号)
if "取消提款" in text or "取消提现" in text or "申请取消提款" in text:
return (
"您好,取消提款需由专员协助核实处理,请稍候为您转接真人客服。"
"<quick_question>转专人服务</quick_question>"
)
return None
def handle_chatwoot_webhook(payload: dict[str, Any]) -> dict[str, Any]:
if not CONFIG.maxkb_bridge_enabled:
return {"ok": True, "skipped": "bridge_disabled"}
incoming = _extract_incoming(payload)
if not incoming:
return {"ok": True, "skipped": "not_incoming"}
account_id = incoming["account_id"]
conversation_id = incoming["conversation_id"]
key = f"{account_id}:{conversation_id}"
message_id = incoming.get("message_id")
content = str(incoming.get("content") or "").strip()
now = int(time.time())
event = str(payload.get("event") or "")
with _SESSION_LOCK:
cur = dict(_SESSIONS.get(key) or {})
if message_id is not None and cur.get("last_message_id") == message_id:
return {"ok": True, "skipped": "duplicate", "event": event}
if (
content
and cur.get("last_content") == content
and now - int(cur.get("last_content_at") or 0) < 45
):
return {"ok": True, "skipped": "duplicate_content", "event": event}
if message_id is not None:
cur["last_message_id"] = message_id
if content:
cur["last_content"] = content
cur["last_content_at"] = now
cur["updated_at"] = now
_SESSIONS[key] = cur
_save_sessions()
# 純圖片/附件沒有文字時,MaxKB 收不到內容,先前會直接略過,畫面像卡住。
if not content:
ack = (
"已收到您的图片。目前客服无法辨识图片内容,"
"请用文字说明您要咨询的问题,例如:充值未到账、无法登录、提款问题。"
)
_chatwoot_reply_from_maxkb(account_id, conversation_id, ack)
return {
"ok": True,
"conversation_id": conversation_id,
"image_ack": True,
"event": event,
}
form_data = {
"account_id": str(account_id),
"conversation_id": str(conversation_id),
"bot_id": str(CONFIG.chatwoot_agent_bot_id or ""),
"username": incoming["username"] or "游客",
}
hard = _hard_reply_for_complaint(incoming["content"])
if hard:
_chatwoot_reply_from_maxkb(account_id, conversation_id, hard)
with _SESSION_LOCK:
cur = dict(_SESSIONS.get(key) or {})
cur["updated_at"] = int(time.time())
cur["hard_reply"] = True
_SESSIONS[key] = cur
_save_sessions()
return {
"ok": True,
"conversation_id": conversation_id,
"hard_reply": True,
"reply_len": len(hard),
"event": event,
}
sess = _get_or_create_session(key)
try:
reply = _maxkb_ask(
sess["jwt"], sess["chat_id"], incoming["content"], form_data
)
except Exception:
logger.exception("MaxKB 對話失敗,嘗試重建 session")
sess = _refresh_session(key)
reply = _maxkb_ask(
sess["jwt"], sess["chat_id"], incoming["content"], form_data
)
_chatwoot_reply_from_maxkb(account_id, conversation_id, reply)
with _SESSION_LOCK:
cur = dict(_SESSIONS.get(key) or {})
cur["updated_at"] = int(time.time())
if message_id is not None:
cur["last_message_id"] = message_id
_SESSIONS[key] = cur
_save_sessions()
return {
"ok": True,
"conversation_id": conversation_id,
"chat_id": sess["chat_id"],
"reply_len": len(reply),
"event": event,
}
@@ -0,0 +1,178 @@
from __future__ import annotations
from typing import Any
from config import CONFIG
from db import db_connection
REQUIRED: dict[str, set[str]] = {
CONFIG.members_table: {
"id",
"uid",
"username",
"username_clean",
"balance",
"remark",
},
CONFIG.withdraw_table: {
"id",
"username",
"username_clean",
"withdraw_amount",
"withdraw_time",
"status",
"remark",
},
CONFIG.withdraw_request_table: {
"id",
"withdraw_request_id",
"username_raw",
"username_clean",
"withdraw_amount",
"request_time",
"operation",
"audit_status",
"remark",
},
CONFIG.deposit_table: {
"id",
"deposit_id",
"username",
"username_clean",
"deposit_amount",
"actual_arrival_amount",
"status",
"deposit_time",
},
}
def extract_column_name(row: Any) -> str:
"""兼容不同 MySQL/PyMySQL 對欄位名稱大小寫的返回方式。"""
if isinstance(row, dict):
for key in (
"column_name_value",
"column_name",
"COLUMN_NAME",
):
value = row.get(key)
if value is not None:
return str(value)
if row:
return str(next(iter(row.values())))
if isinstance(row, (list, tuple)) and row:
return str(row[0])
raise KeyError(f"無法取得欄位名稱:{row!r}")
def fetch_table_columns(cursor: Any, table: str) -> set[str]:
cursor.execute(
"""
SELECT COLUMN_NAME AS column_name_value
FROM information_schema.COLUMNS
WHERE TABLE_SCHEMA=%s
AND TABLE_NAME=%s
ORDER BY ORDINAL_POSITION
""",
(
CONFIG.db_name,
table,
),
)
return {
extract_column_name(row)
for row in cursor.fetchall()
}
def main() -> int:
print("API接口系統資料庫預檢")
print("=" * 48)
print(f"資料庫:{CONFIG.db_name}")
print(f"唯讀模式:{CONFIG.read_only_mode}")
print(f"會員表:{CONFIG.members_table}")
print(f"提款記錄表:{CONFIG.withdraw_table}")
print(f"提款申請表:{CONFIG.withdraw_request_table}")
print(f"充值表:{CONFIG.deposit_table}")
print("-" * 48)
failed = False
try:
with db_connection() as conn, conn.cursor() as cursor:
cursor.execute(
"""
SELECT
DATABASE() AS database_name,
@@global.read_only AS global_read_only
"""
)
server_info = cursor.fetchone() or {}
if isinstance(server_info, dict):
database_name = (
server_info.get("database_name")
or server_info.get("DATABASE_NAME")
or CONFIG.db_name
)
if "global_read_only" in server_info:
mysql_read_only = server_info["global_read_only"]
else:
mysql_read_only = server_info.get("GLOBAL_READ_ONLY")
else:
database_name = CONFIG.db_name
mysql_read_only = "unknown"
print(f"目前連線資料庫:{database_name}")
print(f"MySQL global read_only:{mysql_read_only}")
print("-" * 48)
for table, required_columns in REQUIRED.items():
columns = fetch_table_columns(cursor, table)
missing = sorted(required_columns - columns)
if not columns:
failed = True
print(
f"[失敗] {table}: "
"資料表不存在,或目前帳號無 information_schema 讀取權限"
)
continue
if missing:
failed = True
print(
f"[失敗] {table}: "
f"共 {len(columns)} 個欄位,缺少 {missing}"
)
continue
print(
f"[正常] {table}: "
f"{len(columns)} 個欄位,必要欄位完整"
)
except Exception as exc:
print(
f"[失敗] 資料庫預檢執行錯誤:"
f"{type(exc).__name__}: {exc}"
)
return 1
print("-" * 48)
if failed:
print("預檢結果:存在資料表或欄位問題")
return 1
print("預檢結果:全部正常")
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -0,0 +1,531 @@
import re
from datetime import datetime, timedelta
from decimal import Decimal
from typing import Any, Optional
from config import CONFIG
from db import db_connection
_IDENTIFIER_RE = re.compile(r"^[A-Za-z0-9_]+$")
_FROZEN_MARK_RE = re.compile(r"[\((]?\s*已冻结\s*[\))]?")
class MappingError(RuntimeError):
pass
def safe_identifier(value: str) -> str:
if not value or not _IDENTIFIER_RE.fullmatch(value):
raise MappingError(f"不安全或無效的資料庫識別字:{value!r}")
return f"`{value}`"
def table_name(value: str) -> str:
return safe_identifier(value)
def decimal_string(value: Any, default: str = "0.0000") -> str:
if value is None or value == "":
return default
try:
return format(Decimal(str(value)), "f")
except Exception:
return str(value)
def format_datetime(value: Any) -> str:
if value is None:
return ""
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
return str(value)
def clean_frozen_username(value: str) -> str:
cleaned = _FROZEN_MARK_RE.sub("", str(value or ""))
return cleaned.strip()
def is_finished_status(value: Any) -> bool:
status = str(value or "").strip().lower()
if not status:
return False
final_keywords = (
"success", "successful", "completed", "complete", "approved",
"paid", "done", "rejected", "cancelled", "canceled", "failed",
"成功", "完成", "已支付", "已出款", "出款成功", "审核通过",
"審核通過", "通过", "通過", "拒绝", "拒絕", "驳回", "駁回",
"取消", "失败", "失敗",
)
return any(keyword in status for keyword in final_keywords)
class PlatformRepository:
"""依照現有 member_system 真實表結構建立的唯讀資料層。"""
def ensure_simulation_tables(self) -> None:
"""唯讀測試版不建立資料表,也不對採集庫執行寫入。"""
return
def get_user_account(self, username: str) -> dict[str, Any]:
"""查詢 members,並由 username 是否包含「已冻结」判斷鎖定。"""
login_username = str(username or "").strip()
if not login_username:
return self._not_found_account("")
members = table_name(CONFIG.members_table)
sql = f"""
SELECT
id,
uid,
username,
username_clean,
member_type,
vip_level,
points,
tier,
balance,
frozen_balance,
other_balance,
deposit_count,
deposit_amount,
withdraw_count,
withdraw_amount,
register_time,
last_login_time,
remark,
updated_at
FROM {members}
WHERE username = %s OR username_clean = %s
ORDER BY
CASE
WHEN username = %s THEN 0
WHEN username_clean = %s THEN 1
ELSE 2
END,
updated_at DESC,
id DESC
LIMIT 1
"""
with db_connection() as conn, conn.cursor() as cursor:
cursor.execute(
sql,
(login_username, login_username, login_username, login_username),
)
row = cursor.fetchone()
# 少數舊資料可能未回填 username_clean;再用移除凍結標記後比對。
if not row:
fallback_sql = f"""
SELECT
id,
uid,
username,
username_clean,
member_type,
vip_level,
points,
tier,
balance,
frozen_balance,
other_balance,
deposit_count,
deposit_amount,
withdraw_count,
withdraw_amount,
register_time,
last_login_time,
remark,
updated_at
FROM {members}
WHERE TRIM(
REPLACE(
REPLACE(
REPLACE(username, '(已冻结)', ''),
'(已冻结)', ''
),
'已冻结', ''
)
) = %s
ORDER BY updated_at DESC, id DESC
LIMIT 1
"""
cursor.execute(fallback_sql, (login_username,))
row = cursor.fetchone()
if not row:
return self._not_found_account(login_username)
raw_username = str(row.get("username") or login_username).strip()
clean_username = str(row.get("username_clean") or "").strip()
if not clean_username:
clean_username = clean_frozen_username(raw_username)
if not clean_username:
clean_username = login_username
locked = "已冻结" in raw_username
remark = str(row.get("remark") or "").strip()
return {
"username": clean_username,
"raw_username": raw_username,
"username_clean": clean_username,
"uid": row.get("uid"),
"is_exist": True,
"status": "locked" if locked else "normal",
"locked_reason": remark if locked else "",
"remark": remark,
"member_type": str(row.get("member_type") or ""),
"vip_level": str(row.get("vip_level") or ""),
"points": decimal_string(row.get("points")),
"tier": str(row.get("tier") or ""),
"balance": decimal_string(row.get("balance")),
"frozen_balance": decimal_string(row.get("frozen_balance")),
"other_balance": decimal_string(row.get("other_balance")),
"deposit_count": int(row.get("deposit_count") or 0),
"deposit_amount": decimal_string(row.get("deposit_amount")),
"withdraw_count": int(row.get("withdraw_count") or 0),
"withdraw_amount": decimal_string(row.get("withdraw_amount")),
"register_time": format_datetime(row.get("register_time")),
"last_login_time": format_datetime(row.get("last_login_time")),
}
@staticmethod
def _not_found_account(username: str) -> dict[str, Any]:
return {
"username": username,
"raw_username": "",
"username_clean": username,
"uid": None,
"is_exist": False,
"status": "not_found",
"locked_reason": "",
}
def get_balance(self, username: str) -> Decimal:
account = self.get_user_account(username)
if not account["is_exist"]:
raise MappingError(f"members 表找不到會員:{username}")
return Decimal(str(account.get("balance") or "0"))
def get_withdraw_orders(
self,
username: str,
unfinished_only: bool = False,
) -> list[dict[str, Any]]:
account = self.get_user_account(username)
clean_username = (
account["username_clean"] if account["is_exist"] else str(username).strip()
)
raw_username = (
account["raw_username"] if account["is_exist"] else str(username).strip()
)
records = self._query_withdraw_records(clean_username, raw_username)
requests = self._query_withdraw_requests(clean_username, raw_username)
combined = records + requests
if unfinished_only:
combined = [
item for item in combined
if not is_finished_status(item.get("status"))
]
combined.sort(
key=lambda item: item.get("sort_time") or datetime.min,
reverse=True,
)
for item in combined:
item.pop("sort_time", None)
return combined[: CONFIG.max_orders]
def _query_withdraw_records(
self,
clean_username: str,
raw_username: str,
) -> list[dict[str, Any]]:
table = table_name(CONFIG.withdraw_table)
sql = f"""
SELECT
withdraw_id,
withdraw_id_2,
username,
username_clean,
withdraw_amount,
fee,
arrival_amount,
withdraw_channel,
withdraw_time,
status,
remark,
reviewer,
review_time,
processor,
process_time
FROM {table}
WHERE
username_clean = %s
OR username = %s
OR username = %s
ORDER BY withdraw_time DESC, id DESC
LIMIT %s
"""
with db_connection() as conn, conn.cursor() as cursor:
cursor.execute(
sql,
(clean_username, clean_username, raw_username, CONFIG.max_orders),
)
rows = cursor.fetchall()
return [
{
"source": "withdraw_records",
"order_id": str(
row.get("withdraw_id") or row.get("withdraw_id_2") or ""
),
"username": clean_username,
"status": str(row.get("status") or ""),
"amount": float(row.get("withdraw_amount") or 0),
"fee": float(row.get("fee") or 0),
"arrival_amount": float(row.get("arrival_amount") or 0),
"withdraw_channel": str(row.get("withdraw_channel") or ""),
"create_time": format_datetime(row.get("withdraw_time")),
"fail_reason": str(row.get("remark") or ""),
"reviewer": str(row.get("reviewer") or ""),
"review_time": format_datetime(row.get("review_time")),
"processor": str(row.get("processor") or ""),
"process_time": format_datetime(row.get("process_time")),
"sort_time": row.get("withdraw_time") or datetime.min,
}
for row in rows
]
def _query_withdraw_requests(
self,
clean_username: str,
raw_username: str,
) -> list[dict[str, Any]]:
"""查詢提款申請。
狀態判斷口徑:
1. operation 為主要狀態欄位;
2. operation 為空時才使用 audit_status;
3. 同一 withdraw_request_id 只保留最新一筆;
4. withdraw_request_id 為空時保留該筆資料,不做錯誤合併。
"""
table = table_name(CONFIG.withdraw_request_table)
# 多抓一些歷史快照,再於 Python 端依 withdraw_request_id 去重。
# SQL 已按 request_time、id 倒序,所以第一筆就是最新狀態。
fetch_limit = max(CONFIG.max_orders * 5, CONFIG.max_orders)
sql = f"""
SELECT
id,
withdraw_request_id,
username_raw,
username_clean,
current_balance,
withdraw_channel,
withdraw_amount,
request_time,
turnover,
operation,
auto_withdraw,
audit_status,
remark
FROM {table}
WHERE
username_clean = %s
OR username_raw = %s
OR username_raw = %s
OR username_raw LIKE %s
OR username_raw LIKE %s
ORDER BY request_time DESC, id DESC
LIMIT %s
"""
with db_connection() as conn, conn.cursor() as cursor:
cursor.execute(
sql,
(
clean_username,
clean_username,
raw_username,
f"{clean_username}(%",
f"{clean_username}(%",
fetch_limit,
),
)
rows = cursor.fetchall()
latest_rows: list[dict[str, Any]] = []
seen_request_ids: set[str] = set()
for row in rows:
request_id = row.get("withdraw_request_id")
# 有 request_id:只保留倒序結果中的第一筆,也就是最新狀態。
if request_id not in (None, ""):
request_key = str(request_id)
if request_key in seen_request_ids:
continue
seen_request_ids.add(request_key)
# 沒有 request_id:無法可靠判定是否同一筆申請,因此保留。
latest_rows.append(row)
if len(latest_rows) >= CONFIG.max_orders:
break
result: list[dict[str, Any]] = []
for row in latest_rows:
operation = str(row.get("operation") or "").strip()
audit_status = str(row.get("audit_status") or "").strip()
# 提款申請狀態以 operation 為主,audit_status 僅作備援。
status = operation or audit_status or "UNKNOWN"
result.append(
{
"source": "withdraw_request_records",
"order_id": str(row.get("withdraw_request_id") or ""),
"record_id": int(row.get("id") or 0),
"username": clean_username,
"status": status,
"operation": operation,
"audit_status": audit_status,
"amount": float(row.get("withdraw_amount") or 0),
"fee": 0.0,
"arrival_amount": 0.0,
"current_balance": float(row.get("current_balance") or 0),
"turnover": float(row.get("turnover") or 0),
"withdraw_channel": str(row.get("withdraw_channel") or ""),
"create_time": format_datetime(row.get("request_time")),
"fail_reason": str(row.get("remark") or ""),
"auto_withdraw": str(row.get("auto_withdraw") or ""),
"sort_time": row.get("request_time") or datetime.min,
}
)
return result
def get_deposit_orders(
self,
username: str,
amount: Optional[Decimal] = None,
created_at: Optional[datetime] = None,
) -> list[dict[str, Any]]:
account = self.get_user_account(username)
clean_username = (
account["username_clean"] if account["is_exist"] else str(username).strip()
)
raw_username = (
account["raw_username"] if account["is_exist"] else str(username).strip()
)
table = table_name(CONFIG.deposit_table)
conditions = [
"(username_clean = %s OR username = %s OR username = %s)"
]
params: list[Any] = [clean_username, clean_username, raw_username]
if amount is not None:
conditions.append("ABS(deposit_amount - %s) < 0.01")
params.append(amount)
if created_at is not None:
hours = CONFIG.deposit_search_hours
conditions.append("deposit_time BETWEEN %s AND %s")
params.extend(
[
created_at - timedelta(hours=hours),
created_at + timedelta(hours=hours),
]
)
sql = f"""
SELECT
deposit_id,
deposit_no,
username,
username_clean,
deposit_amount,
actual_arrival_amount,
crypto_amount,
fee,
deposit_bank,
deposit_type,
status,
description,
deposit_time,
arrival_time,
operation_time,
backend_remark,
frontend_remark
FROM {table}
WHERE {' AND '.join(conditions)}
ORDER BY deposit_time DESC, id DESC
LIMIT %s
"""
params.append(CONFIG.max_orders)
with db_connection() as conn, conn.cursor() as cursor:
cursor.execute(sql, params)
rows = cursor.fetchall()
return [
{
"username": clean_username,
"thirdparty_order_id": str(
row.get("deposit_no") or row.get("deposit_id") or ""
),
"deposit_id": str(row.get("deposit_id") or ""),
"deposit_no": str(row.get("deposit_no") or ""),
"amount": float(row.get("deposit_amount") or 0),
"actual_arrival_amount": float(
row.get("actual_arrival_amount") or 0
),
"crypto_amount": float(row.get("crypto_amount") or 0),
"fee": float(row.get("fee") or 0),
"status": str(row.get("status") or ""),
"deposit_bank": str(row.get("deposit_bank") or ""),
"deposit_type": str(row.get("deposit_type") or ""),
"description": str(row.get("description") or ""),
"created_at": format_datetime(row.get("deposit_time")),
"arrival_time": format_datetime(row.get("arrival_time")),
"operation_time": format_datetime(row.get("operation_time")),
"backend_remark": str(row.get("backend_remark") or ""),
"frontend_remark": str(row.get("frontend_remark") or ""),
}
for row in rows
]
def apply_activity(self, username: str, activity_id: str) -> dict[str, Any]:
"""唯讀測試:只模擬資格回傳,不寫入資料庫。"""
account = self.get_user_account(username)
if not account["is_exist"]:
return {
"activity_found": False,
"qualified": False,
"status": "user_not_found",
"simulated": True,
}
activity_found = activity_id == CONFIG.default_activity_id
return {
"activity_found": activity_found,
"qualified": activity_found,
"status": (
"simulated_qualified" if activity_found else "activity_not_found"
),
"simulated": True,
}
def log_balance_fix(self, username: str, balance: Decimal) -> None:
"""唯讀模式不寫入任何修復日誌。"""
return
@@ -0,0 +1,4 @@
Flask>=3.1,<4
PyMySQL>=1.1,<2
python-dotenv>=1.0,<2
gunicorn>=23,<24
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1,453 @@
{% if chatwoot_config.enabled %}
<script>
(function () {
"use strict";
/*
* 會員身份由 Flask Session 注入,identifier 直接使用 username_clean。
* 不再接受 ?identifier=... 等網址參數,避免使用者冒充其他會員。
*/
const CHATWOOT_CONFIG = {{ chatwoot_config | tojson }};
const CHATWOOT_USER = {{ chatwoot_user | tojson }};
window.chatwootSettings = {
hideMessageBubble: false,
showUnreadMessagesDialog: false,
position: "right",
locale: "zh_TW",
useBrowserLanguage: false,
type: "standard",
darkMode: "auto",
launcherTitle: ""
};
function debugLog() {
if (!CHATWOOT_CONFIG.debug) {
return;
}
console.log.apply(console, arguments);
}
function configureChatwootIdentity() {
if (!window.$chatwoot) {
return;
}
if (!CHATWOOT_USER.is_authenticated) {
if (typeof window.$chatwoot.setCustomAttributes === "function") {
window.$chatwoot.setCustomAttributes({
visitor_type: "guest"
});
}
debugLog("Chatwoot anonymous guest ready");
return;
}
if (
typeof window.$chatwoot.setUser !== "function" ||
!CHATWOOT_USER.identifier
) {
return;
}
const userPayload = {
name: CHATWOOT_USER.name || CHATWOOT_USER.username
};
if (CHATWOOT_USER.identifier_hash) {
userPayload.identifier_hash = CHATWOOT_USER.identifier_hash;
}
window.$chatwoot.setUser(
CHATWOOT_USER.identifier,
userPayload
);
if (typeof window.$chatwoot.setCustomAttributes === "function") {
window.$chatwoot.setCustomAttributes({
visitor_type: "member",
username: CHATWOOT_USER.username || "",
member_uid: CHATWOOT_USER.uid == null
? ""
: String(CHATWOOT_USER.uid),
account_status: CHATWOOT_USER.account_status || "",
locked_reason: CHATWOOT_USER.locked_reason || "",
member_type: CHATWOOT_USER.member_type || "",
vip_level: CHATWOOT_USER.vip_level || "",
tier: CHATWOOT_USER.tier || ""
});
}
}
/*
* Flask 登出前清除 Chatwoot Session,避免下一個登入會員
* 沿用上一位會員的 Chatwoot Cookie 與對話。
*/
window.resetChatwootBeforeLogout = function (form) {
if (!form) {
return true;
}
/*
* 防止使用者重複點擊登出,造成多次提交。
*/
if (form.dataset.submitting === "true") {
return false;
}
form.dataset.submitting = "true";
const submitLogout = function () {
form.submit();
};
if (
window.$chatwoot &&
typeof window.$chatwoot.reset === "function"
) {
try {
window.$chatwoot.reset();
} catch (error) {
console.error(
"Chatwoot reset before logout failed:",
error
);
}
/*
* Chatwoot reset 沒有 Promise 回傳,因此提供短暫緩衝時間。
* 即使前端清理未完成,後端的一次性 guest reset
* 仍會在登出後首次載入匿名頁面時再次執行。
*/
window.setTimeout(
submitLogout,
300
);
return false;
}
submitLogout();
return false;
};
window.openGuestChatNewConversation = function () {
createGuestConversationAndShow({ openAfter: true });
};
window.exitGuestChatAndStartNew = function () {
// 先關掉視窗,再換成新對話並重新打開
closeGuestChat();
createGuestConversationAndShow({ openAfter: true, delayMs: 350 });
};
function createGuestConversationAndShow(options) {
var opts = options || {};
var openAfter = opts.openAfter !== false;
var delayMs = typeof opts.delayMs === "number" ? opts.delayMs : 700;
if (!CHATWOOT_CONFIG.guest_new_path) {
return;
}
fetch(CHATWOOT_CONFIG.guest_new_path, {
method: "POST",
headers: {
Accept: "application/json",
"X-CSRF-Token": CHATWOOT_CONFIG.csrf_token || ""
}
})
.then(function (response) {
return response.json().then(function (data) {
if (!response.ok) {
throw new Error((data && data.message) || "建立游客对话失败");
}
return data;
});
})
.then(function (data) {
if (!data || !data.widget_token) {
throw new Error("Chatwoot 未回傳 widget_token");
}
document.querySelectorAll("iframe[src*='widget']").forEach(
function (iframe) {
const url = new URL(iframe.src, window.location.origin);
url.searchParams.set("cw_conversation", data.widget_token);
url.hash = "/messages";
iframe.src = url.toString();
}
);
if (!openAfter) {
window.__guestWidgetOpen = false;
return;
}
window.__guestWidgetOpen = true;
window.setTimeout(function () {
if (
window.$chatwoot &&
typeof window.$chatwoot.toggle === "function"
) {
window.$chatwoot.toggle("open");
}
}, delayMs);
})
.catch(function (error) {
console.error("Chatwoot guest open failed:", error);
window.alert("客服暂时无法开启,请稍后再试。");
});
};
function isGuestChatOpen() {
var holder = document.querySelector(".woot-widget-holder");
if (holder && !holder.classList.contains("woot--hide")) {
var style = window.getComputedStyle(holder);
var rect = holder.getBoundingClientRect();
if (
style.display !== "none" &&
style.visibility !== "hidden" &&
Number(style.opacity || "1") > 0 &&
rect.width > 0 &&
rect.height > 0
) {
return true;
}
}
return !!window.__guestWidgetOpen;
}
function openGuestChatResume() {
window.__guestWidgetOpen = true;
if (window.$chatwoot && typeof window.$chatwoot.toggle === "function") {
try {
window.$chatwoot.toggle("open");
return;
} catch (error) {}
}
var holder = document.querySelector(".woot-widget-holder");
if (holder) {
holder.classList.remove("woot--hide");
}
}
function closeGuestChat() {
window.__guestWidgetOpen = false;
if (window.$chatwoot && typeof window.$chatwoot.toggle === "function") {
try {
window.$chatwoot.toggle("close");
return;
} catch (error) {}
}
var holder = document.querySelector(".woot-widget-holder");
if (holder) {
holder.classList.add("woot--hide");
}
}
function bindGuestBubbleToggle() {
if (!CHATWOOT_CONFIG.guest_new_path || window.__guestBubbleBound) {
return;
}
window.__guestBubbleBound = true;
window.addEventListener("chatwoot:opened", function () {
window.__guestWidgetOpen = true;
});
window.addEventListener("chatwoot:closed", function () {
window.__guestWidgetOpen = false;
});
document.addEventListener(
"click",
function (event) {
var target = event.target;
if (!target || typeof target.closest !== "function") {
return;
}
var bubble = target.closest(
".woot-widget-bubble, .woot--bubble-holder, button.woot-widget-bubble"
);
if (!bubble) {
return;
}
// 右下角氣泡:只收起/重開同一個對話,绝不開新單。
event.preventDefault();
event.stopPropagation();
if (isGuestChatOpen()) {
closeGuestChat();
} else {
openGuestChatResume();
}
},
true
);
}
window.addEventListener("message", function (event) {
var raw = event && event.data;
if (typeof raw !== "string" || raw.indexOf("chatwoot-widget:") !== 0) {
return;
}
try {
var payload = JSON.parse(raw.replace("chatwoot-widget:", ""));
if (payload && payload.event === "guestExitNewChat") {
if (typeof window.exitGuestChatAndStartNew === "function") {
window.exitGuestChatAndStartNew();
} else if (typeof window.openGuestChatNewConversation === "function") {
window.openGuestChatNewConversation();
}
}
} catch (error) {}
});
window.addEventListener(
"chatwoot:ready",
function () {
debugLog("chatwoot:ready", window.$chatwoot);
const guestResetRequired =
{{ chatwoot_guest_reset_required | tojson }};
/*
* 只有「剛完成會員登出」且目前未登入時,才清除舊會員身份。
* 一般遊客頁面不會每次都 reset,因此不會破壞既有匿名對話。
*/
if (
!CHATWOOT_USER.is_authenticated &&
guestResetRequired &&
window.$chatwoot &&
typeof window.$chatwoot.reset === "function"
) {
try {
window.$chatwoot.reset();
} catch (error) {
console.error(
"Chatwoot guest reset failed:",
error
);
}
/*
* reset 後稍候再設定 visitor_type=guest。
* 不執行 setUser(),因此不會向 Chatwoot 傳遞用戶名。
*/
window.setTimeout(
configureChatwootIdentity,
300
);
bindGuestBubbleToggle();
return;
}
configureChatwootIdentity();
bindGuestBubbleToggle();
}
);
window.addEventListener(
"chatwoot:error",
function (event) {
console.error(
"chatwoot:error",
event && event.detail ? event.detail : event
);
}
);
if (CHATWOOT_CONFIG.debug) {
window.addEventListener(
"chatwoot:on-message",
function (event) {
debugLog("chatwoot:on-message", event.detail);
}
);
window.addEventListener(
"chatwoot:postback",
function (event) {
debugLog("chatwoot:postback", event.detail);
}
);
window.addEventListener(
"chatwoot:opened",
function () {
debugLog("chatwoot:opened");
}
);
window.addEventListener(
"chatwoot:closed",
function () {
debugLog("chatwoot:closed");
}
);
window.addEventListener(
"chatwoot:on-start-conversation",
function (event) {
debugLog(
"chatwoot:on-start-conversation",
event.detail
);
}
);
}
const script = document.createElement("script");
const firstScript = document.getElementsByTagName("script")[0];
script.src = (
CHATWOOT_CONFIG.base_url +
"/packs/js/sdk.js"
);
script.async = true;
script.onload = function () {
if (!window.chatwootSDK) {
console.error("Chatwoot SDK object not found");
return;
}
window.chatwootSDK.run({
websiteToken: CHATWOOT_CONFIG.website_token,
baseUrl: CHATWOOT_CONFIG.base_url
});
// 父頁強制觸發 iframe 內快捷入口/宣傳圖掛載(同源 /cw)
function boostGuestUi() {
try {
var iframes = document.querySelectorAll("iframe");
for (var i = 0; i < iframes.length; i += 1) {
var iframe = iframes[i];
var src = iframe.getAttribute("src") || "";
if (src.indexOf("widget") === -1 && src.indexOf("/cw") === -1) {
continue;
}
var win = iframe.contentWindow;
if (win && typeof win.__ydEnsureGuestUI === "function") {
win.__ydEnsureGuestUI();
}
}
} catch (err) {}
}
window.setInterval(boostGuestUi, 1000);
window.setTimeout(boostGuestUi, 1500);
window.setTimeout(boostGuestUi, 3500);
};
script.onerror = function () {
console.error(
"Chatwoot SDK load failed:",
script.src
);
};
firstScript.parentNode.insertBefore(
script,
firstScript
);
})();
</script>
{% endif %}
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}{{ app_name }}{% endblock %}</title>
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
</head>
<body class="{% block body_class %}{% endblock %}">
{% block body %}{% endblock %}
{% block integrations %}{% endblock %}
</body>
</html>

Some files were not shown because too many files have changed in this diff Show More