微信公众号配置 Token 认证以及消息推送功能

前言

基于后端服务,操作微信公众号,定制化菜单、消息推送、消息回复等功能,都需要通过服务端进行统一的 Token 认证,这样双方才能形成绑定的关系,从而实现相互认知后进行通信.

公众号服务配置 Token认证

接入服务器配置的目的:接入微信第三方开发平台,在微信公众平台提供的基础服务上开发微网站、微商城、活动应用zd、娱乐插件等扩展功能

如何配置

服务器基本配置
在这里插入图片描述
URL必须是以 HTTP 或 HTTPS 开头,必须是在线上可访问的域名地址,部署在本地不可以,/wx/auth 是后台的接口地址,Token 是在后端服务配置的一个固定值,必须要一致相互才能 ping 通,其他如上图所示,点击提交后进行启用在这里插入图片描述

Token 认证接口

/wx/auth 接口源码如下:

/**
 * @author vnjohn
 * @since 2023/2/19
 */
@Slf4j
@RestController("/wx")
@Api(value = "微信服务器 token 认证", tags = "微信服务器 token 认证 接口API")
public class WxPortalController {

    @GetMapping("/auth")
    public void authPost(HttpServletRequest request, HttpServletResponse response) throws IOException {
        if (StringUtils.isNotBlank(request.getParameter("signature"))) {
            String signature = request.getParameter("signature");
            String timestamp = request.getParameter("timestamp");
            String nonce = request.getParameter("nonce");
            String echostr = request.getParameter("echostr");
            log.info("signature[{}], timestamp[{}], nonce[{}], echostr[{}]", signature, timestamp, nonce, echostr);
            if (SignUtil.checkSignature(signature, timestamp, nonce)) {
                log.info("数据源为微信后台,将echostr[{}]返回!", echostr);
                response.getOutputStream().println(echostr);
            }
        }
    }
}

Token 认证类源码如下:

/**
 * @author vnjohn
 * @since 2023/2/19
 */
public class SignUtil {
    /**
     * 这里是自定义 Token,需和你在公众号后台提交的 Token 保持一致
     */
    private static final String TOKEN = "familySchool";

    /**
     * 校验签名
     *
     * @param signature 签名
     * @param timestamp 时间戳
     * @param nonce     随机数
     * @return 布尔值
     */
    public static boolean checkSignature(String signature, String timestamp, String nonce) {
        String checktext = null;
        if (null != signature) {
            // 对ToKen,timestamp,nonce 按字典排序
            String[] paramArr = new String[]{TOKEN, timestamp, nonce};
            Arrays.sort(paramArr);
            // 将排序后的结果拼成一个字符串
            String content = paramArr[0].concat(paramArr[1]).concat(paramArr[2]);

            try {
                MessageDigest md = MessageDigest.getInstance("SHA-1");
                // 对接后的字符串进行sha1加密
                byte[] digest = md.digest(content.getBytes());
                checktext = byteToStr(digest);
            } catch (NoSuchAlgorithmException e) {
                e.printStackTrace();
            }
        }
        // 将加密后的字符串与signature进行对比
        return checktext != null && checktext.equals(signature.toUpperCase());
    }

    /**
     * 将字节数组转化为16进制字符串
     *
     * @param byteArrays 字符数组
     * @return 字符串
     */
    private static String byteToStr(byte[] byteArrays) {
        String str = "";
        for (int i = 0; i < byteArrays.length; i++) {
            str += byteToHexStr(byteArrays[i]);
        }
        return str;
    }

    /**
     * 将字节转化为十六进制字符串
     *
     * @param myByte 字节
     * @return 字符串
     */
    private static String byteToHexStr(byte myByte) {
        char[] Digit = {'0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'A', 'B', 'C', 'D', 'E', 'F'};
        char[] tampArr = new char[2];
        tampArr[0] = Digit[(myByte >>> 4) & 0X0F];
        tampArr[1] = Digit[myByte & 0X0F];
        String str = new String(tampArr);
        return str;
    }
}

公众号获取网页授权及用户信息

导向

获取网页授权前提是我们需要在公众号上配置好网页授权的域名,配置好以后,我们需要一个触发点去触发这个网页(H5)以便用户点击我们设置好的触发按钮或事件时能拿到其对应的用户信息,本篇文章我们采用被关注自动回复消息,点击消息触发

此处介绍公众号UNION_ID和OPEN_ID区别:

