一、前言

在跨境电商、ERP物流系统开发中,物流对接是核心刚需环节。云途物流(YunExpress)作为主流跨境物流服务商,提供了成熟的开放API接口,支持在线创建物流订单、获取运单号、轨迹查询等核心能力。

本文基于 Java,完整实现云途物流寄件下单接口,包含参数校验、本地草稿落库、API请求体组装、响应解析、异常容错、数据回写全流程。代码可直接商用,同时解决了官方文档字段兼容、空值过滤、多格式响应解析等开发痛点。

核心实现思路

  1. 参数合法性前置校验,拦截无效请求;

  2. 订单先以草稿状态落库,避免API调用失败数据丢失;

  3. 适配云途snake_case接口规范,智能组装请求参数;

  4. 调用云途官方下单API,兼容多种响应格式;

  5. 成功回写运单号、更新订单状态;失败记录错误信息、保障数据一致性;

  6. 全局异常捕获,友好返回前端提示。

二、开发环境与前置准备

2.1 技术栈

  • 后端框架:RuoYi-Vue 开源框架

  • 开发语言:Java 8+

  • JSON解析:Fastjson2

  • 数据库:MySQL

  • 对接接口:云途物流 /v1/order/package/create 包裹创建接口

2.2 前置配置

  • 已申请云途物流开发者账号、获取API密钥、商户编码;

  • 已封装云途通用请求客户端(YunExpressClient)、客户端工厂类;

  • 已创建物流订单数据库实体 ErpLogisticsOrders 及对应的Mapper层。

2.3 官方文档

 官方文档:OpenAPIopen_api_front

三、核心业务逻辑设计

3.1 订单状态设计

为保障下单流程容错性,自定义三种订单状态:

  • 0 草稿状态:参数校验通过,已落库,未调用云途API;

  • 1 下单成功:API调用成功,已获取运单号、云途订单号;

  • 2 下单失败:API调用异常,记录错误信息,可后续重试。

3.2 核心流程

  1. 参数校验:校验渠道代码、收件人信息、重量、申报信息等必填字段;

  2. 字段兼容处理:适配新旧字段,兼容历史数据展示;

  3. 草稿落库:优先保存订单数据,防止接口报错数据丢失;

  4. 构建请求体:严格按照云途snake_case规范组装参数,过滤空值;

  5. 调用第三方API:通过工厂类获取租户对应的云途客户端,发起下单请求;

  6. 响应解析:兼容云途多种返回格式,精准提取运单号、订单号;

  7. 数据回写:成功更新订单状态、运单号、提交时间、原始响应数据;

  8. 异常处理:捕获API异常、系统异常,更新失败状态并记录错误日志。

3.3 效果截图

四、完整核心代码实现

本文只展示核心Service业务实现层,基础增删改查沿用若依通用模板,重点讲解createLogisticsOrder 下单核心方法

4.1 Service层完整代码

import java.math.BigDecimal;
import java.util.Date;
import java.util.HashMap;
import java.util.List;
import java.util.Map;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.ruoyi.common.exception.ServiceException;
import com.ruoyi.common.utils.DateUtils;
import com.ruoyi.common.utils.StringUtils;
import com.ruoyi.ecommerce.domain.ErpLogisticsOrders;
import com.ruoyi.ecommerce.mapper.ErpLogisticsOrdersMapper;
import com.ruoyi.ecommerce.service.IErpLogisticsOrdersService;
import com.ruoyi.ecommerce.yunexpress.YunExpressClient;
import com.ruoyi.ecommerce.yunexpress.YunExpressClientFactory;
import com.ruoyi.ecommerce.yunexpress.YunExpressException;

/**
 * 物流订单Service业务层处理
 *
 * @author superjean
 * @date 2026-07-08
 */
@Service
public class ErpLogisticsOrdersServiceImpl implements IErpLogisticsOrdersService {
    @Autowired
    private ErpLogisticsOrdersMapper erpLogisticsOrdersMapper;

    @Autowired
    private YunExpressClientFactory yunExpressClientFactory;

    @Override
    public ErpLogisticsOrders selectErpLogisticsOrdersById(Long id) {
        return erpLogisticsOrdersMapper.selectErpLogisticsOrdersById(id);
    }

    @Override
    public List<ErpLogisticsOrders> selectErpLogisticsOrdersList(ErpLogisticsOrders erpLogisticsOrders) {
        return erpLogisticsOrdersMapper.selectErpLogisticsOrdersList(erpLogisticsOrders);
    }

