wechat_mini_auth.py 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174
  1. """微信小程序登录:code2session、手机号快速验证。"""
  2. from __future__ import annotations
  3. import logging
  4. import re
  5. from typing import Any, Dict, Optional
  6. import httpx
  7. from app.core.config import settings
  8. logger = logging.getLogger(__name__)
  9. _WX_CODE2SESSION = "https://api.weixin.qq.com/sns/jscode2session"
  10. _WX_TOKEN = "https://api.weixin.qq.com/cgi-bin/token"
  11. _WX_PHONE = "https://api.weixin.qq.com/wxa/business/getuserphonenumber"
  12. class WechatMiniAuthError(Exception):
  13. def __init__(self, message: str, *, wx_errcode: Optional[int] = None):
  14. super().__init__(message)
  15. self.wx_errcode = wx_errcode
  16. def normalize_cn_mobile(raw: Optional[str]) -> str:
  17. if raw is None:
  18. return ""
  19. s = re.sub(r"\s+", "", str(raw).strip())
  20. if s.startswith("+86"):
  21. s = s[3:]
  22. elif s.startswith("86") and len(s) == 13:
  23. s = s[2:]
  24. return s
  25. def phones_match(account_phone: Optional[str], authorized_phone: Optional[str]) -> bool:
  26. """账号手机号与本次微信授权手机号是否一致(规范化后比较)。"""
  27. a = normalize_cn_mobile(account_phone)
  28. b = normalize_cn_mobile(authorized_phone)
  29. return bool(a) and bool(b) and a == b
  30. def _require_mini_credentials() -> tuple:
  31. appid = (settings.WECHAT_MP_APPID or "").strip()
  32. secret = (settings.WECHAT_MP_SECRET or "").strip()
  33. if not appid or not secret:
  34. raise WechatMiniAuthError("未配置小程序凭证(WECHAT_MP_APPID / WECHAT_MP_SECRET)")
  35. return appid, secret
  36. def code2session(login_code: str) -> Dict[str, Any]:
  37. code = (login_code or "").strip()
  38. if not code:
  39. raise WechatMiniAuthError("loginCode 不能为空")
  40. appid, secret = _require_mini_credentials()
  41. with httpx.Client(timeout=15.0) as client:
  42. r = client.get(
  43. _WX_CODE2SESSION,
  44. params={
  45. "appid": appid,
  46. "secret": secret,
  47. "js_code": code,
  48. "grant_type": "authorization_code",
  49. },
  50. )
  51. r.raise_for_status()
  52. data = r.json()
  53. if data.get("errcode"):
  54. logger.warning("code2session failed: %s", data)
  55. raise WechatMiniAuthError(
  56. "微信登录凭证无效或已过期", wx_errcode=data.get("errcode")
  57. )
  58. openid = data.get("openid")
  59. if not openid:
  60. raise WechatMiniAuthError("微信未返回 openid")
  61. return {
  62. "openid": openid,
  63. "session_key": data.get("session_key"),
  64. "unionid": data.get("unionid"),
  65. }
  66. def _get_access_token() -> str:
  67. appid, secret = _require_mini_credentials()
  68. with httpx.Client(timeout=15.0) as client:
  69. r = client.get(
  70. _WX_TOKEN,
  71. params={
  72. "grant_type": "client_credential",
  73. "appid": appid,
  74. "secret": secret,
  75. },
  76. )
  77. r.raise_for_status()
  78. data = r.json()
  79. token = data.get("access_token")
  80. if not token:
  81. logger.warning("get access_token failed: %s", data)
  82. raise WechatMiniAuthError(
  83. "获取微信 access_token 失败", wx_errcode=data.get("errcode")
  84. )
  85. return token
  86. def get_phone_number(phone_code: str) -> str:
  87. code = (phone_code or "").strip()
  88. if not code:
  89. raise WechatMiniAuthError("phoneCode 不能为空")
  90. token = _get_access_token()
  91. with httpx.Client(timeout=15.0) as client:
  92. r = client.post(
  93. f"{_WX_PHONE}?access_token={token}",
  94. json={"code": code},
  95. )
  96. r.raise_for_status()
  97. data = r.json()
  98. if data.get("errcode") not in (0, None):
  99. logger.warning("getuserphonenumber failed: %s", data)
  100. raise WechatMiniAuthError(
  101. "获取手机号失败,请改用账号密码登录",
  102. wx_errcode=data.get("errcode"),
  103. )
  104. info = data.get("phone_info") or {}
  105. phone = normalize_cn_mobile(
  106. info.get("purePhoneNumber") or info.get("phoneNumber")
  107. )
  108. if not phone:
  109. raise WechatMiniAuthError("微信未返回手机号")
  110. return phone
  111. _WX_WXACODE_UNLIMITED = "https://api.weixin.qq.com/wxa/getwxacodeunlimit"
  112. def get_unlimited_wxacode(
  113. *,
  114. scene: str,
  115. page: str,
  116. env_version: Optional[str] = None,
  117. ) -> bytes:
  118. """获取不限制小程序码 PNG/JPEG 字节;失败抛 WechatMiniAuthError。"""
  119. sc = (scene or "").strip()
  120. if not sc or len(sc) > 32:
  121. raise WechatMiniAuthError("scene 无效或超过 32 字符")
  122. pg = (page or "").strip()
  123. if not pg:
  124. raise WechatMiniAuthError("page 不能为空")
  125. ver = (env_version or getattr(settings, "WX_QR_ENV_VERSION", None) or "release").strip()
  126. if ver not in ("release", "trial", "develop"):
  127. ver = "release"
  128. token = _get_access_token()
  129. with httpx.Client(timeout=20.0) as client:
  130. r = client.post(
  131. f"{_WX_WXACODE_UNLIMITED}?access_token={token}",
  132. json={
  133. "scene": sc,
  134. "page": pg,
  135. "check_path": False,
  136. "env_version": ver,
  137. "width": 280,
  138. },
  139. )
  140. r.raise_for_status()
  141. ctype = (r.headers.get("Content-Type") or "").lower()
  142. if "application/json" in ctype:
  143. data = r.json()
  144. logger.warning("getwxacodeunlimit failed: %s", data)
  145. raise WechatMiniAuthError(
  146. "生成小程序码失败",
  147. wx_errcode=data.get("errcode"),
  148. )
  149. if not r.content:
  150. raise WechatMiniAuthError("生成小程序码失败:空响应")
  151. return r.content