实现 PC 端前后分离微信二维码扫码登录全攻略

20 阅读3分钟

在现代应用开发中,便捷的登录方式是提升用户体验的关键。扫码登录凭借其便捷性和高普及率,成为众多应用的首选。本文将详细介绍如何在 RuoYi-Vue 框架中实现 PC 端前后分离的二维码扫码登录功能,涵盖技术原理、详细步骤与代码示例。

一、技术背景与原理

RuoYi-Vue 是基于 Spring Boot、Vue 的前后端分离权限管理系统。扫码登录基于 OAuth2.0 协议,流程大致如下:

  1. 生成二维码:应用向服务器请求生成带有唯一标识(state)的二维码 URL,用户扫码后,将用户重定向到应用指定的回调地址,并携带授权码(code)。

  2. 获取授权信息:应用使用授权码和自身的 appId、secret,向服务器换取 access_token 和 openid,openid 是用户在开放平台的唯一标识。

  3. 用户认证与登录:应用根据 openid 判断用户是否已注册,若已注册则直接登录;若未注册,可创建新用户并关联 openid,最终完成登录流程。

二、后端实现步骤

1. 配置文件添加相关配置

在​​src/main/resources/application.yml​​中添加配置信息,需替换为在开放平台申请的真实信息:

# 配置
wechat:
  appId: your_app_id
  secret: your_app_secret
  qrcodeUrl: https://open.weixin.qq.com/connect/qrconnect?appid=%s&redirect_uri=%s&response_type=code&scope=snsapi_login&state=%s#wechat_redirect

其中​​qrcodeUrl​​是提供的二维码生成接口,包含应用 ID、回调地址和状态标识。

2. 创建二维码服务接口与实现类

接口定义:在​​com.ruoyi.framework.web.service​​​包下创建​​WeChatQrCodeService​​接口,定义生成二维码、检查状态和处理回调的方法:

package com.ruoyi.framework.web.service;

import java.util.Map;

public interface WeChatQrCodeService {
    /**
     * 生成登录二维码
     * @return 包含二维码URL和唯一标识的Map
     */
    Map<String, Object> generateQrCode();

    /**
     * 检查二维码扫描状态
     * @param uuid 二维码唯一标识
     * @return 扫描状态结果
     */
    Map<String, Object> checkQrCodeStatus(String uuid);

    /**
     * 登录回调处理
     * @param code 授权码
     * @param uuid 二维码唯一标识
     * @return 登录结果
     */
    Map<String, Object> weChatLoginCallback(String code, String uuid);
}

实现类:在​​com.ruoyi.framework.web.service.impl​​​包下创建​​WeChatQrCodeServiceImpl​​实现类,实现接口方法,核心代码如下:

package com.ruoyi.framework.web.service.impl;

import com.alibaba.fastjson.JSONObject;
import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.common.core.domain.entity.SysUser;
import com.ruoyi.common.core.redis.RedisCache;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.framework.manager.AsyncManager;
import com.ruoyi.framework.manager.factory.AsyncFactory;
import com.ruoyi.framework.security.LoginUser;
import com.ruoyi.framework.security.service.TokenService;
import com.ruoyi.framework.web.service.SysLoginService;
import com.ruoyi.framework.web.service.WeChatQrCodeService;
import com.ruoyi.system.service.ISysUserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import org.springframework.web.client.RestTemplate;

import java.util.HashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;

@Service
public class WeChatQrCodeServiceImpl implements WeChatQrCodeService {
    @Value("${wechat.appId}")
    private String appId;
    @Value("${wechat.secret}")
    private String secret;
    @Value("${wechat.qrcodeUrl}")
    private String qrcodeUrl;

    @Autowired
    private RedisCache redisCache;
    @Autowired
    private TokenService tokenService;
    @Autowired
    private SysLoginService loginService;
    @Autowired
    private ISysUserService userService;
    @Autowired
    private RestTemplate restTemplate;

    private static final String QRCODE_PREFIX = "wechat:qrcode:";
    private static final String QRCODE_SCANNED = "scanned";
    private static final String QRCODE_CONFIRMED = "confirmed";
    private static final String QRCODE_EXPIRED = "expired";

    @Override
    public Map<String, Object> generateQrCode() {
        String uuid = UUID.randomUUID().toString();
        String qrcodeKey = QRCODE_PREFIX + uuid;

        // 生成二维码URL,这里使用模拟URL,实际应调用接口
        String qrCodeUrl = String.format(qrcodeUrl, appId, uuid);

        // 将二维码信息存入Redis,有效期5分钟
        Map<String, Object> qrCodeInfo = new HashMap<>();
        qrCodeInfo.put("status", "pending");
        qrCodeInfo.put("createTime", System.currentTimeMillis());
        redisCache.setCacheMap(qrcodeKey, qrCodeInfo, 5, TimeUnit.MINUTES);

        Map<String, Object> result = new HashMap<>();
        result.put("uuid", uuid);
        result.put("qrCodeUrl", qrCodeUrl);
        return result;
    }