    @Override
    public int insertErpLogisticsOrders(ErpLogisticsOrders erpLogisticsOrders) {
        return erpLogisticsOrdersMapper.insertErpLogisticsOrders(erpLogisticsOrders);
    }

    @Override
    public int updateErpLogisticsOrders(ErpLogisticsOrders erpLogisticsOrders) {
        return erpLogisticsOrdersMapper.updateErpLogisticsOrders(erpLogisticsOrders);
    }

    @Override
    public int deleteErpLogisticsOrdersById(Long id) {
        return erpLogisticsOrdersMapper.deleteErpLogisticsOrdersById(id);
    }

    @Override
    public int deleteErpLogisticsOrdersByIds(Long[] ids) {
        return erpLogisticsOrdersMapper.deleteErpLogisticsOrdersByIds(ids);
    }

    /**
     * 创建物流订单:先按草稿落库,再调云途 /v1/package/create 提交,
     * 成功回写运单号、状态置 1;失败状态置 2 并保留错误信息。
     */
    @Override
    public Map<String, Object> createLogisticsOrder(ErpLogisticsOrders order) {
        // 必填项校验(与云途 snake_case 结构对齐)
        if (StringUtils.isEmpty(order.getProductCode())) {
            throw new ServiceException("渠道代码(product_code)不能为空");
        }
        String receiverFirstName = StringUtils.isNotEmpty(order.getReceiverFirstName())
                ? order.getReceiverFirstName() : order.getReceiverName();
        if (StringUtils.isEmpty(receiverFirstName)) {
            throw new ServiceException("收件人名(receiver.first_name)不能为空");
        }
        if (StringUtils.isEmpty(order.getReceiverCountry())) {
            throw new ServiceException("收件人国家(receiver.country_code)不能为空");
        }
        if (StringUtils.isEmpty(order.getReceiverCity())) {
            throw new ServiceException("收件人城市(receiver.city)不能为空");
        }
        if (StringUtils.isEmpty(order.getReceiverAddress())) {
            throw new ServiceException("收件人地址(receiver.address_lines)不能为空");
        }
        if (StringUtils.isEmpty(order.getReceiverPostCode())) {
            throw new ServiceException("收件人邮编(receiver.postal_code)不能为空");
        }
        if (order.getWeight() == null) {
            throw new ServiceException("包裹重量(packages[].weight)不能为空");
        }
        if (StringUtils.isEmpty(order.getNameEn())) {
            throw new ServiceException("申报英文名(declaration_info[].name_en)不能为空");
        }
        if (order.getQuantity() == null) {
            throw new ServiceException("申报数量(declaration_info[].quantity)不能为空");
        }
        if (order.getUnitPrice() == null) {
            throw new ServiceException("申报单价(declaration_info[].unit_price)不能为空");
        }
        if (order.getUnitWeight() == null) {
            throw new ServiceException("申报单件重量(declaration_info[].unit_weight)不能为空");
        }
        // 回填兼容字段,便于本地展示
        if (StringUtils.isEmpty(order.getReceiverName())) {
            order.setReceiverName(receiverFirstName);
        }
        if (StringUtils.isEmpty(order.getCountryCode())) {
            order.setCountryCode(order.getReceiverCountry());
        }
        // 默认草稿状态
        if (StringUtils.isEmpty(order.getStatus())) {
            order.setStatus("0");
        }
        // 落库
        erpLogisticsOrdersMapper.insertErpLogisticsOrders(order);

        Map<String, Object> result = new HashMap<>();
        try {
            YunExpressClient client = yunExpressClientFactory.create(order.getUserId());
            JSONObject body = buildCreateBody(order);
            JSONObject resp = client.createPackage(body);
            // 解析云途返回:result/Items 中含运单号
            String trackingNo = parseTrackingNumber(resp);
            String yunOrderNo = parseOrderNo(resp);
            order.setTrackingNumber(trackingNo);
            order.setYunExpressOrderNo(yunOrderNo);
            order.setStatus("1");
            order.setSubmitMessage(StringUtils.isNotEmpty(trackingNo) ? trackingNo : "提交成功");
            order.setSubmitTime(DateUtils.getNowDate());
            order.setRawInfo(resp.toJSONString());
            erpLogisticsOrdersMapper.updateErpLogisticsOrders(order);
            result.put("success", true);
            result.put("trackingNumber", trackingNo);
            result.put("yunExpressOrderNo", yunOrderNo);
            result.put("message", "提交成功,云途运单号:" + trackingNo);
        } catch (YunExpressException e) {
            order.setStatus("2");
            order.setSubmitMessage(e.getMessage());
            order.setSubmitTime(DateUtils.getNowDate());
            erpLogisticsOrdersMapper.updateErpLogisticsOrders(order);
            result.put("success", false);
            result.put("message", "提交云途失败:" + e.getMessage());
            // 抛出便于前端提示,本地草稿已保存
            throw new ServiceException("提交云途失败:" + e.getMessage());
        } catch (Exception e) {
            order.setStatus("2");
            order.setSubmitMessage(e.getMessage());
            order.setSubmitTime(DateUtils.getNowDate());
            erpLogisticsOrdersMapper.updateErpLogisticsOrders(order);
            throw new ServiceException("提交云途失败:" + e.getMessage());
        }
        return result;
    }

