使用ThinkJs搭建微信中控服務

前言

本人前端渣渣一枚,這篇文章是第一次寫,若是有硬核bug,請大佬們輕噴、指出... 另外,本文不涉及任何接口安全、參數校驗之類的東西,默認對調用方無腦級的信任😂 目前自用的接口包括但不限於如下這些html

|--- 微信相關
| |--- 0. 處理微信推過來的一些消息
| |--- 1. 獲取微信SDK配置參數
| |--- 2. 微信鑑權登錄
| |--- 3. 獲取微信用戶信息
| |--- 4. 獲取AccessToken
| |--- 5. 批量發送模版消息
| |--- 6. 獲取模版消息列表
| |--- 7. 批量發送客服消息
複製代碼

背景

  • 【需求】小項目不少很雜,並且大部分需求都是基於微信開發的,每次都查微信文檔的話就會很鬱悶😒...
  • 【號多】公衆號超級多,項目中偶爾會涉及借權獲取用戶信息(在不綁定微信開放平臺的前提下,須要臨時自建各個公衆號的openid關聯關係),相似這樣同時須要不止一個公衆號配合來完成一件事的需求,就容易把人整懵逼...
  • 【支付】微信支付的商戶號也不少,並且有時候支付須要用的商戶號,還不能用關聯的公衆號取出來的openid去支付...
  • 【官方】微信官方文檔建議!把獲取AccessToken等微信API抽離成單獨的服務...
  • 等等等等........因此...😂

建立ThinkJS項目

官網

thinkjs.org/前端

簡介

ThinkJS 是一款面向將來開發的 Node.js 框架,整合了大量的項目最佳實踐,讓企業級開發變得如此簡單、高效。從 3.0 開始,框架底層基於 Koa 2.x 實現,兼容 Koa 的全部功能。node

安裝腳手架

$ npm install -g think-cli
複製代碼

建立及啓動項目

$ thinkjs new demo;
$ cd demo;
$ npm install; 
$ npm start; 
複製代碼

目錄結構

|--- development.js   //開發環境下的入口文件
|--- nginx.conf  //nginx 配置文件
|--- package.json
|--- pm2.json //pm2 配置文件
|--- production.js //生產環境下的入口文件
|--- README.md
|--- src
| |--- bootstrap  //啓動自動執行目錄 
| | |--- master.js //Master 進程下自動執行
| | |--- worker.js //Worker 進程下自動執行
| |--- config  //配置文件目錄
| | |--- adapter.js  // adapter 配置文件 
| | |--- config.js  // 默認配置文件 
| | |--- config.production.js  //生產環境下的默認配置文件,和 config.js 合併 
| | |--- extend.js  //extend 配置文件 
| | |--- middleware.js //middleware 配置文件 
| | |--- router.js //自定義路由配置文件
| |--- controller  //控制器目錄 
| | |--- base.js
| | |--- index.js
| |--- logic //logic 目錄
| | |--- index.js
| |--- model //模型目錄
| | |--- index.js
|--- view  //模板目錄
| |--- index_index.html
複製代碼

安裝think-wechat插件

介紹

微信中間件,基於 node-webot/wechat,支持 thinkJS 3.0ios

安裝

$ npm install think-wechat --save
複製代碼

nginx

$ cnpm install think-wechat --save
複製代碼

配置

文件:/src/config/middleware.jsgit

const wechat = require('think-wechat')
module.exports = [
    ...
     {
        handle: wechat,
        match: '/index',
        options: {
            token: '', // 令牌,和公衆號/基本配置/服務器配置裏面寫同樣的便可
            appid: '', // 這裏貌似能夠隨便填,由於咱們後面要用數據庫配置多個公衆號
            encodingAESKey: '',
            checkSignature: false
        }
    }, {
        handle: 'payload', // think-wechat 必需要在 payload 中間件前面加載,它會代替 payload 處理微信發過來的 post 請求中的數據。
        options: {
            keepExtensions: true,
            limit: '5mb'
        }
    },
]
複製代碼

