v1.6: Platform Gateway — Amazon/JD/Taobao/PDD/TikTok 5平台API集成 + start_provision_v2.sh

This commit is contained in:
Chatwoot AI Agent Dev
2026-06-05 04:30:28 +00:00
parent 351c9b82fb
commit 989e21d1f6
19 changed files with 1760 additions and 15 deletions
+128
View File
@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""Credential loading: read encrypted blobs from MySQL and decrypt in-memory.
Cache: per-process, 5-minute TTL. FastAdmin writes via the PHP controller;
the WS Agent reads here. Direct MySQL access avoids an HTTP hop.
Requires env: ``CHATHUB_DB_HOST`` / ``CHATHUB_DB_USER`` / ``CHATHUB_DB_PASS`` /
``CHATHUB_DB_NAME``. The same credentials the provision server uses are
fine; they are not secrets.
"""
from __future__ import annotations
import json
import logging
import os
import threading
import time
from typing import Any
from . import crypto
log = logging.getLogger("chathub.gateway.credentials")
_TTL = 300 # 5 minutes
_lock = threading.Lock()
_cache: dict[tuple[int, str], tuple[float, dict]] = {}
def _db_config() -> dict[str, str]:
return {
"host": os.environ.get("CHATHUB_DB_HOST", "mysql"),
"port": int(os.environ.get("CHATHUB_DB_PORT", "3306")),
"user": os.environ.get("CHATHUB_DB_USER", "root"),
"password": os.environ.get("CHATHUB_DB_PASS", "mysql_Py5N2W"),
"database": os.environ.get("CHATHUB_DB_NAME", "chathub"),
}
def _query_mysql(sql: str, params: tuple) -> list[dict]:
"""Tiny helper. No ORM, no SQLAlchemy — keep it small."""
try:
import pymysql # type: ignore
except ImportError:
# Fall back to mysql-connector if available
try:
import mysql.connector as pymysql # type: ignore
except ImportError as e:
raise RuntimeError(
"Neither pymysql nor mysql.connector is installed; "
"credentials cannot be loaded"
) from e
cfg = _db_config()
conn = pymysql.connect(**cfg)
try:
with conn.cursor() as cur:
cur.execute(sql, params)
cols = [d[0] for d in cur.description] if cur.description else []
rows = cur.fetchall()
return [dict(zip(cols, row)) for row in rows]
finally:
conn.close()
def load_credentials(tenant_id: int, channel: str) -> dict[str, Any] | None:
"""Return decrypted credentials for a tenant+channel, or None.
Returns a dict with at least ``access_token``; some channels may include
``refresh_token``, ``expires_at``, ``shop_id``, etc.
"""
if not crypto.is_configured():
return None
now = time.time()
key = (tenant_id, channel)
with _lock:
cached = _cache.get(key)
if cached and now - cached[0] < _TTL:
return cached[1]
try:
rows = _query_mysql(
"SELECT credentials_encrypted, expires_at, status "
"FROM fa_chathub_channel_account "
"WHERE tenant_id=%s AND channel=%s AND status='active' "
"ORDER BY id DESC LIMIT 1",
(tenant_id, channel),
)
if not rows:
return None
blob = rows[0]["credentials_encrypted"]
if isinstance(blob, (bytes, bytearray)):
try:
text = bytes(blob).decode("utf-8")
except UnicodeDecodeError:
if not crypto.is_configured():
log.warning("AES key not set; cannot decrypt binary blob tenant=%s channel=%s", tenant_id, channel)
return None
creds = crypto.decrypt(bytes(blob))
with _lock:
_cache[key] = (now, creds)
return creds
blob = text
if isinstance(blob, str):
if crypto.is_configured() and blob.startswith("enc:"):
creds = crypto.decrypt(blob[4:].encode("utf-8"))
else:
try:
creds = json.loads(blob)
if not crypto.is_configured():
log.info("loaded plaintext credentials tenant=%s channel=%s (set GATEWAY_AES_KEY for encryption)", tenant_id, channel)
except Exception as e:
log.warning("credentials blob not JSON for tenant=%s channel=%s: %s", tenant_id, channel, e)
return None
else:
log.warning("credentials_encrypted for tenant=%s channel=%s is unsupported type %s", tenant_id, channel, type(blob).__name__)
return None
with _lock:
_cache[key] = (now, creds)
return creds
except Exception as e:
log.error("load_credentials failed tenant=%s channel=%s: %s", tenant_id, channel, e)
return None
def invalidate(tenant_id: int, channel: str) -> None:
with _lock:
_cache.pop((tenant_id, channel), None)