"""微信 SDK 最小集成层(T8.2) 目标:不依赖第三方微信 SDK,仅用标准库封装两类常用能力: - 订阅消息推送(/cgi-bin/message/subscribe/send) - 客服文本消息(/cgi-bin/message/custom/send) 设计取向: - access_token 自动缓存与过期刷新 - 纯标准库 HTTP 传输,便于单测注入 fake transport - 上游 errcode / HTTP 状态统一包装为结构化异常 """ from __future__ import annotations import json import os import time from collections.abc import Callable from dataclasses import dataclass from typing import Any, Optional, Protocol from urllib import error, parse, request DEFAULT_API_BASE = "https://api.weixin.qq.com" DEFAULT_TIMEOUT_SECONDS = 5.0 DEFAULT_TOKEN_REFRESH_SKEW_SECONDS = 60.0 class WeChatConfigError(ValueError): """微信配置缺失或非法。""" class WeChatAPIError(RuntimeError): """微信接口返回错误。""" def __init__( self, message: str, *, errcode: Optional[int] = None, errmsg: Optional[str] = None, http_status: Optional[int] = None, endpoint: Optional[str] = None, ) -> None: super().__init__(message) self.errcode = errcode self.errmsg = errmsg self.http_status = http_status self.endpoint = endpoint @dataclass(frozen=True) class WeChatConfig: app_id: str app_secret: str api_base: str = DEFAULT_API_BASE timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS token_refresh_skew_seconds: float = DEFAULT_TOKEN_REFRESH_SKEW_SECONDS def __post_init__(self) -> None: if not self.app_id: raise WeChatConfigError("GAOKAO_WECHAT_APP_ID 未设置") if not self.app_secret: raise WeChatConfigError("GAOKAO_WECHAT_APP_SECRET 未设置") if self.timeout_seconds <= 0: raise WeChatConfigError("timeout_seconds 必须大于 0") if self.token_refresh_skew_seconds < 0: raise WeChatConfigError("token_refresh_skew_seconds 不能为负") @dataclass class AccessToken: token: str expires_at: float def valid(self, now: float, skew_seconds: float) -> bool: return bool(self.token) and now < (self.expires_at - skew_seconds) class Transport(Protocol): def request( self, method: str, url: str, *, headers: Optional[dict[str, str]] = None, body: Optional[bytes] = None, timeout: float = DEFAULT_TIMEOUT_SECONDS, ) -> tuple[int, bytes]: ... class UrllibTransport: """基于 urllib 的标准库 HTTP 传输实现。""" def request( self, method: str, url: str, *, headers: Optional[dict[str, str]] = None, body: Optional[bytes] = None, timeout: float = DEFAULT_TIMEOUT_SECONDS, ) -> tuple[int, bytes]: req = request.Request(url=url, method=method.upper(), data=body) for key, value in (headers or {}).items(): req.add_header(key, value) try: with request.urlopen(req, timeout=timeout) as resp: return int(resp.status), resp.read() except error.HTTPError as e: return int(e.code), e.read() class WeChatClient: """微信开放接口最小客户端。""" def __init__( self, *, config: WeChatConfig, transport: Optional[Transport] = None, clock: Optional[Callable[[], float]] = None, ) -> None: self._config = config self._transport = transport or UrllibTransport() self._clock = clock or time.time self._access_token: Optional[AccessToken] = None @classmethod def from_env( cls, *, transport: Optional[Transport] = None, clock: Optional[Callable[[], float]] = None, ) -> "WeChatClient": return cls( config=WeChatConfig( app_id=os.environ.get("GAOKAO_WECHAT_APP_ID", ""), app_secret=os.environ.get("GAOKAO_WECHAT_APP_SECRET", ""), api_base=os.environ.get("GAOKAO_WECHAT_API_BASE", DEFAULT_API_BASE), timeout_seconds=float( os.environ.get( "GAOKAO_WECHAT_TIMEOUT_SECONDS", DEFAULT_TIMEOUT_SECONDS ) ), token_refresh_skew_seconds=float( os.environ.get( "GAOKAO_WECHAT_TOKEN_REFRESH_SKEW_SECONDS", DEFAULT_TOKEN_REFRESH_SKEW_SECONDS, ) ), ), transport=transport, clock=clock, ) def _now(self) -> float: return float(self._clock()) def _api_url(self, path: str) -> str: base = self._config.api_base.rstrip("/") return f"{base}/{path.lstrip('/')}" def _request_json( self, method: str, path: str, *, query: Optional[dict[str, Any]] = None, payload: Optional[dict[str, Any]] = None, ) -> dict[str, Any]: url = self._api_url(path) if query: url = f"{url}?{parse.urlencode(query)}" body = None headers: dict[str, str] = {} if payload is not None: body = json.dumps(payload, ensure_ascii=False).encode("utf-8") headers["Content-Type"] = "application/json; charset=utf-8" try: status, raw = self._transport.request( method.upper(), url, headers=headers, body=body, timeout=self._config.timeout_seconds, ) except Exception as e: raise WeChatAPIError( f"微信接口 transport_error: {type(e).__name__}: {e}", endpoint=path, ) from e try: data = json.loads(raw.decode("utf-8") or "{}") except Exception as e: # pragma: no cover - defensive raise WeChatAPIError( f"微信接口返回非 JSON: {e}", http_status=status, endpoint=path ) from e if status >= 400: raise WeChatAPIError( f"微信接口 HTTP {status}", http_status=status, endpoint=path, errmsg=data.get("errmsg") if isinstance(data, dict) else None, errcode=data.get("errcode") if isinstance(data, dict) else None, ) if not isinstance(data, dict): raise WeChatAPIError( "微信接口返回结构非法", http_status=status, endpoint=path, ) errcode = data.get("errcode", 0) if errcode not in (0, None): raise WeChatAPIError( f"微信接口错误: {errcode} {data.get('errmsg', '')}".strip(), errcode=int(errcode), errmsg=str(data.get("errmsg", "")), http_status=status, endpoint=path, ) return data def _get_access_token(self, *, force_refresh: bool = False) -> str: now = self._now() if ( not force_refresh and self._access_token is not None and self._access_token.valid(now, self._config.token_refresh_skew_seconds) ): return self._access_token.token data = self._request_json( "GET", "/cgi-bin/token", query={ "grant_type": "client_credential", "appid": self._config.app_id, "secret": self._config.app_secret, }, ) token = str(data.get("access_token", "")) if not token: raise WeChatAPIError( "微信接口未返回 access_token", http_status=200, endpoint="/cgi-bin/token", ) expires_in = float(data.get("expires_in", 7200)) self._access_token = AccessToken(token=token, expires_at=now + expires_in) return token @staticmethod def _normalize_msg_data(data: dict[str, Any]) -> dict[str, dict[str, Any]]: normalized: dict[str, dict[str, Any]] = {} for key, value in data.items(): if isinstance(value, dict) and "value" in value: normalized[key] = {"value": value["value"]} else: normalized[key] = {"value": value} return normalized def send_subscribe_message( self, *, openid: str, template_id: str, data: dict[str, Any], page: Optional[str] = None, miniprogram_state: Optional[str] = None, lang: Optional[str] = None, ) -> dict[str, Any]: token = self._get_access_token() payload: dict[str, Any] = { "touser": openid, "template_id": template_id, "data": self._normalize_msg_data(data), } if page: payload["page"] = page if miniprogram_state: payload["miniprogram_state"] = miniprogram_state if lang: payload["lang"] = lang return self._request_json( "POST", "/cgi-bin/message/subscribe/send", query={"access_token": token}, payload=payload, ) def send_custom_message( self, *, openid: str, msgtype: str, payload: dict[str, Any], kf_account: Optional[str] = None, ) -> dict[str, Any]: token = self._get_access_token() body: dict[str, Any] = {"touser": openid, "msgtype": msgtype, msgtype: payload} if kf_account: body["customservice"] = {"kf_account": kf_account} return self._request_json( "POST", "/cgi-bin/message/custom/send", query={"access_token": token}, payload=body, ) def send_custom_text( self, *, openid: str, content: str, kf_account: Optional[str] = None, ) -> dict[str, Any]: return self.send_custom_message( openid=openid, msgtype="text", payload={"content": content}, kf_account=kf_account, ) __all__ = [ "AccessToken", "DEFAULT_API_BASE", "DEFAULT_TIMEOUT_SECONDS", "DEFAULT_TOKEN_REFRESH_SKEW_SECONDS", "Transport", "UrllibTransport", "WeChatAPIError", "WeChatClient", "WeChatConfig", "WeChatConfigError", ]