Files
inquiry_robot/inquiry-agent/agent/redis_coord/card_debounce.py
T

103 lines
3.2 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
卡片按钮集中防抖:同一张卡同一按钮在 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