diff --git a/from_server_guest-chat/bak_test7777/flask/app.py b/from_server_guest-chat/bak_test7777/flask/app.py new file mode 100644 index 0000000..d8ea7a1 --- /dev/null +++ b/from_server_guest-chat/bak_test7777/flask/app.py @@ -0,0 +1,627 @@ +from datetime import datetime +from decimal import Decimal, InvalidOperation +from functools import wraps +import hashlib +import hmac +import logging +import secrets +from typing import Any, Callable + +from flask import ( + Flask, + abort, + flash, + jsonify, + redirect, + render_template, + request, + session, + url_for, +) +from pymysql import MySQLError +from werkzeug.exceptions import HTTPException + +from chatwoot_guest import ChatwootGuestError, create_guest_conversation +from chatwoot_proxy import proxy_chatwoot +from config import CONFIG +from maxkb_chatwoot_bridge import handle_chatwoot_webhook +from repository import MappingError, PlatformRepository, decimal_string +import threading + + +app = Flask(__name__) +app.config.update( + JSON_AS_ASCII=False, + SECRET_KEY=CONFIG.secret_key, + SESSION_COOKIE_NAME="api_interface_session", + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + SESSION_COOKIE_SECURE=CONFIG.session_cookie_secure, + PERMANENT_SESSION_LIFETIME=28800, + MAX_CONTENT_LENGTH=1024 * 1024, +) + +logging.basicConfig( + level=logging.DEBUG if CONFIG.debug else logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", +) + +repo = PlatformRepository() + + +def payload() -> dict[str, Any]: + data: dict[str, Any] = {} + body = request.get_json(silent=True) + if isinstance(body, dict): + data.update(body) + data.update(request.form.to_dict()) + data.update(request.args.to_dict()) + return data + + +def api_auth(view: Callable): + @wraps(view) + def wrapped(*args, **kwargs): + if CONFIG.api_key: + provided = request.headers.get("X-API-Key", "") + if not hmac.compare_digest(provided, CONFIG.api_key): + return jsonify( + {"error": "unauthorized", "message": "API Key 無效"} + ), 401 + return view(*args, **kwargs) + + return wrapped + + +def login_required(view: Callable): + @wraps(view) + def wrapped(*args, **kwargs): + if not session.get("username"): + return redirect(url_for("login")) + return view(*args, **kwargs) + + return wrapped + + +def csrf_token() -> str: + token = session.get("csrf_token") + if not token: + token = secrets.token_urlsafe(32) + session["csrf_token"] = token + return str(token) + + +def verify_csrf() -> None: + received = str(request.form.get("csrf_token", "")) + saved = str(session.get("csrf_token", "")) + if not received or not saved or not hmac.compare_digest(received, saved): + abort(403, description="CSRF 验证失败") + + +def required_text( + data: dict[str, Any], + name: str, + max_length: int = 191, +) -> str: + value = str(data.get(name, "")).strip() + if not value: + raise ValueError(f"{name} 為必填") + if len(value) > max_length: + raise ValueError(f"{name} 長度不可超過 {max_length}") + return value + + +def is_api_request() -> bool: + return request.path == "/health" or request.path.startswith("/platform/") + + +def update_member_session(account: dict[str, Any]) -> None: + """將目前會員資料同步到 Flask Session。""" + session["username"] = account.get("username_clean") or account.get("username") or "" + session["username_clean"] = account.get("username_clean") or "" + session["raw_username"] = account.get("raw_username") or "" + session["uid"] = account.get("uid") + session["account_status"] = account.get("status") or "normal" + session["locked_reason"] = account.get("locked_reason") or "" + session["member_type"] = account.get("member_type") or "" + session["vip_level"] = account.get("vip_level") or "" + session["tier"] = account.get("tier") or "" + + +def build_chatwoot_user() -> dict[str, Any]: + """建立傳給 Chatwoot 前端 SDK 的安全會員資料。 + + identifier 直接使用 username_clean,作為 Chatwoot 抓取會員名的關鍵標識。 + 顯示名稱與自訂屬性同樣使用 username_clean。 + """ + username = str( + session.get("username_clean") + or session.get("username") + or "" + ).strip() + uid = session.get("uid") + + # Chatwoot 的 identifier 直接使用登入會員名。 + # 例如:yongle001,而不是 member-311。 + identifier = username if username else "" + + identifier_hash = "" + if identifier and CONFIG.chatwoot_identity_secret: + identifier_hash = hmac.new( + CONFIG.chatwoot_identity_secret.encode("utf-8"), + identifier.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + return { + "is_authenticated": bool(identifier), + "visitor_type": "member" if identifier else "guest", + "identifier": identifier, + "identifier_hash": identifier_hash, + "name": username, + "username": username, + "uid": uid, + "account_status": str( + session.get("account_status", "") + ), + "locked_reason": str( + session.get("locked_reason", "") + ), + "member_type": str( + session.get("member_type", "") + ), + "vip_level": str( + session.get("vip_level", "") + ), + "tier": str( + session.get("tier", "") + ), + } + + +@app.context_processor +def inject_template_values(): + use_guest_widget = request.path.startswith("/guest-customer-service") + guest_token = CONFIG.chatwoot_guest_website_token + if use_guest_widget and guest_token: + # 依访客实际访问的 host(IP 或 test.pubuli.com)加载 /cw,避免写死域名 + base_url = f"{request.scheme}://{request.host}/cw" + else: + base_url = CONFIG.chatwoot_base_url + website_token = ( + guest_token + if use_guest_widget and guest_token + else CONFIG.chatwoot_website_token + ) + chatwoot_config = { + "enabled": bool( + CONFIG.chatwoot_enabled + and base_url + and website_token + ), + "base_url": base_url, + "website_token": website_token, + "debug": CONFIG.chatwoot_debug, + "csrf_token": csrf_token(), + "guest_new_path": "/guest-chat/new", + } + + return { + "app_name": CONFIG.app_name, + "csrf_token": csrf_token, + "chatwoot_config": chatwoot_config, + "chatwoot_user": build_chatwoot_user(), + + # 只在登出後首次渲染未登入頁面時為 True。 + # 使用 pop() 確保該標記只消費一次。 + "chatwoot_guest_reset_required": bool( + session.pop( + "chatwoot_guest_reset_required", + False, + ) + ), + } + + +@app.get("/") +def root(): + if session.get("username"): + return redirect(url_for("home")) + return redirect(url_for("login")) + + +@app.get("/guest-customer-service") +def guest_customer_service(): + """遊客客服頁:不登入、不查會員資料,只載入匿名 Chatwoot。""" + return render_template( + "guest_customer_service.html", + is_guest=True, + ) + + +@app.route( + "/cw/", + defaults={"subpath": ""}, + methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], +) +@app.route( + "/cw/", + methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], +) +def chatwoot_guest_proxy(subpath: str): + """把本機 Chatwoot widget 代理進來,才能注入略過「開始對話」的腳本。""" + return proxy_chatwoot(subpath) + + +@app.route( + "/vite/", + 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/", + 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/", + 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) diff --git a/from_server_guest-chat/bak_test7777/flask/chatwoot_guest.py b/from_server_guest-chat/bak_test7777/flask/chatwoot_guest.py new file mode 100644 index 0000000..709599a --- /dev/null +++ b/from_server_guest-chat/bak_test7777/flask/chatwoot_guest.py @@ -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("=") diff --git a/from_server_guest-chat/bak_test7777/flask/chatwoot_proxy.py b/from_server_guest-chat/bak_test7777/flask/chatwoot_proxy.py new file mode 100644 index 0000000..f02d538 --- /dev/null +++ b/from_server_guest-chat/bak_test7777/flask/chatwoot_proxy.py @@ -0,0 +1,1138 @@ +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 = """ + + + +""" + + +def _cable_ws_origin() -> str: + from urllib.parse import urlparse + + from flask import has_request_context, request + + # 浏览器 WebSocket 必须走对外 host + /cw,不能注入容器内 127.0.0.1:3000 + if has_request_context() and getattr(request, "host", None): + scheme = "wss" if request.scheme == "https" else "ws" + return f"{scheme}://{request.host}/cw" + + public_base = ( + CONFIG.chatwoot_guest_base_url + or CONFIG.chatwoot_base_url + or "http://127.0.0.1:7788/cw" + ) + parsed = urlparse(public_base) + scheme = "wss" if parsed.scheme == "https" else "ws" + host = parsed.netloc or "127.0.0.1:7788" + path = (parsed.path or "/cw").rstrip("/") or "/cw" + return f"{scheme}://{host}{path}" + + +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 "" in html: + html = html.replace("", inject + "", 1) + elif "" in html: + html = html.replace("", inject + "", 1) + else: + html += inject + payload = html.encode("utf-8") + # 避免瀏覽器快取舊版 widget HTML(導致注入腳本不更新) + response_headers = [ + (key, value) + for key, value in response_headers + if key.lower() + not in { + "content-length", + "content-encoding", + "etag", + "last-modified", + "cache-control", + "pragma", + "expires", + } + ] + response_headers.extend( + [ + ("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0"), + ("Pragma", "no-cache"), + ("Expires", "0"), + ] + ) + + return Response(payload, status=status, headers=response_headers) diff --git a/from_server_guest-chat/bak_test7777/flask/config.py b/from_server_guest-chat/bak_test7777/flask/config.py new file mode 100644 index 0000000..b79b8b3 --- /dev/null +++ b/from_server_guest-chat/bak_test7777/flask/config.py @@ -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() diff --git a/from_server_guest-chat/bak_test7777/flask/maxkb_chatwoot_bridge.py b/from_server_guest-chat/bak_test7777/flask/maxkb_chatwoot_bridge.py new file mode 100644 index 0000000..967ae9c --- /dev/null +++ b/from_server_guest-chat/bak_test7777/flask/maxkb_chatwoot_bridge.py @@ -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"\s*(.*?)\s*", + 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 的 標籤拆成純文字 + 按鈕選項。""" + 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 ( + "您好,取消提款需由专员协助核实处理,请稍候为您转接真人客服。" + "转专人服务" + ) + 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, + } diff --git a/from_server_guest-chat/bak_test7777/flask/static/style.css b/from_server_guest-chat/bak_test7777/flask/static/style.css new file mode 100644 index 0000000..42d5015 --- /dev/null +++ b/from_server_guest-chat/bak_test7777/flask/static/style.css @@ -0,0 +1,172 @@ +:root{--bg:#090d1a;--panel:#11182b;--panel2:#19233c;--text:#f5f7ff;--muted:#9aa7c5;--accent:#ffcc4d;--accent2:#7b61ff;--danger:#ff6577;--success:#50d890;--border:rgba(255,255,255,.09)} +*{box-sizing:border-box}body{margin:0;min-height:100vh;font-family:Inter,"Noto Sans TC","Microsoft JhengHei",sans-serif;color:var(--text);background:radial-gradient(circle at top right,rgba(123,97,255,.20),transparent 34%),radial-gradient(circle at bottom left,rgba(255,204,77,.10),transparent 30%),var(--bg)}a{color:inherit}.login-body{display:grid;place-items:center;padding:24px}.login-card{width:min(440px,100%);padding:36px;background:rgba(17,24,43,.94);border:1px solid var(--border);border-radius:24px;box-shadow:0 24px 80px rgba(0,0,0,.38)}.brand-mark{display:grid;place-items:center;width:58px;height:58px;border-radius:18px;background:linear-gradient(135deg,var(--accent),#ff8a4d);color:#1b1520;font-weight:900;letter-spacing:.08em}h1,h2,h3,p{margin-top:0}.muted,.site-subtitle,.section-heading span{color:var(--muted)}label{display:block;margin:24px 0 8px;font-weight:700}input{width:100%;padding:14px 16px;color:var(--text);background:#0c1222;border:1px solid var(--border);border-radius:12px;outline:none}input:focus{border-color:var(--accent2)}.primary-button,.game-card button{width:100%;margin-top:16px;padding:14px 18px;border:0;border-radius:12px;font-weight:800}.primary-button,.primary-link{color:#17120a;background:var(--accent);cursor:pointer}.primary-link{display:inline-block;padding:12px 16px;border-radius:12px;text-decoration:none;font-weight:800}.security-note{margin:18px 0 0;color:var(--muted);font-size:13px}.topbar{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:18px clamp(18px,4vw,56px);background:rgba(9,13,26,.88);border-bottom:1px solid var(--border);backdrop-filter:blur(16px);position:sticky;top:0;z-index:10}.site-title{font-weight:900;letter-spacing:.08em}.site-subtitle{margin-top:3px;font-size:13px}.member-area{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:flex-end}.member-area form{margin:0}.logout-button,.back-link{padding:9px 13px;border:1px solid var(--border);border-radius:10px;color:var(--text);background:transparent;text-decoration:none;cursor:pointer}.status-badge{padding:5px 9px;border-radius:999px;font-size:12px;font-weight:800}.status-badge.normal{color:var(--success);background:rgba(80,216,144,.12)}.status-badge.locked{color:var(--danger);background:rgba(255,101,119,.12)}.page{width:min(1180px,calc(100% - 32px));margin:0 auto;padding:40px 0 72px}.page.narrow{width:min(820px,calc(100% - 32px))}.hero{display:grid;grid-template-columns:1fr auto;align-items:center;gap:24px;min-height:280px;padding:clamp(28px,5vw,60px);margin-bottom:24px;border:1px solid var(--border);border-radius:28px;background:linear-gradient(135deg,rgba(123,97,255,.26),rgba(17,24,43,.94)),var(--panel)}.hero h1{max-width:720px;font-size:clamp(30px,5vw,56px);line-height:1.08}.hero p{max-width:720px;color:var(--muted);font-size:17px}.eyebrow{color:var(--accent);font-size:12px;font-weight:900;letter-spacing:.16em}.service-entry{display:flex;gap:14px;align-items:center;min-width:230px;padding:18px;border:1px solid rgba(255,204,77,.35);border-radius:18px;background:rgba(255,204,77,.10);text-decoration:none}.service-entry small{display:block;margin-top:4px;color:var(--muted)}.service-icon{font-size:34px}.account-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:34px}.metric-card{padding:18px;border:1px solid var(--border);border-radius:16px;background:var(--panel)}.metric-card span{display:block;color:var(--muted);font-size:13px;margin-bottom:8px}.metric-card strong{font-size:20px}.section-heading{display:flex;align-items:end;justify-content:space-between;gap:18px;margin:0 4px 16px}.game-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.game-card{display:grid;grid-template-columns:auto 1fr;gap:16px;padding:22px;border:1px solid var(--border);border-radius:20px;background:var(--panel)}.game-card h3{margin-bottom:6px}.game-card p{margin-bottom:0;color:var(--muted)}.game-icon{font-size:38px}.game-card button{grid-column:1/-1;color:var(--muted);background:var(--panel2);cursor:not-allowed}.floating-service{position:fixed;right:22px;bottom:22px;padding:14px 18px;border-radius:999px;color:#17120a;background:var(--accent);font-weight:900;text-decoration:none;box-shadow:0 16px 36px rgba(0,0,0,.35)}.alert{padding:14px 16px;margin-bottom:18px;border-radius:12px}.alert-error{color:#ffdfe3;background:rgba(255,101,119,.14);border:1px solid rgba(255,101,119,.28)}.alert-warning{color:#fff2c5;background:rgba(255,204,77,.12);border:1px solid rgba(255,204,77,.28)}.support-panel{padding:34px;border:1px solid var(--border);border-radius:24px;background:var(--panel)}.support-avatar{font-size:48px}.empty-state{padding:24px;color:var(--muted);background:var(--panel2);border-radius:14px}.order-list{display:grid;gap:12px}.order-card{display:grid;gap:7px;padding:16px;border:1px solid var(--border);border-radius:14px;background:var(--panel2)}.order-card span{color:var(--muted)}.section-gap{margin-top:30px}@media(max-width:800px){.hero{grid-template-columns:1fr}.game-grid{grid-template-columns:1fr 1fr}.account-grid{grid-template-columns:1fr 1fr}.topbar{align-items:flex-start}}@media(max-width:560px){.game-grid,.account-grid{grid-template-columns:1fr}.topbar{flex-direction:column}.member-area{justify-content:flex-start}.login-card{padding:26px}} + + +.login-divider { + display: flex; + align-items: center; + gap: 12px; + margin: 20px 0; + color: var(--muted); +} +.login-divider::before, +.login-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border); +} +.guest-service-button { + display: block; + padding: 14px 18px; + border: 1px solid rgba(255, 204, 77, .35); + border-radius: 12px; + color: var(--accent); + background: rgba(255, 204, 77, .08); + text-align: center; + text-decoration: none; + font-weight: 800; +} +.guest-service-note { + margin-top: 12px; + color: var(--muted); + font-size: 13px; + line-height: 1.6; +} +.guest-support-panel { + text-align: center; +} +.guest-badge { + display: inline-block; + margin: 6px 0 18px; + padding: 6px 10px; + border-radius: 999px; + color: var(--accent); + background: rgba(255, 204, 77, .10); + font-size: 12px; + font-weight: 800; +} +.guest-open-chat { + max-width: 320px; + margin-left: auto; + margin-right: auto; +} + +.guest-page { + color: #111; + background: #fff; +} +.guest-page .woot-widget-holder, +.guest-page .woot-widget-holder.woot-elements--right { + width: 400px !important; + height: min(82vh, 760px) !important; + max-height: 82vh !important; +} +.guest-page .woot-widget-holder iframe, +.guest-page .woot-widget-holder .woot-widget-box { + width: 100% !important; + height: 100% !important; + min-height: min(82vh, 760px) !important; +} +.guest-page .woot-widget-bubble { + background: #e31c23 !important; +} +.guest-page .woot-widget-bubble:hover { + background: #c4161c !important; +} +.guest-page .topbar { + color: #111; + background: #fff; + border-bottom: 1px solid #e6e6e6; +} +.guest-page .site-subtitle { + color: #555; +} +.guest-page .back-link { + color: #111; + border-color: #d8d8d8; + background: #fff; +} +.guest-page .support-panel { + color: #111; + background: #fff; + border: 1px solid #e6e6e6; +} +.guest-page .support-panel p { + color: #333; +} +.guest-page .guest-badge { + color: #111; + background: #f3f3f3; +} +.guest-page .alert-warning { + color: #111; + background: #f7f7f7; + border: 1px solid #e0e0e0; +} +.guest-page .primary-button { + color: #111; + background: #ffcc4d; +} + +/* 登录页:白底黑字 */ +.login-body { + color: #111; + background: #fff; +} +.login-body .login-card { + color: #111; + background: #fff; + border: 1px solid #e6e6e6; + box-shadow: 0 12px 36px rgba(0, 0, 0, .08); +} +.login-body .brand-mark { + color: #111; + background: #ffcc4d; +} +.login-body h1 { + color: #111; +} +.login-body .muted, +.login-body .guest-service-note, +.login-body .security-note, +.login-body .login-divider { + color: #555; +} +.login-body .login-divider::before, +.login-body .login-divider::after { + background: #e6e6e6; +} +.login-body label { + color: #111; +} +.login-body input { + color: #111; + background: #fff; + border: 1px solid #d8d8d8; +} +.login-body input:focus { + border-color: #ffcc4d; +} +.login-body .primary-button { + color: #111; + background: #ffcc4d; +} +.login-body .guest-service-button { + color: #111; + border-color: #d8d8d8; + background: #f7f7f7; +} +.login-body .guest-service-button:hover { + background: #f0f0f0; +} +.login-body .alert-error { + color: #111; + background: #fff1f2; + border: 1px solid #ffc9d0; +} +.login-body .alert-warning { + color: #111; + background: #f7f7f7; + border: 1px solid #e0e0e0; +} diff --git a/from_server_guest-chat/bak_test7777/flask/templates/_chatwoot.html b/from_server_guest-chat/bak_test7777/flask/templates/_chatwoot.html new file mode 100644 index 0000000..2668bcb --- /dev/null +++ b/from_server_guest-chat/bak_test7777/flask/templates/_chatwoot.html @@ -0,0 +1,453 @@ +{% if chatwoot_config.enabled %} + +{% endif %} diff --git a/from_server_guest-chat/bak_test7777/flask/templates/base.html b/from_server_guest-chat/bak_test7777/flask/templates/base.html new file mode 100644 index 0000000..54c6b66 --- /dev/null +++ b/from_server_guest-chat/bak_test7777/flask/templates/base.html @@ -0,0 +1,13 @@ + + + + + + {% block title %}{{ app_name }}{% endblock %} + + + +{% block body %}{% endblock %} +{% block integrations %}{% endblock %} + + diff --git a/from_server_guest-chat/bak_test7777/flask/templates/guest_customer_service.html b/from_server_guest-chat/bak_test7777/flask/templates/guest_customer_service.html new file mode 100644 index 0000000..0ed82c7 --- /dev/null +++ b/from_server_guest-chat/bak_test7777/flask/templates/guest_customer_service.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}游客客服|{{ app_name }}{% endblock %} +{% block body_class %}guest-page{% endblock %} + +{% block body %} +
+
+
游客客服中心
+
Guest Customer Service
+
+ 返回登录页 +
+ +
+
+
🎧
+ 游客模式 +

欢迎使用在线客服

+

您目前以游客身份进入,可咨询注册、登录、活动与平台操作问题。

+ +
+ 游客模式不会识别会员身份,也不能查询充值、提款、余额或其他会员资料。 +
+ + +
+
+{% endblock %} + +{% block integrations %} +{% include "_chatwoot.html" %} +{% endblock %} diff --git a/from_server_guest-chat/bak_test7777/flask/templates/home.html b/from_server_guest-chat/bak_test7777/flask/templates/home.html new file mode 100644 index 0000000..c27c5f1 --- /dev/null +++ b/from_server_guest-chat/bak_test7777/flask/templates/home.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% block title %}遊戲首頁|{{ app_name }}{% endblock %} +{% block body %} +
+
+
YD GAME DEMO
+
Python 唯讀 API 測試站點
+
+
+ 用戶:{{ account.raw_username or username }} + + {{ "已凍結" if account.status == "locked" else "正常" }} + +
+ + +
+
+
+ +
+ {% if warning %} +
帳戶資料讀取失敗:{{ warning }}
+ {% endif %} + + {% if account.status == "locked" %} +
+ 帳戶已凍結 + {% if account.locked_reason %}|凍結原因:{{ account.locked_reason }}{% endif %} +
+ {% endif %} + +
+
+ PYTHON TEST ENVIRONMENT +

歡迎進入遊戲站點模擬首頁

+

登入會員:{{ account.username_clean or username }}。本頁只用於驗證會員資料、充值、提款與客服 API。

+
+ + 🎧 + 在線客服查詢充值與提款資料 + +
+ + + +
+

熱門遊戲

畫面模擬,不執行真實投注
+
+ {% for game in games %} +
+
{{ game.icon }}
+

{{ game.name }}

{{ game.category }}

+ +
+ {% endfor %} +
+
+
+🎧 客服 +{% endblock %} + +{% block integrations %} +{% include "_chatwoot.html" %} +{% endblock %} diff --git a/from_server_guest-chat/bak_test7777/flask/templates/login.html b/from_server_guest-chat/bak_test7777/flask/templates/login.html new file mode 100644 index 0000000..125912e --- /dev/null +++ b/from_server_guest-chat/bak_test7777/flask/templates/login.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}登录|{{ app_name }}{% endblock %} +{% block body_class %}login-body{% endblock %} +{% block body %} +
+
YD
+

游戏站点模拟

+

仅使用 members 表内存在的用户名登录,不需要密码。

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} + +
+ + + + +
+ + + + + 🎧 游客进入客服 + + +

+ 游客只能进行一般咨询,不能查询会员充值、提款或账户资料。 +

+ +

【测试限定】此登录方式不具备正式身份验证能力。

