19a8310b23
在企业微信「微信客服」侧复刻服务号「发图 → 弹比价窗」的触发链路。下游(心跳下发 pending_compare → 前端弹窗 → 走比价)完全复用服务号 PoC, 本次仅新增上游入口。 - /wx/kf/callback: GET 接入验证(验签 + 解密 echostr 返回明文)+ POST 收 kf_msg_or_event 事件 → sync_msg 增量拉消息 → 客户(origin=3)发来的图片 / 小程序卡片 → set_pending(测试设备, "meituan") - wx_kf_client: access_token 进程内缓存 + sync_msg(按 open_kfid 游标续拉)+ 临时素材下载 - 回调加解密与公众号安全模式同源, 直接复用 wx_mp_crypto(receiveid 传 corpid), 未新写 crypto - config 增 WX_KF_CORP_ID/SECRET/TOKEN/AES_KEY + wx_kf_callback_configured(全空=拒收, 上线零风险) - 无新依赖、无 migration(信号进程内存、图片落文件) 依赖服务号 PoC 的下游(wx_poc_signal / device 心跳 / wx_mp_crypto), 故基于 poc/wx-screenshot-compare。 external_userid↔device 绑定 / 信号落库 / 平台识别沿用服务号 PoC 遗留(正式化用 unionid 绑定)。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
127 lines
5.1 KiB
Python
127 lines
5.1 KiB
Python
"""微信客服(企业微信)API 客户端:access_token 缓存 + sync_msg 拉消息 + 临时素材下载。
|
|
|
|
对应 api/v1/wx_kf.py:收到 `kf_msg_or_event` 回调后, 用这里的函数增量拉取用户消息。
|
|
- access_token(7200s)进程内缓存, 提前 300s 刷新(单 worker 够用, 重启即失效)。
|
|
- sync_msg 游标 next_cursor 按 open_kfid 进程内存续(PoC 重启后从最近 3 天重拉, 可接受)。
|
|
凭证来自 settings(WX_KF_CORP_ID / WX_KF_SECRET);纯外部 HTTP, 不含业务逻辑。
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import asyncio
|
|
import logging
|
|
import time
|
|
from typing import Any
|
|
|
|
import httpx
|
|
|
|
from app.core.config import settings
|
|
|
|
logger = logging.getLogger("shagua.wx_kf")
|
|
|
|
_QYAPI = "https://qyapi.weixin.qq.com/cgi-bin"
|
|
|
|
# access_token 进程内缓存(单 worker)。token 空 / 未到刷新点直接用。
|
|
_token_lock = asyncio.Lock()
|
|
_token_cache: dict[str, Any] = {"token": "", "expire_at": 0.0}
|
|
|
|
# sync_msg 游标:open_kfid -> next_cursor(PoC 进程内存;重启从最近 3 天重拉)
|
|
_cursors: dict[str, str] = {}
|
|
|
|
|
|
async def _get_access_token() -> str:
|
|
"""取企业微信 access_token, 进程内缓存, 提前 300s 过期刷新。失败返回空串(调用方降级)。"""
|
|
now = time.time()
|
|
if _token_cache["token"] and _token_cache["expire_at"] - 300 > now:
|
|
return _token_cache["token"]
|
|
async with _token_lock:
|
|
now = time.time() # 拿锁后复检, 避免并发重复刷新
|
|
if _token_cache["token"] and _token_cache["expire_at"] - 300 > now:
|
|
return _token_cache["token"]
|
|
try:
|
|
async with httpx.AsyncClient(timeout=10) as client:
|
|
resp = await client.get(
|
|
f"{_QYAPI}/gettoken",
|
|
params={
|
|
"corpid": settings.WX_KF_CORP_ID,
|
|
"corpsecret": settings.WX_KF_SECRET,
|
|
},
|
|
)
|
|
data = resp.json()
|
|
except Exception:
|
|
logger.exception("wx_kf gettoken 请求失败")
|
|
return ""
|
|
if data.get("errcode"):
|
|
logger.warning(
|
|
"wx_kf gettoken errcode=%s errmsg=%s", data.get("errcode"), data.get("errmsg")
|
|
)
|
|
return ""
|
|
_token_cache["token"] = data.get("access_token", "")
|
|
_token_cache["expire_at"] = now + int(data.get("expires_in", 7200))
|
|
return _token_cache["token"]
|
|
|
|
|
|
async def sync_messages(token: str, open_kfid: str) -> list[dict[str, Any]]:
|
|
"""收到 kf_msg_or_event 回调后调用:用回调带的一次性 token 增量拉消息, has_more 循环拉完。
|
|
|
|
token: 回调事件里的 <Token>(消息拉取凭证, 非配置 Token;不传会有严格频控)。
|
|
open_kfid:客服账号 id(回调里的 <OpenKfId>)。
|
|
返回本次新拉到的 msg_list(已合并多页);游标按 open_kfid 进程内存续。
|
|
"""
|
|
access_token = await _get_access_token()
|
|
if not access_token:
|
|
return []
|
|
out: list[dict[str, Any]] = []
|
|
cursor = _cursors.get(open_kfid, "")
|
|
try:
|
|
async with httpx.AsyncClient(timeout=15) as client:
|
|
for _ in range(20): # 最多 20 页护栏, 防异常时死循环
|
|
body: dict[str, Any] = {"cursor": cursor, "token": token, "limit": 1000}
|
|
if open_kfid:
|
|
body["open_kfid"] = open_kfid
|
|
resp = await client.post(
|
|
f"{_QYAPI}/kf/sync_msg",
|
|
params={"access_token": access_token},
|
|
json=body,
|
|
)
|
|
data = resp.json()
|
|
if data.get("errcode"):
|
|
logger.warning(
|
|
"wx_kf sync_msg errcode=%s errmsg=%s",
|
|
data.get("errcode"),
|
|
data.get("errmsg"),
|
|
)
|
|
break
|
|
out.extend(data.get("msg_list", []))
|
|
cursor = data.get("next_cursor", cursor)
|
|
_cursors[open_kfid] = cursor
|
|
if not data.get("has_more"):
|
|
break
|
|
except Exception:
|
|
logger.exception("wx_kf sync_msg 请求失败 open_kfid=%s", open_kfid)
|
|
return out
|
|
|
|
|
|
async def download_media(media_id: str) -> bytes | None:
|
|
"""临时素材下载(图片消息只给 media_id)。成功返回二进制, 失败返回 None。"""
|
|
if not media_id:
|
|
return None
|
|
access_token = await _get_access_token()
|
|
if not access_token:
|
|
return None
|
|
try:
|
|
async with httpx.AsyncClient(timeout=20) as client:
|
|
resp = await client.get(
|
|
f"{_QYAPI}/media/get",
|
|
params={"access_token": access_token, "media_id": media_id},
|
|
)
|
|
resp.raise_for_status()
|
|
# 成功=二进制文件流;失败=JSON(errcode)。据 Content-Type 区分。
|
|
ctype = resp.headers.get("Content-Type", "")
|
|
if "application/json" in ctype or "text/plain" in ctype:
|
|
logger.warning("wx_kf media/get 非文件响应: %s", resp.text[:200])
|
|
return None
|
|
return resp.content
|
|
except Exception:
|
|
logger.exception("wx_kf media/get 下载失败 media_id=%s", media_id)
|
|
return None
|