微信小程序生态13-微信公众号自定义菜单配置
序
微信公众号分为订阅号和服务号两种,虽然二者很大的不同,但是这两种公众号的底部却是差不多的:都有菜单栏,而且这些底部菜单也都是自定义配置的。
如CSDN的官方公众号的底部就有精彩栏目、新程序员、CSDN等菜单可供使用:
那这些菜单是如何生成的呢?微信以配置方式的不同把它分为了两类:自定义菜单、个性化菜单。
自定义菜单
微信公众号自定义菜单栏的配置需要登录『微信公众平台』,依次选择 内容与互动—>自定义菜单 ,如下:
在『菜单信息栏』中我们有3种类型的菜单可以选择:发送消息、跳转网页、跳转小程序。
菜单类型
1. 发送消息
这种菜单点击后,公众号会自动弹出一条消息,比如华为运动健康的『最新排行』:
2. 跳转网页
这种菜单点击后,当前会离开公众号,跳转到指定的页面,这个多用在H5小程序上。
3. 跳转小程序
这种菜单点击后,当前会离开公众号,跳转到指定的小程序的指定页面。
小结一下
虽然提供了这三种菜单栏,但灵活性上还是不够,想要修改必须要登录『微信公众平台』,做不到自动更新。
举个例子,有个菜单名为『最新资讯』,点击即可打开最新的那一条资讯或文章,如果是自定义菜单,那么就需要运营自己不断地去手动更新跳转的链接,这样运营就很辛苦了。
为了方便运营,官方还推出了个性化菜单,所谓个性化菜单就是微信官方推出了可以更改自定义菜单的接口,开发者调用该接口就可以达到菜单的配置。
这里要注意一下,启用个性化菜单后,自定义菜单栏就会变得不可用了,这两种方式只能选其一。
个性化菜单
第一步、开启服务器配置
在设置与开发—>基础配置页找到『服务器配置』
注意:这个界面的信息我们都用的上!
开发者ID(AppID)
这个是当前公众号的唯一标识,生成后就不会变化
开发者密码(AppSecret)
这个是动态生成的一串密钥,非常重要,且不能泄露,如有需要请生成后妥善保存。密钥一般可以重复生成。
IP白名单
只有在这里配置过的IP地址,才可以调用微信的服务,之前有篇文章提到过:微信小程序生态7-微信公众号设置IP白名单
服务器地址(URL)
这个是『微信公众号』回调我们的地址,必须以http://或https://开头,分别支持80端口和443端口。也就是说这个不能配置IP,得是域名才行。
例如:https://xxx.com/handleWxCheckSignature
令牌(Token)
自己设置,越复杂越好,不要太简单
消息加解密密钥(EncodingAESKey)
可以随机生成,这个密钥用于解析推送过来的值。
消息加解密方式
有三种,明文模式(明文模式下,不使用消息体加解密功能,安全系数较低)、兼容模式(兼容模式下,明文、密文将共存,方便开发者调试和维护)、安全模式(推荐,安全模式下,消息包为纯密文,需要开发者加密和解密,安全系数高)
注意:这里的配置一旦启用,自定义菜单就会失效并停用;而且如果想要调用服务号的各种功能就必须要启用该配置,那么自定义菜单就一定会失效 -.-!
第二步、『微信公众号』签名校验接口实现
第一步中有一个服务器地址(URL),这个地址有一个作用:校验服务器是安全且可用的。即微信那边需要我们保证这个接口微信官方可以调通并且正确返回他们想要的值,如果调不通的话则无法设置自定义菜单。
这里我是用的是SpringBoot应用作为服务
1. 引入小程序开发包依赖
<!-- weixin-java-mp SDK框架-->
<dependency>
<groupId>com.github.binarywang</groupId>
<artifactId>weixin-java-mp</artifactId>
<version>3.6.0</version>
</dependency>
2. 编写微信内容解码类
由于消息加解密方式我们选择的是安全模式,微信传给我们的内容都是加过密的,所以我们需要写一个解码类。
WXBizMsgCrypt.java
import lombok.extern.slf4j.Slf4j;
import me.chanjar.weixin.common.util.crypto.PKCS7Encoder;
import org.apache.commons.codec.binary.Base64;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import org.w3c.dom.NodeList;
import org.xml.sax.InputSource;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.StringReader;
import java.nio.charset.Charset;
import java.security.MessageDigest;
import java.util.Arrays;
/**
* 提供接收和推送给公众平台消息的加解密接口(UTF8编码的字符串).
* 第三方回复加密消息给公众平台,第三方收到公众平台发送的消息,验证消息的安全性,并对消息进行解密。
*/
@Slf4j
public class WXBizMsgCrypt {
static Charset CHARSET = Charset.forName("utf-8");
Base64 base64 = new Base64();
byte[] aesKey;
String token;
String appId;
/**
* 构造函数
*
* @param token 公众平台上,开发者设置的token
* @param encodingAesKey 公众平台上,开发者设置的EncodingAESKey
* @param appId 公众平台appid
* @throws WxAesException 执行失败,请查看该异常的错误码和具体的错误信息
*/
public WXBizMsgCrypt(String token, String encodingAesKey, String appId) throws WxAesException {
if (encodingAesKey.length() != 43) {
throw new WxAesException(WxAesException.IllegalAesKey);
}
this.token = token;
this.appId = appId;
aesKey = Base64.decodeBase64(encodingAesKey + "=");
}
// 还原4个字节的网络字节序
int recoverNetworkBytesOrder(byte[] orderBytes) {
int sourceNumber = 0;
for (int i = 0; i < 4; i++) {
sourceNumber <<= 8;
sourceNumber |= orderBytes[i] & 0xff;
}
return sourceNumber;
}
/**
* 对密文进行解密.
*
* @param text 需要解密的密文
* @return 解密得到的明文
* @throws WxAesException aes解密失败
*/
String decrypt(String text) throws WxAesException {
byte[] original;
try {
// 设置解密模式为AES的CBC模式
Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
SecretKeySpec key_spec = new SecretKeySpec(aesKey, "AES");
IvParameterSpec iv = new IvParameterSpec(Arrays.copyOfRange(aesKey, 0, 16));
cipher.init(Cipher.DECRYPT_MODE, key_spec, iv);
// 使用BASE64对密文进行解码
byte[] encrypted = Base64.decodeBase64(text);
// 解密
original = cipher.doFinal(encrypted);
} catch (Exception e) {
e.printStackTrace();
throw new WxAesException(WxAesException.DecryptAESError);
}
String xmlContent, from_appid;
try {
// 去除补位字符
byte[] bytes = PKCS7Encoder.decode(original);
// 分离16位随机字符串,网络字节序和AppId
byte[] networkOrder = Arrays.copyOfRange(bytes, 16, 20);
int xmlLength = recoverNetworkBytesOrder(networkOrder);
xmlContent = new String(Arrays.copyOfRange(bytes, 20, 20 + xmlLength), CHARSET);
from_appid =
new String(Arrays.copyOfRange(bytes, 20 + xmlLength, bytes.length), CHARSET);
} catch (Exception e) {
e.printStackTrace();
throw new WxAesException(WxAesException.IllegalBuffer);
}
// appid不相同的情况
if (!from_appid.equals(appId)) {
throw new WxAesException(WxAesException.ValidateSignatureError);
}
return xmlContent;
}
/**
* * 检验消息的真实性,并且获取解密后的明文.
* <ol>
* <li>利用收到的密文生成安全签名,进行签名验证</li>
* <li>若验证通过,则提取xml中的加密消息</li>
* <li>对消息进行解密</li>
* </ol>
*
* @param msgSignature 签名串,对应URL参数的msg_signature
* @param timeStamp 时间戳,对应URL参数的timestamp
* @param nonce 随机串,对应URL参数的nonce
* @param postData 密文,对应POST请求的数据
* @return 解密后的原文
* @throws WxAesException 执行失败,请查看该异常的错误码和具体的错误信息
*/
public String decryptMsg(String msgSignature, String timeStamp, String nonce, String postData)
throws WxAesException {
// 密钥,公众账号的app secret
// 提取密文
Object[] encrypt = extract(postData);
// 验证安全签名
String signature = getSHA1(token, timeStamp, nonce, encrypt[1].toString());
// 和URL中的签名比较是否相等
// System.out.println("第三方收到URL中的签名:" + msg_sign);
// System.out.println("第三方校验签名:" + signature);
if (!signature.equals(msgSignature)) {
throw new WxAesException(WxAesException.ValidateSignatureError);
}
// 解密
String result = decrypt(encrypt[1].toString());
return result;
}
/**
* 提取出xml数据包中的加密消息
*
* @param xmltext 待提取的xml字符串
* @return 提取出的加密消息字符串
* @throws WxAesException
*/
public static Object[] extract(String xmltext) throws WxAesException {
Object[] result = new Object[3];
try {
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
DocumentBuilder db = dbf.newDocumentBuilder();
StringReader sr = new StringReader(xmltext);
InputSource is = new InputSource(sr);
Document document = db.parse(is);
Element root = document.getDocumentElement();
NodeList nodelist1 = root.getElementsByTagName("Encrypt");
NodeList nodelist2 = root.getElementsByTagName("ToUserName");
result[0] = 0;
result[1] = nodelist1.item(0).getTextContent();
//注意这里,获取ticket中的xml里面没有ToUserName这个元素,官网原示例代码在这里会报空
//空指针,所以需要处理一下
if (nodelist2 != null) {
if (nodelist2.item(0) != null) {
result[2] = nodelist2.item(0).getTextContent();
}
}
return result;
} catch (Exception e) {
e.printStackTrace();
throw new WxAesException(WxAesException.ParseXmlError);
}
}
/**
* 用SHA1算法生成安全签名
*
* @param token 票据
* @param timestamp 时间戳
* @param nonce 随机字符串
* @param encrypt 密文
* @return 安全签名
* @throws WxAesException
*/
public static String getSHA1(String token, String timestamp, String nonce, String encrypt)
throws WxAesException {
try {
String[] array = new String[]{token, timestamp, nonce, encrypt};
StringBuffer sb = new StringBuffer();
// 字符串排序
Arrays.sort(array);
for (int i = 0; i < 4; i++) {
sb.append(array[i]);
}
String str = sb.toString();
// SHA1签名生成
MessageDigest md = MessageDigest.getInstance("SHA-1");
md.update(str.getBytes());
byte[] digest = md.digest();
StringBuffer hexstr = new StringBuffer();
String shaHex = "";
for (int i = 0; i < digest.length; i++) {
shaHex = Integer.toHexString(digest[i] & 0xFF);
if (shaHex.length() < 2) {
hexstr.append(0);
}
hexstr.append(shaHex);
}
return hexstr.toString();
} catch (Exception e) {
throw new WxAesException(WxAesException.ComputeSignatureError);
}
}
/**
* 校验签名
*
* @param signature 签名
* @param timestamp 时间戳
* @param nonce 随机数
* @return 布尔值
*/
public static boolean checkSignature(String signature, String timestamp, String nonce, String token) {
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.toString().getBytes());
checkText = byteToStr(digest);
} catch (Exception e) {
log.error("解码发生异常", e);
}
}
//将加密后的字符串与signature进行对比
return checkText != null ? checkText.equals(signature.toUpperCase()) : false;
}
/**
* 将字节数组转化我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;
}
}
3. 回调接口代码
这里有一个gzhToken,这个token也就是上面配置中的令牌(Token)。
@GetMapping(value = "/handleWxCheckSignature")
@ResponseBody
public void handleWxCheckSignature(HttpServletRequest request, HttpServletResponse response) {
//微信加密签名
String signature = request.getParameter("signature");
//时间戳
String timestamp = request.getParameter("timestamp");
//随机数
String nonce = request.getParameter("nonce");
//随机字符串
String echostr = request.getParameter("echostr");
//接入验证
if (WXBizMsgCrypt.checkSignature(signature, timestamp, nonce, gzhToken)) {
log.info("微信公众号校验完成echostr:[{}]", echostr);
try {
response.getWriter().print(SecurityUtil.escapeHtml(echostr));
} catch (IOException e) {
log.error("输出返回值异常", e);
}
return;
}
throw new RuntimeException("解析签名发生异常");
}
第三步、『微信公众号』获取accessToken
这一步想必只要对接过开放平台的小伙伴都清楚是啥,我就不赘述了,调用获取代码如下:
@Override
public String getGzhAccessToken(String appId,String appSecret) {
String url = String.format("https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=%s&secret=%s", appId, appSecret);
ResponseEntity<String> result = restTemplate.getForEntity(url, String.class);
log.info("创建公众号AccessToken:{}", result.getBody());
if (StringUtils.isEmpty(result.toString())) {
return null;
} else {
return JSONObject.parseObject(result.getBody()).getString("access_token");
}
}
第四步、构建菜单栏数据
官方规定数据格式如下,详见官方文档:创建菜单
简单解释一下:DefaultMenu是一个保底的菜单,如果其他配置都失效了,用这个;菜单栏对于安卓端和IOS端是单独配置的,需要配置两份。
{
"DefaultMenu": {
"button": [
{
"type": "miniprogram",
"name": "首页",
"url": "小程序安全链接,当小程序打不开时跳转到此页",
"appid": "appId",
"pagepath": "pages/Main/pages/Index/index"
}
],
"matchrule": {
"client_platform_type": "1"
}
},
"iosMenu": {
"button": [
{
"name": "菜单栏1",
"sub_button": [
{
"type": "miniprogram",
"name": "小程序",
"url": "小程序安全链接,当小程序打不开时跳转到此页",
"appid": "appId",
"pagepath": "pages/Main/pages/Index/index"
},
{
"type": "view",
"name": "网页",
"url": "网页链接"
}
]
},
{
"name": "菜单栏2",
"type": "miniprogram",
"url": "小程序安全链接,当小程序打不开时跳转到此页",
"appid": "appId",
"pagepath": "pages/Main/pages/Index/index"
}
],
"matchrule": {
"client_platform_type": "1"
}
},
"androidMenu": {
"button": [
{
"name": "菜单栏1",
"sub_button": [
{
"type": "miniprogram",
"name": "小程序",
"url": "小程序安全链接,当小程序打不开时跳转到此页",
"appid": "appId",
"pagepath": "pages/Main/pages/Index/index"
},
{
"type": "view",
"name": "网页",
"url": "网页链接"
}
]
}
],
"matchrule": {
"client_platform_type": "2"
}
}
}
第五步、调用创建菜单接口
按照微信官方的说法,一共有两个接口, https://api.weixin.qq.com/cgi-bin/menu/create 和 https://api.weixin.qq.com/cgi-bin/menu/addconditional
第一个接口是创建默认菜单,也就是DefaultMenu的内容,第一个接口则是个性化菜单,也就是iosMenu和androidMenu
详见官方文档:创建默认菜单、创建个性化菜单
代码如下:
private JSONObject getResult(JSONObject data, String url) {
HttpHeaders headers = new HttpHeaders();
HttpMethod method = HttpMethod.POST;
// 以表单的方式提交
headers.setContentType(MediaType.APPLICATION_JSON_UTF8);
//将请求头部和参数合成一个请求
HttpEntity<JSONObject> requestEntity = new HttpEntity<>(data, headers);
//执行HTTP请求,将返回的结构使用ResultVO类格式化
ResponseEntity<JSONObject> response = restTemplate.exchange(url, method, requestEntity, JSONObject.class);
return response.getBody();
}
//创建默认菜单
public JSONObject addProgramDefaultMenu(String menus, String accessToken) {
String url = "https://api.weixin.qq.com/cgi-bin/menu/create?access_token=" + accessToken;
JSONObject data = JSONObject.parseObject(menus, JSONObject.class);
return getResult(data, url);
}
//创建个性化菜单
public JSONObject addProgramCustomMenu(String menus, String accessToken) {
String url = "https://api.weixin.qq.com/cgi-bin/menu/addconditional?access_token=" + accessToken;
JSONObject data = JSONObject.parseObject(menus, JSONObject.class);
return getResult(data, url);
}
总结
微信公众号的菜单栏很像浏览器网页上的导航栏,但是限于屏幕大小、生态属性,没法做的很花里胡哨,不过微信官方也在尽力帮助运营和开发者把菜单栏做的个性化一点。虽然上面的流程比较长,但只要好好看文档,实现这个功能也不难,核心就是把菜单配置好后,调用接口而已。不过确实有一些坑在:
- IP白名单的配置
- 微信签名校验解码
- 菜单栏数据格式错误等等
希望我这篇文章可以让大家少走点弯路,成功实现自己想要的效果。
微信小程序生态13-微信公众号自定义菜单配置的更多相关文章
- JAVA获取微信小程序openid和获取公众号openid,以及通过openid获取用户信息
一,首先说明下这个微信的openid 为了识别用户,每个用户针对每个公众号会产生一个安全的OpenID,如果需要在多公众号.移动应用之间做用户共通,则需前往微信开放平台,将这些公众号和应用绑定到一个开 ...
- 微信电脑版也能用公众号自定义菜单 微信1.2 for Windows发布
昨日,微信电脑版发布更新,版本为微信1.2 for Windows,最大的特色就是加入了保存聊天记录功能,可以使用公账号菜单,手机上收藏的表情也能在电脑版上发送,可以接收转账消息. 本次微信pc版更新 ...
- 微信小程序一:微信小程序UI组件、开发框架、实用库
作者:NiceCui 本文谢绝转载,如需转载需征得作者本人同意,谢谢. 本文链接:http://www.cnblogs.com/NiceCui/p/8079095.html 内容持续更新,维护中 邮箱 ...
- 微信小程序开发之微信支付
微信支付是小程序开发中很重要的一个环节,下面会结合实战进行分析总结 环境准备 https服务器 微信小程序只支持https请求,因此需要配置https的单向认证服务(请参考 另一篇文章https受信证 ...
- 通过微信公众号API复制公众号自定义菜单同时增加子菜单方法
主要的原因是再不破坏公众号以前的菜单的基础上增加自定义菜单,主要步骤如下: 1.通过微信提供的微信公众平台接口调试工具获取公众号的所有自定义菜单 网址:https://mp.weixin.qq.com ...
- [转]微信小程序之购物车 —— 微信小程序实战商城系列(5)
本文转自:http://blog.csdn.net/michael_ouyang/article/details/70755892 续上一篇的文章:微信小程序之商品属性分类 —— 微信小程序实战商城 ...
- 微信小程序开发01 --- 微信小程序项目结构介绍
一.微信小程序简单介绍: 微信官方介绍微信小程序是一个不需要下载安装就可使用(呵呵,JS代码不用下载吗?展示的UI不用下载吗?)的应用,它实现了应用“触手可及”的梦想,用户扫一扫或搜一下即可打开应用. ...
- 微信小程序唤起其他微信小程序 / 移动应用App唤起小程序
微信小程序唤起其他微信小程序 / 移动应用App唤起小程序 1. 微信小程序唤起微信小程序 小程序唤起其他小程序很简单 先上链接 小程序跳转小程序 Navigator组件 推荐使用 小程序跳转小程序 ...
- 【小程序】微信小程序打开其他小程序(打开同一主体公众号下关联的另一个小程序)
微信小程序打开其他小程序(打开同一公众号下关联的另一个小程序) 注:只有同一(主体)公众号下的关联的小程序之间才可相互跳转 wx.navigateToMiniProgram(OBJECT) wx.n ...
- 微信小程序内链微信公众号的方法
最近接了一个需求,要求在微信小程序内部添加关注微信公众号的方式并给出解决方案,经过几天的翻官网文档,查周边资料,问资深技术员,初步给出两个解决方案: 题外话: 搬砖容易,建设难,搬砖的小伙伴请注明文章 ...
随机推荐
- Oracle 专用模式与共享模式的学习与思考
Oracle 专用模式与共享模式的学习与思考 说明 Oracle数据库中的专用模式和共享模式是两种不同的数据库运行模式,它们在应用场景和权限管理上有所不同. 专用模式(Dedicated Mode): ...
- 常见的docker hub mirror镜像仓库
阿里云(杭州) https://registry.cn-hangzhou.aliyuncs.com 阿里云(上海) https://registry.cn-shanghai.aliyuncs.com ...
- [转帖]ASH、AWR、ADDM区别联系
==================================================================================================== ...
- [转帖]linux磁盘IO读写性能优化
在LINUX系统中,如果有大量读请求,默认的请求队列或许应付不过来,我们可以 动态调整请求队列数来提高效率,默认的请求队列数存放在/sys/block/xvda/queue/nr_requests 文 ...
- Docker与虚拟化技术浅析第一弹之docker与Kubernetes
1 前言 Docker是一个开源的引擎,可以轻松地为任何应用创建一个轻量级的. 可移植的.自给自足的容器.开发者在笔记本电脑上编译测试通过的容器可以批量地在生产环境中部署,包括VMs (虚拟机).ba ...
- 【贪心】AGC018C Coins
Problem Link 现在有 \(X+Y+Z\) 个人,第 \(i\) 个人有三个权值 \(a_i,b_i,c_i\),现在要求依次选出 \(X\) 个人,\(Y\) 个人和 \(Z\) 个人(一 ...
- echarts更改x和y轴的颜色
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <title> ...
- 分享一个项目:`learning_go_plan9_assembly`, 学习 golang plan9 汇编
作者:张富春(ahfuzhang),转载时请注明作者和引用链接,谢谢! cnblogs博客 zhihu Github 公众号:一本正经的瞎扯 近期在学习 golang plan9 汇编,总算基本做到了 ...
- 【JS 逆向百例】如何跟栈调试?某 e 网通 AES 加密分析
关注微信公众号:K哥爬虫,QQ交流群:808574309,持续分享爬虫进阶.JS/安卓逆向等技术干货! 声明 本文章中所有内容仅供学习交流,抓包内容.敏感网址.数据接口均已做脱敏处理,严禁用于商业用途 ...
- 大语言模型的预训练[2]:GPT、GPT2、GPT3、GPT3.5、GPT4相关理论知识和模型实现、模型应用以及各个版本之间的区别详解
大语言模型的预训练[2]:GPT.GPT2.GPT3.GPT3.5.GPT4相关理论知识和模型实现.模型应用以及各个版本之间的区别详解 1.GPT 模型 1.1 GPT 模型简介 在自然语言处理问题中 ...