    /**
     * 构造云途 /v1/package/create 请求体(snake_case 结构)。
     * 字段参考云途官方样例:product_code / receiver / packages / declaration_info / sender /
     * customs_number / extra_services 等。空字符串字段不放入,避免覆盖默认。
     */
    private JSONObject buildCreateBody(ErpLogisticsOrders o) {
        JSONObject body = new JSONObject();
        putIfNotEmpty(body, "product_code", o.getProductCode());
        putIfNotEmpty(body, "customer_order_number", o.getCustomerOrderNo());
        putIfNotEmpty(body, "platform_account_code", o.getPlatformAccountCode());
        putIfNotEmpty(body, "source_code", o.getSourceCode());
        putIfNotEmpty(body, "sensitive_type", o.getSensitiveType());
        putIfNotEmpty(body, "label_type", o.getLabelType());
        putIfNotEmpty(body, "point_relais_num", o.getPointRelaisNum());
        putIfNotEmpty(body, "weight_unit", o.getWeightUnit());
        putIfNotEmpty(body, "size_unit", o.getSizeUnit());
        putIfNotEmpty(body, "dangerous_goods_type", o.getDangerousGoodsType());

        // 订单号组
        JSONObject orderNumbers = new JSONObject();
        putIfNotEmpty(orderNumbers, "waybill_number", o.getWaybillNumber());
        putIfNotEmpty(orderNumbers, "platform_order_number", o.getPlatformOrderNumber());
        putIfNotEmpty(orderNumbers, "tracking_number", o.getTrackingNumber());
        if (StringUtils.isNotEmpty(o.getReferenceNumbers())) {
            JSONArray refArr = new JSONArray();
            for (String s : o.getReferenceNumbers().split(",")) {
                String t = s.trim();
                if (StringUtils.isNotEmpty(t)) {
                    refArr.add(t);
                }
            }
            if (!refArr.isEmpty()) {
                orderNumbers.put("reference_numbers", refArr);
            }
        }
        if (!orderNumbers.isEmpty()) {
            body.put("order_numbers", orderNumbers);
        }

        // 收件人
        body.put("receiver", buildAddress(o, true));

        // 发件人(任一关键字段填写则放入)
        if (StringUtils.isNotEmpty(o.getSenderFirstName()) || StringUtils.isNotEmpty(o.getSenderName())
                || StringUtils.isNotEmpty(o.getSenderAddress())) {
            body.put("sender", buildAddress(o, false));
        }

        // 包裹(单包裹)
        JSONObject pkg = new JSONObject();
        if (o.getLength() != null) {
            pkg.put("length", o.getLength());
        }
        if (o.getWidth() != null) {
            pkg.put("width", o.getWidth());
        }
        if (o.getHeight() != null) {
            pkg.put("height", o.getHeight());
        }
        pkg.put("weight", o.getWeight());

        // 申报信息(单行)
        JSONArray declArr = new JSONArray();
        JSONObject decl = new JSONObject();
        putIfNotEmpty(decl, "sku_code", StringUtils.isNotEmpty(o.getSkuCode()) ? o.getSkuCode() : o.getSku());
        putIfNotEmpty(decl, "name_local", o.getNameLocal());
        putIfNotEmpty(decl, "name_en", o.getNameEn());
        if (o.getQuantity() != null) {
            decl.put("quantity", o.getQuantity());
        }
        if (o.getUnitPrice() != null) {
            decl.put("unit_price", o.getUnitPrice());
        }
        if (o.getUnitWeight() != null) {
            decl.put("unit_weight", o.getUnitWeight());
        }
        putIfNotEmpty(decl, "hs_code", o.getHsCode());
        putIfNotEmpty(decl, "sales_url", o.getSalesUrl());
        putIfNotEmpty(decl, "currency", o.getDeclareCurrency());
        putIfNotEmpty(decl, "material", o.getMaterial());
        putIfNotEmpty(decl, "purpose", o.getPurpose());
        putIfNotEmpty(decl, "brand", o.getBrand());
        putIfNotEmpty(decl, "spec", o.getSpec());
        putIfNotEmpty(decl, "model", o.getModel());
        putIfNotEmpty(decl, "remark", o.getRemark());
        declArr.add(decl);


        JSONArray packages = new JSONArray();
        packages.add(pkg);
        body.put("packages", packages);
        body.put("declaration_info", declArr);

        // 清关号码
        JSONObject customs = new JSONObject();
        putIfNotEmpty(customs, "tax_number", o.getCustomsTaxNumber());
        putIfNotEmpty(customs, "ioss_code", o.getCustomsIossCode());
        putIfNotEmpty(customs, "vat_code", o.getCustomsVatCode());
        putIfNotEmpty(customs, "eori_number", o.getCustomsEoriNumber());
        if (!customs.isEmpty()) {
            body.put("customs_number", customs);
        }

        // 增值服务
        if (StringUtils.isNotEmpty(o.getExtraCode())) {
            JSONArray extraArr = new JSONArray();
            JSONObject extra = new JSONObject();
            extra.put("extra_code", o.getExtraCode());
            putIfNotEmpty(extra, "extra_value", o.getExtraValue());
            extraArr.add(extra);
            body.put("extra_services", extraArr);
        }
        return body;
    }

