乐趣区

Spring-BootVue前后端分离微信公众号网页授权解决方案

一、引言

全网最全的前后端分离微信 网页授权 解决方案。如果有更好的优化方案,欢迎多多交流,文末有作者联系方式,欢迎叨扰。

二、网页授权的步骤

  • 1 第一步:用户同意授权,获取 code
  • 2 第二步:通过 code 换取网页授权 access_token
  • 3 第三步:刷新 access_token(如果需要)
  • 4 第四步:拉取用户信息(需 scope 为 snsapi_userinfo)
  • 5 附:检验授权凭证(access_token)是否有效

详情参考官方文档

注意:这里的 access_token 属于网页授权 access_token,而非普通授权的 access_token,官方给出的解释如下:

关于网页授权 access_token 和普通 access_token 的区别
1、微信网页授权是通过 OAuth2.0 机制实现的,在用户授权给公众号后,公众号可以获取到一个网页授权特有的接口调用凭证(网页授权 access_token),通过网页授权 access_token 可以进行授权后接口调用,如获取用户基本信息;
2、其他微信接口,需要通过基础支持中的“获取 access_token”接口来获取到的普通 access_token 调用。

但是没有讲得很明白。其实两者的区别就是:

  • 第一,网页授权 access_token 只要用户允许后就可以获取用户信息,可以不关注公众号,而普通 access_token 没有关注公众号,获取用户信息为空;
  • 第二,两者的每日限制调用凭次不同,普通 access_token 每日 2000 次,获取网页授权 access_token 不限次数,获取用户信息每日 5 万次。

三、后端接入

后端采用开源工具 weixin-java-tools

3.1 pom.xml 引入 jar 包

<dependency>
    <groupId>com.github.binarywang</groupId>
    <artifactId>weixin-java-mp</artifactId>
    <version>3.8.0</version>
</dependency>

3.2 application.yml 添加配置

这里换成自己的 appid 和 appsecret,可以申请测试账号

# 微信公众号
wechat:
  mpAppId: appid
  mpAppSecret: appsecret

3.3 新建读取配置文件 WechatMpProperties.java

package com.hsc.power.dm.wechat.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;

/**
 * 微信公众号配置文件
 *
 * @author liupan
 * @date 2020-05-26
 */
@Data
@Component
@ConfigurationProperties(prefix = "wechat")
public class WechatMpProperties {
    private String mpAppId;
    private String mpAppSecret;
}

3.4 新建自定义微信配置 WechatMpConfig.java

package com.hsc.power.dm.wechat.config;

import me.chanjar.weixin.mp.api.WxMpService;
import me.chanjar.weixin.mp.api.impl.WxMpServiceImpl;
import me.chanjar.weixin.mp.config.WxMpConfigStorage;
import me.chanjar.weixin.mp.config.impl.WxMpDefaultConfigImpl;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.stereotype.Component;

/**
 * 微信公众号配置
 *
 * @author liupan
 * @date 2020-05-26
 */
@Component
public class WechatMpConfig {

    @Autowired
    private WechatMpProperties wechatMpProperties;

    /**
     * 配置 WxMpService 所需信息
     *
     * @return
     */
    @Bean  // 此注解指定在 Spring 容器启动时,就执行该方法并将该方法返回的对象交由 Spring 容器管理
    public WxMpService wxMpService() {WxMpService wxMpService = new WxMpServiceImpl();
        // 设置配置信息的存储位置
        wxMpService.setWxMpConfigStorage(wxMpConfigStorage());
        return wxMpService;
    }

    /**
     * 配置 appID 和 appsecret
     *
     * @return
     */
    @Bean
    public WxMpConfigStorage wxMpConfigStorage() {
        // 使用这个实现类则表示将配置信息存储在内存中
        WxMpDefaultConfigImpl wxMpDefaultConfig = new WxMpDefaultConfigImpl();
        wxMpDefaultConfig.setAppId(wechatMpProperties.getMpAppId());
        wxMpDefaultConfig.setSecret(wechatMpProperties.getMpAppSecret());
        return wxMpDefaultConfig;
    }
}