  • UNION_ID:在同一个主体下,公众号、小程序、APP 涉及到微信应用的时候,所拿到的 UNIONID 都是一样的,打个比方:相当于身份证,在哪个地方都可以用到而且是唯一的
  • OPEN_ID:公众号、小程序都会有一个会话 ID 就是 OPEN_ID,一拿到这个会话 ID 我们就能在这个应用作为唯一标识通行,打个比方:社保卡在我们购买药品时可以用到、驾驶证在我们出行时可以使用

同一个微信开放平台下的相同主体的 App、公众号、小程序,如果用户已经关注公众号,或者曾经登录过 App 或公众号,则用户打开小程序时,开发者可以直接通过 wx.login 获取到该用户 UNION_ID,无须用户再次授权。如果用户未关注公众号,可以通过 wx.getUserInfo 获取到该用户 UNION_ID

在这里插入图片描述

网页授权

首先要拿到网页授权的接口权限
在这里插入图片描述
修改网页授权接口时,要修改 JS 接口安全域名以及网页授权域名

上图需要下载对应的文件,前提是域名/文件名需要访问到你对应的文件内容才能配好网页授权域名,代码如下:

/**
 * @author vnjohn
 * @since 2023/2/19
 */
@RestController
public class DomainAuthController {
    /**
     * 网页授权域名,需要把用到的文件下载下来,文件名是需要请求的接口
     * 返回的是文件对应的内容
     */
    @GetMapping("MP_verify_zxgx6O368tVx5sDK.txt")
    private String returnConfigFile() {
        //把 MP_verify_xxxxxx.txt 中的内容返回
        return "zxgx6O368tVx5sDK";
    }
}

关注后消息触发授权

在导向中提及到的 被关注后自动回复消息点击触发
在这里插入图片描述
URL 地址:<a href="https://open.weixin.qq.com/connect/oauth2/authorize?appid=wx6a14ea8110b086af&redirect_uri=http%3A%2F%2Fxxxx%2Fwx%2FwxLogin&response_type=code&scope=snsapi_userinfo&state=STATE#wechat_redirect">点击绑定你的个人信息</a>

域名是我们配置好网页授权域名,接口地址是我们后台处理的接口
官方介绍:微信网页授权认证
utf-
redirect_uri 回调的链接地址是需要通过 utf-8 编码过后的,如下:

java.net.URLEncoder.encode(source, "utf-8");

WxConstant 类是自定义微信常量类:存放请求 TOKEN Address、APPID、SECRET

/**
 * @author vnjohn
 * @since 2023/2/19
 */
public class WxConstant {
    /**
     * 小程序授权grant_type
     */
    public static final String AUTHORIZATION_CODE = "023VMFlQ0fMEp72wK7kQ0FIqlQ0VMFlu";
    /**
     * 打卡模板id
     */
    public final static String PUNCH_TEMPLATE_ID = "0pMwyHxbobrotPdcbkIUt3fY03pnVm9Ril4KEqLmeNE";
    /**
     * 收款模板id
     */
    public final static String COLLECTION_TEMPLATE_ID = "7vdNCysQL64KBQtHG4xy2wsTQWyvQo4P66HPDvXbIHE";
    /**
     * 学校通知模板id
     */
    public final static String MSG_TEMPLATE_ID = "DVBa7LpPssglj5DYgNtenxDhZ-8DBuxnctVgiwnhGxs";
    /**
     * 公众号APP_ID
     */
    public final static String GZH_APP_ID = "公众号APP_ID";
    /**
     * 公众号SECRET
     */
    public final static String GZH_SECRET = "公众号SECRET";
    /**
     * 公众号获取用户信息的 URL
     */
    public final static String GZH_USER_INFO = "https://api.weixin.qq.com/sns/userinfo?access_token=ACCESS_TOKEN&openid=OPENID";
    /**
     * 小程序APP_ID
     */
    public final static String APPLET_APP_ID = "小程序APP_ID";
    /**
     * 小程序APP_SECRET
     */
    public final static String APPLET_SECRET = "小程序APP_SECRET";
    /**
     * 小程序获取token凭证获取(GET)url、公众号推送消息获取token凭证
     */
    public final static String TOKEN_URL = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=" +
            "APPID&secret=SECRET";
    /**
     * 生成小程序二维码
     */
    public final static String QRCODE_URL = "https://api.weixin.qq.com/wxa/getwxacodeunlimit?access_token=ACCESSTOKEN";
    /**
     * 公众号推送模板消息URL
     */
    public final static String MSG_API = "https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=ACCESSTOKEN";
    /**
     * 获取网页授权凭证URL
     */
    public final static String AUTH_URL = "https://api.weixin.qq.com/sns/oauth2/access_token?appid=APPID&secret=SECRET&code=CODE&grant_type=authorization_code";
}
@Data
public class Oauth2Token {
    /**
     * 网页授权接口调用凭证
     */
    private String accessToken;
    /**
     * 凭证有效时长
     */
    private int expiresIn;
    /**
     * 用于刷新凭证
     */
    private String refreshToken;
    /**
     * 用户标识
     */
    private String openId;
    /**
     * 用户授权作用域
     */
    private String scope;
}

/wx/login 接口源码如下:

/**
 * @author vnjohn
 * @since 2023/2/19
 */
@Slf4j
@RestController
@RequestMapping("/wx/wxLogin")
public class WxLoginController {

