luoweijian 4 weeks ago
commit
bef831212f
  1. 38
      .gitignore
  2. 8
      .idea/.gitignore
  3. 7
      .idea/encodings.xml
  4. 14
      .idea/misc.xml
  5. 124
      .idea/uiDesigner.xml
  6. 6
      .idea/vcs.xml
  7. 190
      pom.xml
  8. 21
      src/main/java/com/project/AutoLogisticsApplication.java
  9. 21
      src/main/java/com/project/base/config/CorsConfig.java
  10. 39
      src/main/java/com/project/base/config/CustomIdGenerator.java
  11. 72
      src/main/java/com/project/base/config/InsertBatchOnDuplicateKeyUpdate.java
  12. 28
      src/main/java/com/project/base/config/MyMetaObjectHandler.java
  13. 31
      src/main/java/com/project/base/config/MybatisPlusConfig.java
  14. 14
      src/main/java/com/project/base/config/ScheduledTaskProperties.java
  15. 185
      src/main/java/com/project/base/config/SnowflakeIdWorker.java
  16. 111
      src/main/java/com/project/base/domain/advice/GlobalExceptionHandlerAdvice.java
  17. 33
      src/main/java/com/project/base/domain/dto/BaseDTO.java
  18. 55
      src/main/java/com/project/base/domain/entity/BaseEntity.java
  19. 24
      src/main/java/com/project/base/domain/enums/HasValueEnum.java
  20. 17
      src/main/java/com/project/base/domain/enums/StatusEnum.java
  21. 21
      src/main/java/com/project/base/domain/exception/BusinessErrorException.java
  22. 21
      src/main/java/com/project/base/domain/exception/MissingParameterException.java
  23. 25
      src/main/java/com/project/base/domain/exception/PermissionErrorException.java
  24. 21
      src/main/java/com/project/base/domain/exception/ResourceNotExistException.java
  25. 10
      src/main/java/com/project/base/domain/param/BaseParam.java
  26. 24
      src/main/java/com/project/base/domain/result/PageResult.java
  27. 70
      src/main/java/com/project/base/domain/result/Result.java
  28. 43
      src/main/java/com/project/base/domain/result/ResultCodeEnum.java
  29. 41
      src/main/java/com/project/base/domain/service/IBaseService.java
  30. 40
      src/main/java/com/project/base/domain/utils/ExcelUtil.java
  31. 14
      src/main/java/com/project/base/domain/utils/PageConverter.java
  32. 65
      src/main/java/com/project/base/domain/utils/ServletUtils.java
  33. 199
      src/main/java/com/project/base/domain/utils/TreeUtils.java
  34. 13
      src/main/java/com/project/base/mapper/BatchUpsertMapper.java
  35. 31
      src/main/java/com/project/logistics/config/LogisticsScannerProperties.java
  36. 47
      src/main/java/com/project/logistics/config/SfApiProperties.java
  37. 36
      src/main/java/com/project/logistics/config/U9DataSourceConfig.java
  38. 54
      src/main/java/com/project/logistics/config/WebDavProperties.java
  39. 73
      src/main/java/com/project/logistics/domain/entity/ApiRetryTaskEntity.java
  40. 57
      src/main/java/com/project/logistics/domain/entity/FeePushLogEntity.java
  41. 74
      src/main/java/com/project/logistics/domain/entity/LogisticsOrderEntity.java
  42. 50
      src/main/java/com/project/logistics/domain/entity/PodPushLogEntity.java
  43. 60
      src/main/java/com/project/logistics/domain/entity/RoutePushLogEntity.java
  44. 28
      src/main/java/com/project/logistics/domain/enums/OrderStatusEnum.java
  45. 14
      src/main/java/com/project/logistics/domain/enums/OrderTypeEnum.java
  46. 24
      src/main/java/com/project/logistics/domain/enums/RetryActionEnum.java
  47. 26
      src/main/java/com/project/logistics/domain/enums/SfRouteOpCodeEnum.java
  48. 19
      src/main/java/com/project/logistics/domain/enums/SyncStatusEnum.java
  49. 19
      src/main/java/com/project/logistics/domain/enums/TaskStatusEnum.java
  50. 118
      src/main/java/com/project/logistics/domain/scheduler/ApiRetryJob.java
  51. 186
      src/main/java/com/project/logistics/domain/scheduler/WebDavScannerJob.java
  52. 137
      src/main/java/com/project/logistics/domain/service/SfApiService.java
  53. 138
      src/main/java/com/project/logistics/domain/service/WebDavService.java
  54. 14
      src/main/java/com/project/logistics/domain/service/base/ApiRetryTaskService.java
  55. 63
      src/main/java/com/project/logistics/domain/service/base/ApiRetryTaskServiceImpl.java
  56. 9
      src/main/java/com/project/logistics/domain/service/base/ErpService.java
  57. 38
      src/main/java/com/project/logistics/domain/service/base/ErpServiceImpl.java
  58. 16
      src/main/java/com/project/logistics/domain/service/base/LogisticsOrderService.java
  59. 68
      src/main/java/com/project/logistics/domain/service/base/LogisticsOrderServiceImpl.java
  60. 20
      src/main/java/com/project/logistics/domain/strategy/ApiTaskHandler.java
  61. 136
      src/main/java/com/project/logistics/domain/strategy/handler/SfCreateOrderHandler.java
  62. 82
      src/main/java/com/project/logistics/domain/strategy/handler/SfUploadResourceHandler.java
  63. 18
      src/main/java/com/project/logistics/domain/utils/FilePathUtil.java
  64. 97
      src/main/java/com/project/logistics/domain/utils/SfTokenUtil.java
  65. 9
      src/main/java/com/project/logistics/mapper/ApiRetryTaskMapper.java
  66. 9
      src/main/java/com/project/logistics/mapper/LogisticsOrderMapper.java
  67. 10
      src/main/java/com/project/logistics/repository/JpaTriggerRepository.java
  68. 162
      src/main/java/com/project/receive/controller/ReceiveController.java
  69. 15
      src/main/java/com/project/receive/dto/FeeInfo.java
  70. 18
      src/main/java/com/project/receive/dto/OrderStateDetail.java
  71. 12
      src/main/java/com/project/receive/dto/RouteBody.java
  72. 19
      src/main/java/com/project/receive/dto/SfFeeContent.java
  73. 20
      src/main/java/com/project/receive/dto/SfFeeResponse.java
  74. 10
      src/main/java/com/project/receive/dto/SfInnerContent.java
  75. 16
      src/main/java/com/project/receive/dto/SfPicturePushRequest.java
  76. 36
      src/main/java/com/project/receive/dto/SfPicturePushResponse.java
  77. 13
      src/main/java/com/project/receive/dto/SfPushRequest.java
  78. 18
      src/main/java/com/project/receive/dto/SfPushResponse.java
  79. 10
      src/main/java/com/project/receive/dto/SfRoutePushRequest.java
  80. 19
      src/main/java/com/project/receive/dto/SfRouteResponse.java
  81. 20
      src/main/java/com/project/receive/dto/WaybillRoute.java
  82. 23
      src/main/java/com/project/receive/utils/SfDecryptUtil.java
  83. 95
      src/main/resources/application.yml
  84. 17
      src/test/java/com/project/logistics/domain/scheduler/WebDavScannerJobTest.java

