import json import httpx from typing import Optional, Dict, Any, Union from mooc.core.config import settings import asyncio import time import logging import os logger = logging.getLogger(__name__) class WeChatAPI: def __init__(self, appid: str = None, appsecret: str = None): self.appid = appid or settings.WECHAT_APPID self.appsecret = appsecret or settings.WECHAT_APPSECRET self._access_token = "" self.timeout = 10.0 # 设置10秒超时 self.max_retries = 3 # 最大重试次数 async def _get(self, url: str) -> Dict[str, Any]: """带重试功能的GET请求""" retry_count = 0 last_error = None while retry_count < self.max_retries: try: logger.info(f"请求微信API: {url}") async with httpx.AsyncClient(verify=False, timeout=self.timeout) as client: response = await client.get(url) logger.info(f"微信API响应: {response.status_code}") return response.json() except (httpx.ConnectTimeout, httpx.ReadTimeout) as e: retry_count += 1 last_error = e logger.warning(f"请求微信API超时,第{retry_count}次重试: {str(e)}") if retry_count < self.max_retries: # 等待一段时间再重试,使用指数退避策略 await asyncio.sleep(1 * (2 ** (retry_count - 1))) # 所有重试都失败 logger.error(f"请求微信API失败,已重试{self.max_retries}次: {str(last_error)}") # 如果开发模式已启用,则返回模拟数据 if hasattr(settings, 'DEV_MODE') and settings.DEV_MODE: # 返回模拟数据 logger.info("使用模拟数据替代微信API响应") if 'jscode2session' in url: return { "openid": f"test_openid_{int(time.time())}", "session_key": "test_session_key", "unionid": f"test_unionid_{int(time.time())}" } elif 'token' in url: return {"access_token": "test_access_token", "expires_in": 7200} # 如果没有开发模式或者不是可以模拟的API,则抛出异常 raise last_error async def _post(self, url: str, data: Dict[str, Any]) -> Dict[str, Any]: """带重试功能的POST请求""" retry_count = 0 last_error = None while retry_count < self.max_retries: try: logger.info(f"POST请求微信API: {url}") async with httpx.AsyncClient(verify=False, timeout=self.timeout) as client: response = await client.post(url, json=data) logger.info(f"微信API响应: {response.status_code}") return response.json() except (httpx.ConnectTimeout, httpx.ReadTimeout) as e: retry_count += 1 last_error = e logger.warning(f"POST请求微信API超时,第{retry_count}次重试: {str(e)}") if retry_count < self.max_retries: await asyncio.sleep(1 * (2 ** (retry_count - 1))) logger.error(f"POST请求微信API失败,已重试{self.max_retries}次: {str(last_error)}") raise last_error async def get_access_token(self) -> str: """获取access token""" url = f"https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid={self.appid}&secret={self.appsecret}" result = await self._get(url) if "access_token" in result: self._access_token = result["access_token"] return self._access_token raise Exception(f"Failed to get access token: {result}") async def code2session(self, code: str) -> Dict[str, Any]: """小程序登录凭证校验""" url = f"https://api.weixin.qq.com/sns/jscode2session?appid={self.appid}&secret={self.appsecret}&js_code={code}&grant_type=authorization_code" result = await self._get(url) if "errcode" in result and result["errcode"] != 0: raise Exception(f"Code2Session failed: {result['errmsg']},code:{code}") return result async def get_unlimited_qrcode(self, scene: str, access_token: str = None) -> bytes: """获取小程序码""" if not access_token: access_token = await self.get_access_token() url = f"https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={access_token}" data = {"scene": scene} async with httpx.AsyncClient() as client: response = await client.post(url, json=data, verify=False) return response.content async def send_template_message(self, data: Dict[str, Any], access_token: str = None) -> Dict[str, Any]: """发送订阅消息""" if not access_token: access_token = await self.get_access_token() url = f"https://api.weixin.qq.com/cgi-bin/message/wxopen/template/send?access_token={access_token}" return await self._post(url, data) async def get_callback_ip(self, access_token: str = None) -> Dict[str, Any]: """获取微信服务器IP地址""" if not access_token: access_token = await self.get_access_token() url = f"https://api.weixin.qq.com/cgi-bin/getcallbackip?access_token={access_token}" return await self._get(url) async def check_callback(self, access_token: str = None, action: str = "all") -> Dict[str, Any]: """检查回调配置""" if not access_token: access_token = await self.get_access_token() url = f"https://api.weixin.qq.com/cgi-bin/callback/check?access_token={access_token}" data = {"action": action, "check_operator": "DEFAULT"} return await self._post(url, data) async def create_menu(self, menu_data: Dict[str, Any], access_token: str = None) -> Dict[str, Any]: """创建自定义菜单""" if not access_token: access_token = await self.get_access_token() url = f"https://api.weixin.qq.com/cgi-bin/menu/create?access_token={access_token}" return await self._post(url, menu_data) async def get_menu(self, access_token: str = None) -> Dict[str, Any]: """获取自定义菜单""" if not access_token: access_token = await self.get_access_token() url = f"https://api.weixin.qq.com/cgi-bin/menu/get?access_token={access_token}" return await self._get(url) def get_oauth_url(self, redirect_uri: str, scope: str, state: str = "") -> str: """生成网页授权URL""" url = (f"https://open.weixin.qq.com/connect/oauth2/authorize?" f"appid={self.appid}&redirect_uri={redirect_uri}&response_type=code&" f"scope={scope}#wechat_redirect") if state: url += f"&state={state}" return url async def get_user_token(self, code: str) -> Dict[str, Any]: """获取用户访问令牌""" url = (f"https://api.weixin.qq.com/sns/oauth2/access_token?" f"appid={self.appid}&secret={self.appsecret}&code={code}&" f"grant_type=authorization_code") result = await self._get(url) if "access_token" not in result: return {"code": 4001, "message": "获取用户授权失败"} return result async def refresh_token(self, refresh_token: str) -> Dict[str, Any]: """刷新访问令牌""" url = (f"https://api.weixin.qq.com/sns/oauth2/refresh_token?" f"appid={self.appid}&grant_type=refresh_token&refresh_token={refresh_token}") return await self._get(url) async def check_user_token(self, access_token: str, openid: str) -> bool: """检查用户访问令牌是否有效""" url = f"https://api.weixin.qq.com/sns/auth?access_token={access_token}&openid={openid}" result = await self._get(url) return result.get("errcode", -1) == 0 async def get_user_info(self, access_token: str, openid: str, refresh_token: str = "") -> Dict[str, Any]: """获取用户信息""" if await self.check_user_token(access_token, openid): url = (f"https://api.weixin.qq.com/sns/userinfo?" f"access_token={access_token}&openid={openid}&lang=zh_CN") return await self._get(url) elif refresh_token: result = await self.refresh_token(refresh_token) new_token = result.get("access_token") if new_token: return await self.get_user_info(new_token, openid) return {"code": 4002, "message": "获取用户详细信息失败,请重新获取code"} async def save_unlimited_qrcode(self, uid: Union[str, int], path: str, filename: str, access_token: str = None) -> bool: """生成并保存小程序码到文件""" if not access_token: access_token = await self.get_access_token() url = f"https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token={access_token}" data = {"scene": f"uid={uid}"} retry_count = 0 last_error = None while retry_count < self.max_retries: try: async with httpx.AsyncClient(verify=False, timeout=self.timeout) as client: response = await client.post(url, json=data) if response.status_code == 200: # 确保目录存在 os.makedirs(os.path.dirname(f"{path}{filename}"), exist_ok=True) full_path = f"{path}{filename}" with open(full_path, 'wb') as f: f.write(response.content) return True except Exception as e: retry_count += 1 last_error = e logger.warning(f"生成二维码失败,第{retry_count}次重试: {str(e)}") if retry_count < self.max_retries: await asyncio.sleep(1 * (2 ** (retry_count - 1))) logger.error(f"生成二维码失败,已重试{self.max_retries}次: {str(last_error)}") return False wx_api = WeChatAPI()