提交 0e1c47ae authored 作者: lidongxu's avatar lidongxu

合并分支 'ldx_sf_wuliu' 到 'master'

合并顺丰物流查询路由功能 查看合并请求 !137
...@@ -11,6 +11,7 @@ import com.sfa.job.pojo.response.OrdersSentDto; ...@@ -11,6 +11,7 @@ import com.sfa.job.pojo.response.OrdersSentDto;
import com.sfa.job.service.order.IOrdersSentQueryService; import com.sfa.job.service.order.IOrdersSentQueryService;
import com.sfa.job.util.JdtcUtil; import com.sfa.job.util.JdtcUtil;
import com.sfa.job.util.KyeUtil; import com.sfa.job.util.KyeUtil;
import com.sfa.job.util.SfUtil;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.util.CollectionUtils; import org.springframework.util.CollectionUtils;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
...@@ -19,10 +20,8 @@ import org.springframework.web.bind.annotation.RestController; ...@@ -19,10 +20,8 @@ import org.springframework.web.bind.annotation.RestController;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Map;
import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeUnit;
/** /**
* @author : liqiulin * @author : liqiulin
* @date : 2025-07-08 13 * @date : 2025-07-08 13
...@@ -31,31 +30,39 @@ import java.util.concurrent.TimeUnit; ...@@ -31,31 +30,39 @@ import java.util.concurrent.TimeUnit;
@RestController @RestController
@RequestMapping("/sent") @RequestMapping("/sent")
public class SentQueryController { public class SentQueryController {
@Autowired @Autowired
private JdtcUtil jdtcUtil; private JdtcUtil jdtcUtil;
@Autowired @Autowired
private KyeUtil kyeUtil; private KyeUtil kyeUtil;
@Autowired @Autowired
private SfUtil sfUtil;
@Autowired
private IOrdersSentQueryService orderSentQueryService; private IOrdersSentQueryService orderSentQueryService;
@Autowired @Autowired
private RedisService redisService; private RedisService redisService;
/**
* 接口:GET /sent/query_p?sentNo=xxx
* 限频:每小时同一单号最多查询 5 次
*/
@GetMapping("/query_p") @GetMapping("/query_p")
public Object query(String sentNo){ public Object query(String sentNo) {
// 限制单号每小时只能查询5次
String rKey = RedisKeyJob.QINCE_ORDER_SENT_INTERNET_COUNT + sentNo; String rKey = RedisKeyJob.QINCE_ORDER_SENT_INTERNET_COUNT + sentNo;
Object cacheObject = redisService.getCacheObject(rKey); Object cacheObject = redisService.getCacheObject(rKey);
if (cacheObject != null && Integer.parseInt(cacheObject.toString()) >= 5) { if (cacheObject != null && Integer.parseInt(cacheObject.toString()) >= 5) {
throw new ServiceException(ECode.SENT_NO_QUERY_COUNT_ERROR); throw new ServiceException(ECode.SENT_NO_QUERY_COUNT_ERROR);
} }
Object sent = queryBySentNode(sentNo); Object sent = queryBySentNode(sentNo);
redisService.setCacheObject(rKey, cacheObject == null ? 1 : Integer.parseInt(cacheObject.toString()) + 1,1L, TimeUnit.HOURS); redisService.setCacheObject(rKey,
cacheObject == null ? 1 : Integer.parseInt(cacheObject.toString()) + 1,
1L, TimeUnit.HOURS);
return sent; return sent;
} }
private Object queryBySentNode(String sentNo){ private Object queryBySentNode(String sentNo) {
OrdersSentDto sent = orderSentQueryService.getSent(sentNo); OrdersSentDto sent = orderSentQueryService.getSent(sentNo);
List<OrderSentInfoResponse> sentInfo = null; List<OrderSentInfoResponse> sentInfo;
switch (sent.getTransport()) { switch (sent.getTransport()) {
case "134": case "134":
sentInfo = jdTC134(sent); sentInfo = jdTC134(sent);
...@@ -63,6 +70,10 @@ public class SentQueryController { ...@@ -63,6 +70,10 @@ public class SentQueryController {
case "109": case "109":
sentInfo = kye109(sent); sentInfo = kye109(sent);
break; break;
case "136": // 顺丰干配
case "117": // 顺丰
sentInfo = sf(sent);
break;
default: default:
throw new ServiceException(ECode.SENT_ISNULL_ERROR); throw new ServiceException(ECode.SENT_ISNULL_ERROR);
} }
...@@ -70,40 +81,80 @@ public class SentQueryController { ...@@ -70,40 +81,80 @@ public class SentQueryController {
return sent; return sent;
} }
private List<OrderSentInfoResponse> kye109(OrdersSentDto sent) { // =================== 顺丰 ===================
JSONArray traces = kyeUtil.getOrderTrace(sent.getExpressNo());
if (CollectionUtils.isEmpty(traces)){ private List<OrderSentInfoResponse> sf(OrdersSentDto sent) {
// getOrderTrace 返回 routeResps 数组,每个元素含 mailNo + routes
// 使用 expressNo(顺丰运单号)+ lastFourPhoneNumber 查询,trackingType=1
JSONArray routeResps = sfUtil.getOrderTrace(sent.getExpressNo(), sent.getLastFourPhoneNumber());
if (CollectionUtils.isEmpty(routeResps)) {
return null; return null;
} }
JSONArray exteriorRouteList = traces.getJSONObject(0).getJSONArray("exteriorRouteList"); // 取第一个运单的 routes 列表
return pKye109(exteriorRouteList); JSONObject routeResp = routeResps.getJSONObject(0);
JSONArray routes = routeResp.getJSONArray("routes");
if (CollectionUtils.isEmpty(routes)) {
return null;
}
return pSf(routes);
} }
private List<OrderSentInfoResponse> jdTC134(OrdersSentDto sent){ private List<OrderSentInfoResponse> pSf(JSONArray routes) {
List<OrderSentInfoResponse> sentInfoList = new ArrayList<>();
routes.forEach(route -> {
JSONObject r = (JSONObject) route;
OrderSentInfoResponse sentInfo = new OrderSentInfoResponse();
sentInfo.setOperateTime(r.getString("acceptTime"));
sentInfo.setOperateRemark(r.getString("remark"));
sentInfo.setAcceptAddress(r.getString("acceptAddress"));
sentInfo.setOpCode(r.getString("opCode"));
sentInfo.setFirstStatusCode(r.getString("firstStatusCode"));
sentInfo.setFirstStatusName(r.getString("firstStatusName"));
sentInfo.setSecondaryStatusCode(r.getString("secondaryStatusCode"));
sentInfo.setSecondaryStatusName(r.getString("secondaryStatusName"));
sentInfoList.add(sentInfo);
});
return sentInfoList;
}
// =================== 京东 ===================
private List<OrderSentInfoResponse> jdTC134(OrdersSentDto sent) {
JSONArray traces = jdtcUtil.getOrderTrace(sent.getBjSentNo() + "-" + sent.getBjSentVersion()); JSONArray traces = jdtcUtil.getOrderTrace(sent.getBjSentNo() + "-" + sent.getBjSentVersion());
JSONObject jb = traces.getJSONObject(0); JSONObject jb = traces.getJSONObject(0);
return pJdTC134(jb.getJSONArray("traceDetails")); return pJdTC134(jb.getJSONArray("traceDetails"));
} }
private List<OrderSentInfoResponse> pJdTC134(JSONArray exteriorRouteList){ private List<OrderSentInfoResponse> pJdTC134(JSONArray exteriorRouteList) {
List<OrderSentInfoResponse> sentInfoList = new ArrayList<>(); List<OrderSentInfoResponse> sentInfoList = new ArrayList<>();
exteriorRouteList.forEach(exteriorRoute -> { exteriorRouteList.forEach(exteriorRoute -> {
JSONObject exteriorRouteJson = (JSONObject) exteriorRoute; JSONObject r = (JSONObject) exteriorRoute;
OrderSentInfoResponse sentInfo = new OrderSentInfoResponse(); OrderSentInfoResponse sentInfo = new OrderSentInfoResponse();
sentInfo.setOperateTime(exteriorRouteJson.getString("operateTime")); sentInfo.setOperateTime(r.getString("operateTime"));
sentInfo.setOperateRemark(exteriorRouteJson.getString("operateRemark")); sentInfo.setOperateRemark(r.getString("operateRemark"));
sentInfoList.add(sentInfo); sentInfoList.add(sentInfo);
}); });
return sentInfoList; return sentInfoList;
} }
private List<OrderSentInfoResponse> pKye109(JSONArray exteriorRouteList){ // =================== 快鱼 ===================
private List<OrderSentInfoResponse> kye109(OrdersSentDto sent) {
JSONArray traces = kyeUtil.getOrderTrace(sent.getExpressNo());
if (CollectionUtils.isEmpty(traces)) {
return null;
}
JSONArray exteriorRouteList = traces.getJSONObject(0).getJSONArray("exteriorRouteList");
return pKye109(exteriorRouteList);
}
private List<OrderSentInfoResponse> pKye109(JSONArray exteriorRouteList) {
List<OrderSentInfoResponse> sentInfoList = new ArrayList<>(); List<OrderSentInfoResponse> sentInfoList = new ArrayList<>();
exteriorRouteList.forEach(exteriorRoute -> { exteriorRouteList.forEach(exteriorRoute -> {
JSONObject exteriorRouteJson = (JSONObject) exteriorRoute; JSONObject r = (JSONObject) exteriorRoute;
OrderSentInfoResponse sentInfo = new OrderSentInfoResponse(); OrderSentInfoResponse sentInfo = new OrderSentInfoResponse();
sentInfo.setOperateTime(exteriorRouteJson.getString("uploadDate")); sentInfo.setOperateTime(r.getString("uploadDate"));
sentInfo.setOperateRemark(exteriorRouteJson.getString("routeDescription")); sentInfo.setOperateRemark(r.getString("routeDescription"));
sentInfoList.add(sentInfo); sentInfoList.add(sentInfo);
}); });
return sentInfoList; return sentInfoList;
......
...@@ -101,6 +101,10 @@ public class OrdersSent implements Serializable { ...@@ -101,6 +101,10 @@ public class OrdersSent implements Serializable {
* 回单图片是否完整 * 回单图片是否完整
*/ */
private String receiptPhotoCompleteFlag; private String receiptPhotoCompleteFlag;
/**
* 最后四位手机号
*/
private String lastFourPhoneNumber;
/** /**
* 创建时间 * 创建时间
......
...@@ -5,18 +5,32 @@ import lombok.Data; ...@@ -5,18 +5,32 @@ import lombok.Data;
/** /**
* @author : liqiulin * @author : liqiulin
* @date : 2025-07-30 15 * @date : 2025-07-30 15
* @describe : * @describe : 统一物流路由节点响应 DTO
*/ */
@Data @Data
public class OrderSentInfoResponse { public class OrderSentInfoResponse {
/** /** 路由节点时间(格式:YYYY-MM-DD HH24:MM:SS) */
* 路由节点描述 private String operateTime;
*/
/** 路由节点描述 */
private String operateRemark; private String operateRemark;
/** /** 路由节点发生地点(顺丰) */
* 路由节点时间 private String acceptAddress;
*/
private String operateTime; /** 路由节点操作码(顺丰) */
private String opCode;
/** 一级状态编码(顺丰) */
private String firstStatusCode;
/** 一级状态名称(顺丰) */
private String firstStatusName;
/** 二级状态编码(顺丰) */
private String secondaryStatusCode;
/** 二级状态名称(顺丰) */
private String secondaryStatusName;
} }
...@@ -95,6 +95,11 @@ public class OrdersSentDto implements Serializable { ...@@ -95,6 +95,11 @@ public class OrdersSentDto implements Serializable {
*/ */
private String receiptPhotoCompleteFlag; private String receiptPhotoCompleteFlag;
/**
* 收件人手机号后四位(顺丰路由查询校验用)
*/
private String lastFourPhoneNumber;
private List<OrderSentInfoResponse> sentInfo; private List<OrderSentInfoResponse> sentInfo;
......
package com.sfa.job.util;
import cn.hutool.http.HttpUtil;
import com.alibaba.fastjson2.JSONArray;
import com.alibaba.fastjson2.JSONObject;
import com.sfa.common.core.enums.ECode;
import com.sfa.common.core.exception.ServiceException;
import com.sfa.common.redis.service.RedisService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
* @author : lidongxu
* @date : 2025-02-05
* @describe : 顺丰物流工具类(OAuth2 accessToken 认证)
*/
@Slf4j
@Component
public class SfUtil {
private static final String SF_ACCESS_TOKEN_CACHE_KEY = "sf:access_token";
private static final String SF_OAUTH_URL = "https://sfapi.sf-express.com/oauth2/accessToken";
@Value("${sf.api_url}")
private String sfApiUrl;
@Value("${sf.partner_id}")
private String partnerId;
@Value("${sf.secret}")
private String secret;
@Autowired
private RedisService redisService;
/**
* 查询顺丰物流轨迹(根据顺丰运单号查询,trackingType=1)
*
* @param expressNo 顺丰运单号
* @param lastFourPhoneNumber 收件人手机号后四位
* @return routeResps 列表(每个元素含 mailNo + routes)
*/
public JSONArray getOrderTrace(String expressNo, String lastFourPhoneNumber) {
try {
String accessToken = getAccessToken();
long timestamp = System.currentTimeMillis();
// 构建 msgData
Map<String, Object> bizData = new HashMap<>();
bizData.put("language", "zh-CN");
bizData.put("trackingType", 1);
bizData.put("trackingNumber", Collections.singletonList(expressNo));
bizData.put("checkPhoneNo", lastFourPhoneNumber);
String msgData = JSONObject.toJSONString(bizData);
// 构建表单参数
Map<String, String> requestBody = new LinkedHashMap<>();
requestBody.put("partnerID", partnerId);
requestBody.put("requestID", UUID.randomUUID().toString());
requestBody.put("serviceCode", "EXP_RECE_SEARCH_ROUTES");
requestBody.put("timestamp", String.valueOf(timestamp));
requestBody.put("accessToken", accessToken);
requestBody.put("msgData", msgData);
// 手动 URL 编码
StringBuilder bodyBuilder = new StringBuilder();
for (Map.Entry<String, String> entry : requestBody.entrySet()) {
if (bodyBuilder.length() > 0) bodyBuilder.append("&");
bodyBuilder.append(URLEncoder.encode(entry.getKey(), StandardCharsets.UTF_8.name()))
.append("=")
.append(URLEncoder.encode(entry.getValue(), StandardCharsets.UTF_8.name()));
}
log.info("顺丰路由查询请求体:{}", bodyBuilder);
String response = HttpUtil.createPost(sfApiUrl)
.header("Authorization", "Bearer " + accessToken)
.header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")
.body(bodyBuilder.toString())
.timeout(15000)
.execute()
.body();
log.info("顺丰路由查询响应:{}", response);
JSONObject responseJson = JSONObject.parseObject(response);
// 外层成功码:A1000
if (!"A1000".equals(responseJson.getString("apiResultCode"))) {
log.error("顺丰路由查询接口返回异常:{}", response);
throw new ServiceException(ECode.SF_ORDER_TRACE_API_ERROR);
}
// apiResultData 是 String,需要再次 parse
String apiResultDataStr = responseJson.getString("apiResultData");
if (apiResultDataStr == null) {
return new JSONArray();
}
JSONObject apiResultData = JSONObject.parseObject(apiResultDataStr);
// 内层业务成功标志
if (!Boolean.TRUE.equals(apiResultData.getBoolean("success"))) {
log.error("顺丰路由查询业务返回失败:{}", apiResultDataStr);
throw new ServiceException(ECode.SF_ORDER_TRACE_API_ERROR);
}
JSONObject msgDataResult = apiResultData.getJSONObject("msgData");
if (msgDataResult == null) {
return new JSONArray();
}
JSONArray routeResps = msgDataResult.getJSONArray("routeResps");
return routeResps != null ? routeResps : new JSONArray();
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
log.error("查询顺丰物流轨迹异常:{}", e.getMessage(), e);
throw new ServiceException(ECode.SF_ORDER_TRACE_QUERY_ERROR);
}
}
/**
* 获取顺丰 OAuth2 accessToken,优先从 Redis 缓存读取
*/
private String getAccessToken() {
String cached = redisService.getCacheObject(SF_ACCESS_TOKEN_CACHE_KEY);
if (cached != null) {
return cached;
}
try {
String response = HttpUtil.createPost(SF_OAUTH_URL)
.header("Content-Type", "application/x-www-form-urlencoded;charset=UTF-8")
.form("partnerID", partnerId)
.form("secret", secret)
.form("grantType", "password")
.timeout(10000)
.execute()
.body();
log.info("顺丰获取 accessToken 响应:{}", response);
JSONObject json = JSONObject.parseObject(response);
if (!"A1000".equals(json.getString("apiResultCode"))) {
log.error("顺丰获取 accessToken 失败:{}", response);
throw new ServiceException(ECode.SF_SIGN_ERROR);
}
String accessToken = json.getString("accessToken");
// expiresIn 单位为秒,提前 60 秒过期,避免临界问题
long expiresIn = json.getLongValue("expiresIn") - 60;
redisService.setCacheObject(SF_ACCESS_TOKEN_CACHE_KEY, accessToken, expiresIn, TimeUnit.SECONDS);
return accessToken;
} catch (ServiceException e) {
throw e;
} catch (Exception e) {
log.error("获取顺丰 accessToken 异常:{}", e.getMessage(), e);
throw new ServiceException(ECode.SF_SIGN_ERROR);
}
}
}
...@@ -32,7 +32,7 @@ ...@@ -32,7 +32,7 @@
</select> </select>
<select id="selectBySentNo" resultMap="ordersSentResultMap"> <select id="selectBySentNo" resultMap="ordersSentResultMap">
select ah_sent_no,bj_sent_no,bj_sent_version,transport,transport_name,express_no,dd_no select ah_sent_no,bj_sent_no,bj_sent_version,transport,transport_name,express_no,dd_no,last_four_phone_number
from orders_sent from orders_sent
<where> <where>
<if test="sentNo.startsWith('BJHQ')"> <if test="sentNo.startsWith('BJHQ')">
......
Markdown 格式
0%
您添加了 0 到此讨论。请谨慎行事。
请先完成此评论的编辑!
注册 或者 后发表评论