微信公众号学习总结

目录

 

1、微信公众号基础铺垫

2、开发

2.1、利用“功能”菜单进行开发

2.2、代码开发

2.2.1、开发前准备

2.2.2、服务器搭建

2.2.3、验证服务器的有效性

2.2.4、模块化代码

2.2.5、获取access_token

2.2.6、获取用户发送的消息以及简单的文本自动回复

2.2.7、定义用户回复消息的模板文件,实现完整回复用户消息

2.2.8、实现自定义菜单

2.2.9、微信网页开发预热

2.2.10、微信网页开发之获取ticket

2.2.11、微信网页开发之验证微信js-sdk

2.2.12、优化项目结构

2.2.13、上传临时素材


1、微信公众号基础铺垫

分类

  • 服务号:针对企业和组织,服务更强大,一个月只能群发4条消息
  • 订阅号:针对媒体和个人,一天只能群发消息1次
  • 小程序:小程序是一种新的开放能力,开发者可以快速地开发一个小程序。小程序可以在微信内被便捷地获取和传播,同时具有出色的使用体验
  • 企业微信(原企业号):企业微信继承企业号所有能力,同时为企业提供专业的通讯工具、丰富的办公应用与API,助力企业高效沟通与办公。

服务号和订阅号的开发:两者开发是一样的,区别是服务号开发需要提供企业资格证,以下以订阅号开发来介绍

注册网址https://mp.weixin.qq.com/

注册流程

1. 打开官网,点击右上角的注册

2. 选择订阅号注册

3. 根据提示继续完成注册

4. 注册后,开发者相关设置,请看设置、开发这两个菜单

2、开发

订阅号的开发,有两种模式:

  • 利用“功能”菜单进行开发(无需写代码);
  • 代码开发

2.1、利用“功能”菜单进行开发

微信公众平台提供了“功能”菜单进行在线开发,可以根据个人需求,直接用,无需代码

2.2、代码开发

2.2.1、开发前准备

开发-接口权限菜单下,我们可以看到有很多接口用不了,这个对个人开发者很不友好,微信公众号考虑到这个问题,因此有提供测试号供我们使用,里面基本上所有接口可以用,测试号怎么进入呢?请继续往下看

点击菜单“开发-开发者工具”,进入以下页面,主要看以下4个

1.开发者文档:开发前,建议打开看下

2.在线接口调试工具:一般后端同学,会用的到,如果在调试一个接口,发现无论如何都调试不成功,可以在这来试试,会提示调用不成功的原因,如果这里调用成功,那肯定是你的代码出问题了,和微信公众号无关

3.web开发者工具:不建议在这里下载,因为这里不是最新的,也不好用

4.公众平台测试账号:也就是我们上方所提到的测试号,按照步骤点进去,进入测试号管理页面  

  • 测试号信息:我们会看到里面有提供appid、appsecret,后面接口参数会用得到
  • 接口配置信息:开发者和用户不会直接接触,所有的互动都是通过微信服务器进行了一层转发,双方需要验证,那么,如何告知微信服务器该转发给谁,也就是这块填写的原因了

      

  • 测试号,最多关注用户只能100个

   

2.2.2、服务器搭建

1.新建空的文件夹“微信公众号”:(中文文件夹有时会报错,改成英文就可以了)

// 第一步:初始化一个包
npm init

// 第二步:搭建服务器,安装express
npm i express

2.新建文件“微信公众号/day01/01_验证服务器有效性.js”

// 引入express
const express = require('express')

// 创建app应用对象
const app = express()

// 验证服务器的有效性
app.use((req, res, next) => {

})

// 端口监听
app.listen(3000, () => console.log('服务器启动成功了~'))
// 启动服务,验证服务是否启动成功
node "该文件所在路径"

如果启动成功,会展示以下页面

3.使用ngrok内网穿透,将本地端口号开启的服务映射外网跨域访问的一个网址

ngrok下载网址:https://ngrok.com/download

双击打开后,运行ngrok http 3000,即可得到

注意:ngrok打开后,不可关闭,因为关闭后,会重新生成网址

2.2.3、验证服务器的有效性

1.在“微信公众号/day01/01_验证服务器有效性.js”里,完善服务器的相互验证代码;(app.use在我们提交接口配置信息时,会被请求到)

测试方式:去接口配置信息里点提交,查看服务器端打印结果

https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index

// 引入express包
const express = require('express');
const sha1 = require('sha1')

// 创建app应用对象
const app = express();
/* 验证服务器的有效性:
https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Access_Overview.html
1.微信服务器知道开发者服务器是哪个
    -测试号管理页面上填写url开发者服务器地址
        -使用ngrok内网穿透,将本地端口号开启的服务映射外网跨域访问的一个网址
        -ngrok http 3000
    -填写token
        -参与微信签名加密的一个参数
2.开发者服务器-验证消息是否来自于微信服务器
    目的:计算出signature微信签名,和微信传过来的signature进行对比,如果一样,说明消息来自微信服务器,否则,反之
    1.将参与微信签名的三个参数(timestamp、nonce、token),组合在一起,按照字典序排序并组合在一起形成一个数组
    2.将数组里所有参数拼接成一个字符串,进行sha1加密
    3.加密完成就生成了一个signature,和微信发送过来的进行对比。
        -如果一样,说明消息来自微信服务器,返回echostr给微信服务器
        -如果不一样,说明不是微信服务器发送的消息,返回error
*/
// 定义配置对象,对应数据从微信后台里获取
// https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index
const config = {
    token: 'asfeasasfa@fasf',
    appID: 'wxbc97af85400e41b3',
    appsecret: 'a7b44f728f1755017f5fe074ed4f1d7a'
}
// app.use可以接收处理所有消息
app.use((req, res, next) => {
    // 微信服务器提交的参数
    console.log(req.query)
    // {
    //     signature: '78623b6cbf8b9802fcf1cf17b95b0c91e88ff8d8', // 微信的加密签名
    //     echostr: '9008477709201493039', // 微信的随机字符串
    //     timestamp: '1600669743', // 微信的发送请求时间戳
    //     nonce: '282464037' // 微信的随机数字
    //   }
    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 = arr.join('')
    const sha1Str = sha1(str)
    console.log(sha1Str)
    // 3.加密完成就生成了一个signature,和微信发送过来的进行对比
    if (sha1Str === signature) {
        res.send(echostr)
    } else {
        res.end('error')
    }
});
// 监听端口号
app.listen(3000, () => console.log('服务器启动成功了~'));

2.2.4、模块化代码

将2.2.3部分内容进行模块化处理,处理之前的目录(将“01_验证服务器的有效性.js”换成以下目录进行保存如下,方便以下自己查看)见图一,模块后的目录如图二

图一
图二

图二对应文件代码如下:

app.js

// 引入express包
const express = require('express');
const auth = require('./wechat/auth')

// 创建app应用对象
const app = express();

// app.use可以接收处理所有消息
app.use(auth());

// 监听端口号
app.listen(3000, () => console.log('服务器启动成功了~'));

config/index.js

// 配置对象模块
module.exports = {
    token: 'asfeasasfa@fasf',
    appID: 'wxbc97af85400e41b3',
    appsecret: 'a7b44f728f1755017f5fe074ed4f1d7a'
}

wechat/auth.js

const sha1 = require('sha1')
const config = require('../config')

// 验证服务器有效性的模块
module.exports = () => {
    return (req, res, next) => {
        // 微信服务器提交的参数
        console.log(req.query)
        // {
        //     signature: '78623b6cbf8b9802fcf1cf17b95b0c91e88ff8d8', // 微信的加密签名
        //     echostr: '9008477709201493039', // 微信的随机字符串
        //     timestamp: '1600669743', // 微信的发送请求时间戳
        //     nonce: '282464037' // 微信的随机数字
        //   }
        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 = arr.join('')
        const sha1Str = sha1(str)
        console.log(sha1Str)
        // 3.加密完成就生成了一个signature,和微信发送过来的进行对比
        if (sha1Str === signature) {
            res.send(echostr)
        } else {
            res.end('error')
        }
    }
}

2.2.5、获取access_token

参考文档:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html

测试方式:node 'accessToken这个文件',查看打印结果

新建文件wechat/accessToken.js

备注:获取token

内容如下:

