操作步骤
- PC端生成二维码
- 手机端登录后,才能扫描PC端二维码。
- 手机端扫描二维码发送通知给PC端。
- 手机端确认登录,发送通知给PC端,你已经登录成功。
截取图
PC端生成二维码

1pc端生成二维码.png
手机端登录

2手机端登录.png
手机端扫描二维码

3扫描电脑二维码.png
PC接收到登录通知

4PC端提示已扫描.png
手机端确认登录

5确认PC端登录.png
PC端接收到确认登录通知

6登录成功.png
使用技术
springboot
webflux
SSE(Server-sent Events)是WebSocket的一种轻量代替方案,使用 HTTP 协议。
vue
uniapp
springJpa
mysql
hutool
后端业务定义
手机端登录接口
生成PC端二维码接口
PC端监听二维码session状态接口,目前定义状态:0 二维码生成成功 ,1 手机端扫码成功 2手机端确认登录 -1 sessionId过期失效
二维码扫描通知,手机端扫描成功会调用此接口,发送session通知
手机端确认通知,手机端确认登录会调用此接口,发送确认登录通知
PC端业务定义
- 显示登录扫描二维码,使用base64编码显示二维码
- 二维码显示成功后,使用SSE方式开启二维码session监听状态,状态:0 二维码生成成功 ,1 手机端扫码成功 2手机端确认登录 -1 sessionId过期失效
手机端业务定义
- 调用登录接口,跳转到扫描二维码界面
- 扫描二维码,发送扫描通知
- 二维码有效,跳转到确认登录界面
- 在确认登录界面点击确认登录,发送确认登录通知
后端接口代码
package com.xl.controller;
import cn.hutool.cache.CacheUtil;
import cn.hutool.cache.impl.TimedCache;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.ObjectUtil;
import cn.hutool.extra.qrcode.QrCodeUtil;
import cn.hutool.extra.qrcode.QrConfig;
import com.xl.domain.entity.UserEntity;
import com.xl.domain.pojo.SessionPojo;
import com.xl.domain.result.AjaxResult;
import com.xl.service.UserService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.HashMap;
import java.util.Map;
/**
* 用户登录
*/
@Slf4j
@RestController
@RequestMapping(value = "/user")
public class UserController {
@Autowired
private UserService userService;
/**
* 缓存放二维码会话,设置1分钟过期
*/
private TimedCache qrCodeSession= CacheUtil.newTimedCache(1000*60);
/**
* 存储token信息
*/
private TimedCache mobileTokenSession=CacheUtil.newTimedCache(1000*60);
/**
* 用户手机端登录方法
* @return
*/
@PostMapping(value = "/loginMobile")
public AjaxResult<Map<String,String>> loginMobile(@RequestBody UserEntity userForm){
UserEntity userEntity=userService.login(userForm);
//登录成功
if(ObjectUtil.isNotNull(userEntity)){
AjaxResult ajaxResult = AjaxResult.SUCCESS;
Map<String,String> resultMap=new HashMap<>();
resultMap.put("username",userEntity.getUsername());
String tokenId=IdUtil.objectId();
resultMap.put("token",tokenId);
log.debug("token={}",tokenId);
ajaxResult.setData(resultMap);
//存储到缓存中
mobileTokenSession.put(tokenId,userEntity);
return ajaxResult;
}else{ //登录失败
return AjaxResult.Fail;
}
}
/**
* 生成PC端登录二维码
* @return
*/
@GetMapping(value = "/pcQrCode")
public Mono<AjaxResult<Map<String,String>>> pcQrCode(){
return Mono.create((sink)->{
//生成会话编号
String sessionId= IdUtil.objectId();
log.debug("sessionId={}",sessionId);
SessionPojo sessionPojo=new SessionPojo();
sessionPojo.setSessionId(sessionId);
//0 二维码 生成状态 ,1 扫码状态 2 登录状态
sessionPojo.setStatus(0);
//生成base64二维码
QrConfig qrConfig=new QrConfig();
qrConfig.setWidth(300);
qrConfig.setHeight(300);
String base64Code=QrCodeUtil.generateAsBase64(sessionId, qrConfig,"jpeg");
//绑定返回体数据
AjaxResult<Map<String,String>> ajaxResult=AjaxResult.SUCCESS;
Map<String,String> resultData=new HashMap<>();
resultData.put("sessionId",sessionId);
resultData.put("base64Code",base64Code);
ajaxResult.setData(resultData);
//存入缓存
qrCodeSession.put(sessionId,sessionPojo);
sink.success(ajaxResult);
});
}
/**
* 监听二维码 session 状态
* @param sessionId
* @return
*/
@GetMapping(value = "/getSessionStatus",produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Mono<AjaxResult<SessionPojo>> getSessionStatus(String sessionId){
return Mono.create((sink)->{
SessionPojo sessionPojo =(SessionPojo)qrCodeSession.get(sessionId);
AjaxResult ajaxResult=AjaxResult.SUCCESS;
if(ObjectUtil.isNull(sessionPojo)){
sessionPojo=new SessionPojo();
sessionPojo.setSessionId(sessionId);
//0 二维码 生成状态 ,1 扫码成功 2 登录成功 -1 sessionId过期失效
sessionPojo.setStatus(-1);
}
ajaxResult.setData(sessionPojo);
sink.success(ajaxResult);
});
}
/**
* 扫码二维码成功
* @return
*/
@PostMapping(value = "/mobileScanOk")
public Mono<AjaxResult> mobileScanOk(ServerWebExchange exchange)
{
return exchange.getFormData().flatMap((formData)->{
//判断二维码session是否有效
String sessionId = formData.getFirst("sessionId");
String token=formData.getFirst("token");
SessionPojo sessionPojo = (SessionPojo) qrCodeSession.get(sessionId);
if(ObjectUtil.isNull(sessionPojo)){
AjaxResult ajaxResult=AjaxResult.Fail;
ajaxResult.setMsg("二维码已失效");
return Mono.just(ajaxResult);
}
//判断token是否有效
UserEntity userEntity = (UserEntity) mobileTokenSession.get(token);
//0 二维码 生成状态 ,1 扫码成功 2 登录成功 -1 sessionId过期失效
mobileTokenSession.get(token);
sessionPojo.setStatus(1);
sessionPojo.setUsername(userEntity.getUsername());
AjaxResult ajaxResult=AjaxResult.SUCCESS;
ajaxResult.setMsg("扫描成功,等待手机端确认操作");
return Mono.just(ajaxResult);
});
}
/**
* 手机端确认登录
* @return
*/
@PostMapping(value = "/mobileOkPcLogin")
public Mono<AjaxResult> mobileOkPcLogin(ServerWebExchange exchange){
return exchange.getFormData().flatMap(formData -> {
String sessionId = formData.getFirst("sessionId");
String token=formData.getFirst("token");
//判断二维码session是否有效
SessionPojo sessionPojo = (SessionPojo) qrCodeSession.get(sessionId);
if (ObjectUtil.isNull(sessionPojo)) {
AjaxResult ajaxResult = AjaxResult.Fail;
ajaxResult.setMsg("二维码已失效");
return Mono.just(ajaxResult);
}
//判断token是否有效
UserEntity userEntity = (UserEntity) mobileTokenSession.get(token);
if (ObjectUtil.isNull(userEntity)) {
AjaxResult ajaxResult = AjaxResult.Fail;
ajaxResult.setMsg("用户信息验证失效");
return Mono.just(ajaxResult);
}
//修改二维码session对象状态
//0 二维码 生成状态 ,1 扫码成功 2 登录成功 -1 sessionId过期失效
sessionPojo.setUsername(userEntity.getUsername());
sessionPojo.setStatus(2);
AjaxResult ajaxResult = AjaxResult.SUCCESS;
ajaxResult.setMsg("PC登录成功");
return Mono.just(ajaxResult);
});
}
}
PC端代码
<template>
<div id="qcode-box" >
<div class="q-login">
<div class="title">扫一扫登录</div>
<div class="success-login" v-if="sessionStatus==2">
√ 登录成功,{{username}}
</div>
<div class="qcode-img" v-else>
<template v-if="sessionStatus==-1">
<div class="qr-expire" @click="resetRefresh()">
二维码过期,重新刷新
</div>
</template>
<template v-else>
<img :src="base64Code"/>
<div v-if="sessionStatus==1" class="qcode-tips">手机已扫描,等待确认</div>
</template>
</div>
<div class="qcode-desc">无需关注 立刻登录</div>
</div>
</div>
</template>
<script>
import axios from 'axios'
let sseObj;
export default {
name: 'QCode',
data () {
return {
requestIp:'http://localhost',
base64Code: '',
sessionId:'',
sessionStatus:0,
username:''
}
},
mounted(){
this.loadQrCode();
},
methods:{
//加载
loadQrCode(){
axios.get(this.requestIp+'/user/pcQrCode').then((response)=>{
let {data}=response.data;
this.base64Code=data.base64Code;
this.sessionId=data.sessionId;
this.startQrCodeListener();
})
},
//开启sse长连接
startQrCodeListener(){
//发送Http长连接
sseObj=new EventSource(this.requestIp+"/user/getSessionStatus?sessionId="+this.sessionId);
//回调方法
sseObj.onmessage=(evt)=>{
let resultJson=JSON.parse(evt.data);
//session状态
this.sessionStatus=resultJson.data.status;
//等于-1表示sessionId过期
if(this.sessionStatus==-1){
sseObj.close();
return;
}else if(this.sessionStatus==2){
this.username=resultJson.data.username;
sseObj.close();
}
console.log(evt.data);
}
sseObj.error=(evt)=>{
sseObj.close();
}
},
//重新刷新二维码
resetRefresh(){
sseObj.close();
this.loadQrCode();
},
}
}
</script>
<style scoped>
#qcode-box{
width: 100vw;
height: 100vh;
}
.q-login{
background: #ebecef;
width: 500px;
height: 300px;
margin: 0 auto;
border-radius: 8px 8px;
position: relative;
top: 50%;
transform: translateY(-50%);
}
.q-login >.title{
line-height: 40px;
font-size: 16px;
font-weight: 600;
color: #222226;
text-align: center;
}
.q-login > .qcode-img{
text-align: center;
padding-top: 10px;
position: relative;
}
.q-login > .qcode-img > img{
width: 200px;
height: 200px;
}
.q-login >.qcode-desc{
text-align: center;
font-size: 13px;
color: #222226;
margin-top: 10px;
}
.qr-expire{
width: 200px;
line-height: 200px;
background: #000;
margin: 0 auto;
color: #fff;
cursor: pointer;
font-size: 13px;
flex: 1;
}
.qcode-tips{
width: 200px;
line-height: 30px;
position: absolute;
background: #000;
color: #fff;
bottom: 0px;
text-align: center;
font-size: 13px;
left: 50%;
transform: translateX(-50%);
}
.success-login{
line-height: 200px;
background: #fff;
color: #046590;
text-align: center;
font-size: 16px;
font-weight: 800;
}
</style>
手机端代码
登录代码
<template>
<view class="content">
<view class="login-box">
<view class="item">
<input type="text" v-model="httpUrl" placeholder="接口地址"/>
</view>
<view class="item">
<input type="text" v-model="username" placeholder="请输入用户名"/>
</view>
<view class="item">
<input type="password" v-model="password" placeholder="请输入密码" />
</view>
<view class="item">
<button type="default" @click="login">登录</button>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
httpUrl: 'http://192.168.1.61',
username:'xiaoliang',
password:'123456'
}
},
onLoad() {
},
methods: {
login(){
let requestUrl=this.httpUrl+'/user/loginMobile';
uni.request({
method:'POST',
url: requestUrl,
data: {
username: this.username,
password: this.password
},
header: {
'content-type': 'application/json'
},
success: (res) => {
console.log(res.data);
let resultData=res.data;
let code=resultData.code;
if(code==0){ //登录成功
let storageInfo={username:this.username,token:resultData.data.token,httpUrl:this.httpUrl};
uni.setStorage({
key: 'info',
data: JSON.stringify(storageInfo)
});
uni.navigateTo({
url: '../qrCode/qrCode'
});
}else{
uni.showToast({
title: '登录失败',
duration: 2000
});
}
}
});
}
}
}
</script>
<style>
.login-box{
flex: 1;
display: flex;
flex-direction: column;
}
.login-box > .item{
flex: 1;
}
.login-box > .item >input{
height: 80rpx;
border-bottom: solid 1px #eee;
padding-left: 10rpx;
}
.login-box > .item >button{
margin: 5rpx;
margin-top: 10rpx;
}
</style>
扫描二维码发送扫描通知代码
<template>
<view>
<view>
<button @click="scanCode()">扫码</button>
</view>
</view>
</template>
<script>
export default {
data() {
return {
sessionId:'',
token:'',
httpUrl:'',
username:''
}
},
methods: {
scanCode(){
let _this=this;
uni.scanCode({
onlyFromCamera: true,
success: function (res) {
console.log('条码类型:' + res.scanType);
console.log('条码内容:' + res.result);
_this.sessionId=res.result;
uni.getStorage({
key:'info',
success: (resStore)=>{
let infoJson=JSON.parse(resStore.data);
console.log(infoJson);
_this.token=infoJson.token;
_this.httpUrl=infoJson.httpUrl;
console.log(_this.token,_this.httpUrl);
_this.mobileScanOk();
}
});
}
});
},
mobileScanOk(){
let requestUrl=this.httpUrl+'/user/mobileScanOk'
uni.request({
method:'POST',
url: requestUrl,
data: {
sessionId: this.sessionId,
token: this.token
},
header:{
"Content-Type": "application/x-www-form-urlencoded"
},
success: (res) => {
let resultData=res.data;
console.log(resultData);
if(resultData.code==0){
let status=resultData.data.status;
if(status==1){ //登录成功
this.username=resultData.data.username;
uni.navigateTo({
url: `./confirm?token=${this.token}&sessionId=${this.sessionId}&httpUrl=${this.httpUrl}&username=${this.username}`
});
}else{
uni.showToast({
title: resultData.msg,
duration: 2000
});
}
}else{
uni.showToast({
title: resultData.msg,
duration: 2000
});
}
}
});
}
}
}
</script>
<style>
</style>
发送确认登录代码
<template>
<view>
<view class="username">{{username}}</view>
<view v-if="msg==''">
<button @click="mobileOkPcLogin()">确认登录</button>
</view>
<view class="msg" v-else>
{{msg}}
</view>
</view>
</template>
<script>
export default {
data() {
return {
sessionId:'',
token:'',
httpUrl:'',
username:'',
msg: ''
}
},
onLoad(option){
this.sessionId=option.sessionId;
this.token=option.token;
this.httpUrl=option.httpUrl;
this.username=option.username;
console.log(option);
},
methods: {
mobileOkPcLogin(){
let _this=this;
let requestUrl=this.httpUrl+'/user/mobileOkPcLogin'
uni.request({
method:'POST',
url: requestUrl,
header:{
"Content-Type": "application/x-www-form-urlencoded"
},
data: {
sessionId: this.sessionId,
token:this.token
},
success: (res) => {
let resultData=res.data;
_this.msg=resultData.msg;
console.log(resultData);
}
});
}
}
}
</script>
<style>
.username{
text-align: center;
font-size: 32rpx;
line-height: 50rpx;
}
.msg{
text-align: center;
font-size: 30rpx;
margin-top: 10rpx;
}
</style>