""" 意图:询价卡片按钮(复制工单号 / 拉产品进群 / 跳过协同 / 采用 / 不采用 / 成交)。 本文件职责:校验当前会话允许的动作后交给流程执行;卡片按钮集中防抖入口。 禁止:不校验 wait/允许集合就改主账;禁止一个函数处理未声明的新按钮。 """ from __future__ import annotations import logging from typing import Any from agent.channel.queue import get_message_store from agent.policy import inquiry_copy as copy from agent.policy.air_text_flow import get_air_text_flow from agent.policy.land_text_flow import get_land_text_flow from agent.policy.sea_text_flow import get_sea_text_flow from agent.redis_coord.card_debounce import debounce_key, try_acquire as try_card_debounce logger = logging.getLogger(__name__) def _pick_card_flow(sender_id: str, payload: dict | None): """ 点卡:先认多段联运,再认海运/陆运,最后才空运。 多段工单主账停点常被收成 tms_miss。若先走空运,「拉产品进群」会被丢掉、销售点了没回声。 """ from agent.policy.multi_text_flow import get_multi_text_flow multi = get_multi_text_flow() multi_sess = multi.session_for_action(sender_id, payload) if multi_sess and (multi_sess.business_line or "").upper() == "MULTI": return multi sea = get_sea_text_flow() sess = sea.session_for_action(sender_id, payload) if sess and (sess.business_line or "").upper() == "SEA": return sea land = get_land_text_flow() land_sess = land.session_for_action(sender_id, payload) if land_sess and (land_sess.business_line or "").upper() == "LAND": return land return get_air_text_flow() def handle_card_action( *, sender_id: str, thread_id: str, wait_version: int, action: str, payload: dict[str, Any] | None = None, flow=None, store=None, ) -> str: """ 处理一次卡片动作。 wait_version:会话书签版本;错版本丢弃,不重放副作用。 payload 须带 task_id / event_key,才能找回点卡当时的工单。 连点:同一 task_id+按钮在防抖 TTL 内只跑一次业务,避免多条智能体提示。 """ _ = thread_id if not (sender_id or "").strip(): return "reject_no_sender" engine = flow or _pick_card_flow(sender_id, payload) sess = engine.session_for_action(sender_id, payload) if sess and wait_version and sess.wait_version != wait_version: logger.info("card waitVersion 不匹配 expect=%s got=%s", sess.wait_version, wait_version) return "card_discard" msg_store = store or get_message_store() wo_hint = "" if sess is not None: wo_hint = (sess.work_order_no or "").strip() if not wo_hint: wo_hint = copy.work_order_from_card_meta(payload) # 集中防抖:键用 task_id+动作,不用 ResponseCode(企微每次点击换新码) dkey = debounce_key( action=action, task_id=str((payload or {}).get("task_id") or ""), work_order_no=wo_hint, sender_id=sender_id, event_key=str((payload or {}).get("event_key") or ""), ) if not try_card_debounce(dkey): logger.info( "card 防抖丢弃 sender=%s action=%s key=%s", sender_id, action, dkey, ) # 仍用新 ResponseCode 置灰,避免企微按钮一直可点;不发业务 ack code = str((payload or {}).get("response_code") or "").strip() if code: msg_store.enqueue_outbound( touser=sender_id, content=copy.BTN_SKIP_COLLAB_DONE, dedupe_key=f"card-grey:{sender_id}:{wo_hint}:{code}", payload=copy.skip_collab_card_update( response_code=code, replace_name=action if action in { copy.BTN_DEAL, copy.BTN_LOST, copy.BTN_NEGOTIATE } else "", ), ) return "card_debounced" def reply(text: str, extra: dict | None = None) -> str: # 销售询价正文不加 [test];[test] 只给运维告警 body = text extra = dict(extra or {}) wo = "" if sess is not None: wo = (sess.work_order_no or "").strip() if not wo: wo = copy.work_order_from_card_meta(payload) code = str((payload or {}).get("response_code") or extra.get("response_code") or "")[:24] msgtype = str(extra.get("msgtype") or "text") # 文本/方案卡按工单+动作+waitVersion 去重。 # 调价后再点「生成」时正文前缀相同,若不带 waitVersion,第二次方案卡会被 PG 唯一键吃掉, # 价格卡已置灰成「已提交」但销售收不到生成 Excel/PDF 的方案卡。 wait_ver = int(getattr(sess, "wait_version", 0) or 0) if sess is not None else 0 if msgtype == "update_template_card": dedupe_key = f"card-grey:{sender_id}:{wo}:{code}:{action}" else: dedupe_key = f"card-ack:{sender_id}:{wo}:{action}:v{wait_ver}:{body[:24]}" ok, info = msg_store.enqueue_outbound( touser=sender_id, content=body, dedupe_key=dedupe_key, payload=extra, ) if not ok: logger.warning("card 出站被去重 sender=%s wo=%s info=%s", sender_id, wo or "-", info) return "" return info return engine.on_button( sender_id=sender_id, action=action, reply=reply, card_meta=payload )