/*
参考文档:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html
获取access_token,
    是什么?微信调用接口全局唯一凭据

    特点:
        1.唯一的
        2.有效期为2小时,提前5分钟请求
        3.接口权限,每天最多可请求2000次
    请求地址:
    https请求方式: GET https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET

    设计思路:
        1.首次本地没有,发送请求获取access_token,保存下来(本地文件),不要以变量的形式保存,因为不安全,容易被修改掉
        2.第二次或以后,
            先去本地读取文件,判断它是否过期
                -过期了:重新请求获取access_token,保存下来覆盖之前的文件(保证文件是唯一的)
                -没有过期:直接使用
    整理思路:
        读取本地文件(readAccessToken)
            -本地有文件
                -判断它是否过期(isValidAccessToken)
                    -过期了:重新请求获取access_token(getAccessToken),保存下来覆盖之前的文件(保证文件是唯一的)(saveAccessToken)
                    -没有过期:直接使用
            -本地没有文件
                -发送请求获取access_token(getAccessToken),保存下来(本地文件)(saveAccessToken),直接使用
*/
const axios = require('axios')
// 引入fs模块
const { writeFile, readFile } = require('fs')

const { appID, appsecret } = require('../config')

class Wechat {
    constructor() { }

    // 用来获取access_token
    getAccessToken() {
        // 定义请求的地址
        const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appID}&secret=${appsecret}`
        // 发送请求
        return new Promise((resolve, reject) => {
            axios({ method: 'GET', url }).then(res => {
                // console.log(res)
                /* {
                     access_token: '37_nMmHkl90bCHMdfwrNJQTFu_7PM6cgA6Hy91fHv8s99WPnUrEqULVRDOxiPitUmi_eOF6WTA8mU5Dly7JlWBMd0fBP3mFL8vWaFJx9fYdocvtjlpZEMS6KHPYqnxEY64rH7jedfRFUa079V3iIMJcAGAQUR',
                     expires_in: 7200
                } */
                // 设置access_token的过期时间
                res.data.expires_in = Date.now() + (res.data.expires_in - 5 * 60) * 1000
                resolve(res.data)
            }).catch(err => {
                console.log(err)
                reject('getAccessToken方法出了问题:' + err)
            })
        })
    }
    /* 用来保存access_token
        @param accessToken 要保存的凭据
    */
    saveAccessToken(accessToken) {
        // 将对象转化成json字符串
        accessToken = JSON.stringify(accessToken)
        // 将access_token保存成一个文件
        return new Promise((resolve, reject) => {
            writeFile('./accessToken.txt', accessToken, err => {
                if (!err) {
                    console.log('文件保存成功')
                    resolve()
                } else {
                    reject('saveAccessToken方法出了问题' + err)
                }
            })
        })
    }
    /* 用来读取access_token
    */
    readAccessToken() {
        // 读取本地文件中的access_token
        return new Promise((resolve, reject) => {
            readFile('./accessToken.txt', (err, data) => {
                if (!err) {
                    console.log('文件读取成功')
                    data = JSON.parse(data)
                    resolve(data)
                } else {
                    reject('readAccessToken方法出了问题' + err)
                }
            })
        })
    }
    // 检测access_token是否过期
    isValidAccessToken(data) {
        // 判断传入的参数是否有效
        if (!data && !data.access_token && !data.expires_in) {
            // 代表access_token无效
            return false
        }
        // 检测access_token是否过期:true为没有过期,false为过期
        return Date.now() < data.expires_in
    }

    // 获取没有过期的access_token
    fetchAccessToken() {
        if (this.access_token && this.expires_in && this.isValidAccessToken(this)) {
            // 说明之前保存过access_token,并且它是有效的,直接使用
            return Promise.resolve({
                access_token: this.access_token,
                expires_in: this.expires_in
            })
        }
        // 是fetchAccessToken函数的返回值
        return this.readAccessToken().then(async res => {
            // 本地有文件
            // 判断是否过期
            if (this.isValidAccessToken(res)) {
                // 有效
                return Promise.resolve(res)
            } else {
                // 过期了
                // 发送请求获取token,然后保存下来
                const res = await this.getAccessToken()
                await this.saveAccessToken(res)
                return Promise.resolve(res)
            }
        }).catch(async err => {
            // 本地没有文件
            // 发送请求获取token,然后保存下来
            const res = await this.getAccessToken()
            await this.saveAccessToken(res)
            return Promise.resolve(res)
        }).then(res => {
            // 将access_token挂载到this上
            this.access_token = res.access_token
            this.expires_in = res.expires_in
            // 返回res包装了一层promise对象(此对象为成功的状态)
            // 是this.readAccessToken的返回值
            console.log(res)
            return Promise.resolve(res)
        })
    }
}

// 模拟测试,运行(node 当前文件)
const w = new Wechat()
// w.getAccessToken()
w.fetchAccessToken()

2.2.6、获取用户发送的消息以及简单的文本自动回复

参考文档:https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Passive_user_reply_message.html

测试方式:写完以下代码,检查服务器是否有启动,以及ngrok是否为online状态后,再去微信测试公众号关注并发送消息,查看服务器端打印结果是否正确

更新文件目录如下:

新增文件utils/tool.js

备注:处理当用户发送消息过来时,微信服务器转发给开发者服务器的数据内容

// 工具函数包
const {parseString} = require('xml2js')
module.exports = {
    getUserDataAsync(req) {
        return new Promise((resolve, reject) => {
            let xmlData = ''
            req.on('data', data => {
                // 当流式数据传递过来时,会触发当前事件,会将数据注入到回调函数中
                // console.log(data)
                // 读取的数据是buffer,需要将其转化成字符串
                xmlData += data.toString();
            }).on('end', () => {
                // 当数据接受完毕时,会触发当前
                resolve(xmlData)
            })
        })
    },
    parseMXLAsync(xmlData) {
        return new Promise((resolve, reject) => {
            parseString(xmlData, { trim: true }, (err, data) => {
                if (!err) {
                    resolve(data)
                } else {
                    reject('parseMXLAsync方法出了问题' + err)
                }
            })
        })
    },
    formatMessage(jsData) {
        let message = {}
        // 获取xml对象
        jsData = jsData.xml
        // 判断数据是否是一个对象
        if (typeof jsData === 'object') {
            // 遍历对象
            for (let key in jsData) {
                // 获取属性值
                let value = jsData[key]
                // 过滤掉空数据
                if(Array.isArray(value) && value.length > 0) {
                    // 将合法的数据复制到message上
                    message[key] = value[0]
                }
            }
        }
        return message
    }
}

更新文件wechat/auth.js的内容如下

备注:实现简单的文本回复

const sha1 = require('sha1')
const config = require('../config')
// 引入tool模块
const { getUserDataAsync, parseMXLAsync, formatMessage } = require('../utils/tool')

// 验证服务器有效性的模块
module.exports = () => {
    return async (req, res, next) => {
        // 微信服务器提交的参数
        // console.log(req.query)
        // {
        //     signature: '78623b6cbf8b9802fcf1cf17b95b0c91e88ff8d8', // 微信的加密签名
        //     echostr: '9008477709201493039', // 微信的随机字符串
        //     timestamp: '1600669743', // 微信的发送请求时间戳
        //     nonce: '282464037' // 微信的随机数字
        //   }
        const { signature, echostr, timestamp, nonce } = req.query
        const { token } = config

        // 1.将参与微信签名的三个参数(timestamp、nonce、token),组合在一起,按照字典序排序并组合在一起形成一个数组
        // 2.将数组里所有参数拼接成一个字符串,进行sha1加密
        const sha1Str = sha1([timestamp, nonce, token].sort().join(''))

        /* 
            微信服务器会发送两种类型的消息给开发者服务器
                1.GET请求
                    -验证服务器的有效性
                2.POST请求
                    -微信服务器会将用户发送过来的数据以post请求的方式转发到开发者服务器上
        */
        if (req.method === 'GET') {
            // 3.加密完成就生成了一个signature,和微信发送过来的进行对比
            if (sha1Str === signature) {
                res.send(echostr)
            } else {
                res.end('error')
            }
        } else if (req.method === 'POST') {
            // 微信服务器会将用户发送过来的数据以post请求的方式转发到开发者服务器上
            // 验证消息来自于服务器
            if (sha1Str !== signature) {
                // 说明消息不是来自于微信服务器
                res.end('error')
            }
            // console.log(req.query)
            // 多了个openid,下方内容打印了三次(因为开发者服务器没有给微信服务器响应)
            // {
            //     signature: 'f8444ba41e55030534ad14967e41a139b95a0a21',
            //     timestamp: '1600827336',
            //     nonce: '74253681',
            //     openid: 'ofrsp5hgaUDEL0lD7UdiSIGCTFRI'
            // }

            // 接收请求体中的数据,流式数据
            const xmlData = await getUserDataAsync(req)
            // console.log('--------->', xmlData)
            /* < xml > <ToUserName><![CDATA[gh_23b0183eebe3]]></ToUserName> // 开发者id
             <FromUserName><![CDATA[ofrsp5hgaUDEL0lD7UdiSIGCTFRI]]></FromUserName> // 用户的openid
             <CreateTime>1600830986</CreateTime> // 发送的时间戳
             <MsgType><![CDATA[text]]></MsgType> // 发送的消息类型
             <Content><![CDATA[2]]></Content> // 发送的内容
             <MsgId>22918388913011339</MsgId> // 消息id,微信服务器会默认保存3天用户发送的数据,通过此id三天内就能找到消息数据,三天后就会被销毁
             </xml >
             */
            // 将xml数据解析为js对象
            const jsData = await parseMXLAsync(xmlData)
            // console.log(jsData)
            /*
            {
                xml: {
                    ToUserName: [ 'gh_23b0183eebe3' ],
                    FromUserName: [ 'ofrsp5hgaUDEL0lD7UdiSIGCTFRI' ],
                    CreateTime: [ '1600833248' ],
                    MsgType: [ 'text' ],
                    Content: [ '4' ],
                    MsgId: [ '22918418971436701' ]
                }
                }
            */
            // 格式化数据
            const message = formatMessage(jsData)
            // console.log(message)
            /*{
                CreateTime: '1600840272',
                    Content: '111',
                        MsgId: '22918525143489625'
            }*/

            /* 参考文档:https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Passive_user_reply_message.html
            一旦遇到以下情况,微信都会在公众号会话中,向用户下发系统提示“该公众号暂时无法提供服务,请稍后再试”:
                1、开发者在5秒内未回复任何内容
                2、开发者回复了异常数据,比如JSON数据等
            */
            // 回复文本消息
            let content = '您在说什么,我听不懂?'
            if (message.MsgType === 'text') {
                if (message.Content === '1') {
                    content = '大吉大利,今晚吃鸡'
                } else if (message.Content === '2') {
                    content = '落地成盒'
                } else if (message.Content.match('爱')) { // 半匹配
                    content = '我爱你~'
                } 
            }
            let replyMessage = `<xml>
            <ToUserName><![CDATA[${message.FromUserName}]]></ToUserName>
            <FromUserName><![CDATA[${message.ToUserName}]]></FromUserName>
            <CreateTime>${Date.now()}</CreateTime>
            <MsgType><![CDATA[text]]></MsgType>
            <Content><![CDATA[${content}]]></Content>
            </xml>`

            // 返回响应给微信服务器
            res.send(replyMessage)

            /*// 如果开发者服务器没有返回响应给微信服务器,微信服务器会发送三次请求过来
            res.end('')*/
        } else {
            res.end('error')
        }
    }
}

2.2.7、定义用户回复消息的模板文件,实现完整回复用户消息

参考文档:https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Receiving_event_pushes.html

测试方式:同2.2.6

更新文件目录如下:

新增文件wechat/template.js

备注:按照官方文档,定义好回复模板

/*
用来加工处理最终回复用户消息的模板(xml数据)
*/
module.exports = (options) => {
    let replyMessage = `<xml>
            <ToUserName><![CDATA[${options.FromUserName}]]></ToUserName>
            <FromUserName><![CDATA[${options.ToUserName}]]></FromUserName>
            <CreateTime>${Date.now()}</CreateTime>
            <MsgType><![CDATA[${options.msgType}]]></MsgType>`
    if (options.msgType === 'text') {
        replyMessage += `<Content><![CDATA[${options.content}]]></Content>`
    } else if (options.msgType === 'image') {
        replyMessage += `<Image><MediaId><![CDATA[${options.mediaId}]]></MediaId></Image>`
    } else if (options.msgType === 'voice') {
        replyMessage += `<Voice><MediaId><![CDATA[${options.mediaId}]]></MediaId></Voice>`
    } else if (options.msgType === 'video') {
        replyMessage += `<Video>
            <MediaId><![CDATA[${options.mediaId}]]></MediaId>
            <Title><![CDATA[${options.title}]]></Title>
            <Description><![CDATA[${options.description}]]></Description>
        </Video>`
    } else if (options.msgType === 'music') {
        replyMessage += `<Music>
            <Title><![CDATA[${options.title}]]></Title>
            <Description><![CDATA[${options.description}]]></Description>
            <MusicUrl><![CDATA[${options.musicUrl}]]></MusicUrl>
            <HQMusicUrl><![CDATA[${options.hqMusicUrl}]]></HQMusicUrl>
            <ThumbMediaId><![CDATA[${options.mediaId}]]></ThumbMediaId>
        </Music>`
    } else if (options.msgType === 'news') {
        replyMessage += `<ArticleCount>${options.content.length}</ArticleCount>
        <Articles>`;

        options.content.forEach(item => {
            replyMessage += `<item>
            <Title><![CDATA[${item.title}]]></Title>
            <Description><![CDATA[${item.description}]]></Description>
            <PicUrl><![CDATA[${item.picurl}]]></PicUrl>
            <Url><![CDATA[${item.url}]]></Url>
            </item>`
        })
        
        replyMessage += '</Articles>'
    }
    replyMessage += '</xml>'
    // 最终回复给用户的xml数据
    return replyMessage
}

新增文件wechat/reply.js

备注:定义自动回复内容

/*
处理用户发送的消息类型和内容,决定返回不同的内容给用户
 */

module.exports = (message) => {
    let options = {
        ToUserName: message.FromUserName,
        FromUserName: message.ToUserName,
        CreateTime: Date.now(),
        MsgType: message.MsgType,
    }
    // 回复文本消息
    let content = '您在说什么,我听不懂?'
    if (message.MsgType === 'text') {
        if (message.Content === '1') {
            content = '大吉大利,今晚吃鸡'
        } else if (message.Content === '2') {
            content = '落地成盒'
        } else if (message.Content.match('爱')) { // 半匹配
            content = '我爱你~'
        }
    } else if (message.MsgType === 'image') {
        // 用户发送图片消息
        options.mediaId = message.mediaId
        console.log(message.PicUrl)
    } else if (message.MsgType === 'voice') {
        // 语音
        options.mediaId = message.mediaId
        console.log(message.Recognition)
    } else if (message.MsgType === 'location') {
        // 地理位置
        // 此事件需开启接口权限(接收语音识别结果):https://mp.weixin.qq.com/debug/cgi-bin/sandboxinfo?action=showinfo&t=sandbox/index
        content = `纬度:${message.Location_X} 经度:${message.Location_Y} 缩放大小:${message.Scale} 位置信息:${message.Label}`
    } else if (message.MsgType === 'event') {
        // 接收到事件推送: https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Receiving_event_pushes.html
        if (message.Event === 'subscribe') {
            // 用户订阅事件
            content = '欢迎您的关注'
            if (message.EventKey) {
                content = '用户扫描带参数的二维码关注事件'
            }
        } else if (message.Event === 'unsubscribe') {
            // 取消关注事件
            console.log('被无情的取消关注了')
        } else if (message.Event === 'SCAN') {
            content = '用户已经关注过,再次扫描二维码关注事件'
        } else if (message.Event === 'LOCATION') {
            // 此事件需开启接口权限(获取用户地理位置):https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Receiving_event_pushes.html
            content = `纬度:${message.Latitude} 经度:${message.Longitude} 精度:${message.Precision}`
        } else if (message.Event === 'CLICK') {
            content = `您点击了按钮:${message.EventKey}`
        }
    }
    options.content = content
    return options
}

更新文件wechat/auth.js

备注:处理当用户发送消息时,给用户对应的回复消息

const sha1 = require('sha1')
const config = require('../config')
// 引入tool模块
const { getUserDataAsync, parseMXLAsync, formatMessage } = require('../utils/tool')
const template = require('./template.js')
const reply = require('./reply.js')

// 验证服务器有效性的模块
module.exports = () => {
    return async (req, res, next) => {
        // 微信服务器提交的参数
        // console.log(req.query)
        // {
        //     signature: '78623b6cbf8b9802fcf1cf17b95b0c91e88ff8d8', // 微信的加密签名
        //     echostr: '9008477709201493039', // 微信的随机字符串
        //     timestamp: '1600669743', // 微信的发送请求时间戳
        //     nonce: '282464037' // 微信的随机数字
        //   }
        const { signature, echostr, timestamp, nonce } = req.query
        const { token } = config

        // 1.将参与微信签名的三个参数(timestamp、nonce、token),组合在一起,按照字典序排序并组合在一起形成一个数组
        // 2.将数组里所有参数拼接成一个字符串,进行sha1加密
        const sha1Str = sha1([timestamp, nonce, token].sort().join(''))

        /* 
            微信服务器会发送两种类型的消息给开发者服务器
                1.GET请求
                    -验证服务器的有效性
                2.POST请求
                    -微信服务器会将用户发送过来的数据以post请求的方式转发到开发者服务器上
        */
        if (req.method === 'GET') {
            // 3.加密完成就生成了一个signature,和微信发送过来的进行对比
            if (sha1Str === signature) {
                res.send(echostr)
            } else {
                res.end('error')
            }
        } else if (req.method === 'POST') {
            // 微信服务器会将用户发送过来的数据以post请求的方式转发到开发者服务器上
            // 验证消息来自于服务器
            if (sha1Str !== signature) {
                // 说明消息不是来自于微信服务器
                res.end('error')
            }
            // console.log(req.query)
            // 多了个openid,下方内容打印了三次(因为开发者服务器没有给微信服务器响应)
            // {
            //     signature: 'f8444ba41e55030534ad14967e41a139b95a0a21',
            //     timestamp: '1600827336',
            //     nonce: '74253681',
            //     openid: 'ofrsp5hgaUDEL0lD7UdiSIGCTFRI'
            // }

            // 接收请求体中的数据,流式数据
            const xmlData = await getUserDataAsync(req)
            // console.log('--------->', xmlData)
            /* < xml > <ToUserName><![CDATA[gh_23b0183eebe3]]></ToUserName> // 开发者id
             <FromUserName><![CDATA[ofrsp5hgaUDEL0lD7UdiSIGCTFRI]]></FromUserName> // 用户的openid
             <CreateTime>1600830986</CreateTime> // 发送的时间戳
             <MsgType><![CDATA[text]]></MsgType> // 发送的消息类型
             <Content><![CDATA[2]]></Content> // 发送的内容
             <MsgId>22918388913011339</MsgId> // 消息id,微信服务器会默认保存3天用户发送的数据,通过此id三天内就能找到消息数据,三天后就会被销毁
             </xml >
             */
            // 将xml数据解析为js对象
            const jsData = await parseMXLAsync(xmlData)
            // console.log(jsData)
            /*
            {
                xml: {
                    ToUserName: [ 'gh_23b0183eebe3' ],
                    FromUserName: [ 'ofrsp5hgaUDEL0lD7UdiSIGCTFRI' ],
                    CreateTime: [ '1600833248' ],
                    MsgType: [ 'text' ],
                    Content: [ '4' ],
                    MsgId: [ '22918418971436701' ]
                }
                }
            */
            // 格式化数据
            const message = formatMessage(jsData)
            // console.log(message)
            /*{
                CreateTime: '1600840272',
                    Content: '111',
                        MsgId: '22918525143489625'
            }*/

            /* 参考文档:https://developers.weixin.qq.com/doc/offiaccount/Message_Management/Passive_user_reply_message.html
            一旦遇到以下情况,微信都会在公众号会话中,向用户下发系统提示“该公众号暂时无法提供服务,请稍后再试”:
                1、开发者在5秒内未回复任何内容
                2、开发者回复了异常数据,比如JSON数据等
            */
           const options = reply(message)
            // 最终回复给用户的消息
            const replyMessage = template(options)
            console.log(replyMessage)

            // 返回响应给微信服务器
            res.send(replyMessage)

            /*// 如果开发者服务器没有返回响应给微信服务器,微信服务器会发送三次请求过来
            res.end('')*/
        } else {
            res.end('error')
        }
    }
}

2.2.8、实现自定义菜单

参考文档:https://developers.weixin.qq.com/doc/offiaccount/Custom_Menus/Creating_Custom-Defined_Menu.html

测试方式:直接运行当前文件(node 当前文件)

更新文件目录如下:

新增文件wechat/menu.js

备注:公众号菜单配置

/* 
自定义菜单
https://developers.weixin.qq.com/doc/offiaccount/Custom_Menus/Creating_Custom-Defined_Menu.html
*/

module.exports = {
    "button": [
        {
            "type": "click",
            "name": "戳我啊~",
            "key": "CLICK"
        },
        {
            "name": "菜单二",
            "sub_button": [
                {
                    "type": "view",
                    "name": "跳转链接",
                    "url": "http://www.soso.com/"
                },
                {
                    "type": "scancode_waitmsg",
                    "name": "扫码带提示",
                    "key": "rselfmenu_0_0"
                },
                {
                    "type": "scancode_push",
                    "name": "扫码推事件",
                    "key": "rselfmenu_0_1"
                }
            ]
        },
        {
            "name": "发图",
            "sub_button": [
                {
                    "type": "pic_sysphoto",
                    "name": "系统拍照发图",
                    "key": "rselfmenu_1_0"
                },
                {
                    "type": "pic_photo_or_album",
                    "name": "拍照或者相册发图",
                    "key": "rselfmenu_1_1"
                },
                {
                    "type": "pic_weixin",
                    "name": "微信相册发图",
                    "key": "rselfmenu_1_2"
                },
                {
                    "name": "发送位置",
                    "type": "location_select",
                    "key": "rselfmenu_2_0"
                }
            ]
        }
    ]
}

更新文件wechat/accessToken.js为wechat/wechat.js

备注:主要增加createMenu和deleteMenu方法

/*
实现微信接口的所有功能,运行时,不依赖服务器,直接运行本文件即可
 */

/*
参考文档:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html
获取access_token,
    是什么?微信调用接口全局唯一凭据

    特点:
        1.唯一的
        2.有效期为2小时,提前5分钟请求
        3.接口权限,每天最多可请求2000次
    请求地址:
    https请求方式: GET https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET

    设计思路:
        1.首次本地没有,发送请求获取access_token,保存下来(本地文件),不要以变量的形式保存,因为不安全,容易被修改掉
        2.第二次或以后,
            先去本地读取文件,判断它是否过期
                -过期了:重新请求获取access_token,保存下来覆盖之前的文件(保证文件是唯一的)
                -没有过期:直接使用
    整理思路:
        读取本地文件(readAccessToken)
            -本地有文件
                -判断它是否过期(isValidAccessToken)
                    -过期了:重新请求获取access_token(getAccessToken),保存下来覆盖之前的文件(保证文件是唯一的)(saveAccessToken)
                    -没有过期:直接使用
            -本地没有文件
                -发送请求获取access_token(getAccessToken),保存下来(本地文件)(saveAccessToken),直接使用
*/
const axios = require('axios')
// 引入fs模块
const { writeFile, readFile } = require('fs')

const { appID, appsecret } = require('../config')
const menu = require('./menu')


class Wechat {
    constructor() { }

    // 用来获取access_token
    getAccessToken() {
        // 定义请求的地址
        const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appID}&secret=${appsecret}`
        // 发送请求
        return new Promise((resolve, reject) => {
            axios({ method: 'GET', url }).then(res => {
                /* {
                     access_token: '37_nMmHkl90bCHMdfwrNJQTFu_7PM6cgA6Hy91fHv8s99WPnUrEqULVRDOxiPitUmi_eOF6WTA8mU5Dly7JlWBMd0fBP3mFL8vWaFJx9fYdocvtjlpZEMS6KHPYqnxEY64rH7jedfRFUa079V3iIMJcAGAQUR',
                     expires_in: 7200
                } */
                // 设置access_token的过期时间
                res.data.expires_in = Date.now() + (res.data.expires_in - 5 * 60) * 1000
                resolve(res)
            }).catch(err => {
                reject('getAccessToken方法出了问题:' + err)
            })
        })
    }
    /* 用来保存access_token
        @param accessToken 要保存的凭据
    */
    saveAccessToken(accessToken) {
        // 将对象转化成json字符串
        accessToken = JSON.stringify(accessToken)
        // 将access_token保存成一个文件
        return new Promise((resolve, reject) => {
            writeFile('./accessToken.txt', accessToken, err => {
                if (!err) {
                    console.log('文件保存成功')
                    resolve()
                } else {
                    reject('saveAccessToken方法出了问题' + err)
                }
            })
        })
    }
    /* 用来读取access_token
    */
    readAccessToken() {
        // 读取本地文件中的access_token
        return new Promise((resolve, reject) => {
            readFile('./accessToken.txt', (err, data) => {
                if (!err) {
                    console.log('文件读取成功')
                    data = JSON.parse(data)
                    resolve(data)
                } else {
                    reject('readAccessToken方法出了问题' + err)
                }
            })
        })
    }
    // 检测access_token是否过期
    isValidAccessToken(data) {
        // 判断传入的参数是否有效
        if (!data && !data.access_token && !data.expires_in) {
            // 代表access_token无效
            return false
        }
        // 检测access_token是否过期:true为没有过期,false为过期
        return Date.now() < data.expires_in
    }

    // 获取没有过期的access_token
    fetchAccessToken() {
        if (this.access_token && this.expires_in && this.isValidAccessToken(this)) {
            // 说明之前保存过access_token,并且它是有效的,直接使用
            return Promise.resolve({
                access_token: this.access_token,
                expires_in: this.expires_in
            })
        }
        // 是fetchAccessToken函数的返回值
        return this.readAccessToken().then(async res => {
            // 本地有文件
            // 判断是否过期
            if (this.isValidAccessToken(res)) {
                // 有效
                return Promise.resolve(res)
            } else {
                // 过期了
                // 发送请求获取token,然后保存下来
                const res = await this.getAccessToken()
                await this.saveAccessToken(res)
                return Promise.resolve(res)
            }
        }).catch(async err => {
            // 本地没有文件
            // 发送请求获取token,然后保存下来
            const res = await this.getAccessToken()
            await this.saveAccessToken(res)
            return Promise.resolve(res)
        }).then(res => {
            // 将access_token挂载到this上
            this.access_token = res.access_token
            this.expires_in = res.expires_in
            // 返回res包装了一层promise对象(此对象为成功的状态)
            // 是this.readAccessToken的返回值
            return Promise.resolve(res)
        })
    }
    /* 创建自定义菜单
        @param menu 菜单配置对象
        @return {promise<any>} 
    */
    createMenu(menu) {
        return new Promise(async (resolve, reject) => {
            try {
                // 获取access_token
                const data = await this.fetchAccessToken()
                // 定义请求地址
                const url = `https://api.weixin.qq.com/cgi-bin/menu/create?access_token=${data.access_token}`
                // 发送请求
                const result = await axios({ method: 'POST', url, data: menu })
                resolve(result.data)
            } catch (err) {
                reject('createMenu方法出了问题' + err)
            }
        })    
    }
    // 删除自定义菜单
    deleteMunu() {
        return new Promise(async (resolve, reject) => {
            try {
                const data = await this.fetchAccessToken()
                // 定义请求地址
                const url = `https://api.weixin.qq.com/cgi-bin/menu/delete?access_token=${data.access_token}`
                // 发送请求
                const result = await axios.get(url)
                resolve(result.data)
            } catch (err) {
                reject('deleteMunu方法出了问题' + err)
            }
        })
    }
}

