最初版本
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,32 @@
|
||||
{
|
||||
"1:91": {
|
||||
"jwt": "eyJhcHBsaWNhdGlvbl9pZCI6IjAxOWZjYWIxLTdiOTAtN2RhMy04YTdjLTEyMzliZmI2NzY5ZCIsInVzZXJfaWQiOiJOb25lIiwiYWNjZXNzX3Rva2VuIjoiNGE0NGNmYWU3MWU4YWVlNyIsInR5cGUiOiJDSEFUX0FOT05ZTU9VU19VU0VSIiwiY2hhdF91c2VyX3R5cGUiOiJBTk9OWU1PVVNfVVNFUiIsImNoYXRfdXNlcl9pZCI6IjAxOWZmNDhjLThhMzktN2IwMy1iZGViLTIxZGRjNzFkNWY1ZiIsImF1dGhlbnRpY2F0aW9uIjoidjZ3UUJnRDU3a3JaNWpXVHVMZTNYME5VakJ3bTJycms2aDErbngyMFA3WXpUb2pieGdYcG54aW1IdlBYNDg5WUsrT2FxVmtZVW5ZZ0NPT1ZZKzQyQ3I1NGhSMVZ6emJ4NllDV21qYjJCeWVHRm9pZ3VhOEltOTRnaFBoOFRVazdhKzdVUEN1MWhmZEVDNlV1Z0t6Rnh5dWlUcnNMRk9TZFNSSktKb0VQa1BHaHQ1NnQ5d2hsSnB4eCtUbE41bG1OMXhRMWhhT0RrdFVma3BOOUVFaHFPWlRiSHk2NG9YcFBxaDhMSlg4NFpydEUrQmp3RTV5Y3UvWERMSCtBWEtFUlV6WUlLSGNYank0cEpSS3NnaERZN3ppRC9IN04rZkNDR1Y2d1doOWwzaCtqdjFXaU0yRlRCU0taN3BRM1hCVWJUUzlXbHlxZlZXOFBzTlZtSXR6dXJ3PT0ifQ:1wu1yQ:JFijp39dHicqSsuPKaEHaAH9y0GDjMrwYVi-TNqb-Ms",
|
||||
"chat_id": "019ff48c-8bd4-7351-a3cc-1bd891eb7f1a",
|
||||
"updated_at": 1786514282,
|
||||
"last_message_id": 999001
|
||||
},
|
||||
"1:94": {
|
||||
"jwt": "eyJhcHBsaWNhdGlvbl9pZCI6IjAxOWZjYWIxLTdiOTAtN2RhMy04YTdjLTEyMzliZmI2NzY5ZCIsInVzZXJfaWQiOiJOb25lIiwiYWNjZXNzX3Rva2VuIjoiNGE0NGNmYWU3MWU4YWVlNyIsInR5cGUiOiJDSEFUX0FOT05ZTU9VU19VU0VSIiwiY2hhdF91c2VyX3R5cGUiOiJBTk9OWU1PVVNfVVNFUiIsImNoYXRfdXNlcl9pZCI6IjAxOWZmNDlkLTVjOGUtNzA3My04YmJiLTEyMmUwODg4ZDc4ZiIsImF1dGhlbnRpY2F0aW9uIjoiNkFOeUhSOC82OERTUkNNYWNidytGcEhRc2IvTDk2ZzI4dkhXWUFZMm5NS0N5MFJlVENQUGUxV05PUWxYRmFIMGxQVitWWks4UGJwZ1BzbVBxYkg3TU9heVMzc2xSMUNrei8rNElnalN4SHFDNTcrQWxrdXhyNXJvYi9yMWNJY3l1L0F0ZlhxcS93L2lwTytmY0tJQnliM2xBVDhuRWt0R3RobFFiTE9pNVpvbzRXZVJWRTI4K0Q4b3c2aFZFRm9aYWNhaTcwQ0NRY2l0OFRCY1JKSzJpakpQYSttMkc4VUlHQVVOYUJIL3hxUUIrNHlvcE5Ed2JHaGc3NzFnTTRtK2Y5ZnAzYkc0VDk2eUxwUE96clA2VHpTLzV0dHVRUE9QdmtvOHNpMzRRbmsvMXh0dVV6UXU5YUxzbldlcEFwWmp3OWtJQm0rWlFMOGhTOWk1MzF4K3RnPT0ifQ:1wu2GD:zNB_Lna1gXi5z0TD8RnbUbQ9g1TFG8_Zi7c02Qk7vko",
|
||||
"chat_id": "019ff49d-5e35-78b3-805a-be56bec8967b",
|
||||
"updated_at": 1786515472,
|
||||
"last_message_id": 112
|
||||
},
|
||||
"1:95": {
|
||||
"jwt": "eyJhcHBsaWNhdGlvbl9pZCI6IjAxOWZjYWIxLTdiOTAtN2RhMy04YTdjLTEyMzliZmI2NzY5ZCIsInVzZXJfaWQiOiJOb25lIiwiYWNjZXNzX3Rva2VuIjoiNGE0NGNmYWU3MWU4YWVlNyIsInR5cGUiOiJDSEFUX0FOT05ZTU9VU19VU0VSIiwiY2hhdF91c2VyX3R5cGUiOiJBTk9OWU1PVVNfVVNFUiIsImNoYXRfdXNlcl9pZCI6IjAxOWZmNGExLTM4M2EtNzExMC04NjVhLTAyYWU2ZWJhZDY4YiIsImF1dGhlbnRpY2F0aW9uIjoiZi8wdldFVjh3VWNxRzVQN3Mvbm0zZkVFZkkzVVJNRlk5QWJkbml2MHVmNHQ3ZG1tTWZUbXFhMDYwQ2E4bUl2U3JFQXJIazdXb1RSVTR6dk1WWUE2M21IOCtENnZ3enJ1blpTemxiM1p2NkZhYkg3c1JmR3YreFR3S1N5bDdyV1BsdWhTekFOTFZZRnFYU0VUYklOWnVsZW9BTG5LeTgzWm9aYU12c1QxZGV1Umo0RGp5UEtocjB3MGRISHoybzhRU2RqbEZpQThqeWYrM2t2MG9yalV2SWVMbHQvN0w4dGRQTFJ1eWVMN0VVckFkWTlldWdzTW5raUVtZ09GS2tFMWZqRTRncXpSdit3aHdCSnFMU0lNb2JRUSttb29BSHZkSkd5VTl5QmJyU1pHR2dBUDlvYlprNWNqYnl0ODBHOEFDSG92a3VQcGxLSStVdExXMEY5RFJ3PT0ifQ:1wu2KI:2yLZ4hHTT9801JP5m80p3GQBZksnBf4ke1WMbiG1qWM",
|
||||
"chat_id": "019ff4a1-39db-7ff1-b2c9-04a9c6353458",
|
||||
"updated_at": 1786517227,
|
||||
"last_message_id": 910002,
|
||||
"hard_reply": true
|
||||
},
|
||||
"1:96": {
|
||||
"updated_at": 1786517150,
|
||||
"last_message_id": 129,
|
||||
"hard_reply": true
|
||||
},
|
||||
"1:97": {
|
||||
"jwt": "eyJhcHBsaWNhdGlvbl9pZCI6IjAxOWZjYWIxLTdiOTAtN2RhMy04YTdjLTEyMzliZmI2NzY5ZCIsInVzZXJfaWQiOiJOb25lIiwiYWNjZXNzX3Rva2VuIjoiNGE0NGNmYWU3MWU4YWVlNyIsInR5cGUiOiJDSEFUX0FOT05ZTU9VU19VU0VSIiwiY2hhdF91c2VyX3R5cGUiOiJBTk9OWU1PVVNfVVNFUiIsImNoYXRfdXNlcl9pZCI6IjAxOWZmNGMwLTcyYTYtN2MxMi1iMWRhLWUwY2ViMTMxOTAzMCIsImF1dGhlbnRpY2F0aW9uIjoiSk9HaVJmL3JYRzFkM3A5MTg3MzE4eVB1R0dRaHR2ZXAxN0NFaks4VUFuT1g3bFo5bHpjTHBjYkNDNTg3aXFXaG5rSFZBS1FGd0YxTkhVak0vT2d0MmpjUTFDeVNMSkVSQXI2MzlUWVMxNFhFS0l4M2NxeGdqWGpPZWpOaEdpckFVdWx3QlRnWmtpSHk4T1Bsa2pnSlRYZzJnUThJTWJDUFdTQVFXN0I4c2dkMzBGOVVZU2JkS1dRUkw3bUNEN1prVC9rZ1dyc2s5VmhGRGRST08vbmZlVXFxV1U3M3ROcUhSRDBkc0JibnpoMGg1SFNaSG1uZ1RvTmdLb3VlOHRaTkxwc0sxQTlzS1JQMGJmRkhaTVNsOGdnTE5BN0RiRzg1VlNPVVhOSHhWbkIwaFlucVlHSUdEeXN2MnRQNlFaMDB4TmlidEJTaXRhZjZQdzFTTXhjUm9nPT0ifQ:1wu2rI:dww9aRK1VIUgs1v4VT6DzguCSO0AuD2wpqQHmZAZtPI",
|
||||
"chat_id": "019ff4c0-744d-71a1-bf16-bd11b829fca6",
|
||||
"updated_at": 1786517715,
|
||||
"last_message_id": 136
|
||||
}
|
||||
}
|
||||
@@ -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,628 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from functools import wraps
|
||||
import hashlib
|
||||
import hmac
|
||||
import logging
|
||||
import secrets
|
||||
from typing import Any, Callable
|
||||
|
||||
from flask import (
|
||||
Flask,
|
||||
abort,
|
||||
flash,
|
||||
jsonify,
|
||||
redirect,
|
||||
render_template,
|
||||
request,
|
||||
session,
|
||||
url_for,
|
||||
)
|
||||
from pymysql import MySQLError
|
||||
from werkzeug.exceptions import HTTPException
|
||||
|
||||
from chatwoot_guest import ChatwootGuestError, create_guest_conversation
|
||||
from chatwoot_proxy import proxy_chatwoot
|
||||
from config import CONFIG
|
||||
from maxkb_chatwoot_bridge import handle_chatwoot_webhook
|
||||
from repository import MappingError, PlatformRepository, decimal_string
|
||||
import threading
|
||||
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.update(
|
||||
JSON_AS_ASCII=False,
|
||||
SECRET_KEY=CONFIG.secret_key,
|
||||
SESSION_COOKIE_NAME="api_interface_session",
|
||||
SESSION_COOKIE_HTTPONLY=True,
|
||||
SESSION_COOKIE_SAMESITE="Lax",
|
||||
SESSION_COOKIE_SECURE=CONFIG.session_cookie_secure,
|
||||
PERMANENT_SESSION_LIFETIME=28800,
|
||||
MAX_CONTENT_LENGTH=1024 * 1024,
|
||||
)
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if CONFIG.debug else logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s %(message)s",
|
||||
)
|
||||
|
||||
repo = PlatformRepository()
|
||||
|
||||
|
||||
def payload() -> dict[str, Any]:
|
||||
data: dict[str, Any] = {}
|
||||
body = request.get_json(silent=True)
|
||||
if isinstance(body, dict):
|
||||
data.update(body)
|
||||
data.update(request.form.to_dict())
|
||||
data.update(request.args.to_dict())
|
||||
return data
|
||||
|
||||
|
||||
def api_auth(view: Callable):
|
||||
@wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if CONFIG.api_key:
|
||||
provided = request.headers.get("X-API-Key", "")
|
||||
if not hmac.compare_digest(provided, CONFIG.api_key):
|
||||
return jsonify(
|
||||
{"error": "unauthorized", "message": "API Key 無效"}
|
||||
), 401
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def login_required(view: Callable):
|
||||
@wraps(view)
|
||||
def wrapped(*args, **kwargs):
|
||||
if not session.get("username"):
|
||||
return redirect(url_for("login"))
|
||||
return view(*args, **kwargs)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
def csrf_token() -> str:
|
||||
token = session.get("csrf_token")
|
||||
if not token:
|
||||
token = secrets.token_urlsafe(32)
|
||||
session["csrf_token"] = token
|
||||
return str(token)
|
||||
|
||||
|
||||
def verify_csrf() -> None:
|
||||
received = str(request.form.get("csrf_token", ""))
|
||||
saved = str(session.get("csrf_token", ""))
|
||||
if not received or not saved or not hmac.compare_digest(received, saved):
|
||||
abort(403, description="CSRF 验证失败")
|
||||
|
||||
|
||||
def required_text(
|
||||
data: dict[str, Any],
|
||||
name: str,
|
||||
max_length: int = 191,
|
||||
) -> str:
|
||||
value = str(data.get(name, "")).strip()
|
||||
if not value:
|
||||
raise ValueError(f"{name} 為必填")
|
||||
if len(value) > max_length:
|
||||
raise ValueError(f"{name} 長度不可超過 {max_length}")
|
||||
return value
|
||||
|
||||
|
||||
def is_api_request() -> bool:
|
||||
return request.path == "/health" or request.path.startswith("/platform/")
|
||||
|
||||
|
||||
def update_member_session(account: dict[str, Any]) -> None:
|
||||
"""將目前會員資料同步到 Flask Session。"""
|
||||
session["username"] = account.get("username_clean") or account.get("username") or ""
|
||||
session["username_clean"] = account.get("username_clean") or ""
|
||||
session["raw_username"] = account.get("raw_username") or ""
|
||||
session["uid"] = account.get("uid")
|
||||
session["account_status"] = account.get("status") or "normal"
|
||||
session["locked_reason"] = account.get("locked_reason") or ""
|
||||
session["member_type"] = account.get("member_type") or ""
|
||||
session["vip_level"] = account.get("vip_level") or ""
|
||||
session["tier"] = account.get("tier") or ""
|
||||
|
||||
|
||||
def build_chatwoot_user() -> dict[str, Any]:
|
||||
"""建立傳給 Chatwoot 前端 SDK 的安全會員資料。
|
||||
|
||||
identifier 直接使用 username_clean,作為 Chatwoot 抓取會員名的關鍵標識。
|
||||
顯示名稱與自訂屬性同樣使用 username_clean。
|
||||
"""
|
||||
username = str(
|
||||
session.get("username_clean")
|
||||
or session.get("username")
|
||||
or ""
|
||||
).strip()
|
||||
uid = session.get("uid")
|
||||
|
||||
# Chatwoot 的 identifier 直接使用登入會員名。
|
||||
# 例如:yongle001,而不是 member-311。
|
||||
identifier = username if username else ""
|
||||
|
||||
identifier_hash = ""
|
||||
if identifier and CONFIG.chatwoot_identity_secret:
|
||||
identifier_hash = hmac.new(
|
||||
CONFIG.chatwoot_identity_secret.encode("utf-8"),
|
||||
identifier.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
return {
|
||||
"is_authenticated": bool(identifier),
|
||||
"visitor_type": "member" if identifier else "guest",
|
||||
"identifier": identifier,
|
||||
"identifier_hash": identifier_hash,
|
||||
"name": username,
|
||||
"username": username,
|
||||
"uid": uid,
|
||||
"account_status": str(
|
||||
session.get("account_status", "")
|
||||
),
|
||||
"locked_reason": str(
|
||||
session.get("locked_reason", "")
|
||||
),
|
||||
"member_type": str(
|
||||
session.get("member_type", "")
|
||||
),
|
||||
"vip_level": str(
|
||||
session.get("vip_level", "")
|
||||
),
|
||||
"tier": str(
|
||||
session.get("tier", "")
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@app.context_processor
|
||||
def inject_template_values():
|
||||
use_guest_widget = request.path.startswith("/guest-customer-service")
|
||||
guest_base = CONFIG.chatwoot_guest_base_url
|
||||
guest_token = CONFIG.chatwoot_guest_website_token
|
||||
base_url = (
|
||||
guest_base
|
||||
if use_guest_widget and guest_base and guest_token
|
||||
else CONFIG.chatwoot_base_url
|
||||
)
|
||||
website_token = (
|
||||
guest_token
|
||||
if use_guest_widget and guest_base and guest_token
|
||||
else CONFIG.chatwoot_website_token
|
||||
)
|
||||
chatwoot_config = {
|
||||
"enabled": bool(
|
||||
CONFIG.chatwoot_enabled
|
||||
and base_url
|
||||
and website_token
|
||||
),
|
||||
"base_url": base_url,
|
||||
"website_token": website_token,
|
||||
"debug": CONFIG.chatwoot_debug,
|
||||
"csrf_token": csrf_token(),
|
||||
"guest_new_path": "/guest-chat/new",
|
||||
}
|
||||
|
||||
return {
|
||||
"app_name": CONFIG.app_name,
|
||||
"csrf_token": csrf_token,
|
||||
"chatwoot_config": chatwoot_config,
|
||||
"chatwoot_user": build_chatwoot_user(),
|
||||
|
||||
# 只在登出後首次渲染未登入頁面時為 True。
|
||||
# 使用 pop() 確保該標記只消費一次。
|
||||
"chatwoot_guest_reset_required": bool(
|
||||
session.pop(
|
||||
"chatwoot_guest_reset_required",
|
||||
False,
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def root():
|
||||
if session.get("username"):
|
||||
return redirect(url_for("home"))
|
||||
return redirect(url_for("login"))
|
||||
|
||||
|
||||
@app.get("/guest-customer-service")
|
||||
def guest_customer_service():
|
||||
"""遊客客服頁:不登入、不查會員資料,只載入匿名 Chatwoot。"""
|
||||
return render_template(
|
||||
"guest_customer_service.html",
|
||||
is_guest=True,
|
||||
)
|
||||
|
||||
|
||||
@app.route(
|
||||
"/cw/",
|
||||
defaults={"subpath": ""},
|
||||
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
|
||||
)
|
||||
@app.route(
|
||||
"/cw/<path:subpath>",
|
||||
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
|
||||
)
|
||||
def chatwoot_guest_proxy(subpath: str):
|
||||
"""把本機 Chatwoot widget 代理進來,才能注入略過「開始對話」的腳本。"""
|
||||
return proxy_chatwoot(subpath)
|
||||
|
||||
|
||||
@app.route(
|
||||
"/vite/<path:subpath>",
|
||||
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
|
||||
)
|
||||
def chatwoot_vite_proxy(subpath: str):
|
||||
"""widget HTML 用絕對路徑 /vite/assets,需一併代理。"""
|
||||
return proxy_chatwoot(f"vite/{subpath}")
|
||||
|
||||
|
||||
@app.route(
|
||||
"/api/v1/widget/",
|
||||
defaults={"subpath": ""},
|
||||
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
|
||||
)
|
||||
@app.route(
|
||||
"/api/v1/widget/<path:subpath>",
|
||||
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
|
||||
)
|
||||
def chatwoot_widget_api_proxy(subpath: str):
|
||||
"""widget 會打同源 /api/v1/widget。"""
|
||||
suffix = f"api/v1/widget/{subpath}" if subpath else "api/v1/widget"
|
||||
return proxy_chatwoot(suffix)
|
||||
|
||||
|
||||
@app.route(
|
||||
"/rails/<path:subpath>",
|
||||
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
|
||||
)
|
||||
def chatwoot_rails_proxy(subpath: str):
|
||||
"""歡迎圖走 Active Storage,widget 會打同源 /rails/active_storage。"""
|
||||
return proxy_chatwoot(f"rails/{subpath}")
|
||||
|
||||
|
||||
@app.post("/guest-chat/new")
|
||||
def guest_chat_new():
|
||||
"""為遊客預先建立 Chatwoot 對話,前端 setUser 後可略過「開始對話」。"""
|
||||
received = str(request.headers.get("X-CSRF-Token", ""))
|
||||
saved = str(session.get("csrf_token", ""))
|
||||
if not received or not saved or not hmac.compare_digest(received, saved):
|
||||
return jsonify(
|
||||
{"error": "csrf_failed", "message": "CSRF 验证失败"}
|
||||
), 403
|
||||
|
||||
try:
|
||||
created = create_guest_conversation()
|
||||
except ChatwootGuestError as exc:
|
||||
app.logger.exception("建立游客对话失败")
|
||||
return jsonify(
|
||||
{"error": "chatwoot_guest_failed", "message": str(exc)}
|
||||
), 502
|
||||
|
||||
identifier = created["identifier"]
|
||||
identifier_hash = ""
|
||||
if identifier and CONFIG.chatwoot_identity_secret:
|
||||
identifier_hash = hmac.new(
|
||||
CONFIG.chatwoot_identity_secret.encode("utf-8"),
|
||||
identifier.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"identifier": identifier,
|
||||
"identifier_hash": identifier_hash,
|
||||
"name": created["name"],
|
||||
"source_id": created.get("source_id") or "",
|
||||
"widget_token": created.get("widget_token") or "",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.post("/webhook/bot1/chatwoot")
|
||||
def chatwoot_maxkb_webhook():
|
||||
"""本機 Chatwoot Agent Bot → PAI MaxKB 橋接(對齊正式站 :6666 路徑)。"""
|
||||
body = request.get_json(silent=True)
|
||||
if not isinstance(body, dict):
|
||||
return jsonify({"ok": False, "error": "invalid_json"}), 400
|
||||
|
||||
# Chatwoot SafeFetch 讀超時約 20s;MaxKB 常更久,先快速 200 再背景回覆。
|
||||
def _run() -> None:
|
||||
try:
|
||||
result = handle_chatwoot_webhook(body)
|
||||
app.logger.info("Chatwoot→MaxKB 橋接完成: %s", result)
|
||||
except Exception:
|
||||
app.logger.exception("Chatwoot→MaxKB 橋接失敗")
|
||||
|
||||
threading.Thread(target=_run, daemon=True).start()
|
||||
return jsonify({"ok": True, "accepted": True})
|
||||
|
||||
|
||||
@app.route("/login", methods=["GET", "POST"])
|
||||
def login():
|
||||
if session.get("username"):
|
||||
return redirect(url_for("home"))
|
||||
|
||||
username = ""
|
||||
if request.method == "POST":
|
||||
verify_csrf()
|
||||
username = str(request.form.get("username", "")).strip()
|
||||
|
||||
if not username or len(username) > 191:
|
||||
flash("请输入有效的用户名", "error")
|
||||
else:
|
||||
try:
|
||||
account = repo.get_user_account(username)
|
||||
except Exception as exc:
|
||||
app.logger.exception("会员登录查询失败")
|
||||
flash(f"登录验证失败:{exc}", "error")
|
||||
else:
|
||||
if not account["is_exist"]:
|
||||
flash("该用户名不存在于 members 表", "error")
|
||||
else:
|
||||
session.clear()
|
||||
update_member_session(account)
|
||||
session.permanent = True
|
||||
csrf_token()
|
||||
return redirect(url_for("home"))
|
||||
|
||||
return render_template("login.html", username=username)
|
||||
|
||||
|
||||
@app.get("/home")
|
||||
@login_required
|
||||
def home():
|
||||
username = str(session["username"])
|
||||
warning = ""
|
||||
account = {
|
||||
"username": username,
|
||||
"username_clean": username,
|
||||
"raw_username": str(session.get("raw_username", username)),
|
||||
"status": str(session.get("account_status", "normal")),
|
||||
"locked_reason": str(session.get("locked_reason", "")),
|
||||
}
|
||||
|
||||
try:
|
||||
refreshed = repo.get_user_account(username)
|
||||
if refreshed["is_exist"]:
|
||||
account = refreshed
|
||||
update_member_session(refreshed)
|
||||
except Exception as exc:
|
||||
app.logger.exception("帳戶狀態刷新失敗")
|
||||
warning = str(exc)
|
||||
|
||||
games = [
|
||||
{"icon": "🎰", "name": "幸運拉霸", "category": "電子遊戲"},
|
||||
{"icon": "🃏", "name": "經典百家樂", "category": "真人遊戲"},
|
||||
{"icon": "⚽", "name": "體育競猜", "category": "體育遊戲"},
|
||||
{"icon": "🎲", "name": "骰寶大廳", "category": "桌面遊戲"},
|
||||
{"icon": "🐉", "name": "龍虎鬥", "category": "真人遊戲"},
|
||||
{"icon": "🚀", "name": "飛行挑戰", "category": "休閒遊戲"},
|
||||
]
|
||||
|
||||
return render_template(
|
||||
"home.html",
|
||||
username=username,
|
||||
account=account,
|
||||
warning=warning,
|
||||
games=games,
|
||||
)
|
||||
|
||||
|
||||
@app.get("/customer-service")
|
||||
@login_required
|
||||
def customer_service():
|
||||
username = str(session["username"])
|
||||
withdrawals: list[dict[str, Any]] = []
|
||||
deposits: list[dict[str, Any]] = []
|
||||
account: dict[str, Any] = {}
|
||||
error = ""
|
||||
|
||||
try:
|
||||
account = repo.get_user_account(username)
|
||||
if account.get("is_exist"):
|
||||
update_member_session(account)
|
||||
withdrawals = repo.get_withdraw_orders(username, unfinished_only=True)
|
||||
deposits = repo.get_deposit_orders(username)[:10]
|
||||
except Exception as exc:
|
||||
app.logger.exception("客服頁資料查詢失敗")
|
||||
error = str(exc)
|
||||
|
||||
return render_template(
|
||||
"customer_service.html",
|
||||
username=username,
|
||||
account=account,
|
||||
withdrawals=withdrawals,
|
||||
deposits=deposits,
|
||||
error=error,
|
||||
)
|
||||
|
||||
|
||||
@app.post("/logout")
|
||||
@login_required
|
||||
def logout():
|
||||
verify_csrf()
|
||||
|
||||
# 清除會員 Session 後,保留一個一次性標記。
|
||||
# 登出後首次載入未登入頁面時,前端會再次 reset Chatwoot,
|
||||
# 避免上一位會員身份殘留在匿名訪客頁面。
|
||||
session.clear()
|
||||
session["chatwoot_guest_reset_required"] = True
|
||||
|
||||
return redirect(url_for("login"))
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
@app.get("/platform/health")
|
||||
def health():
|
||||
return jsonify(
|
||||
{
|
||||
"service": CONFIG.app_name,
|
||||
"status": "ok",
|
||||
"read_only_mode": CONFIG.read_only_mode,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.get("/platform/get-user-account")
|
||||
@api_auth
|
||||
def get_user_account():
|
||||
username = required_text(payload(), "username")
|
||||
return jsonify(repo.get_user_account(username))
|
||||
|
||||
|
||||
@app.get("/platform/withdraw-orders")
|
||||
@api_auth
|
||||
def withdraw_orders():
|
||||
username = required_text(payload(), "username")
|
||||
return jsonify(repo.get_withdraw_orders(username, unfinished_only=False))
|
||||
|
||||
|
||||
@app.get("/platform/withdraw-orders-unfinish")
|
||||
@api_auth
|
||||
def withdraw_orders_unfinish():
|
||||
username = required_text(payload(), "username")
|
||||
return jsonify(repo.get_withdraw_orders(username, unfinished_only=True))
|
||||
|
||||
|
||||
@app.get("/platform/deposit-orders")
|
||||
@app.get("/platform/deposit-not-received")
|
||||
@api_auth
|
||||
def deposit_orders():
|
||||
data = payload()
|
||||
username = required_text(data, "username")
|
||||
|
||||
amount: Decimal | None = None
|
||||
raw_amount = str(data.get("amount", "")).strip()
|
||||
if raw_amount:
|
||||
try:
|
||||
amount = Decimal(raw_amount)
|
||||
except InvalidOperation as exc:
|
||||
raise ValueError("amount 格式不正確") from exc
|
||||
|
||||
created_at: datetime | None = None
|
||||
raw_created_at = str(data.get("created_at", "")).strip()
|
||||
if raw_created_at:
|
||||
try:
|
||||
created_at = datetime.fromisoformat(
|
||||
raw_created_at.replace("Z", "+00:00")
|
||||
)
|
||||
if created_at.tzinfo is not None:
|
||||
created_at = created_at.astimezone().replace(tzinfo=None)
|
||||
except ValueError as exc:
|
||||
raise ValueError("created_at 必須為 ISO-8601 日期時間") from exc
|
||||
|
||||
return jsonify(repo.get_deposit_orders(username, amount, created_at))
|
||||
|
||||
|
||||
@app.route("/platform/apply-activity", methods=["POST", "PUT"])
|
||||
@api_auth
|
||||
def apply_activity():
|
||||
data = payload()
|
||||
username = required_text(data, "username")
|
||||
activity_id = required_text(data, "activity_id", max_length=64)
|
||||
return jsonify(repo.apply_activity(username, activity_id))
|
||||
|
||||
|
||||
@app.route("/platform/fix-balance", methods=["POST", "PUT"])
|
||||
@api_auth
|
||||
def fix_balance():
|
||||
username = required_text(payload(), "username")
|
||||
account = repo.get_user_account(username)
|
||||
if not account["is_exist"]:
|
||||
return jsonify({"error": "not_found", "message": "找不到用戶"}), 404
|
||||
|
||||
balance = repo.get_balance(username)
|
||||
repo.log_balance_fix(username, balance)
|
||||
return jsonify(
|
||||
{
|
||||
"username": account["username_clean"],
|
||||
"balance": decimal_string(balance),
|
||||
"simulated": True,
|
||||
"message": "唯讀測試模式:僅回傳 members.balance,未修改資料庫",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@app.errorhandler(403)
|
||||
def handle_forbidden_error(exc: HTTPException):
|
||||
message = getattr(exc, "description", "拒絕存取")
|
||||
if is_api_request():
|
||||
return jsonify({"error": "forbidden", "message": message}), 403
|
||||
return render_template(
|
||||
"error.html", title="請求驗證失敗", message=message
|
||||
), 403
|
||||
|
||||
|
||||
@app.errorhandler(ValueError)
|
||||
def handle_value_error(exc: ValueError):
|
||||
if is_api_request():
|
||||
return jsonify({"error": "bad_request", "message": str(exc)}), 400
|
||||
return render_template(
|
||||
"error.html", title="請求錯誤", message=str(exc)
|
||||
), 400
|
||||
|
||||
|
||||
@app.errorhandler(MappingError)
|
||||
def handle_mapping_error(exc: MappingError):
|
||||
if is_api_request():
|
||||
return jsonify({"error": "mapping_error", "message": str(exc)}), 500
|
||||
return render_template(
|
||||
"error.html", title="資料映射錯誤", message=str(exc)
|
||||
), 500
|
||||
|
||||
|
||||
@app.errorhandler(MySQLError)
|
||||
def handle_mysql_error(exc: MySQLError):
|
||||
app.logger.exception("Database error")
|
||||
message = str(exc) if CONFIG.debug else "資料庫查詢失敗,請檢查連線與欄位設定"
|
||||
if is_api_request():
|
||||
return jsonify({"error": "database_error", "message": message}), 500
|
||||
return render_template(
|
||||
"error.html", title="資料庫錯誤", message=message
|
||||
), 500
|
||||
|
||||
|
||||
@app.errorhandler(HTTPException)
|
||||
def handle_http_exception(exc: HTTPException):
|
||||
status_code = int(exc.code or 500)
|
||||
message = str(exc.description or exc.name)
|
||||
if is_api_request():
|
||||
return jsonify(
|
||||
{
|
||||
"error": exc.name.lower().replace(" ", "_"),
|
||||
"message": message,
|
||||
}
|
||||
), status_code
|
||||
return render_template(
|
||||
"error.html", title=exc.name, message=message
|
||||
), status_code
|
||||
|
||||
|
||||
@app.errorhandler(Exception)
|
||||
def handle_unexpected_error(exc: Exception):
|
||||
app.logger.exception("Unexpected error")
|
||||
if is_api_request():
|
||||
return jsonify(
|
||||
{"error": "internal_error", "message": "系統內部錯誤"}
|
||||
), 500
|
||||
return render_template(
|
||||
"error.html", title="系統錯誤", message="系統內部錯誤"
|
||||
), 500
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app.logger.info(
|
||||
"啟動 %s host=%s port=%s read_only=%s",
|
||||
CONFIG.app_name,
|
||||
CONFIG.host,
|
||||
CONFIG.port,
|
||||
CONFIG.read_only_mode,
|
||||
)
|
||||
app.run(host=CONFIG.host, port=CONFIG.port, debug=CONFIG.debug)
|
||||
@@ -0,0 +1,256 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
import uuid
|
||||
from typing import Any
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from config import CONFIG
|
||||
|
||||
_AUTH_CACHE: dict[str, Any] = {"headers": None, "expires_at": 0.0}
|
||||
|
||||
|
||||
DEFAULT_GREETING = (
|
||||
"您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?"
|
||||
)
|
||||
|
||||
|
||||
class ChatwootGuestError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def _request(
|
||||
method: str,
|
||||
path: str,
|
||||
headers: dict[str, str],
|
||||
payload: dict[str, Any] | None = None,
|
||||
) -> tuple[int, dict[str, str], Any]:
|
||||
body = None
|
||||
req_headers = dict(headers)
|
||||
if payload is not None:
|
||||
body = json.dumps(payload, ensure_ascii=False).encode("utf-8")
|
||||
req_headers["Content-Type"] = "application/json"
|
||||
req_headers["Accept"] = "application/json"
|
||||
|
||||
api_base = (
|
||||
CONFIG.chatwoot_guest_api_base or CONFIG.chatwoot_base_url
|
||||
).rstrip("/")
|
||||
request = Request(
|
||||
f"{api_base}{path}",
|
||||
data=body,
|
||||
headers=req_headers,
|
||||
method=method,
|
||||
)
|
||||
try:
|
||||
with urlopen(request, timeout=20) as response:
|
||||
raw = response.read().decode("utf-8", "replace")
|
||||
data = json.loads(raw) if raw else {}
|
||||
return response.status, dict(response.headers.items()), data
|
||||
except HTTPError as exc:
|
||||
raw = exc.read().decode("utf-8", "replace")
|
||||
try:
|
||||
data = json.loads(raw) if raw else {}
|
||||
except json.JSONDecodeError:
|
||||
data = {"message": raw[:300]}
|
||||
raise ChatwootGuestError(
|
||||
f"Chatwoot {method} {path} 失敗:{exc.code} {data}"
|
||||
) from exc
|
||||
except URLError as exc:
|
||||
raise ChatwootGuestError(f"無法連線 Chatwoot:{exc.reason}") from exc
|
||||
|
||||
|
||||
def _login_headers(*, force: bool = False) -> dict[str, str]:
|
||||
email = CONFIG.chatwoot_guest_api_email or CONFIG.chatwoot_api_email
|
||||
password = CONFIG.chatwoot_guest_api_password or CONFIG.chatwoot_api_password
|
||||
if not email or not password:
|
||||
raise ChatwootGuestError("未設定 Chatwoot API 帳號密碼")
|
||||
|
||||
now = time.time()
|
||||
cached = _AUTH_CACHE.get("headers")
|
||||
if not force and cached and now < float(_AUTH_CACHE.get("expires_at") or 0):
|
||||
return cached
|
||||
|
||||
status, headers, _data = _request(
|
||||
"POST",
|
||||
"/auth/sign_in",
|
||||
{},
|
||||
{
|
||||
"email": email,
|
||||
"password": password,
|
||||
},
|
||||
)
|
||||
if status >= 400:
|
||||
raise ChatwootGuestError("Chatwoot 登入失敗")
|
||||
|
||||
token = headers.get("access-token") or headers.get("Access-Token") or ""
|
||||
client = headers.get("client") or headers.get("Client") or ""
|
||||
uid = headers.get("uid") or headers.get("Uid") or ""
|
||||
if not token or not client or not uid:
|
||||
raise ChatwootGuestError("Chatwoot 登入未回傳 token")
|
||||
|
||||
auth_headers = {
|
||||
"access-token": token,
|
||||
"client": client,
|
||||
"uid": uid,
|
||||
"token-type": "Bearer",
|
||||
}
|
||||
_AUTH_CACHE["headers"] = auth_headers
|
||||
_AUTH_CACHE["expires_at"] = now + 300
|
||||
return auth_headers
|
||||
|
||||
|
||||
def _dig(data: Any, *keys: str) -> Any:
|
||||
current = data
|
||||
for key in keys:
|
||||
if not isinstance(current, dict):
|
||||
return None
|
||||
current = current.get(key)
|
||||
return current
|
||||
|
||||
|
||||
def _source_id(*payloads: Any) -> str:
|
||||
for payload in payloads:
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
body = payload.get("payload") or payload
|
||||
candidates = [
|
||||
_dig(body, "contact_inbox", "source_id"),
|
||||
_dig(payload, "contact_inbox", "source_id"),
|
||||
_dig(
|
||||
payload,
|
||||
"last_non_activity_message",
|
||||
"conversation",
|
||||
"contact_inbox",
|
||||
"source_id",
|
||||
),
|
||||
]
|
||||
messages = body.get("messages") if isinstance(body, dict) else None
|
||||
if isinstance(messages, list) and messages:
|
||||
candidates.append(
|
||||
_dig(
|
||||
messages[0],
|
||||
"conversation",
|
||||
"contact_inbox",
|
||||
"source_id",
|
||||
)
|
||||
)
|
||||
for value in candidates:
|
||||
if value:
|
||||
return str(value)
|
||||
return ""
|
||||
|
||||
|
||||
def _contact_id(payload: Any) -> int | None:
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
body = payload.get("payload") or payload
|
||||
if not isinstance(body, dict):
|
||||
return None
|
||||
contact = body.get("contact") or body
|
||||
if not isinstance(contact, dict):
|
||||
return None
|
||||
contact_id = contact.get("id")
|
||||
return int(contact_id) if contact_id is not None else None
|
||||
|
||||
|
||||
def create_guest_conversation() -> dict[str, Any]:
|
||||
"""建立新的遊客聯絡人與對話,讓 widget 直接進入聊天室。"""
|
||||
identifier = f"guest-{uuid.uuid4().hex[:16]}"
|
||||
headers = _login_headers()
|
||||
account_id = CONFIG.chatwoot_account_id
|
||||
|
||||
try:
|
||||
return _create_with_headers(identifier, headers, account_id)
|
||||
except ChatwootGuestError as exc:
|
||||
if "401" not in str(exc) and "403" not in str(exc):
|
||||
raise
|
||||
headers = _login_headers(force=True)
|
||||
return _create_with_headers(identifier, headers, account_id)
|
||||
|
||||
|
||||
def _create_with_headers(
|
||||
identifier: str,
|
||||
headers: dict[str, str],
|
||||
account_id: int,
|
||||
) -> dict[str, Any]:
|
||||
_, _, contact_body = _request(
|
||||
"POST",
|
||||
f"/api/v1/accounts/{account_id}/contacts",
|
||||
headers,
|
||||
{
|
||||
"inbox_id": CONFIG.chatwoot_guest_inbox_id or CONFIG.chatwoot_inbox_id,
|
||||
"name": "遊客",
|
||||
"identifier": identifier,
|
||||
"custom_attributes": {"visitor_type": "guest"},
|
||||
},
|
||||
)
|
||||
contact_id = _contact_id(contact_body)
|
||||
if not contact_id:
|
||||
raise ChatwootGuestError("Chatwoot 未回傳 contact id")
|
||||
|
||||
source_id = _source_id(contact_body)
|
||||
if not source_id:
|
||||
raise ChatwootGuestError("Chatwoot 未回傳 source_id")
|
||||
|
||||
# 必須帶同一個 source_id。只傳 contact_id + inbox_id 時,
|
||||
# ContactInboxBuilder 會再產生一組 WebWidget UUID,
|
||||
# 歡迎語會掛在新 session,widget JWT 對不到就變成空白對話。
|
||||
# 歡迎圖不走 Chatwoot 附件,否則 widget 會顯示檔名與「下載」。
|
||||
_, _, conversation = _request(
|
||||
"POST",
|
||||
f"/api/v1/accounts/{account_id}/conversations",
|
||||
headers,
|
||||
{
|
||||
"inbox_id": CONFIG.chatwoot_guest_inbox_id or CONFIG.chatwoot_inbox_id,
|
||||
"contact_id": contact_id,
|
||||
"source_id": source_id,
|
||||
"status": "open",
|
||||
"message": {
|
||||
"content": CONFIG.chatwoot_greeting or DEFAULT_GREETING,
|
||||
"message_type": "outgoing",
|
||||
},
|
||||
},
|
||||
)
|
||||
conversation_id = conversation.get("id") if isinstance(conversation, dict) else None
|
||||
|
||||
return {
|
||||
"identifier": identifier,
|
||||
"contact_id": contact_id,
|
||||
"conversation_id": conversation_id,
|
||||
"source_id": source_id,
|
||||
"widget_token": make_widget_token(source_id),
|
||||
"name": "遊客",
|
||||
}
|
||||
|
||||
|
||||
def make_widget_token(source_id: str) -> str:
|
||||
"""產生 Chatwoot widget 的 cw_conversation JWT。"""
|
||||
secret = CONFIG.chatwoot_widget_jwt_secret
|
||||
if not secret:
|
||||
raise ChatwootGuestError("未設定 CHATWOOT_WIDGET_JWT_SECRET")
|
||||
|
||||
now = int(time.time())
|
||||
header = _b64url(json.dumps({"alg": "HS256"}, separators=(",", ":")).encode())
|
||||
payload = _b64url(
|
||||
json.dumps(
|
||||
{
|
||||
"source_id": source_id,
|
||||
"inbox_id": CONFIG.chatwoot_guest_inbox_id or CONFIG.chatwoot_inbox_id,
|
||||
"iat": now,
|
||||
"exp": now + 60 * 60 * 24 * 180,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
).encode()
|
||||
)
|
||||
signing_input = f"{header}.{payload}".encode()
|
||||
signature = hmac.new(secret.encode("utf-8"), signing_input, hashlib.sha256).digest()
|
||||
return f"{header}.{payload}.{_b64url(signature)}"
|
||||
|
||||
|
||||
def _b64url(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
||||
@@ -0,0 +1,650 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlencode
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from flask import Response, request
|
||||
|
||||
from config import CONFIG
|
||||
|
||||
HOP_BY_HOP = {
|
||||
"connection",
|
||||
"keep-alive",
|
||||
"proxy-authenticate",
|
||||
"proxy-authorization",
|
||||
"te",
|
||||
"trailers",
|
||||
"transfer-encoding",
|
||||
"upgrade",
|
||||
"host",
|
||||
"content-length",
|
||||
}
|
||||
|
||||
INJECT_SCRIPT = """
|
||||
<script>
|
||||
(function () {
|
||||
var CABLE_ORIGIN = "__CABLE_WS_ORIGIN__";
|
||||
var NativeWS = window.WebSocket;
|
||||
if (!NativeWS || window.__ydCablePatched) {
|
||||
return;
|
||||
}
|
||||
window.__ydCablePatched = true;
|
||||
function WrappedWS(url, protocols) {
|
||||
var next = String(url || "");
|
||||
if (next.indexOf("/cable") !== -1 && next.indexOf(CABLE_ORIGIN) !== 0) {
|
||||
try {
|
||||
var parsed = new URL(next, window.location.href);
|
||||
next = CABLE_ORIGIN + "/cable" + (parsed.search || "") + (parsed.hash || "");
|
||||
} catch (err) {
|
||||
next = CABLE_ORIGIN + "/cable";
|
||||
}
|
||||
}
|
||||
if (protocols === undefined) {
|
||||
return new NativeWS(next);
|
||||
}
|
||||
return new NativeWS(next, protocols);
|
||||
}
|
||||
WrappedWS.prototype = NativeWS.prototype;
|
||||
WrappedWS.CONNECTING = NativeWS.CONNECTING;
|
||||
WrappedWS.OPEN = NativeWS.OPEN;
|
||||
WrappedWS.CLOSING = NativeWS.CLOSING;
|
||||
WrappedWS.CLOSED = NativeWS.CLOSED;
|
||||
window.WebSocket = WrappedWS;
|
||||
})();
|
||||
</script>
|
||||
<style>
|
||||
/* 旧版深色外观(保留稳定功能,去掉 8/12 红头白底皮肤) */
|
||||
a[href*="chatwoot.com"],
|
||||
a[href*="Chatwoot"],
|
||||
.branding--container,
|
||||
.branding {
|
||||
display: none !important;
|
||||
}
|
||||
html, body, #app, .conversation-wrap, .messages-wrap {
|
||||
background: #090d1a !important;
|
||||
color: #f5f7ff !important;
|
||||
}
|
||||
header {
|
||||
background: #11182b !important;
|
||||
color: #f5f7ff !important;
|
||||
border: 0 !important;
|
||||
border-bottom: 1px solid rgba(255,255,255,.09) !important;
|
||||
box-shadow: none !important;
|
||||
min-height: 64px !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: space-between !important;
|
||||
gap: 8px !important;
|
||||
padding: 10px 12px !important;
|
||||
}
|
||||
header [data-yd-right] {
|
||||
display: inline-flex !important;
|
||||
align-items: center !important;
|
||||
gap: 8px !important;
|
||||
margin-left: auto !important;
|
||||
}
|
||||
header, header * {
|
||||
color: #f5f7ff !important;
|
||||
}
|
||||
header button.close-button,
|
||||
header button[data-yd-close],
|
||||
header button[data-yd-exit] {
|
||||
display: inline-flex !important;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
padding: 0;
|
||||
font-size: 26px;
|
||||
line-height: 1;
|
||||
color: #f5f7ff !important;
|
||||
background: transparent !important;
|
||||
border: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
header button[data-yd-exit] svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
display: block;
|
||||
}
|
||||
.agent-message .chat-bubble,
|
||||
.agent-message-wrap .chat-bubble,
|
||||
.prose-bubble {
|
||||
background: #19233c !important;
|
||||
color: #f5f7ff !important;
|
||||
border-radius: 14px !important;
|
||||
border: 1px solid rgba(255,255,255,.08) !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
.user-message .chat-bubble,
|
||||
.user-message-wrap .chat-bubble {
|
||||
background: rgba(123,97,255,.28) !important;
|
||||
color: #f5f7ff !important;
|
||||
border-radius: 14px !important;
|
||||
border: 0 !important;
|
||||
}
|
||||
.chat-message--input,
|
||||
.chat-footer,
|
||||
footer {
|
||||
background: #11182b !important;
|
||||
border-top: 1px solid rgba(255,255,255,.09) !important;
|
||||
}
|
||||
textarea, input[type="text"] {
|
||||
background: #0c1222 !important;
|
||||
color: #f5f7ff !important;
|
||||
border-radius: 12px !important;
|
||||
border: 1px solid rgba(255,255,255,.09) !important;
|
||||
}
|
||||
[data-yd-panel],
|
||||
[data-yd-promo],
|
||||
img[data-yd-promo],
|
||||
[data-yd-powered] {
|
||||
display: none !important;
|
||||
}
|
||||
[data-yd-send] {
|
||||
display: inline-grid !important;
|
||||
place-items: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
margin-left: 8px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
color: #17120a;
|
||||
background: #ffcc4d;
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
<script>
|
||||
(function () {
|
||||
var PROMO_SRC = "/static/guest_promo.png?v=49";
|
||||
var GREET_MARK = "欢迎光临【49彩票集团】";
|
||||
var QUICK = [
|
||||
{ label: "充值问题", text: "我想咨询充值问题", color: "#3b82f6", icon: "充" },
|
||||
{ label: "提款问题", text: "我想咨询提款问题", color: "#22c55e", icon: "提" },
|
||||
{ label: "优惠活动", text: "我想咨询优惠活动", color: "#f59e0b", icon: "惠" },
|
||||
{ label: "账户问题", text: "我想咨询账户问题", color: "#8b5cf6", icon: "户" },
|
||||
{ label: "游戏问题", text: "我想咨询游戏问题", color: "#a855f7", icon: "游" },
|
||||
{ label: "代理合作", text: "我想咨询代理合作", color: "#f97316", icon: "代" },
|
||||
{ label: "常见问题", text: "常见问题", color: "#ef4444", icon: "?" },
|
||||
{ label: "转人工客服", text: "转人工客服", color: "#e31c23", icon: "人" }
|
||||
];
|
||||
var FAQ = [
|
||||
"提款未到账怎么办?",
|
||||
"如何修改登录密码?",
|
||||
"如何绑定银行卡?"
|
||||
];
|
||||
|
||||
function norm(text) {
|
||||
return String(text || "").replace(/\\s+/g, "");
|
||||
}
|
||||
|
||||
function hideChatwootBrand() {
|
||||
// 只藏 branding,不要每次掃整頁 DOM(會卡死)
|
||||
var nodes = document.querySelectorAll(
|
||||
"a[href*='chatwoot'], .branding--container, .branding, .branding--text"
|
||||
);
|
||||
for (var i = 0; i < nodes.length; i += 1) {
|
||||
nodes[i].style.setProperty("display", "none", "important");
|
||||
}
|
||||
}
|
||||
|
||||
function hideStartButtons() {
|
||||
// 不再隱藏「开始会话/開始對話」:退出後要回到可點「开始会话」的首頁
|
||||
}
|
||||
|
||||
function isMessagesView() {
|
||||
var hash = String(window.location.hash || "");
|
||||
return hash.indexOf("messages") !== -1;
|
||||
}
|
||||
|
||||
function goToStartSessionScreen() {
|
||||
try {
|
||||
var url = new URL(window.location.href);
|
||||
url.searchParams.delete("cw_conversation");
|
||||
url.hash = "/";
|
||||
window.location.replace(url.toString());
|
||||
return;
|
||||
} catch (err) {}
|
||||
try {
|
||||
if (
|
||||
window.parent &&
|
||||
typeof window.parent.exitGuestChatToStartSession === "function"
|
||||
) {
|
||||
window.parent.exitGuestChatToStartSession();
|
||||
return;
|
||||
}
|
||||
} catch (err2) {}
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
"chatwoot-widget:" +
|
||||
JSON.stringify({ event: "guestExitToStartSession" }),
|
||||
"*"
|
||||
);
|
||||
} catch (err3) {}
|
||||
}
|
||||
|
||||
function exitAndNewChat() {
|
||||
// 舊名保留:實際改為回到「开始会话」首頁,不再強制開新對話進聊天室
|
||||
goToStartSessionScreen();
|
||||
}
|
||||
|
||||
function hideFileChrome() {
|
||||
var nodes = document.querySelectorAll("a, button, div, span, p");
|
||||
for (var i = 0; i < nodes.length; i += 1) {
|
||||
var el = nodes[i];
|
||||
var text = el.textContent || "";
|
||||
if (
|
||||
text.indexOf("guest_promo.png") === -1 &&
|
||||
!(text.indexOf("下載") !== -1 && text.indexOf(".png") !== -1)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
var box = el.closest(".file") || el.parentElement;
|
||||
if (box) {
|
||||
box.style.setProperty("display", "none", "important");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function findGreetingNode() {
|
||||
var nodes = document.querySelectorAll("p, div, span");
|
||||
for (var i = 0; i < nodes.length; i += 1) {
|
||||
var el = nodes[i];
|
||||
if ((el.textContent || "").indexOf(GREET_MARK) === -1) {
|
||||
continue;
|
||||
}
|
||||
if (el.querySelector && el.querySelector("p, div")) {
|
||||
continue;
|
||||
}
|
||||
return el;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function closeWidget() {
|
||||
try {
|
||||
window.parent.postMessage(
|
||||
"chatwoot-widget:" + JSON.stringify({ event: "closeWindow" }),
|
||||
"*"
|
||||
);
|
||||
} catch (err) {}
|
||||
}
|
||||
|
||||
var EXIT_ICON =
|
||||
'<svg viewBox="0 0 24 24" fill="none" aria-hidden="true">' +
|
||||
'<path d="M10 4H6a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h4" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>' +
|
||||
'<path d="M14 12H21" stroke="#fff" stroke-width="1.8" stroke-linecap="round"/>' +
|
||||
'<path d="M18 8l4 4-4 4" stroke="#fff" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/>' +
|
||||
"</svg>";
|
||||
|
||||
function fillAndSend(text) {
|
||||
var ta = document.querySelector("textarea");
|
||||
if (!ta) {
|
||||
return;
|
||||
}
|
||||
var setter = Object.getOwnPropertyDescriptor(
|
||||
window.HTMLTextAreaElement.prototype,
|
||||
"value"
|
||||
);
|
||||
if (setter && setter.set) {
|
||||
setter.set.call(ta, text);
|
||||
} else {
|
||||
ta.value = text;
|
||||
}
|
||||
ta.dispatchEvent(new Event("input", { bubbles: true }));
|
||||
ta.dispatchEvent(
|
||||
new KeyboardEvent("keydown", { key: "Enter", code: "Enter", bubbles: true })
|
||||
);
|
||||
}
|
||||
|
||||
function tuneHeader() {
|
||||
var header = document.querySelector("header");
|
||||
if (!header) {
|
||||
return;
|
||||
}
|
||||
header.style.setProperty("background", "#11182b", "important");
|
||||
header.style.setProperty("color", "#f5f7ff", "important");
|
||||
header.style.setProperty("display", "flex", "important");
|
||||
header.style.setProperty("align-items", "center", "important");
|
||||
header.style.setProperty("justify-content", "space-between", "important");
|
||||
var oldTitles = header.querySelectorAll("h1, h2, p, span, strong, div");
|
||||
for (var ot = 0; ot < oldTitles.length; ot += 1) {
|
||||
var node = oldTitles[ot];
|
||||
if (
|
||||
node.closest("[data-yd-title]") ||
|
||||
node.closest("[data-yd-online]") ||
|
||||
node.closest("[data-yd-right]") ||
|
||||
node.getAttribute("data-yd-title") ||
|
||||
node.getAttribute("data-yd-online") ||
|
||||
node.getAttribute("data-yd-right") ||
|
||||
node.tagName === "BUTTON" ||
|
||||
(node.querySelector &&
|
||||
node.querySelector(
|
||||
"button, img, svg, [data-yd-title], [data-yd-online], [data-yd-right]"
|
||||
))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
var t = norm(node.textContent);
|
||||
if (!t || t === "×" || t === "在线" || t.indexOf("7x24") !== -1 || t === "在线客服") {
|
||||
continue;
|
||||
}
|
||||
if (t.length <= 30) {
|
||||
node.style.setProperty("display", "none", "important");
|
||||
}
|
||||
}
|
||||
if (!header.querySelector("[data-yd-title]")) {
|
||||
var box = document.createElement("div");
|
||||
box.setAttribute("data-yd-title", "1");
|
||||
box.style.cssText =
|
||||
"display:flex;flex-direction:column;gap:2px;line-height:1.2;color:#f5f7ff;flex:1;";
|
||||
box.innerHTML =
|
||||
'<strong style="font-size:16px;color:#f5f7ff;">在线客服</strong>' +
|
||||
'<span style="font-size:11px;opacity:.92;color:#9aa7c5;">7x24小时为您服务</span>';
|
||||
header.insertBefore(box, header.firstChild);
|
||||
}
|
||||
var right = header.querySelector("[data-yd-right]");
|
||||
if (!right) {
|
||||
right = document.createElement("div");
|
||||
right.setAttribute("data-yd-right", "1");
|
||||
right.style.cssText =
|
||||
"display:inline-flex;align-items:center;gap:8px;margin-left:auto;";
|
||||
header.appendChild(right);
|
||||
}
|
||||
var online = header.querySelector("[data-yd-online]");
|
||||
if (!online) {
|
||||
online = document.createElement("span");
|
||||
online.setAttribute("data-yd-online", "1");
|
||||
online.style.cssText =
|
||||
"display:inline-flex;align-items:center;gap:5px;font-size:12px;color:#f5f7ff;white-space:nowrap;";
|
||||
online.innerHTML =
|
||||
'<i style="width:8px;height:8px;border-radius:999px;background:#22c55e;display:inline-block;"></i>在线';
|
||||
}
|
||||
if (online.parentNode !== right) {
|
||||
right.appendChild(online);
|
||||
}
|
||||
// 隱藏原生關閉鈕,改用「退出並開新聊天」圖示
|
||||
var buttons = header.querySelectorAll("button");
|
||||
for (var i = 0; i < buttons.length; i += 1) {
|
||||
var btn = buttons[i];
|
||||
if (btn.getAttribute("data-yd-exit")) {
|
||||
continue;
|
||||
}
|
||||
btn.style.setProperty("display", "none", "important");
|
||||
}
|
||||
var exitBtn = header.querySelector("button[data-yd-exit]");
|
||||
if (!exitBtn) {
|
||||
exitBtn = document.createElement("button");
|
||||
exitBtn.type = "button";
|
||||
exitBtn.setAttribute("data-yd-exit", "1");
|
||||
exitBtn.setAttribute("aria-label", "退出到开始会话");
|
||||
exitBtn.title = "退出到开始会话";
|
||||
exitBtn.innerHTML = EXIT_ICON;
|
||||
exitBtn.addEventListener("click", function (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
goToStartSessionScreen();
|
||||
});
|
||||
right.appendChild(exitBtn);
|
||||
}
|
||||
exitBtn.style.setProperty("display", "inline-flex", "important");
|
||||
if (exitBtn.parentNode !== right) {
|
||||
right.appendChild(exitBtn);
|
||||
} else if (online.nextSibling !== exitBtn) {
|
||||
right.appendChild(exitBtn);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureQuickPanel() {
|
||||
var greet = findGreetingNode();
|
||||
if (!greet || !greet.parentNode) {
|
||||
return;
|
||||
}
|
||||
var panel = document.querySelector("[data-yd-panel]");
|
||||
if (!panel) {
|
||||
panel = document.createElement("div");
|
||||
panel.setAttribute("data-yd-panel", "1");
|
||||
var hint = document.createElement("p");
|
||||
hint.className = "yd-hint";
|
||||
hint.textContent = "您可以点击下方快捷入口获取帮助";
|
||||
panel.appendChild(hint);
|
||||
var grid = document.createElement("div");
|
||||
grid.className = "yd-grid";
|
||||
QUICK.forEach(function (item) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.innerHTML =
|
||||
'<span style="background:' +
|
||||
item.color +
|
||||
'">' +
|
||||
item.icon +
|
||||
"</span>" +
|
||||
item.label;
|
||||
b.addEventListener("click", function () {
|
||||
fillAndSend(item.text);
|
||||
});
|
||||
grid.appendChild(b);
|
||||
});
|
||||
panel.appendChild(grid);
|
||||
var faqTitle = document.createElement("div");
|
||||
faqTitle.className = "yd-faq-title";
|
||||
faqTitle.textContent = "猜你想问";
|
||||
panel.appendChild(faqTitle);
|
||||
var faq = document.createElement("div");
|
||||
faq.className = "yd-faq";
|
||||
FAQ.forEach(function (q) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.innerHTML = "<span>" + q + "</span><span>›</span>";
|
||||
b.addEventListener("click", function () {
|
||||
fillAndSend(q);
|
||||
});
|
||||
faq.appendChild(b);
|
||||
});
|
||||
panel.appendChild(faq);
|
||||
}
|
||||
// 固定插在歡迎語正下方(同氣泡內、緊接文字後)
|
||||
if (greet.nextSibling !== panel) {
|
||||
greet.parentNode.insertBefore(panel, greet.nextSibling);
|
||||
}
|
||||
}
|
||||
|
||||
function ensureComposer() {
|
||||
var ta = document.querySelector("textarea");
|
||||
if (!ta || !ta.parentNode) {
|
||||
return;
|
||||
}
|
||||
if (!ta.placeholder || ta.placeholder.indexOf("訊息") !== -1 || ta.placeholder.indexOf("请输入信息") !== -1) {
|
||||
ta.placeholder = "请输入您的问题...";
|
||||
}
|
||||
if (!document.querySelector("[data-yd-send]")) {
|
||||
var send = document.createElement("button");
|
||||
send.type = "button";
|
||||
send.setAttribute("data-yd-send", "1");
|
||||
send.setAttribute("aria-label", "发送");
|
||||
send.textContent = "➤";
|
||||
send.addEventListener("click", function () {
|
||||
fillAndSend(ta.value || "");
|
||||
});
|
||||
ta.parentNode.appendChild(send);
|
||||
}
|
||||
var footer = document.querySelector(".chat-message--input, .chat-footer, footer") || ta.parentNode.parentNode;
|
||||
var powered = document.querySelector("[data-yd-powered]");
|
||||
if (footer && !powered) {
|
||||
powered = document.createElement("div");
|
||||
powered.setAttribute("data-yd-powered", "1");
|
||||
footer.appendChild(powered);
|
||||
}
|
||||
if (powered) {
|
||||
powered.textContent = "";
|
||||
powered.style.setProperty("display", "none", "important");
|
||||
}
|
||||
}
|
||||
|
||||
function paintTheme() {
|
||||
var userBubbles = document.querySelectorAll(
|
||||
".user-message .chat-bubble, .user-message-wrap .chat-bubble"
|
||||
);
|
||||
for (var i = 0; i < userBubbles.length; i += 1) {
|
||||
userBubbles[i].style.setProperty("background", "rgba(123,97,255,.28)", "important");
|
||||
userBubbles[i].style.setProperty("color", "#f5f7ff", "important");
|
||||
}
|
||||
var agentBubbles = document.querySelectorAll(
|
||||
".agent-message .chat-bubble, .agent-message-wrap .chat-bubble"
|
||||
);
|
||||
for (var j = 0; j < agentBubbles.length; j += 1) {
|
||||
agentBubbles[j].style.setProperty("background", "#19233c", "important");
|
||||
agentBubbles[j].style.setProperty("color", "#f5f7ff", "important");
|
||||
}
|
||||
ensureComposer();
|
||||
// 旧版:不插入快捷面板 / 猜你想问
|
||||
}
|
||||
|
||||
function paintPromo() {
|
||||
// 首頁(开始会话)不套聊天室皮膚,避免蓋住「开始会话」
|
||||
if (!isMessagesView()) {
|
||||
hideChatwootBrand();
|
||||
return;
|
||||
}
|
||||
hideStartButtons();
|
||||
hideFileChrome();
|
||||
hideChatwootBrand();
|
||||
tuneHeader();
|
||||
paintTheme();
|
||||
var promo = document.querySelector("img[data-yd-promo], [data-yd-panel]");
|
||||
if (promo) {
|
||||
promo.remove();
|
||||
}
|
||||
}
|
||||
|
||||
setInterval(paintPromo, 1500);
|
||||
document.addEventListener("DOMContentLoaded", paintPromo);
|
||||
|
||||
// ActionCable 若仍失敗,輪詢訊息補上坐席回覆
|
||||
var lastMsgKey = "";
|
||||
function pollAgentMessages() {
|
||||
try {
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
var token = params.get("website_token");
|
||||
var cw = params.get("cw_conversation");
|
||||
if (!token || !cw) {
|
||||
return;
|
||||
}
|
||||
fetch(
|
||||
"/api/v1/widget/messages?website_token=" +
|
||||
encodeURIComponent(token) +
|
||||
"&cw_conversation=" +
|
||||
encodeURIComponent(cw),
|
||||
{ credentials: "same-origin" }
|
||||
)
|
||||
.then(function (response) {
|
||||
return response.json();
|
||||
})
|
||||
.then(function (data) {
|
||||
var list = [];
|
||||
if (data && Array.isArray(data.payload)) {
|
||||
list = data.payload;
|
||||
} else if (data && data.payload && Array.isArray(data.payload.messages)) {
|
||||
list = data.payload.messages;
|
||||
} else if (Array.isArray(data)) {
|
||||
list = data;
|
||||
} else if (data && Array.isArray(data.messages)) {
|
||||
list = data.messages;
|
||||
}
|
||||
if (!list.length) {
|
||||
return;
|
||||
}
|
||||
var maxId = 0;
|
||||
for (var i = 0; i < list.length; i += 1) {
|
||||
var id = Number((list[i] && list[i].id) || 0);
|
||||
if (id > maxId) {
|
||||
maxId = id;
|
||||
}
|
||||
}
|
||||
var key = String(maxId) + ":" + String(list.length);
|
||||
// 禁止整頁 reload:訊息一變就刷新會造成聊天室卡死/閃爍
|
||||
lastMsgKey = key;
|
||||
})
|
||||
.catch(function () {});
|
||||
} catch (err) {}
|
||||
}
|
||||
setInterval(pollAgentMessages, 2500);
|
||||
setTimeout(pollAgentMessages, 1200);
|
||||
})();
|
||||
</script>
|
||||
"""
|
||||
|
||||
|
||||
def _cable_ws_origin() -> str:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(CONFIG.chatwoot_guest_proxy_target or "http://127.0.0.1:3000")
|
||||
scheme = "wss" if parsed.scheme == "https" else "ws"
|
||||
host = parsed.netloc or "127.0.0.1:3000"
|
||||
return f"{scheme}://{host}"
|
||||
|
||||
|
||||
def _inject_script() -> str:
|
||||
return INJECT_SCRIPT.replace("__CABLE_WS_ORIGIN__", _cable_ws_origin())
|
||||
|
||||
|
||||
def _forward_headers() -> dict[str, str]:
|
||||
headers: dict[str, str] = {}
|
||||
for key, value in request.headers.items():
|
||||
if key.lower() in HOP_BY_HOP:
|
||||
continue
|
||||
headers[key] = value
|
||||
return headers
|
||||
|
||||
|
||||
def proxy_chatwoot(subpath: str) -> Response:
|
||||
target = CONFIG.chatwoot_guest_proxy_target.rstrip("/")
|
||||
query = urlencode(request.args, doseq=True)
|
||||
url = f"{target}/{subpath.lstrip('/')}"
|
||||
if query:
|
||||
url = f"{url}?{query}"
|
||||
|
||||
body = request.get_data() if request.method not in {"GET", "HEAD"} else None
|
||||
forwarded = Request(
|
||||
url,
|
||||
data=body,
|
||||
headers=_forward_headers(),
|
||||
method=request.method,
|
||||
)
|
||||
|
||||
try:
|
||||
with urlopen(forwarded, timeout=30) as upstream:
|
||||
payload = upstream.read()
|
||||
status = upstream.status
|
||||
content_type = upstream.headers.get("Content-Type", "")
|
||||
response_headers = [
|
||||
(key, value)
|
||||
for key, value in upstream.headers.items()
|
||||
if key.lower() not in HOP_BY_HOP
|
||||
]
|
||||
except HTTPError as exc:
|
||||
payload = exc.read()
|
||||
status = exc.code
|
||||
content_type = exc.headers.get("Content-Type", "") if exc.headers else ""
|
||||
response_headers = []
|
||||
except URLError as exc:
|
||||
return Response(
|
||||
f"无法连线本机 Chatwoot:{exc.reason}",
|
||||
status=502,
|
||||
mimetype="text/plain; charset=utf-8",
|
||||
)
|
||||
|
||||
if "text/html" in content_type.lower() and payload:
|
||||
html = payload.decode("utf-8", "replace")
|
||||
inject = _inject_script()
|
||||
if "</head>" in html:
|
||||
html = html.replace("</head>", inject + "</head>", 1)
|
||||
elif "</body>" in html:
|
||||
html = html.replace("</body>", inject + "</body>", 1)
|
||||
else:
|
||||
html += inject
|
||||
payload = html.encode("utf-8")
|
||||
|
||||
return Response(payload, status=status, headers=response_headers)
|
||||
@@ -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,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
|
||||
@@ -0,0 +1,16 @@
|
||||
from app import app
|
||||
|
||||
|
||||
def main() -> None:
|
||||
client = app.test_client()
|
||||
health = client.get("/health")
|
||||
assert health.status_code == 200, health.data
|
||||
login = client.get("/login")
|
||||
assert login.status_code == 200, login.data
|
||||
print("[正常] /health")
|
||||
print("[正常] /login")
|
||||
print("注意:此測試不連接實際會員資料。")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,746 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
|
||||
DEFAULT_BASE_URL = "http://www.pubuli.com:7788/"
|
||||
DEFAULT_ACTIVITY_ID = "ad-x1234"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TestResult:
|
||||
name: str
|
||||
passed: bool
|
||||
status_code: int | None
|
||||
message: str
|
||||
response: Any = None
|
||||
|
||||
|
||||
def build_url(
|
||||
base_url: str,
|
||||
path: str,
|
||||
query: dict[str, Any] | None = None,
|
||||
) -> str:
|
||||
url = f"{base_url.rstrip('/')}/{path.lstrip('/')}"
|
||||
|
||||
if query:
|
||||
filtered = {
|
||||
key: value
|
||||
for key, value in query.items()
|
||||
if value is not None and value != ""
|
||||
}
|
||||
|
||||
if filtered:
|
||||
url += "?" + urllib.parse.urlencode(filtered)
|
||||
|
||||
return url
|
||||
|
||||
|
||||
def request_json(
|
||||
*,
|
||||
base_url: str,
|
||||
path: str,
|
||||
method: str = "GET",
|
||||
query: dict[str, Any] | None = None,
|
||||
body: dict[str, Any] | None = None,
|
||||
timeout: int = 15,
|
||||
) -> tuple[int, Any]:
|
||||
url = build_url(
|
||||
base_url=base_url,
|
||||
path=path,
|
||||
query=query,
|
||||
)
|
||||
|
||||
headers = {
|
||||
"Accept": "application/json",
|
||||
"User-Agent": "API-System-Smoke-Test/2.0",
|
||||
}
|
||||
|
||||
data = None
|
||||
|
||||
if body is not None:
|
||||
data = json.dumps(
|
||||
body,
|
||||
ensure_ascii=False,
|
||||
).encode("utf-8")
|
||||
|
||||
headers["Content-Type"] = "application/json"
|
||||
|
||||
request = urllib.request.Request(
|
||||
url=url,
|
||||
data=data,
|
||||
headers=headers,
|
||||
method=method.upper(),
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
request,
|
||||
timeout=timeout,
|
||||
) as response:
|
||||
status_code = int(response.status)
|
||||
raw_text = response.read().decode(
|
||||
"utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
except urllib.error.HTTPError as exc:
|
||||
status_code = int(exc.code)
|
||||
raw_text = exc.read().decode(
|
||||
"utf-8",
|
||||
errors="replace",
|
||||
)
|
||||
|
||||
try:
|
||||
parsed = (
|
||||
json.loads(raw_text)
|
||||
if raw_text
|
||||
else None
|
||||
)
|
||||
except json.JSONDecodeError:
|
||||
parsed = raw_text
|
||||
|
||||
return status_code, parsed
|
||||
|
||||
|
||||
def request_text(
|
||||
*,
|
||||
base_url: str,
|
||||
path: str,
|
||||
timeout: int = 15,
|
||||
) -> tuple[int, str, str]:
|
||||
url = build_url(
|
||||
base_url=base_url,
|
||||
path=path,
|
||||
)
|
||||
|
||||
request = urllib.request.Request(
|
||||
url=url,
|
||||
method="GET",
|
||||
headers={
|
||||
"Accept": "text/html,application/xhtml+xml",
|
||||
"User-Agent": "API-System-Smoke-Test/2.0",
|
||||
},
|
||||
)
|
||||
|
||||
try:
|
||||
with urllib.request.urlopen(
|
||||
request,
|
||||
timeout=timeout,
|
||||
) as response:
|
||||
return (
|
||||
int(response.status),
|
||||
response.geturl(),
|
||||
response.read().decode(
|
||||
"utf-8",
|
||||
errors="replace",
|
||||
),
|
||||
)
|
||||
|
||||
except urllib.error.HTTPError as exc:
|
||||
return (
|
||||
int(exc.code),
|
||||
exc.geturl(),
|
||||
exc.read().decode(
|
||||
"utf-8",
|
||||
errors="replace",
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def add_result(
|
||||
results: list[TestResult],
|
||||
*,
|
||||
name: str,
|
||||
passed: bool,
|
||||
status_code: int | None,
|
||||
message: str,
|
||||
response: Any = None,
|
||||
) -> None:
|
||||
results.append(
|
||||
TestResult(
|
||||
name=name,
|
||||
passed=passed,
|
||||
status_code=status_code,
|
||||
message=message,
|
||||
response=response,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def ask_username() -> str:
|
||||
"""
|
||||
互動式要求輸入測試會員名。
|
||||
|
||||
使用者直接按 Enter 時會持續要求輸入,
|
||||
避免傳送空白會員名給 API。
|
||||
"""
|
||||
while True:
|
||||
username = input(
|
||||
"請輸入要測試的會員名:"
|
||||
).strip()
|
||||
|
||||
if username:
|
||||
return username
|
||||
|
||||
print("會員名不可為空,請重新輸入。")
|
||||
|
||||
|
||||
def run_tests(
|
||||
args: argparse.Namespace,
|
||||
) -> list[TestResult]:
|
||||
results: list[TestResult] = []
|
||||
|
||||
# 1. 主域名
|
||||
try:
|
||||
status, final_url, html = request_text(
|
||||
base_url=args.base_url,
|
||||
path="/",
|
||||
timeout=args.timeout,
|
||||
)
|
||||
|
||||
passed = status == 200
|
||||
|
||||
add_result(
|
||||
results,
|
||||
name="主域名訪問",
|
||||
passed=passed,
|
||||
status_code=status,
|
||||
message=(
|
||||
f"可訪問,最終地址:{final_url}"
|
||||
if passed
|
||||
else f"主域名返回異常,最終地址:{final_url}"
|
||||
),
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
add_result(
|
||||
results,
|
||||
name="主域名訪問",
|
||||
passed=False,
|
||||
status_code=None,
|
||||
message=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
# 2. 登入頁
|
||||
try:
|
||||
status, final_url, html = request_text(
|
||||
base_url=args.base_url,
|
||||
path="/login",
|
||||
timeout=args.timeout,
|
||||
)
|
||||
|
||||
passed = (
|
||||
status == 200
|
||||
and (
|
||||
"用戶名" in html
|
||||
or "username" in html.lower()
|
||||
)
|
||||
)
|
||||
|
||||
add_result(
|
||||
results,
|
||||
name="登入頁",
|
||||
passed=passed,
|
||||
status_code=status,
|
||||
message=(
|
||||
f"登入頁正常:{final_url}"
|
||||
if passed
|
||||
else "登入頁內容不符合預期"
|
||||
),
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
add_result(
|
||||
results,
|
||||
name="登入頁",
|
||||
passed=False,
|
||||
status_code=None,
|
||||
message=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
|
||||
# 3. 健康檢查
|
||||
try:
|
||||
status, data = request_json(
|
||||
base_url=args.base_url,
|
||||
path="/health",
|
||||
timeout=args.timeout,
|
||||
)
|
||||
|
||||
passed = (
|
||||
status == 200
|
||||
and isinstance(data, dict)
|
||||
and data.get("status") == "ok"
|
||||
)
|
||||
|
||||
add_result(
|
||||
results,
|
||||
name="健康檢查",
|
||||
passed=passed,
|
||||
status_code=status,
|
||||
message=(
|
||||
"服務狀態正常"
|
||||
if passed
|
||||
else "健康接口回應不符合預期"
|
||||
),
|
||||
response=data,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
add_result(
|
||||
results,
|
||||
name="健康檢查",
|
||||
passed=False,
|
||||
status_code=None,
|
||||
message=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
|
||||
# 4. 帳號查詢
|
||||
account_data: Any = None
|
||||
|
||||
try:
|
||||
status, account_data = request_json(
|
||||
base_url=args.base_url,
|
||||
path="/platform/get-user-account",
|
||||
query={
|
||||
"username": args.username,
|
||||
},
|
||||
timeout=args.timeout,
|
||||
)
|
||||
|
||||
passed = (
|
||||
status == 200
|
||||
and isinstance(account_data, dict)
|
||||
and account_data.get("is_exist") is True
|
||||
)
|
||||
|
||||
add_result(
|
||||
results,
|
||||
name="帳號查詢",
|
||||
passed=passed,
|
||||
status_code=status,
|
||||
message=(
|
||||
f"找到會員,狀態={account_data.get('status')}"
|
||||
if passed
|
||||
else "找不到會員或帳號接口格式錯誤"
|
||||
),
|
||||
response=account_data,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
add_result(
|
||||
results,
|
||||
name="帳號查詢",
|
||||
passed=False,
|
||||
status_code=None,
|
||||
message=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
|
||||
query_username = args.username
|
||||
|
||||
if isinstance(account_data, dict):
|
||||
query_username = (
|
||||
account_data.get("username_clean")
|
||||
or account_data.get("username")
|
||||
or args.username
|
||||
)
|
||||
|
||||
# 5. 提款記錄
|
||||
try:
|
||||
status, data = request_json(
|
||||
base_url=args.base_url,
|
||||
path="/platform/withdraw-orders",
|
||||
query={
|
||||
"username": query_username,
|
||||
},
|
||||
timeout=args.timeout,
|
||||
)
|
||||
|
||||
passed = (
|
||||
status == 200
|
||||
and isinstance(data, list)
|
||||
)
|
||||
|
||||
add_result(
|
||||
results,
|
||||
name="提款記錄查詢",
|
||||
passed=passed,
|
||||
status_code=status,
|
||||
message=(
|
||||
f"返回 {len(data)} 筆提款記錄"
|
||||
if passed
|
||||
else "提款接口回應格式錯誤"
|
||||
),
|
||||
response=(
|
||||
data[:5]
|
||||
if isinstance(data, list)
|
||||
else data
|
||||
),
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
add_result(
|
||||
results,
|
||||
name="提款記錄查詢",
|
||||
passed=False,
|
||||
status_code=None,
|
||||
message=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
|
||||
# 6. 未完成提款
|
||||
try:
|
||||
status, data = request_json(
|
||||
base_url=args.base_url,
|
||||
path="/platform/withdraw-orders-unfinish",
|
||||
query={
|
||||
"username": query_username,
|
||||
},
|
||||
timeout=args.timeout,
|
||||
)
|
||||
|
||||
passed = (
|
||||
status == 200
|
||||
and isinstance(data, list)
|
||||
)
|
||||
|
||||
add_result(
|
||||
results,
|
||||
name="未完成提款查詢",
|
||||
passed=passed,
|
||||
status_code=status,
|
||||
message=(
|
||||
f"返回 {len(data)} 筆未完成提款"
|
||||
if passed
|
||||
else "未完成提款接口回應格式錯誤"
|
||||
),
|
||||
response=(
|
||||
data[:5]
|
||||
if isinstance(data, list)
|
||||
else data
|
||||
),
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
add_result(
|
||||
results,
|
||||
name="未完成提款查詢",
|
||||
passed=False,
|
||||
status_code=None,
|
||||
message=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
|
||||
# 7. 充值記錄
|
||||
deposit_query: dict[str, Any] = {
|
||||
"username": query_username,
|
||||
}
|
||||
|
||||
if args.amount:
|
||||
deposit_query["amount"] = args.amount
|
||||
|
||||
if args.created_at:
|
||||
deposit_query["created_at"] = args.created_at
|
||||
|
||||
try:
|
||||
status, data = request_json(
|
||||
base_url=args.base_url,
|
||||
path="/platform/deposit-not-received",
|
||||
query=deposit_query,
|
||||
timeout=args.timeout,
|
||||
)
|
||||
|
||||
passed = (
|
||||
status == 200
|
||||
and isinstance(data, list)
|
||||
)
|
||||
|
||||
add_result(
|
||||
results,
|
||||
name="充值記錄查詢",
|
||||
passed=passed,
|
||||
status_code=status,
|
||||
message=(
|
||||
f"返回 {len(data)} 筆充值記錄"
|
||||
if passed
|
||||
else "充值接口回應格式錯誤"
|
||||
),
|
||||
response=(
|
||||
data[:5]
|
||||
if isinstance(data, list)
|
||||
else data
|
||||
),
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
add_result(
|
||||
results,
|
||||
name="充值記錄查詢",
|
||||
passed=False,
|
||||
status_code=None,
|
||||
message=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
|
||||
# 8. 活動申請模擬
|
||||
try:
|
||||
status, data = request_json(
|
||||
base_url=args.base_url,
|
||||
path="/platform/apply-activity",
|
||||
method="POST",
|
||||
body={
|
||||
"username": query_username,
|
||||
"activity_id": args.activity_id,
|
||||
},
|
||||
timeout=args.timeout,
|
||||
)
|
||||
|
||||
passed = (
|
||||
status == 200
|
||||
and isinstance(data, dict)
|
||||
and "activity_found" in data
|
||||
and "qualified" in data
|
||||
)
|
||||
|
||||
add_result(
|
||||
results,
|
||||
name="活動申請模擬",
|
||||
passed=passed,
|
||||
status_code=status,
|
||||
message=(
|
||||
"活動模擬接口正常"
|
||||
if passed
|
||||
else "活動接口回應格式錯誤"
|
||||
),
|
||||
response=data,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
add_result(
|
||||
results,
|
||||
name="活動申請模擬",
|
||||
passed=False,
|
||||
status_code=None,
|
||||
message=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
|
||||
# 9. 餘額查詢模擬
|
||||
try:
|
||||
status, data = request_json(
|
||||
base_url=args.base_url,
|
||||
path="/platform/fix-balance",
|
||||
method="PUT",
|
||||
body={
|
||||
"username": query_username,
|
||||
},
|
||||
timeout=args.timeout,
|
||||
)
|
||||
|
||||
passed = (
|
||||
status == 200
|
||||
and isinstance(data, dict)
|
||||
and "balance" in data
|
||||
)
|
||||
|
||||
add_result(
|
||||
results,
|
||||
name="餘額查詢模擬",
|
||||
passed=passed,
|
||||
status_code=status,
|
||||
message=(
|
||||
f"餘額={data.get('balance')}"
|
||||
if passed
|
||||
else "餘額接口回應格式錯誤"
|
||||
),
|
||||
response=data,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
add_result(
|
||||
results,
|
||||
name="餘額查詢模擬",
|
||||
passed=False,
|
||||
status_code=None,
|
||||
message=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def print_results(
|
||||
results: list[TestResult],
|
||||
verbose: bool,
|
||||
) -> None:
|
||||
print()
|
||||
print("API接口系統測試結果")
|
||||
print("=" * 76)
|
||||
|
||||
for index, result in enumerate(
|
||||
results,
|
||||
start=1,
|
||||
):
|
||||
mark = (
|
||||
"PASS"
|
||||
if result.passed
|
||||
else "FAIL"
|
||||
)
|
||||
|
||||
status_text = (
|
||||
str(result.status_code)
|
||||
if result.status_code is not None
|
||||
else "-"
|
||||
)
|
||||
|
||||
print(
|
||||
f"[{mark}] {index:02d}. "
|
||||
f"{result.name} "
|
||||
f"(HTTP {status_text})"
|
||||
)
|
||||
print(f" {result.message}")
|
||||
|
||||
if (
|
||||
verbose
|
||||
and result.response is not None
|
||||
):
|
||||
print(
|
||||
json.dumps(
|
||||
result.response,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
default=str,
|
||||
)
|
||||
)
|
||||
|
||||
passed_count = sum(
|
||||
1
|
||||
for result in results
|
||||
if result.passed
|
||||
)
|
||||
|
||||
failed_count = (
|
||||
len(results)
|
||||
- passed_count
|
||||
)
|
||||
|
||||
print("-" * 76)
|
||||
|
||||
print(
|
||||
f"總計:{len(results)},"
|
||||
f"通過:{passed_count},"
|
||||
f"失敗:{failed_count}"
|
||||
)
|
||||
|
||||
print(
|
||||
"測試結果:全部通過"
|
||||
if failed_count == 0
|
||||
else "測試結果:存在失敗項目"
|
||||
)
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(
|
||||
description=(
|
||||
"pubuli.com API接口系統"
|
||||
"可輸入會員名測試腳本"
|
||||
)
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--base-url",
|
||||
default=DEFAULT_BASE_URL,
|
||||
help=(
|
||||
"主域名,預設:"
|
||||
f"{DEFAULT_BASE_URL}"
|
||||
),
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--username",
|
||||
default="",
|
||||
help=(
|
||||
"測試會員名。"
|
||||
"未指定時,程式會提示輸入。"
|
||||
),
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--amount",
|
||||
default="",
|
||||
help="可選:指定充值金額",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--created-at",
|
||||
default="",
|
||||
help=(
|
||||
"可選:指定充值時間,"
|
||||
"格式為 ISO-8601"
|
||||
),
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--activity-id",
|
||||
default=DEFAULT_ACTIVITY_ID,
|
||||
help="測試活動 ID",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--timeout",
|
||||
type=int,
|
||||
default=15,
|
||||
help="單個請求超時秒數",
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--verbose",
|
||||
action="store_true",
|
||||
help="顯示完整 JSON 回應",
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# 沒有透過命令列指定會員名時,進入互動輸入模式。
|
||||
if not args.username.strip():
|
||||
args.username = ask_username()
|
||||
else:
|
||||
args.username = args.username.strip()
|
||||
|
||||
return args
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
print()
|
||||
print("pubuli.com API接口系統冒煙測試")
|
||||
print("=" * 76)
|
||||
print(f"主域名:{args.base_url}")
|
||||
print(f"測試會員:{args.username}")
|
||||
print("API Key:目前不使用")
|
||||
print(f"請求超時:{args.timeout} 秒")
|
||||
|
||||
results = run_tests(args)
|
||||
|
||||
print_results(
|
||||
results=results,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
|
||||
return (
|
||||
0
|
||||
if all(
|
||||
result.passed
|
||||
for result in results
|
||||
)
|
||||
else 1
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 207 KiB |
File diff suppressed because one or more lines are too long
@@ -0,0 +1,456 @@
|
||||
{% 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: "dark",
|
||||
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.exitGuestChatToStartSession = function () {
|
||||
// 清掉對話,回到 Chatwoot「开始会话」首頁(不預建新對話)
|
||||
window.__guestWidgetOpen = true;
|
||||
try {
|
||||
if (window.$chatwoot && typeof window.$chatwoot.reset === "function") {
|
||||
window.$chatwoot.reset();
|
||||
}
|
||||
} catch (error) {}
|
||||
document.querySelectorAll("iframe[src*='widget']").forEach(function (iframe) {
|
||||
var url = new URL(iframe.src, window.location.origin);
|
||||
url.searchParams.delete("cw_conversation");
|
||||
url.hash = "/";
|
||||
iframe.src = url.toString();
|
||||
});
|
||||
window.setTimeout(function () {
|
||||
if (window.$chatwoot && typeof window.$chatwoot.toggle === "function") {
|
||||
try {
|
||||
window.$chatwoot.toggle("open");
|
||||
} catch (error) {}
|
||||
}
|
||||
}, 400);
|
||||
};
|
||||
|
||||
// 相容舊名稱:退出圖示改為回「开始会话」,不再強制開新單
|
||||
window.exitGuestChatAndStartNew = window.exitGuestChatToStartSession;
|
||||
|
||||
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 === "guestExitToStartSession" ||
|
||||
payload.event === "guestExitNewChat")
|
||||
) {
|
||||
if (typeof window.exitGuestChatToStartSession === "function") {
|
||||
window.exitGuestChatToStartSession();
|
||||
} else if (typeof window.exitGuestChatAndStartNew === "function") {
|
||||
window.exitGuestChatAndStartNew();
|
||||
}
|
||||
}
|
||||
} catch (error) {}
|
||||
});
|
||||
|
||||
|
||||
window.addEventListener(
|
||||
"chatwoot:ready",
|
||||
function () {
|
||||
debugLog("chatwoot:ready", window.$chatwoot);
|
||||
|
||||
const guestResetRequired =
|
||||
{{ chatwoot_guest_reset_required | tojson }};
|
||||
|
||||
/*
|
||||
* 只有「剛完成會員登出」且目前未登入時,才清除舊會員身份。
|
||||
* 一般遊客頁面不會每次都 reset,因此不會破壞既有匿名對話。
|
||||
*/
|
||||
if (
|
||||
!CHATWOOT_USER.is_authenticated &&
|
||||
guestResetRequired &&
|
||||
window.$chatwoot &&
|
||||
typeof window.$chatwoot.reset === "function"
|
||||
) {
|
||||
try {
|
||||
window.$chatwoot.reset();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"Chatwoot guest reset failed:",
|
||||
error
|
||||
);
|
||||
}
|
||||
|
||||
/*
|
||||
* reset 後稍候再設定 visitor_type=guest。
|
||||
* 不執行 setUser(),因此不會向 Chatwoot 傳遞用戶名。
|
||||
*/
|
||||
window.setTimeout(
|
||||
configureChatwootIdentity,
|
||||
300
|
||||
);
|
||||
bindGuestBubbleToggle();
|
||||
return;
|
||||
}
|
||||
|
||||
configureChatwootIdentity();
|
||||
bindGuestBubbleToggle();
|
||||
}
|
||||
);
|
||||
|
||||
window.addEventListener(
|
||||
"chatwoot:error",
|
||||
function (event) {
|
||||
console.error(
|
||||
"chatwoot:error",
|
||||
event && event.detail ? event.detail : event
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
if (CHATWOOT_CONFIG.debug) {
|
||||
window.addEventListener(
|
||||
"chatwoot:on-message",
|
||||
function (event) {
|
||||
debugLog("chatwoot:on-message", event.detail);
|
||||
}
|
||||
);
|
||||
|
||||
window.addEventListener(
|
||||
"chatwoot:postback",
|
||||
function (event) {
|
||||
debugLog("chatwoot:postback", event.detail);
|
||||
}
|
||||
);
|
||||
|
||||
window.addEventListener(
|
||||
"chatwoot:opened",
|
||||
function () {
|
||||
debugLog("chatwoot:opened");
|
||||
}
|
||||
);
|
||||
|
||||
window.addEventListener(
|
||||
"chatwoot:closed",
|
||||
function () {
|
||||
debugLog("chatwoot:closed");
|
||||
}
|
||||
);
|
||||
|
||||
window.addEventListener(
|
||||
"chatwoot:on-start-conversation",
|
||||
function (event) {
|
||||
debugLog(
|
||||
"chatwoot:on-start-conversation",
|
||||
event.detail
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
const script = document.createElement("script");
|
||||
const firstScript = document.getElementsByTagName("script")[0];
|
||||
|
||||
script.src = (
|
||||
CHATWOOT_CONFIG.base_url +
|
||||
"/packs/js/sdk.js"
|
||||
);
|
||||
script.async = true;
|
||||
|
||||
script.onload = function () {
|
||||
if (!window.chatwootSDK) {
|
||||
console.error("Chatwoot SDK object not found");
|
||||
return;
|
||||
}
|
||||
|
||||
window.chatwootSDK.run({
|
||||
websiteToken: CHATWOOT_CONFIG.website_token,
|
||||
baseUrl: CHATWOOT_CONFIG.base_url
|
||||
});
|
||||
};
|
||||
|
||||
script.onerror = function () {
|
||||
console.error(
|
||||
"Chatwoot SDK load failed:",
|
||||
script.src
|
||||
);
|
||||
};
|
||||
|
||||
firstScript.parentNode.insertBefore(
|
||||
script,
|
||||
firstScript
|
||||
);
|
||||
})();
|
||||
</script>
|
||||
{% endif %}
|
||||
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}{{ app_name }}{% endblock %}</title>
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='style.css') }}">
|
||||
</head>
|
||||
<body class="{% block body_class %}{% endblock %}">
|
||||
{% block body %}{% endblock %}
|
||||
{% block integrations %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,67 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}客服中心|{{ app_name }}{% endblock %}
|
||||
{% block body %}
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<div class="site-title">客服測試入口</div>
|
||||
<div class="site-subtitle">目前用戶:{{ username }}</div>
|
||||
</div>
|
||||
<a class="back-link" href="{{ url_for('home') }}">返回首頁</a>
|
||||
</header>
|
||||
|
||||
<main class="page narrow">
|
||||
<section class="support-panel">
|
||||
<div class="support-avatar">🎧</div>
|
||||
<h1>會員資料查詢</h1>
|
||||
{% if error %}
|
||||
<div class="alert alert-error">{{ error }}</div>
|
||||
{% else %}
|
||||
{% if account.status == "locked" %}
|
||||
<div class="alert alert-warning">
|
||||
帳戶已凍結{% if account.locked_reason %}|{{ account.locked_reason }}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2>未完成提款</h2>
|
||||
{% if not withdrawals %}
|
||||
<div class="empty-state">目前沒有查到未完成提款資料。</div>
|
||||
{% else %}
|
||||
<div class="order-list">
|
||||
{% for order in withdrawals %}
|
||||
<article class="order-card">
|
||||
<strong>{{ order.status or "狀態未知" }}</strong>
|
||||
<span>來源:{{ order.source }}</span>
|
||||
<span>訂單:{{ order.order_id or "NA" }}</span>
|
||||
<span>金額:{{ order.amount }}</span>
|
||||
<span>時間:{{ order.create_time or "NA" }}</span>
|
||||
{% if order.fail_reason %}<span>備註:{{ order.fail_reason }}</span>{% endif %}
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<h2 class="section-gap">最近充值</h2>
|
||||
{% if not deposits %}
|
||||
<div class="empty-state">目前沒有查到充值資料。</div>
|
||||
{% else %}
|
||||
<div class="order-list">
|
||||
{% for order in deposits %}
|
||||
<article class="order-card">
|
||||
<strong>{{ order.status or "狀態未知" }}</strong>
|
||||
<span>訂單:{{ order.thirdparty_order_id or "NA" }}</span>
|
||||
<span>充值金額:{{ order.amount }}</span>
|
||||
<span>實際到帳:{{ order.actual_arrival_amount }}</span>
|
||||
<span>充值時間:{{ order.created_at or "NA" }}</span>
|
||||
<span>到帳時間:{{ order.arrival_time or "NA" }}</span>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
{% block integrations %}
|
||||
{% include "_chatwoot.html" %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,11 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}{{ title }}|{{ app_name }}{% endblock %}
|
||||
{% block body_class %}login-body{% endblock %}
|
||||
{% block body %}
|
||||
<main class="login-card">
|
||||
<div class="brand-mark">!</div>
|
||||
<h1>{{ title }}</h1>
|
||||
<div class="alert alert-error">{{ message }}</div>
|
||||
<a class="primary-link" href="{{ url_for('root') }}">返回系統</a>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,38 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}遊客客服|{{ app_name }}{% endblock %}
|
||||
{% block body_class %}guest-page{% endblock %}
|
||||
|
||||
{% block body %}
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<div class="site-title">遊客客服中心</div>
|
||||
<div class="site-subtitle">Guest Customer Service</div>
|
||||
</div>
|
||||
<a class="back-link" href="{{ url_for('login') }}">返回登入頁</a>
|
||||
</header>
|
||||
|
||||
<main class="page narrow">
|
||||
<section class="support-panel guest-support-panel">
|
||||
<div class="support-avatar">🎧</div>
|
||||
<span class="guest-badge">遊客模式</span>
|
||||
<h1>歡迎使用線上客服</h1>
|
||||
<p>您目前以遊客身分進入,可諮詢註冊、登入、活動與平台操作問題。</p>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
遊客模式不會識別會員身分,也不能查詢充值、提款、餘額或其他會員資料。
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="primary-button guest-open-chat"
|
||||
onclick="if (window.openGuestChatNewConversation) { window.openGuestChatNewConversation(); }"
|
||||
>
|
||||
開啟線上客服
|
||||
</button>
|
||||
</section>
|
||||
</main>
|
||||
{% endblock %}
|
||||
|
||||
{% block integrations %}
|
||||
{% include "_chatwoot.html" %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,74 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}遊戲首頁|{{ app_name }}{% endblock %}
|
||||
{% block body %}
|
||||
<header class="topbar">
|
||||
<div>
|
||||
<div class="site-title">YD GAME DEMO</div>
|
||||
<div class="site-subtitle">Python 唯讀 API 測試站點</div>
|
||||
</div>
|
||||
<div class="member-area">
|
||||
<span>用戶:<strong>{{ account.raw_username or username }}</strong></span>
|
||||
<span class="status-badge {{ account.status }}">
|
||||
{{ "已凍結" if account.status == "locked" else "正常" }}
|
||||
</span>
|
||||
<form
|
||||
method="post"
|
||||
action="{{ url_for('logout') }}"
|
||||
onsubmit="return window.resetChatwootBeforeLogout ? window.resetChatwootBeforeLogout(this) : true;"
|
||||
>
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button class="logout-button" type="submit">登出</button>
|
||||
</form>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main class="page">
|
||||
{% if warning %}
|
||||
<div class="alert alert-error">帳戶資料讀取失敗:{{ warning }}</div>
|
||||
{% endif %}
|
||||
|
||||
{% if account.status == "locked" %}
|
||||
<div class="alert alert-warning">
|
||||
<strong>帳戶已凍結</strong>
|
||||
{% if account.locked_reason %}|凍結原因:{{ account.locked_reason }}{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<section class="hero">
|
||||
<div>
|
||||
<span class="eyebrow">PYTHON TEST ENVIRONMENT</span>
|
||||
<h1>歡迎進入遊戲站點模擬首頁</h1>
|
||||
<p>登入會員:{{ account.username_clean or username }}。本頁只用於驗證會員資料、充值、提款與客服 API。</p>
|
||||
</div>
|
||||
<a class="service-entry" href="{{ url_for('customer_service') }}">
|
||||
<span class="service-icon">🎧</span>
|
||||
<span><strong>在線客服</strong><small>查詢充值與提款資料</small></span>
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<section class="account-grid">
|
||||
<div class="metric-card"><span>UID</span><strong>{{ account.uid or "NA" }}</strong></div>
|
||||
<div class="metric-card"><span>VIP 等級</span><strong>{{ account.vip_level or "NA" }}</strong></div>
|
||||
<div class="metric-card"><span>會員餘額</span><strong>{{ account.balance or "0.0000" }}</strong></div>
|
||||
<div class="metric-card"><span>凍結餘額</span><strong>{{ account.frozen_balance or "0.0000" }}</strong></div>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="section-heading"><h2>熱門遊戲</h2><span>畫面模擬,不執行真實投注</span></div>
|
||||
<div class="game-grid">
|
||||
{% for game in games %}
|
||||
<article class="game-card">
|
||||
<div class="game-icon">{{ game.icon }}</div>
|
||||
<div><h3>{{ game.name }}</h3><p>{{ game.category }}</p></div>
|
||||
<button type="button" disabled>模擬遊戲</button>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
<a class="floating-service" href="{{ url_for('customer_service') }}">🎧 客服</a>
|
||||
{% endblock %}
|
||||
|
||||
{% block integrations %}
|
||||
{% include "_chatwoot.html" %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,40 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}登录|{{ app_name }}{% endblock %}
|
||||
{% block body_class %}login-body{% endblock %}
|
||||
{% block body %}
|
||||
<main class="login-card">
|
||||
<div class="brand-mark">YD</div>
|
||||
<h1>游戏站点模拟</h1>
|
||||
<p class="muted">仅使用 members 表内存在的用户名登录,不需要密码。</p>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }}">{{ message }}</div>
|
||||
{% endfor %}
|
||||
{% endwith %}
|
||||
|
||||
<form method="post" autocomplete="off">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<label for="username">用户名</label>
|
||||
<input id="username" name="username" type="text" maxlength="191"
|
||||
value="{{ username }}" placeholder="输入 username 或 username_clean"
|
||||
required autofocus>
|
||||
<button type="submit" class="primary-button">登录测试站点</button>
|
||||
</form>
|
||||
|
||||
<div class="login-divider"><span>或</span></div>
|
||||
|
||||
<a
|
||||
class="guest-service-button"
|
||||
href="{{ url_for('guest_customer_service') }}"
|
||||
>
|
||||
🎧 游客进入客服
|
||||
</a>
|
||||
|
||||
<p class="guest-service-note">
|
||||
游客只能进行一般咨询,不能查询会员充值、提款或账户资料。
|
||||
</p>
|
||||
|
||||
<p class="security-note">【测试限定】此登录方式不具备正式身份验证能力。</p>
|
||||
</main>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,16 @@
|
||||
#!/usr/bin/env python3
|
||||
# -*- coding: utf-8 -*-
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
SECRET = "YYHDjxD7Yk8DM7dzLQu1MAeP"
|
||||
IDENTIFIER = "qiuyue001"
|
||||
|
||||
identifier_hash = hmac.new(
|
||||
SECRET.encode("utf-8"),
|
||||
IDENTIFIER.encode("utf-8"),
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
|
||||
print("identifier:", IDENTIFIER)
|
||||
print("identifier_hash:", identifier_hash)
|
||||
@@ -0,0 +1,4 @@
|
||||
from app import app
|
||||
|
||||
|
||||
application = app
|
||||
Reference in New Issue
Block a user