338 lines
10 KiB
Python
338 lines
10 KiB
Python
"""微信 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",
|
||
]
|