// 模拟测试token,运行(node 当前文件)
// const w = new Wechat()
// w.getAccessToken()
// w.fetchAccessToken()

// 模拟测试自定义创建菜单
(async () => {
    const w = new Wechat()
    // 删除之前定义的菜单
    let result = await w.deleteMunu()
    // 创建新的菜单
    result = await w.createMenu(menu)
})()

2.2.9、微信网页开发预热

测试方式:完成下方代码后,在网页上打开“localhost:3000/search”或"外网/search"

更新目录如下:

新增文件views/search.ejs

备注:服务器将要渲染的页面,和html写法一致

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>search</title>
</head>
<body>
    <h1>这是一个搜索页面</h1>
</body>
</html>

更新文件app.js

备注:使用ejs之前,需要先安装npm i ejs;将写好的页面,渲染好给用户

// 引入express包
const express = require('express');
const auth = require('./wechat/auth')

// 创建app应用对象
const app = express();
// 配置模板资源目录
app.set('views', './views')
// 配置模板引擎-先npm i ejs
app.set('view engine', 'ejs')
// 页面路由
app.get('/search', (req, res) => {
    // 渲染页面,将渲染好的页面返回给用户
    res.render('search')
})

// app.use可以接收处理所有消息
app.use(auth());

// 监听端口号
app.listen(3000, () => console.log('服务器启动成功了~'));