38
.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

8
.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

7
.idea/encodings.xml

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Encoding">
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/resources" charset="UTF-8" />
</component>
</project>

14
.idea/misc.xml

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ExternalStorageConfigurationManager" enabled="true" />
<component name="MavenProjectsManager">
<option name="originalFiles">
<list>
<option value="$PROJECT_DIR$/pom.xml" />
</list>
</option>
</component>
<component name="ProjectRootManager" version="2" languageLevel="JDK_17" default="true" project-jdk-name="17" project-jdk-type="JavaSDK">
<output url="file://$PROJECT_DIR$/out" />
</component>
</project>

124
.idea/uiDesigner.xml

@ -0,0 +1,124 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Palette2">
<group name="Swing">
<item class="com.intellij.uiDesigner.HSpacer" tooltip-text="Horizontal Spacer" icon="/com/intellij/uiDesigner/icons/hspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="1" hsize-policy="6" anchor="0" fill="1" />
</item>
<item class="com.intellij.uiDesigner.VSpacer" tooltip-text="Vertical Spacer" icon="/com/intellij/uiDesigner/icons/vspacer.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="1" anchor="0" fill="2" />
</item>
<item class="javax.swing.JPanel" icon="/com/intellij/uiDesigner/icons/panel.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3" />
</item>
<item class="javax.swing.JScrollPane" icon="/com/intellij/uiDesigner/icons/scrollPane.svg" removable="false" auto-create-binding="false" can-attach-label="true">
<default-constraints vsize-policy="7" hsize-policy="7" anchor="0" fill="3" />
</item>
<item class="javax.swing.JButton" icon="/com/intellij/uiDesigner/icons/button.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="0" fill="1" />
<initial-values>
<property name="text" value="Button" />
</initial-values>
</item>
<item class="javax.swing.JRadioButton" icon="/com/intellij/uiDesigner/icons/radioButton.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
<initial-values>
<property name="text" value="RadioButton" />
</initial-values>
</item>
<item class="javax.swing.JCheckBox" icon="/com/intellij/uiDesigner/icons/checkBox.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="3" anchor="8" fill="0" />
<initial-values>
<property name="text" value="CheckBox" />
</initial-values>
</item>
<item class="javax.swing.JLabel" icon="/com/intellij/uiDesigner/icons/label.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="0" anchor="8" fill="0" />
<initial-values>
<property name="text" value="Label" />
</initial-values>
</item>
<item class="javax.swing.JTextField" icon="/com/intellij/uiDesigner/icons/textField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JPasswordField" icon="/com/intellij/uiDesigner/icons/passwordField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JFormattedTextField" icon="/com/intellij/uiDesigner/icons/formattedTextField.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1">
<preferred-size width="150" height="-1" />
</default-constraints>
</item>
<item class="javax.swing.JTextArea" icon="/com/intellij/uiDesigner/icons/textArea.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTextPane" icon="/com/intellij/uiDesigner/icons/textPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JEditorPane" icon="/com/intellij/uiDesigner/icons/editorPane.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JComboBox" icon="/com/intellij/uiDesigner/icons/comboBox.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="2" anchor="8" fill="1" />
</item>
<item class="javax.swing.JTable" icon="/com/intellij/uiDesigner/icons/table.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JList" icon="/com/intellij/uiDesigner/icons/list.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="2" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTree" icon="/com/intellij/uiDesigner/icons/tree.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3">
<preferred-size width="150" height="50" />
</default-constraints>
</item>
<item class="javax.swing.JTabbedPane" icon="/com/intellij/uiDesigner/icons/tabbedPane.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
<preferred-size width="200" height="200" />
</default-constraints>
</item>
<item class="javax.swing.JSplitPane" icon="/com/intellij/uiDesigner/icons/splitPane.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="3" hsize-policy="3" anchor="0" fill="3">
<preferred-size width="200" height="200" />
</default-constraints>
</item>
<item class="javax.swing.JSpinner" icon="/com/intellij/uiDesigner/icons/spinner.svg" removable="false" auto-create-binding="true" can-attach-label="true">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
</item>
<item class="javax.swing.JSlider" icon="/com/intellij/uiDesigner/icons/slider.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="8" fill="1" />
</item>
<item class="javax.swing.JSeparator" icon="/com/intellij/uiDesigner/icons/separator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="6" anchor="0" fill="3" />
</item>
<item class="javax.swing.JProgressBar" icon="/com/intellij/uiDesigner/icons/progressbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1" />
</item>
<item class="javax.swing.JToolBar" icon="/com/intellij/uiDesigner/icons/toolbar.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="6" anchor="0" fill="1">
<preferred-size width="-1" height="20" />
</default-constraints>
</item>
<item class="javax.swing.JToolBar$Separator" icon="/com/intellij/uiDesigner/icons/toolbarSeparator.svg" removable="false" auto-create-binding="false" can-attach-label="false">
<default-constraints vsize-policy="0" hsize-policy="0" anchor="0" fill="1" />
</item>
<item class="javax.swing.JScrollBar" icon="/com/intellij/uiDesigner/icons/scrollbar.svg" removable="false" auto-create-binding="true" can-attach-label="false">
<default-constraints vsize-policy="6" hsize-policy="0" anchor="0" fill="2" />
</item>
</group>
</component>
</project>