    /**
     * 构造收件人/发件人地址对象(snake_case)。isReceiver=true 取收件人字段,否则取发件人字段。
     */
    private JSONObject buildAddress(ErpLogisticsOrders o, boolean isReceiver) {
        JSONObject addr = new JSONObject();
        String firstName = isReceiver ? o.getReceiverFirstName() : o.getSenderFirstName();
        String lastName = isReceiver ? o.getReceiverLastName() : o.getSenderLastName();
        // 收件人兼容旧 receiver_name(发件人兼容旧 sender_name)
        if (StringUtils.isEmpty(firstName)) {
            firstName = isReceiver ? o.getReceiverName() : o.getSenderName();
        }
        putIfNotEmpty(addr, "first_name", firstName);
        putIfNotEmpty(addr, "last_name", lastName);
        putIfNotEmpty(addr, "company", isReceiver ? o.getReceiverCompany() : o.getSenderCompany());
        putIfNotEmpty(addr, "country_code", isReceiver ? o.getReceiverCountry() : o.getSenderCountry());
        putIfNotEmpty(addr, "province", isReceiver ? o.getReceiverState() : o.getSenderState());
        putIfNotEmpty(addr, "city", isReceiver ? o.getReceiverCity() : o.getSenderCity());
        String street = isReceiver ? o.getReceiverAddress() : o.getSenderAddress();
        if (StringUtils.isNotEmpty(street)) {
            JSONArray lines = new JSONArray();
            lines.add(street);
            addr.put("address_lines", lines);
        }
        putIfNotEmpty(addr, "postal_code", isReceiver ? o.getReceiverPostCode() : o.getSenderPostCode());
        putIfNotEmpty(addr, "phone_number", isReceiver ? o.getReceiverPhone() : o.getSenderPhone());
        putIfNotEmpty(addr, "email", isReceiver ? o.getReceiverEmail() : o.getSenderEmail());
        putIfNotEmpty(addr, "certificate_type", isReceiver ? o.getReceiverCertificateType() : o.getSenderCertificateType());
        putIfNotEmpty(addr, "certificate_code", isReceiver ? o.getReceiverCertificateCode() : o.getSenderCertificateCode());
        return addr;
    }

    /**
     * 仅在 value 非空时放入 key,避免覆盖默认。
     */
    private void putIfNotEmpty(JSONObject obj, String key, String value) {
        if (StringUtils.isNotEmpty(value)) {
            obj.put(key, value);
        }
    }

