我发现了大厂OpenApi接口的bug!

本文记录我在对接字节旗下产品火山云游戏OpenApi 接口文档时遇到的坑,希望能帮助大家(火山云旗下云游戏产品的文档坑很多,我算是从零到一都踩了一遍,特此记录,希望大家引以为鉴)。

1. 文档问题

很经典的开局一张图,对接全靠问,

图片

产品给的图

这里给大家强调下,当要跟第三方产品对接时,一定要确认拿到的文档是不是最新版本。

比如我在这次对接中,第一次拿到的文档是产品给的,在业务中需要用到一个用户主动退出游戏的接口,于是我在第一份文档里面找到一个用户退出游戏的接口 RomoveUser。

图片

RemoveUser

但是当我在控制台调用此接口报错后,去群里一问才发现,对方建议我使用官网公布的最新接口文档。

官网最新文档:https://www.volcengine.com/docs/6512/143674

进入官网发现 RemoveUser 这个接口已经是历史接口了,官方建议换到 BanRoomUser 接口。

图片

BanRoomUser

OK,这里算是踩到了第一个坑,文档版本不是最新。

ps:还要说一下,火山云旗下云游戏的这个 OpenApi 接口文档需要在群里联系他们开白才能看到,说实话给我的感觉很奇怪,怀疑产品是否有赶鸭子上架问题,暂且怀疑他们的目的是防止不明攻击吧。

2. OpenApi 示例 demo

第三方接口的接入一般都需要做鉴权。火山云旗下云游戏产品的 OpenApi 接口接入当然也不例外。于是我开始了第二个踩坑之旅,那就是他们给出的 OpenApi 示例 demo 的使用过于简单。

图片

火山云旗下云游戏产品的 OpenApi 示例 demo 写的很简单,只提供了一个 GET 请求示例。

OpenApi 示例 demo 地址:https://github.com/volcengine/veGame

但是在我司的业务场景还是上个问题,需要一个用户主动退出游戏的接口,在火山云官网的 OpenApi 文档中我也找到了这个接口,就是上文提到的 BanRoomUser 接口。

但是在官方文档中 BanRoomUser 接口是一个 POST JSON 格式的请求。官方给出的 OpenApi 示例 demo 中并没有关于 POST JSON 请求的示例代码,所以只能靠我一个人查看他们提供的 SDK 依赖源码硬猜来写...,这就很让人头痛了。

好在我翻阅他们 SDK 源码中找到一个靠谱的 json(...) 请求方法,来完成这个 POST JSON 请求。

图片

OK,说干就干,直接写好示例代码,开始发送 POST JSON 请求,

图片

错误返回

what f**k?什么鬼,返回了我一个 null,此时我的内心中充满了一个大大的问号。

图片

我开始怀疑我的代码是不是写错了。但是当我经历过数次源码 debug 以及调用其他 OpenApi 接口测试并得到正确返回后,我坚定的认为我没错,这就是火山云 OpenApi 的 bug!

图片

正常返回

OK,说干就干,直接反馈给火山那边。

接着火山那边的人就联系说下午两点开会一起远程共享我的屏幕看看,OK 欣然接收,让他们见证下他们写的 bug!

...

时间来到下午两点,当我共享屏幕给字节工程师演示这个 bug 时,我的控制台打印如下,

图片

woca,竟然不是 null!好在我脑袋灵活,思路清晰,瞬间想到我改了一个参数 GameId,之前返回 null 时,我传的 GameId 是一个假数据,现在我传的是一个真数据。造成了返回不一致。

OK,找到了返回正常的原因,当我把 GameId 改成假数据时,如我所愿,返回了一个 null。

图片

自此,我也就在字节工程师的围观下,复现了他们的 OpenApi 接口的线上 bug。大功告成。

3. 鉴权失败

字节提供的 OpenApi 示例 demo 现在算是跑通了,但是由于我司项目一些依赖限制问题,我们不能直接引入火山云旗下云游戏产品的 SDK 依赖。所以我还得手动编写生成签名的代码。于是我开始了第三个踩坑之旅,那就是 GET 请求验签成功 POST 请求验签失败的问题。

这里先说一下,火山云提供了手动生成签名的示例代码

图片

Java 生成签名的代码:https://github.com/volcengine/volc-openapi-demos/blob/main/signature/java/Sign.java

这里我也是直接把签名代码拿来即用就行,一开始接入生成签名代码非常顺利,GET 请求的 OpenApi 接口都是可以顺利调通的,但是当我调用 BanRoomUser 接口时(没错,又是这个接口,踩的三个坑都与这个接口有关),直接提示验签失败!

图片

验签失败

OK,开始排查为什么签名失败。

图片

查看源码发现,POST JSON 请求时的 contentType 还是 application/x-www-form-urlencoded,直觉告诉我这里不对,所以改成 application/json 试试,看看控制台返回,

图片

很好,还是验签失败!!!