3.5 新建微信用户 Bean

package com.hsc.power.dm.wechat.vo;

import lombok.Data;
import me.chanjar.weixin.mp.bean.result.WxMpUser;

@Data
public class WechatUser {public WechatUser(WxMpUser wxMpUser, String accessToken) {this.setAccessToken(accessToken);
        this.setOpenid(wxMpUser.getOpenId());
        this.setUnionId(wxMpUser.getUnionId());
        this.setNickname(wxMpUser.getNickname());
        this.setLanguage(wxMpUser.getLanguage());
        this.setCountry(wxMpUser.getCountry());
        this.setProvince(wxMpUser.getCity());
        this.setCity(wxMpUser.getCity());
        this.setSex(wxMpUser.getSex());
        this.setSexDesc(wxMpUser.getSexDesc());
        this.setHeadImgUrl(wxMpUser.getHeadImgUrl());
    }

    private String openid;
    private String accessToken;
    private String unionId;
    private String nickname;
    private String language;
    private String country;
    private String province;
    private String city;
    private Integer sex;
    private String sexDesc;
    private String headImgUrl;
}

3.6 授权接口 WechatController.java

    1. /auth:获取授权跳转地址
    1. /auth/user/info:初次授权获取用户信息
    1. /token/user/info:静默授权获取用户信息
package com.hsc.power.dm.wechat.web;

import com.baomidou.mybatisplus.core.toolkit.ExceptionUtils;
import com.hsc.power.core.base.ret.Rb;
import com.hsc.power.dm.wechat.vo.WechatUser;
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.common.api.WxConsts;
import me.chanjar.weixin.common.error.WxErrorException;
import me.chanjar.weixin.mp.api.WxMpService;
import me.chanjar.weixin.mp.bean.result.WxMpOAuth2AccessToken;
import me.chanjar.weixin.mp.bean.result.WxMpUser;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.net.URLEncoder;

/**
 * 微信公众号接口
 *
 * @author liupan
 * @date 2020-05-26
 */
@Slf4j
@RestController
@RequestMapping("/wechat")
public class WechatController {
    @Autowired
    private WxMpService wxMpService;

    /**
     * 获取 code 参数
     *
     * @param returnUrl 需要跳转的 url
     * @return
     */
    @GetMapping("/auth")
    public Rb<String> authorize(@RequestParam String authCallbackUrl, @RequestParam String returnUrl) {
        // 暂时将我们的回调地址硬编码在这里,方便一会调试
        // 获取微信返回的重定向 url
        String redirectUrl = wxMpService.oauth2buildAuthorizationUrl(authCallbackUrl, WxConsts.OAuth2Scope.SNSAPI_USERINFO, URLEncoder.encode(returnUrl));
        log.info("【微信网页授权】获取 code,redirectUrl = {}", redirectUrl);
        return Rb.ok(redirectUrl);
    }

    /**
     * 初次授权获取用户信息
     *
     * @param code
     * @param returnUrl
     * @return
     */
    @GetMapping("/auth/user/info")
    public Rb<WechatUser> userInfo(@RequestParam("code") String code, @RequestParam("state") String returnUrl) {
        WxMpOAuth2AccessToken wxMpOAuth2AccessToken;
        WxMpUser wxMpUser;
        try {
            // 使用 code 换取 access_token 信息
            wxMpOAuth2AccessToken = wxMpService.oauth2getAccessToken(code);
            wxMpUser = wxMpService.oauth2getUserInfo(wxMpOAuth2AccessToken, null);
        } catch (WxErrorException e) {log.error("【微信网页授权】异常,{}", e);
            throw ExceptionUtils.mpe(e.getError().getErrorMsg());
        }
        // 从 access_token 信息中获取到用户的 openid
        String openId = wxMpOAuth2AccessToken.getOpenId();
        log.info("【微信网页授权】获取 openId,openId = {}", openId);

        WechatUser wechatUser = new WechatUser(wxMpUser, wxMpOAuth2AccessToken.getAccessToken());
        return Rb.ok(wechatUser);
    }

