云途物流API开发-鉴权认证(Java)

一、注册云途API开发

文档地址:OpenAPIopen_api_front

Key

云途物流用户中心

二、鉴权认证

官方地址:OpenAPIopen_api_front

官方给的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;
    }
}
相关推荐
liguojun20251 小时前
篮球馆自动计时收费系统:从规则配置到自动结算的全流程拆解
java·大数据·运维·人工智能·物联网·1024程序员节
huashengzsj1 小时前
Shopify个人建站如何搭建独立站:从零开始的完整指南
前端·网络·人工智能
三川6982 小时前
Tkinter库的学习记录06-变量类别
python
wenying_443237442 小时前
Python脚本—1.提取压缩包中的pdf文件
python·ai编程
952362 小时前
RabbitMQ-基础操作
java·spring boot·分布式·后端·spring·rabbitmq
_Jimmy_2 小时前
SQLAlchemy 复杂 SQL 执行指南与模板库
python·sql
六个九十度2 小时前
用python脚本访问被网关隔离的嵌入式设备
开发语言·python
卓怡学长2 小时前
w266基于spring boot + vue 圣地延安美食乐享系统
java·数据库·vue.js·spring boot·spring·intellij-idea
GEO_ai_zhijian3 小时前
饮料生产线质量检测系统供应商哪家强
大数据·python