43 changed files with 751 additions and 164 deletions
@ -1,6 +1,6 @@ |
|||||
<?xml version="1.0" encoding="UTF-8"?> |
<?xml version="1.0" encoding="UTF-8"?> |
||||
<project version="4"> |
<project version="4"> |
||||
<component name="SqlDialectMappings"> |
<component name="SqlDialectMappings"> |
||||
<file url="file://$PROJECT_DIR$/src/main/java/com/project/logistics/domain/service/base/ErpServiceImpl.java" dialect="GenericSQL" /> |
<file url="file://$PROJECT_DIR$/src/main/java/com/project/logistics/domain/service/base/impl/ErpServiceImpl.java" dialect="GenericSQL" /> |
||||
</component> |
</component> |
||||
</project> |
</project> |
||||
@ -0,0 +1,55 @@ |
|||||
|
package com.project.logistics.domain.scheduler; |
||||
|
|
||||
|
import com.project.logistics.config.WebDavProperties; |
||||
|
import com.project.logistics.domain.service.WebDavService; |
||||
|
import com.project.logistics.domain.utils.FilePathUtil; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.scheduling.annotation.Scheduled; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
import java.util.Date; |
||||
|
|
||||
|
@Slf4j |
||||
|
@Component |
||||
|
public class WebDavDirectoryInitJob { |
||||
|
|
||||
|
@Autowired |
||||
|
private WebDavService webDavService; |
||||
|
|
||||
|
@Autowired |
||||
|
private WebDavProperties webDavProperties; |
||||
|
|
||||
|
/** |
||||
|
* 每天凌晨 00:00:01 执行 |
||||
|
* 预先创建好当天所有的业务文件夹 |
||||
|
*/ |
||||
|
@Scheduled(cron = "8 0 0 * * ?") |
||||
|
public void initTodayDirectories() { |
||||
|
log.info(">>> [系统预热] 开始初始化今日 WebDAV 业务目录..."); |
||||
|
|
||||
|
// 1. 生成今日层级路径: 2026年/2026年3月/2026年3月30日
|
||||
|
String datePath = FilePathUtil.getHierarchicalPath(new Date()); |
||||
|
|
||||
|
try { |
||||
|
// 2. 初始化【待处理出货单】目录 (用户上班要往里放文件)
|
||||
|
webDavService.initFolder(webDavProperties.getShipmentRoot(), datePath); |
||||
|
|
||||
|
// 3. 初始化【已自动下单出货单】目录 (下单成功后文件会自动移入)
|
||||
|
webDavService.initFolder(webDavProperties.getProcessedFolderName(), datePath); |
||||
|
|
||||
|
// 4. 初始化【顺丰面单】目录
|
||||
|
// 假设你在 WebDavProperties 中定义了 waybillRoot
|
||||
|
webDavService.initFolder("顺丰面单", datePath); |
||||
|
|
||||
|
// 5. 初始化【签收回单】目录
|
||||
|
webDavService.initFolder(webDavProperties.getPodRoot(), datePath); |
||||
|
|
||||
|
log.info(">>> [系统预热] 今日所有目录初始化完成!"); |
||||
|
|
||||
|
} catch (Exception e) { |
||||
|
log.error(">>> [系统预热] 自动创建今日目录失败: {}", e.getMessage()); |
||||
|
// 注意:这里失败没关系,业务运行时的 ensureDirectoryExists 还有一次兜底机会
|
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -1,9 +1,10 @@ |
|||||
package com.project.logistics.domain.service.base; |
package com.project.logistics.domain.service.base.impl; |
||||
|
|
||||
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
||||
import com.project.logistics.domain.entity.ApiRetryTaskEntity; |
import com.project.logistics.domain.entity.ApiRetryTaskEntity; |
||||
import com.project.logistics.domain.enums.RetryActionEnum; |
import com.project.logistics.domain.enums.RetryActionEnum; |
||||
import com.project.logistics.domain.enums.TaskStatusEnum; |
import com.project.logistics.domain.enums.TaskStatusEnum; |
||||
|
import com.project.logistics.domain.service.base.ApiRetryTaskService; |
||||
import com.project.logistics.mapper.ApiRetryTaskMapper; |
import com.project.logistics.mapper.ApiRetryTaskMapper; |
||||
import lombok.extern.slf4j.Slf4j; |
import lombok.extern.slf4j.Slf4j; |
||||
import org.springframework.stereotype.Service; |
import org.springframework.stereotype.Service; |
||||
@ -1,162 +1,87 @@ |
|||||
package com.project.receive.controller; |
package com.project.receive.controller; |
||||
|
|
||||
import com.fasterxml.jackson.databind.ObjectMapper; |
|
||||
import com.project.receive.dto.*; |
import com.project.receive.domain.dto.SfRoutePushRequest; |
||||
import com.project.receive.utils.SfDecryptUtil; |
import com.project.receive.domain.service.ReceiveService; |
||||
import lombok.extern.slf4j.Slf4j; |
import lombok.extern.slf4j.Slf4j; |
||||
import org.springframework.beans.factory.annotation.Autowired; |
import org.springframework.beans.factory.annotation.Autowired; |
||||
import org.springframework.web.bind.annotation.*; |
import org.springframework.web.bind.annotation.*; |
||||
|
|
||||
import java.io.FileOutputStream; |
import java.util.HashMap; |
||||
import java.nio.file.Path; |
import java.util.Map; |
||||
import java.nio.file.Paths; |
|
||||
|
|
||||
@RestController |
|
||||
@Slf4j |
@Slf4j |
||||
@RequestMapping("/api/") |
@RestController |
||||
|
@RequestMapping("/api/sf/callback") |
||||
public class ReceiveController { |
public class ReceiveController { |
||||
|
|
||||
private static final String SF_CHECKWORD = "ZXmoWOQdSd2UTBmSP6Kv3VW9Q4N5dJqz"; |
@Autowired |
||||
|
private ReceiveService receiveService; |
||||
|
|
||||
/** |
/** |
||||
* 接收顺丰订单状态推送接口 |
* 1. 路由状态回调 (揽收/签收) |
||||
* 对应文档 2.6 节 JSON 示例 |
* 对应接口: PushOrderState |
||||
*/ |
*/ |
||||
@PostMapping("/pushOrderState") |
@PostMapping("/route") |
||||
public SfPushResponse receiveOrderState(@RequestBody SfPushRequest request) { |
public Map<String, Object> handleRoutePush(@RequestBody SfRoutePushRequest request) { |
||||
// 1. 打印接收到的原始数据日志
|
log.info(">>> 收到顺丰路由推送,包含节点数: {}", |
||||
log.info("==== 收到顺丰状态推送 ===="); |
(request.getBody() != null && request.getBody().getWaybillRoute() != null) ? |
||||
log.info("Request ID: {}", request.getRequestId()); |
request.getBody().getWaybillRoute().size() : 0); |
||||
log.info("Timestamp: {}", request.getTimestamp()); |
|
||||
|
|
||||
if (request.getOrderState() != null) { |
try { |
||||
request.getOrderState().forEach(state -> { |
// 业务处理逻辑
|
||||
log.info("订单号: {}, 运单号: {}, 状态码: {}, 描述: {}", |
receiveService.processRoutePush(request); |
||||
state.getOrderNo(), |
|
||||
state.getWaybillNo(), |
|
||||
state.getOrderStateCode(), |
|
||||
state.getOrderStateDesc()); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// 2. 根据文档 2.7 节,返回成功响应
|
// 返回顺丰要求的成功格式
|
||||
return SfPushResponse.ok(); |
return successResponse(); |
||||
|
} catch (Exception e) { |
||||
|
log.error(">>> 路由解析处理失败", e); |
||||
|
return failResponse(e.getMessage()); |
||||
|
} |
||||
} |
} |
||||
|
|
||||
|
|
||||
/** |
/** |
||||
* 接收顺丰路由推送 |
* 2. 费用与重量回调 |
||||
* 对应文档 2.5 节 JSON 示例 |
* 对应接口: EXP_RECE_WAYBILLS_FEE_PUSH |
||||
*/ |
*/ |
||||
@PostMapping("/pushRoute") |
@PostMapping("/fee") |
||||
public SfRouteResponse receiveRoute(@RequestBody SfRoutePushRequest request) { |
public Map<String, Object> handleFeePush(@RequestParam("content") String content) { |
||||
log.info(">>>> 收到顺丰路由信息推送 <<<<"); |
log.info(">>> 收到顺丰【运费重量】推送: {}", content); |
||||
|
try { |
||||
if (request.getBody() != null && request.getBody().getWaybillRoute() != null) { |
receiveService.saveFeeLog(content); |
||||
for (WaybillRoute route : request.getBody().getWaybillRoute()) { |
return successResponse(); |
||||
log.info("运单号: {}, 订单号: {}, 时间: {}, 状态: {}, 备注: {}", |
} catch (Exception e) { |
||||
route.getMailno(), |
log.error("运费处理异常", e); |
||||
route.getOrderid(), |
return failResponse(e.getMessage()); |
||||
route.getAcceptTime(), |
|
||||
route.getOpCode(), |
|
||||
route.getRemark()); |
|
||||
} |
|
||||
} else { |
|
||||
log.warn("收到的路由数据内容为空"); |
|
||||
} |
} |
||||
|
|
||||
// 根据文档 2.6,必须返回 0000 告知顺丰接收成功,否则顺丰会重复推送
|
|
||||
return SfRouteResponse.ok(); |
|
||||
} |
} |
||||
|
|
||||
|
|
||||
@RequestMapping("/pushOrderStateRaw") |
|
||||
public String receiveRaw(@RequestBody String rawBody) { |
|
||||
log.info("收到原始报文: {}", rawBody); |
|
||||
// 返回 JSON 格式的成功字符串
|
|
||||
return "{\"success\":\"true\",\"code\":\"0\",\"msg\":\"\"}"; |
|
||||
} |
|
||||
|
|
||||
@Autowired |
|
||||
private ObjectMapper objectMapper; |
|
||||
|
|
||||
/** |
/** |
||||
* 接收顺丰运费推送 |
* 3. 电子回单图片回调 |
||||
* 报文类型: application/x-www-form-urlencoded |
* 对应接口: 图片注册及推送接口 |
||||
*/ |
*/ |
||||
@PostMapping(value = "/pushFee", consumes = "application/x-www-form-urlencoded") |
@PostMapping("/pod-picture") |
||||
public SfFeeResponse receiveFee( |
public Map<String, Object> handlePodPicturePush(@RequestParam("content") String content) { |
||||
@RequestParam("content") String content, |
log.info(">>> 收到顺丰【电子回单图片】推送 (内容较长,不完整打印)"); |
||||
@RequestParam(value = "sign", required = false) String sign) { |
|
||||
|
|
||||
log.info(">>>> 收到顺丰运费推送 <<<<"); |
|
||||
log.info("签名(sign): {}", sign); |
|
||||
log.info("原始内容(content): {}", content); |
|
||||
|
|
||||
try { |
try { |
||||
// 将 content 字符串解析为 Java 对象
|
receiveService.savePodPictureLog(content); |
||||
SfFeeContent feeData = objectMapper.readValue(content, SfFeeContent.class); |
return successResponse(); |
||||
|
|
||||
log.info("解析成功 - 运单号: {}, 订单号: {}, 计费重量: {}", |
|
||||
feeData.getWaybillNo(), |
|
||||
feeData.getOrderNo(), |
|
||||
feeData.getMeterageWeightQty()); |
|
||||
|
|
||||
if (feeData.getFeeList() != null) { |
|
||||
feeData.getFeeList().forEach(fee -> { |
|
||||
log.info("费用项 - 类型: {}, 金额: {}", fee.getFeeTypeCode(), fee.getFeeAmt()); |
|
||||
}); |
|
||||
} |
|
||||
|
|
||||
// 返回成功响应 (code 必须为 200)
|
|
||||
return SfFeeResponse.ok("your_partner_code"); |
|
||||
|
|
||||
} catch (Exception e) { |
} catch (Exception e) { |
||||
log.error("解析顺丰运费推送失败", e); |
log.error("回单图片处理异常", e); |
||||
SfFeeResponse error = new SfFeeResponse(); |
return failResponse(e.getMessage()); |
||||
error.setCode(400); |
|
||||
error.setMessage("解析异常"); |
|
||||
return error; |
|
||||
} |
} |
||||
} |
} |
||||
|
|
||||
@PostMapping("/pushElectronicReceipt") |
private Map<String, Object> successResponse() { |
||||
public SfPicturePushResponse receiveReceipt(@RequestBody SfPicturePushRequest request) { |
Map<String, Object> res = new HashMap<>(); |
||||
log.info(">>>> 收到顺丰电子回单(IN149)推送, 运单号: {} <<<<", request.getWaybillNo()); |
res.put("success", true); |
||||
|
res.put("errorCode", "S0000"); |
||||
try { |
return res; |
||||
// 步骤 1:解密最外层的 content 得到内部 JSON 字符串
|
} |
||||
byte[] firstLevelDecrypted = SfDecryptUtil.decrypt(request.getContent(), SF_CHECKWORD); |
|
||||
String innerJsonStr = new String(firstLevelDecrypted, "UTF-8"); |
|
||||
log.info("第一层解密成功"); |
|
||||
|
|
||||
// 步骤 2:解析内部 JSON 获取文件密文
|
|
||||
SfInnerContent innerContent = objectMapper.readValue(innerJsonStr, SfInnerContent.class); |
|
||||
String encryptedFileBase64 = innerContent.getContent(); |
|
||||
|
|
||||
// 步骤 3:对文件密文进行第二层 AES 解密 (根据文档示例,文件内容也是加密的)
|
|
||||
// 先 Base64 解码,再 AES 解密
|
|
||||
byte[] pdfBytes = SfDecryptUtil.decrypt(encryptedFileBase64, SF_CHECKWORD); |
|
||||
log.info("第二层解密(文件流)成功,大小: {} bytes", pdfBytes.length); |
|
||||
|
|
||||
// 步骤 4:保存为 PDF 文件到当前目录
|
|
||||
String fileName = request.getWaybillNo() + "_receipt.pdf"; |
|
||||
Path path = Paths.get(System.getProperty("user.dir"), fileName); |
|
||||
|
|
||||
try (FileOutputStream fos = new FileOutputStream(path.toFile())) { |
|
||||
fos.write(pdfBytes); |
|
||||
fos.flush(); |
|
||||
} |
|
||||
|
|
||||
log.info("电子回单已保存至: {}", path.toAbsolutePath()); |
|
||||
|
|
||||
return SfPicturePushResponse.ok(); |
|
||||
|
|
||||
} catch (Exception e) { |
private Map<String, Object> failResponse(String msg) { |
||||
log.error("处理电子回单推送失败", e); |
Map<String, Object> res = new HashMap<>(); |
||||
SfPicturePushResponse error = new SfPicturePushResponse(); |
res.put("success", false); |
||||
error.setReturnCode("1000"); |
res.put("errorMsg", msg); |
||||
error.setReturnMsg("解析保存失败: " + e.getMessage()); |
return res; |
||||
return error; |
|
||||
} |
|
||||
} |
} |
||||
} |
} |
||||
@ -0,0 +1,29 @@ |
|||||
|
package com.project.receive.domain.dto; |
||||
|
|
||||
|
import com.fasterxml.jackson.annotation.JsonProperty; |
||||
|
import lombok.Data; |
||||
|
import java.util.List; |
||||
|
|
||||
|
@Data |
||||
|
public class SfRoutePushRequest { |
||||
|
|
||||
|
@JsonProperty("Body") // 必须对应顺丰的大写 Body
|
||||
|
private RouteBody body; |
||||
|
|
||||
|
@Data |
||||
|
public static class RouteBody { |
||||
|
@JsonProperty("WaybillRoute") // 必须对应顺丰的大写 WaybillRoute
|
||||
|
private List<WaybillRouteDetail> waybillRoute; |
||||
|
} |
||||
|
|
||||
|
@Data |
||||
|
public static class WaybillRouteDetail { |
||||
|
private String mailno; // 顺丰运单号
|
||||
|
private String orderid; // 客户订单号 (U9单号)
|
||||
|
private String acceptAddress; // 发生地址
|
||||
|
private String acceptTime; // 发生时间
|
||||
|
private String remark; // 备注内容
|
||||
|
private String opCode; // 操作码 (50:揽收, 80:签收)
|
||||
|
private String id; // 推送ID
|
||||
|
} |
||||
|
} |
||||
@ -1,4 +1,4 @@ |
|||||
package com.project.logistics.domain.entity; |
package com.project.receive.domain.entity; |
||||
|
|
||||
import com.baomidou.mybatisplus.annotation.TableField; |
import com.baomidou.mybatisplus.annotation.TableField; |
||||
import com.baomidou.mybatisplus.annotation.TableName; |
import com.baomidou.mybatisplus.annotation.TableName; |
||||
@ -0,0 +1,181 @@ |
|||||
|
package com.project.receive.domain.service; |
||||
|
|
||||
|
import com.jayway.jsonpath.JsonPath; |
||||
|
import com.project.logistics.domain.entity.LogisticsOrderEntity; |
||||
|
|
||||
|
import com.project.logistics.domain.enums.*; |
||||
|
import com.project.logistics.domain.service.base.*; |
||||
|
import com.project.receive.domain.dto.SfRoutePushRequest; |
||||
|
import com.project.receive.domain.entity.FeePushLogEntity; |
||||
|
import com.project.receive.domain.entity.PodPushLogEntity; |
||||
|
import com.project.receive.domain.entity.RoutePushLogEntity; |
||||
|
import com.project.receive.domain.service.base.FeePushLogService; |
||||
|
import com.project.receive.domain.service.base.PodPushLogService; |
||||
|
import com.project.receive.domain.service.base.RoutePushLogService; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
import org.springframework.transaction.annotation.Transactional; |
||||
|
|
||||
|
import java.math.BigDecimal; |
||||
|
import java.util.List; |
||||
|
|
||||
|
@Slf4j |
||||
|
@Service |
||||
|
public class ReceiveService { |
||||
|
|
||||
|
@Autowired |
||||
|
private LogisticsOrderService logisticsOrderService; |
||||
|
@Autowired |
||||
|
private ApiRetryTaskService apiRetryTaskService; |
||||
|
|
||||
|
@Autowired |
||||
|
private RoutePushLogService routePushLogService; |
||||
|
@Autowired |
||||
|
private FeePushLogService feePushLogService; |
||||
|
@Autowired |
||||
|
private PodPushLogService podPushLogService; |
||||
|
|
||||
|
/** |
||||
|
* 处理路由推送:识别揽收与签收 |
||||
|
*/ |
||||
|
public void processRoutePush(SfRoutePushRequest request) throws Exception { |
||||
|
if (request.getBody() == null || request.getBody().getWaybillRoute() == null) { |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 循环处理顺丰推过来的每一个路由节点
|
||||
|
for (SfRoutePushRequest.WaybillRouteDetail detail : request.getBody().getWaybillRoute()) { |
||||
|
|
||||
|
log.info(">>> 正在解析路由: 单号={}, 操作码={}, 描述={}", |
||||
|
detail.getOrderid(), detail.getOpCode(), detail.getRemark()); |
||||
|
|
||||
|
// 1. 存入流水日志
|
||||
|
saveToLogTable(detail); |
||||
|
|
||||
|
// 2. 状态机流转 (仅针对揽收50和签收80)
|
||||
|
handleStatusMachine(detail); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void handleStatusMachine(SfRoutePushRequest.WaybillRouteDetail detail) throws Exception { |
||||
|
String orderNo = detail.getOrderid(); |
||||
|
String opCode = detail.getOpCode(); |
||||
|
|
||||
|
// 尝试获取本地订单
|
||||
|
LogisticsOrderEntity order = logisticsOrderService.getByOrderNo(orderNo); |
||||
|
if (order == null) { |
||||
|
log.warn(">>> 收到路由推送,但本地库找不到单号: {}", orderNo); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
// 判断操作码
|
||||
|
if (SfRouteOpCodeEnum.PICKED_UP.getCode().equals(opCode)) { |
||||
|
// 已揽收
|
||||
|
order.setOrderStatus(OrderStatusEnum.PICKED_UP.getCode()); |
||||
|
logisticsOrderService.updateById(order); |
||||
|
// 触发回写 ERP 任务
|
||||
|
apiRetryTaskService.createNextTask(orderNo, RetryActionEnum.ERP_UPDATE_PICKED_UP); |
||||
|
} |
||||
|
else if (SfRouteOpCodeEnum.DELIVERED.getCode().equals(opCode)) { |
||||
|
// 已签收
|
||||
|
order.setOrderStatus(OrderStatusEnum.DELIVERED.getCode()); |
||||
|
logisticsOrderService.updateById(order); |
||||
|
// 触发回写 ERP 任务
|
||||
|
apiRetryTaskService.createNextTask(orderNo, RetryActionEnum.ERP_UPDATE_DELIVERED); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
private void saveToLogTable(SfRoutePushRequest.WaybillRouteDetail detail) { |
||||
|
RoutePushLogEntity logEntity = new RoutePushLogEntity(); |
||||
|
logEntity.setOrderNo(detail.getOrderid()); |
||||
|
logEntity.setWaybillNo(detail.getMailno()); |
||||
|
logEntity.setOpCode(detail.getOpCode()); |
||||
|
logEntity.setRemark(detail.getRemark()); |
||||
|
logEntity.setAcceptTime(detail.getAcceptTime()); |
||||
|
routePushLogService.save(logEntity); |
||||
|
} |
||||
|
/** |
||||
|
* 处理运费推送 |
||||
|
*/ |
||||
|
@Transactional(rollbackFor = Exception.class) |
||||
|
public void saveFeeLog(String rawJson) { |
||||
|
try { |
||||
|
// 1. 提取基础信息
|
||||
|
String orderNo = JsonPath.read(rawJson, "$.orderNo"); |
||||
|
String waybillNo = JsonPath.read(rawJson, "$.waybillNo"); |
||||
|
|
||||
|
// 提取件数 (quantity)
|
||||
|
Object qtyObj = JsonPath.read(rawJson, "$.quantity"); |
||||
|
Integer itemCount = new java.math.BigDecimal(String.valueOf(qtyObj)).intValue(); |
||||
|
|
||||
|
// 提取重量 (meterageWeightQty)
|
||||
|
Object weightObj = JsonPath.read(rawJson, "$.meterageWeightQty"); |
||||
|
BigDecimal weight = new java.math.BigDecimal(String.valueOf(weightObj)) |
||||
|
.setScale(3, java.math.RoundingMode.HALF_UP); |
||||
|
// 2. 【核心】计算总费用
|
||||
|
// 从 feeList 数组中提取所有 feeAmt 并求和
|
||||
|
List<Double> fees = JsonPath.read(rawJson, "$.feeList[*].feeAmt"); |
||||
|
BigDecimal totalFee = BigDecimal.ZERO; |
||||
|
if (fees != null) { |
||||
|
for (Object f : fees) { |
||||
|
totalFee = totalFee.add(new BigDecimal(String.valueOf(f))); |
||||
|
} |
||||
|
} |
||||
|
totalFee = totalFee.setScale(2, java.math.RoundingMode.HALF_UP); |
||||
|
|
||||
|
log.info(">>> 解析运费报文: 单号={}, 件数={}, 重量={}, 总费={}", |
||||
|
orderNo, itemCount, weight, totalFee); |
||||
|
|
||||
|
// 3. 存入数据库
|
||||
|
FeePushLogEntity feeEntity = new FeePushLogEntity(); |
||||
|
feeEntity.setOrderNo(orderNo); |
||||
|
feeEntity.setWaybillNo(waybillNo); |
||||
|
feeEntity.setItemCount(itemCount); |
||||
|
feeEntity.setRealWeightQty(weight); |
||||
|
feeEntity.setTotalFeeAmt(totalFee); |
||||
|
feeEntity.setSyncStatus(SyncStatusEnum.WAIT.getCode()); |
||||
|
feeEntity.setRawPushData(rawJson); |
||||
|
feePushLogService.save(feeEntity); |
||||
|
|
||||
|
// 4. 驱动状态机与回写任务
|
||||
|
// 找到主订单,如果当前状态是已揽收,则推进到“运费已清点”状态
|
||||
|
LogisticsOrderEntity order = logisticsOrderService.getByOrderNo(orderNo); |
||||
|
if (order != null) { |
||||
|
// 只有状态处于 PICKED_UP 时才更新主表状态,避免覆盖更高级的 DELIVERED 状态
|
||||
|
if (OrderStatusEnum.PICKED_UP.getCode().equals(order.getOrderStatus())) { |
||||
|
order.setOrderStatus(OrderStatusEnum.ERP_FEE_UPDATED.getCode()); |
||||
|
logisticsOrderService.updateById(order); |
||||
|
} |
||||
|
|
||||
|
// 无论主表状态如何,都要触发一次“回写运费到ERP”的任务
|
||||
|
apiRetryTaskService.createNextTask(orderNo, RetryActionEnum.ERP_UPDATE_FEE); |
||||
|
} |
||||
|
|
||||
|
} catch (Exception e) { |
||||
|
log.error(">>> 运费报文解析失败: {}", rawJson, e); |
||||
|
throw new RuntimeException("FEE_PARSE_ERROR"); |
||||
|
} |
||||
|
} |
||||
|
/** |
||||
|
* 处理 POD 图片推送 |
||||
|
*/ |
||||
|
public void savePodPictureLog(String content) throws Exception { |
||||
|
// 关键:POD 推送包含巨大的 Base64 字节,这里只做存库。
|
||||
|
// 具体的解析、下载、传 WebDAV 交由后续的 SF_DOWNLOAD_POD 任务去重试执行。
|
||||
|
String waybillNo = JsonPath.read(content, "$.waybillNo"); |
||||
|
|
||||
|
PodPushLogEntity podLog = new PodPushLogEntity(); |
||||
|
podLog.setWaybillNo(waybillNo); |
||||
|
podLog.setRawPushData(content); // 存入 LONGTEXT 字段
|
||||
|
podLog.setProcessStatus(SyncStatusEnum.WAIT.getCode()); |
||||
|
podPushLogService.save(podLog); |
||||
|
|
||||
|
// 查找订单号并触发“处理图片”的异步任务
|
||||
|
LogisticsOrderEntity order = logisticsOrderService.lambdaQuery() |
||||
|
.eq(LogisticsOrderEntity::getSfWaybillNo, waybillNo).one(); |
||||
|
if (order != null) { |
||||
|
apiRetryTaskService.createNextTask(order.getOrderNo(), RetryActionEnum.SF_DOWNLOAD_POD); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
package com.project.receive.domain.service.base; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.extension.service.IService; |
||||
|
import com.project.receive.domain.entity.FeePushLogEntity; |
||||
|
|
||||
|
public interface FeePushLogService extends IService<FeePushLogEntity> { |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
package com.project.receive.domain.service.base; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.extension.service.IService; |
||||
|
import com.project.receive.domain.entity.PodPushLogEntity; |
||||
|
|
||||
|
public interface PodPushLogService extends IService<PodPushLogEntity> { |
||||
|
} |
||||
@ -0,0 +1,7 @@ |
|||||
|
package com.project.receive.domain.service.base; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.extension.service.IService; |
||||
|
import com.project.receive.domain.entity.RoutePushLogEntity; |
||||
|
|
||||
|
public interface RoutePushLogService extends IService<RoutePushLogEntity> { |
||||
|
} |
||||
@ -0,0 +1,12 @@ |
|||||
|
package com.project.receive.domain.service.base.impl; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
||||
|
import com.project.receive.domain.entity.FeePushLogEntity; |
||||
|
import com.project.receive.domain.service.base.FeePushLogService; |
||||
|
import com.project.receive.mapper.FeePushLogMapper; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
|
||||
|
@Service |
||||
|
public class FeePushLogServiceImpl extends ServiceImpl<FeePushLogMapper , FeePushLogEntity> |
||||
|
implements FeePushLogService { |
||||
|
} |
||||
@ -0,0 +1,13 @@ |
|||||
|
package com.project.receive.domain.service.base.impl; |
||||
|
|
||||
|
|
||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
||||
|
import com.project.receive.domain.entity.PodPushLogEntity; |
||||
|
import com.project.receive.domain.service.base.PodPushLogService; |
||||
|
import com.project.receive.mapper.PodPushLogMapper; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
|
||||
|
@Service |
||||
|
public class PodPushLogServiceImpl extends ServiceImpl<PodPushLogMapper, PodPushLogEntity> |
||||
|
implements PodPushLogService { |
||||
|
} |
||||
@ -0,0 +1,13 @@ |
|||||
|
package com.project.receive.domain.service.base.impl; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
||||
|
import com.project.receive.domain.entity.RoutePushLogEntity; |
||||
|
import com.project.receive.domain.service.base.RoutePushLogService; |
||||
|
import com.project.receive.mapper.RoutePushLogMapper; |
||||
|
import org.springframework.stereotype.Service; |
||||
|
|
||||
|
|
||||
|
@Service |
||||
|
public class RoutePushLogServiceImpl extends ServiceImpl<RoutePushLogMapper, RoutePushLogEntity> |
||||
|
implements RoutePushLogService { |
||||
|
} |
||||
@ -0,0 +1,10 @@ |
|||||
|
package com.project.receive.mapper; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
|
import com.project.receive.domain.entity.FeePushLogEntity; |
||||
|
import org.apache.ibatis.annotations.Mapper; |
||||
|
|
||||
|
@Mapper |
||||
|
|
||||
|
public interface FeePushLogMapper extends BaseMapper<FeePushLogEntity> { |
||||
|
} |
||||
@ -0,0 +1,10 @@ |
|||||
|
package com.project.receive.mapper; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
|
import com.project.receive.domain.entity.PodPushLogEntity; |
||||
|
import org.apache.ibatis.annotations.Mapper; |
||||
|
|
||||
|
@Mapper |
||||
|
|
||||
|
public interface PodPushLogMapper extends BaseMapper<PodPushLogEntity> { |
||||
|
} |
||||
@ -0,0 +1,10 @@ |
|||||
|
package com.project.receive.mapper; |
||||
|
|
||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
||||
|
import com.project.receive.domain.entity.RoutePushLogEntity; |
||||
|
import org.apache.ibatis.annotations.Mapper; |
||||
|
|
||||
|
@Mapper |
||||
|
|
||||
|
public interface RoutePushLogMapper extends BaseMapper<RoutePushLogEntity> { |
||||
|
} |
||||
@ -0,0 +1,58 @@ |
|||||
|
package com.project.receive.strategy; |
||||
|
|
||||
|
import com.project.logistics.domain.entity.ApiRetryTaskEntity; |
||||
|
import com.project.logistics.domain.entity.LogisticsOrderEntity; |
||||
|
import com.project.logistics.domain.enums.RetryActionEnum; |
||||
|
import com.project.logistics.domain.enums.SyncStatusEnum; |
||||
|
import com.project.logistics.domain.service.base.ErpService; |
||||
|
import com.project.logistics.domain.strategy.ApiTaskHandler; |
||||
|
import com.project.receive.domain.entity.FeePushLogEntity; |
||||
|
import com.project.receive.domain.service.base.FeePushLogService; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
@Slf4j |
||||
|
@Component |
||||
|
public class ErpUpdateFeeHandler implements ApiTaskHandler { |
||||
|
|
||||
|
@Autowired |
||||
|
private ErpService erpService; |
||||
|
|
||||
|
@Autowired |
||||
|
private FeePushLogService feePushLogService; |
||||
|
|
||||
|
@Override |
||||
|
public String getActionCode() { |
||||
|
return RetryActionEnum.ERP_UPDATE_FEE.getCode(); |
||||
|
} |
||||
|
|
||||
|
@Override |
||||
|
public void handle(ApiRetryTaskEntity task, LogisticsOrderEntity order) throws Exception { |
||||
|
log.info(">>> 开始回写运费件数到 ERP,单号: {}", order.getOrderNo()); |
||||
|
|
||||
|
// 1. 获取顺丰推过来的费用明细 (取最新的一条)
|
||||
|
FeePushLogEntity feeLog = feePushLogService.lambdaQuery() |
||||
|
.eq(FeePushLogEntity::getOrderNo, order.getOrderNo()) |
||||
|
.orderByDesc(FeePushLogEntity::getCreateTime) |
||||
|
.last("limit 1") |
||||
|
.one(); |
||||
|
|
||||
|
if (feeLog == null) { |
||||
|
throw new RuntimeException("尚未收到顺丰的计费推送报文,任务等待重试"); |
||||
|
} |
||||
|
|
||||
|
// 2. 调用 U9 专线执行更新
|
||||
|
erpService.updateFeeToErp( |
||||
|
order.getOrderNo(), |
||||
|
feeLog.getItemCount(), |
||||
|
feeLog.getTotalFeeAmt() |
||||
|
); |
||||
|
|
||||
|
// 3. 更新费用日志的同步状态
|
||||
|
feeLog.setSyncStatus(SyncStatusEnum.SUCCESS.getCode()); |
||||
|
feePushLogService.updateById(feeLog); |
||||
|
|
||||
|
log.info(">>> ERP 费用回写成功,单号: {}", order.getOrderNo()); |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,162 @@ |
|||||
|
package com.project.receivedemo.controller; |
||||
|
|
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.project.receivedemo.dto.*; |
||||
|
import com.project.receivedemo.utils.SfDecryptUtil; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.beans.factory.annotation.Autowired; |
||||
|
import org.springframework.web.bind.annotation.*; |
||||
|
|
||||
|
import java.io.FileOutputStream; |
||||
|
import java.nio.file.Path; |
||||
|
import java.nio.file.Paths; |
||||
|
|
||||
|
@RestController |
||||
|
@Slf4j |
||||
|
@RequestMapping("/api/") |
||||
|
public class ReceiveDemoController { |
||||
|
|
||||
|
private static final String SF_CHECKWORD = "ZXmoWOQdSd2UTBmSP6Kv3VW9Q4N5dJqz"; |
||||
|
/** |
||||
|
* 接收顺丰订单状态推送接口 |
||||
|
* 对应文档 2.6 节 JSON 示例 |
||||
|
*/ |
||||
|
@PostMapping("/pushOrderState") |
||||
|
public SfPushResponse receiveOrderState(@RequestBody SfPushRequest request) { |
||||
|
// 1. 打印接收到的原始数据日志
|
||||
|
log.info("==== 收到顺丰状态推送 ===="); |
||||
|
log.info("Request ID: {}", request.getRequestId()); |
||||
|
log.info("Timestamp: {}", request.getTimestamp()); |
||||
|
|
||||
|
if (request.getOrderState() != null) { |
||||
|
request.getOrderState().forEach(state -> { |
||||
|
log.info("订单号: {}, 运单号: {}, 状态码: {}, 描述: {}", |
||||
|
state.getOrderNo(), |
||||
|
state.getWaybillNo(), |
||||
|
state.getOrderStateCode(), |
||||
|
state.getOrderStateDesc()); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// 2. 根据文档 2.7 节,返回成功响应
|
||||
|
return SfPushResponse.ok(); |
||||
|
} |
||||
|
|
||||
|
|
||||
|
/** |
||||
|
* 接收顺丰路由推送 |
||||
|
* 对应文档 2.5 节 JSON 示例 |
||||
|
*/ |
||||
|
@PostMapping("/pushRoute") |
||||
|
public SfRouteResponse receiveRoute(@RequestBody SfRoutePushRequest request) { |
||||
|
log.info(">>>> 收到顺丰路由信息推送 <<<<"); |
||||
|
|
||||
|
if (request.getBody() != null && request.getBody().getWaybillRoute() != null) { |
||||
|
for (WaybillRoute route : request.getBody().getWaybillRoute()) { |
||||
|
log.info("运单号: {}, 订单号: {}, 时间: {}, 状态: {}, 备注: {}", |
||||
|
route.getMailno(), |
||||
|
route.getOrderid(), |
||||
|
route.getAcceptTime(), |
||||
|
route.getOpCode(), |
||||
|
route.getRemark()); |
||||
|
} |
||||
|
} else { |
||||
|
log.warn("收到的路由数据内容为空"); |
||||
|
} |
||||
|
|
||||
|
// 根据文档 2.6,必须返回 0000 告知顺丰接收成功,否则顺丰会重复推送
|
||||
|
return SfRouteResponse.ok(); |
||||
|
} |
||||
|
|
||||
|
|
||||
|
@RequestMapping("/pushOrderStateRaw") |
||||
|
public String receiveRaw(@RequestBody String rawBody) { |
||||
|
log.info("收到原始报文: {}", rawBody); |
||||
|
// 返回 JSON 格式的成功字符串
|
||||
|
return "{\"success\":\"true\",\"code\":\"0\",\"msg\":\"\"}"; |
||||
|
} |
||||
|
|
||||
|
@Autowired |
||||
|
private ObjectMapper objectMapper; |
||||
|
|
||||
|
/** |
||||
|
* 接收顺丰运费推送 |
||||
|
* 报文类型: application/x-www-form-urlencoded |
||||
|
*/ |
||||
|
@PostMapping(value = "/pushFee", consumes = "application/x-www-form-urlencoded") |
||||
|
public SfFeeResponse receiveFee( |
||||
|
@RequestParam("content") String content, |
||||
|
@RequestParam(value = "sign", required = false) String sign) { |
||||
|
|
||||
|
log.info(">>>> 收到顺丰运费推送 <<<<"); |
||||
|
log.info("签名(sign): {}", sign); |
||||
|
log.info("原始内容(content): {}", content); |
||||
|
|
||||
|
try { |
||||
|
// 将 content 字符串解析为 Java 对象
|
||||
|
SfFeeContent feeData = objectMapper.readValue(content, SfFeeContent.class); |
||||
|
|
||||
|
log.info("解析成功 - 运单号: {}, 订单号: {}, 计费重量: {}", |
||||
|
feeData.getWaybillNo(), |
||||
|
feeData.getOrderNo(), |
||||
|
feeData.getMeterageWeightQty()); |
||||
|
|
||||
|
if (feeData.getFeeList() != null) { |
||||
|
feeData.getFeeList().forEach(fee -> { |
||||
|
log.info("费用项 - 类型: {}, 金额: {}", fee.getFeeTypeCode(), fee.getFeeAmt()); |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
// 返回成功响应 (code 必须为 200)
|
||||
|
return SfFeeResponse.ok("your_partner_code"); |
||||
|
|
||||
|
} catch (Exception e) { |
||||
|
log.error("解析顺丰运费推送失败", e); |
||||
|
SfFeeResponse error = new SfFeeResponse(); |
||||
|
error.setCode(400); |
||||
|
error.setMessage("解析异常"); |
||||
|
return error; |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
@PostMapping("/pushElectronicReceipt") |
||||
|
public SfPicturePushResponse receiveReceipt(@RequestBody SfPicturePushRequest request) { |
||||
|
log.info(">>>> 收到顺丰电子回单(IN149)推送, 运单号: {} <<<<", request.getWaybillNo()); |
||||
|
|
||||
|
try { |
||||
|
// 步骤 1:解密最外层的 content 得到内部 JSON 字符串
|
||||
|
byte[] firstLevelDecrypted = SfDecryptUtil.decrypt(request.getContent(), SF_CHECKWORD); |
||||
|
String innerJsonStr = new String(firstLevelDecrypted, "UTF-8"); |
||||
|
log.info("第一层解密成功"); |
||||
|
|
||||
|
// 步骤 2:解析内部 JSON 获取文件密文
|
||||
|
SfInnerContent innerContent = objectMapper.readValue(innerJsonStr, SfInnerContent.class); |
||||
|
String encryptedFileBase64 = innerContent.getContent(); |
||||
|
|
||||
|
// 步骤 3:对文件密文进行第二层 AES 解密 (根据文档示例,文件内容也是加密的)
|
||||
|
// 先 Base64 解码,再 AES 解密
|
||||
|
byte[] pdfBytes = SfDecryptUtil.decrypt(encryptedFileBase64, SF_CHECKWORD); |
||||
|
log.info("第二层解密(文件流)成功,大小: {} bytes", pdfBytes.length); |
||||
|
|
||||
|
// 步骤 4:保存为 PDF 文件到当前目录
|
||||
|
String fileName = request.getWaybillNo() + "_receipt.pdf"; |
||||
|
Path path = Paths.get(System.getProperty("user.dir"), fileName); |
||||
|
|
||||
|
try (FileOutputStream fos = new FileOutputStream(path.toFile())) { |
||||
|
fos.write(pdfBytes); |
||||
|
fos.flush(); |
||||
|
} |
||||
|
|
||||
|
log.info("电子回单已保存至: {}", path.toAbsolutePath()); |
||||
|
|
||||
|
return SfPicturePushResponse.ok(); |
||||
|
|
||||
|
} catch (Exception e) { |
||||
|
log.error("处理电子回单推送失败", e); |
||||
|
SfPicturePushResponse error = new SfPicturePushResponse(); |
||||
|
error.setReturnCode("1000"); |
||||
|
error.setReturnMsg("解析保存失败: " + e.getMessage()); |
||||
|
return error; |
||||
|
} |
||||
|
} |
||||
|
} |
||||
@ -1,4 +1,4 @@ |
|||||
package com.project.receive.dto; |
package com.project.receivedemo.dto; |
||||
|
|
||||
import lombok.Data; |
import lombok.Data; |
||||
|
|
||||
@ -1,4 +1,4 @@ |
|||||
package com.project.receive.dto; |
package com.project.receivedemo.dto; |
||||
|
|
||||
import lombok.Data; |
import lombok.Data; |
||||
|
|
||||
@ -1,4 +1,4 @@ |
|||||
package com.project.receive.dto; |
package com.project.receivedemo.dto; |
||||
|
|
||||
import com.fasterxml.jackson.annotation.JsonProperty; |
import com.fasterxml.jackson.annotation.JsonProperty; |
||||
import lombok.Data; |
import lombok.Data; |
||||
@ -1,4 +1,4 @@ |
|||||
package com.project.receive.dto; |
package com.project.receivedemo.dto; |
||||
|
|
||||
import lombok.Data; |
import lombok.Data; |
||||
|
|
||||
@ -1,4 +1,4 @@ |
|||||
package com.project.receive.dto; |
package com.project.receivedemo.dto; |
||||
|
|
||||
import lombok.Data; |
import lombok.Data; |
||||
|
|
||||
@ -1,4 +1,4 @@ |
|||||
package com.project.receive.dto; |
package com.project.receivedemo.dto; |
||||
|
|
||||
import lombok.Data; |
import lombok.Data; |
||||
|
|
||||
@ -1,4 +1,4 @@ |
|||||
package com.project.receive.dto; |
package com.project.receivedemo.dto; |
||||
|
|
||||
import lombok.Data; |
import lombok.Data; |
||||
|
|
||||
@ -1,4 +1,4 @@ |
|||||
package com.project.receive.dto; |
package com.project.receivedemo.dto; |
||||
|
|
||||
import com.fasterxml.jackson.annotation.JsonProperty; |
import com.fasterxml.jackson.annotation.JsonProperty; |
||||
import lombok.Data; |
import lombok.Data; |
||||
@ -1,4 +1,4 @@ |
|||||
package com.project.receive.dto; |
package com.project.receivedemo.dto; |
||||
|
|
||||
import lombok.Data; |
import lombok.Data; |
||||
|
|
||||
@ -1,4 +1,4 @@ |
|||||
package com.project.receive.dto; |
package com.project.receivedemo.dto; |
||||
|
|
||||
import lombok.Data; |
import lombok.Data; |
||||
|
|
||||
@ -1,4 +1,4 @@ |
|||||
package com.project.receive.dto; |
package com.project.receivedemo.dto; |
||||
|
|
||||
import com.fasterxml.jackson.annotation.JsonProperty; |
import com.fasterxml.jackson.annotation.JsonProperty; |
||||
import lombok.Data; |
import lombok.Data; |
||||
@ -1,4 +1,4 @@ |
|||||
package com.project.receive.dto; |
package com.project.receivedemo.dto; |
||||
|
|
||||
import com.fasterxml.jackson.annotation.JsonProperty; |
import com.fasterxml.jackson.annotation.JsonProperty; |
||||
import lombok.Data; |
import lombok.Data; |
||||
@ -1,4 +1,4 @@ |
|||||
package com.project.receive.dto; |
package com.project.receivedemo.dto; |
||||
|
|
||||
import lombok.Data; |
import lombok.Data; |
||||
|
|
||||
@ -1,4 +1,4 @@ |
|||||
package com.project.receive.utils; |
package com.project.receivedemo.utils; |
||||
|
|
||||
import javax.crypto.Cipher; |
import javax.crypto.Cipher; |
||||
import javax.crypto.spec.IvParameterSpec; |
import javax.crypto.spec.IvParameterSpec; |
||||
Loading…
Reference in new issue