内网环境下实现pc端微信扫码登录

背景

需要实现医院内网环境下,医生通过扫码登录内网系统

前提条件

内网环境 + 可以访问外网的前置机 + 云端服务器

参考资料

网站应用微信登录官方文档:关于微信快速登录功能的说明 | 微信开放文档

分析过程

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)
            }
        })
    }

实现效果

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值