# 第三方 API 对接推荐的方式(SDK 方式)

本文:https://www.yuque.com/mrcode.cn/note-combat/tgpynzwe04xlrsdz

对于第三方 API 的对接,也就是封装成 SDK,有两个方面可以考虑:

  1. 一些公用参数的封装
  2. 通用的响应结果:指的是对 API 调用结果的封装

拿 Tiktok 的 授权 (opens new window)接口来说明,一共有两个接口:获取 token、token 刷新 :::tips 本文章讲解案例是基于对 Tiktok 店铺、商品等 API 的封装调用 (opens new window) :::

# 封装 APP 信息对象

对 API 分析之后,发现很多接口参数里面都需要传递应用(APP)相关的参数,于是封装一个专门的类来承载他的信息

package cn.mrcode.config.tiktok;

import lombok.Data;
import lombok.ToString;
import org.springframework.web.util.UriComponentsBuilder;

/**
 * @author mrcode
 * @date 2023/12/22 19:43
 */
@Data
@ToString
public class TiktokAppItem {

    private String appId;
    private String appKey;
    private String appSecret;
    // 授权地址
    private String authUrl;
    // 店铺 auth API 地址
    private String authApiHost;
    /**
     * open-api 版本
     * 如果有单独模块的版本更新,可以逐渐增加每个模块自定义的 api host
     */
    private String openApiVersion;
    /**
     * open-api 地址
     */
    private String openApiHost;


