""" 微信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, }