    @GetMapping
    public void weixinLogin(HttpServletRequest request, HttpServletResponse response) throws Exception {
        // 用户同意授权后,能获取到code
        String code = request.getParameter("code");//拿到code的值
        logger.info("****************code:" + code);
        // 用户同意授权

        if (!"authdeny".equals(code)) {
            // 获取网页授权access_token
            Oauth2Token oauth2Token = getOauth2AccessToken(WxConstant.GZH_APP_ID, WxConstant.GZH_SECRET, code);
            logger.info("***********************************oauth2Token信息:" + oauth2Token.toString());
            // 网页授权接口访问凭证
            String accessToken = oauth2Token.getAccessToken();
            // 用户标识
            String openId = oauth2Token.getOpenId();
            // 获取用户信息
            WxUserInfo wxUserInfo = getWxUserInfo(accessToken, openId);
            logger.info("***********************************用户信息unionId:" + wxUserInfo.getUnionid() + "***:" + wxUserInfo.getNickname());
            //到数据库查询 该 用户信息是否存在
            int result = userService.queryIsExists(wxUserInfo.getUnionid());
            if (result < 1) {//添加一条新的记录
                User communityUser = new User();
                communityUser.setNickName(wxUserInfo.getNickname());
                communityUser.setOpenId(wxUserInfo.getOpenId());
                communityUser.setUnionId(wxUserInfo.getUnionid());
                result = userService.addUser(communityUser);
                if (result > 0) {
                    logger.info("绑定用户信息成功");
                } else {
                    logger.info("绑定用户信息失败");
                }
            } else {
                // 这种情况出现:已关注->取关->再次关注(数据库肯定存在记录,此时重新删除,再次添加)
                userService.delInfo(wxUserInfo.getUnionid());
                User communityUser = new User();
                communityUser.setNickName(wxUserInfo.getNickname());
                communityUser.setOpenId(wxUserInfo.getOpenId());
                communityUser.setUnionId(wxUserInfo.getUnionid());
                result = userService.addUser(communityUser);
                if (result > 0) {
                    logger.info("绑定用户信息成功");
                } else {
                    logger.info("绑定用户信息失败");
                }
            }
            // 设置要传递的参数
            //具体业务start
            logger.info("openId:" + openId);
            //具体业务end
            logger.info(wxUserInfo.toString());
            //重定向到我们需要跳转的地址 由前端编写的h5页面 必须是部署到服务器上的
            response.sendRedirect("跳转的网页地址");
            return;
        }
        return;
    }