    /**
     * 静默授权获取用户信息,判断 accessToken 是否失效,失效即刷新 accecssToken
     * @param openid
     * @param token
     * @return
     */
    @GetMapping("/token/user/info")
    public Rb<WechatUser> getUserInfo(@RequestParam String openid, @RequestParam String token) {WxMpOAuth2AccessToken wxMpOAuth2AccessToken = new WxMpOAuth2AccessToken();
        wxMpOAuth2AccessToken.setOpenId(openid);
        wxMpOAuth2AccessToken.setAccessToken(token);
        boolean ret = wxMpService.oauth2validateAccessToken(wxMpOAuth2AccessToken);
        if (!ret) {
            // 已经失效
            try {
                // 刷新 accessToken
                wxMpOAuth2AccessToken = wxMpService.oauth2refreshAccessToken(wxMpOAuth2AccessToken.getRefreshToken());
            } catch (WxErrorException e) {log.error("【微信网页授权】刷新 token 失败,{}", e.getError().getErrorMsg());
                throw ExceptionUtils.mpe(e.getError().getErrorMsg());
            }
        }
        // 获取用户信息
        try {WxMpUser wxMpUser = wxMpService.oauth2getUserInfo(wxMpOAuth2AccessToken, null);
            WechatUser wechatUser = new WechatUser(wxMpUser, wxMpOAuth2AccessToken.getAccessToken());
            return Rb.ok(wechatUser);
        } catch (WxErrorException e) {log.error("【微信网页授权】获取用户信息失败,{}", e.getError().getErrorMsg());
            throw ExceptionUtils.mpe(e.getError().getErrorMsg());
        }
    }
}

四、前端接入

4.1 路由拦截

noAuth 配置是否需要授权页面

router.beforeEach((to, from, next) => {
  // 微信公众号授权
  if (!to.meta.noAuth) {
    // 路由需要授权
    if (_.isEmpty(store.getters.wechatUserInfo)) {
      // 获取用户信息
      if (!_.isEmpty(store.getters.openid) &&
        !_.isEmpty(store.getters.accessToken)
      ) {
        // 存在 openid 和 accessToken,已经授过权
        // 判断 accessToken 是否过期,过期刷新 token,获取用户信息
        store.dispatch('getUserInfo')
        next()} else {
        // todo 跳转网页授权
        // 记录当前页面 url
        localStorage.setItem('currentUrl', to.fullPath)
        next({name: 'auth'})
      }
    } else {
      // todo 已经存在用户信息,需要定期更新
      next()}
  } else {
    // 路由不需要授权
    next()}
})

4.2 授权页面

{
  path: '/auth',
  name: 'auth',
  component: resolve => {require(['@/views/auth/index.vue'], resolve)
  },
  meta: {noAuth: true}
},
<template></template>

<script>
import config from '@/config'
import WechatService from '@/api/wechat'
export default {mounted() {WechatService.auth(config.WechatAuthCallbackUrl).then(res => {if (res.ok()) {
        // 获取授权页面后直接进行跳转
        window.location.href = res.data
      }
    })
  }
}
</script>

4.3 授权 store

在 vuex 中进行授权和存储用户信息