+
+{% endblock %} diff --git a/from_server_guest-chat/bak_test7777/flask/wsgi.py b/from_server_guest-chat/bak_test7777/flask/wsgi.py new file mode 100644 index 0000000..74dcf8d --- /dev/null +++ b/from_server_guest-chat/bak_test7777/flask/wsgi.py @@ -0,0 +1,4 @@ +from app import app + + +application = app diff --git a/from_server_guest-chat/bak_test7777/guest-chat.env.redacted b/from_server_guest-chat/bak_test7777/guest-chat.env.redacted new file mode 100644 index 0000000..9c11836 --- /dev/null +++ b/from_server_guest-chat/bak_test7777/guest-chat.env.redacted @@ -0,0 +1,30 @@ +CHATWOOT_INBOX_ID=1 +MAXKB_TIMEOUT_SEC=120 +CHATWOOT_ENABLED=true +CHATWOOT_GUEST_BASE_URL=http://20.191.152.26:7777/cw +MAXKB_ACCESS_TOKEN=*** +APP_NAME=API接口系统 +CHATWOOT_API_PASSWORD=*** +CHATWOOT_WEBSITE_TOKEN=*** +MAXKB_BASE_URL=http://pai.pubuli.com:8080 +APP_DEBUG=false +CHATWOOT_BASE_URL=http://pai.pubuli.com:3000 +CHATWOOT_AGENT_BOT_TOKEN=*** +MAXKB_BRIDGE_ENABLED=true +CHATWOOT_GUEST_API_PASSWORD=*** +CHATWOOT_GREETING=您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢? +CHATWOOT_GUEST_INBOX_ID=1 +CHATWOOT_ACCOUNT_ID=1 +CHATWOOT_GUEST_API_EMAIL=admin@localhost.local +CHATWOOT_GUEST_API_BASE=http://127.0.0.1:3000 +APP_HOST=0.0.0.0 +CHATWOOT_GUEST_WEBSITE_TOKEN=*** +CHATWOOT_AGENT_BOT_ID=1 +FRONTEND_URL=http://20.191.152.26:7777 +CHATWOOT_API_EMAIL=yyds@nobody.com +CHATWOOT_GUEST_PROXY_TARGET=http://127.0.0.1:3000 +CHATWOOT_IDENTITY_SECRET=*** +APP_PORT=7788 +CHATWOOT_WIDGET_JWT_SECRET=*** +CHATWOOT_DEBUG=false +BUNDLE_APP_CONFIG=/usr/local/bundle diff --git a/from_server_guest-chat/bak_test7777/stack/bootstrap-chatwoot-test.sh b/from_server_guest-chat/bak_test7777/stack/bootstrap-chatwoot-test.sh new file mode 100644 index 0000000..9304bbe --- /dev/null +++ b/from_server_guest-chat/bak_test7777/stack/bootstrap-chatwoot-test.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -euo pipefail +# test.pubuli.com:7777 空库初始化 +sudo docker exec guest-chat bundle exec rails runner " +email = 'admin@localhost.local' +password = 'LocalCw#168' +greeting = '您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?' +site_url = 'http://test.pubuli.com:7777' + +account = Account.first || Account.create!(name: '测试客服', locale: 'zh_TW') +user = User.find_by(email: email) +unless user + user = User.new(name: '测试管理员', email: email, password: password, password_confirmation: password) + user.save! + AccountUser.create!(account: account, user: user, role: :administrator) +end + +inbox = account.inboxes.first +unless inbox + channel = Channel::WebWidget.create!( + account: account, + website_url: site_url, + widget_color: '#E53935', + welcome_title: '', + welcome_tagline: greeting, + pre_chat_form_enabled: false + ) + inbox = Inbox.create!(account: account, name: '49彩票集团客服', channel: channel, greeting_enabled: true, greeting_message: greeting) +end + +bot = AgentBot.find_by(name: 'AI 客服') || AgentBot.create!(name: 'AI 客服', outgoing_url: 'http://127.0.0.1:7788/webhook/bot1/chatwoot', account: account) +AgentBotInbox.find_or_create_by!(inbox: inbox, agent_bot: bot, account: account) + +puts \"OK account=#{account.id} inbox=#{inbox.id} token=#{inbox.channel.website_token} bot_url=#{bot.outgoing_url}\" +" diff --git a/from_server_guest-chat/bak_test7777/stack/bootstrap-chatwoot.sh b/from_server_guest-chat/bak_test7777/stack/bootstrap-chatwoot.sh new file mode 100644 index 0000000..2fe74a5 --- /dev/null +++ b/from_server_guest-chat/bak_test7777/stack/bootstrap-chatwoot.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -euo pipefail +sudo docker exec guest-chat bundle exec rails runner " +email = 'admin@localhost.local' +password = 'LocalCw#168' +greeting = '您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?' + +account = Account.first || Account.create!(name: '本地客服', locale: 'zh_TW') +user = User.find_by(email: email) +unless user + user = User.new(name: '本地管理员', email: email, password: password, password_confirmation: password) + user.save! + AccountUser.create!(account: account, user: user, role: :administrator) +end + +inbox = account.inboxes.first +unless inbox + channel = Channel::WebWidget.create!( + account: account, + website_url: 'http://www.pubuli.com:7777', + widget_color: '#E53935', + welcome_title: '', + welcome_tagline: greeting, + pre_chat_form_enabled: false + ) + inbox = Inbox.create!(account: account, name: '49彩票集团客服', channel: channel, greeting_enabled: true, greeting_message: greeting) +end + +bot = AgentBot.find_by(name: 'AI 客服') || AgentBot.create!(name: 'AI 客服', outgoing_url: 'http://127.0.0.1:7788/webhook/bot1/chatwoot', account: account) +AgentBotInbox.find_or_create_by!(inbox: inbox, agent_bot: bot, account: account) + +puts \"OK account=#{account.id} inbox=#{inbox.id} token=#{inbox.channel.website_token} bot_url=#{bot.outgoing_url}\" +" diff --git a/from_server_guest-chat/bak_test7777/stack/chatwoot.env b/from_server_guest-chat/bak_test7777/stack/chatwoot.env new file mode 100644 index 0000000..c9d86c4 --- /dev/null +++ b/from_server_guest-chat/bak_test7777/stack/chatwoot.env @@ -0,0 +1,28 @@ +SECRET_KEY_BASE=61db0140c2e890c28f6737913fb22734365590c7b9501590cd43b108532e13dfe368b1792e6a6bee1ad90dda9dbb42fca39c9d4ef76749c24488a63246ee01ca +FRONTEND_URL=http://127.0.0.1:3000 +FORCE_SSL=false +DEFAULT_LOCALE=zh_TW +ENABLE_ACCOUNT_SIGNUP=true +RAILS_ENV=production +NODE_ENV=production +RAILS_LOG_TO_STDOUT=true +RAILS_SERVE_STATIC_FILES=true +INSTALLATION_ENV=docker +BUNDLE_WITHOUT=development:test +EXECJS_RUNTIME=Disabled + +REDIS_URL=redis://redis:6379 +REDIS_PASSWORD=ziqIkS_An0QDXH9FQ70V7KTz + +POSTGRES_HOST=postgres +POSTGRES_USERNAME=postgres +POSTGRES_PASSWORD=xi_E9IhtgSYjC9PoQXZmUnMX +POSTGRES_DATABASE=chatwoot_production + +ACTIVE_STORAGE_SERVICE=local +MAILER_SENDER_EMAIL=Chatwoot +SMTP_ADDRESS= +ENABLE_PUSH_RELAY_SERVER=false + +# 允许 Agent Bot webhook 打 Docker 内网 guest-cs +SAFE_FETCH_ALLOW_PRIVATE_NETWORK=true diff --git a/from_server_guest-chat/bak_test7777/stack/docker-compose.chat.remote.yaml b/from_server_guest-chat/bak_test7777/stack/docker-compose.chat.remote.yaml new file mode 100644 index 0000000..c1cf7d0 --- /dev/null +++ b/from_server_guest-chat/bak_test7777/stack/docker-compose.chat.remote.yaml @@ -0,0 +1,12 @@ +# 远端部署(对外 7777 → 容器 7788) +# www.pubuli.com / 20.191.152.26 + +services: + guest-chat: + ports: !override + - "7777:7788" + env_file: + - ./guest-cs.remote.env + environment: + CHATWOOT_GUEST_BASE_URL: http://www.pubuli.com:7777/cw + FRONTEND_URL: http://www.pubuli.com:7777 diff --git a/from_server_guest-chat/bak_test7777/stack/docker-compose.chat.test7777.yaml b/from_server_guest-chat/bak_test7777/stack/docker-compose.chat.test7777.yaml new file mode 100644 index 0000000..5913a75 --- /dev/null +++ b/from_server_guest-chat/bak_test7777/stack/docker-compose.chat.test7777.yaml @@ -0,0 +1,12 @@ +# test.pubuli.com 部署(对外 7777 → 容器 7788) +# docker compose -f docker-compose.chat.yaml -f docker-compose.chat.test7777.yaml up -d --force-recreate + +services: + guest-chat: + ports: !override + - "7777:7788" + env_file: + - ./guest-cs.test.env + environment: + CHATWOOT_GUEST_BASE_URL: http://20.191.152.26:7777/cw + FRONTEND_URL: http://20.191.152.26:7777 diff --git a/from_server_guest-chat/bak_test7777/stack/docker-compose.chat.yaml b/from_server_guest-chat/bak_test7777/stack/docker-compose.chat.yaml new file mode 100644 index 0000000..c8d71b0 --- /dev/null +++ b/from_server_guest-chat/bak_test7777/stack/docker-compose.chat.yaml @@ -0,0 +1,78 @@ +# 只部署「在线客服聊天框」(production 预编译前端) +# 浏览器入口:http://127.0.0.1:7788/guest-customer-service +# +# docker pull ladioussuapp/guest-chat:latest +# docker compose -f docker-compose.chat.yaml -f docker-compose.reuse-chatwoot-data.yaml up -d + +name: guest-chat + +x-cw-env: &cw-env + NODE_ENV: production + RAILS_ENV: production + INSTALLATION_ENV: docker + RAILS_SERVE_STATIC_FILES: "true" + +services: + guest-chat: + image: ladioussuapp/guest-chat:latest + build: + context: . + dockerfile: Dockerfile.chatwidget + args: + RAILS_ENV: production + NODE_ENV: production + container_name: guest-chat + restart: always + env_file: + - ./chatwoot.env + - ./guest-cs.env + environment: + <<: *cw-env + CHATWOOT_GUEST_PROXY_TARGET: http://127.0.0.1:3000 + CHATWOOT_GUEST_API_BASE: http://127.0.0.1:3000 + CHATWOOT_GUEST_BASE_URL: http://127.0.0.1:7788/cw + APP_DEBUG: "false" + ports: + # 只对外开聊天框;Chatwoot 后台 3000 不映射 + - "127.0.0.1:7788:7788" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - storage_data:/app/storage + depends_on: + - postgres + - redis + networks: + - guest-chat-net + + postgres: + image: pgvector/pgvector:pg16 + restart: always + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + POSTGRES_DB: chatwoot_production + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-xi_E9IhtgSYjC9PoQXZmUnMX} + networks: + - guest-chat-net + + redis: + image: redis:alpine + restart: always + command: ["sh", "-c", "redis-server --requirepass \"$$REDIS_PASSWORD\""] + env_file: + - ./chatwoot.env + volumes: + - redis_data:/data + networks: + - guest-chat-net + +volumes: + storage_data: + postgres_data: + redis_data: + +networks: + guest-chat-net: + driver: bridge diff --git a/from_server_guest-chat/bak_test7777/stack/guest-cs.env b/from_server_guest-chat/bak_test7777/stack/guest-cs.env new file mode 100644 index 0000000..bb5af32 --- /dev/null +++ b/from_server_guest-chat/bak_test7777/stack/guest-cs.env @@ -0,0 +1,56 @@ +APP_NAME=API接口系统 +APP_HOST=0.0.0.0 +APP_PORT=7788 +APP_DEBUG=false + +CHATWOOT_ENABLED=true +CHATWOOT_BASE_URL=http://pai.pubuli.com:3000 +CHATWOOT_WEBSITE_TOKEN=FZBhe2W4VXSXNT9Ek4etmP3L +CHATWOOT_IDENTITY_SECRET=9Fq7fmzHmButzJdq2mkbaHx5 +CHATWOOT_DEBUG=false +CHATWOOT_ACCOUNT_ID=1 +CHATWOOT_INBOX_ID=1 +CHATWOOT_API_EMAIL=yyds@nobody.com +CHATWOOT_API_PASSWORD=YYDSyyds#168 +CHATWOOT_GREETING=您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢? + +# 浏览器访问仍用 127.0.0.1:7788/cw;容器内代理/API 走 docker 网 +CHATWOOT_GUEST_BASE_URL=http://127.0.0.1:7788/cw +CHATWOOT_GUEST_WEBSITE_TOKEN=utiK8hZAiaqmqdp2baN6Pq14 +CHATWOOT_GUEST_PROXY_TARGET=http://127.0.0.1:3000 +CHATWOOT_GUEST_API_BASE=http://127.0.0.1:3000 +CHATWOOT_GUEST_API_EMAIL=admin@localhost.local +CHATWOOT_GUEST_API_PASSWORD=LocalCw#168 +CHATWOOT_GUEST_INBOX_ID=1 +CHATWOOT_WIDGET_JWT_SECRET=61db0140c2e890c28f6737913fb22734365590c7b9501590cd43b108532e13dfe368b1792e6a6bee1ad90dda9dbb42fca39c9d4ef76749c24488a63246ee01ca +CHATWOOT_AGENT_BOT_ID=1 +CHATWOOT_AGENT_BOT_TOKEN=E4rBf9TbMi1C5ShpDtLztGZh + +MAXKB_BRIDGE_ENABLED=true +MAXKB_BASE_URL=http://pai.pubuli.com:8080 +MAXKB_ACCESS_TOKEN=4a44cfae71e8aee7 +MAXKB_TIMEOUT_SEC=120 + +SECRET_KEY=replace-with-a-long-random-secret +API_KEY= +SESSION_COOKIE_SECURE=false + +# 会员库:本机隧道时指向宿主机;无隧道则登录会员功能不可用,游客页仍可用 +DB_HOST=host.docker.internal +DB_PORT=3306 +DB_USER=root +DB_PASSWORD=z88KmX2VjuiYHep +DB_NAME=member_system +DB_CHARSET=utf8mb4 +DB_CONNECT_TIMEOUT=5 + +MEMBERS_TABLE=members +WITHDRAW_TABLE=withdraw_records +WITHDRAW_REQUEST_TABLE=withdraw_request_records +DEPOSIT_TABLE=deposit_records + +READ_ONLY_MODE=true +AUTO_INIT_SIM_TABLES=false +DEFAULT_ACTIVITY_ID=ad-x1234 +DEPOSIT_SEARCH_HOURS=24 +MAX_ORDERS=50 diff --git a/from_server_guest-chat/bak_test7777/stack/guest-cs.remote.env b/from_server_guest-chat/bak_test7777/stack/guest-cs.remote.env new file mode 100644 index 0000000..deda46f --- /dev/null +++ b/from_server_guest-chat/bak_test7777/stack/guest-cs.remote.env @@ -0,0 +1,3 @@ +# 远端 20.191.152.26 / www.pubuli.com:7777 专用(bootstrap 后 token) +CHATWOOT_GUEST_WEBSITE_TOKEN=j1UAjqBgZMWQ8WfczgJSymQx +CHATWOOT_GUEST_BASE_URL=http://www.pubuli.com:7777/cw diff --git a/from_server_guest-chat/bak_test7777/stack/guest-cs.test.env b/from_server_guest-chat/bak_test7777/stack/guest-cs.test.env new file mode 100644 index 0000000..1e2b214 --- /dev/null +++ b/from_server_guest-chat/bak_test7777/stack/guest-cs.test.env @@ -0,0 +1,4 @@ +# test.pubuli.com:7777 专用(bootstrap 后 token) +CHATWOOT_GUEST_WEBSITE_TOKEN=j1UAjqBgZMWQ8WfczgJSymQx +CHATWOOT_GUEST_BASE_URL=http://20.191.152.26:7777/cw +CHATWOOT_AGENT_BOT_TOKEN=SrQpnTgYzXq4i17nVQhemxjT diff --git a/guest-cs-stack/DEPLOY-TEST.md b/guest-cs-stack/DEPLOY-TEST.md new file mode 100644 index 0000000..8a995b6 --- /dev/null +++ b/guest-cs-stack/DEPLOY-TEST.md @@ -0,0 +1,69 @@ +# test.pubuli.com:7777 部署说明 + +目标:把本地 Docker 版 `http://127.0.0.1:7788/guest-customer-service` 部署到 +**http://test.pubuli.com:7777/guest-customer-service** + +## 现状 + +| 项目 | 说明 | +|------|------| +| DNS | `test.pubuli.com` → **100.31.73.254**(与 `www.pubuli.com` / 20.191.152.26 不同) | +| 当前 7777 | 旧 **Werkzeug** Python 直跑,不是 Docker | +| SSH | 100.31.73.254 **仅允许公钥**,密码登录被拒 | + +## 在 test 服务器部署 + +### 1. SSH 登录(需有密钥权限) + +```bash +ssh root@100.31.73.254 +# 或你们实际账号 +``` + +### 2. 上传 guest-cs-stack 目录 + +需包含: +- `docker-compose.chat.yaml` +- `docker-compose.chat.test7777.yaml` +- `chatwoot.env` +- `guest-cs.env` +- `guest-cs.test.env` +- `deploy-test7777.sh` +- `bootstrap-chatwoot-test.sh` + +### 3. 一键部署 + +```bash +cd guest-cs-stack +chmod +x deploy-test7777.sh bootstrap-chatwoot-test.sh +./deploy-test7777.sh +``` + +空库首次需 bootstrap,把输出的 `token=` 写入 `guest-cs.test.env` 的 `CHATWOOT_GUEST_WEBSITE_TOKEN`,再: + +```bash +docker compose -f docker-compose.chat.yaml -f docker-compose.chat.test7777.yaml up -d --force-recreate guest-chat +``` + +### 4. 验证 + +```bash +curl -I http://127.0.0.1:7777/guest-customer-service +# 应看到 Server: gunicorn(不是 Werkzeug) +``` + +浏览器:http://test.pubuli.com:7777/guest-customer-service(Ctrl+F5) + +## 镜像 + +```bash +docker pull ladioussuapp/guest-chat:latest +``` + +Hub:https://hub.docker.com/r/ladioussuapp/guest-chat + +## 注意 + +- 只 `docker pull` 不会更新正在跑的旧 Python,必须 `./deploy-test7777.sh` 停旧进程并 `--force-recreate` +- MaxKB 仍走 `pai.pubuli.com:8080`(guest-cs.env 已配置) +- 若无法 SSH 到 100.31.73.254,需提供 **SSH 私钥** 或请有权限的人在服务器执行上述命令 diff --git a/guest-cs-stack/Dockerfile.allinone b/guest-cs-stack/Dockerfile.allinone new file mode 100644 index 0000000..35219cb --- /dev/null +++ b/guest-cs-stack/Dockerfile.allinone @@ -0,0 +1,39 @@ +# 基于官方 Chatwoot production 镜像(构建时已 assets:precompile) +# 再叠 Flask;运行时 RAILS_ENV=production,不跑 development / vite +FROM chatwoot/chatwoot:v4.16.2 + +USER root + +RUN apk add --no-cache python3 py3-pip py3-virtualenv + +WORKDIR /opt/guest-cs +RUN python3 -m venv /opt/guest-cs/.venv +ENV PATH="/opt/guest-cs/.venv/bin:$PATH" + +COPY flask/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY flask/ . +COPY start-all.sh /usr/local/bin/start-all.sh +RUN chmod +x /usr/local/bin/start-all.sh \ + && mkdir -p /app/storage \ + && sed -i 's/\r$//' /usr/local/bin/start-all.sh \ + && test -d /app/public/assets + +# 与 docker-compose.production.yaml / 官方 Dockerfile 生产参数对齐 +ENV RAILS_ENV=production \ + NODE_ENV=production \ + INSTALLATION_ENV=docker \ + RAILS_SERVE_STATIC_FILES=true \ + BUNDLE_WITHOUT=development:test \ + EXECJS_RUNTIME=Disabled \ + APP_HOST=0.0.0.0 \ + APP_PORT=7788 \ + APP_DEBUG=false \ + CHATWOOT_GUEST_PROXY_TARGET=http://127.0.0.1:3000 \ + CHATWOOT_GUEST_API_BASE=http://127.0.0.1:3000 + +EXPOSE 3000 7788 + +WORKDIR /app +ENTRYPOINT ["/usr/local/bin/start-all.sh"] diff --git a/guest-cs-stack/Dockerfile.chatwidget b/guest-cs-stack/Dockerfile.chatwidget new file mode 100644 index 0000000..3d233a2 --- /dev/null +++ b/guest-cs-stack/Dockerfile.chatwidget @@ -0,0 +1,51 @@ +# 只打包「游客在线客服聊天框」 +# 前端:官方 Chatwoot production 镜像(构建期已 assets:precompile / Vite 编译) +# 运行:RAILS_ENV=production,不跑 development / vite / pnpm / bundle install +# +# 对照:https://github.com/chatwoot/chatwoot/blob/v4.16.2/docker-compose.production.yaml +# https://github.com/chatwoot/chatwoot/blob/v4.16.2/docker/Dockerfile + +FROM chatwoot/chatwoot:v4.16.2 + +USER root + +# 确认生产静态资源已存在(官方镜像 precompile 产物) +RUN test -d /app/public/assets \ + && test -d /app/public/vite/assets \ + && test "$(ls -A /app/public/vite/assets 2>/dev/null | wc -l)" -gt 0 \ + && echo "[ok] Chatwoot production frontend assets present" + +RUN apk add --no-cache python3 py3-pip py3-virtualenv + +WORKDIR /opt/guest-cs +RUN python3 -m venv /opt/guest-cs/.venv +ENV PATH="/opt/guest-cs/.venv/bin:$PATH" + +COPY flask/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY flask/ . +COPY start-chat.sh /usr/local/bin/start-chat.sh +RUN chmod +x /usr/local/bin/start-chat.sh \ + && mkdir -p /app/storage \ + && sed -i 's/\r$//' /usr/local/bin/start-chat.sh + +# 与 docker-compose.production.yaml 对齐;聊天框经 7788 代理 widget +ENV RAILS_ENV=production \ + NODE_ENV=production \ + INSTALLATION_ENV=docker \ + RAILS_SERVE_STATIC_FILES=true \ + BUNDLE_WITHOUT=development:test \ + EXECJS_RUNTIME=Disabled \ + APP_HOST=0.0.0.0 \ + APP_PORT=7788 \ + APP_DEBUG=false \ + CHATWOOT_GUEST_PROXY_TARGET=http://127.0.0.1:3000 \ + CHATWOOT_GUEST_API_BASE=http://127.0.0.1:3000 \ + CHATWOOT_GUEST_BASE_URL=http://127.0.0.1:7788/cw + +# 对外产品口是 7788(聊天框);3000 仅容器内给 widget API +EXPOSE 7788 + +WORKDIR /app +ENTRYPOINT ["/usr/local/bin/start-chat.sh"] diff --git a/guest-cs-stack/Dockerfile.chatwoot b/guest-cs-stack/Dockerfile.chatwoot new file mode 100644 index 0000000..dee5dfe --- /dev/null +++ b/guest-cs-stack/Dockerfile.chatwoot @@ -0,0 +1,4 @@ +# 基于官方 Chatwoot 镜像打本地标签,便于 docker save 打包 +FROM chatwoot/chatwoot:v4.16.2 +LABEL org.opencontainers.image.title="guest-cs-chatwoot" +LABEL org.opencontainers.image.description="Chatwoot for guest customer service stack" diff --git a/guest-cs-stack/README.md b/guest-cs-stack/README.md new file mode 100644 index 0000000..a7a07dd --- /dev/null +++ b/guest-cs-stack/README.md @@ -0,0 +1,37 @@ +# 在线客服聊天框(production) + +只打包图上那个聊天框,前端用官方 Chatwoot **production 预编译**资源(`assets:precompile` / Vite),不跑 development。 + +## Pull + +```bash +docker pull ladioussuapp/guest-chat:latest +``` + +Hub:https://hub.docker.com/r/ladioussuapp/guest-chat + +## 启动(复用本机旧 Chatwoot 数据) + +```powershell +cd F:\AI\guest-cs-stack +docker compose -f docker-compose.chat.yaml -f docker-compose.chat-data.yaml up -d +``` + +## 远端 test.pubuli.com(对外 7788) + +详见 [DEPLOY-TEST.md](./DEPLOY-TEST.md) + +```bash +docker pull ladioussuapp/guest-chat:latest +docker compose -f docker-compose.chat.yaml -f docker-compose.chat.test.yaml up -d --force-recreate +``` + +入口:http://test.pubuli.com:7788/guest-customer-service + +**注意:** test 服务器目前 7788 仍是旧 Werkzeug,必须先 SSH 上去停旧进程再部署 Docker。 + +## 入口 + +- http://127.0.0.1:7788/guest-customer-service + +对外只开 `7788`;Chatwoot 后台 `3000` 仅容器内给 widget API,不映射到宿主机。 diff --git a/guest-cs-stack/_aug12_image_flask/.dockerignore b/guest-cs-stack/_aug12_image_flask/.dockerignore new file mode 100644 index 0000000..d6f78cd --- /dev/null +++ b/guest-cs-stack/_aug12_image_flask/.dockerignore @@ -0,0 +1,8 @@ +venv/ +__pycache__/ +*.pyc +*.pyo +.env +.maxkb_cw_sessions.json +.pytest_cache/ +*.md diff --git a/guest-cs-stack/_aug12_image_flask/.env.example b/guest-cs-stack/_aug12_image_flask/.env.example new file mode 100644 index 0000000..7ca1384 --- /dev/null +++ b/guest-cs-stack/_aug12_image_flask/.env.example @@ -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 diff --git a/guest-cs-stack/_aug12_image_flask/Dockerfile b/guest-cs-stack/_aug12_image_flask/Dockerfile new file mode 100644 index 0000000..19b4435 --- /dev/null +++ b/guest-cs-stack/_aug12_image_flask/Dockerfile @@ -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"] diff --git a/guest-cs-stack/_aug12_image_flask/app.py b/guest-cs-stack/_aug12_image_flask/app.py new file mode 100644 index 0000000..0a2964c --- /dev/null +++ b/guest-cs-stack/_aug12_image_flask/app.py @@ -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/", + methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], +) +def chatwoot_guest_proxy(subpath: str): + """把本機 Chatwoot widget 代理進來,才能注入略過「開始對話」的腳本。""" + return proxy_chatwoot(subpath) + + +@app.route( + "/vite/", + 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/", + 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/", + 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) diff --git a/guest-cs-stack/_aug12_image_flask/chatwoot_guest.py b/guest-cs-stack/_aug12_image_flask/chatwoot_guest.py new file mode 100644 index 0000000..709599a --- /dev/null +++ b/guest-cs-stack/_aug12_image_flask/chatwoot_guest.py @@ -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("=") diff --git a/guest-cs-stack/_aug12_image_flask/chatwoot_proxy.py b/guest-cs-stack/_aug12_image_flask/chatwoot_proxy.py new file mode 100644 index 0000000..497c3af --- /dev/null +++ b/guest-cs-stack/_aug12_image_flask/chatwoot_proxy.py @@ -0,0 +1,724 @@ +from __future__ import annotations + +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from flask import Response, request + +from config import CONFIG + +HOP_BY_HOP = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + "host", + "content-length", +} + +INJECT_SCRIPT = """ + + + +""" + + +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 "" in html: + html = html.replace("", inject + "", 1) + elif "" in html: + html = html.replace("", inject + "", 1) + else: + html += inject + payload = html.encode("utf-8") + + return Response(payload, status=status, headers=response_headers) diff --git a/guest-cs-stack/_aug12_image_flask/config.py b/guest-cs-stack/_aug12_image_flask/config.py new file mode 100644 index 0000000..b79b8b3 --- /dev/null +++ b/guest-cs-stack/_aug12_image_flask/config.py @@ -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() diff --git a/guest-cs-stack/_aug12_image_flask/db.py b/guest-cs-stack/_aug12_image_flask/db.py new file mode 100644 index 0000000..1803500 --- /dev/null +++ b/guest-cs-stack/_aug12_image_flask/db.py @@ -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() diff --git a/guest-cs-stack/_aug12_image_flask/maxkb_chatwoot_bridge.py b/guest-cs-stack/_aug12_image_flask/maxkb_chatwoot_bridge.py new file mode 100644 index 0000000..9df21c1 --- /dev/null +++ b/guest-cs-stack/_aug12_image_flask/maxkb_chatwoot_bridge.py @@ -0,0 +1,352 @@ +"""Chatwoot Agent Bot webhook → MaxKB(PAI)→ 回寫對話。""" + +from __future__ import annotations + +import json +import logging +import threading +import time +from pathlib import Path +from typing import Any +from urllib.error import HTTPError, URLError +from urllib.request import Request, urlopen + +from config import CONFIG + +logger = logging.getLogger(__name__) + +_SESSION_LOCK = threading.Lock() +_SESSIONS: dict[str, dict[str, Any]] = {} +_SESSION_FILE = Path(__file__).resolve().parent / ".maxkb_cw_sessions.json" + + +def _load_sessions() -> None: + if not _SESSION_FILE.exists(): + return + try: + data = json.loads(_SESSION_FILE.read_text(encoding="utf-8")) + if isinstance(data, dict): + _SESSIONS.update(data) + except Exception: + logger.exception("讀取 MaxKB session 快取失敗") + + +def _save_sessions() -> None: + try: + _SESSION_FILE.write_text( + json.dumps(_SESSIONS, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + except Exception: + logger.exception("寫入 MaxKB session 快取失敗") + + +_load_sessions() + + +def _http_json( + method: str, + url: str, + *, + headers: dict[str, str] | None = None, + body: Any = None, + timeout: float = 120, +) -> tuple[int, Any]: + data = None + if body is not None: + data = body if isinstance(body, (bytes, bytearray)) else json.dumps( + body, ensure_ascii=False + ).encode("utf-8") + req_headers = {"Content-Type": "application/json", "Accept": "application/json"} + if headers: + req_headers.update(headers) + req = Request(url, data=data, headers=req_headers, method=method) + try: + with urlopen(req, timeout=timeout) as resp: + raw = resp.read() + try: + return resp.status, json.loads(raw.decode("utf-8") or "null") + except Exception: + return resp.status, raw.decode("utf-8", errors="replace") + except HTTPError as exc: + raw = exc.read() + try: + return exc.code, json.loads(raw.decode("utf-8") or "null") + except Exception: + return exc.code, raw.decode("utf-8", errors="replace") + except URLError as exc: + raise RuntimeError(f"HTTP 連線失敗: {url}: {exc}") from exc + + +def _maxkb_auth() -> str: + status, body = _http_json( + "POST", + f"{CONFIG.maxkb_base_url}/chat/api/auth/anonymous", + body={"access_token": CONFIG.maxkb_access_token}, + timeout=30, + ) + if status != 200 or not isinstance(body, dict) or body.get("code") != 200: + raise RuntimeError(f"MaxKB anonymous auth 失敗: {status} {body}") + token = body.get("data") + if not token: + raise RuntimeError("MaxKB anonymous auth 未回傳 token") + return str(token) + + +def _maxkb_open(jwt: str) -> str: + status, body = _http_json( + "GET", + f"{CONFIG.maxkb_base_url}/chat/api/open", + headers={"Authorization": f"Bearer {jwt}"}, + timeout=30, + ) + if status != 200 or not isinstance(body, dict) or body.get("code") != 200: + raise RuntimeError(f"MaxKB open 失敗: {status} {body}") + chat_id = body.get("data") + if not chat_id: + raise RuntimeError("MaxKB open 未回傳 chat_id") + return str(chat_id) + + +def _maxkb_ask( + jwt: str, + chat_id: str, + message: str, + form_data: dict[str, Any], +) -> str: + status, body = _http_json( + "POST", + f"{CONFIG.maxkb_base_url}/chat/api/chat_message/{chat_id}", + headers={"Authorization": f"Bearer {jwt}"}, + body={ + "message": message, + "stream": False, + "re_chat": False, + "form_data": form_data, + }, + timeout=CONFIG.maxkb_timeout_sec, + ) + if status != 200 or not isinstance(body, dict) or body.get("code") != 200: + raise RuntimeError(f"MaxKB chat 失敗: {status} {body}") + data = body.get("data") or {} + content = "" + if isinstance(data, dict): + content = str(data.get("content") or "").strip() + if not content: + answers = data.get("answer_list") or [] + if answers and isinstance(answers[0], dict): + content = str(answers[0].get("content") or "").strip() + if not content: + raise RuntimeError(f"MaxKB 回覆為空: {body}") + return content + + +def _get_or_create_session(conversation_key: str) -> dict[str, Any]: + with _SESSION_LOCK: + sess = _SESSIONS.get(conversation_key) + if sess and sess.get("jwt") and sess.get("chat_id"): + return dict(sess) + + jwt = _maxkb_auth() + chat_id = _maxkb_open(jwt) + sess = { + "jwt": jwt, + "chat_id": chat_id, + "updated_at": int(time.time()), + } + with _SESSION_LOCK: + _SESSIONS[conversation_key] = sess + _save_sessions() + return dict(sess) + + +def _refresh_session(conversation_key: str) -> dict[str, Any]: + with _SESSION_LOCK: + _SESSIONS.pop(conversation_key, None) + _save_sessions() + return _get_or_create_session(conversation_key) + + +def _chatwoot_reply( + account_id: int, + conversation_id: int, + content: str, +) -> None: + base = ( + CONFIG.chatwoot_guest_api_base + or CONFIG.chatwoot_guest_proxy_target + or "http://127.0.0.1:3000" + ).rstrip("/") + token = CONFIG.chatwoot_agent_bot_token + if not token: + raise RuntimeError("未設定 CHATWOOT_AGENT_BOT_TOKEN") + url = ( + f"{base}/api/v1/accounts/{account_id}/conversations/" + f"{conversation_id}/messages" + ) + status, body = _http_json( + "POST", + url, + headers={"api_access_token": token}, + body={ + "content": content, + "message_type": "outgoing", + "private": False, + }, + timeout=30, + ) + if status >= 300: + raise RuntimeError(f"Chatwoot 回覆失敗: {status} {body}") + + +def _extract_incoming(payload: dict[str, Any]) -> dict[str, Any] | None: + event = str(payload.get("event") or "") + # Agent Bot 會送 message_created / message_updated;只處理新建且來自聯絡人的訊息 + if event and event not in {"message_created"}: + return None + + message = payload.get("message") or payload + if not isinstance(message, dict): + return None + + # 0 / incoming = 聯絡人;1 / outgoing = 客服或 bot(避免自回) + message_type = message.get("message_type") + incoming_types = {0, "0", "incoming", "Incoming"} + if message_type not in incoming_types: + return None + if message.get("private"): + return None + + content = str(message.get("content") or "").strip() + if not content: + return None + + conversation = message.get("conversation") or payload.get("conversation") or {} + if not isinstance(conversation, dict): + conversation = {} + conversation_id = conversation.get("id") or message.get("conversation_id") + account = payload.get("account") if isinstance(payload.get("account"), dict) else {} + account_id = ( + message.get("account_id") + or conversation.get("account_id") + or account.get("id") + or CONFIG.chatwoot_account_id + ) + if not conversation_id: + return None + + sender = message.get("sender") or {} + username = "" + if isinstance(sender, dict): + username = str( + sender.get("name") + or sender.get("identifier") + or (sender.get("additional_attributes") or {}).get("username") + or "" + ) + + return { + "account_id": int(account_id), + "conversation_id": int(conversation_id), + "content": content, + "username": username, + "message_id": message.get("id"), + } + + +def _hard_reply_for_complaint(message: str) -> str | None: + """客诉/提款强制话术,避免意图误判。""" + text = "".join(str(message or "").split()) + if "一次不给赢" in text: + return "亲,每次游戏都会有输有赢,关键是保持好心态,接受挑战,最终会有丰厚的回报!" + # YD-CUSTOMER-018 + if "回归" in text and "杀" in text: + return ( + "亲,游戏输输赢赢很正常的,拼的是智力、耐心、心态和运气," + "如果您觉得手气不好,可以稍作休息,转移一下注意力,稍后再进行投注," + "或许下一个大奖就是您的,祝您鸿运当头,盈利多多。" + ) + # YD-CUSTOMER-017 + low = text.lower() + if ( + "pg电子" in low + or "PG电子" in text + or "电子真是假" in text + or "假的我永远" in text + or ("电子" in text and "假" in text) + ): + return ( + "亲,您好!电子游戏的大奖奖池是根据机率来的哦,每一轮的结果都是随机的," + "所以不能保证每次都能中奖。希望您能理解,这也是游戏的一部分乐趣所在!" + "我们可以一起再试试,说不定下一次就能碰上好运呢" + ) + # YD-WITHDRAW-016(标准为转专人,禁止追问订单号) + if "取消提款" in text or "取消提现" in text or "申请取消提款" in text: + return ( + "您好,取消提款需由专员协助核实处理,请稍候为您转接真人客服。" + "转专人服务" + ) + return None + + +def handle_chatwoot_webhook(payload: dict[str, Any]) -> dict[str, Any]: + if not CONFIG.maxkb_bridge_enabled: + return {"ok": True, "skipped": "bridge_disabled"} + + incoming = _extract_incoming(payload) + if not incoming: + return {"ok": True, "skipped": "not_incoming"} + + account_id = incoming["account_id"] + conversation_id = incoming["conversation_id"] + key = f"{account_id}:{conversation_id}" + form_data = { + "account_id": str(account_id), + "conversation_id": str(conversation_id), + "bot_id": str(CONFIG.chatwoot_agent_bot_id or ""), + "username": incoming["username"] or "游客", + } + + hard = _hard_reply_for_complaint(incoming["content"]) + if hard: + _chatwoot_reply(account_id, conversation_id, hard) + with _SESSION_LOCK: + cur = _SESSIONS.get(key) or {} + cur["updated_at"] = int(time.time()) + cur["last_message_id"] = incoming.get("message_id") + cur["hard_reply"] = True + _SESSIONS[key] = cur + _save_sessions() + return { + "ok": True, + "conversation_id": conversation_id, + "hard_reply": True, + "reply_len": len(hard), + } + + sess = _get_or_create_session(key) + try: + reply = _maxkb_ask( + sess["jwt"], sess["chat_id"], incoming["content"], form_data + ) + except Exception: + logger.exception("MaxKB 對話失敗,嘗試重建 session") + sess = _refresh_session(key) + reply = _maxkb_ask( + sess["jwt"], sess["chat_id"], incoming["content"], form_data + ) + + _chatwoot_reply(account_id, conversation_id, reply) + with _SESSION_LOCK: + cur = _SESSIONS.get(key) or {} + cur["updated_at"] = int(time.time()) + cur["last_message_id"] = incoming.get("message_id") + _SESSIONS[key] = cur + _save_sessions() + + return { + "ok": True, + "conversation_id": conversation_id, + "chat_id": sess["chat_id"], + "reply_len": len(reply), + } diff --git a/guest-cs-stack/_aug12_image_flask/preflight.py b/guest-cs-stack/_aug12_image_flask/preflight.py new file mode 100644 index 0000000..3f4d142 --- /dev/null +++ b/guest-cs-stack/_aug12_image_flask/preflight.py @@ -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()) diff --git a/guest-cs-stack/_aug12_image_flask/repository.py b/guest-cs-stack/_aug12_image_flask/repository.py new file mode 100644 index 0000000..ab54f7f --- /dev/null +++ b/guest-cs-stack/_aug12_image_flask/repository.py @@ -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 diff --git a/guest-cs-stack/_aug12_image_flask/requirements.txt b/guest-cs-stack/_aug12_image_flask/requirements.txt new file mode 100644 index 0000000..8a9c356 --- /dev/null +++ b/guest-cs-stack/_aug12_image_flask/requirements.txt @@ -0,0 +1,4 @@ +Flask>=3.1,<4 +PyMySQL>=1.1,<2 +python-dotenv>=1.0,<2 +gunicorn>=23,<24 diff --git a/guest-cs-stack/_aug12_image_flask/static/style.css b/guest-cs-stack/_aug12_image_flask/static/style.css new file mode 100644 index 0000000..42d5015 --- /dev/null +++ b/guest-cs-stack/_aug12_image_flask/static/style.css @@ -0,0 +1,172 @@ +:root{--bg:#090d1a;--panel:#11182b;--panel2:#19233c;--text:#f5f7ff;--muted:#9aa7c5;--accent:#ffcc4d;--accent2:#7b61ff;--danger:#ff6577;--success:#50d890;--border:rgba(255,255,255,.09)} +*{box-sizing:border-box}body{margin:0;min-height:100vh;font-family:Inter,"Noto Sans TC","Microsoft JhengHei",sans-serif;color:var(--text);background:radial-gradient(circle at top right,rgba(123,97,255,.20),transparent 34%),radial-gradient(circle at bottom left,rgba(255,204,77,.10),transparent 30%),var(--bg)}a{color:inherit}.login-body{display:grid;place-items:center;padding:24px}.login-card{width:min(440px,100%);padding:36px;background:rgba(17,24,43,.94);border:1px solid var(--border);border-radius:24px;box-shadow:0 24px 80px rgba(0,0,0,.38)}.brand-mark{display:grid;place-items:center;width:58px;height:58px;border-radius:18px;background:linear-gradient(135deg,var(--accent),#ff8a4d);color:#1b1520;font-weight:900;letter-spacing:.08em}h1,h2,h3,p{margin-top:0}.muted,.site-subtitle,.section-heading span{color:var(--muted)}label{display:block;margin:24px 0 8px;font-weight:700}input{width:100%;padding:14px 16px;color:var(--text);background:#0c1222;border:1px solid var(--border);border-radius:12px;outline:none}input:focus{border-color:var(--accent2)}.primary-button,.game-card button{width:100%;margin-top:16px;padding:14px 18px;border:0;border-radius:12px;font-weight:800}.primary-button,.primary-link{color:#17120a;background:var(--accent);cursor:pointer}.primary-link{display:inline-block;padding:12px 16px;border-radius:12px;text-decoration:none;font-weight:800}.security-note{margin:18px 0 0;color:var(--muted);font-size:13px}.topbar{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:18px clamp(18px,4vw,56px);background:rgba(9,13,26,.88);border-bottom:1px solid var(--border);backdrop-filter:blur(16px);position:sticky;top:0;z-index:10}.site-title{font-weight:900;letter-spacing:.08em}.site-subtitle{margin-top:3px;font-size:13px}.member-area{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:flex-end}.member-area form{margin:0}.logout-button,.back-link{padding:9px 13px;border:1px solid var(--border);border-radius:10px;color:var(--text);background:transparent;text-decoration:none;cursor:pointer}.status-badge{padding:5px 9px;border-radius:999px;font-size:12px;font-weight:800}.status-badge.normal{color:var(--success);background:rgba(80,216,144,.12)}.status-badge.locked{color:var(--danger);background:rgba(255,101,119,.12)}.page{width:min(1180px,calc(100% - 32px));margin:0 auto;padding:40px 0 72px}.page.narrow{width:min(820px,calc(100% - 32px))}.hero{display:grid;grid-template-columns:1fr auto;align-items:center;gap:24px;min-height:280px;padding:clamp(28px,5vw,60px);margin-bottom:24px;border:1px solid var(--border);border-radius:28px;background:linear-gradient(135deg,rgba(123,97,255,.26),rgba(17,24,43,.94)),var(--panel)}.hero h1{max-width:720px;font-size:clamp(30px,5vw,56px);line-height:1.08}.hero p{max-width:720px;color:var(--muted);font-size:17px}.eyebrow{color:var(--accent);font-size:12px;font-weight:900;letter-spacing:.16em}.service-entry{display:flex;gap:14px;align-items:center;min-width:230px;padding:18px;border:1px solid rgba(255,204,77,.35);border-radius:18px;background:rgba(255,204,77,.10);text-decoration:none}.service-entry small{display:block;margin-top:4px;color:var(--muted)}.service-icon{font-size:34px}.account-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:34px}.metric-card{padding:18px;border:1px solid var(--border);border-radius:16px;background:var(--panel)}.metric-card span{display:block;color:var(--muted);font-size:13px;margin-bottom:8px}.metric-card strong{font-size:20px}.section-heading{display:flex;align-items:end;justify-content:space-between;gap:18px;margin:0 4px 16px}.game-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.game-card{display:grid;grid-template-columns:auto 1fr;gap:16px;padding:22px;border:1px solid var(--border);border-radius:20px;background:var(--panel)}.game-card h3{margin-bottom:6px}.game-card p{margin-bottom:0;color:var(--muted)}.game-icon{font-size:38px}.game-card button{grid-column:1/-1;color:var(--muted);background:var(--panel2);cursor:not-allowed}.floating-service{position:fixed;right:22px;bottom:22px;padding:14px 18px;border-radius:999px;color:#17120a;background:var(--accent);font-weight:900;text-decoration:none;box-shadow:0 16px 36px rgba(0,0,0,.35)}.alert{padding:14px 16px;margin-bottom:18px;border-radius:12px}.alert-error{color:#ffdfe3;background:rgba(255,101,119,.14);border:1px solid rgba(255,101,119,.28)}.alert-warning{color:#fff2c5;background:rgba(255,204,77,.12);border:1px solid rgba(255,204,77,.28)}.support-panel{padding:34px;border:1px solid var(--border);border-radius:24px;background:var(--panel)}.support-avatar{font-size:48px}.empty-state{padding:24px;color:var(--muted);background:var(--panel2);border-radius:14px}.order-list{display:grid;gap:12px}.order-card{display:grid;gap:7px;padding:16px;border:1px solid var(--border);border-radius:14px;background:var(--panel2)}.order-card span{color:var(--muted)}.section-gap{margin-top:30px}@media(max-width:800px){.hero{grid-template-columns:1fr}.game-grid{grid-template-columns:1fr 1fr}.account-grid{grid-template-columns:1fr 1fr}.topbar{align-items:flex-start}}@media(max-width:560px){.game-grid,.account-grid{grid-template-columns:1fr}.topbar{flex-direction:column}.member-area{justify-content:flex-start}.login-card{padding:26px}} + + +.login-divider { + display: flex; + align-items: center; + gap: 12px; + margin: 20px 0; + color: var(--muted); +} +.login-divider::before, +.login-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border); +} +.guest-service-button { + display: block; + padding: 14px 18px; + border: 1px solid rgba(255, 204, 77, .35); + border-radius: 12px; + color: var(--accent); + background: rgba(255, 204, 77, .08); + text-align: center; + text-decoration: none; + font-weight: 800; +} +.guest-service-note { + margin-top: 12px; + color: var(--muted); + font-size: 13px; + line-height: 1.6; +} +.guest-support-panel { + text-align: center; +} +.guest-badge { + display: inline-block; + margin: 6px 0 18px; + padding: 6px 10px; + border-radius: 999px; + color: var(--accent); + background: rgba(255, 204, 77, .10); + font-size: 12px; + font-weight: 800; +} +.guest-open-chat { + max-width: 320px; + margin-left: auto; + margin-right: auto; +} + +.guest-page { + color: #111; + background: #fff; +} +.guest-page .woot-widget-holder, +.guest-page .woot-widget-holder.woot-elements--right { + width: 400px !important; + height: min(82vh, 760px) !important; + max-height: 82vh !important; +} +.guest-page .woot-widget-holder iframe, +.guest-page .woot-widget-holder .woot-widget-box { + width: 100% !important; + height: 100% !important; + min-height: min(82vh, 760px) !important; +} +.guest-page .woot-widget-bubble { + background: #e31c23 !important; +} +.guest-page .woot-widget-bubble:hover { + background: #c4161c !important; +} +.guest-page .topbar { + color: #111; + background: #fff; + border-bottom: 1px solid #e6e6e6; +} +.guest-page .site-subtitle { + color: #555; +} +.guest-page .back-link { + color: #111; + border-color: #d8d8d8; + background: #fff; +} +.guest-page .support-panel { + color: #111; + background: #fff; + border: 1px solid #e6e6e6; +} +.guest-page .support-panel p { + color: #333; +} +.guest-page .guest-badge { + color: #111; + background: #f3f3f3; +} +.guest-page .alert-warning { + color: #111; + background: #f7f7f7; + border: 1px solid #e0e0e0; +} +.guest-page .primary-button { + color: #111; + background: #ffcc4d; +} + +/* 登录页:白底黑字 */ +.login-body { + color: #111; + background: #fff; +} +.login-body .login-card { + color: #111; + background: #fff; + border: 1px solid #e6e6e6; + box-shadow: 0 12px 36px rgba(0, 0, 0, .08); +} +.login-body .brand-mark { + color: #111; + background: #ffcc4d; +} +.login-body h1 { + color: #111; +} +.login-body .muted, +.login-body .guest-service-note, +.login-body .security-note, +.login-body .login-divider { + color: #555; +} +.login-body .login-divider::before, +.login-body .login-divider::after { + background: #e6e6e6; +} +.login-body label { + color: #111; +} +.login-body input { + color: #111; + background: #fff; + border: 1px solid #d8d8d8; +} +.login-body input:focus { + border-color: #ffcc4d; +} +.login-body .primary-button { + color: #111; + background: #ffcc4d; +} +.login-body .guest-service-button { + color: #111; + border-color: #d8d8d8; + background: #f7f7f7; +} +.login-body .guest-service-button:hover { + background: #f0f0f0; +} +.login-body .alert-error { + color: #111; + background: #fff1f2; + border: 1px solid #ffc9d0; +} +.login-body .alert-warning { + color: #111; + background: #f7f7f7; + border: 1px solid #e0e0e0; +} diff --git a/guest-cs-stack/_aug12_image_flask/templates/_chatwoot.html b/guest-cs-stack/_aug12_image_flask/templates/_chatwoot.html new file mode 100644 index 0000000..aab3fca --- /dev/null +++ b/guest-cs-stack/_aug12_image_flask/templates/_chatwoot.html @@ -0,0 +1,432 @@ +{% if chatwoot_config.enabled %} + +{% endif %} diff --git a/guest-cs-stack/_aug12_image_flask/templates/base.html b/guest-cs-stack/_aug12_image_flask/templates/base.html new file mode 100644 index 0000000..54c6b66 --- /dev/null +++ b/guest-cs-stack/_aug12_image_flask/templates/base.html @@ -0,0 +1,13 @@ + + + + + + {% block title %}{{ app_name }}{% endblock %} + + + +{% block body %}{% endblock %} +{% block integrations %}{% endblock %} + + diff --git a/guest-cs-stack/_aug12_image_flask/templates/customer_service.html b/guest-cs-stack/_aug12_image_flask/templates/customer_service.html new file mode 100644 index 0000000..d4f8901 --- /dev/null +++ b/guest-cs-stack/_aug12_image_flask/templates/customer_service.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% block title %}客服中心|{{ app_name }}{% endblock %} +{% block body %} +
+
+
客服測試入口
+
目前用戶:{{ username }}
+
+ 返回首頁 +
+ +
+
+
🎧
+