2.2.10、微信网页开发之获取ticket

参考文档:https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html(附录1-JS-SDK使用权限签名算法)

测试方式:直接运行当前文件(node 当前文件wechat.js)

更新目录如下:

新增文件utils/api.js

备注:将微信所提供的接口提取到api文件中

// 地址前缀
const prefix = 'https://api.weixin.qq.com/cgi-bin/'

module.exports = {
    accessToken: `${prefix}token?grant_type=client_credential`,
    ticket: `${prefix}ticket/getticket?type=jsapi`,
    menu: {
        create: `${prefix}menu/create?`,
        delete: `${prefix}menu/delete?`
    }
}

更新文件utils/tool.js

备注:增加writeFileAsync和readFileAsync方法

// 工具函数包
const { parseString } = require('xml2js')
// 引入fs模块
const { writeFile, readFile } = require('fs')
// 引入path模块
const {resolve} = require('path')

module.exports = {
    getUserDataAsync(req) {
        return new Promise((resolve, reject) => {
            let xmlData = ''
            req.on('data', data => {
                // 当流式数据传递过来时,会触发当前事件,会将数据注入到回调函数中
                // console.log(data)
                // 读取的数据是buffer,需要将其转化成字符串
                xmlData += data.toString();
            }).on('end', () => {
                // 当数据接受完毕时,会触发当前
                resolve(xmlData)
            })
        })
    },
    parseMXLAsync(xmlData) {
        return new Promise((resolve, reject) => {
            parseString(xmlData, { trim: true }, (err, data) => {
                if (!err) {
                    resolve(data)
                } else {
                    reject('parseMXLAsync方法出了问题' + err)
                }
            })
        })
    },
    formatMessage(jsData) {
        let message = {}
        // 获取xml对象
        jsData = jsData.xml
        // 判断数据是否是一个对象
        if (typeof jsData === 'object') {
            // 遍历对象
            for (let key in jsData) {
                // 获取属性值
                let value = jsData[key]
                // 过滤掉空数据
                if(Array.isArray(value) && value.length > 0) {
                    // 将合法的数据复制到message上
                    message[key] = value[0]
                }
            }
        }
        return message
    },
    writeFileAsync(data, fileName) {
        // 将对象转化成json字符串
        data = JSON.stringify(data)
        // 改为绝对路径,使每次保存文件都在当前文件夹下,免得每次保存文件,文件路径改变
        const filePath = resolve(__dirname, fileName)
        return new Promise((resolve, reject) => {
            writeFile(filePath, data, err => {
                if (!err) {
                    console.log(fileName + '文件保存成功')
                    resolve()
                } else {
                    reject('writeFileAsync方法出了问题' + err)
                }
            })
        })
    },
    // 读取本地文件
    readFileAsync(fileName) {
        const filePath = resolve(__dirname, fileName)
        return new Promise((resolve, reject) => {
            readFile(filePath, (err, data) => {
                if (!err) {
                    console.log('文件读取成功')
                    data = JSON.parse(data)
                    resolve(data)
                } else {
                    reject('readFileAsync方法出了问题' + err)
                }
            })
        })
    }
}