我尽力了兄弟们,这个坑踩的我是无话可说。直接联系直接字节开发人员看下我的请求内容是哪里有问题。

在与字节开发人员一起观摩我写的代码以及生成的签名之后,大家都没找到问题所在。那没办法了,只能上服务器看接口请求日志了。

图片

大家可以看出问题在哪里吗?没错我刚刚不是把 contentType 改成了 application/json 吗,为什么日志显示的 contentType 是 application/json; charset=utf-8!。

OK,到这里问题也找到了,原因是我这个项目用的 http 请求工具是 okhttp3。他自动给我拼接上去的!

那么怎么解决嘞,替换 http3 工具的话,改造成本比较大,所以我就顺势把代码的 contentType 也改成application/json; charset=utf-8

在测试一遍,看看控制台打印。

图片

OK,拿到成功响应,自此也就解决了第三个坑,POST JSON 请求时的验签不匹配问题。

最后给大家贴出手动生成验签的代码,有需要自取。

@Slf4j
public class Sign {
    private static final BitSet URLENCODER = new BitSet(256);
    private static final String CONST_ENCODE = "0123456789ABCDEF";
    public static final Charset UTF_8 = StandardCharsets.UTF_8;
    private final String region;
    private final String service;
    private final String host;
    private final String path;
    private final String ak;
    private final String sk;
    static {
        int i;
        for (i = 97; i <= 122; ++i) {
            URLENCODER.set(i);
        }

        for (i = 65; i <= 90; ++i) {
            URLENCODER.set(i);
        }

        for (i = 48; i <= 57; ++i) {
            URLENCODER.set(i);
        }
        URLENCODER.set('-');
        URLENCODER.set('_');
        URLENCODER.set('.');
        URLENCODER.set('~');
    }

    public Sign(String region, String service, String host, String path, String ak, String sk) {
        this.region = region;
        this.service = service;
        this.host = host;
        this.path = path;
        this.ak = ak;
        this.sk = sk;
    }

    public Headers calcAuthorization(String method, Map<String, String> queryList, byte[] body,
                                     Date date, String action, String version) throws Exception {
        // 请求头
        Map<String, String> headerMap = new HashMap<>();
        String contentType = "application/x-www-form-urlencoded; charset=utf-8";
        if (body == null) {
            body = new byte[0];
        } else {
            contentType = "application/json; charset=utf-8";
        }
        String xContentSha256 = hashSHA256(body);
        SimpleDateFormat sdf = new SimpleDateFormat("yyyyMMdd'T'HHmmss'Z'");
        sdf.setTimeZone(TimeZone.getTimeZone("GMT"));
        // String xDate = "20240515T061353Z";
        String xDate = sdf.format(date);
        String shortXDate = xDate.substring(0, 8);
        String signHeader = "content-type;host;x-content-sha256;x-date";

        SortedMap<String, String> realQueryList = new TreeMap<>(queryList);
        realQueryList.put("Action", action);
        realQueryList.put("Version", version);
        StringBuilder querySB = new StringBuilder();
        for (String key : realQueryList.keySet()) {
            querySB.append(signStringEncoder(key)).append("=").append(signStringEncoder(realQueryList.get(key))).append("&");
        }
        querySB.deleteCharAt(querySB.length() - 1);
        String canonicalStringBuilder = method + "\n" + path + "\n" + querySB + "\n" +
                "content-type:" + contentType + "\n" +
                "host:" + host + "\n" +
                "x-content-sha256:" + xContentSha256 + "\n" +
                "x-date:" + xDate + "\n" +
                "\n" +
                signHeader + "\n" +
                xContentSha256;

        // log.info("canonicalStringBuilder is {}", canonicalStringBuilder);
        String hashcanonicalString = hashSHA256(canonicalStringBuilder.getBytes());
        String credentialScope = shortXDate + "/" + region + "/" + service + "/request";
        String signString = "HMAC-SHA256" + "\n" + xDate + "\n" + credentialScope + "\n" + hashcanonicalString;
        // log.info("signString is {}", signString);

        byte[] signKey = genSigningSecretKeyV4(sk, shortXDate, region, service);
        String signature = HexUtil.encodeHexStr(hmacSHA256(signKey, signString));
        String auth = "HMAC-SHA256" +
                " Credential=" + ak + "/" + credentialScope +
                ", SignedHeaders=" + signHeader +
                ", Signature=" + signature;
        headerMap.put("Authorization", auth);
        headerMap.put("X-Date", xDate);
        headerMap.put("X-Content-Sha256", xContentSha256);
        headerMap.put("Host", host);
        headerMap.put("Content-Type", contentType);
        headerMap.put("User-Agent", "volc-sdk-java/v");
        headerMap.put("Accept", "application/json");
        return Headers.of(headerMap);
    }