    /**
     * 从云途创建响应中提取运单号(兼容多种结构)。
     */
    private String parseTrackingNumber(JSONObject resp) {
        if (resp == null) {
            return null;
        }
        Object result = resp.get("result");
        if (result == null) {
            result = resp.get("data");
        }
        if (result instanceof JSONObject) {
            JSONObject obj = (JSONObject) result;
            // 可能直接是单个对象或 Items 数组
            String[] keys = {"TrackingNumber", "WaybillNumber", "trackingNumber", "tracking_number", "waybill_number", "OrderNumber", "order_number"};
            for (String k : keys) {
                String v = obj.getString(k);
                if (StringUtils.isNotEmpty(v)) {
                    return v;
                }
            }
            JSONArray items = obj.getJSONArray("Items");
            if (items != null && !items.isEmpty()) {
                JSONObject first = items.getJSONObject(0);
                for (String k : keys) {
                    String v = first.getString(k);
                    if (StringUtils.isNotEmpty(v)) {
                        return v;
                    }
                }
            }
        }
        if (result instanceof JSONArray) {
            JSONArray arr = (JSONArray) result;
            if (!arr.isEmpty()) {
                JSONObject first = arr.getJSONObject(0);
                String[] keys = {"TrackingNumber", "WaybillNumber", "trackingNumber", "tracking_number", "waybill_number"};
                for (String k : keys) {
                    String v = first.getString(k);
                    if (StringUtils.isNotEmpty(v)) {
                        return v;
                    }
                }
            }
        }
        return null;
    }

    /**
     * 从云途创建响应中提取云途订单号。
     */
    private String parseOrderNo(JSONObject resp) {
        if (resp == null) {
            return null;
        }
        Object result = resp.get("result");
        if (result == null) {
            result = resp.get("data");
        }
        if (result instanceof JSONObject) {
            JSONObject obj = (JSONObject) result;
            String[] keys = {"YunExpressOrderNo", "yun_express_order_no", "OrderNo", "orderNo", "order_number"};
            for (String k : keys) {
                String v = obj.getString(k);
                if (StringUtils.isNotEmpty(v)) {
                    return v;
                }
            }
        }
        return null;
    }
}

五、核心功能亮点与踩坑总结

5.1 核心亮点

  1. 数据安全容错:先落库再调接口,杜绝API超时、异常导致的数据丢失,支持失败重试;

  2. 强参数校验:精准对齐云途API必填字段,提前拦截非法请求,减少第三方接口报错;

  3. 全字段兼容:适配新旧字段、snake_case官方规范,自动过滤空值,不覆盖接口默认参数;

  4. 多格式响应解析:兼容云途result/data、对象/数组多种返回格式,避免接口迭代导致解析失效;

  5. 完整日志留存:保存原始接口响应数据、错误信息、提交时间,便于问题排查。

5.2 开发踩坑记录

  1. 字段格式不匹配:云途API强制snake_case下划线格式,Java实体为驼峰,必须手动转换,不能直接序列化实体;

  2. 空值参数报错:直接传入空字符串会覆盖接口默认值,导致下单失败,必须自定义工具类过滤空值;

  3. 响应格式不固定:云途不同渠道返回参数大小写、层级不一致,必须多关键字、多结构兼容解析;

  4. 数据一致性问题:未提前落库会导致接口报错后无任何记录,无法追溯和重试,必须先草稿落库。

六、扩展优化方向

  • 重试机制:针对网络超时、临时接口异常,添加定时任务自动重试下单;

  • 幂等性设计:添加唯一订单幂等键,防止重复下单;

  • 批量下单:适配云途批量创建包裹接口,支持批量订单提交;

  • 轨迹同步:新增定时任务,调用轨迹查询API,自动同步物流状态;

  • 日志优化:接入日志框架,打印完整请求、响应参数,便于线上问题排查。

七、总结

本文基于Java框架完整实现了云途物流寄件下单API的生产级代码,覆盖参数校验、请求组装、接口调用、响应解析、异常容错、数据落库全流程。代码适配官方接口规范,解决了跨境物流对接中的常见痛点,可直接用于ERP、跨境电商后台、物流管理系统的开发。

Logo

电商企业物流数字化转型必备!快递鸟 API 接口,72 小时快速完成物流系统集成。全流程实战1V1指导,营造开放的API技术生态圈。

更多推荐