invalid credential,access_token is invalid or not latest,could get access_token by getStableAccess排查

问题

某次应用上线后,作为一个极其负责的开发者,对生产应用,即App进行功能验收。发现用户输入的敏感词内容没有被检测出来,一开始还以为是前端没有对接后端提供的接口。秉着怀疑他人前先怀疑自己的意识和职业素养,查看起ELK日志来,发现如下报错日志:

敏感词返回结果:{"errcode":40001,"errmsg":"invalid credential, access_token is invalid or not latest, could get access_token by getStableAccessToken, more details at https://mmbizurl.cn/s/JtxxFh33r rid: 657fb5df-054a7b70-55249500"}

invalid credential,access_token is invalid or not latest,could get access_token by getStableAccess排查_第1张图片

排查

初步分析

敏感词检测逻辑方法源码如下:

private int sensitiveCheck(String content) {
    try {
        String accessToken = redisTemplate.opsForValue().get("ACCESS_TOKEN_CACHE:" + configService.getAppIdSelf());
        if (null == accessToken) {
            accessToken = WxUtils.getAccessToken(configService.getAppIdSelf(), configService.getAppSecretSelf());
            redisTemplate.opsForValue().set(RedisConstants.ACCESS_TOKEN_CACHE_PREFIX + configService.getAppIdSelf(), accessToken, 7200, TimeUnit.SECONDS);
        }
        return WxUtils.sensitiveCheck(accessToken, content);
    } catch (Exception e) {
        log.error("sensitiveCheck failed: ", e);
        // 网络异常等暂不考虑
        return 0;
    }
}

从Redis里获取到Access-Token,然后拿着这个Access-Token去请求微信提供的敏感词检测接口,参考官方给出的API文档。

注意到敏感词检测方法封装成组件,其源码如下:

/**
 * 敏感词检测
 *
 * @param token   access_token
 * @param content content
 * @return errcode
 */
public static int sensitiveCheck(String token, String content) throws Exception {
    Map<String, Object> paramMap = new HashMap<>(1);
    paramMap.put("content", content);
    String result = HttpUtils.post("https://api.weixin.qq.com/wxa/msg_sec_check?access_token=" + token, JsonUtil.beanToJson(paramMap));
    log.info("敏感词返回结果:{}", result);
    JSONObject jsonObject = JSONObject.parseObject(result);
    return jsonObject.getInteger("errcode");
}

注意到上面代码里面的log日志正好是ELK查询到的应用日志。

回到报错提示:invalid credential, access_token is invalid or not latest。简单分析下,不难得出结论:Redis里缓存的Access-Token过期,拿着过期的Token去请求微信的接口,于是有此报错。因为是生产问题,首要任务是解决问题;问题出现时间越短,其影响面越小。其次才去分析问题,从根源上杜绝问题。

抱着这个想法,通过客户端连接到生产环境的Redis,找到并删除Redis Key:ACCESS_TOKEN_CACHE:

生产环境验证,问题消失。

那为啥Redis里缓存的Access-Token会过期呢?


不得而解。事情太多,杂乱无章。暂且搁置。埋下不定时炸弹。


再次爆出

时隔一个月!!!!!!!!!!!!

某次在使用生产App时,再次发现敏感词出现问题,即用户输入的昵称里显然没有敏感词,却提示无法保存用户信息。

这说明自动化测试的极度重要性!!

于是开始继续排查这个40001问题。

在使用微信提供的几乎所有接口(功能)之前都需要先得到微信的校验,校验通过之后才能免费(或付费)使用微信提供的功能。

怎么校验呢?先调用获取Access-Token接口,传参AppId和AppSecret,参考官方给出的API文档。

/**
 * 获取 access_token, 公众号和小程序均可以使用AppID和AppSecret调用本接口来获取access_token
 * ...
 *
 * @param appId  appId
 * @param secret secret
 */
