| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201 |
- """
- 微信JS-SDK服务
- 用于获取jsapi_ticket和生成签名
- """
- import hashlib
- import time
- import logging
- from typing import Optional, Dict, Callable, Awaitable
- import httpx
- from config import settings
- logger = logging.getLogger(__name__)
- class WechatJSAPIService:
- """微信JS-SDK服务"""
-
- def __init__(self, redis_getter: Optional[Callable[[], Awaitable]] = None):
- """
- 初始化服务
-
- Args:
- redis_getter: Redis客户端获取函数(可选,用于缓存)
- """
- self.redis_getter = redis_getter
- self.cache_key = settings.WECHAT_JSAPI_TICKET_CACHE_KEY
- self.cache_ttl = settings.WECHAT_JSAPI_TICKET_CACHE_TTL
-
- async def get_access_token(self) -> Optional[str]:
- """
- 获取微信access_token(用于获取jsapi_ticket)
-
- Returns:
- access_token字符串,失败返回None
- """
- if not settings.WECHAT_MP_APP_ID or not settings.WECHAT_MP_APP_SECRET:
- logger.error("微信公众平台AppID或AppSecret未配置,请检查环境变量 WECHAT_MP_APP_ID 和 WECHAT_MP_APP_SECRET")
- return None
-
- try:
- url = "https://api.weixin.qq.com/cgi-bin/token"
- params = {
- "grant_type": "client_credential",
- "appid": settings.WECHAT_MP_APP_ID, # 使用公众平台的AppID
- "secret": settings.WECHAT_MP_APP_SECRET, # 使用公众平台的AppSecret
- }
-
- async with httpx.AsyncClient(timeout=10.0) as client:
- response = await client.get(url, params=params)
- data = response.json()
-
- if "errcode" in data:
- logger.error(f"获取access_token失败: {data}")
- return None
-
- access_token = data.get("access_token")
- if not access_token:
- logger.error(f"access_token为空: {data}")
- return None
-
- return access_token
- except Exception as e:
- logger.error(f"获取access_token异常: {e}")
- return None
-
- async def get_jsapi_ticket(self, force_refresh: bool = False) -> Optional[str]:
- """
- 获取微信jsapi_ticket(带缓存)
-
- Args:
- force_refresh: 是否强制刷新(忽略缓存)
-
- Returns:
- jsapi_ticket字符串,失败返回None
- """
- # 先尝试从缓存获取
- if not force_refresh and self.redis_getter:
- try:
- redis = await self.redis_getter()
- cached_ticket = await redis.get(self.cache_key)
- if cached_ticket:
- logger.debug("从缓存获取jsapi_ticket")
- return cached_ticket.decode('utf-8') if isinstance(cached_ticket, bytes) else cached_ticket
- except Exception as e:
- logger.warning(f"从缓存获取jsapi_ticket失败: {e}")
-
- # 缓存未命中,从微信服务器获取
- access_token = await self.get_access_token()
- if not access_token:
- return None
-
- try:
- url = "https://api.weixin.qq.com/cgi-bin/ticket/getticket"
- params = {
- "type": "jsapi",
- "access_token": access_token,
- }
-
- async with httpx.AsyncClient(timeout=10.0) as client:
- response = await client.get(url, params=params)
- data = response.json()
-
- if data.get("errcode") != 0:
- logger.error(f"获取jsapi_ticket失败: {data}")
- return None
-
- ticket = data.get("ticket")
- if not ticket:
- logger.error(f"jsapi_ticket为空: {data}")
- return None
-
- # 保存到缓存
- if self.redis_getter:
- try:
- redis = await self.redis_getter()
- await redis.setex(self.cache_key, self.cache_ttl, ticket)
- logger.debug(f"jsapi_ticket已缓存,TTL={self.cache_ttl}秒")
- except Exception as e:
- logger.warning(f"缓存jsapi_ticket失败: {e}")
-
- return ticket
- except Exception as e:
- logger.error(f"获取jsapi_ticket异常: {e}")
- return None
-
- def generate_signature(self, jsapi_ticket: str, noncestr: str, timestamp: int, url: str) -> str:
- """
- 生成微信JS-SDK签名
-
- Args:
- jsapi_ticket: jsapi_ticket
- noncestr: 随机字符串
- timestamp: 时间戳
- url: 当前页面URL(不包含#及其后面部分)
-
- Returns:
- SHA1签名字符串
- """
- import urllib.parse
-
- # 1. URL解码(如果前端进行了编码)
- try:
- decoded_url = urllib.parse.unquote(url)
- except Exception:
- decoded_url = url
-
- # 2. 清理URL(移除#及其后面部分)
- clean_url = decoded_url.split('#')[0]
-
- # 3. 确保URL格式正确(微信要求完整URL,包含协议)
- if not clean_url.startswith('http://') and not clean_url.startswith('https://'):
- logger.warning(f"URL格式可能不正确: {clean_url}")
-
- # 4. 按照微信要求拼接字符串
- string = f"jsapi_ticket={jsapi_ticket}&noncestr={noncestr}×tamp={timestamp}&url={clean_url}"
-
- logger.debug(f"签名字符串: {string[:100]}...") # 只记录前100个字符,避免泄露敏感信息
-
- # 5. SHA1加密
- signature = hashlib.sha1(string.encode('utf-8')).hexdigest()
-
- logger.debug(f"生成的签名: {signature[:20]}...")
-
- return signature
-
- async def get_jsapi_config(self, url: str) -> Optional[Dict[str, any]]:
- """
- 获取微信JS-SDK配置(包含签名)
-
- Args:
- url: 当前页面URL(用于生成签名)
-
- Returns:
- 配置字典,包含appId, timestamp, nonceStr, signature
- 失败返回None
- """
- if not settings.WECHAT_MP_APP_ID:
- logger.error("微信公众平台AppID未配置,请检查环境变量 WECHAT_MP_APP_ID")
- return None
-
- # 获取jsapi_ticket
- jsapi_ticket = await self.get_jsapi_ticket()
- if not jsapi_ticket:
- logger.error("获取jsapi_ticket失败")
- return None
-
- # 生成随机字符串和时间戳
- import secrets
- noncestr = secrets.token_urlsafe(16)
- timestamp = int(time.time())
-
- # 生成签名
- signature = self.generate_signature(jsapi_ticket, noncestr, timestamp, url)
-
- return {
- "appId": settings.WECHAT_MP_APP_ID, # 使用公众平台的AppID
- "timestamp": timestamp,
- "nonceStr": noncestr,
- "signature": signature,
- }
|