背景

网页上的内容分享至朋友圈、微信好友时总是显示干巴巴的一个链接地址,对于预期的打开率、转化率提升显然很不利,也弱化了潜在的分享动机。于是便想着能否自定义标题、描述、题图(如Logo)?

了解到微信网页开发JS-SDK便是需要的解决方案,于是就此动手开始踩坑之旅……

基础步骤

  1. 绑定域名
  2. 引入JS文件
  3. 通过config接口注入权限验证配置
  4. 通过ready接口处理成功验证
  5. 通过error接口处理失败验证

在此不再赘述,详见微信开放文档官方说明。

获取AppID和AppSecret

在公众号后台→开发→基本配置获取开发者ID(AppID)和开发者密码(AppSecret),并设置IP白名单(包括线上服务器IP和本地开发调试IP)。

获取access_token

因为request包提示deprecated,因此不得已需寻找替代方案。Node.js原生的http模块无甚封装,调用实在麻烦,于是转而使用request官方issue中提到的一轻量包:node-fetch

另,考虑到ES7(8)的支持和应用已经很成熟,想想自己也应与时俱进,于是在expressjs中也试着采用async方式。

代码如下:

javascriptCopy code
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
async fetchAccessToken() { const url = 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential'; const data = await fetch(`${url}&appid=${credentials.wxMpAppID}&secret=${credentials.wxMpAppSecret}`) .then((res) => res.json()); if (data.access_token) { return data.access_token; } logger.error(formatOpLog({ fn: 'fetchAccessToken', msg: data.errmsg || 'Fetch access_token fail.', data: { code: data.errcode || STATUS_CODES.UNKNOWN_ERROR } })); throw new Error('Fetch access_token fail.'); }
async fetchAccessToken() { const url = 'https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential'; const data = await fetch(`${url}&appid=${credentials.wxMpAppID}&secret=${credentials.wxMpAppSecret}`) .then((res) => res.json()); if (data.access_token) { return data.access_token; } logger.error(formatOpLog({ fn: 'fetchAccessToken', msg: data.errmsg || 'Fetch access_token fail.', data: { code: data.errcode || STATUS_CODES.UNKNOWN_ERROR } })); throw new Error('Fetch access_token fail.'); }

获取jsapi_ticket

同理,使用node-fetch发请求获取jsapi_ticket,代码如下:

javascriptCopy code
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
async fetchJsApiTicket(param) { const url = 'https://api.weixin.qq.com/cgi-bin/ticket/getticket?type=jsapi'; const data = await fetch(`${url}&access_token=${param.token}`) .then((res) => res.json()); if (data.ticket) { return data.ticket; } logger.error(formatOpLog({ fn: 'fetchJsApiTicket', msg: data.errmsg || 'Fetch jsapi_ticket fail.', data: { code: data.errcode || STATUS_CODES.UNKNOWN_ERROR } })); throw new Error('Fetch jsapi_ticket fail.'); }
async fetchJsApiTicket(param) { const url = 'https://api.weixin.qq.com/cgi-bin/ticket/getticket?type=jsapi'; const data = await fetch(`${url}&access_token=${param.token}`) .then((res) => res.json()); if (data.ticket) { return data.ticket; } logger.error(formatOpLog({ fn: 'fetchJsApiTicket', msg: data.errmsg || 'Fetch jsapi_ticket fail.', data: { code: data.errcode || STATUS_CODES.UNKNOWN_ERROR } })); throw new Error('Fetch jsapi_ticket fail.'); }

存储access_token和jsapi_ticket

微信开放文档中数次强调需对access_tokenjsapi_ticket进行缓存,以避免API调用受限,因此还需对获取到的access_tokenjsapi_ticket进行持久化存储。此处采用数据库存储方式进行持久化,利用现有的options竖表,增加4条键值对记录。

代码如下:

javascriptCopy code
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
  • 50
  • 51
async saveTokenAndTicket(data) { await optionService.saveOptions({ settings: [{ name: constants.KEY_WX_MP_ACCESS_TOKEN, value: data.token }, { name: constants.KEY_WX_MP_ACCESS_TOKEN_TIME, value: data.fetchTime }, { name: constants.KEY_WX_MP_JSAPI_TICKET, value: data.ticket }, { name: constants.KEY_WX_MP_JSAPI_TICKET_TIME, value: data.fetchTime }] }); } async saveOptions(param) { const {settings} = param; let transaction; try { transaction = await models.sequelize.transaction(); for (let setting of settings) { await Option.update({ optionValue: setting.value }, { where: { optionName: { [Op.eq]: setting.name } }, transaction }); } await transaction.commit(); } catch (err) { logger.error(formatOpLog({ fn: 'saveOptions', msg: err.message, data: { options: settings } })); if (transaction) { await transaction.rollback(); } throw err; } }
async saveTokenAndTicket(data) { await optionService.saveOptions({ settings: [{ name: constants.KEY_WX_MP_ACCESS_TOKEN, value: data.token }, { name: constants.KEY_WX_MP_ACCESS_TOKEN_TIME, value: data.fetchTime }, { name: constants.KEY_WX_MP_JSAPI_TICKET, value: data.ticket }, { name: constants.KEY_WX_MP_JSAPI_TICKET_TIME, value: data.fetchTime }] }); } async saveOptions(param) { const {settings} = param; let transaction; try { transaction = await models.sequelize.transaction(); for (let setting of settings) { await Option.update({ optionValue: setting.value }, { where: { optionName: { [Op.eq]: setting.name } }, transaction }); } await transaction.commit(); } catch (err) { logger.error(formatOpLog({ fn: 'saveOptions', msg: err.message, data: { options: settings } })); if (transaction) { await transaction.rollback(); } throw err; } }

此处,修改前原采用async.times调用,通过日志查看到调用顺序是根据迭代的顺序,因此改为async+for+await貌似并无不妥,但最终的执行效率似乎相比async.times相差了60-80%,只是总计大概20-30ms的差距似乎可以忽略不计。

完整流程

将以上步骤串联起来,包括:判断是否已缓存access_tokenjsapi_ticket,调用API获取access_tokenjsapi_ticket,重新缓存,返回结果。

代码如下:

javascriptCopy code
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
async getTokenAndTicket() { try { const data = await optionService.getOptionsByKeys({ optionKeys: [ constants.KEY_WX_MP_ACCESS_TOKEN, constants.KEY_WX_MP_ACCESS_TOKEN_TIME, constants.KEY_WX_MP_JSAPI_TICKET, constants.KEY_WX_MP_JSAPI_TICKET_TIME ] }); let token = data[constants.KEY_WX_MP_ACCESS_TOKEN].optionValue; let tokenStart = +data[constants.KEY_WX_MP_ACCESS_TOKEN_TIME].optionValue || 0; let ticket = data[constants.KEY_WX_MP_JSAPI_TICKET].optionValue; let ticketStart = +data[constants.KEY_WX_MP_JSAPI_TICKET_TIME].optionValue || 0; const nowTime = Math.ceil(Date.now() / 1000); let refresh = false; if (tokenStart + constants.WX_MP_ACCESS_TOKEN_EXPIRES <= nowTime) { refresh = true; token = await actions.fetchAccessToken(); } if (ticketStart + constants.WX_MP_ACCESS_TOKEN_EXPIRES <= nowTime) { ticket = await actions.fetchJsApiTicket({ token }); } if (refresh) { await actions.saveTokenAndTicket({ token, ticket, fetchTime: nowTime }); } return { token, ticket, fetchTime: nowTime }; } catch (e) { logger.error(formatOpLog({ fn: 'saveOptions', msg: e.message })); } }
async getTokenAndTicket() { try { const data = await optionService.getOptionsByKeys({ optionKeys: [ constants.KEY_WX_MP_ACCESS_TOKEN, constants.KEY_WX_MP_ACCESS_TOKEN_TIME, constants.KEY_WX_MP_JSAPI_TICKET, constants.KEY_WX_MP_JSAPI_TICKET_TIME ] }); let token = data[constants.KEY_WX_MP_ACCESS_TOKEN].optionValue; let tokenStart = +data[constants.KEY_WX_MP_ACCESS_TOKEN_TIME].optionValue || 0; let ticket = data[constants.KEY_WX_MP_JSAPI_TICKET].optionValue; let ticketStart = +data[constants.KEY_WX_MP_JSAPI_TICKET_TIME].optionValue || 0; const nowTime = Math.ceil(Date.now() / 1000); let refresh = false; if (tokenStart + constants.WX_MP_ACCESS_TOKEN_EXPIRES <= nowTime) { refresh = true; token = await actions.fetchAccessToken(); } if (ticketStart + constants.WX_MP_ACCESS_TOKEN_EXPIRES <= nowTime) { ticket = await actions.fetchJsApiTicket({ token }); } if (refresh) { await actions.saveTokenAndTicket({ token, ticket, fetchTime: nowTime }); } return { token, ticket, fetchTime: nowTime }; } catch (e) { logger.error(formatOpLog({ fn: 'saveOptions', msg: e.message })); } }

签名

获取到access_tokenjsapi_ticket后,便需根据文档中的算法对其进行签名,参考微信开放文档提供的Demo(代码无力吐槽),简化如下:

javascriptCopy code
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
const createNonceStr = () => { const alphaDigitCount = 36; const strLength = 15; return Math.random().toString(alphaDigitCount).substr(2, strLength); }; const createTimestamp = () => Math.ceil(Date.now() / 1000) + ''; const transformArgs = (args) => { let keys = Object.keys(args); keys = keys.sort(); let argsArr = []; keys.forEach((key) => { argsArr.push(key.toLowerCase() + '=' + args[key]); }); return argsArr.join('&'); }; /** * 签名算法 * @param {string} ticket 用于签名的 jsapi_ticket * @param {string} url 用于签名的 url * @returns {Object} 签名结果 */ const sign = (ticket, url) => { const argsObj = { 'jsapi_ticket': ticket, 'nonceStr': createNonceStr(), 'timestamp': createTimestamp(), url }; const argsStr = transformArgs(argsObj); const shaObj = new JsSHA('SHA-1', 'TEXT'); shaObj.update(argsStr); argsObj.signature = shaObj.getHash('HEX'); logger.info(formatOpLog({ fn: 'sign', msg: 'Sign success.', data: argsObj })); return argsObj; };
const createNonceStr = () => { const alphaDigitCount = 36; const strLength = 15; return Math.random().toString(alphaDigitCount).substr(2, strLength); }; const createTimestamp = () => Math.ceil(Date.now() / 1000) + ''; const transformArgs = (args) => { let keys = Object.keys(args); keys = keys.sort(); let argsArr = []; keys.forEach((key) => { argsArr.push(key.toLowerCase() + '=' + args[key]); }); return argsArr.join('&'); }; /** * 签名算法 * @param {string} ticket 用于签名的 jsapi_ticket * @param {string} url 用于签名的 url * @returns {Object} 签名结果 */ const sign = (ticket, url) => { const argsObj = { 'jsapi_ticket': ticket, 'nonceStr': createNonceStr(), 'timestamp': createTimestamp(), url }; const argsStr = transformArgs(argsObj); const shaObj = new JsSHA('SHA-1', 'TEXT'); shaObj.update(argsStr); argsObj.signature = shaObj.getHash('HEX'); logger.info(formatOpLog({ fn: 'sign', msg: 'Sign success.', data: argsObj })); return argsObj; };

其中,加密库jssha相较Demo中的版本已做了大幅更新,API无法直接使用(否则会报错),o(╯□╰)o。

增加接口路由

最后,新增/sign路由,以提供post接口供前端调用(也可以无需调用直接输出在页面上,只需要保证签名的url和api调用的url的一致性)。

代码如下:

javascriptCopy code
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
app.post('/wechat/sign', wechat.getSignature); async getSignature(reqUrl) { const data = await actions.getTokenAndTicket(); const signData = actions.sign(data.ticket, reqUrl); return { appId: credentials.wxMpAppID, timestamp: signData.timestamp, nonceStr: signData.nonceStr, signature: signData.signature }; }
app.post('/wechat/sign', wechat.getSignature); async getSignature(reqUrl) { const data = await actions.getTokenAndTicket(); const signData = actions.sign(data.ticket, reqUrl); return { appId: credentials.wxMpAppID, timestamp: signData.timestamp, nonceStr: signData.nonceStr, signature: signData.signature }; }

至此,服务端部分结束。测试/sign调用,完美返回需要的结果:

javascriptCopy code
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
{ "code": 0, "message": null, "token": "rzRO9fCb-Ur31Yb5GvVfSpfCJIZLTmX_ZxMM", "data": { "appId": "...", "timestamp": "1593315860", "nonceStr": "...", "signature": "..." } }
{ "code": 0, "message": null, "token": "rzRO9fCb-Ur31Yb5GvVfSpfCJIZLTmX_ZxMM", "data": { "appId": "...", "timestamp": "1593315860", "nonceStr": "...", "signature": "..." } }

前端调用实现

参考微信开放文档,代码如下:

javascriptCopy code
  • 1
  • 2
  • 3
  • 4
  • 5
  • 6
  • 7
  • 8
  • 9
  • 10
  • 11
  • 12
  • 13
  • 14
  • 15
  • 16
  • 17
  • 18
  • 19
  • 20
  • 21
  • 22
  • 23
  • 24
  • 25
  • 26
  • 27
  • 28
  • 29
  • 30
  • 31
  • 32
  • 33
  • 34
  • 35
  • 36
  • 37
  • 38
  • 39
  • 40
  • 41
  • 42
  • 43
  • 44
  • 45
  • 46
  • 47
  • 48
  • 49
const service = { initWxConfig: function () { wx.config({ debug: false, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。 appId: service.signData.appId, // 必填,公众号的唯一标识 timestamp: service.signData.timestamp, // 必填,生成签名的时间戳 nonceStr: service.signData.nonceStr, // 必填,生成签名的随机串 signature: service.signData.signature, // 必填,签名 jsApiList: ['updateAppMessageShareData', 'updateTimelineShareData', 'onMenuShareAppMessage', 'onMenuShareTimeline'] // 必填,需要使用的JS接口列表 }); }, initWxEvent: function () { wx.ready(function () { service.checkJsApi(() => { service.initWxShareEvent(); }); }); wx.error(function (res) { logger.log(res.errMsg); }); }, checkJsApi: function (cb) { wx.checkJsApi({ jsApiList: ['updateAppMessageShareData', 'updateTimelineShareData', 'onMenuShareAppMessage', 'onMenuShareTimeline'], success: function (res) { // 以键值对的形式返回,可用的api值true,不可用为false service.wxApiFlag = res.checkResult; cb(); } }); }, initWxShareEvent: function () { const shareData = { title: document.title, desc: $('[name="description"]').attr('content'), link: url, imgUrl: 'https://www.ifuyun.com/logo.png', success: function () { logger.info('已分享'); } }; if (service.wxApiFlag.updateAppMessageShareData) { wx.updateAppMessageShareData(shareData); } if (service.wxApiFlag.updateTimelineShareData) { wx.updateTimelineShareData(shareData); } } }
const service = { initWxConfig: function () { wx.config({ debug: false, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。 appId: service.signData.appId, // 必填,公众号的唯一标识 timestamp: service.signData.timestamp, // 必填,生成签名的时间戳 nonceStr: service.signData.nonceStr, // 必填,生成签名的随机串 signature: service.signData.signature, // 必填,签名 jsApiList: ['updateAppMessageShareData', 'updateTimelineShareData', 'onMenuShareAppMessage', 'onMenuShareTimeline'] // 必填,需要使用的JS接口列表 }); }, initWxEvent: function () { wx.ready(function () { service.checkJsApi(() => { service.initWxShareEvent(); }); }); wx.error(function (res) { logger.log(res.errMsg); }); }, checkJsApi: function (cb) { wx.checkJsApi({ jsApiList: ['updateAppMessageShareData', 'updateTimelineShareData', 'onMenuShareAppMessage', 'onMenuShareTimeline'], success: function (res) { // 以键值对的形式返回,可用的api值true,不可用为false service.wxApiFlag = res.checkResult; cb(); } }); }, initWxShareEvent: function () { const shareData = { title: document.title, desc: $('[name="description"]').attr('content'), link: url, imgUrl: 'https://www.ifuyun.com/logo.png', success: function () { logger.info('已分享'); } }; if (service.wxApiFlag.updateAppMessageShareData) { wx.updateAppMessageShareData(shareData); } if (service.wxApiFlag.updateTimelineShareData) { wx.updateTimelineShareData(shareData); } } }

测试

一切准备就绪并发布上线后,兴奋地在浏览器和微信中进行测试分享,结果……

"errMsg": "updateAppMessageShareData:permission denied"

"errMsg": "updateTimelineShareData:permission denied"

在网上搜了一圈,才发现没有权限……没有权……没有……没……

搞了半天,你告诉我只有认证号才有此权限?(╯‵□′)╯︵┻━┻

缓过神后到微信开放文档仔细逛了一圈,才发现在某个小小的角落里提到了这么几个字:在申请到认证公众号之前……原来“认证”两个字在这里摆着……无奈,只能雪藏了开发了近两天的代码,等待重见天日的那一天……

 

后记

完整代码参见:GitHub。ღ( ´・ᴗ・` )