操作步驟
- PC端生成二維碼
- 手機(jī)端登錄后,才能掃描PC端二維碼。
- 手機(jī)端掃描二維碼發(fā)送通知給PC端。
- 手機(jī)端確認(rèn)登錄,發(fā)送通知給PC端,你已經(jīng)登錄成功。
截取圖
PC端生成二維碼

1pc端生成二維碼.png
手機(jī)端登錄

2手機(jī)端登錄.png
手機(jī)端掃描二維碼

3掃描電腦二維碼.png
PC接收到登錄通知

4PC端提示已掃描.png
手機(jī)端確認(rèn)登錄

5確認(rèn)PC端登錄.png
PC端接收到確認(rèn)登錄通知

6登錄成功.png
使用技術(shù)
springboot
webflux
SSE(Server-sent Events)是WebSocket的一種輕量代替方案,使用 HTTP 協(xié)議。
vue
uniapp
springJpa
mysql
hutool
后端業(yè)務(wù)定義
手機(jī)端登錄接口
生成PC端二維碼接口
PC端監(jiān)聽二維碼session狀態(tài)接口,目前定義狀態(tài):0 二維碼生成成功 ,1 手機(jī)端掃碼成功 2手機(jī)端確認(rèn)登錄 -1 sessionId過期失效
二維碼掃描通知,手機(jī)端掃描成功會調(diào)用此接口,發(fā)送session通知
手機(jī)端確認(rèn)通知,手機(jī)端確認(rèn)登錄會調(diào)用此接口,發(fā)送確認(rèn)登錄通知
PC端業(yè)務(wù)定義
- 顯示登錄掃描二維碼,使用base64編碼顯示二維碼
- 二維碼顯示成功后,使用SSE方式開啟二維碼session監(jiān)聽狀態(tài),狀態(tài):0 二維碼生成成功 ,1 手機(jī)端掃碼成功 2手機(jī)端確認(rèn)登錄 -1 sessionId過期失效
手機(jī)端業(yè)務(wù)定義
- 調(diào)用登錄接口,跳轉(zhuǎn)到掃描二維碼界面
- 掃描二維碼,發(fā)送掃描通知
- 二維碼有效,跳轉(zhuǎn)到確認(rèn)登錄界面
- 在確認(rèn)登錄界面點(diǎn)擊確認(rèn)登錄,發(fā)送確認(rèn)登錄通知
后端接口代碼
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;
/**
* 緩存放二維碼會話,設(shè)置1分鐘過期
*/
private TimedCache qrCodeSession= CacheUtil.newTimedCache(1000*60);
/**
* 存儲token信息
*/
private TimedCache mobileTokenSession=CacheUtil.newTimedCache(1000*60);
/**
* 用戶手機(jī)端登錄方法
* @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 二維碼 生成狀態(tài) ,1 掃碼狀態(tài) 2 登錄狀態(tài)
sessionPojo.setStatus(0);
//生成base64二維碼
QrConfig qrConfig=new QrConfig();
qrConfig.setWidth(300);
qrConfig.setHeight(300);
String base64Code=QrCodeUtil.generateAsBase64(sessionId, qrConfig,"jpeg");
//綁定返回體數(shù)據(jù)
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);
});
}
/**
* 監(jiān)聽二維碼 session 狀態(tài)
* @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 二維碼 生成狀態(tài) ,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 二維碼 生成狀態(tài) ,1 掃碼成功 2 登錄成功 -1 sessionId過期失效
mobileTokenSession.get(token);
sessionPojo.setStatus(1);
sessionPojo.setUsername(userEntity.getUsername());
AjaxResult ajaxResult=AjaxResult.SUCCESS;
ajaxResult.setMsg("掃描成功,等待手機(jī)端確認(rèn)操作");
return Mono.just(ajaxResult);
});
}
/**
* 手機(jī)端確認(rèn)登錄
* @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("用戶信息驗(yàn)證失效");
return Mono.just(ajaxResult);
}
//修改二維碼session對象狀態(tài)
//0 二維碼 生成狀態(tài) ,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">手機(jī)已掃描,等待確認(rèn)</div>
</template>
</div>
<div class="qcode-desc">無需關(guān)注 立刻登錄</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(){
//發(fā)送Http長連接
sseObj=new EventSource(this.requestIp+"/user/getSessionStatus?sessionId="+this.sessionId);
//回調(diào)方法
sseObj.onmessage=(evt)=>{
let resultJson=JSON.parse(evt.data);
//session狀態(tài)
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>
手機(jī)端代碼
登錄代碼
<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>
掃描二維碼發(fā)送掃描通知代碼
<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('條碼內(nèi)容:' + 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>
發(fā)送確認(rèn)登錄代碼
<template>
<view>
<view class="username">{{username}}</view>
<view v-if="msg==''">
<button @click="mobileOkPcLogin()">確認(rèn)登錄</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>