# 记一次生成环境DEBUG过程

# 背景介绍

公司的一个公众号内嵌H5页面,用户在公众号内打开这个H5页面,点击页面的按钮打开微信小程序来领取优惠券。

运营反馈从昨天开始,用户点击打开小程序的按钮后没有反应,但是点击跳转h5的按钮可以正常跳转。而且时好时坏,并不是每次点击都无效。由于影响范围较大(公众号的主要功能受影响,受影响活跃用户在5w左右),所以进行了立即排查。

# 页面结构

H5的页面内部的主要逻辑是展示优惠券领取按钮(包括小程序和H5)。页面从点击到加载完成主要进行了下面的操作。

  • 静默授权获取用户的openid
  • 生成优惠券跳转的path和链接。
  • 生成微信JSSDK的配置参数,包括appIdtimestampnonceStrsignature,用于调用微信JSSDK的接口。
  • 后端渲染页面优惠券部分,其中使用了wx-open-launch-weapp标签来打开小程序。
  • 通过wx.config接口注入权限验证配置。

# 初步分析

由于这个业务稳定运行了三年多了,最近没有迭代,所以初步分析不应该是代码层面的问题。所以排查的方向主要集中在动态生成的信息上面。主要排查了以下几个方向。

# 小程序path是否生成

H5页面通过wx-open-launch-weapp标签来打开小程序,需要usernamepath两个参数。username是固定的,所以排查的方向是path是否生成。通过日志和前端调试发现,path是正常生成的。所以初步判断path生成没有问题。

<wx-open-launch-weapp username="gh_xxxxx" path="/index/pages/xxxxxx" style="position: absolute; width: 6.41rem; height: 100%;"><script type="text/wxtag-template"><div style="position: absolute; width: 100%; height: 100%;"></div></script></wx-open-launch-weapp>

# 微信JSSDK配置是否生成并注入成功

微信JS-SDK是微信公众平台 面向网页开发者提供的基于微信内的网页开发工具包。

通过使用微信JS-SDK,网页开发者可借助微信高效地使用拍照、选图、语音、位置等手机系统的能力,同时可以直接使用微信分享、扫一扫、卡券、支付等微信特有的能力,为微信用户提供更优质的网页体验。

微信JSSDK的配置参数包括appIdtimestampnonceStrsignature,这些参数都是通过后端生成并注入到页面中的。通过后端日志查看参数生成成功,但是查询前端console日志发现wx.config接口没有调用成功,一致报 config:fail,invalid signature,所以初步判断微信JSSDK配置没有注入成功。

  wx.config({
    debug: false, // 开启调试模式,调用的所有api的返回值会在客户端alert出来,若要查看传入的参数,可以在pc端打开,参数信息会通过log打出,仅在pc端时才会打印。
    appId: 'xxxxxx', // 必填,公众号的唯一标识
    timestamp: 1732606316, // 必填,生成签名的时间戳
    nonceStr: 'xxxxxxx', // 必填,生成签名的随机串
    signature: 'xxxxxxxx',// 必填,签名
    jsApiList: ['getLocation', 'openLocation', 'updateAppMessageShareData', 'updateTimelineShareData'], // 必填,需要使用的JS接口列表
    openTagList: ['wx-open-launch-weapp']
  });

# 深入排查

到这一步,我们已经初步定位到问题是微信JSSDK配置签名失败,但是具体原因还需要进一步排查。由于微信JSSDK的配置参数是通过后端生成并注入到页面中的,所以排查的方向是后端生成签名的过程。由于生产环境受影响较大,所以直接在生产灰度机上进行调试。

微信的JSSDK签名算法如下:

  1. 参考以下文档获取access_token(有效期7200秒,开发者必须在自己的服务全局缓存access_token):https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html
  2. 用第一步拿到的access_token 采用http GET方式请求获得jsapi_ticket(有效期7200秒,开发者必须在自己的服务全局缓存jsapi_ticket):https://api.weixin.qq.com/cgi-bin/ticket/getticket?access_token=ACCESS_TOKEN&type=jsapi

成功返回如下JSON:

{
  "errcode":0,
  "errmsg":"ok",
  "ticket":"bxLdikRXVbTPdHSM05e5u5sUoXNKd8-41ZO3MhKoyN5OfkWITDGgnr2fwJ0m9E8NYzWKVZvdVtaUgWvsdshFKA",
  "expires_in":7200
}

