Files
gaokao-volunteer-system/data/channel_sync/wechat_adapter.py
Hermes Agent 3f46811e49
Some checks failed
CI / pytest (Python 3.10) (push) Has been cancelled
CI / pytest (Python 3.11) (push) Has been cancelled
CI / pytest (Python 3.12) (push) Has been cancelled
release: cut v2.1
2026-06-13 14:49:58 +08:00

338 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""微信 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",
]