會員資料查詢

+ {% if error %} +
{{ error }}
+ {% else %} + {% if account.status == "locked" %} +
+ 帳戶已凍結{% if account.locked_reason %}|{{ account.locked_reason }}{% endif %} +
+ {% endif %} + +

未完成提款

+ {% if not withdrawals %} +
目前沒有查到未完成提款資料。
+ {% else %} +
+ {% for order in withdrawals %} +
+ {{ order.status or "狀態未知" }} + 來源:{{ order.source }} + 訂單:{{ order.order_id or "NA" }} + 金額:{{ order.amount }} + 時間:{{ order.create_time or "NA" }} + {% if order.fail_reason %}備註:{{ order.fail_reason }}{% endif %} +
+ {% endfor %} +
+ {% endif %} + +

最近充值

+ {% if not deposits %} +
目前沒有查到充值資料。
+ {% else %} +
+ {% for order in deposits %} +
+ {{ order.status or "狀態未知" }} + 訂單:{{ order.thirdparty_order_id or "NA" }} + 充值金額:{{ order.amount }} + 實際到帳:{{ order.actual_arrival_amount }} + 充值時間:{{ order.created_at or "NA" }} + 到帳時間:{{ order.arrival_time or "NA" }} +
+ {% endfor %} +
+ {% endif %} + {% endif %} +
+
+{% endblock %} + +{% block integrations %} +{% include "_chatwoot.html" %} +{% endblock %} diff --git a/guest-cs-stack/_aug12_image_flask/templates/error.html b/guest-cs-stack/_aug12_image_flask/templates/error.html new file mode 100644 index 0000000..8ba4c6a --- /dev/null +++ b/guest-cs-stack/_aug12_image_flask/templates/error.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}{{ title }}|{{ app_name }}{% endblock %} +{% block body_class %}login-body{% endblock %} +{% block body %} +
+
!
+

{{ title }}

+
{{ message }}
+ 返回系統 +
+{% endblock %} diff --git a/guest-cs-stack/_aug12_image_flask/templates/guest_customer_service.html b/guest-cs-stack/_aug12_image_flask/templates/guest_customer_service.html new file mode 100644 index 0000000..0ed82c7 --- /dev/null +++ b/guest-cs-stack/_aug12_image_flask/templates/guest_customer_service.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}游客客服|{{ app_name }}{% endblock %} +{% block body_class %}guest-page{% endblock %} + +{% block body %} +
+
+
游客客服中心
+
Guest Customer Service
+
+ 返回登录页 +
+ +
+
+
🎧
+ 游客模式 +

欢迎使用在线客服

+

您目前以游客身份进入,可咨询注册、登录、活动与平台操作问题。

+ +
+ 游客模式不会识别会员身份,也不能查询充值、提款、余额或其他会员资料。 +
+ + +
+
+{% endblock %} + +{% block integrations %} +{% include "_chatwoot.html" %} +{% endblock %} diff --git a/guest-cs-stack/_aug12_image_flask/templates/home.html b/guest-cs-stack/_aug12_image_flask/templates/home.html new file mode 100644 index 0000000..c27c5f1 --- /dev/null +++ b/guest-cs-stack/_aug12_image_flask/templates/home.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% block title %}遊戲首頁|{{ app_name }}{% endblock %} +{% block body %} +
+
+
YD GAME DEMO
+
Python 唯讀 API 測試站點
+
+
+ 用戶:{{ account.raw_username or username }} + + {{ "已凍結" if account.status == "locked" else "正常" }} + +
+ + +
+
+
+ +
+ {% if warning %} +
帳戶資料讀取失敗:{{ warning }}
+ {% endif %} + + {% if account.status == "locked" %} +
+ 帳戶已凍結 + {% if account.locked_reason %}|凍結原因:{{ account.locked_reason }}{% endif %} +
+ {% endif %} + +
+
+ PYTHON TEST ENVIRONMENT +

歡迎進入遊戲站點模擬首頁

+

登入會員:{{ account.username_clean or username }}。本頁只用於驗證會員資料、充值、提款與客服 API。

+
+ + 🎧 + 在線客服查詢充值與提款資料 + +
+ + + +
+

熱門遊戲

畫面模擬,不執行真實投注
+
+ {% for game in games %} +
+
{{ game.icon }}
+

{{ game.name }}

{{ game.category }}

+ +
+ {% endfor %} +
+
+
+🎧 客服 +{% endblock %} + +{% block integrations %} +{% include "_chatwoot.html" %} +{% endblock %} diff --git a/guest-cs-stack/_aug12_image_flask/templates/login.html b/guest-cs-stack/_aug12_image_flask/templates/login.html new file mode 100644 index 0000000..125912e --- /dev/null +++ b/guest-cs-stack/_aug12_image_flask/templates/login.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}登录|{{ app_name }}{% endblock %} +{% block body_class %}login-body{% endblock %} +{% block body %} +
+
YD
+

游戏站点模拟

+

仅使用 members 表内存在的用户名登录,不需要密码。

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} + +
+ + + + +
+ + + + + 🎧 游客进入客服 + + +

+ 游客只能进行一般咨询,不能查询会员充值、提款或账户资料。 +

+ +

【测试限定】此登录方式不具备正式身份验证能力。

+
+{% endblock %} diff --git a/guest-cs-stack/_aug12_image_flask/wsgi.py b/guest-cs-stack/_aug12_image_flask/wsgi.py new file mode 100644 index 0000000..74dcf8d --- /dev/null +++ b/guest-cs-stack/_aug12_image_flask/wsgi.py @@ -0,0 +1,4 @@ +from app import app + + +application = app diff --git a/guest-cs-stack/_azure_meta.py b/guest-cs-stack/_azure_meta.py new file mode 100644 index 0000000..0b691d5 --- /dev/null +++ b/guest-cs-stack/_azure_meta.py @@ -0,0 +1,22 @@ +import os +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +meta_cmds = [ + "curl -s -H Metadata:true 'http://169.254.169.254/metadata/instance/compute/name?api-version=2021-02-01&format=text'", + "curl -s -H Metadata:true 'http://169.254.169.254/metadata/instance/compute/resourceGroupName?api-version=2021-02-01&format=text'", + "curl -s -H Metadata:true 'http://169.254.169.254/metadata/instance/compute/location?api-version=2021-02-01&format=text'", + "curl -s -H Metadata:true 'http://169.254.169.254/metadata/instance/network/interface/0/ipv4/ipAddress/0/publicIpAddress?api-version=2021-02-01&format=text'", + "curl -s -H Metadata:true 'http://169.254.169.254/metadata/instance?api-version=2021-02-01' | python3 -c \"import sys,json; d=json.load(sys.stdin); print('vm',d['compute']['name']); print('rg',d['compute']['resourceGroupName']); print('loc',d['compute']['location']); print('pubip',d['network']['interface'][0]['ipv4']['ipAddress'][0].get('publicIpAddress','?'))\" 2>/dev/null || true", +] + +for cmd in meta_cmds: + print(">>>", cmd[-60:]) + _, o, _ = c.exec_command(cmd, timeout=20) + print(o.read().decode().strip()) + +c.close() diff --git a/guest-cs-stack/_check_base_url.py b/guest-cs-stack/_check_base_url.py new file mode 100644 index 0000000..fd02f1f --- /dev/null +++ b/guest-cs-stack/_check_base_url.py @@ -0,0 +1,13 @@ +import os +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) +_, o, _ = c.exec_command( + "curl -s http://127.0.0.1:7777/guest-customer-service | grep CHATWOOT_CONFIG | head -c 300", + timeout=30, +) +print(o.read().decode()) +c.close() diff --git a/guest-cs-stack/_check_baseurl.py b/guest-cs-stack/_check_baseurl.py new file mode 100644 index 0000000..173f106 --- /dev/null +++ b/guest-cs-stack/_check_baseurl.py @@ -0,0 +1,19 @@ +import os +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +cmds = [ + "curl -s http://127.0.0.1:7777/guest-customer-service -H 'Host: 20.191.152.26:7777' | grep -E 'CHATWOOT_CONFIG|base_url|127.0.0.1' | head -5", + "sudo docker exec guest-chat printenv FRONTEND_URL CHATWOOT_GUEST_BASE_URL", + "grep -n 'request.host' /home/wakka/app.py 2>/dev/null || sudo docker exec guest-chat grep -n 'request.host' /opt/guest-cs/app.py | head -3", +] + +for cmd in cmds: + print("===", cmd[:80]) + _, o, _ = c.exec_command(cmd, timeout=30) + print(o.read().decode(errors="replace")[:800]) +c.close() diff --git a/guest-cs-stack/_check_cable.py b/guest-cs-stack/_check_cable.py new file mode 100644 index 0000000..a5a8b3e --- /dev/null +++ b/guest-cs-stack/_check_cable.py @@ -0,0 +1,14 @@ +import os +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) +cmd = """curl -s -o /dev/null -w '%{http_code}' 'http://127.0.0.1:7777/api/v1/widget/messages?website_token=j1UAjqBgZMWQ8WfczgJSymQx&cw_conversation=test' +echo +curl -s 'http://127.0.0.1:7777/cw/widget?website_token=j1UAjqBgZMWQ8WfczgJSymQx' | grep -o 'CABLE_ORIGIN = \"[^\"]*\"' | head -1 +""" +_, o, _ = c.exec_command(cmd, timeout=30) +print(o.read().decode()) +c.close() diff --git a/guest-cs-stack/_check_conv.py b/guest-cs-stack/_check_conv.py new file mode 100644 index 0000000..64b63f8 --- /dev/null +++ b/guest-cs-stack/_check_conv.py @@ -0,0 +1,28 @@ +import os +import sys +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +script = r""" +sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -c " +SELECT m.id, m.conversation_id, m.message_type, m.sender_type, left(m.content,50) AS content, m.created_at +FROM messages m +WHERE m.content LIKE '%特平尾%' OR m.conversation_id >= 4 +ORDER BY m.id DESC LIMIT 20; +" + +sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -c " +SELECT c.id, c.display_id, c.status, count(m.id) AS msgs +FROM conversations c +LEFT JOIN messages m ON m.conversation_id = c.id +GROUP BY c.id ORDER BY c.id DESC LIMIT 6; +" +""" + +_, o, e = c.exec_command(script, timeout=60) +sys.stdout.buffer.write(o.read()) +c.close() diff --git a/guest-cs-stack/_check_guest_chat.py b/guest-cs-stack/_check_guest_chat.py new file mode 100644 index 0000000..f372b2e --- /dev/null +++ b/guest-cs-stack/_check_guest_chat.py @@ -0,0 +1,22 @@ +import os +import sys +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) +cmds = [ + "sudo docker ps -a --filter name=guest-chat --format '{{.Status}}'", + "sudo docker logs --tail 40 guest-chat 2>&1", + "ss -tlnp | grep 7777 || true", + "curl -s -o /dev/null -w 'guest:%{http_code}\\n' http://127.0.0.1:7777/guest-customer-service || echo curl_fail", +] +for cmd in cmds: + print("====", cmd) + _, o, e = c.exec_command(cmd, timeout=60) + print(o.read().decode("utf-8", "replace")) + err = e.read().decode("utf-8", "replace") + if err: + print("ERR", err[:500]) +c.close() diff --git a/guest-cs-stack/_check_js_regex.py b/guest-cs-stack/_check_js_regex.py new file mode 100644 index 0000000..b8d9a97 --- /dev/null +++ b/guest-cs-stack/_check_js_regex.py @@ -0,0 +1,54 @@ +import ast +import pathlib +import re +import subprocess +import tempfile + +text = pathlib.Path(r"F:/AI/guest-cs-stack/flask/chatwoot_proxy.py").read_text(encoding="utf-8") +mod = ast.parse(text) +found = None +for node in ast.walk(mod): + if isinstance(node, ast.Constant) and isinstance(node.value, str) and "polishQuickQuestions" in node.value: + found = node.value + break + +# strip script tags, keep JS +js = found +js = re.sub(r"]*>", "\n", js, flags=re.I) +# remove style blocks if any +js = re.sub(r"", "\n", js, flags=re.I) +# remove remaining HTML-ish lines starting with < +lines = [] +for line in js.splitlines(): + s = line.strip() + if s.startswith("<") and not s.startswith(">" if i + 1 == ln else " " + print(f"{mark}{i+1}: {all_lines[i]}") diff --git a/guest-cs-stack/_check_maxkb_link.py b/guest-cs-stack/_check_maxkb_link.py new file mode 100644 index 0000000..ed47088 --- /dev/null +++ b/guest-cs-stack/_check_maxkb_link.py @@ -0,0 +1,51 @@ +import os +import sys +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +script = r""" +echo '=== env ===' +sudo docker exec guest-chat printenv MAXKB_BASE_URL MAXKB_ACCESS_TOKEN MAXKB_BRIDGE_ENABLED CHATWOOT_AGENT_BOT_TOKEN + +echo '=== agent bot webhook ===' +sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -t -A -c "SELECT name, outgoing_url FROM agent_bots WHERE id=1;" + +echo '=== maxkb auth+open+chat test ===' +sudo docker exec guest-chat python3 - <<'PY' +import os, json, urllib.request, sys +sys.path.insert(0, '/opt/guest-cs') +base = os.environ.get('MAXKB_BASE_URL', '') +tok = os.environ.get('MAXKB_ACCESS_TOKEN', '') +print('MAXKB', base) +try: + req = urllib.request.Request(base + '/chat/api/auth/anonymous', data=json.dumps({'access_token': tok}).encode(), headers={'Content-Type': 'application/json'}, method='POST') + with urllib.request.urlopen(req, timeout=25) as r: + body = json.loads(r.read().decode()) + print('auth', body.get('code'), str(body.get('data', ''))[:40] + '...') + jwt = body['data'] + req2 = urllib.request.Request(base + '/chat/api/open', headers={'Authorization': 'Bearer ' + jwt}) + with urllib.request.urlopen(req2, timeout=25) as r: + open_body = json.loads(r.read().decode()) + chat_id = open_body['data'] + print('open chat_id', chat_id) + req3 = urllib.request.Request(base + '/chat/api/chat_message/' + chat_id, data=json.dumps({'message': '你好', 'stream': False, 're_chat': False, 'form_data': {'username': 'test'}}).encode(), headers={'Authorization': 'Bearer ' + jwt, 'Content-Type': 'application/json'}, method='POST') + with urllib.request.urlopen(req3, timeout=120) as r: + ans = json.loads(r.read().decode()) + content = (ans.get('data') or {}).get('content', '')[:120] + print('chat reply', ans.get('code'), content) +except Exception as e: + print('FAIL', e) +PY + +echo '=== recent bridge logs ===' +sudo docker logs guest-chat 2>&1 | grep -iE 'MaxKB|bridge|webhook/bot1' | tail -8 +""" + +_, o, e = c.exec_command(script, timeout=180) +sys.stdout.buffer.write(o.read()) +sys.stdout.buffer.write(e.read()) +c.close() diff --git a/guest-cs-stack/_check_mi.py b/guest-cs-stack/_check_mi.py new file mode 100644 index 0000000..4e3408e --- /dev/null +++ b/guest-cs-stack/_check_mi.py @@ -0,0 +1,20 @@ +import os +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +cmds = [ + "curl -s -H Metadata:true 'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https://management.azure.com/' -d 'grant_type=client_credentials' 2>&1 | head -c 300", + "curl -s -H Metadata:true 'http://169.254.169.254/metadata/instance/compute/subscriptionId?api-version=2021-02-01&format=text'", +] + +for cmd in cmds: + print("===", cmd[:80]) + _, o, e = c.exec_command(cmd, timeout=20) + print(o.read().decode()[:500]) + print(e.read().decode()[:200]) + +c.close() diff --git a/guest-cs-stack/_check_nsg.py b/guest-cs-stack/_check_nsg.py new file mode 100644 index 0000000..f51a457 --- /dev/null +++ b/guest-cs-stack/_check_nsg.py @@ -0,0 +1,43 @@ +import os +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +script = r""" +python3 <<'PY' +import json, urllib.request + +def meta(path): + req = urllib.request.Request( + f"http://169.254.169.254{path}", + headers={"Metadata": "true"}, + ) + with urllib.request.urlopen(req, timeout=5) as r: + return r.read().decode() + +try: + tok = json.loads(meta( + "/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fmanagement.azure.com%2F" + )) + print("MI_TOKEN", "yes" if tok.get("access_token") else "no") +except Exception as e: + print("MI_TOKEN", "no", e) + +inst = json.loads(meta("/metadata/instance?api-version=2021-02-01")) +comp = inst["compute"] +net = inst["network"]["interface"][0] +print("VM", comp["name"]) +print("RG", comp["resourceGroupName"]) +print("LOC", comp["location"]) +print("PUBIP", net["ipv4"]["ipAddress"][0].get("publicIpAddress", "")) +PY +curl -sI http://127.0.0.1:7777/guest-customer-service | grep -i server +""" + +_, o, e = c.exec_command(script, timeout=30) +print(o.read().decode()) +print(e.read().decode()) +c.close() diff --git a/guest-cs-stack/_check_ready.py b/guest-cs-stack/_check_ready.py new file mode 100644 index 0000000..a8c80b8 --- /dev/null +++ b/guest-cs-stack/_check_ready.py @@ -0,0 +1,25 @@ +import os +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +cmds = [ + "hostname -I", + "curl -s http://127.0.0.1:7777/guest-customer-service | grep CHATWOOT_CONFIG", + "sudo docker exec guest-chat printenv CHATWOOT_GUEST_BASE_URL FRONTEND_URL", + "curl -s -H Metadata:true 'http://169.254.169.254/metadata/instance/compute?api-version=2021-02-01&format=text' 2>/dev/null | head -20", + "command -v az || echo no-az", +] + +for cmd in cmds: + print("===", cmd) + _, o, e = c.exec_command(cmd, timeout=30) + print(o.read().decode()[:800]) + err = e.read().decode() + if err.strip(): + print("ERR:", err[:200]) + +c.close() diff --git a/guest-cs-stack/_check_remote.py b/guest-cs-stack/_check_remote.py new file mode 100644 index 0000000..9edcd59 --- /dev/null +++ b/guest-cs-stack/_check_remote.py @@ -0,0 +1,19 @@ +import os, paramiko +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) +cmds = [ + "sudo docker ps -a", + "ss -tlnp | grep 7777 || true", + "curl -sI --max-time 20 http://127.0.0.1:7777/guest-customer-service | head -12", + "sudo docker logs guest-chat --tail 50 2>&1", +] +for cmd in cmds: + print("===", cmd) + _, o, e = c.exec_command(cmd, timeout=120) + print(o.read().decode()) + err = e.read().decode() + if err: + print("ERR:", err[:800]) +c.close() diff --git a/guest-cs-stack/_check_remote2.py b/guest-cs-stack/_check_remote2.py new file mode 100644 index 0000000..aaf91c0 --- /dev/null +++ b/guest-cs-stack/_check_remote2.py @@ -0,0 +1,19 @@ +import os, paramiko +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) +cmds = [ + "sudo ufw status 2>/dev/null || echo ufw_n/a", + "sudo iptables -L INPUT -n 2>/dev/null | head -15 || true", + "curl -s http://127.0.0.1:7777/guest-customer-service | grep -o 'guest_new_path[^,]*' | head -1", + "sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -c 'SELECT count(*) AS accounts FROM accounts;'", +] +for cmd in cmds: + print("===", cmd) + _, o, e = c.exec_command(cmd, timeout=60) + print(o.read().decode()) + err = e.read().decode() + if err: + print("ERR:", err[:400]) +c.close() diff --git a/guest-cs-stack/_check_widget.py b/guest-cs-stack/_check_widget.py new file mode 100644 index 0000000..ebbcace --- /dev/null +++ b/guest-cs-stack/_check_widget.py @@ -0,0 +1,15 @@ +import os +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) +cmd = r"""sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -c " +SELECT id, pre_chat_form_enabled, feature_flags FROM channel_web_widgets; +SELECT m.id, m.conversation_id, m.message_type, left(m.content,60), m.created_at FROM messages ORDER BY id DESC LIMIT 5; +" +""" +_, o, _ = c.exec_command(cmd, timeout=30) +print(o.read().decode(errors='replace')) +c.close() diff --git a/guest-cs-stack/_css_compare/guest_guestchat.html b/guest-cs-stack/_css_compare/guest_guestchat.html new file mode 100644 index 0000000..0ed82c7 --- /dev/null +++ b/guest-cs-stack/_css_compare/guest_guestchat.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}游客客服|{{ app_name }}{% endblock %} +{% block body_class %}guest-page{% endblock %} + +{% block body %} +
+
+
游客客服中心
+
Guest Customer Service
+
+ 返回登录页 +
+ +
+
+
🎧
+ 游客模式 +

欢迎使用在线客服

+

您目前以游客身份进入,可咨询注册、登录、活动与平台操作问题。

+ +
+ 游客模式不会识别会员身份,也不能查询充值、提款、余额或其他会员资料。 +
+ + +
+
+{% endblock %} + +{% block integrations %} +{% include "_chatwoot.html" %} +{% endblock %} diff --git a/guest-cs-stack/_css_compare/guest_guestcs.html b/guest-cs-stack/_css_compare/guest_guestcs.html new file mode 100644 index 0000000..0ed82c7 --- /dev/null +++ b/guest-cs-stack/_css_compare/guest_guestcs.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}游客客服|{{ app_name }}{% endblock %} +{% block body_class %}guest-page{% endblock %} + +{% block body %} +
+
+
游客客服中心
+
Guest Customer Service
+
+ 返回登录页 +
+ +
+
+
🎧
+ 游客模式 +

欢迎使用在线客服

+

您目前以游客身份进入,可咨询注册、登录、活动与平台操作问题。

+ +
+ 游客模式不会识别会员身份,也不能查询充值、提款、余额或其他会员资料。 +
+ + +
+
+{% endblock %} + +{% block integrations %} +{% include "_chatwoot.html" %} +{% endblock %} diff --git a/guest-cs-stack/_css_compare/home_guestchat.html b/guest-cs-stack/_css_compare/home_guestchat.html new file mode 100644 index 0000000..c27c5f1 --- /dev/null +++ b/guest-cs-stack/_css_compare/home_guestchat.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% block title %}遊戲首頁|{{ app_name }}{% endblock %} +{% block body %} +
+
+
YD GAME DEMO
+
Python 唯讀 API 測試站點
+
+
+ 用戶:{{ account.raw_username or username }} + + {{ "已凍結" if account.status == "locked" else "正常" }} + +
+ + +
+
+
+ +
+ {% if warning %} +
帳戶資料讀取失敗:{{ warning }}
+ {% endif %} + + {% if account.status == "locked" %} +
+ 帳戶已凍結 + {% if account.locked_reason %}|凍結原因:{{ account.locked_reason }}{% endif %} +
+ {% endif %} + +
+
+ PYTHON TEST ENVIRONMENT +

歡迎進入遊戲站點模擬首頁

+

登入會員:{{ account.username_clean or username }}。本頁只用於驗證會員資料、充值、提款與客服 API。

+
+ + 🎧 + 在線客服查詢充值與提款資料 + +
+ + + +
+

熱門遊戲

畫面模擬,不執行真實投注
+
+ {% for game in games %} +
+
{{ game.icon }}
+

{{ game.name }}

{{ game.category }}

+ +
+ {% endfor %} +
+
+
+🎧 客服 +{% endblock %} + +{% block integrations %} +{% include "_chatwoot.html" %} +{% endblock %} diff --git a/guest-cs-stack/_css_compare/home_guestcs.html b/guest-cs-stack/_css_compare/home_guestcs.html new file mode 100644 index 0000000..c27c5f1 --- /dev/null +++ b/guest-cs-stack/_css_compare/home_guestcs.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% block title %}遊戲首頁|{{ app_name }}{% endblock %} +{% block body %} +
+
+
YD GAME DEMO
+
Python 唯讀 API 測試站點
+
+
+ 用戶:{{ account.raw_username or username }} + + {{ "已凍結" if account.status == "locked" else "正常" }} + +
+ + +
+
+
+ +
+ {% if warning %} +
帳戶資料讀取失敗:{{ warning }}
+ {% endif %} + + {% if account.status == "locked" %} +
+ 帳戶已凍結 + {% if account.locked_reason %}|凍結原因:{{ account.locked_reason }}{% endif %} +
+ {% endif %} + +
+
+ PYTHON TEST ENVIRONMENT +

歡迎進入遊戲站點模擬首頁

+

登入會員:{{ account.username_clean or username }}。本頁只用於驗證會員資料、充值、提款與客服 API。

+
+ + 🎧 + 在線客服查詢充值與提款資料 + +
+ + + +
+

熱門遊戲

畫面模擬,不執行真實投注
+
+ {% for game in games %} +
+
{{ game.icon }}
+

{{ game.name }}

{{ game.category }}

