目录
整个微信公众号扫码登录业务描述和代码逻辑都进行了详细的介绍,最后还提供了前后端关键代码,只需要根据自己的项目进行更改即可。并且需要修改代码位置都进行了图片指引。
一、微信扫码关注公众号登录
1.1需求明细:
要求用户在网站登录页面选择扫码登录;通过扫描二维码关注公众号,成功关注公众号的用户,网站自动跳转到登录成功页面;并在公众号关注成功之后进行模板消息的提示;
1.2相关链接,正常需要企业申请公众号然后获取appId和appsecret等相关消息;开发时建议使用微信公众号测试平台获取相关信息进行代码开发。
1.3具体业务流程分析:
1.向微信请求AccessToken:
——access_token是公众号的全局唯一接口调用凭据,公众号调用各接口时都需使用access_token。开发者需要进行妥善保存。access_token的存储至少要保留512个字符空间。access_token的有效期目前为2个小时,需定时刷新,重复获取将导致上次获取的access_token失效。
接口调用请求说明
https请求方式: GET https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET
参数说明
参数 是否必须 说明 grant_type 是 获取access_token填写client_credential appid 是 第三方用户唯一凭证 secret 是 第三方用户唯一凭证密钥,即appsecret 返回说明
正常情况下,微信会返回下述JSON数据包给公众号:
{"access_token":"ACCESS_TOKEN","expires_in":7200}
参数说明
具体详情查看:可以去 微信公众号平台查看开发者文档;
参数 说明 access_token 获取到的凭证 expires_in 凭证有效时间,单位:秒
1.4具体代码流程分析:
1.前端发送请求getQRCode(),获取ticket,用于生成二维码;固定链接+ticket;前端请求二维码接口的同时对登录接口check()进行轮询。
https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=${data.ticket}
2.后端通过调用微信接口凭证方法getAccessToken()方法
3.前端轮询check(),主要是判断登录接口返回的数据中scanResult的值;
scanResult = 1 表示关注成功,用户登录
scanResult = -1 未关注成功
scanResult = -2 二维码过期
4.用户扫描二维码,并成功关注公众号之后,微信内部会触发我们配置的回调接口方法;该
方法主要是获取用户唯一标识openId,用于查找用户的信息。
5.存redis数据;在获取二维码时redis存缓存
"WEI_XIN_TICKET" +ticket : 1
"WEI_XIN_TICKET" +ticket : 获取的openId
在进行轮询check接口的时候,会始终去redis获取"WEI_XIN_TICKET" +ticket键的值,如果是1则表示用户没有关注成功,因为openId是在回调接口中进行赋值的,只有用户关注成功才会触发微信内部的回调方法,并且回调成功才会将获取的openId存入redis; 如果用户没有关注成功则不会执行回调方法,redis存的值始终会是1;
6.注意点:微信公众号平台配置的回调地址,测试的时候需要使用内网穿透地址,正式情况下使用自己公司的域名;端口使用80端口;值得注意的是在微信公众号平台上修改配置信息时会尝试去调用回调的方法是否成功,这个时候的get请求,而我们在用户关注成功时回调方法是post请求;使用需要controller写两个同路径的回调方法,一个get请求一个post请求;如果后期不需要改回调接口的地址那就直接get请求配置号回调接口后,留一个post请求的就ok了。
7.相关拼接链接的地址
1) 获取二维码图片:https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=ticket
2) : 向微信请求AccessToken:
accessToken = https://api.weixin.qq.com/cgi-bin/token?appid=appId&secret=appsecret&grant_type==client_credential
请求参数
属性 类型 必填 说明 grant_type string 是 填写 client_credential appid string 是 账号唯一凭证,即 AppID,可在「微信公众平台 - 设置 - 开发设置」页中获得。(需要已经成为开发者,且账号没有异常状态) secret string 是 账号唯一凭证密钥,即 AppSecret,获取方式同 appid force_refresh boolean 否 默认使用 false。1. force_refresh = false 时为普通调用模式,access_token 有效期内重复调用该接口不会更新 access_token;2. 当force_refresh = true 时为强制刷新模式,会导致上次获取的 access_token 失效,并返回新的 access_token 返回参数
属性 类型 说明 access_token string 获取到的凭证 expires_in number 凭证有效时间,单位:秒。目前是7200秒之内的值。 3) 获取ticket :
ticket = "https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=" + accessToken
1.5模板消息页面:
二、后端代码
相关后端微信接口路径描述
redis相关设置说明
该键用户标识用户有没有成功关注公众号:
"WEI_XIN_TICKET"+ticket = 1 【获取二维码的接口进行设置】
"WEI_XIN_TICKET"+ticket = 自己获取的用户标识openId 【回调接口获取的openid值】
获取微信二维码接口 | weixin/getQRCode【Post请求】 |
微信回调地址接口 | weixin/receive 【get请求用于微信公众号配置回调接口调用】 【post请求用于用户关注公众号之后微信内部回调接口】 只要用户成功关注公众号之后,微信平台内部平台会去回调这个接口获取openId用户的身份唯一凭证 |
轮询登录接口 【前端通过返回的scanResult参数值来判断是否停止轮询】 | weixin/receive 【post请求】 |
Controller控制层
@Api(tags = "微信登录相关接口")
@RequestMapping(value = "/weixin")
@RestController
public class WeiXinLoginController {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@Autowired
private WeiXinLoginService weiXinLoginService;
@ApiOperation("微信扫码登录,提供二维码")
@PostMapping(value = "/getQRCode")
public AjaxResult weinLogin(){
log.info("微信扫码登录接口开始执行:/weixin/getQRCode");
//获取ticket
Map<String, String> codeResult = weiXinLoginService.getQrCode();
log.info("微信扫码登录接口执行结束!");
return AjaxResult.success("成功",codeResult);
}
@GetMapping(value = "/receive")
@ApiOperation("接收微信消息事件,判断用户是否完成扫码关注")
public String getWxLoginReceive(HttpServletRequest request) throws IOException {
log.info("微信回调接口开始执行get请求/weixin/receive");
// 获取微信请求参数
String signature = request.getParameter("signature");
String timestamp = request.getParameter("timestamp");
String nonce = request.getParameter("nonce");
String echostr = request.getParameter("echostr");
log.info("开始校验此次消息是否来自微信服务器,param->signature:{},\ntimestamp:{},\nnonce:{},\nechostr:{}",
signature, timestamp, nonce, echostr);
String result = weiXinLoginService.receive(signature,timestamp,nonce,echostr,request);
System.out.println(result);
log.info("微信回调接口get请求执行结束!");
return result;
}
@PostMapping(value = "/receive")
@ApiOperation("接收微信消息事件,判断用户是否完成扫码关注")
public String postWxLoginReceive(HttpServletRequest request) throws IOException {
log.info("微信回调接口开始执行post请求/weixin/receive");
// 获取微信请求参数
String signature = request.getParameter("signature");
String timestamp = request.getParameter("timestamp");
String nonce = request.getParameter("nonce");
String echostr = request.getParameter("echostr");
log.info("开始校验此次消息是否来自微信服务器,param->signature:{},\ntimestamp:{},\nnonce:{},\nechostr:{}",
signature, timestamp, nonce, echostr);
String result = weiXinLoginService.receive(signature,timestamp,nonce,echostr,request);
System.out.println(result);
log.info("微信回调接口post请求执行结束!");
return result;
}
@GetMapping("/check")
@ApiOperation("获取扫码登录状态,前端进行轮询")
public R checkLogin(@RequestParam String ticket) {
log.info("前端二维码轮询接口开始执行/weixin/check");
Map<String, Object> resultMap = weiXinLoginService.checkLogin(ticket);
log.info("前端二维码轮询接口执行结束!");
return R.ok(resultMap);
}
}
Service业务层
public interface WeiXinLoginService {
Map<String,String> getQrCode();
String receive(String signature, String timestamp, String nonce, String echostr, HttpServletRequest request) throws IOException;
Map<String, Object> checkLogin(String ticket);
}
@Service
public class WeiXinLoginServiceImpl implements WeiXinLoginService {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@Autowired
private TokenService tokenService;
@Autowired
private RedisService redisService;
@Autowired
private SysUserSpecMapper sysUserSpecMapper; //用户mapper
@Autowired
private WxUtil wxUtil;
//模板消息ID
private String loginTemplateId = "自己设置模板消息的ID";
@Override
public Map<String,String> getQrCode() {
log.info("getQrCode方法开始执行!");
// 获取 AccessToken
String accessToken = null;
try {
accessToken = wxUtil.getAccessToken();
log.info("获取到的acesstoken为:‘{}’",accessToken);
} catch (Exception e) {
e.printStackTrace();
throw new ServiceException("获取AccessToken异常");
}
// 获取ticket
String ticket = null;
String expireSeconds = null;
try {
String url = "https://api.weixin.qq.com/cgi-bin/qrcode/create?access_token=" + accessToken;
// 组织请求数据
Map<String, Object> jsonData = new HashMap<>();
jsonData.put("expire_seconds", 1800); // 二维码过期时间
jsonData.put("action_name", "QR_SCENE");
Map<String, Object> actionInfo = new HashMap<>();
Map<String, Object> scene = new HashMap<>();
scene.put("scene_str", "3D");
actionInfo.put("scene", scene);
jsonData.put("action_info", actionInfo);
// 发送请求
String result = HttpRequest.post(url).body(JSON.toJSONString(jsonData)).execute().body();
log.info("请求微信接口的结果:'{}'",result);
// 结果处理
JSONObject ticketJson = JSONObject.parseObject(result);
ticket = ticketJson.getString("ticket");
expireSeconds = ticketJson.getString("expire_seconds");
} catch (HttpException e) {
e.printStackTrace();
throw new ServiceException("获取tikect异常");
}
redisService.setStringVule("WEI_XIN_TICKET" + ticket, "1", Long.parseLong(expireSeconds));
// 通过ticket换取二维码 https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=
HashMap<String, String> map = new HashMap<>();
map.put("ticket", ticket);
map.put("expire_seconds", expireSeconds);
log.info("getQrCode方法执行结束!");
return map;
}
@Override
public String receive(String signature, String timestamp, String nonce, String echostr, HttpServletRequest request) throws IOException {
log.info("微信回调方法开始执行");
String token = "R5Nam0KY6lleYuAsLVNhOg";
// 验证微信签名
if (!wxUtil.checkSignature(signature, timestamp, nonce, token)){
throw new ServiceException("微信回调参数异常!");
}
// 验证服务端配置
if (echostr != null){
return echostr;
}
// 接收微信推送的消息
String xmlString = wxUtil.readRequest(request);
try{
Map<String, String> resXml = wxUtil.ResponseXmlToMap(xmlString);
String ticket = resXml.get("Ticket"); // 获取二维码凭证
String fromUserName = resXml.get("FromUserName"); // 获取OpenId
String event = resXml.get("Event"); // 获取事件类型
// 只处理带场景值的二维码事件推送
if(StrUtil.isEmpty(ticket)){
return "";
}
// 处理绑定微信号事件
if ("1".equals((String)redisService.getCacheObject("WEI_XIN_TICKET"+ticket))){
//先删除
redisService.deleteObject("WEI_XIN_TICKET"+ticket);
redisService.setStringVule("WEI_XIN_TICKET"+ticket,fromUserName,100000);
}
//数据库去查找是否存在该openId,如果没有就新创建一个新用户设置一下基本信息
Integer count = sysUserSpecMapper.countByOpenId(fromUserName);
//说明数据库中没有这个人,创建一个新用户
if (!StrUtil.isEmpty(ticket) && count == 0){
String username = "U-"+fromUserName.substring(fromUserName.length() - 6);
SysUser sysUser = new SysUser();
sysUser.setUserName(username);
sysUser.setNickName("聚变"+new Date().getTime());
sysUser.setOpenId(fromUserName);
sysUser.setCount(5);
sysUser.setUserDesc("聚变让你变的不一样");
//上传图像
sysUser.setAvatar("https://img2.baidu.com/it/u=4260815398,3507716568&fm=253&fmt=auto&app=138&f=JPEG?w=888&h=500");
//保存用户
sysUserSpecMapper.insertSysUser(sysUser);
}
//说明之前关注过,直接回调结束
if(!StrUtil.isEmpty(ticket) && count != 0){
return "";
}
}catch (Exception e){
e.printStackTrace();
throw new ServiceException("系统异常");
}
log.info("微信回调方法执行结束!");
return "";
}
@Override
public Map<String, Object> checkLogin(String ticket) {
log.info("checkLogin方法开始执行,入参ticket:{}",ticket);
// 从缓存获取扫码状态
String openId = null;
try {
openId = redisService.getCacheObject("WEI_XIN_TICKET" + ticket);
System.out.println(openId+"哈哈");
} catch (Exception e) {
throw new RuntimeException(e);
}
log.info(openId);
// 判断扫码状态
if (StringUtils.isEmpty(openId)){
throw new ServiceException("WEI_XIN_TICKET"+ticket+"的值为空");
}else if(openId.equals("1")){
//1表达没有回调,没有关注关注号
HashMap<String, Object> scanResultMap = new HashMap<>();
scanResultMap.put("scanResult",-1);
return scanResultMap;
}else if (openId == null){
System.out.println("openId="+openId);
//说明二维码过期了,停止轮询
HashMap<String, Object> scanResultMap2 = new HashMap<>();
scanResultMap2.put("scanResult",-2);
return scanResultMap2;
}
// 验证用户注册信息
SysUser sysUser = null;
try {
//根据openId查用户
sysUser = sysUserSpecMapper.searchIdByOpenId(openId);
} catch (Exception e) {
throw new RuntimeException(e);
}
//公众号下发欢迎消息
wxUtil.sendLoginMassage(openId, sysUser.getUserName(), loginTemplateId);
//生成token
LoginUser loginUser = new LoginUser();
if(sysUser != null){
loginUser.setSysUser(sysUser);
}
Map<String, Object> token = tokenService.createToken(loginUser);
HashMap<String, Object> resultMap = new HashMap<>();
resultMap.put("token", token);
resultMap.put("user", sysUser);
resultMap.put("scanResult",1);
log.info("checkLogin方法执行结束!");
return resultMap;
}
}
mapper持久层
//用户信息mapper
@Repository
public interface SysUserSpecMapper {
//根据openId查询 用户是否存在
Integer countByOpenId(String fromUserName);
//根据openId查询用户
SysUser searchIdByOpenId(String openId);
//添加一个用户
void insertSysUser(SysUser sysUser);
}
WXUtils工具类
@Component
public class WxUtil {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
private String appId = "自己的appId";
private String appSecret = "自己的appSecret";
@Autowired
private RedisService redisService;
/**
* 用于获取 AccessToken (微信接口调用凭证)
* 由于微信限制每日只能获取 2000 次 AccessToken,使用缓存进行存储,避免接口调用次数过多
* @return AccessToken
*/
public String getAccessToken(){
// 查询Redis,若存在则直接返回
String accessToken = redisService.getCacheObject("WEI_XIN_ACCESS_TOKEN");
if (accessToken != null){
return accessToken;
}
// 缓存不存在,向微信请求AccessToken
Map<String, Object> params = new HashMap<>();
params.put("appId", appId);
params.put("secret", appSecret);
params.put("grant_type", "client_credential");
// 发送GET请求
accessToken = HttpUtil.get("https://api.weixin.qq.com/cgi-bin/token?", params);
// 处理结果
JSONObject jsonObject = JSONObject.parseObject(accessToken);
System.out.println(jsonObject);
accessToken = jsonObject.getString("access_token");
// 存入缓存
if (StringUtils.isNotEmpty(accessToken) && accessToken != null){
redisService.setStringVule("WEI_XIN_ACCESS_TOKEN",accessToken,60*60*2);
}else {
log.info("微信返回的accessToken为空");
throw new ServiceException("微信返回的accessToken为空");
}
return accessToken;
}
/**
* 读取 Request Body 内容作为字符串
* @param request HttpServletRequest
* @return XmlString
* @throws IOException XmlIO
*/
public String readRequest(HttpServletRequest request) throws IOException {
InputStream inputStream;
StringBuffer sb = new StringBuffer();
inputStream = request.getInputStream();
String str;
BufferedReader in = new BufferedReader(new InputStreamReader(inputStream, "UTF-8"));
while ((str = in.readLine()) != null) {
sb.append(str);
}
in.close();
inputStream.close();
return sb.toString();
}
/**
* 将微信获取的XML结果转为Map
* @param xmlString xml
* @return Map
* @throws DocumentException DocumentException
*/
public Map<String, String> ResponseXmlToMap(String xmlString) throws DocumentException {
// 解析 XML 字符串为 Document 对象
Document document = DocumentHelper.parseText(xmlString);
// 获取根元素
Element rootElement = document.getRootElement();
// 获取子元素
List<Element> nodes = rootElement.elements();
// 获取子元素的文本内容
Map<String, String> resultMap = new HashMap<>();
for (Node node:nodes ) {
Element element = (Element) node;
String nodeName = element.getName();
String nodeText = element.getTextTrim();
resultMap.put(nodeName, nodeText);
}
if (CollectionUtils.isEmpty(resultMap) && resultMap.containsKey("errcode")) {
throw new ServiceException("系统异常");
}
return resultMap;
}
/**
* 验证微信签名,确保接收到的消息来自微信官方
*/
public boolean checkSignature(String signature, String timestamp,String nonce, String token) {
// 1.将token、timestamp、nonce三个参数进行字典序排序
String[] arr = new String[]{token, timestamp, nonce};
Arrays.sort(arr);
// 2. 将三个参数字符串拼接成一个字符串进行sha1加密
StringBuilder content = new StringBuilder();
for (int i = 0; i < arr.length; i++) {
content.append(arr[i]);
}
MessageDigest md = null;
String tmpStr = null;
try {
md = MessageDigest.getInstance("SHA-1");
// 将三个参数字符串拼接成一个字符串进行sha1加密
byte[] digest = md.digest(content.toString().getBytes());
tmpStr = byteToStr(digest);
} catch (NoSuchAlgorithmException e) {
e.printStackTrace();
}
content = null;
// 3.将sha1加密后的字符串可与signature对比,标识该请求来源于微信
return tmpStr != null && tmpStr.equalsIgnoreCase(signature.toUpperCase());
}
private String byteToStr(byte[] byteArray) {
StringBuilder strDigest = new StringBuilder();
for (int i = 0; i < byteArray.length; i++) {
strDigest.append(byteToHexStr(byteArray[i]));
}
return strDigest.toString();
}
private String byteToHexStr(byte mByte) {
char[] Digit = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A',
'B', 'C', 'D', 'E', 'F'};
char[] tempArr = new char[2];
tempArr[0] = Digit[(mByte >>> 4) & 0X0F];
tempArr[1] = Digit[mByte & 0X0F];
String s = new String(tempArr);
return s;
}
/**
* 发送卡片消息
*/
public void sendLoginMassage(String openId,String userName,String templateId){
// 获取 AccessToken
String accessToken = getAccessToken();
String url = "https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=" + accessToken;
// 组织请求数据
Map<String, Object> data = new HashMap<>();
// 登录用户
Map<String, String> keyword3 = new HashMap<>();
keyword3.put("value", userName);
data.put("模板信息.DATA前面的字符串",keyword3);
// 登录时间
Map<String, String> keyword4 = new HashMap<>();
keyword4.put("value", new SimpleDateFormat("yyyy年MM月dd日 HH:mm").format(new Date()));
data.put("模板信息.DATA前面的字符串",keyword4);
// 登录网站
Map<String, String> keyword1 = new HashMap<>();
keyword1.put("value", "网站名称");
data.put("模板信息.DATA前面的字符串",keyword1);
// 登录网址
Map<String, String> keyword2 = new HashMap<>();
keyword2.put("value", "www.123.com");
data.put("模板信息.DATA前面的字符串",keyword2);
Map<String, Object> jsonData = new HashMap<>();
jsonData.put("touser", openId);
jsonData.put("template_id", templateId);
// jsonData.put("client_msg_id", openId); // 防重入id(对于同一个openid + client_msg_id, 10分钟内只发送一条消息)
jsonData.put("data", data);
// 发送请求
String result = HttpRequest.post(url).body(JSON.toJSONString(jsonData)).execute().body();
// 结果处理
JSONObject ticketJson = JSONObject.parseObject(result);
Integer errcode = ticketJson.getInteger("errcode");
if (errcode != 0){
log.error(errcode + ":" + ticketJson.getString("errmsg"));
throw new ServiceException("消息发送失败!");
}
}
}
三、前端代码
// 二维码的接口
export const getQRCode = () => {
return http({
method: "post",
url: "http://ip地址:80/system/weixin/getQRCode"
})
}
// 二维码扫码的登录轮询接口
export const checkQRCode = (data) => {
return http({
method: "get",
url: `http://ip地址/system/weixin/check`,
params: data
})
}
// 微信发送请求【轮询】
const scan = async () => {
//调用二维码接口,获取ticket
const res = await getQRCode()
data.value = res.data;
//固定链接+拼接ticket,获取二维码图片
ticket.value = `https://mp.weixin.qq.com/cgi-bin/showqrcode?ticket=${data.value.ticket}`;
if (ticket.value) {
//设置定时器轮询check()轮询登录接口
let loginTimer = setInterval(async () => {
const res = await checkQRCode({ ticket: data.value.ticket })
//条件判断,!= -1 表示关注成功
if (res.data.scanResult != -1) {
//停止轮询
clearInterval(loginTimer);
//用户信息赋值
userInfo.access_token = res.data.token.access_token
userInfo.avatar = res.data.user.avatar
userInfo.nickname = res.data.user.nickname
userInfo.userId = res.data.user.userId
} else if (res.data.scanResult == -2) {
//== -2 二维码过期
clearInterval(loginTimer);
}
//判断如果用户token存在提示登录成功
if (userInfo.access_token) {
ElMessage({ type: "success", message: "登录成功" });
// 2.关闭弹窗
dialogTableVisible.value = false;
}
}, 3000);
}
}
四、需要更改的地方
根据自己微信公众号的基本信息进行配置,共修改以下5个地方即可: