""" 卡片按钮集中防抖:同一张卡同一按钮在 TTL 内只处理一次。 本文件职责:按 task_id/工单号 + 按钮动作抢占;HTTP/Worker 共用 Redis, 单测无 Redis 时退回进程内字典。短协调,不当六态、不当 checkpoint。 禁止:用 ResponseCode 当业务键(企微每次点击换新码);禁止全局锁所有工单。 """ from __future__ import annotations import logging import threading import time from collections import OrderedDict from typing import Optional from agent.redis_coord.keys import IDEMPOTENCY logger = logging.getLogger(__name__) # 连点窗口:够挡住企微连点/重试,又不挡稍后改点「成交/未成交」 _TTL_SEC = 20 _SCOPE = "card-btn" _LOCAL_MAX = 2000 _local_lock = threading.Lock() _local_seen: OrderedDict[str, float] = OrderedDict() def debounce_key( *, action: str, task_id: str = "", work_order_no: str = "", sender_id: str = "", event_key: str = "", ) -> str: """ 拼防抖业务键。优先 task_id(同卡唯一),否则工单号,再退回 event_key/sender。 不含 ResponseCode:每次点击码不同,不能当去重依据。 """ act = (action or "").strip() tid = (task_id or "").strip() if tid and act: return f"{tid}|{act}" wo = (work_order_no or "").strip() if wo and act: return f"{wo}|{act}|{(sender_id or '').strip()}" ek = (event_key or "").strip() if ek: return f"{ek}|{(sender_id or '').strip()}" return f"{(sender_id or '').strip()}|{act}" def try_acquire(key: str, *, ttl_seconds: int = _TTL_SEC) -> bool: """ 尝试占用卡片按钮防抖键。 返回 True=首次(可继续业务);False=TTL 内重复(应丢弃副作用与 ack)。 Redis 不可用时用进程内字典,保证单测与本机复现脚本也能挡住连点。 """ token = (key or "").strip() if not token: return True ttl = max(1, int(ttl_seconds or _TTL_SEC)) redis_hit = _try_redis(token, ttl) if redis_hit is not None: return redis_hit return _try_local(token, ttl) def _try_redis(key: str, ttl: int) -> Optional[bool]: try: from agent.redis_coord.runtime import get_redis_runtime client = get_redis_runtime().client full = client.key(IDEMPOTENCY, _SCOPE, key) ok = client.raw.set(full, "1", nx=True, ex=ttl) acquired = bool(ok) if not acquired: logger.info("卡片按钮防抖命中重复 key=%s", key) return acquired except Exception: logger.debug("卡片按钮防抖 Redis 不可用,改用进程内", exc_info=True) return None def _try_local(key: str, ttl: int) -> bool: now = time.time() with _local_lock: stale = [k for k, ts in _local_seen.items() if now - ts >= ttl] for k in stale: _local_seen.pop(k, None) prev = _local_seen.get(key) if prev is not None and now - prev < ttl: logger.info("卡片按钮防抖(本地)命中重复 key=%s", key) return False _local_seen[key] = now while len(_local_seen) > _LOCAL_MAX: _local_seen.popitem(last=False) return True