feat(passport): 优化扫码登录确认流程,改用 wx.login + loginByOpenId 验证身份
- 移除 useUser 依赖,采用微信 wx.login 获取 code 并调用 loginByOpenId 接口验证用户身份 - 登录流程调整为先通过 code 校验用户是否存在,存在则自动确认登录, 不存在跳转注册页面 - 增加用户信息本地缓存及优先使用 wx.login 返回的用户数据 - 修改二维码确认登录页面 UI,展示微信登录用户头像及昵称 - 新增 wx-login API 接口,实现通过 code 换取用户身份信息及 OpenId 获取接口 - 强化登录失败及用户未注册状态的提示和跳转逻辑
This commit is contained in:
123
src/api/passport/wx-login/index.ts
Normal file
123
src/api/passport/wx-login/index.ts
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
import request from '@/utils/request';
|
||||||
|
import type { ApiResult } from '@/api';
|
||||||
|
import { SERVER_API_URL } from '@/utils/server';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信小程序登录相关接口
|
||||||
|
*/
|
||||||
|
|
||||||
|
// 微信登录请求参数
|
||||||
|
export interface WxLoginParam {
|
||||||
|
// 微信登录凭证(wx.login() 获取)
|
||||||
|
code: string;
|
||||||
|
// 租户ID
|
||||||
|
tenantId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 微信登录用户信息
|
||||||
|
export interface WxLoginUserInfo {
|
||||||
|
userId: number;
|
||||||
|
username?: string;
|
||||||
|
nickname?: string;
|
||||||
|
avatar?: string;
|
||||||
|
phone?: string;
|
||||||
|
openid?: string;
|
||||||
|
unionid?: string;
|
||||||
|
tenantId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 微信登录结果
|
||||||
|
export interface WxLoginResult {
|
||||||
|
// JWT 访问令牌
|
||||||
|
access_token?: string;
|
||||||
|
// 用户信息
|
||||||
|
user?: WxLoginUserInfo;
|
||||||
|
// 租户ID
|
||||||
|
tenantId?: number;
|
||||||
|
// 登录消息
|
||||||
|
message?: string;
|
||||||
|
// 用户ID
|
||||||
|
userId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 微信小程序登录(通过 openid)
|
||||||
|
*
|
||||||
|
* 流程:
|
||||||
|
* 1. 调用 wx.login() 获取 code
|
||||||
|
* 2. 用 code 调用此接口
|
||||||
|
* 3. 后端用 code 换取 openid,查库返回用户信息
|
||||||
|
* 4. 如果用户存在,返回登录 token
|
||||||
|
* 5. 如果用户不存在,返回 "用户未注册" + openid
|
||||||
|
*/
|
||||||
|
export async function loginByOpenId(data: WxLoginParam): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
message: string;
|
||||||
|
data?: WxLoginResult;
|
||||||
|
openid?: string;
|
||||||
|
}> {
|
||||||
|
const res = await request.post<ApiResult<WxLoginResult>>(
|
||||||
|
SERVER_API_URL + '/wx-login/loginByOpenId',
|
||||||
|
data
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('[WxLogin] loginByOpenId 响应:', res);
|
||||||
|
|
||||||
|
// 成功:用户已注册
|
||||||
|
if ((res.code === 0 || res.code === 200) && res.data) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
message: res.message || '登录成功',
|
||||||
|
data: res.data
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// 失败:用户未注册或登录失败
|
||||||
|
// 后端返回 "用户未注册" 时,data 字段是 openid
|
||||||
|
if (res.message === '用户未注册' || res.message?.includes('用户未注册')) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: '用户未注册',
|
||||||
|
openid: res.data as unknown as string
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: res.message || '登录失败'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取微信 OpenId(仅获取,不登录)
|
||||||
|
*/
|
||||||
|
export async function getOpenId(code: string): Promise<{
|
||||||
|
success: boolean;
|
||||||
|
openid?: string;
|
||||||
|
unionid?: string;
|
||||||
|
session_key?: string;
|
||||||
|
message?: string;
|
||||||
|
}> {
|
||||||
|
const res = await request.post<ApiResult<{
|
||||||
|
openid: string;
|
||||||
|
unionid?: string;
|
||||||
|
session_key?: string;
|
||||||
|
}>>(
|
||||||
|
SERVER_API_URL + '/wx-login/getOpenId',
|
||||||
|
{ code }
|
||||||
|
);
|
||||||
|
|
||||||
|
if ((res.code === 0 || res.code === 200) && res.data) {
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
openid: res.data.openid,
|
||||||
|
unionid: res.data.unionid,
|
||||||
|
session_key: res.data.session_key
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
message: res.message || '获取 OpenId 失败'
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import { Button, Loading, Card } from '@nutui/nutui-react-taro';
|
|||||||
import { Success, Failure, Tips, User } from '@nutui/icons-react-taro';
|
import { Success, Failure, Tips, User } from '@nutui/icons-react-taro';
|
||||||
import Taro, { useRouter } from '@tarojs/taro';
|
import Taro, { useRouter } from '@tarojs/taro';
|
||||||
import { confirmQRLogin } from '@/api/passport/qr-login';
|
import { confirmQRLogin } from '@/api/passport/qr-login';
|
||||||
import { useUser } from '@/hooks/useUser';
|
import { loginByOpenId } from '@/api/passport/wx-login';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 扫码登录确认页面
|
* 扫码登录确认页面
|
||||||
@@ -17,15 +17,23 @@ import { useUser } from '@/hooks/useUser';
|
|||||||
* - 微信「扫普通链接二维码打开小程序」配置的二维码规则:`https://websopy.websoft.top/wx-scan/`
|
* - 微信「扫普通链接二维码打开小程序」配置的二维码规则:`https://websopy.websoft.top/wx-scan/`
|
||||||
* - 扫码后 URL:`https://websopy.websoft.top/wx-scan?token=xxx`
|
* - 扫码后 URL:`https://websopy.websoft.top/wx-scan?token=xxx`
|
||||||
* - 小程序接收到参数后自动确认登录
|
* - 小程序接收到参数后自动确认登录
|
||||||
|
*
|
||||||
|
* 登录流程(2026-04-07 实现):
|
||||||
|
* 1. 用户扫码 → 进入 qr-confirm 页面
|
||||||
|
* 2. 页面立即调用 wx.login() 获取 code
|
||||||
|
* 3. 用 code 调用 /api/wx-login/loginByOpenId 获取/验证用户身份
|
||||||
|
* 4. 如果用户不存在 → 跳转到用户页引导注册
|
||||||
|
* 5. 如果用户存在 → 自动调用 confirmQRLogin 确认登录
|
||||||
*/
|
*/
|
||||||
const QRConfirmPage: React.FC = () => {
|
const QRConfirmPage: React.FC = () => {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const { user, getDisplayName, isLoggedIn } = useUser();
|
// 移除 useUser 依赖,改用 wx.login() + loginByOpenId 方式验证用户身份
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [confirmed, setConfirmed] = useState(false);
|
const [confirmed, setConfirmed] = useState(false);
|
||||||
const [error, setError] = useState<string>('');
|
const [error, setError] = useState<string>('');
|
||||||
const [token, setToken] = useState<string>('');
|
const [token, setToken] = useState<string>('');
|
||||||
const [loginMethod, setLoginMethod] = useState<'scan' | 'url'>('url');
|
const [loginMethod, setLoginMethod] = useState<'scan' | 'url'>('url');
|
||||||
|
const [userInfo, setUserInfo] = useState<any>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// 从 URL 参数中获取 token
|
// 从 URL 参数中获取 token
|
||||||
@@ -80,32 +88,74 @@ const QRConfirmPage: React.FC = () => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 自动确认登录(URL 扫码场景)
|
* 自动确认登录(URL 扫码场景)
|
||||||
|
*
|
||||||
|
* 新的登录流程(2026-04-07):
|
||||||
|
* 1. 调用 wx.login() 获取 code
|
||||||
|
* 2. 用 code 调用后端 loginByOpenId 接口验证用户身份
|
||||||
|
* 3. 用户存在 → 调用 confirmQRLogin 确认登录
|
||||||
|
* 4. 用户不存在 → 跳转到用户页引导注册
|
||||||
*/
|
*/
|
||||||
const handleAutoConfirm = async (loginToken: string) => {
|
const handleAutoConfirm = async (loginToken: string) => {
|
||||||
if (!isLoggedIn || !user?.userId) {
|
try {
|
||||||
// 用户未登录,跳转到登录页面
|
setLoading(true);
|
||||||
console.log('[QRConfirm] 用户未登录,跳转到登录页');
|
|
||||||
Taro.showToast({
|
// 1. 调用微信登录获取 code
|
||||||
title: '请先登录小程序',
|
console.log('[QRConfirm] 调用 wx.login() 获取 code...');
|
||||||
icon: 'none',
|
const loginResult = await Taro.login();
|
||||||
duration: 2000
|
|
||||||
|
if (!loginResult.code) {
|
||||||
|
throw new Error('获取微信登录凭证失败');
|
||||||
|
}
|
||||||
|
console.log('[QRConfirm] 获取到 code:', loginResult.code);
|
||||||
|
|
||||||
|
// 2. 用 code 调用后端接口验证用户身份
|
||||||
|
console.log('[QRConfirm] 调用后端 loginByOpenId...');
|
||||||
|
const wxLoginResult = await loginByOpenId({
|
||||||
|
code: loginResult.code,
|
||||||
|
tenantId: 10398 // 使用固定租户ID
|
||||||
});
|
});
|
||||||
|
|
||||||
setTimeout(() => {
|
console.log('[QRConfirm] loginByOpenId 结果:', wxLoginResult);
|
||||||
Taro.redirectTo({
|
|
||||||
url: '/passport/login'
|
// 3. 判断用户是否存在
|
||||||
|
if (wxLoginResult.success && wxLoginResult.data) {
|
||||||
|
// 用户已注册,保存用户信息并继续确认登录
|
||||||
|
console.log('[QRConfirm] 用户已注册,开始确认登录...');
|
||||||
|
setUserInfo(wxLoginResult.data.user);
|
||||||
|
|
||||||
|
// 调用确认登录
|
||||||
|
await handleConfirmLogin(loginToken, wxLoginResult.data.user);
|
||||||
|
} else {
|
||||||
|
// 用户未注册,跳转到用户页引导注册
|
||||||
|
console.log('[QRConfirm] 用户未注册,跳转到用户页引导注册');
|
||||||
|
|
||||||
|
Taro.showToast({
|
||||||
|
title: '请先注册/登录小程序',
|
||||||
|
icon: 'none',
|
||||||
|
duration: 2000
|
||||||
});
|
});
|
||||||
}, 2000);
|
|
||||||
return;
|
setTimeout(() => {
|
||||||
|
// 跳转到用户中心,在那里可以完成注册和登录
|
||||||
|
Taro.switchTab({
|
||||||
|
url: '/pages/user/user'
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error('[QRConfirm] 自动确认登录失败:', err);
|
||||||
|
setError(err.message || '自动确认登录失败');
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
await handleConfirmLogin(loginToken);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 确认登录
|
* 确认登录
|
||||||
|
* @param loginToken - 可选的登录token,默认使用页面token
|
||||||
|
* @param wxUserInfo - 微信登录获取的用户信息(可选)
|
||||||
*/
|
*/
|
||||||
const handleConfirmLogin = async (loginToken?: string) => {
|
const handleConfirmLogin = async (loginToken?: string, wxUserInfo?: any) => {
|
||||||
const confirmToken = loginToken || token;
|
const confirmToken = loginToken || token;
|
||||||
|
|
||||||
if (!confirmToken) {
|
if (!confirmToken) {
|
||||||
@@ -113,19 +163,27 @@ const QRConfirmPage: React.FC = () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!user?.userId) {
|
// 优先使用传入的用户信息,否则尝试从本地存储获取
|
||||||
setError('请先登录小程序');
|
const currentUser = wxUserInfo || userInfo;
|
||||||
Taro.showToast({
|
|
||||||
title: '请先登录小程序',
|
if (!currentUser?.userId) {
|
||||||
icon: 'none'
|
// 没有用户信息,尝试从本地存储获取
|
||||||
});
|
const userId = Taro.getStorageSync('UserId');
|
||||||
|
if (!userId) {
|
||||||
setTimeout(() => {
|
setError('请先登录小程序');
|
||||||
Taro.redirectTo({
|
Taro.showToast({
|
||||||
url: '/passport/login'
|
title: '请先登录小程序',
|
||||||
|
icon: 'none'
|
||||||
});
|
});
|
||||||
}, 1500);
|
|
||||||
return;
|
setTimeout(() => {
|
||||||
|
Taro.switchTab({
|
||||||
|
url: '/pages/user/user'
|
||||||
|
});
|
||||||
|
}, 1500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
currentUser && (currentUser.userId = Number(userId));
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -134,11 +192,11 @@ const QRConfirmPage: React.FC = () => {
|
|||||||
|
|
||||||
const result = await confirmQRLogin({
|
const result = await confirmQRLogin({
|
||||||
token: confirmToken,
|
token: confirmToken,
|
||||||
userId: user.userId,
|
userId: currentUser.userId,
|
||||||
platform: 'wechat',
|
platform: 'wechat',
|
||||||
wechatInfo: {
|
wechatInfo: {
|
||||||
nickname: user.nickname,
|
nickname: currentUser.nickname || currentUser.username,
|
||||||
avatar: user.avatar
|
avatar: currentUser.avatar
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -324,7 +382,8 @@ const QRConfirmPage: React.FC = () => {
|
|||||||
if (loginMethod === 'url') {
|
if (loginMethod === 'url') {
|
||||||
return '检测到登录请求,是否确认登录?';
|
return '检测到登录请求,是否确认登录?';
|
||||||
}
|
}
|
||||||
return `确认使用 ${getDisplayName()} 登录网页端?`;
|
const displayName = userInfo?.nickname || userInfo?.username || '当前用户';
|
||||||
|
return `确认使用 ${displayName} 登录网页端?`;
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -355,13 +414,13 @@ const QRConfirmPage: React.FC = () => {
|
|||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{/* 用户信息 */}
|
{/* 用户信息 */}
|
||||||
{!loading && !confirmed && !error && user && (
|
{!loading && !confirmed && !error && userInfo && (
|
||||||
<View className="bg-gray-50 rounded-xl p-4 mb-6">
|
<View className="bg-gray-50 rounded-xl p-4 mb-6">
|
||||||
<View className="flex items-center justify-center">
|
<View className="flex items-center justify-center">
|
||||||
{user.avatar ? (
|
{userInfo.avatar ? (
|
||||||
<View
|
<View
|
||||||
className="w-12 h-12 rounded-full bg-blue-100 mr-3 overflow-hidden"
|
className="w-12 h-12 rounded-full bg-blue-100 mr-3 overflow-hidden"
|
||||||
style={{ backgroundImage: `url(${user.avatar})`, backgroundSize: 'cover' }}
|
style={{ backgroundImage: `url(${userInfo.avatar})`, backgroundSize: 'cover' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<View className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center mr-3">
|
<View className="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center mr-3">
|
||||||
@@ -370,10 +429,10 @@ const QRConfirmPage: React.FC = () => {
|
|||||||
)}
|
)}
|
||||||
<View className="text-left">
|
<View className="text-left">
|
||||||
<Text className="text-sm font-medium text-gray-800 block">
|
<Text className="text-sm font-medium text-gray-800 block">
|
||||||
{user.nickname || user.username || '用户'}
|
{userInfo.nickname || userInfo.username || '用户'}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-xs text-gray-500 block">
|
<Text className="text-xs text-gray-500 block">
|
||||||
ID: {user.userId}
|
ID: {userInfo.userId}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
Reference in New Issue
Block a user