
一、注册云途API开发
Key




二、鉴权认证

官方给的SDK一直没有成功,缺少相关依赖,索性就自己写了个
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Base64;
import java.util.List;
import java.util.Map;
import java.util.TreeMap;
import java.util.concurrent.TimeUnit;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.alibaba.fastjson2.JSON;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.utils.StringUtils;
import okhttp3.HttpUrl;
import okhttp3.MediaType;
import okhttp3.OkHttpClient;
import okhttp3.Request;
import okhttp3.RequestBody;
import okhttp3.Response;
/**
* 云途物流(YunExpress) OpenAPI 客户端。
*
* 直接用 OkHttp 调云途鉴权 + REST 接口,移植自 Python 版 test01.py / test02.py:
* - 鉴权:POST /openapi/oauth2/token(grantType=client_credentials)换取 accessToken,缓存约 50 分钟
* - 签名:HMAC-SHA256(appSecret, "body=<body>&date=<ts_ms>&method=<METHOD>&uri=<URI>") -> Base64
* GET 请求 body 为空、uri 带查询串;POST 请求 body 为请求体原文、uri 为纯路径
* - 请求头:Content-Type、Accept-Language、sign、token、date
*
* 三组凭证 appId / appSecret / sourceKey 由 eco_config 表按当前 user_id 读取
* (config_key:YUANTU_APP_ID / YUANTU_APP_SECRET / YUNTU_SOURCE_KEY)。
*
* @author superjean
* @date 2026-07-08
*/
public class YunExpressClient
{
private static final Logger log = LoggerFactory.getLogger(YunExpressClient.class);
/** 正式环境 */
private static final String DEFAULT_BASE_URL = "https://openapi.yunexpress.cn";
private static final String TOKEN_URI = "/openapi/oauth2/token";
private static final MediaType JSON_MEDIA = MediaType.parse("application/json; charset=utf-8");
private final OkHttpClient httpClient;
private final String baseUrl;
private final String appId;
private final String appSecret;
private final String sourceKey;
/** 缓存的 accessToken 及过期时间(提前 60 秒刷新) */
private String token;
private long tokenExpireAt;
public YunExpressClient(String appId, String appSecret, String sourceKey)
{
this(DEFAULT_BASE_URL, appId, appSecret, sourceKey);
}
public YunExpressClient(String baseUrl, String appId, String appSecret, String sourceKey)
{
if (StringUtils.isEmpty(appId) || StringUtils.isEmpty(appSecret) || StringUtils.isEmpty(sourceKey))
{
throw new YunExpressException("云途授权信息不完整,请在电商配置-业务配置中设置 YUANTU_APP_ID / YUANTU_APP_SECRET / YUNTU_SOURCE_KEY");
}
this.baseUrl = StringUtils.isNotEmpty(baseUrl) ? baseUrl.replaceAll("/+$", "") : DEFAULT_BASE_URL;
this.appId = appId.trim();
this.appSecret = appSecret.trim();
this.sourceKey = sourceKey.trim();
this.httpClient = new OkHttpClient.Builder()
.connectTimeout(30, TimeUnit.SECONDS)
.readTimeout(60, TimeUnit.SECONDS)
.writeTimeout(60, TimeUnit.SECONDS)
.build();
}
// ===================== 鉴权 =====================
/** 获取 accessToken(带缓存)。 */
private String accessToken()
{
if (StringUtils.isNotEmpty(token) && System.currentTimeMillis() < tokenExpireAt)
{
return token;
}
JSONObject body = new JSONObject();
body.put("grantType", "client_credentials");
body.put("appId", appId);
body.put("appSecret", appSecret);
body.put("sourceKey", sourceKey);
RequestBody reqBody = RequestBody.create(body.toJSONString(), JSON_MEDIA);
Request request = new Request.Builder()
.url(baseUrl + TOKEN_URI)
.header("Content-Type", "application/json")
.post(reqBody)
.build();
try (Response resp = httpClient.newCall(request).execute())
{
String text = resp.body() != null ? resp.body().string() : "";
if (!resp.isSuccessful())
{
log.error("云途鉴权失败, status={}, body={}", resp.code(), text);
throw new YunExpressException("云途鉴权失败(" + resp.code() + "): "
+ (StringUtils.isNotEmpty(text) ? text : "无响应内容")
+ "。请检查 YUANTU_APP_ID / YUANTU_APP_SECRET / YUNTU_SOURCE_KEY。", resp.code());
}
JSONObject json = JSON.parseObject(text);
String accessToken = json.getString("accessToken");
if (StringUtils.isEmpty(accessToken))
{
throw new YunExpressException("云途鉴权返回缺少 accessToken: " + text);
}
token = accessToken;
int expiresIn = json.getIntValue("expiresIn", 3600);
// 提前 60 秒过期
tokenExpireAt = System.currentTimeMillis() + (expiresIn - 60) * 1000L;
return token;
}
catch (YunExpressException e)
{
throw e;
}
catch (Exception e)
{
throw new YunExpressException("无法连接云途鉴权服务: " + e.getMessage());
}
}
// ===================== 签名 =====================
/**
* HMAC-SHA256 签名,返回 Base64。
* 对应 Python:hmac.new(key, message, sha256).digest() -> base64.b64encode
*/
private String sign(String message)
{
try
{
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(appSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
byte[] raw = mac.doFinal(message.getBytes(StandardCharsets.UTF_8));
return Base64.getEncoder().encodeToString(raw);
}
catch (Exception e)
{
throw new YunExpressException("云途签名计算失败: " + e.getMessage());
}
}
/**
* 构造签名原文:body=<body>&date=<ts_ms>&method=<METHOD>&uri=<URI>
* GET 无请求体时 body 为空;POST 时 body 为请求体原文(紧凑 JSON)。
*/
private String buildSignData(String bodyStr, String timestampMs, String method, String uri)
{
return "body=" + bodyStr + "&date=" + timestampMs + "&method=" + method + "&uri=" + uri;
}
// ===================== 底层请求 =====================
/**
* GET 请求。查询参数按 key 排序生成确定的查询串,签名与实际请求用同一份。
* 返回云途响应 JSONObject。
*/
public JSONObject get(String uri, Map<String, String> params)
{
// 按 key 排序生成查询串,确保签名与实际 URL 一致
TreeMap<String, String> sorted = new TreeMap<>();
if (params != null)
{
for (Map.Entry<String, String> e : params.entrySet())
{
if (e.getValue() != null)
{
sorted.put(e.getKey(), e.getValue());
}
}
}
StringBuilder query = new StringBuilder();
for (Map.Entry<String, String> e : sorted.entrySet())
{
if (query.length() > 0)
{
query.append("&");
}
query.append(e.getKey()).append("=").append(e.getValue());
}
String uriWithQuery = query.length() > 0 ? uri + "?" + query.toString() : uri;
String url = baseUrl + uriWithQuery;
String timestampMs = String.valueOf(System.currentTimeMillis());
// GET 无请求体 -> body 为空
String signData = buildSignData("", timestampMs, "GET", uriWithQuery);
String sign = sign(signData);
int retries = 3;
Exception lastErr = null;
for (int attempt = 0; attempt < retries; attempt++)
{
Request request = new Request.Builder()
.url(url)
.header("Content-Type", "application/json;charset=utf-8")
.header("Accept-Language", "zh-CN")
.header("sign", sign)
.header("token", accessToken())
.header("date", timestampMs)
.get()
.build();
try (Response resp = httpClient.newCall(request).execute())
{
return handleResponse(resp);
}
catch (YunExpressException e)
{
// token 失效强制刷新后重试一次
if (isTokenExpired(e) && attempt < retries - 1)
{
token = null;
sleep(1000L * (attempt + 1));
continue;
}
throw e;
}
catch (Exception e)
{
lastErr = e;
sleep(1500L * (attempt + 1));
}
}
throw new YunExpressException("请求云途接口失败(已重试 " + retries + " 次): "
+ (lastErr != null ? lastErr.getMessage() : ""));
}
/**
* POST 请求。bodyJson 为请求体,签名使用其紧凑 JSON 字符串。
* 返回云途响应 JSONObject。
*/
public JSONObject post(String uri, JSONObject bodyJson)
{
String bodyStr = bodyJson != null ? bodyJson.toJSONString() : "";
String timestampMs = String.valueOf(System.currentTimeMillis());
String signData = buildSignData(bodyStr, timestampMs, "POST", uri);
String sign = sign(signData);
int retries = 3;
Exception lastErr = null;
for (int attempt = 0; attempt < retries; attempt++)
{
RequestBody reqBody = RequestBody.create(bodyStr, JSON_MEDIA);
Request request = new Request.Builder()
.url(baseUrl + uri)
.header("Content-Type", "application/json;charset=utf-8")
.header("Accept-Language", "zh-CN")
.header("sign", sign)
.header("token", accessToken())
.header("date", timestampMs)
.post(reqBody)
.build();
try (Response resp = httpClient.newCall(request).execute())
{
return handleResponse(resp);
}
catch (YunExpressException e)
{
if (isTokenExpired(e) && attempt < retries - 1)
{
token = null;
sleep(1000L * (attempt + 1));
continue;
}
throw e;
}
catch (Exception e)
{
lastErr = e;
sleep(1500L * (attempt + 1));
}
}
throw new YunExpressException("请求云途接口失败(已重试 " + retries + " 次): "
+ (lastErr != null ? lastErr.getMessage() : ""));
}
/** 统一处理响应:解析 JSON,对常见错误给中文友好提示。 */
private JSONObject handleResponse(Response resp) throws Exception
{
int code = resp.code();
String text = resp.body() != null ? resp.body().string() : "";
if (code == 401 || code == 403)
{
throw new YunExpressException("云途鉴权失败(" + code + "): token 无效或已过期。", code);
}
if (code == 429)
{
throw new YunExpressException("云途接口限流(429),请稍后重试。", 429);
}
if (code >= 400)
{
throw new YunExpressException("云途接口错误(" + code + "): "
+ (StringUtils.isNotEmpty(text) ? text : "无响应内容"), code);
}
JSONObject json = StringUtils.isNotEmpty(text) ? JSON.parseObject(text) : new JSONObject();
// 云途约定:code 为空或 "0"/"200" 表示成功;其余为业务错误
String bizCode = json.getString("code");
if (StringUtils.isNotEmpty(bizCode)
&& !"0".equals(bizCode) && !"200".equals(bizCode)
&& !"success".equalsIgnoreCase(bizCode))
{
String msg = json.getString("msg");
if (StringUtils.isEmpty(msg))
{
msg = json.getString("message");
}
throw new YunExpressException("云途业务错误(code=" + bizCode + "): " + msg, code);
}
return json;
}
/** 判断是否 token 失效类错误(需刷新重试)。 */
private boolean isTokenExpired(YunExpressException e)
{
int sc = e.getStatusCode();
return sc == 401 || sc == 403;
}
private void sleep(long ms)
{
try
{
Thread.sleep(ms);
}
catch (InterruptedException ignored)
{
Thread.currentThread().interrupt();
}
}
// ===================== 业务方法 =====================
/**
* 鉴权测试:强制刷新并调用云途 OAuth2 token 接口,
* 验证 appId / appSecret / sourceKey 是否正确。
* 成功返回包含 accessToken 与 expiresIn 的结果;失败抛 YunExpressException(带友好中文提示)。
*/
public JSONObject testAuth()
{
// 强制重新获取 token,确保真的命中一次鉴权接口、验证凭证有效性
token = null;
String accessToken = accessToken();
JSONObject result = new JSONObject();
result.put("accessToken", accessToken);
result.put("expiresIn", (tokenExpireAt - System.currentTimeMillis()) / 1000);
result.put("baseUrl", baseUrl);
return result;
}
/**
* 账单明细列表:GET /v1/bill/details/list
* 必填 bill_code(账单编号)、bill_type(账单类型)。自动翻页返回全部明细。
*/
public List<JSONObject> listBillDetails(String billCode, String billType, int pageSize)
{
List<JSONObject> all = new ArrayList<>();
int pageNo = 1;
int size = pageSize > 0 && pageSize <= 100 ? pageSize : 100;
while (true)
{
TreeMap<String, String> params = new TreeMap<>();
params.put("bill_code", billCode);
params.put("bill_type", billType);
params.put("page_no", String.valueOf(pageNo));
params.put("page_size", String.valueOf(size));
JSONObject resp = get("/v1/bill/details/list", params);
List<JSONObject> records = extractRecords(resp);
all.addAll(records);
int total = extractTotal(resp);
if (records.isEmpty())
{
break;
}
if (total >= 0 && all.size() >= total)
{
break;
}
if (records.size() < size)
{
break;
}
pageNo++;
}
return all;
}
/**
* 包裹/物流订单列表:POST /v1/package/list
* filters 可传创建时间等筛选条件(如 createTimeStart / createTimeEnd)。
*/
public List<JSONObject> listPackages(int pageIndex, int pageSize, JSONObject filters)
{
JSONObject body = new JSONObject();
body.put("page_index", pageIndex);
body.put("page_size", pageSize > 0 && pageSize <= 100 ? pageSize : 100);
if (filters != null)
{
for (String key : filters.keySet())
{
body.put(key, filters.get(key));
}
}
return extractRecords(post("/v1/package/list", body));
}
/**
* 拉取全部包裹订单(自动翻页)。
*/
public List<JSONObject> listAllPackages(JSONObject filters)
{
List<JSONObject> all = new ArrayList<>();
int pageIndex = 1;
int size = 100;
while (true)
{
JSONObject body = new JSONObject();
body.put("page_index", pageIndex);
body.put("page_size", size);
if (filters != null)
{
for (String key : filters.keySet())
{
body.put(key, filters.get(key));
}
}
JSONObject resp = post("/v1/package/list", body);
List<JSONObject> records = extractRecords(resp);
all.addAll(records);
int total = extractTotal(resp);
if (records.isEmpty())
{
break;
}
if (total >= 0 && all.size() >= total)
{
break;
}
if (records.size() < size)
{
break;
}
pageIndex++;
}
return all;
}
/**
* 创建发货单/物流订单:POST /v1/package/create
* bodyJson 为发货单请求体(发件人、收件人、包裹明细等)。
* 返回云途创建结果。
*/
public JSONObject createPackage(JSONObject bodyJson)
{
return post("/v1/order/package/create", bodyJson);
}
// ===================== 响应解析工具 =====================
/** 从云途响应中提取明细/订单列表(兼容 result/data 下多种 key)。 */
public static List<JSONObject> extractRecords(JSONObject resp)
{
List<JSONObject> list = new ArrayList<>();
if (resp == null)
{
return list;
}
// 云途不同接口包了一层 result 或 data
Object root = resp.get("result");
if (root == null)
{
root = resp.get("data");
}
if (root == null)
{
root = resp;
}
if (root instanceof JSONArray)
{
JSONArray arr = (JSONArray) root;
for (int i = 0; i < arr.size(); i++)
{
list.add(arr.getJSONObject(i));
}
return list;
}
if (root instanceof JSONObject)
{
JSONObject obj = (JSONObject) root;
for (String key : new String[] { "list", "records", "items", "rows", "details", "packageList", "billList" })
{
Object val = obj.get(key);
if (val instanceof JSONArray)
{
JSONArray arr = (JSONArray) val;
for (int i = 0; i < arr.size(); i++)
{
list.add(arr.getJSONObject(i));
}
return list;
}
}
}
return list;
}
/** 从云途响应中提取总记录数(兼容多种 key),无法解析返回 -1。 */
public static int extractTotal(JSONObject resp)
{
if (resp == null)
{
return -1;
}
Object root = resp.get("result");
if (root == null)
{
root = resp.get("data");
}
if (root == null)
{
root = resp;
}
if (!(root instanceof JSONObject))
{
return -1;
}
JSONObject obj = (JSONObject) root;
for (String key : new String[] { "total", "totalCount", "total_count", "totalRecord", "totalRow", "totalNum" })
{
Object val = obj.get(key);
if (val != null)
{
try
{
return Integer.parseInt(String.valueOf(val));
}
catch (NumberFormatException ignored)
{
}
}
}
return -1;
}
}