404 lines
15 KiB
Python
404 lines
15 KiB
Python
"""
|
||
工作手机SDK v3.0 - 存客宝对接服务
|
||
CunKeBao API Service
|
||
|
||
功能:
|
||
- 封装存客宝 API 调用逻辑(签名、参数转换、错误处理)
|
||
- 实现 report_lead(线索上报)接口
|
||
- 实现 Frida Hook 事件实时上报机制
|
||
- API Key 管理与请求频率限制
|
||
|
||
作者:Manus AI
|
||
版本:v1.0.0
|
||
日期:2026-05-15
|
||
"""
|
||
|
||
import hashlib
|
||
import time
|
||
import logging
|
||
import asyncio
|
||
import json
|
||
from typing import Optional, Dict, Any, List
|
||
from dataclasses import dataclass, field
|
||
from collections import defaultdict
|
||
try:
|
||
import aiohttp
|
||
except ImportError: # NAS armv7 无 aiohttp wheel 时降级
|
||
aiohttp = None # type: ignore[assignment]
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
# ═══════════════════════════════════════════════
|
||
# 一、配置模型
|
||
# ═══════════════════════════════════════════════
|
||
|
||
@dataclass
|
||
class CunKeBaoConfig:
|
||
"""存客宝对接配置"""
|
||
api_key: str = ""
|
||
base_url: str = "https://ckbapi.quwanzhi.com/v1/api/scenarios"
|
||
timeout: int = 10
|
||
max_retries: int = 3
|
||
rate_limit_per_minute: int = 60 # 每分钟最大请求数
|
||
enabled: bool = False
|
||
|
||
@classmethod
|
||
def from_dict(cls, d: dict) -> "CunKeBaoConfig":
|
||
return cls(**{k: v for k, v in d.items() if k in cls.__dataclass_fields__})
|
||
|
||
|
||
# ═══════════════════════════════════════════════
|
||
# 二、签名算法
|
||
# ═══════════════════════════════════════════════
|
||
|
||
def generate_ckb_sign(params: Dict[str, Any], api_key: str) -> str:
|
||
"""
|
||
存客宝双重MD5签名算法
|
||
|
||
规则:
|
||
1. 移除 sign / apiKey / portrait 字段
|
||
2. 移除 null 和空字符串值
|
||
3. 按键名 ASCII 升序排序
|
||
4. 只取值拼接(无分隔符)
|
||
5. 第一次 MD5(拼接字符串)
|
||
6. 第二次 MD5(firstMd5 + apiKey)
|
||
"""
|
||
sign_params = {k: v for k, v in params.items()
|
||
if k not in ("sign", "apiKey", "portrait")
|
||
and v is not None and v != ""}
|
||
|
||
sorted_keys = sorted(sign_params.keys())
|
||
concat_str = "".join(str(sign_params[k]) for k in sorted_keys)
|
||
|
||
first_md5 = hashlib.md5(concat_str.encode("utf-8")).hexdigest()
|
||
final_sign = hashlib.md5((first_md5 + api_key).encode("utf-8")).hexdigest()
|
||
return final_sign
|
||
|
||
|
||
# ═══════════════════════════════════════════════
|
||
# 三、数据模型
|
||
# ═══════════════════════════════════════════════
|
||
|
||
@dataclass
|
||
class LeadData:
|
||
"""线索数据模型(对应存客宝 report_lead 接口)"""
|
||
phone: str = "" # 手机号(必填)
|
||
name: str = "" # 姓名
|
||
source: str = "微信" # 来源渠道
|
||
wechat_id: str = "" # 微信号
|
||
wechat_nickname: str = "" # 微信昵称
|
||
wechat_remark: str = "" # 备注名
|
||
tags: List[str] = field(default_factory=list) # 标签列表
|
||
group_name: str = "" # 所在群名
|
||
device_id: str = "" # 设备ID
|
||
extra: Dict[str, Any] = field(default_factory=dict) # 扩展字段
|
||
|
||
def to_api_params(self) -> Dict[str, Any]:
|
||
"""转换为存客宝 API 参数格式"""
|
||
params: Dict[str, Any] = {
|
||
"timestamp": int(time.time()),
|
||
}
|
||
if self.phone:
|
||
params["phone"] = self.phone
|
||
if self.name:
|
||
params["name"] = self.name
|
||
if self.source:
|
||
params["source"] = self.source
|
||
if self.wechat_id:
|
||
params["wechatId"] = self.wechat_id
|
||
if self.wechat_nickname:
|
||
params["wechatNickname"] = self.wechat_nickname
|
||
if self.wechat_remark:
|
||
params["remark"] = self.wechat_remark
|
||
if self.tags:
|
||
params["tags"] = ",".join(self.tags)
|
||
if self.group_name:
|
||
params["groupName"] = self.group_name
|
||
if self.device_id:
|
||
params["deviceId"] = self.device_id
|
||
# 合并扩展字段
|
||
params.update(self.extra)
|
||
return params
|
||
|
||
|
||
@dataclass
|
||
class FriendAddEvent:
|
||
"""新好友添加事件"""
|
||
device_id: str
|
||
wechat_id: str
|
||
nickname: str
|
||
avatar: str = ""
|
||
source: str = "微信添加"
|
||
timestamp: int = field(default_factory=lambda: int(time.time()))
|
||
|
||
|
||
@dataclass
|
||
class GroupChangeEvent:
|
||
"""群变动事件"""
|
||
device_id: str
|
||
group_id: str
|
||
group_name: str
|
||
event_type: str # "member_join" | "member_leave" | "group_create"
|
||
member_wechat_id: str = ""
|
||
member_nickname: str = ""
|
||
timestamp: int = field(default_factory=lambda: int(time.time()))
|
||
|
||
|
||
# ═══════════════════════════════════════════════
|
||
# 四、速率限制器
|
||
# ═══════════════════════════════════════════════
|
||
|
||
class RateLimiter:
|
||
"""简单的滑动窗口速率限制器"""
|
||
|
||
def __init__(self, max_calls: int, period: int = 60):
|
||
self.max_calls = max_calls
|
||
self.period = period
|
||
self._calls: List[float] = []
|
||
|
||
def is_allowed(self) -> bool:
|
||
now = time.time()
|
||
self._calls = [t for t in self._calls if now - t < self.period]
|
||
if len(self._calls) < self.max_calls:
|
||
self._calls.append(now)
|
||
return True
|
||
return False
|
||
|
||
def wait_time(self) -> float:
|
||
if not self._calls:
|
||
return 0.0
|
||
oldest = min(self._calls)
|
||
return max(0.0, self.period - (time.time() - oldest))
|
||
|
||
|
||
# ═══════════════════════════════════════════════
|
||
# 五、存客宝服务主类
|
||
# ═══════════════════════════════════════════════
|
||
|
||
class CunKeBaoService:
|
||
"""
|
||
存客宝对接服务
|
||
|
||
职责:
|
||
- 管理 API Key 配置
|
||
- 封装签名与 HTTP 请求
|
||
- 实现线索上报(report_lead)
|
||
- 处理 Frida Hook 事件上报
|
||
- 速率限制与错误重试
|
||
"""
|
||
|
||
def __init__(self, config: Optional[CunKeBaoConfig] = None):
|
||
self.config = config or CunKeBaoConfig()
|
||
self._session: Optional[aiohttp.ClientSession] = None
|
||
self._rate_limiter = RateLimiter(
|
||
max_calls=self.config.rate_limit_per_minute,
|
||
period=60
|
||
)
|
||
# 统计计数器
|
||
self._stats = {
|
||
"total_requests": 0,
|
||
"success_count": 0,
|
||
"fail_count": 0,
|
||
"last_error": "",
|
||
}
|
||
|
||
def update_config(self, config: CunKeBaoConfig):
|
||
"""动态更新配置"""
|
||
self.config = config
|
||
self._rate_limiter = RateLimiter(
|
||
max_calls=config.rate_limit_per_minute,
|
||
period=60
|
||
)
|
||
logger.info(f"[CKB] 配置已更新,enabled={config.enabled}, api_key={'***' if config.api_key else '未设置'}")
|
||
|
||
async def _get_session(self) -> "aiohttp.ClientSession":
|
||
if aiohttp is None:
|
||
raise RuntimeError("aiohttp 未安装,存客宝 HTTP 不可用")
|
||
if self._session is None or self._session.closed:
|
||
timeout = aiohttp.ClientTimeout(total=self.config.timeout)
|
||
self._session = aiohttp.ClientSession(timeout=timeout)
|
||
return self._session
|
||
|
||
async def close(self):
|
||
if self._session and not self._session.closed:
|
||
await self._session.close()
|
||
|
||
# ─── 核心请求方法 ───────────────────────────────
|
||
|
||
async def _post(self, params: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""
|
||
发送签名请求到存客宝 API
|
||
|
||
Returns:
|
||
{"code": 200, "msg": "success", "data": {...}}
|
||
"""
|
||
if not self.config.enabled:
|
||
logger.debug("[CKB] 服务未启用,跳过上报")
|
||
return {"code": -1, "msg": "服务未启用", "data": None}
|
||
|
||
if not self.config.api_key:
|
||
logger.warning("[CKB] API Key 未配置,跳过上报")
|
||
return {"code": -1, "msg": "API Key 未配置", "data": None}
|
||
|
||
if aiohttp is None:
|
||
return {"code": -1, "msg": "aiohttp 不可用", "data": None}
|
||
|
||
if not self._rate_limiter.is_allowed():
|
||
wait = self._rate_limiter.wait_time()
|
||
logger.warning(f"[CKB] 速率限制,需等待 {wait:.1f}s")
|
||
return {"code": 429, "msg": f"速率限制,请 {wait:.1f}s 后重试", "data": None}
|
||
|
||
# 添加 apiKey 和签名
|
||
params["apiKey"] = self.config.api_key
|
||
params["sign"] = generate_ckb_sign(params, self.config.api_key)
|
||
|
||
self._stats["total_requests"] += 1
|
||
last_error = ""
|
||
|
||
for attempt in range(self.config.max_retries):
|
||
try:
|
||
session = await self._get_session()
|
||
async with session.post(
|
||
self.config.base_url,
|
||
json=params,
|
||
headers={"Content-Type": "application/json"}
|
||
) as resp:
|
||
if resp.status == 200:
|
||
result = await resp.json()
|
||
self._stats["success_count"] += 1
|
||
logger.info(f"[CKB] 上报成功: {result.get('msg', 'ok')}")
|
||
return result
|
||
else:
|
||
text = await resp.text()
|
||
last_error = f"HTTP {resp.status}: {text[:200]}"
|
||
logger.warning(f"[CKB] 请求失败 (attempt {attempt+1}): {last_error}")
|
||
|
||
except aiohttp.ClientConnectorError as e:
|
||
last_error = f"连接失败: {e}"
|
||
logger.error(f"[CKB] {last_error} (attempt {attempt+1})")
|
||
except asyncio.TimeoutError:
|
||
last_error = "请求超时"
|
||
logger.error(f"[CKB] 请求超时 (attempt {attempt+1})")
|
||
except Exception as e:
|
||
last_error = str(e)
|
||
logger.error(f"[CKB] 未知错误: {e} (attempt {attempt+1})")
|
||
|
||
if attempt < self.config.max_retries - 1:
|
||
await asyncio.sleep(2 ** attempt) # 指数退避
|
||
|
||
self._stats["fail_count"] += 1
|
||
self._stats["last_error"] = last_error
|
||
return {"code": 500, "msg": last_error, "data": None}
|
||
|
||
# ─── 业务方法 ───────────────────────────────────
|
||
|
||
async def report_lead(self, lead: LeadData) -> Dict[str, Any]:
|
||
"""
|
||
上报线索到存客宝
|
||
|
||
Args:
|
||
lead: 线索数据
|
||
|
||
Returns:
|
||
存客宝 API 响应
|
||
"""
|
||
params = lead.to_api_params()
|
||
logger.info(f"[CKB] 上报线索: wechat_id={lead.wechat_id}, nickname={lead.wechat_nickname}")
|
||
result = await self._post(params)
|
||
return result
|
||
|
||
async def report_friend_add(self, event: FriendAddEvent) -> Dict[str, Any]:
|
||
"""
|
||
上报新好友添加事件
|
||
|
||
将 Frida Hook 捕获的新好友事件转换为存客宝线索上报
|
||
"""
|
||
lead = LeadData(
|
||
wechat_id=event.wechat_id,
|
||
wechat_nickname=event.nickname,
|
||
source=event.source,
|
||
device_id=event.device_id,
|
||
extra={"avatar": event.avatar, "event_time": event.timestamp}
|
||
)
|
||
return await self.report_lead(lead)
|
||
|
||
async def report_group_change(self, event: GroupChangeEvent) -> Dict[str, Any]:
|
||
"""
|
||
上报群变动事件
|
||
|
||
将 Frida Hook 捕获的群成员变动转换为存客宝线索上报
|
||
"""
|
||
if event.event_type == "member_join":
|
||
lead = LeadData(
|
||
wechat_id=event.member_wechat_id,
|
||
wechat_nickname=event.member_nickname,
|
||
source="群加入",
|
||
group_name=event.group_name,
|
||
device_id=event.device_id,
|
||
extra={"group_id": event.group_id, "event_time": event.timestamp}
|
||
)
|
||
return await self.report_lead(lead)
|
||
else:
|
||
logger.debug(f"[CKB] 群事件 {event.event_type} 暂不上报")
|
||
return {"code": 0, "msg": "跳过", "data": None}
|
||
|
||
async def batch_report_contacts(
|
||
self,
|
||
contacts: List[Dict[str, Any]],
|
||
device_id: str,
|
||
source: str = "微信通讯录"
|
||
) -> Dict[str, Any]:
|
||
"""
|
||
批量上报联系人(来自 Frida Hook 获取的通讯录)
|
||
|
||
Args:
|
||
contacts: 联系人列表,每项包含 wechat_id, nickname, remark, tags 等
|
||
device_id: 设备ID
|
||
source: 来源标识
|
||
|
||
Returns:
|
||
{"total": N, "success": M, "failed": K, "errors": [...]}
|
||
"""
|
||
results = {"total": len(contacts), "success": 0, "failed": 0, "errors": []}
|
||
|
||
for contact in contacts:
|
||
lead = LeadData(
|
||
wechat_id=contact.get("wechat_id", ""),
|
||
wechat_nickname=contact.get("nickname", ""),
|
||
wechat_remark=contact.get("remark", ""),
|
||
tags=contact.get("tags", []),
|
||
phone=contact.get("phone", ""),
|
||
source=source,
|
||
device_id=device_id,
|
||
)
|
||
result = await self.report_lead(lead)
|
||
if result.get("code") == 200:
|
||
results["success"] += 1
|
||
else:
|
||
results["failed"] += 1
|
||
results["errors"].append({
|
||
"wechat_id": lead.wechat_id,
|
||
"error": result.get("msg", "未知错误")
|
||
})
|
||
# 批量上报时适当延迟,避免触发速率限制
|
||
await asyncio.sleep(0.1)
|
||
|
||
logger.info(f"[CKB] 批量上报完成: 成功={results['success']}, 失败={results['failed']}")
|
||
return results
|
||
|
||
def get_stats(self) -> Dict[str, Any]:
|
||
"""获取服务统计信息"""
|
||
return {
|
||
**self._stats,
|
||
"enabled": self.config.enabled,
|
||
"api_key_set": bool(self.config.api_key),
|
||
"rate_limit": self.config.rate_limit_per_minute,
|
||
}
|
||
|
||
|
||
# ═══════════════════════════════════════════════
|
||
# 六、全局单例
|
||
# ═══════════════════════════════════════════════
|
||
|
||
cunke_bao_service = CunKeBaoService()
|