103 lines
3.2 KiB
Python
103 lines
3.2 KiB
Python
"""
|
||
卡片按钮集中防抖:同一张卡同一按钮在 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
|