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 订单全流程管理系统 - 输入账号密码,系统自动识别身份 + 点击下方按钮,微信授权手机号快捷登录 - - 账号 - - - - 密码 - - - {{message}} + + 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 {