wechat_jsapi_service.py 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. """
  2. 微信JS-SDK服务
  3. 用于获取jsapi_ticket和生成签名
  4. """
  5. import hashlib
  6. import time
  7. import logging
  8. from typing import Optional, Dict, Callable, Awaitable
  9. import httpx
  10. from config import settings
  11. logger = logging.getLogger(__name__)
  12. class WechatJSAPIService:
  13. """微信JS-SDK服务"""
  14. def __init__(self, redis_getter: Optional[Callable[[], Awaitable]] = None):
  15. """
  16. 初始化服务
  17. Args:
  18. redis_getter: Redis客户端获取函数(可选,用于缓存)
  19. """
  20. self.redis_getter = redis_getter
  21. self.cache_key = settings.WECHAT_JSAPI_TICKET_CACHE_KEY
  22. self.cache_ttl = settings.WECHAT_JSAPI_TICKET_CACHE_TTL
  23. async def get_access_token(self) -> Optional[str]:
  24. """
  25. 获取微信access_token(用于获取jsapi_ticket)
  26. Returns:
  27. access_token字符串,失败返回None
  28. """
  29. if not settings.WECHAT_MP_APP_ID or not settings.WECHAT_MP_APP_SECRET:
  30. logger.error("微信公众平台AppID或AppSecret未配置,请检查环境变量 WECHAT_MP_APP_ID 和 WECHAT_MP_APP_SECRET")
  31. return None
  32. try:
  33. url = "https://api.weixin.qq.com/cgi-bin/token"
  34. params = {
  35. "grant_type": "client_credential",
  36. "appid": settings.WECHAT_MP_APP_ID, # 使用公众平台的AppID
  37. "secret": settings.WECHAT_MP_APP_SECRET, # 使用公众平台的AppSecret
  38. }
  39. async with httpx.AsyncClient(timeout=10.0) as client:
  40. response = await client.get(url, params=params)
  41. data = response.json()
  42. if "errcode" in data:
  43. logger.error(f"获取access_token失败: {data}")
  44. return None
  45. access_token = data.get("access_token")
  46. if not access_token:
  47. logger.error(f"access_token为空: {data}")
  48. return None
  49. return access_token
  50. except Exception as e:
  51. logger.error(f"获取access_token异常: {e}")
  52. return None
  53. async def get_jsapi_ticket(self, force_refresh: bool = False) -> Optional[str]:
  54. """
  55. 获取微信jsapi_ticket(带缓存)
  56. Args:
  57. force_refresh: 是否强制刷新(忽略缓存)
  58. Returns:
  59. jsapi_ticket字符串,失败返回None
  60. """
  61. # 先尝试从缓存获取
  62. if not force_refresh and self.redis_getter:
  63. try:
  64. redis = await self.redis_getter()
  65. cached_ticket = await redis.get(self.cache_key)
  66. if cached_ticket:
  67. logger.debug("从缓存获取jsapi_ticket")
  68. return cached_ticket.decode('utf-8') if isinstance(cached_ticket, bytes) else cached_ticket
  69. except Exception as e:
  70. logger.warning(f"从缓存获取jsapi_ticket失败: {e}")
  71. # 缓存未命中,从微信服务器获取
  72. access_token = await self.get_access_token()
  73. if not access_token:
  74. return None
  75. try:
  76. url = "https://api.weixin.qq.com/cgi-bin/ticket/getticket"
  77. params = {
  78. "type": "jsapi",
  79. "access_token": access_token,
  80. }
  81. async with httpx.AsyncClient(timeout=10.0) as client:
  82. response = await client.get(url, params=params)
  83. data = response.json()
  84. if data.get("errcode") != 0:
  85. logger.error(f"获取jsapi_ticket失败: {data}")
  86. return None
  87. ticket = data.get("ticket")
  88. if not ticket:
  89. logger.error(f"jsapi_ticket为空: {data}")
  90. return None
  91. # 保存到缓存
  92. if self.redis_getter:
  93. try:
  94. redis = await self.redis_getter()
  95. await redis.setex(self.cache_key, self.cache_ttl, ticket)
  96. logger.debug(f"jsapi_ticket已缓存,TTL={self.cache_ttl}秒")
  97. except Exception as e:
  98. logger.warning(f"缓存jsapi_ticket失败: {e}")
  99. return ticket
  100. except Exception as e:
  101. logger.error(f"获取jsapi_ticket异常: {e}")
  102. return None
  103. def generate_signature(self, jsapi_ticket: str, noncestr: str, timestamp: int, url: str) -> str:
  104. """
  105. 生成微信JS-SDK签名
  106. Args:
  107. jsapi_ticket: jsapi_ticket
  108. noncestr: 随机字符串
  109. timestamp: 时间戳
  110. url: 当前页面URL(不包含#及其后面部分)
  111. Returns:
  112. SHA1签名字符串
  113. """
  114. import urllib.parse
  115. # 1. URL解码(如果前端进行了编码)
  116. try:
  117. decoded_url = urllib.parse.unquote(url)
  118. except Exception:
  119. decoded_url = url
  120. # 2. 清理URL(移除#及其后面部分)
  121. clean_url = decoded_url.split('#')[0]
  122. # 3. 确保URL格式正确(微信要求完整URL,包含协议)
  123. if not clean_url.startswith('http://') and not clean_url.startswith('https://'):
  124. logger.warning(f"URL格式可能不正确: {clean_url}")
  125. # 4. 按照微信要求拼接字符串
  126. string = f"jsapi_ticket={jsapi_ticket}&noncestr={noncestr}&timestamp={timestamp}&url={clean_url}"
  127. logger.debug(f"签名字符串: {string[:100]}...") # 只记录前100个字符,避免泄露敏感信息
  128. # 5. SHA1加密
  129. signature = hashlib.sha1(string.encode('utf-8')).hexdigest()
  130. logger.debug(f"生成的签名: {signature[:20]}...")
  131. return signature
  132. async def get_jsapi_config(self, url: str) -> Optional[Dict[str, any]]:
  133. """
  134. 获取微信JS-SDK配置(包含签名)
  135. Args:
  136. url: 当前页面URL(用于生成签名)
  137. Returns:
  138. 配置字典,包含appId, timestamp, nonceStr, signature
  139. 失败返回None
  140. """
  141. if not settings.WECHAT_MP_APP_ID:
  142. logger.error("微信公众平台AppID未配置,请检查环境变量 WECHAT_MP_APP_ID")
  143. return None
  144. # 获取jsapi_ticket
  145. jsapi_ticket = await self.get_jsapi_ticket()
  146. if not jsapi_ticket:
  147. logger.error("获取jsapi_ticket失败")
  148. return None
  149. # 生成随机字符串和时间戳
  150. import secrets
  151. noncestr = secrets.token_urlsafe(16)
  152. timestamp = int(time.time())
  153. # 生成签名
  154. signature = self.generate_signature(jsapi_ticket, noncestr, timestamp, url)
  155. return {
  156. "appId": settings.WECHAT_MP_APP_ID, # 使用公众平台的AppID
  157. "timestamp": timestamp,
  158. "nonceStr": noncestr,
  159. "signature": signature,
  160. }