    /**
     * 获取网页授权凭证
     *
     * @param appId     公众账号的唯一标识
     * @param appSecret 公众账号的密钥
     * @param code
     * @return Oauth2Token
     */
    public static Oauth2Token getOauth2AccessToken(String appId, String appSecret, String code) {
        Oauth2Token wat = null;
        // 拼接请求地址
        String requestUrl = WxConstant.AUTH_URL.replace("APPID", appId).replace("SECRET", appSecret).replace("CODE", code);
        // 获取网页授权凭证
        JSONObject jsonObject = JSON.parseObject(HttpUtil.get(requestUrl));
        if (null != jsonObject) {
            try {
                wat = new Oauth2Token();
                wat.setAccessToken(jsonObject.getString("access_token"));
                wat.setExpiresIn(jsonObject.getInteger("expires_in"));
                wat.setRefreshToken(jsonObject.getString("refresh_token"));
                wat.setOpenId(jsonObject.getString("openid"));
                wat.setScope(jsonObject.getString("scope"));
            } catch (Exception e) {
                wat = null;
                int errorCode = jsonObject.getInteger("errcode");
                String errorMsg = jsonObject.getString("errmsg");
                log.error("获取网页授权凭证失败 errcode:{} errmsg:{}", errorCode, errorMsg);
            }
        }
        return wat;
    }

    /**
     * 通过网页授权获取用户信息
     *
     * @param accessToken 网页授权接口调用凭证
     * @param openId      用户标识
     * @return WxUserInfo
     */
    public static WxUserInfo getWxUserInfo(String accessToken, String openId) {
        WxUserInfo wxUserInfo = null;
        // 拼接请求地址
        String requestUrl = WxConstant.GZH_USER_INFO.replace("ACCESS_TOKEN", accessToken).replace("OPENID", openId);
        // 通过网页授权获取用户信息
        JSONObject jsonObject = JSON.parseObject(HttpUtil.get(requestUrl));
        if (null != jsonObject) {
            try {
                wxUserInfo = new WxUserInfo();
                // 用户的标识
                wxUserInfo.setOpenId(jsonObject.getString("openid"));
                // 昵称
                wxUserInfo.setNickname(jsonObject.getString("nickname"));
                // 性别(1是男性,2是女性,0是未知)
                wxUserInfo.setSex(jsonObject.getInteger("sex"));
                // 用户所在国家
                wxUserInfo.setCountry(jsonObject.getString("country"));
                // 用户所在省份
                wxUserInfo.setProvince(jsonObject.getString("province"));
                // 用户所在城市
                wxUserInfo.setCity(jsonObject.getString("city"));
                // 用户头像
                wxUserInfo.setHeadImgUrl(jsonObject.getString("headimgurl"));
                // 用户特权信息
                List<String> list = JSON.parseArray(jsonObject.getString("privilege"), String.class);
                wxUserInfo.setPrivilegeList(list);
                // 与开放平台共用的唯一标识,只有在用户将公众号绑定到微信开放平台帐号后,才会出现该字段。
                wxUserInfo.setUnionid(jsonObject.getString("unionid"));
                // 此处需要进入数据库  需要将 UnionId、openId 存入数据库
            } catch (Exception e) {
                wxUserInfo = null;
                int errorCode = jsonObject.getInteger("errcode");
                String errorMsg = jsonObject.getString("errmsg");
                log.error("获取用户信息失败 errcode:{} errmsg:{}", errorCode, errorMsg);
            }
        }
        return wxUserInfo;
    }

    /**
     * URL编码(utf-8)
     *
     * @param source
     * @return
     */
    public static String urlEncodeUTF8(String source) {
        String result = source;
        try {
            result = java.net.URLEncoder.encode(source, "utf-8");
        } catch (UnsupportedEncodingException e) {
            e.printStackTrace();
        }
        return result;
    }
}

微信网页授权成功并拿到用户信息以后跳转的 H5 网页是一个简单的显示跳转成功页面,让公司的前端大佬做一下就 OK
在这里插入图片描述
关注后会显示出来,点击绑定你的个人信息
在这里插入图片描述
点击后跳转成功,完成测试 ,注意:全部操作需要部署到线上操作,或者通过内网穿透的方式来部署,否则无法进行测试

公众号推送模板消息

模板消息功能插件

在这里插入图片描述
开通成功以后会在功能栏下出现模板消息,右侧是我们从模板库引入的一个模板,稍后推送会用到
在这里插入图片描述

推送模版消息接口介绍

官方文档:模版消息接口
按照官方文档操作步骤如下:

