feat: implement WeChat mini login with openid bind and phone match

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
zlwuandCursor committed 2026-08-10 16:24:19 +08:00
1 parent 89d9471f37
commit efd7efbc69
15 files changed
+524 -3

No files matched your search

+1
View File
@@ -50,6 +50,7 @@ DATABASE_URL=mysql+pymysql://root:password@localhost:3306/zkxw
# 微信支付配置示例
# 小程序支付使用 JSAPI(trade_type=JSAPI)
# 同时复用于小程序微信登录(code2session / 手机号):WXPAY_MINI_APPID + WXPAY_SECRET
# WXPAY_MCHID=1900000109
# WXPAY_MINI_APPID=wx1234567890abcdef
# WXPAY_SECRET=your-miniapp-secret
+2
View File
@@ -34,6 +34,7 @@ from app.api.cleanroom_garment import router as cleanroom_garment_router
from app.api.market import router as market_router
from app.api.pre_transit_sample import router as pre_transit_sample_router
from app.api.oa_approval import router as oa_approval_router
from app.api.mini_auth import router as mini_auth_router
__all__ = [
"public_router",
@@ -71,4 +72,5 @@ __all__ = [
"market_router",
"pre_transit_sample_router",
"oa_approval_router",
"mini_auth_router",
]
+130
View File
@@ -0,0 +1,130 @@
"""小程序鉴权:微信登录 / 绑定 openid。"""
from __future__ import annotations
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlalchemy.orm import Session
from app.core.auth import get_current_user_by_token
from app.core.database import get_db
from app.core.response import error_response, success_response
from app.crud.user import user_crud
from app.services.issue_user_tokens import issue_tokens_for_user
from app.services.wechat_mini_auth import (
WechatMiniAuthError,
code2session,
get_phone_number,
)
router = APIRouter(prefix="/mini/auth", tags=["小程序鉴权"])
async def _resolve_user_id(request: Request, db: Session) -> str:
authorization = request.headers.get("Authorization", "")
if not authorization.startswith("Bearer "):
raise HTTPException(status_code=401, detail="未授权")
token = authorization.replace("Bearer ", "")
current = await get_current_user_by_token(token=token, db=db)
user_id = current.get("user_id") or current.get("sub")
if not user_id:
raise HTTPException(status_code=401, detail="未授权")
return user_id
@router.post("/wechat-login", response_model=dict)
async def wechat_login(body: dict, db: Session = Depends(get_db)):
"""微信授权登录:优先 openid;未绑定则用手机号匹配员工并绑定。"""
login_code = (body.get("loginCode") or "").strip()
phone_code = (body.get("phoneCode") or "").strip() or None
try:
sess = code2session(login_code)
except WechatMiniAuthError as exc:
return error_response(400, str(exc))
openid = sess["openid"]
user = user_crud.get_by_wx_openid(db, openid)
if user:
try:
data = await issue_tokens_for_user(db, user)
except ValueError as exc:
return error_response(403, str(exc))
return success_response(data)
if not phone_code:
return error_response(400, "请授权手机号登录,或使用账号密码登录")
try:
phone = get_phone_number(phone_code)
except WechatMiniAuthError as exc:
return error_response(400, str(exc))
users = user_crud.list_active_by_phone_no(db, phone)
if not users:
return error_response(
401, "未找到与该手机号匹配的员工账号,请使用账号密码登录"
)
if len(users) > 1:
return error_response(400, "手机号对应多个账号,请使用账号密码登录")
user = users[0]
other = user_crud.get_by_wx_openid(db, openid)
if other and other.id != user.id:
return error_response(409, "该微信已绑定其他账号")
existing_oid = (getattr(user, "wx_openid", None) or "").strip()
if existing_oid and existing_oid != openid:
return error_response(409, "该账号已绑定其他微信,请联系管理员")
user.wx_openid = openid
user.update_time = datetime.now()
db.add(user)
db.commit()
db.refresh(user)
try:
data = await issue_tokens_for_user(db, user)
except ValueError as exc:
return error_response(403, str(exc))
return success_response(data)
@router.post("/wechat-bind", response_model=dict)
async def wechat_bind(
request: Request,
body: dict,
db: Session = Depends(get_db),
):
"""密码登录后绑定当前微信 openid(需 Bearer)。"""
try:
user_id = await _resolve_user_id(request, db)
except HTTPException:
return error_response(401, "未授权")
login_code = (body.get("loginCode") or "").strip()
try:
sess = code2session(login_code)
except WechatMiniAuthError as exc:
return error_response(400, str(exc))
openid = sess["openid"]
user = user_crud.get(db, user_id)
if not user or user.del_flag != 0:
return error_response(401, "用户不存在")
other = user_crud.get_by_wx_openid(db, openid)
if other and other.id != user.id:
return error_response(409, "该微信已绑定其他账号")
existing_oid = (getattr(user, "wx_openid", None) or "").strip()
if existing_oid == openid:
return success_response({"bound": True, "message": "已绑定"})
if existing_oid and existing_oid != openid:
return error_response(409, "该账号已绑定其他微信,请联系管理员")
user.wx_openid = openid
user.update_time = datetime.now()
db.add(user)
db.commit()
return success_response({"bound": True, "message": "绑定成功"})
+1
View File
@@ -134,6 +134,7 @@ class Settings(BaseSettings):
"/api/web/auth/login-rsa-public-key,"
"/api/web/auth/login,"
"/api/web/auth/refresh-token,"
"/api/mini/auth/wechat-login,"
"/api/web/admin/attachment/upload,"
"/api/web/admin/products/upload-image,"
"/api/web/admin/categories/upload-image,"
+24
View File
@@ -31,6 +31,30 @@ 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]:
phone = (phone_no or "").strip()
if not phone:
return []
return (
db.query(SysUser)
.filter(
SysUser.phone_no == phone,
SysUser.del_flag == 0,
SysUser.status == "1",
)
.all()
)
user_crud = _UserCRUDCompat(SysUser)
+2 -1
View File
@@ -13,7 +13,7 @@ from app.core.database import SessionLocal
from app.core.api_request_log_middleware import ApiRequestLogMiddleware
from app.core.api_crypto_middleware import ApiCryptoMiddleware
from app.models import models # noqa: F401
from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router
from app.api import public_router, menu_router, admin_router, workflow_router, basic_info_router, collection_protocol_router, leave_router, operations_router, ethnic_router, sample_reception_router, sample_production_reception_router, qc_router, research_donation_router, production_router, pilot_service_router, insurance_company_router, material_router, storage_release_review_router, sample_sms_notification_router, storage_entry_sms_notification_router, qc_sms_notification_router, protocol_sample_collection_router, research_donation_sample_collection_router, pilot_application_router, pilot_checkup_router, pilot_delivery_router, pilot_infusion_confirm_router, pilot_infusion_schedule_mgmt_router, outbound_release_review_router, clinical_payment_router, env_detect_router, cleanroom_garment_router, market_router, pre_transit_sample_router, oa_approval_router, mini_auth_router
@asynccontextmanager
@@ -133,6 +133,7 @@ app.include_router(cleanroom_garment_router, prefix=settings.API_V1_PREFIX)
app.include_router(market_router, prefix=settings.API_V1_PREFIX)
app.include_router(pre_transit_sample_router, prefix=settings.API_V1_PREFIX)
app.include_router(oa_approval_router, prefix=settings.API_V1_PREFIX)
app.include_router(mini_auth_router, prefix=settings.API_V1_PREFIX)
def _mask_secret(value: str) -> str:
+1
View File
@@ -31,6 +31,7 @@ class SysUser(Base):
nick_name = Column(String(30), nullable=False, comment="用户名")
email = Column(String(50), nullable=False, default="", comment="用户邮箱")
phone_no = Column(String(11), nullable=False, default="", comment="手机号码")
wx_openid = Column(String(64), nullable=True, comment="微信小程序 openid")
hire_date = Column(Date, nullable=True, comment="入职日期")
probation_months = Column(Integer, nullable=True, comment="试用期月数")
last_work_day = Column(Date, nullable=True, comment="最后工作日")
+102
View File
@@ -0,0 +1,102 @@
"""为已通过校验的 SysUser 签发与 web 登录一致的 JWT 会话包。"""
from __future__ import annotations
from typing import Optional
from sqlalchemy.orm import Session
from app.core.auth import (
create_access_token,
create_refresh_token,
store_refresh_token,
store_session,
)
from app.core.config import settings
from app.core.utils import generate_uuid
from app.models.models import SysUser
from app.services.web_auth_roles import (
get_user_role_names,
resolve_web_role_for_user,
user_has_active_roles,
)
def _avatar_url_field(user: SysUser) -> Optional[str]:
raw = getattr(user, "avatar", None)
if not raw:
return None
s = str(raw).strip()
return s if s else None
def build_user_info_payload(user: SysUser, web_role: str, role_names: list = None) -> dict:
phone = (getattr(user, "phone_no", None) or "").strip() or None
return {
"id": user.id,
"username": user.user_no,
"realName": user.nick_name,
"phone": phone,
"email": (getattr(user, "email", None) or "").strip() or None,
"hireDate": user.hire_date.strftime("%Y-%m-%d") if getattr(user, "hire_date", None) else None,
"probationMonths": getattr(user, "probation_months", None),
"lastWorkDay": user.last_work_day.strftime("%Y-%m-%d")
if getattr(user, "last_work_day", None)
else None,
"sex": getattr(user, "sex", None) or "0",
"remark": (getattr(user, "remark", None) or "").strip() or None,
"role": web_role,
"roleNames": role_names or [],
"regionId": user.dept_id,
"regionName": None,
"avatarUrl": _avatar_url_field(user),
}
def _jwt_user_payload(user: SysUser, web_role: str) -> dict:
return {
"sub": user.id,
"username": user.user_no,
"role": web_role,
"region_id": user.dept_id,
"real_name": user.nick_name,
}
async def issue_tokens_for_user(db: Session, user: SysUser) -> dict:
"""校验停用/角色后签发 token。失败抛 ValueError(中文 message)。"""
if user.status != "1":
raise ValueError("账号已被停用,请联系管理员")
if not user_has_active_roles(db, user.id):
raise ValueError("账号未分配角色,请联系管理员")
web_role = resolve_web_role_for_user(db, user.id)
role_names = get_user_role_names(db, user.id)
session_id = generate_uuid()
access_jti = generate_uuid()
refresh_jti = generate_uuid()
access_token = create_access_token(
data=_jwt_user_payload(user, web_role),
session_id=session_id,
token_jti=access_jti,
)
refresh_token = create_refresh_token(
data={
"sub": user.id,
"username": user.user_no,
"role": web_role,
},
session_id=session_id,
refresh_jti=refresh_jti,
)
await store_session(session_id=session_id, user_id=user.id)
await store_refresh_token(refresh_jti=refresh_jti, user_id=user.id)
return {
"token": access_token,
"refreshToken": refresh_token,
"expiresIn": settings.ACCESS_TOKEN_EXPIRE_MINUTES * 60,
"userInfo": build_user_info_payload(user, web_role, role_names),
}
+122
View File
@@ -0,0 +1,122 @@
"""微信小程序登录: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 _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
@@ -0,0 +1,15 @@
"""手机号规范化单测(微信登录匹配 sys_user.phone_no)。"""
from app.services.wechat_mini_auth import normalize_cn_mobile
def test_normalize_strips_plus_86():
assert normalize_cn_mobile("+8613800138000") == "13800138000"
def test_normalize_strips_spaces():
assert normalize_cn_mobile(" 138 0013 8000 ") == "13800138000"
def test_normalize_empty():
assert normalize_cn_mobile("") == ""
assert normalize_cn_mobile(None) == ""
+8
View File
@@ -0,0 +1,8 @@
-- 员工微信小程序 openid 绑定字段(微信授权登录)
-- MySQL 5.7:唯一索引允许多个 NULL
ALTER TABLE `sys_user`
ADD COLUMN `wx_openid` varchar(64) DEFAULT NULL COMMENT '微信小程序 openid' AFTER `phone_no`;
ALTER TABLE `sys_user`
ADD UNIQUE INDEX `uk_sys_user_wx_openid` (`wx_openid`);
+1 -1
View File
@@ -50,7 +50,7 @@
"quickapp" : {},
/* 小程序特有相关 */
"mp-weixin" : {
"appid" : "",
"appid" : "wxa261ad85f2c99b26",
"setting" : {
"urlCheck" : false
},
+62 -1
View File
@@ -74,7 +74,23 @@
<view class="login-divider">或</view>
<view class="login-wechat is-disabled">
<button
v-if="agreed"
class="login-wechat"
:class="{ 'is-disabled': loading }"
open-type="getPhoneNumber"
:disabled="loading"
@getphonenumber="onGetPhoneNumber"
>
<image class="login-wechat__icon" src="/static/wechat-logo.png" mode="aspectFit" />
<text>微信授权登录</text>
</button>
<view
v-else
class="login-wechat"
:class="{ 'is-disabled': loading }"
@click="onWechatGate"
>
<image class="login-wechat__icon" src="/static/wechat-logo.png" mode="aspectFit" />
<text>微信授权登录</text>
</view>
@@ -105,6 +121,7 @@
import {
isLoggedIn,
passwordLogin,
wechatLogin,
clearLegacyMockSessionIfNeeded
} from '../../utils/auth.js'
import { validateCredentials } from '../../utils/validate-credentials.js'
@@ -199,6 +216,50 @@
} finally {
this.loading = false
}
},
async onWechatGate() {
if (this.loading) return
await this.ensureAgree()
},
async onGetPhoneNumber(e) {
if (this.loading) return
if (!(await this.ensureAgree())) return
const detail = (e && e.detail) || {}
const errMsg = detail.errMsg || ''
if (errMsg && errMsg.indexOf('ok') === -1) {
uni.showToast({
title: '需要授权手机号,或请使用账号密码登录',
icon: 'none'
})
return
}
const phoneCode = detail.code
if (!phoneCode) {
uni.showToast({
title: '需要授权手机号,或请使用账号密码登录',
icon: 'none'
})
return
}
this.loading = true
try {
const loginRes = await new Promise((resolve, reject) => {
uni.login({ provider: 'weixin', success: resolve, fail: reject })
})
await wechatLogin({
loginCode: loginRes.code,
phoneCode
})
uni.showToast({ title: '登录成功', icon: 'success' })
setTimeout(() => this.goHome(), 400)
} catch (err) {
uni.showToast({
title: (err && err.message) || '登录失败',
icon: 'none'
})
} finally {
this.loading = false
}
}
}
}
+9
View File
@@ -159,6 +159,15 @@
font-size: 28rpx;
font-weight: 500;
color: var(--text-primary);
width: 100%;
margin: 0;
padding: 0;
line-height: normal;
box-sizing: border-box;
}
button.login-wechat::after {
border: none;
}
.login-wechat.is-disabled {
+44
View File
@@ -87,5 +87,49 @@ export async function passwordLogin({ username, password }) {
refreshToken: data.refreshToken,
userInfo: data.userInfo
})
// 密码登录成功后静默绑定微信(失败不影响登录)
bindWechatQuietly()
return data
}
/**
* 微信授权登录(openid 优先;未绑定需 phoneCode)。
*/
export async function wechatLogin({ loginCode, phoneCode }) {
const payload = { loginCode }
if (phoneCode) {
payload.phoneCode = phoneCode
}
const data = await post('/mini/auth/wechat-login', payload)
if (!data || !data.token) {
throw new Error('登录失败')
}
saveSession({
token: data.token,
refreshToken: data.refreshToken,
userInfo: data.userInfo
})
return data
}
/**
* 已登录态下用 wx.login code 绑定 openid;失败静默。
*/
export async function bindWechatQuietly() {
try {
const loginRes = await new Promise((resolve, reject) => {
uni.login({ provider: 'weixin', success: resolve, fail: reject })
})
const loginCode = loginRes && loginRes.code
if (!loginCode) return
const token = getToken()
if (!token) return
await post(
'/mini/auth/wechat-bind',
{ loginCode },
{ Authorization: 'Bearer ' + token }
)
} catch (e) {
console.warn('wechat bind skipped', e && e.message)
}
}