QR码登录实现规约

2025年1月27日
6 分钟阅读
作者:Penny Lens Team

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('操作失败,请重试');
  }
}

🔒 安全注意事项

  1. 会话有效期: 二维码默认5分钟过期,过期后需要重新生成
  2. 设备信息: 建议收集设备信息用于安全审计
  3. Token安全: 登录成功后妥善保存Token,避免泄露
  4. HTTPS: 生产环境必须使用HTTPS协议
  5. 频率限制: 建议对轮询请求进行频率限制

🧪 测试用例

正常流程测试

  1. PC端生成二维码
  2. 手机端扫码并确认
  3. PC端轮询获取登录结果
  4. 验证登录状态和用户信息

异常情况测试

  1. 二维码过期测试
  2. 重复扫码测试
  3. 网络异常测试
  4. 用户取消测试

📝 开发规约要点

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 层实现规范