feat: 新增微信授权登录(wx.login + getPhoneNumber)

- 后端加 /api/auth/wechat-login,用 phone_code 换手机号后匹配用户表登录
- 引入 WechatMiniClient 封装 code2session / getPhoneNumber,并做 mock 兜底
- 小程序登录页加"微信授权登录"按钮,apiBase 指向 finai.ctc-zc.com:8775
- 引入 WechatSI 插件占位

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
shijing 2026-07-15 09:06:31 +08:00
parent 7214649759
commit 79508ef85c
9 changed files with 254 additions and 2 deletions

View File

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

View File

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

View File

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

View File

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

View File

@ -4,6 +4,12 @@
"pages/home/home",
"pages/chat/chat"
],
"plugins": {
"WechatSI": {
"version": "0.3.5",
"provider": "wx069ba97219f66d99"
}
},
"window": {
"backgroundTextStyle": "light",
"navigationBarBackgroundColor": "#ffffff",

BIN
miniapp/images/wchat.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

@ -8,6 +8,7 @@ Page({
code: '',
sending: false,
submitting: false,
wxSubmitting: false,
countdown: 0
},
@ -69,6 +70,54 @@ Page({
}
},
wxLoginCode() {
return new Promise((resolve) => {
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()

View File

@ -49,6 +49,20 @@
{{submitting ? '登录中…' : '登录'}}
</button>
<view class="divider"><text>或</text></view>
<view class="wechat-entry">
<button
class="wechat-icon-btn {{wxSubmitting ? 'disabled' : ''}}"
open-type="getPhoneNumber"
bindgetphonenumber="onGetPhoneNumber"
disabled="{{wxSubmitting || submitting}}"
>
<image class="wechat-icon-img" src="/images/wchat.png" mode="aspectFit" />
</button>
<text class="wechat-entry-tip">{{wxSubmitting ? '授权中…' : '微信授权登录'}}</text>
</view>
<view class="tip">测试账号13800000000 / 验证码 123456</view>
</view>
</view>

View File

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