95 lines
4.0 KiB
Python
95 lines
4.0 KiB
Python
"""服务号传输。异常只记录错误码,不记录 URL/token/secret/微信响应原文。"""
|
|
import threading
|
|
import time
|
|
|
|
import requests
|
|
|
|
from app.core.config import settings
|
|
|
|
|
|
class WechatAPIError(Exception):
|
|
def __init__(self, code):
|
|
self.code = int(code)
|
|
super().__init__(f"微信接口错误码 {self.code}")
|
|
|
|
|
|
class WechatDeliveryUnknown(Exception):
|
|
"""请求可能已被微信接收,不能盲目重发。"""
|
|
|
|
|
|
class WechatOAClient:
|
|
def __init__(self):
|
|
self._lock = threading.Lock()
|
|
self._token = None
|
|
self._expires = 0
|
|
self._identity = None
|
|
|
|
def _json(self, method, endpoint, *, sending=False, **kwargs):
|
|
try:
|
|
response = requests.request(method, f"https://api.weixin.qq.com/{endpoint}", timeout=(5, 10), **kwargs)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
if not isinstance(data, dict):
|
|
raise ValueError("invalid response")
|
|
except (requests.RequestException, ValueError):
|
|
if sending:
|
|
raise WechatDeliveryUnknown("微信发送结果不确定,请核对发送记录") from None
|
|
raise WechatAPIError(-1) from None
|
|
if data.get("errcode", 0):
|
|
raise WechatAPIError(data["errcode"])
|
|
return data
|
|
|
|
def access_token(self, force=False):
|
|
if not settings.WECHAT_OA_APPID or not settings.WECHAT_OA_SECRET:
|
|
raise WechatAPIError(40125)
|
|
with self._lock:
|
|
identity = (settings.WECHAT_OA_APPID, settings.WECHAT_OA_SECRET)
|
|
if not force and self._identity == identity and self._token and time.monotonic() < self._expires:
|
|
return self._token
|
|
# stable_token 不因多实例重复取 token 而作废其他实例的有效 token。
|
|
data = self._json("POST", "cgi-bin/stable_token", json={
|
|
"grant_type": "client_credential", "appid": settings.WECHAT_OA_APPID,
|
|
"secret": settings.WECHAT_OA_SECRET, "force_refresh": False,
|
|
})
|
|
token = data.get("access_token")
|
|
if not token:
|
|
raise WechatAPIError(-1)
|
|
self._token, self._identity = token, identity
|
|
self._expires = time.monotonic() + max(1, int(data.get("expires_in", 7200)) - 300)
|
|
return token
|
|
|
|
def authenticated(self, method, endpoint, **kwargs):
|
|
for force in (False, True):
|
|
try:
|
|
return self._json(method, endpoint, params={"access_token": self.access_token(force)}, **kwargs)
|
|
except WechatAPIError as exc:
|
|
if force or exc.code not in (40001, 40014, 42001):
|
|
raise
|
|
|
|
def subscribed(self, openid):
|
|
# openid 放在独立参数中,避免打印请求异常泄露查询串。
|
|
data = self.authenticated("POST", "cgi-bin/user/info/batchget", json={"user_list": [{"openid": openid, "lang": "zh_CN"}]})
|
|
users = data.get("user_info_list") or []
|
|
return bool(users and users[0].get("openid") == openid and users[0].get("subscribe") == 1)
|
|
|
|
def oauth_openid(self, code):
|
|
data = self._json("GET", "sns/oauth2/access_token", params={
|
|
"appid": settings.WECHAT_OA_APPID, "secret": settings.WECHAT_OA_SECRET,
|
|
"code": code, "grant_type": "authorization_code",
|
|
})
|
|
if not data.get("openid"):
|
|
raise WechatAPIError(-1)
|
|
return data["openid"]
|
|
|
|
def send(self, *, openid, template_id, data, event_key, page=""):
|
|
payload = {"touser": openid, "template_id": template_id, "data": data, "client_msg_id": event_key}
|
|
if page and settings.WECHAT_OA_MINI_APPID:
|
|
payload["miniprogram"] = {"appid": settings.WECHAT_OA_MINI_APPID, "pagepath": page}
|
|
result = self.authenticated("POST", "cgi-bin/message/template/send", sending=True, json=payload)
|
|
if not result.get("msgid"):
|
|
raise WechatDeliveryUnknown("微信未返回消息编号,请核对发送记录")
|
|
return str(result["msgid"])
|
|
|
|
|
|
client = WechatOAClient()
|