Files
gaokao-volunteer-system/data/channel_sync/wechat_adapter.py

338 lines
10 KiB
Python
Raw Normal View History

2026-06-13 14:49:58 +08:00
"""微信 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",
]