diff --git a/src/main/java/com/project/logistics/config/SfApiProperties.java b/src/main/java/com/project/logistics/config/SfApiProperties.java index 01a587a..8411cc2 100644 --- a/src/main/java/com/project/logistics/config/SfApiProperties.java +++ b/src/main/java/com/project/logistics/config/SfApiProperties.java @@ -48,4 +48,8 @@ public class SfApiProperties { * 面单模板编码 */ private String templateCode; + /** + * 电子回单密钥 + */ + private String podSecret; } \ No newline at end of file diff --git a/src/main/java/com/project/logistics/domain/enums/OrderStatusEnum.java b/src/main/java/com/project/logistics/domain/enums/OrderStatusEnum.java index eb7790d..f39347c 100644 --- a/src/main/java/com/project/logistics/domain/enums/OrderStatusEnum.java +++ b/src/main/java/com/project/logistics/domain/enums/OrderStatusEnum.java @@ -18,9 +18,13 @@ public enum OrderStatusEnum { ERP_FEE_UPDATED("ERP_FEE_UPDATED", "7.运费和件数已回写ERP"), 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.异常阻断(需人工介入)"); private final String code; diff --git a/src/main/java/com/project/logistics/domain/enums/RetryActionEnum.java b/src/main/java/com/project/logistics/domain/enums/RetryActionEnum.java index ba5dd11..b1f035f 100644 --- a/src/main/java/com/project/logistics/domain/enums/RetryActionEnum.java +++ b/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_CREATE_ORDER("SF_CREATE_ORDER", "顺丰下单(获取运单号)"), SF_DOWNLOAD_WAYBILL("SF_DOWNLOAD_WAYBILL", "获取顺丰云打印面单PDF"), + SF_REGISTER_POD("SF_REGISTER_POD", "主动注册回单图片推送"), + SF_DOWNLOAD_POD("SF_DOWNLOAD_POD", "解析并下载顺丰签收底单图片"), /* --- ERP(U9)回写动作 --- */ diff --git a/src/main/java/com/project/logistics/domain/enums/SampleOrderInfoFieldEnum.java b/src/main/java/com/project/logistics/domain/enums/SampleOrderInfoFieldEnum.java index 144e1ff..f22a1b3 100644 --- a/src/main/java/com/project/logistics/domain/enums/SampleOrderInfoFieldEnum.java +++ b/src/main/java/com/project/logistics/domain/enums/SampleOrderInfoFieldEnum.java @@ -16,7 +16,7 @@ public enum SampleOrderInfoFieldEnum { recipientAddress("DescFlexField_PubDescSeg14" , "收货地址") , payer("DescFlexField_PubDescSeg20" , "运费承担") , waybillNo("DescFlexField_PrivateDescSeg20" , "货运单号") , - expressType("DescFlexField_PubDescSeg25" , "寄付方式") , + expressType("DescFlexField_PrivateDescSeg25" , "寄付方式") , fee("DescFlexField_PrivateDescSeg21" , "运费") , quantity("DescFlexField_PrivateDescSeg13" , "件数") , diff --git a/src/main/java/com/project/logistics/domain/service/SfApiService.java b/src/main/java/com/project/logistics/domain/service/SfApiService.java index 6759ed6..e765aa2 100644 --- a/src/main/java/com/project/logistics/domain/service/SfApiService.java +++ b/src/main/java/com/project/logistics/domain/service/SfApiService.java @@ -46,7 +46,6 @@ public class SfApiService { getUrlData.put("fileName", fileName); getUrlData.put("fileSize", fileSize); - // 注意:根据文档 2.3,获取地址接口也需要 Channel-Code Map headers = new HashMap<>(); headers.put("Channel-Code", sfApiProperties.getChannelCode()); @@ -66,15 +65,77 @@ public class SfApiService { log.info(">>> 顺丰侧已存在相同 MD5 文件, 直接使用 fileCode: {}", fileCode); } - // Step 3: 创建资源 + // Step 3: 创建资源 (核心业务逻辑修改) JSONObject createResourceData = new JSONObject(); - createResourceData.put("partyType", "SPECI_NAME"); // 签署人类型:收件人 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); } - /** * 执行二进制流上传 (HTTP PUT) */ diff --git a/src/main/java/com/project/logistics/domain/strategy/handler/SfCreateOrderHandler.java b/src/main/java/com/project/logistics/domain/strategy/handler/SfCreateOrderHandler.java index 9818019..415e0c6 100644 --- a/src/main/java/com/project/logistics/domain/strategy/handler/SfCreateOrderHandler.java +++ b/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("orderId", order.getOrderNo()); // 客户订单号 + msgData.put("totalWeight", 1); + String transportMethod = u9Data.getString(ShipmentOrderInfoFieldEnum.transportMethod.name()); if (StrUtil.isBlank(transportMethod) || !transportMethod.contains("顺丰")) { diff --git a/src/main/java/com/project/logistics/domain/utils/FilePathUtil.java b/src/main/java/com/project/logistics/domain/utils/FilePathUtil.java index 4a54b1d..ff18878 100644 --- a/src/main/java/com/project/logistics/domain/utils/FilePathUtil.java +++ b/src/main/java/com/project/logistics/domain/utils/FilePathUtil.java @@ -11,14 +11,14 @@ public class FilePathUtil { * 生成层级路径: 2026年/2026年1月/2026年1月31日 */ public static String getHierarchicalPath(Date date) { - // 使用Calendar类对日期进行减一天的操作 -// Calendar calendar = Calendar.getInstance(); -// calendar.setTime(date); -// calendar.add(Calendar.DAY_OF_YEAR, 0); -// Date newDate = calendar.getTime(); - String year = DateUtil.format(date, "yyyy年"); - String month = DateUtil.format(date, "yyyy年M月"); - String day = DateUtil.format(date, "yyyy年M月d日"); +// 使用Calendar类对日期进行减一天的操作 + Calendar calendar = Calendar.getInstance(); + calendar.setTime(date); + calendar.add(Calendar.DAY_OF_YEAR, 0); + Date newDate = calendar.getTime(); + String year = DateUtil.format(newDate, "yyyy年"); + String month = DateUtil.format(newDate, "yyyy年M月"); + String day = DateUtil.format(newDate, "yyyy年M月d日"); return year + "/" + month + "/" + day; } diff --git a/src/main/java/com/project/receive/domain/service/ReceiveService.java b/src/main/java/com/project/receive/domain/service/ReceiveService.java index 4817fb7..16a59fa 100644 --- a/src/main/java/com/project/receive/domain/service/ReceiveService.java +++ b/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.transaction.annotation.Transactional; +import javax.crypto.Cipher; +import javax.crypto.spec.IvParameterSpec; +import javax.crypto.spec.SecretKeySpec; import java.math.BigDecimal; +import java.util.Base64; import java.util.List; @Slf4j @@ -108,12 +112,12 @@ public class ReceiveService { order.setSfCurrentStateCode(opCode); // 根据核心节点跳转 order_status 状态机 - if (StrUtil.equals(SfRouteOpCodeEnum.PICKED_UP.name() , opCode)) { + if (StrUtil.equals(SfRouteOpCodeEnum.PICKED_UP.getCode() , opCode)) { // 顺丰已揽收 order.setOrderStatus(OrderStatusEnum.PICKED_UP.getCode()); log.info(">>> 单号 {} 状态机流转至: [PICKED_UP]", orderNo); } - else if (StrUtil.equals(SfRouteOpCodeEnum.DELIVERED.name() , opCode)) { + else if (StrUtil.equals(SfRouteOpCodeEnum.DELIVERED.getCode() , opCode)) { // 1. 更新为已签收 order.setOrderStatus(OrderStatusEnum.DELIVERED.getCode()); @@ -122,8 +126,10 @@ public class ReceiveService { log.info(">>> 样品单 {} 已签收,流程结束", orderNo); order.setOrderStatus(OrderStatusEnum.FINISHED.getCode()); } 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 图片推送 */ public void savePodPictureLog(SfPodPushRequest request) throws Exception { - // 关键:POD 推送包含巨大的 Base64 字节,这里只做存库。 - // 具体的解析、下载、传 WebDAV 交由后续的 SF_DOWNLOAD_POD 任务去重试执行。 String waybillNo = request.getWaybillNo(); - String base64Data = request.getContent(); - if (waybillNo == null) { - throw new IllegalArgumentException("运单号不能为空"); - } + String rawContent = request.getContent(); // 原始加密 Base64 字符串 + if (StrUtil.isBlank(rawContent)) return; + + // 1. 原样存入流水表 PodPushLogEntity podLog = new PodPushLogEntity(); podLog.setWaybillNo(waybillNo); - podLog.setRawPushData(base64Data); // 存入 LONGTEXT 字段 - podLog.setProcessStatus(SyncStatusEnum.WAIT.getCode()); + podLog.setRawPushData(rawContent); // 存储加密原文 + podLog.setProcessStatus(0); // 待处理状态 podPushLogService.save(podLog); - // 查找订单号并触发“处理图片”的异步任务 + // 2. 查找订单并触发异步处理任务 LogisticsOrderEntity order = logisticsOrderService.lambdaQuery() .eq(LogisticsOrderEntity::getSfWaybillNo, waybillNo).one(); if (order != null) { apiRetryTaskService.createNextTask(order.getOrderNo(), RetryActionEnum.SF_DOWNLOAD_POD); } + + + } + + /** + * 顺丰图片 AES 解密实现 + * 算法:AES/CBC/PKCS5Padding + * 偏移量 IV:16位 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); } } \ No newline at end of file diff --git a/src/main/java/com/project/receive/strategy/handler/SfDownloadPodHandler.java b/src/main/java/com/project/receive/strategy/handler/SfDownloadPodHandler.java new file mode 100644 index 0000000..63283b0 --- /dev/null +++ b/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); + } +} \ No newline at end of file diff --git a/src/main/java/com/project/receive/strategy/handler/SfRegisterPodHandler.java b/src/main/java/com/project/receive/strategy/handler/SfRegisterPodHandler.java new file mode 100644 index 0000000..8593a8a --- /dev/null +++ b/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")); + } + } +} \ No newline at end of file diff --git a/src/main/resources/application-preprod.yml b/src/main/resources/application-preprod.yml new file mode 100644 index 0000000..6ffb0dc --- /dev/null +++ b/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 \ No newline at end of file diff --git a/src/main/resources/application-sftest.yml b/src/main/resources/application-sftest.yml index 2872461..043af56 100644 --- a/src/main/resources/application-sftest.yml +++ b/src/main/resources/application-sftest.yml @@ -53,6 +53,7 @@ sf: tokenUrl: https://sfapi.sit.sf-express.com:45273/oauth2/accessToken baseUrl: https://sfapi.sit.sf-express.com:45273/std/service channelCode: INC-VMOS-CORE + podSecret: b283e485aad2a78f mybatis-plus: configuration: # log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发环境下打印SQL diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml index 1442b34..c51a3b0 100644 --- a/src/main/resources/application-test.yml +++ b/src/main/resources/application-test.yml @@ -53,6 +53,7 @@ sf: tokenUrl: https://sfapi-sbox.sf-express.com/oauth2/accessToken baseUrl: https://sfapi-sbox.sf-express.com/std/service channelCode: MCS-CAS-API-BOX + podSecret: axjGikUwgYVKiJ3A mybatis-plus: configuration: # log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发环境下打印SQL @@ -69,33 +70,33 @@ logistics: scanner: enabled: true # 每 10 分钟唤醒一次 (系统开销微乎其微) - cron: "0 0/10 * * * ?" + cron: "0 8 11,15,16,17,18,19,20 * * ?" windows: # 这个窗口设置10分钟有效期,配合10分钟一次的唤醒,只会跑一次 - name: "上午11点班次" - startTime: "11:08" - endTime: "11:08" - intervalMinutes: 60 + startTime: "11:00" + endTime: "11:15" + intervalMinutes: 30 # 这个窗口在15-20点之间,每隔20分钟会真正跑一次逻辑 - name: "下午至傍晚波次" - startTime: "15:08" - endTime: "20:08" - intervalMinutes: 60 + startTime: "15:00" + endTime: "20:15" + intervalMinutes: 30 sample-scanner: enabled: true # 每 10 分钟唤醒一次 (系统开销微乎其微) - cron: "0 0/10 * * * ?" + cron: "0 8 11,15,16,17,18,19,20 * * ?" windows: # 这个窗口设置10分钟有效期,配合10分钟一次的唤醒,只会跑一次 - name: "上午11点班次" - startTime: "11:08" - endTime: "11:08" - intervalMinutes: 60 + startTime: "11:00" + endTime: "11:15" + intervalMinutes: 30 # 这个窗口在15-20点之间,每隔20分钟会真正跑一次逻辑 - name: "下午至傍晚波次" - startTime: "15:08" - endTime: "20:08" - intervalMinutes: 60 + startTime: "15:00" + endTime: "20:15" + intervalMinutes: 30 logging: level: # 强制打印 Hibernate 初始化过程 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 3057fac..9382408 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -53,6 +53,7 @@ sf: tokenUrl: https://sfapi-sbox.sf-express.com/oauth2/accessToken baseUrl: https://sfapi-sbox.sf-express.com/std/service channelCode: MCS-CAS-API-BOX + podSecret: b4fb275c23204056 mybatis-plus: configuration: # log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发环境下打印SQL diff --git a/src/test/java/com/project/logistics/domain/scheduler/SfTest.java b/src/test/java/com/project/logistics/domain/scheduler/SfTest.java new file mode 100644 index 0000000..71f8434 --- /dev/null +++ b/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"); + } +} \ No newline at end of file