SSE实现小程序扫码登录(SpringCloud、SpringBoot)

877 阅读2分钟

业务背景

公司需要实现小程序扫码登录。微信小程序扫描web端二维码,实现自动免密登录。

实现方案

  1. 后端生成二维码返回给前端,二维码是通过一个32位随机数生成。
  2. 前端通过随机数调创建sse连接接口,接口保持长连接,等待后端发送消息给前端。redis中以随机数为key,存储二维码状态0(等待扫描)。
  3. 小程序扫码,调用后端接口更改redis中二维码状态为1(已扫码,待确认登录)。服务端通过步骤2创建的连接发送消息给web端,web端刷新二维码显示待移动端确认。
  4. 小程序点击确认后,掉后端接口,后端模拟登录发登录后的个人信息给web端,并关闭链接。web端收到个人信息后登录系统首页。

具体实现

生成二维码接口

@GetMapping(path = "/createORCode")
public R<JSONObject> createORCode(){
    // 生成随机数
    // 生成二维码,base64编码
    // 组装base64编码和random给前端
}

创建sse连接

@GetMapping(path = "/createSseConnect")
public SseEmitter createSseConnect(HttpServletRequest request){
    String random = request.getParameter("random");
    Assert.hasText(random, "参数:random不能为空,请检查!");
    SseEmitter sseEmitter = getSseEmitter(random);
    sseEmitter.send(SseEmitter.event().id(random).data(ORCodeStatusEnum.WAITING_FOR_SCAN.getCode())
        .comment(ORCodeStatusEnum.WAITING_FOR_SCAN.getName()));
    // 记录二维码状态
    bladeRedis.setEx(CacheNames.ORCODE_STATUS_KEY + random, ORCodeStatusEnum.WAITING_FOR_SCAN.getCode(),
        Duration.ofMinutes(2));
    new Thread(() -> {
        try {
            while (true) {
                // 休眠一秒
                Thread.sleep(1000L);
                String status = bladeRedis.get(CacheNames.ORCODE_STATUS_KEY + random);
                // 二维码过期,关闭链接,重新生成二维码
                if (StringUtil.isBlank(status)) {
                    sseEmitter.send(SseEmitter.event().id(random).data(ORCodeStatusEnum.EXPIRED.getCode())
                        .comment(ORCodeStatusEnum.EXPIRED.getName()));
                    sseEmitter.complete();
                    break;
                }
                if (ORCodeStatusEnum.WAITING_FOR_SCAN.getCode().equals(status)) {
                    continue;
                }
                // 二维码扫描成功,待确认登录
                if (ORCodeStatusEnum.WAITING_FOR_CONFIRM.getCode().equals(status)) {
                    sseEmitter
                        .send(SseEmitter.event().id(random).data(ORCodeStatusEnum.WAITING_FOR_CONFIRM.getCode())
                            .comment(ORCodeStatusEnum.WAITING_FOR_CONFIRM.getName()));
                    log.info("二维码扫描成功!");
                    continue;
                }
                // 二维码确认成功,登录成功
                if (ORCodeStatusEnum.CONFIRMED.getCode().equals(status)) {
                    // 登录成功逻辑
                    sseEmitter.send(
                        SseEmitter.event().id(random).data("登录成功").comment(ORCodeStatusEnum.CONFIRMED.getName()));
                    sseEmitter.complete();
                    log.info("二维码确认成功!");
                    break;
                }
                // redis中二维码状态异常
                sseEmitter.send(SseEmitter.event().id(random).data(ORCodeStatusEnum.UNKNOWN.getCode())
                    .comment(ORCodeStatusEnum.UNKNOWN.getName()));
                sseEmitter.complete();
                break;
            }
        } catch (Exception e) {
            sseEmitter.completeWithError(e);
            bladeRedis.del(CacheNames.ORCODE_STATUS_KEY + random);
        }
    }).start();
    return sseEmitter;
}
private SseEmitter getSseEmitter(String random) {
    SseEmitter sseEmitter = new SseEmitter(2 * 60 * 1000L);
    // 完成连接后清除redis缓存
    sseEmitter.onCompletion(() -> {
        bladeRedis.del(CacheNames.ORCODE_STATUS_KEY + random);
    });
    // 超时处理 回调
    sseEmitter.onTimeout(() -> {
        bladeRedis.del(CacheNames.ORCODE_STATUS_KEY + random);
        log.error("sse连接超时!");
    });
    // 错误处理 回调
    sseEmitter.onError(e -> {
        bladeRedis.del(CacheNames.ORCODE_STATUS_KEY + random);
        e.printStackTrace();
        log.error("sse连接出错!", e);
    });
    return sseEmitter;
}
@Getter
@AllArgsConstructor
public enum ORCodeStatusEnum {

    WAITING_FOR_SCAN("0", "等待扫码"), WAITING_FOR_CONFIRM("1", "等待确认"), CONFIRMED("2", "已确认"), SCAN_FAILED("3", "扫码失败"),
    EXPIRED("4", "二维码已过期"), UNKNOWN("5", "未知状态"), LOGIN_FAILED("6", "登录失败");

    final String code;
    final String name;
}

扫码接口

@GetMapping(path = "/scanQRCode")
public R<String> scanQRCode(HttpServletRequest request) {
    String random = request.getParameter("random");
    bladeRedis.setEx(CacheNames.ORCODE_STATUS_KEY + random, ORCodeStatusEnum.WAITING_FOR_CONFIRM.getCode(),
        Duration.ofMinutes(2));
    return R.data(random);
}

确认接口

@GetMapping(path = "/oauth/confirmQRCode")
public R<String> confirmQRCode(HttpServletRequest request) {
    String random = request.getParameter("random");
    bladeRedis.setEx(CacheNames.ORCODE_STATUS_KEY + random, ORCodeStatusEnum.CONFIRMED.getCode(),
        Duration.ofMinutes(2));
    return R.data(random);
}

注意事项

  1. SseEmitter.event()的name属性,默认是message,和前端流中type字段对应,如果制定了name,前端需要自定义监听器,用默认的onMessage是监听不到的。
  2. SseEmitter发送消息后(即调用了complete方法后),客户端会默认重新连接。需要根据业务,在页面生命周期结束时使用EventSource对象的close方法关闭连接。(关闭后的连接无法再次打开,需要重新创建连接)