    private static String signStringEncoder(String source) {
        if (source == null) {
            return null;
        }
        StringBuilder buf = new StringBuilder(source.length());
        ByteBuffer bb = UTF_8.encode(source);
        while (bb.hasRemaining()) {
            int b = bb.get() & 255;
            if (URLENCODER.get(b)) {
                buf.append((char) b);
            } else if (b == 32) {
                buf.append("%20");
            } else {
                buf.append("%");
                char hex1 = CONST_ENCODE.charAt(b >> 4);
                char hex2 = CONST_ENCODE.charAt(b & 15);
                buf.append(hex1);
                buf.append(hex2);
            }
        }

        return buf.toString();
    }

    public static String hashSHA256(byte[] content) throws Exception {
        try {
            MessageDigest md = MessageDigest.getInstance("SHA-256");
            // return HexFormat.of().formatHex(md.digest(content));
            return HexUtil.encodeHexStr(md.digest(content));
        } catch (Exception e) {
            throw new Exception(
                    "Unable to compute hash while signing request: "
                            + e.getMessage(), e);
        }
    }

    public static byte[] hmacSHA256(byte[] key, String content) throws Exception {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            mac.init(new SecretKeySpec(key, "HmacSHA256"));
            return mac.doFinal(content.getBytes());
        } catch (Exception e) {
            throw new Exception(
                    "Unable to calculate a request signature: "
                            + e.getMessage(), e);
        }
    }

    private byte[] genSigningSecretKeyV4(String secretKey, String date, String region, String service) throws Exception {
        byte[] kDate = hmacSHA256((secretKey).getBytes(), date);
        byte[] kRegion = hmacSHA256(kDate, region);
        byte[] kService = hmacSHA256(kRegion, service);
        return hmacSHA256(kService, "request");
    }
}

总结

在与火山云旗下云游戏产品的 OpenApi 接口对接过程中,我总共踩了三个坑。一是文档版本不是最新,二是官方提供的 OpenApi 示例 demo 过于简单,三是官方提供的验签代码没有考虑到 POST JSON 请求场景下的 contentType 设置问题。

在这里也想给大家传个话,没有必要神话大厂,大厂也有 bug,大厂的产品也会服务中断。比如火山云旗下云游戏产品的 OpenApi 接口文档示例 demo 简陋,手动生成签名代码场景单一,覆盖不全等问题,最后就是竟然还返回了一个 null 给我!不过此次对接过程中,在我反馈 OpenApi 接口各种问题时,群里小伙伴都能及时回应以及拉群沟通查看问题解决问题的态度点个赞👍。字节的小伙伴还是很可靠的啦。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
通用接口实现类LinkJOS 扩展于:public class LinkJOS extends LinkOAuth2(LinkOAuth2 extends JspEasy) 构造函数 LinkJOS(HttpServletRequest request,HttpServletResponse response) 京东JOS接口访问函数 public String link2(String link,String method,Bag sys,Bag apps,String appSecret,String file,String FileField) 作用:访问京东JOS平台的所有接口 参数: String link,京东JOS平台接口访问地址,目前固定为:https://api.jd.com/routerjson String method,向京东JOS平台提交数据时的方法,需要应用级别参数时建议用POST方法,不需要时用GET(参见后边的实例) Bag sys,系统级别参数书包(一般只需在接口参数文件中放入接口方法即可,参见后边的实例) Bag apps,应用级别参数书包(在接口参数文件中放入必须的应用级别参数,若不需要应用级别参数时直接用new Bag(-1)构造一个空书包即可,参见后边的实例) String appSecret,应用证书中的App Secret,前边已经设置,固定用"@{pPage:app_secret}"即可 String file,调用上传文件接口上传文件(如图片)到京东JOS平台时的文件全名(含相对路径,如:images/logo.png),不是调用上传文件接口时为空字符串即可(参见后边的实例) String FileField,调用上传文件接口上传文件(如图片)到京东JOS平台时的字段名,配合前边的参数,不是调用上传文件接口时为空字符串即可(参见后边的实例) 返回为京东JOS平台接口对应的JSON格式的字符串 JSON文本解析方法 public void parseJson(String json) 作用:解析京东JOS平台接口返回的JSON格式的字符串,并根据内容生成N个对应的书包 参数:String json,京东JOS平台接口返回的JSON格式的字符串 根据JSON文本的内容在系统中生成N个书包,根书包名称为j0,下一层的josn文本内容生成的书包名称用上一层的Key放在上一层的书名中,下边用实例说明寻找对应书包的方法: 如店铺信息查询接口jingdong.vender.shop.query返回的json文本为 { "jingdong_vender_shop_query_responce": { "shop_jos_result": { "open_time": "", "shop_id": "", "category_main_name": "", "category_main": "", "vender_id": "", "brief": "", "logo_url": "", "shop_name": "" } } } 找出店铺信息书包名的方法如下 @{j0:jingdong_vender_shop_query_responce} @{@{pPage:bag}:shop_jos_result} 这时候的@{pPage:bag}即为需要的店铺信息书包名 具体用法请阅读下载包中的《京东卖家如何快速开发网店工具软件》

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值