更新文件wechat/wechat.js

备注:将api文件对应替换,同时复制token的5个方法,改写成ticket的方法

/*
实现微信接口的所有功能,运行时,不依赖服务器,直接运行本文件即可
 */

/*
参考文档:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html
获取access_token,
    是什么?微信调用接口全局唯一凭据

    特点:
        1.唯一的
        2.有效期为2小时,提前5分钟请求
        3.接口权限,每天最多可请求2000次
    请求地址:
    https请求方式: GET https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET

    设计思路:
        1.首次本地没有,发送请求获取access_token,保存下来(本地文件),不要以变量的形式保存,因为不安全,容易被修改掉
        2.第二次或以后,
            先去本地读取文件,判断它是否过期
                -过期了:重新请求获取access_token,保存下来覆盖之前的文件(保证文件是唯一的)
                -没有过期:直接使用
    整理思路:
        读取本地文件(readAccessToken)
            -本地有文件
                -判断它是否过期(isValidAccessToken)
                    -过期了:重新请求获取access_token(getAccessToken),保存下来覆盖之前的文件(保证文件是唯一的)(saveAccessToken)
                    -没有过期:直接使用
            -本地没有文件
                -发送请求获取access_token(getAccessToken),保存下来(本地文件)(saveAccessToken),直接使用
*/
const axios = require('axios')

const { appID, appsecret } = require('../config')
const menu = require('./menu')
const api = require('../utils/api')
const { writeFileAsync, readFileAsync } = require('../utils/tool')

