From ea47ea444967358399246dfa9faf63b6d84ff1ab Mon Sep 17 00:00:00 2001 From: sa19840531 Date: Sat, 15 Aug 2026 14:51:51 +0800 Subject: [PATCH] =?UTF-8?q?=E6=9C=80=E5=88=9D=E7=89=88=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../bak_test7777/flask/app.py | 627 +++++++++ .../bak_test7777/flask/chatwoot_guest.py | 256 ++++ .../bak_test7777/flask/chatwoot_proxy.py | 1138 +++++++++++++++++ .../bak_test7777/flask/config.py | 113 ++ .../flask/maxkb_chatwoot_bridge.py | 470 +++++++ .../bak_test7777/flask/static/style.css | 172 +++ .../flask/templates/_chatwoot.html | 453 +++++++ .../bak_test7777/flask/templates/base.html | 13 + .../templates/guest_customer_service.html | 38 + .../bak_test7777/flask/templates/home.html | 74 ++ .../bak_test7777/flask/templates/login.html | 40 + .../bak_test7777/flask/wsgi.py | 4 + .../bak_test7777/guest-chat.env.redacted | 30 + .../stack/bootstrap-chatwoot-test.sh | 35 + .../bak_test7777/stack/bootstrap-chatwoot.sh | 33 + .../bak_test7777/stack/chatwoot.env | 28 + .../stack/docker-compose.chat.remote.yaml | 12 + .../stack/docker-compose.chat.test7777.yaml | 12 + .../stack/docker-compose.chat.yaml | 78 ++ .../bak_test7777/stack/guest-cs.env | 56 + .../bak_test7777/stack/guest-cs.remote.env | 3 + .../bak_test7777/stack/guest-cs.test.env | 4 + guest-cs-stack/DEPLOY-TEST.md | 69 + guest-cs-stack/Dockerfile.allinone | 39 + guest-cs-stack/Dockerfile.chatwidget | 51 + guest-cs-stack/Dockerfile.chatwoot | 4 + guest-cs-stack/README.md | 37 + .../_aug12_image_flask/.dockerignore | 8 + .../_aug12_image_flask/.env.example | 38 + guest-cs-stack/_aug12_image_flask/Dockerfile | 17 + guest-cs-stack/_aug12_image_flask/app.py | 628 +++++++++ .../_aug12_image_flask/chatwoot_guest.py | 256 ++++ .../_aug12_image_flask/chatwoot_proxy.py | 724 +++++++++++ guest-cs-stack/_aug12_image_flask/config.py | 113 ++ guest-cs-stack/_aug12_image_flask/db.py | 28 + .../maxkb_chatwoot_bridge.py | 352 +++++ .../_aug12_image_flask/preflight.py | 178 +++ .../_aug12_image_flask/repository.py | 531 ++++++++ .../_aug12_image_flask/requirements.txt | 4 + .../_aug12_image_flask/static/style.css | 172 +++ .../templates/_chatwoot.html | 432 +++++++ .../_aug12_image_flask/templates/base.html | 13 + .../templates/customer_service.html | 67 + .../_aug12_image_flask/templates/error.html | 11 + .../templates/guest_customer_service.html | 38 + .../_aug12_image_flask/templates/home.html | 74 ++ .../_aug12_image_flask/templates/login.html | 40 + guest-cs-stack/_aug12_image_flask/wsgi.py | 4 + guest-cs-stack/_azure_meta.py | 22 + guest-cs-stack/_check_base_url.py | 13 + guest-cs-stack/_check_baseurl.py | 19 + guest-cs-stack/_check_cable.py | 14 + guest-cs-stack/_check_conv.py | 28 + guest-cs-stack/_check_guest_chat.py | 22 + guest-cs-stack/_check_js_regex.py | 54 + guest-cs-stack/_check_maxkb_link.py | 51 + guest-cs-stack/_check_mi.py | 20 + guest-cs-stack/_check_nsg.py | 43 + guest-cs-stack/_check_ready.py | 25 + guest-cs-stack/_check_remote.py | 19 + guest-cs-stack/_check_remote2.py | 19 + guest-cs-stack/_check_widget.py | 15 + .../_css_compare/guest_guestchat.html | 38 + .../_css_compare/guest_guestcs.html | 38 + .../_css_compare/home_guestchat.html | 74 ++ guest-cs-stack/_css_compare/home_guestcs.html | 74 ++ .../_css_compare/style_guestchat.css | 172 +++ guest-cs-stack/_css_compare/style_guestcs.css | 172 +++ guest-cs-stack/_debug_guest_chat.py | 26 + guest-cs-stack/_debug_maxkb.py | 51 + guest-cs-stack/_debug_maxkb2.py | 45 + guest-cs-stack/_debug_stuck.py | 59 + guest-cs-stack/_debug_webhook.py | 25 + guest-cs-stack/_deploy_proxy_fix.py | 43 + guest-cs-stack/_diag_stuck7777.py | 50 + guest-cs-stack/_diag_wakka.py | 25 + guest-cs-stack/_find_ssh_key.py | 25 + guest-cs-stack/_fix_autoreply.py | 54 + guest-cs-stack/_fix_autoreply2.py | 56 + guest-cs-stack/_fix_chatwoot_confirm.py | 63 + .../.dockerignore | 8 + .../.env.example | 38 + .../Dockerfile | 17 + .../_chatwoot.html | 453 +++++++ .../_flask_bak_before_restore_20260815/app.py | 627 +++++++++ .../base.html | 13 + .../chatwoot_guest.py | 256 ++++ .../chatwoot_proxy.py | 1138 +++++++++++++++++ .../config.py | 113 ++ .../_flask_bak_before_restore_20260815/db.py | 28 + .../guest_customer_service.html | 38 + .../login.html | 40 + .../maxkb_chatwoot_bridge.py | 470 +++++++ .../preflight.py | 178 +++ .../repository.py | 531 ++++++++ .../requirements.txt | 4 + .../static_style.css | 172 +++ .../style.css | 172 +++ .../templates__chatwoot.html | 453 +++++++ .../templates_base.html | 13 + .../templates_customer_service.html | 67 + .../templates_error.html | 11 + .../templates_guest_customer_service.html | 38 + .../templates_home.html | 74 ++ .../templates_login.html | 40 + .../wsgi.py | 4 + guest-cs-stack/_get_bot_token.py | 28 + guest-cs-stack/_inspect_remote.py | 21 + guest-cs-stack/_patch_cable_fix.py | 57 + guest-cs-stack/_patch_remote_app.py | 60 + guest-cs-stack/_probe_test.py | 27 + guest-cs-stack/_proxy_from_api_app.py | 724 +++++++++++ guest-cs-stack/_recreate_guest_chat.py | 40 + .../_restore_aug12_from_image/.dockerignore | 8 + .../_restore_aug12_from_image/.env.example | 38 + .../.maxkb_cw_sessions.json | 32 + guest-cs-stack/_schema_bot.py | 14 + guest-cs-stack/_test_guest_new.py | 22 + guest-cs-stack/_try_azure_nsg.py | 35 + guest-cs-stack/_verify_autoreply.py | 36 + guest-cs-stack/_verify_deploy.py | 22 + guest-cs-stack/_verify_remote.py | 16 + guest-cs-stack/_verify_widget_js.py | 52 + guest-cs-stack/_wait_chatwoot.py | 24 + guest-cs-stack/_wait_verify.py | 22 + guest-cs-stack/bootstrap-chatwoot-test.sh | 41 + guest-cs-stack/bootstrap-chatwoot.sh | 39 + guest-cs-stack/build-and-save.bat | 15 + guest-cs-stack/chatwoot.env | 28 + guest-cs-stack/deploy-test.sh | 38 + guest-cs-stack/deploy-test7777.sh | 41 + guest-cs-stack/deploy_remote.py | 94 ++ guest-cs-stack/deploy_test7777.py | 122 ++ guest-cs-stack/deploy_test7777_wakka.py | 120 ++ guest-cs-stack/deploy_test_on_wakka.py | 91 ++ guest-cs-stack/deploy_wakka_test7777.py | 80 ++ guest-cs-stack/docker-compose.chat-data.yaml | 23 + .../docker-compose.chat.remote.yaml | 12 + guest-cs-stack/docker-compose.chat.test.yaml | 12 + .../docker-compose.chat.test7777.yaml | 12 + guest-cs-stack/docker-compose.chat.yaml | 78 ++ guest-cs-stack/docker-compose.production.yaml | 79 ++ .../docker-compose.reuse-chatwoot-data.yaml | 23 + guest-cs-stack/docker-compose.test-ports.yaml | 9 + guest-cs-stack/docker-compose.yml | 7 + guest-cs-stack/flask/.dockerignore | 8 + guest-cs-stack/flask/.env.example | 38 + guest-cs-stack/flask/.maxkb_cw_sessions.json | 32 + guest-cs-stack/flask/Dockerfile | 17 + guest-cs-stack/flask/app.py | 628 +++++++++ guest-cs-stack/flask/chatwoot_guest.py | 256 ++++ guest-cs-stack/flask/chatwoot_proxy.py | 650 ++++++++++ guest-cs-stack/flask/config.py | 113 ++ guest-cs-stack/flask/db.py | 28 + guest-cs-stack/flask/maxkb_chatwoot_bridge.py | 470 +++++++ guest-cs-stack/flask/preflight.py | 178 +++ guest-cs-stack/flask/repository.py | 531 ++++++++ guest-cs-stack/flask/requirements.txt | 4 + guest-cs-stack/flask/smoke_test.py | 16 + guest-cs-stack/flask/smoke_test_pubuli.py | 746 +++++++++++ guest-cs-stack/flask/static/guest_promo.png | Bin 0 -> 212162 bytes guest-cs-stack/flask/static/style.css | 75 ++ guest-cs-stack/flask/templates/_chatwoot.html | 456 +++++++ guest-cs-stack/flask/templates/base.html | 13 + .../flask/templates/customer_service.html | 67 + guest-cs-stack/flask/templates/error.html | 11 + .../templates/guest_customer_service.html | 38 + guest-cs-stack/flask/templates/home.html | 74 ++ guest-cs-stack/flask/templates/login.html | 40 + guest-cs-stack/flask/test_chatwoot_hmac.py | 16 + guest-cs-stack/flask/wsgi.py | 4 + guest-cs-stack/guest-cs.env | 56 + guest-cs-stack/guest-cs.remote.env | 3 + guest-cs-stack/guest-cs.test.env | 4 + guest-cs-stack/start-all.sh | 62 + guest-cs-stack/start-chat.sh | 66 + 176 files changed, 21009 insertions(+) create mode 100644 from_server_guest-chat/bak_test7777/flask/app.py create mode 100644 from_server_guest-chat/bak_test7777/flask/chatwoot_guest.py create mode 100644 from_server_guest-chat/bak_test7777/flask/chatwoot_proxy.py create mode 100644 from_server_guest-chat/bak_test7777/flask/config.py create mode 100644 from_server_guest-chat/bak_test7777/flask/maxkb_chatwoot_bridge.py create mode 100644 from_server_guest-chat/bak_test7777/flask/static/style.css create mode 100644 from_server_guest-chat/bak_test7777/flask/templates/_chatwoot.html create mode 100644 from_server_guest-chat/bak_test7777/flask/templates/base.html create mode 100644 from_server_guest-chat/bak_test7777/flask/templates/guest_customer_service.html create mode 100644 from_server_guest-chat/bak_test7777/flask/templates/home.html create mode 100644 from_server_guest-chat/bak_test7777/flask/templates/login.html create mode 100644 from_server_guest-chat/bak_test7777/flask/wsgi.py create mode 100644 from_server_guest-chat/bak_test7777/guest-chat.env.redacted create mode 100644 from_server_guest-chat/bak_test7777/stack/bootstrap-chatwoot-test.sh create mode 100644 from_server_guest-chat/bak_test7777/stack/bootstrap-chatwoot.sh create mode 100644 from_server_guest-chat/bak_test7777/stack/chatwoot.env create mode 100644 from_server_guest-chat/bak_test7777/stack/docker-compose.chat.remote.yaml create mode 100644 from_server_guest-chat/bak_test7777/stack/docker-compose.chat.test7777.yaml create mode 100644 from_server_guest-chat/bak_test7777/stack/docker-compose.chat.yaml create mode 100644 from_server_guest-chat/bak_test7777/stack/guest-cs.env create mode 100644 from_server_guest-chat/bak_test7777/stack/guest-cs.remote.env create mode 100644 from_server_guest-chat/bak_test7777/stack/guest-cs.test.env create mode 100644 guest-cs-stack/DEPLOY-TEST.md create mode 100644 guest-cs-stack/Dockerfile.allinone create mode 100644 guest-cs-stack/Dockerfile.chatwidget create mode 100644 guest-cs-stack/Dockerfile.chatwoot create mode 100644 guest-cs-stack/README.md create mode 100644 guest-cs-stack/_aug12_image_flask/.dockerignore create mode 100644 guest-cs-stack/_aug12_image_flask/.env.example create mode 100644 guest-cs-stack/_aug12_image_flask/Dockerfile create mode 100644 guest-cs-stack/_aug12_image_flask/app.py create mode 100644 guest-cs-stack/_aug12_image_flask/chatwoot_guest.py create mode 100644 guest-cs-stack/_aug12_image_flask/chatwoot_proxy.py create mode 100644 guest-cs-stack/_aug12_image_flask/config.py create mode 100644 guest-cs-stack/_aug12_image_flask/db.py create mode 100644 guest-cs-stack/_aug12_image_flask/maxkb_chatwoot_bridge.py create mode 100644 guest-cs-stack/_aug12_image_flask/preflight.py create mode 100644 guest-cs-stack/_aug12_image_flask/repository.py create mode 100644 guest-cs-stack/_aug12_image_flask/requirements.txt create mode 100644 guest-cs-stack/_aug12_image_flask/static/style.css create mode 100644 guest-cs-stack/_aug12_image_flask/templates/_chatwoot.html create mode 100644 guest-cs-stack/_aug12_image_flask/templates/base.html create mode 100644 guest-cs-stack/_aug12_image_flask/templates/customer_service.html create mode 100644 guest-cs-stack/_aug12_image_flask/templates/error.html create mode 100644 guest-cs-stack/_aug12_image_flask/templates/guest_customer_service.html create mode 100644 guest-cs-stack/_aug12_image_flask/templates/home.html create mode 100644 guest-cs-stack/_aug12_image_flask/templates/login.html create mode 100644 guest-cs-stack/_aug12_image_flask/wsgi.py create mode 100644 guest-cs-stack/_azure_meta.py create mode 100644 guest-cs-stack/_check_base_url.py create mode 100644 guest-cs-stack/_check_baseurl.py create mode 100644 guest-cs-stack/_check_cable.py create mode 100644 guest-cs-stack/_check_conv.py create mode 100644 guest-cs-stack/_check_guest_chat.py create mode 100644 guest-cs-stack/_check_js_regex.py create mode 100644 guest-cs-stack/_check_maxkb_link.py create mode 100644 guest-cs-stack/_check_mi.py create mode 100644 guest-cs-stack/_check_nsg.py create mode 100644 guest-cs-stack/_check_ready.py create mode 100644 guest-cs-stack/_check_remote.py create mode 100644 guest-cs-stack/_check_remote2.py create mode 100644 guest-cs-stack/_check_widget.py create mode 100644 guest-cs-stack/_css_compare/guest_guestchat.html create mode 100644 guest-cs-stack/_css_compare/guest_guestcs.html create mode 100644 guest-cs-stack/_css_compare/home_guestchat.html create mode 100644 guest-cs-stack/_css_compare/home_guestcs.html create mode 100644 guest-cs-stack/_css_compare/style_guestchat.css create mode 100644 guest-cs-stack/_css_compare/style_guestcs.css create mode 100644 guest-cs-stack/_debug_guest_chat.py create mode 100644 guest-cs-stack/_debug_maxkb.py create mode 100644 guest-cs-stack/_debug_maxkb2.py create mode 100644 guest-cs-stack/_debug_stuck.py create mode 100644 guest-cs-stack/_debug_webhook.py create mode 100644 guest-cs-stack/_deploy_proxy_fix.py create mode 100644 guest-cs-stack/_diag_stuck7777.py create mode 100644 guest-cs-stack/_diag_wakka.py create mode 100644 guest-cs-stack/_find_ssh_key.py create mode 100644 guest-cs-stack/_fix_autoreply.py create mode 100644 guest-cs-stack/_fix_autoreply2.py create mode 100644 guest-cs-stack/_fix_chatwoot_confirm.py create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/.dockerignore create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/.env.example create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/Dockerfile create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/_chatwoot.html create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/app.py create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/base.html create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/chatwoot_guest.py create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/chatwoot_proxy.py create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/config.py create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/db.py create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/guest_customer_service.html create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/login.html create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/maxkb_chatwoot_bridge.py create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/preflight.py create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/repository.py create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/requirements.txt create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/static_style.css create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/style.css create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/templates__chatwoot.html create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/templates_base.html create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/templates_customer_service.html create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/templates_error.html create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/templates_guest_customer_service.html create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/templates_home.html create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/templates_login.html create mode 100644 guest-cs-stack/_flask_bak_before_restore_20260815/wsgi.py create mode 100644 guest-cs-stack/_get_bot_token.py create mode 100644 guest-cs-stack/_inspect_remote.py create mode 100644 guest-cs-stack/_patch_cable_fix.py create mode 100644 guest-cs-stack/_patch_remote_app.py create mode 100644 guest-cs-stack/_probe_test.py create mode 100644 guest-cs-stack/_proxy_from_api_app.py create mode 100644 guest-cs-stack/_recreate_guest_chat.py create mode 100644 guest-cs-stack/_restore_aug12_from_image/.dockerignore create mode 100644 guest-cs-stack/_restore_aug12_from_image/.env.example create mode 100644 guest-cs-stack/_restore_aug12_from_image/.maxkb_cw_sessions.json create mode 100644 guest-cs-stack/_schema_bot.py create mode 100644 guest-cs-stack/_test_guest_new.py create mode 100644 guest-cs-stack/_try_azure_nsg.py create mode 100644 guest-cs-stack/_verify_autoreply.py create mode 100644 guest-cs-stack/_verify_deploy.py create mode 100644 guest-cs-stack/_verify_remote.py create mode 100644 guest-cs-stack/_verify_widget_js.py create mode 100644 guest-cs-stack/_wait_chatwoot.py create mode 100644 guest-cs-stack/_wait_verify.py create mode 100644 guest-cs-stack/bootstrap-chatwoot-test.sh create mode 100644 guest-cs-stack/bootstrap-chatwoot.sh create mode 100644 guest-cs-stack/build-and-save.bat create mode 100644 guest-cs-stack/chatwoot.env create mode 100644 guest-cs-stack/deploy-test.sh create mode 100644 guest-cs-stack/deploy-test7777.sh create mode 100644 guest-cs-stack/deploy_remote.py create mode 100644 guest-cs-stack/deploy_test7777.py create mode 100644 guest-cs-stack/deploy_test7777_wakka.py create mode 100644 guest-cs-stack/deploy_test_on_wakka.py create mode 100644 guest-cs-stack/deploy_wakka_test7777.py create mode 100644 guest-cs-stack/docker-compose.chat-data.yaml create mode 100644 guest-cs-stack/docker-compose.chat.remote.yaml create mode 100644 guest-cs-stack/docker-compose.chat.test.yaml create mode 100644 guest-cs-stack/docker-compose.chat.test7777.yaml create mode 100644 guest-cs-stack/docker-compose.chat.yaml create mode 100644 guest-cs-stack/docker-compose.production.yaml create mode 100644 guest-cs-stack/docker-compose.reuse-chatwoot-data.yaml create mode 100644 guest-cs-stack/docker-compose.test-ports.yaml create mode 100644 guest-cs-stack/docker-compose.yml create mode 100644 guest-cs-stack/flask/.dockerignore create mode 100644 guest-cs-stack/flask/.env.example create mode 100644 guest-cs-stack/flask/.maxkb_cw_sessions.json create mode 100644 guest-cs-stack/flask/Dockerfile create mode 100644 guest-cs-stack/flask/app.py create mode 100644 guest-cs-stack/flask/chatwoot_guest.py create mode 100644 guest-cs-stack/flask/chatwoot_proxy.py create mode 100644 guest-cs-stack/flask/config.py create mode 100644 guest-cs-stack/flask/db.py create mode 100644 guest-cs-stack/flask/maxkb_chatwoot_bridge.py create mode 100644 guest-cs-stack/flask/preflight.py create mode 100644 guest-cs-stack/flask/repository.py create mode 100644 guest-cs-stack/flask/requirements.txt create mode 100644 guest-cs-stack/flask/smoke_test.py create mode 100644 guest-cs-stack/flask/smoke_test_pubuli.py create mode 100644 guest-cs-stack/flask/static/guest_promo.png create mode 100644 guest-cs-stack/flask/static/style.css create mode 100644 guest-cs-stack/flask/templates/_chatwoot.html create mode 100644 guest-cs-stack/flask/templates/base.html create mode 100644 guest-cs-stack/flask/templates/customer_service.html create mode 100644 guest-cs-stack/flask/templates/error.html create mode 100644 guest-cs-stack/flask/templates/guest_customer_service.html create mode 100644 guest-cs-stack/flask/templates/home.html create mode 100644 guest-cs-stack/flask/templates/login.html create mode 100644 guest-cs-stack/flask/test_chatwoot_hmac.py create mode 100644 guest-cs-stack/flask/wsgi.py create mode 100644 guest-cs-stack/guest-cs.env create mode 100644 guest-cs-stack/guest-cs.remote.env create mode 100644 guest-cs-stack/guest-cs.test.env create mode 100644 guest-cs-stack/start-all.sh create mode 100644 guest-cs-stack/start-chat.sh 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 0000000000000000000000000000000000000000..7097b2386b25719c590f7893082155e39318542b GIT binary patch literal 212162 zcmb5VbyOTd(=WWZy9WvG?j9T#XBTG)?h;%B!7aGEySqbhm*5`UEkT2SV<-rsua^)m>G6X19C0YTlOLwg8y&GC&ys6chje^?m@}cA%Dll9C3hAQc&)qV#_; zMgZ>w%MJk8*}FJ_WF^S8b#%!Qw*ITdKc10^v%|mp|3L5k-Yx#?9RQeP`5$EdZ`DYq zX3i$>0>9rcYNvPQ@5bW3V_b{>!u0>J@qc0Af7so{!R1{>^&fUp2T8tT(|1g7@qb|B z{{x#iIQ^54e3ucnvvvKa>mU8I7}d;P1N?qRe7{Hl&HxZV79jD@|M$=D^d%nv;J*a` zU}FEf&NvMKXbA=Y2v`5Rjye|rzz6~Wny3G}?!U*x!N|$zKi$E;m(b?s0KioV0Dz_g z0O0%t0Fd?m!+S6O2iwTsStRdveR)4D0JZ=#0672%um_j`Sl zq5qxl1p9u%A;Q7I!ondVAiyJ{BBP?BAfuq5p<`pBp<|(=pkU%-V&UN8;o+fT5D?&<-3fS@ ze-GmSCMY;~1Vm_9BpBp(z8xk23L5U+c35OY3>0Ko7$hiY7yv9BJQe~4A~pw(xEd~{ zks}fwKBs?Nd|urQl>|uLDIsx|fRLJtTT%+FVH}WO-@8pj!^11>>=GC>w?iwVX=3VH z(9rjr&Mc{)Pb;`^_m5j6y{!4byE>A0LlFLd*1W5KAF_9g-nnvE04Nw}SZH{}e`5g+ z4Fv}Uc;{llVN-C3tHI;o8ac+{QF4O7{&{uyz1zR3B#dVexVW9-sd*&-&;*#|OQ}28 z_wBx|0#ISzTVlar0X_kW{0?A^vzj9dGV#t;gG|w9A`z(@$;JGkV9CW0*vY9A$<3kG z#i7CwlMVYOn;bq#WTIGoHlj+S>&EhKfDgWkHDa}NsFO$b?yS7Gb_;ER#e|0T7sY~M ztM*0A$Xpne<#ug1PMTJOsoN7J<3OO_mtnFbruuNuX{(f=mZE(q4GoLfsJvmS{H_=i zlU_!$(1Zxa9|}_zwmMcIH+%xNBHuq#%umLUI+qudD>{~3OdR#F5)qS}ie7vd6G5BD z#m`Us02cKNG&F$(EFvbhsy{R=tfiPgG&J=;H$DI||6%-pJWNFKFj7NfXb{D_QpA4+ z65>Cm5Jf5`$N!Rlcl*5&LZJ!5B&<6-7P;V%4pt;0Vs-t9KcRo>h%GsViw`+YDm^Av z9$|J}qOn(xSROn)DuCT^iyZTxYJ914!b42*=qE9GAxb)0c=89sp+0CkV;OR^M;U!X z04yvf3?{jlUt$m;Kf5Y9c^BrkL_oZ=+(i5pSjjXl15I zUqtpqM06R68xSB)NGVdZ&MX*AXG>Pw!6k!->~u&(j{CHYCa4&yAI}5Fn=2z-4==Zh zOeXQTf_^6M;!gzC2x}SNiE8l$k@-uim*qwJr1ezcPdgpyJB8ATqNa9uZ{DF(tsbcF z9OcVer9Xf6*&WFS5q;>n0aVhgX0Y=MjfMboIOCl`%Ijyefrwm}FeAMJcgnV;2 zIc^)!(>3pFn*e#jMp#Jew;7}=pY8-(p!#xQr&(s&Rd~qQT+ps9%yEE=ifNV_SrV3F$oZoj6&RQr1KV|gxi zUP-vc-hxdhoJOONDDw=5HVM`_mx*ZV#h78IA&}ji4)oo5DtY zV{?w9UAU`!Szk6dYD$WS#6D?7yP5-4A_Fz1s@zS@n=uVw?}$HV-~p(#jPiS#%1G92 zoy<*Ev zeWmHJi<$lf8WGwRd1aO-dj=RwF-}$7qAv}pZ=N(Zy>|n#Rvd|k!iHjeNCA^8XeZqx z@e`G_=VTU2415uS!&9VB09o6? zj;Xpwxl;FL4ko4U-HA$@AobrgdZdM&0lhdlcIXwh9_VMcVYF7kV~YIaVPJa-X-4@W zM5tW31ez7f6Qipx!1VR`2BRgtlp)o4 z2lT&}p~J`gi!Ns_cQsd74?l@0RDfHRx+sHRp)EocQv~R#)4c{KTwV!pr2_pV>|4+Mb z_&HPl^WztZA}s$)6iqBV;U?RL?G2E!;Qw0n34$^EPt-v3)4OQk3C+DcaIp3b5Iy|> zht{*%P7Ch+)c}?A^R6!D+Uoh}`vtLiiREJPF8((M+G2?B@u zI5;~Vh5CMS#|!e8c-1Kf!;t~Fv(jV_pY5xP0iIqyxk7bQs~UxRGSH!Xi?V~K*eQ}F zD>>|ok|KAUpjDQ6iz`l?KjGkWzBfJ&NyOc+CyN|1YtzC}*n9f|+=z$9VrHA}DzOX- zmAL~g)*_7NeQI-(cseQDeGTy_)g326L9GLpO^H}?@d~0DSDwvh)kabi9!c+a?=BS`TZpuPOgs(x?DNS6}~ZuVPxYk3ar6SEU5OdG4;!O z)*Je#lGL%DOryBph3hr0PR=RXYl2++X1(bGBkZ)N2%`K&ewBvZABbW~VcLQiPPQs6 zbYS{3Cak{`_w0vi_!n!jmMqRFG+SP4ST(sSwsT?~gW9RR6m=A0mQOlZd}vA`4Lr28 z)j+B6K*G!ehy}CCKe(ZD=%^%upkUUZVT!lU6tY@3u_+c z4Zvy7_L}$xIFK>O4as%azW?|JkpFf~p|AHqE9ApAKxV{|iBk$|ZL&axZ`b66zxXNEj(y3mk)pD;aKhHhW{XSbrbnk0L}++>_guq+aQTBh zaI*$HN94LdndX{0NuSuS`t7*=lUSs-0K$sA#i4wxHb+fcQsv$Fu0{=khFZ?AVTwkN zT1^J8ep)}M)CCqg9W-7O^Zri00b*ZISp1`Qz%NYcb#DMMrXTTy2*H!u7qvF;h*a-^ z@+J#o=*x4!cXFwcN6lM{SHGnnRO|>SUUfNtpgSHY{1<-2lO_CDS8Aq+NuT6@Ra!iY zRb$*qqGt3B)-2UqjcTu z{_pD^;C~i9VrS%N1MdqX`+u`P&HrBdATrBpk8!-O1&}D->%|m46!b=wn+Y1%d}5=a zpUp{6b!6dQl{r!42gv+T!|zx=o7Mc%rSG+sZP6Utl6F3ugj(2L6)TE1-HUAF+zO`` zuEm8`7+?0d|4~Dm$wlYXx{61kOo3I|OQUaYqMf~vo&?i)<@dMV=fzsJ1xH9sn@VFQ z4I1a-v@+IwVUAahqOea~6q4QA#X{RLY7sgFqd0>XGZRlF7Q3rsMDtSuen?ySqfb9g zxN?E=LXq}exf=FSY>GCxut?~kpjai6UwdTZSxF3XjdRSRr1=ps_v1kFeG7`EZ>PHc zfN$ura?BB_PrGY}O38HRh_$ZOyz>Nu=Y?)dG^8?s_lVMB$2okg26M^Cn=vtW%nUo; zN(d_F>sMfyI!vnMc;?P-3tZvRB&jqF#kn%8chuzMS;Lg!YbrShx)5}KxwXiKLQCn{ z-P|og{J0hAFY3PlK3b&Q)@2q*Juf~HnJxUu`4eHf;NGB2LutaqOK@Sj&!<#dbq9#* zUv|oujV05l1Uq#m9=srVU?W6F+xE7WE2I76a zoaLwqM7V!m5ZKw(roR1roW3CJr1l z|C^eVz3!pCsxNYf#Tn3HAYuGav{p-ZB-yB^aJTLN4RcHfeOJ`;XT#DwvZ0DG?{IvH zc5gkdH$X9~Y`qfSvcpj=y1Lp-FvZ$RDwTD@2O?}oBc6;~zu?>VHN;yx z)6w-es(Xm@!!1 zNw3k@=$CO(wlUu?CWdV;!Jg`d7!x(aT7)sA30s!!9X!A7I2wqE)lZRm2JtsQ5M|yI z6M<0Tyv|J_&M4>F6xQxo;oyDCkrQtfxs9m<|%V+^@7}1l2b zgXQgUUzYJ14_H}i!bTPQW*)CFynJF1D zP$h2b^{Wr@z5x*V7D+rjE2`rabTscZx)la?5bbSNcT-?b=Fx<5Ph8>D^+HsqMpz(& z)lRX>9P?h=S;f!nR=;8(GHo_LyQ)gdlGs0=C;#Bk9_fq8$NpQsz|n_E#-ZMQ1+@|Az!^rXCE5Kl`hADQ ztaD2CsXRA4DJyEqG&x2ua_u>((Y0`bvpEq-A|B-69iv@!KMngqbP~Bm!_DtFT@rnQ zD3E)*F90~jwL>}5d6JgwYDwhCKtlz)w`(6a)v+7tV^$@nNq;hTn30%jx8f?e!D=K= zPhLWPGeyxJDzo@YCSE=+u)c0K0Z1$E_)T}|^(0=cJaTPmU(hO_)L7XQolfYXOnZ2e zbiG+Wr9!oAZnAdM{JFzE08@~G{I8dZf8w}#iCPVx3}-c;y6f*gvmDa&fYBZx@^RZ` zbkng?(*SB$n@q_(quXQ$6$9fMlEPI)dg{Ks7%48i+nN z7^Ub#$Kv&c9Ofc~bC3uO)`6$Yz%x+2|~*KL0OWP{h%Ogdt|Z`16vsFxhb*TYbOc_xJ?-93pH z!paEDAeGuO(1-f8;&gBu=e=?JS>m&X_#fe2h7>dEYX9S)+}G${|+8_kfv?fA6~8 zb%IMtX8Al5@{-&@c$_#lWMvr@GPqbLO46yxS;a%c8bWCIJ_kyu22leNqx=9a(Vw3v zHIuXFF4e%z2eAudEuM_^pe;Ay0aG@r{AVfUuSS1Xl;(85o;R6IQ95liuzV6-Ij{z| z2IVvx%#ieEw$fb)3|o{Okop{ILUc3L)IibuQrU(5t_io5Bfk*7jnK6dhp0=dGm-5; zZBGxk>8lvO)~**9Z(vWghUjSspdjn|!3O(B?H1+NkvpcXd>bk`jYiSIaYkyR-uvJ< zq+6=U!+f%$BOf5&v;534m+TXRqR?O`an=4)4%VOj7gtM!QMr|w35qsc$7I}1b4X6h zUHCrB1b4%imPtJv%~%9@H^@hYvd3K;>DSVVm8un59U!A zNs&JI{|+6BvkHoiSx$WVoFT-gnwW5~mzu50T0tii3oCm{=Gq;u5)Fj{S;{}!l6&Y07DQR z5)X~#guY;AK%jJH*C;RBr-V9-eoTfGCM$TAoMZ=lkmbCV)AfYkyw-HlC_*HkVn^_W zkc#r}{`lY;!uVBIzq)CBEZlEKRR94AhQo$GaO?HgoCsB${g}qIL&qoir1LdvqZ>CH z;T|JxIgyu{$S|GTZDNUYnbZX;(T;9wRS|zXjcmty-gvhkKB!M@1UQZwS z_#@g^g5&Ihqoz7X2=3_c$=n3NM_t-^KNSubqa!TC#)e=K;hBsuEtYVG6`fy`N)?S6 zpr3_tk(kEBTP0y3ta{e|B{vb22gPMhh7(h5oI$*a*EpH3BF3u_9=hX3tI;-3Gv&^H zc~|TsO_V}tPuUvG(gV1(s#5J|;~H!{PVh8UeQOq5QTWW4>%B<2xmj}J-2fJrANF2R zzGW1`PBl`bkTp?bm!&SPu+!RjYMbvUt%2?NUzHs!N(7CH(U<6>lz~%} zcFAoAw23H`>bLONhdD35sluctf9TdfceQgRm+$t}!CuWz} zKaxdu0F8A3;Ae7J%(*33DLS6HlLb) zUVMP-mm^$4D7RUt@jY;bz59^FkyD{?PEQDSd^ANp2b5?eV}(@P9tw}44;h+3BtHYb zB)NKwH>`KxKp!L;<#F)QE+yASw}QQMGCtut9_OlOrY5o}Gd1C;3dvYhTPMs;Q5V_f zO$|1|EK%^Uc?^ea@V-Blt^d$vX;mbmT>&og6d+L#JhmE^;W%VV%!wc#JAmd| zC%Ya0(_Stt(a_i~=* z>H#3g|)sc?U#$QGwPnx8vvpc{Q>wz z>`V&h(ux@aqbW4VKTxuUn2E%=RcrfqQ#C6pZyP?HG(**(pNhcW-2o-$Oy`PS4n6+f zrKbex0~UOi1%hD@ZhKW~kcLUF~49SUq(8|kpXwypgPC=YCg0tB^ZJsW+ z@hc#Q#?rw-R_CZZ)?}IQ{29fJmK*ZFmoV*FS(;MFJbt80O=GpxiS;O+#QPW`%5N{4 zgH+UyY9{j-6~2%cWVAt!2fQ5RfIIlljGIpfhqsa{iW{IWtAs>EStcb91HB`CclaA# z&^fO^XI@~=I#SlkYyQfr&cgig5rhT0+wO_^?wN~Jq6ih2rRI19J zk%|ThfShaD1F6=PqPuxNdxqeT{9bD1b_Tck7;eiO^cfN)MPAc?Z^4rAxQid5-$nU~V1H z#A>sUa^=b@n9^Q3cs$SOf-m}FSK zKG&NR`>OpJW_gK&OX2q?gHB<)DTn>ejNNo?u*W;dFxN9_4@i0tV>ey`wErZ9aa_O zPOlO9Inyc;Nl&M0g`g%TE%GuN5opM*M}N!;bUpoJlC=(jT?+>rD9b57UCdWad_Zum zC(#+}7jD)M#QwaMYQ#E70b|ITK9sKV_TJ8evleMEK0c}xLei2^1=nAKlF*$c%>$mM#{i03eH`OW<6KfU>f}) zY?38gFmpBo(1rww^t2Ba5wnQ=KidxPI}Rkn{QhuIG-i|VRD?v7&;<39MAQIx5!laf z0OOOJ*!Zen_`4SrZKI7`+&y$~J<*1_!tX(?!@%##pz9s{#S(Rz^o#^1$0Z$C3OQX+ zfP?rczE)T@9-|Sd6-st37Tfup8uMPG%!VGz{l|OyeVb-$x?= z`Qza6`4PI9m6t&yx#BJo%SMKBVVtM5jiTDCA?oY(K;tp*G!+p>T{|#7LdI`oQlrZ6jfJ;j37?Wq#tUv zST%!aw>9%sEXGA1)1az$Gqr6-`kN(;#LU9d>yKzHL?OFL(?nMfm8jZ2&amnthB1d_ zbYL5sFZuqhrJpkuPPh`gc#C+b6k>sP4*1#vyi=IdsVENPsUgM%_a2iXp1@+>sJlFQux~=M#1FtxY0YxPCi&u4YSPm z&|hUhjbtZnY6dvbvnQZ125TiKYt}qjVrh8Ly~uSiz8OWFKeTL#(sJxz3==;azyX@{vIp3 z=Q8Wa*M)WSGX!E!jTsQtBxjqT=X9p{rx(==ZS3O(HYGjkwR4i>`Y zKhWbJ{^-Y=i^eWv z%yrl(iML1BK$b+O_rQz(b{fL4&nb6|G`sk5uRZ_Qd#J@UUFLu3a7HS)VfZ_GHBaV z4{hDzD)aKTUbbmvc~eI|!a96`&}5d_Nbk~bJyB^e1jW79scjr;I<`O#_=kt_nl7Bf zg)-qEu(-FyEMMxfCjTn-p~CgXq3wen5UZ8koS2$uH+ zA|)Q0-B9y4(Qg6VFC%c1ux0*+9|oY2$do%mNjTnl%li;MS#ZSFosH}vEas>BD?+Hz zteTcoj-^ROq_L87Azb=r9W1nu#ilEscF7Q3>>}=h_y8HXzm#x0dfyo>&^4?{f?n_* z^UJFQPw>Z&ZK-1Y#78y-!f%-U1LviL2Heqk?=hQg_oAc|xpeIU(tQ+&)Z4G)PGS91 zf65E?==K|To=aH`obyP|zpU*w|F!DgYa&)&a8@86s{@xRDbghIBPm0Ku!Ziz)tnJY zN#tPu&Wyj}wQ9CrjO`y=L$oxU=De1DnsFG&kN4CJIH*zm#D`?7nFa1di+*Y4l)D>= zLY;j(3ER=h{Mob7aekwm))6`O5w1RbGQlm|{Yk)1gwA+kt46)Ds$Qd0CGS}Snc{ICuqik|!h8Og;UhJwf zk}1OkLtxsl1ux6R5viWz-a^RXxAGs(0A5`mSVj&;pg3tRS&=Ped5ND6e%u%j>&5MZ z+~W@Wj%0Ar(XUttpR15+b@)TNGf>woC#{blsgPvr(J1no8xm9A932s&6~b8p&b`IB z*IlcN19SzHuPtX3YPGs1ZA~7%3`J*&w-iMmTzwkND4aPeHcP@6uCFQ zNUtv@V^vg|sH9_3(_O62QUN&=7zB?cH~!wW)jF=UD$*?+6w7-M-3Y$3tANGx>~i90 z)Rfs-BJkfGbjgn09
    aSm~_^G1n^KGECNT@uu*NO!El9g0BBd~)KUT=ls^>QSQ_ zqyheg)zhucUsFfcW#q9w7tEk-4jfgV?<2mJ8(Gf0iT0iBs{Zg&#yd#++Q?+xummy# z$DbxJ%NNz=WgDW)-3*SWZLka^6C~->z*fn-@ikoK2tWV;U~{8Y>MYzof{!PZRHiX$8wV7_K1*R_fQY> z!ssG7Dj)Z`C`3TDj5lL;yoz^KP0Zbcgk#C|DvG-YA*e9~;Z;=Uo8?)gh~<^h7F(gZn{7bya1JK_6R$W3C&|1269= zg0S@>V6xY{)Y+|M%2@F)FGe@jMI<%Pg}s*G1eq`LCUzit-ysh=LF{RYpYU0}66PI0 z*h>c|sY=DmTBZeL_+&udS6hZ|*B|XO`v!^grp<53rN+lD=^VGB}|r_f#p8h zChYDAhFvgtQq;E)1QlYdE3*rYip)%Oi}DxdSqsn9mqykJ(I|QFTMOsO>-B7lH>~^d z(slM(IK#l`VjlfsGY}ZHv@`L({Kj-mfs4FgL1*H zP&-e0*>F)B9t_|Y^D^G9u$NI4NWQ^Uu^E{+iolptRHrC^w(Wm~t7l~QGS z-lwY~8j<*UPHEsQ7UZ&@EOY`TI9#xMFJ z<=z0zEe}8Ky$RPE*I%%|Q^(T|k=GHo5AE}`<}9qW$eqh;$p37HwA0aApmx-s*Pa$N zI^n??!B9EC+Wy`wyBDq0;d0F=-*j5bvG#O2ALH+jEX}2%W=itDrStgwMfc1i8GB(< zj!^{KB4hN`g^{DQd43Ez&OuI4SZWsIc7Ti9M3(d`=sOwtNqd&hDe>QTGb39jW!o=B}2a-$b7D&zFC_UuBs0YN-DiB ze4lrYNs4NVzebVabXMn?S?EsVUC&f482ufZUtwD!#_t;3L4R976STlgdw z9_<1lY6-=0Bv&JO+sjB3qTcVT*F_V4PJF##5orV6>1~w#GB-tTRgJNYcTa`8j)O_g2(VYU?hUb=!_kzlOI?gTqQ~; z!6lc-kiwht`MC*}X9i?a@tZz<&}T7WTq`tb#}DSU&4Hn#plJHP3iC)2P2*(?tLHQi zyrf+A!1|rBKIB?#PN5L0d|Y?AqET#)5aQ)m#E6*zw@z(>=tzC;2#qDB?3}b@M05R; zs}j74EWhD==FIdC0VGooZvW>QqrJuN-n0G;2qqVvms;&VE9$(Gn;|3$4H znF#U$-z<0OQybHt8*R8w(7r_bs3sdJXfSDriF0=}%>a@CGL~Z}TJhia7u?v(gF-2rZ`UXh!LOgTrQjBl0!d7qhMoNlJ zv|?r-^Ibq9gqgcNAc~7B!DsDS{79HvlJ`vmQ=oNyQyTwIf__qg!?nwUWL}YCeVUu> zMwOHyMp%DFs1xze4qS`p_~9GlgeS`Ov5tVlq7|3hiV@08%3rn7;N@To)=-M1k#&=j#nA67{f?|iZ!uhr#bF;HF z-c2*30cMtoBC$QPI+iw$I&xGG7fh#d(k@wpebaMEa#qr;Li87Pv(jf39bF~I4=i5R zR%I8XY)*|Ms{5Lbn@2(37Lod&x=ycfbxkL(8ycdElH<*W**K1K1o1C7P-`HwrxnlA8@n`PEP*UAtbD&g zx~pR2Z9zWDGX+z6BYnHTqPK$pq*oSlRi#fcE8UfDi%1edrm(9tVd@>@#PApp zNLP3dLcUT+Ye|NECc0Q!-*;}gAeifqXlGuaQ3K*h3+7OwdCF%^t*0IwOjmI96CYGA zc-_^^6Ahd2ePVwEtWAO=s1Cl*NH4A)v{=El72kbFNiLfPh6|*nkR0qw0OjjtrGoqu zHfOn(vT^M63AaJbff_6rEu**$2ALwX^F|=dQ(hEbmhQay>Vqwj#$vIk2e9jvjyDvtBOy#4EJ*D zUa)#Xw3+?blSRc3Al~UnQ)``1h~J#ejZ98AEvgQ-JL0YoA^ zCfV4rQQ9l4NMZ~Mz+SUbqo#HrR=g;yQ_h=c6?sr$lyaVq@Cu-x$7|evQRA-{yBvTY zIkKh-;VRnz-RNI_osP{~ka|xpwOKNffW_ix*I~z$&g|`_wnKoTrXWI(3nPm3>+uV& zdEb*0?%HQ&rKl2ESqRzBaRu4)7x-n*27yba~dDsBl03DzAdcFSfq99_V)O4>vS>8ZM+ z&*4|ahFh7_UGHCCCy?eaed{U&0O>maVkj!fxtNIjOw#Q*Qiq@42Q^Tr$PtNxIfG1g zIUKH5h?3UWIHZL)3L4kAYJ8+`UkJ#Xc7udMC28$kwV0eVfKL&2P9rJt@_v?u%_4tJ z9}lQ%lZ$BZUn*pa$zh8@O2uLy2gN=v;C+I8Z031_G+JlaFH)aY%`t+x5_Z&l^7ZVH z?DXQA_bFMPZ2Dx3FuT(C*UY>lmy|T%gcwEgoLrg@`lU-Jdnxx_A5{`D*z6J~7^+_< z;-PMCVyrw@1XjudS$FL_`#K2Z`RMr(5_5v=NcMM9B0f|{J1Pli=C`J(4+_sh?DPt; zv+Zrsix`EZN45jP)r*edKG-)mb?8 zd*KmxG&wD`a2m*8X)&oA*DWj#2+KYWBMPaAo%tzR?>m8(RgWw6N7bLv83-bIybgpy;k6zTRGZDl2YQiX)fGjGP#+@q3rX0 zt=N3;8;I6?!*Rd$xqNIxKsOv^Ec_3LOU>G1N=b5(%uZ!%vy~ItT?|VjUMU^9ttg>b z3YRk}a-5z#1CuxZ?bVvE$e7@Ei`JJ(wUlbc`?>{lMxY&%RHeY3LvFRnp@&nLw+-I5 zejWE?o=&r+%zlN!m7DtZ1WKDGM%QK`h232U;TFz5h zu993%QM>sz7`CGy05=IAAHqVU(vRH1RCaZU!&(W8WHl@jdwA8=eFue@yjMdluiDXn z!I|=9@!js{a<5_0fN{k(*5cw1H0X`Bjis)zr1_t7{|HqQ;F*KsWZLbm%2rCS2RaKI zSHpFur3xjn+InK)meh`A@Snw`?mD@-9e;d;Th)wamyNV_E9o3C%WXCrHL;oxYTEpq zQ3LrB{mb#VEa11D#a$!LdUjekZZ#Pj3oldTNDqk^A56p@7kMn#b-%YN3jA1g8|R8= z48^ysk1j$yreD?IPn!Ksq>p_ym|W15vRka|$Ee4_X|7iC=73>_pQVfCS9fmc(d z;CPYi=$i3aIfQwV0YyT6Zzpk}4{?|i@2>wNF2ya5S+m~rVn86Gncmv`1ed*Z)RHW} zZBp&(93$i|XO8B$F??}$C*BXG^JRO`73BXXoUu(OJ{MHX$V1d@T0{uci|Niy%*DVq zNrVtN+P{W9*~wJZKHsw3eV;w!72E&nGUNt`iYdo*b&p_r3P&mbyjh%g+@#pGGCE_m zl?-xj_C0$85T5acy_goCHBHaxO!_IGv=AAXe@VT312hRc>JQB=L4wX}Olb|l8xpfO zb3l>CS=sJ=7#F_il5TqBWhZOHiPmZ}`kRaBHixo4(~$5rrqXrAI*l$y+bb2}BXo-7 zk&Pe=`_FB|+BNH@4th*Ba#IHQLQv^?o=(2;yr}5+#II}--W_=`f3-TQFFxhZ8S+OH zqsbcE*s>M0WxoN&0}7bS6kC%@%%}rUU8w@Am>O!7pmH@U7B>X*G}FKvm2;C1>s!80FG8;&lY*)> z4H8ATmCp2~7qZ2ery&PuT??V|*13tumL+K3=pEnPEsNb-?PpH{8aPa6^@lfja!w-= zP<7=I*jlY=5!{VlYI`(-2Y{M5WiIf;esOef07WaMk;peQE#DGiWM~UiWG>ofn=P?+wMFlIPwe+5UuD<=v3&|4L;dxIQ9Q=Us z22gnegh1cyhRvhCM&QzTq53~BQ&B$!-pMX>TCIj={HTxB-PV%RRn>Dt!^HYU^9I-n zX8Vbww5JwQg$zOn(4%_a`n2xbVW;zvZ+Qbye9C~&_!!2WDO9@n6goUI|LLZmvm3N_ z^ffN*7hgG<{p%RuPTE!;ElRQx(tOOB7{lUgR2Hnz68#glFzOmNI0X_1A~k|U7r2y0 zhX~46X40KxH-nGk@9}P>)1(3xcePV=9n=B*eLh%{uAvHtjR9|fGTjBFW{YC#HqQf^ zUL-9}i4d+IlE`6Lgp-E82Jt5%BxNPsLm8FRw1pQg4;AD2>v-CHLrJUmH2Nny<*r1y zii-Gj4_H%PTTUcWWk$c>QyRW`%wFi~R(xIoXPu{ArMkl0eLOA~5pAVYO zo;|rOrd6q^9*3PTu8(0N65C~8Ir|@lMo~q$N0+KqmN&&{<*GA+q4m+H&X$)m1K{y4 zRU|Q=q}tAAN&*gV9kp)b**n+3w(G)p!F2X@diJjfjlZtjtz91j7q*4ee4Nok=;DLm zO84HaLad{?Ff}EsgHz)VF5mF-*J?hxL(|MvUwc~;<>t-> zR&GJB@;-XzC&sozhu|8YB#NEpyGoEpOTE=BP7N3Blaut~>`8n)$_cTbgWLCsk4(Dt zvab`)CE>?|>KrWt zMA`^NIuH;!Ry(0MEuQzxuux$c6F!p5jE}Pll|TniByMf>qc842`)f8|e29`UY7>Zn znc@;+)d~<$myjdf)*i0m_{bK$#;TF*4Me&X1|iQzPx$$exqQ^;4rCzEWGSpj7cGUB zRC-2>FYa2SJ=|fEN&^Ay(v}VgqPLdiudfm7_P4Vh=`TdSz5h0cxU}CC+C&+Dek>W8 ztQ}-1F{RPsP4p`v*=J(t6#)zxYqIW{(6N`5qiByj&^I*wsZcih;4j}#*cYwXdI{N_a-WECi9edyEVv6 zD{Ym3VUg&r+pI2eb)^@6OeDOYQslesW2A9eOypi>!a=?5t&U3P)*Dqrr$|hwc5it( zx|~Bh{m{f_yTPfU6CazVfe<%^!2psYC-KVj2FG?uEOsQF5L;(Ty+{ue?QJs9Rd39g z->fY^7ichW@(2|lx|y~7B;j;vZu=Au(UK4(LHyxT8v;M_1VO1TVln0B_ogc2rG~PB z)F7#tDpv@fXiGDuKd(jXTcw%4|D(AF!0$cg4_NM>m)}38XSbHBuW1Hf{!ni|OzQj; zu|k!ySQ9}aXqS&qmQb5utA`B&YxicmLah{eDMXBZ1Yq8P$62w9oN?x}u5rPm3RsQg zt=Se=kn()Xq7LIQZs*49~t6^{a3u zflc;(8k}-}M_zF4I$JY$*RXhT#^34{mUVl?-s9u2H-I{0dZ1#0;zkOKLPZcp{alGe z?D0*W;nrg`CE?bz8e7KfJY^o;{^CvhK!@`jMv&;NA3_R(Yauk>&0s15G9w*X10Lw0 zIDu+#9!P$cfX^SQHwx;=iQW*aFSjXPtP=R3_=tWs*?5I?Yf;mSm!NZN{7v@@*kPU~ zH7WHBbC~)EI}m9K{po&KlMO{8`0L!)kgbAI%_8pv!4U(tt?Ys=4wzrU$W~3d62|7K z&o7lb;tqET7`2)9Ul$Dhe-AFu*bxe%RzI?k5rnC|2Ro>BR5)r*+9u~JvDBvX`A)Bx zXHT84oGu>}urFB(MaIXcpvXx~Z-cHlsU(wPzEgg2c^ca!h5Rehb{}P^kM1Oyq zvQn2qA}#goFgy{Pt5Tr#P~KBL2}5qc2{cl`y5C_rpPX@?nU!e`p}J^Gh8Okt@z`d# z$j^~&{y6uw%~FX4b+HtqOx~*L45sW7h6?u4sHJJjKGbZgU3b|UmHWOiWdFzN8^Da} z_gX&gIm`T{tdmCJ19RsN&2i7`{CN0z#J9}2-!fz)c)N6898N(cRa@a>9h343&FCjk zMhg7D`(%gFhCI4+^*|bo6|)%WS&l=8gz@5|@{;sourcx~a+QMp6qO{Z`ehZAlT~fN zWS~^O27Pm?n1~!>&tGF27Fo!*5bniOCWVk`wJ=#QWNWd7weZpq&-6GBhA75r#cXu4 zz26VvgPKH-KraHkFnE!Ub&utXmZ_n~VEA^5(!|+`^>ouj5t*h$sN^nYaGr$IZYvG7 zYPS8134e5~yj3Wl1RcdI=9@*sU;czukFc zeDyW9ehKqmsHKH?UL?#k#iG*56axcJFB;6J0=bfu%tC5+Y8j^p80r+17-KxoG~@J^ zfl=nfR)>DWGO3%MXq`V?rNC|;cr)zNgSZ5m;y*vLuL(Xyi#R9N+Lf@JK>9Rvvq>rt zg_;9*NoE`e`A?FI@K|K2v#&o`Gg7W(snM1M{Bi+>HDjs%G3tM+bPgk;tAyr&W?Pc{ z_im0QIWOV+whlJ6;gG%wZWuqck#;mflU)_(;;1IwN5wAI_NAuFDp>%VfNjEGy{{be z9T96qzi>SxG@QgEiUjtyM?DMS^7OxYdF=-8QUK{`n1EyI2>}>k-+$zijR@MU!dAhk z7X3=QQV&_F7&k1FBLwg#Y)1dJ25qfRZudI9CK)%GK1a^YMZEhTv{{V}CG3biu<#XNEzd+Hk(NLJ`y&kLi1Vl1ZGzN_XDRpa!i17VnEUx ze)bn_D&n)KwJMb5K4dMoWM!1#N{3OLU>qL!kCb^wfsT}W7WX1&G{)Tl0fvN9Q@I_ zZF?QMp>#Tz?u){aNDAF)XSAJ|^>Zr&=Z=i?c8VYYpA!jlH%%H!t1nD`t0szz>g8%t zPbalm@CnX)qDYPg*k!+oTsPB|hW$fBJAhS(g5DZu^qTd)>lKQVwRFqOdr?bu)bev7 zJG$pDc26QlsKu$TR;8#Enc9@%`zc9AR{N?w$w(yq5xAaEoS`8p>H)gCt_WpBH=~j8qtPli9KFLkBY**-#H3WT%=%su4 zgptTuS4P+3tnMS6EvM{hZ2OxZ7J!Qgv>D72iqx2Km+43DbKU4x!wz=ED7fC&1`!VtRBMu09td)%_=q>_*`GA z#U7n-(xxeuSvIN?6A9#YT4BCqEOo&LuSn-V4}2JFRR^{%SDLV?(`6;_CKS1aW1-}1 zft+Wli?yb@H7%#OecO(^anJJMQ#DEDtvmJQZ{Clbzyx`J*uG{pxwwCQOvmnJ%p6)wIX{N&4Rt`o% zh&PMtV&rPYnY^tU-2FbMM{BfxNtwko)YBubz~C%(9x)ei88dh4(&ulxioHJNGFe7e zPM+lQ+z&bS?kPF8(yb()upKPi5IFh8KrWyFD{;CEB^p~J<-$N4>&cH(Do5?+^A}L^O!~u z6X!P|gZ8OEcjBn1JU+-)IC|n9LA#bsbm^9qsaHWDfz6Kba;_|_#GtR{ZMzGRlWl6ys?&8PYpAP8hLymH!k?>+2E+wJP z;9kk$j~W_Q>D~-m)R%JabI+$9n^hrtx&1U(`B8Snsp2?*ydY~D+x&S z!(wwBSwXqtRLol(n-n58H!C@DJ?Hx^$EF*Xms`&`mYQuPYg*0{l#cRygmX`fo-x;V zRwivbUH)ndAhzR)=`ypiQT{0C2Y*;~@sGvo>JoD{rL^&8O~K0Rxx(M|_#@HO#SB!s z=4KT0sw#CyB$yEf&NDw&{r><(f6@Mryb)0_*MQ~U;;**=TE%PWzMR}G+%+`GJvBbs znnz}(k^^KM;Cf=tjxBV>rD0X_NjTbF!;|1V*yqO%{3Tu*)k;-DM!zp^Py^kY&naqC zq>s65V6pgO5dq$5kVw zbDbgF3ut@Il(9s(pO5AnT1f?K%oW)b(yF_ZdM^mUZOBP0)r3Ch# z-WrEwUTpr?$P>Ok=$MW`mH9!k%+SLGl7Ufh&Uf3ykgUa(d{OVZ8#Q_y-3z|({)Fvfy*MOk%`GTf#U)rOI2#X1i1&uAG^*(qkx=cb)Wb6sNp980 z*22n@m8`1)N31e<_)$DH*D|7y)l#`B>-y;F2xuK{OZ?F?2&AbIwB4lJeN@3>YXz;U zN}WZqP*qFF!b|H*OG*$`&~p)t>~@5^yIJj<3U{%uOwCh)I1f80=VX0L1C;!66!4#@ znrTGSx+SS^R7{ljW|v=Xp@cm!@_;H{dMNh~K#K!YmzStY8ELj!nwM8roQ~d^j&AXNa#L>Ydbny-sie%wK9);w z1d*IJGqE0sy1JXeZMAx`?9H=Np0lc|B|yrkyDX)6DWZJe9v01p5CmiP_{4_7trOI9cEAv()$rfO0_nG&2f5JPC^ zD(>dVIEJH`ODm)<(}K*ITP;kr6+nV>a66rc$ygb6?2tx8M;b|}=JA@lXvcBbDO+Tjz8?V{&aqSljg@!I39tC_iKq`bH%e|S)zq!{TVs~w`wd;b8gIYFay z4&7WwnxmOhkJN+Yo%*~Cbw$~O&c8wavkzi#3@v9M749hqRAP^lS2OtVIpPL%kGy67 z0HY0V8uf$pBE3~Q^6@0%QnjT@z$!`U@Pb4+`051l0Xv_(rT+k<75KCin7?dnnwT;B z5Enwjsl`SZ$e*za>}hup^<8!1mWbx;Q6*6^3+r0W>@*-mh->h>xfi{qJ zvYv1WOK)R6-(xY-XB2V(`J*?8G($65&4TCn%{w5e>R2oQ3y=Djc2w!t{ox?Xq%iG8 z@J>!~&UTD+V?k@$>3B0pqqb1qLgy!(Ni9n^oz|oD4)~ia?i4FJ!!MMZs|R$)dJd*60(Wi$Cta)3-42k?>>%#G!j?COCPE2 z5UuNL;iQ4E#p_9L`2P7V>k7)GX{DJo2%NGkWvE2@^SN{;ZR-9eY89%;8y zw;3r8ARLrrWM|ePnX9^=Ptt2Dez`obR?-MzjPCOBu=@QG6Yy7gRCN;8ylyJ1obyxF zl(~R*NbI%)&?6|}BjlrIiTU9Ls zc2d7XlB_p3JX|Jil&wJW4|=R*dT2!3lWo&xF zo}bv1-88dgt@=~%%}dLbD9lWRg}9^MK-<7XhSs_|K*l=66(ma=X*!{N*{|~PRbz51|F8Ywtk2W#i=!uNO&LImbUVVSK zTIzaN;xsI5q?4|@o!3(?-YYDtc`r95KHWvs1LvhdERW?LzXUDlzMwy;R-=~_i{_S^ znp!1!Q-pSCz{-2b&Uf@jIkXps+nZi4MqF_(%iGPmJhHDa32h1QN;B`4dxUigjc-$J zN*z9pM`N+3$g-9gPumoZ^XWUEh9Xze#YW3#%;UprEbVPQJuRS*S8+T3{1bCtE6ma~ zX7PE|^0)0{$Ok>@Nk|_&aye=3a8CIk?-&07k1a$+zotxE%(;o$)4&uD-cWK+!f_M| zUnL1oShd00;Z9^cO4oF5pDYqO-qPuh2(>nx(`km;!VuiwE)(2)G(@X>*H=KZD^*5a z481EV;;GdZ*i(Wmx=I(dtx|ovfDDybgJp3@Ie|ly!uDBzTsfO6e5ScGY32+J#MiNR+fl zTWKKk5)R{G9O;8|x)i(4yzzTN4`pxQHXH7zTu$YCwp_T^4l=R=7+ z?;$P@(P;4q#_`dh@K0tcKsG8UujIWb5$Y1Sm$c1(Zqn60)ce&(R0@$U%0OC= z$KMnOT(#P}uXkapRS%O>rO8B%Js$Ctcb`g**)Yu!k@^8<50WY@8mkhmY}G)4dz<}& zj?|?n^DN~@e3&3{1&lyDMY`>v0NoVf!o|3n)$r4bNIac09Tv8!##&u0p|hU+2WO zUR1oWUROj8=#I6YiV9wl+fS^%muk)+iFst4kVx2=tQv>KLMh65H1~w`^0wyBnwYDX zW!X`@s;<;3Qwn|MoQ$24JOm6rhIhZLf2!U}Ef+>at`Diy4jk9D^?TIy2IQQq)n*j@ z)WXV%QO_vw5Z=Z~jJvS~h}VDsuw5C9Y;6o~!2{AUjE_jh4J{7r!41S57y}S@L1LYg zycijRJBZ0WF##6HuwcN!gC`I=!6R9M0rW)8!riNha+tM`h$pu(2uW zKu_z%$4jHr&&}2uGx`YTmR$0;ewrDFW~CY|Owi|FD@@e6IVeMia7R&`#0{goc~xzi ze6>pz zP&H!8>}pcXRVdX)66O?;p@kD0th4?=#nyV38rAs#@vkn1IPfnL&9$#GM9Tz+V}jzB&x_p7^$p| zS&R;g@K>k4A^tXXu@@qyJe9-S&mP8I8Ods0Cv|)#DcNYYptjUdw4P+CJwc87g-Uw7 z_?W0nE@|+9*jtArBn^kEBFzU{_7Z(iZ*F{*H8)1#rmJ;CLf86uE1kg)8;{%b5>_qs zH_c304M|j;Kdc^sDnD}$t~dCZlbEMWQmGRueJzzs%Pk58D_Iy@qtFJ!*BtayVJ-|D z5ONQMZB(jLb(OMbA5;B`IUDBakq%D@n=3D7UgUG`@J)`6!4+?7VAtt>ek)CDE;g*3 zley1)bZ3G-DdgpJVcTZ0Hcf7h?_ngD4(nxEDL$m&$2!UaMmwWoLTX~1;+~X!Y&xWr z6*jI@f;SNSq0f!XjM$x(wk3o|iALDv8(8@%cZwZpcU3RBnQ2Ltxki(4@~b;cJNC{W zpn4(~?VV2J&A8l7(AoniU*(zGlzkU<&ikrnUR7rQ989WKtT(5li^Qy}VzT$|~1Cn^MI2hkJ;X0bjS zZL~U)hQa0{L!a{0io>!bKwg4F;q#NUwLJh^PF0eGc9c3kFUznZvb6tbc zo+Pb{n?|Vj9cs5LPk1zzCIrKPD7cUT!g`osYR9qxZ(4G1vuw(HWi~cM)Zy%y9*{lq zy-~~=D~6{bB&k_vz+kATB}oe9J|n^t97FT37iOju`pZ)k}KG#RcW76$L7*T9}=d$>o%6mZedjxomwWc#mvdK)AHxq?!5!R&JLv4>xHj z@|!;nI{Ju&fi9kZ?Tp-D0o@Obtrl9?u&CJ{K+qe@ODnab{>f!>n&T2zypwgwQG}V+ z0BT@*NcXDwqUBKdp|LKjGG@C+t+MB0jH*@R$oduB3Qy8Djw(4S7BF$^jp+)_R*=KY z%FejcPN1P+E2uEkjC-0yPO&R3Sfa$h}3EI6dH2R7arj6q73zCJjvj}M81 zDb~woaOhtgY;Yc~-O2C6W3u$lAS}9_N@=-ic4}yO`I&|Cr2uY8COZ92*E<5F=wXY( zkmID|l{0Aiz?)Wa19fDSaEpm|b})Oz9)(EVAR(hK5Fr!3-=EzS#x@cuW& z))6kPLapBhRaLrzrwpc3%s)l8W<=mhic9D@muUJv?OvXd zQ5?AI38V^8s63i*{iQ0vBf$8NL}^Pf5(Y>0L@`60=AHLw*bY8C{5Dj$e@7vvoW9rb z;o!aJCLgHIJ2f`)<|Z9iGTm_HNo{3{AK)G z2;|D9oJ+}Z4ivbPl&l;OW5T^xsN54Br9y4Vli;Jb#TCuNy?<5K2UGeiJ}ZGiRZ-WG z{f~;Uy7R_0Yg6S{D9hz-6O^=oQ7+%9KW=>{EpG*S(@1|psgm{T}y(e{ZHg03C7Z^hF+)*Zo0~ zXtOO{T&f7i*qcZ{L^W!)`jyEzsk*fL^%)Dj6ePBZY>#Uh1lcqs)qM z_uSi>^P$|eS^|;SOex0Ej5guO81V3mTcbQ~)UCNjWNNcZeF*^~UVTML$MIb1BB!Oa ztHah~&G|&B2fi#SDWR!8u(9SAvV0|yvcz02vC{aqv#T~$R;tshlbw;5Q>vAF1QWhF zR5m-uG&K)XD)+tZH*xt>veRxjOG<4;DnfIx$cAjB^yI9gvW?9+nOA-LJorh+qCFW% zJ>#c&h9II4IK9U=0Hfh}JXB@nquNpouD;x|w&e9O*PKzBY&N%BaG)#={Pgr!Na)6f z>%FTnr8b*2I`p$ORLC}mnO=E;NWkv!0y$FR0ZAKr^BZhlR|P38Hzz)}kkG;a17QtM zK-n8;Kxs{4m@6tk)akB^vh`g+p~zdaRb@P$pC}#K%=5{JBy@tq+3tk z={QynD04XLo0{k*o^gdklBd%tvf;UkOshzjLm&p^X54PGK98Bxo3Y(#iO;RV@ znV763%dmtfb0^1`cNpeRlPR?&XmGaR+$kpnM)d7sqf?cZmz|qi?8}l{LWsa8sff+O z8rcJb?k}y=QebhKhRDX+#{J52;Py^k-Aq*6+lfOm%!k}@T>uUzc<5C(RBbBc`q+`K zuXm+5RaXOY*9|gZ!?qinLXXoNilg2&dtzE?s$|z-iJW$bDGCZBzXmsH zI@A@e9cpD~jNGAMBl{ls1A>C)W-Ib=P zX}9J4*78U$;PuCF`eE_G_PjHuOw3cJnN-a>YnQieqvy7??3Irli5QB$Wzv)#xAn)8 z7VNWuY`UhLA8dtS#lxthbR9!9AD?=-eiIvM`hqlFdbjm@*SVD6lg~KXmObN)kfG~` z!l$WHaw?pDfAu;O%%yS>f$1hV;YrlX#+0OqONxDfo}7xC1`B>-`oyW%k!WEI2(Fg-#>RL^#w^a0^o|r)1R$V-? zHO|#~-;t7#n3hEU~Cji83Z+CR~?vcebo&*W(Ho9r18oa;(Z!=a%f7N0~C|^IHn>!0KXy(2whGWV?I! zSqhv1u6PlW1@%tNdDWSXYSd}E;)0rxN7!W8KWNbD@R(&&nGa2{*LvJaeljCTlwms& z9*J>Hs3|0+NjU^{1~h^o#t7M<0c|O%+fNXUoR>^=x?+Q-)=QGL8mydyuFdwP54sX? zP?4N`5#y^GnQ2eyjn6E_#ZGniDek2xOX^l#Ra|`_06Rbq!V}!C zRA$&yPt2)kJw^b`W?6ziIFd(?{Vb@#-06$lPWM{PfCq$RpgD8aFakDZWt^H&ZKl@Z z&qSd)F{e6eR@sLuY*pL#KeT?9x@6v=R%M%;n@qu$rXl3>C9YBDyT99HfIKi^bJ-Ns zRMY6TeI18Vx$!<~MFCkU4dayjCNtsQ>91P4KSZKYta`;_q=dNRuRht`v#M3enCq27Ohlxgk^Shh_$sNG|B~L<5(eyR+N#<(mv^DBw%#j7pVL%X{9lV zMyKdT<1)>qlsQv%MJWUBTS*B>>$c`A)sMsl$HUu=+PD3zx>EtgILor^hS0E2aZj4B zUQ9l29zCq7I%3?XQ|D*qv9n9%LNn=BPtg&i?QM2g8DYThf0|-S3W4fU58<7%o%~4s zgyYv;6VQ7~#-6WL?Uty)loYfnlM7{UJC8L9PDm#|6my1QZ7yNeGJ?}3M4<{fDNc3- z_w5foTGoq;S}qjSD{t~jGm$AE(IKW2=pE zYNoZ@`ej9aZgzE-=Ms#uCd+`WYVHTXM>J$Av-HgQ9P84f!KIXsnlYT5nW#J3%}hBm zv-ZxYsK#Zu5bHBOOOFj!UOtc6{q&TE>D!|;5d%Ofkd^c$Gn!kUuD)I_wvKX1DztAPHeeQpa zjC=+s3%d8FU)q$0nkYr7Lj6r9$k_y^WiRHy^a!h}mF2=H&A5~KDhkORQ!u1}k!@T@ z4nyX&CMY@pg@226(tQhi>yHFdIDaP!w(bGyTc`Tr=TX*+Dpk(KG?t$Y?amN4gvD!} zpptoL4EpdQyfyywywgp?DqJs?Px&NYX}EhH`rNPlDtGgv$x?hgwys}u)$l!T{&X#` zTJ1MM#RY-sA`z#xa@>9JmZ&W(oq!$ieOu0%o#viy6FEBPAN`hQnZwCzKG0Xktxo=Q zd9_U8-*UTtx<{b9`O#qM+&f)WdK|ptcEvG(`$=&vJu%9(F%~9`!e!4_VYlYwDO8SN z71=zp;?w9L2ociHCaf<%G%Mnw!pQB_1%&Jiwf^e9E}z4rN!s78=kT5S;XQKPJO$_4 z3g{X8r++#pTmJxtd%o3?c6L^SBVCqg6)h4gJ*We(nnv+nSA0=gm{U$t8bZ=YP+D+7 z9YG)a=>;V;(VIwKK59(`H7+R3^mPt+9G$57C1#|!c2e{06@Na0@8>|9mf_*Ff@l@* zgt__AE@=*e&FdYun(ZV~Wo0{MGE*>>6#|~UJ#i{a@H*Vv1KSFef*0N;<%#-CNM;>X zBSV^9WBp2=s}RB{WHC=v;NhVcHwvT6BZo&6dFXZE2c_T6k4c2^;DrQ=HF)Q{F8*{} zx=#Z(Os)+4)6+};0I6*)gY?Bp*LqF2rCOy_w?#LaS)NB2Qi#fsp^=mEz?On|Tb<57 z@=L<%@a$rs(^1VKa2M9#StW9C<{H|KTpqIT=SGoN4yaMh61IIj&i!#=bOVJdeyGvS z!Ew_ZrcvK)i8gvV={+3uC%|{bcGSyq->@&5#kdtMQt}6jUQ;Zrr1yY)6gm;yV4p)Y zQHfsn0DIM)7@)7Ll7gOhx<+bs)4s>MiuO%MhVkaAjawfJznvMr_YH-hu}H3G&|UoK zm-IV>n`2ol7A`r8yv;FgJmY1in?i@0an!_!&#}X3yslq$6Uqm{mf`kclRN^~yI<;5 zwOFnXNi*Q6s*^KG1Xz)$da4Y~aN6RC_MWyk@ptp1X}9=lYpp7_Jvn#tqSPN_HqRTI zmZCj9i*c(t>}6UCM%PtMAL?6)2M4JF<<9>Al7`%WqvvXwJc_IDRXA!=fcC1jgWZ>Z zIy3gihQ`s6NUeMY-_DC1eT;iR9%FY@usm6AD$fu)F-ph=c~*q|xeA z_dK+sQiCTn6Xg$M1dy*Z4DafLhYxh^E@R!NdR8v_EE0I2tEhfw@;eQ#bruSyBB8@@ zS>~kIG2Oerod)GchNY~3dMkekbMvBL;oE`fsoSb?oldkKO(?JxF7hWiLL3Khpo6|d zY4HQVg=$k!IrQq=Th{Zk=uv4e53x8lP|B5^`ojGgD37en{dip8?4fr2Db@XlwUJ?b z*@i>&zUnp;JTw-;G>YDRZvJ#)aMh@8?B`d)UIZoL)uaP{;Z!e_UdpV+Pns1l~0Qf6<%z;XaF)9bWJGCVe}B9jfH= za<75z8Ula8HLrnA{&Z<7dxn38#@Y8)JBAW;7R;-epXABR(7w9Kogo#l?c=G!UFGn_Ow-c262pTl?Sj|Qq7HJ6X~rnZoL3%{KW$~Ozz zk91OHDs-ztdU~BS*m0SL(vpRw^dR#Hn7DZ0in&(5nfHHdO-S3#u;Y$)zF>8D!cml~ zBXggE9FB%r2m#O6zfZEQt!5iT43j>x`39go6W|5+OlK;5HUy9FRbgH^F8*|4ncf;P zwI;XmyZO;8aY@01oxbH7UrVgYocqp|u5t7}UP2Nu5}YWE9OT3=FTyUVC~ad@@!jSp zr^Homgq`5>?1f0LI-R*l685h-{A2y;I{9^tc>x$yGD`b75_DP#ZN~8lKs7 zy3qJt{OHDFV}_I;HkVsgeJ=iVD)3{&#iy>7n~95$%M#r^rkrQV9S9_-oaZCa4elv4 ze%{h8R(jo`c|xR7D9hl@EG=1IHg^Nj0n`p`eQ1%^@kxc0lj27U>!j;Izje3WZZSQ> zQdQ?tt6+LdznvM(RXAxqDK)%)6Th7hdzAK4IuD99%!5kXXpSJ`);rB<4@XG<0L+e;j^!`6|w76znu`0WEunBh%+YG?jIBmqyGTN?=+vor{g=# zO=_y}+MSIewtY%)f9j7^)VOVNyoyC@J_SzxbU|y7X-9A~8F?Elw4z|UIQQM=kI)68 z?w!{0RxeMjEezCp`soxZ9aZ^+G+tSG$54c%cazPwV!mrMwX;vZ>M}N*x|yX)E6Mqk z>YVKsobxCWdS_wkiK&8~9<&}}r-04Zb)H57h8ELAZR)@|K9VA>sY)c3D=H@>_@46$ zqGO#Su)VQBn!{UZ)%sksf_3fPi$Be|(gJ5QTz5D?&b3=wpO48Qd z*XN-5D3p~LZW7a;p|?CQ`2-X<$b!K!?=B@~2bny=iRcu0V`Z3L6ka2GNrZ9R-TC z7LaMORl6>EyS}*nN^>2nm>3-Z1EeJ_Cj-J4th!{4Qs$7AsXis-+;9gRCm0dGs8qQr zTdnyTONp73NdcucbDu;;5;3n40mA9KhP+0Z=@|7uCk7>z@YQ-E$rmalQtYA0EjFyI zC&v*Lxh|xVl9Ch-4%@<_sETX#ZU)FlBy!&&y|-Rq<^+1TD(d!16r<=7HR$&ab2prk zu2;*n>IgV#DZqARL#R%ABz@E=@->3wqLx-dF7N%QIE+C>pS$>ewWg(1)g3QWX3{*> zsp8@l=ukgQSBsw1s9H58NmN)Yxz#G_m5&sj@dX^rli4WP{KEFKM}Zk&MklO2bm|E? zF_dC~8FxV-0^g%iChOD4l#^t-@{RxzC#Qv7MRGwijnRvZC}d${CKR|~M1t=IJ93`X zC}So%i$X&)RJITho=}i6h^48mk~w;&1&qeAxvxWh%eqK!bIa%eq=|B@PXWR5uZha3bw$fw)-?uw>>jJ3(_mLg@2|84-y&frtoQ zW)AVQPp8aO=a`e0X-`bdEH>L{87fgY0DLj3k%+V$DcE%TQjb>E>!ArnDvFf~Yim#I zul?BZ#=v?{q8gS#6AOxiTU03_GFs<==6NSI`%sqQmqev(8e^zU z@Bs3UhAX_Z>gDCbNLfNa#sQ6M-ua!>OU|QlR1{0|P7;R6{cRs=pMYW$MA;*CZ{HBx z8EEyrqq>`d(h5k7lIr)HpA@XA!amvv&Kz}wb2ZFH@I0%0W*3!ddf=7YogrAvTOJ4a zd~;APv|hEAKfDx&0hddIX^DZ;AH`2o>59#ynsrpwyZ)DLQo#<*I18_LGT6s5&!`yc zb^UnToF`}^l_=T}-9B5%>GH(kQ!@l@pB$aZiImJ}erKHm=Djh;q=E+FbXv_eo#ChR zDrjx?)P?dbDhb%~oZ$FJA~=_*c6$AMqd(<0K7kki0E0`qcJ$D?ZClOtRjSg+O6K2~c%`O^8RY4 z04~{T)`3gNw&GPcO`Lg`TL*EI%#wXz#cbmrhCAWSS*l%D{T^n!NUJF?6-pB< zrAZt2b$}*xaE8XBwkljtr4<7hKBy!BO@^C&HL|t*Eb(b2#1j+r>FRoEHw=mc&P%rE zJ8ra2Ig(EM9_X!J$TpSAS(mLc?x!{xYPc4Ov>;(Ux-faZ5PoQL@E60y)nHszE6V7T za*!UD@<~Z2wsm1d6)8#X8wgbPOl~Q3S(c3}Ak95EY&C)|QYPP$3Q^B_Q<&zA6_875!1ushXw9XIZm1O(vkp zlv_d0M*CD8r9_OMHz#jQSj_{|txUYHtIqbi<|g#&vrt1)p^AK~e2=$+dA$7j$v7O) zSE%c@GLGG0WRi0V{UNm$Q9PBw;|^>1Eu z&CAl%qT2KKg)PS%O)}z}Qd2UOEDX4!HeY@Q1~C;P($f7|nY*hNWiCpmD_EaMZm7>W zLU|NIvg*OW8|3fb4L(Z{Eqzn;)RR|r^R}t5jI9GH7Ps>f2BQA}BYhRR;wOyfso3j8OWU>cR0QcPlP?wAHFS;fQ{G_axi<#!<&ivW$%yiE|G!D_> zI?$T9Ytyo>R)akA)n~lc*-!6n=b+EROi-RBHDZUV`kzv)+q9NxbmDt7Ewu8gWR9+= z4S_KpXPKLFR+pibRItw_9RwuC2C|*W@9yNKo(gpSnXI>UW{+E(sUl8RNIu(AbBkmJ zmEil+7M`1;+CQfniAk4T&o;%#)9tx0AvalZN?M8YM=YH^f3BKQeW7$jjm z@wHvojTXBs;cC?u^wj|^Hpr7)li@t5$0*M(cEE`eRR?m{_o~3hK~mO23ncYT&YBMP z_xP-Bt`{_>@t`iNLa){;vVL+?Gb>qjXyzS8L5^$Pvx}6t>Qcq`rVA4;G?mOaR=g!d zBo!0rj+R;+Kxnp{TMp6f=iQ}K8|QbiM2q}^L~f!=c8*$J6&9*DJz8pAz~c|iI^q?M zsVgKOh{QUg$A*TDc&@+Di7N#~OQ+43Ou2=R71S;acyW`{J7tTerdpo4Dy$`8)E;EZ zG@uAV52w7usjHN`-m3jMR)yhCwVOf~CJ{X)rQt%m4FqpVT+TRgez5_>>vGL!ZE)jMJ@udrtgvN!(# z@x8ujwXHQhUDLfit=tdlWeTj+qa>HOdu{pL}r?#B((?P_R<8sD%Tu2Rp?@L-W~pu7<|g zspNB;mDl$}O^0wLb*0vEdO0!K@N+}S{{Wk7Kg?WJjVbm+YBsf}Z(3|vh^f0FEZmcB zrl3-%hyVf;*_;#M786T*3!MPiu4{$1s8m)-OHRp7b9~@vE_T!>hJ#wXLe+IDlP!MC z-7=QS%KWL-&ZQuErFE|S zz^_)W3sypIw0ek|YN+IruUYTRzS?Xym}RJp>( zWyn%7jzwoY(A#EaQqoW6s-v!}uEiPDQ^s5Y82}kh`tQHZIrQS-sMfevlb);0y*E;J zg6gIpm{&5^zD0sb_#=}}J{?gh?G3E*q3=Hb3Whx zTuRri2D<&%QGQQGt%~z1b113J%&>=F+utZDvUAJBymDQp}cTp?Dpo{4U)DXD6W&my&sWTs}gloC8fL?as((s5%N#a;(L^o1W(I{JX! z1^mLUb7P%`$3w(5{{Y;mSJ^mFZmnXgBX*`oUrHjE(~|V#&9^Sn-*smRLrE%8J^&7} z(QD&}?JT~`y6!DRs8$y&k{VRyb+z&X)DK)zO)k4=I#sW-H7Um&a)C*zIp;PzWfqcD z5=K4ubX3eb$uG6nY3Py4Kj z##qyp+P_(>UAQsdtp)m<~g2_3^eF>^I9j(se;G~VZ=NKI4~JwHsDmS8TPq^Go7YD#i@w0R%3_I?pEC>RS7>)bhrk`iJFyn?L>%q-Q=s#I(d8Zg0L>~jt~p6Q zCOC3mXUM;9sbThyavllD_>>H6X`4>a_+h0=ypo5Qx7OOydW8nm3@$gdt}X)0l~>}m zIEEb+EnBlo9Gji(s;+*}5`Nwd>>)k=RS0=PD*NX=<*XG^%%0(_GZ+ev>Mu)UJkQVPON{9LM5-96G#*_o~p< zVgnB86-90J5$WTU0rgQ26Z{@p+FhpaU9_s)#FMhgZ4OAx4$&{Tt+0YOPDn9KJfj-} zw{%=DWQSM8-C~JiNIN`01Q%tGlF-B@`kwGqRXRnLaMfGHM3Vp zT;~!+`EW{xYaQ@jR$(yAdSekY>4tKxuU-?TE@o2(8g>ON_ufm5MWqfz{F*kEbk8|N*IrMRvejeEi{7Tir6JP zlN(feEA^SgFwPU7e6!Ia5ifhyiUZD4XlqI2^Io1t&Mfz(J_03E(!QbA8!X;8kHLaO z9Q|B&AK$reRVq~La(J^hnYY{b?X<5oC&NB4i*Vn9q|0*Dwv9cEdEpB@%Jh>B8D?f{ zPbDeJma~KFi9XiR^W+WBRNNBnHnj7F9r<&-XyT-fD_C6fTqh9e4k3vb31qWR0igX< zS3dm1js+=eQ0@vO#tgdZhf)MjHC-8#tIGLZDe*7?!cd;L^u%Ry+3!#izI3accO+y8 znRT1WLTwr4wX#>pJIL!9GhJIM*~O^%<`U+4!jCY)jbyDr!h=E8Cou&+sTb7q3jyZ@ z%UH)|_~rn5JR$8Q{W`wVRVJ?|n#b!4K5CDk#4g**N$ZGGk*h9QX(@E%bi_lNk~xN> zRieWBL0yHs7@v0XI)x@a+Syrc#HlMCLb||~m~vUvING^1y>(Pu&G$VF#icm3I1~?3 zytrF}2lqm82rk8?xVt+9hvF{93Ir&{ofd~+#f$dM^Z9+>zwWwg<;YDu`TX+fZ9GJhUt;;mLK|`nApxcH5T}fz(GRm@|1H6<)5)pTJzy~e%FueMp%$EE zIg?t-^j0Wkb+Cg|cu%XAX!L=YXXNWouh>47gl2UqS>l=S$A= z1abyR7P?u#iYwm9eBWyM`=FvPnsOjBNS+CLn`VbxZs2InDs_lTKdH@hv^WJ&$VqEu z;8?N4a<)uI>6A#_d2ImCDbN)DCca6Cl*-nrIs}4e``!yV3Ns03|7pjX)>p5asak^Y zk1bdYP3F%QpRY-agQ%dcpcdu72MI#o1B2UpH- zq)A0?t9xr~x1<4}ko?5Zvq%zZD!st}YeW}0iBp+ak>x61t35%G)|6%((_$Fhn zbB~zkz1T#R**ber40lJoaE$RHy?fF&go1%I_r)t z+er=JvOI!M<-U!9tFKma?c5Kwpe&dg32vtN$|`!+dH`uV5F;Ul@jD;OSdKihe@N(6 z8MYIqf=Xrks8%7U3Jg*$QAGoxA$d|w?6pbW=$wUknJN)YM*(Jv>@~{7(ohx6U!or? ze(YVR<rPE*v^bGtPcdB%Kc$ZQKobdkX$gGaCUTfKK!)wbbetGeSIrx4 zBT9!k*K*b?4Khid$;!tqeB~E-;rOo9J3oB~?XpJwjqu)`M!$AIyvH{fIijj_k6Cwe z#E+pk1sMVdB+z)&)9&*8+C#%`>ZV2$V@zqIy|SbO6DSBbi4xO%KN0*x%3maY{DjhW z-1p~J-ewgt;_(k@`F0MVimx276oyel8c7x|)^fI{q^TqmNC3PUDvZV>tIm@*=2chS zEKgQSamLk}VfjJSe4e2$G%o%w!Y+}#{Uy~9oUunZjyh)*6Kj4S=y^Y!np&tLtaTD? z-}%`bZYoaHzlNK&+h5)BtVSBsO%UL(P9FU;)ej$UZzceDcA&)2kKVprQ$P;JIpU*e zvvTFmE^)^UP$XYxZwF;`-zXW&kBD+Ji`QQ|e#(Lh9qhkUHj4?ub@X)j-Qryr(`Mm( z^53?z6#-tl=%|{T@Bij@!P;NdVKkx@*Xe>R0BH0Co&uW$nH2>sLaazDI+94N> z`LWZ8CgtZ=`5RsBFN=;G-s12}74;8nD#p98uu>(*E_i{^$t=2Bfacg4+s(M)Qg!nU zxz&~C?iGugniuDna|9u}_g_@QK*`T>PV4Ov1)A1DeXS&32(dT4W0invar@bM;@9fU&oA~%E~nqqUSbWJ7SqxTVjgR9jiS6^6Ap%X3|Ls0 zq6c%os5zP)1VE>Cs)co0nhI5u{F&6r%DojI)t;dGlsok^svU2)QbOK4&)o=i^Kt6r zc`}Wo6Ubzz4Qwb+uO;SiSZ5!Hzt`oO7|fS-OQ9dFfZPrcc2jBjqQXQG9x3lBf%?DfpT#2YdTH(ElP@}wLuzoh=!AjnAb zqi@?CkvYrlJUlASM9N7eT6P_6NWuZ>qW})2|3o{bkG$>_uYMDlXJUaMfl@PvC<_ZD z4B8b)fpqdigISy|NbL&;j(vA(_--5w3vMdPq366#HosIp-hVg22n4%-nWe%fyGiW` z5Wu1L<6&*G#rx<@8M5t{!FMPBJlX~=iRY-OT$C&AIcxcK>~{J*7g@3{KGZ$|c+H)HJAAc#@n}VJ zwa$>?dsVkE<_iB~9JnR`P@$k{hbG*MSnn+#LW^NWhj5Ga4NOskQH7ox5aYJ4`?R@B|& z6oI6FTH#%|x8rSK)?Ld|=k1I(yd+BQk24X5)t6}T!@O#A12;1|v#qpf)WSGmMaU`X z95sv&=AA*24gd}>JAeZYTz|;j+=g;3{2LqFlx^=i!#||ZR&`ibd`7F+4d?FRf(9)< zz~b#X-37I)!~6&==TuMzNYeCErYt5(Q&ZPl@(bmu!|9U#wpWm98Iv9FwGUa!AsCqIR5xr~|BwB`k@4^~MR zIX0L8Akx#tRP3W#JttpHGpMudjas2+(j|pqXtXq=&8I`2IdQXkhxqDtI9WX4i^rT+ zIy%AXT8`8cjU)T-1XqXK`t_WOz>>ftXuD$dcmvZLz?!{t8k5}pfYFy8Q7n(H@V3L2 z^XR?C*5ZWC;nbjPtq&N!4*mx18K z{p*Mdur%;g<$92uP54k4)J*zbN+@*^%TFTS-e8f~^B#!)NcM~9M{-2^$7R$Gae%7nn1&ve)80vHh8A)vg_;f*p)J94e)O zCXTP4d-%qL3!WJOSGf`(qX=+26B-Onp;CjXDSn>zU>aMGH*33|U&uA6=;oh`u@!m1 ziIRYX=?cV4X6%Y$r?v4Uo`lC{)7kFJjj z3cO0yYIC-)O}Ez`(@y1v_HsedNJwcMEq%8i?VTDfD9fuo_m<~*+PQOl)TabH>W+So*8hK+$S?%Wc8VQGu!GH=^uJs@t8u{UfixB4|Jg z-Q3iA+V_b*vV|8Ce1HmCq?a$WvCe1c&UgKS&@X|;0Gd>5`1~KG zJ`L?|s0ZLO2LM)LfU zpw^H&ZYRQg%L^J6Iy4DNhruh*>wyI%teeY%6}z#-Cfz{P^!CX<%Y>`s7KX#gvq-Lg zNKhQm_bRZ%Xzuv_z9o%}=04RPNQj(Lwjz8LEpT9KFrGnFDG=NVm^dc6FJ7rr-q!@F zM|l0+p%&~Hi0E{MHM<`@^KKM(7OYxzMY--ey23(^hCeLrPC3|iKJALwSMaP7c?Ep! z$KO|d)cfyMfhZ`%vYq&V;|F(w4Ei1_Al(Wp+2NyGIr5aT*G? z7~O`Cc&)-SYJp*Dt?VKRdR80Xk0eKa{FR7ON)s+#Qsx!JG*22k$)Jnq8YXOt{9HVf z&(+!y&~ZHfXqXAg*RICR8+?1Rj$@bt#t3xH+SeW)T_Hn0msBxLw2ayADSxgl<&iD0 zo=zVgY9;wFIdM+d!03T>y;Fra;XBTF6J1CC-D*yR@nBTOgfTld{;oH?mNerxc)yk7 z`D0eKiB^ioZBCMYMs}VqQ6?ybJHIPAtIu1o?=XCVj`6sivs*hIVs2sTjz3Usl^|!$G~6D*|vI$eW(O4 zriu2)B=-ejznlCsYR%3$tMvOiD&^y&zWqj|%iNWc(!2z@iEZ#+29X$3?%I9QOFMtJ zGMBvrsl8RHvzn3$OLj%Fx&oP)0{9(8b8q4!(P}?ajYC0R6*%i^txOFyEmJfm^LCu( zZFmBA>;BfvqiUB)vGb4HbABQR%ybE5=D3fj$w_*LXJMWQCL=2w#`shfm@m1hayyqG zvW}Vr;`<3j*KYpNUM-(4!)}WYJb#-K94S10HRwy_EpOqo$L@c=O;;@dgtbcFKZFgX zY9-co9G|mRIcUOx;^spLW(l)}nqso&POkM0RS4(rxq4^=I62*C)G1j=aIJm`!Jz#8 zXB*qEQuMN#nQxdW8^{WPmNH4y0RYuR1lDv5iPpT16 zU*lAiH_wvFli~B%0)=LydgTPIt!-C5WmZ?edMOd4>Wap&d)2`E>xqf@e@L%2U7b{I z8)}s!YSgQAERPn3C*HZ4M<%}8lmhM!h*F1))+5NLP}e0kDL5rPCQbNn%e2HMSU)vq z%l3>g$(`i=9eNX6Pa%M#D*%B_bt+pp)uG!Ts3f#A5b`x*KW3{o+NJt5&mkakknr!g1OT_BEvIYE|98Qov+}p3svAa ztzB|H{5rn6$}qeRq;mpL;b4^bD4}qXp&~&i8kbiD>=BoE@~Y=R(5=NDbs8jQm!}vXZQ(QF`|Hr%6X+Jg>0 z_X*j)E^=}Tn8^6BO*0!KJAagEplXK@h*fRrvsm@JI*NJbg_rL)1nX+oMFyJBcrG5? zpx;(b*H+y}kIwHC|9qu3v>*L$Xs*g0kEMDfxNV z)!38WkyG_%AEQi!{d<0(X2=Am8mnc?3R09(FH3bgXvV2Z8J9nWQvIFMvLm{=%;wyi zHP+lzD|}uk;(Yqm=n(SPF3o6}C7OKIF_BjOx%yIa0Nb|+|0()=Pl5*GewC5Ru9lZ( ziHQh9o!M3NLoORyRM`()nTva{jFYBWG5P^IDlNRQKiJ&f>6D65pWSg~)}O1y*$TW8 z-v|E5>hgs?U%DPf4xq`qD~UkY+-fZkD0Ow!+_)`Vij8rXpu{S+xYTm3g zTN|Ws;Oy*oZv5zIz-XbAC#GS_n*#GJ3;p4EP5^{SF0H9ee=6#Oa_vv%jcPyRHFjT! z8hX+!I-Kz|s#Vu1r4>rH#JEPUbJTXld+}ULyE+FykLbs5e~udYv%4Xm&fLuv9Pw0! zCr3;2H!HLC-Gx)9;7%n&=G(V_5*$ozV$881xl_K%*uxPVpWv-X4%^uzo!c!cvs83% zbk>xKAAiFO!vY9WWE1*~urzf8wzUxKS+&tN#{xh*AOGk`6A-0_IEfl^Y$0UzQp86F z;>Q2g5zv8r_g2QwCQst;?lqyZ4tP~nsZ)&X3(4?GnAMHD@FULJWVPd!I;fUuKLfEF zRtp45A}4y$(!G9p+lKCzT*WaBQXAt?QGVYPsB4EL25U_2zlo~rinQ~K?y|NU*EABs zYl3>R>Zv>w^Ly4+yT-Nsz%*YTa@aIa&ZZG)J>S&}iw#c3i!olZ21^g~u^nH93GHfo zZL$zn7m9L`GKHYj(sJ0QeGC=~_9l?C;8lB+(Jab~(nzVFo{i#7O=wK)&?GD&D5=-L z(pyjHC-L>cg>@-+DgLIdCNiWgZB^-<6d~;v^;ocHW6RF>{Ox;H!=0T{H?2jpxuhC! za<@IbK#uLPDQVIBxA{iq3yK#RkVs*Ji>#4bv%zl8@?hNT3EWpnvpDq(M^v|_-^iS3 zde-i@?$FPQ_txi1cA7pJ_94`m0InuXrpq>7(+j8`AR_J)nR#YjXEw17hPg3o_k@98 zNr9DPFp1>Qp?QC(JqqDnUHx%s&g*l&yXM2&uWth&eq!!b_k zRK}N(Y|@2K?tB6E_NGa4^P3p1=J=JTSFqy!*MCSpIHdCx_j4?yk^L`B3aj4V@k;DG zE7+Yh?1|#$tYEQrUIy%7P=eY^M{ND$Y-zyy0>!hzu&8FCN6*90q$KSCT2w>YSq6Ee z!Wp;tzsWDRJYv{0V8MkhL=6L2a&r*Pgc3+UxF;7=A487Y*b+(oL#w)K_9~=8qn63I zjEdtGeyw*@LD^^cg1uZSky|>Dj7eV#Ql88hOq9fpF@i7st(A)EC05%k1kpC19tw#k z{y!wL%6=S%=M%<-neJ6O=(L`^Hd1zb`*phhS?>1=e`CG{*zinl)wGC7z=C(>-vEN7 zijd{xi;*UE6m9LBh3fa)ijT{~e}v^RBuf^EFJzxjSoOg1c~Cx*ichjeE*RJ87;9bs zkUkqC7_XbyJas@b0Iu8mh+CIe++|Rc0h0?!7s~ouuLLcwYZDzahb4*Y>Sg5C_jycQ zXdZv|X{ngmLtw(9_8a&Yk}tR7K|nOx=F!7eIa+xC^RD0nOtGa!3;;`&)uG&RVGt~f z$bC-Js~5>uuD`nw?=H%?C~tNfa9t1p13LWIn|S6 z2);&Xg)!eiK`pgkb6W~u7_ZSIPOr%*={$luh7m`ss=33$u3u3=e>SNpJl@3W7GSbQ zzkMb+*jp6t^6&KJ>=^g)AN#X+^o&XE3_Z&NQD`lt|YMC1$`o8r& zfS~>k)kCKSVp#IR8q|~M)j?lMPyS3+8TqXK52-goR32@j*oP@M67&+^}tLYeZvFI4h`ntU8K{-(c4VIC&=UP@~&XV;B z14R;7Rrz;BLUUWKCjLY5%O$Tmibpt0fI$iV4FU@Qkr+X$m9u0;bXuu9Ah|0S**tMY zissK>mNL7|kJu3SXcx$I{BpR!psknjTL1cO=->E7fn)0`?Rv&yh}JkRkheiNY0%eZ z)(sf#smS7meiBomAMIEh2Z>mn zL>sj^#cNsuTC(B!)9^VF(uPJf`b8Ld1l<;(1#5Afbu0^Wf5Vf1Muse@oRW0RH=R`y1 z-;fkfCWL*A%3K;CeTz^w8S&+j+(%5NKU74s$O8+$^tAhtk7JNlZHRk*p(h}}g+Q4j zgQa$zEJXsQG}mwWvmFp^w7I60z;YMB_D#4K!u}Aa{n}){v)63Cx_4R-TW;-_h4b58 zTgQ+KxswCbc`Mw&m}`}PNIA6UbRPzoNE`cVG$lzSH)U4zWXZW4S|wPuMkB#0R^er4 z9aYWpf9K}{gl2H8BPGFEI@Z~0uZ3Bot+v$gEQqJ$oRogeVcFekRK=?E%pH9te!fUm zo%~p}8|qqK+-YJ;BsWI8X<+gUndsjNrl0o{?_9|`zwMDLvglOW{5NTQD9&6Y6%0{x+~4 zzk~SB_V29l-Xq$Dr1Dlft}^=()SLQK?TD|$zM4jvM`%F^*hVg{25l-`Oxdx~>lZjTafjV@mUIXY-^aOIk5$ zl@b2In~^%jdMsxw8$`8g9CbMIqpYFS*%=(DfQxcs2Uo4(&CR!}Of+e?S%YhXiChaT zPYX`usH5j{Blt*a>k7MoscoMrekYHhgbqwJ~Naf z{-ZAIa4v#gE{Rqub0GQ51(8_6kx9l7h;;jkvWL25!LOPXzA0nvH+xQups&r4G7KoQ zG0GI>S&|wrCWCE}j$!FdPT7L=pWy3>Y9BipA24=;63QUQr4U6~{6cjL$Abn2;>?#g z2C7F$`(c&LJr*Kv_@g@VqzEY!-Y*S5#m~bCgGhC>xMiZlV=b<0S3OD8HR0~s<1P6G z8J?;&`>_-UsQhtzA~mv4`UqQ$_XC{tj92!(xW0ZI|B&wJcTAK{aRytx7*IwEuxLRYrGQ;fWHk!%8C z$N_FBx|>CJ=eHa3x#SuW*_TTEmt6}XrZVO01z+;fXT*d32!3(V`VTCMPka!kAr}kT zg7$cz0!H0(&{f0gBe~|L7nRIzhJ!d8 zA@O$xdZ_c>2yC%yN{}H|ExrNHw+yr;t;CZ1b1#>S6b~Ohum%0BxvycXeruj`uhcdI za(ZMVvSOhKyc+@|=G44;Q#gmN`eES3KcpM}e@NX?D}9)@yXV#OQj${@&%1sv$qxu= zn^xjo{JTby@kkm;&q9*7N-tWw+Ie5C@H7|^r#9XMVn;oV%}b0wRcbPS^G|#n3$#@D zhg9a9)C#F?;X8&%o-+uz@Tf4N!lC#Q5r}RwFjJjQZ_t_#qK8DnGh6jm@Y3cXBK2#k zE9zrh6|9}++%~ff369PQ(pw}$ZS0!k=$yIh*jk;4*ppO7E-oVihg^Vqv?csZQdt;F zd;U(_Gffk>)_X&LW?WI)YrqBHRfh)@2Z}n2wXR2F>qb<`Da8J_(u$eMx)X5$$*$R4BokKd+Vkm)o76I7bp9553mm8lkSbKW&H+ zXpwIiR>;zn|3iA-D%^Y;2xO3yqieeW4TjKt$xAqF$g`Vei(veI)N~ra3WXVzViGJGhA^4x?CF=SO({km5(> zlCprdMRzF83Y-FI%)i z8-}~h)es^zf91nTMR8Y#y!1^oek@lx^1HvRYcY*SJ5T^$YyH{Nd``KHl)f`f)K>Oo zFBUM`n%A}=dV(2?!8~oIrc1eV2XCV#>oXs3g!dOt8A~qJ-5R`JiDcU@ryU384VUF1 zu-6g>rRNFa=-P8SP98#%91dJ3AuOlj<52`t`?P$jC*dP{ddY0s{rZsz<>N<2xk4sY zSt%rRdd+GJleJ8qw+d#649HaC$Et;+q3-5zd%Kp@yxqHp5*`F`0>?OpWjUitca_+f zPh(Pm>kLIYbvbm*=wm9O{HX=V_;p=7bhh!!2D-aU+R~`zDVh{vH8BY+SUFa#JDgqI zd@&au;;jWds1o1mcGL2^$`_w=X+M7tmfA zsjARCCn?_8f=7`4LvrmlVi$vBUsILS#1E6w=r**$S#WRFXslkhMUtWiaP+0UB1-ps zM>&VJ>nShmKUyce-j_NT*l6C-RW3rx5)JogonqqN-mtox@UK=7##Vq$SCko0s600F z&qGHaT|&aT0$0SpvyDvfWc&J$Yl0A3QQ}5&2c?<6+U9Sx*7doHdny9AHb|D^g>%H# zGi48*m|1JqC|iC={X_b-TkdCC8zV5KQt1A}-q5^VCuAMhSfqBtu$=|a?dxO`QI5~M zzbHIm%RmPe-)BAaNaE$gGqTa<@>BBC?kbJv72!W9NQPsSxz#G;r&KmxJwy>55`l^O8 zo8Srhgm${8UJZc{7S{TX)3jS|tY!z>v>X$pihsM}Rw>Y?Y8W?9SV=t>A7d2_{ZhH& zsL;W{`gTx~Hb39<#_$^+)}#8zP+t0JwM+mVa*AJxaLU&u!_gH(zrcEatZ+c@y@2EF z8;$B8UaH7p+}|*{*gJ5Y-z1oep!sPmRhOavabaK-tk5P3OJ}`C>Ele}2M+dykg-d3 z*xr5OUO)ATEOgwmt!VR65RF+_7k~BESG+tm{kU333%tucg9FkPe~2FZR4L;AX~KvE z=tb#}eHGfeU3q;e{f_@1LcGWz!IL))Xgfa3;9`D#<8yhyehH0(|pR3S+b z!m<-N+}gIohSK6#c*Xj(oO(oNp*^c7Qkpu`a++umuI|>d8|Q_PrHL~g4eMwj>d03R zToT(~kQ*!QN{DsXvZBqUDOl`5=SAciG=A+5*K!)#K9}Bq$LK^*{1-iy5-E_g{-mvr zJhJWmRiw#VrN%*^y|k@)-?-iNDsuty!bGbL@&rI$#R96L_XId#-t4aH_z4U+iDvwR zYv80^*uJhP^n0M2g;?6V_g6F+QwB5BnuZW%l>FaZt<H12I!}tV+%Q&aLC4^y zg>a?eN}~c_?-qAhsX-kBRICETk{XQhSvV#fg`=AwSQnLIHy(4_(NtON+5;s|f&&sCeEU5+FGl6UOI6M?Up%bNz`1w*S8@3A<5O>T$e(w1 z--wW&1dSzbB=}>$j9rN#0S_c}nar zu^Yv>>~G?wLLLjaZ4zfkI5@KtWcyt9)Pc}K|Fwxf5pp>G-`Gq4b62M^^4vm4j>e1G ztpaTswo>4+!OMR=6Nk+OeU{|H=ma^Rv+RAlw#@taWUhHmOV>I787&3)oYJ59oMcu8 z&}6ZLJ{8|6%EwozNVfRfBf?&V2zxpj!oSc!p347%2-ibSfus7^zu+oH|0dJwbDd6; zs61S0vCDckH{-%tkAHuM7{`C!qTK-`{udFCKlscAsmc<0QDc8dIf6WD_VBAkIL{Qa z9|!KOF**S@{{c+=4GX_nT($UrA(2#~Dt?~*ifH%JJ=nnf+&c4ebb*?htd8&8{Xdw@ zPM^7779r%6w_`8j75R4Ge27GvOx$p|mxYJwAx%~WPH4PnJd~C(|AgK(UQ`a?$YlQa zD4ZF$E9ECE$^Q)SkuV>x6C)q#Sc1{7dX7eG12kvN-1z$uXevZmPk(`D72m*XR@S^p2}HqXJG-49Ym z7!rnc{qfJq@B(D=?^iMRW-HXxh%o;T8fT#FyUu%!{{}7jcKQ+7J>35lU!tI=EMf?8 zTHh#x0t@31J;Ha>p8hYyXW%SJ+ZWf+Yva6`7v9Q)Yn;5GGBP7!K8MjQ{OVU77-`?< zobdO8EIk}oJ%%3ho=VaFfA=Erl=}Zb=!%ET)P0~Unf{#orC{`_yWrO6O%2dFo$7_R-53rKoiei82%S{Ld^d){pJ4*|HD&>I8WZ0dXn*ucW}fQ zof>UafF`XO6|o+xs?bvsF{H=_lg7i-YE43Vo*-lX%Qh+QM9H#SnxB-YoqK}@wKzJ1 zw)y4oj3d8*aVpA(pd!6*yl(eQj6C9mUJ>8KGc5HpflOk9-+dM5cPwIjBTlMiUdqYMAL>MOY+;ZS-CH} zv)gEE&W#q7OZ}pLO8;!FUv!qWmhqA88qf4^8~j$>p>_xMJ_eiW`7~qQYynee6SKY= zXYA5&<6YUoYWt5il72nz(P=!162W}q7%FFM1_mY?#Ye!StTgelUs^yk!1P%#Lj0R) zpz6Um(@{GnP+aXV?Go{ZH}H5hh(+`)G~-4xzZHGHmSF3APeVw2hNxHF$>O2S_St8j zIC-A-IhDduVnfY!#EmO!p{csFHvU^{(d*Ys@=P2`|rv~d}=2-<42ooLNX^C8lb0)!+=LZ8UJQJeW*TLvB{Tv7H!t6@TX-}A@hT7xZ$8ajN%!K-v1wvSLZpplDr|*n z`14z#T~1*Tu?!WTfR<==EGN^{N^rnC3h>0q`WW>iJ~4jHwhSaQ4~UUL?&g-z%^Suf zb_{MvOQaf^z?|s%0h-JC_TDJ{Cd2N;MhKLFD4@d^$I5oiT~w`uQDMvUvF5O}D5_Vq zyr`wET`Bi308IEeYcs$-L=+6gf9Vs4(kr8@!)~Ovq9aI`i!q#JH-oC*jf>I}*A1AM zUu*Gp5qw}IywtMDre_lhUXv?Gs=lu0+(sBa7wM_kWNC1J%7Ce<4TUb;vIe-f)rz@G#@C7icr(Fn*&m9)M zE^E2$&E+{Nth3K#0y|dw&ZBGkilCP5%qv5x-pJbl-1Fa}lNBhdHmm{6$JFqXc6~P4 zXs%afC>s!4{IXjhZ zU#+W9oV-V0bc-8BSYjPB!b7P(V(M;6S$Ql2#6 zIGVXy4fP8a^**EBc*gGDM!*6p|0?!klNZt}^_OSDv4VF3F*bUw5_LL8BigsRRd!^9 zO=-O81U$#bq@Uvr%3>F86E-G-er_h%mI* zTV~i~KMgQ+$hmY4ZW?fPX_xrcnV1ty{mn0d{UN1SuY+#@v+tQ%a#VTjF#}p^QGUe# zk&feY*H+>a)f(|lXfiZS-1=G>N}s{gFbnSkRA(4JdkR0%WAnC?tmphZ=^2U>N=?!z z!A!`$K(kn@JDl2mBoT4IZ&&fL_3YH0ElbLeq8I;^HkON}hP*b1H?I|iiM*uKOL;_M z!W;_ptwH5k( zKB=sZg_0Dj$~ucv za#Eqp_dyhtrak1SLPSu4xxDikMVllvKT;z=L++fU5Ezge`>M_or1EPny2v=(L3x%J z{G)4?h;%;*v0s#P9UDi@ySN4=JFPPC^{p<~<5}S{fF#K6urT_X8)%xS4#z17M=UH= zW#q^54)+eZ&K@TArL^T1=E?#Lr3dI(<3>ET+Y>;7i7P}p$}|;D8#jtJL_;Zdg*dE^ zP`R(KQ@rT#Bakpnj0ooyGzs*3RP-~^GG(jh&vCqDiiH^`hld+0+X}`<=-7Y*x?aqT z*jDF?UyXh&R*|yVw+dhx_LRuacZIu!Dq&-guOfFSWHQh%veDk@@sG9`ikE!7VoZLR z3o4{hzBS+<0ql|BL`qPn8ocD;kZ^q^b4B4%Bs^)c^Bt#|S!bZVQ4RFhk9`x(ZvQxg z&{a06T2T%(Y0r1%YQq^Z!N7QU8VF6qip)I`_D0Atj%(p32YNU$Lap|Q+adx%U^ zBiOBSaM?)p>h&7ZmLmDuD8cXfb4#zZUZ!djLw7YVsDNrE3^>VSlxc*?<}ci_r^%Qw zeGm#ufS+B5kb)O>N~EViEhmrlp*JWSq|AnH3iZ%{Q1b)gl(fXoAKz}qgkv=#Uj>Ia z&^3*5qi!wsYt!c>=bqwOnIJItxB|?;Z6C6WGhmU`5gn5`26h`YP!x4 z^$)9mv!Ypq+Gk)MSC?9b(XtkHktB2RT$X6`Qoq0a8E^`pZ^ih6`^chq(hKCm(LI1J z%`fj8S~7#TQ^b@ogu)&V%slQ??CZ3gy8AL^3O(yrI(?P~Ny==fzWEaiOK^qqBM=v#IEzYw9d+EAyBaEebetoe(sKT=;wi_milU#Hj`yK& zyGAqNNAwS(TscXfja|AjI(vRIa2Z$L5t3p=ask*qpMhPF*fTFFFD|xm;CJnaWpzL& zNVqu^Zd3Gz$lX23AndLIjZ+r-eux_EBKo;Ayw+>n zU!tE2U<_{Yjr(CT#QC*&->a#UF$7gjVP<=B!rf8uiXD%CTPA5+O(tZWz@M(Xe5C@` z&BwiMU|yD9=OBcIWSP~#qFn2Onpb4s`-#rwU7wcF`rO0R0=sP5?Z8TOb~0ct)a-SY zygr8T5%J`nf~kzoN>wI!xEsiBlt9JAhQgTon?CCxK{-R@R#za)$_m-RzJ!Bo9%TeF zU_+?qOlyg+&e@f-$H-K1JT5(uoh}2Oxgn+AdM@qJUZI%cx4Ry%u(V(A*_QF7XV*C2 zquVk=6$JeDyA;@l4+bV!T+eS*+k2xs`?+Xw$$ct(#V~&0C#c)l2?E}iWG)6It-y3l zy_EF#1~?v84*2BU-74cAf5}lT@Mr~F3aV`BGkNhJw1sEKde6Up(pen42uUV!r5Rhl zoX6-;{f9KWaGxBzIrenL#RO<{;k#RT8jIoqfFZeuI{%P<`+Ed!(Ox>=>19g1LK1ae zU5Egj!+nfn}b_nWqa;KGX_W60(esp;Z9qsp?5srfktfEHB`oBx2Z zRJJ1L-J}ENK_g(9wxv>DrD7^+l?aa;)r~{Gc=l-A`vmt5xa(=|LBU5}cv|1x@K)qp zcv?JAmGMg?Gv*I*B=0QiRU$P#`#Pm!W2mXy!W|a1iqePEzuyaIjna}~WNBedv*H&l zJqb5&p@b_->N*n))bm59g(U%!!gfd00z(`b&;l9%12vL|ek_~KhZ4U0CG3&ihqWRw zZN8cd^10vQI6-NS-UUTW)qZ@^So!Fc0rb=fpOEiP2Q9(N7Bs7XddA9A zg6P;l6u|kw(MF37wrI!VDo!=cCoLt%g40REfPg}~@uPObLCsa7B+&>ZS~+d0t4SSe zd&vC{plZ`Uq^r_O4aEg5fiJ=O_U(~B4)7cr7Bd-JVo{U3FlO+vd}D#mV&i{~NkEx4 z@ZJ?Qi?R)~@t!LB;bJWoWR{=wbINn#I;CM#Tn(LJ6>e!lP?U?I!70iTPLGIP_JZ{X zvizucK)%iDkqzy7818R5VELVuCTA;)(G@e8XVPyQoSVw$24Zw)toY1*DL)9Ai4Yml zd)8c}94zMVr1U{66wS;RSGpPEU7x{$Jrt-+4Y-I+`0Rr<+-iL%)o_ZHVd@-VNFNw? zY(oUpwZ5*#wf?N3Fez4VUt`b8{!GePQzQcxzw90t#e^jIZG41w#yRk%^J-ojj>0k@6>@Ua%rc{4{X#?0ia(WozJ!d^%E|5q$|fCHyt~Y74>`zC+^TuS5{sv8)Fms-r*UPi|H#gT=8iAGczQI< z&2K$_JDV0ACc4H6(h4u}-~I9>MS_^XbbERIxK54cHm2iC_-TNTW3$Qoe2G8X{J+XC zLqSS}Y?EusR;VO}VX`7yxMK~Md0-IN-1ntOoZWSeYL6yPe~gJeh~8Q#V2w@kHcj3TFp+WCP!A!!BU@hoVH{h>7P=~at(S50akjl%;@LM&o)Z7b*o z^it{fTJnbF`B##$Y`N^;^FQhrJysK_1&wLW&ec0HEZgOWZqq+0e-urGSA@l6%ZzG3 zlajBqcD&Qm9Pw34zxvzG8rS?;@}qAKoh-_dND2?9&3e0Im%Dx-d>&WMjRUn6HSm4^ z#P7~9bl`H^e7t4uJoSD&ng3Ds4Z)IaLxMLRQC^;9!Ec5l<99AIE_O_tr_&lWU^xH% zgAW9%Bm_Okf=Y!-p+ZQ6>_%qeCGM>4oS4_SZY4~TC#DLFKM5ZbpJN&usn|X3wpMtk z+_vq&Ff+4VQmO<}8=mk>_)f|3tg4S>s~f3=FSgF{Xn%ZQ{$(M|6$;Pq=kYyS7?Mf# z#GTS+%*(KIwn<`>MauJ5!gaWj2x+u=fhZ7km*n` zmdL|Ti}cSzM@*Dx*}RlKe4tGJ^$m5MSd{u6YqL15~zyoe4S!G@aVy&jRo+SCF0 zU+}w*O~D1z!H)ey&v6}~_MQ@6Qwctog7jGQP;iyb!r!!ZY~;Ok$bES7I?>9*FtE&y z6qh0AaD%Qgq7=1io-Bj-`saOpXIVIILZmAOYT6|uOAqc_p+olXeOQrS4HI5c>`3Rl zK@$R^e85!g2}dy{nK1?ng$pTX8V=9WXlm-(Svy0qlMSM#!M)d@oV6ciAMcli2I!)? zjIER?DqersjiJrR^s>(@r*wntHZ6&yA)(nXTb}l4oYIKt+CC?O+90}hi?-98PUFy{ zXvK^p!zK{vDxP_k3u;QvgS*4#gZHNrnZ_X;J@(nX(Y)#fyR+MH#?%7>5qxcOI8{J7BgcumbAw5+@NSd1(vwCaF>J?gFX|xz}N}B1Og^>yO&( zWOY8hTeuF-<+X;*<~%se!@y}*W8nE#hE_cvHgeQ2q}%5|1K!Qi5LV^n+!n(0s_SOT z_BSjnTo+R=*D6OVYC#XJ^y z+RgJEc9+$rw02n0Y=c@GLT-&^*^HbS*|2F;_J?UoXV3`=YBPfE1H)ViRUs2z;WG9O z5rgHda~`*Zyffc^EMJX6g(HIqud5!tW$c@7#VukjIMa(2++5SzndhubKZ9kIen}9T zwjL}W7%v>D_j))rIOf2uld7_<7d2+3_41Ax{bofjspAZub(;@ZJcwEqb5fmdDjPu> zxaxG5(|uvPq)bBa;yg^Xc4@aan&oFWLts{I`^{=YPqt10tZ2iY+J2jNgUxxI!-%?E z^x?NY$NU*Zx*jR@r?R>b(bYt{r=H;r!Cuwz6v6>3+I9 z@wS}jrp|sjlc7&KbkSG|Ocw5r#&}J4H)x!eLor>M(xnbv;#?6b%BXTvli+pH?lH%r zNvXx$4i{9oZa!=~CuDb(Q{7O5Dq_&v?YT=p1CnV`O@CQw>FTgs8$CeOzy*bM{d-y* zttm~j_yFQ|Q>(%pcF2eue8DVIIDpArzFfZq*cSe-oXHyH$dE+WPq8HaP-l1=cMAS0uROIEw_z%VWTDRx%YX@|3}qZhPAmwYs0kA zBE{W{L!h{8aS0L#5S-%f!L7KvJH-iJ+)Ht%NT7IecZbsb(Y?=kuk-y%e$2|uTKB!4 zE6>cVbvHHy4rVCYU_cpNQ(aZh_}k9uQAl@$@4MV=+Qg#?v}*_{j%TgGEW^`qYA zbrWe^o|7%9APmkK3aoMi$YM^>+;+YlS9odEEQdy2o;cRBQ4-2|G~==Fx@PoUn60}* z8k?d_CKP918LcytYHaPHJ=$9wV1>*x=Hm) z=j`df!XPDYf(E#97R-L?jHdX^6|E_Z1jsif)Vl{Klg|Ew(D*qQ=Zy5KOf>vq8vWy< zN{d^akc6vPE9VRnD{WAaXTT&Aw?_JjvF!Cl3h@iMvGB_F8Pm zC}y9)FtI!M3x0}BSEwgQ2dbL8N=z21$m?neKPX5TOQA$r{V(5&9hFO4$icEWM zQgZ>X1uX3WKvS{(D+X4xwwD>LApCX9=|gW z%8%S+?EXnPqM(VaZlk3_`Bqc@AvQtaXRD3Zo1sVA zV7~eqU$t1SdR!NN#kLhT;TD$B^@hC{mA=s}z3yW|CGPbT6z-R(z4o?^_IT8UA5J#R@IZ!>+gM_hzOg;U*5NTHXpX-aON+-k@0D;XPPIkuRLjvl;4yv_ z(G?yXD>R&QRkux9^v+2lBu5{o&7_tp80y1!j2i<_)X}l(YJQU`o7vl?aJy4#kYHvl zM&OQDh_1MsdZ!da)0d&2U8wO>j8T}W!t}HDrJqH6&#RgW1&_E`CM(O8@mo2MMDF}j z9*fqC88;&~cZ>$9_==G;Bw05_95U*D9QzTKDh9vUB+UK z^aJBj3Ywp+nM!BU?=b+l4qyl>o{NN}T_(iS**AZuyQH&3oPlC?&;$*%*^Xq@ zfY0TGO5(G;;`TD`ybDu=wo@?`>=u*>{joN9WW`jY6XfM7`eRS~xX~umPMfD1Zjqm&D;aF#{wb5uK2Cxo)JgFx z$ljoEM090{uHRgqg)GxLEijSS;kL(~lP zfDai-0x zc(rI@FjcWq1`yjWB_IUMKk`@*vH22~y>ogV2-y>Jf7lXQ? zA zF5LkLv~G&294Xa#cCbw+|4AW1GF8gzm&pTrGFV&j%S8P?6%q0dk^$2}Z)0U=n86uc z#yz1&m8*PBcoNp<+SZd8m^{0H*@(WNIYozF7POGil2uu-yCPD#L-(H8SaI=SHp_Q{ z4`{GhYSET*XA@E`pt(T%jgua{i{AcSVV$Fin|VO{4Xf~728I!>@-)di1Iu?!kWq_d zd4<*%Cig9kyEVq#A8`f|B-c0Rk;$MBBtE)X8hRjy>Jf?`$2M7+20ci!+_PQ5+GJSL z#1`rv_$@#)652D8cH=?HOwKPR7snffMJ~F?G@44_dOg4i*9l#0PgiqJdAWgHiE)E3X$WP3_Au*R`P1wkK%7ho7$Q)FC&NWv6r~&H{)K* zdN9y4Gt?~JrlzfemfgwK#VMsfuzZ$_d2J|;K-Dvtqz{=dfaEk*AzQ~+J$OeND`O-r zf3~agOl*|kJ1x@OAJ2;AvIj$t@kWb%>0S#D{tRWAzj3)xgd;P(WdoHw{nOgMPEN0TV}NOIK&gmC}|8#375M)xL#D2(NQO^hZjoFHnQ;o`w+a zXP@`&<;;*znJY6V&Fdkp;kW4qPT$bG;bl0bT$y&n&kRgk)&`S937k|aE&87|+Lyd$ zE)VLc7-F*h`NT$+oJp4N?L+I*@k!N2WZgM~yv5_ViD~YL6eqG(6bV>7`_D*BIXI}V zgDVoe>S@{f1w=LyDHX;YIM%HrDp<5edcxkK}Pn{q|#FYv*mqlDVz?o=8EDDpt+=KIIBFuRn_6uDz$_Nh>LduaQ5MBDoR_ z;kpe;rL|e{qA6`6CN)&C8T98}mt*^9Y;;#!Z=WU|64?BF(R+1~xuI@idI8aQ086{y z!&=m&)On#d>ZW;xh@gQj*mG7Doc0~{aLT1#(*%Fe^NQc89ZTx#Mu=uRAU2cX zc_{FAid1P9RJ2MTSAJQ;I@8@UH`Cb|iYA%`G^7iTNomblDoGF$p_h-?GCsW zt!wS3KiJc}C@!`@mcvKsejF|VK3ycPn0nPv^HP&c9B6+$k>$))s$xTkU4VpT;|5-e z#aV=PrrWT$f7Mh$aO0{86RFuLR(=qcNDHoQ@0Ld2pauMNa1S>OKmX)q9as9&Vl5XO zcbyXaTp3|(%R-A}Wae#B&fBk2j4Rh6G_)hjk+twFqop_6ki5ZH&L{&Qk$A-h^fkT^ zjmvD4r&{csy^7`6^NH}uR0gmikSOd3uJCcuO!^RcD_!BfgCbNAD@5{6SpsytR#AUXW@VmwR`H=etr~w{FvR)T;39X4!cdAEG@gV z07=Mf&XA}}acxFn%lgaVZrGg>FsV`-3$=AY>{Iy?aW zD(kJzU1={y+hb>6j$)= z@+OJUNSV4&Wf`2z2HYiV_lUkeQNwf6y+%&rvx(|0B-^2Su(%0JFR{#05+FFTSmPRJ_Y8|&BfaOe4WCf$aKvA+sbc#!m1(XR_QYnX818#E z|KX>Kk*TkTgnm&yf~Q;5dM!CQceU=atV8OKL)Le9-hP#&pfG?_Q=)Y+p8 zAJb=8yknX(DyZM6BSD$%>CS?0VG4)d{VaY8hD+=wAnWHvSw9~BTyAs?Gjd&A3eg>@ z3GL;z%J)33^%kpT6Y!ElM}_(wNgXu%%v3sPGeJy%Phh{@6nMub$wJq~09K06QMx9s z3+ge+vJ)BKlmBGmdRl+b>Y-%P2iqtzr&Y55>fW9nzl+=#wD&g9b~0{10x#(nZc}8a zD`@Z{A7pNr5SY(n$>F`)&zETP?2xaS)yRA@R{{~1E6KG_Rciidt8;#RgX3MwLk6;- zRIh9(Kh%u_#$@q76fJ?IKM3-gSIWi(?9vj9|}T)=BS1T;<;S_jpIfGr2bOodOh7Cr~$`#(43Uk#~f@z2$G zuIaPtgPa8{U=TGzQ1b_tV|n@X;{edd?7Uo)-^=bzY(Gj{Kt;1V2le);fDej%*Z~S7 zO9VZrNH;OijCha&g&;JuqY0AnO`iE8<4c$y8Jo&V@6Lg*_R9o_ zHlrT^BQYgRnI2!%*4G$5uwy=Q0t$t^80G8%1`59@^D+@V3~4_HxZ(hC0$XO0xS(j} z+_V`JKFQbTa+>i|>R|(+gFV9;4)#8+SMKfQPx?*Pxg=j192gH8czO7|UK3N=DC|T0 z;bIv@OHdO-eIwg<{IL_vZF&|4*5ElXc)=n$rGDOfuX9Mo7T=5cMD&3)q>?+yu`F@^ zy*QXK|23BOWUgXr@dz(~k!`N3QUOJZm5v@&ejkBMk{Mv2dTw~8;j~-4LHyg3l`gg{ z#65h)E#!E%D_rab;{qz5oxicRpIpY#0*Q1@;hAv(zO-cwJj4Nex@>Mn7ca?(+JV4( z42<(vr;SfQd2wcWM8R{;86pzJSXfg)F_W@An*-Z?5o{pO{KE);^7$rN zUYgu6UFtlcNrT`xSRzr<5z%f?ek=2{`C1N>w8@1e)-1 zU#HY3&YmQ36LH9PiDj|?)1(_yI=GUC@2WWhLgH0%nqvA}sSjy!){aDDP5jgQ>8jC( zDeAWQWP%ZigVBb^aRkHRG{ochW_bxHBLPAEr>2v(x7>f9L-y+&PU}3D|0TcZno%2 z%BGKQk)oDao_)63p&tWh>8P+)CNPiQEZ^H3W!~#X!xEyQ0Cbn1n!x3X%Bh|Gl`b$+mlRXieUqJOnk(0W1PP*PZduGW&{GgoT$9^!x)kV|*ugX{R22+?8 z0n2_UXji3ZX09SvCuV~|!C~vP4jP|F&B|fLwh2z~-9xdH`LWPJ0}l0nj!l{?o=XY@ zf;hzKit{1P_%37WbGhO#3O9ldRw40f(agl#aoqSh2#OV%NyGHUo0`oVg=x1qNj_iz5p6kSf`}geO(?Y#y zN)&Y8?~t8J;h3$csPiN4)?en5dgWZb^|)9JNOt0~Q?bHCRo7EmM&hNV+%TYosSe&N zdB%&@X(Ozyjo3?r?&Pc7`_YoWR)M9%hIVjjrS4`12u=&zE|L9VS*l`{r7Jnk27y9$ zhsLQ-+g|k`pmuqJmHLXK1X|_sEAAfzl)5Y#YHbWSLnZE@GCSZ^SXvcKds6doMM}we zqR?HtC+4U=At$5HDO4k4n4^d{?K{PfwTiCYd}6m9UW~wYtCi$|59J=e28sB++Ur)AgqP$VM zEHR{(mZmL%bT;WiOl0kWs7?je0J1B(>wAyLWG%=Qsi|iA5WhtY2*l1ev>DL>Oet38 zkyEWIx2!OkA-gfR+-oScpPfAL7&gx_FBlDbzz&SZ_*3pUI@h1G85RC>1u=V0_WY(D zRt=|c1_)~RE`#D@h)zMN2N+Vn^yq2|F%-% z`zD32b@vM4?^wF4Rl@=Nddcq;-Wz8-qG}bf&c4J-G$pNyj~>9o3xOx>YbUY9Tjz&L{?f6%3+>3|y9!2Tkf zX}?yUpE8=9|LMjatP7hN~j+CnNURIHB_^>LK025rN1wc zU7^MCRrdpc*Tf+@mR?^ov{D2I(SiQautpudZj`rb&iTkY{~#1@>r0mx2caUl(g5xd zVdEu}wNKKuKeqJ&?x|dxdg7z=USMRfF*YL9!8Y18ZnaynER^s`l2{RX00DDQsyy68 zXlUNZf^-T$CYVk}T8s|Y6LAGR?LjBnr9VVzWRL+HE|Khw%(aa$o%BmRW=K$t#6A}) z5Zz$g?|dHrsA1Bv7ST1n_W7l?iOy{e=nUd79+M18CiCL{L?yM6%J>kTepqBtdI!qi z|1x3zqndUBpUvbOLt2G?t1?i59v1Ra3rzeGg%ebZWoo0t4K%1NPdZfu$faggCCxO( zor!jazTb}c*766Y)9#tduj&73wAZV@$XWqPN#}v34~edgvqn`IXpw8#o(qu2RAS6{ z5lCWYp&-L3fja(jyfa`dnl_!S^A?OBd6qVR(DzIAOu6MdmF`BLIFqo>Ic;9d&<(i8 zl*o@`@Z-Pz=-M1`1SK*b6L#%Pej;HdvHq+kKN>$o8SA{N=7^U01)OhnBdH4zxY_Pc zE+e=8xu7&kZ=13`O$k{okk|W!Iyj9+Ld8yExO^A%873vsp?Vyk5fzdVe8oXyF8`9#nzQSk+iEjcxO|)EQi5&9@0-& z4^fW=dP^gquB?aG$W2N4KD?Jm6cv6OJCCJT>Mnl){NG_k?>**}{*3)Gek?Hke+Dl9 z8W8H{dwThoU#a_)O_(8eYzsJk$CDZ#9IA4t_4S1gOB^~Io?xdZ30zK`P~acqIo~I`~HI< zoyK0l^#0`*RF;1^wV&G5Y4x{PpEQ`C^q9b)?F zs^MvQzCYFrcOkd+>Sq?+;(;e-Wos|%dy`hSM)|3NsBHW1vXW0PBk$J#`W}54_1Wzo z1deCb3P4ikbIigpPI^;P_}$A~;usbGE$ZR@$7#A3JQ=Ijudv|6Po0H+55EWeRO_t& z1A^#(06}jOJHLEZ+x<5=5?{JRN=+SqzXZYGvX4@ez1#|XnWXkaKYy+2cOc@@h{V6) zs)zgZjonD`^0%M8O!CxK82H5g0``~KXX&T2hw#Hts8Y(uzfd4I{RblD{|^$sH8teZ zZ!a_nJiU$h>j>v34Hh3Az+J=3`u`^c!DH%YSnP20xwV6HyK%x83&@oJ2eL?-&0*BUsSPN+r~RM)GVy<@i>_{teEJs^o7(@wME_H!FOK~A zFKgnsbCZ7m(oHABzg!D{QR&a1?KTRx@_$@JAow@B|AY9Q*UP}6VXc?owga9Q{&J+H zss2CQ@JA%R{bd)SZl3Ai*#1Yw)YHw_7tFEX2!_|jPqhzahoO$PJD^kdZt)%{WpmaKDkP?q@LWP{SDNC z+Eea}#Q>LT?9RU#_-~|YLBlUl(5_Apoz=cTfvN?^$6EaK7m6r5*z(A~{HPE3rTF4C zdXwS5EG+x$MB>1M>^{Tim-M^7gUa|6jm;z7_i2{q93sn+N{; z95I9|-K}^9yi6^ zV`&c2G?}N7@4rZiZ8wGTcquje=8L(Yu8L*UR++13x=2tgv5+#(G(C~89z?qEFw$kE zeQXg4`AvcY^QH%uYbMQz4Keq*3?p`A@g0FsJ+^bSIu#G5hDcUVOY^}MZ(<&FGo6v) zqo;peY~V78skhx$eK{_*uq`}cNj}Sc2C$QW>P*PfkeMSG5Wb3}M<;HHKcm?Ek3aGk# zra3oyH5<>bJ;@mte9T`FwdBUb3aaJ+xQ&S#^CvIxXTFNbAk5Hxy_k0dH@zlVW z-<(o4(b7N3Wf+v_LhC-ENr>VnQ_#-ozVqPi{@rr&C7etQjVz-0x3b=8msWv(goJc| zR?^(a1;Q4Gos$qH;McN_lDD4*ehZFL=7@Ar&I!|V!@KiqMBFC9E2*lIH{zpWUfHVq zSRr!mtIUwvmv{I6EA*G!(IQ=PFDLHWkqo6{wgq0o6)1WNA~kWFd{Yx7$eL)niGZyF zUBbo@Le>1sCZ3cS7G&(TcKa+0s|tromJhp5sqOnDLp8*ZH}?}x?X%p<3d2b8F{97qSAXs4^Q^~gPI}vj`3il|P4-1jQ#8Mj6k(-?xz(~_D z9e4PifhTeHJuCzUVl!V)?8$|S`D{LI@lL%iM58`j?hsQQqG9J zFP^2m-8Hc(5(T5~*pIXguqSxrxQ+v%>J-7!X20xF*~Zz*OJcu@d@a3I_THD@WuQ8d z&H`DfCZPkU-&45-}`xBUB~~^75;g@$L0}75O2X z)@?Jyl>s&h{hXqI&rY&58zWMj&$&~{T1G<6DPRRjRn&Fqy{KuLQu~aSn5Mc~Tv>ST z|MSk%!MLGRNU0;Mn}s{jUp|M~P*30_h5v!J`-A%HOkjqE&95BA_yR#2u)ZbQ#dY4< ztMfrU?3|8!yY;A_QOz<$>LjzV-6J@-Zo-F6Cs5Y*xnx>yM_Lis`>qm0F^0dwV{Pul zY01W=^gmvUqb+argpKkdjd5?Mc>CB<>Egy*mh6CJip+9j34xaQ3V*)G zSF3-UuaT-Js(5l3jkHr{y(5?`kD2hL!nF?#B~yje@Id>n;yM_69mgS9wTc$TI(M+5#XxVkf})f18OgGjES5bX zLMs)TOBGDY7`pyS>r)^XdD*J8$48cHK_cBjW448a62C(HFQTF}N#~eVv+P(`m-$kO z9>?^E1_ICfFOo>2;eiV+15SCVcuO`PeP1VjnVm;NCzrjpYa``#y_Rv_?2Alu_0P+q zpqQ)K&2`LRLXU*RFX!X>gn$+>EDYBRKy$k(IP46)>&|kgU@rfNt+(Ze8mA?nc%!47 z%@uQfVRc1kq)3;Hn^C^t>bVPZ%f_8qA=Kπ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
    >s%K?T=PlnN0>R$DO0po=Hq>MW2QZ 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
    ;$kN-G+Q05cMz(bqkwEV0&Uv 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 literal 0 HcmV?d00001 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 %} + + +
    +
    +
    🎧
    +

    會員資料查詢

    + {% 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
    +
    +
    客服測試入口
    +
    目前用戶:{{ username }}
    +
    +
    返回首頁 +