| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174 |
- """微信小程序登录:code2session、手机号快速验证。"""
- from __future__ import annotations
- import logging
- import re
- from typing import Any, Dict, Optional
- import httpx
- from app.core.config import settings
- logger = logging.getLogger(__name__)
- _WX_CODE2SESSION = "https://api.weixin.qq.com/sns/jscode2session"
- _WX_TOKEN = "https://api.weixin.qq.com/cgi-bin/token"
- _WX_PHONE = "https://api.weixin.qq.com/wxa/business/getuserphonenumber"
- class WechatMiniAuthError(Exception):
- def __init__(self, message: str, *, wx_errcode: Optional[int] = None):
- super().__init__(message)
- self.wx_errcode = wx_errcode
- def normalize_cn_mobile(raw: Optional[str]) -> str:
- if raw is None:
- return ""
- s = re.sub(r"\s+", "", str(raw).strip())
- if s.startswith("+86"):
- s = s[3:]
- elif s.startswith("86") and len(s) == 13:
- s = s[2:]
- return s
- def phones_match(account_phone: Optional[str], authorized_phone: Optional[str]) -> bool:
- """账号手机号与本次微信授权手机号是否一致(规范化后比较)。"""
- a = normalize_cn_mobile(account_phone)
- b = normalize_cn_mobile(authorized_phone)
- return bool(a) and bool(b) and a == b
- def _require_mini_credentials() -> tuple:
- appid = (settings.WXPAY_MINI_APPID or "").strip()
- secret = (settings.WXPAY_SECRET or "").strip()
- if not appid or not secret:
- raise WechatMiniAuthError("未配置小程序凭证(WXPAY_MINI_APPID / WXPAY_SECRET)")
- return appid, secret
- def code2session(login_code: str) -> Dict[str, Any]:
- code = (login_code or "").strip()
- if not code:
- raise WechatMiniAuthError("loginCode 不能为空")
- appid, secret = _require_mini_credentials()
- with httpx.Client(timeout=15.0) as client:
- r = client.get(
- _WX_CODE2SESSION,
- params={
- "appid": appid,
- "secret": secret,
- "js_code": code,
- "grant_type": "authorization_code",
- },
- )
- r.raise_for_status()
- data = r.json()
- if data.get("errcode"):
- logger.warning("code2session failed: %s", data)
- raise WechatMiniAuthError(
- "微信登录凭证无效或已过期", wx_errcode=data.get("errcode")
- )
- openid = data.get("openid")
- if not openid:
- raise WechatMiniAuthError("微信未返回 openid")
- return {
- "openid": openid,
- "session_key": data.get("session_key"),
- "unionid": data.get("unionid"),
- }
- def _get_access_token() -> str:
- appid, secret = _require_mini_credentials()
- with httpx.Client(timeout=15.0) as client:
- r = client.get(
- _WX_TOKEN,
- params={
- "grant_type": "client_credential",
- "appid": appid,
- "secret": secret,
- },
- )
- r.raise_for_status()
- data = r.json()
- token = data.get("access_token")
- if not token:
- logger.warning("get access_token failed: %s", data)
- raise WechatMiniAuthError(
- "获取微信 access_token 失败", wx_errcode=data.get("errcode")
- )
- return token
- def get_phone_number(phone_code: str) -> str:
- code = (phone_code or "").strip()
- if not code:
- raise WechatMiniAuthError("phoneCode 不能为空")
- token = _get_access_token()
- with httpx.Client(timeout=15.0) as client:
- r = client.post(
- f"{_WX_PHONE}?access_token={token}",
- json={"code": code},
- )
- r.raise_for_status()
- data = r.json()
- if data.get("errcode") not in (0, None):
- logger.warning("getuserphonenumber failed: %s", data)
- raise WechatMiniAuthError(
- "获取手机号失败,请改用账号密码登录",
- wx_errcode=data.get("errcode"),
- )
- info = data.get("phone_info") or {}
- phone = normalize_cn_mobile(
- info.get("purePhoneNumber") or info.get("phoneNumber")
- )
- if not phone:
- raise WechatMiniAuthError("微信未返回手机号")
- return phone
- _WX_WXACODE_UNLIMITED = "https://api.weixin.qq.com/wxa/getwxacodeunlimit"
- def get_unlimited_wxacode(
- *,
- scene: str,
- page: str,
- env_version: Optional[str] = None,
- ) -> bytes:
- """获取不限制小程序码 PNG/JPEG 字节;失败抛 WechatMiniAuthError。"""
- sc = (scene or "").strip()
- if not sc or len(sc) > 32:
- raise WechatMiniAuthError("scene 无效或超过 32 字符")
- pg = (page or "").strip()
- if not pg:
- raise WechatMiniAuthError("page 不能为空")
- ver = (env_version or getattr(settings, "WX_QR_ENV_VERSION", None) or "release").strip()
- if ver not in ("release", "trial", "develop"):
- ver = "release"
- token = _get_access_token()
- with httpx.Client(timeout=20.0) as client:
- r = client.post(
- f"{_WX_WXACODE_UNLIMITED}?access_token={token}",
- json={
- "scene": sc,
- "page": pg,
- "check_path": False,
- "env_version": ver,
- "width": 280,
- },
- )
- r.raise_for_status()
- ctype = (r.headers.get("Content-Type") or "").lower()
- if "application/json" in ctype:
- data = r.json()
- logger.warning("getwxacodeunlimit failed: %s", data)
- raise WechatMiniAuthError(
- "生成小程序码失败",
- wx_errcode=data.get("errcode"),
- )
- if not r.content:
- raise WechatMiniAuthError("生成小程序码失败:空响应")
- return r.content
|