現現在,前端開發涉及到的愈來愈多,基於微信的開發(公衆號、企業微信、小程序)成了前端必備的開發技能html
然而,不少前端開發仍是僅限於客戶端,與微信之間的對接依舊是依賴於後端來處理,大大限制了本身的輸出範圍。其實,由於 Node.js 的出現,咱們擁有了 複雜度相對較低 的後端開發能力。在微信網關這個場景上,咱們徹底有能力本身搞定前端
接下來,咱們就經過不斷提出的需求,來一步步搭建和完善咱們的微信網關git
咱們的重點在於網關服務的實現,至於怎麼樣進行服務部署並暴露給微信去調用,以及公衆號、企業微信、小程序等的申請,不在此贅述github
本文代碼獲取方式:github.com/splendourhu… ,走過路過,歡迎點贊和 star,本人會持續優化該項目typescript
使用 Egg 官方文檔 提供的初始化命令進行項目初始化npm
mkdir store && cd store
npm init egg --type=ts
npm i
npm run dev
複製代碼
咱們須要提供一個 API,用於讓微信服務器認證回調接口是否正常運行,以便開啓回調模式。先來看看官方提供的回調接口認證流程json
先安裝一個包含參數校驗邏輯的包小程序
npm i --save wechat-crypto
複製代碼
而後,把校驗過程封裝在 service 中後端
export default class extends Service {
// 微信回調校驗
public async callbackVerify(): Promise<{ message: string } | void> {
const { ctx } = this;
const {
echostr,
msg_signature: signature,
timestamp,
nonce,
encrypt_type
} = ctx.query;
const config = ctx.app.config.wechat;
// 判斷是否加密傳輸
const encrypted = !!(encrypt_type && encrypt_type === 'aes' && signature);
if (!encrypted) {
return { message: echostr };
}
// 使用官方提供的包進行驗證和解密
const cryptor = encrypted
? new WXBizMsgCrypt(config.token, config.encodingAESKey, config.appId)
: {};
if (signature !== cryptor.getSignature(timestamp, nonce, echostr)) {
return;
}
return cryptor.decrypt(echostr);
}
}
複製代碼
接着,實現該接口的 controllerbash
export default class extends Controller {
public async callback() {
const { ctx } = this;
// 回調校驗
if (ctx.method === 'GET') {
const verifyResult = await ctx.service.wechat.callbackVerify();
ctx.body = verifyResult ? verifyResult.message : '';
return;
}
}
複製代碼
最後,在 router 中增長路由定義
router.all('/wechat/callback', controller.wechat.callback);
複製代碼
這裏用 all,是由於微信回調消息會採用和校驗消息同樣的地址,GET 請求用於校驗,POST 請求用於消息回調,在 controller 中也能夠體現
接着,在微信回調配置界面,生成 Token
和 EncodingAESKey
(若是是測試號,則不須要配置;若是開啓了明文模式,則該配置不生效),而且配置到項目的配置文件中。啓動服務,保存微信回調配置,便可校驗成功
微信回調配置成功後,則後續用戶給公衆號發送的消息,以及在公衆號中觸發的事件,都會產生一條信息回調給咱們的服務。咱們來看看官方提供的消息回調流程(以文本消息爲例)
咱們把這個過程分爲兩個階段:消息解析(decode)和消息轉化(encode)
在 decode 階段,咱們處理 xml 消息的接收、解析和解密,利用 xml2js 庫來將 xml 格式的數據轉換爲 json 數據以方便處理,參考 co-wechat 庫來實現整個解析過程
在 service/wechat 中,添加解析函數
// 將 xml 轉化後的格式變成 json 格式
private formatMessage(result) {
let message = {};
if (typeof result === 'object') {
for (const key in result) {
if (result[key].length === 1) {
const val = result[key][0];
if (typeof val === 'object') {
message[key] = this.formatMessage(val);
} else {
message[key] = (val || '').trim();
}
} else {
message = result[key].map(this.formatMessage);
}
}
}
return message;
}
// 微信屬性格式轉換爲駝峯式,將來有更多消息類型時,須要在此補充新字段
private mapMsg(msg: any): IMessage {
return {
createdTime: msg.CreateTime,
msgType: msg.MsgType,
content: msg.Content,
mediaId: msg.MediaId,
recognition: msg.Recognition,
userId: msg.FromUserName
};
}
public async decodeMsg(): Promise<IMessage> {
const { ctx } = this;
const {
msg_signature: signature,
timestamp,
nonce,
encrypt_type
} = ctx.query;
const config = ctx.app.config.wechat;
// 判斷是否加密傳輸
const encrypted = !!(encrypt_type && encrypt_type === 'aes' && signature);
const cryptor = encrypted
? new WXBizMsgCrypt(config.token, config.encodingAESKey, config.appId)
: {};
// 流式獲取請求體
return new Promise((resolve, reject) => {
let data = '';
ctx.req.setEncoding('utf8');
ctx.req.on('data', (chunk: string) => {
data += chunk;
});
ctx.req.on('error', (err: Error) => {
reject(err);
});
ctx.req.on('end', () => {
// 解析原始 xml
xml2js.parseString(data, { trim: true }, (err, result) => {
if (err) {
reject(new Error('xml 解析失敗'));
}
// 原始 xml 轉換爲 json
const originMessage: any = this.formatMessage(result.xml);
if (!encrypted) {
// 不加密時,originMessage 已是解析好的參數
ctx.mySession.message = originMessage;
resolve(this.mapMsg(originMessage));
return;
}
const encryptMessage = originMessage.Encrypt;
if (
signature !== cryptor.getSignature(timestamp, nonce, encryptMessage)
) {
reject(new Error('signature 校驗失敗'));
}
const decrypted = cryptor.decrypt(encryptMessage);
// 提取出解密後的 xml
const messageWrapXml = decrypted.message;
if (messageWrapXml === '') {
reject(new Error('xml 解析失敗'));
}
// 解析解密後的 xml
xml2js.parseString(messageWrapXml, { trim: true }, (err, result) => {
if (err) {
reject(new Error('xml 解析失敗'));
}
const message: any = this.formatMessage(result.xml);
ctx.mySession.message = message;
resolve(this.mapMsg(message));
});
});
});
});
}
複製代碼
這裏將解析後的消息存放在 ctx.mySession
中,以便後續使用,須要增長一個簡單的中間件
export default () => {
return async (ctx, next) => {
if (!ctx.mySession) {
ctx.mySession = {};
}
await next();
};
};
複製代碼
這裏還用到了一個接口 IMessage,咱們根據微信響應消息的類型和內容,定義一個 interface,將來有更多類型時,在此基礎上擴展
export interface IMessage {
createdTime: string;
userId: string;
msgType?: 'voice' | 'image' | 'video';
title?: string;
content?: string;
// 語音識別結果
recognition?: string;
// 文本消息
text?: string;
// 文件消息
fileName?: string;
contentType?: string;
// 媒體文件消息
mediaId?: string;
// 圖文消息
articles?: any[];
}
複製代碼
完成了 decode 的過程,咱們拿到了用戶的消息,這時候就須要作出響應,咱們稱之爲「被動響應消息」,參考文檔 被動回覆用戶消息
本質上,就是在獲取用戶消息後,根據業務需求,給予微信服務器一個特定的響應,讓微信服務器給用戶回覆對應的內容。咱們先以最簡單的文本消息回覆爲,例子
在 service/wechat 中,咱們實現一個經過將 json 格式轉化爲微信須要的 xml 格式消息的方法,而且兼容直接傳字符串做爲文本消息的回覆
// 獲取原始回覆 xml
private compileReply(info) {
return ejs.compile(
[
'<xml>',
'<ToUserName><![CDATA[<%-toUsername%>]]></ToUserName>',
'<FromUserName><![CDATA[<%-fromUsername%>]]></FromUserName>',
'<CreateTime><%=createTime%></CreateTime>',
'<MsgType><![CDATA[<%=msgType%>]]></MsgType>',
'<% if (msgType === "text") { %>',
'<Content><![CDATA[<%-content%>]]></Content>',
'<% } %>',
'</xml>'
].join('')
)(info);
}
// 獲取加密 xml
private compileWrap(wrap) {
return ejs.compile(
[
'<xml>',
'<Encrypt><![CDATA[<%-encrypt%>]]></Encrypt>',
'<MsgSignature><![CDATA[<%-signature%>]]></MsgSignature>',
'<TimeStamp><%-timestamp%></TimeStamp>',
'<Nonce><![CDATA[<%-nonce%>]]></Nonce>',
'</xml>'
].join('')
)(wrap);
}
// 將回復內容轉換爲微信須要的 xml 格式
private getReplyXml(content, fromUsername, toUsername) {
const info: any = {};
let type = 'text';
info.content = content || '';
if (typeof content === 'object') {
if (content.hasOwnProperty('type')) {
type = content.type;
info.content = content.content;
}
}
info.msgType = type;
info.createTime = new Date().getTime();
info.toUsername = toUsername;
info.fromUsername = fromUsername;
return this.compileReply(info);
}
// 將 json 或者 string 格式的內容轉換爲微信須要的 xml 格式
public async encodeMsg(content) {
const { ctx } = this;
if (!content) {
return '';
}
const { msg_signature: signature, encrypt_type } = ctx.query;
const config = ctx.app.config.wechat;
// 判斷是否加密傳輸
const encrypted = !!(encrypt_type && encrypt_type === 'aes' && signature);
const cryptor = encrypted
? new WXBizMsgCrypt(config.token, config.encodingAESKey, config.appId)
: {};
const { message } = ctx.mySession;
// 組裝 xml
const xml = this.getReplyXml(
content,
message.ToUserName,
message.FromUserName
);
// 不需加密時,返回原始 xml
if (!encrypted) {
return xml;
}
// 組裝加密 xml
const wrap: any = {};
wrap.encrypt = cryptor.encrypt(xml);
wrap.nonce = parseInt((Math.random() * 100000000000) as any, 10);
wrap.timestamp = new Date().getTime();
wrap.signature = cryptor.getSignature(
wrap.timestamp,
wrap.nonce,
wrap.encrypt
);
return this.compileWrap(wrap);
}
複製代碼
最後,在 controller/wechat 中,完成消息解析和回覆的過程
export default class extends Controller {
public async callback() {
const { ctx } = this;
// 回調校驗
if (ctx.method === 'GET') {
const verifyResult = await ctx.service.wechat.callbackVerify();
ctx.body = verifyResult ? verifyResult.message : '';
return;
}
// 接收消息
try {
const message = await ctx.service.wechat.decodeMsg();
ctx.logger.info('Get message from wechat', message);
// 在此補充本身的業務邏輯,根據用戶消息,轉換爲須要回覆的消息
// eg: 純文本消息回覆
ctx.body = await ctx.service.wechat.encodeMsg('收到消息了');
} catch (error) {
ctx.logger.error(error);
// 給微信空值響應,避免公衆號出現報錯
ctx.body = '';
}
}
}
複製代碼
至此,咱們搭建起來一個基本的微信回調服務,當收到用戶消息時,能給用戶作一個最簡單的文本回復。在下一篇內容裏,咱們繼續來探索,當用戶發送不一樣類型的消息,以及咱們須要給用戶響應不一樣類型的消息時,應該做何處理