获得jsapi_ticket之后,就可以生成JS-SDK权限验证的签名了。

  1. 签名算法如下:参与签名的字段包括noncestr(随机字符串), 有效的jsapi_ticket, timestamp(时间戳), url(当前网页的URL,不包含#及其后面部分) 。对所有待签名参数按照字段名的ASCII 码从小到大排序(字典序)后,使用URL键值对的格式(即key1=value1&key2=value2…)拼接成字符串string1。这里需要注意的是所有参数名均为小写字符。对string1作sha1加密,字段名和字段值都采用原始值,不进行URL 转义。

示例:

noncestr=Wm3WZYTPz0wzccnW
jsapi_ticket=sM4AOVdWfPE4DxkXGEs8VMCPGGVi4C3VM0P37wVUCFvkVAy_90u5h9nbSlYy3-Sl-HhTdfl2fzFy1AOcHKP7qg
timestamp=1414587457
url=http://mp.weixin.qq.com?params=value

步骤1. 对所有待签名参数按照字段名的ASCII 码从小到大排序(字典序)后,使用URL键值对的格式(即key1=value1&key2=value2…)拼接成字符串string1:

jsapi_ticket=sM4AOVdWfPE4DxkXGEs8VMCPGGVi4C3VM0P37wVUCFvkVAy_90u5h9nbSlYy3-Sl-HhTdfl2fzFy1AOcHKP7qg&noncestr=Wm3WZYTPz0wzccnW&timestamp=1414587457&url=http://mp.weixin.qq.com?params=value

步骤2. 对string1进行sha1签名,得到signature:

0f9de62fce790f9a083d5c99e95740ceb90c27ed

如果签名失败,所以首先怀疑ticket是否生成成功,代码中的ticket是被动缓存的,因此查询对应的redis key

$ get weixin:xxxxx:ticket

发现ticket不存在,因此可以确定ticket生成失败,进一步排查发现access_token生成失败。

生成tickettoken的代码如下:

/**
 * 获取ticket
 *
 * @return string
 */
public function getTicket(){
    $ticket = '';
    $cacheKey = "weixin:xxxxx:ticket";
    $ticket = $client->get($cacheKey);
    if(empty($ticket)) {
        $token = $this->getToken();
        $url = sprintf(self::URL_JSAPI_TICKET, $token);
        $return = json_decode(file_get_contents($url), true);
        if(!empty($return['ticket'])) {
            $ticket = $return['ticket'];
            $client->setex($cacheKey, 3600, $ticket);
        }
    }
    return $ticket;
}


/**
 * 获取token
 *
 * @return string
 */
public function getToken() {
    $token = '';
    $cacheKey = "weixin:xxxxx_appid:xxxxxx_secret:token";
     $token = $client->get($cacheKey);
    if(empty($token)) {
        $url = sprintf(self::URL_ACCESS_TOKEN, $this->_appId, $this->_appSecrectKey);
        $return = json_decode(file_get_contents($url), true);
        if(!empty($return['access_token'])) {
            $token = $return['access_token'];
            $client->setex($cacheKey, intval($return['expires_in']), $token);
        }
    }
    return $token;
}

调用生成token的接口返回:

{
    "errcode": 40164,
    "errmsg": "invalid ip xxx.xxx.xxx.xxx ipv6 ::fxxx.xxx.xxx.xxx, not in whitelist rid: xxxxxxxx"
}

到这里可以确定,access_token生成失败的原因是ip不在白名单中,因此需要将ip添加到白名单中。

# 解决方案

在微信公众号管理后台,将ip添加到白名单中。然后测试发现access_token生成成功,ticket生成成功,jsapi_ticket生成成功,signature生成成功,点击跳转小程序功能正常。

# 问题总结

在解决完问题之后,我不由在想ip不在白名单里面的问题时有发生,我们应该如何避免呢?tickettoken的代码为什么对调用失败的情况没有任何处理?我们正常应该怎么处理?下面是我的思考:

  1. 虽然现在在去中台化,但是对于外部的调用,应该还是有中台的概念。建立中台服务。由中台负责和外部服务打交道。公司各个业务部门只需要调用中台服务即可。中台服务负责缓存,错误处理,异常处理等。这样就可以避免很多问题。
  • 中台可以固定几个IP地址,方便对外添加固定的IP白名单。
  • 中台也可以统计调用量,方便监控和报警。
  1. tickettoken的代码对调用失败的情况没有任何处理,这暴露了我们在编写代码时的线性思维和对错误处理的不够重视性,成熟的编程模型通常将日志分为错误日志、警告日志、信息日志、调试日志等是有道理的。我们不能在编程中只考虑正常情况,而忽略了异常情况。我们应该在编写代码时,考虑到各种异常情况,并做好相应的处理。像上面的tickettoken的代码,应该对调用失败的情况进行处理,调用错误处理服务。进行相应级别的处理。
  • error级别的错误应该立即通过钉钉或者手机短信进行报警,并通知相关人员。
  • warning级别的错误应该记录日志并发送邮件,并通知相关人员。
  • info级别的错误应该通过日志进行记录,并定期进行统计和分析。
  • debug级别的错误应该通过日志进行记录,并定期进行统计和分析。