Browse Source

顺丰SIT环境

master
luoweijian 1 month ago
parent
commit
70a40baf12
  1. 4
      src/main/java/com/project/logistics/config/SfApiProperties.java
  2. 8
      src/main/java/com/project/logistics/domain/enums/OrderStatusEnum.java
  3. 2
      src/main/java/com/project/logistics/domain/enums/RetryActionEnum.java
  4. 2
      src/main/java/com/project/logistics/domain/enums/SampleOrderInfoFieldEnum.java
  5. 73
      src/main/java/com/project/logistics/domain/service/SfApiService.java
  6. 2
      src/main/java/com/project/logistics/domain/strategy/handler/SfCreateOrderHandler.java
  7. 14
      src/main/java/com/project/logistics/domain/utils/FilePathUtil.java
  8. 55
      src/main/java/com/project/receive/domain/service/ReceiveService.java
  9. 165
      src/main/java/com/project/receive/strategy/handler/SfDownloadPodHandler.java
  10. 75
      src/main/java/com/project/receive/strategy/handler/SfRegisterPodHandler.java
  11. 119
      src/main/resources/application-preprod.yml
  12. 1
      src/main/resources/application-sftest.yml
  13. 29
      src/main/resources/application-test.yml
  14. 1
      src/main/resources/application.yml
  15. 82
      src/test/java/com/project/logistics/domain/scheduler/SfTest.java

4
src/main/java/com/project/logistics/config/SfApiProperties.java

@ -48,4 +48,8 @@ public class SfApiProperties {
* 面单模板编码 * 面单模板编码
*/ */
private String templateCode; private String templateCode;
/**
* 电子回单密钥
*/
private String podSecret;
} }

8
src/main/java/com/project/logistics/domain/enums/OrderStatusEnum.java

