diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py
index f7ad2a6..18d2ad9 100644
--- a/backend/app/api/auth.py
+++ b/backend/app/api/auth.py
@@ -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 解析当前用户
diff --git a/backend/app/repositories/system_repository.py b/backend/app/repositories/system_repository.py
index 1826d08..b3d00ef 100644
--- a/backend/app/repositories/system_repository.py
+++ b/backend/app/repositories/system_repository.py
@@ -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 查询用户
diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py
index 6290c60..6e6b020 100644
--- a/backend/app/schemas/auth.py
+++ b/backend/app/schemas/auth.py
@@ -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):
"""用户信息响应体,包含用户基本信息、角色权限和菜单数据。用于登录成功后返回用户画像。"""
diff --git a/backend/app/services/auth_service.py b/backend/app/services/auth_service.py
index 1729073..e92a527 100644
--- a/backend/app/services/auth_service.py
+++ b/backend/app/services/auth_service.py
@@ -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 获取当前用户信息(无需重新登录)。
diff --git a/frontend/mini-app/app.json b/frontend/mini-app/app.json
index 4b0cce6..c997ac7 100644
--- a/frontend/mini-app/app.json
+++ b/frontend/mini-app/app.json
@@ -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",
diff --git a/frontend/mini-app/pages/login/login-password/login-password.js b/frontend/mini-app/pages/login/login-password/login-password.js
new file mode 100644
index 0000000..4f75a90
--- /dev/null
+++ b/frontend/mini-app/pages/login/login-password/login-password.js
@@ -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();
+ },
+});
diff --git a/frontend/mini-app/pages/login/login-password/login-password.json b/frontend/mini-app/pages/login/login-password/login-password.json
new file mode 100644
index 0000000..15d94f7
--- /dev/null
+++ b/frontend/mini-app/pages/login/login-password/login-password.json
@@ -0,0 +1,3 @@
+{
+ "navigationBarTitleText": "账号密码登录"
+}
diff --git a/frontend/mini-app/pages/login/login-password/login-password.wxml b/frontend/mini-app/pages/login/login-password/login-password.wxml
new file mode 100644
index 0000000..4a2a7a0
--- /dev/null
+++ b/frontend/mini-app/pages/login/login-password/login-password.wxml
@@ -0,0 +1,23 @@
+
+
+ Order System
+ 账号密码登录
+ 输入账号密码,系统自动识别身份
+
+
+
+
+ 账号
+
+
+
+ 密码
+
+
+
+ {{message}}
+ 返回微信登录
+
+
diff --git a/frontend/mini-app/pages/login/login-password/login-password.wxss b/frontend/mini-app/pages/login/login-password/login-password.wxss
new file mode 100644
index 0000000..9458bf4
--- /dev/null
+++ b/frontend/mini-app/pages/login/login-password/login-password.wxss
@@ -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;
+}
diff --git a/frontend/mini-app/pages/login/login.js b/frontend/mini-app/pages/login/login.js
index f67b34e..48d3673 100644
--- a/frontend/mini-app/pages/login/login.js
+++ b/frontend/mini-app/pages/login/login.js
@@ -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" });
+ },
});
diff --git a/frontend/mini-app/pages/login/login.wxml b/frontend/mini-app/pages/login/login.wxml
index 1f9f5c7..3bc0a34 100644
--- a/frontend/mini-app/pages/login/login.wxml
+++ b/frontend/mini-app/pages/login/login.wxml
@@ -2,21 +2,15 @@
Order System
订单全流程管理系统
- 输入账号密码,系统自动识别身份
+ 点击下方按钮,微信授权手机号快捷登录
-
- 账号
-
-
-
- 密码
-
-
-
diff --git a/frontend/mini-app/pages/login/login.wxss b/frontend/mini-app/pages/login/login.wxss
index d83dd15..1134593 100644
--- a/frontend/mini-app/pages/login/login.wxss
+++ b/frontend/mini-app/pages/login/login.wxss
@@ -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 {