"""微信服务号网页授权:构造授权链接 + code 换 openid + 拉 userinfo。 用于 CPS 落地页(coupon.shaguabijia.com)在微信内拿用户身份。流程(见微信文档): 授权链接(snsapi_base|snsapi_userinfo) → 回调带 code → sns/oauth2/access_token 换 openid → (userinfo 时)sns/userinfo 拿昵称头像。 注意: - 这里的 access_token 是【网页授权 token】,与基础 access_token(cgi-bin/token)不同, 走 sns/* 接口,不需要 IP 白名单。 - secret 只在服务器,不下发客户端。 - 用 WX_MP_APPID/SECRET(已认证服务号),区别于 WECHAT_APP_ID(App 移动应用)。 """ from __future__ import annotations from urllib.parse import quote import httpx from app.core.config import settings _API = "https://api.weixin.qq.com" _AUTHORIZE = "https://open.weixin.qq.com/connect/oauth2/authorize" _TIMEOUT = 10 class WxOauthError(Exception): """网页授权调用失败(凭证/网络/code 失效等)。调用方兜底为无 openid。""" def build_authorize_url(redirect_uri: str, scope: str, state: str) -> str: """构造网页授权跳转链接。scope: 'snsapi_base'(静默,仅 openid) | 'snsapi_userinfo'(昵称头像)。 微信要求 redirect_uri urlEncode、参数顺序固定、结尾 #wechat_redirect。 """ return ( f"{_AUTHORIZE}?appid={settings.WX_MP_APPID}" f"&redirect_uri={quote(redirect_uri, safe='')}" f"&response_type=code&scope={scope}&state={quote(state, safe='')}" f"#wechat_redirect" ) def exchange_code(code: str) -> dict: """code 换网页授权 access_token + openid。返回含 openid/access_token/scope(/unionid)。 code 5 分钟内一次性有效。失败(errcode)抛 WxOauthError。 """ resp = httpx.get( f"{_API}/sns/oauth2/access_token", params={ "appid": settings.WX_MP_APPID, "secret": settings.WX_MP_SECRET, "code": code, "grant_type": "authorization_code", }, timeout=_TIMEOUT, ) data = resp.json() if "openid" not in data: raise WxOauthError(f"exchange_code failed: {data}") return data def get_userinfo(access_token: str, openid: str) -> dict: """拉用户信息(nickname/headimgurl/unionid)。需 scope=snsapi_userinfo 的 access_token。""" resp = httpx.get( f"{_API}/sns/userinfo", params={"access_token": access_token, "openid": openid, "lang": "zh_CN"}, timeout=_TIMEOUT, ) resp.encoding = "utf-8" # 昵称含中文/emoji,强制 utf-8 避免乱码 data = resp.json() if "openid" not in data: raise WxOauthError(f"get_userinfo failed: {data}") return data