public static String getAccessToken(String appId, String secret) throws Exception {
    String url = "https://api.weixin.qq.com/cgi-bin/token?grant_type=" + GRANT_TYPE + "&appid=" + appId + "&secret=" + secret;
    return JSONObject.parseObject(HttpUtils.get(url)).getString(ACCESS_TOKEN);
}

关于微信接口的一些理论储备(有些是事后反思回顾才搞清楚的,):

  • 微信公众号先于微信小程序出现
  • getAccessToken这一套接口方法一开始是用于微信公众号,后来可以复用到微信小程序
  • 不管是微信公众号还是微信小程序,都是一个主体概念,不存在所谓的本地环境、开发环境、测试环境、预发环境、生产环境
  • 一个主体就是一个App,有对应的AppId和AppSecret,两者是一一对应的
  • 不能拿着公众号A的AppId和小程序B的AppSecret,去请求微信获取Access-Token接口
  • Access-Token是AppId维度的,也就是说不同的AppId,其Access-Token肯定不一样
  • 同一个AppId,前后两次请求,返回的Access-Token肯定也不一样
  • 每次请求获取到的Access-Token,其有效期为7200秒,即2小时
  • 同一个AppId,第二次请求之后,第一次获取到的Access-Token就会失效
  • 可以反复请求此接口,但是一天有上限,微信不可能允许你无限量(免费)调用他们提供的接口。至于具体上限数值,TODO。

也就是说,我们应用层,很有必要把Access-Token缓存下来,无论是出于业务并发量的考虑(一般而言,请求微信外部接口的耗时,肯定比微服务之间Feign请求,数据库查询请求等耗时大),还是出于代码可维护性(功能复用,相同或相似的业务逻辑代码组件化)考虑。都需要增加Redis缓存功能,以及组件化util方法。

打开报错日志里面提到的链接https://mmbizurl.cn/s/JtxxFh33r ,这是一个【短链接】,简单来说,根据URL我们完全分析不出这个URL包含的任何意义。此短链接对应的完整URL为:https://developers.weixin.qq.com/miniprogram/dev/OpenApiDoc/mp-access-token/getStableAccessToken.html

这个微信文档说的啥啊?我的应用里根本就没有调用https://api.weixin.qq.com/cgi-bin/stable_token接口,且此接口的报错Error Code里也并没有40001。command + F搜索才能发现点东西:

access_token 泄漏紧急处理
使用强制刷新模式以间隔30s发起两次调用可将已经泄漏的 access_token立即失效,同时正常的业务请求可能会返回错误码40001(access_token过期),请妥善使用该策略。其次,需要立即排查泄漏原因,加以修正,必要时可以考虑重置 appsecret;

也就是说,40001是就是access_token过期,和Redis组件无关。

同一个微信AppId,不区分测试和生产环境,测试环境在调用微信的getAccessToken接口时(并且此时生产环境也有最大过期时间为7200秒,即2小时的缓存数据),生产的access-token就会在5分钟(参考文档)后过期,如果在这最大2小时内,生产上也在走同个业务需要使用access-token,发现缓存内有一个已经生效的access-token,生产就会报错40001。

缓存还是很有必要的,之前看到微信的文档也提过建议使用的。TODO,待补充。

继续看官方API文档:

目前access_token的有效期通过返回的expires_in来传达,目前是7200秒之内的值。中控服务器需要根据这个有效时间提前去刷新新access_token。在刷新过程中,中控服务器可对外继续输出的老access_token,此时公众平台后台会保证在5分钟内,新老access_token都可用,这保证了第三方业务的平滑过渡;

5分钟后失效,极端情况下,测试环境和生产轮流请求同一个AppId的token。测试环境请求后,生产的就会失效,反之亦然。

这个【中控服务器】难道可以跨环境同步数据??

解决方案

