lfx пре 1 месец
родитељ
комит
842f18c550
85 измењених фајлова са 8406 додато и 0 уклоњено
  1. 57 0
      mjava-ts/pom.xml
  2. 20 0
      mjava-ts/src/main/java/com/malk/test/Boot.java
  3. 27 0
      mjava-ts/src/main/resources/application-dev.yml
  4. 27 0
      mjava-ts/src/main/resources/application-prod.yml
  5. 9 0
      mjava-ts/src/main/resources/application.yml
  6. 170 0
      mjava-ts/src/main/resources/logback-spring.xml
  7. 30 0
      mjava-ts/src/test/java/test.java
  8. 49 0
      mjava/pom.xml
  9. 58 0
      mjava/src/main/java/com/malk/config/WebConfiguration.java
  10. 73 0
      mjava/src/main/java/com/malk/controller/DDCallbackController.java
  11. 101 0
      mjava/src/main/java/com/malk/core/AsyncConfig.java
  12. 37 0
      mjava/src/main/java/com/malk/delegate/DDEvent.java
  13. 17 0
      mjava/src/main/java/com/malk/delegate/McDelegate.java
  14. 49 0
      mjava/src/main/java/com/malk/delegate/impl/DDImplEvent.java
  15. 21 0
      mjava/src/main/java/com/malk/delegate/impl/McImplDelegate.java
  16. 215 0
      mjava/src/main/java/com/malk/filter/CatchException.java
  17. 53 0
      mjava/src/main/java/com/malk/filter/ExceptionNotice.java
  18. 32 0
      mjava/src/main/java/com/malk/filter/RequestFilter.java
  19. 41 0
      mjava/src/main/java/com/malk/filter/RequestInterceptor.java
  20. 189 0
      mjava/src/main/java/com/malk/server/aliwork/YDConf.java
  21. 224 0
      mjava/src/main/java/com/malk/server/aliwork/YDParam.java
  22. 32 0
      mjava/src/main/java/com/malk/server/aliwork/YDR.java
  23. 42 0
      mjava/src/main/java/com/malk/server/common/FilePath.java
  24. 167 0
      mjava/src/main/java/com/malk/server/common/McException.java
  25. 39 0
      mjava/src/main/java/com/malk/server/common/McPage.java
  26. 104 0
      mjava/src/main/java/com/malk/server/common/McR.java
  27. 33 0
      mjava/src/main/java/com/malk/server/common/McREnum.java
  28. 15 0
      mjava/src/main/java/com/malk/server/common/Page.java
  29. 54 0
      mjava/src/main/java/com/malk/server/common/VenR.java
  30. 76 0
      mjava/src/main/java/com/malk/server/dingtalk/DDConf.java
  31. 78 0
      mjava/src/main/java/com/malk/server/dingtalk/DDConfigSign.java
  32. 79 0
      mjava/src/main/java/com/malk/server/dingtalk/DDFormComponentDto.java
  33. 24 0
      mjava/src/main/java/com/malk/server/dingtalk/DDInterActiveCard.java
  34. 101 0
      mjava/src/main/java/com/malk/server/dingtalk/DDR.java
  35. 144 0
      mjava/src/main/java/com/malk/server/dingtalk/DDR_New.java
  36. 404 0
      mjava/src/main/java/com/malk/server/dingtalk/crypto/DingCallbackCrypto.java
  37. 29 0
      mjava/src/main/java/com/malk/service/aliwork/YDClient.java
  38. 100 0
      mjava/src/main/java/com/malk/service/aliwork/YDService.java
  39. 167 0
      mjava/src/main/java/com/malk/service/aliwork/impl/YDClientImpl.java
  40. 330 0
      mjava/src/main/java/com/malk/service/aliwork/impl/YDServiceImpl.java
  41. 36 0
      mjava/src/main/java/com/malk/service/dingtalk/DDClient.java
  42. 98 0
      mjava/src/main/java/com/malk/service/dingtalk/DDClient_Attendance.java
  43. 99 0
      mjava/src/main/java/com/malk/service/dingtalk/DDClient_Contacts.java
  44. 29 0
      mjava/src/main/java/com/malk/service/dingtalk/DDClient_Dedicated.java
  45. 39 0
      mjava/src/main/java/com/malk/service/dingtalk/DDClient_Event.java
  46. 40 0
      mjava/src/main/java/com/malk/service/dingtalk/DDClient_Extension.java
  47. 15 0
      mjava/src/main/java/com/malk/service/dingtalk/DDClient_Group.java
  48. 16 0
      mjava/src/main/java/com/malk/service/dingtalk/DDClient_Notice.java
  49. 50 0
      mjava/src/main/java/com/malk/service/dingtalk/DDClient_Personnel.java
  50. 14 0
      mjava/src/main/java/com/malk/service/dingtalk/DDClient_Report.java
  51. 15 0
      mjava/src/main/java/com/malk/service/dingtalk/DDClient_Schedule.java
  52. 89 0
      mjava/src/main/java/com/malk/service/dingtalk/DDClient_Storage.java
  53. 98 0
      mjava/src/main/java/com/malk/service/dingtalk/DDClient_Workflow.java
  54. 70 0
      mjava/src/main/java/com/malk/service/dingtalk/DDService.java
  55. 113 0
      mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient.java
  56. 194 0
      mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Attendance.java
  57. 250 0
      mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Contacts.java
  58. 58 0
      mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Dedicated.java
  59. 138 0
      mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Event.java
  60. 69 0
      mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Extension.java
  61. 40 0
      mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Group.java
  62. 37 0
      mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Notice.java
  63. 175 0
      mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Personnel.java
  64. 35 0
      mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Report.java
  65. 44 0
      mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Schedule.java
  66. 229 0
      mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Storage.java
  67. 224 0
      mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Workflow.java
  68. 232 0
      mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplService.java
  69. 141 0
      mjava/src/main/java/com/malk/utils/UtilConvert.java
  70. 214 0
      mjava/src/main/java/com/malk/utils/UtilDateTime.java
  71. 135 0
      mjava/src/main/java/com/malk/utils/UtilEnv.java
  72. 249 0
      mjava/src/main/java/com/malk/utils/UtilExcel.java
  73. 240 0
      mjava/src/main/java/com/malk/utils/UtilFile.java
  74. 233 0
      mjava/src/main/java/com/malk/utils/UtilHttp.java
  75. 88 0
      mjava/src/main/java/com/malk/utils/UtilList.java
  76. 340 0
      mjava/src/main/java/com/malk/utils/UtilMap.java
  77. 24 0
      mjava/src/main/java/com/malk/utils/UtilMath.java
  78. 50 0
      mjava/src/main/java/com/malk/utils/UtilMc.java
  79. 156 0
      mjava/src/main/java/com/malk/utils/UtilNumber.java
  80. 81 0
      mjava/src/main/java/com/malk/utils/UtilServlet.java
  81. 63 0
      mjava/src/main/java/com/malk/utils/UtilString.java
  82. 28 0
      mjava/src/main/java/com/malk/utils/UtilToken.java
  83. BIN
      mjava/src/main/resources/assets/logo/logo-text.png
  84. 7 0
      mjava/src/main/resources/banner.txt
  85. 294 0
      pom.xml

+ 57 - 0
mjava-ts/pom.xml

@@ -0,0 +1,57 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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">
+    <parent>
+        <artifactId>mjava-third</artifactId>
+        <groupId>com.malk</groupId>
+        <version>1.0-SNAPSHOT</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+
+    <artifactId>mjava-test</artifactId>
+    <description>测试</description>
+
+    <properties>
+        <maven.compiler.source>8</maven.compiler.source>
+        <maven.compiler.target>8</maven.compiler.target>
+        <sqlserver-jdbc.version>6.4.0.jre8</sqlserver-jdbc.version>
+    </properties>
+
+    <dependencies>
+        <!-- 核心模块-->
+        <dependency>
+            <groupId>com.malk</groupId>
+            <artifactId>mjava</artifactId>
+            <version>0.0.3</version>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>2.1.1.RELEASE</version>
+                <configuration>
+                    <includeSystemScope>true</includeSystemScope>
+                    <!-- 避免中文乱码 -->
+                    <jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
+                </configuration>
+                <!-- 允许生成可运行jar -->
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>repackage</goal>
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+        <finalName>${project.artifactId}</finalName>
+    </build>
+</project>

+ 20 - 0
mjava-ts/src/main/java/com/malk/test/Boot.java

@@ -0,0 +1,20 @@
+package com.malk.test;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.scheduling.annotation.EnableAsync;
+
+
+@EnableAsync
+@SpringBootApplication(scanBasePackages = {"com.malk"})
+public class Boot {
+
+    public static void main(String... args) {
+
+        try {
+            SpringApplication.run(Boot.class, args);
+        }catch (Exception e){
+            e.printStackTrace();
+        }
+    }
+}

+ 27 - 0
mjava-ts/src/main/resources/application-dev.yml

@@ -0,0 +1,27 @@
+# 环境配置
+server:
+  port: 9090
+  servlet:
+    context-path: /api/test
+enable:
+   scheduling: false
+
+logging:
+  config: classpath:logback-spring.xml
+  file:
+    path: /Users/malk/server/_Tool/var/mjava/log
+    level:
+      com.malk.*: info
+
+dingtalk:
+  agentId: 4118669757
+  appKey: "dingo2zkcbeoahcvhcis"
+  appSecret: "UVhiyGaFKdpwbSVYmxpVPv_cAWIVXWtEzBo9tDT8etplIbpBBIGz2gAAKDYVVgRq"
+  corpId: "ding321c72787fffc78b35c2f4657eb6378f"
+  aesKey:
+  token:
+  operator: ""   # OA管理员账号 [首字符若为0需要转一下字符串]
+
+aliwork:
+  appType: "APP_X0QK1JS6EL8VSELYCJRP"
+  systemToken: "IO766F8179UVSQ1K62LFE5M5S1GX2QQ64T8BMJ9"

+ 27 - 0
mjava-ts/src/main/resources/application-prod.yml

@@ -0,0 +1,27 @@
+# 环境配置
+server:
+  port: 9090
+  servlet:
+    context-path: /api/test
+enable:
+  scheduling: true
+
+logging:
+  config: classpath:logback-spring.xml
+  file:
+    path: /Users/malk/server/_Tool/var/mjava/log
+    level:
+      com.malk.*: info
+
+dingtalk:
+  agentId:
+  appKey:
+  appSecret:
+  corpId:
+  aesKey:
+  token:
+  operator: ""   # OA管理员账号 [首字符若为0需要转一下字符串]
+
+aliwork:
+  appType: ""
+  systemToken: ""

+ 9 - 0
mjava-ts/src/main/resources/application.yml

@@ -0,0 +1,9 @@
+spring:
+  profiles:
+    active: dev
+  servlet:
+    multipart:
+      max-file-size: 100MB
+      max-request-size: 100MB
+  http:
+    enabled: false

+ 170 - 0
mjava-ts/src/main/resources/logback-spring.xml

@@ -0,0 +1,170 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<configuration>
+    <!-- 日志存放路径 -->
+    <springProperty scope="context" name="log.path" source="logging.file.path"/>
+    <!-- 日志输出格式 -->
+    <property name="log.pattern" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level - [%method,%line] - %msg%n"/>
+
+    <!-- 控制台输出 -->
+    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
+        <encoder>
+            <pattern>${log.pattern}</pattern>
+        </encoder>
+    </appender>
+
+    <!-- 错误日志输出 -->
+    <appender name="ERROR_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${log.path}/error.log</file>
+        <!-- 循环政策:基于时间创建日志文件 -->
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <!-- 日志文件名格式 -->
+            <fileNamePattern>${log.path}/%d{yyyy-MM-dd}/error-%i.log.gz</fileNamePattern>
+            <!--日志文件最大的大小 -->
+            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
+                <maxFileSize>20MB</maxFileSize>
+            </timeBasedFileNamingAndTriggeringPolicy>
+            <!-- 日志最大的历史 60天 -->
+            <maxHistory>60</maxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${log.pattern}</pattern>
+        </encoder>
+        <filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <!-- 过滤的级别: 如果定义了日志级别为warn ,却没有指定 warn的日志处理方式: warn日志信息就不会有 -->
+            <level>ERROR</level>
+            <!-- 匹配时的操作:接收(记录) -->
+            <onMatch>ACCEPT</onMatch>
+            <!-- 不匹配时的操作:拒绝(不记录) -->
+            <onMismatch>DENY</onMismatch>
+        </filter>
+    </appender>
+
+    <!-- 警告日志输出 -->
+    <appender name="WARN_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${log.path}/warn.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <fileNamePattern>${log.path}/%d{yyyy-MM-dd}/warn-%i.log.gz</fileNamePattern>
+            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
+                <maxFileSize>20MB</maxFileSize>
+            </timeBasedFileNamingAndTriggeringPolicy>
+            <maxHistory>60</maxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${log.pattern}</pattern>
+        </encoder>
+        <filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <level>WARN</level>
+            <onMatch>ACCEPT</onMatch>
+            <onMismatch>DENY</onMismatch>
+        </filter>
+    </appender>
+
+    <!-- 记录日志输出 -->
+    <appender name="INFO_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${log.path}/info.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <fileNamePattern>${log.path}/%d{yyyy-MM-dd}/info-%i.log.gz</fileNamePattern>
+            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
+                <maxFileSize>20MB</maxFileSize>
+            </timeBasedFileNamingAndTriggeringPolicy>
+            <maxHistory>60</maxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${log.pattern}</pattern>
+        </encoder>
+        <filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <level>INFO</level>
+            <onMatch>ACCEPT</onMatch>
+            <onMismatch>DENY</onMismatch>
+        </filter>
+    </appender>
+
+    <!-- 调试日志输出 -->
+    <appender name="DEBUG_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${log.path}/debug.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <fileNamePattern>${log.path}/%d{yyyy-MM-dd}/debug-%i.log.gz</fileNamePattern>
+            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
+                <maxFileSize>20MB</maxFileSize>
+            </timeBasedFileNamingAndTriggeringPolicy>
+            <maxHistory>30</maxHistory>
+        </rollingPolicy>
+        <encoder>
+            <pattern>${log.pattern}</pattern>
+        </encoder>
+        <filter class="ch.qos.logback.classic.filter.LevelFilter">
+            <level>DEBUG</level>
+            <onMatch>ACCEPT</onMatch>
+            <onMismatch>DENY</onMismatch>
+        </filter>
+    </appender>
+
+    <!-- 指定日志输出 -->
+    <appender name="POINT_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
+        <file>${log.path}/point.log</file>
+        <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
+            <fileNamePattern>${log.path}/%d{yyyy-MM-dd}/point-%i.log.gz</fileNamePattern>
+            <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
+                <maxFileSize>20MB</maxFileSize>
+            </timeBasedFileNamingAndTriggeringPolicy>
+            <maxHistory>60</maxHistory>
+        </rollingPolicy>
+        <encoder class="ch.qos.logback.core.encoder.LayoutWrappingEncoder">
+            <layout class="ch.qos.logback.classic.PatternLayout">
+                <pattern>${log.pattern}</pattern>
+            </layout>
+        </encoder>
+    </appender>
+
+    <!-- Spring日志级别控制  -->
+    <logger name="org.springframework" level="warn"/>
+
+    <!-- hikari 日志级别 -->
+    <Logger name="com.zaxxer.hikari" level="info"></Logger>
+
+    <!-- Hibernate 日志级别 -->
+    <logger name="org.hibernate.type.descriptor.sql.BasicBinder" level="DEBUG"/>
+    <logger name="org.hibernate.type.descriptor.sql.BasicExtractor" level="DEBUG"/>
+    <logger name="org.hibernate.SQL" level="DEBUG"/>
+    <logger name="org.hibernate.engine.QueryParameters" level="DEBUG"/>
+    <logger name="org.hibernate.engine.query.HQLQueryPlan" level="DEBUG"/>
+
+    <!-- 配置文件默认名字:logback-spring.xml,也可以用logback.xml -->
+
+    <!-- 1. 多环境配置,通过springProfile设置环境,root内容会自动追加到logger -->
+    <!-- 2. 过滤的级别: 如果定义了日志级别为warn ,却没有指定 warn的日志处理方式: warn日志信息就不会有 -->
+    <!-- 3. 指定类输出日志到指定文件夹: private static final Logger logger = LoggerFactory.getLogger("point"); -->
+    <!-- # 日志配置 logging.level.com.mcli=debug logging.level.org.springframework: warn -->
+
+    <logger name="point" level="DEBUG">
+        <appender-ref ref="POINT_FILE"/>
+    </logger>
+
+    <!-- 开发环境: 打印控制台 -->
+    <springProfile name="dev">
+        <root level="warn">
+            <appender-ref ref="CONSOLE"/>
+            <appender-ref ref="INFO_FILE"/>
+        </root>
+    </springProfile>
+
+    <!-- 测试环境:输出文件 -->
+    <springProfile name="test">
+        <root level="info">
+            <appender-ref ref="DEBUG_FILE"/>
+            <appender-ref ref="INFO_FILE"/>
+            <appender-ref ref="WARN_FILE"/>
+            <appender-ref ref="ERROR_FILE"/>
+        </root>
+    </springProfile>
+
+    <!-- 生产环境: 输出文件 -->
+    <springProfile name="prod">
+        <root level="info">
+            <appender-ref ref="DEBUG_FILE"/>
+            <appender-ref ref="INFO_FILE"/>
+            <appender-ref ref="WARN_FILE"/>
+            <appender-ref ref="ERROR_FILE"/>
+        </root>
+    </springProfile>
+</configuration>

+ 30 - 0
mjava-ts/src/test/java/test.java

@@ -0,0 +1,30 @@
+import com.alibaba.fastjson.JSONObject;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.service.aliwork.YDClient;
+import com.malk.test.Boot;
+import lombok.extern.slf4j.Slf4j;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.junit4.SpringRunner;
+
+import java.util.Map;
+
+@Slf4j
+@SpringBootTest(classes = Boot.class)
+@RunWith(SpringRunner.class)
+public class test {
+
+    @Autowired
+    private YDClient ydClient;
+
+    @Test
+    public void test1(){
+        Object result= ydClient.queryData(YDParam.builder().formUuid("FORM-C1B3CA85AB054744A793F7DA74758648KY3J").build(), YDConf.FORM_QUERY.retrieve_list_all).getData();
+        System.out.println(JSONObject.toJSONString(result));
+    }
+
+
+}

+ 49 - 0
mjava/pom.xml

@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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">
+    <parent>
+        <artifactId>mjava-third</artifactId>
+        <groupId>com.malk</groupId>
+        <version>1.0-SNAPSHOT</version>
+    </parent>
+    <modelVersion>4.0.0</modelVersion>
+    <!-- mjava版本, 不同java-cli项目区分底层依赖, 使用变量有警告 -->
+    <version>0.0.3</version>
+
+    <artifactId>mjava</artifactId>
+    <description>mjava framework</description>
+
+    <properties>
+        <maven.compiler.source>8</maven.compiler.source>
+        <maven.compiler.target>8</maven.compiler.target>
+    </properties>
+
+    <dependencies>
+
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-maven-plugin</artifactId>
+                <version>2.1.1.RELEASE</version>
+                <configuration>
+                    <includeSystemScope>true</includeSystemScope>
+                    <!-- 避免中文乱码 -->
+                    <jvmArguments>-Dfile.encoding=UTF-8</jvmArguments>
+                </configuration>
+                <!-- 允许生成可运行jar: 发布作为基础包提供, 注释 executions 再执行 install 到本地 maven. 若开启即可作为独立 jar 运行 -->
+                <executions>
+                    <execution>
+                        <goals>
+                            <!--                            <goal>repackage</goal>-->
+                        </goals>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+        <finalName>${project.artifactId}</finalName>
+    </build>
+</project>

+ 58 - 0
mjava/src/main/java/com/malk/config/WebConfiguration.java

@@ -0,0 +1,58 @@
+package com.malk.config;
+
+import com.malk.filter.RequestInterceptor;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.CorsRegistry;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+
+@Configuration
+public class WebConfiguration implements WebMvcConfigurer {
+
+    // 指定类输出日志到指定文件夹
+    private static final Logger logger = LoggerFactory.getLogger("point");
+
+    // 请求拦截
+    @Override
+    public void addInterceptors(InterceptorRegistry registry) {
+        logger.info("拦截器 ▷ 初始化");
+        registry.addInterceptor(new RequestInterceptor())
+                .addPathPatterns("/**")
+                // ppExt: 若无需对外访问, 不用添加路径. static/public 为默认
+                .excludePathPatterns("/assets/**", "/templates/**");
+    }
+
+    // 跨域支持: 端口不匹配也会报跨域, 若是单个控制器开放, 可使用 @CrossOrigin 注解
+    @Override
+    public void addCorsMappings(CorsRegistry registry) {
+        logger.info("拦截器 ▷ 开启CORS");
+        registry.addMapping("/**")  // 添加映射路径
+                .allowedOrigins("*")  // 放行哪些原始
+                .allowCredentials(true)  // 是否发送Cookie信息
+                .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH")  // 放行哪些原始域(请求方式)
+                .allowedHeaders("*")  // 放行哪些原始域(头部信息)
+                .allowCredentials(true);  // 放行证书
+    }
+
+    /**
+     * 静态资源映射
+     * -
+     * 默认的静态资源路径为: classpath:/META-INF/resources/, classpath:/resources/,classpath:/static/, classpath:/public [默认路径不会进拦截器]
+     * 读取的是target内容, 访问路径 assets: http://localhost:9001/api/assets/logo/logo-text.png [自定义拦截器添加路径排除: excludePathPatterns]
+     * web网页路径: http://localhost:9011/api/项目路径/web2/index.html#/模块名称/home [前后端模块化, 不配置Nginx从Tomcat透出vue页面]
+     * 当在SpringBoot项目内添加网页资源时,在windows服务器,需要C:\Windows\System32下添加tomcat-native-1.2.14-win32-bin.zip内x64下两个文件, 重启项目
+     * -
+     * ppExt: ClassPathResource, 需要打包/编译后才能访问到. 识别不是架包内内容 [static 可自动过滤, 自动识别子文件夹作为 path]
+     * [两个示例]: mjs http://localhost:9001/api/项目路径/mjs/mjs.min.js / http://localhost:9001/api/项目路径/json/personnel.json
+     */
+    @Override
+    public void addResourceHandlers(ResourceHandlerRegistry registry) {
+        registry.addResourceHandler("/static/**").addResourceLocations("classpath:/static/");
+        registry.addResourceHandler("/web2/**").addResourceLocations("classpath:/static/web2/");
+        registry.addResourceHandler("/assets/**").addResourceLocations("classpath:/assets/");
+        registry.addResourceHandler("/templates/**").addResourceLocations("classpath:/templates/");
+    }
+}

+ 73 - 0
mjava/src/main/java/com/malk/controller/DDCallbackController.java

@@ -0,0 +1,73 @@
+package com.malk.controller;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONObject;
+import com.malk.server.common.McR;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.server.dingtalk.crypto.DingCallbackCrypto;
+import com.malk.service.dingtalk.DDClient_Event;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.Arrays;
+import java.util.Map;
+
+/**
+ * 钉钉事件回调 3_1
+ * -
+ * [子项目直接继承即可有调用, 无需实现]
+ * -
+ * 注解 @RequestMapping 路径不能重复 [主子项目属同一个项目];
+ * 获取项目回调请求地址, https://mc.cloudpure.cn/frp/mc/dd/callback [调试代理: frp + nginx]
+ */
+@Slf4j
+@RestController
+@RequestMapping("/mc/dd")
+public class DDCallbackController {
+
+    @Autowired
+    private DDConf ddConf;
+
+    @Autowired
+    private DDClient_Event ddClient_event;
+
+    /**
+     * 钉钉审批回调: [依赖包方案已弃用]
+     * -
+     * DingCallbackCrypto 方案: 官网案例 DingCallbackCrypto 不在钉钉架包, 需要单独引用
+     * 在钉钉开放平台重新保存回调地址后, 所有的注册事件会被关闭:: 通过代码注册不成功; 官方回复, 要么使用调用注册的方式 要么是后台的方式, 二选一
+     */
+    @SneakyThrows
+    @RequestMapping(value = "/callback", method = RequestMethod.POST)
+    public Map<String, String> invokeCallback(@RequestParam(value = "signature", required = false) String signature,
+                                              @RequestParam(value = "timestamp", required = false) String timestamp,
+                                              @RequestParam(value = "nonce", required = false) String nonce,
+                                              @RequestBody(required = false) JSONObject json) {
+
+        DingCallbackCrypto callbackCrypto = new DingCallbackCrypto(ddConf.getToken(), ddConf.getAesKey(), ddConf.getAppKey());
+        final String decryptMsg = callbackCrypto.getDecryptMsg(signature, timestamp, nonce, json.getString("encrypt"));
+        JSONObject eventJson = JSON.parseObject(decryptMsg);
+        Map success = callbackCrypto.getEncryptedMap(DDConf.CALLBACK_RESPONSE, System.currentTimeMillis(), DingCallbackCrypto.Utils.getRandomStr(8));
+        String eventType = eventJson.getString("EventType");
+        if (DDConf.CALLBACK_CHECK.equals(eventType)) {
+            log.info("----- [DD]验证注册 -----");
+            return success;
+        }
+        // [回调任务执行逻辑: 异步] 钉钉超时3s未返回会被记录为失败, 可通过失败接口获取记录
+        if (Arrays.asList(DDConf.BPMS_INSTANCE_CHANGE, DDConf.BPMS_TASK_CHANGE).contains(eventType)) {
+            log.info("[DD]审批回调, {}", eventJson);
+            ddClient_event.callBackEvent_Workflow(eventJson);
+            return success;
+        }
+        log.info("----- [DD]已注册, 未处理的其它回调 -----, {}", eventJson);
+        return success;
+    }
+
+    @PostMapping("robot")
+    McR robot(@RequestBody Map data) {
+        log.info("xxx, {}", data);
+        return McR.success();
+    }
+}

+ 101 - 0
mjava/src/main/java/com/malk/core/AsyncConfig.java

@@ -0,0 +1,101 @@
+package com.malk.core;
+
+//import com.mcli.filter.ExceptionNotice;
+
+import lombok.extern.slf4j.Slf4j;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.aop.interceptor.AsyncUncaughtExceptionHandler;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.scheduling.annotation.AsyncConfigurer;
+import org.springframework.scheduling.annotation.EnableAsync;
+import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
+
+import java.util.concurrent.Executor;
+import java.util.concurrent.ThreadPoolExecutor;
+
+/**
+ * 线程池配置 [函数式编程:详见YDClient/YDService使用说明]
+ * -
+ * 前提: 1. 必须仅适用于 public 实例方法; 2.在同一个类中调用异步方法将无法正常工作(self-invocation); 3. 访问对象必须通过 @Autowired 进行注入, 否则无效
+ * 返回: 1. 一般为 void, 若需要返回值, 使用Future. 2. 不建议使用回调函数: 回调函数没有状态, 若执行过程中出现数据增删, 简单的通过数量判断不可取 [YDService]
+ * 备注: 开启异步在配置类添加 @EnableAsync 类注解, 无需在启动类添加 [非必要]
+ * 扩展:
+ * - 1. 可以搭配 lombok 的 @Synchronized, 实现异步情况下的线程安全. 会等到前一次执行成功后执行后一次线程调用 [若是不使用 @Async, 可能会超时失败, 宜搭不稳定]
+ * - 2. 指定线程 @Async("aliworkConcurrence") 配置, 统一配置调用上限避免超并发 [宜搭, YDClient内并发公共一个@Async配置]
+ */
+@Slf4j
+@Configuration
+@EnableAsync
+public class AsyncConfig implements AsyncConfigurer {
+
+    // 指定类输出日志到指定文件夹
+    private static final Logger logger = LoggerFactory.getLogger("point");
+
+//    @Autowired
+//    private ExceptionNotice notice;
+
+    /**
+     * 异步执行配置
+     *
+     * @async 更建议是为了实现异步, 若有并发等待也可满足
+     * Delayed 函数回调存在线程死锁, 导致内存问题, 已弃用
+     */
+    @Override
+    public Executor getAsyncExecutor() {
+        logger.info("asyncExecutor ▷ 多线程执行");
+        ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();  // 定义线程池
+        taskExecutor.setCorePoolSize(3);  // 设置核心线程
+        taskExecutor.setMaxPoolSize(10);  // 设置最大线程
+        taskExecutor.setQueueCapacity(200);  // 设置线程队列最大线程数
+        taskExecutor.setKeepAliveSeconds(10);  // 允许线程的空闲时间 [勿动]
+        taskExecutor.setThreadNamePrefix("async-executor-");  // 线程池名的前缀
+        taskExecutor.initialize();  // 初始化
+        return taskExecutor;
+    }
+
+    /**
+     * 并发线程配置: 宜搭 [统一配置调用上限避免超过并发, YDClient内并发公共一个@Async配置]
+     *
+     * @Async("aliworkConcurrence") 指定线程
+     */
+    @Bean("aliworkConcurrence")
+    public ThreadPoolTaskExecutor aliworkTaskExecutor() {
+        logger.info("aliworkConcurrence ▷ 多线程初始化");
+        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
+        // 核心线程数:线程池创建时候初始化的线程数 ==> 宜搭并发200次/s, 限流配置 [4核偶尔出现, 3核极其偶尔]
+        executor.setCorePoolSize(2);
+        // 最大线程数:线程池最大的线程数,只有在缓冲队列满了之后才会申请超过核心线程数的线程
+        executor.setMaxPoolSize(2);
+        // 缓冲队列:用来缓冲执行任务的队列
+        executor.setQueueCapacity(200);
+        // 允许线程的空闲时间60秒:当超过了核心线程之外的线程在空闲时间到达之后会被销毁
+        executor.setKeepAliveSeconds(60);
+        // 线程池名的前缀:设置好了之后可以方便我们定位处理任务所在的线程池
+        executor.setThreadNamePrefix("aliwork-concurrence-");
+        // 缓冲队列满了之后的拒绝策略:由调用线程处理(一般是主线程)
+        executor.setRejectedExecutionHandler(new ThreadPoolExecutor.DiscardPolicy());
+        executor.initialize();
+        return executor;
+    }
+
+    /**
+     * 处理异步方法中未捕获的异常
+     * -
+     * ppExt 重要声明
+     * 1. @Async 内若调用了不能并发的方法, 如查询限流, 需要在调用的方法上添加 @Synchronized, 避免异步情况下执行触发限流导致抛出错误到多线程
+     * 2. @Async 方法上建议要添加 @Synchronized, 否则就在多次连续调用的时候, 入参需要做拷贝, 若同一个入参对象连续调用 @Async 会读取最后一次赋值
+     */
+    @Override
+    public AsyncUncaughtExceptionHandler getAsyncUncaughtExceptionHandler() {
+        return (ex, method, params) -> {
+            log.error("Async反馈异常 {}. {}. {}", ex, method, params);  // 记录错误日志
+            try {
+                throw ex;
+            } catch (Throwable throwable) {
+                throwable.printStackTrace();
+            }
+        };
+    }
+}

+ 37 - 0
mjava/src/main/java/com/malk/delegate/DDEvent.java

@@ -0,0 +1,37 @@
+package com.malk.delegate;
+
+import com.fasterxml.jackson.core.JsonProcessingException;
+import org.springframework.scheduling.annotation.Async;
+
+/**
+ * 钉钉事件回调 3_2
+ * -
+ * [主项目若无实现, 项目启动异常; 若子项目有订阅需添加 @Primary 以实现优先注入]
+ * -
+ * 子项目实现接口 [静态代理], 添加对应 processCode 单据业务逻辑
+ * OA审批, 撤销和拒绝流程不继续执行连接器, 通过事件订阅实现实时同步
+ */
+public interface DDEvent {
+
+
+    // todo, 回调做try, 失败记录做存储, 提供查询接口
+
+    // todo, 回调参数统一, 宜搭查询接口统一
+
+    // 审批任务回调执行业务逻辑
+    @Async
+    void executeEvent_Task_Finish(String processInstanceId, String processCode, boolean isAgree, String remark);
+
+    @Async
+    void executeEvent_Task_Start(String processInstanceId, String processCode);
+
+    @Async
+    void executeEvent_Task_Redirect(String processInstanceId, String processCode);
+
+    // 审批实例回调执行业务逻辑
+    @Async
+    void executeEvent_Instance_Finish(String processInstanceId, String processCode, boolean isAgree, boolean isTerminate, String staffId)  ;
+
+    @Async
+    void executeEvent_Instance_Start(String processInstanceId, String processCode);
+}

+ 17 - 0
mjava/src/main/java/com/malk/delegate/McDelegate.java

@@ -0,0 +1,17 @@
+package com.malk.delegate;
+
+import org.springframework.scheduling.annotation.Async;
+
+public interface McDelegate {
+
+    /**
+     * 异步线程回调
+     */
+    @Async
+    void setTimeout(Invoke fn, long millis);
+
+    @FunctionalInterface
+    interface Invoke {
+        void execute();
+    }
+}

+ 49 - 0
mjava/src/main/java/com/malk/delegate/impl/DDImplEvent.java

@@ -0,0 +1,49 @@
+package com.malk.delegate.impl;
+
+import com.malk.delegate.DDEvent;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+/**
+ * OA审批事件 [主项目若无实现, 项目启动异常; 若子项目有订阅需添加 @Primary 以实现优先注入]
+ * -
+ * 取消方案: 撤销和拒绝流程不继续执行连接器, 因此不使用连接器与轮询审批记录方案 [低效而且占用较高钉钉api调次数];
+ * 优化方案: 通过事件订阅实现实时同步所有审批状态, 定时查询钉钉回调失败记录 [配置钉钉事件Delegate, 添加定时任务]
+ */
+@Slf4j
+@Service
+public class DDImplEvent implements DDEvent {
+
+    // 审批任务回调执行业务逻辑
+    @Async
+    @Override
+    public void executeEvent_Task_Finish(String processInstanceId, String processCode, boolean isAgree, String remark) {
+        log.info("executeEvent_Task_Finish: 未被代理");
+    }
+
+    @Async
+    @Override
+    public void executeEvent_Task_Start(String processInstanceId, String processCode) {
+        log.info("executeEvent_Task_Start: 未被代理");
+    }
+
+    @Async
+    @Override
+    public void executeEvent_Task_Redirect(String processInstanceId, String processCode) {
+        log.info("executeEvent_Task_Redirect: 未被代理");
+    }
+
+    @Async
+    @Override
+    public void executeEvent_Instance_Start(String processInstanceId, String processCode) {
+        log.info("executeEvent_Instance_Start: 未被代理");
+    }
+
+    // 审批实例回调执行业务逻辑
+    @Async
+    @Override
+    public void executeEvent_Instance_Finish(String processInstanceId, String processCode, boolean isAgree, boolean isTerminate, String staffId) {
+        log.info("executeEvent_Instance_Finish: 未被代理");
+    }
+}

+ 21 - 0
mjava/src/main/java/com/malk/delegate/impl/McImplDelegate.java

@@ -0,0 +1,21 @@
+package com.malk.delegate.impl;
+
+import com.malk.delegate.McDelegate;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+@Service
+@Slf4j
+public class McImplDelegate implements McDelegate {
+
+    @SneakyThrows
+    @Async
+    @Override
+    public void setTimeout(Invoke fn, long millis) {
+
+        Thread.sleep(millis);
+        fn.execute();
+    }
+}

+ 215 - 0
mjava/src/main/java/com/malk/filter/CatchException.java

@@ -0,0 +1,215 @@
+package com.malk.filter;
+
+import com.alibaba.fastjson.JSONException;
+import com.malk.server.common.McException;
+import com.malk.server.common.McR;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.converter.HttpMessageNotReadableException;
+import org.springframework.validation.BindException;
+import org.springframework.validation.FieldError;
+import org.springframework.web.HttpMediaTypeNotSupportedException;
+import org.springframework.web.bind.MethodArgumentNotValidException;
+import org.springframework.web.bind.MissingServletRequestParameterException;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestController;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
+
+import javax.validation.ConstraintDeclarationException;
+import javax.validation.ConstraintViolationException;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * 统一错误拦截
+ * -
+ * 参数校验 @Validated【javax.validation】
+ * 1. @Validated 针对实体类进行字段入参校验,可做分组,实现不同接口请求下字段的校验规则
+ * 2. @RestControllerAdvice 搭配 @ExceptionHandler 注解进行错误统一捕获和拦截返回,无需 try…catch 即可格式化返回标准输出::注意类名
+ * 3. 若无分组验证需求,可将@Validated 置于类上,不需要在@requestBody前声明。分组验证,可以在实体类组合好,若是取并集,则可直接在@requestBody上设置多个即可
+ * -
+ * 拦截param自定义message: @RequestParam默认是required,若想使用该检验,请求方法无需添加@RequestParam,且需要在Controller上添加@Validated
+ * 参数@RequestParam,可以配置默认值,指定name,指定name后可以设置是否必填。若是要获取所有(@RequestParam MultiValueMap<String, String> paramMap),每一个字段的值是一个集合
+ * -
+ * 关于在Service上使用校验, 首先需要impl类上添加@Validated [和Controller一致]
+ * 建议注解到方法声明上, 抛出到ConstraintViolationException. 若校验注解到方法实现上, 则错误抛出到 ConstraintDeclarationException, 没有友好的提示
+ * -
+ * -
+ * 若入参是Body,需要使用@RequestBody解析到实体/Map内,校验错误抛出到MethodArgumentNotValidException。@RequestBody @Validated({YDParam.Retrieve_Condition.class}) YDParam param
+ * 如入参是formData,不能使用@RequestBody,参数会自动解析到实体; 若不是实体则需要通过方法转Map, 错会抛出到BindException. @Validated({YDParam.Retrieve_Condition.class}) YDParam param
+ * -
+ * 使用@RestController后,请求方法无需再使用@RequestBody,@RequestParam,直接可对入参进行修饰,效果类似解构,除了正常获取HttpServletRequest外,相关参数自动处理到注解内
+ * RESTFul 风格接口, 参数获取方式均相同, GET 没有 body, @PathVariable 获取 url 占位符, RequestParam 获取 url 参数, @RequestBody 获取 body 数据
+ * -
+ * 关于入参为JSONString类型: body内的json需要传入字符串, 若为对象会解析报错. 若是formData, 传入对象会自动转为字符串, 不会解析错误, 无需转为JSONString
+ * 注解@JsonInclude(JsonInclude.Include.NON_NULL):类注解过滤null字段,包含入参和返回值【参数类搭配 @Data,才可以实例化返回值以及ToString输出】
+ */
+@Slf4j
+@RestControllerAdvice(annotations = RestController.class)
+public class CatchException<T> {
+
+//    @Autowired
+//    private ExceptionNotice notice;
+
+    /**
+     * 通用错误类抛出
+     */
+    @ExceptionHandler(McException.class)
+    public McR McException(McException e) {
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.R(e.isSuccess(), e.getCode(), e.getMessage(), null, e.getSource());  // IGNORE_EXECUTE 为成功
+    }
+
+    /**************** validated不合法 ****************/
+
+    /**
+     * @validated 验证入参对象 @Validated({YDParam.Retrieve_Condition.class}) YDParam param
+     * -
+     * 如入参是formData,不能使用@RequestBody,参数会自动解析到实体; 若不是实体通过方法转Map .报错会抛出到BindException。@Validated({YDParam.Retrieve_Condition.class}) YDParam param
+     */
+    @ExceptionHandler(value = BindException.class)
+    public McR BindException(BindException e) {
+        List errorParam = new ArrayList();
+        e.getFieldErrors().forEach(fieldError -> {
+            errorParam.add(fieldError.getField() + ": " + fieldError.getDefaultMessage());
+        });
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.errorParam("formData参数 -> " + String.join(", ", errorParam));
+    }
+
+    /**
+     * @Validated 关于在Service上使用校验,
+     * -
+     * 首先需要impl类上添加@Validated [和Controller一致]
+     * 建议注解到方法声明上, 抛出到ConstraintViolationException. 若校验注解到方法实现上, 则错误抛出到 ConstraintDeclarationException, 没有友好的提示
+     */
+    @ExceptionHandler(ConstraintDeclarationException.class)
+    public McR ConstraintDeclarationException(ConstraintDeclarationException e) {
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.errorParam("param参数 -> " + e.getMessage());
+    }
+
+    /**
+     * @validated 验证入参字段 @NotNull(message = "cur不能为空") String cur
+     * -
+     * 若入参是Body,需要使用@RequestBody解析到实体内,校验错误抛出到MethodArgumentNotValidException。@RequestBody @Validated({YDParam.Retrieve_Condition.class}) YDParam param
+     * 如入参是formData,不能使用@RequestBody,参数会自动解析到实体; 若不是实体通过方法转Map .报错会抛出到BindException。@Validated({YDParam.Retrieve_Condition.class}) YDParam param
+     * 关于入参为JSONString类型: body内的json需要传入字符串, 若为对象会解析报错. 若是formData, 传入对象会自动转为字符串, 不会解析错误, 无需转为JSONString
+     */
+    @ExceptionHandler(ConstraintViolationException.class)
+    public McR ConstraintViolationException(ConstraintViolationException e) {
+        List errorParam = new ArrayList();
+        e.getConstraintViolations().forEach(fieldError -> {
+            errorParam.add(fieldError.getPropertyPath() + ": " + fieldError.getMessage());
+        });
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.errorParam("param参数 -> " + String.join(", ", errorParam));
+    }
+
+    /**
+     * @validated 验证入参对象 @RequestBody @Validated({YDParam.Retrieve_Condition.class}) YDParam param
+     * -
+     * 若入参是Body,需要使用@RequestBody解析到实体内,校验错误抛出到MethodArgumentNotValidException。@RequestBody @Validated({YDParam.Retrieve_Condition.class}) YDParam param
+     */
+    @ExceptionHandler(MethodArgumentNotValidException.class)
+    public McR MethodArgumentNotValidException(MethodArgumentNotValidException e) {
+        List errorParam = new ArrayList();
+        e.getBindingResult().getAllErrors().forEach(err -> {
+            FieldError fieldError = (FieldError) err;
+            errorParam.add(fieldError.getField() + ": " + fieldError.getDefaultMessage());
+        });
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.errorParam("body参数 -> " + String.join(", ", errorParam));
+    }
+
+    /**************** 请求参数异常 ****************/
+
+    /**
+     * @RequestParam默认是required 验证入参字段 @RequestParam String cur
+     * -
+     * 参数@RequestParam,可以配置默认值,指定name,指定name后可以设置是否必填。若是要获取所有(@RequestParam MultiValueMap<String, String> param
+     */
+    @ExceptionHandler(MissingServletRequestParameterException.class)
+    public McR MissingServletRequestParameterException(MissingServletRequestParameterException e) {
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.errorParam("param参数 -> " + e.getMessage());
+    }
+
+    /**
+     * @RequestParam 参数类型解析异常
+     */
+    @ExceptionHandler(MethodArgumentTypeMismatchException.class)
+    public McR MethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) {
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.errorParam("param参数 ->  " + e.getName() + "不合法: " + e.getMessage());
+    }
+
+    /**
+     * @RequestBody 序列化字段为非Sting类型
+     * -
+     * 关于入参为JSONString类型: body内的json需要传入字符串, 若为对象会解析报错. 若是formData, 传入对象会自动转为字符串, 不会解析错误, 无需转为JSONString
+     */
+    @ExceptionHandler(HttpMessageNotReadableException.class)
+    public McR HttpMessageNotReadableException(HttpMessageNotReadableException e) {
+        log.error(e.getMessage(), e);  // 记录错误日志
+        if (e.getMessage().contains("Required request body is missing")) {
+            return McR.errorParam("请求体参数不能为空");
+        }
+        return McR.errorParam("请求体参数格式不合法");
+    }
+
+    /**
+     * Content-Type不合法
+     */
+    @ExceptionHandler(HttpMediaTypeNotSupportedException.class)
+    public McR HttpMediaTypeNotSupportedException(HttpMediaTypeNotSupportedException e) {
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.errorParam("Content-Type不合法: " + e.getMessage());
+    }
+
+    /**************** 网络请求异常 ****************/
+
+    /**
+     * http请求返回json解析异常
+     */
+    @ExceptionHandler(JSONException.class)
+    public McR JSONException(JSONException e) {
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.errorParam("返回JSON解析异常: " + e.getMessage());
+    }
+
+    /**************** 数据类型异常 ****************/
+
+    /**
+     * BigDecimal 非数值型字符串, 空字符串取值异常
+     */
+    @ExceptionHandler(NumberFormatException.class)
+    public McR NumberFormatException(NumberFormatException e) {
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.errorParam(e.getMessage() + ": 非数值型字符串, 空字符串取值异常");
+    }
+
+    /**
+     * 反射未匹配到路径
+     */
+    @ExceptionHandler(ClassNotFoundException.class)
+    public McR ClassNotFoundException(ClassNotFoundException e) {
+        log.error(e.getMessage(), e);  // 记录错误日志
+        return McR.errorParam("反射未匹配到路径: " + e.getMessage());
+    }
+
+    /**
+     * 系统错误抛出
+     * -
+     * NOTE: 空指针判定, 日志目前会记录在warn内, 不输出到error内, 做一层包装返回 (空指针message为null)
+     */
+    @ExceptionHandler(Exception.class)
+    public McR Exception(Exception e) {
+        log.error(e.getMessage(), e);       // 记录错误日志
+//        notice.noticeErrorByDingtalk(e);    // 上报错误日志
+        if (e instanceof NullPointerException) {
+            return McR.errorNullPointer();
+        }
+        return McR.errorUnknown(e.getMessage());
+    }
+}

+ 53 - 0
mjava/src/main/java/com/malk/filter/ExceptionNotice.java

@@ -0,0 +1,53 @@
+//package com.mcli.filter;
+//
+//import cn.hutool.core.util.ObjectUtil;
+//import com.alibaba.fastjson.JSONObject;
+//import com.mcli.com.mcli.mcli.utils.UtilDateTime;
+//import com.mcli.service.common.McConf;
+//import com.mcli.service.common.McException;
+//import com.mcli.service.dingtalk.DDConf;
+//import lombok.extern.slf4j.Slf4j;
+//import org.springframework.beans.factory.annotation.Autowired;
+//import org.springframework.stereotype.Component;
+//
+//import java.util.Date;
+//
+///**
+// * 报错消息: 发送钉钉工作通知
+// */
+//@Slf4j
+//@Component
+//public class ExceptionNotice {
+//
+//    @Autowired
+//    private DDClient ddClient;
+//
+//    @Autowired
+//    private DDConf ddConf;
+//
+//    @Autowired
+//    private McConf mcConf;
+//
+//    /**
+//     * 上报错误日志 [未知错误]
+//     */
+//    public void noticeErrorByDingtalk(Exception e) {
+//        // 通知人为空为空则忽略
+//        if (ObjectUtil.isNull(mcConf.getEngineers()) || mcConf.getEngineers().isEmpty()) {
+//            return;
+//        }
+//        String content = e.getMessage();
+//        if (e instanceof McException) {
+//            McException mcE = (McException) e;
+//            content = mcE.toString();
+//        }
+//        log.warn("上报错误日志, {}, {}", mcConf.getEngineers(), content);
+//        content += " --> " + UtilDateTime.formatDateTime(new Date());
+//        JSONObject message = new JSONObject();
+//        message.put("msgtype", "text");
+//        JSONObject info = new JSONObject();
+//        info.put("content", content);
+//        message.put("text", info);
+//        ddClient.sendCorpConversationMessage(ddConf.getAgentId(), mcConf.getEngineers(), null, false, message);
+//    }
+//}

+ 32 - 0
mjava/src/main/java/com/malk/filter/RequestFilter.java

@@ -0,0 +1,32 @@
+package com.malk.filter;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+
+import javax.servlet.*;
+import java.io.IOException;
+
+@Component
+public class RequestFilter implements Filter {
+
+    // 指定类输出日志到指定文件夹
+    private static final Logger logger = LoggerFactory.getLogger("point");
+
+    @Override
+    public void init(FilterConfig filterConfig) throws ServletException {
+        logger.info("过滤器 ▷ 初始化");
+    }
+
+    @Override
+    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
+        logger.trace("过滤器 ▷ 开始执行");
+        chain.doFilter(request, response);
+        logger.trace("过滤器 ▷ 执行结束");
+    }
+
+    @Override
+    public void destroy() {
+        logger.info("过滤器 ▷ 销毁");
+    }
+}

+ 41 - 0
mjava/src/main/java/com/malk/filter/RequestInterceptor.java

@@ -0,0 +1,41 @@
+package com.malk.filter;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.stereotype.Component;
+import org.springframework.web.servlet.ModelAndView;
+import org.springframework.web.servlet.handler.HandlerInterceptorAdapter;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+/**
+ * 请求拦截器: 继承 HandlerInterceptor 或实现 HandlerInterceptor
+ */
+@Component
+public class RequestInterceptor extends HandlerInterceptorAdapter {
+
+    // 指定类输出日志到指定文件夹
+    private static final Logger logger = LoggerFactory.getLogger("point");
+
+    // 请求拦截
+    @Override
+    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
+        logger.info("拦截器 ▷ 收到请求: {}", request.getServletPath());
+        return super.preHandle(request, response, handler);
+    }
+
+    // 视图渲染
+    @Override
+    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
+        logger.trace("拦截器 ▷ 视图渲染");
+        super.postHandle(request, response, handler, modelAndView);
+    }
+
+    // 数据返回
+    @Override
+    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
+        logger.trace("拦截器 ▷ 数据返回");
+        super.afterCompletion(request, response, handler, ex);
+    }
+}

+ 189 - 0
mjava/src/main/java/com/malk/server/aliwork/YDConf.java

@@ -0,0 +1,189 @@
+package com.malk.server.aliwork;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.utils.UtilMap;
+import com.malk.utils.UtilString;
+import lombok.Data;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+@Data
+@Component
+@ConfigurationProperties(prefix = "aliwork")
+@Slf4j
+public class YDConf {
+
+    private String appType;
+
+    private String systemToken;
+
+    /**
+     * 一个切片数量上限
+     */
+    public static final Integer UPPER_LIMIT = 30000;
+
+    /**
+     * 一个分页数量上限
+     */
+    public static final Integer PAGE_SIZE_LIMIT = 100;
+
+    /**
+     * 接口访问账号 [不能触发待办与消息通知, 业务规则亦不能]
+     * -
+     * 非管理员账号只可以查询, 不检验权限
+     * 操作数据需要管理员账号, 或宜搭平台
+     */
+    public static final String PUB_ACCOUNT = "yida_pub_account";
+
+    ////////////////////////// 新版本API //////////////////////////
+
+    /**
+     * 查询表单
+     */
+    public enum FORM_QUERY {
+
+        retrieve_list,              // 全局查询, 不包含子表单
+        retrieve_list_all,          // 全局查询, 包含子表单
+        multi_retrieve_id,
+
+        retrieve_id,                // 单个ID查询 todo 若秘钥不匹配, 返回空, 添加报错说明
+
+        retrieve_search_process,            // 流程列表
+        retrieve_search_form,               // 表单列表
+
+        retrieve_search_process_id,         // 流程列表
+        retrieve_search_form_id,             // 表单列表
+
+        retrieve_details,             // 子表数据[表单]
+        retrieve_changed,    // 变更记录
+        retrieve_definition, // 表单定义
+
+    }
+
+    /**
+     * 表单操作
+     */
+    public enum FORM_OPERATION {
+        create,
+        delete,             // 传入为body, 文档为param
+        update,
+        upsert,                     // insertOrUpdate
+        upsert_v2,// 新版本 searchCondition 条件精准匹配
+        multi_create,               // 批量操作
+        delete_batch,               // 批量删除
+        multi_update,               // 批量更新
+        start,                      // 发起流程
+        batchSave,                  // 批量创建
+    }
+
+    /**
+     * 关联表单处理
+     *
+     * @param formType: "receipt" 跳转为表单【若是流程,则权限体系会失效,但也会显示审批节点】; formType: "process" 为流程
+     * @apiNote ppExt 接口更新, 传入jsonString或List都可以. 返回数据都是两层json解析, 组件 + _id格式
+     */
+    public List<Map> associationForm(String formUuid, String formInstanceId, String title, String subTitle, boolean isProcess) {
+        return associationForm(appType, formUuid, formInstanceId, title, subTitle, isProcess);
+    }
+
+    // todo 部门ID写入需要是string集合\数组, 封装
+    public static List<Map> associationForm(String appType, String formUuid, String formInstanceId, String title, String subTitle, boolean isProcess) {
+        String formType = isProcess ? "process" : "receipt";
+        return Arrays.asList(UtilMap.map("appType, formUuid, instanceId, title, subTitle, formType", appType, formUuid, formInstanceId, title, subTitle, formType));
+    }
+
+    /**
+     * 读取关联表单: ppExt 两层解析后才是List, 若是赋值取值转一层, 宜搭也可进行写入
+     * - 赋值说明 -
+     * 若是同一个应用,appType与formUuid为空(不指定下formUuid可为错误,但appType只能为空),点击页面链接会自动匹配;
+     * 若是跨应用实例,则不能通过导入,需要通过接口指定appType
+     */
+    public List<Map> associationForm(String associations) {
+        if (UtilString.isBlankCompatNull(associations)) {
+            return new ArrayList<>();
+        }
+        return (List<Map>) JSON.parse(String.valueOf(JSON.parse(String.valueOf(associations))));
+    }
+
+    /**
+     * 高级筛选: ppExt 通过表单\流程 search 宜搭目前下拉框也会被模糊匹配, 为避免事后过滤, 使用高级查询; 且高级查询支持同一个字段多条件
+     */
+    public static Map searchCondition_TextFiled(String compId, Object value, String operator) {
+        return UtilMap.map("key, value, type, operator, componentName", compId, value, "TEXT", operator, "TextField");
+    }
+
+    // todo text组件是否可用于select
+    public static Map searchCondition_processInstanceStatus(List status) {
+        return UtilMap.map("key, value, type, operator, componentName", "processInstanceStatus", status, "ARRAY", "in", "SelectField");
+    }
+
+    /**
+     * 组件格式化取值 [取值] todo 明细表递归, 循环传递入参
+     */
+    public static Object getDataByCompId(Map formData, String compId_cur) {
+        if (compId_cur.contains("associationFormField_")) {
+            // 服务注册 #{_yida_all_data} 直接组件Id
+            if (!formData.containsKey(compId_cur)) {
+                // 接口返回数据, 关联组件ID, 不会返回组件id字段
+                compId_cur = compId_cur + "_id";
+            }
+            return JSON.parse(String.valueOf(formData.get(compId_cur)));
+        } else if (compId_cur.contains("employeeField_")) {
+            if (!formData.containsKey(compId_cur + "_id")) {
+                // 服务注册 #{_yida_all_data} 返回JsonString userid 数组
+                return JSON.parse(String.valueOf(formData.get(compId_cur)));
+            } else {
+                // 成员组件, 接口返回组件id返回为姓名, _id返回是userId
+                return formData.get(compId_cur + "_id");
+            }
+        }
+        return formData.get(compId_cur);
+    }
+
+    ////////////////////////// 老版本API //////////////////////////
+
+    /**
+     * 表单接口适用于流程: 查询和更新以及删除
+     */
+    private static String formatApiForm(String uri) {
+        return "/yida_vpc/form/" + uri + ".json";
+    }
+
+    private static String formatApiProcess(String uri) {
+        return "/yida_vpc/process/" + uri + ".json";
+    }
+
+    /**
+     * 接口地址: 表单
+     */
+    public static final String API_FORM_CREATE = formatApiForm("saveFormData");
+    public static final String API_FORM_DELETE = formatApiForm("deleteFormData");
+    public static final String API_FORM_UPDATE = formatApiForm("updateFormData");
+    public static final String API_FORM_DETAIL = formatApiForm("getFormDataById");
+    public static final String API_FORM_QUERY_ID = formatApiForm("searchFormDataIds");
+    public static final String API_FORM_QUERY_DATA = formatApiForm("searchFormDatas");
+
+    public static final String API_FORM_DEFINITION = "/yida_vpc/formDesign/getFormComponentDefinationList.json";
+
+    /**
+     * 接口地址: 流程
+     */
+    public static final String API_PROCESS_CREATE = formatApiProcess("startInstance");
+    public static final String API_PROCESS_DELETE = formatApiProcess("deleteInstance");
+    public static final String API_PROCESS_UPDATE = formatApiProcess("updateInstance");
+    public static final String API_PROCESS_DETAIL = formatApiProcess("getInstanceById");
+    public static final String API_PROCESS_BATCH_DETAIL = formatApiProcess("getInstancesByIds");
+    public static final String API_PROCESS_QUERY_ID = formatApiProcess("getInstanceIds");
+    public static final String API_PROCESS_QUERY_DATA = formatApiProcess("getInstances");
+
+    /**
+     * 其它接口
+     */
+    public static final String API_OPEN_URL = "/yida_vpc/file/getOpenUrl.json";
+}

+ 224 - 0
mjava/src/main/java/com/malk/server/aliwork/YDParam.java

@@ -0,0 +1,224 @@
+package com.malk.server.aliwork;
+
+import com.alibaba.fastjson.JSONObject;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.apache.commons.lang3.StringUtils;
+
+import javax.validation.constraints.NotNull;
+import javax.validation.constraints.Null;
+import javax.validation.groups.Default;
+import java.util.List;
+
+/**
+ * 表单接口适用于流程: 查询和更新以及删除 [参数校验参考CatchException]
+ */
+@Data
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class YDParam {
+
+    /**
+     * 接口参数
+     */
+    @NotNull(message = "应用编码不能为空")
+    private String appType;
+
+    @Null(message = "应用秘钥无需传递")
+    private String systemToken;
+
+    // 默认宜搭平台, 有操作数据权限
+    @Builder.Default
+    private String userId = YDConf.PUB_ACCOUNT;
+
+    @NotNull(message = "表单编码不能为空", groups = {Create.class, Retrieve_Condition.class, Definition.class, Create_Process.class, Retrieve_Condition_Update.class})
+    private String formUuid;
+
+    /// FIXME: 表单/流程组件查询
+    private String searchFieldJson;
+
+    @Builder.Default
+    private Integer currentPage = 1;
+
+    @Builder.Default
+    private Integer pageSize = YDConf.PAGE_SIZE_LIMIT;
+
+    // 流程暂不支持排序
+    private String dynamicOrder;
+
+    // 修改开始时间, ppExt: 需要同时传递开始和就结束才会生效; 其次日期格式yyyy-MM-dd, 但默认是0点, 结束日期j建议加1
+    private String modifiedFromTimeGMT;
+
+    // 修改结束时间, ppExt: 需要同时传递开始和就结束才会生效; 其次日期格式yyyy-MM-dd, 但默认是0点, 结束日期j建议加1
+    private String modifiedToTimeGMT;
+
+    @NotNull(message = "实例ID不能为空", groups = {Update.class, Delete.class, Retrieve_FormInstId.class})
+    private String formInstId;
+
+    @NotNull(message = "更新内容不能为空", groups = {Update.class, Update_ProcessInstanceId.class, Retrieve_Condition_Update.class})
+    private String updateFormDataJson;
+
+    @NotNull(message = "流程编码不能为空", groups = Create_Process.class)
+    private String processCode;
+
+    // todo: 成员组件未匹配, 接口可以新增 [系统会忽略], 导入会提示无法匹配
+    @NotNull(message = "新增内容不能为空", groups = {Create.class, Create_Process.class})
+    public String formDataJson;
+
+    @NotNull(message = "实例ID不能为空", groups = {Update_ProcessInstanceId.class, Delete_ProcessInstanceId.class, Retrieve_ProcessInstanceId.class})
+    private String processInstanceId;
+
+    @NotNull(message = "实例ID不能为空", groups = Retrieve_ProcessInstanceIds.class)
+    private String processInstanceIds;
+
+    private String instanceStatus;
+
+    private String approvedResult;
+
+    /**
+     * 格式化赋值: 内部使用
+     */
+    public void setDynamicOrder(String dynamicOrder) {
+        this.dynamicOrder = dynamicOrder;
+    }
+
+    public void setDynamicOrder(String rule, String... compIds) {
+        for (String compId : compIds) {
+            JSONObject orderJson = new JSONObject();
+            orderJson.put(compId, rule);
+            this.dynamicOrder = orderJson.toJSONString();
+        }
+    }
+
+    /**
+     * 自定义参数
+     */
+    private String compIdSerial; // 流水号排序字段
+
+    /**
+     * 钉钉新版本接口
+     */
+    @Builder.Default
+    private Integer pageNumber = 1;
+
+    private String formInstanceId;
+
+    // formInstanceId 为新版本实例id字段
+    public String getFormInstanceId() {
+        if (StringUtils.isBlank(formInstanceId)) {
+            return formInstId;
+        }
+        return formInstanceId;
+    }
+
+    /// FIXME: 表单/流程全局查询
+    private String searchCondition;
+
+    // 全局条件查询列表数据 FIXME: 若是查询组件也传searchFieldJson格式
+    public String getSearchCondition() {
+        if (StringUtils.isBlank(searchCondition)) {
+            return searchFieldJson;
+        }
+        return searchCondition;
+    }
+
+    // 查询列表可查询多个指定实例ID | 批量删除 [上限50] | 批量更新
+    private List<String> formInstanceIdList;
+
+    // 是否需要宜搭表单组件格式的实例数据
+    private boolean needFormInstanceValue = false;
+
+    // 查询明细数据列表, 默认仅返回前50
+    private String tableFieldId;
+
+    // 附件转临时免登地址
+    private String fileUrl;
+    // 临时地址失效时间, 默认10分钟, 最大值24小时
+    @Builder.Default
+    private Integer timeout = 600000;
+
+    /**
+     * 批量删除
+     */
+
+    // 是否需要宜搭服务端异步执行该任务
+    @Builder.Default
+    boolean asynchronousExecution = false;
+
+    // 是否需要触发表单绑定的校验规则、关联业务规则和第三方服务回调
+    @Builder.Default
+    boolean executeExpression = false;
+
+    // 是否不触发表单绑定的校验规则、关联业务规则和第三方服务回调。
+    @Builder.Default
+    boolean noExecuteExpression = true;
+
+    // 是否忽略空值。
+    @Builder.Default
+    boolean ignoreEmpty = true;
+
+    // 是否使用最新的表单schema版本。
+    @Builder.Default
+    boolean useLatestFormSchemaVersion = true;
+
+    // 使用最新的表单版本进行更新。
+    @Builder.Default
+    boolean useLatestVersion = false;
+
+    /**
+     * 分组校验
+     *
+     * @RequestBody @Validated(YDParam.Create.class) YDParam ydParam
+     */
+
+    public interface Create extends Default {
+
+    }
+
+    public interface Create_Process extends Default {
+
+    }
+
+    public interface Retrieve_Condition extends Default {
+
+    }
+
+    public interface Retrieve_Condition_Update extends Default {
+
+    }
+
+    public interface Retrieve_FormInstId extends Default {
+
+    }
+
+    public interface Retrieve_ProcessInstanceId extends Default {
+
+    }
+
+    public interface Retrieve_ProcessInstanceIds extends Default {
+
+    }
+
+    public interface Update extends Default {
+
+    }
+
+    public interface Update_ProcessInstanceId extends Default {
+
+    }
+
+    public interface Delete extends Default {
+
+    }
+
+    public interface Delete_ProcessInstanceId extends Default {
+
+    }
+
+    public interface Definition extends Default {
+
+    }
+}

+ 32 - 0
mjava/src/main/java/com/malk/server/aliwork/YDR.java

@@ -0,0 +1,32 @@
+package com.malk.server.aliwork;
+
+import com.malk.server.common.Page;
+import com.malk.utils.UtilMap;
+import lombok.Data;
+
+import java.util.Map;
+
+/**
+ * * 返回数据_宜搭
+ **/
+@Data
+public class YDR {
+
+    private boolean success;
+
+    private String errorCode;
+
+    private String errorMsg;
+
+    // 查询为data之集合, 新增为实例Id, 删除/更新为空, ....
+    private Object result;
+
+    /**
+     * 宜搭表格分页管理
+     */
+    public static final Map formatPage(Page<Map> page) {
+        Map data = UtilMap.map("currentPage, pageSize, totalCount", page.getNumber() + 1, page.getNumberOfElements(), page.getTotalElements());
+        data.put("data", page.getContent());
+        return data;
+    }
+}

+ 42 - 0
mjava/src/main/java/com/malk/server/common/FilePath.java

@@ -0,0 +1,42 @@
+package com.malk.server.common;
+
+import lombok.Data;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.stereotype.Component;
+
+/**
+ * 读取配置文件参考McConf
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "file")
+public class FilePath {
+
+    @Autowired
+    private Path path;
+
+    @Autowired
+    private Source source;
+
+    @Data
+    @Configuration
+    @ConfigurationProperties(prefix = "file.path")
+    public class Path {
+
+        private String file;
+
+        private String image;
+
+        private String tmp;
+    }
+
+    @Data
+    @Configuration
+    @ConfigurationProperties(prefix = "file.source")
+    public class Source {
+
+        private String fonts;
+    }
+}

+ 167 - 0
mjava/src/main/java/com/malk/server/common/McException.java

@@ -0,0 +1,167 @@
+package com.malk.server.common;
+
+import com.malk.utils.UtilServlet;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.Map;
+
+/**
+ * 通用错误类 [错误抛出与拦截详见 CatchException]
+ */
+@Data
+@Builder
+@NoArgsConstructor
+@AllArgsConstructor
+public class McException extends RuntimeException {
+
+    private static final long serialVersionUID = 5131110381034275224L;
+
+    private boolean success;
+
+    private String code;
+
+    private String message;
+
+    // 错误来源
+    private String source;
+
+    public McException(McREnum rEnum) {
+        this(rEnum.isSuc(), rEnum.getCode(), rEnum.getMsg());
+    }
+
+    public McException(String code, String message) {
+        this(false, code, message);
+    }
+
+    public McException(boolean suc, String code, String message) {
+        super(message);
+        this.success = suc;
+        this.code = code;
+        this.message = message;
+    }
+
+
+    /// 断言错误: 自定义错误信息 ///
+
+    /**
+     * 错误断言: 枚举
+     */
+    public static void assertException(boolean isAssert, McREnum rEnum) {
+        if (isAssert) {
+            throw McException.builder().code(rEnum.getCode()).message(rEnum.getMsg()).build();
+        }
+    }
+
+    /**
+     * 错误断言: 信息
+     */
+    public static void assertException(boolean isAssert, String code, String message) {
+        if (isAssert) {
+            throw McException.builder().code(code).message(message).build();
+        }
+    }
+
+    /**
+     * 错误断言: 来源
+     */
+    public static void assertException(boolean isAssert, String code, String message, String source) {
+        if (isAssert) {
+            throw McException.builder().code(code).message(message).source(source).build();
+        }
+    }
+
+    /**
+     * 断言: 业务校验不通过
+     */
+    public static void
+    assertAccessException(boolean isAssert, String message) {
+        if (isAssert) {
+            throw McException.builder().code(McREnum.VALIDATED_ACCESS.getCode()).message(message).build();
+        }
+    }
+
+    /**
+     * 断言: 参数校验不通过
+     */
+    public static void assertParamException(boolean isAssert, String message) {
+        if (isAssert) {
+            throw McException.builder().code(McREnum.VALIDATED_PARAM.getCode()).message(message).build();
+        }
+    }
+
+    /**
+     * 断言: 参数不合法
+     * -
+     * 实例: McException.assertParamException_Null(UtilServlet.isNull(param, "projectName", "userName"));
+     */
+    public static void assertParamException_Null(String key) {
+        if (StringUtils.isNotBlank(key)) {
+            throw McException.builder().code(McREnum.VALIDATED_PARAM.getCode()).message(("参数不能为空: ").concat(key)).build();
+        }
+    }
+
+    public static void assertParamException_Null(Map param, String... keys) {
+        McException.assertParamException_Null(UtilServlet.isNull(param, keys));
+    }
+
+    public static void assertParamException_Null(Map param, String keys) {
+        McException.assertParamException_Null(UtilServlet.isNull(param, keys));
+    }
+
+    /**
+     * 断言: 参数不能为空
+     */
+    public static void assertParamException_Rule(String key) {
+        if (StringUtils.isNotBlank(key)) {
+            throw McException.builder().code(McREnum.VALIDATED_PARAM.getCode()).message(("参数不合法: ").concat(key)).build();
+        }
+    }
+
+    /// 快速枚举: 自定义错误信息 ///
+
+    /**
+     * 参数不合法
+     */
+    public static McR exceptionParam(String message) {
+        throw McException.builder().code(McREnum.VALIDATED_PARAM.getCode()).message(message).build();
+    }
+
+    /**
+     * 无效token
+     */
+    public static McR exceptionToken(String message) {
+        throw McException.builder().code(McREnum.TOKEN_INVALID.getCode()).message(message).build();
+    }
+
+    /**
+     * 授权无效
+     */
+    public static McR exceptionAuth(String message) {
+        throw McException.builder().code(McREnum.NOT_AUTHORIZED.getCode()).message(message).build();
+    }
+
+    /**
+     * 业务校验不通过
+     */
+    public static McR exceptionAccess(String message) {
+        throw McException.builder().code(McREnum.VALIDATED_ACCESS.getCode()).message(message).build();
+    }
+
+    /**
+     * 方法执行失败
+     */
+    public static McR exceptionExecute(String message) {
+        throw McException.builder().code(McREnum.METHOD_EXECUTE.getCode()).message(message).build();
+    }
+
+    /**
+     * 未知错误
+     */
+    public static McR exceptionUnknown(String message) {
+        throw McException.builder().code(McREnum.UNKNOWN_EXCEPTION.getCode()).message(message).build();
+    }
+}

+ 39 - 0
mjava/src/main/java/com/malk/server/common/McPage.java

@@ -0,0 +1,39 @@
+package com.malk.server.common;
+
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+
+/**
+ * 分页集合数据结构
+ */
+@Data
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class McPage {
+
+    private int page = 1;
+
+    private int size;
+
+    private long total = 0;
+
+    private List list;
+
+    public static McPage page(Page page) {
+        return page(page, page.getContent());
+    }
+
+    public static McPage page(Page page, List dataList) {
+        return McPage.builder()
+                .total(page.getTotalElements())
+                .page(page.getNumber())
+                .size(page.getSize())
+                .list(dataList)
+                .build();
+    }
+}

+ 104 - 0
mjava/src/main/java/com/malk/server/common/McR.java

@@ -0,0 +1,104 @@
+package com.malk.server.common;
+
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * 定义返回值结构信息
+ * - java使用了未经检查或不安全的操作,编译会有打印,如泛型 T
+ * 单个文件屏蔽: @SuppressWarnings("unchecked") 。注解在类上编辑就不会有输出
+ * 插件显示警告: 配置 -Xlint:unchecked, 显示具体报错警告位置, 如 Map, List 也会报警告
+ */
+@Data
+@Builder
+public class McR<T>  {
+
+    private boolean success;
+    private String code;
+    private String message;
+    private T data;
+
+    // 错误来源
+    private String source;
+
+    /// 错误实例 ///
+
+    public McR(McREnum rEnum, T data) {
+        this(rEnum.isSuc(), rEnum.getCode(), rEnum.getMsg(), data);
+    }
+
+    public McR(boolean success, String code, String message, T data, String source) {
+        this.success = success;
+        this.code = code;
+        this.message = message;
+        this.data = data;
+        this.source = source;
+    }
+
+    public McR(boolean success, String code, String message, T data) {
+        this(success, code, message, data, null);
+    }
+
+    /// 静态访问 ///
+
+    public static McR R(McREnum rEnum) {
+        return new McR(rEnum, null);
+    }
+
+    public static McR R(McREnum rEnum, Object data) {
+        return new McR(rEnum, data);
+    }
+
+    public static McR R(boolean success, String code, String message, Object data, String source) {
+        return new McR(success, code, message, data, source);
+    }
+
+    /// 快速访问: 实例 ///
+
+    public static McR error(String code, String message) {
+        return R(false, code, message, null, null);
+    }
+
+    public static McR errorParam(String message) {
+        return error(McREnum.VALIDATED_PARAM.getCode(), message);
+    }
+
+    public static McR errorAccess(String message) {
+        return error(McREnum.VALIDATED_ACCESS.getCode(), message);
+    }
+
+    public static McR errorAuth(String message) {
+        return error(McREnum.NOT_AUTHORIZED.getCode(), message);
+    }
+
+    public static McR errorVendor(String message, String source) {
+        return R(false, McREnum.VENDOR_ERROR.getCode(), message, null, source);
+    }
+
+    public static McR errorUnknown(String message) {
+        return error(McREnum.UNKNOWN_EXCEPTION.getCode(), message);
+    }
+
+    /// 快速访问: 枚举 ///
+
+    public static McR success() {
+        return R(McREnum.SUCCESS);
+    }
+
+    public static McR success(Object data) {
+        return R(McREnum.SUCCESS, data);
+    }
+
+    public static McR errorIgnore() {
+        return R(McREnum.IGNORE_EXECUTE);
+    }
+
+    public static McR errorToken() {
+        return R(McREnum.TOKEN_INVALID);
+    }
+
+    public static McR errorNullPointer() {
+        return R(McREnum.NULL_POINTER);
+    }
+}
+

+ 33 - 0
mjava/src/main/java/com/malk/server/common/McREnum.java

@@ -0,0 +1,33 @@
+package com.malk.server.common;
+
+import lombok.Getter;
+
+/**
+ * 定义返回值和对应状态的信息
+ */
+public enum McREnum {
+
+    SUCCESS(true, "200", "SUCCESS"),
+    IGNORE_EXECUTE(true, "200", "IGNORE THE CURRENT EXECUTE RESULT"),
+    VALIDATED_PARAM(false, "4001", "PARAMETER VERIFICATION FAILS"),
+    TOKEN_INVALID(false, "4002", "TOKEN INVALID"),
+    NOT_AUTHORIZED(false, "4003", "ERROR INVALID AUTH STATE"),
+    VALIDATED_ACCESS(false, "4004", "VALIDATE IS DISSATISFY THE CONDITION"),
+    METHOD_EXECUTE(false, "5001", "ENCOUNTER AN ERROR WHEN EXECUTE METHOD"),
+    NULL_POINTER(false, "5002", "ENCOUNTER AN ERROR NULL POINTER EXCEPTION"),
+    VENDOR_ERROR(false, "5004", "VENDOR PLATFORM ERROR EXCEPTION"),
+    UNKNOWN_EXCEPTION(false, "6001", "THIS IS AN UNKNOWN EXCEPTION");
+
+    @Getter
+    private String code;
+    @Getter
+    boolean suc;
+    @Getter
+    private String msg;
+
+    McREnum(boolean suc, String code, String msg) {
+        this.suc = suc;
+        this.code = code;
+        this.msg = msg;
+    }
+}

+ 15 - 0
mjava/src/main/java/com/malk/server/common/Page.java

@@ -0,0 +1,15 @@
+package com.malk.server.common;
+
+import lombok.Data;
+
+import java.util.List;
+
+@Data
+public class Page<T> {
+
+    private List<T> content;
+    private long totalElements;
+    private int number;
+    private int size;
+    private int numberOfElements;
+}

+ 54 - 0
mjava/src/main/java/com/malk/server/common/VenR.java

@@ -0,0 +1,54 @@
+package com.malk.server.common;
+
+import com.malk.utils.UtilHttp;
+import lombok.Data;
+import lombok.SneakyThrows;
+
+import java.util.Map;
+
+/**
+ * 定义第三方返回值结构信息
+ */
+@Data
+public class VenR {
+
+    /**
+     * 断言错误信息: 子类实现 [静态代理]
+     */
+    public void assertSuccess() {
+
+    }
+
+    ///-- 反射 [通过Class.forName(全类名)方式获取Class]
+
+    public static final String RC_MC = "com.malk.server.common.MCR";
+    public static final String RC_YD = "com.malk.server.aliwork.YDR";
+    public static final String RC_ALY = "com.malk.server.aliyun.ALYR";
+    public static final String RC_DD = "com.malk.server.dingtalk.DDR";
+    public static final String RC_DD_New = "com.malk.server.dingtalk.DDR_New";
+    public static final String RC_EKB = "com.malk.server.ekuaibao.EKBRR";
+    public static final String RC_FXK = "com.malk.server.fxiaoke.FXKR ";
+    public static final String RC_XBB = "com.malk.server.xbongbong.XBBR";
+    public static final String RC_VK = "com.malk.server.vika.VKR";
+
+    /**
+     * 通用post请求
+     */
+    @SneakyThrows
+    public static VenR doPost(String url, Map header, Map param, Map body, String path) {
+        Class rClass = Class.forName(path);
+        VenR rsp = UtilHttp.doPost(url, header, param, body, rClass);
+        return rsp;
+    }
+
+    /**
+     * 通用get请求
+     */
+    @SneakyThrows
+    public static VenR doGet(String url, Map header, Map param, String path) {
+        Class rClass = Class.forName(path);
+        VenR rsp = UtilHttp.doGet(url, header, param, rClass);
+        return rsp;
+    }
+}
+

+ 76 - 0
mjava/src/main/java/com/malk/server/dingtalk/DDConf.java

@@ -0,0 +1,76 @@
+package com.malk.server.dingtalk;
+
+import com.malk.utils.UtilMap;
+import lombok.Data;
+import org.springframework.boot.context.properties.ConfigurationProperties;
+import org.springframework.stereotype.Component;
+
+import java.util.Map;
+
+/**
+ * 读取配置文件参考FilePah
+ */
+@Data
+@Component
+@ConfigurationProperties(prefix = "dingtalk")
+public class DDConf {
+
+    private Number agentId;
+
+    private String appKey;
+
+    private String appSecret;
+
+    private String corpId;
+
+    private String corpToken;
+
+    private String aesKey;
+
+    private String token;
+
+    // 操作人, 需要为OA后台管理员
+    private String operator;
+
+    // 机器人编号
+    private String robotCode;
+
+    /**
+     * 钉钉一级部门: 1
+     */
+    public static final long TOP_DEPARTMENT = 1L;
+
+    /**
+     * 钉钉回调响应
+     */
+    public static final String CALLBACK_RESPONSE = "success";
+
+    /**
+     * 验证注册地址, 通过开发平台配置触发
+     */
+    public static final String CALLBACK_CHECK = "check_url";
+
+    /**
+     * 审批任务回调 [审批任务开始、结束、转交]
+     */
+    public static final String BPMS_TASK_CHANGE = "bpms_task_change";
+
+    /**
+     * 审批实例回调 [审批实例开始、结束]
+     */
+    public static final String BPMS_INSTANCE_CHANGE = "bpms_instance_change";
+
+    /**
+     * token授权参数: 旧版本
+     */
+    public static Map initTokenParams(String access_token) {
+        return UtilMap.map("access_token", access_token);
+    }
+
+    /**
+     * token授权参数: 新版本
+     */
+    public static Map initTokenHeader(String access_token) {
+        return UtilMap.map("x-acs-dingtalk-access-token", access_token);
+    }
+}

+ 78 - 0
mjava/src/main/java/com/malk/server/dingtalk/DDConfigSign.java

@@ -0,0 +1,78 @@
+package com.malk.server.dingtalk;
+
+import lombok.SneakyThrows;
+
+import java.net.URL;
+import java.net.URLDecoder;
+import java.security.MessageDigest;
+import java.util.Formatter;
+import java.util.Random;
+
+/**
+ * 计算dd.config的签名参数
+ **/
+public class DDConfigSign {
+
+    /**
+     * 计算dd.config的签名参数
+     *
+     * @param jsticket  通过微应用appKey获取的jsticket
+     * @param nonceStr  自定义固定字符串
+     * @param timeStamp 当前时间戳
+     * @param url       调用dd.config的当前页面URL
+     */
+    @SneakyThrows
+    public static String sign(String jsticket, String nonceStr, long timeStamp, String url) {
+        String plain = "jsapi_ticket=" + jsticket + "&noncestr=" + nonceStr + "&timestamp=" + timeStamp + "&url=" + decodeUrl(url);
+        MessageDigest sha1 = MessageDigest.getInstance("SHA-256");
+        sha1.reset();
+        sha1.update(plain.getBytes("UTF-8"));
+        return byteToHex(sha1.digest());
+    }
+
+    // 字节数组转化成十六进制字符串
+    private static String byteToHex(final byte[] hash) {
+        Formatter formatter = new Formatter();
+        for (byte b : hash) {
+            formatter.format("%02x", b);
+        }
+        String result = formatter.toString();
+        formatter.close();
+        return result;
+    }
+
+    /**
+     * 因为ios端上传递的url是encode过的,android是原始的url。开发者使用的也是原始url,
+     * 所以需要把参数进行一般urlDecode
+     */
+    private static String decodeUrl(String url) throws Exception {
+        URL urler = new URL(url);
+        StringBuilder urlBuffer = new StringBuilder();
+        urlBuffer.append(urler.getProtocol());
+        urlBuffer.append(":");
+        if (urler.getAuthority() != null && urler.getAuthority().length() > 0) {
+            urlBuffer.append("//");
+            urlBuffer.append(urler.getAuthority());
+        }
+        if (urler.getPath() != null) {
+            urlBuffer.append(urler.getPath());
+        }
+        if (urler.getQuery() != null) {
+            urlBuffer.append('?');
+            urlBuffer.append(URLDecoder.decode(urler.getQuery(), "utf-8"));
+        }
+        return urlBuffer.toString();
+    }
+
+    /// test
+    public static String getRandomStr(int count) {
+        String base = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+        Random random = new Random();
+        StringBuffer sb = new StringBuffer();
+        for (int i = 0; i < count; i++) {
+            int number = random.nextInt(base.length());
+            sb.append(base.charAt(number));
+        }
+        return sb.toString();
+    }
+}

+ 79 - 0
mjava/src/main/java/com/malk/server/dingtalk/DDFormComponentDto.java

@@ -0,0 +1,79 @@
+package com.malk.server.dingtalk;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.utils.UtilMap;
+import lombok.AllArgsConstructor;
+import lombok.Builder;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 钉钉组件要求
+ * -
+ * 1. 数字组件不能传null或空, 计算字段会自动计算, 不用传值有可. 但打印不支持会不显示
+ * 2. 撤回和提交字段必填设置, 接口调用也会受控. 但若发起页面只读, 审批节点必填则不影响
+ */
+@Data
+@Builder
+@AllArgsConstructor
+@NoArgsConstructor
+public class DDFormComponentDto {
+
+    /**
+     * 组件名称
+     */
+    private String name;
+
+    /**
+     * 组件的值
+     */
+    private String value;
+
+    /**
+     * todo: 钉钉回调, 数字组件不能传null或空, 文本组件null过滤为空; 计算字段胡自动计算, 不用传值, 日期仅支持为年月日或年月日时分两种格式, 人员组件JSON.toJSONString(Arrays.asList(userId))))
+     * 快速格式化, 若value非直接取值需预处理好
+     *
+     * @param formData   表单数据源, 若Value类型为list, 则触发ruleDetail
+     * @param formRule   表单数据格式: { 字段名: 组件名 }
+     * @param detailRule 表单明细数据格式: 单子表 { 字段名: 组件名 }; 多明细 { 明细字段:  { 字段名: 组件名 } }, 明细字段保持和数据字段一致
+     */
+    public static List<DDFormComponentDto> formatComponentValues(Map<String, ?> formData, Map<String, String> formRule, Map detailRule) {
+        // 审批流表单参数,设置各表单项值
+        Map<String, String> ruleDetail = detailRule;
+        List<DDFormComponentDto> formComponentValues = new ArrayList<>();
+        for (String key : formRule.keySet()) {
+            if (formData.get(key) instanceof List && !key.equals("employeeField_mektp0zu_id")) {
+                // 明细组件: 每一个组件都是集合, table为集合嵌套集合
+                List<List<DDFormComponentDto>> formComponentDetailsValues = new ArrayList<>();
+                List<Map> details = (List<Map>) formData.get(key);
+                if (detailRule.get(key) instanceof Map) {
+                    ruleDetail = (Map<String, String>) detailRule.get(key);
+                }
+                for (Map detail : details) {
+                    List<DDFormComponentDto> detailComponentValues = new ArrayList<>();
+                    for (String sub : ruleDetail.keySet()) {
+                        detailComponentValues.add(DDFormComponentDto.builder().name(ruleDetail.get(sub)).value(String.valueOf(detail.get(sub))).build());
+                    }
+                    formComponentDetailsValues.add(detailComponentValues);
+                }
+                formComponentValues.add(DDFormComponentDto.builder().name(formRule.get(key)).value(JSON.toJSONString(formComponentDetailsValues)).build());
+            } else {
+                formComponentValues.add(DDFormComponentDto.builder().name(formRule.get(key)).value(String.valueOf(formData.get(key))).build());
+            }
+        }
+        return formComponentValues;
+    }
+
+    /**
+     * 快速格式化, 上传釘盘的文件转为OA审批对象
+     */
+    public static String formatAttachment(Map dentry) {
+        Map data = UtilMap.map("spaceId, fileName, fileSize, fileType, fileId", "spaceId, name, size, extension, id", dentry);
+        return JSON.toJSONString(Arrays.asList(data));
+    }
+}

+ 24 - 0
mjava/src/main/java/com/malk/server/dingtalk/DDInterActiveCard.java

@@ -0,0 +1,24 @@
+package com.malk.server.dingtalk;
+
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.serializer.SerializerFeature;
+import com.malk.utils.UtilMap;
+import org.apache.commons.collections4.map.HashedMap;
+
+import java.util.Map;
+
+public class DDInterActiveCard {
+
+    /**
+     * 格式交互卡片, 表格数据类型 [兼容多表格], 格式详见 DDClient_Extension 发送卡片
+     */
+    public static Map formCardDataForTable(Map data, String... props) {
+
+        Map cardData = new HashedMap();
+        for (String prop : props) {
+            cardData.put(prop, UtilMap.map("data, meta", data.get(prop), data.get("meta")));
+        }
+        /// fastjson 避免循环引用: 当一个对象包含另一个对象时 或 当一个对象和另一个对象完全相同时
+        return UtilMap.map("cardParamMap", UtilMap.map("sys_full_json_obj", JSON.toJSONString(cardData, SerializerFeature.DisableCircularReferenceDetect)));
+    }
+}

+ 101 - 0
mjava/src/main/java/com/malk/server/dingtalk/DDR.java

@@ -0,0 +1,101 @@
+package com.malk.server.dingtalk;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.malk.server.common.McException;
+import com.malk.server.common.VenR;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 返回值配置参考McR
+ */
+@Data
+@NoArgsConstructor
+public class DDR<T> extends VenR {
+
+    // 请求状态
+    private boolean success;
+
+    private String errcode;
+
+    private String errmsg;
+
+    private T result;
+
+    /**
+     * token
+     */
+    private String accessToken;
+    private int expiresIn;
+
+    /**
+     * ticket
+     */
+    private String ticket;
+
+    /**
+     * 审批实例ID
+     */
+    private String processInstanceId;
+
+    /**
+     * 审批实例详情
+     */
+    private Map processInstance;
+
+    /**
+     * 回调失败事件
+     */
+    private List<Map> failedList;
+
+    private boolean hasMore;
+
+    private String corpid;
+
+    private List<String> userIdList;
+
+    private Long nextToken;
+
+    /**
+     * 发送工作通知
+     */
+    private String task_id; // 可调用获取工作通知消息的发送结果查询结果
+
+    /**
+     * 考勤打卡数据
+     */
+    private List<Map> recordresult;
+
+    // 成功状态标记
+    private final static String SUC_CODE = "0";
+
+    // 创建用户邀请成功提示
+    private final static String USER_CREATE = "40103";
+
+    /**
+     * 断言错误信息
+     */
+    @Override
+    public void assertSuccess() {
+//        if (!hasMore && ObjectUtil.isNull(userIdList)){
+            McException.assertException(!errcode.equals(SUC_CODE) && !errcode.equals(USER_CREATE), errcode, errmsg, "dingtalk");
+//        }
+    }
+
+    /**
+     * 通用post请求
+     */
+    public static DDR doPost(String url, Map header, Map param, Map body) {
+        return (DDR) DDR.doPost(url, header, param, body, VenR.RC_DD);
+    }
+
+    /**
+     * 通用get请求
+     */
+    public static DDR doGet(String url, Map header, Map param) {
+        return (DDR) DDR.doGet(url, header, param, VenR.RC_DD);
+    }
+}

+ 144 - 0
mjava/src/main/java/com/malk/server/dingtalk/DDR_New.java

@@ -0,0 +1,144 @@
+package com.malk.server.dingtalk;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.malk.server.common.McException;
+import com.malk.server.common.VenR;
+import lombok.Data;
+import lombok.NoArgsConstructor;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 返回值配置参考McR
+ */
+@Data
+@NoArgsConstructor
+public class DDR_New<T> extends VenR {
+
+    private String code;
+
+    private String message;
+
+    private Object data;
+
+    private boolean success;
+
+    private T result;
+
+    /**
+     * 无权限对应CODE
+     */
+    private Map accessdenieddetail;
+
+    /**
+     * 离职记录列表
+     */
+    private List<Map<String, String>> records;
+
+    /**
+     * 审批实例id
+     */
+    private String instanceId;
+
+    ////  宜搭数据  ////
+
+    /**
+     * 列表查询数据
+     */
+    private long pageNumber;
+
+    private long totalCount;
+
+    /**
+     * 实例ID详情
+     */
+    private Map originator;
+
+    private String modifiedTimeGMT;
+
+    private String formInstId;
+
+    private Map formData;
+
+    /**
+     * 变更记录
+     */
+    private Map operationLogMap;
+
+
+    ////  储存空间  ////
+
+    /**
+     * 上传唯一标识
+     */
+    private String uploadKey;
+
+    /**
+     * 文件存储类型。DINGTALK:钉钉统一存储驱动, ALIDOC:钉钉文档存储驱动, UNKNOWN:未知驱动
+     */
+    private String storageDriver;
+
+    /**
+     * 上传协议。HEADER_SIGNATURE:Header加签
+     */
+    private String protocol;
+
+    /**
+     * Header加签上传信息。说明: 当protocol参数传HEADER_SIGNATURE时,返回该字段
+     */
+    private Map headerSignatureInfo;
+
+    /**
+     * 文件信息
+     */
+    private Map dentry;
+
+    /**
+     * 钉盘: 下一页的游标,为空字符串则表示分页结束
+     */
+    private String nextToken;
+
+    /// 空间列表
+    private List<Map> spaces;
+
+    /// 文件或文件夹列表
+    private List<Map> dentries;
+
+
+    ////  专属钉  ////
+
+    // 避免无数据返回空
+    private List<Map> list = new ArrayList<>();
+
+    // 成功状态标记 [氚云]
+    private final static String SUC_CODE = "success";
+
+    /**
+     * 断言错误信息
+     * -
+     * 新版本: 若存在code则失败, 否则直接返回请求字段, 无状态包装 [已冗余全部字段]
+     */
+    @Override
+    public void assertSuccess() {
+        if (ObjectUtil.isNotNull(accessdenieddetail)) {
+            message = "没有调用该接口的权限: " + accessdenieddetail;
+        }
+        McException.assertException(ObjectUtil.isNotNull(code) && !SUC_CODE.equals(code), code, message, "dingtalk_new");
+    }
+
+    /**
+     * 通用post请求
+     */
+    public static DDR_New doPost(String url, Map header, Map param, Map body) {
+        return (DDR_New) DDR.doPost(url, header, param, body, VenR.RC_DD_New);
+    }
+
+    /**
+     * 通用get请求
+     */
+    public static DDR_New doGet(String url, Map header, Map param) {
+        return (DDR_New) DDR.doGet(url, header, param, VenR.RC_DD_New);
+    }
+}

+ 404 - 0
mjava/src/main/java/com/malk/server/dingtalk/crypto/DingCallbackCrypto.java

@@ -0,0 +1,404 @@
+package com.malk.server.dingtalk.crypto;
+
+import com.google.common.io.BaseEncoding;
+import org.apache.commons.codec.binary.Base64;
+
+import javax.crypto.Cipher;
+import javax.crypto.spec.IvParameterSpec;
+import javax.crypto.spec.SecretKeySpec;
+import java.io.ByteArrayOutputStream;
+import java.lang.reflect.Field;
+import java.nio.charset.Charset;
+import java.security.MessageDigest;
+import java.security.Permission;
+import java.security.PermissionCollection;
+import java.security.Security;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Random;
+
+/**
+ * 钉钉开放平台加解密方法
+ * 在ORACLE官方网站下载JCE无限制权限策略文件
+ * JDK6的下载地址:http://www.oracle.com/technetwork/java/javase/downloads/jce-6-download-429243.html
+ * JDK7的下载地址: http://www.oracle.com/technetwork/java/javase/downloads/jce-7-download-432124.html
+ * JDK8的下载地址 https://www.oracle.com/java/technologies/javase-jce8-downloads.html
+ */
+public class DingCallbackCrypto {
+
+    private static final Charset CHARSET = Charset.forName("utf-8");
+    private static final Base64 base64 = new Base64();
+    private byte[] aesKey;
+    private String token;
+    private String corpId;
+    /**
+     * ask getPaddingBytes key固定长度
+     **/
+    private static final Integer AES_ENCODE_KEY_LENGTH = 43;
+    /**
+     * 加密随机字符串字节长度
+     **/
+    private static final Integer RANDOM_LENGTH = 16;
+
+    /**
+     * 构造函数
+     *
+     * @param token          钉钉开放平台上,开发者设置的token
+     * @param encodingAesKey 钉钉开放台上,开发者设置的EncodingAESKey
+     * @param corpId         企业自建应用-事件订阅, 使用appKey
+     *                       企业自建应用-注册回调地址, 使用corpId
+     *                       第三方企业应用, 使用suiteKey
+     * @throws DingTalkEncryptException 执行失败,请查看该异常的错误码和具体的错误信息
+     */
+    public DingCallbackCrypto(String token, String encodingAesKey, String corpId) throws DingTalkEncryptException {
+        if (null == encodingAesKey || encodingAesKey.length() != AES_ENCODE_KEY_LENGTH) {
+            throw new DingTalkEncryptException(DingTalkEncryptException.AES_KEY_ILLEGAL);
+        }
+        this.token = token;
+        this.corpId = corpId;
+        // ppExt: 23.10.26 钉钉新方式以Steam接入, HTTP形式commonsc-codec在升级之后,其内部做了一个validateCharacter校验. 使用 guava 替代
+        aesKey = BaseEncoding.base64().decode(encodingAesKey + "=");
+    }
+
+    public Map<String, String> getEncryptedMap(String plaintext) throws DingTalkEncryptException {
+        return getEncryptedMap(plaintext, System.currentTimeMillis(), Utils.getRandomStr(16));
+    }
+
+    /**
+     * 将和钉钉开放平台同步的消息体加密,返回加密Map
+     *
+     * @param plaintext 传递的消息体明文
+     * @param timeStamp 时间戳
+     * @param nonce     随机字符串
+     * @return
+     * @throws DingTalkEncryptException
+     */
+    public Map<String, String> getEncryptedMap(String plaintext, Long timeStamp, String nonce)
+            throws DingTalkEncryptException {
+        if (null == plaintext) {
+            throw new DingTalkEncryptException(DingTalkEncryptException.ENCRYPTION_PLAINTEXT_ILLEGAL);
+        }
+        if (null == timeStamp) {
+            throw new DingTalkEncryptException(DingTalkEncryptException.ENCRYPTION_TIMESTAMP_ILLEGAL);
+        }
+        if (null == nonce) {
+            throw new DingTalkEncryptException(DingTalkEncryptException.ENCRYPTION_NONCE_ILLEGAL);
+        }
+        // 加密
+        String encrypt = encrypt(Utils.getRandomStr(RANDOM_LENGTH), plaintext);
+        String signature = getSignature(token, String.valueOf(timeStamp), nonce, encrypt);
+        Map<String, String> resultMap = new HashMap<String, String>();
+        resultMap.put("msg_signature", signature);
+        resultMap.put("encrypt", encrypt);
+        resultMap.put("timeStamp", String.valueOf(timeStamp));
+        resultMap.put("nonce", nonce);
+        return resultMap;
+    }
+
+    /**
+     * 密文解密
+     *
+     * @param msgSignature 签名串
+     * @param timeStamp    时间戳
+     * @param nonce        随机串
+     * @param encryptMsg   密文
+     * @return 解密后的原文
+     * @throws DingTalkEncryptException
+     */
+    public String getDecryptMsg(String msgSignature, String timeStamp, String nonce, String encryptMsg)
+            throws DingTalkEncryptException {
+        //校验签名
+        String signature = getSignature(token, timeStamp, nonce, encryptMsg);
+        if (!signature.equals(msgSignature)) {
+            throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_SIGNATURE_ERROR);
+        }
+        // 解密
+        String result = decrypt(encryptMsg);
+        return result;
+    }
+
+    /*
+     * 对明文加密.
+     * @param text 需要加密的明文
+     * @return 加密后base64编码的字符串
+     */
+    private String encrypt(String random, String plaintext) throws DingTalkEncryptException {
+        try {
+            byte[] randomBytes = random.getBytes(CHARSET);
+            byte[] plainTextBytes = plaintext.getBytes(CHARSET);
+            byte[] lengthByte = Utils.int2Bytes(plainTextBytes.length);
+            byte[] corpidBytes = corpId.getBytes(CHARSET);
+            ByteArrayOutputStream byteStream = new ByteArrayOutputStream();
+            byteStream.write(randomBytes);
+            byteStream.write(lengthByte);
+            byteStream.write(plainTextBytes);
+            byteStream.write(corpidBytes);
+            byte[] padBytes = PKCS7Padding.getPaddingBytes(byteStream.size());
+            byteStream.write(padBytes);
+            byte[] unencrypted = byteStream.toByteArray();
+            byteStream.close();
+            Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
+            SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
+            IvParameterSpec iv = new IvParameterSpec(aesKey, 0, 16);
+            cipher.init(Cipher.ENCRYPT_MODE, keySpec, iv);
+            byte[] encrypted = cipher.doFinal(unencrypted);
+            String result = base64.encodeToString(encrypted);
+            return result;
+        } catch (Exception e) {
+            throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_ENCRYPT_TEXT_ERROR);
+        }
+    }
+
+    /*
+     * 对密文进行解密.
+     * @param text 需要解密的密文
+     * @return 解密得到的明文
+     */
+    private String decrypt(String text) throws DingTalkEncryptException {
+        byte[] originalArr;
+        try {
+            // 设置解密模式为AES的CBC模式
+            Cipher cipher = Cipher.getInstance("AES/CBC/NoPadding");
+            SecretKeySpec keySpec = new SecretKeySpec(aesKey, "AES");
+            IvParameterSpec iv = new IvParameterSpec(Arrays.copyOfRange(aesKey, 0, 16));
+            cipher.init(Cipher.DECRYPT_MODE, keySpec, iv);
+            // 使用BASE64对密文进行解码
+            byte[] encrypted = Base64.decodeBase64(text);
+            // 解密
+            originalArr = cipher.doFinal(encrypted);
+        } catch (Exception e) {
+            throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_DECRYPT_TEXT_ERROR);
+        }
+
+        String plainText;
+        String fromCorpid;
+        try {
+            // 去除补位字符
+            byte[] bytes = PKCS7Padding.removePaddingBytes(originalArr);
+            // 分离16位随机字符串,网络字节序和corpId
+            byte[] networkOrder = Arrays.copyOfRange(bytes, 16, 20);
+            int plainTextLegth = Utils.bytes2int(networkOrder);
+            plainText = new String(Arrays.copyOfRange(bytes, 20, 20 + plainTextLegth), CHARSET);
+            fromCorpid = new String(Arrays.copyOfRange(bytes, 20 + plainTextLegth, bytes.length), CHARSET);
+        } catch (Exception e) {
+            throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_DECRYPT_TEXT_LENGTH_ERROR);
+        }
+
+        // corpid不相同的情况
+        if (!fromCorpid.equals(corpId)) {
+            throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_DECRYPT_TEXT_CORPID_ERROR);
+        }
+        return plainText;
+    }
+
+    /**
+     * 数字签名
+     *
+     * @param token     isv token
+     * @param timestamp 时间戳
+     * @param nonce     随机串
+     * @param encrypt   加密文本
+     * @return
+     * @throws DingTalkEncryptException
+     */
+    public String getSignature(String token, String timestamp, String nonce, String encrypt)
+            throws DingTalkEncryptException {
+        try {
+            String[] array = new String[]{token, timestamp, nonce, encrypt};
+            Arrays.sort(array);
+//            System.out.println(JSON.toJSONString(array));
+            StringBuffer sb = new StringBuffer();
+            for (int i = 0; i < 4; i++) {
+                sb.append(array[i]);
+            }
+            String str = sb.toString();
+//            System.out.println(str);
+            MessageDigest md = MessageDigest.getInstance("SHA-1");
+            md.update(str.getBytes());
+            byte[] digest = md.digest();
+
+            StringBuffer hexstr = new StringBuffer();
+            String shaHex = "";
+            for (int i = 0; i < digest.length; i++) {
+                shaHex = Integer.toHexString(digest[i] & 0xFF);
+                if (shaHex.length() < 2) {
+                    hexstr.append(0);
+                }
+                hexstr.append(shaHex);
+            }
+            return hexstr.toString();
+        } catch (Exception e) {
+            throw new DingTalkEncryptException(DingTalkEncryptException.COMPUTE_SIGNATURE_ERROR);
+        }
+    }
+
+    public static class Utils {
+        public Utils() {
+        }
+
+        public static String getRandomStr(int count) {
+            String base = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
+            Random random = new Random();
+            StringBuffer sb = new StringBuffer();
+
+            for (int i = 0; i < count; ++i) {
+                int number = random.nextInt(base.length());
+                sb.append(base.charAt(number));
+            }
+
+            return sb.toString();
+        }
+
+        public static byte[] int2Bytes(int count) {
+            byte[] byteArr = new byte[]{(byte) (count >> 24 & 255), (byte) (count >> 16 & 255), (byte) (count >> 8 & 255),
+                    (byte) (count & 255)};
+            return byteArr;
+        }
+
+        public static int bytes2int(byte[] byteArr) {
+            int count = 0;
+
+            for (int i = 0; i < 4; ++i) {
+                count <<= 8;
+                count |= byteArr[i] & 255;
+            }
+
+            return count;
+        }
+    }
+
+    public static class PKCS7Padding {
+        private static final Charset CHARSET = Charset.forName("utf-8");
+        private static final int BLOCK_SIZE = 32;
+
+        public PKCS7Padding() {
+        }
+
+        public static byte[] getPaddingBytes(int count) {
+            int amountToPad = 32 - count % 32;
+            if (amountToPad == 0) {
+                amountToPad = 32;
+            }
+
+            char padChr = chr(amountToPad);
+            String tmp = new String();
+
+            for (int index = 0; index < amountToPad; ++index) {
+                tmp = tmp + padChr;
+            }
+
+            return tmp.getBytes(CHARSET);
+        }
+
+        public static byte[] removePaddingBytes(byte[] decrypted) {
+            int pad = decrypted[decrypted.length - 1];
+            if (pad < 1 || pad > 32) {
+                pad = 0;
+            }
+
+            return Arrays.copyOfRange(decrypted, 0, decrypted.length - pad);
+        }
+
+        private static char chr(int a) {
+            byte target = (byte) (a & 255);
+            return (char) target;
+        }
+    }
+
+    public static class DingTalkEncryptException extends Exception {
+        public static final int SUCCESS = 0;
+        public static final int ENCRYPTION_PLAINTEXT_ILLEGAL = 900001;
+        public static final int ENCRYPTION_TIMESTAMP_ILLEGAL = 900002;
+        public static final int ENCRYPTION_NONCE_ILLEGAL = 900003;
+        public static final int AES_KEY_ILLEGAL = 900004;
+        public static final int SIGNATURE_NOT_MATCH = 900005;
+        public static final int COMPUTE_SIGNATURE_ERROR = 900006;
+        public static final int COMPUTE_ENCRYPT_TEXT_ERROR = 900007;
+        public static final int COMPUTE_DECRYPT_TEXT_ERROR = 900008;
+        public static final int COMPUTE_DECRYPT_TEXT_LENGTH_ERROR = 900009;
+        public static final int COMPUTE_DECRYPT_TEXT_CORPID_ERROR = 900010;
+        private static final long serialVersionUID = 4139585721260116198L;
+        private static Map<Integer, String> msgMap = new HashMap();
+        private Integer code;
+
+        static {
+            msgMap.put(0, "成功");
+            msgMap.put(900001, "加密明文文本非法");
+            msgMap.put(900002, "加密时间戳参数非法");
+            msgMap.put(900003, "加密随机字符串参数非法");
+            msgMap.put(900005, "签名不匹配");
+            msgMap.put(900006, "签名计算失败");
+            msgMap.put(900004, "不合法的aes key");
+            msgMap.put(900007, "计算加密文字错误");
+            msgMap.put(900008, "计算解密文字错误");
+            msgMap.put(900009, "计算解密文字长度不匹配");
+            msgMap.put(900010, "计算解密文字corpid不匹配");
+        }
+
+        public Integer getCode() {
+            return this.code;
+        }
+
+        public DingTalkEncryptException(Integer exceptionCode) {
+            super((String) msgMap.get(exceptionCode));
+            this.code = exceptionCode;
+        }
+    }
+
+    static {
+        try {
+            Security.setProperty("crypto.policy", "limited");
+            RemoveCryptographyRestrictions();
+        } catch (Exception var1) {
+        }
+
+    }
+
+    private static void RemoveCryptographyRestrictions() throws Exception {
+        Class<?> jceSecurity = getClazz("javax.crypto.JceSecurity");
+        Class<?> cryptoPermissions = getClazz("javax.crypto.CryptoPermissions");
+        Class<?> cryptoAllPermission = getClazz("javax.crypto.CryptoAllPermission");
+        if (jceSecurity != null) {
+            setFinalStaticValue(jceSecurity, "isRestricted", false);
+            PermissionCollection defaultPolicy = (PermissionCollection) getFieldValue(jceSecurity, "defaultPolicy", (Object) null, PermissionCollection.class);
+            if (cryptoPermissions != null) {
+                Map<?, ?> map = (Map) getFieldValue(cryptoPermissions, "perms", defaultPolicy, Map.class);
+                map.clear();
+            }
+
+            if (cryptoAllPermission != null) {
+                Permission permission = (Permission) getFieldValue(cryptoAllPermission, "INSTANCE", (Object) null, Permission.class);
+                defaultPolicy.add(permission);
+            }
+        }
+
+    }
+
+    private static Class<?> getClazz(String className) {
+        Class clazz = null;
+
+        try {
+            clazz = Class.forName(className);
+        } catch (Exception var3) {
+        }
+
+        return clazz;
+    }
+
+    private static void setFinalStaticValue(Class<?> srcClazz, String fieldName, Object newValue) throws Exception {
+        Field field = srcClazz.getDeclaredField(fieldName);
+        field.setAccessible(true);
+        Field modifiersField = Field.class.getDeclaredField("modifiers");
+        modifiersField.setAccessible(true);
+        modifiersField.setInt(field, field.getModifiers() & -17);
+        field.set((Object) null, newValue);
+    }
+
+    private static <T> T getFieldValue(Class<?> srcClazz, String fieldName, Object owner, Class<T> dstClazz) throws Exception {
+        Field field = srcClazz.getDeclaredField(fieldName);
+        field.setAccessible(true);
+        return dstClazz.cast(field.get(owner));
+    }
+
+}

+ 29 - 0
mjava/src/main/java/com/malk/service/aliwork/YDClient.java

@@ -0,0 +1,29 @@
+package com.malk.service.aliwork;
+
+
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.dingtalk.DDR_New;
+
+public interface YDClient {
+
+    /**
+     * 操作数据
+     */
+    Object operateData(YDParam param, YDConf.FORM_OPERATION type);
+
+    /**
+     * 查询数据
+     */
+    DDR_New queryData(YDParam param, YDConf.FORM_QUERY type);
+
+    /**
+     * w
+     * 获取宜搭附件临时免登地址
+     */
+    String convertTemporaryUrl(String url, int timeout);
+
+    String convertTemporaryUrl(String url);
+
+    String convertTemporaryUrl_PN(String url);
+}

+ 100 - 0
mjava/src/main/java/com/malk/service/aliwork/YDService.java

@@ -0,0 +1,100 @@
+package com.malk.service.aliwork;
+
+
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+
+import java.util.List;
+import java.util.Map;
+
+public interface YDService {
+
+    // todo: 批量创建接口异常; 查询子表超过50自动查询全量; 查询表单全部数据, 避免第一次重复查询优化, 添加直接返回formData, 兼容formInstId;  upsert方法
+
+    // todo 0402 批量更新 / 删除
+
+    /**
+     * 操作数据 [异步]
+     */
+    Object operateData(YDParam param, YDConf.FORM_OPERATION type);
+
+    /**
+     * 操作数据 [异步]
+     */
+    Object operateData2(Map data, Map update, YDParam param, YDConf.FORM_OPERATION type);
+
+    /**
+     * 查询数据 [子表] todo 参数控制
+     * <p>
+     * .formUuid("FORM-TD966Z81I9ODTCY66GH345S03VW03JJF6EQLL5")
+     * .formInstanceId(data.get("formInstanceId").toString())
+     * .tableFieldId("tableField_llqe7fgb")
+     */
+    List<Map> queryDetails(YDParam ydParam);
+
+    /**
+     * 查询全部 [主表] todo 合并formData, 或data, putAll
+     * <p>
+     * .stream().map(item -> {
+     * item.putAll( (Map) item.get("formData"));
+     * return item;
+     * }).collect(Collectors.toList());
+     */
+    List<Map> queryAllFormData(YDParam YDParam);
+
+    // todo: 宜搭大数据量处理会有异常, 可结合排序, 或失败记录进行处理 (避免更新后, 数据重新查询排序导致未全量同步)
+    List<Map> queryFormData_all(YDParam YDParam);
+
+    // ppExt: 查询100后再进分页, 且转为formData内数据, todo Process待完成 [bug]
+    List<Map> queryFormData_all_advance(YDParam ydParam, YDConf.FORM_QUERY type);
+
+    /**
+     * 查询宜搭数据
+     */
+    List<Map> queryDataList_FormData(String formUuid, Map conditions);
+
+    /**
+     * 查询宜搭数据 [精确匹配]
+     */
+    List queryFormData(String formUuid, String conditions);
+
+    /**
+     * 字段复制 [服务注册传递参数都是 string 格式]
+     * -
+     * fixme: 服务注册
+     * 1. 在提交校验,因为数据还未执行, 除 #{_yida_all_data} 外,绝大部分字段都是为空,且若是子表字段数据:会拆为为单一字段,数据为该字段下明细数据数组
+     * 2. 但在在业务规则内配置,可以正常获取. 若是子表单, 除返回提交下该字段明细数据数组外, 也会返回子表组件, 及子表内对应的数据 [可有效避免触发何查询操作]
+     * 业务规则场景: 若是写入关联表单,自关联后,通过自动化传递。若是数据全表复制,可获取全量数据进行写入操作
+     * ppExt: 组件格式
+     * 1. { "cur": "目标表, 主表组件ID[包含子表组件ID]", "src": "当前表, 主表组件ID[包含子表组件ID]", "子表组件ID + cur/src": "子表内的组件ID" }
+     * 2. 所有组件通过英文逗号 + 空格区分, 子表组件为避免组件id相同但子表内组件不一致的情况, 在单独子表组件ID后添加 src/cur 区分来源
+     */
+    Object copyFormData(Map data);
+
+    /**
+     * 全表复制 [两张表组件完全一致]
+     */
+    Object mirrorFormData(String instanceId, String formUuid, String processCode, Map updateData, String updateInstanceId);
+
+    /**
+     * upsert方法 [todo: 优化 批量的数据兼容]
+     *
+     * @param lambda 查询数据回调, 传递查询结果list, 接收回调返回map传递钉到formData内, 若返回为空则不支持Upsert
+     */
+    Object upsertFormData(String formUuid, Map condition, Map formData, UpsertLambda lambda);
+
+    /**
+     * ppExt 函数式编程:Stream类、Lambda表达式和函数接口(Functional Inteface)
+     * -
+     * 自定义Lambda是为了更好的代码提示和数据类型定义: @FunctionalInterface,该接口为函数接口,一个函数接口只能存在一个方法
+     * 特别注意,非异步情况下,和网络请求一致,回调默认同步等待【回调方式可自定义:如兼容并发可有,同步单次回调、同步累积回调、并发异步执行、并发累积回调】
+     * 函数编程的最大好处,是可传入执行逻辑,而无需等待全部数据结果,尤其并发和异步情况下【异步效果优于Future】
+     * -
+     * 结合异步与并发, @Async说明和配置详见core/AsyncConfig
+     */
+    @FunctionalInterface
+    interface UpsertLambda {
+        Map dataList(List<Map> list);
+    }
+
+}

+ 167 - 0
mjava/src/main/java/com/malk/service/aliwork/impl/YDClientImpl.java

@@ -0,0 +1,167 @@
+package com.malk.service.aliwork.impl;
+
+import com.alibaba.fastjson.JSONObject;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.service.aliwork.YDClient;
+import com.malk.service.dingtalk.DDClient;
+import com.malk.utils.UtilHttp;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class YDClientImpl implements YDClient {
+
+    @Autowired
+    private YDConf ydConf;
+
+    @Autowired
+    private DDClient ddClient;
+
+    // 初始化请求参数
+    Map _initBodyParam(YDParam ydParam) {
+        if (StringUtils.isBlank(ydParam.getUserId())) {
+            ydParam.setUserId(YDConf.PUB_ACCOUNT);
+        }
+        // 配置宜搭请求参数: appType, systemToken
+        if (StringUtils.isBlank(ydParam.getAppType()) || ydParam.getAppType().equals(ydConf.getAppType())) {
+            ydParam.setAppType(ydConf.getAppType());
+            ydParam.setSystemToken(ydConf.getSystemToken());
+        }
+        return JSONObject.parseObject(JSONObject.toJSONString(ydParam), Map.class);
+    }
+
+    // 请求地址 [拼接]
+    private String getRequestUrl(String uri) {
+        return "https://api.dingtalk.com/v1.0/yida" + uri;
+    }
+    private String getRequestUrl_V2(String uri) {
+        return "https://api.dingtalk.com/v2.0/yida" + uri;
+    }
+
+    // 请求地址 [实例]
+    private String getRequestUrl(String uri, String instanceId) {
+        return getRequestUrl(uri) + "/" + instanceId;
+    }
+
+    /**
+     * 操作数据
+     */
+    @Override
+    public Object operateData(YDParam ydParam, YDConf.FORM_OPERATION type) {
+        DDR_New ddr_new = null;
+        Map bodys = _initBodyParam(ydParam);
+        switch (type) {
+            case create:
+                ddr_new = (DDR_New) UtilHttp.doPost(getRequestUrl("/forms/instances"), ddClient.initTokenHeader(), bodys, DDR_New.class);
+                break;
+            case update:
+                ddr_new = (DDR_New) UtilHttp.doPut(getRequestUrl("/forms/instances"), ddClient.initTokenHeader(), bodys, DDR_New.class);
+                break;
+            case delete:
+                ddr_new = (DDR_New) UtilHttp.doDelete(getRequestUrl("/forms/instances"), ddClient.initTokenHeader(), bodys, DDR_New.class);
+                break;
+            case upsert:
+                ddr_new = (DDR_New) UtilHttp.doPost( getRequestUrl("/forms/instances/insertOrUpdate"), this.ddClient.initTokenHeader(), bodys, DDR_New.class);
+                break;
+            case upsert_v2:
+                ddr_new = (DDR_New) UtilHttp.doPost(this.getRequestUrl_V2("/forms/instances/insertOrUpdate"), this.ddClient.initTokenHeader(), bodys, DDR_New.class);
+                break;
+            case start:
+                ddr_new = (DDR_New) UtilHttp.doPost(getRequestUrl("/processes/instances/start"), ddClient.initTokenHeader(), bodys, DDR_New.class);
+                break;
+            case delete_batch:
+                ddr_new = (DDR_New) UtilHttp.doPost(getRequestUrl("/forms/instances/batchRemove"), ddClient.initTokenHeader(), bodys, DDR_New.class);
+                break;
+            case multi_update:
+                ddr_new = (DDR_New) UtilHttp.doPut(getRequestUrl("/forms/instances/components"), ddClient.initTokenHeader(), bodys, DDR_New.class);
+                break;
+            case batchSave:
+                ddr_new = (DDR_New) UtilHttp.doPost(getRequestUrl("/forms/instances/batchSave"), ddClient.initTokenHeader(), null, _initBodyParam(ydParam), DDR_New.class);
+                break;
+            default:
+                break;
+        }
+        return ddr_new.getResult();
+    }
+
+    /**
+     * 查询数据
+     *
+     * @apiNote
+     */
+    @Override
+    public DDR_New queryData(YDParam ydParam, YDConf.FORM_QUERY type) {
+        DDR_New ddr_new = null;
+        Map param = _initBodyParam(ydParam);
+        switch (type) {
+            case retrieve_list_all:
+                ddr_new = DDR_New.doPost(getRequestUrl("/forms/instances/advances/queryAll"), ddClient.initTokenHeader(), null, param);
+                break;
+            case retrieve_list:
+                ddr_new = DDR_New.doPost(getRequestUrl("/forms/instances/advances/query"), ddClient.initTokenHeader(), null, param);
+                break;
+            case retrieve_id:
+                ddr_new = DDR_New.doGet(getRequestUrl("/forms/instances", ydParam.getFormInstanceId()), ddClient.initTokenHeader(), param);
+                break;
+            case retrieve_search_process:
+                ddr_new = DDR_New.doPost(getRequestUrl("/processes/instances"), ddClient.initTokenHeader(), param, param);
+                break;
+
+            case retrieve_search_process_id:
+                ddr_new = DDR_New.doPost(getRequestUrl("/processes/instanceIds", ydParam.getProcessInstanceId()), ddClient.initTokenHeader(), param, param);
+                break;
+
+            case retrieve_search_form:
+                ddr_new = DDR_New.doPost(getRequestUrl("/forms/instances/search"), ddClient.initTokenHeader(), null, param);
+                break;
+
+            case retrieve_search_form_id:
+                ddr_new = DDR_New.doPost(getRequestUrl("/forms/instances/ids") + "/" + ydParam.getAppType() + "/" + ydParam.getFormUuid(), ddClient.initTokenHeader(), param, param);
+                break;
+
+            case retrieve_details:
+                ddr_new = DDR_New.doGet(getRequestUrl("/forms/innerTables", ydParam.getFormInstanceId()), ddClient.initTokenHeader(), param);
+                break;
+            case retrieve_changed:
+                ddr_new = DDR_New.doPost(getRequestUrl("/forms/operationsLogs/query"), ddClient.initTokenHeader(), null, param);
+                break;
+        }
+        return ddr_new;
+    }
+
+    /**
+     * 临时免登地址
+     */
+    @Override
+    public String convertTemporaryUrl(String url, int timeout) {
+        Map param = new HashMap();
+        param.put("systemToken", ydConf.getSystemToken());
+        param.put("userId", YDConf.PUB_ACCOUNT);
+        param.put("fileUrl", url);          // URL在param上时, 需要编码 [UtilHttp已经做了编码] - URLEncoder.encode(url, "UTF-8")
+        param.put("timeout", timeout);      // 默认1分钟, 最大24小时 [毫秒]
+        return (String) DDR_New.doGet("https://api.dingtalk.com/v1.0/yida/apps/temporaryUrls/" + ydConf.getAppType(), ddClient.initTokenHeader(), param).getResult();
+    }
+
+    @Override
+    public String convertTemporaryUrl(String url) {
+        return convertTemporaryUrl(url, 3600000);
+    }
+    public String convertTemporaryUrl_PN(String url ) {
+        //  String ddapptonken= dd.getAccessToken("dingraobfxqulra4mp9r","KN28UwWycxxCkKkug1x61lsWWAhEaNpQbc8efrgMKyNUXayw-O2sRxDkhh9LTh8y");
+        Map param = new HashMap();
+        param.put("systemToken", "BN766KC1CS9Q2LJQDAKIN82VVUHT2J07ALF3M7R2");
+        param.put("userId", YDConf.PUB_ACCOUNT);
+        param.put("fileUrl", url);          // URL在param上时, 需要编码 [UtilHttp已经做了编码] - URLEncoder.encode(url, "UTF-8")
+        param.put("timeout", 3600000);      // 默认1分钟, 最大24小时 [毫秒]
+       // System.out.println("ssss:"+(String) DDR_New.doGet("https://api.dingtalk.com/v1.0/yida/apps/temporaryUrls/APP_G951QZ32AUJNJUE4G127" , ddClient.initTokenHeader_PN(), param).getResult());
+        return (String) DDR_New.doGet("https://api.dingtalk.com/v1.0/yida/apps/temporaryUrls/APP_OM7HTYCXYQYCKJ046D9V" ,  ddClient.initTokenHeader_PN(), param).getResult();
+    }
+}

+ 330 - 0
mjava/src/main/java/com/malk/service/aliwork/impl/YDServiceImpl.java

@@ -0,0 +1,330 @@
+package com.malk.service.aliwork.impl;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.alibaba.fastjson.JSON;
+import com.malk.server.aliwork.YDConf;
+import com.malk.server.aliwork.YDParam;
+import com.malk.server.common.McException;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.service.aliwork.YDClient;
+import com.malk.service.aliwork.YDService;
+import com.malk.utils.UtilMap;
+import com.malk.utils.UtilString;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections4.map.HashedMap;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Slf4j
+@Service
+public class YDServiceImpl implements YDService {
+
+    @Autowired
+    private YDClient ydClient;
+
+    /**
+     * 操作数据 [异步] - 审批通过立即更新, 会无效
+     */
+    @Async
+    @SneakyThrows
+    @Override
+    public Object operateData(YDParam ydParam, YDConf.FORM_OPERATION type) {
+        Thread.sleep(3000);
+        return ydClient.operateData(ydParam, type);
+    }
+
+    @Async
+    @SneakyThrows
+    @Override
+    public Object operateData2(Map data, Map update, YDParam ydParam, YDConf.FORM_OPERATION type) {
+
+        // prd 9.10 更新报销单, 关联到发票:: todo 宜搭服务注册拿不到系统默认字段, 先查询解决
+        if (data.containsKey("aUuid")) {
+            Thread.sleep(3000);
+            List<Map> process = (List<Map>) ydClient.queryData(YDParam.builder()
+                    .formUuid(data.get("aFormUuid").toString())
+                    .searchFieldJson(JSON.toJSONString(UtilMap.map("textField_lmewsobs", data.get("aUuid"))))
+                    .build(), YDConf.FORM_QUERY.retrieve_search_form).getData();
+
+            update.put(data.get("aCompId"),
+                    Arrays.asList(UtilMap.map("appType, formType, instanceId, formUuid, title",
+                            "APP_FKRK7Y94DPI1S9DV1605", "process", process.get(0).get("formInstanceId"), data.get("aFormUuid"), process.get(0).get("title"))));
+            ydParam.setUpdateFormDataJson(JSON.toJSONString(update));
+        }
+        return ydClient.operateData(ydParam, type);
+    }
+
+
+    /**
+     * 子表全部数据获取, 最大分页为50 [可考虑中间表思路]
+     */
+    @Override
+    public List<Map> queryDetails(YDParam ydParam) {
+        return _queryDetails(ydParam, new ArrayList<>());
+    }
+
+    // 递归查询 todo 如果查询中, totalCount 发生变化, 就会进入死循环
+    private List<Map> _queryDetails(YDParam ydParam, List<Map> details) {
+        ydParam.setPageSize(50);
+        DDR_New ddr_new = ydClient.queryData(ydParam, YDConf.FORM_QUERY.retrieve_details);
+        details.addAll((List<Map>) ddr_new.getData());
+        if (ddr_new.getTotalCount() != details.size()) {
+            ydParam.setPageNumber(ydParam.getPageNumber() + 1);
+            _queryDetails(ydParam, details);
+        }
+        return details;
+    }
+
+    /**
+     * 查询全部 [主表]
+     */
+    @Override
+    public List<Map> queryAllFormData(YDParam ydParam) {
+        float pageSize = YDConf.PAGE_SIZE_LIMIT;
+        // 查询数据量 [todo 先查询100, 再进行分页, 避免无效查询多一次]; 2. 直接返回formData, 兼容实例ID
+        ydParam.setPageSize(1);
+        long totalCount = ydClient.queryData(ydParam, YDConf.FORM_QUERY.retrieve_search_form).getTotalCount();
+        // 轮询累计数据
+        List<Map> dataList = new ArrayList<>();
+        ydParam.setCurrentPage(1);
+        ydParam.setPageSize((int) pageSize);
+        for (int page = 1; page <= Math.ceil(totalCount / pageSize); page++) {
+            ydParam.setCurrentPage(page);
+            dataList.addAll((List<Map>) ydClient.queryData(ydParam, YDConf.FORM_QUERY.retrieve_search_form).getData());
+        }
+        return dataList;
+    }
+
+    // ppExt: 查询100后再进分页, 且转为formData内数据, todo Process待完成
+    @Override
+    public List<Map> queryFormData_all_advance(YDParam ydParam, YDConf.FORM_QUERY type) {
+
+        float pageSize = YDConf.PAGE_SIZE_LIMIT;
+
+        ydParam.setPageNumber(1); // todo 分页
+        ydParam.setPageSize((int) pageSize);
+        DDR_New ddr_new = ydClient.queryData(ydParam, type);
+
+        List<Map> dataList = new ArrayList<>();
+        dataList.addAll((List<Map>) ddr_new.getData());
+
+        long totalCount = ddr_new.getTotalCount(); // todo 大于1扣减
+        if (totalCount > 100) {
+            for (int page = 2; page <= Math.ceil(totalCount / pageSize); page++) {
+                ydParam.setPageNumber(page);
+                dataList.addAll((List<Map>) ydClient.queryData(ydParam, type).getData());
+            }
+        }
+        return dataList.stream().map(item -> {
+            Map formData = (Map) item.get("formData");
+            formData.put("instanceId", item.get("formInstanceId"));
+            formData.put("formInstanceId", item.get("formInstanceId"));
+            return formData;
+        }).collect(Collectors.toList());
+    }
+
+    @Override
+    public List<Map> queryFormData_all(YDParam YDParam) {
+        List<Map> dataList = queryAllFormData(YDParam);
+        return dataList.stream().map(item -> {
+            Map formData = (Map) item.get("formData");
+            formData.put("instanceId", item.get("formInstanceId"));
+            formData.put("formInstanceId", item.get("formInstanceId"));
+            return formData;
+        }).collect(Collectors.toList());
+    }
+
+    /**
+     * 查询宜搭数据
+     */
+    @Override
+    public List queryDataList_FormData(String formUuid, Map conditions) {
+        conditions = UtilMap.empty(conditions);
+        List<Map> dataList = (List<Map>) ydClient.queryData(YDParam.builder()
+                        .formUuid(formUuid)
+                        .searchFieldJson(JSON.toJSONString(conditions)).build(),
+                YDConf.FORM_QUERY.retrieve_search_form).getData();
+        return dataList.stream().map(item -> {
+            Map formData = (Map) item.get("formData");
+            formData.put("instanceId", item.get("formInstanceId"));
+            return formData;
+        }).collect(Collectors.toList());
+    }
+
+    /**
+     * 查询宜搭数据 [精确匹配]
+     */
+    @Override
+    public List queryFormData(String formUuid, String conditions) {
+        List<Map> dataList = (List<Map>) ydClient.queryData(YDParam.builder()
+                        .formUuid(formUuid)
+                        .searchCondition(conditions).build(),
+                YDConf.FORM_QUERY.retrieve_list).getData();
+        return dataList.stream().map(item -> {
+            Map formData = (Map) item.get("formData");
+            formData.put("instanceId", item.get("formInstanceId"));
+            return formData;
+        }).collect(Collectors.toList());
+    }
+
+    /**
+     * 字段复制
+     */
+    @Override
+    public Object copyFormData(Map data) {
+
+        McException.assertParamException_Null(data, "formData, formUuid, processCode, compIds");
+        Map compIds = (Map) JSON.parse(String.valueOf(data.get("compIds")));
+        McException.assertParamException_Null(compIds, "cur, src"); // compIds 包含全部组件
+        Map formData = (Map) JSON.parse(String.valueOf(data.get("formData")));
+
+        String[] compIds_cur = UtilMap.getString(compIds, "cur").split(", ");
+        String[] compIds_src = UtilMap.getString(compIds, "src").split(", ");
+        McException.assertAccessException(compIds_cur.length != compIds_src.length, "主表的字段数量不一致, 请检查配置");
+
+        // 目标表数据处理
+        Map dataForm = new HashedMap();
+        for (int i = 0; i < compIds_cur.length; i++) {
+            String compId_cur = compIds_cur[i];
+            String compId_src = compIds_src[i];
+            // 子表的顺序要保持一致
+            if (compId_cur.contains("tableField_") || compId_src.contains("tableField_")) {
+                boolean isAssert = (compId_cur.contains("tableField_") && !compId_src.contains("tableField_")) || (!compId_cur.contains("tableField_") && compId_src.contains("tableField_"));
+                McException.assertAccessException(isAssert, "子表组件类型不一致, 请检查配置");
+                String[] compIds_cur_detail = UtilMap.getString(compIds, compId_cur + "_cur").split(", ");
+                String[] compIds_src_detail = UtilMap.getString(compIds, compId_src + "_src").split(", ");
+                McException.assertAccessException(compIds_cur_detail.length != compIds_src_detail.length, "子表的字段数量不一致, 请检查配置");
+                // 子表数据处理
+                List<Map> details = new ArrayList<>();
+                for (Map detail : (List<Map>) UtilMap.getList(formData, compId_cur)) {
+                    Map rowData = new HashedMap();
+                    // 子表组件处理
+                    for (int j = 0; j < compIds_cur_detail.length; j++) {
+                        String compId_cur_detail = compIds_cur_detail[j];
+                        String compId_src_detail = compIds_src_detail[j];
+                        rowData.put(compId_src_detail, YDConf.getDataByCompId(detail, compId_cur_detail));
+                    }
+                    details.add(rowData);
+                }
+                dataForm.put(compId_cur, details);
+            } else {
+                // 主表数据处理
+                dataForm.put(compId_src, YDConf.getDataByCompId(formData, compId_cur));
+            }
+        }
+        // 发起流程/创建表单
+        YDConf.FORM_OPERATION type = data.containsKey("processCode") ? YDConf.FORM_OPERATION.start : YDConf.FORM_OPERATION.create;
+        return ydClient.operateData(YDParam.builder()
+                .formUuid(UtilMap.getString(data, "formUuid"))
+                .processCode(UtilMap.getString(data, "processCode"))
+                .formDataJson(JSON.toJSONString(dataForm))
+                .build(), type);
+    }
+
+    /**
+     * 全表复制 [两张表组件完全一致]
+     */
+    @Override
+    public Object mirrorFormData(String instanceId, String formUuid, String processCode, Map updateData, String updateInstanceId) {
+
+        YDConf.FORM_OPERATION type = StringUtils.isNotBlank(processCode) ? YDConf.FORM_OPERATION.start : YDConf.FORM_OPERATION.create;
+        Map<String, ?> formData = ydClient.queryData(YDParam.builder()
+                        .formInstanceId(instanceId)
+                        .build(),
+                YDConf.FORM_QUERY.retrieve_id).getFormData();
+
+        Map dataForm = UtilMap.empty();
+        for (String compId : formData.keySet()) {
+            // ppExt: 组件会返回带有_id/_value字段, 关联表单 & 成员组件赋值为原字段 [关联表单仅仅会返回带Id字段, 成员组件不带Id返回姓名过滤]
+            if (compId.endsWith("_value") || (compId.startsWith("employeeField_") && !compId.endsWith("_id"))) {
+                continue;
+            }
+            if (compId.endsWith("_id")) {
+                if (compId.startsWith("employeeField_") || compId.startsWith("associationFormField_")) {
+                    compId = compId.replace("_id", "");
+                } else {
+                    continue;
+                }
+            }
+            dataForm.put(compId, YDConf.getDataByCompId(formData, compId));
+            if (compId.startsWith("tableField_")) {
+
+                List<Map<String, ?>> details = (List<Map<String, ?>>) formData.get(compId);
+                List<Map> table = new ArrayList<>();
+                for (Map detail : details) {
+                    Map row = UtilMap.empty();
+                    for (String subId : details.get(0).keySet()) {
+                        // ppExt: 组件会返回带有_id/_value字段, 关联表单 & 成员组件赋值为原字段 [关联表单仅仅会返回带Id字段, 成员组件不带Id返回姓名过滤]
+                        if (subId.endsWith("_value") || (subId.startsWith("employeeField_") && !subId.endsWith("_id"))) {
+                            continue;
+                        }
+                        if (subId.endsWith("_id")) {
+                            if (subId.startsWith("employeeField_") || subId.startsWith("associationFormField_")) {
+                                subId = subId.replace("_id", "");
+                            } else {
+                                continue;
+                            }
+                        }
+                        row.put(subId, YDConf.getDataByCompId(detail, subId));
+                    }
+                    table.add(row);
+                }
+                dataForm.put(compId, table);
+            }
+        }
+        UtilMap.putAll(dataForm, updateData);
+        if (UtilString.isBlankCompatNull(updateInstanceId)) {
+            return ydClient.operateData(YDParam.builder()
+                    .formUuid(formUuid)
+                    .processCode(processCode)
+                    .userId(String.valueOf(UtilMap.getList(formData, "employeeField_l843wfsm_id").get(0)))
+                    .formDataJson(JSON.toJSONString(dataForm))
+                    .build(), type);
+        }
+        return ydClient.operateData(YDParam.builder()
+                .formInstanceId(updateInstanceId)
+                .updateFormDataJson(JSON.toJSONString(dataForm))
+                .build(), YDConf.FORM_OPERATION.update);
+    }
+
+    /**
+     * upsert方法
+     */
+    @Override
+    public Object upsertFormData(String formUuid, Map condition, Map formData, UpsertLambda lambda) {
+        YDParam ydParam = YDParam.builder()
+                .formUuid(formUuid)
+                .searchFieldJson(JSON.toJSONString(condition))
+                .build();
+        // 查询数据, 是否存在
+        List<Map> dataList = (List<Map>) ydClient.queryData(ydParam, YDConf.FORM_QUERY.retrieve_search_form).getData();
+        McException.assertAccessException(dataList.size() > 1, "upsert方法, 查询条件返回数据不唯一");
+        // 异步回调, 查询结果
+        if (ObjectUtil.isNotNull(lambda)) {
+            Map form = lambda.dataList(dataList);
+            // 若返回为空, 不执行
+            if (ObjectUtil.isNull(form)) {
+                return null;
+            }
+            // 回调数据, 写入表单
+            formData.putAll(form);
+        }
+        if (dataList.size() > 0) {
+            ydParam.setUpdateFormDataJson(JSON.toJSONString(formData));
+            ydParam.setFormInstanceId(dataList.get(0).get("formInstanceId").toString());
+            return ydClient.operateData(ydParam, YDConf.FORM_OPERATION.update);
+        }
+        formData.putAll(condition); // 新增写入查询条件数据
+        ydParam.setFormDataJson(JSON.toJSONString(formData));
+        return ydClient.operateData(ydParam, YDConf.FORM_OPERATION.create);
+    }
+}

+ 36 - 0
mjava/src/main/java/com/malk/service/dingtalk/DDClient.java

@@ -0,0 +1,36 @@
+package com.malk.service.dingtalk;
+
+import java.util.Map;
+
+public interface DDClient {
+
+    /**
+     * 获取访问授权
+     */
+    String getAccessToken();
+
+    String getAccessToken(String appKey, String appSecret);
+
+    /**
+     * token授权参数: 旧版本
+     */
+    Map initTokenParams();
+
+    /**
+     * token授权参数: 新版本
+     */
+    Map initTokenHeader();
+
+    /**
+     * 获取js_ticket
+     */
+    String getJsApiTicket(String accessToken);
+
+    /**
+     * 通过免登码获取用户信息
+     */
+    Map getUserInfoByCode(String accessToken, String code);
+    Map initTokenHeader_PN();
+
+}
+

+ 98 - 0
mjava/src/main/java/com/malk/service/dingtalk/DDClient_Attendance.java

@@ -0,0 +1,98 @@
+package com.malk.service.dingtalk;
+
+import java.util.List;
+import java.util.Map;
+
+public interface DDClient_Attendance {
+
+    /**
+     * 获取打卡结果
+     *
+     * @param userIdList   企业内的员工ID列表,最大值50;
+     * @param workDateFrom 和workDateTo参数相隔最多7天(包含7天); 格式为“yyyy-MM-dd HH:mm:ss”,HH:mm:ss可以使用00:00:00,将返回此日期从0点到24点的结果
+     * @param offset       表示获取考勤数据的起始点, 下次获取传的offset值为之前的offset+limit; 表示获取考勤数据的条数,最大值50。
+     */
+    List<Map> listAttendanceResult(String access_token, String[] userIdList, String workDateFrom, String workDateTo, Number offset, Number size);
+
+    /**
+     * 获取打卡详情
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/attendance-clock-in-record-is-open
+     */
+    List<Map> listAttendanceRecord(String access_token, List userIds, String checkDateFrom, String checkDateTo);
+
+    /**
+     * 上传打卡记录 [Notice]
+     * -
+     * 1. 接口已下架, 需要开通专业版联系官方开权限
+     * 2. 不是userId, 也可请求成功, 但无数据写入
+     * 3. 考勤会以接口写入为准, 如已存在18点打卡, 再写入14点记录, 显示早退
+     * 4. 可写入未来日期开启, 查询钉钉考勤记录正常返回 [创建时间为接口调用时间]
+     */
+    boolean uploadAttendanceRecord(String access_token, String userId, String deviceName, String deviceId, String photoUrl, long userCheckTime);
+
+    /**
+     * 查询假期余额 [分页是根据游标控制]
+     *
+     * @param op_userid  当前企业内拥有OA审批应用权限的管理员的userId
+     * @param leave_code 假期类型唯一标识 [查询假期规则列表]
+     * @param userids    待查询的员工ID列表, 半角逗号相隔
+     * @param offset     分页偏移,从0开始的非负整数
+     * @param size       分页偏移,最大50
+     */
+    List<Map> queryVacationQuota(String access_token, String op_userid, String leave_code, String userids, int offset, int size);
+
+    /**
+     * 查询假期余额_全部
+     */
+    List<Map> queryVacationQuota_all(String access_token, String op_userid, String leave_code, String userids, int offset, int size);
+
+    /**
+     * 批量查询人员排班信息 [班次ID,无该字段,表明当天休息]
+     *
+     * @param to_date_time 开始时间和结束时间的间隔不能超过7天; 查询时间限制距今180天内.
+     * @param userids      查询的人员userId列表,一次最多可传50个
+     */
+    List<Map> listScheduleUsers(String access_token, String op_user_id, List<String> userids, long from_date_time, long to_date_time);
+
+    /**
+     * 获取考勤报表列定义 [获取假期相关字段信息,不返回ID。可设置考勤字段规则, 对于接口和钉钉后台考勤统计均生效]
+     */
+    List<Map> getAttColumns(String access_token);
+
+    /**
+     * 查询是否启用智能统计报表
+     */
+    boolean isOpenSmartReport(String access_token);
+
+    /**
+     * 获取考勤报表列值
+     *
+     * @param column_id_list 报表列ID,多值用英文逗号分隔,最大长度20 [获取考勤报表列定义接口]
+     * @param to_date        结束时间,结束时间减去开始时间必须在31天以内 [格式为 yyyy-MM-dd HH:mm:ss]
+     */
+    List<Map> getAttColumnVal(String access_token, String userid, List<String> column_id_list, String from_date, String to_date);
+
+    /**
+     * 获取考勤报表列值
+     *
+     * @param leave_names 报表列ID,假期名称,多个用英文逗号分隔,最大长度20 [获取考勤报表列定义接口, 无Id列]
+     * @param to_date     结束时间,结束时间减去开始时间必须在31天以内 [格式为 yyyy-MM-dd HH:mm:ss]
+     */
+    List<Map> getLeaveTimeByNames(String access_token, String userid, List<String> leave_names, String from_date, String to_date);
+
+    /**
+     * 获取班次详情 [应出勤天数, 仅返回当天]
+     */
+    Map getAttendanceShiftDetail(String access_token, String op_user_id, String shift_id);
+
+    /**
+     * 搜索考勤组详情
+     */
+    Map getAttendanceGroupDetail(String access_token, String op_user_id, String group_id);
+
+    /**
+     * 搜索考勤组摘要
+     */
+    List<Map> getAttendanceGroupSearch(String access_token, String op_user_id, String group_name);
+}

+ 99 - 0
mjava/src/main/java/com/malk/service/dingtalk/DDClient_Contacts.java

@@ -0,0 +1,99 @@
+package com.malk.service.dingtalk;
+
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 钉钉通讯录管理
+ *
+ * @apiNote https://open.dingtalk.com/document/orgapp/contacts-overview
+ */
+public interface DDClient_Contacts {
+
+    /**
+     * 获取子部门ID列表 [获取企业部门下的所有直属子部门列表]
+     */
+    List<Long> listSubDepartmentId(String access_token, long dept_id);
+
+    /**
+     * 获取部门列表
+     */
+    List<Map> listSubDepartmentDetail(String access_token, long dept_id);
+
+    /**
+     * 获取全部架构内部门_全部 ID [是否包含一级部门]
+     */
+    List<Long> getDepartmentId_all(String access_token, boolean containsTop);
+
+    List<Long> getDepartmentId_all(String access_token, boolean containsTop, long deptId);
+
+    /**
+     * 获取全部架构内部门_全部 detail [是否包含一级部门]
+     */
+    List<Map> getDepartmentDetail_all(String access_token, boolean containsTop);
+
+    /**
+     * 获取部门用户userid列表 [无需分页]
+     */
+    List<String> listDepartmentUserId(String access_token, long dept_id);
+
+    /**
+     * 查询用户详情
+     * -
+     * [入职时间 需要再在花名册添加员工可见, 接口才会返回]
+     * [手机号\邮箱等, 只有应用开通通讯录邮箱等个人信息权限,才会返回该字段: 如个人邮箱 fieldEmail]
+     * ppExt: 企业邮箱org_email, 若无则字段不返回; 手机号telephone无值, 但mobile会返回
+     */
+    Map getUserInfoById(String access_token, String userId);
+
+    /**
+     * 根据手机号查询用户 [手机号查询仅返回userid, 查询详情后返回]
+     */
+    Map getUserInfoByMobile(String access_token, String mobile);
+
+    /**
+     * 删除用户
+     */
+    boolean deleteUser(String access_token, String userId);
+
+    /**
+     * 查询离职记录列表
+     *
+     * @param startTime 开始时间: 格式:YYYY-MM-DDTHH:mm:ssZ(ISO 8601/RFC 3339)
+     * @param extInfo   maxResults, 每页最大条目数,最大值50. nextToken 分页游标. endTime 结束时间, 默认当前时间, 查询跨度不能超过365天
+     */
+    List<Map<String, String>> getLeaveEmployeeRecords(String access_token, Date startTime, Map extInfo);
+
+    /**
+     * 获取指定用户的所有父部门列表
+     */
+    Map listParentByUser(String access_token, String userId);
+
+    /**
+     * 创建用户 [24小时只能收到一次邀请通知, 若是频繁退出再进入, 可关闭邀请确认后自动加入, 路径: 设置 - 安全中心 - 隐私开关 - 团队添加我时需要我的确认 - 关闭]
+     */
+    Map createUser(String access_token, String name, String mobile, List<String> dept_id_list, Map body_ext);
+
+    /**
+     * 创建钉钉自建企业账号 [邮箱为必填]
+     */
+    Map createUser_dingTalk(String access_token, String login_id, String init_password, String name, List<Long> dept_id_list, Map body_ext);
+
+    /**
+     * 创建部门
+     */
+    Map createDepartment(String access_token, String name, long parent_id, Map body_ext);
+
+    /**
+     * 获取部门详情
+     */
+    Map getDepartmentInfo(String access_token, long dept_id);
+
+    /**
+     * 获取员工人数
+     *
+     * @param only_active 是否包含未激活钉钉人数
+     */
+    int getUserCount(String access_token, boolean only_active);
+}

+ 29 - 0
mjava/src/main/java/com/malk/service/dingtalk/DDClient_Dedicated.java

@@ -0,0 +1,29 @@
+package com.malk.service.dingtalk;
+
+import javax.validation.constraints.NotNull;
+import java.util.List;
+import java.util.Map;
+
+public interface DDClient_Dedicated {
+
+    /**
+     * 获取文件操作记录
+     *
+     * @param startDate     操作日志起始时间,UNIX时间戳,单位毫秒。
+     * @param endDate       操作日志截止时间,UNIX时间戳,单位毫秒。
+     * @param pageSize      每页最大条目数,最大值500。
+     * @param preLastRecord nextGmtCreate/nextBizId,作为分页偏移量。[如果是非首次调用,该参数传上次调用时返回的最后一条记录的值。]
+     */
+    List<Map> obtainFileOperationRecords(String accessToken, @NotNull long startDate, @NotNull long endDate, @NotNull int pageSize, Map preLastRecord);
+
+    /**
+     * 启用企业帐号
+     **/
+
+    boolean orgAccountsEnable(String accessToken, String userId);
+
+    /**
+     * 停用企业帐号
+     */
+    boolean orgAccountsDisable(String accessToken, String userId);
+}

+ 39 - 0
mjava/src/main/java/com/malk/service/dingtalk/DDClient_Event.java

@@ -0,0 +1,39 @@
+package com.malk.service.dingtalk;
+
+import com.alibaba.fastjson.JSONObject;
+
+import java.util.List;
+import java.util.Map;
+
+public interface DDClient_Event {
+
+    /**
+     * 钉钉审批回调事件
+     * -
+     * 审批任务可以获取到, 审批意见 [监听转交]; 审批回调可以监听到发起人撤销
+     */
+    void callBackEvent_Workflow(JSONObject eventJson);
+
+    /**
+     * 同步推送失败记录定时处理 [推送失败钉钉记录, 获取后记录会被清空]
+     * -
+     * 定时任务进行调用: 同步后自动执行 callBackEvent_Workflow 方法实现
+     * 回调数据字段名称: bpmsCallBackData:审批回调, roleLabelChange:角色回调, callbackData:其他回调
+     */
+    void syncFailedList(String access_token);
+
+    ////////////////////////////////////////////////////////////////////////////
+
+    /**
+     * 获取推送失败列表
+     * -
+     * 推送失败钉钉记录, 获取后记录会被清空
+     */
+    List<Map> getFailedList(String access_token);
+
+    /**
+     * 获取推送失败列表_全部
+     */
+    List getFailedList_all(String access_token);
+
+}

+ 40 - 0
mjava/src/main/java/com/malk/service/dingtalk/DDClient_Extension.java

@@ -0,0 +1,40 @@
+package com.malk.service.dingtalk;
+
+import java.util.Map;
+
+public interface DDClient_Extension {
+
+    /**
+     * 发送钉钉互动卡片 [微应用机器人创建后, 需要次日才能使用, 期间会出现机器人不存在提示]
+     *
+     * @param cardTemplateId   互动卡片的消息模板ID
+     * @param outTrackId       是由开发者自己生成并作为入参传递给钉钉的,钉钉只在对应使用到outTrackId的场景,帮助开发者对TrackId进行记录
+     * @param conversationType 0:单聊 1:群聊 [ fixme: openConversationId 必须填写, 通过钉钉事件回调\机器人消息回调\酷应用快捷入口进行获取. 微应用是否配置会话推送不强制 ]
+     * @param robotCode        场景群使用     [ 非场景群的企业内部开发-机器人发送单聊:chatBotId和robotCode都不填写,直接用支持单聊的机器人应用来发送 ]
+     * @param chatBotId        非场景群的企业内部开发-机器人发送群聊 [ fixme: cardData 内数据参考酷应用卡片内的 mock 格式, 若是表格需要传递 meta 表头 ]
+     * @param cardData         卡片数据 { cardData: { cardParamMap : { sys_full_json_obj: JSONString } } }: sys_full_json_obj, 将所有非 String 参数构建成一个 JSONObject
+     * @param extInfo          extInfo.put("atOpenIds", UtilMap.map("key", UtilMap.map("**@ALL**", "**@ALL**"))); // 所有人。UtilMap.map("cardOptions", UtilMap.map("supportForward", true)); // 允许转发
+     * @implSpec fixme: 注意检查是否没有更换 outTrackId。一般情况下,如果使用了新的 cardTemplateId 或 cardData 等参数,则需要生成一个全新的 outTrackId,否则更改不会生效
+     */
+    Map sendInteractiveCards(String accessToken, String cardTemplateId, String outTrackId, int conversationType, String robotCode, String chatBotId, String openConversationId, Map cardData, Map extInfo);
+
+    /**
+     * 注册互动卡片回调地址
+     */
+    Map registerInterActiveCard(String access_token, String callback_url, Map extInfo);
+
+    /**
+     * 机器人发送群聊消息 [其它参数公司, 参考 发送钉钉互动卡片]
+     *
+     * @param coolAppCode 当使用群聊酷应用的方式安装机器人时,必须传入此参数
+     */
+    Map sendGroupMessages(String accessToken, Map msgParam, String msgKey, String openConversationId, String robotCode, String coolAppCode);
+
+    /**
+     * 自定义机器人发送群消息
+     *
+     * @param extInfo 区分消息类型作为不同参数. 另 at { isAtAll: bool } 是否@所有人。
+     */
+    Map sendMessages(String accessToken, Map msgType, Map extInfo);
+
+}

+ 15 - 0
mjava/src/main/java/com/malk/service/dingtalk/DDClient_Group.java

@@ -0,0 +1,15 @@
+package com.malk.service.dingtalk;
+
+import java.util.Map;
+
+public interface DDClient_Group {
+
+    Map createGroup(String access_token, Map param);
+
+    String createGroupByTemp(String access_token, String title, String template_id, String owner_user_id, String user_ids);
+
+    Boolean addGroupUser(String access_token, String open_conversation_id, String user_ids);
+
+    Boolean delGroupUser(String access_token, String open_conversation_id, String user_ids);
+
+}

+ 16 - 0
mjava/src/main/java/com/malk/service/dingtalk/DDClient_Notice.java

@@ -0,0 +1,16 @@
+package com.malk.service.dingtalk;
+
+import java.util.List;
+import java.util.Map;
+
+public interface DDClient_Notice {
+
+    /**
+     * 发送工作通知
+     *
+     * @param userid_list  接收者的userid列表,最大用户列表长度100
+     * @param dept_id_list 接收者的部门id列表,最大列表长度20。接收者是部门ID时,包括子部门下的所有用户
+     * @param to_all_user  是否发送给企业全部用户。当设置为false时必须指定userid_list或dept_id_list其中一个参数的值
+     */
+    String sendNotification(String access_token, List<String> userid_list, List<String> dept_id_list, boolean to_all_user, Map msg);
+}

+ 50 - 0
mjava/src/main/java/com/malk/service/dingtalk/DDClient_Personnel.java

@@ -0,0 +1,50 @@
+package com.malk.service.dingtalk;
+
+import java.util.List;
+import java.util.Map;
+
+public interface DDClient_Personnel {
+
+    /**
+     * 获取花名册元数据
+     */
+    List<Map> getPersonnelMeta(String access_token, Number agentid);
+
+    /**
+     * 获取员工花名册字段信息
+     *
+     * @param userIds           员工的userid列表,多个userid之间使用逗号分隔,一次最多支持传100个值
+     * @param field_filter_list [非必填] 需要获取的花名册字段field_code值列表,多个字段之间使用逗号分隔,一次最多支持传100个值
+     */
+    List<Map> getEmployeeInfos(String access_token, List<String> userIds, Number agentId, List<String> field_filter_list);
+
+    /**
+     * 获取待入职员工列表
+     * @param access_token
+     * @return
+     */
+    List<String> getPendingEmployeeIds(String access_token);
+
+    /**
+     * 获取在职员工列表
+     * @param access_token
+     * @param statusList
+     * @return
+     */
+    List<String> getWorkingEmployeeIds(String access_token, String statusList);
+
+    /**
+     * 获取离职员工id列表
+     * @param access_token
+     * @return
+     */
+    List<String> getLeaveEmployeeIdList(String access_token);
+
+    /**
+     * 根据离职员工id列表批量获取离职员工信息
+     * @param access_token
+     * @param userIdList
+     * @return
+     */
+    List<Map> getLeaveEmployeeInfos(String access_token, List<String> userIdList);
+}

+ 14 - 0
mjava/src/main/java/com/malk/service/dingtalk/DDClient_Report.java

@@ -0,0 +1,14 @@
+package com.malk.service.dingtalk;
+
+import java.util.List;
+import java.util.Map;
+
+public interface DDClient_Report {
+
+    /**
+     * 获取用户发出的日志列表
+     *
+     * @param extInfo size 每页数据量,最大值为20; 查询游标,初始传入0,后续从上一次的返回值中获取
+     */
+    List<Map> reportList(String access_token, long start_time, long end_time, Map extInfo);
+}

+ 15 - 0
mjava/src/main/java/com/malk/service/dingtalk/DDClient_Schedule.java

@@ -0,0 +1,15 @@
+package com.malk.service.dingtalk;
+
+import com.malk.server.dingtalk.DDR_New;
+
+import java.util.Map;
+
+public interface DDClient_Schedule {
+
+    /**
+     * 创建日程
+     *
+     * @param body start/time 1. UtilDateTime.DATE_TIME_ISO: ISO-8601的date-time格式, 2. 非全天日程传递 timeZone: Asia/Shanghai
+     */
+    DDR_New eventsSchedule(String access_token, String userId, Map body);
+}

+ 89 - 0
mjava/src/main/java/com/malk/service/dingtalk/DDClient_Storage.java

@@ -0,0 +1,89 @@
+package com.malk.service.dingtalk;
+
+import com.malk.server.dingtalk.DDR_New;
+
+import java.util.List;
+import java.util.Map;
+
+public interface DDClient_Storage {
+
+
+    ///////////////////////// 钉盘附件 /////////////////////////
+
+    /**
+     * 新建空间
+     *
+     * @implSpec 客户端云盘内是不可见: 1. 官方OA审批 - 审批钉盘空间&附件 - 获取审批钉盘空间信息; 2. 文档/文件 - 存储管理 - 空间管理 - 添加空间
+     */
+    DDR_New createSpaces(String accessToken, String name, String unionId);
+
+    /**
+     * 获取空间列表 [钉盘-团队文件-第一层目录列表]
+     *
+     * @implSpec 忽略分页, 一次性全量查询
+     */
+    List<Map> getSpaces(String accessToken, String unionId);
+
+    /**
+     * 获取文件或文件夹列表
+     *
+     * @param parentId 父目录Id。根目录时,该参数是0
+     * @implSpec 忽略分页, 一次性全量查询; extInfo 非必填参数
+     */
+    List<Map> getDentries(String accessToken, String unionId, String spaceId, String parentId, Map extInfo);
+
+    /**
+     * 添加文件夹 [特殊字符已处理]
+     *
+     * @param name   文件夹的名称,命名有以下要求:头尾不能包含空格,否则会自动去除; 不能包含特殊字符,包括:制表符、*、"、<、>、|; 不能以"."结尾
+     * @param option ppExt: 文件夹名称冲突策略, 返回已存在文件夹, 避免查询文件夹列表  UtilMap.map("conflictStrategy", "RETURN_DENTRY_IF_EXISTS")
+     */
+    Map addFolders(String accessToken, String unionId, String spaceId, String parentId, String name, Map option);
+
+    ///////////////////////// OA审批附件 /////////////////////////
+
+    /**
+     * 获取审批钉盘空间信息
+     *
+     * @implNote space_id 说明
+     * - 一个企业内审批附件钉盘space_id是唯一的。
+     * - 本接口有授权上传权限的作用。每次调用上传附件API接口前,建议使用上传操作人userId再调用一次本接口。
+     * - 审批附件钉盘,属于企业钉盘的一部分,占用的是企业钉盘空间,但是审批附件钉盘空间和其中的文件在客户端内是不可见的。
+     */
+    String getSpacesInfos(String access_token, String userId, String agentId);
+
+    /**
+     * 添加权限
+     *
+     * @param dentryId 文件或者文件夹Id。说明: 如果对整个空间授权,该参数值传0
+     * @param roleId   权限角色Id。 OWNER:拥有者, MANAGER:管理者, EDITOR:编辑者, DOWNLOADER:下载者, READER:查看者
+     * @param members  权限成员列表,最大值30。[配置详见文档]
+     */
+    boolean addSpacePermissions(String access_token, String spaceId, String dentryId, String unionId, String roleId, List<Map> members, Map option);
+
+    ///////////////////////// 通用上传逻辑 /////////////////////////
+
+    /**
+     * 获取文件上传信息
+     *
+     * @implNote protocol 通过指定上传协议返回不同协议上传所需要的信息。HEADER_SIGNATURE:Header加签
+     * @implNote multipart 是否需要分片上传。 true:需要; false:不需要. 说明: 5G以下文件,设为false,简化上传步骤。
+     * 5G以上文件,必须设为true,否则会上传失败。
+     */
+    DDR_New getUploadInfos(String access_token, String spaceId, String unionId, Map option);
+
+    /**
+     * 文件上传
+     *
+     * @return uploadKey: 上传唯一标识; headerSignatureInfo: { resourceUrls: 多个上传下载URL列表, headers: 请求头信息 }
+     */
+    boolean uploadFiles(String resourceUrl, Map<String, String> headerSignatureInfo, String pathFile);
+
+    /**
+     * 提交文件
+     *
+     * @param name     文件的名称,带后缀 [注意命名规范]
+     * @param parentId 父目录Id。根目录时,该参数是0。
+     */
+    Map commitFiles(String access_token, String spaceId, String unionId, String uploadKey, String name, String parentId, Map option);
+}

+ 98 - 0
mjava/src/main/java/com/malk/service/dingtalk/DDClient_Workflow.java

@@ -0,0 +1,98 @@
+package com.malk.service.dingtalk;
+
+import com.malk.server.dingtalk.DDR_New;
+
+import java.io.File;
+import java.util.List;
+import java.util.Map;
+
+public interface DDClient_Workflow {
+
+
+    /**
+     *获取权限范围内的相关部门审批实例ID列表
+     */
+
+    Map getProcessInstanceId(String access_token, String processInstanceId);
+
+    /**
+     * 发起审批实例 [旧版本有字段报错提示] ppExt: 子组件内的数字输入框只要是开了编辑权限的,审批以后都会计入修改记录 [但手动发起不编辑不会记录]
+     *
+     * @param body_ext 包含必填字段, 不能为空, 统一扩展合并到请求参数内
+     * @implNote 若approvers已传值时(即直接指定审批人列表, 最大列表长度:20。),则deptId不需填写。若approvers未传值时(即不直接指定审批人列表),则deptId需必填,若为根部门ID需填1。
+     * @implNote targetSelectActioners 使用OA后台设置的默认流程,并且流程中有审批人自选节点,该参数必填。
+     * @implNote formComponentValues 表单数据内容,控件列表,最大列表长度:150。
+     */
+    String doProcessInstances(String access_token, String originatorUserId, String processCode, List formComponentValues, Map body_ext);
+
+    /**
+     * 获取审批实例详情
+     *
+     */
+    Map getProcessinstanceIds_PN(String access_token,String processCode);
+
+    /**
+     * 撤回审批实例 NOTE: 终审通过无法撤销,若是假勤管理单据才能通知撤销
+     *
+     * @param processInstanceId 审批实例ID
+     * @param isSystem          是否通过系统操作
+     * @param remark            终止说明
+     * @param operatingUserId   操作人的userid, 当isSystem为false时,该参数必传
+     */
+    boolean terminateRunningApprove(String access_token, String processInstanceId, boolean isSystem, String remark, String operatingUserId);
+
+    /**
+     * 同意或拒绝审批任务
+     */
+    boolean executeRunningApprove(String access_token, String processInstanceId, String actionerUserId, String taskId, String result, String remark, Map file);
+
+    /**
+     * 通知审批撤销
+     */
+    boolean cancelRunningApprove(String access_token, String userid, String approve_id);
+
+    /**
+     * 添加审批评论
+     *
+     * @param processInstanceId 审批实例ID
+     * @param file              文件
+     * @param text              评论的内容
+     * @param commentUserid     评论人的userid
+     */
+    boolean commentApproveInstance(String access_token, String processInstanceId, File file, String text, String commentUserid);
+
+    /**
+     * 获取审批实例ID列表
+     *
+     * @param body_ext 扩展参数
+     *                 - nextToken 分页游标: 如果是首次调用,该参数不传。
+     *                 - maxResults 分页参数,每页大小,最多传20。
+     *                 - userIds 发起人userId列表,最大列表长度为10。
+     *                 - statuses 流程实例状态:NEW:新创建, RUNNING:审批中, TERMINATED:被终止, COMPLETED:完成, CANCELED:取消
+     */
+    Map getInstanceIds(String access_token, String processCode, long startTime, long endTime, Map body_ext);
+
+    /**
+     * 获取审批实例ID列表_全部 [若轮询, 时间需要覆盖]
+     *
+     * @param startTime 审批实例开始时间,Unix时间戳,单位毫秒。
+     * @param endTime   审批实例结束时间,Unix时间戳,单位毫秒。
+     */
+    List<String> getInstanceIds_all(String access_token, String processCode, long startTime, long endTime, Map extInfo);
+
+    /**
+     * 创建钉钉待办任务新版SDK
+     *
+     * @param createUserId       包含 unionId 与 operatorId 参数, 通过 userId 换取对应的 unionId 值
+     * @param subject            待办标题,最大长度1024
+     * @param description        待办备注描述,最大长度4096 [不显示]
+     * @param dueTime            截止时间,Unix时间戳,单位毫秒
+     * @param executorIds        执行者的unionId,最大数量1000
+     * @param participantIds     参与者的unionId,最大数量1000
+     * @param detailUrl          详情页url跳转地址: appUrl APP端详情页url跳转地址, pcUrl PC端详情页url跳转地址
+     * @param isOnlyShowExecutor 生成的待办是否仅展示在执行者的待办列表中
+     * @param priority           优先级,取值:10:较低, 20:普通, 30:紧急,40:非常紧急
+     * @param notifyConfigs      待办通知配置: dingNotify DING通知配置,目前仅支持取值为1,表示应用内DING
+     */
+    DDR_New createTBTask(String access_token, String createUserId, String subject, String description, long dueTime, List<String> executorIds, List<String> participantIds, Map detailUrl, boolean isOnlyShowExecutor, int priority, Map notifyConfigs);
+}

+ 70 - 0
mjava/src/main/java/com/malk/service/dingtalk/DDService.java

@@ -0,0 +1,70 @@
+package com.malk.service.dingtalk;
+
+import java.util.List;
+import java.util.Map;
+
+public interface DDService {
+
+    /**
+     * 新发起审批15s内不允许撤销, 异步执行
+     */
+    void terminateRunningApprove_async(String access_token, String processInstanceId, boolean isSystem, String subject, String description, String operatingUserId, String noteUserId);
+
+    /**
+     * 钉钉查询假期余额返回是记录, 按照消失/天单位计算该假期类型下的余额
+     */
+    Map queryVacationQuota_balance(String access_token, String op_userid, String leave_code, String userids, int offset, int size);
+
+    /**
+     * 上传审批附件
+     *
+     * @param urlFile      远程文件地址
+     * @param fileNamePure 文件名称[后缀自动通过地址获取]
+     */
+    Map uploadFileFormUrlForOverflow(String accessToken, String userId, String urlFile, String fileNamePure);
+
+    /**
+     * 上传审批附件
+     *
+     * @param filePath 已存在文件, 本地file绝对路径
+     */
+    Map uploadFileFormUrlForOverflow(String accessToken, String userId, String filePath);
+
+    /**
+     * 上传钉盘文件
+     *
+     * @param urlFile      远程文件地址
+     * @param fileNamePure 氚云前端直接获取的附件ID,且免登后也无文件相关信息。若需要如文件名称,需要单独再查询SQL. 因此不能通过url截取文件名称
+     * @param unionId      String.valueOf(ddClient_contacts.getUserInfoById(ddClient.getAccessToken(), ddConf.getOperator()).get("unionid"));
+     */
+    Map uploadFileFormUrlForDingDrive(String accessToken, String unionId, String spaceId, String parentId, String urlFile, String fileNamePure);
+
+    // todo 通讯录部门结构返回; 通讯录全量数据同步; 限流控制
+
+    /**
+     * 判断员工是否在指定部门
+     */
+    boolean matchDepartment(String access_token, String userId, List<Long> deptIds);
+
+    /**
+     * 获取员工所属部门层级路径 [一个人存在多个部门默认取第一个, 不包含第一层部门]
+     */
+    List<Map> getUserDepartmentHierarchy(String access_token, String userId);
+
+    /**
+     * 获取员工所属部门层级路径 [名称拼接]
+     */
+    String getUserDepartmentHierarchyJoin(String access_token, String userId, String jon);
+
+    /**
+     * jsApi 注册
+     *
+     * @implNote H5无需配置, 但宜搭内免得需要配置鉴权, 才能获取到code. 宜搭非免登页面不会执行调用
+     */
+    Map registerJsApi(String url, String nonceStr);
+
+    /**
+     * 免登code获取用户信息
+     */
+    Map getUserInfoByCode(String code);
+}

+ 113 - 0
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient.java

@@ -0,0 +1,113 @@
+package com.malk.service.dingtalk.impl;
+
+import com.malk.server.dingtalk.DDConf;
+import com.malk.server.dingtalk.DDR;
+import com.malk.service.dingtalk.DDClient;
+import com.malk.utils.UtilHttp;
+import com.malk.utils.UtilMap;
+import com.malk.utils.UtilToken;
+import lombok.Synchronized;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.HashMap;
+import java.util.Map;
+
+@Slf4j
+@Service
+public class DDImplClient implements DDClient {
+
+    @Autowired
+    private DDConf ddConf;
+
+    /**
+     * 获取访问授权
+     *
+     * @apiNote https://open.dingtalk.com/document/personalapp-service/obtain-user-token
+     */
+    @Synchronized
+    @Override
+    public String getAccessToken() {
+        String accessToken = UtilToken.get("invalid-token-dingtalk");
+        if (StringUtils.isNotBlank(accessToken)) return accessToken;
+        Map param = new HashMap();
+        param.put("appkey", ddConf.getAppKey());
+        param.put("appsecret", ddConf.getAppSecret());
+        DDR r = (DDR) UtilHttp.doGet("https://oapi.dingtalk.com/gettoken", param, DDR.class);
+        log.info("响应token, {}", r.getAccessToken());
+        accessToken = r.getAccessToken();
+        // token失效自动重置: DD重新调用会重置过期时间
+        UtilToken.put("invalid-token-dingtalk", accessToken, r.getExpiresIn() * 1000L);
+        return accessToken;
+    }
+
+    @Override
+    public String getAccessToken(String appKey, String appSecret) {
+        Map param = UtilMap.map("appkey, appsecret", appKey, appSecret);
+        DDR r = DDR.doGet("https://oapi.dingtalk.com/gettoken", null, param);
+        log.info("响应token, {}", r.getAccessToken());
+        return r.getAccessToken();
+    }
+
+    /**
+     * token授权参数: 旧版本
+     */
+    @Override
+    public Map initTokenParams() {
+        return DDConf.initTokenParams(getAccessToken());
+    }
+
+    /**
+     * token授权参数: 新版本
+     */
+    @Override
+    public Map initTokenHeader() {
+        return DDConf.initTokenHeader(getAccessToken());
+    }
+
+    /**
+     * 获取jsapi_ticket
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/obtain-jsapi_ticket
+     */
+    @Synchronized
+    @Override
+    public String getJsApiTicket(String accessToken) {
+        String ticket = UtilToken.get("invalid-ticket-dingtalk");
+        if (StringUtils.isNotBlank(ticket)) return ticket;
+        DDR r = DDR.doGet("https://oapi.dingtalk.com/get_jsapi_ticket", null, UtilMap.map("access_token", accessToken));
+        log.info("响应ticket, {}", r.getAccessToken());
+        ticket = r.getTicket();
+        // token失效自动重置: DD重新调用会重置过期时间
+        UtilToken.put("invalid-ticket-dingtalk", ticket, r.getExpiresIn() * 1000L);
+        return ticket;
+    }
+
+    /**
+     * 通过免登码获取用户信息
+     */
+    @Override
+    public Map getUserInfoByCode(String accessToken, String code) {
+        return (Map) DDR.doPost("https://oapi.dingtalk.com/topapi/v2/user/getuserinfo", null, DDConf.initTokenParams(accessToken), UtilMap.map("code", code)).getResult();
+    }
+
+    public Map initTokenHeader_PN() {
+        return DDConf.initTokenHeader(getAccessToken_PN());
+    }
+    public String getAccessToken_PN() {
+        //浦江基石
+        String accessToken = UtilToken.get("invalid-token-dingtalk");
+        if (StringUtils.isNotBlank(accessToken)) return accessToken;
+        Map param = new HashMap();
+        param.put("appkey", "dingmpxci8bolc3jpima");
+        param.put("appsecret", "Y_k3jpKNHbGvb9S9As2Y61ZaUFNglm7SCqquIkcowLBRoc4ZpH7DG0ZTn8LyHMwI");
+        DDR r = (DDR) UtilHttp.doGet("https://oapi.dingtalk.com/gettoken", param, DDR.class);
+        log.info("响应token, {}", r.getAccessToken());
+        accessToken = r.getAccessToken();
+        // token失效自动重置: DD重新调用会重置过期时间
+        UtilToken.put("invalid-token-dingtalk", accessToken, r.getExpiresIn() * 1000L);
+        return accessToken;
+    }
+}

+ 194 - 0
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Attendance.java

@@ -0,0 +1,194 @@
+package com.malk.service.dingtalk.impl;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.server.dingtalk.DDR;
+import com.malk.service.dingtalk.DDClient_Attendance;
+import com.malk.utils.UtilHttp;
+import com.malk.utils.UtilMap;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 考勤
+ *
+ * @apiNote https://open.dingtalk.com/document/orgapp/attendance-overview
+ */
+@Service
+@Slf4j
+public class DDImplClient_Attendance implements DDClient_Attendance {
+
+    /**
+     * 获取打卡结果
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/open-attendance-clock-in-data
+     */
+    @Override
+    public List<Map> listAttendanceResult(String access_token, String[] userIdList, String workDateFrom, String workDateTo, Number offset, Number limit) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = UtilMap.map("userIdList, workDateFrom, workDateTo, offset, limit, isI18n", userIdList, workDateFrom, workDateTo, offset, limit, false);
+        return DDR.doPost("https://oapi.dingtalk.com/attendance/list", null, param, body).getRecordresult();
+    }
+
+    /**
+     * 获取打卡详情
+     *
+     * @param userIds       企业内的员工ID列表,最大值50;
+     * @param checkDateFrom checkDateFrom 和workDateTo参数相隔最多7天(包含7天); 注: 参数传"2021-12-01 18:00:00",员工在19:00的打卡信息获取不到
+     */
+    @Override
+    public List<Map> listAttendanceRecord(String access_token, List userIds, String checkDateFrom, String checkDateTo) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = UtilMap.map("userIds, checkDateFrom, checkDateTo, isI18n", userIds, checkDateFrom, checkDateTo, false);
+        return DDR.doPost("https://oapi.dingtalk.com/attendance/listRecord", null, param, body).getRecordresult();
+    }
+
+    /**
+     * 获取打卡详情
+     *
+     * @apiNote https://open.dingtalk.com/document/isvapp/upload-punch-records
+     */
+    @Override
+    public boolean uploadAttendanceRecord(String access_token, String userId, String deviceName, String deviceId, String photoUrl, long userCheckTime) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = UtilMap.map("userid, device_name, device_id, photo_url, user_check_time", userId, deviceName, deviceId, photoUrl, userCheckTime);
+        return DDR.doPost("https://oapi.dingtalk.com/topapi/attendance/record/upload", null, param, body).isSuccess();
+    }
+
+    /**
+     * 查询假期余额 [分页是根据游标控制]
+     *
+     * @apiNote https://open.dingtalk.com/document/isvapp-server/query-holiday-balance
+     */
+    @Override
+    public List<Map> queryVacationQuota(String access_token, String op_userid, String leave_code, String userids, int offset, int size) {
+        return _queryVacationQuota(access_token, op_userid, leave_code, userids, offset + size, size, null);
+    }
+
+    /**
+     * 查询假期余额_全部
+     */
+    @Override
+    public List<Map> queryVacationQuota_all(String access_token, String op_userid, String leave_code, String userids, int offset, int size) {
+        return _queryVacationQuota(access_token, op_userid, leave_code, userids, offset + size, size, new ArrayList<>());
+    }
+
+    // 递归查询 | 单次查询
+    private List<Map> _queryVacationQuota(String access_token, String op_userid, String leave_code, String userids, int offset, int size, List<Map> records) {
+        Map body = new HashMap();
+        body.put("leave_code", leave_code);
+        body.put("op_userid", op_userid);
+        body.put("userids", userids);
+        body.put("offset", offset);
+        body.put("size", size);
+        DDR ddr = (DDR) UtilHttp.doPost("https://oapi.dingtalk.com/topapi/attendance/vacation/quota/list", null, DDConf.initTokenParams(access_token), body, DDR.class);
+        List<Map> list = (List<Map>) ((Map) ddr.getResult()).get("leave_quotas");
+        if (ObjectUtil.isNull(records)) {
+            return list;
+        }
+        records.addAll(list);
+        if (Boolean.TRUE.equals(((Map<?, ?>) ddr.getResult()).get("has_more"))) {
+            _queryVacationQuota(access_token, op_userid, leave_code, userids, offset + size, size, records);
+        }
+        return records;
+    }
+
+    /**
+     * 批量查询人员排班信息
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/query-batch-scheduling-information
+     */
+    @Override
+    public List<Map> listScheduleUsers(String access_token, String op_user_id, List<String> userids, long from_date_time, long to_date_time) {
+        Map params = UtilMap.map("op_user_id, userids, from_date_time, to_date_time", op_user_id, String.join(",", userids), from_date_time, to_date_time);
+        DDR ddr = DDR.doPost("https://oapi.dingtalk.com/topapi/attendance/schedule/listbyusers", null, DDConf.initTokenParams(access_token), params);
+        if (ObjectUtil.isNull(ddr.getResult())) {
+            return new ArrayList<>();
+        }
+        return (List<Map>) ddr.getResult();
+    }
+
+    /**
+     * 查询是否启用智能统计报表
+     */
+    @Override
+    public boolean isOpenSmartReport(String access_token) {
+        DDR ddr = DDR.doPost("https://oapi.dingtalk.com/topapi/attendance/isopensmartreport", null, DDConf.initTokenParams(access_token), null);
+        return UtilMap.getBoolean(((Map) ddr.getResult()), "smart_report");
+    }
+
+    /**
+     * 获取考勤报表列定义
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/queries-the-enterprise-attendance-report-column
+     */
+    @Override
+    public List<Map> getAttColumns(String access_token) {
+        DDR ddr = DDR.doPost("https://oapi.dingtalk.com/topapi/attendance/getattcolumns", null, DDConf.initTokenParams(access_token), null);
+        return (List<Map>) ((Map) (ddr.getResult())).get("columns");
+    }
+
+    /**
+     * 获取考勤报表列值
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/queries-the-column-value-of-the-attendance-report
+     */
+    @Override
+    public List<Map> getAttColumnVal(String access_token, String userid, List<String> column_id_list, String from_date, String to_date) {
+        Map boyds = UtilMap.map("userid, column_id_list, from_date, to_date", userid, String.join(",", column_id_list), from_date, to_date);
+        DDR ddr = DDR.doPost("https://oapi.dingtalk.com/topapi/attendance/getcolumnval", null, DDConf.initTokenParams(access_token), boyds);
+        return (List<Map>) ((Map) (ddr.getResult())).get("column_vals");
+    }
+
+    /**
+     * 获取用户考勤数据
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/obtains-the-holiday-data-from-the-smart-attendance-report
+     */
+    @Override
+    public List<Map> getLeaveTimeByNames(String access_token, String userid, List<String> leave_names, String from_date, String to_date) {
+        Map boyds = UtilMap.map("userid, leave_names, from_date, to_date", userid, String.join(",", leave_names), from_date, to_date);
+        DDR ddr = DDR.doPost("https://oapi.dingtalk.com/topapi/attendance/getleavetimebynames", null, DDConf.initTokenParams(access_token), boyds);
+        return (List<Map>) ((Map) (ddr.getResult())).get("columns");
+    }
+
+    /**
+     * 获取班次详情
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/shift-query
+     */
+    @Override
+    public Map getAttendanceShiftDetail(String access_token, String op_user_id, String shift_id) {
+        Map boyds = UtilMap.map("op_user_id, shift_id", op_user_id, shift_id);
+        return (Map) DDR.doPost("https://oapi.dingtalk.com/topapi/attendance/shift/query", null, DDConf.initTokenParams(access_token), boyds).getResult();
+    }
+
+    /**
+     * 查询企业考勤排班详情
+     */
+
+    /**
+     * @apiNote https://open.dingtalk.com/document/isvapp/query-a-single-attendance-group
+     */
+    @Override
+    public Map getAttendanceGroupDetail(String access_token, String op_user_id, String group_id) {
+        Map boyds = UtilMap.map("op_user_id, group_id", op_user_id, group_id);
+        return (Map) DDR.doPost("https://oapi.dingtalk.com/topapi/attendance/group/query", null, DDConf.initTokenParams(access_token), boyds).getResult();
+    }
+
+    /**
+     * 搜索考勤组摘要
+     *
+     * @apiNote https://open.dingtalk.com/document/isvapp/query-a-single-attendance-group
+     */
+    @Override
+    public List<Map> getAttendanceGroupSearch(String access_token, String op_user_id, String group_name) {
+        Map boyds = UtilMap.map("op_user_id, group_name", op_user_id, group_name);
+        return (List<Map>) DDR.doPost("https://oapi.dingtalk.com/topapi/attendance/group/search", null, DDConf.initTokenParams(access_token), boyds).getResult();
+    }
+}

+ 250 - 0
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Contacts.java

@@ -0,0 +1,250 @@
+package com.malk.service.dingtalk.impl;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.server.dingtalk.DDR;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.service.dingtalk.DDClient_Contacts;
+import com.malk.utils.UtilDateTime;
+import com.malk.utils.UtilList;
+import com.malk.utils.UtilMap;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Service
+@Slf4j
+public class DDImplClient_Contacts implements DDClient_Contacts {
+
+    /**
+     * 获取子部门ID列表 [获取企业部门下的所有直属子部门列表]
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/obtain-a-sub-department-id-list-v2
+     */
+    @Override
+    public List<Long> listSubDepartmentId(String access_token, long dept_id) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = UtilMap.map("dept_id", dept_id);
+        Map rsp = (Map) DDR.doPost("https://oapi.dingtalk.com/topapi/v2/department/listsubid", null, param, body).getResult();
+        List<Number> list = (List<Number>) rsp.get("dept_id_list");
+        // ppExt: 不要直接使用 Number 作为类型, 不同基本类型比较值时会有偏差 [可以作为父类接受数据, 避免直接强制类型转换错误, 再进行基本类型处理]
+        return list.stream().map(item -> item.longValue()).collect(Collectors.toList());
+    }
+
+    /**
+     * 获取部门列表
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/obtain-the-department-list-v2
+     */
+    @Override
+    public List<Map> listSubDepartmentDetail(String access_token, long dept_id) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = UtilMap.map("dept_id", dept_id);
+        return (List<Map>) DDR.doPost("https://oapi.dingtalk.com/topapi/v2/department/listsub", null, param, body).getResult();
+    }
+
+    /**
+     * 获取全部架构内部门_全部 detail [包含一级部门]
+     */
+    @Override
+    public List<Map> getDepartmentDetail_all(String access_token, boolean containsTop) {
+        List<Map> deptList = containsTop ? UtilList.asList(getDepartmentInfo(access_token, DDConf.TOP_DEPARTMENT)) : new ArrayList<>();
+        return _getDepartment_all(access_token, DDConf.TOP_DEPARTMENT, deptList, false);
+    }
+
+    /**
+     * 获取全部架构内部门_全部 id [包含一级部门]
+     */
+    @Override
+    public List<Long> getDepartmentId_all(String access_token, boolean containsTop) {
+        List<Long> deptList = containsTop ? UtilList.asList(DDConf.TOP_DEPARTMENT) : new ArrayList<>();
+        return (List<Long>) _getDepartment_all(access_token, DDConf.TOP_DEPARTMENT, deptList, true)
+                .stream().map(detpId -> Long.valueOf(String.valueOf(detpId))).collect(Collectors.toList());
+    }
+
+    @Override
+    public List<Long> getDepartmentId_all(String access_token, boolean containsTop, long deptId) {
+        List<Long> deptList = containsTop ? UtilList.asList(deptId) : new ArrayList<>();
+        return (List<Long>) _getDepartment_all(access_token, deptId, deptList, true)
+                .stream().map(detpId -> Long.valueOf(String.valueOf(detpId))).collect(Collectors.toList());
+    }
+
+    /// 递归查询
+    private List _getDepartment_all(String access_token, long deptId, List deptIdList, boolean isIdList) {
+        List tList = isIdList ?
+                listSubDepartmentId(access_token, deptId) :
+                listSubDepartmentDetail(access_token, deptId);
+        deptIdList.addAll(tList);
+        // 当所有子部门都为空, 递归出口
+        for (Object dept : tList) {
+            _getDepartment_all(access_token, Long.valueOf(String.valueOf(isIdList ? dept : ((Map) dept).get("dept_id"))), deptIdList, isIdList);
+        }
+        return deptIdList;
+    }
+
+    /**
+     * 获取部门用户userid列表 [无需分页]
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/query-the-list-of-department-userids
+     */
+    @Override
+    public List<String> listDepartmentUserId(String access_token, long dept_id) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = UtilMap.map("dept_id", dept_id);
+        Map rsp = (Map) DDR.doPost("https://oapi.dingtalk.com/topapi/user/listid", null, param, body).getResult();
+        return (List<String>) rsp.get("userid_list");
+    }
+
+    /**
+     * 查询用户详情
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/query-user-details
+     */
+    @Override
+    public Map getUserInfoById(String access_token, String userId) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = UtilMap.map("userid", userId);
+        return (Map) DDR.doPost("https://oapi.dingtalk.com/topapi/v2/user/get", null, param, body).getResult();
+    }
+
+    /**
+     * 查询用户详情
+     */
+    @Override
+    public Map getUserInfoByMobile(String access_token, String mobile) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = UtilMap.map("mobile", mobile);
+        Map result = (Map) DDR.doPost("https://oapi.dingtalk.com/topapi/v2/user/getbymobile", null, param, body).getResult();
+        return getUserInfoById(access_token, String.valueOf(result.get("userid")));
+    }
+
+    /**
+     * 删除用户
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/delete-a-user
+     */
+    @Override
+    public boolean deleteUser(String access_token, String userId) {
+        DDR.doPost("https://oapi.dingtalk.com/topapi/v2/user/delete", null, DDConf.initTokenParams(access_token), UtilMap.map("userid", userId));
+        return true;
+    }
+
+    /**
+     * 查询离职记录列表
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/query-the-details-of-employees-who-have-left-office
+     */
+    @Override
+    public List<Map<String, String>> getLeaveEmployeeRecords(String access_token, Date startTime, Map extInfo) {
+        return _getLeaveEmployeeRecords(access_token, startTime, extInfo, new ArrayList<>());
+    }
+
+    /// 递归所有离职人员
+    public List<Map<String, String>> _getLeaveEmployeeRecords(String access_token, Date startTime, Map extInfo, List dataList) {
+        String start = UtilDateTime.format(startTime, "yyyy-MM-dd'T'HH:mm:ss") + "Z"; // 字符格式
+        Map body = UtilMap.map("startTime", start);
+        if (ObjectUtil.isNotNull(extInfo)) {
+            body.putAll(extInfo);
+            if (body.containsKey("endTime")) {
+                Date end = (Date) extInfo.get("endTime");
+                body.put("endTime", UtilDateTime.format(end, "yyyy-MM-dd'T'HH:mm:ss") + "Z");
+            }
+        }
+        if (!body.containsKey("maxResults")) {
+            body.put("maxResults", 50);
+        }
+        DDR_New ddr_new = DDR_New.doGet("https://api.dingtalk.com/v1.0/contact/empLeaveRecords", DDConf.initTokenHeader(access_token), body);
+        if (ObjectUtil.isNotNull(ddr_new.getRecords())){
+            dataList.addAll(ddr_new.getRecords());
+        }
+        if (StringUtils.isNotBlank(ddr_new.getNextToken())) {
+            extInfo = UtilMap.putNotNull(extInfo, "nextToken", ddr_new.getNextToken());
+            _getLeaveEmployeeRecords(access_token, startTime, extInfo, dataList);
+        }
+        return dataList;
+    }
+
+    /**
+     * 获取指定用户的所有父部门列表
+     */
+    @Override
+    public Map listParentByUser(String access_token, String userId) {
+        DDR r = DDR.doPost("https://oapi.dingtalk.com/topapi/v2/department/listparentbyuser", null, DDConf.initTokenParams(access_token), UtilMap.map("userid", userId));
+        return (Map) r.getResult();
+    }
+
+    /**
+     * 创建用户
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/user-information-creation
+     */
+    @Override
+    public Map createUser(String access_token, String name, String mobile, List<String> dept_id_list, Map body_ext) {
+        Map param = UtilMap.map("access_token", access_token);
+        String deptIds = String.join(",", dept_id_list.stream().map(dept -> String.valueOf(dept)).collect(Collectors.toList()));
+        Map body = UtilMap.map("name, mobile, dept_id_list", name, mobile, deptIds);
+        if (ObjectUtil.isNotNull(body_ext)) {
+            body.putAll(body_ext);
+        }
+        return (Map) DDR.doPost("https://oapi.dingtalk.com/topapi/v2/user/create", null, param, body).getResult();
+    }
+
+    /**
+     * 创建钉钉自建企业账号
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/create-dingtalk-user-created-dedicated-account
+     */
+    @Override
+    public Map createUser_dingTalk(String access_token, String login_id, String init_password, String name, List<Long> dept_id_list, Map body_ext) {
+        Map param = UtilMap.map("access_token", access_token);
+        String deptIds = String.join(",", dept_id_list.stream().map(dept -> String.valueOf(dept)).collect(Collectors.toList()));
+        Map body = UtilMap.map("exclusive_account, exclusive_account_type, login_id, init_password, name, dept_id_list", true, "dingtalk", login_id, init_password, name, deptIds);
+        if (ObjectUtil.isNotNull(body_ext)) {
+            body.putAll(body_ext);
+        }
+        return (Map) DDR.doPost("https://oapi.dingtalk.com/topapi/v2/user/create", null, param, body).getResult();
+    }
+
+    /**
+     * 创建部门
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/create-a-department-v2
+     */
+    @Override
+    public Map createDepartment(String access_token, String name, long parent_id, Map body_ext) {
+
+        // 长度限制为1~64个字符,不允许包含字符"-"","以及","
+        name = name.replaceAll("-", "").replaceAll(",", "");
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = UtilMap.map("name, parent_id", name, parent_id);
+        if (ObjectUtil.isNotNull(body_ext)) {
+            body.putAll(body_ext);
+        }
+        return (Map) DDR.doPost("https://oapi.dingtalk.com/topapi/v2/department/create", null, param, body).getResult();
+    }
+
+    /**
+     * 获取部门详情
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/query-department-details0-v2
+     */
+    @Override
+    public Map getDepartmentInfo(String access_token, long dept_id) {
+        return (Map) DDR.doPost("https://oapi.dingtalk.com/topapi/v2/department/get", null, DDConf.initTokenParams(access_token), UtilMap.map("dept_id", dept_id)).getResult();
+    }
+
+    /**
+     * 获取员工人数
+     */
+    @Override
+    public int getUserCount(String access_token, boolean only_active) {
+        Map result = (Map) DDR.doPost("https://oapi.dingtalk.com/topapi/user/count", null, DDConf.initTokenParams(access_token), UtilMap.map("only_active", only_active)).getResult();
+        return UtilMap.getInt(result, "count");
+    }
+}

+ 58 - 0
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Dedicated.java

@@ -0,0 +1,58 @@
+package com.malk.service.dingtalk.impl;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.service.dingtalk.DDClient_Dedicated;
+import com.malk.utils.UtilMap;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import javax.validation.constraints.NotNull;
+import java.util.List;
+import java.util.Map;
+
+@Service
+@Slf4j
+public class DDImplClient_Dedicated implements DDClient_Dedicated {
+
+    /**
+     * 获取文件操作记录
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/obtain-file-operation-records
+     */
+    @Override
+    public List<Map> obtainFileOperationRecords(String accessToken, @NotNull long startDate, @NotNull long endDate, @NotNull int pageSize, Map preLastRecord) {
+
+        Map params = UtilMap.map("startDate, endDate, pageSize", startDate, endDate, pageSize);
+        if (ObjectUtil.isNotNull(preLastRecord)) {
+            params.put("nextGmtCreate", preLastRecord.get("gmtCreate"));
+            params.put("nextBizId", preLastRecord.get("bizId"));
+        }
+        DDR_New ddr_new = DDR_New.doGet("https://api.dingtalk.com/v1.0/exclusive/fileAuditLogs", DDConf.initTokenHeader(accessToken), params);
+        return ddr_new.getList();
+    }
+
+    /**
+     * 启用企业帐号
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/enable-a-dedicated-account
+     */
+    @Override
+    public boolean orgAccountsEnable(String accessToken, String userId) {
+        DDR_New ddr_new = DDR_New.doPost("https://api.dingtalk.com/v1.0/contact/orgAccounts/enable", DDConf.initTokenHeader(accessToken), null, UtilMap.map("userId", userId));
+        return (Boolean) ddr_new.getResult();
+    }
+
+    /**
+     * 停用企业帐号
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/disable-an-exclusive-account
+     */
+    @Override
+    public boolean orgAccountsDisable(String accessToken, String userId) {
+        DDR_New ddr_new = DDR_New.doPost("https://api.dingtalk.com/v1.0/contact/orgAccounts/disable", DDConf.initTokenHeader(accessToken), null, UtilMap.map("userId", userId));
+        return (Boolean) ddr_new.getResult();
+    }
+
+}

+ 138 - 0
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Event.java

@@ -0,0 +1,138 @@
+package com.malk.service.dingtalk.impl;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.alibaba.fastjson.JSONObject;
+import com.malk.delegate.DDEvent;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.server.dingtalk.DDR;
+import com.malk.service.dingtalk.DDClient_Event;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 事件订阅
+ *
+ * @apiNote https://open.dingtalk.com/document/orgapp/obtain-the-event-list-of-failed-push-messages
+ */
+@Slf4j
+@Service
+public class DDImplClient_Event implements DDClient_Event {
+
+    // 子项目实现接口 [静态代理]
+    @Autowired
+    private DDEvent event_delegate;
+
+    /**
+     * 钉钉审批回调事件
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/approval-events
+     */
+    @Override
+    public void callBackEvent_Workflow(JSONObject eventJson) {
+
+        String eventType = eventJson.getString("EventType");
+        String type = eventJson.getString("type");
+        String processInstanceId = eventJson.getString("processInstanceId");
+        String processCode = eventJson.getString("processCode");
+        boolean isAgree = "agree".equals(eventJson.getString("result"));
+
+        // 根据回调数据类型做不同的业务处理
+        if (DDConf.BPMS_TASK_CHANGE.equals(eventType)) {
+            if ("start".equals(type)) {
+                log.info("[开始]审批任务回调, {}", eventJson);
+                event_delegate.executeEvent_Task_Start(processInstanceId, processCode);
+                return;
+            }
+            // 任务回调: 审批任务开始、结束、转交 -> finish:审批正常结束(同意或拒绝), terminate:审批终止(发起人撤销审批单)
+            if ("finish".equals(type)) {
+                if (eventJson.getString("result").equals("redirect")) {
+                    log.info("[转交]审批任务回调, {}", eventJson);
+                    event_delegate.executeEvent_Task_Redirect(processInstanceId, processCode);
+                    return;
+                }
+                log.info("[结束]审批任务回调, {}", eventJson);
+                // 任务可获取到拒绝原因, 审批实例不能
+                String remark = eventJson.getString("remark");
+                event_delegate.executeEvent_Task_Finish(processInstanceId, processCode, isAgree, remark);
+            }
+            return;
+        }
+        if (DDConf.BPMS_INSTANCE_CHANGE.equals(eventType)) {
+            if ("start".equals(type)) {
+                log.info("[开始]审批实例回调, {}", eventJson);
+                event_delegate.executeEvent_Instance_Start(processInstanceId, processCode);
+                return;
+            }
+            // 审批终止(发起人撤销审批单) - result 为空
+            boolean isTerminate = "terminate".equals(type);
+            if (isTerminate) {
+                log.info("[终止]审批实例回调, {}", eventJson);
+                event_delegate.executeEvent_Instance_Finish(processInstanceId, processCode, isAgree, isTerminate, eventJson.getString("staffId"));
+                return;
+            }
+            // 实例回调: 审批实例开始、结束 -> finish:审批正常结束(同意或拒绝), terminate:审批终止(发起人撤销审批单)
+            if ("finish".equals(type)) {
+                log.info("[结束]审批实例回调, {}", eventJson);
+                event_delegate.executeEvent_Instance_Finish(processInstanceId, processCode, isAgree, isTerminate, eventJson.getString("staffId"));
+            }
+            return;
+        }
+    }
+
+    /**
+     * 同步推送失败记录定时处理
+     */
+    @Override
+    public void syncFailedList(String access_token) {
+        List<JSONObject> failedList = getFailedList_all(access_token);
+        log.info("###### [DD]开始同步推送失败记录 ######, {}", failedList.size());
+        for (JSONObject record : failedList) {
+            String eventType = String.valueOf(record.get("call_back_tag"));
+            if (Arrays.asList(DDConf.BPMS_INSTANCE_CHANGE, DDConf.BPMS_TASK_CHANGE).contains(eventType)) {
+                JSONObject eventJson = record.getJSONObject(eventType).getJSONObject("bpmsCallBackData");
+                log.info("[DD]审批失败记录, {}", eventJson);
+                callBackEvent_Workflow(eventJson);
+                continue;
+            }
+            log.info("[DD]未处理失败记录, {}", record);
+        }
+        log.info("###### [DD]同步推送失败记录结束 ######");
+    }
+
+    ////////////////////////////////////////////////////////////////////////////
+
+    /**
+     * 获取推送失败列表
+     */
+    @Override
+    public List<Map> getFailedList(String access_token) {
+        return _queryFailedList(access_token, null);
+    }
+
+    /**
+     * 获取推送失败列表_全部
+     */
+    @Override
+    public List getFailedList_all(String access_token) {
+        return _queryFailedList(access_token, new ArrayList<>());
+    }
+
+    // 递归查询 | 单次查询
+    private List<Map> _queryFailedList(String access_token, List<Map> records) {
+        DDR ddr = DDR.doGet("https://oapi.dingtalk.com/call_back/get_call_back_failed_result", null, DDConf.initTokenParams(access_token));
+        if (ObjectUtil.isNull(records)) {
+            return ddr.getFailedList();
+        }
+        records.addAll(ddr.getFailedList());
+        if (ddr.isHasMore()) {
+            _queryFailedList(access_token, records);
+        }
+        return records;
+    }
+}

+ 69 - 0
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Extension.java

@@ -0,0 +1,69 @@
+package com.malk.service.dingtalk.impl;
+
+import com.alibaba.fastjson.JSON;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.server.dingtalk.DDR;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.service.dingtalk.DDClient_Extension;
+import com.malk.utils.UtilMap;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+
+@Service
+@Slf4j
+public class DDImplClient_Extension implements DDClient_Extension {
+
+    /**
+     * 发送钉钉互动卡片
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/send-interactive-dynamic-cards-1
+     */
+    @Override
+    public Map sendInteractiveCards(String accessToken, String cardTemplateId, String outTrackId, int conversationType, String robotCode, String chatBotId, String openConversationId, Map cardData, Map extInfo) {
+
+        Map data = UtilMap.map("cardTemplateId, outTrackId, conversationType, robotCode, chatBotId, openConversationId, cardData", cardTemplateId, outTrackId, conversationType, robotCode, chatBotId, openConversationId, cardData);
+        UtilMap.putAll(data, extInfo);
+        return (Map) DDR_New.doPost("https://api.dingtalk.com/v1.0/im/interactiveCards/send", DDConf.initTokenHeader(accessToken), null, data).getResult();
+    }
+
+    /**
+     * 注册互动卡片回调地址
+     *
+     * @implNote https://open.dingtalk.com/document/orgapp/register-card-callback-address
+     */
+    @Override
+    public Map registerInterActiveCard(String access_token, String callback_url, Map extInfo) {
+
+        Map data = UtilMap.map("callback_url", callback_url);
+        UtilMap.putAll(data, extInfo);
+        return (Map) DDR.doPost("https://oapi.dingtalk.com/topapi/im/chat/scencegroup/interactivecard/callback/register", null, DDConf.initTokenParams(access_token), data).getResult();
+    }
+
+    /**
+     * 机器人发送群聊消息 [其它参数公司, 参考 发送钉钉互动卡片]
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/the-robot-sends-a-group-message
+     * @apiNote https://open.dingtalk.com/document/orgapp/types-of-messages-sent-by-robots
+     */
+    @Override
+    public Map sendGroupMessages(String accessToken, Map msgParam, String msgKey, String openConversationId, String robotCode, String coolAppCode) {
+
+        Map data = UtilMap.map("msgParam, msgKey, openConversationId, robotCode, coolAppCode", JSON.toJSONString(msgParam), msgKey, openConversationId, robotCode, coolAppCode);
+        return (Map) DDR_New.doPost("https://api.dingtalk.com/v1.0/robot/groupMessages/send", DDConf.initTokenHeader(accessToken), null, data).getResult();
+    }
+
+    /**
+     * 自定义机器人发送群消息
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/custom-robots-send-group-messages
+     */
+    @Override
+    public Map sendMessages(String accessToken, Map msgType, Map extInfo) {
+
+        Map data = UtilMap.map("msgtype", msgType);
+        UtilMap.putAll(data, extInfo);
+        return (Map) DDR.doPost("https://oapi.dingtalk.com/robot/send", null, DDConf.initTokenParams(accessToken), extInfo).getResult();
+    }
+}

+ 40 - 0
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Group.java

@@ -0,0 +1,40 @@
+package com.malk.service.dingtalk.impl;
+
+import com.malk.server.dingtalk.DDR;
+import com.malk.service.dingtalk.DDClient_Group;
+import com.malk.utils.UtilMap;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.Map;
+
+@Service
+@Slf4j
+public class DDImplClient_Group implements DDClient_Group {
+
+    @Override
+    public Map createGroup(String access_token,Map body) {
+        Map param = UtilMap.map("access_token", access_token);
+        return (Map) DDR.doPost("https://oapi.dingtalk.com/topapi/im/chat/scenegroup/create", null, param, body).getResult();
+    }
+
+    @Override
+    public String createGroupByTemp(String access_token, String title, String template_id, String owner_user_id, String user_ids) {
+        Map body = UtilMap.map("title, template_id, owner_user_id, user_ids",title,template_id,owner_user_id,user_ids);
+        return String.valueOf(createGroup(access_token,body).get("open_conversation_id"));
+    }
+
+    @Override
+    public Boolean addGroupUser(String access_token, String open_conversation_id, String user_ids) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = UtilMap.map("open_conversation_id, user_ids",open_conversation_id,user_ids);
+        return DDR.doPost("https://oapi.dingtalk.com/topapi/im/chat/scenegroup/member/add", null, param, body).isSuccess();
+    }
+
+    @Override
+    public Boolean delGroupUser(String access_token, String open_conversation_id, String user_ids) {
+        Map param = UtilMap.map("access_token", access_token);
+        Map body = UtilMap.map("open_conversation_id, user_ids",open_conversation_id,user_ids);
+        return DDR.doPost("https://oapi.dingtalk.com/topapi/im/chat/scenegroup/member/delete", null, param, body).isSuccess();
+    }
+}

+ 37 - 0
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Notice.java

@@ -0,0 +1,37 @@
+package com.malk.service.dingtalk.impl;
+
+import com.malk.server.dingtalk.DDConf;
+import com.malk.server.dingtalk.DDR;
+import com.malk.service.dingtalk.DDClient_Notice;
+import com.malk.utils.UtilList;
+import com.malk.utils.UtilMap;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Map;
+
+@Service
+@Slf4j
+public class DDImplClient_Notice implements DDClient_Notice {
+
+    @Autowired
+    private DDConf ddConf;
+
+    /**
+     * 发送工作通知
+     */
+    @Override
+    public String sendNotification(String access_token, List<String> userid_list, List<String> dept_id_list, boolean to_all_user, Map msg) {
+        Map body = UtilMap.map("agent_id, to_all_user, msg", ddConf.getAgentId(), to_all_user, msg);
+        if (UtilList.isNotEmpty(userid_list)) {
+            body.put("userid_list", String.join(",", userid_list));
+        }
+        if (UtilList.isNotEmpty(dept_id_list)) {
+            body.put("dept_id_list", String.join(",", dept_id_list));
+        }
+        DDR ddr = DDR.doPost("https://oapi.dingtalk.com/topapi/message/corpconversation/asyncsend_v2", null, DDConf.initTokenParams(access_token), body);
+        return ddr.getTask_id();
+    }
+}

+ 175 - 0
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Personnel.java

@@ -0,0 +1,175 @@
+package com.malk.service.dingtalk.impl;
+
+import cn.hutool.core.collection.ListUtil;
+import cn.hutool.core.util.ObjectUtil;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.JSONArray;
+import com.alibaba.fastjson.JSONObject;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.server.dingtalk.DDR;
+import com.malk.service.dingtalk.DDClient_Personnel;
+import com.malk.utils.UtilList;
+import com.malk.utils.UtilMap;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+@Service
+@Slf4j
+public class DDImplClient_Personnel implements DDClient_Personnel {
+
+    private final int size = 50;
+
+    private final int offset = 0;
+
+    /**
+     * 获取花名册元数据
+     */
+    @Override
+    public List<Map> getPersonnelMeta(String access_token, Number agentid) {
+        return (List<Map>) DDR.doPost("https://oapi.dingtalk.com/topapi/smartwork/hrm/roster/meta/get", null, DDConf.initTokenParams(access_token), UtilMap.map("agentid", agentid)).getResult();
+    }
+
+    /**
+     * 获取员工花名册字段信息
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/intelligent-personnel-obtain-employee-roster-information
+     */
+    @Override
+    public List<Map> getEmployeeInfos(String access_token, List<String> userIds, Number agentId, List<String> field_filter_list) {
+
+        int start = 0;
+        boolean isNext = true;
+        List<Map> result = new ArrayList<>();
+        StringBuilder ids = new StringBuilder();
+        StringBuilder filters = new StringBuilder();
+        if (ObjectUtil.isNotNull(field_filter_list)){
+            filters.append(String.join(",",field_filter_list));
+        }
+        while (isNext){
+            ids.append(String.join(",", userIds.subList(start * size, Math.min((start + 1) * size, userIds.size()))));
+            Map bodys = UtilMap.map("userid_list, agentid", ids.toString(), agentId);
+            if (ObjectUtil.isNotEmpty(filters)){
+                bodys.put("field_filter_list",filters);
+            }
+            List<Map> dataList = (List<Map>) DDR.doPost("https://oapi.dingtalk.com/topapi/smartwork/hrm/employee/v2/list", null,  DDConf.initTokenParams(access_token), bodys).getResult();
+            if (ObjectUtil.isNotNull(dataList) && dataList.size()>0){
+                result.addAll(dataList);
+            }
+            if (userIds.size() <= ((start + 1)*size)){
+                isNext = false;
+            }else {
+                start++;
+            }
+            ids = new StringBuilder();
+
+        }
+        return result;
+    }
+
+    /**
+     * 获取待入职员工列表
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/intelligent-personnel-obtain-employee-roster-information
+     */
+    @Override
+    public List<String> getPendingEmployeeIds(String access_token) {
+        boolean isNext = true;
+        List<String> dataList = new ArrayList<>();
+        Map bodys = UtilMap.map("offset, size", offset, size );
+        while (isNext){
+            DDR ddr = DDR.doPost("https://oapi.dingtalk.com/topapi/smartwork/hrm/employee/querypreentry", null, DDConf.initTokenParams(access_token), bodys);
+            if (ObjectUtil.isNotNull(ddr.getResult())){
+                JSONObject jsonObject = JSONObject.parseObject(JSON.toJSONString(ddr.getResult()));
+                JSONArray jsonArray = JSONArray.parseArray(JSON.toJSONString(jsonObject.get("data_list")));
+                if (ObjectUtil.isNull(jsonObject.get("next_cursor"))){
+                    isNext = false;
+                }else {
+                    int next = Integer.parseInt(jsonObject.get("next_cursor").toString());
+                    bodys.put("offset",next);
+                }
+                jsonArray.forEach(e->{
+                    dataList.add(e.toString());
+                });
+            }
+        }
+        return dataList;
+    }
+
+    /**
+     * 获取在职员工列表
+     *
+     * @apiNote https://oapi.dingtalk.com/topapi/smartwork/hrm/employee/queryonjob
+     */
+    @Override
+    public List<String> getWorkingEmployeeIds(String access_token, String statusList) {
+        boolean isNext = true;
+        List<String> dataList = new ArrayList<>();
+        Map bodys = UtilMap.map("status_list, offset, size",statusList, offset, size );
+        while (isNext){
+            DDR ddr = DDR.doPost("https://oapi.dingtalk.com/topapi/smartwork/hrm/employee/queryonjob", null, DDConf.initTokenParams(access_token), bodys);
+            JSONObject jsonObject = JSONObject.parseObject(JSON.toJSONString(ddr.getResult()));
+            JSONArray jsonArray = JSONArray.parseArray(JSON.toJSONString(jsonObject.get("data_list")));
+            if (ObjectUtil.isNull(jsonObject.get("next_cursor"))){
+                isNext = false;
+            }else {
+                int next = Integer.parseInt(jsonObject.get("next_cursor").toString());
+                bodys.put("offset",next);
+            }
+            jsonArray.forEach(e->{
+                dataList.add(e.toString());
+            });
+        }
+        return dataList;
+    }
+
+    /**
+     * 获取离职员工id列表
+     *
+     * @apiNote https://api.dingtalk.com/v1.0/hrm/employees/dismissions?nextToken=?
+     */
+    @Override
+    public List<String> getLeaveEmployeeIdList(String access_token) {
+        log.info("正在调用钉钉后台智能人事离职人员ID接口");
+        Long nextToken = 0L;
+        boolean hasMore = true;
+        List<String> maps = new ArrayList<>();
+        Map header = DDConf.initTokenHeader(access_token);
+        while (hasMore){
+            DDR ddr = DDR.doGet("https://api.dingtalk.com/v1.0/hrm/employees/dismissions?nextToken=" + nextToken + "&maxResults=" + size, header, null);
+            List<String> userIdList = ddr.getUserIdList();
+            maps.addAll(userIdList);
+            hasMore = ddr.isHasMore();
+            if (hasMore){
+                nextToken = ddr.getNextToken();
+            }
+        }
+        return maps;
+    }
+
+    /**
+     * 根据员工id获取离职员工信息
+     *
+     * @apiNote https://oapi.dingtalk.com/topapi/smartwork/hrm/employee/queryonjob
+     */
+    @Override
+    public List<Map> getLeaveEmployeeInfos(String access_token, List<String> userIdList) {
+        log.info("正在调用钉钉后台智能人事离职人员信息接口");
+        List<String> idList = new ArrayList<>();
+        List<Map> lastResult = new ArrayList<>();
+        Map header = DDConf.initTokenHeader(access_token);
+        for (int i = 0; i < userIdList.size(); i+=size) {
+            idList.addAll(userIdList.subList(i,Math.min(i+size,userIdList.size())));
+                List<Map> result = (List<Map>) DDR.doGet("https://api.dingtalk.com/v1.0/hrm/employees/dimissionInfos?userIdList=" + JSON.toJSONString(idList), header, null).getResult();
+                if (ObjectUtil.isNotNull(result) && result.size()>0){
+                    lastResult.addAll(result);
+                }
+            idList.clear();
+        }
+        return lastResult;
+    }
+
+}

+ 35 - 0
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Report.java

@@ -0,0 +1,35 @@
+package com.malk.service.dingtalk.impl;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.server.dingtalk.DDR;
+import com.malk.service.dingtalk.DDClient_Report;
+import com.malk.utils.UtilMap;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Map;
+
+@Service
+@Slf4j
+public class DDImplClient_Report implements DDClient_Report {
+
+    /**
+     * 获取用户发出的日志列表
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/query-logs-sent-by-an-employee
+     */
+    @Override
+    public List<Map> reportList(String access_token, long start_time, long end_time, Map extInfo) {
+        Map body = UtilMap.map("start_time, end_time, size", start_time, end_time, 20);
+        if (ObjectUtil.isNotNull(extInfo)) {
+            body.putAll(extInfo);
+        }
+        if (!body.containsKey("cursor")) {
+            body.put("cursor", 0);
+        }
+        Map result = (Map) DDR.doPost("https://oapi.dingtalk.com/topapi/report/list", null, DDConf.initTokenParams(access_token), body).getResult();
+        return (List<Map>) result.get("data_list");
+    }
+}

+ 44 - 0
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Schedule.java

@@ -0,0 +1,44 @@
+package com.malk.service.dingtalk.impl;
+
+import com.malk.server.dingtalk.DDConf;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.service.dingtalk.DDClient_Contacts;
+import com.malk.service.dingtalk.DDClient_Schedule;
+import com.malk.utils.UtilList;
+import com.malk.utils.UtilMap;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.util.List;
+import java.util.Map;
+import java.util.stream.Collectors;
+
+@Service
+@Slf4j
+public class DDImplClient_Schedule implements DDClient_Schedule {
+
+    @Autowired
+    private DDClient_Contacts ddClient_contacts;
+
+    /**
+     * 创建日程 [注意区分全天和非全天数据格式]
+     *
+     * @apiNote https://open.dingtalk.com/document/personalapp/create-schedule
+     */
+    @Override
+    public DDR_New eventsSchedule(String access_token, String userId, Map body) {
+
+        String unionId = String.valueOf(ddClient_contacts.getUserInfoById(access_token, userId).get("unionid"));
+        // attendees 日程参数人上限为500, 需要传递unionId. ppExt: start 与 end 不能使用同一个map对象, 会报错date不能为空.
+        List<String> attendees = UtilMap.getList(body, "userIds");
+        if (UtilList.isNotEmpty(attendees)) {
+            body.put("attendees", attendees.stream().map(dId -> {
+                String uId = String.valueOf(ddClient_contacts.getUserInfoById(access_token, dId).get("unionid"));
+                return UtilMap.map("id", uId);
+            }).collect(Collectors.toList()));
+        }
+        // path参数: calendarId 统一为primary,表示用户的主日历
+        return DDR_New.doPost("https://api.dingtalk.com/v1.0/calendar/users/" + unionId + "/calendars/primary/events", DDConf.initTokenHeader(access_token), null, body);
+    }
+}

+ 229 - 0
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Storage.java

@@ -0,0 +1,229 @@
+package com.malk.service.dingtalk.impl;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.service.dingtalk.DDClient_Storage;
+import com.malk.utils.UtilMap;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.stereotype.Service;
+
+import java.io.FileInputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * 储存管理
+ *
+ * @apiNote https://open.dingtalk.com/document/orgapp/dingtalk-storage-overview
+ */
+@Service
+@Slf4j
+public class DDImplClient_Storage implements DDClient_Storage {
+
+    ///////////////////////// 钉盘附件 /////////////////////////
+
+    // ppExt 最大50. 参数不能为1, nextToken 传递后第二次调用就返回为空
+    private static final int maxResults = 50;
+
+    /**
+     * 新建空间
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/new-space
+     */
+    @Override
+    public DDR_New createSpaces(String accessToken, String name, String unionId) {
+        return null;
+    }
+
+    /**
+     * 获取空间列表
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/queries-a-space-list
+     */
+    @Override
+    public List<Map> getSpaces(String accessToken, String unionId) {
+
+        return _getSpaces(accessToken, unionId, maxResults, null, new ArrayList<>());
+    }
+
+    /// 递归空间列表
+    private List<Map> _getSpaces(String accessToken, String unionId, int maxResults, String nextToken, List<Map> dataList) {
+
+        // spaceType: 空间类型。org:企业空间
+        Map param = UtilMap.map("unionId, spaceType, maxResults, nextToken", unionId, "org", maxResults, nextToken);
+        DDR_New ddr_new = DDR_New.doGet("https://api.dingtalk.com/v1.0/drive/spaces", DDConf.initTokenHeader(accessToken), param);
+        dataList.addAll(ddr_new.getSpaces());
+        // 返回空字符串, 过滤
+        if (StringUtils.isNotBlank(ddr_new.getNextToken())) {
+            _getSpaces(accessToken, unionId, maxResults, ddr_new.getNextToken(), dataList);
+        }
+        return dataList;
+    }
+
+    /**
+     * 获取文件或文件夹列表
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/obtain-the-file-list-storage
+     */
+
+    @Override
+    public List<Map> getDentries(String accessToken, String unionId, String spaceId, String parentId, Map extInfo) {
+
+        return _getDentries(accessToken, unionId, spaceId, parentId, maxResults, null, extInfo, new ArrayList<>());
+    }
+
+    /// 递归文件/文件夹列表
+    private List<Map> _getDentries(String accessToken, String unionId, String spaceId, String parentId, int maxResults, String nextToken, Map extInfo, List<Map> dataList) {
+
+        Map param = UtilMap.map("unionId, parentId, maxResults, nextToken", unionId, parentId, maxResults, nextToken);
+        UtilMap.putAll(param, extInfo);
+        DDR_New ddr_new = DDR_New.doGet("https://api.dingtalk.com//v1.0/storage/spaces/" + spaceId + "/dentries", DDConf.initTokenHeader(accessToken), param);
+        dataList.addAll(ddr_new.getDentries());
+        // 返回空字符串, 过滤
+        if (StringUtils.isNotBlank(ddr_new.getNextToken())) {
+            _getDentries(accessToken, unionId, spaceId, parentId, maxResults, ddr_new.getNextToken(), extInfo, dataList);
+        }
+        return dataList;
+    }
+
+    /**
+     * 添加文件夹
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/add-folder
+     */
+    @Override
+    public Map addFolders(String accessToken, String unionId, String spaceId, String parentId, String name, Map option) {
+
+        // ppExt: / \ * < > | "  [参考: 常用Unicode字符对照表]
+        name = name.replaceAll("\\u002F", "");
+        name = name.replaceAll("\\u005C", "");
+        name = name.replaceAll("\\u002A", "");
+        name = name.replaceAll("\\u003C", "");
+        name = name.replaceAll("\\u003E", "");
+        name = name.replaceAll("\\u007C", "");
+        name = name.replaceAll("\\u0022", "");
+        if (name.endsWith(".")) {
+            name = name.substring(0, name.length() - 1);
+        }
+        Map param = UtilMap.map("unionId", unionId);
+        Map body = UtilMap.map("name", name);
+        if (ObjectUtil.isNotNull(option)) {
+            body.put("option", option);
+        }
+        DDR_New ddr_new = DDR_New.doPost("https://api.dingtalk.com//v1.0/storage/spaces/" + spaceId + "/dentries/" + parentId + "/folders", DDConf.initTokenHeader(accessToken), param, body);
+        return ddr_new.getDentry();
+    }
+
+    ///////////////////////// OA审批附件 /////////////////////////
+
+    /**
+     * 获取审批钉盘空间信息
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/obtains-the-information-about-approval-nail-disk
+     */
+    @Override
+    public String getSpacesInfos(String access_token, String userId, String agentId) {
+        Map header = DDConf.initTokenHeader(access_token);
+        Map body = UtilMap.map("userId, agentId", userId, agentId);
+        DDR_New ddr = DDR_New.doPost("https://api.dingtalk.com/v1.0/workflow/processInstances/spaces/infos/query", header, null, body);
+        return String.valueOf(((Map) ddr.getResult()).get("spaceId"));
+    }
+
+    /**
+     * 添加权限
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/add-storage-permissions
+     */
+    @Override
+    public boolean addSpacePermissions(String access_token, String spaceId, String dentryId, String unionId, String roleId, List<Map> members, Map option) {
+        String url = "https://api.dingtalk.com/v1.0/storage/spaces/" + spaceId + "/dentries/" + dentryId + "/permissions";
+        Map header = DDConf.initTokenHeader(access_token);
+        Map param = UtilMap.map("unionId", unionId);
+        Map body = UtilMap.map("roleId, members, option", roleId, members, option);
+        if (ObjectUtil.isNotNull(option)) {
+            body.putAll(option);
+        }
+        return DDR_New.doPost(url, header, param, body).isSuccess();
+    }
+
+    ///////////////////////// 通用上传逻辑 /////////////////////////
+
+    /**
+     * 获取文件上传信息
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/obtain-storage-upload-information
+     */
+    @Override
+    public DDR_New getUploadInfos(String access_token, String spaceId, String unionId, Map option) {
+        String path = "https://api.dingtalk.com/v1.0/storage/spaces/" + spaceId + "/files/uploadInfos/query";
+        Map header = DDConf.initTokenHeader(access_token);
+        Map param = UtilMap.map("unionId", unionId);
+        Map body = UtilMap.map("protocol, multipart", "HEADER_SIGNATURE", true);
+        if (ObjectUtil.isNotNull(option)) {
+            body.putAll(option);
+        }
+        return DDR_New.doPost(path, header, param, body);
+    }
+
+    /**
+     * 文件上传 [官方]
+     *
+     * @implNote 从[获取文件上传信息]接口返回信息中拿到resourceUrls和headers
+     */
+    @Override
+    @SneakyThrows
+    public boolean uploadFiles(String resourceUrl, Map<String, String> headerSignatureInfo, String pathFile) {
+        Map<String, String> headers = headerSignatureInfo;
+        URL url = new URL(resourceUrl);
+        HttpURLConnection connection = (HttpURLConnection) url.openConnection();
+        if (headers != null) {
+            for (Map.Entry<String, String> entry : headers.entrySet()) {
+                connection.setRequestProperty(entry.getKey(), entry.getValue());
+            }
+        }
+        connection.setDoOutput(true);
+        connection.setRequestMethod("PUT");
+        connection.setUseCaches(false);
+        connection.setReadTimeout(10000);
+        connection.setConnectTimeout(10000);
+        connection.connect();
+        OutputStream out = connection.getOutputStream();
+        InputStream is = new FileInputStream(pathFile);
+        byte[] b = new byte[1024];
+        int temp;
+        while ((temp = is.read(b)) != -1) {
+            out.write(b, 0, temp);
+        }
+        out.flush();
+        out.close();
+        int responseCode = connection.getResponseCode();
+        connection.disconnect();
+        return responseCode == 200;
+    }
+
+    /**
+     * 提交文件
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/submit-documents
+     */
+    @Override
+    public Map commitFiles(String access_token, String spaceId, String unionId, String uploadKey, String name, String parentId, Map option) {
+        String url = "https://api.dingtalk.com/v1.0/storage/spaces/" + spaceId + "/files/commit";
+        Map header = DDConf.initTokenHeader(access_token);
+        Map param = UtilMap.map("unionId", unionId);
+        Map body = UtilMap.map("uploadKey, name, parentId", uploadKey, name, parentId);
+        if (ObjectUtil.isNotNull(option)) {
+            body.putAll(option);
+        }
+        DDR_New ddr = DDR_New.doPost(url, header, param, body);
+        return ddr.getDentry();
+    }
+}

+ 224 - 0
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplClient_Workflow.java

@@ -0,0 +1,224 @@
+package com.malk.service.dingtalk.impl;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.server.dingtalk.DDR;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.service.dingtalk.DDClient_Contacts;
+import com.malk.service.dingtalk.DDClient_Workflow;
+import com.malk.utils.UtilHttp;
+import com.malk.utils.UtilMap;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.stereotype.Service;
+
+import java.io.File;
+import java.util.*;
+
+/**
+ * OA审批
+ *
+ * @apiNote https://open.dingtalk.com/document/orgapp/workflow-overview
+ */
+@Service
+@Slf4j
+public class DDImplClient_Workflow implements DDClient_Workflow {
+
+    /**
+     * 发起审批实例
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp-service/initiate-approval
+     */
+    @Override
+    public String doProcessInstances(String access_token, String originatorUserId, String processCode, List formComponentValues, Map body_ext) {
+        Map header = DDConf.initTokenHeader(access_token);
+        Map body = UtilMap.map("originator_user_id, process_code, form_component_values", originatorUserId, processCode, formComponentValues);
+        body.putAll(body_ext);
+        DDR ddr = DDR.doPost("https://oapi.dingtalk.com/topapi/processinstance/create", header, DDConf.initTokenParams(access_token), body);
+        log.info("ddr, {}", ddr);
+        return ddr.getProcessInstanceId();
+    }
+
+    @Override
+    public Map getProcessinstanceIds_PN(String access_token, String processCode) {
+        Map header = DDConf.initTokenHeader(access_token);
+//        Calendar calendar = Calendar.getInstance();
+//        calendar.add(Calendar.DAY_OF_YEAR, -110); // 设置为前120天的时间
+//        long startTime = calendar.getTimeInMillis(); // 获取时间戳
+//
+//        System.out.println("120天前的时间戳:" + startTime);
+////        long endTime = System.currentTimeMillis();
+////        System.out.println("当前时间戳: " + endTime);
+//        String nextToken="0";//分页游标。如果是首次调用,该参数传0。
+//        String maxResults="20";//分页参数,每页大小,最多传20。
+//        String statuses="COMPLETED";//审批完成
+//        Map param_post = new HashMap();
+//        param_post.put("processCode",processCode);
+//        param_post.put("startTime",startTime);
+//        param_post.put("nextToken",nextToken);
+//        param_post.put("maxResults",maxResults);
+//       // param_post.put("statuses",statuses);
+//         DDR r= DDR_New.doPost("https://api.dingtalk.com/v1.0/workflow/processes/instanceIds", null, DDConf.initTokenParams(access_token), param_post ).getResult();
+        return header;
+    }
+
+    /**
+     * 获取单个审批实例详情
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/obtains-the-details-of-a-single-approval-instance
+     */
+    @Override
+    public Map getProcessInstanceId(String access_token, String processInstanceId) {
+        Map header = DDConf.initTokenHeader(access_token);
+        Map body = UtilMap.map("processInstanceId", processInstanceId);
+        DDR_New ddr = DDR_New.doGet("https://api.dingtalk.com/v1.0/workflow/processInstances", header, body);
+        return (Map) ddr.getResult();
+    }
+
+    /**
+     * 撤销审批实例
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp-service/terminate-a-workflow-by-using-an-instance-id
+     */
+    @Override
+    public boolean terminateRunningApprove(String access_token, String processInstanceId, boolean isSystem, String remark, String operatingUserId) {
+        Map header = DDConf.initTokenParams(access_token);
+        Map request = UtilMap.map("process_instance_id, is_system, remark, operating_userid", processInstanceId, isSystem, remark, operatingUserId);
+        Map body = UtilMap.map("request", request);
+        DDR r = (DDR) UtilHttp.doPost("https://oapi.dingtalk.com/topapi/process/instance/terminate", null, header, body, DDR.class);
+        return (Boolean) r.getResult();
+    }
+
+    /**
+     * 同意或拒绝审批任务
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/approve-or-reject-the-approval-task
+     */
+    @Override
+    public boolean executeRunningApprove(String access_token, String processInstanceId, String actionerUserId, String taskId, String result, String remark, Map file) {
+
+        Map header = DDConf.initTokenHeader(access_token);
+        Map body = UtilMap.map("processInstanceId, result, actionerUserId, taskId, file", processInstanceId, result, actionerUserId, taskId, file);
+        UtilMap.putNotEmpty(body, "remark", remark);
+        DDR_New r = DDR_New.doPost("https://api.dingtalk.com/v1.0/workflow/processInstances/execute", header, null, body);
+        return (Boolean) r.getResult();
+    }
+
+    /**
+     * 通知审批撤销
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/notify-the-attendance-to-modify-the-punch-result-when-the
+     **/
+    @Override
+    public boolean cancelRunningApprove(String access_token, String userid, String approve_id) {
+        Map header = DDConf.initTokenParams(access_token);
+        Map body = UtilMap.map("approve_id, userid", approve_id, userid);
+        DDR r = (DDR) UtilHttp.doPost("https://oapi.dingtalk.com/topapi/attendance/approve/cancel", null, header, body, DDR.class);
+        return (Boolean) r.getResult();
+    }
+
+
+    /**
+     * 添加审批评论
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp-service/add-an-approval-comment
+     */
+    @Override
+    public boolean commentApproveInstance(String access_token, String processInstanceId, File file, String
+            text, String commentUserid) {
+        Map header = DDConf.initTokenParams(access_token);
+        Map body = UtilMap.map("process_instance_id, file, text, comment_userid", processInstanceId, file, text, commentUserid);
+        Map req = UtilMap.map("request", body);
+        DDR r = (DDR) UtilHttp.doPost("https://oapi.dingtalk.com/topapi/process/instance/comment/add", header, req, DDR.class);
+        return (Boolean) r.getResult();
+    }
+
+    /**
+     * 获取审批实例ID列表
+     *
+     * @apiNote https://open.dingtalk.com/document/orgapp/obtain-an-approval-list-of-instance-ids
+     */
+    @Override
+    public Map getInstanceIds(String access_token, String processCode, long startTime, long endTime, Map body_ext) {
+        Map header = DDConf.initTokenHeader(access_token);
+        Map body = UtilMap.map("processCode, startTime, endTime", processCode, startTime, endTime);
+        if (ObjectUtil.isNotNull(body_ext)) {
+            body.putAll(body_ext);
+        }
+        if (!body.containsKey("maxResults")) {
+            body.put("maxResults", 20);
+        }
+        if (!body.containsKey("nextToken")) {
+            body.put("nextToken", 0);
+        }
+        if (!body.containsKey("statuses")) {
+            String[] param_post = new String[]{"COMPLETED"};//审批完成
+
+            body.put("statuses", param_post);
+        }
+        DDR_New ddr_new = DDR_New.doPost("https://api.dingtalk.com/v1.0/workflow/processes/instanceIds/query", header, null, body);
+        return (Map) ddr_new.getResult();
+    }
+
+    /**
+     * 获取审批实例ID列表_全部
+     */
+    @Override
+    public List<String> getInstanceIds_all(String access_token, String processCode, long startTime,
+                                           long endTime, Map extInfo) {
+        return _getInstanceIds_all(access_token, processCode, startTime, endTime, extInfo, new ArrayList());
+    }
+
+    // 递归查询
+    private List<String> _getInstanceIds_all(String access_token, String processCode, long startTime,
+                                             long endTime, Map extInfo, List dataList) {
+        Map rsp = getInstanceIds(access_token, processCode, startTime, endTime, extInfo);
+        dataList.addAll(((List) rsp.get("list")));
+        if (rsp.containsKey("nextToken")) {
+            extInfo.put("nextToken", rsp.get("nextToken"));
+            _getInstanceIds_all(access_token, processCode, startTime, endTime, extInfo, dataList);
+        }
+        return dataList;
+    }
+
+    @Autowired
+    private DDClient_Contacts ddClient_contacts;
+
+    /**
+     * 创建钉钉待办任务新版SDK
+     *
+     * @apiNote https://open.dingtalk.com/document/isvapp/add-dingtalk-to-do-task
+     */
+    @Override
+    public DDR_New createTBTask(String access_token, String createUserId, String subject, String description,
+                                long dueTime, List<String> executorIds, List<String> participantIds, Map detailUrl,
+                                boolean isOnlyShowExecutor, int priority, Map notifyConfigs) {
+        String unionId = String.valueOf(ddClient_contacts.getUserInfoById(access_token, createUserId).get("unionid"));
+        Map param = new HashMap();
+        param.put("operatorId", unionId);
+        Map body = new HashMap();
+        body.put("subject", subject);
+        if (StringUtils.isNotBlank(description)) body.put("description", description);
+        if (dueTime > 0) body.put("dueTime", dueTime);
+        if (ObjectUtil.isNotNull(executorIds)) {
+            List<String> executorUnionIds = new ArrayList<>();
+            for (String userId : executorIds) {
+                executorUnionIds.add(String.valueOf(ddClient_contacts.getUserInfoById(access_token, userId).get("unionid")));
+            }
+            body.put("executorIds", executorUnionIds);
+        }
+        if (ObjectUtil.isNotNull(participantIds)) {
+            List<String> participantUnionIds = new ArrayList<>();
+            for (String userId : participantIds) {
+                participantUnionIds.add(String.valueOf(ddClient_contacts.getUserInfoById(access_token, userId).get("unionid")));
+            }
+            body.put("participantIds", participantUnionIds);
+        }
+        if (ObjectUtil.isNotNull(detailUrl)) body.put("detailUrl", detailUrl);
+        body.put("isOnlyShowExecutor", isOnlyShowExecutor);
+        body.put("priority", priority);
+        if (ObjectUtil.isNotNull(detailUrl)) body.put("notifyConfigs", notifyConfigs);
+        return DDR_New.doPost("https://api.dingtalk.com/v1.0/todo/users/" + unionId + "/tasks", DDConf.initTokenHeader(access_token), param, body);
+    }
+}

+ 232 - 0
mjava/src/main/java/com/malk/service/dingtalk/impl/DDImplService.java

@@ -0,0 +1,232 @@
+package com.malk.service.dingtalk.impl;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.malk.server.common.FilePath;
+import com.malk.server.dingtalk.DDConf;
+import com.malk.server.dingtalk.DDConfigSign;
+import com.malk.server.dingtalk.DDR_New;
+import com.malk.service.dingtalk.*;
+import com.malk.utils.UtilFile;
+import com.malk.utils.UtilHttp;
+import com.malk.utils.UtilList;
+import com.malk.utils.UtilMap;
+import lombok.SneakyThrows;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.scheduling.annotation.Async;
+import org.springframework.stereotype.Service;
+
+import java.io.File;
+import java.util.*;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.atomic.AtomicReference;
+import java.util.stream.Collectors;
+
+@Service
+@Slf4j
+public class DDImplService implements DDService {
+
+    @Autowired
+    private DDClient_Workflow ddClient_workflow;
+
+    @Autowired
+    private DDClient_Attendance ddClient_attendance;
+
+    @Autowired
+    private DDClient_Contacts ddClient_contacts;
+
+    @Autowired
+    private DDClient_Storage ddClient_storage;
+
+    @Autowired
+    private FilePath filePath;
+
+    @Autowired
+    private DDConf ddConf;
+
+    @Autowired
+    private DDClient ddClient;
+
+    /**
+     * 新发起审批15s内不允许撤销, 异步执行 [审批同意/拒绝只能通过节点操作, 系统无法直接介入]   -- 异步需要中转一层进行触发, client为原子接口
+     */
+    @Async
+    @Override
+    @SneakyThrows
+    public void terminateRunningApprove_async(String access_token, String processInstanceId, boolean isSystem, String subject, String description, String operatingUserId, String noteUserId) {
+        ddClient_workflow.createTBTask(access_token, noteUserId, subject, description, new Date().getTime() + 16000, Arrays.asList(noteUserId), null, null, false, 20, null);
+        Thread.sleep(16000);
+        ddClient_workflow.terminateRunningApprove(access_token, processInstanceId, isSystem, description, operatingUserId);
+    }
+
+    /**
+     * 钉钉查询假期余额返回是记录, 按照消失/天单位计算该假期类型下的余额
+     */
+    @Override
+    public Map queryVacationQuota_balance(String access_token, String op_userid, String leave_code, String userids, int offset, int size) {
+        List<Map> records = ddClient_attendance.queryVacationQuota_all(access_token, op_userid, leave_code, userids, offset, size);
+        if (ObjectUtil.isNull(records)) {
+            return null;
+        }
+        AtomicInteger balance = new AtomicInteger();
+        AtomicReference<String> unit = new AtomicReference<>("");
+        records.forEach(item -> {
+            if (ObjectUtil.isNotNull(item.get("quota_num_per_hour"))) {
+                balance.set(balance.get() + Integer.valueOf(String.valueOf(item.get("quota_num_per_hour"))) - Integer.valueOf(String.valueOf(item.get("used_num_per_hour"))));
+                unit.set("小时");
+            } else {
+                balance.set(balance.get() + (Integer.valueOf(String.valueOf(item.get("quota_num_per_day"))) - Integer.valueOf(String.valueOf(item.get("used_num_per_day")))));
+                unit.set("天");
+            }
+        });
+        Map result = new HashMap();
+        result.put("balance", balance.get() / 100f);
+        result.put("unit", unit);
+        return result;
+    }
+
+    /**
+     * 上传审批附件
+     *
+     * @param urlFile      远程文件地址
+     * @param fileNamePure 文件名称[后缀自动通过地址获取]
+     */
+    @Override
+    public Map uploadFileFormUrlForOverflow(String accessToken, String userId, String urlFile, String fileNamePure) {
+        // 下载到本地
+        String fileName = fileNamePure + urlFile.substring(urlFile.lastIndexOf(".")).toLowerCase();
+        File file = UtilFile.mkdirIfNot(fileName, filePath.getPath().getFile());
+        UtilHttp.doDownload(urlFile, file);
+        return uploadFileFormUrlForOverflow(accessToken, userId, file.getAbsolutePath());
+    }
+
+    /**
+     * 上传审批附件
+     *
+     * @param filePath 已存在文件, 本地file绝对路径
+     */
+    @Override
+    public Map uploadFileFormUrlForOverflow(String accessToken, String userId, String filePath) {
+        // 获取用户unionId
+        String unionId = String.valueOf(ddClient_contacts.getUserInfoById(accessToken, userId).get("unionid"));
+        // 获取储存空间
+        String spaceId = ddClient_storage.getSpacesInfos(accessToken, userId, null);
+        // 添加空间用户权限
+        List<Map> members = Arrays.asList(UtilMap.map("type, id, corpId", "USER", unionId, ddConf.getCorpId()));
+        ddClient_storage.addSpacePermissions(accessToken, spaceId, "0", unionId, "EDITOR", members, null);
+        // 获取空间上传信息
+        DDR_New ddr_new = ddClient_storage.getUploadInfos(accessToken, spaceId, unionId, null);
+        // 执行文件上传
+        String resourceUrl = ((List<String>) ddr_new.getHeaderSignatureInfo().get("resourceUrls")).get(0);
+        ddClient_storage.uploadFiles(resourceUrl, (Map<String, String>) ddr_new.getHeaderSignatureInfo().get("headers"), filePath);
+        // 提交文件
+        return ddClient_storage.commitFiles(accessToken, spaceId, unionId, ddr_new.getUploadKey(), String.valueOf(UtilList.getLast(filePath.split("/"))), "0", null);
+    }
+
+    /**
+     * 上传钉盘文件
+     *
+     * @param urlFile 远程文件地址
+     */
+    @Override
+    public Map uploadFileFormUrlForDingDrive(String accessToken, String unionId, String spaceId, String parentId, String urlFile, String fileNamePure) {
+        // 下载到本地 [兼容中文]
+        // String fileName = URLDecoder.decode(String.valueOf(UtilList.getLast(urlFile.split("/"))));
+        File file = UtilFile.mkdirIfNot(fileNamePure, filePath.getPath().getFile());
+        UtilHttp.doDownload(urlFile, file);
+        // 获取空间上传信息
+        DDR_New ddr_new = ddClient_storage.getUploadInfos(accessToken, spaceId, unionId, null);
+        // 执行文件上传
+        String resourceUrl = ((List<String>) ddr_new.getHeaderSignatureInfo().get("resourceUrls")).get(0);
+        ddClient_storage.uploadFiles(resourceUrl, (Map<String, String>) ddr_new.getHeaderSignatureInfo().get("headers"), file.getAbsolutePath());
+        // 提交文件
+        Map dentry = ddClient_storage.commitFiles(accessToken, spaceId, unionId, ddr_new.getUploadKey(), fileNamePure, parentId, null);
+        // 删除本地临时文件
+        UtilFile.deleteFile(file.getAbsolutePath());
+        return dentry;
+    }
+
+    /**
+     * 判断员工是否在指定部门
+     */
+    @Override
+    public boolean matchDepartment(String access_token, String userId, List<Long> deptIds) {
+        List<Number> deptIdList = (List<Number>) ddClient_contacts.getUserInfoById(access_token, userId).get("dept_id_list");
+        boolean isMatch = false;
+        // 兼容多部门场景
+        for (Number deptId : deptIdList) {
+            // ppExt: 不要直接使用 Number 作为类型, 不同基本类型比较值时会有偏差 [可以作为父类接受数据, 避免直接强制类型转换错误, 再进行基本类型处理]
+            isMatch = _matchDepartment(access_token, deptId.longValue(), deptIds);
+            if (isMatch) {
+                break;
+            }
+        }
+        return isMatch;
+    }
+
+    /// 递归: 判断员工是否在指定部门
+    boolean _matchDepartment(String access_token, long dept_id, List<Long> deptIds) {
+        boolean isMatch = false;
+        // 判断入参 [同样作为递归出口]
+        if (dept_id == DDConf.TOP_DEPARTMENT) {
+            return false;
+        }
+        if (deptIds.contains(dept_id)) {
+            isMatch = true;
+        }
+        // 递归出口 [查询上级部门匹配]
+        if (!isMatch) {
+            Map deptInfo = ddClient_contacts.getDepartmentInfo(access_token, dept_id);
+            long parentId = UtilMap.getLong(deptInfo, "parent_id");
+            return _matchDepartment(access_token, parentId, deptIds);
+        }
+        return isMatch;
+    }
+
+    /**
+     * 获取员工所属部门全路径
+     */
+    @Override
+    public List<Map> getUserDepartmentHierarchy(String access_token, String userId) {
+        // PRD: 一个人存在多个部门默认取第一个
+        List<Number> deptIdList = (List<Number>) ((List<Map>) ddClient_contacts.listParentByUser(access_token, userId).get("parent_list")).get(0).get("parent_dept_id_list");
+        List<Map> deptInfo = new ArrayList();
+        // Number 仅仅作为数据类型声明, 避免比较类型不一致导致判定问题
+        for (Number deptId : deptIdList) {
+            if (deptId.longValue() == DDConf.TOP_DEPARTMENT) {
+                continue;
+            }
+            deptInfo.add(UtilMap.map("id, name", deptId, ddClient_contacts.getDepartmentInfo(access_token, deptId.longValue()).get("name")));
+        }
+        Collections.reverse(deptInfo);
+        return deptInfo;
+    }
+
+    /**
+     * 获取员工所属部门路径层级 [名称拼接]
+     */
+    @Override
+    public String getUserDepartmentHierarchyJoin(String access_token, String userId, String delimiter) {
+        return String.join(delimiter, getUserDepartmentHierarchy(access_token, userId).stream().map(dept -> dept.get("name").toString()).collect(Collectors.toList()));
+    }
+
+    /**
+     * jsApi 注册
+     */
+    @Override
+    public Map registerJsApi(String url, String nonceStr) {
+        String jsTicket = ddClient.getJsApiTicket(ddClient.getAccessToken());
+        long timeStamp = new Date().getTime();
+        String signature = DDConfigSign.sign(jsTicket, nonceStr, timeStamp, url);
+        return UtilMap.map("nonceStr, agentId, timeStamp, corpId, signature", nonceStr, ddConf.getAgentId(), timeStamp, ddConf.getCorpId(), signature);
+    }
+
+    /**
+     * 免登code获取用户信息
+     */
+    @Override
+    public Map getUserInfoByCode(String code) {
+        Map rsp = ddClient.getUserInfoByCode(ddClient.getAccessToken(), code);
+        return ddClient_contacts.getUserInfoById(ddClient.getAccessToken(), rsp.get("userid").toString());
+    }
+}

+ 141 - 0
mjava/src/main/java/com/malk/utils/UtilConvert.java

@@ -0,0 +1,141 @@
+package com.malk.utils;
+
+import lombok.extern.slf4j.Slf4j;
+
+import java.lang.reflect.Field;
+import java.util.ArrayList;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * 行转列工具类
+ */
+@Slf4j
+public class UtilConvert {
+
+    private static final String NULL_VALUE = "";
+    private static final String HEADER_NULL_VALUE = "";
+    private static Set<Object> headerSet;
+    private static Set<Object> firstColSet;
+
+    public static class ConvertData {
+        private Set<Object> headerSet;
+        private Set<Object> firstColSet;
+        private List<List<Object>> dataList;
+
+        public ConvertData(List<List<Object>> dataList, Set<Object> headerSet, Set<Object> firstColSet) {
+            this.headerSet = headerSet;
+            this.firstColSet = firstColSet;
+            this.dataList = dataList;
+        }
+
+        public Set<Object> getHeaderSet() {
+            return headerSet;
+        }
+
+        public void setHeaderSet(Set<Object> headerSet) {
+            this.headerSet = headerSet;
+        }
+
+        public Set<Object> getFirstColSet() {
+            return firstColSet;
+        }
+
+        public void setFirstColSet(Set<Object> firstColSet) {
+            this.firstColSet = firstColSet;
+        }
+
+        public List<List<Object>> getDataList() {
+            return dataList;
+        }
+
+        public void setDataList(List<List<Object>> dataList) {
+            this.dataList = dataList;
+        }
+    }
+
+    /**
+     * 行转列,返回ConvertData
+     *
+     * @param orignalList   原始list
+     * @param headerName    列表头字段名
+     * @param firstColName  首列字段名
+     * @param valueFiedName 值列的字段名
+     * @param needHeader    是否需要返回列表头
+     * @return ConvertData
+     */
+    public static synchronized ConvertData doConvertReturnObj(List orignalList, String headerName, String firstColName, String valueFiedName, boolean needHeader) throws Exception {
+        List<List<Object>> lists = doConvert(orignalList, headerName, firstColName, valueFiedName, needHeader);
+        return new ConvertData(lists, headerSet, firstColSet);
+    }
+
+    /**
+     * 行转列,返回转换后的list
+     *
+     * @param orignalList   原始list
+     * @param headerName    列表头字段名
+     * @param firstColName  首列字段名
+     * @param valueFiedName 值列的字段名
+     * @param needHeader    是否需要返回列表头
+     */
+    public static synchronized List<List<Object>> doConvert(List orignalList, String headerName, String firstColName, String valueFiedName, boolean needHeader) throws Exception {
+        headerSet = new LinkedHashSet<>();
+        firstColSet = new LinkedHashSet<>();
+        List<List<Object>> resultList = new ArrayList<>();
+
+        getHeaderFirstcolSet(orignalList, headerName, firstColName);
+        if (needHeader) {
+            List<Object> headerList = new ArrayList<>();
+            //填充进header
+            headerList.add(HEADER_NULL_VALUE);
+            headerList.addAll(headerSet);
+            resultList.add(headerList);
+        }
+        for (Object firstColNameItem : firstColSet) {
+            List<Object> colList = new ArrayList<>();
+            //名称
+            colList.add(firstColNameItem);
+            for (Object headerItem : headerSet) {
+                boolean flag = true;
+                for (Object orignalObjectItem : orignalList) {
+                    Field headerField = orignalObjectItem.getClass().getDeclaredField(headerName);
+                    headerField.setAccessible(true);
+                    Field firstColField = orignalObjectItem.getClass().getDeclaredField(firstColName);
+                    firstColField.setAccessible(true);
+                    Field valueField = orignalObjectItem.getClass().getDeclaredField(valueFiedName);
+                    valueField.setAccessible(true);
+                    if (headerItem.equals(headerField.get(orignalObjectItem))) {
+                        if (firstColNameItem.equals(firstColField.get(orignalObjectItem))) {
+                            colList.add(valueField.get(orignalObjectItem));
+                            flag = false;
+                            break;
+                        }
+                    }
+                }
+                if (flag) {
+                    colList.add(NULL_VALUE);
+                }
+            }
+            resultList.add(colList);
+        }
+        return resultList;
+    }
+
+    private static void getHeaderFirstcolSet(List orignalList, String headerName, String firstColName) {
+        try {
+            for (Object item : orignalList) {
+                Field headerField = item.getClass().getDeclaredField(headerName);
+                headerField.setAccessible(true);
+                Field firstColField = item.getClass().getDeclaredField(firstColName);
+                firstColField.setAccessible(true);
+                headerSet.add(headerField.get(item));
+                firstColSet.add(firstColField.get(item));
+            }
+        } catch (NoSuchFieldException e) {
+            log.error(e.getMessage(), e); // 记录错误日志
+        } catch (IllegalAccessException e) {
+            log.error(e.getMessage(), e); // 记录错误日志
+        }
+    }
+}

+ 214 - 0
mjava/src/main/java/com/malk/utils/UtilDateTime.java

@@ -0,0 +1,214 @@
+package com.malk.utils;
+
+import cn.hutool.core.util.ObjectUtil;
+import lombok.SneakyThrows;
+import org.apache.commons.lang3.StringUtils;
+
+import java.text.SimpleDateFormat;
+import java.time.*;
+import java.time.format.DateTimeFormatter;
+import java.time.temporal.Temporal;
+import java.time.temporal.TemporalAdjusters;
+import java.util.Calendar;
+import java.util.Date;
+
+/**
+ * 时间格式 [Calendar 日历类, Date 是java 8前产物, LocalDateTime]
+ */
+public abstract class UtilDateTime {
+
+    // todo: 获取次日开启时间, 获取次日结束时间
+
+    public final static String DATE_PATTERN = "yyyy-MM-dd";
+    public final static String TIME_PATTERN = "HH:mm:ss";
+    public final static String DATE_TIME_PATTERN = "yyyy-MM-dd HH:mm:ss";
+    /// ISO-8601的date-time格式: T表示分隔符,Z表示的是UTC
+    public final static String DATE_TIME_ISO = "yyyy-MM-dd'T'HH:mm:ssZ";
+    public final static String DATE_MSEL_ISO = "yyyy-MM-dd'T'HH:mm:ss.SSS"; // TB
+
+
+    //// convert ////
+
+    // 将java.util.Date 转换为java8 的java.time.LocalDateTime,默认时区为东8区
+    public static LocalDateTime convertToLocalDateTimeFromDate(Date date) {
+        return date.toInstant().atOffset(ZoneOffset.of("+8")).toLocalDateTime();
+    }
+
+    // 将java8 的 java.time.LocalDateTime 转换为 java.util.Date,默认时区为东8区
+    public static Date convertToDateFromLocalDateTime(LocalDateTime localDateTime) {
+        return Date.from(localDateTime.toInstant(ZoneOffset.of("+8")));
+    }
+
+    // 在start之前, 兼容等于
+    public static boolean beforeAndEqual(Date start, Date compare) {
+        return start.before(compare) || start.equals(compare);
+    }
+
+    // 在end之后, 兼容等于
+    public static boolean afterAndEqual(Date end, Date compare) {
+        return end.after(compare) || end.equals(compare);
+    }
+
+    // 获取时间段内小时
+    public static double betweenHour(Temporal startInclusive, Temporal endExclusive) {
+        return UtilNumber.formatPrecisionValue(Duration.between(startInclusive, endExclusive).toMinutes() / 60f);
+    }
+
+    // 获取上月第一天0点
+    public static LocalDateTime firstDayOfLastMonth(LocalDateTime dateTime) {
+        int month = dateTime.getMonthValue() - 1;
+        int year = dateTime.getYear();
+        if (month == 0) {
+            month = 12;
+            year -= 1;
+        }
+        return LocalDateTime.of(year, month, 1, 0, 0, 0);
+    }
+
+    // 计算时间差和指定如一月最后一天很方便, 如下个月第一天 .... [时间会保留传入时间]
+    public static LocalDateTime firstDayOfNextMonth(LocalDateTime dateTime) {
+        return dateTime.with(TemporalAdjusters.firstDayOfNextMonth());
+    }
+
+    // 获取当天开始时间
+    public static LocalDateTime minLocalDateTime(LocalDateTime dateTime) {
+        return LocalDateTime.of(dateTime.toLocalDate(), LocalTime.MIN);
+    }
+
+    // 获取当天结束时间
+    public static LocalDateTime maxLocalDateTime(LocalDateTime dateTime) {
+        return LocalDateTime.of(dateTime.toLocalDate(), LocalTime.MAX);
+    }
+
+    // 当前时间时间戳
+    public static long getLocalDateTimeTimeStamp() {
+        return LocalDateTime.now().toInstant(ZoneOffset.of("+8")).toEpochMilli();
+    }
+
+    // 转中国时区时间戳
+    public static long getLocalDateTimeTimeStamp(LocalDateTime dataTime) {
+        return dataTime.toInstant(ZoneOffset.of("+8")).toEpochMilli();
+    }
+
+    // 时间戳转为本地时间日期
+    public static LocalDateTime getLocalDateTimeFromTimestamp(long millisecond) {
+        return LocalDateTime.ofEpochSecond(millisecond / 1000, 0, ZoneOffset.ofHours(8));
+    }
+
+    //// Date ////
+
+    public static String formatDateTime(Date dateTime) {
+        if (ObjectUtil.isNull(dateTime)) return "";
+        return new SimpleDateFormat(DATE_TIME_PATTERN).format(dateTime);
+    }
+
+    public static String formatDate(Date dateTime) {
+        return new SimpleDateFormat(DATE_PATTERN).format(dateTime);
+    }
+
+    public static String formatTime(Date dateTime) {
+        return new SimpleDateFormat(TIME_PATTERN).format(dateTime);
+    }
+
+    public static String format(Date dateTime, String pattern) {
+        return new SimpleDateFormat(pattern).format(dateTime);
+    }
+
+    public static String formatQuarter(Date date) {
+        // 月份从0开始
+        return date.getYear() + "-" + (date.getMonth() / 3 + 1);
+    }
+
+    @SneakyThrows
+    public static Date parseDateTime(String dateStr) {
+        if (StringUtils.isBlank(dateStr)) {
+            return new Date();
+        }
+        return new SimpleDateFormat(DATE_TIME_PATTERN).parse(dateStr);
+    }
+
+    @SneakyThrows
+    public static Date parseDate(String dateStr) {
+        return new SimpleDateFormat(DATE_PATTERN).parse(dateStr);
+    }
+
+    @SneakyThrows
+    public static Date parseTime(String dateStr) {
+        return new SimpleDateFormat(TIME_PATTERN).parse(dateStr);
+    }
+
+    @SneakyThrows
+    public static Date parse(String dateStr, String pattern) {
+        return new SimpleDateFormat(pattern).parse(dateStr);
+    }
+
+    //// LocalDateTime ////
+
+    public static String formatLocalDateTime(LocalDateTime dateTime) {
+        if (ObjectUtil.isNull(dateTime)) return "";
+        return DateTimeFormatter.ofPattern(DATE_TIME_PATTERN).format(dateTime);
+    }
+
+    public static String formatLocalDate(LocalDate dateTime) {
+        return DateTimeFormatter.ofPattern(DATE_PATTERN).format(dateTime);
+    }
+
+    public static String formatLocalTime(LocalTime dateTime) {
+        return DateTimeFormatter.ofPattern(TIME_PATTERN).format(dateTime);
+    }
+
+    public static String formatLocal(LocalDateTime dateTime, String pattern) {
+        return DateTimeFormatter.ofPattern(pattern).format(dateTime);
+    }
+
+    public static String formatLocal(LocalDate dateTime, String pattern) {
+        return DateTimeFormatter.ofPattern(pattern).format(dateTime);
+    }
+
+    public static String formatLocalQuarter(LocalDate date) {
+        // 月份从1开始
+        return date.getYear() + "-Q" + ((date.getMonth().getValue() - 1) / 3 + 1);
+    }
+
+    public static LocalDateTime parseLocalDateTime(String dateStr) {
+        if (StringUtils.isBlank(dateStr)) {
+            return LocalDateTime.now();
+        }
+        return LocalDateTime.parse(dateStr, DateTimeFormatter.ofPattern(DATE_TIME_PATTERN));
+    }
+
+    public static LocalDate parseLocalDate(String dateStr) {
+        return LocalDate.parse(dateStr, DateTimeFormatter.ofPattern(DATE_PATTERN));
+    }
+
+    public static LocalTime parseLocalTime(String dateStr) {
+        return LocalTime.parse(dateStr, DateTimeFormatter.ofPattern(TIME_PATTERN));
+    }
+
+    public static LocalTime parseLocal(String dateStr, String pattern) {
+        return LocalTime.parse(dateStr, DateTimeFormatter.ofPattern(pattern));
+    }
+
+    //// Calendar ////
+
+    // 获取上月最后一天
+    public static String lastDayOfNextMonth(Date date) {
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(date);
+        // 指定日期月份减去一
+        cal.add(Calendar.MONTH, -1);
+        cal.set(Calendar.DAY_OF_MONTH, 1);
+        cal.roll(Calendar.DAY_OF_MONTH, -1);
+        return formatDate(cal.getTime()) + " 23:59:59";
+    }
+
+    // 获取上月第一天
+    public static String firstDayOfNextMonth(Date date) {
+        Calendar cal = Calendar.getInstance();
+        cal.setTime(date);
+        // 指定日期月份减去一
+        cal.add(Calendar.MONTH, -1);
+        cal.set(Calendar.DAY_OF_MONTH, 1);
+        return formatDate(cal.getTime()) + " 00:00:00";
+    }
+}

+ 135 - 0
mjava/src/main/java/com/malk/utils/UtilEnv.java

@@ -0,0 +1,135 @@
+package com.malk.utils;
+
+import org.springframework.aop.framework.AopContext;
+import org.springframework.beans.BeansException;
+import org.springframework.beans.factory.NoSuchBeanDefinitionException;
+import org.springframework.beans.factory.config.BeanFactoryPostProcessor;
+import org.springframework.beans.factory.config.ConfigurableListableBeanFactory;
+import org.springframework.context.ApplicationContext;
+import org.springframework.context.ApplicationContextAware;
+import org.springframework.stereotype.Component;
+
+/**
+ * spring工具类 方便在非spring管理环境中获取bean
+ *
+ * @author Rangers
+ */
+@Component
+public final class UtilEnv implements BeanFactoryPostProcessor, ApplicationContextAware {
+
+    public final static String ENV_PROD = "prod";
+    public final static String ENV_TEST = "test";
+    public final static String ENV_DEV = "dev";
+
+    /**
+     * Spring应用上下文环境
+     */
+    private static ConfigurableListableBeanFactory beanFactory;
+
+    private static ApplicationContext applicationContext;
+
+    @Override
+    public void postProcessBeanFactory(ConfigurableListableBeanFactory beanFactory) throws BeansException {
+        this.beanFactory = beanFactory;
+    }
+
+    @Override
+    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
+        this.applicationContext = applicationContext;
+    }
+
+    /**
+     * 获取对象
+     *
+     * @param name
+     * @return Object 一个以所给名字注册的bean的实例
+     * @throws BeansException
+     */
+    @SuppressWarnings("unchecked")
+    public static <T> T getBean(String name) throws BeansException {
+        return (T) beanFactory.getBean(name);
+    }
+
+    /**
+     * 获取类型为requiredType的对象
+     *
+     * @param clz
+     * @return
+     * @throws BeansException
+     */
+    public static <T> T getBean(Class<T> clz) throws BeansException {
+        T result = (T) beanFactory.getBean(clz);
+        return result;
+    }
+
+    /**
+     * 如果BeanFactory包含一个与所给名称匹配的bean定义,则返回true
+     *
+     * @param name
+     * @return boolean
+     */
+    public static boolean containsBean(String name) {
+        return beanFactory.containsBean(name);
+    }
+
+    /**
+     * 判断以给定名字注册的bean定义是一个singleton还是一个prototype。 如果与给定名字相应的bean定义没有被找到,将会抛出一个异常(NoSuchBeanDefinitionException)
+     *
+     * @param name
+     * @return boolean
+     * @throws NoSuchBeanDefinitionException
+     */
+    public static boolean isSingleton(String name) throws NoSuchBeanDefinitionException {
+        return beanFactory.isSingleton(name);
+    }
+
+    /**
+     * @param name
+     * @return Class 注册对象的类型
+     * @throws NoSuchBeanDefinitionException
+     */
+    public static Class<?> getType(String name) throws NoSuchBeanDefinitionException {
+        return beanFactory.getType(name);
+    }
+
+    /**
+     * 如果给定的bean名字在bean定义中有别名,则返回这些别名
+     *
+     * @param name
+     * @return
+     * @throws NoSuchBeanDefinitionException
+     */
+    public static String[] getAliases(String name) throws NoSuchBeanDefinitionException {
+        return beanFactory.getAliases(name);
+    }
+
+    /**
+     * 获取aop代理对象
+     *
+     * @param invoker
+     * @return
+     */
+    @SuppressWarnings("unchecked")
+    public static <T> T getAopProxy(T invoker) {
+        return (T) AopContext.currentProxy();
+    }
+
+    /**
+     * 获取当前的环境配置,无配置返回null
+     *
+     * @return 当前的环境配置
+     */
+    public static String[] getActiveProfiles() {
+        return applicationContext.getEnvironment().getActiveProfiles();
+    }
+
+    /**
+     * 获取当前的环境配置,当有多个环境配置时,只获取第一个
+     *
+     * @return 当前的环境配置
+     */
+    public static String getActiveProfile() {
+        final String[] activeProfiles = getActiveProfiles();
+        return UtilList.isNotEmpty(activeProfiles) ? activeProfiles[0] : null;
+    }
+}

+ 249 - 0
mjava/src/main/java/com/malk/utils/UtilExcel.java

@@ -0,0 +1,249 @@
+package com.malk.utils;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.alibaba.excel.EasyExcel;
+import com.alibaba.excel.ExcelWriter;
+import com.alibaba.excel.write.metadata.WriteSheet;
+import com.alibaba.excel.write.metadata.fill.FillConfig;
+import com.google.common.base.Strings;
+import com.malk.server.common.McException;
+import com.malk.server.common.McREnum;
+import lombok.Builder;
+import lombok.Data;
+import lombok.SneakyThrows;
+import org.apache.commons.lang3.StringUtils;
+import org.apache.poi.ss.usermodel.BorderStyle;
+import org.apache.poi.xssf.usermodel.*;
+
+import javax.annotation.Nullable;
+import javax.servlet.http.HttpServletResponse;
+import javax.validation.constraints.NotNull;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.math.BigDecimal;
+import java.net.URLEncoder;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * excel 导出工具 [poi & easy excel]
+ */
+@Builder
+@Data
+public class UtilExcel {
+
+    /**
+     * 设置响应流
+     */
+    @SneakyThrows
+    public static void setResponseHeader(HttpServletResponse response, @NotNull String fileName, String extension) {
+        // 后缀: poi, xlsx下预览无详情; EasyExcel 需要使用xlsx, 否则不能预览
+        if (StringUtils.isBlank(extension)) {
+            extension = ".xls";
+        }
+        String date = new SimpleDateFormat("yyyy-MM-dd HH_mm_ss").format(new Date());
+        fileName = fileName + "_" + date + extension;
+        // 设置导出流信息
+        response.setContentType("application/vnd.ms-excel;charset=UTF-8");
+        response.setCharacterEncoding("utf-8");
+        // 文件名兼容中文
+        fileName = URLEncoder.encode(fileName, "UTF-8").replaceAll("\\+", "%20");
+        response.setHeader("Content-Disposition", "attachment;filename*=utf-8" + fileName);
+        // 想要让客户端CellStyleModel可以访问到其他的首部信息,服务器不仅要在header里加入该首部,还要将它们在 Access-Control-Expose-Headers 里面列出来
+        response.setHeader("Access-Control-Expose-Headers", "Content-Disposition");
+    }
+
+    /////////////////// Poi ///////////////////
+
+    // 各个列的表头
+    private String[] heardList;
+    // 各个列的元素key值
+    private String[] heardKey;
+    // 需要填充的数据信息
+    private List<Map> data;
+    // 导出文件名称
+    private String fileName;
+
+    // 字体大小
+    @Builder.Default
+    private int fontSize = 12;
+    @Builder.Default
+    private String fontName = "微软雅黑";
+    // 行高
+    @Builder.Default
+    private int rowHeight = 30;
+    // 列宽
+    @Builder.Default
+    private int columnWidth = 20;
+    // 工作表
+    @Builder.Default
+    private String sheetName = "sheet1";
+
+    /**
+     * 回调单元格样式
+     * -
+     * cellStyle.setFillForegroundColor(IndexedColors.RED.getIndex()); // 是设置前景色不是背景色
+     * cellStyle.setFillPattern(FillPatternType.SOLID_FOREGROUND);
+     */
+    @FunctionalInterface
+    public interface UpdateCellStyle {
+        void invoke(String value, XSSFCellStyle cellStyle);
+    }
+
+    public void exportExcelByPoi(HttpServletResponse response) {
+        exportExcelByPoi(response, null);
+    }
+
+    /**
+     * Poi 导出功能
+     * -
+     * - java中的强制类型转换只是针对单个对象的,想要偷懒将整个数组转换成另外一种类型的数组是不行的. toArray 运行中已经转为 Object, 需要指定初始化类型
+     * - 4.x打包引用会异常: HSSFCellStyle.BORDER_THIN 指向的是 CellStyle.BORDER_THIN. 另若引入 easyExcel, 无需再引入 poi [其依赖是低版本 poi]
+     * - 数量限制: HSSF改为XSSF后,导出1000000条数据. HSSF最大容量为65535 [最大65536(2的16次方)行;即横向256个单元格,竖向65536个单元格]
+     */
+    public void exportExcelByPoi(HttpServletResponse response, UpdateCellStyle lambda) {
+        // 检查参数配置信息
+        checkConfig();
+        // 创建工作簿
+        XSSFWorkbook wb = new XSSFWorkbook();
+        // 创建工作表
+        XSSFSheet wbSheet = wb.createSheet(sheetName);
+        // 设置默认行宽
+        wbSheet.setDefaultColumnWidth(columnWidth);
+
+        // 设置表格样式
+        XSSFCellStyle styleHeader = createCellStyle(wb);
+        // 设置表头字体
+        XSSFFont fontHeader = wb.createFont();
+        fontHeader.setFontHeightInPoints((short) fontSize);
+        fontHeader.setFontName(fontName);
+        fontHeader.setBold(true);
+        styleHeader.setFont(fontHeader);
+
+        //设置列头元素
+        XSSFRow row = wbSheet.createRow(0);
+        XSSFCell cellHead = null;
+        for (int i = 0; i < heardList.length; i++) {
+            cellHead = row.createCell(i);
+            cellHead.setCellValue(heardList[i]);
+            cellHead.setCellStyle(styleHeader);
+        }
+
+        // 设置表格样式
+        XSSFCellStyle bodyStyle = createCellStyle(wb);
+        // 设置表格字体
+        XSSFFont fontBody = wb.createFont();
+        fontBody.setFontHeightInPoints((short) fontSize);
+        fontBody.setFontName(fontName);
+        fontBody.setBold(false);
+        bodyStyle.setFont(fontBody);
+
+        //开始写入实体数据信息
+        int a = 1;
+        for (int i = 0; i < data.size(); i++) {
+            XSSFRow roww = wbSheet.createRow(a);
+            Map map = data.get(i);
+            XSSFCell cell = null;
+            for (int j = 0; j < heardKey.length; j++) {
+                cell = roww.createCell(j);
+                cell.setCellStyle(bodyStyle);
+                Object valueObject = map.get(heardKey[j]);
+                String value = null;
+                if (valueObject == null) {
+                    valueObject = "";
+                }
+                if (valueObject instanceof String) {
+                    //取出的数据是字符串直接赋值
+                    value = (String) map.get(heardKey[j]);
+                } else if (valueObject instanceof Integer) {
+                    //取出的数据是Integer
+                    value = String.valueOf(((Integer) (valueObject)).floatValue());
+                } else if (valueObject instanceof BigDecimal) {
+                    //取出的数据是BigDecimal
+                    value = String.valueOf(((BigDecimal) (valueObject)).floatValue());
+                } else {
+                    value = valueObject.toString();
+                }
+                cell.setCellValue(Strings.isNullOrEmpty(value) ? "" : value);
+                // 独立设置单元格样式
+                if (ObjectUtil.isNotNull(lambda)) {
+                    XSSFCellStyle newBodyStyle = createCellStyle(wb);
+                    newBodyStyle.setFont(fontBody);
+                    lambda.invoke(value, newBodyStyle);
+                    cell.setCellStyle(newBodyStyle);
+                }
+            }
+            a++;
+        }
+        // 导出文件
+        setResponseHeader(response, fileName, ".xls");
+        try {
+            // 取得输出流
+            OutputStream os = response.getOutputStream();
+            wb.write(os);
+            os.flush();
+            os.close();
+        } catch (IOException ex) {
+            throw new McException(McREnum.METHOD_EXECUTE);
+        }
+    }
+
+    // 不同的 CellStyle 需要独立创建
+    private XSSFCellStyle createCellStyle(XSSFWorkbook wb) {
+        XSSFCellStyle style = wb.createCellStyle();
+        style.setBorderBottom(BorderStyle.THIN);     //下边框
+        style.setBorderLeft(BorderStyle.THIN);       //左边框
+        style.setBorderTop(BorderStyle.THIN);        //上边框
+        style.setBorderRight(BorderStyle.THIN);      //右边框
+        return style;
+    }
+
+    // 检查数据配置问题
+    protected void checkConfig() {
+        if (heardKey == null || heardList.length == 0) {
+            McException.exceptionParam("列名数组不能为空或者为NULL");
+        }
+        if (fontSize < 0 || rowHeight < 0 || columnWidth < 0) {
+            McException.exceptionParam("字体、宽度或者高度不能为负值");
+        }
+        if (Strings.isNullOrEmpty(fileName)) {
+            McException.exceptionParam("导出文件名称不能为NULL");
+        }
+    }
+
+    /////////////////// EasyExcel ///////////////////
+
+    /**
+     * 导出功能 EasyExcel [class支持map]
+     * -
+     * - Mac下, 若报错 Times 字体找不到, 下载安装一下, 不影响功能使用
+     * - ClassPathResource, 需要打包/编译后才能访问到. 识别不是架包内内容
+     */
+    @SneakyThrows
+    public static void exportListByTemplate(HttpServletResponse response, List dataList, Class dtoClass, @Nullable String fileName, String templateName) {
+        InputStream inputStream = UtilFile.readPackageResource("templates/" + templateName);
+        UtilExcel.setResponseHeader(response, fileName, ".xlsx");
+        // 模板导出
+        EasyExcel.write(response.getOutputStream(), dtoClass).withTemplate(inputStream).sheet().doFill(dataList);
+    }
+
+    /**
+     * 列表与主表进行填充 [格式: 模板主表 {字段}, 列表 {.字段}]
+     */
+    @SneakyThrows
+    public static void exportMapAndListByTemplate(HttpServletResponse response, Object dataMain, List dataList, Class dtoClass, @Nullable String fileName, String templateName) {
+        InputStream inputStream = UtilFile.readPackageResource("templates/" + templateName);
+        UtilExcel.setResponseHeader(response, fileName, ".xlsx");
+        ExcelWriter workBook = EasyExcel.write(response.getOutputStream(), dtoClass).withTemplate(inputStream).build();
+        WriteSheet sheet = EasyExcel.writerSheet().build();
+        FillConfig fillConfig = FillConfig.builder().forceNewRow(Boolean.TRUE).build();
+        // 先单组数据填充,再多组数据填充
+        workBook.fill(dataList, fillConfig, sheet);
+        workBook.fill(dataMain, sheet);
+        workBook.finish();
+    }
+
+}

+ 240 - 0
mjava/src/main/java/com/malk/utils/UtilFile.java

@@ -0,0 +1,240 @@
+package com.malk.utils;
+
+import com.alibaba.fastjson.JSON;
+import lombok.SneakyThrows;
+import org.apache.commons.codec.binary.Base64;
+import org.springframework.core.io.ClassPathResource;
+
+import javax.annotation.Nullable;
+import java.io.*;
+import java.text.SimpleDateFormat;
+import java.util.Date;
+
+public abstract class UtilFile {
+
+    ////////////////////////////// File Path //////////////////////////////
+
+    /**
+     * 匹配路径: 自动追加年月日作为目录
+     * -
+     * 文件上传存储,必须是绝对路径. 日志的配置地址可以是相对路径
+     */
+    public static File mkdirIfNot(@Nullable String fileName, @Nullable String dirName) {
+        String date = new SimpleDateFormat("yyyy-MM-dd").format(new Date());
+        String outputFile = dirName + "/" + date + "/" + fileName;
+        File file = new File(outputFile);
+        // 创建 mkdirIfNot
+        if (!file.getParentFile().exists()) {
+            file.getParentFile().mkdirs();
+        }
+        return file;
+    }
+
+    /**
+     * 根据路径删除指定的目录或文件,无论存在与否
+     */
+    public static boolean DeleteFolder(String sPath) {
+        boolean flag = false;
+        File file = new File(sPath);
+        // 判断目录或文件是否存在
+        if (!file.exists()) {  // 不存在返回 false
+            return flag;
+        } else {
+            // 判断是否为文件
+            if (file.isFile()) {  // 为文件时调用删除文件方法
+                return deleteFile(sPath);
+            } else {  // 为目录时调用删除目录方法
+                return deleteDirectory(sPath);
+            }
+        }
+    }
+
+    /**
+     * 删除单个文件
+     */
+    public static boolean deleteFile(String sPath) {
+        boolean flag = false;
+        File file = new File(sPath);
+        // 路径为文件且不为空则进行删除
+        if (file.isFile() && file.exists()) {
+            file.delete();
+            flag = true;
+        }
+        return flag;
+    }
+
+    /**
+     * 删除目录(文件夹)以及目录下的文件
+     */
+    public static boolean deleteDirectory(String sPath) {
+        // 如果sPath不以文件分隔符结尾,自动添加文件分隔符
+        if (!sPath.endsWith(File.separator)) {
+            sPath = sPath + File.separator;
+        }
+        File dirFile = new File(sPath);
+        // 如果dir对应的文件不存在,或者不是一个目录,则退出
+        if (!dirFile.exists() || !dirFile.isDirectory()) {
+            return false;
+        }
+        boolean flag = true;
+        // 删除文件夹下的所有文件(包括子目录)
+        File[] files = dirFile.listFiles();
+        for (int i = 0; i < files.length; i++) {
+            // 删除子文件
+            if (files[i].isFile()) {
+                flag = deleteFile(files[i].getAbsolutePath());
+                if (!flag) break;
+            } // 删除子目录
+            else {
+                flag = deleteDirectory(files[i].getAbsolutePath());
+                if (!flag) break;
+            }
+        }
+        if (!flag) return false;
+        // 删除当前目录
+        if (dirFile.delete()) {
+            return true;
+        } else {
+            return false;
+        }
+    }
+
+    ////////////////////////////// File & 字节/流 //////////////////////////////
+
+    /**
+     * 文件转化为byte字节数组
+     */
+    public static byte[] fileToByteArray(File file) {
+        byte[] data = null;
+        try {
+            FileInputStream fis = new FileInputStream(file);
+            ByteArrayOutputStream baos = new ByteArrayOutputStream();
+            int len;
+            byte[] buffer = new byte[1024];
+            while ((len = fis.read(buffer)) != -1) {
+                baos.write(buffer, 0, len);
+            }
+            data = baos.toByteArray();
+            fis.close();
+            baos.close();
+        } catch (Exception e) {
+            e.printStackTrace();
+        }
+        return data;
+    }
+
+    /**
+     * 文件全路径转Base64
+     */
+    public static String fileToBase64(String path) {
+        String base64 = null;
+        InputStream in = null;
+        try {
+            File file = new File(path);
+            in = new FileInputStream(file);
+            byte[] bytes = new byte[(int) file.length()];
+            in.read(bytes);
+            base64 = new String(Base64.encodeBase64(bytes), "UTF-8");
+        } catch (Exception e) {
+            e.printStackTrace();
+        } finally {
+            if (in != null) {
+                try {
+                    in.close();
+                } catch (IOException e) {
+                    e.printStackTrace();
+                }
+            }
+        }
+        return base64;
+    }
+
+    /**
+     * Base64转文件储存
+     */
+    public static void base64ToFile(String outFilePath, String base64, String outFileName) {
+        File file = null;
+        //创建文件目录
+        String filePath = outFilePath;
+        File dir = new File(filePath);
+        if (!dir.exists() && !dir.isDirectory()) {
+            dir.mkdirs();
+        }
+        BufferedOutputStream bos = null;
+        java.io.FileOutputStream fos = null;
+        try {
+            byte[] bytes = Base64.decodeBase64(base64);
+            file = new File(filePath + "/" + outFileName);
+            fos = new java.io.FileOutputStream(file);
+            bos = new BufferedOutputStream(fos);
+            bos.write(bytes);
+        } catch (Exception e) {
+            e.printStackTrace();
+        } finally {
+            if (bos != null) {
+                try {
+                    bos.close();
+                } catch (IOException e) {
+                    e.printStackTrace();
+                }
+            }
+            if (fos != null) {
+                try {
+                    fos.close();
+                } catch (IOException e) {
+                    e.printStackTrace();
+                }
+            }
+        }
+    }
+
+    ////////////////////////////// Resource 读取 //////////////////////////////
+
+    /**
+     * 项目包文件, 读取 [ ppExt: 读取 Resource 必须 ]
+     * -
+     * 1. ClassPathResource, 需要打包/编译后才能访问到. 识别不是架包内内容
+     * 2. 路径若 WebConfiguration 配置, 可使用配置别名. [详见 WebConfiguration]
+     */
+    @SneakyThrows
+    public static InputStream readPackageResource(String path) {
+        ClassPathResource classPathResource = new ClassPathResource(path);
+        InputStream inputStream = classPathResource.getInputStream();
+        return inputStream;
+    }
+
+    /**
+     * 项目包JSON, 读取 [ ppExt: 读取 Resource 必须 ]
+     */
+    public static Object readJsonObjectFromResource(String path) {
+        return _readJsonObjectFromStream(readPackageResource(path));
+    }
+
+    /// ppExt: 文件流, 转JSONObject
+    /// 1. 使用 JSONUtil.class.getClassLoader().getResource(path).getPath(); 本地访问路径正常, 部署服务器路径访问访问, 需要读取 Resource
+    /// 2. 若是读取本地json, 文件不能使用 .josn 后缀, 会被转义导致解析异常 / 但若是 Resource 资源则不受影响, [访问路径详见 WebConfiguration]
+    @SneakyThrows
+    private static Object _readJsonObjectFromStream(InputStream inputStream) {
+        Reader reader = new InputStreamReader(inputStream, "utf-8");
+        int ch = 0;
+        StringBuffer sb = new StringBuffer();
+        while ((ch = reader.read()) != -1) {
+            sb.append((char) ch);
+        }
+        reader.close();
+        return JSON.parse(sb.toString());
+    }
+
+    /**
+     * 文件绝对路径转json
+     */
+    @SneakyThrows
+    public static Object readJsonObjectFromFile(String absolutePath) {
+        File jsonFile = new File(absolutePath);
+        FileReader fileReader = new FileReader(jsonFile);
+        // 通过文件, 读取流转json对象
+        Object jsonObject = _readJsonObjectFromStream(new FileInputStream(jsonFile));
+        fileReader.close();
+        return jsonObject;
+    }
+}

+ 233 - 0
mjava/src/main/java/com/malk/utils/UtilHttp.java

@@ -0,0 +1,233 @@
+package com.malk.utils;
+
+import cn.hutool.core.util.CharsetUtil;
+import cn.hutool.core.util.ObjectUtil;
+import cn.hutool.core.util.XmlUtil;
+import cn.hutool.http.HttpRequest;
+import cn.hutool.http.HttpResponse;
+import cn.hutool.http.HttpUtil;
+import cn.hutool.http.webservice.SoapClient;
+import com.alibaba.fastjson.JSON;
+import com.alibaba.fastjson.serializer.SerializerFeature;
+import com.malk.server.common.VenR;
+import com.malk.server.dingtalk.DDR;
+import com.malk.server.dingtalk.DDR_New;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+
+import java.io.File;
+import java.util.Map;
+
+
+/**
+ * HttpUtil [取值详见CatchException]
+ * -
+ * - 后端方法说明
+ * 1. param 使用转码, 避免中文字符导致参数识别异常, 如中文小括号
+ * 2. body 注意 .form 是表单内容; .body 才是请求内容 [Restful请求]
+ * 3. form 方式, 支持文件上传, 参数包到 map. body 不允许为空, 导致异常
+ * 4. response 请求后若需要转为数据类型, 读取请求结果body为JSONString. 若直接获取格式字符串, 不能再转为对象或Map
+ * -
+ * -  前端请求格式
+ * 1. getDefault: url上param, 后端取值@requestParam,也可用request.getParameterMap().getDefault(“key”), 参数会被放入一个集合
+ * 2. post: body内json, 后端取值@requestBody, Map 或转为实体
+ * 3. form: body内格式为form, 和content-type有关系, 需要为form格式后端才能读取: 不能使用@RequestBody,参数会自动解析到实体; 若不是实体通过方法转Map
+ * 4. upload: body-formData, 一般用于文件上传, 追加数据流
+ */
+@Slf4j
+public abstract class UtilHttp {
+
+    public enum METHOD {
+        POST,
+        GET,
+        PUT,
+        PATCH,
+        DELETE,
+        UPLOAD
+    }
+
+    /******** 创建请求 ********/
+
+    // todo: 认证格式 - Authorization:Basic base64(“admin:密码”)
+    public static String doRequest(METHOD method, String url, Map header, Map<String, Object> param, Object body, Map form, String usr, String pwd) {
+        log.debug("请求入参, url = {}, header = {}, param = {}, body = {}, form = {}", url, header, param, body, form);
+        String path = HttpUtil.urlWithForm(url, param, CharsetUtil.CHARSET_UTF_8, true);
+        HttpRequest request;
+        switch (method) {
+            case GET:
+                request = HttpRequest.get(path);
+                break;
+            case PUT:
+                request = HttpRequest.put(path);
+                break;
+            case PATCH:
+                request = HttpRequest.patch(path);
+                break;
+            case DELETE:
+                request = HttpRequest.delete(path);
+                break;
+            default:
+                request = HttpRequest.post(path);
+                break;
+        }
+        request.addHeaders(header).form(form); // form允许为空
+        //request.addHeaders(header); // form允许为空
+        if (ObjectUtil.isNotNull(body)) {
+            // ppExt: 序列号保留null字段, 不做过滤
+            request.body(JSON.toJSONString(body, SerializerFeature.WriteMapNullValue));
+        }
+        if (StringUtils.isNotBlank(usr) && StringUtils.isNotBlank(pwd)) {
+            request.basicAuth(usr, pwd);
+        }
+        HttpResponse out = request.execute();
+        log.debug("请求响应, {}, {}", out.getStatus(), out.body()); // http 状态判定
+        // ppExt: 外部接口http状态异常, 不直接阻断, 通过 r.assertSuccess(); 校验
+        //McException.assertException(out.getStatus() != 200, String.valueOf(out.getStatus()), "ERROR HTTP STATUS EXCEPTION");
+        return out.body();
+    }
+
+    public static String doRequest(METHOD method, String url, Map header, Map<String, Object> param, Map body, Map form) {
+        return doRequest(method, url, header, param, body, form, null, null);
+    }
+
+    public static String doRequest(METHOD method, String url, Map header, Map<String, Object> param, Map body) {
+        return doRequest(method, url, header, param, body, null);
+    }
+
+    /*** ------------ 创建POST请求 ------------ ***/
+
+    public static String doPost(String url, Map header, Map<String, Object> param, Object body, Map form) {
+        return doRequest(METHOD.POST, url, header, param, body, form, null, null); // 兼容非 map
+    }
+
+    public static String doPost(String url, Map header, Map<String, Object> param, Map body, Map form) {
+        return doRequest(METHOD.POST, url, header, param, body, form);
+    }
+
+    public static String doPost_S(String url, Map header, Map<String, Object> param, Map body) {
+        return doRequest(METHOD.POST, url, header, param, body);
+    }
+    public static DDR_New doPost(String url, Map header, Map param, Map body) {
+        return (DDR_New) DDR.doPost(url, header, param, body, VenR.RC_DD_New);
+    }
+
+
+    public static VenR doPost(String url, Map header, Map<String, Object> param, Map body, Map form, Class rClass) {
+        String rsp = doPost(url, header, param, body, form);
+        VenR r = (VenR) JSON.parseObject(rsp, rClass);
+        r.assertSuccess();
+        return r;
+    }
+
+    public static VenR doPost(String url, Map header, Map<String, Object> param, Map body, Class rClass) {
+        return doPost(url, header, param, body, null, rClass);
+    }
+
+    public static VenR doPost(String url, Map header, Map body, Class rClass) {
+        return doPost(url, header, null, body, rClass);
+    }
+
+
+    /*** ------------ 创建GET请求 ------------ ***/
+
+    public static String doGet(String url, Map header, Map param) {
+        return doRequest(METHOD.GET, url, header, param, null);
+    }
+
+    public static VenR doGet(String url, Map header, Map param, Class rClass) {
+        String rsp = doGet(url, header, param);
+        VenR r = (VenR) JSON.parseObject(rsp, rClass);
+//        r.assertSuccess();
+        return r;
+    }
+
+    public static VenR doGet(String url, Map<String, Object> param, Class rClass) {
+        return doGet(url, null, param, rClass);
+    }
+
+    /*** ------------ 创建PUT请求 ------------ ***/
+
+    public static String doPut(String url, Map header, Map param, Map body) {
+        return doRequest(METHOD.PUT, url, header, param, body);
+    }
+
+    public static VenR doPut(String url, Map header, Map param, Map body, Class rClass) {
+        String rsp = doPut(url, header, param, body);
+        VenR r = (VenR) JSON.parseObject(rsp, rClass);
+        r.assertSuccess();
+        return r;
+    }
+
+    public static VenR doPut(String url, Map header, Map body, Class rClass) {
+        return doPut(url, header, null, body, rClass);
+    }
+
+    public static VenR doPut(String url, Map body, Class rClass) {
+        return doPut(url, null, null, body, rClass);
+    }
+
+    /*** ------------ 创建DELETE请求 ------------ ***/
+
+    public static String doDelete(String url, Map header, Map param, Map body) {
+        return doRequest(METHOD.DELETE, url, header, param, body);
+    }
+
+    public static VenR doDelete(String url, Map header, Map param, Class rClass) {
+        String rsp = doDelete(url, header, param, (Map) null);
+        VenR r = (VenR) JSON.parseObject(rsp, rClass);
+        r.assertSuccess();
+        return r;
+    }
+
+    /*** ------------ 创建PATCH请求 ------------ ***/
+
+    public static String doPatch(String url, Map header, Map param, Map body) {
+        return doRequest(METHOD.PATCH, url, header, param, body);
+    }
+
+    public static VenR doPatch(String url, Map header, Map param, Map body, Class rClass) {
+        String rsp = doPatch(url, header, param, body);
+        VenR r = (VenR) JSON.parseObject(rsp, rClass);
+        r.assertSuccess();
+        return r;
+    }
+
+    public static VenR doPatch(String url, Map header, Map body, Class rClass) {
+        return doPatch(url, header, null, body, rClass);
+    }
+
+    /*** ------------ 文件访问处理 ------------ ***/
+
+    public static String doUpload(String url, Map header, Map<String, Object> param, Map form) {
+        return doPost(url, header, param, null, form);
+    }
+
+    public static VenR doUpload(String url, Map header, Map<String, Object> param, Map form, Class rClass) {
+        return doPost(url, header, param, null, form, rClass);
+    }
+
+    public static VenR doUpload(String url, Map header, Map form, Class rClass) {
+        return doPost(url, header, null, null, form, rClass);
+    }
+
+    public static long doDownload(String url, File file) {
+        return HttpUtil.downloadFile(url, file);
+    }
+
+    /*** ------------ 创建SOAP请求 ------------ ***/
+
+    public static Map doSoap(String url, Map param, String methodName, String namespaceURI) {
+        log.debug("请求入参, url = {}, param = {}, methodName = {}, namespaceURI = {}", url, param, methodName, namespaceURI);
+        // 新建客户端
+        SoapClient client = SoapClient.create(url)
+                // 设置要请求的方法,此接口方法前缀为web,传入对应的命名空间
+                .setMethod(methodName, namespaceURI)
+                // 设置参数,此处自动添加方法的前缀:web2
+                .setParams(param);
+        // 发送请求,参数true表示返回一个格式化后的XML内容
+        // 返回内容为XML字符串,可以配合XmlUtil解析这个响应
+        String rsp = client.send(true);
+        log.debug("请求响应, {}", rsp);
+        return XmlUtil.xmlToMap(rsp);
+    }
+}

+ 88 - 0
mjava/src/main/java/com/malk/utils/UtilList.java

@@ -0,0 +1,88 @@
+package com.malk.utils;
+
+import cn.hutool.core.util.ObjectUtil;
+
+import java.util.*;
+import java.util.stream.Collectors;
+
+public abstract class UtilList {
+
+    // 访问安全, 避免空指针访问访问属性异常
+    public static boolean isEmpty(List list) {
+        return ObjectUtil.isNull(list) || list.isEmpty();
+    }
+
+    public static boolean isNotEmpty(List list) {
+        return !isEmpty(list);
+    }
+
+    public static boolean isEmpty(Object[] list) {
+        return ObjectUtil.isNull(list) || list.length == 0;
+    }
+
+    public static boolean isNotEmpty(Object[] list) {
+        return !isEmpty(list);
+    }
+
+    public static Object getLast(List list) {
+        if (isEmpty(list)) return null;
+        return list.get(list.size() - 1);
+    }
+
+    public static Object getLast(Object[] list) {
+        if (isEmpty(list)) return null;
+        return list[list.length - 1];
+    }
+
+    /**
+     * Arrays.asList 不可变, asList 为可变 [Set -> new ArrayList<>(Map.keySet())]
+     */
+    public static List asList(Object... a) {
+        List tList = new ArrayList<>();
+        tList.addAll(Arrays.asList(a));
+        return tList;
+    }
+
+    /// collection.frequency方法,可以统计出某个对象在collection中出现的次数
+    private static Map _frequency(List list) {
+        Set uniqueWords = new HashSet<>(list);
+        int max = 0;
+        Object val = null;
+
+        for (Object word : uniqueWords) {
+            int num = Collections.frequency(list, word);
+            if (num > max) {
+                max = num;
+                val = word;
+            }
+        }
+        return UtilMap.map("max, val", max, val);
+    }
+
+    /**
+     * 某个对象在collection中出现最多次对象
+     */
+    public static Object maxFrequencyObject(List list) {
+        return _frequency(list).get("val");
+    }
+
+    /**
+     * 某个对象在collection中出现最多次次数
+     */
+    public static Object maxFrequencyCounty(List list) {
+        return _frequency(list).get("max");
+    }
+
+    /**
+     * 忽略集合内, map属性为0字段 [列表/导出]
+     */
+    public static List<Map> ignoreListMapZero(List<Map> list) {
+        return list.stream().map(item -> {
+            Map data = new HashMap();
+            for (Object key : item.keySet()) {
+                UtilMap.putNotZero(data, key.toString(), item.get(key));
+            }
+            return data;
+        }).collect(Collectors.toList());
+    }
+}

+ 340 - 0
mjava/src/main/java/com/malk/utils/UtilMap.java

@@ -0,0 +1,340 @@
+package com.malk.utils;
+
+import cn.hutool.core.util.ObjectUtil;
+import com.malk.server.common.McException;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.collections4.map.HashedMap;
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.*;
+
+@Slf4j
+public abstract class UtilMap {
+
+    /************* 快速创建map ************/
+
+    /**
+     * 快速创建map [key, Objects]
+     */
+    public static Map<String, Object> map(String keys, Object... values) {
+        String[] props = keys.split(", ");
+        if (props.length != values.length) {
+            McException.assertParamException_Null(keys);
+        }
+        Map<String, Object> map = new HashMap<>();
+        Arrays.stream(values).forEach(UtilMc.consumerWithIndex((item, index) -> {
+            map.put(props[index], item);
+        }));
+        return map;
+    }
+
+    /**
+     * 快速创建map [key, Objects] todo, 添加枚举, 忽略空, 忽略0, 忽略""/null字符串 ...
+     */
+    public static Map<String, Object> mapNotNull(String keys, Object... values) {
+        String[] props = keys.split(", ");
+        if (props.length != values.length) {
+            McException.assertParamException_Null(keys);
+        }
+        Map<String, Object> map = new HashMap<>();
+        Arrays.stream(values).forEach(UtilMc.consumerWithIndex((item, index) -> {
+            if (ObjectUtil.isNotNull(item) && UtilString.isNotBlankCompatNull(String.valueOf(item))) {
+                map.put(props[index], item);
+            }
+        }));
+        return map;
+    }
+
+    /**
+     * 快速创建map [key, Strings]
+     */
+    public static Map<String, String> map(String keys, String values) {
+        if (UtilString.isBlankCompatNull(keys) || UtilString.isBlankCompatNull(values)) {
+            return UtilMap.empty();
+        }
+        String[] props = keys.split(", ");
+        String[] contents = values.split(", ");
+        if (props.length != contents.length) {
+            McException.assertParamException_Null(keys);
+        }
+        Map<String, String> map = new HashMap<>();
+        Arrays.stream(contents).forEach(UtilMc.consumerWithIndex((item, index) -> {
+            map.put(props[index], String.valueOf(item));
+        }));
+        return map;
+    }
+
+    /**
+     * 快速创建map [保留skeys, 取值ckeys] todo: 新版本更新, 字段覆盖
+     */
+    public static Map<String, Object> map(String skeys, String ckeys, Map map) {
+        String[] sprops = skeys.split(", ");
+        String[] cprops = ckeys.split(", ");
+        if (sprops.length != cprops.length) {
+            McException.assertParamException_Null(skeys);
+        }
+        Map data = new HashMap();
+        Arrays.stream(cprops).forEach(UtilMc.consumerWithIndex((item, index) -> {
+            data.put(sprops[index], map.get(item));
+        }));
+        return data;
+    }
+
+    /**
+     * 快速创建map [合并skeys, 取值ckeys]
+     */
+    public static void map(String skeys, String ckeys, Map sMap, Map cMap) {
+        sMap.putAll(map(skeys, ckeys, cMap));
+    }
+
+    /// 创建空对象
+    public static Map empty() {
+        return new HashedMap();
+    }
+
+    /// 判定 & 创建空对象
+    public static Map empty(Map data) {
+        if (ObjectUtil.isNotNull(data)) {
+            return data;
+        }
+        return new HashedMap();
+    }
+
+    /************* 赋值 ************/
+
+    /**
+     * 赋值 [为空对象null, 忽略]
+     */
+    public static Map putNotNull(Map data, String key, Object value) {
+        if (ObjectUtil.isNull(data)) {
+            data = new HashMap();
+        }
+        if (ObjectUtil.isNotNull(value)) {
+            data.put(key, value);
+        }
+        return data;
+    }
+
+    /**
+     * 赋值 [为空字符串, 忽略]
+     */
+    public static Map putNotEmpty(Map data, String key, String value) {
+        if (ObjectUtil.isNull(data)) {
+            data = new HashMap();
+        }
+        if (StringUtils.isNotBlank(value)) {
+            data.put(key, value);
+        }
+        return data;
+    }
+
+    /**
+     * 非空对象全量合并
+     */
+    public static Map putAll(Map data, Map value) {
+        if (ObjectUtil.isNull(data)) {
+            if (ObjectUtil.isNull(value)) {
+                return empty();
+            }
+            return value;
+        }
+        if (ObjectUtil.isNotNull(value)) {
+            data.putAll(value);
+        }
+        return data;
+    }
+
+    /**
+     * 赋值 [值为0, 忽略]
+     */
+    public static Map putNotZero(Map data, String key, Object value) {
+        float val = 0.f;
+        try {
+            val = Float.valueOf(String.valueOf(value));
+        } catch (Exception e) {
+            data.put(key, value);
+        }
+        if (val != 0.f) {
+            data.put(key, value);
+        }
+        return data;
+    }
+
+    /**
+     * 赋值 [原值为空赋值默认值]
+     */
+    public static Map put(Map data, String key, Object defaultValue) {
+        data = empty(data);
+        Object value = data.get(key);
+        if (ObjectUtil.isNull(value)) {
+            value = defaultValue;
+        }
+        data.put(key, value);
+        return data;
+    }
+
+    /**
+     * 赋值 [赋值为空赋值默认值]
+     */
+    public static Map put(Map data, String key, Object value, Object defaultValue) {
+        data = empty(data);
+        if (ObjectUtil.isNull(value)) {
+            value = defaultValue;
+        }
+        data.put(key, value);
+        return data;
+    }
+
+    /************* 取值 ************/
+
+    /**
+     * 取值 [String.valueOf 避免空指针]
+     */
+    public static String getString(Map data, String key) {
+        if (data.containsKey(key)) {
+            // 不能判空字符串
+            if (ObjectUtil.isNull(data.get(key))) {
+                return "";
+            }
+            String value = String.valueOf(data.get(key));
+            // 屏蔽空指针转换
+            if (value.equals("null")) {
+                return "";
+            }
+            return value;
+        }
+        return "";
+    }
+
+    /**
+     * 取值 [转为 long]
+     */
+    public static long getLong(Map data, String key) {
+        McException.assertParamException_Null(data, key);
+        return Long.valueOf(getString(data, key)) + 0L;
+    }
+
+    /**
+     * 取值 [转为 int]
+     */
+    public static int getInt(Map data, String key) {
+        String txt = getString(data, key);
+        if (StringUtils.isBlank(txt)) {
+            return 0;
+        }
+        return Integer.valueOf(txt);
+    }
+
+    /**
+     * 取值 [转为 float]
+     */
+    public static Float getFloat(Map data, String key) {
+        String txt = getString(data, key);
+        if (StringUtils.isBlank(txt)) {
+            return 0f;
+        }
+        return Float.valueOf(txt);
+    }
+
+    public static Double getDouble(Map data, String key) {
+        String txt = getString(data, key);
+        if (StringUtils.isBlank(txt)) {
+            return 0d;
+        }
+        return Double.valueOf(txt);
+    }
+
+    /**
+     * 取值 [转为 bool]
+     */
+    public static Boolean getBoolean(Map data, String key) {
+        return Boolean.valueOf(getString(data, key));
+    }
+
+    /**
+     * 取值 [为空返回默认值]
+     */
+    public static Object get_default(Map data, String key, Object defaultValue) {
+        Object value = data.get(key);
+        if (ObjectUtil.isNull(value)) {
+            return defaultValue;
+        }
+        return value;
+    }
+
+    /**
+     * 取值 [为空返回默认字符串]
+     */
+    public static String getString_default(Map data, String key, String defaultValue) {
+        String value = getString(data, key);
+        if (StringUtils.isBlank(value)) {
+            return defaultValue;
+        }
+        return value;
+    }
+
+    /**
+     * 取值 [未定key, 取第一个命中]
+     */
+    public static Object get_first(Map data, String... keys) {
+        Object value = null;
+        for (String key : keys) {
+            if (data.containsKey(key)) {
+                value = data.get(key);
+                break;
+            }
+        }
+        return value;
+    }
+
+    /**
+     * 取值 String [未定key, 取第一个命中]
+     */
+    public static String getString_first(Map data, String... keys) {
+        String value = "";
+        for (String key : keys) {
+            // 字段 + 值判定
+            if (data.containsKey(key) && StringUtils.isNotBlank(getString(data, key))) {
+                value = UtilString.stringFormatNull(data, key);
+                break;
+            }
+        }
+        return value;
+    }
+
+    /**
+     * 取值 List
+     */
+    public static List getList(Map data, String key) {
+        if (data.containsKey(key)) {
+            return (List) data.get(key);
+        }
+        return new ArrayList();
+    }
+
+    /**
+     * 取值 Map
+     */
+    public static Map getMap(Map data, String key) {
+        if (data.containsKey(key)) {
+            return (Map) data.get(key);
+        }
+        return new HashedMap();
+    }
+
+    /************* 判定 ************/
+
+    /**
+     * 是否空字符串
+     */
+    public static boolean isBlankString(Map data, String key) {
+        return StringUtils.isBlank(getString(data, key));
+    }
+
+    /**
+     * 是否不为空字符串
+     */
+    public static boolean isNotBlankString(Map data, String key) {
+        return StringUtils.isNotBlank(getString(data, key));
+    }
+}

+ 24 - 0
mjava/src/main/java/com/malk/utils/UtilMath.java

@@ -0,0 +1,24 @@
+package com.malk.utils;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+
+public abstract class UtilMath {
+
+    // 不要使用浮点数进行大小比较, 比较使用 BigDecimal
+    public final static BigDecimal formatFloatRound(float value) {
+        BigDecimal bd = new BigDecimal(value);
+        return bd.setScale(2, RoundingMode.HALF_UP);
+    }
+
+    // 用来对超过16位有效位的数据进行精确的运算
+    public final static BigDecimal formatDoubleRound(double value) {
+        BigDecimal bd = new BigDecimal(value);
+        return bd.setScale(2, RoundingMode.HALF_UP);
+    }
+
+    // Math.ceil 需要有浮点数, 否则转 int 类型忽略掉, 向上取整就会是当前值
+    public final static int pagesOfTotalAndSize(int total, int size) {
+        return (int) Math.ceil(total * 1.0f / size);
+    }
+}

+ 50 - 0
mjava/src/main/java/com/malk/utils/UtilMc.java

@@ -0,0 +1,50 @@
+package com.malk.utils;
+
+import java.util.List;
+import java.util.Map;
+import java.util.Objects;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.function.BiConsumer;
+import java.util.function.Consumer;
+import java.util.function.Function;
+import java.util.function.Predicate;
+import java.util.stream.Collectors;
+
+public class UtilMc {
+
+    /**
+     * distinctByKey [SQL去重只能返回列, 不支持返回实体对象]
+     * -
+     * 成员列表去除重复userId
+     * 简写: poList.stream().filter(UtilMc.distinctByKey(XdDdApproveRecordPo::getOpenUserId)).collect(Collectors.toList())
+     * 展开: List<XdDdApproveRecordPo> poList = new ArrayList<>();
+     * poPage.getContent().stream().filter(UtilMc.distinctByKey(p -> p.getOpenUserId()))
+     * .forEach(poList::add);
+     * 打印: poList.forEach(System.out::println);
+     */
+    public static <T> Predicate<T> distinctByKey(Function<? super T, ?> keyExtractor) {
+        Map<Object, Boolean> seen = new ConcurrentHashMap<>();
+        return t -> Objects.isNull(seen.putIfAbsent(keyExtractor.apply(t), Boolean.TRUE));
+    }
+
+    /**
+     * 集合内Map唯一值, 返回集合 [返回去重后数据本身]
+     */
+    public static List<Map> distinctByKey(List<Map> dataList, String key) {
+        return dataList.stream().filter(distinctByKey(item -> item.get(key))).collect(Collectors.toList());
+    }
+
+    /**
+     * 工具方法 [forEach 索引]
+     */
+    public static Consumer<Object> consumerWithIndex(BiConsumer<Object, Integer> consumer) {
+        class Obj {
+            int i;
+        }
+        Obj obj = new Obj();
+        return t -> {
+            int index = obj.i++;
+            consumer.accept(t, index);
+        };
+    }
+}

+ 156 - 0
mjava/src/main/java/com/malk/utils/UtilNumber.java

@@ -0,0 +1,156 @@
+package com.malk.utils;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+
+import java.math.BigDecimal;
+import java.text.DecimalFormat;
+import java.text.NumberFormat;
+import java.util.Locale;
+import java.util.regex.Pattern;
+
+/**
+ * 数字格式化
+ * -
+ * ppExt: 不要直接使用 Number 作为类型, 不同基本类型比较值时会有偏差 [可以作为父类接受数据, 避免直接强制类型转换错误, 再进行基本类型处理]
+ */
+@Slf4j
+public class UtilNumber {
+
+    /**
+     * 货币格式化
+     */
+    public static final String formatCurrency(Number number) {
+        DecimalFormat curFormat = (DecimalFormat) NumberFormat.getCurrencyInstance(Locale.CHINA);
+        return curFormat.format(number);
+    }
+
+    /**
+     * 去除CHY货币标识
+     */
+    public static final String replaceCurrencyCHY(String cur) {
+        return cur.replace("¥", "").replace("¥", "");
+    }
+
+    /**
+     * 判断是否数字
+     */
+    public static boolean matchNumber(String num) {
+        Pattern pattern = Pattern.compile("^-?\\d+(\\.\\d+)?$");
+        return pattern.matcher(num).matches();
+    }
+
+    /**
+     * 去除CHY货币标识
+     */
+    public static final BigDecimal replaceCurrencyCHYToDecimal(String cur) {
+        String num = replaceCurrencyCHY(cur);
+        if (StringUtils.isBlank(num) || !matchNumber(num)) {
+            num = "0";
+        }
+        return new BigDecimal(num);
+    }
+
+    /**
+     * 百分比格式化
+     */
+    public static final String formatPercent(Number number) {
+        DecimalFormat percentFormat = (DecimalFormat) NumberFormat.getPercentInstance(Locale.CHINA);
+        percentFormat.setMinimumFractionDigits(2);
+        return percentFormat.format(number);
+    }
+
+    /**
+     * 小数位精度格式
+     */
+    public static final String formatPrecisionString(double number) {
+        return formatPrecisionString(number, "#.00");
+    }
+
+    /**
+     * 小数位精度格式
+     */
+    public static final String formatPrecisionString(double number, String pattern) {
+        DecimalFormat df = new DecimalFormat(pattern);
+        return df.format(new BigDecimal(number));
+    }
+
+    /**
+     * 小数位精度格式
+     */
+    public static final double formatPrecisionValue(double number) {
+        return Double.valueOf(formatPrecisionString(number));
+    }
+
+    /**
+     * 小数位精度格式
+     */
+    public static final double formatPrecisionValue(float number) {
+        return Double.valueOf(formatPrecisionString(number));
+    }
+
+    /**
+     * 小数位精度格式
+     */
+    public static final String formatPrecisionString(float number) {
+        return formatPrecisionString(Double.valueOf(String.valueOf(number)));
+    }
+
+    /**
+     * 非数值型字符串, 空字符串兼容
+     */
+    public static final BigDecimal setBigDecimal(String strNum) {
+        BigDecimal decimal = null; // 空
+        if (StringUtils.isNotBlank(strNum)) {
+            try {
+                decimal = new BigDecimal(strNum);
+            } catch (NumberFormatException e) {
+                decimal = BigDecimal.ZERO;
+            }
+        }
+        return decimal;
+    }
+
+    /**
+     * 判断两个字符串, 转数值后是否相等
+     */
+    public static final int compareBigDecimal(String n1, String n2) {
+        return setBigDecimal(n1).compareTo(setBigDecimal(n2));
+    }
+
+    /**
+     * 判断两个字符串, 转数值后是否相等
+     */
+    public static final boolean equalBigDecimal(String n1, String n2) {
+        return compareBigDecimal(n1, n2) == 0;
+    }
+
+    /**
+     * 判断两个BigDecimal, 转数值后是否相等
+     */
+    public static final int compareBigDecimal(BigDecimal n1, BigDecimal n2) {
+        return n1.compareTo(n2);
+    }
+
+    /**
+     * 判断两个BigDecimal, 转数值后是否相等
+     */
+    public static final boolean equalBigDecimal(BigDecimal n1, BigDecimal n2) {
+        return compareBigDecimal(n1, n2) == 0;
+    }
+
+    /**
+     * 尾数: 0.5取整逻辑, 小于0.5为0.5, 大于0.5为1
+     */
+    public static double roundHalf(double num) {
+        double half = num % 1;
+        if (half != 0 && half != 0.5) {
+            if (half > 0.5f) {
+                num = Math.floor(num) + 1.0f;
+            } else {
+                num = Math.floor(num) + 0.5f;
+            }
+        }
+        return num;
+    }
+}

+ 81 - 0
mjava/src/main/java/com/malk/utils/UtilServlet.java

@@ -0,0 +1,81 @@
+package com.malk.utils;
+
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+
+import javax.servlet.http.HttpServletRequest;
+import java.util.*;
+
+@Slf4j
+public abstract class UtilServlet {
+
+    /**
+     * 获取 formData 参数转 map
+     *
+     * @return
+     */
+    public static Map<String, Object> getParamMap(HttpServletRequest req) {
+        Map map = new HashMap();
+        Map<String, String[]> paramMap = req.getParameterMap();
+        if (paramMap != null && !paramMap.isEmpty()) {
+            Set<String> keySet = paramMap.keySet();
+            Iterator var4 = keySet.iterator();
+            while (true) {
+                while (var4.hasNext()) {
+                    String key = (String) var4.next();
+                    String[] values = paramMap.get(key);
+                    if (values != null && values.length != 0) {
+                        if (values.length == 1) {
+                            map.put(key, values[0]);
+                        } else {
+                            map.put(key, values);
+                        }
+                    } else {
+                        map.put(key, "");
+                    }
+                }
+                return (Map) map;
+            }
+        } else {
+            return (Map) map;
+        }
+    }
+
+    /**
+     * 判断map取值是否为空
+     */
+    public static String isNull(Map param, String... keys) {
+        for (String key : keys) {
+            if (!param.containsKey(key) || param.get(key) == null || String.valueOf(param.get(key)).trim().equals("")
+                    || String.valueOf(param.get(key)).trim().equals("null")) {
+                return key;
+            }
+        }
+        return null;
+    }
+
+    /**
+     * 判断map取值是否为空
+     */
+    public static String isNull(Map param, String keys) {
+        return isNull(param, keys.split(", "));
+    }
+
+    public static boolean isNotNull(Map param, String... keys) {
+        return StringUtils.isBlank(isNull(param, keys));
+    }
+
+    /**
+     * 获取所有header
+     */
+    public static Map<String, String> getHeaders(HttpServletRequest request) {
+        Map<String, String> headerMap = new HashMap<>();
+        Enumeration<String> enumeration = request.getHeaderNames();
+        while (enumeration.hasMoreElements()) {
+            String name = enumeration.nextElement();
+            String value = request.getHeader(name);
+            headerMap.put(name, value);
+        }
+        return headerMap;
+    }
+}

+ 63 - 0
mjava/src/main/java/com/malk/utils/UtilString.java

@@ -0,0 +1,63 @@
+package com.malk.utils;
+
+import cn.hutool.core.util.ObjectUtil;
+import lombok.extern.slf4j.Slf4j;
+import org.apache.commons.lang3.StringUtils;
+
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+
+@Slf4j
+public abstract class UtilString {
+
+
+    // 避免str1为null触发空指针
+    public static boolean equal(Object str1, Object str2) {
+        return ObjectUtil.isNotNull(str1) && str1.equals(str2);
+    }
+
+    // 字符串拆分为集合按照英文逗号
+    public static List stringSplitToList(String str) {
+        String reg = ",";
+        if (str.contains(", ")) reg = ", ";
+        return Arrays.asList(str.split(reg));
+    }
+
+    // json序列化已经将空字符串过滤, 若转换还有null字符串, 可能是key为null或SerializerFeature未指定到类型, 如Date
+    public static boolean isNotBlankCompatNull(String str) {
+        return StringUtils.isNotBlank(str) && !str.equals("null");
+    }
+
+    public static boolean isBlankCompatNull(String str) {
+        return !isNotBlankCompatNull(str);
+    }
+
+    // 字符串判断为空返回空字符串而不是null
+    public static String stringFormatNull(Map<String, String> data, String key) {
+        String str = data.get(key);
+        if (StringUtils.isBlank(str)) return "";
+        if (isBlankCompatNull(str)) return "";
+        return str;
+    }
+
+    // 格式化保留两位小数
+    public static String stringFormatFixed(Number value) {
+        return String.format("%.2f", value);
+    }
+
+    // 中文圆括号转为英文圆括号 [参考: 常用Unicode字符对照表]
+    public static String replaceBracketIsSemiangle(String value) {
+        return value.replaceAll("\\u0028", "(").replaceAll("\\u0029", ")");
+    }
+
+    // 英文圆括号转为中文圆括号 [参考: 常用Unicode字符对照表]
+    public static String replaceBracketIsWhole(String value) {
+        return value.replaceAll("(", "(").replaceAll(")", ")");
+    }
+
+    // 格式刷日期, 去除年月日
+    public static String replaceDateZH_cn(String date) {
+        return date.replace("年", "-").replace("月", "-").replace("日", "");
+    }
+}

+ 28 - 0
mjava/src/main/java/com/malk/utils/UtilToken.java

@@ -0,0 +1,28 @@
+package com.malk.utils;
+
+import cn.hutool.cache.CacheUtil;
+import cn.hutool.cache.impl.TimedCache;
+
+/**
+ * token过期处理
+ */
+public abstract class UtilToken {
+
+    private static final TimedCache<String, String> TIMED_CACHE = CacheUtil.newTimedCache(0);
+
+    public static void put(String key, String value, Long timeout) {
+        if (timeout > 5000) timeout -= 5000; // 避免极端情况, 冗余5s容错处理
+        /** 设置消逝时间 */
+        TIMED_CACHE.put(key, value, timeout);
+    }
+
+    public static String get(String key) {
+        // 不刷新消逝时间
+        return TIMED_CACHE.get(key, false);
+    }
+
+    public static String getWithRefresh(String key) {
+        // 重新刷新消逝时间
+        return TIMED_CACHE.get(key);
+    }
+}

BIN
mjava/src/main/resources/assets/logo/logo-text.png


+ 7 - 0
mjava/src/main/resources/banner.txt

@@ -0,0 +1,7 @@
+
+               ,
+---_--_------------__---------__-
+  / /  )     /   /   ) | /  /   )
+_/_/__/_____/___(___(__|/__(___(_
+           /                     
+       (_ /

+ 294 - 0
pom.xml

@@ -0,0 +1,294 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<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>
+
+    <groupId>com.malk</groupId>
+    <artifactId>mjava-third</artifactId>
+    <version>1.0-SNAPSHOT</version>
+
+    <modules>
+        <module>mjava</module>
+
+        <module>mjava-ts</module>
+
+    </modules>
+    <packaging>pom</packaging>
+
+    <name>java-mcli</name>
+    <description>mjava framework</description>
+
+    <!-- 版本管理 Management -->
+    <properties>
+        <!-- mjava版本: 修改mjava pom配置 -->
+        <mjava.version>0.0.3</mjava.version>
+        <!-- 全局配置 -->
+        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
+        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
+        <java.version>1.8</java.version>
+        <!-- 公共依赖 -->
+        <spring-boot-dependencies.version>2.2.13.RELEASE</spring-boot-dependencies.version>
+        <junit.verson>4.12</junit.verson>
+        <lombok.version>1.18.8</lombok.version>
+        <validation-api.version>2.0.1.Final</validation-api.version>
+        <fastjson.version>1.2.83</fastjson.version>
+        <commons-lang3.version>3.10</commons-lang3.version>
+        <guava.version>30.1.1-jre</guava.version>
+        <hutool-all.version>5.6.0</hutool-all.version>
+        <spring-boot-starter-data-jpa.version>2.1.3.RELEASE</spring-boot-starter-data-jpa.version>
+        <querydsl-apt.version>4.2.1</querydsl-apt.version>
+        <querydsl-jpa.version>4.2.1</querydsl-jpa.version>
+        <spring-boot-starter-jdbc.version>2.2.13.RELEASE</spring-boot-starter-jdbc.version>
+        <easyexcel.version>2.2.7</easyexcel.version>
+        <java-jwt.version>3.4.0</java-jwt.version>
+        <!-- 数据库连接 [仅mysql为全局依赖] -->
+        <mysql-connector-java.version>8.0.22</mysql-connector-java.version>
+        <mssql-jdbc.version>6.4.0.jre8</mssql-jdbc.version>
+        <ojdbc6.version>11.2.0.4</ojdbc6.version>
+        <mongo-java-driver.version>3.12.7</mongo-java-driver.version>
+        <spring-boot-starter-data-mongodb.version>2.2.13.RELEASE</spring-boot-starter-data-mongodb.version>
+        <!-- jsp [非全局依赖] -->
+        <tomcat-embed-jasper.version>9.0.41</tomcat-embed-jasper.version>
+        <jstl.version>1.2</jstl.version>
+        <javax.servlet-api.version>4.0.1</javax.servlet-api.version>
+        <javax.servlet.jsp-api.version>2.3.1</javax.servlet.jsp-api.version>
+        <!-- jwt [非全局依赖] -->
+        <!-- swagger3: todo -->
+        <springfox-boot-starter.version>3.0.0</springfox-boot-starter.version>
+        <!-- 网页转pdf [非全局依赖] -->
+        <flying-saucer-pdf-itext5.version>9.0.3</flying-saucer-pdf-itext5.version>
+        <!-- 腾讯云[发票识别] [非全局依赖] -->
+        <tencentcloud-sdk-java.version>3.1.778</tencentcloud-sdk-java.version>
+        <!-- 不执行单元测试,也不编译测试类 -->
+        <skipTests>true</skipTests>
+        <!-- 不执行单元测试,但会编译测试类,并在target/test-classes目录下生成相应的class -->
+        <maven.test.skip>true</maven.test.skip>
+    </properties>
+
+    <!-- 依赖声明 & 版本 -->
+    <dependencyManagement>
+        <dependencies>
+            <!-- SpringBoot 依赖 -->
+            <dependency>
+                <groupId>org.springframework.boot</groupId>
+                <artifactId>spring-boot-dependencies</artifactId>
+                <version>${spring-boot-dependencies.version}</version>
+                <type>pom</type>
+                <scope>import</scope>
+            </dependency>
+            <!-- 单元测试 -->
+            <dependency>
+                <groupId>junit</groupId>
+                <artifactId>junit</artifactId>
+                <version>${junit.verson}</version>
+                <scope>test</scope>
+            </dependency>
+
+
+            <!-- lombok -->
+            <dependency>
+                <groupId>org.projectlombok</groupId>
+                <artifactId>lombok</artifactId>
+                <version>${lombok.version}</version>
+                <scope>provided</scope>
+            </dependency>
+
+            <!-- validation 参数校验 -->
+            <dependency>
+                <groupId>javax.validation</groupId>
+                <artifactId>validation-api</artifactId>
+                <version>${validation-api.version}</version>
+            </dependency>
+
+            <!-- 阿里巴巴 json -->
+            <dependency>
+                <groupId>com.alibaba</groupId>
+                <artifactId>fastjson</artifactId>
+                <version>${fastjson.version}</version>
+            </dependency>
+
+            <!-- 通用的工具类集 -->
+            <dependency>
+                <groupId>org.apache.commons</groupId>
+                <artifactId>commons-lang3</artifactId>
+                <version>${commons-lang3.version}</version>
+            </dependency>
+            <!-- ppExt: 23.10.26 钉钉新方式以Steam接入, HTTP形式commonsc-codec在升级之后,其内部做了一个validateCharacter校验. 使用 guava 替代-->
+            <dependency>
+                <groupId>com.google.guava</groupId>
+                <artifactId>guava</artifactId>
+                <version>${guava.version}</version>
+            </dependency>
+            <!-- 国产工具集 -->
+            <dependency>
+                <groupId>cn.hutool</groupId>
+                <artifactId>hutool-all</artifactId>
+                <version>${hutool-all.version}</version>
+            </dependency>
+
+            <!-- QueryDSL 4.x 支持-->
+            <dependency>
+                <groupId>com.querydsl</groupId>
+                <artifactId>querydsl-apt</artifactId>
+                <scope>provided</scope>
+                <version>${querydsl-apt.version}</version>
+            </dependency>
+            <dependency>
+                <groupId>com.querydsl</groupId>
+                <artifactId>querydsl-jpa</artifactId>
+                <version>${querydsl-jpa.version}</version>
+            </dependency>
+
+            <!-- easyExcel 优化 poi [不影响单独引入poi, 会冲突] -->
+            <dependency>
+                <groupId>com.alibaba</groupId>
+                <artifactId>easyexcel</artifactId>
+                <version>${easyexcel.version}</version>
+            </dependency>
+
+            <!-- jwt [teambition] -->
+            <dependency>
+                <groupId>com.auth0</groupId>
+                <artifactId>java-jwt</artifactId>
+                <version>${java-jwt.version}</version>
+            </dependency>
+
+            <!-- url转pdf -->
+            <dependency>
+                <groupId>org.xhtmlrenderer</groupId>
+                <artifactId>flying-saucer-pdf-itext5</artifactId>
+                <version>${flying-saucer-pdf-itext5.version}</version>
+            </dependency>
+
+            <!-- 腾讯云 -->
+            <dependency>
+                <groupId>com.tencentcloudapi</groupId>
+                <artifactId>tencentcloud-sdk-java</artifactId>
+                <version>${tencentcloud-sdk-java.version}</version>
+            </dependency>
+        </dependencies>
+    </dependencyManagement>
+
+    <!-- 子项目需要与 mjava 相同的依赖, 否则调试可运行, 打包后会运行报错. 为了避免重复引入 pom, 将 mjava 依赖直接在全局 pom 引入 -->
+    <dependencies>
+        <!-- spring boot -->
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-test</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-web</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-starter-tomcat</artifactId>
+        </dependency>
+        <dependency>
+            <groupId>org.springframework.boot</groupId>
+            <artifactId>spring-boot-configuration-processor</artifactId>
+            <optional>true</optional>
+        </dependency>
+        <!-- 单元测试 -->
+        <dependency>
+            <groupId>junit</groupId>
+            <artifactId>junit</artifactId>
+            <scope>test</scope>
+        </dependency>
+
+        <!-- lombok -->
+        <dependency>
+            <groupId>org.projectlombok</groupId>
+            <artifactId>lombok</artifactId>
+            <scope>provided</scope>
+        </dependency>
+
+        <!-- validation 参数校验 -->
+        <dependency>
+            <groupId>javax.validation</groupId>
+            <artifactId>validation-api</artifactId>
+        </dependency>
+
+        <!-- 阿里巴巴 json -->
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>fastjson</artifactId>
+        </dependency>
+
+        <!-- 通用的工具类集 -->
+        <dependency>
+            <groupId>org.apache.commons</groupId>
+            <artifactId>commons-lang3</artifactId>
+        </dependency>
+        <!-- 国产工具集 -->
+        <dependency>
+            <groupId>cn.hutool</groupId>
+            <artifactId>hutool-all</artifactId>
+        </dependency>
+
+        <!-- QueryDSL 4.x 支持-->
+        <dependency>
+            <groupId>com.querydsl</groupId>
+            <artifactId>querydsl-apt</artifactId>
+            <scope>provided</scope>
+        </dependency>
+        <dependency>
+            <groupId>com.querydsl</groupId>
+            <artifactId>querydsl-jpa</artifactId>
+        </dependency>
+
+        <!-- easyExcel 优化 poi [不影响单独引入poi, 会冲突] -->
+        <dependency>
+            <groupId>com.alibaba</groupId>
+            <artifactId>easyexcel</artifactId>
+        </dependency>
+
+        <!-- jwt [teambition] -->
+        <dependency>
+            <groupId>com.auth0</groupId>
+            <artifactId>java-jwt</artifactId>
+        </dependency>
+
+        <dependency>
+            <groupId>com.googlecode.json-simple</groupId>
+            <artifactId>json-simple</artifactId>
+            <version>1.1</version>
+        </dependency>
+    </dependencies>
+
+    <build>
+        <plugins>
+            <plugin>
+                <groupId>org.apache.maven.plugins</groupId>
+                <artifactId>maven-compiler-plugin</artifactId>
+                <version>3.8.1</version>
+                <configuration>
+                    <source>${java.version}</source>
+                    <target>${java.version}</target>
+                    <encoding>${project.build.sourceEncoding}</encoding>
+                    <!-- jva使用了未经检查或不安全的操作,编译会有打印,通过插件显示具体报错警告位置, 如 T, Map, List 也会报警告 -->
+                    <compilerArgument>-Xlint:unchecked</compilerArgument>
+                </configuration>
+            </plugin>
+            <!-- QueryDSL 插件: 因为QueryDsl是类型安全的,所以还需要加上Maven APT plugin,使用 APT 自动生成Q类 -->
+            <plugin>
+                <groupId>com.mysema.maven</groupId>
+                <artifactId>apt-maven-plugin</artifactId>
+                <version>1.1.3</version>
+                <executions>
+                    <execution>
+                        <goals>
+                            <goal>process</goal>
+                        </goals>
+                        <configuration>
+                            <outputDirectory>target/generated-sources/java</outputDirectory>
+                            <processor>com.querydsl.apt.jpa.JPAAnnotationProcessor</processor>
+                        </configuration>
+                    </execution>
+                </executions>
+            </plugin>
+        </plugins>
+    </build>
+</project>