commit bef831212fb00d6efefbac26bd643416281c388a
Author: luoweijian <1329394916@qq.com>
Date: Mon Mar 30 08:54:25 2026 +0800
fm
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..5ff6309
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,38 @@
+target/
+!.mvn/wrapper/maven-wrapper.jar
+!**/src/main/**/target/
+!**/src/test/**/target/
+
+### IntelliJ IDEA ###
+.idea/modules.xml
+.idea/jarRepositories.xml
+.idea/compiler.xml
+.idea/libraries/
+*.iws
+*.iml
+*.ipr
+
+### Eclipse ###
+.apt_generated
+.classpath
+.factorypath
+.project
+.settings
+.springBeans
+.sts4-cache
+
+### NetBeans ###
+/nbproject/private/
+/nbbuild/
+/dist/
+/nbdist/
+/.nb-gradle/
+build/
+!**/src/main/**/build/
+!**/src/test/**/build/
+
+### VS Code ###
+.vscode/
+
+### Mac OS ###
+.DS_Store
\ No newline at end of file
diff --git a/.idea/.gitignore b/.idea/.gitignore
new file mode 100644
index 0000000..13566b8
--- /dev/null
+++ b/.idea/.gitignore
@@ -0,0 +1,8 @@
+# Default ignored files
+/shelf/
+/workspace.xml
+# Editor-based HTTP Client requests
+/httpRequests/
+# Datasource local storage ignored files
+/dataSources/
+/dataSources.local.xml
diff --git a/.idea/encodings.xml b/.idea/encodings.xml
new file mode 100644
index 0000000..aa00ffa
--- /dev/null
+++ b/.idea/encodings.xml
@@ -0,0 +1,7 @@
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/misc.xml b/.idea/misc.xml
new file mode 100644
index 0000000..82dbec8
--- /dev/null
+++ b/.idea/misc.xml
@@ -0,0 +1,14 @@
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/uiDesigner.xml b/.idea/uiDesigner.xml
new file mode 100644
index 0000000..2b63946
--- /dev/null
+++ b/.idea/uiDesigner.xml
@@ -0,0 +1,124 @@
+
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
+ -
+
+
+ -
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/vcs.xml b/.idea/vcs.xml
new file mode 100644
index 0000000..94a25f7
--- /dev/null
+++ b/.idea/vcs.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/pom.xml b/pom.xml
new file mode 100644
index 0000000..2d93234
--- /dev/null
+++ b/pom.xml
@@ -0,0 +1,190 @@
+
+ 4.0.0
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 3.2.3
+
+
+ com.project
+ auto-logistics
+ 1.0-SNAPSHOT
+ jar
+
+ auto-logistics
+ http://maven.apache.org
+
+
+ UTF-8
+ 4.3.0
+
+
+
+
+ javax.xml.bind
+ jaxb-api
+ 2.3.1
+
+
+ org.glassfish.jaxb
+ jaxb-runtime
+ 2.3.1
+
+
+ javax.activation
+ javax.activation-api
+ 1.2.0
+
+
+ cn.hutool
+ hutool-all
+ 5.8.3
+
+
+ org.springframework.boot
+ spring-boot-starter-data-jpa
+
+
+
+ com.microsoft.sqlserver
+ mssql-jdbc
+ 12.4.2.jre11
+
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-data-redis
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ org.springframework.boot
+ spring-boot-configuration-processor
+ true
+
+
+
+
+ com.baomidou
+ mybatis-plus-spring-boot3-starter
+ 3.5.6
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ com.mysql
+ mysql-connector-j
+ runtime
+
+
+
+
+ cn.hutool
+ hutool-all
+ 5.8.3
+
+
+ com.alibaba
+ fastjson
+ 2.0.47
+
+
+ com.google.guava
+ guava
+ 31.1-jre
+
+
+ org.apache.commons
+ commons-lang3
+
+
+
+
+ com.github.lookfirst
+ sardine
+ 5.10
+
+
+ com.alibaba
+ easyexcel
+ 3.3.4
+
+
+ com.jayway.jsonpath
+ json-path
+
+
+
+ org.projectlombok
+ lombok
+ true
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
+ repackage
+
+
+
+
+
+
+ org.projectlombok
+ lombok
+
+
+
+
+
+
+
+
diff --git a/src/main/java/com/project/AutoLogisticsApplication.java b/src/main/java/com/project/AutoLogisticsApplication.java
new file mode 100644
index 0000000..e31d928
--- /dev/null
+++ b/src/main/java/com/project/AutoLogisticsApplication.java
@@ -0,0 +1,21 @@
+package com.project;
+
+import org.mybatis.spring.annotation.MapperScan;
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.boot.autoconfigure.domain.EntityScan;
+import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
+import org.springframework.scheduling.annotation.EnableScheduling;
+
+@SpringBootApplication
+@EnableScheduling
+@MapperScan({
+ "com.project.*.mapper"})
+@EntityScan("com.project.*.domain.entity")
+@EnableJpaRepositories("com.project.logistics.repository") // 只扫 JPA Repository
+
+public class AutoLogisticsApplication {
+ public static void main( String[] args ) {
+ SpringApplication.run(AutoLogisticsApplication.class , args);
+ }
+}
diff --git a/src/main/java/com/project/base/config/CorsConfig.java b/src/main/java/com/project/base/config/CorsConfig.java
new file mode 100644
index 0000000..2bf64b6
--- /dev/null
+++ b/src/main/java/com/project/base/config/CorsConfig.java
@@ -0,0 +1,21 @@
+package com.project.base.config;
+
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+/**
+ * Cors解决跨域问题
+ */
+@Configuration
+public class CorsConfig implements WebMvcConfigurer {
+ @Override
+ public void addCorsMappings(CorsRegistry registry) {
+ registry.addMapping("/**")
+ .allowedOrigins("*")
+ .allowedMethods("GET", "POST", "Options", "DELETE", "PUT")
+ .allowedHeaders("*")
+ .allowCredentials(false)
+ .maxAge(16800);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/base/config/CustomIdGenerator.java b/src/main/java/com/project/base/config/CustomIdGenerator.java
new file mode 100644
index 0000000..b986645
--- /dev/null
+++ b/src/main/java/com/project/base/config/CustomIdGenerator.java
@@ -0,0 +1,39 @@
+package com.project.base.config;
+
+import com.baomidou.mybatisplus.core.incrementer.IdentifierGenerator;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Component;
+
+import java.util.concurrent.atomic.AtomicLong;
+
+/**
+ * 自定义ID生成器
+ */
+@Slf4j
+@Component
+public class CustomIdGenerator implements IdentifierGenerator {
+
+ /**
+ * workerId,机器id
+ * datacenterId,数据标识id
+ */
+ private final SnowflakeIdWorker idWorker = new SnowflakeIdWorker(0, 0);
+
+ /**
+ * AtomicLong是作用是对长整形进行原子操作。
+ * 在32位操作系统中,64位的long 和 double 变量由于会被JVM当作两个分离的32位来进行操作,所以不具有原子性。
+ * 而使用AtomicLong能让long的操作保持原子型。
+ * @param entity
+ * @return
+ */
+ @Override
+ public Long nextId(Object entity) {
+ //可以将当前传入的class全类名来作为bizKey,或者提取参数来生成bizKey进行分布式Id调用生成.
+ String bizKey = entity.getClass().getName();
+ log.debug("bizKey:{}", bizKey);
+ AtomicLong al = new AtomicLong(idWorker.nextId());
+ final long id = al.get();
+ log.debug("为{}生成主键值->:{}", bizKey, id);
+ return id;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/base/config/InsertBatchOnDuplicateKeyUpdate.java b/src/main/java/com/project/base/config/InsertBatchOnDuplicateKeyUpdate.java
new file mode 100644
index 0000000..1fac427
--- /dev/null
+++ b/src/main/java/com/project/base/config/InsertBatchOnDuplicateKeyUpdate.java
@@ -0,0 +1,72 @@
+package com.project.base.config;
+
+import com.baomidou.mybatisplus.core.injector.AbstractMethod;
+import com.baomidou.mybatisplus.core.metadata.TableFieldInfo;
+import com.baomidou.mybatisplus.core.metadata.TableInfo;
+import org.apache.ibatis.executor.keygen.NoKeyGenerator;
+import org.apache.ibatis.mapping.MappedStatement;
+import org.apache.ibatis.mapping.SqlSource;
+
+import java.util.stream.Collectors;
+
+/**
+ * 自定义选装件:MySQL 批量 Upsert
+ */
+public class InsertBatchOnDuplicateKeyUpdate extends AbstractMethod {
+
+ protected InsertBatchOnDuplicateKeyUpdate() {
+ super("batchUpsert"); // 方法名
+ }
+
+ @Override
+ public MappedStatement injectMappedStatement(Class> mapperClass, Class> modelClass, TableInfo tableInfo) {
+ final String methodName = "batchUpsert";
+
+ // 1. 准备 INSERT 字段
+ String columnScript = "(" + tableInfo.getKeyColumn() + "," +
+ tableInfo.getFieldList().stream()
+ .map(TableFieldInfo::getColumn)
+ .collect(Collectors.joining(",")) + ")";
+
+ // 2. 准备 VALUES 部分 (处理逻辑删除位)
+ String valuesScript = "(#{item." + tableInfo.getKeyProperty() + "}," +
+ tableInfo.getFieldList().stream()
+ .map(i -> {
+ // A. 如果是逻辑删除字段:处理“没传就默认 0”
+ if (i.isLogicDelete()) {
+ // 使用 IFNULL 保证如果 Java 对象里 deleted 是 null,数据库填入 0
+ return "IFNULL(#{item." + i.getProperty() + "}, 0)";
+ }
+
+ // B. 处理 TypeHandler (如之前处理的 JSON 字段)
+ if (i.getTypeHandler() != null) {
+ return "#{item." + i.getProperty() + ",typeHandler=" + i.getTypeHandler().getName() + "}";
+ }
+
+ // C. 普通字段
+ return "#{item." + i.getProperty() + "}";
+ })
+ .collect(Collectors.joining(",")) + ")";
+
+ // 3. 准备 ON DUPLICATE KEY UPDATE 部分
+ String updateScript = tableInfo.getFieldList().stream()
+ // 排除主键和创建时间
+ .filter(i -> !i.getColumn().equals("create_time"))
+ .map(i -> {
+ // 如果是逻辑删除位,在更新时强制置为 0(即复活数据)
+ // 理由:全量同步中,只要数据还在钉钉列表里,就说明该部门/人员是活跃的
+ if (i.isLogicDelete()) {
+ return i.getColumn() + " = 0";
+ }
+ // 其他字段正常更新
+ return i.getColumn() + " = VALUES(" + i.getColumn() + ")";
+ })
+ .collect(Collectors.joining(",")) + ", update_time = NOW()";
+
+ String sql = String.format("",
+ tableInfo.getTableName(), columnScript, valuesScript, updateScript);
+
+ SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, modelClass);
+ return this.addInsertMappedStatement(mapperClass, modelClass, methodName, sqlSource, new NoKeyGenerator(), null, null);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/base/config/MyMetaObjectHandler.java b/src/main/java/com/project/base/config/MyMetaObjectHandler.java
new file mode 100644
index 0000000..76a2e0f
--- /dev/null
+++ b/src/main/java/com/project/base/config/MyMetaObjectHandler.java
@@ -0,0 +1,28 @@
+package com.project.base.config;
+
+import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.ibatis.reflection.MetaObject;
+import org.springframework.stereotype.Component;
+
+import java.util.Date;
+
+@Slf4j
+@Component
+public class MyMetaObjectHandler implements MetaObjectHandler {
+
+ @Override
+ public void insertFill(MetaObject metaObject) {
+ long timeMillis = System.currentTimeMillis() / 1000 * 1000;
+ Date currentDate = new Date(timeMillis);
+ this.strictInsertFill(metaObject, "createTime", Date.class, currentDate); // 起始版本 3.3.0(推荐使用)
+ this.strictInsertFill(metaObject, "updateTime", Date.class, currentDate);
+ }
+
+ @Override
+ public void updateFill(MetaObject metaObject) {
+ long timeMillis = System.currentTimeMillis() / 1000 * 1000;
+ Date currentDate = new Date(timeMillis);
+ this.strictUpdateFill(metaObject, "updateTime", Date.class, currentDate); // 起始版本 3.3.0(推荐使用)
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/base/config/MybatisPlusConfig.java b/src/main/java/com/project/base/config/MybatisPlusConfig.java
new file mode 100644
index 0000000..c0b5d9e
--- /dev/null
+++ b/src/main/java/com/project/base/config/MybatisPlusConfig.java
@@ -0,0 +1,31 @@
+package com.project.base.config;
+
+import com.baomidou.mybatisplus.annotation.DbType;
+import com.baomidou.mybatisplus.core.injector.AbstractMethod;
+import com.baomidou.mybatisplus.core.injector.DefaultSqlInjector;
+import com.baomidou.mybatisplus.core.metadata.TableInfo;
+import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
+import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+import java.util.List;
+
+@Configuration
+public class MybatisPlusConfig extends DefaultSqlInjector {
+
+ @Override
+ public List getMethodList(Class> mapperClass, TableInfo tableInfo) {
+ List methodList = super.getMethodList(mapperClass, tableInfo);
+ methodList.add(new InsertBatchOnDuplicateKeyUpdate()); // 确保这里添加了你的自定义方法
+ return methodList;
+ }
+ @Bean
+ public MybatisPlusInterceptor mybatisPlusInterceptor() {
+ MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
+ // 向拦截器链中添加分页拦截器
+ // DbType.MYSQL 必须设置,否则插件可能无法正确分页
+ interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
+ return interceptor;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/base/config/ScheduledTaskProperties.java b/src/main/java/com/project/base/config/ScheduledTaskProperties.java
new file mode 100644
index 0000000..de823b5
--- /dev/null
+++ b/src/main/java/com/project/base/config/ScheduledTaskProperties.java
@@ -0,0 +1,14 @@
+package com.project.base.config;
+
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "scheduled-task")
+public class ScheduledTaskProperties {
+ private String owner = "local";
+
+}
diff --git a/src/main/java/com/project/base/config/SnowflakeIdWorker.java b/src/main/java/com/project/base/config/SnowflakeIdWorker.java
new file mode 100644
index 0000000..533955c
--- /dev/null
+++ b/src/main/java/com/project/base/config/SnowflakeIdWorker.java
@@ -0,0 +1,185 @@
+package com.project.base.config;
+
+/**
+ * Twitter_Snowflake
+ * SnowFlake的结构如下(每部分用-分开):
+ * 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000
+ * 1位标识,由于long基本类型在Java中是带符号的,最高位是符号位,正数是0,负数是1,所以id一般是正数,最高位是0
+ * 41位时间截(毫秒级),注意,41位时间截不是存储当前时间的时间截,而是存储时间截的差值(当前时间截 - 开始时间截)
+ * 得到的值),这里的的开始时间截,一般是我们的id生成器开始使用的时间,由我们程序来指定的(如下下面程序IdWorker类的startTime属性)。41位的时间截,可以使用69年,年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69
+ * 10位的数据机器位,可以部署在1024个节点,包括5位datacenterId和5位workerId
+ * 12位序列,毫秒内的计数,12位的计数顺序号支持每个节点每毫秒(同一机器,同一时间截)产生4096个ID序号
+ * 加起来刚好64位,为一个Long型。
+ * SnowFlake的优点是,整体上按照时间自增排序,并且整个分布式系统内不会产生ID碰撞(由数据中心ID和机器ID作区分),并且效率较高,经测试,SnowFlake每秒能够产生26万ID左右。
+ */
+public class SnowflakeIdWorker {
+
+ // ==============================Fields===========================================
+ /**
+ * 1766395305
+ * 开始时间截 2021-01-01 00:00:00
+ * https://tool.lu/timestamp/
+ */
+ private static final long twepoch = 1609430400000L;
+
+ /**
+ * 机器id所占的位数
+ */
+ private static final long workerIdBits = 5L;
+
+ /**
+ * 数据标识id所占的位数
+ */
+ private static final long datacenterIdBits = 5L;
+
+ /**
+ * 支持的最大机器id,结果是31 (这个移位算法可以很快的计算出几位二进制数所能表示的最大十进制数)
+ */
+ private static final long maxWorkerId = -1L ^ (-1L << workerIdBits);
+
+ /**
+ * 支持的最大数据标识id,结果是31
+ */
+ private static final long maxDatacenterId = -1L ^ (-1L << datacenterIdBits);
+
+ /**
+ * 序列在id中占的位数
+ */
+ private static final long sequenceBits = 12L;
+
+ /**
+ * 机器ID向左移12位
+ */
+ private static final long workerIdShift = sequenceBits;
+
+ /**
+ * 数据标识id向左移17位(12+5)
+ */
+ private static final long datacenterIdShift = sequenceBits + workerIdBits;
+
+ /**
+ * 时间截向左移22位(5+5+12)
+ */
+ private static final long timestampLeftShift = sequenceBits + workerIdBits + datacenterIdBits;
+
+ /**
+ * 生成序列的掩码,这里为4095 (0b111111111111=0xfff=4095)
+ */
+ private static final long sequenceMask = -1L ^ (-1L << sequenceBits);
+
+ /**
+ * 工作机器ID(0~31)
+ */
+ private long workerId;
+
+ /**
+ * 数据中心ID(0~31)
+ */
+ private long datacenterId;
+
+ /**
+ * 毫秒内序列(0~4095)
+ */
+ private long sequence = 0L;
+
+ /**
+ * 上次生成ID的时间截
+ */
+ private long lastTimestamp = -1L;
+
+ //==============================Constructors=====================================
+
+ /**
+ * 构造函数
+ *
+ * @param workerId 工作ID (0~31)
+ * @param datacenterId 数据中心ID (0~31)
+ */
+ public SnowflakeIdWorker(long workerId, long datacenterId) {
+ if (workerId > maxWorkerId || workerId < 0) {
+ throw new IllegalArgumentException(String.format("worker Id can't be greater than %d or less than 0", maxWorkerId));
+ }
+ if (datacenterId > maxDatacenterId || datacenterId < 0) {
+ throw new IllegalArgumentException(String.format("datacenter Id can't be greater than %d or less than 0", maxDatacenterId));
+ }
+ this.workerId = workerId;
+ this.datacenterId = datacenterId;
+ }
+
+ // ==============================Methods==========================================
+
+ /**
+ * 获得下一个ID (该方法是线程安全的)
+ *
+ * @return SnowflakeId
+ */
+ public synchronized long nextId() {
+ long timestamp = timeGen();
+
+ //如果当前时间小于上一次ID生成的时间戳,说明系统时钟回退过这个时候应当抛出异常
+ if (timestamp < lastTimestamp) {
+ throw new RuntimeException(
+ String.format("Clock moved backwards. Refusing to generate id for %d milliseconds", lastTimestamp - timestamp));
+ }
+
+ //如果是同一时间生成的,则进行毫秒内序列
+ if (lastTimestamp == timestamp) {
+ sequence = (sequence + 1) & sequenceMask;
+ //毫秒内序列溢出
+ if (sequence == 0) {
+ //阻塞到下一个毫秒,获得新的时间戳
+ timestamp = tilNextMillis(lastTimestamp);
+ }
+ }
+ //时间戳改变,毫秒内序列重置
+ else {
+ sequence = 0L;
+ }
+
+ //上次生成ID的时间截
+ lastTimestamp = timestamp;
+
+ //移位并通过或运算拼到一起组成64位的ID
+ return ((timestamp - twepoch) << timestampLeftShift) //
+ | (datacenterId << datacenterIdShift) //
+ | (workerId << workerIdShift) //
+ | sequence;
+ }
+
+ /**
+ * 阻塞到下一个毫秒,直到获得新的时间戳
+ *
+ * @param lastTimestamp 上次生成ID的时间截
+ * @return 当前时间戳
+ */
+ protected long tilNextMillis(long lastTimestamp) {
+ long timestamp = timeGen();
+ while (timestamp <= lastTimestamp) {
+ timestamp = timeGen();
+ }
+ return timestamp;
+ }
+
+ /**
+ * 返回以毫秒为单位的当前时间
+ *
+ * @return 当前时间(毫秒)
+ */
+ protected long timeGen() {
+ return System.currentTimeMillis();
+ }
+
+ //==============================Test=============================================
+
+ /**
+ * 测试
+ */
+ public static void main(String[] args) {
+ SnowflakeIdWorker idWorker = new SnowflakeIdWorker(0, 0);
+ for (int i = 0; i < 1000; i++) {
+ long id = idWorker.nextId();
+ System.out.println(Long.toBinaryString(id));
+ System.out.println(id);
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/base/domain/advice/GlobalExceptionHandlerAdvice.java b/src/main/java/com/project/base/domain/advice/GlobalExceptionHandlerAdvice.java
new file mode 100644
index 0000000..052cae1
--- /dev/null
+++ b/src/main/java/com/project/base/domain/advice/GlobalExceptionHandlerAdvice.java
@@ -0,0 +1,111 @@
+package com.project.base.domain.advice;
+
+
+import com.project.base.domain.exception.BusinessErrorException;
+import com.project.base.domain.exception.MissingParameterException;
+import com.project.base.domain.exception.PermissionErrorException;
+import com.project.base.domain.exception.ResourceNotExistException;
+import com.project.base.domain.result.Result;
+import com.project.base.domain.result.ResultCodeEnum;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.HttpStatus;
+import org.springframework.validation.BindException;
+import org.springframework.web.HttpRequestMethodNotSupportedException;
+import org.springframework.web.bind.annotation.ControllerAdvice;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.ResponseBody;
+import org.springframework.web.bind.annotation.ResponseStatus;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
+import org.springframework.web.multipart.MaxUploadSizeExceededException;
+
+@Slf4j
+@ControllerAdvice
+public class GlobalExceptionHandlerAdvice {
+ /**-------- 其他错误 --------**/
+
+ @ExceptionHandler(Exception.class)
+ @ResponseBody
+ @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+ public Result error(Exception e) {
+ e.printStackTrace();
+ log.error("全局异常捕获:" + e);
+ return Result.fail();
+ }
+
+
+ /**-------- 前端传参类型转换错误 --------**/
+ @ExceptionHandler(BindException.class)
+ @ResponseBody
+ @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+ public Result error(BindException e) {
+ e.printStackTrace();
+ log.error("全局异常捕获:" + e);
+ return Result.fail(ResultCodeEnum.MISSING_PARAMETER);
+ }
+
+ @ExceptionHandler(MethodArgumentTypeMismatchException.class)
+ @ResponseBody
+ @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
+ public Result error(MethodArgumentTypeMismatchException e) {
+ e.printStackTrace();
+ log.error("全局异常捕获:" + e);
+ return Result.fail(ResultCodeEnum.MISSING_PARAMETER);
+ }
+ /**-------- 业务错误 --------**/
+ @ExceptionHandler(BusinessErrorException.class)
+ @ResponseBody
+ @ResponseStatus(HttpStatus.OK)
+ public Result error(BusinessErrorException e) {
+ e.printStackTrace();
+ log.error("全局异常捕获:" + e);
+ return Result.fail(ResultCodeEnum.BUSINESS_ERROR , e.getMessage());
+ }
+
+ /**-------- 权限错误 --------**/
+ @ExceptionHandler(PermissionErrorException.class)
+ @ResponseBody
+ @ResponseStatus(HttpStatus.OK)
+ public Result error(PermissionErrorException e) {
+ e.printStackTrace();
+ log.error("全局异常捕获:" + e);
+ return Result.fail(ResultCodeEnum.PERMISSION_DENIED , e.getMessage());
+ }
+
+ /**-------- 缺少必须参数 --------**/
+ @ExceptionHandler(MissingParameterException.class)
+ @ResponseBody
+ @ResponseStatus(HttpStatus.OK)
+ public Result error(MissingParameterException e) {
+ e.printStackTrace();
+ log.error("全局异常捕获:" + e);
+ return Result.fail(ResultCodeEnum.getResultCodeEnumByCode(e.getCode()) , e.getMessage());
+ }
+
+ @ExceptionHandler(HttpRequestMethodNotSupportedException.class)
+ @ResponseBody
+ @ResponseStatus(HttpStatus.OK)
+ public Result error(HttpRequestMethodNotSupportedException e) {
+ e.printStackTrace();
+ log.error("全局异常捕获:" + e);
+ return Result.fail(ResultCodeEnum.REQUEST_METHOD_NOT_SUPPORTED);
+ }
+
+
+ @ExceptionHandler(ResourceNotExistException.class)
+ @ResponseBody
+ @ResponseStatus(HttpStatus.OK)
+ public Result error(ResourceNotExistException e) {
+ log.error("出错了" , e);
+ log.error("全局异常捕获:" + e);
+ return Result.fail(ResultCodeEnum.getResultCodeEnumByCode(e.getCode()) , e.getMessage());
+ }
+
+ @ExceptionHandler(MaxUploadSizeExceededException.class)
+ @ResponseBody
+ @ResponseStatus(HttpStatus.OK)
+ public Result error(MaxUploadSizeExceededException e) {
+ e.printStackTrace();
+ log.error("全局异常捕获:" + e);
+ return Result.fail(ResultCodeEnum.MISSING_PARAMETER , e.getMessage());
+ }
+}
diff --git a/src/main/java/com/project/base/domain/dto/BaseDTO.java b/src/main/java/com/project/base/domain/dto/BaseDTO.java
new file mode 100644
index 0000000..904fb1e
--- /dev/null
+++ b/src/main/java/com/project/base/domain/dto/BaseDTO.java
@@ -0,0 +1,33 @@
+package com.project.base.domain.dto;
+
+
+import cn.hutool.core.bean.BeanUtil;
+import com.project.base.domain.enums.StatusEnum;
+import lombok.Data;
+
+import java.util.Date;
+import java.util.function.Supplier;
+
+@Data
+public class BaseDTO {
+
+
+ private String creatorId;
+
+ private Date createTime;
+
+ private Long updaterId;
+
+ private Date updateTime;
+
+ private Boolean deleted = StatusEnum.Normal.getValue();
+
+
+ public T toEntity(Supplier supplier) {
+ T entity = supplier.get();
+ BeanUtil.copyProperties(this, entity);
+ return entity;
+ }
+
+
+}
diff --git a/src/main/java/com/project/base/domain/entity/BaseEntity.java b/src/main/java/com/project/base/domain/entity/BaseEntity.java
new file mode 100644
index 0000000..06f10f6
--- /dev/null
+++ b/src/main/java/com/project/base/domain/entity/BaseEntity.java
@@ -0,0 +1,55 @@
+package com.project.base.domain.entity;
+
+import cn.hutool.core.bean.BeanUtil;
+import com.baomidou.mybatisplus.annotation.*;
+import com.project.base.domain.enums.StatusEnum;
+import jakarta.persistence.Column;
+import jakarta.persistence.MappedSuperclass;
+import lombok.Data;
+import org.hibernate.annotations.Comment;
+
+import java.io.Serializable;
+import java.util.Date;
+import java.util.function.Supplier;
+
+@MappedSuperclass
+@Data
+
+public class BaseEntity implements Serializable {
+
+
+ @TableField(value = "creator_id" , fill = FieldFill.INSERT)
+ @Column(name = "creator_id" , columnDefinition="varchar(50) comment '创建用户id'")
+ private String creatorId;
+
+ @TableField(value = "create_time" , fill = FieldFill.INSERT)
+ @Comment("创建时间")
+ @Column(name = "create_time")
+ private Date createTime;
+
+ @TableField(value = "updater_id" , fill = FieldFill.INSERT_UPDATE)
+ @Column(name = "updater_id", columnDefinition="bigint(20) comment '更新用户id'")
+ private Long updaterId;
+
+ @TableField(value = "update_time" , fill = FieldFill.INSERT_UPDATE)
+ @Comment("更新时间")
+ @Column(name = "update_time")
+ private Date updateTime;
+
+ /**
+ * deleted字段请在数据库中 设置为tinyInt 并且非null 默认值为0
+ */
+ @TableField("deleted")
+ @TableLogic
+ @Comment("逻辑删除位")
+ @Column(name = "deleted")
+ private Boolean deleted = StatusEnum.Normal.getValue();
+
+
+
+ public T toDTO(Supplier supplier) {
+ T dto = supplier.get();
+ BeanUtil.copyProperties(this, dto);
+ return dto;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/base/domain/enums/HasValueEnum.java b/src/main/java/com/project/base/domain/enums/HasValueEnum.java
new file mode 100644
index 0000000..db82b89
--- /dev/null
+++ b/src/main/java/com/project/base/domain/enums/HasValueEnum.java
@@ -0,0 +1,24 @@
+package com.project.base.domain.enums;
+
+
+public interface HasValueEnum {
+
+ /**
+ * 获取枚举名,将会由枚举抽象类默认实现
+ *
+ * @see Enum
+ */
+ String name();
+
+ /**
+ * 获取枚举值
+ */
+ T getValue();
+
+ /**
+ * 枚举名比较时,是否需要区分大小写,默认为需要区分
+ */
+ default boolean caseCompare() {
+ return true;
+ }
+}
diff --git a/src/main/java/com/project/base/domain/enums/StatusEnum.java b/src/main/java/com/project/base/domain/enums/StatusEnum.java
new file mode 100644
index 0000000..33cfab2
--- /dev/null
+++ b/src/main/java/com/project/base/domain/enums/StatusEnum.java
@@ -0,0 +1,17 @@
+package com.project.base.domain.enums;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum StatusEnum implements HasValueEnum{
+
+ Normal(Boolean.FALSE), Delete(Boolean.TRUE);
+
+
+ private final Boolean value;
+
+
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/base/domain/exception/BusinessErrorException.java b/src/main/java/com/project/base/domain/exception/BusinessErrorException.java
new file mode 100644
index 0000000..4eff757
--- /dev/null
+++ b/src/main/java/com/project/base/domain/exception/BusinessErrorException.java
@@ -0,0 +1,21 @@
+package com.project.base.domain.exception;
+
+import com.project.base.domain.result.ResultCodeEnum;
+import lombok.Data;
+
+@Data
+public class BusinessErrorException extends RuntimeException{
+ private Integer code;
+
+ public BusinessErrorException(String msg) {
+ super(msg);
+ this.code = ResultCodeEnum.BUSINESS_ERROR.getCode();
+ }
+
+
+ public BusinessErrorException(Integer code , String msg) {
+ super(msg);
+ this.code = code;
+ }
+
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/base/domain/exception/MissingParameterException.java b/src/main/java/com/project/base/domain/exception/MissingParameterException.java
new file mode 100644
index 0000000..cd429b9
--- /dev/null
+++ b/src/main/java/com/project/base/domain/exception/MissingParameterException.java
@@ -0,0 +1,21 @@
+package com.project.base.domain.exception;
+
+import com.project.base.domain.result.ResultCodeEnum;
+import lombok.Data;
+
+
+@Data
+public class MissingParameterException extends RuntimeException {
+ private final Integer code;
+
+ public MissingParameterException(String msg) {
+ super(msg);
+ this.code = ResultCodeEnum.MISSING_PARAMETER.getCode();
+ }
+
+
+ public MissingParameterException(Integer code , String msg) {
+ super(msg);
+ this.code = code;
+ }
+}
diff --git a/src/main/java/com/project/base/domain/exception/PermissionErrorException.java b/src/main/java/com/project/base/domain/exception/PermissionErrorException.java
new file mode 100644
index 0000000..9c2908f
--- /dev/null
+++ b/src/main/java/com/project/base/domain/exception/PermissionErrorException.java
@@ -0,0 +1,25 @@
+package com.project.base.domain.exception;
+
+import com.project.base.domain.result.ResultCodeEnum;
+import lombok.Data;
+
+@Data
+public class PermissionErrorException extends RuntimeException {
+ private Integer code;
+
+ public PermissionErrorException() {
+ super(ResultCodeEnum.PERMISSION_DENIED.getMessage());
+ this.code = ResultCodeEnum.PERMISSION_DENIED.getCode();
+ }
+
+ public PermissionErrorException(String msg) {
+ super(msg);
+ this.code = ResultCodeEnum.PERMISSION_DENIED.getCode();
+ }
+
+
+ public PermissionErrorException(Integer code , String msg) {
+ super(msg);
+ this.code = code;
+ }
+}
diff --git a/src/main/java/com/project/base/domain/exception/ResourceNotExistException.java b/src/main/java/com/project/base/domain/exception/ResourceNotExistException.java
new file mode 100644
index 0000000..8e9773e
--- /dev/null
+++ b/src/main/java/com/project/base/domain/exception/ResourceNotExistException.java
@@ -0,0 +1,21 @@
+package com.project.base.domain.exception;
+
+
+import com.project.base.domain.result.ResultCodeEnum;
+import lombok.Data;
+
+@Data
+public class ResourceNotExistException extends RuntimeException {
+ private final Integer code;
+
+ public ResourceNotExistException(String msg) {
+ super(msg);
+ this.code = ResultCodeEnum.RESOURCE_NOT_EXIST.getCode();
+ }
+
+ public ResourceNotExistException(Integer code , String msg) {
+ super(msg);
+ this.code = code;
+ }
+
+}
diff --git a/src/main/java/com/project/base/domain/param/BaseParam.java b/src/main/java/com/project/base/domain/param/BaseParam.java
new file mode 100644
index 0000000..72b42d3
--- /dev/null
+++ b/src/main/java/com/project/base/domain/param/BaseParam.java
@@ -0,0 +1,10 @@
+package com.project.base.domain.param;
+
+
+import lombok.Data;
+
+@Data
+public class BaseParam {
+ private Integer current = 1;
+ private Integer size = 10;
+}
diff --git a/src/main/java/com/project/base/domain/result/PageResult.java b/src/main/java/com/project/base/domain/result/PageResult.java
new file mode 100644
index 0000000..2782f9c
--- /dev/null
+++ b/src/main/java/com/project/base/domain/result/PageResult.java
@@ -0,0 +1,24 @@
+package com.project.base.domain.result;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import lombok.Data;
+
+import java.io.Serializable;
+import java.util.List;
+
+@Data
+public class PageResult implements Serializable {
+ private List content; // 数据列表
+ private long total; // 总条数
+ private long size; // 每页条数
+ private long current; // 当前页码 (从1开始)
+ private long pages; // 总页数
+
+ public PageResult(IPage mpPage) {
+ this.content = mpPage.getRecords();
+ this.total = mpPage.getTotal();
+ this.size = mpPage.getSize();
+ this.current = mpPage.getCurrent();
+ this.pages = mpPage.getPages();
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/base/domain/result/Result.java b/src/main/java/com/project/base/domain/result/Result.java
new file mode 100644
index 0000000..728b441
--- /dev/null
+++ b/src/main/java/com/project/base/domain/result/Result.java
@@ -0,0 +1,70 @@
+package com.project.base.domain.result;
+
+import com.baomidou.mybatisplus.core.metadata.IPage;
+import lombok.Getter;
+import lombok.Setter;
+
+import java.io.Serializable;
+
+
+
+
+@Getter
+@Setter
+public class Result implements Serializable {
+ private Integer code = 0;;
+
+ private Boolean success = true;
+
+ private String message;
+
+
+ private T data;
+
+
+
+ public static Result success(T data) {
+ Result result = new Result();
+ result.setCode(ResultCodeEnum.SUCCESS.getCode());
+ result.setMessage("success");
+ result.setData(data);
+ return result;
+ }
+
+ public static Result success(T data, String message) {
+ Result result = new Result();
+ result.setCode(ResultCodeEnum.SUCCESS.getCode());
+ result.setMessage(message);
+ result.setData(data);
+ return result;
+ }
+
+ public static Result fail() {
+ Result res = new Result();
+ res.setCode(ResultCodeEnum.UNKNOWN_ERROR.getCode());
+ res.setMessage(ResultCodeEnum.UNKNOWN_ERROR.getMessage());
+ res.setSuccess(false);
+ return res;
+ }
+
+ public static Result fail(ResultCodeEnum resultCodeEnum) {
+ Result res = new Result();
+ res.setCode(resultCodeEnum.getCode());
+ res.setMessage(resultCodeEnum.getMessage());
+ res.setSuccess(false);
+ return res;
+ }
+
+ public static Result fail(ResultCodeEnum resultCodeEnum , String msg) {
+ Result res = new Result();
+ res.setCode(resultCodeEnum.getCode());
+ res.setMessage(msg);
+ res.setSuccess(false);
+ return res;
+ }
+
+ public static Result> page(IPage mpPage) {
+ return success(new PageResult<>(mpPage));
+ }
+
+}
diff --git a/src/main/java/com/project/base/domain/result/ResultCodeEnum.java b/src/main/java/com/project/base/domain/result/ResultCodeEnum.java
new file mode 100644
index 0000000..94f46bf
--- /dev/null
+++ b/src/main/java/com/project/base/domain/result/ResultCodeEnum.java
@@ -0,0 +1,43 @@
+package com.project.base.domain.result;
+
+import lombok.Getter;
+import lombok.RequiredArgsConstructor;
+
+@Getter
+@RequiredArgsConstructor
+public enum ResultCodeEnum {
+ SUCCESS(true , 0 , "成功"),
+ MISSING_PARAMETER(false , 40001 , "请求参数缺失或格式错误"),
+ INVALID_REQUEST_BODY(false , 40002 , "无效的请求体"),
+ SIGNATURE_VERIFICATION_FAIL(false , 40101 , "API签名验证失败"),
+ TIMESTAMP_INVALID(false ,40102 , "请求时间戳已过期") ,
+ USER_NOT_FIND(false , 40103 , "用户身份信息缺失") ,
+ PERMISSION_DENIED(false , 40301 , "操作权限不足"),
+ RESOURCE_NOT_EXIST(false , 40401 , "请求的资源不存在") ,
+ REQUEST_METHOD_NOT_SUPPORTED(false , 40501 , "错误的请求方式") ,
+
+ INTERNAL_SYSTEM_ERROR(false , 50001 , "系统内部错误,请稍后重试"),
+ EXTERNAL_CALL_ERROR(false , 50002 , "外部服务调用失败"),
+ BUSINESS_ERROR(false , 60001 , "业务错误"),
+ UNKNOWN_ERROR(false , -1 , "未知错误"),
+
+ ;
+ private Boolean success;
+ private Integer code;
+ private String message;
+ ResultCodeEnum(boolean success, Integer code, String message) {
+ this.success = success;
+ this.code = code;
+ this.message = message;
+ }
+
+ public static ResultCodeEnum getResultCodeEnumByCode(Integer code) {
+ for (ResultCodeEnum value : ResultCodeEnum.values()) {
+ if (value.getCode().equals(code)) {
+ return value;
+ }
+ }
+ return null;
+ }
+
+}
diff --git a/src/main/java/com/project/base/domain/service/IBaseService.java b/src/main/java/com/project/base/domain/service/IBaseService.java
new file mode 100644
index 0000000..ca070d7
--- /dev/null
+++ b/src/main/java/com/project/base/domain/service/IBaseService.java
@@ -0,0 +1,41 @@
+package com.project.base.domain.service;
+
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.project.base.domain.dto.BaseDTO;
+import com.project.base.domain.entity.BaseEntity;
+
+import java.util.function.Supplier;
+
+/**
+ * 增强版通用 Service 接口
+ */
+public interface IBaseService extends IService {
+
+ /**
+ * 通用保存 DTO 的方法:转换 -> 保存 -> 回传带ID的DTO
+ */
+ default D saveDTO(D dto, Supplier entitySupplier, Supplier dtoSupplier) {
+ if (dto == null) return null;
+
+ // 1. DTO 转 Entity
+ T entity = dto.toEntity(entitySupplier);
+
+ // 2. 调用 MyBatis-Plus 的 save 方法
+ // 由于 IBaseService 继承了 IService,所以这里可以直接调 this.save
+ this.save(entity);
+
+ // 3. 将持久化后(带ID和自动填充字段)的 Entity 转回 DTO 并返回
+ return entity.toDTO(dtoSupplier);
+ }
+
+ /**
+ * 【扩展】通用更新 DTO 的方法
+ */
+ default boolean updateDTO(D dto, Supplier entitySupplier) {
+ if (dto == null) {
+ return false;
+ }
+ T entity = dto.toEntity(entitySupplier);
+ return this.updateById(entity);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/base/domain/utils/ExcelUtil.java b/src/main/java/com/project/base/domain/utils/ExcelUtil.java
new file mode 100644
index 0000000..64dcd70
--- /dev/null
+++ b/src/main/java/com/project/base/domain/utils/ExcelUtil.java
@@ -0,0 +1,40 @@
+package com.project.base.domain.utils;
+
+import com.alibaba.excel.EasyExcel;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.util.CollectionUtils;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.List;
+
+
+public class ExcelUtil {
+ public void exportExcel(HttpServletResponse response, List list, String sheetName,String fileName,Class clazz) {
+ if (CollectionUtils.isEmpty(list)) {
+ list = new ArrayList<>();
+ }
+
+ try {
+ //设置响应头
+ response.setContentType(
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
+ response.setCharacterEncoding("utf-8");
+
+ String encodeFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8);
+ response.setHeader(
+ "Content-Disposition",
+ "attachment;filename=" + encodeFileName + ".xlsx");
+
+ //写 Excel
+ EasyExcel.write(response.getOutputStream(), clazz)
+ .sheet(sheetName)
+ .doWrite(list);
+
+ } catch (Exception e) {
+ throw new RuntimeException("导出 Excel 失败", e);
+ }
+
+ }
+}
diff --git a/src/main/java/com/project/base/domain/utils/PageConverter.java b/src/main/java/com/project/base/domain/utils/PageConverter.java
new file mode 100644
index 0000000..a0ce060
--- /dev/null
+++ b/src/main/java/com/project/base/domain/utils/PageConverter.java
@@ -0,0 +1,14 @@
+package com.project.base.domain.utils;
+
+import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
+import com.project.base.domain.param.BaseParam;
+
+public class PageConverter {
+
+ /**
+ * 将任意 BaseParam 子类转换为 MyBatis-Plus 的 Page 对象
+ */
+ public static Page toMpPage(BaseParam param) {
+ return new Page<>(param.getCurrent(), param.getSize());
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/base/domain/utils/ServletUtils.java b/src/main/java/com/project/base/domain/utils/ServletUtils.java
new file mode 100644
index 0000000..4c04793
--- /dev/null
+++ b/src/main/java/com/project/base/domain/utils/ServletUtils.java
@@ -0,0 +1,65 @@
+package com.project.base.domain.utils;
+
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+import org.springframework.web.context.request.RequestContextHolder;
+import org.springframework.web.context.request.ServletRequestAttributes;
+
+/**
+ * 客户端请求工具类 (Spring Boot 3 / Jakarta EE 版)
+ */
+public class ServletUtils {
+
+ /**
+ * 获取当前请求对象 HttpServletRequest
+ */
+ public static HttpServletRequest getRequest() {
+ ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+ return attributes != null ? attributes.getRequest() : null;
+ }
+
+ /**
+ * 获取当前响应对象 HttpServletResponse
+ */
+ public static HttpServletResponse getResponse() {
+ ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
+ return attributes != null ? attributes.getResponse() : null;
+ }
+
+ /**
+ * 获取请求头信息
+ */
+ public static String getHeader(String name) {
+ HttpServletRequest request = getRequest();
+ return request != null ? request.getHeader(name) : null;
+ }
+
+ /**
+ * 获取客户端真实 IP
+ * 考虑了 Nginx/网关 代理的情况
+ */
+ public static String getClientIp() {
+ HttpServletRequest request = getRequest();
+ if (request == null) return "unknown";
+
+ // 处理常用的代理 Header
+ String ip = request.getHeader("x-forwarded-for");
+ if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+ ip = request.getHeader("Proxy-Client-IP");
+ }
+ if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+ ip = request.getHeader("WL-Proxy-Client-IP");
+ }
+ if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
+ ip = request.getRemoteAddr();
+ }
+
+ // 对于通过多个代理的情况,第一个IP为客户端真实IP,多个IP按照','分割
+ if (ip != null && ip.length() > 15) {
+ if (ip.indexOf(",") > 0) {
+ ip = ip.substring(0, ip.indexOf(","));
+ }
+ }
+ return ip;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/base/domain/utils/TreeUtils.java b/src/main/java/com/project/base/domain/utils/TreeUtils.java
new file mode 100644
index 0000000..a40318d
--- /dev/null
+++ b/src/main/java/com/project/base/domain/utils/TreeUtils.java
@@ -0,0 +1,199 @@
+package com.project.base.domain.utils;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.StrUtil;
+import com.google.common.collect.Lists;
+import lombok.extern.slf4j.Slf4j;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.function.BiConsumer;
+import java.util.function.Function;
+
+/**
+ * @Description: 树形结构对象的构建工具类
+ * @ClassName: TreeUtils
+ * @Author: luoweijian
+ * @Date: 2023/8/7 13:59
+ * Copyright (C) 2021-2022 CASEEDER, All Rights Reserved.
+ * 注意:本内容仅限于内部传阅,禁止外泄以及用于其他的商业目的
+ */
+@Slf4j
+public class TreeUtils {
+
+
+ /**
+ * 构建前端所需要树结构,主键为 Long 时
+ * @param tList 要构建的节点列表
+ * @param getIdFn 获取节点标识Fn
+ * @param getParentIdFn 获取父节点标识Fn
+ * @param setChild 知道父节点的所有子节点后的Fn
+ * @return
+ * @param
+ * @param
+ */
+ public static List buildLongTree(List tList , Function getIdFn , Function getParentIdFn , BiConsumer> setChild) {
+ try {
+ List returnList = new ArrayList<>();
+ //主键id集合
+ List tempList = new ArrayList<>();
+ for (T t : tList) {
+ Long primaryId = (Long) getIdFn.apply(t);
+ tempList.add(primaryId);
+ }
+ for (T t : tList) {
+ // 如果是顶级节点, 遍历该父节点的所有子节点
+ Long parentId = (Long) getParentIdFn.apply(t);
+ if (!tempList.contains(parentId)) {
+ recursionLong(tList, t , getIdFn , getParentIdFn, setChild);
+ returnList.add(t);
+ }
+ }
+ if (returnList.isEmpty()) {
+ returnList = tList;
+ }
+ return returnList;
+ } catch (Exception e) {
+ log.error(String.format("树结构转换失败:%s" , e.getMessage()));
+ return tList;
+ }
+ }
+
+
+
+ /**
+ * 递归设置子集数据,主键为 Long 时
+ * @param list
+ * @param o
+ * @param getIdFn
+ * @param getParentIdFn
+ * @param setChild
+ * @param
+ * @param
+ * @throws IllegalAccessException
+ */
+ private static void recursionLong(List list, T o , Function getIdFn , Function getParentIdFn , BiConsumer> setChild) throws IllegalAccessException {
+ // 得到子节点列表
+ List childList = getLongChildList(list, o , getIdFn , getParentIdFn);
+ invokeChildrenList(o, childList , setChild);
+
+ for (T oChild : childList) {
+ if (getLongChildList(list, oChild , getIdFn , getParentIdFn).size() > 0) {
+ recursionLong(list, oChild , getIdFn , getParentIdFn , setChild);
+ }
+ }
+ }
+
+
+
+ /**
+ * 得到子节点列表,主键为 Long 时
+ * @param list
+ * @param object
+ * @param getIdFn
+ * @param getParentIdFn
+ * @return
+ * @param
+ * @param
+ * @throws IllegalAccessException
+ */
+ private static List getLongChildList(List list, T object , Function getIdFn , Function getParentIdFn) throws IllegalAccessException {
+ Long primaryId = (Long) getIdFn.apply(object);
+
+ List objects = new ArrayList<>();
+ for (T o : list) {
+ Long parentId = (Long) getParentIdFn.apply(o);
+ if (null != parentId && parentId.longValue() == primaryId.longValue()) {
+ objects.add(o);
+ }
+ }
+ return objects;
+ }
+
+
+
+ /**
+ * 设置子集数据
+ * @param o
+ * @param childList
+ * @param setChild
+ * @param
+ */
+ private static void invokeChildrenList(T o, List childList , BiConsumer> setChild) {
+ setChild.accept(o , childList);
+ }
+
+
+ /**
+ * 将树转为List
+ * @param list
+ * @param getChildFn
+ * @return
+ * @param
+ */
+ public static List tree2List(List list , Function> getChildFn) {
+ List res = Lists.newArrayList();
+ for (T node : list) {
+ List childList = getChildFn.apply(node);
+ res.add(node);
+ if (CollUtil.isNotEmpty(childList)) {
+ res.addAll(tree2List(childList , getChildFn));
+ }
+ }
+ return res;
+ }
+
+ public static List buildStrTree(List tList , Function getIdFn , Function getParentIdFn , BiConsumer> setChild) {
+ try {
+ List returnList = new ArrayList<>();
+ //主键id集合
+ List tempList = new ArrayList<>();
+ for (T t : tList) {
+ String primaryId = String.valueOf(getIdFn.apply(t));
+ tempList.add(primaryId);
+ }
+ for (T t : tList) {
+ // 如果是顶级节点, 遍历该父节点的所有子节点
+ String parentId = String.valueOf(getParentIdFn.apply(t));
+ if (!tempList.contains(parentId)) {
+ recursionStr(tList, t , getIdFn , getParentIdFn, setChild);
+ returnList.add(t);
+ }
+ }
+ if (returnList.isEmpty()) {
+ returnList = tList;
+ }
+ return returnList;
+ } catch (Exception e) {
+ log.error(String.format("树结构转换失败:%s" , e.getMessage()));
+ return tList;
+ }
+ }
+
+ private static void recursionStr(List list, T o , Function getIdFn , Function getParentIdFn , BiConsumer> setChild) throws IllegalAccessException {
+ // 得到子节点列表
+ List childList = getStrChildList(list, o , getIdFn , getParentIdFn);
+ invokeChildrenList(o, childList , setChild);
+
+ for (T oChild : childList) {
+ if (getStrChildList(list, oChild , getIdFn , getParentIdFn).size() > 0) {
+ recursionStr(list, oChild , getIdFn , getParentIdFn , setChild);
+ }
+ }
+ }
+
+ private static List getStrChildList(List list, T object , Function getIdFn , Function getParentIdFn) throws IllegalAccessException {
+ String primaryId = String.valueOf(getIdFn.apply(object));
+
+ List objects = new ArrayList<>();
+ for (T o : list) {
+ String parentId = String.valueOf(getParentIdFn.apply(o));
+ if (null != parentId && StrUtil.equals(parentId , primaryId)) {
+ objects.add(o);
+ }
+ }
+ return objects;
+ }
+
+}
+
diff --git a/src/main/java/com/project/base/mapper/BatchUpsertMapper.java b/src/main/java/com/project/base/mapper/BatchUpsertMapper.java
new file mode 100644
index 0000000..5c41df2
--- /dev/null
+++ b/src/main/java/com/project/base/mapper/BatchUpsertMapper.java
@@ -0,0 +1,13 @@
+package com.project.base.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import org.apache.ibatis.annotations.Param;
+
+import java.util.List;
+
+public interface BatchUpsertMapper extends BaseMapper {
+ /**
+ * 自定义全局批量 Upsert
+ */
+ int batchUpsert(@Param("list") List list);
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/logistics/config/LogisticsScannerProperties.java b/src/main/java/com/project/logistics/config/LogisticsScannerProperties.java
new file mode 100644
index 0000000..6df96e9
--- /dev/null
+++ b/src/main/java/com/project/logistics/config/LogisticsScannerProperties.java
@@ -0,0 +1,31 @@
+package com.project.logistics.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.List;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "logistics.scanner")
+public class LogisticsScannerProperties {
+
+ private boolean enabled = true;
+
+ /**
+ * 默认每 10 分钟唤醒一次检查(10分钟唤醒一次开销极低,且能满足20分钟及以上的间隔)
+ */
+ private String cron = "0 0/10 * * * ?";
+
+ private List windows = new ArrayList<>();
+
+ @Data
+ public static class ScanWindow {
+ private String name; // 描述:如 "上午班次"
+ private String startTime; // "11:00"
+ private String endTime; // "12:00"
+ private int intervalMinutes; // 最小执行间隔,如 20
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/logistics/config/SfApiProperties.java b/src/main/java/com/project/logistics/config/SfApiProperties.java
new file mode 100644
index 0000000..03b712d
--- /dev/null
+++ b/src/main/java/com/project/logistics/config/SfApiProperties.java
@@ -0,0 +1,47 @@
+package com.project.logistics.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "sf.api")
+public class SfApiProperties {
+
+ /**
+ * 顾客编码 (partnerID)
+ */
+ private String partnerId;
+
+ /**
+ * 校验码 (secret / checkWord)
+ */
+ private String secret;
+
+ /**
+ * 申请类型,默认填 password
+ */
+ private String grantType = "password";
+
+ /**
+ * OAuth2 获取 Token 的地址
+ * 沙箱: https://sfapi-sbox.sf-express.com/oauth2/accessToken
+ * 正式: https://sfapi.sf-express.com/oauth2/accessToken
+ */
+ private String tokenUrl;
+
+ /**
+ * 下单/面单等通用业务接口地址
+ * 沙箱: https://sfapi-sbox.sf-express.com/std/service
+ * 正式: https://bspgw.sf-express.com/std/service
+ */
+ private String baseUrl;
+
+ /**
+ * 渠道编码 (Channel-Code)
+ * 沙箱环境专用: MCS-CAS-API-BOX
+ * 正式环境: 由顺丰提供
+ */
+ private String channelCode;
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/logistics/config/U9DataSourceConfig.java b/src/main/java/com/project/logistics/config/U9DataSourceConfig.java
new file mode 100644
index 0000000..e34bcd8
--- /dev/null
+++ b/src/main/java/com/project/logistics/config/U9DataSourceConfig.java
@@ -0,0 +1,36 @@
+package com.project.logistics.config;
+
+import com.zaxxer.hikari.HikariDataSource;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.jdbc.core.JdbcTemplate;
+
+@Configuration
+public class U9DataSourceConfig {
+
+ @Value("${u9-source.url}")
+ private String url;
+ @Value("${u9-source.username}")
+ private String user;
+ @Value("${u9-source.password}")
+ private String pass;
+ @Value("${u9-source.driver-class-name}")
+ private String driver;
+
+ @Bean(name = "u9JdbcTemplate")
+ public JdbcTemplate u9JdbcTemplate() {
+ // 在方法内部创建数据源,但不加 @Bean 注解
+ // 这样这个数据源在 Spring 容器里是“不可见的”,Hibernate 扫描不到它
+ HikariDataSource ds = new HikariDataSource();
+ ds.setJdbcUrl(url);
+ ds.setUsername(user);
+ ds.setPassword(pass);
+ ds.setDriverClassName(driver);
+ ds.setPoolName("U9-Isolated-Pool");
+ ds.setMaximumPoolSize(5);
+
+ // 返回绑定了“私有”数据源的 JdbcTemplate
+ return new JdbcTemplate(ds);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/logistics/config/WebDavProperties.java b/src/main/java/com/project/logistics/config/WebDavProperties.java
new file mode 100644
index 0000000..aea74d2
--- /dev/null
+++ b/src/main/java/com/project/logistics/config/WebDavProperties.java
@@ -0,0 +1,54 @@
+package com.project.logistics.config;
+
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "webdav")
+public class WebDavProperties {
+
+ /**
+ * WebDAV 服务器基础地址 (例如 http://192.168.1.100:8080)
+ */
+ private String url;
+
+ /**
+ * 认证用户名
+ */
+ private String username;
+
+ /**
+ * 认证密码
+ */
+ private String password;
+
+ /**
+ * 待处理出货单的根路径 (用于扫描)
+ * 例如: /U9_Orders/Pending
+ */
+ private String shipmentRoot = "/常规出货单";
+
+ /**
+ * 已处理出货单的归档文件夹名称
+ * 默认值: processed
+ */
+ private String processedFolderName = "/已自动下单出货单";
+
+ /**
+ * 签收回单(POD)的存放根路径
+ * 例如: /U9_Orders/Signed_Receipts
+ */
+ private String podRoot;
+
+ /**
+ * 连接超时时间 (毫秒),默认 10000
+ */
+ private int connectionTimeout = 10000;
+
+ /**
+ * 读取超时时间 (毫秒),默认 20000
+ */
+ private int readTimeout = 20000;
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/logistics/domain/entity/ApiRetryTaskEntity.java b/src/main/java/com/project/logistics/domain/entity/ApiRetryTaskEntity.java
new file mode 100644
index 0000000..9fcf56d
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/entity/ApiRetryTaskEntity.java
@@ -0,0 +1,73 @@
+package com.project.logistics.domain.entity;
+
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.project.base.domain.entity.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.hibernate.annotations.Comment;
+
+import java.util.Date;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Entity
+@Table(name = "auto_api_retry_task")
+@TableName("auto_api_retry_task")
+@Comment("外部API交互异步重试任务表")
+public class ApiRetryTaskEntity extends BaseEntity {
+
+ @Id
+ @Column(name = "id", columnDefinition = "bigint(20) comment '主键ID'")
+ private Long id;
+
+ @Comment("关联的业务单号(出货单号/样品单号,与主表一致)")
+ @Column(name = "order_no", length = 64, nullable = false)
+ @TableField("order_no")
+ private String orderNo;
+
+ @Comment("接口动作代码(如: SF_UPLOAD_RESOURCE, SF_CREATE_ORDER, ERP_UPDATE_WAYBILL 等)")
+ @Column(name = "action_code", length = 64, nullable = false)
+ @TableField("action_code")
+ private String actionCode;
+
+ @Comment("请求报文/业务参数(JSON)")
+ @Column(name = "request_data", columnDefinition = "longtext")
+ @TableField("request_data")
+ private String requestData;
+
+ @Comment("响应报文(JSON,记录最后一次失败或成功的响应)")
+ @Column(name = "response_data", columnDefinition = "longtext")
+ @TableField("response_data")
+ private String responseData;
+
+ @Comment("任务状态:PENDING(待执行), SUCCESS(成功), FAILED(失败可重试), MAX_RETRY_FAILED(超过最大重试死信)")
+ @Column(name = "task_status", length = 32, nullable = false)
+ @TableField("task_status")
+ private String taskStatus;
+
+ @Comment("当前已重试次数")
+ @Column(name = "retry_count", columnDefinition = "int default 0")
+ @TableField("retry_count")
+ private Integer retryCount;
+
+ @Comment("最大重试次数(默认5)")
+ @Column(name = "max_retries", columnDefinition = "int default 5")
+ @TableField("max_retries")
+ private Integer maxRetries;
+
+ @Comment("下次允许执行的时间(支持退避策略,如1min, 5min, 15min等)")
+ @Column(name = "next_execute_time")
+ @TableField("next_execute_time")
+ private Date nextExecuteTime;
+
+ @Comment("最后一次错误异常简述")
+ @Column(name = "error_message", columnDefinition = "text")
+ @TableField("error_message")
+ private String errorMessage;
+}
diff --git a/src/main/java/com/project/logistics/domain/entity/FeePushLogEntity.java b/src/main/java/com/project/logistics/domain/entity/FeePushLogEntity.java
new file mode 100644
index 0000000..d446c29
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/entity/FeePushLogEntity.java
@@ -0,0 +1,57 @@
+package com.project.logistics.domain.entity;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.project.base.domain.entity.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.hibernate.annotations.Comment;
+
+import java.math.BigDecimal;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Entity
+@Table(name = "auto_fee_push_log")
+@TableName("auto_fee_push_log")
+@Comment("顺丰清点运费推送表")
+public class FeePushLogEntity extends BaseEntity {
+
+ @Id
+ @Column(name = "id", columnDefinition = "bigint(20) comment '主键ID'")
+ private Long id;
+
+ @Comment("关联客户订单号(即出货单号/样品单号)")
+ @Column(name = "order_no", length = 64)
+ @TableField("order_no")
+ private String orderNo;
+
+ @Comment("关联顺丰运单号")
+ @Column(name = "waybill_no", length = 64, nullable = false)
+ @TableField("waybill_no")
+ private String waybillNo;
+
+ @Comment("实际计费重量(kg)")
+ @Column(name = "real_weight_qty", precision = 10, scale = 2)
+ @TableField("real_weight_qty")
+ private BigDecimal realWeightQty;
+
+ @Comment("总费用(明细见原始报文)")
+ @Column(name = "total_fee_amt", precision = 10, scale = 2)
+ @TableField("total_fee_amt")
+ private BigDecimal totalFeeAmt;
+
+ @Comment("是否已将运费同步/回写到ERP(0:否, 1:是)")
+ @Column(name = "sync_status", columnDefinition = "tinyint default 0")
+ @TableField("sync_status")
+ private Integer syncStatus;
+
+ @Comment("顺丰推送的原始报文(JSON格式)")
+ @Column(name = "raw_push_data", columnDefinition = "text")
+ @TableField("raw_push_data")
+ private String rawPushData;
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/logistics/domain/entity/LogisticsOrderEntity.java b/src/main/java/com/project/logistics/domain/entity/LogisticsOrderEntity.java
new file mode 100644
index 0000000..fb6db98
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/entity/LogisticsOrderEntity.java
@@ -0,0 +1,74 @@
+package com.project.logistics.domain.entity;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.project.base.domain.entity.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.hibernate.annotations.Comment;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Entity
+@Table(name = "auto_logistics_order")
+@TableName("auto_logistics_order")
+@Comment("顺丰物流自动化订单主表")
+public class LogisticsOrderEntity extends BaseEntity {
+
+ @Id
+ @Column(name = "id", columnDefinition = "bigint(20) comment '主键ID'")
+ private Long id;
+
+ @Comment("U9业务单号(出货单号/样品单号,唯一凭证)")
+ @Column(name = "order_no", length = 64, nullable = false, unique = true)
+ @TableField("order_no")
+ private String orderNo;
+
+ @Comment("单据类型:SHIPMENT(出货单-需签收回单), SAMPLE(样品单-无需回单)")
+ @Column(name = "order_type", length = 32, nullable = false)
+ @TableField("order_type")
+ private String orderType;
+
+ @Comment("顺丰运单号(母单号)")
+ @Column(name = "sf_waybill_no", length = 64)
+ @TableField("sf_waybill_no")
+ private String sfWaybillNo;
+
+ @Comment("从U9获取的完整下单信息快照(JSON格式,防U9后续改单)")
+ @Column(name = "order_info", columnDefinition = "text")
+ @TableField("order_info")
+ private String orderInfo;
+
+ @Comment("顺丰电子回单资源编码(IN149),样品单该字段为空")
+ @Column(name = "resource_code", length = 100)
+ @TableField("resource_code")
+ private String resourceCode;
+
+ @Comment("原始出货单WebDAV路径(相对路径),样品单为空")
+ @Column(name = "original_pdf_path", length = 255)
+ @TableField("original_pdf_path")
+ private String originalPdfPath;
+
+ @Comment("顺丰云打印面单文件WebDAV路径")
+ @Column(name = "waybill_pdf_path", length = 255)
+ @TableField("waybill_pdf_path")
+ private String waybillPdfPath;
+
+ @Comment("最终签收回单(图片/PDF)WebDAV路径,样品单为空")
+ @Column(name = "pod_pdf_path", length = 255)
+ @TableField("pod_pdf_path")
+ private String podPdfPath;
+
+ @Comment("订单内部流转状态(如: INIT, RESOURCE_CREATED, ORDER_SUBMITTED, WAYBILL_DOWNLOADED, ERP_UPDATED, DELIVERED, FINISHED, ERROR)") @Column(name = "order_status", length = 32, nullable = false)
+ @TableField("order_status")
+ private String orderStatus;
+
+ @Comment("顺丰最新路由状态码(如: 04代表签收等)")
+ @Column(name = "sf_current_state_code", length = 32)
+ @TableField("sf_current_state_code")
+ private String sfCurrentStateCode;
+}
diff --git a/src/main/java/com/project/logistics/domain/entity/PodPushLogEntity.java b/src/main/java/com/project/logistics/domain/entity/PodPushLogEntity.java
new file mode 100644
index 0000000..562d578
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/entity/PodPushLogEntity.java
@@ -0,0 +1,50 @@
+package com.project.logistics.domain.entity;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.project.base.domain.entity.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.hibernate.annotations.Comment;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Entity
+@Table(name = "auto_pod_push_log")
+@TableName("auto_pod_push_log")
+@Comment("顺丰回单图片推送记录表")
+public class PodPushLogEntity extends BaseEntity {
+
+ @Id
+ @Column(name = "id", columnDefinition = "bigint(20) comment '主键ID'")
+ private Long id;
+
+ @Comment("关联顺丰运单号")
+ @Column(name = "waybill_no", length = 64, nullable = false)
+ @TableField("waybill_no")
+ private String waybillNo;
+
+ @Comment("关联客户订单号(出货单号)")
+ @Column(name = "order_no", length = 64)
+ @TableField("order_no")
+ private String orderNo;
+
+ @Comment("图片类型(如: 1代表回单/清单, 71代表拍照回传等,见顺丰字典)")
+ @Column(name = "img_type", length = 32)
+ @TableField("img_type")
+ private String imgType;
+
+ @Comment("处理状态:0-待处理(刚收到推送), 1-已成功解码并存入WebDAV, 2-处理失败")
+ @Column(name = "process_status", columnDefinition = "tinyint default 0")
+ @TableField("process_status")
+ private Integer processStatus;
+
+ @Comment("顺丰推送的原始报文(包含巨大的Base64图片数据,必须用LONGTEXT)")
+ @Column(name = "raw_push_data", columnDefinition = "longtext")
+ @TableField("raw_push_data")
+ private String rawPushData;
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/logistics/domain/entity/RoutePushLogEntity.java b/src/main/java/com/project/logistics/domain/entity/RoutePushLogEntity.java
new file mode 100644
index 0000000..81ec32e
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/entity/RoutePushLogEntity.java
@@ -0,0 +1,60 @@
+package com.project.logistics.domain.entity;
+
+import com.baomidou.mybatisplus.annotation.TableField;
+import com.baomidou.mybatisplus.annotation.TableName;
+import com.project.base.domain.entity.BaseEntity;
+import jakarta.persistence.Column;
+import jakarta.persistence.Entity;
+import jakarta.persistence.Id;
+import jakarta.persistence.Table;
+import lombok.Data;
+import lombok.EqualsAndHashCode;
+import org.hibernate.annotations.Comment;
+
+@Data
+@EqualsAndHashCode(callSuper = true)
+@Entity
+@Table(name = "auto_route_push_log")
+@TableName("auto_route_push_log")
+@Comment("顺丰路由状态推送流水表")
+public class RoutePushLogEntity extends BaseEntity {
+
+ @Id
+ @Column(name = "id", columnDefinition = "bigint(20) comment '主键ID'")
+ private Long id;
+
+ @Comment("关联顺丰运单号")
+ @Column(name = "waybill_no", length = 64, nullable = false)
+ @TableField("waybill_no")
+ private String waybillNo;
+
+ @Comment("关联客户订单号(即出货单号/样品单号)")
+ @Column(name = "order_no", length = 64)
+ @TableField("order_no")
+ private String orderNo;
+
+ @Comment("顺丰路由状态代码")
+ @Column(name = "order_state_code", length = 32)
+ @TableField("order_state_code")
+ private String orderStateCode;
+
+ @Comment("顺丰路由状态描述(如:已收取、派件中、签收成功等)")
+ @Column(name = "order_state_desc", length = 255)
+ @TableField("order_state_desc")
+ private String orderStateDesc;
+
+ @Comment("收派员工号")
+ @Column(name = "emp_code", length = 100)
+ @TableField("emp_code")
+ private String empCode;
+
+ @Comment("收派员手机号")
+ @Column(name = "emp_phone", length = 100)
+ @TableField("emp_phone")
+ private String empPhone;
+
+ @Comment("顺丰推送的原始报文(JSON/XML,防扯皮留存)")
+ @Column(name = "raw_push_data", columnDefinition = "text")
+ @TableField("raw_push_data")
+ private String rawPushData;
+}
\ 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
new file mode 100644
index 0000000..eb7790d
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/enums/OrderStatusEnum.java
@@ -0,0 +1,28 @@
+package com.project.logistics.domain.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public enum OrderStatusEnum {
+
+ INIT("INIT", "1.初始化完毕(已获取ERP数据)"),
+ RESOURCE_CREATED("RESOURCE_CREATED", "2.电子回单资源已上传(仅出货单)"),
+ ORDER_SUBMITTED("ORDER_SUBMITTED", "3.顺丰下单成功(已获取运单号)"),
+ WAYBILL_DOWNLOADED("WAYBILL_DOWNLOADED", "4.面单文件已下载"),
+ ERP_WAYBILL_UPDATED("ERP_WAYBILL_UPDATED", "5.运单号已回写ERP"),
+
+ PICKED_UP("PICKED_UP", "6.快递已揽收(等待清点计费)"),
+
+ ERP_FEE_UPDATED("ERP_FEE_UPDATED", "7.运费和件数已回写ERP"),
+
+ DELIVERED("DELIVERED", "8.快递已签收"),
+ POD_DOWNLOADED("POD_DOWNLOADED", "9.签收底单(图片)已下载(仅出货单)"),
+ FINISHED("FINISHED", "10.流程正常结束"),
+
+ ERROR("ERROR", "X.异常阻断(需人工介入)");
+
+ private final String code;
+ private final String desc;
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/logistics/domain/enums/OrderTypeEnum.java b/src/main/java/com/project/logistics/domain/enums/OrderTypeEnum.java
new file mode 100644
index 0000000..69ba33e
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/enums/OrderTypeEnum.java
@@ -0,0 +1,14 @@
+package com.project.logistics.domain.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public enum OrderTypeEnum {
+ SHIPMENT("SHIPMENT", "出货单(需上传PDF并获取签收回单)"),
+ SAMPLE("SAMPLE", "样品单(纯数据流转,无需回单)");
+
+ private final String code;
+ private final String desc;
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/logistics/domain/enums/RetryActionEnum.java b/src/main/java/com/project/logistics/domain/enums/RetryActionEnum.java
new file mode 100644
index 0000000..ba5dd11
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/enums/RetryActionEnum.java
@@ -0,0 +1,24 @@
+package com.project.logistics.domain.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public enum RetryActionEnum {
+
+ /* --- 顺丰主动调用动作 --- */
+ SF_UPLOAD_RESOURCE("SF_UPLOAD_RESOURCE", "上传电子回单原始PDF资源"),
+ SF_CREATE_ORDER("SF_CREATE_ORDER", "顺丰下单(获取运单号)"),
+ SF_DOWNLOAD_WAYBILL("SF_DOWNLOAD_WAYBILL", "获取顺丰云打印面单PDF"),
+ SF_DOWNLOAD_POD("SF_DOWNLOAD_POD", "解析并下载顺丰签收底单图片"),
+
+ /* --- ERP(U9)回写动作 --- */
+ ERP_UPDATE_WAYBILL("ERP_UPDATE_WAYBILL", "回写顺丰运单号到ERP"),
+ ERP_UPDATE_PICKED_UP("ERP_UPDATE_PICKUP", "回写【已揽收】状态到ERP"),
+ ERP_UPDATE_FEE("ERP_UPDATE_FEE", "回写【运费/计费重量/件数】到ERP"),
+ ERP_UPDATE_DELIVERED("ERP_UPDATE_DELIVERED", "回写【已签收】状态到ERP");
+
+ private final String code;
+ private final String desc;
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/logistics/domain/enums/SfRouteOpCodeEnum.java b/src/main/java/com/project/logistics/domain/enums/SfRouteOpCodeEnum.java
new file mode 100644
index 0000000..8610b89
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/enums/SfRouteOpCodeEnum.java
@@ -0,0 +1,26 @@
+package com.project.logistics.domain.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public enum SfRouteOpCodeEnum {
+
+ PICKED_UP("50", "已收取/已揽收"),
+ IN_TRANSIT("30", "转运中"),
+ ARRIVED_DEST("307", "到达目的地网点"),
+ DELIVERING("44", "正在派送"),
+ DELIVERED("80", "已签收"),
+ RETURNED("70", "快件退回");
+
+ private final String code;
+ private final String desc;
+
+ public static SfRouteOpCodeEnum getByCode(String code) {
+ for (SfRouteOpCodeEnum value : values()) {
+ if (value.getCode().equals(code)) return value;
+ }
+ return null;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/logistics/domain/enums/SyncStatusEnum.java b/src/main/java/com/project/logistics/domain/enums/SyncStatusEnum.java
new file mode 100644
index 0000000..70aa42c
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/enums/SyncStatusEnum.java
@@ -0,0 +1,19 @@
+package com.project.logistics.domain.enums;
+
+import com.baomidou.mybatisplus.annotation.EnumValue;
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public enum SyncStatusEnum {
+
+ WAIT(0, "未处理/待同步"),
+ SUCCESS(1, "成功回写ERP/成功存入网盘"),
+ FAILED(2, "处理失败"),
+ IGNORE(3, "忽略(非核心路由或重复数据)");
+
+ @EnumValue // MyBatis-Plus 标识入库字段
+ private final Integer code;
+ private final String desc;
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/logistics/domain/enums/TaskStatusEnum.java b/src/main/java/com/project/logistics/domain/enums/TaskStatusEnum.java
new file mode 100644
index 0000000..7300283
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/enums/TaskStatusEnum.java
@@ -0,0 +1,19 @@
+package com.project.logistics.domain.enums;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+
+@Getter
+@AllArgsConstructor
+public enum TaskStatusEnum {
+
+ PENDING("PENDING", "待执行/待重试"),
+ EXECUTING("EXECUTING", "正在执行中(防并发锁定)"),
+ SUCCESS("SUCCESS", "执行成功(终态)"),
+ FAILED("FAILED", "执行失败(等待下次重试)"),
+ MAX_RETRY_FAILED("MAX_RETRY_FAILED", "已达最大重试次数(需人工介入)"),
+ CANCELLED("CANCELLED", "任务已取消");
+
+ private final String code;
+ private final String desc;
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/logistics/domain/scheduler/ApiRetryJob.java b/src/main/java/com/project/logistics/domain/scheduler/ApiRetryJob.java
new file mode 100644
index 0000000..8214623
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/scheduler/ApiRetryJob.java
@@ -0,0 +1,118 @@
+package com.project.logistics.domain.scheduler;
+
+import com.project.logistics.domain.entity.ApiRetryTaskEntity;
+import com.project.logistics.domain.entity.LogisticsOrderEntity;
+import com.project.logistics.domain.enums.TaskStatusEnum;
+import com.project.logistics.domain.service.base.ApiRetryTaskService;
+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.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Component
+public class ApiRetryJob {
+
+ @Autowired
+ private ApiRetryTaskService apiRetryTaskService;
+ @Autowired
+ private LogisticsOrderService logisticsOrderService;
+
+ // Spring 会自动把所有实现了 ApiTaskHandler 的类注入到这个 List 里
+ private final Map handlerMap;
+
+ @Autowired
+ public ApiRetryJob(List handlers) {
+ // 将 List 转换成 Map,方便通过 ActionCode 快速查找对应的处理器
+ this.handlerMap = handlers.stream()
+ .collect(Collectors.toMap(ApiTaskHandler::getActionCode, Function.identity()));
+ }
+
+ /**
+ * 每隔 10 秒钟执行一次,拉取待处理任务
+ * 这个频率可以快一点,因为它只查本地库,不怎么耗资源
+ */
+ @Scheduled(fixedDelay = 10000)
+ public void executePendingTasks() throws Exception {
+ // 1. 从数据库捞取所有 状态=PENDING/FAILED 且 执行时间<=当前时间 的任务
+ List pendingTasks = apiRetryTaskService.listPendingTasks();
+
+ if (pendingTasks.isEmpty()) {
+ return;
+ }
+
+ log.info(">>> 引擎启动,发现 {} 个待处理的 API 任务", pendingTasks.size());
+
+ for (ApiRetryTaskEntity task : pendingTasks) {
+ processSingleTask(task);
+ }
+ }
+
+ private void processSingleTask(ApiRetryTaskEntity task) {
+ try {
+ // 获取对应的处理器
+ ApiTaskHandler handler = handlerMap.get(task.getActionCode());
+ if (handler == null) {
+ log.error("未找到对应的任务处理器: {}", task.getActionCode());
+ return;
+ }
+
+ // 获取订单信息
+ LogisticsOrderEntity order = logisticsOrderService.getByOrderNo(task.getOrderNo());
+ if (order == null) {
+ log.error("任务关联的订单不存在: {}", task.getOrderNo());
+ return;
+ }
+
+ // 锁定任务状态为执行中 (防止并发重复执行)
+ task.setTaskStatus(TaskStatusEnum.EXECUTING.getCode());
+ apiRetryTaskService.updateById(task);
+
+ // ==================
+ // 【执行真正的业务逻辑】
+ // ==================
+ handler.handle(task, order);
+
+ // 如果没抛异常,说明执行成功
+ task.setTaskStatus(TaskStatusEnum.SUCCESS.getCode());
+ task.setErrorMessage("");
+ apiRetryTaskService.updateById(task);
+
+ } catch (Exception e) {
+ handleTaskFailure(task, e);
+ }
+ }
+
+ /**
+ * 失败重试与退避机制
+ */
+ private void handleTaskFailure(ApiRetryTaskEntity task, Exception e) {
+ log.error(">>> 任务 [{}] 执行失败,单号: {}", task.getActionCode(), task.getOrderNo(), e);
+
+ int currentRetry = task.getRetryCount() + 1;
+ task.setRetryCount(currentRetry);
+ task.setErrorMessage(e.getMessage());
+
+ if (currentRetry >= task.getMaxRetries()) {
+ // 超过最大重试次数,标记为死信,需人工排查
+ task.setTaskStatus(TaskStatusEnum.MAX_RETRY_FAILED.getCode());
+ log.error("!!!任务已达最大重试次数,彻底失败,需人工介入!!!");
+ } else {
+ // 状态改为 FAILED,等待下次重试
+ task.setTaskStatus(TaskStatusEnum.FAILED.getCode());
+ // 计算下次执行时间 (简单的退避策略:2分钟, 5分钟, 10分钟...)
+ long delayMillis = (long) Math.pow(2, currentRetry) * 60 * 1000L;
+ task.setNextExecuteTime(new Date(System.currentTimeMillis() + delayMillis));
+ log.info(">>> 任务将在 {} 后进行第 {} 次重试", delayMillis / 1000 / 60 + " 分钟", currentRetry);
+ }
+ apiRetryTaskService.updateById(task);
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/logistics/domain/scheduler/WebDavScannerJob.java b/src/main/java/com/project/logistics/domain/scheduler/WebDavScannerJob.java
new file mode 100644
index 0000000..06be444
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/scheduler/WebDavScannerJob.java
@@ -0,0 +1,186 @@
+package com.project.logistics.domain.scheduler;
+
+import cn.hutool.core.collection.CollUtil;
+import cn.hutool.core.util.StrUtil;
+import cn.hutool.json.JSONObject;
+import com.github.sardine.DavResource;
+import com.project.logistics.config.LogisticsScannerProperties;
+
+import com.project.logistics.config.WebDavProperties;
+import com.project.logistics.domain.service.WebDavService;
+import com.project.logistics.domain.service.base.ApiRetryTaskService;
+import com.project.logistics.domain.service.base.ErpService;
+import com.project.logistics.domain.service.base.LogisticsOrderService;
+import com.project.logistics.domain.utils.FilePathUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.data.redis.core.StringRedisTemplate;
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+import java.time.LocalTime;
+import java.time.format.DateTimeFormatter;
+import java.util.Date;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+@Slf4j
+@Component
+public class WebDavScannerJob {
+
+ @Autowired
+ private LogisticsScannerProperties scannerProperties;
+
+ @Autowired
+ private StringRedisTemplate redisTemplate;
+
+ @Autowired
+ private WebDavService webDavService;
+
+ // 记录上一次成功扫描的时间戳,用于判断间隔
+ private static final String REDIS_LAST_SUCCESS_KEY = "auto_logistics:scanner:last_success_ts";
+
+ @Autowired
+ private LogisticsOrderService logisticsOrderService;
+
+ @Autowired
+ private ApiRetryTaskService apiRetryTaskService;
+
+ @Autowired
+ private ErpService erpService;
+
+ @Autowired
+ private WebDavProperties webDavProperties;
+
+ /**
+ * 10 分钟唤醒一次,这个频率既不占用资源,也能保证 20 分钟/1 小时的间隔能被准确触发
+ */
+ @Scheduled(cron = "${logistics.scanner.cron:0 0/10 * * * ?}")
+ public void execute() {
+ if (!scannerProperties.isEnabled()) {
+ return;
+ }
+
+ LocalTime now = LocalTime.now();
+ LogisticsScannerProperties.ScanWindow currentWindow = findMatchedWindow(now);
+
+ if (currentWindow == null) {
+ log.debug("当前时刻 {} 不在任何配置的扫描窗口内", now);
+ return;
+ }
+
+ // 校验距离上次成功执行的时间间隔
+ if (checkInterval(currentWindow)) {
+ log.info(">>> 命中窗口 [{}], 设定的执行间隔为 {} 分钟, 开始扫描 WebDAV...",
+ currentWindow.getName(), currentWindow.getIntervalMinutes());
+
+ // 执行实际扫描业务
+ boolean success = doScanWork();
+
+ if (success) {
+ // 更新最后成功执行的时间戳
+ redisTemplate.opsForValue().set(REDIS_LAST_SUCCESS_KEY,
+ String.valueOf(System.currentTimeMillis()), 24, TimeUnit.HOURS);
+ }
+ }
+ }
+
+ private boolean checkInterval(LogisticsScannerProperties.ScanWindow window) {
+ String lastTs = redisTemplate.opsForValue().get(REDIS_LAST_SUCCESS_KEY);
+ if (lastTs == null) return true; // 第一次运行或缓存失效,允许运行
+
+ long lastTime = Long.parseLong(lastTs);
+ long diffMinutes = (System.currentTimeMillis() - lastTime) / (1000 * 60);
+
+ // 如果距离上次运行的时间 >= 窗口要求的间隔,则允许执行
+ // 注意:这里由于唤醒是10分钟一次,所以实际间隔会是10的倍数(如 20, 30, 40)
+ return diffMinutes >= window.getIntervalMinutes();
+ }
+
+ private LogisticsScannerProperties.ScanWindow findMatchedWindow(LocalTime now) {
+ DateTimeFormatter dtf = DateTimeFormatter.ofPattern("HH:mm");
+ for (LogisticsScannerProperties.ScanWindow window : scannerProperties.getWindows()) {
+ LocalTime start = LocalTime.parse(window.getStartTime(), dtf);
+ LocalTime end = LocalTime.parse(window.getEndTime(), dtf);
+ if (!now.isBefore(start) && !now.isAfter(end)) {
+ return window;
+ }
+ }
+ return null;
+ }
+
+ public boolean doScanWork() {
+ // 1. 生成日期层级路径: 2026年/2026年3月/2026年3月27日
+ String datePathSuffix = FilePathUtil.getHierarchicalPath(new Date());
+
+ // 2. 拼接 WebDAV 相对扫描路径: /常规出货单/2026年/.../
+ String scanRelativePath = webDavProperties.getShipmentRoot() + "/" + datePathSuffix + "/";
+
+ log.info(">>> 开始扫描 WebDAV 业务目录: {}", scanRelativePath);
+
+ try {
+ // 调用通用化的 list 方法
+ List pdfFiles = webDavService.listPdfFilesInDirectory(scanRelativePath);
+
+ if (CollUtil.isEmpty(pdfFiles)) {
+ log.info("目录 {} 下未发现待处理文件", scanRelativePath);
+ return true;
+ }
+
+ log.info("发现 {} 个待处理文件,准备执行导入逻辑...", pdfFiles.size());
+ int successCount = 0;
+
+ for (DavResource file : pdfFiles) {
+ String fileName = file.getName();
+ String orderNo = StrUtil.removeAny(fileName, ".pdf", ".PDF");
+
+ // 【核心修改】构造该文件的完整 WebDAV 相对路径,用于存库
+ String fullFilePath = scanRelativePath + fileName;
+
+ try {
+ // 传递 orderNo 和 完整路径
+ if (processSingleFile(orderNo, fullFilePath)) {
+ successCount++;
+ }
+ } catch (Exception e) {
+ log.error("文件 [{}] 处理失败: {}", fileName, e.getMessage());
+ }
+ }
+
+ log.info(">>> 扫描任务结束。共扫描 {} 个文件,成功导入 {} 个", pdfFiles.size(), successCount);
+ return true;
+
+ } catch (Exception e) {
+ log.error("WebDAV 扫描过程发生异常", e);
+ return false;
+ }
+ }
+
+ /**
+ * 处理单个文件
+ * @param fullFilePath 完整的相对路径,例如: /常规出货单/2026年/3月/27日/BSM123.pdf
+ */
+ public boolean processSingleFile(String orderNo, String fullFilePath) throws Exception {
+
+ // 1. 幂等性校验
+ if (logisticsOrderService.existsByOrderNo(orderNo)) {
+ log.debug("单号 {} 本地已存在(等待下单中),忽略本次扫描", orderNo);
+ return false;
+ }
+
+ // 2. 跨库查询 U9 (无事务环境,完美适配专线查询)
+ JSONObject u9Data = erpService.getShipmentOrderInfo(orderNo);
+
+ if (u9Data == null || u9Data.isEmpty()) {
+ log.warn("单号 {} 在 U9 数据库中未找到数据", orderNo);
+ return false;
+ }
+
+ // 3. 调用 Service 存入数据库 (内部带 @Transactional)
+ // 直接传入拼接好的 fullFilePath
+ logisticsOrderService.createOrderAndTask(orderNo, u9Data, fullFilePath);
+
+ log.info("单号 {} 导入成功,路径已存库", orderNo);
+ return true;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/logistics/domain/service/SfApiService.java b/src/main/java/com/project/logistics/domain/service/SfApiService.java
new file mode 100644
index 0000000..059f8b7
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/service/SfApiService.java
@@ -0,0 +1,137 @@
+package com.project.logistics.domain.service;
+
+import cn.hutool.core.codec.Base64;
+import cn.hutool.core.util.IdUtil;
+import cn.hutool.crypto.digest.DigestUtil;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.project.logistics.config.SfApiProperties;
+import com.project.logistics.domain.utils.SfTokenUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.*;
+import org.springframework.stereotype.Service;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class SfApiService {
+
+ @Autowired
+ private SfApiProperties sfApiProperties;
+
+ @Autowired
+ private SfTokenUtil sfTokenUtil;
+
+ private final RestTemplate restTemplate = new RestTemplate();
+
+ /**
+ * 【核心实现】电子回单资源上传全流程:获取URL -> PUT上传 -> 创建资源
+ */
+ public String uploadResourceFlow(byte[] pdfBytes, String fileName) {
+ // 1. 计算文件 MD5 (Base64编码格式,顺丰要求)
+ String fileMd5 = Base64.encode(DigestUtil.md5(pdfBytes));
+ long fileSize = pdfBytes.length;
+
+ // Step 1: 获取文件上传地址
+ JSONObject getUrlData = new JSONObject();
+ getUrlData.put("fileMd5", fileMd5);
+ getUrlData.put("fileName", fileName);
+ getUrlData.put("fileSize", fileSize);
+
+ // 注意:根据文档 2.3,获取地址接口也需要 Channel-Code
+ Map headers = new HashMap<>();
+ headers.put("Channel-Code", sfApiProperties.getChannelCode());
+
+ String getUrlRes = callSfApi("COM_RECE_WP_GET_FILE_UPLOAD_URL", getUrlData, headers);
+ JSONObject getUrlJson = parseSfResponse(getUrlRes);
+
+ JSONObject data = getUrlJson.getJSONObject("data");
+ String fileCode = data.getString("fileCode");
+ boolean exist = data.getBooleanValue("exist");
+
+ // Step 2: 如果顺丰侧不存在该文件,则执行二进制上传
+ if (!exist) {
+ String uploadUrl = data.getString("uploadUrl");
+ executeBinaryPut(uploadUrl, pdfBytes, fileMd5);
+ log.info(">>> 二进制文件 PUT 上传成功, fileCode: {}", fileCode);
+ } else {
+ log.info(">>> 顺丰侧已存在相同 MD5 文件, 直接使用 fileCode: {}", fileCode);
+ }
+
+ // Step 3: 创建资源
+ JSONObject createResourceData = new JSONObject();
+ createResourceData.put("partyType", "SPECI_NAME"); // 签署人类型:收件人
+ createResourceData.put("fileCode", fileCode);
+ // 这里可以根据实际需求构造 taskSigners 等复杂结构...
+
+ return callSfApi("COM_RECE_WP_CREATE_UPDATE_RESOURCE", createResourceData, headers);
+ }
+
+ /**
+ * 执行二进制流上传 (HTTP PUT)
+ */
+ private void executeBinaryPut(String uploadUrl, byte[] pdfBytes, String fileMd5) {
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
+ headers.set("Content-MD5", fileMd5); // 必须与第一步的 fileMd5 一致
+
+ HttpEntity requestEntity = new HttpEntity<>(pdfBytes, headers);
+ try {
+ // 使用 PUT 方法上传
+ ResponseEntity response = restTemplate.exchange(uploadUrl, HttpMethod.PUT, requestEntity, String.class);
+ if (response.getStatusCode() != HttpStatus.OK) {
+ throw new RuntimeException("文件流上传失败, 状态码: " + response.getStatusCode());
+ }
+ } catch (Exception e) {
+ log.error(">>> 二进制 PUT 上传发生异常", e);
+ throw new RuntimeException("SF_FILE_PUT_FAILED");
+ }
+ }
+
+ /**
+ * 通用 API 调用基础方法 (Content-Type: x-www-form-urlencoded)
+ */
+ public String callSfApi(String serviceCode, Object msgDataObj, Map extraHeaders) {
+ String requestId = IdUtil.fastSimpleUUID();
+ String accessToken = sfTokenUtil.getAccessToken();
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+ if (extraHeaders != null) extraHeaders.forEach(headers::add);
+
+ MultiValueMap params = new LinkedMultiValueMap<>();
+ params.add("partnerID", sfApiProperties.getPartnerId());
+ params.add("requestID", requestId);
+ params.add("serviceCode", serviceCode);
+ params.add("timestamp", String.valueOf(System.currentTimeMillis()));
+ params.add("accessToken", accessToken);
+ params.add("msgData", JSON.toJSONString(msgDataObj));
+
+ HttpEntity> request = new HttpEntity<>(params, headers);
+
+ log.info(">>> 发起顺丰 API 请求 [{}], RequestID: {}", serviceCode, requestId);
+ ResponseEntity response = restTemplate.postForEntity(sfApiProperties.getBaseUrl(), request, String.class);
+ return response.getBody();
+ }
+
+ /**
+ * 统一解析顺丰外层 A1000 逻辑
+ */
+ private JSONObject parseSfResponse(String response) {
+ JSONObject json = JSON.parseObject(response);
+ if (!"A1000".equals(json.getString("apiResultCode"))) {
+ throw new RuntimeException("顺丰平台接入失败: " + json.getString("apiErrorMsg"));
+ }
+ JSONObject resultData = JSON.parseObject(json.getString("apiResultData"));
+ if (resultData.getInteger("code") != 0) {
+ throw new RuntimeException("顺丰业务处理失败: " + resultData.getString("message"));
+ }
+ return resultData;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/logistics/domain/service/WebDavService.java b/src/main/java/com/project/logistics/domain/service/WebDavService.java
new file mode 100644
index 0000000..45d2508
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/service/WebDavService.java
@@ -0,0 +1,138 @@
+package com.project.logistics.domain.service;
+
+import cn.hutool.core.io.IoUtil;
+import cn.hutool.core.net.url.UrlBuilder;
+import cn.hutool.core.util.CharsetUtil;
+import cn.hutool.core.util.URLUtil;
+import com.github.sardine.DavResource;
+import com.github.sardine.Sardine;
+import com.github.sardine.SardineFactory;
+import com.project.logistics.config.WebDavProperties;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.io.InputStream;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class WebDavService {
+
+ @Autowired
+ private WebDavProperties properties;
+
+ private Sardine getSardine() {
+ return SardineFactory.begin(properties.getUsername(), properties.getPassword());
+ }
+
+ /**
+ * 1. 扫描指定目录下的 PDF 文件
+ * @param fullDirectoryPath 完整的目录路径 (如: /常规出货单/2026年/2026年3月/2026年3月27日/)
+ */
+ public List listPdfFilesInDirectory(String fullDirectoryPath) throws IOException {
+ Sardine sardine = getSardine();
+ String rawUrl = properties.getUrl() + "/" + fullDirectoryPath;
+ String encodedUrl = getEncodedUrl(rawUrl);
+
+ if (!sardine.exists(encodedUrl)) {
+ log.info(">>> 目录不存在,正在自动初始化: {}", URLUtil.decode(encodedUrl));
+ ensureDirectoryExists(sardine, encodedUrl);
+ return new ArrayList<>();
+ }
+
+ return sardine.list(encodedUrl).stream()
+ .filter(res -> !res.isDirectory() && res.getName().toLowerCase().endsWith(".pdf"))
+ .collect(Collectors.toList());
+ }
+
+ /**
+ * 2. 【通用】下载 WebDAV 文件
+ * @param fullFilePath 完整的文件路径 (如: /常规出货单/2026年/.../单号.pdf)
+ */
+ public byte[] downloadFile(String fullFilePath) throws IOException {
+ Sardine sardine = getSardine();
+ String sourceUrl = getEncodedUrl(properties.getUrl() + "/" + fullFilePath);
+
+ if (!sardine.exists(sourceUrl)) {
+ log.error(">>> [严重异常] WebDAV 文件丢失: {}", URLUtil.decode(sourceUrl));
+ throw new FileNotFoundException("文件不存在: " + fullFilePath);
+ }
+
+ log.info(">>> 正在下载文件: {}", URLUtil.decode(sourceUrl));
+ try (InputStream is = sardine.get(sourceUrl)) {
+ return IoUtil.readBytes(is);
+ }
+ }
+
+ /**
+ * 3. 【通用】将文件移动到同级目录下的 processed 文件夹中
+ * @param fullFilePath 完整的文件路径 (如: /常规出货单/2026年/.../单号.pdf)
+ */
+ public void moveFileToProcessed(String fullFilePath) throws IOException {
+ Sardine sardine = getSardine();
+
+ // 1. 从完整路径中截取 目录 和 文件名
+ int lastSlashIdx = fullFilePath.lastIndexOf("/");
+ if (lastSlashIdx == -1) throw new IllegalArgumentException("文件路径格式错误: " + fullFilePath);
+
+ String directoryPath = fullFilePath.substring(0, lastSlashIdx + 1); // 包含末尾斜杠
+ String fileName = fullFilePath.substring(lastSlashIdx + 1);
+
+ // 2. 构造源 URL 和 目标 URL
+ String sourceUrl = getEncodedUrl(properties.getUrl() + "/" + fullFilePath);
+ String targetFolderUrl = getEncodedUrl(properties.getUrl() + "/" + directoryPath + properties.getProcessedFolderName() + "/");
+ String targetFileUrl = getEncodedUrl(properties.getUrl() + "/" + directoryPath + properties.getProcessedFolderName() + "/" + fileName);
+
+ if (!sardine.exists(sourceUrl)) {
+ log.warn(">>> 移动跳过: 源文件已不存在 {}", URLUtil.decode(sourceUrl));
+ return;
+ }
+
+ ensureDirectoryExists(sardine, targetFolderUrl);
+ sardine.move(sourceUrl, targetFileUrl);
+ log.info(">>> 文件已移入归档目录: {}", URLUtil.decode(targetFileUrl));
+ }
+
+ /**
+ * 4. 【通用】上传文件到指定路径
+ * @param targetFilePath 完整的目标文件路径 (如: /签收回单/2026年/.../单号_POD.pdf)
+ */
+ public void uploadFile(String targetFilePath, byte[] fileData) throws IOException {
+ Sardine sardine = getSardine();
+ String fullUrl = getEncodedUrl(properties.getUrl() + "/" + targetFilePath);
+
+ // 截取目标所在的文件夹路径
+ String targetFolderUrl = fullUrl.substring(0, fullUrl.lastIndexOf("/") + 1);
+ ensureDirectoryExists(sardine, targetFolderUrl);
+
+ sardine.put(fullUrl, fileData);
+ log.info(">>> 文件上传成功: {}", URLUtil.decode(fullUrl));
+ }
+
+ private void ensureDirectoryExists(Sardine sardine, String encodedFolderUrl) throws IOException {
+ if (sardine.exists(encodedFolderUrl)) return;
+
+ String pathWithoutTrailingSlash = encodedFolderUrl.substring(0, encodedFolderUrl.length() - 1);
+ int lastSlashIndex = pathWithoutTrailingSlash.lastIndexOf("/");
+ if (lastSlashIndex <= 8) return; // 避开 http://
+
+ ensureDirectoryExists(sardine, encodedFolderUrl.substring(0, lastSlashIndex + 1));
+
+ try {
+ sardine.createDirectory(encodedFolderUrl);
+ log.info(">>> 自动创建目录成功: {}", URLUtil.decode(encodedFolderUrl));
+ } catch (IOException e) {
+ if (!sardine.exists(encodedFolderUrl)) throw e;
+ }
+ }
+
+ private String getEncodedUrl(String rawUrl) {
+ String normalized = rawUrl.replaceAll("(? {
+
+ List listPendingTasks() throws Exception;
+
+ void createNextTask(String orderNo, RetryActionEnum nextAction) throws Exception;
+}
diff --git a/src/main/java/com/project/logistics/domain/service/base/ApiRetryTaskServiceImpl.java b/src/main/java/com/project/logistics/domain/service/base/ApiRetryTaskServiceImpl.java
new file mode 100644
index 0000000..95a801c
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/service/base/ApiRetryTaskServiceImpl.java
@@ -0,0 +1,63 @@
+package com.project.logistics.domain.service.base;
+
+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.mapper.ApiRetryTaskMapper;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Date;
+import java.util.List;
+
+
+@Service
+@Slf4j
+public class ApiRetryTaskServiceImpl extends ServiceImpl
+ implements ApiRetryTaskService {
+
+ @Override
+ public List listPendingTasks() throws Exception {
+ return this.lambdaQuery()
+ .in(ApiRetryTaskEntity::getTaskStatus ,
+ List.of(TaskStatusEnum.PENDING.getCode() ,
+ TaskStatusEnum.FAILED.getCode()))
+ .le(ApiRetryTaskEntity::getNextExecuteTime, new Date())
+ .list();
+ }
+ /**
+ * 创建下一个任务节点
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void createNextTask(String orderNo, RetryActionEnum nextAction) {
+
+ // 1. 简单幂等检查:如果该单号已经存在【相同动作】且处于【未完成】状态的任务,不再重复创建
+ Long count = this.lambdaQuery()
+ .eq(ApiRetryTaskEntity::getOrderNo, orderNo)
+ .eq(ApiRetryTaskEntity::getActionCode, nextAction.getCode())
+ .in(ApiRetryTaskEntity::getTaskStatus, TaskStatusEnum.PENDING.getCode(), TaskStatusEnum.EXECUTING.getCode())
+ .count();
+
+ if (count > 0) {
+ log.warn(">>> 任务 [{}] 已存在且尚未完成,跳过重复创建, 单号: {}", nextAction.getCode(), orderNo);
+ return;
+ }
+
+ // 2. 构造新任务
+ ApiRetryTaskEntity nextTask = new ApiRetryTaskEntity();
+ nextTask.setOrderNo(orderNo);
+ nextTask.setActionCode(nextAction.getCode());
+ nextTask.setTaskStatus(TaskStatusEnum.PENDING.getCode());
+
+ nextTask.setRetryCount(0); // 初始重试次数为0
+ nextTask.setMaxRetries(5); // 默认最大重试5次
+ nextTask.setNextExecuteTime(new Date()); // 设置为当前时间,意味着下个10秒轮询就能扫到它
+
+ this.save(nextTask);
+ log.info(">>> 成功生成下一阶段任务: [{}], 单号: {}", nextAction.getCode(), orderNo);
+ }
+
+}
diff --git a/src/main/java/com/project/logistics/domain/service/base/ErpService.java b/src/main/java/com/project/logistics/domain/service/base/ErpService.java
new file mode 100644
index 0000000..55382b5
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/service/base/ErpService.java
@@ -0,0 +1,9 @@
+package com.project.logistics.domain.service.base;
+
+import cn.hutool.json.JSONObject;
+
+public interface ErpService {
+
+
+ JSONObject getShipmentOrderInfo(String orderNo) throws Exception;
+}
diff --git a/src/main/java/com/project/logistics/domain/service/base/ErpServiceImpl.java b/src/main/java/com/project/logistics/domain/service/base/ErpServiceImpl.java
new file mode 100644
index 0000000..f80508b
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/service/base/ErpServiceImpl.java
@@ -0,0 +1,38 @@
+package com.project.logistics.domain.service.base;
+
+import cn.hutool.json.JSONObject;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Qualifier;
+import org.springframework.jdbc.core.JdbcTemplate;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+
+@Service
+@Slf4j
+public class ErpServiceImpl implements ErpService {
+
+ // 关键点:通过 Qualifier 明确指定使用 U9 的那套 JdbcTemplate
+ @Autowired
+ @Qualifier("u9JdbcTemplate")
+ private JdbcTemplate u9JdbcTemplate;
+
+ @Override
+ public JSONObject getShipmentOrderInfo(String orderNo) throws Exception {
+ log.info(">>> 正在通过专属 U9 管道查询单据: {}", orderNo);
+
+ // 这里不再需要 @DS 注解,也不需要 push/poll,因为 u9JdbcTemplate 内部绑定的就是 SQL Server
+ String sql = "SELECT TOP 1 * FROM SM_Ship WITH(NOLOCK) WHERE DocNo = ?";
+
+ try {
+ Map result = u9JdbcTemplate.queryForMap(sql, orderNo);
+ JSONObject json = new JSONObject(result);
+ log.info(">>> [U9查询成功]: \n{}", json.toString());
+ return json;
+ } catch (Exception e) {
+ log.error(">>> [U9查询失败]: {}", e.getMessage());
+ throw e;
+ }
+ }
+}
diff --git a/src/main/java/com/project/logistics/domain/service/base/LogisticsOrderService.java b/src/main/java/com/project/logistics/domain/service/base/LogisticsOrderService.java
new file mode 100644
index 0000000..e177986
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/service/base/LogisticsOrderService.java
@@ -0,0 +1,16 @@
+package com.project.logistics.domain.service.base;
+
+import cn.hutool.json.JSONObject;
+import com.baomidou.mybatisplus.extension.service.IService;
+import com.project.logistics.domain.entity.LogisticsOrderEntity;
+
+
+public interface LogisticsOrderService extends IService {
+
+ Boolean existsByOrderNo(String orderNo) throws Exception;
+
+ void createOrderAndTask(String orderNo, JSONObject u9Data, String fullPdfPath);
+
+
+ LogisticsOrderEntity getByOrderNo(String orderNo) throws Exception;
+}
diff --git a/src/main/java/com/project/logistics/domain/service/base/LogisticsOrderServiceImpl.java b/src/main/java/com/project/logistics/domain/service/base/LogisticsOrderServiceImpl.java
new file mode 100644
index 0000000..e1e3dfc
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/service/base/LogisticsOrderServiceImpl.java
@@ -0,0 +1,68 @@
+package com.project.logistics.domain.service.base;
+
+import cn.hutool.json.JSONObject;
+import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl;
+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.OrderTypeEnum;
+import com.project.logistics.domain.enums.RetryActionEnum;
+import com.project.logistics.domain.enums.TaskStatusEnum;
+import com.project.logistics.mapper.LogisticsOrderMapper;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Date;
+import java.util.Objects;
+
+@Service
+public class LogisticsOrderServiceImpl extends ServiceImpl
+ implements LogisticsOrderService {
+
+ @Autowired
+ private ApiRetryTaskService apiRetryTaskService;
+
+
+ @Override
+ public Boolean existsByOrderNo(String orderNo) throws Exception {
+ LogisticsOrderEntity one = this.lambdaQuery()
+ .eq(LogisticsOrderEntity::getOrderNo, orderNo).last("limit 1")
+ .one();
+ return Objects.nonNull(one);
+ }
+
+ /**
+ * @param fullPdfPath 完整的 WebDAV 相对路径 (如: 常规出货单/2026年/3月/单号.pdf)
+ */
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void createOrderAndTask(String orderNo, JSONObject u9Data, String fullPdfPath) {
+ // 1. 持久化本地物流订单主表
+ LogisticsOrderEntity order = new LogisticsOrderEntity();
+ order.setOrderNo(orderNo);
+ order.setOrderType(OrderTypeEnum.SHIPMENT.getCode());
+ order.setOrderStatus(OrderStatusEnum.INIT.getCode());
+ order.setOrderInfo(u9Data.toString());
+ // 关键:直接存储完整的通用路径
+ order.setOriginalPdfPath(fullPdfPath);
+ this.save(order);
+
+ // 2. 初始化第一个异步重试任务 (SF_UPLOAD_RESOURCE)
+ ApiRetryTaskEntity task = new ApiRetryTaskEntity();
+ task.setOrderNo(orderNo);
+ task.setActionCode(RetryActionEnum.SF_UPLOAD_RESOURCE.getCode());
+ task.setTaskStatus(TaskStatusEnum.PENDING.getCode());
+ task.setRetryCount(0);
+ task.setMaxRetries(5);
+ task.setNextExecuteTime(new Date());
+ apiRetryTaskService.save(task);
+ }
+
+ @Override
+ public LogisticsOrderEntity getByOrderNo(String orderNo) throws Exception {
+ return this.lambdaQuery()
+ .eq(LogisticsOrderEntity::getOrderNo, orderNo).last("limit 1")
+ .one();
+ }
+}
diff --git a/src/main/java/com/project/logistics/domain/strategy/ApiTaskHandler.java b/src/main/java/com/project/logistics/domain/strategy/ApiTaskHandler.java
new file mode 100644
index 0000000..b4b14aa
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/strategy/ApiTaskHandler.java
@@ -0,0 +1,20 @@
+package com.project.logistics.domain.strategy;
+
+import com.project.logistics.domain.entity.ApiRetryTaskEntity;
+import com.project.logistics.domain.entity.LogisticsOrderEntity;
+
+/**
+ * 异步任务处理器统一接口
+ */
+public interface ApiTaskHandler {
+
+ /**
+ * 告诉引擎:我这个工人专门处理哪种任务?
+ */
+ String getActionCode();
+
+ /**
+ * 具体的业务处理逻辑
+ */
+ void handle(ApiRetryTaskEntity task, LogisticsOrderEntity order) throws Exception;
+}
\ No newline at end of file
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
new file mode 100644
index 0000000..dfba94e
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/strategy/handler/SfCreateOrderHandler.java
@@ -0,0 +1,136 @@
+package com.project.logistics.domain.strategy.handler;
+
+import cn.hutool.json.JSONUtil;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.jayway.jsonpath.JsonPath;
+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.WebDavService;
+import com.project.logistics.domain.service.base.ApiRetryTaskService;
+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;
+import org.springframework.transaction.annotation.Transactional;
+
+@Slf4j
+@Component
+public class SfCreateOrderHandler implements ApiTaskHandler {
+
+ @Autowired
+ private SfApiService sfApiService;
+ @Autowired
+ private LogisticsOrderService logisticsOrderService;
+ @Autowired
+ private ApiRetryTaskService apiRetryTaskService;
+ @Autowired
+ private WebDavService webDavService;
+
+ @Override
+ public String getActionCode() {
+ return RetryActionEnum.SF_CREATE_ORDER.getCode();
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void handle(ApiRetryTaskEntity task, LogisticsOrderEntity order) throws Exception {
+ log.info(">>> 开始执行顺丰正式下单,单号: {}", order.getOrderNo());
+
+ // 1. 从 order_info 快照中解析出 U9 的原始数据
+ JSONObject u9Data = JSON.parseObject(order.getOrderInfo());
+
+ // 2. 构造顺丰下单报文 (参考 PDF 第 2-10 页)
+ JSONObject msgData = new JSONObject();
+ msgData.put("language", "zh-CN");
+ msgData.put("orderId", order.getOrderNo()); // 客户订单号
+
+ // 2.1 构造收寄双方信息 (根据你之前 SELECT * 看到的 U9 字段名来取值)
+ JSONArray contactInfoList = new JSONArray();
+
+ // 寄件方 (通常写死公司信息,或从配置取)
+ JSONObject sender = new JSONObject();
+ sender.put("contactType", 1);
+ sender.put("contact", "您的公司名");
+ sender.put("mobile", "13800000000");
+ sender.put("address", "公司详细地址");
+ contactInfoList.add(sender);
+
+ // 收件方 (从 U9 快照中动态获取)
+ JSONObject receiver = new JSONObject();
+ receiver.put("contactType", 2);
+ receiver.put("contact", u9Data.getString("Receiver_Contact")); // 假设 U9 字段名是这个
+ receiver.put("mobile", u9Data.getString("Receiver_Phone"));
+ receiver.put("province", u9Data.getString("Receiver_Province"));
+ receiver.put("city", u9Data.getString("Receiver_City"));
+ receiver.put("address", u9Data.getString("Receiver_Address"));
+ contactInfoList.add(receiver);
+
+ msgData.put("contactInfoList", contactInfoList);
+
+ // 2.2 货物信息与支付方式
+ msgData.put("monthlyCard", u9Data.getString("MonthlyCard")); // 月结卡号
+ msgData.put("payMethod", u9Data.getInteger("PayMethod")); // 付款方式
+ msgData.put("expressTypeId", "1"); // 标准快递
+
+ // 2.3 【关键步骤】绑定电子回单增值服务
+ if (order.getResourceCode() != null) {
+ JSONArray serviceList = new JSONArray();
+ JSONObject receiptService = new JSONObject();
+ receiptService.put("name", "IN149"); // 电子回单服务代码
+ receiptService.put("value", order.getResourceCode());
+ serviceList.add(receiptService);
+ msgData.put("serviceList", serviceList);
+ }
+
+ // 3. 发起下单请求
+ String result = sfApiService.callSfApi("EXP_RECE_CREATE_ORDER", msgData, null);
+
+ // 4. 解析结果 (使用 JsonPath)
+ try {
+ String apiCode = JsonPath.read(result, "$.apiResultCode");
+ if (!"A1000".equals(apiCode)) {
+ throw new RuntimeException("顺丰下单网关失败: " + JsonPath.read(result, "$.apiErrorMsg"));
+ }
+
+ // 解析内层业务逻辑
+ String innerJsonStr = JsonPath.read(result, "$.apiResultData");
+ boolean success = JsonPath.read(innerJsonStr, "$.success");
+
+ if (success) {
+ // 下单成功!提取顺丰运单号
+ String waybillNo = JsonPath.read(innerJsonStr, "$.msgData.waybillNoInfoList[0].waybillNo");
+ log.info(">>> 顺丰下单成功!运单号: {}", waybillNo);
+
+ // 5. 更新主表状态
+ order.setSfWaybillNo(waybillNo);
+ order.setOrderStatus(OrderStatusEnum.ORDER_SUBMITTED.getCode());
+ logisticsOrderService.updateById(order);
+
+ // 6. 【业务要求】下单成功后,将 WebDAV 上的文件移走
+ try {
+ if (order.getOriginalPdfPath() != null) {
+ webDavService.moveFileToProcessed(order.getOriginalPdfPath());
+ }
+ } catch (Exception e) {
+ log.error(">>> 顺丰下单成功但文件移位失败: {}", e.getMessage());
+ }
+
+ // 7. 触发下一个任务:回写 U9 运单号
+ apiRetryTaskService.createNextTask(order.getOrderNo(), RetryActionEnum.ERP_UPDATE_WAYBILL);
+
+ } else {
+ String errorMsg = JsonPath.read(innerJsonStr, "$.errorMsg");
+ throw new RuntimeException("顺丰业务逻辑失败: " + errorMsg);
+ }
+ } catch (Exception e) {
+ log.error(">>> 下单报文解析异常: {}", result);
+ throw e;
+ }
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/logistics/domain/strategy/handler/SfUploadResourceHandler.java b/src/main/java/com/project/logistics/domain/strategy/handler/SfUploadResourceHandler.java
new file mode 100644
index 0000000..4c941ff
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/strategy/handler/SfUploadResourceHandler.java
@@ -0,0 +1,82 @@
+package com.project.logistics.domain.strategy.handler;
+
+import com.jayway.jsonpath.JsonPath;
+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.enums.TaskStatusEnum;
+import com.project.logistics.domain.service.SfApiService;
+import com.project.logistics.domain.service.WebDavService;
+import com.project.logistics.domain.service.base.ApiRetryTaskService;
+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;
+import org.springframework.transaction.annotation.Transactional;
+
+import java.util.Date;
+
+@Slf4j
+@Component
+public class SfUploadResourceHandler implements ApiTaskHandler {
+
+ @Autowired
+ private WebDavService webDavService;
+ @Autowired
+ private LogisticsOrderService logisticsOrderService;
+ @Autowired
+ private ApiRetryTaskService apiRetryTaskService;
+
+ @Autowired
+ private SfApiService sfApiService; // 你的顺丰 API 调用服务
+
+ @Override
+ public String getActionCode() {
+ return RetryActionEnum.SF_UPLOAD_RESOURCE.getCode();
+ }
+
+ @Override
+ @Transactional(rollbackFor = Exception.class)
+ public void handle(ApiRetryTaskEntity task, LogisticsOrderEntity order) throws Exception {
+ // 1. 下载并上传(之前的三步走流程)
+ byte[] pdfBytes = webDavService.downloadFile(order.getOriginalPdfPath());
+ String finalResult = sfApiService.uploadResourceFlow(pdfBytes, order.getOrderNo() + ".pdf");
+ try {
+ // --- 第一步:校验外层状态 ---
+ String apiCode = JsonPath.read(finalResult, "$.apiResultCode");
+ if (!"A1000".equals(apiCode)) {
+ throw new RuntimeException("顺丰平台异常: " + JsonPath.read(finalResult, "$.apiErrorMsg"));
+ }
+
+ // --- 第二步:提取 apiResultData 字符串 ---
+ // 注意:这里拿出来的是那串带斜杠的 JSON 字符串
+ String innerJsonStr = JsonPath.read(finalResult, "$.apiResultData");
+
+ // --- 第三步:对提取出来的字符串再次进行 JsonPath 解析 ---
+ Integer code = JsonPath.read(innerJsonStr, "$.code");
+ if (code == null || code != 0) {
+ String msg = JsonPath.read(innerJsonStr, "$.message");
+ throw new RuntimeException("资源创建失败: " + msg);
+ }
+
+ // 提取最终的 resourceCode
+ String resourceCode = JsonPath.read(innerJsonStr, "$.data.resourceCode");
+
+ // --- 第四步:后续业务逻辑 ---
+ log.info(">>> 解析成功,获取到资源编码: {}", resourceCode);
+
+ order.setResourceCode(resourceCode);
+ order.setOrderStatus(OrderStatusEnum.RESOURCE_CREATED.getCode());
+ logisticsOrderService.updateById(order);
+
+ apiRetryTaskService.createNextTask(order.getOrderNo(), RetryActionEnum.SF_CREATE_ORDER);
+
+ } catch (Exception e) {
+ log.error(">>> 解析顺丰报文失败,单号: {}, 原报文: {}", order.getOrderNo(), finalResult);
+ throw e; // 抛出异常触发重试
+ }
+
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/logistics/domain/utils/FilePathUtil.java b/src/main/java/com/project/logistics/domain/utils/FilePathUtil.java
new file mode 100644
index 0000000..f5ab7ce
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/utils/FilePathUtil.java
@@ -0,0 +1,18 @@
+package com.project.logistics.domain.utils;
+
+import cn.hutool.core.date.DateUtil;
+import java.util.Date;
+
+public class FilePathUtil {
+
+ /**
+ * 生成层级路径: 2026年/2026年1月/2026年1月31日
+ */
+ public static String getHierarchicalPath(Date date) {
+ String year = DateUtil.format(date, "yyyy年");
+ String month = DateUtil.format(date, "yyyy年M月");
+ String day = DateUtil.format(date, "yyyy年M月d日");
+
+ return year + "/" + month + "/" + day;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/logistics/domain/utils/SfTokenUtil.java b/src/main/java/com/project/logistics/domain/utils/SfTokenUtil.java
new file mode 100644
index 0000000..8e2deec
--- /dev/null
+++ b/src/main/java/com/project/logistics/domain/utils/SfTokenUtil.java
@@ -0,0 +1,97 @@
+package com.project.logistics.domain.utils;
+
+import com.project.logistics.config.SfApiProperties;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.*;
+import org.springframework.stereotype.Component;
+import org.springframework.util.LinkedMultiValueMap;
+import org.springframework.util.MultiValueMap;
+import org.springframework.web.client.RestTemplate;
+
+import java.util.concurrent.locks.ReentrantLock;
+
+@Slf4j
+@Component
+public class SfTokenUtil {
+
+ @Autowired
+ private SfApiProperties sfApiProperties;
+
+ private final RestTemplate restTemplate = new RestTemplate();
+ private final ReentrantLock lock = new ReentrantLock();
+
+ // 本地缓存
+ private String cachedToken;
+ private long expiryTimestamp = 0L;
+
+ /**
+ * 获取可用的 AccessToken
+ */
+ public String getAccessToken() {
+ // 提前60秒判定过期
+ if (cachedToken != null && System.currentTimeMillis() < expiryTimestamp - 60000) {
+ return cachedToken;
+ }
+
+ lock.lock();
+ try {
+ if (cachedToken != null && System.currentTimeMillis() < expiryTimestamp - 60000) {
+ return cachedToken;
+ }
+ return refreshToken();
+ } finally {
+ lock.unlock();
+ }
+ }
+
+ private String refreshToken() {
+ log.info("开始向顺丰申请新的 AccessToken...");
+
+ HttpHeaders headers = new HttpHeaders();
+ headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
+
+ MultiValueMap map = new LinkedMultiValueMap<>();
+ map.add("partnerID", sfApiProperties.getPartnerId());
+ map.add("secret", sfApiProperties.getSecret());
+ map.add("grantType", sfApiProperties.getGrantType());
+
+ HttpEntity> request = new HttpEntity<>(map, headers);
+
+ try {
+ ResponseEntity responseEntity = restTemplate.postForEntity(
+ sfApiProperties.getTokenUrl(),
+ request,
+ SfTokenResponse.class
+ );
+ SfTokenResponse body = responseEntity.getBody();
+
+ if (body != null && "A1000".equals(body.getApiResultCode())) {
+ this.cachedToken = body.getAccessToken();
+ // 默认 7200 秒,转换为毫秒时间戳
+ this.expiryTimestamp = System.currentTimeMillis() + (body.getExpiresIn() * 1000);
+ log.info("AccessToken 刷新成功,有效期至: {}", new java.util.Date(this.expiryTimestamp));
+ return this.cachedToken;
+ } else {
+ String errorMsg = body != null ? body.getApiErrorMsg() : "Empty Body";
+ log.error("获取顺丰 Token 失败: {}", errorMsg);
+ throw new RuntimeException("SF_TOKEN_ERROR: " + errorMsg);
+ }
+ } catch (Exception e) {
+ log.error("调用顺丰 Token 接口发生网络异常", e);
+ throw new RuntimeException("SF_TOKEN_NETWORK_ERROR", e);
+ }
+ }
+ /**
+ * 内部专用的 Token 响应模型
+ */
+ @Data
+ private static class SfTokenResponse {
+ private String apiResultCode; // 响应代码,A1000为成功
+ private String apiErrorMsg; // 错误简述
+ private String apiResponseID; // 响应ID
+ private String accessToken; // 访问令牌
+ private Long expiresIn; // 有效期(秒)
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/logistics/mapper/ApiRetryTaskMapper.java b/src/main/java/com/project/logistics/mapper/ApiRetryTaskMapper.java
new file mode 100644
index 0000000..31b8020
--- /dev/null
+++ b/src/main/java/com/project/logistics/mapper/ApiRetryTaskMapper.java
@@ -0,0 +1,9 @@
+package com.project.logistics.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.project.logistics.domain.entity.ApiRetryTaskEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface ApiRetryTaskMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/project/logistics/mapper/LogisticsOrderMapper.java b/src/main/java/com/project/logistics/mapper/LogisticsOrderMapper.java
new file mode 100644
index 0000000..60f0345
--- /dev/null
+++ b/src/main/java/com/project/logistics/mapper/LogisticsOrderMapper.java
@@ -0,0 +1,9 @@
+package com.project.logistics.mapper;
+
+import com.baomidou.mybatisplus.core.mapper.BaseMapper;
+import com.project.logistics.domain.entity.LogisticsOrderEntity;
+import org.apache.ibatis.annotations.Mapper;
+
+@Mapper
+public interface LogisticsOrderMapper extends BaseMapper {
+}
diff --git a/src/main/java/com/project/logistics/repository/JpaTriggerRepository.java b/src/main/java/com/project/logistics/repository/JpaTriggerRepository.java
new file mode 100644
index 0000000..6bbb6c9
--- /dev/null
+++ b/src/main/java/com/project/logistics/repository/JpaTriggerRepository.java
@@ -0,0 +1,10 @@
+package com.project.logistics.repository;
+
+import com.project.logistics.domain.entity.LogisticsOrderEntity;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.stereotype.Repository;
+
+@Repository
+public interface JpaTriggerRepository extends JpaRepository {
+ // 这个接口哪怕你一个方法都不写,放在这里就能激活 Hibernate 的建表逻辑
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/receive/controller/ReceiveController.java b/src/main/java/com/project/receive/controller/ReceiveController.java
new file mode 100644
index 0000000..87fcae6
--- /dev/null
+++ b/src/main/java/com/project/receive/controller/ReceiveController.java
@@ -0,0 +1,162 @@
+package com.project.receive.controller;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.project.receive.dto.*;
+import com.project.receive.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 ReceiveController {
+
+ 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;
+ }
+ }
+}
diff --git a/src/main/java/com/project/receive/dto/FeeInfo.java b/src/main/java/com/project/receive/dto/FeeInfo.java
new file mode 100644
index 0000000..c4cfe17
--- /dev/null
+++ b/src/main/java/com/project/receive/dto/FeeInfo.java
@@ -0,0 +1,15 @@
+package com.project.receive.dto;
+
+import lombok.Data;
+
+/**
+ * 费用明细
+ */
+@Data
+public class FeeInfo {
+ private String feeAmt; // 金额
+ private String feeTypeCode; // 费用类型代码 (1:运费, 2:其他费用...)
+ private String currencyCode; // 币别
+ private String bizOwnerZoneCode; // 业务所属地区编码
+ // ... 其他字段可以按需添加
+}
diff --git a/src/main/java/com/project/receive/dto/OrderStateDetail.java b/src/main/java/com/project/receive/dto/OrderStateDetail.java
new file mode 100644
index 0000000..5eb2f58
--- /dev/null
+++ b/src/main/java/com/project/receive/dto/OrderStateDetail.java
@@ -0,0 +1,18 @@
+package com.project.receive.dto;
+
+import lombok.Data;
+
+@Data
+public class OrderStateDetail {
+ private String orderNo;
+ private String waybillNo;
+ private String orderStateCode;
+ private String orderStateDesc;
+ private String empCode;
+ private String empPhone;
+ private String netCode;
+ private String lastTime;
+ private String bookTime;
+ private String carrierCode;
+ private String createTm;
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/receive/dto/RouteBody.java b/src/main/java/com/project/receive/dto/RouteBody.java
new file mode 100644
index 0000000..bdd4f1d
--- /dev/null
+++ b/src/main/java/com/project/receive/dto/RouteBody.java
@@ -0,0 +1,12 @@
+package com.project.receive.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class RouteBody {
+ @JsonProperty("WaybillRoute")
+ private List waybillRoute;
+}
diff --git a/src/main/java/com/project/receive/dto/SfFeeContent.java b/src/main/java/com/project/receive/dto/SfFeeContent.java
new file mode 100644
index 0000000..6faf1b6
--- /dev/null
+++ b/src/main/java/com/project/receive/dto/SfFeeContent.java
@@ -0,0 +1,19 @@
+package com.project.receive.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class SfFeeContent {
+ private String orderNo; // 客户订单号
+ private String waybillNo; // 顺丰运单号
+ private String childNos; // 子单号
+ private String customerAcctCode; // 月结账号
+ private String meterageWeightQty; // 计费重量
+ private String realWeightQty; // 实际重量
+ private String productName; // 产品名称
+ private String quantity; // 托寄物数量
+ private String volume; // 体积
+ private List feeList; // 费用项列表
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/receive/dto/SfFeeResponse.java b/src/main/java/com/project/receive/dto/SfFeeResponse.java
new file mode 100644
index 0000000..be3dfa9
--- /dev/null
+++ b/src/main/java/com/project/receive/dto/SfFeeResponse.java
@@ -0,0 +1,20 @@
+package com.project.receive.dto;
+
+import lombok.Data;
+
+@Data
+public class SfFeeResponse {
+ private int code; // 200成功,400失败
+ private String partnerCode;
+ private String service;
+ private String message;
+
+ public static SfFeeResponse ok(String partnerCode) {
+ SfFeeResponse res = new SfFeeResponse();
+ res.setCode(200);
+ res.setPartnerCode(partnerCode);
+ res.setService("");
+ res.setMessage("");
+ return res;
+ }
+}
diff --git a/src/main/java/com/project/receive/dto/SfInnerContent.java b/src/main/java/com/project/receive/dto/SfInnerContent.java
new file mode 100644
index 0000000..2d49eeb
--- /dev/null
+++ b/src/main/java/com/project/receive/dto/SfInnerContent.java
@@ -0,0 +1,10 @@
+package com.project.receive.dto;
+
+import lombok.Data;
+
+@Data
+public class SfInnerContent {
+ private String waybillNo;
+ private String companyLogo;
+ private String content; // 这里才是真正的文件 Base64 数据
+}
diff --git a/src/main/java/com/project/receive/dto/SfPicturePushRequest.java b/src/main/java/com/project/receive/dto/SfPicturePushRequest.java
new file mode 100644
index 0000000..5e9267d
--- /dev/null
+++ b/src/main/java/com/project/receive/dto/SfPicturePushRequest.java
@@ -0,0 +1,16 @@
+package com.project.receive.dto;
+
+import lombok.Data;
+
+@Data
+public class SfPicturePushRequest {
+ // 企业标识,默认 SF
+ private String companyLogo;
+
+ // 顺丰运单号
+ private String waybillNo;
+
+ // 关键字段:加密后的图片信息内容
+ // 这一段是 Base64 编码的 AES 密文,解密后是一个内部 JSON 字符串
+ private String content;
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/receive/dto/SfPicturePushResponse.java b/src/main/java/com/project/receive/dto/SfPicturePushResponse.java
new file mode 100644
index 0000000..eacd394
--- /dev/null
+++ b/src/main/java/com/project/receive/dto/SfPicturePushResponse.java
@@ -0,0 +1,36 @@
+package com.project.receive.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class SfPicturePushResponse {
+
+ // 顺丰要求字段名为 return_code
+ @JsonProperty("return_code")
+ private String returnCode; // 0000 表示成功,1000 表示失败
+
+ // 顺丰要求字段名为 return_msg
+ @JsonProperty("return_msg")
+ private String returnMsg;
+
+ /**
+ * 快捷生成成功响应
+ */
+ public static SfPicturePushResponse ok() {
+ SfPicturePushResponse res = new SfPicturePushResponse();
+ res.setReturnCode("0000");
+ res.setReturnMsg("成功");
+ return res;
+ }
+
+ /**
+ * 快捷生成失败响应
+ */
+ public static SfPicturePushResponse fail(String message) {
+ SfPicturePushResponse res = new SfPicturePushResponse();
+ res.setReturnCode("1000");
+ res.setReturnMsg(message);
+ return res;
+ }
+}
\ No newline at end of file
diff --git a/src/main/java/com/project/receive/dto/SfPushRequest.java b/src/main/java/com/project/receive/dto/SfPushRequest.java
new file mode 100644
index 0000000..e4c857e
--- /dev/null
+++ b/src/main/java/com/project/receive/dto/SfPushRequest.java
@@ -0,0 +1,13 @@
+package com.project.receive.dto;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class SfPushRequest {
+ private String requestId;
+ private String timestamp;
+ // 文档显示 orderState 是个数组
+ private List orderState;
+}
diff --git a/src/main/java/com/project/receive/dto/SfPushResponse.java b/src/main/java/com/project/receive/dto/SfPushResponse.java
new file mode 100644
index 0000000..f8f2801
--- /dev/null
+++ b/src/main/java/com/project/receive/dto/SfPushResponse.java
@@ -0,0 +1,18 @@
+package com.project.receive.dto;
+
+import lombok.Data;
+
+@Data
+public class SfPushResponse {
+ private String success;
+ private String code;
+ private String msg;
+
+ public static SfPushResponse ok() {
+ SfPushResponse res = new SfPushResponse();
+ res.setSuccess("true");
+ res.setCode("0");
+ res.setMsg("");
+ return res;
+ }
+}
diff --git a/src/main/java/com/project/receive/dto/SfRoutePushRequest.java b/src/main/java/com/project/receive/dto/SfRoutePushRequest.java
new file mode 100644
index 0000000..fb91a07
--- /dev/null
+++ b/src/main/java/com/project/receive/dto/SfRoutePushRequest.java
@@ -0,0 +1,10 @@
+package com.project.receive.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class SfRoutePushRequest {
+ @JsonProperty("Body")
+ private RouteBody body;
+}
diff --git a/src/main/java/com/project/receive/dto/SfRouteResponse.java b/src/main/java/com/project/receive/dto/SfRouteResponse.java
new file mode 100644
index 0000000..72ae18a
--- /dev/null
+++ b/src/main/java/com/project/receive/dto/SfRouteResponse.java
@@ -0,0 +1,19 @@
+package com.project.receive.dto;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+import lombok.Data;
+
+@Data
+public class SfRouteResponse {
+ @JsonProperty("return_code")
+ private String returnCode;
+ @JsonProperty("return_msg")
+ private String returnMsg;
+
+ public static SfRouteResponse ok() {
+ SfRouteResponse res = new SfRouteResponse();
+ res.setReturnCode("0000");
+ res.setReturnMsg("成功");
+ return res;
+ }
+}
diff --git a/src/main/java/com/project/receive/dto/WaybillRoute.java b/src/main/java/com/project/receive/dto/WaybillRoute.java
new file mode 100644
index 0000000..bf98fa6
--- /dev/null
+++ b/src/main/java/com/project/receive/dto/WaybillRoute.java
@@ -0,0 +1,20 @@
+package com.project.receive.dto;
+
+import lombok.Data;
+
+@Data
+public class WaybillRoute {
+ private String mailno; // 运单号
+ private String orderid; // 客户订单号
+ private String acceptAddress; // 收货地址
+ private String acceptTime; // 收货时间
+ private String remark; // 备注
+ private String opCode; // 操作码
+ private String id; // ID
+ private String reasonName; // 异常描述
+ private String reasonCode; // 异常编码
+ private String firstStatusCode; // 一级状态码
+ private String firstStatusName; // 一级状态描述
+ private String secondaryStatusCode; // 二级状态码
+ private String secondaryStatusName; // 二级状态描述
+}
diff --git a/src/main/java/com/project/receive/utils/SfDecryptUtil.java b/src/main/java/com/project/receive/utils/SfDecryptUtil.java
new file mode 100644
index 0000000..98b6cb0
--- /dev/null
+++ b/src/main/java/com/project/receive/utils/SfDecryptUtil.java
@@ -0,0 +1,23 @@
+package com.project.receive.utils;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.util.Base64;
+
+public class SfDecryptUtil {
+ private static final byte[] IV_BYTES = {0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0};
+
+ public static byte[] decrypt(String encryptedData, String secretKey) throws Exception {
+ // 顺丰推送的密文可能是 Base64 编码的字符串
+ byte[] decode = Base64.getDecoder().decode(encryptedData);
+
+ SecretKeySpec keySpec = new SecretKeySpec(secretKey.getBytes("UTF-8"), "AES");
+ IvParameterSpec ivSpec = new IvParameterSpec(IV_BYTES);
+
+ Cipher cipher = Cipher.getInstance("AES/CBC/PKCS5Padding");
+ cipher.init(Cipher.DECRYPT_MODE, keySpec, ivSpec);
+
+ return cipher.doFinal(decode);
+ }
+}
\ No newline at end of file
diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml
new file mode 100644
index 0000000..dc076ea
--- /dev/null
+++ b/src/main/resources/application.yml
@@ -0,0 +1,95 @@
+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?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: 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: ZXmoWOQdSd2UTBmSP6Kv3VW9Q4N5dJqz
+ tokenUrl: https://sfapi-sbox.sf-express.com/oauth2/accessToken
+ baseUrl: https://sfapi-sbox.sf-express.com/std/service
+ channelCode: MCS-CAS-API-BOX
+mybatis-plus:
+ configuration:
+ log-impl: org.apache.ibatis.logging.stdout.StdOutImpl # 开发环境下打印SQL
+ 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: true
+ # 每 10 分钟唤醒一次 (系统开销微乎其微)
+ cron: "0 0/10 * * * ?"
+ windows:
+ # 这个窗口设置10分钟有效期,配合10分钟一次的唤醒,只会跑一次
+ - name: "上午11点班次"
+ startTime: "11:00"
+ endTime: "11:10"
+ intervalMinutes: 60
+ # 这个窗口在15-20点之间,每隔20分钟会真正跑一次逻辑
+ - name: "下午至傍晚波次"
+ startTime: "15:00"
+ endTime: "20:00"
+ 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
\ No newline at end of file
diff --git a/src/test/java/com/project/logistics/domain/scheduler/WebDavScannerJobTest.java b/src/test/java/com/project/logistics/domain/scheduler/WebDavScannerJobTest.java
new file mode 100644
index 0000000..5079cd9
--- /dev/null
+++ b/src/test/java/com/project/logistics/domain/scheduler/WebDavScannerJobTest.java
@@ -0,0 +1,17 @@
+package com.project.logistics.domain.scheduler;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+
+
+@SpringBootTest
+public class WebDavScannerJobTest {
+
+ @Autowired
+ private WebDavScannerJob webDavScannerJob;
+ @Test
+ public void test() {
+ webDavScannerJob.doScanWork();
+ }
+}
\ No newline at end of file