wechat_oa_client.py 4.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596
  1. """服务号传输。异常只记录错误码,不记录 URL/token/secret/微信响应原文。"""
  2. import threading
  3. import time
  4. import requests
  5. from app.core.config import settings
  6. class WechatAPIError(Exception):
  7. def __init__(self, code):
  8. self.code = int(code)
  9. super().__init__(f"微信接口错误码 {self.code}")
  10. class WechatDeliveryUnknown(Exception):
  11. """请求可能已被微信接收,不能盲目重发。"""
  12. class WechatOAClient:
  13. def __init__(self):
  14. self._lock = threading.Lock()
  15. self._token = None
  16. self._expires = 0
  17. self._identity = None
  18. def _json(self, method, endpoint, *, sending=False, **kwargs):
  19. try:
  20. response = requests.request(method, f"https://api.weixin.qq.com/{endpoint}", timeout=(5, 10), **kwargs)
  21. response.raise_for_status()
  22. data = response.json()
  23. if not isinstance(data, dict):
  24. raise ValueError("invalid response")
  25. except (requests.RequestException, ValueError):
  26. if sending:
  27. raise WechatDeliveryUnknown("微信发送结果不确定,请核对发送记录") from None
  28. raise WechatAPIError(-1) from None
  29. if data.get("errcode", 0):
  30. raise WechatAPIError(data["errcode"])
  31. return data
  32. def access_token(self, force=False):
  33. if not settings.WECHAT_OA_APPID or not settings.WECHAT_OA_SECRET:
  34. raise WechatAPIError(40125)
  35. with self._lock:
  36. identity = (settings.WECHAT_OA_APPID, settings.WECHAT_OA_SECRET)
  37. if not force and self._identity == identity and self._token and time.monotonic() < self._expires:
  38. return self._token
  39. # stable_token 不因多实例重复取 token 而作废其他实例的有效 token。
  40. data = self._json("POST", "cgi-bin/stable_token", json={
  41. "grant_type": "client_credential", "appid": settings.WECHAT_OA_APPID,
  42. "secret": settings.WECHAT_OA_SECRET, "force_refresh": False,
  43. })
  44. token = data.get("access_token")
  45. if not token:
  46. raise WechatAPIError(-1)
  47. self._token, self._identity = token, identity
  48. self._expires = time.monotonic() + max(1, int(data.get("expires_in", 7200)) - 300)
  49. return token
  50. def authenticated(self, method, endpoint, **kwargs):
  51. for force in (False, True):
  52. try:
  53. return self._json(method, endpoint, params={"access_token": self.access_token(force)}, **kwargs)
  54. except WechatAPIError as exc:
  55. if force or exc.code not in (40001, 40014, 42001):
  56. raise
  57. def subscribed(self, openid):
  58. # openid 放在独立参数中,避免打印请求异常泄露查询串。
  59. data = self.authenticated("POST", "cgi-bin/user/info/batchget", json={"user_list": [{"openid": openid, "lang": "zh_CN"}]})
  60. users = data.get("user_info_list") or []
  61. return bool(users and users[0].get("openid") == openid and users[0].get("subscribe") == 1)
  62. def oauth_openid(self, code):
  63. data = self._json("GET", "sns/oauth2/access_token", params={
  64. "appid": settings.WECHAT_OA_APPID, "secret": settings.WECHAT_OA_SECRET,
  65. "code": code, "grant_type": "authorization_code",
  66. })
  67. if not data.get("openid"):
  68. raise WechatAPIError(-1)
  69. return data["openid"]
  70. def send(self, *, openid, template_id, data, event_key, page="", url=""):
  71. payload = {"touser": openid, "template_id": template_id, "data": data, "client_msg_id": event_key}
  72. if page and settings.WECHAT_OA_MINI_APPID:
  73. payload["miniprogram"] = {"appid": settings.WECHAT_OA_MINI_APPID, "pagepath": page}
  74. if url:
  75. payload["url"] = url
  76. result = self.authenticated("POST", "cgi-bin/message/template/send", sending=True, json=payload)
  77. if not result.get("msgid"):
  78. raise WechatDeliveryUnknown("微信未返回消息编号,请核对发送记录")
  79. return str(result["msgid"])
  80. client = WechatOAClient()