feat: 小程序微信手机号一键登录,替代账号密码输入
- 后端新增 /api/auth/wechat-login 接口,用手机号自动匹配系统用户 - 登录时自动绑定 open_id,为微信订阅消息通知做好准备 - 小程序登录页替换为微信手机号授权按钮,保留密码登录为备用入口 - 新增 get_user_by_mobile 按手机号查询用户方法 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
1a3b67c423
commit
feaf4298ca
@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
|
||||
|
||||
from backend.app.api.deps import get_auth_service, get_current_user
|
||||
from backend.app.db import get_db_session
|
||||
from backend.app.schemas.auth import LoginRequest
|
||||
from backend.app.schemas.auth import LoginRequest, WechatLoginRequest
|
||||
from backend.app.schemas.common import success_payload
|
||||
from backend.app.services.auth_service import AuthService
|
||||
from backend.app.services.wechat_notification_service import wechat_notification_service
|
||||
@ -42,6 +42,22 @@ def login(
|
||||
return success_payload(auth_service.login(payload.username, payload.password, payload.role_type, session))
|
||||
|
||||
|
||||
@router.post("/wechat-login")
|
||||
def wechat_login(
|
||||
payload: WechatLoginRequest, # 微信手机号登录请求体
|
||||
auth_service: AuthService = Depends(get_auth_service), # 注入认证服务
|
||||
session: Session = Depends(get_db_session), # 注入数据库会话
|
||||
) -> dict:
|
||||
"""微信手机号一键登录接口
|
||||
|
||||
用途:通过微信手机号授权登录,自动匹配系统用户并绑定 open_id。
|
||||
请求参数:WechatLoginRequest(code、encrypted_data、iv)。
|
||||
返回值:登录结果,包含 token 等认证信息。
|
||||
权限要求:无需认证(公开接口)。
|
||||
"""
|
||||
return success_payload(auth_service.wechat_login(payload.code, payload.phone_code, session))
|
||||
|
||||
|
||||
@router.get("/me")
|
||||
def me(
|
||||
current_user: dict = Depends(get_current_user), # 从 JWT Token 解析当前用户
|
||||
|
||||
@ -31,6 +31,19 @@ class SystemRepository:
|
||||
stmt = select(User).where(User.username == username)
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def get_user_by_mobile(self, session: Session, mobile: str) -> User | None:
|
||||
"""根据手机号查询启用状态的用户
|
||||
|
||||
参数:
|
||||
session: 数据库会话
|
||||
mobile: 手机号
|
||||
|
||||
返回:
|
||||
匹配的启用状态用户对象,不存在则返回 None
|
||||
"""
|
||||
stmt = select(User).where(User.mobile == mobile, User.status == 1)
|
||||
return session.execute(stmt).scalar_one_or_none()
|
||||
|
||||
def get_user(self, session: Session, user_id: int) -> User | None:
|
||||
"""根据 ID 查询用户
|
||||
|
||||
|
||||
@ -20,6 +20,16 @@ class LoginRequest(BaseModel):
|
||||
"""角色类型,区分不同登录角色(如 admin / sales),为空时由后端自动识别"""
|
||||
|
||||
|
||||
class WechatLoginRequest(BaseModel):
|
||||
"""微信手机号登录请求体,用于微信一键登录。"""
|
||||
|
||||
code: str
|
||||
"""wx.login() 获取的临时登录凭证"""
|
||||
|
||||
phone_code: str
|
||||
"""wx.getPhoneNumber 返回的授权 code(新版 API,用于换取手机号)"""
|
||||
|
||||
|
||||
class UserProfile(BaseModel):
|
||||
"""用户信息响应体,包含用户基本信息、角色权限和菜单数据。用于登录成功后返回用户画像。"""
|
||||
|
||||
|
||||
@ -7,8 +7,10 @@
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from urllib import error, request as urllib_request
|
||||
|
||||
from sqlalchemy.exc import SQLAlchemyError
|
||||
from sqlalchemy.orm import Session
|
||||
@ -152,6 +154,125 @@ class AuthService:
|
||||
|
||||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500)
|
||||
|
||||
def wechat_login(self, code: str, phone_code: str, session: Session | None = None) -> dict:
|
||||
"""微信手机号一键登录。
|
||||
|
||||
用 code 换取 session_key 和 open_id,用 phone_code 换取手机号,
|
||||
匹配系统用户并自动绑定 open_id。
|
||||
|
||||
Args:
|
||||
code: wx.login() 获取的临时凭证
|
||||
phone_code: wx.getPhoneNumber 返回的授权 code
|
||||
session: 数据库会话
|
||||
|
||||
Returns:
|
||||
用户信息字典(含 token),手机号未匹配时抛出异常
|
||||
|
||||
被调用路由: auth.py - POST /auth/wechat-login
|
||||
"""
|
||||
if session is None:
|
||||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500)
|
||||
|
||||
settings = self.settings
|
||||
if not settings.wechat_app_id or not settings.wechat_app_secret:
|
||||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="微信配置未就绪,请联系管理员", status_code=500)
|
||||
|
||||
try:
|
||||
# 1. 用 code 换取 session_key 和 open_id
|
||||
session_key, open_id = self._code2session(code, settings)
|
||||
if not session_key or not open_id:
|
||||
raise AppException(code=ErrorCode.UNAUTHORIZED, message="微信登录凭证无效", status_code=401)
|
||||
|
||||
# 2. 用 phone_code 换取手机号
|
||||
phone_number = self._get_phone_number(phone_code, settings)
|
||||
if not phone_number:
|
||||
raise AppException(code=ErrorCode.UNAUTHORIZED, message="手机号获取失败", status_code=401)
|
||||
|
||||
# 3. 用手机号匹配系统用户
|
||||
user = self.repository.get_user_by_mobile(session, phone_number)
|
||||
if user is None:
|
||||
raise AppException(code=ErrorCode.NOT_FOUND, message=f"手机号 {phone_number} 未注册系统账号", status_code=404)
|
||||
|
||||
# 4. 自动绑定 open_id
|
||||
user.open_id = open_id
|
||||
session.add(user)
|
||||
session.commit()
|
||||
|
||||
# 5. 加载角色并返回
|
||||
role = self.repository.get_role(session, user.role_id)
|
||||
if role is None:
|
||||
raise AppException(code=ErrorCode.NOT_FOUND, message="角色不存在", status_code=404)
|
||||
|
||||
return self._build_profile(session, user, role, include_token=True)
|
||||
except AppException:
|
||||
raise
|
||||
except SQLAlchemyError as exc:
|
||||
session.rollback()
|
||||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库异常", status_code=500) from exc
|
||||
except Exception as exc:
|
||||
raise AppException(code=ErrorCode.INTERNAL_ERROR, message=f"微信登录失败: {exc}", status_code=500) from exc
|
||||
|
||||
def _code2session(self, code: str, settings) -> tuple[str, str]:
|
||||
"""用 code 调用微信 code2Session 接口,获取 session_key 和 open_id。"""
|
||||
url = (
|
||||
f"https://api.weixin.qq.com/sns/jscode2session"
|
||||
f"?appid={settings.wechat_app_id}"
|
||||
f"&secret={settings.wechat_app_secret}"
|
||||
f"&js_code={code}"
|
||||
f"&grant_type=authorization_code"
|
||||
)
|
||||
req = urllib_request.Request(url=url, method="GET")
|
||||
try:
|
||||
with urllib_request.urlopen(req, timeout=10) as response:
|
||||
result = json.loads(response.read().decode("utf-8") or "{}")
|
||||
return result.get("session_key", ""), result.get("openid", "")
|
||||
except (error.HTTPError, error.URLError, json.JSONDecodeError):
|
||||
return "", ""
|
||||
|
||||
def _get_phone_number(self, phone_code: str, settings) -> str:
|
||||
"""用 phone_code 调用微信 getuserphonenumber 接口获取手机号。"""
|
||||
access_token = self._get_access_token(settings)
|
||||
if not access_token:
|
||||
return ""
|
||||
url = f"https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token={access_token}"
|
||||
body = json.dumps({"code": phone_code}).encode("utf-8")
|
||||
req = urllib_request.Request(url=url, data=body, headers={"Content-Type": "application/json"}, method="POST")
|
||||
try:
|
||||
with urllib_request.urlopen(req, timeout=10) as response:
|
||||
result = json.loads(response.read().decode("utf-8") or "{}")
|
||||
if result.get("errcode") == 0:
|
||||
return result.get("phone_info", {}).get("purePhoneNumber", "")
|
||||
except (error.HTTPError, error.URLError, json.JSONDecodeError):
|
||||
pass
|
||||
return ""
|
||||
|
||||
def _get_access_token(self, settings) -> str:
|
||||
"""获取微信 access_token(简单缓存)。"""
|
||||
now = time.time()
|
||||
if not hasattr(self, "_token_cache"):
|
||||
self._token_cache = {"token": "", "expires_at": 0}
|
||||
if self._token_cache["token"] and self._token_cache["expires_at"] > now + 60:
|
||||
return self._token_cache["token"]
|
||||
url = (
|
||||
f"https://api.weixin.qq.com/cgi-bin/token"
|
||||
f"?grant_type=client_credential"
|
||||
f"&appid={settings.wechat_app_id}"
|
||||
f"&secret={settings.wechat_app_secret}"
|
||||
)
|
||||
req = urllib_request.Request(url=url, method="GET")
|
||||
try:
|
||||
with urllib_request.urlopen(req, timeout=10) as response:
|
||||
result = json.loads(response.read().decode("utf-8") or "{}")
|
||||
token = result.get("access_token", "")
|
||||
expires_in = int(result.get("expires_in", 0))
|
||||
if token and expires_in > 0:
|
||||
self._token_cache["token"] = token
|
||||
self._token_cache["expires_at"] = now + expires_in
|
||||
return token
|
||||
except (error.HTTPError, error.URLError, json.JSONDecodeError):
|
||||
pass
|
||||
return ""
|
||||
|
||||
def get_me(self, token: str, session: Session | None = None) -> dict | None:
|
||||
"""根据 Token 获取当前用户信息(无需重新登录)。
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
{
|
||||
"pages": [
|
||||
"pages/login/login",
|
||||
"pages/login/login-password/login-password",
|
||||
"pages/my/my",
|
||||
"pages/driver/task-list/task-list",
|
||||
"pages/driver/task-detail/task-detail",
|
||||
|
||||
@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 账号密码登录页面(备用)
|
||||
* 职责:提供账号密码登录,后端自动识别角色,根据 role_code 跳转对应首页。
|
||||
* 作为微信手机号登录的备用入口。
|
||||
*/
|
||||
var auth = require("../../../utils/auth");
|
||||
|
||||
Page({
|
||||
data: {
|
||||
username: "",
|
||||
password: "",
|
||||
loading: false,
|
||||
message: "",
|
||||
},
|
||||
|
||||
handleInput: function (event) {
|
||||
var field = event.currentTarget.dataset.field;
|
||||
this.setData({ [field]: event.detail.value });
|
||||
},
|
||||
|
||||
handleLogin: async function () {
|
||||
var app = getApp();
|
||||
this.setData({ loading: true, message: "" });
|
||||
try {
|
||||
var data = await app.request({
|
||||
url: "/api/auth/login",
|
||||
method: "POST",
|
||||
data: {
|
||||
username: this.data.username,
|
||||
password: this.data.password,
|
||||
},
|
||||
});
|
||||
var role = data.role_code || "driver";
|
||||
app.globalData.authToken = data.token;
|
||||
app.globalData.userRole = role;
|
||||
auth.setToken(data.token);
|
||||
auth.setUser(data);
|
||||
|
||||
wx.showToast({
|
||||
title: "欢迎," + (data.real_name || data.username),
|
||||
icon: "success",
|
||||
});
|
||||
|
||||
var entry = role === "driver" ? "/pages/driver/task-list/task-list" : "/pages/manager/dashboard/dashboard";
|
||||
wx.switchTab({ url: entry });
|
||||
} catch (error) {
|
||||
this.setData({ message: error.message || "登录失败" });
|
||||
} finally {
|
||||
this.setData({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
goBack: function () {
|
||||
wx.navigateBack();
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,3 @@
|
||||
{
|
||||
"navigationBarTitleText": "账号密码登录"
|
||||
}
|
||||
@ -0,0 +1,23 @@
|
||||
<view class="page login-page">
|
||||
<view class="login-hero">
|
||||
<view class="hero-badge">Order System</view>
|
||||
<view class="hero-title">账号密码登录</view>
|
||||
<view class="hero-desc">输入账号密码,系统自动识别身份</view>
|
||||
</view>
|
||||
|
||||
<view class="card login-card">
|
||||
<view class="field-group">
|
||||
<view class="field-label">账号</view>
|
||||
<input class="input" value="{{username}}" data-field="username" bindinput="handleInput" placeholder="请输入账号" />
|
||||
</view>
|
||||
<view class="field-group">
|
||||
<view class="field-label">密码</view>
|
||||
<input class="input" password value="{{password}}" data-field="password" bindinput="handleInput" placeholder="请输入密码" />
|
||||
</view>
|
||||
<button class="primary-btn" type="primary" loading="{{loading}}" bindtap="handleLogin">
|
||||
{{ loading ? "登录中..." : "登录" }}
|
||||
</button>
|
||||
<view wx:if="{{message}}" class="message">{{message}}</view>
|
||||
<view class="back-link" bindtap="goBack">返回微信登录</view>
|
||||
</view>
|
||||
</view>
|
||||
@ -0,0 +1,79 @@
|
||||
.page {
|
||||
padding: 32rpx 28rpx 40rpx;
|
||||
}
|
||||
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
box-sizing: border-box;
|
||||
display: grid;
|
||||
gap: 32rpx;
|
||||
align-content: start;
|
||||
}
|
||||
|
||||
.login-hero {
|
||||
padding: 40rpx 8rpx 16rpx;
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
display: inline-block;
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 999rpx;
|
||||
background: rgba(37, 99, 235, 0.12);
|
||||
color: #1d4ed8;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.hero-title {
|
||||
font-size: 48rpx;
|
||||
font-weight: 800;
|
||||
color: #0f172a;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.hero-desc {
|
||||
font-size: 28rpx;
|
||||
line-height: 1.7;
|
||||
color: #64748b;
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
padding: 32rpx;
|
||||
display: grid;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: grid;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 24rpx;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
margin-top: 8rpx;
|
||||
font-size: 30rpx;
|
||||
border-radius: 22rpx;
|
||||
}
|
||||
|
||||
.message {
|
||||
color: #b91c1c;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.back-link {
|
||||
text-align: center;
|
||||
color: #64748b;
|
||||
font-size: 26rpx;
|
||||
padding: 16rpx 0;
|
||||
}
|
||||
@ -1,46 +1,47 @@
|
||||
/**
|
||||
* 统一登录页面
|
||||
* 职责:提供账号密码登录,后端自动识别角色,根据 role_code 跳转对应首页。
|
||||
* 统一登录页面(微信手机号一键登录)
|
||||
* 职责:微信手机号授权登录,自动匹配系统用户。保留账号密码登录作为备用。
|
||||
*/
|
||||
var auth = require("../../utils/auth");
|
||||
|
||||
Page({
|
||||
data: {
|
||||
username: "",
|
||||
password: "",
|
||||
loading: false,
|
||||
message: "",
|
||||
},
|
||||
|
||||
handleInput: function (event) {
|
||||
var field = event.currentTarget.dataset.field;
|
||||
this.setData({ [field]: event.detail.value });
|
||||
},
|
||||
|
||||
handleLogin: async function () {
|
||||
var app = getApp();
|
||||
handleGetPhoneNumber: async function (e) {
|
||||
if (e.detail.errMsg !== "getPhoneNumber:ok") {
|
||||
this.setData({ message: "您拒绝了手机号授权" });
|
||||
return;
|
||||
}
|
||||
this.setData({ loading: true, message: "" });
|
||||
try {
|
||||
var app = getApp();
|
||||
// wx.login 拿 code
|
||||
var loginRes = await new Promise(function (resolve, reject) {
|
||||
wx.login({ success: resolve, fail: reject });
|
||||
});
|
||||
// 调用后端微信登录接口
|
||||
var data = await app.request({
|
||||
url: "/api/auth/login",
|
||||
url: "/api/auth/wechat-login",
|
||||
method: "POST",
|
||||
data: {
|
||||
username: this.data.username,
|
||||
password: this.data.password,
|
||||
code: loginRes.code,
|
||||
phone_code: e.detail.code,
|
||||
},
|
||||
});
|
||||
// 保存登录态
|
||||
var role = data.role_code || "driver";
|
||||
app.globalData.authToken = data.token;
|
||||
app.globalData.userRole = role;
|
||||
auth.setToken(data.token);
|
||||
auth.setUser(data);
|
||||
|
||||
wx.showToast({
|
||||
title: "欢迎," + (data.real_name || data.username),
|
||||
icon: "success",
|
||||
});
|
||||
|
||||
var entry = role === "driver" ? "/pages/driver/task-list/task-list" : "/pages/manager/dashboard/dashboard";
|
||||
wx.showToast({ title: "欢迎," + data.real_name, icon: "success" });
|
||||
var entry = role === "driver"
|
||||
? "/pages/driver/task-list/task-list"
|
||||
: "/pages/manager/dashboard/dashboard";
|
||||
wx.switchTab({ url: entry });
|
||||
} catch (error) {
|
||||
this.setData({ message: error.message || "登录失败" });
|
||||
@ -48,4 +49,8 @@ Page({
|
||||
this.setData({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
goPasswordLogin: function () {
|
||||
wx.navigateTo({ url: "/pages/login/login-password/login-password" });
|
||||
},
|
||||
});
|
||||
|
||||
@ -2,21 +2,15 @@
|
||||
<view class="login-hero">
|
||||
<view class="hero-badge">Order System</view>
|
||||
<view class="hero-title">订单全流程管理系统</view>
|
||||
<view class="hero-desc">输入账号密码,系统自动识别身份</view>
|
||||
<view class="hero-desc">点击下方按钮,微信授权手机号快捷登录</view>
|
||||
</view>
|
||||
|
||||
<view class="card login-card">
|
||||
<view class="field-group">
|
||||
<view class="field-label">账号</view>
|
||||
<input class="input" value="{{username}}" data-field="username" bindinput="handleInput" placeholder="请输入账号" />
|
||||
</view>
|
||||
<view class="field-group">
|
||||
<view class="field-label">密码</view>
|
||||
<input class="input" password value="{{password}}" data-field="password" bindinput="handleInput" placeholder="请输入密码" />
|
||||
</view>
|
||||
<button class="primary-btn" type="primary" loading="{{loading}}" bindtap="handleLogin">
|
||||
{{ loading ? "登录中..." : "登录" }}
|
||||
<button class="wechat-btn" open-type="getPhoneNumber" bindgetphonenumber="handleGetPhoneNumber" loading="{{loading}}">
|
||||
{{ loading ? "登录中..." : "微信手机号一键登录" }}
|
||||
</button>
|
||||
<view wx:if="{{message}}" class="message">{{message}}</view>
|
||||
<view class="divider">或</view>
|
||||
<button class="ghost-btn" bindtap="goPasswordLogin">使用账号密码登录</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@ -45,24 +45,37 @@
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: grid;
|
||||
gap: 10rpx;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 24rpx;
|
||||
color: #475569;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
.wechat-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
margin-top: 8rpx;
|
||||
font-size: 30rpx;
|
||||
border-radius: 22rpx;
|
||||
background: #07c160;
|
||||
color: #fff;
|
||||
border: none;
|
||||
}
|
||||
|
||||
.wechat-btn[disabled] {
|
||||
background: #95d5b2;
|
||||
}
|
||||
|
||||
.divider {
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
font-size: 24rpx;
|
||||
margin: 8rpx 0;
|
||||
}
|
||||
|
||||
.ghost-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
font-size: 28rpx;
|
||||
border-radius: 22rpx;
|
||||
background: #f8fafc;
|
||||
color: #475569;
|
||||
border: 1px solid #cbd5e1;
|
||||
}
|
||||
|
||||
.message {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user