import _ from 'lodash'
import WechatService from '@/api/wechat'
import localStorageUtil from '@/utils/LocalStorageUtil'
export default {
  state: {
    unionId: '',
    openid: '',
    accessToken: '',
    wechatUserInfo: {}},
  getters: {
    unionId: state => {return state.unionId || localStorageUtil.get('unionId')
    },
    openid: state => {return state.openid || localStorageUtil.get('openid')
    },
    accessToken: state => {return state.accessToken || localStorageUtil.get('accessToken')
    },
    wechatUserInfo: state => {return state.wechatUserInfo || localStorageUtil.get('wechatUserInfo')
    }
  },
  mutations: {saveWechatUserInfo: (state, res) => {
      state.wechatUserInfo = res
      // todo 保存到 storage,设置一定日期,定期更新
      state.unionId = res.unionId
      state.openid = res.openid
      state.accessToken = res.accessToken
      localStorageUtil.set('unionId', res.unionId)
      localStorageUtil.set('openid', res.openid)
      localStorageUtil.set('accessToken', res.accessToken)
      // 保存 userInfo,设置有效时间,默认 30 天
      localStorageUtil.set('wechatUserInfo', res, 30)
    }
  },
  actions: {
    // 静默授权获取用户信息
    async getUserInfo({commit, getters}) {
      const openid = getters.openid
      const token = getters.accessToken
      if (!_.isEmpty(openid) && !_.isEmpty(token)) {
        // 存在 openid 和 accessToken,已经授过权
        // 判断 accessToken 是否过期,过期刷新 token,获取用户信息
        const res = await WechatService.getUserInfo(openid, token)
        if (res.ok()) {
          // todo 判断 res.data 是否有误
          commit('saveWechatUserInfo', res.data)
        }
      }
    },
    // 初次授权获取用户信息
    async getAuthUserInfo({commit}, {code, state}) {if (!_.isEmpty(code) && !_.isEmpty(state)) {const res = await WechatService.getAuthUserInfo(code, state)
        if (res.ok()) {commit('saveWechatUserInfo', res.data)
        }
      }
    }
  }
}

4.4 自定义存储工具 localStorageUtil.js

localStorageUtil.js:用于设置保存有效期

在这里,用户信息设置保存 30 天,根据前面 4.1 路由拦截判断,用户信息过期,需要重新进行授权认证。 感觉这种方式不太好,但是获取用户信息每月限制 5 万次,不想每次都去调用接口获取用户信息,这里有更好的方案吗?

import _ from 'lodash'
import moment from 'moment'
export default {
  /**
   * 获取 session-storage 中的值
   * @param {*} key
   * @param {*} defaultValue
   */
  get(key, defaultValue) {return this.parse(key, defaultValue)
  },
  /**
   * 放入 session-storage 中,自动字符串化 obj
   * @param {*} key
   * @param {*} obj
   * @param {Integer} expires 过期时间:天
   */
  set(key, obj, expires) {if (expires) {const tmpTime = moment()
        .add(expires, 'days')
        .format('YYYY-MM-DD')
      const handleObj = {expires: tmpTime, value: obj}
      localStorage.setItem(key, JSON.stringify(handleObj))
    } else {if (_.isObject(obj)) {localStorage.setItem(key, JSON.stringify(obj))
      } else {localStorage.setItem(key, obj)
      }
    }
  },
  /**
   * 从 session-storage 中移除 key
   * @param {*} key
   */
  remove(key) {localStorage.removeItem(key)
  },

  /**
   * 从 session-storage 取出 key 并将值对象化
   * @param {*} key
   * @param {*} defaultValue
   */
  parse(key, defaultValue) {let value = localStorage.getItem(key)
    if (_.isObject(value)) {const valueObj = JSON.parse(value)
      if (valueObj.expires) {
        // 有过期时间,判断是否过期:在现在时间之前,过期
        if (moment(valueObj.expires).isBefore(moment(), 'day')) {
          // 删除
          this.remove(key)
          // 直接返回
          return null
        }
        return valueObj.value
      }
      // 没有过期时间直接返回对象
      return valueObj
    }
    // 不是对象,返回值
    return value || defaultValue
  }
}

至此大功告成,在微信开发者工具中即可获取用户信息,亲测有效。

开源不易,且用且珍惜!

赞助作者,互相交流

转载请注明:我的技术分享 » Spring Boot+Vue 前后端分离微信公众号网页授权解决方案

退出移动版