43 changed files with 751 additions and 164 deletions
@ -1,6 +1,6 @@ |
|||
<?xml version="1.0" encoding="UTF-8"?> |
|||
<project version="4"> |
|||
<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> |
|||
</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.project.logistics.domain.entity.ApiRetryTaskEntity; |
|||
import com.project.logistics.domain.enums.RetryActionEnum; |
|||
import com.project.logistics.domain.enums.TaskStatusEnum; |
|||
import com.project.logistics.domain.service.base.ApiRetryTaskService; |
|||
import com.project.logistics.mapper.ApiRetryTaskMapper; |
|||
import lombok.extern.slf4j.Slf4j; |
|||
import org.springframework.stereotype.Service; |
|||
@ -1,162 +1,87 @@ |
|||
package com.project.receive.controller; |
|||
|
|||
import com.fasterxml.jackson.databind.ObjectMapper; |
|||
import com.project.receive.dto.*; |
|||
import com.project.receive.utils.SfDecryptUtil; |
|||
|
|||
import com.project.receive.domain.dto.SfRoutePushRequest; |
|||
import com.project.receive.domain.service.ReceiveService; |
|||
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; |
|||
import java.util.HashMap; |
|||
import java.util.Map; |
|||
|
|||
@RestController |
|||
@Slf4j |
|||
@RequestMapping("/api/") |
|||
@RestController |
|||
@RequestMapping("/api/sf/callback") |
|||
public class ReceiveController { |
|||
|
|||
private static final String SF_CHECKWORD = "ZXmoWOQdSd2UTBmSP6Kv3VW9Q4N5dJqz"; |
|||
@Autowired |
|||
private ReceiveService receiveService; |
|||
|
|||
/** |
|||
* 接收顺丰订单状态推送接口 |
|||
* 对应文档 2.6 节 JSON 示例 |
|||
* 1. 路由状态回调 (揽收/签收) |
|||
* 对应接口: PushOrderState |
|||
*/ |
|||
@PostMapping("/pushOrderState") |
|||
public SfPushResponse receiveOrderState(@RequestBody SfPushRequest request) { |
|||
// 1. 打印接收到的原始数据日志
|
|||
log.info("==== 收到顺丰状态推送 ===="); |
|||
log.info("Request ID: {}", request.getRequestId()); |
|||
log.info("Timestamp: {}", request.getTimestamp()); |
|||
@PostMapping("/route") |
|||
public Map<String, Object> handleRoutePush(@RequestBody SfRoutePushRequest request) { |
|||
log.info(">>> 收到顺丰路由推送,包含节点数: {}", |
|||
(request.getBody() != null && request.getBody().getWaybillRoute() != null) ? |
|||
request.getBody().getWaybillRoute().size() : 0); |
|||
|
|||
if (request.getOrderState() != null) { |
|||
request.getOrderState().forEach(state -> { |
|||
log.info("订单号: {}, 运单号: {}, 状态码: {}, 描述: {}", |
|||
state.getOrderNo(), |
|||
state.getWaybillNo(), |
|||
state.getOrderStateCode(), |
|||
state.getOrderStateDesc()); |
|||
}); |
|||
} |
|||
try { |
|||
// 业务处理逻辑
|
|||
receiveService.processRoutePush(request); |
|||
|
|||
// 2. 根据文档 2.7 节,返回成功响应
|
|||
return SfPushResponse.ok(); |
|||
// 返回顺丰要求的成功格式
|
|||
return successResponse(); |
|||
} catch (Exception e) { |
|||
log.error(">>> 路由解析处理失败", e); |
|||
return failResponse(e.getMessage()); |
|||
} |
|||
} |
|||
|
|||
|
|||
/** |
|||
* 接收顺丰路由推送 |
|||
* 对应文档 2.5 节 JSON 示例 |
|||
* 2. 费用与重量回调 |
|||
* 对应接口: EXP_RECE_WAYBILLS_FEE_PUSH |
|||
*/ |
|||
@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("收到的路由数据内容为空"); |
|||
@PostMapping("/fee") |
|||
public Map<String, Object> handleFeePush(@RequestParam("content") String content) { |
|||
log.info(">>> 收到顺丰【运费重量】推送: {}", content); |
|||
try { |
|||
receiveService.saveFeeLog(content); |
|||
return successResponse(); |
|||
} catch (Exception e) { |
|||
log.error("运费处理异常", e); |
|||
return failResponse(e.getMessage()); |
|||
} |
|||
|
|||
// 根据文档 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 |
|||
* 3. 电子回单图片回调 |
|||
* 对应接口: 图片注册及推送接口 |
|||
*/ |
|||
@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); |
|||
|
|||
@PostMapping("/pod-picture") |
|||
public Map<String, Object> handlePodPicturePush(@RequestParam("content") String content) { |
|||
log.info(">>> 收到顺丰【电子回单图片】推送 (内容较长,不完整打印)"); |
|||
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"); |
|||
|
|||
receiveService.savePodPictureLog(content); |
|||
return successResponse(); |
|||
} catch (Exception e) { |
|||
log.error("解析顺丰运费推送失败", e); |
|||
SfFeeResponse error = new SfFeeResponse(); |
|||
error.setCode(400); |
|||
error.setMessage("解析异常"); |
|||
return error; |
|||
log.error("回单图片处理异常", e); |
|||
return failResponse(e.getMessage()); |
|||
} |
|||
} |
|||
|
|||
@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(); |
|||
private Map<String, Object> successResponse() { |
|||
Map<String, Object> res = new HashMap<>(); |
|||
res.put("success", true); |
|||
res.put("errorCode", "S0000"); |
|||
return res; |
|||
} |
|||
|
|||
} catch (Exception e) { |
|||
log.error("处理电子回单推送失败", e); |
|||
SfPicturePushResponse error = new SfPicturePushResponse(); |
|||
error.setReturnCode("1000"); |
|||
error.setReturnMsg("解析保存失败: " + e.getMessage()); |
|||
return error; |
|||
} |
|||
private Map<String, Object> failResponse(String msg) { |
|||
Map<String, Object> res = new HashMap<>(); |
|||
res.put("success", false); |
|||
res.put("errorMsg", msg); |
|||
return res; |
|||
} |
|||
} |
|||
} |
|||
@ -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.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; |
|||
|
|||
@ -1,4 +1,4 @@ |
|||
package com.project.receive.dto; |
|||
package com.project.receivedemo.dto; |
|||
|
|||
import lombok.Data; |
|||
|
|||
@ -1,4 +1,4 @@ |
|||
package com.project.receive.dto; |
|||
package com.project.receivedemo.dto; |
|||
|
|||
import com.fasterxml.jackson.annotation.JsonProperty; |
|||
import lombok.Data; |
|||
@ -1,4 +1,4 @@ |
|||
package com.project.receive.dto; |
|||
package com.project.receivedemo.dto; |
|||
|
|||
import lombok.Data; |
|||
|
|||
@ -1,4 +1,4 @@ |
|||
package com.project.receive.dto; |
|||
package com.project.receivedemo.dto; |
|||
|
|||
import lombok.Data; |
|||
|
|||
@ -1,4 +1,4 @@ |
|||
package com.project.receive.dto; |
|||
package com.project.receivedemo.dto; |
|||
|
|||
import lombok.Data; |
|||
|
|||
@ -1,4 +1,4 @@ |
|||
package com.project.receive.dto; |
|||
package com.project.receivedemo.dto; |
|||
|
|||
import lombok.Data; |
|||
|
|||
@ -1,4 +1,4 @@ |
|||
package com.project.receive.dto; |
|||
package com.project.receivedemo.dto; |
|||
|
|||
import com.fasterxml.jackson.annotation.JsonProperty; |
|||
import lombok.Data; |
|||
@ -1,4 +1,4 @@ |
|||
package com.project.receive.dto; |
|||
package com.project.receivedemo.dto; |
|||
|
|||
import lombok.Data; |
|||
|
|||
@ -1,4 +1,4 @@ |
|||
package com.project.receive.dto; |
|||
package com.project.receivedemo.dto; |
|||
|
|||
import lombok.Data; |
|||
|
|||
@ -1,4 +1,4 @@ |
|||
package com.project.receive.dto; |
|||
package com.project.receivedemo.dto; |
|||
|
|||
import com.fasterxml.jackson.annotation.JsonProperty; |
|||
import lombok.Data; |
|||
@ -1,4 +1,4 @@ |
|||
package com.project.receive.dto; |
|||
package com.project.receivedemo.dto; |
|||
|
|||
import com.fasterxml.jackson.annotation.JsonProperty; |
|||
import lombok.Data; |
|||
@ -1,4 +1,4 @@ |
|||
package com.project.receive.dto; |
|||
package com.project.receivedemo.dto; |
|||
|
|||
import lombok.Data; |
|||
|
|||
@ -1,4 +1,4 @@ |
|||
package com.project.receive.utils; |
|||
package com.project.receivedemo.utils; |
|||
|
|||
import javax.crypto.Cipher; |
|||
import javax.crypto.spec.IvParameterSpec; |
|||
Loading…
Reference in new issue