+ +
+ {% endfor %} +
+
+
+🎧 客服 +{% endblock %} + +{% block integrations %} +{% include "_chatwoot.html" %} +{% endblock %} diff --git a/guest-cs-stack/_css_compare/style_guestchat.css b/guest-cs-stack/_css_compare/style_guestchat.css new file mode 100644 index 0000000..42d5015 --- /dev/null +++ b/guest-cs-stack/_css_compare/style_guestchat.css @@ -0,0 +1,172 @@ +:root{--bg:#090d1a;--panel:#11182b;--panel2:#19233c;--text:#f5f7ff;--muted:#9aa7c5;--accent:#ffcc4d;--accent2:#7b61ff;--danger:#ff6577;--success:#50d890;--border:rgba(255,255,255,.09)} +*{box-sizing:border-box}body{margin:0;min-height:100vh;font-family:Inter,"Noto Sans TC","Microsoft JhengHei",sans-serif;color:var(--text);background:radial-gradient(circle at top right,rgba(123,97,255,.20),transparent 34%),radial-gradient(circle at bottom left,rgba(255,204,77,.10),transparent 30%),var(--bg)}a{color:inherit}.login-body{display:grid;place-items:center;padding:24px}.login-card{width:min(440px,100%);padding:36px;background:rgba(17,24,43,.94);border:1px solid var(--border);border-radius:24px;box-shadow:0 24px 80px rgba(0,0,0,.38)}.brand-mark{display:grid;place-items:center;width:58px;height:58px;border-radius:18px;background:linear-gradient(135deg,var(--accent),#ff8a4d);color:#1b1520;font-weight:900;letter-spacing:.08em}h1,h2,h3,p{margin-top:0}.muted,.site-subtitle,.section-heading span{color:var(--muted)}label{display:block;margin:24px 0 8px;font-weight:700}input{width:100%;padding:14px 16px;color:var(--text);background:#0c1222;border:1px solid var(--border);border-radius:12px;outline:none}input:focus{border-color:var(--accent2)}.primary-button,.game-card button{width:100%;margin-top:16px;padding:14px 18px;border:0;border-radius:12px;font-weight:800}.primary-button,.primary-link{color:#17120a;background:var(--accent);cursor:pointer}.primary-link{display:inline-block;padding:12px 16px;border-radius:12px;text-decoration:none;font-weight:800}.security-note{margin:18px 0 0;color:var(--muted);font-size:13px}.topbar{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:18px clamp(18px,4vw,56px);background:rgba(9,13,26,.88);border-bottom:1px solid var(--border);backdrop-filter:blur(16px);position:sticky;top:0;z-index:10}.site-title{font-weight:900;letter-spacing:.08em}.site-subtitle{margin-top:3px;font-size:13px}.member-area{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:flex-end}.member-area form{margin:0}.logout-button,.back-link{padding:9px 13px;border:1px solid var(--border);border-radius:10px;color:var(--text);background:transparent;text-decoration:none;cursor:pointer}.status-badge{padding:5px 9px;border-radius:999px;font-size:12px;font-weight:800}.status-badge.normal{color:var(--success);background:rgba(80,216,144,.12)}.status-badge.locked{color:var(--danger);background:rgba(255,101,119,.12)}.page{width:min(1180px,calc(100% - 32px));margin:0 auto;padding:40px 0 72px}.page.narrow{width:min(820px,calc(100% - 32px))}.hero{display:grid;grid-template-columns:1fr auto;align-items:center;gap:24px;min-height:280px;padding:clamp(28px,5vw,60px);margin-bottom:24px;border:1px solid var(--border);border-radius:28px;background:linear-gradient(135deg,rgba(123,97,255,.26),rgba(17,24,43,.94)),var(--panel)}.hero h1{max-width:720px;font-size:clamp(30px,5vw,56px);line-height:1.08}.hero p{max-width:720px;color:var(--muted);font-size:17px}.eyebrow{color:var(--accent);font-size:12px;font-weight:900;letter-spacing:.16em}.service-entry{display:flex;gap:14px;align-items:center;min-width:230px;padding:18px;border:1px solid rgba(255,204,77,.35);border-radius:18px;background:rgba(255,204,77,.10);text-decoration:none}.service-entry small{display:block;margin-top:4px;color:var(--muted)}.service-icon{font-size:34px}.account-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:34px}.metric-card{padding:18px;border:1px solid var(--border);border-radius:16px;background:var(--panel)}.metric-card span{display:block;color:var(--muted);font-size:13px;margin-bottom:8px}.metric-card strong{font-size:20px}.section-heading{display:flex;align-items:end;justify-content:space-between;gap:18px;margin:0 4px 16px}.game-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.game-card{display:grid;grid-template-columns:auto 1fr;gap:16px;padding:22px;border:1px solid var(--border);border-radius:20px;background:var(--panel)}.game-card h3{margin-bottom:6px}.game-card p{margin-bottom:0;color:var(--muted)}.game-icon{font-size:38px}.game-card button{grid-column:1/-1;color:var(--muted);background:var(--panel2);cursor:not-allowed}.floating-service{position:fixed;right:22px;bottom:22px;padding:14px 18px;border-radius:999px;color:#17120a;background:var(--accent);font-weight:900;text-decoration:none;box-shadow:0 16px 36px rgba(0,0,0,.35)}.alert{padding:14px 16px;margin-bottom:18px;border-radius:12px}.alert-error{color:#ffdfe3;background:rgba(255,101,119,.14);border:1px solid rgba(255,101,119,.28)}.alert-warning{color:#fff2c5;background:rgba(255,204,77,.12);border:1px solid rgba(255,204,77,.28)}.support-panel{padding:34px;border:1px solid var(--border);border-radius:24px;background:var(--panel)}.support-avatar{font-size:48px}.empty-state{padding:24px;color:var(--muted);background:var(--panel2);border-radius:14px}.order-list{display:grid;gap:12px}.order-card{display:grid;gap:7px;padding:16px;border:1px solid var(--border);border-radius:14px;background:var(--panel2)}.order-card span{color:var(--muted)}.section-gap{margin-top:30px}@media(max-width:800px){.hero{grid-template-columns:1fr}.game-grid{grid-template-columns:1fr 1fr}.account-grid{grid-template-columns:1fr 1fr}.topbar{align-items:flex-start}}@media(max-width:560px){.game-grid,.account-grid{grid-template-columns:1fr}.topbar{flex-direction:column}.member-area{justify-content:flex-start}.login-card{padding:26px}} + + +.login-divider { + display: flex; + align-items: center; + gap: 12px; + margin: 20px 0; + color: var(--muted); +} +.login-divider::before, +.login-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border); +} +.guest-service-button { + display: block; + padding: 14px 18px; + border: 1px solid rgba(255, 204, 77, .35); + border-radius: 12px; + color: var(--accent); + background: rgba(255, 204, 77, .08); + text-align: center; + text-decoration: none; + font-weight: 800; +} +.guest-service-note { + margin-top: 12px; + color: var(--muted); + font-size: 13px; + line-height: 1.6; +} +.guest-support-panel { + text-align: center; +} +.guest-badge { + display: inline-block; + margin: 6px 0 18px; + padding: 6px 10px; + border-radius: 999px; + color: var(--accent); + background: rgba(255, 204, 77, .10); + font-size: 12px; + font-weight: 800; +} +.guest-open-chat { + max-width: 320px; + margin-left: auto; + margin-right: auto; +} + +.guest-page { + color: #111; + background: #fff; +} +.guest-page .woot-widget-holder, +.guest-page .woot-widget-holder.woot-elements--right { + width: 400px !important; + height: min(82vh, 760px) !important; + max-height: 82vh !important; +} +.guest-page .woot-widget-holder iframe, +.guest-page .woot-widget-holder .woot-widget-box { + width: 100% !important; + height: 100% !important; + min-height: min(82vh, 760px) !important; +} +.guest-page .woot-widget-bubble { + background: #e31c23 !important; +} +.guest-page .woot-widget-bubble:hover { + background: #c4161c !important; +} +.guest-page .topbar { + color: #111; + background: #fff; + border-bottom: 1px solid #e6e6e6; +} +.guest-page .site-subtitle { + color: #555; +} +.guest-page .back-link { + color: #111; + border-color: #d8d8d8; + background: #fff; +} +.guest-page .support-panel { + color: #111; + background: #fff; + border: 1px solid #e6e6e6; +} +.guest-page .support-panel p { + color: #333; +} +.guest-page .guest-badge { + color: #111; + background: #f3f3f3; +} +.guest-page .alert-warning { + color: #111; + background: #f7f7f7; + border: 1px solid #e0e0e0; +} +.guest-page .primary-button { + color: #111; + background: #ffcc4d; +} + +/* 登录页:白底黑字 */ +.login-body { + color: #111; + background: #fff; +} +.login-body .login-card { + color: #111; + background: #fff; + border: 1px solid #e6e6e6; + box-shadow: 0 12px 36px rgba(0, 0, 0, .08); +} +.login-body .brand-mark { + color: #111; + background: #ffcc4d; +} +.login-body h1 { + color: #111; +} +.login-body .muted, +.login-body .guest-service-note, +.login-body .security-note, +.login-body .login-divider { + color: #555; +} +.login-body .login-divider::before, +.login-body .login-divider::after { + background: #e6e6e6; +} +.login-body label { + color: #111; +} +.login-body input { + color: #111; + background: #fff; + border: 1px solid #d8d8d8; +} +.login-body input:focus { + border-color: #ffcc4d; +} +.login-body .primary-button { + color: #111; + background: #ffcc4d; +} +.login-body .guest-service-button { + color: #111; + border-color: #d8d8d8; + background: #f7f7f7; +} +.login-body .guest-service-button:hover { + background: #f0f0f0; +} +.login-body .alert-error { + color: #111; + background: #fff1f2; + border: 1px solid #ffc9d0; +} +.login-body .alert-warning { + color: #111; + background: #f7f7f7; + border: 1px solid #e0e0e0; +} diff --git a/guest-cs-stack/_css_compare/style_guestcs.css b/guest-cs-stack/_css_compare/style_guestcs.css new file mode 100644 index 0000000..42d5015 --- /dev/null +++ b/guest-cs-stack/_css_compare/style_guestcs.css @@ -0,0 +1,172 @@ +:root{--bg:#090d1a;--panel:#11182b;--panel2:#19233c;--text:#f5f7ff;--muted:#9aa7c5;--accent:#ffcc4d;--accent2:#7b61ff;--danger:#ff6577;--success:#50d890;--border:rgba(255,255,255,.09)} +*{box-sizing:border-box}body{margin:0;min-height:100vh;font-family:Inter,"Noto Sans TC","Microsoft JhengHei",sans-serif;color:var(--text);background:radial-gradient(circle at top right,rgba(123,97,255,.20),transparent 34%),radial-gradient(circle at bottom left,rgba(255,204,77,.10),transparent 30%),var(--bg)}a{color:inherit}.login-body{display:grid;place-items:center;padding:24px}.login-card{width:min(440px,100%);padding:36px;background:rgba(17,24,43,.94);border:1px solid var(--border);border-radius:24px;box-shadow:0 24px 80px rgba(0,0,0,.38)}.brand-mark{display:grid;place-items:center;width:58px;height:58px;border-radius:18px;background:linear-gradient(135deg,var(--accent),#ff8a4d);color:#1b1520;font-weight:900;letter-spacing:.08em}h1,h2,h3,p{margin-top:0}.muted,.site-subtitle,.section-heading span{color:var(--muted)}label{display:block;margin:24px 0 8px;font-weight:700}input{width:100%;padding:14px 16px;color:var(--text);background:#0c1222;border:1px solid var(--border);border-radius:12px;outline:none}input:focus{border-color:var(--accent2)}.primary-button,.game-card button{width:100%;margin-top:16px;padding:14px 18px;border:0;border-radius:12px;font-weight:800}.primary-button,.primary-link{color:#17120a;background:var(--accent);cursor:pointer}.primary-link{display:inline-block;padding:12px 16px;border-radius:12px;text-decoration:none;font-weight:800}.security-note{margin:18px 0 0;color:var(--muted);font-size:13px}.topbar{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:18px clamp(18px,4vw,56px);background:rgba(9,13,26,.88);border-bottom:1px solid var(--border);backdrop-filter:blur(16px);position:sticky;top:0;z-index:10}.site-title{font-weight:900;letter-spacing:.08em}.site-subtitle{margin-top:3px;font-size:13px}.member-area{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:flex-end}.member-area form{margin:0}.logout-button,.back-link{padding:9px 13px;border:1px solid var(--border);border-radius:10px;color:var(--text);background:transparent;text-decoration:none;cursor:pointer}.status-badge{padding:5px 9px;border-radius:999px;font-size:12px;font-weight:800}.status-badge.normal{color:var(--success);background:rgba(80,216,144,.12)}.status-badge.locked{color:var(--danger);background:rgba(255,101,119,.12)}.page{width:min(1180px,calc(100% - 32px));margin:0 auto;padding:40px 0 72px}.page.narrow{width:min(820px,calc(100% - 32px))}.hero{display:grid;grid-template-columns:1fr auto;align-items:center;gap:24px;min-height:280px;padding:clamp(28px,5vw,60px);margin-bottom:24px;border:1px solid var(--border);border-radius:28px;background:linear-gradient(135deg,rgba(123,97,255,.26),rgba(17,24,43,.94)),var(--panel)}.hero h1{max-width:720px;font-size:clamp(30px,5vw,56px);line-height:1.08}.hero p{max-width:720px;color:var(--muted);font-size:17px}.eyebrow{color:var(--accent);font-size:12px;font-weight:900;letter-spacing:.16em}.service-entry{display:flex;gap:14px;align-items:center;min-width:230px;padding:18px;border:1px solid rgba(255,204,77,.35);border-radius:18px;background:rgba(255,204,77,.10);text-decoration:none}.service-entry small{display:block;margin-top:4px;color:var(--muted)}.service-icon{font-size:34px}.account-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:34px}.metric-card{padding:18px;border:1px solid var(--border);border-radius:16px;background:var(--panel)}.metric-card span{display:block;color:var(--muted);font-size:13px;margin-bottom:8px}.metric-card strong{font-size:20px}.section-heading{display:flex;align-items:end;justify-content:space-between;gap:18px;margin:0 4px 16px}.game-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.game-card{display:grid;grid-template-columns:auto 1fr;gap:16px;padding:22px;border:1px solid var(--border);border-radius:20px;background:var(--panel)}.game-card h3{margin-bottom:6px}.game-card p{margin-bottom:0;color:var(--muted)}.game-icon{font-size:38px}.game-card button{grid-column:1/-1;color:var(--muted);background:var(--panel2);cursor:not-allowed}.floating-service{position:fixed;right:22px;bottom:22px;padding:14px 18px;border-radius:999px;color:#17120a;background:var(--accent);font-weight:900;text-decoration:none;box-shadow:0 16px 36px rgba(0,0,0,.35)}.alert{padding:14px 16px;margin-bottom:18px;border-radius:12px}.alert-error{color:#ffdfe3;background:rgba(255,101,119,.14);border:1px solid rgba(255,101,119,.28)}.alert-warning{color:#fff2c5;background:rgba(255,204,77,.12);border:1px solid rgba(255,204,77,.28)}.support-panel{padding:34px;border:1px solid var(--border);border-radius:24px;background:var(--panel)}.support-avatar{font-size:48px}.empty-state{padding:24px;color:var(--muted);background:var(--panel2);border-radius:14px}.order-list{display:grid;gap:12px}.order-card{display:grid;gap:7px;padding:16px;border:1px solid var(--border);border-radius:14px;background:var(--panel2)}.order-card span{color:var(--muted)}.section-gap{margin-top:30px}@media(max-width:800px){.hero{grid-template-columns:1fr}.game-grid{grid-template-columns:1fr 1fr}.account-grid{grid-template-columns:1fr 1fr}.topbar{align-items:flex-start}}@media(max-width:560px){.game-grid,.account-grid{grid-template-columns:1fr}.topbar{flex-direction:column}.member-area{justify-content:flex-start}.login-card{padding:26px}} + + +.login-divider { + display: flex; + align-items: center; + gap: 12px; + margin: 20px 0; + color: var(--muted); +} +.login-divider::before, +.login-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border); +} +.guest-service-button { + display: block; + padding: 14px 18px; + border: 1px solid rgba(255, 204, 77, .35); + border-radius: 12px; + color: var(--accent); + background: rgba(255, 204, 77, .08); + text-align: center; + text-decoration: none; + font-weight: 800; +} +.guest-service-note { + margin-top: 12px; + color: var(--muted); + font-size: 13px; + line-height: 1.6; +} +.guest-support-panel { + text-align: center; +} +.guest-badge { + display: inline-block; + margin: 6px 0 18px; + padding: 6px 10px; + border-radius: 999px; + color: var(--accent); + background: rgba(255, 204, 77, .10); + font-size: 12px; + font-weight: 800; +} +.guest-open-chat { + max-width: 320px; + margin-left: auto; + margin-right: auto; +} + +.guest-page { + color: #111; + background: #fff; +} +.guest-page .woot-widget-holder, +.guest-page .woot-widget-holder.woot-elements--right { + width: 400px !important; + height: min(82vh, 760px) !important; + max-height: 82vh !important; +} +.guest-page .woot-widget-holder iframe, +.guest-page .woot-widget-holder .woot-widget-box { + width: 100% !important; + height: 100% !important; + min-height: min(82vh, 760px) !important; +} +.guest-page .woot-widget-bubble { + background: #e31c23 !important; +} +.guest-page .woot-widget-bubble:hover { + background: #c4161c !important; +} +.guest-page .topbar { + color: #111; + background: #fff; + border-bottom: 1px solid #e6e6e6; +} +.guest-page .site-subtitle { + color: #555; +} +.guest-page .back-link { + color: #111; + border-color: #d8d8d8; + background: #fff; +} +.guest-page .support-panel { + color: #111; + background: #fff; + border: 1px solid #e6e6e6; +} +.guest-page .support-panel p { + color: #333; +} +.guest-page .guest-badge { + color: #111; + background: #f3f3f3; +} +.guest-page .alert-warning { + color: #111; + background: #f7f7f7; + border: 1px solid #e0e0e0; +} +.guest-page .primary-button { + color: #111; + background: #ffcc4d; +} + +/* 登录页:白底黑字 */ +.login-body { + color: #111; + background: #fff; +} +.login-body .login-card { + color: #111; + background: #fff; + border: 1px solid #e6e6e6; + box-shadow: 0 12px 36px rgba(0, 0, 0, .08); +} +.login-body .brand-mark { + color: #111; + background: #ffcc4d; +} +.login-body h1 { + color: #111; +} +.login-body .muted, +.login-body .guest-service-note, +.login-body .security-note, +.login-body .login-divider { + color: #555; +} +.login-body .login-divider::before, +.login-body .login-divider::after { + background: #e6e6e6; +} +.login-body label { + color: #111; +} +.login-body input { + color: #111; + background: #fff; + border: 1px solid #d8d8d8; +} +.login-body input:focus { + border-color: #ffcc4d; +} +.login-body .primary-button { + color: #111; + background: #ffcc4d; +} +.login-body .guest-service-button { + color: #111; + border-color: #d8d8d8; + background: #f7f7f7; +} +.login-body .guest-service-button:hover { + background: #f0f0f0; +} +.login-body .alert-error { + color: #111; + background: #fff1f2; + border: 1px solid #ffc9d0; +} +.login-body .alert-warning { + color: #111; + background: #f7f7f7; + border: 1px solid #e0e0e0; +} diff --git a/guest-cs-stack/_debug_guest_chat.py b/guest-cs-stack/_debug_guest_chat.py new file mode 100644 index 0000000..2f808d0 --- /dev/null +++ b/guest-cs-stack/_debug_guest_chat.py @@ -0,0 +1,26 @@ +import os +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +cmds = [ + "curl -s http://127.0.0.1:7777/guest-customer-service | grep -E 'guest_new_path|base_url|csrf' | head -3", + "curl -s -X POST http://127.0.0.1:7777/guest-chat/new -H 'Content-Type: application/json' -d '{}' -w '\\nHTTP:%{http_code}\\n'", + "sudo docker logs guest-chat --tail 80 2>&1", + "sudo docker exec guest-chat curl -sI http://127.0.0.1:3000 2>&1 | head -5", + "sudo docker exec guest-chat curl -s http://127.0.0.1:3000/api/v1/widget/conversations -X POST -H 'Content-Type: application/json' -d '{}' 2>&1 | head -c 300", +] + +for cmd in cmds: + print("===", cmd[:100]) + _, o, e = c.exec_command(cmd, timeout=60) + out = o.read().decode(errors="replace") + err = e.read().decode(errors="replace") + print(out[:2000]) + if err.strip(): + print("ERR:", err[:300]) + +c.close() diff --git a/guest-cs-stack/_debug_maxkb.py b/guest-cs-stack/_debug_maxkb.py new file mode 100644 index 0000000..35f58c8 --- /dev/null +++ b/guest-cs-stack/_debug_maxkb.py @@ -0,0 +1,51 @@ +import os +import sys +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +script = r""" +echo '=== bot webhook ===' +sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -t -A -c "SELECT outgoing_url FROM agent_bots LIMIT 1;" + +echo '=== maxkb env ===' +sudo docker exec guest-chat printenv MAXKB_BASE_URL MAXKB_ACCESS_TOKEN MAXKB_BRIDGE_ENABLED + +echo '=== maxkb reachability ===' +sudo docker exec guest-chat python3 - <<'PY' +import json, os, urllib.request +base=os.environ.get('MAXKB_BASE_URL','') +tok=os.environ.get('MAXKB_ACCESS_TOKEN','') +print('base', base) +try: + req=urllib.request.Request(base+'/chat/api/auth/anonymous', data=json.dumps({'access_token':tok}).encode(), headers={'Content-Type':'application/json'}, method='POST') + with urllib.request.urlopen(req, timeout=20) as r: + print('auth', r.status, r.read()[:200]) +except Exception as e: + print('auth_fail', e) +PY + +echo '=== recent guest-cs logs ===' +sudo docker logs guest-chat 2>&1 | grep -iE 'webhook|MaxKB|chatwoot|bridge|error|exception|502' | tail -30 + +echo '=== test webhook locally ===' +sudo docker exec guest-chat python3 - <<'PY' +import json, urllib.request +payload={"event":"message_created","message_type":"incoming","content":"123","conversation":{"id":1,"inbox_id":1,"contact_inbox":{"source_id":"test"}},"sender":{"id":1}} +req=urllib.request.Request('http://127.0.0.1:7788/webhook/bot1/chatwoot', data=json.dumps(payload).encode(), headers={'Content-Type':'application/json'}, method='POST') +try: + with urllib.request.urlopen(req, timeout=15) as r: + print('webhook', r.status, r.read()[:200]) +except Exception as e: + if hasattr(e,'read'): print('webhook_fail', e.read()[:500]) + else: print('webhook_fail', e) +PY +""" + +_, o, e = c.exec_command(script, timeout=120) +sys.stdout.buffer.write(o.read()) +sys.stdout.buffer.write(e.read()) +c.close() diff --git a/guest-cs-stack/_debug_maxkb2.py b/guest-cs-stack/_debug_maxkb2.py new file mode 100644 index 0000000..dd50b45 --- /dev/null +++ b/guest-cs-stack/_debug_maxkb2.py @@ -0,0 +1,45 @@ +import os +import sys +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +script = r""" +sudo docker exec guest-chat python3 -c " +import os, json, urllib.request, traceback +base=os.environ.get('MAXKB_BASE_URL','') +tok=os.environ.get('MAXKB_ACCESS_TOKEN','') +bot=os.environ.get('CHATWOOT_AGENT_BOT_TOKEN','') +print('MAXKB_BASE_URL', base) +print('BOT_TOKEN', bot[:8]+'...' if bot else 'EMPTY') +try: + req=urllib.request.Request(base+'/chat/api/auth/anonymous', data=json.dumps({'access_token':tok}).encode(), headers={'Content-Type':'application/json'}, method='POST') + with urllib.request.urlopen(req, timeout=25) as r: + body=r.read().decode() + print('maxkb_auth_ok', r.status, body[:250]) +except Exception as e: + print('maxkb_auth_err', repr(e)) + traceback.print_exc() + +payload={'event':'message_created','message_type':0,'content':'123','conversation':{'id':999,'account_id':1,'inbox_id':1},'account':{'id':1},'sender':{'id':1,'name':'test'}} +try: + req=urllib.request.Request('http://127.0.0.1:7788/webhook/bot1/chatwoot', data=json.dumps(payload).encode(), headers={'Content-Type':'application/json'}, method='POST') + with urllib.request.urlopen(req, timeout=20) as r: + print('webhook_ok', r.status, r.read()[:200]) +except Exception as e: + if hasattr(e,'read'): print('webhook_err_body', e.read()[:500]) + print('webhook_err', repr(e)) +" + +sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -t -A -c "SELECT access_token FROM agent_bots WHERE id=1;" + +sudo docker logs guest-chat 2>&1 | grep -iE 'MaxKB|bridge|webhook|Chatwoot.*回覆|agent_bot' | tail -20 +""" + +_, o, e = c.exec_command(script, timeout=120) +sys.stdout.buffer.write(o.read()) +sys.stdout.buffer.write(e.read()) +c.close() diff --git a/guest-cs-stack/_debug_stuck.py b/guest-cs-stack/_debug_stuck.py new file mode 100644 index 0000000..282383e --- /dev/null +++ b/guest-cs-stack/_debug_stuck.py @@ -0,0 +1,59 @@ +import os +import sys +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +script = r""" +echo '=== latest messages ===' +sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -c "SELECT id, message_type, left(content,40) AS content, created_at FROM messages ORDER BY id DESC LIMIT 8;" + +echo '=== bridge logs (last 25) ===' +sudo docker logs guest-chat 2>&1 | grep -iE 'MaxKB|bridge|401|failed|reply_len|exception|特平|webhook/bot1' | tail -25 + +echo '=== test maxkb ask ===' +sudo docker exec guest-chat python3 - <<'PY' +import os, json, urllib.request, sys, time +sys.path.insert(0,'/opt/guest-cs') +base=os.environ['MAXKB_BASE_URL'] +tok=os.environ['MAXKB_ACCESS_TOKEN'] +t0=time.time() +req=urllib.request.Request(base+'/chat/api/auth/anonymous', data=json.dumps({'access_token':tok}).encode(), headers={'Content-Type':'application/json'}, method='POST') +with urllib.request.urlopen(req, timeout=30) as r: + jwt=json.loads(r.read())['data'] +req2=urllib.request.Request(base+'/chat/api/open', headers={'Authorization':'Bearer '+jwt}) +with urllib.request.urlopen(req2, timeout=30) as r: + cid=json.loads(r.read())['data'] +body={'message':'特平尾怎么赔?','stream':False,'re_chat':False,'form_data':{'username':'游客'}} +req3=urllib.request.Request(base+f'/chat/api/chat_message/{cid}', data=json.dumps(body).encode(), headers={'Authorization':'Bearer '+jwt,'Content-Type':'application/json'}, method='POST') +with urllib.request.urlopen(req3, timeout=120) as r: + ans=json.loads(r.read()) +print('elapsed', round(time.time()-t0,1), 's') +print('code', ans.get('code')) +data=ans.get('data') or {} +print('reply', (data.get('content') or '')[:200]) +PY + +echo '=== simulate webhook for user msg ===' +sudo docker exec guest-chat python3 - <<'PY' +import json, urllib.request, sys +sys.path.insert(0,'/opt/guest-cs') +import maxkb_chatwoot_bridge as m +# get latest incoming msg id from db would be ideal; use high id +p={'event':'conversation_updated','id':5,'account':{'id':1},'messages':[{'id':9999991,'content':'特平尾怎么赔?','message_type':0,'private':False,'conversation_id':5,'account_id':1,'sender':{'name':'游客'}}]} +print('extract', m._extract_incoming(p)) +req=urllib.request.Request('http://127.0.0.1:7788/webhook/bot1/chatwoot', data=json.dumps(p).encode(), headers={'Content-Type':'application/json'}, method='POST') +with urllib.request.urlopen(req, timeout=180) as r: + print('webhook', r.status, r.read()[:300]) +PY +sleep 3 +sudo docker logs guest-chat 2>&1 | grep -iE 'bridge|MaxKB|401|reply_len|failed' | tail -8 +""" + +_, o, e = c.exec_command(script, timeout=240) +sys.stdout.buffer.write(o.read()) +sys.stdout.buffer.write(e.read()) +c.close() diff --git a/guest-cs-stack/_debug_webhook.py b/guest-cs-stack/_debug_webhook.py new file mode 100644 index 0000000..50f5800 --- /dev/null +++ b/guest-cs-stack/_debug_webhook.py @@ -0,0 +1,25 @@ +import os +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +cmds = [ + """sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -c "SELECT id,name,outgoing_url FROM agent_bots;" """, + """sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -c "SELECT id,status,content FROM messages ORDER BY id DESC LIMIT 5;" """, + "sudo docker logs guest-chat --tail 120 2>&1", + "sudo docker exec guest-chat printenv MAXKB_BASE_URL MAXKB_BRIDGE_ENABLED CHATWOOT_AGENT_BOT_TOKEN", +] + +for cmd in cmds: + print("===", cmd[:90]) + _, o, e = c.exec_command(cmd, timeout=90) + out = o.read().decode(errors="replace") + err = e.read().decode(errors="replace") + print(out[:4000]) + if err.strip(): + print("ERR:", err[:300]) + +c.close() diff --git a/guest-cs-stack/_deploy_proxy_fix.py b/guest-cs-stack/_deploy_proxy_fix.py new file mode 100644 index 0000000..8314e5c --- /dev/null +++ b/guest-cs-stack/_deploy_proxy_fix.py @@ -0,0 +1,43 @@ +"""Deploy fixed chatwoot_proxy.py (safe polishQuickQuestions) to guest-chat.""" +import os +import sys +from pathlib import Path + +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +local = Path(r"F:/AI/guest-cs-stack/flask/chatwoot_proxy.py") +remote_sh = "/home/wakka/_deploy_proxy_fix.sh" + +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +sftp = c.open_sftp() +with sftp.file("/home/wakka/chatwoot_proxy.py.patch", "wb") as rf: + rf.write(local.read_bytes().replace(b"\r\n", b"\n")) + +script = b"""#!/bin/bash +set -e +sudo docker cp /home/wakka/chatwoot_proxy.py.patch guest-chat:/opt/guest-cs/chatwoot_proxy.py +sudo docker restart guest-chat +sleep 14 +curl -s -o /dev/null -w "guest:%{http_code}\\n" http://127.0.0.1:7777/guest-customer-service +html=$(curl -s "http://127.0.0.1:7777/cw/widget?website_token=j1UAjqBgZMWQ8WfczgJSymQx" -H "Host: 20.191.152.26:7777") +echo "has_pullTags=$(printf '%s' "$html" | grep -c 'function pullTags' || true)" +echo "has_bad_escape=$(printf '%s' "$html" | grep -c 'tagOpen.replace' || true)" +echo "has_qq=$(printf '%s' "$html" | grep -c 'polishQuickQuestions' || true)" +printf '%s' "$html" | grep -n 'pullTags\\|data-yd-qq\\|PROMO_SRC' | head -20 +""" +with sftp.file(remote_sh, "wb") as rf: + rf.write(script) +sftp.close() + +_, o, e = c.exec_command(f"bash {remote_sh}", timeout=120) +sys.stdout.write(o.read().decode("utf-8", "replace")) +err = e.read().decode("utf-8", "replace") +if err: + sys.stderr.write(err) +code = o.channel.recv_exit_status() +c.close() +sys.exit(code) diff --git a/guest-cs-stack/_diag_stuck7777.py b/guest-cs-stack/_diag_stuck7777.py new file mode 100644 index 0000000..f2da558 --- /dev/null +++ b/guest-cs-stack/_diag_stuck7777.py @@ -0,0 +1,50 @@ +"""Diagnose remote 7777 stuck: guest-new + messages API + chatwoot health.""" +import os +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +script = r""" +set -e +echo '=== docker ps ===' +sudo docker ps --filter name=guest-chat --format 'table {{.Names}}\t{{.Status}}' + +echo '=== chatwoot rails :3000 ===' +curl -s -o /dev/null -w 'rails root: %{http_code}\n' http://127.0.0.1:3000/ 2>/dev/null || echo rails curl failed +sudo docker exec guest-chat bash -c 'curl -s -o /dev/null -w "internal rails: %{http_code}\n" http://127.0.0.1:3000/' 2>/dev/null || true + +echo '=== guest-chat/new ===' +rm -f /tmp/gc.jar /tmp/g.html +curl -s -c /tmp/gc.jar http://127.0.0.1:7777/guest-customer-service -o /tmp/g.html +csrf=$(python3 -c "import re; print(re.search(r'\"csrf_token\":\s*\"([^\"]+)\"', open('/tmp/g.html').read()).group(1))") +NEW=$(curl -s -b /tmp/gc.jar -X POST http://127.0.0.1:7777/guest-chat/new -H 'Content-Type: application/json' -H "X-CSRF-Token: $csrf" -d '{}') +echo "$NEW" | head -c 400 +echo +wt=$(python3 -c "import json,sys; d=json.loads(open('/dev/stdin').read()); print(d.get('widget_token',''))" <<< "$NEW") +echo widget_token_len=${#wt} + +echo '=== messages via flask proxy ===' +curl -s -o /dev/null -w 'proxy messages: %{http_code}\n' "http://127.0.0.1:7777/api/v1/widget/messages?website_token=j1UAjqBgZMWQ8WfczgJSymQx&cw_conversation=$wt" +curl -s "http://127.0.0.1:7777/api/v1/widget/messages?website_token=j1UAjqBgZMWQ8WfczgJSymQx&cw_conversation=$wt" | head -c 500 +echo + +echo '=== messages direct chatwoot ===' +curl -s -o /dev/null -w 'direct messages: %{http_code}\n' "http://127.0.0.1:3000/api/v1/widget/messages?website_token=j1UAjqBgZMWQ8WfczgJSymQx&cw_conversation=$wt" 2>/dev/null || echo direct failed + +echo '=== FRONTEND_URL ===' +sudo docker exec guest-chat printenv FRONTEND_URL 2>/dev/null || true +sudo docker exec guest-chat printenv CHATWOOT_GUEST_BASE_URL 2>/dev/null || true + +echo '=== recent logs ===' +sudo docker logs guest-chat --tail 25 2>&1 +""" + +_, o, e = c.exec_command(script, timeout=120) +print(o.read().decode()) +err = e.read().decode() +if err: + print("ERR:", err[:800]) +c.close() diff --git a/guest-cs-stack/_diag_wakka.py b/guest-cs-stack/_diag_wakka.py new file mode 100644 index 0000000..5f314fe --- /dev/null +++ b/guest-cs-stack/_diag_wakka.py @@ -0,0 +1,25 @@ +import os +import sys + +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +cmds = [ + "ls -la /home/wakka/guest-cs-stack/", + "cd /home/wakka/guest-cs-stack && sudo docker compose -f docker-compose.chat.yaml -f docker-compose.chat.test7777.yaml config 2>&1 | tail -30", + "sudo docker inspect guest-chat --format '{{.Created}} {{range .Config.Env}}{{println .}}{{end}}' | head -20", +] + +for cmd in cmds: + print("===", cmd[:80]) + _, o, e = c.exec_command(cmd, timeout=60) + print(o.read().decode()) + err = e.read().decode() + if err: + print("ERR:", err[:300]) + +c.close() diff --git a/guest-cs-stack/_find_ssh_key.py b/guest-cs-stack/_find_ssh_key.py new file mode 100644 index 0000000..0dad977 --- /dev/null +++ b/guest-cs-stack/_find_ssh_key.py @@ -0,0 +1,25 @@ +import os +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +cmds = [ + "ls -la ~/.ssh/ 2>/dev/null; cat ~/.ssh/id_ed25519.pub 2>/dev/null; cat ~/.ssh/id_rsa.pub 2>/dev/null", + "ssh -o BatchMode=yes -o ConnectTimeout=8 wakka@100.31.73.254 hostname 2>&1", + "ssh -o BatchMode=yes -o ConnectTimeout=8 root@100.31.73.254 hostname 2>&1", + "ssh -o BatchMode=yes -o ConnectTimeout=8 ubuntu@100.31.73.254 hostname 2>&1", + "for k in ~/.ssh/id_ed25519 ~/.ssh/id_rsa; do [ -f \"$k\" ] && ssh -i \"$k\" -o BatchMode=yes -o ConnectTimeout=8 root@100.31.73.254 hostname 2>&1; done", +] + +for cmd in cmds: + print("===", cmd[:90]) + _, o, e = c.exec_command(cmd, timeout=30) + print(o.read().decode()) + err = e.read().decode() + if err.strip(): + print("ERR:", err[:200]) + +c.close() diff --git a/guest-cs-stack/_fix_autoreply.py b/guest-cs-stack/_fix_autoreply.py new file mode 100644 index 0000000..4425c92 --- /dev/null +++ b/guest-cs-stack/_fix_autoreply.py @@ -0,0 +1,54 @@ +"""Apply auto-reply fix on wakka-test-server.""" +import os + +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +base = os.path.dirname(os.path.abspath(__file__)) +remote_stack = "/home/wakka/guest-cs-stack" +compose = "docker-compose.chat.yaml -f docker-compose.chat.test7777.yaml" + +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +uploads = [ + (os.path.join(base, "flask", "maxkb_chatwoot_bridge.py"), "/home/wakka/maxkb_chatwoot_bridge.py"), + (os.path.join(base, "flask", "app.py"), "/home/wakka/app.py"), + (os.path.join(base, "guest-cs.test.env"), f"{remote_stack}/guest-cs.test.env"), +] + +sftp = c.open_sftp() +for local, remote in uploads: + with open(local, "rb") as fh: + data = fh.read().replace(b"\r\n", b"\n") + with sftp.file(remote, "w") as rf: + rf.write(data) + print("uploaded", os.path.basename(local)) +sftp.close() + +cmd = f""" +set -e +sudo docker cp /home/wakka/maxkb_chatwoot_bridge.py guest-chat:/opt/guest-cs/maxkb_chatwoot_bridge.py +sudo docker cp /home/wakka/app.py guest-chat:/opt/guest-cs/app.py +cd {remote_stack} +sudo docker compose -f {compose} up -d --force-recreate guest-chat +sleep 40 +sudo docker exec guest-chat printenv CHATWOOT_AGENT_BOT_TOKEN +sudo docker exec guest-chat python3 -c " +import json, urllib.request +p={{'event':'conversation_updated','id':3,'account':{{'id':1}},'messages':[{{'id':888888,'content':'你好','message_type':0,'private':False,'conversation_id':3,'account_id':1,'sender':{{'name':'游客'}}}}]}} +req=urllib.request.Request('http://127.0.0.1:7788/webhook/bot1/chatwoot', data=json.dumps(p).encode(), headers={{'Content-Type':'application/json'}}, method='POST') +with urllib.request.urlopen(req, timeout=120) as r: + print('webhook', r.status, r.read()[:250]) +" +sudo docker logs guest-chat 2>&1 | grep -iE 'bridge|MaxKB|401|reply_len' | tail -6 +""" + +print(">>> apply fix") +_, o, e = c.exec_command(cmd, timeout=240) +print(o.read().decode(errors="replace")) +err = e.read().decode(errors="replace") +if err.strip(): + print(err) +c.close() diff --git a/guest-cs-stack/_fix_autoreply2.py b/guest-cs-stack/_fix_autoreply2.py new file mode 100644 index 0000000..48ecad2 --- /dev/null +++ b/guest-cs-stack/_fix_autoreply2.py @@ -0,0 +1,56 @@ +import os +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +base = os.path.dirname(os.path.abspath(__file__)) +remote_stack = "/home/wakka/guest-cs-stack" +compose = "docker-compose.chat.yaml -f docker-compose.chat.test7777.yaml" + +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +sftp = c.open_sftp() +for name in ["maxkb_chatwoot_bridge.py", "app.py"]: + with open(os.path.join(base, "flask", name), "rb") as fh: + data = fh.read().replace(b"\r\n", b"\n") + with sftp.file(f"/home/wakka/{name}", "w") as rf: + rf.write(data) +with open(os.path.join(base, "guest-cs.test.env"), "rb") as fh: + data = fh.read().replace(b"\r\n", b"\n") +with sftp.file(f"{remote_stack}/guest-cs.test.env", "w") as rf: + rf.write(data) +sftp.close() + +cmd = f""" +set -e +cd {remote_stack} +sudo docker compose -f {compose} up -d --force-recreate guest-chat +sleep 40 +sudo docker cp /home/wakka/maxkb_chatwoot_bridge.py guest-chat:/opt/guest-cs/maxkb_chatwoot_bridge.py +sudo docker cp /home/wakka/app.py guest-chat:/opt/guest-cs/app.py +# reload gunicorn workers +sudo docker exec guest-chat pkill -HUP gunicorn || sudo docker restart guest-chat +sleep 15 +sudo docker exec guest-chat printenv CHATWOOT_AGENT_BOT_TOKEN +sudo docker exec guest-chat python3 -c " +import json, urllib.request, importlib, maxkb_chatwoot_bridge as m +importlib.reload(m) +p={{'event':'conversation_updated','id':3,'account':{{'id':1}},'messages':[{{'id':777777,'content':'你好','message_type':0,'private':False,'conversation_id':3,'account_id':1,'sender':{{'name':'游客'}}}}]}} +print('extract', m._extract_incoming(p)) +" +sudo docker exec guest-chat python3 -c " +import json, urllib.request +p={{'event':'conversation_updated','id':3,'account':{{'id':1}},'messages':[{{'id':777776,'content':'你好','message_type':0,'private':False,'conversation_id':3,'account_id':1,'sender':{{'name':'游客'}}}}]}} +req=urllib.request.Request('http://127.0.0.1:7788/webhook/bot1/chatwoot', data=json.dumps(p).encode(), headers={{'Content-Type':'application/json'}}, method='POST') +with urllib.request.urlopen(req, timeout=120) as r: + print('webhook', r.status, r.read()[:300]) +" +sleep 5 +sudo docker logs guest-chat 2>&1 | grep -iE 'bridge|MaxKB|401|reply_len|橋接' | tail -8 +""" + +_, o, e = c.exec_command(cmd, timeout=300) +print(o.read().decode(errors="replace")) +print(e.read().decode(errors="replace")) +c.close() diff --git a/guest-cs-stack/_fix_chatwoot_confirm.py b/guest-cs-stack/_fix_chatwoot_confirm.py new file mode 100644 index 0000000..be7b2e3 --- /dev/null +++ b/guest-cs-stack/_fix_chatwoot_confirm.py @@ -0,0 +1,63 @@ +import os +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +fix = r""" +sudo docker exec guest-chat bundle exec rails runner " +email = 'admin@localhost.local' +user = User.find_by(email: email) +if user + user.confirm if user.respond_to?(:confirm) + user.update!(confirmed_at: Time.current) if user.respond_to?(:confirmed_at) && user.confirmed_at.nil? + puts \"confirmed user id=#{user.id} confirmed_at=#{user.confirmed_at}\" +else + puts 'user not found' +end +" +""" + +print(">>> confirm admin user") +_, o, e = c.exec_command(fix, timeout=120) +print(o.read().decode()) +print(e.read().decode()) + +# test guest-chat/new with csrf +test = r""" +python3 <<'PY' +import re, json, urllib.request + +req = urllib.request.Request('http://127.0.0.1:7777/guest-customer-service') +with urllib.request.urlopen(req, timeout=15) as r: + html = r.read().decode() +m = re.search(r'"csrf_token":\s*"([^"]+)"', html) +csrf = m.group(1) if m else '' +print('csrf', csrf[:20] + '...') + +post = urllib.request.Request( + 'http://127.0.0.1:7777/guest-chat/new', + data=b'{}', + headers={'Content-Type': 'application/json', 'X-CSRF-Token': csrf}, + method='POST', +) +try: + with urllib.request.urlopen(post, timeout=30) as r: + body = r.read().decode() + print('status', r.status) + print('body', body[:300]) +except Exception as e: + if hasattr(e, 'read'): + print('error', e.read().decode()[:400]) + else: + print('error', e) +PY +""" + +print(">>> test guest-chat/new") +_, o, e = c.exec_command(test, timeout=60) +print(o.read().decode()) +print(e.read().decode()) +c.close() diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/.dockerignore b/guest-cs-stack/_flask_bak_before_restore_20260815/.dockerignore new file mode 100644 index 0000000..d6f78cd --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/.dockerignore @@ -0,0 +1,8 @@ +venv/ +__pycache__/ +*.pyc +*.pyo +.env +.maxkb_cw_sessions.json +.pytest_cache/ +*.md diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/.env.example b/guest-cs-stack/_flask_bak_before_restore_20260815/.env.example new file mode 100644 index 0000000..7ca1384 --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/.env.example @@ -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 diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/Dockerfile b/guest-cs-stack/_flask_bak_before_restore_20260815/Dockerfile new file mode 100644 index 0000000..19b4435 --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/Dockerfile @@ -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"] diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/_chatwoot.html b/guest-cs-stack/_flask_bak_before_restore_20260815/_chatwoot.html new file mode 100644 index 0000000..2668bcb --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/_chatwoot.html @@ -0,0 +1,453 @@ +{% if chatwoot_config.enabled %} + +{% endif %} diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/app.py b/guest-cs-stack/_flask_bak_before_restore_20260815/app.py new file mode 100644 index 0000000..d8ea7a1 --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/app.py @@ -0,0 +1,627 @@ +from datetime import datetime +from decimal import Decimal, InvalidOperation +from functools import wraps +import hashlib +import hmac +import logging +import secrets +from typing import Any, Callable + +from flask import ( + Flask, + abort, + flash, + jsonify, + redirect, + render_template, + request, + session, + url_for, +) +from pymysql import MySQLError +from werkzeug.exceptions import HTTPException + +from chatwoot_guest import ChatwootGuestError, create_guest_conversation +from chatwoot_proxy import proxy_chatwoot +from config import CONFIG +from maxkb_chatwoot_bridge import handle_chatwoot_webhook +from repository import MappingError, PlatformRepository, decimal_string +import threading + + +app = Flask(__name__) +app.config.update( + JSON_AS_ASCII=False, + SECRET_KEY=CONFIG.secret_key, + SESSION_COOKIE_NAME="api_interface_session", + SESSION_COOKIE_HTTPONLY=True, + SESSION_COOKIE_SAMESITE="Lax", + SESSION_COOKIE_SECURE=CONFIG.session_cookie_secure, + PERMANENT_SESSION_LIFETIME=28800, + MAX_CONTENT_LENGTH=1024 * 1024, +) + +logging.basicConfig( + level=logging.DEBUG if CONFIG.debug else logging.INFO, + format="%(asctime)s %(levelname)s %(name)s %(message)s", +) + +repo = PlatformRepository() + + +def payload() -> dict[str, Any]: + data: dict[str, Any] = {} + body = request.get_json(silent=True) + if isinstance(body, dict): + data.update(body) + data.update(request.form.to_dict()) + data.update(request.args.to_dict()) + return data + + +def api_auth(view: Callable): + @wraps(view) + def wrapped(*args, **kwargs): + if CONFIG.api_key: + provided = request.headers.get("X-API-Key", "") + if not hmac.compare_digest(provided, CONFIG.api_key): + return jsonify( + {"error": "unauthorized", "message": "API Key 無效"} + ), 401 + return view(*args, **kwargs) + + return wrapped + + +def login_required(view: Callable): + @wraps(view) + def wrapped(*args, **kwargs): + if not session.get("username"): + return redirect(url_for("login")) + return view(*args, **kwargs) + + return wrapped + + +def csrf_token() -> str: + token = session.get("csrf_token") + if not token: + token = secrets.token_urlsafe(32) + session["csrf_token"] = token + return str(token) + + +def verify_csrf() -> None: + received = str(request.form.get("csrf_token", "")) + saved = str(session.get("csrf_token", "")) + if not received or not saved or not hmac.compare_digest(received, saved): + abort(403, description="CSRF 验证失败") + + +def required_text( + data: dict[str, Any], + name: str, + max_length: int = 191, +) -> str: + value = str(data.get(name, "")).strip() + if not value: + raise ValueError(f"{name} 為必填") + if len(value) > max_length: + raise ValueError(f"{name} 長度不可超過 {max_length}") + return value + + +def is_api_request() -> bool: + return request.path == "/health" or request.path.startswith("/platform/") + + +def update_member_session(account: dict[str, Any]) -> None: + """將目前會員資料同步到 Flask Session。""" + session["username"] = account.get("username_clean") or account.get("username") or "" + session["username_clean"] = account.get("username_clean") or "" + session["raw_username"] = account.get("raw_username") or "" + session["uid"] = account.get("uid") + session["account_status"] = account.get("status") or "normal" + session["locked_reason"] = account.get("locked_reason") or "" + session["member_type"] = account.get("member_type") or "" + session["vip_level"] = account.get("vip_level") or "" + session["tier"] = account.get("tier") or "" + + +def build_chatwoot_user() -> dict[str, Any]: + """建立傳給 Chatwoot 前端 SDK 的安全會員資料。 + + identifier 直接使用 username_clean,作為 Chatwoot 抓取會員名的關鍵標識。 + 顯示名稱與自訂屬性同樣使用 username_clean。 + """ + username = str( + session.get("username_clean") + or session.get("username") + or "" + ).strip() + uid = session.get("uid") + + # Chatwoot 的 identifier 直接使用登入會員名。 + # 例如:yongle001,而不是 member-311。 + identifier = username if username else "" + + identifier_hash = "" + if identifier and CONFIG.chatwoot_identity_secret: + identifier_hash = hmac.new( + CONFIG.chatwoot_identity_secret.encode("utf-8"), + identifier.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + return { + "is_authenticated": bool(identifier), + "visitor_type": "member" if identifier else "guest", + "identifier": identifier, + "identifier_hash": identifier_hash, + "name": username, + "username": username, + "uid": uid, + "account_status": str( + session.get("account_status", "") + ), + "locked_reason": str( + session.get("locked_reason", "") + ), + "member_type": str( + session.get("member_type", "") + ), + "vip_level": str( + session.get("vip_level", "") + ), + "tier": str( + session.get("tier", "") + ), + } + + +@app.context_processor +def inject_template_values(): + use_guest_widget = request.path.startswith("/guest-customer-service") + guest_token = CONFIG.chatwoot_guest_website_token + if use_guest_widget and guest_token: + # 依访客实际访问的 host(IP 或 test.pubuli.com)加载 /cw,避免写死域名 + base_url = f"{request.scheme}://{request.host}/cw" + else: + base_url = CONFIG.chatwoot_base_url + website_token = ( + guest_token + if use_guest_widget and guest_token + else CONFIG.chatwoot_website_token + ) + chatwoot_config = { + "enabled": bool( + CONFIG.chatwoot_enabled + and base_url + and website_token + ), + "base_url": base_url, + "website_token": website_token, + "debug": CONFIG.chatwoot_debug, + "csrf_token": csrf_token(), + "guest_new_path": "/guest-chat/new", + } + + return { + "app_name": CONFIG.app_name, + "csrf_token": csrf_token, + "chatwoot_config": chatwoot_config, + "chatwoot_user": build_chatwoot_user(), + + # 只在登出後首次渲染未登入頁面時為 True。 + # 使用 pop() 確保該標記只消費一次。 + "chatwoot_guest_reset_required": bool( + session.pop( + "chatwoot_guest_reset_required", + False, + ) + ), + } + + +@app.get("/") +def root(): + if session.get("username"): + return redirect(url_for("home")) + return redirect(url_for("login")) + + +@app.get("/guest-customer-service") +def guest_customer_service(): + """遊客客服頁:不登入、不查會員資料,只載入匿名 Chatwoot。""" + return render_template( + "guest_customer_service.html", + is_guest=True, + ) + + +@app.route( + "/cw/", + defaults={"subpath": ""}, + methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], +) +@app.route( + "/cw/", + methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], +) +def chatwoot_guest_proxy(subpath: str): + """把本機 Chatwoot widget 代理進來,才能注入略過「開始對話」的腳本。""" + return proxy_chatwoot(subpath) + + +@app.route( + "/vite/", + 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/", + 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/", + 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) diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/base.html b/guest-cs-stack/_flask_bak_before_restore_20260815/base.html new file mode 100644 index 0000000..54c6b66 --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/base.html @@ -0,0 +1,13 @@ + + + + + + {% block title %}{{ app_name }}{% endblock %} + + + +{% block body %}{% endblock %} +{% block integrations %}{% endblock %} + + diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/chatwoot_guest.py b/guest-cs-stack/_flask_bak_before_restore_20260815/chatwoot_guest.py new file mode 100644 index 0000000..709599a --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/chatwoot_guest.py @@ -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("=") diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/chatwoot_proxy.py b/guest-cs-stack/_flask_bak_before_restore_20260815/chatwoot_proxy.py new file mode 100644 index 0000000..f02d538 --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/chatwoot_proxy.py @@ -0,0 +1,1138 @@ +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 = """ + + + +""" + + +def _cable_ws_origin() -> str: + from urllib.parse import urlparse + + from flask import has_request_context, request + + # 浏览器 WebSocket 必须走对外 host + /cw,不能注入容器内 127.0.0.1:3000 + if has_request_context() and getattr(request, "host", None): + scheme = "wss" if request.scheme == "https" else "ws" + return f"{scheme}://{request.host}/cw" + + public_base = ( + CONFIG.chatwoot_guest_base_url + or CONFIG.chatwoot_base_url + or "http://127.0.0.1:7788/cw" + ) + parsed = urlparse(public_base) + scheme = "wss" if parsed.scheme == "https" else "ws" + host = parsed.netloc or "127.0.0.1:7788" + path = (parsed.path or "/cw").rstrip("/") or "/cw" + return f"{scheme}://{host}{path}" + + +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 "" in html: + html = html.replace("", inject + "", 1) + elif "" in html: + html = html.replace("", inject + "", 1) + else: + html += inject + payload = html.encode("utf-8") + # 避免瀏覽器快取舊版 widget HTML(導致注入腳本不更新) + response_headers = [ + (key, value) + for key, value in response_headers + if key.lower() + not in { + "content-length", + "content-encoding", + "etag", + "last-modified", + "cache-control", + "pragma", + "expires", + } + ] + response_headers.extend( + [ + ("Cache-Control", "no-store, no-cache, must-revalidate, max-age=0"), + ("Pragma", "no-cache"), + ("Expires", "0"), + ] + ) + + return Response(payload, status=status, headers=response_headers) diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/config.py b/guest-cs-stack/_flask_bak_before_restore_20260815/config.py new file mode 100644 index 0000000..b79b8b3 --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/config.py @@ -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() diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/db.py b/guest-cs-stack/_flask_bak_before_restore_20260815/db.py new file mode 100644 index 0000000..1803500 --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/db.py @@ -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() diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/guest_customer_service.html b/guest-cs-stack/_flask_bak_before_restore_20260815/guest_customer_service.html new file mode 100644 index 0000000..0ed82c7 --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/guest_customer_service.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}游客客服|{{ app_name }}{% endblock %} +{% block body_class %}guest-page{% endblock %} + +{% block body %} +
+
+
游客客服中心
+
Guest Customer Service
+
+ 返回登录页 +
+ +
+
+
🎧
+ 游客模式 +

欢迎使用在线客服

+

您目前以游客身份进入,可咨询注册、登录、活动与平台操作问题。

+ +
+ 游客模式不会识别会员身份,也不能查询充值、提款、余额或其他会员资料。 +
+ + +
+
+{% endblock %} + +{% block integrations %} +{% include "_chatwoot.html" %} +{% endblock %} diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/login.html b/guest-cs-stack/_flask_bak_before_restore_20260815/login.html new file mode 100644 index 0000000..125912e --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/login.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}登录|{{ app_name }}{% endblock %} +{% block body_class %}login-body{% endblock %} +{% block body %} +
+
YD
+

