基础
微信服务器会发送两种类型的消息给开发者服务器。
基于微信公众号订阅号开发的学习(一):基础知识
auth.js
//引入sha1
const sha1 = require("sha1");
//引入config配置模块
const config = require("../config/index");
module.exports = () => {
return (req, res, next) => {
//查看请求参数
//console.log("请求参数:", req.query);
const { signature, echostr, timestamp, nonce } = req.query;
const { token } = config;
//1、将参与微信加密签名的三个参数(timestamp、nonce、token),按照字典序排序并组合在一起形成一个数组
const arr = [timestamp, nonce, token];
const arrSort = arr.sort();
//2、将数组里所有参数拼接成一个字符串,然后进行`sha1`加密
const str = arrSort.join("");
const sha1Str = sha1(str);
/**
* get请求:验证服务器的有效性
* post请求:将用户发给微信的消息转发给开发者服务器
*/
if (req.method === "GET") {
//3、加密完成后就会生成一个signature,和微信发送过来的进行对比,判断是否一致。如果一致返回`echostr` 给微信服务器;如果不一致返回`error`
if (sha1Str === signature) {
res.send(echostr);
} else {
res.end("error");
}
} else if (req.method === "POST") {
//验证消息是否来源于服务器
if(sha1Str !== signature){
//说明消息不是微信服务器
res.end("error");
}else{
console.log(req.query)
}
}else{
//非get、post请求
res.end("error");
}
};
};
启动一下服务器,并检查ngrok是否正常
ngrok http
你自己的端口号
在你的微信测试接口号里发送一条消息。这里遇见了一个大问题
排查了很久最后发现是因为你重新启动ngrok后,你的地址变了与微信公众号平台填写的那个地址不一致了。
解决:用新生成的地址替换一下原来的地址。如果还不好用就按照:测试服务器的搭建
再重新生成一下地址,然后再替换一下微信公众号平台的那个地址。
成功后会返回下面这些信息:
{
signature: '30868312eca1fdf897089cef83c1bc4577aca7c4',
timestamp: '1648351535',
nonce: '955953481',
openid: 'ok2t66FlpFCVcZ14Kg2g-VNsWswk' //用户的微信id
}
如果开发者服务器没有返回消息给微信服务器,微信服务器会发送三次请求过来。会浪费请求资源,可以通过 res.end('')
返回一个空消息。
1、新建一个util
文件夹,用来放置一些工具函数
tool.js
module.exports = {
/**
* 异步获取用户数据
*/
getUserDataAsync(req) {
//该函数是异步的,通过Promise保证可以获取到数据
return new Promise((resolve, reject) => {
let xmlData = "";
//当流式数据传递过来是触发
req
.on("data", (data) => {
//读取的数据是buffer数据,需要转成字符串数据
xmlData += data.toString();
})
//当数据接收完毕时会触发
.on("end", () => {
resolve(xmlData);
});
});
},
};
auth.js
//引入sha1
const sha1 = require("sha1");
//引入config配置模块
const config = require("../config/index");
//引入tool模块
const { getUserDataAsync } = require("../util/tool.js");
module.exports = () => {
return async (req, res, next) => {
//查看请求参数
//console.log("请求参数:", req.query);
const { signature, echostr, timestamp, nonce } = req.query;
const { token } = config;
//1、将参与微信加密签名的三个参数(timestamp、nonce、token),按照字典序排序并组合在一起形成一个数组
const arr = [timestamp, nonce, token];
const arrSort = arr.sort();
//2、将数组里所有参数拼接成一个字符串,然后进行`sha1`加密
const str = arrSort.join("");
const sha1Str = sha1(str);
/**
* get请求:验证服务器的有效性
* post请求:将用户发给微信的消息转发给开发者服务器
*/
if (req.method === "GET") {
//3、加密完成后就会生成一个signature,和微信发送过来的进行对比,判断是否一致。如果一致返回`echostr` 给微信服务器;如果不一致返回`error`
if (sha1Str === signature) {
res.send(echostr);
} else {
res.end("error");
}
} else if (req.method === "POST") {
//验证消息是否来源于服务器
if (sha1Str !== signature) {
//说明消息不是微信服务器
res.end("error");
} else {
//验证一下是否请求成功
// console.log(req.query)
//接收请求体中的数据,流式数据
const xmlData = await getUserDataAsync(req);
console.log(xmlData)
res.end("");
}
} else {
//非get、post请求
res.end("error");
}
};
};
ToUserName:开发者的id
FromUserName:用户的openid
CreateTime:创建时间
MSgType:消息类型
Content:用户发送的内容
MsgId:消息的id,微信服务器默认保存此消息3天,通过该id可以在3天内找到该消息
将xml数据解析为js对象
这里需要用到xml2js
npm i xml2js
const { parseString } = require("xml2js");
parseXMLAsync(xmlData) {
return new Promise((resolve, reject) => {
parseString(xmlData, { trim: true }, (err, data) => {
if (!err) {
resolve(data);
} else {
reject("parseXMLAsync执行失败:" + err);
}
});
});
},
formatMessage(jsData) {
let message = {};
let xml = jsData.xml;
if (typeof xml === "object") {
for (let key in xml) {
let value = xml[key];
if (Array.isArray(value) && value.length > 0) {
message[key] = value[0];
}
}
}
return message;
},
假如服务器无法保证在五秒内处理并回复,必须做出下述回复,这样微信服务器才不会对此作任何处理,并且不会发起重试(这种情况下,可以使用客服消息接口进行异步回复),否则,将出现严重的错误提示。详见下面说明:
1、直接回复success(推荐方式) 2、直接回复空串(指字节长度为0的空字符串,而不是XML结构体中content字段的内容为空)
一旦遇到以下情况,微信都会在公众号会话中,向用户下发系统提示“该公众号暂时无法提供服务,请稍后再试”:
1、开发者在5秒内未回复任何内容 2、开发者回复了异常数据,比如JSON数据、字符串、xml数据中有多余的空格等
另外,请注意,回复图片(不支持gif动图)等多媒体消息时需要预先通过素材管理接口上传临时素材到微信服务器,可以使用素材管理中的临时素材,也可以使用永久素材。
let replayContent = "";
if (message.MsgType == "text") {
//消息是文本类型
if (message.Content == "1") {
replayContent = "你好,世界!";
} else if (message.Content == "2") {
replayContent = "hello world!";
} else {
replayContent = "请回复1或2!";
}
}
//回复的消息,注意xml中尖括号里一定不能有空格
let replayMsg = `
${message.FromUserName}]]>
${message.ToUserName}]]>
${Date.now()}
${replayContent}]]>
`;
//返回响应给微信服务器
res.send(replayMsg);
//测试时可以返回一个空字符串,放置重复请求
//res.end("");
}
} else {
//非get、post请求
res.end("error");
}
从微信官方文档,被动回复用户消息 可以看到有5种回复类型,这里简单进行封装
新建一个template.js,用来封装回复用户消息的模板
/**
* 回复用户消息的模板
*/
module.exports = (option) => {
let replayMsg = `
${option.toUserName}]]>
${option.fromUserName}]]>
${option.createTime}
${option.mesType}]]> `;
if (option.msgType === "text") {
//回复文字
replayMsg += `${option.content}]]> `;
} else if (option.msgType === "image") {
replayMsg += `
${option.mediaId}]]>
`;
} else if (option.msgType == "voice") {
//回复语音
replayMsg += `
${option.mediaId}]]>
`;
} else if (option.msgType === "video") {
//回复视频
replayMsg += `
`;
} else if (option.msgType === "music") {
//回复音乐
replayMsg += `
${option.title}]]>
${option.description}]]>
${option.musicUrl}]]>
${option.hqMusicUrl}]]>
${option.mediaId}]]>
`;
} else if (option.msgType === "news") {
//回复图文信息
replayMsg += `
1
-
${option.title}]]>
${option.description}]]>
${option.picUrl}]]>
${option.url}]]>
`;
}
replayMsg += "";
return replayMsg;
};
根据用户发送的消息类型来决定回复用户的消息。可分为普通消息和事件推送两种。
官方文档
创建replay.js: 用于回复消息
/**
* 处理用户发送的消息类型和内容,决定返回不同的内容给用户
*/
module.exports = (message) => {
let option = {
toUserName: message.FromUserName,
fromUserName: message.ToUserName,
createTime: Date.now(),
msgType: "text",
content: "",
};
let replayContent = "";
if (message.MsgType == "text") {
//消息是文本类型
if (message.Content == "1") {
replayContent = "你好,世界!";
} else if (message.Content == "2") {
replayContent = "hello world!";
} else {
replayContent = "请回复1或2!";
}
} else if (message.MsgType == "image") {
//用户发送图片消息
option.msgType = "image";
option.mediaId = message.MediaId;
console.log("图片:", message.PicUrl);
} else if (message.msgType == "voice") {
//语音
option.msgType = "voice";
option.mediaId = message.MediaId;
console.log("语音内容:", message.Recognition);
} else if (message.msgType == "event") {
if (message.Event == "subscribe") {
//订阅
replayContent = "很高兴能在茫茫人海中遇见你。";
} else if (message.Event == "unsubscribe") {
//取消订阅
console.log("期待与你的下次响应。");
} else if (message.Event == "CLICK") {
replayContent = "您点击了菜单!" + message.EventKey;
}
}
option.content = replayContent;
return option;
};
自定义菜单官方文档
注意:
自定义菜单接口可实现多种类型按钮,这里以click和view类型按钮为例
定义一个菜单模块
创建菜单需要用到ACCESS_TOKEN
,这里在accessToken.js
基础上进行开发,这里改名为wechart.js
/*
* @Description:
* @Author: 姚崇
* @Date: 2022-03-14 22:15:28
* @LastEditTime: 2022-03-20 23:01:29
* @LastEditors: 姚崇
*/
//获取acess token
/**
* 特点:1、唯一 2、有效时间2小时,为防止过期提前5分钟请求 3、每天最多请求2000次
* get请求:请求地址:https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET
*/
/**
* 设计思路:
* 1、首次本地没有,发起请求获取acess token,并保存下来(本地文件)
* 2、第二次及以后:
* a、从本地读取文件,判断是否过期
* b、没有过期,直接使用
* c、过期了,重新请求,保存并覆盖之前的文件
*
* 整理思路:
* 读取本地文件(readAccessToken)
* a、没有文件,发送请求获取(getAccessToken) 保存(saveAccessToken)
* b、有文件,判断是否过期(isValidAccessToken)
*/
//只需要引入request-promise-native即可
const rp = require("request-promise-native");
//引入fs模块
const fs = require("fs");
//引入配置文件
const { appID, appsecret } = require("../config/index");
const { json } = require("express/lib/response");
//引入菜单
const menu = require("./menu");
class Wechat {
//构造器
constructor() {}
/**
* 获取accessToken
*/
getAccessToken() {
//请求地址,从config配置文件中获取
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appID}&secret=${appsecret}`;
//发送请求,需要使用request和request-promise-native两个库。
//用promise进行包装,确保返回数据
return new Promise((resolve, reject) => {
rp({ methods: "GET", url, json: true })
.then((res) => {
// console.log(res);
//设置access token过期时间,提前5分钟,乘1000是秒变毫秒
res.expires_in = Date.now() + (res.expires_in - 5 * 60) * 1000;
resolve(res);
})
.catch((err) => {
// console.log(err);
reject("getAccessToken执行失败:" + err);
});
});
}
/**
* 保存accessToken的方法
* @param {*} accessToken 要保存的数据
*/
saveAccessToken(accessToken) {
let data = JSON.stringify(accessToken);
return new Promise((resolve, reject) => {
fs.writeFile("./accessToken.txt", data, (err) => {
if (!err) {
console.log("accessToken保存成功");
resolve();
} else {
reject("accessToken保存失败:" + err);
}
});
});
}
/**
* 读取accessToken
*/
readAccessToken() {
return new Promise((resolve, reject) => {
fs.readFile("./accessToken.txt", (err, data) => {
if (!err) {
resolve(JSON.parse(data));
} else {
reject("读取accessToken失败:" + err);
}
});
});
}
/**
* 判断accessToken是否是有效的
* @param {*} accessToken :凭证
*/
isValidAccessToken(data) {
if (!data && !data.access_token && !data.expires_in) {
//无效
return false;
}
//判断是否在有效期内
return data.expires_in > Date.now();
}
/**
* 用来获取没有过期的accessToken
*/
fetchAccessToken() {
if (this.access_token && this.expires_in && this.isValidAccessToken(this)) {
//说明之前保存过,并且是有效的
return Promise.resolve({
access_token: this.access_token,
expires_in: this.expires_in,
});
}
//读取accessToken
return this.readAccessToken()
.then(async (res) => {
//本地有文件判断是否过期
if (this.isValidAccessToken(res)) {
resolve(res);
} else {
//重新请求
const res = await this.getAccessToken();
await this.saveAccessToken(res);
//将请求回来的token返回出去
return Promise.resolve(res);
// resolve(res);
}
})
.catch(async (err) => {
//本地没有文件
//重新请求
const res = await this.getAccessToken();
await this.saveAccessToken(res);
//将请求回来的token返回出去
return Promise.resolve(res);
// resolve(res);
})
.then((res) => {
//将accessToken挂在到this上
this.access_token = res.access_token;
this.expires_in = res.expires_in;
return Promise.resolve(res);
});
}
/**
* 创建菜单
*/
createMenu(menu) {
return new Promise(async (resolve, reject) => {
try {
//获取access_token
let data = await this.fetchAccessToken();
//定义请求地址
let url = `https://api.weixin.qq.com/cgi-bin/menu/create?access_token=${data.access_token}`;
//发送请求
let result = await rp({
method: "POST",
url,
json: true,
body: menu,
});
resolve(result);
} catch (error) {
reject("createMenu失败:" + error);
}
});
}
/**
* 删除菜单
*/
deleteMenu() {
return new Promise(async (resolve, reject) => {
try {
//获取access_token
let data = await this.fetchAccessToken();
//请求地址
let url = `https://api.weixin.qq.com/cgi-bin/menu/delete?access_token=${data.access_token}`;
//发送请求
let result = await rp({
method: "GET",
url,
json: true,
});
resolve(result);
} catch (error) {
reject("deleteMenu失败:" + error);
}
});
}
}
(async () => {
//创建对象
const w = new Wechat();
//删除之前的菜单
let result = await w.deleteMenu();
console.log("删除菜单:", result);
//创建菜单
result = await w.createMenu(menu);
console.log("创建菜单:", result);
})();