QR码登录实现规约
📱 功能概述
QR码登录是一种跨设备登录方式,允许用户通过手机小程序扫码在PC端完成登录。该功能支持微信和支付宝两个平台,提供安全便捷的登录体验。
🔄 登录流程
PC端(被扫端) 服务端 手机端(扫码端) | | | |-- 1. 生成二维码 ---------->| | |<-- 2. 返回二维码URL ------| | | | | | |<-- 3. 扫码确认 ----------| | |-- 4. 返回确认结果 ------->| | | | |-- 5. 轮询登录状态 ------->| | |<-- 6. 返回登录结果 -------| |🛠️ API接口设计
1. 生成扫码登录会话(PC端)
接口路径: user.generateQrCodeLogin
请求方法: POST
是否需要认证: 否
请求参数:
interface GenerateQrCodeLoginRequest {
platform: "wechat" | "alipay"; // 平台类型
deviceId?: string; // PC端设备ID(可选)
deviceInfo?: string; // PC端设备信息(可选)
}响应数据:
interface GenerateQrCodeLoginResponse {
sessionKey: string; // 会话key,用于后续轮询
qrCodeUrl: string; // 二维码URL
expiresAt: number; // 过期时间戳
expiresIn: number; // 过期倒计时(秒)
}2. 确认扫码登录(小程序端)
接口路径: user.confirmQrCodeLogin
请求方法: POST
是否需要认证: 是(需要小程序登录态)
请求参数:
interface ConfirmQrCodeLoginRequest {
sessionKey: string; // 会话key(从二维码中解析)
scanDeviceId?: string; // 扫码端设备ID(可选)
scanDeviceInfo?: string; // 扫码端设备信息(可选)
}3. 轮询扫码登录状态(PC端)
接口路径: user.pollQrCodeLogin
请求方法: POST
是否需要认证: 否
状态说明:
| 状态 | 说明 | 下一步操作 |
|---|---|---|
pending | 等待扫码 | PC端继续轮询 |
scanned | 已扫码,等待确认 | PC端继续轮询 |
confirmed | 已确认,登录成功 | PC端停止轮询,使用返回的token |
expired | 会话已过期 | PC端重新生成二维码 |
cancelled | 会话已取消 | PC端重新生成二维码 |
🔧 开发规约实现
Service 层实现规范
import type { UserResponse } from "../types/user";
import type {
GenerateQrCodeLoginRequest,
ConfirmQrCodeLoginRequest,
PollQrCodeLoginRequest
} from "../types/qrLogin";
import { BaseService } from "../types/BaseService";
import { ValidationException, NotFoundException } from "../exceptions/AppException";
// 数据库集合定义
const db = cloud.database();
const qrLoginSessionsCollection = db.collection("qrLoginSessions");
/**
* QR码登录服务类
* 提供QR码登录会话的创建、确认、轮询等核心功能
*/
export class QrLoginService extends BaseService {
/**
* 生成QR码登录会话
*/
public async generateQrCodeLogin(
params: GenerateQrCodeLoginRequest,
_userInfo: UserResponse, // PC端不需要用户信息
): Promise<GenerateQrCodeLoginResponse> {
// 参数验证
if (!params.platform) {
throw new ValidationException("平台类型不能为空");
}
if (!["wechat", "alipay"].includes(params.platform)) {
throw new ValidationException("不支持的平台类型");
}
// 生成会话key
const sessionKey = this.generateSessionKey();
const expiresAt = Date.now() + 5 * 60 * 1000; // 5分钟过期
// 创建会话记录
const sessionData = {
sessionKey,
platform: params.platform,
deviceId: params.deviceId,
deviceInfo: params.deviceInfo,
status: "pending",
expiresAt,
createdAt: Date.now(),
updatedAt: Date.now()
};
await qrLoginSessionsCollection.add(sessionData);
// 生成二维码URL
const qrCodeUrl = this.generateQrCodeUrl(sessionKey);
return {
sessionKey,
qrCodeUrl,
expiresAt,
expiresIn: 300 // 5分钟
};
}
/**
* 确认QR码登录
*/
public async confirmQrCodeLogin(
params: ConfirmQrCodeLoginRequest,
userInfo: UserResponse,
): Promise<{ success: boolean; }> {
// 参数验证
if (!params.sessionKey) {
throw new ValidationException("会话key不能为空");
}
// 查找会话
const session = await this.validateRecordExists(
qrLoginSessionsCollection,
{ sessionKey: params.sessionKey },
"QR码登录会话不存在或已过期"
);
// 检查会话状态
if (session.status !== "scanned") {
throw new ValidationException("会话状态不正确");
}
// 更新会话状态
await qrLoginSessionsCollection
.doc(session._id)
.update({
status: "confirmed",
userId: userInfo.id,
scanDeviceId: params.scanDeviceId,
scanDeviceInfo: params.scanDeviceInfo,
confirmedAt: Date.now(),
updatedAt: Date.now()
});
return { success: true };
}
/**
* 轮询QR码登录状态
*/
public async pollQrCodeLogin(
params: PollQrCodeLoginRequest,
_userInfo: UserResponse, // PC端不需要用户信息
): Promise<PollQrCodeLoginResponse> {
// 参数验证
if (!params.sessionKey) {
throw new ValidationException("会话key不能为空");
}
// 查找会话
const session = await qrLoginSessionsCollection
.where({ sessionKey: params.sessionKey })
.get();
if (session.data.length === 0) {
throw new NotFoundException("QR码登录会话不存在");
}
const sessionData = session.data[0];
// 检查是否过期
if (Date.now() > sessionData.expiresAt) {
return {
status: "expired",
message: "会话已过期"
};
}
// 根据状态返回不同信息
switch (sessionData.status) {
case "pending":
return {
status: "pending",
message: "等待扫码"
};
case "scanned":
return {
status: "scanned",
message: "已扫码,等待确认"
};
case "confirmed":
// 获取用户信息和token
const user = await this.getUserById(sessionData.userId);
const token = this.generateToken(user);
return {
status: "confirmed",
message: "登录成功",
token,
user,
scanDeviceInfo: sessionData.scanDeviceInfo
};
default:
return {
status: "cancelled",
message: "会话已取消"
};
}
}
/**
* 生成会话key
*/
private generateSessionKey(): string {
return `qr_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
}
/**
* 生成二维码URL
*/
private generateQrCodeUrl(sessionKey: string): string {
const baseUrl = process.env.QR_CODE_BASE_URL || "https://penny-lens.com/qr";
return `${baseUrl}?key=${sessionKey}`;
}
}Controller 层实现规范
import type { AppResponse } from "../types/appResponse";
import type { UserResponse } from "../types/user";
import type {
GenerateQrCodeLoginRequest,
ConfirmQrCodeLoginRequest,
PollQrCodeLoginRequest,
GenerateQrCodeLoginResponse,
PollQrCodeLoginResponse,
} from "../types/qrLogin";
import { QrLoginService } from "../services/qrLoginService";
import { BaseController } from "../types/BaseController";
/**
* QR码登录控制器
*/
export class QrLoginController extends BaseController {
private qrLoginService: QrLoginService;
constructor() {
super();
this.qrLoginService = new QrLoginService();
}
/**
* 生成QR码登录会话
*/
async generateQrCodeLogin(
params: GenerateQrCodeLoginRequest,
_userInfo: UserResponse, // PC端不需要用户信息
): Promise<AppResponse<GenerateQrCodeLoginResponse>> {
return this.wrapAsync(
async () => {
return this.qrLoginService.generateQrCodeLogin(params, _userInfo);
},
"生成QR码登录会话成功",
);
}
/**
* 确认QR码登录
*/
async confirmQrCodeLogin(
params: ConfirmQrCodeLoginRequest,
userInfo: UserResponse,
): Promise<AppResponse<{ success: boolean; }>> {
return this.wrapAsync(
async () => {
return this.qrLoginService.confirmQrCodeLogin(params, userInfo);
},
"确认QR码登录成功",
);
}
/**
* 轮询QR码登录状态
*/
async pollQrCodeLogin(
params: PollQrCodeLoginRequest,
_userInfo: UserResponse, // PC端不需要用户信息
): Promise<AppResponse<PollQrCodeLoginResponse>> {
return this.wrapAsync(
async () => {
return this.qrLoginService.pollQrCodeLogin(params, _userInfo);
},
"轮询QR码登录状态成功",
);
}
}路由配置规范
// QR码登录相关路由
export const qrLoginActions = [
{ action: "generateQrCodeLogin", requireAuth: false }, // PC端生成二维码不需要认证
{ action: "confirmQrCodeLogin", requireAuth: true }, // 小程序端确认需要认证
{ action: "pollQrCodeLogin", requireAuth: false }, // PC端轮询不需要认证
];
// 构建路由配置
export const routes: Record<string, RouteConfig> = {
...buildRoutes("user", qrLoginActions, qrLoginController),
};💻 前端实现指南
PC端(被扫端)实现
1. 生成二维码
async function generateQRCode() {
try {
const response = await fetch('/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'user.generateQrCodeLogin',
platform: 'wechat', // 或 'alipay'
deviceId: getDeviceId(),
deviceInfo: getDeviceInfo()
})
});
const result = await response.json();
if (result.code === 200) {
const { sessionKey, qrCodeUrl, expiresIn } = result.data;
// 显示二维码
displayQRCode(qrCodeUrl);
// 开始轮询
startPolling(sessionKey);
// 设置过期倒计时
startCountdown(expiresIn);
}
} catch (error) {
console.error('生成二维码失败:', error);
}
}2. 轮询登录状态
function startPolling(sessionKey) {
const pollInterval = setInterval(async () => {
try {
const response = await fetch('/api', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
action: 'user.pollQrCodeLogin',
sessionKey
})
});
const result = await response.json();
if (result.code === 200) {
const { status, token, user, scanDeviceInfo } = result.data;
switch (status) {
case 'pending':
updateStatus('等待扫码...');
break;
case 'scanned':
updateStatus('已扫码,等待确认...');
break;
case 'confirmed':
// 登录成功
clearInterval(pollInterval);
handleLoginSuccess(token, user, scanDeviceInfo);
break;
case 'expired':
case 'cancelled':
clearInterval(pollInterval);
handleLoginFailed(status);
break;
}
}
} catch (error) {
console.error('轮询失败:', error);
}
}, 2000); // 每2秒轮询一次
}小程序端(扫码端)实现
1. 扫码识别
// 微信小程序
wx.scanCode({
success: (res) => {
const sessionKey = extractSessionKey(res.result);
if (sessionKey) {
confirmQRLogin(sessionKey);
} else {
wx.showToast({ title: '无效的二维码', icon: 'error' });
}
},
fail: (error) => {
console.error('扫码失败:', error);
}
});
// 支付宝小程序
my.scan({
success: (res) => {
const sessionKey = extractSessionKey(res.code);
if (sessionKey) {
confirmQRLogin(sessionKey);
} else {
my.showToast({ content: '无效的二维码' });
}
},
fail: (error) => {
console.error('扫码失败:', error);
}
});2. 确认登录
async function confirmQRLogin(sessionKey) {
try {
// 检查用户是否已登录
const userInfo = await getUserInfo();
if (!userInfo) {
showMessage('请先登录小程序');
return;
}
const response = await request({
url: '/api',
method: 'POST',
data: {
action: 'user.confirmQrCodeLogin',
sessionKey,
scanDeviceId: getDeviceId(),
scanDeviceInfo: getDeviceInfo()
}
});
if (response.code === 200) {
showMessage('确认成功,PC端将自动登录');
} else {
showMessage(response.message || '确认失败');
}
} catch (error) {
console.error('确认登录失败:', error);
showMessage('确认失败,请重试');
}
}⚠️ 错误处理
常见错误码
| 错误码 | 说明 | 处理方式 |
|---|---|---|
| 400 | 请求参数错误 | 检查请求参数格式 |
| 401 | 未授权 | 小程序端需要先登录 |
| 404 | 会话不存在 | 重新生成二维码 |
| 410 | 会话已过期 | 重新生成二维码 |
| 500 | 服务器错误 | 稍后重试 |
错误处理示例
function handleError(error) {
switch (error.code) {
case 404:
showMessage('二维码已失效,请重新生成');
generateQRCode();
break;
case 410:
showMessage('二维码已过期,请重新生成');
generateQRCode();
break;
default:
showMessage('操作失败,请重试');
}
}🔒 安全注意事项
- 会话有效期: 二维码默认5分钟过期,过期后需要重新生成
- 设备信息: 建议收集设备信息用于安全审计
- Token安全: 登录成功后妥善保存Token,避免泄露
- HTTPS: 生产环境必须使用HTTPS协议
- 频率限制: 建议对轮询请求进行频率限制
🧪 测试用例
正常流程测试
- PC端生成二维码
- 手机端扫码并确认
- PC端轮询获取登录结果
- 验证登录状态和用户信息
异常情况测试
- 二维码过期测试
- 重复扫码测试
- 网络异常测试
- 用户取消测试
📝 开发规约要点
Service 层规范
- 继承 BaseService:使用
validateRecordExists等工具方法 - 参数验证:在方法开始处进行参数验证,抛出
ValidationException - 数据库操作:使用统一的数据库集合定义和查询规范
- 异常处理:使用具体的业务异常类,提供清晰的错误信息
Controller 层规范
- 继承 BaseController:使用
wrapAsync包装业务逻辑 - 不使用 public 关键字:TypeScript 类成员默认为 public
- 异步方法:使用
async关键字声明异步方法 - 未使用参数:使用下划线前缀(如
_userInfo)表示有意忽略的参数
路由配置规范
- 使用 buildRoutes:批量构建路由配置
- 认证控制:明确指定
requireAuth属性 - 业务分组:相关路由按业务领域分组定义
📝 更新日志
- v1.0.0 (2024-01-01): 初始版本,支持微信和支付宝扫码登录
- v1.1.0 (2024-01-15): 添加扫码端设备信息返回,优化错误处理
- v1.2.0 (2025-01-27): 整合开发规约,完善 Service 和 Controller 层实现规范
