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:
taiyi 2026-06-06 17:41:11 +08:00
parent 1a3b67c423
commit feaf4298ca
12 changed files with 379 additions and 45 deletions

View File

@ -13,7 +13,7 @@ from sqlalchemy.orm import Session
from backend.app.api.deps import get_auth_service, get_current_user from backend.app.api.deps import get_auth_service, get_current_user
from backend.app.db import get_db_session 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.schemas.common import success_payload
from backend.app.services.auth_service import AuthService from backend.app.services.auth_service import AuthService
from backend.app.services.wechat_notification_service import wechat_notification_service 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)) 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
请求参数WechatLoginRequestcodeencrypted_dataiv
返回值登录结果包含 token 等认证信息
权限要求无需认证公开接口
"""
return success_payload(auth_service.wechat_login(payload.code, payload.phone_code, session))
@router.get("/me") @router.get("/me")
def me( def me(
current_user: dict = Depends(get_current_user), # 从 JWT Token 解析当前用户 current_user: dict = Depends(get_current_user), # 从 JWT Token 解析当前用户

View File

@ -31,6 +31,19 @@ class SystemRepository:
stmt = select(User).where(User.username == username) stmt = select(User).where(User.username == username)
return session.execute(stmt).scalar_one_or_none() 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: def get_user(self, session: Session, user_id: int) -> User | None:
"""根据 ID 查询用户 """根据 ID 查询用户

View File

@ -20,6 +20,16 @@ class LoginRequest(BaseModel):
"""角色类型,区分不同登录角色(如 admin / sales为空时由后端自动识别""" """角色类型,区分不同登录角色(如 admin / sales为空时由后端自动识别"""
class WechatLoginRequest(BaseModel):
"""微信手机号登录请求体,用于微信一键登录。"""
code: str
"""wx.login() 获取的临时登录凭证"""
phone_code: str
"""wx.getPhoneNumber 返回的授权 code新版 API用于换取手机号"""
class UserProfile(BaseModel): class UserProfile(BaseModel):
"""用户信息响应体,包含用户基本信息、角色权限和菜单数据。用于登录成功后返回用户画像。""" """用户信息响应体,包含用户基本信息、角色权限和菜单数据。用于登录成功后返回用户画像。"""

View File

@ -7,8 +7,10 @@
""" """
import json import json
import time
from datetime import datetime, timezone from datetime import datetime, timezone
from pathlib import Path from pathlib import Path
from urllib import error, request as urllib_request
from sqlalchemy.exc import SQLAlchemyError from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@ -152,6 +154,125 @@ class AuthService:
raise AppException(code=ErrorCode.INTERNAL_ERROR, message="数据库连接不可用", status_code=500) 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: def get_me(self, token: str, session: Session | None = None) -> dict | None:
"""根据 Token 获取当前用户信息(无需重新登录)。 """根据 Token 获取当前用户信息(无需重新登录)。

View File

@ -1,6 +1,7 @@
{ {
"pages": [ "pages": [
"pages/login/login", "pages/login/login",
"pages/login/login-password/login-password",
"pages/my/my", "pages/my/my",
"pages/driver/task-list/task-list", "pages/driver/task-list/task-list",
"pages/driver/task-detail/task-detail", "pages/driver/task-detail/task-detail",

View File

@ -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();
},
});

View File

@ -0,0 +1,3 @@
{
"navigationBarTitleText": "账号密码登录"
}

View File

@ -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>

View File

@ -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;
}

View File

@ -1,46 +1,47 @@
/** /**
* 统一登录页面 * 统一登录页面微信手机号一键登录
* 职责提供账号密码登录后端自动识别角色根据 role_code 跳转对应首页 * 职责微信手机号授权登录自动匹配系统用户保留账号密码登录作为备用
*/ */
var auth = require("../../utils/auth"); var auth = require("../../utils/auth");
Page({ Page({
data: { data: {
username: "",
password: "",
loading: false, loading: false,
message: "", message: "",
}, },
handleInput: function (event) { handleGetPhoneNumber: async function (e) {
var field = event.currentTarget.dataset.field; if (e.detail.errMsg !== "getPhoneNumber:ok") {
this.setData({ [field]: event.detail.value }); this.setData({ message: "您拒绝了手机号授权" });
}, return;
}
handleLogin: async function () {
var app = getApp();
this.setData({ loading: true, message: "" }); this.setData({ loading: true, message: "" });
try { 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({ var data = await app.request({
url: "/api/auth/login", url: "/api/auth/wechat-login",
method: "POST", method: "POST",
data: { data: {
username: this.data.username, code: loginRes.code,
password: this.data.password, phone_code: e.detail.code,
}, },
}); });
// 保存登录态
var role = data.role_code || "driver"; var role = data.role_code || "driver";
app.globalData.authToken = data.token; app.globalData.authToken = data.token;
app.globalData.userRole = role; app.globalData.userRole = role;
auth.setToken(data.token); auth.setToken(data.token);
auth.setUser(data); auth.setUser(data);
wx.showToast({ wx.showToast({ title: "欢迎," + data.real_name, icon: "success" });
title: "欢迎," + (data.real_name || data.username), var entry = role === "driver"
icon: "success", ? "/pages/driver/task-list/task-list"
}); : "/pages/manager/dashboard/dashboard";
var entry = role === "driver" ? "/pages/driver/task-list/task-list" : "/pages/manager/dashboard/dashboard";
wx.switchTab({ url: entry }); wx.switchTab({ url: entry });
} catch (error) { } catch (error) {
this.setData({ message: error.message || "登录失败" }); this.setData({ message: error.message || "登录失败" });
@ -48,4 +49,8 @@ Page({
this.setData({ loading: false }); this.setData({ loading: false });
} }
}, },
goPasswordLogin: function () {
wx.navigateTo({ url: "/pages/login/login-password/login-password" });
},
}); });

View File

@ -2,21 +2,15 @@
<view class="login-hero"> <view class="login-hero">
<view class="hero-badge">Order System</view> <view class="hero-badge">Order System</view>
<view class="hero-title">订单全流程管理系统</view> <view class="hero-title">订单全流程管理系统</view>
<view class="hero-desc">输入账号密码,系统自动识别身份</view> <view class="hero-desc">点击下方按钮,微信授权手机号快捷登录</view>
</view> </view>
<view class="card login-card"> <view class="card login-card">
<view class="field-group"> <button class="wechat-btn" open-type="getPhoneNumber" bindgetphonenumber="handleGetPhoneNumber" loading="{{loading}}">
<view class="field-label">账号</view> {{ loading ? "登录中..." : "微信手机号一键登录" }}
<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> </button>
<view wx:if="{{message}}" class="message">{{message}}</view> <view wx:if="{{message}}" class="message">{{message}}</view>
<view class="divider">或</view>
<button class="ghost-btn" bindtap="goPasswordLogin">使用账号密码登录</button>
</view> </view>
</view> </view>

View File

@ -45,24 +45,37 @@
gap: 20rpx; gap: 20rpx;
} }
.field-group { .wechat-btn {
display: grid;
gap: 10rpx;
}
.field-label {
font-size: 24rpx;
color: #475569;
font-weight: 600;
}
.primary-btn {
width: 100%; width: 100%;
height: 88rpx; height: 88rpx;
line-height: 88rpx; line-height: 88rpx;
margin-top: 8rpx;
font-size: 30rpx; font-size: 30rpx;
border-radius: 22rpx; 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 { .message {