最初版本
This commit is contained in:
@@ -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>
|
||||
@@ -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 %}
|
||||
+38
@@ -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
|
||||
Reference in New Issue
Block a user