新建项目
$ npm i egg-init -g
$ egg-init wechat_public_number_demo --type=simple
$ cd wechat_public_number_demo
$ npm i
$ npm run dev
$ open localhost:7001
接入公众号
由于配置的URL必须要域名,为了实现本地开发,本人采用Sunny-Ngrok进行端口映射,具体用法请自行百度。
注意:端口若不为80,则无法配置成功。
config/config.default.js
config.wechat_config = {
token: 'wechat_public_number_demo',
};
router.js
router.get('/wechat', controller.home.fromWechat);
controller/home.js
const crypto = require('crypto');
async fromWechat() {
const token = this.ctx.app.config.wechat_config.token;
const query = this.ctx.query;
const timestamp = query.timestamp;
const nonce = query.nonce;
const signature = query.signature;
const echostr = query.echostr;
const str = [ token, timestamp, nonce ].sort().join('');
const hash = crypto.createHash('sha1');
hash.update(str);
const sha = hash.digest('hex');
if (sha === signature) {
this.ctx.body = echostr;
}
}
package.json
"dev": "egg-bin dev --port=80",
获取access_token
config/config.default.js
appid: 'wx230f799414023398',
secret: '27118b180d47a9b11c094a03cea63a74',
getAccessTokenUrl: 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET',
schedule/access_token.js
'use strict';
const Subscription = require('egg').Subscription;
class UpdateCache extends Subscription {
static get schedule() {
return {
interval: '2h',
type: 'all',
};
}
async subscribe() {
const config = this.ctx.app.config.wechat_config;
const url = config.getAccessTokenUrl.replace('APPID', config.appid).replace('APPSECRET', config.secret);
const res = await this.ctx.curl(url, {
dataType: 'json',
});
console.log(res.data.access_token);
this.ctx.app.access_token = res.data.access_token;
}
}
module.exports = UpdateCache;
app.js
'use strict';
module.exports = app => {
app.beforeStart(async () => {
await app.runSchedule('access_token');
});
};
自定义菜单
由于egg内置了安全插件,请求会报错,暂时的解决方案是关闭csrf检查。
config.default.js
postCreateMenuUrl: 'https://api.weixin.qq.com/cgi-bin/menu/create?access_token=ACCESS_TOKEN',
config.security = {
csrf: {
enable: false,
},
};
app.js
const menu = {
button: [
{
name: '组一',
sub_button: [
{
type: 'click',
name: 'click',
key: 'click',
},
{
type: 'view',
name: 'view',
url: 'http://chrish.free.ngrok.cc',
},
],
},
{
name: '组二',
sub_button: [
{
type: 'scancode_waitmsg',
name: '扫码带提示',
key: 'scancode_waitmsg',
},
{
type: 'scancode_push',
name: '扫码推事件',
key: 'scancode_push',
},
{
type: 'pic_sysphoto',
name: '系统拍照发图',
key: 'pic_sysphoto',
},
{
type: 'pic_photo_or_album',
name: '拍照或者相册发图',
key: 'pic_photo_or_album',
},
{
type: 'pic_weixin',
name: '微信相册发图',
key: 'pic_weixin',
},
],
},
{
type: 'location_select',
name: '发送位置',
key: 'location_select',
},
],
};
const config = app.config.wechat_config;
const url = config.postCreateMenuUrl.replace('ACCESS_TOKEN', app.access_token);
const res = await app.curl(url, {
method: 'POST',
contentType: 'json',
data: menu,
dataType: 'json',
});
console.log(res.data.errcode);
消息管理
由于微信服务器发送和接收的都是XML数据包,所以引入xml2js对XML进行解析和封装。
特别注意: xml2js对XML的解析是异步的,若在其中直接返回数据,微信服务器是收不到的,会一直提示“该公众号提供的服务出现故障,请稍后再试”。
package.json
"xml2js": "^0.4.19"
util/xml2js.js
'use strict';
const xml2js = require('xml2js');
const parseXml = str => {
return new Promise((resolve, reject) => {
const parseString = xml2js.parseString;
parseString(str, { explicitArray: false }, (err, json) => {
if (json) {
resolve(json.xml);
} else {
reject(err);
}
});
});
};
const createXml = obj => {
const builder = new xml2js.Builder({
rootName: 'xml',
headless: true,
cdata: true,
});
return builder.buildObject(obj);
};
module.exports = {
parseXml,
createXml,
};
middleware/xml2js.js
'use strict';
const xml2js = require('../util/xml2js');
module.exports = () => {
return async (ctx, next) => {
if (ctx.method === 'POST' && ctx.is('text/xml')) {
const promise = new Promise((resolve, reject) => {
let data = '';
ctx.req.on('data', chunk => {
data += chunk;
});
ctx.req.on('end', () => {
console.log(data);
xml2js.parseXml(data).then(result => {
resolve(result);
}).catch(err => {
reject(err);
});
});
});
await promise.then(result => {
ctx.req.body = result;
}).catch(() => {
ctx.req.body = '';
});
}
await next();
};
};
config.default.js
config.middleware = [ 'xml2js' ];
router.js
router.post('/wechat', controller.home.toWechat);
controller/home.js
const replyMsg = require('../util/reply_msg');
async toWechat() {
const message = this.ctx.req.body;
if (message) {
const MsgType = message.MsgType;
let reply;
if (MsgType === 'event') {
reply = this.handleEvent(message);
} else {
reply = this.handleMsg(message);
}
if (reply) {
const result = replyMsg(message, reply);
console.log(result);
this.ctx.body = result;
return true;
}
}
this.ctx.body = 'success';
}
handleEvent(message) {
const { Event, EventKey, Ticket, Latitude, Longitude, Precision } = message;
let reply;
switch (Event) {
case 'subscribe':
console.log(EventKey);
console.log(Ticket);
reply = '欢迎关注XXX的测试公众号';
break;
case 'unsubscribe':
reply = '';
break;
case 'SCAN':
reply = 'EventKey:' + EventKey + ', Ticket:' + Ticket;
break;
case 'LOCATION':
reply = 'Latitude:' + Latitude + ', Longitude:' + Longitude + ', Precision:' + Precision;
break;
case 'CLICK':
reply = 'EventKey:' + EventKey;
break;
case 'VIEW':
reply = 'EventKey:' + EventKey;
break;
default:
reply = '';
break;
}
return reply;
}
handleMsg(message) {
const { MsgType, Content, PicUrl, MediaId, Recognition, Label, Url } = message;
let reply;
switch (MsgType) {
case 'text':
reply = Content;
break;
case 'image':
reply = PicUrl;
break;
case 'voice':
console.log(Recognition);
reply = MediaId;
break;
case 'video':
reply = MediaId;
break;
case 'shortvideo':
reply = MediaId;
break;
case 'location':
reply = Label;
break;
case 'link':
reply = Url;
break;
default:
reply = '';
break;
}
return reply;
}
util/reply_msg.js
'use strict';
const xml2js = require('./xml2js');
module.exports = (message, Content) => {
const obj = {
ToUserName: message.FromUserName,
FromUserName: message.ToUserName,
CreateTime: new Date().getTime(),
MsgType: 'text',
Content,
};
return xml2js.createXml(obj);
};