  • http请求方式: POST https://api.weixin.qq.com/cgi-bin/message/template/send?access_token=ACCESS_TOKEN,access_token 接口调用凭证

POST 请求 data 数据需要传递的参数介绍:
在这里插入图片描述
注:url、miniprogram 都是非必填字段,若都不传则模板无跳转;若都传,会优先跳转至小程序。开发者可根据实际需要选择其中一种跳转方式即可;当用户的微信客户端版本不支持跳小程序时,将会跳转至 url

获取 Token 接口凭证

获取 token 接口凭证,获取到的 token 有效时间为 2 小时,在我们程序正常运行的时候,不可能每次都重启,我们可以使用定时器去定时更新我们 token 接口凭证

token 接口凭证类源码如下:

/**
 * 公众号推送消息所用的实体类
 * @author vnjohn
 * @since 2023/2/19
 */
@Data
public class AccessToken {
    /**
     * 公众号通知类型:1服务号 2订阅号
     */
    private Integer noticeType;
    private Integer schoolId;
    private String accessToken;
    private int expiresIn;
}
/**
 * @author vnjohn
 * @since 2023/2/19
 */
public class TokenUtil {
    public static AccessToken accessToken = null;
    private final static Logger logger = LoggerFactory.getLogger(TokenUtil.class);

    /**
     * 获取接口访问凭证
     * 刷新 access_token 110 分钟刷新一次,服务器启动的时候刷新一次(access_token 有效期是120分钟,我设置的是每 110 分钟刷新一次)
     *
     * @return 认证信息
     */
    @Scheduled(initialDelay = 1000, fixedDelay = 60 * 1000 * 110)
    private static AccessToken getAccessToken() {
        String requestUrl = WxConstant.TOKEN_URL.replace("APPID", WxConstant.GZH_APP_ID)
                                                .replace("SECRET", WxConstant.GZH_SECRET);
        // 发起GET请求获取凭证
        JSONObject jsonObject = JSONObject.parseObject(HttpUtil.sendPost(requestUrl, "GET", null));
        if (null != jsonObject) {
            try {
                TokenUtil.accessToken = new AccessToken();
                TokenUtil.accessToken.setAccessToken(jsonObject.getString("access_token"));
                TokenUtil.accessToken.setExpiresIn(jsonObject.getIntValue("expires_in"));
                logger.info("AccessToken>>>>>>>>>>>>>" + jsonObject.toString());
            } catch (Exception e) {
                TokenUtil.accessToken = null;
                // 获取token失败
                logger.error(e.getMessage());
            }
        }
        return TokenUtil.accessToken;
    }
}

模版消息 Send 工具类

/**
 * @author vnjohn
 * @since 2023/2/19
 */
@Data
@NoArgsConstructor
@AllArgsConstructor
public class TemplateData {
    /**
     * 只能是 value 属性不能是 name 属性
     */
    private String value;
    private String color;
}
/**
 * @author vnjohn
 * @since 2023/2/19
 */
public class SendTemplateMsgUtil {
    private final static Logger logger = LoggerFactory.getLogger(SendTemplateMsgUtil.class);
    private final static String TO_URL = "http://weixin.qq.com/download";

    /**
     * 点击消息跳转相关参数map
     *
     * @return
     */
    public static Map<String, String> getMiniProgramMap() {
        //点击消息跳转相关参数map
        Map<String, String> miniProgramMap = new HashMap<String, String>();
        miniProgramMap.put("appid", WxConstant.APPLET_APP_ID);
        miniProgramMap.put("path", "pages/me/me");
        return miniProgramMap;
    }