6
.idea/vcs.xml

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="$PROJECT_DIR$" vcs="Git" />
</component>
</project>

190
pom.xml

@ -0,0 +1,190 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.3</version>
<relativePath/>
</parent>
<groupId>com.project</groupId>
<artifactId>auto-logistics</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>jar</packaging>
<name>auto-logistics</name>
<url>http://maven.apache.org</url>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<dynamic-datasource.version>4.3.0</dynamic-datasource.version>
</properties>
<dependencies>
<dependency>
<groupId>javax.xml.bind</groupId>
<artifactId>jaxb-api</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>org.glassfish.jaxb</groupId>
<artifactId>jaxb-runtime</artifactId>
<version>2.3.1</version>
</dependency>
<dependency>
<groupId>javax.activation</groupId>
<artifactId>javax.activation-api</artifactId>
<version>1.2.0</version>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.3</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- SQL Server 驱动 (用于连接 U9 数据库) -->
<dependency>
<groupId>com.microsoft.sqlserver</groupId>
<artifactId>mssql-jdbc</artifactId>
<version>12.4.2.jre11</version>
</dependency>
<!-- <dependency>-->
<!-- <groupId>com.baomidou</groupId>-->
<!-- <artifactId>dynamic-datasource-spring-boot-starter</artifactId>-->
<!-- <version>${dynamic-datasource.version}</version>-->
<!-- </dependency>-->
<!-- Spring Boot 核心 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
<optional>true</optional>
</dependency>
<!-- 数据库与持久化 -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>3.5.6</version>
</dependency>
<!-- <exclusions>-->
<!-- <exclusion>-->
<!-- <groupId>org.mybatis</groupId>-->
<!-- <artifactId>mybatis-spring</artifactId>-->
<!-- </exclusion>-->
<!-- </exclusions>-->
<!-- <dependency>-->
<!-- <groupId>org.mybatis</groupId>-->
<!-- <artifactId>mybatis-spring</artifactId>-->
<!-- <version>3.0.3</version>-->
<!-- </dependency>-->
<!-- <dependency>-->
<!-- <groupId>jakarta.persistence</groupId>-->
<!-- <artifactId>jakarta.persistence-api</artifactId>-->
<!-- <version>3.1.0</version>-->
<!-- </dependency>-->
<!-- <dependency>-->
<!-- <groupId>org.hibernate.orm</groupId>-->
<!-- <artifactId>hibernate-core</artifactId>-->
<!-- <version>6.4.4.Final</version>-->
<!-- </dependency>-->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 工具类 -->
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.3</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>2.0.47</version>
</dependency>
<dependency>
<groupId>com.google.guava</groupId>
<artifactId>guava</artifactId>
<version>31.1-jre</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
</dependency>
<!-- 文件处理 (WebDAV & Excel) -->
<dependency>
<groupId>com.github.lookfirst</groupId>
<artifactId>sardine</artifactId>
<version>5.10</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>easyexcel</artifactId>
<version>3.3.4</version>
</dependency>
<dependency>
<groupId>com.jayway.jsonpath</groupId>
<artifactId>json-path</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 测试 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<!-- 必须确保有这个 execution,它负责把普通 JAR 重新打包成可执行 JAR -->
<executions>
<execution>
<goals>
<goal>repackage</goal>
</goals>
</execution>
</executions>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

