# 记一次生成环境DEBUG过程
# 背景介绍
公司的一个公众号内嵌H5页面,用户在公众号内打开这个H5页面,点击页面的按钮打开微信小程序来领取优惠券。
运营反馈从昨天开始,用户点击打开小程序的按钮后没有反应,但是点击跳转h5的按钮可以正常跳转。而且时好时坏,并不是每次点击都无效。由于影响范围较大(公众号的主要功能受影响,受影响活跃用户在5w左右),所以进行了立即排查。
# 页面结构
H5的页面内部的主要逻辑是展示优惠券领取按钮(包括小程序和H5)。页面从点击到加载完成主要进行了下面的操作。
- 静默授权获取用户的
openid
- 生成优惠券跳转的
path
和链接。 - 生成微信JSSDK的配置参数,包括
appId
、timestamp
、nonceStr
和signature
,用于调用微信JSSDK的接口。 - 后端渲染页面优惠券部分,其中使用了
wx-open-launch-weapp
标签来打开小程序。 - 通过
wx.config
接口注入权限验证配置。
# 初步分析
由于这个业务稳定运行了三年多了,最近没有迭代,所以初步分析不应该是代码层面的问题。所以排查的方向主要集中在动态生成的信息上面。主要排查了以下几个方向。
# 小程序path
是否生成
H5页面通过wx-open-launch-weapp
标签来打开小程序,需要username
和path
两个参数。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的配置参数包括appId
、timestamp
、nonceStr
和signature
,这些参数都是通过后端生成并注入到页面中的。通过后端日志查看参数生成成功,但是查询前端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签名算法如下:
- 参考以下文档获取access_token(有效期7200秒,开发者必须在自己的服务全局缓存access_token):https://developers.weixin.qq.com/doc/offiaccount/Basic_Information/Get_access_token.html
- 用第一步拿到的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权限验证的签名了。
- 签名算法如下:参与签名的字段包括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×tamp=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
生成失败。
生成ticket
和token
的代码如下:
/**
* 获取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
不在白名单里面的问题时有发生,我们应该如何避免呢?ticket
和token
的代码为什么对调用失败的情况没有任何处理?我们正常应该怎么处理?下面是我的思考:
- 虽然现在在去中台化,但是对于外部的调用,应该还是有中台的概念。建立中台服务。由中台负责和外部服务打交道。公司各个业务部门只需要调用中台服务即可。中台服务负责缓存,错误处理,异常处理等。这样就可以避免很多问题。
- 中台可以固定几个
IP
地址,方便对外添加固定的IP白名单。 - 中台也可以统计调用量,方便监控和报警。
ticket
和token
的代码对调用失败的情况没有任何处理,这暴露了我们在编写代码时的线性思维和对错误处理的不够重视性,成熟的编程模型通常将日志分为错误日志、警告日志、信息日志、调试日志等是有道理的。我们不能在编程中只考虑正常情况,而忽略了异常情况。我们应该在编写代码时,考虑到各种异常情况,并做好相应的处理。像上面的ticket
和token
的代码,应该对调用失败的情况进行处理,调用错误处理服务。进行相应级别的处理。
error
级别的错误应该立即通过钉钉或者手机短信进行报警,并通知相关人员。warning
级别的错误应该记录日志并发送邮件,并通知相关人员。info
级别的错误应该通过日志进行记录,并定期进行统计和分析。debug
级别的错误应该通过日志进行记录,并定期进行统计和分析。