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:
parent
7214649759
commit
79508ef85c
|
|
@ -2,12 +2,22 @@ from fastapi import APIRouter, Depends, HTTPException
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
from app.core.security import create_access_token
|
from app.core.security import create_access_token
|
||||||
|
from app.config import get_settings
|
||||||
from app.db import get_db
|
from app.db import get_db
|
||||||
from app.deps import get_current_user
|
from app.deps import get_current_user
|
||||||
from app.integrations.sms_client import SmsClient
|
from app.integrations.sms_client import SmsClient
|
||||||
|
from app.integrations.wechat_client import WechatMiniClient, WechatMiniClientError
|
||||||
from app.models import Permission, User
|
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.audit_service import write_audit
|
||||||
from app.services.user_service import get_or_create_login_user
|
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")
|
@router.post("/logout")
|
||||||
def logout(current_user: User = Depends(get_current_user), db: Session = Depends(get_db)) -> dict:
|
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))
|
write_audit(db, "auth.logout", actor_id=current_user.id, target_type="user", target_id=str(current_user.id))
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
@ -10,6 +10,15 @@ class LoginRequest(BaseModel):
|
||||||
code: str = Field(min_length=4, max_length=8)
|
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):
|
class CurrentUserResponse(BaseModel):
|
||||||
id: int
|
id: int
|
||||||
phone: str
|
phone: str
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,7 @@ App({
|
||||||
globalData: {
|
globalData: {
|
||||||
// 后端 API 基址:开发环境需要在开发者工具「详情 - 本地设置」中勾选
|
// 后端 API 基址:开发环境需要在开发者工具「详情 - 本地设置」中勾选
|
||||||
// 「不校验合法域名、web-view (业务域名)、TLS 版本以及 HTTPS 证书」
|
// 「不校验合法域名、web-view (业务域名)、TLS 版本以及 HTTPS 证书」
|
||||||
apiBase: 'http://localhost:8000',
|
apiBase: 'https://finai.ctc-zc.com:8775',
|
||||||
token: '',
|
token: '',
|
||||||
user: null
|
user: null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,12 @@
|
||||||
"pages/home/home",
|
"pages/home/home",
|
||||||
"pages/chat/chat"
|
"pages/chat/chat"
|
||||||
],
|
],
|
||||||
|
"plugins": {
|
||||||
|
"WechatSI": {
|
||||||
|
"version": "0.3.5",
|
||||||
|
"provider": "wx069ba97219f66d99"
|
||||||
|
}
|
||||||
|
},
|
||||||
"window": {
|
"window": {
|
||||||
"backgroundTextStyle": "light",
|
"backgroundTextStyle": "light",
|
||||||
"navigationBarBackgroundColor": "#ffffff",
|
"navigationBarBackgroundColor": "#ffffff",
|
||||||
|
|
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 2.9 KiB |
|
|
@ -8,6 +8,7 @@ Page({
|
||||||
code: '',
|
code: '',
|
||||||
sending: false,
|
sending: false,
|
||||||
submitting: false,
|
submitting: false,
|
||||||
|
wxSubmitting: false,
|
||||||
countdown: 0
|
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() {
|
async login() {
|
||||||
if (this.data.submitting) return
|
if (this.data.submitting) return
|
||||||
const phone = (this.data.phone || '').trim()
|
const phone = (this.data.phone || '').trim()
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,20 @@
|
||||||
{{submitting ? '登录中…' : '登录'}}
|
{{submitting ? '登录中…' : '登录'}}
|
||||||
</button>
|
</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 class="tip">测试账号:13800000000 / 验证码 123456</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
|
||||||
|
|
@ -144,3 +144,52 @@
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: var(--muted);
|
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);
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue