feat(backend): add wechat mini auth helpers and user phone lookups.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
wuzilongandCursor committed 2026-09-18 11:09:04 +08:00
1 parent 7e7f9226cf
commit 3d003e8d37
6 files changed
+14101 -11770

No files matched your search

+23
View File
@@ -47,6 +47,29 @@ class _UserCRUDCompat(CRUDBase[SysUser]):
.first()
)
def get_by_wx_openid(self, db: Session, wx_openid: str) -> Optional[SysUser]:
oid = (wx_openid or "").strip()
if not oid:
return None
return (
db.query(SysUser)
.filter(SysUser.wx_openid == oid, SysUser.del_flag == 0)
.first()
)
def list_active_by_phone_no(self, db: Session, phone_no: str) -> List[SysUser]:
from app.services.wechat_mini_auth import normalize_cn_mobile
phone = normalize_cn_mobile(phone_no)
if not phone:
return []
rows = (
db.query(SysUser)
.filter(SysUser.del_flag == 0, SysUser.status == "1")
.all()
)
return [u for u in rows if normalize_cn_mobile(getattr(u, "phone_no", None)) == phone]
user_crud = _UserCRUDCompat(SysUser)
+129
View File
@@ -0,0 +1,129 @@
"""微信小程序登录: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.WECHAT_MP_APPID or "").strip()
secret = (settings.WECHAT_MP_SECRET or "").strip()
if not appid or not secret:
raise WechatMiniAuthError("未配置小程序凭证(WECHAT_MP_APPID / WECHAT_MP_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
File diff suppressed because it is too large. Load diff
+13052 -10894
View File
File diff suppressed because it is too large. Load diff
+48 -36
View File
@@ -52,8 +52,8 @@
"hash": "3f7807dffdde603ff65eeeed5883f115"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/config.py": {
"mtime": 1789271051.6514022,
"hash": "16f4ff37be052148d727a759e08f555f"
"mtime": 1789373467.5495024,
"hash": "5b2c6168e163e58236ada6b5ab8dd071"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/core/wechat_pay.py": {
"mtime": 1780108930.0,
@@ -188,8 +188,8 @@
"hash": "2ef1c6a59c58f94b060fb9d538fa11f7"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/models/pl_models.py": {
"mtime": 1789266659.770212,
"hash": "4da8e7e16181d4d2f50476e1e961565f"
"mtime": 1789562398.1298687,
"hash": "1457235b06908fcf04cac0ecb324f432"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/models/models.py": {
"mtime": 1786161240.0,
@@ -280,8 +280,8 @@
"hash": "ad4432fdb117062d34913e058e82fdf1"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/pl/freight.py": {
"mtime": 1787724338.0,
"hash": "cca47dbed4c57f3d6738fecae5f0500b"
"mtime": 1789654781.2961106,
"hash": "6e959d77a734e75998475c564350b090"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/pl/io_order.py": {
"mtime": 1787744772.0,
@@ -292,8 +292,8 @@
"hash": "02d09e09744437c12c4ffb0f552e0c51"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/pl/vehicle_fuel.py": {
"mtime": 1789221269.290904,
"hash": "27aee602b6b2ecdfb12e0e308799cab0"
"mtime": 1789655928.1170447,
"hash": "95cd29428afaa23d9a79ad21b5c62b8d"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/schemas/pl/base_data.py": {
"mtime": 1789266820.0755212,
@@ -564,8 +564,8 @@
"hash": "4d8029bbd8b95070a0409e5e772997fe"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/quote.py": {
"mtime": 1788874861.3405318,
"hash": "c8adf2d5a279ab53ac4d12794f879036"
"mtime": 1789698217.9624789,
"hash": "5e03ed7c553061e69023a9ae17da6936"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/customer_follow.py": {
"mtime": 1787300292.0,
@@ -620,8 +620,8 @@
"hash": "b472eb2a15bcb6efe9cf68398f882908"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/product.py": {
"mtime": 1788663105.0063384,
"hash": "f3bd549dd200248954b83fc8bacfccf5"
"mtime": 1789698215.9374895,
"hash": "959c2ff7b9e1c284aa2ca152ecacaba9"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/customer.py": {
"mtime": 1788874749.2997048,
@@ -636,8 +636,8 @@
"hash": "ccc481ae99fc6b0989f93f7b21524969"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contract.py": {
"mtime": 1789032008.3066862,
"hash": "24153ba7a6120e394e6d51284856bfd3"
"mtime": 1789698224.7728891,
"hash": "8a04615af4dfaaa3715d571035751fd5"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/crud/crm/contact.py": {
"mtime": 1787300292.0,
@@ -648,8 +648,8 @@
"hash": "f0cbbf78fec330a5d81ec209b85f6e1e"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/api/public.py": {
"mtime": 1786197474.0,
"hash": "0b474dfb38e4ddb5accea2aeb84ef9e9"
"mtime": 1789373465.851525,
"hash": "99670ea1643d1babca1dcde6e4361718"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/api/monitor.py": {
"mtime": 1787304454.0,
@@ -720,8 +720,8 @@
"hash": "553abb42d49d2ade2aa7db70c25f22c5"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/api/pl/freight.py": {
"mtime": 1788683786.9565156,
"hash": "111746cda147d43cb70e1f822083ea47"
"mtime": 1789655264.0642653,
"hash": "db82470c353c0e50d862e0cefcff5faa"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/api/pl/public_site.py": {
"mtime": 1785969013.0,
@@ -736,8 +736,8 @@
"hash": "6c178157eaa345c13147d09b80d1cc79"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/api/pl/vehicle_fuel.py": {
"mtime": 1789221269.2909205,
"hash": "c664a58dad1400c8e9d959bb3d41adeb"
"mtime": 1789655928.977467,
"hash": "1a9e05df666dc78296cef7dfce92d50b"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/api/pl/ocr.py": {
"mtime": 1784035957.0,
@@ -876,8 +876,8 @@
"hash": "7c83274934dc5fe587de43643535183f"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/services/pl_transport_stop_feedback_service.py": {
"mtime": 1787808558.0,
"hash": "b7195140f69e1b3ab637c66d4c8c41e5"
"mtime": 1789539990.9049704,
"hash": "b9a8a882a152a708c50068d9d3d51ac3"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/services/db_inventory_site_token.py": {
"mtime": 1782225270.0,
@@ -960,8 +960,8 @@
"hash": "06bfe0225c40d171b259a4c24eafdc24"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/services/pl_freight_settlement_service.py": {
"mtime": 1788683736.9360983,
"hash": "b87f8c208b79698dfa7e301beb330979"
"mtime": 1789655260.6589975,
"hash": "2c7b4a4285d6c599339b156618ad059b"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/services/pl_personnel_user_bind.py": {
"mtime": 1789266818.3101022,
@@ -976,8 +976,8 @@
"hash": "392b1a2c65bd876ab2c40a94c453a10c"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/services/pl_vehicle_fuel_service.py": {
"mtime": 1789221269.290894,
"hash": "9e000ee8c420ba0b1bf6cee39f244354"
"mtime": 1789656506.347816,
"hash": "31db87a5e292761d05000c843adb10ed"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/services/pl_vehicle_maintenance_service.py": {
"mtime": 1788878764.9085488,
@@ -1008,8 +1008,8 @@
"hash": "4222656212dd4cfef49a44ba32ff815e"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/services/mp_webview_ticket.py": {
"mtime": 1789373254.4287198,
"hash": "c301a6d2083fab1fc22c71db6187211c"
"mtime": 1789536234.6766896,
"hash": "099be22c3a8dfcc28f96c6101a17cbdb"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/services/pl_io_order_service.py": {
"mtime": 1787746009.0,
@@ -1096,8 +1096,8 @@
"hash": "e319775ece357114b4666c37f8943869"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/services/pl/freight_list_filters.py": {
"mtime": 1788683495.549828,
"hash": "b80f49991ee8d3bdd2f152b2412e176d"
"mtime": 1789655228.7287679,
"hash": "b731b081543fcb8c18b7f8b8ceb91ab9"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/app/services/pl/personnel_list_filters.py": {
"mtime": 1788685222.151241,
@@ -1328,8 +1328,8 @@
"hash": "cf0d903888bed6da71ace26f6ff97032"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_vehicle_fuel_schema.py": {
"mtime": 1789221269.2908795,
"hash": "26ee0e0e0a2ceab5950337e8a567c696"
"mtime": 1789656023.2436283,
"hash": "f275f6098d88d15bbc4bb5358af79871"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_id_card_age.py": {
"mtime": 1787140680.0,
@@ -1356,8 +1356,8 @@
"hash": "ab0e4f34ba23352b1539217b86477810"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_mp_webview_ticket.py": {
"mtime": 1789373251.195948,
"hash": "44804276b962376e2aca5864936b93f7"
"mtime": 1789536211.8726232,
"hash": "b032f53e541f0c032ae3c02c2fcfb9fc"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_delete_guard.py": {
"mtime": 1782687611.0,
@@ -1583,6 +1583,10 @@
"mtime": 1788679318.068224,
"hash": "1ff8f4da2f762c1bd1a2afe12039d86a"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_mp_webview_auth_api.py": {
"mtime": 1789373470.859986,
"hash": "8598410535959c1483f7e8f4623e7fb5"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_pound_qr_label_items.py": {
"mtime": 1789262900.8196542,
"hash": "5845c144fa1f5400a06613dfff66c0d1"
@@ -1655,6 +1659,10 @@
"mtime": 1788603988.6160898,
"hash": "d07391879a9f64397d26b87ae364e0ce"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_resolve_product_id_no_auto_create.py": {
"mtime": 1789698225.0437787,
"hash": "c2a15309f66bc4c9d8fcfc00575484af"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_dict_ensure.py": {
"mtime": 1787296131.0,
"hash": "6e2dc0f1608b406a93d23b1094f994e9"
@@ -1664,8 +1672,8 @@
"hash": "8fcd74a7c9ceeed56cc26842259dc8b9"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_pl_freight_list_filters.py": {
"mtime": 1788683495.7270317,
"hash": "0aab363e9f7486938094fe8bc4507543"
"mtime": 1789655317.1733906,
"hash": "35ac689504792e5d04cc178c4fccd203"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/tests/test_pl_brand_archive.py": {
"mtime": 1785453323.0,
@@ -1743,6 +1751,10 @@
"mtime": 1785469153.0,
"hash": "4274cc4a7467069efbd04d4215ecbf15"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/scripts/import_vehicle_fuel_register.py": {
"mtime": 1789562392.6117399,
"hash": "cd6902eba05c9319d01672c4adb85061"
},
"/Users/zlwu/work/00code/shanxia2.0/backend/scripts/migrate_crm_quote.py": {
"mtime": 1785455807.0,
"hash": "ebe7d542c8254547db2b7176baff2e47"
@@ -0,0 +1,26 @@
from app.services.wechat_mini_auth import normalize_cn_mobile, phones_match
def test_normalize_strips_plus86():
assert normalize_cn_mobile("+8613800138000") == "13800138000"
def test_normalize_strips_86_prefix_13_digits():
assert normalize_cn_mobile("8613800138000") == "13800138000"
def test_phones_match_equal():
assert phones_match("13800138000", "13800138000") is True
def test_phones_match_normalizes():
assert phones_match("13800138000", "+8613800138000") is True
def test_phones_match_mismatch():
assert phones_match("13800138000", "13900139000") is False
def test_phones_match_empty_fails():
assert phones_match("", "13800138000") is False
assert phones_match(None, "13800138000") is False