21
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);
}
}

21
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);
}
}

39
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;
}
}

72
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("<script>INSERT INTO %s %s VALUES <foreach collection=\"list\" item=\"item\" separator=\",\">%s</foreach> ON DUPLICATE KEY UPDATE %s</script>",
tableInfo.getTableName(), columnScript, valuesScript, updateScript);
SqlSource sqlSource = languageDriver.createSqlSource(configuration, sql, modelClass);
return this.addInsertMappedStatement(mapperClass, modelClass, methodName, sqlSource, new NoKeyGenerator(), null, null);
}
}

28
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(推荐使用)
}
}

31
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<AbstractMethod> getMethodList(Class<?> mapperClass, TableInfo tableInfo) {
List<AbstractMethod> 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;
}
}

14
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";
}

185
src/main/java/com/project/base/config/SnowflakeIdWorker.java

@ -0,0 +1,185 @@
package com.project.base.config;
/**
* Twitter_Snowflake<br>
* SnowFlake的结构如下(每部分用-分开):<br>
* 0 - 0000000000 0000000000 0000000000 0000000000 0 - 00000 - 00000 - 000000000000 <br>
* 1位标识由于long基本类型在Java中是带符号的最高位是符号位正数是0负数是1所以id一般是正数最高位是0<br>
* 41位时间截(毫秒级)注意41位时间截不是存储当前时间的时间截而是存储时间截的差值当前时间截 - 开始时间截)
* 得到的值这里的的开始时间截一般是我们的id生成器开始使用的时间由我们程序来指定的如下下面程序IdWorker类的startTime属性41位的时间截可以使用69年年T = (1L << 41) / (1000L * 60 * 60 * 24 * 365) = 69<br>
* 10位的数据机器位可以部署在1024个节点包括5位datacenterId和5位workerId<br>
* 12位序列毫秒内的计数12位的计数顺序号支持每个节点每毫秒(同一机器同一时间截)产生4096个ID序号<br>
* 加起来刚好64位为一个Long型<br>
* 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);
}
}
}

111
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());
}
}

33
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> T toEntity(Supplier<T> supplier) {
T entity = supplier.get();
BeanUtil.copyProperties(this, entity);
return entity;
}
}

55
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> T toDTO(Supplier<T> supplier) {
T dto = supplier.get();
BeanUtil.copyProperties(this, dto);
return dto;
}
}

24
src/main/java/com/project/base/domain/enums/HasValueEnum.java

@ -0,0 +1,24 @@
package com.project.base.domain.enums;
public interface HasValueEnum<T> {
/**
* 获取枚举名将会由枚举抽象类默认实现
*
* @see Enum
*/
String name();
/**
* 获取枚举值
*/
T getValue();
/**
* 枚举名比较时是否需要区分大小写默认为需要区分
*/
default boolean caseCompare() {
return true;
}
}

