引言
上学期研究了一下微信登录相关内容,也写了两三篇笔记,但是最后实际登录流程没有写,主要因为感觉功能完成有所欠缺,一直也没有好的思路;这两天我又看了看官方文档,重新构思了一下微信公众号登录相关的内容,可能还是有不足之处,但是基本架子已经搭起来了,在这里我就简单说明一下我的想法;
关于登录方式
登录方式在我看来是必须要设计好的一个关键点,我设计的系统涉及到了手机号验证码登录,每个用户绑定的手机号是唯一的;所以对于初次使用微信登录的用户,必须绑定一个手机号才可以;(如果你只是单纯的微信登录,就会少很多步骤,下面我会细说)
微信中有一个叫openId的参数,这个参数可以说对每个关注公众号的用户都是不一样的,也就是每个用户的唯一性标识;
那么我们可以分析出来,一个用户除了id主键唯一以外,他的手机号是唯一的,微信中对应的openId也是唯一的;
所以数据库中就需要有一个用来存放openId的字段,后期微信登录就要通过该字段来检索用户;
准备工作
想要实现微信公众号扫码登录,首先要知道以下三点的实现:
1,获取微信公众号二维码
2,微信网页授权的实现
3,微信公众号消息接收和回复的实现
这三点我之前已经写过对应文章了,可以结合微信官方文档学习;
- 微信公众号扫码登录(一)—— 获取微信公众号二维码
- 微信登录——授权登录获取用户信息
- 微信公众号被动消息回复实现
下面代码可能会和上面文章有所重复,但也有修改之处,看不懂可以对比来看;
登录流程
我的思路如下:
可以分为以下步骤:
1,判断fromUserName即用户openId在数据库中是否存在
2, 如果存在则通过该openId查询到该用户信息,生成token进行登录操作
3, 如果不存在用户信息则跳转到手机号绑定页面
4, 如果该手机号已经注册有用户,则绑定该openId
5, 如果没有注册,则获取用户微信信息,将openId和手机号绑定
大致思路就是这样,其中当然还有很多小细节,下面用代码来大致演示一下;
代码实现
这个接口其实是实现微信消息接收和推送的接口,上面文章中也有,这里只是提取了service层
/**
* 接收微信公众号消息(微信登录也经由该接口)
*/
@PostMapping("/callback")
@ResponseBody
public String responseMsg(HttpServletRequest req, HttpServletResponse resp) throws IOException {
req.setCharacterEncoding("UTF-8");
String respContent = wxService.responseMsg(req);
return respContent;
}
service
// 这个方法就是微信的登录方法
@Override
public String responseMsg(HttpServletRequest req) {
if (req == null) {
throw new BusinessException(StatusCode.SYSTEM_ERROR);
}
String message = "success";
try {
// 把微信返回的xml信息转义成map
Map<String, String> xmlMessage = WxMessageUtil.xmlToMap(req); // 解析微信发来的请求信息
String fromUserName = xmlMessage.get("FromUserName"); // 这个就该事件的用户openId
String toUserName = xmlMessage.get("ToUserName"); // 这个开发者微信号
String msgType = xmlMessage.get("MsgType"); // 消息类型(event或者text)
String createTime = xmlMessage.get("CreateTime"); // 消息创建时间 (整型)
log.info("发送方帐号(用户的openId)=>" + fromUserName);
log.info("开发者微信号=>" + toUserName);
log.info("消息类型为=>" + msgType);
log.info("消息创建时间 (整型)=>" + createTime);
if ("event".equals(msgType)) { // 如果是事件推送
String eventType = xmlMessage.get("Event"); // 事件类型
String eventKey = xmlMessage.get("EventKey"); // 获取事件KEY值
if ("subscribe".equals(eventType)) { // 如果是扫描二维码后订阅消息
String subscribeContent = "感谢关注";
// 如果是扫码登录二维码后订阅公众号,则获取该用户信息进行登录操作
if (!StringUtils.isAnyBlank(eventKey)
&& WxConstant.LOGIN_QR_ID.toString().equals(eventKey.split("_")[1])) {
subscribeContent = dealWithWxLoginUser(fromUserName, subscribeContent);
}
String subscribeReturnXml = WxMessageUtil.getWxReturnMsg(xmlMessage, subscribeContent);
return subscribeReturnXml;
}
if ("SCAN".equals(eventType)) { // 如果是扫码消息
String scanContent = "扫码成功";
// 如果是扫描登录二维码,则获取该用户信息进行登录操作
if (!StringUtils.isAnyBlank(eventKey)
&& WxConstant.LOGIN_QR_ID.toString().equals(eventKey)) {
scanContent = dealWithWxLoginUser(fromUserName, scanContent);
}
String scanReturnXml = WxMessageUtil.getWxReturnMsg(xmlMessage, scanContent);
return scanReturnXml;
}
}
if ("text".equals(msgType)) { // 如果是文本消息推送
String content = xmlMessage.get("Content"); // 接收到的消息内容
String textReturnXml = WxMessageUtil.getWxReturnMsg(xmlMessage, content);
return textReturnXml; // 将接收到的文本消息变成xml格式再返回
}
} catch (IOException | DocumentException e) {
throw new RuntimeException(e);
}
return message;
}
/**
* 处理微信登录的用户
* @param openId 扫码登录用户的openId
* @param content 处理结果
* @return 处理信息
*/
private String dealWithWxLoginUser(String openId, String content) {
if (StringUtils.isAnyBlank(openId, content)) {
throw new BusinessException(StatusCode.PARAMS_ERROR, "dealWithWxLoginUser方法参数为空");
}
// 1,判断fromUserName即用户openId在数据库中是否存在
User loginUser = userService.getOne(new LambdaQueryWrapper<User>().eq(User::getWechatNum, openId));
if (loginUser == null) {
// 如果不存在用户信息则跳转到手机号绑定页面
// (此时微信登录就和这里的方法没有关系了,登录工作由下面跳转的绑定页面完成,这里链接目的之一是引导用户授权信息)
// 能访问到该绑定页面只有两种情况,不符合这两种情况不能访问该页面:
// 1,该openId对应用户未绑定手机号 2,该openId对应用户为空
content = "[POLAR]该账号未绑定手机号,请绑定手机号后登录\n" +
"<a href =\"http://内网穿透域名/api/user/wx/redirect\">[绑定手机号]</a>";
} else {
// 如果存在则通过该openId查询到该用户信息,生成token(存入redis)进行登录操作
// 封装用户信息
UserVo userVo = userUtil.setUserVo(loginUser);
// 生成token
String token = JwtUtil.createJWT(loginUser.getId().toString());
// 将用户信息存入redis
redisTemplate.opsForValue().set(RedisKey.LOGIN_USER + loginUser.getId(), userVo, 14, TimeUnit.DAYS);
content = "用户" + userVo.getNickname() + "登录成功\n\n" +
"登录日期:" + new Date();
}
return content;
}
这里实现的就是流程图中的第一步判断openId是否存在,主要判断方法就是dealWithWxLoginUser这个方法,该方法触发的条件就是用户扫码订阅公众号事件或者扫码登录事件(SCAN和subscribe)。
可以看到dealWithWxLoginUser方法中当用户不存在时,则需要引导用户进入手机号绑定界面,这里就是通过公众号向用户发送了手机号绑定超链接;实际效果如图:
然后用户点击下面超链接进行手机号绑定;
如果已经绑定openId,则执行登录操作,效果如图:
接下来就是绑定手机号步骤,绑定手机号链接到的是微信的网页授权接口,这一块在微信授权文章里说过,通过内网穿透映射到对应链接;
代码如下:
// 调用微信授权接口重定向
@GetMapping("/redirect")
@ResponseBody
public String toRedirectUrl(HttpServletResponse response) {
String redirectUrl = "https://open.weixin.qq.com/connect/oauth2/authorize" +
"?appid=" + WxConfigurationConstant.APP_ID +
"&redirect_uri=" + WxConfigurationConstant.REDIRECT_URL +
"&response_type=code" + "&scope=snsapi_userinfo" + // 只有关注公众号才能获取用户全部信息
"&state=STATE" + "&connect_redirect=1#wechat_redirect";
try {
response.sendRedirect(redirectUrl); // 重定向url
} catch (IOException e) {
log.error("获取微信code失败: " + e.getMessage());
}
return "重定向成功";
}
// 授权接口重定向回调方法
@GetMapping("/redirect/info")
public String redirectInfo(@RequestParam(value = "code") String code,
@RequestParam(value = "state", required = false) String state,
HttpServletResponse response,
Model model) {
// 获取登录用户信息
WxUserInfo loginUserInfo = wxService.getWxLoginUserInfo(code);
// 判断该用户是否已经绑定手机号(只有用户绑定手机号后恶意访问绑定链接才会触发),已绑定则跳转错误界面
User user = userService.getOne(new LambdaQueryWrapper<User>()
.eq(User::getWechatNum, loginUserInfo.getOpenid()));
if (user != null) {
return "BindPhoneErrorPage";
}
model.addAttribute("loginUserInfo", loginUserInfo);
// 不响应json数据,返回一个手机号绑定界面
return "BindPhonePage";
}
第一个/redirect接口就是上面超链接的url,通过该接口进行用户信息授权获取的:
然后该接口重定向到下面的/redirect/info接口,这个接口的作用才是实际获取用户信息的接口,通过getWxLoginUserInfo方法获取扫码用户的微信信息;这里可能会有疑惑,既然/redirect/info接口是获取用户信息的接口,那么要/redirect接口干嘛,这里上面微信授权文章也讲过,目的就是一个:获取code参数,只有重定向方式才能活到到code,有了code参数才能获取用户信息;微信官方文档有写;
/redirect/info接口返回的是一个html页面,即手机号绑定页面,并把授权获取的用户信息loginUserInfo也传到了该页面,这里用的是springboot的thymleaf模板,因为我前端实在是烂,就简单写了个条条框框实现这个功能,后期再完善页面:
BindPhonePage:
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>绑定手机号</title>
</head>
<body>
<div>
<br/>
<!-- <span id="hideWxUserInfo" th:text="${loginUserInfo}" hidden></span>-->
<input id="phoneNum" placeholder="请输入手机号" /> <br/><br/>
<input id="code" placeholder="请输入验证码" />
<button id="sendCode">发送验证码</button><br/><br/>
<button id="bindClick">绑定</button>
</div>
</body>
<script src="https://apps.bdimg.com/libs/jquery/2.1.4/jquery.min.js"></script>
<script type="text/javascript" th:inline="javascript">
$("#sendCode").click(() => {
// 发送ajax请求获取验证码
var phone = $("#phoneNum").val();
$.ajax({
url:'http://内网穿透域名/api/user/sms/aliyun/code/' + phone,
// data:{
// },
type:'post',
// dataType:'json',
success:function (data) {
if (data.code == 20000) {
alert('验证码发送成功')
} else {
// 提示信息
alert(data.message);
}
}
});
})
$("#bindClick").click(() => {
// 发送手机号绑定请求
var wxUserInfo = [[${loginUserInfo}]];
var phone = $("#phoneNum").val();
var code = $("#code").val();
var allInfo = {
"phone": phone,
"code": code,
"wxUserInfo": wxUserInfo
}
$.ajax({
url:'http://内网穿透域名/api/user/bind/phone',
data: JSON.stringify(allInfo),
contentType: "application/json;charset=UTF-8",
type:'post',
dataType:'json',
success:function (data) {
if (data.code == 20000) {
alert('绑定成功');
// TODO 跳转失败不知道为什么
window.location.href = 'BindPhoneErrorPage.html';
} else {
// 提示信息
alert(data.description);
}
}
});
})
</script>
</html>
可以看到绑定按钮点击后会发送一个绑定请求:http://内网穿透域名/api/user/bind/phone,这个接口主要完成流程图的注册或添加openId功能,代码如下:
@PostMapping("/bind/phone")
public BaseResponse<String> bindPhoneAndOpenId(@RequestBody BindPhoneAndOpenIdRequest bindRequest) {
if (bindRequest == null
|| StringUtils.isAnyBlank(bindRequest.getPhone(), bindRequest.getCode())
|| bindRequest.getWxUserInfo() == null) {
throw new BusinessException(StatusCode.PARAMS_ERROR);
}
String phone = bindRequest.getPhone();
String code = bindRequest.getCode();
WxUserInfo wxUserInfo = bindRequest.getWxUserInfo();
userService.bindPhoneAndOpenId(phone, code, wxUserInfo);
return ResultUtils.success("绑定成功");
}
service
@Transactional
@Override
public void bindPhoneAndOpenId(String phone, String code, WxUserInfo wxUserInfo) {
// 参数校验
if (StringUtils.isAnyBlank(phone, code) || wxUserInfo == null) {
throw new BusinessException(StatusCode.PARAMS_ERROR, "参数为空");
}
RegExpUtil.regExpVerify(RegExpUtil.phoneRegExp, phone, "手机号格式错误");
// 从redis中获取验证码进行校验
String phoneCode = (String) redisTemplate.opsForValue().get(RedisKey.SMS_LOGIN_CODE + phone);
if (StringUtils.isAnyBlank(phoneCode)) {
throw new BusinessException(StatusCode.OPERATION_ERROR, "验证码不存在或已超时");
}
phoneCode = phoneCode.split("_")[0]; // 获取真正的验证码
if (!code.equals(phoneCode)) {
throw new BusinessException(StatusCode.OPERATION_ERROR, "验证码错误");
}
User user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getPhone, phone));
if (user != null) { // 如果该手机号已经注册有用户,则绑定该openId
user.setWechatNum(wxUserInfo.getOpenid());
user.setUpdateTime(null);
userMapper.updateById(user);
// 再次查询处理好的user数据
user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getPhone, phone));
} else { // 如果没有注册,则获取用户微信信息,将openId和手机号绑定(用户注册流程)
user = new User();
user.setPhone(phone);
user.setNickname(wxUserInfo.getNickname());
user.setAvatar(wxUserInfo.getHeadimgurl());
user.setGender(wxUserInfo.getSex());
user.setWechatNum(wxUserInfo.getOpenid());
user.setProfile("简单介绍一下自己吧!");
userMapper.insert(user); // 新增用户
// 重新获取新增用户信息
user = userMapper.selectOne(new LambdaQueryWrapper<User>().eq(User::getPhone, phone));
// 设置用户为普通用户
Role normalRole = roleService.getOne(new LambdaQueryWrapper<Role>().eq(Role::getRoleName, "普通用户"));
UserRoleRelation userRoleRelation = new UserRoleRelation();
userRoleRelation.setUserId(user.getId());
userRoleRelation.setRoleId(normalRole.getId());
userRoleRelationService.save(userRoleRelation);
}
// 封装用户信息
UserVo userVo = userUtil.setUserVo(user);
// 生成token
String token = JwtUtil.createJWT(user.getId().toString());
// 将用户信息存入redis
redisTemplate.opsForValue().set(RedisKey.LOGIN_USER + user.getId(), userVo, 14, TimeUnit.DAYS);
// 登录成功后将验证码清除
redisTemplate.delete(RedisKey.SMS_LOGIN_CODE + phone);
}
这里就是对应流程图的如下步骤:
前面一大堆就是参数校验和验证码校验,后面if~else才是核心;
其中跳转BindPhoneErrorPage.html失败,没有找到原因,后期完善了补充;
BindPhoneErrorPage页面
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>ERROR</title>
</head>
<body>
<h1>绑定手机号成功</h1>
</body>
</html>
效果如下:
用户点击绑定手机号会有如下页面进行操作:
获取验证码后点击绑定即可:
绑定成功后查看数据库可以看到完整用户数据,即phone和openId字段都有(我这里openId字段是wechat_num):
第一条openId为空的数据就是没有使用过微信登录只通过手机号验证码登录的用户;第二条就是绑定好的数据;
查看redis:
至此微信登录大致就完成了;
总结
其实我这里实现的仅仅是后端部分,前端如何判断用户是否扫码,如何获取用户登录的token都是待解决的问题,这里我可以提供两个思路,前端可以通过和后端长连接websocket通信进行判断用户是否扫码从而进一行下一步操作;或者是很多网站都是用的前端通过轮询的方式定时向后端发送请求查看后端是否登录完成;
以上思路和代码仅仅是我个人想法,稳定性和效率上没有经过测试,希望能給你提供一个思路,如果有问题请指点一二;