游戏站点模拟

+

仅使用 members 表内存在的用户名登录,不需要密码。

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} + +
+ + + + +
+ + + + + 🎧 游客进入客服 + + +

+ 游客只能进行一般咨询,不能查询会员充值、提款或账户资料。 +

+ +

【测试限定】此登录方式不具备正式身份验证能力。

+
+{% endblock %} diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/maxkb_chatwoot_bridge.py b/guest-cs-stack/_flask_bak_before_restore_20260815/maxkb_chatwoot_bridge.py new file mode 100644 index 0000000..967ae9c --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/maxkb_chatwoot_bridge.py @@ -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"\s*(.*?)\s*", + 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 的 標籤拆成純文字 + 按鈕選項。""" + 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 ( + "您好,取消提款需由专员协助核实处理,请稍候为您转接真人客服。" + "转专人服务" + ) + 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, + } diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/preflight.py b/guest-cs-stack/_flask_bak_before_restore_20260815/preflight.py new file mode 100644 index 0000000..3f4d142 --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/preflight.py @@ -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()) diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/repository.py b/guest-cs-stack/_flask_bak_before_restore_20260815/repository.py new file mode 100644 index 0000000..ab54f7f --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/repository.py @@ -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 diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/requirements.txt b/guest-cs-stack/_flask_bak_before_restore_20260815/requirements.txt new file mode 100644 index 0000000..8a9c356 --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/requirements.txt @@ -0,0 +1,4 @@ +Flask>=3.1,<4 +PyMySQL>=1.1,<2 +python-dotenv>=1.0,<2 +gunicorn>=23,<24 diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/static_style.css b/guest-cs-stack/_flask_bak_before_restore_20260815/static_style.css new file mode 100644 index 0000000..42d5015 --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/static_style.css @@ -0,0 +1,172 @@ +:root{--bg:#090d1a;--panel:#11182b;--panel2:#19233c;--text:#f5f7ff;--muted:#9aa7c5;--accent:#ffcc4d;--accent2:#7b61ff;--danger:#ff6577;--success:#50d890;--border:rgba(255,255,255,.09)} +*{box-sizing:border-box}body{margin:0;min-height:100vh;font-family:Inter,"Noto Sans TC","Microsoft JhengHei",sans-serif;color:var(--text);background:radial-gradient(circle at top right,rgba(123,97,255,.20),transparent 34%),radial-gradient(circle at bottom left,rgba(255,204,77,.10),transparent 30%),var(--bg)}a{color:inherit}.login-body{display:grid;place-items:center;padding:24px}.login-card{width:min(440px,100%);padding:36px;background:rgba(17,24,43,.94);border:1px solid var(--border);border-radius:24px;box-shadow:0 24px 80px rgba(0,0,0,.38)}.brand-mark{display:grid;place-items:center;width:58px;height:58px;border-radius:18px;background:linear-gradient(135deg,var(--accent),#ff8a4d);color:#1b1520;font-weight:900;letter-spacing:.08em}h1,h2,h3,p{margin-top:0}.muted,.site-subtitle,.section-heading span{color:var(--muted)}label{display:block;margin:24px 0 8px;font-weight:700}input{width:100%;padding:14px 16px;color:var(--text);background:#0c1222;border:1px solid var(--border);border-radius:12px;outline:none}input:focus{border-color:var(--accent2)}.primary-button,.game-card button{width:100%;margin-top:16px;padding:14px 18px;border:0;border-radius:12px;font-weight:800}.primary-button,.primary-link{color:#17120a;background:var(--accent);cursor:pointer}.primary-link{display:inline-block;padding:12px 16px;border-radius:12px;text-decoration:none;font-weight:800}.security-note{margin:18px 0 0;color:var(--muted);font-size:13px}.topbar{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:18px clamp(18px,4vw,56px);background:rgba(9,13,26,.88);border-bottom:1px solid var(--border);backdrop-filter:blur(16px);position:sticky;top:0;z-index:10}.site-title{font-weight:900;letter-spacing:.08em}.site-subtitle{margin-top:3px;font-size:13px}.member-area{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:flex-end}.member-area form{margin:0}.logout-button,.back-link{padding:9px 13px;border:1px solid var(--border);border-radius:10px;color:var(--text);background:transparent;text-decoration:none;cursor:pointer}.status-badge{padding:5px 9px;border-radius:999px;font-size:12px;font-weight:800}.status-badge.normal{color:var(--success);background:rgba(80,216,144,.12)}.status-badge.locked{color:var(--danger);background:rgba(255,101,119,.12)}.page{width:min(1180px,calc(100% - 32px));margin:0 auto;padding:40px 0 72px}.page.narrow{width:min(820px,calc(100% - 32px))}.hero{display:grid;grid-template-columns:1fr auto;align-items:center;gap:24px;min-height:280px;padding:clamp(28px,5vw,60px);margin-bottom:24px;border:1px solid var(--border);border-radius:28px;background:linear-gradient(135deg,rgba(123,97,255,.26),rgba(17,24,43,.94)),var(--panel)}.hero h1{max-width:720px;font-size:clamp(30px,5vw,56px);line-height:1.08}.hero p{max-width:720px;color:var(--muted);font-size:17px}.eyebrow{color:var(--accent);font-size:12px;font-weight:900;letter-spacing:.16em}.service-entry{display:flex;gap:14px;align-items:center;min-width:230px;padding:18px;border:1px solid rgba(255,204,77,.35);border-radius:18px;background:rgba(255,204,77,.10);text-decoration:none}.service-entry small{display:block;margin-top:4px;color:var(--muted)}.service-icon{font-size:34px}.account-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:34px}.metric-card{padding:18px;border:1px solid var(--border);border-radius:16px;background:var(--panel)}.metric-card span{display:block;color:var(--muted);font-size:13px;margin-bottom:8px}.metric-card strong{font-size:20px}.section-heading{display:flex;align-items:end;justify-content:space-between;gap:18px;margin:0 4px 16px}.game-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.game-card{display:grid;grid-template-columns:auto 1fr;gap:16px;padding:22px;border:1px solid var(--border);border-radius:20px;background:var(--panel)}.game-card h3{margin-bottom:6px}.game-card p{margin-bottom:0;color:var(--muted)}.game-icon{font-size:38px}.game-card button{grid-column:1/-1;color:var(--muted);background:var(--panel2);cursor:not-allowed}.floating-service{position:fixed;right:22px;bottom:22px;padding:14px 18px;border-radius:999px;color:#17120a;background:var(--accent);font-weight:900;text-decoration:none;box-shadow:0 16px 36px rgba(0,0,0,.35)}.alert{padding:14px 16px;margin-bottom:18px;border-radius:12px}.alert-error{color:#ffdfe3;background:rgba(255,101,119,.14);border:1px solid rgba(255,101,119,.28)}.alert-warning{color:#fff2c5;background:rgba(255,204,77,.12);border:1px solid rgba(255,204,77,.28)}.support-panel{padding:34px;border:1px solid var(--border);border-radius:24px;background:var(--panel)}.support-avatar{font-size:48px}.empty-state{padding:24px;color:var(--muted);background:var(--panel2);border-radius:14px}.order-list{display:grid;gap:12px}.order-card{display:grid;gap:7px;padding:16px;border:1px solid var(--border);border-radius:14px;background:var(--panel2)}.order-card span{color:var(--muted)}.section-gap{margin-top:30px}@media(max-width:800px){.hero{grid-template-columns:1fr}.game-grid{grid-template-columns:1fr 1fr}.account-grid{grid-template-columns:1fr 1fr}.topbar{align-items:flex-start}}@media(max-width:560px){.game-grid,.account-grid{grid-template-columns:1fr}.topbar{flex-direction:column}.member-area{justify-content:flex-start}.login-card{padding:26px}} + + +.login-divider { + display: flex; + align-items: center; + gap: 12px; + margin: 20px 0; + color: var(--muted); +} +.login-divider::before, +.login-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border); +} +.guest-service-button { + display: block; + padding: 14px 18px; + border: 1px solid rgba(255, 204, 77, .35); + border-radius: 12px; + color: var(--accent); + background: rgba(255, 204, 77, .08); + text-align: center; + text-decoration: none; + font-weight: 800; +} +.guest-service-note { + margin-top: 12px; + color: var(--muted); + font-size: 13px; + line-height: 1.6; +} +.guest-support-panel { + text-align: center; +} +.guest-badge { + display: inline-block; + margin: 6px 0 18px; + padding: 6px 10px; + border-radius: 999px; + color: var(--accent); + background: rgba(255, 204, 77, .10); + font-size: 12px; + font-weight: 800; +} +.guest-open-chat { + max-width: 320px; + margin-left: auto; + margin-right: auto; +} + +.guest-page { + color: #111; + background: #fff; +} +.guest-page .woot-widget-holder, +.guest-page .woot-widget-holder.woot-elements--right { + width: 400px !important; + height: min(82vh, 760px) !important; + max-height: 82vh !important; +} +.guest-page .woot-widget-holder iframe, +.guest-page .woot-widget-holder .woot-widget-box { + width: 100% !important; + height: 100% !important; + min-height: min(82vh, 760px) !important; +} +.guest-page .woot-widget-bubble { + background: #e31c23 !important; +} +.guest-page .woot-widget-bubble:hover { + background: #c4161c !important; +} +.guest-page .topbar { + color: #111; + background: #fff; + border-bottom: 1px solid #e6e6e6; +} +.guest-page .site-subtitle { + color: #555; +} +.guest-page .back-link { + color: #111; + border-color: #d8d8d8; + background: #fff; +} +.guest-page .support-panel { + color: #111; + background: #fff; + border: 1px solid #e6e6e6; +} +.guest-page .support-panel p { + color: #333; +} +.guest-page .guest-badge { + color: #111; + background: #f3f3f3; +} +.guest-page .alert-warning { + color: #111; + background: #f7f7f7; + border: 1px solid #e0e0e0; +} +.guest-page .primary-button { + color: #111; + background: #ffcc4d; +} + +/* 登录页:白底黑字 */ +.login-body { + color: #111; + background: #fff; +} +.login-body .login-card { + color: #111; + background: #fff; + border: 1px solid #e6e6e6; + box-shadow: 0 12px 36px rgba(0, 0, 0, .08); +} +.login-body .brand-mark { + color: #111; + background: #ffcc4d; +} +.login-body h1 { + color: #111; +} +.login-body .muted, +.login-body .guest-service-note, +.login-body .security-note, +.login-body .login-divider { + color: #555; +} +.login-body .login-divider::before, +.login-body .login-divider::after { + background: #e6e6e6; +} +.login-body label { + color: #111; +} +.login-body input { + color: #111; + background: #fff; + border: 1px solid #d8d8d8; +} +.login-body input:focus { + border-color: #ffcc4d; +} +.login-body .primary-button { + color: #111; + background: #ffcc4d; +} +.login-body .guest-service-button { + color: #111; + border-color: #d8d8d8; + background: #f7f7f7; +} +.login-body .guest-service-button:hover { + background: #f0f0f0; +} +.login-body .alert-error { + color: #111; + background: #fff1f2; + border: 1px solid #ffc9d0; +} +.login-body .alert-warning { + color: #111; + background: #f7f7f7; + border: 1px solid #e0e0e0; +} diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/style.css b/guest-cs-stack/_flask_bak_before_restore_20260815/style.css new file mode 100644 index 0000000..42d5015 --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/style.css @@ -0,0 +1,172 @@ +:root{--bg:#090d1a;--panel:#11182b;--panel2:#19233c;--text:#f5f7ff;--muted:#9aa7c5;--accent:#ffcc4d;--accent2:#7b61ff;--danger:#ff6577;--success:#50d890;--border:rgba(255,255,255,.09)} +*{box-sizing:border-box}body{margin:0;min-height:100vh;font-family:Inter,"Noto Sans TC","Microsoft JhengHei",sans-serif;color:var(--text);background:radial-gradient(circle at top right,rgba(123,97,255,.20),transparent 34%),radial-gradient(circle at bottom left,rgba(255,204,77,.10),transparent 30%),var(--bg)}a{color:inherit}.login-body{display:grid;place-items:center;padding:24px}.login-card{width:min(440px,100%);padding:36px;background:rgba(17,24,43,.94);border:1px solid var(--border);border-radius:24px;box-shadow:0 24px 80px rgba(0,0,0,.38)}.brand-mark{display:grid;place-items:center;width:58px;height:58px;border-radius:18px;background:linear-gradient(135deg,var(--accent),#ff8a4d);color:#1b1520;font-weight:900;letter-spacing:.08em}h1,h2,h3,p{margin-top:0}.muted,.site-subtitle,.section-heading span{color:var(--muted)}label{display:block;margin:24px 0 8px;font-weight:700}input{width:100%;padding:14px 16px;color:var(--text);background:#0c1222;border:1px solid var(--border);border-radius:12px;outline:none}input:focus{border-color:var(--accent2)}.primary-button,.game-card button{width:100%;margin-top:16px;padding:14px 18px;border:0;border-radius:12px;font-weight:800}.primary-button,.primary-link{color:#17120a;background:var(--accent);cursor:pointer}.primary-link{display:inline-block;padding:12px 16px;border-radius:12px;text-decoration:none;font-weight:800}.security-note{margin:18px 0 0;color:var(--muted);font-size:13px}.topbar{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:18px clamp(18px,4vw,56px);background:rgba(9,13,26,.88);border-bottom:1px solid var(--border);backdrop-filter:blur(16px);position:sticky;top:0;z-index:10}.site-title{font-weight:900;letter-spacing:.08em}.site-subtitle{margin-top:3px;font-size:13px}.member-area{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:flex-end}.member-area form{margin:0}.logout-button,.back-link{padding:9px 13px;border:1px solid var(--border);border-radius:10px;color:var(--text);background:transparent;text-decoration:none;cursor:pointer}.status-badge{padding:5px 9px;border-radius:999px;font-size:12px;font-weight:800}.status-badge.normal{color:var(--success);background:rgba(80,216,144,.12)}.status-badge.locked{color:var(--danger);background:rgba(255,101,119,.12)}.page{width:min(1180px,calc(100% - 32px));margin:0 auto;padding:40px 0 72px}.page.narrow{width:min(820px,calc(100% - 32px))}.hero{display:grid;grid-template-columns:1fr auto;align-items:center;gap:24px;min-height:280px;padding:clamp(28px,5vw,60px);margin-bottom:24px;border:1px solid var(--border);border-radius:28px;background:linear-gradient(135deg,rgba(123,97,255,.26),rgba(17,24,43,.94)),var(--panel)}.hero h1{max-width:720px;font-size:clamp(30px,5vw,56px);line-height:1.08}.hero p{max-width:720px;color:var(--muted);font-size:17px}.eyebrow{color:var(--accent);font-size:12px;font-weight:900;letter-spacing:.16em}.service-entry{display:flex;gap:14px;align-items:center;min-width:230px;padding:18px;border:1px solid rgba(255,204,77,.35);border-radius:18px;background:rgba(255,204,77,.10);text-decoration:none}.service-entry small{display:block;margin-top:4px;color:var(--muted)}.service-icon{font-size:34px}.account-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:34px}.metric-card{padding:18px;border:1px solid var(--border);border-radius:16px;background:var(--panel)}.metric-card span{display:block;color:var(--muted);font-size:13px;margin-bottom:8px}.metric-card strong{font-size:20px}.section-heading{display:flex;align-items:end;justify-content:space-between;gap:18px;margin:0 4px 16px}.game-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.game-card{display:grid;grid-template-columns:auto 1fr;gap:16px;padding:22px;border:1px solid var(--border);border-radius:20px;background:var(--panel)}.game-card h3{margin-bottom:6px}.game-card p{margin-bottom:0;color:var(--muted)}.game-icon{font-size:38px}.game-card button{grid-column:1/-1;color:var(--muted);background:var(--panel2);cursor:not-allowed}.floating-service{position:fixed;right:22px;bottom:22px;padding:14px 18px;border-radius:999px;color:#17120a;background:var(--accent);font-weight:900;text-decoration:none;box-shadow:0 16px 36px rgba(0,0,0,.35)}.alert{padding:14px 16px;margin-bottom:18px;border-radius:12px}.alert-error{color:#ffdfe3;background:rgba(255,101,119,.14);border:1px solid rgba(255,101,119,.28)}.alert-warning{color:#fff2c5;background:rgba(255,204,77,.12);border:1px solid rgba(255,204,77,.28)}.support-panel{padding:34px;border:1px solid var(--border);border-radius:24px;background:var(--panel)}.support-avatar{font-size:48px}.empty-state{padding:24px;color:var(--muted);background:var(--panel2);border-radius:14px}.order-list{display:grid;gap:12px}.order-card{display:grid;gap:7px;padding:16px;border:1px solid var(--border);border-radius:14px;background:var(--panel2)}.order-card span{color:var(--muted)}.section-gap{margin-top:30px}@media(max-width:800px){.hero{grid-template-columns:1fr}.game-grid{grid-template-columns:1fr 1fr}.account-grid{grid-template-columns:1fr 1fr}.topbar{align-items:flex-start}}@media(max-width:560px){.game-grid,.account-grid{grid-template-columns:1fr}.topbar{flex-direction:column}.member-area{justify-content:flex-start}.login-card{padding:26px}} + + +.login-divider { + display: flex; + align-items: center; + gap: 12px; + margin: 20px 0; + color: var(--muted); +} +.login-divider::before, +.login-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border); +} +.guest-service-button { + display: block; + padding: 14px 18px; + border: 1px solid rgba(255, 204, 77, .35); + border-radius: 12px; + color: var(--accent); + background: rgba(255, 204, 77, .08); + text-align: center; + text-decoration: none; + font-weight: 800; +} +.guest-service-note { + margin-top: 12px; + color: var(--muted); + font-size: 13px; + line-height: 1.6; +} +.guest-support-panel { + text-align: center; +} +.guest-badge { + display: inline-block; + margin: 6px 0 18px; + padding: 6px 10px; + border-radius: 999px; + color: var(--accent); + background: rgba(255, 204, 77, .10); + font-size: 12px; + font-weight: 800; +} +.guest-open-chat { + max-width: 320px; + margin-left: auto; + margin-right: auto; +} + +.guest-page { + color: #111; + background: #fff; +} +.guest-page .woot-widget-holder, +.guest-page .woot-widget-holder.woot-elements--right { + width: 400px !important; + height: min(82vh, 760px) !important; + max-height: 82vh !important; +} +.guest-page .woot-widget-holder iframe, +.guest-page .woot-widget-holder .woot-widget-box { + width: 100% !important; + height: 100% !important; + min-height: min(82vh, 760px) !important; +} +.guest-page .woot-widget-bubble { + background: #e31c23 !important; +} +.guest-page .woot-widget-bubble:hover { + background: #c4161c !important; +} +.guest-page .topbar { + color: #111; + background: #fff; + border-bottom: 1px solid #e6e6e6; +} +.guest-page .site-subtitle { + color: #555; +} +.guest-page .back-link { + color: #111; + border-color: #d8d8d8; + background: #fff; +} +.guest-page .support-panel { + color: #111; + background: #fff; + border: 1px solid #e6e6e6; +} +.guest-page .support-panel p { + color: #333; +} +.guest-page .guest-badge { + color: #111; + background: #f3f3f3; +} +.guest-page .alert-warning { + color: #111; + background: #f7f7f7; + border: 1px solid #e0e0e0; +} +.guest-page .primary-button { + color: #111; + background: #ffcc4d; +} + +/* 登录页:白底黑字 */ +.login-body { + color: #111; + background: #fff; +} +.login-body .login-card { + color: #111; + background: #fff; + border: 1px solid #e6e6e6; + box-shadow: 0 12px 36px rgba(0, 0, 0, .08); +} +.login-body .brand-mark { + color: #111; + background: #ffcc4d; +} +.login-body h1 { + color: #111; +} +.login-body .muted, +.login-body .guest-service-note, +.login-body .security-note, +.login-body .login-divider { + color: #555; +} +.login-body .login-divider::before, +.login-body .login-divider::after { + background: #e6e6e6; +} +.login-body label { + color: #111; +} +.login-body input { + color: #111; + background: #fff; + border: 1px solid #d8d8d8; +} +.login-body input:focus { + border-color: #ffcc4d; +} +.login-body .primary-button { + color: #111; + background: #ffcc4d; +} +.login-body .guest-service-button { + color: #111; + border-color: #d8d8d8; + background: #f7f7f7; +} +.login-body .guest-service-button:hover { + background: #f0f0f0; +} +.login-body .alert-error { + color: #111; + background: #fff1f2; + border: 1px solid #ffc9d0; +} +.login-body .alert-warning { + color: #111; + background: #f7f7f7; + border: 1px solid #e0e0e0; +} diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/templates__chatwoot.html b/guest-cs-stack/_flask_bak_before_restore_20260815/templates__chatwoot.html new file mode 100644 index 0000000..2668bcb --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/templates__chatwoot.html @@ -0,0 +1,453 @@ +{% if chatwoot_config.enabled %} + +{% endif %} diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/templates_base.html b/guest-cs-stack/_flask_bak_before_restore_20260815/templates_base.html new file mode 100644 index 0000000..54c6b66 --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/templates_base.html @@ -0,0 +1,13 @@ + + + + + + {% block title %}{{ app_name }}{% endblock %} + + + +{% block body %}{% endblock %} +{% block integrations %}{% endblock %} + + diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/templates_customer_service.html b/guest-cs-stack/_flask_bak_before_restore_20260815/templates_customer_service.html new file mode 100644 index 0000000..d4f8901 --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/templates_customer_service.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% block title %}客服中心|{{ app_name }}{% endblock %} +{% block body %} +
+
+
客服測試入口
+
目前用戶:{{ username }}
+
+ 返回首頁 +
+ +
+
+
🎧
+

會員資料查詢

+ {% if error %} +
{{ error }}
+ {% else %} + {% if account.status == "locked" %} +
+ 帳戶已凍結{% if account.locked_reason %}|{{ account.locked_reason }}{% endif %} +
+ {% endif %} + +

未完成提款

+ {% if not withdrawals %} +
目前沒有查到未完成提款資料。
+ {% else %} +
+ {% for order in withdrawals %} +
+ {{ order.status or "狀態未知" }} + 來源:{{ order.source }} + 訂單:{{ order.order_id or "NA" }} + 金額:{{ order.amount }} + 時間:{{ order.create_time or "NA" }} + {% if order.fail_reason %}備註:{{ order.fail_reason }}{% endif %} +
+ {% endfor %} +
+ {% endif %} + +

最近充值

+ {% if not deposits %} +
目前沒有查到充值資料。
+ {% else %} +
+ {% for order in deposits %} +
+ {{ order.status or "狀態未知" }} + 訂單:{{ order.thirdparty_order_id or "NA" }} + 充值金額:{{ order.amount }} + 實際到帳:{{ order.actual_arrival_amount }} + 充值時間:{{ order.created_at or "NA" }} + 到帳時間:{{ order.arrival_time or "NA" }} +
+ {% endfor %} +
+ {% endif %} + {% endif %} +
+
+{% endblock %} + +{% block integrations %} +{% include "_chatwoot.html" %} +{% endblock %} diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/templates_error.html b/guest-cs-stack/_flask_bak_before_restore_20260815/templates_error.html new file mode 100644 index 0000000..8ba4c6a --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/templates_error.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}{{ title }}|{{ app_name }}{% endblock %} +{% block body_class %}login-body{% endblock %} +{% block body %} +
+
!
+

{{ title }}

+
{{ message }}
+ 返回系統 +
+{% endblock %} diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/templates_guest_customer_service.html b/guest-cs-stack/_flask_bak_before_restore_20260815/templates_guest_customer_service.html new file mode 100644 index 0000000..0ed82c7 --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/templates_guest_customer_service.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}游客客服|{{ app_name }}{% endblock %} +{% block body_class %}guest-page{% endblock %} + +{% block body %} +
+
+
游客客服中心
+
Guest Customer Service
+
+ 返回登录页 +
+ +
+
+
🎧
+ 游客模式 +

欢迎使用在线客服

+

您目前以游客身份进入,可咨询注册、登录、活动与平台操作问题。

+ +
+ 游客模式不会识别会员身份,也不能查询充值、提款、余额或其他会员资料。 +
+ + +
+
+{% endblock %} + +{% block integrations %} +{% include "_chatwoot.html" %} +{% endblock %} diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/templates_home.html b/guest-cs-stack/_flask_bak_before_restore_20260815/templates_home.html new file mode 100644 index 0000000..c27c5f1 --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/templates_home.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% block title %}遊戲首頁|{{ app_name }}{% endblock %} +{% block body %} +
+
+
YD GAME DEMO
+
Python 唯讀 API 測試站點
+
+
+ 用戶:{{ account.raw_username or username }} + + {{ "已凍結" if account.status == "locked" else "正常" }} + +
+ + +
+
+
+ +
+ {% if warning %} +
帳戶資料讀取失敗:{{ warning }}
+ {% endif %} + + {% if account.status == "locked" %} +
+ 帳戶已凍結 + {% if account.locked_reason %}|凍結原因:{{ account.locked_reason }}{% endif %} +
+ {% endif %} + +
+
+ PYTHON TEST ENVIRONMENT +

歡迎進入遊戲站點模擬首頁

+

登入會員:{{ account.username_clean or username }}。本頁只用於驗證會員資料、充值、提款與客服 API。

+
+ + 🎧 + 在線客服查詢充值與提款資料 + +
+ + + +
+

熱門遊戲

畫面模擬,不執行真實投注
+
+ {% for game in games %} +
+
{{ game.icon }}
+

{{ game.name }}

{{ game.category }}

+ +
+ {% endfor %} +
+
+
+🎧 客服 +{% endblock %} + +{% block integrations %} +{% include "_chatwoot.html" %} +{% endblock %} diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/templates_login.html b/guest-cs-stack/_flask_bak_before_restore_20260815/templates_login.html new file mode 100644 index 0000000..125912e --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/templates_login.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}登录|{{ app_name }}{% endblock %} +{% block body_class %}login-body{% endblock %} +{% block body %} +
+
YD
+

游戏站点模拟

+

仅使用 members 表内存在的用户名登录,不需要密码。

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} + +
+ + + + +
+ + + + + 🎧 游客进入客服 + + +

+ 游客只能进行一般咨询,不能查询会员充值、提款或账户资料。 +

+ +

【测试限定】此登录方式不具备正式身份验证能力。

+
+{% endblock %} diff --git a/guest-cs-stack/_flask_bak_before_restore_20260815/wsgi.py b/guest-cs-stack/_flask_bak_before_restore_20260815/wsgi.py new file mode 100644 index 0000000..74dcf8d --- /dev/null +++ b/guest-cs-stack/_flask_bak_before_restore_20260815/wsgi.py @@ -0,0 +1,4 @@ +from app import app + + +application = app diff --git a/guest-cs-stack/_get_bot_token.py b/guest-cs-stack/_get_bot_token.py new file mode 100644 index 0000000..f19cc9f --- /dev/null +++ b/guest-cs-stack/_get_bot_token.py @@ -0,0 +1,28 @@ +import os +import sys +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) +cmd = r"""sudo docker exec guest-chat bundle exec rails runner " +bot = AgentBot.find(1) +puts 'bot_id=' + bot.id.to_s +puts 'outgoing=' + bot.outgoing_url.to_s +if bot.respond_to?(:access_token) + puts 'access_token=' + bot.access_token.token.to_s rescue puts 'no token method' +end +at = AccessToken.find_by(owner_type: 'AgentBot', owner_id: bot.id) rescue nil +if at + puts 'AccessToken=' + at.token.to_s +else + at2 = bot.try(:access_token) + puts 'try_access_token=' + (at2&.token || 'nil').to_s +end +" +""" +_, o, e = c.exec_command(cmd, timeout=90) +sys.stdout.buffer.write(o.read()) +sys.stdout.buffer.write(e.read()) +c.close() diff --git a/guest-cs-stack/_inspect_remote.py b/guest-cs-stack/_inspect_remote.py new file mode 100644 index 0000000..716c742 --- /dev/null +++ b/guest-cs-stack/_inspect_remote.py @@ -0,0 +1,21 @@ +import os, paramiko +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) +cmds = [ + "hostname; id; groups", + "which docker || true; docker --version 2>&1 || true", + "sudo -n docker --version 2>&1 || true", + "ss -tlnp 2>/dev/null | grep 7777 || true", + "ps aux | grep python | grep -v grep | head -10", + "curl -sI http://127.0.0.1:7777/guest-customer-service 2>/dev/null | head -8 || true", +] +for cmd in cmds: + print("===", cmd) + _, o, e = c.exec_command(cmd, timeout=30) + print(o.read().decode()) + err = e.read().decode() + if err: + print("ERR:", err) +c.close() diff --git a/guest-cs-stack/_patch_cable_fix.py b/guest-cs-stack/_patch_cable_fix.py new file mode 100644 index 0000000..4d40e9f --- /dev/null +++ b/guest-cs-stack/_patch_cable_fix.py @@ -0,0 +1,57 @@ +"""Deploy chatwoot_proxy.py cable fix + verify on remote.""" +import os +from pathlib import Path + +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +STACK = Path(__file__).resolve().parent +FILES = [ + ("flask/chatwoot_proxy.py", "/opt/guest-cs/chatwoot_proxy.py"), + ("flask/app.py", "/opt/guest-cs/app.py"), + ("flask/maxkb_chatwoot_bridge.py", "/opt/guest-cs/maxkb_chatwoot_bridge.py"), +] + +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) +sftp = c.open_sftp() + +for local_rel, container_path in FILES: + local = STACK / local_rel + remote_tmp = f"/home/wakka/{Path(local_rel).name}.patch" + data = local.read_bytes().replace(b"\r\n", b"\n") + with sftp.file(remote_tmp, "wb") as rf: + rf.write(data) + cmd = f"sudo docker cp {remote_tmp} guest-chat:{container_path}" + print(">>>", cmd) + _, o, e = c.exec_command(cmd, timeout=60) + out, err = o.read().decode(), e.read().decode() + if out: + print(out) + if err: + print("ERR:", err) + +sftp.close() + +verify = r""" +sudo docker exec guest-chat supervisorctl restart gunicorn 2>/dev/null || sudo docker restart guest-chat +sleep 12 +echo '=== CABLE_ORIGIN via Host 20.191.152.26:7777 ===' +curl -s 'http://127.0.0.1:7777/cw/widget?website_token=j1UAjqBgZMWQ8WfczgJSymQx' \ + -H 'Host: 20.191.152.26:7777' | grep -o 'CABLE_ORIGIN = "[^"]*"' | head -1 +echo +echo '=== guest page base_url ===' +curl -s 'http://127.0.0.1:7777/guest-customer-service' -H 'Host: 20.191.152.26:7777' \ + | grep -oE 'base_url[^,}]+' | head -1 +echo +echo '=== health ===' +curl -s -o /dev/null -w 'guest: %{http_code}\n' http://127.0.0.1:7777/guest-customer-service +""" +print(">>> restart + verify") +_, o, e = c.exec_command(verify, timeout=120) +print(o.read().decode()) +err = e.read().decode() +if err: + print("ERR:", err[:500]) +c.close() diff --git a/guest-cs-stack/_patch_remote_app.py b/guest-cs-stack/_patch_remote_app.py new file mode 100644 index 0000000..c50c98d --- /dev/null +++ b/guest-cs-stack/_patch_remote_app.py @@ -0,0 +1,60 @@ +"""Patch running guest-chat container: confirm admin + dynamic base_url in app.py.""" +import os +import textwrap + +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +LOCAL_APP = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "flask", "app.py" +) + +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +# 1) confirm admin +confirm = r""" +sudo docker exec guest-chat bundle exec rails runner " +u = User.find_by(email: 'admin@localhost.local') +u.confirm if u&.respond_to?(:confirm) +u.update!(confirmed_at: Time.current) if u && u.respond_to?(:confirmed_at) && u.confirmed_at.nil? +puts u ? \"confirmed #{u.email}\" : 'no user' +" +""" +print(">>> confirm admin") +_, o, _ = c.exec_command(confirm, timeout=120) +print(o.read().decode()) + +# 2) upload patched app.py into container +sftp = c.open_sftp() +remote_tmp = "/home/wakka/app.py.patch" +with open(LOCAL_APP, "rb") as fh: + data = fh.read().replace(b"\r\n", b"\n") +with sftp.file(remote_tmp, "w") as rf: + rf.write(data) +sftp.close() + +patch = f""" +sudo docker cp {remote_tmp} guest-chat:/opt/guest-cs/app.py +sudo docker exec guest-chat supervisorctl restart gunicorn 2>/dev/null || sudo docker restart guest-chat +sleep 15 +curl -s http://127.0.0.1:7777/guest-customer-service | grep -o 'base_url[^,]*' | head -1 +""" +print(">>> patch app.py") +_, o, e = c.exec_command(patch, timeout=120) +print(o.read().decode()) +print(e.read().decode()) + +# 3) test guest-chat/new +test = r""" +rm -f /tmp/gc.jar /tmp/g.html +curl -s -c /tmp/gc.jar http://127.0.0.1:7777/guest-customer-service -o /tmp/g.html +csrf=$(python3 -c "import re; print(re.search(r'\"csrf_token\":\s*\"([^\"]+)\"', open('/tmp/g.html').read()).group(1))") +curl -s -b /tmp/gc.jar -X POST http://127.0.0.1:7777/guest-chat/new -H "Content-Type: application/json" -H "X-CSRF-Token: $csrf" -d '{}' | head -c 200 +echo +""" +print(">>> test guest-chat/new") +_, o, _ = c.exec_command(test, timeout=60) +print(o.read().decode()) +c.close() diff --git a/guest-cs-stack/_probe_test.py b/guest-cs-stack/_probe_test.py new file mode 100644 index 0000000..4f2ff13 --- /dev/null +++ b/guest-cs-stack/_probe_test.py @@ -0,0 +1,27 @@ +import os +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +cmds = [ + "curl -sI http://127.0.0.1:7777/guest-customer-service | head -5", + "getent hosts test.pubuli.com || nslookup test.pubuli.com 2>/dev/null | tail -3", + "ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no wakka@100.31.73.254 hostname 2>&1", + "ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=no root@100.31.73.254 hostname 2>&1", + "curl -sI --connect-timeout 5 http://100.31.73.254:7777/guest-customer-service 2>&1 | head -5", +] + +for cmd in cmds: + print("===", cmd[:90]) + _, o, e = c.exec_command(cmd, timeout=30) + out = o.read().decode() + err = e.read().decode() + if out: + print(out) + if err.strip(): + print("ERR:", err[:200]) + +c.close() diff --git a/guest-cs-stack/_proxy_from_api_app.py b/guest-cs-stack/_proxy_from_api_app.py new file mode 100644 index 0000000..497c3af --- /dev/null +++ b/guest-cs-stack/_proxy_from_api_app.py @@ -0,0 +1,724 @@ +from __future__ import annotations + +from urllib.error import HTTPError, URLError +from urllib.parse import urlencode +from urllib.request import Request, urlopen + +from flask import Response, request + +from config import CONFIG + +HOP_BY_HOP = { + "connection", + "keep-alive", + "proxy-authenticate", + "proxy-authorization", + "te", + "trailers", + "transfer-encoding", + "upgrade", + "host", + "content-length", +} + +INJECT_SCRIPT = """ + + + +""" + + +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 "" in html: + html = html.replace("", inject + "", 1) + elif "" in html: + html = html.replace("", inject + "", 1) + else: + html += inject + payload = html.encode("utf-8") + + return Response(payload, status=status, headers=response_headers) diff --git a/guest-cs-stack/_recreate_guest_chat.py b/guest-cs-stack/_recreate_guest_chat.py new file mode 100644 index 0000000..9b37b35 --- /dev/null +++ b/guest-cs-stack/_recreate_guest_chat.py @@ -0,0 +1,40 @@ +import os +import sys + +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +REMOTE = "/home/wakka/guest-cs-stack" +COMPOSE = "docker-compose.chat.yaml -f docker-compose.chat.test7777.yaml" + +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +# 使用远端 bootstrap 后的 token +sftp = c.open_sftp() +with sftp.open(f"{REMOTE}/guest-cs.test.env", "w") as f: + f.write( + "# test.pubuli.com:7777\n" + "CHATWOOT_GUEST_WEBSITE_TOKEN=j1UAjqBgZMWQ8WfczgJSymQx\n" + "CHATWOOT_GUEST_BASE_URL=http://test.pubuli.com:7777/cw\n" + ) +sftp.close() + +cmd = f""" +set -e +cd {REMOTE} +sudo docker compose -f {COMPOSE} up -d --force-recreate guest-chat +sleep 20 +sudo docker inspect guest-chat --format '{{{{.Created}}}}' +sudo docker exec guest-chat printenv CHATWOOT_GUEST_BASE_URL FRONTEND_URL CHATWOOT_GUEST_WEBSITE_TOKEN +curl -sI http://127.0.0.1:7777/guest-customer-service +""" + +print(">>> recreate guest-chat only") +_, o, e = c.exec_command(cmd, timeout=180) +print(o.read().decode()) +err = e.read().decode() +if err: + print(err, file=sys.stderr) +c.close() diff --git a/guest-cs-stack/_restore_aug12_from_image/.dockerignore b/guest-cs-stack/_restore_aug12_from_image/.dockerignore new file mode 100644 index 0000000..d6f78cd --- /dev/null +++ b/guest-cs-stack/_restore_aug12_from_image/.dockerignore @@ -0,0 +1,8 @@ +venv/ +__pycache__/ +*.pyc +*.pyo +.env +.maxkb_cw_sessions.json +.pytest_cache/ +*.md diff --git a/guest-cs-stack/_restore_aug12_from_image/.env.example b/guest-cs-stack/_restore_aug12_from_image/.env.example new file mode 100644 index 0000000..7ca1384 --- /dev/null +++ b/guest-cs-stack/_restore_aug12_from_image/.env.example @@ -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 diff --git a/guest-cs-stack/_restore_aug12_from_image/.maxkb_cw_sessions.json b/guest-cs-stack/_restore_aug12_from_image/.maxkb_cw_sessions.json new file mode 100644 index 0000000..7a0d985 --- /dev/null +++ b/guest-cs-stack/_restore_aug12_from_image/.maxkb_cw_sessions.json @@ -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 + } +} \ No newline at end of file diff --git a/guest-cs-stack/_schema_bot.py b/guest-cs-stack/_schema_bot.py new file mode 100644 index 0000000..13f9d74 --- /dev/null +++ b/guest-cs-stack/_schema_bot.py @@ -0,0 +1,14 @@ +import os +import sys +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) +_, o, _ = c.exec_command( + "sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -c '\\d agent_bots'", + timeout=30, +) +sys.stdout.buffer.write(o.read()) +c.close() diff --git a/guest-cs-stack/_test_guest_new.py b/guest-cs-stack/_test_guest_new.py new file mode 100644 index 0000000..04b51a3 --- /dev/null +++ b/guest-cs-stack/_test_guest_new.py @@ -0,0 +1,22 @@ +import os +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +cmd = r""" +rm -f /tmp/gc.jar /tmp/g.html +curl -s -c /tmp/gc.jar http://127.0.0.1:7777/guest-customer-service -o /tmp/g.html +csrf=$(python3 -c "import re; print(re.search(r'\"csrf_token\":\s*\"([^\"]+)\"', open('/tmp/g.html').read()).group(1))") +echo csrf=${csrf:0:20}... +curl -s -b /tmp/gc.jar -c /tmp/gc.jar -X POST http://127.0.0.1:7777/guest-chat/new \ + -H "Content-Type: application/json" -H "X-CSRF-Token: $csrf" -d '{}' +echo +""" + +_, o, e = c.exec_command(cmd, timeout=60) +print(o.read().decode()) +print(e.read().decode()) +c.close() diff --git a/guest-cs-stack/_try_azure_nsg.py b/guest-cs-stack/_try_azure_nsg.py new file mode 100644 index 0000000..0a55e98 --- /dev/null +++ b/guest-cs-stack/_try_azure_nsg.py @@ -0,0 +1,35 @@ +import os +import sys + +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +script = r""" +set -e +TOKEN=$(curl -s -H Metadata:true \ + 'http://169.254.169.254/metadata/identity/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fmanagement.azure.com%2F' \ + | python3 -c "import sys,json; print(json.load(sys.stdin).get('access_token',''))" 2>/dev/null || true) +if [ -z "$TOKEN" ]; then echo NO_MI_TOKEN; exit 0; fi +SUB=$(curl -s -H Metadata:true 'http://169.254.169.254/metadata/instance/compute/subscriptionId?api-version=2021-02-01&format=text') +RG=$(curl -s -H Metadata:true 'http://169.254.169.254/metadata/instance/compute/resourceGroupName?api-version=2021-02-01&format=text') +VM=$(curl -s -H Metadata:true 'http://169.254.169.254/metadata/instance/compute/name?api-version=2021-02-01&format=text') +echo SUB=$SUB RG=$RG VM=$VM +# list NSGs attached to NIC +NIC_URL=$(curl -s -H Metadata:true 'http://169.254.169.254/metadata/instance/network/interface/0/id?api-version=2021-02-01&format=text') +echo NIC=$NIC_URL +curl -s -H "Authorization: Bearer $TOKEN" "https://management.azure.com${NIC_URL}?api-version=2023-09-01" | python3 -c " +import sys,json +d=json.load(sys.stdin) +for rule in d.get('properties',{}).get('ipConfigurations',[]): + print('ipconfig', rule.get('name')) +" 2>/dev/null || true +""" + +_, o, e = c.exec_command(script, timeout=60) +print(o.read().decode()) +print(e.read().decode(), file=sys.stderr) +c.close() diff --git a/guest-cs-stack/_verify_autoreply.py b/guest-cs-stack/_verify_autoreply.py new file mode 100644 index 0000000..ccaeff4 --- /dev/null +++ b/guest-cs-stack/_verify_autoreply.py @@ -0,0 +1,36 @@ +import os +import sys +import time +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +time.sleep(5) +script = r""" +sudo docker exec guest-chat printenv CHATWOOT_AGENT_BOT_TOKEN +sudo docker exec guest-chat python3 -c " +import sys; sys.path.insert(0,'/opt/guest-cs') +import maxkb_chatwoot_bridge as m +p={'event':'conversation_updated','id':3,'account':{'id':1},'messages':[{'id':666661,'content':'你好','message_type':0,'private':False,'conversation_id':3,'account_id':1,'sender':{'name':'游客'}}]} +print('extract', m._extract_incoming(p)) +" + +sudo docker exec guest-chat python3 -c " +import json, urllib.request +p={'event':'conversation_updated','id':3,'account':{'id':1},'messages':[{'id':666662,'content':'你好','message_type':0,'private':False,'conversation_id':3,'account_id':1,'sender':{'name':'游客'}}]} +req=urllib.request.Request('http://127.0.0.1:7788/webhook/bot1/chatwoot', data=json.dumps(p).encode(), headers={'Content-Type':'application/json'}, method='POST') +with urllib.request.urlopen(req, timeout=120) as r: + print('webhook', r.status, r.read()[:300]) +" + +sleep 8 +sudo docker logs guest-chat 2>&1 | grep -iE 'bridge|MaxKB|401|reply_len|橋接|failed' | tail -10 +""" + +_, o, e = c.exec_command(script, timeout=180) +sys.stdout.buffer.write(o.read()) +sys.stdout.buffer.write(e.read()) +c.close() diff --git a/guest-cs-stack/_verify_deploy.py b/guest-cs-stack/_verify_deploy.py new file mode 100644 index 0000000..bcd82d6 --- /dev/null +++ b/guest-cs-stack/_verify_deploy.py @@ -0,0 +1,22 @@ +import os +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +cmds = [ + "screen -ls 2>/dev/null || true", + "ss -tlnp | grep 7777 || true", + "curl -sI http://127.0.0.1:7777/guest-customer-service", + "curl -s http://127.0.0.1:7777/guest-customer-service | grep CHATWOOT_CONFIG | head -c 250", + "sudo docker compose -f /home/wakka/guest-cs-stack/docker-compose.chat.yaml -f /home/wakka/guest-cs-stack/docker-compose.chat.test7777.yaml ps", +] + +for cmd in cmds: + print("===", cmd) + _, o, _ = c.exec_command(cmd, timeout=30) + print(o.read().decode()) + +c.close() diff --git a/guest-cs-stack/_verify_remote.py b/guest-cs-stack/_verify_remote.py new file mode 100644 index 0000000..9bdd6ad --- /dev/null +++ b/guest-cs-stack/_verify_remote.py @@ -0,0 +1,16 @@ +import os +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) +cmds = [ + 'sudo docker exec guest-chat printenv | grep -E "CHATWOOT_GUEST|FRONTEND"', + "curl -s http://127.0.0.1:7777/guest-customer-service | grep websiteToken | head -c 200", +] +for cmd in cmds: + print("===", cmd) + _, o, e = c.exec_command(cmd, timeout=60) + print(o.read().decode()) +c.close() diff --git a/guest-cs-stack/_verify_widget_js.py b/guest-cs-stack/_verify_widget_js.py new file mode 100644 index 0000000..b20a68f --- /dev/null +++ b/guest-cs-stack/_verify_widget_js.py @@ -0,0 +1,52 @@ +import os +import re +import sys +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +cmd = r""" +html=$(curl -s "http://127.0.0.1:7777/cw/widget?website_token=j1UAjqBgZMWQ8WfczgJSymQx" -H "Host: 20.191.152.26:7777") +python3 - <<'PY' +import os, re, subprocess, tempfile +html = os.popen('''curl -s "http://127.0.0.1:7777/cw/widget?website_token=j1UAjqBgZMWQ8WfczgJSymQx" -H "Host: 20.191.152.26:7777"''').read() +print('len', len(html)) +print('pullTags', html.count('function pullTags')) +print('tagOpen.replace', html.count('tagOpen.replace')) +print('polishQuickQuestions', html.count('polishQuickQuestions')) +print('PROMO_SRC', 'PROMO_SRC' in html) +print('{3,}', '{3,}' in html) +# extract all script bodies and node-check +scripts = re.findall(r'', html) +print('scripts', len(scripts)) +ok = 0 +fail = 0 +for i, s in enumerate(scripts): + with tempfile.NamedTemporaryFile('w', suffix='.js', delete=False) as f: + f.write(s) + path = f.name + r = subprocess.run(['node', '--check', path], capture_output=True, text=True) + if r.returncode != 0: + fail += 1 + print('FAIL script', i, r.stderr[:400]) + # show around error line + m = re.search(r':(\d+)', r.stderr) + if m: + ln = int(m.group(1)) + lines = s.splitlines() + for j in range(max(0, ln-3), min(len(lines), ln+2)): + print(f'{j+1}: {lines[j][:160]}') + else: + ok += 1 +print('node_ok', ok, 'node_fail', fail) +PY +""" +_, o, e = c.exec_command(cmd, timeout=60) +sys.stdout.write(o.read().decode("utf-8", "replace")) +err = e.read().decode("utf-8", "replace") +if err: + sys.stderr.write(err[:1000]) +c.close() diff --git a/guest-cs-stack/_wait_chatwoot.py b/guest-cs-stack/_wait_chatwoot.py new file mode 100644 index 0000000..0d945ba --- /dev/null +++ b/guest-cs-stack/_wait_chatwoot.py @@ -0,0 +1,24 @@ +import os +import time +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +cmds = [ + "sudo docker ps --filter name=guest-chat --format '{{.Status}}'", + "sudo docker logs guest-chat --tail 40 2>&1", + "sleep 30; curl -sI http://127.0.0.1:7777/guest-customer-service | head -3", +] + +for cmd in cmds: + print("===", cmd[:80]) + _, o, e = c.exec_command(cmd, timeout=90) + print(o.read().decode()[:2500]) + err = e.read().decode() + if err.strip(): + print("ERR:", err[:200]) + +c.close() diff --git a/guest-cs-stack/_wait_verify.py b/guest-cs-stack/_wait_verify.py new file mode 100644 index 0000000..f28523f --- /dev/null +++ b/guest-cs-stack/_wait_verify.py @@ -0,0 +1,22 @@ +import os, time +import paramiko + +pw = os.environ["DEPLOY_PASSWORD"] +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect("20.191.152.26", username="wakka", password=pw, timeout=30) + +for i in range(6): + cmd = r""" +sudo docker ps --filter name=guest-chat --format '{{.Status}}' +curl -s -o /dev/null -w 'guest: %{http_code}\n' http://127.0.0.1:7777/guest-customer-service +curl -s 'http://127.0.0.1:7777/cw/widget?website_token=j1UAjqBgZMWQ8WfczgJSymQx' \ + -H 'Host: 20.191.152.26:7777' | grep -o 'CABLE_ORIGIN = "[^"]*"' | head -1 +sudo docker logs guest-chat --tail 15 2>&1 +""" + print(f"=== attempt {i+1} ===") + _, o, _ = c.exec_command(cmd, timeout=60) + print(o.read().decode()) + time.sleep(10) + +c.close() diff --git a/guest-cs-stack/bootstrap-chatwoot-test.sh b/guest-cs-stack/bootstrap-chatwoot-test.sh new file mode 100644 index 0000000..e42ee9e --- /dev/null +++ b/guest-cs-stack/bootstrap-chatwoot-test.sh @@ -0,0 +1,41 @@ +#!/bin/bash +set -euo pipefail +# test.pubuli.com:7777 空库初始化 +sudo docker exec guest-chat bundle exec rails runner " +email = 'admin@localhost.local' +password = 'LocalCw#168' +greeting = '您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?' +site_url = 'http://test.pubuli.com:7777' + +account = Account.first || Account.create!(name: '测试客服', locale: 'zh_TW') +user = User.find_by(email: email) +unless user + user = User.new(name: '测试管理员', email: email, password: password, password_confirmation: password) + user.save! + AccountUser.create!(account: account, user: user, role: :administrator) +end +user.confirm if user.respond_to?(:confirm) +user.update!(confirmed_at: Time.current) if user.respond_to?(:confirmed_at) && user.confirmed_at.nil? + +inbox = account.inboxes.first +unless inbox + channel = Channel::WebWidget.create!( + account: account, + website_url: site_url, + widget_color: '#E53935', + welcome_title: '', + welcome_tagline: greeting, + pre_chat_form_enabled: false, + continuity_via_email: false + ) + inbox = Inbox.create!(account: account, name: '49彩票集团客服', channel: channel, greeting_enabled: true, greeting_message: greeting, enable_email_collect: false) +end +inbox.update!(enable_email_collect: false) if inbox.respond_to?(:enable_email_collect=) +channel = inbox.channel +channel.update!(continuity_via_email: false, pre_chat_form_enabled: false) if channel.respond_to?(:continuity_via_email=) + +bot = AgentBot.find_by(name: 'AI 客服') || AgentBot.create!(name: 'AI 客服', outgoing_url: 'http://127.0.0.1:7788/webhook/bot1/chatwoot', account: account) +AgentBotInbox.find_or_create_by!(inbox: inbox, agent_bot: bot, account: account) + +puts \"OK account=#{account.id} inbox=#{inbox.id} token=#{inbox.channel.website_token} bot_url=#{bot.outgoing_url}\" +" diff --git a/guest-cs-stack/bootstrap-chatwoot.sh b/guest-cs-stack/bootstrap-chatwoot.sh new file mode 100644 index 0000000..0b05e56 --- /dev/null +++ b/guest-cs-stack/bootstrap-chatwoot.sh @@ -0,0 +1,39 @@ +#!/bin/bash +set -euo pipefail +sudo docker exec guest-chat bundle exec rails runner " +email = 'admin@localhost.local' +password = 'LocalCw#168' +greeting = '您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢?' + +account = Account.first || Account.create!(name: '本地客服', locale: 'zh_TW') +user = User.find_by(email: email) +unless user + user = User.new(name: '本地管理员', email: email, password: password, password_confirmation: password) + user.save! + AccountUser.create!(account: account, user: user, role: :administrator) +end +user.confirm if user.respond_to?(:confirm) +user.update!(confirmed_at: Time.current) if user.respond_to?(:confirmed_at) && user.confirmed_at.nil? + +inbox = account.inboxes.first +unless inbox + channel = Channel::WebWidget.create!( + account: account, + website_url: 'http://www.pubuli.com:7777', + widget_color: '#E53935', + welcome_title: '', + welcome_tagline: greeting, + pre_chat_form_enabled: false, + continuity_via_email: false + ) + inbox = Inbox.create!(account: account, name: '49彩票集团客服', channel: channel, greeting_enabled: true, greeting_message: greeting, enable_email_collect: false) +end +inbox.update!(enable_email_collect: false) if inbox.respond_to?(:enable_email_collect=) +channel = inbox.channel +channel.update!(continuity_via_email: false, pre_chat_form_enabled: false) if channel.respond_to?(:continuity_via_email=) + +bot = AgentBot.find_by(name: 'AI 客服') || AgentBot.create!(name: 'AI 客服', outgoing_url: 'http://127.0.0.1:7788/webhook/bot1/chatwoot', account: account) +AgentBotInbox.find_or_create_by!(inbox: inbox, agent_bot: bot, account: account) + +puts \"OK account=#{account.id} inbox=#{inbox.id} token=#{inbox.channel.website_token} bot_url=#{bot.outgoing_url}\" +" diff --git a/guest-cs-stack/build-and-save.bat b/guest-cs-stack/build-and-save.bat new file mode 100644 index 0000000..25efce4 --- /dev/null +++ b/guest-cs-stack/build-and-save.bat @@ -0,0 +1,15 @@ +@echo off +setlocal +cd /d "%~dp0" + +echo [1/3] Building images... +docker compose build +if errorlevel 1 exit /b 1 + +echo [2/3] Saving images to guest-cs-images.tar ... +docker save -o guest-cs-images.tar guest-cs-api:local guest-cs-chatwoot:v4.16.2 pgvector/pgvector:pg16 redis:alpine +if errorlevel 1 exit /b 1 + +echo [3/3] Done. +dir guest-cs-images.tar +endlocal diff --git a/guest-cs-stack/chatwoot.env b/guest-cs-stack/chatwoot.env new file mode 100644 index 0000000..c9d86c4 --- /dev/null +++ b/guest-cs-stack/chatwoot.env @@ -0,0 +1,28 @@ +SECRET_KEY_BASE=61db0140c2e890c28f6737913fb22734365590c7b9501590cd43b108532e13dfe368b1792e6a6bee1ad90dda9dbb42fca39c9d4ef76749c24488a63246ee01ca +FRONTEND_URL=http://127.0.0.1:3000 +FORCE_SSL=false +DEFAULT_LOCALE=zh_TW +ENABLE_ACCOUNT_SIGNUP=true +RAILS_ENV=production +NODE_ENV=production +RAILS_LOG_TO_STDOUT=true +RAILS_SERVE_STATIC_FILES=true +INSTALLATION_ENV=docker +BUNDLE_WITHOUT=development:test +EXECJS_RUNTIME=Disabled + +REDIS_URL=redis://redis:6379 +REDIS_PASSWORD=ziqIkS_An0QDXH9FQ70V7KTz + +POSTGRES_HOST=postgres +POSTGRES_USERNAME=postgres +POSTGRES_PASSWORD=xi_E9IhtgSYjC9PoQXZmUnMX +POSTGRES_DATABASE=chatwoot_production + +ACTIVE_STORAGE_SERVICE=local +MAILER_SENDER_EMAIL=Chatwoot +SMTP_ADDRESS= +ENABLE_PUSH_RELAY_SERVER=false + +# 允许 Agent Bot webhook 打 Docker 内网 guest-cs +SAFE_FETCH_ALLOW_PRIVATE_NETWORK=true diff --git a/guest-cs-stack/deploy-test.sh b/guest-cs-stack/deploy-test.sh new file mode 100644 index 0000000..bd8e6b0 --- /dev/null +++ b/guest-cs-stack/deploy-test.sh @@ -0,0 +1,38 @@ +#!/bin/bash +set -euo pipefail + +# test.pubuli.com:7788 一键部署 guest-chat +# 用法:在服务器上 git clone / 上传 guest-cs-stack 后执行 +# chmod +x deploy-test.sh && ./deploy-test.sh + +COMPOSE="docker compose -f docker-compose.chat.yaml -f docker-compose.chat.test.yaml" + +echo "[1/5] 停止占用 7788 的旧进程(Werkzeug / 旧 Flask)..." +if command -v fuser >/dev/null 2>&1; then + fuser -k 7788/tcp 2>/dev/null || true +fi +pkill -f "python.*7788" 2>/dev/null || true +pkill -f "flask run.*7788" 2>/dev/null || true +pkill -f "gunicorn.*7788" 2>/dev/null || true +sleep 2 + +echo "[2/5] 拉取最新镜像..." +docker pull ladioussuapp/guest-chat:latest +docker inspect ladioussuapp/guest-chat:latest --format 'Digest={{index .RepoDigests 0}}' + +echo "[3/5] 停止旧容器..." +$COMPOSE down 2>/dev/null || true + +echo "[4/5] 启动新栈..." +$COMPOSE up -d --force-recreate + +echo "[5/5] 等待就绪..." +sleep 15 +$COMPOSE ps +curl -sf -o /dev/null -w "guest HTTP %{http_code}\n" http://127.0.0.1:7788/guest-customer-service || true + +echo "" +echo "完成。请访问:" +echo " http://test.pubuli.com:7788/guest-customer-service" +echo "" +echo "若仍像旧版,请确认 Server 响应头应为 gunicorn,不是 Werkzeug。" diff --git a/guest-cs-stack/deploy-test7777.sh b/guest-cs-stack/deploy-test7777.sh new file mode 100644 index 0000000..52e98fa --- /dev/null +++ b/guest-cs-stack/deploy-test7777.sh @@ -0,0 +1,41 @@ +#!/bin/bash +set -euo pipefail + +# test.pubuli.com:7777 一键部署 guest-chat(等同本地 127.0.0.1:7788 Docker 版) +# 用法:在 test 服务器 (100.31.73.254) 上传 guest-cs-stack 后执行 +# chmod +x deploy-test7777.sh && ./deploy-test7777.sh + +COMPOSE="docker compose -f docker-compose.chat.yaml -f docker-compose.chat.test7777.yaml" + +echo "[1/6] 停止占用 7777/7788 的旧进程(Werkzeug / 旧 Flask)..." +fuser -k 7777/tcp 2>/dev/null || true +fuser -k 7788/tcp 2>/dev/null || true +pkill -f "python app.py" 2>/dev/null || true +pkill -f "python.*7777" 2>/dev/null || true +pkill -f "python.*7788" 2>/dev/null || true +sleep 2 + +echo "[2/6] 拉取最新镜像..." +docker pull ladioussuapp/guest-chat:latest +docker inspect ladioussuapp/guest-chat:latest --format 'Digest={{index .RepoDigests 0}}' + +echo "[3/6] 停止旧容器..." +$COMPOSE down 2>/dev/null || true + +echo "[4/6] 启动新栈..." +$COMPOSE up -d --force-recreate + +echo "[5/6] 等待就绪..." +sleep 25 +$COMPOSE ps + +echo "[6/6] 验证..." +curl -sf -o /dev/null -w "guest HTTP %{http_code}\n" http://127.0.0.1:7777/guest-customer-service || true +curl -sI http://127.0.0.1:7777/guest-customer-service | grep -i server || true + +echo "" +echo "若 Server 不是 gunicorn,或 Chatwoot 空库,执行:" +echo " chmod +x bootstrap-chatwoot-test.sh && ./bootstrap-chatwoot-test.sh" +echo "" +echo "完成。请访问:" +echo " http://test.pubuli.com:7777/guest-customer-service" diff --git a/guest-cs-stack/deploy_remote.py b/guest-cs-stack/deploy_remote.py new file mode 100644 index 0000000..2ef17a2 --- /dev/null +++ b/guest-cs-stack/deploy_remote.py @@ -0,0 +1,94 @@ +import os, sys, time +import paramiko + +HOST = "20.191.152.26" +USER = "wakka" +PASSWORD = os.environ["DEPLOY_PASSWORD"] +REMOTE_DIR = "/home/wakka/guest-cs-stack" +LOCAL_STACK = os.path.dirname(os.path.abspath(__file__)) + +FILES = [ + "docker-compose.chat.yaml", + "docker-compose.chat.remote.yaml", + "chatwoot.env", + "guest-cs.env", + "guest-cs.remote.env", + "bootstrap-chatwoot.sh", +] + +INSTALL_DOCKER = r""" +set -e +if ! command -v docker >/dev/null 2>&1; then + echo "[install] docker..." + sudo apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq docker.io docker-compose-v2 + sudo usermod -aG docker wakka || true +fi +docker --version +docker compose version +""" + +DEPLOY = rf""" +set -euo pipefail +cd {REMOTE_DIR} +echo "[deploy] stop old python app on 7777..." +pkill -f "python app.py" 2>/dev/null || true +fuser -k 7777/tcp 2>/dev/null || true +sleep 2 +echo "[deploy] pull..." +docker pull ladioussuapp/guest-chat:latest +echo "[deploy] up..." +docker compose -f docker-compose.chat.yaml -f docker-compose.chat.remote.yaml down 2>/dev/null || true +docker compose -f docker-compose.chat.yaml -f docker-compose.chat.remote.yaml up -d --force-recreate +sleep 25 +docker compose -f docker-compose.chat.yaml -f docker-compose.chat.remote.yaml ps +curl -sI http://127.0.0.1:7777/guest-customer-service | head -8 +""" + + +def run(client, cmd, timeout=900): + print("\n>>>", cmd[:120].replace("\n", " ")) + _, o, e = client.exec_command(cmd, timeout=timeout) + out = o.read().decode("utf-8", errors="replace") + err = e.read().decode("utf-8", errors="replace") + code = o.channel.recv_exit_status() + if out: + print(out) + if err: + print(err, file=sys.stderr) + return code, out, err + + +def main(): + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + c.connect(HOST, username=USER, password=PASSWORD, timeout=30) + run(c, "hostname; pwd; ls -la app.py 2>/dev/null; ps aux | grep 'python app' | grep -v grep") + + code, _, _ = run(c, INSTALL_DOCKER, timeout=900) + if code != 0: + return code + + # re-connect after docker group change may need new session with docker group + c.close() + time.sleep(2) + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + c.connect(HOST, username=USER, password=PASSWORD, timeout=30) + + run(c, f"mkdir -p {REMOTE_DIR}") + sftp = c.open_sftp() + for name in FILES: + sftp.put(os.path.join(LOCAL_STACK, name), f"{REMOTE_DIR}/{name}") + print(f"uploaded {name}") + sftp.close() + + # use sudo docker if group not active yet + deploy_cmd = DEPLOY.replace("docker ", "sudo docker ") + code, out, _ = run(c, deploy_cmd, timeout=900) + c.close() + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/guest-cs-stack/deploy_test7777.py b/guest-cs-stack/deploy_test7777.py new file mode 100644 index 0000000..3a13096 --- /dev/null +++ b/guest-cs-stack/deploy_test7777.py @@ -0,0 +1,122 @@ +"""Deploy guest-chat to test.pubuli.com (100.31.73.254) on port 7777.""" +from __future__ import annotations + +import os +import sys +import time + +import paramiko + +HOST = os.environ.get("DEPLOY_HOST", "100.31.73.254") +USER = os.environ.get("DEPLOY_USER", "wakka") +PASSWORD = os.environ["DEPLOY_PASSWORD"] +REMOTE_DIR = "/home/wakka/guest-cs-stack" +LOCAL_STACK = os.path.dirname(os.path.abspath(__file__)) + +FILES = [ + "docker-compose.chat.yaml", + "docker-compose.chat.test7777.yaml", + "chatwoot.env", + "guest-cs.env", + "guest-cs.test.env", + "deploy-test7777.sh", + "bootstrap-chatwoot-test.sh", +] + +INSTALL_DOCKER = r""" +set -e +if ! command -v docker >/dev/null 2>&1; then + echo "[install] docker..." + sudo apt-get update -qq + sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq docker.io docker-compose-v2 + sudo usermod -aG docker wakka || true +fi +docker --version +docker compose version +""" + +DEPLOY = rf""" +set -euo pipefail +cd {REMOTE_DIR} +echo "[deploy] stop old processes on 7777/7788..." +fuser -k 7777/tcp 2>/dev/null || true +fuser -k 7788/tcp 2>/dev/null || true +pkill -f "python app.py" 2>/dev/null || true +pkill -f "python.*7788" 2>/dev/null || true +pkill -f "python.*7777" 2>/dev/null || true +sleep 2 +echo "[deploy] pull..." +sudo docker pull ladioussuapp/guest-chat:latest +echo "[deploy] up..." +sudo docker compose -f docker-compose.chat.yaml -f docker-compose.chat.test7777.yaml down 2>/dev/null || true +sudo docker compose -f docker-compose.chat.yaml -f docker-compose.chat.test7777.yaml up -d --force-recreate +sleep 30 +sudo docker compose -f docker-compose.chat.yaml -f docker-compose.chat.test7777.yaml ps +curl -sI http://127.0.0.1:7777/guest-customer-service | head -8 +""" + + +def run(client, cmd, timeout=900): + print("\n>>>", cmd[:140].replace("\n", " ")) + _, o, e = client.exec_command(cmd, timeout=timeout) + out = o.read().decode("utf-8", errors="replace") + err = e.read().decode("utf-8", errors="replace") + code = o.channel.recv_exit_status() + if out: + print(out) + if err: + print(err, file=sys.stderr) + return code, out, err + + +def try_connect(host: str, user: str, password: str) -> paramiko.SSHClient | None: + try: + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + c.connect(host, username=user, password=password, timeout=20, allow_agent=False, look_for_keys=False) + return c + except Exception as exc: + print(f"connect {user}@{host} failed: {exc}") + return None + + +def main() -> int: + users = [USER, "root", "ubuntu", "admin"] + c = None + for u in dict.fromkeys(users): + c = try_connect(HOST, u, PASSWORD) + if c: + print(f"connected as {u}@{HOST}") + break + if not c: + return 1 + + run(c, "hostname; whoami; ss -tlnp | grep -E '7777|7788' || true") + run(c, INSTALL_DOCKER, timeout=900) + + c.close() + time.sleep(1) + c = try_connect(HOST, USER if c else users[0], PASSWORD) + if not c: + return 1 + + run(c, f"mkdir -p {REMOTE_DIR}") + sftp = c.open_sftp() + for name in FILES: + sftp.put(os.path.join(LOCAL_STACK, name), f"{REMOTE_DIR}/{name}") + print(f"uploaded {name}") + sftp.close() + + code, out, _ = run(c, DEPLOY, timeout=900) + + # bootstrap if empty db + if "Server: gunicorn" not in out: + run(c, f"chmod +x {REMOTE_DIR}/bootstrap-chatwoot.sh && bash {REMOTE_DIR}/bootstrap-chatwoot.sh", timeout=300) + run(c, DEPLOY, timeout=900) + + c.close() + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/guest-cs-stack/deploy_test7777_wakka.py b/guest-cs-stack/deploy_test7777_wakka.py new file mode 100644 index 0000000..b547f61 --- /dev/null +++ b/guest-cs-stack/deploy_test7777_wakka.py @@ -0,0 +1,120 @@ +"""Stop old API_System_Python on wakka-test-server and deploy guest-chat :7777 for test.pubuli.com.""" +from __future__ import annotations + +import os +import sys + +import paramiko + +HOST = "20.191.152.26" +USER = "wakka" +PASSWORD = os.environ["DEPLOY_PASSWORD"] +REMOTE = "/home/wakka/guest-cs-stack" +LOCAL = os.path.dirname(os.path.abspath(__file__)) +COMPOSE = "docker-compose.chat.yaml -f docker-compose.chat.test7777.yaml" + +FILES = [ + "docker-compose.chat.yaml", + "docker-compose.chat.test7777.yaml", + "chatwoot.env", + "guest-cs.env", + "guest-cs.test.env", + "bootstrap-chatwoot-test.sh", +] + + +def run(client: paramiko.SSHClient, cmd: str, timeout: int = 900) -> tuple[int, str]: + print(">>>", cmd.split("\n")[0][:110]) + _, stdout, stderr = client.exec_command(cmd, timeout=timeout) + out = stdout.read().decode("utf-8", errors="replace") + err = stderr.read().decode("utf-8", errors="replace") + code = stdout.channel.recv_exit_status() + if out: + print(out) + if err: + print(err, file=sys.stderr) + return code, out + + +def main() -> int: + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect(HOST, username=USER, password=PASSWORD, timeout=30) + + run(client, "hostname; screen -ls 2>/dev/null || true; ss -tlnp | grep -E '7777|7788' || true") + + stop_old = r""" +set -e +echo "[stop] old screen API_System_Python_7788..." +screen -S API_System_Python_7788 -X quit 2>/dev/null || true +for sid in $(screen -ls 2>/dev/null | awk '/API_System_Python/ {print $1}' | tr -d ')'); do + screen -S "$sid" -X quit 2>/dev/null || true +done +pkill -f "API_System_Python" 2>/dev/null || true +pkill -f "python app.py" 2>/dev/null || true +pkill -f "python.*7788" 2>/dev/null || true +pkill -f "python.*7777" 2>/dev/null || true +fuser -k 7777/tcp 7788/tcp 2>/dev/null || true +sleep 2 +screen -ls 2>/dev/null || echo "no screen" +ps aux | grep -E 'python.*app|API_System' | grep -v grep || echo "no old python" +""" + run(client, stop_old, timeout=120) + + run(client, f"mkdir -p {REMOTE}") + sftp = client.open_sftp() + for name in FILES: + local = os.path.join(LOCAL, name) + remote = f"{REMOTE}/{name}" + with open(local, "rb") as fh: + data = fh.read().replace(b"\r\n", b"\n") + with sftp.file(remote, "w") as rf: + rf.write(data) + print("uploaded", name) + sftp.close() + + deploy = f""" +set -euo pipefail +cd {REMOTE} +chmod +x bootstrap-chatwoot-test.sh 2>/dev/null || true +sudo docker pull ladioussuapp/guest-chat:latest +sudo docker compose -f {COMPOSE} up -d --force-recreate guest-chat +sleep 25 +sudo docker compose -f {COMPOSE} ps +curl -sI http://127.0.0.1:7777/guest-customer-service +sudo docker exec guest-chat printenv CHATWOOT_GUEST_BASE_URL FRONTEND_URL CHATWOOT_GUEST_WEBSITE_TOKEN APP_PORT +""" + + code, out = run(client, deploy, timeout=600) + if "gunicorn" not in out.lower(): + run(client, f"cd {REMOTE} && bash bootstrap-chatwoot-test.sh", timeout=300) + token_cmd = ( + "sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -t -A " + "-c \"SELECT website_token FROM channel_web_widgets LIMIT 1;\"" + ) + _, token_out = run(client, token_cmd, timeout=60) + token = token_out.strip() + if token: + env_content = ( + "# test.pubuli.com:7777\n" + f"CHATWOOT_GUEST_WEBSITE_TOKEN={token}\n" + "CHATWOOT_GUEST_BASE_URL=http://test.pubuli.com:7777/cw\n" + ) + sftp = client.open_sftp() + with sftp.file(f"{REMOTE}/guest-cs.test.env", "w") as f: + f.write(env_content) + sftp.close() + print("updated guest-cs.test.env token=", token) + run(client, deploy, timeout=600) + + verify = """ +curl -s http://127.0.0.1:7777/guest-customer-service | grep -o 'base_url[^,]*' | head -1 +curl -s http://127.0.0.1:7777/guest-customer-service | grep -o 'guest_new_path[^,]*' | head -1 +""" + run(client, verify, timeout=60) + client.close() + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/guest-cs-stack/deploy_test_on_wakka.py b/guest-cs-stack/deploy_test_on_wakka.py new file mode 100644 index 0000000..7d4d368 --- /dev/null +++ b/guest-cs-stack/deploy_test_on_wakka.py @@ -0,0 +1,91 @@ +import os +import sys +import time + +import paramiko + +HOST = "20.191.152.26" +USER = "wakka" +PASSWORD = os.environ["DEPLOY_PASSWORD"] +REMOTE_DIR = "/home/wakka/guest-cs-stack" +LOCAL_STACK = os.path.dirname(os.path.abspath(__file__)) + +# 目标:本地 127.0.0.1:7788 同款 → test.pubuli.com:7777(hostname: wakka-test-server) +COMPOSE = "docker-compose.chat.yaml -f docker-compose.chat.test7777.yaml" + +FILES = [ + "docker-compose.chat.yaml", + "docker-compose.chat.test7777.yaml", + "chatwoot.env", + "guest-cs.env", + "guest-cs.test.env", + "deploy-test7777.sh", + "bootstrap-chatwoot-test.sh", +] + +DEPLOY = rf""" +set -euo pipefail +cd {REMOTE_DIR} +chmod +x deploy-test7777.sh bootstrap-chatwoot-test.sh 2>/dev/null || true +echo "[deploy] stop old processes..." +fuser -k 7777/tcp 7788/tcp 2>/dev/null || true +pkill -f "python app.py" 2>/dev/null || true +sleep 2 +echo "[deploy] pull..." +sudo docker pull ladioussuapp/guest-chat:latest +echo "[deploy] up..." +sudo docker compose -f {COMPOSE} down 2>/dev/null || true +sudo docker compose -f {COMPOSE} up -d --force-recreate +sleep 30 +sudo docker compose -f {COMPOSE} ps +curl -sI http://127.0.0.1:7777/guest-customer-service | head -10 +""" + + +def run(client, cmd, timeout=900): + print("\n>>>", cmd[:140].replace("\n", " ")) + _, o, e = client.exec_command(cmd, timeout=timeout) + out = o.read().decode("utf-8", errors="replace") + err = e.read().decode("utf-8", errors="replace") + code = o.channel.recv_exit_status() + if out: + print(out) + if err: + print(err, file=sys.stderr) + return code, out, err + + +def main(): + c = paramiko.SSHClient() + c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + c.connect(HOST, username=USER, password=PASSWORD, timeout=30) + run(c, "hostname; ss -tlnp | grep -E '7777|7788' || true") + + run(c, f"mkdir -p {REMOTE_DIR}") + sftp = c.open_sftp() + for name in FILES: + sftp.put(os.path.join(LOCAL_STACK, name), f"{REMOTE_DIR}/{name}") + print("uploaded", name) + sftp.close() + + code, out, _ = run(c, DEPLOY, timeout=900) + if "Server: gunicorn" not in out and "200 OK" in out: + pass + elif "Server: gunicorn" not in out: + run(c, f"cd {REMOTE_DIR} && bash bootstrap-chatwoot-test.sh", timeout=300) + code, out, _ = run(c, DEPLOY, timeout=900) + + # 若 bootstrap 产生新 token,打印供更新 guest-cs.test.env + run( + c, + "sudo docker exec guest-chat-postgres-1 psql -U postgres -d chatwoot_production -t -c " + "\"SELECT website_token FROM channel_web_widgets LIMIT 1;\" 2>/dev/null || true", + timeout=60, + ) + run(c, "sudo docker exec guest-chat printenv | grep CHATWOOT_GUEST", timeout=60) + c.close() + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/guest-cs-stack/deploy_wakka_test7777.py b/guest-cs-stack/deploy_wakka_test7777.py new file mode 100644 index 0000000..3d1b4d8 --- /dev/null +++ b/guest-cs-stack/deploy_wakka_test7777.py @@ -0,0 +1,80 @@ +import os +import sys + +import paramiko + +HOST = "20.191.152.26" +USER = "wakka" +PASSWORD = os.environ["DEPLOY_PASSWORD"] +REMOTE = "/home/wakka/guest-cs-stack" +LOCAL = os.path.dirname(os.path.abspath(__file__)) +COMPOSE = "docker-compose.chat.yaml -f docker-compose.chat.test7777.yaml" + +FILES = [ + "docker-compose.chat.yaml", + "docker-compose.chat.test7777.yaml", + "chatwoot.env", + "guest-cs.env", + "guest-cs.test.env", + "bootstrap-chatwoot-test.sh", +] + + +def run(client, cmd, timeout=900): + print(">>>", cmd.split("\n")[0][:100]) + _, stdout, stderr = client.exec_command(cmd, timeout=timeout) + out = stdout.read().decode("utf-8", errors="replace") + err = stderr.read().decode("utf-8", errors="replace") + code = stdout.channel.recv_exit_status() + if out: + print(out) + if err: + print(err, file=sys.stderr) + return code, out + + +def main(): + client = paramiko.SSHClient() + client.set_missing_host_key_policy(paramiko.AutoAddPolicy()) + client.connect(HOST, username=USER, password=PASSWORD, timeout=30) + + run(client, "hostname; ss -tlnp | grep 7777 || true") + run(client, f"mkdir -p {REMOTE}") + + sftp = client.open_sftp() + for name in FILES: + sftp.put(os.path.join(LOCAL, name), f"{REMOTE}/{name}") + print("uploaded", name) + sftp.close() + + deploy = f""" +set -euo pipefail +cd {REMOTE} +fuser -k 7777/tcp 7788/tcp 2>/dev/null || true +pkill -f "python app.py" 2>/dev/null || true +sleep 2 +sudo docker pull ladioussuapp/guest-chat:latest +sudo docker compose -f {COMPOSE} down || true +sudo docker compose -f {COMPOSE} up -d --force-recreate +""" + + verify = f""" +cd {REMOTE} +sleep 25 +sudo docker compose -f {COMPOSE} ps +curl -sI http://127.0.0.1:7777/guest-customer-service +sudo docker exec guest-chat printenv CHATWOOT_GUEST_BASE_URL FRONTEND_URL CHATWOOT_GUEST_WEBSITE_TOKEN +""" + + code, out = run(client, deploy, timeout=600) + code, out = run(client, verify, timeout=120) + if "gunicorn" not in out.lower(): + run(client, f"cd {REMOTE} && chmod +x bootstrap-chatwoot-test.sh && bash bootstrap-chatwoot-test.sh", 300) + code, out = run(client, deploy, timeout=900) + + client.close() + return code + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/guest-cs-stack/docker-compose.chat-data.yaml b/guest-cs-stack/docker-compose.chat-data.yaml new file mode 100644 index 0000000..e1e4b54 --- /dev/null +++ b/guest-cs-stack/docker-compose.chat-data.yaml @@ -0,0 +1,23 @@ +# 复用本机旧 Chatwoot 数据(inbox / website_token / 对话) +# 与 docker-compose.chat.yaml 一起用 + +services: + guest-chat: + volumes: !override + - chatwoot_storage_data:/app/storage + + postgres: + volumes: !override + - chatwoot_postgres_data:/var/lib/postgresql/data + + redis: + volumes: !override + - chatwoot_redis_data:/data + +volumes: + chatwoot_storage_data: + external: true + chatwoot_postgres_data: + external: true + chatwoot_redis_data: + external: true diff --git a/guest-cs-stack/docker-compose.chat.remote.yaml b/guest-cs-stack/docker-compose.chat.remote.yaml new file mode 100644 index 0000000..c1cf7d0 --- /dev/null +++ b/guest-cs-stack/docker-compose.chat.remote.yaml @@ -0,0 +1,12 @@ +# 远端部署(对外 7777 → 容器 7788) +# www.pubuli.com / 20.191.152.26 + +services: + guest-chat: + ports: !override + - "7777:7788" + env_file: + - ./guest-cs.remote.env + environment: + CHATWOOT_GUEST_BASE_URL: http://www.pubuli.com:7777/cw + FRONTEND_URL: http://www.pubuli.com:7777 diff --git a/guest-cs-stack/docker-compose.chat.test.yaml b/guest-cs-stack/docker-compose.chat.test.yaml new file mode 100644 index 0000000..e5047c3 --- /dev/null +++ b/guest-cs-stack/docker-compose.chat.test.yaml @@ -0,0 +1,12 @@ +# test.pubuli.com 部署(对外 7788) +# 在服务器上执行: +# docker pull ladioussuapp/guest-chat:latest +# docker compose -f docker-compose.chat.yaml -f docker-compose.chat.test.yaml up -d --force-recreate + +services: + guest-chat: + ports: !override + - "7788:7788" + environment: + CHATWOOT_GUEST_BASE_URL: http://test.pubuli.com:7788/cw + FRONTEND_URL: http://test.pubuli.com:7788 diff --git a/guest-cs-stack/docker-compose.chat.test7777.yaml b/guest-cs-stack/docker-compose.chat.test7777.yaml new file mode 100644 index 0000000..5913a75 --- /dev/null +++ b/guest-cs-stack/docker-compose.chat.test7777.yaml @@ -0,0 +1,12 @@ +# test.pubuli.com 部署(对外 7777 → 容器 7788) +# docker compose -f docker-compose.chat.yaml -f docker-compose.chat.test7777.yaml up -d --force-recreate + +services: + guest-chat: + ports: !override + - "7777:7788" + env_file: + - ./guest-cs.test.env + environment: + CHATWOOT_GUEST_BASE_URL: http://20.191.152.26:7777/cw + FRONTEND_URL: http://20.191.152.26:7777 diff --git a/guest-cs-stack/docker-compose.chat.yaml b/guest-cs-stack/docker-compose.chat.yaml new file mode 100644 index 0000000..dac49ca --- /dev/null +++ b/guest-cs-stack/docker-compose.chat.yaml @@ -0,0 +1,78 @@ +# 只部署「在线客服聊天框」(production 预编译前端) +# 浏览器入口:http://127.0.0.1:7788/guest-customer-service +# +# docker pull ladioussuapp/guest-chat:latest +# docker compose -f docker-compose.chat.yaml -f docker-compose.reuse-chatwoot-data.yaml up -d + +name: guest-chat + +x-cw-env: &cw-env + NODE_ENV: production + RAILS_ENV: production + INSTALLATION_ENV: docker + RAILS_SERVE_STATIC_FILES: "true" + +services: + guest-chat: + image: ladioussuapp/guest-chat:latest + build: + context: . + dockerfile: Dockerfile.chatwidget + args: + RAILS_ENV: production + NODE_ENV: production + container_name: guest-chat + restart: always + env_file: + - ./chatwoot.env + - ./guest-cs.env + environment: + <<: *cw-env + CHATWOOT_GUEST_PROXY_TARGET: http://pai.pubuli.com:3000 + CHATWOOT_GUEST_API_BASE: http://pai.pubuli.com:3000 + CHATWOOT_GUEST_BASE_URL: http://127.0.0.1:7788/cw + APP_DEBUG: "false" + ports: + # 只对外开聊天框;Chatwoot 后台 3000 不映射 + - "127.0.0.1:7788:7788" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - storage_data:/app/storage + depends_on: + - postgres + - redis + networks: + - guest-chat-net + + postgres: + image: pgvector/pgvector:pg16 + restart: always + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + POSTGRES_DB: chatwoot_production + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-xi_E9IhtgSYjC9PoQXZmUnMX} + networks: + - guest-chat-net + + redis: + image: redis:alpine + restart: always + command: ["sh", "-c", "redis-server --requirepass \"$$REDIS_PASSWORD\""] + env_file: + - ./chatwoot.env + volumes: + - redis_data:/data + networks: + - guest-chat-net + +volumes: + storage_data: + postgres_data: + redis_data: + +networks: + guest-chat-net: + driver: bridge diff --git a/guest-cs-stack/docker-compose.production.yaml b/guest-cs-stack/docker-compose.production.yaml new file mode 100644 index 0000000..4706918 --- /dev/null +++ b/guest-cs-stack/docker-compose.production.yaml @@ -0,0 +1,79 @@ +# 对照官方 https://github.com/chatwoot/chatwoot/blob/v4.16.2/docker-compose.production.yaml +# Chatwoot 使用已预编译前端的 production 镜像;另含 Flask 一包镜像 +# +# 启动: +# docker compose -f docker-compose.production.yaml up -d +# 拉取一包: +# docker pull ladioussuapp/guest-cs:latest + +name: guest-cs + +x-cw-env: &cw-env + NODE_ENV: production + RAILS_ENV: production + INSTALLATION_ENV: docker + RAILS_SERVE_STATIC_FILES: "true" + +services: + guest-cs: + image: ladioussuapp/guest-cs:latest + build: + context: . + dockerfile: Dockerfile.allinone + args: + RAILS_ENV: production + NODE_ENV: production + container_name: guest-cs + restart: always + env_file: + - ./chatwoot.env + - ./guest-cs.env + environment: + <<: *cw-env + CHATWOOT_GUEST_PROXY_TARGET: http://127.0.0.1:3000 + CHATWOOT_GUEST_API_BASE: http://127.0.0.1:3000 + APP_DEBUG: "false" + ports: + - "127.0.0.1:3000:3000" + - "127.0.0.1:7788:7788" + extra_hosts: + - "host.docker.internal:host-gateway" + volumes: + - storage_data:/app/storage + depends_on: + - postgres + - redis + networks: + - guest-cs-net + + postgres: + image: pgvector/pgvector:pg16 + restart: always + volumes: + - postgres_data:/var/lib/postgresql/data + environment: + POSTGRES_DB: chatwoot_production + POSTGRES_USER: postgres + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-xi_E9IhtgSYjC9PoQXZmUnMX} + networks: + - guest-cs-net + + redis: + image: redis:alpine + restart: always + command: ["sh", "-c", "redis-server --requirepass \"$$REDIS_PASSWORD\""] + env_file: + - ./chatwoot.env + volumes: + - redis_data:/data + networks: + - guest-cs-net + +volumes: + storage_data: + postgres_data: + redis_data: + +networks: + guest-cs-net: + driver: bridge diff --git a/guest-cs-stack/docker-compose.reuse-chatwoot-data.yaml b/guest-cs-stack/docker-compose.reuse-chatwoot-data.yaml new file mode 100644 index 0000000..fb64aaa --- /dev/null +++ b/guest-cs-stack/docker-compose.reuse-chatwoot-data.yaml @@ -0,0 +1,23 @@ +# 复用本机旧 Chatwoot volume(账号/inbox/website_token/对话) +# docker compose -f docker-compose.production.yaml -f docker-compose.reuse-chatwoot-data.yaml up -d + +services: + guest-cs: + volumes: !override + - chatwoot_storage_data:/app/storage + + postgres: + volumes: !override + - chatwoot_postgres_data:/var/lib/postgresql/data + + redis: + volumes: !override + - chatwoot_redis_data:/data + +volumes: + chatwoot_storage_data: + external: true + chatwoot_postgres_data: + external: true + chatwoot_redis_data: + external: true diff --git a/guest-cs-stack/docker-compose.test-ports.yaml b/guest-cs-stack/docker-compose.test-ports.yaml new file mode 100644 index 0000000..5b5eec6 --- /dev/null +++ b/guest-cs-stack/docker-compose.test-ports.yaml @@ -0,0 +1,9 @@ +# 临时测试端口,避免与本机已运行的 3000/7788 冲突 +services: + guest-cs: + ports: !override + - "127.0.0.1:13000:3000" + - "127.0.0.1:17788:7788" + environment: + FRONTEND_URL: http://127.0.0.1:13000 + CHATWOOT_GUEST_BASE_URL: http://127.0.0.1:17788/cw diff --git a/guest-cs-stack/docker-compose.yml b/guest-cs-stack/docker-compose.yml new file mode 100644 index 0000000..e1114f2 --- /dev/null +++ b/guest-cs-stack/docker-compose.yml @@ -0,0 +1,7 @@ +# 默认等同 production(已预编译前端,非 development) +# 建议直接用:docker compose -f docker-compose.production.yaml up -d + +name: guest-cs + +include: + - path: docker-compose.production.yaml diff --git a/guest-cs-stack/flask/.dockerignore b/guest-cs-stack/flask/.dockerignore new file mode 100644 index 0000000..d6f78cd --- /dev/null +++ b/guest-cs-stack/flask/.dockerignore @@ -0,0 +1,8 @@ +venv/ +__pycache__/ +*.pyc +*.pyo +.env +.maxkb_cw_sessions.json +.pytest_cache/ +*.md diff --git a/guest-cs-stack/flask/.env.example b/guest-cs-stack/flask/.env.example new file mode 100644 index 0000000..7ca1384 --- /dev/null +++ b/guest-cs-stack/flask/.env.example @@ -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 diff --git a/guest-cs-stack/flask/.maxkb_cw_sessions.json b/guest-cs-stack/flask/.maxkb_cw_sessions.json new file mode 100644 index 0000000..7a0d985 --- /dev/null +++ b/guest-cs-stack/flask/.maxkb_cw_sessions.json @@ -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 + } +} \ No newline at end of file diff --git a/guest-cs-stack/flask/Dockerfile b/guest-cs-stack/flask/Dockerfile new file mode 100644 index 0000000..19b4435 --- /dev/null +++ b/guest-cs-stack/flask/Dockerfile @@ -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"] diff --git a/guest-cs-stack/flask/app.py b/guest-cs-stack/flask/app.py new file mode 100644 index 0000000..0a2964c --- /dev/null +++ b/guest-cs-stack/flask/app.py @@ -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/", + methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS", "HEAD"], +) +def chatwoot_guest_proxy(subpath: str): + """把本機 Chatwoot widget 代理進來,才能注入略過「開始對話」的腳本。""" + return proxy_chatwoot(subpath) + + +@app.route( + "/vite/", + 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/", + 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/", + 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) diff --git a/guest-cs-stack/flask/chatwoot_guest.py b/guest-cs-stack/flask/chatwoot_guest.py new file mode 100644 index 0000000..709599a --- /dev/null +++ b/guest-cs-stack/flask/chatwoot_guest.py @@ -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("=") diff --git a/guest-cs-stack/flask/chatwoot_proxy.py b/guest-cs-stack/flask/chatwoot_proxy.py new file mode 100644 index 0000000..b79bffc --- /dev/null +++ b/guest-cs-stack/flask/chatwoot_proxy.py @@ -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 = """ + + + +""" + + +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 "" in html: + html = html.replace("", inject + "", 1) + elif "" in html: + html = html.replace("", inject + "", 1) + else: + html += inject + payload = html.encode("utf-8") + + return Response(payload, status=status, headers=response_headers) diff --git a/guest-cs-stack/flask/config.py b/guest-cs-stack/flask/config.py new file mode 100644 index 0000000..b79b8b3 --- /dev/null +++ b/guest-cs-stack/flask/config.py @@ -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() diff --git a/guest-cs-stack/flask/db.py b/guest-cs-stack/flask/db.py new file mode 100644 index 0000000..1803500 --- /dev/null +++ b/guest-cs-stack/flask/db.py @@ -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() diff --git a/guest-cs-stack/flask/maxkb_chatwoot_bridge.py b/guest-cs-stack/flask/maxkb_chatwoot_bridge.py new file mode 100644 index 0000000..967ae9c --- /dev/null +++ b/guest-cs-stack/flask/maxkb_chatwoot_bridge.py @@ -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"\s*(.*?)\s*", + 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 的 標籤拆成純文字 + 按鈕選項。""" + 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 ( + "您好,取消提款需由专员协助核实处理,请稍候为您转接真人客服。" + "转专人服务" + ) + 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, + } diff --git a/guest-cs-stack/flask/preflight.py b/guest-cs-stack/flask/preflight.py new file mode 100644 index 0000000..3f4d142 --- /dev/null +++ b/guest-cs-stack/flask/preflight.py @@ -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()) diff --git a/guest-cs-stack/flask/repository.py b/guest-cs-stack/flask/repository.py new file mode 100644 index 0000000..ab54f7f --- /dev/null +++ b/guest-cs-stack/flask/repository.py @@ -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 diff --git a/guest-cs-stack/flask/requirements.txt b/guest-cs-stack/flask/requirements.txt new file mode 100644 index 0000000..8a9c356 --- /dev/null +++ b/guest-cs-stack/flask/requirements.txt @@ -0,0 +1,4 @@ +Flask>=3.1,<4 +PyMySQL>=1.1,<2 +python-dotenv>=1.0,<2 +gunicorn>=23,<24 diff --git a/guest-cs-stack/flask/smoke_test.py b/guest-cs-stack/flask/smoke_test.py new file mode 100644 index 0000000..6f7aab4 --- /dev/null +++ b/guest-cs-stack/flask/smoke_test.py @@ -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() diff --git a/guest-cs-stack/flask/smoke_test_pubuli.py b/guest-cs-stack/flask/smoke_test_pubuli.py new file mode 100644 index 0000000..04dbcca --- /dev/null +++ b/guest-cs-stack/flask/smoke_test_pubuli.py @@ -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()) diff --git a/guest-cs-stack/flask/static/guest_promo.png b/guest-cs-stack/flask/static/guest_promo.png new file mode 100644 index 0000000..7097b23 Binary files /dev/null and b/guest-cs-stack/flask/static/guest_promo.png differ diff --git a/guest-cs-stack/flask/static/style.css b/guest-cs-stack/flask/static/style.css new file mode 100644 index 0000000..238517d --- /dev/null +++ b/guest-cs-stack/flask/static/style.css @@ -0,0 +1,75 @@ +:root{--bg:#090d1a;--panel:#11182b;--panel2:#19233c;--text:#f5f7ff;--muted:#9aa7c5;--accent:#ffcc4d;--accent2:#7b61ff;--danger:#ff6577;--success:#50d890;--border:rgba(255,255,255,.09)} +*{box-sizing:border-box}body{margin:0;min-height:100vh;font-family:Inter,"Noto Sans TC","Microsoft JhengHei",sans-serif;color:var(--text);background:radial-gradient(circle at top right,rgba(123,97,255,.20),transparent 34%),radial-gradient(circle at bottom left,rgba(255,204,77,.10),transparent 30%),var(--bg)}a{color:inherit}.login-body{display:grid;place-items:center;padding:24px}.login-card{width:min(440px,100%);padding:36px;background:rgba(17,24,43,.94);border:1px solid var(--border);border-radius:24px;box-shadow:0 24px 80px rgba(0,0,0,.38)}.brand-mark{display:grid;place-items:center;width:58px;height:58px;border-radius:18px;background:linear-gradient(135deg,var(--accent),#ff8a4d);color:#1b1520;font-weight:900;letter-spacing:.08em}h1,h2,h3,p{margin-top:0}.muted,.site-subtitle,.section-heading span{color:var(--muted)}label{display:block;margin:24px 0 8px;font-weight:700}input{width:100%;padding:14px 16px;color:var(--text);background:#0c1222;border:1px solid var(--border);border-radius:12px;outline:none}input:focus{border-color:var(--accent2)}.primary-button,.game-card button{width:100%;margin-top:16px;padding:14px 18px;border:0;border-radius:12px;font-weight:800}.primary-button,.primary-link{color:#17120a;background:var(--accent);cursor:pointer}.primary-link{display:inline-block;padding:12px 16px;border-radius:12px;text-decoration:none;font-weight:800}.security-note{margin:18px 0 0;color:var(--muted);font-size:13px}.topbar{display:flex;align-items:center;justify-content:space-between;gap:24px;padding:18px clamp(18px,4vw,56px);background:rgba(9,13,26,.88);border-bottom:1px solid var(--border);backdrop-filter:blur(16px);position:sticky;top:0;z-index:10}.site-title{font-weight:900;letter-spacing:.08em}.site-subtitle{margin-top:3px;font-size:13px}.member-area{display:flex;align-items:center;gap:12px;flex-wrap:wrap;justify-content:flex-end}.member-area form{margin:0}.logout-button,.back-link{padding:9px 13px;border:1px solid var(--border);border-radius:10px;color:var(--text);background:transparent;text-decoration:none;cursor:pointer}.status-badge{padding:5px 9px;border-radius:999px;font-size:12px;font-weight:800}.status-badge.normal{color:var(--success);background:rgba(80,216,144,.12)}.status-badge.locked{color:var(--danger);background:rgba(255,101,119,.12)}.page{width:min(1180px,calc(100% - 32px));margin:0 auto;padding:40px 0 72px}.page.narrow{width:min(820px,calc(100% - 32px))}.hero{display:grid;grid-template-columns:1fr auto;align-items:center;gap:24px;min-height:280px;padding:clamp(28px,5vw,60px);margin-bottom:24px;border:1px solid var(--border);border-radius:28px;background:linear-gradient(135deg,rgba(123,97,255,.26),rgba(17,24,43,.94)),var(--panel)}.hero h1{max-width:720px;font-size:clamp(30px,5vw,56px);line-height:1.08}.hero p{max-width:720px;color:var(--muted);font-size:17px}.eyebrow{color:var(--accent);font-size:12px;font-weight:900;letter-spacing:.16em}.service-entry{display:flex;gap:14px;align-items:center;min-width:230px;padding:18px;border:1px solid rgba(255,204,77,.35);border-radius:18px;background:rgba(255,204,77,.10);text-decoration:none}.service-entry small{display:block;margin-top:4px;color:var(--muted)}.service-icon{font-size:34px}.account-grid{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:14px;margin-bottom:34px}.metric-card{padding:18px;border:1px solid var(--border);border-radius:16px;background:var(--panel)}.metric-card span{display:block;color:var(--muted);font-size:13px;margin-bottom:8px}.metric-card strong{font-size:20px}.section-heading{display:flex;align-items:end;justify-content:space-between;gap:18px;margin:0 4px 16px}.game-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.game-card{display:grid;grid-template-columns:auto 1fr;gap:16px;padding:22px;border:1px solid var(--border);border-radius:20px;background:var(--panel)}.game-card h3{margin-bottom:6px}.game-card p{margin-bottom:0;color:var(--muted)}.game-icon{font-size:38px}.game-card button{grid-column:1/-1;color:var(--muted);background:var(--panel2);cursor:not-allowed}.floating-service{position:fixed;right:22px;bottom:22px;padding:14px 18px;border-radius:999px;color:#17120a;background:var(--accent);font-weight:900;text-decoration:none;box-shadow:0 16px 36px rgba(0,0,0,.35)}.alert{padding:14px 16px;margin-bottom:18px;border-radius:12px}.alert-error{color:#ffdfe3;background:rgba(255,101,119,.14);border:1px solid rgba(255,101,119,.28)}.alert-warning{color:#fff2c5;background:rgba(255,204,77,.12);border:1px solid rgba(255,204,77,.28)}.support-panel{padding:34px;border:1px solid var(--border);border-radius:24px;background:var(--panel)}.support-avatar{font-size:48px}.empty-state{padding:24px;color:var(--muted);background:var(--panel2);border-radius:14px}.order-list{display:grid;gap:12px}.order-card{display:grid;gap:7px;padding:16px;border:1px solid var(--border);border-radius:14px;background:var(--panel2)}.order-card span{color:var(--muted)}.section-gap{margin-top:30px}@media(max-width:800px){.hero{grid-template-columns:1fr}.game-grid{grid-template-columns:1fr 1fr}.account-grid{grid-template-columns:1fr 1fr}.topbar{align-items:flex-start}}@media(max-width:560px){.game-grid,.account-grid{grid-template-columns:1fr}.topbar{flex-direction:column}.member-area{justify-content:flex-start}.login-card{padding:26px}} + + +.login-divider { + display: flex; + align-items: center; + gap: 12px; + margin: 20px 0; + color: var(--muted); +} +.login-divider::before, +.login-divider::after { + content: ""; + flex: 1; + height: 1px; + background: var(--border); +} +.guest-service-button { + display: block; + padding: 14px 18px; + border: 1px solid rgba(255, 204, 77, .35); + border-radius: 12px; + color: var(--accent); + background: rgba(255, 204, 77, .08); + text-align: center; + text-decoration: none; + font-weight: 800; +} +.guest-service-note { + margin-top: 12px; + color: var(--muted); + font-size: 13px; + line-height: 1.6; +} +.guest-support-panel { + text-align: center; +} +.guest-badge { + display: inline-block; + margin: 6px 0 18px; + padding: 6px 10px; + border-radius: 999px; + color: var(--accent); + background: rgba(255, 204, 77, .10); + font-size: 12px; + font-weight: 800; +} +.guest-open-chat { + max-width: 320px; + margin-left: auto; + margin-right: auto; +} + +/* 旧版:仅保留客服气泡尺寸,不盖白底主题 */ +.guest-page .woot-widget-holder, +.guest-page .woot-widget-holder.woot-elements--right { + width: 400px !important; + height: min(82vh, 760px) !important; + max-height: 82vh !important; +} +.guest-page .woot-widget-holder iframe, +.guest-page .woot-widget-holder .woot-widget-box { + width: 100% !important; + height: 100% !important; + min-height: min(82vh, 760px) !important; +} +.guest-page .woot-widget-bubble, +.woot-widget-bubble { + background: #7b61ff !important; +} +.guest-page .woot-widget-bubble:hover, +.woot-widget-bubble:hover { + background: #6a52e6 !important; +} diff --git a/guest-cs-stack/flask/templates/_chatwoot.html b/guest-cs-stack/flask/templates/_chatwoot.html new file mode 100644 index 0000000..a775a63 --- /dev/null +++ b/guest-cs-stack/flask/templates/_chatwoot.html @@ -0,0 +1,456 @@ +{% if chatwoot_config.enabled %} + +{% endif %} diff --git a/guest-cs-stack/flask/templates/base.html b/guest-cs-stack/flask/templates/base.html new file mode 100644 index 0000000..54c6b66 --- /dev/null +++ b/guest-cs-stack/flask/templates/base.html @@ -0,0 +1,13 @@ + + + + + + {% block title %}{{ app_name }}{% endblock %} + + + +{% block body %}{% endblock %} +{% block integrations %}{% endblock %} + + diff --git a/guest-cs-stack/flask/templates/customer_service.html b/guest-cs-stack/flask/templates/customer_service.html new file mode 100644 index 0000000..d4f8901 --- /dev/null +++ b/guest-cs-stack/flask/templates/customer_service.html @@ -0,0 +1,67 @@ +{% extends "base.html" %} +{% block title %}客服中心|{{ app_name }}{% endblock %} +{% block body %} +
+
+
客服測試入口
+
目前用戶:{{ username }}
+
+ 返回首頁 +
+ +
+
+
🎧
+