    /**
     * 定时推送 打卡消息通知
     *
     * @param openId
     */
    public static void SendWeChatPunchClockMsg(String openId, String studentName, String category, String content) {
        logger.info(TokenUtil.accessToken.toString());
        AccessToken accessToken = TokenUtil.accessToken;
        // 接口地址
        String sendMsgApi = WxConstant.MSG_API.replace("ACCESSTOKEN", accessToken.getAccessToken());
        // 消息模板ID
        String template_id = WxConstant.PUNCH_TEMPLATE_ID;
        // 整体参数map
        Map<String, Object> paramMap = new HashMap<String, Object>();
        paramMap.put("touser", openId);
        paramMap.put("template_id", template_id);
        paramMap.put("url", TO_URL);
        paramMap.put("miniprogram", getMiniProgramMap());
        paramMap.put("data", getDataMap(studentName, category, content, 1));
        logger.info(HttpUtil.sendPost(sendMsgApi, "POST", paramMap));
    }

    /**
     * 消息主题显示相关map
     *
     * @param msgType 不同的值走不同的推送
     * @return
     */
    public static Map<String, Object> getDataMap(String param1, String param2,
                                                 String param3, Integer msgType) {
        Map<String, Object> dataMap = new HashMap<>();
        if (msgType.equals(1)) {//打卡通知
            dataMap.put("first", new TemplateData("亲爱的" + param1 + "家长," + param1 + "今天的作业如下:", "#173177"));
            dataMap.put("name", new TemplateData(param1, "#173177"));
            dataMap.put("subject", new TemplateData(param2, "#173177"));
            dataMap.put("content", new TemplateData(param3, "#173177"));
        } else if (msgType.equals(2)) {//收款通知
            dataMap.put("first", new TemplateData(param1 + "家长你好,你的孩子需要收取费用:", "#173177"));
            dataMap.put("keyword1", new TemplateData(param1, "#173177"));//param1 为学生姓名
            dataMap.put("keyword2", new TemplateData(param2, "#173177"));//缴费类型
            dataMap.put("keyword3", new TemplateData(param3, "#173177"));//缴费金额
            dataMap.put("remark", new TemplateData("为有助于学校各项工作的顺利开展,请您务必在缴费截止日期前缴费", "#173177"));
        } else if (msgType.equals(3)) {//消息通知
            dataMap.put("first", new TemplateData("您好,为方便对学生进行管理,特通知如下:", "#173177"));
            dataMap.put("keyword1", new TemplateData(param1, "#173177"));//keyword1  param1 为学校
            dataMap.put("keyword2", new TemplateData(param2, "#173177"));//keyword2  param2 为通知人
            dataMap.put("keyword3", new TemplateData(DateUtil.dateToString(new Date()), "#173177"));//keyword3   为通知时间
            dataMap.put("keyword4", new TemplateData(param3, "#173177"));//keyword4  param3  为通知内容
            dataMap.put("remark", new TemplateData("为有助于学校各项工作的顺利开展", "#173177"));
        }
        return dataMap;
    }
}

推送模板消息接口测试

传入刚刚网页授权所拿到的 openId、模板 id 进行测试,在获取 Token 接口凭证时报错:invalid IP

因为该 IP 未设置白名单,进入公众号后台设置 IP 白名单
在这里插入图片描述
重新启动以后,已拿到 token 接口凭证,之后我们可以通过 postman 进行发送模板消息请求

@GetMapping("/wx/sendMsg")
public void sendMsg(@RequestParam String openId,@RequestParam String studentName,@RequestParam String category,@RequestParam String content){
    SendMsgUtil.SendWeChatPunchClockMsg(openId,studentName,category,content);
}

Post man 测试推送消息接口:
在这里插入图片描述
手机上正常接收到消息提醒
在这里插入图片描述

总结

该文章介绍在使用公众号开发功能时,涉及到的菜单、消息回复、推送等功能时,前面需要的一些准备工作,以及如何配置后端服务进行交互,同时提供了 Token 认证配置、网页授权、模版消息推送功能相关的源码给到大家

如果觉得博文不错,关注我 vnjohn,后续会有更多实战、源码、架构干货分享!
大家的「关注❤️ + 点赞? + 收藏⭐」就是我创作的最大动力!谢谢大家的支持,我们下文见!