17
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<Boolean>{
Normal(Boolean.FALSE), Delete(Boolean.TRUE);
private final Boolean value;
}

21
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;
}
}

21
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;
}
}

25
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;
}
}

21
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;
}
}

10
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;
}

24
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<T> implements Serializable {
private List<T> content; // 数据列表
private long total; // 总条数
private long size; // 每页条数
private long current; // 当前页码 (从1开始)
private long pages; // 总页数
public PageResult(IPage<T> mpPage) {
this.content = mpPage.getRecords();
this.total = mpPage.getTotal();
this.size = mpPage.getSize();
this.current = mpPage.getCurrent();
this.pages = mpPage.getPages();
}
}

70
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<T> implements Serializable {
private Integer code = 0;;
private Boolean success = true;
private String message;
private T data;
public static <T> Result<T> success(T data) {
Result<T> result = new Result<T>();
result.setCode(ResultCodeEnum.SUCCESS.getCode());
result.setMessage("success");
result.setData(data);
return result;
}
public static <T> Result<T> success(T data, String message) {
Result<T> result = new Result<T>();
result.setCode(ResultCodeEnum.SUCCESS.getCode());
result.setMessage(message);
result.setData(data);
return result;
}
public static <T> Result<T> fail() {
Result<T> res = new Result<T>();
res.setCode(ResultCodeEnum.UNKNOWN_ERROR.getCode());
res.setMessage(ResultCodeEnum.UNKNOWN_ERROR.getMessage());
res.setSuccess(false);
return res;
}
public static <T> Result<T> fail(ResultCodeEnum resultCodeEnum) {
Result<T> res = new Result<T>();
res.setCode(resultCodeEnum.getCode());
res.setMessage(resultCodeEnum.getMessage());
res.setSuccess(false);
return res;
}
public static <T> Result<T> fail(ResultCodeEnum resultCodeEnum , String msg) {
Result<T> res = new Result<T>();
res.setCode(resultCodeEnum.getCode());
res.setMessage(msg);
res.setSuccess(false);
return res;
}
public static <T> Result<PageResult<T>> page(IPage<T> mpPage) {
return success(new PageResult<>(mpPage));
}
}