會員資料查詢

+ {% if error %} +
{{ error }}
+ {% else %} + {% if account.status == "locked" %} +
+ 帳戶已凍結{% if account.locked_reason %}|{{ account.locked_reason }}{% endif %} +
+ {% endif %} + +

未完成提款

+ {% if not withdrawals %} +
目前沒有查到未完成提款資料。
+ {% else %} +
+ {% for order in withdrawals %} +
+ {{ order.status or "狀態未知" }} + 來源:{{ order.source }} + 訂單:{{ order.order_id or "NA" }} + 金額:{{ order.amount }} + 時間:{{ order.create_time or "NA" }} + {% if order.fail_reason %}備註:{{ order.fail_reason }}{% endif %} +
+ {% endfor %} +
+ {% endif %} + +

最近充值

+ {% if not deposits %} +
目前沒有查到充值資料。
+ {% else %} +
+ {% for order in deposits %} +
+ {{ order.status or "狀態未知" }} + 訂單:{{ order.thirdparty_order_id or "NA" }} + 充值金額:{{ order.amount }} + 實際到帳:{{ order.actual_arrival_amount }} + 充值時間:{{ order.created_at or "NA" }} + 到帳時間:{{ order.arrival_time or "NA" }} +
+ {% endfor %} +
+ {% endif %} + {% endif %} +
+
+{% endblock %} + +{% block integrations %} +{% include "_chatwoot.html" %} +{% endblock %} diff --git a/guest-cs-stack/flask/templates/error.html b/guest-cs-stack/flask/templates/error.html new file mode 100644 index 0000000..8ba4c6a --- /dev/null +++ b/guest-cs-stack/flask/templates/error.html @@ -0,0 +1,11 @@ +{% extends "base.html" %} +{% block title %}{{ title }}|{{ app_name }}{% endblock %} +{% block body_class %}login-body{% endblock %} +{% block body %} +
+
!
+

