from __future__ import annotations import logging import os logger = logging.getLogger(__name__) PAY_DEDUP_TTL_SECONDS = 259200 class PayNotifyDeduplicator: def __init__(self, redis_client): self._redis = redis_client @staticmethod def _key(notification_id: str) -> str: return f"wp:dedup:{notification_id}" async def try_claim(self, notification_id: str) -> bool: key = self._key(notification_id) try: result = await self._redis.set(key, "1", nx=True, ex=PAY_DEDUP_TTL_SECONDS) return bool(result) except Exception: logger.exception("PayNotifyDeduplicator Redis error, allowing through") return True async def get_status(self, notification_id: str) -> str | None: key = self._key(notification_id) try: val = await self._redis.get(key) return val.decode("utf-8") if val else None except Exception: return None class InMemoryPayDeduplicator: def __init__(self, max_size: int = 2000): from collections import OrderedDict self._store: OrderedDict[str, float] = OrderedDict() self._max_size = max_size async def try_claim(self, notification_id: str) -> bool: import time now = time.monotonic() if notification_id in self._store: return False self._store[notification_id] = now while len(self._store) > self._max_size: self._store.popitem(last=False) return True _DEDUP_INSTANCE = None def get_pay_deduplicator(): global _DEDUP_INSTANCE if _DEDUP_INSTANCE is not None: return _DEDUP_INSTANCE redis_url = os.environ.get("YUXI_REDIS_URL", os.environ.get("REDIS_URL", "")) if redis_url: try: import redis.asyncio as aioredis client = aioredis.from_url(redis_url, decode_responses=False) _DEDUP_INSTANCE = PayNotifyDeduplicator(client) logger.info("PayNotifyDeduplicator: using Redis backend (TTL=%ds)", PAY_DEDUP_TTL_SECONDS) return _DEDUP_INSTANCE except Exception: logger.warning("PayNotifyDeduplicator: Redis unavailable, falling back to in-memory") _DEDUP_INSTANCE = InMemoryPayDeduplicator() logger.warning("PayNotifyDeduplicator: using in-memory backend (NOT production-ready)") return _DEDUP_INSTANCE