业务背景
公司需要实现小程序扫码登录。微信小程序扫描web端二维码,实现自动免密登录。
实现方案
- 后端生成二维码返回给前端,二维码是通过一个32位随机数生成。
- 前端通过随机数调创建sse连接接口,接口保持长连接,等待后端发送消息给前端。redis中以随机数为key,存储二维码状态0(等待扫描)。
- 小程序扫码,调用后端接口更改redis中二维码状态为1(已扫码,待确认登录)。服务端通过步骤2创建的连接发送消息给web端,web端刷新二维码显示待移动端确认。
- 小程序点击确认后,掉后端接口,后端模拟登录发登录后的个人信息给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);
}
注意事项
- SseEmitter.event()的name属性,默认是message,和前端流中type字段对应,如果制定了name,前端需要自定义监听器,用默认的onMessage是监听不到的。
- SseEmitter发送消息后(即调用了complete方法后),客户端会默认重新连接。需要根据业务,在页面生命周期结束时使用EventSource对象的close方法关闭连接。(关闭后的连接无法再次打开,需要重新创建连接)