ForcePilot/backend/package/yuxi/channel/extensions/taobao/webhook.py

128 lines
4.5 KiB
Python
Raw Normal View History

import json
import logging
import time
from collections import defaultdict
from fastapi import APIRouter, Request, Response
from yuxi.channel.extensions.taobao.config import TaobaoConfigAdapter
from yuxi.channel.extensions.taobao.crypto import decrypt_message, verify_top_sign
from yuxi.channel.extensions.taobao.gateway import get_current_gateway
from yuxi.channel.extensions.taobao.message import parse_to_unified_message
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/webhook/taobao", tags=["taobao"])
_config_adapter = TaobaoConfigAdapter()
_dedupe_sets: dict[str, dict[str, float]] = defaultdict(dict)
DEDUPE_TTL_SECONDS = 300
def _get_config(account_id: str = "default"):
config = _config_adapter.resolve_account(account_id)
return _config_adapter.build_account(account_id, config)
def _dedupe_check(account_id: str, msg_id: str) -> bool:
now = time.monotonic()
dedupe = _dedupe_sets[account_id]
if msg_id in dedupe and now - dedupe[msg_id] < DEDUPE_TTL_SECONDS:
return True
dedupe[msg_id] = now
cutoff = now - DEDUPE_TTL_SECONDS
_dedupe_sets[account_id] = {k: v for k, v in dedupe.items() if v >= cutoff}
return False
@router.get("/callback")
async def verify_callback_default(request: Request):
return await _verify_callback(request, "default")
@router.get("/account/{account_id}/callback")
async def verify_callback_account(request: Request, account_id: str):
return await _verify_callback(request, account_id)
async def _verify_callback(request: Request, account_id: str):
config = _get_config(account_id)
if not config.is_configured():
return Response(status_code=503, content="not configured")
params = dict(request.query_params)
sign = params.pop("sign", "")
computed_ok = verify_top_sign(params, config.app_secret, sign, config.sign_method)
if computed_ok:
return Response(content="success")
return Response(status_code=403, content="sign error")
@router.post("/callback")
async def handle_callback_default(request: Request):
return await _handle_callback(request, "default")
@router.post("/account/{account_id}/callback")
async def handle_callback_account(request: Request, account_id: str):
return await _handle_callback(request, account_id)
async def _handle_callback(request: Request, account_id: str):
gateway = get_current_gateway()
if gateway is None:
return Response(status_code=503, content="gateway not started")
queue = gateway.get_message_queue()
if queue is None:
return Response(status_code=503, content="gateway not started")
config = _get_config(account_id)
if not config.is_configured():
return Response(status_code=503, content="not configured")
try:
body = await request.body()
content_type = request.headers.get("content-type", "")
if "application/x-www-form-urlencoded" in content_type or "multipart/form-data" in content_type:
form = await request.form()
params = {k: v for k, v in form.items()}
else:
params = json.loads(body.decode("utf-8"))
sign = params.pop("sign", "")
if not verify_top_sign(params, config.app_secret, sign, config.sign_method):
return Response(status_code=403, content="sign verification failed")
message_data = params
if config.encrypt_messages and "encrypt_data" in params:
decrypted = decrypt_message(
encrypt_data=params.get("encrypt_data", ""),
encrypt_key=params.get("encrypt_key", ""),
sign=params.get("encrypt_sign", ""),
timestamp=params.get("timestamp", ""),
app_secret=config.app_secret,
rsa_private_key_pem=config.rsa_private_key,
)
if decrypted is None:
return Response(status_code=400, content="decrypt failed")
message_data = json.loads(decrypted)
msg_id = message_data.get("msg_id", "") or message_data.get("id", "")
if msg_id and _dedupe_check(account_id, msg_id):
logger.debug("Duplicate message ignored: %s", msg_id)
return Response(content="ok")
unified = parse_to_unified_message(message_data)
if unified:
await gateway.enqueue_message(unified)
return Response(content="ok")
except Exception:
logger.exception("Taobao webhook processing error")
return Response(status_code=500, content="internal error")