Files
workphone-sdk/sdk/app/services/cunke_bao_service.py
Manus AI 42fe10401a 1
1
2026-05-24 17:50:04 +08:00

404 lines
15 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.

"""
工作手机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()