From 79508ef85cc46af437940dbfa5619f75c2bcc2eb Mon Sep 17 00:00:00 2001 From: shijing Date: Wed, 15 Jul 2026 09:06:31 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=E5=BE=AE=E4=BF=A1?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E7=99=BB=E5=BD=95=EF=BC=88wx.login=20+=20get?= =?UTF-8?q?PhoneNumber=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端加 /api/auth/wechat-login,用 phone_code 换手机号后匹配用户表登录 - 引入 WechatMiniClient 封装 code2session / getPhoneNumber,并做 mock 兜底 - 小程序登录页加"微信授权登录"按钮,apiBase 指向 finai.ctc-zc.com:8775 - 引入 WechatSI 插件占位 Co-Authored-By: Claude Opus 4.7 --- backend/app/api/auth.py | 57 +++++++++++++++++- backend/app/integrations/wechat_client.py | 70 ++++++++++++++++++++++ backend/app/schemas/auth.py | 9 +++ miniapp/app.js | 2 +- miniapp/app.json | 6 ++ miniapp/images/wchat.png | Bin 0 -> 2944 bytes miniapp/pages/login/login.js | 49 +++++++++++++++ miniapp/pages/login/login.wxml | 14 +++++ miniapp/pages/login/login.wxss | 49 +++++++++++++++ 9 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 backend/app/integrations/wechat_client.py create mode 100644 miniapp/images/wchat.png diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index e6a70ba..a788a66 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -2,12 +2,22 @@ from fastapi import APIRouter, Depends, HTTPException from sqlalchemy import select from sqlalchemy.orm import Session +from datetime import datetime + from app.core.security import create_access_token +from app.config import get_settings from app.db import get_db from app.deps import get_current_user from app.integrations.sms_client import SmsClient +from app.integrations.wechat_client import WechatMiniClient, WechatMiniClientError from app.models import Permission, User -from app.schemas.auth import CurrentUserResponse, LoginRequest, SmsCodeRequest, TokenResponse +from app.schemas.auth import ( + CurrentUserResponse, + LoginRequest, + SmsCodeRequest, + TokenResponse, + WechatLoginRequest, +) from app.services.audit_service import write_audit from app.services.user_service import get_or_create_login_user @@ -60,6 +70,51 @@ def login(payload: LoginRequest, db: Session = Depends(get_db)) -> TokenResponse ) +@router.post("/wechat-login", response_model=TokenResponse) +def wechat_login(payload: WechatLoginRequest, db: Session = Depends(get_db)) -> TokenResponse: + """微信授权登录:拿手机号在用户表中匹配,找到则登录,找不到提示联系管理员。""" + settings = get_settings() + client = WechatMiniClient() + phone: str | None = None + + if client.configured and payload.phone_code: + try: + phone = client.get_phone_number(payload.phone_code) + except WechatMiniClientError as exc: + raise HTTPException(status_code=400, detail=str(exc)) + elif payload.phone: + # 未配置微信凭据或前端未拿到 phone_code 时的兜底(开发/联调) + phone = payload.phone.strip() + elif not client.configured: + # mock 模式且未传 phone:用配置里的测试手机号,方便本地联调 + phone = settings.wx_mini_mock_phone + + if not phone: + raise HTTPException(status_code=400, detail="未获取到微信手机号") + + # 可选:换 openid 做审计追踪,失败不阻断登录 + if client.configured and payload.js_code: + try: + client.code2session(payload.js_code) + except WechatMiniClientError: + pass + + user = db.scalar(select(User).where(User.phone == phone)) + if user is None: + raise HTTPException(status_code=403, detail="未找到匹配账号,请联系管理员添加账号后使用") + if user.status != "active": + raise HTTPException(status_code=403, detail="账号已禁用") + + user.last_login_at = datetime.utcnow() + write_audit(db, "auth.wechat_login", actor_id=user.id, target_type="user", target_id=str(user.id)) + db.commit() + db.refresh(user) + return TokenResponse( + access_token=create_access_token(str(user.id)), + user=_build_current_user(db, user), + ) + + @router.post("/logout") def logout(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> dict: write_audit(db, "auth.logout", actor_id=current_user.id, target_type="user", target_id=str(current_user.id)) diff --git a/backend/app/integrations/wechat_client.py b/backend/app/integrations/wechat_client.py new file mode 100644 index 0000000..1c6837f --- /dev/null +++ b/backend/app/integrations/wechat_client.py @@ -0,0 +1,70 @@ +import httpx + +from app.config import get_settings + + +class WechatMiniClientError(Exception): + pass + + +class WechatMiniClient: + """微信小程序 code2session / getPhoneNumber 客户端。 + + 未配置 appid/secret 时视为 mock 模式,由上层跳过真实调用。 + """ + + CODE2SESSION_URL = "https://api.weixin.qq.com/sns/jscode2session" + ACCESS_TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token" + GET_PHONE_URL = "https://api.weixin.qq.com/wxa/business/getuserphonenumber" + + def __init__(self) -> None: + settings = get_settings() + self.appid = settings.wx_mini_appid + self.secret = settings.wx_mini_secret + self.timeout = 10.0 + + @property + def configured(self) -> bool: + return bool(self.appid and self.secret) + + def code2session(self, js_code: str) -> dict: + params = { + "appid": self.appid, + "secret": self.secret, + "js_code": js_code, + "grant_type": "authorization_code", + } + with httpx.Client(timeout=self.timeout, trust_env=False) as client: + resp = client.get(self.CODE2SESSION_URL, params=params) + data = resp.json() + if data.get("errcode"): + raise WechatMiniClientError(f"code2session 失败: {data.get('errmsg')}") + return data + + def _access_token(self) -> str: + params = { + "grant_type": "client_credential", + "appid": self.appid, + "secret": self.secret, + } + with httpx.Client(timeout=self.timeout, trust_env=False) as client: + resp = client.get(self.ACCESS_TOKEN_URL, params=params) + data = resp.json() + token = data.get("access_token") + if not token: + raise WechatMiniClientError(f"获取 access_token 失败: {data.get('errmsg')}") + return token + + def get_phone_number(self, phone_code: str) -> str: + token = self._access_token() + url = f"{self.GET_PHONE_URL}?access_token={token}" + with httpx.Client(timeout=self.timeout, trust_env=False) as client: + resp = client.post(url, json={"code": phone_code}) + data = resp.json() + if data.get("errcode"): + raise WechatMiniClientError(f"getPhoneNumber 失败: {data.get('errmsg')}") + phone_info = data.get("phone_info") or {} + phone = phone_info.get("purePhoneNumber") or phone_info.get("phoneNumber") + if not phone: + raise WechatMiniClientError("getPhoneNumber 未返回手机号") + return phone diff --git a/backend/app/schemas/auth.py b/backend/app/schemas/auth.py index f222385..5a9183b 100644 --- a/backend/app/schemas/auth.py +++ b/backend/app/schemas/auth.py @@ -10,6 +10,15 @@ class LoginRequest(BaseModel): code: str = Field(min_length=4, max_length=8) +class WechatLoginRequest(BaseModel): + # wx.login() 返回的 jscode,用于换 openid(可选,仅记录用途) + js_code: str | None = None + # button open-type="getPhoneNumber" 事件返回的 code,用于换手机号 + phone_code: str | None = None + # mock 模式或跳过手机号服务时,前端可直接传手机号(生产可禁用) + phone: str | None = None + + class CurrentUserResponse(BaseModel): id: int phone: str diff --git a/miniapp/app.js b/miniapp/app.js index 8900a15..e5b4fda 100644 --- a/miniapp/app.js +++ b/miniapp/app.js @@ -10,7 +10,7 @@ App({ globalData: { // 后端 API 基址:开发环境需要在开发者工具「详情 - 本地设置」中勾选 // 「不校验合法域名、web-view (业务域名)、TLS 版本以及 HTTPS 证书」 - apiBase: 'http://localhost:8000', + apiBase: 'https://finai.ctc-zc.com:8775', token: '', user: null } diff --git a/miniapp/app.json b/miniapp/app.json index b04fd5e..14d95d8 100644 --- a/miniapp/app.json +++ b/miniapp/app.json @@ -4,6 +4,12 @@ "pages/home/home", "pages/chat/chat" ], + "plugins": { + "WechatSI": { + "version": "0.3.5", + "provider": "wx069ba97219f66d99" + } + }, "window": { "backgroundTextStyle": "light", "navigationBarBackgroundColor": "#ffffff", diff --git a/miniapp/images/wchat.png b/miniapp/images/wchat.png new file mode 100644 index 0000000000000000000000000000000000000000..4d07666f0eb6342b8d414943c866b83ea6a20611 GIT binary patch literal 2944 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 { + wx.login({ + success: (res) => resolve(res && res.code ? res.code : ''), + fail: () => resolve('') + }) + }) + }, + + async onGetPhoneNumber(e) { + if (this.data.wxSubmitting) return + const detail = e.detail || {} + // 用户拒绝授权 + if (!detail.code && !detail.encryptedData) { + wx.showToast({ title: '已取消微信授权', icon: 'none' }) + return + } + this.setData({ wxSubmitting: true }) + try { + const jsCode = await this.wxLoginCode() + const payload = { js_code: jsCode, phone_code: detail.code || '' } + const result = await request('/api/auth/wechat-login', { + method: 'POST', + data: payload + }) + app.globalData.token = result.access_token + app.globalData.user = result.user + wx.setStorageSync('token', result.access_token) + wx.setStorageSync('user', result.user) + wx.showToast({ title: '登录成功', icon: 'success' }) + wx.redirectTo({ url: '/pages/home/home' }) + } catch (err) { + const msg = (err && err.message) || '微信登录失败' + if (msg.indexOf('联系管理员') !== -1) { + wx.showModal({ + title: '无法登录', + content: '未找到匹配账号,请联系管理员添加账号后使用', + showCancel: false, + confirmText: '我知道了' + }) + } else { + wx.showToast({ title: msg, icon: 'none' }) + } + } finally { + this.setData({ wxSubmitting: false }) + } + }, + async login() { if (this.data.submitting) return const phone = (this.data.phone || '').trim() diff --git a/miniapp/pages/login/login.wxml b/miniapp/pages/login/login.wxml index 1a214fb..bdb8dc7 100644 --- a/miniapp/pages/login/login.wxml +++ b/miniapp/pages/login/login.wxml @@ -49,6 +49,20 @@ {{submitting ? '登录中…' : '登录'}} + + + + + {{wxSubmitting ? '授权中…' : '微信授权登录'}} + + 测试账号:13800000000 / 验证码 123456 diff --git a/miniapp/pages/login/login.wxss b/miniapp/pages/login/login.wxss index b4daa3b..5c1a7c6 100644 --- a/miniapp/pages/login/login.wxss +++ b/miniapp/pages/login/login.wxss @@ -144,3 +144,52 @@ font-size: 22rpx; color: var(--muted); } + +.divider { + display: flex; + align-items: center; + gap: 16rpx; + margin: 24rpx 0 8rpx; + color: var(--muted); + font-size: 22rpx; +} +.divider::before, +.divider::after { + content: ''; + flex: 1; + height: 1rpx; + background: var(--line); +} + +.wechat-entry { + display: flex; + flex-direction: column; + align-items: center; + gap: 12rpx; + margin-top: 8rpx; +} +.wechat-icon-btn { + width: 96rpx; + height: 96rpx; + padding: 0; + border-radius: 50%; + background: transparent; + display: flex; + align-items: center; + justify-content: center; + line-height: normal; +} +.wechat-icon-btn::after { + border: none; +} +.wechat-icon-btn.disabled { + opacity: 0.5; +} +.wechat-icon-img { + width: 96rpx; + height: 96rpx; +} +.wechat-entry-tip { + font-size: 24rpx; + color: var(--muted); +}