注:match下我這裏寫的是/index,對應的項目文件是/src/controller/index.js,對應的公衆號後臺所需配置的服務器地址就是http(https)://域名:端口/indexweb

建立數據庫和相關表

我這裏建立了三個微信的相關表。數據庫

配置表:wx_config

字段 類型 說明
id int 主鍵
name varchar 名稱
appid varchar appid
secret varchar secret

用戶表:wx_userinfo

字段 類型 註釋
id int 主鍵
subscribe int 用戶是否訂閱該公衆號標識,值爲0時,表明此用戶沒有關注該公衆號,拉取不到其他信息。
nickname varchar 用戶的暱稱
sex int 用戶的性別,值爲1時是男性,值爲2時是女性,值爲0時是未知
language varchar 用戶所在省份
city varchar 用戶所在城市
province varchar 用戶所在省份
country varchar 用戶所在國家
headimgurl longtext 用戶頭像,最後一個數值表明正方形頭像大小(有0、4六、6四、9六、132數值可選,0表明640*640正方形頭像),用戶沒有頭像時該項爲空。若用戶更換頭像,原有頭像URL將失效。
subscribe_time double 用戶關注時間,爲時間戳。若是用戶曾屢次關注,則取最後關注時間
unionid varchar 只有在用戶將公衆號綁定到微信開放平臺賬號後,纔會出現該字段。
openid varchar 用戶的標識,對當前公衆號惟一
wx_config_id int 對應配置的微信號id

模版消息日誌表:wx_template_log

字段 類型 註釋
id int 主鍵
template_id varchar 模版id
openid varchar 用戶的標識,對當前公衆號惟一
url varchar 跳轉url
miniprogram varchar 跳轉小程序
data varchar 發送內容json字符串
add_time double 添加時間戳
send_time double 發送時間戳
send_status varchar 發送結果
wx_config_id double 對應配置的微信號id
uuid varchar 本次發送的uuid,業務系統可經過uuid查詢模版消息推送結果

處理微信推送消息

文件目錄

/src/controller/index.js
複製代碼

文件內容

module.exports = class extends think.Controller {
    /*
    * 入口:驗證開發者服務器
    * 驗證開發者服務器,這裏只是演示,因此沒作簽名校驗,實際上應該要根據微信要求進行簽名校驗
    */
    async indexAction() {
        let that = this;
        if (that.method != 'REPLY') {
            return that.json({code: 1, msg: '非法請求', data: null})
        }
        const {echostr} = that.get();
        return that.end(echostr);
    }
     
    /*
    * 文字
    * 用於處理微信推過來的文字消息
    */
    async textAction() {
        let that = this;
        let {id, signature, timestamp, nonce, openid} = that.get();
        let {ToUserName, FromUserName, CreateTime, MsgType, Content, MsgId} = that.post();
        .....
        that.success('')
    }
    
    /*
    * 事件
    * 用於處理微信推過來的事件消息,例如點擊菜單等
    */
    async eventAction() {
        let that = this;
        let {id, signature, timestamp, nonce, openid} = that.get();
        let {ToUserName, FromUserName, CreateTime, MsgType, Event, EventKey, Ticket, Latitude, Longitude, Precision} = that.post();
        switch (Event) {
            case 'subscribe': // 關注公衆號
                ...
                break;
            case 'unsubscribe': // 取消關注公衆號
                ...
                break;
            case 'SCAN': // 已關注掃碼
                ...
                break;
            case 'LOCATION': // 地理位置
                ...
                break;
            case 'CLICK': // 自定義菜菜單
                ...
                break;
            case 'VIEW': // 跳轉
                ...
                break;
            case 'TEMPLATESENDJOBFINISH':// 模版消息發送完畢
                ...
                break;
        } 
        that.success('')
    }
}
複製代碼