43
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;
}
}

41
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<T extends BaseEntity> extends IService<T> {
/**
* 通用保存 DTO 的方法转换 -> 保存 -> 回传带ID的DTO
*/
default <D extends BaseDTO> D saveDTO(D dto, Supplier<T> entitySupplier, Supplier<D> 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 <D extends BaseDTO> boolean updateDTO(D dto, Supplier<T> entitySupplier) {
if (dto == null) {
return false;
}
T entity = dto.toEntity(entitySupplier);
return this.updateById(entity);
}
}

40
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<T> {
public void exportExcel(HttpServletResponse response, List<T> list, String sheetName,String fileName,Class<T> 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);
}
}
}

14
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 <T> Page<T> toMpPage(BaseParam param) {
return new Page<>(param.getCurrent(), param.getSize());
}
}

65
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;
}
}

199
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 <T>
* @param <R>
*/
public static <T, R> List<T> buildLongTree(List<T> tList , Function<T, R> getIdFn , Function<T , R> getParentIdFn , BiConsumer<T , List<T>> setChild) {
try {
List<T> returnList = new ArrayList<>();
//主键id集合
List<Long> 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 <T>
* @param <R>
* @throws IllegalAccessException
*/
private static <T, R> void recursionLong(List<T> list, T o , Function<T, R> getIdFn , Function<T , R> getParentIdFn , BiConsumer<T , List<T>> setChild) throws IllegalAccessException {
// 得到子节点列表
List<T> 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 <T>
* @param <R>
* @throws IllegalAccessException
*/
private static <T, R> List<T> getLongChildList(List<T> list, T object , Function<T, R> getIdFn , Function<T , R> getParentIdFn) throws IllegalAccessException {
Long primaryId = (Long) getIdFn.apply(object);
List<T> 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 <T>
*/
private static <T> void invokeChildrenList(T o, List<T> childList , BiConsumer<T , List<T>> setChild) {
setChild.accept(o , childList);
}
/**
* 将树转为List
* @param list
* @param getChildFn
* @return
* @param <T>
*/
public static <T> List<T> tree2List(List<T> list , Function<T , List<T>> getChildFn) {
List<T> res = Lists.newArrayList();
for (T node : list) {
List<T> childList = getChildFn.apply(node);
res.add(node);
if (CollUtil.isNotEmpty(childList)) {
res.addAll(tree2List(childList , getChildFn));
}
}
return res;
}
public static <T, R> List<T> buildStrTree(List<T> tList , Function<T, R> getIdFn , Function<T , R> getParentIdFn , BiConsumer<T , List<T>> setChild) {
try {
List<T> returnList = new ArrayList<>();
//主键id集合
List<String> 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 <T, R> void recursionStr(List<T> list, T o , Function<T, R> getIdFn , Function<T , R> getParentIdFn , BiConsumer<T , List<T>> setChild) throws IllegalAccessException {
// 得到子节点列表
List<T> 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 <T, R> List<T> getStrChildList(List<T> list, T object , Function<T, R> getIdFn , Function<T , R> getParentIdFn) throws IllegalAccessException {
String primaryId = String.valueOf(getIdFn.apply(object));
List<T> 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;
}
}

13
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<T> extends BaseMapper<T> {
/**
* 自定义全局批量 Upsert
*/
int batchUpsert(@Param("list") List<T> list);
}

31
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<ScanWindow> 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
}
}

47
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;
}

36
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);
}
}