    @Override
    public Map<String, Object> checkQrCodeStatus(String uuid) {
        String qrcodeKey = QRCODE_PREFIX + uuid;
        Map<Object, Object> qrCodeInfo = redisCache.getCacheMap(qrcodeKey);

        if (qrCodeInfo == null) {
            Map<String, Object> result = new HashMap<>();
            result.put("status", QRCODE_EXPIRED);
            return result;
        }

        return new HashMap<>(qrCodeInfo);
    }

    @Override
    public Map<String, Object> weChatLoginCallback(String code, String uuid) {
        if (StringUtils.isEmpty(code) || StringUtils.isEmpty(uuid)) {
            throw new ServiceException("授权码或UUID不能为空");
        }

        String qrcodeKey = QRCODE_PREFIX + uuid;
        Map<Object, Object> qrCodeInfo = redisCache.getCacheMap(qrcodeKey);

        if (qrCodeInfo == null ||!"pending".equals(qrCodeInfo.get("status"))) {
            throw new ServiceException("二维码已过期或状态异常");
        }

        // 获取access_token和openid
        String accessTokenUrl = "https://api.weixin.qq.com/sns/oauth2/access_token" +
                "?appid=" + appId +
                "&secret=" + secret +
                "&code=" + code +
                "&grant_type=authorization_code";

        String response = restTemplate.getForObject(accessTokenUrl, String.class);
        JSONObject jsonObject = JSONObject.parseObject(response);

        if (jsonObject.containsKey("errcode")) {
            throw new ServiceException("获取授权信息失败: " + jsonObject.getString("errmsg"));
        }

        String openid = jsonObject.getString("openid");
        String accessToken = jsonObject.getString("access_token");

        // 根据openid查询用户
        SysUser user = userService.selectUserByOpenid(openid);

        if (user == null) {
            // 用户首次使用登录,创建新用户
            user = createWeChatUser(openid, accessToken);
        }

        // 更新二维码状态为已确认
        qrCodeInfo.put("status", QRCODE_CONFIRMED);
        qrCodeInfo.put("userId", user.getUserId());
        redisCache.setCacheMap(qrcodeKey, qrCodeInfo);

        // 记录登录信息
        AsyncManager.me().execute(AsyncFactory.recordLogininfor(user.getUserName(), "扫码登录", "成功", ""));

        // 生成token
        LoginUser loginUser = new LoginUser();
        loginUser.setUser(user);
        String token = tokenService.createToken(loginUser);

        Map<String, Object> result = new HashMap<>();
        result.put("token", token);
        result.put("userInfo", user);
        return result;
    }

    private SysUser createWeChatUser(String openid, String accessToken) {
        // 获取用户信息
        String userInfoUrl = "https://api.weixin.qq.com/sns/userinfo" +
                "?access_token=" + accessToken +
                "&openid=" + openid;

        String response = restTemplate.getForObject(userInfoUrl, String.class);
        JSONObject userInfo = JSONObject.parseObject(response);

        if (userInfo.containsKey("errcode")) {
            throw new ServiceException("获取用户信息失败: " + userInfo.getString("errmsg"));
        }

        // 创建新用户
        SysUser user = new SysUser();
        user.setUserName("wx_" + openid.substring(0, 8));
        user.setNickName(userInfo.getString("nickname"));
        user.setOpenid(openid);
        user.setAvatar(userInfo.getString("headimgurl"));
        user.setSex(userInfo.getString("sex"));
        user.setCreateBy("system");
        
        // 默认普通用户角色
        user.setRoleIds(new Long[]{2L});

        // 插入用户
        userService.insertUser(user);
        
        return user;
    }
}

3. 登录控制器添加登录接口

在​​com.ruoyi.web.controller.system.SysLoginController​​中添加登录相关接口:

package com.ruoyi.web.controller.system;

import com.ruoyi.common.core.domain.AjaxResult;
import com.ruoyi.framework.web.service.WeChatQrCodeService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;

import java.util.Map;

@RestController
@RequestMapping("/api")
public class SysLoginController {
    @Autowired
    private WeChatQrCodeService weChatQrCodeService;

    /**
     * 生成登录二维码
     */
    @GetMapping("/wechat/qrcode")
    public AjaxResult generateWeChatQrCode() {
        Map<String, Object> result = weChatQrCodeService.generateQrCode();
        return AjaxResult.success(result);
    }

    /**
     * 检查二维码状态
     */
    @GetMapping("/wechat/qrcode/status")
    public AjaxResult checkWeChatQrCodeStatus(@RequestParam String uuid) {
        Map<String, Object> result = weChatQrCodeService.checkQrCodeStatus(uuid);
        return AjaxResult.success(result);
    }

    /**
     * 登录回调
     */
    @PostMapping("/wechat/login/callback")
    public AjaxResult weChatLoginCallback(@RequestParam String code, @RequestParam String uuid) {
        Map<String, Object> result = weChatQrCodeService.weChatLoginCallback(code, uuid);
        return AjaxResult.success(result);
    }
}

三、前端实现步骤