注:支持的action包括:textActionimageActionvoiceActionvideoActionshortvideoActionlocationActionlinkActioneventActiondeviceTextActiondeviceEventActionnpm

公衆號後臺配置

注:後面跟的id參數是爲了區分是哪一個公衆號推過來的消息,在上面的接口參數中也有體現

微信相關API的編寫

目錄結構

|--- src
| |--- controller  //控制器目錄 
| | |--- index.js // 處理微信推送的消息,上面有寫到
| | |--- common.js // 一些公共方法
| | |--- open // 開放給其餘業務服務的api接口
| | | |--- wx.js
| | |--- private // 放一些內部調用的方法,調用微信api的方法主要在這裏面
| | | |--- wx.js
複製代碼

這個目錄結構可能不太合理,後期再改進吧😁json

公共方法

// src/controller/common.js

import axios from 'axios'
import {baseSql} from "./unit";

module.exports = class extends think.Controller {
    // 獲取appinfo
    async getWxConfigById(id) {
        let that = this;
        let data = await that.cache(`wx_config:wxid_${id}`, async () => {
            // 數據庫內取
            let info = await that.model('wx_config', baseSql).where({id: id}).find();
            if (!think.isEmpty(info)) {
                return info
            }
        })
        return data || {}
    }

    // 獲取access_token
    async getAccessToken(id) {
        let that = this;
        let accessToken = await that.cache(`wx_access_token:wxid_${id}`, async () => {
            let {appid, secret} = await that.getWxConfigById(id);
            let {data} = await axios({
                method: 'get',
                url: `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appid}&secret=${secret}`
            });
            return data.access_token
        });
        return accessToken
    }
}
複製代碼

接口過濾器

全部開放出來的接口的前置方法,俗稱過濾器?全部開放的接口必傳get參數是wxid,對應數據庫表wx_config裏面id

// src/controller/open/wx.js

async __before() {
    let that = this;
    let wxid = that.get('wxid');
    if (think.isEmpty(wxid)) {
        return that.json({code: 1, msg: 'wxid不存在'})
    }
    that.wxConfig = await that.controller('common').getWxConfigById(wxid);
    if (think.isEmpty(that.wxConfig)) {
        return that.json({code: 1, msg: 'wxid不存在'})
    }
}
複製代碼

接口 - 獲取AccessToken

代碼

// src/controller/open/wx.js

async get_access_tokenAction() {
    let that = this;
    let accessToken = await that.controller('common').getAccessToken(that.wxConfig.id);
    return that.json({code: 0, msg: '', data: {access_token: accessToken}})
}
複製代碼

文檔

接口 - 獲取微信sdk的config

代碼

// src/controller/open/wx.js

async get_wxsdk_configAction() {
    let that = this;
    let {url} = that.get();
    if (think.isEmpty(url)) {
        return that.json({code: 1, msg: '參數不正確'})
    }
    let sdkConfig = await that.controller('private/wx').getSdkConfig(that.wxConfig.id, url);
    return that.json({code: 0, msg: '', data: sdkConfig})
}


// src/controller/private/wx.js

const sha1 = require('sha1');
const getTimestamp = () => parseInt(Date.now() / 1000)
const getNonceStr = () => Math.random().toString(36).substr(2, 15)
const getSignature = (params) => sha1(Object.keys(params).sort().map(key => `${key.toLowerCase()}=${params[key]}`).join('&'));

async getSdkConfig(id, url) {
    let that = this;
    let {appid} = await that.controller('common').getWxConfigById(id);
    let shareConfig = {
        nonceStr: getNonceStr(),
        jsapi_ticket: await that.getJsapiTicket(id),
        timestamp: getTimestamp(),
        url: url
    }
    return {
        appId: appid,
        timestamp: shareConfig.timestamp,
        nonceStr: shareConfig.nonceStr,
        signature: getSignature(shareConfig)
    }
}
複製代碼

文檔

接口 - 獲取UserInfo

