一、淘宝开放平台接入准备
1.1 权限申请流程
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ 注册开发者 │ → │ 创建应用 │ → │ 申请API权限 │ → │ 获取授权 │
│ (淘宝联盟) │ │ (选择类型) │ │ (类目审核) │ │ (OAuth2.0) │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
| 关键要素 | 说明 | 获取方式 |
|---|
| App Key | 应用标识 | 开放平台控制台创建应用 |
| App Secret | 应用密钥 | 与App Key配对生成 |
| Session Key | 用户授权令牌 | OAuth2.0授权流程 |
| API权限 | item.get等接口调用权 | 按类目申请,需审核 |
1.2 开发环境配置
<!-- pom.xml 依赖配置 -->
<dependencies>
<!-- 淘宝SDK(官方推荐) -->
<dependency>
<groupId>com.taobao.api</groupId>
<artifactId>taobao-sdk-java-auto</artifactId>
<version>20230824</version>
</dependency>
<!-- 或手动集成:HTTP客户端 -->
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<!-- JSON处理 -->
<dependency>
<groupId>com.alibaba.fastjson2</groupId>
<artifactId>fastjson2</artifactId>
<version>2.0.43</version>
</dependency>
<!-- 测试框架 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
<scope>test</scope>
</dependency>
<!-- 日志 -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.4.14</version>
</dependency>
</dependencies>
二、核心实现:淘宝API客户端
2.1 基础API客户端封装
package com.taobao.api.test;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONObject;
import okhttp3.*;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.*;
import java.util.concurrent.TimeUnit;
/**
* 淘宝API客户端(基于TOP协议)
* 文档:https://open.taobao.com/doc.htm
*/
public class TaobaoApiClient {
private static final String API_URL = "https://gw.api.taobao.com/router/rest";
private static final String API_VERSION = "2.0";
private static final String SIGN_METHOD = "hmac-sha256"; // 或 md5
private final String appKey;
private final String appSecret;
private final OkHttpClient httpClient;
public TaobaoApiClient(String appKey, String appSecret) {
this.appKey = appKey;
this.appSecret = appSecret;
this.httpClient = new OkHttpClient.Builder()
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.writeTimeout(10, TimeUnit.SECONDS)
.connectionPool(new ConnectionPool(10, 5, TimeUnit.MINUTES))
.addInterceptor(new RetryInterceptor(3)) // 重试3次
.build();
}
/**
* 调用淘宝API(通用方法)
*/
public ApiResponse call(String apiName, Map<String, String> params, String sessionKey)
throws ApiException {
// 1. 构建公共参数
Map<String, String> allParams = new HashMap<>();
allParams.put("method", apiName);
allParams.put("app_key", appKey);
allParams.put("timestamp", LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")));
allParams.put("format", "json");
allParams.put("v", API_VERSION);
allParams.put("sign_method", SIGN_METHOD);
// 2. 业务参数
if (params != null) {
allParams.putAll(params);
}
// 3. 用户授权参数(需要SessionKey的接口)
if (sessionKey != null) {
allParams.put("session", sessionKey);
}
// 4. 生成签名
String sign = generateSign(allParams, appSecret, SIGN_METHOD);
allParams.put("sign", sign);
// 5. 构建请求
FormBody.Builder formBuilder = new FormBody.Builder();
allParams.forEach(formBuilder::add);
Request request = new Request.Builder()
.url(API_URL)
.post(formBuilder.build())
.header("Content-Type", "application/x-www-form-urlencoded")
.build();
// 6. 执行请求
try (Response response = httpClient.newCall(request).execute()) {
if (!response.isSuccessful()) {
throw new ApiException("HTTP错误: " + response.code());
}
String body = response.body().string();
JSONObject json = JSON.parseObject(body);
// 7. 检查API错误
if (json.containsKey("error_response")) {
JSONObject error = json.getJSONObject("error_response");
throw new ApiException(
error.getString("msg"),
error.getString("sub_msg"),
error.getString("code")
);
}
return new ApiResponse(json, apiName);
} catch (IOException e) {
throw new ApiException("请求失败: " + e.getMessage(), e);
}
}
/**
* 生成签名(HMAC-SHA256)
*/
private String generateSign(Map<String, String> params, String secret, String method) {
try {
// 1. 参数排序
List<String> keys = new ArrayList<>(params.keySet());
Collections.sort(keys);
// 2. 拼接字符串
StringBuilder sb = new StringBuilder();
if (method.equals("md5")) {
sb.append(secret);
}
for (String key : keys) {
String value = params.get(key);
if (value != null && !value.isEmpty()) {
sb.append(key).append(value);
}
}
if (method.equals("md5")) {
sb.append(secret);
// MD5签名
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] digest = md.digest(sb.toString().getBytes(StandardCharsets.UTF_8));
return bytesToHex(digest).toUpperCase();
} else {
// HMAC-SHA256签名
Mac mac = Mac.getInstance("HmacSHA256");
SecretKeySpec keySpec = new SecretKeySpec(
(secret + "&").getBytes(StandardCharsets.UTF_8),
"HmacSHA256"
);
mac.init(keySpec);
byte[] digest = mac.doFinal(
sb.toString().getBytes(StandardCharsets.UTF_8)
);
return bytesToHex(digest).toUpperCase();
}
} catch (Exception e) {
throw new RuntimeException("签名生成失败", e);
}
}
private String bytesToHex(byte[] bytes) {
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
sb.append(String.format("%02x", b));
}
return sb.toString();
}
/**
* 获取商品详情(taobao.item.get)
*/
public ItemDetail getItemDetail(Long numIid, String fields) throws ApiException {
Map<String, String> params = new HashMap<>();
params.put("num_iid", String.valueOf(numIid));
params.put("fields", fields != null ? fields :
"num_iid,title,price,desc,pic_url,sku,props_name,item_imgs");
ApiResponse response = call("taobao.item.get", params, null);
return response.parseItemDetail();
}
/**
* 带授权的商品查询(taobao.item.seller.get)
*/
public ItemDetail getSellerItem(Long numIid, String sessionKey) throws ApiException {
Map<String, String> params = new HashMap<>();
params.put("num_iid", String.valueOf(numIid));
params.put("fields", "num_iid,title,price,desc,sku,outer_id,quantity");
ApiResponse response = call("taobao.item.seller.get", params, sessionKey);
return response.parseItemDetail();
}
public void close() {
httpClient.dispatcher().executorService().shutdown();
httpClient.connectionPool().evictAll();
}
}
2.2 响应数据模型
package com.taobao.api.test;
import com.alibaba.fastjson2.JSONObject;
import lombok.Data;
import java.math.BigDecimal;
import java.util.List;
@Data
public class ItemDetail {
private Long numIid; // 商品ID
private String title; // 标题
private BigDecimal price; // 价格
private String desc; // 描述
private String picUrl; // 主图
private List<Sku> skus; // SKU列表
private List<ItemImg> itemImgs; // 详情图
private String propsName; // 属性名
private Long quantity; // 库存
@Data
public static class Sku {
private String skuId;
private BigDecimal price;
private Long quantity;
private String properties;
private String outerId;
}
@Data
public static class ItemImg {
private String url;
}
}
class ApiResponse {
private final JSONObject rawData;
private final String apiName;
public ApiResponse(JSONObject data, String apiName) {
this.rawData = data;
this.apiName = apiName;
}
public ItemDetail parseItemDetail() {
// 解析淘宝响应结构:{ "item_get_response": { "item": {...} } }
String responseKey = apiName.replace(".", "_") + "_response";
JSONObject response = rawData.getJSONObject(responseKey);
if (response == null) {
throw new ApiException("响应格式错误,缺少" + responseKey);
}
JSONObject item = response.getJSONObject("item");
return item.toJavaObject(ItemDetail.class);
}
public JSONObject getRawData() {
return rawData;
}
}
class ApiException extends RuntimeException {
private String subMsg;
private String errorCode;
public ApiException(String message) {
super(message);
}
public ApiException(String message, Throwable cause) {
super(message, cause);
}
public ApiException(String message, String subMsg, String errorCode) {
super(message + " | " + subMsg);
this.subMsg = subMsg;
this.errorCode = errorCode;
}
}
三、完整的测试用例
3.1 JUnit 5 测试类
package com.taobao.api.test;
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import static org.junit.jupiter.api.Assertions.*;
import java.math.BigDecimal;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
/**
* 淘宝商品详情API测试套件
* 运行前需配置:APP_KEY, APP_SECRET, SESSION_KEY(部分接口需要)
*/
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
public class TaobaoApiTest {
private static final String APP_KEY = System.getenv("TB_APP_KEY");
private static final String APP_SECRET = System.getenv("TB_APP_SECRET");
private static final String SESSION_KEY = System.getenv("TB_SESSION_KEY");
private TaobaoApiClient client;
@BeforeAll
void setUp() {
Assumptions.assumeTrue(APP_KEY != null, "未配置TB_APP_KEY环境变量");
client = new TaobaoApiClient(APP_KEY, APP_SECRET);
}
@AfterAll
void tearDown() {
if (client != null) client.close();
}
// ==================== 基础功能测试 ====================
@Test
@DisplayName("测试获取公开商品详情(taobao.item.get)")
void testGetPublicItemDetail() {
// 使用公开测试商品ID(如:淘宝商品ID)
Long testItemId = 123456789L; // 替换为实际测试商品ID
assertDoesNotThrow(() -> {
ItemDetail item = client.getItemDetail(testItemId,
"num_iid,title,price,pic_url");
assertNotNull(item, "商品详情不应为空");
assertEquals(testItemId, item.getNumIid(), "商品ID应匹配");
assertNotNull(item.getTitle(), "标题不应为空");
assertTrue(item.getPrice().compareTo(BigDecimal.ZERO) > 0,
"价格应大于0");
System.out.println("商品标题: " + item.getTitle());
System.out.println("商品价格: " + item.getPrice());
});
}
@Test
@DisplayName("测试获取私密商品详情(需授权)")
void testGetSellerItemDetail() {
Assumptions.assumeTrue(SESSION_KEY != null, "未配置SESSION_KEY");
Long testItemId = 987654321L; // 店铺内商品ID
ItemDetail item = assertDoesNotThrow(() ->
client.getSellerItem(testItemId, SESSION_KEY)
);
assertNotNull(item.getQuantity(), "库存信息需要授权才能获取");
System.out.println("库存数量: " + item.getQuantity());
}
// ==================== 异常场景测试 ====================
@Test
@DisplayName("测试无效商品ID处理")
void testInvalidItemId() {
Long invalidId = 999999999999L;
ApiException exception = assertThrows(ApiException.class, () -> {
client.getItemDetail(invalidId, "num_iid,title");
});
assertTrue(exception.getMessage().contains("商品不存在") ||
exception.getMessage().contains("isv.item-not-exist"));
}
@Test
@DisplayName("测试无效AppKey处理")
void testInvalidAppKey() {
TaobaoApiClient invalidClient = new TaobaoApiClient("invalid_key", "invalid_secret");
assertThrows(ApiException.class, () -> {
invalidClient.getItemDetail(123L, "num_iid");
});
}
@ParameterizedTest
@ValueSource(strings = {"num_iid,title", "num_iid,title,price,desc,sku", "num_iid"})
@DisplayName("测试不同字段组合")
void testDifferentFields(String fields) {
Long testId = 123456789L;
assertDoesNotThrow(() -> {
ItemDetail item = client.getItemDetail(testId, fields);
assertNotNull(item.getNumIid());
});
}
// ==================== 性能与并发测试 ====================
@Test
@DisplayName("测试接口响应时间(P99 < 500ms)")
void testResponseTime() {
int iterations = 100;
Long testId = 123456789L;
long[] latencies = new long[iterations];
for (int i = 0; i < iterations; i++) {
long start = System.nanoTime();
client.getItemDetail(testId, "num_iid,title,price");
long end = System.nanoTime();
latencies[i] = (end - start) / 1_000_000; // 转换为ms
}
// 计算P99
java.util.Arrays.sort(latencies);
long p99 = latencies[(int)(iterations * 0.99)];
System.out.println("P99 响应时间: " + p99 + "ms");
assertTrue(p99 < 500, "P99响应时间应小于500ms,实际: " + p99 + "ms");
}
@Test
@DisplayName("测试并发限流(淘宝限制:App级别5000次/分钟)")
void testConcurrentRateLimit() throws InterruptedException {
int threadCount = 10;
int requestsPerThread = 20;
Long testId = 123456789L;
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
CountDownLatch latch = new CountDownLatch(threadCount);
AtomicInteger successCount = new AtomicInteger(0);
AtomicInteger rateLimitCount = new AtomicInteger(0);
for (int i = 0; i < threadCount; i++) {
executor.submit(() -> {
try {
for (int j = 0; j < requestsPerThread; j++) {
try {
client.getItemDetail(testId, "num_iid,title");
successCount.incrementAndGet();
} catch (ApiException e) {
if (e.getMessage().contains("限流") ||
e.getMessage().contains("access-limit")) {
rateLimitCount.incrementAndGet();
// 遇到限流,退避等待
Thread.sleep(1000);
}
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
latch.countDown();
}
});
}
latch.await(2, TimeUnit.MINUTES);
executor.shutdown();
int total = threadCount * requestsPerThread;
System.out.println("总请求: " + total);
System.out.println("成功: " + successCount.get());
System.out.println("限流: " + rateLimitCount.get());
// 断言:成功率应 > 80%(考虑限流)
assertTrue(successCount.get() > total * 0.8,
"成功率过低,请检查限流策略");
}
// ==================== 数据完整性测试 ====================
@Test
@DisplayName("测试SKU数据完整性")
void testSkuDataIntegrity() {
Long skuItemId = 111111111L; // 有SKU的商品
ItemDetail item = client.getItemDetail(skuItemId,
"num_iid,title,sku,props_name");
assertNotNull(item.getSkus(), "SKU列表不应为空");
assertFalse(item.getSkus().isEmpty(), "SKU不应为空列表");
for (ItemDetail.Sku sku : item.getSkus()) {
assertNotNull(sku.getSkuId(), "SKU ID不应为空");
assertNotNull(sku.getPrice(), "SKU价格不应为空");
assertTrue(sku.getPrice().compareTo(BigDecimal.ZERO) > 0,
"SKU价格应大于0");
}
}
@ParameterizedTest
@CsvSource({
"123456789, 公开商品",
"987654321, 私密商品",
"111111111, SKU商品"
})
@DisplayName("参数化商品详情测试")
void testMultipleItems(Long itemId, String desc) {
System.out.println("测试[" + desc + "], ID: " + itemId);
assertDoesNotThrow(() -> {
ItemDetail item = client.getItemDetail(itemId,
"num_iid,title,price,pic_url");
assertNotNull(item);
});
}
}
四、高级测试场景
4.1 数据驱动测试(JSON配置)
package com.taobao.api.test;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.TypeReference;
import org.junit.jupiter.api.DynamicTest;
import org.junit.jupiter.api.TestFactory;
import java.io.InputStream;
import java.util.List;
import java.util.stream.Stream;
/**
* 数据驱动测试:从JSON文件加载测试用例
*/
public class DataDrivenTest {
@TestFactory
Stream<DynamicTest> testFromJsonConfig() {
// 加载测试数据
InputStream is = getClass().getResourceAsStream("/test-items.json");
List<TestCase> testCases = JSON.parseObject(is,
new TypeReference<List<TestCase>>() {});
TaobaoApiClient client = new TaobaoApiClient(APP_KEY, APP_SECRET);
return testCases.stream().map(testCase ->
DynamicTest.dynamicTest(testCase.getName(), () -> {
ItemDetail item = client.getItemDetail(
testCase.getItemId(),
testCase.getFields()
);
// 断言规则
for (AssertionRule rule : testCase.getRules()) {
rule.verify(item);
}
})
);
}
// test-items.json 示例:
/*
[
{
"name": "测试iPhone商品",
"itemId": 123456789,
"fields": "num_iid,title,price",
"rules": [
{"field": "title", "operator": "contains", "value": "iPhone"},
{"field": "price", "operator": "greaterThan", "value": 5000}
]
}
]
*/
}
4.2 契约测试(Pact)
package com.taobao.api.test;
import au.com.dius.pact.consumer.MockServer;
import au.com.dius.pact.consumer.dsl.PactDslWithProvider;
import au.com.dius.pact.consumer.junit5.PactConsumerTestExt;
import au.com.dius.pact.consumer.junit5.PactTestFor;
import au.com.dius.pact.core.model.V4Pact;
import au.com.dius.pact.core.model.annotations.Pact;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import static org.junit.jupiter.api.Assertions.assertEquals;
/**
* 消费者驱动的契约测试(CDC)
* 确保淘宝API变更时及时发现
*/
@ExtendWith(PactConsumerTestExt.class)
@PactTestFor(providerName = "taobao-api", port = "8080")
public class TaobaoApiContractTest {
@Pact(consumer = "our-service")
public V4Pact itemGetPact(PactDslWithProvider builder) {
return builder
.given("商品存在")
.uponReceiving("获取商品详情请求")
.path("/router/rest")
.method("POST")
.body("method=taobao.item.get&num_iid=123456&fields=num_iid,title,price")
.willRespondWith()
.status(200)
.body("""
{
"item_get_response": {
"item": {
"num_iid": 123456,
"title": "测试商品",
"price": "99.99"
}
}
}
""")
.toPact(V4Pact.class);
}
@Test
@PactTestFor(pactMethod = "itemGetPact")
void testItemGetContract(MockServer mockServer) {
// 使用Mock服务器测试消费者代码
TaobaoApiClient mockClient = new TaobaoApiClient("test", "test") {
@Override
protected String getApiUrl() {
return mockServer.getUrl();
}
};
ItemDetail item = mockClient.getItemDetail(123456L, "num_iid,title,price");
assertEquals("测试商品", item.getTitle());
}
}
五、Mock测试方案(无真实调用)
package com.taobao.api.test;
import com.github.tomakehurst.wiremock.WireMockServer;
import com.github.tomakehurst.wiremock.client.WireMock;
import static com.github.tomakehurst.wiremock.client.WireMock.*;
import org.junit.jupiter.api.*;
/**
* 使用WireMock进行本地Mock测试
* 适用于CI环境或无网络权限场景
*/
public class TaobaoApiMockTest {
private static WireMockServer wireMockServer;
private TaobaoApiClient client;
@BeforeAll
static void startServer() {
wireMockServer = new WireMockServer(9999);
wireMockServer.start();
WireMock.configureFor("localhost", 9999);
}
@BeforeEach
void setUp() {
// 配置Mock响应
stubFor(post(urlEqualTo("/router/rest"))
.withRequestBody(containing("method=taobao.item.get"))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody("""
{
"item_get_response": {
"item": {
"num_iid": 123456,
"title": "Mock商品",
"price": "199.99",
"pic_url": "http://example.com/img.jpg"
}
}
}
""")));
// 创建指向Mock服务器的客户端
client = new TaobaoApiClient("mock_key", "mock_secret") {
@Override
protected String getApiUrl() {
return "http://localhost:9999/router/rest";
}
};
}
@Test
void testWithMockServer() {
ItemDetail item = client.getItemDetail(123456L, "num_iid,title,price");
assertEquals("Mock商品", item.getTitle());
assertEquals(new BigDecimal("199.99"), item.getPrice());
// 验证请求
verify(postRequestedFor(urlEqualTo("/router/rest"))
.withRequestBody(containing("num_iid=123456")));
}
@AfterAll
static void stopServer() {
wireMockServer.stop();
}
}
六、测试报告与持续集成
6.1 Maven Surefire配置
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.1.2</version>
<configuration>
<environmentVariables>
<TB_APP_KEY>${env.TB_APP_KEY}</TB_APP_KEY>
<TB_APP_SECRET>${env.TB_APP_SECRET}</TB_APP_SECRET>
</environmentVariables>
<systemPropertyVariables>
<junit.jupiter.execution.parallel.enabled>true</junit.jupiter.execution.parallel.enabled>
</systemPropertyVariables>
<reportsDirectory>${project.build.directory}/surefire-reports</reportsDirectory>
</configuration>
</plugin>
</plugins>
</build>
6.2 GitHub Actions CI配置
# .github/workflows/taobao-api-test.yml
name: Taobao API Tests
on:
schedule:
- cron: '0 2 * * *' # 每天凌晨2点运行
workflow_dispatch:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up JDK 17
uses: actions/setup-java@v4
with:
java-version: '17'
distribution: 'temurin'
- name: Cache Maven dependencies
uses: actions/cache@v3
with:
path: ~/.m2
key: ${{ runner.os }}-m2-${{ hashFiles('**/pom.xml') }}
- name: Run API Tests
env:
TB_APP_KEY: ${{ secrets.TB_APP_KEY }}
TB_APP_SECRET: ${{ secrets.TB_APP_SECRET }}
TB_SESSION_KEY: ${{ secrets.TB_SESSION_KEY }}
run: mvn test -Dtest=TaobaoApiTest
- name: Generate Report
uses: dorny/test-reporter@v1
if: success() || failure()
with:
name: Taobao API Test Results
path: target/surefire-reports/*.xml
reporter: java-junit
- name: Upload Coverage
uses: codecov/codecov-action@v3
with:
files: target/site/jacoco/jacoco.xml
七、合规与安全检查清单
□ 权限合规
□ 已申请淘宝开放平台正式权限(非爬虫方式)
□ App Key已绑定服务器IP白名单
□ Session Key通过OAuth2.0正规授权获取
□ 数据安全
□ App Secret存储在环境变量/密钥管理系统(非代码)
□ 日志中脱敏处理(不打印完整Session Key)
□ 响应数据不缓存敏感信息(如用户隐私)
□ 调用规范
□ 单App并发不超过5000次/分钟
□ 实现退避重试(遇到限流自动降速)
□ 非高峰期进行压力测试(避免影响生产)
□ 监控告警
□ 监控API错误率(>5%触发告警)
□ 监控响应时间(P99>1s触发告警)
□ 监控限流触发次数(频繁限流需调整策略)
八、总结
| 测试类型 | 工具/方法 | 适用场景 |
|---|
| 单元测试 | JUnit 5 + Mockito | 签名算法、参数构建 |
| 集成测试 | 真实API调用 | 验证与淘宝API连通性 |
| 契约测试 | Pact | 防止API变更破坏消费者 |
| Mock测试 | WireMock | CI环境、无网络权限 |
| 性能测试 | JMH/自定义并发 | 验证限流策略、容量规划 |