class Wechat {
    constructor() { }

    // 用来获取access_token
    getAccessToken() {
        // 定义请求的地址
        const url = `${api.accessToken}&appid=${appID}&secret=${appsecret}`
        // 发送请求
        return new Promise((resolve, reject) => {
            axios({ method: 'GET', url }).then(res => {
                /* {
                     access_token: '37_nMmHkl90bCHMdfwrNJQTFu_7PM6cgA6Hy91fHv8s99WPnUrEqULVRDOxiPitUmi_eOF6WTA8mU5Dly7JlWBMd0fBP3mFL8vWaFJx9fYdocvtjlpZEMS6KHPYqnxEY64rH7jedfRFUa079V3iIMJcAGAQUR',
                     expires_in: 7200
                } */
                // 设置access_token的过期时间
                res.data.expires_in = Date.now() + (res.data.expires_in - 5 * 60) * 1000
                resolve(res.data)
            }).catch(err => {
                reject('getAccessToken方法出了问题:' + err)
            })
        })
    }
    /* 用来保存access_token
        @param accessToken 要保存的凭据
    */
    saveAccessToken(accessToken) {
        return writeFileAsync(accessToken, 'access_token.txt')
    }
    /* 用来读取access_token
    */
    readAccessToken() {
        // 读取本地文件中的access_token
        return readFileAsync('access_token.txt')
    }
    // 检测access_token是否过期
    isValidAccessToken(data) {
        // 判断传入的参数是否有效
        if (!data && !data.access_token && !data.expires_in) {
            // 代表access_token无效
            return false
        }
        // 检测access_token是否过期:true为没有过期,false为过期
        return Date.now() < data.expires_in
    }
    // 获取没有过期的access_token
    fetchAccessToken() {
        if (this.access_token && this.expires_in && this.isValidAccessToken(this)) {
            // 说明之前保存过access_token,并且它是有效的,直接使用
            return Promise.resolve({
                access_token: this.access_token,
                expires_in: this.expires_in
            })
        }
        // 是fetchAccessToken函数的返回值
        return this.readAccessToken().then(async res => {
            // 本地有文件
            // 判断是否过期
            if (this.isValidAccessToken(res)) {
                // 有效
                return Promise.resolve(res)
            } else {
                // 过期了
                // 发送请求获取token,然后保存下来
                const res = await this.getAccessToken()
                await this.saveAccessToken(res)
                return Promise.resolve(res)
            }
        }).catch(async err => {
            // 本地没有文件
            // 发送请求获取token,然后保存下来
            const res = await this.getAccessToken()
            await this.saveAccessToken(res)
            return Promise.resolve(res)
        }).then(res => {
            // 将access_token挂载到this上
            this.access_token = res.access_token
            this.expires_in = res.expires_in
            // 返回res包装了一层promise对象(此对象为成功的状态)
            // 是this.readAccessToken的返回值
            return Promise.resolve(res)
        })
    }

    /* 创建自定义菜单
        @param menu 菜单配置对象
        @return {promise<any>} 
    */
    createMenu(menu) {
        return new Promise(async (resolve, reject) => {
            try {
                // 获取access_token
                const data = await this.fetchAccessToken()
                // 定义请求地址
                const url = `${api.menu.create}access_token=${data.access_token}`
                // 发送请求
                const result = await axios({ method: 'POST', url, data: menu })
                resolve(result.data)
            } catch (err) {
                reject('createMenu方法出了问题' + err)
            }
        })    
    }
    // 删除自定义菜单
    deleteMunu() {
        return new Promise(async (resolve, reject) => {
            try {
                const data = await this.fetchAccessToken()
                // 定义请求地址
                const url = `${api.menu.delete}access_token=${data.access_token}`
                // 发送请求
                const result = await axios.get(url)
                resolve(result.data)
            } catch (err) {
                reject('deleteMunu方法出了问题' + err)
            }
        })
    }

    // 用来获取jsapi_ticket
    getTicket() {
        // 发送请求
        return new Promise(async (resolve, reject) => {
            // 获取token值
            const data = await this.fetchAccessToken()
            // 定义请求的地址
            const url = `${api.ticket}&access_token=${data.access_token}`
            axios({ method: 'GET', url }).then(res => {
                resolve({
                    ticket: res.data.ticket,
                    // 设置过期时间
                    expires_in: Date.now() + (res.data.expires_in - 5 * 60) * 1000
                })
            }).catch(err => {
                reject('getTicket方法出了问题:' + err)
            })
        })
    }
    /* 用来保存ticket
        @param ticket 要保存的凭据
    */
    saveTicket(ticket) {
        return writeFileAsync(ticket, 'ticket.txt')
    }
    /* 用来读取saveTicket
    */
    readTicket() {
        // 读取本地文件中的Ticket
        return readFileAsync('ticket.txt')
    }
    // 检测ticket是否过期
    isValidTicket(data) {
        // 判断传入的参数是否有效
        if (!data && !data.ticket && !data.expires_in) {
            // 代表ticket无效
            return false
        }
        // 检测ticket是否过期:true为没有过期,false为过期
        return Date.now() < data.expires_in
    }
    // 获取没有过期的ticket
    fetchTicket() {
        if (this.ticket && this.ticket_expires_in && this.isValidTicket(this)) {
            // 说明之前保存过ticket,并且它是有效的,直接使用
            return Promise.resolve({
                ticket: this.ticket,
                expires_in: this.ticket_expires_in
            })
        }
        // 是fetchTicket函数的返回值
        return this.readTicket().then(async res => {
            // 本地有文件
            // 判断是否过期
            if (this.isValidTicket(res)) {
                // 有效
                return Promise.resolve(res)
            } else {
                // 过期了
                // 发送请求获取ticket,然后保存下来
                const res = await this.getTicket()
                await this.saveTicket(res)
                return Promise.resolve(res)
            }
        }).catch(async err => {
            // 本地没有文件
            // 发送请求获取ticket,然后保存下来
            const res = await this.getTicket()
            await this.saveTicket(res)
            return Promise.resolve(res)
        }).then(res => {
            // 将ticket挂载到this上
            this.ticket = res.ticket
            this.ticket_expires_in = res.ticket_expires_in
            // 返回res包装了一层promise对象(此对象为成功的状态)
            // 是this.readTicket的返回值
            return Promise.resolve(res)
        })
    }
}

// 模拟测试token,运行(node 当前文件)
// const w = new Wechat()
// w.getAccessToken()
// w.fetchAccessToken()

// 模拟测试自定义创建菜单
(async () => {
    const w = new Wechat()
    // // 删除之前定义的菜单
    // let result = await w.deleteMunu()
    // // 创建新的菜单
    // result = await w.createMenu(menu)
    const data = await w.fetchTicket()
    console.log('------------------------>', data)
})()

2.2.11、微信网页开发之验证微信js-sdk

参考网址:https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html

注意:应该到app.js文件所在目录下启动服务:node app.js,否则启动服务正常,但是页面渲染会报错,因为view文件路径会有问题(可以使用绝对路径,引入path来解决,这里不作多余介绍)

更新目录如下:

更新文件wechat/wechat.js

备注:将Wechat实例导出

/*
实现微信接口的所有功能,运行时,不依赖服务器,直接运行本文件即可
 */

/*
参考文档:https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html
获取access_token,
    是什么?微信调用接口全局唯一凭据

    特点:
        1.唯一的
        2.有效期为2小时,提前5分钟请求
        3.接口权限,每天最多可请求2000次
    请求地址:
    https请求方式: GET https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=APPID&secret=APPSECRET

    设计思路:
        1.首次本地没有,发送请求获取access_token,保存下来(本地文件),不要以变量的形式保存,因为不安全,容易被修改掉
        2.第二次或以后,
            先去本地读取文件,判断它是否过期
                -过期了:重新请求获取access_token,保存下来覆盖之前的文件(保证文件是唯一的)
                -没有过期:直接使用
    整理思路:
        读取本地文件(readAccessToken)
            -本地有文件
                -判断它是否过期(isValidAccessToken)
                    -过期了:重新请求获取access_token(getAccessToken),保存下来覆盖之前的文件(保证文件是唯一的)(saveAccessToken)
                    -没有过期:直接使用
            -本地没有文件
                -发送请求获取access_token(getAccessToken),保存下来(本地文件)(saveAccessToken),直接使用
*/
const axios = require('axios')

const { appID, appsecret } = require('../config')
const menu = require('./menu')
const api = require('../utils/api')
const { writeFileAsync, readFileAsync } = require('../utils/tool')

