一、前言
在跨境电商、ERP物流系统开发中,物流对接是核心刚需环节。云途物流(YunExpress)作为主流跨境物流服务商,提供了成熟的开放API接口,支持在线创建物流订单、获取运单号、轨迹查询等核心能力。
本文基于Java,完整实现云途物流寄件下单接口,包含参数校验、本地草稿落库、API请求体组装、响应解析、异常容错、数据回写全流程。代码可直接商用,同时解决了官方文档字段兼容、空值过滤、多格式响应解析等开发痛点。
核心实现思路:
参数合法性前置校验,拦截无效请求;
订单先以草稿状态落库,避免API调用失败数据丢失;
适配云途snake_case接口规范,智能组装请求参数;
调用云途官方下单API,兼容多种响应格式;
成功回写运单号、更新订单状态;失败记录错误信息、保障数据一致性;
全局异常捕获,友好返回前端提示。
二、开发环境与前置准备
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 核心流程
参数校验:校验渠道代码、收件人信息、重量、申报信息等必填字段;
字段兼容处理:适配新旧字段,兼容历史数据展示;
草稿落库:优先保存订单数据,防止接口报错数据丢失;
构建请求体:严格按照云途snake_case规范组装参数,过滤空值;
调用第三方API:通过工厂类获取租户对应的云途客户端,发起下单请求;
响应解析:兼容云途多种返回格式,精准提取运单号、订单号;
数据回写:成功更新订单状态、运单号、提交时间、原始响应数据;
异常处理:捕获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 核心亮点
数据安全容错:先落库再调接口,杜绝API超时、异常导致的数据丢失,支持失败重试;
强参数校验:精准对齐云途API必填字段,提前拦截非法请求,减少第三方接口报错;
全字段兼容:适配新旧字段、snake_case官方规范,自动过滤空值,不覆盖接口默认参数;
多格式响应解析:兼容云途result/data、对象/数组多种返回格式,避免接口迭代导致解析失效;
完整日志留存:保存原始接口响应数据、错误信息、提交时间,便于问题排查。
5.2 开发踩坑记录
字段格式不匹配:云途API强制snake_case下划线格式,Java实体为驼峰,必须手动转换,不能直接序列化实体;
空值参数报错:直接传入空字符串会覆盖接口默认值,导致下单失败,必须自定义工具类过滤空值;
响应格式不固定:云途不同渠道返回参数大小写、层级不一致,必须多关键字、多结构兼容解析;
数据一致性问题:未提前落库会导致接口报错后无任何记录,无法追溯和重试,必须先草稿落库。
六、扩展优化方向
重试机制:针对网络超时、临时接口异常,添加定时任务自动重试下单;
幂等性设计:添加唯一订单幂等键,防止重复下单;
批量下单:适配云途批量创建包裹接口,支持批量订单提交;
轨迹同步:新增定时任务,调用轨迹查询API,自动同步物流状态;
日志优化:接入日志框架,打印完整请求、响应参数,便于线上问题排查。
七、总结
本文基于Java框架完整实现了云途物流寄件下单API的生产级代码,覆盖参数校验、请求组装、接口调用、响应解析、异常容错、数据落库全流程。代码适配官方接口规范,解决了跨境物流对接中的常见痛点,可直接用于ERP、跨境电商后台、物流管理系统的开发。