微信小程序订阅功能实现
基础环境
- jdk8
- springboot2.7
- 微信开发者工具
前端用户订阅实现
根据官方给出的wx.requestSubscribeMessage接口文档,不难写出以下代码:
subscribe(){
wx.requestSubscribeMessage({
tmplIds: ['模板id,获取方式见下文'],
success : (res) => {
if(res['模板id,获取方式见下文'] == "accept") {
subscribe(); //订阅成功后要干的事
}
},
})
}
后端发送订阅信息
根据官方文档的描述,服务端通过发送一个https请求将订阅模板发送至用户微信。那么我们面临以下问题:
- springboot中怎么发送http(s)请求
- 官网给的接口需要传递的参数如何获取(处理)
- 发送成功后返回的结果如何处理
前置准备
依赖准备
使用jdk的java.net包发送http请求。太麻烦,还得自己封装工具类- 本文使用第三方工具类发请求🎉️,如下:
//maven引入工具包,使用糊涂工具包发送http请求,官方教程:https://hutool.cn/docs/#/http/Http%E5%AE%A2%E6%88%B7%E7%AB%AF%E5%B7%A5%E5%85%B7%E7%B1%BB-HttpUtil
<!--糊涂工具包-->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.7.5</version>
</dependency>
<!-- lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 阿里JSON解析器 -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.23</version>
</dependency>
微信小程序配置类
/**
* wx配置属性,
*
**/
@Data
@Configuration
@ConfigurationProperties(prefix = "wx")
public class WxConfigProperties {
private String url;
/**
* wx appId
*/
private String appId;
/**
*
*/
private String appSecret;
}
//#小程序配置,在application.yml文件配置
//wx:
// url: https://api.weixin.qq.com/sns/jscode2session
// appId: 你自己的
// appSecret: 你自己的
订阅模板获取
本文测试的模板如下
开始编码
根据小程序官方的教程,发送订阅信息需要如下参数:
请求头JSON字符串的构建
使用构建者模式完成json参数的构建:
/**
* author: xlf
* 构建请求json参数抽象类,根据自己的需求继承,编写实现类
*/
public abstract class AbstractTemplateBuilder {
protected JSONObject product = new JSONObject();
/**
*跳转小程序类型:developer为开发版;trial为体验版;formal为正式版;默认为正式版
*/
public void buildMiniprogramState(){
product.put("miniprogram_state","developer");
}
/**
* 进入小程序查看的语言类型,支持zh_CN(简体中文)、en_US(英文)、zh_HK(繁体中文)、zh_TW(繁体中文),默认为zh_CN
*/
public void buildLang(){
product.put("lang","zh_CN");
}
/**
* 模板id
*/
public abstract void buildTemplateId();
/**
* 订阅的人
*/
public abstract void buildOpenId();
/**
* 跳转的页面
*/
public abstract void buildPage();
/**
* 模板消息
*/
public abstract void buildData();
/**
* 获取模板
* @return
*/
public JSONObject construct(){
buildTemplateId();
buildPage();
buildOpenId();
buildData();
buildMiniprogramState();
buildLang();
return product;
}
/**
* data构建者
* @return
*/
public DataBuilder DataBuilder(){
DataBuilder dataBuilder = new DataBuilder();
return dataBuilder;
}
/**
*
*/
protected class DataBuilder {
private JSONObject data;
public DataBuilder(){
data = new JSONObject();
}
public DataBuilder addParam(String key, String value){
JSONObject jsonObject = new JSONObject();
jsonObject.put("value",value);
data.put(key,jsonObject);
return this;
}
public JSONObject build(){
return data;
}
}
}
/**
* 构建参数具体实现类,照猫画虎,需要根据自己的业务逻辑更改
* author: xlf
*/
public class TalkCommentTemplateBuilderImpl extends AbstractTemplateBuilder {
//配合自己的业务逻辑传递更改实体类
private TalkCommentDTO talkComment;
public TalkCommentTemplateBuilderImpl(TalkCommentDTO talkComment){
super();
this.talkComment = talkComment;
}
/**
* 订阅模板
*/
@Override
public void buildTemplateId() {
product.put("template_id","w6J_8bSEtK_2Tnscws-HWKJ_SxG0ANCQIcOZwK-AsWw");
}
/**
* 订阅的人
*/
@Override
public void buildOpenId() {
product.put("touser",talkComment.getTouser());
}
/**
* 点击订阅消息跳转的页面
*/
@Override
public void buildPage() {
product.put("page","/pages/chatDetail/chatDetail?id="+talkComment.getTalkId());
}
/**
* 模板消息,配合自己的模板构建data数据。
*/
@Override
public void buildData() {
JSONObject data = DataBuilder()
.addParam("thing1", talkComment.getTalkContent()) //帖子内容
.addParam("thing4", talkComment.getNickName()) //评论用户
.addParam("thing2", talkComment.getContent()) //评论内容
.addParam("time3", talkComment.getData()) //评论的时间
.build();
product.put("data",data);
}
}
access_token的获取方法
获取接口调用凭据,获取方法:官方入口。
照猫画虎,根据官方的文档写代码
/*
获取token的url
*/
private String tokenUrl = "https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=";
/**
* 获取AccessToken
* @return
*/
private String getAccessToken() {
String url = tokenUrl + wxConfigProperties.getAppId() + "&secret=" + wxConfigProperties.getAppSecret();
// access_token 的有效期通过返回的 expires_in 来传达,目前是7200秒之内的值,中控服务器需要根据这个有效时间提前去刷新。
// 在刷新过程中,中控服务器可对外继续输出的老 access_token,此时公众平台后台会保证在5分钟内,新老 access_token 都可用,这保证了第三方业务的平滑过渡
return redisCache.queryWithMutexAndBuild(ASSESS_TOKEN_CACHE, (k) -> redisCache.getCacheObject(k), (k) -> {
AccessToken accessToken = JSON.parseObject(HttpUtil.get(url), AccessToken.class);
log.info("{} + accessToken:{}",url,accessToken);
redisCache.setCacheObject(k, accessToken.getAccess_token(), accessToken.getExpires_in(), TimeUnit.SECONDS);
return accessToken.getAccess_token();
});
}
/**
* 获取AccessToken
* @return
*/
private String getAccessToken() {
String url = tokenUrl + wxConfigProperties.getAppId() + "&secret=" + wxConfigProperties.getAppSecret();
AccessToken accessToken = JSON.parseObject(HttpUtil.get(url), AccessToken.class);
log.info("{} + accessToken:{}",url,accessToken);
return accessToken.getAccess_token();
}
//返回结果的实体类dto
@Data
public class AccessToken implements Serializable {
private String access_token;
private int expires_in;
}
access_token的获取优化
官方给的建议:
翻译一下就是:不用每次订阅的时候都去获取一次access_token,access_token的有效期是2个小时,所以可以复用之前的token。
我做的优化就是使用redis储存获取的token,然后每次获取token的时候先看redis是否存在缓存,如果存在就返回,如果不存在就重新发请求获取并且把它丢到redis缓存中。整个过程要考虑缓存穿透,缓存击穿,缓存雪崩等问题。以下代码需要引入redisTemplate和redission分布式锁,适合有一定基础的同学
/**
* 优化后的代码获取AccessToken
* @return
*/
private String getAccessToken() {
String url = tokenUrl + wxConfigProperties.getAppId() + "&secret=" + wxConfigProperties.getAppSecret();
// access_token 的有效期通过返回的 expires_in 来传达,目前是7200秒之内的值,中控服务器需要根据这个有效时间提前去刷新。
// 在刷新过程中,中控服务器可对外继续输出的老 access_token,此时公众平台后台会保证在5分钟内,新老 access_token 都可用,这保证了第三方业务的平滑过渡
return redisCache.queryWithMutexAndBuild(ASSESS_TOKEN_CACHE, (k) -> redisCache.getCacheObject(k), (k) -> {
AccessToken accessToken = JSON.parseObject(HttpUtil.get(url), AccessToken.class);
log.info("{} + accessToken:{}",url,accessToken);
redisCache.setCacheObject(k, accessToken.getAccess_token(), accessToken.getExpires_in(), TimeUnit.SECONDS);
return accessToken.getAccess_token();
});
}
/**
*author: xlf
* 从缓存拿数据,没有就通过obj重构缓存,返回R
* @param key 缓存key
* @param queryByRedis 从redis查询数据
* @param buildRedis 构建缓存
* @param <R>
* @return
*/
public <R> R queryWithMutexAndBuild(
String key,Function<String, R> queryByRedis, Function<String,R> buildRedis)
{
//缓存存在
if(exist(key)){
return queryByRedis.apply(key);
}
//缓存不存在
String lockKey = LOCK_KEY + key;
RLock lock = redissonClient.getLock(lockKey);
//加锁防止大量数据打到数据库 缓存击穿
lock.lock();// 加锁,拿不到锁会一直自旋等待
//redisson加的锁默认过期时长是30s,没过10s续一次
//redisson不会死锁,因为有过期时间
try { // 获取锁成功,重构缓存
if (!exist(key)) { // double check
//重构缓存并且返回
return buildRedis.apply(key);
}
return queryByRedis.apply(key);
} catch (Exception e) {
throw new RuntimeException(e);
}finally {
// 7.释放锁
lock.unlock();
}
}
/**
* 判断某个key是否存在
* @param key
* @return
*/
public boolean exist(String key){
Boolean flag = false;
try{
flag = redisTemplate.hasKey(key);
return flag;
}catch(Exception e){
e.printStackTrace();
}
return flag;
}
订阅模板的发送
/*
发订阅内容的url
*/
private String url = "https://api.weixin.qq.com/cgi-bin/message/subscribe/send?access_token=";
/**
* 推送
* @param access_token token,传递第二步获取的access_token
* @param params 请求体参数,传递第一步构建出来的json参数
*/
public void sead(String access_token,JSONObject params){
//使用糊涂工具包发送http请求
String res = HttpUtil.post(url + access_token,params.toJSONString());
SendRes sendRes = JSON.parseObject(res, SendRes.class);
log.info("成功发送:{}",sendRes);
switch (sendRes.errcode){
case 40001:
throw new RuntimeException("获取 access_token 时 AppSecret 错误,或者 access_token 无效。请开发者认真比对 AppSecret 的正确性,或查看是否正在为恰当的公众号调用接口");
case 40003:
throw new RuntimeException("不合法的 OpenID ,请开发者确认 OpenID (该用户)是否已关注公众号,或是否是其他公众号的 OpenID");
case 40014:
throw new RuntimeException("不合法的 access_token ,请开发者认真比对 access_token 的有效性(如是否过期),或查看是否正在为恰当的公众号调用接口");
case 40037:
throw new RuntimeException("不合法的 template_id");
}
}
/**
* 返回结果
*/
@Data
private class SendRes implements Serializable{
private Integer errcode;
private String errmsg;
}
并发量高时的建议
使用线程池或引入消息队列(如rabbitmq)进行异步处理。
access_token的获取应当参照优化后的代码进行处理。