54
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;
}

73
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;
}

57
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;
}

74
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;
}

50
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;
}

60
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;
}

28
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;
}

14
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;
}

24
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;
}

26
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;
}
}

19
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;
}

19
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;
}

118
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<String, ApiTaskHandler> handlerMap;
@Autowired
public ApiRetryJob(List<ApiTaskHandler> 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<ApiRetryTaskEntity> 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);
}
}

186
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<DavResource> 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;
}
}

137
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<String, String> 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<byte[]> requestEntity = new HttpEntity<>(pdfBytes, headers);
try {
// 使用 PUT 方法上传
ResponseEntity<String> 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<String, String> 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<String, String> 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<MultiValueMap<String, String>> request = new HttpEntity<>(params, headers);
log.info(">>> 发起顺丰 API 请求 [{}], RequestID: {}", serviceCode, requestId);
ResponseEntity<String> 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;
}
}

138
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<DavResource> 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("(?<!:)/+", "/");
return UrlBuilder.ofHttp(normalized, CharsetUtil.CHARSET_UTF_8).build();
}
}

14
src/main/java/com/project/logistics/domain/service/base/ApiRetryTaskService.java

@ -0,0 +1,14 @@
package com.project.logistics.domain.service.base;
import com.baomidou.mybatisplus.extension.service.IService;
import com.project.logistics.domain.entity.ApiRetryTaskEntity;
import com.project.logistics.domain.enums.RetryActionEnum;
import java.util.List;
public interface ApiRetryTaskService extends IService<ApiRetryTaskEntity> {
List<ApiRetryTaskEntity> listPendingTasks() throws Exception;
void createNextTask(String orderNo, RetryActionEnum nextAction) throws Exception;
}

63
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<ApiRetryTaskMapper, ApiRetryTaskEntity>
implements ApiRetryTaskService {
@Override
public List<ApiRetryTaskEntity> 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);
}
}

9
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;
}

38
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<String, Object> 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;
}
}
}

16
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<LogisticsOrderEntity> {
Boolean existsByOrderNo(String orderNo) throws Exception;
void createOrderAndTask(String orderNo, JSONObject u9Data, String fullPdfPath);
LogisticsOrderEntity getByOrderNo(String orderNo) throws Exception;
}

68
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<LogisticsOrderMapper, LogisticsOrderEntity>
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();
}
}

20
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;
}

136
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;
}
}
}

82
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; // 抛出异常触发重试
}
}
}

18
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;
}
}

97
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<String, String> map = new LinkedMultiValueMap<>();
map.add("partnerID", sfApiProperties.getPartnerId());
map.add("secret", sfApiProperties.getSecret());
map.add("grantType", sfApiProperties.getGrantType());
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(map, headers);
try {
ResponseEntity<SfTokenResponse> 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; // 有效期(秒)
}
}

9
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<ApiRetryTaskEntity> {
}

9
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<LogisticsOrderEntity> {
}

10
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<LogisticsOrderEntity, Long> {
// 这个接口哪怕你一个方法都不写,放在这里就能激活 Hibernate 的建表逻辑
}

162
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;
}
}
}

15
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; // 业务所属地区编码
// ... 其他字段可以按需添加
}

18
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;
}

12
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> waybillRoute;
}

19
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<FeeInfo> feeList; // 费用项列表
}

20
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;
}
}

10
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 数据
}

16
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;
}

36
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;
}
}

13
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<OrderStateDetail> orderState;
}

18
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;
}
}

10
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;
}

19
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;
}
}

20
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; // 二级状态描述
}

23
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);
}
}

95
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

17
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();
}
}
Loading…
Cancel
Save