正在加载文档...
文档内容较大,正在处理中,请稍候
正在加载文档...
文档内容较大,正在处理中,请稍候
App扫码登录Web端功能允许用户通过移动端App扫描Web登录页面的二维码,实现快速登录Web端系统。该功能采用轮询机制实现,无需SSE(Server-Sent Events)支持,适合各种部署环境。
┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Web端 │ │ 后端API │ │ App端 │
│ │ │ │ │ │
│ 1.生成二维码│────────>│ 生成Token │ │ │
│ │ │ 存储状态 │ │ │
│ │ │ │ │ │
│ 2.显示二维码│ │ │ │ 3.扫描二维码│
│ 开始轮询 │ │ │<────────│ │
│ │ │ │ │ │
│ 4.轮询状态 │────────>│ 检查状态 │ │ 5.确认登录 │
│ (每2秒) │ │ │<────────│ │
│ │ │ │ │ │
│ 6.获取信息 │<────────│ 返回用户信息│ │ │
│ 自动登录 │ │ │ │ │
└─────────────┘ └─────────────┘ └─────────────┘┌─────────────────────────────────────────────────────────────────┐
│ 扫码登录完整流程 │
└─────────────────────────────────────────────────────────────────┘
【阶段1:初始化】
Web端用户操作:点击切换到二维码登录
↓
Web端:调用 generateQrcodeToken() API
↓
后端:生成UUID token,状态设为pending,存储到内存/Redis
↓
后端:返回 { token, expiresIn: 300 }
↓
Web端:接收token,使用QRCodeSVG生成二维码图片
↓
Web端:启动倒计时(5分钟),开始轮询(每2秒)
【阶段2:扫码确认】
App端用户操作:打开App扫码页面,扫描二维码
↓
App端:识别UUID格式的token(正则验证)
↓
App端:检查是否已登录(检查accessToken)
├─ 未登录 → 提示"请先登录App",结束流程
└─ 已登录 → 继续
↓
App端:调用 confirmQrcodeLoginByApp(token) API
↓
后端:验证App token(JWT验证)
├─ 无效 → 返回401错误
└─ 有效 → 继续
↓
后端:获取用户信息,生成Web端token(accessToken + refreshToken)
↓
后端:获取用户菜单和权限信息
↓
后端:调用 confirmQrcodeLogin(token, userInfo)
↓
后端:更新状态为confirmed,存储userInfo,延长过期时间至60秒
↓
后端:返回成功响应
↓
App端:显示"登录确认成功,Web端将自动登录"
↓
App端:1.5秒后自动返回上一页
【阶段3:轮询检测】
Web端:轮询检测(每2秒调用 checkQrcodeLoginStatus(token))
↓
后端:检查token状态
├─ pending → 返回 { status: "pending", userInfo: null }
├─ scanned → 返回 { status: "scanned", userInfo: null }
├─ confirmed → 返回 { status: "confirmed", userInfo: {...} }
└─ expired → 返回 { status: "expired", userInfo: null }
↓
Web端:根据status更新UI
├─ pending → 显示"请使用WL-Admin移动端扫描二维码登录"
├─ scanned → 显示"已扫码,请在移动端确认登录"
├─ confirmed → 执行登录逻辑
└─ expired → 显示"二维码已过期,正在自动刷新..."
【阶段4:自动登录】
Web端:检测到confirmed状态
↓
Web端:提取userInfo中的信息
- accessToken, refreshToken
- userinfo(用户基本信息)
- menus(菜单列表)
- permissions(权限列表)
- sessionId(会话ID)
↓
Web端:处理菜单数据
- 生成扁平化文件列表(flatFiles)
- 生成树形菜单结构(treeMenus)
↓
Web端:存储到Redux store
- dispatch(setsessionid(sessionId))
- dispatch(login({ userinfo, accessToken, refreshToken }))
- dispatch(setmenus(treeMenus))
- dispatch(setpermissions(permissions))
- dispatch(setasyncfiles(flatFiles))
↓
Web端:停止轮询和倒计时
↓
Web端:显示成功提示"二维码登录成功"
↓
Web端:1秒后自动跳转到首页(navigate("/", { replace: true }))用户操作:点击切换到二维码登录
Web端处理:
generateQrcodeToken() API后端处理:
crypto.randomUUID()){
token: "uuid-string",
status: "pending",
expiresAt: Date.now() + 300000, // 5分钟后过期
createdAt: Date.now(),
userInfo: null
}{ token, expiresAt, expiresInSeconds: 300 }Web端后续:
用户操作:打开App扫码页面,扫描Web端的二维码
App端处理:
识别二维码内容
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i检查登录状态
调用确认接口
POST /api/qrcode/confirmAuthorization: Bearer <app-access-token>{ token: "uuid-string" }后端处理:
验证App token
获取用户信息
生成Web端token
client_type: "web"获取菜单和权限
确认登录
confirmQrcodeLogin(token, userInfo)confirmed返回响应
{
"code": 200,
"message": "登录确认成功",
"data": {
"token": "uuid-string",
"message": "Web端将自动登录"
}
}App端后续:
轮询机制:
checkQrcodeLoginStatus(token)后端处理:
{
status: "pending" | "scanned" | "confirmed" | "expired",
userInfo: null | { accessToken, refreshToken, userinfo, menus, permissions, sessionId },
reason: null | "expired" | "not_found"
}Web端状态处理:
| 状态 | 处理逻辑 | UI显示 |
|---|---|---|
pending |
继续轮询 | "请使用WL-Admin移动端扫描二维码登录" |
scanned |
继续轮询 | "已扫码,请在移动端确认登录" |
confirmed |
停止轮询,执行登录逻辑 | 执行登录并跳转 |
expired |
停止轮询,清除token | "二维码已过期,正在自动刷新..." |
触发条件:轮询检测到status === "confirmed"且userInfo不为空
处理步骤:
停止轮询和倒计时
isPollingActiveRef.current = false;
clearInterval(pollingIntervalRef.current);
clearInterval(countdownIntervalRef.current);提取用户信息
const { menus, permissions, userinfo, accessToken, refreshToken, sessionId } = userInfo;处理菜单数据
// 生成扁平化文件列表(用于动态路由)
const flatFiles = menus
.filter((item) => item.node_type !== 1 && item.component_path)
.map((item) => ({
path: item.route_path,
filepath: item.component_path,
title: item.name,
}));
// 生成树形菜单结构(用于侧边栏)
const treeMenus = getMenusTree(menus);存储到Redux
dispatch(setsessionid(sessionId));
dispatch(login({ userinfo, accessToken, refreshToken }));
dispatch(setmenus(treeMenus));
dispatch(setpermissions(permissions || []));
dispatch(setasyncfiles(flatFiles));显示成功提示并跳转
success("二维码登录成功", {
duration: 1,
onClose: () => {
navigate("/", { replace: true });
},
});触发场景:
处理流程:
Web端检测到过期
status: "expired"App端扫描过期二维码
触发场景:App端扫描二维码时,本地没有accessToken
处理流程:
getAccessToken()返回nullWeb端处理:
App端处理:
处理流程:
页面隐藏时
页面显示时
Web端 后端 App端
│ │ │
│──generateQrcode──>│ │
│<──token───────────│ │
│ │ │
│ [显示二维码] │ │
│ [开始轮询] │ │
│ │ │
│──checkStatus─────>│ │
│<──pending─────────│ │
│ │ │
│──checkStatus─────>│ │
│<──pending─────────│ │
│ │ │
│ │<──scan QR code─────│
│ │ │
│ │<──confirm(token)───│
│ │──verify App token─>│
│ │<──userInfo─────────│
│ │──generate Web token│
│ │──update status─────│
│ │──confirmed────────>│
│ │ │
│──checkStatus─────>│ │
│<──confirmed───────│ │
│ [执行登录] │ │
│ [跳转首页] │ │apps/react-antd-webpack/
├── src/
│ ├── pages/
│ │ └── login/
│ │ ├── index.jsx # 登录页面主文件
│ │ └── index.less # 样式文件
│ └── services/
│ └── auth.js # API服务const [qrcodeToken, setQrcodeToken] = useState(null); // 二维码token
const [qrcodeStatus, setQrcodeStatus] = useState("pending"); // 状态:pending/scanned/confirmed/expired
const [qrcodeExpiresAt, setQrcodeExpiresAt] = useState(null); // 过期时间戳
const [countdown, setCountdown] = useState(null); // 倒计时(秒)
const pollingIntervalRef = useRef(null); // 轮询定时器引用
const countdownIntervalRef = useRef(null); // 倒计时定时器引用
const isPollingActiveRef = useRef(false); // 轮询活跃状态标记| 状态值 | 说明 | UI显示 |
|---|---|---|
pending |
待扫码 | "请使用WL-Admin移动端扫描二维码登录" |
scanned |
已扫码(可选) | "已扫码,请在移动端确认登录" |
confirmed |
已确认 | 执行登录逻辑,跳转首页 |
expired |
已过期 | "二维码已过期,正在自动刷新..." |
const generateQrcode = async () => {
// 清除之前的轮询和倒计时
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
countdownIntervalRef.current = null;
}
const [, res] = await generateQrcodeToken();
if (res?.code === 200 && res?.data) {
const { token, expiresIn, expiresInSeconds } = res.data;
const expiresInValue = expiresIn || expiresInSeconds || 300; // 默认5分钟
const expiresAt = Date.now() + expiresInValue * 1000; // 计算过期时间戳
setQrcodeToken(token);
setQrcodeStatus("pending");
setQrcodeExpiresAt(expiresAt);
setCountdown(expiresInValue);
// 启动倒计时
startCountdown(expiresAt);
// 只有在页面可见时才开始轮询
if (document.visibilityState === "visible") {
startPolling(token);
}
}
};关键点:
const startCountdown = (expiresAt) => {
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
}
const updateCountdown = () => {
const now = Date.now();
const remaining = Math.max(0, Math.floor((expiresAt - now) / 1000));
setCountdown(remaining);
// 如果剩余时间少于30秒,自动刷新二维码
if (remaining <= 30 && remaining > 0 && qrcodeStatus !== "confirmed") {
console.log("[二维码登录] 二维码即将过期,自动刷新");
generateQrcode();
return;
}
if (remaining <= 0) {
setCountdown(null);
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
countdownIntervalRef.current = null;
}
}
};
// 立即更新一次
updateCountdown();
// 每秒更新一次
const interval = setInterval(updateCountdown, 1000);
countdownIntervalRef.current = interval;
};关键点:
const startPolling = (token) => {
// 清除之前的轮询
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
// 如果页面隐藏,不开始轮询
if (document.visibilityState === "hidden") {
console.log("[二维码登录] 页面隐藏,不开始轮询");
isPollingActiveRef.current = false;
return;
}
// 标记轮询为活跃状态
isPollingActiveRef.current = true;
// 立即检查一次
checkQrcodeStatusOnce(token);
const interval = setInterval(async () => {
// 检查轮询是否应该继续
if (!isPollingActiveRef.current) {
console.log("[二维码登录] 轮询已标记为停止");
clearInterval(interval);
pollingIntervalRef.current = null;
return;
}
// 每次轮询前都检查页面可见性
if (document.visibilityState === "hidden") {
console.log("[二维码登录] 页面隐藏,停止轮询");
isPollingActiveRef.current = false;
clearInterval(interval);
pollingIntervalRef.current = null;
return;
}
const handled = await checkQrcodeStatusOnce(token);
if (handled) {
// 如果已处理(登录成功或过期),停止轮询
console.log("[二维码登录] 登录状态已处理,停止轮询");
isPollingActiveRef.current = false;
clearInterval(interval);
pollingIntervalRef.current = null;
}
}, 2000); // 每2秒轮询一次
pollingIntervalRef.current = interval;
console.log("[二维码登录] 开始轮询,token:", token);
};关键点:
isPollingActiveRef 标记控制轮询状态const checkQrcodeStatusOnce = async (token) => {
if (!token) {
return false;
}
const [, res] = await checkQrcodeLoginStatus(token);
if (res?.code === 200 && res?.data) {
const { status, userInfo } = res.data;
setQrcodeStatus(status);
if (status === "confirmed" && userInfo) {
// 登录成功,停止轮询和倒计时
isPollingActiveRef.current = false;
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
countdownIntervalRef.current = null;
}
// 执行登录逻辑
handleQrcodeLoginSuccess(userInfo);
return true; // 返回true表示已处理
} else if (status === "expired") {
// 二维码过期
isPollingActiveRef.current = false;
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
countdownIntervalRef.current = null;
}
setQrcodeToken(null);
error("二维码已过期,请刷新二维码");
return true; // 返回true表示已处理
}
}
return false; // 返回false表示未处理,继续轮询
};关键点:
const handleQrcodeLoginSuccess = (userInfo) => {
const { menus, permissions, userinfo, accessToken, refreshToken, sessionId } = userInfo;
// 处理菜单数据
const flatFiles = menus
.filter((item) => item.node_type !== 1 && item.component_path)
.map((item) => ({
path: item.route_path,
filepath: item.component_path,
title: item.name,
}));
const treeMenus = getMenusTree(menus);
// 存储到Redux
dispatch(setsessionid(sessionId));
dispatch(login({ userinfo, accessToken, refreshToken }));
dispatch(setmenus(treeMenus));
dispatch(setpermissions(permissions || []));
dispatch(setasyncfiles(flatFiles));
// 显示成功提示并跳转
success("二维码登录成功", {
duration: 1,
onClose: () => {
navigate("/", { replace: true });
},
});
};关键点:
useEffect(() => {
if (logintype !== "qrcode" || !qrcodeToken) {
return;
}
const handleVisibilityChange = async () => {
console.log("[二维码登录] 页面可见性变化:", document.visibilityState);
if (document.visibilityState === "hidden") {
// 页面隐藏,立即停止轮询和倒计时
console.log("[二维码登录] 页面隐藏,清除轮询和倒计时");
isPollingActiveRef.current = false;
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
countdownIntervalRef.current = null;
}
} else if (document.visibilityState === "visible") {
// 页面显示,立即检查一次状态
console.log("[二维码登录] 页面显示,立即检查状态");
const handled = await checkQrcodeStatusOnce(qrcodeToken);
// 如果二维码存在且未过期,重新启动倒计时
if (qrcodeExpiresAt && qrcodeStatus !== "confirmed" && qrcodeStatus !== "expired") {
startCountdown(qrcodeExpiresAt);
}
if (!handled && pollingIntervalRef.current === null) {
// 如果未处理(未登录成功也未过期)且轮询已停止,重新开始轮询
console.log("[二维码登录] 重新开始轮询");
startPolling(qrcodeToken);
}
}
};
// 初始检查:如果页面隐藏,停止轮询
if (document.visibilityState === "hidden" && pollingIntervalRef.current) {
isPollingActiveRef.current = false;
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [logintype, qrcodeToken, qrcodeExpiresAt, qrcodeStatus]);关键点:
useEffect(() => {
if (logintype === "qrcode") {
// 切换到二维码登录,生成二维码
generateQrcode();
} else {
// 切换到密码登录,清除轮询
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
countdownIntervalRef.current = null;
}
setQrcodeToken(null);
setQrcodeStatus("pending");
setQrcodeExpiresAt(null);
setCountdown(null);
isPollingActiveRef.current = false;
}
// 组件卸载时清除轮询
return () => {
if (pollingIntervalRef.current) {
clearInterval(pollingIntervalRef.current);
pollingIntervalRef.current = null;
}
if (countdownIntervalRef.current) {
clearInterval(countdownIntervalRef.current);
countdownIntervalRef.current = null;
}
};
}, [logintype]);关键点:
{
logintype === "qrcode" && (
<div className={styles.qrcodebox}>
{qrcodeToken ? (
<>
{qrcodeStatus === "pending" && <h2>扫码登录</h2>}
<QRCodeSVG
value={qrcodeToken || ""}
width={250}
height={250}
bgColor={"#fff"}
fgColor={"#444444"}
/>
{qrcodeStatus === "scanned" && (
<p style={{ color: "#1890ff", marginTop: 10 }}>已扫码,请在移动端确认登录</p>
)}
{qrcodeStatus === "expired" && (
<p style={{ color: "#ff4d4f", marginTop: 10 }}>二维码已过期,正在自动刷新...</p>
)}
{/* 倒计时显示 */}
{countdown !== null && qrcodeStatus !== "expired" && (
<p
style={{
marginTop: 20,
color: countdown <= 30 ? "#ff4d4f" : "#666",
fontSize: "14px",
}}
>
二维码有效期:{formatCountdown(countdown)}
{countdown <= 30 && "(即将过期,将自动刷新)"}
</p>
)}
<Button type="link" onClick={generateQrcode} style={{ marginTop: 0 }}>
刷新二维码
</Button>
</>
) : (
<div style={{ textAlign: "center", padding: "50px 0" }}>
<p>正在生成二维码...</p>
</div>
)}
</div>
);
}关键点:
QRCodeSVG 组件生成二维码const formatCountdown = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins}:${secs.toString().padStart(2, "0")}`;
};文件:apps/react-antd-webpack/src/services/auth.js
// 生成二维码登录token
export function generateQrcodeToken() {
return Get("/api/qrcode/generate");
}
// 检查二维码登录状态
export function checkQrcodeLoginStatus(token) {
return Get("/api/qrcode/status", { token });
}页面可见性优化
自动刷新机制
资源清理
实时倒计时
状态提示
错误处理
apps/app-rn-ts/
├── app/
│ └── scan.tsx # 扫码页面
└── src/
├── services/
│ └── api/
│ └── auth.api.ts # API服务
└── services/
└── storage.ts # 本地存储服务const handleBarCodeScanned = async ({ data }: { data: string }) => {
if (scanned || confirming) return;
setScanned(true);
// 检查是否是登录二维码token(UUID格式)
const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
if (uuidRegex.test(data)) {
// 是登录二维码,检查App是否已登录
const token = await getAccessToken();
if (!token) {
setSnackbarMessage("请先登录App");
setSnackbarVisible(true);
setTimeout(() => {
setScanned(false);
}, 2000);
return;
}
// 确认登录
setConfirming(true);
const [error, response] = await confirmQrcodeLogin(data);
if (error) {
// 根据错误信息判断是否需要提示用户重新扫描
let errorMessage = error.message || "确认登录失败";
if (
errorMessage.includes("不存在") ||
errorMessage.includes("已失效") ||
errorMessage.includes("已过期")
) {
errorMessage = "二维码已失效,请重新扫描Web端的最新二维码";
}
setSnackbarMessage(errorMessage);
setSnackbarVisible(true);
setTimeout(() => {
setScanned(false);
setConfirming(false);
}, 3000);
} else if (response?.code === 200) {
setSnackbarMessage("登录确认成功,Web端将自动登录");
setSnackbarVisible(true);
setTimeout(() => {
router.back();
}, 1500);
}
} else {
// 其他类型的二维码,显示结果
setSnackbarMessage(`扫描结果:${data}`);
setSnackbarVisible(true);
setTimeout(() => {
setScanned(false);
}, 2000);
}
};关键点:
文件:apps/app-rn-ts/src/services/api/auth.api.ts
export async function confirmQrcodeLogin(
token: string
): Promise<[Error | null, ApiResponse<any> | undefined]> {
try {
const { getAccessToken } = await import("@services/storage");
const accessToken = await getAccessToken();
if (!accessToken) {
return [new Error("请先登录App"), undefined];
}
const response = await fetch(`${apiConfig.baseURL}${API_ENDPOINTS.QRCODE_CONFIRM}`, {
method: "POST",
headers: {
...apiConfig.headers,
Authorization: `Bearer ${accessToken}`,
},
body: JSON.stringify({ token }),
});
const data: ApiResponse<any> = await response.json();
if (response.ok && data.code === 200) {
return [null, data];
} else {
const errorMsg = data.message || `确认登录失败 (${response.status})`;
return [new Error(errorMsg), undefined];
}
} catch (error) {
return [error instanceof Error ? error : new Error("网络请求失败"), undefined];
}
}关键点:
{
confirming ? (
<View style={styles.confirmingContainer}>
<ActivityIndicator size="small" color="#fff" />
<PaperText variant="bodyMedium" style={styles.hint}>
正在确认登录...
</PaperText>
</View>
) : (
<PaperText variant="bodyMedium" style={styles.hint}>
将二维码放入框内即可自动扫描
</PaperText>
);
}const [permission, requestPermission] = useCameraPermissions();
useEffect(() => {
// 请求相机权限
if (permission && !permission.granted) {
requestPermission();
}
}, [permission]);关键点:
expo-camera 的权限Hook打开扫码页面
扫描二维码
确认登录
完成登录
apps/node-express-mysql/
├── src/
│ ├── services/
│ │ └── qrcodeLoginService.js # 二维码登录服务
│ ├── controllers/
│ │ └── publicAuthController.js # 控制器方法
│ └── routes/
│ └── publicAuthRoutes.js # 路由配置
└── config/
└── base/
└── qrcodeLogin.js # 配置文件const generateQrcodeToken = async () => {
const token = crypto.randomUUID();
const expireSeconds = Number(config?.qrcodeLogin?.expireTime || 300); // 5分钟过期
const expiresAt = Date.now() + expireSeconds * 1000;
const qrcodeData = {
token,
status: "pending", // pending: 待扫码, scanned: 已扫码, confirmed: 已确认, expired: 已过期
expiresAt,
createdAt: Date.now(),
userInfo: null, // 扫码确认后存储用户信息
};
if (type === "redis" && isRedisEnabled() && getRedisClient()) {
const key = `${prefix}${token}`;
const client = getRedisClient();
await client.set(key, JSON.stringify(qrcodeData), "EX", expireSeconds);
logger.info("二维码token生成成功", { token, store: "redis", key, ttl: expireSeconds });
} else {
qrcodeLoginStore.set(token, qrcodeData);
logger.info("二维码token生成成功", {
token,
store: "memory",
expiresIn: expireSeconds,
expiresAt: new Date(expiresAt).toISOString(),
storeSize: qrcodeLoginStore.size,
});
}
return { token, expiresAt, expiresInSeconds: expireSeconds };
};关键点:
crypto.randomUUID() 生成唯一tokenconst checkQrcodeStatus = async (token) => {
if (!token) {
return { status: "invalid", userInfo: null, reason: "missing_token" };
}
let qrcodeData = null;
if (type === "redis" && isRedisEnabled() && getRedisClient()) {
const client = getRedisClient();
const key = `${prefix}${token}`;
const val = await client.get(key);
if (!val) {
logger.warn("二维码token不存在或已过期", { token, store: "redis" });
return { status: "expired", userInfo: null, reason: "expired" };
}
qrcodeData = JSON.parse(val);
} else {
qrcodeData = qrcodeLoginStore.get(token);
if (!qrcodeData) {
logger.warn("二维码token不存在", { token, store: "memory" });
return { status: "not_found", userInfo: null, reason: "not_found" };
}
if (Date.now() > qrcodeData.expiresAt) {
qrcodeLoginStore.delete(token);
logger.warn("二维码token已过期", { token, store: "memory" });
return { status: "expired", userInfo: null, reason: "expired" };
}
}
return {
status: qrcodeData.status,
userInfo: qrcodeData.userInfo,
reason: null,
};
};关键点:
const confirmQrcodeLogin = async (token, userInfo) => {
if (!token || !userInfo) {
return { ok: false, reason: "missing_params" };
}
let qrcodeData = null;
if (type === "redis" && isRedisEnabled() && getRedisClient()) {
const client = getRedisClient();
const key = `${prefix}${token}`;
const val = await client.get(key);
if (!val) {
logger.warn("二维码token不存在或已过期", { token, store: "redis" });
return { ok: false, reason: "expired" };
}
qrcodeData = JSON.parse(val);
if (qrcodeData.status !== "pending" && qrcodeData.status !== "scanned") {
logger.warn("二维码状态不正确", { token, status: qrcodeData.status });
return { ok: false, reason: "invalid_status" };
}
// 更新状态和用户信息
qrcodeData.status = "confirmed";
qrcodeData.userInfo = userInfo;
qrcodeData.confirmedAt = Date.now();
// 延长过期时间,给Web端足够时间轮询
const expireSeconds = 60; // 确认后60秒内有效
await client.set(key, JSON.stringify(qrcodeData), "EX", expireSeconds);
logger.info("二维码登录确认成功", { token, store: "redis", userId: userInfo?.id });
} else {
qrcodeData = qrcodeLoginStore.get(token);
if (!qrcodeData || Date.now() > qrcodeData.expiresAt) {
return { ok: false, reason: "expired" };
}
if (qrcodeData.status !== "pending" && qrcodeData.status !== "scanned") {
return { ok: false, reason: "invalid_status" };
}
// 更新状态和用户信息
qrcodeData.status = "confirmed";
qrcodeData.userInfo = userInfo;
qrcodeData.confirmedAt = Date.now();
// 延长过期时间
qrcodeData.expiresAt = Date.now() + 60000; // 确认后60秒内有效
logger.info("二维码登录确认成功", { token, store: "memory", userId: userInfo?.id });
}
return { ok: true, reason: null };
};关键点:
支持两种存储方式:
内存存储(默认)
Map 数据结构Redis存储
type: 'redis'const generateQrcode = async (req, res) => {
try {
const result = await generateQrcodeToken();
success(req, res, "二维码生成成功", {
token: result.token,
expiresIn: result.expiresInSeconds,
});
} catch (err) {
logger.error("生成二维码错误:", err);
serverError(req, res, "生成二维码失败");
}
};const checkQrcodeLoginStatus = async (req, res) => {
try {
const { token } = req.query;
if (!token) {
return badRequest(req, res, "缺少token参数");
}
const status = await checkQrcodeStatus(token);
success(req, res, "查询成功", status);
} catch (err) {
logger.error("检查二维码登录状态错误:", err);
serverError(req, res, "查询失败");
}
};const confirmQrcodeLoginByApp = async (req, res) => {
try {
const { token } = req.body;
if (!token) {
return badRequest(req, res, "缺少token参数");
}
// 从请求头获取App的token(App需要先登录)
const authHeader = req.headers.authorization;
if (!authHeader || !authHeader.startsWith("Bearer ")) {
return unauthorized(req, res, "需要App登录token");
}
const appToken = authHeader.substring(7);
// 验证App token并获取用户信息
let decoded;
try {
decoded = jwt.verify(appToken, jwtConfig.secret);
} catch (err) {
return unauthorized(req, res, "App token无效或已过期");
}
// 获取用户信息
const user = await User.findById(decoded.id, decoded.id);
if (!user) {
return unauthorized(req, res, "用户不存在");
}
// 生成Web端的sessionId和token
const sessionId = generateSessionId();
const accessToken = jwt.sign(
{
id: user.id,
username: user.username,
sessionid: sessionId,
jti: Date.now().toString(36) + Math.random().toString(36).substr(2),
},
jwtConfig.secret,
{
expiresIn: jwtConfig.expiresIn,
algorithm: jwtConfig.algorithm,
notBefore: 0,
}
);
const refreshToken = jwt.sign(
{
id: user.id,
username: user.username,
sessionid: sessionId,
jti: Date.now().toString(36) + Math.random().toString(36).substr(2),
},
jwtConfig.refreshSecret,
{
expiresIn: jwtConfig.refreshExpiresIn,
algorithm: jwtConfig.algorithm,
notBefore: 0,
}
);
// 获取用户菜单和权限
const menus = await MenuModel.findMenusByUserId(user.id);
const permissions = await PermissionModel.getPermissionCodesByUserId(user.id);
// 构造用户信息
const userinfo = {
id: user.id,
is_admin: user.is_admin,
username: user.username,
name: user.name,
// ... 其他用户信息
};
// 确认二维码登录
const confirmResult = await confirmQrcodeLogin(token, {
accessToken,
refreshToken,
userinfo,
menus: uniqueMenus,
permissions: uniquePermissions,
sessionId,
});
if (!confirmResult.ok) {
let errorMessage = "确认失败";
if (confirmResult.reason === "expired") {
errorMessage = "二维码已过期";
} else if (confirmResult.reason === "not_found") {
errorMessage = "二维码不存在或已失效";
} else if (confirmResult.reason === "invalid_status") {
errorMessage = "二维码状态不正确";
}
return badRequest(req, res, errorMessage);
}
success(req, res, "登录确认成功", {
token,
message: "Web端将自动登录",
});
} catch (err) {
logger.error("App确认二维码登录错误:", err);
serverError(req, res, "确认登录失败");
}
};关键点:
文件:apps/node-express-mysql/src/routes/publicAuthRoutes.js
// 二维码登录相关路由
router.get("/qrcode/generate", publicAuthController.generateQrcode); // Web端生成二维码
router.get("/qrcode/status", publicAuthController.checkQrcodeLoginStatus); // Web端轮询检查状态
router.post("/qrcode/confirm", publicAuthController.confirmQrcodeLoginByApp); // App确认登录配置文件:apps/node-express-mysql/config/base/qrcodeLogin.js
module.exports = {
// 存储类型:memory(内存)或 redis
type: "memory",
// Redis key前缀
cachePrefix: "qrcode_login_",
// 二维码过期时间(秒),默认5分钟
expireTime: 300,
// 清理间隔(毫秒),默认60秒
cleanupInterval: 60000,
};环境变量覆盖:可在 config/env/ 目录下的环境配置文件中覆盖这些值。
| 端点 | 方法 | 认证 | 说明 |
|---|---|---|---|
/api/qrcode/generate |
GET | 否 | 生成二维码token |
/api/qrcode/status |
GET | 否 | 检查登录状态(需要token参数) |
/api/qrcode/confirm |
POST | 是(App token) | App确认登录 |
可能原因:
解决方法:
/api/qrcode/generate)可能原因:
解决方法:
/api/qrcode/status)可能原因:
解决方法:
可能原因:
解决方法:
打开浏览器开发者工具:
代码中已包含详细的调试日志,可以通过以下方式查看:
console.log("[二维码登录] Token:", qrcodeToken);
console.log("[二维码登录] Status:", qrcodeStatus);
console.log("[二维码登录] 轮询响应:", res);查看日志:
console.log("[扫码] 扫描结果:", data);
console.log("[扫码] 确认登录响应:", response);检查网络请求:
查看日志:
# 查看二维码登录相关日志
tail -f logs/app.log | grep qrcode检查Redis(如果使用):
redis-cli
> KEYS qrcode_login_*
> GET qrcode_login_<token>检查内存存储:
// 在服务端代码中添加调试日志
console.log("当前存储的token数量:", qrcodeLoginStore.size);
console.log("所有token:", Array.from(qrcodeLoginStore.keys()));Token验证
过期机制
一次性使用
HTTPS传输
限制请求频率
Token加密
日志记录
异常监控
存储选择
定期清理
日志优化
轮询优化
状态管理
错误处理
扫码状态提示
markQrcodeScanned功能二维码刷新
多设备登录
登录历史
实现简单
资源消耗可控
稳定性高
用户体验良好
实时性有限
资源消耗
功能限制
安全性可提升
使用SSE替代轮询机制,实现真正的实时推送,提升用户体验和系统效率。
Web端 后端 App端
│ │ │
│──generateQrcode──>│ │
│<──token───────────│ │
│ │ │
│──SSE连接─────────>│ │
│<──保持连接─────────│ │
│ │ │
│ │<──scan QR code─────│
│ │ │
│ │<──confirm(token)───│
│ │──verify token─────>│
│ │──update status─────│
│<──推送confirmed───│ │
│ [执行登录] │ │服务端实现:
// 建立SSE连接
router.get("/qrcode/stream/:token", (req, res) => {
const { token } = req.params;
res.writeHead(200, {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
Connection: "keep-alive",
});
// 监听二维码状态变化
const checkStatus = setInterval(async () => {
const status = await checkQrcodeStatus(token);
res.write(`data: ${JSON.stringify(status)}\n\n`);
if (status.status === "confirmed" || status.status === "expired") {
clearInterval(checkStatus);
res.end();
}
}, 1000); // 1秒检查一次
req.on("close", () => {
clearInterval(checkStatus);
});
});Web端实现:
const eventSource = new EventSource(`/api/qrcode/stream/${token}`);
eventSource.onmessage = (event) => {
const status = JSON.parse(event.data);
setQrcodeStatus(status.status);
if (status.status === "confirmed" && status.userInfo) {
handleQrcodeLoginSuccess(status.userInfo);
eventSource.close();
}
};
eventSource.onerror = () => {
// 连接错误,降级到轮询
startPolling(token);
};使用WebSocket实现双向实时通信,支持更复杂的交互场景。
Web端 后端 App端
│ │ │
│──generateQrcode──>│ │
│<──token───────────│ │
│ │ │
│──WebSocket连接───>│ │
│<──保持连接─────────│ │
│ │ │
│ │<──scan QR code─────│
│ │ │
│ │<──confirm(token)───│
│ │──verify token─────>│
│ │──update status─────│
│<──推送confirmed───│ │
│ [执行登录] │ │服务端实现:
const WebSocket = require("ws");
const wss = new WebSocket.Server({ port: 8080 });
wss.on("connection", (ws, req) => {
const token = new URL(req.url, "http://localhost").searchParams.get("token");
// 监听二维码状态变化
const checkStatus = setInterval(async () => {
const status = await checkQrcodeStatus(token);
ws.send(JSON.stringify(status));
if (status.status === "confirmed" || status.status === "expired") {
clearInterval(checkStatus);
ws.close();
}
}, 1000);
ws.on("close", () => {
clearInterval(checkStatus);
});
});Web端实现:
const ws = new WebSocket(`ws://localhost:8080?token=${token}`);
ws.onmessage = (event) => {
const status = JSON.parse(event.data);
setQrcodeStatus(status.status);
if (status.status === "confirmed" && status.userInfo) {
handleQrcodeLoginSuccess(status.userInfo);
ws.close();
}
};
ws.onerror = () => {
// 连接错误,降级到轮询
startPolling(token);
};在现有轮询基础上进行优化,实现更智能的轮询策略。
1. 自适应轮询间隔
const startAdaptivePolling = (token) => {
let interval = 2000; // 初始2秒
let consecutivePendingCount = 0;
const poll = async () => {
const status = await checkQrcodeStatus(token);
if (status.status === "pending") {
consecutivePendingCount++;
// 如果连续多次pending,逐渐增加轮询间隔
if (consecutivePendingCount > 10) {
interval = Math.min(interval * 1.5, 10000); // 最多10秒
}
} else {
consecutivePendingCount = 0;
interval = 2000; // 重置为2秒
}
// 根据状态调整下次轮询时间
setTimeout(poll, interval);
};
poll();
};2. 长轮询(Long Polling)
// 服务端:等待状态变化或超时
const longPollStatus = async (req, res) => {
const { token } = req.query;
const timeout = 30000; // 30秒超时
const checkStatus = async () => {
const status = await checkQrcodeStatus(token);
if (status.status !== "pending") {
return res.json(status);
}
};
// 每5秒检查一次,最多等待30秒
const interval = setInterval(checkStatus, 5000);
setTimeout(() => {
clearInterval(interval);
res.json(await checkQrcodeStatus(token));
}, timeout);
};3. 事件驱动轮询
// 服务端:当状态变化时通知所有轮询请求
const statusWatchers = new Map();
const notifyStatusChange = (token, status) => {
const watchers = statusWatchers.get(token) || [];
watchers.forEach((res) => {
res.json(status);
});
statusWatchers.delete(token);
};
// 轮询接口:等待状态变化
router.get("/qrcode/status", async (req, res) => {
const { token } = req.query;
const watchers = statusWatchers.get(token) || [];
watchers.push(res);
statusWatchers.set(token, watchers);
// 30秒超时
setTimeout(() => {
const index = watchers.indexOf(res);
if (index > -1) {
watchers.splice(index, 1);
res.json(await checkQrcodeStatus(token));
}
}, 30000);
});增强二维码的安全性,防止重放攻击和伪造。
1. Token签名
const crypto = require("crypto");
const generateSignedToken = () => {
const token = crypto.randomUUID();
const timestamp = Date.now();
const secret = process.env.QRCODE_SECRET;
// 生成签名
const signature = crypto
.createHmac("sha256", secret)
.update(`${token}:${timestamp}`)
.digest("hex");
// 返回签名后的token
return `${token}:${timestamp}:${signature}`;
};
const verifyToken = (signedToken) => {
const [token, timestamp, signature] = signedToken.split(":");
const secret = process.env.QRCODE_SECRET;
// 验证签名
const expectedSignature = crypto
.createHmac("sha256", secret)
.update(`${token}:${timestamp}`)
.digest("hex");
// 验证时间戳(5分钟内有效)
const age = Date.now() - parseInt(timestamp);
if (age > 300000) {
return false;
}
return signature === expectedSignature;
};2. 加密Token
const crypto = require("crypto");
const encryptToken = (token) => {
const algorithm = "aes-256-gcm";
const key = crypto.scryptSync(process.env.QRCODE_KEY, "salt", 32);
const iv = crypto.randomBytes(16);
const cipher = crypto.createCipheriv(algorithm, key, iv);
let encrypted = cipher.update(token, "utf8", "hex");
encrypted += cipher.final("hex");
const authTag = cipher.getAuthTag();
return {
encrypted,
iv: iv.toString("hex"),
authTag: authTag.toString("hex"),
};
};3. 防重放机制
// 使用Redis记录已使用的token
const usedTokens = new Set();
const confirmQrcodeLogin = async (token, userInfo) => {
// 检查token是否已被使用
if (usedTokens.has(token)) {
return { ok: false, reason: "token_already_used" };
}
// ... 确认逻辑 ...
// 标记token为已使用
usedTokens.add(token);
setTimeout(() => usedTokens.delete(token), 60000); // 60秒后清理
return { ok: true };
};实现"已扫码"状态的实时反馈,提升用户体验。
方案A:App端主动标记
// App端扫描后立即调用
const markScanned = async (token) => {
await fetch("/api/qrcode/mark-scanned", {
method: "POST",
body: JSON.stringify({ token }),
});
};
// 服务端更新状态
const markQrcodeScanned = async (token) => {
const qrcodeData = await getQrcodeData(token);
if (qrcodeData.status === "pending") {
qrcodeData.status = "scanned";
await saveQrcodeData(token, qrcodeData);
}
};方案B:服务端检测
// 服务端:检测到确认请求时,先标记为scanned
const confirmQrcodeLoginByApp = async (req, res) => {
const { token } = req.body;
// 先标记为已扫码
await markQrcodeScanned(token);
// 延迟100ms后确认,给Web端时间看到"已扫码"状态
setTimeout(async () => {
await confirmQrcodeLogin(token, userInfo);
}, 100);
};优先使用SSE,失败时自动降级到轮询,兼顾实时性和兼容性。
const connectWithFallback = async (token) => {
// 优先尝试SSE
try {
const eventSource = new EventSource(`/api/qrcode/stream/${token}`);
eventSource.onmessage = (event) => {
const status = JSON.parse(event.data);
handleStatusChange(status);
};
eventSource.onerror = () => {
// SSE失败,降级到轮询
eventSource.close();
startPolling(token);
};
return () => eventSource.close();
} catch (error) {
// 直接使用轮询
return startPolling(token);
}
};| 方案 | 实时性 | 实现复杂度 | 资源消耗 | 兼容性 | 推荐度 |
|---|---|---|---|---|---|
| 当前方案(轮询) | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| SSE实时推送 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| WebSocket | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ |
| 智能轮询 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| 安全增强 | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
| 状态反馈 | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ |
| 混合方案 | ⭐⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ |
实现扫码状态反馈
安全增强
智能轮询优化
SSE实时推送
混合方案
WebSocket支持
多端登录管理
AI智能优化
高优先级(立即实施)
中优先级(3个月内)
低优先级(6个月后)
渐进式升级
A/B测试
降级机制
| 版本 | 日期 | 变更内容 |
|---|---|---|
| 1.0.0 | 2025-12-10 | 初始版本发布 |
| - 实现基础二维码登录功能 | ||
| - 支持轮询机制 | ||
| - 支持Redis和内存存储 | ||
| - Web端和App端完整实现 | ||
| - 完整的错误处理和状态提示 | ||
| - 页面可见性优化 | ||
| - 自动刷新机制 | ||
| - 添加未来升级方案章节 |
在浏览器控制台可以检查:
// 检查轮询状态
console.log("轮询活跃:", isPollingActiveRef.current);
console.log("轮询定时器:", pollingIntervalRef.current);
// 检查倒计时
console.log("倒计时:", countdown);
console.log("过期时间:", new Date(qrcodeExpiresAt));未来的路还很长