""" 微信公众号模板消息工具 通过微信服务号向关注用户发送模板消息。 前提条件: 1. 需要微信认证服务号(订阅号无模板消息权限) 2. 用户需关注该公众号,系统记录用户的 openid 3. 在公众号后台添加消息模板,获取 template_id 4. 复用 WECHAT_APP_ID / WECHAT_APP_SECRET(与 OAuth 登录同一应用) """ import logging import time from typing import Any, Dict, List, Optional import httpx from app.config import settings logger = logging.getLogger(__name__) class WechatMPMessage: """微信公众号模板消息""" TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token" SEND_URL = "https://api.weixin.qq.com/cgi-bin/message/template/send" _access_token: Optional[str] = None _token_expires_at: float = 0 @classmethod def is_configured(cls) -> bool: """检查是否已配置(仅检查 env,同步兼容)""" return bool( settings.WECHAT_APP_ID and settings.WECHAT_APP_SECRET and settings.WECHAT_MP_TEMPLATE_ID ) @classmethod async def is_configured_async(cls) -> bool: """检查是否已配置(通过 config_manager 三级获取)""" from app.config_manager import config_manager oauth_config = await config_manager.get_group("oauth_wechat") mp_config = await config_manager.get_group("notify_wechat_mp") return bool( oauth_config.get("app_id") and oauth_config.get("app_secret") and mp_config.get("template_id") ) @classmethod async def _get_access_token(cls) -> Optional[str]: """获取 access_token(带缓存)""" now = time.time() if cls._access_token and now < cls._token_expires_at: return cls._access_token from app.config_manager import config_manager oauth_config = await config_manager.get_group("oauth_wechat") app_id = oauth_config.get("app_id") app_secret = oauth_config.get("app_secret") if not (app_id and app_secret): logger.warning("微信 app_id/app_secret 未配置") return None try: async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.get( cls.TOKEN_URL, params={ "grant_type": "client_credential", "appid": app_id, "secret": app_secret, }, ) resp.raise_for_status() result = resp.json() if "access_token" in result: cls._access_token = result["access_token"] cls._token_expires_at = now + result.get("expires_in", 7200) - 300 logger.info("微信公众号 access_token 获取成功") return cls._access_token else: logger.error(f"获取微信公众号 access_token 失败: {result}") return None except Exception as e: logger.error(f"获取微信公众号 access_token 异常: {e}") return None @classmethod async def send_template( cls, openid: str, template_data: Dict[str, Dict[str, str]], template_id: str = None, url: str = None, miniprogram: Dict[str, str] = None, ) -> bool: """ 发送模板消息 Args: openid: 用户的 openid template_data: 模板数据,格式如: { "first": {"value": "通知标题", "color": "#173177"}, "keyword1": {"value": "内容1"}, "keyword2": {"value": "内容2"}, "remark": {"value": "备注信息"} } template_id: 模板ID,默认使用配置的 WECHAT_MP_TEMPLATE_ID url: 点击跳转链接,默认使用配置的 WECHAT_MP_URL miniprogram: 跳转小程序配置 {"appid": "...", "pagepath": "..."} """ if not await cls.is_configured_async(): logger.warning("微信公众号模板消息未配置,跳过发送") return False token = await cls._get_access_token() if not token: return False from app.config_manager import config_manager mp_config = await config_manager.get_group("notify_wechat_mp") try: payload: Dict[str, Any] = { "touser": openid, "template_id": template_id or mp_config.get("template_id") or settings.WECHAT_MP_TEMPLATE_ID, "data": template_data, } # 跳转链接 jump_url = url or mp_config.get("url") or settings.WECHAT_MP_URL if jump_url: payload["url"] = jump_url # 小程序跳转(优先级高于 url) mini = miniprogram mini_appid = mp_config.get("mini_appid") or settings.WECHAT_MP_MINI_APPID if not mini and mini_appid: mini = { "appid": mini_appid, "pagepath": mp_config.get("mini_page") or settings.WECHAT_MP_MINI_PAGE or "", } if mini: payload["miniprogram"] = mini async with httpx.AsyncClient(timeout=10.0) as client: resp = await client.post( f"{cls.SEND_URL}?access_token={token}", json=payload, ) resp.raise_for_status() result = resp.json() if result.get("errcode") == 0: logger.info(f"微信模板消息发送成功: {openid}") return True else: logger.error(f"微信模板消息发送失败: {result}") return False except Exception as e: logger.error(f"微信模板消息请求异常: {e}") return False @classmethod async def send_notification( cls, openid: str, title: str, content: str, remark: str = "", url: str = None, ) -> bool: """ 发送通知类模板消息(简化接口) 使用通用模板格式: first: 标题 keyword1: 通知内容 keyword2: 时间 remark: 备注 Args: openid: 用户的 openid title: 通知标题 content: 通知内容 remark: 备注信息 url: 点击跳转链接 """ import datetime now_str = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") app_name = settings.APP_NAME template_data = { "first": {"value": title, "color": "#173177"}, "keyword1": {"value": content}, "keyword2": {"value": now_str}, "remark": {"value": remark or f"来自 {app_name}"}, } return await cls.send_template(openid, template_data, url=url) @classmethod async def batch_send_notification( cls, openids: List[str], title: str, content: str, remark: str = "", url: str = None, ) -> int: """ 批量发送通知类模板消息 Args: openids: 用户 openid 列表 title: 通知标题 content: 通知内容 remark: 备注信息 url: 点击跳转链接 Returns: 成功发送的数量 """ success_count = 0 for openid in openids: try: ok = await cls.send_notification(openid, title, content, remark, url) if ok: success_count += 1 except Exception as e: logger.warning(f"微信模板消息发送失败 [{openid}]: {e}") return success_count