@ -18,8 +18,12 @@ public enum OrderStatusEnum {
ERP_FEE_UPDATED("ERP_FEE_UPDATED", "7.运费和件数已回写ERP"), ERP_FEE_UPDATED("ERP_FEE_UPDATED", "7.运费和件数已回写ERP"),
DELIVERED("DELIVERED", "8.快递已签收"), DELIVERED("DELIVERED", "8.快递已签收"),
POD_DOWNLOADED("POD_DOWNLOADED", "9.签收底单(图片)已下载(仅出货单)"),
FINISHED("FINISHED", "10.流程正常结束"), POD_REGISTERED("POD_REGISTERED", "9.回单图片已主动注册(等待顺丰推送)"),
POD_DOWNLOADED("POD_DOWNLOADED", "10.签收回单已解密并下载"),
FINISHED("FINISHED", "11.流程正常结束"),
ERROR("ERROR", "X.异常阻断(需人工介入)"); ERROR("ERROR", "X.异常阻断(需人工介入)");

2
src/main/java/com/project/logistics/domain/enums/RetryActionEnum.java

@ -11,6 +11,8 @@ public enum RetryActionEnum {
SF_UPLOAD_RESOURCE("SF_UPLOAD_RESOURCE", "上传电子回单原始PDF资源"), SF_UPLOAD_RESOURCE("SF_UPLOAD_RESOURCE", "上传电子回单原始PDF资源"),
SF_CREATE_ORDER("SF_CREATE_ORDER", "顺丰下单(获取运单号)"), SF_CREATE_ORDER("SF_CREATE_ORDER", "顺丰下单(获取运单号)"),
SF_DOWNLOAD_WAYBILL("SF_DOWNLOAD_WAYBILL", "获取顺丰云打印面单PDF"), SF_DOWNLOAD_WAYBILL("SF_DOWNLOAD_WAYBILL", "获取顺丰云打印面单PDF"),
SF_REGISTER_POD("SF_REGISTER_POD", "主动注册回单图片推送"),
SF_DOWNLOAD_POD("SF_DOWNLOAD_POD", "解析并下载顺丰签收底单图片"), SF_DOWNLOAD_POD("SF_DOWNLOAD_POD", "解析并下载顺丰签收底单图片"),
/* --- ERP(U9)回写动作 --- */ /* --- ERP(U9)回写动作 --- */

2
src/main/java/com/project/logistics/domain/enums/SampleOrderInfoFieldEnum.java

@ -16,7 +16,7 @@ public enum SampleOrderInfoFieldEnum {
recipientAddress("DescFlexField_PubDescSeg14" , "收货地址") , recipientAddress("DescFlexField_PubDescSeg14" , "收货地址") ,
payer("DescFlexField_PubDescSeg20" , "运费承担") , payer("DescFlexField_PubDescSeg20" , "运费承担") ,
waybillNo("DescFlexField_PrivateDescSeg20" , "货运单号") , waybillNo("DescFlexField_PrivateDescSeg20" , "货运单号") ,
expressType("DescFlexField_PubDescSeg25" , "寄付方式") , expressType("DescFlexField_PrivateDescSeg25" , "寄付方式") ,
fee("DescFlexField_PrivateDescSeg21" , "运费") , fee("DescFlexField_PrivateDescSeg21" , "运费") ,
quantity("DescFlexField_PrivateDescSeg13" , "件数") , quantity("DescFlexField_PrivateDescSeg13" , "件数") ,

73
src/main/java/com/project/logistics/domain/service/SfApiService.java

@ -46,7 +46,6 @@ public class SfApiService {
getUrlData.put("fileName", fileName); getUrlData.put("fileName", fileName);
getUrlData.put("fileSize", fileSize); getUrlData.put("fileSize", fileSize);
// 注意:根据文档 2.3,获取地址接口也需要 Channel-Code
Map<String, String> headers = new HashMap<>(); Map<String, String> headers = new HashMap<>();
headers.put("Channel-Code", sfApiProperties.getChannelCode()); headers.put("Channel-Code", sfApiProperties.getChannelCode());
@ -66,15 +65,77 @@ public class SfApiService {
log.info(">>> 顺丰侧已存在相同 MD5 文件, 直接使用 fileCode: {}", fileCode); log.info(">>> 顺丰侧已存在相同 MD5 文件, 直接使用 fileCode: {}", fileCode);
} }
// Step 3: 创建资源 // Step 3: 创建资源 (核心业务逻辑修改)
JSONObject createResourceData = new JSONObject(); JSONObject createResourceData = new JSONObject();
createResourceData.put("partyType", "SPECI_NAME"); // 签署人类型:收件人
createResourceData.put("fileCode", fileCode); createResourceData.put("fileCode", fileCode);
// 这里可以根据实际需求构造 taskSigners 等复杂结构... // 签署人类型换成:任意自然人 (ALL_NM)
createResourceData.put("partyType", "ALL_NM");
// 构造签署区域配置
JSONArray taskSigners = new JSONArray();
JSONObject signer = new JSONObject();
JSONArray signAreas = new JSONArray();
// --- 区域 1: 签名位置 (X:391, Y:98, 宽:90, 高:12) ---
JSONObject signArea = new JSONObject();
signArea.put("signFieldType", "SIGN");
JSONObject signFieldConfig = new JSONObject();
// 坐标计算配置:最后一页 + 自定义坐标方式
JSONObject signCalcPos = new JSONObject();
signCalcPos.put("calcPage", "LAST_PAGE");
signCalcPos.put("calcPosType", "FREE_POS");
signFieldConfig.put("calcPosConfig", signCalcPos);
// 具体位置坐标
JSONObject signPos = new JSONObject();
signPos.put("positionX", 391);
signPos.put("positionY", 115);
signFieldConfig.put("position", signPos);
signFieldConfig.put("dateFormat", "YYYYMMDD2");
// 区域大小
signFieldConfig.put("signFieldWidth", 90);
signFieldConfig.put("signFieldHeight", 20);
signArea.put("signFieldConfig", signFieldConfig);
signAreas.add(signArea);
// --- 区域 2: 日期位置 (X:527, Y:98, 宽:80, 高:12) ---
// 采用 REMARK 类型实现独立坐标的日期展示
JSONObject dateArea = new JSONObject();
dateArea.put("signFieldType", "REMARK");
JSONObject remarkFieldConfig = new JSONObject();
// 坐标计算配置:最后一页 + 自定义坐标方式
JSONObject dateCalcPos = new JSONObject();
dateCalcPos.put("calcPage", "LAST_PAGE");
dateCalcPos.put("calcPosType", "FREE_POS");
remarkFieldConfig.put("calcPosConfig", dateCalcPos);
// 具体位置坐标
JSONObject datePos = new JSONObject();
datePos.put("positionX", 527);
datePos.put("positionY", 105);
remarkFieldConfig.put("position", datePos);
// 区域大小
remarkFieldConfig.put("signFieldWidth", 80);
remarkFieldConfig.put("signFieldHeight", 20);
dateArea.put("remarkFieldConfig", remarkFieldConfig);
signAreas.add(dateArea);
// 组装最终报文
signer.put("signAreas", signAreas);
taskSigners.add(signer);
createResourceData.put("taskSigners", taskSigners);
log.info(">>> 开始创建资源, msgData: {}", createResourceData.toJSONString());
return callSfApi("COM_RECE_WP_CREATE_UPDATE_RESOURCE", createResourceData, headers); return callSfApi("COM_RECE_WP_CREATE_UPDATE_RESOURCE", createResourceData, headers);
} }
/** /**
* 执行二进制流上传 (HTTP PUT) * 执行二进制流上传 (HTTP PUT)
*/ */

2
src/main/java/com/project/logistics/domain/strategy/handler/SfCreateOrderHandler.java

@ -73,6 +73,8 @@ public class SfCreateOrderHandler implements ApiTaskHandler {
msgData.put("language", "zh-CN"); msgData.put("language", "zh-CN");
msgData.put("orderId", order.getOrderNo()); // 客户订单号 msgData.put("orderId", order.getOrderNo()); // 客户订单号
msgData.put("totalWeight", 1);
String transportMethod = u9Data.getString(ShipmentOrderInfoFieldEnum.transportMethod.name()); String transportMethod = u9Data.getString(ShipmentOrderInfoFieldEnum.transportMethod.name());
if (StrUtil.isBlank(transportMethod) || !transportMethod.contains("顺丰")) { if (StrUtil.isBlank(transportMethod) || !transportMethod.contains("顺丰")) {

14
src/main/java/com/project/logistics/domain/utils/FilePathUtil.java

@ -12,13 +12,13 @@ public class FilePathUtil {
*/ */
public static String getHierarchicalPath(Date date) { public static String getHierarchicalPath(Date date) {
// 使用Calendar类对日期进行减一天的操作 // 使用Calendar类对日期进行减一天的操作
// Calendar calendar = Calendar.getInstance(); Calendar calendar = Calendar.getInstance();
// calendar.setTime(date); calendar.setTime(date);
// calendar.add(Calendar.DAY_OF_YEAR, 0); calendar.add(Calendar.DAY_OF_YEAR, 0);
// Date newDate = calendar.getTime(); Date newDate = calendar.getTime();
String year = DateUtil.format(date, "yyyy年"); String year = DateUtil.format(newDate, "yyyy年");
String month = DateUtil.format(date, "yyyy年M月"); String month = DateUtil.format(newDate, "yyyy年M月");
String day = DateUtil.format(date, "yyyy年M月d日"); String day = DateUtil.format(newDate, "yyyy年M月d日");
return year + "/" + month + "/" + day; return year + "/" + month + "/" + day;
} }

55
src/main/java/com/project/receive/domain/service/ReceiveService.java

@ -19,7 +19,11 @@ import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.util.Base64;
import java.util.List; import java.util.List;
@Slf4j @Slf4j
@ -108,12 +112,12 @@ public class ReceiveService {
order.setSfCurrentStateCode(opCode); order.setSfCurrentStateCode(opCode);
// 根据核心节点跳转 order_status 状态机 // 根据核心节点跳转 order_status 状态机
if (StrUtil.equals(SfRouteOpCodeEnum.PICKED_UP.name() , opCode)) { if (StrUtil.equals(SfRouteOpCodeEnum.PICKED_UP.getCode() , opCode)) {
// 顺丰已揽收 // 顺丰已揽收
order.setOrderStatus(OrderStatusEnum.PICKED_UP.getCode()); order.setOrderStatus(OrderStatusEnum.PICKED_UP.getCode());
log.info(">>> 单号 {} 状态机流转至: [PICKED_UP]", orderNo); log.info(">>> 单号 {} 状态机流转至: [PICKED_UP]", orderNo);
} }
else if (StrUtil.equals(SfRouteOpCodeEnum.DELIVERED.name() , opCode)) { else if (StrUtil.equals(SfRouteOpCodeEnum.DELIVERED.getCode() , opCode)) {
// 1. 更新为已签收 // 1. 更新为已签收
order.setOrderStatus(OrderStatusEnum.DELIVERED.getCode()); order.setOrderStatus(OrderStatusEnum.DELIVERED.getCode());
@ -122,8 +126,10 @@ public class ReceiveService {
log.info(">>> 样品单 {} 已签收,流程结束", orderNo); log.info(">>> 样品单 {} 已签收,流程结束", orderNo);
order.setOrderStatus(OrderStatusEnum.FINISHED.getCode()); order.setOrderStatus(OrderStatusEnum.FINISHED.getCode());
} else { } else {
// 出货单则继续保持 DELIVERED,等待图片推送触发下载任务 // 常规出货单:需要电子回单,主动触发图片注册任务
log.info(">>> 出货单 {} 已签收,等待顺丰推送回单图片...", orderNo); log.info(">>> 出货单 {} 已签收,创建主动注册回单图片任务 [SF_REGISTER_POD]", orderNo);
apiRetryTaskService.createNextTask(orderNo, RetryActionEnum.SF_REGISTER_POD);
} }
} }
@ -206,25 +212,48 @@ public class ReceiveService {
* 处理 POD 图片推送 * 处理 POD 图片推送
*/ */
public void savePodPictureLog(SfPodPushRequest request) throws Exception { public void savePodPictureLog(SfPodPushRequest request) throws Exception {
// 关键:POD 推送包含巨大的 Base64 字节,这里只做存库。
// 具体的解析、下载、传 WebDAV 交由后续的 SF_DOWNLOAD_POD 任务去重试执行。
String waybillNo = request.getWaybillNo(); String waybillNo = request.getWaybillNo();
String base64Data = request.getContent(); String rawContent = request.getContent(); // 原始加密 Base64 字符串
if (waybillNo == null) {
throw new IllegalArgumentException("运单号不能为空");
}
if (StrUtil.isBlank(rawContent)) return;
// 1. 原样存入流水表
PodPushLogEntity podLog = new PodPushLogEntity(); PodPushLogEntity podLog = new PodPushLogEntity();
podLog.setWaybillNo(waybillNo); podLog.setWaybillNo(waybillNo);
podLog.setRawPushData(base64Data); // 存入 LONGTEXT 字段 podLog.setRawPushData(rawContent); // 存储加密原文
podLog.setProcessStatus(SyncStatusEnum.WAIT.getCode()); podLog.setProcessStatus(0); // 待处理状态
podPushLogService.save(podLog); podPushLogService.save(podLog);
// 查找订单号并触发“处理图片”的异步任务 // 2. 查找订单并触发异步处理任务
LogisticsOrderEntity order = logisticsOrderService.lambdaQuery() LogisticsOrderEntity order = logisticsOrderService.lambdaQuery()
.eq(LogisticsOrderEntity::getSfWaybillNo, waybillNo).one(); .eq(LogisticsOrderEntity::getSfWaybillNo, waybillNo).one();
if (order != null) { if (order != null) {
apiRetryTaskService.createNextTask(order.getOrderNo(), RetryActionEnum.SF_DOWNLOAD_POD); apiRetryTaskService.createNextTask(order.getOrderNo(), RetryActionEnum.SF_DOWNLOAD_POD);
} }
}
/**
* 顺丰图片 AES 解密实现
* 算法AES/CBC/PKCS5Padding
* 偏移量 IV16位 0x00
*/
private byte[] decryptSfImage(String base64Content, String secret) throws Exception {
// Base64 解码密文
byte[] cipherText = Base64.getDecoder().decode(base64Content);
// 初始化 IV (16个0)
byte[] ivBytes = new byte[]{0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
IvParameterSpec iv = new IvParameterSpec(ivBytes);
// 初始化 Key
SecretKeySpec skeySpec = new SecretKeySpec(secret.getBytes("UTF-8"), "AES");
// 执行解密
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, skeySpec, iv);
return cipher.doFinal(cipherText);
} }
} }

165
src/main/java/com/project/receive/strategy/handler/SfDownloadPodHandler.java

@ -0,0 +1,165 @@
package com.project.receive.strategy.handler;
import cn.hutool.core.date.DateUtil;
import cn.hutool.core.util.StrUtil;
import com.project.logistics.config.FixedRuleProperties;
import com.project.logistics.config.SfApiProperties;
import com.project.logistics.config.WebDavProperties;
import com.project.logistics.domain.entity.ApiRetryTaskEntity;
import com.project.logistics.domain.entity.LogisticsOrderEntity;
import com.project.logistics.domain.enums.OrderStatusEnum;
import com.project.logistics.domain.enums.RetryActionEnum;
import com.project.logistics.domain.service.WebDavService;
import com.project.logistics.domain.service.base.LogisticsOrderService;
import com.project.logistics.domain.strategy.ApiTaskHandler;
import com.project.logistics.domain.utils.FilePathUtil;
import com.project.receive.domain.entity.PodPushLogEntity;
import com.project.receive.domain.service.base.PodPushLogService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.spec.AlgorithmParameterSpec;
import java.util.Base64;
import java.util.Date;
@Slf4j
@Component
public class SfDownloadPodHandler implements ApiTaskHandler {
@Autowired
private PodPushLogService podPushLogService;
@Autowired
private LogisticsOrderService logisticsOrderService;
@Autowired
private WebDavService webDavService;
@Autowired
private FixedRuleProperties fixedRuleProperties;
@Autowired
private WebDavProperties webDavProperties;
@Autowired
private SfApiProperties sfApiProperties;
@Override
public String getActionCode() {
return RetryActionEnum.SF_DOWNLOAD_POD.getCode();
}
@Override
public void handle(ApiRetryTaskEntity task, LogisticsOrderEntity order) throws Exception {
// 1. 获取原始密文数据
PodPushLogEntity podLog = podPushLogService.lambdaQuery()
.eq(PodPushLogEntity::getWaybillNo, order.getSfWaybillNo())
.orderByDesc(PodPushLogEntity::getId)
.last("LIMIT 1").one();
if (podLog == null || StrUtil.isBlank(podLog.getRawPushData())) {
throw new RuntimeException("流水表中未发现加密的回单数据,等待顺丰推送...");
}
log.info(">>> 任务执行:开始解密单号 {} 的原始报文", order.getOrderNo());
// 2. 执行解密逻辑 (AES解密 + 二次解码)
byte[] finalPdfBytes;
try {
// 获取并处理 Key (CheckWord 前16位)
// String secret = "b283e485aad2a78f";
String secret = sfApiProperties.getPodSecret();
if (StrUtil.isBlank(secret)) {
secret = "axjGikuWgvYkI3JA"; // 沙箱
}
if (secret.length() > 16) {
secret = secret.substring(0, 16);
}
finalPdfBytes = decryptAndDecode2(podLog.getRawPushData(), secret);
} catch (Exception e) {
log.error(">>> 解密失败: {}", e.getMessage());
throw new RuntimeException("DECRYPT_PROCESS_FAILED: " + e.getMessage());
}
// 3. 上传 WebDAV
String datePath = FilePathUtil.getHierarchicalPath(new Date());
String fileName = order.getOrderNo() + ".pdf";
String fullSavePath = webDavProperties.getPodRoot() + "/" + datePath + "/" + fileName;
webDavService.uploadFile(fullSavePath , finalPdfBytes);
// 4. 更新订单和日志状态
order.setPodPdfPath(fullSavePath);
order.setOrderStatus(OrderStatusEnum.FINISHED.getCode()); // 流程结束
logisticsOrderService.updateById(order);
podLog.setProcessStatus(1); // 成功
podPushLogService.updateById(podLog);
log.info(">>> 回单处理成功并已上传 WebDAV: {}", fullSavePath);
}
/**
* 内部解密方法AES -> Base64解码
*/
private byte[] decryptAndDecode(String rawEncryptedBase64, String secret) throws Exception {
// A. 第一次解码:密文Base64 -> 密文字节
byte[] cipherText = Base64.getDecoder().decode(rawEncryptedBase64.trim().replaceAll("\\s+", ""));
// B. AES 解密
SecretKeySpec skeySpec = new SecretKeySpec(secret.getBytes("UTF-8"), "AES");
IvParameterSpec iv = new IvParameterSpec(new byte[16]); // 16个0
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, skeySpec, iv);
byte[] decryptedBytes = cipher.doFinal(cipherText);
// C. 第二次解码:顺丰解密后的字节其实是 PDF 的 Base64 字符串
String pdfBase64Str = new String(decryptedBytes, "UTF-8").trim();
return Base64.getDecoder().decode(pdfBase64Str);
}
public static byte[] ivBytes = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x00, 0x00 };
private byte[] decryptAndDecode2(String rawEncryptedBase64, String secret) throws Exception {
// 1. 密钥深度清理:去空格、确保16位
String cleanedSecret = secret.trim();
if (cleanedSecret.length() > 16) {
cleanedSecret = cleanedSecret.substring(0, 16);
}
byte[] keyBytes = cleanedSecret.getBytes(StandardCharsets.UTF_8);
// 2. 密文深度清理:去除所有换行、回车、空格
// 关键点:Standard Decoder 遇到换行会出问题,必须手动清理或使用 MimeDecoder
String cleanedPayload = rawEncryptedBase64.trim().replaceAll("\\s", "");
// 3. 使用 MimeDecoder (更稳健,会自动忽略非法字符)
byte[] encryptedBytes = Base64.getMimeDecoder().decode(cleanedPayload);
// 4. AES 解密配置
SecretKeySpec newKey = new SecretKeySpec(keyBytes, "AES");
// 顺丰 POD 推送固定使用 16 个 0 字节作为 IV
IvParameterSpec ivSpec = new IvParameterSpec(new byte[16]);
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, newKey, ivSpec);
byte[] decryptedResult;
try {
log.info("DEBUG: 待解密字节数组长度: {}", encryptedBytes.length);
decryptedResult = cipher.doFinal(encryptedBytes);
} catch (Exception e) {
log.error("AES解密失败!密钥长度: {}, 密文字节长度: {}", keyBytes.length, encryptedBytes.length);
throw e;
}
// 5. 顺丰解出的是 PDF 的 Base64 文本,需要进行二次解码
String pdfBase64Str = new String(decryptedResult, StandardCharsets.UTF_8).trim().replaceAll("\\s", "");
return Base64.getMimeDecoder().decode(pdfBase64Str);
}
}

75
src/main/java/com/project/receive/strategy/handler/SfRegisterPodHandler.java

@ -0,0 +1,75 @@
package com.project.receive.strategy.handler;
import com.alibaba.fastjson.JSONObject;
import com.jayway.jsonpath.JsonPath;
import com.project.logistics.config.FixedRuleProperties;
import com.project.logistics.config.SfApiProperties;
import com.project.logistics.domain.entity.ApiRetryTaskEntity;
import com.project.logistics.domain.entity.LogisticsOrderEntity;
import com.project.logistics.domain.enums.OrderStatusEnum;
import com.project.logistics.domain.enums.RetryActionEnum;
import com.project.logistics.domain.service.SfApiService;
import com.project.logistics.domain.service.base.LogisticsOrderService;
import com.project.logistics.domain.strategy.ApiTaskHandler;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class SfRegisterPodHandler implements ApiTaskHandler {
@Autowired
private SfApiService sfApiService;
@Autowired
private LogisticsOrderService logisticsOrderService;
@Autowired
private FixedRuleProperties fixedRuleProperties;
@Autowired
private SfApiProperties sfApiProperties;
@Override
public String getActionCode() {
return RetryActionEnum.SF_REGISTER_POD.getCode();
}
@Override
public void handle(ApiRetryTaskEntity task, LogisticsOrderEntity order) throws Exception {
log.info(">>> [常规出货单] 发起回单图片主动注册: {}", order.getOrderNo());
// 构造请求报文 (参考顺丰文档)
JSONObject msgData = new JSONObject();
msgData.put("clientCode", sfApiProperties.getPartnerId());
msgData.put("waybillNo", order.getSfWaybillNo());
// 图片类型:122代表电子回单(IN149)
msgData.put("imgType", "122");
// 校验电话:通常传寄件人电话
msgData.put("customerAcctCode", fixedRuleProperties.getMonthlyCard());
msgData.put("phone", fixedRuleProperties.getSenderMobile());
task.setRequestData(msgData.toJSONString());
String result = sfApiService.callSfApi("EXP_RECE_REGISTER_WAYBILL_PICTURE", msgData, null);
task.setResponseData(result);
// 解析结果
String apiCode = JsonPath.read(result, "$.apiResultCode");
if ("A1000".equals(apiCode)) {
String innerJsonStr = JsonPath.read(result, "$.apiResultData");
boolean success = JsonPath.read(innerJsonStr, "$.success");
if (success) {
log.info(">>> 顺丰回单图片注册成功,等待回调推送...");
order.setOrderStatus(OrderStatusEnum.POD_REGISTERED.getCode());
logisticsOrderService.updateById(order);
} else {
String errorMsg = JsonPath.read(innerJsonStr, "$.errorMsg");
throw new RuntimeException("业务注册失败: " + errorMsg);
}
} else {
throw new RuntimeException("网关调用失败: " + JsonPath.read(result, "$.apiErrorMsg"));
}
}
}

119
src/main/resources/application-preprod.yml

@ -0,0 +1,119 @@
server:
port: 9088
spring:
main:
# 允许 Bean 覆盖,解决 dynamic-datasource 与 JPA 的初始化冲突
allow-bean-definition-overriding: true
datasource:
url: jdbc:mysql://8.129.84.155:3306/auto_logistics_pre?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true&allowPublicKeyRetrieval=true&useSSL=false
username: logistics_admin
password: Itc@123456
driver-class-name: com.mysql.cj.jdbc.Driver
# dynamic:
# primary: master
# datasource:
# master:
# driverClassName: com.mysql.cj.jdbc.Driver
# password: Itc@123456
# url: jdbc:mysql://8.129.84.155:3306/auto_logistics?serverTimezone=GMT%2B8&useUnicode=true&characterEncoding=utf-8&allowMultiQueries=true&allowPublicKeyRetrieval=true&useSSL=false
# username: logistics_admin
data:
redis:
host: 8.129.84.155
port: 6379
password: Itc@123456
database: 5
timeout: 5000ms
lettuce:
pool:
max-active: 8
max-idle: 30
max-wait: 10000
min-idle: 10
jpa:
hibernate:
# 确保是 update
ddl-auto: update
# 显式指定数据库平台
database-platform: org.hibernate.dialect.MySQL8Dialect
show-sql: true
# 关键:告诉 Hibernate 自动扫描实体类
open-in-view: true
properties:
hibernate:
dialect: org.hibernate.dialect.MySQL8Dialect
# 显式指定命名策略,防止大小写或下划线解析错误
physical_strategy: org.hibernate.boot.model.naming.PhysicalNamingStrategyStandardImpl
sf:
api:
partnerId: Y847O1KA
secret: DgNS7ANgynXYcnhvu4moAe4rCAHkWvk9
tokenUrl: https://bspgw.sf-express.com/oauth2/accessToken
baseUrl: https://bspgw.sf-express.com/std/service
channelCode: INC-VMOS-CORE
podSecret: b4fb275c23204056
mybatis-plus:
configuration:
# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发环境下打印SQL
log-impl: org.apache.ibatis.logging.slf4j.Slf4jImpl
map-underscore-to-camel-case: true # 开启驼峰命名
global-config:
db-config:
id-type: assign_id # 使用雪花算法生成本地订单主键ID
webdav:
url: "http://8.129.84.155:8881"
username: "admin"
password: "123456"
logistics:
scanner:
enabled: false
# 每 10 分钟唤醒一次 (系统开销微乎其微)
cron: "0 0/10 * * * ?"
windows:
# 这个窗口设置10分钟有效期,配合10分钟一次的唤醒,只会跑一次
- name: "上午11点班次"
startTime: "11:08"
endTime: "11:08"
intervalMinutes: 60
# 这个窗口在15-20点之间,每隔20分钟会真正跑一次逻辑
- name: "下午至傍晚波次"
startTime: "15:08"
endTime: "20:08"
intervalMinutes: 60
sample-scanner:
enabled: false
# 每 10 分钟唤醒一次 (系统开销微乎其微)
cron: "0 0/10 * * * ?"
windows:
# 这个窗口设置10分钟有效期,配合10分钟一次的唤醒,只会跑一次
- name: "上午11点班次"
startTime: "11:08"
endTime: "11:08"
intervalMinutes: 60
# 这个窗口在15-20点之间,每隔20分钟会真正跑一次逻辑
- name: "下午至傍晚波次"
startTime: "15:08"
endTime: "20:08"
intervalMinutes: 20
logging:
level:
# 强制打印 Hibernate 初始化过程
org.hibernate.SQL: debug
org.hibernate.orm.deprecation: error
org.hibernate.tool.schema: debug
# 看看 Spring 到底有没有加载 JPA
org.springframework.orm.jpa: debug
u9-source:
url: jdbc:sqlserver://192.168.4.202:1433;databaseName=20241030;encrypt=false;trustServerCertificate=true
username: sa
password: 'Liujun1928374650'
driver-class-name: com.microsoft.sqlserver.jdbc.SQLServerDriver
fixed-rule:
senderPayContact: '罗伟健'
senderMobile: '15014218570'
senderAddress: '广东省广州市番禺区石碁镇南荔东路56号'
monthlyCard: '0200467987'
scheduled-task:
owner: test

1
src/main/resources/application-sftest.yml

@ -53,6 +53,7 @@ sf:
tokenUrl: https://sfapi.sit.sf-express.com:45273/oauth2/accessToken tokenUrl: https://sfapi.sit.sf-express.com:45273/oauth2/accessToken
baseUrl: https://sfapi.sit.sf-express.com:45273/std/service baseUrl: https://sfapi.sit.sf-express.com:45273/std/service
channelCode: INC-VMOS-CORE channelCode: INC-VMOS-CORE
podSecret: b283e485aad2a78f
mybatis-plus: mybatis-plus:
configuration: configuration:
# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发环境下打印SQL # log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发环境下打印SQL

29
src/main/resources/application-test.yml

@ -53,6 +53,7 @@ sf:
tokenUrl: https://sfapi-sbox.sf-express.com/oauth2/accessToken tokenUrl: https://sfapi-sbox.sf-express.com/oauth2/accessToken
baseUrl: https://sfapi-sbox.sf-express.com/std/service baseUrl: https://sfapi-sbox.sf-express.com/std/service
channelCode: MCS-CAS-API-BOX channelCode: MCS-CAS-API-BOX
podSecret: axjGikUwgYVKiJ3A
mybatis-plus: mybatis-plus:
configuration: configuration:
# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发环境下打印SQL # log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发环境下打印SQL
@ -69,33 +70,33 @@ logistics:
scanner: scanner:
enabled: true enabled: true
# 每 10 分钟唤醒一次 (系统开销微乎其微) # 每 10 分钟唤醒一次 (系统开销微乎其微)
cron: "0 0/10 * * * ?" cron: "0 8 11,15,16,17,18,19,20 * * ?"
windows: windows:
# 这个窗口设置10分钟有效期,配合10分钟一次的唤醒,只会跑一次 # 这个窗口设置10分钟有效期,配合10分钟一次的唤醒,只会跑一次
- name: "上午11点班次" - name: "上午11点班次"
startTime: "11:08" startTime: "11:00"
endTime: "11:08" endTime: "11:15"
intervalMinutes: 60 intervalMinutes: 30
# 这个窗口在15-20点之间,每隔20分钟会真正跑一次逻辑 # 这个窗口在15-20点之间,每隔20分钟会真正跑一次逻辑
- name: "下午至傍晚波次" - name: "下午至傍晚波次"
startTime: "15:08" startTime: "15:00"
endTime: "20:08" endTime: "20:15"
intervalMinutes: 60 intervalMinutes: 30
sample-scanner: sample-scanner:
enabled: true enabled: true
# 每 10 分钟唤醒一次 (系统开销微乎其微) # 每 10 分钟唤醒一次 (系统开销微乎其微)
cron: "0 0/10 * * * ?" cron: "0 8 11,15,16,17,18,19,20 * * ?"
windows: windows:
# 这个窗口设置10分钟有效期,配合10分钟一次的唤醒,只会跑一次 # 这个窗口设置10分钟有效期,配合10分钟一次的唤醒,只会跑一次
- name: "上午11点班次" - name: "上午11点班次"
startTime: "11:08" startTime: "11:00"
endTime: "11:08" endTime: "11:15"
intervalMinutes: 60 intervalMinutes: 30
# 这个窗口在15-20点之间,每隔20分钟会真正跑一次逻辑 # 这个窗口在15-20点之间,每隔20分钟会真正跑一次逻辑
- name: "下午至傍晚波次" - name: "下午至傍晚波次"
startTime: "15:08" startTime: "15:00"
endTime: "20:08" endTime: "20:15"
intervalMinutes: 60 intervalMinutes: 30
logging: logging:
level: level:
# 强制打印 Hibernate 初始化过程 # 强制打印 Hibernate 初始化过程

1
src/main/resources/application.yml

@ -53,6 +53,7 @@ sf:
tokenUrl: https://sfapi-sbox.sf-express.com/oauth2/accessToken tokenUrl: https://sfapi-sbox.sf-express.com/oauth2/accessToken
baseUrl: https://sfapi-sbox.sf-express.com/std/service baseUrl: https://sfapi-sbox.sf-express.com/std/service
channelCode: MCS-CAS-API-BOX channelCode: MCS-CAS-API-BOX
podSecret: b4fb275c23204056
mybatis-plus: mybatis-plus:
configuration: configuration:
# log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发环境下打印SQL # log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发环境下打印SQL

82
src/test/java/com/project/logistics/domain/scheduler/SfTest.java

@ -0,0 +1,82 @@
package com.project.logistics.domain.scheduler;
import javax.crypto.Cipher;
import javax.crypto.spec.IvParameterSpec;
import javax.crypto.spec.SecretKeySpec;
import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Base64;
public class SfTest {
public static void main(String[] args) throws Exception {
// 1. 文档提供的样例密文
String rawContent = "njdEklz2DvgAnu6GNrtzrxZrta+jg6Z9DptuedRafk2aJ8Lbo6FH7c8Z4ZcS8I7MTV1Q02GaBacX\\nOrT" +
"IfNesEsrVE1POLzPUXfnRznmVbXcPz0mknRfU1qtMSJBtBJdT3loY6Fnd30oatPmNaMey7G2x\\nO4Sh3V4EbeCt3GrBwEMQk/qwBA" +
"IhS56B4rquN2lX3fvJENJt/boklc1pz0JybTHgyvp1yTRIyqkR\\nMjfcfrTks1OF/WJ5Iuy/dYGe8HFooHyYS+5y+agVJWjIXleOZ" +
"gXUwm/WcLGTxsjJgiVdGDQhPsJU\\nwU86HOqapeJbGSFDw0Nzxzqu53rXVeqYxJ+UdsUGSiTL7+0kStTWmtknxoj/1XpeueUkytbN" +
"jgP9\\nickUKA6V/k/+PkVKoWbyKO4K7CGtljrAvOIwnWPwTsygnFRTsiEG7w+QzAQuMqIFwkSBSjKesv7d\\nLkizTOENT18CkcNi" +
"tSZJ2fOv0liqdU0gfGpFK3hxlKf5fr4Q5YKl0UfZdr3YEQl0QqW+pViRjezI\\ndS7Fyk4984DHab4L+se6HTUf1v+OTzQ47cnKPc" +
"SNpYjRRB8HSeI+xLgAI7k+oUU/1xgWXZlMO2Jh\\nMG96iRI92cXHjH0W67CvFYYA5oSct/wClQZBj02h5VCtslx160swgOMZj" +
"eGqgK4SekhZdCs0qdSm\\nmNHQue4dfQt0EtnWJ3rDyEw6+66UAsYftEYdVAWKt92yl2ATctelJvN060wwQutxx2EPjOLx0whx\\n" +
"i8rfktmzh3bUyf/VLCUPjBl1jiHIFCreAa8XA+rOo/6HcyWHKMwSpY7P54nDY7Ds4U7c0NOqoMtF\\ns+R9u81jQnAKsylj4iDm" +
"eMoMi63cBJ6pfsh3ztU2qa260NWiEa0pzS9P0QEjCmjBpWAHAW70gx7L\\n/uGAu+ln/Uf27cbE2c61stnRBLDfMohQ9wvrLSF8xHx" +
"nV9h5noCXkAd4U/EtqhSaGRWdAIEZ6Dxk\\nucyMxtpz13v9M6eZ9PMgJKNIyHUVe38QF1XNw8SpvuBVUj41QMA0cJWS6NwSKUVBEf" +
"y7Zmiv7z/7\\nbEIwGv9Z/zUt1TFzhxn1VXJLCwYLAYDOaPYpj8qhHCaTs59dfFBIvj6k3rUeqS3c4dGzCUJQNbW0\\nCtqBWClPz" +
"AW29C4/2w+f9s0V1Mx3HaJKROEy1UyfdtiAUTL3XF6u5aki7ZKKf4iRvuC5tooNkA1M\\ng222fEqxpwAFfGQS98yHL0unes39XGo/" +
"17ke8CB1aI2f4m6+NW3fe+ciSZe5zEvz/84U4M8IDW/s\\nR3OImJNWoLF5sadfnf5xSYV8zzz2MRn+/4bN0tjJkIIBvBEiVf7cxFD" +
"F1tLR3rXE1SiMQDso110A\\nn1PsLjwcl6uMqYkcso8cLOuk5ysfim2MRs1CYsqMPqKu6XT2w5dwKYY+9hKtTW4lxBbBsqgvPp/t\\n" +
"qn50WZu7bYVPsxErXYTN1sHi17bp8bsl/qjAieZkiqw4TyxJF30El7Lb+BqD8opqSHeUlaxm68/s\\nQ8fh3xpfWxDLxa1j0Vt+a" +
"w18dadX3burYtjqPZo3L5Ny+Ph94dl0h4eeOceFPz1jqBwA8G/o3FPR\\n3zKCWnXgjy7Xqcspdu0v0lBSQHF5KVqwifnSDJZQDRZ" +
"A2qB9+Mgx1IgMMqaZwY+Kmfqyw5D7CVLk\\nilN/ecMPA3Ng5TLd8UvzUbr46Vxaun7JkwyfE3VgRC63DaLUeq08qVApggUSVgjK" +
"vz9IwpaNW3yL\\nb/01vjOVWRUVzUoj4of0A7VBERPOxHhDSFNjhj/GRbPInlR4mT7m5tUQoGQblhAByACKMvl0Omgr\\nASRACmav" +
"UcaxWrw05bn+8wdqtLvZuB8d+1wmhULgSUaQNH1jGz04UUsZhN3FWtEVFnO5TlHShFco\\nbhQK38J9Cblb+Lt4sgjSMcjX66VPpa" +
"PYxpQIVtiWTxfnWdkbJcFRKby5MBPVnL9qOL5npM/qmCcO\\nQUQkpK7iL9upbASLRZ9Oeu4GAiKg2H9AxJHC/gZ27LDQq7+REXnq" +
"zUAeZ/O3HDMXOA3amupgLCiC\\noZr6lUoAm3HbcOgP7KvduuqgOuzy5YfpHnG4IkQ/l0gVtT/5/DOVMTZAc3ORmeaVKoskdKzgri" +
"Zi\\nVFCBqUbH7DZdCTvMReGW3ykXFopMyYbclorXlVQICxcZEhdQ9XIfm3w2Qr48AYjuNReX7mP4IebB\\nudaM8vs3UZSpQd2gF" +
"V+Gl2GullPhCgXV+m0Ntpxu5pR3bwWCsEYpnN2nG7QXdtE+j7OgYoHK/geZ\\nAdfVCcrPCY265NmBzU8jH1haJCQdr7jlxrgKdtp" +
"DroKTJGh7CuuTq4oWe5fhyGlVBtF8dYByjGSD\\nNUhDzjrJLy0qhT4C6Q74TWe6Pr/LHDZyOhNsvXBl0BBamH96Ndn1QO0GMP8JO" +
"stzQFaRbXQ9uC1m\\nhsJkx0KqYQ1bC0dhLcfHCohcniP4OY/kF+dz1rGRrwkaiP96xpSpKzfbitUCrQ66ZdoBHwJYZuXn\\njpvqQ" +
"HDwWuFgARUvJ79ig+end1vv7k6DqqmsB1ZxC12hsv/oN/E8HtdT7zi9WcAeCAq6A5iKxdM4\\naKhrVDo7qgWNZYxVPn6q5XnZLDz" +
"OgnUu8TXGr8DD/Jk+HfnoOODMOIvHcozxsyReb/npTekxmYqF\\nC/NE5pXNmDdCsVFojO33l4PIrpfiK1TcGMwZeSF4upKX0id42" +
"SNnK2BPGKBFEmUsIXu7vWXHpxBd\\n8lIVmPNZ0vb/eXP2Fcom3v39ALVdMY93jgQI1BsuFh7U4N8YBKWbfAgrtFj/anujArNJcSg" +
"biWUr\\n1CFJjmAanPPuXaWHfU/3fIqQCFDt/8X8S0Xbzvt4dBe0LqRC7ObFIqLa2esaXcJ34bNgs3MgvdpV\\n6duebo7PNjX6ra0" +
"LHYbDUrSo3XO9NMBprWbToa+uKoDHgzsAVT0zffJTJSU6bG5zytta22mMKAOG\\nFmxwuU1z11/cltUMAxYd4nljt3tGBLhGRrWmvY" +
"dlQGRnqdMyhChvaFpkZB68rrvOTT9HQ08Pf2UT\\nz+A0dpXwZLpfwV7PzFHZRN4czA4LeUIqQVh3byfU0r/ReC26UEL4KUBU0hm6Y" +
"mDvuRM72QOWM7HD\\nNyMywtWUbOIdoslcKqa97FTu+bgXo4VS9c+gyQLY1SizSlPvxePewaoqMjzYT6TTsSZW0dAiOMdf\\nSUehT" +
"wNPvRs/NZBGPBXHKLTWZtiHE4WCCV9ghYXdzXxyL3N2BSYa2AM16wGQsDb201nmRsDWPFD8\\ngW9ZBuEMIB80XqdmYeWNHA+tWtH8" +
"Le59WyBNolNbjXxIZCL7HGT3qKH1McUxHLJrhL4UWFbg2u4H\\nYBX+QbI5li7ugK+mTlXJZ/eW81PmSUFkaTVLM+BrWseGsbjmZBD" +
"PT8aBk9QAb1j1Wyka9yX6NZ20\\nQEeijYAUWU3KlsOsrfSPqyrVoO9deyc8sTPdtxUzbJCtxGcHKoNwEG04/WF1TrNa5eZrPoYW1F" +
"0Q\\ne9+U4BnPSn8Fe6M9KysLT5Xu0XTGhbYhuRGXJFlU1JbfYxywlUs4r0nznodZFrrhmWV7MzavKUGQ\\n+/i2nuQDbjdz3Fxz8Z" +
"05lH22WnYmiGEiK+fwb8Z7JINT6a5uEYGchpXJYQB6t/ucjPRnE+Emcyco\\nt4oLVP3jaSrebQgg7TIXL84Ib9we/YbT63u0LDZp95" +
"jnZkrmjBPSz9YhVuD/oRgznuVjm8L5VTkS\\nWCQZ0opBwYfaW/NVaZhg5cDVcv+4wRCLp4S3zmMerM02FaUtoxShK9VwQ6K4nq4al";
// 2. 文档第6页标注的样例密钥
String secret = "axjGikuWgvYkI3JA";
// 第一步:Base64解码获取密文字节
byte[] cipherText = Base64.getDecoder().decode(rawContent);
// 第二步:AES解密
SecretKeySpec skeySpec = new SecretKeySpec(secret.getBytes("UTF-8"), "AES");
// 文档第6页底部显示:ivBytes 16个0
byte[] ivBytes = new byte[16];
IvParameterSpec iv = new IvParameterSpec(ivBytes);
Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
cipher.init(Cipher.DECRYPT_MODE, skeySpec, iv);
// 解密后得到的内容(其实是一串 Base64 文本)
byte[] decryptedTextBytes = cipher.doFinal(cipherText);
String base64PdfText = new String(decryptedTextBytes, "UTF-8");
// 第三步:【关键】对解密出来的文本再次进行 Base64 解码,得到 PDF 二进制流
byte[] pdfFileBytes = Base64.getDecoder().decode(base64PdfText);
// 保存文件到本地
Files.write(Paths.get("SF_POD_SUCCESS.pdf"), pdfFileBytes);
System.out.println(">>> 解密成功!文件已生成:SF_POD_SUCCESS.pdf");
}
}
Loading…
Cancel
Save