private int sensitiveCheck(String content) {
    try {
        String accessToken = redisTemplate.opsForValue().get(RedisConstants.ACCESS_TOKEN_CACHE_PREFIX + configService.getAppIdSelf());
        if (null == accessToken) {
            accessToken = WxUtils.getAccessToken(configService.getAppIdSelf(), configService.getAppSecretSelf());
            redisTemplate.opsForValue().set(RedisConstants.ACCESS_TOKEN_CACHE_PREFIX + configService.getAppIdSelf(), accessToken, 7200, TimeUnit.SECONDS);
        }
        int code = WxUtils.sensitiveCheck(accessToken, content);
        if (code == Constants.TOKEN_EXPIRED_CODE) {
            // token已失效,需再次请求,并缓存
            accessToken = WxUtils.getAccessToken(configService.getAppIdSelf(), configService.getAppSecretSelf());
            redisTemplate.opsForValue().set(RedisConstants.ACCESS_TOKEN_CACHE_PREFIX + configService.getAppIdSelf(), accessToken, 7200, TimeUnit.SECONDS);
            return WxUtils.sensitiveCheck(accessToken, content);
        } else {
            return code;
        }
    } catch (Exception e) {
        log.error("sensitiveCheck failed: ", e);
        // 网络异常等暂不考虑
        return 0;
    }
}

事故缘起

所以,行文至此,这次时间跨度长达一个月的生产事故,根源在于引入Redis??在于引入组件化复用思想??

在这之前,我们微服务有30个。微服务数量太多,一定程度上意味着开发工作量的增加。

更为致命的是一个模块业务拆分成两个应用。举例来说,有这么一个支付模块,支付涉及到各种各样的支付方式,如微信,支付宝支付,这些支付逻辑会放在Payment-Service里,这就是一个应用,记为payment-A

支付肯定会涉及到一些定时补偿任务,以及消费Kafka消息的业务功能。这又是一个应用!!记为payment-B,且payment-B肯定会引用payment-A提供的jar包。

如果payment-A里面的代码有逻辑调整,并发布上线,那我要不要一起发布上线payment-B应用呢?

扯远了。上面的问题的答案是:要!!!读者们可以思考一下为什么。

另外,后来我把payment-B里的逻辑代码移入到payment-A里,并下线payment-B应用。

总之,我想表达的意思是,在若干个微服务里,零零散散看到调用微信提供的功能或API的代码。有的是直接copy自其他某个应用;有的是用原生HttpClient写十几行甚至几十行代码,有的是另写一套HttpUtils.post()方法(基于HttpClient),有的是使用Spring提供的restTemplate.exchange()方法。

这谁能忍??作为一个代码洁癖强迫症。

反思

找到根源

事情多,肯定不是借口。

通过work-around解决问题,远远算不上真正解决问题,只是在给自己制造麻烦,埋下不定时炸弹。

日志规范

ELK以及Prometheus对于监控生产业务健康良态运行,起到举足轻重的作用。专业的程序员一定要极度重视ELK等日志监控体系。毕竟,测试用例不可能百分百覆盖到业务所有功能及模块。事实上,哪怕真的覆盖到1000%(没写错,百分之一千)的业务功能模块,也无法保证生产服务的健康运行。君不见,服务器异常重启屡见不鲜,数据中心遭遇断电事故偶有发生。

扯远了。

ELK监控业务的前提是应用日志记录的规范性。上面源代码里的敏感词返回结果:那一行,显然需要进行微信返回responseCode判断,正常的响应使用info日志级别,非正常的响应使用ERROR日志级别。

除了日志级别外,使用AOP记录Controller层接口的requestBody和responseBody非常有必要。如果请求量比较大(如healthCheck接口每隔1s就请求一次这种),或response比较大的接口(打印responseBody会占用服务器资源),可考虑使用@Pointcut进行排除:

@Pointcut("within(com.aaaa.backend.provider.controller.HealthController))")
public void exclude() {
}

@After("webLog() && !exclude()")
public void doAfter() {
    log.info("=== End ===");
}

另外,在调用第三方或外部接口时,一定要明确外部接口正常的响应状态码是什么,异常的响应状态码是什么,并记录ERROR或WARN级别的日志。

还有其他一些日志规范。

最后

你可能感兴趣的:(生产问题,微信小程序)