{{ title }}

+
{{ message }}
+ 返回系統 +
+{% endblock %} diff --git a/guest-cs-stack/flask/templates/guest_customer_service.html b/guest-cs-stack/flask/templates/guest_customer_service.html new file mode 100644 index 0000000..7ee5e3f --- /dev/null +++ b/guest-cs-stack/flask/templates/guest_customer_service.html @@ -0,0 +1,38 @@ +{% extends "base.html" %} +{% block title %}遊客客服|{{ app_name }}{% endblock %} +{% block body_class %}guest-page{% endblock %} + +{% block body %} +
+
+
遊客客服中心
+
Guest Customer Service
+
+ 返回登入頁 +
+ +
+
+
🎧
+ 遊客模式 +

歡迎使用線上客服

+

您目前以遊客身分進入,可諮詢註冊、登入、活動與平台操作問題。

+ +
+ 遊客模式不會識別會員身分,也不能查詢充值、提款、餘額或其他會員資料。 +
+ + +
+
+{% endblock %} + +{% block integrations %} +{% include "_chatwoot.html" %} +{% endblock %} diff --git a/guest-cs-stack/flask/templates/home.html b/guest-cs-stack/flask/templates/home.html new file mode 100644 index 0000000..c27c5f1 --- /dev/null +++ b/guest-cs-stack/flask/templates/home.html @@ -0,0 +1,74 @@ +{% extends "base.html" %} +{% block title %}遊戲首頁|{{ app_name }}{% endblock %} +{% block body %} +
+
+
YD GAME DEMO
+
Python 唯讀 API 測試站點
+
+
+ 用戶:{{ account.raw_username or username }} + + {{ "已凍結" if account.status == "locked" else "正常" }} + +
+ + +
+
+
+ +
+ {% if warning %} +
帳戶資料讀取失敗:{{ warning }}
+ {% endif %} + + {% if account.status == "locked" %} +
+ 帳戶已凍結 + {% if account.locked_reason %}|凍結原因:{{ account.locked_reason }}{% endif %} +
+ {% endif %} + +
+
+ PYTHON TEST ENVIRONMENT +

