如果企业或者组织同时在微信平台有公众号和小程序,就可以进行小程序和公众号的联动,例如以下场景:
- 用户关注公众号后推送小程序的入口链接,点击直接跳转至小程序。
- 用户在小程序完成特定操作后,通过公众号向用户发送消息通知。


接下来开始实现公众号和小程序类似以上场景的联动效果。
### 准备工作
- 公众号关联小程序
登录公众号后台,在 "广告与服务" - "小程序管理" - "添加" 关联小程序。
另外登录小程序后台,也可以在 "设置" - "关联设置" - "关联的公众号" 可以查看刚关联的公众号。
**关联之后,公众号可在自定义菜单、模板消息、客服消息等功能中使用小程序。**
- 微信开放平台绑定公众号和小程序
登录开放平台,在 "管理中心" 绑定公众号和小程序。
**绑定之后可以通过 UnionID 区分是否是同一个用户**,参见 [UnionID 机制](https://developers.weixin.qq.com/miniprogram/dev/framework/open-ability/union-id.html) 。
- 公众号白名单和服务器配置
登录公众号后台,在 "设置与开发" - "基本配置" 中完成 IP 白名单 和 [服务器配置](https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html)。IP 白名单填写后端服务器 IP 即可,服务器配置在开发阶段可以选择明文模式,URL 需要填写已经在后端服务器可访问的接口地址,并且需要在后端 **验证消息来自微信服务器** 。
### 验证消息来自微信服务器
参考官方文档公众号开发 [接入指南](https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html) ,需要在公众号服务器配置填写 URL 对应得接口来验证消息是否来自微信服务器。
```java
@RestController
@Slf4j
public class GzhTokenServerCheckController {
/**
* 服务验证
* @param signature 微信加密签名,signature结合了开发者填写的token参数和请求中的timestamp参数、nonce参数
* @param timestamp 时间戳
* @param nonce 随机数
* @param echostr 随机字符串
* @return
*/
@GetMapping("/wx/gzh/serverCheck")
public String serverCheck(String signature, String timestamp, String nonce, String echostr) {
log.info("接收到来自微信服务器的认证消息:[{}, {}, {}, {}]", signature, timestamp, nonce, echostr);
if (StringUtils.isAnyBlank(signature, timestamp, nonce, echostr)) {
throw new IllegalArgumentException("请求参数非法,请核实!");
}
String token = "公众平台服务器配置填写的token"; // 公众平台服务器配置填写的 token
String signatureCheck = getSHA1(token, timestamp, nonce);
log.info("加密后的signatureCheck={}", signatureCheck);
return signatureCheck.equals(signature) ? echostr : "";
}
/**
* 用SHA1算法验证 token
* 1) 将token、timestamp、nonce三个参数进行字典序排序
* 2)将三个参数字符串拼接成一个字符串进行sha1加密
* 3)开发者获得加密后的字符串可与signature对比,标识该请求来源于微信
*
* @param token 票据
* @param timestamp 时间戳
* @param nonce 随机字符串
* @return 安全签名
*/
public static String getSHA1(String token, String timestamp, String nonce) {
try {
String[] array = new String[]{token, timestamp, nonce};
StringBuilder sb = new StringBuilder();
// 字符串排序
Arrays.sort(array);
for (int i = 0; i < 3; i++) {
sb.append(array[i]);
}
// SHA1签名生成
MessageDigest md = MessageDigest.getInstance("SHA-1");
md.update(sb.toString().getBytes());
byte[] digest = md.digest();
StringBuilder hexStr = new StringBuilder();
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) {
log.error("验证 token 异常", e);
return "";
}
}
}
```
### 关注公众号后回复消息
#### 基础消息能力
参考微信公众号基础消息能力中 [接收事件推送](https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Receiving_event_pushes.html) 来实现。
接收微信服务器事件推送接口 URL 与验证消息 URL 是相同的,都是在公众号后台服务器配置的那个 URL,需要区分的是接口请求方式,处理事件推送的接口为 POST 请求,验证消息为 GET 请求。
微信服务器请求报文为 XML 格式的,可以将请求报文解析为 Map 方便后续处理。
```java
@Slf4j
@RestController
public class GzhMessageEventController {
@Autowired
private GzhMessageEventAssign gzhMessageEventAssign;
@PostMapping("/wx/gzh/serverCheck")
public String receiveMessageAndEvent(HttpServletRequest request) {
Map<String, String> map = parseRequestParam(request);
if (map.isEmpty()) return "success";
log.info("GZH server check request param: {}", map);
String response = gzhMessageEventAssign.process(map);
log.info("GZH server check response: {}", response);
return response;
}
/**
* 解析请求参数
* @param request HttpServletRequest
* @return Map
*/
public Map<String,String> parseRequestParam(HttpServletRequest request) {
Map<String, String> map = new HashMap<>();
try (InputStream inputStream = request.getInputStream()) {
SAXReader reader = new SAXReader();
Document document = reader.read(inputStream);
Element root = document.getRootElement();
List<Element> elementList = root.elements();
for (Element e : elementList) {
map.put(e.getName(), e.getText());
}
} catch (Exception e) {
log.error("Parse Request to xml error:", e);
}
return map;
}
}
```
处理公众号推送的消息和事件,报文格式也是类似的。推送的消息目前分为文本消息、图片消息、语音消息、视频消息、小视频消息、音乐消息;推送的事件分为关注和取消关注事件、扫描带参数二维码事件、自定义菜单事件等。
定义处理消息和事件的接口(示例,具体细节需要丰富):
```java
public interface GzhMessageEventProcess {
// 处理消息或者事件
default void process(Map<String, String> param){}
// 生成回复内容的报文
default Map reply(){
return null;
}
}
```
然后根据不同消息类型或者事件创建不同的实现类,例如:
- GzhTextMessageProcessor 处理文本消息
- GzhSubscribeEventProcessor 处理订阅消息
这样就可以根据不同的请求类型或者返回类型分发给不同实现类去处理,GzhMessageEventAssign 类可以看做是一个任务分发器:
```java
@Slf4j
@Service
public class GzhMessageEventAssign {
@Autowired
private GzhMessageEventTextProcessor textProcessor;
@Autowired
private GzhMessageEventProcessor eventProcessor;
@Autowired
private GzhMessageEventImageProcessor imageProcessor;
@Autowired
private GzhMessageEventVoiceProcessor voiceProcessor;
@Autowired
private GzhMessageEventVideoProcessor videoProcessor;
@Autowired
private GzhMessageEventMusicProcessor musicProcessor;
/**
* 分发处理
*/
public String process(Map<String, String> param) {
String magType = param.get("MsgType");
try {
if (MsgType.TEXT.getValue().equals(magType)) {
textProcessor.process(param);
} else if (MsgType.EVENT.getValue().equals(magType)) {
eventProcessor.process(param);
}
return reply(param);
} catch (Exception e) {
log.error("处理公众号消息或事件异常", e);
}
return "success";
}
/**
* 回复消息(被动回复消息,即时)
* 目前微信只支持 [text\image\voice\video\music\news] 6 种即时回复
* @param param map
* @return string
*/
public String reply(Map<String, String> param) {
String type = param.get("MsgType");
Map map;
if (MsgType.TEXT.getValue().equals(type)) {
map = textProcessor.reply();
} else if (MsgType.IMAGE.getValue().equals(type)) {
map = imageProcessor.reply();
} else if (MsgType.VOICE.getValue().equals(type)) {
map = voiceProcessor.reply();
} else if (MsgType.VIDEO.getValue().equals(type)) {
map = videoProcessor.reply();
} else if (MsgType.MUSIC.getValue().equals(type)) {
map = musicProcessor.reply();
} else if (MsgType.NEWS.getValue().equals(type)) {
map = articlesProcessor.reply();
} else {
return "success";
}
String openId = param.get("FromUserName");
map.put("FromUserName", param.get("ToUserName")); // 开发者微信号
map.put("ToUserName", openId); // 接收方帐号(收到的OpenID)
map.put("CreateTime", System.currentTimeMillis()); // 消息创建时间
return XmlUtil.mapToXML(map);
}
}
```
贴一下 XmlUtil 代码
```java
public class XmlUtil {
public static String mapToXML(Map map) {
StringBuilder sb = new StringBuilder();
sb.append("<xml>");
mapToXML2(map, sb);
sb.append("</xml>");
return sb.toString();
}
private static void mapToXML2(Map map, StringBuilder sb) {
Set set = map.entrySet();
for (Iterator it = set.iterator(); it.hasNext();) {
Map.Entry entry = (Map.Entry) it.next();
String key = (String) entry.getKey();
Object value = entry.getValue();
if (null == value){
value = "";
}
if (value.getClass().getName().equals("java.util.ArrayList")) {
ArrayList list = (ArrayList) map.get(key);
sb.append("<" + key + ">");
for (Object o : list) {
mapToXML2((HashMap)o, sb);
}
sb.append("</" + key + ">");
} else {
if (value instanceof HashMap) {
sb.append("<" + key + ">");
mapToXML2((HashMap) value, sb);
sb.append("</" + key + ">");
} else {
sb.append("<" + key + "><![CDATA[" + value + "]]></" + key + ">");
}
}
}
}
}
```
梳理一下实现关注公众号回复消息的流程:
- 解析请求参数
- 任务分发器判断类型,如果是订阅事件,则分给处理订阅事件的类去处理
- 任务分发器判断响应类型,根据响应类型和组装返回报文
#### 客服消息
公众号回复消息的类型目前只支持 文本、图片、语音、视频、音乐、图文 ([被动回复用户消息](https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Passive_user_reply_message.html)),那么文章开头如果需要回复一个小程序卡片是无法做到的,客服消息可以实现。下面只描述思路,需要完善细节才能使用。
接口新增处理客服消息的方法:
```java
public interface GzhMessageEventProcess {
default void process(Map<String, String> param){}
default Map reply(){
return null;
}
// 处理客服消息(入参细节需要丰富)
default Map customMessage(){
return null;
}
}
```
新增 GzhMiniProgramPageProcessor 实现类,用来组装返回 **[小程序卡片](https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Service_Center_messages.html)** 报文,报文示例
```json
{
"touser":"OPENID",
"msgtype":"miniprogrampage",
"miniprogrampage":
{
"title":"title",
"appid":"appid",
"pagepath":"pagepath",
"thumb_media_id":"thumb_media_id"
}
}
```
在不同场景下 [客服消息](https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Service_Center_messages.html) 是有额度的,具体规则以官方为准,当前(2021-11-26)关注公众号后 1 分钟内可以向用户发送 3 条消息。
对分发器 GzhMessageEventAssign 做一下小改动:
```java
public String process(Map<String, String> param) {
String magType = param.get("MsgType");
try {
if (MsgType.TEXT.getValue().equals(magType)) {
textProcessor.process(param);
} else if (MsgType.EVENT.getValue().equals(magType)) {
eventProcessor.process(param);
}
// 发送客服消息
sendCustomMessage(param);
return reply(param);
} catch (Exception e) {
log.error("处理公众号消息或事件异常", e);
}
return "success";
}
/**
* 发送客服消息
* @param param
*/
private void sendCustomMessage(Map<String, String> param) {
// 可以发送多条客服消息
// for (int i = 0; ;) {}
try {
// 组装小程序卡片报文
Map map = miniProgramPageProcessor.customMessage(param);
// 通过 RestTemplate 请求微信发送客服消息接口
restTemplate.post("https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=ACCESS_TOKEN", map);
} catch (Exception e){
}
}
```
客服消息与被动回复消息是不冲突的,所以可以同时使用,通过以上改动,就可以在用户关注公众号之后回复小程序卡片了。
### 向公众号发送通知消息
用户在小程序有一笔未支付订单、某个活动取消、优惠券快到期了,类似这种消息如何通知用户,小程序中有订阅消息功能可以做到,但是前提是用户需要每次手动做一次订阅,体验不是很好。在有公众号(服务号)的前提下,完全可以使用公众号模板消息来做。
准备阶段提到 UnionID 在公众号和小程序是区分用户唯一标识。所以思路就是:
- 小程序中通过 OpenID 找到 UnionID
- 通过 UnionID 找到公众号的 OpenID
- 向公众号的用户发送模板消息
#### 小程序获取 UnionID
小程序中调用接口 [wx.login](https://developers.weixin.qq.com/miniprogram/dev/api/open-api/login/wx.login.html) 获取登录凭证(code),然后通过后台服务器调用 [auth.code2Session](https://developers.weixin.qq.com/miniprogram/dev/api-backend/open-api/login/auth.code2Session.html) ,使用登录凭证获取 OpenID 和 UnionID。
记录下 OpenID 和 UnionID 的对应关系,例如维护 wx_xcx_user 表:
```sql
CREATE TABLE `wx_xcx_user` (
`id` int NOT NULL AUTO_INCREMENT,
`open_id` varchar(50) NOT NULL COMMENT '微信小程序登录后获取的 OPEN_ID',
`union_id` varchar(100) DEFAULT NULL,
`create_time` datetime DEFAULT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `uk_openid` (`open_id`),
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
```
#### 公众号获取 UnionID
前面在公众号基础消息能力部分说到,如果在公众号后台完成服务器配置,就可以处理公众号推送的消息和事件。
所以可以在处理 [关注公众号事件](https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Receiving_event_pushes.html) 时,获取公众号 OpenID 和 UnionID。
**微信推送关注事件的请求报文:**
```xml
<xml>
<ToUserName><![CDATA[toUser]]></ToUserName>
<FromUserName><![CDATA[FromUser]]></FromUserName>
<CreateTime>123456789</CreateTime>
<MsgType><![CDATA[event]]></MsgType>
<Event><![CDATA[subscribe]]></Event>
</xml>
```
FromUserName :用户 OpenID
MsgType :消息类型,值为 event
Event:事件类型,值为 subscribe 表示关注(订阅)
**获取用户基本信息(包含 UnionID),参考 [文档](https://developers.weixin.qq.com/doc/offiaccount/User_Management/Get_users_basic_information_UnionID.html#UinonId) :**
接口调用请求方式: GET https://api.weixin.qq.com/cgi-bin/user/info?access_token=ACCESS_TOKEN&openid=OPENID&lang=zh_CN
微信会返回下述JSON数据包:
```json
{
"subscribe": 1,
"openid": "o6_bmjrPTlm6_2sgVt7hMZOPfL2M",
"nickname": "Band",
"language": "zh_CN",
"headimgurl":"http://thirdwx.qlogo.cn/mmopen/g3MonUZtNHkdmzicIlibx6iaFqAc56vxLSUfpb6n5WKSYVY0ChQKkiaJSgQ1dZuTOgvLLrhJbERQQ4eMsv84eavHiaiceqxibJxCfHe/0",
"subscribe_time": 1382694957,
"unionid": "o6_bmasdasdsad6_2sgVt7hMZOPfL",
"remark": "",
"groupid": 0,
"tagid_list":[128,2],
"subscribe_scene": "ADD_SCENE_QR_CODE",
"qr_scene": 98765,
"qr_scene_str": ""
}
```
**获取调用接口凭证 ACCESS_TOKEN,参考 [文档](https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html) :**
重点需要说明的是:
- ACCESS_TOKEN 是有失效时间的,应该定期刷新
- 应该使用中控服务器统一去刷新 ACCESS_TOKEN,使用时统一向中控服务器获取
- 虽然文档上说有效期为 2 小时,刷新过程中保证 5 分钟内新老 ACCESS_TOKEN 都可以使用,实际测试,当有效期超过 20 分钟左右时后续使用会出现错误码(开发者社区好多人遇到这个问题,暂时还没有解决),避免出现影响正常使用,刷新间隔时间短一些。
记录下 OpenID 和 UnionID 的对应关系,例如维护 wx_gzh_user 表:
```sql
CREATE TABLE `wx_gzh_user` (
`id` int NOT NULL AUTO_INCREMENT,
`open_id` varchar(50) NOT NULL,
`union_id` varchar(100) DEFAULT NULL,
`create_time` datetime NOT NULL,
`subscribe` int DEFAULT NULL COMMENT '订阅标志 0 未关注',
`nikename` varchar(50) DEFAULT NULL COMMENT '用户昵称',
PRIMARY KEY (`id`),
UNIQUE KEY `uk_openid` (`open_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
```
#### 公众号模板消息
在公众号后台 "广告与服务" - "模板消息" 中从模板库添加一个合适的模板,点击查看模板详情,例如:

接下来参考 [发送模板消息](https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Template_Message_Interface.html#5) ,在后台编写发送报文即可,例如组装活动取消的参数:
```java
/**
* 取消活动的模板消息
* @param openId 公众号 openid
* @param detail 活动详情
* @param dateTime 活动时间
* @param reason 取消原因
* @return Map
*/
public Map<String, Object> getActivityCancelParams(String openId, String detail, String dateTime, String reason) {
Map<String, Object> map = new HashMap<>();
map.put("touser", openId);
map.put("template_id", "模板ID");
map.put("url", "http://weixin.qq.com/download"); //模板消息跳转到小程序
map.put("miniprogram", new HashMap<String, String>(2){{
put("appid", "小程序的AppID");
put("pagepath", "pages/baby/detail/detail"); //点击后跳转到小程序的页面路径
}});
map.put("data", new HashMap<String, Object>(4){{
put("first", new HashMap<String, String>(2){{
put("value", "");
}});
put("keyword1", new HashMap<String, String>(2){{
put("value", detail);
}});
put("keyword2", new HashMap<String, String>(2){{
put("value", dateTime);
put("color", "#173177");
}});
put("keyword3", new HashMap<String, String>(2){{
put("value", reason);
}});
put("remark", new HashMap<String, String>(2){{
put("value", "点击进入小程序查看详细信息");
}});
}});
return map;
}
```
**活动取消通知的开发流程**:
1、用户在小程序预约活动,由于天气原因活动取消。
2、后台查询预约用户的小程序 OpenID,并通过 wx_xcx_user 表查询 UnionID,再通过 wx_gzh_user 表查询到公众号用户的 OpenID。
3、调用组装活动取消模板消息参数的方法,调用微信发送模板消息的接口。
4、关于公众号的用户收到通知消息。

微信小程序向公众号推送通知