背景
需要实现医院内网环境下,医生通过扫码登录内网系统
前提条件
内网环境 + 可以访问外网的前置机 + 云端服务器
参考资料
网站应用微信登录官方文档:关于微信快速登录功能的说明 | 微信开放文档
分析过程
1:微信扫码登录,官方支持两种方式:
一种使用官方的接口,将参数填写进去,放在html页面,打开后即可生成二维码,进行扫码。
另一种使用微信提供的js,但本质上还是需要调用微信的接口
遇到问题
以上两种方式都需要可以连接互联网才能使用,在内网的环境中无法正常使用上述两种方式!
分析过程
问题一:获取二维码
首先访问微信的接口(https://open.weixin.qq.com/connect/qrconnect?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=SCOPE&state=STATE#wechat_redirect),发现此接口返回的是一个html页面,再查看微信接口(https://open.weixin.qq.com/connect/qrcode/0415pKBZ3dwjml2x),发现此接口返回登录的二维码,但接口中图片地址为一个随机的一串字符,所以需要先获取这个随机字符,再通过微信接口,获取二维码https://open.weixin.qq.com/connect/qrconnect?appid=APPID&redirect_uri=REDIRECT_URI&response_type=code&scope=SCOPE&state=STATE#wechat_redirect
通过查看html页面,发现<image>标签中的一串随机字符就是我们需要的随机字符,我们只需要获取这个字符再调用https://open.weixin.qq.com/connect/qrcode/随机字符 即可获得登录的二维码
问题二:扫码后无反应,无法与页面交互
通过接口获取到二维码之后,使用手机扫码,可以正常识别,并且可以选择确认登录,但是选择登录,我们无法拿到用户的操作信息,没办法进行登录,页面无法跳转。
通过分析微信生成的扫码页面,发现微信会轮询的发送一个请求,来获取用户的操作状态,所以我们也模拟微信的方式,轮询的调用这个接口,获取用户扫码状态,即可判断是否进行登录!
返回code代表含义
// 405表示用户扫码成功并登录 // 404表示已经扫描 // 403表示用户扫描然后按了取消 // 408则是初始状态,表示无操作 // 402二维码已过期 // 接口返回结果格式 window.wx_errcode=408;window.wx_code='';
解决办法
可以通过内网发送请求到前置机,前置机(只安装MQ服务)发送请求到云端服务器,云端服务器调用微信api,获取返回结果后,返回给前置机,再返给内网服务器。
使用到的组件
MQ+Redis
后端接口:
第一步:发送获取二维码的请求
发送获取二维码请求到前置机MQ消息队列,云端MQ订阅前置机MQ,整个消息流程通过session id来区分不同客户端的请求
/**
* 发送获取二维码请求给前置机MQ
*
* @param request
*/
@Override
public void sendQrCodeRequest(HttpServletRequest request) {
// 通过session id来区分不同请求
String id = request.getSession().getId();
// 调用MQ接口发送请求
// 携带session id 区分客户端
QrLoginSendMessage message = new QrLoginSendMessage();
message.setMessageType(QrLoginConstant.QR_LOGIN_IMAGE_TYPE);
message.setSessionId(id);
remoteRocketMQService.sendQrCodeRequest(JSONUtil.toJsonStr(message));
}
通过messageType来区分是获取二维码图片的请求还是获取扫码状态的请求
public class QrLoginSendMessage {
/**
* 消息类型
* 0:请求获取扫码code
* 1:请求获取扫码状态
*/
private Integer messageType;
/**
* 前端请求的session id
*/
private String sessionId;
/**
* 扫码登录的二维码随机数
*/
private String codeUUID;
/**
* 上一次扫码的随机数
*/
private String last;
}
第二步:云端接口请求,调用微信API,并将结果发送到前置机MQ
@Override
public void prepareStart(DefaultMQPushConsumer consumer) {
consumer.setInstanceName("RocketMQ-QRCodeCallWxApi-cloud");
consumer.setMaxReconsumeTimes(maxReconsumeTimes);
consumer.registerMessageListener((MessageListenerConcurrently) (msgs, context) -> {
for (MessageExt messageExt : msgs) {
// 轮询接收到的消息
try {
long now = System.currentTimeMillis();
// 获取并解析消息体
String body = new String(messageExt.getBody());
log.info("云端消费者监听接收到消息: {}", body);
QrLoginSendMessage receiveMessage = JSONUtil.toBean(body, QrLoginSendMessage.class);
QrLoginReceiveMessage sendMessage = new QrLoginReceiveMessage();
sendMessage.setSessionId(receiveMessage.getSessionId());
sendMessage.setMessageType(receiveMessage.getMessageType());
// 获取扫码登录二维码
if (QrLoginConstant.QR_LOGIN_IMAGE_TYPE.equals(receiveMessage.getMessageType())) {
// 获取二维码图片
QrLoginImageDTO dto = qrLoginService.getQrCode();
sendMessage.setCodeUUID(dto.getCodeUUID());
sendMessage.setImage(dto.getImage());
}
// 获取扫码状态
if (QrLoginConstant.QR_LOGIN_STATUS_TYPE.equals(receiveMessage.getMessageType())) {
String result = qrLoginService.getQrCodeResult(receiveMessage.getCodeUUID(), receiveMessage.getLast());
sendMessage.setStatus(result);
}
// 发送数据到前置机
producerQrLoginCloud.sendMessage(JSONUtil.toJsonStr(sendMessage));
long costTime = System.currentTimeMillis() - now;
log.info("云端消息处理成功,耗时: {}, message_id:{}", costTime, messageExt.getMsgId());
} catch (Exception e) {
log.warn("云端consume message failed. messageId:{}, topic:{}, reconsumeTimes:{}", new Object[]{messageExt.getMsgId(), messageExt.getTopic(), messageExt.getReconsumeTimes(), e});
context.setDelayLevelWhenNextConsume(0);
return ConsumeConcurrentlyStatus.RECONSUME_LATER;
}
}
return ConsumeConcurrentlyStatus.CONSUME_SUCCESS;
});
}
调用微信接口,获取二维码,并保存图片的随机数(后面查询扫码状态要用)
public QrLoginImageDTO getQrCode() {
QrLoginImageDTO dto = new QrLoginImageDTO();
// 获取微信返回的扫码登录页面html
String url = "https://open.weixin.qq.com/connect/qrconnect?appid={0}&redirect_uri={1}&response_type=code&scope=snsapi_login&state=STATE#wechat_redirect";
String requestUrl = MessageFormat.format(url, webAppId, redirectUri);
// 返回的是个html页面,需要找到页面中扫码的二维码图片,转成byte数组返回
String html = restTemplate.getForObject(requestUrl, String.class);
// 解析html页面,获取二维码图片地址
Document doc = Jsoup.parse(html);
Element img = doc.select("img.js_qrcode_img.web_qrcode_img").first();
// 获取src属性的值
if (img != null) {
String src = img.attr("src");
String codeUrl = "https://open.weixin.qq.com" + src;
// 将二维码图片转成字节数组
ResponseEntity<byte[]> responseEntity = restTemplate.getForEntity(codeUrl, byte[].class);
String codeUUID = src.substring(src.lastIndexOf("/") + 1);
dto.setCodeUUID(codeUUID);
dto.setImage(responseEntity.getBody());
return dto;
} else {
log.error("No image found with the specified classes.");
return null;
}
}
第三步:前置机保存结果到redis
redis的key使用session id
// 获取到的二维码
if (QrLoginConstant.QR_LOGIN_IMAGE_TYPE.equals(receiveMessage.getMessageType())) {
redisService.redisTemplate.opsForList().leftPush(QrLoginConstant.QR_LOGIN_CODE + receiveMessage.getSessionId(), body);
// 设置
redisService.expire(QrLoginConstant.QR_LOGIN_CODE + receiveMessage.getSessionId(), 10, TimeUnit.MINUTES);
}
// 获取到的扫码状态
if (QrLoginConstant.QR_LOGIN_STATUS_TYPE.equals(receiveMessage.getMessageType())) {
redisService.redisTemplate.opsForList().leftPush(QrLoginConstant.QR_LOGIN_STATUS + receiveMessage.getSessionId(), body);
// 设置
redisService.expire(QrLoginConstant.QR_LOGIN_STATUS + receiveMessage.getSessionId(), 10, TimeUnit.MINUTES);
}
第四步:从redis中获取二维码字节数组
/**
* 前端轮询调用,获取微信扫码登录的二维码
*
* @param response 响应
* @param request 请求
* @return
*/
@GetMapping("getQrCode")
public void getQrCode(HttpServletResponse response, HttpServletRequest request) throws IOException {
QrLoginReceiveMessage message = wxService.getQrCode(response, request);
if (ObjectUtils.isNotEmpty(message)) {
response.setContentType("image/jpeg");
response.getOutputStream().write(message.getImage());
response.addHeader("Content-Disposition", "attachment;filename=image.jpg");
}else {
response.getOutputStream().write(500);
}
}
第五步:从redis中获取扫码状态
/**
* 查询扫码状态
*
* @param response 响应
* @param request 请求
*/
@GetMapping("getQrCodeResult")
public String getQrCodeResult(HttpServletResponse response, HttpServletRequest request) {
String status = wxService.getQrCodeResult(request);
if (StringUtils.isEmpty(status)) {
return "window.wx_errcode=408;window.wx_code='';";
}
return status;
}
前端页面
第一步:获取扫码登录图片
调用接口获取后台返回的二维码图片,在img标签中显示
<template>
<div class="login-scan-container">
<div style="text-align: center;font-size: 22px">微信登录</div>
<div class="image-container">
<img id="image" :src="imageUrl" alt="加载中..." width="300" height="300"/>
<div @click="clickRefresh()" v-if="scan==402" class="overlay">
<el-icon color="#696969" size="40">
<RefreshRight/>
</el-icon>
</div>
</div>
<div v-if="loginError" style="display: flex">
<div style="margin-left: 20px;">
<el-icon color="red" size="40">
<Warning/>
</el-icon>
</div>
<div>
<div style="font-size: 15px">{{errorMessage}}</div>
</div>
</div>
<div v-if="scan==100">
<div style="text-align: center;font-size: 13px">使用微信扫一扫登录</div>
<div style="text-align: center;font-size: 13px">“网站名称”</div>
</div>
<div v-if="scan==404" style="display: flex">
<div style="margin-left: 20px;">
<el-icon color="#08c160" size="40">
<CircleCheckFilled/>
</el-icon>
</div>
<div>
<div style="font-size: 15px">扫描成功</div>
<div style="font-size: 13px">在微信中轻触允许即可登录</div>
</div>
</div>
<div v-if="scan==403" style="display: flex">
<div style="margin-left: 20px;">
<el-icon color="red" size="40">
<Warning/>
</el-icon>
</div>
<div>
<div style="font-size: 15px">你已取消此次登录</div>
<div style="font-size: 13px">你可再此扫描登陆,或关闭窗口</div>
</div>
</div>
</div>
</template>
// 获取二维码图片
const getScanCode = () => {
getQrCode().then(response => {
if (response.type !== 'image/jpeg') {
setTimeout(getScanCode, 1000)
} else {
blobToDataURI(response, (res) => {
loading.value = false
imageUrl.value = res
setTimeout(sendScanStatusRequest, 1000)
scan.value = 100
})
}
})
}
// blob 转 base64
const blobToDataURI = (blob, callback) => {
var reader = new FileReader();
reader.readAsDataURL(blob);
reader.onload = function (e) {
callback(e.target.result);
};
}
第二步:获取用户扫码状态
状态666出现的情况是:用户扫码后,状态为404时,如果调用微信接口太频繁,接下来会返回状态码为666,此时需要用户刷新重新生成二维码
// 获取扫码的状态
// 405表示用户扫码成功并登录
// 404表示已经扫描
// 403表示用户扫描然后按了取消
// 408则是初始状态,表示无操作
// 402二维码已过期
// 接口返回结果格式 window.wx_errcode=408;window.wx_code='';
const getCodeState = () => {
getQrCodeResult().then((res) => {
const regex = /window\.wx_errcode=(\d+)/;
const match = res.match(regex);
// 如果找到了匹配项,返回第一个捕获组(即错误码)
const code = match ? match[1].trim() : null;
if (code) {
switch (parseInt(code)) {
// 用户扫码成功并登录
case 405:
const regexCode = /window\.wx_code=(['"])(.*?)\1(?:;|$)/;
const codeArray = res.match(regexCode);
// 如果找到了匹配项,返回第一个捕获组
// 获取微信返回的code,用来换取token
const wxCode = codeArray ? codeArray[2] : null;
login(wxCode)
break;
case 404:
// 表示已经扫描
scan.value = 404
setTimeout(sendScanStatusRequest, 1000, code);
break;
case 403:
// 表示用户扫描然后按了取消
scan.value = 403
setTimeout(sendScanStatusRequest, 1000, code);
break;
case 402:
case 500:
case 666:
scan.value = 402
break;
case 408:
// 初始状态,表示无操作
setTimeout(sendScanStatusRequest, 1000)
}
}
}).catch((res) => {
if (res) {
const regex = /window\.wx_errcode=(\d+)/;
const match = res.match(regex);
// 如果找到了匹配项,返回第一个捕获组(即错误码)
const code = match ? match[1].trim() : null;
if (code) {
408 == parseInt(code) ? setTimeout(sendScanStatusRequest, 1000) : setTimeout(sendScanStatusRequest, 1000, code)
} else {
setTimeout(sendScanStatusRequest, 1000)
}
} else {
setTimeout(sendScanStatusRequest, 1000)
}
})
}