feat: upgrade客服多店铺分流、批量报价与稳定性防护

This commit is contained in:
2026-02-28 18:52:31 +08:00
parent c39840fe15
commit 46143be86c
16 changed files with 1329 additions and 37 deletions

View File

@@ -3,10 +3,12 @@ import websockets
import json
import re
import logging
import random
import time
from collections import deque
from datetime import datetime
from pathlib import Path
from typing import Optional
from typing import Optional, Dict, Any, List
# ========== 转接分组映射 ==========
def _get_transfer_group(acc_id: str) -> str:
@@ -49,6 +51,7 @@ import os
logger = setup_logger()
from db.chat_log_db import log_message as _chat_log
from utils.metrics_tracker import emit as metrics_emit
# 导入 Agent 模块
try:
@@ -94,6 +97,8 @@ class QingjianAPIClient:
self._agent_semaphore = asyncio.Semaphore(8)
self._pending_images: dict = {}
self._pending_image_tasks: dict = {}
self._system_inquiry_rules = self._load_system_inquiry_rules()
self._last_reply_sent_at: dict = {} # customer_key -> monotonic ts
# 延迟加载任务模块(避免循环导入)
self.task_scheduler = None
@@ -289,7 +294,7 @@ class QingjianAPIClient:
if self._is_transfer_msg(data):
# 会话转交 → 主动打招呼
print(f"[{self.get_time()}] 收到转交消息,发送问候")
greeting = "在呢,发图来我看看"
greeting = self._pick_transfer_greeting()
await self.send_reply(data, greeting)
try:
from utils.wechat_chat_log import push_chat_to_wechat
@@ -324,6 +329,8 @@ class QingjianAPIClient:
))
except Exception:
pass
elif await self._handle_system_inquiry(data):
print(f"[{self.get_time()}] 系统客服询单消息,已按规则处理")
elif self._should_ignore(data):
print(f"[{self.get_time()}] 系统通知,跳过回复")
else:
@@ -529,6 +536,12 @@ class QingjianAPIClient:
except Exception:
pass
# 超大尺寸(米制)直接拒单,避免进入报价/处理流程
oversize_reply = self._oversize_reply_if_needed(msg_text)
if oversize_reply:
await self.send_reply(data, oversize_reply)
return
# 消息含图片URL累积到待处理列表先询问要求
if self._msg_has_image_url(msg_text):
urls = self._extract_image_urls(msg_text)
@@ -543,6 +556,7 @@ class QingjianAPIClient:
await self._flush_pending_images(capture_key, capture_data)
task = asyncio.create_task(_delay_flush(key, data))
self._pending_image_tasks[key] = task
return
elif self._msg_refers_images(msg_text):
urls = self._collect_recent_image_urls(_cid, data.get('acc_id', ''), max_count=6)
if urls:
@@ -550,20 +564,24 @@ class QingjianAPIClient:
self._add_pending_images(key, urls)
await self.send_reply(data, "稍等,我找找刚才那几张")
await self._flush_pending_images(key, data)
return
else:
status = self._detect_order_status(msg_text)
if status == "paid":
ack = "收到付款,我马上安排处理,有需要第一时间联系您"
await self.send_reply(data, ack)
return
elif status in ("waiting", "order"):
ack = "订单我看到了哈,方便的话请完成付款,我好安排处理"
await self.send_reply(data, ack)
return
else:
urls = self._extract_image_urls(msg_text)
if len(urls) == 1:
key = self._customer_key(data)
self._add_pending_images(key, urls)
await self.send_reply(data, "收到,我看看哈")
return
else:
if self._msg_requests_external_contact(msg_text):
reply = "这里沟通就可以哦,其他联系方式不方便"
@@ -603,9 +621,11 @@ class QingjianAPIClient:
if status == "paid":
ack = "收到付款,我马上安排处理,有需要第一时间联系您"
await self.send_reply(data, ack)
return
elif status in ("waiting", "order"):
ack = "订单我看到了哈,方便的话请完成付款,我好安排处理"
await self.send_reply(data, ack)
return
# 构建 CustomerMessage
customer_msg = CustomerMessage(
@@ -813,11 +833,68 @@ class QingjianAPIClient:
lower = msg.lower()
kws = ("加qq", "qq号", "vx", "微信", "加v", "联系方式", "私聊", "加一下", "加个", "手机号", "电话", "加群", "q q", "v 信")
return any(k in lower for k in kws)
@staticmethod
def _extract_size_pairs_m(msg: str) -> list[tuple[float, float]]:
"""提取消息中的米制尺寸对,如 15*6.4米 / 15米*6.4 / 15x6.4m。"""
if not msg:
return []
s = (msg or "").lower().replace("×", "*").replace("x", "*")
pairs = []
patterns = [
r'(\d+(?:\.\d+)?)\s*\*\s*(\d+(?:\.\d+)?)\s*(?:米|m)\b',
r'(\d+(?:\.\d+)?)\s*(?:米|m)\s*\*\s*(\d+(?:\.\d+)?)\b',
]
for p in patterns:
for m in re.findall(p, s):
try:
a = float(m[0])
b = float(m[1])
if a > 0 and b > 0:
pairs.append((a, b))
except Exception:
continue
return pairs
def _oversize_reply_if_needed(self, msg: str) -> str:
"""
检测超大尺寸需求并返回拒绝话术;未命中返回空字符串。
规则:最长边 > 阈值 或 面积 > 阈值。
"""
try:
from config.config import MAX_SERVICE_SIZE_LONGEST_METERS, MAX_SERVICE_SIZE_AREA_SQM
longest_limit = float(MAX_SERVICE_SIZE_LONGEST_METERS)
area_limit = float(MAX_SERVICE_SIZE_AREA_SQM)
except Exception:
longest_limit = 10.0
area_limit = 20.0
pairs = self._extract_size_pairs_m(msg)
for w, h in pairs:
longest = max(w, h)
area = w * h
if longest > longest_limit or area > area_limit:
return (
f"{w:g}米*{h:g}米这个尺寸太大了,我们这边做不了。"
"如果要做可以拆成几段小尺寸,我再给你按段评估。"
)
return ""
def _is_transfer_msg(self, data: dict) -> bool:
"""判断是否是会话转交消息(需要主动打招呼)"""
msg = self.to_chinese(data.get('msg', ''))
return '转交给' in msg or '转接给' in msg
def _pick_transfer_greeting(self) -> str:
"""转接后问候话术:简短自然,随机避免机械感。"""
choices = [
"在的亲,发图我看下",
"在呢亲,有需求直接说",
"我在的,您把要求发我",
"在的哈,你说我这边看着处理",
"在呢,图和需求发来我看看",
]
return random.choice(choices)
def _is_shop_card(self, data: dict) -> bool:
"""判断是否是进店卡片消息"""
msg = self.to_chinese(data.get('msg', ''))
@@ -838,6 +915,130 @@ class QingjianAPIClient:
except Exception:
return False
def _load_system_inquiry_rules(self) -> Dict[str, Any]:
"""加载系统客服询单规则(全局 + 店铺覆盖)。"""
from config.config import (
SYSTEM_INQUIRY_ENABLED,
SYSTEM_INQUIRY_DEFAULT_ACTION,
SYSTEM_INQUIRY_DEFAULT_REPLY,
SYSTEM_INQUIRY_RULES_FILE,
)
enabled_env = os.getenv("SYSTEM_INQUIRY_ENABLED")
enabled = (
enabled_env.lower() in ("1", "true", "yes")
if isinstance(enabled_env, str)
else bool(SYSTEM_INQUIRY_ENABLED)
)
action = (os.getenv("SYSTEM_INQUIRY_DEFAULT_ACTION") or SYSTEM_INQUIRY_DEFAULT_ACTION or "silent").strip().lower()
reply = os.getenv("SYSTEM_INQUIRY_DEFAULT_REPLY") or SYSTEM_INQUIRY_DEFAULT_REPLY or ""
rules_file = os.getenv("SYSTEM_INQUIRY_RULES_FILE") or str(SYSTEM_INQUIRY_RULES_FILE)
defaults: Dict[str, Any] = {
"enabled": bool(enabled),
"default_action": action,
"default_reply": reply,
"sender_keywords": ["系统客服", "官方客服", "平台客服", "机器人客服", "商家客服系统"],
"message_keywords": ["系统询单", "代客咨询", "平台代问", "系统代发", "客服询单"],
"shops": {},
}
try:
p = Path(rules_file)
if p.exists():
with p.open("r", encoding="utf-8") as f:
loaded = json.load(f)
if isinstance(loaded, dict):
defaults.update(loaded)
except Exception as e:
logger.warning(f"系统询单规则加载失败,使用默认规则: {e}")
return defaults
@staticmethod
def _normalize_kw_list(v: Any) -> List[str]:
if not isinstance(v, list):
return []
return [str(x).strip().lower() for x in v if str(x).strip()]
def _resolve_system_inquiry_policy(self, acc_id: str) -> Dict[str, Any]:
"""根据店铺合并系统询单策略。"""
from config.config import SYSTEM_INQUIRY_SHOPS
rules = self._system_inquiry_rules or {}
if not bool(rules.get("enabled", True)):
return {"enabled": False}
shops_env = os.getenv("SYSTEM_INQUIRY_SHOPS", SYSTEM_INQUIRY_SHOPS or "")
shop_whitelist = [s.strip() for s in shops_env.split(",") if s.strip()]
if shop_whitelist and (acc_id or "") not in shop_whitelist:
return {"enabled": False}
policy: Dict[str, Any] = {
"enabled": True,
"action": str(rules.get("default_action", "silent")).strip().lower(),
"reply": str(rules.get("default_reply", "")).strip(),
"sender_keywords": self._normalize_kw_list(rules.get("sender_keywords")),
"message_keywords": self._normalize_kw_list(rules.get("message_keywords")),
}
shop_cfg = (rules.get("shops") or {}).get(acc_id or "", {})
if isinstance(shop_cfg, dict):
if "enabled" in shop_cfg and not bool(shop_cfg.get("enabled", True)):
return {"enabled": False}
if shop_cfg.get("action"):
policy["action"] = str(shop_cfg.get("action")).strip().lower()
if shop_cfg.get("reply"):
policy["reply"] = str(shop_cfg.get("reply")).strip()
if isinstance(shop_cfg.get("sender_keywords"), list):
policy["sender_keywords"] = self._normalize_kw_list(shop_cfg.get("sender_keywords"))
if isinstance(shop_cfg.get("message_keywords"), list):
policy["message_keywords"] = self._normalize_kw_list(shop_cfg.get("message_keywords"))
if policy["action"] not in ("silent", "reply", "transfer"):
policy["action"] = "silent"
return policy
def _match_system_inquiry(self, data: dict, policy: Dict[str, Any]) -> bool:
"""识别是否为系统客服询单消息。"""
if not policy.get("enabled", False):
return False
from_name = self.to_chinese(data.get("from_name", "") or "").lower()
from_id = str(data.get("from_id", "") or "").lower()
msg = self.to_chinese(data.get("msg", "") or "").lower()
sender_hits = 0
for kw in policy.get("sender_keywords", []):
if kw and (kw in from_name or kw in from_id):
sender_hits += 1
message_hits = 0
for kw in policy.get("message_keywords", []):
if kw and kw in msg:
message_hits += 1
# 优先看发送者特征;纯文本命中时至少要求两个关键词,降低误判风险
return sender_hits > 0 or message_hits >= 2
async def _handle_system_inquiry(self, data: dict) -> bool:
"""命中系统询单后按策略处理。"""
acc_id = data.get("acc_id", "")
policy = self._resolve_system_inquiry_policy(acc_id)
if not self._match_system_inquiry(data, policy):
return False
customer_id = data.get("from_id", "")
metrics_emit("system_inquiry_detected", customer_id=customer_id, acc_id=acc_id)
action = policy.get("action", "silent")
logger.info(f"系统询单命中 | 店铺:{acc_id} | 客户:{customer_id} | action:{action}")
if action == "reply":
reply = policy.get("reply") or "您好,这边已收到询单消息,稍后由人工客服跟进处理。"
await self.send_reply(data, reply)
metrics_emit("system_inquiry_auto_reply", customer_id=customer_id, acc_id=acc_id)
return True
if action == "transfer":
await self.transfer_to_human(data, "系统询单转人工")
metrics_emit("system_inquiry_transfer", customer_id=customer_id, acc_id=acc_id)
return True
metrics_emit("system_inquiry_ignored", customer_id=customer_id, acc_id=acc_id)
return True
def _should_ignore(self, data: dict) -> bool:
"""判断是否应该忽略该消息(不回复)"""
msg = self.to_chinese(data.get('msg', ''))
@@ -1107,6 +1308,23 @@ class QingjianAPIClient:
if not self.websocket:
print(f"[{self.get_time()}] 错误: 未连接到服务器")
return
# 同一客户外发限流N 秒内最多 1 条
try:
from config.config import OUTBOUND_PER_CUSTOMER_COOLDOWN_SECONDS
cooldown = max(0, int(OUTBOUND_PER_CUSTOMER_COOLDOWN_SECONDS))
except Exception:
cooldown = 5
if cooldown > 0:
ckey = f"{original_msg.get('acc_id', '')}:{original_msg.get('from_id', '')}"
now_mono = time.monotonic()
last = self._last_reply_sent_at.get(ckey, 0.0)
if (now_mono - last) < cooldown:
logger.info(
f"外发限流命中,跳过发送 | 客户:{ckey} | cooldown:{cooldown}s | msg:{str(reply_content)[:40]}"
)
return
self._last_reply_sent_at[ckey] = now_mono
shop_id = original_msg.get("acc_id", "")