当前位置: 首页 → 技术圈 → 

JavaScript

Node.js接入微信网页开发JS-SDK踩坑记

 

背景

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

代码如下:

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,代码如下:

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条键值对记录。

代码如下:

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,重新缓存,返回结果。

代码如下:

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(代码无力吐槽),简化如下:

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的一致性)。

代码如下:

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调用,完美返回需要的结果:

{
    "code": 0,
    "message": null,
    "token": "rzRO9fCb-Ur31Yb5GvVfSpfCJIZLTmX_ZxMM",
    "data": {
        "appId": "wx29fac68e418200de",
        "timestamp": "1593315860",
        "nonceStr": "vw02ug0anw",
        "signature": "4467839dacc93d28a74dea2b04793a8fc1a754cc"
    }
}

前端调用实现

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

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: 'http://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。ღ( ´・ᴗ・` )


公众号:黑匣子思维
 

*

*

* 验证码

*