歡迎進入遊戲站點模擬首頁

+

登入會員:{{ account.username_clean or username }}。本頁只用於驗證會員資料、充值、提款與客服 API。

+
+ + 🎧 + 在線客服查詢充值與提款資料 + +
+ + + +
+

熱門遊戲

畫面模擬,不執行真實投注
+
+ {% for game in games %} +
+
{{ game.icon }}
+

{{ game.name }}

{{ game.category }}

+ +
+ {% endfor %} +
+
+
+🎧 客服 +{% endblock %} + +{% block integrations %} +{% include "_chatwoot.html" %} +{% endblock %} diff --git a/guest-cs-stack/flask/templates/login.html b/guest-cs-stack/flask/templates/login.html new file mode 100644 index 0000000..125912e --- /dev/null +++ b/guest-cs-stack/flask/templates/login.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} +{% block title %}登录|{{ app_name }}{% endblock %} +{% block body_class %}login-body{% endblock %} +{% block body %} +
+
YD
+

游戏站点模拟

+

仅使用 members 表内存在的用户名登录,不需要密码。

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endwith %} + +
+ + + + +
+ + + + + 🎧 游客进入客服 + + +

+ 游客只能进行一般咨询,不能查询会员充值、提款或账户资料。 +

+ +

【测试限定】此登录方式不具备正式身份验证能力。

+
+{% endblock %} diff --git a/guest-cs-stack/flask/test_chatwoot_hmac.py b/guest-cs-stack/flask/test_chatwoot_hmac.py new file mode 100644 index 0000000..85c8d49 --- /dev/null +++ b/guest-cs-stack/flask/test_chatwoot_hmac.py @@ -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) diff --git a/guest-cs-stack/flask/wsgi.py b/guest-cs-stack/flask/wsgi.py new file mode 100644 index 0000000..74dcf8d --- /dev/null +++ b/guest-cs-stack/flask/wsgi.py @@ -0,0 +1,4 @@ +from app import app + + +application = app diff --git a/guest-cs-stack/guest-cs.env b/guest-cs-stack/guest-cs.env new file mode 100644 index 0000000..4cb29cb --- /dev/null +++ b/guest-cs-stack/guest-cs.env @@ -0,0 +1,56 @@ +APP_NAME=API接口系统 +APP_HOST=0.0.0.0 +APP_PORT=7788 +APP_DEBUG=false + +CHATWOOT_ENABLED=true +CHATWOOT_BASE_URL=http://pai.pubuli.com:3000 +CHATWOOT_WEBSITE_TOKEN=FZBhe2W4VXSXNT9Ek4etmP3L +CHATWOOT_IDENTITY_SECRET=9Fq7fmzHmButzJdq2mkbaHx5 +CHATWOOT_DEBUG=false +CHATWOOT_ACCOUNT_ID=1 +CHATWOOT_INBOX_ID=1 +CHATWOOT_API_EMAIL=yyds@nobody.com +CHATWOOT_API_PASSWORD=YYDSyyds#168 +CHATWOOT_GREETING=您好,欢迎光临【49彩票集团】客服服务中心,请问有什么可以帮到您的呢? + +# 浏览器访问仍用 127.0.0.1:7788/cw;后端代理到 pai Chatwoot(account 1 / inbox 1) +CHATWOOT_GUEST_BASE_URL=http://127.0.0.1:7788/cw +CHATWOOT_GUEST_WEBSITE_TOKEN=FZBhe2W4VXSXNT9Ek4etmP3L +CHATWOOT_GUEST_PROXY_TARGET=http://pai.pubuli.com:3000 +CHATWOOT_GUEST_API_BASE=http://pai.pubuli.com:3000 +CHATWOOT_GUEST_API_EMAIL=yyds@nobody.com +CHATWOOT_GUEST_API_PASSWORD=YYDSyyds#168 +CHATWOOT_GUEST_INBOX_ID=1 +CHATWOOT_WIDGET_JWT_SECRET=61db0140c2e890c28f6737913fb22734365590c7b9501590cd43b108532e13dfe368b1792e6a6bee1ad90dda9dbb42fca39c9d4ef76749c24488a63246ee01ca +CHATWOOT_AGENT_BOT_ID=1 +CHATWOOT_AGENT_BOT_TOKEN=E4rBf9TbMi1C5ShpDtLztGZh + +MAXKB_BRIDGE_ENABLED=true +MAXKB_BASE_URL=http://pai.pubuli.com:8080 +MAXKB_ACCESS_TOKEN=4a44cfae71e8aee7 +MAXKB_TIMEOUT_SEC=120 + +SECRET_KEY=replace-with-a-long-random-secret +API_KEY= +SESSION_COOKIE_SECURE=false + +# 会员库:本机隧道时指向宿主机;无隧道则登录会员功能不可用,游客页仍可用 +DB_HOST=host.docker.internal +DB_PORT=3306 +DB_USER=root +DB_PASSWORD=z88KmX2VjuiYHep +DB_NAME=member_system +DB_CHARSET=utf8mb4 +DB_CONNECT_TIMEOUT=5 + +MEMBERS_TABLE=members +WITHDRAW_TABLE=withdraw_records +WITHDRAW_REQUEST_TABLE=withdraw_request_records +DEPOSIT_TABLE=deposit_records + +READ_ONLY_MODE=true +AUTO_INIT_SIM_TABLES=false +DEFAULT_ACTIVITY_ID=ad-x1234 +DEPOSIT_SEARCH_HOURS=24 +MAX_ORDERS=50 diff --git a/guest-cs-stack/guest-cs.remote.env b/guest-cs-stack/guest-cs.remote.env new file mode 100644 index 0000000..deda46f --- /dev/null +++ b/guest-cs-stack/guest-cs.remote.env @@ -0,0 +1,3 @@ +# 远端 20.191.152.26 / www.pubuli.com:7777 专用(bootstrap 后 token) +CHATWOOT_GUEST_WEBSITE_TOKEN=j1UAjqBgZMWQ8WfczgJSymQx +CHATWOOT_GUEST_BASE_URL=http://www.pubuli.com:7777/cw diff --git a/guest-cs-stack/guest-cs.test.env b/guest-cs-stack/guest-cs.test.env new file mode 100644 index 0000000..1e2b214 --- /dev/null +++ b/guest-cs-stack/guest-cs.test.env @@ -0,0 +1,4 @@ +# test.pubuli.com:7777 专用(bootstrap 后 token) +CHATWOOT_GUEST_WEBSITE_TOKEN=j1UAjqBgZMWQ8WfczgJSymQx +CHATWOOT_GUEST_BASE_URL=http://20.191.152.26:7777/cw +CHATWOOT_AGENT_BOT_TOKEN=SrQpnTgYzXq4i17nVQhemxjT diff --git a/guest-cs-stack/start-all.sh b/guest-cs-stack/start-all.sh new file mode 100644 index 0000000..b85d6d2 --- /dev/null +++ b/guest-cs-stack/start-all.sh @@ -0,0 +1,62 @@ +#!/bin/sh +set -eu + +# 强制 production(与官方 docker-compose.production.yaml 一致) +export RAILS_ENV=production +export NODE_ENV=production +export INSTALLATION_ENV=docker +export RAILS_SERVE_STATIC_FILES=true +export BUNDLE_WITHOUT="${BUNDLE_WITHOUT:-development:test}" +export EXECJS_RUNTIME="${EXECJS_RUNTIME:-Disabled}" +export APP_DEBUG=false + +cd /app + +rm -rf /app/tmp/pids/server.pid +rm -rf /app/tmp/cache/* + +echo "[guest-cs] Waiting for postgres..." +# shellcheck disable=SC2046 +eval $(docker/entrypoints/helpers/pg_database_url.rb) +PG_READY="pg_isready -h $POSTGRES_HOST -p ${POSTGRES_PORT:-5432} -U $POSTGRES_USERNAME" +until $PG_READY; do + sleep 2 +done +echo "[guest-cs] Database ready." + +# 空库首次启动:初始化 schema(官方生产流程 db:chatwoot_prepare) +echo "[guest-cs] Ensuring Chatwoot DB schema..." +bundle exec rails db:chatwoot_prepare + +# production 镜像已含编译前端与 gems,不做 bundle install / pnpm(那是 development) + +echo "[guest-cs] starting sidekiq (production)..." +bundle exec sidekiq -C config/sidekiq.yml & +SIDEKIQ_PID=$! + +echo "[guest-cs] starting rails :3000 (production, static assets)..." +bundle exec rails s -p 3000 -b 0.0.0.0 & +RAILS_PID=$! + +echo "[guest-cs] starting flask :7788 (gunicorn)..." +cd /opt/guest-cs +/opt/guest-cs/.venv/bin/gunicorn --bind 0.0.0.0:7788 --workers 2 --threads 4 --timeout 120 wsgi:application & +FLASK_PID=$! + +_term() { + echo "[guest-cs] shutting down..." + kill -TERM "$FLASK_PID" "$RAILS_PID" "$SIDEKIQ_PID" 2>/dev/null || true + wait || true +} +trap _term TERM INT + +while true; do + for pid in "$SIDEKIQ_PID" "$RAILS_PID" "$FLASK_PID"; do + if ! kill -0 "$pid" 2>/dev/null; then + echo "[guest-cs] process $pid exited" + _term + exit 1 + fi + done + sleep 2 +done diff --git a/guest-cs-stack/start-chat.sh b/guest-cs-stack/start-chat.sh new file mode 100644 index 0000000..6045ab4 --- /dev/null +++ b/guest-cs-stack/start-chat.sh @@ -0,0 +1,66 @@ +#!/bin/sh +set -eu + +# 强制 production(官方 docker-compose.production.yaml) +export RAILS_ENV=production +export NODE_ENV=production +export INSTALLATION_ENV=docker +export RAILS_SERVE_STATIC_FILES=true +export BUNDLE_WITHOUT="${BUNDLE_WITHOUT:-development:test}" +export EXECJS_RUNTIME="${EXECJS_RUNTIME:-Disabled}" +export APP_DEBUG=false + +cd /app + +rm -rf /app/tmp/pids/server.pid +rm -rf /app/tmp/cache/* + +echo "[guest-chat] Waiting for postgres..." +# shellcheck disable=SC2046 +eval $(docker/entrypoints/helpers/pg_database_url.rb) +PG_READY="pg_isready -h $POSTGRES_HOST -p ${POSTGRES_PORT:-5432} -U $POSTGRES_USERNAME" +until $PG_READY; do + sleep 2 +done +echo "[guest-chat] Database ready." + +echo "[guest-chat] Ensuring Chatwoot DB schema..." +bundle exec rails db:chatwoot_prepare + +# 同容器内 Flask 在 7788;避免 host.docker.internal 走 IPv6 失败 +echo "[guest-chat] Fixing Agent Bot webhook to 127.0.0.1:7788..." +bundle exec rails runner "bot=AgentBot.find_by(id:1); if bot; bot.update!(outgoing_url:'http://127.0.0.1:7788/webhook/bot1/chatwoot'); puts \"bot##{bot.id} -> #{bot.outgoing_url}\"; else; puts 'agent_bot#1 missing'; end" || true + +# 不做 bundle install / pnpm / vite(development 才会需要) +echo "[guest-chat] production assets: $(ls /app/public/vite/assets 2>/dev/null | wc -l) vite files" + +echo "[guest-chat] starting sidekiq (production)..." +bundle exec sidekiq -C config/sidekiq.yml & +SIDEKIQ_PID=$! + +echo "[guest-chat] starting rails :3000 internal (production static)..." +bundle exec rails s -p 3000 -b 127.0.0.1 & +RAILS_PID=$! + +echo "[guest-chat] starting chat UI :7788 (gunicorn)..." +cd /opt/guest-cs +/opt/guest-cs/.venv/bin/gunicorn --bind 0.0.0.0:7788 --workers 2 --threads 4 --timeout 120 wsgi:application & +FLASK_PID=$! + +_term() { + echo "[guest-chat] shutting down..." + kill -TERM "$FLASK_PID" "$RAILS_PID" "$SIDEKIQ_PID" 2>/dev/null || true + wait || true +} +trap _term TERM INT + +while true; do + for pid in "$SIDEKIQ_PID" "$RAILS_PID" "$FLASK_PID"; do + if ! kill -0 "$pid" 2>/dev/null; then + echo "[guest-chat] process $pid exited" + _term + exit 1 + fi + done + sleep 2 +done