1. 封装登录 API

在​​src/api/login.js​​中添加登录相关接口:

import request from '@/utils/request'

// 生成二维码
export function generateWeChatQrCode() {
  return request({
    url: '/api/wechat/qrcode',
    method: 'get'
  })
}

// 检查二维码状态
export function checkWeChatQrCodeStatus(uuid) {
  return request({
    url: '/api/wechat/qrcode/status',
    method: 'get',
    params: { uuid }
  })
}

// 登录回调
export function weChatLoginCallback(code, uuid) {
  return request({
    url: '/api/wechat/login/callback',
    method: 'post',
    params: { code, uuid }
  })
}

2. 登录页面扩展扫码登录功能

在​​src/views/login/index.vue​​中添加扫码登录选项卡,核心代码如下:

<template>
  <div class="login-container">
    <el-tabs v-model="activeTab">
      <el-tab-pane label="账号密码登录">
        <!-- 账号密码登录表单 -->
      </el-tab-pane>
      <el-tab-pane label="扫码登录">
        <div id="wechat-login-container"></div>
      </el-tab-pane>
    </el-tabs>
  </div>
</template>

<script>
import { generateWeChatQrCode, checkWeChatQrCodeStatus } from '@/api/login'

export default {
  data() {
    return {
      activeTab: 'account',
      qrcodeData: null,
      checkInterval: null
    }
  },
  mounted() {
    // 监听选项卡切换,切换到扫码登录时生成二维码
    this.$watch('activeTab', (newVal) => {
      if (newVal === 'wechat') {
        this.generateQrCode()
      }
    })
  },
  methods: {
    async generateQrCode() {
      try {
        const res = await generateWeChatQrCode()
        if (res.code === 200) {
          this.qrcodeData = res.data
          this.showQrCode()
        }
      } catch (error) {
        console.error('生成二维码失败', error)
      }
    },
    showQrCode() {
      const container = document.getElementById('wechat-login-container')
      if (!container) return
      container.innerHTML = `
        <div class="wechat-qrcode-container">
          <div class="qrcode-box">
            <img src="${this.qrcodeData.qrCodeUrl}" alt="登录二维码" class="wechat-qrcode-img">
          </div>
          <p class="qrcode-status">请使用扫描二维码登录</p>
          <p class="qrcode-tip">扫码后请在手机上确认登录</p>
        </div>
      `
      this.startCheckingStatus()
    },
    startCheckingStatus() {
      const that = this
      this.checkInterval = setInterval(() => {
        checkWeChatQrCodeStatus(this.qrcodeData.uuid).then((res) => {
          if (res.code === 200) {
            const status = res.data.status
            const statusElement = document.querySelector('.qrcode-status')
            if (status ==='scanned') {
              statusElement.textContent = '已扫码,请在手机上确认登录'
              statusElement.classList.add('status-scanned')
            } else if (status === 'confirmed') {
              statusElement.textContent = '登录成功,正在跳转...'
              statusElement.classList.add('status-confirmed')
              clearInterval(that.checkInterval)
              // 保存token并跳转
              this.handleLoginSuccess(res.data.token)
            } else if (status === 'expired') {
              statusElement.textContent = '二维码已过期,请刷新页面'
              statusElement.classList.add('status-expired')
              clearInterval(that.checkInterval)
            }
          }
        }).catch(() => {
          // 忽略错误,继续检查
        })
      }, 2000)
    },
    handleLoginSuccess(token) {
      // 保存token到localStorage
      localStorage.setItem('token', token)
      // 跳转到首页
      this.$router.push({ path: '/' })
    }
  }
}
</script>

3. 用户模块扩展 openid 管理

在​​src/store/modules/user.js​​中扩展用户模块,添加 openid 相关状态管理:

// 在state中添加wechatOpenid
state: {
  token: getToken(),
  name: '',
  avatar: '',
  roles: [],
  permissions: [],
  wechatOpenid: '' // openid
},

// 在getters中添加wechatOpenid
getters: {
  token: state => state.token,
  name: state => state.name,
  avatar: state => state.avatar,
  roles: state => state.roles,
  permissions: state => state.permissions,
  wechatOpenid: state => state.wechatOpenid // openid
},

// 在actions的getInfo方法中添加wechatOpenid
getInfo({ commit, state }) {
  return new Promise((resolve, reject) => {
    getInfo(state.token).then(response => {
      const { user, roles, permissions } = response;
      
      if (!user) {
        reject('验证失败,请重新登录');
      }
      
      commit('SET_ROLES', roles);
      commit('SET_PERMISSIONS', permissions);
      commit('SET_NAME', user.userName);
      commit('SET_AVATAR', user.avatar);
      commit('SET_WECHAT_OPENID', user.wechatOpenid || ''); // 添加openid
      resolve(response);
    }).catch(error => {
      reject(error);
    });
  });
},

// 添加mutations
mutations: {
  // 原有mutations...
  
  // 设置openid
  SET_WECHAT_OPENID: (state, wechatOpenid) => {
    state.wechat