wechat_mini_auth.py 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  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