class Wechat {
    constructor() { }

    // 用来获取access_token
    getAccessToken() {
        // 定义请求的地址
        const url = `${api.accessToken}&appid=${appID}&secret=${appsecret}`
        // 发送请求
        return new Promise((resolve, reject) => {
            axios({ method: 'GET', url }).then(res => {
                /* {
                     access_token: '37_nMmHkl90bCHMdfwrNJQTFu_7PM6cgA6Hy91fHv8s99WPnUrEqULVRDOxiPitUmi_eOF6WTA8mU5Dly7JlWBMd0fBP3mFL8vWaFJx9fYdocvtjlpZEMS6KHPYqnxEY64rH7jedfRFUa079V3iIMJcAGAQUR',
                     expires_in: 7200
                } */
                // 设置access_token的过期时间
                res.data.expires_in = Date.now() + (res.data.expires_in - 5 * 60) * 1000
                resolve(res.data)
            }).catch(err => {
                reject('getAccessToken方法出了问题:' + err)
            })
        })
    }
    /* 用来保存access_token
        @param accessToken 要保存的凭据
    */
    saveAccessToken(accessToken) {
        return writeFileAsync(accessToken, 'access_token.txt')
    }
    /* 用来读取access_token
    */
    readAccessToken() {
        // 读取本地文件中的access_token
        return readFileAsync('access_token.txt')
    }
    // 检测access_token是否过期
    isValidAccessToken(data) {
        // 判断传入的参数是否有效
        if (!data && !data.access_token && !data.expires_in) {
            // 代表access_token无效
            return false
        }
        // 检测access_token是否过期:true为没有过期,false为过期
        return Date.now() < data.expires_in
    }
    // 获取没有过期的access_token
    fetchAccessToken() {
        if (this.access_token && this.expires_in && this.isValidAccessToken(this)) {
            // 说明之前保存过access_token,并且它是有效的,直接使用
            return Promise.resolve({
                access_token: this.access_token,
                expires_in: this.expires_in
            })
        }
        // 是fetchAccessToken函数的返回值
        return this.readAccessToken().then(async res => {
            // 本地有文件
            // 判断是否过期
            if (this.isValidAccessToken(res)) {
                // 有效
                return Promise.resolve(res)
            } else {
                // 过期了
                // 发送请求获取token,然后保存下来
                const res = await this.getAccessToken()
                await this.saveAccessToken(res)
                return Promise.resolve(res)
            }
        }).catch(async err => {
            // 本地没有文件
            // 发送请求获取token,然后保存下来
            const res = await this.getAccessToken()
            await this.saveAccessToken(res)
            return Promise.resolve(res)
        }).then(res => {
            // 将access_token挂载到this上
            this.access_token = res.access_token
            this.expires_in = res.expires_in
            // 返回res包装了一层promise对象(此对象为成功的状态)
            // 是this.readAccessToken的返回值
            return Promise.resolve(res)
        })
    }

    /* 创建自定义菜单
        @param menu 菜单配置对象
        @return {promise<any>} 
    */
    createMenu(menu) {
        return new Promise(async (resolve, reject) => {
            try {
                // 获取access_token
                const data = await this.fetchAccessToken()
                // 定义请求地址
                const url = `${api.menu.create}access_token=${data.access_token}`
                // 发送请求
                const result = await axios({ method: 'POST', url, data: menu })
                resolve(result.data)
            } catch (err) {
                reject('createMenu方法出了问题' + err)
            }
        })    
    }
    // 删除自定义菜单
    deleteMunu() {
        return new Promise(async (resolve, reject) => {
            try {
                const data = await this.fetchAccessToken()
                // 定义请求地址
                const url = `${api.menu.delete}access_token=${data.access_token}`
                // 发送请求
                const result = await axios.get(url)
                resolve(result.data)
            } catch (err) {
                reject('deleteMunu方法出了问题' + err)
            }
        })
    }

    // 用来获取jsapi_ticket
    getTicket() {
        // 发送请求
        return new Promise(async (resolve, reject) => {
            // 获取token值
            const data = await this.fetchAccessToken()
            // 定义请求的地址
            const url = `${api.ticket}&access_token=${data.access_token}`
            axios({ method: 'GET', url }).then(res => {
                resolve({
                    ticket: res.data.ticket,
                    // 设置过期时间
                    expires_in: Date.now() + (res.data.expires_in - 5 * 60) * 1000
                })
            }).catch(err => {
                reject('getTicket方法出了问题:' + err)
            })
        })
    }
    /* 用来保存ticket
        @param ticket 要保存的凭据
    */
    saveTicket(ticket) {
        return writeFileAsync(ticket, 'ticket.txt')
    }
    /* 用来读取saveTicket
    */
    readTicket() {
        // 读取本地文件中的Ticket
        return readFileAsync('ticket.txt')
    }
    // 检测ticket是否过期
    isValidTicket(data) {
        // 判断传入的参数是否有效
        if (!data && !data.ticket && !data.expires_in) {
            // 代表ticket无效
            return false
        }
        // 检测ticket是否过期:true为没有过期,false为过期
        return Date.now() < data.expires_in
    }
    // 获取没有过期的ticket
    fetchTicket() {
        if (this.ticket && this.ticket_expires_in && this.isValidTicket(this)) {
            // 说明之前保存过ticket,并且它是有效的,直接使用
            return Promise.resolve({
                ticket: this.ticket,
                expires_in: this.ticket_expires_in
            })
        }
        // 是fetchTicket函数的返回值
        return this.readTicket().then(async res => {
            // 本地有文件
            // 判断是否过期
            if (this.isValidTicket(res)) {
                // 有效
                return Promise.resolve(res)
            } else {
                // 过期了
                // 发送请求获取ticket,然后保存下来
                const res = await this.getTicket()
                await this.saveTicket(res)
                return Promise.resolve(res)
            }
        }).catch(async err => {
            // 本地没有文件
            // 发送请求获取ticket,然后保存下来
            const res = await this.getTicket()
            await this.saveTicket(res)
            return Promise.resolve(res)
        }).then(res => {
            // 将ticket挂载到this上
            this.ticket = res.ticket
            this.ticket_expires_in = res.ticket_expires_in
            // 返回res包装了一层promise对象(此对象为成功的状态)
            // 是this.readTicket的返回值
            return Promise.resolve(res)
        })
    }
}

// 模拟测试token,运行(node 当前文件)
// const w = new Wechat()
// w.getAccessToken()
// w.fetchAccessToken()

// 模拟测试自定义创建菜单
// (async () => {
//     const w = new Wechat()
//     // // 删除之前定义的菜单
//     // let result = await w.deleteMunu()
//     // // 创建新的菜单
//     // result = await w.createMenu(menu)
//     const data = await w.fetchTicket()
//     console.log('------------------------>', data)
// })()

module.exports = Wechat

更新文件config/index.js

备注:增加url配置

// 配置对象模块
module.exports = {
    token: 'asfeasasfa@fasf',
    appID: 'wxbc97af85400e41b3',
    appsecret: 'a7b44f728f1755017f5fe074ed4f1d7a',
    url: 'http://0abc074e7255.ngrok.io' // 服务器地址
}

更新文件app.js

参考链接:https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html#62

备注:根据ticket票据,生成签名signature

// 引入express包
const express = require('express');
const auth = require('./wechat/auth')
const Wechat = require('./wechat/wechat')
const {url} = require('./config')

const sha1 = require('sha1')

// 创建app应用对象
const app = express();
// 配置模板资源目录
app.set('views', './views')
// 配置模板引擎-先npm i ejs
app.set('view engine', 'ejs')

// 创建wechat实例对象,以获得实例中的方法
const wechatApi = new Wechat()

// 页面路由
app.get('/search', async (req, res) => {
    /*生成js-sdk使用的签名:
        1.组合参与签名的四个参数,jsapi_ticket(临时票据)、noncestr(随机字符串)、timestamp(时间戳)、url(当前网页的URL)
        2.将其进行字典序排序,以'&'拼接在一起
        3.进行sha1加密,最终生成signature
     */
    // 获取票据
    const {ticket} = await wechatApi.fetchTicket()
    // 获取随机字符串
    const noncestr = Math.random().toString().split('.')[1]
    // 获取时间戳
    const timestamp = Date.now()
    // 1.组合参与签名的四个参数
    const arr = [
        `jsapi_ticket=${ticket}`,
        `noncestr=${noncestr}`,
        `timestamp=${timestamp}`,
        `url=${url}/search`
    ]
    // 2.将其进行字典序排序,以'&'拼接在一起
    const str = arr.sort().join('&')
    // 3.进行sha1加密,最终生成signature
    const signature = sha1(str)

    // 渲染页面,将渲染好的页面返回给用户
    res.render('search', { signature, noncestr, timestamp })
})