    public String buildServiceAuthorizeUrl(String state) {
        return UriComponentsBuilder.fromHttpUrl(this.authUrl).queryParam("state", state).toUriString();
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37

# 封装 API - 入门

这里先给出封装后的 API ,再分解讲解为什么这样做

package cn.mrcode.sdk.tiktok.shop;

import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import cn.mrcode.config.tiktok.TiktokAppItem;
import cn.mrcode.sdk.tiktok.shop.dto.SdkTikTokShopTokenRes;
import cn.mrcode.sdk.tiktok.shop.dto.SdkTiktokRes;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.concurrent.TimeUnit;

/**
 * @author mrcode
 * @date 2023/12/19 10:02
 */
@Component
@Slf4j
public class SdkTiktokShopAuthService {
    private final static String API_TOKEN_GET = "/token/get";
    private final static String API_TOKEN_REFRESH = "/token/refresh";
    public static final int TIMEOUT_MILLIS = (int) TimeUnit.SECONDS.toMillis(30);

    /**
     * token 获取
     *
     * @param app
     * @param authCode
     * @return
     */
    public SdkTiktokRes<SdkTikTokShopTokenRes> tokenGet(TiktokAppItem app, String authCode) {
        String authApiHost = app.getAuthApiHost();
        String apiUrl = authApiHost + API_TOKEN_GET;
        HashMap<String, Object> paramMap = new HashMap<>();
        paramMap.put("app_key", app.getAppKey());
        paramMap.put("app_secret", app.getAppSecret());
        paramMap.put("auth_code", authCode);
        paramMap.put("grant_type", "authorized_code");

        String resJson = HttpUtil.get(apiUrl, paramMap, TIMEOUT_MILLIS);
        JSONObject resObj = JSONObject.parse(resJson);
        SdkTiktokRes<SdkTikTokShopTokenRes> result = new SdkTiktokRes<>();
        int code = resObj.getIntValue("code", -1);
        if (code != 0) {
            result.setSuccess(false);
            result.setError(resObj.getString("message"));
            result.setRaw(resObj);
            return result;
        }
        JSONObject data = resObj.getJSONObject("data");
        result.setSuccess(true);
        result.setData(data.toJavaObject(SdkTikTokShopTokenRes.class));
        return result;
    }

    /**
     * token 刷新
     *
     * @param app
     * @param refreshToken
     * @return
     */
    public SdkTiktokRes<SdkTikTokShopTokenRes> tokenRefresh(TiktokAppItem app, String refreshToken) {
        String authApiHost = app.getAuthApiHost();
        String apiUrl = authApiHost + API_TOKEN_REFRESH;
        HashMap<String, Object> paramMap = new HashMap<>();
        paramMap.put("app_key", app.getAppKey());
        paramMap.put("app_secret", app.getAppSecret());
        paramMap.put("refresh_token", refreshToken);
        paramMap.put("grant_type", "refresh_token");

        String resJson = HttpUtil.get(apiUrl, paramMap, TIMEOUT_MILLIS);
        JSONObject resObj = JSONObject.parse(resJson);
        SdkTiktokRes<SdkTikTokShopTokenRes> result = new SdkTiktokRes<>();
        int code = resObj.getIntValue("code", -1);
        if (code != 0) {
            result.setSuccess(false);
            result.setError(resObj.getString("message"));
            result.setRaw(resObj);
            return result;
        }
        JSONObject data = resObj.getJSONObject("data");
        result.setSuccess(true);
        result.setData(data.toJavaObject(SdkTikTokShopTokenRes.class));
        return result;
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89

# 入参 - 简单入参

tokenGet(TiktokAppItem app, String authCode)
tokenRefresh(TiktokAppItem app, String refreshToken)
1
2

因为该 API 需要的入参比较简单,这里把 app 单独传递,其他的参数就用单个基本类型传递就行了,后面会介绍复杂的参数

# 响应结果

    public SdkTiktokRes<SdkTikTokShopTokenRes> tokenGet(TiktokAppItem app, String authCode) {
        String authApiHost = app.getAuthApiHost();
        String apiUrl = authApiHost + API_TOKEN_GET;
        HashMap<String, Object> paramMap = new HashMap<>();
        paramMap.put("app_key", app.getAppKey());
        paramMap.put("app_secret", app.getAppSecret());
        paramMap.put("auth_code", authCode);
        paramMap.put("grant_type", "authorized_code");

        String resJson = HttpUtil.get(apiUrl, paramMap, TIMEOUT_MILLIS);
        // 拿到请求的对象后,弄成 json 对象
        JSONObject resObj = JSONObject.parse(resJson);
        SdkTiktokRes<SdkTikTokShopTokenRes> result = new SdkTiktokRes<>();

        // 根据规则,判定响应结果是否成功
        int code = resObj.getIntValue("code", -1);
        if (code != 0) {
            // 不成功,设置当次调用失败
            result.setSuccess(false);
            // 并拿到响应中的错误消息
            result.setError(resObj.getString("message"));
            // 同时设置 原始的响应对象
            result.setRaw(resObj);
            return result;
        }
        JSONObject data = resObj.getJSONObject("data");
        result.setSuccess(true);
        // 响应成功,这里将原始的业务消息序列化为对象响应
        result.setData(data.toJavaObject(SdkTikTokShopTokenRes.class));
        return result;
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31

它自身的响应结果如下

{      
"code":0,      
"message":"success",      
"data":{      
        "access_token":"ROW_Fw8rBwAAAAAkW03FYd09DG-9INtpw361hWthei8S3fHX8iPJ5AUv99fLSCYD9-UucaqxTgNRzKZxi5-tfFMtdWqglEt5_iCk",      
        "access_token_expire_in":1660556783,      
        "refresh_token":"NTUxZTNhYTQ2ZDk2YmRmZWNmYWY2YWY2YzkxNGYwNjQ3YjkzYTllYjA0YmNlMw",      
        "refresh_token_expire_in":1691487031,      
        "open_id":"7010736057180325637",      
        "seller_name":"Jjj test shop",      
        "seller_base_region":"ID",      
        "user_type":0      
    },      
"request_id":"2022080809462301024509910319695C45"      
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15

其实它也有通用的响应结构,本例中由于是刚开始接触 TIKTOK 所以不是很确定是不是所有接口都是这种统一的结构,所以这里并没有单独构建对象来承载

对于响应的 data 对象,也就是我们需要的对象,这个使用一个类来承载

package cn.mrcode.sdk.tiktok.shop.dto;

import com.alibaba.fastjson2.annotation.JSONField;
import lombok.Data;
import lombok.ToString;

import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;

/**
 * @author mrcode
 * @date 2023/12/19 10:18
 */
@Data
@ToString
public class SdkTikTokShopTokenRes {
    // 使用别名方式,在反序列化的时候可以识别 access_token 字段填充到该字段
    @JSONField(alternateNames = {"access_token"})
    private String accessToken;

    private LocalDateTime accessTokenExpireIn;
    @JSONField(alternateNames = {"refresh_token"})
    private String refreshToken;
    private LocalDateTime refreshTokenExpireIn;
    @JSONField(alternateNames = {"open_id"})
    private String openId;
    @JSONField(alternateNames = {"seller_name"})
    private String sellerName;
    @JSONField(alternateNames = {"seller_base_region"})
    private String sellerBaseRegion;
    @JSONField(alternateNames = {"user_type"})
    private Integer userType;

    // 对于过期时间,我们需要自己处理转换为 JDK 8 的对象
    @JSONField(alternateNames = {"access_token_expire_in"})
    public void setAccessTokenExpireIn(int accessTokenExpireIn) {
        this.accessTokenExpireIn = LocalDateTime.ofInstant(Instant.ofEpochSecond(accessTokenExpireIn), ZoneId.systemDefault());
    }

    @JSONField(alternateNames = {"refresh_token_expire_in"})
    public void setRefreshTokenExpireIn(int refreshTokenExpireIn) {
        this.refreshTokenExpireIn = LocalDateTime.ofInstant(Instant.ofEpochSecond(refreshTokenExpireIn), ZoneId.systemDefault());
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46

# SdkTiktokRes 通用响应结果类设计思想

下面重点来说说 SdkTiktokRes 这个通用的调用结果响应类的设计思想

package cn.mrcode.sdk.tiktok.shop.dto;

import lombok.*;

/**
 * 包装统一的响应
 * @author mrcode
 * @date 2023/12/19 11:03
 */
@Data
@ToString
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class SdkTiktokRes<T> {
    private Integer code;
    private boolean success;
    private String error;
    // 原始请求对象,只有当 success = false 的时候有该值
    private Object raw;
    private T data;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
  • code:tiktok 的所有通用结果的响应 code 码,其实这个并不重要,如果有通用的就拿过来,没有也就算了
  • success:调用我们封装的 sdk 是否正常响应,为 true 的时候,一定是预期的响应结果
  • error:有几种方式
    • 当 code (tiktok 的响应码)不是成功码的时候,用来记录 tiktok api 响应的错误消息
    • 当我们处理,或者调用失败的时候,这个时候可能并没有调用通 tiktok 的 api,就会记录我们自己的错误信息
  • raw:当 success = false 的时候,会记录原始 api 调用响应的结果

这样做的原因是因为,你刚接触一个 API 体系的时候,你并不能了解到所有的情况,所以这个基本上会在使用过程中如果调用失败,你还能拿到原始的错误信息,去分析

  • 我们调用 tiktok 需要的业务数据

# 看看业务中是如何使用封装好的 SDK

该 API 使用比较简单,调用后判定是否成功,如果失败就往前面抛出异常就行了

    @Override
    public void handlerAuthorize(StateCacheItem cacheItem, String code) {
        int tenantId = commonService.getTenantId(cacheItem.getUserId());
        TiktokAppItem account = tiktokAppService.getAccount(cacheItem.getAccountName());
        SdkTiktokRes<SdkTikTokShopTokenRes> result = sdkTiktokShopAuthService.tokenGet(account, code);
        if (!result.isSuccess()) {
            log.error("授权失败:{}", result);
            throw new AppACodeApiException(result.getError());
        }
        SdkTikTokShopTokenRes data = result.getData();
        String openId = data.getOpenId();
    	...
    }
1
2
3
4
5
6
7
8
9
10
11
12
13

该 API 使用和上面不太一样了,不过调用之后,都要判断是否调用成功,不成功获取到错误信息记录到数据库


    public void handler(Long id) {
        TntTiktokShop shop = tiktokShopService.getById(id);
        String devAccount = shop.getDevAccount();
        TiktokAppItem account = tiktokAppService.getAccount(devAccount);
        try {

            SdkTiktokRes<SdkTikTokShopTokenRes> res = sdkTiktokShopAuthService.tokenRefresh(account, shop.getRefreshToken());
            if (res.isSuccess()) {
                SdkTikTokShopTokenRes data = res.getData();
                // 请求成功,更新数据
                TntTiktokShop r = new TntTiktokShop();
                r.setId(shop.getId());
                r.setAccessToken(data.getAccessToken());
                r.setAccessTokenExpireIn(data.getAccessTokenExpireIn());
                r.setRefreshToken(data.getRefreshToken());
                r.setRefreshTokenExpireIn(data.getRefreshTokenExpireIn());
                r.setSellerName(data.getSellerName());
                r.setSellerBaseRegion(data.getSellerBaseRegion());
                r.setUserType(data.getUserType());
                r.setIsReAuth(false);
                r.setReAuthRemark("");
                repoService.service.updateById(r);
            } else {
                // 请求失败,建议重新授权
                TntTiktokShop r = new TntTiktokShop();
                r.setId(shop.getId());
                r.setIsReAuth(true);
                String reAuthRemark = STR."自动刷新 TOKEN 失败,建议重新授权店铺,错误信息:\{res.getError()}";
                if (reAuthRemark.length() > 1000) {
                    reAuthRemark = STR."\{reAuthRemark.substring(0, 1000)} ...";
                }
                r.setReAuthRemark(reAuthRemark);
                r.setReAuthTime(LocalDateTime.now());
                repoService.service.updateById(r);
            }
        } catch (Exception e) {
            log.error(STR."自动刷新 TOKEN 异常,\{shop.getId()}", e);
        }
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

# 封装 API - 进阶

随着你的进度,封装的 API 多的时候,就发现有一些共性,比如前面没有将统一的响应结构封装成对象,下面看看一些相对较为复杂的 SDK 封装方式

下面以 商品搜索 和 商品详情的 API 来演示

package cn.mrcode.sdk.tiktok.shop.product;

import cn.hutool.core.net.url.UrlBuilder;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.alibaba.fastjson2.JSONObject;
import lombok.extern.slf4j.Slf4j;
import cn.mrcode.config.tiktok.TiktokAppItem;
import cn.mrcode.sdk.tiktok.shop.SkdTiktokShopSignature;
import cn.mrcode.sdk.tiktok.shop.dto.SdkTiktokRes;
import cn.mrcode.sdk.tiktok.shop.product.dto.SdkTiktokProductGetReq;
import cn.mrcode.sdk.tiktok.shop.product.dto.search.SdkTiktokProductSearchReq;
import cn.mrcode.sdk.tiktok.shop.product.dto.search.SdkTiktokProductSearchRes;
import org.springframework.stereotype.Component;

import java.time.Instant;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

import static net.meshop.cloud_store.sdk.tiktok.shop.SdkTiktokShopCommonService.buildResp;

/**
 * 店铺产品服务
 *
 * @author zhuqiang
 * @date 2023/12/21 15:49
 */
@Component
@Slf4j
public class SdkTiktokShopProductService {
    public static final int TIMEOUT_MILLIS = (int) TimeUnit.SECONDS.toMillis(30);

    /**
     商品列表搜索
    */
    public SdkTiktokRes<SdkTiktokProductSearchRes> search(TiktokAppItem app, SdkTiktokProductSearchReq params) {
        String queryPath = STR."/product/\{app.getOpenApiVersion()}/products/search";
        String apiUrl = STR."\{app.getOpenApiHost()}\{queryPath}";

        HashMap<String, Object> queries = new HashMap<>();
        // 公共参数
        queries.put("app_key", app.getAppKey());
        queries.put("timestamp", Instant.now().getEpochSecond());
        queries.put("page_size", params.getPageSize());
        queries.put("shop_cipher", params.getShopCipher());
        String pageToken = params.getPageToken();
        if (pageToken != null) {
            queries.put("page_token", pageToken);
        }

        JSONObject reqBodyBody = new JSONObject();
        String reqBody = reqBodyBody.toJSONString();

        String sign = SkdTiktokShopSignature.sign(app.getAppSecret(), queryPath, queries, reqBody);
        queries.put("sign", sign);

        UrlBuilder urlBuilder = UrlBuilder.of(apiUrl);
        for (Map.Entry<String, Object> entry : queries.entrySet()) {
            urlBuilder.addQuery(entry.getKey(), entry.getValue());
        }

        HttpResponse httpResponse = HttpRequest.post(urlBuilder.build())
                .header("content-type", "application/json")
                .header("x-tts-access-token", params.getXTtsAccessToken())
                .body(reqBody)
                .timeout(TIMEOUT_MILLIS)
                .execute();
        return buildResp(httpResponse, SdkTiktokRes::new, item -> ((JSONObject) item).toJavaObject(SdkTiktokProductSearchRes.class));
    }

    /**
        单个商品详情获取
    */
    public SdkTiktokRes<JSONObject> get(TiktokAppItem app, SdkTiktokProductGetReq params) {
        String queryPath = STR."/product/\{app.getOpenApiVersion()}/products/\{params.getProductId()}";
        String apiUrl = STR."\{app.getOpenApiHost()}\{queryPath}";

        HashMap<String, Object> queries = new HashMap<>();
        // 公共参数
        queries.put("app_key", app.getAppKey());
        queries.put("timestamp", Instant.now().getEpochSecond());
        queries.put("shop_cipher", params.getShopCipher());

        String sign = SkdTiktokShopSignature.sign(app.getAppSecret(), queryPath, queries, null);
        queries.put("sign", sign);

        UrlBuilder urlBuilder = UrlBuilder.of(apiUrl);
        for (Map.Entry<String, Object> entry : queries.entrySet()) {
            urlBuilder.addQuery(entry.getKey(), entry.getValue());
        }

        HttpResponse httpResponse = HttpRequest.get(urlBuilder.build())
                .header("content-type", "application/json")
                .header("x-tts-access-token", params.getXTtsAccessToken())
                .timeout(TIMEOUT_MILLIS)
                .execute();
        return buildResp(httpResponse, SdkTiktokRes::new, item -> (JSONObject) item);
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101

可以先去官网文档看看:

# 通用构建响应对象讲解

构建这个方法的时候,其实不是一开始就想好的,这个是在写了几个 API 之后,发现有太多重复的代码,也就是说是有一定套路的,那么就该重构了,所以出现了这个函数,可以仔细看看前面两个写好的 API,他们只有一小部分是不同的

package cn.mrcode.sdk.tiktok.shop;

import cn.hutool.http.HttpResponse;
import com.alibaba.fastjson2.JSONObject;
import cn.mrcode.sdk.tiktok.shop.dto.SdkTiktokRes;
import org.jetbrains.annotations.NotNull;

import java.util.function.Function;
import java.util.function.Supplier;

/**
 * @author mrcode
 * @date 2023/12/27 18:07
 */
public class SdkTiktokShopCommonService {
    /**
     * 是否是统一返回格式,有些状态码返回统一的 JSON 格式
     * 有些状态码响应的不是 JSON 格式
     * 不过这个方式不是很好,我在看同事的代码的时候,发现 fastjson 还有一个校验 json 格式的工具类
     * 比如使用下面这种方式,就不用这么麻烦的去观察和搜集哪些状态返回的不是 json 数据了
     *  JSONValidator from = JSONValidator.from(result.getData().toString());
        if (!from.validate()) {
            log.error("订单查询 API 未返回 JSON 数据");
            throw new BusinessException("查询站点为:" + url + "的订单失败,原因:" + result.getMessage());
        }
     *
     * @param httpStatusCode
     * @return
     */
    public static boolean isResUnifiedrFomat(int httpStatusCode) {
        return httpStatusCode == 200
                || httpStatusCode == 400
                || httpStatusCode == 401
                || httpStatusCode == 405
                || httpStatusCode == 500
                || httpStatusCode == 504
                || httpStatusCode == 503;
    }

    /**
     * @param httpResponse 使用了 hutool 的请求工具类,所以需要传入响应对象
     * @param supplier 外部初始化结果对象,
     		最开始用来初始化泛型响应结果对象,后面发现从 dataHandler 可以推断出来泛型的对象
            所以实际上这个参数可以不需要,直接在内部 new SdkTiktokRes(); 就行
     * @param dataHandler  由于每个 API 响应的 data 对象可能是 null 可能是一个 obj 对象,也可能是一个数组,也就是说任何结构都可以
     					   所以这个需要需要调用处去处理了,我直接把 data 反序列化的 对象用 Object 给你
                           你调用的地方知道应该改对象是什么,知道怎么去转换成你需要的对象信息
                           
     * @param <T>
     * @return
     */
    @NotNull
    public static <T> SdkTiktokRes<T> buildResp(HttpResponse httpResponse,
                                                Supplier<SdkTiktokRes<T>> supplier,
                                                Function<Object, T> dataHandler
    ) {
        int status = httpResponse.getStatus();
        SdkTiktokRes<T> result = supplier.get();
        String body = httpResponse.body();
        // 直接把原始响应的 body 存入到 raw 里面
        // 因为在某些场景里面,你不可能会需要所有的参数信息,只会关注你需要的字段
        // 但是处理报错后,你又不知道为什么错误,这个时候就可以在 捕获异常的时候,打印这个 res 对象
        // 就能拿到原始的信息,进行分析
        result.setRaw(body);
        // 这里就是前面说的是否返回的 json 数据判定
        if (SdkTiktokShopCommonService.isResUnifiedrFomat(status)) {
            // 先序列化为 obj 对象
            JSONObject resObj = JSONObject.parse(body);
            Integer code = resObj.getInteger("code");
            result.setCode(code);
            // 判定如果响应失败,返回错误消息
            if (code == null || 0 != code) {
                result.setSuccess(false);
                result.setError(resObj.getString("message"));
                result.setRaw(resObj);
                return result;
            }

            // 如果成功,调用数据处理,获得真实解析后的结果
            result.setSuccess(true);
            Object data = resObj.get("data");
            result.setData(dataHandler.apply(data));
            return result;
        }

        result.setSuccess(false);
        return result;
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90

# 商品列表搜索讲解

其实这个就没有什么讲解的了,主要看看构建公共 API 的案例:

  1. 入参
  2. 响应参数
  3. 如何构建通用的响应参数
    /**
     商品列表搜索
    */
    public SdkTiktokRes<SdkTiktokProductSearchRes> search(TiktokAppItem app, SdkTiktokProductSearchReq params) {
        // 构建商品列表搜索的 api 路径,该 API 属于店铺 API,有版本号之分
        String queryPath = STR."/product/\{app.getOpenApiVersion()}/products/search";
        String apiUrl = STR."\{app.getOpenApiHost()}\{queryPath}";

        HashMap<String, Object> queries = new HashMap<>();
        // 公共参数
        queries.put("app_key", app.getAppKey());
        queries.put("timestamp", Instant.now().getEpochSecond());
        queries.put("page_size", params.getPageSize());
        queries.put("shop_cipher", params.getShopCipher());
        String pageToken = params.getPageToken();
        if (pageToken != null) {
            queries.put("page_token", pageToken);
        }

        // 构建请求 body 参数,由于这里没有对接 body 里面的查询参数,所以我这里只是预留了
        JSONObject reqBodyBody = new JSONObject();
        String reqBody = reqBodyBody.toJSONString();
        
    	// 按照店铺 API 的流程进行签名
        String sign = SkdTiktokShopSignature.sign(app.getAppSecret(), queryPath, queries, reqBody);
        queries.put("sign", sign);

        UrlBuilder urlBuilder = UrlBuilder.of(apiUrl);
        for (Map.Entry<String, Object> entry : queries.entrySet()) {
            urlBuilder.addQuery(entry.getKey(), entry.getValue());
        }

        HttpResponse httpResponse = HttpRequest.post(urlBuilder.build())
                .header("content-type", "application/json")
                .header("x-tts-access-token", params.getXTtsAccessToken())
                .body(reqBody)
                .timeout(TIMEOUT_MILLIS)
                .execute();
        return buildResp(httpResponse, SdkTiktokRes::new, item -> ((JSONObject) item).toJavaObject(SdkTiktokProductSearchRes.class));
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

着重看看构建响应结果的调用处

 return buildResp(httpResponse, SdkTiktokRes::new, item -> ((JSONObject) item).toJavaObject(SdkTiktokProductSearchRes.class));
1

# 看看如何构建业务数据结果类

SdkTiktokProductSearchRes 是一个复杂的类,很多属性,是一个分页结果,里面还有子类

package cn.mrcode.sdk.tiktok.shop.product.dto.search;

import cn.hutool.core.util.StrUtil;
import com.alibaba.fastjson2.annotation.JSONField;
import lombok.Data;
import lombok.ToString;

import java.util.List;

/**
 * @author mrcode
 * @date 2023/12/21 17:47
 */
@Data
@ToString
public class SdkTiktokProductSearchRes {
    /**
     * 产品总数量
     */
    @JSONField(alternateNames = {"total_count"})
    private Long totalCount;

    /**
     * 有下一页的时候,才会有该值
     */
    @JSONField(alternateNames = {"next_page_token"})
    private String nextPageToken;

    private List<SdkTiktokProductSearchItem> products;

    /**
     * 是否还有下一页
     *
     * @return
     */
    public boolean hasNextPage() {
        return StrUtil.isNotBlank(nextPageToken);
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
package cn.mrcode.sdk.tiktok.shop.product.dto.search;

import com.alibaba.fastjson2.annotation.JSONField;
import lombok.Data;
import lombok.ToString;
import cn.mrcode.sdk.tiktok.fastjson.LocalDateTimeUnixTimestampObjectDeserializer;

import java.time.LocalDateTime;
import java.util.List;

/**
 * @author mrcode
 * @date 2023/12/25 09:40
 */
@Data
@ToString
public class SdkTiktokProductSearchItem {
    // fast json 反序列化处理类,这里使用 deserializeUsing ,自定义实现了一个 LocalDateTimeUnixTimestampObjectDeserializer 处理类
    // 可以查看该笔记 https://www.yuque.com/mrcode.cn/note-combat/teunqt7cgigsca78#YRCcT
    @JSONField(alternateNames = {"create_time"}, deserializeUsing = LocalDateTimeUnixTimestampObjectDeserializer.class)
    private LocalDateTime createTime;
    private String id;
    
    @JSONField(alternateNames = {"sales_regions"})
    private List<String> salesRegions;
    private List<Sku> skus;
    private String status;
    private String title;
    @JSONField(alternateNames = {"update_time"}, deserializeUsing = LocalDateTimeUnixTimestampObjectDeserializer.class)
    private LocalDateTime updateTime;


    @Data
    @ToString
    public static class Sku {
        private String id;
        private List<Inventory> inventory;
        private Price price;
        @JSONField(alternateNames = {"seller_sku"})
        private String sellerSku;
    }

    @Data
    @ToString
    public static class Inventory {
        private int quantity;
        @JSONField(alternateNames = {"warehouse_id"})
        private String warehouseId;
    }

    @Data
    @ToString
    public static class Price {
        private String currency;
        // 税前价格
        @JSONField(alternateNames = {"tax_exclusive_price"})
        private String taxExclusivePrice;
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60

# 看看如何根据接口构建参数多的入参类

package cn.mrcode.sdk.tiktok.shop.product.dto.search;

import jakarta.validation.constraints.NotNull;
import lombok.Data;
import lombok.ToString;

/**
 * 店铺商品搜索
 * 非常建议,将每个 API 的页面链接写上,能方便的去查看对应的说明
 * @link <a href="https://partner.tiktokshop.com/docv2/page/6503081a56e2bb0289dd6d7d?external_id=6503081a56e2bb0289dd6d7d">
 *       对应文档</a>
 *
 * @author mrcode
 * @date 2023/12/21 16:21
 */
@Data
@ToString
public class SdkTiktokProductSearchReq {
    //~~ 下面是 query 参数
    @NotNull
    private String shopCipher;
    /**
     * 分页大小
     */
    @NotNull
    private Integer pageSize = 100;
    /**
     * 分页游标,第一页不需要
     */
    private String pageToken;

    // ~ 下面是 header 的参数
    /**
     * 店铺 accessToken
     */
    @NotNull
    private String xTtsAccessToken;

    //~~ 下面是 request body 参数
    /**
     * 产品状态,用作产品搜索的筛选标准。
     * 全部、草稿、待定、失败、激活、卖家已停用、平台已停用、冻结、已删除
     * ALL, DRAFT, PENDING, FAILED, ACTIVATE, SELLER_DEACTIVATED, PLATFORM_DEACTIVATED, FREEZE, DELETED
     */
    private String status;
    /**
     * 创建时间 大于等于,单位是 UTC Unix 时间戳
     * 如果 ge 和 le 只有其中一个,另一个则被默认为是当前时间
     */
    private Integer createTimeGe;
    private Integer createTimeLe;

    private Integer updateTimeGe;
    private Integer updateTimeLe;

    public void setPageSize(Integer pageSize) {
        if (pageSize == null || pageSize < 1 || pageSize > 100) {
            this.pageSize = 100;
        } else {
            this.pageSize = pageSize;
        }
    }

    public static void main(String[] args) {
        SdkTiktokProductSearchReq req = new SdkTiktokProductSearchReq();
        req.setPageSize(4);
        System.out.println(req);
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70

# 看看使用处的案例

下面的使用场景,就是同步某个店铺的所有商品信息,分页调用,获取一批处理一批

private String syncProduct(Long shopId) {
        TntTiktokShop shop = tiktokShopService.getById(shopId);
        TiktokAppItem account = tiktokAppService.getAccount(shop.getDevAccount());

    	// 构建请求参数
        SdkTiktokProductSearchReq params = new SdkTiktokProductSearchReq();
        params.setShopCipher(shop.getAtuhCipher());
        params.setXTtsAccessToken(shop.getAccessToken());

        // 调用搜索结果
        SdkTiktokRes<SdkTiktokProductSearchRes> res = sdkTiktokShopProductService.search(account, params);
        // 数据库插入或新增完成
        AtomicInteger dbHandlerCnt = new AtomicInteger();
        boolean isContinue = true;
        do {
            // 判断结果
            if (!res.isSuccess()) {
                return STR."处理过程中异常,已处理 \{dbHandlerCnt} 个商品,异常原因:\{res.getError()}";
            }
            SdkTiktokProductSearchRes data = res.getData();
            if (data.getTotalCount() == 0) {
                return "无商品可处理";
            }
            // 处理商品
            List<SdkTiktokProductSearchItem> products = data.getProducts();
            for (SdkTiktokProductSearchItem product : products) {
                // 处理每一个商品
                handlerProduct(account, shop, product, _ -> dbHandlerCnt.getAndIncrement());
            }
            // 是否还要继续下一页的处理
            if (data.hasNextPage()) {
                params.setPageToken(data.getNextPageToken());
                res = sdkTiktokShopProductService.search(account, params);
            } else {
                isContinue = false;
            }
        } while (isContinue);
        return STR."处理完成,共处理 \{dbHandlerCnt} 个商品";
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39

看看处理每一个商品数据的逻辑,因为这个里面去调用了 **商品详情 **的接口


    private void handlerProduct(TiktokAppItem account, TntTiktokShop shop, SdkTiktokProductSearchItem product, Consumer<Void> handlerFun) {

        String id = product.getId();
        String key = TntTiktokProductService.buildProductKey(shop.getId(), id);
        Releasable lock = productLock.acquire(key);
        try {
            TntTiktokProduct r = new TntTiktokProduct();
            Long dbId = getIdByProductKey(key);
            if (dbId != null) {
                r.setId(dbId);
                // 更新
                r.setUpdatedTime(LocalDateTime.now());
            } else {
                // 新增
                r.setCreatedBy(shop.getCreatedBy());
                r.setCreatedTime(LocalDateTime.now());
                r.setUpdatedTime(LocalDateTime.now());
                r.setTenantId(shop.getTenantId());
                r.setShopId(shop.getId());
                r.setAuthId(shop.getAuthId());
                r.setProductKey(key);
                r.setProductId(id);
            }
            r.setTitle(product.getTitle());
            r.setStatus(product.getStatus());

            SdkTiktokRes<JSONObject> objectSdkTiktokRes = sdkTiktokShopProductService.get(account, SdkTiktokProductGetReq
                    .builder()
                    .shopCipher(shop.getAtuhCipher())
                    .xTtsAccessToken(shop.getAccessToken())
                    .productId(id)
                    .build());
            if (!objectSdkTiktokRes.isSuccess()) {
                return;
            }
            JSONObject data = objectSdkTiktokRes.getData();

            r.setMainImage(Convert.toStr(JSONPath.of("$.main_images[0].urls[0]").eval(data), ""));

            JSONArray skusObj = data.getJSONArray("skus");
            BigDecimal minSalePrice = null;
            BigDecimal maxSalePrice = null;
            String currency = "";
            Integer inventoryCnt = 0;
            if (skusObj != null && !skusObj.isEmpty()) {
                r.setSkus(skusObj.toJSONString());
                // 提取价格
                for (Object o : skusObj) {
                    JSONObject skuObj = (JSONObject) o;
                    String hCurrency = (String) JSONPath.of("$.price.currency").eval(skuObj);
                    if (hCurrency != null) {
                        currency = hCurrency;
                    }
                    BigDecimal hSalePrice = Convert.toBigDecimal(JSONPath.of("$.price.sale_price").eval(skuObj), null);
                    if (hSalePrice != null) {
                        if (minSalePrice == null) {
                            minSalePrice = hSalePrice;
                        }
                        if (maxSalePrice == null) {
                            maxSalePrice = hSalePrice;
                        }
                        if (hSalePrice.compareTo(minSalePrice) < 0) {
                            minSalePrice = hSalePrice;
                        }
                        if (hSalePrice.compareTo(maxSalePrice) > 0) {
                            maxSalePrice = hSalePrice;
                        }
                    }
                    JSONArray inventorysObj = skuObj.getJSONArray("inventory");
                    if (inventorysObj != null && !inventorysObj.isEmpty()) {
                        for (Object inventoryObject : inventorysObj) {
                            JSONObject inventoryObj = (JSONObject) inventoryObject;
                            int quantity = inventoryObj.getIntValue("quantity");
                            inventoryCnt += quantity;
                        }
                    }
                }
                if (minSalePrice == null) {
                    minSalePrice = new BigDecimal("-1");
                }
                if (maxSalePrice == null) {
                    maxSalePrice = new BigDecimal("-1");
                }
            }
            r.setCurrency(currency);
            r.setMinSalePrice(minSalePrice);
            r.setMaxSalePrice(maxSalePrice);
            r.setInventoryCnt(inventoryCnt);
            r.setCategory(Convert.toStr(JSONPath.of("$.category_chains[0].local_name").eval(data), ""));
            r.setDetailStatus(Convert.toStr(data.getString("status"), ""));
            JSONObject rawObj = new JSONObject();
            rawObj.put("product", product);
            rawObj.put("detail", data);
            r.setRaw(rawObj.toJSONString());
            r.setSyncTime(LocalDateTime.now());
            repoService.service.saveOrUpdate(r);
            handlerFun.accept(null);
        } finally {
            lock.close();
        }
    }

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103

现在我来解释为什么商品详情的接口响应参数是 SdkTiktokRes<JSONObject> 了,你可以去看看商品详情里面的属性有多少 (opens new window),嵌套的属性有多深,然而目前的需求只需要部分数据,就不需要 sdk 里面费时费力的全部解析成 JAVA 类了,在外部使用 JSONPath.of("$.price.currency").eval(skuObj) 方式能快速的获取到需要的那几个属性

# 来看看几个 API 使用通用构建响应方法的写法

放到一起对比看看,就知道为什么需要单独实现构建通用响应了

buildResp(httpResponse, SdkTiktokRes::new, item -> ((JSONObject) item).toJavaObject(SdkTiktokProductSearchRes.class));
buildResp(httpResponse, SdkTiktokRes::new, item -> (JSONObject) item);
buildResp(httpResponse, SdkTiktokRes::new, item -> {
            JSONObject data = (JSONObject) item;
            JSONArray shops = data.getJSONArray("shops");
            return shops.toJavaList(SdkTiktokShopAuthorized.class);
        });
buildResp(httpResponse, SdkTiktokRes::new, item -> ((JSONObject) item).toJavaObject(SdkOrderSearchRes.class));
1
2
3
4
5
6
7
8

通过这几个 构建方式来看,节省了一大波构建响应的逻辑,唯一不同的就是 如何处理 API 响应的业务数据,无论是需要原始信息,通过 fastjson 直接反序列化,还是只需要部分信息,还是说需要自己加工后都能满足 同时统一了 API 的调用方式,和实现封装 SDK 的写法(简单说就是模板写法了),而且对于排错上来说也是比较友好

# 总结

  1. 有很多共用的入参,建议抽成单独的类传入,或者做成基类
  2. 一定要做一个统一的响应类

虽然这样做了之后,对于开发来说,有一点很明显的区别就是感觉在调用第三方的接口,而不是像调用本地接口一样 但是好处就是写程序的时候比较健壮和稳定

  1. 通过分析观察:可以写成静态工具类(大部分情况下都可以,除了少数场景,因为没有线程安全问题)

# 封装过程示例

由于有新的需求,这里以拿到第三方 API 文档后,开始研究明白如何调用他们的 API 后,就可以开始封装了 本次以 牛信云 WhatsApp API (opens new window)为例

# 编写第一个接口

有了前面的基础思想,明白了,至少是响应结果必须搞一个统一的类,所以第一个 API 至少要满足这个需求,下面是例子

package cn.mrcode.whatsapp.sdk;

import lombok.*;

/**
 * 包装统一的响应
 * @author mrcode
 * @date 2023/12/19 11:03
 */
@Data
@ToString
@AllArgsConstructor
@NoArgsConstructor
@Builder
public class SdkWhatsAppRes<T> {
    // 为了后面排查问题,把 HTTPStatus 状态码也带上
    private Integer httpStatus;
    // 牛信云的统一响应 code 码
    private Integer code;
    private boolean success;
    private String message;
    // 原始请求对象,只有当 success = false 的时候有该值
    private Object raw;
    private T data;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26

这是第一个接口的接口文档 (opens new window),由于入参比较少,第一个 API 所以看不出来到底要如何封装,下面是第一版,工具类还是使用的是 hutool 的

implementation group: 'cn.hutool', name: 'hutool-all', version:'5.5.8'
// 该项目是一个老项目,所以还是使用的是 fastjson1 
implementation group: 'com.alibaba', name: 'fastjson', version:'1.2.75'
1
2
3
package cn.mrcode.whatsapp.sdk;

import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.JSONValidator;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;

import java.util.Map;
import java.util.concurrent.TimeUnit;

/**
 * <a href="https://www.nxcloud.com/document/wa-jcs/WhatsApp-API-login"> WhatsApp 集成商 API </a>
 *
 * @author zhuqiang
 * @date 2024/2/1 16:45
 */
@Slf4j
public class SdkWhatsAppIntegratorApi {
    public static final int TIMEOUT_MILLIS = (int) TimeUnit.SECONDS.toMillis(30);


    /**
     * 集成商登录
     * @param accessKey 开发账户 accessKey
     * @return
     */
    public static SdkWhatsAppRes<String> embeddedRegisterLogin(String accessKey) {
        // 没有测试环境,只有一个环境,直接在这里写死地址了
        String apiUrl = "https://api2.nxcloud.com/api/wa/integrator/embedded/register/login";
        Map<String, String> headers = SdkWhatsAppCommonApi.buildCommonHeaders(accessKey, "login");
        String sign = SdkWhatsAppCommonApi.calcSign(headers, null, accessKey);
        headers.put("sign", sign);
        HttpResponse httpResponse = HttpUtil.createPost(apiUrl)
                .addHeaders(headers)
                .timeout(TIMEOUT_MILLIS)
                .execute();

        SdkWhatsAppRes<String> res = new SdkWhatsAppRes<>();
        res.setHttpStatus(httpResponse.getStatus());
        String body = httpResponse.body();
        res.setRaw(body);
        // 如果响应的不是一个 JSON 字符串
        if (!JSONValidator.from(body).validate()) {
            res.setSuccess(false);
            res.setMessage("响应不是 JSON 字符串");
            return res;
        }

        // 根据文档响应数据判定是否调用成功
        JSONObject resultObj = JSON.parseObject(body);
        int code = resultObj.getIntValue("code");
        res.setCode(code);
        if (code != 0) {
            res.setSuccess(false);
            res.setMessage(resultObj.getString("message"));
            return res;
        }

        res.setSuccess(true);
        // 响应结果就是一个简单字符类型
        res.setData(resultObj.getString("data"));
        return res;
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68

测试

    @Test
    public void setp1(){
        SdkWhatsAppRes<String> res = SdkWhatsAppIntegratorApi.embeddedRegisterLogin(ACCESS_KEY);
        System.out.println(res);
    }
1
2
3
4
5

输出

SdkWhatsAppRes(httpStatus=200, code=1003, success=false, message=Invalid signature, raw={"code":1003,"data":null,"message":"Invalid signature"}, data=null)
1

看这个结果就非常清晰 出师不利,签名部分写得有问题,排查问题后发现少了 accessSecret 参数,也就是说至少要下面这样

    public static SdkWhatsAppRes<String> embeddedRegisterLogin(String accessKey, String accessSecret) {
        // 没有测试环境,只有一个环境,直接在这里写死地址了
        String apiUrl = "https://api2.nxcloud.com/api/wa/integrator/embedded/register/login";
        Map<String, String> headers = SdkWhatsAppCommonApi.buildCommonHeaders(accessKey, "login");
        String sign = SdkWhatsAppCommonApi.calcSign(headers, null, accessSecret);
        headers.put("sign", sign);
        HttpResponse httpResponse = HttpUtil.createPost(apiUrl)
                .addHeaders(headers)
                .timeout(TIMEOUT_MILLIS)
                .execute();

        SdkWhatsAppRes<String> res = new SdkWhatsAppRes<>();
        res.setHttpStatus(httpResponse.getStatus());
        String body = httpResponse.body();
        res.setRaw(body);
        // 如果响应的不是一个 JSON 字符串
        if (!JSONValidator.from(body).validate()) {
            res.setSuccess(false);
            res.setMessage("响应不是 JSON 字符串");
            return res;
        }

        JSONObject resultObj = JSON.parseObject(body);
        int code = resultObj.getIntValue("code");
        res.setCode(code);
        if (code != 0) {
            res.setSuccess(false);
            res.setMessage(resultObj.getString("message"));
            return res;
        }

        res.setSuccess(true);
        res.setData(resultObj.getString("data"));
        return res;
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35

改完之后,再次测试发现成功了

SdkWhatsAppRes(httpStatus=200, code=0, success=true, message=null, raw={"code":0,"data":{"token":"ebc5cfa9-sss"},"message":"请求成功"}, data={"token":"ebc5cfa9-sss"})
1

WARNING

这里获取 data 的时候,忘记拿到最里面的 token 了,因为这里就只有一个字段 后面重构的时候修复这个问题

# 编写第二个接口

客户应用的号码 - 接口文档 (opens new window)

在写第二个接口的时候,在前面发现关于开发者账户的入参就有 2 个了,而且每个接口几乎上都会用到这两个参数,所以封装一个开发者账户的类

package cn.mrcode.whatsapp.sdk.dto;

import lombok.Data;
import lombok.ToString;

/**
 * 牛信云,开发者账户
 * @author mrcode
 * @date 2024/2/2 13:21
 */
@Data
@ToString
public class SdkWhatsAppAccount {
    private String accessKey;
    private String accessSecret;
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

对于接口响应的数据

{
  "code": 0,
  "data": [
    {
      "display_phone_number": "+86 xxx 24",
      "quality_rating": "GREEN",
      "name_status": "APPROVED",
      "verified_name": "GLORY JACK",
      "code_verification_status": "VERIFIED",
      "current_limit": "",
      "register_status": 1,
      "status": "CONNECTED",
      "waba_id": "11696xxxxx86404",
      "waba_name": "GLORY TEST"
    },
    {
      "display_phone_number": "+1 xxx99",
      "quality_rating": "GREEN",
      "name_status": "APPROVED",
      "verified_name": "Glory HK",
      "code_verification_status": "NOT_VERIFIED",
      "current_limit": "",
      "register_status": 1,
      "status": "CONNECTED",
      "waba_id": "11696xxxxx86404",
      "waba_name": "GLORY TEST"
    }
  ],
  "message": "请求成功"
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30

我们需要的还是 data 里面的数据,外面的和第一个接口的逻辑一样,data 里面的数据较多,所以封装成类

package ccn.mrcode.whatsapp.sdk.dto;

import com.alibaba.fastjson.annotation.JSONField;
import lombok.Data;
import lombok.ToString;

/**
 * 注册的电话号码
 * @author mrcode
 * @date 2024/2/2 13:31
 */
@Data
@ToString
public class SdkWhatsAppPhone {
    /**
     * 注册WhatsApp的号码
     */
    @JSONField(alternateNames = {"display_phone_number"})
    public String displayPhoneNumber;

    @JSONField(alternateNames = {"quality_rating"})
    /**
     * 号码质量枚举 枚举值: GREEN: 高质量;YELLOW:中质量;
     * RED:低质量 ;UNKNOW:质量未知
     */
    public String qualityRating;
    /**
     * 号码的显示名
     */
    @JSONField(alternateNames = {"verified_name"})
    public String verifiedName;

    /**
     * 名称审核状态枚举
     * 枚举值:
     * APPROVED: 名称尚未获得批准。
     * NONE: 没有可用证书
     * AVAILABLE_WITHOUT_REVIEW: 可下载电话号码的证书,而且无需进行审核,即可使用显示名
     * DECLINED: 名称尚未获得批准。您不能下载证书
     * EXPIRED: 您的证书已过期,不可再下载
     * PENDING_REVIEW: 您的名称请求正在审核中。您不能下载证书
     */
    @JSONField(alternateNames = {"name_status"})
    public String nameStatus;
    /**
     * 连接状态
     * 1 创建失败(本地客户端)
     * 2 注册本地客户端失败
     * 3 创建中(本地客户端)
     * 4 创建成功(本地客户端)
     * 5 注册本地客户端成功
     */
    @JSONField(alternateNames = {"register_status"})
    public int registerStatus;

    /**
     * 号码状态枚举
     * 枚举值:
     * PENDING, DELETED,
     * MIGRATED, BANNED,
     * RESTRICTED, RATE_LIMITED,
     * FLAGGED, CONNECTED,
     * DISCONNECTED,UNKNOWN,
     * UNVERIFIED
     */
    @JSONField(alternateNames = {"status"})
    public String status;



    /**
     * 商户发起会话24小时限制等级枚举
     * 枚举值:
     * TIER_1K: 1 千位客户/24 小时
     * TIER_10K: 1 万位客户/24 小时
     * TIER_100K: 10万位客户/24 小时
     * TIER_50: 50 位客户/24 小时
     * TIER_250: 250 位客户/24 小时
     * TIER_UNLIMITED: 不适用
     */
    @JSONField(alternateNames = {"current_limit"})
    public String currentLimit;

    /**
     * 号码验证状态枚举
     * 枚举值:
     * NOT_VERIFIED: 未验证 VERIFIED: 已验证
     */
    @JSONField(alternateNames = {"code_verification_status"})
    public String codeVerificationStatus;

    /**
     * WABA唯一ID
     */
    @JSONField(alternateNames = {"waba_id"})
    public String wabaId;

    /**
     * WABA名称
     */
    @JSONField(alternateNames = {"waba_name"})
    public String wabaName;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104

这里介绍下生成的技巧,使用 ChatGPT 生成,一共两个问题就行:

  1. 生成 类

{ "display_phone_number": "+86 xxx 24", "quality_rating": "GREEN", "name_status": "APPROVED", "verified_name": "GLORY JACK", "code_verification_status": "VERIFIED", "current_limit": "", "register_status": 1, "status": "CONNECTED", "waba_id": "11696xxxxx86404", "waba_name": "GLORY TEST" }

请将这个数据转换为 java 类,不要生成 setter 和 getter 方法
1

image.png

  1. 添加 @JSONField 注解
请给每个字段添加 @JSONField 注解,比如
@JSONField(alternateNames = {"display_phone_number"})
public String displayPhoneNumber;
1
2
3

image.png 这就快速的生成了对象类,不过在字段较多的时候,生成的结果可能会少,所以建议生成后,再对着文档,将每个字段的说明添加到类上,这样一来也达到了检查的目的,也完善了 SDK 的完整性

最后按照第一个结构的写法完成第二个接口

package cn.mrcode.whatsapp.sdk;

import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.JSONValidator;
import cn.mrcode.whatsapp.sdk.dto.SdkWhatsAppAccount;
import cn.mrcode.sdk.dto.SdkWhatsAppPhone;
import lombok.extern.slf4j.Slf4j;

import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;

@Slf4j
public class SdkWhatsAppIntegratorApi {
    public static final int TIMEOUT_MILLIS = (int) TimeUnit.SECONDS.toMillis(30);

    /**
     * 集成商查询客户应用的号码列表(仅供集成商使用)
     *
     * @param account
     * @param appkey
     * @return
     */
    public static SdkWhatsAppRes<List<SdkWhatsAppPhone>> phoneList(SdkWhatsAppAccount account, String appkey) {
        // 没有测试环境,只有一个环境,直接在这里写死地址了
        String apiUrl = "https://api2.nxcloud.com/api/wa/integrator/embedded/register/phoneList";
        Map<String, String> headers = SdkWhatsAppCommonApi.buildCommonHeaders(account.getAccessKey(), "phoneList");
        JSONObject bodyParams = new JSONObject();
        bodyParams.put("appkey", appkey);
        String bodyParamsJSONString = bodyParams.toJSONString();
        String sign = SdkWhatsAppCommonApi.calcSign(headers, bodyParamsJSONString, account.getAccessSecret());
        headers.put("sign", sign);
        HttpResponse httpResponse = HttpUtil.createPost(apiUrl)
                .addHeaders(headers)
                .body(bodyParamsJSONString)
                .timeout(TIMEOUT_MILLIS)
                .execute();

        SdkWhatsAppRes<List<SdkWhatsAppPhone>> res = new SdkWhatsAppRes<>();
        res.setHttpStatus(httpResponse.getStatus());
        String body = httpResponse.body();
        res.setRaw(body);
        // 如果响应的不是一个 JSON 字符串
        if (!JSONValidator.from(body).validate()) {
            res.setSuccess(false);
            res.setMessage("响应不是 JSON 字符串");
            return res;
        }

        JSONObject resultObj = JSON.parseObject(body);
        int code = resultObj.getIntValue("code");
        res.setCode(code);
        if (code != 0) {
            res.setSuccess(false);
            res.setMessage(resultObj.getString("message"));
            return res;
        }

        res.setSuccess(true);

        JSONArray objData = resultObj.getJSONArray("data");
        if (objData == null) {
            return res;
        }
        List<SdkWhatsAppPhone> data = objData.toJavaList(SdkWhatsAppPhone.class);
        res.setData(data);
        return res;
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74

完成第二个接口之后,你会发现和第一个好像很多代码都是一样的,特别是在构建响应结果的时候,这里可以来对比看看

        SdkWhatsAppRes<String> res = new SdkWhatsAppRes<>();
        res.setHttpStatus(httpResponse.getStatus());
        String body = httpResponse.body();
        res.setRaw(body);
        // 如果响应的不是一个 JSON 字符串
        if (!JSONValidator.from(body).validate()) {
            res.setSuccess(false);
            res.setMessage("响应不是 JSON 字符串");
            return res;
        }

        JSONObject resultObj = JSON.parseObject(body);
        int code = resultObj.getIntValue("code");
        res.setCode(code);
        if (code != 0) {
            res.setSuccess(false);
            res.setMessage(resultObj.getString("message"));
            return res;
        }

        res.setSuccess(true);
        res.setData(resultObj.getString("data"));
        return res;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
SdkWhatsAppRes<List<SdkWhatsAppPhone>> res = new SdkWhatsAppRes<>();
res.setHttpStatus(httpResponse.getStatus());
String body = httpResponse.body();
res.setRaw(body);
// 如果响应的不是一个 JSON 字符串
if (!JSONValidator.from(body).validate()) {
    res.setSuccess(false);
    res.setMessage("响应不是 JSON 字符串");
    return res;
}

JSONObject resultObj = JSON.parseObject(body);
int code = resultObj.getIntValue("code");
res.setCode(code);
if (code != 0) {
    res.setSuccess(false);
    res.setMessage(resultObj.getString("message"));
    return res;
}

res.setSuccess(true);

JSONArray objData = resultObj.getJSONArray("data");
if (objData == null) {
    return res;
}
List<SdkWhatsAppPhone> data = objData.toJavaList(SdkWhatsAppPhone.class);
res.setData(data);
return res;
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29

� 上面着色的都是完全一样的代码,不一样的在于如何处理接口响应中的 data 的数据,那么就可以学本章前面章节说的 通用构建响应对象讲解 的思路来重构一个公共的构建响应的方法(建议公共的方法在单独类中,前面的代码中其实也用到了两个公共方法,这里一起贴出来)

package cn.mrcode.whatsapp.sdk;

import cn.hutool.http.HttpResponse;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.JSONValidator;
import com.meshop.crm.service.whatsapp.sdk.dto.SdkWhatsAppPhone;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.codec.digest.DigestUtils;
import org.apache.commons.lang3.StringUtils;

import java.time.Instant;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;

/**
 * @author mrcode
 * @date 2024/2/1 16:49
 */
@Slf4j
public class SdkWhatsAppCommonApi {
    /**
     * 构建公共的请求头
     *
     * @param accessKey
     * @param action
     * @return
     */
    public static Map<String, String> buildCommonHeaders(String accessKey, String action) {
        Map<String, String> headers = new HashMap<>(8);
        headers.put("accessKey", accessKey);
        headers.put("ts", Instant.now().toEpochMilli() + "");
        headers.put("bizType", "2");
        headers.put("action", action);
        return headers;
    }

    /**
     * 计算 sign 签名
     *
     * @param headers      请求头中的公共参数
     * @param body         body中的json字符串
     * @param accessSecret 秘钥
     * @return
     */
    public static String calcSign(Map<String, String> headers, String body, String accessSecret) {
        StringBuilder raw = new StringBuilder();

        // step1: 拼接header参数
        raw.append("accessKey=").append(headers.get("accessKey")).append("&action=").append(headers.get("action"))
                .append("&bizType=").append(headers.get("bizType")).append("&ts=").append(headers.get("ts"));

        log.debug("step1: {}", raw); // step1: accessKey=fme2na3kdi3ki&action=send&bizType=1&ts=1655710885431

        // step2: 拼接body参数
        if (StringUtils.isNotEmpty(body)) {
            raw.append("&body=").append(body);
        }
        log.debug("step2: {}", raw); // step2: accessKey=fme2na3kdi3ki&action=send&bizType=1&ts=1655710885431&body={"name":"牛小信","id":10001}

        // step3: 拼接accessSecret
        raw.append("&accessSecret=").append(accessSecret);
        log.debug("step3: {}", raw); // step3: accessKey=fme2na3kdi3ki&action=send&bizType=1&ts=1655710885431&body={"name":"牛小信","id":10001}&accessSecret=abciiiko2k3

        // step4: MD5算法加密,结果转换成十六进制小写
        String sign = DigestUtils.md5Hex(raw.toString());
        log.debug("step4: sign={}", sign); // step4: sign=87c3560d3331ae23f1021e2025722354

        return sign;
    }

    /**
     * 构建响应
     *
     * @param httpResponse
     * @param dataHandler  数据处理,请求接口后 data 中的数据转换处理,根据对应问题对象的类型不同,比如有可能是 JSONObject 或则 JSONArray
     * @param <T>
     * @return
     */
    public static <T> SdkWhatsAppRes<T> buildResp(HttpResponse httpResponse, Function<Object, T> dataHandler) {
        SdkWhatsAppRes<T> res = new SdkWhatsAppRes<>();
        res.setHttpStatus(httpResponse.getStatus());
        String body = httpResponse.body();
        res.setRaw(body);
        // 如果响应的不是一个 JSON 字符串
        if (!JSONValidator.from(body).validate()) {
            res.setSuccess(false);
            res.setMessage("响应不是 JSON 字符串");
            return res;
        }

        JSONObject resultObj = JSON.parseObject(body);
        int code = resultObj.getIntValue("code");
        res.setCode(code);
        if (code != 0) {
            res.setSuccess(false);
            res.setMessage(resultObj.getString("message"));
            return res;
        }

        res.setSuccess(true);
        Object objData = resultObj.get("data");
        if (objData == null) {
            // 本身就没有数据,直接返回结果
            return res;
        }
        // 有数据的情况下,需要委托调用处处理
        T data = dataHandler.apply(objData);
        res.setData(data);
        return res;
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116

# 重构现有代码

前面有了公共的响应构建,现在来重构前面两个已经写完的接口代码

package cn.mrcode.whatsapp.sdk;

import cn.hutool.http.HttpResponse;
import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson.JSON;
import com.alibaba.fastjson.JSONArray;
import com.alibaba.fastjson.JSONObject;
import com.alibaba.fastjson.JSONValidator;
import cn.mrcode.whatsapp.sdk.dto.SdkWhatsAppAccount;
import cn.mrcode.sdk.dto.SdkWhatsAppPhone;
import lombok.extern.slf4j.Slf4j;

import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit;

import static cn.mrcode.whatsapp.sdk.SdkWhatsAppCommonApi.*;

@Slf4j
public class SdkWhatsAppIntegratorApi {
    public static final int TIMEOUT_MILLIS = (int) TimeUnit.SECONDS.toMillis(30);

    /**
     * 集成商登录; 返回的 token  5 小时过期
     *
     * @param accessKey 开发账户 accessKey
     * @return
     */
    public static SdkWhatsAppRes<String> login(String accessKey, String accessSecret) {
        // 没有测试环境,只有一个环境,直接在这里写死地址了
        String apiUrl = "https://api2.nxcloud.com/api/wa/integrator/embedded/register/login";
        Map<String, String> headers = buildCommonHeaders(accessKey, "login");
        String sign = calcSign(headers, null, accessSecret);
        headers.put("sign", sign);
        HttpResponse httpResponse = HttpUtil.createPost(apiUrl)
                .addHeaders(headers)
                .timeout(TIMEOUT_MILLIS)
                .execute();
  		return buildResp(httpResponse, data -> ((JSONObject) data).getString("token"));
    }

    /**
     * 集成商查询客户应用的号码列表(仅供集成商使用)
     *
     * @param account
     * @param appkey
     * @return
     */
    public static SdkWhatsAppRes<List<SdkWhatsAppPhone>> phoneList(SdkWhatsAppAccount account, String appkey) {
        // 没有测试环境,只有一个环境,直接在这里写死地址了
        String apiUrl = "https://api2.nxcloud.com/api/wa/integrator/embedded/register/phoneList";
        Map<String, String> headers = buildCommonHeaders(account.getAccessKey(), "phoneList");
        JSONObject bodyParams = new JSONObject();
        bodyParams.put("appkey", appkey);
        String bodyParamsJSONString = bodyParams.toJSONString();
        String sign = calcSign(headers, bodyParamsJSONString, account.getAccessSecret());
        headers.put("sign", sign);
        HttpResponse httpResponse = HttpUtil.createPost(apiUrl)
                .addHeaders(headers)
                .body(bodyParamsJSONString)
                .timeout(TIMEOUT_MILLIS)
                .execute();

        return buildResp(httpResponse, data -> ((JSONArray) data).toJavaList(SdkWhatsAppPhone.class));
    }
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67

可以看到,这个代码瞬间就清爽了很多

# 给定一个文件上传的例子

hutool 文件上传官网文档 (opens new window)HttpRequest 方式也是类似的 (opens new window)

    /**
     * 上传模板示例文件
     *
     * @param account
     * @param params
     * @return
     */
    public static SdkWhatsAppRes<String> uploadTemplateFile(SdkWhatsAppAccount account, UploadTemplateFileReq params) {
        // 没有测试环境,只有一个环境,直接在这里写死地址了
        String apiUrl = "https://api2.nxcloud.com/api/wa/uploadTemplateFile";
        Map<String, String> headers = buildCommonHeaders(account.getAccessKey(), "uploadTemplateFile");

        JSONObject bodyParams = new JSONObject();
        bodyParams.put("business_phone", params.getBusinessPhone());
        bodyParams.put("messaging_product", params.getMessagingProduct());
        bodyParams.put("type", params.getType());
        // 直接传入 File 对象
        bodyParams.put("file", params.getFile());

        String sign = calcSign(headers, null, account.getAccessSecret());
        headers.put("sign", sign);
        HttpResponse httpResponse = HttpUtil.createPost(apiUrl)
                .addHeaders(headers)
                // 用 form
                .form(bodyParams)
                .timeout(TIMEOUT_MILLIS)
                .execute();
        return buildResp(httpResponse, data -> ((JSONObject) data).getString("id"));
    }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29