最初版本

This commit is contained in:
sa19840531
2026-08-15 14:51:51 +08:00
parent 4e01df4dce
commit ea47ea4449
176 changed files with 21009 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
venv/
__pycache__/
*.pyc
*.pyo
.env
.maxkb_cw_sessions.json
.pytest_cache/
*.md
+38
View File
@@ -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
}
}
+17
View File
@@ -0,0 +1,17 @@
FROM python:3.13-slim
WORKDIR /app
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1 \
APP_HOST=0.0.0.0 \
APP_PORT=7788
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
EXPOSE 7788
CMD ["gunicorn", "--bind", "0.0.0.0:7788", "--workers", "2", "--threads", "4", "--timeout", "120", "wsgi:application"]
+628
View File
@@ -0,0 +1,628 @@
from datetime import datetime
from decimal import Decimal, InvalidOperation
from functools import wraps
import hashlib
import hmac
import logging
import secrets
from typing import Any, Callable
from flask import (
Flask,
abort,
flash,
jsonify,
redirect,
render_template,
request,
session,
url_for,
)
from pymysql import MySQLError
from werkzeug.exceptions import HTTPException
from chatwoot_guest import ChatwootGuestError, create_guest_conversation
from chatwoot_proxy import proxy_chatwoot
from config import CONFIG
from maxkb_chatwoot_bridge import handle_chatwoot_webhook
from repository import MappingError, PlatformRepository, decimal_string
import threading
app = Flask(__name__)
app.config.update(
JSON_AS_ASCII=False,
SECRET_KEY=CONFIG.secret_key,
SESSION_COOKIE_NAME="api_interface_session",
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE="Lax",
SESSION_COOKIE_SECURE=CONFIG.session_cookie_secure,
PERMANENT_SESSION_LIFETIME=28800,
MAX_CONTENT_LENGTH=1024 * 1024,
)
logging.basicConfig(
level=logging.DEBUG if CONFIG.debug else logging.INFO,
format="%(asctime)s %(levelname)s %(name)s %(message)s",
)
repo = PlatformRepository()
def payload() -> dict[str, Any]:
data: dict[str, Any] = {}
body = request.get_json(silent=True)
if isinstance(body, dict):
data.update(body)
data.update(request.form.to_dict())
data.update(request.args.to_dict())
return data
def api_auth(view: Callable):
@wraps(view)
def wrapped(*args, **kwargs):
if CONFIG.api_key:
provided = request.headers.get("X-API-Key", "")
if not hmac.compare_digest(provided, CONFIG.api_key):
return jsonify(
{"error": "unauthorized", "message": "API Key 無效"}
), 401
return view(*args, **kwargs)
return wrapped
def login_required(view: Callable):
@wraps(view)
def wrapped(*args, **kwargs):
if not session.get("username"):
return redirect(url_for("login"))
return view(*args, **kwargs)
return wrapped
def csrf_token() -> str:
token = session.get("csrf_token")
if not token:
token = secrets.token_urlsafe(32)
session["csrf_token"] = token
return str(token)
def verify_csrf() -> None:
received = str(request.form.get("csrf_token", ""))
saved = str(session.get("csrf_token", ""))
if not received or not saved or not hmac.compare_digest(received, saved):
abort(403, description="CSRF 验证失败")
def required_text(
data: dict[str, Any],
name: str,
max_length: int = 191,
) -> str:
value = str(data.get(name, "")).strip()
if not value:
raise ValueError(f"{name} 為必填")
if len(value) > max_length:
raise ValueError(f"{name} 長度不可超過 {max_length}")
return value
def is_api_request() -> bool:
return request.path == "/health" or request.path.startswith("/platform/")
def update_member_session(account: dict[str, Any]) -> None:
"""將目前會員資料同步到 Flask Session。"""
session["username"] = account.get("username_clean") or account.get("username") or ""
session["username_clean"] = account.get("username_clean") or ""
session["raw_username"] = account.get("raw_username") or ""
session["uid"] = account.get("uid")
session["account_status"] = account.get("status") or "normal"
session["locked_reason"] = account.get("locked_reason") or ""
session["member_type"] = account.get("member_type") or ""
session["vip_level"] = account.get("vip_level") or ""
session["tier"] = account.get("tier") or ""
def build_chatwoot_user() -> dict[str, Any]:
"""建立傳給 Chatwoot 前端 SDK 的安全會員資料。
identifier 直接使用 username_clean,作為 Chatwoot 抓取會員名的關鍵標識。
顯示名稱與自訂屬性同樣使用 username_clean。
"""
username = str(
session.get("username_clean")
or session.get("username")
or ""
).strip()
uid = session.get("uid")
# Chatwoot 的 identifier 直接使用登入會員名。
# 例如:yongle001,而不是 member-311。
identifier = username if username else ""
identifier_hash = ""
if identifier and CONFIG.chatwoot_identity_secret:
identifier_hash = hmac.new(
CONFIG.chatwoot_identity_secret.encode("utf-8"),
identifier.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return {
"is_authenticated": bool(identifier),
"visitor_type": "member" if identifier else "guest",
"identifier": identifier,
"identifier_hash": identifier_hash,
"name": username,
"username": username,
"uid": uid,
"account_status": str(
session.get("account_status", "")
),
"locked_reason": str(
session.get("locked_reason", "")
),
"member_type": str(
session.get("member_type", "")
),
"vip_level": str(
session.get("vip_level", "")
),
"tier": str(
session.get("tier", "")
),
}
@app.context_processor
def inject_template_values():
use_guest_widget = request.path.startswith("/guest-customer-service")
guest_base = CONFIG.chatwoot_guest_base_url
guest_token = CONFIG.chatwoot_guest_website_token
base_url = (
guest_base
if use_guest_widget and guest_base and guest_token
else CONFIG.chatwoot_base_url
)
website_token = (
guest_token
if use_guest_widget and guest_base and guest_token
else CONFIG.chatwoot_website_token
)
chatwoot_config = {
"enabled": bool(
CONFIG.chatwoot_enabled
and base_url
and website_token
),
"base_url": base_url,
"website_token": website_token,
"debug": CONFIG.chatwoot_debug,
"csrf_token": csrf_token(),
"guest_new_path": "/guest-chat/new",
}
return {
"app_name": CONFIG.app_name,
"csrf_token": csrf_token,
"chatwoot_config": chatwoot_config,
"chatwoot_user": build_chatwoot_user(),
# 只在登出後首次渲染未登入頁面時為 True。
# 使用 pop() 確保該標記只消費一次。
"chatwoot_guest_reset_required": bool(
session.pop(
"chatwoot_guest_reset_required",
False,
)
),
}
@app.get("/")
def root():
if session.get("username"):
return redirect(url_for("home"))
return redirect(url_for("login"))
@app.get("/guest-customer-service")
def guest_customer_service():
"""遊客客服頁:不登入、不查會員資料,只載入匿名 Chatwoot。"""
return render_template(
"guest_customer_service.html",
is_guest=True,
)
@app.route(
"/cw/",
defaults={"subpath": ""},
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
@app.route(
"/cw/<path:subpath>",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
def chatwoot_guest_proxy(subpath: str):
"""把本機 Chatwoot widget 代理進來,才能注入略過「開始對話」的腳本。"""
return proxy_chatwoot(subpath)
@app.route(
"/vite/<path:subpath>",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
def chatwoot_vite_proxy(subpath: str):
"""widget HTML 用絕對路徑 /vite/assets,需一併代理。"""
return proxy_chatwoot(f"vite/{subpath}")
@app.route(
"/api/v1/widget/",
defaults={"subpath": ""},
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
@app.route(
"/api/v1/widget/<path:subpath>",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
def chatwoot_widget_api_proxy(subpath: str):
"""widget 會打同源 /api/v1/widget。"""
suffix = f"api/v1/widget/{subpath}" if subpath else "api/v1/widget"
return proxy_chatwoot(suffix)
@app.route(
"/rails/<path:subpath>",
methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"],
)
def chatwoot_rails_proxy(subpath: str):
"""歡迎圖走 Active Storage,widget 會打同源 /rails/active_storage。"""
return proxy_chatwoot(f"rails/{subpath}")
@app.post("/guest-chat/new")
def guest_chat_new():
"""為遊客預先建立 Chatwoot 對話,前端 setUser 後可略過「開始對話」。"""
received = str(request.headers.get("X-CSRF-Token", ""))
saved = str(session.get("csrf_token", ""))
if not received or not saved or not hmac.compare_digest(received, saved):
return jsonify(
{"error": "csrf_failed", "message": "CSRF 验证失败"}
), 403
try:
created = create_guest_conversation()
except ChatwootGuestError as exc:
app.logger.exception("建立游客对话失败")
return jsonify(
{"error": "chatwoot_guest_failed", "message": str(exc)}
), 502
identifier = created["identifier"]
identifier_hash = ""
if identifier and CONFIG.chatwoot_identity_secret:
identifier_hash = hmac.new(
CONFIG.chatwoot_identity_secret.encode("utf-8"),
identifier.encode("utf-8"),
hashlib.sha256,
).hexdigest()
return jsonify(
{
"identifier": identifier,
"identifier_hash": identifier_hash,
"name": created["name"],
"source_id": created.get("source_id") or "",
"widget_token": created.get("widget_token") or "",
}
)
@app.post("/webhook/bot1/chatwoot")
def chatwoot_maxkb_webhook():
"""本機 Chatwoot Agent Bot → PAI MaxKB 橋接(對齊正式站 :6666 路徑)。"""
body = request.get_json(silent=True)
if not isinstance(body, dict):
return jsonify({"ok": False, "error": "invalid_json"}), 400
# Chatwoot SafeFetch 讀超時約 20s;MaxKB 常更久,先快速 200 再背景回覆。
def _run() -> None:
try:
result = handle_chatwoot_webhook(body)
app.logger.info("Chatwoot→MaxKB 橋接完成: %s", result)
except Exception:
app.logger.exception("Chatwoot→MaxKB 橋接失敗")
threading.Thread(target=_run, daemon=True).start()
return jsonify({"ok": True, "accepted": True})
@app.route("/login", methods=["GET", "POST"])
def login():
if session.get("username"):
return redirect(url_for("home"))
username = ""
if request.method == "POST":
verify_csrf()
username = str(request.form.get("username", "")).strip()
if not username or len(username) > 191:
flash("请输入有效的用户名", "error")
else:
try:
account = repo.get_user_account(username)
except Exception as exc:
app.logger.exception("会员登录查询失败")
flash(f"登录验证失败:{exc}", "error")
else:
if not account["is_exist"]:
flash("该用户名不存在于 members 表", "error")
else:
session.clear()
update_member_session(account)
session.permanent = True
csrf_token()
return redirect(url_for("home"))
return render_template("login.html", username=username)
@app.get("/home")
@login_required
def home():
username = str(session["username"])
warning = ""
account = {
"username": username,
"username_clean": username,
"raw_username": str(session.get("raw_username", username)),
"status": str(session.get("account_status", "normal")),
"locked_reason": str(session.get("locked_reason", "")),
}
try:
refreshed = repo.get_user_account(username)
if refreshed["is_exist"]:
account = refreshed
update_member_session(refreshed)
except Exception as exc:
app.logger.exception("帳戶狀態刷新失敗")
warning = str(exc)
games = [
{"icon": "🎰", "name": "幸運拉霸", "category": "電子遊戲"},
{"icon": "🃏", "name": "經典百家樂", "category": "真人遊戲"},
{"icon": "⚽", "name": "體育競猜", "category": "體育遊戲"},
{"icon": "🎲", "name": "骰寶大廳", "category": "桌面遊戲"},
{"icon": "🐉", "name": "龍虎鬥", "category": "真人遊戲"},
{"icon": "🚀", "name": "飛行挑戰", "category": "休閒遊戲"},
]
return render_template(
"home.html",
username=username,
account=account,
warning=warning,
games=games,
)
@app.get("/customer-service")
@login_required
def customer_service():
username = str(session["username"])
withdrawals: list[dict[str, Any]] = []
deposits: list[dict[str, Any]] = []
account: dict[str, Any] = {}
error = ""
try:
account = repo.get_user_account(username)
if account.get("is_exist"):
update_member_session(account)
withdrawals = repo.get_withdraw_orders(username, unfinished_only=True)
deposits = repo.get_deposit_orders(username)[:10]
except Exception as exc:
app.logger.exception("客服頁資料查詢失敗")
error = str(exc)
return render_template(
"customer_service.html",
username=username,
account=account,
withdrawals=withdrawals,
deposits=deposits,
error=error,
)
@app.post("/logout")
@login_required
def logout():
verify_csrf()
# 清除會員 Session 後,保留一個一次性標記。
# 登出後首次載入未登入頁面時,前端會再次 reset Chatwoot,
# 避免上一位會員身份殘留在匿名訪客頁面。
session.clear()
session["chatwoot_guest_reset_required"] = True
return redirect(url_for("login"))
@app.get("/health")
@app.get("/platform/health")
def health():
return jsonify(
{
"service": CONFIG.app_name,
"status": "ok",
"read_only_mode": CONFIG.read_only_mode,
}
)
@app.get("/platform/get-user-account")
@api_auth
def get_user_account():
username = required_text(payload(), "username")
return jsonify(repo.get_user_account(username))
@app.get("/platform/withdraw-orders")
@api_auth
def withdraw_orders():
username = required_text(payload(), "username")
return jsonify(repo.get_withdraw_orders(username, unfinished_only=False))
@app.get("/platform/withdraw-orders-unfinish")
@api_auth
def withdraw_orders_unfinish():
username = required_text(payload(), "username")
return jsonify(repo.get_withdraw_orders(username, unfinished_only=True))
@app.get("/platform/deposit-orders")
@app.get("/platform/deposit-not-received")
@api_auth
def deposit_orders():
data = payload()
username = required_text(data, "username")
amount: Decimal | None = None
raw_amount = str(data.get("amount", "")).strip()
if raw_amount:
try:
amount = Decimal(raw_amount)
except InvalidOperation as exc:
raise ValueError("amount 格式不正確") from exc
created_at: datetime | None = None
raw_created_at = str(data.get("created_at", "")).strip()
if raw_created_at:
try:
created_at = datetime.fromisoformat(
raw_created_at.replace("Z", "+00:00")
)
if created_at.tzinfo is not None:
created_at = created_at.astimezone().replace(tzinfo=None)
except ValueError as exc:
raise ValueError("created_at 必須為 ISO-8601 日期時間") from exc
return jsonify(repo.get_deposit_orders(username, amount, created_at))
@app.route("/platform/apply-activity", methods=["POST", "PUT"])
@api_auth
def apply_activity():
data = payload()
username = required_text(data, "username")
activity_id = required_text(data, "activity_id", max_length=64)
return jsonify(repo.apply_activity(username, activity_id))
@app.route("/platform/fix-balance", methods=["POST", "PUT"])
@api_auth
def fix_balance():
username = required_text(payload(), "username")
account = repo.get_user_account(username)
if not account["is_exist"]:
return jsonify({"error": "not_found", "message": "找不到用戶"}), 404
balance = repo.get_balance(username)
repo.log_balance_fix(username, balance)
return jsonify(
{
"username": account["username_clean"],
"balance": decimal_string(balance),
"simulated": True,
"message": "唯讀測試模式:僅回傳 members.balance,未修改資料庫",
}
)
@app.errorhandler(403)
def handle_forbidden_error(exc: HTTPException):
message = getattr(exc, "description", "拒絕存取")
if is_api_request():
return jsonify({"error": "forbidden", "message": message}), 403
return render_template(
"error.html", title="請求驗證失敗", message=message
), 403
@app.errorhandler(ValueError)
def handle_value_error(exc: ValueError):
if is_api_request():
return jsonify({"error": "bad_request", "message": str(exc)}), 400
return render_template(
"error.html", title="請求錯誤", message=str(exc)
), 400
@app.errorhandler(MappingError)
def handle_mapping_error(exc: MappingError):
if is_api_request():
return jsonify({"error": "mapping_error", "message": str(exc)}), 500
return render_template(
"error.html", title="資料映射錯誤", message=str(exc)
), 500
@app.errorhandler(MySQLError)
def handle_mysql_error(exc: MySQLError):
app.logger.exception("Database error")
message = str(exc) if CONFIG.debug else "資料庫查詢失敗,請檢查連線與欄位設定"
if is_api_request():
return jsonify({"error": "database_error", "message": message}), 500
return render_template(
"error.html", title="資料庫錯誤", message=message
), 500
@app.errorhandler(HTTPException)
def handle_http_exception(exc: HTTPException):
status_code = int(exc.code or 500)
message = str(exc.description or exc.name)
if is_api_request():
return jsonify(
{
"error": exc.name.lower().replace(" ", "_"),
"message": message,
}
), status_code
return render_template(
"error.html", title=exc.name, message=message
), status_code
@app.errorhandler(Exception)
def handle_unexpected_error(exc: Exception):
app.logger.exception("Unexpected error")
if is_api_request():
return jsonify(
{"error": "internal_error", "message": "系統內部錯誤"}
), 500
return render_template(
"error.html", title="系統錯誤", message="系統內部錯誤"
), 500
if __name__ == "__main__":
app.logger.info(
"啟動 %s host=%s port=%s read_only=%s",
CONFIG.app_name,
CONFIG.host,
CONFIG.port,
CONFIG.read_only_mode,
)
app.run(host=CONFIG.host, port=CONFIG.port, debug=CONFIG.debug)
+256
View File
@@ -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("=")
+650
View File
@@ -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)
+113
View File
@@ -0,0 +1,113 @@
import os
from dataclasses import dataclass
from pathlib import Path
from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".env", override=True)
def env_bool(name: str, default: bool = False) -> bool:
value = os.getenv(name)
if value is None:
return default
return value.strip().lower() in {"1", "true", "yes", "on"}
@dataclass(frozen=True)
class Config:
app_name: str = os.getenv("APP_NAME", "API接口系统")
host: str = os.getenv("APP_HOST", os.getenv("API_HOST", "0.0.0.0"))
port: int = int(os.getenv("APP_PORT", os.getenv("API_PORT", "7788")))
debug: bool = env_bool("APP_DEBUG", env_bool("API_DEBUG", False))
secret_key: str = os.getenv("SECRET_KEY", "replace-this-secret-key")
api_key: str = os.getenv("API_KEY", "change-me")
session_cookie_secure: bool = env_bool("SESSION_COOKIE_SECURE", False)
db_host: str = os.getenv("DB_HOST", "127.0.0.1")
db_port: int = int(os.getenv("DB_PORT", "3306"))
db_user: str = os.getenv("DB_USER", "api_reader")
db_password: str = os.getenv("DB_PASSWORD", "")
db_name: str = os.getenv("DB_NAME", "member_system")
db_charset: str = os.getenv("DB_CHARSET", "utf8mb4")
db_connect_timeout: int = int(os.getenv("DB_CONNECT_TIMEOUT", "5"))
members_table: str = os.getenv("MEMBERS_TABLE", "members")
withdraw_table: str = os.getenv("WITHDRAW_TABLE", "withdraw_records")
withdraw_request_table: str = os.getenv(
"WITHDRAW_REQUEST_TABLE", "withdraw_request_records"
)
deposit_table: str = os.getenv("DEPOSIT_TABLE", "deposit_records")
read_only_mode: bool = env_bool("READ_ONLY_MODE", True)
auto_init_sim_tables: bool = env_bool("AUTO_INIT_SIM_TABLES", False)
default_activity_id: str = os.getenv("DEFAULT_ACTIVITY_ID", "ad-x1234")
deposit_search_hours: int = int(os.getenv("DEPOSIT_SEARCH_HOURS", "24"))
max_orders: int = int(os.getenv("MAX_ORDERS", "50"))
# Chatwoot 網站客服設定
chatwoot_enabled: bool = env_bool("CHATWOOT_ENABLED", True)
chatwoot_base_url: str = os.getenv(
"CHATWOOT_BASE_URL",
"http://pai.pubuli.com",
).rstrip("/")
chatwoot_website_token: str = os.getenv(
"CHATWOOT_WEBSITE_TOKEN",
"Vivum4g1WWxH1AmxoTivAcX5",
)
# 必須使用 Chatwoot Website Inbox 的 Identity Validation Token。
# 此值只保留在後端,絕不可輸出到 HTML 或 JavaScript。
chatwoot_identity_secret: str = os.getenv(
"CHATWOOT_IDENTITY_SECRET",
"",
)
chatwoot_debug: bool = env_bool("CHATWOOT_DEBUG", False)
chatwoot_account_id: int = int(os.getenv("CHATWOOT_ACCOUNT_ID", "1"))
chatwoot_inbox_id: int = int(os.getenv("CHATWOOT_INBOX_ID", "1"))
chatwoot_api_email: str = os.getenv("CHATWOOT_API_EMAIL", "")
chatwoot_api_password: str = os.getenv("CHATWOOT_API_PASSWORD", "")
chatwoot_greeting: str = os.getenv(
"CHATWOOT_GREETING",
"您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?",
)
chatwoot_greeting_image: str = os.getenv(
"CHATWOOT_GREETING_IMAGE",
"static/guest_promo.png",
)
chatwoot_guest_base_url: str = os.getenv(
"CHATWOOT_GUEST_BASE_URL",
"",
).rstrip("/")
chatwoot_guest_website_token: str = os.getenv(
"CHATWOOT_GUEST_WEBSITE_TOKEN",
"",
)
chatwoot_guest_proxy_target: str = os.getenv(
"CHATWOOT_GUEST_PROXY_TARGET",
"http://127.0.0.1:3000",
).rstrip("/")
chatwoot_guest_api_base: str = os.getenv(
"CHATWOOT_GUEST_API_BASE",
"",
).rstrip("/")
chatwoot_guest_api_email: str = os.getenv("CHATWOOT_GUEST_API_EMAIL", "")
chatwoot_guest_api_password: str = os.getenv("CHATWOOT_GUEST_API_PASSWORD", "")
chatwoot_guest_inbox_id: int = int(os.getenv("CHATWOOT_GUEST_INBOX_ID", "1"))
chatwoot_widget_jwt_secret: str = os.getenv("CHATWOOT_WIDGET_JWT_SECRET", "")
chatwoot_agent_bot_id: int = int(os.getenv("CHATWOOT_AGENT_BOT_ID", "0") or "0")
chatwoot_agent_bot_token: str = os.getenv("CHATWOOT_AGENT_BOT_TOKEN", "")
# 本機 Chatwoot → PAI MaxKB 橋接
maxkb_bridge_enabled: bool = env_bool("MAXKB_BRIDGE_ENABLED", True)
maxkb_base_url: str = os.getenv(
"MAXKB_BASE_URL",
"http://pai.pubuli.com:8080",
).rstrip("/")
maxkb_access_token: str = os.getenv("MAXKB_ACCESS_TOKEN", "")
maxkb_timeout_sec: float = float(os.getenv("MAXKB_TIMEOUT_SEC", "120"))
CONFIG = Config()
+28
View File
@@ -0,0 +1,28 @@
from contextlib import contextmanager
from typing import Iterator
import pymysql
from pymysql.connections import Connection
from config import CONFIG
@contextmanager
def db_connection() -> Iterator[Connection]:
connection = pymysql.connect(
host=CONFIG.db_host,
port=CONFIG.db_port,
user=CONFIG.db_user,
password=CONFIG.db_password,
database=CONFIG.db_name,
charset=CONFIG.db_charset,
cursorclass=pymysql.cursors.DictCursor,
autocommit=True,
connect_timeout=CONFIG.db_connect_timeout,
read_timeout=20,
write_timeout=20,
)
try:
yield connection
finally:
connection.close()
@@ -0,0 +1,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,
}
+178
View File
@@ -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())
+531
View File
@@ -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
+4
View File
@@ -0,0 +1,4 @@
Flask>=3.1,<4
PyMySQL>=1.1,<2
python-dotenv>=1.0,<2
gunicorn>=23,<24
+16
View File
@@ -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()
+746
View File
@@ -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 %}
+13
View File
@@ -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 %}
+11
View File
@@ -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 %}
+74
View File
@@ -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 %}
+40
View File
@@ -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)
+4
View File
@@ -0,0 +1,4 @@
from app import app
application = app