// app.use可以接收处理所有消息
app.use(auth());

// 监听端口号
app.listen(3000, () => console.log('服务器启动成功了~'));

更新文件views/search.ejs

参考链接:https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html#2

备注:验证微信js-sdk(所有需要使用JS-SDK的【页面】必须先注入配置信息,否则将无法调用)

<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>search</title>
</head>
<body>
    <h1 id="search">点击录音搜索电影</h1>
    <ul id="list">
    </ul>
    <h1 id="share">分享到微博空间</h1>
</body>
<script type="text/javascript" src="http://res.wx.qq.com/open/js/jweixin-1.6.0.js"></script>
<!-- dom操作,包含jQuery -->
<script src="https://cdn.bootcdn.net/ajax/libs/zepto/0.0.1/zepto.min.js"></script>
<script type="text/javascript">
/*https://developers.weixin.qq.com/doc/offiaccount/OA_Web_Apps/JS-SDK.html
    1.绑定域名
        -在接口测试号页面上填写js安全域名接口(同接口配置信息,但是不要写协议名http/https)
    2.引入js文件
    3.通过config接口注入权限验证配置
*/
    wx.config({
        debug: true, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。
        appId: 'wxbc97af85400e41b3', // 必填,公众号的唯一标识
        timestamp: '<%= timestamp %>', // 必填,生成签名的时间戳
        nonceStr: '<%= noncestr %>', // 必填,生成签名的随机串
        signature: '<%= signature %>',// 必填,签名
        jsApiList: [
            'onMenuShareQQ',
            'onMenuShareWeibo',
            'startRecord',
            'stopRecord',
            'translateVoice'
        ] // 必填,需要使用的JS接口列表
    });
    // 微信SDK验证通过的回调函数
    wx.ready(function () {
        // config信息验证后会执行ready方法,所有接口调用都必须在config接口获得结果之后,
        // config是一个客户端的异步操作,所以如果需要在页面加载时就调用相关接口,则须把相关接口放在ready函数中调用来确保正确执行。
        // 对于用户触发时才调用的接口,则可以直接调用,不需要放在ready函数中。
        
        // 验证接口是否有权限
        // wx.checkJsApi({
        //     jsApiList: ['onMenuShareQQ',
        //         'onMenuShareWeibo',
        //         'startRecord',
        //         'stopRecord',
        //         'translateVoice'], // 需要检测的JS接口列表,所有JS接口列表见附录2,
        //     success: function (res) {
        //         // 以键值对的形式返回,可用的api值true,不可用为false
        //         // 如:{"checkResult":{"chooseImage":true},"errMsg":"checkJsApi:ok"}
        //         console.log(res)
        //     },
        //     fail: function (err) {
        //         console.log(err)
        //     }
        // });
        
        // 设置标志位,是否在录音中
        var isRecord = false;
        // 语音识别的功能 
        $('#search').tap(function () {
            if (!isRecord) {
                // 开始录音
                wx.startRecord();
                isRecord = true
            } else {
                // 结束录音
                wx.stopRecord({
                    success: function (res) {
                        // 结束录音后,会自动上传到微信服务器中,微信服务器会返回一个id给开发者使用
                        var localId = res.localId;
                        // wx.translateVoice({
                        //     localId, // 需要识别的音频的本地Id,由录音相关接口获得
                        //     isShowProgressTips: 1, // 默认为1,显示进度提示
                        //     success: function (res) {
                        //         alert(res.translateResult); // 语音识别的结果
                        //     }
                        // });
                        // 模拟结果 -- 西虹市首富
                        // 去豆瓣请求响应的电影数据
                        // http://api.douban.com/v2/movie/search?q={text}
                        // 可以根据api将对应数据渲染到页面上,这里就不一一举例了,此api已过期,模拟数据,不用看
                        // var url = 'http://api.douban.com/v2/movie/search?q=西虹市首富'
                        // var html = ''
                        // $.getJSON(url, function(data) {
                        //     var {subjects} = data
                        //     subjects.forEach(function(item) {
                        //         html ='<h2>' + item.title + '</h2>' +
                        //               '< p > 评分:' + item.average + '</p >'
                        //     })
                        //     $('#list').html(html)
                        // })
                        isRecord = false
                    }
                });
            }
            
        })

        // 分享到微博空间
        $('#share').tap(function() {
            wx.onMenuShareWeibo({
                title: '分享标题', // 分享标题
                desc: '分享描述', // 分享描述
                link: '分享链接', // 分享链接
                imgUrl: '分享图标', // 分享图标
                success: function () {
                    // 用户确认分享后执行的回调函数
                    alert('分享成功')
                },
                cancel: function () {
                    // 用户取消分享后执行的回调函数
                    alert('分享取消')
                }
            })
        })
    });
    // 微信SDK验证失败的回调函数
    wx.error(function (res) {
        // config信息验证失败会执行error函数,如签名过期导致验证失败,具体错误信息可以打开config的debug模式查看,也可以在返回的res参数中查看,对于SPA可以在这里更新签名。
    });
</script>
</html>

2.2.12、优化项目结构

更新目录如下:

1.将用户消息管理部分抽取出来单独放在一个文件夹reply下

  • 将wechat/auth.js文件名改为reply/index.js
  • 将wechat文件夹下reply.js和template.js文件移到reply文件夹下
  • 更新app.js文件中auth.js的引入路径

2.将路由抽取出来

新增文件router/index.js

const express = require('express')
const sha1 = require('sha1')
const Wechat = require('../wechat/wechat')
const { url } = require('../config')

// 获取router
const Router = express.Router;
// 创建路由器对象
const router = new Router()

// 创建wechat实例对象,以获得实例中的方法
const wechatApi = new Wechat()
// 页面路由
router.get('/search', async (req, res) => {
    /*生成js-sdk使用的签名:
        1.组合参与签名的四个参数,jsapi_ticket(临时票据)、noncestr(随机字符串)、timestamp(时间戳)、url(当前网页的URL)
        2.将其进行字典序排序,以'&'拼接在一起
        3.进行sha1加密,最终生成signature
     */
    // 获取票据
    const { ticket } = await wechatApi.fetchTicket()
    // 获取随机字符串
    const noncestr = Math.random().toString().split('.')[1]
    // 获取时间戳
    const timestamp = Date.now()
    // 1.组合参与签名的四个参数
    const arr = [
        `jsapi_ticket=${ticket}`,
        `noncestr=${noncestr}`,
        `timestamp=${timestamp}`,
        `url=${url}/search`
    ]
    // 2.将其进行字典序排序,以'&'拼接在一起
    const str = arr.sort().join('&')
    // 3.进行sha1加密,最终生成signature
    const signature = sha1(str)

    // 渲染页面,将渲染好的页面返回给用户
    res.render('search', { signature, noncestr, timestamp })
})

module.exports = router

更新文件app.js

// 引入express包
const express = require('express');
const reply = require('./reply')
const router = require('../router')


// 创建app应用对象
const app = express();
// 配置模板资源目录
app.set('views', './views')
// 配置模板引擎-先npm i ejs
app.set('view engine', 'ejs')

// app.use可以接收处理所有消息
app.use(reply());
// 应用路由器
app.use(router);

// 监听端口号
app.listen(3000, () => console.log('服务器启动成功了~'));

更新wechat.js/menu.js

/* 
自定义菜单
https://developers.weixin.qq.com/doc/offiaccount/Custom_Menus/Creating_Custom-Defined_Menu.html
*/
const {url} = require('../config')
module.exports = {
    "button": [
        {
            "type": "view",
            "name": "硅谷电影",
            "url": `${url}/movie`
        },
        {
            "type": "view",
            "name": "语音识别页面",
            "url": `${url}/search`
        },
        {
            "name": "戳我",
            "sub_button": [
                {
                    "type": "view",
                    "name": "官网",
                    "url": 'http://www.atguigu.com'
                },
                {
                    "type": "click",
                    "name": "帮助[玫瑰]",
                    "key": "help"
                }
            ]
        }
    ]
}

测试路由方法:进入wechat文件夹下,运行(运行前,需先打开创建菜单的方法)

node wechat.js

2.2.13、上传临时素材

持续更新中。。。

 

相关代码资料:

链接: https://pan.baidu.com/s/1N0UODZYzN6BfLpGxANfIcw 提取码: ud7n

 

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值