代碼

// src/controller/open/wx.js

async get_userinfoAction() {
    let that = this;
    let {openid} = that.get();
    if (think.isEmpty(openid)) {
        return that.json({code: 1, msg: '參數不正確'})
    }
    let userInfo = await that.controller('private/wx').getUserInfo(that.wxConfig.id, openid);
    if (think.isEmpty(userInfo)) {
        return that.json({code: 1, msg: 'openid不存在', data: null})
    }
    return that.json({code: 0, msg: '', data: userInfo})
}


// src/controller/private/wx.js

async getUserInfo(id, openid) {
    let that = this;
    let userInfo = await that.cache(`wx_userinfo:wxid_${id}:${openid}`, async () => {
        //先取數據庫
        let model = that.model('wx_userinfo', baseSql);
        let userInfo = await model.where({wx_config_id: id, openid: openid}).find();
        if (!think.isEmpty(userInfo) && userInfo.subscribe == 1 && userInfo.unionid != null) {
            return userInfo
        }
        //若是數據庫內沒有,取新的存入數據庫
        let accessToken = await that.controller('common').getAccessToken(id);
        let url = `https://api.weixin.qq.com/cgi-bin/user/info?access_token=${accessToken}&openid=${openid}&lang=zh_CN`;
        let {data} = await axios({method: 'get', url: url});
        if (data.openid) {
            //命中修改,沒有命中添加
            let resId = await model.thenUpdate(
                Object.assign(data, {wx_config_id: id}),
                {openid: openid, wx_config_id: id});
            return await model.where({id: resId}).find();
        }
    })
    return userInfo
}
複製代碼

文檔

接口 - 批量發送文字客服消息

代碼

// src/controller/open/wx.js

async send_msg_textAction() {
    let that = this;
    let {list} = that.post();
    if (think.isEmpty(list)) {
        return that.json({code: 1, msg: '參數不正確'})
    }
    that._sendMsgTextList(that.wxConfig.id, list);
    return that.json({code: 0, msg: '', data: null})
 }
 
 async _sendMsgTextList(wxid, list) {
    let that = this;
    let apiWxController = that.controller('private/wx');
    for (let item of list) {
        let data = await apiWxController.sendMsgText(wxid, item.openid, item.text)
    }
}


// src/controller/private/wx.js

async sendMsgText(id, openid, content) {
    let that = this;
    let accessToken = await that.controller('common').getAccessToken(id);
    let url = `https://api.weixin.qq.com/cgi-bin/message/custom/send?access_token=${accessToken}`
    let {data} = await axios({
        method: 'post', url: url, data: {"msgtype": 'text', "touser": openid, "text": {"content": content}}
    })
    return data;
}
複製代碼

文檔

寫在結尾

其實還有不少接口,這裏就不所有列出來了。

應該能看出來,在這個項目裏面並不只僅是把微信的接口作了個簡單的轉發,而是有一些本身的處理邏輯在裏面。

好比獲取微信用戶信息的時候,會先判斷緩存裏有沒有,若是沒有就取數據庫,若是尚未再去微信的接口取;若是數據庫有,而且關注字段是未關注的話,仍是會調用微信的接口取一波再更新。 反正一天內,微信接口的調用次數是絕對夠用的。

再好比批量發送模版消息,中控服務在收到請求後會先建立一個uuid,要發的模版消息所有保存到數據庫內,直接把uuid返給調用方。 而後中控會異步用uuid取出來這批模版消息,一個一個發,一個一個更新結果。 這樣在業務方調用發送模版消息以後,無需等待所有發送完畢,就能夠用拿到的uuid,去中控查詢此次批量發送的狀態結果。

目前是綁了七八個公衆號,在沒燒過香的前提下,還沒出過什麼問題

最後

沒了。 哈哈哈哈😂😂😂😂。

第一次寫啊...不知道怎麼結尾。 就這麼着吧。

相關文章
相關標籤/搜索