# 第三方 API 对接推荐的方式(SDK 方式)
本文:https://www.yuque.com/mrcode.cn/note-combat/tgpynzwe04xlrsdz
对于第三方 API 的对接,也就是封装成 SDK,有两个方面可以考虑:
- 一些公用参数的封装
- 通用的响应结果:指的是对 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();
}
}
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;
}
}
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)
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;
}
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"
}
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());
}
}
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;
}
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();
...
}
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);
}
}
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);
}
}
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;
}
}
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 的案例:
- 入参
- 响应参数
- 如何构建通用的响应参数
/**
商品列表搜索
*/
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));
}
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));
# 看看如何构建业务数据结果类
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);
}
}
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;
}
}
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);
}
}
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} 个商品";
}
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();
}
}
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));
2
3
4
5
6
7
8
通过这几个 构建方式来看,节省了一大波构建响应的逻辑,唯一不同的就是 如何处理 API 响应的业务数据,无论是需要原始信息,通过 fastjson 直接反序列化,还是只需要部分信息,还是说需要自己加工后都能满足 同时统一了 API 的调用方式,和实现封装 SDK 的写法(简单说就是模板写法了),而且对于排错上来说也是比较友好
# 总结
- 有很多共用的入参,建议抽成单独的类传入,或者做成基类
- 一定要做一个统一的响应类
虽然这样做了之后,对于开发来说,有一点很明显的区别就是感觉在调用第三方的接口,而不是像调用本地接口一样 但是好处就是写程序的时候比较健壮和稳定
- 通过分析观察:可以写成静态工具类(大部分情况下都可以,除了少数场景,因为没有线程安全问题)
# 封装过程示例
由于有新的需求,这里以拿到第三方 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;
}
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'
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;
}
}
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);
}
2
3
4
5
输出
SdkWhatsAppRes(httpStatus=200, code=1003, success=false, message=Invalid signature, raw={"code":1003,"data":null,"message":"Invalid signature"}, data=null)
看这个结果就非常清晰 出师不利,签名部分写得有问题,排查问题后发现少了 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;
}
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"})
WARNING
这里获取 data 的时候,忘记拿到最里面的 token 了,因为这里就只有一个字段 后面重构的时候修复这个问题
# 编写第二个接口
在写第二个接口的时候,在前面发现关于开发者账户的入参就有 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;
}
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": "请求成功"
}
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;
}
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 生成,一共两个问题就行:
- 生成 类
{ "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 方法
- 添加 @JSONField 注解
请给每个字段添加 @JSONField 注解,比如
@JSONField(alternateNames = {"display_phone_number"})
public String displayPhoneNumber;
2
3
这就快速的生成了对象类,不过在字段较多的时候,生成的结果可能会少,所以建议生成后,再对着文档,将每个字段的说明添加到类上,这样一来也达到了检查的目的,也完善了 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;
}
}
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;
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;
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;
}
}
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));
}
}
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"));
}
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